You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Closing a tab activates its neighbour, not the last-used tab — tab_mru is maintained but never consulted (and is index-based, so reorder corrupts it) #673
Closing a tab activates its positional neighbour, not the tab you were last looking at. There is a tab_mru stack in the engine, it is maintained on every activation — and it is never consulted to choose the successor.
Reported by the user:
If I am looking at a tab, then open 2 tabs and then later close both, I want to see the tab that was at the top before I opened the new ones. That's not what happens.
Root cause
Engine::close_tab (src/core/engine/windows.rs:345) picks the next active tab by index arithmetic alone:
self.active_group_mut().tabs.remove(active_tab_idx);
...// Adjust active tab index
let tabs_len = self.active_group().tabs.len();ifself.active_group().active_tab >= tabs_len {self.active_group_mut().active_tab = tabs_len - 1;}self.tab_mru_touch();
active_tab still holds the closed tab's index, so whatever tab shifted into that slot becomes active (the tab to the right), clamped to the last tab when the rightmost was closed. tab_mru_touch() is called after the decision and merely records whatever adjacency chose — the MRU stack is written but never read here.
⚠ The naive repro passes by accident, and a test written against it will pass with the bug fully present. With tabs [A, B, C] and A never revisited, closing C then B lands on A by pure adjacency — the right answer for the wrong reason. The defect only shows when the tab you were on is not adjacent to the new ones:
tabs: [X, A, Y, Z] active = A (idx 1)
open B -> [X, A, Y, Z, B] active = B
open C -> [X, A, Y, Z, B, C] active = C
close C -> active = B (idx 5 clamped to 4)
close B -> active = Z (idx 4 clamped to 3) <-- expected A
This is the #553 failure mode waiting to happen: a green test that never discriminated. Write the test with a non-adjacent prior tab, and verify it fails against current develop before you fix anything.
Second defect — closing a background tab steals focus
The user called this out explicitly: "you can close a tab without activating it." Today you cannot. close_tab_at(group_id, tab_idx) (windows.rs:430) is documented as "Used for right-click 'Close' on non-active tabs" and implemented as switch to the target group/tab, then close it. So closing a background tab:
changes which tab is active as a side effect,
pushes the closed tab onto the MRU and nav history on its way out,
and therefore corrupts the very stack that should have decided the successor.
Closing a non-active tab must leave the active tab alone and simply remove the closed tab from the stack.
Third defect — the MRU stack is index-based and silently corrupted by reorder
tab_mru stores (GroupId, usize) — a positional index. tab_nav_history, right next to it, correctly stores (GroupId, TabId). The index form forces the fixup loop at windows.rs:392-396 on close, and it is not fixed up at all by the functions that move tabs around:
So today, dragging any tab silently re-points every MRU entry at a different tab. Fixing the close-successor without fixing this just makes the wrong answer arrive more confidently.
Recommended fix
Re-key tab_mru to (GroupId, TabId), matching tab_nav_history. This deletes the index-fixup loop at :392-396 and makes the reorder paths correct by construction rather than by remembering to patch them.
Add one well-named successor function — e.g. Engine::successor_tab_after_close(group, closed_tab_id) -> Option<(GroupId, TabId)> — that reads the MRU and falls back to adjacency only when the MRU has nothing live. Route every close path through it: close_tab, close_tab_at, close_other_tabs (:457), close_tabs_to_right (:480), close_tabs_to_left (:497), close_saved_tabs (:514), close_all_tabs (:799).
Make close_tab_at not activate its target. Remove the closed tab from tab_mru and tab_nav_history and leave active_tab untouched unless the tab being closed is the active one.
Keep the policy in that one function. quadraui#596 (WorkspaceController, open-N-view-one) and quadraui#597 (preview tier, lifted from vimcode) are absorbing exactly this kind of tab-lifecycle policy — vimcode#658 is the adoption issue. A single well-named engine function is liftable into WorkspaceController later; the same rule smeared across seven close paths is not. Do not wait for quadraui: this is src/core/, the shared engine both backends already consume, so the Platform-Neutrality Rule does not gate it.
Acceptance criteria
With the non-adjacent repro above, closing C then B lands on A. Assert this at the black-box tier with a TuiDriver test (in-crate, #[cfg(test)], using the existing make_test_app fixtures) that reads the rendered tab bar — not only as an engine unit test.
Right-click → Close on a background tab leaves the active tab unchanged.
Dragging a tab to a new position does not change which tab a subsequent close activates.
Closing tabs across two editor groups picks the correct successor per group.
tab_mru contains no positional indices.
Existing tab tests in src/core/engine/tests.rs pass with their assertions unchanged. If one needs its assertion edited, that is a behaviour change — stop and reconcile rather than adjusting the number.
cargo build && cargo test EXIT=0.
Files
src/core/engine/windows.rs
src/core/engine/mod.rs
src/core/engine/keys.rs
src/core/engine/tests.rs
src/tui_main/shell_app.rs
Out of scope
The Ctrl-O / Ctrl-I jumplist — filed separately. That is a different structure (jump_list, cursor positions) in a different file, and conflating the two is how this becomes a two-session issue.
Any change to tab ordering in the tab bar. This issue changes only which tab is activated, never where tabs sit.
Summary
Closing a tab activates its positional neighbour, not the tab you were last looking at. There is a
tab_mrustack in the engine, it is maintained on every activation — and it is never consulted to choose the successor.Reported by the user:
Root cause
Engine::close_tab(src/core/engine/windows.rs:345) picks the next active tab by index arithmetic alone:active_tabstill holds the closed tab's index, so whatever tab shifted into that slot becomes active (the tab to the right), clamped to the last tab when the rightmost was closed.tab_mru_touch()is called after the decision and merely records whatever adjacency chose — the MRU stack is written but never read here.⚠ The naive repro passes by accident, and a test written against it will pass with the bug fully present. With tabs
[A, B, C]and A never revisited, closing C then B lands on A by pure adjacency — the right answer for the wrong reason. The defect only shows when the tab you were on is not adjacent to the new ones:This is the #553 failure mode waiting to happen: a green test that never discriminated. Write the test with a non-adjacent prior tab, and verify it fails against current
developbefore you fix anything.Second defect — closing a background tab steals focus
The user called this out explicitly: "you can close a tab without activating it." Today you cannot.
close_tab_at(group_id, tab_idx)(windows.rs:430) is documented as "Used for right-click 'Close' on non-active tabs" and implemented as switch to the target group/tab, then close it. So closing a background tab:Closing a non-active tab must leave the active tab alone and simply remove the closed tab from the stack.
Third defect — the MRU stack is index-based and silently corrupted by reorder
tab_mrustores(GroupId, usize)— a positional index.tab_nav_history, right next to it, correctly stores(GroupId, TabId). The index form forces the fixup loop atwindows.rs:392-396on close, and it is not fixed up at all by the functions that move tabs around:reorder_tab_in_group(windows.rs:2174) —tabs.remove(from)+tabs.insert(to), notab_mrutouch. This is the tab drag-and-drop path (Consolidate editor-group tab drag-drop onto quadraui drop primitives (full TabGroupController adoption deferred — see quadraui#395) #515).move_tab_to_other_group(windows.rs:2004) — moves a tab between groups, notab_mrutouch.move_tab_to_target_group_at(:2058),move_tab_to_new_split(:2096) — same.So today, dragging any tab silently re-points every MRU entry at a different tab. Fixing the close-successor without fixing this just makes the wrong answer arrive more confidently.
Recommended fix
tab_mruto(GroupId, TabId), matchingtab_nav_history. This deletes the index-fixup loop at:392-396and makes the reorder paths correct by construction rather than by remembering to patch them.Engine::successor_tab_after_close(group, closed_tab_id) -> Option<(GroupId, TabId)>— that reads the MRU and falls back to adjacency only when the MRU has nothing live. Route every close path through it:close_tab,close_tab_at,close_other_tabs(:457),close_tabs_to_right(:480),close_tabs_to_left(:497),close_saved_tabs(:514),close_all_tabs(:799).close_tab_atnot activate its target. Remove the closed tab fromtab_mruandtab_nav_historyand leaveactive_tabuntouched unless the tab being closed is the active one.Keep the policy in that one function. quadraui#596 (
WorkspaceController, open-N-view-one) and quadraui#597 (preview tier, lifted from vimcode) are absorbing exactly this kind of tab-lifecycle policy — vimcode#658 is the adoption issue. A single well-named engine function is liftable intoWorkspaceControllerlater; the same rule smeared across seven close paths is not. Do not wait for quadraui: this issrc/core/, the shared engine both backends already consume, so the Platform-Neutrality Rule does not gate it.Acceptance criteria
TuiDrivertest (in-crate,#[cfg(test)], using the existingmake_test_appfixtures) that reads the rendered tab bar — not only as an engine unit test.developand watched it fail. A test that passes both ways is worse than no test here (#448-F: GTK tab click (activate/close) dead when only ONE tab group exists #553).tab_mrucontains no positional indices.src/core/engine/tests.rspass with their assertions unchanged. If one needs its assertion edited, that is a behaviour change — stop and reconcile rather than adjusting the number.cargo build && cargo testEXIT=0.Files
src/core/engine/windows.rssrc/core/engine/mod.rssrc/core/engine/keys.rssrc/core/engine/tests.rssrc/tui_main/shell_app.rsOut of scope
Ctrl-O/Ctrl-Ijumplist — filed separately. That is a different structure (jump_list, cursor positions) in a different file, and conflating the two is how this becomes a two-session issue.