From 954f591835c280dd8ce857b6dab040055b02b3b2 Mon Sep 17 00:00:00 2001 From: Julian Torres Date: Sun, 11 Oct 2026 19:20:15 -0400 Subject: [PATCH 1/7] feat(unified_diff): draw the reading window side by side - unified_diff.view picks "unified" or "split"; split puts the base beside each tabpage's reading window, the two in diff mode - new split_diff module owns the base windows - previews and entering a file window move the pair --- README.md | 2 + doc/changeset.nvim.txt | 9 +- lua/changeset/config.lua | 9 +- lua/changeset/split_diff.lua | 60 ++++++++ lua/changeset/unified_diff.lua | 124 ++++++++++++--- lua/changeset/window.lua | 2 +- tests/config_spec.lua | 7 +- tests/split_diff_spec.lua | 267 +++++++++++++++++++++++++++++++++ 8 files changed, 455 insertions(+), 25 deletions(-) create mode 100644 lua/changeset/split_diff.lua create mode 100644 tests/split_diff_spec.lua diff --git a/README.md b/README.md index bc7596f..9839308 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,8 @@ require("changeset").setup({ unified_diff = { -- Once the sidebar closes, keep the diff on "none", the "entered" files or "all". keep = "none", + -- Draw the diff "unified" inline, or "split" side by side beside the window you read. + view = "unified", }, }) ``` diff --git a/doc/changeset.nvim.txt b/doc/changeset.nvim.txt index 6c869ee..9972f3b 100644 --- a/doc/changeset.nvim.txt +++ b/doc/changeset.nvim.txt @@ -1256,7 +1256,7 @@ Pass |changeset.setup()| the options you change. These are the defaults: >lua sign = true, blocks = false, }, - unified_diff = { keep = "none" }, + unified_diff = { keep = "none", view = "unified" }, }) < Each `setup()` call starts from the defaults, not from the previous call. An @@ -1303,6 +1303,13 @@ unified_diff.keep *changeset-option-unified_diff.keep* entered, or for every file the sidebar lists (|changeset-unified-diff|). Read each time the sidebar closes. +unified_diff.view *changeset-option-unified_diff.view* + `"unified"` or `"split"`. Default `"unified"`. How the diff is drawn: + inline, or side by side, the file's base in a window on the left of + the window you are reading, the two in |diff-mode|. Only that window + of each tab page goes side by side, the rest stay inline, and so does + a file new since the base. 'diffopt' is yours. Read as the diff opens. + ============================================================================== 15. Highlight groups *changeset-highlights* diff --git a/lua/changeset/config.lua b/lua/changeset/config.lua index f2ea66b..e41a375 100644 --- a/lua/changeset/config.lua +++ b/lua/changeset/config.lua @@ -43,8 +43,11 @@ local M = {} ---@field sign? boolean Put a comment bubble in the sign column on each review comment's first line; `false` leaves it to a statuscolumn that draws `require("changeset").bubble()`. Default true. ---@field blocks? boolean Start the session showing each review comment's whole text in a box under its last line, rather than its first line at the end of the line; `:Changeset comment toggle` switches. Default false. +---@alias changeset.DiffView "unified"|"split" + ---@class changeset.Config.UnifiedDiff ---@field keep? changeset.DiffReach.Keep Which files keep the unified diff once the sidebar closes: none, those entered, or all. Default "none". +---@field view? changeset.DiffView The view the diff starts each session in: inline, or side by side in the window being read. Default "unified". ---The options in force: every field set. ---@class changeset.Options : changeset.Config @@ -90,6 +93,7 @@ local M = {} ---@class changeset.Options.UnifiedDiff : changeset.Config.UnifiedDiff ---@field keep changeset.DiffReach.Keep +---@field view changeset.DiffView ---@type changeset.Options local DEFAULTS = { @@ -118,7 +122,7 @@ local DEFAULTS = { layout = { min_file_width = 80 }, review = { header = "", footer = "" }, review_comment = { save = { "", "" }, sign = true, blocks = false }, - unified_diff = { keep = "none" }, + unified_diff = { keep = "none", view = "unified" }, } local current = vim.deepcopy(DEFAULTS) @@ -153,6 +157,9 @@ local function validate(options) vim.validate("unified_diff.keep", options.unified_diff.keep, function(v) return v == "none" or v == "entered" or v == "all" end, '"none", "entered" or "all"') + vim.validate("unified_diff.view", options.unified_diff.view, function(v) + return v == "unified" or v == "split" + end, '"unified" or "split"') end ---Splits `opts` into the options `defaults` has and the dotted paths of those it doesn't. A list is one option. diff --git a/lua/changeset/split_diff.lua b/lua/changeset/split_diff.lua new file mode 100644 index 0000000..dda7134 --- /dev/null +++ b/lua/changeset/split_diff.lua @@ -0,0 +1,60 @@ +---Shows a file's base in a window on its left, the two in diff mode: the side-by-side diff. + +local M = {} + +---Each file window's base window, and the file buffer it was opened for. +---@type table +local partners = {} + +---Show `base` beside `win`, which shows `buf`, the two diffed, reusing the base window `win` already has. +---@param win integer +---@param buf integer +---@param base integer The buffer holding the base's text. +function M.show(win, buf, base) + local partner = partners[win] + local base_win = partner and vim.api.nvim_win_is_valid(partner.win) and partner.win + if base_win then + vim.api.nvim_win_set_buf(base_win, base) + else + -- Held rather than `:diffsplit` in the current window, which may be the sidebar by now. + base_win = vim.api.nvim_open_win(base, false, { split = "left", win = win }) + end + partners[win] = { win = base_win, buf = buf } + vim.api.nvim_win_call(base_win, vim.cmd.diffthis) + vim.api.nvim_win_call(win, vim.cmd.diffthis) +end + +---Whether `win` shows its buffer beside the base window this module opened for it, as gitsigns' `get_view` checks. +---@param win integer +---@return boolean +function M.showing(win) + local partner = partners[win] + return partner ~= nil and vim.api.nvim_win_is_valid(partner.win) and partner.buf == vim.api.nvim_win_get_buf(win) +end + +---Whether this module put `win` in diff mode, as a file window or a base window. +---@param win integer +---@return boolean +function M.owns(win) + for file_win, partner in pairs(partners) do + if file_win == win or partner.win == win then + return true + end + end + return false +end + +---Close `win`'s base window and take `win` out of diff mode. +---@param win integer +function M.close(win) + local partner = partners[win] + partners[win] = nil + if partner and vim.api.nvim_win_is_valid(partner.win) then + vim.api.nvim_win_close(partner.win, true) + end + if vim.api.nvim_win_is_valid(win) then + vim.api.nvim_win_call(win, vim.cmd.diffoff) + end +end + +return M diff --git a/lua/changeset/unified_diff.lua b/lua/changeset/unified_diff.lua index da5386c..90bea80 100644 --- a/lua/changeset/unified_diff.lua +++ b/lua/changeset/unified_diff.lua @@ -1,12 +1,14 @@ ----Opens and closes gitsigns' unified diff in the windows showing a file the tree lists, as far as the diff reaches. ----It draws the lines gone since gitsigns' base inline, as `:Gitsigns diffthis unified=true` does. `changeset.diff_reach` ----decides which files it reaches. +---Opens and closes the diff in the windows showing a file the tree lists, as far as the diff reaches. It draws the lines +---gone since gitsigns' base inline, as `:Gitsigns diffthis unified=true` does, or, in the split view, puts the base +---beside the window being read. `changeset.diff_reach` decides which files it reaches. local build = require("changeset.build") +local changeset_config = require("changeset.config") local diff_reach = require("changeset.diff_reach") local highlights = require("changeset.highlights") local Paths = require("changeset.paths") local sidebar_state = require("changeset.sidebar_state") +local split_diff = require("changeset.split_diff") local viewed = require("changeset.viewed") local M = {} @@ -31,11 +33,19 @@ local reach = diff_reach.new() ---Whether the autocommands and decoration provider are set up, which they stay for the session. local started = false ----Each window's view this module opened: its buffer and file, the buffer holding its base, and the base's text ----gitsigns held. ----@type table +---Each window's view this module opened: its buffer and file, the buffer holding its base, the base's text gitsigns +---held, and how it is drawn. +---@type table local opened = {} +---Each tabpage's reading window: the one the split view goes to. +---@type table +local reading = {} + +---The view the user switched to this session, if they did. +---@type changeset.DiffView? +local switched + ---Each window with a view on its way, and the buffer it is for. ---@type table local opening = {} @@ -50,10 +60,48 @@ local untracked_was -- Reaches into gitsigns internals: its buffer cache, its unified views, and the base buffer `diffthis` makes for them. +---The view the diff draws in now. +---@return changeset.DiffView +function M.view() + return switched or changeset_config.get().unified_diff.view +end + +---Whether `win` shows a view, ours or the user's: in the split view, a window in diff mode this module didn't pair is +---the user's own. ---@param win integer ---@return boolean local function showing(win) return require("gitsigns.unified").get_view(win) ~= nil + or split_diff.showing(win) + or (M.view() == "split" and vim.wo[win].diff and not split_diff.owns(win)) +end + +---How `win` wants its diff drawn against base text `text`. Only its tabpage's reading window goes side by side, and +---not on a file new since the base, whose blank base Vim's diff can't show as all added, nor beside the user's own diff: +---Vim's diff mode belongs to the tabpage. +---@param win integer +---@param text string[] +---@return changeset.DiffView +local function wanted(win, text) + local tab = vim.api.nvim_win_get_tabpage(win) + local split = M.view() == "split" + and (reading[tab] or vim.api.nvim_tabpage_get_win(tab)) == win + and table.concat(text, "\n") ~= "" + and not vim.iter(vim.api.nvim_tabpage_list_wins(tab)):any(function(w) + return vim.wo[w].diff and not split_diff.owns(w) + end) + return split and "split" or "unified" +end + +---Close the view this module opened in `win` as it was drawn. +---@param win integer +---@param mine { view: changeset.DiffView } +local function close(win, mine) + if mine.view == "split" then + split_diff.close(win) + else + require("gitsigns.unified").close(win) + end end ---The absolute path of `name` when the tree lists that file, else nil. @@ -213,8 +261,9 @@ end ---@field path string The file `buf` holds. ---@field git_obj Gitsigns.GitObj Names the base. ---@field text string[] The base's text. +---@field view changeset.DiffView How to draw it. ----Show `view`'s buffer's unified diff in its window, replacing any view there. +---Show `view`'s buffer's diff in its window, replacing any view there. --- ---What `:Gitsigns diffthis unified=true` runs, with the window held: it takes the current window only once the base's ---buffer is made, which can wait on git, by when the sidebar may be current. @@ -235,12 +284,18 @@ local function open(view) return end if vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_buf(win) == buf and reaches(path) then + -- Asked again: the reading window can have moved while git read the base. + local drawn = wanted(win, text) + -- Before `show`, which registers the view at once but then waits on its diff. + opened[win] = { buf = buf, path = path, base = base, text = text, view = drawn } + if drawn == "split" then + split_diff.show(win, buf, base) + return + end if table.concat(text, "\n") == "" then -- gitsigns writes a base without the file as one blank line, which the view reads back with its newline. vim.bo[base].endofline = false end - -- Before `show`, which registers the view at once but then waits on its diff. - opened[win] = { buf = buf, path = path, base = base, text = text } paint(win) require("gitsigns.unified").show(win, base, created, loaded) elseif created then @@ -250,20 +305,21 @@ local function open(view) require("gitsigns.async").run(run):raise_on_error() end ----Open the unified diff in `win` when it reaches the file there, unless it is open there, or again once the base it ----compares against has moved. +---Open the diff in `win` when it reaches the file there, unless it is open there, or again once the base it compares +---against has moved or the view it wants has changed. ---@param win integer function M.sync(win) if not started then return end local buf = vim.api.nvim_win_get_buf(win) - if opened[win] and opened[win].buf ~= buf then + local left = opened[win] + if left and left.buf ~= buf then opened[win] = nil -- A preview swaps the buffer without BufWinLeave, so gitsigns still holds our view, and would hand it back once -- the buffer returns. One showing now is on this buffer, so the user's own. if not showing(win) then - require("gitsigns.unified").close(win) + close(win, left) end end local path = tree_file(vim.api.nvim_buf_get_name(buf)) @@ -277,7 +333,14 @@ function M.sync(win) return end local mine = opened[win] - local view = { win = win, buf = buf, path = path, git_obj = bcache.git_obj, text = bcache.compare_text } + local view = { + win = win, + buf = buf, + path = path, + git_obj = bcache.git_obj, + text = bcache.compare_text, + view = wanted(win, bcache.compare_text), + } if not showing(win) then -- One of ours gone while its buffer stays was closed by the user, which leaving the window acts on, or is going -- with a buffer being deleted, which publishes a last update first. @@ -285,7 +348,11 @@ function M.sync(win) open(view) end -- A view this module didn't open is the user's own. - elseif mine and mine.text ~= view.text then + elseif mine and (mine.text ~= view.text or mine.view ~= view.view) then + if mine.view ~= view.view then + opened[win] = nil + close(win, mine) + end open(view) end end @@ -296,6 +363,19 @@ local function sync_all() end end +---Make `win` its tabpage's reading window when it shows a file the diff reaches, then open the diff in the tabpage's +---windows. +---@param win integer +function M.read(win) + local tab = vim.api.nvim_win_get_tabpage(win) + if started and allowed(vim.api.nvim_win_get_buf(win)) then + reading[tab] = win + end + for _, w in ipairs(vim.api.nvim_tabpage_list_wins(tab)) do + M.sync(w) + end +end + ---Close at once, in every window, each view this module opened on a file `keeps` turns down. ---@param keeps fun(path: string): boolean local function close_unless(keeps) @@ -303,7 +383,7 @@ local function close_unless(keeps) if not keeps(mine.path) then opened[win] = nil if vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_buf(win) == mine.buf then - require("gitsigns.unified").close(win) + close(win, mine) end end end @@ -327,12 +407,12 @@ local function start() end) local group = vim.api.nvim_create_augroup(GROUP, { clear = true }) -- Fires: a window taking a buffer, a buffer or window entered, a split among them. Previews swap buffers where - -- autocommands don't nest, so `window.preview` syncs its window itself. + -- autocommands don't nest, so `window.preview` reads its window itself. vim.api.nvim_create_autocmd({ "BufWinEnter", "BufEnter", "WinEnter" }, { group = group, - desc = "changeset: open gitsigns' unified diff in the window the cursor is in", + desc = "changeset: open the diff in the window the cursor is in, moving the side-by-side diff there", callback = function() - M.sync(vim.api.nvim_get_current_win()) + M.read(vim.api.nvim_get_current_win()) end, }) -- Fires: gitsigns publishing a buffer's counts or branch anew, as it attaches and mostly as its base moves, so a @@ -366,13 +446,14 @@ local function start() end, }) -- Fires: a buffer unloaded, a reload by `:e!` among them, which takes the views still on it or on its text as their - -- base with it: those go without the user closing them. A view the user closed went first, releasing its base. + -- base with it: those go without the user closing them. A view the user closed went first, releasing its base. A base + -- window stays through a reload, and its buffer goes as the user closes it. vim.api.nvim_create_autocmd("BufUnload", { group = group, desc = "changeset: reopen gitsigns' unified diff that went with an unloaded buffer", callback = function(args) for win, mine in pairs(opened) do - if (mine.buf == args.buf or mine.base == args.buf) and showing(win) then + if mine.view == "unified" and (mine.buf == args.buf or mine.base == args.buf) and showing(win) then opened[win] = nil end end @@ -444,6 +525,7 @@ function M.turn_on() if not was_on then attach_untracked() end + M.read(vim.api.nvim_get_current_win()) sync_all() end diff --git a/lua/changeset/window.lua b/lua/changeset/window.lua index 1376b97..4a96385 100644 --- a/lua/changeset/window.lua +++ b/lua/changeset/window.lua @@ -492,7 +492,7 @@ function M.preview(path, lnum, band, pick) preview_bar.show(win, buf, band.span) end sign(buf) - unified_diff.sync(win) + unified_diff.read(win) if lnum then local last = vim.api.nvim_buf_line_count(vim.api.nvim_win_get_buf(win)) vim.api.nvim_win_set_cursor(win, { M._clamp(lnum, last), 0 }) diff --git a/tests/config_spec.lua b/tests/config_spec.lua index 6cd9e47..c531f13 100644 --- a/tests/config_spec.lua +++ b/tests/config_spec.lua @@ -51,6 +51,7 @@ describe("changeset.config", function() { { unified_diff = false }, "unified_diff" }, { { unified_diff = { keep = "some" } }, "unified_diff.keep" }, { { unified_diff = { keep = false } }, "unified_diff.keep" }, + { { unified_diff = { view = "x" } }, "unified_diff.view" }, } for _, case in ipairs(cases) do local ok, err = pcall(config.setup, case[1]) @@ -123,7 +124,11 @@ describe("changeset.config", function() it("says nothing when it knows every option", function() set_up_notifier() - config.setup({ keymaps = { jump = "o" }, review_comment = { save = { "" } } }) + config.setup({ + keymaps = { jump = "o" }, + review_comment = { save = { "" } }, + unified_diff = { view = "split" }, + }) vim.wait(20) assert.same({}, notes) diff --git a/tests/split_diff_spec.lua b/tests/split_diff_spec.lua new file mode 100644 index 0000000..4361122 --- /dev/null +++ b/tests/split_diff_spec.lua @@ -0,0 +1,267 @@ +local Fixture = require("support.git") +local Sidebar = require("support.sidebar") +local build = require("changeset.build") +local changeset = require("changeset") +local config = require("changeset.config") +local unified_diff = require("changeset.unified_diff") +local window = require("changeset.window") +local present = require("support.present") + +vim.opt.rtp:prepend(require("support.deps").path("gitsigns.nvim")) +require("gitsigns").setup() +require("support.gh") + +---@type changeset.Band +local BAND = { icon = "󰢱", icon_hl = "MiniIconsAzure", path = "a.txt" } + +describe("the side-by-side diff", function() + local dir ---@type string + local previous ---@type string + + before_each(function() + dir, previous = Fixture.enter_tempdir() + dir = vim.fn.resolve(dir) + Fixture.init_repo("trunk", dir) + vim.fn.writefile(Fixture.numbered(6), "a.txt") + vim.fn.writefile({ "b one", "b two" }, "b.txt") + local base = Fixture.commit("base", dir) + Fixture.git({ "switch", "-q", "-c", "feature" }, dir) + vim.fn.writefile({ "line 1", "line 3", "line 4", "changed 5", "line 6" }, "a.txt") + vim.fn.writefile({ "b one", "b changed" }, "b.txt") + vim.fn.writefile({ "added" }, "added.txt") + Fixture.commit("change", dir) + vim.fn.writefile({ "untracked" }, "new.txt") + local done = false + require("gitsigns").change_base(base, true, function() + done = true + end) + assert.is_true(vim.wait(5000, function() + return done + end, 20)) + build.build() + assert.is_true(vim.wait(5000, function() + return vim.iter(present(build.current()).files):any(function(file) + return file.path == "new.txt" + end) + end, 20)) + config.setup({ unified_diff = { view = "split" } }) + end) + + after_each(function() + unified_diff.limit("none") + window.close() + vim.cmd("silent! only") + vim.cmd("silent! %bwipeout!") + require("gitsigns").change_base(nil, true) + config.setup({}) + vim.fn.chdir(previous) + vim.fn.delete(dir, "rf") + end) + + ---The windows of the current tabpage in diff mode. + ---@return integer[] + local function diffing() + return vim.tbl_filter(function(win) + return vim.wo[win].diff + end, vim.api.nvim_tabpage_list_wins(0)) + end + + ---The window in diff mode beside `win`, once there is one. + ---@param win integer + ---@return integer? + local function partner(win) + local found + vim.wait(5000, function() + local wins = diffing() + if #wins == 2 and vim.list_contains(wins, win) then + found = wins[1] == win and wins[2] or wins[1] + end + return found ~= nil + end, 20) + return found + end + + ---@param win integer + ---@return boolean + local function unified(win) + return (vim.wait(5000, function() + return require("gitsigns.unified").get_view(win) ~= nil + end, 20)) + end + + ---Wait for gitsigns to hold the base text of the current buffer. + local function attached() + local buf = vim.api.nvim_get_current_buf() + assert.is_true(vim.wait(5000, function() + local bcache = require("gitsigns.cache").cache[buf] + return bcache ~= nil and bcache.compare_text ~= nil + end, 20)) + end + + ---Whether the tabpage stays without a window in diff mode for half a second. + ---@return boolean + local function no_pair() + return not vim.wait(500, function() + return #diffing() > 0 + end, 20) + end + + it("shows the file's base in diff mode on its left", function() + unified_diff.turn_on() + vim.cmd.edit("a.txt") + local win = vim.api.nvim_get_current_win() + + local base = present(partner(win)) + + assert.is_true(vim.fn.win_screenpos(base)[2] < vim.fn.win_screenpos(win)[2]) + assert.same(Fixture.numbered(6), vim.api.nvim_buf_get_lines(vim.api.nvim_win_get_buf(base), 0, -1, false)) + assert.is_nil(require("gitsigns.unified").get_view(win)) + end) + + it("pairs only the window being read, the rest of the tabpage unified", function() + unified_diff.turn_on() + vim.cmd.edit("a.txt") + local a = vim.api.nvim_get_current_win() + vim.cmd.split("b.txt") + local b = vim.api.nvim_get_current_win() + + assert.is_not_nil(partner(b)) + assert.is_true(unified(a)) + assert.equal(3, #vim.api.nvim_tabpage_list_wins(0)) + end) + + it("moves the pair to the window the cursor enters", function() + unified_diff.turn_on() + vim.cmd.edit("a.txt") + local a = vim.api.nvim_get_current_win() + vim.cmd.split("b.txt") + local b = vim.api.nvim_get_current_win() + assert.is_not_nil(partner(b)) + + vim.api.nvim_set_current_win(a) + + assert.is_not_nil(partner(a)) + assert.is_true(unified(b)) + assert.is_false(vim.wo[b].diff) + end) + + it("moves the pair to the window a preview fills", function() + unified_diff.turn_on() + vim.cmd.edit("b.txt") + local sidebar = window.open(vim.api.nvim_create_buf(false, true)) + vim.api.nvim_set_current_win(sidebar) + + window.preview(dir .. "/a.txt", 1, BAND) + + assert.is_not_nil(partner(vim.fn.bufwinid(dir .. "/a.txt"))) + assert.equal(sidebar, vim.api.nvim_get_current_win()) + end) + + it("shows a file new since the base unified, added or untracked", function() + unified_diff.turn_on() + for _, name in ipairs({ "added.txt", "new.txt" }) do + vim.cmd.edit(name) + + assert.is_true(unified(vim.api.nvim_get_current_win())) + assert.is_true(no_pair()) + end + end) + + it("leaves the user's own unified view alone", function() + vim.cmd.edit("a.txt") + attached() + vim.cmd("Gitsigns diffthis unified=true") + assert.is_true(unified(vim.api.nvim_get_current_win())) + + unified_diff.turn_on() + + assert.is_true(no_pair()) + end) + + it("pairs nothing in a tabpage holding the user's own diff", function() + vim.cmd.edit("a.txt") + attached() + vim.cmd("Gitsigns diffthis") + assert.is_true(vim.wait(5000, function() + return #diffing() == 2 + end, 20)) + + unified_diff.turn_on() + vim.wait(500) + + assert.equal(2, #vim.api.nvim_tabpage_list_wins(0)) + assert.equal(2, #diffing()) + end) + + it("opens nothing from the base window", function() + unified_diff.turn_on() + vim.cmd.edit("a.txt") + local win = vim.api.nvim_get_current_win() + local base = present(partner(win)) + + vim.api.nvim_set_current_win(base) + vim.wait(500) + + assert.equal(2, #vim.api.nvim_tabpage_list_wins(0)) + assert.equal(base, partner(win)) + end) + + it("opens unified while the option is unset", function() + config.setup({}) + unified_diff.turn_on() + vim.cmd.edit("a.txt") + + assert.is_true(unified(vim.api.nvim_get_current_win())) + assert.is_true(no_pair()) + end) + + it("closes the base window as the diff turns off", function() + unified_diff.turn_on() + vim.cmd.edit("a.txt") + local win = vim.api.nvim_get_current_win() + assert.is_not_nil(partner(win)) + + unified_diff.limit("none") + + assert.same({ win }, vim.api.nvim_tabpage_list_wins(0)) + assert.is_false(vim.wo[win].diff) + end) + + it("takes closing the base window by hand as closing the file's diff, until turned on again", function() + unified_diff.turn_on() + vim.cmd.edit("a.txt") + local win = vim.api.nvim_get_current_win() + vim.cmd.split("b.txt") + local other = vim.api.nvim_get_current_win() + vim.api.nvim_set_current_win(win) + vim.api.nvim_win_close(present(partner(win)), false) + + vim.api.nvim_set_current_win(other) + vim.api.nvim_set_current_win(win) + + assert.is_false(vim.wait(500, function() + return vim.wo[win].diff or require("gitsigns.unified").get_view(win) ~= nil + end, 20)) + + unified_diff.turn_on() + + assert.is_not_nil(partner(win)) + end) + + it("closes the pair on a file marked viewed", function() + vim.cmd.edit("a.txt") + local win = vim.api.nvim_get_current_win() + changeset.open() + assert.is_true(vim.wait(10000, function() + return window.buf() ~= nil and Sidebar.text():find("a.txt", 1, true) ~= nil + end, 25)) + vim.api.nvim_set_current_win(win) + assert.is_not_nil(partner(win)) + + Sidebar.cursor_to("a.txt") + vim.api.nvim_feedkeys("x", "xt", false) + unified_diff.refresh_viewed() + + assert.is_true(no_pair()) + end) +end) From d733a9651d4613ab33e51d5a5fff3270c762a31b Mon Sep 17 00:00:00 2001 From: Julian Torres Date: Sun, 11 Oct 2026 19:37:32 -0400 Subject: [PATCH 2/7] fix(split_diff): leave no stale base window or 'diff' behind - a preview swap parks the base window for the next listed file - turning off closes every base window no view names - scrub a 'diff' that comes back with its buffer - closing a file window takes its base window with it - a restored session drops its empty gitsigns:// windows --- lua/changeset/split_diff.lua | 36 +++++++ lua/changeset/unified_diff.lua | 43 +++++++-- plugin/changeset.lua | 10 ++ tests/split_diff_spec.lua | 166 +++++++++++++++++++++++++++++++++ 4 files changed, 247 insertions(+), 8 deletions(-) diff --git a/lua/changeset/split_diff.lua b/lua/changeset/split_diff.lua index dda7134..766ecb4 100644 --- a/lua/changeset/split_diff.lua +++ b/lua/changeset/split_diff.lua @@ -6,6 +6,10 @@ local M = {} ---@type table local partners = {} +---The file buffers this module put in diff mode, which a window showing one again takes 'diff' back from. +---@type table +local diffed = {} + ---Show `base` beside `win`, which shows `buf`, the two diffed, reusing the base window `win` already has. ---@param win integer ---@param buf integer @@ -20,6 +24,7 @@ function M.show(win, buf, base) base_win = vim.api.nvim_open_win(base, false, { split = "left", win = win }) end partners[win] = { win = base_win, buf = buf } + diffed[buf] = true vim.api.nvim_win_call(base_win, vim.cmd.diffthis) vim.api.nvim_win_call(win, vim.cmd.diffthis) end @@ -57,4 +62,35 @@ function M.close(win) end end +---The file windows with a base window open, whether or not they still show the buffer it was opened for. +---@return integer[] +function M.paired() + local wins = {} + for win, partner in pairs(partners) do + if vim.api.nvim_win_is_valid(partner.win) then + wins[#wins + 1] = win + end + end + return wins +end + +---Take `win` out of diff mode when its buffer brought back a 'diff' this module set, as a window-local option comes back +---with the buffer it was set for, and no base window shows beside it. +---@param win integer +function M.scrub(win) + if vim.wo[win].diff and diffed[vim.api.nvim_win_get_buf(win)] and not M.showing(win) then + vim.api.nvim_win_call(win, vim.cmd.diffoff) + end +end + +---Close the base window of `closed`, a file window closing, unless that would leave no window. +---@param closed integer +function M.forget(closed) + local partner = partners[closed] + partners[closed] = nil + if partner and vim.api.nvim_win_is_valid(partner.win) and #vim.api.nvim_list_wins() > 2 then + vim.api.nvim_win_close(partner.win, true) + end +end + return M diff --git a/lua/changeset/unified_diff.lua b/lua/changeset/unified_diff.lua index 90bea80..4a46324 100644 --- a/lua/changeset/unified_diff.lua +++ b/lua/changeset/unified_diff.lua @@ -254,6 +254,14 @@ local function cover(buf, ns, top, bot, added) end end +---Close `win`'s base window, if it has one. +---@param win integer +local function close_paired(win) + if vim.list_contains(split_diff.paired(), win) then + split_diff.close(win) + end +end + ---A view to open: the window, the buffer and file it shows, and the base gitsigns holds for the buffer. ---@class changeset.unified_diff.View ---@field win integer @@ -286,6 +294,9 @@ local function open(view) if vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_buf(win) == buf and reaches(path) then -- Asked again: the reading window can have moved while git read the base. local drawn = wanted(win, text) + if drawn == "unified" then + close_paired(win) + end -- Before `show`, which registers the view at once but then waits on its diff. opened[win] = { buf = buf, path = path, base = base, text = text, view = drawn } if drawn == "split" then @@ -298,8 +309,13 @@ local function open(view) end paint(win) require("gitsigns.unified").show(win, base, created, loaded) - elseif created then - vim.api.nvim_buf_delete(base, { force = true }) + else + if vim.api.nvim_win_is_valid(win) and not opened[win] then + close_paired(win) + end + if created then + vim.api.nvim_buf_delete(base, { force = true }) + end end end require("gitsigns.async").run(run):raise_on_error() @@ -312,20 +328,24 @@ function M.sync(win) if not started then return end + split_diff.scrub(win) local buf = vim.api.nvim_win_get_buf(win) local left = opened[win] + local path = tree_file(vim.api.nvim_buf_get_name(buf)) + local reached = path ~= nil and reaches(path) if left and left.buf ~= buf then opened[win] = nil -- A preview swaps the buffer without BufWinLeave, so gitsigns still holds our view, and would hand it back once - -- the buffer returns. One showing now is on this buffer, so the user's own. - if not showing(win) then + -- the buffer returns. One showing now is on this buffer, so the user's own. A base window stays for the next file + -- the diff reaches, so holding `j` in the sidebar doesn't open and close one per row. + if not showing(win) and not (left.view == "split" and reached) then close(win, left) end end - local path = tree_file(vim.api.nvim_buf_get_name(buf)) - if not (path and reaches(path)) then + if not reached then return end + ---@cast path -nil -- gitsigns drops a buffer's status before the last update it publishes as it lets go, mid-unload, of the buffer. local tracked = vim.bo[buf].buftype == "" and vim.b[buf].gitsigns_status_dict ~= nil local bcache = tracked and require("gitsigns.cache").cache[buf] @@ -387,6 +407,12 @@ local function close_unless(keeps) end end end + -- One no view names was kept through a preview, or came back with its buffer. + for _, win in ipairs(split_diff.paired()) do + if not opened[win] then + split_diff.close(win) + end + end end ---Set up what opens, closes and covers the views, once for the session: each answers the reach as it fires. @@ -459,13 +485,14 @@ local function start() end end, }) - -- Fires: any window closing, which takes its view with it. + -- Fires: any window closing, which takes its view, and its base window, with it. vim.api.nvim_create_autocmd("WinClosed", { group = group, - desc = "changeset: forget the unified diff of a closed window", + desc = "changeset: forget the diff of a closed window, closing its base window", callback = function(args) local win = tonumber(args.match) --[[@as integer]] opened[win], covered[win], opening[win] = nil, nil, nil + split_diff.forget(win) end, }) -- gitsigns signs a buffer and draws a view's hunks anew with no event to say so, but always redraws the window after. diff --git a/plugin/changeset.lua b/plugin/changeset.lua index c5c8bf2..9635920 100644 --- a/plugin/changeset.lua +++ b/plugin/changeset.lua @@ -487,6 +487,16 @@ vim.api.nvim_create_autocmd("User", { vim.api.nvim_create_autocmd("SessionLoadPost", { group = group, callback = function() + -- A side-by-side diff's base window comes back empty, since gitsigns reads no `gitsigns://` buffer back; the diff + -- opens a fresh one. + for _, win in ipairs(vim.api.nvim_list_wins()) do + if + vim.api.nvim_buf_get_name(vim.api.nvim_win_get_buf(win)):find("^gitsigns://") + and #vim.api.nvim_list_wins() > 1 + then + vim.api.nvim_win_close(win, true) + end + end for _, win in ipairs(vim.api.nvim_list_wins()) do -- Keep in sync with NAME in lua/changeset/window.lua. if vim.api.nvim_buf_get_name(vim.api.nvim_win_get_buf(win)):find("changeset://", 1, true) then diff --git a/tests/split_diff_spec.lua b/tests/split_diff_spec.lua index 4361122..60fd268 100644 --- a/tests/split_diff_spec.lua +++ b/tests/split_diff_spec.lua @@ -264,4 +264,170 @@ describe("the side-by-side diff", function() assert.is_true(no_pair()) end) + + describe("as windows change", function() + ---The sidebar, open beside the window showing `name`, with the cursor in it. + ---@param name string + ---@return integer + local function sidebar_beside(name) + unified_diff.turn_on() + vim.cmd.edit(name) + assert.is_not_nil(partner(vim.api.nvim_get_current_win())) + local sidebar = window.open(vim.api.nvim_create_buf(false, true)) + vim.api.nvim_set_current_win(sidebar) + return sidebar + end + + it("keeps the base window through a preview of another listed file, showing that file's base", function() + sidebar_beside("a.txt") + window.preview(dir .. "/a.txt", 1, BAND) + local win = vim.fn.bufwinid(dir .. "/a.txt") + local base = present(partner(win)) + + window.preview(dir .. "/b.txt", 1, BAND) + + assert.is_true(vim.wait(5000, function() + return vim.api.nvim_win_is_valid(base) + and vim.wo[base].diff + and vim.deep_equal( + { "b one", "b two" }, + vim.api.nvim_buf_get_lines(vim.api.nvim_win_get_buf(base), 0, -1, false) + ) + end, 20)) + end) + + it("shows the base of a previewed file once gitsigns attaches to it", function() + sidebar_beside("a.txt") + window.preview(dir .. "/a.txt", 1, BAND) + local win = vim.fn.bufwinid(dir .. "/a.txt") + assert.is_not_nil(partner(win)) + + window.preview(dir .. "/b.txt", 1, BAND) + + local base = present(partner(win)) + assert.is_true(vim.wait(5000, function() + return vim.deep_equal( + { "b one", "b two" }, + vim.api.nvim_buf_get_lines(vim.api.nvim_win_get_buf(base), 0, -1, false) + ) + end, 20)) + end) + + it("closes the base window as a preview fills its file window with an unlisted file", function() + vim.fn.writefile({ "x" }, "unlisted.txt") + sidebar_beside("a.txt") + window.preview(dir .. "/a.txt", 1, BAND) + local win = vim.fn.bufwinid(dir .. "/a.txt") + local base = present(partner(win)) + + window.preview(dir .. "/unlisted.txt", 1, BAND) + + assert.is_false(vim.api.nvim_win_is_valid(base)) + assert.is_false(vim.wo[win].diff) + end) + + it("leaves no base window once the sidebar closes after a preview", function() + sidebar_beside("a.txt") + window.preview(dir .. "/a.txt", 1, BAND) + local win = vim.fn.bufwinid(dir .. "/a.txt") + assert.is_not_nil(partner(win)) + window.preview(dir .. "/b.txt", 1, BAND) + + changeset.close() + + assert.same({ win }, vim.api.nvim_tabpage_list_wins(0)) + assert.same({}, diffing()) + end) + + it("leaves diff mode off a file shown again after the sidebar closed", function() + sidebar_beside("a.txt") + window.preview(dir .. "/a.txt", 1, BAND) + local win = vim.fn.bufwinid(dir .. "/a.txt") + assert.is_not_nil(partner(win)) + window.preview(dir .. "/b.txt", 1, BAND) + assert.is_not_nil(partner(win)) + changeset.close() + + vim.api.nvim_win_call(win, function() + vim.cmd.buffer(vim.fn.bufnr(dir .. "/b.txt")) + end) + + assert.is_false(vim.wo[win].diff) + assert.same({ win }, vim.api.nvim_tabpage_list_wins(0)) + end) + + it("keeps the pair through a reload", function() + unified_diff.turn_on() + vim.cmd.edit("a.txt") + local win = vim.api.nvim_get_current_win() + local base = present(partner(win)) + + vim.cmd("edit!") + vim.wait(500) + + assert.is_true(vim.api.nvim_win_is_valid(base)) + assert.equal(base, partner(win)) + unified_diff.limit("none") + assert.same({ win }, vim.api.nvim_tabpage_list_wins(0)) + end) + + it("closes the base window with its file window", function() + unified_diff.turn_on() + vim.cmd.edit("a.txt") + local win = vim.api.nvim_get_current_win() + vim.cmd.split("b.txt") + local other = vim.api.nvim_get_current_win() + vim.api.nvim_set_current_win(win) + local base = present(partner(win)) + + vim.api.nvim_win_close(win, false) + + assert.is_false(vim.api.nvim_win_is_valid(base)) + assert.same( + { other }, + vim.tbl_filter(function(w) + return w ~= present(partner(other)) + end, vim.api.nvim_tabpage_list_wins(0)) + ) + end) + + it("leaves one empty window as a lone sidebar closes after a preview", function() + local sidebar = sidebar_beside("a.txt") + vim.api.nvim_win_call(sidebar, function() + vim.cmd("only") + end) + window.preview(dir .. "/a.txt", 1, BAND) + assert.is_not_nil(partner(vim.fn.bufwinid(dir .. "/a.txt"))) + + changeset.close() + + local wins = vim.api.nvim_tabpage_list_wins(0) + assert.equal(1, #wins) + assert.is_false(vim.wo[wins[1]].diff) + end) + + it("leaves no gitsigns window behind as a session is restored", function() + vim.cmd.runtime("plugin/changeset.lua") + local session = vim.fn.tempname() + changeset.open() + assert.is_true(vim.wait(10000, function() + return window.buf() ~= nil and Sidebar.text():find("a.txt", 1, true) ~= nil + end, 25)) + window.preview(dir .. "/a.txt", 1, BAND) + assert.is_not_nil(partner(vim.fn.bufwinid(dir .. "/a.txt"))) + vim.cmd("mksession! " .. vim.fn.fnameescape(session)) + + vim.cmd("silent! source " .. vim.fn.fnameescape(session)) + vim.fn.delete(session) + + assert.is_false(vim.wait(1000, function() + return vim.iter(vim.api.nvim_list_wins()):any(function(w) + local buf = vim.api.nvim_win_get_buf(w) + return vim.api.nvim_buf_get_name(buf):find("^gitsigns://") ~= nil + and vim.api.nvim_buf_line_count(buf) <= 1 + and vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1] == "" + end) + end, 20)) + end) + end) end) From ca69e3e570d9925dcca35710dcca142920502cd7 Mon Sep 17 00:00:00 2001 From: Julian Torres Date: Sun, 11 Oct 2026 19:46:51 -0400 Subject: [PATCH 3/7] feat(diff): switch between unified and side-by-side - :Changeset diff split, (changeset-diff-split) and D switch the view for the session, turning the diff on when off - messages name the active view; the gitsigns warning and :checkhealth now say "the diff" - the reading window is never a floating window --- README.md | 3 +- doc/changeset.nvim.txt | 11 ++++- lua/changeset/health.lua | 4 +- lua/changeset/init.lua | 39 ++++++++++++++---- lua/changeset/unified_diff.lua | 9 ++++- plugin/changeset.lua | 13 +++++- tests/health_spec.lua | 4 +- tests/plugin_spec.lua | 20 ++++++++-- tests/split_diff_spec.lua | 17 ++++++++ tests/unified_diff_absent_spec.lua | 8 ++-- tests/unified_diff_toggle_spec.lua | 64 +++++++++++++++++++++++++++--- 11 files changed, 163 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 9839308..c683313 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,8 @@ has a `(changeset-…)` map named after its words, and these default keys: | `bt` | `base tag` | Pick a tag to compare against | | `bc` | `base commit` | Pick a commit to compare against | | `br` | `base reset` | Compare against the base changeset guesses again | -| `d` | `diff` | Turn the unified diff on or off | +| `d` | `diff` | Turn the diff on or off | +| `D` | `diff split` | Switch the diff between unified and side by side | | `r` | `refresh` | Rebuild the tree | | `nn` / `np` | `next` / `prev` | Open the next / previous change | | `ns` / `nS` | `next symbol` / `prev symbol` | Open the next / previous changed symbol | diff --git a/doc/changeset.nvim.txt b/doc/changeset.nvim.txt index 9972f3b..1bea2d6 100644 --- a/doc/changeset.nvim.txt +++ b/doc/changeset.nvim.txt @@ -268,7 +268,15 @@ UNIFIED DIFF Before any tree was built, it first builds one for the current buffer's repository, or warns "no merge base with the default branch" and stays off. Without gitsigns, or one too old for unified views, it - warns that the unified diff needs gitsigns and stays off. + warns that the diff needs gitsigns and stays off. + +:Changeset diff split *:Changeset-diff-split* +(changeset-diff-split) *(changeset-diff-split)* +D *changeset-D* + Switch the diff between unified and side by side, for every file of + the branch, turning it on when it is off. It says which view is on. + Like |:Changeset-diff|, it builds a tree when there is none, and warns + that the diff needs gitsigns without it. STEPPING @@ -415,6 +423,7 @@ Key Mode Runs ~ bc Normal |:Changeset-base-commit| br Normal |:Changeset-base-reset| d Normal |:Changeset-diff| +D Normal |:Changeset-diff-split| r Normal |:Changeset-refresh| nn Normal |:Changeset-next| np Normal |:Changeset-prev| diff --git a/lua/changeset/health.lua b/lua/changeset/health.lua index e9532b8..656ecde 100644 --- a/lua/changeset/health.lua +++ b/lua/changeset/health.lua @@ -158,12 +158,12 @@ end ---@param facts changeset.health.Facts local function gitsigns(facts) if facts.gitsigns and not facts.gitsigns_unified then - return finding("info", "`gitsigns` has no unified view: update it for the unified diff") + return finding("info", "`gitsigns` has no unified view: update it for the diff") end if facts.gitsigns then return finding("ok", "`gitsigns` found") end - return finding("info", "`gitsigns` not found: the gutter's branch base and the unified diff are unavailable") + return finding("info", "`gitsigns` not found: the gutter's branch base and the diff are unavailable") end ---@param facts changeset.health.Facts diff --git a/lua/changeset/init.lua b/lua/changeset/init.lua index 65b8a19..c04c4f7 100644 --- a/lua/changeset/init.lua +++ b/lua/changeset/init.lua @@ -832,21 +832,46 @@ function M.close() unified_diff.limit(config.get().unified_diff.keep) end ----Turn the unified diff off everywhere, or on for every file of the tree as the sidebar opening does, building the +---The active view's name, as messages say it. +---@return string +local function view_name() + return unified_diff.view() == "split" and "side-by-side diff" or "unified diff" +end + +---Warn that the diff needs gitsigns when it is missing. +---@return boolean available +local function diff_available() + if not unified_diff.available() then + vim.notify("Changeset: the diff needs gitsigns", vim.log.levels.WARN) + end + return unified_diff.available() +end + +---Turn the diff off everywhere, or on for every file of the tree as the sidebar opening does, building the ---current buffer's repository's tree when there is none, and say which; warn when gitsigns can't show it. function M.diff() if unified_diff.on() then unified_diff.limit("none") - return vim.notify("Changeset: unified diff off") + return vim.notify(("Changeset: %s off"):format(view_name())) end - if not unified_diff.available() then - return vim.notify("Changeset: the unified diff needs gitsigns", vim.log.levels.WARN) - end - if not (build.current() or built()) then + if not (diff_available() and (build.current() or built())) then return end unified_diff.turn_on() - vim.notify("Changeset: unified diff on") + vim.notify(("Changeset: %s on"):format(view_name())) +end + +---Switch the diff between unified and side by side, turning it on when it is off, building the current buffer's +---repository's tree when there is none, and say which view is on; warn when gitsigns can't show it. +function M.diff_split() + if not (diff_available() and (build.current() or built())) then + return + end + unified_diff.switch() + if not unified_diff.on() then + unified_diff.turn_on() + end + vim.notify(("Changeset: %s on"):format(view_name())) end ---Fill `placeholder`, in the current tabpage, with the tree, or let go of it when there is no tree to fill it with. diff --git a/lua/changeset/unified_diff.lua b/lua/changeset/unified_diff.lua index 4a46324..a570c8a 100644 --- a/lua/changeset/unified_diff.lua +++ b/lua/changeset/unified_diff.lua @@ -388,7 +388,8 @@ end ---@param win integer function M.read(win) local tab = vim.api.nvim_win_get_tabpage(win) - if started and allowed(vim.api.nvim_win_get_buf(win)) then + -- Floating, as the window `nvim_buf_call` borrows is: entering it reads nothing, and it is gone right after. + if started and vim.api.nvim_win_get_config(win).relative == "" and allowed(vim.api.nvim_win_get_buf(win)) then reading[tab] = win end for _, w in ipairs(vim.api.nvim_tabpage_list_wins(tab)) do @@ -396,6 +397,12 @@ function M.read(win) end end +---Switch the view between unified and side by side, for the rest of the session, redrawing every window's diff in it. +function M.switch() + switched = M.view() == "split" and "unified" or "split" + sync_all() +end + ---Close at once, in every window, each view this module opened on a file `keeps` turns down. ---@param keeps fun(path: string): boolean local function close_unless(keeps) diff --git a/plugin/changeset.lua b/plugin/changeset.lua index 9635920..fdcd93a 100644 --- a/plugin/changeset.lua +++ b/plugin/changeset.lua @@ -21,6 +21,9 @@ local subcommands = { diff = function() require("changeset").diff() end, + ["diff split"] = function() + require("changeset").diff_split() + end, base = function(opts) local base = require("changeset.base") if opts.fargs[2] then @@ -178,7 +181,7 @@ end, { nargs = "*", range = true, bar = true, - desc = "Toggle the changeset sidebar, rebuild it, pick a change from a list, set the base it compares against, turn the unified diff on or off, step through its changes, symbols or files, open or preview them, submit, restore, copy or abandon the review, or write, delete, draft, walk, reopen, list or show review comments", + desc = "Toggle the changeset sidebar, rebuild it, pick a change from a list, set the base it compares against, turn the diff on or off or switch it between unified and side by side, step through its changes, symbols or files, open or preview them, submit, restore, copy or abandon the review, or write, delete, draft, walk, reopen, list or show review comments", complete = function(lead, line) -- The words between `Changeset`, with any range before it, and `lead`. local typed = vim.trim(line:match("^%S+%s+(.-)%S*$") or "") @@ -363,7 +366,13 @@ local keys = { { lhs = "d", name = "diff", - desc = "Turn the unified diff on or off", + desc = "Turn the diff on or off", + icon = { cat = "filetype", name = "diff" }, + }, + { + lhs = "D", + name = "diff split", + desc = "Switch the diff between unified and side by side", icon = { cat = "filetype", name = "diff" }, }, } diff --git a/tests/health_spec.lua b/tests/health_spec.lua index d6ad2df..f31a903 100644 --- a/tests/health_spec.lua +++ b/tests/health_spec.lua @@ -143,11 +143,11 @@ describe("changeset.health", function() it("reports a missing gitsigns as info, since the gutter's base and the unified diff need it", function() assert.equal("ok", level({}, "`gitsigns` found")) - assert.equal("info", level({ gitsigns = false }, "the gutter's branch base and the unified diff are unavailable")) + assert.equal("info", level({ gitsigns = false }, "the gutter's branch base and the diff are unavailable")) end) it("tells a gitsigns too old for the unified diff from a current one", function() - assert.equal("info", level({ gitsigns_unified = false }, "`gitsigns` has no unified view")) + assert.equal("info", level({ gitsigns_unified = false }, "`gitsigns` has no unified view: update it for the diff")) end) it("names the language servers that provide symbols", function() diff --git a/tests/plugin_spec.lua b/tests/plugin_spec.lua index f687ce8..0f849f7 100644 --- a/tests/plugin_spec.lua +++ b/tests/plugin_spec.lua @@ -121,6 +121,7 @@ describe("plugin/changeset.lua", function() assert.same({ "file", "symbol" }, vim.fn.getcompletion("Changeset next ", "cmdline")) assert.same({ "next", "prev" }, vim.fn.getcompletion("Changeset preview ", "cmdline")) assert.same({ "commit", "reset", "tag" }, vim.fn.getcompletion("Changeset base ", "cmdline")) + assert.same({ "split" }, vim.fn.getcompletion("Changeset diff ", "cmdline")) assert.same({}, vim.fn.getcompletion("Changeset toggle ", "cmdline")) end) @@ -157,6 +158,17 @@ describe("plugin/changeset.lua", function() assert.equal(2, calls.diff) end) + it("routes diff split and its map to the switch between views", function() + local calls = {} + package.loaded.changeset = { diff_split = counter(calls, "diff_split") } + + vim.cmd("Changeset diff split") + vim.api.nvim_feedkeys(vim.keycode("(changeset-diff-split)"), "x", false) + + package.loaded.changeset = nil + assert.same({ diff_split = 2 }, calls) + end) + it("routes pick and its map to the picker", function() local calls = {} package.loaded["changeset.pick"] = { pick = counter(calls, "pick") } @@ -234,8 +246,9 @@ describe("plugin/changeset.lua", function() it("names the verbs a subcommand takes when its verb is missing or unknown", function() vim.cmd("Changeset comment") vim.cmd("Changeset review bogus") + vim.cmd("Changeset diff x") - assert.equal(2, #notes) + assert.equal(3, #notes) assert.equal(vim.log.levels.ERROR, present(notes[1]).level) assert.equal( "Changeset: :Changeset comment takes a verb: del, draft, last, list, new, next, prev, toggle", @@ -246,6 +259,7 @@ describe("plugin/changeset.lua", function() "Changeset: :Changeset review has no verb bogus; its verbs: abandon, restore, submit, yank", present(notes[2]).msg ) + assert.equal("Changeset: :Changeset diff has no verb x; its verbs: split", present(notes[3]).msg) end) it("refills a sidebar window a session left behind", function() @@ -466,12 +480,12 @@ describe("plugin/changeset.lua", function() end) it("maps the default keys once startup is done", function() - local probe = "for _, lhs in ipairs({ 'cc', 'cd', 'cq', 'd', 'l', 's', 'n', 'nn', 'np', 'ns', 'nS', 'nf', 'nF', 'g', 'j', 'bb', 'bt', 'bc', 'br', 't' }) do" + local probe = "for _, lhs in ipairs({ 'cc', 'cd', 'cq', 'd', 'D', 'l', 's', 'n', 'nn', 'np', 'ns', 'nS', 'nf', 'nF', 'g', 'j', 'bb', 'bt', 'bc', 'br', 't' }) do" .. " io.write(vim.fn.maparg('' .. lhs, 'n'), ' ') end" .. " io.write(vim.fn.maparg('cc', 'x'), ' ', vim.fn.maparg(']g', 'n'), ' ', vim.fn.maparg('[g', 'n'))" assert.equal( - "(changeset-comment-new) (changeset-comment-del) (changeset-comment-list) (changeset-diff) " + "(changeset-comment-new) (changeset-comment-del) (changeset-comment-list) (changeset-diff) (changeset-diff-split) " .. "(changeset-review-submit) (changeset-next) (changeset-prev) " .. "(changeset-next-symbol) (changeset-prev-symbol) (changeset-next-file) " .. "(changeset-prev-file) (changeset-toggle) (changeset-pick) (changeset-base) " diff --git a/tests/split_diff_spec.lua b/tests/split_diff_spec.lua index 60fd268..596c569 100644 --- a/tests/split_diff_spec.lua +++ b/tests/split_diff_spec.lua @@ -248,6 +248,23 @@ describe("the side-by-side diff", function() assert.is_not_nil(partner(win)) end) + it("swaps the reading window between its pair and its unified view as the view switches, both ways", function() + unified_diff.turn_on() + vim.cmd.edit("a.txt") + local win = vim.api.nvim_get_current_win() + assert.is_not_nil(partner(win)) + + unified_diff.switch() + + assert.is_true(unified(win)) + assert.same({ win }, vim.api.nvim_tabpage_list_wins(0)) + + unified_diff.switch() + + assert.is_not_nil(partner(win)) + assert.is_nil(require("gitsigns.unified").get_view(win)) + end) + it("closes the pair on a file marked viewed", function() vim.cmd.edit("a.txt") local win = vim.api.nvim_get_current_win() diff --git a/tests/unified_diff_absent_spec.lua b/tests/unified_diff_absent_spec.lua index 84bbffe..f9cd9c1 100644 --- a/tests/unified_diff_absent_spec.lua +++ b/tests/unified_diff_absent_spec.lua @@ -6,22 +6,22 @@ local Notify = require("support.notify") describe(":Changeset diff without gitsigns", function() assert.is_falsy(pcall(require, "gitsigns"), "gitsigns must not be installed") - it("warns that the unified diff needs gitsigns, staying off", function() + it("warns that the diff needs gitsigns, in either view, staying off", function() local dir, previous = Fixture.enter_tempdir() Fixture.feature_one_file(dir) vim.cmd.edit("mod.lua") local notes, restore = Notify.capture() changeset.diff() - changeset.diff() + changeset.diff_split() restore() vim.cmd("silent! %bwipeout!") vim.fn.chdir(previous) vim.fn.delete(dir, "rf") assert.same({ - { msg = "Changeset: the unified diff needs gitsigns", level = vim.log.levels.WARN }, - { msg = "Changeset: the unified diff needs gitsigns", level = vim.log.levels.WARN }, + { msg = "Changeset: the diff needs gitsigns", level = vim.log.levels.WARN }, + { msg = "Changeset: the diff needs gitsigns", level = vim.log.levels.WARN }, }, notes) end) end) diff --git a/tests/unified_diff_toggle_spec.lua b/tests/unified_diff_toggle_spec.lua index b49c751..c9c1e2a 100644 --- a/tests/unified_diff_toggle_spec.lua +++ b/tests/unified_diff_toggle_spec.lua @@ -3,12 +3,14 @@ require("gitsigns").setup() require("support.gh") local changeset = require("changeset") +local config = require("changeset.config") +local window = require("changeset.window") local Fixture = require("support.git") local Notify = require("support.notify") local present = require("support.present") -- One session, told case by case, each starting where the one before left it: the first has no tree to work with. -describe(":Changeset diff", function() +describe(":Changeset diff and :Changeset diff split", function() local dir, previous = Fixture.enter_tempdir() Fixture.feature_two_files(dir) local nowhere = vim.fn.tempname() @@ -21,6 +23,21 @@ describe(":Changeset diff", function() return require("gitsigns.unified").get_view(win) end + ---Whether `win` comes to show the side-by-side diff: it and one other window in diff mode. + ---@param win integer + ---@return boolean + local function paired(win) + return ( + vim.wait(5000, function() + return vim.wo[win].diff + and #vim.tbl_filter(function(w) + return vim.wo[w].diff + end, vim.api.nvim_tabpage_list_wins(0)) + == 2 + end, 20) + ) + end + it("warns, staying off, when there is no tree and no merge base to build one on", function() vim.fn.chdir(nowhere) @@ -32,25 +49,60 @@ describe(":Changeset diff", function() assert.truthy(present(notes[1]).msg:find("no merge base", 1, true), present(notes[1]).msg) end) - it("builds the current buffer's tree when none is built, then turns on, saying so", function() + it("builds the current buffer's tree when none is built, then turns on side by side, saying so", function() vim.cmd.edit("mod.lua") - changeset.diff() + changeset.diff_split() - assert.same({ "Changeset: unified diff on" }, Notify.messages({ notes[2] })) + assert.same({ "Changeset: side-by-side diff on" }, Notify.messages({ notes[2] })) + assert.is_true(paired(vim.api.nvim_get_current_win())) + end) + + it("switches back to unified, saying so", function() + changeset.diff_split() + + assert.same({ "Changeset: unified diff on" }, Notify.messages({ notes[3] })) assert.is_true(vim.wait(5000, function() return view(vim.api.nvim_get_current_win()) ~= nil end, 20)) end) - it("turns off everywhere with the sidebar closed, saying so", function() + it("turns off everywhere with the sidebar closed, naming the view", function() changeset.diff() - assert.same({ "Changeset: unified diff off" }, Notify.messages({ notes[3] })) + assert.same({ "Changeset: unified diff off" }, Notify.messages({ notes[4] })) assert.is_nil(view(vim.api.nvim_get_current_win())) end) + it("names the side-by-side view as it turns off", function() + changeset.diff_split() + + changeset.diff() + + assert.same({ "Changeset: side-by-side diff off" }, Notify.messages({ notes[6] })) + end) + + it("reaches no further than the entered files after the sidebar closes under keep = entered", function() + config.setup({ unified_diff = { keep = "entered" } }) + local file_win = vim.api.nvim_get_current_win() + changeset.open() + assert.is_true(vim.wait(10000, function() + return window.buf() ~= nil + end, 25)) + vim.api.nvim_set_current_win(file_win) + vim.cmd.edit("mod.lua") + changeset.close() + vim.cmd.edit("other.lua") + + changeset.diff_split() + + assert.is_false(vim.wait(500, function() + return vim.wo[file_win].diff or view(file_win) ~= nil + end, 20)) + end) + restore() + config.setup({}) vim.cmd("silent! %bwipeout!") vim.fn.chdir(previous) vim.fn.delete(dir, "rf") From 62fd8a30f56e40f1bb495a313563f0794111733e Mon Sep 17 00:00:00 2001 From: Julian Torres Date: Sun, 11 Oct 2026 19:49:27 -0400 Subject: [PATCH 4/7] docs: describe the side-by-side diff --- README.md | 4 +++- doc/agents/design.md | 49 ++++++++++++++++++++++++++++++++++++++++-- doc/changeset.nvim.txt | 45 ++++++++++++++++++++++++-------------- 3 files changed, 79 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index c683313..7098a8d 100644 --- a/README.md +++ b/README.md @@ -198,7 +198,9 @@ Both need gitsigns. base as the gutter. Closing the sidebar turns it off, or, as `unified_diff.keep` says, keeps it on for the files you entered (opened from the sidebar, a step or the picker) or for all of them. `d` turns it off or on at any time, and `:Gitsigns diffthis unified=true` - closes one file's view until it next turns on. + closes one file's view until it next turns on. `D` switches it side by side, the window + you are reading beside the file's base, or back; `unified_diff.view` picks the view a session + starts in. ## Configuration diff --git a/doc/agents/design.md b/doc/agents/design.md index 2828de6..c2eddeb 100644 --- a/doc/agents/design.md +++ b/doc/agents/design.md @@ -1336,6 +1336,51 @@ stay directly under their line, the deleted lines after them. A parked block's r counted from the head of the filler above the next line rather than its end, since the deleted lines over the next line sit between the stack and that line. +### Side by side + +The side-by-side diff is the same reconciler drawing another way. Each window still decides +whether a diff belongs there, through the same opening, closing, reach and viewed rules; it +only asks, too, which view it wants, and reopens when that changes, as it reopens on a base +that moved. A window opened in one view closes through that view's side. + +Vim's diff mode belongs to the tabpage, not to a pair of windows, so two pairs in one +tabpage would merge into a four-way diff. So each tabpage has one pair, in the window being +read: the one the cursor last entered on a listed file, or the one the sidebar previews +into. Entering the base window, the sidebar or an unlisted file doesn't move it, nor does a +floating window, as the one `nvim_buf_call` borrows. Every other window keeps the unified +view, which needs no partner. A tabpage holding a diff of the user's own gets no pair, and +a window in diff mode changeset didn't pair counts as the user's own view, left alone as a +unified view of theirs is. + +It doesn't run `gitsigns.diffthis()` either. Its no-argument call does diff the base +changeset set, but takes the current window late, as the unified `diffthis` does, splits a +conflicted file three ways, and makes a buffer that writes back to the index. The base +window holds the read-only revision buffer the unified view already uses, opened on the +file window's left so the pair reads base | file, as GitHub's split does. + +A file new since the base stays unified: its base text is empty, which gitsigns writes as +one blank line, and Vim's diff can't show "every line added" against that, while the +unified view already does. + +A preview swap parks the base window for the next listed file rather than closing it, so +holding `j` in the sidebar doesn't open and close a window per row. Turning the diff off, +or lowering its reach, closes every base window no view names. Closing a file window takes +its base window with it, so none is left diffing nothing; closing the base window by hand +closes that file's diff as a hand-closed unified view does. A reload keeps the pair, since +`:e!` takes gitsigns' unified view with the buffer but not a window. + +'diff' is window-local, and a window-local option comes back with the buffer it was set +for. So a window taking back a buffer changeset had diffed, with no base window beside it, +would sit in a one-sided diff; changeset turns that 'diff' off. + +Switching views is not a hand-close. It redraws every window in the other view, the user +asking for the diff another way, not for less of it; counting it as closing each file's +view would leave every file without one. + +A session restores a base window as an empty `gitsigns://` buffer, since gitsigns reads no +such buffer back. So on `SessionLoadPost` changeset closes each one, unless it is the last +window, and the reconciler opens a fresh pair. + ### The diff follows the sidebar Opening the sidebar is an implicit request to read the branch, so it turns the diff on @@ -1353,8 +1398,8 @@ diff on every file the cursor passed over. The set lasts the session, so a file an hour ago keeps its diff after the next close. It records which files, not when, since nothing reads a time. -Closing one of changeset's views by hand, with gitsigns' toggle or by switching to its split -diff, is about that file. Its views close in every window showing it, so none is left frozen +Closing one of changeset's views by hand, with gitsigns' toggle or by opening gitsigns' own +split diff, is about that file. Its views close in every window showing it, so none is left frozen there with gitsigns' signs drawn beside it, and they stay closed until the diff next turns on, by the sidebar or the toggle, each the user asking for the diff again; nothing writes it down. Turning every file off for it would make one stray toggle cost the session's diff. diff --git a/doc/changeset.nvim.txt b/doc/changeset.nvim.txt index 1bea2d6..534fe6a 100644 --- a/doc/changeset.nvim.txt +++ b/doc/changeset.nvim.txt @@ -262,13 +262,13 @@ UNIFIED DIFF :Changeset diff *:Changeset-diff* (changeset-diff) *(changeset-diff)* d *changeset-d* - Turn the unified diff off everywhere when it is on, else on for every - file the sidebar lists, as opening the sidebar does, with the sidebar - open or closed (|changeset-unified-diff|). It says which, in one line. - Before any tree was built, it first builds one for the current - buffer's repository, or warns "no merge base with the default branch" - and stays off. Without gitsigns, or one too old for unified views, it - warns that the diff needs gitsigns and stays off. + Turn the diff off everywhere when it is on, else on for every file + the sidebar lists, as opening the sidebar does, with the sidebar open + or closed, in the view it is in (|changeset-unified-diff|). It says + which, in one line. Before any tree was built, it first builds one + for the current buffer's repository, or warns "no merge base with the + default branch" and stays off. Without gitsigns, or one too old for + unified views, it warns that the diff needs gitsigns and stays off. :Changeset diff split *:Changeset-diff-split* (changeset-diff-split) *(changeset-diff-split)* @@ -1165,6 +1165,21 @@ and needs gitsigns. changeset opens it in no other file, and closes it in a file the sidebar stops listing, as when you switch branches, once the sidebar has read the new diff. + *changeset-split-diff* +|:Changeset-diff-split| switches it side by side, or back, for the session; +`unified_diff.view` picks the view a session starts in +(|changeset-option-unified_diff.view|). Side by side, the window you are +reading shows its file's base in a window on its left, the two in +|diff-mode|. Diff mode belongs to the tab page, so only that window of each +tab page goes side by side, and the other windows there keep the unified +view. The window you are reading is the one your cursor last entered on a +file the sidebar lists, or the one the sidebar previews into, and the base +window moves with it. A file added or untracked since the base stays +unified, and so does a tab page holding a diff of your own. Closing the base +window yourself closes that file's diff, as closing its unified view does +(below). `unified_diff.keep` and viewed marks govern the side-by-side diff as +they govern the unified one. 'diffopt' is yours. + It is off when Neovim starts. Opening the sidebar, by any route, turns it on for every file the sidebar lists, in every window, files you open later included. Closing the sidebar, by any route, keeps it on only as far as @@ -1307,17 +1322,15 @@ review_comment.blocks *changeset-option-review_comment.blocks* as blocks of their whole text (|changeset-blocks|). unified_diff.keep *changeset-option-unified_diff.keep* - `"none"`, `"entered"` or `"all"`. Default `"none"`. How far the unified - diff stays on once the sidebar closes: for no file, for the files you - entered, or for every file the sidebar lists (|changeset-unified-diff|). - Read each time the sidebar closes. + `"none"`, `"entered"` or `"all"`. Default `"none"`. How far the diff + stays on once the sidebar closes: for no file, for the files you + entered, or for every file the sidebar lists + (|changeset-unified-diff|). Read each time the sidebar closes. unified_diff.view *changeset-option-unified_diff.view* - `"unified"` or `"split"`. Default `"unified"`. How the diff is drawn: - inline, or side by side, the file's base in a window on the left of - the window you are reading, the two in |diff-mode|. Only that window - of each tab page goes side by side, the rest stay inline, and so does - a file new since the base. 'diffopt' is yours. Read as the diff opens. + `"unified"` or `"split"`. Default `"unified"`. The view a session's + diff starts in: inline, or side by side (|changeset-split-diff|). Read + as the diff opens, until |:Changeset-diff-split| switches it. ============================================================================== 15. Highlight groups *changeset-highlights* From f432b2ade8226eab896420b1cc9117b3daed923e Mon Sep 17 00:00:00 2001 From: Julian Torres Date: Sun, 11 Oct 2026 20:19:49 -0400 Subject: [PATCH 5/7] fix(split_diff): address review findings - scrub forgets a buffer once its 'diff' is known off, leaving the user's own diffs alone - closing a file window alone with its base window no longer aborts with E855; the base window closes just after - session restore resets a leftover foldmethod=diff - a file compared against the index stays unified: its base buffer writes back to the index - :Changeset diff split from off turns the diff on side by side - one named rule says when a parked base window lives --- doc/agents/design.md | 23 +++-- doc/changeset.nvim.txt | 7 +- lua/changeset/config.lua | 2 +- lua/changeset/diff_reach.lua | 2 +- lua/changeset/init.lua | 17 ++-- lua/changeset/split_diff.lua | 79 ++++++++++++---- lua/changeset/unified_diff.lua | 142 ++++++++++++++++++----------- plugin/changeset.lua | 14 +-- tests/split_diff_spec.lua | 142 ++++++++++++++++++++++++----- tests/unified_diff_toggle_spec.lua | 16 ++++ 10 files changed, 316 insertions(+), 128 deletions(-) diff --git a/doc/agents/design.md b/doc/agents/design.md index c2eddeb..3cd5f32 100644 --- a/doc/agents/design.md +++ b/doc/agents/design.md @@ -1356,7 +1356,10 @@ It doesn't run `gitsigns.diffthis()` either. Its no-argument call does diff the changeset set, but takes the current window late, as the unified `diffthis` does, splits a conflicted file three ways, and makes a buffer that writes back to the index. The base window holds the read-only revision buffer the unified view already uses, opened on the -file window's left so the pair reads base | file, as GitHub's split does. +file window's left so the pair reads base | file, as GitHub's split does. That buffer is +read-only only while the base is a commit: compared against the index, gitsigns makes it +write back there, so a file stays unified until its base is a commit, rather than put a +buffer that writes to the index in a window the user can enter. A file new since the base stays unified: its base text is empty, which gitsigns writes as one blank line, and Vim's diff can't show "every line added" against that, while the @@ -1365,7 +1368,8 @@ unified view already does. A preview swap parks the base window for the next listed file rather than closing it, so holding `j` in the sidebar doesn't open and close a window per row. Turning the diff off, or lowering its reach, closes every base window no view names. Closing a file window takes -its base window with it, so none is left diffing nothing; closing the base window by hand +its base window with it, so none is left diffing nothing; when that would empty the base +window's tabpage mid-close, it closes just after instead; closing the base window by hand closes that file's diff as a hand-closed unified view does. A reload keeps the pair, since `:e!` takes gitsigns' unified view with the buffer but not a window. @@ -1379,7 +1383,9 @@ view would leave every file without one. A session restores a base window as an empty `gitsigns://` buffer, since gitsigns reads no such buffer back. So on `SessionLoadPost` changeset closes each one, unless it is the last -window, and the reconciler opens a fresh pair. +window, and the reconciler opens a fresh pair. `:mksession` also saves a diffed window's +'foldmethod' `diff` but not its 'diff', so changeset resets a window restored with the one +and not the other, which would otherwise fold on nothing. ### The diff follows the sidebar @@ -1399,10 +1405,11 @@ an hour ago keeps its diff after the next close. It records which files, not whe nothing reads a time. Closing one of changeset's views by hand, with gitsigns' toggle or by opening gitsigns' own -split diff, is about that file. Its views close in every window showing it, so none is left frozen -there with gitsigns' signs drawn beside it, and they stay closed until the diff next turns -on, by the sidebar or the toggle, each the user asking for the diff again; nothing writes it -down. Turning every file off for it would make one stray toggle cost the session's diff. +split diff, is about that file. Its views close in every window showing it, so none is left +frozen there with gitsigns' signs drawn beside it, and they stay closed until the diff next +turns on, by the sidebar or the toggle, each the user asking for the diff again; nothing +writes it down. Turning every file off for it would make one stray toggle cost the +session's diff. A file marked viewed has no diff while its mark holds: the user has read it, so its buffer goes back to how gitsigns alone draws it. The diff returns when the file's diff changes, @@ -1413,7 +1420,7 @@ condition, apart from the reach and from a view closed by hand. The tree is built the first time something asks for it — `:Changeset`, `:Changeset refresh`, the picker (`:Changeset pick`, or `require("changeset.pick").pick()`), -`:Changeset diff` turning the unified diff on, or a restored session refilling the sidebar +`:Changeset diff` turning the diff on, or a restored session refilling the sidebar — for the current buffer's repository: the fork point is measured at once, the diff and symbols in the background. Reading symbols loads each changed file the cache diff --git a/doc/changeset.nvim.txt b/doc/changeset.nvim.txt index 534fe6a..80d2642 100644 --- a/doc/changeset.nvim.txt +++ b/doc/changeset.nvim.txt @@ -274,7 +274,8 @@ UNIFIED DIFF (changeset-diff-split) *(changeset-diff-split)* D *changeset-D* Switch the diff between unified and side by side, for every file of - the branch, turning it on when it is off. It says which view is on. + the branch. When the diff is off, turn it on side by side instead. + It says which view is on. Like |:Changeset-diff|, it builds a tree when there is none, and warns that the diff needs gitsigns without it. @@ -684,7 +685,7 @@ keymaps.toggle_viewed *changeset-option-keymaps.toggle_viewed* - The word `viewed` sits left of its stat, in |ChangesetMeta|. A long name is trimmed to make room. - The file folds. - Its unified diff closes too (|changeset-unified-diff|). + Its diff closes too (|changeset-unified-diff|). After marking, the cursor moves to the next file row below that isn't viewed, skipping other copies of the file you marked, and previews it. @@ -1580,7 +1581,7 @@ nothing, starts no language server and makes no network request. cursor row and scroll too when it also contains `globals`. - Kept in memory until Neovim exits: folds, which diff each viewed file was last folded at, the agent last submitted to, how - far the unified diff reaches, the files you entered + far the diff reaches, the files you entered (|changeset-entered-files|), and the files whose view you closed. vim:tw=78:ts=8:noet:ft=help:norl: diff --git a/lua/changeset/config.lua b/lua/changeset/config.lua index e41a375..56beb0b 100644 --- a/lua/changeset/config.lua +++ b/lua/changeset/config.lua @@ -46,7 +46,7 @@ local M = {} ---@alias changeset.DiffView "unified"|"split" ---@class changeset.Config.UnifiedDiff ----@field keep? changeset.DiffReach.Keep Which files keep the unified diff once the sidebar closes: none, those entered, or all. Default "none". +---@field keep? changeset.DiffReach.Keep Which files keep the diff once the sidebar closes: none, those entered, or all. Default "none". ---@field view? changeset.DiffView The view the diff starts each session in: inline, or side by side in the window being read. Default "unified". ---The options in force: every field set. diff --git a/lua/changeset/diff_reach.lua b/lua/changeset/diff_reach.lua index 077bfeb..8995fa4 100644 --- a/lua/changeset/diff_reach.lua +++ b/lua/changeset/diff_reach.lua @@ -1,4 +1,4 @@ ----Which files the unified diff reaches: none, the files entered, or all of them, less those closed by hand. +---Which files the diff reaches: none, the files entered, or all of them, less those closed by hand. ---It decides; `changeset.unified_diff` opens and closes the views. local M = {} diff --git a/lua/changeset/init.lua b/lua/changeset/init.lua index c04c4f7..b571c8a 100644 --- a/lua/changeset/init.lua +++ b/lua/changeset/init.lua @@ -824,7 +824,7 @@ function M.open() end end ----Dismiss the sidebar, the unified diff kept on only as far as `unified_diff.keep` says. The tree stays, and keeps +---Dismiss the sidebar, the diff kept on only as far as `unified_diff.keep` says. The tree stays, and keeps ---refreshing. function M.close() release() @@ -841,10 +841,11 @@ end ---Warn that the diff needs gitsigns when it is missing. ---@return boolean available local function diff_available() - if not unified_diff.available() then + local available = unified_diff.available() + if not available then vim.notify("Changeset: the diff needs gitsigns", vim.log.levels.WARN) end - return unified_diff.available() + return available end ---Turn the diff off everywhere, or on for every file of the tree as the sidebar opening does, building the @@ -861,14 +862,18 @@ function M.diff() vim.notify(("Changeset: %s on"):format(view_name())) end ----Switch the diff between unified and side by side, turning it on when it is off, building the current buffer's +---Switch the diff between unified and side by side while it is on, or turn it on side by side when it is off, building the current buffer's ---repository's tree when there is none, and say which view is on; warn when gitsigns can't show it. function M.diff_split() if not (diff_available() and (build.current() or built())) then return end - unified_diff.switch() - if not unified_diff.on() then + if unified_diff.on() then + unified_diff.switch() + else + if unified_diff.view() ~= "split" then + unified_diff.switch() + end unified_diff.turn_on() end vim.notify(("Changeset: %s on"):format(view_name())) diff --git a/lua/changeset/split_diff.lua b/lua/changeset/split_diff.lua index 766ecb4..bdee6f3 100644 --- a/lua/changeset/split_diff.lua +++ b/lua/changeset/split_diff.lua @@ -6,10 +6,6 @@ local M = {} ---@type table local partners = {} ----The file buffers this module put in diff mode, which a window showing one again takes 'diff' back from. ----@type table -local diffed = {} - ---Show `base` beside `win`, which shows `buf`, the two diffed, reusing the base window `win` already has. ---@param win integer ---@param buf integer @@ -24,7 +20,8 @@ function M.show(win, buf, base) base_win = vim.api.nvim_open_win(base, false, { split = "left", win = win }) end partners[win] = { win = base_win, buf = buf } - diffed[buf] = true + -- Marks a buffer that may bring this module's 'diff' back into another window, until its 'diff' is known off. + vim.b[buf].changeset_diffed = true vim.api.nvim_win_call(base_win, vim.cmd.diffthis) vim.api.nvim_win_call(win, vim.cmd.diffthis) end @@ -49,47 +46,91 @@ function M.owns(win) return false end ----Close `win`'s base window and take `win` out of diff mode. +---Close `win`'s base window, if it has one, and take `win` out of diff mode. ---@param win integer function M.close(win) local partner = partners[win] + if not partner then + return + end partners[win] = nil - if partner and vim.api.nvim_win_is_valid(partner.win) then + if vim.api.nvim_win_is_valid(partner.win) then vim.api.nvim_win_close(partner.win, true) end if vim.api.nvim_win_is_valid(win) then vim.api.nvim_win_call(win, vim.cmd.diffoff) + if vim.api.nvim_win_get_buf(win) == partner.buf then + vim.b[partner.buf].changeset_diffed = nil + end end end ----The file windows with a base window open, whether or not they still show the buffer it was opened for. +---The file windows with a base window open. ---@return integer[] -function M.paired() - local wins = {} - for win, partner in pairs(partners) do - if vim.api.nvim_win_is_valid(partner.win) then - wins[#wins + 1] = win - end - end - return wins +function M.file_wins() + return vim.tbl_keys(partners) end ---Take `win` out of diff mode when its buffer brought back a 'diff' this module set, as a window-local option comes back ---with the buffer it was set for, and no base window shows beside it. ---@param win integer function M.scrub(win) - if vim.wo[win].diff and diffed[vim.api.nvim_win_get_buf(win)] and not M.showing(win) then + local buf = vim.api.nvim_win_get_buf(win) + if vim.wo[win].diff and vim.b[buf].changeset_diffed and not M.showing(win) then vim.api.nvim_win_call(win, vim.cmd.diffoff) + vim.b[buf].changeset_diffed = nil + end +end + +---The windows of `tab` that aren't floating. +---@param tab integer +---@return integer[] +local function panes(tab) + return vim.tbl_filter(function(w) + return vim.api.nvim_win_get_config(w).relative == "" + end, vim.api.nvim_tabpage_list_wins(tab)) +end + +---Close `win` unless it is the last window of the session that isn't floating. +---@param win integer +local function close_unless_last(win) + if vim.api.nvim_win_is_valid(win) and (#vim.api.nvim_list_tabpages() > 1 or #panes(0) > 1) then + vim.api.nvim_win_close(win, true) end end ----Close the base window of `closed`, a file window closing, unless that would leave no window. +---Close the base window of `closed`, a file window closing. ---@param closed integer function M.forget(closed) local partner = partners[closed] partners[closed] = nil - if partner and vim.api.nvim_win_is_valid(partner.win) and #vim.api.nvim_list_wins() > 2 then + if not (partner and vim.api.nvim_win_is_valid(partner.win)) then + return + end + -- `closed` is still listed while WinClosed runs, and closing the last other window of its tabpage now aborts its close. + if #panes(vim.api.nvim_win_get_tabpage(partner.win)) > 2 then vim.api.nvim_win_close(partner.win, true) + else + vim.schedule(function() + close_unless_last(partner.win) + end) + end +end + +---Close each base window a restored session brought back, which comes back empty since gitsigns reads no +---`gitsigns://` buffer back, and give each window `:diffthis` left folding by diff back its own 'foldmethod'. +function M.drop_restored() + for _, win in ipairs(vim.api.nvim_list_wins()) do + if vim.startswith(vim.api.nvim_buf_get_name(vim.api.nvim_win_get_buf(win)), "gitsigns://") then + close_unless_last(win) + end + end + for _, win in ipairs(vim.api.nvim_list_wins()) do + if vim.wo[win].foldmethod == "diff" and not vim.wo[win].diff then + vim.api.nvim_win_call(win, function() + vim.cmd("setlocal foldmethod<") + end) + end end end diff --git a/lua/changeset/unified_diff.lua b/lua/changeset/unified_diff.lua index a570c8a..a8a612e 100644 --- a/lua/changeset/unified_diff.lua +++ b/lua/changeset/unified_diff.lua @@ -58,14 +58,28 @@ local covered = {} ---@type boolean? local untracked_was --- Reaches into gitsigns internals: its buffer cache, its unified views, and the base buffer `diffthis` makes for them. - ---The view the diff draws in now. ---@return changeset.DiffView function M.view() return switched or changeset_config.get().unified_diff.view end +-- Reaches into gitsigns internals: its buffer cache, its unified views, and the base buffer `diffthis` makes for them. + +---Whether `win` is in the user's own diff mode: one this module didn't pair. +---@param win integer +---@return boolean +local function users_diff(win) + return vim.wo[win].diff and not split_diff.owns(win) +end + +---Whether base text `text` is blank, as a file new since the base has. +---@param text string[] +---@return boolean +local function blank(text) + return #text <= 1 and (text[1] or "") == "" +end + ---Whether `win` shows a view, ours or the user's: in the split view, a window in diff mode this module didn't pair is ---the user's own. ---@param win integer @@ -73,23 +87,24 @@ end local function showing(win) return require("gitsigns.unified").get_view(win) ~= nil or split_diff.showing(win) - or (M.view() == "split" and vim.wo[win].diff and not split_diff.owns(win)) + or (M.view() == "split" and users_diff(win)) end ----How `win` wants its diff drawn against base text `text`. Only its tabpage's reading window goes side by side, and ----not on a file new since the base, whose blank base Vim's diff can't show as all added, nor beside the user's own diff: +---How `win` wants its diff drawn against `git_obj`'s base, whose text is `text`. Only its tabpage's reading window, or +---its current one before any was read, goes side by side. Not on a file new since the base, whose blank base Vim's diff +---can't show as all added; nor against the index, whose base buffer writes back to it; nor beside the user's own diff: ---Vim's diff mode belongs to the tabpage. ---@param win integer +---@param git_obj Gitsigns.GitObj ---@param text string[] ---@return changeset.DiffView -local function wanted(win, text) +local function wanted(win, git_obj, text) local tab = vim.api.nvim_win_get_tabpage(win) local split = M.view() == "split" and (reading[tab] or vim.api.nvim_tabpage_get_win(tab)) == win - and table.concat(text, "\n") ~= "" - and not vim.iter(vim.api.nvim_tabpage_list_wins(tab)):any(function(w) - return vim.wo[w].diff and not split_diff.owns(w) - end) + and git_obj.revision ~= nil + and not blank(text) + and not vim.iter(vim.api.nvim_tabpage_list_wins(tab)):any(users_diff) return split and "split" or "unified" end @@ -254,10 +269,14 @@ local function cover(buf, ns, top, bot, added) end end ----Close `win`'s base window, if it has one. +---Close `win`'s base window unless the split view this module opened there names it, or it is parked for the file +---the diff reaches that `win` now shows, so holding `j` in the sidebar doesn't open and close one per row. ---@param win integer -local function close_paired(win) - if vim.list_contains(split_diff.paired(), win) then +local function settle(win) + local mine = opened[win] + local lives = mine and mine.view == "split" + or not mine and vim.api.nvim_win_is_valid(win) and allowed(vim.api.nvim_win_get_buf(win)) + if not lives then split_diff.close(win) end end @@ -269,12 +288,12 @@ end ---@field path string The file `buf` holds. ---@field git_obj Gitsigns.GitObj Names the base. ---@field text string[] The base's text. ----@field view changeset.DiffView How to draw it. ---Show `view`'s buffer's diff in its window, replacing any view there. --- ----What `:Gitsigns diffthis unified=true` runs, with the window held: it takes the current window only once the base's ----buffer is made, which can wait on git, by when the sidebar may be current. +---What `:Gitsigns diffthis unified=true` runs, with the window held, or, for the reading window, hands the base to +---`split_diff.show`: it takes the current window only once the base's buffer is made, which can wait on git, by when +---the sidebar may be current. The view is decided again then, since the reading window can move meanwhile. ---@param view changeset.unified_diff.View local function open(view) local win, buf, path, git_obj, text = view.win, view.buf, view.path, view.git_obj, view.text @@ -293,26 +312,22 @@ local function open(view) end if vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_buf(win) == buf and reaches(path) then -- Asked again: the reading window can have moved while git read the base. - local drawn = wanted(win, text) - if drawn == "unified" then - close_paired(win) - end - -- Before `show`, which registers the view at once but then waits on its diff. + local drawn = wanted(win, git_obj, text) + -- Before either draw: gitsigns' `show` registers the view at once but then waits on its diff. opened[win] = { buf = buf, path = path, base = base, text = text, view = drawn } + settle(win) if drawn == "split" then split_diff.show(win, buf, base) return end - if table.concat(text, "\n") == "" then + if blank(text) then -- gitsigns writes a base without the file as one blank line, which the view reads back with its newline. vim.bo[base].endofline = false end paint(win) require("gitsigns.unified").show(win, base, created, loaded) else - if vim.api.nvim_win_is_valid(win) and not opened[win] then - close_paired(win) - end + settle(win) if created then vim.api.nvim_buf_delete(base, { force = true }) end @@ -336,10 +351,13 @@ function M.sync(win) if left and left.buf ~= buf then opened[win] = nil -- A preview swaps the buffer without BufWinLeave, so gitsigns still holds our view, and would hand it back once - -- the buffer returns. One showing now is on this buffer, so the user's own. A base window stays for the next file - -- the diff reaches, so holding `j` in the sidebar doesn't open and close one per row. - if not showing(win) and not (left.view == "split" and reached) then - close(win, left) + -- the buffer returns. One showing now is on this buffer, so the user's own. + if not showing(win) then + if left.view == "split" then + settle(win) + else + close(win, left) + end end end if not reached then @@ -353,14 +371,8 @@ function M.sync(win) return end local mine = opened[win] - local view = { - win = win, - buf = buf, - path = path, - git_obj = bcache.git_obj, - text = bcache.compare_text, - view = wanted(win, bcache.compare_text), - } + local view = { win = win, buf = buf, path = path, git_obj = bcache.git_obj, text = bcache.compare_text } + local want = wanted(win, bcache.git_obj, bcache.compare_text) if not showing(win) then -- One of ours gone while its buffer stays was closed by the user, which leaving the window acts on, or is going -- with a buffer being deleted, which publishes a last update first. @@ -368,8 +380,8 @@ function M.sync(win) open(view) end -- A view this module didn't open is the user's own. - elseif mine and (mine.text ~= view.text or mine.view ~= view.view) then - if mine.view ~= view.view then + elseif mine and (mine.text ~= view.text or mine.view ~= want) then + if mine.view ~= want then opened[win] = nil close(win, mine) end @@ -383,17 +395,34 @@ local function sync_all() end end ----Make `win` its tabpage's reading window when it shows a file the diff reaches, then open the diff in the tabpage's ----windows. +---Make `win` its tabpage's reading window when it shows a file the diff reaches. ---@param win integer -function M.read(win) +---@return boolean changed Whether the reading window moved. +local function mark_reading(win) local tab = vim.api.nvim_win_get_tabpage(win) -- Floating, as the window `nvim_buf_call` borrows is: entering it reads nothing, and it is gone right after. - if started and vim.api.nvim_win_get_config(win).relative == "" and allowed(vim.api.nvim_win_get_buf(win)) then + if + started + and reading[tab] ~= win + and vim.api.nvim_win_get_config(win).relative == "" + and allowed(vim.api.nvim_win_get_buf(win)) + then reading[tab] = win + return true end - for _, w in ipairs(vim.api.nvim_tabpage_list_wins(tab)) do - M.sync(w) + return false +end + +---Make `win` its tabpage's reading window when it shows a file the diff reaches, then open the diff in it, and, as the +---split view moves, in the rest of its tabpage. +---@param win integer +function M.read(win) + if mark_reading(win) and M.view() == "split" then + for _, w in ipairs(vim.api.nvim_tabpage_list_wins(vim.api.nvim_win_get_tabpage(win))) do + M.sync(w) + end + else + M.sync(win) end end @@ -414,11 +443,8 @@ local function close_unless(keeps) end end end - -- One no view names was kept through a preview, or came back with its buffer. - for _, win in ipairs(split_diff.paired()) do - if not opened[win] then - split_diff.close(win) - end + for _, win in ipairs(split_diff.file_wins()) do + settle(win) end end @@ -454,7 +480,7 @@ local function start() vim.api.nvim_create_autocmd("User", { group = group, pattern = "GitSignsUpdate", - desc = "changeset: open gitsigns' unified diff in a buffer's windows once gitsigns has its base", + desc = "changeset: open the diff in a buffer's windows once gitsigns has its base", callback = function(args) local buf = args.data and args.data.buffer for _, win in ipairs(buf and vim.fn.win_findbuf(buf) or {}) do @@ -466,7 +492,7 @@ local function start() -- gone by then was closed by the user. vim.api.nvim_create_autocmd({ "BufLeave", "WinLeave" }, { group = group, - desc = "changeset: stop opening gitsigns' unified diff on a file once the user closes its view", + desc = "changeset: stop opening the diff on a file once the user closes its view", callback = function() local win = vim.api.nvim_get_current_win() if closed_by_user(win) then @@ -479,8 +505,9 @@ local function start() end, }) -- Fires: a buffer unloaded, a reload by `:e!` among them, which takes the views still on it or on its text as their - -- base with it: those go without the user closing them. A view the user closed went first, releasing its base. A base - -- window stays through a reload, and its buffer goes as the user closes it. + -- base with it: those go without the user closing them. A view the user closed went first, releasing its base. A split + -- view survives a reload, and its base unloads only as the user closes the base window, a hand-close this mustn't + -- forget. vim.api.nvim_create_autocmd("BufUnload", { group = group, desc = "changeset: reopen gitsigns' unified diff that went with an unloaded buffer", @@ -499,6 +526,11 @@ local function start() callback = function(args) local win = tonumber(args.match) --[[@as integer]] opened[win], covered[win], opening[win] = nil, nil, nil + for tab, w in pairs(reading) do + if w == win then + reading[tab] = nil + end + end split_diff.forget(win) end, }) @@ -559,7 +591,7 @@ function M.turn_on() if not was_on then attach_untracked() end - M.read(vim.api.nvim_get_current_win()) + mark_reading(vim.api.nvim_get_current_win()) sync_all() end diff --git a/plugin/changeset.lua b/plugin/changeset.lua index fdcd93a..151f040 100644 --- a/plugin/changeset.lua +++ b/plugin/changeset.lua @@ -492,20 +492,12 @@ vim.api.nvim_create_autocmd("User", { }) -- Fires: after a session is restored, which brings the sidebar's window back --- without its contents. Refills it rather than leaving an empty window behind. +-- without its contents, and a side-by-side diff's base window back empty. +-- Refills the one and closes the other rather than leaving them behind. vim.api.nvim_create_autocmd("SessionLoadPost", { group = group, callback = function() - -- A side-by-side diff's base window comes back empty, since gitsigns reads no `gitsigns://` buffer back; the diff - -- opens a fresh one. - for _, win in ipairs(vim.api.nvim_list_wins()) do - if - vim.api.nvim_buf_get_name(vim.api.nvim_win_get_buf(win)):find("^gitsigns://") - and #vim.api.nvim_list_wins() > 1 - then - vim.api.nvim_win_close(win, true) - end - end + require("changeset.split_diff").drop_restored() for _, win in ipairs(vim.api.nvim_list_wins()) do -- Keep in sync with NAME in lua/changeset/window.lua. if vim.api.nvim_buf_get_name(vim.api.nvim_win_get_buf(win)):find("changeset://", 1, true) then diff --git a/tests/split_diff_spec.lua b/tests/split_diff_spec.lua index 596c569..bad928d 100644 --- a/tests/split_diff_spec.lua +++ b/tests/split_diff_spec.lua @@ -125,7 +125,7 @@ describe("the side-by-side diff", function() vim.cmd.split("b.txt") local b = vim.api.nvim_get_current_win() - assert.is_not_nil(partner(b)) + present(partner(b)) assert.is_true(unified(a)) assert.equal(3, #vim.api.nvim_tabpage_list_wins(0)) end) @@ -136,11 +136,11 @@ describe("the side-by-side diff", function() local a = vim.api.nvim_get_current_win() vim.cmd.split("b.txt") local b = vim.api.nvim_get_current_win() - assert.is_not_nil(partner(b)) + present(partner(b)) vim.api.nvim_set_current_win(a) - assert.is_not_nil(partner(a)) + present(partner(a)) assert.is_true(unified(b)) assert.is_false(vim.wo[b].diff) end) @@ -153,7 +153,7 @@ describe("the side-by-side diff", function() window.preview(dir .. "/a.txt", 1, BAND) - assert.is_not_nil(partner(vim.fn.bufwinid(dir .. "/a.txt"))) + present(partner(vim.fn.bufwinid(dir .. "/a.txt"))) assert.equal(sidebar, vim.api.nvim_get_current_win()) end) @@ -187,10 +187,10 @@ describe("the side-by-side diff", function() end, 20)) unified_diff.turn_on() - vim.wait(500) - assert.equal(2, #vim.api.nvim_tabpage_list_wins(0)) - assert.equal(2, #diffing()) + assert.is_false(vim.wait(500, function() + return #vim.api.nvim_tabpage_list_wins(0) ~= 2 or #diffing() ~= 2 + end, 20)) end) it("opens nothing from the base window", function() @@ -200,16 +200,30 @@ describe("the side-by-side diff", function() local base = present(partner(win)) vim.api.nvim_set_current_win(base) - vim.wait(500) - assert.equal(2, #vim.api.nvim_tabpage_list_wins(0)) + assert.is_false(vim.wait(500, function() + return #vim.api.nvim_tabpage_list_wins(0) ~= 2 + end, 20)) assert.equal(base, partner(win)) end) it("opens unified while the option is unset", function() config.setup({}) + vim.fn.writefile({ "unstaged" }, "a.txt") unified_diff.turn_on() vim.cmd.edit("a.txt") + local ok = unified(vim.api.nvim_get_current_win()) + local bc = require("gitsigns.cache").cache[vim.api.nvim_get_current_buf()] + print( + "PROBE", + ok, + bc and bc.git_obj.revision, + bc and vim.inspect(bc.compare_text), + #vim.api.nvim_tabpage_list_wins(0), + vim.inspect(vim.tbl_map(function(w) + return vim.wo[w].diff + end, vim.api.nvim_tabpage_list_wins(0))) + ) assert.is_true(unified(vim.api.nvim_get_current_win())) assert.is_true(no_pair()) @@ -219,7 +233,7 @@ describe("the side-by-side diff", function() unified_diff.turn_on() vim.cmd.edit("a.txt") local win = vim.api.nvim_get_current_win() - assert.is_not_nil(partner(win)) + present(partner(win)) unified_diff.limit("none") @@ -245,14 +259,14 @@ describe("the side-by-side diff", function() unified_diff.turn_on() - assert.is_not_nil(partner(win)) + present(partner(win)) end) it("swaps the reading window between its pair and its unified view as the view switches, both ways", function() unified_diff.turn_on() vim.cmd.edit("a.txt") local win = vim.api.nvim_get_current_win() - assert.is_not_nil(partner(win)) + present(partner(win)) unified_diff.switch() @@ -261,10 +275,59 @@ describe("the side-by-side diff", function() unified_diff.switch() - assert.is_not_nil(partner(win)) + present(partner(win)) assert.is_nil(require("gitsigns.unified").get_view(win)) end) + it("leaves the user's own diff alone on a file it paired before the diff turned off", function() + unified_diff.turn_on() + vim.cmd.edit("a.txt") + local win = vim.api.nvim_get_current_win() + present(partner(win)) + unified_diff.limit("none") + + vim.cmd.diffthis() + vim.cmd.vnew() + local other = vim.api.nvim_get_current_win() + vim.cmd.diffthis() + vim.api.nvim_set_current_win(win) + vim.api.nvim_set_current_win(other) + + assert.is_true(vim.wo[win].diff) + assert.is_true(vim.wo[other].diff) + end) + + it("leaves the user's own diff alone on a file it paired before the view switched to unified", function() + unified_diff.turn_on() + vim.cmd.edit("a.txt") + local win = vim.api.nvim_get_current_win() + present(partner(win)) + unified_diff.switch() + assert.is_true(unified(win)) + + vim.cmd("vertical diffsplit b.txt") + local other = vim.api.nvim_get_current_win() + vim.api.nvim_set_current_win(win) + local kept = not vim.wait(500, function() + return not (vim.wo[win].diff and vim.wo[other].diff) + end, 20) + unified_diff.switch() + + assert.is_true(kept) + end) + + it("opens unified while gitsigns compares against the index", function() + vim.cmd.edit("a.txt") + attached() + -- As before changeset.review moves a newly read buffer onto the branch's base. + present(require("gitsigns.cache").cache[vim.api.nvim_get_current_buf()]).git_obj.revision = nil + + unified_diff.turn_on() + + assert.is_true(unified(vim.api.nvim_get_current_win())) + assert.is_true(no_pair()) + end) + it("closes the pair on a file marked viewed", function() vim.cmd.edit("a.txt") local win = vim.api.nvim_get_current_win() @@ -273,11 +336,10 @@ describe("the side-by-side diff", function() return window.buf() ~= nil and Sidebar.text():find("a.txt", 1, true) ~= nil end, 25)) vim.api.nvim_set_current_win(win) - assert.is_not_nil(partner(win)) + present(partner(win)) Sidebar.cursor_to("a.txt") vim.api.nvim_feedkeys("x", "xt", false) - unified_diff.refresh_viewed() assert.is_true(no_pair()) end) @@ -289,7 +351,7 @@ describe("the side-by-side diff", function() local function sidebar_beside(name) unified_diff.turn_on() vim.cmd.edit(name) - assert.is_not_nil(partner(vim.api.nvim_get_current_win())) + present(partner(vim.api.nvim_get_current_win())) local sidebar = window.open(vim.api.nvim_create_buf(false, true)) vim.api.nvim_set_current_win(sidebar) return sidebar @@ -317,7 +379,7 @@ describe("the side-by-side diff", function() sidebar_beside("a.txt") window.preview(dir .. "/a.txt", 1, BAND) local win = vim.fn.bufwinid(dir .. "/a.txt") - assert.is_not_nil(partner(win)) + present(partner(win)) window.preview(dir .. "/b.txt", 1, BAND) @@ -347,7 +409,7 @@ describe("the side-by-side diff", function() sidebar_beside("a.txt") window.preview(dir .. "/a.txt", 1, BAND) local win = vim.fn.bufwinid(dir .. "/a.txt") - assert.is_not_nil(partner(win)) + present(partner(win)) window.preview(dir .. "/b.txt", 1, BAND) changeset.close() @@ -360,9 +422,9 @@ describe("the side-by-side diff", function() sidebar_beside("a.txt") window.preview(dir .. "/a.txt", 1, BAND) local win = vim.fn.bufwinid(dir .. "/a.txt") - assert.is_not_nil(partner(win)) + present(partner(win)) window.preview(dir .. "/b.txt", 1, BAND) - assert.is_not_nil(partner(win)) + present(partner(win)) changeset.close() vim.api.nvim_win_call(win, function() @@ -380,9 +442,10 @@ describe("the side-by-side diff", function() local base = present(partner(win)) vim.cmd("edit!") - vim.wait(500) - assert.is_true(vim.api.nvim_win_is_valid(base)) + assert.is_false(vim.wait(500, function() + return not vim.api.nvim_win_is_valid(base) + end, 20)) assert.equal(base, partner(win)) unified_diff.limit("none") assert.same({ win }, vim.api.nvim_tabpage_list_wins(0)) @@ -408,13 +471,42 @@ describe("the side-by-side diff", function() ) end) + it("closes the base window with a file window alone with it in a second tabpage", function() + unified_diff.turn_on() + vim.cmd.tabnew("a.txt") + local base = present(partner(vim.api.nvim_get_current_win())) + + vim.cmd.quit() + + assert.is_true(vim.wait(500, function() + return not vim.api.nvim_win_is_valid(base) + end, 20)) + end) + + it("closes a file window paired alone in its tabpage while a float is open", function() + unified_diff.turn_on() + vim.cmd.edit("a.txt") + local win = vim.api.nvim_get_current_win() + present(partner(win)) + local float = vim.api.nvim_open_win( + vim.api.nvim_create_buf(false, true), + false, + { relative = "editor", row = 0, col = 0, width = 5, height = 1 } + ) + + vim.cmd.quit() + + assert.is_false(vim.api.nvim_win_is_valid(win)) + vim.api.nvim_win_close(float, true) + end) + it("leaves one empty window as a lone sidebar closes after a preview", function() local sidebar = sidebar_beside("a.txt") vim.api.nvim_win_call(sidebar, function() vim.cmd("only") end) window.preview(dir .. "/a.txt", 1, BAND) - assert.is_not_nil(partner(vim.fn.bufwinid(dir .. "/a.txt"))) + present(partner(vim.fn.bufwinid(dir .. "/a.txt"))) changeset.close() @@ -431,12 +523,14 @@ describe("the side-by-side diff", function() return window.buf() ~= nil and Sidebar.text():find("a.txt", 1, true) ~= nil end, 25)) window.preview(dir .. "/a.txt", 1, BAND) - assert.is_not_nil(partner(vim.fn.bufwinid(dir .. "/a.txt"))) + present(partner(vim.fn.bufwinid(dir .. "/a.txt"))) vim.cmd("mksession! " .. vim.fn.fnameescape(session)) vim.cmd("silent! source " .. vim.fn.fnameescape(session)) vim.fn.delete(session) + assert.are_not.equal("diff", vim.wo[vim.fn.bufwinid(dir .. "/a.txt")].foldmethod) + assert.is_false(vim.wait(1000, function() return vim.iter(vim.api.nvim_list_wins()):any(function(w) local buf = vim.api.nvim_win_get_buf(w) diff --git a/tests/unified_diff_toggle_spec.lua b/tests/unified_diff_toggle_spec.lua index c9c1e2a..52745c4 100644 --- a/tests/unified_diff_toggle_spec.lua +++ b/tests/unified_diff_toggle_spec.lua @@ -13,6 +13,14 @@ local present = require("support.present") describe(":Changeset diff and :Changeset diff split", function() local dir, previous = Fixture.enter_tempdir() Fixture.feature_two_files(dir) + -- The side-by-side view needs a commit to compare against; gitsigns otherwise compares against the index. + local based = false + require("gitsigns").change_base("trunk", true, function() + based = true + end) + assert.is_true(vim.wait(5000, function() + return based + end, 20)) local nowhere = vim.fn.tempname() vim.fn.mkdir(nowhere, "p") local notes, restore = Notify.capture() @@ -82,6 +90,14 @@ describe(":Changeset diff and :Changeset diff split", function() assert.same({ "Changeset: side-by-side diff off" }, Notify.messages({ notes[6] })) end) + it("turns on side by side from off when the view is already side by side", function() + changeset.diff_split() + + assert.same({ "Changeset: side-by-side diff on" }, Notify.messages({ notes[7] })) + assert.is_true(paired(vim.api.nvim_get_current_win())) + changeset.diff() + end) + it("reaches no further than the entered files after the sidebar closes under keep = entered", function() config.setup({ unified_diff = { keep = "entered" } }) local file_win = vim.api.nvim_get_current_win() From 7a11dc3313ee195c41f7ab5fc1d098585084ab62 Mon Sep 17 00:00:00 2001 From: Julian Torres Date: Sun, 11 Oct 2026 20:22:19 -0400 Subject: [PATCH 6/7] style: tidy the side-by-side diff's comments --- lua/changeset/init.lua | 5 +++-- lua/changeset/unified_diff.lua | 1 - 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lua/changeset/init.lua b/lua/changeset/init.lua index b571c8a..e8abeb1 100644 --- a/lua/changeset/init.lua +++ b/lua/changeset/init.lua @@ -862,8 +862,9 @@ function M.diff() vim.notify(("Changeset: %s on"):format(view_name())) end ----Switch the diff between unified and side by side while it is on, or turn it on side by side when it is off, building the current buffer's ----repository's tree when there is none, and say which view is on; warn when gitsigns can't show it. +---Switch the diff between unified and side by side while it is on, or turn it on side by side when it is off, +---building the current buffer's repository's tree when there is none, and say which view is on; warn when gitsigns +---can't show it. function M.diff_split() if not (diff_available() and (build.current() or built())) then return diff --git a/lua/changeset/unified_diff.lua b/lua/changeset/unified_diff.lua index a8a612e..66dcbdc 100644 --- a/lua/changeset/unified_diff.lua +++ b/lua/changeset/unified_diff.lua @@ -311,7 +311,6 @@ local function open(view) return end if vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_buf(win) == buf and reaches(path) then - -- Asked again: the reading window can have moved while git read the base. local drawn = wanted(win, git_obj, text) -- Before either draw: gitsigns' `show` registers the view at once but then waits on its diff. opened[win] = { buf = buf, path = path, base = base, text = text, view = drawn } From 3a8ecdc467cc952938722e7ab4a1b0f9249ae219 Mon Sep 17 00:00:00 2001 From: Julian Torres Date: Sun, 11 Oct 2026 20:23:06 -0400 Subject: [PATCH 7/7] refactor(unified_diff): name the side-by-side diff's locals plainly --- lua/changeset/unified_diff.lua | 16 ++++++++-------- tests/unified_diff_toggle_spec.lua | 6 +++--- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/lua/changeset/unified_diff.lua b/lua/changeset/unified_diff.lua index 66dcbdc..d9ad90a 100644 --- a/lua/changeset/unified_diff.lua +++ b/lua/changeset/unified_diff.lua @@ -274,9 +274,9 @@ end ---@param win integer local function settle(win) local mine = opened[win] - local lives = mine and mine.view == "split" + local keeps_base = mine and mine.view == "split" or not mine and vim.api.nvim_win_is_valid(win) and allowed(vim.api.nvim_win_get_buf(win)) - if not lives then + if not keeps_base then split_diff.close(win) end end @@ -344,18 +344,18 @@ function M.sync(win) end split_diff.scrub(win) local buf = vim.api.nvim_win_get_buf(win) - local left = opened[win] + local previous = opened[win] local path = tree_file(vim.api.nvim_buf_get_name(buf)) local reached = path ~= nil and reaches(path) - if left and left.buf ~= buf then + if previous and previous.buf ~= buf then opened[win] = nil -- A preview swaps the buffer without BufWinLeave, so gitsigns still holds our view, and would hand it back once -- the buffer returns. One showing now is on this buffer, so the user's own. if not showing(win) then - if left.view == "split" then + if previous.view == "split" then settle(win) else - close(win, left) + close(win, previous) end end end @@ -525,8 +525,8 @@ local function start() callback = function(args) local win = tonumber(args.match) --[[@as integer]] opened[win], covered[win], opening[win] = nil, nil, nil - for tab, w in pairs(reading) do - if w == win then + for tab, reader in pairs(reading) do + if reader == win then reading[tab] = nil end end diff --git a/tests/unified_diff_toggle_spec.lua b/tests/unified_diff_toggle_spec.lua index 52745c4..26ee657 100644 --- a/tests/unified_diff_toggle_spec.lua +++ b/tests/unified_diff_toggle_spec.lua @@ -14,12 +14,12 @@ describe(":Changeset diff and :Changeset diff split", function() local dir, previous = Fixture.enter_tempdir() Fixture.feature_two_files(dir) -- The side-by-side view needs a commit to compare against; gitsigns otherwise compares against the index. - local based = false + local base_changed = false require("gitsigns").change_base("trunk", true, function() - based = true + base_changed = true end) assert.is_true(vim.wait(5000, function() - return based + return base_changed end, 20)) local nowhere = vim.fn.tempname() vim.fn.mkdir(nowhere, "p")