mirror of
https://github.com/zoriya/jj.nvim.git
synced 2026-08-06 11:16:08 +00:00
Add cursor position restoration with configurable timing to handle terminal
buffer rendering asynchronously. This fixes cursor column being reset to 0
when refreshing jj log commands.
Changes:
- Add `terminal.cursor_render_delay` configuration option (default: 10ms)
- Add buffer.get_cursor() and buffer.set_cursor() helpers in core/buffer
* buffer.set_cursor() uses defer_fn with configurable delay for terminal buffers
* Automatic position validation with clamping to buffer bounds
* Line number clamped to [1, line_count]
* Column clamped to [0, line_length] based on actual line content
* Validation happens inside deferred callback to check against final rendered content
- Extract clamp_cursor_position() helper to eliminate code duplication
- Refactor terminal module to use new buffer cursor helpers
- Store and restore cursor position when cycling through log commands
- Update README with terminal configuration section and example usage
The delay is necessary because nvim_open_term() + nvim_chan_send() have
asynchronous rendering in the terminal emulator layer. Setting the cursor
before rendering completes results in the column being reset to 0. Users
experiencing issues can increase the delay value if needed.
378 lines
10 KiB
Lua
378 lines
10 KiB
Lua
--- @class jj.ui.terminal
|
|
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
|
|
|
|
local buffer = require("jj.core.buffer")
|
|
|
|
--- @type jj.ui.terminal.opts
|
|
local opts = {
|
|
cursor_render_delay = 10,
|
|
}
|
|
|
|
--- @class jj.ui.terminal.state
|
|
local state = {
|
|
-- The current terminal buffer for jj commands
|
|
--- @type integer|nil
|
|
buf = nil,
|
|
-- The current channel to communciate with the terminal
|
|
--- @type integer|nil
|
|
chan = nil,
|
|
--- The current job id for the terminal buffer
|
|
--- @type integer|nil
|
|
job_id = nil,
|
|
-- The current command being displayed
|
|
--- @type string|nil
|
|
buf_cmd = nil,
|
|
|
|
-- The floating buffer if any
|
|
--- @type integer|nil
|
|
floating_buf = nil,
|
|
-- The floating channel to communciate with the terminal
|
|
--- @type integer|nil
|
|
floating_chan = nil,
|
|
--- The floating job id for the terminal buffer
|
|
--- @type integer|nil
|
|
floating_job_id = nil,
|
|
|
|
-- Cursor position
|
|
cursor_restore_pos = nil,
|
|
}
|
|
|
|
-- Re-export
|
|
M.state = state
|
|
|
|
--- Setup function to configure terminal options
|
|
--- @param user_opts jj.ui.terminal.opts Configuration options
|
|
function M.setup(user_opts)
|
|
opts = vim.tbl_deep_extend("force", opts, user_opts or {})
|
|
end
|
|
|
|
--- Close the current terminal buffer if it exists
|
|
function M.close_terminal_buffer()
|
|
buffer.close(state.buf)
|
|
end
|
|
|
|
--- Close the current terminal buffer if it exists
|
|
function M.close_floating_buffer()
|
|
buffer.close(state.floating_buf)
|
|
end
|
|
|
|
--- Hide the current floating window
|
|
function M.hide_floating_buffer()
|
|
if not state.floating_buf then
|
|
return
|
|
elseif state.floating_buf and vim.api.nvim_buf_is_valid(state.floating_buf) then
|
|
vim.cmd("hide")
|
|
end
|
|
end
|
|
|
|
--- Store the current cursor position, the terminal will restore it on the next render
|
|
function M.store_cursor_position()
|
|
if state.buf then
|
|
state.cursor_restore_pos = buffer.get_cursor(state.buf)
|
|
end
|
|
end
|
|
|
|
--- Restore the stored cursor position
|
|
function M.restore_cursor_position()
|
|
if not state.cursor_restore_pos or not state.buf then
|
|
return
|
|
end
|
|
|
|
buffer.set_cursor(
|
|
state.buf,
|
|
state.cursor_restore_pos,
|
|
{ delay = opts.cursor_render_delay and opts.cursor_render_delay or 10 }
|
|
)
|
|
state.cursor_restore_pos = nil
|
|
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)
|
|
-- 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
|
|
end
|
|
|
|
-- Stop any running job first
|
|
if state.floating_job_id then
|
|
vim.fn.jobstop(state.floating_job_id)
|
|
state.floating_job_id = nil
|
|
end
|
|
|
|
-- Close previous channel
|
|
if state.floating_chan then
|
|
vim.fn.chanclose(state.floating_chan)
|
|
state.floating_chan = nil
|
|
end
|
|
|
|
-- Wipe old buffer if it exists
|
|
if state.floating_buf and vim.api.nvim_buf_is_valid(state.floating_buf) then
|
|
vim.api.nvim_buf_delete(state.floating_buf, { force = true })
|
|
state.floating_buf = nil
|
|
end
|
|
|
|
-- Create new floating buffer
|
|
local buf, win = buffer.create_float({
|
|
title = " JJ Diff ",
|
|
title_pos = "center",
|
|
enter = true,
|
|
bufhidden = "hide",
|
|
win_options = {
|
|
wrap = true,
|
|
number = false,
|
|
relativenumber = false,
|
|
cursorline = false,
|
|
signcolumn = "no",
|
|
},
|
|
on_exit = function(b)
|
|
if state.floating_buf == b then
|
|
state.floating_buf = nil
|
|
end
|
|
if state.floating_chan then
|
|
vim.fn.chanclose(state.floating_chan)
|
|
state.floating_chan = nil
|
|
end
|
|
if state.floating_job_id then
|
|
vim.fn.jobstop(state.floating_job_id)
|
|
state.floating_job_id = nil
|
|
end
|
|
end,
|
|
})
|
|
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
|
|
|
|
-- 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()
|
|
buffer.set_modifiable(state.floating_buf, false)
|
|
buffer.stop_insert(state.floating_buf)
|
|
end)
|
|
end,
|
|
})
|
|
|
|
if jid <= 0 then
|
|
vim.api.nvim_chan_send(chan, "Failed to start command: " .. cmd .. "\r\n")
|
|
state.floating_chan = nil
|
|
else
|
|
state.floating_job_id = jid
|
|
end
|
|
|
|
-- 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", "v" }, lhs = "i", rhs = function() end },
|
|
{ modes = { "n", "v" }, lhs = "c", rhs = function() end },
|
|
{ modes = { "n", "v" }, lhs = "a", rhs = function() end },
|
|
{ modes = { "n", "v" }, lhs = "u", rhs = function() end },
|
|
}
|
|
|
|
-- Merge default keymaps with provided keymaps
|
|
if keymaps and #keymaps > 0 then
|
|
for _, km in ipairs(keymaps) do
|
|
table.insert(default_keymaps, km)
|
|
end
|
|
end
|
|
|
|
buffer.set_keymaps(state.floating_buf, default_keymaps)
|
|
vim.b[state.floating_buf].jj_keymaps_set = true
|
|
end
|
|
end
|
|
|
|
--- Run a command and show it's output in a terminal buffer
|
|
--- If a previous command already existed it smartly reuses the buffer cleaning the previous output
|
|
--- @param cmd string|string[] The command to run in the terminal buffer
|
|
--- @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 type(cmd) == "string" then
|
|
cmd = { cmd }
|
|
end
|
|
|
|
-- Clean up previous state if invalid
|
|
if state.buf and not vim.api.nvim_buf_is_valid(state.buf) then
|
|
state.buf = nil
|
|
state.chan = nil
|
|
state.job_id = nil
|
|
state.buf_cmd = nil
|
|
end
|
|
|
|
-- Stop any running job first
|
|
if state.job_id then
|
|
vim.fn.jobstop(state.job_id)
|
|
state.job_id = nil
|
|
end
|
|
|
|
-- Close previous channel
|
|
if state.chan then
|
|
vim.fn.chanclose(state.chan)
|
|
state.chan = nil
|
|
end
|
|
|
|
-- Wipe old buffer if it exists
|
|
if state.buf and vim.api.nvim_buf_is_valid(state.buf) then
|
|
vim.api.nvim_buf_delete(state.buf, { force = true })
|
|
state.buf = nil
|
|
end
|
|
|
|
-- Create new terminal buffer
|
|
state.buf = buffer.create({
|
|
split = "horizontal",
|
|
size = math.floor(vim.o.lines / 2),
|
|
on_exit = function(buf)
|
|
if state.buf == buf then
|
|
state.buf = nil
|
|
end
|
|
if state.chan then
|
|
vim.fn.chanclose(state.chan)
|
|
state.chan = nil
|
|
end
|
|
if state.job_id then
|
|
vim.fn.jobstop(state.job_id)
|
|
state.job_id = nil
|
|
end
|
|
state.buf_cmd = nil
|
|
end,
|
|
})
|
|
|
|
local win = vim.api.nvim_get_current_win()
|
|
vim.bo[state.buf].bufhidden = "wipe"
|
|
|
|
-- Create new terminal channel
|
|
local chan = vim.api.nvim_open_term(state.buf, {})
|
|
if not chan or chan <= 0 then
|
|
vim.notify("Failed to create terminal channel", vim.log.levels.ERROR)
|
|
return
|
|
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
|
|
cmd = vim.split(cmd[1], "%s+")
|
|
end
|
|
|
|
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.buf or not vim.api.nvim_buf_is_valid(state.buf) or not state.chan then
|
|
return
|
|
end
|
|
local output = table.concat(data, "\n")
|
|
vim.api.nvim_chan_send(state.chan, output)
|
|
end,
|
|
on_exit = function(_, exit_code)
|
|
vim.schedule(function()
|
|
-- Store the subcommand on successful exit
|
|
if exit_code == 0 then
|
|
state.buf_cmd = cmd[2] or nil
|
|
end
|
|
-- Make the buffer not modifiable
|
|
buffer.set_modifiable(state.buf, false)
|
|
buffer.stop_insert(state.buf)
|
|
-- Restore cursor position after buffer is ready
|
|
if state.cursor_restore_pos then
|
|
M.restore_cursor_position()
|
|
end
|
|
end)
|
|
end,
|
|
})
|
|
|
|
if jid <= 0 then
|
|
vim.api.nvim_chan_send(chan, "Failed to start command: " .. cmd .. "\r\n")
|
|
state.chan = nil
|
|
else
|
|
state.job_id = jid
|
|
end
|
|
|
|
-- Set keymaps only if they haven't been set for this buffer
|
|
-- Set base keymaps only if they haven't been set for this buffer yet
|
|
if not vim.b[state.buf].jj_keymaps_set then
|
|
buffer.set_keymaps(state.buf, {
|
|
-- Disable insert, command and append modes
|
|
{ modes = { "n", "v" }, lhs = "i", rhs = function() end },
|
|
{ modes = { "n", "v" }, lhs = "c", rhs = function() end },
|
|
{ modes = { "n", "v" }, lhs = "a", rhs = function() end },
|
|
{ modes = { "n", "v" }, lhs = "u", rhs = function() end },
|
|
})
|
|
|
|
vim.b[state.buf].jj_keymaps_set = true
|
|
end
|
|
|
|
-- Remove command-specific keymaps from previous runs
|
|
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
|
|
|
|
-- Add command-specific keymaps for jj buffers
|
|
local new_command_keymaps = {}
|
|
|
|
-- Append the given keymaps
|
|
-- Add a debug
|
|
if keymaps and #keymaps > 0 then
|
|
for _, km in ipairs(keymaps) do
|
|
table.insert(new_command_keymaps, km)
|
|
end
|
|
end
|
|
|
|
-- Status keymaps are already handled in cmd.lua via status_keymaps()
|
|
-- No need to duplicate them here
|
|
if #new_command_keymaps > 0 then
|
|
buffer.set_keymaps(state.buf, new_command_keymaps)
|
|
vim.b[state.buf].jj_command_keymaps = new_command_keymaps
|
|
end
|
|
|
|
vim.cmd("stopinsert")
|
|
|
|
return state.buf
|
|
end
|
|
|
|
return M
|