feat(split): add jj split command with interactive floating terminal

Add split command that allows splitting a change into multiple revisions
    from the log buffer or via :J split. Supports --parallel, --message,
    --fileset, and --ignore-immutable flags. Includes immutability checks
    with user confirmation and empty change detection.
This commit is contained in:
NicolasGB
2026-02-13 18:44:27 +01:00
committed by Nicolas GB
parent 3115a0b3f0
commit 3d78c28f7e
6 changed files with 325 additions and 44 deletions
+51
View File
@@ -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 = "<CR>",
edit_immutable = "<S-CR>",
},
split = "<C-s>",
},
status = {
open_file = "<CR>",
@@ -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",
+25
View File
@@ -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())
+100
View File
@@ -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
+85 -44
View File
@@ -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 = "<S-a>", 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
+18
View File
@@ -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