diff --git a/README.md b/README.md index 520b6bb..c8dfcf1 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,31 @@ You can fetch and push directly from the log buffer: - Select from existing bookmarks to move them - Or create a new bookmark at that revision +### Rebase changes from the log buffer + +Enter an interactive rebase mode directly from the log buffer to rebase one or more changes: + +- `r` - Enter rebase mode targeting the revision under cursor (in normal mode) or selected revisions (in visual mode) + +Once in rebase mode, the interface highlights your selection and the current rebase destination: + +- Selected changes are highlighted in your configured `selected_hl` color (default: dark magenta) +- The cursor position (potential rebase destination) is highlighted in your configured `targeted_hl` color (default: green) +- Move the cursor to preview different rebase destinations with live highlighting + +From rebase mode, choose how to rebase: + +- `` or `o` - Rebase onto (`-o`) the revision under cursor +- `a` or `A` - Rebase after (`-A`) the revision under cursor +- `b` or `B` - Rebase before (`-B`) the revision under cursor +- `` or `` - Exit rebase mode without making changes + +**Visual mode selection:** Select multiple revisions in visual mode before pressing `r` to rebase them all at once. The plugin extracts each selected revision and rebases them together. + +**Single revision:** In normal mode, place your cursor on a single revision and press `r` to rebase just that change. + +![Rebase-from-log](https://github.com/NicolasGB/jj.nvim/raw/main/assets/rebase.gif) + ### Open a PR/MR from the log buffer - `o` - Open a PR/MR for the revision under cursor @@ -103,6 +128,8 @@ The plugin automatically: - Handles both HTTPS and SSH remote URLs - Prompts you to select a remote if you have multiple +**This is a jj.nvim exclusive feature** - the ability to seamlessly bridge from your Neovim jj workflow directly to your remote platform's PR/MR interface. + ### Open a changed file Just press enter to open the a file from the `status` output in your current window. @@ -114,14 +141,6 @@ Press `` on a file from the `status` output and that's it, it's restored. ![Restore-status](https://github.com/NicolasGB/jj.nvim/raw/main/assets/x-status.gif) -### Open a PR/MR on your remote - -Press `o` on a change in the log buffer to open a PR/MR on your remote (GitHub, GitLab, Gitea, Forgejo, etc.). - -The plugin automatically detects your git platform and constructs the appropriate PR URL. If you have multiple remotes, you'll be prompted to select which one to use. Works with both HTTPS and SSH URLs. - -**This is a jj.nvim exclusive feature** - the ability to seamlessly bridge from your Neovim jj workflow directly to your remote platform's PR/MR interface. - ## Installation Using [lazy.nvim](https://github.com/folke/lazy.nvim): @@ -210,7 +229,9 @@ The plugin also provides `:Jdiff`, `:Jvdiff`, and `:Jhdiff` commands for diffing -- Configure log command behavior log = { - close_on_edit = false, -- Close log buffer after editing a change + close_on_edit = false, -- Close log buffer after editing a change + selected_hl = { bg = "#3d2c52", ctermbg = "DarkMagenta" }, -- Highlight for selected changes when rebasing/squashing (squash not yet implemented) + targeted_hl = { fg = "#5a9e6f", ctermfg = "Green" }, -- Highlight for targeted change when rebasing/squashing (squash not yet implemented) }, -- Configure bookmark command @@ -239,6 +260,13 @@ The plugin also provides `:Jdiff`, `:Jvdiff`, and `:Jhdiff` commands for diffing push_all = "", -- Push all changes to remote open_pr = "o", -- Open PR/MR for revision under cursor open_pr_list = "", -- Open PR/MR by selecting from all bookmarks + rebase = "r", -- Enter rebase mode targeting revision under cursor or selected revisions + rebase_mode = { + onto = { "", "o" }, -- Select revision under cursor as rebase destination + after = { "a", "A" }, -- Rebase after revision under cursor + before = { "b", "B" }, -- Rebase before revision under cursor + exit_mode = { "", "" }, -- Exit rebase mode + }, }, -- Status buffer keymaps (set to nil to disable) status = { diff --git a/assets/rebase.gif b/assets/rebase.gif new file mode 100644 index 0000000..3a3256d Binary files /dev/null and b/assets/rebase.gif differ diff --git a/lua/jj/cmd/init.lua b/lua/jj/cmd/init.lua index 90c809b..3f42200 100644 --- a/lua/jj/cmd/init.lua +++ b/lua/jj/cmd/init.lua @@ -25,6 +25,10 @@ local status_module = require("jj.cmd.status") --- @class jj.cmd.log --- @field close_on_edit? boolean Whether to close the log buffer when editing a change +--- @class jj.cmd.log.highlights Highlights for the log buffer +--- @field selected? table Highlights for the selected revisions in log buffer (when rebasing/squashing) +--- @field targeted? table Highlights for the targeted revision in log buffer (when rebasing/squashing) + --- @class jj.cmd.log.keymaps --- @field edit? string|string[] Keymaps for the log command buffer, setting a keymap to nil will disable it --- @field edit_immutable? string|string[] @@ -42,6 +46,13 @@ local status_module = require("jj.cmd.status") --- @field open_pr? string|string[] --- @field open_pr_list? string|string[] --- @field bookmark? string|string[] +--- @field rebase_mode? jj.cmd.rebase.keymaps + +--- @class jj.cmd.rebase.keymaps +--- @field onto? string|string[] +--- @field after? string|string[] +--- @field before? string|string[] +--- @field exit_mode? string|string[] --- @class jj.cmd.bookmark --- @field prefix? string Prefix to append when creating a bookmark @@ -69,6 +80,7 @@ local status_module = require("jj.cmd.status") --- @class jj.cmd.keymap_spec --- @field desc string --- @field handler function|string +--- @field modes string[] --- @field args? table --- @alias jj.cmd.keymap_specs table @@ -113,6 +125,13 @@ M.config = { open_pr = "o", open_pr_list = "", bookmark = "b", + rebase = "r", + rebase_mode = { + onto = { "", "o" }, + after = { "a", "A" }, + before = { "b", "B" }, + exit_mode = { "", "" }, + }, }, status = { open_file = "", @@ -130,6 +149,8 @@ M.config = { --- @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 {}) + + require("jj.cmd.log").init_log_highlights() end -- Reexport log function @@ -163,6 +184,7 @@ function M.resolve_keymaps_from_specs(cfg, specs) local keymaps = {} for key, spec in pairs(specs) do + -- Get the lhs of the keymap from the given config local lhs = cfg[key] if lhs and spec.handler then -- Create the handler, wrapping it with args if provided @@ -175,10 +197,13 @@ function M.resolve_keymaps_from_specs(cfg, specs) if type(lhs) == "table" then for _, key_lhs in ipairs(lhs) do - table.insert(keymaps, { modes = "n", lhs = key_lhs, rhs = handler, opts = { desc = spec.desc } }) + table.insert( + keymaps, + { modes = spec.modes, lhs = key_lhs, rhs = handler, opts = { desc = spec.desc } } + ) end else - table.insert(keymaps, { modes = "n", lhs = lhs, rhs = handler, opts = { desc = spec.desc } }) + table.insert(keymaps, { modes = spec.modes, lhs = lhs, rhs = handler, opts = { desc = spec.desc } }) end end end @@ -195,6 +220,7 @@ function M.terminal_keymaps() close = { desc = "Close buffer", handler = terminal.close_terminal_buffer, + modes = { "n" }, }, }) end @@ -208,10 +234,12 @@ function M.floating_keymaps() close = { desc = "Close floating buffer", handler = terminal.close_floating_buffer, + modes = { "n" }, }, hide = { desc = "Hide floating buffer", handler = terminal.hide_floating_buffer, + modes = { "n" }, }, }) end diff --git a/lua/jj/cmd/log.lua b/lua/jj/cmd/log.lua index c5b3778..6c6a020 100644 --- a/lua/jj/cmd/log.lua +++ b/lua/jj/cmd/log.lua @@ -6,6 +6,14 @@ local runner = require("jj.core.runner") local parser = require("jj.core.parser") local terminal = require("jj.ui.terminal") +local log_selected_hl_group = "JJLogSelectedHlGroup" +local log_selected_ns_id = vim.api.nvim_create_namespace(log_selected_hl_group) +local log_rebase_target_hl_group = "JJLogRebaseTargetHlGroup" +local log_rebase_target_ns_id = vim.api.nvim_create_namespace(log_rebase_target_hl_group) +local rebase_mode_autocmd_id = nil +local last_rebase_target_line = nil +local HIGHLIGHT_RANGE = 2 -- Revision line + description line + --- @class jj.cmd.log_opts --- @field summary? boolean --- @field reversed? boolean @@ -17,6 +25,147 @@ local terminal = require("jj.ui.terminal") ---@type jj.cmd.log_opts local default_log_opts = { summary = false, reversed = false, no_graph = false, limit = 20, raw_flags = nil } +--- Init log highlight groups +function M.init_log_highlights() + local cfg = require("jj").config.highlights.log + if not cfg then + return + end + + vim.api.nvim_set_hl(0, log_selected_hl_group, cfg.selected) + vim.api.nvim_set_hl(0, log_rebase_target_hl_group, cfg.targeted) +end + +--- Find the revision line under cursor, handling description lines +local function get_revset_line() + local buf = terminal.state.buf or vim.api.nvim_get_current_buf() + local current_line_num = vim.api.nvim_win_get_cursor(0)[1] - 1 + local line = vim.api.nvim_get_current_line() + local revset_line = current_line_num + + if not parser.get_revset(line) and current_line_num > 0 then + revset_line = current_line_num - 1 + line = vim.api.nvim_buf_get_lines(buf, revset_line, revset_line + 1, false)[1] + end + + return revset_line, parser.get_revset(line) +end + +--- Gets revset from current line or parent in case we're on a description. +--- @return string|nil The revset if found in either current line or previous line, nil otherwise +local function get_revset() + local _, rev = get_revset_line() + return rev +end + +--- If a line is selected in visual mode, get the all the needed marks +--- from selected lines to highlight them during operations like rebase. +--- Otherwise, get the mark from the current line. +--- @return {line: uinteger, col: uinteger, end_line: uinteger, end_col: uinteger}[]|nil List of marks +local function get_highlight_marks() + local marks = {} + local mode = vim.fn.mode() + local buf = terminal.state.buf + if not buf then + utils.notify("No open log buffer", vim.log.levels.ERROR) + return + end + + if mode == "v" or mode == "V" then + -- Visual mode: get selected lines + local start_line = vim.fn.line("v") + local end_line = vim.fn.line(".") + if start_line > end_line then + start_line, end_line = end_line, start_line + end + + local lines = vim.api.nvim_buf_get_lines(buf, start_line - 1, end_line, false) + for i, line in ipairs(lines) do + -- If the current line has a revset highlight it with the following one (which is the description) + if parser.get_revset(line) then + -- Compute the actual line number + local mark_lstart = start_line + i - 2 -- marks expect 0 api since, start-line and ipars are both 1 indexed we need to remove 2 to transform to 0-index (For future self) + local mark_lend = start_line + i - 1 -- We highlight start + description so we actually want to stop at the next line included + local next_line = lines[i + 1] -- Get the next line contents + if not next_line then + -- Only fetch if it's the last selected line and has a revset + local next_line_num = start_line + i + next_line = vim.api.nvim_buf_get_lines(buf, next_line_num - 1, next_line_num, false)[1] or "" + end + + table.insert(marks, { + line = mark_lstart, + col = 0, -- Maybe at some point will make the parser say where the data starts but one thing at the time + end_line = mark_lend, + end_col = #next_line, + }) + end + end + else + -- + -- Normal mode: current or previous line + local revset_line, rev = get_revset_line() + if rev then + -- Get the next line (description) + local next_line = vim.api.nvim_buf_get_lines(buf, revset_line + 1, revset_line + 2, false)[1] or "" + table.insert(marks, { + line = revset_line, + col = 0, + end_line = revset_line + 1, + end_col = #next_line, + }) + end + end + + return marks +end + +--- Apply highlight to target revision +local function apply_target_highlight(buf, revset_line, hl_group) + vim.api.nvim_buf_set_extmark( + buf, + log_rebase_target_ns_id, + revset_line, + 0, + { end_line = revset_line + HIGHLIGHT_RANGE, hl_group = hl_group } + ) + last_rebase_target_line = revset_line +end + +--- Clear previous target highlight +local function clear_target_highlight(buf) + if last_rebase_target_line ~= nil then + vim.api.nvim_buf_clear_namespace( + buf, + log_rebase_target_ns_id, + last_rebase_target_line, + last_rebase_target_line + HIGHLIGHT_RANGE + ) + end + last_rebase_target_line = nil +end + +--- Update rebase target highlight on cursor movement +local function update_rebase_target_highlight() + local buf = terminal.state.buf + if not buf then + return + end + + clear_target_highlight(buf) + + local revset_line, rev = get_revset_line() + if not rev then + return + end + + -- Only highlight if rev is not in selection + local is_in_selection = vim.b.jj_rebase_revsets and string.find(vim.b.jj_rebase_revsets, rev, 1, true) + if not is_in_selection then + apply_target_highlight(buf, revset_line, log_rebase_target_hl_group) + end +end + --- Jujutsu log --- @param opts? jj.cmd.log_opts Optional command options function M.log(opts) @@ -62,8 +211,7 @@ end --- @param flag? 'after' Position relative to the current revision; nil to branch off. --- @param ignore_immut? boolean Pass --ignore-immutable to jj when true. function M.handle_log_new(flag, ignore_immut) - local line = vim.api.nvim_get_current_line() - local revset = parser.get_rev_from_log_line(line) + local revset = get_revset() if not revset or revset == "" then return end @@ -104,8 +252,7 @@ end --- Handle diffing a log line function M.handle_log_diff() - local line = vim.api.nvim_get_current_line() - local revset = parser.get_rev_from_log_line(line) + local revset = get_revset() if revset then local cmd = string.format("jj show --no-pager %s", revset) @@ -117,8 +264,7 @@ end --- Handle describing a log line function M.handle_log_describe() - local line = vim.api.nvim_get_current_line() - local revset = parser.get_rev_from_log_line(line) + local revset = get_revset() if revset then require("jj.cmd").describe(nil, revset) else @@ -133,8 +279,7 @@ end --- @param ignore_immut? boolean Pass --ignore-immutable to jj edit when true. --- @param close_on_exit? boolean Close the log buffer after editing when true. function M.handle_log_edit(ignore_immut, close_on_exit) - local line = vim.api.nvim_get_current_line() - local revset = parser.get_rev_from_log_line(line) + local revset = get_revset() if not revset or revset == "" then return end @@ -170,8 +315,7 @@ end --- On success, notifies and refreshes the log buffer. --- @param ignore_immut? boolean Pass --ignore-immutable to jj abandon when true. function M.handle_log_abandon(ignore_immut) - local line = vim.api.nvim_get_current_line() - local revset = parser.get_rev_from_log_line(line) + local revset = get_revset() if not revset or revset == "" then return end @@ -247,8 +391,7 @@ end --- Handle log pushing bookmark from current line in `jj log` buffer. function M.handle_log_push_bookmark() - local line = vim.api.nvim_get_current_line() - local revset = parser.get_rev_from_log_line(line) + local revset = get_revset() if not revset or revset == "" then return end @@ -309,8 +452,7 @@ function M.handle_log_open_pr(list_bookmarks) end -- Default behavior: parse revision and open PR - local line = vim.api.nvim_get_current_line() - local revset = parser.get_rev_from_log_line(line) + local revset = get_revset() if not revset or revset == "" then return end @@ -341,8 +483,7 @@ end -- Create or move bookmark at revision under cursor in `jj log` buffer function M.handle_log_bookmark() - local line = vim.api.nvim_get_current_line() - local revset = parser.get_rev_from_log_line(line) + local revset = get_revset() if not revset or revset == "" then return end @@ -382,6 +523,69 @@ function M.handle_log_bookmark() end) end +--- Rebase bookmark(s) +function M.handle_log_rebase() + local buf = terminal.state.buf + if not buf then + utils.notify("No open log buffer", vim.log.levels.ERROR) + return + end + + local revsets_str = nil + local mode = vim.fn.mode() + + -- Get revsets based on mode + if mode == "n" then + revsets_str = get_revset() + elseif mode == "v" or mode == "V" then + local start_line = vim.fn.line("v") + local end_line = vim.fn.line(".") + if start_line > end_line then + start_line, end_line = end_line, start_line + end + + local lines = vim.api.nvim_buf_get_lines(buf, start_line - 1, end_line, false) + local revsets = parser.get_all_revsets(lines) + if not revsets or #revsets == 0 then + utils.notify("No valid revisions found in selected lines", vim.log.levels.ERROR) + return + end + + revsets_str = table.concat(revsets, " | ") + -- Exit visual mode before transition + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "n", true) + else + return + end + + -- Validate revsets + if not revsets_str or revsets_str == "" then + return + end + + vim.b.jj_rebase_revsets = revsets_str + + -- Set highlights + local marks = get_highlight_marks() + if not marks or #marks == 0 then + utils.notify("No valid revisions found to highlight during rebase", vim.log.levels.ERROR) + return + end + + for _, mark in ipairs(marks) do + vim.api.nvim_buf_set_extmark( + buf, + log_selected_ns_id, + mark.line, + mark.col, + { end_line = mark.end_line, end_col = mark.end_col, hl_group = log_selected_hl_group } + ) + end + + M.transition_mode("rebase") + utils.notify("Rebase `started`.", vim.log.levels.INFO, 500) +end + --- Resolve log keymaps from config, filtering out nil values --- @return jj.core.buffer.keymap[] function M.log_keymaps() @@ -395,47 +599,57 @@ function M.log_keymaps() local keymaps = cmd.config.keymaps.log or {} local close_on_edit = cmd.config.log.close_on_edit or false + --- @type jj.cmd.keymap_specs local specs = { edit = { desc = "Checkout revision under cursor", handler = M.handle_log_edit, args = { false, close_on_edit }, + modes = { "n" }, }, edit_immutable = { desc = "Checkout revision under cursor (ignores immutability)", handler = M.handle_log_edit, args = { true, close_on_edit }, + modes = { "n" }, }, describe = { desc = "Describe revision under cursor", handler = M.handle_log_describe, + modes = { "n" }, }, diff = { desc = "Diff revision under cursor", handler = M.handle_log_diff, + modes = { "n" }, }, new = { desc = "Create new change branching off revision under cursor", handler = M.handle_log_new, args = { nil, false }, + modes = { "n" }, }, new_after = { desc = "Create new change after revision under cursor", handler = M.handle_log_new, args = { "after", false }, + modes = { "n" }, }, new_after_immutable = { desc = "Create new change after revision under cursor (ignore immutable)", handler = M.handle_log_new, args = { "after", true }, + modes = { "n" }, }, undo = { desc = "Undo last change", handler = cmd.undo, + modes = { "n" }, }, redo = { desc = "Redo last undone change", handler = cmd.redo, + modes = { "n" }, }, abandon = { desc = "Abandon revision under cursor", @@ -443,35 +657,173 @@ function M.log_keymaps() -- As of now i'm only exposing the non ignore-immutable version of abandon in the keymaps -- Maybe in the future we can add another keymap for that, if people request it args = { false }, + modes = { "n" }, }, fetch = { desc = "Fetch from remote", handler = M.handle_log_fetch, + modes = { "n" }, }, push_all = { desc = "Push all to remote", handler = M.handle_log_push_all, + modes = { "n" }, }, push = { desc = "Push bookmark of revision under cursor to remote", handler = M.handle_log_push_bookmark, + modes = { "n" }, }, open_pr = { desc = "Open PR/MR for revision under cursor", handler = M.handle_log_open_pr, + modes = { "n" }, }, open_pr_list = { desc = "Open PR/MR by selecting from all bookmarks", handler = M.handle_log_open_pr, args = { true }, + modes = { "n" }, }, bookmark = { desc = "Create or move bookmark at revision under cursor", handler = M.handle_log_bookmark, + modes = { "n" }, + }, + rebase = { + desc = "Rebase bookmark(s)", + handler = M.handle_log_rebase, + modes = { "n", "v" }, }, } return cmd.merge_keymaps(cmd.resolve_keymaps_from_specs(keymaps, specs), cmd.terminal_keymaps()) end +--- Rebase mode keymaps +--- @return jj.core.buffer.keymap[] +function M.rebase_keymaps() + local cmd = require("jj.cmd") + local keymaps = cmd.config.keymaps.log.rebase_mode or {} + + --- @type jj.cmd.keymap_specs + local spec = { + onto = { + desc = "Rebase onto (-O) the revision under cursor", + handler = M.handle_rebase_execute, + args = { "onto" }, + modes = { "n" }, + }, + after = { + desc = "Rebase revset(s) after (-A) the revision under cursor", + handler = M.handle_rebase_execute, + args = { "after" }, + modes = { "n" }, + }, + before = { + desc = "Rebase revset(s) before (-B) the revision under cursor", + handler = M.handle_rebase_execute, + args = { "before" }, + modes = { "n" }, + }, + exit_mode = { + desc = "Exit rebase to normal mode", + handler = M.handle_rebase_mode_exit, + modes = { "n" }, + }, + } + + return cmd.resolve_keymaps_from_specs(keymaps, spec) +end + +--- Get keymaps for a specific mode +--- @param mode string Mode name +--- @return jj.core.buffer.keymap[] +function M.get_keymaps_for_mode(mode) + if mode == "normal" then + return M.log_keymaps() + elseif mode == "rebase" then + return M.rebase_keymaps() + end + return {} +end + +--- Transition between buffer modes by swapping keymaps +--- @param target_mode string Target mode name (e.g., "normal", "rebase") +function M.transition_mode(target_mode) + -- Get the mode keymaps + if target_mode == vim.b.jj_mode then + return + end + + -- Get new keymaps for target mode + local new_keymaps = M.get_keymaps_for_mode(target_mode) + terminal.replace_terminal_keymaps(new_keymaps) + + -- Set up or tear down rebase mode autocmd + if target_mode == "rebase" then + rebase_mode_autocmd_id = vim.api.nvim_create_autocmd("CursorMoved", { + buffer = terminal.state.buf, + callback = update_rebase_target_highlight, + }) + -- Highlight initial position + update_rebase_target_highlight() + elseif rebase_mode_autocmd_id then + vim.api.nvim_del_autocmd(rebase_mode_autocmd_id) + rebase_mode_autocmd_id = nil + -- Clear target highlight + local buf = terminal.state.buf or 0 + vim.api.nvim_buf_clear_namespace(buf, log_rebase_target_ns_id, 0, -1) + last_rebase_target_line = nil + end + + -- Update buffer mode state + vim.b.jj_mode = target_mode +end + +--- Handle rebase mode exit +function M.handle_rebase_mode_exit() + -- Clear stored revsets + vim.b.jj_rebase_revsets = nil + + M.transition_mode("normal") + -- Clear highlights + local buf = terminal.state.buf or 0 + vim.api.nvim_buf_clear_namespace(buf, log_selected_ns_id, 0, -1) + + utils.notify("Rebase operation `canceled`", vim.log.levels.INFO, 500) +end + +--- Handle rebase execution with mode +--- @param mode "onto" | "after" | "before" Rebase mode +function M.handle_rebase_execute(mode) + -- Get all revsets in the format "xx xy xz" + local revsets = vim.b.jj_rebase_revsets + local destination_revset = get_revset() + if not destination_revset or destination_revset == "" then + return + end + + local mode_flat = "-o" + if mode == "after" then + mode_flat = "-A" + elseif mode == "before" then + mode_flat = "-B" + end + + utils.notify(string.format("Rebasing...", revsets, mode, destination_revset), vim.log.levels.INFO, 500) + local cmd = string.format("jj rebase -r '%s' %s %s", revsets, mode_flat, destination_revset) + runner.execute_command_async(cmd, function() + utils.notify( + string.format("Rebased `%s` %s `%s` successfully", revsets, mode, destination_revset), + vim.log.levels.INFO + ) + vim.b.jj_rebase_revsets = nil + + M.transition_mode("normal") + -- Refresh log + M.log({}) + end, "Error during rebase onto") +end + return M diff --git a/lua/jj/core/parser.lua b/lua/jj/core/parser.lua index 25fa3e9..0ed1557 100644 --- a/lua/jj/core/parser.lua +++ b/lua/jj/core/parser.lua @@ -112,7 +112,7 @@ 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) +function M.get_revset(line) -- Build pattern to match graph characters and symbols at start of line -- Include: box-drawing chars, whitespace, jujutsu UTF-8 symbols, and ASCII markers local graph_chars = "│┃┆┇┊┋╭╮╰╯├┤┬┴┼─└┘┌┐%s" -- box-drawing + whitespace @@ -147,6 +147,21 @@ function M.get_rev_from_log_line(line) return revset end +--- Given a string with N lines find all revsets in them +--- @param lines string[] An array of lines to parse +--- @return string[]|nil An array of revsets found, or nil if none found +function M.get_all_revsets(lines) + local revsets = {} + for _, line in pairs(lines) do + local revset = M.get_revset(line) + if revset then + table.insert(revsets, revset) + end + end + + return #revsets > 0 and revsets or nil +end + --- Given an annotation line, parses and returns its components with positions --- @param line string The annotation line to parse --- @return table A table with {rev = {value = string|nil, pos = {start, end}|nil}, name = {value = string|nil, pos = {start, end}|nil}, date = {value = string|nil, pos = {start, end}|nil}} diff --git a/lua/jj/init.lua b/lua/jj/init.lua index bf7e9fe..2a63715 100644 --- a/lua/jj/init.lua +++ b/lua/jj/init.lua @@ -9,16 +9,26 @@ local terminal = require("jj.ui.terminal") --- @field cmd? jj.cmd.opts Options for command module --- @field picker? jj.picker.config Options for picker module --- @field terminal? jj.ui.terminal.opts Options for the terminal ---- @field highlights? jj.ui.editor.highlights Highlight configuration for describe buffer +--- @field highlights? jj.highlights Options for the highlights +--- @class jj.highlights +--- @field editor? jj.ui.editor.highlights Highlight configuration for describe buffer +--- @field log? jj.cmd.log.highlights Highlight configuration for the log buffer + +---@type jj.Config M.config = { - -- Default configuration - --- @type jj.picker.config picker = { snacks = {}, }, - --- @type jj.ui.editor.highlights Highlight configuration for describe buffer - highlights = {}, + highlights = { + editor = { + renamed = { fg = "#d29922", ctermfg = "Yellow" }, + }, + log = { + selected = { bg = "#3d2c52", ctermbg = "DarkMagenta" }, + targeted = { fg = "#5a9e6f", ctermfg = "Green" }, + }, + }, } --- Setup the plugin @@ -28,7 +38,7 @@ function M.setup(opts) -- Setup for sub-modules picker.setup(opts and opts.picker or {}) - editor.setup({ highlights = M.config.highlights }) + editor.setup({ highlights = M.config.highlights.editor }) cmd.setup(opts and opts.cmd or {}) terminal.setup(opts and opts.terminal or {}) diff --git a/lua/jj/ui/terminal.lua b/lua/jj/ui/terminal.lua index b064618..1c00538 100644 --- a/lua/jj/ui/terminal.lua +++ b/lua/jj/ui/terminal.lua @@ -282,9 +282,6 @@ function M.run(cmd, keymaps) 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 @@ -380,4 +377,42 @@ function M.run(cmd, keymaps) return state.buf end +--- Replace keymaps for normal terminal +--- @param keymaps jj.core.buffer.keymap[] New keymaps to set +function M.replace_terminal_keymaps(keymaps) + if not state.buf or not vim.api.nvim_buf_is_valid(state.buf) then + return + end + + -- Remove previous keymaps if any + if vim.b[state.buf].jj_command_keymaps then + buffer.remove_keymaps(state.buf, vim.b[state.buf].jj_command_keymaps) + vim.b[state.buf].jj_command_keymaps = nil + end + + -- Set new keymaps + if keymaps and #keymaps > 0 then + buffer.set_keymaps(state.buf, keymaps) + vim.b[state.buf].jj_command_keymaps = keymaps + end +end + +--- Replace keymaps for floating terminal +--- @param keymaps jj.core.buffer.keymap[] New keymaps to set +function M.replace_floating_keymaps(keymaps) + if not state.floating_buf or not vim.api.nvim_buf_is_valid(state.floating_buf) then + return + end + -- Remove previous keymaps if any + if vim.b[state.floating_buf].jj_command_keymaps then + buffer.remove_keymaps(state.floating_buf, vim.b[state.floating_buf].jj_command_keymaps) + vim.b[state.floating_buf].jj_command_keymaps = nil + end + -- Set new keymaps + if keymaps and #keymaps > 0 then + buffer.set_keymaps(state.floating_buf, keymaps) + vim.b[state.floating_buf].jj_command_keymaps = keymaps + end +end + return M diff --git a/tests/run_tests.lua b/tests/run_tests.lua index cefc5d5..ccf5f7b 100755 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -44,207 +44,207 @@ print("\n=== Running parser tests ===\n") -- Test cases run_test("parses simple diamond symbol", function() local line = "◆ abc123 my commit message" - assert_equals("abc123", parser.get_rev_from_log_line(line)) + assert_equals("abc123", parser.get_revset(line)) end) run_test("parses simple circle symbol", function() local line = "○ def456 another commit" - assert_equals("def456", parser.get_rev_from_log_line(line)) + assert_equals("def456", parser.get_revset(line)) end) run_test("parses simple @ symbol", function() local line = "@ def456 another commit" - assert_equals("def456", parser.get_rev_from_log_line(line)) + assert_equals("def456", parser.get_revset(line)) end) run_test("parses conflict symbol", function() local line = "× ghi789 conflicted commit" - assert_equals("ghi789", parser.get_rev_from_log_line(line)) + assert_equals("ghi789", parser.get_revset(line)) end) run_test("parses with leading whitespace", function() local line = " ◆ jkl012 indented commit" - assert_equals("jkl012", parser.get_rev_from_log_line(line)) + assert_equals("jkl012", parser.get_revset(line)) end) run_test("parses single branch with box drawing", function() local line = "│ ○ mno345 commit on branch" - assert_equals("mno345", parser.get_rev_from_log_line(line)) + assert_equals("mno345", parser.get_revset(line)) end) run_test("parses multiple branches", function() local line = "│ │ ◆ pqr678 commit with multiple branches" - assert_equals("pqr678", parser.get_rev_from_log_line(line)) + assert_equals("pqr678", parser.get_revset(line)) end) run_test("parses complex graph with connectors", function() local line = "├─○ stu901 commit after merge" - assert_equals("stu901", parser.get_rev_from_log_line(line)) + assert_equals("stu901", parser.get_revset(line)) end) run_test("parses graph with multiple box chars", function() local line = "│ ├─◆ vwx234 complex branch" - assert_equals("vwx234", parser.get_rev_from_log_line(line)) + assert_equals("vwx234", parser.get_revset(line)) end) run_test("parses ASCII @ symbol", function() local line = "@ yza567 current working copy" - assert_equals("yza567", parser.get_rev_from_log_line(line)) + assert_equals("yza567", parser.get_revset(line)) end) run_test("parses ASCII * symbol (git-style)", function() local line = "* bcd890 git style commit" - assert_equals("bcd890", parser.get_rev_from_log_line(line)) + assert_equals("bcd890", parser.get_revset(line)) end) run_test("parses ASCII graph with pipe", function() local line = "| * efg123 ascii branch" - assert_equals("efg123", parser.get_rev_from_log_line(line)) + assert_equals("efg123", parser.get_revset(line)) end) run_test("parses mixed ASCII graph", function() local line = "|\\ @ hij456 merge commit" - assert_equals("hij456", parser.get_rev_from_log_line(line)) + assert_equals("hij456", parser.get_revset(line)) end) run_test("parses @ not at top", function() local line = "│ @ klm789 current change in middle" - assert_equals("klm789", parser.get_rev_from_log_line(line)) + assert_equals("klm789", parser.get_revset(line)) end) run_test("parses @ with multiple branches", function() local line = "│ │ @ nop012 working copy on branch" - assert_equals("nop012", parser.get_rev_from_log_line(line)) + assert_equals("nop012", parser.get_revset(line)) end) run_test("parses @ after merge connector", function() local line = "├─@ qrs345 working copy after merge" - assert_equals("qrs345", parser.get_rev_from_log_line(line)) + assert_equals("qrs345", parser.get_revset(line)) end) run_test("parses with various box drawing characters", function() local line = "╭─╮ ○ tuv678 fancy box" - assert_equals("tuv678", parser.get_rev_from_log_line(line)) + assert_equals("tuv678", parser.get_revset(line)) end) run_test("parses deeply nested branches", function() local line = "│ │ │ │ ◆ wxy901 deeply nested" - assert_equals("wxy901", parser.get_rev_from_log_line(line)) + assert_equals("wxy901", parser.get_revset(line)) end) run_test("parses revision with numbers and letters", function() local line = "◆ abc123def456 mixed alphanumeric" - assert_equals("abc123def456", parser.get_rev_from_log_line(line)) + assert_equals("abc123def456", parser.get_revset(line)) end) run_test("stops at first non-alphanumeric after revision", function() local line = "○ xyz789 this is the message" - assert_equals("xyz789", parser.get_rev_from_log_line(line)) + assert_equals("xyz789", parser.get_revset(line)) end) run_test("parses with different line connector styles", function() local line = "┼─┤ ◆ zab234 cross connector" - assert_equals("zab234", parser.get_rev_from_log_line(line)) + assert_equals("zab234", parser.get_revset(line)) end) run_test("parses with curve connectors", function() local line = "╰─○ cde567 curve connector" - assert_equals("cde567", parser.get_rev_from_log_line(line)) + assert_equals("cde567", parser.get_revset(line)) end) run_test("parses with double line vertical ┃", function() local line = "┃ ◆ fgh890 double line vertical" - assert_equals("fgh890", parser.get_rev_from_log_line(line)) + assert_equals("fgh890", parser.get_revset(line)) end) run_test("parses with light triple dash vertical ┆", function() local line = "┆ ○ ijk123 light triple dash" - assert_equals("ijk123", parser.get_rev_from_log_line(line)) + assert_equals("ijk123", parser.get_revset(line)) end) run_test("parses with heavy triple dash vertical ┇", function() local line = "┇ ◆ lmn456 heavy triple dash" - assert_equals("lmn456", parser.get_rev_from_log_line(line)) + assert_equals("lmn456", parser.get_revset(line)) end) run_test("parses with light quadruple dash vertical ┊", function() local line = "┊ ○ opq789 light quadruple dash" - assert_equals("opq789", parser.get_rev_from_log_line(line)) + assert_equals("opq789", parser.get_revset(line)) end) run_test("parses with heavy quadruple dash vertical ┋", function() local line = "┋ ◆ rst012 heavy quadruple dash" - assert_equals("rst012", parser.get_rev_from_log_line(line)) + assert_equals("rst012", parser.get_revset(line)) end) run_test("parses with top-left corner ┌", function() local line = "┌─○ uvw345 top left corner" - assert_equals("uvw345", parser.get_rev_from_log_line(line)) + assert_equals("uvw345", parser.get_revset(line)) end) run_test("parses with top-right corner ┐", function() local line = "┐ ◆ xyz678 top right corner" - assert_equals("xyz678", parser.get_rev_from_log_line(line)) + assert_equals("xyz678", parser.get_revset(line)) end) run_test("parses with bottom-left corner └", function() local line = "└─○ abc901 bottom left corner" - assert_equals("abc901", parser.get_rev_from_log_line(line)) + assert_equals("abc901", parser.get_revset(line)) end) run_test("parses with bottom-right corner ┘", function() local line = "┘ ◆ def234 bottom right corner" - assert_equals("def234", parser.get_rev_from_log_line(line)) + assert_equals("def234", parser.get_revset(line)) end) run_test("parses with left tee ├", function() local line = "├ ○ ghi567 left tee" - assert_equals("ghi567", parser.get_rev_from_log_line(line)) + assert_equals("ghi567", parser.get_revset(line)) end) run_test("parses with right tee ┤", function() local line = "┤ ◆ jkl890 right tee" - assert_equals("jkl890", parser.get_rev_from_log_line(line)) + assert_equals("jkl890", parser.get_revset(line)) end) run_test("parses with top tee ┬", function() local line = "┬─○ mno123 top tee" - assert_equals("mno123", parser.get_rev_from_log_line(line)) + assert_equals("mno123", parser.get_revset(line)) end) run_test("parses with bottom tee ┴", function() local line = "┴─◆ pqr456 bottom tee" - assert_equals("pqr456", parser.get_rev_from_log_line(line)) + assert_equals("pqr456", parser.get_revset(line)) end) run_test("parses with cross ┼", function() local line = "┼ ○ stu789 cross" - assert_equals("stu789", parser.get_rev_from_log_line(line)) + assert_equals("stu789", parser.get_revset(line)) end) run_test("parses with mixed special box chars", function() local line = "┃ ┆ ┇ ○ vwx012 mixed special" - assert_equals("vwx012", parser.get_rev_from_log_line(line)) + assert_equals("vwx012", parser.get_revset(line)) end) run_test("parses with rounded corners", function() local line = "╭─╮ ╰─╯ ◆ yza345 rounded corners" - assert_equals("yza345", parser.get_rev_from_log_line(line)) + assert_equals("yza345", parser.get_revset(line)) end) run_test("returns nil for lines without revision", function() local line = "This is just a description line" - assert_is_nil(parser.get_rev_from_log_line(line)) + assert_is_nil(parser.get_revset(line)) end) run_test("returns nil for empty line", function() local line = "" - assert_is_nil(parser.get_rev_from_log_line(line)) + assert_is_nil(parser.get_revset(line)) end) run_test("returns nil for only graph characters", function() local line = "│ │ ├─" - assert_is_nil(parser.get_rev_from_log_line(line)) + assert_is_nil(parser.get_revset(line)) end) -- Print summary