diff --git a/CHANGELOG.md b/CHANGELOG.md index ec642cb0..9066ece0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Reopen closed tab with Cmd+Shift+T (up to 20 tabs in history) +- Pinned tabs — pin important tabs to prevent accidental close, always at left side +- MRU tab selection — closing a tab now selects the most recently active tab, not just adjacent + +### Changed + +- Replace native macOS window tabs with in-app tab bar for instant tab switching (was 600ms+ per tab) +- Tab restoration now loads all tabs in a single window instead of opening N separate windows + ### Fixed - Raw SQL injection via external URL scheme deeplinks — now requires user confirmation diff --git a/TablePro/AppDelegate+WindowConfig.swift b/TablePro/AppDelegate+WindowConfig.swift index ae1e35a0..1de81248 100644 --- a/TablePro/AppDelegate+WindowConfig.swift +++ b/TablePro/AppDelegate+WindowConfig.swift @@ -69,12 +69,11 @@ extension AppDelegate { }) else { return } - let payload = EditorTabPayload( - connectionId: connectionId, - intent: .newEmptyTab - ) + // Add an in-app tab to the active coordinator instead of creating a new native window MainActor.assumeIsolated { - WindowOpener.shared.openNativeTab(payload) + if let coordinator = MainContentCoordinator.firstCoordinator(for: connectionId) { + coordinator.addNewQueryTab() + } } } @@ -253,7 +252,9 @@ extension AppDelegate { } if isMainWindow(window) && !configuredWindows.contains(windowId) { - window.tabbingMode = .preferred + // In-app tabs: disallow native window tabbing for editor tabs. + // Connection-level grouping (groupAllConnectionTabs) uses addTabbedWindow below. + window.tabbingMode = .disallowed window.isRestorable = false configuredWindows.insert(windowId) @@ -293,6 +294,7 @@ extension AppDelegate { && $0.tabbingIdentifier == resolvedIdentifier } } + if let existingWindow = matchingWindow { let targetWindow = existingWindow.tabbedWindows?.last ?? existingWindow targetWindow.addTabbedWindow(window, ordered: .above) @@ -311,7 +313,6 @@ extension AppDelegate { let remainingMainWindows = NSApp.windows.filter { $0 !== window && isMainWindow($0) && $0.isVisible }.count - if remainingMainWindows == 0 { NotificationCenter.default.post(name: .mainWindowWillClose, object: nil) openWelcomeWindow() @@ -365,9 +366,6 @@ extension AppDelegate { } continue } catch { - windowLogger.error( - "Auto-reconnect failed for '\(connection.name)': \(error.localizedDescription)" - ) for window in WindowLifecycleMonitor.shared.windows(for: connection.id) { window.close() } diff --git a/TablePro/ContentView.swift b/TablePro/ContentView.swift index abddd800..dea3b24d 100644 --- a/TablePro/ContentView.swift +++ b/TablePro/ContentView.swift @@ -13,7 +13,7 @@ import TableProPluginKit struct ContentView: View { private static let logger = Logger(subsystem: "com.TablePro", category: "ContentView") - /// Payload identifying what this native window-tab should display. + /// Payload identifying what this connection window should display. /// nil = default empty query tab (first window on connection). let payload: EditorTabPayload? @@ -54,7 +54,7 @@ struct ContentView: View { // Resolve session synchronously to avoid "Connecting..." flash. // For payload with connectionId: look up that specific session. - // For nil payload (native tab bar "+"): fall back to current session. + // For nil payload: fall back to current session. var resolvedSession: ConnectionSession? if let connectionId = payload?.connectionId { resolvedSession = DatabaseManager.shared.activeSessions[connectionId] @@ -63,20 +63,10 @@ struct ContentView: View { } _currentSession = State(initialValue: resolvedSession) - if let session = resolvedSession { - _rightPanelState = State(initialValue: RightPanelState()) - let state = SessionStateFactory.create( - connection: session.connection, payload: payload - ) - _sessionState = State(initialValue: state) - if payload?.intent == .newEmptyTab, - let tabTitle = state.coordinator.tabManager.selectedTab?.title { - _windowTitle = State(initialValue: tabTitle) - } - } else { - _rightPanelState = State(initialValue: nil) - _sessionState = State(initialValue: nil) - } + // SessionState is created lazily in ensureSessionState() on first + // connection event — not in init, which SwiftUI may call speculatively. + _rightPanelState = State(initialValue: nil) + _sessionState = State(initialValue: nil) } var body: some View { @@ -113,15 +103,7 @@ struct ContentView: View { currentSession = DatabaseManager.shared.activeSessions[connectionId] columnVisibility = currentSession != nil ? .all : .detailOnly if let session = currentSession { - if rightPanelState == nil { - rightPanelState = RightPanelState() - } - if sessionState == nil { - sessionState = SessionStateFactory.create( - connection: session.connection, - payload: payload - ) - } + ensureSessionState(for: session) } } else { currentSession = nil @@ -171,21 +153,9 @@ struct ContentView: View { activeTableName: windowTitle, 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) - } + // Promote any in-app preview tab, then open the table permanently + sessionState.coordinator.promotePreviewTab() + sessionState.coordinator.openTableTab(table.name, isView: isView) }, pendingTruncates: sessionPendingTruncatesBinding, pendingDeletes: sessionPendingDeletesBinding, @@ -345,18 +315,30 @@ struct ContentView: View { return } currentSession = newSession - // Update window title on first session connect (fixes cold-launch stale title) - if payload?.tableName == nil, windowTitle == "SQL Query" || windowTitle.hasSuffix(" Query") { - windowTitle = newSession.connection.name - } + ensureSessionState(for: newSession) + } + + /// Create SessionState exactly once per connection. Called from reactive + /// handlers (onChange, handleConnectionStatusChange) — never from init, + /// because SwiftUI may call init speculatively during body evaluation. + private func ensureSessionState(for session: ConnectionSession) { + guard sessionState == nil else { return } if rightPanelState == nil { rightPanelState = RightPanelState() } - if sessionState == nil { - sessionState = SessionStateFactory.create( - connection: newSession.connection, - payload: payload - ) + let state = SessionStateFactory.create( + connection: session.connection, + payload: payload + ) + sessionState = state + columnVisibility = .all + // Update window title on first connect + if payload?.intent == .newEmptyTab, + let tabTitle = state.coordinator.tabManager.selectedTab?.title { + windowTitle = tabTitle + } else if payload?.tableName == nil, + windowTitle == "SQL Query" || windowTitle.hasSuffix(" Query") { + windowTitle = session.connection.name } } diff --git a/TablePro/Core/Database/DatabaseManager+Sessions.swift b/TablePro/Core/Database/DatabaseManager+Sessions.swift index 69812257..600e33db 100644 --- a/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -144,6 +144,7 @@ extension DatabaseManager { // Save as last connection for "Reopen Last Session" feature AppSettingsStorage.shared.saveLastConnectionId(connection.id) + persistOpenConnectionIds() // Post notification for reliable delivery NotificationCenter.default.post(name: .databaseDidConnect, object: nil) @@ -256,6 +257,7 @@ extension DatabaseManager { await stopHealthMonitor(for: sessionId) session.driver?.disconnect() + removeSessionEntry(for: sessionId) // Clean up shared schema cache for this connection @@ -263,6 +265,7 @@ extension DatabaseManager { // Clean up shared sidebar state for this connection SharedSidebarState.removeConnection(sessionId) + persistOpenConnectionIds() // If this was the current session, switch to another or clear if currentSessionId == sessionId { @@ -315,6 +318,18 @@ extension DatabaseManager { NotificationCenter.default.post(name: .connectionStatusDidChange, object: connectionId) } + /// Persist active connection IDs to UserDefaults immediately. + /// Apple's recommended pattern: save critical state incrementally as it changes, + /// not only in applicationWillTerminate (which doesn't fire on SIGKILL/Force Quit). + private func persistOpenConnectionIds() { + let connections = ConnectionStorage.shared.loadConnections() + let activeIds = Set(activeSessions.keys) + let openIds = connections + .filter { activeIds.contains($0.id) } + .map(\.id) + AppSettingsStorage.shared.saveLastOpenConnectionIds(openIds) + } + #if DEBUG /// Test-only: inject a session for unit testing without real database connections internal func injectSession(_ session: ConnectionSession, for connectionId: UUID) { diff --git a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift index f2266d48..b123675c 100644 --- a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift +++ b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift @@ -149,7 +149,8 @@ internal final class TabPersistenceCoordinator { isView: tab.isView, databaseName: tab.databaseName, schemaName: tab.schemaName, - sourceFileURL: tab.sourceFileURL + sourceFileURL: tab.sourceFileURL, + isPinned: tab.isPinned ) } } diff --git a/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift b/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift index fdc6adf7..32d771ce 100644 --- a/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift +++ b/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift @@ -21,6 +21,9 @@ internal final class WindowLifecycleMonitor { weak var window: NSWindow? var observer: NSObjectProtocol? var isPreview: Bool = false + /// Called on NSWindow.willCloseNotification — deterministic teardown + /// for coordinator and panel state that must not depend on SwiftUI onDisappear. + var onWindowClose: (@MainActor () -> Void)? } private var entries: [UUID: Entry] = [:] @@ -40,7 +43,11 @@ internal final class WindowLifecycleMonitor { // MARK: - Registration /// Register a window and start observing its willCloseNotification. - internal func register(window: NSWindow, connectionId: UUID, windowId: UUID, isPreview: Bool = false) { + internal func register( + window: NSWindow, connectionId: UUID, windowId: UUID, + isPreview: Bool = false, + onWindowClose: (@MainActor () -> Void)? = nil + ) { // Remove any existing entry for this windowId to avoid duplicate observers if let existing = entries[windowId] { if existing.window !== window { @@ -66,7 +73,8 @@ internal final class WindowLifecycleMonitor { connectionId: connectionId, window: window, observer: observer, - isPreview: isPreview + isPreview: isPreview, + onWindowClose: onWindowClose ) } @@ -205,6 +213,11 @@ internal final class WindowLifecycleMonitor { let closedConnectionId = entry.connectionId + // Deterministic teardown: coordinator and panel state are cleaned up here, + // triggered by NSWindow.willCloseNotification — not by SwiftUI onDisappear + // which fires transiently during view hierarchy reconstruction. + entry.onWindowClose?() + if let observer = entry.observer { NotificationCenter.default.removeObserver(observer) } diff --git a/TablePro/Core/Services/Infrastructure/WindowOpener.swift b/TablePro/Core/Services/Infrastructure/WindowOpener.swift index c75ca71f..477293c4 100644 --- a/TablePro/Core/Services/Infrastructure/WindowOpener.swift +++ b/TablePro/Core/Services/Infrastructure/WindowOpener.swift @@ -51,7 +51,7 @@ internal final class WindowOpener { /// Whether any payloads are pending — used for orphan detection in windowDidBecomeKey. internal var hasPendingPayloads: Bool { !pendingPayloads.isEmpty } - /// Opens a new native window tab with the given payload. + /// Opens a new connection window with the given payload. /// Falls back to .openMainWindow notification if openWindow is not yet available /// (cold launch from Dock menu before any SwiftUI view has appeared). internal func openNativeTab(_ payload: EditorTabPayload) { diff --git a/TablePro/Models/Query/EditorTabPayload.swift b/TablePro/Models/Query/EditorTabPayload.swift index f0ca4784..e7daac14 100644 --- a/TablePro/Models/Query/EditorTabPayload.swift +++ b/TablePro/Models/Query/EditorTabPayload.swift @@ -2,8 +2,8 @@ // EditorTabPayload.swift // TablePro // -// Payload for identifying the content of a native window tab. -// Used with WindowGroup(for:) to create native macOS window tabs. +// Payload for identifying the content of a connection window. +// Used with WindowGroup(for:) to create connection windows. // import Foundation @@ -18,7 +18,7 @@ internal enum TabIntent: String, Codable, Hashable { case restoreOrDefault } -/// Payload passed to each native window tab to identify what content it should display. +/// Payload passed to each connection window to identify what content it should display. /// Each window-tab receives this at creation time via `openWindow(id:value:)`. internal struct EditorTabPayload: Codable, Hashable { /// Unique identifier for this window-tab (ensures openWindow always creates a new window) diff --git a/TablePro/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index 3e56804b..9611321c 100644 --- a/TablePro/Models/Query/QueryTab.swift +++ b/TablePro/Models/Query/QueryTab.swift @@ -104,6 +104,9 @@ struct QueryTab: Identifiable, Equatable { // Whether this tab is a preview (temporary) tab that gets replaced on next navigation var isPreview: Bool + // Whether this tab is pinned (cannot be closed, always at left) + var isPinned: Bool + // Multi-result-set support (Phase 0: added alongside existing single-result properties) var resultSets: [ResultSet] = [] var activeResultSetId: UUID? @@ -130,6 +133,15 @@ struct QueryTab: Identifiable, Equatable { // Version counter incremented on pagination changes, used to scroll grid to top var paginationVersion: Int + /// Composite version for NSHostingView rootView rebuild decisions. + /// Includes all state that affects visual content. + var contentVersion: Int { + var v = resultVersion &+ metadataVersion &+ paginationVersion + if errorMessage != nil { v = v &+ 1 } + if isExecuting { v = v &+ 2 } + return v + } + /// Whether the editor content differs from the last saved/loaded file content. /// Returns false for tabs not backed by a file. /// Uses O(1) length pre-check to avoid O(n) string comparison on every keystroke. @@ -174,6 +186,7 @@ struct QueryTab: Identifiable, Equatable { self.filterState = TabFilterState() self.columnLayout = ColumnLayoutState() self.isPreview = false + self.isPinned = false self.sourceFileURL = nil self.resultVersion = 0 self.metadataVersion = 0 @@ -211,6 +224,7 @@ struct QueryTab: Identifiable, Equatable { self.filterState = TabFilterState() self.columnLayout = ColumnLayoutState() self.isPreview = false + self.isPinned = persisted.isPinned self.sourceFileURL = persisted.sourceFileURL self.resultVersion = 0 self.metadataVersion = 0 diff --git a/TablePro/Models/Query/QueryTabManager.swift b/TablePro/Models/Query/QueryTabManager.swift index 10c5120f..4986b4db 100644 --- a/TablePro/Models/Query/QueryTabManager.swift +++ b/TablePro/Models/Query/QueryTabManager.swift @@ -19,6 +19,49 @@ final class QueryTabManager { @ObservationIgnored private var _tabIndexMap: [UUID: Int] = [:] @ObservationIgnored private var _tabIndexMapDirty = true + // MARK: - Closed Tab History (Cmd+Shift+T reopen) + + /// Closed tab snapshots for reopen. Capped to limit memory. + @ObservationIgnored private var closedTabHistory: [QueryTab] = [] + private static let maxClosedHistory = 20 + + func pushClosedTab(_ tab: QueryTab) { + closedTabHistory.append(tab) + if closedTabHistory.count > Self.maxClosedHistory { + closedTabHistory.removeFirst() + } + } + + func popClosedTab() -> QueryTab? { + closedTabHistory.popLast() + } + + var hasClosedTabs: Bool { !closedTabHistory.isEmpty } + + // MARK: - MRU Tab Activation Order + + /// Most-recently-used tab order for smart selection after close. + @ObservationIgnored private(set) var tabActivationOrder: [UUID] = [] + + func trackActivation(_ tabId: UUID) { + tabActivationOrder.removeAll { $0 == tabId } + tabActivationOrder.append(tabId) + // Cap to prevent unbounded growth from open/close cycles + if tabActivationOrder.count > 50 { + tabActivationOrder.removeFirst(tabActivationOrder.count - 50) + } + } + + /// Returns the most recently active tab ID, excluding a given ID. + func mruTabId(excluding id: UUID) -> UUID? { + for candidateId in tabActivationOrder.reversed() { + if candidateId != id, tabs.contains(where: { $0.id == candidateId }) { + return candidateId + } + } + return nil + } + private func rebuildTabIndexMapIfNeeded() { guard _tabIndexMapDirty else { return } _tabIndexMap = Dictionary(uniqueKeysWithValues: tabs.enumerated().map { ($1.id, $0) }) diff --git a/TablePro/Models/Query/QueryTabState.swift b/TablePro/Models/Query/QueryTabState.swift index 5856830e..bc153908 100644 --- a/TablePro/Models/Query/QueryTabState.swift +++ b/TablePro/Models/Query/QueryTabState.swift @@ -26,6 +26,7 @@ struct PersistedTab: Codable { var schemaName: String? var sourceFileURL: URL? var erDiagramSchemaKey: String? + var isPinned: Bool = false } /// Stores pending changes for a tab (used to preserve state when switching tabs) diff --git a/TablePro/Models/UI/ColumnVisibilityManager.swift b/TablePro/Models/UI/ColumnVisibilityManager.swift index 05924380..2326e5c1 100644 --- a/TablePro/Models/UI/ColumnVisibilityManager.swift +++ b/TablePro/Models/UI/ColumnVisibilityManager.swift @@ -49,7 +49,7 @@ internal final class ColumnVisibilityManager { } func restoreFromColumnLayout(_ columns: Set) { - hiddenColumns = columns + if hiddenColumns != columns { hiddenColumns = columns } } // MARK: - Per-Table UserDefaults Persistence diff --git a/TablePro/Models/UI/FilterState.swift b/TablePro/Models/UI/FilterState.swift index 55305d24..c39bf359 100644 --- a/TablePro/Models/UI/FilterState.swift +++ b/TablePro/Models/UI/FilterState.swift @@ -240,12 +240,12 @@ final class FilterStateManager { ) } - /// Restore filter state from tab + /// Restore filter state from tab — skips mutations when values unchanged func restoreFromTabState(_ state: TabFilterState) { - filters = state.filters - appliedFilters = state.appliedFilters - isVisible = state.isVisible - filterLogicMode = state.filterLogicMode + if filters != state.filters { filters = state.filters } + if appliedFilters != state.appliedFilters { appliedFilters = state.appliedFilters } + if isVisible != state.isVisible { isVisible = state.isVisible } + if filterLogicMode != state.filterLogicMode { filterLogicMode = state.filterLogicMode } } /// Save filters for a table (for "Restore Last Filter" setting) diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index d93e02e0..0f466871 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -22,6 +22,9 @@ } } } + }, + "\n\n… (%d more characters not shown)" : { + }, " — %@" : { "localizations" : { @@ -1229,6 +1232,9 @@ } } } + }, + "%d plugin(s) could not be loaded" : { + }, "%d-%d of %@%@ rows" : { "localizations" : { @@ -4437,6 +4443,9 @@ } } } + }, + "An external link wants to apply a filter:\n\n%@" : { + }, "An external link wants to open a query on connection \"%@\":\n\n%@" : { "localizations" : { @@ -4803,6 +4812,12 @@ } } } + }, + "Apply Filter" : { + + }, + "Apply Filter from Link" : { + }, "Apply filters" : { "localizations" : { @@ -7303,6 +7318,9 @@ } } } + }, + "Close All" : { + }, "Close Others" : { "localizations" : { @@ -7391,6 +7409,9 @@ } } } + }, + "Close Tabs to the Right" : { + }, "Closing this tab will discard all unsaved changes." : { "extractionState" : "stale", @@ -13448,6 +13469,9 @@ } } } + }, + "Enter the passphrase for SSH key \"%@\":" : { + }, "Enter the passphrase to decrypt and import connections." : { "localizations" : { @@ -16541,8 +16565,12 @@ }, "Format Query" : { + }, + "Format Query (⇧⌘L)" : { + }, "Format Query (⌥⌘F)" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -26344,6 +26372,9 @@ } } } + }, + "Preferred" : { + }, "Preserve all values as strings" : { "extractionState" : "stale", @@ -27622,8 +27653,12 @@ } } } + }, + "Quick Switcher (⇧⌘O)" : { + }, "Quick Switcher (⌘P)" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -27666,6 +27701,9 @@ } } } + }, + "Quit Anyway" : { + }, "Quote" : { "extractionState" : "stale", @@ -30185,6 +30223,9 @@ } } } + }, + "Save passphrase in Keychain" : { + }, "Save Sidebar Changes" : { "localizations" : { @@ -32493,6 +32534,12 @@ } } } + }, + "Some tabs have unsaved changes that will be lost." : { + + }, + "Some tabs have unsaved edits. Quitting will discard these changes." : { + }, "Something went wrong (error %d). Try again in a moment." : { "localizations" : { @@ -33093,6 +33140,9 @@ } } } + }, + "SSH Key Passphrase Required" : { + }, "SSH Port" : { "localizations" : { @@ -35194,6 +35244,9 @@ } } } + }, + "The following plugins were rejected:\n\n%@\n\nPlease update them from the plugin registry." : { + }, "The license has been suspended." : { "localizations" : { @@ -36305,8 +36358,12 @@ } } } + }, + "Toggle Filters (⇧⌘F)" : { + }, "Toggle Filters (⌘F)" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -39107,6 +39164,12 @@ } } } + }, + "You have unsaved changes" : { + + }, + "You have unsaved changes that will be lost." : { + }, "You have unsaved changes to the table structure. Refreshing will discard these changes." : { "localizations" : { diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index 838e2e34..0940ebe6 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -183,14 +183,7 @@ struct AppMenuCommands: Commands { if let actions { actions.closeTab() } else if let window = NSApp.keyWindow { - // Only performClose for non-main windows (Settings, Welcome, Connection Form). - // For main windows where @FocusedValue hasn't resolved yet, do nothing — - // prevents accidentally closing the connection window when user intended - // to close a tab. - let isMainWindow = window.identifier?.rawValue.hasPrefix("main") == true - if !isMainWindow { - window.performClose(nil) - } + window.performClose(nil) } } .optionalKeyboardShortcut(shortcut(for: .closeTab)) @@ -441,7 +434,7 @@ struct AppMenuCommands: Commands { .keyboardShortcut("-", modifiers: .command) } - // Tab navigation shortcuts — native macOS window tabs + // Tab navigation shortcuts — in-app tab switching CommandGroup(after: .windowArrangement) { // Tab switching by number (Cmd+1 through Cmd+9) ForEach(1...9, id: \.self) { number in @@ -457,22 +450,30 @@ struct AppMenuCommands: Commands { Divider() - // Previous tab (Cmd+Shift+[) — delegate to native macOS tab switching + // Previous tab (Cmd+Shift+[) — in-app tab switching Button("Show Previous Tab") { - NSApp.sendAction(#selector(NSWindow.selectPreviousTab(_:)), to: nil, from: nil) + actions?.selectPreviousTab() } .optionalKeyboardShortcut(shortcut(for: .showPreviousTab)) .disabled(!(actions?.isConnected ?? false)) - // Next tab (Cmd+Shift+]) — delegate to native macOS tab switching + // Next tab (Cmd+Shift+]) — in-app tab switching Button("Show Next Tab") { - NSApp.sendAction(#selector(NSWindow.selectNextTab(_:)), to: nil, from: nil) + actions?.selectNextTab() } .optionalKeyboardShortcut(shortcut(for: .showNextTab)) .disabled(!(actions?.isConnected ?? false)) Divider() + Button(String(localized: "Reopen Closed Tab")) { + actions?.reopenClosedTab() + } + .keyboardShortcut("t", modifiers: [.command, .shift]) + .disabled(!(actions?.canReopenClosedTab ?? false)) + + Divider() + Button("Bring All to Front") { NSApp.arrangeInFront(nil) } @@ -533,7 +534,7 @@ struct TableProApp: App { .windowResizability(.contentSize) // Main Window - opens when connecting to database - // Each native window-tab gets its own ContentView with independent state. + // Each connection window gets its own ContentView with independent state. WindowGroup(id: "main", for: EditorTabPayload.self) { $payload in ContentView(payload: payload) .background(OpenWindowHandler()) diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 2a5e829b..4f515529 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -75,7 +75,7 @@ struct MainEditorContentView: View { @State private var serverDashboardViewModels: [UUID: ServerDashboardViewModel] = [:] @State private var favoriteDialogQuery: FavoriteDialogQuery? - // Native macOS window tabs — no LRU tracking needed (single tab per window) + // In-app tabs with LRU eviction for inactive tab RowBuffers // MARK: - Environment @@ -90,18 +90,51 @@ struct MainEditorContentView: View { return AnyChangeManager(dataManager: changeManager) } + /// Composite version counter for the active tab — drives `updateNSView` + /// when query results, metadata, or pagination change. Hidden tabs are + /// not tracked; their rootView is refreshed when they become active. + private var activeTabContentVersion: Int { + tabManager.selectedTab?.contentVersion ?? 0 + } + // MARK: - Body var body: some View { let isHistoryVisible = coordinator.toolbarState.isHistoryPanelVisible VStack(spacing: 0) { - // Native macOS window tabs replace the custom tab bar. - // Each window-tab contains a single tab — no ZStack keep-alive needed. - if let tab = tabManager.selectedTab { - tabContent(for: tab) - } else { + if !tabManager.tabs.isEmpty { + EditorTabBar( + tabs: tabManager.tabs, + selectedTabId: Binding( + get: { tabManager.selectedTabId }, + set: { tabManager.selectedTabId = $0 } + ), + databaseType: connection.type, + onClose: { id in coordinator.closeInAppTab(id) }, + onCloseOthers: { id in coordinator.closeOtherTabs(excluding: id) }, + onCloseAll: { coordinator.closeAllTabs() }, + onReorder: { tabs in coordinator.reorderTabs(tabs) }, + onRename: { id, name in coordinator.renameTab(id, to: name) }, + onAddTab: { coordinator.addNewQueryTab() }, + onDuplicate: { id in coordinator.duplicateTab(id) }, + onTogglePin: { id in coordinator.togglePinTab(id) } + ) + Divider() + } + + if tabManager.tabs.isEmpty { emptyStateView + } else { + // Tab content lives in AppKit NSHostingViews managed by a + // Coordinator. Tab switching toggles NSView.isHidden — + // no SwiftUI body re-evaluation, no CALayer opacity relayout. + TabContentContainerView( + tabManager: tabManager, + tabIds: tabManager.tabIds, + activeTabContentVersion: activeTabContentVersion, + contentBuilder: { tab in AnyView(tabContent(for: tab)) } + ) } // Global History Panel @@ -140,7 +173,7 @@ struct MainEditorContentView: View { if cached?.resultVersion != tab.resultVersion || cached?.metadataVersion != tab.metadataVersion { - cacheRowProvider(for: tab) + cacheRowProvider(for: tab) } } .onAppear { @@ -159,7 +192,7 @@ struct MainEditorContentView: View { guard let tab = tabManager.selectedTab, newVersion != nil else { return } cacheRowProvider(for: tab) } - .onChange(of: tabManager.selectedTab?.metadataVersion) { _, _ in + .onChange(of: tabManager.selectedTab?.metadataVersion) { _, newVersion in guard let tab = tabManager.selectedTab else { return } cacheRowProvider(for: tab) } @@ -255,7 +288,11 @@ struct MainEditorContentView: View { connectionId: coordinator.connection.id, connectionAIPolicy: coordinator.connection.aiPolicy ?? AppSettingsManager.shared.ai.defaultConnectionPolicy, onCloseTab: { - NSApp.keyWindow?.close() + if tabManager.tabs.count > 1, let selectedId = tabManager.selectedTabId { + coordinator.closeInAppTab(selectedId) + } else { + NSApp.keyWindow?.close() + } }, onExecuteQuery: { coordinator.runQuery() }, onExplain: { variant in @@ -553,6 +590,15 @@ struct MainEditorContentView: View { } private func cacheRowProvider(for tab: QueryTab) { + // Skip if the cached entry is still valid + if let entry = tabProviderCache[tab.id], + entry.resultVersion == tab.resultVersion, + entry.metadataVersion == tab.metadataVersion, + entry.sortState == tab.sortState, + !tab.rowBuffer.isEvicted + { + return + } let provider = makeRowProvider(for: tab) tabProviderCache[tab.id] = RowProviderCacheEntry( provider: provider, diff --git a/TablePro/Views/Main/Child/TabContentContainerView.swift b/TablePro/Views/Main/Child/TabContentContainerView.swift new file mode 100644 index 00000000..240c2cfd --- /dev/null +++ b/TablePro/Views/Main/Child/TabContentContainerView.swift @@ -0,0 +1,101 @@ +// +// TabContentContainerView.swift +// TablePro +// +// AppKit container that manages one NSHostingView per tab. +// Tab switching toggles NSView.isHidden — only the active tab +// is visible. Note: hidden NSHostingViews still run SwiftUI +// observation tracking; isHidden only suppresses rendering. +// + +import SwiftUI + +/// NSViewRepresentable that manages tab content views in AppKit. +/// Only the active tab's NSHostingView is visible (isHidden = false). +/// Inactive tabs are hidden so SwiftUI suspends their rendering. +@MainActor +struct TabContentContainerView: NSViewRepresentable { + let tabManager: QueryTabManager + let tabIds: [UUID] + let activeTabContentVersion: Int + let contentBuilder: (QueryTab) -> AnyView + + // MARK: - Coordinator + + @MainActor + final class Coordinator { + var hostingViews: [UUID: NSHostingView] = [:] + var activeTabId: UUID? + var builtVersions: [UUID: Int] = [:] + } + + func makeCoordinator() -> Coordinator { Coordinator() } + + func makeNSView(context: Context) -> NSView { + let container = NSView() + container.wantsLayer = true + syncHostingViews(container: container, coordinator: context.coordinator) + return container + } + + func updateNSView(_ container: NSView, context: Context) { + let coordinator = context.coordinator + syncHostingViews(container: container, coordinator: coordinator) + + // Toggle visibility + let selectedId = tabManager.selectedTabId + if coordinator.activeTabId != selectedId { + if let oldId = coordinator.activeTabId { + coordinator.hostingViews[oldId]?.isHidden = true + } + if let newId = selectedId { + coordinator.hostingViews[newId]?.isHidden = false + } + coordinator.activeTabId = selectedId + } + + // Refresh active tab's rootView only when data version changed + if let activeId = selectedId, + let tab = tabManager.tabs.first(where: { $0.id == activeId }), + let hosting = coordinator.hostingViews[activeId] + { + if coordinator.builtVersions[activeId] != tab.contentVersion { + hosting.rootView = contentBuilder(tab) + coordinator.builtVersions[activeId] = tab.contentVersion + } + } + } + + private func syncHostingViews(container: NSView, coordinator: Coordinator) { + let currentIds = Set(tabIds) + + for id in coordinator.hostingViews.keys where !currentIds.contains(id) { + coordinator.hostingViews[id]?.removeFromSuperview() + coordinator.hostingViews.removeValue(forKey: id) + coordinator.builtVersions.removeValue(forKey: id) + } + + for tab in tabManager.tabs where coordinator.hostingViews[tab.id] == nil { + let hosting = NSHostingView(rootView: contentBuilder(tab)) + hosting.translatesAutoresizingMaskIntoConstraints = false + hosting.isHidden = (tab.id != tabManager.selectedTabId) + container.addSubview(hosting) + NSLayoutConstraint.activate([ + hosting.leadingAnchor.constraint(equalTo: container.leadingAnchor), + hosting.trailingAnchor.constraint(equalTo: container.trailingAnchor), + hosting.topAnchor.constraint(equalTo: container.topAnchor), + hosting.bottomAnchor.constraint(equalTo: container.bottomAnchor), + ]) + coordinator.hostingViews[tab.id] = hosting + coordinator.builtVersions[tab.id] = tab.contentVersion + } + } + + static func dismantleNSView(_ container: NSView, coordinator: Coordinator) { + for hosting in coordinator.hostingViews.values { + hosting.removeFromSuperview() + } + coordinator.hostingViews.removeAll() + coordinator.builtVersions.removeAll() + } +} diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift index 7c2e8f1c..23ca781b 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift @@ -53,7 +53,7 @@ extension MainContentCoordinator { return } - // If current tab has unsaved changes, open in a new native tab instead of replacing + // If current tab has unsaved changes, open in a new in-app tab instead of replacing if changeManager.hasChanges { let fkFilterState = TabFilterState( filters: [filter], @@ -61,16 +61,18 @@ extension MainContentCoordinator { isVisible: true, filterLogicMode: .and ) - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .table, + tabManager.addTableTab( tableName: referencedTable, - databaseName: currentDatabase, - schemaName: targetSchema, - isView: false, - initialFilterState: fkFilterState + databaseType: connection.type, + databaseName: currentDatabase ) - WindowOpener.shared.openNativeTab(payload) + if let tabIndex = tabManager.selectedTabIndex { + tabManager.tabs[tabIndex].schemaName = targetSchema + tabManager.tabs[tabIndex].filterState = fkFilterState + filterStateManager.restoreFromTabState(fkFilterState) + } + restoreColumnLayoutForTable(referencedTable) + runQuery() return } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift index bd7f1ce0..7e18c4a4 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift @@ -42,12 +42,6 @@ extension MainContentCoordinator { return } - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .query, - databaseName: connection.database, - initialQuery: favorite.query - ) - WindowOpener.shared.openNativeTab(payload) + tabManager.addTab(initialQuery: favorite.query, databaseName: connection.database) } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 08827eba..821d87f2 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -48,7 +48,7 @@ extension MainContentCoordinator { } // During database switch, update the existing tab in-place instead of - // opening a new native window tab. + // opening a new in-app tab. if sidebarLoadingState == .loading { if tabManager.tabs.isEmpty { tabManager.addTableTab( @@ -60,15 +60,12 @@ extension MainContentCoordinator { return } - // Check if another native window tab already has this table open — switch to it - if let keyWindow = NSApp.keyWindow { - let ownWindows = Set(WindowLifecycleMonitor.shared.windows(for: connectionId).map { ObjectIdentifier($0) }) - let tabbedWindows = keyWindow.tabbedWindows ?? [keyWindow] - for window in tabbedWindows - where window.title == tableName && ownWindows.contains(ObjectIdentifier(window)) { - window.makeKeyAndOrderFront(nil) - return - } + // Check if another in-app tab already has this table open — switch to it + if let existingTab = tabManager.tabs.first(where: { + $0.tabType == .table && $0.tableName == tableName && $0.databaseName == currentDatabase + }) { + tabManager.selectedTabId = existingTab.id + return } // If no tabs exist (empty state), add a table tab directly. @@ -80,10 +77,7 @@ extension MainContentCoordinator { databaseType: connection.type, databaseName: currentDatabase ) - if let wid = windowId { - WindowLifecycleMonitor.shared.setPreview(true, for: wid) - WindowLifecycleMonitor.shared.window(for: wid)?.subtitle = "\(connection.name) — Preview" - } + contentWindow?.subtitle = "\(connection.name) — Preview" } else { tabManager.addTableTab( tableName: tableName, @@ -111,7 +105,7 @@ extension MainContentCoordinator { } // In-place navigation: replace current tab content rather than - // opening new native window tabs (e.g. Redis database switching). + // opening new in-app tabs (e.g. Redis database switching). if navigationModel == .inPlace { if let oldTab = tabManager.selectedTab, let oldTableName = oldTab.tableName { filterStateManager.saveLastFilters(for: oldTableName) @@ -136,21 +130,18 @@ extension MainContentCoordinator { return } - // If current tab has unsaved changes, active filters, or sorting, open in a new native tab + // If current tab has unsaved changes, active filters, or sorting, open in a new in-app tab let hasActiveWork = changeManager.hasChanges || filterStateManager.hasAppliedFilters || (tabManager.selectedTab?.sortState.isSorting ?? false) if hasActiveWork { - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .table, + addTableTabInApp( tableName: tableName, databaseName: currentDatabase, schemaName: currentSchema, isView: isView, showStructure: showStructure ) - WindowOpener.shared.openNativeTab(payload) return } @@ -160,17 +151,42 @@ extension MainContentCoordinator { return } - // Default: open table in a new native tab - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .table, + // Default: open table in a new in-app tab + addTableTabInApp( tableName: tableName, databaseName: currentDatabase, schemaName: currentSchema, isView: isView, showStructure: showStructure ) - WindowOpener.shared.openNativeTab(payload) + } + + /// Helper: add a table tab in-app and execute its query + private func addTableTabInApp( + tableName: String, + databaseName: String, + schemaName: String?, + isView: Bool = false, + showStructure: Bool = false + ) { + tabManager.addTableTab( + tableName: tableName, + databaseType: connection.type, + databaseName: databaseName + ) + if let tabIndex = tabManager.selectedTabIndex { + tabManager.tabs[tabIndex].isView = isView + tabManager.tabs[tabIndex].isEditable = !isView + tabManager.tabs[tabIndex].schemaName = schemaName + if showStructure { + tabManager.tabs[tabIndex].showStructure = true + } + tabManager.tabs[tabIndex].pagination.reset() + toolbarState.isTableTab = true + } + restoreColumnLayoutForTable(tableName) + restoreFiltersForTable(tableName) + runQuery() } // MARK: - Preview Tabs @@ -180,48 +196,57 @@ extension MainContentCoordinator { databaseName: String = "", schemaName: String? = nil, 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 - } - if let oldTab = previewCoordinator.tabManager.selectedTab, - let oldTableName = oldTab.tableName { - previewCoordinator.filterStateManager.saveLastFilters(for: oldTableName) - } - previewCoordinator.tabManager.replaceTabContent( + // Check if a preview tab already exists in this window's tab manager + if let previewIndex = tabManager.tabs.firstIndex(where: { $0.isPreview }) { + let previewTab = tabManager.tabs[previewIndex] + // Skip if preview tab already shows this table + if previewTab.tableName == tableName, previewTab.databaseName == databaseName { + tabManager.selectedTabId = previewTab.id + return + } + // Preview tab has unsaved changes — promote it and open a new tab instead + if previewTab.pendingChanges.hasChanges || previewTab.isFileDirty { + tabManager.tabs[previewIndex].isPreview = false + contentWindow?.subtitle = connection.name + addTableTabInApp( tableName: tableName, - databaseType: connection.type, - isView: isView, databaseName: databaseName, schemaName: schemaName, - isPreview: true + isView: isView, + showStructure: showStructure ) - previewCoordinator.filterStateManager.clearAll() - if let tabIndex = previewCoordinator.tabManager.selectedTabIndex { - previewCoordinator.tabManager.tabs[tabIndex].showStructure = showStructure - previewCoordinator.tabManager.tabs[tabIndex].pagination.reset() - previewCoordinator.toolbarState.isTableTab = true - } - preview.window.makeKeyAndOrderFront(nil) - previewCoordinator.restoreColumnLayoutForTable(tableName) - previewCoordinator.restoreFiltersForTable(tableName) - previewCoordinator.runQuery() return } + if let oldTableName = previewTab.tableName { + filterStateManager.saveLastFilters(for: oldTableName) + } + // Select the preview tab first so replaceTabContent operates on it + tabManager.selectedTabId = previewTab.id + tabManager.replaceTabContent( + tableName: tableName, + databaseType: connection.type, + isView: isView, + databaseName: databaseName, + schemaName: schemaName, + isPreview: true + ) + filterStateManager.clearAll() + if let tabIndex = tabManager.selectedTabIndex { + tabManager.tabs[tabIndex].showStructure = showStructure + tabManager.tabs[tabIndex].pagination.reset() + toolbarState.isTableTab = true + } + restoreColumnLayoutForTable(tableName) + restoreFiltersForTable(tableName) + runQuery() + return } - // No preview window exists but current tab can be reused: replace in-place. - // This covers: preview tabs, non-preview table tabs with no active work, + // No preview tab exists but current tab can be reused: replace in-place. + // This covers: non-preview table tabs with no active work, // and empty/default query tabs (no user-entered content). let isReusableTab: Bool = { guard let tab = tabManager.selectedTab else { return false } - if tab.isPreview { return true } // Table tab with no active work if tab.tabType == .table && !changeManager.hasChanges && !filterStateManager.hasAppliedFilters && !tab.sortState.isSorting { @@ -239,7 +264,7 @@ extension MainContentCoordinator { if selectedTab.tableName == tableName, selectedTab.databaseName == databaseName { return } - // If preview tab has active work, promote it and open new tab instead + // If reusable tab has active work, promote it and open new tab instead let hasUnsavedQuery = tabManager.selectedTab.map { tab in tab.tabType == .query && !tab.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } ?? false @@ -249,16 +274,13 @@ extension MainContentCoordinator { || hasUnsavedQuery if previewHasWork { promotePreviewTab() - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .table, + addTableTabInApp( tableName: tableName, databaseName: databaseName, schemaName: schemaName, isView: isView, showStructure: showStructure ) - WindowOpener.shared.openNativeTab(payload) return } if let oldTableName = selectedTab.tableName { @@ -284,40 +306,37 @@ extension MainContentCoordinator { return } - // No preview tab anywhere: create a new native preview tab - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .table, + // No reusable tab: create a new in-app preview tab + tabManager.addPreviewTableTab( tableName: tableName, - databaseName: databaseName, - schemaName: schemaName, - isView: isView, - showStructure: showStructure, - isPreview: true + databaseType: connection.type, + databaseName: databaseName ) - WindowOpener.shared.openNativeTab(payload) + if let tabIndex = tabManager.selectedTabIndex { + tabManager.tabs[tabIndex].isView = isView + tabManager.tabs[tabIndex].schemaName = schemaName + if showStructure { + tabManager.tabs[tabIndex].showStructure = true + } + tabManager.tabs[tabIndex].pagination.reset() + toolbarState.isTableTab = true + } + restoreColumnLayoutForTable(tableName) + restoreFiltersForTable(tableName) + runQuery() } 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 - } + contentWindow?.subtitle = connection.name } func showAllTablesMetadata() { guard let sql = allTablesMetadataSQL() else { return } - - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .query, - initialQuery: sql - ) - WindowOpener.shared.openNativeTab(payload) + tabManager.addTab(initialQuery: sql, databaseName: connection.database) + runQuery() } private func currentSchemaName(fallback: String) -> String { @@ -355,22 +374,6 @@ extension MainContentCoordinator { // MARK: - Database Switching - /// Close all sibling native window-tabs except the current key window. - /// Each table opened via WindowOpener creates a separate NSWindow in the same - /// tab group. Clearing `tabManager.tabs` only affects the in-app state of the - /// *current* window — other NSWindows remain open with stale content. - private func closeSiblingNativeWindows() { - guard let keyWindow = NSApp.keyWindow else { return } - let siblings = keyWindow.tabbedWindows ?? [] - let ownWindows = Set(WindowLifecycleMonitor.shared.windows(for: connectionId).map { ObjectIdentifier($0) }) - for sibling in siblings where sibling !== keyWindow { - // Only close windows belonging to this connection to avoid - // destroying tabs from other connections when groupAllConnectionTabs is ON - guard ownWindows.contains(ObjectIdentifier(sibling)) else { continue } - sibling.close() - } - } - /// Switch to a different database (called from database switcher) func switchDatabase(to database: String) async { sidebarLoadingState = .loading @@ -385,7 +388,6 @@ extension MainContentCoordinator { let previousDatabase = toolbarState.databaseName toolbarState.databaseName = database - closeSiblingNativeWindows() persistence.saveNowSync(tabs: tabManager.tabs, selectedTabId: tabManager.selectedTabId) tabManager.tabs = [] tabManager.selectedTabId = nil @@ -450,7 +452,7 @@ extension MainContentCoordinator { let previousSchema = toolbarState.databaseName toolbarState.databaseName = schema - closeSiblingNativeWindows() + persistence.saveNowSync(tabs: tabManager.tabs, selectedTabId: tabManager.selectedTabId) tabManager.tabs = [] tabManager.selectedTabId = nil DatabaseManager.shared.updateSession(connectionId) { session in diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift index e5dacead..6dfdce0c 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift @@ -39,17 +39,7 @@ extension MainContentCoordinator { func createNewTable() { guard !safeModeLevel.blocksAllWrites else { return } - - if tabManager.tabs.isEmpty { - tabManager.addCreateTableTab(databaseName: connection.database) - } else { - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .createTable, - databaseName: connection.database - ) - WindowOpener.shared.openNativeTab(payload) - } + tabManager.addCreateTableTab(databaseName: connection.database) } func showERDiagram() { @@ -65,13 +55,7 @@ extension MainContentCoordinator { let template = driver?.createViewTemplate() ?? "CREATE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;" - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .query, - databaseName: connection.database, - initialQuery: template - ) - WindowOpener.shared.openNativeTab(payload) + tabManager.addTab(initialQuery: template, databaseName: connection.database) } func editViewDefinition(_ viewName: String) { @@ -79,25 +63,13 @@ extension MainContentCoordinator { do { guard let driver = DatabaseManager.shared.driver(for: self.connection.id) else { return } let definition = try await driver.fetchViewDefinition(view: viewName) - - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .query, - initialQuery: definition - ) - WindowOpener.shared.openNativeTab(payload) + tabManager.addTab(initialQuery: definition, databaseName: connection.database) } catch { let driver = DatabaseManager.shared.driver(for: self.connection.id) let template = driver?.editViewFallbackTemplate(viewName: viewName) ?? "CREATE OR REPLACE VIEW \(viewName) AS\nSELECT * FROM table_name;" let fallbackSQL = "-- Could not fetch view definition: \(error.localizedDescription)\n\(template)" - - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .query, - initialQuery: fallbackSQL - ) - WindowOpener.shared.openNativeTab(payload) + tabManager.addTab(initialQuery: fallbackSQL, databaseName: connection.database) } } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabOperations.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabOperations.swift new file mode 100644 index 00000000..e4a19ea3 --- /dev/null +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabOperations.swift @@ -0,0 +1,268 @@ +// +// MainContentCoordinator+TabOperations.swift +// TablePro +// +// In-app tab bar operations: close, reorder, rename, duplicate, pin, reopen. +// + +import AppKit +import Foundation + +extension MainContentCoordinator { + // MARK: - Tab Close + + func closeInAppTab(_ id: UUID) { + guard let index = tabManager.tabs.firstIndex(where: { $0.id == id }) else { return } + + let tab = tabManager.tabs[index] + + // Pinned tabs cannot be closed + guard !tab.isPinned else { return } + + let isSelected = tabManager.selectedTabId == id + + // Check for unsaved changes on this specific tab + if isSelected && changeManager.hasChanges { + Task { @MainActor in + let result = await AlertHelper.confirmSaveChanges( + message: String(localized: "Your changes will be lost if you don't save them."), + window: contentWindow + ) + switch result { + case .save: + await self.saveDataChangesAndClose(tabId: id) + case .dontSave: + changeManager.clearChangesAndUndoHistory() + removeTab(id) + case .cancel: + return + } + } + return + } + + // Check for dirty file + if tab.isFileDirty { + Task { @MainActor in + let result = await AlertHelper.confirmSaveChanges( + message: String(localized: "Your changes will be lost if you don't save them."), + window: contentWindow + ) + switch result { + case .save: + if let url = tab.sourceFileURL { + try? await SQLFileService.writeFile(content: tab.query, to: url) + } + removeTab(id) + case .dontSave: + removeTab(id) + case .cancel: + return + } + } + return + } + + removeTab(id) + } + + private func removeTab(_ id: UUID) { + guard let index = tabManager.tabs.firstIndex(where: { $0.id == id }) else { return } + let wasSelected = tabManager.selectedTabId == id + + // Snapshot for Cmd+Shift+T reopen before eviction + tabManager.pushClosedTab(tabManager.tabs[index]) + + tabManager.tabs[index].rowBuffer.evict() + tabManager.tabs.remove(at: index) + + if wasSelected { + if tabManager.tabs.isEmpty { + tabManager.selectedTabId = nil + } else { + // MRU: select the most recently active tab, not just adjacent + tabManager.selectedTabId = tabManager.mruTabId(excluding: id) + ?? tabManager.tabs[min(index, tabManager.tabs.count - 1)].id + } + } + + persistence.saveNow(tabs: tabManager.tabs, selectedTabId: tabManager.selectedTabId) + } + + private func saveDataChangesAndClose(tabId: UUID) async { + var truncates: Set = [] + var deletes: Set = [] + var options: [String: TableOperationOptions] = [:] + let saved = await withCheckedContinuation { (continuation: CheckedContinuation) in + saveCompletionContinuation = continuation + saveChanges(pendingTruncates: &truncates, pendingDeletes: &deletes, tableOperationOptions: &options) + } + if saved { + removeTab(tabId) + } + } + + func closeOtherTabs(excluding id: UUID) { + // Skip pinned tabs — they survive "Close Others" + let tabsToClose = tabManager.tabs.filter { $0.id != id && !$0.isPinned } + let selectedIsBeingClosed = tabsToClose.contains { $0.id == tabManager.selectedTabId } + let hasUnsavedWork = tabsToClose.contains { $0.pendingChanges.hasChanges || $0.isFileDirty } + || (selectedIsBeingClosed && changeManager.hasChanges) + + if hasUnsavedWork { + Task { @MainActor in + let result = await AlertHelper.confirmSaveChanges( + message: String(localized: "Some tabs have unsaved changes that will be lost."), + window: contentWindow + ) + switch result { + case .save, .dontSave: + if selectedIsBeingClosed { + changeManager.clearChangesAndUndoHistory() + } + forceCloseOtherTabs(excluding: id) + case .cancel: + return + } + } + return + } + + forceCloseOtherTabs(excluding: id) + } + + private func forceCloseOtherTabs(excluding id: UUID) { + for tab in tabManager.tabs where tab.id != id && !tab.isPinned { + tabManager.pushClosedTab(tab) + tab.rowBuffer.evict() + } + tabManager.tabs.removeAll { $0.id != id && !$0.isPinned } + tabManager.selectedTabId = id + persistence.saveNow(tabs: tabManager.tabs, selectedTabId: tabManager.selectedTabId) + } + + func closeAllTabs() { + // Skip pinned tabs — they survive "Close All" + let closableTabs = tabManager.tabs.filter { !$0.isPinned } + guard !closableTabs.isEmpty else { return } + + let hasUnsavedWork = closableTabs.contains { $0.pendingChanges.hasChanges || $0.isFileDirty } + || changeManager.hasChanges + + if hasUnsavedWork { + Task { @MainActor in + let result = await AlertHelper.confirmSaveChanges( + message: String(localized: "You have unsaved changes that will be lost."), + window: contentWindow + ) + switch result { + case .save, .dontSave: + changeManager.clearChangesAndUndoHistory() + forceCloseAllTabs() + case .cancel: + return + } + } + return + } + + forceCloseAllTabs() + } + + private func forceCloseAllTabs() { + let closable = tabManager.tabs.filter { !$0.isPinned } + for tab in closable { + tabManager.pushClosedTab(tab) + tab.rowBuffer.evict() + } + tabManager.tabs.removeAll { !$0.isPinned } + + if tabManager.tabs.isEmpty { + tabManager.selectedTabId = nil + persistence.clearSavedState() + } else { + // Pinned tabs remain — select the first one + tabManager.selectedTabId = tabManager.tabs.first?.id + persistence.saveNow(tabs: tabManager.tabs, selectedTabId: tabManager.selectedTabId) + } + } + + // MARK: - Reopen Closed Tab (Cmd+Shift+T) + + func reopenClosedTab() { + guard var tab = tabManager.popClosedTab() else { return } + tab.rowBuffer = RowBuffer() + tabManager.tabs.append(tab) + tabManager.selectedTabId = tab.id + if tab.tabType == .table, !tab.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + runQuery() + } + persistence.saveNow(tabs: tabManager.tabs, selectedTabId: tabManager.selectedTabId) + } + + // MARK: - Pin Tab + + func togglePinTab(_ id: UUID) { + guard let index = tabManager.tabs.firstIndex(where: { $0.id == id }) else { return } + tabManager.tabs[index].isPinned.toggle() + + // Stable sort: pinned tabs first, preserving relative order within each group + let pinned = tabManager.tabs.filter(\.isPinned) + let unpinned = tabManager.tabs.filter { !$0.isPinned } + tabManager.tabs = pinned + unpinned + + persistence.saveNow(tabs: tabManager.tabs, selectedTabId: tabManager.selectedTabId) + } + + // MARK: - Tab Reorder + + func reorderTabs(_ newOrder: [QueryTab]) { + tabManager.tabs = newOrder + persistence.saveNow(tabs: tabManager.tabs, selectedTabId: tabManager.selectedTabId) + } + + // MARK: - Tab Rename + + func renameTab(_ id: UUID, to name: String) { + guard let index = tabManager.tabs.firstIndex(where: { $0.id == id }) else { return } + tabManager.tabs[index].title = name + persistence.saveNow(tabs: tabManager.tabs, selectedTabId: tabManager.selectedTabId) + } + + // MARK: - Add Tab + + func addNewQueryTab() { + let allTabs = tabManager.tabs + let title = QueryTabManager.nextQueryTitle(existingTabs: allTabs) + tabManager.addTab(title: title, databaseName: connection.database) + } + + // MARK: - Duplicate Tab + + func duplicateTab(_ id: UUID) { + guard let sourceTab = tabManager.tabs.first(where: { $0.id == id }) else { return } + + switch sourceTab.tabType { + case .table: + if let tableName = sourceTab.tableName { + tabManager.addTableTab( + tableName: tableName, + databaseType: connection.type, + databaseName: sourceTab.databaseName + ) + } + case .query: + tabManager.addTab( + initialQuery: sourceTab.query, + title: sourceTab.title + " Copy", + databaseName: sourceTab.databaseName + ) + case .createTable: + tabManager.addCreateTableTab(databaseName: sourceTab.databaseName) + case .erDiagram: + openERDiagramTab() + case .serverDashboard: + openServerDashboardTab() + } + } +} diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift index 952483f5..c1f6525e 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift @@ -7,99 +7,113 @@ // import Foundation +import os extension MainContentCoordinator { - func handleTabChange( + /// Schedule a tab switch. Phase 1 (synchronous): MRU tracking only. + /// Phase 2 (deferred Task): save outgoing state, restore incoming + /// shared managers, lazy query, sidebar/title/persist settlement. + /// Rapid Cmd+1/2/3 coalesces — only the LAST switch's Phase 2 runs. + func scheduleTabSwitch( from oldTabId: UUID?, - to newTabId: UUID?, - selectedRowIndices: inout Set, - tabs: [QueryTab] + to newTabId: UUID? ) { + // isHandlingTabSwitch is true only during this synchronous block. + // onChange handlers check it to skip cascading work. isHandlingTabSwitch = true defer { isHandlingTabSwitch = false } - // Persist the outgoing tab's unsaved changes and filter state so they survive the switch + if let newId = newTabId { + tabManager.trackActivation(newId) + } + + // Save outgoing tab state synchronously (Phase 1) so it's never lost + // during rapid Cmd+1/2/3 coalescing where Phase 2 Tasks get cancelled. if let oldId = oldTabId, let oldIndex = tabManager.tabs.firstIndex(where: { $0.id == oldId }) { + var tab = tabManager.tabs[oldIndex] if changeManager.hasChanges { - tabManager.tabs[oldIndex].pendingChanges = changeManager.saveState() + tab.pendingChanges = changeManager.saveState() } - tabManager.tabs[oldIndex].filterState = filterStateManager.saveToTabState() - if let tableName = tabManager.tabs[oldIndex].tableName { + tab.filterState = filterStateManager.saveToTabState() + tabManager.tabs[oldIndex] = tab + if let tableName = tab.tableName { filterStateManager.saveLastFilters(for: tableName) } saveColumnVisibilityToTab() saveColumnLayoutForTable() } - if tabManager.tabs.count > 2 { - let activeIds: Set = Set([oldTabId, newTabId].compactMap { $0 }) - evictInactiveTabs(excluding: activeIds) - } - - if let newId = newTabId, - let newIndex = tabManager.tabs.firstIndex(where: { $0.id == newId }) { - let newTab = tabManager.tabs[newIndex] - - // Restore filter state for new tab - filterStateManager.restoreFromTabState(newTab.filterState) - - // Restore column visibility for new tab - columnVisibilityManager.restoreFromColumnLayout(newTab.columnLayout.hiddenColumns) - - selectedRowIndices = newTab.selectedRowIndices - toolbarState.isTableTab = newTab.tabType == .table - toolbarState.isResultsCollapsed = newTab.isResultsCollapsed + // Phase 2: Deferred — restore incoming state + lazy query. + // During rapid Cmd+1/2/3, only the LAST switch's Phase 2 executes. + tabSwitchTask?.cancel() + let capturedNewId = newTabId + tabSwitchTask = Task { @MainActor [weak self] in + guard let self, !Task.isCancelled else { return } + guard !Task.isCancelled else { return } + + // Restore incoming tab shared state. + guard let newId = capturedNewId, + let newIndex = self.tabManager.tabs.firstIndex(where: { $0.id == newId }) + else { + self.toolbarState.isTableTab = false + self.toolbarState.isResultsCollapsed = false + self.filterStateManager.clearAll() + return + } + let newTab = self.tabManager.tabs[newIndex] + + // Guard each mutation — skip when the value is already correct. + // Avoids unnecessary @Observable notifications that would cause + // ALL NSHostingViews to re-evaluate (expensive for tabs with many rows). + let isTable = newTab.tabType == .table + if self.toolbarState.isTableTab != isTable { + self.toolbarState.isTableTab = isTable + } + if self.toolbarState.isResultsCollapsed != newTab.isResultsCollapsed { + self.toolbarState.isResultsCollapsed = newTab.isResultsCollapsed + } + self.filterStateManager.restoreFromTabState(newTab.filterState) + self.restoreColumnVisibilityFromTab(newTab) - // Configure change manager without triggering reload yet — we'll fire a single - // reloadVersion bump below after everything is set up. + // Reconfigure change manager only when the table actually changed + let newTableName = newTab.tableName ?? "" let pendingState = newTab.pendingChanges if pendingState.hasChanges { - changeManager.restoreState(from: pendingState, tableName: newTab.tableName ?? "", databaseType: connection.type) - } else { - changeManager.configureForTable( - tableName: newTab.tableName ?? "", + self.changeManager.restoreState( + from: pendingState, + tableName: newTableName, + databaseType: self.connection.type + ) + } else if self.changeManager.tableName != newTableName + || self.changeManager.columns != newTab.resultColumns + { + self.changeManager.configureForTable( + tableName: newTableName, columns: newTab.resultColumns, primaryKeyColumns: newTab.primaryKeyColumns.isEmpty - ? newTab.resultColumns.prefix(1).map { $0 } + ? Array(newTab.resultColumns.prefix(1)) : newTab.primaryKeyColumns, - databaseType: connection.type, + databaseType: self.connection.type, triggerReload: false ) } - - // Defer reloadVersion bump — only needed when we won't run a query. - // When a query runs, executeQueryInternal Phase 1 sets new result data - // that triggers its own SwiftUI update; bumping beforehand causes a - // redundant re-evaluation that blocks the Task executor (15-40ms). - + // Database switch check if !newTab.databaseName.isEmpty { - let currentDatabase: String - if let session = DatabaseManager.shared.session(for: connectionId) { - currentDatabase = session.activeDatabase - } else { - currentDatabase = connection.database - } - + let currentDatabase = DatabaseManager.shared.session(for: self.connectionId)?.activeDatabase + ?? self.connection.database if newTab.databaseName != currentDatabase { - changeManager.reloadVersion += 1 - Task { @MainActor in - await switchDatabase(to: newTab.databaseName) - } - return // switchDatabase will re-execute the query + self.changeManager.reloadVersion += 1 + await self.switchDatabase(to: newTab.databaseName) + return } } - // If the tab shows isExecuting but has no results, the previous query was - // likely cancelled when the user rapidly switched away. Force-clear the stale - // flag so the lazy-load check below can re-execute the query. + // Clear stale isExecuting flag if newTab.isExecuting && newTab.resultRows.isEmpty && newTab.lastExecutedAt == nil { - let tabId = newId - Task { @MainActor [weak self] in - guard let self, - let idx = self.tabManager.tabs.firstIndex(where: { $0.id == tabId }), - self.tabManager.tabs[idx].isExecuting else { return } + if let idx = self.tabManager.tabs.firstIndex(where: { $0.id == newId }), + self.tabManager.tabs[idx].isExecuting { self.tabManager.tabs[idx].isExecuting = false } } @@ -112,53 +126,20 @@ extension MainContentCoordinator { && !newTab.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty if needsLazyQuery { - if let session = DatabaseManager.shared.session(for: connectionId), session.isConnected { - executeTableTabQueryDirectly() + if let session = DatabaseManager.shared.session(for: self.connectionId), session.isConnected { + self.executeTableTabQueryDirectly() } else { - changeManager.reloadVersion += 1 - needsLazyLoad = true + self.changeManager.reloadVersion += 1 + self.needsLazyLoad = true } - } else { - changeManager.reloadVersion += 1 } - } else { - toolbarState.isTableTab = false - toolbarState.isResultsCollapsed = false - filterStateManager.clearAll() - } - } - - private func evictInactiveTabs(excluding activeTabIds: Set) { - let candidates = tabManager.tabs.filter { - !activeTabIds.contains($0.id) - && !$0.rowBuffer.isEvicted - && !$0.resultRows.isEmpty - && $0.lastExecutedAt != nil - && !$0.pendingChanges.hasChanges - } - - // Sort by oldest first, breaking ties by largest estimated footprint first - let sorted = candidates.sorted { - let t0 = $0.lastExecutedAt ?? .distantFuture - let t1 = $1.lastExecutedAt ?? .distantFuture - if t0 != t1 { return t0 < t1 } - let size0 = MemoryPressureAdvisor.estimatedFootprint( - rowCount: $0.rowBuffer.rows.count, - columnCount: $0.rowBuffer.columns.count - ) - let size1 = MemoryPressureAdvisor.estimatedFootprint( - rowCount: $1.rowBuffer.rows.count, - columnCount: $1.rowBuffer.columns.count - ) - return size0 > size1 - } - - let maxInactiveLoaded = MemoryPressureAdvisor.budgetForInactiveTabs() - guard sorted.count > maxInactiveLoaded else { return } - let toEvict = sorted.dropLast(maxInactiveLoaded) - for tab in toEvict { - tab.rowBuffer.evict() + // Only run settled callback if THIS tab is still selected. + // During rapid Cmd+1/2/3, the user may have already switched to + // another tab — running sidebar sync/title/persist for a stale + // tab causes cascading onChange(selectedTables) body re-evals. + guard self.tabManager.selectedTabId == capturedNewId else { return } + self.onTabSwitchSettled?() } } } diff --git a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index 57a0d458..3e98576e 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -11,34 +11,14 @@ import SwiftUI extension MainContentView { // MARK: - Event Handlers - func handleTabSelectionChange(from oldTabId: UUID?, to newTabId: UUID?) { - coordinator.handleTabChange( - from: oldTabId, - to: newTabId, - selectedRowIndices: &selectedRowIndices, - tabs: tabManager.tabs - ) - - updateWindowTitleAndFileState() - - // Sync sidebar selection to match the newly selected tab. - // Critical for new native windows: localSelectedTables starts empty, - // and this is the only place that can seed it from the restored tab. - syncSidebarToCurrentTab() - - // Persist tab selection explicitly (skip during teardown) - guard !coordinator.isTearingDown else { return } - coordinator.persistence.saveNow( - tabs: tabManager.tabs, - selectedTabId: newTabId - ) - } - func handleTabsChange(_ newTabs: [QueryTab]) { + // Skip during tab switch — handleTabChange saves outgoing tab state which + // mutates tabs[], triggering this handler redundantly. The tab selection + // handler already persists at the end. + guard !coordinator.isHandlingTabSwitch else { return } + updateWindowTitleAndFileState() - // Don't persist during teardown — SwiftUI may fire onChange with empty tabs - // as the view is being deallocated guard !coordinator.isTearingDown else { return } guard !coordinator.isUpdatingColumnLayout else { return } @@ -100,13 +80,13 @@ extension MainContentView { } // Only navigate when this is the focused window. - // Prevents feedback loops when shared sidebar state syncs across native tabs. + // Prevents feedback loops when shared sidebar state syncs across connection windows. guard isKeyWindow else { return } let isPreviewMode = AppSettingsManager.shared.tabs.enablePreviewTabs - let hasPreview = WindowLifecycleMonitor.shared.previewWindow(for: connection.id) != nil + let hasPreview = tabManager.tabs.contains { $0.isPreview } let result = SidebarNavigationResult.resolve( clickedTableName: tableName, diff --git a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index 38b8e02c..ab2642fd 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -95,44 +95,24 @@ extension MainContentView { } } - let selectedId = result.selectedTabId - - // First tab in the array gets the current window to preserve order. - // Remaining tabs open as native window tabs in order. - let firstTab = restoredTabs[0] - tabManager.tabs = [firstTab] - tabManager.selectedTabId = firstTab.id - - let remainingTabs = Array(restoredTabs.dropFirst()) - - if !remainingTabs.isEmpty { - let selectedWasFirst = firstTab.id == selectedId - Task { @MainActor in - for tab in remainingTabs { - let restorePayload = EditorTabPayload( - from: tab, connectionId: connection.id, skipAutoExecute: true) - WindowOpener.shared.openNativeTab(restorePayload) - } - // Bring the first window to front only if it had the selected tab. - // Otherwise let the last restored window stay focused. - if selectedWasFirst { - viewWindow?.makeKeyAndOrderFront(nil) - } - } - } + // All tabs go into one QueryTabManager — no native window loop + tabManager.tabs = restoredTabs + tabManager.selectedTabId = result.selectedTabId ?? restoredTabs.first?.id - if firstTab.tabType == .table, - !firstTab.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + // Execute the selected tab's query if it's a table tab + if let selectedTab = tabManager.selectedTab, + selectedTab.tabType == .table, + !selectedTab.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { if let session = DatabaseManager.shared.activeSessions[connection.id], session.isConnected { - if !firstTab.databaseName.isEmpty, - firstTab.databaseName != session.activeDatabase + if !selectedTab.databaseName.isEmpty, + selectedTab.databaseName != session.activeDatabase { - Task { await coordinator.switchDatabase(to: firstTab.databaseName) } + Task { await coordinator.switchDatabase(to: selectedTab.databaseName) } } else { - if let tableName = firstTab.tableName { + if let tableName = selectedTab.tableName { coordinator.restoreColumnLayoutForTable(tableName) } coordinator.executeTableTabQueryDirectly() @@ -188,19 +168,48 @@ extension MainContentView { let resolvedId = WindowOpener.tabbingIdentifier(for: connection.id) window.tabbingIdentifier = resolvedId - window.tabbingMode = .preferred + // Disallow native window tabbing — tabs are managed in-app via EditorTabBar + window.tabbingMode = .disallowed coordinator.windowId = windowId WindowLifecycleMonitor.shared.register( window: window, connectionId: connection.id, windowId: windowId, - isPreview: isPreview + isPreview: isPreview, + onWindowClose: { [coordinator, rightPanelState] in + coordinator.teardown() + rightPanelState.teardown() + } ) + viewWindow = window coordinator.contentWindow = window isKeyWindow = window.isKeyWindow + // Intercept Cmd+W via NSEvent monitor — close the active in-app tab + // instead of the window. Uses event monitor (like VimKeyInterceptor) + // instead of window.delegate to avoid overwriting SwiftUI's internal delegate. + if coordinator.closeTabMonitor == nil { + let capturedTabManager = tabManager + let capturedCoordinator = coordinator + weak var capturedWindow = window + coordinator.closeTabMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in + guard event.modifierFlags.contains(.command), + !event.modifierFlags.contains(.shift), + event.charactersIgnoringModifiers == "w", + NSApp.keyWindow === capturedWindow + else { return event } + + if let selectedId = capturedTabManager.selectedTabId { + capturedCoordinator.closeInAppTab(selectedId) + } else { + capturedWindow?.close() + } + return nil // Consume the event + } + } + if let payloadId = payload?.id { WindowOpener.shared.acknowledgePayload(payloadId) } diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 3254a4fe..2b916df5 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -329,20 +329,20 @@ final class MainContentCommandActions { // MARK: - Tab Operations (Group A — Called Directly) + func reopenClosedTab() { + coordinator?.reopenClosedTab() + } + + var canReopenClosedTab: Bool { + coordinator?.tabManager.hasClosedTabs ?? false + } + func newTab(initialQuery: String? = nil) { - // If no tabs exist (empty state), add directly to this window - if coordinator?.tabManager.tabs.isEmpty == true { + if let initialQuery { coordinator?.tabManager.addTab(initialQuery: initialQuery, databaseName: connection.database) - return + } else { + coordinator?.addNewQueryTab() } - // Open a new native macOS window tab with a query editor - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .query, - initialQuery: initialQuery, - intent: .newEmptyTab - ) - WindowOpener.shared.openNativeTab(payload) } func closeTab() { @@ -369,20 +369,17 @@ final class MainContentCommandActions { } private func performClose() { - guard let keyWindow = NSApp.keyWindow else { return } - let tabbedWindows = keyWindow.tabbedWindows ?? [keyWindow] + guard let coordinator else { + NSApp.keyWindow?.close() + return + } - if tabbedWindows.count > 1 { - keyWindow.close() - } else if coordinator?.tabManager.tabs.isEmpty == true { - keyWindow.close() + // Close the active in-app tab. Empty state is shown when no tabs remain. + if let selectedId = coordinator.tabManager.selectedTabId { + coordinator.closeInAppTab(selectedId) } else { - for tab in coordinator?.tabManager.tabs ?? [] { - tab.rowBuffer.evict() - } - coordinator?.tabManager.tabs.removeAll() - coordinator?.tabManager.selectedTabId = nil - coordinator?.toolbarState.isTableTab = false + // No tabs open — close the connection window + coordinator.contentWindow?.close() } } @@ -454,6 +451,12 @@ final class MainContentCommandActions { pendingTruncates.wrappedValue.removeAll() pendingDeletes.wrappedValue.removeAll() rightPanelState.editState.clearEdits() + // Clear file dirty state to prevent closeInAppTab from showing a second dialog + if let tab = coordinator?.tabManager.selectedTab, tab.isFileDirty, + let index = coordinator?.tabManager.selectedTabIndex + { + coordinator?.tabManager.tabs[index].savedFileContent = tab.query + } performClose() } @@ -490,11 +493,26 @@ final class MainContentCommandActions { // MARK: - Tab Navigation (Group A — Called Directly) func selectTab(number: Int) { - // Switch to the nth native window tab - guard let keyWindow = NSApp.keyWindow, - let tabbedWindows = keyWindow.tabbedWindows, - number > 0, number <= tabbedWindows.count else { return } - tabbedWindows[number - 1].makeKeyAndOrderFront(nil) + guard let tabs = coordinator?.tabManager.tabs, + number > 0, number <= tabs.count else { return } + let targetId = tabs[number - 1].id + // Skip if already on this tab (keyboard repeat of same Cmd+N) + guard coordinator?.tabManager.selectedTabId != targetId else { return } + coordinator?.tabManager.selectedTabId = targetId + } + + func selectPreviousTab() { + guard let tabs = coordinator?.tabManager.tabs, tabs.count > 1, + let currentIndex = coordinator?.tabManager.selectedTabIndex else { return } + let newIndex = (currentIndex - 1 + tabs.count) % tabs.count + coordinator?.tabManager.selectedTabId = tabs[newIndex].id + } + + func selectNextTab() { + guard let tabs = coordinator?.tabManager.tabs, tabs.count > 1, + let currentIndex = coordinator?.tabManager.selectedTabIndex else { return } + let newIndex = (currentIndex + 1) % tabs.count + coordinator?.tabManager.selectedTabId = tabs[newIndex].id } // MARK: - Filter Operations (Group A — Called Directly) @@ -799,13 +817,14 @@ final class MainContentCommandActions { }.value if let content { - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .query, + coordinator?.tabManager.addTab( initialQuery: content, + databaseName: connection.database, sourceFileURL: url ) - WindowOpener.shared.openNativeTab(payload) + if let windowId = coordinator?.windowId { + WindowLifecycleMonitor.shared.registerSourceFile(url, windowId: windowId) + } } } } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 1622a8d9..f65cddd9 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -108,6 +108,10 @@ final class MainContentCoordinator { /// Avoids NSApp.keyWindow which may return a sheet window, causing stuck dialogs. @ObservationIgnored weak var contentWindow: NSWindow? + /// NSEvent monitor that intercepts Cmd+W to close tabs instead of the window. + /// Removed in teardown. + @ObservationIgnored var closeTabMonitor: Any? + // MARK: - Published State var schemaProvider: SQLSchemaProvider @@ -133,6 +137,7 @@ final class MainContentCoordinator { @ObservationIgnored internal var currentQueryTask: Task? @ObservationIgnored internal var redisDatabaseSwitchTask: Task? @ObservationIgnored private var changeManagerUpdateTask: Task? + @ObservationIgnored internal var tabSwitchTask: Task? @ObservationIgnored private var activeSortTasks: [UUID: Task] = [:] @ObservationIgnored private var terminationObserver: NSObjectProtocol? @ObservationIgnored private var urlFilterObservers: [NSObjectProtocol] = [] @@ -156,6 +161,9 @@ final class MainContentCoordinator { /// Called during teardown to let the view layer release cached row providers and sort data. @ObservationIgnored var onTeardown: (() -> Void)? + /// Called from Phase 2 of tab switch after deferred state is settled. + /// View layer uses this to update title, sidebar, and persistence. + @ObservationIgnored var onTabSwitchSettled: (() -> Void)? /// True once the coordinator's view has appeared (onAppear fired). /// Coordinators that SwiftUI creates during body re-evaluation but never @@ -165,13 +173,8 @@ final class MainContentCoordinator { /// Tracks whether teardown() was called; used by deinit to log missed teardowns @ObservationIgnored private let _didTeardown = OSAllocatedUnfairLock(initialState: false) - /// Tracks whether teardown has been scheduled (but not yet executed) - /// so deinit doesn't warn if SwiftUI deallocates before the delayed Task fires - @ObservationIgnored private let _teardownScheduled = OSAllocatedUnfairLock(initialState: false) - - /// Whether teardown is scheduled or already completed — used by views to skip - /// persistence during window close teardown - var isTearingDown: Bool { _teardownScheduled.withLock { $0 } || _didTeardown.withLock { $0 } } + /// Whether teardown has completed — used by views to skip persistence during teardown + var isTearingDown: Bool { _didTeardown.withLock { $0 } } /// Set when NSApplication is terminating — suppresses deinit warning since /// SwiftUI does not call onDisappear during app termination @@ -200,10 +203,15 @@ final class MainContentCoordinator { activeCoordinators.values.first { $0.windowId == windowId } } + /// Find the first coordinator for a connection (used by AppDelegate for Cmd+T). + static func firstCoordinator(for connectionId: UUID) -> MainContentCoordinator? { + activeCoordinators.values.first { $0.connectionId == connectionId } + } + /// Check whether any active coordinator has unsaved edits. static func hasAnyUnsavedChanges() -> Bool { activeCoordinators.values.contains { coordinator in - coordinator.tabManager.tabs.contains { $0.pendingChanges.hasChanges } + coordinator.tabManager.tabs.contains { $0.pendingChanges.hasChanges || $0.isFileDirty } } } @@ -214,45 +222,6 @@ final class MainContentCoordinator { .flatMap { $0.tabManager.tabs } } - /// Collect non-preview tabs for persistence. - private static func aggregatedTabs(for connectionId: UUID) -> [QueryTab] { - let coordinators = activeCoordinators.values - .filter { $0.connectionId == connectionId } - - // Sort by native window tab order to preserve left-to-right position - let orderedCoordinators: [MainContentCoordinator] - if let firstWindow = coordinators.compactMap({ $0.contentWindow }).first, - let tabbedWindows = firstWindow.tabbedWindows { - let windowOrder = Dictionary(uniqueKeysWithValues: - tabbedWindows.enumerated().map { (ObjectIdentifier($0.element), $0.offset) } - ) - orderedCoordinators = coordinators.sorted { a, b in - let aIdx = a.contentWindow.flatMap { windowOrder[ObjectIdentifier($0)] } ?? Int.max - let bIdx = b.contentWindow.flatMap { windowOrder[ObjectIdentifier($0)] } ?? Int.max - return aIdx < bIdx - } - } else { - orderedCoordinators = Array(coordinators) - } - - return orderedCoordinators - .flatMap { $0.tabManager.tabs } - .filter { !$0.isPreview } - } - - /// Get selected tab ID from any coordinator for a given connectionId. - private static func aggregatedSelectedTabId(for connectionId: UUID) -> UUID? { - activeCoordinators.values - .first { $0.connectionId == connectionId && $0.tabManager.selectedTabId != nil }? - .tabManager.selectedTabId - } - - /// Check if this coordinator is the first registered for its connection. - private func isFirstCoordinatorForConnection() -> Bool { - Self.activeCoordinators.values - .first { $0.connectionId == self.connectionId } === self - } - private static let registerTerminationObserver: Void = { NotificationCenter.default.addObserver( forName: NSApplication.willTerminateNotification, @@ -264,7 +233,7 @@ final class MainContentCoordinator { }() /// Evict row data for background tabs in this coordinator to free memory. - /// Called when the coordinator's native window-tab becomes inactive. + /// Called when the connection window becomes inactive. /// The currently selected tab is kept in memory so the user sees no /// refresh flicker when switching back — matching native macOS behavior. /// Background tabs are re-fetched automatically when selected. @@ -329,17 +298,10 @@ final class MainContentCoordinator { ) { [weak self] _ in MainActor.assumeIsolated { guard let self else { return } - // Only the first coordinator for this connection saves, - // aggregating tabs from all windows to fix last-write-wins bug. - // Skip isTearingDown check: during Cmd+Q, onDisappear fires - // markTeardownScheduled() before willTerminate, and we still - // need to save here. - guard self.isFirstCoordinatorForConnection() else { return } - let allTabs = Self.aggregatedTabs(for: self.connectionId) - let selectedId = Self.aggregatedSelectedTabId(for: self.connectionId) + // Save all tabs directly — single coordinator per connection self.persistence.saveNowSync( - tabs: allTabs, - selectedTabId: selectedId + tabs: self.tabManager.tabs, + selectedTabId: self.tabManager.selectedTabId ) } } @@ -404,14 +366,6 @@ final class MainContentCoordinator { } } - func markTeardownScheduled() { - _teardownScheduled.withLock { $0 = true } - } - - func clearTeardownScheduled() { - _teardownScheduled.withLock { $0 = false } - } - func refreshTables() async { lastSchemaRefreshDate = Date() sidebarLoadingState = .loading @@ -460,6 +414,16 @@ final class MainContentCoordinator { func teardown() { _didTeardown.withLock { $0 = true } + // Resume any pending save continuation to prevent Task leak + saveCompletionContinuation?.resume(returning: false) + saveCompletionContinuation = nil + + // Remove Cmd+W event monitor + if let monitor = closeTabMonitor { + NSEvent.removeMonitor(monitor) + closeTabMonitor = nil + } + unregisterFromPersistence() for observer in urlFilterObservers { NotificationCenter.default.removeObserver(observer) @@ -473,6 +437,7 @@ final class MainContentCoordinator { NotificationCenter.default.removeObserver(observer) pluginDriverObserver = nil } + fileWatcher?.stopWatching(connectionId: connectionId) fileWatcher = nil currentQueryTask?.cancel() @@ -481,6 +446,8 @@ final class MainContentCoordinator { changeManagerUpdateTask = nil redisDatabaseSwitchTask?.cancel() redisDatabaseSwitchTask = nil + tabSwitchTask?.cancel() + tabSwitchTask = nil for task in activeSortTasks.values { task.cancel() } activeSortTasks.removeAll() @@ -526,7 +493,7 @@ final class MainContentCoordinator { saveCompletionContinuation = nil let connectionId = connection.id - let alreadyHandled = _didTeardown.withLock { $0 } || _teardownScheduled.withLock { $0 } + let alreadyHandled = _didTeardown.withLock { $0 } // Never-activated coordinators are throwaway instances created by SwiftUI // during body re-evaluation — @State only keeps the first, rest are discarded @@ -792,12 +759,7 @@ final class MainContentCoordinator { tabManager.tabs[tabIndex].query = query tabManager.tabs[tabIndex].hasUserInteraction = true } else { - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .query, - initialQuery: query - ) - WindowOpener.shared.openNativeTab(payload) + tabManager.addTab(initialQuery: query, databaseName: connection.database) } } @@ -815,12 +777,7 @@ final class MainContentCoordinator { } else if tabManager.tabs.isEmpty { tabManager.addTab(initialQuery: query, databaseName: connection.database) } else { - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .query, - initialQuery: query - ) - WindowOpener.shared.openNativeTab(payload) + tabManager.addTab(initialQuery: query, databaseName: connection.database) } } diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index bb7a46d3..50d4f2d1 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -53,8 +53,8 @@ struct MainContentView: View { @State var queryResultsSummaryCache: (tabId: UUID, version: Int, summary: String?)? @State var inspectorUpdateTask: Task? @State var lazyLoadTask: Task? - @State var pendingTabSwitch: Task? - @State var evictionTask: Task? + // pendingTabSwitch removed — tab switch is synchronous (2ms), no debounce needed + // evictionTask removed — eviction only on memory pressure, not window resign /// Stable identifier for this window in WindowLifecycleMonitor @State var windowId = UUID() @State var hasInitialized = false @@ -64,10 +64,6 @@ struct MainContentView: View { /// Reference to this view's NSWindow for filtering notifications @State var viewWindow: NSWindow? - /// Grace period for onDisappear: SwiftUI fires onDisappear transiently - /// during tab group merges, then re-fires onAppear shortly after. - private static let tabGroupMergeGracePeriod: Duration = .milliseconds(200) - // MARK: - Environment @@ -227,12 +223,9 @@ struct MainContentView: View { configureWindow(window) } } - .task(id: currentTab?.tableName) { - // Only load metadata after the tab has executed at least once — - // avoids a redundant DB query racing with the initial data query - guard currentTab?.lastExecutedAt != nil else { return } - await loadTableMetadataIfNeeded() - } + // Metadata loading moved to query completion (executeQueryInternal) + // and Phase 2 tab switch settlement. Removed .task(id: currentTab?.tableName) + // which created N queued tasks during rapid Cmd+1/2/3 switching. .onChange(of: inspectorTrigger) { scheduleInspectorUpdate() } @@ -249,47 +242,28 @@ struct MainContentView: View { rightPanelState.aiViewModel.schemaProvider = coordinator.schemaProvider coordinator.aiViewModel = rightPanelState.aiViewModel coordinator.rightPanelState = rightPanelState + coordinator.onTabSwitchSettled = { + // Capture reference types explicitly — MainContentView is a struct, + // but @State/@Binding storage is reference-stable. + self.updateWindowTitleAndFileState() + self.syncSidebarToCurrentTab() + guard !self.coordinator.isTearingDown else { return } + self.coordinator.persistence.saveNow( + tabs: self.tabManager.tabs, + selectedTabId: self.tabManager.selectedTabId + ) + if let tab = self.tabManager.selectedTab, tab.lastExecutedAt != nil { + Task { await self.loadTableMetadataIfNeeded() } + } + } // Window registration is handled by WindowAccessor in .background } .onDisappear { - // Mark teardown intent synchronously so deinit doesn't warn - // if SwiftUI deallocates the coordinator before the delayed Task fires - coordinator.markTeardownScheduled() - - let capturedWindowId = windowId - let connectionId = connection.id - Task { @MainActor in - // Grace period: SwiftUI fires onDisappear transiently during tab group - // merges/splits, then re-fires onAppear shortly after. The onAppear - // handler re-registers via WindowLifecycleMonitor on DispatchQueue.main.async, - // so this delay must exceed that dispatch latency to avoid tearing down - // a window that's about to reappear. - try? await Task.sleep(for: Self.tabGroupMergeGracePeriod) - - // If this window re-registered (temporary disappear during tab group merge), skip cleanup - if WindowLifecycleMonitor.shared.isRegistered(windowId: capturedWindowId) { - coordinator.clearTeardownScheduled() - return - } - - // Window truly closed — teardown coordinator - coordinator.teardown() - rightPanelState.teardown() - - // If no more windows for this connection, disconnect. - // Tab state is NOT cleared here — it's preserved for next reconnect. - // Only handleTabsChange(count=0) clears state (user explicitly closed all tabs). - guard !WindowLifecycleMonitor.shared.hasWindows(for: connectionId) else { - return - } - await DatabaseManager.shared.disconnectSession(connectionId) - - // Give SwiftUI/AppKit time to deallocate view hierarchies, - // then hint malloc to return freed pages to the OS - try? await Task.sleep(for: .seconds(2)) - malloc_zone_pressure_relief(nil, 0) - } + // No teardown here. Coordinator and panel cleanup is handled by + // WindowLifecycleMonitor.handleWindowClose (NSWindow.willCloseNotification) + // — a deterministic AppKit signal. SwiftUI's onDisappear fires transiently + // during view hierarchy reconstruction and is not reliable for resource cleanup. } .onChange(of: pendingChangeTrigger) { updateToolbarPendingState() @@ -321,13 +295,13 @@ struct MainContentView: View { .modifier(ToolbarTintModifier(connectionColor: connection.color)) .task { await initializeAndRestoreTabs() } .onChange(of: tabManager.selectedTabId) { _, newTabId in - pendingTabSwitch?.cancel() - pendingTabSwitch = Task { @MainActor in - await Task.yield() - guard !Task.isCancelled else { return } - handleTabSelectionChange(from: previousSelectedTabId, to: newTabId) - previousSelectedTabId = newTabId - } + // ZStack opacity flip happens automatically from selectedTabId binding. + // ALL work is deferred to Phase 2 (handleTabChange's Task) which + // coalesces rapid Cmd+1/2/3 switches via tabSwitchTask cancellation. + // No synchronous mutations here — avoids triggering body re-evals + // that block the main thread during keyboard repeat spam. + coordinator.scheduleTabSwitch(from: previousSelectedTabId, to: newTabId) + previousSelectedTabId = newTabId } .onChange(of: tabManager.tabs) { _, newTabs in handleTabsChange(newTabs) @@ -353,8 +327,6 @@ struct MainContentView: View { notificationWindow === viewWindow else { return } isKeyWindow = true - evictionTask?.cancel() - evictionTask = nil Task { @MainActor in syncSidebarToCurrentTab() } @@ -394,16 +366,10 @@ struct MainContentView: View { isKeyWindow = false lastResignKeyDate = Date() - // Schedule row data eviction for inactive native window-tabs. - // 5s delay avoids thrashing when quickly switching between tabs. - // Per-tab pendingChanges checks inside evictInactiveRowData() protect - // tabs with unsaved changes from eviction. - evictionTask?.cancel() - evictionTask = Task { @MainActor in - try? await Task.sleep(for: .seconds(5)) - guard !Task.isCancelled else { return } - coordinator.evictInactiveRowData() - } + // Row data eviction only happens under system memory pressure + // (via MemoryPressureAdvisor), not on window resign. Other DB clients + // (Beekeeper, DataGrip, TablePlus) keep data in memory until close. + // Evicting on resign caused re-fetch delays when switching back. } .onChange(of: tables) { _, newTables in let syncAction = SidebarSyncAction.resolveOnTablesLoad( diff --git a/TablePro/Views/Main/SidebarNavigationResult.swift b/TablePro/Views/Main/SidebarNavigationResult.swift index 7ea14d69..8483f835 100644 --- a/TablePro/Views/Main/SidebarNavigationResult.swift +++ b/TablePro/Views/Main/SidebarNavigationResult.swift @@ -15,7 +15,7 @@ enum SidebarNavigationResult: Equatable { /// No existing tabs: navigate in-place inside this window. case openInPlace /// Existing tabs present: revert sidebar to the current tab immediately, - /// then open the clicked table in a new native window tab. + /// then open the clicked table in a new in-app tab. /// Reverting synchronously prevents SwiftUI from rendering the [B] state /// before coalescing back to [A] — eliminating the visible flash. case revertAndOpenNewWindow @@ -54,7 +54,7 @@ enum SidebarNavigationResult: Equatable { return .openNewPreviewTab } - // Default: revert sidebar synchronously (no flash), then open in a new native tab. + // Default: revert sidebar synchronously (no flash), then open in a new in-app tab. return .revertAndOpenNewWindow } } diff --git a/TablePro/Views/TabBar/EditorTabBar.swift b/TablePro/Views/TabBar/EditorTabBar.swift new file mode 100644 index 00000000..6fb3c889 --- /dev/null +++ b/TablePro/Views/TabBar/EditorTabBar.swift @@ -0,0 +1,145 @@ +// +// EditorTabBar.swift +// TablePro +// +// Horizontal tab bar for switching between editor tabs within a connection window. +// Replaces native macOS window tabs for instant tab switching. +// + +import SwiftUI +import UniformTypeIdentifiers + +struct EditorTabBar: View { + let tabs: [QueryTab] + @Binding var selectedTabId: UUID? + let databaseType: DatabaseType + var onClose: (UUID) -> Void + var onCloseOthers: (UUID) -> Void + var onCloseAll: () -> Void + var onReorder: ([QueryTab]) -> Void + var onRename: (UUID, String) -> Void + var onAddTab: () -> Void + var onDuplicate: (UUID) -> Void + var onTogglePin: (UUID) -> Void + + @State private var draggedTabId: UUID? + + private var pinnedTabs: [QueryTab] { tabs.filter(\.isPinned) } + private var unpinnedTabs: [QueryTab] { tabs.filter { !$0.isPinned } } + + var body: some View { + HStack(spacing: 0) { + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 1) { + ForEach(pinnedTabs) { tab in + tabItem(for: tab) + } + if !pinnedTabs.isEmpty && !unpinnedTabs.isEmpty { + Divider() + .frame(height: 16) + .padding(.horizontal, 2) + } + ForEach(unpinnedTabs) { tab in + tabItem(for: tab) + } + } + .padding(.horizontal, 4) + } + .onChange(of: selectedTabId, initial: true) { _, newId in + if let id = newId { + proxy.scrollTo(id, anchor: .center) + } + } + } + + Divider() + .frame(height: 16) + + Button { + onAddTab() + } label: { + Image(systemName: "plus") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .padding(.horizontal, 8) + .help(String(localized: "New Tab")) + } + .frame(height: 30) + .background(Color(nsColor: .controlBackgroundColor)) + } + + @ViewBuilder + private func tabItem(for tab: QueryTab) -> some View { + EditorTabBarItem( + tab: tab, + isSelected: tab.id == selectedTabId, + databaseType: databaseType, + onSelect: { selectedTabId = tab.id }, + onClose: { onClose(tab.id) }, + onCloseOthers: { onCloseOthers(tab.id) }, + onCloseTabsToRight: { closeTabsToRight(of: tab.id) }, + onCloseAll: onCloseAll, + onDuplicate: { onDuplicate(tab.id) }, + onRename: { name in onRename(tab.id, name) }, + onTogglePin: { onTogglePin(tab.id) } + ) + .id(tab.id) + .onDrag { + draggedTabId = tab.id + return NSItemProvider(object: tab.id.uuidString as NSString) + } + .onDrop(of: [.text], delegate: TabDropDelegate( + targetId: tab.id, + tabs: tabs, + draggedTabId: $draggedTabId, + onReorder: onReorder + )) + } + + private func closeTabsToRight(of id: UUID) { + guard let index = tabs.firstIndex(where: { $0.id == id }) else { return } + let idsToClose = tabs[(index + 1)...].filter { !$0.isPinned }.map(\.id) + for tabId in idsToClose { + onClose(tabId) + } + } +} + +// MARK: - Drag & Drop + +private struct TabDropDelegate: DropDelegate { + let targetId: UUID + let tabs: [QueryTab] + @Binding var draggedTabId: UUID? + let onReorder: ([QueryTab]) -> Void + + func performDrop(info: DropInfo) -> Bool { + draggedTabId = nil + return true + } + + func dropEntered(info: DropInfo) { + guard let draggedId = draggedTabId, + draggedId != targetId, + let fromIndex = tabs.firstIndex(where: { $0.id == draggedId }), + let toIndex = tabs.firstIndex(where: { $0.id == targetId }) + else { return } + + var reordered = tabs + let moved = reordered.remove(at: fromIndex) + reordered.insert(moved, at: toIndex) + onReorder(reordered) + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + DropProposal(operation: .move) + } + + func dropExited(info: DropInfo) { + // Don't clear draggedTabId here — during reorder, dropEntered on the + // next tab needs it. It's cleared in performDrop when the drag ends. + } +} diff --git a/TablePro/Views/TabBar/EditorTabBarItem.swift b/TablePro/Views/TabBar/EditorTabBarItem.swift new file mode 100644 index 00000000..d402a7b5 --- /dev/null +++ b/TablePro/Views/TabBar/EditorTabBarItem.swift @@ -0,0 +1,135 @@ +// +// EditorTabBarItem.swift +// TablePro +// +// Individual tab item for the editor tab bar. +// + +import SwiftUI + +struct EditorTabBarItem: View { + let tab: QueryTab + let isSelected: Bool + let databaseType: DatabaseType + var onSelect: () -> Void + var onClose: () -> Void + var onCloseOthers: () -> Void + var onCloseTabsToRight: () -> Void + var onCloseAll: () -> Void + var onDuplicate: () -> Void + var onRename: (String) -> Void + var onTogglePin: () -> Void + + @State private var isEditing = false + @State private var editingTitle = "" + @State private var isHovering = false + @FocusState private var isEditingFocused: Bool + + private var icon: String { + switch tab.tabType { + case .table: + return "tablecells" + case .query: + return "chevron.left.forwardslash.chevron.right" + case .createTable: + return "plus.rectangle" + case .erDiagram: + return "chart.dots.scatter" + case .serverDashboard: + return "gauge.with.dots.needle.bottom.50percent" + } + } + + var body: some View { + HStack(spacing: 4) { + Image(systemName: icon) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + + if isEditing { + TextField("", text: $editingTitle) + .textFieldStyle(.plain) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) + .frame(minWidth: 40, maxWidth: 120) + .focused($isEditingFocused) + .onSubmit { + let trimmed = editingTitle.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + onRename(trimmed) + } + isEditing = false + } + .onChange(of: isEditingFocused) { _, focused in + if !focused && isEditing { + isEditing = false + } + } + } else { + Text(tab.title) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) + .italic(tab.isPreview) + .lineLimit(1) + } + + if tab.isFileDirty || tab.pendingChanges.hasChanges { + Circle() + .fill(Color.primary.opacity(0.5)) + .frame(width: 6, height: 6) + } + + // Pinned tabs: show pin icon, no close button + // Unpinned tabs: show close button on hover/selected + if tab.isPinned { + Image(systemName: "pin.fill") + .font(.system(size: 8)) + .foregroundStyle(.tertiary) + .frame(width: 14, height: 14) + } else if isHovering || isSelected { + Button { + onClose() + } label: { + Image(systemName: "xmark") + .font(.system(size: 8, weight: .semibold)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .frame(width: 14, height: 14) + } else { + Color.clear + .frame(width: 14, height: 14) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: ThemeEngine.shared.activeTheme.cornerRadius.small) + .fill(isSelected ? Color(nsColor: .selectedControlColor) : Color.clear) + ) + .contentShape(Rectangle()) + .onHover { isHovering = $0 } + .onTapGesture { onSelect() } + .contextMenu { + if tab.tabType == .query { + Button(String(localized: "Rename")) { + editingTitle = tab.title + isEditing = true + isEditingFocused = true + } + Divider() + } + Button(tab.isPinned ? String(localized: "Unpin Tab") : String(localized: "Pin Tab")) { + onTogglePin() + } + Divider() + if !tab.isPinned { + Button(String(localized: "Close")) { onClose() } + } + Button(String(localized: "Close Others")) { onCloseOthers() } + Button(String(localized: "Close Tabs to the Right")) { onCloseTabsToRight() } + Divider() + Button(String(localized: "Close All")) { onCloseAll() } + Divider() + Button(String(localized: "Duplicate")) { onDuplicate() } + } + } +} diff --git a/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift b/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift index ccaccd5b..d212daab 100644 --- a/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift +++ b/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift @@ -328,17 +328,9 @@ struct ConnectionSwitcherPopover: View { /// merge as a tab with the current connection's window group /// (unless the user opted to group all connections in one window). private func openWindowForDifferentConnection(_ payload: EditorTabPayload) { - if AppSettingsManager.shared.tabs.groupAllConnectionTabs { - WindowOpener.shared.openNativeTab(payload) - } else { - // Temporarily disable tab merging so the new window opens independently - let currentWindow = NSApp.keyWindow - let previousMode = currentWindow?.tabbingMode ?? .preferred - currentWindow?.tabbingMode = .disallowed - WindowOpener.shared.openNativeTab(payload) - DispatchQueue.main.async { - currentWindow?.tabbingMode = previousMode - } - } + // Each connection opens its own window. With in-app tabs and + // tabbingMode = .disallowed, windows don't auto-merge. + // groupAllConnectionTabs merging is handled by windowDidBecomeKey. + WindowOpener.shared.openNativeTab(payload) } }