From cb22bfdbe40c6a6a2746d34c1bc08e8495626aba Mon Sep 17 00:00:00 2001 From: Fredrik Averpil Date: Tue, 10 Mar 2026 06:23:29 +0100 Subject: [PATCH] feat(history): auto-expand commits on file navigation boundary --- lua/codediff/ui/history/render.lua | 230 +++++++++++++++++++---------- 1 file changed, 149 insertions(+), 81 deletions(-) diff --git a/lua/codediff/ui/history/render.lua b/lua/codediff/ui/history/render.lua index cf10b442..489dfed8 100644 --- a/lua/codediff/ui/history/render.lua +++ b/lua/codediff/ui/history/render.lua @@ -440,131 +440,199 @@ function M.rerender_current(history) return false end --- Get all file nodes from tree (for navigation) -function M.get_all_files(tree) +-- Collect all files from a commit node (handles tree mode with nested directories) +local function collect_commit_files(tree, commit_node) local files = {} - local function collect_files(parent_node) - if not parent_node:has_children() then - return - end - if not parent_node:is_expanded() then - return + local function collect_recursive(node_ids) + for _, node_id in ipairs(node_ids) do + local node = tree:get_node(node_id) + if node and node.data then + if node.data.type == "file" then + table.insert(files, { node = node, data = node.data }) + elseif node.data.type == "directory" then + collect_recursive(node:get_child_ids() or {}) + end + end end + end + + if commit_node:has_children() then + collect_recursive(commit_node:get_child_ids() or {}) + end - for _, child_id in ipairs(parent_node:get_child_ids()) do - local node = tree:get_node(child_id) - if node and node.data and node.data.type == "file" then - table.insert(files, { - node = node, - data = node.data, - }) + return files +end + +-- Get all file nodes from expanded commits (for external navigation) +function M.get_all_files(tree) + local files = {} + for _, node in ipairs(tree:get_nodes()) do + if node.data and node.data.type == "commit" and node:is_expanded() then + for _, file in ipairs(collect_commit_files(tree, node)) do + table.insert(files, file) end end end + return files +end - local nodes = tree:get_nodes() - for _, commit_node in ipairs(nodes) do - collect_files(commit_node) +-- Update cursor position in history panel +local function update_cursor(history, node) + local current_win = vim.api.nvim_get_current_win() + if vim.api.nvim_win_is_valid(history.winid) then + vim.api.nvim_set_current_win(history.winid) + vim.api.nvim_win_set_cursor(history.winid, { node._line or 1, 0 }) + vim.api.nvim_set_current_win(current_win) end +end - return files +-- Find current position: returns commit_idx, file_idx, commits list +local function find_current_position(history) + local commits = {} + for _, node in ipairs(history.tree:get_nodes()) do + if node.data and node.data.type == "commit" then + table.insert(commits, node) + end + end + + if #commits == 0 then + return nil, nil, commits + end + + for commit_idx, commit_node in ipairs(commits) do + if commit_node.data.hash == history.current_commit and commit_node:is_expanded() then + local files = collect_commit_files(history.tree, commit_node) + for file_idx, file in ipairs(files) do + if file.data.path == history.current_file then + return commit_idx, file_idx, commits + end + end + end + end + + return nil, nil, commits end --- Navigate to next file +-- Navigate to next file (auto-expands next commit at boundary) function M.navigate_next(history) - local all_files = M.get_all_files(history.tree) - if #all_files == 0 then - vim.notify("No files in history", vim.log.levels.WARN) + local commit_idx, file_idx, commits = find_current_position(history) + + if #commits == 0 then + vim.notify("No commits in history", vim.log.levels.WARN) return end - local current_commit = history.current_commit - local current_file = history.current_file - - if not current_commit or not current_file then - local first_file = all_files[1] - history.on_file_select(first_file.data) + -- No current selection: select first file of first expanded commit + if not commit_idx then + for _, commit_node in ipairs(commits) do + if commit_node:is_expanded() then + local files = collect_commit_files(history.tree, commit_node) + if #files > 0 then + update_cursor(history, files[1].node) + history.on_file_select(files[1].data) + return + end + end + end + vim.notify("No files in history", vim.log.levels.WARN) return end - -- Find current index - local current_index = 0 - for i, file in ipairs(all_files) do - if file.data.commit_hash == current_commit and file.data.path == current_file then - current_index = i - break - end + local current_commit = commits[commit_idx] + local files = collect_commit_files(history.tree, current_commit) + + -- Not at boundary: go to next file in same commit + if file_idx < #files then + local next_file = files[file_idx + 1] + update_cursor(history, next_file.node) + history.on_file_select(next_file.data) + return end - if current_index >= #all_files and not config.options.diff.cycle_next_file then - vim.api.nvim_echo({ { string.format("Last file (%d of %d)", #all_files, #all_files), "WarningMsg" } }, false, {}) + -- At boundary: go to next commit + if commit_idx >= #commits and not config.options.diff.cycle_next_file then + vim.api.nvim_echo({ { string.format("Last file (%d of %d commits)", #commits, #commits), "WarningMsg" } }, false, {}) return - else - vim.api.nvim_echo({}, false, {}) end - local next_index = current_index % #all_files + 1 - local next_file = all_files[next_index] + local next_commit_idx = commit_idx % #commits + 1 + local next_commit = commits[next_commit_idx] - -- Update cursor position - local current_win = vim.api.nvim_get_current_win() - if vim.api.nvim_win_is_valid(history.winid) then - vim.api.nvim_set_current_win(history.winid) - vim.api.nvim_win_set_cursor(history.winid, { next_file.node._line or 1, 0 }) - vim.api.nvim_set_current_win(current_win) + local function select_first_file() + local next_files = collect_commit_files(history.tree, next_commit) + if #next_files > 0 then + update_cursor(history, next_files[1].node) + history.on_file_select(next_files[1].data) + end end - history.on_file_select(next_file.data) + if next_commit:is_expanded() then + select_first_file() + elseif history._load_commit_files then + history._load_commit_files(next_commit, select_first_file) + end end --- Navigate to previous file +-- Navigate to previous file (auto-expands previous commit at boundary) function M.navigate_prev(history) - local all_files = M.get_all_files(history.tree) - if #all_files == 0 then - vim.notify("No files in history", vim.log.levels.WARN) - return - end - - local current_commit = history.current_commit - local current_file = history.current_file + local commit_idx, file_idx, commits = find_current_position(history) - if not current_commit or not current_file then - local last_file = all_files[#all_files] - history.on_file_select(last_file.data) + if #commits == 0 then + vim.notify("No commits in history", vim.log.levels.WARN) return end - local current_index = 0 - for i, file in ipairs(all_files) do - if file.data.commit_hash == current_commit and file.data.path == current_file then - current_index = i - break + -- No current selection: select last file of last expanded commit + if not commit_idx then + for i = #commits, 1, -1 do + local commit_node = commits[i] + if commit_node:is_expanded() then + local files = collect_commit_files(history.tree, commit_node) + if #files > 0 then + update_cursor(history, files[#files].node) + history.on_file_select(files[#files].data) + return + end + end end + vim.notify("No files in history", vim.log.levels.WARN) + return end - if current_index <= 1 and not config.options.diff.cycle_next_file then - vim.api.nvim_echo({ { string.format("First file (1 of %d)", #all_files), "WarningMsg" } }, false, {}) + local current_commit = commits[commit_idx] + local files = collect_commit_files(history.tree, current_commit) + + -- Not at boundary: go to previous file in same commit + if file_idx > 1 then + local prev_file = files[file_idx - 1] + update_cursor(history, prev_file.node) + history.on_file_select(prev_file.data) return - else - vim.api.nvim_echo({}, false, {}) end - local prev_index = current_index - 2 - if prev_index < 0 then - prev_index = #all_files + prev_index + -- At boundary: go to previous commit + if commit_idx <= 1 and not config.options.diff.cycle_next_file then + vim.api.nvim_echo({ { string.format("First file (1 of %d commits)", #commits), "WarningMsg" } }, false, {}) + return end - prev_index = prev_index % #all_files + 1 - local prev_file = all_files[prev_index] - local current_win = vim.api.nvim_get_current_win() - if vim.api.nvim_win_is_valid(history.winid) then - vim.api.nvim_set_current_win(history.winid) - vim.api.nvim_win_set_cursor(history.winid, { prev_file.node._line or 1, 0 }) - vim.api.nvim_set_current_win(current_win) + local prev_commit_idx = (commit_idx - 2) % #commits + 1 + local prev_commit = commits[prev_commit_idx] + + local function select_last_file() + local prev_files = collect_commit_files(history.tree, prev_commit) + if #prev_files > 0 then + update_cursor(history, prev_files[#prev_files].node) + history.on_file_select(prev_files[#prev_files].data) + end end - history.on_file_select(prev_file.data) + if prev_commit:is_expanded() then + select_last_file() + elseif history._load_commit_files then + history._load_commit_files(prev_commit, select_last_file) + end end -- Get all commit nodes from tree (for navigation in single-file mode)