From f00daa25ebaa0d2c0a1c1b66b6037b47a846dcb8 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 31 Jul 2026 14:55:04 +0200 Subject: [PATCH] Add jj stage picker --- claude-mux/internal/ui/list.go | 9 +- flake.nix | 1 + nvim/lua/jj-snacks.lua | 199 +++++++++++++++++++++++++++++++-- nvim/lua/plugins/snacks.lua | 1 + overlays/default.nix | 2 +- 5 files changed, 200 insertions(+), 12 deletions(-) diff --git a/claude-mux/internal/ui/list.go b/claude-mux/internal/ui/list.go index ce26483..30df7af 100644 --- a/claude-mux/internal/ui/list.go +++ b/claude-mux/internal/ui/list.go @@ -153,6 +153,7 @@ func (m *model) startLoad() tea.Cmd { // applyLoad folds a completed load into the model, keeping the cursor on the // same session even when attention sorting reorders the list. func (m *model) applyLoad(msg loadedMsg) { + firstLoad := !m.loaded m.loading = false m.loaded = true m.err = msg.err @@ -160,8 +161,14 @@ func (m *model) applyLoad(msg loadedMsg) { return } + // After the first load, keep the cursor on whatever session it was already + // pointing at (attention sorting can reorder the list). On the very first + // load there is nothing to track yet, so start the cursor on the current + // session — the one open in the pane the picker was floated over. var selectedID string - if m.cursor >= 0 && m.cursor < len(m.entries) { + if firstLoad { + selectedID = m.current + } else if m.cursor >= 0 && m.cursor < len(m.entries) { selectedID = m.entries[m.cursor].ID } m.raw = msg.entries diff --git a/flake.nix b/flake.nix index 11013e6..e527cce 100644 --- a/flake.nix +++ b/flake.nix @@ -152,6 +152,7 @@ in rec { default = nvim; nvim = pkgs.nvim; + claude-mux = pkgs.claude-mux; }); devShells = eachSystem (system: let diff --git a/nvim/lua/jj-snacks.lua b/nvim/lua/jj-snacks.lua index 03d78e0..f2ff4b4 100644 --- a/nvim/lua/jj-snacks.lua +++ b/nvim/lua/jj-snacks.lua @@ -11,6 +11,19 @@ local function jj_cwd(from, opts, ctx) return cwd end +local STATUS_HL = { + ["A"] = "SnacksPickerGitStatusAdded", + ["M"] = "SnacksPickerGitStatusModified", + ["D"] = "SnacksPickerGitStatusDeleted", + ["R"] = "SnacksPickerGitStatusRenamed", + ["C"] = "SnacksPickerGitStatusCopied", + ["?"] = "SnacksPickerGitStatusUntracked", +} + +local function status_hl(status) + return STATUS_HL[status] or "SnacksPickerGitStatus" +end + Snacks.picker.jj_log = function(file) local title = "jj log" if file then @@ -171,6 +184,164 @@ Snacks.picker.jj_log_file = function() Snacks.picker.jj_log(vim.fn.expand("%:p")) end +-- Stage-area style picker: show the combined diff of `@` and `@-`, with a two +-- character status column (`@` first, `@-` second) à la git's staged/unstaged +-- columns, and let move a file's change between `@` and `@-`. +Snacks.picker.jj_stage = function() + local from = vim.api.nvim_buf_get_name(0) + if from == "" then + from = nil + end + + local title = "jj stage @ / @-" + local root = vim.fs.root(vim.fs.normalize(from or vim.uv.cwd() or "."), ".jj") + if root then + local function desc(rev) + local out = vim.system({ + "jj", "log", "--no-graph", "--color=never", "-r", rev, "-T", + "if(description, description.first_line(), description_placeholder)", + }, { cwd = root, text = true }):wait() + return vim.trim(out.stdout or "") + end + title = string.format("@ %s │ @- %s", desc("@"), desc("@-")) + end + + Snacks.picker.pick({ + title = title, + supports_live = false, + finder = function(opts, ctx) + local cwd = jj_cwd(from, opts, ctx) + if not cwd then + return {} + end + + -- Parse `jj diff --summary` for a revision into { [file] = entry }. + local function summary(rev) + local out = vim.system( + { "jj", "diff", "--summary", "--color=never", "-r", rev }, + { cwd = cwd, text = true } + ):wait() + local files = {} + for line in vim.gsplit(out.stdout or "", "\n", { trimempty = true }) do + local status, file = line:match("^(.) (.+)$") + if status then + local entry = { status = status, file = file } + local base, prev, next, suffix = file:match("^(.*){(.*) => (.*)}(.*)$") + if base ~= nil then + entry.rename = base .. prev .. suffix + entry.file = base .. next .. suffix + end + files[entry.file] = entry + end + end + return files + end + + local at = summary("@") + local parent = summary("@-") + + local order, seen = {}, {} + for _, map in ipairs({ at, parent }) do + for k in pairs(map) do + if not seen[k] then + seen[k] = true + order[#order + 1] = k + end + end + end + table.sort(order) + + local items = {} + for _, k in ipairs(order) do + local a, p = at[k], parent[k] + local at_status = a and a.status or " " + local parent_status = p and p.status or " " + items[#items + 1] = { + text = k, + cwd = cwd, + file = k, + rename = (a and a.rename) or (p and p.rename), + -- Two status columns, `@` first then `@-`: `MM`, `M `, ` M`, ... + at_status = at_status, + parent_status = parent_status, + in_at = a ~= nil, + } + end + return items + end, + format = function(item, picker) + local ret = {} ---@type snacks.picker.Highlight[] + + table.insert(ret, { item.at_status, status_hl(item.at_status) }) + table.insert(ret, { item.parent_status, status_hl(item.parent_status) }) + table.insert(ret, { " " }) + if item.rename then + local file = item.file + item.file = item.rename + item._path = nil + vim.list_extend(ret, Snacks.picker.format.filename(item, picker)) + item.file = file + item._path = nil + ret[#ret + 1] = { "-> ", "SnacksPickerDelim" } + ret[#ret + 1] = { " " } + end + vim.list_extend(ret, Snacks.picker.format.filename(item, picker)) + return ret + end, + preview = function(ctx) + local cmd = { "jj", "diff", "--git", "--no-pager", "--from", "@--", "--to", "@" } + table.insert(cmd, string.format("file:'%s'", ctx.item.file)) + Snacks.picker.preview.cmd(cmd, ctx, { ft = "diff" }) + end, + sort = { fields = { 'score:desc', 'idx' } }, + win = { + input = { + keys = { + [""] = { "jj_toggle", mode = { "i", "n" } }, + [""] = { "jj_gsplit", mode = { "i" } }, + [""] = { "jj_gvsplit", mode = { "i" } }, + }, + }, + }, + actions = { + -- Move a file's change between `@` and `@-`. When it lives in `@` + -- (`M ` or `MM`) squash it down into `@-`; when it only lives in `@-` + -- (` M`) move it back up into `@`. + jj_toggle = function(picker) + local items = picker:selected({ fallback = true }) + local done = 0 + for _, item in ipairs(items) do + local paths = { string.format("file:'%s'", item.file) } + if item.rename then + table.insert(paths, string.format("file:'%s'", item.rename)) + end + local cmd + if item.in_at then + cmd = { "jj", "squash", "--into", "@-" } + else + cmd = { "jj", "squash", "--from", "@-", "--into", "@" } + end + vim.list_extend(cmd, paths) + Snacks.picker.util.cmd(cmd, function() + done = done + 1 + if done == #items then + picker:refresh() + end + end, { cwd = item.cwd }) + end + end, + jj_gsplit = function(picker, item) + picker:close() + vim.cmd({ cmd = "Jsplit", args = { "@:" .. item.file } }) + end, + jj_gvsplit = function(picker, item) + picker:close() + vim.cmd({ cmd = "Jvsplit", args = { "@:" .. item.file } }) + end, + }, + }) +end + Snacks.picker.jj_show = function(spec) if type(spec) == "string" then spec = { ref = spec } @@ -242,16 +413,7 @@ Snacks.picker.jj_show = function(spec) format = function(item, picker) local ret = {} ---@type snacks.picker.Highlight[] - local hls = { - ["A"] = "SnacksPickerGitStatusAdded", - ["M"] = "SnacksPickerGitStatusModified", - ["D"] = "SnacksPickerGitStatusDeleted", - ["R"] = "SnacksPickerGitStatusRenamed", - ["C"] = "SnacksPickerGitStatusCopied", - ["?"] = "SnacksPickerGitStatusUntracked", - } - local hl = hls[item.status] or "SnacksPickerGitStatus" - table.insert(ret, { item.status, hl }) + table.insert(ret, { item.status, status_hl(item.status) }) table.insert(ret, { " " }) if item.rename then local file = item.file @@ -281,6 +443,7 @@ Snacks.picker.jj_show = function(spec) input = { keys = { [""] = { "jj_split", mode = { "i" } }, + [""] = { "jj_squash", mode = { "i" } }, [""] = { "jj_gsplit", mode = { "i" } }, [""] = { "jj_gvsplit", mode = { "i" } }, }, @@ -297,6 +460,22 @@ Snacks.picker.jj_show = function(spec) table.concat(items, "' | '") )) end, + jj_squash = function(picker) + local items = picker:selected({ fallback = true }) + if #items == 0 then + return + end + local cmd = { "jj", "squash", "-r", show_ref } + for _, item in ipairs(items) do + table.insert(cmd, string.format("file:'%s'", item.file)) + if item.rename then + table.insert(cmd, string.format("file:'%s'", item.rename)) + end + end + Snacks.picker.util.cmd(cmd, function() + picker:refresh() + end, { cwd = items[1].cwd }) + end, jj_gsplit = function(picker, item) picker:close() vim.cmd({ cmd = "Jsplit", args = { show_ref .. ":" .. item.file } }) diff --git a/nvim/lua/plugins/snacks.lua b/nvim/lua/plugins/snacks.lua index 13db038..3244e8a 100644 --- a/nvim/lua/plugins/snacks.lua +++ b/nvim/lua/plugins/snacks.lua @@ -297,6 +297,7 @@ return { vim.keymap.set("n", "jl", Snacks.picker.jj_log, { desc = "jj log" }) vim.keymap.set("n", "jh", Snacks.picker.jj_log_file, { desc = "jj file log" }) vim.keymap.set("n", "js", Snacks.picker.jj_show, { desc = "jj status" }) + vim.keymap.set("n", "jS", Snacks.picker.jj_stage, { desc = "jj stage (@ / @-)" }) vim.keymap.set("n", "jr", function() Snacks.picker.jj_show({ from = "trunk()", to = "@" }) end, { desc = "review branch" }) diff --git a/overlays/default.nix b/overlays/default.nix index 5aaf499..72ee1b8 100644 --- a/overlays/default.nix +++ b/overlays/default.nix @@ -57,7 +57,7 @@ in { pname = "claude-mux"; version = "0.1.0"; src = ../claude-mux; - vendorHash = "sha256-uwBJAqN4sIepiiJf9lCDumLqfKJEowQO2tOiSWD3Fig="; + vendorHash = "sha256-TUbaUoqDZoQTkcOMtoE/FlAiqkWN+x49JeGkDguh2UU="; nativeBuildInputs = [super.makeWrapper]; postInstall = '' wrapProgram $out/bin/claude-mux \