--- @class jj.cmd local M = {} local utils = require("jj.utils") local diff = require("jj.diff") -- Config for cmd module M.config = { describe_editor = "buffer", -- "buffer" or "input" } local state = { -- The current terminal buffer for jj commands --- @type integer|nil buf = nil, -- The current channel to communciate with the terminal --- @type integer|nil chan = nil, --- The current job id for the terminal buffer --- @type integer|nil job_id = nil, -- The current command being displayed --- @type string|nil buf_cmd = nil, -- The floating buffer if any --- @type integer|nil floating_buf = nil, -- The floating channel to communciate with the terminal --- @type integer|nil floating_chan = nil, --- The floating job id for the terminal buffer --- @type integer|nil floating_job_id = nil, } --- Close the current terminal buffer if it exists local function close_terminal_buffer() if state.buf and vim.api.nvim_buf_is_valid(state.buf) then vim.cmd("bwipeout! " .. state.buf) else vim.cmd("close") end end --- Close the current terminal buffer if it exists local function close_floating_buffer() if state.buf and vim.api.nvim_buf_is_valid(state.floating_buf) then vim.cmd("bwipeout! " .. state.floating_buf) else vim.cmd("close") end end --- Hide the current floating window local function hide_floating_window() if state.floating_buf and vim.api.nvim_buf_is_valid(state.floating_buf) then vim.cmd("hide") end end local function handle_status_enter() local file_info = utils.parse_file_info_from_status_line() if not file_info then return end local filepath = file_info.new_path local stat = vim.uv.fs_stat(filepath) if not stat then utils.notify("File not found: " .. filepath, vim.log.levels.ERROR) return end -- Go to the previous window (split above) vim.cmd("wincmd p") -- Open the file in that window, replacing current buffer vim.cmd("edit " .. vim.fn.fnameescape(filepath)) end local function handle_status_restore() local file_info = utils.parse_file_info_from_status_line() if not file_info then return end if file_info.is_rename then -- For renamed files, remove the new file and restore the old one from parent revision local rm_cmd = "rm " .. vim.fn.shellescape(file_info.new_path) local restore_cmd = "jj restore --from @- " .. vim.fn.shellescape(file_info.old_path) local _, rm_success = utils.execute_command(rm_cmd, "Failed to remove renamed file") if rm_success then local _, restore_success = utils.execute_command(restore_cmd, "Failed to restore original file") if restore_success then utils.notify( "Reverted rename: " .. file_info.new_path .. " -> " .. file_info.old_path, vim.log.levels.INFO ) M.status() end end else -- For non-renamed files, use regular restore local restore_cmd = "jj restore " .. vim.fn.shellescape(file_info.old_path) local _, success = utils.execute_command(restore_cmd, "Failed to restore") if success then utils.notify("Restored: " .. file_info.old_path, vim.log.levels.INFO) M.status() end end end --- Extract revision ID from a jujutsu log line --- @param line string The log line to parse --- @return string|nil The revision ID if found, nil otherwise local function get_rev_from_log_line(line) -- Define jujutsu symbols with their UTF-8 byte sequences local jj_symbols = { diamond = "\226\151\134", -- ◆ U+25C6 circle = "\226\151\139", -- ○ U+25CB conflict = "\195\151", -- × U+00D7 } local revset -- Try each symbol pattern for _, symbol in pairs(jj_symbols) do -- Pattern: Lines starting with symbol revset = line:match("^%s*" .. symbol .. "%s+(%w+)") if revset then return revset end -- Pattern: Lines with │ followed by symbol (this are the branches) revset = line:match("^│%s*" .. symbol .. "%s+(%w+)") if revset then return revset end end -- Pattern for simple ASCII symbols revset = line:match("^%s*[@]%s+(%w+)") if revset then return revset end return nil end --- Handle keypress enter on `jj log` buffer to edit a previous revision local function handle_log_enter() local line = vim.api.nvim_get_current_line() local revset = get_rev_from_log_line(line) if revset then -- If we found a revision, edit it local cmd = string.format("jj edit %s", revset) local _, success = utils.execute_command(cmd, "Error editing change") if not success then return end utils.notify(string.format("Editing change: `%s`", revset), vim.log.levels.INFO) -- Close the terminal buffer close_terminal_buffer() end end --- Create a new change relative to the revision under the cursor in a jj log buffer. --- Behavior: --- flag == nil -> branch off the current revision --- flag == "after" -> create a new change after the current revision (-A) --- 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. --- @param flag? 'after' Position relative to the current revision; nil to branch off. --- @param ignore_immut? boolean Pass --ignore-immutable to jj when true. local function handle_log_new(flag, ignore_immut) local line = vim.api.nvim_get_current_line() local revset = get_rev_from_log_line(line) if not revset or revset == "" then return end -- Mapping for flag-specific options and messages. local flag_map = { after = { opt = "-A", err = "Error creating new change after: `%s`", ok = "Successfully created change after: `%s`", }, default = { opt = "", err = "Error creating new change branching off `%s`", ok = "Successfully created change branching off `%s`", }, } local cfg = flag_map[flag] or flag_map.default -- Build command parts local cmd_parts = { "jj", "new" } if cfg.opt ~= "" then table.insert(cmd_parts, cfg.opt) end table.insert(cmd_parts, revset) if ignore_immut then table.insert(cmd_parts, "--ignore-immutable") end local cmd = table.concat(cmd_parts, " ") local _, success = utils.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. M.log() end --- --- --- Create a floating window for terminal output --- @param config table Window configuration options --- @param enter boolean Whether to enter the window after creation --- @return integer buf Buffer number --- @return integer win Window number local function create_floating_window(config, enter) local default_config = { width = math.floor(vim.o.columns * 0.8), height = math.floor(vim.o.lines * 0.8), row = math.floor((vim.o.lines - math.floor(vim.o.lines * 0.8)) / 2), col = math.floor((vim.o.columns - math.floor(vim.o.columns * 0.8)) / 2), relative = "editor", style = "minimal", border = "rounded", title = " JJ Diff ", title_pos = "center", } local merged_config = vim.tbl_extend("force", default_config, config or {}) -- Create buffer local buf = vim.api.nvim_create_buf(false, true) -- Create window local win = vim.api.nvim_open_win(buf, enter or false, merged_config) -- Set buffer options vim.bo[buf].bufhidden = "hide" -- Set window options vim.wo[win].wrap = true vim.wo[win].number = false vim.wo[win].relativenumber = false vim.wo[win].cursorline = false vim.wo[win].signcolumn = "no" return buf, win end --- Run the command in a floating window --- @param cmd string The command to run in the floating window local function run_floating(cmd) -- Clean up previous state if invalid if state.floating_buf and not vim.api.nvim_buf_is_valid(state.floating_buf) then state.floating_buf = nil state.floating_chan = nil state.floating_job_id = nil end -- Stop any running job first if state.floating_job_id then vim.fn.jobstop(state.floating_job_id) state.floating_job_id = nil end -- Close previous channel if state.floating_chan then vim.fn.chanclose(state.floating_chan) state.floating_chan = nil end local win, buf if state.floating_buf and vim.api.nvim_buf_is_valid(state.floating_buf) then -- Find or create a window for the buffer local buf_wins = vim.fn.win_findbuf(state.floating_buf) if #buf_wins > 0 then vim.api.nvim_set_current_win(buf_wins[1]) win = buf_wins[1] else -- If the buffer is hidden create a new window and override the buffer of it _, win = create_floating_window({}, true) vim.api.nvim_win_set_buf(win, state.floating_buf) end else -- Otherwise create a new buffer buf, win = create_floating_window({}, true) state.floating_buf = buf end -- Create new terminal channel local chan = vim.api.nvim_open_term(state.floating_buf, {}) if not chan or chan <= 0 then vim.notify("Failed to create terminal channel", vim.log.levels.ERROR) return end state.floating_chan = chan -- Clear terminal before running new command vim.api.nvim_chan_send(chan, "\27[H\27[2J") local jid = vim.fn.jobstart(cmd, { pty = true, width = vim.api.nvim_win_get_width(win), height = vim.api.nvim_win_get_height(win), env = { TERM = "xterm-256color", PAGER = "cat", DELTA_PAGER = "cat", COLORTERM = "truecolor", DFT_BACKGROUND = "light", }, on_stdout = function(_, data) if not vim.api.nvim_buf_is_valid(state.floating_buf) then return end local output = table.concat(data, "\n") vim.api.nvim_chan_send(chan, output) end, on_exit = function(_, _) vim.schedule(function() if vim.api.nvim_buf_is_valid(state.floating_buf) then vim.bo[state.floating_buf].modifiable = false if vim.api.nvim_get_current_buf() == state.floating_buf then vim.cmd("stopinsert") end end end) end, }) -- Set keymaps only if they haven't been set for this buffer if not vim.b[state.floating_buf].jj_keymaps_set then vim.keymap.set( { "n", "v" }, "i", function() end, { buffer = state.floating_buf, noremap = true, silent = true } ) vim.keymap.set( { "n", "v" }, "c", function() end, { buffer = state.floating_buf, noremap = true, silent = true } ) vim.keymap.set( { "n", "v" }, "a", function() end, { buffer = state.floating_buf, noremap = true, silent = true } ) vim.keymap.set( { "n", "v" }, "q", close_floating_buffer, { buffer = state.floating_bufbuf, noremap = true, silent = true, desc = "Close the floating buffer" } ) vim.keymap.set( { "n" }, "", hide_floating_window, { buffer = state.floating_bufbuf, noremap = true, silent = true, desc = "Hide the buffer" } ) vim.b[state.floating_buf].jj_keymaps_set = true end -- Set up cleanup autocmd only once per buffer if not vim.b[state.floating_buf].jj_cleanup_set then vim.api.nvim_create_autocmd({ "BufWipeout", "BufDelete" }, { buffer = state.floating_buf, callback = function() if state.floating_buf and vim.api.nvim_buf_is_valid(state.floating_buf) then state.floating_buf = nil end if state.floating_chan then vim.fn.chanclose(chan) end if jid then vim.fn.jobstop(jid) end end, }) vim.b[state.floating_buf].jj_cleanup_set = true end end --- Handle diffing a log line local function handle_log_diff() local line = vim.api.nvim_get_current_line() local revset = get_rev_from_log_line(line) if revset then local cmd = string.format("jj show %s", revset) run_floating(cmd) else utils.notify("No valid revision found in the log line", vim.log.levels.ERROR) end end --- Run a command and show it's output in a terminal buffer --- If a previous command already existed it smartly reuses the buffer cleaning the previous output ---@param cmd string local function run(cmd) -- Clean up previous state if invalid if state.buf and not vim.api.nvim_buf_is_valid(state.buf) then state.buf = nil state.chan = nil state.job_id = nil state.buf_cmd = nil end -- Stop any running job first if state.job_id then vim.fn.jobstop(state.job_id) state.job_id = nil end -- Close previous channel if state.chan then vim.fn.chanclose(state.chan) state.chan = nil end local win if state.buf and vim.api.nvim_buf_is_valid(state.buf) then -- Find or create a window for the buffer local buf_wins = vim.fn.win_findbuf(state.buf) if #buf_wins > 0 then vim.api.nvim_set_current_win(buf_wins[1]) win = buf_wins[1] else vim.cmd("split") win = vim.api.nvim_get_current_win() vim.api.nvim_win_set_buf(win, state.buf) end else -- Create new terminal buffer vim.cmd("split") win = vim.api.nvim_get_current_win() state.buf = vim.api.nvim_create_buf(false, true) vim.api.nvim_win_set_buf(win, state.buf) -- Set buffer options only once vim.bo[state.buf].bufhidden = "wipe" end -- Create new terminal channel local chan = vim.api.nvim_open_term(state.buf, {}) if not chan or chan <= 0 then vim.notify("Failed to create terminal channel", vim.log.levels.ERROR) return end state.chan = chan -- Split the current command in parts for further use local cmd_parts = vim.split(cmd, "%s+") -- Clear terminal before running new command vim.api.nvim_chan_send(chan, "\27[H\27[2J") local jid = vim.fn.jobstart(cmd, { pty = true, width = vim.api.nvim_win_get_width(win), height = vim.api.nvim_win_get_height(win), env = { TERM = "xterm-256color", PAGER = "cat", DELTA_PAGER = "cat", COLORTERM = "truecolor", DFT_BACKGROUND = "light", }, on_stdout = function(_, data) if not vim.api.nvim_buf_is_valid(state.buf) or not state.chan then return end local output = table.concat(data, "\n") vim.api.nvim_chan_send(state.chan, output) end, on_exit = function(_, exit_code) vim.schedule(function() -- Make the buffer not modifiable if vim.api.nvim_buf_is_valid(state.buf) then vim.bo[state.buf].modifiable = false if vim.api.nvim_get_current_buf() == state.buf then vim.cmd("stopinsert") end end -- Store the subcommand on successful exit if exit_code == 0 then state.buf_cmd = cmd_parts[2] or nil end end) end, }) if jid <= 0 then vim.api.nvim_chan_send(chan, "Failed to start command: " .. cmd .. "\r\n") state.chan = nil else state.job_id = jid end -- Set keymaps only if they haven't been set for this buffer -- Set base keymaps only if they haven't been set for this buffer yet if not vim.b[state.buf].jj_keymaps_set then vim.keymap.set({ "n", "v" }, "i", function() end, { buffer = state.buf, noremap = true, silent = true }) vim.keymap.set({ "n", "v" }, "c", function() end, { buffer = state.buf, noremap = true, silent = true }) vim.keymap.set({ "n", "v" }, "a", function() end, { buffer = state.buf, noremap = true, silent = true }) vim.keymap.set( { "n", "v" }, "q", close_terminal_buffer, { buffer = state.buf, noremap = true, silent = true, desc = "Close the terminal buffer" } ) vim.keymap.set( { "n" }, "", close_terminal_buffer, { buffer = state.buf, noremap = true, silent = true, desc = "Close the terminal buffer" } ) vim.b[state.buf].jj_keymaps_set = true end -- Remove command-specific keymaps from previous runs if vim.b[state.buf].jj_command_keymaps then for _, map in ipairs(vim.b[state.buf].jj_command_keymaps) do local modes = map.modes if type(modes) ~= "table" then modes = { modes } end for _, mode in ipairs(modes) do pcall(vim.keymap.del, mode, map.lhs, { buffer = state.buf }) end end vim.b[state.buf].jj_command_keymaps = nil end -- Add command-specific keymaps for jj buffers local new_command_keymaps = {} local function register_command_keymap(modes, lhs, rhs, opts) local normalized_modes = type(modes) == "table" and vim.deepcopy(modes) or { modes } opts = opts or {} opts.buffer = state.buf if opts.noremap == nil then opts.noremap = true end if opts.silent == nil then opts.silent = true end vim.keymap.set(modes, lhs, rhs, opts) table.insert(new_command_keymaps, { modes = normalized_modes, lhs = lhs }) end -- Add Enter key mapping for status buffers to open files if cmd_parts[2] == "st" or cmd_parts[2] == "status" then register_command_keymap({ "n" }, "", handle_status_enter, { desc = "Open file under cursor" }) register_command_keymap({ "n" }, "X", handle_status_restore, { desc = "Restore file under cursor" }) elseif cmd_parts[2] == "log" then -- Edit register_command_keymap({ "n" }, "", handle_log_enter, { desc = "Edit change under cursor" }) -- Diff register_command_keymap({ "n" }, "d", handle_log_diff, { desc = "Diff change under cursor" }) -- New register_command_keymap({ "n" }, "n", handle_log_new, { desc = "New change off the change under cursor" }) register_command_keymap({ "n" }, "", function() handle_log_new("after") end, { desc = "New change after the change under cursor" }) register_command_keymap({ "n" }, "", function() handle_log_new("after", true) end, { desc = "New change after the change under cursor ignoring immutability" }) -- Undo/Redo register_command_keymap({ "n" }, "u", function() M.undo() end, { desc = "Undo last operation" }) register_command_keymap({ "n" }, "r", function() M.redo() end, { desc = "Redo last operation" }) end if #new_command_keymaps > 0 then vim.b[state.buf].jj_command_keymaps = new_command_keymaps end vim.cmd("stopinsert") -- Set up cleanup autocmd only once per buffer if not vim.b[state.buf].jj_cleanup_set then vim.api.nvim_create_autocmd({ "BufWipeout", "BufDelete" }, { buffer = state.buf, callback = function() if state.buf and vim.api.nvim_buf_is_valid(state.buf) then state.buf = nil end if state.chan then vim.fn.chanclose(state.chan) state.chan = nil end if state.job_id then vim.fn.jobstop(state.job_id) state.job_id = nil end state.buf_cmd = nil end, }) vim.b[state.buf].jj_cleanup_set = true end end --- Execute jj describe command with the given description ---@param description string The description text local function execute_describe(description) if not description or description == "" then utils.notify("Description cannot be empty", vim.log.levels.ERROR) return end -- Use --stdin to properly handle multi-line and special characters local _, success = utils.execute_command("jj describe --stdin", "Failed to describe", description) if success then utils.notify("Description set.", vim.log.levels.INFO) end end --- @class jj.cmd.describe_opts --- @field with_status boolean: Whether or not `jj st` should be displayed in a buffer while describing the commit --- @type jj.cmd.describe_opts local default_describe_opts = { with_status = true, } --- Jujutsu describe ---@param description? string Optional description text ---@param opts? jj.cmd.describe_opts Optional command options function M.describe(description, opts) if not utils.ensure_jj() then return end -- Check if a description was provided otherwise require for input if description then -- Description provided directly execute_describe(description) else -- Use buffer editor mode if M.config.describe_editor == "buffer" then -- Build initial lines local status_files = utils.get_status_files() local text = { "JJ: This commit contains the following changes:" } for _, item in ipairs(status_files) do table.insert(text, string.format("JJ: %s %s", item.status, item.file)) end table.insert(text, "JJ:") -- blank line table.insert(text, 'JJ: Lines starting with "JJ:" (like this one) will be ignored when finalizing') table.insert(text, "") -- Empty line to separate from user input table.insert(text, "") -- Another empty line where user can start typing utils.open_ephemeral_buffer(text, function(buf_lines) local user_lines = {} for _, line in ipairs(buf_lines) do if not line:match("^JJ:") then table.insert(user_lines, line) end end -- Join lines and trim leading/trailing whitespace local trimmed_description = table.concat(user_lines, "\n"):gsub("^%s+", ""):gsub("%s+$", "") execute_describe(trimmed_description) end) else local merged_opts = vim.tbl_deep_extend("force", default_describe_opts, opts or {}) if merged_opts.with_status then -- Show the status in a terminal buffer M.status() end vim.ui.input({ prompt = "Description: ", default = "", }, function(input) -- If the user inputed something, execute the describe command if input then execute_describe(input) end -- Close the current terminal when finished close_terminal_buffer() end) end end end --- Jujutsu status. -- -- it executes `jj st` and either: -- 1. Shows the output in a notification (if `opts.notify` is true), or -- 2. Displays it in the buffer by default. -- -- @param opts? table Optional settings: -- @field notify boolean If true, show the status in a notification instead of buffer. function M.status(opts) if not utils.ensure_jj() then return end local cmd = "jj st" if opts and opts.notify then local output = utils.execute_command(cmd, "Failed to get status") if output then utils.notify(output, vim.log.levels.INFO) end else -- Default behavior: show in buffer run(cmd) end end --- @class jj.cmd.new_opts --- @field show_log? boolean Whether or not to display the log command after creating a new --- @field with_input? boolean Whether or not to use nvim input to decide the parent of the new commit --- @field args? string The arguments to append to the new command --- Jujutsu new ---@param opts jj.cmd.new_opts|nil function M.new(opts) if not utils.ensure_jj() then return end ---@param cmd string local function execute_new(cmd) utils.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 and opts.show_log then M.log() end end -- If the user wants use input mode if opts and opts.with_input then if opts.show_log then M.log() end vim.ui.input({ prompt = "Parent(s) of the new change [default: @]", }, function(input) if input then execute_new(string.format("jj new %s", input)) end close_terminal_buffer() end) else -- Otherwise follow a classic flow for inputing local cmd = "jj new" if opts and opts.args then cmd = string.format("jj new %s", opts.args) end execute_new(cmd) -- If the show log is enabled show log if opts and opts.show_log then M.log() end end end -- Jujutsu edit function M.edit() if not utils.ensure_jj() then return end M.log({}) vim.ui.input({ prompt = "Change to edit: ", default = "", }, function(input) -- If the user inputed something, execute the describe command if input then local _, success = utils.execute_command(string.format("jj edit %s", input), "Error editing change") if not success then return end -- If ok update the log window M.log({}) else -- If user exited without saving discard the log close_terminal_buffer() end end) end --- Jujutsu squash function M.squash() if not utils.ensure_jj() then return end local cmd = "jj squash" local _, success = utils.execute_command(cmd, "Failed to squash") if success then utils.notify("Command `squash` was succesful.", vim.log.levels.INFO) if state.buf_cmd == "log" then M.log() end end end ---@class jj.cmd.log_opts ---@field summary? boolean: Show a summary of the log ---@field reversed? boolean: Show the log in reverse order ---@field no_graph? boolean: Do not show the graph in the log output ---@field limit? uinteger : Limit the number of log entries shown, defaults to 20 if not provided ---@field revisions? string: Which revisions to show --- @type jj.cmd.log_opts local default_log_opts = { --- @type boolean summary = false, --- @type boolean reversed = false, --- @type boolean no_graph = false, --- @type uinteger limit = 20, } --- Jujutsu log ---@param opts jj.cmd.log_opts|nil Command options from nvim_create_user_command function M.log(opts) if not utils.ensure_jj() then return end local cmd = "jj log" -- Merge default options with provided ones local merged_opts = vim.tbl_extend("force", default_log_opts, opts or {}) -- Add options to the command for key, value in pairs(merged_opts) do -- Replace _ with - for command line options key = key:gsub("_", "-") -- Handle special cases such as limit if key == "limit" and value then cmd = string.format("%s --%s %d", cmd, key, value) elseif key == "revisions" and value then cmd = string.format("%s --%s %s", cmd, key, value) elseif value then -- Simply append the option cmd = string.format("%s --%s", cmd, key) end end run(cmd) end ---@class jj.cmd.diff_opts ---@field current boolean Wether or not to only diff the current buffer --- Jujutsu diff --- @param opts? jj.cmd.diff_opts The options for the diff command function M.diff(opts) if not utils.ensure_jj() then return end local cmd = "jj diff" if opts and opts.current then local file = vim.fn.expand("%:p") if file and file ~= "" then cmd = string.format("%s %s", cmd, vim.fn.fnameescape(file)) else utils.notify("Current buffer is not a file", vim.log.levels.ERROR) return end end run(cmd) end --- Jujutsu rebase function M.rebase() if not utils.ensure_jj() then return end -- show log before rebasing M.log({}) vim.ui.input({ prompt = "Rebase destination: ", default = "trunk()", }, function(input) 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 = utils.execute_command(cmd, "Error rebasing") if success then utils.notify("Rebase successful.", vim.log.levels.INFO) M.log({}) end else close_terminal_buffer() end end) end --- Jujutsu create bookmark function M.bookmark_create() if not utils.ensure_jj() then return end -- show log before rebasing M.log({}) vim.ui.input({ prompt = "Bookmark name: ", }, function(input) if input then local cmd = string.format("jj b c %s", input) local _, success = utils.execute_command(cmd, "Error creating bookmark") if success then utils.notify(string.format("Bookmark `%s` created successfully for @", input), vim.log.levels.INFO) M.log({}) end else close_terminal_buffer() end end) end --- Jujutsu delete bookmark function M.bookmark_delete() if not utils.ensure_jj() then return end -- show log before rebasing M.log({}) vim.ui.input({ prompt = "Bookmark name: ", }, function(input) if input then local cmd = string.format("jj b d %s", input) local _, success = utils.execute_command(cmd, "Error deleting bookmark") if success then utils.notify(string.format("Bookmark `%s` deleted successfully.", input), vim.log.levels.INFO) M.log({}) end else close_terminal_buffer() end end) end --- Jujutsu undo function M.undo() if not utils.ensure_jj() then return end local cmd = "jj undo" local _, success = utils.execute_command(cmd, "Failed to undo") if success then utils.notify("Command `undo` was succesful.", vim.log.levels.INFO) if state.buf_cmd == "log" then M.log({}) end end end --- --- Jujutsu redo function M.redo() if not utils.ensure_jj() then return end local cmd = "jj redo" local _, success = utils.execute_command(cmd, "Failed to redo") if success then utils.notify("Command `redo` was succesful.", vim.log.levels.INFO) if state.buf_cmd == "log" then M.log({}) end end end --- @param args string|string[] jj command arguments function M.j(args) if not utils.ensure_jj() then return end if #args == 0 then local default_cmd, success = utils.execute_command("jj config get ui.default-command", "Error getting user's default command", nil, true) if not success or default_cmd == "" then run("jj") return end args = { vim.trim(default_cmd or "") } end -- Normalize to table if type(args) == "string" then args = vim.split(args, "%s+") end local subcommand = args[1] local remaining_args = vim.list_slice(args, 2) local cmd = string.format("jj %s", table.concat(args, " ")) local remaining_args_str = table.concat(remaining_args, " ") -- Dispatch table for known subcommands local handlers = { describe = function() M.describe(remaining_args_str ~= "" and remaining_args_str or nil) end, desc = function() M.describe(remaining_args_str ~= "" and remaining_args_str or nil) end, edit = function() if #remaining_args == 0 then M.edit() else run(cmd) end end, new = function() M.new({ show_log = true, args = remaining_args_str, with_input = false }) end, rebase = function() M.rebase() end, undo = function() M.undo() end, redo = function() M.redo() end, } if handlers[subcommand] then handlers[subcommand]() else run(cmd) end end --- Handle J command with subcommands and direct jj passthrough ---@param opts table Command options from nvim_create_user_command local function handle_j_command(opts) local args = opts.fargs M.j(args) end --- Register the J and Jdiff commands function M.register_command() vim.api.nvim_create_user_command("J", handle_j_command, { nargs = "*", complete = function(arglead, _, _) -- Basic completion for common jj subcommands local subcommands = { "log", "status", "st", "diff", "describe", "new", "squash", "bookmark", "edit", "abandon", "b", "git", "rebase", "abandon", "undo", "redo", } local matches = {} for _, cmd in ipairs(subcommands) do if cmd:match("^" .. vim.pesc(arglead)) then table.insert(matches, cmd) end end return matches end, desc = "Execute jj commands with subcommand support", }) -- Unified creation of jj diff commands with optional revision argument local function create_diff_command(name, fn, desc) vim.api.nvim_create_user_command(name, function(opts) local rev = opts.fargs[1] if rev then fn({ rev = rev }) else fn() end end, { nargs = "?", desc = desc .. " (optionally pass jj revision)", }) end -- Commands: -- Jdiff : vertical diff by default -- Jhdiff : horizontal diff -- Jvdiff : vertical diff (explicit) create_diff_command("Jdiff", diff.open_vdiff, "Vertical diff against jj revision") create_diff_command("Jhdiff", diff.open_hdiff, "Horizontal diff against jj revision") create_diff_command("Jvdiff", diff.open_vdiff, "Vertical diff against jj revision") end return M