Skip to content

Commit 9046ce1

Browse files
authored
Merge pull request #247 from datlechin/feat/preview-tabs
feat: add preview tabs (#245)
2 parents c10fbe7 + b331b17 commit 9046ce1

24 files changed

Lines changed: 659 additions & 45 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- Preview tabs: single-click opens a temporary preview tab, double-click or editing promotes it to a permanent tab
1213
- Import plugin system: SQL import extracted into a `.tableplugin` bundle, matching the export plugin architecture
1314
- `ImportFormatPlugin` protocol in TableProPluginKit for building custom import format plugins
1415
- SQLImportPlugin as the first import format plugin (SQL files and .gz compressed SQL)

TablePro.xcodeproj/project.pbxproj

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1735,9 +1735,9 @@
17351735
DEVELOPMENT_TEAM = D7HJ5TFYCU;
17361736
DYLIB_COMPATIBILITY_VERSION = 1;
17371737
DYLIB_CURRENT_VERSION = 1;
1738+
DYLIB_INSTALL_NAME_BASE = "@rpath";
17381739
GENERATE_INFOPLIST_FILE = YES;
17391740
INFOPLIST_FILE = "";
1740-
DYLIB_INSTALL_NAME_BASE = "@rpath";
17411741
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
17421742
LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks";
17431743
MACOSX_DEPLOYMENT_TARGET = 14.0;
@@ -1761,9 +1761,9 @@
17611761
DEVELOPMENT_TEAM = D7HJ5TFYCU;
17621762
DYLIB_COMPATIBILITY_VERSION = 1;
17631763
DYLIB_CURRENT_VERSION = 1;
1764+
DYLIB_INSTALL_NAME_BASE = "@rpath";
17641765
GENERATE_INFOPLIST_FILE = YES;
17651766
INFOPLIST_FILE = "";
1766-
DYLIB_INSTALL_NAME_BASE = "@rpath";
17671767
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
17681768
LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks";
17691769
MACOSX_DEPLOYMENT_TARGET = 14.0;
@@ -2475,51 +2475,51 @@
24752475
};
24762476
name = Debug;
24772477
};
2478-
5A86F000600000000 /* Debug */ = {
2478+
5A86E000700000000 /* Release */ = {
24792479
isa = XCBuildConfiguration;
24802480
buildSettings = {
24812481
CODE_SIGN_STYLE = Automatic;
24822482
COMBINE_HIDPI_IMAGES = YES;
24832483
CURRENT_PROJECT_VERSION = 1;
24842484
DEVELOPMENT_TEAM = D7HJ5TFYCU;
24852485
GENERATE_INFOPLIST_FILE = YES;
2486-
INFOPLIST_FILE = Plugins/SQLImportPlugin/Info.plist;
2487-
INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).SQLImportPlugin";
2486+
INFOPLIST_FILE = Plugins/MQLExportPlugin/Info.plist;
2487+
INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).MQLExportPlugin";
24882488
LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks";
24892489
MACOSX_DEPLOYMENT_TARGET = 14.0;
24902490
MARKETING_VERSION = 1.0;
2491-
PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.SQLImportPlugin;
2491+
PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.MQLExportPlugin;
24922492
PRODUCT_NAME = "$(TARGET_NAME)";
24932493
SDKROOT = macosx;
24942494
SKIP_INSTALL = YES;
24952495
SUPPORTED_PLATFORMS = macosx;
24962496
SWIFT_VERSION = 5.9;
24972497
WRAPPER_EXTENSION = tableplugin;
24982498
};
2499-
name = Debug;
2499+
name = Release;
25002500
};
2501-
5A86E000700000000 /* Release */ = {
2501+
5A86F000600000000 /* Debug */ = {
25022502
isa = XCBuildConfiguration;
25032503
buildSettings = {
25042504
CODE_SIGN_STYLE = Automatic;
25052505
COMBINE_HIDPI_IMAGES = YES;
25062506
CURRENT_PROJECT_VERSION = 1;
25072507
DEVELOPMENT_TEAM = D7HJ5TFYCU;
25082508
GENERATE_INFOPLIST_FILE = YES;
2509-
INFOPLIST_FILE = Plugins/MQLExportPlugin/Info.plist;
2510-
INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).MQLExportPlugin";
2509+
INFOPLIST_FILE = Plugins/SQLImportPlugin/Info.plist;
2510+
INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).SQLImportPlugin";
25112511
LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks";
25122512
MACOSX_DEPLOYMENT_TARGET = 14.0;
25132513
MARKETING_VERSION = 1.0;
2514-
PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.MQLExportPlugin;
2514+
PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.SQLImportPlugin;
25152515
PRODUCT_NAME = "$(TARGET_NAME)";
25162516
SDKROOT = macosx;
25172517
SKIP_INSTALL = YES;
25182518
SUPPORTED_PLATFORMS = macosx;
25192519
SWIFT_VERSION = 5.9;
25202520
WRAPPER_EXTENSION = tableplugin;
25212521
};
2522-
name = Release;
2522+
name = Debug;
25232523
};
25242524
5A86F000700000000 /* Release */ = {
25252525
isa = XCBuildConfiguration;

TablePro/AppDelegate.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
519519
for window in NSApp.windows where isMainWindow(window) {
520520
let hasActiveSession = DatabaseManager.shared.activeSessions.values.contains {
521521
window.subtitle == $0.connection.name
522+
|| window.subtitle == "\($0.connection.name) — Preview"
522523
}
523524
if !hasActiveSession {
524525
window.close()

TablePro/ContentView.swift

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,11 @@ struct ContentView: View {
187187
// for the brief window before registration completes.
188188
let isOurWindow = WindowLifecycleMonitor.shared.windows(for: connectionId)
189189
.contains(where: { $0 === notificationWindow })
190-
|| notificationWindow.subtitle == currentSession?.connection.name
190+
|| {
191+
guard let name = currentSession?.connection.name, !name.isEmpty else { return false }
192+
return notificationWindow.subtitle == name
193+
|| notificationWindow.subtitle == "\(name) — Preview"
194+
}()
191195
guard isOurWindow else { return }
192196

193197
if let session = DatabaseManager.shared.activeSessions[connectionId] {
@@ -219,6 +223,24 @@ struct ContentView: View {
219223
onShowAllTables: {
220224
showAllTablesMetadata()
221225
},
226+
onDoubleClick: { table in
227+
let isView = table.type == .view
228+
if let preview = WindowLifecycleMonitor.shared.previewWindow(for: currentSession.connection.id),
229+
let previewCoordinator = MainContentCoordinator.coordinator(for: preview.windowId) {
230+
// If the preview tab shows this table, promote it
231+
if previewCoordinator.tabManager.selectedTab?.tableName == table.name {
232+
previewCoordinator.promotePreviewTab()
233+
} else {
234+
// Preview shows a different table — promote it first, then open this table permanently
235+
previewCoordinator.promotePreviewTab()
236+
sessionState.coordinator.openTableTab(table.name, isView: isView)
237+
}
238+
} else {
239+
// No preview tab — promote current if it's a preview, otherwise open permanently
240+
sessionState.coordinator.promotePreviewTab()
241+
sessionState.coordinator.openTableTab(table.name, isView: isView)
242+
}
243+
},
222244
pendingTruncates: sessionPendingTruncatesBinding,
223245
pendingDeletes: sessionPendingDeletesBinding,
224246
tableOperationOptions: sessionTableOperationOptionsBinding,

TablePro/Core/Services/Infrastructure/SessionStateFactory.swift

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,19 @@ enum SessionStateFactory {
5050
switch payload.tabType {
5151
case .table:
5252
if let tableName = payload.tableName {
53-
tabMgr.addTableTab(
54-
tableName: tableName,
55-
databaseType: connection.type,
56-
databaseName: payload.databaseName ?? connection.database
57-
)
53+
if payload.isPreview {
54+
tabMgr.addPreviewTableTab(
55+
tableName: tableName,
56+
databaseType: connection.type,
57+
databaseName: payload.databaseName ?? connection.database
58+
)
59+
} else {
60+
tabMgr.addTableTab(
61+
tableName: tableName,
62+
databaseType: connection.type,
63+
databaseName: payload.databaseName ?? connection.database
64+
)
65+
}
5866
if let index = tabMgr.selectedTabIndex {
5967
tabMgr.tabs[index].isView = payload.isView
6068
tabMgr.tabs[index].isEditable = !payload.isView

TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,18 @@ internal final class TabPersistenceCoordinator {
3737
/// Save tab state to disk. Called explicitly at named business events
3838
/// (tab switch, window close, quit, etc.).
3939
internal func saveNow(tabs: [QueryTab], selectedTabId: UUID?) {
40-
let persisted = tabs.map { convertToPersistedTab($0) }
40+
let nonPreviewTabs = tabs.filter { !$0.isPreview }
41+
guard !nonPreviewTabs.isEmpty else {
42+
clearSavedState()
43+
return
44+
}
45+
let persisted = nonPreviewTabs.map { convertToPersistedTab($0) }
4146
let connId = connectionId
42-
let selectedId = selectedTabId
47+
let normalizedSelectedId = nonPreviewTabs.contains(where: { $0.id == selectedTabId })
48+
? selectedTabId : nonPreviewTabs.first?.id
4349

4450
Task {
45-
await TabDiskActor.shared.save(connectionId: connId, tabs: persisted, selectedTabId: selectedId)
51+
await TabDiskActor.shared.save(connectionId: connId, tabs: persisted, selectedTabId: normalizedSelectedId)
4652
}
4753
}
4854

@@ -60,8 +66,15 @@ internal final class TabPersistenceCoordinator {
6066
/// Synchronous save for `applicationWillTerminate` where no run loop
6167
/// remains to service async Tasks. Bypasses the actor and writes directly.
6268
internal func saveNowSync(tabs: [QueryTab], selectedTabId: UUID?) {
63-
let persisted = tabs.map { convertToPersistedTab($0) }
64-
TabDiskActor.saveSync(connectionId: connectionId, tabs: persisted, selectedTabId: selectedTabId)
69+
let nonPreviewTabs = tabs.filter { !$0.isPreview }
70+
guard !nonPreviewTabs.isEmpty else {
71+
TabDiskActor.saveSync(connectionId: connectionId, tabs: [], selectedTabId: nil)
72+
return
73+
}
74+
let persisted = nonPreviewTabs.map { convertToPersistedTab($0) }
75+
let normalizedSelectedId = nonPreviewTabs.contains(where: { $0.id == selectedTabId })
76+
? selectedTabId : nonPreviewTabs.first?.id
77+
TabDiskActor.saveSync(connectionId: connectionId, tabs: persisted, selectedTabId: normalizedSelectedId)
6578
}
6679

6780
// MARK: - Clear

TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ internal final class WindowLifecycleMonitor {
1818
let connectionId: UUID
1919
let window: NSWindow
2020
var observer: NSObjectProtocol?
21+
var isPreview: Bool = false
2122
}
2223

2324
private var entries: [UUID: Entry] = [:]
@@ -36,7 +37,7 @@ internal final class WindowLifecycleMonitor {
3637
// MARK: - Registration
3738

3839
/// Register a window and start observing its willCloseNotification.
39-
internal func register(window: NSWindow, connectionId: UUID, windowId: UUID) {
40+
internal func register(window: NSWindow, connectionId: UUID, windowId: UUID, isPreview: Bool = false) {
4041
// Remove any existing entry for this windowId to avoid duplicate observers
4142
if let existing = entries[windowId] {
4243
if let observer = existing.observer {
@@ -58,7 +59,8 @@ internal final class WindowLifecycleMonitor {
5859
entries[windowId] = Entry(
5960
connectionId: connectionId,
6061
window: window,
61-
observer: observer
62+
observer: observer,
63+
isPreview: isPreview
6264
)
6365
}
6466

@@ -115,6 +117,22 @@ internal final class WindowLifecycleMonitor {
115117
entries[windowId] != nil
116118
}
117119

120+
/// Find the first preview window for a connection.
121+
internal func previewWindow(for connectionId: UUID) -> (windowId: UUID, window: NSWindow)? {
122+
entries.first { $0.value.connectionId == connectionId && $0.value.isPreview }
123+
.map { ($0.key, $0.value.window) }
124+
}
125+
126+
/// Look up the NSWindow for a given windowId.
127+
internal func window(for windowId: UUID) -> NSWindow? {
128+
entries[windowId]?.window
129+
}
130+
131+
/// Update the preview flag for a registered window.
132+
internal func setPreview(_ isPreview: Bool, for windowId: UUID) {
133+
entries[windowId]?.isPreview = isPreview
134+
}
135+
118136
// MARK: - Private
119137

120138
private func handleWindowClose(_ closedWindow: NSWindow) {

TablePro/Models/Query/EditorTabPayload.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ internal struct EditorTabPayload: Codable, Hashable {
2929
internal let showStructure: Bool
3030
/// Whether to skip automatic query execution (used for restored tabs that should lazy-load)
3131
internal let skipAutoExecute: Bool
32+
/// Whether this tab is a preview (temporary) tab
33+
internal let isPreview: Bool
3234

3335
internal init(
3436
id: UUID = UUID(),
@@ -39,7 +41,8 @@ internal struct EditorTabPayload: Codable, Hashable {
3941
initialQuery: String? = nil,
4042
isView: Bool = false,
4143
showStructure: Bool = false,
42-
skipAutoExecute: Bool = false
44+
skipAutoExecute: Bool = false,
45+
isPreview: Bool = false
4346
) {
4447
self.id = id
4548
self.connectionId = connectionId
@@ -50,6 +53,7 @@ internal struct EditorTabPayload: Codable, Hashable {
5053
self.isView = isView
5154
self.showStructure = showStructure
5255
self.skipAutoExecute = skipAutoExecute
56+
self.isPreview = isPreview
5357
}
5458

5559
internal init(from decoder: Decoder) throws {
@@ -63,6 +67,7 @@ internal struct EditorTabPayload: Codable, Hashable {
6367
isView = try container.decodeIfPresent(Bool.self, forKey: .isView) ?? false
6468
showStructure = try container.decodeIfPresent(Bool.self, forKey: .showStructure) ?? false
6569
skipAutoExecute = try container.decodeIfPresent(Bool.self, forKey: .skipAutoExecute) ?? false
70+
isPreview = try container.decodeIfPresent(Bool.self, forKey: .isPreview) ?? false
6671
}
6772

6873
/// Whether this payload is a "connection-only" payload — just a connectionId
@@ -83,5 +88,6 @@ internal struct EditorTabPayload: Codable, Hashable {
8388
self.isView = tab.isView
8489
self.showStructure = tab.showStructure
8590
self.skipAutoExecute = skipAutoExecute
91+
self.isPreview = false
8692
}
8793
}

TablePro/Models/Query/QueryTab.swift

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,9 @@ struct QueryTab: Identifiable, Equatable {
353353
// Per-tab column layout (widths/order persist across reloads within tab session)
354354
var columnLayout: ColumnLayoutState
355355

356+
// Whether this tab is a preview (temporary) tab that gets replaced on next navigation
357+
var isPreview: Bool
358+
356359
// Version counter incremented when resultRows changes (used for sort caching)
357360
var resultVersion: Int
358361

@@ -389,6 +392,7 @@ struct QueryTab: Identifiable, Equatable {
389392
self.pagination = PaginationState()
390393
self.filterState = TabFilterState()
391394
self.columnLayout = ColumnLayoutState()
395+
self.isPreview = false
392396
self.resultVersion = 0
393397
self.metadataVersion = 0
394398
}
@@ -420,6 +424,7 @@ struct QueryTab: Identifiable, Equatable {
420424
self.pagination = PaginationState()
421425
self.filterState = TabFilterState()
422426
self.columnLayout = ColumnLayoutState()
427+
self.isPreview = false
423428
self.resultVersion = 0
424429
self.metadataVersion = 0
425430
}
@@ -484,6 +489,8 @@ struct QueryTab: Identifiable, Equatable {
484489
&& lhs.isView == rhs.isView
485490
&& lhs.tabType == rhs.tabType
486491
&& lhs.rowsAffected == rhs.rowsAffected
492+
&& lhs.isPreview == rhs.isPreview
493+
&& lhs.hasUserInteraction == rhs.hasUserInteraction
487494
}
488495
}
489496

@@ -550,13 +557,30 @@ final class QueryTabManager {
550557
selectedTabId = newTab.id
551558
}
552559

560+
func addPreviewTableTab(tableName: String, databaseType: DatabaseType = .mysql, databaseName: String = "") {
561+
let pageSize = AppSettingsManager.shared.dataGrid.defaultPageSize
562+
let query = QueryTab.buildBaseTableQuery(tableName: tableName, databaseType: databaseType)
563+
var newTab = QueryTab(
564+
title: tableName,
565+
query: query,
566+
tabType: .table,
567+
tableName: tableName
568+
)
569+
newTab.pagination = PaginationState(pageSize: pageSize)
570+
newTab.databaseName = databaseName
571+
newTab.isPreview = true
572+
tabs.append(newTab)
573+
selectedTabId = newTab.id
574+
}
575+
553576
/// Replace the currently selected tab's content with a new table.
554577
/// - Returns: `true` if the replacement happened (caller should run the query),
555578
/// `false` if there is no selected tab.
556579
@discardableResult
557580
func replaceTabContent(
558581
tableName: String, databaseType: DatabaseType = .mysql,
559-
isView: Bool = false, databaseName: String = ""
582+
isView: Bool = false, databaseName: String = "",
583+
isPreview: Bool = false
560584
) -> Bool {
561585
guard let selectedId = selectedTabId,
562586
let selectedIndex = tabs.firstIndex(where: { $0.id == selectedId })
@@ -603,6 +627,7 @@ final class QueryTabManager {
603627
tab.columnLayout = ColumnLayoutState()
604628
tab.pagination = PaginationState(pageSize: pageSize)
605629
tab.databaseName = databaseName
630+
tab.isPreview = isPreview
606631
tabs[selectedIndex] = tab
607632
return true
608633
}

TablePro/Models/Settings/AppSettings.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,5 +482,15 @@ struct HistorySettings: Codable, Equatable {
482482

483483
/// Tab behavior settings
484484
struct TabSettings: Codable, Equatable {
485+
var enablePreviewTabs: Bool = true
485486
static let `default` = TabSettings()
487+
488+
init(enablePreviewTabs: Bool = true) {
489+
self.enablePreviewTabs = enablePreviewTabs
490+
}
491+
492+
init(from decoder: Decoder) throws {
493+
let container = try decoder.container(keyedBy: CodingKeys.self)
494+
enablePreviewTabs = try container.decodeIfPresent(Bool.self, forKey: .enablePreviewTabs) ?? true
495+
}
486496
}

0 commit comments

Comments
 (0)