mirror of
https://github.com/zoriya/jj.nvim.git
synced 2026-08-09 12:46:15 +00:00
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:
@@ -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:
|
||||
|
||||

|
||||
|
||||
### Split changes from the log buffer
|
||||
|
||||
Split a change into two or more revisions directly from the log buffer or the command line:
|
||||
|
||||
- `<C-s>` - 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 <your-alias>
|
||||
@@ -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 = { "<Esc>", "<C-c>" }, -- Exit squash mode
|
||||
},
|
||||
quick_squash = "<S-s>", -- Quick squash revision under cursor into its parent (ignore immutability)
|
||||
split = "<C-s>", -- Split the revision under cursor
|
||||
summary = "<S-k>", -- Show summary tooltip for revision under cursor
|
||||
summary_tooltip = {
|
||||
diff = "<S-d>", -- Diff file at this revision
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user