mirror of
https://github.com/zoriya/jj.nvim.git
synced 2026-08-05 02:36:07 +00:00
feat: Initial commit with basic commands and first functionalities
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
+370
@@ -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", "<ESC>", 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user