mirror of
https://github.com/zoriya/jj.nvim.git
synced 2026-08-15 23:53:18 +00:00
feat(log): Add summary view (#70)
Display a summary of changed files for any revision in the log buffer using a floating tooltip. From the tooltip, users can diff or edit specific files directly. - Add summary tooltip triggered by <S-k> in log buffer - Integrate tooltip as stateful component of terminal - Make summary keymaps configurable - Add documentation and demo gif
This commit is contained in:
+15
-2
@@ -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 = { "<Esc>", "<C-c>" },
|
||||
},
|
||||
quick_squash = "<S-s>",
|
||||
summary = "<S-k>",
|
||||
summary_tooltip = {
|
||||
diff = "<S-d>",
|
||||
edit = "<CR>",
|
||||
edit_immutable = "<S-CR>",
|
||||
},
|
||||
},
|
||||
status = {
|
||||
open_file = "<CR>",
|
||||
@@ -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
|
||||
|
||||
+193
-1
@@ -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())
|
||||
|
||||
@@ -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", "<Esc>", 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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user