Thanks to visit codestin.com
Credit goes to github.com

Skip to content

fix(tabs): reopen a closed tab into the window already showing its connection - #2829

Merged
datlechin merged 4 commits into
mainfrom
fix/tabs-reopen-closed-tab
Sep 13, 2026
Merged

fix(tabs): reopen a closed tab into the window already showing its connection#2829
datlechin merged 4 commits into
mainfrom
fix/tabs-reopen-closed-tab

Conversation

@datlechin

Copy link
Copy Markdown
Member

Problem

Close one of several tabs, press Cmd+Shift+T, and nothing comes back. The closed tab is also gone from File > Reopen Closed Tab, so it cannot be tried again. This happened whenever the connection's window was already showing it, which is the ordinary case.

Root cause

RecentlyClosedTabReopener.reopen(id:) removed the history entry first (consume(id:)) and only then tried to open the tab. For a connection a window already hosts, it registered the tab in RestorationGroupRegistry and sent a .restoreOrDefault payload through WindowManager.openTab. That path hands the payload to the existing workspace's open(_:), and EditorTabOpener.apply does nothing for .restoreOrDefault. Only a newly built MainContentView ever read the registry, so the group expired after 10 seconds unread. A hosted workspace that was still connecting queued the same payload and later drained it into the same no-op.

The empty-window shortcut (emptyWindowCoordinator) searched the app-wide coordinator registry instead of the window's workspaces and never selected the workspace it found, so a background connection with no tabs got the tab without being shown.

Fix

Reopening is now a workspace operation, and the history entry is removed only once the tab is in a tab list.

  • RecentlyClosedTabStore: consume(id:) is split into restorableEntry(id:), which folds the overflow text back in and removes nothing, and discard(id:).
  • MainContentCoordinator.adoptRestoredTab(_:) (new +ReopenClosedTab extension) adopts the tab and restores hidden columns and filters the way a restored session does, then loads it.
  • ConnectionWorkspace.adoptRestoredTab(_:isStillClosed:onAdopted:) applies the tab when the workspace has a session state, and otherwise queues it for drainPendingPayloads. Payloads and restored tabs share one queue, so they land in order. isStillClosed is asked right before adopting, so a queued tab that was reopened somewhere else while it waited is not opened twice.
  • WindowManager.reopen(_:connectionId:isStillClosed:onAdopted:) selects the hosting workspace before adopting, so the tab lands in the pane on screen, then brings the window to the front. A connection no workspace hosts gets a seeded SessionState under .openContent, the way openTabInNewWindow does it, and onAdopted fires only when that state actually reached a workspace. A state that reached none leaves the coordinator registry, because its coordinator is registered eagerly and the aggregated save would otherwise write its tab into the saved set, and the app's activation role is recounted since no window appeared.
  • TabRouter.reopenClosedTab connects first and then goes through the same path.
  • Deleted the dead path: emptyWindowCoordinator, openWindowTab, RestorationGroupRegistry, and its branch in MainContentView+Setup.handleRestoreOrDefault, along with the consumeDeferredWhenKey parameter only that branch set. RestoreLoadTiming moved into that file as a private type.

Tests

  • MainSplitViewControllerPaneSynchronizationTests (the harness using injectSession and refreshFromActiveSessions):
    • A connected background workspace with one tab adopts a restored tab: 2 tabs, restored one selected, onAdopted once.
    • A workspace with no session state queues the tab; after the session is adopted (two status passes) it holds the tab, selected, and onAdopted fired exactly once.
    • A queued tab whose entry went while it waited is not adopted.
  • RecentlyClosedTabStoreTests: restorableEntry(id:) returns the full query of an entry over 500,000 UTF-16 units and leaves the entry and its overflow file in place; discard(id:) removes both, and a reloaded store is empty. The existing consume cases now use the new API.
  • RecentlyClosedTabReopenerTests (new, temp-dir store, stub adoption): the entry remains while onAdopted has not been called, is gone after it, and isStillClosed follows the history.
  • ReopenClosedTabUITests (new): on the bundled SQLite sample, open Album and Artist, close Artist with Cmd+W, press Cmd+Shift+T, and expect the Artist tab back and selected.

Injected test sessions set status = .connected explicitly.

Verification

All through .claude/skills/fix-issue/scripts/verify.sh:

  • generate: PASS
  • build TablePro: PASS
  • test: PASS, 147 executed, 147 passed, 0 failed, across RecentlyClosedTabStoreTests, RecentlyClosedTabReopenerTests, MainSplitViewControllerPaneSynchronizationTests and every suite that references a changed type (AppLaunchCoordinatorTests, ConnectionWindowChromeTests, ConnectionWorkspaceContainersTests, ConnectionWorkspaceRegistryTests, EditorTabOpenerTests, LaunchIntentFailureOwnershipTests, WindowTabGroupingTests, ConnectionFailureClassifierTests, MainContentCoordinatorTabSwitchTests, TabCloseProtectionTests). All 9 new cases are in the log as passed. The same suites passed again at 77815959f, 147 of 147. At the final commit ffe4b7f4d, two runs hung before the test runner connected (0 cases, inconclusive, not counted); after testmanagerd was restarted the same 13 suites passed, 147 of 147, with all 9 new cases executed.
  • lint on every changed file, tests included: PASS, 0 violations, and again on WindowManager.swift after the follow-up commit.
  • uitest ReopenClosedTabUITests: did not run locally. The UI test target compiled and the runner launched, then failed before any case with "Timed out while enabling automation mode", 0 cases executed. On this machine that means macOS is holding an unanswered "Enable UI Automation" password prompt, which only the user can dismiss. It was tried once and not retried. CI is the first real run of this suite.

Reviewed in two rounds with the code-review skill (Codex is unavailable until 2026-09-19). Round 2 found that the round 1 follow-up's teardown() released a schema provider hold a never-activated coordinator does not own, and that both early returns in the seeded path left the app promoted to the foreground with no window. Both are fixed in the last commit: the failure branch now only removes the coordinator from the registry, as an expired pending entry does, and both returns recount the activation role, as openInNewWindow does.

Found while investigating #2820

  • A tab opened into a workspace before its content view first mounts can be replaced by that view's first restore. MainContentView.initializeAndRestoreTabs runs handleRestoreOrDefault for a .restoreOrDefault or nil payload, and applyRestoredGroup assigns tabManager.tabs outright. On main this already drops queued payloads, for example TabRouter.openTable into a hosted workspace that is still connecting. It also reaches a reopened tab, see review finding 1 below.
  • A detached tab leaves a connection hosted by two windows. host(for:) prefers the frontmost owner and otherwise takes the first one, so a reopened tab can land in the window that is not in front. reopen then brings that window forward.
  • A queued reopen whose connect never succeeds stays queued until the workspace is torn down. Its history entry is kept, so the tab can still be reopened once the connection is up.

Found in review, not fixed here

  1. High. Reopening while the hosted connection is still connecting, or in a connected background workspace whose content view has not mounted yet, adopts the tab and discards its entry. The view's first restore then replaces the tab list with the saved set, so the tab is lost whenever the connection has saved tabs. Main loses the tab in the same case, since it consumed the entry and opened nothing, so this is not a regression. The fix is either for that first restore to keep tabs already in the manager or for the discard to wait until the restore has run. Both change the cold restore path every connection open goes through, which is outside the approved design for this PR.
  2. Low. Pressing Reopen Closed Tab several times while a connection is connecting reopens only the most recent tab, because each press targets the same entry until it is adopted. Nothing is lost: the other entries stay in the history and reopen normally once the connection is up. Moving later presses on to the next entry needs in-flight tracking the approved design does not include.
  3. Low. A closed tab whose connection cannot connect (server down, pre-connect script refused, authentication cancelled) stays at the top of the history, so every Cmd+Shift+T retries that connect and the older entries cannot be reached, since no menu lists them. Main took the entry out on the first press, so the tab was lost but the next press moved on. Keeping the entry until the tab is shown is the point of this change, and choosing when a failed connect may skip an entry is a separate decision.

https://claude.ai/code/session_01SNC28GzXZvLwbgAB8G583A

@datlechin
datlechin merged commit 3df2bb8 into main Sep 13, 2026
7 checks passed
@datlechin
datlechin deleted the fix/tabs-reopen-closed-tab branch September 13, 2026 23:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant