feat: v3 - #513
Draft
shortcuts wants to merge 140 commits into
Draft
Conversation
1 task
shortcuts
force-pushed
the
feat/integrations
branch
from
January 21, 2026 22:58
213e702 to
e122374
Compare
- 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.
…ved col scanning and debounce logic
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
📃 Summary
closes #511
closes #514
closes #297
closes #470
closes #507
closes #444
closes #227
closes #436
closes #516
v3.0.0 Changelog
Minimum Neovim version bumped to 0.10
2.xbranch.vim.api.nvim_buf_get_option,nvim_buf_set_option,nvim_win_get_option, andnvim_win_set_optioncalls have been replaced withnvim_get_option_value/nvim_set_option_value.has_nvim9config 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
integrationstable are now matched directly against thefiletypeof integration windows instead of using a separatefileTypePatternfield. This means:NeoTree"neo-tree"NvimDAPUIdapNvimTreeNvimTree(unchanged, filetype isNvimTree)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
reopenoption removedThe
reopenfield has been removed from all integration definitions. The plugin no longer attempts to reopen integrations that were open before enabling. Thecloseandopencommands have also been removed from the hardcoded integration table.Integration
fileTypePatternremovedIntegration matching now uses direct filetype comparison (
name == filetypeorstring.find(filetype, name)) instead of the oldvim.startswith(filetype, fileTypePattern)approach. This is simpler and supports arbitrary user-defined integrations.Hardcoded
INTEGRATIONSconstant table removedThe
constants.INTEGRATIONStable has been deleted entirely. Integration definitions now come from the user config at_G.NoNeckPain.config.integrationsand are initialized into state at runtime. This enables fully dynamic, user-defined integrations.constants.DASHBOARDSremovedThe dashboard filetype list is no longer maintained as a constant. Dashboard integration detection has been reworked.
Default value changes
autocmds.reloadOnColorSchemeChangefalsetrueautocmds.skipEnteringNoNeckPainBufferfalsetrueConfig field rename:
buffers.set_names→buffers.setNamesThe buffer naming config key has been corrected from
set_namestosetNamesto match the camelCase convention used everywhere else.state:is_side_enabled_and_valid()renamed tostate:is_side_valid()The method
is_side_enabled_and_validhas been renamed tois_side_valid. Same behavior, shorter name.state:is_side_the_active_win()renamed tostate:is_side_focused()Renamed for clarity. Same behavior.
state:resize_win()signature changedOld:
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_wininstead ofvnewSide buffers are now created with
nvim_create_buf+nvim_open_win(split mode) instead ofvim.cmd("topleft vnew")/vim.cmd("botright vnew"). This gives more precise control over window placement and avoids triggering autocmds during creation.callbackssection removed from README default config displayThe
callbacksconfig (preEnable,postEnable,preDisable,postDisable) still works but has been replaced in the README by adebugmapping entry. Callbacks are still functional but de-emphasized.enable()is no longer debouncedmain.enable()is now called synchronously from the public API instead of being wrapped inapi.debounce("public_api_enable", main.enable, 10).state:set_active_tab()moved beforeevent.skip_enable()In
main.enable(), the active tab is now set before checkingevent.skip_enable(), which means the tab context is available during the skip check.🚀 Features
Dynamic user-defined integrations
The
integrationsconfig 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.New built-in integrations:
oil,snacks_explorerTwo new integrations are now recognized out of the box:
oil) — position:"left"snacks_explorer) — position:"left"position = "none"integrations now properly accounted in width calculationIntegrations with
position = "none"(e.g.,dap-ui) now correctly track their column count via a newnone_columnsstate 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 justleafnodes. This fixes detection of integrations likesnacks_exploreranddap-uithat use column-based window layouts.Session restore support
New
SessionLoadPostautocmd handler prevents the plugin state from being wiped during Neovim session restore (mksession/source). Two new functionsmain.signal_session_restore_start()andmain.signal_session_restore_complete()gate the state reset during session load.skipEnteringNoNeckPainBuffermoved to dedicated autocmdThe skip-entering logic has been extracted from a
WinEnter-only handler into a combinedVimEnter+WinEnterautocmd created duringmain.enable(). It now uses askip_entering_in_progressreentrancy guard to prevent infinite loops. The handler also correctly skips when scratchpad is active.FileTypesafety net autocmdA new
FileTypeautocmd is registered during setup to handle deferred filetype resolution (e.g., dashboard plugins that set their filetype late). This makesenableOnVimEnter = "safe"mode more reliable.New
log.warn()convenience functionAdded
log.warn(scope, str, ...)that always prints atWARNlevel regardless of debug mode.New
helpersutility module (lua/no-neck-pain/util/helpers.lua)A new centralized module providing:
helpers.get_config()/helpers.get_config_field(field)— safe config accesshelpers.set_config(config)— config setter (direct assignment, no validation overhead)helpers.get_state()/helpers.get_state_field(field)— safe state accesshelpers.set_state(state)— state setter (direct assignment, no validation overhead)helpers.merge_config(updates)— deep-merge partial config updateshelpers.ensure_config_loaded(config)— lazy config initialization guardhelpers.ensure_plugin_enabled()— enabled state assertionhelpers.safe_delete_augroup(name)— pcall wrapper for augroup deletionhelpers.is_filetype_integration(ft)— single-call integration detectionAll direct
_G.NoNeckPain.configand_G.NoNeckPain.stateaccesses throughout the codebase have been replaced with these helpers.Integration position validation at config time
config.setup()now validates that every integration'spositionfield 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_sidesis now focus-neutralui.move_sides()now wrapsnvim_set_current_winand window-move commands withnoautocmd, preventing autocmd side effects (focus steal, layout recalculation) during side window repositioning.Window validity guards throughout
All
nvim_set_current_winandnvim_win_get_widthcalls are now guarded withnvim_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 arescheduleflag instead of recursively calling itself with a new timer while the callback is executing. This prevents potential timer leaks.WinEnterhandler triggers resize on window count changeThe
WinEnterautocmd 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_windowsmethod now skips the main buffer (curr) when scanning for integrations, preventing the main editing buffer from being incorrectly tagged as an integration.fallbackOnBufferDeleteuseshelpers.get_config_field()The BufDelete handler now accesses
fallbackOnBufferDeletevia 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 callsstate: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) > 1to 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_windowuses correct window IDapi.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()returnsnilinstead of crashingget_side_id()now returnsnilwhen 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 checkself:has_tabs() and self.tabs[self.active_tab]before accessing tab state, preventing nil index errors.Focus restoration validity check in
toggle_scratch_padtoggle_scratch_padnow checksnvim_win_is_validon the previously focused window before restoring focus.BufDeletehandler validatescurrwindowThe
BufDelete/QuitPrehandler now checks that thecurrwindow 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
fallbackOnBufferDeletetriggers a split from a side window, the new window's options are now reset usingnvim_set_option_value(withscope = "local") instead of the removedapi.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 duringmain.enable(), ensuring the previously focused window is tracked from the start.walk_layoutcol-of-leaves counting fixWhen the top-level layout is a
colof leaves, the scanner now correctly counts it as one visual column and scans each leaf for integrations, instead of recursing as if it were arow.walk_layoutleaf removal fix (non-mutating)state:walk_layoutno longer mutates theleafstable in-place withtable.remove(leafs, 1). It now creates a copy before removing the first element, preventing side effects.skip_enablescope matching updatedThe
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 >= minSideBufferWidthcomparisonSide buffer creation now uses
>=instead of>when comparing padding tominSideBufferWidth, 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 casestest_constants.lua— constants module coveragetest_debug_tabs.lua— tab debugging scenariostest_diagnostic.lua— diagnostic integrationtest_event.lua— event handling and skip logictest_log.lua— logging module coveragetest_state_access_regression.lua— state access safetytest_state_edge_cases.lua— state edge cases and race conditionstest_width_calculations.lua— width calculation property testingExisting test files significantly expanded
test_API.lua— expanded from ~100 to ~400 linestest_autocmds.lua— expanded from ~100 to ~480 linestest_buffers.lua— expanded from ~100 to ~385 linestest_integrations.lua— expanded from ~200 to ~1000+ linestest_splits.lua— expanded with new vsplit width assertionstest_tabs.lua— expanded with tab lifecycle testsTest helpers improvements
child.wait_for_plugin_enabled(timeout)— poll-based wait for plugin enableHelpers.generate_width_configs(min, max, count)— property-based test data generationTest runner changes
make testruns eachtest-*target) instead of a singleMiniTest.run()callluacheckremoved from lint target (replaced bylualsonly)luarocks install luacheckremoved from deps🔧 Infrastructure
CI matrix updated
v0.10.4,v0.11.7,v0.12.0(wasv0.9.5,v0.10.2,v0.11.3)Linting
luacheckremoved from the lint pipelineluals(lua-language-server) is now the sole linterDocumentation
AGENTS.md,CLAUDE.md,GEMINI.mdfor AI-assisted developmentdoc/no-neck-pain.txt) regenerated🏗 Internal Refactoring
main.enable()god function decomposedThe 508-line
main.enable()function has been split into three named top-level handlers:main._on_skip_entering(p)— VimEnter/WinEnter focus-rerouting logicmain._on_win_change(p)— WinEnter/WinClosed layout sync logicmain._on_buf_delete(p)— QuitPre/BufDelete cleanup logicmain.enable()is now ~50 lines of pure wiring. Each handler is independently readable and testable.should_reinit()moved tostate:determine_layout_action()The 8-parameter oracle deciding whether to
"disable","init", or do nothing after a window event now lives instate.luaasstate:determine_layout_action(...). Layout decision logic belongs next to layout state.validate_side_windows()moved tostate: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 removedThe private closure inside
main.enable()that duplicatedhelpers.is_filetype_integration()has been deleted. All callers now usehelpers.is_filetype_integration()directly.session_restore_in_progressmoved to state objectThe module-level mutable global
session_restore_in_progressinmain.luahas been moved tostate.session_restore_in_progress, eliminating a hidden cross-tab coupling point.state:_scan_col_children()extracted to eliminate triplicationThe inline loop that scanned leaf children of a
collayout node for integrations was copy-pasted in three places (set_layout_windows,scan_layoutleaf-only branch, andscan_layoutcomplex branch). All three now callstate:_scan_col_children(scope, children).state:set_tab()no longer deep-copies integrations from configset_tab()now initializesintegrations = {}and defers population toinit_integrations(), which is the single source of truth. This removes a redundant deep-copy and eliminates the race whereset_tabandinit_integrationscould initialize from different config snapshots.helpers.set_config()/helpers.set_state()simplified to direct assignmentsBoth setters previously validated structure (
widthfield presence,enabledboolean type) and returnedtrue/false, but callers never checked the return value, making validation silent dead code. Both are now direct assignments. Structural validation belongs atconfig.setup()time, not at every mutation site.Config/state access centralized via
helpersmoduleAll direct
_G.NoNeckPain.config.*and_G.NoNeckPain.state.*accesses replaced withhelpers.get_config_field(),helpers.get_state(), etc. This provides a single point of access with nil safety.constants.INTEGRATIONSremovedIntegration 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 fromhelpers.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()andmain.disable(),helpers.get_config_field("callbacks")is called once and stored in a local, avoiding repeated global lookups.state:save()useshelpers.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()andColorSchemehandler now usehelpers.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 ascan_layoutrescan) 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.