Skip to content

Commit b13887b

Browse files
feat(history): auto-expand commits on file navigation boundary
Navigate between files across commits in history mode. When reaching the last file in a commit and pressing next, the next commit is automatically expanded and its first file selected. Respects the cycle_next_file config to stop at boundaries when disabled. Also renames _load_commit_files to load_commit_files for consistency and fixes collect_commit_files to recurse into directory nodes in tree view mode.
1 parent d21a9a3 commit b13887b

2 files changed

Lines changed: 154 additions & 86 deletions

File tree

lua/codediff/ui/history/refresh.lua

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -158,17 +158,17 @@ function M.refresh(history)
158158

159159
-- Re-expand previously expanded commits and reload their files
160160
local has_pending_expand = false
161-
if history._load_commit_files then
161+
if history.load_commit_files then
162162
for _, node in ipairs(history.tree:get_nodes() or {}) do
163163
if node.data and node.data.type == "commit" and expanded_hashes[node.data.hash] then
164164
has_pending_expand = true
165-
history._load_commit_files(node)
165+
history.load_commit_files(node)
166166
end
167167
end
168168
end
169169

170170
-- Skip render if expanded commits are being reloaded async
171-
-- (_load_commit_files triggers its own render when files arrive)
171+
-- (load_commit_files triggers its own render when files arrive)
172172
if not has_pending_expand then
173173
history.tree:render()
174174
end

lua/codediff/ui/history/render.lua

Lines changed: 151 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -345,8 +345,8 @@ function M.create(commits, git_root, tabpage, width, opts)
345345
on_file_select(file_data, opts)
346346
end
347347

348-
-- Store load_commit_files for refresh to re-expand commits
349-
history._load_commit_files = load_commit_files
348+
-- Store load_commit_files for refresh and navigation
349+
history.load_commit_files = load_commit_files
350350

351351
-- Setup keymaps
352352
keymaps_module.setup(history, {
@@ -440,131 +440,199 @@ function M.rerender_current(history)
440440
return false
441441
end
442442

443-
-- Get all file nodes from tree (for navigation)
444-
function M.get_all_files(tree)
443+
-- Collect all files from a commit node (handles tree mode with nested directories)
444+
local function collect_commit_files(tree, commit_node)
445445
local files = {}
446446

447-
local function collect_files(parent_node)
448-
if not parent_node:has_children() then
449-
return
450-
end
451-
if not parent_node:is_expanded() then
452-
return
447+
local function collect_recursive(node_ids)
448+
for _, node_id in ipairs(node_ids) do
449+
local node = tree:get_node(node_id)
450+
if node and node.data then
451+
if node.data.type == "file" then
452+
table.insert(files, { node = node, data = node.data })
453+
elseif node.data.type == "directory" then
454+
collect_recursive(node:get_child_ids() or {})
455+
end
456+
end
453457
end
458+
end
459+
460+
if commit_node:has_children() then
461+
collect_recursive(commit_node:get_child_ids() or {})
462+
end
454463

455-
for _, child_id in ipairs(parent_node:get_child_ids()) do
456-
local node = tree:get_node(child_id)
457-
if node and node.data and node.data.type == "file" then
458-
table.insert(files, {
459-
node = node,
460-
data = node.data,
461-
})
464+
return files
465+
end
466+
467+
-- Get all file nodes from expanded commits (for external navigation)
468+
function M.get_all_files(tree)
469+
local files = {}
470+
for _, node in ipairs(tree:get_nodes()) do
471+
if node.data and node.data.type == "commit" and node:is_expanded() then
472+
for _, file in ipairs(collect_commit_files(tree, node)) do
473+
table.insert(files, file)
462474
end
463475
end
464476
end
477+
return files
478+
end
465479

466-
local nodes = tree:get_nodes()
467-
for _, commit_node in ipairs(nodes) do
468-
collect_files(commit_node)
480+
-- Update cursor position in history panel
481+
local function update_cursor(history, node)
482+
local current_win = vim.api.nvim_get_current_win()
483+
if vim.api.nvim_win_is_valid(history.winid) then
484+
vim.api.nvim_set_current_win(history.winid)
485+
vim.api.nvim_win_set_cursor(history.winid, { node._line or 1, 0 })
486+
vim.api.nvim_set_current_win(current_win)
469487
end
488+
end
470489

471-
return files
490+
-- Find current position: returns commit_idx, file_idx, commits list
491+
local function find_current_position(history)
492+
local commits = {}
493+
for _, node in ipairs(history.tree:get_nodes()) do
494+
if node.data and node.data.type == "commit" then
495+
table.insert(commits, node)
496+
end
497+
end
498+
499+
if #commits == 0 then
500+
return nil, nil, commits
501+
end
502+
503+
for commit_idx, commit_node in ipairs(commits) do
504+
if commit_node.data.hash == history.current_commit and commit_node:is_expanded() then
505+
local files = collect_commit_files(history.tree, commit_node)
506+
for file_idx, file in ipairs(files) do
507+
if file.data.path == history.current_file then
508+
return commit_idx, file_idx, commits
509+
end
510+
end
511+
end
512+
end
513+
514+
return nil, nil, commits
472515
end
473516

474-
-- Navigate to next file
517+
-- Navigate to next file (auto-expands next commit at boundary)
475518
function M.navigate_next(history)
476-
local all_files = M.get_all_files(history.tree)
477-
if #all_files == 0 then
478-
vim.notify("No files in history", vim.log.levels.WARN)
519+
local commit_idx, file_idx, commits = find_current_position(history)
520+
521+
if #commits == 0 then
522+
vim.notify("No commits in history", vim.log.levels.WARN)
479523
return
480524
end
481525

482-
local current_commit = history.current_commit
483-
local current_file = history.current_file
484-
485-
if not current_commit or not current_file then
486-
local first_file = all_files[1]
487-
history.on_file_select(first_file.data)
526+
-- No current selection: select first file of first expanded commit
527+
if not commit_idx then
528+
for _, commit_node in ipairs(commits) do
529+
if commit_node:is_expanded() then
530+
local files = collect_commit_files(history.tree, commit_node)
531+
if #files > 0 then
532+
update_cursor(history, files[1].node)
533+
history.on_file_select(files[1].data)
534+
return
535+
end
536+
end
537+
end
538+
vim.notify("No files in history", vim.log.levels.WARN)
488539
return
489540
end
490541

491-
-- Find current index
492-
local current_index = 0
493-
for i, file in ipairs(all_files) do
494-
if file.data.commit_hash == current_commit and file.data.path == current_file then
495-
current_index = i
496-
break
497-
end
542+
local current_commit = commits[commit_idx]
543+
local files = collect_commit_files(history.tree, current_commit)
544+
545+
-- Not at boundary: go to next file in same commit
546+
if file_idx < #files then
547+
local next_file = files[file_idx + 1]
548+
update_cursor(history, next_file.node)
549+
history.on_file_select(next_file.data)
550+
return
498551
end
499552

500-
if current_index >= #all_files and not config.options.diff.cycle_next_file then
501-
vim.api.nvim_echo({ { string.format("Last file (%d of %d)", #all_files, #all_files), "WarningMsg" } }, false, {})
553+
-- At boundary: go to next commit
554+
if commit_idx >= #commits and not config.options.diff.cycle_next_file then
555+
vim.api.nvim_echo({ { string.format("Last file (%d of %d commits)", #commits, #commits), "WarningMsg" } }, false, {})
502556
return
503-
else
504-
vim.api.nvim_echo({}, false, {})
505557
end
506558

507-
local next_index = current_index % #all_files + 1
508-
local next_file = all_files[next_index]
559+
local next_commit_idx = commit_idx % #commits + 1
560+
local next_commit = commits[next_commit_idx]
509561

510-
-- Update cursor position
511-
local current_win = vim.api.nvim_get_current_win()
512-
if vim.api.nvim_win_is_valid(history.winid) then
513-
vim.api.nvim_set_current_win(history.winid)
514-
vim.api.nvim_win_set_cursor(history.winid, { next_file.node._line or 1, 0 })
515-
vim.api.nvim_set_current_win(current_win)
562+
local function select_first_file()
563+
local next_files = collect_commit_files(history.tree, next_commit)
564+
if #next_files > 0 then
565+
update_cursor(history, next_files[1].node)
566+
history.on_file_select(next_files[1].data)
567+
end
516568
end
517569

518-
history.on_file_select(next_file.data)
570+
if next_commit:is_expanded() then
571+
select_first_file()
572+
elseif history.load_commit_files then
573+
history.load_commit_files(next_commit, select_first_file)
574+
end
519575
end
520576

521-
-- Navigate to previous file
577+
-- Navigate to previous file (auto-expands previous commit at boundary)
522578
function M.navigate_prev(history)
523-
local all_files = M.get_all_files(history.tree)
524-
if #all_files == 0 then
525-
vim.notify("No files in history", vim.log.levels.WARN)
526-
return
527-
end
528-
529-
local current_commit = history.current_commit
530-
local current_file = history.current_file
579+
local commit_idx, file_idx, commits = find_current_position(history)
531580

532-
if not current_commit or not current_file then
533-
local last_file = all_files[#all_files]
534-
history.on_file_select(last_file.data)
581+
if #commits == 0 then
582+
vim.notify("No commits in history", vim.log.levels.WARN)
535583
return
536584
end
537585

538-
local current_index = 0
539-
for i, file in ipairs(all_files) do
540-
if file.data.commit_hash == current_commit and file.data.path == current_file then
541-
current_index = i
542-
break
586+
-- No current selection: select last file of last expanded commit
587+
if not commit_idx then
588+
for i = #commits, 1, -1 do
589+
local commit_node = commits[i]
590+
if commit_node:is_expanded() then
591+
local files = collect_commit_files(history.tree, commit_node)
592+
if #files > 0 then
593+
update_cursor(history, files[#files].node)
594+
history.on_file_select(files[#files].data)
595+
return
596+
end
597+
end
543598
end
599+
vim.notify("No files in history", vim.log.levels.WARN)
600+
return
544601
end
545602

546-
if current_index <= 1 and not config.options.diff.cycle_next_file then
547-
vim.api.nvim_echo({ { string.format("First file (1 of %d)", #all_files), "WarningMsg" } }, false, {})
603+
local current_commit = commits[commit_idx]
604+
local files = collect_commit_files(history.tree, current_commit)
605+
606+
-- Not at boundary: go to previous file in same commit
607+
if file_idx > 1 then
608+
local prev_file = files[file_idx - 1]
609+
update_cursor(history, prev_file.node)
610+
history.on_file_select(prev_file.data)
548611
return
549-
else
550-
vim.api.nvim_echo({}, false, {})
551612
end
552613

553-
local prev_index = current_index - 2
554-
if prev_index < 0 then
555-
prev_index = #all_files + prev_index
614+
-- At boundary: go to previous commit
615+
if commit_idx <= 1 and not config.options.diff.cycle_next_file then
616+
vim.api.nvim_echo({ { string.format("First file (1 of %d commits)", #commits), "WarningMsg" } }, false, {})
617+
return
556618
end
557-
prev_index = prev_index % #all_files + 1
558-
local prev_file = all_files[prev_index]
559619

560-
local current_win = vim.api.nvim_get_current_win()
561-
if vim.api.nvim_win_is_valid(history.winid) then
562-
vim.api.nvim_set_current_win(history.winid)
563-
vim.api.nvim_win_set_cursor(history.winid, { prev_file.node._line or 1, 0 })
564-
vim.api.nvim_set_current_win(current_win)
620+
local prev_commit_idx = (commit_idx - 2) % #commits + 1
621+
local prev_commit = commits[prev_commit_idx]
622+
623+
local function select_last_file()
624+
local prev_files = collect_commit_files(history.tree, prev_commit)
625+
if #prev_files > 0 then
626+
update_cursor(history, prev_files[#prev_files].node)
627+
history.on_file_select(prev_files[#prev_files].data)
628+
end
565629
end
566630

567-
history.on_file_select(prev_file.data)
631+
if prev_commit:is_expanded() then
632+
select_last_file()
633+
elseif history.load_commit_files then
634+
history.load_commit_files(prev_commit, select_last_file)
635+
end
568636
end
569637

570638
-- Get all commit nodes from tree (for navigation in single-file mode)

0 commit comments

Comments
 (0)