From ea4fa1ff06510ba9aba32f43a47c5045e8dbbb69 Mon Sep 17 00:00:00 2001 From: Nicolas GB Date: Thu, 2 Oct 2025 09:43:04 +0200 Subject: [PATCH] Change the :J describe behaviour to open an editable buffer instead of an input box (#6) Co-authored-by: fautore --- README.md | 100 +++++++++++++++----- lua/jj/cmd.lua | 102 ++++++++++++++++----- lua/jj/init.lua | 18 +++- lua/jj/test.lua | 0 lua/jj/utils.lua | 234 ++++++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 405 insertions(+), 49 deletions(-) create mode 100644 lua/jj/test.lua diff --git a/README.md b/README.md index b9a1d72..72836ec 100644 --- a/README.md +++ b/README.md @@ -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 ``, 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", "jd", cmd.describe, { desc = "JJ describe" }) - vim.keymap.set("n", "jl", cmd.log, { desc = "JJ log" }) - vim.keymap.set("n", "je", cmd.edit, { desc = "JJ edit" }) - vim.keymap.set("n", "jn", cmd.new, { desc = "JJ new" }) - vim.keymap.set("n", "js", cmd.status, { desc = "JJ status" }) - vim.keymap.set("n", "dj", cmd.diff, { desc = "JJ diff" }) - vim.keymap.set("n", "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", "jd", jj.describe, { desc = "JJ describe" }) + vim.keymap.set("n", "jl", jj.log, { desc = "JJ log" }) + vim.keymap.set("n", "je", jj.edit, { desc = "JJ edit" }) + vim.keymap.set("n", "jn", jj.new, { desc = "JJ new" }) + vim.keymap.set("n", "js", jj.status, { desc = "JJ status" }) + vim.keymap.set("n", "dj", jj.diff, { desc = "JJ diff" }) + vim.keymap.set("n", "sj", jj.squash, { desc = "JJ squash" }) -- Pickers - vim.keymap.set("n", "gj", function() - require("jj.picker").status() - end, { desc = "JJ Picker status" }) - vim.keymap.set("n", "gl", function() - require("jj.picker").file_history() - end, { desc = "JJ Picker file history" }) - - + local picker = require("jj.picker") + vim.keymap.set("n", "gj", picker.status, { desc = "JJ Picker status" }) + vim.keymap.set("n", "gl", picker.file_history, { desc = "JJ Picker file history" }) -- Some functions like `describe` or `log` can take parameters - vim.keymap.set("n", "jl", function() - cmd.log { + vim.keymap.set("n", "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", "jt", function() + local cmd = require("jj.cmd") cmd.j "tug" cmd.log {} end, { desc = "JJ tug" }) diff --git a/lua/jj/cmd.lua b/lua/jj/cmd.lua index e665215..2e3ee32 100644 --- a/lua/jj/cmd.lua +++ b/lua/jj/cmd.lua @@ -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 diff --git a/lua/jj/init.lua b/lua/jj/init.lua index e285a77..ec95cfd 100644 --- a/lua/jj/init.lua +++ b/lua/jj/init.lua @@ -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 diff --git a/lua/jj/test.lua b/lua/jj/test.lua new file mode 100644 index 0000000..e69de29 diff --git a/lua/jj/utils.lua b/lua/jj/utils.lua index 42d0c29..b03e694 100644 --- a/lua/jj/utils.lua +++ b/lua/jj/utils.lua @@ -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", + "close!", + { buffer = buf, noremap = true, silent = true, desc = "Close describe buffer" } + ) + + -- Add keymap to close the buffer with '' in normal mode + vim.keymap.set( + "n", + "", + "close!", + { buffer = buf, noremap = true, silent = true, desc = "Close describe buffer" } + ) +end + return M