diff --git a/lua/git/diff.lua b/lua/git/diff.lua index 8f1a28a..ec41b88 100644 --- a/lua/git/diff.lua +++ b/lua/git/diff.lua @@ -3,19 +3,6 @@ local util = require("git.util") local M = {} ----Name a buffer and re-run filetype detection from the (re-)set name. ----Wrapped in `pcall` because a buffer with that name may already exist ----(E95). ----@param buf integer ----@param name string -local function set_buf_name_and_filetype(buf, name) - pcall(vim.api.nvim_buf_set_name, buf, name) - local ft = vim.filetype.match({ buf = buf }) - if ft then - vim.bo[buf].filetype = ft - end -end - ---Toggle a window into or out of Vim's diff mode. Goes through the ---`:diffthis` / `:diffoff` commands rather than `vim.wo[win].diff = X`. ---The command path runs Vim's full `diff_win_options` setup, which sets @@ -25,7 +12,7 @@ end ---tabpage's diff group and corrupting its render. ---@param win integer ---@param enabled boolean -local function set_diff(win, enabled) +function M.set_diff(win, enabled) vim.api.nvim_win_call(win, function() vim.cmd(enabled and "diffthis" or "diffoff") end) @@ -46,10 +33,9 @@ function M.open(left, right, vertical) vim.api.nvim_set_current_buf(right) vim.cmd.diffsplit({ args = { left_name }, - mods = { split = "aboveleft", vertical = vertical }, + mods = { split = "aboveleft", vertical = vertical, keepjumps = true }, magic = { file = false }, }) - vim.cmd("clearjumps") end ---Repoint two existing diff windows at a new pair of buffers. @@ -63,17 +49,19 @@ end ---@param right_win integer ---@param pair ow.Git.DiffPair function M.update_pair(left_win, right_win, pair) - set_diff(left_win, false) - set_diff(right_win, false) + 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 - set_buf_name_and_filetype(side.buf, side.name) + util.set_buf_name(side.buf, side.name) end end - set_diff(left_win, true) - set_diff(right_win, true) + 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 @@ -92,7 +80,7 @@ local function place_pair(buf_a, buf_b, a_left, vertical) end ---Dispatch for `M.split` when the current buffer is a `git://` ----URI. Placement is writable-on-the-left via `place_pair`. +---URI. Placement follows the older-on-left convention. --- ---gd/gh: pair cur with the worktree file at the URI's path. --- @@ -121,7 +109,6 @@ local function uri_split(opts, cur_buf, cur_revspec) end -- Lazy-required to break the load-time cycle with `git.object`. local object = require("git.object") - local cur_writable = cur.stage == 0 if opts.revspec ~= "" and opts.revspec:find(":", 1, true) then local content = util.exec( @@ -135,16 +122,21 @@ local function uri_split(opts, cur_buf, cur_revspec) place_pair( cur_buf, object.buf_for(worktree, opts.revspec, content), - cur_writable, + false, opts.vertical ) return end if opts.revspec == "" then - local worktree_buf = vim.fn.bufadd(vim.fs.joinpath(worktree, cur.path)) + local worktree_path = vim.fs.joinpath(worktree, cur.path) + if not vim.uv.fs_stat(worktree_path) then + util.warning("worktree file does not exist: %s", cur.path) + return + end + local worktree_buf = vim.fn.bufadd(worktree_path) vim.fn.bufload(worktree_buf) - place_pair(cur_buf, worktree_buf, cur_writable, opts.vertical) + place_pair(cur_buf, worktree_buf, true, opts.vertical) return end @@ -153,16 +145,13 @@ local function uri_split(opts, cur_buf, cur_revspec) return end - local other_revspec - if cur.stage == 2 then - other_revspec = ":3:" .. cur.path - elseif cur.stage == 3 then - other_revspec = ":2:" .. cur.path - elseif cur.stage == 0 then - other_revspec = "HEAD:" .. cur.path - else - other_revspec = ":0:" .. cur.path - end + local mapping = { + [2] = { ":3:" .. cur.path, true }, + [3] = { ":2:" .. cur.path, false }, + [0] = { "HEAD:" .. cur.path, false }, + } + local m = mapping[cur.stage] or { ":0:" .. cur.path, true } + local other_revspec, cur_left = m[1], m[2] local content = util.exec( { "git", "cat-file", "-p", other_revspec }, { cwd = worktree, silent = true } @@ -174,7 +163,7 @@ local function uri_split(opts, cur_buf, cur_revspec) place_pair( cur_buf, object.buf_for(worktree, other_revspec, content), - cur.stage ~= nil, + cur_left, opts.vertical ) end @@ -230,8 +219,8 @@ function M.split(opts) end local object = require("git.object") local buf = object.buf_for(worktree, revspec, content) - local other_writable = util.parse_revspec(revspec).stage == 0 - place_pair(buf, cur_buf, other_writable, opts.vertical) + -- Revspec snapshot is older than the worktree, so it goes left. + place_pair(buf, cur_buf, true, opts.vertical) end return M diff --git a/lua/git/object.lua b/lua/git/object.lua index cdcc3ac..6a9847b 100644 --- a/lua/git/object.lua +++ b/lua/git/object.lua @@ -275,19 +275,24 @@ function M.read_uri(buf) vim.api.nvim_exec_autocmds("BufReadPost", { buffer = buf }) end ----Buffer for the file at `:`. A zero/nil blob (file absent on ----this side of the diff) yields an empty placeholder. +---Buffer for the file at `:`. A zero/nil blob (file absent +---on this side of the diff) maps to `/dev/null`, mirroring git's diff +---headers. ---@param worktree string ---@param blob string? ---@param path string ---@param ref string the commit ref the blob represents (e.g. `` or `^`) ---@return integer local function blob_buf(worktree, blob, path, ref) - local revspec = ref .. ":" .. path if is_zero(blob) then - return util.empty_buf({ name = util.uri(revspec) }) + local buf = vim.fn.bufnr("/dev/null") + if buf == -1 then + buf = vim.api.nvim_create_buf(true, true) + pcall(vim.api.nvim_buf_set_name, buf, "/dev/null") + end + return buf end - return M.buf_for(worktree, revspec) + return M.buf_for(worktree, ref .. ":" .. path) end ---@param worktree string @@ -308,8 +313,6 @@ local function open_section(ctx, section) return end local parent = ctx.parent_ref or "0" - -- An empty buffer for zero blobs keeps `:diffsplit` from - -- BufReadCmd-loading a non-existent revspec. local left = blob_buf(ctx.worktree, section.pre_blob, section.pre_path, parent) local right = diff --git a/lua/git/sidebar.lua b/lua/git/sidebar.lua index 48c4e65..1cadfda 100644 --- a/lua/git/sidebar.lua +++ b/lua/git/sidebar.lua @@ -488,18 +488,6 @@ local function head_pane(worktree, path) } end ----@param worktree string ----@param path string ----@param kind "index"|"HEAD"|"worktree" ----@return ow.Git.DiffSide -local function absent_pane(worktree, path, kind) - local rel = vim.fn.fnamemodify(vim.fs.joinpath(worktree, path), ":.") - return { - buf = util.empty_buf(), - name = string.format("[absent %s] %s", kind, rel), - } -end - ---@param worktree string ---@param path string ---@return ow.Git.DiffSide @@ -513,13 +501,6 @@ end ---@param entry ow.Git.FileEntry ---@return ow.Git.DiffSide local function index_pane(s, entry) - local in_index = not ( - entry.section == "Untracked" - or (entry.section == "Staged" and entry.x == "D") - ) - if not in_index then - return absent_pane(s.worktree, entry.path, "index") - end return { buf = object.buf_for(s.worktree, ":0:" .. entry.path), name = util.uri(":0:" .. entry.path), @@ -529,39 +510,40 @@ end ---@param s ow.Git.SidebarState ---@param entry ow.Git.FileEntry ---@return ow.Git.DiffSide? -local function other_pane(s, entry) - local p = entry.path - local worktree = s.worktree +local function older_pane(s, entry) if entry.section == "Staged" then if entry.x == "A" then - return absent_pane(worktree, p, "HEAD") - end - if entry.x == "D" then - return head_pane(worktree, p) + return nil end -- HEAD holds the pre-rename path - return head_pane(worktree, entry.orig or p) + return head_pane(s.worktree, entry.orig or entry.path) end if entry.section == "Unstaged" then - if entry.y == "D" then - return absent_pane(worktree, p, "worktree") - end - return worktree_pane(worktree, p) - end - if entry.section == "Untracked" then - return worktree_pane(worktree, p) + return index_pane(s, entry) end + return nil end ---@param s ow.Git.SidebarState ---@param entry ow.Git.FileEntry ----@return ow.Git.DiffPair? -local function compute_pair(s, entry) - local other = other_pane(s, entry) - if not other then - return nil +---@return ow.Git.DiffSide? +local function newer_pane(s, entry) + if entry.section == "Staged" then + if entry.x == "D" then + return nil + end + return index_pane(s, entry) end - return { left = index_pane(s, entry), right = other } + if entry.section == "Unstaged" then + if entry.y == "D" then + return nil + end + return worktree_pane(s.worktree, entry.path) + end + if entry.section == "Untracked" then + return worktree_pane(s.worktree, entry.path) + end + return nil end ---@param win integer @@ -642,10 +624,32 @@ local function vsplit_at(target_win, dir) true, { split = dir, win = target_win } ) - vim.cmd("clearjumps") + vim.cmd.clearjumps() return win end +---Make sure `right_win` exists, repurposing the invocation window or +---splitting the sidebar. Returns the right window. +---@param s ow.Git.SidebarState +---@param sidebar_win integer +---@param right_win integer? +---@return integer +local function ensure_right_win(s, sidebar_win, right_win) + if right_win then + return right_win + end + local target = invocation_win_for(s) + if target then + right_win = target + else + -- Sidebar-only case: split steals from sidebar, restore width. + right_win = vsplit_at(sidebar_win, "right") + vim.api.nvim_win_set_width(sidebar_win, SIDEBAR_WIDTH) + end + reset_diff_win(right_win) + return right_win +end + ---@param s ow.Git.SidebarState ---@param entry ow.Git.SidebarEntry ---@param focus_left boolean @@ -659,22 +663,49 @@ local function view_entry(s, entry, focus_left) return end - local left_win, right_win = adopt_diff_wins(s, sidebar_win) + local left = older_pane(s, entry) + local right = newer_pane(s, entry) + if not left and not right then + util.warning("no content for %s entry: %s", entry.section, entry.path) + return + end + local key = entry_key(entry) + local left_win, right_win = adopt_diff_wins(s, sidebar_win) + local want_pair = left and right - if s.last_shown_key == key and left_win and right_win then - if focus_left then - vim.api.nvim_set_current_win(left_win) - else - vim.api.nvim_set_current_win(sidebar_win) + if s.last_shown_key == key then + local intact = (want_pair and left_win and right_win) + or (not want_pair and right_win and not left_win) + if intact then + local target = focus_left and (left_win or right_win) or sidebar_win + vim.api.nvim_set_current_win(target) + return end - return end - local pair = compute_pair(s, entry) - if not pair then + if not want_pair then + if left_win and vim.api.nvim_win_is_valid(left_win) then + pcall(vim.api.nvim_win_close, left_win, false) + left_win = nil + s.diff_left_win = nil + end + right_win = ensure_right_win(s, sidebar_win, right_win) + s.diff_right_win = right_win + vim.w[right_win].git_diff_role = "right" + local side = left or right + ---@cast side ow.Git.DiffSide + diff.set_diff(right_win, false) + vim.api.nvim_win_set_buf(right_win, side.buf) + if side.name then + util.set_buf_name(side.buf, side.name) + end + s.last_shown_key = key + vim.api.nvim_set_current_win(focus_left and right_win or sidebar_win) return end + ---@cast left ow.Git.DiffSide + ---@cast right ow.Git.DiffSide if left_win and not right_win then right_win = vsplit_at(left_win, "right") @@ -683,25 +714,9 @@ local function view_entry(s, entry, focus_left) left_win = vsplit_at(right_win, "left") reset_diff_win(left_win) elseif not (left_win or right_win) then - local target = invocation_win_for(s) - if target then - right_win = target - reset_diff_win(right_win) - left_win = vsplit_at(right_win, "left") - reset_diff_win(left_win) - else - -- Invocation window is gone (closed or in another tabpage). - -- Open the diff pair by splitting from the sidebar. winfixwidth - -- keeps the sidebar at 50 when there are other windows to - -- absorb the split. If the sidebar is the only window in the - -- tab, the split has to take from the sidebar itself, so - -- restore the width explicitly. - right_win = vsplit_at(sidebar_win, "right") - reset_diff_win(right_win) - left_win = vsplit_at(right_win, "left") - reset_diff_win(left_win) - vim.api.nvim_win_set_width(sidebar_win, SIDEBAR_WIDTH) - end + right_win = ensure_right_win(s, sidebar_win, nil) + left_win = vsplit_at(right_win, "left") + reset_diff_win(left_win) local combined = vim.api.nvim_win_get_width(left_win) + vim.api.nvim_win_get_width(right_win) vim.api.nvim_win_set_width(left_win, math.floor(combined / 2)) @@ -713,14 +728,10 @@ local function view_entry(s, entry, focus_left) s.diff_left_win = left_win s.diff_right_win = right_win - diff.update_pair(left_win, right_win, pair) + diff.update_pair(left_win, right_win, { left = left, right = right }) s.last_shown_key = key - if focus_left then - vim.api.nvim_set_current_win(left_win) - else - vim.api.nvim_set_current_win(sidebar_win) - end + vim.api.nvim_set_current_win(focus_left and left_win or sidebar_win) end ---@param focus_left boolean diff --git a/lua/git/util.lua b/lua/git/util.lua index 11d4d15..4f5eb0d 100644 --- a/lua/git/util.lua +++ b/lua/git/util.lua @@ -62,22 +62,16 @@ local function setup_scratch(buf, opts) end end ----Build a read-only scratch buffer, optionally naming it. When `opts.name` ----is set and a loaded buffer with that name already exists, returns it ----instead of creating a duplicate. ----@param opts ow.Git.ScratchOpts? ----@return integer -function M.empty_buf(opts) - opts = opts or {} - if opts.name then - local existing = vim.fn.bufnr(opts.name) - if existing ~= -1 and vim.api.nvim_buf_is_loaded(existing) then - return existing - end +---Set a buffer's name and re-run filetype detection from it. Wrapped +---in `pcall` because a buffer with that name may already exist (E95). +---@param buf integer +---@param name string +function M.set_buf_name(buf, name) + pcall(vim.api.nvim_buf_set_name, buf, name) + local ft = vim.filetype.match({ buf = buf }) + if ft then + vim.bo[buf].filetype = ft end - local buf = vim.api.nvim_create_buf(false, true) - setup_scratch(buf, opts) - return buf end ---Place a buffer in the current window or a new split per `split`. @@ -96,7 +90,7 @@ function M.place_buf(buf, split) local win = vim.api.nvim_open_win(buf, true, { split = split or (vim.o.splitbelow and "below" or "above"), }) - vim.cmd("clearjumps") + vim.cmd.clearjumps() return win end