From 193616e87dd0e99f122ed10c135815be89450667 Mon Sep 17 00:00:00 2001 From: Oscar Wallberg Date: Fri, 29 May 2026 14:22:24 +0200 Subject: [PATCH] refactor(diffsplit): clarify diff side API --- lua/git/diffsplit.lua | 146 +++++++++++++++++++++++++++------- lua/git/object.lua | 8 +- lua/git/status_view.lua | 12 +-- plugin/git.lua | 20 ++--- test/git/diffsplit_test.lua | 129 ++++++++++++++++++++++++++++++ test/git/status_view_test.lua | 2 +- 6 files changed, 262 insertions(+), 55 deletions(-) create mode 100644 test/git/diffsplit_test.lua diff --git a/lua/git/diffsplit.lua b/lua/git/diffsplit.lua index d16e562..6ba5688 100644 --- a/lua/git/diffsplit.lua +++ b/lua/git/diffsplit.lua @@ -6,13 +6,15 @@ local util = require("git.core.util") local M = {} ---@class ow.Git.Diffsplit.OpenOpts ----@field target string? +---@field other string? +---@field layout ("vertical"|"horizontal")? ---@field mods vim.api.keyset.cmd.mods? +---@field focus ("current"|"other")? ---@param cur_buf integer ----@return string? target +---@return string? other ---@return string? err -local function infer_target(cur_buf) +local function infer_other(cur_buf) local cur_name = vim.api.nvim_buf_get_name(cur_buf) local cur_rev = object.parse_uri(cur_name) if cur_rev then @@ -48,16 +50,16 @@ local function infer_target(cur_buf) return object.format_uri(Revision.new({ stage = 0, path = rel })), nil end ----@param target string +---@param other string ---@param cur_buf integer ---@return string? resolved ---@return string? err -local function resolve_target(target, cur_buf) - if vim.startswith(target, object.URI_PREFIX) then - return target, nil +local function resolve_other(other, cur_buf) + if vim.startswith(other, object.URI_PREFIX) then + return other, nil end - if vim.fn.filereadable(target) == 1 then - return target, nil + if vim.fn.filereadable(other) == 1 then + return other, nil end local cur_name = vim.api.nvim_buf_get_name(cur_buf) local cur_rev = object.parse_uri(cur_name) @@ -78,57 +80,141 @@ local function resolve_target(target, cur_buf) if not rel then return nil, "current buffer has no path" end - if not r:rev_parse(target, true) then - return nil, "invalid rev: " .. target + if not r:rev_parse(other, true) then + return nil, "invalid rev: " .. other end - return object.format_uri(Revision.new({ base = target, path = rel })), nil + return object.format_uri(Revision.new({ base = other, path = rel })), nil end ---@param cur_buf integer ----@param target string +---@param other string ---@return 'aboveleft'|'belowright'|nil -local function default_split(cur_buf, target) +local function default_split(cur_buf, other) local cur_rev = object.parse_uri(vim.api.nvim_buf_get_name(cur_buf)) - local target_rev = object.parse_uri(target) - if not cur_rev and target_rev then + local other_rev = object.parse_uri(other) + if not cur_rev and other_rev then return "aboveleft" end - if cur_rev and not target_rev then + if cur_rev and not other_rev then return "belowright" end - if cur_rev and target_rev then - if cur_rev.stage == 0 and target_rev.base then + if cur_rev and other_rev then + if cur_rev.stage == 0 and other_rev.base then return "aboveleft" end - if cur_rev.base and target_rev.stage == 0 then + if cur_rev.base and other_rev.stage == 0 then return "belowright" end end return nil end +---@alias ow.Git.Diffsplit.Side string|integer + +---@class ow.Git.Diffsplit.OpenPairOpts +---@field layout ("vertical"|"horizontal")? +---@field mods vim.api.keyset.cmd.mods? +---@field focus ("old"|"new")? + +---@param mods vim.api.keyset.cmd.mods? +---@param layout ("vertical"|"horizontal")? +---@return vim.api.keyset.cmd.mods +local function layout_mods(mods, layout) + mods = vim.tbl_extend("force", {}, mods or {}) + if mods.vertical == nil then + mods.vertical = layout ~= "horizontal" + end + return mods +end + +---@param side ow.Git.Diffsplit.Side +---@param cur_buf integer +---@return string? name +---@return integer? buf +---@return string? err +local function resolve_side(side, cur_buf) + if type(side) == "number" then + local name = vim.api.nvim_buf_get_name(side) + if name == "" then + return nil, nil, "diff side buffer has no name" + end + return name, side, nil + end + local name, err = resolve_other(side, cur_buf) + return name, nil, err +end + +---@param side ow.Git.Diffsplit.Side +---@param cur_buf integer +---@return integer? buf +---@return string? err +local function buf_for_side(side, cur_buf) + local name, buf, err = resolve_side(side, cur_buf) + if not name then + return nil, err + end + if buf then + return buf, nil + end + buf = vim.fn.bufadd(name) + vim.fn.bufload(buf) + return buf, nil +end + ---@param opts? ow.Git.Diffsplit.OpenOpts function M.open(opts) opts = opts or {} local cur_buf = vim.api.nvim_get_current_buf() - local target, err - if opts.target then - target, err = resolve_target(opts.target, cur_buf) + local other, err + if opts.other then + other, err = resolve_other(opts.other, cur_buf) else - target, err = infer_target(cur_buf) + other, err = infer_other(cur_buf) end - if not target then - util.error("%s", err or "no diff target") + if not other then + util.error("%s", err or "no diff side") return end - local mods = opts.mods - if not mods or mods.split == nil then - local placement = default_split(cur_buf, target) + local mods = layout_mods(opts.mods, opts.layout) + if mods.split == nil then + local placement = default_split(cur_buf, other) if placement then mods = vim.tbl_extend("force", mods or {}, { split = placement }) end end - vim.cmd.diffsplit({ args = { target }, mods = mods }) + local cur_win = vim.api.nvim_get_current_win() + vim.cmd.diffsplit({ args = { other }, mods = mods }) + if opts.focus == "current" and vim.api.nvim_win_is_valid(cur_win) then + vim.api.nvim_set_current_win(cur_win) + end +end + +---@param old ow.Git.Diffsplit.Side +---@param new ow.Git.Diffsplit.Side +---@param opts? ow.Git.Diffsplit.OpenPairOpts +function M.open_pair(old, new, opts) + opts = opts or {} + local cur_buf = vim.api.nvim_get_current_buf() + local new_buf, err = buf_for_side(new, cur_buf) + if not new_buf then + util.error("%s", err or "no new diff side") + return + end + local old_name, _, old_err = resolve_side(old, cur_buf) + if not old_name then + util.error("%s", old_err or "no old diff side") + return + end + + vim.cmd.normal({ "m'", bang = true }) + vim.api.nvim_set_current_buf(new_buf) + + local mods = layout_mods(opts.mods, opts.layout) + mods.split = mods.split or "aboveleft" + vim.cmd.diffsplit({ args = { old_name }, mods = mods }) + if opts.focus ~= "old" then + vim.cmd.wincmd("p") + end end return M diff --git a/lua/git/object.lua b/lua/git/object.lua index 22e8876..87fd7bd 100644 --- a/lua/git/object.lua +++ b/lua/git/object.lua @@ -395,11 +395,9 @@ local function open_section(r, commit, section) local left = side(r, commit, true, section.blob_a, section.path_a) local right = side(r, commit, false, section.blob_b, section.path_b) if left and right then - vim.cmd.normal({ "m'", bang = true }) - vim.api.nvim_set_current_buf(right) - require("git.diffsplit").open({ - target = vim.api.nvim_buf_get_name(left), - mods = { vertical = true }, + require("git.diffsplit").open_pair(left, right, { + layout = "vertical", + focus = "new", }) return end diff --git a/lua/git/status_view.lua b/lua/git/status_view.lua index 5b14623..162e4ff 100644 --- a/lua/git/status_view.lua +++ b/lua/git/status_view.lua @@ -391,17 +391,11 @@ local function view_row(s, row, focus_left) ---@cast left ow.Git.StatusView.Pane ---@cast right ow.Git.StatusView.Pane - vim.api.nvim_win_set_buf(target, right.buf) - if right.name then - util.set_buf_name(right.buf, right.name) - end - - local older = left.name or vim.api.nvim_buf_get_name(left.buf) local left_win vim.api.nvim_win_call(target, function() - diffsplit.open({ - target = older, - mods = { vertical = true }, + diffsplit.open_pair(left.buf, right.buf, { + layout = "vertical", + focus = "old", }) left_win = vim.api.nvim_get_current_win() vim.api.nvim_win_set_cursor(left_win, { 1, 0 }) diff --git a/plugin/git.lua b/plugin/git.lua index f5d8f65..c1835fc 100644 --- a/plugin/git.lua +++ b/plugin/git.lua @@ -183,16 +183,16 @@ local DIFF_DIRECTIONS = { "vertical", "horizontal" } vim.api.nvim_create_user_command("Gdiffsplit", function(opts) local fargs = opts.fargs - local mods = nil + local layout = nil local rev_idx = 1 if fargs[1] == "vertical" then - mods = { vertical = true } + layout = "vertical" rev_idx = 2 elseif fargs[1] == "horizontal" then - mods = { vertical = false } + layout = "horizontal" rev_idx = 2 end - require("git.diffsplit").open({ target = fargs[rev_idx], mods = mods }) + require("git.diffsplit").open({ other = fargs[rev_idx], layout = layout }) end, { nargs = "*", complete = function(arg_lead, cmd_line, _) @@ -253,21 +253,21 @@ vim.keymap.set("n", "(git-edit)", function() end, { silent = true, desc = "Edit a git object" }) vim.keymap.set("n", "(git-diffsplit-vertical)", function() - require("git.diffsplit").open({ mods = { vertical = true } }) + require("git.diffsplit").open({ layout = "vertical" }) end, { silent = true, desc = "Open a diff split against index (vertical)" }) vim.keymap.set("n", "(git-diffsplit-horizontal)", function() - require("git.diffsplit").open({ mods = { vertical = false } }) + require("git.diffsplit").open({ layout = "horizontal" }) end, { silent = true, desc = "Open a diff split against index (horizontal)" }) vim.keymap.set("n", "(git-diffsplit-vertical-head)", function() require("git.diffsplit").open({ - target = "HEAD", - mods = { vertical = true }, + other = "HEAD", + layout = "vertical", }) end, { silent = true, desc = "Open a diff split against HEAD (vertical)" }) vim.keymap.set("n", "(git-diffsplit-horizontal-head)", function() require("git.diffsplit").open({ - target = "HEAD", - mods = { vertical = false }, + other = "HEAD", + layout = "horizontal", }) end, { silent = true, desc = "Open a diff split against HEAD (horizontal)" }) diff --git a/test/git/diffsplit_test.lua b/test/git/diffsplit_test.lua new file mode 100644 index 0000000..bc19272 --- /dev/null +++ b/test/git/diffsplit_test.lua @@ -0,0 +1,129 @@ +local Revision = require("git.core.revision") +local h = require("test.git.helpers") +local t = require("test") + +---@return integer[] +local function diff_wins() + local wins = {} + for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do + if vim.wo[win].diff then + table.insert(wins, win) + end + end + return wins +end + +---@param role "left"|"right" +---@return integer +local function vertical_diff_win(role) + local wins = diff_wins() + table.sort(wins, function(a, b) + return vim.api.nvim_win_get_position(a)[2] + < vim.api.nvim_win_get_position(b)[2] + end) + local win = role == "left" and wins[1] or wins[#wins] + if not win then + error("diff window not found") + end + return win +end + +---@param dir string +local function cleanup_dir_buffers(dir) + for _, buf in ipairs(vim.api.nvim_list_bufs()) do + local name = vim.api.nvim_buf_get_name(buf) + if name:find(dir, 1, true) then + pcall(vim.api.nvim_buf_delete, buf, { force = true }) + end + end +end + +t.test("open from worktree focuses the index pane", function() + local dir = h.make_repo({ ["a.txt"] = "old\n" }) + t.write(dir, "a.txt", "new\n") + vim.cmd.edit(vim.fs.joinpath(dir, "a.txt")) + + local current_buf = vim.api.nvim_get_current_buf() + require("git.diffsplit").open() + + t.wait_for(function() + return #diff_wins() == 2 + end, "diff windows to open") + + t.eq(vim.api.nvim_get_current_win(), vertical_diff_win("left")) + t.truthy( + vim.api.nvim_get_current_buf() ~= current_buf, + "worktree buffer should lose focus" + ) +end) + +t.test("open from index focuses the worktree pane", function() + local dir = h.make_repo({ ["a.txt"] = "old\n" }) + t.write(dir, "a.txt", "new\n") + local r = assert(require("git.core.repo").resolve(dir)) + local index_buf = require("git.object").buf_for( + r, + Revision.new({ stage = 0, path = "a.txt" }) + ) + vim.api.nvim_set_current_buf(index_buf) + + require("git.diffsplit").open({ layout = "vertical" }) + + t.wait_for(function() + return #diff_wins() == 2 + end, "diff windows to open") + + local right = vertical_diff_win("right") + t.eq( + vim.api.nvim_get_current_win(), + right, + "worktree buffer should be focused" + ) + t.truthy( + vim.api.nvim_get_current_buf() ~= index_buf, + "index buffer should lose focus" + ) +end) + +t.test("open_pair keeps old on the left when splitright is set", function() + local dir = vim.fn.tempname() + vim.fn.mkdir(dir, "p") + t.defer(function() + cleanup_dir_buffers(dir) + vim.fn.delete(dir, "rf") + end) + + local old = vim.fs.joinpath(dir, "old.txt") + local new = vim.fs.joinpath(dir, "new.txt") + vim.fn.writefile({ "old" }, old) + vim.fn.writefile({ "new" }, new) + + local prev_splitright = vim.o.splitright + vim.o.splitright = true + t.defer(function() + vim.o.splitright = prev_splitright + end) + + local old_buf = vim.fn.bufadd(old) + local new_buf = vim.fn.bufadd(new) + vim.fn.bufload(old_buf) + vim.fn.bufload(new_buf) + + require("git.diffsplit").open_pair(old_buf, new_buf, { + layout = "vertical", + }) + + t.wait_for(function() + return #diff_wins() == 2 + end, "diff windows to open") + + local left = vertical_diff_win("left") + local right = vertical_diff_win("right") + t.eq(vim.api.nvim_win_get_buf(left), old_buf, "old pane should be left") + t.eq(vim.api.nvim_win_get_buf(right), new_buf, "new pane should be right") + t.eq( + vim.api.nvim_get_current_win(), + right, + "new pane should be focused by default" + ) +end) diff --git a/test/git/status_view_test.lua b/test/git/status_view_test.lua index 77e5cd1..b2e9c38 100644 --- a/test/git/status_view_test.lua +++ b/test/git/status_view_test.lua @@ -341,7 +341,7 @@ t.test( error("a non-sidebar window should remain after close") end vim.api.nvim_set_current_win(remaining) - require("git.diffsplit").open({ mods = { vertical = true } }) + require("git.diffsplit").open({ layout = "vertical" }) t.wait_for(function() local count = 0 for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do