diff --git a/README.md b/README.md index dcaa855..ff31554 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,14 @@ The plugin also provides `:Jdiff`, `:Jvdiff`, and `:Jhdiff` commands for diffing renamed = { fg = "#d29922", ctermfg = "Yellow" }, -- Renamed files }, + -- Configure terminal behavior + terminal = { + -- Cursor render delay in milliseconds (default: 10) + -- 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 cmd module (describe editor, keymaps) cmd = { -- Configure describe editor @@ -305,6 +313,9 @@ jj.diff.hsplit({ rev = "@-2" }) -- Horizontal split against @-2 config = function() local jj = require("jj") jj.setup({ + terminal = { + cursor_render_delay = 10, -- Adjust if cursor position isn't restoring correctly + }, cmd = { describe = { editor = { diff --git a/lua/jj/cmd/describe.lua b/lua/jj/cmd/describe.lua index e908d32..4f26fbf 100644 --- a/lua/jj/cmd/describe.lua +++ b/lua/jj/cmd/describe.lua @@ -124,10 +124,7 @@ function M.describe(description, revset, opts) end, function() if open_log_on_close then vim.schedule(function() - vim.o.lazyredraw = true cmd.log({}) - vim.o.lazyredraw = false - vim.cmd("redraw!") end) end end, describe_editor_keymaps()) diff --git a/lua/jj/cmd/log.lua b/lua/jj/cmd/log.lua index 9d31e41..02f7373 100644 --- a/lua/jj/cmd/log.lua +++ b/lua/jj/cmd/log.lua @@ -15,7 +15,7 @@ local terminal = require("jj.ui.terminal") --- @field raw_flags? string ---@type jj.cmd.log_opts -local default_log_opts = { summary = false, reversed = false, no_graph = false, limit = 20, raw_flats = nil } +local default_log_opts = { summary = false, reversed = false, no_graph = false, limit = 20, raw_flags = nil } --- Jujutsu log --- @param opts? jj.cmd.log_opts Optional command options @@ -24,12 +24,17 @@ function M.log(opts) return 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 + terminal.store_cursor_position() + end + local jj_cmd = "jj log" local merged_opts = vim.tbl_extend("force", default_log_opts, opts or {}) -- If a raw has been given simply execute it as is - if merged_opts.raw then - return terminal.run(string.format("%s %s", jj_cmd, merged_opts.raw), M.log_keymaps()) + if merged_opts.raw_flags then + return terminal.run(string.format("%s %s", jj_cmd, merged_opts.raw_flags), M.log_keymaps()) end for key, value in pairs(merged_opts) do diff --git a/lua/jj/core/buffer.lua b/lua/jj/core/buffer.lua index e06eeaf..59b1dec 100644 --- a/lua/jj/core/buffer.lua +++ b/lua/jj/core/buffer.lua @@ -297,4 +297,76 @@ function M.start_insert(buf) vim.cmd("startinsert") end -return M \ No newline at end of file +--- Get cursor position for a buffer +--- @param buf number Buffer handle +--- @return number[]|nil Cursor position as {line, col} or nil if buffer not visible +function M.get_cursor(buf) + if not vim.api.nvim_buf_is_valid(buf) then + return nil + end + + local winid = vim.fn.bufwinid(buf) + if winid == -1 then + return nil + end + + return vim.api.nvim_win_get_cursor(winid) +end + +--- Clamp cursor position to valid buffer bounds +--- @param buf number Buffer handle +--- @param pos number[] Cursor position as {line, col} +--- @return number[] Clamped position as {line, col} +local function clamp_cursor_position(buf, pos) + -- Validate and clamp position to buffer bounds + local line_count = vim.api.nvim_buf_line_count(buf) + local target_line = math.max(1, math.min(pos[1], line_count)) + + -- Get the actual line content to validate column + local line_content = vim.api.nvim_buf_get_lines(buf, target_line - 1, target_line, false)[1] or "" + local max_col = #line_content + local target_col = math.max(0, math.min(pos[2], max_col)) + + return { target_line, target_col } +end + +--- Set cursor position for a buffer +--- Automatically validates and clamps position to buffer bounds: +--- - Line number is clamped to [1, line_count] +--- - Column is clamped to [0, line_length] based on actual line content +--- For terminal buffers, uses defer_fn to allow terminal rendering to stabilize +--- @param buf number Buffer handle +--- @param pos number[] Cursor position as {line, col} (1-indexed line, 0-indexed column) +--- @param opts? {delay?: number} Optional delay in ms for terminal buffers (default: 10) +function M.set_cursor(buf, pos, opts) + if not vim.api.nvim_buf_is_valid(buf) then + return + end + + opts = opts or {} + local delay = opts.delay or 10 + + local winid = vim.fn.bufwinid(buf) + if winid == -1 then + return + end + + -- For terminal buffers, delay to allow rendering to complete + -- Must validate position INSIDE the deferred function for terminal buffers + -- because the buffer content may not be stable yet + if vim.bo[buf].buftype == "terminal" or delay > 0 then + vim.defer_fn(function() + if not vim.api.nvim_buf_is_valid(buf) or not vim.api.nvim_win_is_valid(winid) then + return + end + + local clamped_pos = clamp_cursor_position(buf, pos) + vim.api.nvim_win_set_cursor(winid, clamped_pos) + end, delay) + else + local clamped_pos = clamp_cursor_position(buf, pos) + vim.api.nvim_win_set_cursor(winid, clamped_pos) + end +end + +return M diff --git a/lua/jj/init.lua b/lua/jj/init.lua index f7f88d8..bf7e9fe 100644 --- a/lua/jj/init.lua +++ b/lua/jj/init.lua @@ -2,12 +2,15 @@ local M = {} local cmd = require("jj.cmd") local picker = require("jj.picker") local editor = require("jj.ui.editor") +local terminal = require("jj.ui.terminal") --- Jujutsu plugin configuration --- @class jj.Config --- @field cmd? jj.cmd.opts Options for command module --- @field picker? jj.picker.config Options for picker module +--- @field terminal? jj.ui.terminal.opts Options for the terminal --- @field highlights? jj.ui.editor.highlights Highlight configuration for describe buffer + M.config = { -- Default configuration --- @type jj.picker.config @@ -27,6 +30,7 @@ function M.setup(opts) picker.setup(opts and opts.picker or {}) editor.setup({ highlights = M.config.highlights }) cmd.setup(opts and opts.cmd or {}) + terminal.setup(opts and opts.terminal or {}) cmd.register_command() end diff --git a/lua/jj/ui/terminal.lua b/lua/jj/ui/terminal.lua index 2573775..6ae896d 100644 --- a/lua/jj/ui/terminal.lua +++ b/lua/jj/ui/terminal.lua @@ -1,8 +1,17 @@ --- @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 @@ -27,11 +36,20 @@ local state = { --- 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) @@ -51,6 +69,27 @@ function M.hide_floating_buffer() 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 @@ -178,6 +217,7 @@ end --- 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 } @@ -241,7 +281,7 @@ function M.run(cmd, keymaps) state.chan = chan -- Move cursor to top before output arrives - vim.api.nvim_win_set_cursor(win, { 1, 0 }) + -- vim.api.nvim_win_set_cursor(win, { 1, 0 }) -- If the command is a string split it into parts -- to store the subcommand later @@ -269,13 +309,17 @@ function M.run(cmd, keymaps) end, on_exit = function(_, exit_code) vim.schedule(function() - -- Make the buffer not modifiable - buffer.set_modifiable(state.buf, false) - buffer.stop_insert(state.buf) -- 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, }) @@ -326,7 +370,8 @@ function M.run(cmd, keymaps) end vim.cmd("stopinsert") + + return state.buf end return M -