From 03dac2e8f81ca8c5281ad3d7879de05a0bab8f41 Mon Sep 17 00:00:00 2001 From: Christian De Santis Date: Wed, 7 Jan 2026 17:45:58 -0400 Subject: [PATCH 01/16] feat: add single-pane mode for untracked files Display untracked files (status ??) in a single pane without diff highlighting, since there's nothing to compare against. When switching to tracked files, the two-pane layout is automatically restored. - Add update_windows() and is_single_pane_mode() to lifecycle module - Handle untracked file selection in explorer to close original window - Add single-pane recovery logic in view.update() for layout restoration --- lua/codediff/ui/explorer/render.lua | 37 +++++++++++++++++++++++++++ lua/codediff/ui/lifecycle/init.lua | 4 +++ lua/codediff/ui/lifecycle/session.lua | 26 +++++++++++++++++++ lua/codediff/ui/view/init.lua | 12 +++++++++ 4 files changed, 79 insertions(+) diff --git a/lua/codediff/ui/explorer/render.lua b/lua/codediff/ui/explorer/render.lua index 2b0f3348..976b4f23 100644 --- a/lua/codediff/ui/explorer/render.lua +++ b/lua/codediff/ui/explorer/render.lua @@ -185,6 +185,43 @@ function M.create(status_result, git_root, tabpage, width, base_revision, target local abs_path = git_root .. "/" .. file_path + -- Handle untracked files: show in single pane without diff + if file_data.status == "??" then + vim.schedule(function() + local sess = lifecycle.get_session(tabpage) + if sess then + local orig_win, mod_win = lifecycle.get_windows(tabpage) + + -- Open the file in the modified window + if mod_win and vim.api.nvim_win_is_valid(mod_win) then + vim.api.nvim_set_current_win(mod_win) + vim.cmd('edit ' .. vim.fn.fnameescape(abs_path)) + + -- Clear any diff highlights from the buffer + local mod_buf = vim.api.nvim_win_get_buf(mod_win) + local highlights = require('codediff.ui.highlights') + vim.api.nvim_buf_clear_namespace(mod_buf, highlights.ns_highlight, 0, -1) + vim.api.nvim_buf_clear_namespace(mod_buf, highlights.ns_filler, 0, -1) + end + + -- Close the original window completely + if orig_win and vim.api.nvim_win_is_valid(orig_win) then + vim.api.nvim_win_close(orig_win, true) + -- Mark session as single-pane mode by setting original_win to nil + lifecycle.update_windows(tabpage, -1, nil) -- Use -1 as sentinel for "closed" + end + + vim.cmd('wincmd =') + end + end) + return + end + + -- For tracked files, check if we need to restore two-pane mode + if lifecycle.is_single_pane_mode(tabpage) then + -- Will be handled by view.update which will recreate the layout + end + -- Check if this exact diff is already being displayed -- Same file can have different diffs (staged vs HEAD, working vs staged) local session = lifecycle.get_session(tabpage) diff --git a/lua/codediff/ui/lifecycle/init.lua b/lua/codediff/ui/lifecycle/init.lua index 2dc16d5f..5d660fe0 100644 --- a/lua/codediff/ui/lifecycle/init.lua +++ b/lua/codediff/ui/lifecycle/init.lua @@ -70,4 +70,8 @@ M.set_tab_keymap = accessors.set_tab_keymap M.clear_tab_keymaps = accessors.clear_tab_keymaps M.setup_auto_sync_on_file_switch = accessors.setup_auto_sync_on_file_switch +-- Single-pane mode support (for untracked files) +M.update_windows = session.update_windows +M.is_single_pane_mode = session.is_single_pane_mode + return M diff --git a/lua/codediff/ui/lifecycle/session.lua b/lua/codediff/ui/lifecycle/session.lua index 93ada983..360d9f55 100644 --- a/lua/codediff/ui/lifecycle/session.lua +++ b/lua/codediff/ui/lifecycle/session.lua @@ -178,4 +178,30 @@ function M.create_session( }) end +--- Update window references (for single-pane mode when hiding original window) +--- @param tabpage number +--- @param original_win number|nil New original window ID (or nil to mark as hidden) +--- @param modified_win number|nil New modified window ID (optional) +function M.update_windows(tabpage, original_win, modified_win) + local sess = active_diffs[tabpage] + if not sess then return false end + + if original_win ~= nil then + sess.original_win = original_win + end + if modified_win ~= nil then + sess.modified_win = modified_win + end + return true +end + +--- Check if session is in single-pane mode (original window hidden/closed) +--- @param tabpage number +--- @return boolean +function M.is_single_pane_mode(tabpage) + local sess = active_diffs[tabpage] + if not sess then return false end + return sess.original_win == nil or not vim.api.nvim_win_is_valid(sess.original_win) +end + return M diff --git a/lua/codediff/ui/view/init.lua b/lua/codediff/ui/view/init.lua index 9b6d3341..fdf4f3ab 100644 --- a/lua/codediff/ui/view/init.lua +++ b/lua/codediff/ui/view/init.lua @@ -482,6 +482,18 @@ function M.update(tabpage, session_config, auto_scroll_to_first_hunk) local old_original_buf, old_modified_buf = lifecycle.get_buffers(tabpage) local original_win, modified_win = lifecycle.get_windows(tabpage) + -- Handle single-pane mode recovery (coming from untracked file view) + local was_single_pane = lifecycle.is_single_pane_mode(tabpage) + if was_single_pane and modified_win and vim.api.nvim_win_is_valid(modified_win) then + -- Recreate the original window by splitting from the modified window + vim.api.nvim_set_current_win(modified_win) + vim.cmd('leftabove vsplit') + original_win = vim.api.nvim_get_current_win() + -- Update session with new window reference + lifecycle.update_windows(tabpage, original_win, nil) + vim.cmd('wincmd =') + end + if not old_original_buf or not old_modified_buf or not original_win or not modified_win then vim.notify("Invalid diff session state", vim.log.levels.ERROR) return false From 15ec17bfd4ca29a000f25fdf7a200c0a650fe9d9 Mon Sep 17 00:00:00 2001 From: Christian De Santis Date: Wed, 7 Jan 2026 18:20:17 -0400 Subject: [PATCH 02/16] fix: keep two-pane layout for untracked files Instead of closing the original window (which corrupts session state), show an empty scratch buffer with a message in the left pane while displaying the untracked file in the right pane. This keeps the session state consistent and allows switching back to tracked files. --- lua/codediff/ui/explorer/render.lua | 67 +++++++++++++++++++++-------- 1 file changed, 49 insertions(+), 18 deletions(-) diff --git a/lua/codediff/ui/explorer/render.lua b/lua/codediff/ui/explorer/render.lua index 976b4f23..c4b5b7b5 100644 --- a/lua/codediff/ui/explorer/render.lua +++ b/lua/codediff/ui/explorer/render.lua @@ -185,43 +185,74 @@ function M.create(status_result, git_root, tabpage, width, base_revision, target local abs_path = git_root .. "/" .. file_path - -- Handle untracked files: show in single pane without diff + -- Dir mode: Compare files from dir1 vs dir2 (no git) + if is_dir_mode then + local original_path = explorer.dir1 .. "/" .. file_path + local modified_path = explorer.dir2 .. "/" .. file_path + + -- Check if already displaying same file + local session = lifecycle.get_session(tabpage) + if session and session.original_path == original_path and session.modified_path == modified_path then + return + end + + vim.schedule(function() + ---@type SessionConfig + local session_config = { + mode = "explorer", + git_root = nil, + original_path = original_path, + modified_path = modified_path, + original_revision = nil, + modified_revision = nil, + } + view.update(tabpage, session_config, true) + end) + return + end + + local abs_path = git_root .. "/" .. file_path + + -- Handle untracked files: show file without diff (empty left pane) if file_data.status == "??" then vim.schedule(function() local sess = lifecycle.get_session(tabpage) if sess then local orig_win, mod_win = lifecycle.get_windows(tabpage) + local highlights = require('codediff.ui.highlights') - -- Open the file in the modified window - if mod_win and vim.api.nvim_win_is_valid(mod_win) then - vim.api.nvim_set_current_win(mod_win) - vim.cmd('edit ' .. vim.fn.fnameescape(abs_path)) + -- Clear highlights from both windows first + local orig_buf = orig_win and vim.api.nvim_win_is_valid(orig_win) and vim.api.nvim_win_get_buf(orig_win) + local mod_buf = mod_win and vim.api.nvim_win_is_valid(mod_win) and vim.api.nvim_win_get_buf(mod_win) - -- Clear any diff highlights from the buffer - local mod_buf = vim.api.nvim_win_get_buf(mod_win) - local highlights = require('codediff.ui.highlights') + if orig_buf and vim.api.nvim_buf_is_valid(orig_buf) then + vim.api.nvim_buf_clear_namespace(orig_buf, highlights.ns_highlight, 0, -1) + vim.api.nvim_buf_clear_namespace(orig_buf, highlights.ns_filler, 0, -1) + end + if mod_buf and vim.api.nvim_buf_is_valid(mod_buf) then vim.api.nvim_buf_clear_namespace(mod_buf, highlights.ns_highlight, 0, -1) vim.api.nvim_buf_clear_namespace(mod_buf, highlights.ns_filler, 0, -1) end - -- Close the original window completely + -- Create empty scratch buffer for original window (no file to compare against) if orig_win and vim.api.nvim_win_is_valid(orig_win) then - vim.api.nvim_win_close(orig_win, true) - -- Mark session as single-pane mode by setting original_win to nil - lifecycle.update_windows(tabpage, -1, nil) -- Use -1 as sentinel for "closed" + local empty_buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(empty_buf, 0, -1, false, {"", " (New untracked file - nothing to compare)", ""}) + vim.bo[empty_buf].modifiable = false + vim.bo[empty_buf].buftype = 'nofile' + vim.api.nvim_win_set_buf(orig_win, empty_buf) end - vim.cmd('wincmd =') + -- Open the untracked file in the modified window + if mod_win and vim.api.nvim_win_is_valid(mod_win) then + vim.api.nvim_set_current_win(mod_win) + vim.cmd('edit ' .. vim.fn.fnameescape(abs_path)) + end end end) return end - -- For tracked files, check if we need to restore two-pane mode - if lifecycle.is_single_pane_mode(tabpage) then - -- Will be handled by view.update which will recreate the layout - end - -- Check if this exact diff is already being displayed -- Same file can have different diffs (staged vs HEAD, working vs staged) local session = lifecycle.get_session(tabpage) From ae26ce98eda5e11da25304a054d79dc9df475b4d Mon Sep 17 00:00:00 2001 From: Christian De Santis Date: Wed, 7 Jan 2026 18:30:11 -0400 Subject: [PATCH 03/16] feat: hide left pane and auto-skip for untracked files When viewing untracked files: - Shrink left pane to 1 column (effectively hidden) - Auto-skip the hidden pane when navigating with Ctrl+H/L - Restore equal window widths when switching to tracked files --- lua/codediff/ui/explorer/render.lua | 86 ++++++++++++++++++++++++++++- lua/codediff/ui/view/init.lua | 12 ++++ 2 files changed, 95 insertions(+), 3 deletions(-) diff --git a/lua/codediff/ui/explorer/render.lua b/lua/codediff/ui/explorer/render.lua index c4b5b7b5..6aed09a4 100644 --- a/lua/codediff/ui/explorer/render.lua +++ b/lua/codediff/ui/explorer/render.lua @@ -213,7 +213,63 @@ function M.create(status_result, git_root, tabpage, width, base_revision, target local abs_path = git_root .. "/" .. file_path - -- Handle untracked files: show file without diff (empty left pane) + -- Dir mode: Compare files from dir1 vs dir2 (no git) + if is_dir_mode then + local original_path = explorer.dir1 .. "/" .. file_path + local modified_path = explorer.dir2 .. "/" .. file_path + + -- Check if already displaying same file + local session = lifecycle.get_session(tabpage) + if session and session.original_path == original_path and session.modified_path == modified_path then + return + end + + vim.schedule(function() + ---@type SessionConfig + local session_config = { + mode = "explorer", + git_root = nil, + original_path = original_path, + modified_path = modified_path, + original_revision = nil, + modified_revision = nil, + } + view.update(tabpage, session_config, true) + end) + return + end + + local abs_path = git_root .. "/" .. file_path + + -- Dir mode: Compare files from dir1 vs dir2 (no git) + if is_dir_mode then + local original_path = explorer.dir1 .. "/" .. file_path + local modified_path = explorer.dir2 .. "/" .. file_path + + -- Check if already displaying same file + local session = lifecycle.get_session(tabpage) + if session and session.original_path == original_path and session.modified_path == modified_path then + return + end + + vim.schedule(function() + ---@type SessionConfig + local session_config = { + mode = "explorer", + git_root = nil, + original_path = original_path, + modified_path = modified_path, + original_revision = nil, + modified_revision = nil, + } + view.update(tabpage, session_config, true) + end) + return + end + + local abs_path = git_root .. "/" .. file_path + + -- Handle untracked files: show file without diff (hide left pane) if file_data.status == "??" then vim.schedule(function() local sess = lifecycle.get_session(tabpage) @@ -234,13 +290,37 @@ function M.create(status_result, git_root, tabpage, width, base_revision, target vim.api.nvim_buf_clear_namespace(mod_buf, highlights.ns_filler, 0, -1) end - -- Create empty scratch buffer for original window (no file to compare against) + -- Create empty scratch buffer for original window and hide it if orig_win and vim.api.nvim_win_is_valid(orig_win) then local empty_buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(empty_buf, 0, -1, false, {"", " (New untracked file - nothing to compare)", ""}) vim.bo[empty_buf].modifiable = false vim.bo[empty_buf].buftype = 'nofile' vim.api.nvim_win_set_buf(orig_win, empty_buf) + + -- Shrink window to minimum width (effectively hidden) + vim.api.nvim_win_set_width(orig_win, 1) + + -- Mark this window as a placeholder for later restoration + vim.w[orig_win].codediff_placeholder = true + + -- Set up auto-skip: when entering this window, redirect based on where we came from + local skip_group = vim.api.nvim_create_augroup('codediff_skip_placeholder_' .. tabpage, { clear = true }) + vim.api.nvim_create_autocmd('WinEnter', { + group = skip_group, + buffer = empty_buf, + callback = function() + -- Get previous window + local prev_win = vim.fn.win_getid(vim.fn.winnr('#')) + + -- If came from file window (right), go left to explorer + -- If came from explorer (left), go right to file + if prev_win == mod_win then + vim.cmd('wincmd h') + else + vim.cmd('wincmd l') + end + end, + }) end -- Open the untracked file in the modified window diff --git a/lua/codediff/ui/view/init.lua b/lua/codediff/ui/view/init.lua index fdf4f3ab..968ec580 100644 --- a/lua/codediff/ui/view/init.lua +++ b/lua/codediff/ui/view/init.lua @@ -626,6 +626,18 @@ function M.update(tabpage, session_config, auto_scroll_to_first_hunk) if saved_current_win and vim.api.nvim_win_is_valid(saved_current_win) then vim.api.nvim_set_current_win(saved_current_win) end + + -- Restore window widths if coming from untracked file view (placeholder mode) + if vim.api.nvim_win_is_valid(original_win) and vim.w[original_win].codediff_placeholder then + vim.w[original_win].codediff_placeholder = nil + -- Clear the skip autocmd group + pcall(vim.api.nvim_del_augroup_by_name, 'codediff_skip_placeholder_' .. tabpage) + -- Equalize diff window widths + local total_width = vim.api.nvim_win_get_width(original_win) + vim.api.nvim_win_get_width(modified_win) + local half_width = math.floor(total_width / 2) + vim.api.nvim_win_set_width(original_win, half_width) + vim.api.nvim_win_set_width(modified_win, half_width) + end end end end From 4ed0e83fb2c999b96ecdb1013fff1089ba752a60 Mon Sep 17 00:00:00 2001 From: Christian De Santis Date: Wed, 7 Jan 2026 18:34:37 -0400 Subject: [PATCH 04/16] fix: run window restoration synchronously Move the placeholder window restoration code to run synchronously after buffer loading, rather than inside the async render_everything callback. This ensures window widths are restored reliably. --- lua/codediff/ui/view/init.lua | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lua/codediff/ui/view/init.lua b/lua/codediff/ui/view/init.lua index 968ec580..3c08e8a2 100644 --- a/lua/codediff/ui/view/init.lua +++ b/lua/codediff/ui/view/init.lua @@ -792,6 +792,18 @@ function M.update(tabpage, session_config, auto_scroll_to_first_hunk) pcall(vim.api.nvim_buf_delete, old_modified_buf, { force = true }) end + -- Restore window widths if coming from untracked file view (placeholder mode) + if vim.api.nvim_win_is_valid(original_win) and vim.w[original_win].codediff_placeholder then + vim.w[original_win].codediff_placeholder = nil + -- Clear the skip autocmd group + pcall(vim.api.nvim_del_augroup_by_name, 'codediff_skip_placeholder_' .. tabpage) + -- Equalize diff window widths + local total_width = vim.api.nvim_win_get_width(original_win) + vim.api.nvim_win_get_width(modified_win) + local half_width = math.floor(total_width / 2) + vim.api.nvim_win_set_width(original_win, half_width) + vim.api.nvim_win_set_width(modified_win, half_width) + end + -- Update session with new buffer/window IDs -- Note: We need to update lifecycle to support this, or recreate session -- For now, we'll update the stored diff result and metadata From 47564d2c69e2fe6d52a6e4e2b825ba51d5f8f097 Mon Sep 17 00:00:00 2001 From: Christian De Santis Date: Wed, 7 Jan 2026 18:45:06 -0400 Subject: [PATCH 05/16] fix: properly update session state for untracked files - Use bufadd/bufload/nvim_win_set_buf instead of :edit to reuse buffers - Get old buffers from session before clearing highlights - Update all session state (buffers, paths, revisions, diff_result) after opening untracked file to keep state consistent - This fixes issues when switching between untracked and tracked files --- lua/codediff/ui/explorer/render.lua | 41 ++++++++++++++++++----------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/lua/codediff/ui/explorer/render.lua b/lua/codediff/ui/explorer/render.lua index 6aed09a4..34469e10 100644 --- a/lua/codediff/ui/explorer/render.lua +++ b/lua/codediff/ui/explorer/render.lua @@ -277,24 +277,24 @@ function M.create(status_result, git_root, tabpage, width, base_revision, target local orig_win, mod_win = lifecycle.get_windows(tabpage) local highlights = require('codediff.ui.highlights') - -- Clear highlights from both windows first - local orig_buf = orig_win and vim.api.nvim_win_is_valid(orig_win) and vim.api.nvim_win_get_buf(orig_win) - local mod_buf = mod_win and vim.api.nvim_win_is_valid(mod_win) and vim.api.nvim_win_get_buf(mod_win) - - if orig_buf and vim.api.nvim_buf_is_valid(orig_buf) then - vim.api.nvim_buf_clear_namespace(orig_buf, highlights.ns_highlight, 0, -1) - vim.api.nvim_buf_clear_namespace(orig_buf, highlights.ns_filler, 0, -1) + -- Clear highlights from current session buffers + local old_orig_buf, old_mod_buf = lifecycle.get_buffers(tabpage) + if old_orig_buf and vim.api.nvim_buf_is_valid(old_orig_buf) then + vim.api.nvim_buf_clear_namespace(old_orig_buf, highlights.ns_highlight, 0, -1) + vim.api.nvim_buf_clear_namespace(old_orig_buf, highlights.ns_filler, 0, -1) end - if mod_buf and vim.api.nvim_buf_is_valid(mod_buf) then - vim.api.nvim_buf_clear_namespace(mod_buf, highlights.ns_highlight, 0, -1) - vim.api.nvim_buf_clear_namespace(mod_buf, highlights.ns_filler, 0, -1) + if old_mod_buf and vim.api.nvim_buf_is_valid(old_mod_buf) then + vim.api.nvim_buf_clear_namespace(old_mod_buf, highlights.ns_highlight, 0, -1) + vim.api.nvim_buf_clear_namespace(old_mod_buf, highlights.ns_filler, 0, -1) end - -- Create empty scratch buffer for original window and hide it + -- Create empty scratch buffer for original window + local empty_buf = vim.api.nvim_create_buf(false, true) + vim.bo[empty_buf].modifiable = false + vim.bo[empty_buf].buftype = 'nofile' + + -- Set up the hidden left pane if orig_win and vim.api.nvim_win_is_valid(orig_win) then - local empty_buf = vim.api.nvim_create_buf(false, true) - vim.bo[empty_buf].modifiable = false - vim.bo[empty_buf].buftype = 'nofile' vim.api.nvim_win_set_buf(orig_win, empty_buf) -- Shrink window to minimum width (effectively hidden) @@ -323,10 +323,19 @@ function M.create(status_result, git_root, tabpage, width, base_revision, target }) end - -- Open the untracked file in the modified window + -- Load the untracked file into modified window (reuse buffer pattern) if mod_win and vim.api.nvim_win_is_valid(mod_win) then + -- Use bufadd/bufload instead of :edit to reuse existing buffer if available + local file_bufnr = vim.fn.bufadd(abs_path) + vim.fn.bufload(file_bufnr) + vim.api.nvim_win_set_buf(mod_win, file_bufnr) vim.api.nvim_set_current_win(mod_win) - vim.cmd('edit ' .. vim.fn.fnameescape(abs_path)) + + -- Update session state to keep it consistent + lifecycle.update_buffers(tabpage, empty_buf, file_bufnr) + lifecycle.update_paths(tabpage, "", abs_path) + lifecycle.update_revisions(tabpage, nil, nil) + lifecycle.update_diff_result(tabpage, {}) -- Empty diff for untracked end end end) From 85a33e5b3584e493b0d1569d4feade0b1ce700c6 Mon Sep 17 00:00:00 2001 From: Christian De Santis Date: Wed, 7 Jan 2026 19:26:47 -0400 Subject: [PATCH 06/16] fix: clean up scratch and placeholder buffers properly - Create separate scratch buffers for each window in explorer mode so the initial tabnew buffer can be properly deleted - Capture scratch buffer before loading new buffers in view.update - Clean up old scratch buffers (buftype='nofile') in view.update - This fixes empty buffer accumulation and improves restoration when switching between untracked and tracked files --- lua/codediff/ui/view/init.lua | 44 ++++++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/lua/codediff/ui/view/init.lua b/lua/codediff/ui/view/init.lua index 3c08e8a2..b0bab084 100644 --- a/lua/codediff/ui/view/init.lua +++ b/lua/codediff/ui/view/init.lua @@ -64,9 +64,17 @@ function M.create(session_config, filetype, on_ready) vim.cmd(split_cmd) modified_win = vim.api.nvim_get_current_win() + -- Create separate scratch buffers for each window (so initial_buf can be deleted) + local orig_scratch = vim.api.nvim_create_buf(false, true) + local mod_scratch = vim.api.nvim_create_buf(false, true) + vim.bo[orig_scratch].buftype = 'nofile' + vim.bo[mod_scratch].buftype = 'nofile' + vim.api.nvim_win_set_buf(original_win, orig_scratch) + vim.api.nvim_win_set_buf(modified_win, mod_scratch) + -- Create placeholder buffer info (will be updated by explorer) - original_info = { bufnr = vim.api.nvim_win_get_buf(original_win) } - modified_info = { bufnr = vim.api.nvim_win_get_buf(modified_win) } + original_info = { bufnr = orig_scratch } + modified_info = { bufnr = mod_scratch } else -- Normal mode: Full buffer setup local original_is_virtual = is_virtual_revision(session_config.original_revision) @@ -520,6 +528,13 @@ function M.update(tabpage, session_config, auto_scroll_to_first_hunk) lifecycle.set_result(tabpage, nil, nil) end + -- Detect placeholder mode BEFORE loading buffers (so we can capture the scratch buffer) + local restoring_from_placeholder = vim.api.nvim_win_is_valid(original_win) and vim.w[original_win].codediff_placeholder + local scratch_buf_to_delete = nil + if restoring_from_placeholder then + scratch_buf_to_delete = vim.api.nvim_win_get_buf(original_win) + end + -- Determine if new buffers are virtual local original_is_virtual = is_virtual_revision(session_config.original_revision) local modified_is_virtual = is_virtual_revision(session_config.modified_revision) @@ -783,25 +798,42 @@ function M.update(tabpage, session_config, auto_scroll_to_first_hunk) -- Update lifecycle session metadata lifecycle.update_paths(tabpage, session_config.original_path, session_config.modified_path) - -- Delete old virtual buffers if they were virtual AND are not reused in either new window - if lifecycle.is_original_virtual(tabpage) and old_original_buf ~= original_info.bufnr and old_original_buf ~= modified_info.bufnr then + -- Delete old buffers if they were virtual OR scratch buffers (not reused in new windows) + local function should_delete_old_buffer(bufnr, is_virtual) + if not vim.api.nvim_buf_is_valid(bufnr) then return false end + if bufnr == original_info.bufnr or bufnr == modified_info.bufnr then return false end + -- Delete if virtual OR scratch buffer (buftype='nofile') + return is_virtual or vim.bo[bufnr].buftype == 'nofile' + end + + if should_delete_old_buffer(old_original_buf, lifecycle.is_original_virtual(tabpage)) then pcall(vim.api.nvim_buf_delete, old_original_buf, { force = true }) end - if lifecycle.is_modified_virtual(tabpage) and old_modified_buf ~= modified_info.bufnr and old_modified_buf ~= original_info.bufnr then + if old_modified_buf ~= old_original_buf and should_delete_old_buffer(old_modified_buf, lifecycle.is_modified_virtual(tabpage)) then pcall(vim.api.nvim_buf_delete, old_modified_buf, { force = true }) end -- Restore window widths if coming from untracked file view (placeholder mode) - if vim.api.nvim_win_is_valid(original_win) and vim.w[original_win].codediff_placeholder then + if restoring_from_placeholder then vim.w[original_win].codediff_placeholder = nil -- Clear the skip autocmd group pcall(vim.api.nvim_del_augroup_by_name, 'codediff_skip_placeholder_' .. tabpage) + -- Equalize diff window widths local total_width = vim.api.nvim_win_get_width(original_win) + vim.api.nvim_win_get_width(modified_win) local half_width = math.floor(total_width / 2) vim.api.nvim_win_set_width(original_win, half_width) vim.api.nvim_win_set_width(modified_win, half_width) + + -- Schedule cleanup of the scratch buffer (captured earlier before buffer loading) + if scratch_buf_to_delete and vim.api.nvim_buf_is_valid(scratch_buf_to_delete) and vim.bo[scratch_buf_to_delete].buftype == 'nofile' then + vim.schedule(function() + if vim.api.nvim_buf_is_valid(scratch_buf_to_delete) then + pcall(vim.api.nvim_buf_delete, scratch_buf_to_delete, { force = true }) + end + end) + end end -- Update session with new buffer/window IDs From 6db451cd379a7e62674adde63c3f30b38fb11d1b Mon Sep 17 00:00:00 2001 From: Christian De Santis Date: Wed, 7 Jan 2026 19:35:30 -0400 Subject: [PATCH 07/16] revert: undo problematic buffer cleanup changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep the explorer scratch buffer fix (prevents empty buffer accumulation) but revert the changes to view.update that broke untracked→tracked file switching. --- lua/codediff/ui/view/init.lua | 36 ++++++++--------------------------- 1 file changed, 8 insertions(+), 28 deletions(-) diff --git a/lua/codediff/ui/view/init.lua b/lua/codediff/ui/view/init.lua index b0bab084..3469f664 100644 --- a/lua/codediff/ui/view/init.lua +++ b/lua/codediff/ui/view/init.lua @@ -528,13 +528,6 @@ function M.update(tabpage, session_config, auto_scroll_to_first_hunk) lifecycle.set_result(tabpage, nil, nil) end - -- Detect placeholder mode BEFORE loading buffers (so we can capture the scratch buffer) - local restoring_from_placeholder = vim.api.nvim_win_is_valid(original_win) and vim.w[original_win].codediff_placeholder - local scratch_buf_to_delete = nil - if restoring_from_placeholder then - scratch_buf_to_delete = vim.api.nvim_win_get_buf(original_win) - end - -- Determine if new buffers are virtual local original_is_virtual = is_virtual_revision(session_config.original_revision) local modified_is_virtual = is_virtual_revision(session_config.modified_revision) @@ -798,42 +791,29 @@ function M.update(tabpage, session_config, auto_scroll_to_first_hunk) -- Update lifecycle session metadata lifecycle.update_paths(tabpage, session_config.original_path, session_config.modified_path) - -- Delete old buffers if they were virtual OR scratch buffers (not reused in new windows) - local function should_delete_old_buffer(bufnr, is_virtual) - if not vim.api.nvim_buf_is_valid(bufnr) then return false end - if bufnr == original_info.bufnr or bufnr == modified_info.bufnr then return false end - -- Delete if virtual OR scratch buffer (buftype='nofile') - return is_virtual or vim.bo[bufnr].buftype == 'nofile' - end - - if should_delete_old_buffer(old_original_buf, lifecycle.is_original_virtual(tabpage)) then + -- Delete old virtual buffers if they were virtual AND are not reused in either new window + if lifecycle.is_original_virtual(tabpage) and + old_original_buf ~= original_info.bufnr and + old_original_buf ~= modified_info.bufnr then pcall(vim.api.nvim_buf_delete, old_original_buf, { force = true }) end - if old_modified_buf ~= old_original_buf and should_delete_old_buffer(old_modified_buf, lifecycle.is_modified_virtual(tabpage)) then + if lifecycle.is_modified_virtual(tabpage) and + old_modified_buf ~= modified_info.bufnr and + old_modified_buf ~= original_info.bufnr then pcall(vim.api.nvim_buf_delete, old_modified_buf, { force = true }) end -- Restore window widths if coming from untracked file view (placeholder mode) - if restoring_from_placeholder then + if vim.api.nvim_win_is_valid(original_win) and vim.w[original_win].codediff_placeholder then vim.w[original_win].codediff_placeholder = nil -- Clear the skip autocmd group pcall(vim.api.nvim_del_augroup_by_name, 'codediff_skip_placeholder_' .. tabpage) - -- Equalize diff window widths local total_width = vim.api.nvim_win_get_width(original_win) + vim.api.nvim_win_get_width(modified_win) local half_width = math.floor(total_width / 2) vim.api.nvim_win_set_width(original_win, half_width) vim.api.nvim_win_set_width(modified_win, half_width) - - -- Schedule cleanup of the scratch buffer (captured earlier before buffer loading) - if scratch_buf_to_delete and vim.api.nvim_buf_is_valid(scratch_buf_to_delete) and vim.bo[scratch_buf_to_delete].buftype == 'nofile' then - vim.schedule(function() - if vim.api.nvim_buf_is_valid(scratch_buf_to_delete) then - pcall(vim.api.nvim_buf_delete, scratch_buf_to_delete, { force = true }) - end - end) - end end -- Update session with new buffer/window IDs From b6165c1fcda84c26102531265eaeeedfa401ee37 Mon Sep 17 00:00:00 2001 From: Christian De Santis Date: Wed, 7 Jan 2026 19:45:07 -0400 Subject: [PATCH 08/16] fix: restore window widths before loading buffers The :edit! command to load virtual files can fail in a 1-column window. Move the placeholder restoration code (window resizing) to run BEFORE buffer loading, so the window is a proper size when loading content. --- lua/codediff/ui/view/init.lua | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/lua/codediff/ui/view/init.lua b/lua/codediff/ui/view/init.lua index 3469f664..2fb4a8a9 100644 --- a/lua/codediff/ui/view/init.lua +++ b/lua/codediff/ui/view/init.lua @@ -528,6 +528,19 @@ function M.update(tabpage, session_config, auto_scroll_to_first_hunk) lifecycle.set_result(tabpage, nil, nil) end + -- IMPORTANT: Restore window widths BEFORE loading buffers + -- Loading virtual files with :edit! in a 1-column window can fail + if vim.api.nvim_win_is_valid(original_win) and vim.w[original_win].codediff_placeholder then + vim.w[original_win].codediff_placeholder = nil + -- Clear the skip autocmd group + pcall(vim.api.nvim_del_augroup_by_name, 'codediff_skip_placeholder_' .. tabpage) + -- Equalize diff window widths BEFORE loading buffers + local total_width = vim.api.nvim_win_get_width(original_win) + vim.api.nvim_win_get_width(modified_win) + local half_width = math.floor(total_width / 2) + vim.api.nvim_win_set_width(original_win, half_width) + vim.api.nvim_win_set_width(modified_win, half_width) + end + -- Determine if new buffers are virtual local original_is_virtual = is_virtual_revision(session_config.original_revision) local modified_is_virtual = is_virtual_revision(session_config.modified_revision) @@ -804,18 +817,6 @@ function M.update(tabpage, session_config, auto_scroll_to_first_hunk) pcall(vim.api.nvim_buf_delete, old_modified_buf, { force = true }) end - -- Restore window widths if coming from untracked file view (placeholder mode) - if vim.api.nvim_win_is_valid(original_win) and vim.w[original_win].codediff_placeholder then - vim.w[original_win].codediff_placeholder = nil - -- Clear the skip autocmd group - pcall(vim.api.nvim_del_augroup_by_name, 'codediff_skip_placeholder_' .. tabpage) - -- Equalize diff window widths - local total_width = vim.api.nvim_win_get_width(original_win) + vim.api.nvim_win_get_width(modified_win) - local half_width = math.floor(total_width / 2) - vim.api.nvim_win_set_width(original_win, half_width) - vim.api.nvim_win_set_width(modified_win, half_width) - end - -- Update session with new buffer/window IDs -- Note: We need to update lifecycle to support this, or recreate session -- For now, we'll update the stored diff result and metadata From 4e5d57b6101404d455af471cba8507e5328f47eb Mon Sep 17 00:00:00 2001 From: Christian De Santis Date: Thu, 29 Jan 2026 23:45:50 -0400 Subject: [PATCH 09/16] fix: handle git c-quoted paths for filenames with spaces --- lua/codediff/core/git.lua | 75 +++++++++++++++++++++++---------------- 1 file changed, 44 insertions(+), 31 deletions(-) diff --git a/lua/codediff/core/git.lua b/lua/codediff/core/git.lua index 5004bd60..25c1a13b 100644 --- a/lua/codediff/core/git.lua +++ b/lua/codediff/core/git.lua @@ -2,6 +2,19 @@ -- All operations are async and atomic local M = {} +-- Unquote git C-quoted paths (e.g., "my file.md" -> my file.md) +local function unquote_path(path) + if path:sub(1, 1) == '"' and path:sub(-1) == '"' then + local unquoted = path:sub(2, -2) + unquoted = unquoted:gsub('\\(.)', function(char) + local escapes = { a = '\a', b = '\b', t = '\t', n = '\n', v = '\v', f = '\f', r = '\r', ['\\'] = '\\', ['"'] = '"' } + return escapes[char] or char + end) + return unquoted + end + return path +end + -- LRU Cache for git file content -- Stores recently fetched file content to avoid redundant git calls local ContentCache = {} @@ -340,7 +353,7 @@ function M.get_status(git_root, callback) if #line >= 3 then local index_status = line:sub(1, 1) local worktree_status = line:sub(2, 2) - local path_part = line:sub(4) + local path_part = unquote_path(line:sub(4)) -- Handle renames: "old_path -> new_path" local old_path, new_path = path_part:match("^(.+) %-> (.+)$") @@ -398,19 +411,19 @@ function M.get_diff_revision(revision, git_root, callback) staged = {}, } - for line in output:gmatch("[^\r\n]+") do - if #line > 0 then - local parts = vim.split(line, "\t") - if #parts >= 2 then - local status = parts[1]:sub(1, 1) - local path = parts[2] - local old_path = nil - - -- Handle renames (R100 or similar) - if status == "R" and #parts >= 3 then - old_path = parts[2] - path = parts[3] - end + for line in output:gmatch("[^\r\n]+") do + if #line > 0 then + local parts = vim.split(line, "\t") + if #parts >= 2 then + local status = parts[1]:sub(1, 1) + local path = unquote_path(parts[2]) + local old_path = nil + + -- Handle renames (R100 or similar) + if status == "R" and #parts >= 3 then + old_path = unquote_path(parts[2]) + path = unquote_path(parts[3]) + end table.insert(result.unstaged, { path = path, @@ -462,23 +475,23 @@ function M.get_diff_revisions(rev1, rev2, git_root, callback) staged = {}, } - -- For revision comparison, we treat everything as "unstaged" for explorer compatibility - -- But to keep explorer compatible, we'll put them in 'staged' as they are committed changes - -- relative to each other. - - for line in output:gmatch("[^\r\n]+") do - if #line > 0 then - local parts = vim.split(line, "\t") - if #parts >= 2 then - local status = parts[1]:sub(1, 1) - local path = parts[2] - local old_path = nil - - -- Handle renames (R100 or similar) - if status == "R" and #parts >= 3 then - old_path = parts[2] - path = parts[3] - end + -- For revision comparison, we treat everything as "unstaged" for explorer compatibility + -- But to keep explorer compatible, we'll put them in 'staged' as they are committed changes + -- relative to each other. + + for line in output:gmatch("[^\r\n]+") do + if #line > 0 then + local parts = vim.split(line, "\t") + if #parts >= 2 then + local status = parts[1]:sub(1, 1) + local path = unquote_path(parts[2]) + local old_path = nil + + -- Handle renames (R100 or similar) + if status == "R" and #parts >= 3 then + old_path = unquote_path(parts[2]) + path = unquote_path(parts[3]) + end table.insert(result.unstaged, { path = path, From 5e3548841465fa734bf261ac831f434a8a6dea68 Mon Sep 17 00:00:00 2001 From: Christian De Santis Date: Thu, 29 Jan 2026 23:46:01 -0400 Subject: [PATCH 10/16] fix: pass buffer to filetype detection for virtual files --- lua/codediff/core/virtual_file.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lua/codediff/core/virtual_file.lua b/lua/codediff/core/virtual_file.lua index 1d06f476..d63472ea 100644 --- a/lua/codediff/core/virtual_file.lua +++ b/lua/codediff/core/virtual_file.lua @@ -50,7 +50,8 @@ local function load_virtual_buffer_content(buf, git_root, commit, filepath) vim.bo[buf].readonly = true -- Detect filetype from the original file path (for TreeSitter only) - local ft = vim.filetype.match({ filename = filepath }) + -- Pass buf for proper detection of some filetypes like .ts + local ft = vim.filetype.match({ filename = filepath, buf = buf }) if ft then vim.bo[buf].filetype = ft end From f6969659052846fe81b8b42dc05ff109fe03dba9 Mon Sep 17 00:00:00 2001 From: Christian De Santis Date: Thu, 29 Jan 2026 23:46:16 -0400 Subject: [PATCH 11/16] fix: re-apply quit keymap in single-pane mode for untracked files --- lua/codediff/ui/explorer/render.lua | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lua/codediff/ui/explorer/render.lua b/lua/codediff/ui/explorer/render.lua index 34469e10..b8ad854b 100644 --- a/lua/codediff/ui/explorer/render.lua +++ b/lua/codediff/ui/explorer/render.lua @@ -336,6 +336,12 @@ function M.create(status_result, git_root, tabpage, width, base_revision, target lifecycle.update_paths(tabpage, "", abs_path) lifecycle.update_revisions(tabpage, nil, nil) lifecycle.update_diff_result(tabpage, {}) -- Empty diff for untracked + + -- Re-apply keymaps (q to quit, etc.) on the new buffer + lifecycle.set_tab_keymap(tabpage, 'n', + require('codediff.config').options.keymaps.view.quit, + function() vim.cmd('tabclose') end, + { desc = 'Close diff view' }) end end end) From 73341642e3379b92fd8f3d903404685756abef40 Mon Sep 17 00:00:00 2001 From: Christian De Santis Date: Thu, 29 Jan 2026 23:46:32 -0400 Subject: [PATCH 12/16] feat: add hunk-level staging and unstaging --- lua/codediff/config.lua | 2 + lua/codediff/core/git.lua | 82 +++++++++++ lua/codediff/ui/view/keymaps.lua | 227 +++++++++++++++++++++++++++++++ 3 files changed, 311 insertions(+) diff --git a/lua/codediff/config.lua b/lua/codediff/config.lua index a87ce23e..d56a2356 100644 --- a/lua/codediff/config.lua +++ b/lua/codediff/config.lua @@ -75,6 +75,8 @@ M.defaults = { diff_put = "dp", -- Put change to other buffer (like vimdiff) open_in_prev_tab = "gf", -- Open current buffer in previous tab (or new tab before current) toggle_stage = "-", -- Stage/unstage current file (works in explorer and diff buffers) + stage_hunk = "S", -- Stage the hunk under cursor to git index + unstage_hunk = "U", -- Unstage the hunk under cursor from git index }, explorer = { select = "", diff --git a/lua/codediff/core/git.lua b/lua/codediff/core/git.lua index 25c1a13b..6bcf5686 100644 --- a/lua/codediff/core/git.lua +++ b/lua/codediff/core/git.lua @@ -506,6 +506,88 @@ function M.get_diff_revisions(rev1, rev2, git_root, callback) end) end +-- Apply a unified diff patch to the git index (async) +-- Used for hunk-level staging: generates a patch for a single hunk and applies it +-- to the index without touching the working tree. +-- +-- git_root: absolute path to git repository root +-- patch: string containing a valid unified diff patch +-- reverse: if true, reverse-apply the patch (used for unstaging) +-- callback: function(err) - nil err on success +function M.apply_patch(git_root, patch, reverse, callback) + local args = { "apply", "--cached", "--unidiff-zero", "-" } + if reverse then + table.insert(args, 3, "--reverse") + end + + if vim.system then + if git_root and vim.fn.isdirectory(git_root) == 0 then + callback("Directory does not exist: " .. git_root) + return + end + + vim.system( + vim.list_extend({ "git" }, args), + { + cwd = git_root, + stdin = patch, + text = true, + }, + function(result) + vim.schedule(function() + if result.code == 0 then + callback(nil) + else + callback(result.stderr or "git apply failed") + end + end) + end + ) + else + -- Fallback for older Neovim (< 0.10) + local stderr_data = {} + local stdin_pipe = vim.loop.new_pipe(false) + local stderr_pipe = vim.loop.new_pipe(false) + + local handle + ---@diagnostic disable-next-line: missing-fields + handle = vim.loop.spawn("git", { + args = args, + cwd = git_root, + stdio = { stdin_pipe, nil, stderr_pipe }, + }, function(code) + if stdin_pipe then stdin_pipe:close() end + if stderr_pipe then stderr_pipe:close() end + if handle then handle:close() end + + vim.schedule(function() + if code == 0 then + callback(nil) + else + callback(table.concat(stderr_data) or "git apply failed") + end + end) + end) + + if not handle then + callback("Failed to spawn git process") + return + end + + if stderr_pipe then + stderr_pipe:read_start(function(err, data) + if data then + table.insert(stderr_data, data) + end + end) + end + + -- Write patch to stdin and close + stdin_pipe:write(patch) + stdin_pipe:shutdown() + end +end + -- Run a git command synchronously -- Returns output string or nil on error local function run_git_sync(args, opts) diff --git a/lua/codediff/ui/view/keymaps.lua b/lua/codediff/ui/view/keymaps.lua index 34795bb2..1805b91d 100644 --- a/lua/codediff/ui/view/keymaps.lua +++ b/lua/codediff/ui/view/keymaps.lua @@ -417,6 +417,225 @@ function M.setup_all_keymaps(tabpage, original_bufnr, modified_bufnr, is_explore pcall(vim.api.nvim_win_set_cursor, target_win, cursor) end + -- ======================================================================== + -- Hunk-level staging (S, U) + -- Generates a unified diff patch for the hunk under cursor and applies it + -- to the git index via `git apply --cached --unidiff-zero`. + -- Stage (S): applies the hunk's changes to the index (working → staged) + -- Unstage (U): reverse-applies to remove the hunk from the index + -- ======================================================================== + + --- Build a minimal unified diff patch string for a single hunk. + --- The patch has no context lines (used with --unidiff-zero). + --- @param file_path string relative path from git root + --- @param orig_lines string[] lines from the original (HEAD) buffer for this hunk + --- @param mod_lines string[] lines from the modified (working/staged) buffer for this hunk + --- @param orig_start number 1-based start line in original file + --- @param mod_start number 1-based start line in modified file + --- @return string patch valid unified diff patch + local function build_hunk_patch(file_path, orig_lines, mod_lines, orig_start, mod_start) + local orig_count = #orig_lines + local mod_count = #mod_lines + + -- For pure insertions with 0 original lines, git expects start to be + -- the line AFTER which content is inserted (0 if at very start) + local hdr_orig_start = orig_count == 0 and (orig_start > 0 and orig_start - 1 or 0) or orig_start + local hdr_mod_start = mod_count == 0 and (mod_start > 0 and mod_start - 1 or 0) or mod_start + + local parts = { + string.format("--- a/%s", file_path), + string.format("+++ b/%s", file_path), + string.format("@@ -%d,%d +%d,%d @@", hdr_orig_start, orig_count, hdr_mod_start, mod_count), + } + + for _, line in ipairs(orig_lines) do + table.insert(parts, "-" .. line) + end + for _, line in ipairs(mod_lines) do + table.insert(parts, "+" .. line) + end + + -- Patch must end with a newline + return table.concat(parts, "\n") .. "\n" + end + + -- Helper: Stage hunk under cursor to git index + local function stage_hunk() + local session = lifecycle.get_session(tabpage) + if not session or not session.git_root then + vim.notify("Not in a git repository", vim.log.levels.WARN) + return + end + + local hunk, hunk_idx = find_hunk_at_cursor() + if not hunk then + vim.notify("No hunk at cursor position", vim.log.levels.WARN) + return + end + + -- Get the file path relative to git root + local file_path = session.original_path or session.modified_path + if not file_path or file_path == "" then + vim.notify("No file path for staging", vim.log.levels.WARN) + return + end + + -- Read lines from both buffers for this hunk + local orig_lines = vim.api.nvim_buf_get_lines( + original_bufnr, + hunk.original.start_line - 1, + hunk.original.end_line - 1, + false + ) + local mod_lines = vim.api.nvim_buf_get_lines( + modified_bufnr, + hunk.modified.start_line - 1, + hunk.modified.end_line - 1, + false + ) + + local patch = build_hunk_patch(file_path, orig_lines, mod_lines, + hunk.original.start_line, hunk.modified.start_line) + + -- Capture hunk count before async call (stored_diff_result may change) + local total_hunks = session.stored_diff_result and #session.stored_diff_result.changes or 0 + local is_unstaged_view = session.modified_revision == nil + + local git = require('codediff.core.git') + git.apply_patch(session.git_root, patch, false, function(err) + if err then + vim.notify("Failed to stage hunk: " .. err, vim.log.levels.ERROR) + return + end + + -- Refresh explorer to reflect staging change + local explorer_obj = lifecycle.get_explorer(tabpage) + if explorer_obj then + local explorer = require('codediff.ui.explorer') + explorer.refresh(explorer_obj) + end + + if total_hunks == 1 and is_unstaged_view and explorer_obj and explorer_obj.on_file_select then + -- Last unstaged hunk staged: switch to staged view + explorer_obj.on_file_select({ + path = file_path, + group = "staged", + status = "M", + }) + vim.notify("All hunks staged — switched to staged view", vim.log.levels.INFO) + else + vim.notify(string.format("Staged hunk %d", hunk_idx), vim.log.levels.INFO) + + -- Refresh diff view: reload virtual buffers and recompute diff + -- For unstaged views where original was HEAD, switch to :0 (index) + -- so the staged hunk disappears from the diff (matches VS Code behavior) + local view = require('codediff.ui.view') + local refresh_config = { + mode = session.mode, + git_root = session.git_root, + original_path = session.original_path, + modified_path = session.modified_path, + original_revision = session.original_revision, + modified_revision = session.modified_revision, + } + if is_unstaged_view and session.original_revision ~= ":0" then + refresh_config.original_revision = ":0" + end + -- Save current window so view.update() doesn't move cursor + -- (creating a new virtual buffer uses :edit! which switches window) + local current_win = vim.api.nvim_get_current_win() + view.update(tabpage, refresh_config, false) + if vim.api.nvim_win_is_valid(current_win) then + vim.api.nvim_set_current_win(current_win) + end + end + end) + end + + -- Helper: Unstage hunk under cursor from git index + local function unstage_hunk() + local session = lifecycle.get_session(tabpage) + if not session or not session.git_root then + vim.notify("Not in a git repository", vim.log.levels.WARN) + return + end + + local hunk, hunk_idx = find_hunk_at_cursor() + if not hunk then + vim.notify("No hunk at cursor position", vim.log.levels.WARN) + return + end + + local file_path = session.original_path or session.modified_path + if not file_path or file_path == "" then + vim.notify("No file path for unstaging", vim.log.levels.WARN) + return + end + + -- Read lines from both buffers for this hunk + local orig_lines = vim.api.nvim_buf_get_lines( + original_bufnr, + hunk.original.start_line - 1, + hunk.original.end_line - 1, + false + ) + local mod_lines = vim.api.nvim_buf_get_lines( + modified_bufnr, + hunk.modified.start_line - 1, + hunk.modified.end_line - 1, + false + ) + + local patch = build_hunk_patch(file_path, orig_lines, mod_lines, + hunk.original.start_line, hunk.modified.start_line) + + -- Capture hunk count before async call (stored_diff_result may change) + local total_hunks = session.stored_diff_result and #session.stored_diff_result.changes or 0 + local is_staged_view = session.modified_revision == ":0" + + local git = require('codediff.core.git') + git.apply_patch(session.git_root, patch, true, function(err) + if err then + vim.notify("Failed to unstage hunk: " .. err, vim.log.levels.ERROR) + return + end + + -- Refresh explorer to reflect unstaging change + local explorer_obj = lifecycle.get_explorer(tabpage) + if explorer_obj then + local explorer = require('codediff.ui.explorer') + explorer.refresh(explorer_obj) + end + + if total_hunks == 1 and is_staged_view and explorer_obj and explorer_obj.on_file_select then + -- Last staged hunk unstaged: switch to unstaged view + explorer_obj.on_file_select({ + path = file_path, + group = "unstaged", + status = "M", + }) + vim.notify("All hunks unstaged — switched to unstaged view", vim.log.levels.INFO) + else + vim.notify(string.format("Unstaged hunk %d", hunk_idx), vim.log.levels.INFO) + + -- Refresh diff view: reload virtual buffers and recompute diff + local view = require('codediff.ui.view') + local current_win = vim.api.nvim_get_current_win() + view.update(tabpage, { + mode = session.mode, + git_root = session.git_root, + original_path = session.original_path, + modified_path = session.modified_path, + original_revision = session.original_revision, + modified_revision = session.modified_revision, + }, false) + if vim.api.nvim_win_is_valid(current_win) then + vim.api.nvim_set_current_win(current_win) + end + end + end) + end + -- ======================================================================== -- Bind all keymaps using unified API (one place for all keymaps!) -- ======================================================================== @@ -478,6 +697,14 @@ function M.setup_all_keymaps(tabpage, original_bufnr, modified_bufnr, is_explore lifecycle.set_tab_keymap(tabpage, "n", keymaps.prev_file, navigate_prev_file, { desc = "Previous file" }) end end + + -- Hunk-level staging (S, U) - stage/unstage individual hunks via git apply + if keymaps.stage_hunk then + lifecycle.set_tab_keymap(tabpage, 'n', keymaps.stage_hunk, stage_hunk, { desc = 'Stage hunk under cursor' }) + end + if keymaps.unstage_hunk then + lifecycle.set_tab_keymap(tabpage, 'n', keymaps.unstage_hunk, unstage_hunk, { desc = 'Unstage hunk under cursor' }) + end end return M From 136eb381ed6b71357de397e5751fd3f214f2d87f Mon Sep 17 00:00:00 2001 From: Christian De Santis Date: Thu, 29 Jan 2026 23:47:59 -0400 Subject: [PATCH 13/16] feat: add explorer file actions (stage, unstage, discard, open, focus) --- lua/codediff/config.lua | 8 +- lua/codediff/ui/explorer/actions.lua | 84 +++++++++++ lua/codediff/ui/explorer/init.lua | 3 + lua/codediff/ui/explorer/render.lua | 212 ++++++++++++++++++++++++--- 4 files changed, 284 insertions(+), 23 deletions(-) diff --git a/lua/codediff/config.lua b/lua/codediff/config.lua index d56a2356..a812a3d9 100644 --- a/lua/codediff/config.lua +++ b/lua/codediff/config.lua @@ -80,9 +80,15 @@ M.defaults = { }, explorer = { select = "", + open = "o", -- Alias for select (open file or toggle group) + focus_file = "l", -- Jump to modified pane if file is open, otherwise open file hover = "K", refresh = "R", - toggle_view_mode = "i", -- Toggle between 'list' and 'tree' views + toggle_view_mode = "i", -- Toggle between 'list' and 'tree' views + stage_file = "a", -- Stage file under cursor (git add) + stage_file_alt = "s", -- Alternative binding for stage + unstage_file = "u", -- Unstage file under cursor (git restore --staged) + discard_file = "d", -- Discard changes or delete untracked (with confirmation) stage_all = "S", -- Stage all files unstage_all = "U", -- Unstage all files restore = "X", -- Discard changes to file (restore to index/HEAD) diff --git a/lua/codediff/ui/explorer/actions.lua b/lua/codediff/ui/explorer/actions.lua index a796ab89..9775cae2 100644 --- a/lua/codediff/ui/explorer/actions.lua +++ b/lua/codediff/ui/explorer/actions.lua @@ -165,6 +165,90 @@ function M.toggle_visibility(explorer) end end +-- Stage file under cursor (git add) +function M.stage_file(explorer, node_data) + if not explorer or not explorer.git_root then return end + if not node_data or not node_data.path then + vim.notify("No file under cursor", vim.log.levels.WARN) + return + end + + local path = node_data.path + vim.system({'git', '-C', explorer.git_root, 'add', '--', path}, {}, function(result) + vim.schedule(function() + if result.code == 0 then + vim.notify("Staged: " .. path, vim.log.levels.INFO) + refresh_module.refresh(explorer) + else + vim.notify("Failed to stage: " .. path, vim.log.levels.ERROR) + end + end) + end) +end + +-- Unstage file under cursor (git restore --staged) +function M.unstage_file(explorer, node_data) + if not explorer or not explorer.git_root then return end + if not node_data or not node_data.path then + vim.notify("No file under cursor", vim.log.levels.WARN) + return + end + + local path = node_data.path + vim.system({'git', '-C', explorer.git_root, 'restore', '--staged', '--', path}, {}, function(result) + vim.schedule(function() + if result.code == 0 then + vim.notify("Unstaged: " .. path, vim.log.levels.INFO) + refresh_module.refresh(explorer) + else + vim.notify("Failed to unstage: " .. path, vim.log.levels.ERROR) + end + end) + end) +end + +-- Discard file changes or delete untracked file (with confirmation) +function M.discard_file(explorer, node_data) + if not explorer or not explorer.git_root then return end + if not node_data or not node_data.path then + vim.notify("No file under cursor", vim.log.levels.WARN) + return + end + + local path = node_data.path + local short = vim.fn.fnamemodify(path, ':t') + local is_untracked = node_data.status == '??' + local prompt = is_untracked + and ('Delete untracked file ' .. short .. '? This cannot be undone.') + or ('Discard changes to ' .. short .. '? This cannot be undone.') + + vim.ui.select({ 'Yes', 'No' }, { prompt = prompt }, function(choice) + if choice ~= 'Yes' then return end + + if is_untracked then + local abs_path = explorer.git_root .. '/' .. path + local ok, err = os.remove(abs_path) + if ok then + vim.notify('Deleted: ' .. path, vim.log.levels.INFO) + refresh_module.refresh(explorer) + else + vim.notify('Failed to delete: ' .. (err or path), vim.log.levels.ERROR) + end + else + vim.system({'git', '-C', explorer.git_root, 'checkout', 'HEAD', '--', path}, {}, function(result) + vim.schedule(function() + if result.code == 0 then + vim.notify('Discarded: ' .. path, vim.log.levels.INFO) + refresh_module.refresh(explorer) + else + vim.notify('Failed to discard: ' .. path, vim.log.levels.ERROR) + end + end) + end) + end + end) +end + -- Toggle view mode between 'list' and 'tree' function M.toggle_view_mode(explorer) if not explorer then diff --git a/lua/codediff/ui/explorer/init.lua b/lua/codediff/ui/explorer/init.lua index f9fc0cbb..b72b943e 100644 --- a/lua/codediff/ui/explorer/init.lua +++ b/lua/codediff/ui/explorer/init.lua @@ -35,6 +35,9 @@ M.navigate_next = actions.navigate_next M.navigate_prev = actions.navigate_prev M.toggle_visibility = actions.toggle_visibility M.toggle_view_mode = actions.toggle_view_mode +M.stage_file = actions.stage_file +M.unstage_file = actions.unstage_file +M.discard_file = actions.discard_file M.toggle_stage_entry = actions.toggle_stage_entry M.toggle_stage_file = actions.toggle_stage_file M.stage_all = actions.stage_all diff --git a/lua/codediff/ui/explorer/render.lua b/lua/codediff/ui/explorer/render.lua index b8ad854b..e089316a 100644 --- a/lua/codediff/ui/explorer/render.lua +++ b/lua/codediff/ui/explorer/render.lua @@ -508,35 +508,203 @@ function M.create(status_result, git_root, tabpage, width, base_revision, target return f, "conflicts" end end - end - for _, f in ipairs(status_result.unstaged) do - if f.path == path then - return f, "unstaged" + end, vim.tbl_extend("force", map_options, { buffer = split.bufnr })) + end + + -- Double click also works for files + vim.keymap.set("n", "<2-LeftMouse>", function() + local node = tree:get_node() + if not node or not node.data or node.data.type == "group" or node.data.type == "directory" then return end + explorer.on_file_select(node.data) + end, vim.tbl_extend("force", map_options, { buffer = split.bufnr })) + + -- Close explorer (disabled) + -- vim.keymap.set("n", "q", function() + -- split:unmount() + -- end, vim.tbl_extend("force", map_options, { buffer = split.bufnr })) + + -- Hover to show full path (K key, like LSP hover) + local hover_win = nil + if config.options.keymaps.explorer.hover then + vim.keymap.set("n", config.options.keymaps.explorer.hover, function() + -- Close existing hover window + if hover_win and vim.api.nvim_win_is_valid(hover_win) then + vim.api.nvim_win_close(hover_win, true) + hover_win = nil + return end - end - for _, f in ipairs(status_result.staged) do - if f.path == path then - return f, "staged" + + local node = tree:get_node() + if not node or not node.data or node.data.type == "group" then return end + + local full_path = node.data.path + local display_text = git_root .. "/" .. full_path + + -- Create hover buffer + local hover_buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(hover_buf, 0, -1, false, { display_text }) + vim.bo[hover_buf].modifiable = false + + -- Calculate window position (next to cursor) + local cursor = vim.api.nvim_win_get_cursor(0) + local row = cursor[1] - 1 + local col = vim.api.nvim_win_get_width(0) + + -- Calculate window dimensions with wrapping + local max_width = 80 + local text_len = #display_text + local width = math.min(text_len + 2, max_width) + local height = math.ceil(text_len / (max_width - 2)) -- Account for padding + + -- Create floating window with wrap enabled + hover_win = vim.api.nvim_open_win(hover_buf, false, { + relative = "win", + row = row, + col = col, + width = width, + height = height, + style = "minimal", + border = "rounded", + }) + + -- Enable wrap in hover window + vim.wo[hover_win].wrap = true + + -- Auto-close on cursor move or buffer leave + vim.api.nvim_create_autocmd({"CursorMoved", "BufLeave"}, { + buffer = split.bufnr, + once = true, + callback = function() + if hover_win and vim.api.nvim_win_is_valid(hover_win) then + vim.api.nvim_win_close(hover_win, true) + hover_win = nil + end + end, + }) + end, vim.tbl_extend("force", map_options, { buffer = split.bufnr })) + end + + -- Refresh explorer (R key) + if config.options.keymaps.explorer.refresh then + vim.keymap.set("n", config.options.keymaps.explorer.refresh, function() + refresh_module.refresh(explorer) + end, vim.tbl_extend("force", map_options, { buffer = split.bufnr })) + end + + -- Toggle view mode (i key) - switch between 'list' and 'tree' + if config.options.keymaps.explorer.toggle_view_mode then + vim.keymap.set("n", config.options.keymaps.explorer.toggle_view_mode, function() + actions_module.toggle_view_mode(explorer) + end, vim.tbl_extend("force", map_options, { buffer = split.bufnr })) + end + + -- Navigate to next file + if config.options.keymaps.view.next_file then + vim.keymap.set("n", config.options.keymaps.view.next_file, function() + M.navigate_next(explorer) + end, vim.tbl_extend("force", map_options, { buffer = split.bufnr })) + end + + -- Navigate to previous file + if config.options.keymaps.view.prev_file then + vim.keymap.set("n", config.options.keymaps.view.prev_file, function() + M.navigate_prev(explorer) + end, vim.tbl_extend("force", map_options, { buffer = split.bufnr })) + end + + -- Open (alias for select) + if config.options.keymaps.explorer.open then + vim.keymap.set("n", config.options.keymaps.explorer.open, function() + local node = tree:get_node() + if not node then return end + if node.data and (node.data.type == "group" or node.data.type == "directory") then + if node:is_expanded() then + node:collapse() + else + node:expand() + end + tree:render() + elseif node.data then + explorer.on_file_select(node.data) end - end - return nil, nil + end, vim.tbl_extend("force", map_options, { buffer = split.bufnr })) end - -- Select initial file: prefer focus_file (current buffer) if changed, else first file - local initial_file, initial_file_group - local focus_file = opts and opts.focus_file - if focus_file then - initial_file, initial_file_group = find_file_in_status(focus_file) + -- Focus file: jump to modified pane if file is already open, otherwise open it + if config.options.keymaps.explorer.focus_file then + vim.keymap.set("n", config.options.keymaps.explorer.focus_file, function() + local node = tree:get_node() + if not node or not node.data then return end + + -- If cursor is on the already-open file, jump to the modified (right) pane + if node.data.path and node.data.path == explorer.current_file_path + and (node.data.group or "unstaged") == explorer.current_file_group then + local lifecycle = require('codediff.ui.lifecycle') + local _, mod_win = lifecycle.get_windows(tabpage) + if mod_win and vim.api.nvim_win_is_valid(mod_win) then + vim.api.nvim_set_current_win(mod_win) + return + end + end + + -- Otherwise open the file (same as select for files, toggle for groups) + if node.data.type == "group" or node.data.type == "directory" then + if node:is_expanded() then + node:collapse() + else + node:expand() + end + tree:render() + elseif node.data.path then + explorer.on_file_select(node.data) + end + end, vim.tbl_extend("force", map_options, { buffer = split.bufnr })) end - if not initial_file then - if status_result.conflicts and #status_result.conflicts > 0 then - initial_file, initial_file_group = status_result.conflicts[1], "conflicts" - elseif #status_result.unstaged > 0 then - initial_file, initial_file_group = status_result.unstaged[1], "unstaged" - elseif #status_result.staged > 0 then - initial_file, initial_file_group = status_result.staged[1], "staged" + + -- Stage file (git add) + local function bind_stage_file(key) + if key then + vim.keymap.set("n", key, function() + local node = tree:get_node() + if not node or not node.data or not node.data.path then return end + actions_module.stage_file(explorer, node.data) + end, vim.tbl_extend("force", map_options, { buffer = split.bufnr })) end end + bind_stage_file(config.options.keymaps.explorer.stage_file) + bind_stage_file(config.options.keymaps.explorer.stage_file_alt) + + -- Unstage file (git restore --staged) + if config.options.keymaps.explorer.unstage_file then + vim.keymap.set("n", config.options.keymaps.explorer.unstage_file, function() + local node = tree:get_node() + if not node or not node.data or not node.data.path then return end + actions_module.unstage_file(explorer, node.data) + end, vim.tbl_extend("force", map_options, { buffer = split.bufnr })) + end + + -- Discard file changes (with confirmation) + if config.options.keymaps.explorer.discard_file then + vim.keymap.set("n", config.options.keymaps.explorer.discard_file, function() + local node = tree:get_node() + if not node or not node.data or not node.data.path then return end + actions_module.discard_file(explorer, node.data) + end, vim.tbl_extend("force", map_options, { buffer = split.bufnr })) + end + + -- Select first file by default (conflicts first, then unstaged, then staged) + local first_file = nil + local first_file_group = nil + if status_result.conflicts and #status_result.conflicts > 0 then + first_file = status_result.conflicts[1] + first_file_group = "conflicts" + elseif #status_result.unstaged > 0 then + first_file = status_result.unstaged[1] + first_file_group = "unstaged" + elseif #status_result.staged > 0 then + first_file = status_result.staged[1] + first_file_group = "staged" + end if initial_file then vim.defer_fn(function() From 9e0fee75930ab9946ba47c9b2242d22cd4b0feb3 Mon Sep 17 00:00:00 2001 From: Christian De Santis Date: Thu, 29 Jan 2026 23:48:14 -0400 Subject: [PATCH 14/16] docs: update readme with fork changes and new keymaps --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 14aa1965..3aad164e 100644 --- a/README.md +++ b/README.md @@ -132,15 +132,23 @@ https://github.com/user-attachments/assets/64c41f01-dffe-4318-bce4-16eec8de356e diff_put = "dp", -- Put change to other buffer (like vimdiff) open_in_prev_tab = "gf", -- Open current buffer in previous tab (or create one before) toggle_stage = "-", -- Stage/unstage current file (works in explorer and diff buffers) + stage_hunk = "S", -- Stage hunk under cursor to git index + unstage_hunk = "U", -- Unstage hunk under cursor from git index }, explorer = { select = "", -- Open diff for selected file + open = "o", -- Alias for select (open file or toggle group) + focus_file = "l", -- Jump to modified pane if file is open, otherwise open file hover = "K", -- Show file diff preview refresh = "R", -- Refresh git status toggle_view_mode = "i", -- Toggle between 'list' and 'tree' views stage_all = "S", -- Stage all files unstage_all = "U", -- Unstage all files restore = "X", -- Discard changes (restore file) + stage_file = "a", -- Stage file under cursor (git add) + stage_file_alt = "s", -- Alternative binding for stage + unstage_file = "u", -- Unstage file under cursor (git restore --staged) + discard_file = "d", -- Discard changes or delete untracked (with confirmation) }, history = { select = "", -- Select commit/file or toggle expand From ea53bffd589ce4217d00f435b62e89a5aa98bab9 Mon Sep 17 00:00:00 2001 From: Christian De Santis Date: Fri, 30 Jan 2026 01:33:31 -0400 Subject: [PATCH 15/16] fix: resolve rebase conflicts in explorer render and highlights --- lua/codediff/ui/explorer/keymaps.lua | 92 +++++++++ lua/codediff/ui/explorer/render.lua | 298 +++------------------------ lua/codediff/ui/highlights.lua | 10 +- 3 files changed, 118 insertions(+), 282 deletions(-) diff --git a/lua/codediff/ui/explorer/keymaps.lua b/lua/codediff/ui/explorer/keymaps.lua index 38564557..22fed766 100644 --- a/lua/codediff/ui/explorer/keymaps.lua +++ b/lua/codediff/ui/explorer/keymaps.lua @@ -175,6 +175,98 @@ function M.setup(explorer) render_module.navigate_prev(explorer) end, vim.tbl_extend("force", map_options, { buffer = split.bufnr, desc = "Previous file" })) end + + -- Helper: focus the modified (right) pane after async file load + local function focus_modified_pane() + vim.defer_fn(function() + local lifecycle = require('codediff.ui.lifecycle') + local _, mod_win = lifecycle.get_windows(explorer.tabpage) + if mod_win and vim.api.nvim_win_is_valid(mod_win) then + vim.api.nvim_set_current_win(mod_win) + end + end, 200) + end + + -- Open (alias for select) — opens file but keeps focus in explorer + if explorer_keymaps.open then + vim.keymap.set("n", explorer_keymaps.open, function() + local node = tree:get_node() + if not node then return end + if node.data and (node.data.type == "group" or node.data.type == "directory") then + if node:is_expanded() then + node:collapse() + else + node:expand() + end + tree:render() + elseif node.data then + explorer.on_file_select(node.data) + end + end, vim.tbl_extend("force", map_options, { buffer = split.bufnr, desc = "Open file or toggle group" })) + end + + -- Focus file: jump to modified pane if file is already open, otherwise open it + if explorer_keymaps.focus_file then + vim.keymap.set("n", explorer_keymaps.focus_file, function() + local node = tree:get_node() + if not node or not node.data then return end + + -- If cursor is on the already-open file, jump to the modified (right) pane + if node.data.path and node.data.path == explorer.current_file_path + and (node.data.group or "unstaged") == explorer.current_file_group then + local lifecycle = require('codediff.ui.lifecycle') + local _, mod_win = lifecycle.get_windows(explorer.tabpage) + if mod_win and vim.api.nvim_win_is_valid(mod_win) then + vim.api.nvim_set_current_win(mod_win) + return + end + end + + -- Otherwise open the file (same as select for files, toggle for groups) + if node.data.type == "group" or node.data.type == "directory" then + if node:is_expanded() then + node:collapse() + else + node:expand() + end + tree:render() + elseif node.data.path then + explorer.on_file_select(node.data) + focus_modified_pane() + end + end, vim.tbl_extend("force", map_options, { buffer = split.bufnr, desc = "Focus file in diff view" })) + end + + -- Stage file (git add) + local function bind_stage_file(key) + if key then + vim.keymap.set("n", key, function() + local node = tree:get_node() + if not node or not node.data or not node.data.path then return end + actions_module.stage_file(explorer, node.data) + end, vim.tbl_extend("force", map_options, { buffer = split.bufnr, desc = "Stage file" })) + end + end + bind_stage_file(explorer_keymaps.stage_file) + bind_stage_file(explorer_keymaps.stage_file_alt) + + -- Unstage file (git restore --staged) + if explorer_keymaps.unstage_file then + vim.keymap.set("n", explorer_keymaps.unstage_file, function() + local node = tree:get_node() + if not node or not node.data or not node.data.path then return end + actions_module.unstage_file(explorer, node.data) + end, vim.tbl_extend("force", map_options, { buffer = split.bufnr, desc = "Unstage file" })) + end + + -- Discard file changes (with confirmation) + if explorer_keymaps.discard_file then + vim.keymap.set("n", explorer_keymaps.discard_file, function() + local node = tree:get_node() + if not node or not node.data or not node.data.path then return end + actions_module.discard_file(explorer, node.data) + end, vim.tbl_extend("force", map_options, { buffer = split.bufnr, desc = "Discard file changes" })) + end end return M diff --git a/lua/codediff/ui/explorer/render.lua b/lua/codediff/ui/explorer/render.lua index e089316a..ac582e02 100644 --- a/lua/codediff/ui/explorer/render.lua +++ b/lua/codediff/ui/explorer/render.lua @@ -136,6 +136,7 @@ function M.create(status_result, git_root, tabpage, width, base_revision, target bufnr = split.bufnr, winid = split.winid, git_root = git_root, + tabpage = tabpage, dir1 = opts.dir1, dir2 = opts.dir2, base_revision = base_revision, @@ -185,90 +186,6 @@ function M.create(status_result, git_root, tabpage, width, base_revision, target local abs_path = git_root .. "/" .. file_path - -- Dir mode: Compare files from dir1 vs dir2 (no git) - if is_dir_mode then - local original_path = explorer.dir1 .. "/" .. file_path - local modified_path = explorer.dir2 .. "/" .. file_path - - -- Check if already displaying same file - local session = lifecycle.get_session(tabpage) - if session and session.original_path == original_path and session.modified_path == modified_path then - return - end - - vim.schedule(function() - ---@type SessionConfig - local session_config = { - mode = "explorer", - git_root = nil, - original_path = original_path, - modified_path = modified_path, - original_revision = nil, - modified_revision = nil, - } - view.update(tabpage, session_config, true) - end) - return - end - - local abs_path = git_root .. "/" .. file_path - - -- Dir mode: Compare files from dir1 vs dir2 (no git) - if is_dir_mode then - local original_path = explorer.dir1 .. "/" .. file_path - local modified_path = explorer.dir2 .. "/" .. file_path - - -- Check if already displaying same file - local session = lifecycle.get_session(tabpage) - if session and session.original_path == original_path and session.modified_path == modified_path then - return - end - - vim.schedule(function() - ---@type SessionConfig - local session_config = { - mode = "explorer", - git_root = nil, - original_path = original_path, - modified_path = modified_path, - original_revision = nil, - modified_revision = nil, - } - view.update(tabpage, session_config, true) - end) - return - end - - local abs_path = git_root .. "/" .. file_path - - -- Dir mode: Compare files from dir1 vs dir2 (no git) - if is_dir_mode then - local original_path = explorer.dir1 .. "/" .. file_path - local modified_path = explorer.dir2 .. "/" .. file_path - - -- Check if already displaying same file - local session = lifecycle.get_session(tabpage) - if session and session.original_path == original_path and session.modified_path == modified_path then - return - end - - vim.schedule(function() - ---@type SessionConfig - local session_config = { - mode = "explorer", - git_root = nil, - original_path = original_path, - modified_path = modified_path, - original_revision = nil, - modified_revision = nil, - } - view.update(tabpage, session_config, true) - end) - return - end - - local abs_path = git_root .. "/" .. file_path - -- Handle untracked files: show file without diff (hide left pane) if file_data.status == "??" then vim.schedule(function() @@ -329,7 +246,6 @@ function M.create(status_result, git_root, tabpage, width, base_revision, target local file_bufnr = vim.fn.bufadd(abs_path) vim.fn.bufload(file_bufnr) vim.api.nvim_win_set_buf(mod_win, file_bufnr) - vim.api.nvim_set_current_win(mod_win) -- Update session state to keep it consistent lifecycle.update_buffers(tabpage, empty_buf, file_bufnr) @@ -508,202 +424,34 @@ function M.create(status_result, git_root, tabpage, width, base_revision, target return f, "conflicts" end end - end, vim.tbl_extend("force", map_options, { buffer = split.bufnr })) - end - - -- Double click also works for files - vim.keymap.set("n", "<2-LeftMouse>", function() - local node = tree:get_node() - if not node or not node.data or node.data.type == "group" or node.data.type == "directory" then return end - explorer.on_file_select(node.data) - end, vim.tbl_extend("force", map_options, { buffer = split.bufnr })) - - -- Close explorer (disabled) - -- vim.keymap.set("n", "q", function() - -- split:unmount() - -- end, vim.tbl_extend("force", map_options, { buffer = split.bufnr })) - - -- Hover to show full path (K key, like LSP hover) - local hover_win = nil - if config.options.keymaps.explorer.hover then - vim.keymap.set("n", config.options.keymaps.explorer.hover, function() - -- Close existing hover window - if hover_win and vim.api.nvim_win_is_valid(hover_win) then - vim.api.nvim_win_close(hover_win, true) - hover_win = nil - return - end - - local node = tree:get_node() - if not node or not node.data or node.data.type == "group" then return end - - local full_path = node.data.path - local display_text = git_root .. "/" .. full_path - - -- Create hover buffer - local hover_buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(hover_buf, 0, -1, false, { display_text }) - vim.bo[hover_buf].modifiable = false - - -- Calculate window position (next to cursor) - local cursor = vim.api.nvim_win_get_cursor(0) - local row = cursor[1] - 1 - local col = vim.api.nvim_win_get_width(0) - - -- Calculate window dimensions with wrapping - local max_width = 80 - local text_len = #display_text - local width = math.min(text_len + 2, max_width) - local height = math.ceil(text_len / (max_width - 2)) -- Account for padding - - -- Create floating window with wrap enabled - hover_win = vim.api.nvim_open_win(hover_buf, false, { - relative = "win", - row = row, - col = col, - width = width, - height = height, - style = "minimal", - border = "rounded", - }) - - -- Enable wrap in hover window - vim.wo[hover_win].wrap = true - - -- Auto-close on cursor move or buffer leave - vim.api.nvim_create_autocmd({"CursorMoved", "BufLeave"}, { - buffer = split.bufnr, - once = true, - callback = function() - if hover_win and vim.api.nvim_win_is_valid(hover_win) then - vim.api.nvim_win_close(hover_win, true) - hover_win = nil - end - end, - }) - end, vim.tbl_extend("force", map_options, { buffer = split.bufnr })) - end - - -- Refresh explorer (R key) - if config.options.keymaps.explorer.refresh then - vim.keymap.set("n", config.options.keymaps.explorer.refresh, function() - refresh_module.refresh(explorer) - end, vim.tbl_extend("force", map_options, { buffer = split.bufnr })) - end - - -- Toggle view mode (i key) - switch between 'list' and 'tree' - if config.options.keymaps.explorer.toggle_view_mode then - vim.keymap.set("n", config.options.keymaps.explorer.toggle_view_mode, function() - actions_module.toggle_view_mode(explorer) - end, vim.tbl_extend("force", map_options, { buffer = split.bufnr })) - end - - -- Navigate to next file - if config.options.keymaps.view.next_file then - vim.keymap.set("n", config.options.keymaps.view.next_file, function() - M.navigate_next(explorer) - end, vim.tbl_extend("force", map_options, { buffer = split.bufnr })) - end - - -- Navigate to previous file - if config.options.keymaps.view.prev_file then - vim.keymap.set("n", config.options.keymaps.view.prev_file, function() - M.navigate_prev(explorer) - end, vim.tbl_extend("force", map_options, { buffer = split.bufnr })) - end - - -- Open (alias for select) - if config.options.keymaps.explorer.open then - vim.keymap.set("n", config.options.keymaps.explorer.open, function() - local node = tree:get_node() - if not node then return end - if node.data and (node.data.type == "group" or node.data.type == "directory") then - if node:is_expanded() then - node:collapse() - else - node:expand() - end - tree:render() - elseif node.data then - explorer.on_file_select(node.data) - end - end, vim.tbl_extend("force", map_options, { buffer = split.bufnr })) - end - - -- Focus file: jump to modified pane if file is already open, otherwise open it - if config.options.keymaps.explorer.focus_file then - vim.keymap.set("n", config.options.keymaps.explorer.focus_file, function() - local node = tree:get_node() - if not node or not node.data then return end - - -- If cursor is on the already-open file, jump to the modified (right) pane - if node.data.path and node.data.path == explorer.current_file_path - and (node.data.group or "unstaged") == explorer.current_file_group then - local lifecycle = require('codediff.ui.lifecycle') - local _, mod_win = lifecycle.get_windows(tabpage) - if mod_win and vim.api.nvim_win_is_valid(mod_win) then - vim.api.nvim_set_current_win(mod_win) - return - end + end + for _, f in ipairs(status_result.unstaged) do + if f.path == path then + return f, "unstaged" end - - -- Otherwise open the file (same as select for files, toggle for groups) - if node.data.type == "group" or node.data.type == "directory" then - if node:is_expanded() then - node:collapse() - else - node:expand() - end - tree:render() - elseif node.data.path then - explorer.on_file_select(node.data) + end + for _, f in ipairs(status_result.staged) do + if f.path == path then + return f, "staged" end - end, vim.tbl_extend("force", map_options, { buffer = split.bufnr })) - end - - -- Stage file (git add) - local function bind_stage_file(key) - if key then - vim.keymap.set("n", key, function() - local node = tree:get_node() - if not node or not node.data or not node.data.path then return end - actions_module.stage_file(explorer, node.data) - end, vim.tbl_extend("force", map_options, { buffer = split.bufnr })) end - end - bind_stage_file(config.options.keymaps.explorer.stage_file) - bind_stage_file(config.options.keymaps.explorer.stage_file_alt) - - -- Unstage file (git restore --staged) - if config.options.keymaps.explorer.unstage_file then - vim.keymap.set("n", config.options.keymaps.explorer.unstage_file, function() - local node = tree:get_node() - if not node or not node.data or not node.data.path then return end - actions_module.unstage_file(explorer, node.data) - end, vim.tbl_extend("force", map_options, { buffer = split.bufnr })) + return nil, nil end - -- Discard file changes (with confirmation) - if config.options.keymaps.explorer.discard_file then - vim.keymap.set("n", config.options.keymaps.explorer.discard_file, function() - local node = tree:get_node() - if not node or not node.data or not node.data.path then return end - actions_module.discard_file(explorer, node.data) - end, vim.tbl_extend("force", map_options, { buffer = split.bufnr })) + -- Select initial file: prefer focus_file (current buffer) if changed, else first file + local initial_file, initial_file_group + local focus_file = opts and opts.focus_file + if focus_file then + initial_file, initial_file_group = find_file_in_status(focus_file) end - - -- Select first file by default (conflicts first, then unstaged, then staged) - local first_file = nil - local first_file_group = nil - if status_result.conflicts and #status_result.conflicts > 0 then - first_file = status_result.conflicts[1] - first_file_group = "conflicts" - elseif #status_result.unstaged > 0 then - first_file = status_result.unstaged[1] - first_file_group = "unstaged" - elseif #status_result.staged > 0 then - first_file = status_result.staged[1] - first_file_group = "staged" + if not initial_file then + if status_result.conflicts and #status_result.conflicts > 0 then + initial_file, initial_file_group = status_result.conflicts[1], "conflicts" + elseif #status_result.unstaged > 0 then + initial_file, initial_file_group = status_result.unstaged[1], "unstaged" + elseif #status_result.staged > 0 then + initial_file, initial_file_group = status_result.staged[1], "staged" + end end if initial_file then diff --git a/lua/codediff/ui/highlights.lua b/lua/codediff/ui/highlights.lua index a8193b25..c3be43f4 100644 --- a/lua/codediff/ui/highlights.lua +++ b/lua/codediff/ui/highlights.lua @@ -28,7 +28,7 @@ end -- Returns a table suitable for nvim_set_hl (e.g., { bg = 0x2ea043 }) local function resolve_color(value, fallback_gui, fallback_cterm) if not value then - return { bg = fallback_gui, ctermbg = fallback_cterm, default = true } + return { bg = fallback_gui, ctermbg = fallback_cterm } end -- If it's a string, check if it's a hex color or highlight group name @@ -42,7 +42,6 @@ local function resolve_color(value, fallback_gui, fallback_cterm) return { bg = r * 65536 + g * 256 + b, ctermbg = fallback_cterm, - default = true, } elseif value:match("^#%x%x%x$") then -- #RGB format - expand to #RRGGBB @@ -52,7 +51,6 @@ local function resolve_color(value, fallback_gui, fallback_cterm) return { bg = r * 65536 + g * 256 + b, ctermbg = fallback_cterm, - default = true, } else -- Assume it's a highlight group name @@ -60,15 +58,14 @@ local function resolve_color(value, fallback_gui, fallback_cterm) return { bg = hl.bg or fallback_gui, ctermbg = hl.ctermbg or fallback_cterm, - default = true, } end elseif type(value) == "number" then -- Direct color number (e.g., 0x2ea043 or a base256 index) - return { bg = value, ctermbg = value, default = true } + return { bg = value, ctermbg = value } end - return { bg = fallback_gui, ctermbg = fallback_cterm, default = true } + return { bg = fallback_gui, ctermbg = fallback_cterm } end -- Returns the base 256 color palette index of rgb color cube where r, g, b are 0-5 inclusive. @@ -118,7 +115,6 @@ function M.setup() char_delete_color = { bg = adjust_brightness(line_delete_color.bg, brightness) or 0x4b2a3d, ctermbg = base256_color(2, 0, 0), - default = true, } end From 95faff8178b0bf4cd47fa706142b0f17a76f9bb9 Mon Sep 17 00:00:00 2001 From: Christian De Santis Date: Fri, 30 Jan 2026 14:04:19 -0400 Subject: [PATCH 16/16] feat: add discard hunk functionality with D keybinding --- README.md | 1 + lua/codediff/config.lua | 1 + lua/codediff/core/git.lua | 78 ++++++++++++++++++++++++++++ lua/codediff/ui/view/keymaps.lua | 87 +++++++++++++++++++++++++++++++- 4 files changed, 166 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3aad164e..386f20bf 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,7 @@ https://github.com/user-attachments/assets/64c41f01-dffe-4318-bce4-16eec8de356e toggle_stage = "-", -- Stage/unstage current file (works in explorer and diff buffers) stage_hunk = "S", -- Stage hunk under cursor to git index unstage_hunk = "U", -- Unstage hunk under cursor from git index + discard_hunk = "D", -- Discard hunk under cursor (working tree only) }, explorer = { select = "", -- Open diff for selected file diff --git a/lua/codediff/config.lua b/lua/codediff/config.lua index a812a3d9..06a5f53f 100644 --- a/lua/codediff/config.lua +++ b/lua/codediff/config.lua @@ -77,6 +77,7 @@ M.defaults = { toggle_stage = "-", -- Stage/unstage current file (works in explorer and diff buffers) stage_hunk = "S", -- Stage the hunk under cursor to git index unstage_hunk = "U", -- Unstage the hunk under cursor from git index + discard_hunk = "D", -- Discard the hunk under cursor (working tree only) }, explorer = { select = "", diff --git a/lua/codediff/core/git.lua b/lua/codediff/core/git.lua index 6bcf5686..8cae9c3c 100644 --- a/lua/codediff/core/git.lua +++ b/lua/codediff/core/git.lua @@ -588,6 +588,84 @@ function M.apply_patch(git_root, patch, reverse, callback) end end +-- Discard a hunk from the working tree by reverse-applying a patch (async) +-- Used for hunk-level discarding: generates a patch for a single hunk and +-- reverse-applies it to the working tree, effectively discarding the changes. +-- +-- git_root: absolute path to git repository root +-- patch: string containing a valid unified diff patch +-- callback: function(err) - nil err on success +function M.discard_hunk_patch(git_root, patch, callback) + local args = { "apply", "--reverse", "--unidiff-zero", "-" } + + if vim.system then + if git_root and vim.fn.isdirectory(git_root) == 0 then + callback("Directory does not exist: " .. git_root) + return + end + + vim.system( + vim.list_extend({ "git" }, args), + { + cwd = git_root, + stdin = patch, + text = true, + }, + function(result) + vim.schedule(function() + if result.code == 0 then + callback(nil) + else + callback(result.stderr or "git apply failed") + end + end) + end + ) + else + -- Fallback for older Neovim (< 0.10) + local stderr_data = {} + local stdin_pipe = vim.loop.new_pipe(false) + local stderr_pipe = vim.loop.new_pipe(false) + + local handle + ---@diagnostic disable-next-line: missing-fields + handle = vim.loop.spawn("git", { + args = args, + cwd = git_root, + stdio = { stdin_pipe, nil, stderr_pipe }, + }, function(code) + if stdin_pipe then stdin_pipe:close() end + if stderr_pipe then stderr_pipe:close() end + if handle then handle:close() end + + vim.schedule(function() + if code == 0 then + callback(nil) + else + callback(table.concat(stderr_data) or "git apply failed") + end + end) + end) + + if not handle then + callback("Failed to spawn git process") + return + end + + if stderr_pipe then + stderr_pipe:read_start(function(err, data) + if data then + table.insert(stderr_data, data) + end + end) + end + + -- Write patch to stdin and close + stdin_pipe:write(patch) + stdin_pipe:shutdown() + end +end + -- Run a git command synchronously -- Returns output string or nil on error local function run_git_sync(args, opts) diff --git a/lua/codediff/ui/view/keymaps.lua b/lua/codediff/ui/view/keymaps.lua index 1805b91d..41ca6e77 100644 --- a/lua/codediff/ui/view/keymaps.lua +++ b/lua/codediff/ui/view/keymaps.lua @@ -636,6 +636,88 @@ function M.setup_all_keymaps(tabpage, original_bufnr, modified_bufnr, is_explore end) end + -- Helper: Discard hunk under cursor from working tree + local function discard_hunk() + local session = lifecycle.get_session(tabpage) + if not session or not session.git_root then + vim.notify("Not in a git repository", vim.log.levels.WARN) + return + end + + -- Only allow discarding in unstaged views (working tree changes) + if session.modified_revision ~= nil then + vim.notify("Discard only works on unstaged changes (working tree)", vim.log.levels.WARN) + return + end + + local hunk, hunk_idx = find_hunk_at_cursor() + if not hunk then + vim.notify("No hunk at cursor position", vim.log.levels.WARN) + return + end + + local file_path = session.original_path or session.modified_path + if not file_path or file_path == "" then + vim.notify("No file path for discarding", vim.log.levels.WARN) + return + end + + -- Prompt for confirmation before discarding (destructive operation) + local prompt = string.format("Discard hunk %d? This cannot be undone.", hunk_idx) + vim.ui.select({ 'Yes', 'No' }, { prompt = prompt }, function(choice) + if choice ~= 'Yes' then return end + + -- Read lines from both buffers for this hunk + local orig_lines = vim.api.nvim_buf_get_lines( + original_bufnr, + hunk.original.start_line - 1, + hunk.original.end_line - 1, + false + ) + local mod_lines = vim.api.nvim_buf_get_lines( + modified_bufnr, + hunk.modified.start_line - 1, + hunk.modified.end_line - 1, + false + ) + + local patch = build_hunk_patch(file_path, orig_lines, mod_lines, + hunk.original.start_line, hunk.modified.start_line) + + local git = require('codediff.core.git') + git.discard_hunk_patch(session.git_root, patch, function(err) + if err then + vim.notify("Failed to discard hunk: " .. err, vim.log.levels.ERROR) + return + end + + -- Refresh explorer to reflect discard + local explorer_obj = lifecycle.get_explorer(tabpage) + if explorer_obj then + local explorer = require('codediff.ui.explorer') + explorer.refresh(explorer_obj) + end + + vim.notify(string.format("Discarded hunk %d", hunk_idx), vim.log.levels.INFO) + + -- Refresh diff view: reload virtual buffers and recompute diff + local view = require('codediff.ui.view') + local current_win = vim.api.nvim_get_current_win() + view.update(tabpage, { + mode = session.mode, + git_root = session.git_root, + original_path = session.original_path, + modified_path = session.modified_path, + original_revision = session.original_revision, + modified_revision = session.modified_revision, + }, false) + if vim.api.nvim_win_is_valid(current_win) then + vim.api.nvim_set_current_win(current_win) + end + end) + end) + end + -- ======================================================================== -- Bind all keymaps using unified API (one place for all keymaps!) -- ======================================================================== @@ -698,13 +780,16 @@ function M.setup_all_keymaps(tabpage, original_bufnr, modified_bufnr, is_explore end end - -- Hunk-level staging (S, U) - stage/unstage individual hunks via git apply + -- Hunk-level staging (S, U, D) - stage/unstage/discard individual hunks via git apply if keymaps.stage_hunk then lifecycle.set_tab_keymap(tabpage, 'n', keymaps.stage_hunk, stage_hunk, { desc = 'Stage hunk under cursor' }) end if keymaps.unstage_hunk then lifecycle.set_tab_keymap(tabpage, 'n', keymaps.unstage_hunk, unstage_hunk, { desc = 'Unstage hunk under cursor' }) end + if keymaps.discard_hunk then + lifecycle.set_tab_keymap(tabpage, 'n', keymaps.discard_hunk, discard_hunk, { desc = 'Discard hunk under cursor' }) + end end return M