From ce0e4d8f4768fa7179795d89ab281fb41734ba44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Fl=C3=BCgge?= <952313+seflue@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:33:38 +0200 Subject: [PATCH 1/2] fix(view): keep the cursor on its code line --- doc/diffview.txt | 18 ++ lua/diffview/scene/views/diff/diff_view.lua | 4 +- .../scene/views/standard/standard_view.lua | 216 +++++++++++-- lua/diffview/session.lua | 27 +- .../tests/functional/cursor_carry_spec.lua | 299 ++++++++++++++++++ .../file_history_cursor_carry_spec.lua | 266 ++++++++++++++++ .../tests/functional/focus_diff_spec.lua | 12 +- .../tests/functional/session_spec.lua | 71 ++++- lua/diffview/tests/helpers.lua | 39 +++ 9 files changed, 915 insertions(+), 37 deletions(-) create mode 100644 lua/diffview/tests/functional/cursor_carry_spec.lua create mode 100644 lua/diffview/tests/functional/file_history_cursor_carry_spec.lua diff --git a/doc/diffview.txt b/doc/diffview.txt index 340b1a85..9e514bda 100644 --- a/doc/diffview.txt +++ b/doc/diffview.txt @@ -2127,6 +2127,24 @@ view_windo({cmd}) *diffview-actions-view_windo* layout. These symbols correspond with the figures under |diffview-config-view.x.layout|. + *diffview-cursor-carry* +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: + + • |diffview-actions-select_next_entry| + • |diffview-actions-select_prev_entry| + • |diffview-actions-select_next_commit| + • |diffview-actions-select_prev_commit| + +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. + *diffview-unused-actions* Unused actions ~ diff --git a/lua/diffview/scene/views/diff/diff_view.lua b/lua/diffview/scene/views/diff/diff_view.lua index f570d7af..7ec1a52a 100644 --- a/lua/diffview/scene/views/diff/diff_view.lua +++ b/lua/diffview/scene/views/diff/diff_view.lua @@ -651,11 +651,11 @@ end) ---listener handles it the same as session-restored state. `winrestview` (the ---consumer in `restore_main_view`) clamps the upper bound; we clamp the ---lower at 1 so non-positive rows don't reach `nvim_win_set_cursor`. ----@param cursor_map table +---@param cursor_map table ---@param options DiffViewOptions function DiffView._seed_cursor_map_from_selection(cursor_map, options) if options.selected_row and options.selected_file then - cursor_map[options.selected_file] = { lnum = math.max(1, options.selected_row) } + cursor_map[options.selected_file] = { winview = { lnum = math.max(1, options.selected_row) } } end end diff --git a/lua/diffview/scene/views/standard/standard_view.lua b/lua/diffview/scene/views/standard/standard_view.lua index 96d51a2b..6a4b4e76 100644 --- a/lua/diffview/scene/views/standard/standard_view.lua +++ b/lua/diffview/scene/views/standard/standard_view.lua @@ -15,6 +15,11 @@ local utils = lazy.require("diffview.utils") ---@module "diffview.utils" local api = vim.api local await, pawait = async.await, async.pawait +-- 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 + local M = {} ---Predicate matching `DiffView.update_files_impl`'s cancellation guard. @@ -36,11 +41,36 @@ end ---@field cur_entry FileEntry ---@field layouts table ---@field no_panel? boolean # Per-view `--no-panel` override. When set, takes precedence over the panel's `show` config (`nil` means defer to config). ----@field cursor_map table # Repo-relative path → `winsaveview()` dict; consumed by `file_open_new` to restore cursor + viewport on first open after session restore. +---@field cursor_map table # Repo-relative path → the cursor and viewport last seen for that path. Consumed the next time the path opens. ---@field package _set_file_in_flight Future? # Active `_set_file` worker; queued callers await this so `await(set_file)` returns only after the latest pending file is opened. ---@field package _set_file_pending FileEntry? # Newest file queued while `_set_file_in_flight` is set; the worker picks it up before terminating. local StandardView = oop.create_class("StandardView", View.__get()) +---The key the arriving entry will look its state up under, when a rename links +---it to the entry being left. `--follow` lists a file under its old name in +---every commit older than the rename, so a step across that commit leaves one +---path and arrives at another while the code stays the same. Only the entry +---for the renaming commit carries both names. +---@param from FileEntry # The entry being left. +---@param to FileEntry? # The entry being opened. +---@return string? +function StandardView._rename_alias(from, to) + if not (to and to.path) then + return nil + end + -- `oldpath` also names the source of a copy (status `C`), where the two + -- paths are two files rather than one file under two names. Line-trace + -- entries carry no status at all, so exclude copies rather than demand a + -- rename. + if from.oldpath == to.path and from.status ~= "C" then + return to.path + end + if to.oldpath == from.path and to.status ~= "C" then + return to.path + end + return nil +end + ---StandardView constructor function StandardView:init(opt) opt = opt or {} @@ -83,40 +113,178 @@ function StandardView:init(opt) self.cursor_map = opt.cursor_map or {} - -- Snapshot the leaving file's view state before `_detach_files_for_next` - -- strips the window association, so mid-navigation saves keep cursor + - -- viewport for every visited file. - self.emitter:on("file_open_pre", function(_, _, cur_entry) + -- Snapshot the leaving file's view state on every swap, so mid-navigation + -- saves keep cursor + viewport for every visited file. + self.emitter:on("file_open_pre", function(_, target, cur_entry) if cur_entry and cur_entry.path then - self:snapshot_main_view(cur_entry.path) + self:snapshot_main_view(cur_entry.path, StandardView._rename_alias(cur_entry, target)) + end + end) + + -- A re-visited entry gets no `file_open_new`, so a step back would keep the + -- cursor the last visit left behind. `opened` is still false on a first + -- open, leaving those to `file_open_new` and its default placement. + self.emitter:on("file_open_post", function(_, entry) + if entry and entry.opened and entry.path then + self:restore_main_view(entry.path) end end) self.emitter:on("post_layout", utils.bind(self.post_layout, self)) end +---@class StandardView.CarryState +---@field winview table # A `winsaveview()` dict. +---@field bufnr? integer # The buffer `winview` was captured in. Missing on a state restored from a session sidecar. +---@field bufname? string # The name `bufnr` carried at capture time. + +---@param winid integer +---@return StandardView.CarryState? +local function capture_winview(winid) + local ok, winview = pcall(api.nvim_win_call, winid, function() + return vim.fn.winsaveview() + end) + if not (ok and type(winview) == "table") then + return nil + end + local bufnr = api.nvim_win_get_buf(winid) + return { winview = winview, bufnr = bufnr, bufname = api.nvim_buf_get_name(bufnr) } +end + +---Translate `state` into the window's current buffer, then apply it. +---@param winid integer +---@param state StandardView.CarryState +---@return boolean # `true` when `winrestview` ran without error. +local function apply_winview(winid, state) + local from_buf = state.bufnr + -- Neovim can hand a wiped buffer's handle to another file. Diffing against + -- that file would place the cursor on an unrelated line. + if + from_buf + and not (api.nvim_buf_is_valid(from_buf) and api.nvim_buf_get_name(from_buf) == state.bufname) + then + from_buf = nil + end + + local target = + StandardView._translate_winview(state.winview, from_buf, api.nvim_win_get_buf(winid)) + + return (pcall(api.nvim_win_call, winid, function() + vim.fn.winrestview(target) + end)) +end + +---A buffer's contents 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 bufnr integer +---@return string +local function buf_text(bufnr) + return table.concat(api.nvim_buf_get_lines(bufnr, 0, -1, false), "\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 +function StandardView._map_lnum(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) + end + delta = delta + count_b - count_a + end + end + + return math.max(1, lnum + delta) +end + +---Rewrite a `winsaveview` dict so its cursor points at the same code in +---`to_buf` as it did in `from_buf`. Returns `winview` unchanged whenever the +---translation can't be computed, which is the untranslated behaviour. +---@param winview table # `winsaveview()` dict. +---@param from_buf integer? # Buffer `winview` was captured in. +---@param to_buf integer # Buffer `winview` is about to be applied in. +---@return table +function StandardView._translate_winview(winview, from_buf, to_buf) + if type(winview.lnum) ~= "number" or from_buf == nil or from_buf == to_buf then + return winview + end + if not (api.nvim_buf_is_valid(from_buf) and api.nvim_buf_is_loaded(from_buf)) then + return winview + end + + local ok, hunks = pcall(diff, buf_text(from_buf), buf_text(to_buf), { result_type = "indices" }) + + if not (ok and type(hunks) == "table") then + return winview + end + + local lnum = StandardView._map_lnum(hunks, winview.lnum) + + if lnum == winview.lnum then + return winview + end + + local out = vim.deepcopy(winview) + out.lnum = lnum + + if type(out.topline) == "number" then + -- Keeps the cursor at its old screen offset instead of outside the + -- replayed window. + out.topline = math.max(1, out.topline + (lnum - winview.lnum)) + end + + return out +end + ---Snapshot the main diff window's cursor + viewport into ----`self.cursor_map[path]`. No-op if the main window is unavailable. +---`self.cursor_map[path]`, along with the buffer it was taken in. The buffer +---is what lets `restore_main_view` translate the line number instead of +---replaying it raw. No-op if the main window is unavailable. ---@param path string repo-relative file path; the map key. -function StandardView:snapshot_main_view(path) +---@param alias? string A second key holding the same state, for a file the +---next entry lists under another name. See `_rename_alias`. +function StandardView:snapshot_main_view(path, alias) local layout = self.cur_layout local main = layout and layout:get_main_win() if not (main and main.id and api.nvim_win_is_valid(main.id)) then return end - local ok, vs = pcall(api.nvim_win_call, main.id, function() - return vim.fn.winsaveview() - end) - if ok and type(vs) == "table" then - self.cursor_map[path] = vs + + local entry = capture_winview(main.id) + if entry then + self.cursor_map[path] = entry + if alias and alias ~= path then + self.cursor_map[alias] = entry + end end end ----Pop and apply the saved `winsaveview` dict for `path`. One-shot per ----path: the entry is removed once successfully applied, so re-visits ----fall through to the caller's default cursor placement. A failed apply ----(main window unavailable, or `winrestview` errors) leaves the saved ----state in place so a later attempt can still restore it. +---Pop and apply the saved view state for `path`. Diffing the snapshotted +---buffer against the arriving one moves the cursor line with its code, so a +---step lands on the same line of code rather than the same line number. +---A successful apply drops the entry; the next swap away from `path` puts a +---fresh one back. A failed apply (no main window, or `winrestview` errors) +---keeps the entry for a later attempt. ---@param path string repo-relative file path. ---@return boolean # `true` when a saved state was applied successfully. function StandardView:restore_main_view(path) @@ -129,9 +297,15 @@ function StandardView:restore_main_view(path) if not (win and win.id and api.nvim_win_is_valid(win.id)) then return false end - local ok = pcall(api.nvim_win_call, win.id, function() - vim.fn.winrestview(target) - end) + + if type(target.winview) ~= "table" then + return false + end + + -- We place only the main window. The layout's other windows follow it + -- through `'cursorbind'`. + local ok = apply_winview(win.id, target) + if ok then self.cursor_map[path] = nil end diff --git a/lua/diffview/session.lua b/lua/diffview/session.lua index 86fbba65..759d0653 100644 --- a/lua/diffview/session.lua +++ b/lua/diffview/session.lua @@ -197,7 +197,7 @@ end ---@field range? integer[] `{line1, line2}` for `:DiffviewFileHistory` ranges. ---@field tabpage_order integer Sort key so restored views come back in their original tab order. ---@field selected_file? string Repo-relative path of the active file (`DiffviewOpen` only). ----@field cursor_map? table Repo-relative path → `winsaveview()` dict for every file visited at save time. +---@field cursor_map? table Repo-relative path → the cursor and viewport of every file visited at save time. The loose type is deliberate: this is whatever the sidecar holds. `sanitize_cursor_map` turns it into `StandardView.CarryState`s on read. ---@field toplevel? string Repo root, for debugging. ---Absolute paths of LOCAL buffers the prior diffview session created @@ -266,7 +266,20 @@ local function capture_view(view) end if view.cursor_map and next(view.cursor_map) ~= nil then - entry.cursor_map = view.cursor_map + -- Only the viewport goes to disk, and in the bare `winsaveview` shape + -- v1 has always written, so a sidecar from here still restores in an + -- older checkout that hands the entry straight to `winrestview`. A + -- buffer handle and the name it carried describe this nvim run, and + -- `sanitize_cursor_map` would drop them anyway. + local cursor_map = {} + for path, state in pairs(view.cursor_map) do + if type(state.winview) == "table" then + cursor_map[path] = state.winview + end + end + if next(cursor_map) ~= nil then + entry.cursor_map = cursor_map + end end return entry @@ -401,10 +414,11 @@ local function warn_failed(err) end end ----Filter a raw sidecar `cursor_map` to string→table entries, or nil ----if nothing usable remains. +---Turn a raw sidecar `cursor_map` into `StandardView.CarryState`s, or nil if +---nothing usable remains. Buffer handles do not survive. This nvim run may +---have handed the same number to an unrelated file. ---@param raw any ----@return table? +---@return table? local function sanitize_cursor_map(raw) if type(raw) ~= "table" then return nil @@ -412,7 +426,8 @@ local function sanitize_cursor_map(raw) local out = {} for path, value in pairs(raw) do if type(path) == "string" and type(value) == "table" then - out[path] = value + -- A sidecar written before the carry holds the `winsaveview` dict bare. + out[path] = { winview = type(value.winview) == "table" and value.winview or value } end end if next(out) == nil then diff --git a/lua/diffview/tests/functional/cursor_carry_spec.lua b/lua/diffview/tests/functional/cursor_carry_spec.lua new file mode 100644 index 00000000..eb350da3 --- /dev/null +++ b/lua/diffview/tests/functional/cursor_carry_spec.lua @@ -0,0 +1,299 @@ +local helpers = require("diffview.tests.helpers") +local StandardView = require("diffview.scene.views.standard.standard_view").StandardView + +local api = vim.api +local eq = helpers.eq +local body = helpers.body + +local function window_pool() + local wins = {} + + return { + ---@param lines string[] + ---@return integer winid, integer bufnr + open = function(lines) + vim.cmd("new") + local win, buf = api.nvim_get_current_win(), api.nvim_get_current_buf() + vim.bo[buf].buftype = "nofile" + api.nvim_buf_set_lines(buf, 0, -1, false, lines) + wins[#wins + 1] = win + return win, buf + end, + close_all = function() + for _, win in ipairs(wins) do + pcall(api.nvim_win_close, win, true) + end + wins = {} + end, + } +end + +---A view whose layout is shaped like `Diff2`: two windows, `b` is the main one. +---@param a_win integer +---@param b_win integer +local function make_view(a_win, b_win) + return setmetatable({ + cursor_map = {}, + cur_layout = { + symbols = { "a", "b" }, + a = { id = a_win }, + b = { id = b_win }, + get_main_win = function(self) + return self.b + end, + }, + }, { __index = StandardView }) +end + +describe("diffview.standard_view _map_lnum", function() + it("leaves a line preceding every hunk untouched", function() + eq(3, StandardView._map_lnum({ { 10, 0, 11, 5 } }, 3)) + end) + + it("shifts a line following an insertion by the inserted count", function() + eq(35, StandardView._map_lnum({ { 0, 0, 1, 30 } }, 5)) + end) + + it("shifts a line following a deletion back by the deleted count", function() + eq(5, StandardView._map_lnum({ { 1, 30, 0, 0 } }, 35)) + end) + + it("maps a line inside a changed hunk to the hunk start on the new side", function() + eq(12, StandardView._map_lnum({ { 10, 4, 12, 6 } }, 11)) + end) + + it("maps a line inside a deleted hunk to the last surviving line before it", function() + eq(4, StandardView._map_lnum({ { 5, 3, 4, 0 } }, 6)) + end) + + it("accumulates deltas across several preceding hunks", function() + eq(14, StandardView._map_lnum({ { 0, 0, 1, 3 }, { 5, 4, 9, 2 } }, 13)) + end) + + it("returns the line unchanged for an empty diff", function() + eq(7, StandardView._map_lnum({}, 7)) + end) + + it("never returns a line below 1", function() + eq(1, StandardView._map_lnum({ { 1, 3, 0, 0 } }, 2)) + end) +end) + +describe("diffview.standard_view _translate_winview", function() + local pool = window_pool() + + after_each(pool.close_all) + + it("moves the cursor onto the same text after a prepend is dropped", function() + local _, old = pool.open(vim.list_extend(body("head", 30), body("body", 20))) + local _, new = pool.open(body("body", 20)) + + local out = StandardView._translate_winview({ lnum = 35, topline = 30 }, old, new) + + eq(5, out.lnum) + eq("body 5", api.nvim_buf_get_lines(new, out.lnum - 1, out.lnum, false)[1]) + end) + + it("shifts the viewport with the cursor", function() + local _, old = pool.open(body("body", 20)) + local _, new = pool.open(vim.list_extend(body("head", 30), body("body", 20))) + + local out = StandardView._translate_winview({ lnum = 5, topline = 3 }, old, new) + + eq(35, out.lnum) + eq(33, out.topline) + end) + + it("leaves the dict untouched when the buffers are identical", function() + local _, old = pool.open(body("body", 20)) + local _, new = pool.open(body("body", 20)) + local winview = { lnum = 7, topline = 4 } + + eq(winview, StandardView._translate_winview(winview, old, new)) + end) + + it("maps a line inside a deleted tail onto the last surviving line", function() + local _, old = pool.open(body("body", 20)) + local _, new = pool.open(body("body", 5)) + + eq(5, StandardView._translate_winview({ lnum = 18 }, old, new).lnum) + end) + + it("leaves an untranslatable line past the new EOF for `winrestview` to clamp", function() + local new_win, new = pool.open(body("body", 5)) + + local out = StandardView._translate_winview({ lnum = 18 }, nil, new) + + api.nvim_win_call(new_win, function() + vim.fn.winrestview(out) + end) + eq(5, api.nvim_win_get_cursor(new_win)[1]) + end) + + it("falls back to the raw dict when the source buffer is gone", function() + local win, old = pool.open(body("body", 20)) + local _, new = pool.open(body("body", 5)) + api.nvim_win_close(win, true) + api.nvim_buf_delete(old, { force = true }) + + eq({ lnum = 18 }, StandardView._translate_winview({ lnum = 18 }, old, new)) + end) + + it("falls back to the raw dict when no source buffer was recorded", function() + local _, new = pool.open(body("body", 5)) + + eq({ lnum = 18 }, StandardView._translate_winview({ lnum = 18 }, nil, new)) + end) + + it("shifts a line sitting directly below an insertion", function() + local _, old = pool.open(body("body", 10)) + local lines = body("body", 3) + table.insert(lines, "inserted") + vim.list_extend(lines, vim.list_slice(body("body", 10), 4, 10)) + local _, new = pool.open(lines) + + local out = StandardView._translate_winview({ lnum = 4 }, old, new) + + eq(5, out.lnum) + eq("body 4", api.nvim_buf_get_lines(new, out.lnum - 1, out.lnum, false)[1]) + end) + + it("nets out a deletion and an insertion above the cursor", function() + local _, old = + pool.open(vim.list_extend(vim.list_extend(body("del", 2), body("keep", 5)), body("tail", 5))) + local _, new = + pool.open(vim.list_extend(vim.list_extend(body("keep", 5), body("new", 3)), body("tail", 5))) + + local out = StandardView._translate_winview({ lnum = 10 }, old, new) + + eq(11, out.lnum) + eq("tail 3", api.nvim_buf_get_lines(new, out.lnum - 1, out.lnum, false)[1]) + end) + + it("maps a line inside a rewritten block to the block's first new line", function() + local _, old = + pool.open(vim.list_extend(vim.list_extend(body("keep", 3), body("old", 2)), body("keep", 3))) + local _, new = pool.open( + vim.list_extend(vim.list_extend(body("keep", 3), body("fresh", 3)), body("keep", 3)) + ) + + local out = StandardView._translate_winview({ lnum = 5 }, old, new) + + eq(4, out.lnum) + eq("fresh 1", api.nvim_buf_get_lines(new, out.lnum - 1, out.lnum, false)[1]) + end) +end) + +describe("diffview.standard_view _rename_alias", function() + it("aliases the arriving path when the leaving entry names it as its old path", function() + local from = { path = "moved.txt", oldpath = "keep.txt", status = "R" } + eq("keep.txt", StandardView._rename_alias(from, { path = "keep.txt", status = "M" })) + end) + + it("aliases the arriving path when the arriving entry names the leaving one", function() + local to = { path = "moved.txt", oldpath = "keep.txt", status = "R" } + eq("moved.txt", StandardView._rename_alias({ path = "keep.txt", status = "M" }, to)) + end) + + it("still aliases when neither entry carries a status, as in line-trace mode", function() + local to = { path = "moved.txt", oldpath = "keep.txt" } + eq("moved.txt", StandardView._rename_alias({ path = "keep.txt" }, to)) + end) + + it("does not alias a copy, whose `oldpath` names a different file", function() + -- `src.txt` survives the copy, so `copy.txt` opens on its own first + -- change rather than inheriting the source's cursor. + local to = { path = "copy.txt", oldpath = "src.txt", status = "C" } + eq(nil, StandardView._rename_alias({ path = "src.txt", status = "M" }, to)) + eq(nil, StandardView._rename_alias(to, { path = "src.txt", status = "M" })) + end) + + it("does not alias when there is no arriving entry", function() + eq(nil, StandardView._rename_alias({ path = "moved.txt", oldpath = "keep.txt" }, nil)) + end) +end) + +describe("diffview.standard_view carry snapshot/restore", function() + local pool = window_pool() + + after_each(pool.close_all) + + it("stores the main window's view state and the buffer it came from", function() + local a_win = pool.open(body("old", 20)) + local b_win, b_buf = pool.open(body("body", 50)) + local view = make_view(a_win, b_win) + + api.nvim_set_current_win(b_win) + api.nvim_win_set_cursor(b_win, { 35, 0 }) + view:snapshot_main_view("file.txt") + + local saved = view.cursor_map["file.txt"] + eq(35, saved.winview.lnum) + eq(b_buf, saved.bufnr) + end) + + it("translates the main window on restore", function() + local a_win = pool.open(vim.list_extend(body("head", 30), body("body", 20))) + local b_win = pool.open(vim.list_extend(body("head", 30), body("body", 20))) + local view = make_view(a_win, b_win) + + api.nvim_win_set_cursor(b_win, { 35, 0 }) + view:snapshot_main_view("file.txt") + + -- The next commit shows the same path in a fresh buffer, without the + -- 30-line prepend. That swap is what the carry has to translate across. + local buf = api.nvim_create_buf(false, true) + api.nvim_buf_set_lines(buf, 0, -1, false, body("body", 20)) + api.nvim_win_set_buf(b_win, buf) + + assert.is_true(view:restore_main_view("file.txt")) + eq(5, api.nvim_win_get_cursor(b_win)[1]) + end) + + it("clears the entry so a re-visit falls through", function() + local a_win = pool.open(body("body", 20)) + local b_win = pool.open(body("body", 20)) + local view = make_view(a_win, b_win) + + api.nvim_set_current_win(b_win) + view:snapshot_main_view("file.txt") + + assert.is_true(view:restore_main_view("file.txt")) + eq(nil, view.cursor_map["file.txt"]) + eq(false, view:restore_main_view("file.txt")) + end) + + it("restores a state that carries no buffer, as a session sidecar does", function() + local a_win = pool.open(body("body", 20)) + local b_win = pool.open(body("body", 20)) + local view = make_view(a_win, b_win) + view.cursor_map["file.txt"] = { winview = { lnum = 12 } } + + assert.is_true(view:restore_main_view("file.txt")) + eq(12, api.nvim_win_get_cursor(b_win)[1]) + end) + + it("refuses an entry that carries no view state", function() + local a_win = pool.open(body("body", 20)) + local b_win = pool.open(body("body", 20)) + local view = make_view(a_win, b_win) + view.cursor_map["file.txt"] = { lnum = 12 } + + eq(false, view:restore_main_view("file.txt")) + end) + + it("skips the translation when the recorded handle now names another buffer", function() + local a_win = pool.open(body("body", 20)) + local b_win, b_buf = pool.open(body("body", 20)) + local view = make_view(a_win, b_win) + + view.cursor_map["file.txt"] = { + winview = { lnum = 12 }, + bufnr = b_buf, + bufname = "/gone/from/this/session.txt", + } + + assert.is_true(view:restore_main_view("file.txt")) + eq(12, api.nvim_win_get_cursor(b_win)[1]) + end) +end) diff --git a/lua/diffview/tests/functional/file_history_cursor_carry_spec.lua b/lua/diffview/tests/functional/file_history_cursor_carry_spec.lua new file mode 100644 index 00000000..787dd814 --- /dev/null +++ b/lua/diffview/tests/functional/file_history_cursor_carry_spec.lua @@ -0,0 +1,266 @@ +local config = require("diffview.config") +local helpers = require("diffview.tests.helpers") +local lib = require("diffview.lib") + +local api = vim.api +local eq = helpers.eq +local commit = helpers.commit +local line_at = helpers.line_at +local body = helpers.body +local write = helpers.write + +-- Every commit prepends a different amount, so no two steps shift by the same +-- number of lines: +-- +-- c1 body 1..20 (20 lines) +-- c2 head 1..30 + body (50) +-- c3 mid 1..5 + head + body (55) +-- c4 tag 1..10 + mid + head + body (65) +local function make_repo() + local repo = helpers.init_repo() + local content = body("body", 20) + write(repo, "file.txt", content) + commit(repo, "c1") + + for i, layer in ipairs({ { "head", 30 }, { "mid", 5 }, { "tag", 10 } }) do + content = vim.list_extend(body(layer[1], layer[2]), content) + write(repo, "file.txt", content) + commit(repo, "c" .. i + 1) + end + + return repo +end + +describe("file history cursor carry", function() + local repo, cwd, view, original_config + + before_each(function() + original_config = vim.deepcopy(config.get_config()) + config.get_config().use_icons = false + repo = make_repo() + cwd = vim.fn.getcwd() + vim.cmd("cd " .. vim.fn.fnameescape(repo)) + end) + + after_each(function() + vim.cmd("cd " .. vim.fn.fnameescape(cwd)) + helpers.close_view(view) + view = nil + helpers.cleanup_repo(repo) + config.setup(original_config) + end) + + ---@return integer # The layout's current main window. + local function main_win() + return view.cur_layout:get_main_win().id + end + + ---Open the single-file history and wait until every commit is listed and the + ---`b` side holds the full c4 content. + ---@return integer main_win + local function open_history() + view = lib.file_history(nil, { "file.txt" }) + assert.is_not_nil(view) + view:open() + + assert.is_true( + vim.wait(10000, function() + return view.ready and #view.panel.entries >= 4 and view.cur_layout ~= nil + end), + "view never became ready" + ) + assert.is_true( + vim.wait(10000, function() + return api.nvim_buf_line_count(api.nvim_win_get_buf(main_win())) >= 65 + end), + "the b-side buffer never loaded" + ) + + return main_win() + end + + ---Emit `event` and wait until the layout shows a different buffer. + ---@param event string + ---@param idx integer # Index of the entry the step must land on. + local function step(event, idx) + local prev = api.nvim_win_get_buf(main_win()) + + view.emitter:emit(event) + + assert.is_true( + vim.wait(10000, function() + return view.panel.cur_item[1] == view.panel.entries[idx] + and api.nvim_win_get_buf(main_win()) ~= prev + end), + ("%s never reached entry %d"):format(event, idx) + ) + vim.wait(200) + end + + ---Walk c4 -> c1 with `event`, requiring the cursor to hold its code line. + ---@param event string + local function assert_carries_across(event) + local main = open_history() + api.nvim_set_current_win(main) + api.nvim_win_set_cursor(main, { 50, 0 }) + eq("body 5", line_at(main)) + + for idx = 2, 4 do + step(event, idx) + eq("body 5", line_at(main_win())) + end + + eq(20, api.nvim_buf_line_count(api.nvim_win_get_buf(main_win()))) + end + + it("keeps the cursor on the same code across select_next_commit", function() + assert_carries_across("select_next_commit") + end) + + it("keeps the cursor on the same code across select_next_entry", function() + assert_carries_across("select_next_entry") + end) + + -- Stepping back lands on an entry that was already opened once, which is the + -- case `file_open_new` does not cover. + it("carries the cursor back onto a commit visited earlier", function() + local main = open_history() + api.nvim_set_current_win(main) + + step("select_next_commit", 2) + + -- c3 drops c4's 10-line `tag` block, so `head 10` sits 10 rows higher here + -- than it does in the commit we came from. + api.nvim_win_set_cursor(main_win(), { 15, 0 }) + eq("head 10", line_at(main_win())) + + step("select_prev_commit", 1) + + eq("head 10", line_at(main_win())) + eq(25, api.nvim_win_get_cursor(main_win())[1]) + end) +end) + +-- `--follow` keeps one file's history across a rename, so two neighbouring +-- entries list the same code under two different paths: +-- +-- n1 keep.txt body 1..20 +-- n2 keep.txt body 5 rewritten +-- n3 moved.txt the rename, no content change +-- n4 moved.txt head 1..10 prepended +local function make_rename_repo() + local repo = helpers.init_repo() + local lines = body("body", 20) + write(repo, "keep.txt", lines) + commit(repo, "n1") + + lines[5] = "body 5 rewritten" + write(repo, "keep.txt", lines) + commit(repo, "n2") + + helpers.run({ "git", "mv", "keep.txt", "moved.txt" }, repo) + commit(repo, "n3") + + lines = vim.list_extend(body("head", 10), lines) + write(repo, "moved.txt", lines) + commit(repo, "n4") + + return repo +end + +describe("file history cursor carry across a rename", function() + local repo, cwd, view, original_config + + before_each(function() + original_config = vim.deepcopy(config.get_config()) + config.get_config().use_icons = false + repo = make_rename_repo() + cwd = vim.fn.getcwd() + vim.cmd("cd " .. vim.fn.fnameescape(repo)) + end) + + after_each(function() + vim.cmd("cd " .. vim.fn.fnameescape(cwd)) + helpers.close_view(view) + view = nil + helpers.cleanup_repo(repo) + config.setup(original_config) + end) + + local function main_win() + return view.cur_layout:get_main_win().id + end + + ---Open the followed history of `moved.txt` and wait for n4's 30 lines. + local function open_history() + view = lib.file_history(nil, { "--follow", "moved.txt" }) + assert.is_not_nil(view) + view:open() + + assert.is_true( + vim.wait(10000, function() + return view.ready and #view.panel.entries >= 4 and view.cur_layout ~= nil + end), + "view never became ready" + ) + assert.is_true( + vim.wait(10000, function() + return api.nvim_buf_line_count(api.nvim_win_get_buf(main_win())) == 30 + end), + "the b-side buffer never loaded" + ) + + api.nvim_set_current_win(main_win()) + end + + ---@param event string + ---@param idx integer # Index of the entry the step must land on. + ---@param path string # The name the file carries in that entry. + ---@param lines integer # Its line count, so the swap is complete before we read. + local function step(event, idx, path, lines) + local prev = api.nvim_win_get_buf(main_win()) + + view.emitter:emit(event) + + assert.is_true( + vim.wait(10000, function() + local buf = api.nvim_win_get_buf(main_win()) + return view.panel.cur_item[1] == view.panel.entries[idx] + and buf ~= prev + and api.nvim_buf_line_count(buf) == lines + end), + ("%s never reached entry %d"):format(event, idx) + ) + eq(path, view.panel.cur_item[2].path) + vim.wait(200) + end + + it("carries the cursor from the new name onto the old one", function() + open_history() + api.nvim_win_set_cursor(main_win(), { 22, 0 }) + eq("body 12", line_at(main_win())) + + step("select_next_commit", 2, "moved.txt", 20) + eq("body 12", line_at(main_win())) + eq(12, api.nvim_win_get_cursor(main_win())[1]) + + -- n2 lists the file as `keep.txt`. Nothing but `oldpath` on n3's entry + -- says the two names hold the same code. + step("select_next_commit", 3, "keep.txt", 20) + eq("body 12", line_at(main_win())) + eq(12, api.nvim_win_get_cursor(main_win())[1]) + end) + + it("carries the cursor from the old name onto the new one", function() + open_history() + step("select_next_commit", 2, "moved.txt", 20) + step("select_next_commit", 3, "keep.txt", 20) + + api.nvim_win_set_cursor(main_win(), { 12, 0 }) + eq("body 12", line_at(main_win())) + + step("select_prev_commit", 2, "moved.txt", 20) + eq("body 12", line_at(main_win())) + eq(12, api.nvim_win_get_cursor(main_win())[1]) + end) +end) diff --git a/lua/diffview/tests/functional/focus_diff_spec.lua b/lua/diffview/tests/functional/focus_diff_spec.lua index 1b870502..66b7802a 100644 --- a/lua/diffview/tests/functional/focus_diff_spec.lua +++ b/lua/diffview/tests/functional/focus_diff_spec.lua @@ -227,7 +227,7 @@ describe("DiffView._seed_cursor_map_from_selection", function() selected_row = 42, selected_file = "foo.lua", }) - assert.same({ lnum = 42 }, cursor_map["foo.lua"]) + assert.same({ winview = { lnum = 42 } }, cursor_map["foo.lua"]) end) it("clamps the row to 1 when selected_row is 0", function() @@ -236,7 +236,7 @@ describe("DiffView._seed_cursor_map_from_selection", function() selected_row = 0, selected_file = "foo.lua", }) - assert.same({ lnum = 1 }, cursor_map["foo.lua"]) + assert.same({ winview = { lnum = 1 } }, cursor_map["foo.lua"]) end) it("clamps the row to 1 when selected_row is negative", function() @@ -245,7 +245,7 @@ describe("DiffView._seed_cursor_map_from_selection", function() selected_row = -5, selected_file = "foo.lua", }) - assert.same({ lnum = 1 }, cursor_map["foo.lua"]) + assert.same({ winview = { lnum = 1 } }, cursor_map["foo.lua"]) end) it("leaves cursor_map untouched when selected_file is missing", function() @@ -261,12 +261,12 @@ describe("DiffView._seed_cursor_map_from_selection", function() end) it("does not overwrite unrelated entries", function() - local cursor_map = { ["other.lua"] = { lnum = 7, col = 3 } } + local cursor_map = { ["other.lua"] = { winview = { lnum = 7, col = 3 } } } DiffView._seed_cursor_map_from_selection(cursor_map, { selected_row = 42, selected_file = "foo.lua", }) - assert.same({ lnum = 7, col = 3 }, cursor_map["other.lua"]) - assert.same({ lnum = 42 }, cursor_map["foo.lua"]) + assert.same({ winview = { lnum = 7, col = 3 } }, cursor_map["other.lua"]) + assert.same({ winview = { lnum = 42 } }, cursor_map["foo.lua"]) end) end) diff --git a/lua/diffview/tests/functional/session_spec.lua b/lua/diffview/tests/functional/session_spec.lua index 358ff491..6f0e5771 100644 --- a/lua/diffview/tests/functional/session_spec.lua +++ b/lua/diffview/tests/functional/session_spec.lua @@ -369,6 +369,7 @@ describe("session save/restore", function() adapter = opts.adapter or { ctx = { toplevel = "/fake/repo" } }, panel = opts.panel, cur_layout = opts.cur_layout, + cursor_map = opts.cursor_map, } end @@ -452,6 +453,35 @@ describe("session save/restore", function() assert.are.same({ "--follow", "src/foo.lua" }, payload.views[2].args) end) + it("writes cursor state in the bare v1 `winsaveview` shape", function() + lib.views = { + fake_view({ + _session_record = { kind = "file_history", args = { "src/main.lua" } }, + tabpage = api.nvim_get_current_tabpage(), + cursor_map = { + ["src/main.lua"] = { + winview = { lnum = 42, col = 3, topline = 10 }, + bufnr = 7, + bufname = "/fake/repo/src/main.lua", + }, + }, + }), + } + + session.save() + + local f = assert(io.open(tmp_session .. ".diffview.json", "r")) + local payload = vim.json.decode(f:read("*a")) + f:close() + + -- A checkout without the carry hands this dict straight to + -- `winrestview`, so the entry stays bare and no buffer handle + -- reaches disk. + assert.are.same({ + ["src/main.lua"] = { lnum = 42, col = 3, topline = 10 }, + }, payload.views[1].cursor_map) + end) + it("sorts entries by tabpage order", function() local tab1 = api.nvim_get_current_tabpage() vim.cmd("tabnew") @@ -676,9 +706,46 @@ describe("session save/restore", function() assert.is_not_nil(captured) assert.equals("src/main.lua", captured.options.selected_file) + -- A pre-carry sidecar holds bare `winsaveview` dicts. Reading one + -- lifts them into the carry shape. assert.are.same({ - ["src/main.lua"] = { lnum = 42, col = 3, topline = 10 }, - ["src/other.lua"] = { lnum = 100, col = 0, topline = 80 }, + ["src/main.lua"] = { winview = { lnum = 42, col = 3, topline = 10 } }, + ["src/other.lua"] = { winview = { lnum = 100, col = 0, topline = 80 } }, + }, captured.cursor_map) + end) + + it("drops buffer handles from a restored `cursor_map`", function() + local captured + lib.file_history = function(_, _) + local v = { tabpage = nil, open = function() end } + captured = v + return v + end + local f = assert(io.open(tmp_session .. ".diffview.json", "w")) + f:write(vim.json.encode({ + version = 1, + views = { + { + kind = "file_history", + args = { "src/main.lua" }, + tabpage_order = 1, + cursor_map = { + ["src/main.lua"] = { + winview = { lnum = 42, col = 3, topline = 10 }, + bufnr = 7, + focus = { sym = "a", bufnr = 8, vs = { lnum = 12 } }, + }, + }, + }, + }, + })) + f:close() + + session.restore() + + assert.is_not_nil(captured) + assert.are.same({ + ["src/main.lua"] = { winview = { lnum = 42, col = 3, topline = 10 } }, }, captured.cursor_map) end) diff --git a/lua/diffview/tests/helpers.lua b/lua/diffview/tests/helpers.lua index 8cde39c0..10b45ec8 100644 --- a/lua/diffview/tests/helpers.lua +++ b/lua/diffview/tests/helpers.lua @@ -107,4 +107,43 @@ function M.close_view(view) require("diffview.lib").dispose_view(view) end +---Stage everything in `repo` and commit it. +---@param repo string +---@param msg string +function M.commit(repo, msg) + M.run({ "git", "add", "-A" }, repo) + M.run({ "git", "-c", "commit.gpgsign=false", "commit", "-q", "-m", msg }, repo) +end + +---`n` numbered lines, `prefix 1` through `prefix n`. Distinct prefixes make a +---fixture's blocks tell each other apart in a failure message. +---@param prefix string +---@param n integer +---@return string[] +function M.body(prefix, n) + local out = {} + for i = 1, n do + out[i] = ("%s %d"):format(prefix, i) + end + return out +end + +---Write `lines` to `name` under `repo`, newline-terminated. +---@param repo string +---@param name string +---@param lines string[] +function M.write(repo, name, lines) + local f = assert(io.open(repo .. "/" .. name, "w")) + f:write(table.concat(lines, "\n") .. "\n") + f:close() +end + +---The text under the cursor in `win`. +---@param win integer +---@return string? +function M.line_at(win) + local row = vim.api.nvim_win_get_cursor(win)[1] + return vim.api.nvim_buf_get_lines(vim.api.nvim_win_get_buf(win), row - 1, row, false)[1] +end + return M From 7ae201ef3612b9628a6b2281e36b1fbd4d335fa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Fl=C3=BCgge?= <952313+seflue@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:32:12 +0200 Subject: [PATCH 2/2] feat(view): open the fold under the carried cursor A diff buffer opens with `'foldlevel'` at its default of 0, so every unchanged region starts closed. The carried cursor usually sits in one of them: the line it followed is context in the commit being opened, rather than part of its diff. A cursor inside a closed fold is not visible, so open the folds over it. --- .../scene/views/standard/standard_view.lua | 22 ++++++++++++++++ .../file_history_cursor_carry_spec.lua | 25 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/lua/diffview/scene/views/standard/standard_view.lua b/lua/diffview/scene/views/standard/standard_view.lua index 6a4b4e76..6ac84d8d 100644 --- a/lua/diffview/scene/views/standard/standard_view.lua +++ b/lua/diffview/scene/views/standard/standard_view.lua @@ -151,6 +151,27 @@ local function capture_winview(winid) return { winview = winview, bufnr = bufnr, bufname = api.nvim_buf_get_name(bufnr) } end +---Open the folds hiding the cursor line in `winid`. +--- +---With `'foldlevel'` at its default of 0 a diff buffer arrives with every +---unchanged region closed, which is exactly where a carried cursor tends to +---land: the line it followed is context in the commit being opened, not part +---of its diff. A cursor inside a closed fold tells the reader nothing about +---where it went. +--- +---Only the main window is revealed, for the same reason only it is placed. +---Doing it in the layout's other windows instead drags the main cursor off its +---line, because `'cursorbind'` syncs on the move `zv` makes there. The other +---panes come out revealed anyway, which `file_history_cursor_carry_spec` +---asserts. `pcall` because a pane may hold a null buffer, or have folding +---switched off entirely. +---@param winid integer +local function reveal_cursor_line(winid) + pcall(api.nvim_win_call, winid, function() + vim.cmd("normal! zv") + end) +end + ---Translate `state` into the window's current buffer, then apply it. ---@param winid integer ---@param state StandardView.CarryState @@ -307,6 +328,7 @@ function StandardView:restore_main_view(path) local ok = apply_winview(win.id, target) if ok then + reveal_cursor_line(win.id) self.cursor_map[path] = nil end return ok diff --git a/lua/diffview/tests/functional/file_history_cursor_carry_spec.lua b/lua/diffview/tests/functional/file_history_cursor_carry_spec.lua index 787dd814..8bf5f852 100644 --- a/lua/diffview/tests/functional/file_history_cursor_carry_spec.lua +++ b/lua/diffview/tests/functional/file_history_cursor_carry_spec.lua @@ -113,6 +113,31 @@ describe("file history cursor carry", function() eq(20, api.nvim_buf_line_count(api.nvim_win_get_buf(main_win()))) end + it("opens the folds hiding the carried cursor", function() + local main = open_history() + api.nvim_set_current_win(main) + api.nvim_win_set_cursor(main, { 50, 0 }) + eq("body 5", line_at(main)) + + step("select_next_commit", 2) + + -- `foldlevel` defaults to 0, so the arriving diff closes every unchanged + -- region -- which is exactly where a carried line lands, since it is + -- context in the commit being opened rather than part of its diff. Both + -- panes are checked: `cursorbind` moves the other window's cursor but + -- opens nothing, and a pane left folded misaligns against its partner. + for i, win in ipairs(view.cur_layout.windows) do + if win.id and api.nvim_win_is_valid(win.id) then + local closed = api.nvim_win_call(win.id, function() + return vim.fn.foldclosed(api.nvim_win_get_cursor(win.id)[1]) + end) + eq(-1, closed, ("window %d left its cursor inside a closed fold"):format(i)) + end + end + + eq("body 5", line_at(main_win())) + end) + it("keeps the cursor on the same code across select_next_commit", function() assert_carries_across("select_next_commit") end)