Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Keep Open for a preview tab, by double-clicking it in the tab strip or from its contextual menu. (#2436)
- Kafka driver plugin: topics in the grid, consumer group lag, and KafkaQL for seeking and producing. (#2419)

## [0.68.1] - 2026-08-26
Expand Down
70 changes: 70 additions & 0 deletions TablePro/Core/Services/Infrastructure/EditorTabActivation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
//
// EditorTabActivation.swift
// TablePro
//

import AppKit
import Foundation

/// What a click on a tab in the editor tab strip is asking for.
internal enum EditorTabActivation: Equatable {
case select
/// Select, then keep the tab: the double-click that turns a preview tab permanent.
case selectAndKeep
}

/// One click on a tab, reduced to the two things that decide what it means.
///
/// `NSEvent.clickCount` is only valid on a mouse-down or mouse-up and raises for anything else,
/// so the type is checked here rather than at the call site. A tab's button also answers the
/// keyboard and VoiceOver, and neither leaves a mouse event current: both resolve to nil, which
/// the resolver reads as a plain selection. Keeping a tab from the keyboard goes through the
/// "Keep Open" command instead.
internal struct EditorTabClick: Equatable {
internal let clickCount: Int
internal let hasModifiers: Bool

internal init(clickCount: Int, hasModifiers: Bool) {
self.clickCount = clickCount
self.hasModifiers = hasModifiers
}

internal init?(event: NSEvent?) {
guard let event, Self.carriesClickCount(event.type) else { return nil }
clickCount = event.clickCount
hasModifiers = !event.modifierFlags.intersection(.deviceIndependentFlagsMask)
.subtracting(.capsLock)
.isEmpty
}

/// Only the primary button. A control-click is the context-menu gesture and arrives as a left
/// mouse-down with a modifier, which `hasModifiers` then rejects.
private static func carriesClickCount(_ type: NSEvent.EventType) -> Bool {
type == .leftMouseDown || type == .leftMouseUp
}
}

/// Resolves a click on a tab into what it means, without reference to AppKit or to the strip.
///
/// 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 of them 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. That same coalescing carries the
/// count on past two, so a double-click following a nearby click arrives as counts two and three:
/// the two is refused by the guard above, and a strict `== 2` would refuse the three as well,
/// leaving a genuine double-click doing nothing. Keeping is idempotent and one way, so acting on
/// the three costs nothing.
internal enum EditorTabActivationResolver {
internal static func resolve(
click: EditorTabClick?,
tabId: UUID,
lastActivatedTabId: UUID?
) -> EditorTabActivation {
guard let click, click.clickCount >= 2, !click.hasModifiers else { return .select }
return lastActivatedTabId == tabId ? .selectAndKeep : .select
}
}
20 changes: 20 additions & 0 deletions TablePro/Models/Query/QueryTabManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,26 @@ final class QueryTabManager {
moveTab(id: id, to: index + offset)
}

/// Keeps a preview tab, so the next table opened from the sidebar lands in a tab of its own
/// instead of replacing this one. The single mutator of `isPreview`: every promotion path,
/// the sidebar's, the editor's and the tab strip's, comes through here.
///
/// Promotion never reorders. A tab that moves as it is kept would be a different feature,
/// which is what pinning is in the editors that offer both.
///
/// One way, deliberately. Nothing turns a kept tab back into a preview, so a tab the user
/// asked to hold on to cannot be thrown away by a later click.
func promotePreviewTab(id: UUID) {
guard let index = tabs.firstIndex(where: { $0.id == id }), tabs[index].isPreview else { return }
mutate(at: index) { $0.isPreview = false }
}

/// Whether keeping this tab would change anything, so a menu item can dim rather than be
/// offered and do nothing.
func canPromotePreviewTab(id: UUID) -> Bool {
tabs.first { $0.id == id }?.isPreview ?? false
}

func selectTab(at index: Int) {
guard tabs.indices.contains(index) else { return }
selectedTabId = tabs[index].id
Expand Down
83 changes: 76 additions & 7 deletions TablePro/Views/Main/EditorTabStrip.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ internal struct EditorTabStrip: View {
/// separators are a property of the row: they are hidden for the whole strip while a tab is in
/// flight, so a line does not appear between two tabs that are mid-swap.
@State private var draggingTabId: UUID?
/// The tab the previous click activated, so the second click of a double-click can be told
/// from a click on a neighbour that happened to land inside the double-click window.
@State private var lastActivatedTabId: UUID?
/// A tab whose selection came from a click on the tab itself, which must not be recentred.
/// The track scrolls once the tabs stop fitting, and sliding the clicked tab to the middle
/// takes it out from under a second click that is already on its way.
@State private var clickSelectedTabId: UUID?
@Environment(\.controlActiveState) private var controlActiveState
@Environment(\.colorSchemeContrast) private var colorSchemeContrast
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
Expand All @@ -72,6 +79,7 @@ internal struct EditorTabStrip: View {
.onChange(of: tabManager.tabs.map(\.id)) { _, ids in
if let hoveredTabId, !ids.contains(hoveredTabId) { self.hoveredTabId = nil }
if let draggingTabId, !ids.contains(draggingTabId) { self.draggingTabId = nil }
if let lastActivatedTabId, !ids.contains(lastActivatedTabId) { self.lastActivatedTabId = nil }
}
.accessibilityElement(children: .contain)
.accessibilityLabel(Text("Editor Tabs"))
Expand Down Expand Up @@ -118,6 +126,10 @@ internal struct EditorTabStrip: View {
/// a tab that is scrolled out of sight, so the selection pulls itself into view.
.onChange(of: tabManager.selectedTabId) { _, newValue in
guard let newValue else { return }
guard clickSelectedTabId != newValue else {
clickSelectedTabId = nil
return
}
withMotion(.easeOut(duration: 0.15)) {
scroller.scrollTo(newValue, anchor: .center)
}
Expand Down Expand Up @@ -165,10 +177,12 @@ internal struct EditorTabStrip: View {
hoveredTabId = nil
}
},
onSelect: { tabManager.selectedTabId = tab.id },
onActivate: { activate(tab.id) },
onClose: { onClose(tab.id) },
onCloseOthers: { onCloseOthers(tab.id) },
onCloseAll: onCloseAll,
canKeepOpen: tabManager.canPromotePreviewTab(id: tab.id),
onKeepOpen: { tabManager.promotePreviewTab(id: tab.id) },
canMoveLeft: tabManager.canMoveTab(id: tab.id, by: -1),
canMoveRight: tabManager.canMoveTab(id: tab.id, by: 1),
onMoveLeft: { tabManager.moveTab(id: tab.id, by: -1) },
Expand All @@ -191,6 +205,30 @@ internal struct EditorTabStrip: View {
)
}

/// Selects the tab, and keeps it when the click that got here was the second of a double-click.
///
/// The click count is read off the event AppKit is currently dispatching rather than arbitrated
/// by a SwiftUI gesture, which is what `NSTableView` does with `action` and `doubleAction`.
/// Measured against the shipping strip: a `TapGesture(count: 2)` in any composition holds the
/// selection back 371ms on every click and drops it entirely on the double, and a
/// `simultaneousGesture` selects twice; reading the event costs the same 22ms as selecting.
private func activate(_ tabId: UUID) {
let activation = EditorTabActivationResolver.resolve(
click: EditorTabClick(event: NSApp.currentEvent),
tabId: tabId,
lastActivatedTabId: lastActivatedTabId
)
lastActivatedTabId = tabId
/// Set only when the selection is about to change, so the flag is always consumed by the
/// `onChange` it is meant for rather than left behind to swallow a later Cmd+1.
if tabManager.selectedTabId != tabId {
clickSelectedTabId = tabId
}
tabManager.selectedTabId = tabId
guard activation == .selectAndKeep else { return }
tabManager.promotePreviewTab(id: tabId)
}

private var isWindowActive: Bool {
controlActiveState != .inactive
}
Expand Down Expand Up @@ -273,10 +311,12 @@ private struct EditorTabStripItem: View {
let position: Int
let count: Int
let onHover: (Bool) -> Void
let onSelect: () -> Void
let onActivate: () -> Void
let onClose: () -> Void
let onCloseOthers: () -> Void
let onCloseAll: () -> Void
let canKeepOpen: Bool
let onKeepOpen: () -> Void
let canMoveLeft: Bool
let canMoveRight: Bool
let onMoveLeft: () -> Void
Expand All @@ -301,8 +341,14 @@ private struct EditorTabStripItem: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
.onHover(perform: onHover)
.help(Text(label.description))
.help(Text(tooltip))
.contextMenu {
/// The double-click that keeps a tab is an editor idiom rather than a system one, so
/// it needs a command beside it: 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.
Button(String(localized: "Keep Open"), action: onKeepOpen)
.disabled(!canKeepOpen)
Divider()
Button(String(localized: "Close Tab"), action: onClose)
Button(String(localized: "Close Other Tabs"), action: onCloseOthers)
Button(String(localized: "Close All Tabs"), action: onCloseAll)
Expand All @@ -322,6 +368,13 @@ private struct EditorTabStripItem: View {
.accessibilityValue(Text(positionDescription))
.accessibilityAddTraits(isSelected ? .isSelected : [])
.accessibilityAction(named: Text("Close Tab"), onClose)
/// Offered only where it does something, so the actions rotor matches the contextual menu
/// rather than announcing a command that silently does nothing on a tab already kept.
.accessibilityActions {
if canKeepOpen {
Button(String(localized: "Keep Open"), action: onKeepOpen)
}
}
.accessibilityAction(named: Text("Move Tab Left")) { if canMoveLeft { onMoveLeft() } }
.accessibilityAction(named: Text("Move Tab Right")) { if canMoveRight { onMoveRight() } }
}
Expand All @@ -336,7 +389,7 @@ private struct EditorTabStripItem: View {
/// label never receives the click.
private var surface: some View {
ZStack {
Button(action: onSelect) { title }
Button(action: onActivate) { title }
.buttonStyle(.plain)

HStack(spacing: 0) {
Expand Down Expand Up @@ -394,10 +447,26 @@ private struct EditorTabStripItem: View {
}
}

/// Carries the preview state, because the italic title cannot: an assistive technology is told
/// the string, never the face it is set in, and the HIG asks that no interface rely on a single
/// method to convey a change in state.
private var positionDescription: String {
let place = String(format: String(localized: "%1$d of %2$d"), position, count)
guard tab.execution.finishedUnseenAt != nil, !isSelected else { return place }
return String(format: String(localized: "%@, finished"), place)
var description = String(format: String(localized: "%1$d of %2$d"), position, count)
if tab.isPreview {
description = String(format: String(localized: "%@, preview tab"), description)
}
guard tab.execution.finishedUnseenAt != nil, !isSelected else { return description }
return String(format: String(localized: "%@, finished"), description)
}

/// The pointer's route to the same state the title's italic carries, and the only place the
/// gesture that keeps the tab is named outside the context menu.
private var tooltip: String {
guard tab.isPreview else { return label.description }
return String(
format: String(localized: "%@\nPreview tab. Double-click to keep it open."),
label.description
)
}

/// The system draws both labels in the same face at the same size and separates them by colour
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -392,9 +392,8 @@ extension MainContentCoordinator {
}

func promotePreviewTab() {
guard let (tab, tabIndex) = tabManager.selectedTabAndIndex,
tab.isPreview else { return }
tabManager.mutate(at: tabIndex) { $0.isPreview = false }
guard let selectedTabId = tabManager.selectedTabId else { return }
tabManager.promotePreviewTab(id: selectedTabId)
}

func showAllTablesMetadata() {
Expand Down
71 changes: 71 additions & 0 deletions TableProTests/Models/PreviewTabTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -103,4 +103,75 @@ struct PreviewTabTests {
let payload = EditorTabPayload(connectionId: UUID(), isPreview: true)
#expect(payload.isPreview == true)
}

// MARK: - Keeping a preview tab (issue #2436)

@Test("promotePreviewTab keeps a tab that is not the selected one")
@MainActor
func promoteKeepsAnUnselectedTab() throws {
let manager = QueryTabManager()
try manager.addTableTab(tableName: "users", databaseType: .mysql, databaseName: "mydb", isPreview: true)
let previewTabId = try #require(manager.selectedTabId)
try manager.addTableTab(tableName: "orders", databaseType: .mysql, databaseName: "mydb")

manager.promotePreviewTab(id: previewTabId)

#expect(manager.tabs.first { $0.id == previewTabId }?.isPreview == false)
#expect(manager.selectedTabId != previewTabId)
}

@Test("promotePreviewTab is a no-op on a tab that is already permanent")
@MainActor
func promoteIsANoOpOnAPermanentTab() throws {
let manager = QueryTabManager()
try manager.addTableTab(tableName: "users", databaseType: .mysql, databaseName: "mydb")
let tabId = try #require(manager.selectedTabId)

manager.promotePreviewTab(id: tabId)

#expect(manager.tabs.first { $0.id == tabId }?.isPreview == false)
}

@Test("promotePreviewTab ignores an id no tab has")
@MainActor
func promoteIgnoresAnUnknownId() throws {
let manager = QueryTabManager()
try manager.addTableTab(tableName: "users", databaseType: .mysql, databaseName: "mydb", isPreview: true)

manager.promotePreviewTab(id: UUID())

#expect(manager.selectedTab?.isPreview == true)
}

/// Keeping a tab is not pinning: the tab holds its place in the strip.
@Test("promotePreviewTab does not reorder the strip")
@MainActor
func promoteDoesNotReorderTheStrip() throws {
let manager = QueryTabManager()
try manager.addTableTab(tableName: "users", databaseType: .mysql, databaseName: "mydb", isPreview: true)
let previewTabId = try #require(manager.selectedTabId)
try manager.addTableTab(tableName: "orders", databaseType: .mysql, databaseName: "mydb")
let orderBefore = manager.tabs.map(\.id)

manager.promotePreviewTab(id: previewTabId)

#expect(manager.tabs.map(\.id) == orderBefore)
}

@Test("canPromotePreviewTab answers for a preview tab, a permanent tab and an unknown id")
@MainActor
func canPromoteAnswersEachCase() throws {
let manager = QueryTabManager()
try manager.addTableTab(tableName: "users", databaseType: .mysql, databaseName: "mydb", isPreview: true)
let previewTabId = try #require(manager.selectedTabId)
try manager.addTableTab(tableName: "orders", databaseType: .mysql, databaseName: "mydb")
let permanentTabId = try #require(manager.selectedTabId)

#expect(manager.canPromotePreviewTab(id: previewTabId))
#expect(manager.canPromotePreviewTab(id: permanentTabId) == false)
#expect(manager.canPromotePreviewTab(id: UUID()) == false)

manager.promotePreviewTab(id: previewTabId)
#expect(manager.canPromotePreviewTab(id: previewTabId) == false)
}
}
Loading
Loading