-
Notifications
You must be signed in to change notification settings - Fork 28
feat: add hunk and file-level staging, explorer actions, and enhanced untracked file support #216
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
03dac2e
15ec17b
ae26ce9
4ed0e83
47564d2
85a33e5
6db451c
b6165c1
4e5d57b
5e35488
f696965
7334164
136eb38
9e0fee7
ea53bff
95faff8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 = "<CR>", | ||
| open = "o", -- Alias for select (open file or toggle group) | ||
| focus_file = "l", -- Jump to modified pane if file is open, otherwise open file | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To confirm the behavior, if a file was not selected, it will select that file first, then focus the modified pane, right?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using 'l' would open the file if not selected already, and move the cursor straight to the right pane so you can start scrolling and reviewing immediately. In case that you use 'l' and the file was already open, it just takes the cursor to the right pane the same way. When using 'o' it opens the file without moving the cursor from the file explorer, I thought it to be a good default, maybe I should've not call it "alias" in the comment next to it |
||
| 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) | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These keymaps for stage/unstage/discard the file under cursor in explorer seem to be already implemented by
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah maybe, I just found these more intuitive. I still don't know if this previously had a key binding for staging the file, since '-' works for unstaging, I tried using '+' to stage but didn't work
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes,
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ohh gotcha! |
||
| 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) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you run
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Will do |
||
| -- 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) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I feel if user wants an alias, they can setup their own keymap locally, so it might not be necessary for us to have one?