From e22bf5ec4e28d332a8ff63821e5f0b80178f532d Mon Sep 17 00:00:00 2001 From: NicolasGB Date: Thu, 27 Nov 2025 15:47:10 +0100 Subject: [PATCH] feat: Add `fetch`/`push` commands and log keybinds, make long-running operations non-blocking Convert blocking jj commands to async using vim.fn.jobstart() to prevent UI freezing during fetch, push, rebase, squash, and other heavy operations. Changes: - Implement execute_command_async() in runner with proper error handling - Captures stdout from job and passes to success callback - Supports stdin input for commands that require it - Includes silent flag to suppress error notifications - Replicates sync version's error handling behavior - Convert blocking operations to async: - handle_log_fetch, handle_log_push_all, handle_log_new - handle_log_edit, handle_log_abandon, handle_log_describe - describe.execute_describe, all init.lua commands - Add push/fetch as public commands with CLI support: - M.fetch() - fetch from remote - M.push(opts) - push all changes or specific bookmark - CLI: :J push, :J push , :J fetch - Implement handle_log_push_bookmark() for revision under cursor: - Retrieves and strips modified bookmark indicator (*) - Pushes specific bookmark to remote - Keybind: Shift+p in log buffer - Update README with new keybinds: - a: abandon revision under cursor - f: fetch from remote - p: push all changes - Shift+p: push revision's bookmark - Add Lua API docs for push command options --- README.md | 42 +++++++++++ lua/jj/cmd/describe.lua | 6 +- lua/jj/cmd/init.lua | 150 ++++++++++++++++++++++++++++------------ lua/jj/cmd/log.lua | 104 ++++++++++++++++++++++------ lua/jj/core/runner.lua | 45 +++++++++++- lua/jj/utils.lua | 6 +- 6 files changed, 282 insertions(+), 71 deletions(-) diff --git a/README.md b/README.md index a07e18c..ae71229 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,20 @@ You can undo/redo changes directly from the log buffer: - `` - Undo the last operation - `` - Redo the last undone operation +### Abandon changes from the log buffer + +You can abandon changes directly from the log buffer: + +- `a` - Abandon the revision under the cursor + +### Fetch and push from the log buffer + +You can fetch and push directly from the log buffer: + +- `f` - Fetch from remote +- `` - Push all changes to remote +- `p` - Push bookmark of revision under cursor to remote + ### Open a changed file Just press enter to open the a file from the `status` output in your current window. @@ -101,6 +115,9 @@ The plugin provides a `:J` command that accepts jj subcommands: :J log :J describe "Your change description" :J new +:J push " Push all changes +:J push main " Push only main bookmark +:J fetch " Fetch from remote :J # This will use your defined default command :J ``` @@ -178,6 +195,10 @@ The plugin also provides `:Jdiff`, `:Jvdiff`, and `:Jhdiff` commands for diffing new_after_immutable = "", -- Create new change after (ignore immutability) undo = "", -- Undo last operation redo = "", -- Redo last undone operation + abandon = "a", -- Abandon revision under cursor + fetch = "f", -- Fetch from remote + push = "p", -- Push bookmark of revision under cursor + push_all = "", -- Push all changes to remote }, -- Status buffer keymaps (set to nil to disable) status = { @@ -296,6 +317,22 @@ cmd.new({ show_log = true, with_input = true }) -- Prompt for parent cmd.new({ args = "--before @" }) -- Pass custom args ``` +### Push Command Options + +The `push` function accepts an options table: + +```lua +local cmd = require("jj.cmd") +cmd.push({ + bookmark = "main" -- Push specific bookmark (default: all changes) +}) + +-- Examples: +cmd.push() -- Push all changes +cmd.push({ bookmark = "main" }) -- Push only main bookmark +cmd.push({ bookmark = "feature" }) -- Push only feature bookmark +``` + ### Diff Split Views Use the `diff` module for opening splits: @@ -338,6 +375,8 @@ diff.open_hsplit({ rev = "@-2" }) -- Horizontal split against @-2 checkout = "", describe = "d", diff = "", + abandon = "", + fetch = "", }, status = { open_file = "", @@ -367,6 +406,9 @@ diff.open_hsplit({ rev = "@-2" }) -- Horizontal split against @-2 vim.keymap.set("n", "jr", cmd.rebase, { desc = "JJ rebase" }) vim.keymap.set("n", "jb", cmd.bookmark_create, { desc = "JJ bookmark create" }) vim.keymap.set("n", "jB", cmd.bookmark_delete, { desc = "JJ bookmark delete" }) + vim.keymap.set("n", "ja", cmd.abandon, { desc = "JJ abandon" }) + vim.keymap.set("n", "jf", cmd.fetch, { desc = "JJ fetch" }) + vim.keymap.set("n", "jp", cmd.push, { desc = "JJ push" }) -- Diff commands local diff = require("jj.diff") diff --git a/lua/jj/cmd/describe.lua b/lua/jj/cmd/describe.lua index 4f26fbf..8c6f96b 100644 --- a/lua/jj/cmd/describe.lua +++ b/lua/jj/cmd/describe.lua @@ -30,10 +30,9 @@ local function execute_describe(description, revset) cmd = cmd .. " --stdin" -- Use --stdin to properly handle multi-line and special characters - local _, success = runner.execute_command(cmd, "Failed to describe", description) - if success then + runner.execute_command_async(cmd, function() utils.notify("Description set.", vim.log.levels.INFO) - end + end, "Failed to describe", description) end --- Resolve describe editor keymaps from config @@ -151,3 +150,4 @@ function M.describe(description, revset, opts) end return M + diff --git a/lua/jj/cmd/init.lua b/lua/jj/cmd/init.lua index df93622..165e2ee 100644 --- a/lua/jj/cmd/init.lua +++ b/lua/jj/cmd/init.lua @@ -36,6 +36,9 @@ local status_module = require("jj.cmd.status") --- @field undo? string|string[] --- @field redo? string|string[] --- @field abandon? string|string[] +--- @field fetch? string|string[] +--- @field push_all? string|string[] +--- @field push? string|string[] --- @class jj.cmd.status.keymaps --- @field open_file? string|string[] Keymaps for the status command buffer, setting a keymap to nil will disable it @@ -54,7 +57,7 @@ local status_module = require("jj.cmd.status") --- @class jj.cmd.opts --- @field describe? jj.cmd.describe --- @field log? jj.cmd.log ---- @field keymaps? jj.cmd.keymaps Keymaps for the buffers containing the output of the commands +--- @field keymaps? jj.cmd.keymaps Keymaps for the buffers containing the of the commands --- --- @class jj.cmd.keymap_spec --- @field desc string @@ -63,6 +66,9 @@ local status_module = require("jj.cmd.status") --- @alias jj.cmd.keymap_specs table +--- @class jj.cmd.push_opts +--- @field bookmark? string Specific bookmark to push (default: all) + --- @type jj.cmd.opts M.config = { describe = { @@ -88,6 +94,9 @@ M.config = { undo = "", redo = "", abandon = "a", + fetch = "f", + push_all = "", + push = "p", }, status = { open_file = "", @@ -207,12 +216,13 @@ function M.new(opts) --- @param cmd string local function execute_new(cmd) - runner.execute_command(cmd, "Failed to create new change") - utils.notify("Command `new` was succesful.", vim.log.levels.INFO) - -- Show the updated log if the user requested it - if opts.show_log then - M.log() - end + runner.execute_command_async(cmd, function() + utils.notify("Command `new` was succesful.", vim.log.levels.INFO) + -- Show the updated log if the user requested it + if opts.show_log then + M.log() + end + end, "Failed to create new change") end -- If the user wants use input mode @@ -255,11 +265,9 @@ function M.edit() default = "", }, function(input) if input then - local _, success = runner.execute_command(string.format("jj edit %s", input), "Error editing change") - if not success then - return - end - M.log({}) + runner.execute_command_async(string.format("jj edit %s", input), function() + M.log({}) + end, "Error editing change") else terminal.close_terminal_buffer() end @@ -273,13 +281,12 @@ function M.squash() end local cmd = "jj squash" - local _, success = runner.execute_command(cmd, "Failed to squash") - if success then + runner.execute_command_async(cmd, function() utils.notify("Command `squash` was succesful.", vim.log.levels.INFO) if terminal.state.buf_cmd == "log" then M.log() end - end + end, "Failed to squash") end --- @class jj.cmd.diff_opts @@ -321,11 +328,10 @@ function M.rebase() if input then local cmd = string.format("jj rebase -d '%s'", input) utils.notify(string.format("Beginning rebase on %s", input), vim.log.levels.INFO) - local _, success = runner.execute_command(cmd, "Error rebasing") - if success then + runner.execute_command_async(cmd, function() utils.notify("Rebase successful.", vim.log.levels.INFO) M.log({}) - end + end, "Error rebasing") else terminal.close_terminal_buffer() end @@ -344,11 +350,10 @@ function M.bookmark_create() }, function(input) if input then local cmd = string.format("jj b c %s", input) - local _, success = runner.execute_command(cmd, "Error creating bookmark") - if success then + runner.execute_command_async(cmd, function() utils.notify(string.format("Bookmark `%s` created successfully for @", input), vim.log.levels.INFO) M.log({}) - end + end, "Error creating bookmark") else terminal.close_terminal_buffer() end @@ -367,11 +372,10 @@ function M.bookmark_delete() }, function(input) if input then local cmd = string.format("jj b d %s", input) - local _, success = runner.execute_command(cmd, "Error deleting bookmark") - if success then + runner.execute_command_async(cmd, function() utils.notify(string.format("Bookmark `%s` deleted successfully.", input), vim.log.levels.INFO) M.log({}) - end + end, "Error deleting bookmark") else terminal.close_terminal_buffer() end @@ -385,13 +389,12 @@ function M.undo() end local cmd = "jj undo" - local _, success = runner.execute_command(cmd, "Failed to undo") - if success then + runner.execute_command_async(cmd, function() utils.notify("Command `undo` was succesful.", vim.log.levels.INFO) if terminal.state.buf_cmd == "log" then M.log({}) end - end + end, "Failed to undo") end -- Jujutsu redo @@ -401,13 +404,12 @@ function M.redo() end local cmd = "jj redo" - local _, success = runner.execute_command(cmd, "Failed to redo") - if success then + runner.execute_command_async(cmd, function() utils.notify("Command `redo` was succesful.", vim.log.levels.INFO) if terminal.state.buf_cmd == "log" then M.log({}) end - end + end, "Failed to redo") end -- Jujutsu abandon @@ -423,16 +425,61 @@ function M.abandon() }, function(input) if input then local cmd = string.format("jj abandon %s", input) - local _, success = runner.execute_command(cmd, "Error abandoning change") - if success then + runner.execute_command_async(cmd, function() utils.notify("Change abandoned successfully.", vim.log.levels.INFO) M.log({}) - end + end, "Error abandoning change") else terminal.close_terminal_buffer() end end) end + +-- Jujutsu fetch +function M.fetch() + if not utils.ensure_jj() then + return + end + + local log_open = terminal.state.buf_cmd == "log" + + local cmd = "jj git fetch" + utils.notify("Fetching ...", vim.log.levels.INFO, 1000) + runner.execute_command_async(cmd, function() + utils.notify("Successfully fetched from remote", vim.log.levels.INFO) + if log_open then + M.log({}) + end + end, "Error fetching from remote") +end + +-- Jujutsu push +--- @param opts? jj.cmd.push_opts Optional push options +function M.push(opts) + if not utils.ensure_jj() then + return + end + + opts = opts or {} + + local log_open = terminal.state.buf_cmd == "log" + + local cmd = "jj git push" + if opts.bookmark then + utils.notify(string.format("Pushing `%s` bookmark ...", opts.bookmark), vim.log.levels.INFO, 1000) + cmd = string.format("%s --bookmark %s", cmd, opts.bookmark) + else + utils.notify(string.format("Pushing `ALL` bookmarks...", opts.bookmark), vim.log.levels.INFO, 1000) + end + + runner.execute_command_async(cmd, function() + utils.notify("Successfully pushed to remote", vim.log.levels.INFO) + if log_open then + M.log({}) + end + end, "Error pushing to remote") +end + --- @param args string|string[] jj command arguments function M.j(args) if not utils.ensure_jj() then @@ -509,6 +556,19 @@ function M.j(args) st = function() M.status() end, + abandon = function() + M.abandon() + end, + push = function() + if #remaining_args > 0 then + M.push({ bookmark = remaining_args[1] }) + else + M.push() + end + end, + fetch = function() + M.fetch() + end, } if handlers[subcommand] then @@ -535,22 +595,23 @@ function M.register_command() nargs = "*", complete = function(arglead, _, _) local subcommands = { - "log", - "status", - "st", - "diff", - "describe", - "new", - "squash", - "bookmark", - "edit", "abandon", "b", + "bookmark", + "describe", + "diff", + "edit", + "fetch", "git", + "log", + "new", + "push", "rebase", - "abandon", - "undo", "redo", + "squash", + "st", + "status", + "undo", } local matches = {} for _, cmd in ipairs(subcommands) do @@ -580,4 +641,3 @@ function M.register_command() end return M - diff --git a/lua/jj/cmd/log.lua b/lua/jj/cmd/log.lua index cc42efb..1cad8f1 100644 --- a/lua/jj/cmd/log.lua +++ b/lua/jj/cmd/log.lua @@ -95,14 +95,11 @@ function M.handle_log_new(flag, ignore_immut) end local cmd = table.concat(cmd_parts, " ") - local _, success = runner.execute_command(cmd, string.format(cfg.err, revset)) - if not success then - return - end - - utils.notify(string.format(cfg.ok, revset), vim.log.levels.INFO) - -- Refresh the log buffer after creating the change. - require("jj.cmd").log() + runner.execute_command_async(cmd, function() + utils.notify(string.format(cfg.ok, revset), vim.log.levels.INFO) + -- Refresh the log buffer after creating the change. + require("jj.cmd").log() + end, string.format(cfg.err, revset)) end --- Handle diffing a log line @@ -129,7 +126,7 @@ function M.handle_log_describe() end end ---- Handle keypress enter on `jj log` buffer to edit a revision. +--- Handle keypress edit on `jj log` buffer to edit a revision. --- If ignore_immut is true, adds --ignore-immutable to the command. --- Silently returns if no revision is found or the jj command fails. --- On success, notifies and refreshes the log buffer. @@ -156,8 +153,17 @@ function M.handle_log_edit(ignore_immut, close_on_exit) local cmd = table.concat(cmd_parts, " ") -- Try to execute cmd - local _, success = runner.execute_command(cmd, "Error editing change") - if not success then + runner.execute_command_async(cmd, function() + -- Close the terminal buffer + if close_on_exit then + utils.notify(string.format("Editing change: `%s`", revset), vim.log.levels.INFO) + terminal.close_terminal_buffer() + else + M.log({}) + end + end, "Error editing change") +end + --- Handle abandon `jj log` buffer. --- If ignore_immut is true, adds --ignore-immutable to the command. --- Silently returns if no revision is found or the jj command fails. @@ -170,17 +176,11 @@ function M.handle_log_abandon(ignore_immut) return end - -- Close the terminal buffer -- If we found a revision, abandon it. - if close_on_exit then - utils.notify(string.format("Editing change: `%s`", revset), vim.log.levels.INFO) -- Build command parts. - terminal.close_terminal_buffer() local cmd_parts = { "jj", "abandon" } - else if ignore_immut then - M.log({}) table.insert(cmd_parts, "--ignore-immutable") end @@ -193,10 +193,62 @@ function M.handle_log_abandon(ignore_immut) runner.execute_command_async(cmd, function() utils.notify(string.format("Abandoned change: `%s`", revset), vim.log.levels.INFO) M.log({}) - end, function() - utils.notify("Error abandoning change", vim.log.levels.ERROR) - end) + end, "Error abandoning change") end + +--- Handle fetching from `jj log` buffer. +function M.handle_log_fetch() + local cmd = "jj git fetch" + utils.notify("Fetching from remote...", vim.log.levels.INFO) + runner.execute_command_async(cmd, function() + utils.notify("Successfully fetched from remote", vim.log.levels.INFO) + M.log({}) + end, "Error fetching from remote") +end + +--- Handle pushing from `jj log` buffer. +function M.handle_log_push_all() + local cmd = "jj git push" + utils.notify("Pushing `ALL` bookmarks", vim.log.levels.INFO) + runner.execute_command_async(cmd, function() + utils.notify("Successfully pushed all to remote", vim.log.levels.INFO) + M.log({}) + end, "Error pushing to remote") +end + +--- Handle log pushing bookmark from current line in `jj log` buffer. +function M.handle_log_push_bookmark() + local line = vim.api.nvim_get_current_line() + local revset = parser.get_rev_from_log_line(line) + if not revset or revset == "" then + return + end + -- If we found a revfision get it's bookmark and push it + local bookmark, success = runner.execute_command( + string.format("jj log -r %s -T 'bookmarks' --no-graph", revset), + string.format("Error retrieving bookmark for `%s`", revset), + nil, + false + ) + if not success or not bookmark then + return + end + + -- If there's a * trim it (bookmarks with modifications have *) + bookmark = bookmark:gsub("%*", ""):gsub("^%s+", ""):gsub("%s+$", "") + + if bookmark == "" then + utils.notify("No bookmark found for revision", vim.log.levels.ERROR) + return + end + + -- Push the bookmark from the revset found + local cmd = string.format("jj git push --bookmark %s -N", bookmark) + utils.notify(string.format("Pushing bookmark `%s`...", bookmark), vim.log.levels.INFO) + runner.execute_command_async(cmd, function() + utils.notify(string.format("Successfully pushed bookmark for `%s`", revset), vim.log.levels.INFO) + M.log({}) + end, string.format("Error pushing bookmark for `%s`", revset)) end --- Resolve log keymaps from config, filtering out nil values @@ -261,6 +313,18 @@ function M.log_keymaps() -- Maybe in the future we can add another keymap for that, if people request it args = { false }, }, + fetch = { + desc = "Fetch from remote", + handler = M.handle_log_fetch, + }, + push_all = { + desc = "Push all to remote", + handler = M.handle_log_push_all, + }, + push = { + desc = "Push bookmark of revision under cursor to remote", + handler = M.handle_log_push_bookmark, + }, } return cmd.merge_keymaps(cmd.resolve_keymaps_from_specs(keymaps, specs), cmd.terminal_keymaps()) diff --git a/lua/jj/core/runner.lua b/lua/jj/core/runner.lua index 2068e78..457938f 100644 --- a/lua/jj/core/runner.lua +++ b/lua/jj/core/runner.lua @@ -29,5 +29,48 @@ function M.execute_command(cmd, error_prefix, input, silent) return output, success end -return M +--- Execute a system command asynchronously +--- @param cmd string The command to execute +--- @param on_success function|nil Callback on success, receives output as parameter +--- @param error_prefix string|nil Optional error message prefix +--- @param input string|nil Optional input to pass to stdin +--- @param silent boolean|nil Optional to silent the notification +function M.execute_command_async(cmd, on_success, error_prefix, input, silent) + local output_lines = {} + local job_id = vim.fn.jobstart(cmd, { + on_stdout = function(_, data) + for _, line in ipairs(data) do + if line ~= "" then + table.insert(output_lines, line) + end + end + end, + on_exit = function(_, exit_code) + local output = table.concat(output_lines, "\n") + if exit_code == 0 then + if on_success then + on_success(output) + end + else + local error_message + if error_prefix then + error_message = string.format("%s: %s", error_prefix, output) + else + error_message = output + end + if not silent then + vim.notify(error_message, vim.log.levels.ERROR, { title = "JJ" }) + end + end + end, + }) + + -- Send stdin if provided + if input then + vim.fn.chansend(job_id, input) + vim.fn.chanclose(job_id, "stdin") + end +end + +return M \ No newline at end of file diff --git a/lua/jj/utils.lua b/lua/jj/utils.lua index 04cb11a..51c3eac 100644 --- a/lua/jj/utils.lua +++ b/lua/jj/utils.lua @@ -108,9 +108,11 @@ end ---- Notify function to display messages with a title --- @param message string The message to display --- @param level? number The log level (default: INFO) -function M.notify(message, level) +--- @param timeout number? The timeout duration in milliseconds (default: 3000) +function M.notify(message, level, timeout) level = level or vim.log.levels.INFO - vim.notify(message, level, { title = "JJ", timeout = 3000 }) + timeout = timeout or 3000 + vim.notify(message, level, { title = "JJ", timeout = timeout }) end return M