diff --git a/README.md b/README.md index 92f05e8..59cd695 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,9 @@ This plugin aims to be something like vim-fugitive but for driving the jj-vcs CL - `new` - Create a new change - `edit` - Edit a change - `squash` - Squash the current diff to it's parent -- Picker `git status` like for [Snacks.nvim](https://github.com/folke/snacks.nvim) +- Picker for for [Snacks.nvim](https://github.com/folke/snacks.nvim) + - `jj status` Displays the current changes diffs + - `jj log` Displays a buffer's history changes and allows to edit it's change ## Installation @@ -51,6 +53,22 @@ The plugin provides a `:J` command that accepts jj subcommands: :J ``` +## Setup config + +```lua +{ + -- Setup snacks as a picker + picker = { + -- Here you can pass the options as you would for snacks. + -- It will be used when using the picker + snacks = { + + } + } +} + +``` + ## Example config ```lua @@ -66,9 +84,15 @@ The plugin provides a `:J` command that accepts jj subcommands: 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" }) + + -- 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" }) + -- Some functions like `describe` or `log` can take parameters @@ -96,6 +120,14 @@ The plugin provides a `:J` command that accepts jj subcommands: This is an early-stage project. Contributions are welcome, but please be aware that the API and features are likely to change significantly. +## Documentation + +Once the plugin is more complete i'll write docs for each of the commands. + +## FAQ + +- Telescope Suport? Planned but i don't use it, it's already thought of by design, will implement it at some point or if someone submits a PR i'll accept it gladly. + ## License [MIT](License) diff --git a/lua/jj/picker.lua b/lua/jj/picker.lua index 0aab49d..4dc9a78 100644 --- a/lua/jj/picker.lua +++ b/lua/jj/picker.lua @@ -10,6 +10,15 @@ local utils = require("jj.utils") --- @field change string The type of change in the file --- @field diff_cmd string The command to get the diff of the file +--- @class jj.picker.log_line +--- @field symbol string The symbol of the log entry +--- @field rev string The revision of the log entry +--- @field author string The author of the log entry +--- @field time string The time of the log entry +--- @field commit_id string The commit id of the log entry +--- @field description string The description of the log entry +--- @field diff_cmd string The command to get the diff of the file + local M = { --- @type jj.picker.config config = { @@ -73,4 +82,83 @@ function M.status() end end +---Parse jj log oneline +---@param file_path string The path of the file to log +---@return jj.picker.log_line[]|nil A list of log lines or nil if not in a jj repo +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)) + if not ok then + return + end + + if type(output) ~= "string" then + return utils.notify(string.format("Could not get log output for file %s", file_path), vim.log.levels.ERROR) + end + + local file_history = {} + local lines = vim.split(output, "\n", { trimempty = true }) + + for _, line in ipairs(lines) do + -- Skip root line and elided revisions + local not_empty = line:match("%S") + local root = line:match("root%(%)") + local elided = line:match("~%s*%(elided revisions%)%s*$") + + if not_empty and not root and not elided then + -- Pattern: [symbol] [rev] [author] [time] [commit_id] [description] + -- Example: @ w ngou0210 10 seconds ago e (no description set) + -- Split at the first space after the symbol + local first_space = line:find("%s") + if not first_space then + goto continue + end + -- Split the line into parts + -- The first part is the symbol, the second part is the revision, the third part is the author, + local symbol = line:sub(1, first_space - 1) + local rest_of_line = line:sub(first_space + 1) + + local rev, author, time_part, commit_id, description = + rest_of_line:match("^%s*(%S+)%s+(%S+)%s+(.-)%s+([%w]+)%s+(.*)$") + + -- It does not make much sense to show the current commit + if symbol and symbol ~= "@" then + if rev and author and commit_id then + table.insert(file_history, { + symbol = symbol or "", + rev = rev, + author = author, + time = time_part or "", + commit_id = commit_id, + description = description or "", + text = line, + diff_cmd = string.format("jj diff %s -r %s --stat --git", file_path, rev), + }) + end + end + end + ::continue:: + end + + return file_history +end + +function M.file_history() + -- Ensure jj is installed + if not utils.ensure_jj() then + return + end + + local file = vim.fn.expand("%:p") + + local log_lines = log_history(file) + + if M.config.snacks then + require("jj.picker.snacks").file_log_history(M.config, log_lines) + else + return utils.notify("No `Picker` enabled", vim.log.levels.INFO) + end +end + return M diff --git a/lua/jj/picker/snacks.lua b/lua/jj/picker/snacks.lua index fe9f0c9..7fa34b6 100644 --- a/lua/jj/picker/snacks.lua +++ b/lua/jj/picker/snacks.lua @@ -37,4 +37,128 @@ function M.status(opts, files) snacks.picker.pick(merged_opts) end +local function format_jj_log(item, picker) + local a = Snacks.picker.util.align + local ret = {} ---@type snacks.picker.Highlight[] + + -- Add symbol (if available) and revision + if item.symbol and item.symbol ~= "" then + ret[#ret + 1] = { a(item.symbol, 1, { truncate = true }), "SnacksPickerGitMsg" } + else + ret[#ret + 1] = { "?", "SnacksPickerGitMsg" } + end + + ret[#ret + 1] = { " " } + + local rev = item.rev or "unknown" + -- INFO: This highlight is kind of a nice hack to avoid doing my own highlighs for the moment + --- At some point i'll probably do mines + ret[#ret + 1] = { a(rev, 4, { truncate = true }), "SnacksPickerGitBreaking" } + if #rev >= 4 then + ret[#ret + 1] = { " " } + end + + if item.author then + ret[#ret + 1] = { a(item.author, 8, { truncate = true }), "SnacksPcikerGitMsg" } + if #item.author >= 8 then + ret[#ret + 1] = { " " } + end + end + + if item.time then + local year, month, day = item.time:match("(%d+)-(%d+)-(%d+)") + local formatted = string.format("%s-%s-%s", year, day, month) + ret[#ret + 1] = { a(formatted, 10), "SnacksPickerGitDate" } + if #formatted >= 10 then + ret[#ret + 1] = { " " } + end + end + + if item.commit_id then + ret[#ret + 1] = { a(item.commit_id, 4, { truncate = true }), "SnacksPickerGitCommit" } + if #item.commit_id >= 4 then + ret[#ret + 1] = { " " } + end + end + + -- This comes from snacks git description formattedr + local desc = item.description or "" + local type, scope, breaking, body = desc:match("^(%S+)%s*(%(.-%))(!?):%s*(.*)$") + + if not type then + type, breaking, body = desc:match("^(%S+)(!?):%s*(.*)$") + end + local msg_hl = "SnacksPickerGitMsg" + if type and body then + local dimmed = vim.tbl_contains({ "chore", "bot", "build", "ci", "style", "test" }, type) + msg_hl = dimmed and "SnacksPickerDimmed" or "SnacksPickerGitMsg" + ret[#ret + 1] = { + type, + breaking ~= "" and "SnacksPickerGitBreaking" or dimmed and "SnacksPickerBold" or "SnacksPickerGitType", + } + if scope and scope ~= "" then + ret[#ret + 1] = { scope, "SnacksPickerGitScope" } + end + if breaking ~= "" then + ret[#ret + 1] = { "!", "SnacksPickerGitBreaking" } + end + ret[#ret + 1] = { ":", "SnacksPickerDelim" } + ret[#ret + 1] = { " " } + desc = body + end + + ret[#ret + 1] = { desc, msg_hl } + return ret +end + +---@param opts jj.picker.config +---@param log_lines jj.picker.log_line[] +function M.file_log_history(opts, log_lines) + if not opts.snacks then + return utils.notify("Snacks picker is `disabled`", vim.log.levels.INFO) + end + + local snacks = require("snacks") + + local snacks_opts + -- If its true we default to an empty table + if opts.snacks == true then + snacks_opts = {} + else + --- Otherwise we get the table from the config + ---@type table + snacks_opts = opts.snacks + end + + local merged_opts = vim.tbl_deep_extend("force", snacks_opts, { + source = "jj", + items = log_lines, + title = "JJ Log", + format = format_jj_log, + confirm = function(picker, item) + picker:close() + + if not item or not item.rev then + return + end + + local _, ok = utils.execute_command( + string.format("jj edit %s --ignore-immutable", item.rev), + string.format("could not edit revision '%s'", item.rev) + ) + + if ok then + utils.notify(string.format("Editing revision `%s`", item.rev), vim.log.levels.INFO) + end + end, + preview = function(ctx) + if ctx.item.rev and ctx.item.diff_cmd then + snacks.picker.preview.cmd(ctx.item.diff_cmd, ctx, {}) + end + end, + }) + + snacks.picker.pick(merged_opts) +end + return M