diff --git a/README.md b/README.md index 4b31d5e..514af20 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ - [Fetch and push from the log buffer](#fetch-and-push-from-the-log-buffer) - [Manage bookmarks from the log buffer](#manage-bookmarks-from-the-log-buffer) - [Squash changes from the log buffer](#squash-changes-from-the-log-buffer) + - [Split changes from the log buffer](#split-changes-from-the-log-buffer) - [Rebase changes from the log buffer](#rebase-changes-from-the-log-buffer) - [Open a PR/MR from the log buffer](#open-a-prmr-from-the-log-buffer) - [Open a changed file](#open-a-changed-file) @@ -60,6 +61,7 @@ - `new` - Create a new change with optional parent selection - `edit` - Edit a change - `squash` - Squash the current diff to its parent or interactive squash mode from the log buffer + - `split` - Split a change interactively in a floating terminal - `rebase` - Rebase changes to a destination - `bookmark create/delete` - Create and delete bookmarks - `undo` - Undo the last operation @@ -176,6 +178,42 @@ From squash mode, choose how to squash: ![Squash-from-log](https://github.com/NicolasGB/jj.nvim/raw/main/assets/squash.gif) +### Split changes from the log buffer + +Split a change into two or more revisions directly from the log buffer or the command line: + +- `` - Split the revision under cursor from the log buffer + +The split command opens an interactive floating terminal where jj guides you through selecting which changes go into the first commit. The remaining changes stay in the second commit. + +> [!NOTE] +> If you want to use something like [hunk.nvim](https://github.com/julienvincent/hunk.nvim), simply follow the steps and update your jj's config to use it as a tool and a neovim instance with hunk will be ran inside your current neovim, for a seamless integration +> +> Other tools that ran in the terminal like jj's native should work out of the box too. + +**Via `:J` command:** + +```sh +:J split " Split @ interactively +:J split abc123 " Split a specific revision +:J split @ --parallel " Create parallel changes instead of sequential +:J split @ --message "first half" " Set a commit message for the first split +``` + +**Via Lua API:** + +```lua +local cmd = require("jj.cmd") +cmd.split() -- Split @ interactively +cmd.split({ rev = "abc123" }) -- Split a specific revision +cmd.split({ parallel = true }) -- Create parallel changes +cmd.split({ message = "first half" }) -- Set message for first split +cmd.split({ filesets = { "src/" } }) -- Only include specific filesets +cmd.split({ ignore_immutable = true }) -- Split an immutable revision +``` + +The floating terminal size is configurable via the `split.width` and `split.height` options (ratios between `0.1` and `1.0`). + ### Rebase changes from the log buffer Enter an interactive rebase mode directly from the log buffer to rebase one or more changes: @@ -262,6 +300,7 @@ The plugin provides a `:J` command that accepts jj subcommands: :J fetch " Fetch from remote :J open_pr " Open PR for current change's bookmark :J open_pr --list " Select bookmark from all and open PR +:J split " Split a change interactively :J bookmark create/move/delete :J # This will use your defined default command :J @@ -344,6 +383,12 @@ The plugin also provides `:Jdiff`, `:Jvdiff`, and `:Jhdiff` commands for diffing close_on_edit = false, -- Close log buffer after editing a change }, + -- Configure split command + split = { + width = 0.99, -- Width ratio of the floating terminal (0.1 to 1.0) + height = 0.95, -- Height ratio of the floating terminal (0.1 to 1.0) + }, + -- Configure bookmark command bookmark = { prefix = "" @@ -387,6 +432,7 @@ The plugin also provides `:Jdiff`, `:Jvdiff`, and `:Jhdiff` commands for diffing exit_mode = { "", "" }, -- Exit squash mode }, quick_squash = "", -- Quick squash revision under cursor into its parent (ignore immutability) + split = "", -- Split the revision under cursor summary = "", -- Show summary tooltip for revision under cursor summary_tooltip = { diff = "", -- Diff file at this revision diff --git a/lua/jj/cmd/init.lua b/lua/jj/cmd/init.lua index 4bd803c..192fafc 100644 --- a/lua/jj/cmd/init.lua +++ b/lua/jj/cmd/init.lua @@ -5,11 +5,13 @@ local utils = require("jj.utils") local runner = require("jj.core.runner") local terminal = require("jj.ui.terminal") local editor = require("jj.ui.editor") +local parser = require("jj.core.parser") local diff = require("jj.diff") local log_module = require("jj.cmd.log") local describe_module = require("jj.cmd.describe") local status_module = require("jj.cmd.status") +local split_module = require("jj.cmd.split") -- Config for cmd module --- @class jj.cmd.describe.editor.keymaps @@ -90,9 +92,24 @@ local status_module = require("jj.cmd.status") --- @field close? string|string[] Keymaps for the close keybind --- @field floating? jj.cmd.floating.keymaps Keymaps for the floating buffer +---@class jj.cmd.split.common +---@field height? number Height % for the split buffer (between 0.1 and 1.0) +---@field width? number Width % for the split buffer (between 0.1 and 1.0) + +---@class jj.cmd.split.opts: jj.cmd.split.common +---@field rev? string Revision to split +---@field message? string Commit message for the new revision +---@field filesets? string[] Filesets to include in the split +---@field ignore_immutable? boolean Ignore immutable revisions +---@field parallel? boolean Run operations in parallel +---@field on_exit? fun(exit_code: number) Callback invoked when command exits + +---@class jj.cmd.split: jj.cmd.split.common + --- @class jj.cmd.opts --- @field describe? jj.cmd.describe --- @field log? jj.cmd.log +--- @field split? jj.cmd.split --- @field bookmark? jj.cmd.bookmark --- @field keymaps? jj.cmd.keymaps Keymaps for the buffers containing the of the commands --- @@ -123,6 +140,10 @@ M.config = { log = { close_on_edit = false, }, + split = { + width = 0.99, + height = 0.95, + }, bookmark = { prefix = "", }, @@ -167,6 +188,7 @@ M.config = { edit = "", edit_immutable = "", }, + split = "", }, status = { open_file = "", @@ -194,6 +216,8 @@ M.log = log_module.log M.describe = describe_module.describe -- Reexport status function M.status = status_module.status +-- Rexport split function +M.split = split_module.split --- Merge multiple keymap arrays into one --- @param ... jj.core.buffer.keymap[][] Keymap arrays to merge @@ -867,6 +891,32 @@ function M.j(args) log = function() M.log({ raw_flags = remaining_args_str ~= "" and remaining_args_str or nil }) end, + split = function() + local rev = remaining_args and remaining_args[1] or "@" + + local opts = { + rev = rev, + } + + local index = 2 + for i = index, #remaining_args do + local arg = remaining_args[i] + if arg == "--parallel" then + opts.parallel = true + elseif arg == "--ignore-immutable" then + opts.ignore_immutable = true + elseif arg == "--message" and remaining_args[i + 1] then + opts.message = remaining_args[i + 1] + index = i + 1 + elseif arg == "--fileset" and remaining_args[i + 1] then + opts.filesets = opts.filesets or {} + table.insert(opts.filesets, remaining_args[i + 1]) + index = i + 1 + end + end + + require("jj.cmd.split").split(opts) + end, diff = function() M.diff({ current = false }) end, @@ -957,6 +1007,7 @@ function M.register_command() "push", "rebase", "redo", + "split", "squash", "st", "status", diff --git a/lua/jj/cmd/log.lua b/lua/jj/cmd/log.lua index b23ee9c..0bea4df 100644 --- a/lua/jj/cmd/log.lua +++ b/lua/jj/cmd/log.lua @@ -725,6 +725,26 @@ function M.handle_log_quick_squash() end, string.format("Error squashing `%s` into it's parent", revset)) end +--- Handle log split +function M.handle_log_split() + local revset = get_revset() + if not revset or revset == "" then + return + end + + require("jj.cmd").split({ + rev = revset, + on_exit = function(exit_code) + if exit_code == 0 then + utils.notify(string.format("Successfully split `%s`", revset), vim.log.levels.INFO) + M.log({}) + else + utils.notify(string.format("Cancelled splitting `%s`", revset), vim.log.levels.WARN) + end + end, + }) +end + --- Handle diff action in summary tooltip --- Diffs the file at revset against its parent (revset-) --- Opens a floating diff, and returns focus to tooltip when closed @@ -1007,6 +1027,11 @@ function M.log_keymaps() handler = M.handle_log_summary, modes = { "n" }, }, + split = { + desc = "Split the revision under cursor", + handler = M.handle_log_split, + modes = { "n" }, + }, } return cmd.merge_keymaps(cmd.resolve_keymaps_from_specs(keymaps, specs), cmd.terminal_keymaps()) diff --git a/lua/jj/cmd/split.lua b/lua/jj/cmd/split.lua new file mode 100644 index 0000000..10eef79 --- /dev/null +++ b/lua/jj/cmd/split.lua @@ -0,0 +1,100 @@ +local M = {} + +local utils = require("jj.utils") +local terminal = require("jj.ui.terminal") + +--- Clamps a ratio value between 0.1 and 1.0, returning a default of 1.0 if the input is invalid. +---@param value? number +---@param field string +---@return number +local function clamp_ratio(value, field) + if type(value) ~= "number" or value < 0.1 or value > 1.0 then + utils.notify( + string.format("Value for field `%s` must be between `0.1` and `1.0`. Defaulted to `1.0`", field), + vim.log.levels.WARN + ) + return 1.0 + end + return value +end + +local function build_split_command(opts) + local args = { "jj", "split" } + + local rev = (opts.rev and opts.rev ~= "") and opts.rev or "@" + table.insert(args, "-r") + table.insert(args, rev) + + if opts.parallel then + table.insert(args, "--parallel") + end + + if opts.message then + table.insert(args, "--message") + table.insert(args, opts.message) + end + + if opts.ignore_immutable then + table.insert(args, "--ignore-immutable") + end + + if opts.filesets then + for _, fileset in ipairs(opts.filesets) do + table.insert(args, fileset) + end + end + + return table.concat(args, " ") +end + +--- Split natively +---@param opts? jj.cmd.split.opts +function M.split(opts) + if not utils.ensure_jj() then + return + end + + local cmd_mod = require("jj.cmd") + opts = vim.tbl_deep_extend("force", cmd_mod.config.split or {}, opts or {}) --[[@as jj.cmd.split.opts]] + opts.height = clamp_ratio(opts.height, "height") + opts.width = clamp_ratio(opts.width, "width") + + -- If it's empty do nothing + if utils.is_change_empty(opts.rev or "@") then + utils.notify(string.format("The change `%s` is empty, nothing to split.", opts.rev or "@"), vim.log.levels.INFO) + return + end + + local function run_split(cmd) + terminal.run_floating(cmd, nil, { + title = " JJ Split ", + modifiable = true, + height = math.floor(vim.o.lines * opts.height), + width = math.floor(vim.o.columns * opts.width), + keep_modifiable = true, + interactive = true, + on_exit = opts.on_exit or nil, + }) + end + + -- If the change is immutable warn the user and prompt him + if utils.is_change_immutable(opts.rev or "@") then + vim.ui.select( + { "Yes", "No" }, + { prompt = string.format("The change `%s` is IMMUTABLE, do you still want to split it?", opts.rev or "@") }, + function(item) + if item == "Yes" then + opts.ignore_immutable = true + local cmd = build_split_command(opts) + run_split(cmd) + return + end + end + ) + else + local cmd = build_split_command(opts) + run_split(cmd) + end +end + +return M diff --git a/lua/jj/ui/terminal.lua b/lua/jj/ui/terminal.lua index 04974cc..75b6947 100644 --- a/lua/jj/ui/terminal.lua +++ b/lua/jj/ui/terminal.lua @@ -218,7 +218,9 @@ end --- Run the command in a floating window --- @param cmd string The command to run in the floating window --- @param keymaps jj.core.buffer.keymap[]|nil Additional keymaps to set for this floating buffer -function M.run_floating(cmd, keymaps) +--- @param float_opts? {title?: string, height?: number, width?: number, modifiable?: boolean, keep_modifiable?: boolean, on_exit?: fun(exit_code: integer), interactive?: boolean} +function M.run_floating(cmd, keymaps, float_opts) + float_opts = float_opts or {} -- 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 @@ -246,10 +248,13 @@ function M.run_floating(cmd, keymaps) -- Create new floating buffer local buf, win = buffer.create_float({ - title = " JJ Diff ", + title = float_opts.title or " JJ Diff ", title_pos = "center", enter = true, bufhidden = "hide", + height = float_opts.height, + width = float_opts.width, + modifiable = float_opts.modifiable ~= nil and float_opts.modifiable or true, win_options = { wrap = true, number = false, @@ -273,47 +278,77 @@ function M.run_floating(cmd, keymaps) }) 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 + local jid + local chan + if float_opts.interactive then + jid = vim.fn.jobstart(cmd, { + term = true, + on_exit = function(_, exit_code) + vim.schedule(function() + if float_opts.on_exit then + float_opts.on_exit(exit_code) + end + if state.floating_buf and vim.api.nvim_buf_is_valid(state.floating_buf) then + M.close_floating_buffer() + end + vim.cmd("stopinsert") + end) + end, + }) + state.floating_chan = jid + vim.cmd("startinsert") + else + -- Create new terminal channel + 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 }) + -- 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 state.floating_buf or 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 state.floating_buf and vim.api.nvim_buf_is_valid(state.floating_buf) then - buffer.set_modifiable(state.floating_buf, false) - buffer.stop_insert(state.floating_buf) + 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 state.floating_buf or not vim.api.nvim_buf_is_valid(state.floating_buf) then + return end - end) - end, - }) + local output = table.concat(data, "\n") + vim.api.nvim_chan_send(chan, output) + end, + on_exit = function(_, exit_code) + if float_opts.on_exit then + float_opts.on_exit(exit_code) + end + vim.schedule(function() + if state.floating_buf and vim.api.nvim_buf_is_valid(state.floating_buf) then + if not float_opts.keep_modifiable then + buffer.set_modifiable(state.floating_buf, false) + end + buffer.stop_insert(state.floating_buf) + end + end) + end, + }) + end - if jid <= 0 then - vim.api.nvim_chan_send(chan, "Failed to start command: " .. cmd .. "\r\n") + if not jid or jid <= 0 then + if chan then + vim.api.nvim_chan_send(chan, "Failed to start command: " .. cmd .. "\r\n") + else + vim.notify("Failed to start command: " .. cmd, vim.log.levels.ERROR) + end state.floating_chan = nil else state.floating_job_id = jid @@ -328,6 +363,10 @@ function M.run_floating(cmd, keymaps) { modes = { "n", "v" }, lhs = "", rhs = function() end }, { modes = { "n", "v" }, lhs = "u", rhs = function() end }, } + -- IF it's interactive do not block them + if float_opts.interactive then + default_keymaps = {} + end -- Merge default keymaps with provided keymaps if keymaps and #keymaps > 0 then @@ -336,11 +375,13 @@ function M.run_floating(cmd, keymaps) end end - -- Remove prompt keymaps - buffer.remove_keymaps(state.floating_buf, { - { modes = { "n", "v" }, lhs = "[[", rhs = function() end }, - { modes = { "n", "v" }, lhs = "]]", rhs = function() end }, - }) + if not float_opts.interactive then + -- Remove prompt keymaps + buffer.remove_keymaps(state.floating_buf, { + { modes = { "n", "v" }, lhs = "[[", rhs = function() end }, + { modes = { "n", "v" }, lhs = "]]", rhs = function() end }, + }) + end buffer.set_keymaps(state.floating_buf, default_keymaps) vim.b[state.floating_buf].jj_keymaps_set = true diff --git a/lua/jj/utils.lua b/lua/jj/utils.lua index e380eb9..e3a2323 100644 --- a/lua/jj/utils.lua +++ b/lua/jj/utils.lua @@ -307,6 +307,24 @@ function M.is_change_immutable(revset) return vim.trim(output) == "true" end +--- Check if a given revset is empty +--- @param revset string The revset to check +--- @return boolean True if the revset is empty, false otherwise +function M.is_change_empty(revset) + local output, success = runner.execute_command( + string.format("jj log --no-graph -r '%s' -T 'empty' --quiet", revset), + "Error checking if revset is empty", + nil, + true + ) + + if not success or not output then + return false + end + + return vim.trim(output) == "true" +end + --- Build describe text for a given revision --- @param revset? string The revision to describe (default: @) --- @return string[]|nil