Skip to content

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

Description

@JDonaghy

Summary

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();
if self.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

  1. 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.
  2. 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).
  3. 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.
  • State in the PR that you ran the new test against unfixed develop and 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).
  • 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingcoordTracked by coord-tui pipelinestatus:readyRefined and ready to enter the work pipelineuiUI/rendering

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions