Add jj and herdr
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
---herdr-splits shared config file (generated artifact) + notation translator.
|
||||
---Owns everything about `herdr-splits.conf`: path resolution, reading existing
|
||||
---managed values, translating Neovim key notation to Herdr chord notation,
|
||||
---and writing the resolved config back (atomically). Requires nothing else
|
||||
---from this plugin (avoiding a require-cycle with config.lua).
|
||||
---@class HerdrSplitsConf
|
||||
local M = {}
|
||||
|
||||
-- Neovim modifier prefix -> Herdr modifier name.
|
||||
local MOD = { C = 'ctrl', M = 'alt', A = 'alt', S = 'shift', D = 'cmd' }
|
||||
-- Herdr's documented specials: lowercase the terminal key when present.
|
||||
local SPECIAL = {
|
||||
left = 'left', right = 'right', up = 'up', down = 'down',
|
||||
enter = 'enter', ['return'] = 'enter', tab = 'tab',
|
||||
esc = 'esc', escape = 'esc', space = 'space', backspace = 'backspace',
|
||||
del = 'delete', delete = 'delete', home = 'home',
|
||||
['end'] = 'end', pageup = 'pageup', pagedown = 'pagedown',
|
||||
}
|
||||
|
||||
-- Marker line stamped into every generated conf. Its presence means the file
|
||||
-- is the plugin's own output: setup() is authoritative, so read_managed() does
|
||||
-- NOT adopt it back (removing an opt reverts to the default on the next start).
|
||||
local MARKER = '# herdr-splits-generated-v1'
|
||||
|
||||
local uv = vim.uv or vim.loop
|
||||
|
||||
-- Lazily-resolved cache of the shared config path, so setup() shells out to
|
||||
-- `herdr plugin config-dir` at most once per process.
|
||||
local resolved_path
|
||||
|
||||
---Resolve the herdr binary: a config.herdr_bin override (when the config
|
||||
---module is loaded), then HERDR_BIN_PATH, then 'herdr'. pcall-required so
|
||||
---conf.lua never hard-requires config.lua at load time (cycle-safe).
|
||||
---@return string
|
||||
local function resolve_herdr_bin()
|
||||
local ok, cfg = pcall(require, 'herdr-splits.config')
|
||||
if ok and cfg.herdr_bin then return cfg.herdr_bin end
|
||||
return vim.env.HERDR_BIN_PATH or 'herdr'
|
||||
end
|
||||
|
||||
---Best-effort `herdr plugin config-dir herdr-splits` query. Returns the
|
||||
---directory printed by herdr (trimmed), or nil on any failure / empty output.
|
||||
---@return string|nil
|
||||
local function query_config_dir()
|
||||
local ok, obj = pcall(vim.system, { resolve_herdr_bin(), 'plugin', 'config-dir', 'herdr-splits' }, { text = true })
|
||||
if not ok or not obj then return nil end
|
||||
local wok, res = pcall(obj.wait, obj)
|
||||
if not wok or not res or res.code ~= 0 then return nil end
|
||||
local out = (res.stdout or ''):gsub('^%s+', ''):gsub('%s+$', '')
|
||||
return out ~= '' and out or nil
|
||||
end
|
||||
|
||||
---Path to the shared herdr-splits config file (same file the Herdr-side
|
||||
---scripts read). Precedence: (1) HERDR_SPLITS_CONFIG; (2) HERDR_PLUGIN_CONFIG_DIR
|
||||
---+ `/herdr-splits.conf`; (3) `herdr plugin config-dir herdr-splits` (via the
|
||||
---herdr binary) + `/herdr-splits.conf`; (4) XDG_CONFIG_HOME / ~/.config
|
||||
---fallback. Resolved once and cached, so setup() shells out at most once.
|
||||
---@return string
|
||||
function M.path()
|
||||
if resolved_path then return resolved_path end
|
||||
if vim.env.HERDR_SPLITS_CONFIG and vim.env.HERDR_SPLITS_CONFIG ~= '' then
|
||||
resolved_path = vim.env.HERDR_SPLITS_CONFIG
|
||||
return resolved_path
|
||||
end
|
||||
if vim.env.HERDR_PLUGIN_CONFIG_DIR and vim.env.HERDR_PLUGIN_CONFIG_DIR ~= '' then
|
||||
resolved_path = vim.env.HERDR_PLUGIN_CONFIG_DIR .. '/herdr-splits.conf'
|
||||
return resolved_path
|
||||
end
|
||||
local dir = query_config_dir()
|
||||
if dir then
|
||||
resolved_path = dir .. '/herdr-splits.conf'
|
||||
return resolved_path
|
||||
end
|
||||
local xdg = vim.env.XDG_CONFIG_HOME
|
||||
local base = (xdg and xdg:sub(1, 1) == '/') and xdg
|
||||
or ((vim.env.HOME or '~') .. '/.config')
|
||||
resolved_path = base .. '/herdr/plugins/config/herdr-splits/herdr-splits.conf'
|
||||
return resolved_path
|
||||
end
|
||||
|
||||
---Translate a Neovim key notation (e.g. `<C-h>`, `<M-Left>`, `h`) into the
|
||||
---Herdr chord notation the scripts and `herdr pane send-keys` expect
|
||||
---(e.g. `ctrl+h`, `alt+left`, `h`). Returns nil for modifier-only / empty
|
||||
---input so callers can fall back to a per-direction default.
|
||||
---@param nvim_key string
|
||||
---@return string|nil
|
||||
function M.to_herdr(nvim_key)
|
||||
if type(nvim_key) ~= 'string' or nvim_key == '' then return nil end
|
||||
if not nvim_key:match('^<.+>$') then return nvim_key:lower() end -- plain "h" -> "h"
|
||||
local inner = nvim_key:sub(2, -2)
|
||||
local mods, key = {}, nil
|
||||
for part in inner:gmatch('[^-]+') do
|
||||
local m = MOD[part:upper()]
|
||||
if m then
|
||||
mods[#mods + 1] = m
|
||||
else
|
||||
key = SPECIAL[part:lower()] or part:lower()
|
||||
end
|
||||
end
|
||||
if not key then return nil end
|
||||
mods[#mods + 1] = key
|
||||
return table.concat(mods, '+')
|
||||
end
|
||||
|
||||
---Parse the shared config file for the managed keys only. When the file
|
||||
---carries the generated MARKER it is the plugin's own output and is NOT read
|
||||
---back (setup() is authoritative — removing an opt reverts to default). When
|
||||
---the MARKER is absent (a legacy/hand-edited conf) the managed values are
|
||||
---parsed and adopted once; the second return value is true only when at
|
||||
---least one managed value was found, so the caller can emit a one-time
|
||||
---migration notice. Returns `({}, false)` for a missing/marker-tagged file;
|
||||
---last occurrence wins (matches the scripts' `tail -n 1`).
|
||||
---@return table values, boolean adopted
|
||||
function M.read_managed()
|
||||
local f = io.open(M.path(), 'r')
|
||||
if not f then return {}, false end
|
||||
local out = { nav_keys = {}, resize_keys = {} }
|
||||
for line in f:lines() do
|
||||
-- Generated marker anywhere => our own output; do not adopt it back.
|
||||
if line:match('^%s*' .. vim.pesc(MARKER) .. '%s*$') then
|
||||
f:close()
|
||||
return {}, false
|
||||
end
|
||||
local k, v = line:match('^%s*([%w_]+)%s*=%s*([^%s#]+)')
|
||||
if k and v then
|
||||
local dir = k:match('^nav_key_(%a+)$')
|
||||
if dir then
|
||||
out.nav_keys[dir] = v
|
||||
else
|
||||
dir = k:match('^resize_key_(%a+)$')
|
||||
if dir then
|
||||
out.resize_keys[dir] = v
|
||||
elseif k == 'unzoom_on_nav' then
|
||||
out.unzoom_on_nav = (v ~= 'false')
|
||||
elseif k == 'nav_at_edge' then
|
||||
out.nav_at_edge = (v == 'stop' and 'stop' or 'wrap')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
f:close()
|
||||
local adopted = false
|
||||
for _, t in pairs({ out.nav_keys, out.resize_keys }) do
|
||||
for _ in pairs(t) do adopted = true; break end
|
||||
end
|
||||
if out.unzoom_on_nav ~= nil or out.nav_at_edge ~= nil then adopted = true end
|
||||
return out, adopted
|
||||
end
|
||||
|
||||
---Write the full managed key set to the shared config with a generated-file
|
||||
---header (including the MARKER), atomically via a unique same-dir temp +
|
||||
---os.rename so a script never observes a partial write and two concurrent
|
||||
---Neovim instances don't share a temp inode. Skips the write entirely when the
|
||||
---content is unchanged (preserves mtime, reduces multi-instance contention).
|
||||
---Returns (true, nil) on success or (false, err) on failure; callers wrap in
|
||||
---pcall so a write failure never crashes startup.
|
||||
---@param resolved table resolved managed config (nav_keys, resize_keys, unzoom_on_nav, nav_at_edge)
|
||||
---@return boolean ok, string|nil err
|
||||
function M.write(resolved)
|
||||
local path = M.path()
|
||||
local lines = {
|
||||
MARKER,
|
||||
'# herdr-splits plugin config — GENERATED by herdr-splits.nvim setup().',
|
||||
'# Regenerated from defaults + setup() opts on every setup(); do not edit by hand.',
|
||||
'# A headerless/legacy conf is adopted once on migration (see the startup',
|
||||
'# notice), then overwritten thereafter — remove an opt in setup() to revert.',
|
||||
'nav_key_left=' .. resolved.nav_keys.left,
|
||||
'nav_key_down=' .. resolved.nav_keys.down,
|
||||
'nav_key_up=' .. resolved.nav_keys.up,
|
||||
'nav_key_right=' .. resolved.nav_keys.right,
|
||||
'resize_key_left=' .. resolved.resize_keys.left,
|
||||
'resize_key_down=' .. resolved.resize_keys.down,
|
||||
'resize_key_up=' .. resolved.resize_keys.up,
|
||||
'resize_key_right=' .. resolved.resize_keys.right,
|
||||
'unzoom_on_nav=' .. (resolved.unzoom_on_nav == false and 'false' or 'true'),
|
||||
'nav_at_edge=' .. (resolved.nav_at_edge == 'stop' and 'stop' or 'wrap'),
|
||||
}
|
||||
local content = table.concat(lines, '\n') .. '\n'
|
||||
|
||||
-- Skip when unchanged: only when the existing file's bytes match exactly.
|
||||
local existing = io.open(path, 'r')
|
||||
if existing then
|
||||
local prev = existing:read('*a')
|
||||
existing:close()
|
||||
if prev == content then return true end
|
||||
end
|
||||
|
||||
vim.fn.mkdir(vim.fn.fnamemodify(path, ':h'), 'p')
|
||||
-- Unique same-dir temp so os.rename stays same-filesystem (atomic) and two
|
||||
-- Neovim instances don't collide on a shared temp inode.
|
||||
local tmp = path .. '.tmp.' .. vim.fn.getpid() .. '.' .. tostring(uv.hrtime())
|
||||
local f, err = io.open(tmp, 'w')
|
||||
if not f then
|
||||
pcall(os.remove, tmp)
|
||||
vim.notify('herdr-splits: failed to open conf temp file: ' .. tostring(err), vim.log.levels.WARN)
|
||||
return false, err
|
||||
end
|
||||
local wok, werr = f:write(content)
|
||||
-- file:close() flushes buffered output and can itself fail with a delayed
|
||||
-- write error (disk full, NFS, quota). Its result MUST be checked before
|
||||
-- os.rename: otherwise an incomplete temp replaces the live config and the
|
||||
-- atomic-write guarantee is lost. LuaJIT returns true on success or
|
||||
-- (nil, err) on a flush failure — same form as file:write, checked here too.
|
||||
local cok, cerr = f:close()
|
||||
if (not wok) or (not cok) then
|
||||
local why = (not wok) and werr or cerr
|
||||
pcall(os.remove, tmp)
|
||||
vim.notify('herdr-splits: failed to write conf temp file: ' .. tostring(why), vim.log.levels.WARN)
|
||||
return false, why
|
||||
end
|
||||
local ok, rerr = os.rename(tmp, path)
|
||||
if not ok then
|
||||
pcall(os.remove, tmp)
|
||||
vim.notify('herdr-splits: failed to write conf: ' .. tostring(rerr), vim.log.levels.WARN)
|
||||
end
|
||||
return ok, rerr
|
||||
end
|
||||
|
||||
---Runnable self-check for the notation translator. Asserts on the documented
|
||||
---mapping table; returns true on success. Intended for headless validation:
|
||||
---`nvim --headless -c "lua assert(require('herdr-splits.conf')._selfcheck())" -c "qa"`.
|
||||
---@return boolean
|
||||
function M._selfcheck()
|
||||
local cases = {
|
||||
{ '<C-h>', 'ctrl+h' }, { '<C-j>', 'ctrl+j' }, { '<C-k>', 'ctrl+k' }, { '<C-l>', 'ctrl+l' },
|
||||
{ '<M-h>', 'alt+h' }, { '<M-Left>', 'alt+left' }, { '<S-Left>', 'shift+left' },
|
||||
{ '<D-x>', 'cmd+x' }, { '<C-M-h>', 'ctrl+alt+h' }, { 'h', 'h' }, { '<Esc>', 'esc' },
|
||||
}
|
||||
for _, c in ipairs(cases) do
|
||||
local got = M.to_herdr(c[1])
|
||||
assert(got == c[2], ('to_herdr(%q)=%q want %q'):format(c[1], tostring(got), c[2]))
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,146 @@
|
||||
---@class HerdrSplitsConfig
|
||||
---@field default_amount number Resize amount as Herdr ratio (float, e.g. 0.03 = 3%)
|
||||
---@field neovim_amount number Resize amount for native Neovim resizes (integer cells, default 3)
|
||||
---@field at_edge 'wrap'|'stop'|'split'|function Neovim window-edge behavior (distinct from nav_at_edge below)
|
||||
---@field ignored_buftypes string[] Buffer types ignored during resize
|
||||
---@field ignored_filetypes string[] Filetypes ignored during resize
|
||||
---@field move_cursor_same_row boolean Keep cursor on same screen row when moving left/right
|
||||
---@field herdr_bin string|nil Path to herdr binary (auto-detected if nil)
|
||||
---@field ignored_events string[] Autocmd events to ignore during resize operations
|
||||
---@field auto_sync_herdr boolean|nil If true, auto-sync the Herdr-managed checkout to match this lazy commit (opt-in; default false)
|
||||
---@field floating_zindex_max number Threshold below which a floating window's zindex classifies it as an embedded sidebar (default 50; Neovim's default float zindex)
|
||||
---@field ignore_previewwindows boolean If true, vim.wo[winid].previewwindow windows (e.g. dadbod `.dbout`) are treated as sidebars (opt-in; default false)
|
||||
--- Managed config (written to herdr-splits.conf by setup()). Pass Neovim
|
||||
--- notation (e.g. `<C-h>`, `<M-Left>`); stored/written in Herdr notation.
|
||||
---@field nav_keys table<string,string> Forward chords per direction: {left,down,up,right} (Neovim notation in opts; Herdr notation internally)
|
||||
---@field resize_keys table<string,string> Resize forward chords per direction: {left,down,up,right} (Neovim notation in opts; Herdr notation internally)
|
||||
---@field unzoom_on_nav boolean If true, auto-unzoom when navigating away from a zoomed pane (default true)
|
||||
---@field nav_at_edge 'wrap'|'stop' Herdr pane-boundary wrap behavior (distinct from at_edge, which is the Neovim window-edge behavior)
|
||||
|
||||
-- Managed config defaults (Herdr notation). The single source of truth for
|
||||
-- the key set written to herdr-splits.conf; deep-copied into M below and into
|
||||
-- the setup() merge base so the two cannot drift.
|
||||
local managed_defaults = {
|
||||
nav_keys = { left = 'ctrl+h', down = 'ctrl+j', up = 'ctrl+k', right = 'ctrl+l' },
|
||||
resize_keys = { left = 'alt+h', down = 'alt+j', up = 'alt+k', right = 'alt+l' },
|
||||
unzoom_on_nav = true,
|
||||
nav_at_edge = 'wrap',
|
||||
}
|
||||
|
||||
local M = {
|
||||
default_amount = 0.03,
|
||||
neovim_amount = 3,
|
||||
at_edge = 'wrap',
|
||||
ignored_buftypes = { 'nofile', 'quickfix', 'prompt', 'help', 'terminal' },
|
||||
ignored_filetypes = {
|
||||
'NvimTree',
|
||||
-- sidebars
|
||||
'neo-tree',
|
||||
'snacks_dashboard',
|
||||
'snacks_explorer',
|
||||
'snacks_picker',
|
||||
-- DB / REPL / data sidebars
|
||||
'dadbod-ui',
|
||||
'dbout',
|
||||
-- outlines / symbols
|
||||
'aerial',
|
||||
'Outline',
|
||||
-- diagnostics / quick lists
|
||||
'Trouble',
|
||||
'quickfix',
|
||||
},
|
||||
move_cursor_same_row = false,
|
||||
herdr_bin = nil,
|
||||
ignored_events = { 'BufEnter', 'WinEnter' },
|
||||
auto_sync_herdr = false,
|
||||
floating_zindex_max = 50,
|
||||
ignore_previewwindows = false,
|
||||
-- Managed config: deep-copied from managed_defaults (single source).
|
||||
-- Stored in Herdr chord notation; setup() translates user Neovim notation
|
||||
-- and writes these to herdr-splits.conf for the scripts.
|
||||
nav_keys = vim.deepcopy(managed_defaults.nav_keys),
|
||||
resize_keys = vim.deepcopy(managed_defaults.resize_keys),
|
||||
unzoom_on_nav = managed_defaults.unzoom_on_nav,
|
||||
nav_at_edge = managed_defaults.nav_at_edge,
|
||||
}
|
||||
|
||||
local conf = require('herdr-splits.conf')
|
||||
|
||||
---Apply user configuration on top of defaults and publish the managed keys
|
||||
---to the shared `herdr-splits.conf` (so the Herdr-side scripts agree).
|
||||
---Idempotent in outcome: managed keys (nav_keys/resize_keys/unzoom_on_nav/nav_at_edge)
|
||||
---rebuild from defaults + opts each call; non-managed opts merge into existing config.
|
||||
---
|
||||
---Managed keys (nav_keys/resize_keys/unzoom_on_nav/nav_at_edge) resolve with
|
||||
---precedence: defaults -> existing conf value (adopt-once) -> explicit opt.
|
||||
---User chords are given in Neovim notation and translated to Herdr notation
|
||||
---before merging; the resolved set is written to the conf atomically (write
|
||||
---failures never crash startup).
|
||||
---@param opts table|nil
|
||||
function M.setup(opts)
|
||||
opts = opts or {}
|
||||
|
||||
-- Apply herdr_bin from opts before the first conf.path() resolution so the
|
||||
-- documented precedence (config.herdr_bin -> HERDR_BIN_PATH -> 'herdr') holds.
|
||||
if opts.herdr_bin ~= nil then M.herdr_bin = opts.herdr_bin end
|
||||
|
||||
-- Managed opts: translate user chords nvim -> herdr, capture only what was passed.
|
||||
local user = {}
|
||||
if opts.nav_keys then
|
||||
user.nav_keys = {}
|
||||
for dir, k in pairs(opts.nav_keys) do user.nav_keys[dir] = conf.to_herdr(k) end
|
||||
end
|
||||
if opts.resize_keys then
|
||||
user.resize_keys = {}
|
||||
for dir, k in pairs(opts.resize_keys) do user.resize_keys[dir] = conf.to_herdr(k) end
|
||||
end
|
||||
if opts.unzoom_on_nav ~= nil then user.unzoom_on_nav = opts.unzoom_on_nav end
|
||||
if opts.nav_at_edge ~= nil then user.nav_at_edge = opts.nav_at_edge end
|
||||
|
||||
-- Precedence: defaults -> legacy/headerless conf (adopt once) -> explicit
|
||||
-- user opts. read_managed returns {} (no adoption) once the conf carries
|
||||
-- our generated marker, so removing an opt reverts to the default next start.
|
||||
local existing, migrated = conf.read_managed()
|
||||
local resolved = vim.deepcopy(managed_defaults)
|
||||
resolved = vim.tbl_deep_extend('force', resolved, existing)
|
||||
resolved = vim.tbl_deep_extend('force', resolved, user)
|
||||
|
||||
M.nav_keys, M.resize_keys = resolved.nav_keys, resolved.resize_keys
|
||||
M.unzoom_on_nav, M.nav_at_edge = resolved.unzoom_on_nav, resolved.nav_at_edge
|
||||
|
||||
-- One-time migration notice: a headerless/legacy conf was adopted. Fires
|
||||
-- once — after this setup() the conf carries the marker, so later setups
|
||||
-- don't adopt. Tell the user to move these into setup() to persist them.
|
||||
if migrated then
|
||||
local parts = {}
|
||||
for _, kind in ipairs({ 'nav_keys', 'resize_keys' }) do
|
||||
for dir, v in pairs(existing[kind] or {}) do
|
||||
parts[#parts + 1] = ('%s.%s=%s'):format(kind, dir, v)
|
||||
end
|
||||
end
|
||||
if existing.unzoom_on_nav ~= nil then
|
||||
parts[#parts + 1] = 'unzoom_on_nav=' .. tostring(existing.unzoom_on_nav)
|
||||
end
|
||||
if existing.nav_at_edge ~= nil then
|
||||
parts[#parts + 1] = 'nav_at_edge=' .. existing.nav_at_edge
|
||||
end
|
||||
vim.notify(
|
||||
'herdr-splits: adopted legacy conf values (' .. table.concat(parts, ', ')
|
||||
.. '). Add them to setup() (Neovim notation) to keep them — the conf is '
|
||||
.. 'now regenerated on every setup().',
|
||||
vim.log.levels.INFO
|
||||
)
|
||||
end
|
||||
|
||||
-- Non-managed opts (default_amount, at_edge, ignored_filetypes, …) merge as before.
|
||||
local other = vim.deepcopy(opts)
|
||||
other.nav_keys, other.resize_keys = nil, nil
|
||||
other.unzoom_on_nav, other.nav_at_edge = nil, nil
|
||||
local merged = vim.tbl_deep_extend('force', M, other)
|
||||
for k, v in pairs(merged) do M[k] = v end
|
||||
|
||||
-- Publish to the shared conf for the Herdr scripts (never fatal).
|
||||
pcall(conf.write, resolved)
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,46 @@
|
||||
---:checkhealth herdr-splits integration.
|
||||
---Discovered automatically by Neovim's built-in `:checkhealth` via the
|
||||
---runtimefile lookup in `lua/<plugin>/health.lua`.
|
||||
---Requires Neovim ≥ 0.10 for `vim.health`.
|
||||
|
||||
local M = {}
|
||||
|
||||
function M.check()
|
||||
local config = require('herdr-splits.config')
|
||||
local herdr = require('herdr-splits.herdr')
|
||||
local win = require('herdr-splits.win')
|
||||
|
||||
vim.health.start('herdr-splits')
|
||||
|
||||
if herdr.is_in_session() then
|
||||
vim.health.ok(
|
||||
string.format(
|
||||
'Running inside a Herdr session (HERDR_ENV=1, HERDR_PANE_ID=%s)',
|
||||
tostring(vim.env.HERDR_PANE_ID)
|
||||
)
|
||||
)
|
||||
else
|
||||
vim.health.warn(
|
||||
'Not inside a Herdr session (HERDR_ENV='
|
||||
.. tostring(vim.env.HERDR_ENV)
|
||||
.. ') — nav/resize will not cross pane boundaries.'
|
||||
)
|
||||
end
|
||||
|
||||
vim.health.info(string.format('ignored_buftypes: %s', vim.inspect(config.ignored_buftypes)))
|
||||
vim.health.info(string.format('ignored_filetypes: %s', vim.inspect(config.ignored_filetypes)))
|
||||
vim.health.info(string.format('floating_zindex_max: %s', tostring(config.floating_zindex_max)))
|
||||
vim.health.info(string.format('ignore_previewwindows: %s', tostring(config.ignore_previewwindows)))
|
||||
|
||||
if win.is_embedded_floating_window() then
|
||||
vim.health.warn('Current window is an embedded floating window (zindex < 50) — treat as sidebar.')
|
||||
end
|
||||
if win.is_ignored_win() then
|
||||
vim.health.info('Current window matches an ignored filetype/buftype.')
|
||||
end
|
||||
if win.is_floating() and not win.is_embedded_floating_window() then
|
||||
vim.health.info('Current window is a true floating popup — nav/resize will forward to Herdr.')
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,160 @@
|
||||
---Herdr CLI wrapper. All Herdr subprocess calls go through this module.
|
||||
---@class HerdrSplitsHerdr
|
||||
local M = {}
|
||||
|
||||
local config = require('herdr-splits.config')
|
||||
|
||||
---Resolve the herdr binary path.
|
||||
---@return string
|
||||
function M.herdr_bin()
|
||||
return config.herdr_bin or vim.env.HERDR_BIN_PATH or 'herdr'
|
||||
end
|
||||
|
||||
---Check if Neovim is running inside a Herdr session.
|
||||
---@return boolean
|
||||
function M.is_in_session()
|
||||
return vim.env.HERDR_ENV == '1' and vim.env.HERDR_PANE_ID ~= nil and #vim.env.HERDR_PANE_ID > 0
|
||||
end
|
||||
|
||||
---Get the current Herdr pane ID.
|
||||
---@return string|nil
|
||||
function M.current_pane_id()
|
||||
if not M.is_in_session() then
|
||||
return nil
|
||||
end
|
||||
return vim.env.HERDR_PANE_ID
|
||||
end
|
||||
|
||||
---Run a herdr CLI command and return stdout, stderr, and exit code.
|
||||
---@param args string[]
|
||||
---@return string stdout, string stderr, number exit_code
|
||||
local function herdr_exec(args)
|
||||
local cmd = vim.list_extend({ M.herdr_bin() }, args, 1, #args)
|
||||
local obj = vim.system(cmd, { text = true }):wait()
|
||||
return obj.stdout, obj.stderr, obj.code
|
||||
end
|
||||
|
||||
---Check if the current Herdr pane is at the layout boundary in the given direction.
|
||||
---Calls `herdr pane edges --current` and parses the JSON response.
|
||||
---@param direction '"left"'|'"right"'|'"up"'|'"down"'
|
||||
---@return boolean|nil true if at edge, false if neighbor exists, nil on error
|
||||
function M.current_pane_at_edge(direction)
|
||||
if not M.is_in_session() then
|
||||
return nil
|
||||
end
|
||||
|
||||
local edge_key = direction
|
||||
local stdout, _, code = herdr_exec({ 'pane', 'edges', '--current' })
|
||||
|
||||
if code ~= 0 or not stdout or #stdout == 0 then
|
||||
return nil
|
||||
end
|
||||
|
||||
local ok, data = pcall(vim.json.decode, stdout)
|
||||
if
|
||||
not ok
|
||||
or type(data) ~= 'table'
|
||||
or type(data.result) ~= 'table'
|
||||
or type(data.result.edges) ~= 'table'
|
||||
then
|
||||
return nil
|
||||
end
|
||||
|
||||
local at_edge = data.result.edges[edge_key]
|
||||
if type(at_edge) ~= 'boolean' then
|
||||
return nil
|
||||
end
|
||||
return at_edge
|
||||
end
|
||||
|
||||
---Check if the current Herdr pane is zoomed.
|
||||
---Calls `herdr pane layout --current` and parses JSON.
|
||||
---@return boolean|nil true if zoomed, false if not, nil on error
|
||||
function M.current_pane_is_zoomed()
|
||||
if not M.is_in_session() then
|
||||
return nil
|
||||
end
|
||||
|
||||
local stdout, _, code = herdr_exec({ 'pane', 'layout', '--current' })
|
||||
|
||||
if code ~= 0 or not stdout or #stdout == 0 then
|
||||
return nil
|
||||
end
|
||||
|
||||
local ok, data = pcall(vim.json.decode, stdout)
|
||||
if
|
||||
not ok
|
||||
or type(data) ~= 'table'
|
||||
or type(data.result) ~= 'table'
|
||||
or type(data.result.layout) ~= 'table'
|
||||
then
|
||||
return nil
|
||||
end
|
||||
|
||||
local zoomed = data.result.layout.zoomed
|
||||
if type(zoomed) ~= 'boolean' then
|
||||
return nil
|
||||
end
|
||||
return zoomed
|
||||
end
|
||||
|
||||
---Focus a Herdr pane in the given direction.
|
||||
---Calls `herdr pane focus --direction <dir> --current`.
|
||||
---@param direction '"left"'|'"right"'|'"up"'|'"down"'
|
||||
---@return boolean true on success
|
||||
function M.focus_pane(direction)
|
||||
if not M.is_in_session() then
|
||||
return false
|
||||
end
|
||||
|
||||
local _, _, code = herdr_exec({ 'pane', 'focus', '--direction', direction, '--current' })
|
||||
return code == 0
|
||||
end
|
||||
|
||||
---Unzoom the current Herdr pane (turn off zoom).
|
||||
---Calls `herdr pane zoom --off --current`.
|
||||
---@return boolean true on success
|
||||
function M.unzoom()
|
||||
if not M.is_in_session() then
|
||||
return false
|
||||
end
|
||||
|
||||
local _, _, code = herdr_exec({ 'pane', 'zoom', '--off', '--current' })
|
||||
return code == 0
|
||||
end
|
||||
|
||||
---Check whether auto-unzoom is enabled.
|
||||
---Reads `config.unzoom_on_nav` (resolved from setup(); default: enabled).
|
||||
---@return boolean
|
||||
function M.unzoom_enabled()
|
||||
return config.unzoom_on_nav ~= false
|
||||
end
|
||||
|
||||
---Edge-wrap behavior across the Herdr pane boundary, read from the resolved
|
||||
---in-memory config (the same value written to the shared conf that the
|
||||
---Herdr-side scripts read). `stop` suppresses wrap-across-boundary on both
|
||||
---the plain-pane side (handled by the Herdr script) and the Neovim
|
||||
---edge-wrap side (handled here). Anything else defaults to `wrap`.
|
||||
---@return '"wrap"'|'"stop"'
|
||||
function M.nav_at_edge()
|
||||
return config.nav_at_edge == 'stop' and 'stop' or 'wrap'
|
||||
end
|
||||
|
||||
---Resize the current Herdr pane in the given direction.
|
||||
---Calls `herdr pane resize --direction <dir> --amount <float> --current`.
|
||||
---@param direction '"left"'|'"right"'|'"up"'|'"down"'
|
||||
---@param amount number Float ratio (e.g., 0.03 = 3% of terminal dimension)
|
||||
---@return boolean true on success
|
||||
function M.resize_pane(direction, amount)
|
||||
if not M.is_in_session() then
|
||||
return false
|
||||
end
|
||||
|
||||
local amount_str = string.format('%.4f', amount)
|
||||
local _, _, code = herdr_exec({
|
||||
'pane', 'resize', '--direction', direction, '--amount', amount_str, '--current',
|
||||
})
|
||||
return code == 0
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,162 @@
|
||||
---herdr-splits.nvim — Seamless navigation and resizing between Neovim splits
|
||||
---and Herdr panes. Makes Herdr terminal splits behave like native Neovim windows.
|
||||
---
|
||||
---Usage:
|
||||
--- require('herdr-splits').setup({ ... })
|
||||
--- vim.keymap.set('n', '<C-h>', require('herdr-splits').move_cursor_left)
|
||||
--- vim.keymap.set('n', '<M-h>', require('herdr-splits').resize_left)
|
||||
|
||||
local config = require('herdr-splits.config')
|
||||
local nav = require('herdr-splits.nav')
|
||||
local resize_mod = require('herdr-splits.resize')
|
||||
local win = require('herdr-splits.win')
|
||||
local sync = require('herdr-splits.sync')
|
||||
|
||||
local M = {}
|
||||
|
||||
---Apply configuration. Idempotent — calling again merges into existing config.
|
||||
---@param opts table|nil Configuration options (see config.lua for defaults)
|
||||
function M.setup(opts)
|
||||
config.setup(opts)
|
||||
pcall(sync.sync)
|
||||
end
|
||||
|
||||
---Sync the Herdr-managed checkout to match this lazy commit.
|
||||
---Intended as a lazy.nvim `build` hook entry point; respects `auto_sync_herdr`.
|
||||
---Safe to call at any time; no-op unless opted in.
|
||||
function M.sync_herdr()
|
||||
pcall(sync.sync)
|
||||
end
|
||||
|
||||
---Resize the current split to the left.
|
||||
---Amount defaults to config.default_amount. Multiplied by vim.v.count1.
|
||||
---@param amount number|nil
|
||||
function M.resize_left(amount)
|
||||
local eventignore_orig = vim.o.eventignore
|
||||
vim.o.eventignore = table.concat(config.ignored_events, ',')
|
||||
resize_mod.is_resizing = true
|
||||
local cur_win = vim.api.nvim_get_current_win()
|
||||
local ok, err = pcall(resize_mod.resize, 'left', amount)
|
||||
if not ok then
|
||||
vim.notify('herdr-splits: resize_left failed: ' .. tostring(err), vim.log.levels.ERROR)
|
||||
end
|
||||
pcall(vim.api.nvim_set_current_win, cur_win)
|
||||
resize_mod.is_resizing = false
|
||||
vim.o.eventignore = eventignore_orig
|
||||
end
|
||||
|
||||
---Resize the current split downward.
|
||||
---@param amount number|nil
|
||||
function M.resize_down(amount)
|
||||
local eventignore_orig = vim.o.eventignore
|
||||
vim.o.eventignore = table.concat(config.ignored_events, ',')
|
||||
resize_mod.is_resizing = true
|
||||
local cur_win = vim.api.nvim_get_current_win()
|
||||
local ok, err = pcall(resize_mod.resize, 'down', amount)
|
||||
if not ok then
|
||||
vim.notify('herdr-splits: resize_down failed: ' .. tostring(err), vim.log.levels.ERROR)
|
||||
end
|
||||
pcall(vim.api.nvim_set_current_win, cur_win)
|
||||
resize_mod.is_resizing = false
|
||||
vim.o.eventignore = eventignore_orig
|
||||
end
|
||||
|
||||
---Resize the current split upward.
|
||||
---@param amount number|nil
|
||||
function M.resize_up(amount)
|
||||
local eventignore_orig = vim.o.eventignore
|
||||
vim.o.eventignore = table.concat(config.ignored_events, ',')
|
||||
resize_mod.is_resizing = true
|
||||
local cur_win = vim.api.nvim_get_current_win()
|
||||
local ok, err = pcall(resize_mod.resize, 'up', amount)
|
||||
if not ok then
|
||||
vim.notify('herdr-splits: resize_up failed: ' .. tostring(err), vim.log.levels.ERROR)
|
||||
end
|
||||
pcall(vim.api.nvim_set_current_win, cur_win)
|
||||
resize_mod.is_resizing = false
|
||||
vim.o.eventignore = eventignore_orig
|
||||
end
|
||||
|
||||
---Resize the current split to the right.
|
||||
---@param amount number|nil
|
||||
function M.resize_right(amount)
|
||||
local eventignore_orig = vim.o.eventignore
|
||||
vim.o.eventignore = table.concat(config.ignored_events, ',')
|
||||
resize_mod.is_resizing = true
|
||||
local cur_win = vim.api.nvim_get_current_win()
|
||||
local ok, err = pcall(resize_mod.resize, 'right', amount)
|
||||
if not ok then
|
||||
vim.notify('herdr-splits: resize_right failed: ' .. tostring(err), vim.log.levels.ERROR)
|
||||
end
|
||||
pcall(vim.api.nvim_set_current_win, cur_win)
|
||||
resize_mod.is_resizing = false
|
||||
vim.o.eventignore = eventignore_orig
|
||||
end
|
||||
|
||||
---Move cursor to the left, crossing into Herdr pane if at Neovim edge.
|
||||
---@param opts table|nil { same_row: boolean|nil, at_edge: string|function|nil }
|
||||
function M.move_cursor_left(opts)
|
||||
resize_mod.is_resizing = false
|
||||
local ok, err = pcall(nav.move_cursor, 'left', opts)
|
||||
if not ok then
|
||||
vim.notify('herdr-splits: move_cursor_left failed: ' .. tostring(err), vim.log.levels.ERROR)
|
||||
end
|
||||
end
|
||||
|
||||
---Move cursor downward, crossing into Herdr pane if at Neovim edge.
|
||||
---@param opts table|nil
|
||||
function M.move_cursor_down(opts)
|
||||
resize_mod.is_resizing = false
|
||||
local ok, err = pcall(nav.move_cursor, 'down', opts)
|
||||
if not ok then
|
||||
vim.notify('herdr-splits: move_cursor_down failed: ' .. tostring(err), vim.log.levels.ERROR)
|
||||
end
|
||||
end
|
||||
|
||||
---Move cursor upward, crossing into Herdr pane if at Neovim edge.
|
||||
---@param opts table|nil
|
||||
function M.move_cursor_up(opts)
|
||||
resize_mod.is_resizing = false
|
||||
local ok, err = pcall(nav.move_cursor, 'up', opts)
|
||||
if not ok then
|
||||
vim.notify('herdr-splits: move_cursor_up failed: ' .. tostring(err), vim.log.levels.ERROR)
|
||||
end
|
||||
end
|
||||
|
||||
---Move cursor to the right, crossing into Herdr pane if at Neovim edge.
|
||||
---@param opts table|nil
|
||||
function M.move_cursor_right(opts)
|
||||
resize_mod.is_resizing = false
|
||||
local ok, err = pcall(nav.move_cursor, 'right', opts)
|
||||
if not ok then
|
||||
vim.notify('herdr-splits: move_cursor_right failed: ' .. tostring(err), vim.log.levels.ERROR)
|
||||
end
|
||||
end
|
||||
|
||||
---Append a filetype to ignored_filetypes at runtime (de-duped).
|
||||
---@param name string Filetype name, e.g. 'fugitive'
|
||||
---@return boolean true if added, false if already present
|
||||
function M.add_ignored_filetype(name)
|
||||
for _, v in ipairs(config.ignored_filetypes) do
|
||||
if v == name then
|
||||
return false
|
||||
end
|
||||
end
|
||||
table.insert(config.ignored_filetypes, name)
|
||||
return true
|
||||
end
|
||||
|
||||
---Append a buftype to ignored_buftypes at runtime (de-duped).
|
||||
---@param name string Buffer type, e.g. 'help'
|
||||
---@return boolean true if added, false if already present
|
||||
function M.add_ignored_buftype(name)
|
||||
for _, v in ipairs(config.ignored_buftypes) do
|
||||
if v == name then
|
||||
return false
|
||||
end
|
||||
end
|
||||
table.insert(config.ignored_buftypes, name)
|
||||
return true
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,206 @@
|
||||
---Navigation logic: seamless movement between Neovim splits and Herdr panes.
|
||||
---@class HerdrSplitsNav
|
||||
local M = {}
|
||||
|
||||
local config = require('herdr-splits.config')
|
||||
local herdr = require('herdr-splits.herdr')
|
||||
local win = require('herdr-splits.win')
|
||||
|
||||
---Treat the current window as a sidebar that should not be navigated through.
|
||||
---Combines the configured ignore lists with the embedded-float heuristic.
|
||||
---@return boolean
|
||||
local function is_sidebar()
|
||||
return win.is_ignored_or_preview() or win.is_embedded_floating_window()
|
||||
end
|
||||
|
||||
---Split a new Neovim window using the user's placement preferences.
|
||||
---@param direction '"left"'|'"right"'|'"up"'|'"down"'
|
||||
local function split_edge(direction)
|
||||
if direction == 'left' or direction == 'right' then
|
||||
vim.cmd('vsp')
|
||||
else
|
||||
vim.cmd('sp')
|
||||
end
|
||||
end
|
||||
|
||||
---Move cursor between Neovim splits, falling through to Herdr at edges.
|
||||
---This is the core navigation function.
|
||||
---@param direction '"left"'|'"right"'|'"up"'|'"down"'
|
||||
---@param opts table|nil { same_row: boolean|nil, at_edge: string|function|nil }
|
||||
function M.move_cursor(direction, opts)
|
||||
local same_row = config.move_cursor_same_row
|
||||
local at_edge_behavior = config.at_edge
|
||||
|
||||
if type(opts) == 'table' then
|
||||
if opts.same_row ~= nil then
|
||||
same_row = opts.same_row
|
||||
end
|
||||
if opts.at_edge ~= nil then
|
||||
at_edge_behavior = opts.at_edge
|
||||
end
|
||||
end
|
||||
|
||||
-- Handle floating windows: just forward to Herdr,
|
||||
-- EXCEPT for embedded sidebars (snacks/neo-tree float/aerial float):
|
||||
-- those should stay in Neovim so the user can keep interacting
|
||||
-- with the picker.
|
||||
if win.is_floating() and not win.is_embedded_floating_window() then
|
||||
herdr.focus_pane(direction)
|
||||
return
|
||||
end
|
||||
|
||||
local dir_key = win.dir_keys[direction]
|
||||
local offset = vim.fn.winline() + vim.api.nvim_win_get_position(0)[1]
|
||||
|
||||
-- Save current window to detect if wincmd changes it
|
||||
local prev_win = vim.api.nvim_get_current_win()
|
||||
|
||||
-- Try moving within Neovim first
|
||||
local will_wrap = false
|
||||
local count = vim.v.count1
|
||||
local target_winnr = vim.fn.winnr(count .. dir_key)
|
||||
if count > 1 then
|
||||
local prev_winnr = vim.fn.winnr((count - 1) .. dir_key)
|
||||
will_wrap = target_winnr == prev_winnr
|
||||
else
|
||||
will_wrap = target_winnr == vim.fn.winnr()
|
||||
end
|
||||
|
||||
-- Command-line window (q:, q/, q?): Neovim forbids all window commands
|
||||
-- (E11). Never wincmd; at a Neovim screen edge, delegate to Herdr
|
||||
-- (subprocess-safe, does not close the cmdwin); otherwise silent no-op.
|
||||
-- Mirrors smart-splits.nvim PR #464.
|
||||
if win.is_command_line_window() then
|
||||
if will_wrap and herdr.is_in_session() then
|
||||
local at_herdr_edge = herdr.current_pane_at_edge(direction)
|
||||
if at_herdr_edge == false then
|
||||
herdr.focus_pane(direction)
|
||||
elseif at_herdr_edge == true and herdr.nav_at_edge() ~= 'stop' then
|
||||
herdr.focus_pane(win.reverse_direction[direction])
|
||||
end
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
-- Execute the wincmd
|
||||
if will_wrap and count == 1 then
|
||||
vim.cmd('wincmd ' .. dir_key)
|
||||
else
|
||||
vim.cmd(count .. 'wincmd ' .. dir_key)
|
||||
end
|
||||
|
||||
if vim.api.nvim_get_current_win() ~= prev_win then
|
||||
-- Moved within Neovim. Restore same-row if configured.
|
||||
if (direction == 'left' or direction == 'right') and same_row then
|
||||
local row = offset - vim.api.nvim_win_get_position(0)[1]
|
||||
if row > 0 then
|
||||
vim.cmd('normal! ' .. row .. 'H')
|
||||
end
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
-- We're at a Neovim edge. Try to cross into Herdr.
|
||||
if not herdr.is_in_session() then
|
||||
if will_wrap and count == 1 then
|
||||
local sidebar = is_sidebar()
|
||||
if type(at_edge_behavior) == 'function' then
|
||||
at_edge_behavior({
|
||||
direction = direction,
|
||||
split = function() split_edge(direction) end,
|
||||
is_sidebar = sidebar,
|
||||
wrap = function()
|
||||
vim.cmd('wincmd ' .. win.dir_keys_reverse[direction])
|
||||
end,
|
||||
})
|
||||
elseif at_edge_behavior == 'stop' then
|
||||
return
|
||||
elseif at_edge_behavior == 'split' then
|
||||
if not sidebar then
|
||||
split_edge(direction)
|
||||
end
|
||||
else -- 'wrap' (default)
|
||||
-- Wrap is allowed even from filetype-based sidebars (dbui, neo-tree,
|
||||
-- ...) so the user can leave them; only embedded floats stay gated.
|
||||
if not win.is_embedded_floating_window() then
|
||||
vim.cmd('wincmd ' .. win.dir_keys_reverse[direction])
|
||||
end
|
||||
end
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
-- Check zoom state: unzoom first, then retry Neovim navigation.
|
||||
-- Must happen BEFORE the at_herdr_edge check — when zoomed, the pane fills
|
||||
-- the screen so herdr reports it as being at every edge, making
|
||||
-- at_herdr_edge useless until we unzoom.
|
||||
if herdr.unzoom_enabled() and herdr.current_pane_is_zoomed() then
|
||||
herdr.unzoom()
|
||||
-- Retry wincmd — other Neovim splits may now be visible
|
||||
vim.cmd('wincmd ' .. dir_key)
|
||||
if vim.api.nvim_get_current_win() ~= prev_win then
|
||||
if (direction == 'left' or direction == 'right') and same_row then
|
||||
local row = offset - vim.api.nvim_win_get_position(0)[1]
|
||||
if row > 0 then
|
||||
vim.cmd('normal! ' .. row .. 'H')
|
||||
end
|
||||
end
|
||||
return
|
||||
end
|
||||
-- Still at edge after unzoom; fall through to Herdr edge check.
|
||||
end
|
||||
|
||||
-- Check if we're at the Herdr edge too
|
||||
local at_herdr_edge = herdr.current_pane_at_edge(direction)
|
||||
if at_herdr_edge == nil then
|
||||
if will_wrap and count == 1 and not win.is_embedded_floating_window() then
|
||||
vim.cmd('wincmd ' .. win.dir_keys_reverse[direction])
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
if not at_herdr_edge then
|
||||
-- There's a Herdr pane in this direction. Cross the boundary.
|
||||
local moved = herdr.focus_pane(direction)
|
||||
if not moved and will_wrap and count == 1 and not win.is_embedded_floating_window() then
|
||||
vim.cmd('wincmd ' .. win.dir_keys_reverse[direction])
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
-- At both Neovim AND Herdr edges (no herdr pane to cross into).
|
||||
-- Apply at_edge behavior. (Any needed unzoom already happened above.)
|
||||
if type(at_edge_behavior) == 'function' then
|
||||
at_edge_behavior({
|
||||
direction = direction,
|
||||
split = function() split_edge(direction) end,
|
||||
is_sidebar = is_sidebar(),
|
||||
wrap = function()
|
||||
vim.cmd('wincmd ' .. win.dir_keys_reverse[direction])
|
||||
end,
|
||||
})
|
||||
elseif at_edge_behavior == 'stop' then
|
||||
return
|
||||
elseif at_edge_behavior == 'split' then
|
||||
if not is_sidebar() then
|
||||
split_edge(direction)
|
||||
end
|
||||
else -- 'wrap' (default)
|
||||
-- Wrap is allowed from filetype-based sidebars (dbui, neo-tree, ...) so
|
||||
-- you can leave them at an edge; only embedded floating overlays stay gated.
|
||||
if will_wrap and count == 1 and not win.is_embedded_floating_window() then
|
||||
-- Wrap to the opposite side. If a Herdr pane exists there AND nav_at_edge
|
||||
-- allows wrap-across-boundary (the default), cross into it; otherwise
|
||||
-- wrap within Neovim. nav_at_edge=stop keeps the wrap inside Neovim.
|
||||
if herdr.nav_at_edge() ~= 'stop'
|
||||
and herdr.current_pane_at_edge(win.reverse_direction[direction]) == false
|
||||
then
|
||||
herdr.focus_pane(win.reverse_direction[direction])
|
||||
else
|
||||
vim.cmd('wincmd ' .. win.dir_keys_reverse[direction])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,108 @@
|
||||
---Resize logic: seamless resize between Neovim splits and Herdr panes.
|
||||
---
|
||||
---Mental model (same as smart-splits.nvim):
|
||||
--- The direction (up/down/left/right) is which way to move the split border.
|
||||
--- The +/- operator depends on whether current window is above/below or
|
||||
--- left/right of the neighbor being resized against.
|
||||
---
|
||||
---When at a Neovim edge with no neighbor in that direction, and the window
|
||||
---fills the terminal, the resize is forwarded to Herdr.
|
||||
---@class HerdrSplitsResize
|
||||
local M = {}
|
||||
|
||||
local config = require('herdr-splits.config')
|
||||
local herdr = require('herdr-splits.herdr')
|
||||
local win = require('herdr-splits.win')
|
||||
|
||||
M.is_resizing = false
|
||||
|
||||
---Compute the +/- operator for a vertical resize.
|
||||
---At start/middle: resize_up shrinks (-), resize_down grows (+).
|
||||
---At last (bottom): inverted — resize_up grows (+), resize_down shrinks (-).
|
||||
---@param direction '"up"'|'"down"'
|
||||
---@return '"+""'|'"-""'
|
||||
local function compute_vertical(direction)
|
||||
local pos = win.win_position(direction)
|
||||
if pos == 'start' or pos == 'middle' then
|
||||
return direction == 'down' and '+' or '-'
|
||||
end
|
||||
return direction == 'down' and '-' or '+'
|
||||
end
|
||||
|
||||
---Compute the +/- operator for a horizontal resize.
|
||||
---At start/middle: resize_left shrinks (-), resize_right grows (+).
|
||||
---At last (right): inverted — resize_left grows (+), resize_right shrinks (-).
|
||||
---@param direction '"left"'|'"right"'
|
||||
---@return '"+""'|'"-""'
|
||||
local function compute_horizontal(direction)
|
||||
local pos = win.win_position(direction)
|
||||
if pos == 'start' or pos == 'middle' then
|
||||
return direction == 'right' and '+' or '-'
|
||||
end
|
||||
return direction == 'right' and '-' or '+'
|
||||
end
|
||||
|
||||
---Resize in a direction.
|
||||
---@param direction '"left"'|'"right"'|'"up"'|'"down"'
|
||||
---@param amount number|nil Override amount in Neovim cells. When nil, uses
|
||||
--- vim.v.count1 * neovim_amount for native or count * default_amount for Herdr.
|
||||
function M.resize(direction, amount)
|
||||
local count = vim.v.count1
|
||||
local has_explicit = amount ~= nil
|
||||
|
||||
-- Embedded floating sidebars (snacks float, neo-tree float, aerial float):
|
||||
-- refuse to resize; the picker owns its own dimensions.
|
||||
if win.is_embedded_floating_window() then
|
||||
return
|
||||
end
|
||||
|
||||
-- Floating windows: forward to Herdr
|
||||
if win.is_floating() then
|
||||
local ratio = has_explicit and amount or (count * config.default_amount)
|
||||
herdr.resize_pane(direction, ratio)
|
||||
return
|
||||
end
|
||||
|
||||
-- Delegate to Herdr ONLY when at both Neovim edges in this dimension
|
||||
-- AND the window fills the terminal (full_width or full_height).
|
||||
local delegate = false
|
||||
-- Never delegate from inside a sidebar; the ignore list applies to resize
|
||||
-- too. The command-line window is exempted: it is `nofile` (so it trips this
|
||||
-- rule) yet a full-width cmdwin should still resize the Herdr pane sideways.
|
||||
local in_sidebar = win.is_ignored_or_preview()
|
||||
if direction == 'left' or direction == 'right' then
|
||||
delegate = win.is_full_width() and win.at_left_edge() and win.at_right_edge() and herdr.is_in_session()
|
||||
else
|
||||
delegate = win.is_full_height() and win.at_top_edge() and win.at_bottom_edge() and herdr.is_in_session()
|
||||
end
|
||||
if delegate and in_sidebar and not win.is_command_line_window() then
|
||||
delegate = false
|
||||
end
|
||||
|
||||
if delegate and herdr.current_pane_is_zoomed() then
|
||||
delegate = false
|
||||
end
|
||||
|
||||
if delegate then
|
||||
local ratio = (has_explicit and amount < 1) and amount or (count * config.default_amount)
|
||||
herdr.resize_pane(direction, ratio)
|
||||
return
|
||||
end
|
||||
|
||||
-- Native Neovim resize
|
||||
local cells = has_explicit and math.floor(amount) or (count * config.neovim_amount)
|
||||
if cells <= 0 then
|
||||
cells = 1
|
||||
end
|
||||
|
||||
local is_horiz = direction == 'left' or direction == 'right'
|
||||
local op = is_horiz and compute_horizontal(direction) or compute_vertical(direction)
|
||||
|
||||
if is_horiz then
|
||||
pcall(vim.cmd, 'vertical resize ' .. op .. cells)
|
||||
else
|
||||
pcall(vim.cmd, 'resize ' .. op .. cells)
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,107 @@
|
||||
---Auto-sync the Herdr-managed checkout to match the lazy.nvim checkout.
|
||||
---
|
||||
---Herdr has no `plugin update` command in v1; the only sanctioned refresh is
|
||||
---reinstall from GitHub. When lazy.nvim pulls a new commit, the bash scripts
|
||||
---under the Herdr-managed checkout stay frozen at the old commit unless the
|
||||
---user manually reinstalls. This module closes that gap: it reinstalls the
|
||||
---Herdr-managed checkout pinned to the exact commit lazy fetched, so the bash
|
||||
---scripts match the lua side byte-for-byte.
|
||||
---
|
||||
---Entirely a no-op unless `auto_sync_herdr = true` is set in setup(). Also a
|
||||
---no-op when: the herdr binary is unavailable, the plugin is installed as a
|
||||
---local link (dev mode — reinstall would be refused anyway), the plugin is not
|
||||
---installed, or the commits already match.
|
||||
local M = {}
|
||||
|
||||
local config = require('herdr-splits.config')
|
||||
|
||||
---Plugin id and GitHub source as used by `herdr plugin install`.
|
||||
local PLUGIN_ID = 'herdr-splits'
|
||||
local PLUGIN_SOURCE = 'lmilojevicc/herdr-splits.nvim'
|
||||
|
||||
---Run the sync. Safe to call at any time; never throws.
|
||||
function M.sync()
|
||||
if config.auto_sync_herdr ~= true then
|
||||
return
|
||||
end
|
||||
|
||||
local ok, err = pcall(function()
|
||||
local bin = config.herdr_bin or vim.env.HERDR_BIN_PATH or 'herdr'
|
||||
if vim.fn.executable(bin) ~= 1 then
|
||||
return
|
||||
end
|
||||
|
||||
-- Resolve this plugin's own checkout root (the lazy clone).
|
||||
local source = debug.getinfo(1, 'S').source -- "@<abs>/lua/herdr-splits/sync.lua"
|
||||
local this_file = source:sub(2) -- strip leading '@'
|
||||
local plugin_root = vim.fn.fnamemodify(this_file, ':p:h:h:h') -- up 3: file<-herdr-splits<-lua<-root
|
||||
if vim.fn.isdirectory(plugin_root .. '/.git') == 0
|
||||
and vim.fn.filereadable(plugin_root .. '/herdr-plugin.toml') == 0 then
|
||||
return -- not a real checkout
|
||||
end
|
||||
|
||||
-- Get the lazy checkout HEAD.
|
||||
local obj = vim.system({ 'git', '-C', plugin_root, 'rev-parse', 'HEAD' }, { text = true }):wait()
|
||||
if obj.code ~= 0 then
|
||||
return
|
||||
end
|
||||
local lazy_sha = vim.trim(obj.stdout or '')
|
||||
if lazy_sha == '' then
|
||||
return
|
||||
end
|
||||
|
||||
-- Get the managed install info.
|
||||
local obj2 = vim.system({ bin, 'plugin', 'list', '--plugin', PLUGIN_ID, '--json' }, { text = true }):wait()
|
||||
if obj2.code ~= 0 then
|
||||
return
|
||||
end
|
||||
|
||||
local decoded, data = pcall(vim.json.decode, obj2.stdout or '')
|
||||
if not decoded or type(data) ~= 'table' then
|
||||
return
|
||||
end
|
||||
|
||||
-- Output may be a single object or an array; find our entry.
|
||||
local entry = data
|
||||
if data[1] ~= nil then
|
||||
for _, e in ipairs(data) do
|
||||
if e.plugin_id == PLUGIN_ID then
|
||||
entry = e
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local src = entry and entry.source
|
||||
if not src then
|
||||
return
|
||||
end
|
||||
-- Dev mode (plugin link) or unknown kind: nothing to do.
|
||||
if src.kind ~= 'github' then
|
||||
return
|
||||
end
|
||||
|
||||
-- Already in sync?
|
||||
local managed_sha = vim.trim(src.resolved_commit or '')
|
||||
if managed_sha ~= '' and managed_sha:lower() == lazy_sha:lower() then
|
||||
return
|
||||
end
|
||||
|
||||
-- Reinstall pinned to the lazy commit.
|
||||
local obj3 = vim.system(
|
||||
{ bin, 'plugin', 'install', PLUGIN_SOURCE, '--ref', lazy_sha, '--yes' },
|
||||
{ text = true }
|
||||
):wait()
|
||||
if obj3.code == 0 then
|
||||
vim.notify('herdr-splits: synced Herdr-side scripts to ' .. lazy_sha:sub(1, 7), vim.log.levels.INFO)
|
||||
else
|
||||
vim.notify('herdr-splits: Herdr-side sync skipped (install failed; local link or offline)', vim.log.levels.INFO)
|
||||
end
|
||||
end)
|
||||
|
||||
if not ok then
|
||||
vim.notify('herdr-splits: sync error: ' .. tostring(err), vim.log.levels.DEBUG)
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,154 @@
|
||||
---Window utility functions for Neovim split detection.
|
||||
---@class HerdrSplitsWin
|
||||
local M = {}
|
||||
|
||||
local config = require('herdr-splits.config')
|
||||
|
||||
---Check if the current window spans the full terminal width.
|
||||
---@param winid number|nil window ID, defaults to current
|
||||
---@return boolean
|
||||
function M.is_full_width(winid)
|
||||
return vim.api.nvim_win_get_width(winid or 0) == vim.o.columns
|
||||
end
|
||||
|
||||
---Check if the current window spans the full terminal height.
|
||||
---Accounts for cmdheight, statusline, and tabline.
|
||||
---@param winid number|nil window ID, defaults to current
|
||||
---@return boolean
|
||||
function M.is_full_height(winid)
|
||||
local height = vim.o.lines - vim.o.cmdheight
|
||||
local tabpages = #vim.api.nvim_list_tabpages()
|
||||
local wins = #vim.api.nvim_tabpage_list_wins(0)
|
||||
|
||||
if (vim.o.laststatus == 1 and wins > 1) or vim.o.laststatus > 1 then
|
||||
height = height - 1
|
||||
end
|
||||
if (vim.o.showtabline == 1 and tabpages > 1) or vim.o.showtabline == 2 then
|
||||
height = height - 1
|
||||
end
|
||||
|
||||
return vim.api.nvim_win_get_height(winid or 0) == height
|
||||
end
|
||||
|
||||
---@return boolean
|
||||
function M.at_left_edge()
|
||||
return vim.fn.winnr() == vim.fn.winnr('h')
|
||||
end
|
||||
|
||||
---@return boolean
|
||||
function M.at_right_edge()
|
||||
return vim.fn.winnr() == vim.fn.winnr('l')
|
||||
end
|
||||
|
||||
---@return boolean
|
||||
function M.at_top_edge()
|
||||
return vim.fn.winnr() == vim.fn.winnr('k')
|
||||
end
|
||||
|
||||
---@return boolean
|
||||
function M.at_bottom_edge()
|
||||
return vim.fn.winnr() == vim.fn.winnr('j')
|
||||
end
|
||||
|
||||
---Determine where the current window sits in the Neovim split layout
|
||||
---for a given direction (horizontal or vertical).
|
||||
---@param direction '"left"'|'"right"'|'"up"'|'"down"'
|
||||
---@return '"start"'|'"middle"'|'"last"'
|
||||
function M.win_position(direction)
|
||||
if direction == 'left' or direction == 'right' then
|
||||
if M.at_left_edge() then
|
||||
return 'start'
|
||||
end
|
||||
if M.at_right_edge() then
|
||||
return 'last'
|
||||
end
|
||||
return 'middle'
|
||||
end
|
||||
|
||||
if M.at_top_edge() then
|
||||
return 'start'
|
||||
end
|
||||
if M.at_bottom_edge() then
|
||||
return 'last'
|
||||
end
|
||||
return 'middle'
|
||||
end
|
||||
|
||||
---Check if a window should be ignored during resize operations.
|
||||
---@param winid number|nil window ID, defaults to current
|
||||
---@return boolean
|
||||
function M.is_ignored_win(winid)
|
||||
local bufnr = vim.api.nvim_win_get_buf(winid or 0)
|
||||
return vim.tbl_contains(config.ignored_buftypes, vim.api.nvim_get_option_value('buftype', { buf = bufnr }))
|
||||
or vim.tbl_contains(config.ignored_filetypes, vim.api.nvim_get_option_value('filetype', { buf = bufnr }))
|
||||
end
|
||||
|
||||
---Check if the current window is a floating window.
|
||||
---@param winid number|nil
|
||||
---@return boolean
|
||||
function M.is_floating(winid)
|
||||
return vim.api.nvim_win_get_config(winid or 0).relative ~= ''
|
||||
end
|
||||
|
||||
---Check if a window is an "embedded" floating window — one that is technically
|
||||
---floating (relative ~= '') but visually behaves like a sidebar (e.g. snacks
|
||||
---explorer). Neovim's default floating zindex is 50; anything explicitly set
|
||||
---below that signals the window is meant to coexist with normal splits.
|
||||
---@param winid number|nil window ID, defaults to current
|
||||
---@return boolean
|
||||
function M.is_embedded_floating_window(winid)
|
||||
if not M.is_floating(winid) then
|
||||
return false
|
||||
end
|
||||
local cfg = vim.api.nvim_win_get_config(winid or 0)
|
||||
local threshold = config.floating_zindex_max or 50
|
||||
return cfg.zindex ~= nil and cfg.zindex < threshold
|
||||
end
|
||||
|
||||
---Same as M.is_ignored_win but also checks previewwindow when opt-in.
|
||||
---@param winid number|nil window ID, defaults to current
|
||||
---@return boolean
|
||||
function M.is_ignored_or_preview(winid)
|
||||
if M.is_ignored_win(winid) then
|
||||
return true
|
||||
end
|
||||
if config.ignore_previewwindows then
|
||||
local ok, pw = pcall(vim.api.nvim_win_get_option, winid or 0, 'previewwindow')
|
||||
if ok and pw then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
---Returns true while Neovim's command-line window (q:, q/, q?) is open.
|
||||
---Inside it all window commands raise E11, so callers must short-circuit.
|
||||
---@return boolean
|
||||
function M.is_command_line_window()
|
||||
return vim.fn.getcmdwintype() ~= ''
|
||||
end
|
||||
|
||||
---Direction key shorthand for wincmd.
|
||||
M.dir_keys = {
|
||||
left = 'h',
|
||||
right = 'l',
|
||||
up = 'k',
|
||||
down = 'j',
|
||||
}
|
||||
|
||||
M.dir_keys_reverse = {
|
||||
left = 'l',
|
||||
right = 'h',
|
||||
up = 'j',
|
||||
down = 'k',
|
||||
}
|
||||
|
||||
---Reverse of a direction name: left<->right, up<->down.
|
||||
M.reverse_direction = {
|
||||
left = 'right',
|
||||
right = 'left',
|
||||
up = 'down',
|
||||
down = 'up',
|
||||
}
|
||||
|
||||
return M
|
||||
Reference in New Issue
Block a user