mirror of
https://github.com/zoriya/jj.nvim.git
synced 2026-08-05 10:46:08 +00:00
refactor: restructure the whole plugin following a better separation of concers
This commit is contained in:
+167
-819
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,149 @@
|
||||
--- @class jj.core.parser
|
||||
local M = {}
|
||||
|
||||
--- Parse the default command from jj config
|
||||
--- @param cmd_output string The output from `jj config get ui.default-command`
|
||||
--- @return table|nil args Array of command arguments, or nil if parsing fails
|
||||
function M.parse_default_cmd(cmd_output)
|
||||
if not cmd_output or cmd_output == "" then
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Remove whitespace and parse TOML output
|
||||
local trimmed_cmd = vim.trim(cmd_output)
|
||||
|
||||
-- Try to parse as TOML array: ["item1", "item2", ...]
|
||||
-- Pattern "%[(.*)%]" captures everything between square brackets
|
||||
local array_items = trimmed_cmd:match("%[(.*)%]")
|
||||
if array_items then
|
||||
local args = {}
|
||||
-- Pattern '"([^"]+)"' captures content between double quotes (non-greedy)
|
||||
for item in array_items:gmatch('"([^"]+)"') do
|
||||
table.insert(args, item)
|
||||
end
|
||||
return #args > 0 and args or nil
|
||||
else
|
||||
-- Single string value, remove surrounding quotes if present
|
||||
-- Pattern '^"?(.-)"?$' optionally matches quotes at start/end, captures content
|
||||
local single_value = trimmed_cmd:match('^"?(.-)"?$')
|
||||
return single_value and { single_value } or nil
|
||||
end
|
||||
end
|
||||
|
||||
--- Get a list of files with their status in the current jj repository.
|
||||
--- @type string status_output The output from `jj status` command
|
||||
--- @return table[] A list of tables with {status = string, file = string}
|
||||
function M.get_status_files(status_output)
|
||||
if not status_output then
|
||||
return {}
|
||||
end
|
||||
|
||||
local files = {}
|
||||
-- Parse jj status output: "M filename", "A filename", "D filename", "R old => new"
|
||||
for line in status_output:gmatch("[^\r\n]+") do
|
||||
local status, file = line:match("^([MADRC])%s+(.+)$")
|
||||
if status and file then
|
||||
table.insert(files, { status = status, file = file })
|
||||
end
|
||||
end
|
||||
|
||||
return files
|
||||
end
|
||||
|
||||
--- Parse the current line in the jj status buffer to extract file information.
|
||||
--- Handles renamed files and regular status lines.
|
||||
--- @return table|nil A table with {old_path = string, new_path = string, is_rename = boolean}, or nil if parsing fails
|
||||
function M.parse_file_info_from_status_line(line)
|
||||
-- Handle renamed files: "R path/{old_name => new_name}" or "R old_path => new_path"
|
||||
local rename_pattern_curly = "^R (.*)/{(.*) => ([^}]+)}"
|
||||
local dir_path, old_name, new_name = line:match(rename_pattern_curly)
|
||||
|
||||
if dir_path and old_name and new_name then
|
||||
return {
|
||||
old_path = dir_path .. "/" .. old_name,
|
||||
new_path = dir_path .. "/" .. new_name,
|
||||
is_rename = true,
|
||||
}
|
||||
else
|
||||
-- Try simple rename pattern: "R old_path => new_path"
|
||||
local rename_pattern_simple = "^R (.*) => (.+)$"
|
||||
local old_path, new_path = line:match(rename_pattern_simple)
|
||||
if old_path and new_path then
|
||||
return {
|
||||
old_path = old_path,
|
||||
new_path = new_path,
|
||||
is_rename = true,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
-- Not a rename, try regular status patterns
|
||||
local filepath
|
||||
-- Handle renamed files: "R path/{old_name => new_name}" or "R old_path => new_path"
|
||||
local rename_pattern_curly_new = "^R (.*)/{.* => ([^}]+)}"
|
||||
local dir_path_new, renamed_file = line:match(rename_pattern_curly_new)
|
||||
|
||||
if dir_path_new and renamed_file then
|
||||
filepath = dir_path_new .. "/" .. renamed_file
|
||||
else
|
||||
-- Try simple rename pattern: "R old_path => new_path"
|
||||
local rename_pattern_simple_new = "^R .* => (.+)$"
|
||||
filepath = line:match(rename_pattern_simple_new)
|
||||
end
|
||||
|
||||
if not filepath then
|
||||
-- jj status format: "M filename" or "A filename"
|
||||
-- Match lines that start with status letter followed by space and filename
|
||||
local pattern = "^[MAD?!] (.+)$"
|
||||
filepath = line:match(pattern)
|
||||
end
|
||||
|
||||
if filepath then
|
||||
return {
|
||||
old_path = filepath,
|
||||
new_path = filepath,
|
||||
is_rename = false,
|
||||
}
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
--- Extract revision ID from a jujutsu log line
|
||||
--- @param line string The log line to parse
|
||||
--- @return string|nil The revision ID if found, nil otherwise
|
||||
function M.get_rev_from_log_line(line)
|
||||
-- Define jujutsu symbols with their UTF-8 byte sequences
|
||||
local jj_symbols = {
|
||||
diamond = "\226\151\134", -- ◆ U+25C6
|
||||
circle = "\226\151\139", -- ○ U+25CB
|
||||
conflict = "\195\151", -- × U+00D7
|
||||
}
|
||||
|
||||
local revset
|
||||
|
||||
-- Try each symbol pattern
|
||||
for _, symbol in pairs(jj_symbols) do
|
||||
-- Pattern: Lines starting with symbol
|
||||
revset = line:match("^%s*" .. symbol .. "%s+(%w+)")
|
||||
if revset then
|
||||
return revset
|
||||
end
|
||||
|
||||
-- Pattern: Lines with │ followed by symbol (this are the branches)
|
||||
revset = line:match("^│%s*" .. symbol .. "%s+(%w+)")
|
||||
if revset then
|
||||
return revset
|
||||
end
|
||||
end
|
||||
|
||||
-- Pattern for simple ASCII symbols
|
||||
revset = line:match("^%s*[@]%s+(%w+)")
|
||||
if revset then
|
||||
return revset
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,33 @@
|
||||
--- @class jj.core.runner
|
||||
local M = {}
|
||||
|
||||
--- 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
|
||||
--- @param silent boolean|nil Optional to silent the notification
|
||||
--- @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, input, silent)
|
||||
local output = vim.fn.system(cmd, input)
|
||||
local success = vim.v.shell_error == 0
|
||||
|
||||
if not success then
|
||||
local error_message
|
||||
if error_prefix then
|
||||
error_message = string.format("%s: %s", error_prefix, output)
|
||||
else
|
||||
error_message = output
|
||||
end
|
||||
if not silent then
|
||||
vim.notify(error_message, vim.log.levels.ERROR, { title = "JJ" })
|
||||
end
|
||||
|
||||
return nil, false
|
||||
end
|
||||
|
||||
return output, success
|
||||
end
|
||||
|
||||
return M
|
||||
|
||||
+31
-3
@@ -1,6 +1,8 @@
|
||||
local M = {}
|
||||
local cmd = require("jj.cmd")
|
||||
local picker = require("jj.picker")
|
||||
local editor = require("jj.ui.editor")
|
||||
local diff = require("jj.diff")
|
||||
local utils = require("jj.utils")
|
||||
|
||||
--- Jujutsu plugin configuration
|
||||
@@ -11,14 +13,14 @@ M.config = {
|
||||
picker = {
|
||||
snacks = {},
|
||||
},
|
||||
--- @type jj.utils.highlights Highlight configuration for describe buffer
|
||||
--- @type jj.ui.editor.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)
|
||||
--- @type "buffer"|"input" Editor mode for describe command: "buffer" (Git-style editor) or "input" (simple input prompt)
|
||||
describe_editor = "buffer",
|
||||
}
|
||||
|
||||
@@ -27,8 +29,10 @@ M.config = {
|
||||
function M.setup(opts)
|
||||
M.config = vim.tbl_deep_extend("force", M.config, opts or {})
|
||||
|
||||
-- Setup for sub-modules
|
||||
picker.setup(opts and opts.picker or {})
|
||||
utils.setup({ highlights = M.config.highlights })
|
||||
editor.setup({ highlights = M.config.highlights })
|
||||
utils.setup(opts) -- Keep for future-proofing, even if it's a no-op now
|
||||
|
||||
-- Pass describe_editor config to cmd module
|
||||
if opts and opts.describe_editor then
|
||||
@@ -36,6 +40,30 @@ function M.setup(opts)
|
||||
end
|
||||
|
||||
cmd.register_command()
|
||||
|
||||
-- Expose public API functions on the top-level module
|
||||
M.status = cmd.status
|
||||
M.describe = cmd.describe
|
||||
M.log = cmd.log
|
||||
M.new = cmd.new
|
||||
M.edit = cmd.edit
|
||||
M.squash = cmd.squash
|
||||
M.rebase = cmd.rebase
|
||||
M.undo = cmd.undo
|
||||
M.redo = cmd.redo
|
||||
M.bookmark_create = cmd.bookmark_create
|
||||
M.bookmark_delete = cmd.bookmark_delete
|
||||
M.j = cmd.j
|
||||
|
||||
M.picker = {
|
||||
status = picker.status,
|
||||
file_history = picker.file_history,
|
||||
}
|
||||
|
||||
M.diff = {
|
||||
vsplit = diff.open_vdiff,
|
||||
hsplit = diff.open_hdiff,
|
||||
}
|
||||
end
|
||||
|
||||
return M
|
||||
|
||||
+3
-2
@@ -1,4 +1,5 @@
|
||||
local utils = require("jj.utils")
|
||||
local runner = require("jj.core.runner")
|
||||
|
||||
--- @class jj.picker
|
||||
|
||||
@@ -35,7 +36,7 @@ end
|
||||
--- Gets the files in the current jj repository
|
||||
--- @return jj.picker.file[]|nil A list of files with their changes or nil if not in a jj repo
|
||||
local function get_files()
|
||||
local diff_ouptut, ok = utils.execute_command("jj diff --summary --quiet")
|
||||
local diff_ouptut, ok = runner.execute_command("jj diff --summary --quiet")
|
||||
if not ok then
|
||||
return
|
||||
end
|
||||
@@ -88,7 +89,7 @@ end
|
||||
local function log_history(file_path)
|
||||
local format =
|
||||
"jj log %s -r 'all()' -T builtin_log_oneline --config 'template-aliases.\"format_timestamp(timestamp)\"=timestamp'"
|
||||
local output, ok = utils.execute_command(string.format(format, file_path))
|
||||
local output, ok = runner.execute_command(string.format(format, file_path))
|
||||
if not ok then
|
||||
return
|
||||
end
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
local utils = require("jj.utils")
|
||||
local runner = require("jj.core.runner")
|
||||
|
||||
--- @class jj.picker.snacks
|
||||
local M = {}
|
||||
@@ -142,7 +143,7 @@ function M.file_log_history(opts, log_lines)
|
||||
return
|
||||
end
|
||||
|
||||
local _, ok = utils.execute_command(
|
||||
local _, ok = runner.execute_command(
|
||||
string.format("jj edit %s --ignore-immutable", item.rev),
|
||||
string.format("could not edit revision '%s'", item.rev)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
--- @class jj.ui.editor
|
||||
local M = {}
|
||||
|
||||
--- @class jj.ui.editor.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
|
||||
|
||||
M.highlights = {
|
||||
added = { fg = "#3fb950", ctermfg = "Green" },
|
||||
modified = { fg = "#56d4dd", ctermfg = "Cyan" },
|
||||
deleted = { fg = "#f85149", ctermfg = "Red" },
|
||||
renamed = { fg = "#d29922", ctermfg = "Yellow" },
|
||||
}
|
||||
M.highlights_initialized = false
|
||||
|
||||
-- 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? { highlights: jj.ui.editor.highlights } 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
|
||||
|
||||
---@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_editor(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("%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("[MADRC] ", 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" or status == "C" 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,
|
||||
})
|
||||
|
||||
-- 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
|
||||
@@ -0,0 +1,620 @@
|
||||
--- @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" },
|
||||
"<ESC>",
|
||||
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" },
|
||||
"<ESC>",
|
||||
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" }, "<CR>", 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" }, "<CR>", function()
|
||||
handle_log_enter(false)
|
||||
end, { desc = "Edit change under cursor" })
|
||||
register_command_keymap({ "n" }, "<S-CR>", 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" }, "<C-n>", function()
|
||||
handle_log_new("after")
|
||||
end, { desc = "New change after the change under cursor" })
|
||||
register_command_keymap({ "n" }, "<S-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
|
||||
+10
-333
@@ -1,55 +1,13 @@
|
||||
local runner = require("jj.core.runner")
|
||||
|
||||
--- @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
|
||||
-- No-op setup, but keep it for API consistency in case we need it later.
|
||||
function M.setup(_) end
|
||||
|
||||
--- Cache for executable checks to avoid repeated system calls
|
||||
|
||||
@@ -98,39 +56,6 @@ function M.ensure_jj()
|
||||
return true
|
||||
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
|
||||
--- @param silent boolean|nil Optional to silent the notification
|
||||
--- @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, input, silent)
|
||||
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
|
||||
local error_message
|
||||
if error_prefix then
|
||||
error_message = string.format("%s: %s", error_prefix, output)
|
||||
else
|
||||
error_message = output
|
||||
end
|
||||
if not silent then
|
||||
M.notify(error_message, vim.log.levels.ERROR)
|
||||
end
|
||||
|
||||
return nil, false
|
||||
end
|
||||
|
||||
return output, success
|
||||
end
|
||||
|
||||
--- Check if we're in a jj repository
|
||||
--- @return boolean True if in jj repo, false otherwise
|
||||
function M.is_jj_repo()
|
||||
@@ -138,7 +63,8 @@ function M.is_jj_repo()
|
||||
return false
|
||||
end
|
||||
|
||||
local _, success = M.execute_command("jj status")
|
||||
-- We require the runner here to avoid a circular dependency loop at startup
|
||||
local _, success = runner.execute_command("jj status")
|
||||
return success
|
||||
end
|
||||
|
||||
@@ -149,42 +75,14 @@ function M.get_jj_root()
|
||||
return nil
|
||||
end
|
||||
|
||||
local output, success = M.execute_command("jj root")
|
||||
-- We require the runner here to avoid a circular dependency loop at startup
|
||||
local output, success = runner.execute_command("jj root")
|
||||
if success and output then
|
||||
return vim.trim(output)
|
||||
end
|
||||
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(revset)
|
||||
if not M.ensure_jj() then
|
||||
return {}
|
||||
end
|
||||
|
||||
if revset == nil then
|
||||
revset = "@"
|
||||
end
|
||||
|
||||
cmd = "jj log -r " .. revset .. " --no-graph -T 'self.diff().summary()'"
|
||||
local result, success = M.execute_command(cmd, "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("^([MADRC])%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()
|
||||
@@ -192,7 +90,8 @@ function M.get_modified_files()
|
||||
return {}
|
||||
end
|
||||
|
||||
local result, success = M.execute_command("jj diff --name-only", "Error getting diff")
|
||||
-- We require the runner here to avoid a circular dependency loop at startup
|
||||
local result, success = runner.execute_command("jj diff --name-only", "Error getting diff")
|
||||
if not success or not result then
|
||||
return {}
|
||||
end
|
||||
@@ -214,226 +113,4 @@ 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("%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("[MADRC] ", 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" or status == "C" 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()
|
||||
local line_count = vim.api.nvim_buf_line_count(buf)
|
||||
local target_line_idx = line_count - 1 -- 0-indexed line number for API calls
|
||||
local last_line_content = vim.api.nvim_buf_get_lines(buf, target_line_idx, line_count, false)[1]
|
||||
local col_index = #last_line_content
|
||||
vim.api.nvim_win_set_cursor(0, { 1, 0 })
|
||||
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
|
||||
|
||||
--- Parse the current line in the jj status buffer to extract file information.
|
||||
--- Handles renamed files and regular status lines.
|
||||
--- @return table|nil A table with {old_path = string, new_path = string, is_rename = boolean}, or nil if parsing fails
|
||||
function M.parse_file_info_from_status_line()
|
||||
local line = vim.api.nvim_get_current_line()
|
||||
|
||||
-- Handle renamed files: "R path/{old_name => new_name}" or "R old_path => new_path"
|
||||
local rename_pattern_curly = "^R (.*)/{(.*) => ([^}]+)}"
|
||||
local dir_path, old_name, new_name = line:match(rename_pattern_curly)
|
||||
|
||||
if dir_path and old_name and new_name then
|
||||
return {
|
||||
old_path = dir_path .. "/" .. old_name,
|
||||
new_path = dir_path .. "/" .. new_name,
|
||||
is_rename = true,
|
||||
}
|
||||
else
|
||||
-- Try simple rename pattern: "R old_path => new_path"
|
||||
local rename_pattern_simple = "^R (.*) => (.+)$"
|
||||
local old_path, new_path = line:match(rename_pattern_simple)
|
||||
if old_path and new_path then
|
||||
return {
|
||||
old_path = old_path,
|
||||
new_path = new_path,
|
||||
is_rename = true,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
-- Not a rename, try regular status patterns
|
||||
local filepath
|
||||
-- Handle renamed files: "R path/{old_name => new_name}" or "R old_path => new_path"
|
||||
local rename_pattern_curly_new = "^R (.*)/{.* => ([^}]+)}"
|
||||
local dir_path_new, renamed_file = line:match(rename_pattern_curly_new)
|
||||
|
||||
if dir_path_new and renamed_file then
|
||||
filepath = dir_path_new .. "/" .. renamed_file
|
||||
else
|
||||
-- Try simple rename pattern: "R old_path => new_path"
|
||||
local rename_pattern_simple_new = "^R .* => (.+)$"
|
||||
filepath = line:match(rename_pattern_simple_new)
|
||||
end
|
||||
|
||||
if not filepath then
|
||||
-- jj status format: "M filename" or "A filename"
|
||||
-- Match lines that start with status letter followed by space and filename
|
||||
local pattern = "^[MAD?!] (.+)$"
|
||||
filepath = line:match(pattern)
|
||||
end
|
||||
|
||||
if filepath then
|
||||
return {
|
||||
old_path = filepath,
|
||||
new_path = filepath,
|
||||
is_rename = false,
|
||||
}
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
--- Parse the default command from jj config
|
||||
--- @return table|nil args Array of command arguments, or nil if parsing fails
|
||||
function M.parse_default_cmd()
|
||||
local default_cmd, success =
|
||||
M.execute_command("jj config get ui.default-command", "Error getting user's default command", nil, true)
|
||||
if not success or not default_cmd or default_cmd == "" then
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Remove whitespace and parse TOML output
|
||||
default_cmd = vim.trim(default_cmd)
|
||||
|
||||
-- Try to parse as TOML array: ["item1", "item2", ...]
|
||||
-- Pattern "%[(.*)%]" captures everything between square brackets
|
||||
local array_items = default_cmd:match("%[(.*)%]")
|
||||
if array_items then
|
||||
local args = {}
|
||||
-- Pattern '"([^"]+)"' captures content between double quotes (non-greedy)
|
||||
for item in array_items:gmatch('"([^"]+)"') do
|
||||
table.insert(args, item)
|
||||
end
|
||||
return #args > 0 and args or nil
|
||||
else
|
||||
-- Single string value, remove surrounding quotes if present
|
||||
-- Pattern '^"?(.-)"?$' optionally matches quotes at start/end, captures content
|
||||
local single_value = default_cmd:match('^"?(.-)"?$')
|
||||
return single_value and { single_value } or nil
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
|
||||
Reference in New Issue
Block a user