From 46141a282eae61249f702fd187db1d235c1b375c Mon Sep 17 00:00:00 2001 From: Jonas Elhs Date: Tue, 7 Apr 2026 10:31:12 +0200 Subject: [PATCH] feat!(terminal): unify terminal window behavior and centralize sizing config (#102) - add terminal.window.type support (hsplit, vsplit, floating, tab) - add terminal.window.split_size for split-based layouts - rename floating size options to terminal.window.floating_width/floating_height - validate terminal window ratios during setup - make terminal.run/run_floating merge default keymaps internally - remove cmd.split width/height sizing in favor of terminal.window config - improve floating/log buffer state handling and refresh behavior - update README examples and configuration docs BREAKING CHANGE: terminal sizing/config moved to terminal.window.*. Removed cmd.split.width/cmd.split.height. Renamed terminal.window.width/height to terminal.window.floating_width/floating_height --- README.md | 18 +++---- lua/jj/cmd/init.lua | 29 ++++-------- lua/jj/cmd/log.lua | 14 ++++-- lua/jj/cmd/split.lua | 19 -------- lua/jj/cmd/status.lua | 10 ++-- lua/jj/core/buffer.lua | 2 + lua/jj/diff/native.lua | 4 +- lua/jj/ui/terminal.lua | 103 +++++++++++++++++++++++++++++++++++++---- 8 files changed, 132 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index f019162..eafabde 100644 --- a/README.md +++ b/README.md @@ -246,8 +246,6 @@ cmd.split({ filesets = { "src/" } }) -- Only include specific filese 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: @@ -427,6 +425,14 @@ The plugin also provides `:Jdiff`, `:Jvdiff`, and `:Jhdiff` commands for diffing -- If cursor column is being reset to 0 when refreshing commands, try increasing this value -- This delay allows the terminal emulator to complete rendering before restoring cursor position cursor_render_delay = 10, + + -- Configure terminal window + window = { + type = "hsplit", -- Type of window the terminal is displayed in + split_size = 0.5, -- Size % of the split window, either height (hsplit) or width (vsplit) (between 0.1 and 1.0) + floating_width = 0.99, -- Width % of the floating window (between 0.1 and 1.0) + floating_height = 0.95, -- Height % of the floating window (between 0.1 and 1.0) + }, }, -- Configure diff module @@ -460,12 +466,6 @@ 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 = "" @@ -529,7 +529,7 @@ The plugin also provides `:Jdiff`, `:Jvdiff`, and `:Jhdiff` commands for diffing }, -- Close keymaps (shared across all buffers) close = { "q", "" }, - -- Floating buffer keymaps (for diff floating windows from log buffer) + -- Floating buffer keymaps floating = { close = "q", -- Close floating buffer hide = "", -- Hide floating buffer diff --git a/lua/jj/cmd/init.lua b/lua/jj/cmd/init.lua index 62c17b7..5c78b56 100644 --- a/lua/jj/cmd/init.lua +++ b/lua/jj/cmd/init.lua @@ -94,11 +94,7 @@ local split_module = require("jj.cmd.split") --- @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 +---@class jj.cmd.split.opts ---@field rev? string Revision to split ---@field message? string Commit message for the new revision ---@field filesets? string[] Filesets to include in the split @@ -106,12 +102,9 @@ local split_module = require("jj.cmd.split") ---@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 --- @@ -149,10 +142,6 @@ M.config = { log = { close_on_edit = false, }, - split = { - width = 0.99, - height = 0.95, - }, bookmark = { prefix = "", }, @@ -401,7 +390,7 @@ function M.squash() local cmd = "jj squash" runner.execute_command_async(cmd, function() utils.notify("Command `squash` was succesful.", vim.log.levels.INFO) - if terminal.state.buf_cmd == "log" then + if terminal.is_log_buffer_open() then M.log() end end, "Failed to squash") @@ -650,7 +639,7 @@ function M.undo() local cmd = "jj undo" runner.execute_command_async(cmd, function() utils.notify("Command `undo` was succesful.", vim.log.levels.INFO) - if terminal.state.buf_cmd == "log" then + if terminal.is_log_buffer_open() then M.log({}) end end, "Failed to undo") @@ -665,7 +654,7 @@ function M.redo() local cmd = "jj redo" runner.execute_command_async(cmd, function() utils.notify("Command `redo` was succesful.", vim.log.levels.INFO) - if terminal.state.buf_cmd == "log" then + if terminal.is_log_buffer_open() then M.log({}) end end, "Failed to redo") @@ -701,7 +690,7 @@ function M.fetch() end -- Save the lop one state to refresh - local log_open = terminal.state.buf_cmd == "log" + local log_open = terminal.is_log_buffer_open() -- Get the list of remotes local remotes = utils.get_remotes() @@ -751,7 +740,7 @@ function M.push(opts) opts = opts or {} -- Save the lop one state to refresh - local log_open = terminal.state.buf_cmd == "log" + local log_open = terminal.is_log_buffer_open() local cmd = "jj git push" if opts.bookmark then @@ -1209,7 +1198,7 @@ function M.j(args) if #remaining_args == 0 then M.edit() else - terminal.run(cmd, M.terminal_keymaps()) + terminal.run(cmd) end end, new = function() @@ -1321,7 +1310,7 @@ function M.j(args) elseif remaining_args[1] == "track" or remaining_args[1] == "t" then M.bookmark_track() else - terminal.run(cmd, M.terminal_keymaps()) + terminal.run(cmd) end end, annotate = function() @@ -1361,7 +1350,7 @@ function M.j(args) if type(cmd) == "table" and cmd[1] ~= "jj" then table.insert(cmd, 1, "jj") end - terminal.run(cmd, M.terminal_keymaps()) + terminal.run(cmd) end end diff --git a/lua/jj/cmd/log.lua b/lua/jj/cmd/log.lua index 1c0c820..70b723e 100644 --- a/lua/jj/cmd/log.lua +++ b/lua/jj/cmd/log.lua @@ -334,7 +334,7 @@ function M.log(opts) end -- If a log was already being displayed before this command we will want to maintain the cursor position - if terminal.state.buf_cmd == "log" then + if terminal.is_log_buffer_open() then terminal.store_cursor_position() -- Make sure to clear highlights before rerunning since the previous log buffer might have some vim.api.nvim_buf_clear_namespace(terminal.state.buf, log_selected_ns_id, 0, -1) @@ -856,9 +856,17 @@ function M.handle_log_split() on_exit = function(exit_code) if exit_code == 0 then utils.notify(string.format("Successfully split `%s`", revset), vim.log.levels.INFO) - M.log({}) + vim.schedule(function() + M.log({}) + end) else utils.notify(string.format("Cancelled splitting `%s`", revset), vim.log.levels.WARN) + -- Since we previously replaced the floating with the split we actually want to re run the log cmd + if require("jj").config.terminal.window.type == "floating" then + vim.schedule(function() + M.log({}) + end) + end end end, }) @@ -1197,7 +1205,7 @@ function M.log_keymaps() desc = "Change the revset(s) being viewed", handler = M.handle_log_change_revset, modes = { "n", "v" }, - }, + }, select_next_revision = { desc = "Move cursor to the next revision", handler = M.handle_log_select_next_revision, diff --git a/lua/jj/cmd/split.lua b/lua/jj/cmd/split.lua index 10eef79..7b93cc0 100644 --- a/lua/jj/cmd/split.lua +++ b/lua/jj/cmd/split.lua @@ -3,21 +3,6 @@ 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" } @@ -56,8 +41,6 @@ function M.split(opts) 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 @@ -69,8 +52,6 @@ function M.split(opts) 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, diff --git a/lua/jj/cmd/status.lua b/lua/jj/cmd/status.lua index 1bd03c1..866079a 100644 --- a/lua/jj/cmd/status.lua +++ b/lua/jj/cmd/status.lua @@ -50,6 +50,10 @@ function M.handle_status_enter() return end + if require("jj").config.terminal.window.type == "floating" then + terminal.close_floating_buffer() + end + local filepath = file_info.new_path local stat = vim.uv.fs_stat(filepath) if not stat then @@ -90,7 +94,7 @@ function M.status(opts) return end - local cmd_str = "jj st" + local cmd_str = "jj status" if opts and opts.notify then local output, success = runner.execute_command(cmd_str, "Failed to get status") @@ -99,9 +103,7 @@ function M.status(opts) end else -- Default behavior: show in buffer - local cmd = require("jj.cmd") - local keymaps = cmd.merge_keymaps(M.status_keymaps(), cmd.terminal_keymaps()) - terminal.run(cmd_str, keymaps) + terminal.run(cmd_str, M.status_keymaps()) end end diff --git a/lua/jj/core/buffer.lua b/lua/jj/core/buffer.lua index 030b5cc..1f9273e 100644 --- a/lua/jj/core/buffer.lua +++ b/lua/jj/core/buffer.lua @@ -39,6 +39,7 @@ local M = {} --- @field on_exit? fun(buf: number) Callback when buffer is closed --- @field keymaps? jj.core.buffer.keymap[] Keymaps to set on the buffer --- @field win_options? table Window-specific options to set +--- @field zindex? number Stacking order (default: 50) --- Create and configure a new buffer --- @param opts jj.core.buffer.opts Buffer configuration options @@ -153,6 +154,7 @@ function M.create_float(opts) height = height, row = row, col = col, + zindex = opts.zindex, style = opts.style or "minimal", border = opts.border or "rounded", } diff --git a/lua/jj/diff/native.lua b/lua/jj/diff/native.lua index 0228204..0958ce6 100644 --- a/lua/jj/diff/native.lua +++ b/lua/jj/diff/native.lua @@ -116,13 +116,13 @@ diff.register_backend("native", { local terminal = require("jj.ui.terminal") local cmd = string.format("jj show -r %s --quiet --no-pager", opts.rev) - terminal.run_floating(cmd, require("jj.cmd").floating_keymaps()) + terminal.run_floating(cmd) end, diff_revisions = function(opts) local terminal = require("jj.ui.terminal") local cmd = string.format("jj diff -f %s -t %s --quiet --no-pager", opts.left, opts.right) - terminal.run_floating(cmd, require("jj.cmd").floating_keymaps()) + terminal.run_floating(cmd) end, diff_history_revisions = function(_) utils.notify( diff --git a/lua/jj/ui/terminal.lua b/lua/jj/ui/terminal.lua index 6825a41..b1e9e71 100644 --- a/lua/jj/ui/terminal.lua +++ b/lua/jj/ui/terminal.lua @@ -4,12 +4,27 @@ local M = {} --- Terminal configuration --- @class jj.ui.terminal.opts --- @field cursor_render_delay integer The delay in ms when cursor rerendering the terminal state (default: 10ms). If you're loosing the column of the cursor try adding more delay. I currently did not find a better way to do so due to async handling of the ouptut in the terminal +--- @field window? jj.terminal.window Options for the window used +--- +--- @class jj.terminal.window +--- @field type? "hsplit"|"vsplit"|"floating"|"tab" Type of window the terminal is displayed in +--- @field split_size? number Size % of the split window, either height (hsplit) or width (vsplit) (between 0.1 and 1.0) +--- @field floating_width? number Width % of the floating window (between 0.1 and 1.0) +--- @field floating_height? number Height % of the floating window (between 0.1 and 1.0) +local utils = require("jj.utils") local buffer = require("jj.core.buffer") --- @type jj.ui.terminal.opts local opts = { cursor_render_delay = 10, + + window = { + type = "hsplit", + split_size = 0.5, + floating_width = 0.99, + floating_height = 0.95, + }, } --- @class jj.ui.terminal.state @@ -36,6 +51,9 @@ local state = { --- The floating job id for the terminal buffer --- @type integer|nil floating_job_id = nil, + -- The current floating command being displayed + --- @type string|nil + floating_buf_cmd = nil, -- Cursor position cursor_restore_pos = nil, @@ -57,6 +75,21 @@ local state = { tooltip_close_autocmd = nil, } +--- 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 + -- Re-export M.state = state @@ -64,6 +97,11 @@ M.state = state --- @param user_opts jj.ui.terminal.opts Configuration options function M.setup(user_opts) opts = vim.tbl_deep_extend("force", opts, user_opts or {}) + + -- Clamp window ratios + opts.window.split_size = clamp_ratio(opts.window.split_size, "terminal.window.split_size") + opts.window.floating_width = clamp_ratio(opts.window.floating_width, "terminal.window.floating_width") + opts.window.floating_height = clamp_ratio(opts.window.floating_height, "terminal.window.floating_height") end --- Help for terminal buffer @@ -158,6 +196,7 @@ function M.close_floating_buffer() state.floating_chan = nil state.floating_job_id = nil state.floating_buf = nil + state.floating_buf_cmd = nil end --- Close the current tooltip buffer if it exists @@ -212,20 +251,27 @@ function M.is_log_buffer_open() if not state.buf or not vim.api.nvim_buf_is_valid(state.buf) then return false end + if opts.window.type == "floating" then + return state.floating_buf_cmd == "log" + end return state.buf_cmd == "log" 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 ---- @param float_opts? {title?: string, height?: number, width?: number, modifiable?: boolean, keep_modifiable?: boolean, on_exit?: fun(exit_code: integer), interactive?: boolean} +--- @param float_opts? {title?: string, modifiable?: boolean, keep_modifiable?: boolean, on_exit?: fun(exit_code: integer), interactive?: boolean} function M.run_floating(cmd, keymaps, float_opts) + local jj_cmd = require("jj.cmd") + keymaps = jj_cmd.merge_keymaps(keymaps or {}, jj_cmd.floating_keymaps()) 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 state.floating_chan = nil state.floating_job_id = nil + state.floating_buf_cmd = nil end -- Stop any running job first @@ -252,8 +298,8 @@ function M.run_floating(cmd, keymaps, float_opts) title_pos = "center", enter = true, bufhidden = "hide", - height = float_opts.height, - width = float_opts.width, + height = math.floor(vim.o.lines * opts.window.floating_height), + width = math.floor(vim.o.columns * opts.window.floating_width), modifiable = float_opts.modifiable ~= nil and float_opts.modifiable or true, win_options = { wrap = true, @@ -261,8 +307,12 @@ function M.run_floating(cmd, keymaps, float_opts) relativenumber = false, cursorline = false, signcolumn = "no", + winfixbuf = true, }, on_exit = function(b) + if state.buf == b then + state.buf = nil + end if state.floating_buf == b then state.floating_buf = nil end @@ -274,9 +324,13 @@ function M.run_floating(cmd, keymaps, float_opts) vim.fn.jobstop(state.floating_job_id) state.floating_job_id = nil end + state.floating_buf_cmd = nil end, }) state.floating_buf = buf + if state.cursor_restore_pos then + M.restore_cursor_position() + end local jid local chan @@ -332,12 +386,18 @@ function M.run_floating(cmd, keymaps, float_opts) 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) + if not state.floating_buf or not vim.api.nvim_buf_is_valid(state.floating_buf) then + return end + -- Store the subcommand on successful exit + if exit_code == 0 then + state.floating_buf_cmd = vim.split(cmd, "%s+")[2] + end + -- Make the bufer optionally not modifiable + if not float_opts.keep_modifiable then + buffer.set_modifiable(state.floating_buf, false) + end + buffer.stop_insert(state.floating_buf) end) end, }) @@ -357,6 +417,7 @@ function M.run_floating(cmd, keymaps, float_opts) -- Set keymaps only if they haven't been set for this buffer if not vim.b[state.floating_buf].jj_keymaps_set then local default_keymaps = { + { modes = { "n" }, lhs = "g?", rhs = M.keymap_help }, { modes = { "n", "v" }, lhs = "i", rhs = function() end }, { modes = { "n", "v" }, lhs = "c", rhs = function() end }, { modes = { "n", "v" }, lhs = "a", rhs = function() end }, @@ -394,9 +455,23 @@ end --- @param keymaps jj.core.buffer.keymap[]|nil Additional keymaps to set for this command buffer --- @return integer|nil buf The buffer handle, or nil on failure function M.run(cmd, keymaps) + if opts.window.type == "floating" then + local subcmd = (type(cmd) == "string" and vim.split(cmd, " ") or cmd)[2] + subcmd = subcmd:sub(1, 1):upper() .. subcmd:sub(2) + + M.run_floating(cmd, keymaps, { + title = " JJ " .. subcmd .. " ", + }) + state.buf = state.floating_buf + + return state.floating_buf + end + if type(cmd) == "string" then cmd = { cmd } end + local jj_cmd = require("jj.cmd") + keymaps = jj_cmd.merge_keymaps(keymaps or {}, jj_cmd.terminal_keymaps()) -- Clean up previous state if invalid if state.buf and not vim.api.nvim_buf_is_valid(state.buf) then @@ -425,9 +500,13 @@ function M.run(cmd, keymaps) end -- Create new terminal buffer + local split_type = opts.window.type == "hsplit" and "horizontal" + or opts.window.type == "vsplit" and "vertical" + or opts.window.type == "tab" and "tab" + local full_size = opts.window.type == "hsplit" and vim.o.lines or vim.o.columns state.buf = buffer.create({ - split = "horizontal", - size = math.floor(vim.o.lines / 2), + split = split_type, + size = math.floor(full_size * opts.window.split_size), on_exit = function(buf) if state.buf == buf then state.buf = nil @@ -445,6 +524,8 @@ function M.run(cmd, keymaps) }) local win = vim.api.nvim_get_current_win() + vim.wo[win].winfixbuf = true + vim.bo[state.buf].bufhidden = "wipe" -- Create new terminal channel @@ -644,12 +725,14 @@ function M.run_tooltip(cmd, tool_opts) col = 0, width = width, height = height, + zindex = 51, win_options = { wrap = true, number = false, relativenumber = false, cursorline = false, signcolumn = "no", + winfixbuf = true, }, })