commit 5df7f4f0e516bdc8abd5d2bcc54074fe507fefa7 Author: NicolasGB Date: Tue Jun 24 17:42:58 2025 +0200 feat: Initial commit with basic commands and first functionalities diff --git a/License b/License new file mode 100644 index 0000000..4224b08 --- /dev/null +++ b/License @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2025 Nicolas GB + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..9fe5fb5 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# jj.nvim + +⚠️ **VERY WORK IN PROGRESS - NOT READY FOR MASSIVE USE** ⚠️ + +A Neovim plugin for [Jujutsu (jj)](https://github.com/jj-vcs/jj) version control system. + +## About + +This plugin aims to be something like vim-fugitive but for piloting the jj-vcs CLI. The goal is to eventually provide features similar to git status, diffs, and pickers for managing Jujutsu repositories directly from Neovim. + +## Current Features + +- Basic jj command execution through `:J` command +- Terminal-based output display for jj commands +- Support for common jj subcommands: + - `describe` - Set change descriptions + - `status` / `st` - Show repository status + - `log` - Display log history with configurable options + - `diff` - Show changes + - `new` - Create a new change + - `edit` - Edit a change + - `squash` - Squash the current diff to it's parent + +## Installation + +Using [lazy.nvim](https://github.com/folke/lazy.nvim): + +```lua +{ + "nicolasgb/jj.nvim", + config = function() + require("jj").setup({}) + end, +} +``` + +## Usage + +The plugin provides a `:J` command that accepts jj subcommands: + +```vim +:J status +:J log +:J describe "Your change description" +:J new +``` + +## Requirements + +- Neovim >= 0.9.0 +- [Jujutsu](https://github.com/jj-vcs/jj) installed and available in PATH + +## Contributing + +This is an early-stage project. Contributions are welcome, but please be aware that the API and features are likely to change significantly. + +## License + +[MIT](License) diff --git a/lua/jj/cmd.lua b/lua/jj/cmd.lua new file mode 100644 index 0000000..dba3d49 --- /dev/null +++ b/lua/jj/cmd.lua @@ -0,0 +1,370 @@ +--- @class jj.cmd +local M = {} + +local utils = require("jj.utils") + +local state = { + -- The current terminal buffer for jj commands + --- @type integer|nil + terminal_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, +} + +--- Execute jj describe command with the given description +---@param description string The description text +local function execute_describe(description) + if not description or description == "" then + utils.notify("Description cannot be empty", vim.log.levels.ERROR) + 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 + utils.notify("Description set.", vim.log.levels.INFO) + end +end + +--- Jujutsu describe +---@param description string|nil Optional description text +function M.describe(description) + if not utils.ensure_jj() then + return + end + + -- Check if a description was provided otherwise require for input + if not description then + vim.ui.input({ + prompt = "Description: ", + default = "", + }, function(input) + -- If the user inputed something, execute the describe command + if input then + execute_describe(input) + end + end) + end +end + +--- Jujutsu status +function M.status() + if not utils.ensure_jj() then + return + end + + local cmd = "jj st" + M.show_output_in_terminal(cmd) +end + +--- Jujutsu new +function M.new() + if not utils.ensure_jj() then + return + end + + local cmd = "jj new" + utils.execute_command(cmd, "Failed to create new") + utils.notify("Command `new` was succesful.", vim.log.levels.INFO) +end + +-- Jujutsu edit +function M.edit() + if not utils.ensure_jj() then + return + end + M.log({}) + vim.ui.input({ + prompt = "Change to edit: ", + default = "", + }, function(input) + -- If the user inputed something, execute the describe command + if input then + local _, success = utils.execute_command(string.format("jj edit %s", input), "Error editing change") + if not success then + return + end + + -- Otherwise update the log window + M.log({}) + end + end) +end + +--- Jujutsu squash +function M.squash() + if not utils.ensure_jj() then + return + end + + local cmd = "jj squash" + utils.execute_command(cmd, "Failed to squash") + utils.notify("Command `squash` was succesful.", vim.log.levels.INFO) +end + +---@class jj.cmd.log_opts +---@field summary? boolean: Show a summary of the log +---@field reversed? boolean: Show the log in reverse order +---@field no_graph? boolean: Do not show the graph in the log output +---@field limit? uinteger : Limit the number of log entries shown, defaults to 20 if not provided +---@field revisions? string: Which revisions to show + +--- @type jj.cmd.log_opts +local default_log_opts = { + --- @type boolean + summary = false, + --- @type boolean + reversed = false, + --- @type boolean + no_graph = false, + --- @type uinteger + limit = 20, +} +--- Jujutsu log +---@param opts jj.cmd.log_opts Command options from nvim_create_user_command +function M.log(opts) + if not utils.ensure_jj() then + return + end + + local cmd = "jj log" + + -- Merge default options with provided ones + local merged_opts = vim.tbl_extend("force", default_log_opts, opts or {}) + + -- Add options to the command + for key, value in pairs(merged_opts) do + -- Replace _ with - for command line options + key = key:gsub("_", "-") + + -- Handle special cases such as limit + if key == "limit" and value then + cmd = string.format("%s --%s %d", cmd, key, value) + elseif key == "revisions" and value then + cmd = string.format("%s --%s %s", cmd, key, value) + elseif value then + -- Simply append the option + cmd = string.format("%s --%s", cmd, key) + end + end + + M.show_output_in_terminal(cmd) +end + +--- Jujutsu diff +function M.diff() + if not utils.ensure_jj() then + return + end + + local cmd = "jj diff" + M.show_output_in_terminal(cmd) +end + +--- Run a command and show it's output in a terminal buffer +---@param cmd string +function M.show_output_in_terminal(cmd) + if state.terminal_buf and state.chan then + -- If we already have a terminal buffer, just switch to it + vim.api.nvim_set_current_buf(state.terminal_buf) + -- Send ansi escape sequence to clear the terminal + vim.api.nvim_chan_send(state.chan, "\27[H\27[2J") + else + -- Split window and create buffer + vim.cmd("split") + -- Otherwise, create a new terminal buffer and store it in the state + local win = vim.api.nvim_get_current_win() + local buf = vim.api.nvim_create_buf(false, true) + + state.terminal_buf = buf + vim.api.nvim_win_set_buf(win, buf) + end + + -- If there was a previous channel with a terminal close it + if state.chan then + vim.fn.chanclose(state.chan) + end + + -- For sure it's set to a terminal buffer + --- @type integer + local buf = state.terminal_buf + local win = vim.api.nvim_get_current_win() + + -- Set buffer options + vim.bo[buf].bufhidden = "wipe" + + -- Create a terminal job that runs the command and exits + --- @type integer + local chan = vim.api.nvim_open_term(buf, {}) + if not chan or chan <= 0 then + vim.notify("Failed to create terminal channel", vim.log.levels.ERROR) + return + end + + -- Set it in the state to close it later + state.chan = chan + + 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", + }, + on_stdout = function(_, data) + if not vim.api.nvim_buf_is_valid(buf) then + return + end + -- Send output directly to the terminal + local ouptut = table.concat(data, "\n") + vim.api.nvim_chan_send(chan, ouptut) + end, + on_exit = function(_, _) + if vim.api.nvim_buf_is_valid(buf) then + -- Once the job exits, we can set the buffer to be non-modifiable + vim.bo[buf].modifiable = false + + -- Switch to normal mode after command completes + vim.schedule(function() + if vim.api.nvim_get_current_buf() == buf then + vim.cmd("stopinsert") + end + end) + end + end, + }) + + -- TODO: HANDLE ERRORS BETTER + if jid <= 0 then + vim.api.nvim_chan_send(chan, "Failed to start command: " .. cmd .. "\r\n") + else + -- Store the job ID in the state for later reference + state.job_id = jid + end + + -- Set keymaps to close and wipe buffer + local function close_and_wipe() + if vim.api.nvim_buf_is_valid(buf) then + vim.cmd("bwipeout! " .. buf) + else + vim.cmd("close") + end + end + + -- Avoid the user being able to go in insert mode for this buffer + vim.keymap.set("n", "i", function() end, { buffer = buf, noremap = true, silent = true }) + + -- Set keymaps for closing the terminal buffer + vim.keymap.set("n", "q", close_and_wipe, { buffer = buf, noremap = true, silent = true }) + vim.keymap.set("n", "", close_and_wipe, { buffer = buf, noremap = true, silent = true }) + + -- Start in normal mode + vim.cmd("stopinsert") + + --- Watch for buffer close events to clean up terminal buffer from the state + vim.api.nvim_create_autocmd({ "BufWipeout", "BufDelete" }, { + buffer = buf, + callback = function(args) + -- Clear the terminal buffer + if state.terminal_buf and args.buf == state.terminal_buf then + state.terminal_buf = nil + end + + --- Clear the channel + if state.chan then + vim.fn.chanclose(state.chan) + end + + -- Clear the job since the terminal is closed + if state.job_id then + vim.fn.jobstop(state.job_id) + state.job_id = nil + end + end, + }) +end + +--- Handle J command with subcommands and direct jj passthrough +---@param opts table Command options from nvim_create_user_command +function M.handle_j_command(opts) + if not utils.ensure_jj() then + return + end + + local args = opts.fargs + if #args == 0 then + -- Use the user's default command and do not try to parse anythng else + M.show_output_in_terminal("jj") + return + end + + local subcommand = args[1] + local remaining_args = vim.list_slice(args, 2) + + local cmd_args = table.concat(args, " ") + local cmd = string.format("jj %s", cmd_args) + + -- Handle known subcommands with custom logic + if subcommand == "describe" then + local description = table.concat(remaining_args, " ") + M.describe(description ~= "" and description or nil) + return + elseif subcommand == "edit" then + M.edit() + return + elseif subcommand == "new" then + local _, success = utils.execute_command(cmd, "Failed to edit change") + if not success then + return + end + + M.log({}) + end + + -- Run the command in the terminal + M.show_output_in_terminal(cmd) +end + +--- Register the J command +function M.register_command() + vim.api.nvim_create_user_command("J", M.handle_j_command, { + nargs = "*", + complete = function(arglead, _, _) + -- Basic completion for common jj subcommands + local subcommands = { + "log", + "status", + "st", + "diff", + "describe", + "new", + "squash", + "bookmark", + "edit", + "abandon", + "b", + "git", + } + + local matches = {} + for _, cmd in ipairs(subcommands) do + if cmd:match("^" .. vim.pesc(arglead)) then + table.insert(matches, cmd) + end + end + return matches + end, + desc = "Execute jj commands with subcommand support", + }) +end + +return M diff --git a/lua/jj/init.lua b/lua/jj/init.lua new file mode 100644 index 0000000..d7ec2e9 --- /dev/null +++ b/lua/jj/init.lua @@ -0,0 +1,23 @@ +local M = {} +local cmd = require("jj.cmd") + +M.picker_config = { + snacks = { + layout = "horizontal", + }, +} + +--- Jujutsu plugin configuration +--- @class jj.Config +M.config = { + -- Default configuration +} + +--- Setup the plugin +--- @param opts table: Options to configure the plugin +function M.setup(opts) + M.config = vim.tbl_deep_extend("force", M.config, opts or {}) + cmd.register_command() +end + +return M diff --git a/lua/jj/utils.lua b/lua/jj/utils.lua new file mode 100644 index 0000000..891fbeb --- /dev/null +++ b/lua/jj/utils.lua @@ -0,0 +1,86 @@ +--- @class jj.utils +local M = {} + +--- Cache for executable checks to avoid repeated system calls +local executable_cache = {} + +--- Check if an executable exists in PATH +--- @param name string The name of the executable to check +--- @return boolean True if executable exists, false otherwise +function M.has_executable(name) + if executable_cache[name] ~= nil then + return executable_cache[name] + end + + local exists = vim.fn.executable(name) == 1 + executable_cache[name] = exists + return exists +end + +--- Clear the executable cache (useful for testing or if PATH changes) +function M.clear_executable_cache() + executable_cache = {} +end + +--- Check if jj executable exists, show error if not +--- @return boolean True if jj exists, false otherwise +function M.ensure_jj() + if not M.has_executable("jj") then + M.notify("jj command not found", vim.log.levels.ERROR) + return false + end + return true +end + +--- Execute a system command and return output with error handling +--- @param cmd string The command to execute +--- @param error_prefix string Optional error message prefix +--- @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) + local success = vim.v.shell_error == 0 + + if not success and error_prefix then + local error_message = string.format("%s: %s", error_prefix, output) + M.notify(error_message, vim.log.levels.ERROR) + 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() + if not M.ensure_jj() then + return false + end + + local _, success = M.execute_command("jj status", "") + return success +end + +--- Get jj repository root path +--- @return string|nil The repository root path, or nil if not in a repo +function M.get_jj_root() + if not M.ensure_jj() then + return nil + end + + local output, success = M.execute_command("jj root", "") + if success and output then + return vim.trim(output) + end + return nil +end + +---- Notify function to display messages with a title +--- @param message string The message to display +--- @param level number The log level (default: INFO) +function M.notify(message, level) + level = level or vim.log.levels.INFO + vim.notify(message, level, { title = "JJ", timeout = 3000 }) +end + +return M