From d1e5bfa550a7b576fb4dbd185552d780aa0e8356 Mon Sep 17 00:00:00 2001 From: Leonardo Laurindo Date: Sat, 15 Aug 2026 10:58:48 -0300 Subject: [PATCH] feat: add hunk navigation API (next_hunk/prev_hunk + commands) Expose change-hunk navigation as a public API instead of forcing users to read the internal prev_hunks state. Adds: - inline-diff.next_hunk() / inline-diff.prev_hunk() Lua functions, jumping the cursor to the next/previous hunk (wrapping at either end) and returning the target line or nil when there are no hunks - :InlineDiffNext / :InlineDiffPrev user commands wrapping the API Includes 7 tests covering next/prev, wrap-around, the no-hunks case, and clamping of deletions at line 0. --- doc/inline-diff.txt | 19 +++++++++++++ lua/inline-diff/init.lua | 44 +++++++++++++++++++++++++++++ plugin/inline-diff.lua | 8 ++++++ tests/init_spec.lua | 61 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 132 insertions(+) diff --git a/doc/inline-diff.txt b/doc/inline-diff.txt index 7f06579..2b65ab1 100644 --- a/doc/inline-diff.txt +++ b/doc/inline-diff.txt @@ -55,6 +55,13 @@ COMMANDS *inline-diff-commands* Toggle inline diff for the current buffer. Without `ref`, disables if currently enabled. With `ref`, always enables (or switches ref). +:InlineDiffNext *:InlineDiffNext* + Jump to the next change hunk. Wraps to the first hunk when past the last. + +:InlineDiffPrev *:InlineDiffPrev* + Jump to the previous change hunk. Wraps to the last hunk when before the + first. + ============================================================================== LUA API *inline-diff-api* @@ -78,12 +85,24 @@ require("inline-diff").toggle([bufnr], [ref]) *inline-diff.toggle()* Toggle inline diff. Without `ref`, disables if currently enabled. With `ref`, always enables (or switches ref). +require("inline-diff").next_hunk([bufnr]) *inline-diff.next_hunk()* + Jump the cursor to the next change hunk (wrapping to the first). Returns + the target 1-based line, or `nil` if there are no hunks. + +require("inline-diff").prev_hunk([bufnr]) *inline-diff.prev_hunk()* + Jump the cursor to the previous change hunk (wrapping to the last). + Returns the target 1-based line, or `nil` if there are no hunks. + Example keymaps: >lua vim.keymap.set("n", "gd", "InlineDiff", { desc = "Toggle inline diff" }) vim.keymap.set("n", "gD", "InlineDiff HEAD~1", { desc = "Diff against HEAD~1" }) + vim.keymap.set("n", "]c", "InlineDiffNext", + { desc = "Next change" }) + vim.keymap.set("n", "[c", "InlineDiffPrev", + { desc = "Prev change" }) < ============================================================================== diff --git a/lua/inline-diff/init.lua b/lua/inline-diff/init.lua index d1ed50b..cfad818 100644 --- a/lua/inline-diff/init.lua +++ b/lua/inline-diff/init.lua @@ -301,4 +301,48 @@ function M.toggle(bufnr, ref) end end +-- Jump to the next (step > 0) or previous (step < 0) change hunk, wrapping +-- around at either end. Returns the target 1-based line, or nil when there +-- are no hunks (or the buffer is not enabled). +function M._goto_hunk(bufnr, step) + local s = state._bufs[bufnr] + local hunks = s and s.prev_hunks + if not s or not s.enabled or not hunks or #hunks == 0 then + return nil + end + local row = vim.api.nvim_win_get_cursor(0)[1] + local target + if step > 0 then + for _, h in ipairs(hunks) do + local t = math.max(h.new_start, 1) + if t > row then + target = t + break + end + end + target = target or math.max(hunks[1].new_start, 1) + else + for i = #hunks, 1, -1 do + local t = math.max(hunks[i].new_start, 1) + if t < row then + target = t + break + end + end + target = target or math.max(hunks[#hunks].new_start, 1) + end + vim.api.nvim_win_set_cursor(0, { target, 0 }) + return target +end + +function M.next_hunk(bufnr) + bufnr = bufnr or vim.api.nvim_get_current_buf() + return M._goto_hunk(bufnr, 1) +end + +function M.prev_hunk(bufnr) + bufnr = bufnr or vim.api.nvim_get_current_buf() + return M._goto_hunk(bufnr, -1) +end + return M diff --git a/plugin/inline-diff.lua b/plugin/inline-diff.lua index b82c5fc..8672b75 100644 --- a/plugin/inline-diff.lua +++ b/plugin/inline-diff.lua @@ -11,3 +11,11 @@ vim.api.nvim_create_user_command("InlineDiff", function(args) local ref = args.args ~= "" and args.args or nil require("inline-diff").toggle(nil, ref) end, { nargs = "?" }) + +vim.api.nvim_create_user_command("InlineDiffNext", function() + require("inline-diff").next_hunk() +end, { desc = "Jump to next inline-diff change" }) + +vim.api.nvim_create_user_command("InlineDiffPrev", function() + require("inline-diff").prev_hunk() +end, { desc = "Jump to previous inline-diff change" }) diff --git a/tests/init_spec.lua b/tests/init_spec.lua index a23a81c..d5dbdc7 100644 --- a/tests/init_spec.lua +++ b/tests/init_spec.lua @@ -101,6 +101,67 @@ describe("enable / disable / toggle", function() end) end) +describe("_goto_hunk", function() + local bufnr + + local function set_hunks(...) + local s = state._bufs[bufnr] + s.enabled = true + s.prev_hunks = { ... } + end + + before_each(function() + bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { "l1", "l2", "l3", "l4", "l5" }) + vim.api.nvim_set_current_buf(bufnr) + M.enable(bufnr) + set_hunks() + end) + + after_each(function() + pcall(M.disable, bufnr) + pcall(vim.api.nvim_buf_delete, bufnr, { force = true }) + state._bufs[bufnr] = nil + end) + + it("returns nil when there are no hunks", function() + assert.is_nil(M.next_hunk(bufnr)) + assert.is_nil(M.prev_hunk(bufnr)) + end) + + it("moves to the next hunk after the cursor", function() + set_hunks(make_hunk(1, { "a" }, 1, { "x" }), make_hunk(3, { "c" }, 3, { "z" })) + vim.api.nvim_win_set_cursor(0, { 2, 0 }) + assert.are.equal(3, M.next_hunk(bufnr)) + assert.are.equal(3, vim.api.nvim_win_get_cursor(0)[1]) + end) + + it("moves to the previous hunk before the cursor", function() + set_hunks(make_hunk(1, { "a" }, 1, { "x" }), make_hunk(3, { "c" }, 3, { "z" })) + vim.api.nvim_win_set_cursor(0, { 4, 0 }) + assert.are.equal(3, M.prev_hunk(bufnr)) + assert.are.equal(3, vim.api.nvim_win_get_cursor(0)[1]) + end) + + it("wraps from the last hunk to the first on next", function() + set_hunks(make_hunk(1, { "a" }, 1, { "x" }), make_hunk(3, { "c" }, 3, { "z" })) + vim.api.nvim_win_set_cursor(0, { 5, 0 }) + assert.are.equal(1, M.next_hunk(bufnr)) + end) + + it("wraps from the first hunk to the last on prev", function() + set_hunks(make_hunk(1, { "a" }, 1, { "x" }), make_hunk(3, { "c" }, 3, { "z" })) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + assert.are.equal(3, M.prev_hunk(bufnr)) + end) + + it("clamps a deletion at line 0 to line 1", function() + set_hunks(make_hunk(1, { "a" }, 0, {})) + vim.api.nvim_win_set_cursor(0, { 2, 0 }) + assert.are.equal(1, M.next_hunk(bufnr)) + end) +end) + describe("_hunks_equal", function() it("returns true for both nil", function() assert.is_true(M._hunks_equal(nil, nil))