diff --git a/README.md b/README.md index c241724..60ab2e6 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ - [Current Features](#current-features) - [Enhanced Integrations](#enhanced-integrations) + - [View change summary from the log buffer](#view-change-summary-from-the-log-buffer) - [Diff any change](#diff-any-change) - [Edit changes](#edit-changes) - [Create new changes from the log buffer](#create-new-changes-from-the-log-buffer) @@ -73,6 +74,21 @@ Here are some cool features you can do with jj.nvim: +### View change summary from the log buffer + +Quickly preview the files changed in any revision without leaving the log: + +- `` - Show a tooltip with the revision's changed files +- `` - To enter the tooltip buffer + +From the summary view, you can: + +- `d` - Diff the file under cursor at that revision +- `` - Edit the revision and open the file +- `` - Edit the revision (ignoring immutability) and open the file + +![Summary-from-log](https://github.com/NicolasGB/jj.nvim/raw/main/assets/summary.gif) + ### Diff any change You can diff any change in your log history by simply pressing `d` on its line, yeah just like that! @@ -347,6 +363,12 @@ The plugin also provides `:Jdiff`, `:Jvdiff`, and `:Jhdiff` commands for diffing exit_mode = { "", "" }, -- Exit squash mode }, quick_squash = "", -- Quick squash revision under cursor into its parent (ignore immutability) + summary = "", -- Show summary tooltip for revision under cursor + summary_tooltip = { + diff = "", -- Diff file at this revision + edit = "", -- Edit revision and open file + edit_immutable = "", -- Edit revision (ignore immutability) and open file + }, }, -- Status buffer keymaps (set to nil to disable) status = { diff --git a/assets/summary.gif b/assets/summary.gif new file mode 100644 index 0000000..73f288d Binary files /dev/null and b/assets/summary.gif differ diff --git a/lua/jj/cmd/init.lua b/lua/jj/cmd/init.lua index 5b59d5f..df6da2c 100644 --- a/lua/jj/cmd/init.lua +++ b/lua/jj/cmd/init.lua @@ -51,6 +51,8 @@ local status_module = require("jj.cmd.status") --- @field squash? string|string[] --- @field squash_mode? jj.cmd.squash.keymaps --- @field quick_squash? string|string[] +--- @field summary? string|string[] +--- @field summary_tooltip? jj.cmd.summary_tooltip.keymaps --- @class jj.cmd.rebase.keymaps --- @field onto? string|string[] @@ -66,6 +68,11 @@ local status_module = require("jj.cmd.status") --- @field into_immutable? string|string[] --- @field exit_mode? string|string[] +--- @class jj.cmd.summary_tooltip.keymaps +--- @field diff? string|string[] +--- @field edit? string|string[] +--- @field edit_immutable? string|string[] + --- @class jj.cmd.bookmark --- @field prefix? string Prefix to append when creating a bookmark @@ -154,6 +161,12 @@ M.config = { exit_mode = { "", "" }, }, quick_squash = "", + summary = "", + summary_tooltip = { + diff = "", + edit = "", + edit_immutable = "", + }, }, status = { open_file = "", @@ -256,12 +269,12 @@ function M.floating_keymaps() close = { desc = "Close floating buffer", handler = terminal.close_floating_buffer, - modes = { "n" }, + modes = { "n", "v" }, }, hide = { desc = "Hide floating buffer", handler = terminal.hide_floating_buffer, - modes = { "n" }, + modes = { "n", "v" }, }, }) end diff --git a/lua/jj/cmd/log.lua b/lua/jj/cmd/log.lua index f4be33b..4c4a2d4 100644 --- a/lua/jj/cmd/log.lua +++ b/lua/jj/cmd/log.lua @@ -5,6 +5,7 @@ local utils = require("jj.utils") local runner = require("jj.core.runner") local parser = require("jj.core.parser") local terminal = require("jj.ui.terminal") +local buffer = require("jj.core.buffer") local log_selected_hl_group = "JJLogSelectedHlGroup" local log_selected_ns_id = vim.api.nvim_create_namespace(log_selected_hl_group) @@ -543,7 +544,6 @@ function M.handle_log_push_bookmark() bookmarks = vim.split(bookmark, "%s+", { trimempty = true }) table.insert(bookmarks, "[All]") - utils.notify(vim.inspect(bookmarks)) vim.ui.select(bookmarks, { prompt = "Which bookmark do you want to push?", }, function(choice) @@ -714,6 +714,193 @@ function M.handle_log_quick_squash() end, string.format("Error squashing `%s` into it's parent", revset)) 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 +--- @param revset string The revision being viewed +function M.handle_summary_diff(revset) + local line = vim.api.nvim_get_current_line() + local filepath = parser.parse_file_info_from_status_line(line) + if not filepath then + utils.notify("No file found on this line", vim.log.levels.WARN) + return + end + + -- Keep the tooltip open + if terminal.state.tooltip_buf and vim.api.nvim_buf_is_valid(terminal.state.tooltip_buf) then + terminal.keep_tooltip_open(true) + end + + -- Add custom close behavior that returns to tooltip + local function close_and_return() + -- Close the floating diff + terminal.close_floating_buffer() + -- Return focus to tooltip if still valid + if terminal.state.tooltip_win and vim.api.nvim_win_is_valid(terminal.state.tooltip_win) then + vim.api.nvim_set_current_win(terminal.state.tooltip_win) + end + -- Clear suppress flag + if terminal.state.tooltip_buf and vim.api.nvim_buf_is_valid(terminal.state.tooltip_buf) then + terminal.keep_tooltip_open(false) + end + end + + local cmd = require("jj.cmd") + local cfg = cmd.config.keymaps.floating or {} + -- In this specific case we override the behaviour of the default terminal close and return to the old buffer + local specs = { + close = { + modes = { "n", "v" }, + handler = close_and_return, + desc = "Close diff and return to tooltip", + }, + hide = { + modes = { "n", "v" }, + handler = close_and_return, + desc = "Close diff and return to tooltip", + }, + } + + terminal.run_floating( + string.format("jj diff -r %s %s", revset, filepath.new_path), + cmd.resolve_keymaps_from_specs(cfg, specs) + ) +end + +--- Handle edit action in summary tooltip (opens file after jj edit) +--- Closes tooltip and log buffer, edits the revision, and opens the file +--- @param revset string The revision to edit +--- @param ignore_immut boolean Whether to ignore immutability +function M.handle_summary_edit(revset, ignore_immut) + -- Before anything check if the revset is immutable and the ignore_immut flag is set + if utils.is_change_immutable(revset) and not ignore_immut then + utils.notify( + string.format("The change `%s` is immutable, use the `edit_immutable` shortcut.", revset), + vim.log.levels.WARN + ) + return + end + + local line = vim.api.nvim_get_current_line() + local filepath = parser.parse_file_info_from_status_line(line) + if not filepath then + utils.notify("No file found on this line", vim.log.levels.WARN) + return + end + + -- Close the tooltip first + if terminal.state.tooltip_buf and vim.api.nvim_buf_is_valid(terminal.state.tooltip_buf) then + terminal.close_tooltip() + end + + -- Close the log buffer + terminal.close_terminal_buffer() + + local cmd = string.format("jj edit %s", revset) + if ignore_immut then + cmd = cmd .. " --ignore-immutable" + end + + runner.execute_command_async(cmd, function() + utils.notify(string.format("Editing revset: `%s`", revset), vim.log.levels.INFO) + -- Open the file in the current window + vim.cmd("edit " .. vim.fn.fnameescape(filepath.new_path)) + end, string.format("Error editing revset: `%s`", revset)) +end + +--- Get keymaps for summary tooltip +--- @param revset string The revision being viewed +--- @return jj.core.buffer.keymap[] +function M.summary_keymaps(revset) + local cmd = require("jj.cmd") + local keymaps = cmd.config.keymaps.log.summary_tooltip or {} + local specs = { + diff = { + modes = { "n" }, + handler = M.handle_summary_diff, + args = { revset }, + desc = "Diff file at this revision", + }, + edit = { + modes = { "n" }, + handler = M.handle_summary_edit, + args = { revset, false }, + desc = "Edit revision and open file", + }, + edit_immutable = { + modes = { "n" }, + handler = M.handle_summary_edit, + args = { revset, true }, + opts = { desc = "Edit revision (ignore immutability) and open file" }, + }, + } + return cmd.resolve_keymaps_from_specs(keymaps, specs) +end + +--- Build the summary command for a revset +--- @param revset string The revision to show summary for +--- @return string The jj log command +local function build_summary_cmd(revset) + local template = '"Commit ID: " ++ commit_id ++ "\\n" ' + .. '++ "Change ID: " ++ change_id ++ "\\n" ' + .. '++ "Author: " ++ author ++ " (" ++ author.timestamp() ++ ")\\n" ' + .. '++ "Committer: " ++ committer ++ " (" ++ committer.timestamp() ++ ")\\n\\n" ' + .. "++ description " + .. '++ "\\n" ++ self.diff().summary()' + + return string.format("jj log -r %s --no-graph -T '%s'", revset, template) +end + +--- Handle showing summary tooltip for revision under cursor +--- First K shows tooltip without entering, second K enters the tooltip +function M.handle_log_summary() + -- If tooltip exists and is valid, enter it on second K + if terminal.state.tooltip_buf and vim.api.nvim_buf_is_valid(terminal.state.tooltip_buf) then + local revset = vim.b[terminal.state.tooltip_buf].jj_summary_revset + if revset and terminal.state.tooltip_buf and vim.api.nvim_win_is_valid(terminal.state.tooltip_win) then + -- Store the cursor position + terminal.store_cursor_position() + -- Enter the tooltip window and set up keymaps + vim.api.nvim_set_current_win(terminal.state.tooltip_win) + buffer.set_keymaps(terminal.state.tooltip_buf, M.summary_keymaps(revset)) + return + end + end + + local revset = get_revset() + if not revset or revset == "" then + return + end + + local cmd = build_summary_cmd(revset) + + -- Run command synchronously first to calculate dimensions + local output, success = runner.execute_command(cmd, nil, nil, false) + if not success or not output then + return + end + + -- Calculate dimensions based on output + local lines = vim.split(output, "\n", { trimempty = false }) + local max_width = 0 + for _, line in ipairs(lines) do + max_width = math.max(max_width, vim.fn.strdisplaywidth(line)) + end + local width = math.min(max_width + 2, vim.o.columns - 4) + local height = #lines + + local buf, _ = terminal.run_tooltip(cmd, { + title = string.format(" Summary: %s ", revset), + enter = false, + width = width, + height = height, + }) + + if buf then + vim.b[terminal.state.tooltip_buf].jj_summary_revset = revset + end +end + --- Resolve log keymaps from config, filtering out nil values --- @return jj.core.buffer.keymap[] function M.log_keymaps() @@ -833,6 +1020,11 @@ function M.log_keymaps() handler = M.handle_log_quick_squash, modes = { "n" }, }, + summary = { + desc = "Show summary tooltip for revision under cursor", + handler = M.handle_log_summary, + modes = { "n" }, + }, } return cmd.merge_keymaps(cmd.resolve_keymaps_from_specs(keymaps, specs), cmd.terminal_keymaps()) diff --git a/lua/jj/ui/terminal.lua b/lua/jj/ui/terminal.lua index 3b01fcd..9e8fbc4 100644 --- a/lua/jj/ui/terminal.lua +++ b/lua/jj/ui/terminal.lua @@ -39,6 +39,22 @@ local state = { -- Cursor position cursor_restore_pos = nil, + + -- The current tooltip buffer + --- @type integer|nil + tooltip_buf = nil, + -- The tooltip window + --- @type integer|nil + tooltip_win = nil, + -- The tooltip channel to communicate with the terminal + --- @type integer|nil + tooltip_chan = nil, + -- The tooltip_job_id + --- @type integer|nil + tooltip_job_id = nil, + -- The tooltip autocmd that auto closes it + --- @type integer|nil + tooltip_close_autocmd = nil, } -- Re-export @@ -123,12 +139,37 @@ end --- Close the current terminal buffer if it exists function M.close_terminal_buffer() + -- If the tooltip is showing that's what we want to close + if state.tooltip_buf then + M.close_tooltip() + return + end + + -- Otherwise close the buffer buffer.close(state.buf) + state.buf_cmd = nil + state.cursor_restore_pos = nil + state.chan = nil + state.job_id = nil end --- Close the current terminal buffer if it exists function M.close_floating_buffer() buffer.close(state.floating_buf) + state.floating_chan = nil + state.floating_job_id = nil + state.floating_buf = nil +end + +--- Close the current tooltip buffer if it exists +function M.close_tooltip() + buffer.close(state.tooltip_buf) + vim.api.nvim_del_autocmd(state.tooltip_close_autocmd) + state.tooltip_chan = nil + state.tooltip_job_id = nil + state.tooltip_buf = nil + state.tooltip_win = nil + state.tooltip_close_autocmd = nil end --- Hide the current floating window @@ -481,6 +522,179 @@ function M.replace_terminal_keymaps(keymaps) end end +--- @class jj.ui.terminal.tooltip_opts +--- @field title? string Tooltip title +--- @field enter? boolean Whether to enter the tooltip window (default: false) +--- @field keymaps? jj.core.buffer.keymap[] Keymaps to set on the tooltip buffer +--- @field on_exit? fun(buf: number) Callback when tooltip is closed +--- @field width? number Tooltip width (default: 80% of columns) +--- @field height? number Tooltip height (default: 80% of lines) + +--- Run a command in a PTY-based tooltip window +--- @param cmd string The command to run +--- @param tool_opts? jj.ui.terminal.tooltip_opts Tooltip options +--- @return number|nil buf Buffer handle, or nil on failure +--- @return number|nil win Window handle, or nil on failure +function M.run_tooltip(cmd, tool_opts) + tool_opts = tool_opts or {} + + -- Clean up previous state if invalid + if state.tooltip_buf and not vim.api.nvim_buf_is_valid(state.tooltip_buf) then + state.tooltip_buf = nil + state.tooltip_win = nil + state.tooltip_chan = nil + state.tooltip_job_id = nil + end + + -- Stop any running job first + if state.tooltip_job_id then + vim.fn.jobstop(state.tooltip_job_id) + state.tooltip_job_id = nil + end + + -- Close previous channel + if state.tooltip_chan then + vim.fn.chanclose(state.tooltip_chan) + state.tooltip_chan = nil + end + + -- Wipe old buffer if it exists + if state.tooltip_buf and vim.api.nvim_buf_is_valid(state.tooltip_buf) then + vim.api.nvim_buf_delete(state.tooltip_buf, { force = true }) + state.tooltip_buf = nil + end + + state.tooltip_buf, state.tooltip_win = buffer.create_float({ + title = tool_opts.title or " JJ ", + title_pos = "center", + enter = tool_opts.enter or false, + bufhidden = "wipe", + relative = "cursor", + row = 1, + col = 0, + width = tool_opts.width, + height = tool_opts.height, + win_options = { + wrap = true, + number = false, + relativenumber = false, + cursorline = false, + signcolumn = "no", + }, + }) + + state.tooltip_chan = vim.api.nvim_open_term(state.tooltip_buf, {}) + if not state.tooltip_chan or state.tooltip_chan <= 0 then + vim.notify("Failed to create terminal channel", vim.log.levels.ERROR) + vim.api.nvim_buf_delete(state.tooltip_buf, { force = true }) + return nil, nil + end + + state.tooltip_job_id = vim.fn.jobstart(cmd, { + pty = true, + width = vim.api.nvim_win_get_width(state.tooltip_win), + height = vim.api.nvim_win_get_height(state.tooltip_win), + env = { + TERM = "xterm-256color", + PAGER = "cat", + DELTA_PAGER = "cat", + COLORTERM = "truecolor", + }, + on_stdout = function(_, data) + if not state.tooltip_buf or not vim.api.nvim_buf_is_valid(state.tooltip_buf) then + return + end + local output = table.concat(data, "\n") + vim.api.nvim_chan_send(state.tooltip_chan, output) + end, + on_exit = function() + vim.schedule(function() + if state.tooltip_buf and vim.api.nvim_buf_is_valid(state.tooltip_buf) then + buffer.set_modifiable(state.tooltip_buf, false) + buffer.stop_insert(state.tooltip_buf) + end + end) + end, + }) + + if state.tooltip_job_id <= 0 then + vim.api.nvim_chan_send(state.tooltip_chan, "Failed to start command: " .. cmd .. "\r\n") + return state.tooltip_buf, state.tooltip_win + end + + vim.api.nvim_create_autocmd({ "BufWipeout", "BufDelete" }, { + buffer = state.tooltip_buf, + once = true, + callback = function() + if state.tooltip_chan then + pcall(vim.fn.chanclose, state.tooltip_chan) + end + if state.tooltip_job_id then + pcall(vim.fn.jobstop, state.tooltip_job_id) + end + + -- Clean the state when closing + state.tooltip_buf = nil + state.tooltip_win = nil + state.tooltip_chan = nil + state.tooltip_job_id = nil + end, + }) + + vim.keymap.set("n", "", function() + M.close_tooltip() + -- Focus the log window before restoring cursor position + if state.buf then + local log_win = vim.fn.bufwinid(state.buf) + if log_win ~= -1 then + vim.api.nvim_set_current_win(log_win) + end + end + M.restore_cursor_position() + end, { buffer = state.tooltip_buf, silent = true }) + vim.keymap.set("n", "q", function() + M.close_tooltip() + -- Focus the log window before restoring cursor position + if state.buf then + local log_win = vim.fn.bufwinid(state.buf) + if log_win ~= -1 then + vim.api.nvim_set_current_win(log_win) + end + end + M.restore_cursor_position() + end, { buffer = state.tooltip_buf, silent = true }) + + -- Close when cursor moves in other windows (unless suppress flag is set) + state.tooltip_close_autocmd = vim.api.nvim_create_autocmd("CursorMoved", { + callback = function() + if + vim.api.nvim_buf_is_valid(state.tooltip_buf) + and vim.api.nvim_win_is_valid(state.tooltip_win) + and vim.api.nvim_get_current_win() ~= state.tooltip_win + then + -- Don't close if the keep open flag is set (e.g., when opening floating diff from tooltip) + if vim.b[state.tooltip_buf].jj_keep_open then + return + end + buffer.close(state.tooltip_buf, true) + return true + end + end, + }) + + if tool_opts.keymaps and #tool_opts.keymaps > 0 then + buffer.set_keymaps(state.tooltip_buf, tool_opts.keymaps) + end + + return state.tooltip_buf, state.tooltip_win +end + +--- Whether or not to keep the tooltip open instead of autoclosing +--- @param keep_open boolean +function M.keep_tooltip_open(keep_open) + vim.b[state.tooltip_buf].jj_keep_open = keep_open +end + --- Replace keymaps for floating terminal --- @param keymaps jj.core.buffer.keymap[] New keymaps to set function M.replace_floating_keymaps(keymaps) diff --git a/lua/jj/utils.lua b/lua/jj/utils.lua index b7f5ca0..7bec0b8 100644 --- a/lua/jj/utils.lua +++ b/lua/jj/utils.lua @@ -256,4 +256,22 @@ function M.open_pr_for_bookmark(bookmark) end) end +--- Check if a given revset represents an immutable change +--- @param revset string The revset to check +--- @return boolean True if the change is immutable, false otherwise +function M.is_change_immutable(revset) + local output, success = runner.execute_command( + string.format("jj log --no-graph -r '%s' -T 'immutable'", revset), + "Error checking change immutability", + nil, + true + ) + + if not success or not output then + return false + end + + return vim.trim(output) == "true" +end + return M