diff --git a/lua/git/diff.lua b/lua/git/diff.lua index a336a4b..97f7a8d 100644 --- a/lua/git/diff.lua +++ b/lua/git/diff.lua @@ -1,175 +1,134 @@ local Revision = require("git.core.revision") +local object = require("git.object") local repo = require("git.core.repo") local util = require("git.core.util") local M = {} ----@class ow.Git.Diff.Side ----@field buf integer ----@field name string? - ----@class ow.Git.Diff.Pair ----@field left ow.Git.Diff.Side ----@field right ow.Git.Diff.Side - ----@param win integer ----@param enabled boolean -function M.set_diff(win, enabled) - vim.api.nvim_win_call(win, function() - vim.cmd(enabled and "diffthis" or "diffoff") - end) -end - ----@param left integer ----@param right integer ----@param vertical boolean -function M.open(left, right, vertical) - vim.cmd.normal({ "m'", bang = true }) - vim.api.nvim_set_current_buf(right) - vim.cmd.diffthis() - vim.api.nvim_open_win(left, true, { - split = vertical and "left" or "above", - }) - vim.cmd.diffthis() -end - ----@param left_win integer ----@param right_win integer ----@param pair ow.Git.Diff.Pair -function M.update_pair(left_win, right_win, pair) - M.set_diff(left_win, false) - M.set_diff(right_win, false) - vim.api.nvim_win_set_buf(left_win, pair.left.buf) - vim.api.nvim_win_set_buf(right_win, pair.right.buf) - for _, side in ipairs({ pair.left, pair.right }) do - if side.name then - util.set_buf_name(side.buf, side.name) - end - end - M.set_diff(left_win, true) - M.set_diff(right_win, true) - vim.api.nvim_win_set_cursor(left_win, { 1, 0 }) - vim.api.nvim_win_set_cursor(right_win, { 1, 0 }) - vim.cmd.syncbind() -end - ----@param buf_a integer ----@param buf_b integer ----@param a_left boolean ----@param vertical boolean -local function place_pair(buf_a, buf_b, a_left, vertical) - if a_left then - M.open(buf_a, buf_b, vertical) - else - M.open(buf_b, buf_a, vertical) - end -end - ----@param opts ow.Git.Diff.SplitOpts ----@param buf integer ----@param rev ow.Git.Revision -local function uri_split(opts, buf, rev) - local r = repo.resolve(buf) - if not r then - util.error("git URI buffer has no worktree") - return - end - if not rev.path then - util.error("git URI has no path, cannot diff against worktree") - return - end - local object = require("git.object") - - if opts.rev and opts.rev:find(":", 1, true) then - if not r:rev_parse(opts.rev, true) then - util.error("invalid rev: %s", opts.rev) - return - end - place_pair( - buf, - object.buf_for(r, Revision.parse(opts.rev)), - false, - opts.vertical - ) - return - end - - if not opts.rev then - local worktree_path = vim.fs.joinpath(r.worktree, rev.path) - if not vim.uv.fs_stat(worktree_path) then - util.error("worktree file does not exist: %s", rev.path) - return - end - local worktree_buf = vim.fn.bufadd(worktree_path) - vim.fn.bufload(worktree_buf) - place_pair(buf, worktree_buf, true, opts.vertical) - return - end - - if rev.stage == 1 then - util.warning("gD on merge base is ambiguous, use :Gdiffsplit ") - return - end - - local mapping = { - [2] = { Revision.new({ stage = 3, path = rev.path }), true }, - [3] = { Revision.new({ stage = 2, path = rev.path }), false }, - [0] = { Revision.new({ base = "HEAD", path = rev.path }), false }, - } - local m = mapping[rev.stage] - or { Revision.new({ stage = 0, path = rev.path }), true } - local other_rev, left = m[1], m[2] - if not r:rev_parse(other_rev:format(), true) then - util.error("invalid rev: %s", other_rev:format()) - return - end - place_pair(buf, object.buf_for(r, other_rev), left, opts.vertical) -end - ---@class ow.Git.Diff.SplitOpts ----@field rev string? ----@field vertical boolean +---@field target string? +---@field mods vim.api.keyset.cmd.mods? ----@param opts ow.Git.Diff.SplitOpts -function M.split(opts) - local cur_buf = vim.api.nvim_get_current_buf() - local cur_path = vim.api.nvim_buf_get_name(cur_buf) - - local cur_rev = require("git.object").parse_uri(cur_path) +---@param cur_buf integer +---@return string? target +---@return string? err +local function infer_target(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 - return uri_split(opts, cur_buf, cur_rev) + local r = repo.resolve(cur_buf) + if not r then + return nil, "git URI buffer has no worktree" + end + if not cur_rev.path then + return nil, "git URI has no path, cannot diff against worktree" + end + local worktree_path = vim.fs.joinpath(r.worktree, cur_rev.path) + if not vim.uv.fs_stat(worktree_path) then + return nil, "worktree file does not exist: " .. cur_rev.path + end + return worktree_path, nil end - if cur_path == "" then - util.error("no file in current buffer") - return + if cur_name == "" then + return nil, "no file in current buffer" end if vim.bo[cur_buf].buftype ~= "" then - util.error("cannot diff this buffer (not a worktree file)") - return + return nil, "cannot diff this buffer (not a worktree file)" end - cur_path = vim.fn.resolve(cur_path) - local r = repo.resolve(cur_path) + local resolved = vim.fn.resolve(cur_name) + local r = repo.resolve(resolved) if not r then - util.error("not in a git repository") - return + return nil, "not in a git repository" end - local rel = vim.fs.relpath(r.worktree, cur_path) + local rel = vim.fs.relpath(r.worktree, resolved) + if not rel then + return nil, "current buffer is outside the worktree" + end + return object.format_uri(Revision.new({ stage = 0, path = rel })), nil +end - local rev - if not opts.rev then - rev = Revision.new({ stage = 0, path = rel }) - elseif opts.rev:find(":", 1, true) then - rev = Revision.parse(opts.rev) - else - rev = Revision.new({ base = opts.rev, path = rel }) +---@param target 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 end - if not r:rev_parse(rev:format(), true) then - util.error("invalid rev: %s", rev:format()) + if vim.fn.filereadable(target) == 1 then + return target, nil + end + local cur_name = vim.api.nvim_buf_get_name(cur_buf) + local cur_rev = object.parse_uri(cur_name) + local r, rel + if cur_rev and cur_rev.path then + r = repo.resolve(cur_buf) + rel = cur_rev.path + elseif cur_name ~= "" then + local resolved = vim.fn.resolve(cur_name) + r = repo.resolve(resolved) + if r then + rel = vim.fs.relpath(r.worktree, resolved) + end + end + if not r then + return nil, "not in a git repository" + end + 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 + end + return object.format_uri(Revision.new({ base = target, path = rel })), nil +end + +---@param cur_buf integer +---@param target string +---@return 'aboveleft'|'belowright'|nil +local function default_split(cur_buf, target) + 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 + return "aboveleft" + end + if cur_rev and not target_rev then + return "belowright" + end + if cur_rev and target_rev then + if cur_rev.stage == 0 and target_rev.base then + return "aboveleft" + end + if cur_rev.base and target_rev.stage == 0 then + return "belowright" + end + end + return nil +end + +---@param opts? ow.Git.Diff.SplitOpts +function M.split(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) + else + target, err = infer_target(cur_buf) + end + if not target then + util.error("%s", err or "no diff target") return end - local buf = require("git.object").buf_for(r, rev) - place_pair(buf, cur_buf, true, opts.vertical) + local mods = opts.mods + if not mods or mods.split == nil then + local placement = default_split(cur_buf, target) + if placement then + mods = vim.tbl_extend("force", mods or {}, { split = placement }) + end + end + vim.cmd.diffsplit({ args = { target }, mods = mods }) end return M diff --git a/lua/git/object.lua b/lua/git/object.lua index 4f71898..6fe67f9 100644 --- a/lua/git/object.lua +++ b/lua/git/object.lua @@ -334,7 +334,12 @@ local function open_section(r, section) local left = side_buf(r, section.blob_a, section.path_a) local right = side_buf(r, section.blob_b, section.path_b) if left and right then - require("git.diff").open(left, right, true) + vim.cmd.normal({ "m'", bang = true }) + vim.api.nvim_set_current_buf(right) + require("git.diff").split({ + target = vim.api.nvim_buf_get_name(left), + mods = { vertical = true }, + }) return end if not left and not right then diff --git a/lua/git/status_view.lua b/lua/git/status_view.lua index 2a17bfc..bbcaab6 100644 --- a/lua/git/status_view.lua +++ b/lua/git/status_view.lua @@ -176,9 +176,13 @@ local function current_entry(bufnr) return s, s.lines[lnum] end +---@class ow.Git.StatusView.Pane +---@field buf integer +---@field name string? + ---@param r ow.Git.Repo ---@param path string ----@return ow.Git.Diff.Side +---@return ow.Git.StatusView.Pane local function head_pane(r, path) local rev = Revision.new({ base = "HEAD", path = path }) return { @@ -189,7 +193,7 @@ end ---@param r ow.Git.Repo ---@param path string ----@return ow.Git.Diff.Side +---@return ow.Git.StatusView.Pane local function worktree_pane(r, path) local buf = vim.fn.bufadd(vim.fs.joinpath(r.worktree, path)) vim.fn.bufload(buf) @@ -198,7 +202,7 @@ end ---@param s ow.Git.StatusView.State ---@param path string ----@return ow.Git.Diff.Side +---@return ow.Git.StatusView.Pane local function index_pane(s, path) local rev = Revision.new({ stage = 0, path = path }) return { @@ -209,7 +213,7 @@ end ---@param s ow.Git.StatusView.State ---@param row ow.Git.Status.Row ----@return ow.Git.Diff.Side? +---@return ow.Git.StatusView.Pane? local function older_pane(s, row) local entry = row.entry if row.section == "staged" then @@ -227,7 +231,7 @@ end ---@param s ow.Git.StatusView.State ---@param row ow.Git.Status.Row ----@return ow.Git.Diff.Side? +---@return ow.Git.StatusView.Pane? local function newer_pane(s, row) local entry = row.entry if row.section == "staged" then @@ -316,8 +320,7 @@ local function view_row(s, row, focus_left) if s.placement ~= "sidebar" then local pane = right or left - ---@cast pane ow.Git.Diff.Side - diff.set_diff(status_win, false) + ---@cast pane -nil vim.cmd.normal({ "m'", bang = true }) vim.api.nvim_win_set_buf(status_win, pane.buf) if pane.name then @@ -332,11 +335,13 @@ local function view_row(s, row, focus_left) end close_other_diff_wins(status_win, target) vim.api.nvim_win_set_width(status_win, WINDOW_WIDTH) - diff.set_diff(target, false) + vim.api.nvim_win_call(target, function() + vim.cmd.diffoff() + end) if not (left and right) then local side = right or left - ---@cast side ow.Git.Diff.Side + ---@cast side ow.Git.StatusView.Pane vim.api.nvim_win_set_buf(target, side.buf) if side.name then util.set_buf_name(side.buf, side.name) @@ -344,15 +349,23 @@ local function view_row(s, row, focus_left) vim.api.nvim_set_current_win(focus_left and target or status_win) return end - ---@cast left ow.Git.Diff.Side - ---@cast right ow.Git.Diff.Side + ---@cast left ow.Git.StatusView.Pane + ---@cast right ow.Git.StatusView.Pane - local left_win = vsplit_at(target, "left") - local combined = vim.api.nvim_win_get_width(left_win) - + vim.api.nvim_win_get_width(target) - vim.api.nvim_win_set_width(left_win, math.floor(combined / 2)) + vim.api.nvim_win_set_buf(target, right.buf) + if right.name then + util.set_buf_name(right.buf, right.name) + end - diff.update_pair(left_win, target, { left = left, right = right }) + local older = left.name or vim.api.nvim_buf_get_name(left.buf) + local left_win + vim.api.nvim_win_call(target, function() + diff.split({ + target = older, + mods = { vertical = true }, + }) + left_win = vim.api.nvim_get_current_win() + end) vim.api.nvim_set_current_win(focus_left and left_win or status_win) end diff --git a/plugin/git.lua b/plugin/git.lua index 8455db3..2ae914f 100644 --- a/plugin/git.lua +++ b/plugin/git.lua @@ -142,22 +142,18 @@ end local DIFF_DIRECTIONS = { "vertical", "horizontal" } -local function default_vertical() - return vim.tbl_contains(vim.opt.diffopt:get(), "vertical") -end - vim.api.nvim_create_user_command("Gdiffsplit", function(opts) local fargs = opts.fargs - local vertical = default_vertical() + local mods = nil local rev_idx = 1 if fargs[1] == "vertical" then - vertical = true + mods = { vertical = true } rev_idx = 2 elseif fargs[1] == "horizontal" then - vertical = false + mods = { vertical = false } rev_idx = 2 end - require("git.diff").split({ rev = fargs[rev_idx], vertical = vertical }) + require("git.diff").split({ target = fargs[rev_idx], mods = mods }) end, { nargs = "*", complete = function(arg_lead, cmd_line, _) @@ -219,16 +215,22 @@ vim.keymap.set("n", "(git-edit)", function() end, { silent = true, desc = "Edit a git object" }) vim.keymap.set("n", "(git-diff-vertical)", function() - require("git.diff").split({ vertical = true }) + require("git.diff").split({ mods = { vertical = true } }) end, { silent = true, desc = "Diff against index (vertical)" }) vim.keymap.set("n", "(git-diff-horizontal)", function() - require("git.diff").split({ vertical = false }) + require("git.diff").split({ mods = { vertical = false } }) end, { silent = true, desc = "Diff against index (horizontal)" }) vim.keymap.set("n", "(git-diff-vertical-head)", function() - require("git.diff").split({ rev = "HEAD", vertical = true }) + require("git.diff").split({ + target = "HEAD", + mods = { vertical = true }, + }) end, { silent = true, desc = "Diff against HEAD (vertical)" }) vim.keymap.set("n", "(git-diff-horizontal-head)", function() - require("git.diff").split({ rev = "HEAD", vertical = false }) + require("git.diff").split({ + target = "HEAD", + mods = { vertical = false }, + }) end, { silent = true, desc = "Diff against HEAD (horizontal)" }) vim.keymap.set("n", "(git-status-toggle)", function() diff --git a/test/git/status_view_test.lua b/test/git/status_view_test.lua index ddb5711..205f0c0 100644 --- a/test/git/status_view_test.lua +++ b/test/git/status_view_test.lua @@ -234,3 +234,84 @@ t.test("refresh on stage updates the index URI buffer's content", function() "index pane should reflect staged content after refresh" ) end) + +t.test( + "re-selecting same entry after close + diffsplit keeps fold state in sync", + function() + local committed, worktree = {}, {} + for i = 1, 30 do + committed[i] = "line " .. i + worktree[i] = i == 15 and "CHANGED" or ("line " .. i) + end + local sidebar_win, line = setup_sidebar_with_unstaged_file( + "foo.txt", + table.concat(committed, "\n") .. "\n", + table.concat(worktree, "\n") .. "\n" + ) + + local prev_foldlevel = vim.o.foldlevel + vim.o.foldlevel = 99 + t.defer(function() + vim.o.foldlevel = prev_foldlevel + end) + + vim.api.nvim_set_current_win(sidebar_win) + vim.api.nvim_win_set_cursor(sidebar_win, { line, 0 }) + t.press("") + t.wait_for(function() + return find_diff_win("left") ~= nil + and find_diff_win("right") ~= nil + end, "first diff pair to appear") + + local first_left = assert(find_diff_win("left")) + vim.api.nvim_win_close(first_left, false) + + local remaining + for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do + if w ~= sidebar_win then + remaining = w + break + end + end + if not remaining then + error("a non-sidebar window should remain after close") + end + vim.api.nvim_set_current_win(remaining) + require("git.diff").split({ mods = { vertical = true } }) + t.wait_for(function() + local count = 0 + for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do + if vim.wo[w].diff then + count = count + 1 + end + end + return count == 2 + end, "diffsplit to produce a diff pair") + + vim.api.nvim_set_current_win(sidebar_win) + vim.api.nvim_win_set_cursor(sidebar_win, { line, 0 }) + t.press("") + t.wait_for(function() + local count = 0 + for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do + if vim.wo[w].diff then + count = count + 1 + end + end + return count == 2 + end, "diff pair after re-selecting entry") + + local left_win = assert(find_diff_win("left")) + local right_win = assert(find_diff_win("right")) + t.eq( + vim.wo[left_win].foldlevel, + 0, + "left pane foldlevel should be 0 after re-select" + ) + t.eq( + vim.wo[right_win].foldlevel, + 0, + "right pane foldlevel should be 0 after re-select" + ) + end +)