diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..50f57ec --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "Lua.diagnostics.globals": [ + "vim" + ] +} \ No newline at end of file diff --git a/README.md b/README.md index cf0ce55..0d9d04a 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ It supports the following multiplexers/terminal, referred to as backends hereaft - tmux - wezterm +- zellij - toggleterm ## Installation @@ -78,23 +79,32 @@ vim.g.command = {--[[ options go here ]]} The backend is the multiplexer or terminal to use. It's controlled by the `backend` key in the options table. If the `backend` key is unset, as it is by default, heuristics are used to pick one of the supported backends. -* If `$TMUX` is set, tmux is used. +The backend is selected using the following table of fallbacks, checked in order from top to bottom. The first condition that matches determines which backend will be used: -* Else if `wezterm(?.exe)` exists in `$PATH`, wezterm is used. +| Condition | Backend | +| ------------------------------------------------- | ---------- | +| `$TMUX` is set | tmux | +| `$ZELLIJ` or `$ZELLIJ_SESSION_NAME` is set | zellij | +| `$TERM` is `wezterm` | wezterm | +| toggleterm's module can be `require()`'ed | toggleterm | -* Else if toggleterm's module can be `require()`'ed, toggleterm is used. +This order ensures that the most specific environment (e.g., tmux or zellij) is prioritized. +**If none of these heuristics succeed, you must explicitly set a backend using the `backend` key in your configuration. Otherwise, an error will be raised and no backend will be used.** ```lua --- @alias backend_used --- | 'tmux' --- | 'wezterm' + --- | 'zellij' --- | 'toggleterm' backend = nil ``` -#### tmux & wezterm +#### tmux, wezterm, and zellij -The tmux and wezterm backends both have 2 built in pane directions, right of the editor pane, and below the editor pane. +The tmux and wezterm backends each have 2 built in pane directions, right of the editor pane, and below the editor pane. + +The zellij backend has those two directions plus a third that sends commands to a floating pane (creating one if none exists). #### ToggleTerm @@ -137,6 +147,7 @@ vim.g.command = { --- @alias backend_used --- | 'tmux' --- | 'wezterm' + --- | 'zellij' --- | 'toggleterm' backend = nil @@ -174,10 +185,10 @@ vim.g.command = { - [x] tmux - [x] ToggleTerm - [x] wezterm + - [x] zellij - [ ] default nvim terminal - [ ] kitty - [ ] some other backend - - [ ] zellij - [x] clean up types - [x] types for all opts, use lua-ls enums - [x] move to utils ? diff --git a/doc/command.nvim.txt b/doc/command.nvim.txt index b62a2ab..d5101a4 100644 --- a/doc/command.nvim.txt +++ b/doc/command.nvim.txt @@ -1,4 +1,4 @@ -*command.nvim.txt* For Neovim >= 0.9.0 Last change: 2025 October 20 +*command.nvim.txt* For Neovim >= 0.9.0 Last change: 2026 March 25 ============================================================================== Table of Contents *command.nvim-table-of-contents* @@ -36,11 +36,12 @@ if you want to make it executable. - |command.nvim-defaults| - |command.nvim-todo| -It supports the following multiplexers/terminal, refered to as backends +It supports the following multiplexers/terminal, referred to as backends hereafter: - tmux - wezterm +- zellij - toggleterm @@ -93,7 +94,7 @@ CONFIGURATION *command.nvim-toc-configuration* Configurations is done by setting the `vim.g.command` table. >lua - vim.g.command = {--[[ options go here ]]} ) + vim.g.command = {--[[ options go here ]]} < @@ -101,26 +102,32 @@ BACKENDS ~ The backend is the multiplexer or terminal to use. It’s controlled by the `backend` key in the options table. If the `backend` key is unset, as it is by -default, heuristics are used to pick on of the supported backends. +default, heuristics are used to pick one of the supported backends. - If `$TMUX` is set, tmux is used. -- Else if `wezterm(?.exe)` exists in `$PATH`, wezterm is used. +- Else if `$ZELLIJ` or `$ZELLIJ_SESSION_NAME` is set (Neovim is running inside + Zellij), zellij is used. +- Else if `$TERM` is `wezterm`, wezterm is used. - Else if toggleterm’s module can be `require()`’ed, toggleterm is used. >lua --- @alias backend_used --- | 'tmux' --- | 'wezterm' + --- | 'zellij' --- | 'toggleterm' backend = nil < -TMUX & WEZTERM +TMUX, WEZTERM, AND ZELLIJ -The tmux and wezterm backends both have 2 built in pane directions, right of +The tmux and wezterm backends each have 2 built in pane directions, right of the editor pane, and below the editor pane. +The zellij backend has those two directions plus a third that sends commands to +a floating pane (creating one if none exists). + TOGGLETERM @@ -133,8 +140,8 @@ RULES ~ When using `Command File` to run a file, instead of simply running the file you might want to run a specific shell command. Using Rules you can, they are key-value pairs of lua patterns and functions. The lua pattern is compared -against the current filename, if it maches the function is run to get the shell -command and run it. +against the current filename, if it matches the function is run to get the +shell command and run it. >lua opts = { @@ -168,8 +175,9 @@ The defaults options are as follows: --- @alias backend_used --- | 'tmux' --- | 'wezterm' + --- | 'zellij' --- | 'toggleterm' - use = nil + backend = nil --- defines rules to overwrite the command to run when using the "run current file" behavior --- keys are lua patterns (see :help lua-pattern) @@ -206,10 +214,10 @@ TODO *command.nvim-toc-todo* - tmux - ToggleTerm - wezterm + - zellij - default nvim terminal - kitty - some other backend - - zellij - clean up types - types for all opts, use lua-ls enums - move to utils ? @@ -228,7 +236,7 @@ TODO *command.nvim-toc-todo* SIMILAR PLUGINS *command.nvim-toc-similar-plugins* -- yeet.nvim , very similar, would not have _originaly_ made this had I known yeet.nvim existed :^P +- yeet.nvim , very similar, would not have _originally_ made this had I known yeet.nvim existed :^P ============================================================================== 3. Links *command.nvim-links* diff --git a/lua/command/command-types.lua b/lua/command/command-types.lua index 3538da2..d005cfb 100644 --- a/lua/command/command-types.lua +++ b/lua/command/command-types.lua @@ -2,12 +2,16 @@ --- | 'wezterm' --- | 'tmux' --- | 'toggleterm' +--- | 'zellij' --- | 'auto' -- pick automatically by examining environment vars --- @alias rule table --- @class direction --- @field name string +--- @field new string|nil +--- @field old string|nil +--- @field split string|nil --- @class backend --- @field run fun(string) diff --git a/lua/command/init.lua b/lua/command/init.lua index b57af78..2590cf6 100644 --- a/lua/command/init.lua +++ b/lua/command/init.lua @@ -11,7 +11,7 @@ M.CommandDirection = 1 M.ChangeDirection = function() if not M.backend.directions then - notify('Changing directions is not supported using backend: ' .. vim.g.command.use, 'error') + notify('Changing directions is not supported using backend: ' .. vim.g.command.backend, 'error') return end M.CommandDirection = (M.CommandDirection % #M.backend.directions + 1) diff --git a/lua/command/zellij.lua b/lua/command/zellij.lua new file mode 100644 index 0000000..1a643bb --- /dev/null +++ b/lua/command/zellij.lua @@ -0,0 +1,305 @@ +local system = require('command.utils').system +local notify = require('command.utils').notify + +--- @type direction[] +local directions = { + { + name = 'pane on right side', + new = 'right', + split = 'right', + }, + { + name = 'pane below editor', + new = 'down', + split = 'down', + }, + { + name = 'floating pane', + new = 'floating', + }, +} + +---@param p table +---@return string|nil +local function terminal_pane_id(p) + if p.is_plugin then + return nil + end + return 'terminal_' .. p.id +end + +---@param a table +---@param b table +---@return boolean +local function overlaps_vertical(a, b) + local ay2 = a.pane_y + a.pane_rows + local by2 = b.pane_y + b.pane_rows + return math.max(a.pane_y, b.pane_y) < math.min(ay2, by2) +end + +---@param a table +---@param b table +---@return boolean +local function overlaps_horizontal(a, b) + local ax2 = a.pane_x + a.pane_columns + local bx2 = b.pane_x + b.pane_columns + return math.max(a.pane_x, b.pane_x) < math.min(ax2, bx2) +end + +---@param focused table +---@param panes table[] +---@return table|nil +local function find_right_neighbor(focused, panes) + local right_edge = focused.pane_x + focused.pane_columns + local best + local best_dist + for _, p in ipairs(panes) do + if not p.is_plugin and p.id ~= focused.id then + if p.pane_x >= right_edge and overlaps_vertical(focused, p) then + local dist = p.pane_x - right_edge + if not best or dist < best_dist then + best = p + best_dist = dist + end + end + end + end + return best +end + +---@param focused table +---@param panes table[] +---@return table|nil +local function find_down_neighbor(focused, panes) + local bottom_edge = focused.pane_y + focused.pane_rows + local best + local best_dist + for _, p in ipairs(panes) do + if not p.is_plugin and p.id ~= focused.id then + if p.pane_y >= bottom_edge and overlaps_horizontal(focused, p) then + local dist = p.pane_y - bottom_edge + if not best or dist < best_dist then + best = p + best_dist = dist + end + end + end + end + return best +end + +---@param panes table[] +---@return table|nil +local function find_focused_terminal(panes) + for _, p in ipairs(panes) do + if p.is_focused and not p.is_plugin then + return p + end + end + local zid = vim.env.ZELLIJ_PANE_ID + if zid then + local num = zid:match 'terminal_(%d+)' or zid:match '^(%d+)$' + if num then + for _, p in ipairs(panes) do + if not p.is_plugin and tostring(p.id) == num then + return p + end + end + end + end + return nil +end + +--- Prefer focused floating terminal, else any floating terminal pane. +---@param panes table[] +---@return table|nil +local function find_floating_terminal(panes) + for _, p in ipairs(panes) do + if not p.is_plugin and p.is_floating and p.is_focused then + return p + end + end + for _, p in ipairs(panes) do + if not p.is_plugin and p.is_floating then + return p + end + end + return nil +end + +---@return table[]|nil, string? +local function list_panes() + local out, err = system { 'zellij', 'action', 'list-panes', '--json', '--geometry', '--state' } + if err then + return nil, err + end + local ok, decoded = pcall(vim.json.decode, out) + if not ok or type(decoded) ~= 'table' then + return nil, 'failed to parse list-panes JSON' + end + return decoded, nil +end + +---@param panes table[] +---@return table +local function terminal_ids_set(panes) + local set = {} + for _, p in ipairs(panes) do + if not p.is_plugin then + set[p.id] = true + end + end + return set +end + +---@param dir string +---@return table|nil, string? +local function split_pane(dir) + local before, err = list_panes() + if err then + return nil, err + end + if not before then + return nil, 'list-panes returned no data' + end + local before_ids = terminal_ids_set(before) + local _, split_err = system { 'zellij', 'action', 'new-pane', '--direction', dir } + if split_err then + return nil, split_err + end + local after, err2 = list_panes() + if err2 then + return nil, err2 + end + if not after then + return nil, 'list-panes returned no data after split' + end + for _, p in ipairs(after) do + if not p.is_plugin and not before_ids[p.id] then + return p, nil + end + end + return nil, 'could not find new pane after split' +end + +---@return table|nil, string? +local function new_floating_pane() + local before, err = list_panes() + if err then + return nil, err + end + if not before then + return nil, 'list-panes returned no data' + end + local before_ids = terminal_ids_set(before) + local _, split_err = system { 'zellij', 'action', 'new-pane', '--floating' } + if split_err then + return nil, split_err + end + local after, err2 = list_panes() + if err2 then + return nil, err2 + end + if not after then + return nil, 'list-panes returned no data after new floating pane' + end + for _, p in ipairs(after) do + if not p.is_plugin and not before_ids[p.id] then + return p, nil + end + end + return nil, 'could not find new floating pane' +end + +---@param cmd string +---@param pane_id string +---@return string? +local function write_chars(cmd, pane_id) + local _, err = system { 'zellij', 'action', 'write-chars', cmd .. '\n', '--pane-id', pane_id } + return err +end + +---@param cmd string +local function zellij_run(cmd) + local panes, err = list_panes() + if err then + notify(err, 'error') + return + end + if not panes then + notify('list-panes returned no data', 'error') + return + end + + local direction = directions[require('command').CommandDirection] + if not direction or not direction.new then + notify('invalid pane direction', 'error') + return + end + + local target + + if direction.new == 'floating' then + target = find_floating_terminal(panes) + if not target then + local fpane, ferr = new_floating_pane() + if ferr then + notify(ferr, 'error') + return + end + if not fpane then + notify('could not create floating pane', 'error') + return + end + target = fpane + end + else + if not direction.split then + notify('invalid pane direction', 'error') + return + end + local focused = find_focused_terminal(panes) + if not focused then + notify('could not find focused terminal pane in Zellij', 'error') + return + end + local neighbor + if direction.new == 'right' then + neighbor = find_right_neighbor(focused, panes) + elseif direction.new == 'down' then + neighbor = find_down_neighbor(focused, panes) + end + target = neighbor + if not target then + local new_pane, split_err = split_pane(direction.split) + if split_err then + notify(split_err, 'error') + return + end + if not new_pane then + notify('split did not create a new pane', 'error') + return + end + target = new_pane + end + end + + local pane_id = terminal_pane_id(target) + if not pane_id then + notify('target pane has no terminal id', 'error') + return + end + + local werr = write_chars(cmd, pane_id) + if werr then + notify(werr, 'error') + end +end + +--- @type backend +local M = { + run = zellij_run, + directions = directions, +} + +return M diff --git a/plugin/command.lua b/plugin/command.lua index 29d531b..95dcbe7 100644 --- a/plugin/command.lua +++ b/plugin/command.lua @@ -27,6 +27,8 @@ local opts = vim.tbl_deep_extend('force', default_opts, vim.g.command or {}) if not opts.backend then if vim.env.TMUX then opts.backend = 'tmux' + elseif vim.env.ZELLIJ or vim.env.ZELLIJ_SESSION_NAME then + opts.backend = 'zellij' elseif vim.env.TERM == 'wezterm' then opts.backend = 'wezterm' elseif pcall(require, 'toggleterm') then