Skip to content

merrebach/window-groups.nvim

window-groups.nvim

Window-scoped buffer groups for Neovim. Each split window maintains its own ordered list of buffers, rendered as a tab strip in the winbar. Buffers have single-membership — opening a buffer already owned by another window redirects focus there instead of duplicating it.

Think VS Code editor groups, not Vim tabpages.

Requirements

Installation

lazy.nvim (recommended)

{
  "merrebach/window-groups.nvim",
  config = function()
    require("window_groups").setup({})
  end,
}

packer.nvim

use {
  "merrebach/window-groups.nvim",
  config = function()
    require("window_groups").setup({})
  end,
}

mini.deps

MiniDeps.add("merrebach/window-groups.nvim")
require("window_groups").setup({})

vim-plug

Plug 'merrebach/window-groups.nvim'
" then in your init.lua or after plug#end():
lua require("window_groups").setup({})

Setup

All options with their defaults:

require("window_groups").setup({
  -- Filetypes merged with built-in exclusions: neo-tree, snacks_dashboard, dashboard
  exclude_filetypes = {},

  -- Set to false if you manage vim.o.winbar yourself (heirline, lualine, etc.)
  winbar = true,

  -- Set to false to disable the group boundary accent and WinSeparator styling.
  border = true,

  -- Character rendered at the left edge of each winbar to signal a group boundary.
  border_char = "",

  -- Custom icon resolver. nil = auto-detect: nvim-web-devicons → mini.icons → plain text
  -- Signature: function(buf: integer) -> icon: string, hl_name: string|nil
  get_icon = nil,

  -- Override fallback highlight specs. Applied on top of colorscheme-defined values.
  highlights = {
    active          = {},   -- focused window's tab in the tabline
    current         = {},   -- active buffer in an unfocused window's tab
    inactive        = {},   -- unfocused window's tab in the tabline
    sep             = {},   -- separator between tabs
    fill            = {},   -- empty space to the right of all tabs
    accent_active   = {},   -- border_char in the focused window
    accent_inactive = {},   -- border_char in unfocused windows
    winsep_active   = {},   -- WinSeparator for the focused window
    winsep_inactive = {},   -- WinSeparator for unfocused windows
  },

  -- Default keymaps. Pass false to disable all, or a table to replace entirely.
  -- See the Keymaps section for the full default set.
  keys = nil,  -- nil → register defaults
})

Keymaps

Default keymaps registered by setup():

Key Action
<leader>q Close current buffer
<leader>bq Close all buffers in current window's group
]b Cycle to next buffer in group
[b Cycle to previous buffer in group
<leader>bmh Move buffer to left window
<leader>bmj Move buffer to bottom window
<leader>bmk Move buffer to top window
<leader>bml Move buffer to right window

Disable all default keymaps:

require("window_groups").setup({ keys = false })

Replace with your own keymaps:

require("window_groups").setup({
  keys = {
    { "<leader>x",   function() require("window_groups").close_buf() end,          desc = "Close buffer" },
    { "<leader>X",   function() require("window_groups").close_group() end,        desc = "Close group" },
    { "<Tab>",       function() require("window_groups").cycle("next") end,         desc = "Next buffer" },
    { "<S-Tab>",     function() require("window_groups").cycle("prev") end,         desc = "Prev buffer" },
    { "<leader>bmh", function() require("window_groups").move_buf("left") end,     desc = "Move buf left" },
    { "<leader>bml", function() require("window_groups").move_buf("right") end,    desc = "Move buf right" },
    { "<leader>bmk", function() require("window_groups").move_buf("up") end,       desc = "Move buf up" },
    { "<leader>bmj", function() require("window_groups").move_buf("down") end,     desc = "Move buf down" },
  },
})

lazy.nvim: use the keys spec for lazy-loading (advanced — not needed for most setups):

{
  "merrebach/window-groups.nvim",
  config = function()
    require("window_groups").setup({ keys = false })  -- don't register again inside setup
  end,
  keys = {
    { "<leader>q",  function() require("window_groups").close_buf() end,   desc = "Close buffer" },
    { "<leader>bq", function() require("window_groups").close_group() end, desc = "Close group" },
    { "]b",         function() require("window_groups").cycle("next") end,  desc = "Next buffer" },
    { "[b",         function() require("window_groups").cycle("prev") end,  desc = "Prev buffer" },
  },
}

Note: lazy-loading window-groups is not recommended. The plugin must run at startup to seed the initial window group and set up the winbar.

API

local wg = require("window_groups")

-- Buffer lifecycle
wg.close_buf()                      -- close current buffer, show neighbor; close window if group empty
wg.close_group()                    -- close all buffers in current window's group and close the window

-- Navigation
wg.cycle("next" | "prev")           -- cycle through buffers in current window's group

-- Layout
wg.move_buf("left" | "right" | "up" | "down") -- move current buffer to the neighbor window in that direction
wg.split("left" | "right" | "up" | "down")    -- open a split, carry current buffer into the new window

-- Introspection
wg.eligible(buf)                    -- bool: can this buffer join a group
wg.list(win)                        -- ordered buffer list for a window (integers)
wg.add(win, buf)                    -- add buffer to window's group
wg.remove(win, buf)                 -- remove buffer from window's group

Highlights

Set any of these groups in your colorscheme. If a group is not defined, fallbacks are derived from TabLine, TabLineSel, and Normal at setup time and refreshed on ColorScheme.

Group Meaning
GroupsActive Tab in the focused window, currently visible buffer
GroupsCurrent Tab in an unfocused window, currently visible buffer
GroupsInactive Tab whose buffer is not currently visible
GroupsSep Separator between tabs
GroupsFill Winbar space after all tabs
GroupsAccentActive border_char in the focused window's winbar
GroupsAccentInactive border_char in unfocused windows' winbars
GroupsWinSepActive WinSeparator override for the focused window
GroupsWinSepInactive WinSeparator override for unfocused windows

Override a single key via setup:

require("window_groups").setup({
  highlights = {
    active = { fg = "#d4c5a9", bold = true },  -- bold active tab, custom foreground
  },
})

Override all groups at once:

require("window_groups").setup({
  highlights = {
    active          = { fg = "#d4c5a9", bg = "#3d3d3d", bold = true },
    current         = { fg = "#9a9a8a", bg = "#2a2a2a" },
    inactive        = { fg = "#5a5a4a", bg = "#2a2a2a" },
    sep             = { fg = "#3d3d3d", bg = "#2a2a2a" },
    fill            = { bg = "#2a2a2a" },
    accent_active   = { fg = "#e06c75" },
    accent_inactive = { fg = "#4b5263" },
    winsep_active   = { fg = "#e06c75" },
    winsep_inactive = { fg = "#3b4048" },
  },
})

Define groups directly in your colorscheme (takes precedence over setup overrides):

vim.api.nvim_set_hl(0, "GroupsActive",        { bg = "#3d3d3d", fg = "#d4c5a9", bold = true })
vim.api.nvim_set_hl(0, "GroupsCurrent",       { bg = "#2a2a2a", fg = "#9a9a8a" })
vim.api.nvim_set_hl(0, "GroupsInactive",      { bg = "#2a2a2a", fg = "#5a5a4a" })
vim.api.nvim_set_hl(0, "GroupsSep",           { bg = "#2a2a2a", fg = "#3d3d3d" })
vim.api.nvim_set_hl(0, "GroupsFill",          { bg = "#2a2a2a" })
vim.api.nvim_set_hl(0, "GroupsAccentActive",  { fg = "#e06c75" })
vim.api.nvim_set_hl(0, "GroupsAccentInactive",{ fg = "#4b5263" })
vim.api.nvim_set_hl(0, "GroupsWinSepActive",  { fg = "#e06c75" })
vim.api.nvim_set_hl(0, "GroupsWinSepInactive",{ fg = "#3b4048" })

If you use singularity.nvim, all highlight groups are defined automatically when integrations.window_groups = true (default).

Filetypes

Add filetypes that should not participate in groups. These are merged with the built-in exclusions (neo-tree, snacks_dashboard, dashboard):

require("window_groups").setup({
  exclude_filetypes = { "NvimTree", "Outline", "aerial", "toggleterm" },
})

Windows showing an excluded filetype get no winbar strip and their buffers are never added to any group.

Icon providers

get_icon lets you supply your own resolver instead of the auto-detected one:

-- Always use a fixed icon regardless of filetype
require("window_groups").setup({
  get_icon = function(buf)
    return "", nil  -- icon string, optional highlight name
  end,
})

-- Use nvim-web-devicons explicitly, ignoring mini.icons even if present
require("window_groups").setup({
  get_icon = function(buf)
    local ok, devicons = pcall(require, "nvim-web-devicons")
    if not ok then return "", nil end
    local name = vim.api.nvim_buf_get_name(buf)
    local icon, hl = devicons.get_icon(name, vim.fn.fnamemodify(name, ":e"), { default = true })
    return icon or "", hl
  end,
})

Behaviour notes

  • Single-membership is per-tabpage. A buffer can appear in different groups across tabpages. Within one tabpage it belongs to at most one group.
  • Winbar only appears on editor windows. Floats, explorer sidebars, help, quickfix, and terminal windows render no winbar strip.
  • setup() is idempotent. Calling it more than once is a no-op.
  • move_buf fails silently on non-editor neighbors. If the neighbor window is a sidebar or float, a warning is shown and the buffer stays where it is.
  • close_buf on an ineligible buffer (terminal, scratch) falls back to :bdelete — no group logic applies.
  • split with no eligible buffer opens a blank scratch split with no group entry.

Contributing

See CONTRIBUTING.md and CONTEXT.md for the domain glossary.

make install-hooks   # install pre-commit lint hook
make lint            # run luacheck
make test            # run plenary tests (requires Neovim in PATH)

License

MIT — see LICENSE.

About

Window-scoped buffer group management for Neovim

Topics

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Packages