From e545d02ceb0c7a40e5cc578edaf1db2a50d35b3f Mon Sep 17 00:00:00 2001 From: NicolasGB Date: Fri, 21 Nov 2025 11:08:24 +0100 Subject: [PATCH] refactor: restructure the whole plugin following a better separation of concers --- README.md | 67 ++- lua/jj/cmd.lua | 986 +++++++-------------------------------- lua/jj/core/buffer.lua | 0 lua/jj/core/parser.lua | 149 ++++++ lua/jj/core/runner.lua | 33 ++ lua/jj/init.lua | 34 +- lua/jj/picker.lua | 5 +- lua/jj/picker/snacks.lua | 3 +- lua/jj/ui/editor.lua | 172 +++++++ lua/jj/ui/terminal.lua | 620 ++++++++++++++++++++++++ lua/jj/utils.lua | 343 +------------- 11 files changed, 1237 insertions(+), 1175 deletions(-) create mode 100644 lua/jj/core/buffer.lua create mode 100644 lua/jj/core/parser.lua create mode 100644 lua/jj/core/runner.lua create mode 100644 lua/jj/ui/editor.lua create mode 100644 lua/jj/ui/terminal.lua diff --git a/README.md b/README.md index 9f18cee..1fb9cca 100644 --- a/README.md +++ b/README.md @@ -172,48 +172,81 @@ require("jj").setup({ ## Example config ```lua + { + "nicolasgb/jj.nvim", + dependencies = { + "folke/snacks.nvim", -- Optional only if you use picker's + }, + config = function() - require("jj").setup({ + + local jj = require("jj") + jj.setup({ + highlights = { -- Customize colors if desired modified = { fg = "#89ddff" }, } }) - local cmd = require("jj.cmd") - vim.keymap.set("n", "jd", cmd.describe, { desc = "JJ describe" }) - vim.keymap.set("n", "jl", cmd.log, { desc = "JJ log" }) - vim.keymap.set("n", "je", cmd.edit, { desc = "JJ edit" }) - vim.keymap.set("n", "jn", cmd.new, { desc = "JJ new" }) - vim.keymap.set("n", "js", cmd.status, { desc = "JJ status" }) - vim.keymap.set("n", "dj", cmd.diff, { desc = "JJ diff" }) - vim.keymap.set("n", "sj", cmd.squash, { desc = "JJ squash" }) - vim.keymap.set("n", "ju", cmd.undo, { desc = "JJ undo" }) - vim.keymap.set("n", "jy", cmd.redo, { desc = "JJ redo" }) + + + vim.keymap.set("n", "jd", jj.describe, { desc = "JJ describe" }) + + vim.keymap.set("n", "jl", jj.log, { desc = "JJ log" }) + + vim.keymap.set("n", "je", jj.edit, { desc = "JJ edit" }) + + vim.keymap.set("n", "jn", jj.new, { desc = "JJ new" }) + + vim.keymap.set("n", "js", jj.status, { desc = "JJ status" }) + + vim.keymap.set("n", "sj", jj.squash, { desc = "JJ squash" }) + + vim.keymap.set("n", "ju", jj.undo, { desc = "JJ undo" }) + + vim.keymap.set("n", "jy", jj.redo, { desc = "JJ redo" }) + + + + -- Using the new `diff` table for clarity + + vim.keymap.set("n", "dj", jj.diff.vsplit, { desc = "JJ diff vertical" }) + + -- Pickers - local picker = require("jj.picker") - vim.keymap.set("n", "gj", picker.status, { desc = "JJ Picker status" }) - vim.keymap.set("n", "gl", picker.file_history, { desc = "JJ Picker file history" }) - -- Some functions like `describe` or `log` can take parameters + vim.keymap.set("n", "gj", jj.picker.status, { desc = "JJ Picker status" }) + + vim.keymap.set("n", "gl", jj.picker.file_history, { desc = "JJ Picker file history" }) + + + + -- Some functions like `log` can take parameters + vim.keymap.set("n", "jL", function() jj.log { revisions = "'all()'", -- equivalent to jj log -r :: } end, { desc = "JJ log all" }) + + -- This is an alias i use for moving bookmarks its so good + vim.keymap.set("n", "jt", function() - cmd.j "tug" - cmd.log {} + jj.j "tug" + jj.log {} end, { desc = "JJ tug" }) + end, + } ``` diff --git a/lua/jj/cmd.lua b/lua/jj/cmd.lua index 8f78b47..31c16ac 100644 --- a/lua/jj/cmd.lua +++ b/lua/jj/cmd.lua @@ -2,683 +2,52 @@ local M = {} local utils = require("jj.utils") +local runner = require("jj.core.runner") +local parser = require("jj.core.parser") +local terminal = require("jj.ui.terminal") +local editor = require("jj.ui.editor") local diff = require("jj.diff") -- Config for cmd module +--- @class jj.cmd.opts M.config = { + --- @type "buffer"|"input" Editor mode for describe command: "buffer" (Git-style editor) or "input" (simple input prompt) 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 not state.buf then - return - elseif 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 not state.floating_buf then - return - elseif state.floating_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 not state.floating_buf then - return - elseif 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 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. ---- @param ignore_immut? boolean Pass --ignore-immutable to jj edit when true. -local function handle_log_enter(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 - -- If we found a revision, edit it. - - -- Build command parts. - local cmd_parts = { "jj", "edit" } - if ignore_immut then - table.insert(cmd_parts, "--ignore-immutable") - end - - table.insert(cmd_parts, revset) - - -- Build cmd string - local cmd = table.concat(cmd_parts, " ") - - -- Try to execute cmd - 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 - ---- 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 - - -- Wipe old buffer if it exists - if state.floating_buf and vim.api.nvim_buf_is_valid(state.floating_buf) then - vim.api.nvim_buf_delete(state.floating_buf, { force = true }) - state.floating_buf = nil - end - - -- Create new floating buffer - local buf, win = create_floating_window({}, true) - state.floating_buf = buf - - -- 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 - - -- Move cursor to top before output arrives - vim.api.nvim_win_set_cursor(win, { 1, 0 }) - - 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 - ---- Handle describign a log line -local function handle_log_describe() - local line = vim.api.nvim_get_current_line() - local revset = get_rev_from_log_line(line) - if revset then - M.describe(nil, revset) - 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 - - -- Wipe old buffer if it exists - if state.buf and vim.api.nvim_buf_is_valid(state.buf) then - vim.api.nvim_buf_delete(state.buf, { force = true }) - state.buf = nil - end - - -- Create new terminal buffer - local height = math.floor(vim.o.lines / 2) - vim.cmd(string.format("%dsplit", height)) - - local 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) - vim.bo[state.buf].bufhidden = "wipe" - - -- 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 - - -- Move cursor to top before output arrives - vim.api.nvim_win_set_cursor(win, { 1, 0 }) - - local cmd_parts = vim.split(cmd, "%s+") - - 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" }, "", function() - handle_log_enter(false) - end, { desc = "Edit change under cursor" }) - register_command_keymap({ "n" }, "", function() - handle_log_enter(true) - end, { desc = "Edit change under cursor ignoring immutability" }) - -- 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" }) - register_command_keymap({ "n" }, "D", handle_log_describe, { desc = "Describe change under cursor" }) - 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, revset) - if not description or description == "" then - utils.notify("Description cannot be empty", vim.log.levels.ERROR) - return - end - if revset == nil then - -- 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 - else - -- Use --stdin to properly handle multi-line and special characters - local cmd = "jj describe -r " .. revset .. " --stdin" - local _, success = utils.execute_command(cmd, "Failed to describe", description) - if success then - utils.notify("Description set.", vim.log.levels.INFO) - end - 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 +--- Execute jj describe command with the given description +--- @param description string The description text +--- @param revset? string The revision to describe +local function execute_describe(description, revset) + if not description or description == "" then + utils.notify("Description cannot be empty", vim.log.levels.ERROR) + return + end + + local cmd = "jj describe" + if revset then + cmd = cmd .. " -r " .. revset + end + 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 + utils.notify("Description set.", vim.log.levels.INFO) + end +end + +-- Jujutsu describe +--- @param description? string Optional description text +--- @param revset? string The revision to describe +--- @param opts? jj.cmd.describe_opts Optional command options function M.describe(description, revset, opts) if not utils.ensure_jj() then return @@ -688,72 +57,75 @@ function M.describe(description, revset, opts) if description then -- Description provided directly execute_describe(description, revset) - else - -- Use buffer editor mode - if M.config.describe_editor == "buffer" then - -- Build initial lines - if not revset then - revset = "@" - end - local cmd = "jj log -r " .. revset .. " --no-graph -T 'coalesce(description, \"\n\")'" - local old_description_raw, success = utils.execute_command(cmd, "Failed to get old description") - if not old_description_raw or not success then - return - end - local old_description = vim.trim(old_description_raw) - local status_files = utils.get_status_files(revset) - local text = { old_description } - table.insert(text, "") -- Empty line to separate from user input - table.insert(text, "JJ: Change ID: " .. revset) - table.insert(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 removed') + return + end - 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, revset) - end) - close_terminal_buffer() - 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 + if not revset then + revset = "@" + end - vim.ui.input({ - prompt = "Description: ", - default = "", - }, function(input) - -- If the user inputed something, execute the describe command - if input then - execute_describe(input, revset) - end - -- Close the current terminal when finished - close_terminal_buffer() - end) + -- Use buffer editor mode + if M.config.describe_editor == "buffer" then + local cmd = "jj log -r " .. revset .. " --no-graph -T 'coalesce(description, \"\n\")'" + local old_description_raw, success = runner.execute_command(cmd, "Failed to get old description") + if not old_description_raw or not success then + return end + + local log_cmd = "jj log -r " .. revset .. " --no-graph -T 'self.diff().summary()'" + local status_result, success2 = runner.execute_command(log_cmd, "Error getting status") + if not success2 then + return + end + + local status_files = parser.get_status_files(status_result) + local old_description = vim.trim(old_description_raw) + + local text = { old_description } + table.insert(text, "") -- Empty line to separate from user input + table.insert(text, "JJ: Change ID: " .. revset) + table.insert(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 removed') + + editor.open_editor(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, revset) + end) + terminal.close_terminal_buffer() + else + -- Use input mode + 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, revset) + end + -- Close the current terminal when finished + terminal.close_terminal_buffer() + 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. +-- Jujutsu status. function M.status(opts) if not utils.ensure_jj() then return @@ -762,13 +134,13 @@ function M.status(opts) 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) + local output, success = runner.execute_command(cmd, "Failed to get status") + if success then + utils.notify(output and output or "", vim.log.levels.INFO) end else -- Default behavior: show in buffer - run(cmd) + terminal.run(cmd) end end @@ -777,25 +149,27 @@ end --- @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 +-- Jujutsu new +--- @param opts? jj.cmd.new_opts function M.new(opts) if not utils.ensure_jj() then return end - ---@param cmd string + opts = opts or {} + + --- @param cmd string local function execute_new(cmd) - utils.execute_command(cmd, "Failed to create new change") + 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 and opts.show_log then + if opts.show_log then M.log() end end -- If the user wants use input mode - if opts and opts.with_input then + if opts.with_input then if opts.show_log then M.log() end @@ -806,18 +180,18 @@ function M.new(opts) if input then execute_new(string.format("jj new %s", input)) end - close_terminal_buffer() + terminal.close_terminal_buffer() end) else -- Otherwise follow a classic flow for inputing local cmd = "jj new" - if opts and opts.args then + if 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 + if opts.show_log then M.log() end end @@ -833,91 +207,71 @@ function M.edit() 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") + local _, success = runner.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() + terminal.close_terminal_buffer() end end) end ---- Jujutsu squash +-- 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") + local _, success = runner.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 + if terminal.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 +--- @class jj.cmd.log_opts +--- @field summary? boolean +--- @field reversed? boolean +--- @field no_graph? boolean +--- @field limit? uinteger +--- @field revisions? string ---- @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 +local default_log_opts = { summary = false, reversed = false, no_graph = false, limit = 20 } + +-- Jujutsu log +--- @param opts? jj.cmd.log_opts 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) + terminal.run(cmd) end ----@class jj.cmd.diff_opts ----@field current boolean Wether or not to only diff the current buffer +--- @class jj.cmd.diff_opts +--- @field current boolean Wether or not to only diff the current buffer ---- Jujutsu diff +-- Jujutsu diff --- @param opts? jj.cmd.diff_opts The options for the diff command function M.diff(opts) if not utils.ensure_jj() then @@ -936,16 +290,15 @@ function M.diff(opts) end end - run(cmd) + terminal.run(cmd) end ---- Jujutsu rebase +-- 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: ", @@ -954,92 +307,90 @@ 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 = utils.execute_command(cmd, "Error rebasing") + local _, success = runner.execute_command(cmd, "Error rebasing") if success then utils.notify("Rebase successful.", vim.log.levels.INFO) M.log({}) end else - close_terminal_buffer() + terminal.close_terminal_buffer() end end) end ---- Jujutsu create bookmark +-- 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") + local _, success = runner.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() + terminal.close_terminal_buffer() end end) end ---- Jujutsu delete bookmark +-- 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") + local _, success = runner.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() + terminal.close_terminal_buffer() end end) end ---- Jujutsu undo +-- 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") + local _, success = runner.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 + if terminal.state.buf_cmd == "log" then M.log({}) end end end ---- ---- Jujutsu redo + +-- 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") + local _, success = runner.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 + if terminal.state.buf_cmd == "log" then M.log({}) end end @@ -1052,17 +403,25 @@ function M.j(args) end if #args == 0 then - -- Parse the default command - local default_cmd = utils.parse_default_cmd() - -- If nil simply run jj + local default_cmd_str, success = runner.execute_command( + "jj config get ui.default-command", + "Error getting user's default command", + nil, + true + ) + if not success then + terminal.run("jj") + return + end + + local default_cmd = parser.parse_default_cmd(default_cmd_str and default_cmd_str or "") if default_cmd == nil then - run("jj") + terminal.run("jj") return end args = default_cmd end - -- Normalize to table if type(args) == "string" then args = vim.split(args, "%s+") end @@ -1072,7 +431,6 @@ function M.j(args) 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) @@ -1084,7 +442,7 @@ function M.j(args) if #remaining_args == 0 then M.edit() else - run(cmd) + terminal.run(cmd) end end, new = function() @@ -1104,23 +462,22 @@ function M.j(args) if handlers[subcommand] then handlers[subcommand]() else - run(cmd) + terminal.run(cmd) end end ---- Handle J command with subcommands and direct jj passthrough ----@param opts table Command options from nvim_create_user_command +-- 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) + M.j(opts.fargs) end ---- Register the J and Jdiff commands +-- 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", @@ -1139,7 +496,6 @@ function M.register_command() "undo", "redo", } - local matches = {} for _, cmd in ipairs(subcommands) do if cmd:match("^" .. vim.pesc(arglead)) then @@ -1151,7 +507,6 @@ function M.register_command() 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] @@ -1160,16 +515,9 @@ function M.register_command() else fn() end - end, { - nargs = "?", - desc = desc .. " (optionally pass jj revision)", - }) + 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") diff --git a/lua/jj/core/buffer.lua b/lua/jj/core/buffer.lua new file mode 100644 index 0000000..e69de29 diff --git a/lua/jj/core/parser.lua b/lua/jj/core/parser.lua new file mode 100644 index 0000000..dc11b24 --- /dev/null +++ b/lua/jj/core/parser.lua @@ -0,0 +1,149 @@ +--- @class jj.core.parser +local M = {} + +--- Parse the default command from jj config +--- @param cmd_output string The output from `jj config get ui.default-command` +--- @return table|nil args Array of command arguments, or nil if parsing fails +function M.parse_default_cmd(cmd_output) + if not cmd_output or cmd_output == "" then + return nil + end + + -- Remove whitespace and parse TOML output + local trimmed_cmd = vim.trim(cmd_output) + + -- Try to parse as TOML array: ["item1", "item2", ...] + -- Pattern "%[(.*)%]" captures everything between square brackets + local array_items = trimmed_cmd:match("%[(.*)%]") + if array_items then + local args = {} + -- Pattern '"([^"]+)"' captures content between double quotes (non-greedy) + for item in array_items:gmatch('"([^"]+)"') do + table.insert(args, item) + end + return #args > 0 and args or nil + else + -- Single string value, remove surrounding quotes if present + -- Pattern '^"?(.-)"?$' optionally matches quotes at start/end, captures content + local single_value = trimmed_cmd:match('^"?(.-)"?$') + return single_value and { single_value } or nil + end +end + +--- Get a list of files with their status in the current jj repository. +--- @type string status_output The output from `jj status` command +--- @return table[] A list of tables with {status = string, file = string} +function M.get_status_files(status_output) + if not status_output then + return {} + end + + local files = {} + -- Parse jj status output: "M filename", "A filename", "D filename", "R old => new" + for line in status_output:gmatch("[^\r\n]+") do + local status, file = line:match("^([MADRC])%s+(.+)$") + if status and file then + table.insert(files, { status = status, file = file }) + end + end + + return files +end + +--- Parse the current line in the jj status buffer to extract file information. +--- Handles renamed files and regular status lines. +--- @return table|nil A table with {old_path = string, new_path = string, is_rename = boolean}, or nil if parsing fails +function M.parse_file_info_from_status_line(line) + -- Handle renamed files: "R path/{old_name => new_name}" or "R old_path => new_path" + local rename_pattern_curly = "^R (.*)/{(.*) => ([^}]+)}" + local dir_path, old_name, new_name = line:match(rename_pattern_curly) + + if dir_path and old_name and new_name then + return { + old_path = dir_path .. "/" .. old_name, + new_path = dir_path .. "/" .. new_name, + is_rename = true, + } + else + -- Try simple rename pattern: "R old_path => new_path" + local rename_pattern_simple = "^R (.*) => (.+)$" + local old_path, new_path = line:match(rename_pattern_simple) + if old_path and new_path then + return { + old_path = old_path, + new_path = new_path, + is_rename = true, + } + end + end + + -- Not a rename, try regular status patterns + local filepath + -- Handle renamed files: "R path/{old_name => new_name}" or "R old_path => new_path" + local rename_pattern_curly_new = "^R (.*)/{.* => ([^}]+)}" + local dir_path_new, renamed_file = line:match(rename_pattern_curly_new) + + if dir_path_new and renamed_file then + filepath = dir_path_new .. "/" .. renamed_file + else + -- Try simple rename pattern: "R old_path => new_path" + local rename_pattern_simple_new = "^R .* => (.+)$" + filepath = line:match(rename_pattern_simple_new) + end + + if not filepath then + -- jj status format: "M filename" or "A filename" + -- Match lines that start with status letter followed by space and filename + local pattern = "^[MAD?!] (.+)$" + filepath = line:match(pattern) + end + + if filepath then + return { + old_path = filepath, + new_path = filepath, + is_rename = false, + } + end + + return nil +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 +function M.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 + +return M diff --git a/lua/jj/core/runner.lua b/lua/jj/core/runner.lua new file mode 100644 index 0000000..2068e78 --- /dev/null +++ b/lua/jj/core/runner.lua @@ -0,0 +1,33 @@ +--- @class jj.core.runner +local M = {} + +--- Execute a system command and return output with error handling +--- @param cmd string The command to execute +--- @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 +--- @return string|nil output The command output, or nil if failed +--- @return boolean success Whether the command succeeded +function M.execute_command(cmd, error_prefix, input, silent) + local output = vim.fn.system(cmd, input) + local success = vim.v.shell_error == 0 + + if not success then + 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 + + return nil, false + end + + return output, success +end + +return M + diff --git a/lua/jj/init.lua b/lua/jj/init.lua index 2f4f7e7..1c9cec1 100644 --- a/lua/jj/init.lua +++ b/lua/jj/init.lua @@ -1,6 +1,8 @@ local M = {} local cmd = require("jj.cmd") local picker = require("jj.picker") +local editor = require("jj.ui.editor") +local diff = require("jj.diff") local utils = require("jj.utils") --- Jujutsu plugin configuration @@ -11,14 +13,14 @@ M.config = { picker = { snacks = {}, }, - --- @type jj.utils.highlights Highlight configuration for describe buffer + --- @type jj.ui.editor.highlights Highlight configuration for describe buffer highlights = { added = { fg = "#3fb950", ctermfg = "Green" }, modified = { fg = "#56d4dd", ctermfg = "Cyan" }, deleted = { fg = "#f85149", ctermfg = "Red" }, renamed = { fg = "#d29922", ctermfg = "Yellow" }, }, - --- @type string Editor mode for describe command: "buffer" (Git-style editor) or "input" (simple input prompt) + --- @type "buffer"|"input" Editor mode for describe command: "buffer" (Git-style editor) or "input" (simple input prompt) describe_editor = "buffer", } @@ -27,8 +29,10 @@ M.config = { function M.setup(opts) M.config = vim.tbl_deep_extend("force", M.config, opts or {}) + -- Setup for sub-modules picker.setup(opts and opts.picker or {}) - utils.setup({ highlights = M.config.highlights }) + editor.setup({ highlights = M.config.highlights }) + utils.setup(opts) -- Keep for future-proofing, even if it's a no-op now -- Pass describe_editor config to cmd module if opts and opts.describe_editor then @@ -36,6 +40,30 @@ function M.setup(opts) end cmd.register_command() + + -- Expose public API functions on the top-level module + M.status = cmd.status + M.describe = cmd.describe + M.log = cmd.log + M.new = cmd.new + M.edit = cmd.edit + M.squash = cmd.squash + M.rebase = cmd.rebase + M.undo = cmd.undo + M.redo = cmd.redo + M.bookmark_create = cmd.bookmark_create + M.bookmark_delete = cmd.bookmark_delete + M.j = cmd.j + + M.picker = { + status = picker.status, + file_history = picker.file_history, + } + + M.diff = { + vsplit = diff.open_vdiff, + hsplit = diff.open_hdiff, + } end return M diff --git a/lua/jj/picker.lua b/lua/jj/picker.lua index 4dc9a78..a66cd12 100644 --- a/lua/jj/picker.lua +++ b/lua/jj/picker.lua @@ -1,4 +1,5 @@ local utils = require("jj.utils") +local runner = require("jj.core.runner") --- @class jj.picker @@ -35,7 +36,7 @@ end --- Gets the files in the current jj repository --- @return jj.picker.file[]|nil A list of files with their changes or nil if not in a jj repo local function get_files() - local diff_ouptut, ok = utils.execute_command("jj diff --summary --quiet") + local diff_ouptut, ok = runner.execute_command("jj diff --summary --quiet") if not ok then return end @@ -88,7 +89,7 @@ end local function log_history(file_path) local format = "jj log %s -r 'all()' -T builtin_log_oneline --config 'template-aliases.\"format_timestamp(timestamp)\"=timestamp'" - local output, ok = utils.execute_command(string.format(format, file_path)) + local output, ok = runner.execute_command(string.format(format, file_path)) if not ok then return end diff --git a/lua/jj/picker/snacks.lua b/lua/jj/picker/snacks.lua index 545c49a..6c3944e 100644 --- a/lua/jj/picker/snacks.lua +++ b/lua/jj/picker/snacks.lua @@ -1,4 +1,5 @@ local utils = require("jj.utils") +local runner = require("jj.core.runner") --- @class jj.picker.snacks local M = {} @@ -142,7 +143,7 @@ function M.file_log_history(opts, log_lines) return end - local _, ok = utils.execute_command( + local _, ok = runner.execute_command( string.format("jj edit %s --ignore-immutable", item.rev), string.format("could not edit revision '%s'", item.rev) ) diff --git a/lua/jj/ui/editor.lua b/lua/jj/ui/editor.lua new file mode 100644 index 0000000..a7e0738 --- /dev/null +++ b/lua/jj/ui/editor.lua @@ -0,0 +1,172 @@ +--- @class jj.ui.editor +local M = {} + +--- @class jj.ui.editor.highlights +---@field added table Highlight settings for added lines +---@field modified table Highlight settings for modified lines +---@field deleted table Highlight settings for deleted lines +---@field renamed table Highlight settings for renamed lines + +M.highlights = { + added = { fg = "#3fb950", ctermfg = "Green" }, + modified = { fg = "#56d4dd", ctermfg = "Cyan" }, + deleted = { fg = "#f85149", ctermfg = "Red" }, + renamed = { fg = "#d29922", ctermfg = "Yellow" }, +} +M.highlights_initialized = false + +-- Initialize highlight groups once +local function init_highlights() + if M.highlights_initialized then + return + end + + vim.api.nvim_set_hl(0, "JJComment", { link = "Comment" }) + vim.api.nvim_set_hl(0, "JJAdded", M.highlights.added) + vim.api.nvim_set_hl(0, "JJModified", M.highlights.modified) + vim.api.nvim_set_hl(0, "JJDeleted", M.highlights.deleted) + vim.api.nvim_set_hl(0, "JJRenamed", M.highlights.renamed) + + M.highlights_initialized = true +end + +--- Setup function to configure highlights and other options +---@param opts? { highlights: jj.ui.editor.highlights } Configuration options +function M.setup(opts) + opts = opts or {} + + -- Merge user highlights with defaults + if opts.highlights then + M.highlights = vim.tbl_deep_extend("force", M.highlights, opts.highlights) + end + + -- Reset highlights flag to force re-initialization with new highlights + if M.highlights_initialized then + M.highlights_initialized = false + init_highlights() + end +end + +---@param initial_text string[] Lines to initialize the buffer with +---@param on_done fun(buf: string[])? Optional callback called with user text on buffer write +function M.open_editor(initial_text, on_done) + -- Initialize highlight groups once + init_highlights() + + -- Create a horizontal split at the bottom, half the screen height + local height = math.floor(vim.o.lines / 2) + vim.cmd(string.format("%dsplit", height)) + + -- Create a new unlisted, scratch buffer + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(buf, "jujutsu:///DESCRIBE_EDITMSG") + vim.api.nvim_buf_set_lines(buf, 0, -1, false, initial_text) + vim.api.nvim_win_set_buf(0, buf) + + -- Configure buffer options + vim.bo[buf].buftype = "acwrite" -- Allow custom write handling + vim.bo[buf].bufhidden = "wipe" -- Automatically wipe buffer when hidden + vim.bo[buf].swapfile = false -- Disable swapfile + vim.bo[buf].modifiable = true -- Allow editing + + -- Create a namespace for our highlights + local ns_id = vim.api.nvim_create_namespace("jj_describe_highlights") + + -- Function to apply highlights to the buffer + local function apply_highlights() + -- Clear existing highlights + vim.api.nvim_buf_clear_namespace(buf, ns_id, 0, -1) + + -- Get all lines + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + + for i, line in ipairs(lines) do + local line_idx = i - 1 -- 0-indexed + + -- First, check if line starts with JJ: and highlight it as comment + if line:match("^JJ:") then + -- Highlight the "JJ:" prefix as comment (first 3 characters) + vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx, 0, { + end_col = 3, + hl_group = "JJComment", + }) + + -- Then check for status indicators and highlight the rest of the line + local status_pos = line:find("[MADRC] ", 4) -- Find status after "JJ:" + if status_pos then + local status = line:sub(status_pos, status_pos) -- Get the status character + local hl_group = nil + + if status == "A" or status == "C" then + hl_group = "JJAdded" + elseif status == "M" then + hl_group = "JJModified" + elseif status == "D" then + hl_group = "JJDeleted" + elseif status == "R" then + hl_group = "JJRenamed" + end + + if hl_group then + -- Highlight from the status character to the end of the line + vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx, status_pos - 1, { + end_col = #line, + hl_group = hl_group, + }) + else + -- No status, keep rest as comment + vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx, 3, { + end_col = #line, + hl_group = "JJComment", + }) + end + else + -- No status indicator, highlight rest of line as comment + vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx, 3, { + end_col = #line, + hl_group = "JJComment", + }) + end + end + end + end + + -- Apply highlights initially + apply_highlights() + + -- Reapply highlights when text changes + vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI" }, { + buffer = buf, + callback = apply_highlights, + }) + + -- Handle :w and :wq commands + vim.api.nvim_create_autocmd("BufWriteCmd", { + buffer = buf, + callback = function() + local buf_lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + if on_done then + on_done(buf_lines) + end + vim.bo[buf].modified = false + end, + }) + + -- Add keymap to close the buffer with 'q' in normal mode + vim.keymap.set( + "n", + "q", + "close!", + { buffer = buf, noremap = true, silent = true, desc = "Close describe buffer" } + ) + + -- Add keymap to close the buffer with '' in normal mode + vim.keymap.set( + "n", + "", + "close!", + { buffer = buf, noremap = true, silent = true, desc = "Close describe buffer" } + ) +end + +return M diff --git a/lua/jj/ui/terminal.lua b/lua/jj/ui/terminal.lua new file mode 100644 index 0000000..47a2c7e --- /dev/null +++ b/lua/jj/ui/terminal.lua @@ -0,0 +1,620 @@ +--- @class jj.ui.terminal +local M = {} + +local utils = require("jj.utils") +local parser = require("jj.core.parser") + +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, +} + +M.state = state + +--- Close the current terminal buffer if it exists +function M.close_terminal_buffer() + if not state.buf then + return + elseif 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 not state.floating_buf then + return + elseif state.floating_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 not state.floating_buf then + return + elseif 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 = parser.parse_file_info_from_status_line(vim.api.nvim_get_current_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 = parser.parse_file_info_from_status_line(vim.api.nvim_get_current_line()) + if not file_info then + return + end + + local runner = require("jj.core.runner") + + 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 = runner.execute_command(rm_cmd, "Failed to remove renamed file") + if rm_success then + local _, restore_success = runner.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 + ) + require("jj.cmd").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 = runner.execute_command(restore_cmd, "Failed to restore") + if success then + utils.notify("Restored: " .. file_info.old_path, vim.log.levels.INFO) + require("jj.cmd").status() + end + end +end + +--- Handle keypress enter 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. +--- @param ignore_immut? boolean Pass --ignore-immutable to jj edit when true. +local function handle_log_enter(ignore_immut) + 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 + + local runner = require("jj.core.runner") + -- If we found a revision, edit it. + + -- Build command parts. + local cmd_parts = { "jj", "edit" } + if ignore_immut then + table.insert(cmd_parts, "--ignore-immutable") + end + + table.insert(cmd_parts, revset) + + -- Build cmd string + local cmd = table.concat(cmd_parts, " ") + + -- Try to execute cmd + local _, success = runner.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 + M.close_terminal_buffer() +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 = parser.get_rev_from_log_line(line) + if not revset or revset == "" then + return + end + + local runner = require("jj.core.runner") + + -- 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 = 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() +end + +--- Handle diffing a log line +local function handle_log_diff() + local line = vim.api.nvim_get_current_line() + local revset = parser.get_rev_from_log_line(line) + + if revset then + local cmd = string.format("jj show %s", revset) + M.run_floating(cmd) + else + utils.notify("No valid revision found in the log line", vim.log.levels.ERROR) + end +end + +--- Handle describing a log line +local function handle_log_describe() + local line = vim.api.nvim_get_current_line() + local revset = parser.get_rev_from_log_line(line) + if revset then + require("jj.cmd").describe(nil, revset) + else + utils.notify("No valid revision found in the log line", vim.log.levels.ERROR) + end +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 +function M.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 + + -- Wipe old buffer if it exists + if state.floating_buf and vim.api.nvim_buf_is_valid(state.floating_buf) then + vim.api.nvim_buf_delete(state.floating_buf, { force = true }) + state.floating_buf = nil + end + + -- Create new floating buffer + local buf, win = create_floating_window({}, true) + state.floating_buf = buf + + -- 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 + + -- Move cursor to top before output arrives + vim.api.nvim_win_set_cursor(win, { 1, 0 }) + + 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(_, _) --[[ exit_code ]] + 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_buf, noremap = true, silent = true, desc = "Close the floating buffer" } + ) + vim.keymap.set( + { "n" }, + "", + hide_floating_window, + { buffer = state.floating_buf, 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 + +--- 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|string[] +function M.run(cmd) + if type(cmd) == "string" then + cmd = { cmd } + end + + -- 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 + + -- Wipe old buffer if it exists + if state.buf and vim.api.nvim_buf_is_valid(state.buf) then + vim.api.nvim_buf_delete(state.buf, { force = true }) + state.buf = nil + end + + -- Create new terminal buffer + local height = math.floor(vim.o.lines / 2) + vim.cmd(string.format("%dsplit", height)) + + local 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) + vim.bo[state.buf].bufhidden = "wipe" + + -- 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 + + -- Move cursor to top before output arrives + vim.api.nvim_win_set_cursor(win, { 1, 0 }) + + -- If the command is a string split it into parts + -- to store the subcommand later + if #cmd == 1 then + cmd = vim.split(cmd[1], "%s+") + end + + 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[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", + M.close_terminal_buffer, + { buffer = state.buf, noremap = true, silent = true, desc = "Close the terminal buffer" } + ) + vim.keymap.set( + { "n" }, + "", + M.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[2] == "st" or cmd[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[2] == "log" then + -- Edit + register_command_keymap({ "n" }, "", function() + handle_log_enter(false) + end, { desc = "Edit change under cursor" }) + register_command_keymap({ "n" }, "", function() + handle_log_enter(true) + end, { desc = "Edit change under cursor ignoring immutability" }) + -- 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() + require("jj.cmd").undo() + end, { desc = "Undo last operation" }) + register_command_keymap({ "n" }, "r", function() + require("jj.cmd").redo() + end, { desc = "Redo last operation" }) + register_command_keymap({ "n" }, "D", handle_log_describe, { desc = "Describe change under cursor" }) + 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 + +return M diff --git a/lua/jj/utils.lua b/lua/jj/utils.lua index c8cdb69..04cb11a 100644 --- a/lua/jj/utils.lua +++ b/lua/jj/utils.lua @@ -1,55 +1,13 @@ +local runner = require("jj.core.runner") + --- @class jj.utils ---- @field highlights jj.utils.highlights Highlight configuration - ----@class jj.utils.highlights ----@field added table Highlight settings for added lines ----@field modified table Highlight settings for modified lines ----@field deleted table Highlight settings for deleted lines ----@field renamed table Highlight settings for renamed lines - local M = { executable_cache = {}, dependency_cache = {}, - highlights_initialized = false, - highlights = { - added = { fg = "#3fb950", ctermfg = "Green" }, - modified = { fg = "#56d4dd", ctermfg = "Cyan" }, - deleted = { fg = "#f85149", ctermfg = "Red" }, - renamed = { fg = "#d29922", ctermfg = "Yellow" }, - }, } --- Initialize highlight groups once -local function init_highlights() - if M.highlights_initialized then - return - end - - vim.api.nvim_set_hl(0, "JJComment", { link = "Comment" }) - vim.api.nvim_set_hl(0, "JJAdded", M.highlights.added) - vim.api.nvim_set_hl(0, "JJModified", M.highlights.modified) - vim.api.nvim_set_hl(0, "JJDeleted", M.highlights.deleted) - vim.api.nvim_set_hl(0, "JJRenamed", M.highlights.renamed) - - M.highlights_initialized = true -end - ---- Setup function to configure highlights and other options ----@param opts? jj.utils Configuration options -function M.setup(opts) - opts = opts or {} - - -- Merge user highlights with defaults - if opts.highlights then - M.highlights = vim.tbl_deep_extend("force", M.highlights, opts.highlights) - end - - -- Reset highlights flag to force re-initialization with new highlights - if M.highlights_initialized then - M.highlights_initialized = false - init_highlights() - end -end +-- No-op setup, but keep it for API consistency in case we need it later. +function M.setup(_) end --- Cache for executable checks to avoid repeated system calls @@ -98,39 +56,6 @@ function M.ensure_jj() return true end ---- Execute a system command and return output with error handling ---- @param cmd string The command to execute ---- @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 ---- @return string|nil output The command output, or nil if failed ---- @return boolean success Whether the command succeeded -function M.execute_command(cmd, error_prefix, input, silent) - local output - if input then - output = vim.fn.system(cmd, input) - else - output = vim.fn.system(cmd) - end - local success = vim.v.shell_error == 0 - - if not success then - 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 - M.notify(error_message, vim.log.levels.ERROR) - end - - return nil, false - end - - return output, success -end - --- Check if we're in a jj repository --- @return boolean True if in jj repo, false otherwise function M.is_jj_repo() @@ -138,7 +63,8 @@ function M.is_jj_repo() return false end - local _, success = M.execute_command("jj status") + -- We require the runner here to avoid a circular dependency loop at startup + local _, success = runner.execute_command("jj status") return success end @@ -149,42 +75,14 @@ function M.get_jj_root() return nil end - local output, success = M.execute_command("jj root") + -- We require the runner here to avoid a circular dependency loop at startup + local output, success = runner.execute_command("jj root") if success and output then return vim.trim(output) end return nil end ---- Get a list of files with their status in the current jj repository. ---- @return table[] A list of tables with {status = string, file = string} -function M.get_status_files(revset) - if not M.ensure_jj() then - return {} - end - - if revset == nil then - revset = "@" - end - - cmd = "jj log -r " .. revset .. " --no-graph -T 'self.diff().summary()'" - local result, success = M.execute_command(cmd, "Error getting status") - if not success or not result then - return {} - end - - local files = {} - -- Parse jj status output: "M filename", "A filename", "D filename", "R old => new" - for line in result:gmatch("[^\r\n]+") do - local status, file = line:match("^([MADRC])%s+(.+)$") - if status and file then - table.insert(files, { status = status, file = file }) - end - end - - return files -end - --- Get a list of files modified in the current jj repository. --- @return string[] A list of modified file paths function M.get_modified_files() @@ -192,7 +90,8 @@ function M.get_modified_files() return {} end - local result, success = M.execute_command("jj diff --name-only", "Error getting diff") + -- We require the runner here to avoid a circular dependency loop at startup + local result, success = runner.execute_command("jj diff --name-only", "Error getting diff") if not success or not result then return {} end @@ -214,226 +113,4 @@ function M.notify(message, level) vim.notify(message, level, { title = "JJ", timeout = 3000 }) end ----@param initial_text string[] Lines to initialize the buffer with ----@param on_done fun(buf: string[])? Optional callback called with user text on buffer write -function M.open_ephemeral_buffer(initial_text, on_done) - -- Initialize highlight groups once - init_highlights() - - -- Create a horizontal split at the bottom, half the screen height - local height = math.floor(vim.o.lines / 2) - vim.cmd(string.format("%dsplit", height)) - - -- Create a new unlisted, scratch buffer - local buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_name(buf, "jujutsu:///DESCRIBE_EDITMSG") - vim.api.nvim_buf_set_lines(buf, 0, -1, false, initial_text) - vim.api.nvim_win_set_buf(0, buf) - - -- Configure buffer options - vim.bo[buf].buftype = "acwrite" -- Allow custom write handling - vim.bo[buf].bufhidden = "wipe" -- Automatically wipe buffer when hidden - vim.bo[buf].swapfile = false -- Disable swapfile - vim.bo[buf].modifiable = true -- Allow editing - - -- Create a namespace for our highlights - local ns_id = vim.api.nvim_create_namespace("jj_describe_highlights") - - -- Function to apply highlights to the buffer - local function apply_highlights() - -- Clear existing highlights - vim.api.nvim_buf_clear_namespace(buf, ns_id, 0, -1) - - -- Get all lines - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - - for i, line in ipairs(lines) do - local line_idx = i - 1 -- 0-indexed - - -- First, check if line starts with JJ: and highlight it as comment - if line:match("^JJ:") then - -- Highlight the "JJ:" prefix as comment (first 3 characters) - vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx, 0, { - end_col = 3, - hl_group = "JJComment", - }) - - -- Then check for status indicators and highlight the rest of the line - local status_pos = line:find("[MADRC] ", 4) -- Find status after "JJ:" - if status_pos then - local status = line:sub(status_pos, status_pos) -- Get the status character - local hl_group = nil - - if status == "A" or status == "C" then - hl_group = "JJAdded" - elseif status == "M" then - hl_group = "JJModified" - elseif status == "D" then - hl_group = "JJDeleted" - elseif status == "R" then - hl_group = "JJRenamed" - end - - if hl_group then - -- Highlight from the status character to the end of the line - vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx, status_pos - 1, { - end_col = #line, - hl_group = hl_group, - }) - else - -- No status, keep rest as comment - vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx, 3, { - end_col = #line, - hl_group = "JJComment", - }) - end - else - -- No status indicator, highlight rest of line as comment - vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx, 3, { - end_col = #line, - hl_group = "JJComment", - }) - end - end - end - end - - -- Apply highlights initially - apply_highlights() - - -- Reapply highlights when text changes - vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI" }, { - buffer = buf, - callback = apply_highlights, - }) - - -- Position cursor at the end (after the last JJ: line) and enter insert mode - vim.schedule(function() - local line_count = vim.api.nvim_buf_line_count(buf) - local target_line_idx = line_count - 1 -- 0-indexed line number for API calls - local last_line_content = vim.api.nvim_buf_get_lines(buf, target_line_idx, line_count, false)[1] - local col_index = #last_line_content - vim.api.nvim_win_set_cursor(0, { 1, 0 }) - end) - - -- Handle :w and :wq commands - vim.api.nvim_create_autocmd("BufWriteCmd", { - buffer = buf, - callback = function() - local buf_lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - if on_done then - on_done(buf_lines) - end - vim.bo[buf].modified = false - end, - }) - - -- Add keymap to close the buffer with 'q' in normal mode - vim.keymap.set( - "n", - "q", - "close!", - { buffer = buf, noremap = true, silent = true, desc = "Close describe buffer" } - ) - - -- Add keymap to close the buffer with '' in normal mode - vim.keymap.set( - "n", - "", - "close!", - { buffer = buf, noremap = true, silent = true, desc = "Close describe buffer" } - ) -end - ---- Parse the current line in the jj status buffer to extract file information. ---- Handles renamed files and regular status lines. ---- @return table|nil A table with {old_path = string, new_path = string, is_rename = boolean}, or nil if parsing fails -function M.parse_file_info_from_status_line() - local line = vim.api.nvim_get_current_line() - - -- Handle renamed files: "R path/{old_name => new_name}" or "R old_path => new_path" - local rename_pattern_curly = "^R (.*)/{(.*) => ([^}]+)}" - local dir_path, old_name, new_name = line:match(rename_pattern_curly) - - if dir_path and old_name and new_name then - return { - old_path = dir_path .. "/" .. old_name, - new_path = dir_path .. "/" .. new_name, - is_rename = true, - } - else - -- Try simple rename pattern: "R old_path => new_path" - local rename_pattern_simple = "^R (.*) => (.+)$" - local old_path, new_path = line:match(rename_pattern_simple) - if old_path and new_path then - return { - old_path = old_path, - new_path = new_path, - is_rename = true, - } - end - end - - -- Not a rename, try regular status patterns - local filepath - -- Handle renamed files: "R path/{old_name => new_name}" or "R old_path => new_path" - local rename_pattern_curly_new = "^R (.*)/{.* => ([^}]+)}" - local dir_path_new, renamed_file = line:match(rename_pattern_curly_new) - - if dir_path_new and renamed_file then - filepath = dir_path_new .. "/" .. renamed_file - else - -- Try simple rename pattern: "R old_path => new_path" - local rename_pattern_simple_new = "^R .* => (.+)$" - filepath = line:match(rename_pattern_simple_new) - end - - if not filepath then - -- jj status format: "M filename" or "A filename" - -- Match lines that start with status letter followed by space and filename - local pattern = "^[MAD?!] (.+)$" - filepath = line:match(pattern) - end - - if filepath then - return { - old_path = filepath, - new_path = filepath, - is_rename = false, - } - end - - return nil -end - ---- Parse the default command from jj config ---- @return table|nil args Array of command arguments, or nil if parsing fails -function M.parse_default_cmd() - local default_cmd, success = - M.execute_command("jj config get ui.default-command", "Error getting user's default command", nil, true) - if not success or not default_cmd or default_cmd == "" then - return nil - end - - -- Remove whitespace and parse TOML output - default_cmd = vim.trim(default_cmd) - - -- Try to parse as TOML array: ["item1", "item2", ...] - -- Pattern "%[(.*)%]" captures everything between square brackets - local array_items = default_cmd:match("%[(.*)%]") - if array_items then - local args = {} - -- Pattern '"([^"]+)"' captures content between double quotes (non-greedy) - for item in array_items:gmatch('"([^"]+)"') do - table.insert(args, item) - end - return #args > 0 and args or nil - else - -- Single string value, remove surrounding quotes if present - -- Pattern '^"?(.-)"?$' optionally matches quotes at start/end, captures content - local single_value = default_cmd:match('^"?(.-)"?$') - return single_value and { single_value } or nil - end -end - return M