Skip to content

feat: v3 - #513

Draft
shortcuts wants to merge 140 commits into
mainfrom
feat/integrations
Draft

feat: v3#513
shortcuts wants to merge 140 commits into
mainfrom
feat/integrations

Conversation

@shortcuts

@shortcuts shortcuts commented Jan 13, 2026

Copy link
Copy Markdown
Owner

📃 Summary

closes #511
closes #514
closes #297
closes #470
closes #507
closes #444
closes #227
closes #436
closes #516


v3.0.0 Changelog

⚠️ Breaking Changes

Minimum Neovim version bumped to 0.10

  • Neovim 0.9 is no longer supported. Users on 0.9 should pin to the 2.x branch.
  • All deprecated vim.api.nvim_buf_get_option, nvim_buf_set_option, nvim_win_get_option, and nvim_win_set_option calls have been replaced with nvim_get_option_value / nvim_set_option_value.
  • The has_nvim9 config flag and all related backward-compatibility shims (api.set_buffer_option, api.set_window_option) have been removed.

Integration keys are now filetype-based (lowercase)

The keys in the integrations table are now matched directly against the filetype of integration windows instead of using a separate fileTypePattern field. This means:

Old key (v2) New key (v3)
NeoTree "neo-tree"
NvimDAPUI dap
NvimTree NvimTree (unchanged, filetype is NvimTree)

User-facing impact: If you have custom integration overrides in your config using the old PascalCase keys, you must update them to match the filetype of the integration window.

Integration reopen option removed

The reopen field has been removed from all integration definitions. The plugin no longer attempts to reopen integrations that were open before enabling. The close and open commands have also been removed from the hardcoded integration table.

Integration fileTypePattern removed

Integration matching now uses direct filetype comparison (name == filetype or string.find(filetype, name)) instead of the old vim.startswith(filetype, fileTypePattern) approach. This is simpler and supports arbitrary user-defined integrations.

Hardcoded INTEGRATIONS constant table removed

The constants.INTEGRATIONS table has been deleted entirely. Integration definitions now come from the user config at _G.NoNeckPain.config.integrations and are initialized into state at runtime. This enables fully dynamic, user-defined integrations.

constants.DASHBOARDS removed

The dashboard filetype list is no longer maintained as a constant. Dashboard integration detection has been reworked.

Default value changes

Option Old default New default Rationale
autocmds.reloadOnColorSchemeChange false true Side buffer colors should refresh when colorscheme changes
autocmds.skipEnteringNoNeckPainBuffer false true Side buffers should not be focusable by default (omitted when scratchpad is active)

Config field rename: buffers.set_namesbuffers.setNames

The buffer naming config key has been corrected from set_names to setNames to match the camelCase convention used everywhere else.

state:is_side_enabled_and_valid() renamed to state:is_side_valid()

The method is_side_enabled_and_valid has been renamed to is_side_valid. Same behavior, shorter name.

state:is_side_the_active_win() renamed to state:is_side_focused()

Renamed for clarity. Same behavior.

state:resize_win() signature changed

Old: state:resize_win(scope, id, width) — took a window ID directly.
New: state:resize_win(scope, side, width) — takes a side name ("left", "right", "curr") and resolves the ID internally.

Side buffer creation uses nvim_open_win instead of vnew

Side buffers are now created with nvim_create_buf + nvim_open_win (split mode) instead of vim.cmd("topleft vnew") / vim.cmd("botright vnew"). This gives more precise control over window placement and avoids triggering autocmds during creation.

callbacks section removed from README default config display

The callbacks config (preEnable, postEnable, preDisable, postDisable) still works but has been replaced in the README by a debug mapping entry. Callbacks are still functional but de-emphasized.

enable() is no longer debounced

main.enable() is now called synchronously from the public API instead of being wrapped in api.debounce("public_api_enable", main.enable, 10).

state:set_active_tab() moved before event.skip_enable()

In main.enable(), the active tab is now set before checking event.skip_enable(), which means the tab context is available during the skip check.


🚀 Features

Dynamic user-defined integrations

The integrations config table now accepts any filetype as a key. Users can register any plugin's sidebar/panel by adding its filetype and position to the table. No upstream changes needed.

integrations = {
    -- Built-in integrations (still supported):
    NvimTree = { position = "left" },
    ["neo-tree"] = { position = "left" },
    -- Custom user-defined integration:
    my_custom_sidebar = { position = "right" },
    snacks_picker = { position = "left" },
}

New built-in integrations: oil, snacks_explorer

Two new integrations are now recognized out of the box:

  • oil.nvim (oil) — position: "left"
  • snacks.nvim explorer (snacks_explorer) — position: "left"

position = "none" integrations now properly accounted in width calculation

Integrations with position = "none" (e.g., dap-ui) now correctly track their column count via a new none_columns state field. The side width calculation subtracts these columns, preventing layout miscalculation when "none"-positioned integrations are open.

Col-layout integration detection

The layout scanner now detects integrations inside col (vertically stacked) layouts, not just leaf nodes. This fixes detection of integrations like snacks_explorer and dap-ui that use column-based window layouts.

Session restore support

New SessionLoadPost autocmd handler prevents the plugin state from being wiped during Neovim session restore (mksession / source). Two new functions main.signal_session_restore_start() and main.signal_session_restore_complete() gate the state reset during session load.

skipEnteringNoNeckPainBuffer moved to dedicated autocmd

The skip-entering logic has been extracted from a WinEnter-only handler into a combined VimEnter + WinEnter autocmd created during main.enable(). It now uses a skip_entering_in_progress reentrancy guard to prevent infinite loops. The handler also correctly skips when scratchpad is active.

FileType safety net autocmd

A new FileType autocmd is registered during setup to handle deferred filetype resolution (e.g., dashboard plugins that set their filetype late). This makes enableOnVimEnter = "safe" mode more reliable.

New log.warn() convenience function

Added log.warn(scope, str, ...) that always prints at WARN level regardless of debug mode.

New helpers utility module (lua/no-neck-pain/util/helpers.lua)

A new centralized module providing:

  • helpers.get_config() / helpers.get_config_field(field) — safe config access
  • helpers.set_config(config) — config setter (direct assignment, no validation overhead)
  • helpers.get_state() / helpers.get_state_field(field) — safe state access
  • helpers.set_state(state) — state setter (direct assignment, no validation overhead)
  • helpers.merge_config(updates) — deep-merge partial config updates
  • helpers.ensure_config_loaded(config) — lazy config initialization guard
  • helpers.ensure_plugin_enabled() — enabled state assertion
  • helpers.safe_delete_augroup(name) — pcall wrapper for augroup deletion
  • helpers.is_filetype_integration(ft) — single-call integration detection

All direct _G.NoNeckPain.config and _G.NoNeckPain.state accesses throughout the codebase have been replaced with these helpers.

Integration position validation at config time

config.setup() now validates that every integration's position field is a string and one of "left", "right", or "none". Invalid values trigger an assertion error at setup time instead of silently misbehaving.

Improved move_sides is now focus-neutral

ui.move_sides() now wraps nvim_set_current_win and window-move commands with noautocmd, preventing autocmd side effects (focus steal, layout recalculation) during side window repositioning.

Window validity guards throughout

All nvim_set_current_win and nvim_win_get_width calls are now guarded with nvim_win_is_valid() and nil checks. This prevents crashes when windows are closed between event dispatch and handler execution.

Improved debouncer (no recursive timer reuse)

The api.debounce() implementation now uses a reschedule flag instead of recursively calling itself with a new timer while the callback is executing. This prevents potential timer leaks.

WinEnter handler triggers resize on window count change

The WinEnter autocmd now detects when the window count changes between events and triggers a full side resize, fixing layout drift when splits are opened/closed.

Reinit instead of disable when side buffer squeezed out

When a side buffer is squeezed out by a split (no longer valid), the plugin now reinitializes the layout instead of fully disabling. This preserves the centered layout through split operations.

Main buffer exclusion from integration scan

The set_layout_windows method now skips the main buffer (curr) when scanning for integrations, preventing the main editing buffer from being incorrectly tagged as an integration.

fallbackOnBufferDelete uses helpers.get_config_field()

The BufDelete handler now accesses fallbackOnBufferDelete via the helpers module instead of direct global access, consistent with the rest of the codebase.

Scan rescan after side buffer creation

After creating side buffers, ui.create_side_buffers() now calls state:scan_layout() to refresh column counts before computing final widths. This fixes width miscalculation when both sides are created in the same cycle.

Side width comparison with tolerance

Side buffer resizing now uses math.abs(current_width - padding) > 1 to avoid unnecessary resize operations when the difference is negligible.


🐛 Bug Fixes

Single-side buffer width calculation

When only one side is enabled, the width calculation now correctly accounts for the reduced column count, preventing the side buffer from being too narrow or too wide.

is_relative_window uses correct window ID

api.is_relative_window() now correctly checks the config of the passed window ID (or current window if 0), instead of potentially checking the wrong window.

state:get_side_id() returns nil instead of crashing

get_side_id() now returns nil when the tab state is not initialized, instead of indexing into a nil table and crashing.

Null safety across state methods

Multiple state methods (get_columns, get_none_columns, consume_redraw, get_integrations, init_integrations, init_columns, set_side_id, set_scratch_pad, get_scratch_pad) now check self:has_tabs() and self.tabs[self.active_tab] before accessing tab state, preventing nil index errors.

Focus restoration validity check in toggle_scratch_pad

toggle_scratch_pad now checks nvim_win_is_valid on the previously focused window before restoring focus.

BufDelete handler validates curr window

The BufDelete / QuitPre handler now checks that the curr window ID is not nil and is valid before using it, preventing errors when the main window is already gone.

Split from side window resets window options correctly

When fallbackOnBufferDelete triggers a split from a side window, the new window's options are now reset using nvim_set_option_value (with scope = "local") instead of the removed api.set_window_option.

Correct variable scope in QuitPre/BufDelete

Fixed a bug where the wrong local variable was used in the handler, causing incorrect behavior.

Previously focused window captured at enable time

state:set_previously_focused_win(vim.api.nvim_get_current_win()) is now called during main.enable(), ensuring the previously focused window is tracked from the start.

walk_layout col-of-leaves counting fix

When the top-level layout is a col of leaves, the scanner now correctly counts it as one visual column and scans each leaf for integrations, instead of recursing as if it were a row.

walk_layout leaf removal fix (non-mutating)

state:walk_layout no longer mutates the leafs table in-place with table.remove(leafs, 1). It now creates a copy before removing the first element, preventing side effects.

skip_enable scope matching updated

The event.skip_enable() check for tab-enter now matches the new scope format "public_api_enable:TabEnter" instead of the old "enable_on_tab_enter".

Fixed side >= minSideBufferWidth comparison

Side buffer creation now uses >= instead of > when comparing padding to minSideBufferWidth, allowing side buffers to be created when the available space exactly equals the minimum.

Float windows excluded from repositioning heuristic

main.init() now filters out floating windows when counting actual windows in the tab, preventing false triggers of the side buffer repositioning logic when floating windows (e.g., popups) are open.


🧪 Tests

Massively expanded test suite

New test files added:

  • test_config_validation.lua — config validation and edge cases
  • test_constants.lua — constants module coverage
  • test_debug_tabs.lua — tab debugging scenarios
  • test_diagnostic.lua — diagnostic integration
  • test_event.lua — event handling and skip logic
  • test_log.lua — logging module coverage
  • test_state_access_regression.lua — state access safety
  • test_state_edge_cases.lua — state edge cases and race conditions
  • test_width_calculations.lua — width calculation property testing

Existing test files significantly expanded

  • test_API.lua — expanded from ~100 to ~400 lines
  • test_autocmds.lua — expanded from ~100 to ~480 lines
  • test_buffers.lua — expanded from ~100 to ~385 lines
  • test_integrations.lua — expanded from ~200 to ~1000+ lines
  • test_splits.lua — expanded with new vsplit width assertions
  • test_tabs.lua — expanded with tab lifecycle tests

Test helpers improvements

  • child.wait_for_plugin_enabled(timeout) — poll-based wait for plugin enable
  • Helpers.generate_width_configs(min, max, count) — property-based test data generation
  • Various assertion helpers and test fixtures

Test runner changes

  • Tests now run individually per file (make test runs each test-* target) instead of a single MiniTest.run() call
  • Test race count reduced from 10 to 5
  • luacheck removed from lint target (replaced by luals only)
  • luarocks install luacheck removed from deps

🔧 Infrastructure

CI matrix updated

  • Tested Neovim versions: v0.10.4, v0.11.7, v0.12.0 (was v0.9.5, v0.10.2, v0.11.3)
  • CI timeout increased from 1 to 3 minutes

Linting

  • luacheck removed from the lint pipeline
  • luals (lua-language-server) is now the sole linter

Documentation

  • README updated for v3 with new defaults, new integrations, and breaking changes section
  • Added structured metadata header to README for AI assistants
  • Added AGENTS.md, CLAUDE.md, GEMINI.md for AI-assisted development
  • Generated docs (doc/no-neck-pain.txt) regenerated

🏗 Internal Refactoring

main.enable() god function decomposed

The 508-line main.enable() function has been split into three named top-level handlers:

  • main._on_skip_entering(p) — VimEnter/WinEnter focus-rerouting logic
  • main._on_win_change(p) — WinEnter/WinClosed layout sync logic
  • main._on_buf_delete(p) — QuitPre/BufDelete cleanup logic

main.enable() is now ~50 lines of pure wiring. Each handler is independently readable and testable.

should_reinit() moved to state:determine_layout_action()

The 8-parameter oracle deciding whether to "disable", "init", or do nothing after a window event now lives in state.lua as state:determine_layout_action(...). Layout decision logic belongs next to layout state.

validate_side_windows() moved to state:validate_sides()

The closure that checked whether stored window IDs were still valid and cleared stale IDs has been promoted to a proper method on state: state:validate_sides(scope, valid_win_set)left_cleared, right_cleared.

Local is_integration_ft() closure removed

The private closure inside main.enable() that duplicated helpers.is_filetype_integration() has been deleted. All callers now use helpers.is_filetype_integration() directly.

session_restore_in_progress moved to state object

The module-level mutable global session_restore_in_progress in main.lua has been moved to state.session_restore_in_progress, eliminating a hidden cross-tab coupling point.

state:_scan_col_children() extracted to eliminate triplication

The inline loop that scanned leaf children of a col layout node for integrations was copy-pasted in three places (set_layout_windows, scan_layout leaf-only branch, and scan_layout complex branch). All three now call state:_scan_col_children(scope, children).

state:set_tab() no longer deep-copies integrations from config

set_tab() now initializes integrations = {} and defers population to init_integrations(), which is the single source of truth. This removes a redundant deep-copy and eliminates the race where set_tab and init_integrations could initialize from different config snapshots.

helpers.set_config() / helpers.set_state() simplified to direct assignments

Both setters previously validated structure (width field presence, enabled boolean type) and returned true/false, but callers never checked the return value, making validation silent dead code. Both are now direct assignments. Structural validation belongs at config.setup() time, not at every mutation site.

Config/state access centralized via helpers module

All direct _G.NoNeckPain.config.* and _G.NoNeckPain.state.* accesses replaced with helpers.get_config_field(), helpers.get_state(), etc. This provides a single point of access with nil safety.

constants.INTEGRATIONS removed

Integration definitions are now purely config-driven, initialized from helpers.get_config_field("integrations") into tab state at runtime.

State integration initialization is now config-driven

state:init_integrations() now deep-copies from helpers.get_config_field("integrations") and normalizes all keys to lowercase, instead of copying from a hardcoded constant.

Callbacks config field lookups cached

In main.enable() and main.disable(), helpers.get_config_field("callbacks") is called once and stored in a local, avoiding repeated global lookups.

state:save() uses helpers.set_state(self)

State persistence now goes through the helpers module instead of directly writing to _G.NoNeckPain.state.

Config setup uses helpers.set_config()

init.setup() and ColorScheme handler now use helpers.set_config() instead of direct assignment.

Side buffer creation refactored

ui.create_side_buffers() now uses a two-pass approach: first loop creates buffers, second loop (after a scan_layout rescan) computes and applies widths. This fixes race conditions where the second side's width was computed before the first side was accounted for in the column count.

@shortcuts shortcuts self-assigned this Jan 13, 2026
@shortcuts shortcuts changed the title feat: allow dynamic integrations feat: v3 Feb 9, 2026
- Add redraw=false initialization to set_tab() (line 173)
- Add nil guards to 8 state methods: get_integrations, get_side_id, get_columns, consume_redraw, get_scratch_pad, set_side_id, set_scratch_pad
- All guards follow pattern: if not (self:has_tabs() and self.tabs[self.active_tab] ~= nil) then return <safe_default> end
- Tab State tests: 9/9 pass
- Fixes nil crashes when accessing tab state methods on unregistered tabs
- Line 139: Change `>` to `>=` for creation threshold (allow at boundary)
- Line 260: Change `<=` to `<` for return-zero threshold (exclude boundary)
- Line 180: Keep `<` as-is (closure threshold)
- Semantic invariant: padding >= minSideBufferWidth allows; padding < minSideBufferWidth disallows
- Update test: "At minSideBufferWidth threshold (exactly)" now expects creation
- Test status: 18/19 passing in test_width_calculations.lua
- Line 39: Add nvim_win_is_valid check before set_current_win in toggle_scratch_pad
- Line 45: Add nvim_win_is_valid check for previously_focused_win
- Line 275: Add validity check after rightbelow split to ensure new window was created
- All nvim_set_current_win calls now properly guarded
- Prevents crash when windows are closed/invalidated between check and use
Fixes 5 out of 6 failing scratchpad tests by:
- Adding explicit window switching before vim.cmd('edit')
- Handling nil/empty pathToFile with proper fallback
- Using explicit buffer IDs instead of current buffer (0)
- Restoring previous window after edit command

This ensures scratchpad buffers are created correctly in their respective
side windows and buffer names are properly set.
Add detailed logging to main.lua, state.lua, and ui.lua to trace:
- Integration detection in scan_layout and set_layout_windows
- Integration id assignment and filetype matching
- Integration width subtraction in get_side_width
- Action decision logic in WinEnter handler

This logging reveals the actual root cause of #511: when right side
is disabled, get_side_width divides by 2 unconditionally, giving left
only half the remaining width instead of all of it.
…#511)

When only one side buffer is enabled AND integrations are consuming space,
the remaining width should not be halved. The divisor should be 1 instead of 2
since there's no other side to share the space with.

This fixes the snacks_picker integration test where:
- Right buffer is disabled (buffers.right.enabled = false)
- Explorer takes 30 columns on the left
- Main buffer should remain ~80 columns
- Left padding should use remaining ~90 columns

Previously:
- Remaining width 90 / 2 = 45 for left_pad
- Main expanded to ~123 (wrong)

Now:
- Remaining width 90 / 1 = 90 for left_pad
- Main stays ~80 (correct)

All 23 integration tests pass, including both snacks_picker tests.
…isor override

Reverted incorrect 'opts.position == side' check in getSideWidth that broke
single-side width calculations. Removed divisor override logic in state.lua
that incorrectly forced divisor=2 for single-side configurations.

Tests verify single-side width correctness under resize and toggle cycles.
When a new split is created with :vnew, the window count changes and the
side windows are deleted by Neovim. The plugin should detect this and
recreate the side windows to maintain the centered layout.

Changes:
- validate_side_windows(): Clear stale side window IDs from state when
  windows are detected as invalid, allowing them to be recreated
- should_reinit(): Allow reinit on WinEnter when window count changes,
  even if side windows were cleared, to trigger side window recreation
- create_side_buffers(): Only close side windows if padding is both less
  than minimum width AND greater than 0, preventing unnecessary closures

Fixes test_colors.lua test case: 'Setup: does not throw on invalid windows'
All tests pass: 252 assertions across 8 test modules
When a split is created with :vnew, the side windows are deleted by Neovim
and the WinEnter handler detects this and triggers reinit to recreate them.
However, the debounce timer (2ms) may not complete before the test assertion
runs, causing the test to fail intermittently.

Add child.wait() after the vnew command to ensure the debounce timer
completes and the side windows are recreated before the assertion.

This makes the test deterministic and stable across repeated runs.

Fixes: test-colors flakiness (10/10 pass rate)
- dedupe shadowed `callbacks` local in main.enable()/disable() so the
  changelog's "cached lookup" claim is actually true
- replace determine_layout_action's 8 positional args with a typed ctx
  table, fold new_integration_found dispatch into a "redraw" action
  instead of a parallel elseif in the caller
- extract state:_register_column to remove the last duplicate of the
  col-counting snippet
- drop dead empty else branch in ui.create_side_buffers
walk_layout() counted any row nested inside a col as extra vsplit
columns, even when the row's windows were just the main buffer split
again (e.g. :split then :vsplit). With one side disabled, this
spurious extra column starved get_side_width() of available width and
force-closed the remaining side buffer, which then got recreated at
the wrong (often much larger) size.

Now a nested row only counts as extra columns when its windows hold a
different buffer than `curr` (i.e. a genuine extra panel like dapui's
watches/scopes, which still needs its own width reservation) — plain
main-buffer splits are folded into the col's existing column instead
of being double-counted.
…or next layout change

vim's 'equalalways' resizes every window in the tab, including NNP's
side buffers, the instant a new :split/:vsplit is created. NNP only
corrected this back on the next event that changed the window count
or column count (e.g. closing the split), so the side buffer visibly
grew/shrank until then.

_on_win_change compared window counts taken before and after
scan_layout within the same call, which are always equal (no window
can appear or disappear synchronously in between), so the count-change
branch in determine_layout_action never actually fired for this case.

Track the window count across events instead (persisted outside of
`tabs` so it doesn't leak into snapshot-style state assertions in
tests), and compare against that to detect the real change and
trigger an immediate resize.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment