From 8900b9b7efe4d3623dcf523020e4406de24eab62 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 14 Aug 2026 14:21:49 -0500 Subject: [PATCH] feat(commands): diff the current buffer against its last save --- README.md | 2 +- doc/diffs.nvim.txt | 23 +++++--- lua/diffs/commands.lua | 86 ++++++++++++++++++----------- lua/diffs/diffargs.lua | 5 +- lua/diffs/generated/source.lua | 8 ++- spec/diff_files_spec.lua | 99 +++++++++++++++++++++++++++++++++- spec/diffargs_spec.lua | 18 ++++--- 7 files changed, 188 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 4c755ec7..da793023 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ highlighting driven by treesitter. - Word and chatacer-level diff highlighting - `:Diff` for [pierre-style](https://diffs.com) unified, stacked, or split diffs against any revision - `:Diff review` full-repo review diff with qflist/loclist navigation -- `:Diff files {a} {b}` to diff two arbitrary files +- `:Diff files` to diff two arbitrary files, or the buffer against its last save - Inline and 3-way merge conflict detection, highlighting, and resolution - Email quoting/patch syntax support (`> diff ...`) - Vim syntax fallback diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index f4bcf991..dbf6c061 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -476,16 +476,23 @@ Diff objects: ~ *diffs.nvim-diff-objects* Diff files: ~ *diffs.nvim-diff-files* - `:Diff files {left} [{right}]` opens a read-only generated diff of two + `:Diff files [{left}] [{right}]` opens a read-only generated diff of two arbitrary files. Neither file has to be tracked by git, and the command works outside a repository. It is the file-path counterpart to a plain |:Diff| object: the first path names the old (left) side, and the second - names the new (right) side. With a single path the new side defaults to the - current buffer's file, mirroring |:diffsplit| and `:Diff {rev}`. + names the new (right) side. + + A named path is always read from disk. An omitted side falls back to the + current buffer: the new side is the buffer's live content, and the old + side is that buffer's file on disk. Bare `:Diff files` therefore compares + the buffer to its last save, like `:Diff {rev}`, which also diffs against + unsaved buffer content. A buffer-backed side is shown as `buffer:{name}`, + and falls back to disk if that buffer is gone when the view reloads. Examples: >vim + :Diff files " last save (old) vs buffer (new) :Diff files old.lua new.lua " old.lua (old) vs new.lua (new) - :Diff files other.lua " other.lua (old) vs current file (new) + :Diff files other.lua " other.lua (old) vs buffer (new) :Diff files ++layout=stacked a.txt b.txt < The `++layout=unified` and `++layout=stacked` layouts and the |:vertical| @@ -495,10 +502,12 @@ Diff files: ~ *diffs.nvim-diff-files* (`do`/`dp`) are not available. Errors: ~ - - With no path, or more than two, the command reports the expected count. + - More than two paths reports the expected count. + - An omitted side needs a named current buffer. - A path that is a directory, missing, unreadable, or binary is refused and - named; directories are intentionally unsupported. - - Two identical files report no changes without opening a buffer. + named; directories are intentionally unsupported. A buffer-backed side + does not have to exist on disk. + - Two identical sides report no changes without opening a buffer. Stacked generated layout: ~ *diffs.nvim-stacked-layout* diff --git a/lua/diffs/commands.lua b/lua/diffs/commands.lua index 7e46b425..7e8f361f 100644 --- a/lua/diffs/commands.lua +++ b/lua/diffs/commands.lua @@ -588,6 +588,27 @@ local function render_section_source(repo_root, section) return replace_combined_diffs(diff_lines, repo_root) end +---@param name string +---@param buffered boolean +---@return string +local function files_side_display(name, buffered) + return buffered and ('buffer:' .. name) or name +end + +---@param source diffs.GeneratedBufferSource +---@return diffs.ContentLines?, boolean +local function files_source_new_lines(source) + local bufnr = source.right_buf + if + bufnr + and vim.api.nvim_buf_is_loaded(bufnr) + and vim.api.nvim_buf_get_name(bufnr) == source.right_path + then + return content.from_buffer(bufnr), true + end + return git.get_working_content(source.right_path), false +end + ---@param source diffs.GeneratedBufferSource ---@return string[]?, diffs.DiffSpec?, string?, table? local function render_source(source) @@ -606,13 +627,13 @@ local function render_source(source) if not old_lines then return nil, nil, source.left_name .. ': file not readable', nil end - local new_lines = git.get_working_content(source.right_path) + local new_lines, buffered = files_source_new_lines(source) if not new_lines then return nil, nil, source.right_name .. ': file not readable', nil end return render.unified_lines(old_lines, new_lines, source.left_name, source.right_name), nil, - 'files:' .. source.left_name .. ' -> ' .. source.right_name, + 'files:' .. source.left_name .. ' -> ' .. files_side_display(source.right_name, buffered), nil end @@ -998,50 +1019,51 @@ end ---@field layout "unified"|"stacked"|"split" ---@field vertical? boolean ----@param left string +---@param path string +---@return string, string +local function files_side_names(path) + local abs = vim.fn.fnamemodify(vim.fn.expand(path), ':p') + local name = vim.fn.fnamemodify(abs, ':~:.') + return abs, name ~= '' and name or abs +end + +---@param left string? ---@param right string? ---@param opts? diffs.DiffFilesViewOpts ---@return integer? function M.diff_files(left, right, opts) opts = opts or {} - local right_input = right - if not right_input then - local current = vim.api.nvim_buf_get_name(vim.api.nvim_get_current_buf()) - if current == '' then - notify('cannot diff unnamed buffer', vim.log.levels.ERROR) - return nil - end - right_input = current + local current_buf = vim.api.nvim_get_current_buf() + local current_name = vim.api.nvim_buf_get_name(current_buf) + local right_buf = right == nil and current_buf or nil + if (left == nil or right == nil) and current_name == '' then + notify('cannot diff unnamed buffer', vim.log.levels.ERROR) + return nil end - local left_abs = vim.fn.fnamemodify(vim.fn.expand(left), ':p') - local right_abs = vim.fn.fnamemodify(vim.fn.expand(right_input), ':p') - local left_name = vim.fn.fnamemodify(left_abs, ':~:.') - if left_name == '' then - left_name = left_abs - end - local right_name = vim.fn.fnamemodify(right_abs, ':~:.') - if right_name == '' then - right_name = right_abs - end + local left_abs, left_name = files_side_names(left or current_name) + local right_abs, right_name = files_side_names(right or current_name) + local right_display = files_side_display(right_name, right_buf ~= nil) for _, side in ipairs({ - { abs = left_abs, name = left_name }, - { abs = right_abs, name = right_name }, + { abs = left_abs, name = left_name, buffered = false }, + { abs = right_abs, name = right_name, buffered = right_buf ~= nil }, }) do if vim.fn.isdirectory(side.abs) == 1 then notify(side.name .. ' is a directory; :Diff files compares two files', vim.log.levels.ERROR) return nil end - if vim.fn.filereadable(side.abs) ~= 1 then + if not side.buffered and vim.fn.filereadable(side.abs) ~= 1 then notify(side.name .. ': file not readable', vim.log.levels.ERROR) return nil end end local old_lines = git.get_working_content(left_abs) or {} - local new_lines = git.get_working_content(right_abs) or {} + local new_lines = right_buf and content.from_buffer(right_buf) + or git.get_working_content(right_abs) + or {} if render.has_binary_lines(old_lines) or render.has_binary_lines(new_lines) then notify('diff does not support binary files', vim.log.levels.ERROR) return nil @@ -1049,30 +1071,32 @@ function M.diff_files(left, right, opts) local diff_lines = render.unified_lines(old_lines, new_lines, left_name, right_name) if #diff_lines == 0 then - notify('no changes between ' .. left_name .. ' and ' .. right_name, vim.log.levels.INFO) + notify('no changes between ' .. left_name .. ' and ' .. right_display, vim.log.levels.INFO) return nil end local diff_buf = create_generated_diff_buffer({ - name = 'diffs://files:' .. left_name .. ' -> ' .. right_name, + name = 'diffs://files:' .. left_name .. ' -> ' .. right_display, lines = diff_lines, - source = generated.files_source(left_abs, right_abs, left_name, right_name), + source = generated.files_source(left_abs, right_abs, left_name, right_name, right_buf), rail_style = rail_style_for_layout(opts.layout), }) show_generated_diff_buffer(diff_buf, opts.vertical) lists.set_for_unified_buffer(diff_buf, diff_lines, { - title = 'diff: files ' .. left_name .. ' -> ' .. right_name, + title = 'diff: files ' .. left_name .. ' -> ' .. right_display, }) attach_generated_diff_buffer(diff_buf) - if difftastic.available() then + if right_buf then + apply_difft_unified(diff_buf, diff_lines, nil, old_lines, new_lines, right_name) + elseif difftastic.available() then local lhs, rhs = difftastic.span_maps_for_paths(left_abs, right_abs) if lhs and rhs then paint_difft_unified(diff_buf, diff_lines, nil, lhs, rhs) end end - dbg('opened files diff buffer %d (%s -> %s)', diff_buf, left_name, right_name) + dbg('opened files diff buffer %d (%s -> %s)', diff_buf, left_name, right_display) return diff_buf end diff --git a/lua/diffs/diffargs.lua b/lua/diffs/diffargs.lua index f2d88fd0..3b59968e 100644 --- a/lua/diffs/diffargs.lua +++ b/lua/diffs/diffargs.lua @@ -222,7 +222,7 @@ function M.parse(args, context) end ---@class diffs.DiffFilesParseResult ----@field left string # old/left side path, as typed +---@field left? string # old/left side path, as typed; nil means the current buffer's file on disk ---@field right? string # new/right side path, as typed; nil means the current buffer ---@field layout "unified"|"stacked"|"split" @@ -235,9 +235,6 @@ function M.parse_files(args) return nil, layout_err end - if #tokens == 0 then - return nil, ':Diff files expects one or two file paths' - end if #tokens > 2 then return nil, 'expected at most two file paths' end diff --git a/lua/diffs/generated/source.lua b/lua/diffs/generated/source.lua index aaf67be1..6f601646 100644 --- a/lua/diffs/generated/source.lua +++ b/lua/diffs/generated/source.lua @@ -153,6 +153,7 @@ end ---@field right_path? string ---@field left_name? string ---@field right_name? string +---@field right_buf? integer ---@param source table ---@return diffs.GeneratedBufferSource? @@ -214,6 +215,9 @@ function M.normalize_source(source) if type(source.right_path) ~= 'string' or source.right_path == '' then error('expected files right_path') end + if source.right_buf ~= nil and type(source.right_buf) ~= 'number' then + error('expected files right_buf') + end else error('unknown source kind') end @@ -301,8 +305,9 @@ end ---@param right_path string # absolute path of the new/right side ---@param left_name string # display name for the old/left side ---@param right_name string # display name for the new/right side +---@param right_buf? integer # buffer whose content is the new/right side; nil reads the path ---@return diffs.GeneratedBufferSource -function M.files_source(left_path, right_path, left_name, right_name) +function M.files_source(left_path, right_path, left_name, right_name, right_buf) return { version = 1, kind = 'files', @@ -310,6 +315,7 @@ function M.files_source(left_path, right_path, left_name, right_name) right_path = right_path, left_name = left_name, right_name = right_name, + right_buf = right_buf, } end diff --git a/spec/diff_files_spec.lua b/spec/diff_files_spec.lua index f3ba0c07..2ceb427f 100644 --- a/spec/diff_files_spec.lua +++ b/spec/diff_files_spec.lua @@ -55,6 +55,7 @@ describe('diffs.commands.diff_files', function() before_each(function() dir = vim.fn.tempname() vim.fn.mkdir(dir, 'p') + dir = vim.fn.resolve(dir) a = dir .. '/a.txt' b = dir .. '/b.txt' vim.fn.writefile({ 'alpha', 'beta', 'gamma' }, a) @@ -69,7 +70,8 @@ describe('diffs.commands.diff_files', function() return false end) for _, buf in ipairs(vim.api.nvim_list_bufs()) do - if vim.api.nvim_buf_is_valid(buf) and vim.api.nvim_buf_get_name(buf):match('^diffs://') then + local name = vim.api.nvim_buf_is_valid(buf) and vim.api.nvim_buf_get_name(buf) or '' + if name:match('^diffs://') or name:find(dir, 1, true) then pcall(vim.api.nvim_buf_delete, buf, { force = true }) end end @@ -91,14 +93,107 @@ describe('diffs.commands.diff_files', function() assert.are.equal(b, source.right_path) end) - it('defaults the new side to the current buffer with a single path', function() + it('defaults the new side to live buffer content with a single path', function() vim.cmd('edit ' .. vim.fn.fnameescape(a)) + local src = vim.api.nvim_get_current_buf() + vim.api.nvim_buf_set_lines(src, 0, -1, false, { 'alpha', 'beta', 'UNSAVED' }) + local bufnr = commands.diff_files(b, nil, { layout = 'unified' }) assert.is_number(bufnr) + assert.is_true(has_line(bufnr, '^.*%+UNSAVED')) + local source = generated.source(bufnr) assert.are.equal(b, source.left_path) assert.are.equal(a, source.right_path) + assert.are.equal(src, source.right_buf) + end) + + it('compares the current buffer against its last save with no paths', function() + vim.cmd('edit ' .. vim.fn.fnameescape(a)) + local src = vim.api.nvim_get_current_buf() + vim.api.nvim_buf_set_lines(src, 2, 3, false, { 'GAMMA' }) + + local bufnr = commands.diff_files(nil, nil, { layout = 'unified' }) + + assert.is_number(bufnr) + assert.is_true(has_line(bufnr, '^.*%-gamma')) + assert.is_true(has_line(bufnr, '^.*%+GAMMA')) + + local source = generated.source(bufnr) + assert.are.equal(a, source.left_path) + assert.are.equal(a, source.right_path) + assert.are.equal(src, source.right_buf) + assert.is_truthy(vim.api.nvim_buf_get_name(bufnr):find('-> buffer:', 1, true)) + end) + + it('reports no changes for an unmodified buffer with no paths', function() + vim.cmd('edit ' .. vim.fn.fnameescape(a)) + + local bufnr = commands.diff_files(nil, nil, { layout = 'unified' }) + + assert.is_nil(bufnr) + local name = vim.fn.fnamemodify(a, ':~:.') + assert.is_true(last_message_has('no changes between ' .. name .. ' and buffer:' .. name)) + end) + + it('reads both sides from disk when the new side is named', function() + vim.cmd('edit ' .. vim.fn.fnameescape(b)) + vim.api.nvim_buf_set_lines(0, 0, -1, false, { 'UNSAVED' }) + + local bufnr = commands.diff_files(a, b, { layout = 'unified' }) + + assert.is_number(bufnr) + assert.is_false(has_line(bufnr, 'UNSAVED')) + assert.is_nil(generated.source(bufnr).right_buf) + end) + + it('allows a buffer-backed side that is not on disk', function() + vim.cmd('edit ' .. vim.fn.fnameescape(dir .. '/new.txt')) + vim.api.nvim_buf_set_lines(0, 0, -1, false, { 'alpha', 'beta', 'gamma', 'delta' }) + + local bufnr = commands.diff_files(a, nil, { layout = 'unified' }) + + assert.is_number(bufnr) + assert.is_true(has_line(bufnr, '^.*%+delta')) + end) + + it('refuses a defaulted side in an unnamed buffer', function() + vim.cmd('enew') + + assert.is_nil(commands.diff_files(a, nil, { layout = 'unified' })) + assert.is_true(last_message_has('cannot diff unnamed buffer')) + end) + + it('re-reads the buffer when the view reloads', function() + vim.cmd('edit ' .. vim.fn.fnameescape(a)) + local src = vim.api.nvim_get_current_buf() + vim.api.nvim_buf_set_lines(src, 0, -1, false, { 'alpha', 'beta', 'first' }) + + local bufnr = commands.diff_files(b, nil, { layout = 'unified' }) + assert.is_number(bufnr) + + vim.api.nvim_buf_set_lines(src, 0, -1, false, { 'alpha', 'beta', 'second' }) + commands.read_buffer(bufnr) + + assert.is_true(has_line(bufnr, '^.*%+second')) + assert.is_false(has_line(bufnr, '^.*%+first')) + end) + + it('falls back to disk when the buffer is gone at reload', function() + vim.cmd('edit ' .. vim.fn.fnameescape(a)) + local src = vim.api.nvim_get_current_buf() + vim.api.nvim_buf_set_lines(src, 0, -1, false, { 'alpha', 'beta', 'UNSAVED' }) + + local bufnr = commands.diff_files(b, nil, { layout = 'unified' }) + assert.is_number(bufnr) + assert.is_true(has_line(bufnr, '^.*%+UNSAVED')) + + vim.api.nvim_buf_delete(src, { force = true }) + commands.read_buffer(bufnr) + + assert.is_false(has_line(bufnr, 'UNSAVED')) + assert.is_true(has_line(bufnr, '^.*%+beta')) end) it('reloads from disk through the files source', function() diff --git a/spec/diffargs_spec.lua b/spec/diffargs_spec.lua index 47a4d1f2..60175b6c 100644 --- a/spec/diffargs_spec.lua +++ b/spec/diffargs_spec.lua @@ -233,13 +233,17 @@ describe('diffs.diffargs.parse_files', function() assert.are.equal('split', result.layout) end) - it('requires at least one path', function() - for _, args in ipairs({ nil, '', '++layout=unified' }) do - local result, err = diffargs.parse_files(args) - - assert.is_nil(result) - assert.are.equal(':Diff files expects one or two file paths', err) - end + it('defaults both sides to the current buffer with no paths', function() + local bare, bare_err = diffargs.parse_files(nil) + assert.is_nil(bare_err) + assert.is_nil(bare.left) + assert.is_nil(bare.right) + assert.are.equal('unified', bare.layout) + + local layout_only = diffargs.parse_files('++layout=stacked') + assert.is_nil(layout_only.left) + assert.is_nil(layout_only.right) + assert.are.equal('stacked', layout_only.layout) end) it('rejects more than two paths', function()