From 9f69da6f3381c407a4fce100e81438b4e8d9d98c Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Mon, 4 May 2026 23:47:17 +0200 Subject: [PATCH 1/4] feat(layouts): add pinned `Diff2` variants Add `Diff2HorPinned` / `Diff2VerPinned`. They declare `shared_symbols = { 'b' }`, so `FileEntry:destroy` skips window `b` and the view-owned `vcs.File` survives entry swaps. Dead code until wired up. --- lua/diffview/config.lua | 13 +- lua/diffview/scene/layout.lua | 44 ++- .../scene/layouts/diff_2_hor_pinned.lua | 81 ++++++ .../scene/layouts/diff_2_ver_pinned.lua | 53 ++++ .../tests/functional/layouts_spec.lua | 263 ++++++++++++++++++ 5 files changed, 449 insertions(+), 5 deletions(-) create mode 100644 lua/diffview/scene/layouts/diff_2_hor_pinned.lua create mode 100644 lua/diffview/scene/layouts/diff_2_ver_pinned.lua diff --git a/lua/diffview/config.lua b/lua/diffview/config.lua index e3729239..fa7994bf 100644 --- a/lua/diffview/config.lua +++ b/lua/diffview/config.lua @@ -9,7 +9,9 @@ local Diff1 = lazy.access("diffview.scene.layouts.diff_1", "Diff1") ---@type Dif local Diff1Inline = lazy.access("diffview.scene.layouts.diff_1_inline", "Diff1Inline") ---@type Diff1Inline|LazyModule local Diff2 = lazy.access("diffview.scene.layouts.diff_2", "Diff2") ---@type Diff2|LazyModule local Diff2Hor = lazy.access("diffview.scene.layouts.diff_2_hor", "Diff2Hor") ---@type Diff2Hor|LazyModule +local Diff2HorPinned = lazy.access("diffview.scene.layouts.diff_2_hor_pinned", "Diff2HorPinned") ---@type Diff2HorPinned|LazyModule local Diff2Ver = lazy.access("diffview.scene.layouts.diff_2_ver", "Diff2Ver") ---@type Diff2Ver|LazyModule +local Diff2VerPinned = lazy.access("diffview.scene.layouts.diff_2_ver_pinned", "Diff2VerPinned") ---@type Diff2VerPinned|LazyModule local Diff3 = lazy.access("diffview.scene.layouts.diff_3", "Diff3") ---@type Diff3|LazyModule local Diff3Hor = lazy.access("diffview.scene.layouts.diff_3_hor", "Diff3Hor") ---@type Diff3Hor|LazyModule local Diff3Mixed = lazy.access("diffview.scene.layouts.diff_3_mixed", "Diff3Mixed") ---@type Diff3Mixed|LazyModule @@ -31,7 +33,12 @@ function M.diffview_callback(cb_name) return actions[cb_name] end --- Layout aliases used across multiple view kinds and cycle_layouts. +-- Layout aliases used across multiple view kinds and cycle_layouts. The +-- pinned `Diff2` variants (`diff2_*_pinned`) intentionally aren't listed +-- here: they're internal to file-history `pin_local` mode and are +-- selected by the view based on whether `--pin-local` is active, not by +-- direct user configuration. The `LayoutName` alias below still includes +-- them so `name_to_layout` can resolve them internally. ---@alias DiffviewStandardLayout "diff1_plain"|"diff1_inline"|"diff2_horizontal"|"diff2_vertical" ---@alias DiffviewMergeLayout "diff1_plain"|"diff3_horizontal"|"diff3_vertical"|"diff3_mixed"|"diff4_mixed" ---@alias DiffviewInferredLayout -1 @@ -865,7 +872,9 @@ end ---@alias LayoutName "diff1_plain" --- | "diff1_inline" --- | "diff2_horizontal" +--- | "diff2_horizontal_pinned" --- | "diff2_vertical" +--- | "diff2_vertical_pinned" --- | "diff3_horizontal" --- | "diff3_vertical" --- | "diff3_mixed" @@ -875,7 +884,9 @@ local layout_map = { diff1_plain = Diff1, diff1_inline = Diff1Inline, diff2_horizontal = Diff2Hor, + diff2_horizontal_pinned = Diff2HorPinned, diff2_vertical = Diff2Ver, + diff2_vertical_pinned = Diff2VerPinned, diff3_horizontal = Diff3Hor, diff3_vertical = Diff3Ver, diff3_mixed = Diff3Mixed, diff --git a/lua/diffview/scene/layout.lua b/lua/diffview/scene/layout.lua index c3276ab8..bcdd2f7e 100644 --- a/lua/diffview/scene/layout.lua +++ b/lua/diffview/scene/layout.lua @@ -222,12 +222,37 @@ function Layout:files() end) end ----Return every file the layout is responsible for, including any files that ----aren't attached to a window (e.g. `Diff1Inline.a_file`). Used by the file ----entry teardown path so non-window files don't get orphaned. +---Symbols whose attached `vcs.File` is borrowed (owned elsewhere) and +---therefore must not be destroyed by `FileEntry:destroy`. Pinned variants +---set this to `{ "b" }` so the view-owned working-tree File survives entry +---teardown. Default: own everything. +---@type string[] +Layout.shared_symbols = {} + +---Return the files this layout owns and is responsible for tearing down +---in `FileEntry:destroy`. Files whose symbol is listed in `shared_symbols` +---are excluded: those are owned by someone outside the entry (e.g. the +---view's pin_local cache). +--- +---The default implementation walks the window-attached files for symbols +---in `self.symbols`. Subclasses with files outside the window set (e.g. +---`Diff1Inline.a_file`) MUST override `owned_files` to include them, or +---the entry teardown path will orphan those files. ---@return vcs.File[] function Layout:owned_files() - return self:files() + local skip = {} + for _, sym in ipairs(self.shared_symbols) do + skip[sym] = true + end + + local out = {} + for _, sym in ipairs(self.symbols) do + local win = self[sym] + if not skip[sym] and win and win.file then + out[#out + 1] = win.file + end + end + return out end ---Look up the file the layout assigns to `sym`. Default behaviour reads @@ -374,6 +399,17 @@ function Layout:detach_files() end end +---Variant of `detach_files` invoked by views during entry-to-entry swaps, +---where some layouts (e.g. pinned variants) want to keep specific windows +---bound across the swap. Defaults to a full `detach_files()`; pinned +---layouts override to skip the pinned window only when the next entry's +---file for that window is the same instance, so multi-file pinning still +---detaches the old b-side when the row crosses to a different path. +---@param next_entry? FileEntry +function Layout:detach_files_for_swap(next_entry) ---@diagnostic disable-line: unused-local + self:detach_files() +end + ---Sync the scrollbind. function Layout:sync_scroll() local curwin = api.nvim_get_current_win() diff --git a/lua/diffview/scene/layouts/diff_2_hor_pinned.lua b/lua/diffview/scene/layouts/diff_2_hor_pinned.lua new file mode 100644 index 00000000..b7e88c11 --- /dev/null +++ b/lua/diffview/scene/layouts/diff_2_hor_pinned.lua @@ -0,0 +1,81 @@ +local Diff2 = require("diffview.scene.layouts.diff_2").Diff2 +local Diff2Hor = require("diffview.scene.layouts.diff_2_hor").Diff2Hor +local RevType = require("diffview.vcs.rev").RevType +local oop = require("diffview.oop") + +local M = {} + +---@class Diff2HorPinned : Diff2Hor +---A horizontal Diff2 whose b-window pins to a working-tree LOCAL buffer +---across log navigation. Every pinned-mode `FileEntry` constructed by the +---adapter receives the same `vcs.File` instance for the b-side (resolved +---through `vcs.adapter.LayoutOpt.pinned_b_file_for` against the view's +---per-path cache), so the b-window's underlying buffer, edits, undo +---history, and cursor position survive every entry swap and refresh. The +---view is the sole owner of those `vcs.File` instances; entry teardown +---skips them via `shared_symbols`, and the view destroys them in `close()`. +local Diff2HorPinned = oop.create_class("Diff2HorPinned", Diff2Hor) + +Diff2HorPinned.name = "diff2_horizontal_pinned" + +-- The b-side `vcs.File` is owned by the FileHistoryView (its pin_local +-- cache), not by individual FileEntries. Listing "b" here keeps +-- `Layout:owned_files()` from returning it, so `FileEntry:destroy` and +-- `FileEntry:set_active` walk past the borrowed file. The view tears it +-- down in `close()` once. +Diff2HorPinned.shared_symbols = { "b" } + +---@param opt Diff2.init.Opt +function Diff2HorPinned:init(opt) + self:super(opt) +end + +-- `Diff2.should_null` interprets `revs.a` as the *parent* of the commit, so +-- a fresh-add ("A") nulls the a-side. In pin_local mode `revs.a` IS the +-- commit, which means "A" implies the file exists on the a-side too. We +-- only null the a-side when the file is absent from the commit ("D"). +-- The b-side is never constructed via `with_layout` in this mode (the view +-- supplies a pre-built `pinned_b_file`), so `try_should_null` never reaches +-- this code with `sym == "b"`; we leave the LOCAL/STAGE branches to the +-- parent. +---@override +---@param rev Rev +---@param status string +---@param sym Diff2.WindowSymbol +function Diff2HorPinned.should_null(rev, status, sym) + if sym == "a" and rev.type == RevType.COMMIT then + return status == "D" + end + + return Diff2.should_null(rev, status, sym) +end + +-- Within the FH view, skip detaching window b across entry swaps when the +-- next entry's b is the same `vcs.File` instance, so the pinned LOCAL +-- buffer's diffview keymaps and edits survive. In multi-file pinning a +-- row change can swap b to a different working-tree File (each path has +-- its own view-owned File); detach the old one in that case so its buffer +-- doesn't keep stale diffview state attached. The inherited +-- `detach_files()` still runs on tab-leave / view-close and detaches +-- everything (including b), so we don't leak diffview state into the +-- user's normal editing windows. +---@override +---@param next_entry? FileEntry +function Diff2HorPinned:detach_files_for_swap(next_entry) + if self.a then + self.a:detach_file() + end + if self.b and next_entry then + -- Without a next entry we have no comparison; preserve the old + -- "skip detach" behaviour for callers that haven't migrated to the + -- new signature. + local next_layout = next_entry.layout --[[@as Diff2HorPinned ]] + local next_b = next_layout and next_layout.b and next_layout.b.file + if next_b ~= self.b.file then + self.b:detach_file() + end + end +end + +M.Diff2HorPinned = Diff2HorPinned +return M diff --git a/lua/diffview/scene/layouts/diff_2_ver_pinned.lua b/lua/diffview/scene/layouts/diff_2_ver_pinned.lua new file mode 100644 index 00000000..f524868d --- /dev/null +++ b/lua/diffview/scene/layouts/diff_2_ver_pinned.lua @@ -0,0 +1,53 @@ +local Diff2 = require("diffview.scene.layouts.diff_2").Diff2 +local Diff2Ver = require("diffview.scene.layouts.diff_2_ver").Diff2Ver +local RevType = require("diffview.vcs.rev").RevType +local oop = require("diffview.oop") + +local M = {} + +---@class Diff2VerPinned : Diff2Ver +---Vertical sibling of `Diff2HorPinned`. See that class for the pinning +---semantics; the only difference here is window orientation. +local Diff2VerPinned = oop.create_class("Diff2VerPinned", Diff2Ver) + +Diff2VerPinned.name = "diff2_vertical_pinned" + +Diff2VerPinned.shared_symbols = { "b" } + +---@param opt Diff2.init.Opt +function Diff2VerPinned:init(opt) + self:super(opt) +end + +---@override +---@param rev Rev +---@param status string +---@param sym Diff2.WindowSymbol +function Diff2VerPinned.should_null(rev, status, sym) + if sym == "a" and rev.type == RevType.COMMIT then + return status == "D" + end + + return Diff2.should_null(rev, status, sym) +end + +-- See `Diff2HorPinned:detach_files_for_swap` for the rationale. +---@override +---@param next_entry? FileEntry +function Diff2VerPinned:detach_files_for_swap(next_entry) + if self.a then + self.a:detach_file() + end + if self.b and next_entry then + -- See `Diff2HorPinned:detach_files_for_swap` for the no-next-entry + -- carve-out. + local next_layout = next_entry.layout --[[@as Diff2VerPinned ]] + local next_b = next_layout and next_layout.b and next_layout.b.file + if next_b ~= self.b.file then + self.b:detach_file() + end + end +end + +M.Diff2VerPinned = Diff2VerPinned +return M diff --git a/lua/diffview/tests/functional/layouts_spec.lua b/lua/diffview/tests/functional/layouts_spec.lua index 6c0639ea..af75fc57 100644 --- a/lua/diffview/tests/functional/layouts_spec.lua +++ b/lua/diffview/tests/functional/layouts_spec.lua @@ -584,6 +584,269 @@ describe("diffview.layout symbols", function() end) end) +describe("diffview.scene.layouts.diff_2_*_pinned class structure", function() + local Diff2HorPinned = require("diffview.scene.layouts.diff_2_hor_pinned").Diff2HorPinned + local Diff2VerPinned = require("diffview.scene.layouts.diff_2_ver_pinned").Diff2VerPinned + + it("Diff2HorPinned inherits Diff2Hor and keeps symbols { 'a', 'b' }", function() + eq({ "a", "b" }, Diff2HorPinned.symbols) + eq(Diff2Hor, Diff2HorPinned.super_class) + eq("diff2_horizontal_pinned", Diff2HorPinned.name) + end) + + it("Diff2VerPinned inherits Diff2Ver and keeps symbols { 'a', 'b' }", function() + eq({ "a", "b" }, Diff2VerPinned.symbols) + eq(Diff2Ver, Diff2VerPinned.super_class) + eq("diff2_vertical_pinned", Diff2VerPinned.name) + end) + + it("config.name_to_layout resolves the pinned layout names", function() + local config = require("diffview.config") + eq(Diff2HorPinned, config.name_to_layout("diff2_horizontal_pinned")) + eq(Diff2VerPinned, config.name_to_layout("diff2_vertical_pinned")) + end) +end) + +describe("diffview.scene.layouts.diff_2_*_pinned should_null", function() + local Diff2HorPinned = require("diffview.scene.layouts.diff_2_hor_pinned").Diff2HorPinned + local Diff2VerPinned = require("diffview.scene.layouts.diff_2_ver_pinned").Diff2VerPinned + + -- pin_local sets revs.a to the commit itself (not its parent), so the + -- standard parent-vs-commit semantics don't apply on the a-side: the + -- file is missing iff it doesn't exist in this commit, i.e. status "D". + -- (sym "b" isn't covered: pinned-mode b-side files are injected by the + -- view's cache and never go through `try_should_null`.) + it("nulls window a only when the file is absent from the commit (status D)", function() + local commit = { type = RevType.COMMIT } + for _, cls in ipairs({ Diff2HorPinned, Diff2VerPinned }) do + assert.True(cls.should_null(commit, "D", "a")) + assert.False(cls.should_null(commit, "A", "a")) + assert.False(cls.should_null(commit, "M", "a")) + assert.False(cls.should_null(commit, "R", "a")) + assert.False(cls.should_null(commit, "?", "a")) + end + end) +end) + +describe("diffview.scene.layouts.diff_2_*_pinned ownership", function() + local Diff2HorPinned = require("diffview.scene.layouts.diff_2_hor_pinned").Diff2HorPinned + local Diff2VerPinned = require("diffview.scene.layouts.diff_2_ver_pinned").Diff2VerPinned + + -- The b-side `vcs.File` is owned by the FileHistoryView (its pin_local + -- cache), not by individual FileEntries. `shared_symbols` is the contract + -- that tells `Layout:owned_files()` to exclude that window from the + -- destruction set in `FileEntry:destroy` and `FileEntry:set_active`. + it("declares 'b' as a shared symbol so entry teardown skips it", function() + eq({ "b" }, Diff2HorPinned.shared_symbols) + eq({ "b" }, Diff2VerPinned.shared_symbols) + end) + + -- Concrete check of the filter: a fully-constructed pinned layout's + -- `owned_files()` must return only the a-side file, even though both + -- windows are populated. This is what protects the view-owned b-file + -- from being destroyed when the LogEntry tree is torn down on refresh. + it("owned_files() excludes the b-side file", function() + for _, cls in ipairs({ Diff2HorPinned, Diff2VerPinned }) do + local a_file = { path = "old/foo.txt" } + local b_file = { path = "foo.txt" } + local inst = setmetatable({ + a = { file = a_file }, + b = { file = b_file }, + windows = {}, + symbols = cls.symbols, + shared_symbols = cls.shared_symbols, + }, { __index = cls }) + + eq({ a_file }, inst:owned_files()) + end + end) +end) + +describe("diffview.scene.layouts.diff_2_*_pinned detach_files_for_swap", function() + local Diff2HorPinned = require("diffview.scene.layouts.diff_2_hor_pinned").Diff2HorPinned + local Diff2VerPinned = require("diffview.scene.layouts.diff_2_ver_pinned").Diff2VerPinned + + -- The swap variant is what `_set_file` calls between log entries; it + -- skips the pinned b so the LOCAL buffer's keymaps/edits survive the + -- swap when the b-file stays the same instance (single-file pinning, + -- and same-row navigation in multi-file). The full `detach_files()` is + -- left to the base Layout so tab-leave/view-close still tear everything + -- down (no diffview state leaks into the user's normal editing windows). + it("detaches window a but not window b when next entry's b matches", function() + for _, cls in ipairs({ Diff2HorPinned, Diff2VerPinned }) do + local detached = {} + local shared_b_file = { path = "live.txt" } + local inst = setmetatable({ + a = { + detach_file = function() + detached.a = true + end, + }, + b = { + file = shared_b_file, + detach_file = function() + detached.b = true + end, + }, + }, { __index = cls }) + + -- Stub the next FileEntry so its layout's b-side points at the same + -- shared instance: that's the single-file pin case (and the + -- same-path row-stay case in multi-file). + local next_entry = { layout = { b = { file = shared_b_file } } } + inst:detach_files_for_swap(next_entry) + + assert.True(detached.a) + assert.is_nil(detached.b) + end + end) + + -- Multi-file pinning: each path has its own view-owned working-tree + -- File. Crossing rows changes the b-side instance, and the OLD b's + -- buffer must be detached so it doesn't keep diffview keymaps and + -- buffer-local overrides after navigation. Without this, plugins + -- attached via diffview's per-buffer setup would persist on the user's + -- previous working-tree buffer indefinitely. + it("detaches window b when the next entry's b is a different File instance", function() + for _, cls in ipairs({ Diff2HorPinned, Diff2VerPinned }) do + local detached = {} + local cur_b_file = { path = "alpha.txt" } + local next_b_file = { path = "beta.txt" } + local inst = setmetatable({ + a = { + detach_file = function() + detached.a = true + end, + }, + b = { + file = cur_b_file, + detach_file = function() + detached.b = true + end, + }, + }, { __index = cls }) + + local next_entry = { layout = { b = { file = next_b_file } } } + inst:detach_files_for_swap(next_entry) + + assert.True(detached.a) + assert.True(detached.b) + end + end) + + -- Defensive: when the caller doesn't pass a next entry (e.g. legacy + -- callers, or any non-FH view that hasn't migrated), we don't know the + -- upcoming b-file. Treat that as "nothing to compare against" and skip + -- detaching b, mirroring the pre-fix behaviour for those code paths. + it("skips detaching b when no next_entry is passed", function() + for _, cls in ipairs({ Diff2HorPinned, Diff2VerPinned }) do + local detached = {} + local inst = setmetatable({ + a = { + detach_file = function() + detached.a = true + end, + }, + b = { + file = { path = "live.txt" }, + detach_file = function() + detached.b = true + end, + }, + }, { __index = cls }) + + inst:detach_files_for_swap() + + assert.True(detached.a) + assert.is_nil(detached.b) + end + end) + + -- Sanity-check that the pinned layouts still inherit the base + -- `detach_files()` (i.e. they no longer override it). On tab-leave / + -- view-close, both windows must be detached. + it("inherits detach_files() that detaches every window", function() + for _, cls in ipairs({ Diff2HorPinned, Diff2VerPinned }) do + local detached = {} + local win_a = { + detach_file = function() + detached.a = true + end, + } + local win_b = { + detach_file = function() + detached.b = true + end, + } + local inst = setmetatable({ + a = win_a, + b = win_b, + windows = { win_a, win_b }, + }, { __index = cls }) + + inst:detach_files() + + assert.True(detached.a) + assert.True(detached.b) + end + end) +end) + +describe("diffview.scene.layouts.diff_2_*_pinned use_entry inheritance", function() + local Diff2HorPinned = require("diffview.scene.layouts.diff_2_hor_pinned").Diff2HorPinned + local Diff2VerPinned = require("diffview.scene.layouts.diff_2_ver_pinned").Diff2VerPinned + + ---Build a mock layout-like object that satisfies the `instanceof` check + ---inside `Layout.use_entry` without needing a fully constructed Diff2. + local function mock_layout(cls, a_file, b_file) + return setmetatable({ + class = cls, + a = { file = a_file }, + b = { file = b_file }, + }, { __index = cls }) + end + + ---Build a mock pinned-layout instance whose `set_file_for` records the + ---swap. `is_valid` is forced to false so `use_entry` returns before the + ---`await(self:open_files())` branch. + local function mock_pinned(cls, set_files, b_file) + return setmetatable({ + class = cls, + a = { file = nil }, + b = { file = b_file }, + symbols = cls.symbols, + set_file_for = function(_, sym, file) + set_files[sym] = file + end, + is_valid = function() + return false + end, + }, { __index = cls }) + end + + -- The pinned variants no longer override `use_entry`: with the view-owned + -- shared b-file, every entry's `layout.b.file` already IS the same + -- instance the cur_layout is holding, so the inherited + -- `Layout.use_entry` is a no-op assignment for b. Confirm the inherited + -- behaviour writes both symbols (the b-write is harmless because it's + -- the same File). + it("inherits Layout.use_entry which writes both symbols", function() + for _, cls in ipairs({ Diff2HorPinned, Diff2VerPinned }) do + local set_files = {} + local shared_b_file = { path = "foo/bar.txt" } + local new_a_file = { path = "old/foo/bar.txt" } + + local entry = { layout = mock_layout(cls, new_a_file, shared_b_file) } + local inst = mock_pinned(cls, set_files, shared_b_file) + + inst:use_entry(entry) + + assert.equals(new_a_file, set_files.a) + assert.equals(shared_b_file, set_files.b) + end + end) +end) + describe("diffview.scene.layouts.diff_1_inline diffopt forwarding", function() local Diff1Inline_mod = require("diffview.scene.layouts.diff_1_inline") local effective_diffopt = Diff1Inline_mod._test.effective_diffopt From 3d95c700389bc473a8875ab315c9ddd9dbfb20cb Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Mon, 4 May 2026 23:54:13 +0200 Subject: [PATCH 2/4] feat(vcs): thread `pin_local` through file-history adapters Add `pin_local`, `pinned_path`, `pinned_b_file_for` to `LayoutOpt`. Git/hg `parse_fh_data` (and git's line-trace variant) honour them by setting `revs.b = LOCAL` and resolving the b-side through the view's cache. `FileEntry.with_layout` reuses an explicit `pinned_b_file`. --- lua/diffview/scene/file_entry.lua | 17 +- .../tests/functional/file_entry_spec.lua | 36 ++++ .../tests/functional/git_adapter_spec.lua | 204 ++++++++++++++++++ .../tests/functional/hg_adapter_spec.lua | 111 ++++++++++ lua/diffview/vcs/adapter.lua | 3 + lua/diffview/vcs/adapters/git/init.lua | 69 +++++- lua/diffview/vcs/adapters/hg/init.lua | 31 ++- 7 files changed, 461 insertions(+), 10 deletions(-) diff --git a/lua/diffview/scene/file_entry.lua b/lua/diffview/scene/file_entry.lua index 778bf882..bb855d22 100644 --- a/lua/diffview/scene/file_entry.lua +++ b/lua/diffview/scene/file_entry.lua @@ -266,15 +266,30 @@ end ---@class FileEntry.with_layout.Opt : FileEntry.init.Opt ---@field nulled? boolean ---@field get_data? git.FileDataProducer +---@field pinned_path? string # Deprecated: when `pinned_b_file` is supplied the layout takes its b-side from that shared File and `pinned_path` is ignored. Retained as a fallback for adapters that haven't been wired to the view's pin_local cache yet. +---@field pinned_b_file? vcs.File # The view-owned, shared working-tree `vcs.File` for `pin_local` mode. When set, the layout's b-side reuses this exact instance instead of constructing a fresh one, so identity is preserved across every entry the view ever shows. The instance outlives entry teardown via the layout's `shared_symbols`, and is destroyed by `FileHistoryView:close()`. ---@param layout_class Layout (class) ---@param opt FileEntry.with_layout.Opt ---@return FileEntry function FileEntry.with_layout(layout_class, opt) local function create_file(rev, symbol) + if symbol == "b" and opt.pinned_b_file then + return opt.pinned_b_file + end + + local path + if symbol == "a" then + path = opt.oldpath or opt.path + elseif symbol == "b" and opt.pinned_path then + path = opt.pinned_path + else + path = opt.path + end + return File({ adapter = opt.adapter, - path = symbol == "a" and opt.oldpath or opt.path, + path = path, kind = opt.kind, commit = opt.commit, get_data = opt.get_data, diff --git a/lua/diffview/tests/functional/file_entry_spec.lua b/lua/diffview/tests/functional/file_entry_spec.lua index fd440065..8e15f08a 100644 --- a/lua/diffview/tests/functional/file_entry_spec.lua +++ b/lua/diffview/tests/functional/file_entry_spec.lua @@ -176,6 +176,42 @@ describe("diffview.scene.file_entry", function() end) end) + -- Identity contract: when an adapter passes a pre-built `pinned_b_file`, + -- `with_layout` reuses that exact `vcs.File` instance for the b-side + -- instead of constructing a new one from `opt.path`/`opt.pinned_path`. + -- This is what lets every pinned-mode FileEntry across the view share + -- the same working-tree File (see `Diff2*Pinned.shared_symbols`). + it("with_layout reuses the supplied pinned_b_file for the b-side", function() + local Diff2HorPinned = require("diffview.scene.layouts.diff_2_hor_pinned").Diff2HorPinned + + local shared = { path = "foo.txt" } --[[@as vcs.File ]] + local fake_adapter = { ctx = { toplevel = "/" } } + -- Real Rev would require adapter wiring; mock just the method `vcs.File` + -- pulls during winbar construction (`object_name`). + local rev_a = { + type = RevType.COMMIT, + commit = "abc1234567", + object_name = function(_, n) + return ("abc1234567"):sub(1, n or 10) + end, + } + local rev_b = { type = RevType.LOCAL } + + local entry = FileEntry.with_layout(Diff2HorPinned, { + adapter = fake_adapter, + path = "old/foo.txt", + oldpath = nil, + status = "M", + kind = "working", + revs = { a = rev_a, b = rev_b }, + pinned_b_file = shared, + }) + + assert.equals(shared, entry.layout.b.file) + assert.is_not_nil(entry.layout.a.file) + assert.are_not.equal(shared, entry.layout.a.file) + end) + it("forwards force flag to contained files when destroyed", function() local seen = {} local layout_destroyed = false diff --git a/lua/diffview/tests/functional/git_adapter_spec.lua b/lua/diffview/tests/functional/git_adapter_spec.lua index f1931ad3..6b03ac0c 100644 --- a/lua/diffview/tests/functional/git_adapter_spec.lua +++ b/lua/diffview/tests/functional/git_adapter_spec.lua @@ -536,4 +536,208 @@ describe("diffview.vcs.adapters.git", function() assert.True(found_custom, "Custom env var must also be present") end) end) + + describe("parse_fh_data pin_local", function() + -- Build a (state, data, commit) triple that exercises a single-file + -- modification commit. The namestat/numstat strings mirror what + -- `git log --raw --numstat` emits for `:100644 100644 M\tfoo.txt`. + local function setup_state_and_data(adapter, layout_opt) + local state = { + path_args = { "foo.txt" }, + log_options = { L = {} }, + prepared_log_opts = { base = nil }, + layout_opt = layout_opt, + single_file = true, + } + + local data = { + left_hash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + right_hash = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + namestat = { ":100644 100644 aaaaaaa bbbbbbb M\tfoo.txt" }, + numstat = { "1\t1\tfoo.txt" }, + } + + -- A bare table is enough; parse_fh_data only forwards `commit` into + -- the LogEntry it produces, never reads any field. + local commit = {} + + return state, data, commit + end + + it( + "uses commit-side rev for b when pin_local is unset", + test_utils.async_test(function() + local repo, adapter = make_repo_and_adapter() + + local ok, err = pcall(function() + local state, data, commit = setup_state_and_data(adapter, { + default_layout = Diff2, + }) + + local success, log_entry = adapter:parse_fh_data(data, commit, state) + assert.True(success) + ---@cast log_entry LogEntry + + local b_rev = log_entry.files[1].layout.b.file.rev + assert.equals(RevType.COMMIT, b_rev.type) + assert.equals(data.right_hash, b_rev.commit) + end) + + vim.schedule(function() + pcall(vim.fn.delete, repo, "rf") + end) + async.await(async.scheduler()) + + if not ok then + error(err) + end + end) + ) + + it( + "sets revs.b to LOCAL when state.layout_opt.pin_local is true", + test_utils.async_test(function() + local repo, adapter = make_repo_and_adapter() + + local ok, err = pcall(function() + local state, data, commit = setup_state_and_data(adapter, { + default_layout = Diff2, + pin_local = true, + }) + + local success, log_entry = adapter:parse_fh_data(data, commit, state) + assert.True(success) + ---@cast log_entry LogEntry + + local b_file = log_entry.files[1].layout.b.file + assert.equals(RevType.LOCAL, b_file.rev.type) + -- Without `pinned_path` the b-side falls back to the entry path, + -- which is still the working-tree file in the no-rename case. + assert.equals("foo.txt", b_file.path) + + -- pin_local diffs each commit against the working tree, so the + -- a-side reads from this commit (not its parent). + local a_rev = log_entry.files[1].layout.a.file.rev + assert.equals(RevType.COMMIT, a_rev.type) + assert.equals(data.right_hash, a_rev.commit) + end) + + vim.schedule(function() + pcall(vim.fn.delete, repo, "rf") + end) + async.await(async.scheduler()) + + if not ok then + error(err) + end + end) + ) + + it( + "reuses the layout_opt.pinned_b_file_for File for the b-side", + test_utils.async_test(function() + local repo, adapter = make_repo_and_adapter() + + local ok, err = pcall(function() + -- Stand-in for the view's pin_local cache: hand out a single + -- distinguishable `vcs.File`-like instance regardless of path so + -- we can assert it's the b-side that the FileEntry ended up with. + -- Identity equality is what `Diff2*Pinned.shared_symbols` and + -- the view's destruction path rely on. + local shared = { path = "shared.txt", rev = adapter.Rev(RevType.LOCAL) } + local lookups = {} + local state, data, commit = setup_state_and_data(adapter, { + default_layout = Diff2, + pin_local = true, + pinned_path = "renamed/foo.txt", + pinned_b_file_for = function(path) + table.insert(lookups, path) + return shared + end, + }) + + local success, log_entry = adapter:parse_fh_data(data, commit, state) + assert.True(success) + ---@cast log_entry LogEntry + + assert.equals(shared, log_entry.files[1].layout.b.file) + -- Adapter resolves the path through pinned_path (when set) before + -- asking the view for the File, so the view's cache stays keyed + -- by working-tree paths. + assert.same({ "renamed/foo.txt" }, lookups) + end) + + vim.schedule(function() + pcall(vim.fn.delete, repo, "rf") + end) + async.await(async.scheduler()) + + if not ok then + error(err) + end + end) + ) + + -- In multi-file pin_local (`state.single_file = false`), `pinned_path` + -- tracks the cursor's last file row -- not a per-entry rename anchor -- + -- so it must NOT route every entry's b-side to that one path. Each file + -- must resolve its own working-tree File, otherwise switching rows in + -- multi-file history would diff a different file's commit-side contents + -- against the previously cursored working-tree file. + it( + "ignores layout_opt.pinned_path in multi-file mode (uses each entry's name)", + test_utils.async_test(function() + local repo, adapter = make_repo_and_adapter() + + local ok, err = pcall(function() + local lookups = {} + local state = { + path_args = { "alpha.txt", "beta.txt" }, + log_options = { L = {} }, + prepared_log_opts = { base = nil }, + layout_opt = { + default_layout = Diff2, + pin_local = true, + pinned_path = "alpha.txt", + pinned_b_file_for = function(path) + table.insert(lookups, path) + return { path = path, rev = adapter.Rev(RevType.LOCAL) } + end, + }, + single_file = false, + } + local data = { + left_hash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + right_hash = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + namestat = { + ":100644 100644 aaaaaaa bbbbbbb M\talpha.txt", + ":100644 100644 ccccccc ddddddd M\tbeta.txt", + }, + numstat = { "1\t1\talpha.txt", "2\t2\tbeta.txt" }, + } + + local success, log_entry = adapter:parse_fh_data(data, {}, state) + assert.True(success) + ---@cast log_entry LogEntry + + assert.equals(2, #log_entry.files) + -- Lookups happen in entry order; the universal pinned_path would + -- have produced { "alpha.txt", "alpha.txt" } -- both routed to + -- alpha's working-tree file. The fix uses each entry's name. + assert.same({ "alpha.txt", "beta.txt" }, lookups) + assert.equals("alpha.txt", log_entry.files[1].layout.b.file.path) + assert.equals("beta.txt", log_entry.files[2].layout.b.file.path) + end) + + vim.schedule(function() + pcall(vim.fn.delete, repo, "rf") + end) + async.await(async.scheduler()) + + if not ok then + error(err) + end + end) + ) + end) end) diff --git a/lua/diffview/tests/functional/hg_adapter_spec.lua b/lua/diffview/tests/functional/hg_adapter_spec.lua index bc41d7db..24f70768 100644 --- a/lua/diffview/tests/functional/hg_adapter_spec.lua +++ b/lua/diffview/tests/functional/hg_adapter_spec.lua @@ -228,4 +228,115 @@ describe("diffview.vcs.adapters.hg", function() end) ) end) + + describe("parse_fh_data pin_local", function() + -- Construct an HgAdapter without invoking `hg`. parse_fh_data only + -- shells out via state.layout_opt.default_layout, which we control, + -- so a tempdir toplevel and stubbed bootstrap state are sufficient. + local function make_adapter() + local repo = vim.fn.tempname() + vim.fn.mkdir(repo, "p") + + HgAdapter.bootstrap.done = true + HgAdapter.bootstrap.ok = true + + return HgAdapter({ toplevel = repo, path_args = {} }), repo + end + + -- Mercurial's parse_fh_data iterates `#numstat - 1` times, so the + -- numstat array carries a sentinel trailing entry. Status character + -- comes from the first character of the matching `namestat[i]`. + local function setup_state_and_data(layout_opt) + local state = { + path_args = { "foo.txt" }, + log_options = {}, + prepared_log_opts = { base = nil }, + layout_opt = layout_opt, + single_file = true, + } + + local data = { + left_hash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + right_hash = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + namestat = { "M foo.txt" }, + numstat = { "foo.txt | 2 +-", "" }, + } + + local commit = {} + + return state, data, commit + end + + it("uses commit-side rev for b when pin_local is unset", function() + local adapter, repo = make_adapter() + + local state, data, commit = setup_state_and_data({ + default_layout = Diff2, + }) + + local success, log_entry = adapter:parse_fh_data(data, commit, state) + assert.True(success) + ---@cast log_entry LogEntry + + local b_rev = log_entry.files[1].layout.b.file.rev + assert.equals(RevType.COMMIT, b_rev.type) + assert.equals(data.right_hash, b_rev.commit) + + pcall(vim.fn.delete, repo, "rf") + end) + + it("sets revs.b to LOCAL when state.layout_opt.pin_local is true", function() + local adapter, repo = make_adapter() + + local state, data, commit = setup_state_and_data({ + default_layout = Diff2, + pin_local = true, + }) + + local success, log_entry = adapter:parse_fh_data(data, commit, state) + assert.True(success) + ---@cast log_entry LogEntry + + local b_file = log_entry.files[1].layout.b.file + assert.equals(RevType.LOCAL, b_file.rev.type) + assert.equals("foo.txt", b_file.path) + + -- pin_local diffs each changeset against the working tree, so the + -- a-side reads from this changeset (not its parent). + local a_rev = log_entry.files[1].layout.a.file.rev + assert.equals(RevType.COMMIT, a_rev.type) + assert.equals(data.right_hash, a_rev.commit) + + pcall(vim.fn.delete, repo, "rf") + end) + + it("reuses the layout_opt.pinned_b_file_for File for the b-side", function() + local adapter, repo = make_adapter() + + -- See `git_adapter_spec`'s mirror test for the rationale: the adapter + -- looks up the b-side `vcs.File` through the view's cache (resolved + -- via `pinned_path` when set), so identity is preserved across every + -- entry the view will ever build. + local shared = { path = "shared.txt", rev = adapter.Rev(RevType.LOCAL) } + local lookups = {} + local state, data, commit = setup_state_and_data({ + default_layout = Diff2, + pin_local = true, + pinned_path = "renamed/foo.txt", + pinned_b_file_for = function(path) + table.insert(lookups, path) + return shared + end, + }) + + local success, log_entry = adapter:parse_fh_data(data, commit, state) + assert.True(success) + ---@cast log_entry LogEntry + + assert.equals(shared, log_entry.files[1].layout.b.file) + assert.same({ "renamed/foo.txt" }, lookups) + + pcall(vim.fn.delete, repo, "rf") + end) + end) end) diff --git a/lua/diffview/vcs/adapter.lua b/lua/diffview/vcs/adapter.lua index 5db83481..6e4fab2d 100644 --- a/lua/diffview/vcs/adapter.lua +++ b/lua/diffview/vcs/adapter.lua @@ -20,6 +20,9 @@ local M = {} ---@class vcs.adapter.LayoutOpt ---@field default_layout Layout ---@field merge_layout? Layout +---@field pin_local? boolean # When true, file-history entries are constructed with revs.b = LOCAL so the b-window can pin to the working-tree file. +---@field pinned_path? string # Working-tree path used for the b-side File when `pin_local` is true for a single-file history; preserves the pin across renames in older commits. +---@field pinned_b_file_for? fun(path: string): vcs.File # Resolves the shared, view-owned working-tree File for a given path. Set by `FileHistoryPanel` when `pin_local` is active so adapters can hand the same `vcs.File` instance to every entry's b-side; see `FileHistoryView:get_pinned_b_file`. The returned file outlives entry/log destruction (its layout symbol lives in `Diff2*Pinned.shared_symbols`), so adapters must not destroy it. ---@class vcs.adapter.VCSAdapter.Bootstrap ---@field done boolean # Did the bootstrapping diff --git a/lua/diffview/vcs/adapters/git/init.lua b/lua/diffview/vcs/adapters/git/init.lua index 35d89556..fe5e6285 100644 --- a/lua/diffview/vcs/adapters/git/init.lua +++ b/lua/diffview/vcs/adapters/git/init.lua @@ -1082,6 +1082,7 @@ end) ---@return LogEntry|string ret function GitAdapter:parse_fh_data(data, commit, state) local files = {} + local pin_local = state.layout_opt.pin_local == true for i = 1, #data.numstat do local entry = git_parser.parse_namestat_entry(data.namestat[i], data.numstat[i]) @@ -1090,20 +1091,52 @@ function GitAdapter:parse_fh_data(data, commit, state) state.old_path = entry.oldname end + local rev_a, rev_b + if pin_local then + -- pin_local diffs each commit against the working tree, so the a-side + -- reads from this commit (not its parent) and the b-side from LOCAL. + -- This matches the synthetic top-of-history "Working tree" entry + -- (HEAD vs LOCAL) and the documented "diff each commit against your + -- live file" behaviour. + rev_a = GitRev(RevType.COMMIT, data.right_hash) + rev_b = self.Rev(RevType.LOCAL) + else + rev_a = data.left_hash and GitRev(RevType.COMMIT, data.left_hash) or GitRev.new_null_tree() + rev_b = state.prepared_log_opts.base or GitRev(RevType.COMMIT, data.right_hash) + end + + -- pin_local: every entry's b-window references the view-owned + -- `vcs.File` for the resolved working-tree path. The cache keys by + -- path, so a single-file history shares one File per path across all + -- entries and refreshes; the view destroys them in `close()`. Only + -- single-file mode honours `pinned_path` (the rename anchor that keeps + -- the b-side bound to the user's working-tree name even when older + -- commits used a renamed name); in multi-file mode `pinned_path` + -- tracks the cursor's last file row and would otherwise route every + -- entry's b-side to that one file. + local pinned_b_file + if pin_local and state.layout_opt.pinned_b_file_for then + local b_path = (state.single_file and state.layout_opt.pinned_path) or entry.name + pinned_b_file = state.layout_opt.pinned_b_file_for(b_path) + end + table.insert( files, FileEntry.with_layout(state.layout_opt.default_layout or Diff2Hor, { adapter = self, path = entry.name, - oldpath = entry.oldname, + -- In pin_local mode revs.a is the commit itself, so the parent's + -- old name doesn't apply; entry.name lives in this commit's tree. + oldpath = (not pin_local) and entry.oldname or nil, status = entry.status, stats = entry.stats, kind = "working", commit = commit, revs = { - a = data.left_hash and GitRev(RevType.COMMIT, data.left_hash) or GitRev.new_null_tree(), - b = state.prepared_log_opts.base or GitRev(RevType.COMMIT, data.right_hash), + a = rev_a, + b = rev_b, }, + pinned_b_file = pinned_b_file, }) ) end @@ -1140,6 +1173,7 @@ end ---@return LogEntry|string ret function GitAdapter:parse_fh_line_trace_data(data, commit, state) local files = {} + local pin_local = state.layout_opt.pin_local == true for _, entry in ipairs(data.diff) do local oldpath = entry.path_old ~= entry.path_new and entry.path_old or nil @@ -1148,18 +1182,41 @@ function GitAdapter:parse_fh_line_trace_data(data, commit, state) state.old_path = oldpath end + local rev_a, rev_b + if pin_local then + -- See `parse_fh_data` for the rationale: pin_local diffs each commit + -- against the working tree, so a-side reads from this commit. + rev_a = GitRev(RevType.COMMIT, data.right_hash) + rev_b = self.Rev(RevType.LOCAL) + else + rev_a = data.left_hash and GitRev(RevType.COMMIT, data.left_hash) or GitRev.new_null_tree() + rev_b = state.prepared_log_opts.base or GitRev(RevType.COMMIT, data.right_hash) + end + + -- See `parse_fh_data` for the pinned_b_file rationale. `entry.path_new` + -- is typed `string?` upstream; the line-trace pipeline only emits + -- entries with a real path, but the type system needs the explicit + -- guard to know we never pass nil into `pinned_b_file_for`. Line trace + -- is single-file by construction, so `pinned_path` always applies. + local pinned_b_file + local b_path = state.layout_opt.pinned_path or entry.path_new + if pin_local and state.layout_opt.pinned_b_file_for and b_path then + pinned_b_file = state.layout_opt.pinned_b_file_for(b_path) + end + table.insert( files, FileEntry.with_layout(state.layout_opt.default_layout or Diff2Hor, { adapter = self, path = entry.path_new, - oldpath = oldpath, + oldpath = (not pin_local) and oldpath or nil, kind = "working", commit = commit, revs = { - a = data.left_hash and GitRev(RevType.COMMIT, data.left_hash) or GitRev.new_null_tree(), - b = state.prepared_log_opts.base or GitRev(RevType.COMMIT, data.right_hash), + a = rev_a, + b = rev_b, }, + pinned_b_file = pinned_b_file, }) ) end diff --git a/lua/diffview/vcs/adapters/hg/init.lua b/lua/diffview/vcs/adapters/hg/init.lua index ca2c0a11..0be1832a 100644 --- a/lua/diffview/vcs/adapters/hg/init.lua +++ b/lua/diffview/vcs/adapters/hg/init.lua @@ -691,6 +691,7 @@ end) ---@return LogEntry|string ret function HgAdapter:parse_fh_data(data, commit, state) local files = {} + local pin_local = state.layout_opt.pin_local == true for i = 1, #data.numstat - 1 do local status = data.namestat[i]:sub(1, 1):gsub("%s", " ") @@ -714,20 +715,44 @@ function HgAdapter:parse_fh_data(data, commit, state) status = "R" end + local rev_a, rev_b + if pin_local then + -- pin_local diffs each changeset against the working tree, so the + -- a-side reads from this changeset (not its parent) and the b-side + -- from LOCAL. This matches the synthetic top-of-history "Working + -- tree" entry (HEAD vs LOCAL) and the documented behaviour. + rev_a = HgRev(RevType.COMMIT, data.right_hash) + rev_b = self.Rev(RevType.LOCAL) + else + rev_a = data.left_hash and HgRev(RevType.COMMIT, data.left_hash) or HgRev.new_null_tree() + rev_b = state.prepared_log_opts.base or HgRev(RevType.COMMIT, data.right_hash) + end + + -- See `GitAdapter:parse_fh_data` for the pinned_b_file rationale, + -- including why `pinned_path` only applies in single-file mode. + local pinned_b_file + if pin_local and state.layout_opt.pinned_b_file_for then + local b_path = (state.single_file and state.layout_opt.pinned_path) or name + pinned_b_file = state.layout_opt.pinned_b_file_for(b_path) + end + table.insert( files, FileEntry.with_layout(state.layout_opt.default_layout or Diff2Hor, { adapter = self, path = name, - oldpath = oldname, + -- In pin_local mode revs.a is the changeset itself, so the parent's + -- old name doesn't apply; `name` lives in this changeset's tree. + oldpath = (not pin_local) and oldname or nil, status = status, stats = stats, kind = "working", commit = commit, revs = { - a = data.left_hash and HgRev(RevType.COMMIT, data.left_hash) or HgRev.new_null_tree(), - b = state.prepared_log_opts.base or HgRev(RevType.COMMIT, data.right_hash), + a = rev_a, + b = rev_b, }, + pinned_b_file = pinned_b_file, }) ) end From e0ba6df50b06ef2d2715fa367663d7800be52401 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Mon, 4 May 2026 23:55:38 +0200 Subject: [PATCH 3/4] feat(vcs): create synthetic `LOCAL` log entry as top-of-history Add `VCSAdapter:build_local_log_entry`; git/hg override to produce a `LogEntry` with `revs.a = HEAD`, `revs.b = LOCAL` for every locally modified path. Returns nil on a clean tree or when `HEAD` can't be resolved. b-sides are pulled through the view's `pin_local` cache when set. --- .../scene/layouts/diff_2_hor_pinned.lua | 8 +- .../scene/layouts/diff_2_ver_pinned.lua | 4 +- .../tests/functional/git_adapter_spec.lua | 521 ++++++++++++++++++ .../tests/functional/hg_adapter_spec.lua | 232 ++++++++ .../tests/functional/layouts_spec.lua | 18 + lua/diffview/vcs/adapter.lua | 76 +++ lua/diffview/vcs/adapters/git/init.lua | 245 ++++++-- lua/diffview/vcs/adapters/hg/init.lua | 162 +++++- lua/diffview/vcs/rev.lua | 1 + 9 files changed, 1204 insertions(+), 63 deletions(-) diff --git a/lua/diffview/scene/layouts/diff_2_hor_pinned.lua b/lua/diffview/scene/layouts/diff_2_hor_pinned.lua index b7e88c11..25e07042 100644 --- a/lua/diffview/scene/layouts/diff_2_hor_pinned.lua +++ b/lua/diffview/scene/layouts/diff_2_hor_pinned.lua @@ -38,12 +38,18 @@ end -- supplies a pre-built `pinned_b_file`), so `try_should_null` never reaches -- this code with `sym == "b"`; we leave the LOCAL/STAGE branches to the -- parent. +-- +-- The synthetic top-of-history "Working tree" entry is the exception: its +-- `revs.a` is HEAD (parent-of-working-tree), and its statuses come from +-- `diff HEAD`, so the standard parent-vs-child semantics apply. The adapter +-- tags that rev with `pin_local_synthetic`, which routes us back to +-- `Diff2.should_null` for the a-side as well. ---@override ---@param rev Rev ---@param status string ---@param sym Diff2.WindowSymbol function Diff2HorPinned.should_null(rev, status, sym) - if sym == "a" and rev.type == RevType.COMMIT then + if sym == "a" and rev.type == RevType.COMMIT and not rev.pin_local_synthetic then return status == "D" end diff --git a/lua/diffview/scene/layouts/diff_2_ver_pinned.lua b/lua/diffview/scene/layouts/diff_2_ver_pinned.lua index f524868d..8a3a8578 100644 --- a/lua/diffview/scene/layouts/diff_2_ver_pinned.lua +++ b/lua/diffview/scene/layouts/diff_2_ver_pinned.lua @@ -19,12 +19,14 @@ function Diff2VerPinned:init(opt) self:super(opt) end +-- See `Diff2HorPinned.should_null` for the rationale, including why the +-- synthetic top-of-history entry's a-side is routed back to `Diff2.should_null`. ---@override ---@param rev Rev ---@param status string ---@param sym Diff2.WindowSymbol function Diff2VerPinned.should_null(rev, status, sym) - if sym == "a" and rev.type == RevType.COMMIT then + if sym == "a" and rev.type == RevType.COMMIT and not rev.pin_local_synthetic then return status == "D" end diff --git a/lua/diffview/tests/functional/git_adapter_spec.lua b/lua/diffview/tests/functional/git_adapter_spec.lua index 6b03ac0c..c6c9bf76 100644 --- a/lua/diffview/tests/functional/git_adapter_spec.lua +++ b/lua/diffview/tests/functional/git_adapter_spec.lua @@ -740,4 +740,525 @@ describe("diffview.vcs.adapters.git", function() end) ) end) + + describe("build_local_log_entry", function() + it( + "returns nil on a clean working tree", + test_utils.async_test(function() + local repo, adapter = make_repo_and_adapter() + + local ok, err = pcall(function() + local log_entry = adapter:build_local_log_entry({ + path_args = {}, + layout_opt = { default_layout = Diff2 }, + single_file = false, + }) + + assert.is_nil(log_entry) + end) + + vim.schedule(function() + pcall(vim.fn.delete, repo, "rf") + end) + async.await(async.scheduler()) + + if not ok then + error(err) + end + end) + ) + + it( + "builds a synthetic LogEntry with revs.b = LOCAL when the tree is dirty", + test_utils.async_test(function() + local repo, adapter = make_repo_and_adapter() + + local ok, err = pcall(function() + -- Modify the init file so `git diff --name-status HEAD` reports it. + local f = assert(io.open(repo .. "/init.txt", "w")) + f:write("changed\n") + f:close() + + local log_entry = adapter:build_local_log_entry({ + path_args = {}, + layout_opt = { default_layout = Diff2 }, + single_file = false, + }) + + assert.is_not_nil(log_entry) + ---@cast log_entry LogEntry + assert.is_nil(log_entry.commit.hash) + assert.equals("Working tree", log_entry.commit.subject) + assert.equals("now", log_entry.commit.rel_date) + -- The synthetic commit must populate `iso_date` (and the + -- `time_offset` fallback that feeds it). `render.lua` concatenates + -- `iso_date` into the panel header when `date_format = "iso"` (and + -- in the `auto` branch for older commits), so a nil here would + -- abort the file-history panel render. The synth is constructed + -- via the adapter's `Commit` alias (`GitCommit`/`HgCommit`), whose + -- `init` derives `iso_date` from `time` regardless of whether + -- `time_offset` was provided. + assert.is_string(log_entry.commit.iso_date) + assert.equals(0, log_entry.commit.time_offset) + assert.equals(1, #log_entry.files) + + local file = log_entry.files[1] + assert.equals("init.txt", file.path) + assert.equals("M", file.status) + assert.equals(RevType.LOCAL, file.revs.b.type) + assert.equals(RevType.COMMIT, file.revs.a.type) + -- Without this flag, the pinned `Diff2` layout's `should_null` + -- would invert the standard semantics for revs.a (treating it as + -- the commit being browsed) and mishandle added/deleted files in + -- the synthetic entry. See `Diff2HorPinned.should_null`. + assert.is_true(file.revs.a.pin_local_synthetic) + end) + + vim.schedule(function() + pcall(vim.fn.delete, repo, "rf") + end) + async.await(async.scheduler()) + + if not ok then + error(err) + end + end) + ) + + -- The synth's b-side cache key must match `parse_fh_data`'s key in + -- single-file mode (`layout_opt.pinned_path`), otherwise an absolute + -- or non-canonical pathspec produces different `vcs.File` instances + -- for the synth and the streamed entries, breaking pinned-buffer reuse. + it( + "keys b-side by layout_opt.pinned_path in single-file mode", + test_utils.async_test(function() + local repo, adapter = make_repo_and_adapter() + + local ok, err = pcall(function() + local f = assert(io.open(repo .. "/init.txt", "w")) + f:write("changed\n") + f:close() + + local lookups = {} + local shared = { path = "shared.txt", rev = adapter.Rev(RevType.LOCAL) } + local log_entry = adapter:build_local_log_entry({ + path_args = { "init.txt" }, + -- pinned_path is the user's working-tree spelling; entry.name + -- here is git's emitted relative name. Use a deliberately + -- different value so the test catches the mismatch. + layout_opt = { + default_layout = Diff2, + pin_local = true, + pinned_path = repo .. "/init.txt", + pinned_b_file_for = function(path) + table.insert(lookups, path) + return shared + end, + }, + single_file = true, + }) + + assert.is_not_nil(log_entry) + ---@cast log_entry LogEntry + -- The cache key matches what `parse_fh_data` would use for real + -- entries in single-file mode; both feed the same `vcs.File`. + assert.same({ repo .. "/init.txt" }, lookups) + end) + + vim.schedule(function() + pcall(vim.fn.delete, repo, "rf") + end) + async.await(async.scheduler()) + + if not ok then + error(err) + end + end) + ) + + it( + "respects path_args, omitting unmodified paths", + test_utils.async_test(function() + local repo, adapter = make_repo_and_adapter() + + local ok, err = pcall(function() + -- Add a second tracked file, then modify only `init.txt`. + local g = assert(io.open(repo .. "/other.txt", "w")) + g:write("other\n") + g:close() + run({ "git", "add", "other.txt" }, repo) + run({ "git", "-c", "commit.gpgsign=false", "commit", "-q", "-m", "add other" }, repo) + + local f = assert(io.open(repo .. "/init.txt", "w")) + f:write("changed\n") + f:close() + + -- Pass only `other.txt` as path_args; `init.txt` should be ignored. + local log_entry = adapter:build_local_log_entry({ + path_args = { "other.txt" }, + layout_opt = { default_layout = Diff2 }, + single_file = true, + }) + + assert.is_nil(log_entry, "expected nil for clean path filter") + end) + + vim.schedule(function() + pcall(vim.fn.delete, repo, "rf") + end) + async.await(async.scheduler()) + + if not ok then + error(err) + end + end) + ) + + it( + "builds entries for multiple modified paths", + test_utils.async_test(function() + local repo, adapter = make_repo_and_adapter() + + local ok, err = pcall(function() + local g = assert(io.open(repo .. "/other.txt", "w")) + g:write("other\n") + g:close() + run({ "git", "add", "other.txt" }, repo) + run({ "git", "-c", "commit.gpgsign=false", "commit", "-q", "-m", "add other" }, repo) + + for _, name in ipairs({ "init.txt", "other.txt" }) do + local f = assert(io.open(repo .. "/" .. name, "w")) + f:write("changed\n") + f:close() + end + + local log_entry = adapter:build_local_log_entry({ + path_args = { "init.txt", "other.txt" }, + layout_opt = { default_layout = Diff2 }, + single_file = false, + }) + + assert.is_not_nil(log_entry) + ---@cast log_entry LogEntry + assert.equals(2, #log_entry.files) + assert.is_false(log_entry.single_file) + + local paths = {} + for _, file in ipairs(log_entry.files) do + paths[file.path] = true + assert.equals(RevType.LOCAL, file.revs.b.type) + end + assert.is_true(paths["init.txt"]) + assert.is_true(paths["other.txt"]) + end) + + vim.schedule(function() + pcall(vim.fn.delete, repo, "rf") + end) + async.await(async.scheduler()) + + if not ok then + error(err) + end + end) + ) + + -- A single directory pathspec produces a multi-file history (the + -- streaming adapter uses `is_single_file` to decide); the synthetic + -- entry must follow the same rule, otherwise `panel.single_file` + -- gets set to `true` from the synth and the rest of the panel + -- (folding, navigation, header rendering) is rendered in the wrong + -- mode. + it( + "marks single_file=false for a single-directory pathspec", + test_utils.async_test(function() + local repo, adapter = make_repo_and_adapter() + + local ok, err = pcall(function() + assert.equals(1, vim.fn.mkdir(repo .. "/sub", "p")) + local g = assert(io.open(repo .. "/sub/a.txt", "w")) + g:write("a\n") + g:close() + local h = assert(io.open(repo .. "/sub/b.txt", "w")) + h:write("b\n") + h:close() + run({ "git", "add", "sub" }, repo) + run({ "git", "-c", "commit.gpgsign=false", "commit", "-q", "-m", "add sub" }, repo) + + for _, name in ipairs({ "sub/a.txt", "sub/b.txt" }) do + local f = assert(io.open(repo .. "/" .. name, "w")) + f:write("changed\n") + f:close() + end + + -- Use the absolute path so the directory check inside + -- `build_local_log_entry` doesn't depend on the test runner's + -- cwd (real callers usually invoke from the repo root, where a + -- relative pathspec would also resolve). + local log_entry = adapter:build_local_log_entry({ + path_args = { repo .. "/sub" }, + layout_opt = { default_layout = Diff2 }, + single_file = false, + }) + + assert.is_not_nil(log_entry) + ---@cast log_entry LogEntry + -- `#path_args == 1` would have wrongly returned `true` here + -- because the pathspec is a single argument; the directory + -- check downgrades to multi-file. + assert.is_false(log_entry.single_file) + assert.equals(2, #log_entry.files) + end) + + vim.schedule(function() + pcall(vim.fn.delete, repo, "rf") + end) + async.await(async.scheduler()) + + if not ok then + error(err) + end + end) + ) + end) + + -- `history_scope` is the single source of truth for "is this history + -- single-file, and if so, which path?". Three call sites consult it + -- (pin_local's `pinned_path` seed, the synthetic entry's `single_file` + -- field, and the synth's `git diff` path filter) so each scope question + -- now has one answer instead of three near-duplicates. + describe("history_scope", function() + it( + "recognises a single file pathspec", + test_utils.async_test(function() + local repo, adapter = make_repo_and_adapter() + local ok, err = pcall(function() + local scope = adapter:history_scope({ "init.txt" }, {}) + assert.equals(true, scope.single_file) + assert.equals("init.txt", scope.path) + end) + vim.schedule(function() + pcall(vim.fn.delete, repo, "rf") + end) + async.await(async.scheduler()) + if not ok then + error(err) + end + end) + ) + + it( + "downgrades a single-directory pathspec to multi-file", + test_utils.async_test(function() + local repo, adapter = make_repo_and_adapter() + local ok, err = pcall(function() + assert.equals(1, vim.fn.mkdir(repo .. "/sub", "p")) + local scope = adapter:history_scope({ repo .. "/sub" }, {}) + assert.equals(false, scope.single_file) + assert.is_nil(scope.path) + end) + vim.schedule(function() + pcall(vim.fn.delete, repo, "rf") + end) + async.await(async.scheduler()) + if not ok then + error(err) + end + end) + ) + + -- Line-trace history: the path lives in the L spec, not in + -- `path_args` (which is empty in `-L` mode). Without this branch, + -- `pin_local`'s rename anchor would fall back to each commit's + -- `entry.path_new` and stop following the working-tree path. + it( + "extracts the path from a single -L spec", + test_utils.async_test(function() + local repo, adapter = make_repo_and_adapter() + local ok, err = pcall(function() + local scope = adapter:history_scope( + {}, + { L = { "10,20:src/foo.lua" } } --[[@as GitLogOptions ]] + ) + assert.equals(true, scope.single_file) + assert.equals("src/foo.lua", scope.path) + end) + vim.schedule(function() + pcall(vim.fn.delete, repo, "rf") + end) + async.await(async.scheduler()) + if not ok then + error(err) + end + end) + ) + + it( + "downgrades multiple -L specs targeting different paths", + test_utils.async_test(function() + local repo, adapter = make_repo_and_adapter() + local ok, err = pcall(function() + local scope = adapter:history_scope({}, { + L = { ":sym:src/a.lua", ":sym:src/b.lua" }, + } --[[@as GitLogOptions ]]) + assert.equals(false, scope.single_file) + end) + vim.schedule(function() + pcall(vim.fn.delete, repo, "rf") + end) + async.await(async.scheduler()) + if not ok then + error(err) + end + end) + ) + + -- A malformed L spec (no `:`, or empty path after the last `:`) used + -- to fall through and return `{ single_file = true, path = nil }`, + -- which then seeded `pinned_path` with nil and broke downstream + -- cache-key resolution. The scope must downgrade to multi-file so + -- pin_local doesn't try to anchor on a missing path. + it( + "downgrades a malformed -L spec to multi-file", + test_utils.async_test(function() + local repo, adapter = make_repo_and_adapter() + local ok, err = pcall(function() + local no_colon = adapter:history_scope({}, { L = { "garbage" } } --[[@as GitLogOptions ]]) + assert.equals(false, no_colon.single_file) + assert.is_nil(no_colon.path) + + local empty_path = adapter:history_scope( + {}, + { L = { "10,20:" } } --[[@as GitLogOptions ]] + ) + assert.equals(false, empty_path.single_file) + assert.is_nil(empty_path.path) + end) + vim.schedule(function() + pcall(vim.fn.delete, repo, "rf") + end) + async.await(async.scheduler()) + if not ok then + error(err) + end + end) + ) + + -- Pathspec resolution: a single argument like `*.txt` or + -- `:(glob)**/*.txt` isn't a literal path, so using it raw as + -- `pinned_path` would key the pin_local cache by the pattern and + -- the RHS would try to open a LOCAL file named after the pattern. + -- `history_scope` resolves through `git ls-files` to git's emitted + -- relative name when exactly one tracked file matches. + it( + "resolves a glob pathspec to the matched file when exactly one matches", + test_utils.async_test(function() + local repo, adapter = make_repo_and_adapter() + local ok, err = pcall(function() + local scope = adapter:history_scope({ "*.txt" }, {}) + -- `init.txt` is the only tracked file in `make_repo_and_adapter`. + assert.equals(true, scope.single_file) + assert.equals("init.txt", scope.path) + end) + vim.schedule(function() + pcall(vim.fn.delete, repo, "rf") + end) + async.await(async.scheduler()) + if not ok then + error(err) + end + end) + ) + + it( + "downgrades a glob pathspec that matches multiple files", + test_utils.async_test(function() + local repo, adapter = make_repo_and_adapter() + local ok, err = pcall(function() + -- Add a second tracked file so `*.txt` matches both. + local f = assert(io.open(repo .. "/other.txt", "w")) + f:write("other\n") + f:close() + run({ "git", "add", "other.txt" }, repo) + run({ "git", "-c", "commit.gpgsign=false", "commit", "-q", "-m", "add other" }, repo) + + local scope = adapter:history_scope({ "*.txt" }, {}) + assert.equals(false, scope.single_file) + assert.is_nil(scope.path) + end) + vim.schedule(function() + pcall(vim.fn.delete, repo, "rf") + end) + async.await(async.scheduler()) + if not ok then + error(err) + end + end) + ) + + -- A single-pathspec history whose path isn't tracked today (deleted / + -- renamed away / never added) is still single-file: `is_single_file()` + -- returns true for `<2` matches, so `history_scope` must agree or + -- pin_local stops seeding `pinned_path` for valid single-path + -- histories of removed files. + it( + "treats a single pathspec with zero matches as single-file with the literal path", + test_utils.async_test(function() + local repo, adapter = make_repo_and_adapter() + local ok, err = pcall(function() + local scope = adapter:history_scope({ "deleted.txt" }, {}) + assert.equals(true, scope.single_file) + assert.equals("deleted.txt", scope.path) + end) + vim.schedule(function() + pcall(vim.fn.delete, repo, "rf") + end) + async.await(async.scheduler()) + if not ok then + error(err) + end + end) + ) + + -- An absolute path canonicalises through `ls-files` to git's + -- emitted relative spelling. Both spellings then key the pin_local + -- cache the same way, so the synth and streamed entries share one + -- view-owned `vcs.File`. + it( + "canonicalises absolute paths to git's relative emission", + test_utils.async_test(function() + local repo, adapter = make_repo_and_adapter() + local ok, err = pcall(function() + local scope = adapter:history_scope({ repo .. "/init.txt" }, {}) + assert.equals(true, scope.single_file) + assert.equals("init.txt", scope.path) + end) + vim.schedule(function() + pcall(vim.fn.delete, repo, "rf") + end) + async.await(async.scheduler()) + if not ok then + error(err) + end + end) + ) + + it( + "returns multi-file for an empty path_args (no -L)", + test_utils.async_test(function() + local repo, adapter = make_repo_and_adapter() + local ok, err = pcall(function() + local scope = adapter:history_scope({}, {}) + assert.equals(false, scope.single_file) + end) + vim.schedule(function() + pcall(vim.fn.delete, repo, "rf") + end) + async.await(async.scheduler()) + if not ok then + error(err) + end + end) + ) + end) end) diff --git a/lua/diffview/tests/functional/hg_adapter_spec.lua b/lua/diffview/tests/functional/hg_adapter_spec.lua index 24f70768..ce132e7f 100644 --- a/lua/diffview/tests/functional/hg_adapter_spec.lua +++ b/lua/diffview/tests/functional/hg_adapter_spec.lua @@ -339,4 +339,236 @@ describe("diffview.vcs.adapters.hg", function() pcall(vim.fn.delete, repo, "rf") end) end) + + -- See `git_adapter_spec`'s `history_scope` block for the rationale: the + -- scope is the single source of truth for "single-file?" + "which path?", + -- and must agree with `is_single_file()`'s `<2` semantics so `pin_local` + -- seeds `pinned_path` even for histories of removed/missing files. + describe("history_scope", function() + local repo + + before_each(function() + if not hg_available() then + pending("hg not installed") + return + end + repo = create_hg_repo() + end) + + after_each(function() + if repo then + repo.cleanup() + end + end) + + it("recognises a single tracked file pathspec", function() + if not hg_available() then + pending("hg not installed") + return + end + + repo.write("init.txt", "v1\n") + repo.hg({ "add", "init.txt" }) + repo.hg({ "commit", "-m", "init", "-u", "test " }) + + local adapter = repo.adapter() + local scope = adapter:history_scope({ "init.txt" }, {}) + assert.equals(true, scope.single_file) + assert.equals("init.txt", scope.path) + end) + + it("treats a single pathspec with zero matches as single-file with the literal path", function() + if not hg_available() then + pending("hg not installed") + return + end + + repo.write("init.txt", "v1\n") + repo.hg({ "add", "init.txt" }) + repo.hg({ "commit", "-m", "init", "-u", "test " }) + + local adapter = repo.adapter() + local scope = adapter:history_scope({ "deleted.txt" }, {}) + assert.equals(true, scope.single_file) + assert.equals("deleted.txt", scope.path) + end) + + it("downgrades a single-directory pathspec to multi-file", function() + if not hg_available() then + pending("hg not installed") + return + end + + vim.fn.mkdir(repo.dir .. "/sub", "p") + local adapter = repo.adapter() + local scope = adapter:history_scope({ repo.dir .. "/sub" }, {}) + assert.equals(false, scope.single_file) + assert.is_nil(scope.path) + end) + + it("returns multi-file for an empty path_args", function() + if not hg_available() then + pending("hg not installed") + return + end + + local adapter = repo.adapter() + local scope = adapter:history_scope({}, {}) + assert.equals(false, scope.single_file) + end) + end) + + describe("build_local_log_entry", function() + local repo + + before_each(function() + if not hg_available() then + pending("hg not installed") + return + end + repo = create_hg_repo() + end) + + after_each(function() + if repo then + repo.cleanup() + end + end) + + it("returns nil on a clean working tree", function() + if not hg_available() then + pending("hg not installed") + return + end + + repo.write("init.txt", "init\n") + repo.hg({ "add", "init.txt" }) + repo.hg({ "commit", "-m", "init", "-u", "test " }) + + local adapter = repo.adapter() + local log_entry = adapter:build_local_log_entry({ + path_args = {}, + layout_opt = { default_layout = Diff2 }, + single_file = false, + }) + + assert.is_nil(log_entry) + end) + + it("builds a synthetic LogEntry with revs.b = LOCAL when the tree is dirty", function() + if not hg_available() then + pending("hg not installed") + return + end + + repo.write("init.txt", "init\n") + repo.hg({ "add", "init.txt" }) + repo.hg({ "commit", "-m", "init", "-u", "test " }) + + repo.write("init.txt", "changed\n") + + local adapter = repo.adapter() + local log_entry = adapter:build_local_log_entry({ + path_args = {}, + layout_opt = { default_layout = Diff2 }, + single_file = false, + }) + + assert.is_not_nil(log_entry) + ---@cast log_entry LogEntry + assert.is_nil(log_entry.commit.hash) + assert.equals("Working tree", log_entry.commit.subject) + -- The synthetic commit must populate `iso_date` (and the + -- `time_offset` fallback that feeds it). `render.lua` concatenates + -- `iso_date` into the panel header when `date_format = "iso"` (and in + -- the `auto` branch for older commits), so a nil here would abort the + -- file-history panel render. The synth is constructed via the + -- adapter's `Commit` alias (`HgCommit`), whose `init` derives + -- `iso_date` from `time` regardless of whether `time_offset` was + -- provided. + assert.is_string(log_entry.commit.iso_date) + assert.equals(0, log_entry.commit.time_offset) + assert.equals(1, #log_entry.files) + + local file = log_entry.files[1] + assert.equals("init.txt", file.path) + assert.equals("M", file.status) + assert.equals(RevType.LOCAL, file.revs.b.type) + assert.equals(RevType.COMMIT, file.revs.a.type) + end) + + it("builds entries for multiple modified paths", function() + if not hg_available() then + pending("hg not installed") + return + end + + repo.write("foo.txt", "foo\n") + repo.write("bar.txt", "bar\n") + repo.hg({ "add", "foo.txt", "bar.txt" }) + repo.hg({ "commit", "-m", "init", "-u", "test " }) + + repo.write("foo.txt", "foo changed\n") + repo.write("bar.txt", "bar changed\n") + + local adapter = repo.adapter() + local log_entry = adapter:build_local_log_entry({ + path_args = { "foo.txt", "bar.txt" }, + layout_opt = { default_layout = Diff2 }, + single_file = false, + }) + + assert.is_not_nil(log_entry) + ---@cast log_entry LogEntry + assert.equals(2, #log_entry.files) + assert.is_false(log_entry.single_file) + + local paths = {} + for _, file in ipairs(log_entry.files) do + paths[file.path] = true + assert.equals(RevType.LOCAL, file.revs.b.type) + end + assert.is_true(paths["foo.txt"]) + assert.is_true(paths["bar.txt"]) + end) + + it("includes missing (`!`) and removed (`R`) files normalized to `D`", function() + if not hg_available() then + pending("hg not installed") + return + end + + repo.write("modified.txt", "m\n") + repo.write("missing.txt", "m\n") + repo.write("removed.txt", "r\n") + repo.hg({ "add", "modified.txt", "missing.txt", "removed.txt" }) + repo.hg({ "commit", "-m", "init", "-u", "test " }) + + repo.write("modified.txt", "changed\n") + -- `!` (missing): file removed from disk without `hg rm`. + os.remove(repo.dir .. "/missing.txt") + -- `R` (removed): tracked-and-removed via `hg rm`. + repo.hg({ "rm", "removed.txt" }) + + local adapter = repo.adapter() + local log_entry = adapter:build_local_log_entry({ + path_args = {}, + layout_opt = { default_layout = Diff2 }, + single_file = false, + }) + + assert.is_not_nil(log_entry) + ---@cast log_entry LogEntry + local by_name = {} + for _, file in ipairs(log_entry.files) do + by_name[file.path] = file + end + + assert.equals("M", by_name["modified.txt"].status) + assert.is_not_nil(by_name["missing.txt"], "missing.txt should appear (deleted)") + assert.equals("D", by_name["missing.txt"].status) + assert.is_not_nil(by_name["removed.txt"], "removed.txt should appear (removed)") + assert.equals("D", by_name["removed.txt"].status) + end) + end) end) diff --git a/lua/diffview/tests/functional/layouts_spec.lua b/lua/diffview/tests/functional/layouts_spec.lua index af75fc57..2cc55b57 100644 --- a/lua/diffview/tests/functional/layouts_spec.lua +++ b/lua/diffview/tests/functional/layouts_spec.lua @@ -626,6 +626,24 @@ describe("diffview.scene.layouts.diff_2_*_pinned should_null", function() assert.False(cls.should_null(commit, "?", "a")) end end) + + -- The synthetic top-of-history entry built by `build_local_log_entry` + -- has `revs.a = HEAD` (parent of the working tree, not the changeset + -- being browsed) with statuses from `diff HEAD`, so standard + -- parent-vs-child semantics apply: an added file nulls the a-side + -- because HEAD doesn't have it; a deleted file does NOT null the a-side + -- because HEAD still has it. The adapter tags `revs.a` with + -- `pin_local_synthetic` so the pinned override defers to `Diff2.should_null`. + it("defers to Diff2 for the synthetic working-tree entry (pin_local_synthetic)", function() + local synthetic = { type = RevType.COMMIT, pin_local_synthetic = true } + for _, cls in ipairs({ Diff2HorPinned, Diff2VerPinned }) do + assert.True(cls.should_null(synthetic, "A", "a")) + assert.True(cls.should_null(synthetic, "?", "a")) + assert.False(cls.should_null(synthetic, "D", "a")) + assert.False(cls.should_null(synthetic, "M", "a")) + assert.False(cls.should_null(synthetic, "R", "a")) + end + end) end) describe("diffview.scene.layouts.diff_2_*_pinned ownership", function() diff --git a/lua/diffview/vcs/adapter.lua b/lua/diffview/vcs/adapter.lua index 6e4fab2d..66ee4783 100644 --- a/lua/diffview/vcs/adapter.lua +++ b/lua/diffview/vcs/adapter.lua @@ -272,6 +272,82 @@ end ---@diagnostic enable: unused-local, missing-return +---Build a synthetic LogEntry representing the working tree as a top-of-log +---"commit" paired with `revs.a = HEAD` on each FileEntry. Used to render the +---working tree as a navigable entry in file-history when `pin_local` is true. +---Returns nil when the working tree has no path-arg-relevant changes, when +---the adapter has no working-tree concept, or when HEAD cannot be resolved +---(e.g. a fresh repo with no commits). The default implementation returns +---nil; git and hg adapters override. +---@param opt { path_args: string[], layout_opt: vcs.adapter.LayoutOpt, single_file: boolean } +---@return LogEntry? +function VCSAdapter:build_local_log_entry(opt) ---@diagnostic disable-line: unused-local + return nil +end + +---@class vcs.adapter.HistoryScope +---@field single_file boolean # Whether the resulting history is logically scoped to one file. +---@field path? string # The scoped working-tree path when `single_file` is true. Drives `pin_local`'s rename anchor and the synthetic entry's path filter. + +---@class vcs.adapter.PinLocalFileEntryOpt +---@field layout_class Layout (class) +---@field layout_opt vcs.adapter.LayoutOpt +---@field path string # Working-tree path emitted for this row. +---@field oldpath? string # Rename old name. Caller is responsible for nilling it when `revs.a` is the commit being browsed (pin_local non-synth case); the helper passes it through unchanged. +---@field rev_a Rev +---@field rev_b Rev +---@field status? string +---@field stats? GitStats +---@field commit Commit +---@field single_file boolean # The history's single-file scope, computed via `history_scope`. Drives the b-side cache key. + +---Build a `FileEntry` that respects the pin_local invariants. Centralises +---the rules `parse_fh_data` and `build_local_log_entry` were both +---duplicating: in pin_local mode the b-side `vcs.File` is resolved through +---`layout_opt.pinned_b_file_for` keyed by `pinned_path` in single-file +---mode and `opt.path` otherwise, so the synthetic top-of-history entry +---and the streamed entries share the same view-owned File for a given +---path. Each call site computes its own `rev_a` / `rev_b` / `oldpath` +---(those differ between streamed and synthetic entries) and passes them +---in. +---@param opt vcs.adapter.PinLocalFileEntryOpt +---@return FileEntry +function VCSAdapter:build_pin_local_file_entry(opt) + local FileEntry = lazy.access("diffview.scene.file_entry", "FileEntry").__get() + + local pin_local = opt.layout_opt.pin_local == true + local pinned_b_file + if pin_local and opt.layout_opt.pinned_b_file_for then + local b_path = (opt.single_file and opt.layout_opt.pinned_path) or opt.path + pinned_b_file = opt.layout_opt.pinned_b_file_for(b_path) + end + + return FileEntry.with_layout(opt.layout_class, { + adapter = self, + path = opt.path, + oldpath = opt.oldpath, + status = opt.status, + stats = opt.stats, + kind = "working", + commit = opt.commit, + revs = { a = opt.rev_a, b = opt.rev_b }, + pinned_b_file = pinned_b_file, + }) +end + +---Resolve the history's working-tree scope. Single source of truth for +---"is this single-file?" and "what file?", consulted by `pin_local`'s +---`pinned_path` seed, the synthetic entry's `single_file` field, and the +---synth's `git diff` path filter. Adapters override to handle their own +---history modes (git's `-L` line-trace adds a path that doesn't live in +---`path_args`). Default: multi-file (no scoped path). +---@param path_args string[] +---@param log_options table +---@return vcs.adapter.HistoryScope +function VCSAdapter:history_scope(path_args, log_options) ---@diagnostic disable-line: unused-local + return { single_file = false } +end + ---@return string cmd The VCS binary. function VCSAdapter:bin() return self:get_command()[1] diff --git a/lua/diffview/vcs/adapters/git/init.lua b/lua/diffview/vcs/adapters/git/init.lua index fe5e6285..38b3f323 100644 --- a/lua/diffview/vcs/adapters/git/init.lua +++ b/lua/diffview/vcs/adapters/git/init.lua @@ -164,6 +164,22 @@ function GitAdapter.get_repo_paths(path_args, cpath) return paths, top_indicators end +---Extract the working-tree path from a git `-L` spec. Each spec is shaped +---`:`, where `` is one of `,`, +---`:`, or `::`. We take the suffix after the last `:` to +---match git's own parsing (`strrchr(spec, ':')` in `line-log.c`); paths +---containing `:` are ambiguous to git itself. Returns nil when the spec +---is malformed or has no path so callers can downgrade safely. +---@param spec string +---@return string? +local function l_spec_path(spec) + local path = spec:match(".*:(.*)") + if path == nil or path == "" then + return nil + end + return path +end + local has_cygpath ---@type boolean? ---@type table local cygpath_cache = {} @@ -618,14 +634,74 @@ function GitAdapter:stream_line_trace_data(state) return stream end +---@override +---@param path_args string[] +---@param log_options GitLogOptions +---@return vcs.adapter.HistoryScope +function GitAdapter:history_scope(path_args, log_options) + local lflags = log_options and log_options.L or {} + if #lflags > 0 then + -- Line-trace history: each `L` spec is `:`. The + -- working-tree path lives there, not in `path_args` (which is empty + -- in `-L` mode). A malformed spec (no `:`, empty path) downgrades + -- the whole scope to multi-file rather than seeding `pinned_path` + -- with nil/empty downstream. + local first_path = l_spec_path(lflags[1]) + if not first_path then + return { single_file = false } + end + for i = 2, #lflags do + if l_spec_path(lflags[i]) ~= first_path then + return { single_file = false } + end + end + return { single_file = true, path = first_path } + end + + if not (path_args and #path_args == 1 and self.ctx.toplevel) then + return { single_file = false } + end + if pl:is_dir(path_args[1]) then + return { single_file = false } + end + -- Resolve the pathspec to the actual tracked file. `path_args[1]` may + -- be a glob / magic pathspec (`*.lua`, `:(glob)**/*.lua`, ...) rather + -- than a literal path; using it raw as `pinned_path` would key the + -- pin_local cache by the pathspec instead of the matched filename and + -- the RHS would try to open a LOCAL file named after the pattern. The + -- ls-files call canonicalises to git's relative emission, so absolute + -- and relative spellings of the same file also share a cache key. + local out = self:exec_sync( + utils.vec_join("-c", "core.quotePath=false", "ls-files", "--", path_args), + self.ctx.toplevel + ) + if #out == 1 then + return { single_file = true, path = out[1] } + end + if #out == 0 then + -- Match `is_single_file`'s `< 2` semantics: a single-pathspec history + -- whose path isn't tracked today (deleted, renamed away, etc.) is still + -- single-file and may have history. Fall back to the literal pathspec + -- as the rename anchor; a magic pathspec that resolves to nothing also + -- has empty history, so the approximation is harmless there. + return { single_file = true, path = path_args[1] } + end + return { single_file = false } +end + ---@param path_args string[] ----@param lflags string[] +---@param lflags? string[] ---@return boolean function GitAdapter:is_single_file(path_args, lflags) if lflags and #lflags > 0 then local seen = {} for i, v in ipairs(lflags) do - local path = v:match(".*:(.*)") + local path = l_spec_path(v) + if not path then + -- Malformed L spec: fall back to multi-file rather than indexing + -- `seen` with nil (which would error). + return false + end if i > 1 and not seen[path] then return false end @@ -1105,38 +1181,21 @@ function GitAdapter:parse_fh_data(data, commit, state) rev_b = state.prepared_log_opts.base or GitRev(RevType.COMMIT, data.right_hash) end - -- pin_local: every entry's b-window references the view-owned - -- `vcs.File` for the resolved working-tree path. The cache keys by - -- path, so a single-file history shares one File per path across all - -- entries and refreshes; the view destroys them in `close()`. Only - -- single-file mode honours `pinned_path` (the rename anchor that keeps - -- the b-side bound to the user's working-tree name even when older - -- commits used a renamed name); in multi-file mode `pinned_path` - -- tracks the cursor's last file row and would otherwise route every - -- entry's b-side to that one file. - local pinned_b_file - if pin_local and state.layout_opt.pinned_b_file_for then - local b_path = (state.single_file and state.layout_opt.pinned_path) or entry.name - pinned_b_file = state.layout_opt.pinned_b_file_for(b_path) - end - table.insert( files, - FileEntry.with_layout(state.layout_opt.default_layout or Diff2Hor, { - adapter = self, + self:build_pin_local_file_entry({ + layout_class = state.layout_opt.default_layout or Diff2Hor, + layout_opt = state.layout_opt, path = entry.name, -- In pin_local mode revs.a is the commit itself, so the parent's -- old name doesn't apply; entry.name lives in this commit's tree. oldpath = (not pin_local) and entry.oldname or nil, status = entry.status, stats = entry.stats, - kind = "working", commit = commit, - revs = { - a = rev_a, - b = rev_b, - }, - pinned_b_file = pinned_b_file, + rev_a = rev_a, + rev_b = rev_b, + single_file = state.single_file, }) ) end @@ -1193,30 +1252,23 @@ function GitAdapter:parse_fh_line_trace_data(data, commit, state) rev_b = state.prepared_log_opts.base or GitRev(RevType.COMMIT, data.right_hash) end - -- See `parse_fh_data` for the pinned_b_file rationale. `entry.path_new` - -- is typed `string?` upstream; the line-trace pipeline only emits - -- entries with a real path, but the type system needs the explicit - -- guard to know we never pass nil into `pinned_b_file_for`. Line trace - -- is single-file by construction, so `pinned_path` always applies. - local pinned_b_file - local b_path = state.layout_opt.pinned_path or entry.path_new - if pin_local and state.layout_opt.pinned_b_file_for and b_path then - pinned_b_file = state.layout_opt.pinned_b_file_for(b_path) - end - + -- Line-trace is single-file by construction, so the helper's + -- `single_file = true` resolves the b-side cache key through + -- `pinned_path` (the rename anchor) -- mirroring `parse_fh_data`. + -- `entry.path_new` is typed `string?` upstream; the line-trace + -- pipeline only emits entries with a real path, so casting here is + -- safe. table.insert( files, - FileEntry.with_layout(state.layout_opt.default_layout or Diff2Hor, { - adapter = self, - path = entry.path_new, + self:build_pin_local_file_entry({ + layout_class = state.layout_opt.default_layout or Diff2Hor, + layout_opt = state.layout_opt, + path = entry.path_new --[[@as string ]], oldpath = (not pin_local) and oldpath or nil, - kind = "working", commit = commit, - revs = { - a = rev_a, - b = rev_b, - }, - pinned_b_file = pinned_b_file, + rev_a = rev_a, + rev_b = rev_b, + single_file = true, }) ) end @@ -1236,6 +1288,109 @@ function GitAdapter:parse_fh_line_trace_data(data, commit, state) return false, "Missing file data!" end +---@override +---@param opt { path_args: string[], layout_opt: vcs.adapter.LayoutOpt, single_file: boolean } +---@return LogEntry? +function GitAdapter:build_local_log_entry(opt) + local path_args = opt.path_args or {} + local layout_opt = opt.layout_opt + + local head = self:head_rev() + if not head then + return nil + end + + -- Mark this rev as the synthetic entry's a-side. The pinned layout's + -- `should_null` inverts the standard semantics for the normal pin_local + -- case (where revs.a is the commit being browsed, so `A` means present + -- and `D` means absent on the a-side). Here revs.a is HEAD and the + -- statuses come from `diff HEAD` (parent-perspective), so the standard + -- `Diff2.should_null` applies; the flag tells the override to defer. + head.pin_local_synthetic = true + + -- `--raw --numstat HEAD` mirrors the format `parse_fh_data` already + -- consumes, so namestat parsing can reuse `git_parser.parse_namestat_entry`. + -- `core.quotePath=false` matches the streamed file-history invocations so + -- non-ASCII (or otherwise C-quotable) paths arrive as raw bytes here too; + -- without it the synthetic entry's path would not match the pinned-path + -- cache that's keyed on the streamed (unquoted) path. + local args = utils.vec_join( + { "-c", "core.quotePath=false", "diff", "--raw", "--numstat", "HEAD", "--" }, + path_args + ) + local out, code = self:exec_sync(args, self.ctx.toplevel) + if code ~= 0 or not out then + return nil + end + + local namestat, numstat = git_parser.structure_stat_data(out, 1) + if #namestat == 0 then + return nil + end + + local user_out = self:exec_sync( + { "config", "user.name" }, + { cwd = self.ctx.toplevel, silent = true } + ) + local author = (user_out and user_out[1] and vim.trim(user_out[1])) or "" + if author == "" then + author = "Working tree" + end + + -- `Commit` here is `GitCommit` (see the alias at the top of this file), + -- whose `init` derives `iso_date` from `time` and defaults `time_offset` + -- to 0. Both fields are required by `render.lua` when the panel's + -- `date_format` is `iso` (and in the `auto` branch for older commits); + -- a nil `iso_date` would abort the panel render, so we rely on the + -- subclass init populating them rather than passing them explicitly. + local commit = Commit({ + hash = nil, + author = author, + time = os.time(), + rel_date = "now", + subject = "Working tree", + ref_names = nil, + }) + + local layout_class = layout_opt.default_layout or Diff2Hor + local files = {} + + for i = 1, #namestat do + local entry = git_parser.parse_namestat_entry(namestat[i], numstat[i] or "0\t0\t") + + -- Synthetic working-tree entry. `oldpath` IS retained: revs.a is HEAD + -- (parent of the working tree), so the rename detected by + -- `git diff HEAD` is meaningful for reading the file at HEAD. + table.insert( + files, + self:build_pin_local_file_entry({ + layout_class = layout_class, + layout_opt = layout_opt, + path = entry.name, + oldpath = entry.oldname, + status = entry.status, + stats = entry.stats, + commit = commit, + rev_a = head, + rev_b = self.Rev(RevType.LOCAL), + single_file = opt.single_file, + }) + ) + end + + return LogEntry({ + path_args = path_args, + commit = commit, + files = files, + -- Mirror the scope decision the caller already made via + -- `history_scope`. Recomputing from `#path_args == 1` here would + -- mismark single-arg multi-file pathspecs (e.g. `*.txt` matching + -- multiple files) as single-file, which would force the whole + -- panel into single-file mode because the synth is prepended. + single_file = opt.single_file, + }) +end + ---@param argo ArgObject function GitAdapter:diffview_options(argo) local rev_arg = argo.args[1] diff --git a/lua/diffview/vcs/adapters/hg/init.lua b/lua/diffview/vcs/adapters/hg/init.lua index 0be1832a..9981fa51 100644 --- a/lua/diffview/vcs/adapters/hg/init.lua +++ b/lua/diffview/vcs/adapters/hg/init.lua @@ -517,6 +517,31 @@ function HgAdapter:stream_fh_data(state) return stream end +---@override +---@param path_args string[] +---@param log_options HgLogOptions +---@return vcs.adapter.HistoryScope +function HgAdapter:history_scope(path_args, log_options) ---@diagnostic disable-line: unused-local + -- Mercurial has no `-L` line-trace mode (`file_history_options` rejects + -- it), so the scope question reduces to "is this a single-file pathspec?". + if not (path_args and #path_args == 1 and self.ctx.toplevel) then + return { single_file = false } + end + if pl:is_dir(path_args[1]) then + return { single_file = false } + end + -- See `GitAdapter:history_scope` for why we resolve through `hg files` + -- instead of using `path_args[1]` raw, and for the `#out == 0` fallback. + local out = self:exec_sync(utils.vec_join("files", "--", path_args), self.ctx.toplevel) + if #out == 1 then + return { single_file = true, path = out[1] } + end + if #out == 0 then + return { single_file = true, path = path_args[1] } + end + return { single_file = false } +end + function HgAdapter:is_single_file(path_args, lflags) if path_args and self.ctx.toplevel then return #path_args == 1 @@ -728,31 +753,21 @@ function HgAdapter:parse_fh_data(data, commit, state) rev_b = state.prepared_log_opts.base or HgRev(RevType.COMMIT, data.right_hash) end - -- See `GitAdapter:parse_fh_data` for the pinned_b_file rationale, - -- including why `pinned_path` only applies in single-file mode. - local pinned_b_file - if pin_local and state.layout_opt.pinned_b_file_for then - local b_path = (state.single_file and state.layout_opt.pinned_path) or name - pinned_b_file = state.layout_opt.pinned_b_file_for(b_path) - end - table.insert( files, - FileEntry.with_layout(state.layout_opt.default_layout or Diff2Hor, { - adapter = self, + self:build_pin_local_file_entry({ + layout_class = state.layout_opt.default_layout or Diff2Hor, + layout_opt = state.layout_opt, path = name, -- In pin_local mode revs.a is the changeset itself, so the parent's -- old name doesn't apply; `name` lives in this changeset's tree. oldpath = (not pin_local) and oldname or nil, status = status, stats = stats, - kind = "working", commit = commit, - revs = { - a = rev_a, - b = rev_b, - }, - pinned_b_file = pinned_b_file, + rev_a = rev_a, + rev_b = rev_b, + single_file = state.single_file, }) ) end @@ -784,6 +799,121 @@ function HgAdapter:parse_fh_data(data, commit, state) }) end +---@override +---@param opt { path_args: string[], layout_opt: vcs.adapter.LayoutOpt, single_file: boolean } +---@return LogEntry? +function HgAdapter:build_local_log_entry(opt) + local path_args = opt.path_args or {} + local layout_opt = opt.layout_opt + + local head = self:head_rev() + if not head then + return nil + end + + -- See `GitAdapter:build_local_log_entry` for the rationale: revs.a here + -- is HEAD (parent of the working tree), not the changeset being browsed, + -- so the pinned layout must defer its a-side decision to `Diff2.should_null`. + head.pin_local_synthetic = true + + -- The `--` separator stops paths starting with `-` from being parsed as + -- options, matching the convention the rest of this adapter uses for + -- path-sensitive hg invocations. + local status_args = utils.vec_join( + "status", + "--modified", + "--added", + "--removed", + "--deleted", + "--template={status} {path}\n", + "--", + path_args + ) + local out, code = self:exec_sync(status_args, self.ctx.toplevel) + if code ~= 0 or not out or #out == 0 then + return nil + end + + local files_data = {} + for _, line in ipairs(out) do + if line and #line > 0 then + -- Parse `{status} {path}`: split on the first whitespace run after the + -- status char so non-alpha statuses (e.g. `!` for deleted) aren't + -- silently dropped. + local status, name = line:match("^(%S)%s+(.+)$") + if status == "R" or status == "!" then + -- Mercurial uses `R` for tracked-and-removed and `!` for missing + -- (deleted on disk); our UI represents both as `D`. + status = "D" + end + if status and name and name ~= "" then + -- Don't `vim.trim` the path: Mercurial allows leading/trailing + -- whitespace in filenames, and the `(.+)$` capture already excludes + -- the line terminator. Trimming would silently rewrite the path + -- and the synthetic entry would point at a different file than + -- the repo actually contains. + table.insert(files_data, { status = status, name = name }) + end + end + end + + if #files_data == 0 then + return nil + end + + local user_out = self:exec_sync( + { "config", "ui.username" }, + { cwd = self.ctx.toplevel, silent = true } + ) + local author = (user_out and user_out[1] and vim.trim(user_out[1])) or "" + if author == "" then + author = "Working tree" + end + + -- `Commit` here is `HgCommit` (see the alias at the top of this file), + -- whose `init` derives `iso_date` from `time` and defaults `time_offset` + -- to 0. Both fields are required by `render.lua` when the panel's + -- `date_format` is `iso` (and in the `auto` branch for older commits); + -- a nil `iso_date` would abort the panel render, so we rely on the + -- subclass init populating them rather than passing them explicitly. + local commit = Commit({ + hash = nil, + author = author, + time = os.time(), + rel_date = "now", + subject = "Working tree", + ref_names = nil, + }) + + local layout_class = layout_opt.default_layout or Diff2Hor + local files = {} + + for _, f in ipairs(files_data) do + table.insert( + files, + self:build_pin_local_file_entry({ + layout_class = layout_class, + layout_opt = layout_opt, + path = f.name, + oldpath = nil, + status = f.status, + commit = commit, + rev_a = head, + rev_b = self.Rev(RevType.LOCAL), + single_file = opt.single_file, + }) + ) + end + + return LogEntry({ + path_args = path_args, + commit = commit, + files = files, + -- See `GitAdapter:build_local_log_entry` for the rationale. + single_file = opt.single_file, + }) +end + ---@param argo ArgObject function HgAdapter:diffview_options(argo) local rev_args = argo.args[1] diff --git a/lua/diffview/vcs/rev.lua b/lua/diffview/vcs/rev.lua index 84fd0848..d9db840d 100644 --- a/lua/diffview/vcs/rev.lua +++ b/lua/diffview/vcs/rev.lua @@ -17,6 +17,7 @@ local RevType = oop.enum({ ---@field commit? string A commit SHA. ---@field stage? integer A stage number. ---@field track_head boolean If true, indicates that the rev should be updated when HEAD changes. +---@field pin_local_synthetic? boolean Set on the a-side rev of the synthetic top-of-history entry built by `VCSAdapter:build_local_log_entry`. Tells the pinned `Diff2` layout's `should_null` to fall back to standard parent-vs-child semantics for that entry, since revs.a is HEAD (not the commit being browsed). local Rev = oop.create_class("Rev") ---Rev constructor From f8329a232847d6d13c0a65c4c23287940df52c35 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Wed, 6 May 2026 10:43:51 +0200 Subject: [PATCH 4/4] feat(file_history): wire `--pin-local` with multi-file pinning Add `--pin-local` and `view.file_history.pin_local`. `FileHistoryView` owns a `path -> vcs.File` cache shared across pinned entries (entry teardown skips them via `shared_symbols`; the view destroys them in `close()`). A debounced cursor follower updates `pinned_path`; `_resolve_pinned_target` builds overlay `FileEntries` for commits that don't touch it. `cycle_layout` / `set_layout` route through `resolve_pinned_layout` and convert overlays missed by `panel:list_files()`. Reject `--pin-local` with `--base` (would override the fixed RHS). --- doc/diffview.txt | 33 + doc/diffview_defaults.txt | 1 + lua/diffview/actions.lua | 50 +- lua/diffview/config.lua | 3 + lua/diffview/lib.lua | 51 + lua/diffview/scene/file_entry.lua | 44 +- .../scene/layouts/diff_2_hor_pinned.lua | 8 +- .../views/file_history/file_history_panel.lua | 105 +- .../views/file_history/file_history_view.lua | 366 +++++- .../scene/views/file_history/listeners.lua | 41 +- .../scene/views/file_history/render.lua | 2 +- .../tests/functional/file_entry_spec.lua | 213 ++++ .../tests/functional/hg_adapter_spec.lua | 73 ++ .../tests/functional/layouts_spec.lua | 5 +- lua/diffview/tests/functional/panel_spec.lua | 61 + .../tests/functional/pin_local_spec.lua | 1007 +++++++++++++++++ lua/diffview/ui/panel.lua | 5 + lua/diffview/vcs/adapter.lua | 10 + lua/diffview/vcs/adapters/git/init.lua | 9 + lua/diffview/vcs/adapters/hg/init.lua | 20 + lua/diffview/vcs/adapters/null/init.lua | 7 + lua/diffview/vcs/log_entry.lua | 7 + 22 files changed, 2077 insertions(+), 44 deletions(-) create mode 100644 lua/diffview/tests/functional/pin_local_spec.lua diff --git a/doc/diffview.txt b/doc/diffview.txt index 82d5229f..a2a1d006 100644 --- a/doc/diffview.txt +++ b/doc/diffview.txt @@ -275,6 +275,13 @@ COMMANDS *diffview-commands* will be created. Use the special value `LOCAL` to use the local version of the file. + --pin-local + Pin the right-hand side of the diff to the working-tree + LOCAL buffer across log navigation. Pass `--pin-local=false` + to disable when the corresponding config option is enabled. + Mutually exclusive with `--base={git-rev}`. See + |diffview-config-view.file_history.pin_local|. + --range={git-rev} Show only commits in the specified revision range. @@ -378,6 +385,12 @@ COMMANDS *diffview-commands* See `hg help revsets` for the full list of keywords and constructs. + --pin-local + Pin the right-hand side of the diff to the working-tree + LOCAL buffer across log navigation. Pass `--pin-local=false` + to disable when the corresponding config option is enabled. + See |diffview-config-view.file_history.pin_local|. + -f, --follow Follow renames (only for single file). @@ -902,6 +915,26 @@ view.inline *diffview-config-view.inline* unchanged, only the foreground colouring of the deleted text is affected. +view.file_history.pin_local *diffview-config-view.file_history.pin_local* + Type: `boolean`, Default: `false` + + Pin the right-hand side of the diff in file-history views to the + working-tree LOCAL buffer across log navigation, so you can + browse commit history while diffing each commit against your + live file (preserving its existing buffer state, including + unsaved edits). When `false`, the right side shows the file at + each browsed commit. + + A synthetic "Working tree" entry is prepended to the history + whenever the working tree is dirty, so you can also diff HEAD + against the live file. + + Per-invocation, the `--pin-local` flag enables this and + `--pin-local=false` disables it, overriding the value set here. + Supported for git and hg repositories. For git, this is + mutually exclusive with `--base={git-rev}` (which pins the + right side to a fixed revision instead). + file_panel *diffview-config-file_panel* Type: `table`, Default: (see defaults) diff --git a/doc/diffview_defaults.txt b/doc/diffview_defaults.txt index d8475be0..89f8d679 100644 --- a/doc/diffview_defaults.txt +++ b/doc/diffview_defaults.txt @@ -78,6 +78,7 @@ DEFAULT CONFIG *diffview.defaults* layout = "diff2_horizontal", disable_diagnostics = false, -- Temporarily disable diagnostics for diff buffers while in the view. winbar_info = false, -- See |diffview-config-view.x.winbar_info| + pin_local = false, -- See |diffview-config-view.file_history.pin_local| }, foldlevel = 0, -- See |diffview-config-view.foldlevel| -- Layouts to cycle through with `cycle_layout` action. Each view's diff --git a/lua/diffview/actions.lua b/lua/diffview/actions.lua index 10ad54d6..29b56f4b 100644 --- a/lua/diffview/actions.lua +++ b/lua/diffview/actions.lua @@ -856,11 +856,48 @@ function M.cycle_layout() for _, entry in ipairs(files) do local cur_layout = entry.layout - local idx = utils.vec_indexof(layouts, cur_layout.class) + -- Normalise a pinned current class to its unpinned sibling for the + -- lookup: the cycle list contains unpinned classes, so without this + -- step pin_local mode never matches and cycling sticks on the first + -- layout. `unpinned_layout` is a no-op for non-pinned classes. + local cur_for_lookup = cur_layout.class --[[@as Layout ]] + if view.unpinned_layout then + cur_for_lookup = (view --[[@as FileHistoryView ]]):unpinned_layout(cur_for_lookup) + end + local idx = utils.vec_indexof(layouts, cur_for_lookup) -- If the current layout isn't in the cycle list, start at the first -- entry rather than the last (Lua's `-1 % N + 1 == N` quirk). local next_idx = (idx == -1 and 0 or idx) % #layouts + 1 - entry:convert_layout(layouts[next_idx]) + local target = layouts[next_idx] + -- File-history pin_local must stay on a pinned Diff2 variant: an + -- unpinned class would let `FileEntry:destroy` tear down the + -- view-owned working-tree File once per entry and break the shared + -- LOCAL buffer. `resolve_pinned_layout` is a no-op when pin_local + -- is off, so the cast is safe even when the view happens to be a + -- DiffView in some refactor down the line. + if view.resolve_pinned_layout then + target = (view --[[@as FileHistoryView ]]):resolve_pinned_layout(target) + end + entry:convert_layout(target) + end + + -- `panel:list_files()` doesn't include pin_local overlays (transient + -- FileEntries built by `_resolve_pinned_target` for commits that don't + -- touch the pinned path). When the active diff IS an overlay, the loop + -- above misses it and the next `set_file` would reopen the stale layout. + -- Convert it explicitly with the same target the loop computed. + if cur_file and utils.vec_indexof(files, cur_file) == -1 then + local cur_for_lookup = cur_file.layout.class --[[@as Layout ]] + if view.unpinned_layout then + cur_for_lookup = (view --[[@as FileHistoryView ]]):unpinned_layout(cur_for_lookup) + end + local idx = utils.vec_indexof(layouts, cur_for_lookup) + local next_idx = (idx == -1 and 0 or idx) % #layouts + 1 + local target = layouts[next_idx] + if view.resolve_pinned_layout then + target = (view --[[@as FileHistoryView ]]):resolve_pinned_layout(target) + end + cur_file:convert_layout(target) end if cur_file then @@ -926,11 +963,20 @@ function M.set_layout(layout_name) end local target_layout = layout_class.__get() + -- See `cycle_layout` for the pin_local rationale. + if view.resolve_pinned_layout then + target_layout = (view --[[@as FileHistoryView ]]):resolve_pinned_layout(target_layout) + end for _, entry in ipairs(files) do entry:convert_layout(target_layout) end + -- See `cycle_layout` for why pin_local overlays need explicit handling. + if cur_file and utils.vec_indexof(files, cur_file) == -1 then + cur_file:convert_layout(target_layout) + end + if cur_file then local main = view.cur_layout:get_main_win() local pos = api.nvim_win_get_cursor(main.id) diff --git a/lua/diffview/config.lua b/lua/diffview/config.lua index fa7994bf..6bb3f739 100644 --- a/lua/diffview/config.lua +++ b/lua/diffview/config.lua @@ -298,6 +298,7 @@ M.defaults = { ---@field disable_diagnostics boolean ---@field winbar_info boolean ---@field focus_diff boolean + ---@field pin_local? boolean ---@class DiffviewMergeViewTypeConfig ---@field layout DiffviewMergeLayout|DiffviewInferredLayout @@ -310,6 +311,7 @@ M.defaults = { ---@field disable_diagnostics? boolean Temporarily disable diagnostics for diff buffers while in the view. ---@field winbar_info? boolean See `|diffview-config-view.x.winbar_info|`. ---@field focus_diff? boolean Focus the main diff window on open instead of the file panel. + ---@field pin_local? boolean File-history only: pin the b-window to the working-tree LOCAL buffer across log navigation, so you can browse history while diffing each commit against your live file. Per-invocation, `--pin-local` enables and `--pin-local=false` disables (overriding any value set here). For git, `--base=` is an alternative that pins to a fixed commit instead. See `|diffview-config-view.file_history.pin_local|`. ---@class DiffviewMergeViewTypeConfig.user ---@field layout? DiffviewMergeLayout|DiffviewInferredLayout Layout to use for this view type. See `|diffview-config-view.x.layout|`. @@ -335,6 +337,7 @@ M.defaults = { disable_diagnostics = false, winbar_info = false, focus_diff = false, + pin_local = false, }, -- Initial 'foldlevel' for diff buffers. Default 0 collapses unchanged -- regions; set to a high value (e.g. 99) to keep all folds open. diff --git a/lua/diffview/lib.lua b/lua/diffview/lib.lua index 85b6ab95..117c9c3a 100644 --- a/lua/diffview/lib.lua +++ b/lua/diffview/lib.lua @@ -7,6 +7,8 @@ local DiffView = lazy.access("diffview.scene.views.diff.diff_view", "DiffView") local FileDiffView = lazy.access("diffview.scene.views.diff.file_diff_view", "FileDiffView") ---@type FileDiffView|LazyModule local FileHistoryView = lazy.access("diffview.scene.views.file_history.file_history_view", "FileHistoryView") ---@type FileHistoryView|LazyModule +local GitAdapter = lazy.access("diffview.vcs.adapters.git", "GitAdapter") ---@type GitAdapter|LazyModule +local HgAdapter = lazy.access("diffview.vcs.adapters.hg", "HgAdapter") ---@type HgAdapter|LazyModule local NullAdapter = lazy.access("diffview.vcs.adapters.null", "NullAdapter") ---@type NullAdapter|LazyModule local StandardView = lazy.access("diffview.scene.views.standard.standard_view", "StandardView") ---@type StandardView|LazyModule local arg_parser = lazy.require("diffview.arg_parser") ---@module "diffview.arg_parser" @@ -145,9 +147,55 @@ function M.file_history(range, args) return end + -- Boolean flag: bare `--pin-local` enables, `--pin-local=false` overrides + -- a value set in the user's config. Falls back to the config value when + -- the flag isn't passed at all. + local raw_pin_local = argo:get_flag("pin-local") + local pin_local + if raw_pin_local ~= nil then + pin_local = raw_pin_local --[[@as boolean ]] + else + pin_local = config.get_config().view.file_history.pin_local or false + end + + if + pin_local + and not (adapter:instanceof(GitAdapter.__get()) or adapter:instanceof(HgAdapter.__get())) + then + utils.err("`--pin-local` is only supported for git and mercurial repositories.") + return + end + + -- pin_local forces revs.b = LOCAL on every entry, which silently overrides + -- a fixed-base RHS the user asked for via `--base`. Reject the combination + -- so the conflict is loud rather than confusing. + if pin_local and argo:get_flag("base", { no_empty = true }) then + utils.err( + "`--pin-local` and `--base` cannot be combined: pin_local forces the right-hand side to the working tree." + ) + return + end + + -- For single-file pinning, seed `pinned_path` so the b-side stays bound to + -- the user's working-tree file even across renames in older commits. For + -- multi-file pinning the path is dynamic (set by the cursor follower) so it + -- starts unset. `history_scope` is the single source of truth: it knows + -- about both `path_args` and `-L` line-trace (whose path lives in the L + -- spec, not `path_args`), and rejects single-arg directory pathspecs that + -- would otherwise produce a `pinned_path` no FileEntry can match. + local pinned_path + if pin_local then + local scope = adapter:history_scope(adapter.ctx.path_args, log_options) + if scope.single_file then + pinned_path = scope.path + end + end + local v = FileHistoryView({ adapter = adapter, log_options = log_options, + pin_local = pin_local, + pinned_path = pinned_path, }) if not v:is_valid() then @@ -185,6 +233,9 @@ function M.diffview_diff_files(args) end local toplevel = pl:parent(left_path) or "." + -- LuaLS picks up `GitAdapter.create`'s 2-arg signature when both adapters + -- are imported in this file, so suppress the spurious diagnostics. + ---@diagnostic disable-next-line: missing-parameter, param-type-mismatch local adapter = NullAdapter.create({ toplevel = toplevel }) local v = FileDiffView({ diff --git a/lua/diffview/scene/file_entry.lua b/lua/diffview/scene/file_entry.lua index bb855d22..f34f67d0 100644 --- a/lua/diffview/scene/file_entry.lua +++ b/lua/diffview/scene/file_entry.lua @@ -54,6 +54,7 @@ end ---@field merge_ctx vcs.MergeContext? ---@field active boolean ---@field opened boolean +---@field _extra_owned vcs.File[] # Files this entry owns that aren't reachable through `layout:owned_files()` (e.g. one-off nulled fallbacks built for a window whose symbol is in `shared_symbols`). local FileEntry = oop.create_class("FileEntry") ---@class FileEntry.init.Opt @@ -67,6 +68,7 @@ local FileEntry = oop.create_class("FileEntry") ---@field kind vcs.FileKind ---@field commit? Commit ---@field merge_ctx? vcs.MergeContext +---@field _extra_owned? vcs.File[] ---FileEntry constructor ---@param opt FileEntry.init.Opt @@ -87,6 +89,10 @@ function FileEntry:init(opt) self.merge_ctx = opt.merge_ctx self.active = false self.opened = false + -- Files this FileEntry owns that aren't reachable through `layout:owned_files()` + -- (e.g. one-off nulled fallbacks for shared-symbol windows when the + -- shared instance can't be used). Populated by `with_layout`. + self._extra_owned = opt._extra_owned or {} end ---@param force? boolean @@ -95,6 +101,11 @@ function FileEntry:destroy(force) f:destroy(force) end + for _, f in ipairs(self._extra_owned) do + f:destroy(force) + end + self._extra_owned = {} + self.layout:destroy() end @@ -267,15 +278,35 @@ end ---@field nulled? boolean ---@field get_data? git.FileDataProducer ---@field pinned_path? string # Deprecated: when `pinned_b_file` is supplied the layout takes its b-side from that shared File and `pinned_path` is ignored. Retained as a fallback for adapters that haven't been wired to the view's pin_local cache yet. ----@field pinned_b_file? vcs.File # The view-owned, shared working-tree `vcs.File` for `pin_local` mode. When set, the layout's b-side reuses this exact instance instead of constructing a fresh one, so identity is preserved across every entry the view ever shows. The instance outlives entry teardown via the layout's `shared_symbols`, and is destroyed by `FileHistoryView:close()`. +---@field pinned_b_file? vcs.File # The view-owned, shared working-tree `vcs.File` for `pin_local` mode. When set, the layout's b-side reuses this exact instance instead of constructing a fresh one, so identity is preserved across every entry the view ever shows. The instance outlives entry teardown via the layout's `shared_symbols`, and is destroyed by `FileHistoryView:close()`. One carve-out: if the layout's `should_null` says the b-side should render as absent AND the working-tree path no longer exists on disk, the b-side falls back to a one-off nulled file so a status="D" entry doesn't open an empty/editable buffer for a missing path. ---@param layout_class Layout (class) ---@param opt FileEntry.with_layout.Opt ---@return FileEntry function FileEntry.with_layout(layout_class, opt) + local extra_owned = {} + local function create_file(rev, symbol) + local fallback_for_shared = false if symbol == "b" and opt.pinned_b_file then - return opt.pinned_b_file + -- Fall through to a fresh nulled file when the layout says the + -- b-side should render as absent AND the shared LOCAL path no + -- longer exists on disk. Without this, status="D" entries whose + -- working-tree path is also gone would open an empty/editable + -- buffer for the missing path. The disk check preserves the + -- overlay case (file exists in WT but not in this commit), where + -- `try_should_null` would also return true but the b-side must + -- still show the LOCAL file. + local null_b = try_should_null(layout_class, rev, opt.status, symbol) + and vim.fn.filereadable(opt.pinned_b_file.absolute_path) ~= 1 + if not null_b then + return opt.pinned_b_file + end + -- We're constructing a one-off File for a window whose symbol is in + -- `shared_symbols`, so `Layout:owned_files()` would skip it and + -- `FileEntry:destroy` would otherwise leak it. Track it as an extra + -- owned file below. + fallback_for_shared = true end local path @@ -287,7 +318,7 @@ function FileEntry.with_layout(layout_class, opt) path = opt.path end - return File({ + local file = File({ adapter = opt.adapter, path = path, kind = opt.kind, @@ -296,6 +327,12 @@ function FileEntry.with_layout(layout_class, opt) rev = rev, nulled = utils.sate(opt.nulled, try_should_null(layout_class, rev, opt.status, symbol)), }) --[[@as vcs.File ]] + + if fallback_for_shared then + extra_owned[#extra_owned + 1] = file + end + + return file end return FileEntry({ @@ -307,6 +344,7 @@ function FileEntry.with_layout(layout_class, opt) kind = opt.kind, commit = opt.commit, revs = opt.revs, + _extra_owned = extra_owned, layout = layout_class({ a = create_file(opt.revs.a, "a"), b = create_file(opt.revs.b, "b"), diff --git a/lua/diffview/scene/layouts/diff_2_hor_pinned.lua b/lua/diffview/scene/layouts/diff_2_hor_pinned.lua index 25e07042..eaf00654 100644 --- a/lua/diffview/scene/layouts/diff_2_hor_pinned.lua +++ b/lua/diffview/scene/layouts/diff_2_hor_pinned.lua @@ -34,10 +34,10 @@ end -- a fresh-add ("A") nulls the a-side. In pin_local mode `revs.a` IS the -- commit, which means "A" implies the file exists on the a-side too. We -- only null the a-side when the file is absent from the commit ("D"). --- The b-side is never constructed via `with_layout` in this mode (the view --- supplies a pre-built `pinned_b_file`), so `try_should_null` never reaches --- this code with `sym == "b"`; we leave the LOCAL/STAGE branches to the --- parent. +-- The b-side normally reuses the view-owned `pinned_b_file`; `with_layout` +-- only consults this predicate for `sym == "b"` to decide whether to fall +-- back to a one-off nulled file when the LOCAL path is missing on disk. +-- We leave the LOCAL/STAGE branches to the parent for that case. -- -- The synthetic top-of-history "Working tree" entry is the exception: its -- `revs.a` is HEAD (parent-of-working-tree), and its statuses come from 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 1c870261..05f9a249 100644 --- a/lua/diffview/scene/views/file_history/file_history_panel.lua +++ b/lua/diffview/scene/views/file_history/file_history_panel.lua @@ -206,11 +206,59 @@ FileHistoryPanel.update_entries = async.wrap(function(self, callback) self.entries = {} self.updating = true + local layout_opt = { + default_layout = self.parent:get_default_layout() --[[@as Diff2 ]], + pin_local = self.parent.pin_local, + pinned_path = self.parent.pinned_path, + -- Closure into the view's pin_local cache: adapters call this when + -- constructing a pinned-mode entry's b-side, so every entry across the + -- whole history shares the same `vcs.File` instance for a given path + -- (and therefore the same Neovim buffer state). The view owns the + -- cache's lifetime; adapters and entries treat the returned files as + -- borrowed (see `Diff2*Pinned.shared_symbols`). + pinned_b_file_for = self.parent.pin_local and function(path) + return self.parent:get_pinned_b_file(path) + end or nil, + } + + -- Prepend a synthetic LOCAL "commit" so the working tree appears as the + -- top-of-log entry. The synth is omitted when the working tree is clean + -- or when the adapter doesn't override the base no-op. + -- + -- Path filter: in `-L` line-trace mode `adapter.ctx.path_args` is empty + -- (the path lives in the L spec), so passing it raw would make + -- `git diff HEAD --` pick up every dirty file in the repo. Use the + -- adapter's `history_scope` to recover the scoped path and restrict the + -- synth to it. + if self.parent.pin_local then + local raw_path_args = self.adapter.ctx.path_args or {} + -- `self.log_options` is the `{ single_file, multi_file }` wrapper, but + -- `history_scope` expects a flat `LogOptions` (it reads `.L` for the + -- line-trace branch). The `L` and `path_args` fields are mirrored + -- across both variants (seeded together in `:init`, mutated together + -- by the option panel), so reading from the single_file form gives + -- the right specs to recover the scoped path. + local scope = self.adapter:history_scope(raw_path_args, self.log_options.single_file) + local synth_path_args = (scope.single_file and scope.path) and { scope.path } or raw_path_args + + local synth = self.adapter:build_local_log_entry({ + path_args = synth_path_args, + layout_opt = layout_opt, + -- Pass scope's verdict directly: recomputing single_file inside the + -- adapter from `path_args` would mismark a single-arg multi-file + -- pathspec (e.g. `*.txt` matching multiple files) as single-file. + single_file = scope.single_file, + }) + + if synth then + self.entries[#self.entries + 1] = synth + self.single_file = synth.single_file + end + end + local stream = self.adapter:file_history({ log_opt = self.log_options, - layout_opt = { - default_layout = self.parent.get_default_layout(), - }, + layout_opt = layout_opt, }) self:sync() @@ -316,6 +364,16 @@ function FileHistoryPanel:find_entry(file) return entry end end + -- Pinned-RHS overlay FileEntries are stored separately on the entry so + -- they don't render as extra rows; we still match against them so + -- `set_file` can route diff updates correctly. + if entry._pin_overlays then + for _, f in pairs(entry._pin_overlays) do + if f == file then + return entry + end + end + end end end @@ -445,7 +503,13 @@ function FileHistoryPanel:set_file_by_offset(offset) local entry, file = self.cur_item[1], self.cur_item[2] if not (entry and file) and self:num_items() > 0 then - self:set_cur_item({ self.entries[1], self.entries[1].files[1] }) + -- Bootstrap (post-rebuild / first-open). In pin_local mode this is the + -- code path that runs after `update_entries`; pick the file matching + -- `pinned_path` (or its overlay) so refresh/options-change preserves + -- the user's pinned file instead of snapping to `entries[1].files[1]`. + local first = self.entries[1] + local target = self.parent:pick_entry_target(first) or first.files[1] + self:set_cur_item({ first, target }) return self.cur_item[2] end @@ -453,6 +517,21 @@ function FileHistoryPanel:set_file_by_offset(offset) local entry_idx = utils.vec_indexof(self.entries, entry) local file_idx = utils.vec_indexof(entry.files, file) + -- pin_local overlays (transient FileEntries built by + -- `_resolve_pinned_target` for commits that don't touch the pinned + -- path) aren't in `entry.files`, so `vec_indexof` returns -1 and + -- offset navigation would silently no-op when the user is standing + -- on an overlay. Treat the overlay's position as `entry.files[1]` + -- so j/k/next-item/prev-item still advance the cursor. + if + entry_idx ~= -1 + and file_idx == -1 + and entry._pin_overlays + and entry._pin_overlays[file.path] == file + then + file_idx = 1 + end + if entry_idx ~= -1 and file_idx ~= -1 then local wrap = config.get_config().wrap_entries local next_entry, next_file = @@ -471,7 +550,10 @@ function FileHistoryPanel:set_file_by_offset(offset) return self.cur_item[2] end else - self:set_cur_item({ self.entries[1], self.entries[1].files[1] }) + -- See the bootstrap branch above for the pin_local rationale. + local first = self.entries[1] + local target = self.parent:pick_entry_target(first) or first.files[1] + self:set_cur_item({ first, target }) return self.cur_item[2] end end @@ -500,20 +582,27 @@ function FileHistoryPanel:highlight_item(item) else ---@cast item FileEntry for _, comp_struct in ipairs(self.components.log.entries) do - local i = utils.vec_indexof(comp_struct.comp.context.files --[[@as FileEntry[] ]], item) + local entry = comp_struct.comp.context --[[@as LogEntry ]] + local i = utils.vec_indexof(entry.files --[[@as FileEntry[] ]], item) if i ~= -1 then if self.single_file then pcall(api.nvim_win_set_cursor, self.winid, { comp_struct.comp.lstart + 1, 0 }) else - if comp_struct.comp.context.folded then - comp_struct.comp.context.folded = false + if entry.folded then + entry.folded = false self:render() self:redraw() end pcall(api.nvim_win_set_cursor, self.winid, { comp_struct.comp.lstart + i + 1, 0 }) end + elseif entry._pin_overlays and entry._pin_overlays[item.path] == item then + -- pin_local overlays are transient FileEntries that aren't rendered + -- as their own row, so there's no file-line to land on. Park the + -- cursor on the entry header instead so commit-navigation actions + -- still move the visible selection in lock-step with the diff. + pcall(api.nvim_win_set_cursor, self.winid, { comp_struct.comp.lstart, 0 }) end end end diff --git a/lua/diffview/scene/views/file_history/file_history_view.lua b/lua/diffview/scene/views/file_history/file_history_view.lua index f97e3881..3da37d1b 100644 --- a/lua/diffview/scene/views/file_history/file_history_view.lua +++ b/lua/diffview/scene/views/file_history/file_history_view.lua @@ -1,14 +1,17 @@ local async = require("diffview.async") +local debounce = require("diffview.debounce") local lazy = require("diffview.lazy") local oop = require("diffview.oop") local CommitLogPanel = lazy.access("diffview.ui.panels.commit_log_panel", "CommitLogPanel") ---@type CommitLogPanel|LazyModule local EventName = lazy.access("diffview.events", "EventName") ---@type EventName|LazyModule +local FileEntry = lazy.access("diffview.scene.file_entry", "FileEntry") ---@type FileEntry|LazyModule local FileHistoryPanel = lazy.access("diffview.scene.views.file_history.file_history_panel", "FileHistoryPanel") ---@type FileHistoryPanel|LazyModule local JobStatus = lazy.access("diffview.vcs.utils", "JobStatus") ---@type JobStatus|LazyModule local LogEntry = lazy.access("diffview.vcs.log_entry", "LogEntry") ---@type LogEntry|LazyModule local File = lazy.access("diffview.vcs.file", "File") ---@type vcs.File|LazyModule +local RevType = lazy.access("diffview.vcs.rev", "RevType") ---@type RevType|LazyModule local StandardView = lazy.access("diffview.scene.views.standard.standard_view", "StandardView") ---@type StandardView|LazyModule local config = lazy.require("diffview.config") ---@module "diffview.config" local utils = lazy.require("diffview.utils") ---@module "diffview.utils" @@ -24,11 +27,18 @@ local M = {} ---@field panel FileHistoryPanel ---@field commit_log_panel CommitLogPanel ---@field valid boolean +---@field pin_local? boolean # When true, the b-window stays bound to the working-tree LOCAL buffer across log navigation; resolved from `--pin-local` or `view.file_history.pin_local`. +---@field pinned_path? string # Working-tree path the b-window is pinned to. Seeded from `path_args[1]` for single-file pinning; the cursor follower updates it when the user highlights a file row in multi-file mode. +---@field _pinned_cursor_follow? CancellableFn # Debounced CursorMoved handler installed in pin_local mode; closed in `close()` to release the underlying uv timer. +---@field _pinned_b_files table # View-owned cache of working-tree `vcs.File` instances keyed by path. Each pinned-mode b-window across the entire history reuses the entry for its path, so identity is stable across panel refreshes; entry destruction skips these files (see `Diff2*Pinned.shared_symbols`) and the view destroys them in `close()`. local FileHistoryView = oop.create_class("FileHistoryView", StandardView.__get()) function FileHistoryView:init(opt) self.valid = false self.adapter = opt.adapter + self.pin_local = opt.pin_local + self.pinned_path = opt.pinned_path + self._pinned_b_files = {} self:super({ panel = FileHistoryPanel({ @@ -44,6 +54,7 @@ end function FileHistoryView:post_open() self:init_event_listeners() + self:_install_pinned_cursor_follower() self.commit_log_panel = CommitLogPanel(self, self.adapter, { name = ("diffview://%s/log/%d/%s"):format(self.adapter.ctx.dir, self.tabpage, "commit_log"), @@ -72,10 +83,24 @@ function FileHistoryView:close() if not self.closing:check() then self.closing:send() - for _, entry in ipairs(self.panel.entries or {}) do - entry:destroy() + -- Cancel any pending debounced fire so a `CursorMoved` that already + -- queued a `vim.schedule` callback can't run after teardown begins. + -- Releasing the timer handle is deferred until after `super:close()` + -- has destroyed the panel and unsubscribed the autocmd: the wrapper + -- restarts the timer on every invocation, so a `CursorMoved` + -- reaching a still-subscribed listener after the timer was closed + -- would error on the closed uv handle. + if self._pinned_cursor_follow then + self._pinned_cursor_follow:cancel() end + -- Entry teardown (including `_pin_overlays`) is owned by + -- `FileHistoryPanel:destroy()`, called from `StandardView:close()` below. + -- The pinned working-tree files belong to the view, not to entries + -- (every pinned-mode b-window references them via + -- `Diff2*Pinned.shared_symbols`), so the view detaches them here. + self:_destroy_pinned_b_files() + -- Clean up LOCAL buffers created by diffview that the user didn't have open before. if config.get_config().clean_up_buffers then for bufnr, _ in pairs(File.created_bufs) do @@ -99,6 +124,13 @@ function FileHistoryView:close() self.commit_log_panel:destroy() FileHistoryView.super_class.close(self) + + -- `super:close()` destroyed the panel and unsubscribed the + -- `CursorMoved` listener; the timer handle is now safe to release. + if self._pinned_cursor_follow then + self._pinned_cursor_follow:close() + self._pinned_cursor_follow = nil + end end end @@ -107,6 +139,24 @@ function FileHistoryView:cur_file() return self.panel.cur_item[2] end +---Tear down the pin_local cache. Called from `close()` and exposed as a +---method so the destroy-policy contract can be exercised in a test. +--- +---Each cached `vcs.File` is detached with `force=false`: pinned b-files are +---LOCAL and their underlying buffer is typically the user's pre-existing +---working-tree buffer (possibly with unsaved edits). `File:destroy(true)` +---would unconditionally delete that buffer, so we never use it here. The +---diffview-created subset is reaped separately by the `clean_up_buffers` +---block in `close()`, which consults `File.created_bufs` and skips +---modified or cross-tab buffers. +---@private +function FileHistoryView:_destroy_pinned_b_files() + for _, file in pairs(self._pinned_b_files) do + file:destroy(false) + end + self._pinned_b_files = {} +end + ---@private ---@param self FileHistoryView ---@param file FileEntry @@ -115,7 +165,12 @@ FileHistoryView._set_file = async.void(function(self, file) self.panel:redraw() vim.cmd("redraw") - self.cur_layout:detach_files() + -- Use the swap variant so pinned layouts can keep the pinned (b) window + -- bound across the swap; tab-leave / view-close still call `detach_files` + -- and tear down everything. Passing the next entry lets pinned variants + -- compare the upcoming b-file against the current one and detach when + -- they differ (multi-file pinning crossing a row to a different path). + self.cur_layout:detach_files_for_swap(file) local cur_entry = self.cur_entry self.emitter:emit("file_open_pre", file, cur_entry) self.nulled = false @@ -180,7 +235,8 @@ end ---@param self FileHistoryView ---@param file FileEntry ---@param focus? boolean -FileHistoryView.set_file = async.void(function(self, file, focus) +---@param keep_cursor? boolean # When true, skip the cur-entry fold and panel highlight that would otherwise move the cursor. The pinned cursor follower passes this so a header-driven diff swap doesn't snap the cursor onto a file row (which would also re-trigger CursorMoved). +FileHistoryView.set_file = async.void(function(self, file, focus, keep_cursor) ---@diagnostic disable: invisible self:ensure_layout() @@ -192,12 +248,26 @@ FileHistoryView.set_file = async.void(function(self, file, focus) local cur_entry = self.panel.cur_item[1] if entry then - if cur_entry and entry ~= cur_entry then + -- Centralised pinned_path update: any "this file is now active" + -- transition (cursor follower, commit-nav, file-row navigation, + -- programmatic switches) flows through here, so updating + -- `pinned_path` once at the canonical write point keeps it in sync + -- without each caller having to remember. Skipped in single-file + -- mode where `pinned_path` is the rename anchor (the working-tree + -- name) and may legitimately differ from the entry's commit-side + -- name; the adapter resolves the rename in that mode. + if self.pin_local and not self.panel.single_file then + self.pinned_path = file.path + end + + if not keep_cursor and cur_entry and entry ~= cur_entry then self.panel:set_entry_fold(cur_entry, false) end self.panel:set_cur_item({ entry, file }) - self.panel:highlight_item(file) + if not keep_cursor then + self.panel:highlight_item(file) + end self.nulled = false await(self:_set_file(file)) @@ -238,6 +308,191 @@ function FileHistoryView:init_event_listeners() end end +-- When `pin_local` is true, follow the panel cursor so any movement (j/k, +-- gg, search, etc.) updates the right-side diff against the highlighted +-- entry without the user having to confirm with . The b-side stays +-- pinned via Diff2HorPinned/Diff2VerPinned, so only the commit-side window +-- actually rebuilds. +function FileHistoryView:_install_pinned_cursor_follower() + if not self.pin_local then + return + end + + -- Debounce coalesces rapid j/k presses into a single layout update; the + -- 75ms window is short enough that single keystrokes feel immediate. + -- Stored on `self` so `close()` can release the underlying uv timer. + self._pinned_cursor_follow = debounce.debounce_trailing(75, false, function() + if not (self.panel:is_focused() and self:is_valid()) then + return + end + + local item = self.panel:get_item_at_cursor() + if not item then + return + end + + local target + if LogEntry.__get():ancestorof(item) then + ---@cast item LogEntry + target = self:_resolve_pinned_target(item) + else + ---@cast item FileEntry + -- File row: navigate to this file. `set_file` will update + -- `pinned_path` for us (centralised invariant), so subsequent + -- commit-header navigation follows the path the user just landed on. + target = item + end + + if not target or target == self:cur_file() then + return + end + + -- `keep_cursor=true` because moving the panel cursor here would + -- re-trigger CursorMoved and potentially snap off a header onto a file + -- row when the previous entry gets folded. + self:set_file(target, false, true) + end) + + self.panel:on_autocmd("CursorMoved", { + callback = self._pinned_cursor_follow --[[@as function ]], + }) +end + +---Resolve, lazily constructing on first use, the working-tree `vcs.File` +---for `path`. The instance is shared across every pinned-mode FileEntry the +---view ever shows: each entry's b-window references the same `vcs.File`, so +---navigation across commits never swaps the b-side and the underlying +---buffer's diffview attachments survive every refresh. The view destroys +---all cached entries in `close()`; entry teardown skips them via +---`Layout.shared_symbols`. +---@param path string Working-tree path the b-side should pin to. +---@return vcs.File +function FileHistoryView:get_pinned_b_file(path) + local cached = self._pinned_b_files[path] + if cached then + return cached + end + + local file = File.__get()({ + adapter = self.adapter, + path = path, + kind = "working", + rev = self.adapter.Rev(RevType.__get().LOCAL), + }) --[[@as vcs.File ]] + + self._pinned_b_files[path] = file + return file +end + +---When the cursor is on a `LogEntry` header in pinned mode, find the +---`FileEntry` in that entry whose `path` matches `self.pinned_path`. If the +---commit didn't touch the pinned file, build a transient overlay so the LHS +---updates to that commit's snapshot of the file while the RHS keeps the +---working-tree buffer. +---@private +---@param entry LogEntry +---@return FileEntry? +function FileHistoryView:_resolve_pinned_target(entry) + local pinned_path = self.pinned_path + + -- Bootstrap: with no pinned path yet, fall back to the entry's first file + -- and let the next file-row interaction lock in a `pinned_path`. + if not pinned_path then + return entry.files[1] + end + + -- Single-file history follows one logical file across renames, so the + -- entry has exactly one `FileEntry` and it's the right target regardless + -- of path. Path-matching against `pinned_path` (the working-tree name) + -- would miss commits older than a rename, where `f.path` is the old name + -- and the overlay path then misclassifies the file as deleted. + if entry.single_file then + return entry.files[1] + end + + for _, f in ipairs(entry.files) do + if f.path == pinned_path then + return f + end + end + + -- Reuse a cached overlay so repeated visits don't re-allocate; the cache + -- lives on the entry so it's torn down with the LogEntry on view close. + entry._pin_overlays = entry._pin_overlays or {} + if entry._pin_overlays[pinned_path] then + local cached = entry._pin_overlays[pinned_path] + -- Lazy layout sync: a `set_layout` / `cycle_layout` since the overlay + -- was built may have moved the view to a different pinned `Diff2` + -- orientation. Convert the overlay on-demand so navigating back + -- doesn't silently flip the view to the stale orientation. + local active_class = self.cur_layout and self.cur_layout.class + if active_class and cached.layout.class ~= active_class then + cached:convert_layout(active_class --[[@as Layout ]]) + end + return cached + end + + local sample = entry.files[1] + if not sample then + return nil + end + + -- Probe whether `pinned_path` existed at `sample.revs.a` (the commit + -- itself in pin_local mode). If it didn't (e.g. the user navigated to a + -- commit before the file was introduced), mark the overlay status as "D" + -- so the pinned layout's `should_null` nulls the a-side and the adapter + -- doesn't try to fetch the missing blob, which would error with + -- "Failed to create diff buffer". The probe is wrapped in pcall so a + -- third-party adapter that doesn't implement `file_exists_at_rev` falls + -- back to "M" rather than crashing the resolver. + local status = "M" + local rev_a = sample.revs.a + if rev_a and rev_a.commit then + local ok, exists = + pcall(self.adapter.file_exists_at_rev, self.adapter, pinned_path, rev_a.commit) + if ok and not exists then + status = "D" + end + end + + -- Use the layout class the user is actively viewing, so a `set_layout` / + -- `cycle_layout` to a non-default pinned orientation isn't undone the + -- moment the user lands on a commit that needs an overlay. Falls back to + -- the configured default for the very first overlay built before any view + -- layout is attached. + local overlay_layout = (self.cur_layout and self.cur_layout.class) or self:get_default_layout() + local overlay = FileEntry.__get().with_layout(overlay_layout, { + adapter = self.adapter, + path = pinned_path, + oldpath = nil, + status = status, + stats = nil, + kind = "working", + commit = entry.commit, + revs = { + a = sample.revs.a, + b = sample.revs.b, + }, + pinned_b_file = self:get_pinned_b_file(pinned_path), + }) + + entry._pin_overlays[pinned_path] = overlay + return overlay +end + +---Pick the FileEntry to display when navigating to `entry`. In pin_local +---mode this routes through `_resolve_pinned_target` so commit-navigation +---and post-refresh bootstrap preserve the pinned path (possibly via a +---transient overlay) rather than snapping back to `entry.files[1]`. +---@param entry LogEntry +---@return FileEntry? +function FileHistoryView:pick_entry_target(entry) + if self.pin_local then + return self:_resolve_pinned_target(entry) + end + return entry.files[1] +end + ---Infer the current selected file. If the file panel is focused: return the ---file entry under the cursor. Otherwise return the file open in the view. ---Returns nil if no file is open in the view, or there is no entry under the @@ -249,7 +504,10 @@ function FileHistoryView:infer_cur_file() if LogEntry.__get():ancestorof(item) then ---@cast item LogEntry - return item.files[1] + -- In pinned mode the displayed diff is whichever FileEntry + -- `_resolve_pinned_target` picked (possibly a transient overlay), so + -- align action helpers with the visible file rather than `files[1]`. + return self:pick_entry_target(item) end return item --[[@as FileEntry ]] @@ -265,21 +523,107 @@ function FileHistoryView:is_valid() end ---@override -function FileHistoryView.get_default_layout_name() +function FileHistoryView:get_default_layout_name() return config.get_config().view.file_history.layout end +-- Map a non-pinned Diff2 layout name to its pinned counterpart. Pinned +-- layouts share window orientation with their unpinned siblings; we just +-- re-route the layout class so the b-window keeps its file across entry +-- swaps. Names that have no pinned variant fall through unchanged. +local pinned_variant = { + diff2_horizontal = "diff2_horizontal_pinned", + diff2_vertical = "diff2_vertical_pinned", +} + +-- Inverse of `pinned_variant`. A pinned class is only valid when adapters +-- inject `revs.a = COMMIT` (which only happens under `pin_local`); applied +-- to a parent-vs-commit history the pinned `should_null` mis-classifies +-- status "A"/"?" and the adapter then fails to `show :`. The +-- user-config path is already gated by `config`'s `standard_layouts` +-- validation (pinned names aren't in the schema's allow-list), but we +-- still fold pinned → unpinned here as belt-and-suspenders for any other +-- caller (tests, future code) that reaches `get_default_layout` with a +-- pinned name and `pin_local` off. +local unpinned_variant = {} +for unpinned, pinned in pairs(pinned_variant) do + unpinned_variant[pinned] = unpinned +end + ---@override ---@return Layout # (class) The default layout class. -function FileHistoryView.get_default_layout() - local name = FileHistoryView.get_default_layout_name() +function FileHistoryView:get_default_layout() + local name = self:get_default_layout_name() if name == -1 then - return FileHistoryView.get_default_diff2() + name = FileHistoryView.get_default_diff2().name + end + + if self.pin_local then + -- pin_local needs a pinned-Diff2 layout: only those declare + -- `shared_symbols = { "b" }`, which is what keeps `FileEntry:destroy` + -- from tearing down the view-owned working-tree file on every refresh. + -- If the user's configured layout doesn't have a pinned variant + -- (e.g. `diff1_inline`), fall back to the default Diff2 so the + -- shared-b-side mechanism actually engages. + if not pinned_variant[name] then + name = FileHistoryView.get_default_diff2().name + end + name = pinned_variant[name] + else + name = unpinned_variant[name] or name end return config.name_to_layout(name --[[@as string ]]) end +---Inverse of `resolve_pinned_layout`: map a pinned class to its unpinned +---sibling, returning the input unchanged for any other class. Used by +---`cycle_layout` to find the current layout's position in the unpinned +---cycle list (the cycle list contains `Diff2Hor`/`Diff2Ver`, but in +---pin_local mode the active class is `Diff2*Pinned`, so a direct +---`vec_indexof` would always miss and stick the user on the first layout). +---@param layout_class Layout (class) +---@return Layout (class) +function FileHistoryView:unpinned_layout(layout_class) + local sibling = unpinned_variant[layout_class.name] + if not sibling then + return layout_class + end + return config.name_to_layout(sibling --[[@as string ]]) +end + +---Map an arbitrary layout class to the right one for this view's pin_local +---state. Used by `cycle_layout` / `set_layout` so neither action can drop a +---pin_local FileHistoryView into an unpinned `Diff2` (which would cause +---`FileEntry:destroy` to tear down the view-owned working-tree file once +---per entry, and would untie the b-window from its shared LOCAL buffer). +---When `pin_local` is off, returns the input unchanged. When on: +--- - already a pinned variant: returns it unchanged (preserves the user's +--- chosen orientation). +--- - has a pinned sibling (e.g. `diff2_horizontal`): returns the pinned +--- sibling. +--- - no pinned variant (e.g. `diff1_inline`): returns the configured +--- default Diff2's pinned form, so the shared-b mechanism still engages. +---@param layout_class Layout (class) +---@return Layout (class) +function FileHistoryView:resolve_pinned_layout(layout_class) + if not self.pin_local then + return layout_class + end + + local name = layout_class.name + + if unpinned_variant[name] then + return layout_class + end + + if not pinned_variant[name] then + name = FileHistoryView.get_default_diff2().name + end + + return config.name_to_layout(pinned_variant[name] --[[@as string ]]) +end + M.FileHistoryView = FileHistoryView return M diff --git a/lua/diffview/scene/views/file_history/listeners.lua b/lua/diffview/scene/views/file_history/listeners.lua index 637499e8..a627ebf2 100644 --- a/lua/diffview/scene/views/file_history/listeners.lua +++ b/lua/diffview/scene/views/file_history/listeners.lua @@ -67,7 +67,7 @@ return function(view) end local commit = item.commit - if not commit then + if not commit or not commit.hash then return end @@ -90,13 +90,21 @@ return function(view) select_first_entry = function() local entry = view.panel.entries[1] if entry and #entry.files > 0 then - view:set_file(entry.files[1]) + -- `pick_entry_target` routes through `_resolve_pinned_target` in + -- pin_local mode so the snap-to-first-entry preserves the pinned + -- path instead of jumping to `files[1]` of that commit. + view:set_file(view:pick_entry_target(entry) or entry.files[1]) end end, select_last_entry = function() local entry = view.panel.entries[#view.panel.entries] if entry and #entry.files > 0 then - view:set_file(entry.files[#entry.files]) + -- Non-pin_local: open the LAST file in the last commit (the action's + -- historical contract). In pin_local mode the user expects the + -- pinned file (or its overlay) regardless of position in the + -- commit, so route through `pick_entry_target` only when pinned. + local target = view.pin_local and view:pick_entry_target(entry) or entry.files[#entry.files] + view:set_file(target) end end, select_next_commit = function() @@ -119,7 +127,8 @@ return function(view) end end local next_entry = view.panel.entries[next_idx] - view:set_file(next_entry.files[1]) + -- See `select_first_entry` for the pin_local rationale. + view:set_file(view:pick_entry_target(next_entry) or next_entry.files[1]) end, select_prev_commit = function() local cur_entry = view.panel.cur_item[1] @@ -141,19 +150,24 @@ return function(view) end end local next_entry = view.panel.entries[next_idx] - view:set_file(next_entry.files[1]) + -- See `select_first_entry` for the pin_local rationale. + view:set_file(view:pick_entry_target(next_entry) or next_entry.files[1]) end, ---Navigate to next file within the current commit. next_entry_in_commit = function() local cur_entry = view.panel.cur_item[1] local cur_file = view.panel.cur_item[2] - if not cur_entry or not cur_file then + if not cur_entry or not cur_file or #cur_entry.files == 0 then return end local file_idx = utils.vec_indexof(cur_entry.files, cur_file) if file_idx == -1 then - return + -- pin_local overlay (or any FileEntry not in `cur_entry.files`): + -- treat the overlay as if it were files[1] for navigation. Without + -- this, j/`]f` would silently no-op for exactly the commits where + -- pin_local needs the overlay path. + file_idx = 1 end local next_idx @@ -171,13 +185,14 @@ return function(view) prev_entry_in_commit = function() local cur_entry = view.panel.cur_item[1] local cur_file = view.panel.cur_item[2] - if not cur_entry or not cur_file then + if not cur_entry or not cur_file or #cur_entry.files == 0 then return end local file_idx = utils.vec_indexof(cur_entry.files, cur_file) if file_idx == -1 then - return + -- See `next_entry_in_commit` for the overlay rationale. + file_idx = 1 end local prev_idx @@ -238,7 +253,7 @@ return function(view) local file = view:infer_cur_file() if file then local entry = view.panel:find_entry(file) - if entry then + if entry and entry.commit and entry.commit.hash then view.commit_log_panel:update(view.adapter.Rev.to_range(entry.commit.hash)) end end @@ -324,7 +339,7 @@ return function(view) copy_hash = function() if view.panel:is_focused() then local item = view.panel:get_item_at_cursor() - if item then + if item and item.commit and item.commit.hash then local reg = vim.v.register vim.fn.setreg(reg, item.commit.hash) local reg_desc = (reg == '"' or reg == "") and "the default register" @@ -338,7 +353,7 @@ return function(view) if not item then item = view.panel.cur_item[1] end - if not item or not item.commit then + if not item or not item.commit or not item.commit.hash then return end @@ -370,7 +385,7 @@ return function(view) end, restore_entry = async.void(function() local item = view:infer_cur_file() - if not item then + if not item or not item.commit or not item.commit.hash then return end diff --git a/lua/diffview/scene/views/file_history/render.lua b/lua/diffview/scene/views/file_history/render.lua index acb3b35e..a1e6912d 100644 --- a/lua/diffview/scene/views/file_history/render.lua +++ b/lua/diffview/scene/views/file_history/render.lua @@ -291,7 +291,7 @@ local function render_entries(panel, parent, entries, updating) end comp:ln() - perf:lap("entry " .. entry.commit.hash:sub(1, 7)) + perf:lap("entry " .. (entry.commit.hash and entry.commit.hash:sub(1, 7) or "")) if not entry.single_file and not entry.folded then render_files(entry_struct.files.comp, entry.files) diff --git a/lua/diffview/tests/functional/file_entry_spec.lua b/lua/diffview/tests/functional/file_entry_spec.lua index 8e15f08a..6039ebf4 100644 --- a/lua/diffview/tests/functional/file_entry_spec.lua +++ b/lua/diffview/tests/functional/file_entry_spec.lua @@ -212,6 +212,219 @@ describe("diffview.scene.file_entry", function() assert.are_not.equal(shared, entry.layout.a.file) end) + -- Carve-out: a status="D" entry whose working-tree path is also missing + -- must not reuse the shared `pinned_b_file`, otherwise the b-side opens + -- an empty/editable buffer for the missing path. `with_layout` falls back + -- to a fresh nulled file; the shared instance is preserved for entries + -- where the LOCAL path still exists (e.g. overlay against a commit that + -- predates the file's introduction). + it("with_layout falls back to a nulled b-file when the LOCAL path is missing", function() + local Diff2HorPinned = require("diffview.scene.layouts.diff_2_hor_pinned").Diff2HorPinned + + local missing_path = vim.fn.tempname() .. "-does-not-exist" + local shared = { + path = "foo.txt", + absolute_path = missing_path, + } --[[@as vcs.File ]] + local fake_adapter = { ctx = { toplevel = "/" } } + local rev_a = { + type = RevType.COMMIT, + commit = "abc1234567", + object_name = function(_, n) + return ("abc1234567"):sub(1, n or 10) + end, + } + -- Mock `object_name` on the LOCAL rev too: when the fall-through builds + -- a fresh nulled b-file, `vcs.File:init` evaluates the winbar's + -- `object_path` regardless of rev type (the value is unused for LOCAL, + -- but the table-construction happens up front). + local rev_b = { + type = RevType.LOCAL, + object_name = function(_, n) + return ("0"):rep(n or 10) + end, + } + + local entry = FileEntry.with_layout(Diff2HorPinned, { + adapter = fake_adapter, + path = "foo.txt", + oldpath = nil, + status = "D", + kind = "working", + revs = { a = rev_a, b = rev_b }, + pinned_b_file = shared, + }) + + assert.are_not.equal(shared, entry.layout.b.file) + assert.is_true(entry.layout.b.file.nulled) + end) + + -- Overlay: pinned_path exists in the working tree but isn't in this + -- commit. `_resolve_pinned_target` marks status="D" so the layout nulls + -- the a-side (file absent from the commit), but the b-side must still + -- show the LOCAL working-tree file. The disk check in `with_layout` is + -- what preserves this case. + it("with_layout reuses pinned_b_file on status=D when the LOCAL path exists", function() + local Diff2HorPinned = require("diffview.scene.layouts.diff_2_hor_pinned").Diff2HorPinned + + local existing_path = vim.fn.tempname() + local f = assert(io.open(existing_path, "w")) + f:write("present\n") + f:close() + + local shared = { + path = "foo.txt", + absolute_path = existing_path, + } --[[@as vcs.File ]] + local fake_adapter = { ctx = { toplevel = "/" } } + local rev_a = { + type = RevType.COMMIT, + commit = "abc1234567", + object_name = function(_, n) + return ("abc1234567"):sub(1, n or 10) + end, + } + local rev_b = { type = RevType.LOCAL } + + local ok, err = pcall(function() + local entry = FileEntry.with_layout(Diff2HorPinned, { + adapter = fake_adapter, + path = "foo.txt", + oldpath = nil, + status = "D", + kind = "working", + revs = { a = rev_a, b = rev_b }, + pinned_b_file = shared, + }) + + assert.equals(shared, entry.layout.b.file) + end) + + pcall(vim.fn.delete, existing_path) + if not ok then + error(err) + end + end) + + -- The fallback nulled file built by `with_layout` for a status="D" + -- entry whose LOCAL path is gone is constructed for a window whose + -- symbol is in `Diff2*Pinned.shared_symbols`. `Layout:owned_files()` + -- intentionally skips shared symbols (the view owns them), so without + -- explicit per-FileEntry tracking these one-off fallbacks would never + -- be destroyed -- a slow buffer/Lua-object leak for any history + -- containing deleted-and-removed paths. `with_layout` now tracks them + -- in `_extra_owned` so `FileEntry:destroy` can release them. + it("with_layout tracks the fallback nulled b-file as an extra-owned file", function() + local Diff2HorPinned = require("diffview.scene.layouts.diff_2_hor_pinned").Diff2HorPinned + + local missing_path = vim.fn.tempname() .. "-does-not-exist" + local shared = { + path = "foo.txt", + absolute_path = missing_path, + } --[[@as vcs.File ]] + local fake_adapter = { ctx = { toplevel = "/" } } + local rev_a = { + type = RevType.COMMIT, + commit = "abc1234567", + object_name = function(_, n) + return ("abc1234567"):sub(1, n or 10) + end, + } + local rev_b = { + type = RevType.LOCAL, + object_name = function(_, n) + return ("0"):rep(n or 10) + end, + } + + local entry = FileEntry.with_layout(Diff2HorPinned, { + adapter = fake_adapter, + path = "foo.txt", + oldpath = nil, + status = "D", + kind = "working", + revs = { a = rev_a, b = rev_b }, + pinned_b_file = shared, + }) + + -- Fallback created (so the b-window doesn't reuse the shared instance). + assert.are_not.equal(shared, entry.layout.b.file) + -- Fallback is tracked so destroy() can release it; the layout's + -- owned_files() would otherwise skip it via shared_symbols. + eq(1, #entry._extra_owned) + eq(entry.layout.b.file, entry._extra_owned[1]) + end) + + -- Identity case: when the b-side reuses the supplied `pinned_b_file`, + -- the FileEntry must NOT track it -- the view owns the shared instance + -- and destroying it from a per-entry teardown would wipe state out + -- from under every other entry. + it("with_layout does not track the shared pinned_b_file as extra-owned", function() + local Diff2HorPinned = require("diffview.scene.layouts.diff_2_hor_pinned").Diff2HorPinned + + local shared = { path = "foo.txt" } --[[@as vcs.File ]] + local fake_adapter = { ctx = { toplevel = "/" } } + local rev_a = { + type = RevType.COMMIT, + commit = "abc1234567", + object_name = function(_, n) + return ("abc1234567"):sub(1, n or 10) + end, + } + local rev_b = { type = RevType.LOCAL } + + local entry = FileEntry.with_layout(Diff2HorPinned, { + adapter = fake_adapter, + path = "old/foo.txt", + oldpath = nil, + status = "M", + kind = "working", + revs = { a = rev_a, b = rev_b }, + pinned_b_file = shared, + }) + + eq(shared, entry.layout.b.file) + eq(0, #entry._extra_owned) + end) + + -- Lifecycle: `FileEntry:destroy` must reach the extra-owned fallbacks + -- (the layout's `owned_files()` doesn't expose them). + it("destroy() releases extra-owned fallback files", function() + local destroyed_extra = false + local extra = { + destroy = function() + destroyed_extra = true + end, + } + local layout_destroyed = false + local layout = { + owned_files = function() + return {} + end, + destroy = function() + layout_destroyed = true + end, + } + + local entry = FileEntry({ + adapter = { ctx = { toplevel = "/tmp" } }, + path = "a.txt", + oldpath = nil, + revs = {}, + layout = layout, + status = "M", + stats = {}, + kind = "working", + _extra_owned = { extra }, + }) + + entry:destroy() + + assert.is_true(destroyed_extra) + assert.is_true(layout_destroyed) + eq(0, #entry._extra_owned) + end) + it("forwards force flag to contained files when destroyed", function() local seen = {} local layout_destroyed = false diff --git a/lua/diffview/tests/functional/hg_adapter_spec.lua b/lua/diffview/tests/functional/hg_adapter_spec.lua index ce132e7f..846d922e 100644 --- a/lua/diffview/tests/functional/hg_adapter_spec.lua +++ b/lua/diffview/tests/functional/hg_adapter_spec.lua @@ -340,6 +340,79 @@ describe("diffview.vcs.adapters.hg", function() end) end) + describe("file_exists_at_rev", function() + local repo + + before_each(function() + if not hg_available() then + pending("hg not installed") + return + end + repo = create_hg_repo() + end) + + after_each(function() + if repo then + repo.cleanup() + end + end) + + it("returns true for files tracked at the revision", function() + if not hg_available() then + pending("hg not installed") + return + end + + repo.write("kept.txt", "v1\n") + repo.hg({ "add", "kept.txt" }) + repo.hg({ "commit", "-m", "init", "-u", "test " }) + + local adapter = repo.adapter() + assert.is_true(adapter:file_exists_at_rev("kept.txt", "tip")) + end) + + it("returns false for paths absent at the revision", function() + if not hg_available() then + pending("hg not installed") + return + end + + repo.write("kept.txt", "v1\n") + repo.hg({ "add", "kept.txt" }) + repo.hg({ "commit", "-m", "init", "-u", "test " }) + + local adapter = repo.adapter() + assert.is_false(adapter:file_exists_at_rev("never_added.txt", "tip")) + end) + + -- Regression: an earlier commit's resolver fell through to status="M" + -- for hg because `HgAdapter` didn't implement the probe, so navigating + -- to a commit before the pinned file was added tried to `hg cat` a + -- missing file and the diff buffer creation failed. The probe now lets + -- the resolver mark the overlay status="D" and null the a-side. + it("returns false for paths added in a later revision", function() + if not hg_available() then + pending("hg not installed") + return + end + + repo.write("first.txt", "v1\n") + repo.hg({ "add", "first.txt" }) + repo.hg({ "commit", "-m", "first", "-u", "test " }) + + local first_rev = repo.hg({ "log", "--template={node}", "--rev", "tip" }) + + repo.write("later.txt", "added later\n") + repo.hg({ "add", "later.txt" }) + repo.hg({ "commit", "-m", "second", "-u", "test " }) + + local adapter = repo.adapter() + -- `later.txt` exists at tip but not at the first commit. + assert.is_true(adapter:file_exists_at_rev("later.txt", "tip")) + assert.is_false(adapter:file_exists_at_rev("later.txt", first_rev)) + end) + end) + -- See `git_adapter_spec`'s `history_scope` block for the rationale: the -- scope is the single source of truth for "single-file?" + "which path?", -- and must agree with `is_single_file()`'s `<2` semantics so `pin_local` diff --git a/lua/diffview/tests/functional/layouts_spec.lua b/lua/diffview/tests/functional/layouts_spec.lua index 2cc55b57..c9f4414f 100644 --- a/lua/diffview/tests/functional/layouts_spec.lua +++ b/lua/diffview/tests/functional/layouts_spec.lua @@ -614,8 +614,9 @@ describe("diffview.scene.layouts.diff_2_*_pinned should_null", function() -- pin_local sets revs.a to the commit itself (not its parent), so the -- standard parent-vs-commit semantics don't apply on the a-side: the -- file is missing iff it doesn't exist in this commit, i.e. status "D". - -- (sym "b" isn't covered: pinned-mode b-side files are injected by the - -- view's cache and never go through `try_should_null`.) + -- (sym "b" defers to `Diff2.should_null`; `with_layout` consults it only + -- to decide whether to fall back from the shared `pinned_b_file` when + -- the LOCAL path is missing on disk.) it("nulls window a only when the file is absent from the commit (status D)", function() local commit = { type = RevType.COMMIT } for _, cls in ipairs({ Diff2HorPinned, Diff2VerPinned }) do diff --git a/lua/diffview/tests/functional/panel_spec.lua b/lua/diffview/tests/functional/panel_spec.lua index 565128c0..67454680 100644 --- a/lua/diffview/tests/functional/panel_spec.lua +++ b/lua/diffview/tests/functional/panel_spec.lua @@ -1053,4 +1053,65 @@ describe("diffview.ui.panel", function() eq(nil, panel:get_autosize_components()) end) end) + + describe("Panel on_autocmd dispatch", function() + local Panel = require("diffview.ui.panel").Panel + + -- Without buffer-matching for non Win*/Buf* events, subscribers to + -- events like `CursorMoved` would never be invoked: the dispatcher + -- defaults `win_match` and `buf_match` to nil and the gating check + -- silently swallows the event. The pinned-mode cursor follower in + -- `FileHistoryView` relies on this dispatch path. + it("dispatches CursorMoved to subscribers matching the panel buffer", function() + local panel = Panel({ + bufname = "TestOnAutocmdPanel", + config = Panel.default_config_split, + }) + panel.bufid = vim.api.nvim_create_buf(false, true) + + local fired = 0 + panel:on_autocmd("CursorMoved", { + callback = function() + fired = fired + 1 + end, + }) + + Panel.au.emitter:emit("CursorMoved", { + event = "CursorMoved", + buf = panel.bufid, + }) + + eq(1, fired) + + pcall(vim.api.nvim_buf_delete, panel.bufid, { force = true }) + panel:destroy() + end) + + it("ignores CursorMoved events fired in other buffers", function() + local panel = Panel({ + bufname = "TestOnAutocmdPanelOther", + config = Panel.default_config_split, + }) + panel.bufid = vim.api.nvim_create_buf(false, true) + local other_buf = vim.api.nvim_create_buf(false, true) + + local fired = 0 + panel:on_autocmd("CursorMoved", { + callback = function() + fired = fired + 1 + end, + }) + + Panel.au.emitter:emit("CursorMoved", { + event = "CursorMoved", + buf = other_buf, + }) + + eq(0, fired) + + pcall(vim.api.nvim_buf_delete, other_buf, { force = true }) + pcall(vim.api.nvim_buf_delete, panel.bufid, { force = true }) + panel:destroy() + end) + end) end) diff --git a/lua/diffview/tests/functional/pin_local_spec.lua b/lua/diffview/tests/functional/pin_local_spec.lua new file mode 100644 index 00000000..99cb7bd7 --- /dev/null +++ b/lua/diffview/tests/functional/pin_local_spec.lua @@ -0,0 +1,1007 @@ +local Diff2 = require("diffview.scene.layouts.diff_2").Diff2 +local Diff2Hor = require("diffview.scene.layouts.diff_2_hor").Diff2Hor +local Diff2HorPinned = require("diffview.scene.layouts.diff_2_hor_pinned").Diff2HorPinned +local Diff2VerPinned = require("diffview.scene.layouts.diff_2_ver_pinned").Diff2VerPinned +local FileEntry = require("diffview.scene.file_entry").FileEntry +local FileHistoryView = + require("diffview.scene.views.file_history.file_history_view").FileHistoryView +local LogEntry = require("diffview.vcs.log_entry").LogEntry +local RevType = require("diffview.vcs.rev").RevType +local config = require("diffview.config") +local helpers = require("diffview.tests.helpers") +local lib = require("diffview.lib") + +local eq = helpers.eq + +local function run(cmd, cwd) + local res = vim.system(cmd, { cwd = cwd, text = true }):wait() + assert.equals(0, res.code, (table.concat(cmd, " ") .. "\n" .. (res.stderr or ""))) + return vim.trim(res.stdout or "") +end + +-- Build a tempdir git repo with a single committed file. +local function make_git_repo() + local repo = vim.fn.tempname() + vim.fn.mkdir(repo, "p") + + run({ "git", "init", "-q" }, repo) + run({ "git", "config", "user.name", "Diffview Test" }, repo) + run({ "git", "config", "user.email", "diffview@test.local" }, repo) + + local f = assert(io.open(repo .. "/foo.txt", "w")) + f:write("hello\n") + f:close() + + run({ "git", "add", "foo.txt" }, repo) + run({ "git", "-c", "commit.gpgsign=false", "commit", "-q", "-m", "init" }, repo) + + return repo +end + +describe("pin_local config option", function() + local original + + before_each(function() + original = vim.deepcopy(config.get_config()) + end) + + after_each(function() + config.setup(original) + end) + + it("defaults to false on view.file_history", function() + config.setup({}) + eq(false, config.get_config().view.file_history.pin_local) + end) + + it("survives setup() when set to true", function() + config.setup({ view = { file_history = { pin_local = true } } }) + eq(true, config.get_config().view.file_history.pin_local) + end) +end) + +describe("FileHistoryView:get_default_layout pinning", function() + local original + + before_each(function() + original = vim.deepcopy(config.get_config()) + end) + + after_each(function() + config.setup(original) + end) + + -- We need an instance only for the method dispatch; the class-level + -- `pin_local` field, plus the layout config, is enough to drive the + -- resolution path without opening any windows. + local function inst(pin_local) + return setmetatable({ pin_local = pin_local }, { __index = FileHistoryView }) + end + + it("returns the configured layout class when pin_local is unset", function() + config.setup({ view = { file_history = { layout = "diff2_horizontal" } } }) + local v = inst(nil) + + eq("diff2_horizontal", v:get_default_layout().name) + end) + + it("upgrades diff2_horizontal to Diff2HorPinned when pin_local is true", function() + config.setup({ view = { file_history = { layout = "diff2_horizontal" } } }) + local v = inst(true) + + eq(Diff2HorPinned, v:get_default_layout()) + end) + + it("upgrades diff2_vertical to Diff2VerPinned when pin_local is true", function() + config.setup({ view = { file_history = { layout = "diff2_vertical" } } }) + local v = inst(true) + + eq(Diff2VerPinned, v:get_default_layout()) + end) +end) + +describe("lib.file_history --pin-local resolution", function() + local original_err + local err_messages + + before_each(function() + err_messages = {} + original_err = require("diffview.utils").err + require("diffview.utils").err = function(msg) + table.insert(err_messages, msg) + end + end) + + after_each(function() + require("diffview.utils").err = original_err + end) + + -- `--pin-local=false` exists so a per-invocation call can override a + -- `pin_local = true` value set in the user's config. Without this, there's + -- no way to opt out of the option from the CLI. + it("clears config-set pin_local when --pin-local=false is passed", function() + local original = vim.deepcopy(config.get_config()) + config.setup({ view = { file_history = { pin_local = true } } }) + local repo = make_git_repo() + + local ok, err = pcall(function() + local cwd = vim.fn.getcwd() + vim.cmd("cd " .. vim.fn.fnameescape(repo)) + + local view = lib.file_history(nil, { "--pin-local=false", "foo.txt" }) + + vim.cmd("cd " .. vim.fn.fnameescape(cwd)) + + assert.is_not_nil(view) + eq(false, view.pin_local) + eq(0, #err_messages) + -- `lib.file_history` registers the view in `lib.views` without + -- opening it; pop it directly so it doesn't leak into other tests. + for i, v in ipairs(lib.views) do + if v == view then + table.remove(lib.views, i) + break + end + end + end) + + pcall(vim.fn.delete, repo, "rf") + config.setup(original) + if not ok then + error(err) + end + end) + + it("enables pin_local when bare --pin-local is passed and config is unset", function() + local repo = make_git_repo() + + local ok, err = pcall(function() + local cwd = vim.fn.getcwd() + vim.cmd("cd " .. vim.fn.fnameescape(repo)) + + local view = lib.file_history(nil, { "--pin-local", "foo.txt" }) + + vim.cmd("cd " .. vim.fn.fnameescape(cwd)) + + assert.is_not_nil(view) + eq(true, view.pin_local) + eq(0, #err_messages) + for i, v in ipairs(lib.views) do + if v == view then + table.remove(lib.views, i) + break + end + end + end) + + pcall(vim.fn.delete, repo, "rf") + if not ok then + error(err) + end + end) +end) + +describe("FileHistoryView:_resolve_pinned_target", function() + local GitAdapter = require("diffview.vcs.adapters.git").GitAdapter + + local function make_adapter() + local repo = make_git_repo() + GitAdapter.bootstrap.done = true + GitAdapter.bootstrap.ok = true + return GitAdapter({ toplevel = repo, path_args = {} }), repo + end + + -- Stub a FileHistoryView with the minimum state `_resolve_pinned_target` + -- reads. We bypass the real constructor so we don't need to open windows. + -- The default layout returned matches the real pin_local code path: + -- `FileHistoryView:get_default_layout` upgrades to the pinned variant + -- whenever `self.pin_local` is set. + local function stub_view(adapter, pinned_path) + return setmetatable({ + pin_local = true, + pinned_path = pinned_path, + adapter = adapter, + -- The view's pin_local cache. `_resolve_pinned_target` builds overlays + -- via `self:get_pinned_b_file(pinned_path)`, which lazily populates + -- this map. Tests don't need the cache pre-seeded; they just need the + -- table to exist so the inherited method can write into it. + _pinned_b_files = {}, + get_default_layout = function() + return Diff2HorPinned + end, + }, { __index = FileHistoryView }) + end + + -- Build a LogEntry whose FileEntries match what `parse_fh_data` produces + -- under `pin_local == true`: revs.a = COMMIT (this commit), revs.b = LOCAL. + local function stub_log_entry(adapter, paths) + local commit = { hash = "abcdef0", subject = "stub" } + local files = {} + for _, p in ipairs(paths) do + table.insert( + files, + FileEntry.with_layout(Diff2HorPinned, { + adapter = adapter, + path = p, + status = "M", + kind = "working", + commit = commit, + revs = { + a = adapter.Rev(RevType.COMMIT, "0000000000000000000000000000000000000000"), + b = adapter.Rev(RevType.LOCAL), + }, + }) + ) + end + return LogEntry({ + path_args = paths, + commit = commit, + files = files, + single_file = #paths == 1, + }) + end + + it("returns the matching FileEntry when the entry contains pinned_path", function() + local adapter, repo = make_adapter() + + local ok, err = pcall(function() + local view = stub_view(adapter, "foo.txt") + local entry = stub_log_entry(adapter, { "bar.txt", "foo.txt", "baz.txt" }) + + local target = view:_resolve_pinned_target(entry) + assert.is_not_nil(target) + eq("foo.txt", target.path) + eq(target, entry.files[2]) + end) + + pcall(vim.fn.delete, repo, "rf") + if not ok then + error(err) + end + end) + + it("falls back to files[1] when pinned_path is unset (bootstrap case)", function() + local adapter, repo = make_adapter() + + local ok, err = pcall(function() + local view = stub_view(adapter, nil) + local entry = stub_log_entry(adapter, { "alpha.txt", "beta.txt" }) + + local target = view:_resolve_pinned_target(entry) + eq(target, entry.files[1]) + end) + + pcall(vim.fn.delete, repo, "rf") + if not ok then + error(err) + end + end) + + it("builds an overlay FileEntry when the entry doesn't contain pinned_path", function() + local adapter, repo = make_adapter() + + local ok, err = pcall(function() + local view = stub_view(adapter, "missing.txt") + local entry = stub_log_entry(adapter, { "foo.txt", "bar.txt" }) + + local target = view:_resolve_pinned_target(entry) + assert.is_not_nil(target) + eq("missing.txt", target.path) + eq(RevType.LOCAL, target.revs.b.type) + eq("missing.txt", target.layout.b.file.path) + assert.is_not_nil(entry._pin_overlays) + eq(target, entry._pin_overlays["missing.txt"]) + end) + + pcall(vim.fn.delete, repo, "rf") + if not ok then + error(err) + end + end) + + -- Multi-file entries are required to exercise the overlay path: single-file + -- entries short-circuit to `files[1]` to handle rename-follow histories + -- where the commit-side path differs from `pinned_path`. + it("marks overlay status='D' when pinned_path doesn't exist at revs.a", function() + local adapter, repo = make_adapter() + + local ok, err = pcall(function() + adapter.file_blob_hash = function() + return nil + end + local view = stub_view(adapter, "missing.txt") + local entry = stub_log_entry(adapter, { "foo.txt", "bar.txt" }) + + local target = view:_resolve_pinned_target(entry) + eq("D", target.status) + -- The pinned layout's `should_null` nulls the a-side for status "D" + -- against a COMMIT rev (file is missing in the commit), so the + -- adapter never has to `show :`. + assert.is_true(target.layout.a.file.nulled) + end) + + pcall(vim.fn.delete, repo, "rf") + if not ok then + error(err) + end + end) + + it("keeps overlay status='M' when pinned_path exists at revs.a", function() + local adapter, repo = make_adapter() + + local ok, err = pcall(function() + adapter.file_blob_hash = function() + return "deadbeefdeadbeefdeadbeefdeadbeef" + end + local view = stub_view(adapter, "missing.txt") + local entry = stub_log_entry(adapter, { "foo.txt", "bar.txt" }) + + local target = view:_resolve_pinned_target(entry) + eq("M", target.status) + end) + + pcall(vim.fn.delete, repo, "rf") + if not ok then + error(err) + end + end) + + it("falls back to status='M' when the adapter has no file_exists_at_rev", function() + local adapter, repo = make_adapter() + + local ok, err = pcall(function() + -- Stub the abstract-stub raise so we exercise the pcall fallback path, + -- guarding against third-party adapters that don't implement the probe. + adapter.file_exists_at_rev = function() + error("Unimplemented abstract method!") + end + local view = stub_view(adapter, "missing.txt") + local entry = stub_log_entry(adapter, { "foo.txt", "bar.txt" }) + + local target = view:_resolve_pinned_target(entry) + eq("M", target.status) + end) + + pcall(vim.fn.delete, repo, "rf") + if not ok then + error(err) + end + end) + + -- Regression: in single-file `--pin-local ` history with renames, + -- `entry.files[1].path` is the file's commit-side (old) name while + -- `pinned_path` is the working-tree (current) name. Path matching alone + -- would miss the entry, push us into the overlay path, and (without a + -- blob in the rename's old commit) misclassify the file as deleted. + -- `entry.single_file` short-circuits this: a single-file history's lone + -- `FileEntry` is always the right target, regardless of path. + it("returns files[1] for single-file entries even when the path differs (rename)", function() + local adapter, repo = make_adapter() + + local ok, err = pcall(function() + -- Sentinel hash: if the short-circuit is missing, the resolver will + -- fall through to the overlay path and call `file_blob_hash`. + local probed = false + adapter.file_blob_hash = function() + probed = true + return nil + end + + local view = stub_view(adapter, "current_name.txt") + -- `single_file = true` (one path) with a different commit-side name + -- to simulate `git log --follow` walking past a rename. + local entry = stub_log_entry(adapter, { "old_name.txt" }) + + local target = view:_resolve_pinned_target(entry) + eq(target, entry.files[1]) + eq("old_name.txt", target.path) + assert.is_false(probed) + assert.is_nil(entry._pin_overlays) + end) + + pcall(vim.fn.delete, repo, "rf") + if not ok then + error(err) + end + end) + + it("returns the cached overlay on repeat calls for the same path", function() + local adapter, repo = make_adapter() + + local ok, err = pcall(function() + local view = stub_view(adapter, "missing.txt") + local entry = stub_log_entry(adapter, { "foo.txt", "bar.txt" }) + + local first = view:_resolve_pinned_target(entry) + local second = view:_resolve_pinned_target(entry) + eq(first, second) + eq(first, entry._pin_overlays["missing.txt"]) + end) + + pcall(vim.fn.delete, repo, "rf") + if not ok then + error(err) + end + end) + + -- Lazy overlay layout sync: a `set_layout` / `cycle_layout` after the + -- overlay was built must not leave the overlay's layout class stale. + -- Navigating back through `_resolve_pinned_target` should detect the + -- mismatch with `view.cur_layout.class` and convert the overlay + -- on-demand, so the user's chosen orientation isn't silently undone. + it("converts a cached overlay's layout when the active class has changed", function() + local adapter, repo = make_adapter() + + local ok, err = pcall(function() + local view = stub_view(adapter, "missing.txt") + local entry = stub_log_entry(adapter, { "foo.txt", "bar.txt" }) + + -- First call builds the overlay with the stub's default + -- (`Diff2HorPinned` from `stub_view.get_default_layout`). + local first = view:_resolve_pinned_target(entry) + assert.is_not_nil(first) + eq(Diff2HorPinned, first.layout.class) + + -- Simulate a cycle to the vertical pinned variant. + view.cur_layout = { class = Diff2VerPinned } + + local second = view:_resolve_pinned_target(entry) + eq(first, second) -- same instance, just relayouted + eq(Diff2VerPinned, second.layout.class) + end) + + pcall(vim.fn.delete, repo, "rf") + if not ok then + error(err) + end + end) + + it("LogEntry:destroy tears down cached overlays so panel rebuilds don't leak", function() + local destroyed = {} + ---@diagnostic disable-next-line: missing-fields + local entry = LogEntry({ + path_args = {}, + commit = { hash = "abcdef0", subject = "stub" }, + files = { + setmetatable({}, { + __index = { + destroy = function(self) + destroyed[self] = true + end, + }, + }), + }, + single_file = true, + }) + local overlay_a = setmetatable({}, { + __index = { + destroy = function(self) + destroyed[self] = true + end, + }, + }) + local overlay_b = setmetatable({}, { + __index = { + destroy = function(self) + destroyed[self] = true + end, + }, + }) + entry._pin_overlays = { ["a.txt"] = overlay_a, ["b.txt"] = overlay_b } + + entry:destroy() + + assert.is_true(destroyed[overlay_a]) + assert.is_true(destroyed[overlay_b]) + assert.is_nil(entry._pin_overlays) + end) +end) + +describe("FileHistoryView:infer_cur_file pinned alignment", function() + local GitAdapter = require("diffview.vcs.adapters.git").GitAdapter + + local function make_adapter() + local repo = make_git_repo() + GitAdapter.bootstrap.done = true + GitAdapter.bootstrap.ok = true + return GitAdapter({ toplevel = repo, path_args = {} }), repo + end + + -- Stub the panel surface that `infer_cur_file` consults so we can drive + -- "panel focused" / "cursor on header" without opening any windows. + local function stub_view(adapter, opts) + return setmetatable({ + pin_local = opts.pin_local, + pinned_path = opts.pinned_path, + adapter = adapter, + _pinned_b_files = {}, + get_default_layout = function() + return Diff2Hor + end, + panel = { + is_focused = function() + return opts.focused + end, + get_item_at_cursor = function() + return opts.item + end, + cur_item = { nil, opts.cur_file }, + }, + }, { __index = FileHistoryView }) + end + + local function stub_log_entry(adapter, paths) + local commit = { hash = "abcdef0", subject = "stub" } + local files = {} + for _, p in ipairs(paths) do + table.insert( + files, + FileEntry.with_layout(Diff2, { + adapter = adapter, + path = p, + status = "M", + kind = "working", + commit = commit, + revs = { + a = adapter.Rev(RevType.COMMIT, "0000000000000000000000000000000000000000"), + b = adapter.Rev(RevType.LOCAL), + }, + }) + ) + end + return LogEntry({ + path_args = paths, + commit = commit, + files = files, + single_file = #paths == 1, + }) + end + + it("returns the pinned file (not files[1]) when cursor is on a header", function() + local adapter, repo = make_adapter() + + local ok, err = pcall(function() + local entry = stub_log_entry(adapter, { "alpha.txt", "beta.txt", "gamma.txt" }) + local view = stub_view(adapter, { + pin_local = true, + pinned_path = "gamma.txt", + focused = true, + item = entry, + }) + + local picked = view:infer_cur_file() + assert.is_not_nil(picked) + eq("gamma.txt", picked.path) + eq(picked, entry.files[3]) + end) + + pcall(vim.fn.delete, repo, "rf") + if not ok then + error(err) + end + end) + + it("returns the overlay when the pinned path isn't in this commit", function() + local adapter, repo = make_adapter() + + local ok, err = pcall(function() + -- Multi-file entry: single-file entries short-circuit to `files[1]` + -- (rename-follow handling), so use multiple paths to exercise the + -- overlay-build path here. + local entry = stub_log_entry(adapter, { "alpha.txt", "beta.txt" }) + local view = stub_view(adapter, { + pin_local = true, + pinned_path = "missing.txt", + focused = true, + item = entry, + }) + + local picked = view:infer_cur_file() + assert.is_not_nil(picked) + eq("missing.txt", picked.path) + eq(picked, entry._pin_overlays["missing.txt"]) + end) + + pcall(vim.fn.delete, repo, "rf") + if not ok then + error(err) + end + end) + + it("falls back to files[1] for a header when pin_local is unset", function() + local adapter, repo = make_adapter() + + local ok, err = pcall(function() + local entry = stub_log_entry(adapter, { "alpha.txt", "beta.txt" }) + local view = stub_view(adapter, { + pin_local = nil, + pinned_path = "beta.txt", + focused = true, + item = entry, + }) + + local picked = view:infer_cur_file() + eq(picked, entry.files[1]) + end) + + pcall(vim.fn.delete, repo, "rf") + if not ok then + error(err) + end + end) +end) + +describe("FileHistoryView pinned-local layout selection (sanity)", function() + -- These checks were added to lock in that the multi-file plan keeps + -- single-file layout selection working unchanged. + local original + + before_each(function() + original = vim.deepcopy(config.get_config()) + end) + + after_each(function() + config.setup(original) + end) + + local function inst(pin_local) + return setmetatable({ pin_local = pin_local }, { __index = FileHistoryView }) + end + + it("resolves to pinned variants regardless of single/multi file mode", function() + config.setup({ view = { file_history = { layout = "diff2_horizontal" } } }) + + eq(Diff2HorPinned, inst(true):get_default_layout()) + end) + + it("does not upgrade when pin_local is unset", function() + config.setup({ view = { file_history = { layout = "diff2_vertical" } } }) + + -- Use the actual layout name, not the class, to avoid pulling in + -- Diff2Ver as another import. + eq("diff2_vertical", inst(nil):get_default_layout().name) + end) + + -- Defensive path: if a pinned layout name reaches `get_default_layout` + -- with `pin_local` unset, it must be downgraded to its unpinned sibling. + -- Pinned classes assume `revs.a` is the commit (the way pin_local sets + -- it); applied to a parent-vs-commit history they mis-classify status + -- "A"/"?" and the adapter then fails to `show :`. The + -- user-config path is already gated by `standard_layouts` validation + -- (pinned names aren't in the schema's allow-list, so `config.setup` + -- silently substitutes the default), so we stub `get_default_layout_name` + -- here to exercise the downgrade directly without going through config. + local function stub_named(layout_name, pin_local) + return setmetatable({ + pin_local = pin_local, + get_default_layout_name = function() + return layout_name + end, + }, { __index = FileHistoryView }) + end + + it("downgrades a pinned layout name when pin_local is unset", function() + eq("diff2_horizontal", stub_named("diff2_horizontal_pinned", nil):get_default_layout().name) + eq("diff2_vertical", stub_named("diff2_vertical_pinned", nil):get_default_layout().name) + end) + + -- pin_local + a non-Diff2 layout (e.g. `diff1_inline`): the layout has no + -- pinned variant, so the shared-b-side mechanism would not engage and + -- entry teardown would tear down the view-owned working-tree file. Force + -- the default Diff2 (which IS pinned-capable) so pinning actually works. + -- The exact orientation depends on `prefer_horizontal()` -- assert only + -- that the result is one of the pinned Diff2 variants. + it("falls back to a pinned Diff2 when pin_local + a non-Diff2 layout", function() + local pinned_names = { + diff2_horizontal_pinned = true, + diff2_vertical_pinned = true, + } + for _, name in ipairs({ "diff1_inline", "diff1_plain" }) do + local resolved = stub_named(name, true):get_default_layout().name + assert.is_true(pinned_names[resolved], "expected pinned Diff2, got " .. tostring(resolved)) + end + end) +end) + +-- Regression: layout-cycle (`g`) and `set_layout` go through +-- `entry:convert_layout(target)`. Without `resolve_pinned_layout` they +-- would route a pin_local view's entries to unpinned `Diff2Hor`/`Diff2Ver`, +-- whose `shared_symbols` is empty -- so the next `FileEntry:destroy` +-- would tear down the view-owned working-tree File once per entry, +-- breaking the pin and wiping shared diffview state from the user's +-- working-tree buffers. +describe("FileHistoryView:resolve_pinned_layout", function() + local Diff2Hor = require("diffview.scene.layouts.diff_2_hor").Diff2Hor + local Diff2Ver = require("diffview.scene.layouts.diff_2_ver").Diff2Ver + local Diff1Inline = require("diffview.scene.layouts.diff_1_inline").Diff1Inline + + local function inst(pin_local) + return setmetatable({ pin_local = pin_local }, { __index = FileHistoryView }) + end + + it("returns the input class unchanged when pin_local is off", function() + eq(Diff2Hor, inst(false):resolve_pinned_layout(Diff2Hor)) + eq(Diff2Ver, inst(false):resolve_pinned_layout(Diff2Ver)) + eq(Diff1Inline, inst(false):resolve_pinned_layout(Diff1Inline)) + end) + + it("upgrades unpinned Diff2 to its pinned sibling", function() + eq(Diff2HorPinned, inst(true):resolve_pinned_layout(Diff2Hor)) + eq(Diff2VerPinned, inst(true):resolve_pinned_layout(Diff2Ver)) + end) + + it("preserves a pinned variant unchanged (idempotent)", function() + eq(Diff2HorPinned, inst(true):resolve_pinned_layout(Diff2HorPinned)) + eq(Diff2VerPinned, inst(true):resolve_pinned_layout(Diff2VerPinned)) + end) + + -- A non-Diff2 layout has no pinned sibling, so the only safe option is + -- to fall back to a pinned Diff2 (the configured default's pinned form), + -- so the shared-b mechanism still engages. The exact orientation + -- depends on `prefer_horizontal()`; only assert it's a pinned Diff2. + it("falls back to a pinned Diff2 when the input has no pinned variant", function() + local resolved = inst(true):resolve_pinned_layout(Diff1Inline) + assert.is_true( + resolved == Diff2HorPinned or resolved == Diff2VerPinned, + "expected a pinned Diff2, got " .. tostring(resolved.name) + ) + end) +end) + +-- `unpinned_layout` is the inverse mapping `cycle_layout` consults to +-- find the active layout's position in the unpinned cycle list. Without +-- it, a pin_local view's `Diff2*Pinned` class would never match the +-- `{ Diff2Hor, Diff2Ver }` cycle and the action would loop forever on +-- the first orientation. The function is a no-op for any class that +-- isn't a known pinned variant, so non-pin_local views keep their +-- existing behaviour. +describe("FileHistoryView:unpinned_layout", function() + local Diff2Hor = require("diffview.scene.layouts.diff_2_hor").Diff2Hor + local Diff2Ver = require("diffview.scene.layouts.diff_2_ver").Diff2Ver + local Diff1Inline = require("diffview.scene.layouts.diff_1_inline").Diff1Inline + + local view = setmetatable({}, { __index = FileHistoryView }) + + it("maps pinned variants back to their unpinned siblings", function() + eq(Diff2Hor, view:unpinned_layout(Diff2HorPinned)) + eq(Diff2Ver, view:unpinned_layout(Diff2VerPinned)) + end) + + it("returns non-pinned classes unchanged", function() + eq(Diff2Hor, view:unpinned_layout(Diff2Hor)) + eq(Diff2Ver, view:unpinned_layout(Diff2Ver)) + eq(Diff1Inline, view:unpinned_layout(Diff1Inline)) + end) +end) + +-- `pick_entry_target` is the helper that listeners and the panel use to +-- decide which FileEntry to display when navigating to a LogEntry. In +-- pin_local mode it must route through `_resolve_pinned_target` so the +-- pinned path (or its overlay) is what gets shown; the previous direct +-- access to `entry.files[1]` snapped to the first file in the commit and +-- ignored the user's pinned selection. +describe("FileHistoryView:pick_entry_target", function() + local function make_view(pin_local, pinned_path) + return setmetatable({ + pin_local = pin_local, + pinned_path = pinned_path, + }, { __index = FileHistoryView }) + end + + local function fake_entry(paths) + local files = {} + for _, p in ipairs(paths) do + table.insert(files, { path = p }) + end + return { + single_file = #paths == 1, + files = files, + } + end + + it("returns entry.files[1] when pin_local is off", function() + local view = make_view(nil, nil) + local entry = fake_entry({ "alpha.txt", "beta.txt" }) + eq(entry.files[1], view:pick_entry_target(entry)) + end) + + it("returns the pinned-path file when present in the entry", function() + local view = make_view(true, "beta.txt") + local entry = fake_entry({ "alpha.txt", "beta.txt", "gamma.txt" }) + eq(entry.files[2], view:pick_entry_target(entry)) + end) + + it("returns entry.files[1] for single-file entries even in pin_local", function() + -- Single-file history follows one logical file across renames, so + -- `_resolve_pinned_target` short-circuits to `files[1]` regardless + -- of whether `pinned_path` matches the commit-side name. + local view = make_view(true, "renamed.txt") + local entry = fake_entry({ "old_name.txt" }) + eq(entry.files[1], view:pick_entry_target(entry)) + end) +end) + +-- Centralised pinned_path invariant: `set_file` is the canonical "this +-- file is now active" path, so it owns the `pinned_path` update. Any +-- programmatic switch (commit-nav, file-row navigation, the cursor +-- follower) flows through here and the pinned path stays in sync +-- without each caller having to remember. Single-file history is the +-- exception: `pinned_path` there is the rename anchor (the working-tree +-- name) and may legitimately differ from the entry's commit-side name. +describe("FileHistoryView:set_file pinned_path invariant", function() + -- Build a view shell with the minimum surface `set_file`'s + -- pinned_path-update branch reads. We don't need to invoke the rest of + -- set_file's async work; the invariant is set before the await. + local function update_pinned_path(view, file) + if view.pin_local and not view.panel.single_file then + view.pinned_path = file.path + end + end + + it("updates pinned_path to file.path in pin_local multi-file mode", function() + local view = { pin_local = true, pinned_path = "alpha.txt", panel = { single_file = false } } + update_pinned_path(view, { path = "beta.txt" }) + eq("beta.txt", view.pinned_path) + end) + + it("does not touch pinned_path when pin_local is off", function() + local view = { pin_local = nil, pinned_path = "alpha.txt", panel = { single_file = false } } + update_pinned_path(view, { path = "beta.txt" }) + eq("alpha.txt", view.pinned_path) + end) + + it("preserves pinned_path in pin_local single-file mode (rename anchor)", function() + -- Single-file history may show `entry.files[1]` with the file's + -- commit-side name (e.g. the OLD name across a rename); pinned_path + -- must stay anchored to the working-tree name so the b-side keeps + -- following the live file. + local view = { pin_local = true, pinned_path = "renamed.txt", panel = { single_file = true } } + update_pinned_path(view, { path = "old_name.txt" }) + eq("renamed.txt", view.pinned_path) + end) +end) + +-- Overlay-aware navigation: pin_local overlays are transient FileEntries +-- that don't live in `entry.files`, so the panel's file-offset code +-- (`set_file_by_offset`) and the in-commit nav actions +-- (`next_entry_in_commit`/`prev_entry_in_commit`) used to silently no-op +-- when `cur_item[2]` was an overlay. The fix is to treat the overlay as if +-- the cursor were at `entry.files[1]` for navigation purposes. +describe("overlay-aware navigation", function() + local FileHistoryPanel = + require("diffview.scene.views.file_history.file_history_panel").FileHistoryPanel + + -- Drive the offset-based file navigation directly. The full panel + -- bring-up isn't required: we only need `entries`, `cur_item`, and + -- `_get_entry_by_file_offset` (inherited). + local function make_panel(entries, cur_entry, cur_file) + return setmetatable({ + entries = entries, + cur_item = { cur_entry, cur_file }, + single_file = false, + num_items = function(self) + local n = 0 + for _, e in ipairs(self.entries) do + n = n + #e.files + end + return n + end, + set_cur_item = function(self, item) + self.cur_item = item + end, + set_entry_fold = function() end, + }, { __index = FileHistoryPanel }) + end + + it("set_file_by_offset advances when cur_file is an overlay", function() + local file_a, file_b = { path = "alpha.txt" }, { path = "beta.txt" } + local overlay = { path = "missing.txt" } + local entry = { files = { file_a, file_b }, _pin_overlays = { ["missing.txt"] = overlay } } + local panel = make_panel({ entry }, entry, overlay) + + -- With the overlay treated as files[1], an offset of +1 should advance + -- to files[2] (beta.txt). Pre-fix this would no-op silently because + -- `vec_indexof(entry.files, overlay)` returns -1. + local result = panel:set_file_by_offset(1) + eq(file_b, result) + eq(file_b, panel.cur_item[2]) + end) + + -- `_resolve_pinned_target` can hand `set_file` a transient overlay + -- FileEntry that isn't in `entry.files`. Pre-fix `highlight_item` would + -- silently no-op (no cursor movement), so commit-navigation actions left + -- the panel cursor stranded on the previous row while the diff jumped. + -- The fix parks the cursor on the entry header so the visible selection + -- tracks the displayed commit. + it("highlight_item parks cursor on entry header for pin_local overlays", function() + local FileEntry = require("diffview.scene.file_entry").FileEntry + + local file_a = setmetatable({ path = "alpha.txt", class = FileEntry }, { __index = FileEntry }) + local file_b = setmetatable({ path = "beta.txt", class = FileEntry }, { __index = FileEntry }) + local overlay = setmetatable( + { path = "missing.txt", class = FileEntry }, + { __index = FileEntry } + ) + + local entry = LogEntry({ + path_args = {}, + commit = { hash = "abc", subject = "stub" }, + files = { file_a, file_b }, + }) + entry._pin_overlays = { ["missing.txt"] = overlay } + + local comp_struct = { comp = { context = entry, lstart = 17 } } + local panel = setmetatable({ + single_file = false, + winid = -1, + components = { log = { entries = { comp_struct } } }, + is_open = function() + return true + end, + buf_loaded = function() + return true + end, + render = function() end, + redraw = function() end, + }, { __index = FileHistoryPanel }) + + -- Spy on cursor moves rather than open a real window: the api function + -- is read fresh from `vim.api` each call, so the local capture in the + -- panel module sees this stub. + local original_set_cursor = vim.api.nvim_win_set_cursor + local captured + vim.api.nvim_win_set_cursor = function(_, pos) + captured = pos + end + local utils_mod = require("diffview.utils") + local original_update_win = utils_mod.update_win + utils_mod.update_win = function() end + + local ok, err = pcall(panel.highlight_item, panel, overlay) + + vim.api.nvim_win_set_cursor = original_set_cursor + utils_mod.update_win = original_update_win + + if not ok then + error(err) + end + assert.is_not_nil(captured) + eq(17, captured[1]) + eq(0, captured[2]) + end) +end) + +describe("FileHistoryView:_destroy_pinned_b_files", function() + -- Regression for a refactor bug where pinned b-files were destroyed with + -- `force=true`, which `File:destroy` propagates straight to + -- `safe_delete_buf`. That wipes the user's pre-existing working-tree + -- buffer (possibly with unsaved edits) on view close. The contract is + -- the opposite: detach diffview state with `force=false` and let the + -- existing `clean_up_buffers` loop in `close()` reap only buffers + -- diffview created. + it("destroys each cached b-file with force=false", function() + local destroy_calls = {} + local fake_file = { + destroy = function(_, force) + destroy_calls[#destroy_calls + 1] = force + end, + } + + local view = setmetatable({ + _pinned_b_files = { + ["foo.txt"] = fake_file, + ["bar.txt"] = fake_file, + }, + }, { __index = FileHistoryView }) + + view:_destroy_pinned_b_files() + + eq(2, #destroy_calls) + for _, force in ipairs(destroy_calls) do + assert.is_false(force) + end + -- Cache is emptied so a re-close (or a stale reference) doesn't try to + -- detach an already-destroyed file. + assert.same({}, view._pinned_b_files) + end) +end) diff --git a/lua/diffview/ui/panel.lua b/lua/diffview/ui/panel.lua index eca50bad..895199bf 100644 --- a/lua/diffview/ui/panel.lua +++ b/lua/diffview/ui/panel.lua @@ -650,6 +650,11 @@ function Panel:on_autocmd(event, opts) end elseif state.event:match("^Buf") then buf_match = state.buf + else + -- Cursor/text/insert/etc. events carry the active buffer in `state.buf`; + -- match by buffer so subscribers can target panel-buffer-local events + -- (e.g. `CursorMoved`) without bypassing this dispatcher. + buf_match = state.buf end if (win_match and win_match == self.winid) or (buf_match and buf_match == self.bufid) then diff --git a/lua/diffview/vcs/adapter.lua b/lua/diffview/vcs/adapter.lua index 66ee4783..dc639afd 100644 --- a/lua/diffview/vcs/adapter.lua +++ b/lua/diffview/vcs/adapter.lua @@ -265,6 +265,16 @@ function VCSAdapter:file_blob_hash(path, rev_arg) oop.abstract_stub() end +---Whether `path` exists at `rev_arg`. Cheaper than `file_blob_hash` for +---adapters where blob identity isn't a first-class concept (e.g. Mercurial), +---and the only thing the pin_local overlay path actually needs. +---@param path string +---@param rev_arg string +---@return boolean +function VCSAdapter:file_exists_at_rev(path, rev_arg) + oop.abstract_stub() +end + ---@return string[] # path to binary for VCS command function VCSAdapter:get_command() oop.abstract_stub() diff --git a/lua/diffview/vcs/adapters/git/init.lua b/lua/diffview/vcs/adapters/git/init.lua index 38b3f323..664a3f8d 100644 --- a/lua/diffview/vcs/adapters/git/init.lua +++ b/lua/diffview/vcs/adapters/git/init.lua @@ -1574,6 +1574,14 @@ function GitAdapter:file_blob_hash(path, rev_arg) return vim.trim(out[1]) end +---@param path string +---@param rev_arg string +---@return boolean +function GitAdapter:file_exists_at_rev(path, rev_arg) + local blob = self:file_blob_hash(path, rev_arg) + return blob ~= nil and blob ~= "" +end + ---Parse two endpoint, commit revs from a symmetric difference notated rev arg. ---@param rev_arg string ---@return Rev? left The left rev. @@ -2488,6 +2496,7 @@ function GitAdapter:init_completion() return vim.fn.getcompletion(arg_lead, "dir") end) self.comp.file_history:put({ "--follow" }) + self.comp.file_history:put({ "--pin-local" }) self.comp.file_history:put({ "--first-parent" }) self.comp.file_history:put({ "--show-pulls" }) self.comp.file_history:put({ "--reflog" }) diff --git a/lua/diffview/vcs/adapters/hg/init.lua b/lua/diffview/vcs/adapters/hg/init.lua index 9981fa51..5778be6d 100644 --- a/lua/diffview/vcs/adapters/hg/init.lua +++ b/lua/diffview/vcs/adapters/hg/init.lua @@ -966,6 +966,25 @@ function HgAdapter:head_rev() return HgRev(RevType.COMMIT, s, true) end +---@param path string +---@param rev_arg string +---@return boolean +function HgAdapter:file_exists_at_rev(path, rev_arg) + -- `hg files -r REV -- path` exits 0 with the path on stdout when the file + -- is tracked in REV, exits non-zero with empty stdout otherwise. The `--` + -- separator keeps paths starting with `-` from being parsed as options. + local out, code = self:exec_sync( + { "files", "-r", rev_arg, "--", path }, + { cwd = self.ctx.toplevel, silent = true } + ) + + if code ~= 0 or not out or not out[1] then + return false + end + + return vim.trim(out[1]) ~= "" +end + ---Get the current branch name. ---@return string? branch_name The branch name, or nil if not available. function HgAdapter:get_branch_name() @@ -1478,6 +1497,7 @@ function HgAdapter:init_completion() end) self.comp.file_history:put({ "--follow", "-f" }) + self.comp.file_history:put({ "--pin-local" }) self.comp.file_history:put({ "--no-merges", "-M" }) self.comp.file_history:put({ "--limit", "-l" }, {}) diff --git a/lua/diffview/vcs/adapters/null/init.lua b/lua/diffview/vcs/adapters/null/init.lua index 91fccb86..962dff0e 100644 --- a/lua/diffview/vcs/adapters/null/init.lua +++ b/lua/diffview/vcs/adapters/null/init.lua @@ -73,6 +73,13 @@ function NullAdapter:file_blob_hash(path, rev_arg) return nil end +---@param path string +---@param rev_arg string +---@return boolean +function NullAdapter:file_exists_at_rev(path, rev_arg) + return false +end + ---@return string[] function NullAdapter:get_command() if vim.fn.has("win32") == 1 then diff --git a/lua/diffview/vcs/log_entry.lua b/lua/diffview/vcs/log_entry.lua index 91cf78cf..fd035872 100644 --- a/lua/diffview/vcs/log_entry.lua +++ b/lua/diffview/vcs/log_entry.lua @@ -17,6 +17,7 @@ local M = {} ---@field folded boolean ---@field nulled boolean ---@field has_remote_ref boolean Whether this commit has a remote ref decoration (e.g. a remote branch tip). +---@field _pin_overlays? table Cache of transient FileEntry overlays keyed by `pinned_path`, populated when a pinned file isn't touched by this commit. local LogEntry = oop.create_class("LogEntry") function LogEntry:init(opt) @@ -43,6 +44,12 @@ function LogEntry:destroy() for _, file in ipairs(self.files) do file:destroy() end + if self._pin_overlays then + for _, overlay in pairs(self._pin_overlays) do + overlay:destroy() + end + self._pin_overlays = nil + end end function LogEntry:update_status()