--- @class jj.ui.terminal local M = {} local utils = require("jj.utils") local parser = require("jj.core.parser") 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, } M.state = state --- Close the current terminal buffer if it exists function M.close_terminal_buffer() if not state.buf then return elseif state.buf and vim.api.nvim_buf_is_valid(state.buf) then vim.cmd("bwipeout! " .. state.buf) else vim.cmd("close") end end --- Close the current terminal buffer if it exists local function close_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("bwipeout! " .. state.floating_buf) else vim.cmd("close") end end --- Hide the current floating window local function hide_floating_window() 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 local function handle_status_enter() local file_info = parser.parse_file_info_from_status_line(vim.api.nvim_get_current_line()) if not file_info then return end local filepath = file_info.new_path local stat = vim.uv.fs_stat(filepath) if not stat then utils.notify("File not found: " .. filepath, vim.log.levels.ERROR) return end -- Go to the previous window (split above) vim.cmd("wincmd p") -- Open the file in that window, replacing current buffer vim.cmd("edit " .. vim.fn.fnameescape(filepath)) end local function handle_status_restore() local file_info = parser.parse_file_info_from_status_line(vim.api.nvim_get_current_line()) if not file_info then return end local runner = require("jj.core.runner") if file_info.is_rename then -- For renamed files, remove the new file and restore the old one from parent revision local rm_cmd = "rm " .. vim.fn.shellescape(file_info.new_path) local restore_cmd = "jj restore --from @- " .. vim.fn.shellescape(file_info.old_path) local _, rm_success = runner.execute_command(rm_cmd, "Failed to remove renamed file") if rm_success then local _, restore_success = runner.execute_command(restore_cmd, "Failed to restore original file") if restore_success then utils.notify( "Reverted rename: " .. file_info.new_path .. " -> " .. file_info.old_path, vim.log.levels.INFO ) require("jj.cmd").status() end end else -- For non-renamed files, use regular restore local restore_cmd = "jj restore " .. vim.fn.shellescape(file_info.old_path) local _, success = runner.execute_command(restore_cmd, "Failed to restore") if success then utils.notify("Restored: " .. file_info.old_path, vim.log.levels.INFO) require("jj.cmd").status() end end end --- Handle keypress enter on `jj log` buffer to edit a revision. --- If ignore_immut is true, adds --ignore-immutable to the command. --- Silently returns if no revision is found or the jj command fails. --- On success, notifies and refreshes the log buffer. --- @param ignore_immut? boolean Pass --ignore-immutable to jj edit when true. local function handle_log_enter(ignore_immut) local line = vim.api.nvim_get_current_line() local revset = parser.get_rev_from_log_line(line) if not revset or revset == "" then return end local runner = require("jj.core.runner") -- If we found a revision, edit it. -- Build command parts. local cmd_parts = { "jj", "edit" } if ignore_immut then table.insert(cmd_parts, "--ignore-immutable") end table.insert(cmd_parts, revset) -- Build cmd string local cmd = table.concat(cmd_parts, " ") -- Try to execute cmd local _, success = runner.execute_command(cmd, "Error editing change") if not success then return end utils.notify(string.format("Editing change: `%s`", revset), vim.log.levels.INFO) -- Close the terminal buffer M.close_terminal_buffer() end --- Create a new change relative to the revision under the cursor in a jj log buffer. --- Behavior: --- flag == nil -> branch off the current revision --- flag == "after" -> create a new change after the current revision (-A) --- If ignore_immut is true, adds --ignore-immutable to the command. --- Silently returns if no revision is found or the jj command fails. --- On success, notifies and refreshes the log buffer. --- @param flag? 'after' Position relative to the current revision; nil to branch off. --- @param ignore_immut? boolean Pass --ignore-immutable to jj when true. local function handle_log_new(flag, ignore_immut) local line = vim.api.nvim_get_current_line() local revset = parser.get_rev_from_log_line(line) if not revset or revset == "" then return end local runner = require("jj.core.runner") -- Mapping for flag-specific options and messages. local flag_map = { after = { opt = "-A", err = "Error creating new change after: `%s`", ok = "Successfully created change after: `%s`", }, default = { opt = "", err = "Error creating new change branching off `%s`", ok = "Successfully created change branching off `%s`", }, } local cfg = flag_map[flag] or flag_map.default -- Build command parts local cmd_parts = { "jj", "new" } if cfg.opt ~= "" then table.insert(cmd_parts, cfg.opt) end table.insert(cmd_parts, revset) if ignore_immut then table.insert(cmd_parts, "--ignore-immutable") end local cmd = table.concat(cmd_parts, " ") local _, success = runner.execute_command(cmd, string.format(cfg.err, revset)) if not success then return end utils.notify(string.format(cfg.ok, revset), vim.log.levels.INFO) -- Refresh the log buffer after creating the change. require("jj.cmd").log() end --- Handle diffing a log line local function handle_log_diff() local line = vim.api.nvim_get_current_line() local revset = parser.get_rev_from_log_line(line) if revset then local cmd = string.format("jj show %s", revset) M.run_floating(cmd) else utils.notify("No valid revision found in the log line", vim.log.levels.ERROR) end end --- Handle describing a log line local function handle_log_describe() local line = vim.api.nvim_get_current_line() local revset = parser.get_rev_from_log_line(line) if revset then require("jj.cmd").describe(nil, revset) else utils.notify("No valid revision found in the log line", vim.log.levels.ERROR) end end --- Create a floating window for terminal output --- @param config table Window configuration options --- @param enter boolean Whether to enter the window after creation --- @return integer buf Buffer number --- @return integer win Window number local function create_floating_window(config, enter) local default_config = { width = math.floor(vim.o.columns * 0.8), height = math.floor(vim.o.lines * 0.8), row = math.floor((vim.o.lines - math.floor(vim.o.lines * 0.8)) / 2), col = math.floor((vim.o.columns - math.floor(vim.o.columns * 0.8)) / 2), relative = "editor", style = "minimal", border = "rounded", title = " JJ Diff ", title_pos = "center", } local merged_config = vim.tbl_extend("force", default_config, config or {}) -- Create buffer local buf = vim.api.nvim_create_buf(false, true) -- Create window local win = vim.api.nvim_open_win(buf, enter or false, merged_config) -- Set buffer options vim.bo[buf].bufhidden = "hide" -- Set window options vim.wo[win].wrap = true vim.wo[win].number = false vim.wo[win].relativenumber = false vim.wo[win].cursorline = false vim.wo[win].signcolumn = "no" return buf, win end --- Run the command in a floating window --- @param cmd string The command to run in the floating window function M.run_floating(cmd) -- 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 = create_floating_window({}, true) 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 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 vim.api.nvim_buf_is_valid(state.floating_buf) then vim.bo[state.floating_buf].modifiable = false if vim.api.nvim_get_current_buf() == state.floating_buf then vim.cmd("stopinsert") end end end) end, }) -- Set keymaps only if they haven't been set for this buffer if not vim.b[state.floating_buf].jj_keymaps_set then vim.keymap.set( { "n", "v" }, "i", function() end, { buffer = state.floating_buf, noremap = true, silent = true } ) vim.keymap.set( { "n", "v" }, "c", function() end, { buffer = state.floating_buf, noremap = true, silent = true } ) vim.keymap.set( { "n", "v" }, "a", function() end, { buffer = state.floating_buf, noremap = true, silent = true } ) vim.keymap.set( { "n", "v" }, "q", close_floating_buffer, { buffer = state.floating_buf, noremap = true, silent = true, desc = "Close the floating buffer" } ) vim.keymap.set( { "n" }, "", hide_floating_window, { buffer = state.floating_buf, noremap = true, silent = true, desc = "Hide the buffer" } ) vim.b[state.floating_buf].jj_keymaps_set = true end -- Set up cleanup autocmd only once per buffer if not vim.b[state.floating_buf].jj_cleanup_set then vim.api.nvim_create_autocmd({ "BufWipeout", "BufDelete" }, { buffer = state.floating_buf, callback = function() if state.floating_buf and vim.api.nvim_buf_is_valid(state.floating_buf) then state.floating_buf = nil end if state.floating_chan then vim.fn.chanclose(chan) end if jid then vim.fn.jobstop(jid) end end, }) vim.b[state.floating_buf].jj_cleanup_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[] function M.run(cmd) 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 local height = math.floor(vim.o.lines / 2) vim.cmd(string.format("%dsplit", height)) local win = vim.api.nvim_get_current_win() state.buf = vim.api.nvim_create_buf(false, true) vim.api.nvim_win_set_buf(win, state.buf) 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 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() -- Make the buffer not modifiable if vim.api.nvim_buf_is_valid(state.buf) then vim.bo[state.buf].modifiable = false if vim.api.nvim_get_current_buf() == state.buf then vim.cmd("stopinsert") end end -- Store the subcommand on successful exit if exit_code == 0 then state.buf_cmd = cmd[2] or nil 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 vim.keymap.set({ "n", "v" }, "i", function() end, { buffer = state.buf, noremap = true, silent = true }) vim.keymap.set({ "n", "v" }, "c", function() end, { buffer = state.buf, noremap = true, silent = true }) vim.keymap.set({ "n", "v" }, "a", function() end, { buffer = state.buf, noremap = true, silent = true }) vim.keymap.set( { "n", "v" }, "q", M.close_terminal_buffer, { buffer = state.buf, noremap = true, silent = true, desc = "Close the terminal buffer" } ) vim.keymap.set( { "n" }, "", M.close_terminal_buffer, { buffer = state.buf, noremap = true, silent = true, desc = "Close the terminal buffer" } ) 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 for _, map in ipairs(vim.b[state.buf].jj_command_keymaps) do local modes = map.modes if type(modes) ~= "table" then modes = { modes } end for _, mode in ipairs(modes) do pcall(vim.keymap.del, mode, map.lhs, { buffer = state.buf }) end end vim.b[state.buf].jj_command_keymaps = nil end -- Add command-specific keymaps for jj buffers local new_command_keymaps = {} local function register_command_keymap(modes, lhs, rhs, opts) local normalized_modes = type(modes) == "table" and vim.deepcopy(modes) or { modes } opts = opts or {} opts.buffer = state.buf if opts.noremap == nil then opts.noremap = true end if opts.silent == nil then opts.silent = true end vim.keymap.set(modes, lhs, rhs, opts) table.insert(new_command_keymaps, { modes = normalized_modes, lhs = lhs }) end -- Add Enter key mapping for status buffers to open files if cmd[2] == "st" or cmd[2] == "status" then register_command_keymap({ "n" }, "", handle_status_enter, { desc = "Open file under cursor" }) register_command_keymap({ "n" }, "X", handle_status_restore, { desc = "Restore file under cursor" }) elseif cmd[2] == "log" then -- Edit register_command_keymap({ "n" }, "", function() handle_log_enter(false) end, { desc = "Edit change under cursor" }) register_command_keymap({ "n" }, "", function() handle_log_enter(true) end, { desc = "Edit change under cursor ignoring immutability" }) -- Diff register_command_keymap({ "n" }, "d", handle_log_diff, { desc = "Diff change under cursor" }) -- New register_command_keymap({ "n" }, "n", handle_log_new, { desc = "New change off the change under cursor" }) register_command_keymap({ "n" }, "", function() handle_log_new("after") end, { desc = "New change after the change under cursor" }) register_command_keymap({ "n" }, "", function() handle_log_new("after", true) end, { desc = "New change after the change under cursor ignoring immutability" }) -- Undo/Redo register_command_keymap({ "n" }, "u", function() require("jj.cmd").undo() end, { desc = "Undo last operation" }) register_command_keymap({ "n" }, "r", function() require("jj.cmd").redo() end, { desc = "Redo last operation" }) register_command_keymap({ "n" }, "D", handle_log_describe, { desc = "Describe change under cursor" }) end if #new_command_keymaps > 0 then vim.b[state.buf].jj_command_keymaps = new_command_keymaps end vim.cmd("stopinsert") -- Set up cleanup autocmd only once per buffer if not vim.b[state.buf].jj_cleanup_set then vim.api.nvim_create_autocmd({ "BufWipeout", "BufDelete" }, { buffer = state.buf, callback = function() if state.buf and vim.api.nvim_buf_is_valid(state.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, }) vim.b[state.buf].jj_cleanup_set = true end end return M