Skip to content

feat(tabs): keep a preview tab by double-clicking it - #2441

Merged
datlechin merged 2 commits into
mainfrom
feat/promote-preview-tab
Aug 26, 2026
Merged

feat(tabs): keep a preview tab by double-clicking it#2441
datlechin merged 2 commits into
mainfrom
feat/promote-preview-tab

Conversation

@datlechin

Copy link
Copy Markdown
Member

Fixes #2436.

A table already open in a preview tab could only be kept by closing it and double-clicking it again in the sidebar. Double-clicking the tab itself did nothing.

Root cause

EditorTabStripItem's only pointer wiring is Button(action: onSelect). Nothing in the strip reads a click count, so a double-click never reached promotePreviewTab(), which already existed and was already bound to the sidebar double-click, FK navigation and the edit-promotes-preview path. A missing input route to an existing action, not missing state.

The reported "no visual indication" half was already shipped for sighted users: the strip has drawn a preview tab's title in italic since well before this issue. What was missing there is the accessibility channel, since accessibilityValue carried only the position, and the tooltip, which said nothing about preview state at all.

The fix

Reading NSApp.currentEvent's click count inside the tab's own button action, which is what NSTableView does with action and doubleAction. Every SwiftUI multi-click gesture is disqualified, measured rather than assumed: driven with CGEvents posted to .cghidEventTap so the window server assigned the click count itself, against a button carrying the strip's own shape, NSEvent.doubleClickInterval at its 0.5s default.

shape single click double click
Button alone (what shipped) select +33ms select, select
+ .onTapGesture(count: 2) select +371ms PROMOTE only, select never fires
+ .simultaneousGesture(TapGesture(count: 2)) select +26ms select, PROMOTE, select
+ NSApp.currentEvent.clickCount select +22ms select, select, PROMOTE

So a count: 2 tap gesture holds every tab selection back 371ms and drops it entirely on the double, and a simultaneous one selects twice. Neither is visible to any other test: the tab still selects and still promotes, just late, or twice. EditorTabStripGestureConventionTests fails on either spelling reappearing in this file, and carries the table.

A selection made by clicking a tab no longer recentres the strip. The track scrolls once the tabs stop fitting, and the existing scrollTo(_:anchor: .center) on a selection change would slide the clicked tab to the middle in a 0.15s animation, taking it out from under the second click that was already on its way. The recentre still runs for every selection that comes from outside the strip, which is what it exists for.

The second click has to land on the tab the first one activated. Two clicks close enough in time and space arrive as one click of count two whichever view each hit, and tabs sit flush against each other, so a pair straddling a boundary would otherwise keep a tab the user only meant to select. NSTableView has the same exposure and lives with it, because its double-click opens the row the second click hit and a single click would have led there anyway; here it would change a tab's state without being asked. Any count above one keeps the tab rather than two exactly, since that same coalescing carries the count past two and a strict == 2 would refuse a genuine double-click that followed a nearby click.

isPreview's single mutator moves to QueryTabManager.promotePreviewTab(id:), beside moveTab(id:by:), because the context menu can fire on a tab that is not selected and the coordinator's version only ever acted on the selected one. MainContentCoordinator.promotePreviewTab() delegates to it, so its four existing callers are unchanged. The strip calls it directly, as it already does for selectedTabId and moveTab.

Alongside the gesture:

  • Keep Open on the tab's contextual menu, disabled once the tab is permanent. A gesture with no menu equivalent cannot be found by a user who does not already expect it, and cannot be performed at all by VoiceOver. TablePlus ships the gesture alone and is criticised for exactly that; VS Code and Xcode both ship the command.
  • A matching accessibility action, and "preview tab" folded into the tab's accessibility value, because an assistive technology is told the string and never the face it is set in.
  • A tooltip naming the state and the gesture.

Promotion does not reorder the tab (that is pinning, a different feature in the editors that offer both) and is one way, matching TablePlus, VS Code, DataGrip and Xcode.

Not done, deliberately

No File-menu item or keyboard shortcut: Xcode puts this on the tab's contextual menu only. No pinning, no demotion, and no change to preview-tab persistence, which deliberately restores every tab permanent.

One consequence of that worth naming: the strip is drawn only once a window holds more than one tab (ConnectionWindowPaneResolver.showsTabStrip), so neither new affordance exists while a lone preview tab is open. Double-clicking the table in the sidebar, or selecting it and pressing Return, already keeps it there, and docs/features/tabs.mdx now says when the strip appears rather than implying it is always available. A File-menu command bound to the selected tab would close the gap if it turns out to matter.

Tests

  • EditorTabActivationTests: the activation resolver over single, double, triple, modified, caps-lock, cross-tab, first-click and no-event cases, plus reading a click off an NSEvent and rejecting a right-click, since NSEvent.clickCount raises for anything that is not a mouse-down or mouse-up.
  • PreviewTabTests: promotePreviewTab(id:) on an unselected tab, on an already-permanent tab and on an unknown id; that it does not reorder the strip; and canPromotePreviewTab(id:) for each case.
  • OpenTableTabTests: a kept tab is not reused by the next table opened from the sidebar, which is the behaviour the issue asks for.
  • EditorTabStripGestureConventionTests: the source guard described above.
  • EditorTabKeepOpenUITests: double-clicking a preview tab keeps it so a third table gets its own tab, and a single click on it does not.

Verification

generate, build, test over the 15 suites owning the changed types, lint over all three targets (0 violations), and docs.

Reviewed by /code-review rather than Codex, which failed with Your workspace is out of credits and never read the diff. Five findings, all acted on: the auto-scroll race above, >= 2 over == 2, two UI tests that sampled their baseline tab count before waiting for the strip (a false green in one and a flake in the other), the docs overclaim, and a "Keep Open" accessibility action offered on tabs where it does nothing.

Screenshots

Not captured. The two visible states are the new Keep Open item on a tab's contextual menu, and a tab title losing its italic once it is kept, which needs two tables open and a double-click between the frames. Driving a Debug build for it did not get far enough: the object browser published no AXOutline to click, so the sidebar rows could not be reached. EditorTabKeepOpenUITests covers both states as behaviour instead.

https://claude.ai/code/session_01KGirjf9LTGgxJBs1vJsFYh

@mintlify

mintlify Bot commented Aug 26, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
TablePro 🟢 Ready View Preview Aug 26, 2026, 5:13 AM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Signed-off-by: Ngô Quốc Đạt <datlechin@gmail.com>
@datlechin
datlechin merged commit 0270511 into main Aug 26, 2026
4 of 5 checks passed
@datlechin
datlechin deleted the feat/promote-preview-tab branch August 26, 2026 05:13
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.

I want to switch the table to “Always Show” mode when I double-click a tab

1 participant