Change the :J describe behaviour to open an editable buffer instead of an input box (#6)

Co-authored-by: fautore <fautore@protonmail.com>
This commit is contained in:
Nicolas GB
2025-10-02 09:43:04 +02:00
committed by GitHub
co-authored by fautore
parent b8eb856228
commit ea4fa1ff06
5 changed files with 405 additions and 49 deletions
+79 -21
View File
@@ -16,7 +16,7 @@ This plugin aims to be something like vim-fugitive but for driving the jj-vcs CL
- Terminal-based output display for jj commands
- Support jj subcommands including your aliases through the cmdline.
- First class citizens with ui integration
- `describe` - Set change descriptions
- `describe` - Set change descriptions with a Git-style commit message editor
- `status` / `st` - Show repository status
- `log` - Display log history with configurable options
- `diff` - Show changes
@@ -91,11 +91,66 @@ The plugin provides a `:J` command that accepts jj subcommands:
snacks = {
}
},
-- Choose the editor mode for describe command
-- "buffer" - Opens a Git-style commit message buffer with syntax highlighting (default)
-- "input" - Uses a simple vim.ui.input prompt
describe_editor = "buffer",
-- Customize syntax highlighting colors for the describe buffer
highlights = {
added = { fg = "#3fb950", ctermfg = "Green" }, -- Added files
modified = { fg = "#56d4dd", ctermfg = "Cyan" }, -- Modified files
deleted = { fg = "#f85149", ctermfg = "Red" }, -- Deleted files
renamed = { fg = "#d29922", ctermfg = "Yellow" }, -- Renamed files
}
}
```
### Describe Editor Modes
The `describe_editor` option lets you choose how you want to write commit descriptions:
- **`"buffer"`** (default) - Opens a full buffer editor similar to Git's commit message editor
- Shows file changes with syntax highlighting
- Multi-line editing with proper formatting
- Close with `q` or `<Esc>`, save with `:w` or `:wq`
- **`"input"`** - Simple single-line input prompt
- Quick and minimal
- Good for short, single-line descriptions
- Uses `vim.ui.input()` which can be customized by UI plugins like dressing.nvim
Example:
```lua
require("jj").setup({
describe_editor = "input", -- Use simple input mode
})
```
### Highlight Customization
The `highlights` option allows you to customize the colors used in the describe buffer's file status display. Each highlight accepts standard Neovim highlight attributes:
- `fg` - Foreground color (hex or color name)
- `bg` - Background color
- `ctermfg` - Terminal foreground color
- `ctermbg` - Terminal background color
- `bold`, `italic`, `underline` - Text styles
Example with custom colors:
```lua
require("jj").setup({
highlights = {
modified = { fg = "#89ddff", bold = true },
added = { fg = "#c3e88d", ctermfg = "LightGreen" },
}
})
```
## Example config
```lua
@@ -105,35 +160,38 @@ The plugin provides a `:J` command that accepts jj subcommands:
"folke/snacks.nvim", -- Optional only if you use picker's
},
config = function()
require("jj").setup({})
local cmd = require "jj.cmd"
vim.keymap.set("n", "<leader>jd", cmd.describe, { desc = "JJ describe" })
vim.keymap.set("n", "<leader>jl", cmd.log, { desc = "JJ log" })
vim.keymap.set("n", "<leader>je", cmd.edit, { desc = "JJ edit" })
vim.keymap.set("n", "<leader>jn", cmd.new, { desc = "JJ new" })
vim.keymap.set("n", "<leader>js", cmd.status, { desc = "JJ status" })
vim.keymap.set("n", "<leader>dj", cmd.diff, { desc = "JJ diff" })
vim.keymap.set("n", "<leader>sj", cmd.squash, { desc = "JJ squash" })
require("jj").setup({
highlights = {
-- Customize colors if desired
modified = { fg = "#89ddff" },
}
})
-- Use the exposed functions directly from the main module
local jj = require("jj")
vim.keymap.set("n", "<leader>jd", jj.describe, { desc = "JJ describe" })
vim.keymap.set("n", "<leader>jl", jj.log, { desc = "JJ log" })
vim.keymap.set("n", "<leader>je", jj.edit, { desc = "JJ edit" })
vim.keymap.set("n", "<leader>jn", jj.new, { desc = "JJ new" })
vim.keymap.set("n", "<leader>js", jj.status, { desc = "JJ status" })
vim.keymap.set("n", "<leader>dj", jj.diff, { desc = "JJ diff" })
vim.keymap.set("n", "<leader>sj", jj.squash, { desc = "JJ squash" })
-- Pickers
vim.keymap.set("n", "<leader>gj", function()
require("jj.picker").status()
end, { desc = "JJ Picker status" })
vim.keymap.set("n", "<leader>gl", function()
require("jj.picker").file_history()
end, { desc = "JJ Picker file history" })
local picker = require("jj.picker")
vim.keymap.set("n", "<leader>gj", picker.status, { desc = "JJ Picker status" })
vim.keymap.set("n", "<leader>gl", picker.file_history, { desc = "JJ Picker file history" })
-- Some functions like `describe` or `log` can take parameters
vim.keymap.set("n", "<leader>jl", function()
cmd.log {
vim.keymap.set("n", "<leader>jL", function()
jj.log {
revisions = "all()",
}
end, { desc = "JJ log" })
end, { desc = "JJ log all" })
-- This is an alias i use for moving bookmarks its so good
vim.keymap.set("n", "<leader>jt", function()
local cmd = require("jj.cmd")
cmd.j "tug"
cmd.log {}
end, { desc = "JJ tug" })
+77 -25
View File
@@ -3,6 +3,11 @@ local M = {}
local utils = require("jj.utils")
-- Config for cmd module
M.config = {
describe_editor = "buffer", -- "buffer" or "input"
}
local state = {
-- The current terminal buffer for jj commands
--- @type integer|nil
@@ -587,11 +592,9 @@ local function execute_describe(description)
return
end
local cmd = string.format("jj describe -m '%s'", description)
local _, success = utils.execute_command(cmd, "Failed to describe")
if not success then
return
else
-- Use --stdin to properly handle multi-line descriptions and special characters
local _, success = utils.execute_command("jj describe --stdin", "Failed to describe", description)
if success then
utils.notify("Description set.", vim.log.levels.INFO)
end
end
@@ -613,35 +616,84 @@ function M.describe(description, opts)
end
-- Check if a description was provided otherwise require for input
if not description then
local merged_opts = vim.tbl_deep_extend("force", default_describe_opts, opts or {})
if merged_opts.with_status then
-- Show the status in a terminal buffer
M.status()
end
vim.ui.input({
prompt = "Description: ",
default = "",
}, function(input)
-- If the user inputed something, execute the describe command
if input then
execute_describe(input)
if description then
-- Description provided directly
execute_describe(description)
else
-- Use buffer editor mode
if M.config.describe_editor == "buffer" then
-- Build initial lines
local status_files = utils.get_status_files()
local text = { "JJ: This commit contains the following changes:" }
for _, item in ipairs(status_files) do
table.insert(text, string.format("JJ: %s %s", item.status, item.file))
end
-- Close the current terminal when finished
close_terminal_buffer()
end)
table.insert(text, "JJ:") -- blank line
table.insert(text, 'JJ: Lines starting with "JJ:" (like this one) will be ignored when finalizing')
table.insert(text, "") -- Empty line to separate from user input
table.insert(text, "") -- Another empty line where user can start typing
utils.open_ephemeral_buffer(text, function(buf_lines)
local user_lines = {}
for _, line in ipairs(buf_lines) do
if not line:match("^JJ:") then
table.insert(user_lines, line)
end
end
-- Join lines and trim leading/trailing whitespace
local trimmed_description = table.concat(user_lines, "\n"):gsub("^%s+", ""):gsub("%s+$", "")
execute_describe(trimmed_description)
local merged_opts = vim.tbl_deep_extend("force", default_describe_opts, opts or {})
if merged_opts.with_status then
M.status()
end
end)
else
local merged_opts = vim.tbl_deep_extend("force", default_describe_opts, opts or {})
if merged_opts.with_status then
-- Show the status in a terminal buffer
M.status()
end
vim.ui.input({
prompt = "Description: ",
default = "",
}, function(input)
-- If the user inputed something, execute the describe command
if input then
execute_describe(input)
end
-- Close the current terminal when finished
close_terminal_buffer()
end)
end
end
end
--- Jujutsu status
function M.status()
--- Jujutsu status.
--
-- it executes `jj st` and either:
-- 1. Shows the output in a notification (if `opts.notify` is true), or
-- 2. Displays it in the buffer by default.
--
-- @param opts? table Optional settings:
-- @field notify boolean If true, show the status in a notification instead of buffer.
function M.status(opts)
if not utils.ensure_jj() then
return
end
local cmd = "jj st"
run(cmd)
if opts and opts.notify then
local output = utils.execute_command(cmd, "Failed to get status")
if output then
utils.notify(output, vim.log.levels.INFO)
end
else
-- Default behavior: show in buffer
run(cmd)
end
end
--- @class jj.cmd.new_opts
+17 -1
View File
@@ -1,6 +1,7 @@
local M = {}
local cmd = require("jj.cmd")
local picker = require("jj.picker")
local utils = require("jj.utils")
--- Jujutsu plugin configuration
--- @class jj.Config
@@ -10,6 +11,15 @@ M.config = {
picker = {
snacks = {},
},
--- @type jj.utils.highlights Highlight configuration for describe buffer
highlights = {
added = { fg = "#3fb950", ctermfg = "Green" },
modified = { fg = "#56d4dd", ctermfg = "Cyan" },
deleted = { fg = "#f85149", ctermfg = "Red" },
renamed = { fg = "#d29922", ctermfg = "Yellow" },
},
--- @type string Editor mode for describe command: "buffer" (Git-style editor) or "input" (simple input prompt)
describe_editor = "buffer",
}
--- Setup the plugin
@@ -17,7 +27,13 @@ M.config = {
function M.setup(opts)
M.config = vim.tbl_deep_extend("force", M.config, opts or {})
picker.setup(opts.picker)
picker.setup(opts and opts.picker or {})
utils.setup({ highlights = M.config.highlights })
-- Pass describe_editor config to cmd module
if opts and opts.describe_editor then
cmd.config.describe_editor = opts.describe_editor
end
cmd.register_command()
end
View File
+232 -2
View File
@@ -1,9 +1,56 @@
--- @class jj.utils
--- @field highlights jj.utils.highlights Highlight configuration
---@class jj.utils.highlights
---@field added table Highlight settings for added lines
---@field modified table Highlight settings for modified lines
---@field deleted table Highlight settings for deleted lines
---@field renamed table Highlight settings for renamed lines
local M = {
executable_cache = {},
dependency_cache = {},
highlights_initialized = false,
highlights = {
added = { fg = "#3fb950", ctermfg = "Green" },
modified = { fg = "#56d4dd", ctermfg = "Cyan" },
deleted = { fg = "#f85149", ctermfg = "Red" },
renamed = { fg = "#d29922", ctermfg = "Yellow" },
},
}
-- Initialize highlight groups once
local function init_highlights()
if M.highlights_initialized then
return
end
vim.api.nvim_set_hl(0, "JJComment", { link = "Comment" })
vim.api.nvim_set_hl(0, "JJAdded", M.highlights.added)
vim.api.nvim_set_hl(0, "JJModified", M.highlights.modified)
vim.api.nvim_set_hl(0, "JJDeleted", M.highlights.deleted)
vim.api.nvim_set_hl(0, "JJRenamed", M.highlights.renamed)
M.highlights_initialized = true
end
--- Setup function to configure highlights and other options
---@param opts? jj.utils Configuration options
function M.setup(opts)
opts = opts or {}
-- Merge user highlights with defaults
if opts.highlights then
M.highlights = vim.tbl_deep_extend("force", M.highlights, opts.highlights)
end
-- Reset highlights flag to force re-initialization with new highlights
if M.highlights_initialized then
M.highlights_initialized = false
init_highlights()
end
end
--- Cache for executable checks to avoid repeated system calls
--- Check if an executable exists in PATH
@@ -54,10 +101,16 @@ end
--- Execute a system command and return output with error handling
--- @param cmd string The command to execute
--- @param error_prefix string|nil Optional error message prefix
--- @param input string|nil Optional input to pass to stdin
--- @return string|nil output The command output, or nil if failed
--- @return boolean success Whether the command succeeded
function M.execute_command(cmd, error_prefix)
local output = vim.fn.system(cmd)
function M.execute_command(cmd, error_prefix, input)
local output
if input then
output = vim.fn.system(cmd, input)
else
output = vim.fn.system(cmd)
end
local success = vim.v.shell_error == 0
if not success then
@@ -99,6 +152,51 @@ function M.get_jj_root()
return nil
end
--- Get a list of files with their status in the current jj repository.
--- @return table[] A list of tables with {status = string, file = string}
function M.get_status_files()
if not M.ensure_jj() then
return {}
end
local result, success = M.execute_command("jj status", "Error getting status")
if not success or not result then
return {}
end
local files = {}
-- Parse jj status output: "M filename", "A filename", "D filename", "R old => new"
for line in result:gmatch("[^\r\n]+") do
local status, file = line:match("^([MADR])%s+(.+)$")
if status and file then
table.insert(files, { status = status, file = file })
end
end
return files
end
--- Get a list of files modified in the current jj repository.
--- @return string[] A list of modified file paths
function M.get_modified_files()
if not M.ensure_jj() then
return {}
end
local result, success = M.execute_command("jj diff --name-only", "Error getting diff")
if not success or not result then
return {}
end
local files = {}
-- Split the result into lines and add each file to the table
for file in result:gmatch("[^\r\n]+") do
table.insert(files, file)
end
return files
end
---- Notify function to display messages with a title
--- @param message string The message to display
--- @param level? number The log level (default: INFO)
@@ -107,4 +205,136 @@ function M.notify(message, level)
vim.notify(message, level, { title = "JJ", timeout = 3000 })
end
---@param initial_text string[] Lines to initialize the buffer with
---@param on_done fun(buf: string[])? Optional callback called with user text on buffer write
function M.open_ephemeral_buffer(initial_text, on_done)
-- Initialize highlight groups once
init_highlights()
-- Create a horizontal split at the bottom, half the screen height
local height = math.floor(vim.o.lines / 2)
vim.cmd(string.format("botright %dsplit", height))
-- Create a new unlisted, scratch buffer
local buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_name(buf, "jujutsu:///DESCRIBE_EDITMSG")
vim.api.nvim_buf_set_lines(buf, 0, -1, false, initial_text)
vim.api.nvim_win_set_buf(0, buf)
-- Configure buffer options
vim.bo[buf].buftype = "acwrite" -- Allow custom write handling
vim.bo[buf].bufhidden = "wipe" -- Automatically wipe buffer when hidden
vim.bo[buf].swapfile = false -- Disable swapfile
vim.bo[buf].modifiable = true -- Allow editing
-- Create a namespace for our highlights
local ns_id = vim.api.nvim_create_namespace("jj_describe_highlights")
-- Function to apply highlights to the buffer
local function apply_highlights()
-- Clear existing highlights
vim.api.nvim_buf_clear_namespace(buf, ns_id, 0, -1)
-- Get all lines
local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
for i, line in ipairs(lines) do
local line_idx = i - 1 -- 0-indexed
-- First, check if line starts with JJ: and highlight it as comment
if line:match("^JJ:") then
-- Highlight the "JJ:" prefix as comment (first 3 characters)
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx, 0, {
end_col = 3,
hl_group = "JJComment",
})
-- Then check for status indicators and highlight the rest of the line
local status_pos = line:find("[MADR] ", 4) -- Find status after "JJ:"
if status_pos then
local status = line:sub(status_pos, status_pos) -- Get the status character
local hl_group = nil
if status == "A" then
hl_group = "JJAdded"
elseif status == "M" then
hl_group = "JJModified"
elseif status == "D" then
hl_group = "JJDeleted"
elseif status == "R" then
hl_group = "JJRenamed"
end
if hl_group then
-- Highlight from the status character to the end of the line
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx, status_pos - 1, {
end_col = #line,
hl_group = hl_group,
})
else
-- No status, keep rest as comment
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx, 3, {
end_col = #line,
hl_group = "JJComment",
})
end
else
-- No status indicator, highlight rest of line as comment
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx, 3, {
end_col = #line,
hl_group = "JJComment",
})
end
end
end
end
-- Apply highlights initially
apply_highlights()
-- Reapply highlights when text changes
vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI" }, {
buffer = buf,
callback = apply_highlights,
})
-- Position cursor at the end (after the last JJ: line) and enter insert mode
vim.schedule(function()
-- Get the number of lines in the buffer
local line_count = vim.api.nvim_buf_line_count(buf)
-- Move cursor to the last line, column 0
vim.api.nvim_win_set_cursor(0, { line_count, 0 })
-- Enter insert mode
vim.cmd("startinsert")
end)
-- Handle :w and :wq commands
vim.api.nvim_create_autocmd("BufWriteCmd", {
buffer = buf,
callback = function()
local buf_lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
if on_done then
on_done(buf_lines)
end
vim.bo[buf].modified = false
end,
})
-- Add keymap to close the buffer with 'q' in normal mode
vim.keymap.set(
"n",
"q",
"<cmd>close!<CR>",
{ buffer = buf, noremap = true, silent = true, desc = "Close describe buffer" }
)
-- Add keymap to close the buffer with '<Esc>' in normal mode
vim.keymap.set(
"n",
"<Esc>",
"<cmd>close!<CR>",
{ buffer = buf, noremap = true, silent = true, desc = "Close describe buffer" }
)
end
return M