diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b12ac93..020df663 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Preview tabs: single-click opens a temporary preview tab, double-click or editing promotes it to a permanent tab - Import plugin system: SQL import extracted into a `.tableplugin` bundle, matching the export plugin architecture - `ImportFormatPlugin` protocol in TableProPluginKit for building custom import format plugins - SQLImportPlugin as the first import format plugin (SQL files and .gz compressed SQL) diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 946c59d1..ab1bb25c 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -1735,9 +1735,9 @@ DEVELOPMENT_TEAM = D7HJ5TFYCU; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = ""; - DYLIB_INSTALL_NAME_BASE = "@rpath"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; @@ -1761,9 +1761,9 @@ DEVELOPMENT_TEAM = D7HJ5TFYCU; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = ""; - DYLIB_INSTALL_NAME_BASE = "@rpath"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; @@ -2475,7 +2475,7 @@ }; name = Debug; }; - 5A86F000600000000 /* Debug */ = { + 5A86E000700000000 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; @@ -2483,12 +2483,12 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = D7HJ5TFYCU; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = Plugins/SQLImportPlugin/Info.plist; - INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).SQLImportPlugin"; + INFOPLIST_FILE = Plugins/MQLExportPlugin/Info.plist; + INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).MQLExportPlugin"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.SQLImportPlugin; + PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.MQLExportPlugin; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = macosx; SKIP_INSTALL = YES; @@ -2496,9 +2496,9 @@ SWIFT_VERSION = 5.9; WRAPPER_EXTENSION = tableplugin; }; - name = Debug; + name = Release; }; - 5A86E000700000000 /* Release */ = { + 5A86F000600000000 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; @@ -2506,12 +2506,12 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = D7HJ5TFYCU; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = Plugins/MQLExportPlugin/Info.plist; - INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).MQLExportPlugin"; + INFOPLIST_FILE = Plugins/SQLImportPlugin/Info.plist; + INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).SQLImportPlugin"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.MQLExportPlugin; + PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.SQLImportPlugin; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = macosx; SKIP_INSTALL = YES; @@ -2519,7 +2519,7 @@ SWIFT_VERSION = 5.9; WRAPPER_EXTENSION = tableplugin; }; - name = Release; + name = Debug; }; 5A86F000700000000 /* Release */ = { isa = XCBuildConfiguration; diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index 7b21e4be..61259999 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -519,6 +519,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { for window in NSApp.windows where isMainWindow(window) { let hasActiveSession = DatabaseManager.shared.activeSessions.values.contains { window.subtitle == $0.connection.name + || window.subtitle == "\($0.connection.name) — Preview" } if !hasActiveSession { window.close() diff --git a/TablePro/ContentView.swift b/TablePro/ContentView.swift index af51b389..d7f230de 100644 --- a/TablePro/ContentView.swift +++ b/TablePro/ContentView.swift @@ -187,7 +187,11 @@ struct ContentView: View { // for the brief window before registration completes. let isOurWindow = WindowLifecycleMonitor.shared.windows(for: connectionId) .contains(where: { $0 === notificationWindow }) - || notificationWindow.subtitle == currentSession?.connection.name + || { + guard let name = currentSession?.connection.name, !name.isEmpty else { return false } + return notificationWindow.subtitle == name + || notificationWindow.subtitle == "\(name) — Preview" + }() guard isOurWindow else { return } if let session = DatabaseManager.shared.activeSessions[connectionId] { @@ -219,6 +223,24 @@ struct ContentView: View { onShowAllTables: { showAllTablesMetadata() }, + onDoubleClick: { table in + let isView = table.type == .view + if let preview = WindowLifecycleMonitor.shared.previewWindow(for: currentSession.connection.id), + let previewCoordinator = MainContentCoordinator.coordinator(for: preview.windowId) { + // If the preview tab shows this table, promote it + if previewCoordinator.tabManager.selectedTab?.tableName == table.name { + previewCoordinator.promotePreviewTab() + } else { + // Preview shows a different table — promote it first, then open this table permanently + previewCoordinator.promotePreviewTab() + sessionState.coordinator.openTableTab(table.name, isView: isView) + } + } else { + // No preview tab — promote current if it's a preview, otherwise open permanently + sessionState.coordinator.promotePreviewTab() + sessionState.coordinator.openTableTab(table.name, isView: isView) + } + }, pendingTruncates: sessionPendingTruncatesBinding, pendingDeletes: sessionPendingDeletesBinding, tableOperationOptions: sessionTableOperationOptionsBinding, diff --git a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift index a1db9cbf..e1343767 100644 --- a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift +++ b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift @@ -50,11 +50,19 @@ enum SessionStateFactory { switch payload.tabType { case .table: if let tableName = payload.tableName { - tabMgr.addTableTab( - tableName: tableName, - databaseType: connection.type, - databaseName: payload.databaseName ?? connection.database - ) + if payload.isPreview { + tabMgr.addPreviewTableTab( + tableName: tableName, + databaseType: connection.type, + databaseName: payload.databaseName ?? connection.database + ) + } else { + tabMgr.addTableTab( + tableName: tableName, + databaseType: connection.type, + databaseName: payload.databaseName ?? connection.database + ) + } if let index = tabMgr.selectedTabIndex { tabMgr.tabs[index].isView = payload.isView tabMgr.tabs[index].isEditable = !payload.isView diff --git a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift index cee11601..ec09b953 100644 --- a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift +++ b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift @@ -37,12 +37,18 @@ internal final class TabPersistenceCoordinator { /// Save tab state to disk. Called explicitly at named business events /// (tab switch, window close, quit, etc.). internal func saveNow(tabs: [QueryTab], selectedTabId: UUID?) { - let persisted = tabs.map { convertToPersistedTab($0) } + let nonPreviewTabs = tabs.filter { !$0.isPreview } + guard !nonPreviewTabs.isEmpty else { + clearSavedState() + return + } + let persisted = nonPreviewTabs.map { convertToPersistedTab($0) } let connId = connectionId - let selectedId = selectedTabId + let normalizedSelectedId = nonPreviewTabs.contains(where: { $0.id == selectedTabId }) + ? selectedTabId : nonPreviewTabs.first?.id Task { - await TabDiskActor.shared.save(connectionId: connId, tabs: persisted, selectedTabId: selectedId) + await TabDiskActor.shared.save(connectionId: connId, tabs: persisted, selectedTabId: normalizedSelectedId) } } @@ -60,8 +66,15 @@ internal final class TabPersistenceCoordinator { /// Synchronous save for `applicationWillTerminate` where no run loop /// remains to service async Tasks. Bypasses the actor and writes directly. internal func saveNowSync(tabs: [QueryTab], selectedTabId: UUID?) { - let persisted = tabs.map { convertToPersistedTab($0) } - TabDiskActor.saveSync(connectionId: connectionId, tabs: persisted, selectedTabId: selectedTabId) + let nonPreviewTabs = tabs.filter { !$0.isPreview } + guard !nonPreviewTabs.isEmpty else { + TabDiskActor.saveSync(connectionId: connectionId, tabs: [], selectedTabId: nil) + return + } + let persisted = nonPreviewTabs.map { convertToPersistedTab($0) } + let normalizedSelectedId = nonPreviewTabs.contains(where: { $0.id == selectedTabId }) + ? selectedTabId : nonPreviewTabs.first?.id + TabDiskActor.saveSync(connectionId: connectionId, tabs: persisted, selectedTabId: normalizedSelectedId) } // MARK: - Clear diff --git a/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift b/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift index 555fac31..ba5a3722 100644 --- a/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift +++ b/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift @@ -18,6 +18,7 @@ internal final class WindowLifecycleMonitor { let connectionId: UUID let window: NSWindow var observer: NSObjectProtocol? + var isPreview: Bool = false } private var entries: [UUID: Entry] = [:] @@ -36,7 +37,7 @@ internal final class WindowLifecycleMonitor { // MARK: - Registration /// Register a window and start observing its willCloseNotification. - internal func register(window: NSWindow, connectionId: UUID, windowId: UUID) { + internal func register(window: NSWindow, connectionId: UUID, windowId: UUID, isPreview: Bool = false) { // Remove any existing entry for this windowId to avoid duplicate observers if let existing = entries[windowId] { if let observer = existing.observer { @@ -58,7 +59,8 @@ internal final class WindowLifecycleMonitor { entries[windowId] = Entry( connectionId: connectionId, window: window, - observer: observer + observer: observer, + isPreview: isPreview ) } @@ -115,6 +117,22 @@ internal final class WindowLifecycleMonitor { entries[windowId] != nil } + /// Find the first preview window for a connection. + internal func previewWindow(for connectionId: UUID) -> (windowId: UUID, window: NSWindow)? { + entries.first { $0.value.connectionId == connectionId && $0.value.isPreview } + .map { ($0.key, $0.value.window) } + } + + /// Look up the NSWindow for a given windowId. + internal func window(for windowId: UUID) -> NSWindow? { + entries[windowId]?.window + } + + /// Update the preview flag for a registered window. + internal func setPreview(_ isPreview: Bool, for windowId: UUID) { + entries[windowId]?.isPreview = isPreview + } + // MARK: - Private private func handleWindowClose(_ closedWindow: NSWindow) { diff --git a/TablePro/Models/Query/EditorTabPayload.swift b/TablePro/Models/Query/EditorTabPayload.swift index 2b950fbc..f2c3c32a 100644 --- a/TablePro/Models/Query/EditorTabPayload.swift +++ b/TablePro/Models/Query/EditorTabPayload.swift @@ -29,6 +29,8 @@ internal struct EditorTabPayload: Codable, Hashable { internal let showStructure: Bool /// Whether to skip automatic query execution (used for restored tabs that should lazy-load) internal let skipAutoExecute: Bool + /// Whether this tab is a preview (temporary) tab + internal let isPreview: Bool internal init( id: UUID = UUID(), @@ -39,7 +41,8 @@ internal struct EditorTabPayload: Codable, Hashable { initialQuery: String? = nil, isView: Bool = false, showStructure: Bool = false, - skipAutoExecute: Bool = false + skipAutoExecute: Bool = false, + isPreview: Bool = false ) { self.id = id self.connectionId = connectionId @@ -50,6 +53,7 @@ internal struct EditorTabPayload: Codable, Hashable { self.isView = isView self.showStructure = showStructure self.skipAutoExecute = skipAutoExecute + self.isPreview = isPreview } internal init(from decoder: Decoder) throws { @@ -63,6 +67,7 @@ internal struct EditorTabPayload: Codable, Hashable { isView = try container.decodeIfPresent(Bool.self, forKey: .isView) ?? false showStructure = try container.decodeIfPresent(Bool.self, forKey: .showStructure) ?? false skipAutoExecute = try container.decodeIfPresent(Bool.self, forKey: .skipAutoExecute) ?? false + isPreview = try container.decodeIfPresent(Bool.self, forKey: .isPreview) ?? false } /// Whether this payload is a "connection-only" payload — just a connectionId @@ -83,5 +88,6 @@ internal struct EditorTabPayload: Codable, Hashable { self.isView = tab.isView self.showStructure = tab.showStructure self.skipAutoExecute = skipAutoExecute + self.isPreview = false } } diff --git a/TablePro/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index 4ef7f82e..68f52458 100644 --- a/TablePro/Models/Query/QueryTab.swift +++ b/TablePro/Models/Query/QueryTab.swift @@ -353,6 +353,9 @@ struct QueryTab: Identifiable, Equatable { // Per-tab column layout (widths/order persist across reloads within tab session) var columnLayout: ColumnLayoutState + // Whether this tab is a preview (temporary) tab that gets replaced on next navigation + var isPreview: Bool + // Version counter incremented when resultRows changes (used for sort caching) var resultVersion: Int @@ -389,6 +392,7 @@ struct QueryTab: Identifiable, Equatable { self.pagination = PaginationState() self.filterState = TabFilterState() self.columnLayout = ColumnLayoutState() + self.isPreview = false self.resultVersion = 0 self.metadataVersion = 0 } @@ -420,6 +424,7 @@ struct QueryTab: Identifiable, Equatable { self.pagination = PaginationState() self.filterState = TabFilterState() self.columnLayout = ColumnLayoutState() + self.isPreview = false self.resultVersion = 0 self.metadataVersion = 0 } @@ -484,6 +489,8 @@ struct QueryTab: Identifiable, Equatable { && lhs.isView == rhs.isView && lhs.tabType == rhs.tabType && lhs.rowsAffected == rhs.rowsAffected + && lhs.isPreview == rhs.isPreview + && lhs.hasUserInteraction == rhs.hasUserInteraction } } @@ -550,13 +557,30 @@ final class QueryTabManager { selectedTabId = newTab.id } + func addPreviewTableTab(tableName: String, databaseType: DatabaseType = .mysql, databaseName: String = "") { + let pageSize = AppSettingsManager.shared.dataGrid.defaultPageSize + let query = QueryTab.buildBaseTableQuery(tableName: tableName, databaseType: databaseType) + var newTab = QueryTab( + title: tableName, + query: query, + tabType: .table, + tableName: tableName + ) + newTab.pagination = PaginationState(pageSize: pageSize) + newTab.databaseName = databaseName + newTab.isPreview = true + tabs.append(newTab) + selectedTabId = newTab.id + } + /// Replace the currently selected tab's content with a new table. /// - Returns: `true` if the replacement happened (caller should run the query), /// `false` if there is no selected tab. @discardableResult func replaceTabContent( tableName: String, databaseType: DatabaseType = .mysql, - isView: Bool = false, databaseName: String = "" + isView: Bool = false, databaseName: String = "", + isPreview: Bool = false ) -> Bool { guard let selectedId = selectedTabId, let selectedIndex = tabs.firstIndex(where: { $0.id == selectedId }) @@ -603,6 +627,7 @@ final class QueryTabManager { tab.columnLayout = ColumnLayoutState() tab.pagination = PaginationState(pageSize: pageSize) tab.databaseName = databaseName + tab.isPreview = isPreview tabs[selectedIndex] = tab return true } diff --git a/TablePro/Models/Settings/AppSettings.swift b/TablePro/Models/Settings/AppSettings.swift index 9e605692..0ec059ad 100644 --- a/TablePro/Models/Settings/AppSettings.swift +++ b/TablePro/Models/Settings/AppSettings.swift @@ -482,5 +482,15 @@ struct HistorySettings: Codable, Equatable { /// Tab behavior settings struct TabSettings: Codable, Equatable { + var enablePreviewTabs: Bool = true static let `default` = TabSettings() + + init(enablePreviewTabs: Bool = true) { + self.enablePreviewTabs = enablePreviewTabs + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + enablePreviewTabs = try container.decodeIfPresent(Bool.self, forKey: .enablePreviewTabs) ?? true + } } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 2e807fe4..61b333c2 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -6005,6 +6005,16 @@ } } }, + "Enable preview tabs" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bật tab xem trước" + } + } + } + }, "Enable SSH Tunnel" : { "localizations" : { "vi" : { @@ -14360,6 +14370,16 @@ } } }, + "Single-clicking a table opens a temporary tab that gets replaced on next click." : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhấp một lần vào bảng sẽ mở tab tạm thời, tab này sẽ được thay thế khi nhấp tiếp." + } + } + } + }, "Size" : { "localizations" : { "vi" : { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 8ab1cd70..30db4805 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -62,13 +62,26 @@ extension MainContentCoordinator { } } - // If no tabs exist (empty state), add a table tab directly + // If no tabs exist (empty state), add a table tab directly. + // In preview mode, mark it as preview so subsequent clicks replace it. if tabManager.tabs.isEmpty { - tabManager.addTableTab( - tableName: tableName, - databaseType: connection.type, - databaseName: currentDatabase - ) + if AppSettingsManager.shared.tabs.enablePreviewTabs { + tabManager.addPreviewTableTab( + tableName: tableName, + databaseType: connection.type, + databaseName: currentDatabase + ) + if let wid = windowId { + WindowLifecycleMonitor.shared.setPreview(true, for: wid) + WindowLifecycleMonitor.shared.window(for: wid)?.subtitle = "\(connection.name) — Preview" + } + } else { + tabManager.addTableTab( + tableName: tableName, + databaseType: connection.type, + databaseName: currentDatabase + ) + } if let tabIndex = tabManager.selectedTabIndex { tabManager.tabs[tabIndex].isView = isView tabManager.tabs[tabIndex].isEditable = !isView @@ -99,6 +112,12 @@ extension MainContentCoordinator { return } + // Preview tab mode: reuse or create a preview tab instead of a new native window + if AppSettingsManager.shared.tabs.enablePreviewTabs { + openPreviewTab(tableName, isView: isView, databaseName: currentDatabase, showStructure: showStructure) + return + } + // If current tab has unsaved changes, open in a new native tab instead of replacing if changeManager.hasChanges { let payload = EditorTabPayload( @@ -125,6 +144,88 @@ extension MainContentCoordinator { WindowOpener.shared.openNativeTab(payload) } + // MARK: - Preview Tabs + + func openPreviewTab( + _ tableName: String, isView: Bool = false, + databaseName: String = "", showStructure: Bool = false + ) { + // Check if a preview window already exists for this connection + if let preview = WindowLifecycleMonitor.shared.previewWindow(for: connectionId) { + if let previewCoordinator = Self.coordinator(for: preview.windowId) { + // Skip if preview tab already shows this table + if let current = previewCoordinator.tabManager.selectedTab, + current.tableName == tableName, + current.databaseName == databaseName { + preview.window.makeKeyAndOrderFront(nil) + return + } + previewCoordinator.tabManager.replaceTabContent( + tableName: tableName, + databaseType: connection.type, + isView: isView, + databaseName: databaseName, + isPreview: true + ) + if let tabIndex = previewCoordinator.tabManager.selectedTabIndex { + previewCoordinator.tabManager.tabs[tabIndex].showStructure = showStructure + previewCoordinator.tabManager.tabs[tabIndex].pagination.reset() + AppState.shared.isCurrentTabEditable = !isView && !tableName.isEmpty + previewCoordinator.toolbarState.isTableTab = true + } + preview.window.makeKeyAndOrderFront(nil) + previewCoordinator.runQuery() + return + } + } + + // No preview window exists but current tab is already a preview: replace in-place + if let selectedTab = tabManager.selectedTab, selectedTab.isPreview { + // Skip if already showing this table + if selectedTab.tableName == tableName, selectedTab.databaseName == databaseName { + return + } + tabManager.replaceTabContent( + tableName: tableName, + databaseType: connection.type, + isView: isView, + databaseName: databaseName, + isPreview: true + ) + if let tabIndex = tabManager.selectedTabIndex { + tabManager.tabs[tabIndex].showStructure = showStructure + tabManager.tabs[tabIndex].pagination.reset() + AppState.shared.isCurrentTabEditable = !isView && !tableName.isEmpty + toolbarState.isTableTab = true + } + runQuery() + return + } + + // No preview tab anywhere: create a new native preview tab + let payload = EditorTabPayload( + connectionId: connection.id, + tabType: .table, + tableName: tableName, + databaseName: databaseName, + isView: isView, + showStructure: showStructure, + isPreview: true + ) + WindowOpener.shared.openNativeTab(payload) + } + + func promotePreviewTab() { + guard let tabIndex = tabManager.selectedTabIndex, + tabManager.tabs[tabIndex].isPreview else { return } + tabManager.tabs[tabIndex].isPreview = false + + if let wid = windowId { + WindowLifecycleMonitor.shared.setPreview(false, for: wid) + WindowLifecycleMonitor.shared.window(for: wid)?.subtitle = connection.name + } + } + func showAllTablesMetadata() { let sql: String switch connection.type { diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index e829cc8e..c7485e78 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -57,6 +57,9 @@ final class MainContentCoordinator { RowOperationsManager(changeManager: changeManager) }() + /// Stable identifier for this coordinator's window (set by MainContentView on appear) + var windowId: UUID? + // MARK: - Published State var schemaProvider: SQLSchemaProvider @@ -131,11 +134,18 @@ final class MainContentCoordinator { Self.activeCoordinators.removeValue(forKey: ObjectIdentifier(self)) } + /// Find a coordinator by its window identifier. + static func coordinator(for windowId: UUID) -> MainContentCoordinator? { + activeCoordinators.values.first { $0.windowId == windowId } + } + /// Collect all tabs from all active coordinators for a given connectionId. + /// Preview tabs are excluded from persistence since they are temporary. private static func aggregatedTabs(for connectionId: UUID) -> [QueryTab] { activeCoordinators.values .filter { $0.connectionId == connectionId } .flatMap { $0.tabManager.tabs } + .filter { !$0.isPreview } } /// Get selected tab ID from any coordinator for a given connectionId. diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index b0b24d0c..b8a433d9 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -198,14 +198,21 @@ struct MainContentView: View { let window = NSApp.keyWindow ?? NSApp.windows.first { $0.isVisible && $0.title == targetTitle } guard let window else { return } - window.subtitle = connection.name + let isPreview = tabManager.selectedTab?.isPreview ?? payload?.isPreview ?? false + if isPreview { + window.subtitle = "\(connection.name) — Preview" + } else { + window.subtitle = connection.name + } window.tabbingIdentifier = "com.TablePro.main.\(connection.id.uuidString)" window.tabbingMode = .preferred + coordinator.windowId = windowId WindowLifecycleMonitor.shared.register( window: window, connectionId: connection.id, - windowId: windowId + windowId: windowId, + isPreview: isPreview ) viewWindow = window isKeyWindow = window.isKeyWindow @@ -243,7 +250,8 @@ struct MainContentView: View { guard !WindowLifecycleMonitor.shared.hasWindows(for: connectionId) else { return } let hasVisibleWindow = NSApp.windows.contains { window in - window.isVisible && window.subtitle == connectionName + window.isVisible && (window.subtitle == connectionName + || window.subtitle == "\(connectionName) — Preview") } if !hasVisibleWindow { await DatabaseManager.shared.disconnectSession(connectionId) @@ -309,11 +317,12 @@ struct MainContentView: View { DispatchQueue.main.async { syncSidebarToCurrentTab() } - // Lazy-load: execute query for restored tabs that skipped auto-execute + // Lazy-load: execute query for restored tabs that skipped auto-execute, + // or re-query tabs whose row data was evicted while inactive. if let tab = tabManager.selectedTab, tab.tabType == .table, - tab.resultRows.isEmpty, - tab.lastExecutedAt == nil, + (tab.resultRows.isEmpty || tab.rowBuffer.isEvicted), + (tab.lastExecutedAt == nil || tab.rowBuffer.isEvicted), !tab.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { coordinator.runQuery() @@ -625,13 +634,21 @@ struct MainContentView: View { // as the view is being deallocated guard !coordinator.isTearingDown else { return } - // Persist tab changes explicitly - if newTabs.isEmpty { + // Promote preview tab if user has interacted with it + if let tab = tabManager.selectedTab, tab.isPreview, tab.hasUserInteraction { + coordinator.promotePreviewTab() + } + + // Persist tab changes (exclude preview tabs from persistence) + let persistableTabs = newTabs.filter { !$0.isPreview } + if persistableTabs.isEmpty { coordinator.persistence.clearSavedState() } else { + let normalizedSelectedId = persistableTabs.contains(where: { $0.id == tabManager.selectedTabId }) + ? tabManager.selectedTabId : persistableTabs.first?.id coordinator.persistence.saveNow( - tabs: newTabs, - selectedTabId: tabManager.selectedTabId + tabs: persistableTabs, + selectedTabId: normalizedSelectedId ) } } @@ -676,10 +693,15 @@ struct MainContentView: View { return } + let isPreviewMode = AppSettingsManager.shared.tabs.enablePreviewTabs + let hasPreview = WindowLifecycleMonitor.shared.previewWindow(for: connection.id) != nil + let result = SidebarNavigationResult.resolve( clickedTableName: tableName, currentTabTableName: tabManager.selectedTab?.tableName, - hasExistingTabs: !tabManager.tabs.isEmpty + hasExistingTabs: !tabManager.tabs.isEmpty, + isPreviewTabMode: isPreviewMode, + hasPreviewTab: hasPreview ) switch result { @@ -691,6 +713,8 @@ struct MainContentView: View { coordinator.openTableTab(tableName, isView: isView) case .revertAndOpenNewWindow: coordinator.openTableTab(tableName, isView: isView) + case .replacePreviewTab, .openNewPreviewTab: + coordinator.openTableTab(tableName, isView: isView) } AppState.shared.hasTableSelection = !newTables.isEmpty diff --git a/TablePro/Views/Main/SidebarNavigationResult.swift b/TablePro/Views/Main/SidebarNavigationResult.swift index b6f8dc90..7ea14d69 100644 --- a/TablePro/Views/Main/SidebarNavigationResult.swift +++ b/TablePro/Views/Main/SidebarNavigationResult.swift @@ -19,6 +19,10 @@ enum SidebarNavigationResult: Equatable { /// Reverting synchronously prevents SwiftUI from rendering the [B] state /// before coalescing back to [A] — eliminating the visible flash. case revertAndOpenNewWindow + /// Preview mode: replace the contents of the existing preview tab. + case replacePreviewTab + /// Preview mode: no preview tab exists yet, so create a new one. + case openNewPreviewTab /// Pure function — no side effects. Determines how a sidebar click should be handled. /// @@ -27,16 +31,29 @@ enum SidebarNavigationResult: Equatable { /// - currentTabTableName: The table name of this window's active tab /// (`nil` when the active tab is a query or create-table tab). /// - hasExistingTabs: `true` when this window already has at least one tab open. + /// - isPreviewTabMode: `true` when preview/temporary tab mode is enabled. + /// - hasPreviewTab: `true` when a preview tab already exists in this window. static func resolve( clickedTableName: String, currentTabTableName: String?, - hasExistingTabs: Bool + hasExistingTabs: Bool, + isPreviewTabMode: Bool = false, + hasPreviewTab: Bool = false ) -> SidebarNavigationResult { // Programmatic sync (e.g. didBecomeKeyNotification): the selection already // reflects the active tab — nothing to do. if currentTabTableName == clickedTableName { return .skip } // No existing tabs: open the table in-place within this window. if !hasExistingTabs { return .openInPlace } + + // Preview tab logic: reuse or create a preview tab instead of opening a new window tab. + if isPreviewTabMode { + if hasPreviewTab { + return .replacePreviewTab + } + return .openNewPreviewTab + } + // Default: revert sidebar synchronously (no flash), then open in a new native tab. return .revertAndOpenNewWindow } diff --git a/TablePro/Views/Settings/GeneralSettingsView.swift b/TablePro/Views/Settings/GeneralSettingsView.swift index 87d6e03b..414dd0ab 100644 --- a/TablePro/Views/Settings/GeneralSettingsView.swift +++ b/TablePro/Views/Settings/GeneralSettingsView.swift @@ -11,6 +11,7 @@ import SwiftUI struct GeneralSettingsView: View { @Binding var settings: GeneralSettings var updaterBridge: UpdaterBridge + @Bindable private var settingsManager = AppSettingsManager.shared @State private var initialLanguage: AppLanguage? private static let standardTimeouts = [10, 20, 30, 40, 50, 60, 90, 120, 180, 300, 600] @@ -73,6 +74,14 @@ struct GeneralSettingsView: View { .font(.caption) .foregroundStyle(.secondary) } + + Section("Tabs") { + Toggle("Enable preview tabs", isOn: $settingsManager.tabs.enablePreviewTabs) + + Text("Single-clicking a table opens a temporary tab that gets replaced on next click.") + .font(.caption) + .foregroundStyle(.secondary) + } } .formStyle(.grouped) .scrollContentBackground(.hidden) diff --git a/TablePro/Views/Sidebar/DoubleClickDetector.swift b/TablePro/Views/Sidebar/DoubleClickDetector.swift new file mode 100644 index 00000000..492fb3f8 --- /dev/null +++ b/TablePro/Views/Sidebar/DoubleClickDetector.swift @@ -0,0 +1,103 @@ +// +// DoubleClickDetector.swift +// TablePro +// +// Transparent overlay that detects double-clicks on sidebar rows. +// Used for preview tabs: single-click opens a preview tab, double-click opens a permanent tab. +// +// Uses a single shared NSEvent monitor instead of one per row to avoid +// O(n) monitors when tables are numerous. +// + +import AppKit +import SwiftUI + +struct DoubleClickDetector: NSViewRepresentable { + var onDoubleClick: () -> Void + + func makeNSView(context: Context) -> SidebarDoubleClickView { + let view = SidebarDoubleClickView() + view.onDoubleClick = onDoubleClick + return view + } + + func updateNSView(_ nsView: SidebarDoubleClickView, context: Context) { + nsView.onDoubleClick = onDoubleClick + } +} + +final class SidebarDoubleClickView: NSView { + var onDoubleClick: (() -> Void)? + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if window != nil { + SharedDoubleClickMonitor.shared.register(self) + } else { + SharedDoubleClickMonitor.shared.unregister(self) + } + } + + override func hitTest(_ point: NSPoint) -> NSView? { + nil + } + + override var acceptsFirstResponder: Bool { false } + + deinit { + MainActor.assumeIsolated { + SharedDoubleClickMonitor.shared.unregister(self) + } + } +} + +/// Single shared event monitor that dispatches double-clicks to registered views. +/// Avoids O(n) monitors when many DoubleClickDetector overlays exist in the sidebar. +/// All callers run on the main thread (NSView lifecycle + NSEvent monitor). +@MainActor +private final class SharedDoubleClickMonitor { + static let shared = SharedDoubleClickMonitor() + + private var registeredViews = NSHashTable.weakObjects() + private var monitor: Any? + + private init() {} + + func register(_ view: SidebarDoubleClickView) { + registeredViews.add(view) + if monitor == nil { + monitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseUp) { [weak self] event in + self?.handleMouseUp(event) + return event + } + } + } + + func unregister(_ view: SidebarDoubleClickView) { + registeredViews.remove(view) + if registeredViews.allObjects.isEmpty, let monitor { + NSEvent.removeMonitor(monitor) + self.monitor = nil + } + } + + private func handleMouseUp(_ event: NSEvent) { + guard event.clickCount == 2 else { return } + + for view in registeredViews.allObjects { + guard let viewWindow = view.window, + event.window === viewWindow else { continue } + let locationInView = view.convert(event.locationInWindow, from: nil) + if view.bounds.contains(locationInView) { + view.onDoubleClick?() + break + } + } + } + + deinit { + if let monitor { + NSEvent.removeMonitor(monitor) + } + } +} diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 2021acb0..59ac0414 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -22,6 +22,7 @@ struct SidebarView: View { var activeTableName: String? var onShowAllTables: (() -> Void)? + var onDoubleClick: ((TableInfo) -> Void)? var connectionId: UUID /// Computed on the view (not ViewModel) so SwiftUI tracks both @@ -43,6 +44,7 @@ struct SidebarView: View { sidebarState: SharedSidebarState, activeTableName: String? = nil, onShowAllTables: (() -> Void)? = nil, + onDoubleClick: ((TableInfo) -> Void)? = nil, pendingTruncates: Binding>, pendingDeletes: Binding>, tableOperationOptions: Binding<[String: TableOperationOptions]>, @@ -52,6 +54,7 @@ struct SidebarView: View { ) { _tables = tables self.sidebarState = sidebarState + self.onDoubleClick = onDoubleClick _pendingTruncates = pendingTruncates _pendingDeletes = pendingDeletes let selectedBinding = Binding( @@ -189,6 +192,11 @@ struct SidebarView: View { isPendingDelete: pendingDeletes.contains(table.name) ) .tag(table) + .overlay { + DoubleClickDetector { + onDoubleClick?(table) + } + } .contextMenu { SidebarContextMenu( clickedTable: table, diff --git a/TableProTests/Models/PreviewTabTests.swift b/TableProTests/Models/PreviewTabTests.swift new file mode 100644 index 00000000..fbd68eaf --- /dev/null +++ b/TableProTests/Models/PreviewTabTests.swift @@ -0,0 +1,104 @@ +// +// PreviewTabTests.swift +// TableProTests +// +// Tests for preview tab data model behavior +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Preview Tab") +struct PreviewTabTests { + @Test("QueryTab isPreview defaults to false") + func queryTabIsPreviewDefaultsFalse() { + let tab = QueryTab(title: "Test", tabType: .query) + #expect(tab.isPreview == false) + } + + @Test("QueryTab from persisted tab is not preview") + func queryTabFromPersistedIsNotPreview() { + let persisted = PersistedTab( + id: UUID(), + title: "users", + query: "SELECT * FROM users", + tabType: .table, + tableName: "users" + ) + let tab = QueryTab(from: persisted) + #expect(tab.isPreview == false) + } + + @Test("TabSettings enablePreviewTabs defaults to true") + func tabSettingsDefaultsToTrue() { + let settings = TabSettings.default + #expect(settings.enablePreviewTabs == true) + } + + @Test("Preview table tab can be added via addPreviewTableTab") + @MainActor + func addPreviewTableTab() { + let manager = QueryTabManager() + manager.addPreviewTableTab(tableName: "users", databaseType: .mysql, databaseName: "mydb") + #expect(manager.tabs.count == 1) + #expect(manager.selectedTab?.isPreview == true) + #expect(manager.selectedTab?.tableName == "users") + } + + @Test("replaceTabContent can set isPreview flag") + @MainActor + func replaceTabContentSetsPreview() { + let manager = QueryTabManager() + manager.addPreviewTableTab(tableName: "users", databaseType: .mysql, databaseName: "mydb") + let replaced = manager.replaceTabContent( + tableName: "orders", + databaseType: .mysql, + databaseName: "mydb", + isPreview: true + ) + #expect(replaced == true) + #expect(manager.selectedTab?.isPreview == true) + #expect(manager.selectedTab?.tableName == "orders") + } + + @Test("replaceTabContent defaults to non-preview") + @MainActor + func replaceTabContentDefaultsNonPreview() { + let manager = QueryTabManager() + manager.addPreviewTableTab(tableName: "users", databaseType: .mysql, databaseName: "mydb") + let replaced = manager.replaceTabContent( + tableName: "orders", + databaseType: .mysql, + databaseName: "mydb" + ) + #expect(replaced == true) + #expect(manager.selectedTab?.isPreview == false) + } + + @Test("TabSettings decodes with missing enablePreviewTabs key (backward compat)") + func tabSettingsBackwardCompatDecoding() throws { + let json = Data("{}".utf8) + let decoded = try JSONDecoder().decode(TabSettings.self, from: json) + #expect(decoded.enablePreviewTabs == true) + } + + @Test("TabSettings decodes with enablePreviewTabs set to false") + func tabSettingsDecodesExplicitFalse() throws { + let json = Data(#"{"enablePreviewTabs":false}"#.utf8) + let decoded = try JSONDecoder().decode(TabSettings.self, from: json) + #expect(decoded.enablePreviewTabs == false) + } + + @Test("EditorTabPayload isPreview defaults to false") + func editorTabPayloadDefaultsFalse() { + let payload = EditorTabPayload(connectionId: UUID()) + #expect(payload.isPreview == false) + } + + @Test("EditorTabPayload isPreview can be set to true") + func editorTabPayloadCanBePreview() { + let payload = EditorTabPayload(connectionId: UUID(), isPreview: true) + #expect(payload.isPreview == true) + } +} diff --git a/TableProTests/Views/SidebarNavigationResultTests.swift b/TableProTests/Views/SidebarNavigationResultTests.swift index affaa88f..815be79f 100644 --- a/TableProTests/Views/SidebarNavigationResultTests.swift +++ b/TableProTests/Views/SidebarNavigationResultTests.swift @@ -274,4 +274,66 @@ struct SidebarNavigationResultTests { ) #expect(result == .openInPlace) } + + // MARK: - Preview tab mode + + @Test("Preview mode disabled returns existing behavior") + func previewModeDisabledReturnsExistingBehavior() { + let result = SidebarNavigationResult.resolve( + clickedTableName: "orders", + currentTabTableName: "users", + hasExistingTabs: true, + isPreviewTabMode: false, + hasPreviewTab: false + ) + #expect(result == .revertAndOpenNewWindow) + } + + @Test("Preview mode enabled with existing preview tab returns replacePreviewTab") + func previewModeWithExistingPreviewTab() { + let result = SidebarNavigationResult.resolve( + clickedTableName: "orders", + currentTabTableName: "users", + hasExistingTabs: true, + isPreviewTabMode: true, + hasPreviewTab: true + ) + #expect(result == .replacePreviewTab) + } + + @Test("Preview mode enabled without preview tab returns openNewPreviewTab") + func previewModeWithoutPreviewTab() { + let result = SidebarNavigationResult.resolve( + clickedTableName: "orders", + currentTabTableName: "users", + hasExistingTabs: true, + isPreviewTabMode: true, + hasPreviewTab: false + ) + #expect(result == .openNewPreviewTab) + } + + @Test("Preview mode skip still works when table matches") + func previewModeSkipWhenTableMatches() { + let result = SidebarNavigationResult.resolve( + clickedTableName: "users", + currentTabTableName: "users", + hasExistingTabs: true, + isPreviewTabMode: true, + hasPreviewTab: true + ) + #expect(result == .skip) + } + + @Test("Preview mode with no existing tabs still opens in-place") + func previewModeNoExistingTabsOpensInPlace() { + let result = SidebarNavigationResult.resolve( + clickedTableName: "orders", + currentTabTableName: nil, + hasExistingTabs: false, + isPreviewTabMode: true, + hasPreviewTab: false + ) + #expect(result == .openInPlace) + } } diff --git a/docs/customization/settings.mdx b/docs/customization/settings.mdx index f08763ac..67363676 100644 --- a/docs/customization/settings.mdx +++ b/docs/customization/settings.mdx @@ -261,6 +261,16 @@ Enable this for single-tab browsing, similar to TablePlus's default behavior. /> +### Preview Tabs + +| Setting | Default | Description | +|---------|---------|-------------| +| **Enable preview tabs** | On | Single-clicking a table opens a temporary tab that gets replaced on next click | + +When enabled, single-clicking a table in the sidebar opens a preview tab. The preview tab is replaced when you click a different table. Double-clicking a table or interacting with the preview tab (sorting, filtering, editing) makes it permanent. + +Preview tabs show "Preview" in the window subtitle and are not persisted across app restarts. + ## Keyboard Settings Customize keyboard shortcuts for menu actions. See [Keyboard Shortcuts](/features/keyboard-shortcuts#customizing-shortcuts) for full details. diff --git a/docs/features/tabs.mdx b/docs/features/tabs.mdx index 71c38ae1..a34260cc 100644 --- a/docs/features/tabs.mdx +++ b/docs/features/tabs.mdx @@ -63,6 +63,21 @@ When you click a table in the sidebar: You can enable **Reuse clean table tab** in **Settings** > **Tabs** for TablePlus-style behavior: clicking a different table replaces the current table tab if it has no unsaved changes, no user interaction (sorting, filtering, etc.), and is not pinned. This keeps the tab bar clean while protecting your work. +### Preview Tabs + +Preview tabs reduce tab clutter when browsing tables. Single-clicking a table in the sidebar opens a temporary tab that gets replaced when you click a different table. This is similar to VS Code's preview tabs. + +Preview tabs are indicated by "Preview" in the window subtitle. They become permanent when you: + +- **Double-click** a table in the sidebar +- **Interact** with the tab (sort, filter, edit data, select rows) + +Preview tabs are not saved across app restarts. + + +Disable preview tabs in **Settings** > **Tabs** if you prefer every click to open a permanent tab. + + {/* Screenshot: Different tab types: Query and Table */} +### Tab Xem Trước + +| Cài đặt | Mặc định | Mô tả | +|---------|---------|-------------| +| **Enable preview tabs** | Bật | Click đơn bảng mở tab tạm thời, bị thay thế khi click bảng khác | + +Khi bật, click đơn bảng trong sidebar mở tab xem trước. Tab xem trước bị thay thế khi click bảng khác. Click đúp bảng hoặc tương tác với tab (sắp xếp, lọc, chỉnh sửa) biến nó thành tab cố định. + +Tab xem trước hiển thị "Preview" trong subtitle cửa sổ và không được lưu qua các lần khởi động lại. + ## Cài đặt Bàn phím Tùy chỉnh phím tắt menu. Xem [Phím tắt](/vi/features/keyboard-shortcuts#tùy-chỉnh-phím-tắt) để biết chi tiết. diff --git a/docs/vi/features/tabs.mdx b/docs/vi/features/tabs.mdx index 63ce9a85..ab28764f 100644 --- a/docs/vi/features/tabs.mdx +++ b/docs/vi/features/tabs.mdx @@ -63,6 +63,21 @@ Khi click bảng trong sidebar: Bật **Tái sử dụng tab bảng sạch** trong **Cài đặt** > **Tab** để có hành vi giống TablePlus: click bảng khác sẽ thay thế tab bảng hiện tại nếu không có thay đổi chưa lưu, không có tương tác người dùng (sắp xếp, lọc, v.v.) và không bị ghim. Giữ thanh tab gọn mà vẫn bảo vệ công việc. +### Tab Xem Trước + +Tab xem trước giảm lộn xộn tab khi duyệt bảng. Click đơn bảng trong sidebar mở tab tạm thời, sẽ bị thay thế khi click bảng khác. Tương tự tab xem trước của VS Code. + +Tab xem trước được chỉ báo bằng "Preview" trong subtitle cửa sổ. Chúng trở thành tab cố định khi: + +- **Click đúp** bảng trong sidebar +- **Tương tác** với tab (sắp xếp, lọc, chỉnh sửa, chọn hàng) + +Tab xem trước không được lưu qua các lần khởi động lại. + + +Tắt tab xem trước trong **Settings** > **Tabs** nếu muốn mỗi click đều mở tab cố định. + + {/* Screenshot: Các loại tab khác nhau: Truy vấn và Bảng */}