From 095fd0800aa324f5b5a65d781a75a441c904e218 Mon Sep 17 00:00:00 2001 From: Yanuo Ma Date: Thu, 5 Feb 2026 00:31:55 -0500 Subject: [PATCH 1/2] chore: bump version to 2.18.0 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index d76bd2b..cf86907 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.17.0 +2.18.0 From 517606263b1329425bbf810c3b55e3ead8aa105a Mon Sep 17 00:00:00 2001 From: Yanuo Ma Date: Thu, 5 Feb 2026 00:34:27 -0500 Subject: [PATCH 2/2] feat: expose navigation API (next_hunk, prev_hunk, next_file, prev_file) Closes #217 - Add navigation.lua module with standalone navigation functions - Export next_hunk(), prev_hunk(), next_file(), prev_file() on main API - Refactor keymaps.lua to use navigation module - Add api_spec.lua tests for exported functions Users can now customize navigation behavior: local codediff = require('codediff') vim.keymap.set('n', ']c', function() codediff.next_hunk() vim.cmd('zz') -- center after jump end) --- lua/codediff/init.lua | 32 ++++- lua/codediff/ui/view/keymaps.lua | 160 +---------------------- lua/codediff/ui/view/navigation.lua | 190 ++++++++++++++++++++++++++++ tests/api_spec.lua | 52 ++++++++ 4 files changed, 278 insertions(+), 156 deletions(-) create mode 100644 lua/codediff/ui/view/navigation.lua create mode 100644 tests/api_spec.lua diff --git a/lua/codediff/init.lua b/lua/codediff/init.lua index d955839..431a485 100644 --- a/lua/codediff/init.lua +++ b/lua/codediff/init.lua @@ -1,7 +1,7 @@ -- vscode-diff main API local M = {} --- Configuration setup - the ONLY public API users need +-- Configuration setup function M.setup(opts) local config = require("codediff.config") config.setup(opts) @@ -10,4 +10,34 @@ function M.setup(opts) render.setup_highlights() end +-- Navigate to next hunk in the current diff view +-- Returns true if navigation succeeded, false otherwise +function M.next_hunk() + local navigation = require("codediff.ui.view.navigation") + return navigation.next_hunk() +end + +-- Navigate to previous hunk in the current diff view +-- Returns true if navigation succeeded, false otherwise +function M.prev_hunk() + local navigation = require("codediff.ui.view.navigation") + return navigation.prev_hunk() +end + +-- Navigate to next file in explorer/history mode +-- In single-file history mode, navigates to next commit instead +-- Returns true if navigation succeeded, false otherwise +function M.next_file() + local navigation = require("codediff.ui.view.navigation") + return navigation.next_file() +end + +-- Navigate to previous file in explorer/history mode +-- In single-file history mode, navigates to previous commit instead +-- Returns true if navigation succeeded, false otherwise +function M.prev_file() + local navigation = require("codediff.ui.view.navigation") + return navigation.prev_file() +end + return M diff --git a/lua/codediff/ui/view/keymaps.lua b/lua/codediff/ui/view/keymaps.lua index 6027952..08a783c 100644 --- a/lua/codediff/ui/view/keymaps.lua +++ b/lua/codediff/ui/view/keymaps.lua @@ -4,6 +4,7 @@ local M = {} local lifecycle = require("codediff.ui.lifecycle") local auto_refresh = require("codediff.ui.auto_refresh") local config = require("codediff.config") +local navigation = require("codediff.ui.view.navigation") -- Centralized keymap setup for all diff view keymaps -- This function sets up ALL keymaps in one place for better maintainability @@ -14,157 +15,6 @@ function M.setup_all_keymaps(tabpage, original_bufnr, modified_bufnr, is_explore local session = lifecycle.get_session(tabpage) local is_history_mode = session and session.mode == "history" - -- Helper: Navigate to next hunk - local function navigate_next_hunk() - local session = lifecycle.get_session(tabpage) - if not session or not session.stored_diff_result then - return - end - local diff_result = session.stored_diff_result - if #diff_result.changes == 0 then - return - end - - local current_buf = vim.api.nvim_get_current_buf() - local is_original = current_buf == original_bufnr - local is_modified = current_buf == modified_bufnr - local is_result = session.result_bufnr and current_buf == session.result_bufnr - - -- If cursor is in result buffer (conflict mode), use modified side line numbers - -- but stay in current window - if is_result then - is_original = false - -- If cursor is not in any diff buffer (e.g., in explorer/history), switch to modified window - elseif not is_original and not is_modified then - is_original = false -- Use modified side for line numbers - local target_win = session.modified_win - if target_win and vim.api.nvim_win_is_valid(target_win) then - vim.api.nvim_set_current_win(target_win) - else - return - end - end - - local cursor = vim.api.nvim_win_get_cursor(0) - local current_line = cursor[1] - - -- Find next hunk after current line - for i, mapping in ipairs(diff_result.changes) do - local target_line = is_original and mapping.original.start_line or mapping.modified.start_line - if target_line > current_line then - pcall(vim.api.nvim_win_set_cursor, 0, { target_line, 0 }) - vim.api.nvim_echo({ { string.format("Hunk %d of %d", i, #diff_result.changes), "None" } }, false, {}) - return - end - end - - -- Wrap around to first hunk (if cycling enabled) - if config.options.diff.cycle_next_hunk then - local first_hunk = diff_result.changes[1] - local target_line = is_original and first_hunk.original.start_line or first_hunk.modified.start_line - pcall(vim.api.nvim_win_set_cursor, 0, { target_line, 0 }) - vim.api.nvim_echo({ { string.format("Hunk 1 of %d", #diff_result.changes), "None" } }, false, {}) - else - vim.api.nvim_echo({ { string.format("Last hunk (%d of %d)", #diff_result.changes, #diff_result.changes), "WarningMsg" } }, false, {}) - end - end - - -- Helper: Navigate to previous hunk - local function navigate_prev_hunk() - local session = lifecycle.get_session(tabpage) - if not session or not session.stored_diff_result then - return - end - local diff_result = session.stored_diff_result - if #diff_result.changes == 0 then - return - end - - local current_buf = vim.api.nvim_get_current_buf() - local is_original = current_buf == original_bufnr - local is_modified = current_buf == modified_bufnr - local is_result = session.result_bufnr and current_buf == session.result_bufnr - - -- If cursor is in result buffer (conflict mode), use modified side line numbers - -- but stay in current window - if is_result then - is_original = false - -- If cursor is not in any diff buffer (e.g., in explorer/history), switch to modified window - elseif not is_original and not is_modified then - is_original = false -- Use modified side for line numbers - local target_win = session.modified_win - if target_win and vim.api.nvim_win_is_valid(target_win) then - vim.api.nvim_set_current_win(target_win) - else - return - end - end - - local cursor = vim.api.nvim_win_get_cursor(0) - local current_line = cursor[1] - - -- Find previous hunk before current line (search backwards) - for i = #diff_result.changes, 1, -1 do - local mapping = diff_result.changes[i] - local target_line = is_original and mapping.original.start_line or mapping.modified.start_line - if target_line < current_line then - pcall(vim.api.nvim_win_set_cursor, 0, { target_line, 0 }) - vim.api.nvim_echo({ { string.format("Hunk %d of %d", i, #diff_result.changes), "None" } }, false, {}) - return - end - end - - -- Wrap around to last hunk (if cycling enabled) - if config.options.diff.cycle_next_hunk then - local last_hunk = diff_result.changes[#diff_result.changes] - local target_line = is_original and last_hunk.original.start_line or last_hunk.modified.start_line - pcall(vim.api.nvim_win_set_cursor, 0, { target_line, 0 }) - vim.api.nvim_echo({ { string.format("Hunk %d of %d", #diff_result.changes, #diff_result.changes), "None" } }, false, {}) - else - vim.api.nvim_echo({ { string.format("First hunk (1 of %d)", #diff_result.changes), "WarningMsg" } }, false, {}) - end - end - - -- Helper: Navigate to next file (works in both explorer and history mode) - -- In single-file history mode, navigates commits instead - local function navigate_next_file() - local panel_obj = lifecycle.get_explorer(tabpage) - if not panel_obj then - return - end - if is_history_mode then - local history = require("codediff.ui.history") - if panel_obj.is_single_file_mode then - history.navigate_next_commit(panel_obj) - else - history.navigate_next(panel_obj) - end - else - local explorer = require("codediff.ui.explorer") - explorer.navigate_next(panel_obj) - end - end - - -- Helper: Navigate to previous file (works in both explorer and history mode) - -- In single-file history mode, navigates commits instead - local function navigate_prev_file() - local panel_obj = lifecycle.get_explorer(tabpage) - if not panel_obj then - return - end - if is_history_mode then - local history = require("codediff.ui.history") - if panel_obj.is_single_file_mode then - history.navigate_prev_commit(panel_obj) - else - history.navigate_prev(panel_obj) - end - else - local explorer = require("codediff.ui.explorer") - explorer.navigate_prev(panel_obj) - end - end - -- Helper: Quit diff view local function quit_diff() -- Check for unsaved conflict files before closing @@ -436,10 +286,10 @@ function M.setup_all_keymaps(tabpage, original_bufnr, modified_bufnr, is_explore -- Hunk navigation (]c, [c) if keymaps.next_hunk then - lifecycle.set_tab_keymap(tabpage, "n", keymaps.next_hunk, navigate_next_hunk, { desc = "Next hunk" }) + lifecycle.set_tab_keymap(tabpage, "n", keymaps.next_hunk, navigation.next_hunk, { desc = "Next hunk" }) end if keymaps.prev_hunk then - lifecycle.set_tab_keymap(tabpage, "n", keymaps.prev_hunk, navigate_prev_hunk, { desc = "Previous hunk" }) + lifecycle.set_tab_keymap(tabpage, "n", keymaps.prev_hunk, navigation.prev_hunk, { desc = "Previous hunk" }) end -- Explorer toggle (e) - only in explorer mode @@ -480,10 +330,10 @@ function M.setup_all_keymaps(tabpage, original_bufnr, modified_bufnr, is_explore -- File navigation (]f, [f) - works in both explorer and history mode if is_explorer_mode or is_history_mode then if keymaps.next_file then - lifecycle.set_tab_keymap(tabpage, "n", keymaps.next_file, navigate_next_file, { desc = "Next file" }) + lifecycle.set_tab_keymap(tabpage, "n", keymaps.next_file, navigation.next_file, { desc = "Next file" }) end if keymaps.prev_file then - lifecycle.set_tab_keymap(tabpage, "n", keymaps.prev_file, navigate_prev_file, { desc = "Previous file" }) + lifecycle.set_tab_keymap(tabpage, "n", keymaps.prev_file, navigation.prev_file, { desc = "Previous file" }) end end end diff --git a/lua/codediff/ui/view/navigation.lua b/lua/codediff/ui/view/navigation.lua new file mode 100644 index 0000000..c23d947 --- /dev/null +++ b/lua/codediff/ui/view/navigation.lua @@ -0,0 +1,190 @@ +-- Navigation module - provides public API for navigating hunks and files +local M = {} + +local lifecycle = require("codediff.ui.lifecycle") +local config = require("codediff.config") + +-- Navigate to next hunk in the current diff view +-- Returns true if navigation succeeded, false otherwise +function M.next_hunk() + local tabpage = vim.api.nvim_get_current_tabpage() + local session = lifecycle.get_session(tabpage) + if not session or not session.stored_diff_result then + return false + end + + local diff_result = session.stored_diff_result + if #diff_result.changes == 0 then + return false + end + + local current_buf = vim.api.nvim_get_current_buf() + local original_bufnr = session.original_bufnr + local modified_bufnr = session.modified_bufnr + + local is_original = current_buf == original_bufnr + local is_modified = current_buf == modified_bufnr + local is_result = session.result_bufnr and current_buf == session.result_bufnr + + -- If cursor is in result buffer (conflict mode), use modified side line numbers + if is_result then + is_original = false + -- If cursor is not in any diff buffer, switch to modified window + elseif not is_original and not is_modified then + is_original = false + local target_win = session.modified_win + if target_win and vim.api.nvim_win_is_valid(target_win) then + vim.api.nvim_set_current_win(target_win) + else + return false + end + end + + local cursor = vim.api.nvim_win_get_cursor(0) + local current_line = cursor[1] + + -- Find next hunk after current line + for i, mapping in ipairs(diff_result.changes) do + local target_line = is_original and mapping.original.start_line or mapping.modified.start_line + if target_line > current_line then + pcall(vim.api.nvim_win_set_cursor, 0, { target_line, 0 }) + vim.api.nvim_echo({ { string.format("Hunk %d of %d", i, #diff_result.changes), "None" } }, false, {}) + return true + end + end + + -- Wrap around to first hunk (if cycling enabled) + if config.options.diff.cycle_next_hunk then + local first_hunk = diff_result.changes[1] + local target_line = is_original and first_hunk.original.start_line or first_hunk.modified.start_line + pcall(vim.api.nvim_win_set_cursor, 0, { target_line, 0 }) + vim.api.nvim_echo({ { string.format("Hunk 1 of %d", #diff_result.changes), "None" } }, false, {}) + return true + else + vim.api.nvim_echo({ { string.format("Last hunk (%d of %d)", #diff_result.changes, #diff_result.changes), "WarningMsg" } }, false, {}) + return false + end +end + +-- Navigate to previous hunk in the current diff view +-- Returns true if navigation succeeded, false otherwise +function M.prev_hunk() + local tabpage = vim.api.nvim_get_current_tabpage() + local session = lifecycle.get_session(tabpage) + if not session or not session.stored_diff_result then + return false + end + + local diff_result = session.stored_diff_result + if #diff_result.changes == 0 then + return false + end + + local current_buf = vim.api.nvim_get_current_buf() + local original_bufnr = session.original_bufnr + local modified_bufnr = session.modified_bufnr + + local is_original = current_buf == original_bufnr + local is_modified = current_buf == modified_bufnr + local is_result = session.result_bufnr and current_buf == session.result_bufnr + + -- If cursor is in result buffer (conflict mode), use modified side line numbers + if is_result then + is_original = false + -- If cursor is not in any diff buffer, switch to modified window + elseif not is_original and not is_modified then + is_original = false + local target_win = session.modified_win + if target_win and vim.api.nvim_win_is_valid(target_win) then + vim.api.nvim_set_current_win(target_win) + else + return false + end + end + + local cursor = vim.api.nvim_win_get_cursor(0) + local current_line = cursor[1] + + -- Find previous hunk before current line (search backwards) + for i = #diff_result.changes, 1, -1 do + local mapping = diff_result.changes[i] + local target_line = is_original and mapping.original.start_line or mapping.modified.start_line + if target_line < current_line then + pcall(vim.api.nvim_win_set_cursor, 0, { target_line, 0 }) + vim.api.nvim_echo({ { string.format("Hunk %d of %d", i, #diff_result.changes), "None" } }, false, {}) + return true + end + end + + -- Wrap around to last hunk (if cycling enabled) + if config.options.diff.cycle_next_hunk then + local last_hunk = diff_result.changes[#diff_result.changes] + local target_line = is_original and last_hunk.original.start_line or last_hunk.modified.start_line + pcall(vim.api.nvim_win_set_cursor, 0, { target_line, 0 }) + vim.api.nvim_echo({ { string.format("Hunk %d of %d", #diff_result.changes, #diff_result.changes), "None" } }, false, {}) + return true + else + vim.api.nvim_echo({ { string.format("First hunk (1 of %d)", #diff_result.changes), "WarningMsg" } }, false, {}) + return false + end +end + +-- Navigate to next file in explorer/history mode +-- In single-file history mode, navigates to next commit instead +-- Returns true if navigation succeeded, false otherwise +function M.next_file() + local tabpage = vim.api.nvim_get_current_tabpage() + local session = lifecycle.get_session(tabpage) + local panel_obj = lifecycle.get_explorer(tabpage) + + if not panel_obj then + return false + end + + local is_history_mode = session and session.mode == "history" + + if is_history_mode then + local history = require("codediff.ui.history") + if panel_obj.is_single_file_mode then + history.navigate_next_commit(panel_obj) + else + history.navigate_next(panel_obj) + end + else + local explorer = require("codediff.ui.explorer") + explorer.navigate_next(panel_obj) + end + + return true +end + +-- Navigate to previous file in explorer/history mode +-- In single-file history mode, navigates to previous commit instead +-- Returns true if navigation succeeded, false otherwise +function M.prev_file() + local tabpage = vim.api.nvim_get_current_tabpage() + local session = lifecycle.get_session(tabpage) + local panel_obj = lifecycle.get_explorer(tabpage) + + if not panel_obj then + return false + end + + local is_history_mode = session and session.mode == "history" + + if is_history_mode then + local history = require("codediff.ui.history") + if panel_obj.is_single_file_mode then + history.navigate_prev_commit(panel_obj) + else + history.navigate_prev(panel_obj) + end + else + local explorer = require("codediff.ui.explorer") + explorer.navigate_prev(panel_obj) + end + + return true +end + +return M diff --git a/tests/api_spec.lua b/tests/api_spec.lua new file mode 100644 index 0000000..d6f8144 --- /dev/null +++ b/tests/api_spec.lua @@ -0,0 +1,52 @@ +-- Tests for public API exports in init.lua +describe("Public API", function() + local codediff + + before_each(function() + codediff = require("codediff") + end) + + describe("exports", function() + it("exports setup function", function() + assert.is_function(codediff.setup) + end) + + it("exports next_hunk function", function() + assert.is_function(codediff.next_hunk) + end) + + it("exports prev_hunk function", function() + assert.is_function(codediff.prev_hunk) + end) + + it("exports next_file function", function() + assert.is_function(codediff.next_file) + end) + + it("exports prev_file function", function() + assert.is_function(codediff.prev_file) + end) + end) + + describe("navigation functions", function() + it("next_hunk returns false when no diff session", function() + local result = codediff.next_hunk() + assert.is_false(result) + end) + + it("prev_hunk returns false when no diff session", function() + local result = codediff.prev_hunk() + assert.is_false(result) + end) + + it("next_file returns false when no explorer/history", function() + local result = codediff.next_file() + assert.is_false(result) + end) + + it("prev_file returns false when no explorer/history", function() + local result = codediff.prev_file() + assert.is_false(result) + end) + end) +end)