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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions doc/diffview.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2065,6 +2065,26 @@ select_last_entry *diffview-actions-select_last_en

Select the commit preceding the subject.

select_next_change_here *diffview-actions-select_next_change_here*
Contexts: `file_history_view`, `file_history_panel`

Open the next older commit that changes the code under the cursor.
Commits that touch the file somewhere else are skipped, and are not
opened on the way: only the revision's content is read. See
|diffview-cursor-carry| for how the line is followed.

In a history spanning several files, commits that leave the file alone
are skipped too, and a commit that touches it alongside others is read
at that file rather than at whichever one it lists first.

If no older commit changes the line, the view does not move.

select_prev_change_here *diffview-actions-select_prev_change_here*
Contexts: `file_history_view`, `file_history_panel`

As |diffview-actions-select_next_change_here|, stepping toward the
newer commits.

stage_all *diffview-actions-stage_all*
Contexts: `diff_view`, `file_panel`

Expand Down Expand Up @@ -2152,8 +2172,10 @@ Cursor carry ~
When a step lands on another revision of the file you are already reading, the
cursor stays on the same line of code rather than the same line number.
Diffview diffs the arriving buffer against the one you are leaving, then maps
your cursor line through the result. This covers the entry and commit actions
alike:
your cursor line through the result.

This applies to every swap between files, however you reached it -- a row in
the file panel, |diffview-actions-goto_file|, or the entry and commit actions:

• |diffview-actions-select_next_entry|
• |diffview-actions-select_prev_entry|
Expand All @@ -2164,6 +2186,13 @@ A line inside a hunk the step introduces has no single counterpart. It lands
on that hunk's first line, or on the last surviving line before it when the
hunk only deletes.

That same distinction answers whether a commit changed the code you are
reading, which is what |diffview-actions-select_next_change_here| walks on.
Those two actions do not diff the endpoints of their skip: they map the cursor
through every revision they pass, and land on the line that walk arrives at.
Over a run of commits that only shift the code, an endpoint diff has no way to
tell a moved block from a deleted one.

*diffview-unused-actions*
Unused actions ~

Expand All @@ -2176,6 +2205,8 @@ actions:
• |diffview-actions-goto_file_edit|
• |diffview-actions-goto_file_edit_close|
• |diffview-actions-jumpto_conflict|
• |diffview-actions-select_next_change_here|
• |diffview-actions-select_prev_change_here|
• |diffview-actions-view_windo|

==============================================================================
Expand Down
4 changes: 4 additions & 0 deletions lua/diffview/actions.lua
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ local pl = lazy.access(utils, "path") --[[@as PathLib ]]
---@field select_last_entry fun()
---@field select_next_commit fun()
---@field select_prev_commit fun()
---@field select_next_change_here fun()
---@field select_prev_change_here fun()
---@field stage_all fun()
---@field toggle_files fun()
---@field toggle_flatten_dirs fun()
Expand Down Expand Up @@ -1265,6 +1267,8 @@ local action_names = {
"select_last_entry",
"select_next_commit",
"select_prev_commit",
"select_next_change_here",
"select_prev_change_here",
"stage_all",
"toggle_files",
"toggle_flatten_dirs",
Expand Down
77 changes: 77 additions & 0 deletions lua/diffview/line_map.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
-- Nvim 0.12 added `vim.text.diff`. `vim.diff` still works, but LuaLS marks
-- it deprecated. Alias once, as `inline_diff` does.
---@diagnostic disable-next-line: deprecated
local diff = vim.diff

---Following a line of code from one revision of a file into another.
---
---This is pure text work: it takes lines and hunks and gives back a line,
---touching no window, buffer, or view state. It lives on its own because both
---the cursor carry and the change-here walk need it, and neither should have to
---reach through the other's view class to get at it.
local M = {}

---Lines as `vim.diff` input. Without the trailing newline `vim.diff` reports
---an addition or deletion at EOF as a modification of the adjacent line.
---`inline_diff` terminates its input the same way.
---@param lines string[]
---@return string
local function lines_text(lines)
return table.concat(lines, "\n") .. "\n"
end

---Map a line number from the `a` side of a diff onto the `b` side. A line
---following a hunk shifts by that hunk's size delta. A line inside a hunk has
---no single counterpart, so it maps to the hunk's start in `b`.
---@param hunks integer[][] # `vim.diff` "indices" hunks: `{ start_a, count_a, start_b, count_b }`, ascending.
---@param lnum integer
---@return integer
---@return boolean # `true` when `lnum` fell inside a hunk, i.e. the diff rewrote the code it pointed at.
function M.map(hunks, lnum)
local delta = 0

for _, hunk in ipairs(hunks) do
local start_a, count_a, start_b, count_b = hunk[1], hunk[2], hunk[3], hunk[4]

if count_a == 0 then
-- Pure insertion, anchored *after* `start_a`.
if lnum <= start_a then
break
end
delta = delta + count_b
else
local last_a = start_a + count_a - 1
if lnum < start_a then
break
elseif lnum <= last_a then
-- `start_b` is the hunk's first line in `b`. When the hunk only
-- deletes, it is the last surviving line before the hunk.
return math.max(1, start_b), true
end
delta = delta + count_b - count_a
end
end

return math.max(1, lnum + delta), false
end

---Diff two revisions of a file and map `lnum` from the first onto the second.
---Takes lines rather than buffers so the history walk can read a revision
---without opening it.
---@param from_lines string[]
---@param to_lines string[]
---@param lnum integer
---@return integer
---@return boolean # `true` when the two revisions differ over `lnum`.
function M.between(from_lines, to_lines, lnum)
local ok, hunks =
pcall(diff, lines_text(from_lines), lines_text(to_lines), { result_type = "indices" })

if not (ok and type(hunks) == "table") then
return lnum, false
end

return M.map(hunks, lnum)
end

return M
8 changes: 8 additions & 0 deletions lua/diffview/scene/file_entry.lua
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,14 @@ function FileEntry:init(opt)
self._extra_owned = opt._extra_owned or {}
end

---The `File` this entry's main window shows, without opening the entry. Nil
---while the layout has no main window bound.
---@return vcs.File?
function FileEntry:main_file()
local win = self.layout and self.layout:get_main_win()
return win and win.file or nil
end

---Destroy owned Files. `force` still drops COMMIT/STAGE/CUSTOM buffers
---unconditionally, but LOCAL buffers stay guarded by `is_buf_in_use`: they
---represent the user's real file buffer and may be visible in windows
Expand Down
167 changes: 167 additions & 0 deletions lua/diffview/scene/views/file_history/file_history_view.lua
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ local File = lazy.access("diffview.vcs.file", "File") ---@type vcs.File|LazyModu
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 line_map = lazy.require("diffview.line_map") ---@module "diffview.line_map"
local utils = lazy.require("diffview.utils") ---@module "diffview.utils"

local api = vim.api
Expand Down Expand Up @@ -209,6 +210,172 @@ function FileHistoryView:prev_item()
end
end

---The file `entry` carries at `path`, under that name or the one it was renamed
---from, or nil when that commit leaves the path alone and so cannot have changed
---the code under the cursor. `pick_entry_target`
---answers with `entry.files[1]`, which is the wrong file as soon as a commit
---touches more than one, so the walk resolves by path instead.
---
---Two modes keep their own answer. `pin_local` already resolves by path, via an
---overlay when the commit lacks the file. Single-file history follows one
---logical file across renames, so its lone `FileEntry` is the right target
---whatever its path -- the same reasoning `_resolve_pinned_target` applies.
---@param self FileHistoryView
---@param entry LogEntry
---@param path string
---@return FileEntry?
local function pick_change_here_target(self, entry, path)
if self.pin_local then
return self:pick_entry_target(entry)
end

if entry.single_file then
return entry.files[1]
end

for _, f in ipairs(entry.files) do
-- `oldpath` is the other half of the answer. A rename splits the history in
-- two: commits older than it list the file under its old name, newer ones
-- under the new one, and only the renaming commit carries both. Matching on
-- `path` alone loses the file at that seam and then skips every commit past
-- it, which reads to the caller as a history where nothing changes the line.
-- `oldpath` also names the source of a copy (status `C`), which is another
-- file, so that pairing is not followed.
if f.path == path or (f.oldpath == path and f.status ~= "C") then
return f
end
end
end

---Walk the history until a commit changes the code under the cursor, then open
---that commit. Reading each revision's content is enough to decide, so the
---commits in between are never opened.
---@param self FileHistoryView
---@param dir integer # `1` walks toward the older commits, `-1` toward the newer.
FileHistoryView.select_change_here = async.void(function(self, dir)
local cur_entry, cur_file = self.panel.cur_item[1], self.panel.cur_item[2]
Comment on lines +255 to +256
if not (cur_entry and cur_file) then
return
end

local idx = utils.vec_indexof(self.panel.entries, cur_entry)
local win = self.cur_layout and self.cur_layout:get_main_win()
if idx == -1 or not (win and win.id and api.nvim_win_is_valid(win.id)) then
return
end

local lnum = api.nvim_win_get_cursor(win.id)[1]
local lines = api.nvim_buf_get_lines(api.nvim_win_get_buf(win.id), 0, -1, false)
local found
-- The newer commit of any neighbouring pair owns the difference between
-- them, so walking toward the older commits the answer is the entry read
-- before this one: `lines` is its content, and `lnum` the cursor in it.
local prev
-- The panel appends older entries as the history loads, so running out of
-- them in that direction means the end of the history only once it has
-- stopped. The newest entry is in place from the start.
local still_loading = false
-- The name the file goes by in the entries ahead. A rename changes it.
local path = cur_file.path

while true do
idx = idx + dir
local entry = self.panel.entries[idx]
if not entry then
still_loading = dir > 0 and self.panel.updating
break
end

-- A commit that leaves the path alone cannot have changed the code under
-- the cursor, so it is passed over without reading a revision at all.
local candidate = pick_change_here_target(self, entry, path)

if candidate then
local file = candidate:main_file()
Comment on lines +293 to +294

-- Nothing to compare against: a binary or unreadable revision has no
-- lines. Open it and let the reader judge.
if not file or file.binary then
found = candidate
Comment on lines +296 to +299
break
end

local err, next_lines = await(file.adapter:show(file.path, file.rev))
Comment thread
seflue marked this conversation as resolved.

-- The read yielded, and the view may have closed or lost its tabpage in
-- the meantime. `show` resumes us in a fast event context, where that
-- cannot be asked.
await(async.scheduler())
if self:swap_cancelled() then
return
end

if err or not next_lines then
found = candidate
break
end

local touched
local before = lnum
lnum, touched = line_map.between(lines, next_lines, lnum)
lines = next_lines

-- A rename lists the file under both names, and the entries beyond it in
-- this direction use only one of them: the older ones the old name, the
-- newer ones the new one. The content was read under the name the
-- candidate itself uses, so the rename is judged like any other commit.
if candidate.oldpath and candidate.oldpath ~= candidate.path and candidate.status ~= "C" then
path = dir > 0 and candidate.oldpath or candidate.path
end

-- Walking toward the newer commits the difference belongs to the
-- candidate itself. Walking toward the older ones it belongs to the
-- entry the walk read before it, and on the first step that entry is
-- the one already open: the reader asked for the next commit that
-- changes the line, not the one they are looking at, so the walk
-- carries on.
local target, target_lnum
if touched then
if dir < 0 then
target, target_lnum = candidate, lnum
elseif prev then
target, target_lnum = prev, before
end
end

if target then
found = target

-- The walk mapped the cursor through every revision it passed, so it
-- holds a line the carry cannot re-derive: left to itself the carry
-- diffs the starting revision straight against this one, and across a
-- skip of several commits that single diff has no way to tell a moved
-- body from a deleted one -- it reads the cursor's line as deleted and
-- drops the cursor above the hunk. Hand the walked line over.
--
-- Only this branch has one. The breaks above leave `lnum` pointing
-- into the text of some earlier revision.
self:set_carry_lnum(target, target_lnum)
break
end

prev = candidate
end
end

-- Nothing ahead changes this line, so the reader stays where they are rather
-- than being dropped at the far end of the history.
if not found then
utils.info(
still_loading and "The history is still loading. Nothing read so far changes this line."
or "No further commit changes this line."
)
return
end

await(self:set_file(found))
end)

---@param self FileHistoryView
---@param file FileEntry
---@param focus? boolean
Expand Down
6 changes: 6 additions & 0 deletions lua/diffview/scene/views/file_history/listeners.lua
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,12 @@ return function(view)
-- 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_next_change_here = function()
view:select_change_here(1)
end,
select_prev_change_here = function()
view:select_change_here(-1)
end,
---Navigate to next file within the current commit.
next_entry_in_commit = function()
local cur_entry = view.panel.cur_item[1]
Expand Down
Loading
Loading