diff --git a/docs/examples/picker.lua b/docs/examples/picker.lua index 3ac73f52b..327c55190 100644 --- a/docs/examples/picker.lua +++ b/docs/examples/picker.lua @@ -64,6 +64,12 @@ M.examples.general = { { "sR", function() Snacks.picker.resume() end, desc = "Resume" }, { "su", function() Snacks.picker.undo() end, desc = "Undo History" }, { "uC", function() Snacks.picker.colorschemes() end, desc = "Colorschemes" }, + -- tmux + { "tt", function() Snacks.picker.tmux() end, desc = "Tmux Tree"}, + { "tp", function() Snacks.picker.tmux() end, desc = "Tmux Panes"}, + { "tw", function() Snacks.picker.tmux() end, desc = "Tmux Windows"}, + { "ts", function() Snacks.picker.tmux() end, desc = "Tmux Sessions"}, + { "tc", function() Snacks.picker.tmux() end, desc = "Tmux Clients"}, -- LSP { "gd", function() Snacks.picker.lsp_definitions() end, desc = "Goto Definition" }, { "gD", function() Snacks.picker.lsp_declarations() end, desc = "Goto Declaration" }, diff --git a/lua/snacks/picker/actions.lua b/lua/snacks/picker/actions.lua index 48691009d..678f46a8f 100644 --- a/lua/snacks/picker/actions.lua +++ b/lua/snacks/picker/actions.lua @@ -841,4 +841,23 @@ function M.list_scroll_up(picker) picker.list:scroll(-picker.list.state.scroll) end +function M.tmux_select(picker) + local items = picker:selected({ fallback = true }) + local first = items[1] + if not first then + Snacks.notify.error("Can't find tmux target", { title = "Snacks Picker" }) + return + end + local target = first.pane_id or first.window_id or first.session_name + if not target then + Snacks.notify.error("Can't find tmux target", { title = "Snacks Picker" }) + return + end + local cmd = { "tmux", "switch-client", "-t", target } + Snacks.picker.util.cmd(cmd, function() + Snacks.notify("Switched to tmux target " .. target, { title = "Snacks Picker" }) + picker:close() + end) +end + return M diff --git a/lua/snacks/picker/config/highlights.lua b/lua/snacks/picker/config/highlights.lua index 40dd0debf..21be17531 100644 --- a/lua/snacks/picker/config/highlights.lua +++ b/lua/snacks/picker/config/highlights.lua @@ -114,6 +114,14 @@ Snacks.util.set_hl({ IconTypeParameter = "@lsp.type.typeParameter", IconVariable = "@variable", Rule = "@punctuation.special.markdown", + TmuxActivity = "Changed", + TmuxAddr = "Number", + TmuxDelim = "Delimiter", + TmuxExtra = "Special", + TmuxIcon = "Special", + TmuxId = "Label", + TmuxName = "Title", + TmuxUser = "Constant", }, { prefix = "SnacksPicker", default = true }) return M diff --git a/lua/snacks/picker/config/sources.lua b/lua/snacks/picker/config/sources.lua index ece49592c..b7e17a6b9 100644 --- a/lua/snacks/picker/config/sources.lua +++ b/lua/snacks/picker/config/sources.lua @@ -1024,6 +1024,47 @@ M.tags = { format = "lsp_symbol", } +M.tmux = { + finder = "tmux_tree", + sort = { fields = { "session_name", "window_index", "pane_index" } }, + format = "tmux", + preview = "tmux", + matcher = { sort_empty = true }, + confirm = "tmux_select", +} + +-- Search tmux clients +M.tmux_clients = { + finder = "tmux_clients", + format = "tmux", + preview = "tmux", + confirm = "tmux_select", +} + +-- Search tmux panes +M.tmux_panes = { + finder = "tmux_panes", + format = "tmux", + preview = "tmux", + confirm = "tmux_select", +} + +-- Search tmux sessions +M.tmux_sessions = { + finder = "tmux_sessions", + format = "tmux", + preview = "tmux", + confirm = "tmux_select", +} + +-- Search tmux windows +M.tmux_windows = { + finder = "tmux_windows", + format = "tmux", + preview = "tmux", + confirm = "tmux_select", +} + ---@class snacks.picker.treesitter.Config: snacks.picker.Config ---@field filter table? symbol kind filter ---@field tree? boolean show symbol tree diff --git a/lua/snacks/picker/format.lua b/lua/snacks/picker/format.lua index 63a56a0df..7dda1021f 100644 --- a/lua/snacks/picker/format.lua +++ b/lua/snacks/picker/format.lua @@ -402,6 +402,108 @@ function M.text(item, picker) return ret end +function M.tmux(item, picker) + local a = Snacks.picker.util.align + local active_window_icons = { + top = "󰁞", + bottom = "󰁆", + left = "󰁎", + right = "󰁕", + ["top-left"] = "󰧄", + ["top-right"] = "󰧆", + ["bottom-left"] = "󰦸", + ["bottom-right"] = "󰦺", + none = "", + } + local inactive_window_icons = { + top = "󰧇", + bottom = "󰦿", + left = "󰧀", + right = "󰧂", + ["top-left"] = "󰧃", + ["top-right"] = "󰧅", + ["bottom-left"] = "󰦷", + ["bottom-right"] = "󰦹", + none = "", + } + local ret = {} ---@type snacks.picker.Highlight[] + local element + + element = item.position and (item.window_active and active_window_icons or inactive_window_icons)[item.position] + or (item.tree and "") + if element then + ret[#ret + 1] = { a(element, 2), "SnacksPickerTmuxIcon" } + end + + if item.type == "client" and item.client_name then + ret[#ret + 1] = { a(item.client_name, 12), "SnacksPickerTmuxId" } + end + + if item.tree then + vim.list_extend(ret, M.tree(item, picker)) + if item.type == "session" and item.session_name then + ret[#ret + 1] = { a(item.session_name .. ":", 9, { truncate = true }), "SnacksPickerTmuxAddr" } + elseif item.type == "window" and item.window_index then + ret[#ret + 1] = { a(tostring(item.window_index) .. ".", 7, { truncate = true }), "SnacksPickerTmuxAddr" } + elseif item.type == "pane" and item.pane_index then + ret[#ret + 1] = { a(tostring(item.pane_index), 5, { truncate = true }), "SnacksPickerTmuxAddr" } + end + else + if item.session_name then + ret[#ret + 1] = { a(item.session_name, 8, { truncate = true, align = "right" }), "SnacksPickerTmuxAddr" } + ret[#ret + 1] = { " :", "SnacksPickerTmuxDelim" } + if item.window_index and item.window_index >= 0 then + ret[#ret + 1] = { a(tostring(item.window_index), 3, { align = "center" }), "SnacksPickerTmuxAddr" } + ret[#ret + 1] = { ". ", "SnacksPickerTmuxDelim" } + if item.pane_index and item.pane_index >= 0 then + ret[#ret + 1] = { a(tostring(item.pane_index), 3), "SnacksPickerTmuxAddr" } + end + else + ret[#ret + 1] = { " ", "SnacksPickerTmuxDelim" } + end + end + end + + element = item.current_command or item.window_name or (item.tree and "") + if element then + ret[#ret + 1] = { a(element, 8, { truncate = true }), "SnacksPickerTmuxName" } + end + + if item.type and item[item.type .. "_id"] then + ret[#ret + 1] = { a(item[item.type .. "_id"], 4), "SnacksPickerTmuxId" } + end + + element = nil + if item.type == "session" and item.session_windows then + element = tostring(item.session_windows) .. " window" .. (item.session_windows == 1 and " " or "s") + elseif item.type == "window" and item.window_panes then + element = tostring(item.window_panes) .. " pane" .. (item.window_panes == 1 and " " or "s") + elseif item.tree then + element = "" + end + if element then + ret[#ret + 1] = { a(element, 11), "SnacksPickerTmuxExtra" } + end + + element = nil + if item.type == "session" and item.session_attached then + element = tostring(item.session_attached) .. " client" .. (item.session_attached == 1 and " " or "s") + elseif (item.type == "pane" and item.pane_active) or (item.type == "window" and item.window_active) then + element = "active" + elseif item.tree then + element = "" + end + if element then + ret[#ret + 1] = { a(element, 11), "SnacksPickerTmuxActivity" } + end + + if item.client_user then + ret[#ret + 1] = { a("<" .. item.client_user .. ">", 12), "SnacksPickerTmuxUser" } + end + + return ret +end + function M.command(item) local ret = {} ---@type snacks.picker.Highlight[] ret[#ret + 1] = { item.cmd, "SnacksPickerCmd" .. (item.cmd:find("^[a-z]") and "Builtin" or "") } diff --git a/lua/snacks/picker/preview.lua b/lua/snacks/picker/preview.lua index 462b9aa03..551eb823e 100644 --- a/lua/snacks/picker/preview.lua +++ b/lua/snacks/picker/preview.lua @@ -376,4 +376,29 @@ function M.man(ctx) }) end +---@param ctx snacks.picker.preview.ctx +function M.tmux(ctx) + local main_type = ctx.item.type:gsub("^%l", string.upper) + local child_type = (ctx.item.type == "session" and "windows") or (ctx.item.type == "window" and "panes") or "preview" + local addr = ctx.item.type == "client" and ctx.item.client_name + or (ctx.item.session_name or "") + .. (ctx.item.window_index >= 0 and ":" .. tostring(ctx.item.window_index) or "") + .. (ctx.item.pane_index >= 0 and "." .. tostring(ctx.item.pane_index) or "") + ctx.preview:set_title(("%s %s %s"):format(main_type, addr, child_type)) + if ctx.item.type == "session" or ctx.item.type == "window" then + ctx.preview:scratch() + local children = vim.tbl_map( + function(val) + return Snacks.picker.format.tmux(val, ctx.picker) + end, + vim.tbl_filter(function(val) + return val[ctx.item.type .. "_id"] == ctx.item[ctx.item.type .. "_id"] + end, require("snacks.picker.source.tmux")[child_type](_, ctx)) + ) + Snacks.picker.highlight.render(ctx.buf, ns, children, { append = true }) + elseif ctx.item.type == "pane" or ctx.item.type == "client" and ctx.item.pane_id then + M.cmd(("setterm -linewrap off; tmux capture-pane -pe -t %s"):format(ctx.item.pane_id), ctx) + end +end + return M diff --git a/lua/snacks/picker/source/tmux.lua b/lua/snacks/picker/source/tmux.lua new file mode 100644 index 000000000..54ff65478 --- /dev/null +++ b/lua/snacks/picker/source/tmux.lua @@ -0,0 +1,221 @@ +local M = {} + +---@param opts snacks.picker.proc.Config +function M.panes(opts, ctx) + local obj = vim + .system({ + "tmux", + "list-panes", + "-aF", + "#{session_name}:#{window_index}.#{pane_index} #{session_id} #{window_id} #{pane_id} #{window_active} #{pane_active} #{pane_at_top} #{pane_at_bottom} #{pane_at_left} #{pane_at_right} #{pane_current_command}", + }, { text = true }) + :wait() + local items = {} + for line in obj.stdout:gmatch("(.-)\n") do + local session_name, window_index, pane_index, session_id, window_id, pane_id, window_active, pane_active, pane_at_top, pane_at_bottom, pane_at_left, pane_at_right, current_command = + line:match("^(.+):(%d+)%.(%d+) (%$%d+) (@%d+) (%%%d+) ([01]) ([01]) ([01]) ([01]) ([01]) ([01]) (.+)$") + window_index = tonumber(window_index) + pane_index = tonumber(pane_index) + pane_active = pane_active == "1" + local position + if pane_at_top == "1" and pane_at_bottom == "0" and pane_at_left == pane_at_right then + position = "top" + elseif pane_at_top == "0" and pane_at_bottom == "1" and pane_at_left == pane_at_right then + position = "bottom" + elseif pane_at_left == "1" and pane_at_right == "0" and pane_at_top == pane_at_bottom then + position = "left" + elseif pane_at_left == "0" and pane_at_right == "1" and pane_at_top == pane_at_bottom then + position = "right" + elseif pane_at_top == "1" and pane_at_bottom == "0" and pane_at_left == "1" and pane_at_right == "0" then + position = "top-left" + elseif pane_at_top == "1" and pane_at_bottom == "0" and pane_at_left == "0" and pane_at_right == "1" then + position = "top-right" + elseif pane_at_top == "0" and pane_at_bottom == "1" and pane_at_left == "1" and pane_at_right == "0" then + position = "bottom-left" + elseif pane_at_top == "0" and pane_at_bottom == "1" and pane_at_left == "0" and pane_at_right == "1" then + position = "bottom-right" + else + position = "none" + end + items[#items + 1] = { + type = "pane", + session_name = session_name, + window_index = window_index, + pane_index = pane_index, + session_id = session_id, + window_id = window_id, + pane_id = pane_id, + window_active = window_active == "1", + pane_active = pane_active, + position = position, + current_command = current_command, + text = ("%s %s:%s.%s %s %s %s %s %s"):format( + "pane", + session_name, + window_index, + pane_index, + session_id, + window_id, + pane_id, + current_command, + pane_active and "active" or "" + ), + } + end + return items +end + +---@param opts snacks.picker.proc.Config +function M.windows(opts, ctx) + local obj = vim + .system({ + "tmux", + "list-windows", + "-aF", + "#{session_name}:#{window_index} #{session_id} #{window_id} #{window_active} #{window_panes} #{window_name}", + }, { text = true }) + :wait() + local items = {} + for line in obj.stdout:gmatch("(.-)\n") do + local session_name, window_index, session_id, window_id, window_active, window_panes, window_name = + line:match("^(.+):(%d+)% (%$%d+) (@%d+) ([01]) (%d+) (.+)$") + window_index = tonumber(window_index) + window_panes = tonumber(window_panes) + window_active = window_active == "1" + items[#items + 1] = { + type = "window", + session_name = session_name, + window_index = window_index, + pane_index = -1, + session_id = session_id, + window_id = window_id, + window_active = window_active, + window_panes = window_panes, + window_name = window_name, + text = ("%s %s:%s. %s %s %s %s"):format( + "window", + session_name, + window_index, + session_id, + window_id, + window_name, + window_active and "active" or "" + ), + } + end + return items +end + +---@param opts snacks.picker.proc.Config +function M.clients(opts, ctx) + local obj = vim + .system({ + "tmux", + "list-clients", + "-F", + "#{session_name}:#{window_index}.#{pane_index} #{pane_id} #{client_name} #{client_user} #{pane_current_command}", + }, { text = true }) + :wait() + local items = {} + for line in obj.stdout:gmatch("(.-)\n") do + local session_name, window_index, pane_index, pane_id, client_name, client_user, current_command = + line:match("^(.+):(%d+)%.(%d+) (%%%d+) (.+) (.+) (.+)$") + window_index = tonumber(window_index) + pane_index = tonumber(pane_index) + items[#items + 1] = { + type = "client", + session_name = session_name, + window_index = window_index, + pane_index = pane_index, + pane_id = pane_id, + client_name = client_name, + client_user = client_user, + current_command = current_command, + } + end + return items +end + +---@param opts snacks.picker.proc.Config +function M.sessions(opts, ctx) + local obj = vim + .system( + { "tmux", "list-sessions", "-F", "#{session_name} #{session_id} #{session_windows} #{session_attached}" }, + { text = true } + ) + :wait() + local items = {} + for line in obj.stdout:gmatch("(.-)\n") do + local session_name, session_id, session_windows, session_attached = line:match("^(.+) (%$%d+) (%d+) (%d+)$") + session_windows = tonumber(session_windows) + session_attached = tonumber(session_attached) + items[#items + 1] = { + type = "session", + session_name = session_name, + session_id = session_id, + window_index = -1, + pane_index = -1, + session_windows = session_windows, + session_attached = session_attached, + text = ("%s %s: %s %s"):format( + "session", + session_name, + session_id, + session_attached > 0 and session_attached .. " client" .. (session_attached > 1 and "s" or "") or "" + ), + } + end + return items +end + +---@param opts snacks.picker.proc.Config +function M.tree(opts, ctx) + local panes = M.panes(opts, ctx) + local windows = M.windows(opts, ctx) + local sessions = M.sessions(opts, ctx) + + local session_map = {} + local window_map = {} + local session_maxima = {} + local window_maxima = {} + + for _, session in ipairs(sessions) do + session.tree = true + session_map[session.session_id] = session + end + for _, window in ipairs(windows) do + window.tree = true + window_map[window.window_id] = window + if not session_maxima[window.session_id] or window.window_index > session_maxima[window.session_id] then + session_maxima[window.session_id] = window.window_index + end + end + for _, pane in ipairs(panes) do + pane.tree = true + if not window_maxima[pane.window_id] or pane.pane_index > window_maxima[pane.window_id] then + window_maxima[pane.window_id] = pane.pane_index + end + end + + sessions[#sessions].last = true + for _, window in ipairs(windows) do + window.parent = session_map[window.session_id] + if window.window_index == session_maxima[window.session_id] then + window.last = true + end + end + for _, pane in ipairs(panes) do + pane.parent = window_map[pane.window_id] + if pane.pane_index == window_maxima[pane.window_id] then + pane.last = true + end + end + + local items = {} + vim.list_extend(items, sessions) + vim.list_extend(items, windows) + vim.list_extend(items, panes) + return items +end + +return M diff --git a/lua/snacks/picker/types.lua b/lua/snacks/picker/types.lua index 82ff62160..111890314 100644 --- a/lua/snacks/picker/types.lua +++ b/lua/snacks/picker/types.lua @@ -65,6 +65,11 @@ ---@field smart fun(opts?: snacks.picker.smart.Config|{}): snacks.Picker ---@field spelling fun(opts?: snacks.picker.Config|{}): snacks.Picker ---@field tags fun(opts?: snacks.picker.tags.Config|{}): snacks.Picker +---@field tmux fun(opts?: snacks.picker.Config|{}): snacks.Picker +---@field tmux_clients fun(opts?: snacks.picker.Config|{}): snacks.Picker +---@field tmux_panes fun(opts?: snacks.picker.Config|{}): snacks.Picker +---@field tmux_sessions fun(opts?: snacks.picker.Config|{}): snacks.Picker +---@field tmux_windows fun(opts?: snacks.picker.Config|{}): snacks.Picker ---@field treesitter fun(opts?: snacks.picker.treesitter.Config|{}): snacks.Picker ---@field undo fun(opts?: snacks.picker.undo.Config|{}): snacks.Picker ---@field zoxide fun(opts?: snacks.picker.Config|{}): snacks.Picker