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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions doc/diffview.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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).

Expand Down Expand Up @@ -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)

Expand Down
1 change: 1 addition & 0 deletions doc/diffview_defaults.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 48 additions & 2 deletions lua/diffview/actions.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
16 changes: 15 additions & 1 deletion lua/diffview/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -291,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
Expand All @@ -303,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=<rev>` 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|`.
Expand All @@ -328,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.
Expand Down Expand Up @@ -865,7 +875,9 @@ end
---@alias LayoutName "diff1_plain"
--- | "diff1_inline"
--- | "diff2_horizontal"
--- | "diff2_horizontal_pinned"
--- | "diff2_vertical"
--- | "diff2_vertical_pinned"
--- | "diff3_horizontal"
--- | "diff3_vertical"
--- | "diff3_mixed"
Expand All @@ -875,7 +887,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,
Expand Down
51 changes: 51 additions & 0 deletions lua/diffview/lib.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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({
Expand Down
57 changes: 55 additions & 2 deletions lua/diffview/scene/file_entry.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -266,21 +277,62 @@ 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()`. 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)
return File({
local fallback_for_shared = false
if symbol == "b" and opt.pinned_b_file then
-- 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
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

local file = 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,
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({
Expand All @@ -292,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"),
Expand Down
Loading
Loading