Git diffview toggle implementation

/home/jm/.config/nvim · 13 messages · 39 parts · · 4,356 tokens
Created Feb 12, 07:12:07 PM · Updated Feb 12, 07:22:16 PM · 66 syncs
userFeb 12, 07:12:07 PM

Can you update /home/jm/.config/nvim/lua/plugins/git.lua , to add a way to toggle the 'Show Diff Against HEAD' off, as that diffthis command opens 2 windwos to do a diff, and I want to quickly be able to close them. You may have to handle unsaved buffers or whatever other state that diffview mode has opened.

assistantFeb 12, 07:12:07 PM
claude-opus-4-6 · anthropic

Let me first read the current git.lua file to understand the existing configuration.

readcompleted
lua/plugins/git.lua
Output
<file>
00001| return {
00002|   {
00003|     'lewis6991/gitsigns.nvim',
00004|     event = { 'BufReadPre', 'BufNewFile' },
00005|     opts = {
00006|       signs_staged_enable = true, -- show staged vs unstaged distinctly
00007|       current_line_blame = false,
00008|       attach_to_untracked = false,
00009|       word_diff = false,
00010|       update_debounce = 100,
00011|       signcolumn = true,
00012|       numhl = false,
00013|       linehl = false,
00014|       preview_config = {
00015|         border = 'single',
00016|         style = 'minimal',
00017|         relative = 'cursor',
00018|         row = 0,
00019|         col = 1,
00020|       },
00021|       on_attach = function(bufnr)
00022|         local gs = package.loaded.gitsigns
00023| 
00024|         local function map(mode, l, r, opts)
00025|           opts = opts or {}
00026|           opts.buffer = bufnr
00027|           vim.keymap.set(mode, l, r, opts)
00028|         end
00029| 
00030|         -- Navigation
00031|         map('n', ']h', function()
00032|           if vim.wo.diff then
00033|             return ']c'
00034|           end
00035|           vim.schedule(function()
00036|             gs.nav_hunk('next')
00037|           end)
00038|           return '<Ignore>'
00039|         end, { expr = true, desc = 'Next Hunk' })
00040| 
00041|         map('n', '[h', function()
00042|           if vim.wo.diff then
00043|             return '[c'
00044|           end
00045|           vim.schedule(function()
00046|             gs.nav_hunk('prev')
00047|           end)
00048|           return '<Ignore>'
00049|         end, { expr = true, desc = 'Prev Hunk' })
00050| 
00051|         -- Actions
00052|         map('n', '<leader>gs', gs.stage_hunk, { desc = 'Stage Hunk' })
00053|         map('n', '<leader>gu', gs.undo_stage_hunk, { desc = 'Undo Stage Hunk' })
00054|         map('n', '<leader>gr', gs.reset_hunk, { desc = 'Reset Hunk' })
00055|         map('v', '<leader>gs', function()
00056|           gs.stage_...
2in / 90out
assistantFeb 12, 07:12:10 PM
claude-opus-4-6 · anthropic

Now let me understand what gs.diffthis does. When you press <leader>gD, it calls gs.diffthis('~') which opens a diff split — Neovim's built-in diffthis mode with two windows showing the current buffer vs the HEAD version. Let me check if there's any existing config/git module referenced:

readcompleted
lua/config/git.lua
Output
<file>
00001| -- lua/util/git_base_toggle_core.lua
00002| local M = {}
00003| 
00004| -- --- Optional Result helpers -----------------------------------------------
00005| local has_res, Res = pcall(require, "nvlib") -- rename to your module name if different
00006| 
00007| local Ok, Err
00008| if has_res and Res.Ok and Res.Err then
00009|   Ok, Err = Res.Ok, Res.Err
00010| else
00011|   Ok = function(v)
00012|     return { ok = true, value = v }
00013|   end
00014|   Err = function(e)
00015|     return { ok = false, error = e }
00016|   end
00017| end
00018| 
00019| -- --- State ------------------------------------------------------------------
00020| local ns = vim.api.nvim_create_namespace("git-base-toggle-badge")
00021| local state = {
00022|   per_buf = {}, -- [bufnr] = { base = "<ref-or-nil>", badge_id = extmark_id }
00023| }
00024| 
00025| local function set_badge(bufnr, text)
00026|   bufnr = bufnr or vim.api.nvim_get_current_buf()
00027|   -- clear old
00028|   local old = state.per_buf[bufnr]
00029|   if old and old.badge_id then
00030|     pcall(vim.api.nvim_buf_del_extmark, bufnr, ns, old.badge_id)
00031|     old.badge_id = nil
00032|   end
00033|   if not text or text == "" then
00034|     return
00035|   end
00036|   local id = vim.api.nvim_buf_set_extmark(bufnr, ns, 0, 0, {
00037|     virt_text = { { " base: ", "Comment" }, { text, "Title" } },
00038|     virt_text_pos = "right_align", -- keep the left edge clean
00039|     hl_mode = "combine",
00040|     priority = 120, -- above indent guides etc.
00041|   })
00042|   state.per_buf[bufnr] = state.per_buf[bufnr] or {}
00043|   state.per_buf[bufnr].badge_id = id
00044| end
00045| 
00046| local function remember_base(bufnr, base)
00047|   bufnr = bufnr or vim.api.nvim_get_current_buf()
00048|   state.per_buf[bufnr] = state.per_buf[bufnr] or {}
00049|   state.per_buf[bufnr].base = base
00050| end
00051| 
00052| local function current_base(bufnr)
00053|   bufnr = bufnr or vim.api.nvim_get_current_buf()
0...
1in / 155out
assistantFeb 12, 07:12:15 PM
claude-opus-4-6 · anthropic

Good. Now I understand the full picture. Here's my analysis:

The Problem

<leader>gD calls gs.diffthis('~') which uses Neovim's built-in diffthis mechanism. This:

  1. Opens a second window/split with the HEAD version of the file
  2. Sets both windows into diff mode (diffthis)
  3. There's no quick way to close this diff and get back to the normal single-window view

The challenge is that the scratch buffer opened by gitsigns for the diff comparison needs to be cleaned up, diff mode needs to be turned off on the original buffer, and the split needs to be closed.

Plan

I'll modify the <leader>gD keybinding (and similarly <leader>gd) to be a toggle. When pressed:

  • If not in diff mode: open the diff as it does now (gs.diffthis('~') or gs.diffthis())
  • If already in diff mode: close the diff by:
    1. Running diffoff! to turn off diff mode in all windows in the current tab
    2. Finding and closing the scratch/comparison buffer that gitsigns created (it's typically a nofile/nomodifiable buffer)
    3. Restoring the single-window layout
questioncompleted
Asked 1 question
Output
User has answered your questions: "Should I make both `<leader>gd` (diff against index) and `<leader>gD` (diff against HEAD) toggleable, or just `<leader>gD`?"="Both (Recommended)". You can now continue with the user's answers in mind.
1in / 492out
userFeb 12, 07:12:25 PM

Use an agent to make the change

assistantFeb 12, 07:12:37 PM
claude-opus-4-6 · anthropic

Here's my plan for making both <leader>gd and <leader>gD toggle their respective diffs off:

Plan

What happens today

When gs.diffthis() or gs.diffthis('~') is called, gitsigns:

  1. Creates a new vertical split
  2. Loads the index/HEAD version of the file into a scratch buffer (buftype=nofile, nomodifiable)
  3. Calls :diffthis on both windows

There's no built-in "close diff" in gitsigns — you have to manually close the split and run :diffoff.

Approach

Add a helper function toggle_diff inside the on_attach callback that:

  1. Checks if the current window is in diff mode (vim.wo.diff)
  2. If in diff mode (close it):
    • Iterate all windows in the current tab
    • For each window, turn off diff mode with vim.api.nvim_win_call(win, function() vim.cmd('diffoff') end)
    • Find the gitsigns scratch buffer — identified by buftype == 'nofile' and the buffer being nomodifiable — and close its window (:close on the window, which also handles cleanup)
    • If the scratch buffer has no other windows showing it, wipe it with vim.api.nvim_buf_delete(buf, { force = true })
  3. If not in diff mode (open it): call gs.diffthis(ref) as before

Keymap changes

Replace the two static mappings:

-- Before
map('n', '<leader>gd', gs.diffthis, { desc = 'Show Diff Against Index' })
map('n', '<leader>gD', function() gs.diffthis('~') end, { desc = 'Show Diff Against HEAD' })

-- After
map('n', '<leader>gd', function() toggle_diff() end, { desc = 'Toggle Diff Against Index' })
map('n', '<leader>gD', function() toggle_diff('~') end, { desc = 'Toggle Diff Against HEAD' })

Edge cases handled

  • Multiple windows in diff mode: iterates all tab windows, so it correctly handles the 2-window split
  • Scratch buffer cleanup: force-wipes the nofile buffer so there's no stale buffer left behind
  • No unsaved buffer prompts: the gitsigns scratch buffer is nofile+nomodifiable, so :close and buf_delete({force=true}) won't prompt about unsaved changes
  • Original buffer stays intact: only the scratch (comparison) window/buffer is closed; the original file window stays open with diff mode turned off

Files modified

  • /home/jm/.config/nvim/lua/plugins/git.lua — only file changed; a toggle_diff function added inside on_attach, two keymaps updated

Shall I proceed with the implementation?

2in / 721out
userFeb 12, 07:13:11 PM

Yes have the @coder-agent handle that

Use the above message and context to generate a prompt and call the task tool with subagent: coder-agent

assistantFeb 12, 07:13:11 PM
claude-opus-4-6 · anthropic
taskcompleted
Add toggle_diff to git.lua
Output
task_id: ses_3acba376cffeMMVj3loXRIawkQ (for resuming to continue this task if needed)

<task_result>
   The file has been successfully modified. Here's a summary of the changes:

1. **Added `toggle_diff` function** (lines 30-57): A local helper function inside `on_attach` that:
   - Checks if the current window is in diff mode
   - If in diff mode: turns off diff in all windows and closes the gitsigns scratch buffer
   - If not in diff mode: opens the diff using `gs.diffthis(ref)`

2. **Updated `<leader>gd` keymap** (lines 92-94): Now calls `toggle_diff()` with description "Toggle Diff Against Index"

3. **Updated `<leader>gD` keymap** (lines 95-97): Now calls `toggle_diff('~')` with description "Toggle Diff Against HEAD"

The gitsigns diff keymaps are now toggleable — pressing them again will cleanly close the diff view and return to the normal single-window view.
</task_result>
2in / 1,049out
assistantFeb 12, 07:16:21 PM
claude-opus-4-6 · anthropic

Let me verify the changes look correct:

readcompleted
lua/plugins/git.lua
Output
<file>
00021|       on_attach = function(bufnr)
00022|         local gs = package.loaded.gitsigns
00023| 
00024|         local function map(mode, l, r, opts)
00025|           opts = opts or {}
00026|           opts.buffer = bufnr
00027|           vim.keymap.set(mode, l, r, opts)
00028|         end
00029| 
00030|         local function toggle_diff(ref)
00031|           -- If current window is already in diff mode, close the diff
00032|           if vim.wo.diff then
00033|             local cur_buf = vim.api.nvim_get_current_buf()
00034|             local tabpage = vim.api.nvim_get_current_tabpage()
00035|             local wins = vim.api.nvim_tabpage_list_wins(tabpage)
00036| 
00037|             for _, win in ipairs(wins) do
00038|               local buf = vim.api.nvim_win_get_buf(win)
00039|               -- Turn off diff mode in every window of this tab
00040|               vim.api.nvim_win_call(win, function()
00041|                 vim.cmd('diffoff')
00042|               end)
00043|               -- Close the gitsigns scratch buffer window (buftype=nofile, not the original buffer)
00044|               if buf ~= cur_buf and vim.bo[buf].buftype == 'nofile' then
00045|                 vim.api.nvim_win_close(win, true)
00046|                 -- Wipe the scratch buffer if it has no remaining windows
00047|                 if vim.api.nvim_buf_is_valid(buf) then
00048|                   vim.api.nvim_buf_delete(buf, { force = true })
00049|                 end
00050|               end
00051|             end
00052|           else
00053|             -- Not in diff mode, open the diff
00054|             local gs = package.loaded.gitsigns
00055|             gs.diffthis(ref)
00056|           end
00057|         end
00058| 
00059|         -- Navigation
00060|         map('n', ']h', function()
00061|           if vim.wo.diff then
00062|             return ']c'
00063|           end
00064|           vim.schedule(function()
00065|             gs.nav_hunk('next')
00066|           e...
1in / 115out
assistantFeb 12, 07:16:25 PM
claude-opus-4-6 · anthropic

The changes look correct. Here's a summary of what was done:

File modified: lua/plugins/git.lua

Changes:

  1. Added toggle_diff(ref) helper (lines 30-57) inside on_attach, right after the map helper. When called:

    • If already in diff mode (vim.wo.diff is true): iterates all windows in the current tab, runs diffoff on each, then finds and closes the gitsigns scratch buffer (identified by buftype == 'nofile' and being a different buffer than the current one), and wipes it with force = true to avoid any unsaved-buffer prompts.
    • If not in diff mode: opens the diff via gs.diffthis(ref) as before.
  2. Updated keymaps:

    • <leader>gdtoggle_diff() — "Toggle Diff Against Index"
    • <leader>gDtoggle_diff('~') — "Toggle Diff Against HEAD"

Now pressing <leader>gd or <leader>gD a second time will cleanly close the diff split and restore your single-window layout.

1in / 288out
userFeb 12, 07:18:45 PM

that doesn t seem to work to go back to the state before starting the diff view

assistantFeb 12, 07:18:45 PM
claude-opus-4-6 · anthropic

Let me investigate what's actually happening when gs.diffthis is called, so I can understand the exact state that needs to be restored.

taskcompleted
Research gitsigns diffthis behavior
Output
task_id: ses_3acb52870ffeZbA23IgCz5VbzM (for resuming to continue this task if needed)

<task_result>
I now have all the information needed. Here is a comprehensive analysis:

---

## Complete Analysis of `gitsigns.diffthis()` Internals

### Source File Locations (at commit `1ce96a464fdbc24208e24c117e2021794259005d`)

The plugin is not installed locally on disk (lazy.nvim hasn't materialized it yet). All source was fetched from GitHub at the pinned commit in `lazy-lock.json`. The relevant files are:

| File | Role |
|------|------|
| `lua/gitsigns/actions/diffthis.lua` | Core `diffthis` implementation |
| `lua/gitsigns/actions.lua` | Public API wrapper, calls into `actions/diffthis.lua` |
| `lua/gitsigns.lua` | Top-level module, delegates to `actions.lua` via `__index` |
| `lua/gitsigns/cache.lua` | Cache entry with `get_rev_bufname()` |
| `lua/gitsigns/util.lua` | Utilities including `norm_base()`, `bufexists()` |
| `lua/gitsigns/status.lua` | Buffer variable management (`vim.b.*`) |
| `lua/gitsigns/config.lua` | Config schema, `DiffthisOpts` type definition |

Your local config file: `/home/jm/.config/nvim/lua/plugins/git.lua`

---

### Full Implementation of `diffthis` (from `lua/gitsigns/actions/diffthis.lua`)

```lua
local async = require('gitsigns.async')
local config = require('gitsigns.config').config
local manager = require('gitsigns.manager')
local message = require('gitsigns.message')
local util = require('gitsigns.util')
local Status = require('gitsigns.status')

local cache = require('gitsigns.cache').cache
local log = require('gitsigns.debug.log')
local throttle_async = require('gitsigns.debounce').throttle_async

local api = vim.api

local M = {}

--- @async
--- @param bufnr integer
--- @param dbufnr integer
--- @param base string?
--- @param relpath string?
local function bufread(bufnr, dbufnr, base, relpath)
  local bcache = assert(cache[bufnr])
  base = util.norm_base(base)
  local text
  if base == bcache.git_obj.revision then
    text = assert(bc...
2in / 565out
assistantFeb 12, 07:21:58 PM
claude-opus-4-6 · anthropic

This is very revealing. The current toggle_diff has a bug — it checks vim.bo[buf].buftype == 'nofile', but gitsigns diff buffers have buftype = 'acwrite' (index) or 'nowrite' (HEAD), never 'nofile'. So the scratch buffer identification and cleanup never fires.

Additionally, Neovim's :diffsplit sets several window options (scrollbind, cursorbind, foldmethod='diff', foldcolumn, diff) on the original window that need to be properly restored.

Revised Plan

Here's what the corrected toggle_diff function needs to do:

Closing the diff (when vim.wo.diff is true):

  1. Save the current window as the one to keep.
  2. Iterate all windows in the tab and for each:
    • If it's a gitsigns diff buffer (identified by buffer name matching ^gitsigns://), close the window with vim.api.nvim_win_close(win, true). Since the buffer has bufhidden = 'wipe', closing the window automatically wipes the buffer — no manual buf_delete needed.
  3. Run diffoff on the original window to restore diff-related window options (diff, scrollbind, cursorbind, foldmethod). Neovim's :diffoff restores these to their pre-diff values.

The key fix: use vim.api.nvim_buf_get_name(buf):match('^gitsigns://') instead of vim.bo[buf].buftype == 'nofile'.

Opening the diff (when vim.wo.diff is false):

Same as before — call gs.diffthis(ref).

Updated code

local function toggle_diff(ref)
  if vim.wo.diff then
    local cur_win = vim.api.nvim_get_current_win()
    local tabpage = vim.api.nvim_get_current_tabpage()
    local wins = vim.api.nvim_tabpage_list_wins(tabpage)

    -- Close gitsigns diff buffer windows
    for _, win in ipairs(wins) do
      if win ~= cur_win then
        local buf = vim.api.nvim_win_get_buf(win)
        local bufname = vim.api.nvim_buf_get_name(buf)
        if bufname:match('^gitsigns://') then
          vim.api.nvim_win_close(win, true) -- bufhidden=wipe handles buffer cleanup
        end
      end
    end

    -- Turn off diff mode on the original window (restores scrollbind, cursorbind, foldmethod, etc.)
    vim.cmd('diffoff')
  else
    package.loaded.gitsigns.diffthis(ref)
  end
end

Why this is better

  • Correct identification: matches gitsigns:// buffer name instead of wrong buftype check
  • No manual buf_delete: gitsigns sets bufhidden = 'wipe', so closing the window auto-wipes the buffer
  • Proper option restore: diffoff on the remaining window restores scrollbind, cursorbind, foldmethod, foldcolumn to their pre-diff state
  • Simpler: no need to iterate all windows for diffoff — just close the diff windows first, then diffoff on the current (original) window

Shall I proceed with the implementation?

1in / 868out