diff --git a/README.md b/README.md index 14aa1965..386f20bf 100644 --- a/README.md +++ b/README.md @@ -132,15 +132,24 @@ 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 + discard_hunk = "D", -- Discard hunk under cursor (working tree only) }, 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 diff --git a/lua/codediff/config.lua b/lua/codediff/config.lua index a87ce23e..06a5f53f 100644 --- a/lua/codediff/config.lua +++ b/lua/codediff/config.lua @@ -75,12 +75,21 @@ 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 + discard_hunk = "D", -- Discard the hunk under cursor (working tree only) }, 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/core/git.lua b/lua/codediff/core/git.lua index 5004bd60..8cae9c3c 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, @@ -493,6 +506,166 @@ 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 + +-- 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/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 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/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 2b0f3348..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,6 +186,84 @@ 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 (hide 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') + + -- 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 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 + 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 + 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 + + -- 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) + + -- 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 + + -- 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) + return + 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/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 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..2fb4a8a9 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) @@ -482,6 +490,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 @@ -508,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) @@ -614,6 +647,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 @@ -760,11 +805,15 @@ function M.update(tabpage, session_config, auto_scroll_to_first_hunk) 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 + 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 lifecycle.is_modified_virtual(tabpage) and old_modified_buf ~= modified_info.bufnr and old_modified_buf ~= original_info.bufnr 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 diff --git a/lua/codediff/ui/view/keymaps.lua b/lua/codediff/ui/view/keymaps.lua index 34795bb2..41ca6e77 100644 --- a/lua/codediff/ui/view/keymaps.lua +++ b/lua/codediff/ui/view/keymaps.lua @@ -417,6 +417,307 @@ 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 + + -- 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!) -- ======================================================================== @@ -478,6 +779,17 @@ 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, 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