From f06b23326eecb2003162b9e9544037c0233fa225 Mon Sep 17 00:00:00 2001 From: NicolasGB Date: Fri, 21 Nov 2025 15:23:37 +0100 Subject: [PATCH] refactor: Restructure keymaps and event handlers into centralized config-driven system - Add M.setup() to cmd module for configuration - Move describe editor config to M.config.describe.editor with nested structure - Add keymaps config with separate log, status, and close sections - Implement resolve_keymaps_from_specs() helper to build keymap lists from config - Extract editor.open_editor() keymaps parameter support - Move status/log event handlers from terminal.lua to cmd.lua - Convert log/status buffer keymaps to config-driven approach with handler specs --- lua/jj/cmd.lua | 367 ++++++++++++++++++++++++++++++++++++++++- lua/jj/init.lua | 10 +- lua/jj/ui/editor.lua | 8 +- lua/jj/ui/terminal.lua | 235 +------------------------- 4 files changed, 366 insertions(+), 254 deletions(-) diff --git a/lua/jj/cmd.lua b/lua/jj/cmd.lua index 31c16ac..e968501 100644 --- a/lua/jj/cmd.lua +++ b/lua/jj/cmd.lua @@ -11,10 +11,89 @@ 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" + --- @class jj.cmd.describe + describe = { + --- @class jj.cmd.describe.editor Options for the describe message editor + editor = { + --- @type "buffer"|"input" Editor mode for describe command: "buffer" (Git-style editor) or "input" (simple input prompt) + type = "buffer", + --- @class jj.cmd.describe.editor.keymaps Keymaps for the describe editor buffer + keymaps = { + --- @type string|string[] Keymaps to close the editor buffer without saving + close = { "", "", "q" }, + }, + }, + }, + --- @class jj.cmd.keymaps Keymaps for the buffers containing the ouptut of the commands + keymaps = { + --- @class jj.cmd.log.keymaps: { [string]: string|string[] } Keymaps for the log command buffer, setting a keymap to nil will disable it + log = { + checkout = "", + checkout_immutable = "", + describe = "d", + diff = "", + edit = "e", + new = "n", + new_after = "", + new_after_immutable = "", + undo = "", + redo = "", + }, + --- @class jj.cmd.status.keymaps: { [string]: string|string[] } Keymaps for the status command buffer, setting a keymap to nil will disable it + status = { + open_file = "", + restore_file = "", + }, + --- @type string|string[] Keymaps for the close keybind + close = { "q", "" }, + }, } +--- Setup the cmd module +--- @param opts jj.cmd.opts: Options to configure the cmd module +function M.setup(opts) + M.config = vim.tbl_deep_extend("force", M.config, opts or {}) +end + +-- Resolve_keymaps_from_specifications +--- @param cfg table The keymap configuration +--- @param specs table The keymap +--- @return jj.core.buffer.keymap[] +local function resolve_keymaps_from_specs(cfg, specs) + local keymaps = {} + + for key, spec in pairs(specs) do + local lhs = cfg[key] + if lhs then + if type(lhs) == "table" then + for _, key_lhs in ipairs(lhs) do + table.insert( + keymaps, + { modes = "n", lhs = key_lhs, rhs = spec.handler, opts = { desc = spec.desc } } + ) + end + else + table.insert(keymaps, { modes = "n", lhs = lhs, rhs = spec.handler, opts = { desc = spec.desc } }) + end + end + end + + return keymaps +end + +-- Resolve close keymaps from config +--- @return jj.core.buffer.keymap[] +local function close_keymaps() + local cfg = M.config.keymaps.close or {} + + return resolve_keymaps_from_specs({ close = cfg }, { + close = { + desc = "Close buffer", + handler = terminal.close_terminal_buffer, + }, + }) +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 @@ -64,8 +143,20 @@ function M.describe(description, revset, opts) revset = "@" end - -- Use buffer editor mode - if M.config.describe_editor == "buffer" then + -- Resolve describe editor keymaps from config + local function describe_editor_keymaps() + local cfg = M.config.describe.editor.keymaps or {} + return resolve_keymaps_from_specs(cfg, { + close = { + desc = "Close describe editor without saving", + handler = "close!", + }, + }) + end + + -- Use buffer editor mode (defaults to "buffer" if not configured) + local editor_mode = M.config.describe.editor.type or "buffer" + if editor_mode == "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 @@ -81,7 +172,8 @@ function M.describe(description, revset, opts) local status_files = parser.get_status_files(status_result) local old_description = vim.trim(old_description_raw) - local text = { old_description } + local first_line = old_description:match("^[^\n]*") or "" + local text = { first_line } 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:") @@ -101,7 +193,8 @@ function M.describe(description, revset, opts) -- 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) + end, describe_editor_keymaps()) + terminal.close_terminal_buffer() else -- Use input mode @@ -125,6 +218,84 @@ function M.describe(description, revset, opts) end end +--- Handle restoring a file from the jj status buffer +--- Supports both renamed and non-renamed files +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 + + 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 opening a file from the jj status buffer +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 + +-- Resolve status keymaps from config, filtering out nil values +--- @return jj.core.buffer.keymap[] +local function status_keymaps() + -- Reduce repetition by declaring a specification table. + -- Each entry maps the config key name to: + local cfg = M.config.keymaps.status or {} + local specs = { + open_file = { + desc = "Open file under cursor", + handler = handle_status_enter, + }, + restore_file = { + desc = "Restore file under cursor", + handler = handle_status_restore, + }, + } + + return resolve_keymaps_from_specs(cfg, specs) +end + -- Jujutsu status. function M.status(opts) if not utils.ensure_jj() then @@ -140,7 +311,8 @@ function M.status(opts) end else -- Default behavior: show in buffer - terminal.run(cmd) + local keymaps = { unpack(status_keymaps()), unpack(close_keymaps()) } + terminal.run(cmd, keymaps) end end @@ -243,6 +415,185 @@ end --- @field revisions? string local default_log_opts = { summary = false, reversed = false, no_graph = false, limit = 20 } +--- +--- 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 + + -- 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) + terminal.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 + +--- 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 + + -- 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 + terminal.close_terminal_buffer() +end + +--- Resolve log keymaps from config, filtering out nil values +--- @return table +local function log_keymaps() + -- Reduce repetition by declaring a specification table. + -- Each entry maps the config key name to: + -- desc: human description + -- handler: function to call + -- args: optional list of arguments passed to handler + local cfg = M.config.keymaps.log or {} + + local specs = { + close = { + desc = "Close log buffer", + handler = terminal.close_terminal_buffer, + }, + checkout = { + desc = "Checkout revision under cursor", + handler = handle_log_enter, + args = { false }, + }, + checkout_immutable = { + desc = "Checkout revision under cursor (ignores immutability)", + handler = handle_log_enter, + args = { true }, + }, + describe = { + desc = "Describe revision under cursor", + handler = handle_log_describe, + }, + diff = { + desc = "Diff revision under cursor", + handler = handle_log_diff, + }, + edit = { + desc = "Edit revision under cursor", + handler = handle_log_enter, + args = { false }, + }, + new = { + desc = "Create new change branching off revision under cursor", + handler = handle_log_new, + args = { nil, false }, + }, + new_after = { + desc = "Create new change after revision under cursor", + handler = handle_log_new, + args = { "after", false }, + }, + new_after_immutable = { + desc = "Create new change after revision under cursor (ignore immutable)", + handler = handle_log_new, + args = { "after", true }, + }, + undo = { + desc = "Undo last change", + handler = M.undo, + }, + redo = { + desc = "Redo last undone change", + handler = M.redo, + }, + } + + return resolve_keymaps_from_specs(cfg, specs) +end -- Jujutsu log --- @param opts? jj.cmd.log_opts @@ -265,7 +616,7 @@ function M.log(opts) end end - terminal.run(cmd) + terminal.run(cmd, log_keymaps()) end --- @class jj.cmd.diff_opts diff --git a/lua/jj/init.lua b/lua/jj/init.lua index 1c9cec1..be9183c 100644 --- a/lua/jj/init.lua +++ b/lua/jj/init.lua @@ -7,6 +7,8 @@ local utils = require("jj.utils") --- Jujutsu plugin configuration --- @class jj.Config +--- @field cmd? jj.cmd.opts Options for command module +--- @field picker? jj.picker.config Options for picker module M.config = { -- Default configuration --- @type jj.picker.config @@ -20,8 +22,6 @@ M.config = { deleted = { fg = "#f85149", ctermfg = "Red" }, renamed = { fg = "#d29922", ctermfg = "Yellow" }, }, - --- @type "buffer"|"input" Editor mode for describe command: "buffer" (Git-style editor) or "input" (simple input prompt) - describe_editor = "buffer", } --- Setup the plugin @@ -32,13 +32,9 @@ function M.setup(opts) -- Setup for sub-modules picker.setup(opts and opts.picker or {}) editor.setup({ highlights = M.config.highlights }) + cmd.setup(opts.cmd) 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 - cmd.config.describe_editor = opts.describe_editor - end - cmd.register_command() -- Expose public API functions on the top-level module diff --git a/lua/jj/ui/editor.lua b/lua/jj/ui/editor.lua index 2547a13..7038dd9 100644 --- a/lua/jj/ui/editor.lua +++ b/lua/jj/ui/editor.lua @@ -51,7 +51,8 @@ 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) +---@param keymaps? jj.core.buffer.keymap[] Optional keymaps for the buffer +function M.open_editor(initial_text, on_done, keymaps) -- Initialize highlight groups once init_highlights() @@ -124,10 +125,7 @@ function M.open_editor(initial_text, on_done) size = math.floor(vim.o.lines / 2), buftype = "acwrite", modifiable = true, - keymaps = { - { modes = "n", lhs = "q", rhs = "close!", opts = { desc = "Close describe buffer" } }, - { modes = "n", lhs = "", rhs = "close!", opts = { desc = "Close describe buffer" } }, - }, + keymaps = keymaps, }) -- Set buffer content diff --git a/lua/jj/ui/terminal.lua b/lua/jj/ui/terminal.lua index d679bfd..f5f9893 100644 --- a/lua/jj/ui/terminal.lua +++ b/lua/jj/ui/terminal.lua @@ -1,10 +1,7 @@ --- @class jj.ui.terminal local M = {} -local utils = require("jj.utils") -local parser = require("jj.core.parser") local buffer = require("jj.core.buffer") -local runner = require("jj.core.runner") --- @class jj.ui.terminal.state local state = { @@ -54,174 +51,6 @@ local function hide_floating_window() 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 - - 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 - - -- 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 - - -- 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 - --- Run the command in a floating window --- @param cmd string The command to run in the floating window function M.run_floating(cmd) @@ -491,6 +320,7 @@ function M.run(cmd, keymaps) local new_command_keymaps = {} -- Append the given keymaps + -- Add a debug if keymaps and #keymaps > 0 then for _, km in ipairs(keymaps) do table.insert(new_command_keymaps, km) @@ -503,69 +333,6 @@ function M.run(cmd, keymaps) { modes = "n", lhs = "", rhs = handle_status_enter, opts = { desc = "Open file under cursor" } }, { modes = "n", lhs = "X", rhs = handle_status_restore, opts = { desc = "Restore file under cursor" } }, } - elseif cmd[2] == "log" then - new_command_keymaps = { - -- Edit - { - modes = "n", - lhs = "", - rhs = function() - handle_log_enter(false) - end, - opts = { desc = "Edit change under cursor" }, - }, - { - modes = "n", - lhs = "", - rhs = function() - handle_log_enter(true) - end, - opts = { desc = "Edit change under cursor ignoring immutability" }, - }, - -- Diff - { modes = "n", lhs = "d", rhs = handle_log_diff, opts = { desc = "Diff change under cursor" } }, - -- New - { - modes = "n", - lhs = "n", - rhs = handle_log_new, - opts = { desc = "New change off the change under cursor" }, - }, - { - modes = "n", - lhs = "", - rhs = function() - handle_log_new("after") - end, - opts = { desc = "New change after the change under cursor" }, - }, - { - modes = "n", - lhs = "", - rhs = function() - handle_log_new("after", true) - end, - opts = { desc = "New change after the change under cursor ignoring immutability" }, - }, - -- Undo/Redo - { - modes = "n", - lhs = "u", - rhs = function() - require("jj.cmd").undo() - end, - opts = { desc = "Undo last operation" }, - }, - { - modes = "n", - lhs = "r", - rhs = function() - require("jj.cmd").redo() - end, - opts = { desc = "Redo last operation" }, - }, - { modes = "n", lhs = "D", rhs = handle_log_describe, opts = { desc = "Describe change under cursor" } }, - } end if #new_command_keymaps > 0 then