diff --git a/CHANGELOG.md b/CHANGELOG.md index 912a90c35..77a9c1057 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Editor tabs now live inside one window per connection, with an in-window tab bar. The sidebar and inspector are shared across all tabs of a connection instead of duplicated per window. (#1220) - Add competitive tracking docs sourced from top TablePlus issues. ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index f96aa06a9..e3377ed34 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,7 +117,7 @@ When adding a new method to the driver protocol: add to `PluginDatabaseDriver` ( - **`SQLEditorTheme`** — single source of truth for editor colors/fonts - **`TableProEditorTheme`** — adapter to CodeEdit's `EditorTheme` protocol - **`CompletionEngine`** — framework-agnostic; **`SQLCompletionAdapter`** bridges to CodeEdit's `CodeSuggestionDelegate` -- Editor tabs use native NSWindow tabs (`NSWindow.tabbingMode = .preferred` in `TabWindowController`); there is no custom tab bar. +- Editor tabs are in-window tabs: one `ConnectionWindowController` window per connection, with `EditorTabStripView` (the in-window tab bar) driving `QueryTabManager.selectedTabId`. `MainContentView` renders the selected tab. `ConnectionWindow.tabbingMode = .automatic`. There are no per-tab NSWindows. - Cursor model: `cursorPositions: [CursorPosition]` (multi-cursor via CodeEditSourceEditor) ### Change Tracking Flow @@ -135,12 +135,9 @@ These have caused real bugs when violated: **WelcomeViewModel tree rebuild**: The welcome screen renders `treeItems` (grouped/filtered), not `connections` directly. Every mutation to `connections` must call `rebuildTree()` afterward, or the UI won't update. -**Tab replacement guard**: `openTableTab` checks for active work (unsaved edits, applied filters, sorting) before replacing the current tab. Tabs with active work open a new native window tab instead. This check runs before the preview tab branch. +**Tab replacement guard**: `openTableTab` checks for active work (unsaved edits, applied filters, sorting) before replacing the current tab. Tabs with active work open a new in-window tab instead. This check runs before the preview tab branch. -**Window tab titles**: Resolved in TWO places that must stay in sync: -1. `ContentView.init` (title resolution chain) — initial title from payload -2. `MainContentView+Setup.swift` `updateWindowTitleAndFileState()` — ongoing title updates -Missing a case produces a wrong "{Language} Query" title on the first frame. +**Window title resolution**: `ConnectionWindowController.refreshWindowTitle()` is the single source of truth for the window title, proxy icon, and dirty dot. It is called from `windowDidBecomeKey`, the controller's `init`, and the selected-tab-change hook in `MainContentView`. Do not resolve the title anywhere else. **Schema loading**: `SQLSchemaProvider` (actor) stores an in-flight `loadTask: Task?`. Concurrent callers `await` the same Task instead of firing duplicate `fetchTables()` queries. Never use a boolean `isLoading` guard that returns without data — callers need to await the result. @@ -150,7 +147,7 @@ Missing a case produces a wrong "{Language} Query" title on the first frame. ### Window Close (Cmd+W) -`EditorWindow` (NSWindow subclass in `TabWindowController.swift`) overrides `performClose:` to route Cmd+W through `closeTab()`. SwiftUI's `.commands { Button(...).keyboardShortcut("w") }` does NOT replace AppKit's built-in "File > Close" — both fire, and AppKit's wins. The NSWindow subclass is the correct native pattern. +`ConnectionWindow` (NSWindow subclass in `ConnectionWindowController.swift`) overrides `performClose:` to route Cmd+W through `commandActions.closeTab()`, which closes the selected in-window tab (or the window when it is the last tab). SwiftUI's `.commands { Button(...).keyboardShortcut("w") }` does NOT replace AppKit's built-in "File > Close": both fire, and AppKit's wins. The NSWindow subclass is the correct native pattern. `closeCurrentTab` closes the window with `NSWindow.close()`, not `performClose(_:)`, to avoid re-entering the override. ### Storage Patterns diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 1d578deec..8e8e5bff1 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -7,7 +7,6 @@ objects = { /* Begin PBXBuildFile section */ - 5AD1D8C12FB5000000000001 /* TableProMSSQLCore in Frameworks */ = {isa = PBXBuildFile; productRef = 5AD1D8C12FB5000000000002 /* TableProMSSQLCore */; }; 5A32BBFB2F9D5EAB00BAEB5F /* X509 in Frameworks */ = {isa = PBXBuildFile; productRef = 5A32BBFA2F9D5EAB00BAEB5F /* X509 */; }; 5A32BC0B2F9D659100BAEB5F /* tablepro-mcp in Copy Files */ = {isa = PBXBuildFile; fileRef = 5A32BC002F9D5F1300BAEB5F /* tablepro-mcp */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; 5A3A69B82F976F38000AC5B2 /* GhosttyTerminal in Frameworks */ = {isa = PBXBuildFile; productRef = 5A3A69B72F976F38000AC5B2 /* GhosttyTerminal */; }; @@ -55,6 +54,7 @@ 5ACE00012F4F000000000005 /* CodeEditLanguages in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000003 /* CodeEditLanguages */; }; 5ACE00012F4F000000000006 /* CodeEditTextView in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000007 /* CodeEditTextView */; }; 5ACE00012F4F00000000000A /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000009 /* Sparkle */; }; + 5AD1D8C12FB5000000000001 /* TableProMSSQLCore in Frameworks */ = {isa = PBXBuildFile; productRef = 5AD1D8C12FB5000000000002 /* TableProMSSQLCore */; }; 5ADDB00100000000000000A1 /* DynamoDBConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ADDB00200000000000000A1 /* DynamoDBConnection.swift */; }; 5ADDB00100000000000000A2 /* DynamoDBItemFlattener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ADDB00200000000000000A2 /* DynamoDBItemFlattener.swift */; }; 5ADDB00100000000000000A3 /* DynamoDBPartiQLParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ADDB00200000000000000A3 /* DynamoDBPartiQLParser.swift */; }; diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index a3e5eeb55..8f1fbcbef 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -260,8 +260,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { .first(where: { $0.connectionId == connectionId })?.commandActions { actions.newTab() } else { - WindowManager.shared.openTab( - payload: EditorTabPayload(connectionId: connectionId, intent: .newEmptyTab) + WindowManager.shared.openConnectionWindow( + for: connectionId, + intent: EditorTabPayload(connectionId: connectionId, intent: .newEmptyTab) ) } } diff --git a/TablePro/Core/MCP/Protocol/Tools/OpenConnectionWindowTool.swift b/TablePro/Core/MCP/Protocol/Tools/OpenConnectionWindowTool.swift index 6d65e7f5a..05a610730 100644 --- a/TablePro/Core/MCP/Protocol/Tools/OpenConnectionWindowTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/OpenConnectionWindowTool.swift @@ -40,21 +40,16 @@ public struct OpenConnectionWindowTool: MCPToolImplementation { Self.logger.debug("open_connection_window invoked for connection \(connectionId.uuidString, privacy: .public)") - let windowId = await MainActor.run { () -> UUID in - let payload = EditorTabPayload( - connectionId: connectionId, - tabType: .query, - intent: .restoreOrDefault - ) - WindowManager.shared.openTab(payload: payload) + let windowId = await MainActor.run { () -> UUID? in + let id = WindowManager.shared.openConnectionWindow(for: connectionId) NSApp.activate(ignoringOtherApps: true) - return payload.id + return id } let result: JsonValue = .object([ "status": .string("opened"), "connection_id": .string(connectionId.uuidString), - "window_id": .string(windowId.uuidString) + "window_id": .string(windowId?.uuidString ?? "") ]) return .structured(result) } diff --git a/TablePro/Core/MCP/Protocol/Tools/OpenTableTabTool.swift b/TablePro/Core/MCP/Protocol/Tools/OpenTableTabTool.swift index 9ab75b6ef..676bd9da7 100644 --- a/TablePro/Core/MCP/Protocol/Tools/OpenTableTabTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/OpenTableTabTool.swift @@ -56,7 +56,7 @@ public struct OpenTableTabTool: MCPToolImplementation { Self.logger.debug("open_table_tab invoked for connection \(connectionId.uuidString, privacy: .public)") - let windowId = await MainActor.run { () -> UUID in + let windowId = await MainActor.run { () -> UUID? in let payload = EditorTabPayload( connectionId: connectionId, tabType: .table, @@ -65,16 +65,16 @@ public struct OpenTableTabTool: MCPToolImplementation { schemaName: schemaName, intent: .openContent ) - WindowManager.shared.openTab(payload: payload) + let id = WindowManager.shared.openConnectionWindow(for: connectionId, intent: payload) NSApp.activate(ignoringOtherApps: true) - return payload.id + return id } let result: JsonValue = .object([ "status": .string("opened"), "connection_id": .string(connectionId.uuidString), "table_name": .string(tableName), - "window_id": .string(windowId.uuidString) + "window_id": .string(windowId?.uuidString ?? "") ]) return .structured(result) } diff --git a/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift b/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift index 66b9a601c..322de331a 100644 --- a/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift +++ b/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift @@ -28,6 +28,7 @@ internal final class AppLaunchCoordinator { internal func didFinishLaunching() { hasFinishedLaunching = true + WindowLayoutMigration.runIfNeeded() let deadline = Date().addingTimeInterval(0.150) phase = .collectingIntents(deadline: deadline) deadlineTask = Task { [weak self] in diff --git a/TablePro/Core/Services/Infrastructure/CommandActionsRegistry.swift b/TablePro/Core/Services/Infrastructure/CommandActionsRegistry.swift index d81ca5eff..9c282bbcb 100644 --- a/TablePro/Core/Services/Infrastructure/CommandActionsRegistry.swift +++ b/TablePro/Core/Services/Infrastructure/CommandActionsRegistry.swift @@ -8,7 +8,7 @@ // (toolbar items + main content) is its own SwiftUI scene context, and // focus-scene-value propagation breaks once a toolbar Button takes scene // focus. The registry is updated on `windowDidBecomeKey` from -// `TabWindowController`, then read by `AppMenuCommands` as a fallback when +// `ConnectionWindowController`, then read by `AppMenuCommands` as a fallback when // `@FocusedValue` returns nil — so menu shortcuts (Cmd+T, Cmd+1...9, etc.) // stay live regardless of which sub-NSHostingController holds focus. // diff --git a/TablePro/Core/Services/Infrastructure/TabWindowController.swift b/TablePro/Core/Services/Infrastructure/ConnectionWindowController.swift similarity index 55% rename from TablePro/Core/Services/Infrastructure/TabWindowController.swift rename to TablePro/Core/Services/Infrastructure/ConnectionWindowController.swift index 768e3d656..bc3e8e22b 100644 --- a/TablePro/Core/Services/Infrastructure/TabWindowController.swift +++ b/TablePro/Core/Services/Infrastructure/ConnectionWindowController.swift @@ -1,5 +1,5 @@ // -// TabWindowController.swift +// ConnectionWindowController.swift // TablePro // @@ -8,7 +8,7 @@ import os import SwiftUI @MainActor -private final class EditorWindow: NSWindow { +private final class ConnectionWindow: NSWindow { override func performClose(_ sender: Any?) { if let coordinator = MainContentCoordinator.coordinator(forWindow: self), let actions = coordinator.commandActions { @@ -29,22 +29,27 @@ private final class EditorWindow: NSWindow { } @MainActor -internal final class TabWindowController: NSWindowController, NSWindowDelegate { +internal final class ConnectionWindowController: NSWindowController, NSWindowDelegate { private static let lifecycleLogger = Logger(subsystem: "com.TablePro", category: "NativeTabLifecycle") - internal static let frameAutosaveName: NSWindow.FrameAutosaveName = "MainEditorWindow" - - internal let payload: EditorTabPayload - + internal let connection: DatabaseConnection internal let controllerId: UUID + internal let coordinator: MainContentCoordinator + private let sessionState: SessionStateFactory.SessionState private var activity: NSUserActivity? - internal init(payload: EditorTabPayload, sessionState: SessionStateFactory.SessionState? = nil) { - self.payload = payload + private var frameAutosaveName: NSWindow.FrameAutosaveName { + "MainEditorWindow.\(connection.id.uuidString)" + } + + internal init(connection: DatabaseConnection, sessionState: SessionStateFactory.SessionState) { + self.connection = connection self.controllerId = UUID() + self.sessionState = sessionState + self.coordinator = sessionState.coordinator - let window = EditorWindow( + let window = ConnectionWindow( contentRect: NSRect(x: 0, y: 0, width: 1_200, height: 800), styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], backing: .buffered, @@ -53,22 +58,25 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { window.identifier = NSUserInterfaceItemIdentifier("main") window.minSize = NSSize(width: 720, height: 480) window.isRestorable = AppSettingsStorage.shared.loadGeneral().startupBehavior == .reopenLast - window.restorationClass = TabWindowRestoration.self + window.restorationClass = ConnectionWindowRestoration.self window.toolbarStyle = .unified window.titleVisibility = .hidden - window.tabbingMode = .preferred - window.tabbingIdentifier = WindowManager.tabbingIdentifier(for: payload.connectionId) + window.tabbingMode = .automatic window.collectionBehavior.insert([.fullScreenPrimary, .managed]) - let splitVC = MainSplitViewController(payload: payload, sessionState: sessionState) + let splitVC = MainSplitViewController(connection: connection, sessionState: sessionState) window.contentViewController = splitVC + window.title = connection.name + super.init(window: window) window.isReleasedWhenClosed = false window.delegate = self - if !window.setFrameUsingName(Self.frameAutosaveName) { + refreshWindowTitle() + + if !window.setFrameUsingName(frameAutosaveName) { let visibleSize = (window.screen ?? NSScreen.main)?.visibleFrame.size ?? NSSize(width: 1_440, height: 900) window.setContentSize(NSSize( @@ -79,18 +87,18 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { } Self.lifecycleLogger.info( - "[open] TabWindowController.init payloadId=\(payload.id, privacy: .public) connId=\(payload.connectionId, privacy: .public) controllerId=\(self.controllerId, privacy: .public) eagerToolbar=\(sessionState != nil)" + "[open] ConnectionWindowController.init connId=\(connection.id, privacy: .public) controllerId=\(self.controllerId, privacy: .public)" ) } @available(*, unavailable) required init?(coder: NSCoder) { - fatalError("TabWindowController does not support NSCoder init") + fatalError("ConnectionWindowController does not support NSCoder init") } override func encodeRestorableState(with coder: NSCoder) { super.encodeRestorableState(with: coder) - coder.encode(payload.connectionId.uuidString as NSString, forKey: TabWindowRestoration.connectionIdKey) + coder.encode(connection.id.uuidString as NSString, forKey: ConnectionWindowRestoration.connectionIdKey) } // MARK: - NSWindowDelegate @@ -98,85 +106,63 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { internal func windowDidResize(_ notification: Notification) { guard let window = notification.object as? NSWindow else { return } guard !window.inLiveResize else { return } - window.saveFrame(usingName: Self.frameAutosaveName) + window.saveFrame(usingName: frameAutosaveName) } internal func windowDidEndLiveResize(_ notification: Notification) { guard let window = notification.object as? NSWindow else { return } - window.saveFrame(usingName: Self.frameAutosaveName) + window.saveFrame(usingName: frameAutosaveName) } internal func windowDidMove(_ notification: Notification) { guard let window = notification.object as? NSWindow else { return } - window.saveFrame(usingName: Self.frameAutosaveName) + window.saveFrame(usingName: frameAutosaveName) } internal func windowDidBecomeKey(_ notification: Notification) { - let seq = MainContentCoordinator.nextSwitchSeq() - let t0 = Date() - guard let window = notification.object as? NSWindow, - let coordinator = MainContentCoordinator.coordinator(forWindow: window) - else { return } - Self.lifecycleLogger.debug( - "[switch] windowDidBecomeKey seq=\(seq) controllerId=\(self.controllerId, privacy: .public) connId=\(coordinator.connectionId, privacy: .public)" - ) + guard let window = notification.object as? NSWindow else { return } if let splitVC = window.contentViewController as? MainSplitViewController { splitVC.installToolbar(coordinator: coordinator) } - Self.lifecycleLogger.debug("[switch] windowDidBecomeKey seq=\(seq) installToolbar ms=\(Int(Date().timeIntervalSince(t0) * 1_000))") CommandActionsRegistry.shared.current = coordinator.commandActions - updateUserActivity(coordinator: coordinator) - Self.lifecycleLogger.debug("[switch] windowDidBecomeKey seq=\(seq) userActivity ms=\(Int(Date().timeIntervalSince(t0) * 1_000))") + updateUserActivity() + refreshWindowTitle() coordinator.handleWindowDidBecomeKey() - Self.lifecycleLogger.debug("[switch] windowDidBecomeKey seq=\(seq) total ms=\(Int(Date().timeIntervalSince(t0) * 1_000))") } internal func windowDidResignKey(_ notification: Notification) { - let seq = MainContentCoordinator.nextSwitchSeq() - let t0 = Date() - guard let window = notification.object as? NSWindow, - let coordinator = MainContentCoordinator.coordinator(forWindow: window) - else { return } - Self.lifecycleLogger.debug( - "[switch] windowDidResignKey seq=\(seq) controllerId=\(self.controllerId, privacy: .public)" - ) if let actions = coordinator.commandActions, CommandActionsRegistry.shared.current === actions { CommandActionsRegistry.shared.current = nil } activity?.resignCurrent() coordinator.handleWindowDidResignKey() - Self.lifecycleLogger.debug("[switch] windowDidResignKey seq=\(seq) total ms=\(Int(Date().timeIntervalSince(t0) * 1_000))") } internal func windowWillClose(_ notification: Notification) { - let seq = MainContentCoordinator.nextSwitchSeq() - let t0 = Date() guard let window = notification.object as? NSWindow else { return } - Self.lifecycleLogger.info("[close] windowWillClose seq=\(seq) controllerId=\(self.controllerId, privacy: .public)") + Self.lifecycleLogger.info( + "[close] ConnectionWindowController.windowWillClose controllerId=\(self.controllerId, privacy: .public)" + ) cancelPendingConnectionIfNeeded() - - window.saveFrame(usingName: Self.frameAutosaveName) + window.saveFrame(usingName: frameAutosaveName) if let splitVC = window.contentViewController as? MainSplitViewController { splitVC.invalidateToolbar() } - let coordinator = MainContentCoordinator.coordinator(forWindow: window) - coordinator?.handleWindowWillClose() - Self.lifecycleLogger.info("[close] windowWillClose seq=\(seq) handleWindowWillClose ms=\(Int(Date().timeIntervalSince(t0) * 1_000))") - if let actions = coordinator?.commandActions, + coordinator.handleWindowWillClose() + if let actions = coordinator.commandActions, CommandActionsRegistry.shared.current === actions { CommandActionsRegistry.shared.current = nil } activity?.invalidate() activity = nil - Self.lifecycleLogger.info("[close] windowWillClose seq=\(seq) total ms=\(Int(Date().timeIntervalSince(t0) * 1_000))") } private func cancelPendingConnectionIfNeeded() { - let connectionId = payload.connectionId + let connectionId = connection.id let session = DatabaseManager.shared.activeSessions[connectionId] guard session?.driver == nil else { return } Task { @@ -184,17 +170,57 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { } } + // MARK: - Window Title + + /// Single source of truth for the window title, proxy icon, and dirty dot. + /// Resolved from the selected tab. Called on `windowDidBecomeKey`, once + /// after the window is created, and from `MainContentView` when the + /// selected tab changes. + internal func refreshWindowTitle() { + guard let window else { return } + let selectedTab = coordinator.tabManager.selectedTab + + let title: String + switch selectedTab?.tabType { + case .serverDashboard: + title = String(localized: "Server Dashboard") + case .createTable: + title = String(localized: "Create Table") + case .erDiagram: + title = String(localized: "ER Diagram") + case .terminal: + title = String(localized: "Terminal") + case .table: + title = selectedTab?.tableContext.tableName + ?? selectedTab?.title + ?? connection.name + default: + if let fileURL = selectedTab?.content.sourceFileURL { + title = selectedTab?.title ?? fileURL.deletingPathExtension().lastPathComponent + } else if let selectedTab { + title = selectedTab.title + } else { + title = connection.name + } + } + + window.title = title + window.representedURL = selectedTab?.content.sourceFileURL + window.isDocumentEdited = selectedTab?.content.isFileDirty ?? false + } + // MARK: - NSUserActivity + /// Refresh the Handoff activity from the current tab. Called on + /// `windowDidBecomeKey` and from `MainContentView` when the selected tab + /// changes. The key-window guard prevents a background window's tab switch + /// from overwriting the foreground window's activity. internal func refreshUserActivity() { - guard let window, window.isKeyWindow, - let coordinator = MainContentCoordinator.coordinator(forWindow: window) - else { return } - updateUserActivity(coordinator: coordinator) + guard let window, window.isKeyWindow else { return } + updateUserActivity() } - private func updateUserActivity(coordinator: MainContentCoordinator) { - let connection = coordinator.connection + private func updateUserActivity() { let selectedTab = coordinator.tabManager.selectedTab let tableName: String? = (selectedTab?.tabType == .table) ? selectedTab?.tableContext.tableName : nil let activityType = tableName != nil ? "com.TablePro.viewTable" : "com.TablePro.viewConnection" @@ -213,12 +239,6 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { info["tableName"] = tableName } activity.userInfo = info - - // becomeCurrent is unconditional. A previous becomeCurrent: Bool gate - // dropped Continuity mid-session whenever the user switched between - // table and query tabs in the same window, because the activity-type - // flip above invalidates the old activity but never promotes its - // replacement. activity.becomeCurrent() } } diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index 47d853aa4..31f4b5f75 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -3,9 +3,10 @@ // TablePro // // NSSplitViewController replacing NavigationSplitView for native sidebar/inspector. -// Owns session state, manages three panes (sidebar, detail, inspector), and -// serves as window.contentViewController so .toggleSidebar and -// .sidebarTrackingSeparator work via the responder chain. +// One instance per connection window, created once and never rebuilt. Manages +// three panes (sidebar, detail, inspector) and serves as +// window.contentViewController so .toggleSidebar and .sidebarTrackingSeparator +// work via the responder chain. // import AppKit @@ -17,18 +18,12 @@ import SwiftUI internal final class MainSplitViewController: NSSplitViewController, InspectorVisibilityProxy { private static let lifecycleLogger = Logger(subsystem: "com.TablePro", category: "NativeTabLifecycle") - // MARK: - Payload & Session + // MARK: - Connection & Session - let payload: EditorTabPayload? - private let payloadConnection: DatabaseConnection? - private var currentSession: ConnectionSession? - private var sessionState: SessionStateFactory.SessionState? - private var rightPanelState: RightPanelState? - private var closingSessionId: UUID? - - var windowTitle: String { - didSet { view.window?.title = windowTitle } - } + private let connection: DatabaseConnection + private let sessionState: SessionStateFactory.SessionState + private var coordinator: MainContentCoordinator { sessionState.coordinator } + private var rightPanelState: RightPanelState { sessionState.rightPanelState } // MARK: - Split View Items @@ -51,64 +46,9 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi // MARK: - Init - init(payload: EditorTabPayload?, sessionState: SessionStateFactory.SessionState?) { - self.payload = payload - if let connectionId = payload?.connectionId { - self.payloadConnection = DatabaseManager.shared.activeSessions[connectionId]?.connection - ?? ConnectionStorage.shared.loadConnections().first { $0.id == connectionId } - } else { - self.payloadConnection = nil - } - - let defaultTitle: String - if payload?.tabType == .serverDashboard { - defaultTitle = String(localized: "Server Dashboard") - } else if payload?.tabType == .erDiagram { - defaultTitle = String(localized: "ER Diagram") - } else if payload?.tabType == .createTable { - defaultTitle = String(localized: "Create Table") - } else if payload?.tabType == .terminal { - defaultTitle = String(localized: "Terminal") - } else if let tabTitle = payload?.tabTitle { - defaultTitle = tabTitle - } else if let tableName = payload?.tableName { - defaultTitle = tableName - } else if let connectionId = payload?.connectionId, - let connection = DatabaseManager.shared.activeSessions[connectionId]?.connection { - let langName = PluginManager.shared.queryLanguageName(for: connection.type) - defaultTitle = "\(langName) Query" - } else { - defaultTitle = String(localized: "SQL Query") - } - self.windowTitle = defaultTitle - - var resolvedSession: ConnectionSession? - if let connectionId = payload?.connectionId { - resolvedSession = DatabaseManager.shared.activeSessions[connectionId] - } else if let currentId = DatabaseManager.shared.currentSessionId { - resolvedSession = DatabaseManager.shared.activeSessions[currentId] - } - self.currentSession = resolvedSession - - if let session = resolvedSession { - self.rightPanelState = RightPanelState() - let state: SessionStateFactory.SessionState - if let payloadId = payload?.id, - let pending = SessionStateFactory.consumePending(for: payloadId) { - state = pending - Self.lifecycleLogger.info( - "[open] MainSplitVC.init consumed pending payloadId=\(payloadId, privacy: .public)" - ) - } else { - state = SessionStateFactory.create(connection: session.connection, payload: payload) - } - self.sessionState = state - if payload?.intent == .newEmptyTab, - let tabTitle = state.coordinator.tabManager.selectedTab?.title { - self.windowTitle = tabTitle - } - } - + init(connection: DatabaseConnection, sessionState: SessionStateFactory.SessionState) { + self.connection = connection + self.sessionState = sessionState super.init(nibName: nil, bundle: nil) } @@ -124,7 +64,10 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi splitView.dividerStyle = .thin splitView.isVertical = true - splitView.autosaveName = "com.TablePro.mainSplit" + splitView.autosaveName = "com.TablePro.mainSplit.\(connection.id.uuidString)" + + coordinator.inspectorProxy = self + coordinator.splitViewController = self sidebarContainer = SidebarContainerViewController(rootView: AnyView(buildSidebarView())) sidebarSplitItem = NSSplitViewItem(sidebarWithViewController: sidebarContainer) @@ -139,7 +82,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi detailSplitItem.holdingPriority = .defaultLow addSplitViewItem(detailSplitItem) - let inspectorPresented = UserDefaults.standard.bool(forKey: Self.inspectorPresentedKey) + let inspectorPresented = UserDefaults.standard.bool(forKey: inspectorPresentedKey) let initialInspectorContent: AnyView if inspectorPresented { initialInspectorContent = AnyView(buildInspectorView()) @@ -154,17 +97,21 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi inspectorSplitItem.maximumThickness = 400 addSplitViewItem(inspectorSplitItem) - if currentSession?.driver == nil { - sidebarSplitItem.isCollapsed = true - } else if let session = currentSession, let coordinator = sessionState?.coordinator { + if isConnected { sidebarContainer.updateSidebarState( - SharedSidebarState.forConnection(session.connection.id), + SharedSidebarState.forConnection(connection.id), windowState: coordinator.windowSidebarState ) + } else { + sidebarSplitItem.isCollapsed = true } inspectorSplitItem.isCollapsed = !inspectorPresented } + private var isConnected: Bool { + DatabaseManager.shared.activeSessions[connection.id]?.driver != nil + } + private func materializeInspectorIfNeeded() { guard !hasMaterializedInspector, let inspectorHosting else { return } hasMaterializedInspector = true @@ -175,20 +122,13 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi super.viewWillAppear() guard let window = view.window else { return } - window.title = windowTitle - if let session = currentSession { - window.subtitle = session.connection.name - } + window.subtitle = connection.name - if let sessionState { - sessionState.coordinator.inspectorProxy = self - sessionState.coordinator.splitViewController = self - installToolbar(coordinator: sessionState.coordinator) - } + installToolbar(coordinator: coordinator) - if let currentSession, let coordinator = sessionState?.coordinator { + if isConnected { sidebarContainer.updateSidebarState( - SharedSidebarState.forConnection(currentSession.connection.id), + SharedSidebarState.forConnection(connection.id), windowState: coordinator.windowSidebarState ) } @@ -207,6 +147,9 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi guard connectionStatusCancellable == nil else { return } connectionStatusCancellable = AppEvents.shared.connectionStatusChanged .receive(on: RunLoop.main) + .filter { [weak self] payload in + payload.connectionId == self?.connection.id + } .sink { [weak self] _ in self?.handleConnectionStatusChange() } @@ -237,64 +180,20 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi // MARK: - Connection Status private func handleConnectionStatusChange() { - guard closingSessionId == nil else { return } - - let sessions = DatabaseManager.shared.activeSessions - let connectionId = payload?.connectionId ?? currentSession?.id ?? DatabaseManager.shared.currentSessionId - - guard let sid = connectionId else { - if currentSession != nil { currentSession = nil } - return - } - - guard let newSession = sessions[sid] else { - if currentSession?.id == sid { - Self.lifecycleLogger.info( - "[close] MainSplitVC session removed connId=\(sid, privacy: .public)" - ) - closingSessionId = sid - rightPanelState?.teardown() - rightPanelState = nil - sessionState?.coordinator.teardown() - sessionState = nil - currentSession = nil - sidebarContainer.updateSidebarState(nil, windowState: nil) - if view.window?.isVisible == true { - sidebarSplitItem.animator().isCollapsed = true - } else { - sidebarSplitItem.isCollapsed = true - } - } - return - } - - if let existing = currentSession, existing.isContentViewEquivalent(to: newSession) { - return - } - currentSession = newSession - - if payload?.tableName == nil, - windowTitle == String(localized: "SQL Query") || windowTitle.hasSuffix(" Query") { - windowTitle = newSession.connection.name - } - view.window?.subtitle = newSession.connection.name - - if rightPanelState == nil { - rightPanelState = RightPanelState() - } - if sessionState == nil { - let state = SessionStateFactory.create(connection: newSession.connection, payload: payload) - sessionState = state - state.coordinator.inspectorProxy = self - state.coordinator.splitViewController = self - installToolbar(coordinator: state.coordinator) - } - - let collapseSidebar = newSession.driver == nil + let session = DatabaseManager.shared.activeSessions[connection.id] + let connected = session?.driver != nil if view.window?.isVisible == true { - sidebarSplitItem.animator().isCollapsed = collapseSidebar + sidebarSplitItem.animator().isCollapsed = !connected } else { - sidebarSplitItem.isCollapsed = collapseSidebar + sidebarSplitItem.isCollapsed = !connected + } + if connected { + sidebarContainer.updateSidebarState( + SharedSidebarState.forConnection(connection.id), + windowState: coordinator.windowSidebarState + ) + } else { + sidebarContainer.updateSidebarState(nil, windowState: nil) } rebuildPanes() } @@ -303,9 +202,9 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi private func rebuildPanes() { sidebarContainer.rootView = AnyView(buildSidebarView()) - if let currentSession, let coordinator = sessionState?.coordinator { + if isConnected { sidebarContainer.updateSidebarState( - SharedSidebarState.forConnection(currentSession.connection.id), + SharedSidebarState.forConnection(connection.id), windowState: coordinator.windowSidebarState ) } @@ -315,8 +214,8 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi @ViewBuilder private func buildSidebarView() -> some View { - if let currentSession, let sessionState { - sidebarBody(currentSession: currentSession, sessionState: sessionState) + if isConnected { + sidebarBody() .transaction { $0.animation = nil } } else { Color.clear @@ -324,35 +223,21 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi } @ViewBuilder - private func sidebarBody( - currentSession: ConnectionSession, - sessionState: SessionStateFactory.SessionState - ) -> some View { + private func sidebarBody() -> some View { SidebarView( - sidebarState: SharedSidebarState.forConnection(currentSession.connection.id), + sidebarState: SharedSidebarState.forConnection(connection.id), onDoubleClick: { [weak self] table in - guard let coordinator = self?.sessionState?.coordinator else { return } - let connectionId = coordinator.connectionId + guard let self else { return } let isView = table.type == .view - if let preview = WindowLifecycleMonitor.shared.previewWindow(for: connectionId), - let previewCoordinator = MainContentCoordinator.coordinator(for: preview.windowId) { - if previewCoordinator.tabManager.selectedTab?.tableContext.tableName == table.name { - previewCoordinator.promotePreviewTab() - } else { - previewCoordinator.promotePreviewTab() - coordinator.openTableTab(table.name, isView: isView) - } - } else { - coordinator.promotePreviewTab() - coordinator.openTableTab(table.name, isView: isView) - } + self.coordinator.promotePreviewTab() + self.coordinator.openTableTab(table.name, isView: isView) }, pendingTruncates: sessionPendingTruncatesBinding, pendingDeletes: sessionPendingDeletesBinding, tableOperationOptions: sessionTableOperationOptionsBinding, - databaseType: currentSession.connection.type, - connectionId: currentSession.connection.id, - coordinator: sessionState.coordinator + databaseType: connection.type, + connectionId: connection.id, + coordinator: coordinator ) } @@ -362,12 +247,10 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi ConnectingStateView(connection: pendingConnection) { [weak self] in self?.cancelConnectionAttempt() } - } else if let currentSession, let rightPanelState, let sessionState { - MainContentView( - connection: currentSession.connection, - payload: payload, - windowTitle: windowTitleBinding, - sidebarState: SharedSidebarState.forConnection(currentSession.connection.id), + } else { + ConnectionSplitContainerView( + connection: connection, + sidebarState: SharedSidebarState.forConnection(connection.id), pendingTruncates: sessionPendingTruncatesBinding, pendingDeletes: sessionPendingDeletesBinding, tableOperationOptions: sessionTableOperationOptionsBinding, @@ -375,21 +258,17 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi tabManager: sessionState.tabManager, changeManager: sessionState.changeManager, toolbarState: sessionState.toolbarState, - coordinator: sessionState.coordinator + coordinator: coordinator ) .transaction { $0.animation = nil } - } else { - Color.clear } } private var connectingConnection: DatabaseConnection? { - guard closingSessionId == nil else { return nil } - guard let connectionId = payload?.connectionId else { return nil } - if let session = DatabaseManager.shared.activeSessions[connectionId] { + if let session = DatabaseManager.shared.activeSessions[connection.id] { return session.driver == nil ? session.connection : nil } - return payloadConnection + return connection } private func cancelConnectionAttempt() { @@ -398,14 +277,10 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi @ViewBuilder private func buildInspectorView() -> some View { - if let currentSession, let rightPanelState { - UnifiedRightPanelView( - state: rightPanelState, - connection: currentSession.connection - ) - } else { - Color.clear - } + UnifiedRightPanelView( + state: rightPanelState, + connection: connection + ) } // MARK: - Session Bindings @@ -417,13 +292,16 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi ) -> Binding { Binding( get: { [weak self] in - guard let session = self?.currentSession else { return defaultValue } + guard let self, + let session = DatabaseManager.shared.activeSessions[self.connection.id] + else { return defaultValue } return get(session) }, set: { [weak self] newValue in - guard let sessionId = self?.payload?.connectionId ?? self?.currentSession?.id else { return } + guard let self else { return } + let connectionId = self.connection.id Task { - DatabaseManager.shared.updateSession(sessionId) { session in + DatabaseManager.shared.updateSession(connectionId) { session in set(&session, newValue) } } @@ -443,13 +321,6 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi createSessionBinding(get: { $0.tableOperationOptions }, set: { $0.tableOperationOptions = $1 }, defaultValue: [:]) } - private var windowTitleBinding: Binding { - Binding( - get: { [weak self] in self?.windowTitle ?? "" }, - set: { [weak self] in self?.windowTitle = $0 } - ) - } - // MARK: - InspectorVisibilityProxy var isInspectorVisible: Bool { @@ -460,12 +331,12 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi func showInspector() { materializeInspectorIfNeeded() inspectorSplitItem?.animator().isCollapsed = false - UserDefaults.standard.set(true, forKey: Self.inspectorPresentedKey) + UserDefaults.standard.set(true, forKey: inspectorPresentedKey) } func hideInspector() { inspectorSplitItem?.animator().isCollapsed = true - UserDefaults.standard.set(false, forKey: Self.inspectorPresentedKey) + UserDefaults.standard.set(false, forKey: inspectorPresentedKey) } @objc override func toggleInspector(_ sender: Any?) { @@ -479,8 +350,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi } func setSidebarTab(_ tab: SidebarTab) { - guard let connectionId = currentSession?.connection.id else { return } - let sidebarState = SharedSidebarState.forConnection(connectionId) + let sidebarState = SharedSidebarState.forConnection(connection.id) if sidebarSplitItem?.isCollapsed == true { sidebarState.selectedSidebarTab = tab @@ -494,5 +364,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi // MARK: - Constants - private static let inspectorPresentedKey = "com.TablePro.rightPanel.isPresented" + private var inspectorPresentedKey: String { + "com.TablePro.rightPanel.isPresented.\(connection.id.uuidString)" + } } diff --git a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift index 638720fdc..8222207c9 100644 --- a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift +++ b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift @@ -4,9 +4,6 @@ // import Foundation -import os - -private let sessionStateLogger = Logger(subsystem: "com.TablePro", category: "SessionStateFactory") @MainActor enum SessionStateFactory { @@ -15,45 +12,10 @@ enum SessionStateFactory { let changeManager: DataChangeManager let toolbarState: ConnectionToolbarState let coordinator: MainContentCoordinator + let rightPanelState: RightPanelState } - private static var pendingSessionStates: [UUID: SessionState] = [:] - private static var pendingExpirationTasks: [UUID: Task] = [:] - - private static let pendingEntryTTL: Duration = .seconds(5) - - static func registerPending(_ state: SessionState, for payloadId: UUID) { - pendingSessionStates[payloadId] = state - pendingExpirationTasks[payloadId]?.cancel() - pendingExpirationTasks[payloadId] = Task { [payloadId] in - try? await Task.sleep(for: pendingEntryTTL) - guard !Task.isCancelled else { return } - await MainActor.run { - pendingExpirationTasks.removeValue(forKey: payloadId) - guard let abandoned = pendingSessionStates.removeValue(forKey: payloadId) else { - return - } - MainContentCoordinator.activeCoordinators.removeValue( - forKey: abandoned.coordinator.instanceId - ) - } - } - } - - static func consumePending(for payloadId: UUID) -> SessionState? { - pendingExpirationTasks.removeValue(forKey: payloadId)?.cancel() - return pendingSessionStates.removeValue(forKey: payloadId) - } - - static func removePending(for payloadId: UUID) { - pendingExpirationTasks.removeValue(forKey: payloadId)?.cancel() - pendingSessionStates.removeValue(forKey: payloadId) - } - - static func create( - connection: DatabaseConnection, - payload: EditorTabPayload? - ) -> SessionState { + static func create(connection: DatabaseConnection) -> SessionState { let connectionId = connection.id let tabSessionRegistry = TabSessionRegistry() let tabMgr = QueryTabManager( @@ -82,87 +44,6 @@ enum SessionStateFactory { toolbarSt.currentDatabase = String(dbIndex) } - let activeDatabaseName = DatabaseManager.shared.activeDatabaseName(for: connection) - - if let payload { - switch payload.intent { - case .openContent: - switch payload.tabType { - case .table: - toolbarSt.isTableTab = true - if let tableName = payload.tableName { - do { - if payload.isPreview { - try tabMgr.addPreviewTableTab( - tableName: tableName, - databaseType: connection.type, - databaseName: payload.databaseName ?? activeDatabaseName - ) - } else { - try tabMgr.addTableTab( - tableName: tableName, - databaseType: connection.type, - databaseName: payload.databaseName ?? activeDatabaseName - ) - } - } catch { - sessionStateLogger.error("create tab for table failed: \(error.localizedDescription, privacy: .public)") - } - if let index = tabMgr.selectedTabIndex { - tabMgr.tabs[index].tableContext.isView = payload.isView - tabMgr.tabs[index].tableContext.isEditable = !payload.isView - tabMgr.tabs[index].tableContext.schemaName = payload.schemaName - if payload.showStructure { - tabMgr.tabs[index].display.resultsViewMode = .structure - } - if let initialFilter = payload.initialFilterState { - tabMgr.tabs[index].filterState = initialFilter - } - } - } else { - tabMgr.addTab(databaseName: payload.databaseName ?? activeDatabaseName) - } - case .query: - let hasContent = payload.initialQuery != nil - || payload.tabTitle != nil - || payload.sourceFileURL != nil - if hasContent { - tabMgr.addTab( - initialQuery: payload.initialQuery, - title: payload.tabTitle, - databaseName: payload.databaseName ?? activeDatabaseName, - sourceFileURL: payload.sourceFileURL - ) - } - case .createTable: - tabMgr.addCreateTableTab( - databaseName: payload.databaseName ?? activeDatabaseName - ) - case .erDiagram: - tabMgr.addERDiagramTab( - schemaKey: payload.erDiagramSchemaKey ?? payload.databaseName ?? activeDatabaseName, - databaseName: payload.databaseName ?? activeDatabaseName - ) - case .serverDashboard: - tabMgr.addServerDashboardTab() - case .terminal: - tabMgr.addTerminalTab( - databaseName: payload.databaseName ?? activeDatabaseName - ) - } - case .newEmptyTab: - let allTabs = MainContentCoordinator.allTabs(for: connection.id) - let title = QueryTabManager.nextQueryTitle(existingTabs: allTabs) - tabMgr.addTab( - initialQuery: payload.initialQuery, - title: title, - databaseName: payload.databaseName ?? activeDatabaseName - ) - case .restoreOrDefault: - break - } - } - let queryExecutor = QueryExecutor(connection: connection) let coord = MainContentCoordinator( @@ -174,18 +55,16 @@ enum SessionStateFactory { queryExecutor: queryExecutor ) - // Eagerly publish to the active-coordinator registry so concurrent - // window opens for the same connection both observe each other when - // computing globals like nextQueryTitle. Without this, two windows - // opened back-to-back can both compute "Query 1" before either has - // run onAppear. + // Eagerly publish to the active-coordinator registry so globals like + // nextQueryTitle observe this coordinator immediately. coord.registerEagerly() return SessionState( tabManager: tabMgr, changeManager: changeMgr, toolbarState: toolbarSt, - coordinator: coord + coordinator: coord, + rightPanelState: RightPanelState() ) } } diff --git a/TablePro/Core/Services/Infrastructure/TabRouter.swift b/TablePro/Core/Services/Infrastructure/TabRouter.swift index 751302e7f..f53d7ac87 100644 --- a/TablePro/Core/Services/Infrastructure/TabRouter.swift +++ b/TablePro/Core/Services/Infrastructure/TabRouter.swift @@ -81,8 +81,7 @@ internal final class TabRouter { return } try await runPreConnectScriptIfNeeded(connection) - let payload = EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault) - WindowManager.shared.openTab(payload: payload) + WindowManager.shared.openConnectionWindow(for: connection.id) NSApp.activate(ignoringOtherApps: true) try await DatabaseManager.shared.ensureConnected(connection) guard WindowManager.shared.hasOpenWindow(for: connection.id) else { @@ -131,7 +130,7 @@ internal final class TabRouter { schemaName: schema, isView: isView ) - WindowManager.shared.openTab(payload: payload) + WindowManager.shared.openConnectionWindow(for: connectionId, intent: payload) NSApp.activate(ignoringOtherApps: true) closeWelcomeWindows() } @@ -195,7 +194,7 @@ internal final class TabRouter { tabType: .query, initialQuery: sql ) - WindowManager.shared.openTab(payload: payload) + WindowManager.shared.openConnectionWindow(for: connectionId, intent: payload) NSApp.activate(ignoringOtherApps: true) closeWelcomeWindows() } @@ -275,8 +274,7 @@ internal final class TabRouter { } try await runPreConnectScriptIfNeeded(connection) - let payload = EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault) - WindowManager.shared.openTab(payload: payload) + WindowManager.shared.openConnectionWindow(for: connection.id) NSApp.activate(ignoringOtherApps: true) try await DatabaseManager.shared.ensureConnected(connection) closeWelcomeWindows() @@ -316,8 +314,7 @@ internal final class TabRouter { type: type ) - let payload = EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault) - WindowManager.shared.openTab(payload: payload) + WindowManager.shared.openConnectionWindow(for: connection.id) NSApp.activate(ignoringOtherApps: true) try await DatabaseManager.shared.ensureConnected(connection) closeWelcomeWindows() @@ -346,7 +343,7 @@ internal final class TabRouter { initialQuery: content, sourceFileURL: url ) - WindowManager.shared.openTab(payload: payload) + WindowManager.shared.openConnectionWindow(for: session.connection.id, intent: payload) NSApp.activate(ignoringOtherApps: true) } else { WelcomeRouter.shared.enqueueSQLFile(url) diff --git a/TablePro/Core/Services/Infrastructure/WindowLayoutMigration.swift b/TablePro/Core/Services/Infrastructure/WindowLayoutMigration.swift new file mode 100644 index 000000000..cd820ddab --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/WindowLayoutMigration.swift @@ -0,0 +1,86 @@ +// +// WindowLayoutMigration.swift +// TablePro +// +// One-time migration of window-layout UserDefaults from the old global keys +// (shared across every connection window) to the per-connection keys used by +// the one-window-per-connection model. +// + +import Foundation +import os + +@MainActor +internal enum WindowLayoutMigration { + private static let logger = Logger(subsystem: "com.TablePro", category: "WindowLayoutMigration") + static let migrationCompleteKey = "com.TablePro.windowLayoutMigrationComplete" + + private static let legacySplitAutosaveName = "com.TablePro.mainSplit" + private static let legacyInspectorPresentedKey = "com.TablePro.rightPanel.isPresented" + private static let legacyWindowFrameAutosaveName = "MainEditorWindow" + + static func splitFramesKey(_ autosaveName: String) -> String { + "NSSplitView Subview Frames \(autosaveName)" + } + + static func windowFrameKey(_ autosaveName: String) -> String { + "NSWindow Frame \(autosaveName)" + } + + static func perConnectionSplitFramesKey(_ connectionId: UUID) -> String { + splitFramesKey("com.TablePro.mainSplit.\(connectionId.uuidString)") + } + + static func perConnectionInspectorKey(_ connectionId: UUID) -> String { + "com.TablePro.rightPanel.isPresented.\(connectionId.uuidString)" + } + + static func perConnectionWindowFrameKey(_ connectionId: UUID) -> String { + windowFrameKey("MainEditorWindow.\(connectionId.uuidString)") + } + + /// Seed each saved connection's per-connection layout keys from the old + /// global values, once. Runs at launch before any window opens. + static func runIfNeeded() { + let connectionIds = ConnectionStorage.shared.loadConnections().map(\.id) + migrate(defaults: .standard, connectionIds: connectionIds) + } + + static func migrate(defaults: UserDefaults, connectionIds: [UUID]) { + guard !defaults.bool(forKey: migrationCompleteKey) else { return } + + let legacySplitFrames = defaults.array(forKey: splitFramesKey(legacySplitAutosaveName)) + let hasLegacyInspector = defaults.object(forKey: legacyInspectorPresentedKey) != nil + let legacyInspectorPresented = defaults.bool(forKey: legacyInspectorPresentedKey) + let legacyWindowFrame = defaults.string(forKey: windowFrameKey(legacyWindowFrameAutosaveName)) + + if legacySplitFrames == nil, !hasLegacyInspector, legacyWindowFrame == nil { + defaults.set(true, forKey: migrationCompleteKey) + logger.trace("No legacy window-layout defaults found, migration skipped") + return + } + + for connectionId in connectionIds { + let perConnectionSplitKey = perConnectionSplitFramesKey(connectionId) + if let legacySplitFrames, defaults.object(forKey: perConnectionSplitKey) == nil { + defaults.set(legacySplitFrames, forKey: perConnectionSplitKey) + } + + let inspectorKey = perConnectionInspectorKey(connectionId) + if hasLegacyInspector, defaults.object(forKey: inspectorKey) == nil { + defaults.set(legacyInspectorPresented, forKey: inspectorKey) + } + + let windowFrameKey = perConnectionWindowFrameKey(connectionId) + if let legacyWindowFrame, defaults.object(forKey: windowFrameKey) == nil { + defaults.set(legacyWindowFrame, forKey: windowFrameKey) + } + } + + defaults.removeObject(forKey: splitFramesKey(legacySplitAutosaveName)) + defaults.removeObject(forKey: legacyInspectorPresentedKey) + defaults.removeObject(forKey: windowFrameKey(legacyWindowFrameAutosaveName)) + defaults.set(true, forKey: migrationCompleteKey) + logger.trace("Window-layout migration complete for \(connectionIds.count) connections") + } +} diff --git a/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift b/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift index 6ebeb8af2..73e30ced0 100644 --- a/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift +++ b/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift @@ -21,7 +21,6 @@ internal final class WindowLifecycleMonitor { let connectionId: UUID weak var window: NSWindow? var observer: NSObjectProtocol? - var isPreview: Bool = false } private var entries: [UUID: Entry] = [:] @@ -41,9 +40,9 @@ 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) { Self.lifecycleLogger.info( - "[open] WindowLifecycleMonitor.register windowId=\(windowId, privacy: .public) connId=\(connectionId, privacy: .public) isPreview=\(isPreview) registeredBefore=\(self.entries.count)" + "[open] WindowLifecycleMonitor.register windowId=\(windowId, privacy: .public) connId=\(connectionId, privacy: .public) registeredBefore=\(self.entries.count)" ) // Remove any existing entry for this windowId to avoid duplicate observers if let existing = entries[windowId] { @@ -69,8 +68,7 @@ internal final class WindowLifecycleMonitor { entries[windowId] = Entry( connectionId: connectionId, window: window, - observer: observer, - isPreview: isPreview + observer: observer ) } @@ -94,14 +92,6 @@ internal final class WindowLifecycleMonitor { .compactMap(\.window) } - /// Check if other live windows exist for a connection, excluding a specific windowId. - internal func hasOtherWindows(for connectionId: UUID, excluding windowId: UUID) -> Bool { - purgeStaleEntries() - return entries.contains { key, value in - key != windowId && value.connectionId == connectionId - } - } - /// All connection IDs that currently have registered windows. internal func allConnectionIds() -> Set { purgeStaleEntries() @@ -148,28 +138,12 @@ internal final class WindowLifecycleMonitor { return entries[windowId] != nil } - /// Find the first preview window for a connection. - internal func previewWindow(for connectionId: UUID) -> (windowId: UUID, window: NSWindow)? { - purgeStaleEntries() - for (windowId, entry) in entries { - guard entry.connectionId == connectionId, entry.isPreview else { continue } - guard let window = entry.window else { continue } - return (windowId, window) - } - return nil - } - /// Look up the NSWindow for a given windowId. internal func window(for windowId: UUID) -> NSWindow? { purgeStaleEntries() return entries[windowId]?.window } - /// Update the preview flag for a registered window. - internal func setPreview(_ isPreview: Bool, for windowId: UUID) { - entries[windowId]?.isPreview = isPreview - } - // MARK: - Source File Tracking internal func registerSourceFile(_ url: URL, windowId: UUID) { diff --git a/TablePro/Core/Services/Infrastructure/WindowManager.swift b/TablePro/Core/Services/Infrastructure/WindowManager.swift index d720d7382..cc0cbe2c3 100644 --- a/TablePro/Core/Services/Infrastructure/WindowManager.swift +++ b/TablePro/Core/Services/Infrastructure/WindowManager.swift @@ -5,7 +5,6 @@ import AppKit import os -import SwiftUI @MainActor internal final class WindowManager { @@ -13,135 +12,106 @@ internal final class WindowManager { internal static let shared = WindowManager() - private var controllers: [ObjectIdentifier: TabWindowController] = [:] - private var closeObservers: [ObjectIdentifier: NSObjectProtocol] = [:] + private var controllers: [UUID: ConnectionWindowController] = [:] + private var closeObservers: [UUID: NSObjectProtocol] = [:] private init() {} // MARK: - Open - internal func openTab(payload: EditorTabPayload) { + /// Open the window for a connection, or focus it if already open. When a + /// payload is provided, its intent is routed to the connection's + /// coordinator (a new tab is added). Returns the controller id. + @discardableResult + internal func openConnectionWindow( + for connectionId: UUID, + intent payload: EditorTabPayload? = nil + ) -> UUID? { let t0 = Date() - Self.lifecycleLogger.info( - "[open] WindowManager.openTab start payloadId=\(payload.id, privacy: .public) connId=\(payload.connectionId, privacy: .public) intent=\(String(describing: payload.intent), privacy: .public) skipAutoExecute=\(payload.skipAutoExecute)" - ) - let resolvedConnection = DatabaseManager.shared.activeSessions[payload.connectionId]?.connection - let preCreatedSessionState: SessionStateFactory.SessionState? - if let resolvedConnection { - let state = SessionStateFactory.create(connection: resolvedConnection, payload: payload) - SessionStateFactory.registerPending(state, for: payload.id) - preCreatedSessionState = state - } else { - preCreatedSessionState = nil + if let existing = controllers[connectionId] { + existing.window?.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + if let payload { + existing.coordinator.handleNewTabIntent(payload) + } + Self.lifecycleLogger.info( + "[open] WindowManager focused existing window connId=\(connectionId, privacy: .public)" + ) + return existing.controllerId + } + + guard let connection = resolveConnection(connectionId) else { + Self.lifecycleLogger.error( + "[open] WindowManager.openConnectionWindow failed: no connection connId=\(connectionId, privacy: .public)" + ) + return nil } - let controller = TabWindowController(payload: payload, sessionState: preCreatedSessionState) + let sessionState = SessionStateFactory.create(connection: connection) + let controller = ConnectionWindowController(connection: connection, sessionState: sessionState) guard let window = controller.window else { Self.lifecycleLogger.error( - "[open] WindowManager.openTab failed: controller has no window payloadId=\(payload.id, privacy: .public)" + "[open] WindowManager.openConnectionWindow failed: controller has no window connId=\(connectionId, privacy: .public)" ) - SessionStateFactory.removePending(for: payload.id) - return + return nil } - retain(controller: controller, window: window) + retain(controller: controller, connectionId: connectionId) - // orderFront before addTabbedWindow avoids a synchronous full-tree - // SwiftUI layout pass that adds 700-900ms per open. - let tabbingId = window.tabbingIdentifier ?? "" - let groupAll = AppSettingsManager.shared.tabs.groupAllConnectionTabs - let sibling = findSibling( - tabbingIdentifier: tabbingId, groupAll: groupAll, excluding: window - ) + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) - if let sibling { - if groupAll { - let otherMains = NSApp.windows.filter { - $0 !== window && Self.isMainWindow($0) && $0.isVisible - } - for existing in otherMains { - existing.tabbingIdentifier = tabbingId - } - } - let target = sibling.tabbedWindows?.last ?? sibling - target.addTabbedWindow(window, ordered: .above) - window.makeKeyAndOrderFront(nil) - Self.lifecycleLogger.info( - "[open] WindowManager joined existing tab group payloadId=\(payload.id, privacy: .public) tabbingId=\(tabbingId, privacy: .public)" - ) - } else { - window.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) - Self.lifecycleLogger.info( - "[open] WindowManager standalone window payloadId=\(payload.id, privacy: .public) tabbingId=\(tabbingId, privacy: .public)" - ) + if let payload { + controller.coordinator.handleNewTabIntent(payload) } Self.lifecycleLogger.info( - "[open] WindowManager.openTab done payloadId=\(payload.id, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(t0) * 1_000))" + "[open] WindowManager.openConnectionWindow done connId=\(connectionId, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(t0) * 1_000))" ) + return controller.controllerId + } + + private func resolveConnection(_ connectionId: UUID) -> DatabaseConnection? { + DatabaseManager.shared.activeSessions[connectionId]?.connection + ?? ConnectionStorage.shared.loadConnections().first { $0.id == connectionId } } // MARK: - Retention - private func retain(controller: TabWindowController, window: NSWindow) { - let key = ObjectIdentifier(window) - controllers[key] = controller - closeObservers[key] = NotificationCenter.default.addObserver( + private func retain(controller: ConnectionWindowController, connectionId: UUID) { + controllers[connectionId] = controller + guard let window = controller.window else { return } + closeObservers[connectionId] = NotificationCenter.default.addObserver( forName: NSWindow.willCloseNotification, object: window, queue: .main ) { [weak self] _ in MainActor.assumeIsolated { - self?.release(windowKey: key) + self?.release(connectionId: connectionId) } } } - private func release(windowKey: ObjectIdentifier) { - if let observer = closeObservers.removeValue(forKey: windowKey) { + private func release(connectionId: UUID) { + if let observer = closeObservers.removeValue(forKey: connectionId) { NotificationCenter.default.removeObserver(observer) } - controllers.removeValue(forKey: windowKey) + controllers.removeValue(forKey: connectionId) } // MARK: - Helpers internal func hasOpenWindow(for connectionId: UUID) -> Bool { - controllers.values.contains { $0.payload.connectionId == connectionId } - } - - internal func closeWindow(for connectionId: UUID) { - let matching = controllers.values.filter { $0.payload.connectionId == connectionId } - for controller in matching { - guard let window = controller.window, window.isVisible else { continue } - window.close() - } + controllers[connectionId] != nil } - private static func isMainWindow(_ window: NSWindow) -> Bool { - guard let raw = window.identifier?.rawValue else { return false } - return raw == "main" || raw.hasPrefix("main-") + internal func window(for connectionId: UUID) -> NSWindow? { + controllers[connectionId]?.window } - internal static func tabbingIdentifier(for connectionId: UUID) -> String { - if AppSettingsManager.shared.tabs.groupAllConnectionTabs { - return "com.TablePro.main" - } - return "com.TablePro.main.\(connectionId.uuidString)" - } - - private func findSibling( - tabbingIdentifier: String, - groupAll: Bool, - excluding: NSWindow - ) -> NSWindow? { - NSApp.windows.first { candidate in - candidate !== excluding - && Self.isMainWindow(candidate) - && candidate.isVisible - && (groupAll || candidate.tabbingIdentifier == tabbingIdentifier) - } + internal func closeWindow(for connectionId: UUID) { + guard let window = controllers[connectionId]?.window, window.isVisible else { return } + window.close() } } diff --git a/TablePro/Extensions/NSWindow+FrameAutosave.swift b/TablePro/Extensions/NSWindow+FrameAutosave.swift index a71e6704b..df57e8de7 100644 --- a/TablePro/Extensions/NSWindow+FrameAutosave.swift +++ b/TablePro/Extensions/NSWindow+FrameAutosave.swift @@ -12,7 +12,7 @@ extension NSWindow { /// installed by `setFrameAutosaveName`, overwriting the persisted frame /// with the small intrinsic size. Use `setFrameUsingName` plus explicit /// `saveFrame(usingName:)` calls in `NSWindowDelegate` methods instead. - /// See `TabWindowController` for that pattern. + /// See `ConnectionWindowController` for that pattern. func applyAutosaveName(_ name: NSWindow.FrameAutosaveName) { setFrameAutosaveName(name) if !setFrameUsingName(name) { diff --git a/TablePro/Models/Query/EditorTabPayload.swift b/TablePro/Models/Query/EditorTabPayload.swift index 460d28bb0..c0d02f087 100644 --- a/TablePro/Models/Query/EditorTabPayload.swift +++ b/TablePro/Models/Query/EditorTabPayload.swift @@ -8,14 +8,12 @@ import Foundation -/// Declares the intent behind creating a new window tab. +/// Declares the intent behind creating a new in-window tab. internal enum TabIntent: String, Codable, Hashable { /// Open a specific tab with content (table, query with SQL, create-table, etc.) case openContent - /// Create a new empty query tab (Cmd+T, native "+" button, toolbar "+") + /// Create a new empty query tab (Cmd+T, the tab strip "+" button, toolbar "+") case newEmptyTab - /// First window for a connection — restore tabs from disk or create default - case restoreOrDefault } /// Payload passed to each native window tab to identify what content it should display. diff --git a/TablePro/Models/Query/QueryTabManager.swift b/TablePro/Models/Query/QueryTabManager.swift index 6ac3fef61..efca9a6d9 100644 --- a/TablePro/Models/Query/QueryTabManager.swift +++ b/TablePro/Models/Query/QueryTabManager.swift @@ -303,6 +303,34 @@ final class QueryTabManager { } } + func removeTab(id: UUID) { + guard let index = tabs.firstIndex(where: { $0.id == id }) else { return } + let wasSelected = selectedTabId == id + tabs.remove(at: index) + guard wasSelected else { return } + if tabs.isEmpty { + selectedTabId = nil + } else { + let nextIndex = min(index, tabs.count - 1) + selectedTabId = tabs[nextIndex].id + } + } + + func selectTab(id: UUID) { + guard tabs.contains(where: { $0.id == id }) else { return } + selectedTabId = id + } + + func moveTab(from source: IndexSet, to destination: Int) { + let moved = source.sorted(by: >).compactMap { index -> QueryTab? in + guard tabs.indices.contains(index) else { return nil } + return tabs.remove(at: index) + } + let insertionOffset = source.filter { $0 < destination }.count + let clampedDestination = max(0, min(destination - insertionOffset, tabs.count)) + tabs.insert(contentsOf: moved.reversed(), at: clampedDestination) + } + @discardableResult func mutate(tabId: UUID, _ block: (inout QueryTab) -> Void) -> Bool { guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { diff --git a/TablePro/Views/EditorTabs/ConnectionSplitContainerView.swift b/TablePro/Views/EditorTabs/ConnectionSplitContainerView.swift new file mode 100644 index 000000000..71a5c076c --- /dev/null +++ b/TablePro/Views/EditorTabs/ConnectionSplitContainerView.swift @@ -0,0 +1,49 @@ +// +// ConnectionSplitContainerView.swift +// TablePro +// +// Detail-pane content for a connection window: the in-window tab strip on +// top, the existing MainContentView (selected-tab content) filling the rest. +// + +import SwiftUI + +struct ConnectionSplitContainerView: View { + let connection: DatabaseConnection + var sidebarState: SharedSidebarState + @Binding var pendingTruncates: Set + @Binding var pendingDeletes: Set + @Binding var tableOperationOptions: [String: TableOperationOptions] + var rightPanelState: RightPanelState + @Bindable var tabManager: QueryTabManager + let changeManager: DataChangeManager + let toolbarState: ConnectionToolbarState + let coordinator: MainContentCoordinator + + var body: some View { + VStack(spacing: 0) { + EditorTabStripView( + tabManager: tabManager, + onNewTab: { coordinator.addNewQueryTab() }, + onCloseTab: { tabId in + coordinator.commandActions?.closeTab(id: tabId) + }, + onSelectTab: { tabManager.selectTab(id: $0) }, + onMoveTab: { tabManager.moveTab(from: $0, to: $1) } + ) + Divider() + MainContentView( + connection: connection, + sidebarState: sidebarState, + pendingTruncates: $pendingTruncates, + pendingDeletes: $pendingDeletes, + tableOperationOptions: $tableOperationOptions, + rightPanelState: rightPanelState, + tabManager: tabManager, + changeManager: changeManager, + toolbarState: toolbarState, + coordinator: coordinator + ) + } + } +} diff --git a/TablePro/Core/Services/Infrastructure/TabWindowRestoration.swift b/TablePro/Views/EditorTabs/ConnectionWindowRestoration.swift similarity index 86% rename from TablePro/Core/Services/Infrastructure/TabWindowRestoration.swift rename to TablePro/Views/EditorTabs/ConnectionWindowRestoration.swift index 68cf21fe1..d0b006e1a 100644 --- a/TablePro/Core/Services/Infrastructure/TabWindowRestoration.swift +++ b/TablePro/Views/EditorTabs/ConnectionWindowRestoration.swift @@ -1,5 +1,5 @@ // -// TabWindowRestoration.swift +// ConnectionWindowRestoration.swift // TablePro // @@ -7,7 +7,7 @@ import AppKit import os @MainActor -final class TabWindowRestoration: NSObject, NSWindowRestoration { +final class ConnectionWindowRestoration: NSObject, NSWindowRestoration { nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "WindowRestoration") nonisolated static let connectionIdKey = "TablePro.connectionId" @@ -33,14 +33,13 @@ final class TabWindowRestoration: NSObject, NSWindowRestoration { return } - let payload = EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault) - WindowManager.shared.openTab(payload: payload) + WindowManager.shared.openConnectionWindow(for: connection.id) let restored = NSApp.windows.first { candidate in guard candidate.isVisible, - let controller = candidate.windowController as? TabWindowController + let controller = candidate.windowController as? ConnectionWindowController else { return false } - return controller.payload.connectionId == connection.id + return controller.connection.id == connection.id } if let restored { @@ -59,7 +58,7 @@ final class TabWindowRestoration: NSObject, NSWindowRestoration { } } } else { - logger.error("[restore] WindowManager opened tab but no window found") + logger.error("[restore] WindowManager opened connection window but no window found") completionHandler(nil, restorationError(.windowNotCreated)) } } diff --git a/TablePro/Views/EditorTabs/EditorTabStripView.swift b/TablePro/Views/EditorTabs/EditorTabStripView.swift new file mode 100644 index 000000000..d593906e8 --- /dev/null +++ b/TablePro/Views/EditorTabs/EditorTabStripView.swift @@ -0,0 +1,148 @@ +// +// EditorTabStripView.swift +// TablePro +// + +import SwiftUI +import UniformTypeIdentifiers + +struct EditorTabStripView: View { + @Bindable var tabManager: QueryTabManager + let onNewTab: () -> Void + let onCloseTab: (UUID) -> Void + let onSelectTab: (UUID) -> Void + let onMoveTab: (IndexSet, Int) -> Void + + var body: some View { + HStack(spacing: 0) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 0) { + ForEach(tabManager.tabs) { tab in + EditorTabItemView( + tab: tab, + isSelected: tab.id == tabManager.selectedTabId, + onSelect: { onSelectTab(tab.id) }, + onClose: { onCloseTab(tab.id) } + ) + .onDrag { + NSItemProvider(object: tab.id.uuidString as NSString) + } + .onDrop( + of: [.text], + delegate: EditorTabDropDelegate( + targetTabId: tab.id, + tabManager: tabManager, + onMoveTab: onMoveTab + ) + ) + Divider().frame(height: 16) + } + } + } + + Button(action: onNewTab) { + Image(systemName: "plus") + .font(.system(size: 11, weight: .medium)) + .frame(width: 28, height: 28) + } + .buttonStyle(.plain) + .contentShape(Rectangle()) + .help(String(localized: "New Tab")) + } + .frame(height: 28) + .background(.bar) + } +} + +private struct EditorTabItemView: View { + let tab: QueryTab + let isSelected: Bool + let onSelect: () -> Void + let onClose: () -> Void + + @State private var isHovering = false + + var body: some View { + HStack(spacing: 6) { + Image(systemName: iconName) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + + Text(tab.title) + .font(.system(size: 12)) + .italic(tab.isPreview) + .lineLimit(1) + + closeOrDirtyIndicator + } + .padding(.horizontal, 10) + .frame(height: 28) + .frame(minWidth: 100, maxWidth: 220) + .background(isSelected ? Color(nsColor: .selectedContentBackgroundColor).opacity(0.25) : .clear) + .contentShape(Rectangle()) + .onTapGesture(perform: onSelect) + .onHover { isHovering = $0 } + } + + @ViewBuilder + private var closeOrDirtyIndicator: some View { + if isHovering { + Button(action: onClose) { + Image(systemName: "xmark") + .font(.system(size: 9, weight: .bold)) + .frame(width: 16, height: 16) + } + .buttonStyle(.plain) + .contentShape(Rectangle()) + .help(String(localized: "Close Tab")) + } else if tab.content.isFileDirty { + Circle() + .fill(.secondary) + .frame(width: 6, height: 6) + .frame(width: 16, height: 16) + } else { + Color.clear.frame(width: 16, height: 16) + } + } + + private var iconName: String { + switch tab.tabType { + case .query: + return "doc.text" + case .table: + return "tablecells" + case .createTable: + return "plus.rectangle.on.folder" + case .erDiagram: + return "point.3.connected.trianglepath.dotted" + case .serverDashboard: + return "gauge.with.dots.needle.bottom.50percent" + case .terminal: + return "terminal" + } + } +} + +private struct EditorTabDropDelegate: DropDelegate { + let targetTabId: UUID + let tabManager: QueryTabManager + let onMoveTab: (IndexSet, Int) -> Void + + func performDrop(info: DropInfo) -> Bool { + guard let item = info.itemProviders(for: [.text]).first else { return false } + item.loadObject(ofClass: NSString.self) { object, _ in + guard let uuidString = object as? String, + let draggedId = UUID(uuidString: uuidString) + else { return } + Task { @MainActor in + guard let sourceIndex = tabManager.tabs.firstIndex(where: { $0.id == draggedId }), + let targetIndex = tabManager.tabs.firstIndex(where: { $0.id == targetTabId }), + sourceIndex != targetIndex + else { return } + let destination = targetIndex > sourceIndex ? targetIndex + 1 : targetIndex + onMoveTab(IndexSet(integer: sourceIndex), destination) + } + } + return true + } +} diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 51ab077c2..4f61cc632 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -81,8 +81,6 @@ struct MainEditorContentView: 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 { @@ -309,7 +307,7 @@ struct MainEditorContentView: View { connectionAIPolicy: coordinator.connection.aiPolicy ?? AppSettingsManager.shared.ai.defaultConnectionPolicy, tabID: tab.id, onCloseTab: { - NSApp.keyWindow?.close() + coordinator.closeCurrentTab() }, onExecuteQuery: { coordinator.runQuery() }, onExplain: { variant in diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+ERDiagram.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+ERDiagram.swift index ad5ec090f..0631901db 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+ERDiagram.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+ERDiagram.swift @@ -3,38 +3,20 @@ import Foundation extension MainContentCoordinator { /// Open (or focus) an ER Diagram tab for the current database/schema. - /// - /// Resolution order: - /// 1. If another window for this connection already hosts an ER Diagram - /// tab with the same schema key, focus that window. - /// 2. If this window's tabManager is empty (fresh window with no restored - /// tabs yet), add the ER Diagram tab locally. - /// 3. Otherwise open a new native window tab so the current tab's content - /// (unsaved queries, filters, etc.) is preserved. + /// If a tab with the same schema key is already open, select it; otherwise + /// add a new ER Diagram tab. func showERDiagram() { let dbName = activeDatabaseName let schemaName = DatabaseManager.shared.session(for: connectionId)?.currentSchema let schemaKey = "\(dbName).\(schemaName ?? "default")" - if let existing = Self.coordinator(forConnection: connectionId, tabMatching: { + if let existing = tabManager.tabs.first(where: { $0.tabType == .erDiagram && $0.display.erDiagramSchemaKey == schemaKey }) { - existing.contentWindow?.makeKeyAndOrderFront(nil) + tabManager.selectTab(id: existing.id) return } - if tabManager.tabs.isEmpty { - tabManager.addERDiagramTab(schemaKey: schemaKey, databaseName: dbName) - return - } - - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .erDiagram, - databaseName: dbName, - schemaName: schemaName, - erDiagramSchemaKey: schemaKey - ) - WindowManager.shared.openTab(payload: payload) + tabManager.addERDiagramTab(schemaKey: schemaKey, databaseName: dbName) } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift index 169ead9a4..37abefac6 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift @@ -43,7 +43,8 @@ extension MainContentCoordinator { return } - // If current tab has unsaved changes, open in a new native tab instead of replacing + // If the current tab has unsaved changes, open a new in-window tab + // instead of replacing the active one. if changeManager.hasChanges { let fkFilterState = TabFilterState( filters: [filter], @@ -51,16 +52,24 @@ extension MainContentCoordinator { isVisible: true, filterLogicMode: .and ) - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .table, - tableName: referencedTable, - databaseName: currentDatabase, - schemaName: targetSchema, - isView: false, - initialFilterState: fkFilterState - ) - WindowManager.shared.openTab(payload: payload) + do { + try tabManager.addTableTab( + tableName: referencedTable, + databaseType: connection.type, + databaseName: currentDatabase + ) + } catch { + fkNavigationLogger.error( + "navigateToFKReference addTableTab failed: \(error.localizedDescription, privacy: .public)" + ) + return + } + if let index = tabManager.selectedTabIndex { + tabManager.mutate(at: index) { tab in + tab.tableContext.schemaName = targetSchema + tab.filterState = fkFilterState + } + } return } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift index 94f46ea40..09ad2e125 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift @@ -50,16 +50,6 @@ extension MainContentCoordinator { WindowLifecycleMonitor.shared.unregisterSourceFile(favorite.fileURL) } - if tabManager.tabs.isEmpty { - tabManager.addTab( - initialQuery: loaded.content, - title: favorite.name, - sourceFileURL: favorite.fileURL - ) - registerWindowForSourceFile(favorite.fileURL) - return - } - if let (tab, tabIndex) = tabManager.selectedTabAndIndex, tab.tabType == .query, tab.content.sourceFileURL == nil, @@ -76,15 +66,13 @@ extension MainContentCoordinator { return } - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .query, - databaseName: activeDatabaseName, + tabManager.addTab( initialQuery: loaded.content, - sourceFileURL: favorite.fileURL, - tabTitle: favorite.name + title: favorite.name, + databaseName: activeDatabaseName, + sourceFileURL: favorite.fileURL ) - WindowManager.shared.openTab(payload: payload) + registerWindowForSourceFile(favorite.fileURL) } private func registerWindowForSourceFile(_ url: URL) { @@ -102,11 +90,6 @@ extension MainContentCoordinator { } func runFavoriteInNewTab(_ favorite: SQLFavorite) { - if tabManager.tabs.isEmpty { - tabManager.addTab(initialQuery: favorite.query) - return - } - if let (tab, tabIndex) = tabManager.selectedTabAndIndex, tab.tabType == .query, tab.content.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { @@ -114,12 +97,6 @@ extension MainContentCoordinator { return } - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .query, - databaseName: activeDatabaseName, - initialQuery: favorite.query - ) - WindowManager.shared.openTab(payload: payload) + tabManager.addTab(initialQuery: favorite.query, databaseName: activeDatabaseName) } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 839376a80..bbfbc06ce 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -60,18 +60,16 @@ extension MainContentCoordinator { return } - // Check if another native window tab already has this table open — switch to it - for sibling in MainContentCoordinator.allActiveCoordinators() - where sibling !== self && sibling.connectionId == connectionId { - let hasMatch = sibling.tabManager.tabs.contains { tab in - tab.tabType == .table - && tab.tableContext.tableName == tableName - && tab.tableContext.databaseName == currentDatabase + if let existing = tabManager.tabs.first(where: { tab in + tab.tabType == .table + && !tab.isPreview + && tab.tableContext.tableName == tableName + && tab.tableContext.databaseName == currentDatabase + }) { + tabManager.selectTab(id: existing.id) + if showStructure, let index = tabManager.selectedTabIndex { + tabManager.mutate(at: index) { $0.display.resultsViewMode = .structure } } - guard hasMatch, - let windowId = sibling.windowId, - let window = WindowLifecycleMonitor.shared.window(for: windowId) else { continue } - window.makeKeyAndOrderFront(nil) return } @@ -85,10 +83,6 @@ 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" - } } else { try tabManager.addTableTab( tableName: tableName, @@ -153,41 +147,65 @@ extension MainContentCoordinator { return } - // If current tab has unsaved changes, active filters, or sorting, open in a new native tab let hasActiveWork = changeManager.hasChanges || selectedTabFilterState.hasAppliedFilters || (tabManager.selectedTab?.sortState.isSorting ?? false) if hasActiveWork { - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .table, - tableName: tableName, - databaseName: currentDatabase, - schemaName: currentSchema, - isView: isView, - showStructure: showStructure + addTableTabAndRun( + tableName, isView: isView, databaseName: currentDatabase, + schemaName: currentSchema, showStructure: showStructure ) - WindowManager.shared.openTab(payload: payload) return } - // Preview tab mode: reuse or create a preview tab instead of a new native window if AppSettingsManager.shared.tabs.enablePreviewTabs { openPreviewTab(tableName, isView: isView, databaseName: currentDatabase, schemaName: currentSchema, showStructure: showStructure) return } - // Default: open table in a new native tab - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .table, - tableName: tableName, - databaseName: currentDatabase, - schemaName: currentSchema, - isView: isView, - showStructure: showStructure + addTableTabAndRun( + tableName, isView: isView, databaseName: currentDatabase, + schemaName: currentSchema, showStructure: showStructure ) - WindowManager.shared.openTab(payload: payload) + } + + private func addTableTabAndRun( + _ tableName: String, isView: Bool, databaseName: String, + schemaName: String?, showStructure: Bool, isPreview: Bool = false + ) { + do { + if isPreview { + try tabManager.addPreviewTableTab( + tableName: tableName, + databaseType: connection.type, + databaseName: databaseName + ) + } else { + try tabManager.addTableTab( + tableName: tableName, + databaseType: connection.type, + databaseName: databaseName + ) + } + } catch { + navigationLogger.error("addTableTabAndRun failed: \(error.localizedDescription, privacy: .public)") + return + } + if let (tab, tabIndex) = tabManager.selectedTabAndIndex { + setActiveTableRows(TableRows(), for: tab.id) + tabManager.mutate(at: tabIndex) { mutTab in + mutTab.tableContext.isView = isView + mutTab.tableContext.isEditable = !isView + mutTab.tableContext.schemaName = schemaName + mutTab.display.resultsViewMode = showStructure ? .structure : .data + mutTab.pagination.reset() + } + toolbarState.isTableTab = true + } + clearFilterState() + restoreLastHiddenColumnsForTable(tableName) + restoreFiltersForTable(tableName) + runQuery() } // MARK: - Preview Tabs @@ -197,63 +215,30 @@ 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.tableContext.tableName == tableName, - current.tableContext.databaseName == databaseName { - preview.window.makeKeyAndOrderFront(nil) - return - } - if let oldTab = previewCoordinator.tabManager.selectedTab, - let oldTableName = oldTab.tableContext.tableName { - previewCoordinator.saveLastFilters(for: oldTableName) - } - do { - try previewCoordinator.tabManager.replaceTabContent( - tableName: tableName, - databaseType: connection.type, - isView: isView, - databaseName: databaseName, - schemaName: schemaName, - isPreview: true - ) - } catch { - navigationLogger.error("openPreviewTab replaceTabContent failed: \(error.localizedDescription, privacy: .public)") - return - } - previewCoordinator.clearFilterState() - if let tabIndex = previewCoordinator.tabManager.selectedTabIndex { - let tabId = previewCoordinator.tabManager.tabs[tabIndex].id - previewCoordinator.setActiveTableRows(TableRows(), for: tabId) - previewCoordinator.tabManager.mutate(at: tabIndex) { tab in - tab.display.resultsViewMode = showStructure ? .structure : .data - tab.pagination.reset() - } - previewCoordinator.toolbarState.isTableTab = true - } - preview.window.makeKeyAndOrderFront(nil) - previewCoordinator.restoreLastHiddenColumnsForTable(tableName) - previewCoordinator.restoreFiltersForTable(tableName) - previewCoordinator.runQuery() + if let previewIndex = tabManager.tabs.firstIndex(where: { $0.isPreview }) { + let previewTab = tabManager.tabs[previewIndex] + tabManager.selectTab(id: previewTab.id) + + if previewTab.tableContext.tableName == tableName, + previewTab.tableContext.databaseName == databaseName { return } + if let oldTableName = previewTab.tableContext.tableName { + saveLastFilters(for: oldTableName) + } + replacePreviewTabContent( + tableName, isView: isView, databaseName: databaseName, + schemaName: schemaName, showStructure: showStructure + ) + 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, - // 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 && !selectedTabFilterState.hasAppliedFilters && !tab.sortState.isSorting { return true } - // Empty/default query tab (no user content, no results, never executed) if tab.tabType == .query && tab.execution.lastExecutedAt == nil && tab.content.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return true @@ -261,97 +246,66 @@ extension MainContentCoordinator { return false }() if let selectedTab = tabManager.selectedTab, isReusableTab { - // Skip if already showing this table - if selectedTab.tableContext.tableName == tableName, selectedTab.tableContext.databaseName == databaseName { - return - } - // If preview tab has active work, promote it and open new tab instead - let hasUnsavedQuery = tabManager.selectedTab.map { tab in - tab.tabType == .query && !tab.content.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } ?? false - let previewHasWork = changeManager.hasChanges - || selectedTabFilterState.hasAppliedFilters - || selectedTab.sortState.isSorting - || hasUnsavedQuery - if previewHasWork { - promotePreviewTab() - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .table, - tableName: tableName, - databaseName: databaseName, - schemaName: schemaName, - isView: isView, - showStructure: showStructure - ) - WindowManager.shared.openTab(payload: payload) + if selectedTab.tableContext.tableName == tableName, + selectedTab.tableContext.databaseName == databaseName { return } if let oldTableName = selectedTab.tableContext.tableName { saveLastFilters(for: oldTableName) } - do { - try tabManager.replaceTabContent( - tableName: tableName, - databaseType: connection.type, - isView: isView, - databaseName: databaseName, - schemaName: schemaName, - isPreview: true - ) - } catch { - navigationLogger.error("openPreviewTab replaceTabContent failed: \(error.localizedDescription, privacy: .public)") - return - } - clearFilterState() - if let (tab, tabIndex) = tabManager.selectedTabAndIndex { - setActiveTableRows(TableRows(), for: tab.id) - tabManager.mutate(at: tabIndex) { - $0.display.resultsViewMode = showStructure ? .structure : .data - $0.pagination.reset() - } - toolbarState.isTableTab = true - } - restoreLastHiddenColumnsForTable(tableName) - restoreFiltersForTable(tableName) - runQuery() + replacePreviewTabContent( + tableName, isView: isView, databaseName: databaseName, + schemaName: schemaName, showStructure: showStructure + ) return } - // No preview tab anywhere: create a new native preview tab - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .table, - tableName: tableName, - databaseName: databaseName, - schemaName: schemaName, - isView: isView, - showStructure: showStructure, - isPreview: true + addTableTabAndRun( + tableName, isView: isView, databaseName: databaseName, + schemaName: schemaName, showStructure: showStructure, isPreview: true ) - WindowManager.shared.openTab(payload: payload) + } + + private func replacePreviewTabContent( + _ tableName: String, isView: Bool, databaseName: String, + schemaName: String?, showStructure: Bool + ) { + do { + try tabManager.replaceTabContent( + tableName: tableName, + databaseType: connection.type, + isView: isView, + databaseName: databaseName, + schemaName: schemaName, + isPreview: true + ) + } catch { + navigationLogger.error("replacePreviewTabContent failed: \(error.localizedDescription, privacy: .public)") + return + } + clearFilterState() + if let (tab, tabIndex) = tabManager.selectedTabAndIndex { + setActiveTableRows(TableRows(), for: tab.id) + tabManager.mutate(at: tabIndex) { + $0.display.resultsViewMode = showStructure ? .structure : .data + $0.pagination.reset() + } + toolbarState.isTableTab = true + } + restoreLastHiddenColumnsForTable(tableName) + restoreFiltersForTable(tableName) + runQuery() } func promotePreviewTab() { guard let (tab, tabIndex) = tabManager.selectedTabAndIndex, tab.isPreview else { return } tabManager.mutate(at: tabIndex) { $0.isPreview = false } - - if let wid = windowId { - WindowLifecycleMonitor.shared.setPreview(false, for: wid) - WindowLifecycleMonitor.shared.window(for: wid)?.subtitle = connection.name - } } func showAllTablesMetadata() { guard let sql = allTablesMetadataSQL() else { return } - - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .query, - initialQuery: sql - ) - WindowManager.shared.openTab(payload: payload) + tabManager.addTab(initialQuery: sql, databaseName: activeDatabaseName) } private func currentSchemaName(fallback: String) -> String { @@ -389,22 +343,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 { clearFilterState() @@ -414,7 +352,6 @@ extension MainContentCoordinator { do { try await DatabaseManager.shared.switchDatabase(to: database, for: connectionId) - closeSiblingNativeWindows() persistence.saveNowSync(tabs: tabManager.tabs, selectedTabId: tabManager.selectedTabId) tabSessionRegistry.removeAll() tabManager.tabs = [] @@ -457,7 +394,6 @@ extension MainContentCoordinator { do { try await DatabaseManager.shared.switchSchema(to: schema, for: connectionId) - closeSiblingNativeWindows() persistence.saveNowSync(tabs: tabManager.tabs, selectedTabId: tabManager.selectedTabId) tabSessionRegistry.removeAll() tabManager.tabs = [] diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Registry.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Registry.swift index 9a766df2c..4acc07acf 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Registry.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Registry.swift @@ -31,14 +31,4 @@ extension MainContentCoordinator { .filter { $0.connectionId == connectionId } .flatMap { $0.tabManager.tabs } } - - static func coordinator( - forConnection connectionId: UUID, - tabMatching predicate: (QueryTab) -> Bool - ) -> MainContentCoordinator? { - activeCoordinators.values.first { coordinator in - coordinator.connectionId == connectionId - && coordinator.tabManager.tabs.contains(where: predicate) - } - } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+ServerDashboard.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+ServerDashboard.swift index 0194d4517..8a0491ee4 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+ServerDashboard.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+ServerDashboard.swift @@ -2,32 +2,10 @@ import AppKit import Foundation extension MainContentCoordinator { - /// Open (or focus) the Server Dashboard tab for this connection. - /// - /// Singleton per connection. Resolution order: - /// 1. If any window for this connection already hosts a Server Dashboard - /// tab, focus that window. - /// 2. If this window's tabManager is empty, add the dashboard tab locally. - /// 3. Otherwise open a new native window tab so the current tab's content - /// is preserved. + /// Open (or focus) the Server Dashboard tab for this connection. Singleton + /// per connection: `addServerDashboardTab` selects the existing tab when + /// one is already open. func showServerDashboard() { - if let existing = Self.coordinator(forConnection: connectionId, tabMatching: { - $0.tabType == .serverDashboard - }) { - existing.contentWindow?.makeKeyAndOrderFront(nil) - return - } - - if tabManager.tabs.isEmpty { - tabManager.addServerDashboardTab() - return - } - - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .serverDashboard, - databaseName: activeDatabaseName - ) - WindowManager.shared.openTab(payload: payload) + tabManager.addServerDashboardTab() } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift index e375d907f..38bc33b1c 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift @@ -41,17 +41,7 @@ extension MainContentCoordinator { func createNewTable() { guard !safeModeLevel.blocksAllWrites else { return } - - if tabManager.tabs.isEmpty { - tabManager.addCreateTableTab(databaseName: activeDatabaseName) - } else { - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .createTable, - databaseName: activeDatabaseName - ) - WindowManager.shared.openTab(payload: payload) - } + tabManager.addCreateTableTab(databaseName: activeDatabaseName) } // MARK: - View Operations @@ -63,39 +53,22 @@ 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: activeDatabaseName, - initialQuery: template - ) - WindowManager.shared.openTab(payload: payload) + tabManager.addTab(initialQuery: template, databaseName: activeDatabaseName) } func editViewDefinition(_ viewName: String) { - Task { + Task { [weak self] in + guard let self else { return } 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 - ) - WindowManager.shared.openTab(payload: payload) + self.tabManager.addTab(initialQuery: definition, databaseName: self.activeDatabaseName) } 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 - ) - WindowManager.shared.openTab(payload: payload) + self.tabManager.addTab(initialQuery: fallbackSQL, databaseName: self.activeDatabaseName) } } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabIntent.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabIntent.swift new file mode 100644 index 000000000..5d4429f96 --- /dev/null +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabIntent.swift @@ -0,0 +1,152 @@ +// +// MainContentCoordinator+TabIntent.swift +// TablePro +// +// In-window tab lifecycle: adding tabs from intents, closing the current +// tab, and adding a new empty query tab. +// + +import AppKit +import Foundation +import os + +private let tabIntentLogger = Logger(subsystem: "com.TablePro", category: "MainContentCoordinator+TabIntent") + +extension MainContentCoordinator { + // MARK: - New Tab + + /// Add a new empty query tab and select it. + func addNewQueryTab(initialQuery: String? = nil) { + let title = QueryTabManager.nextQueryTitle(existingTabs: tabManager.tabs) + tabManager.addTab( + initialQuery: initialQuery, + title: title, + databaseName: activeDatabaseName + ) + } + + // MARK: - Close Tab + + /// Remove the currently selected tab. When it is the last tab, close the + /// connection window instead. + func closeCurrentTab() { + guard let selectedId = tabManager.selectedTabId else { + contentWindow?.close() + return + } + closeTab(id: selectedId) + } + + /// Remove the tab with the given id. When it is the last tab, close the + /// connection window instead. + func closeTab(id: UUID) { + guard let tab = tabManager.tabs.first(where: { $0.id == id }) else { + contentWindow?.close() + return + } + + if tabManager.tabs.count <= 1 { + contentWindow?.close() + return + } + + tabSessionRegistry.removeTableRows(for: tab.id) + if let url = tab.content.sourceFileURL { + WindowLifecycleMonitor.shared.unregisterSourceFile(url) + } + querySortCache.removeValue(forKey: tab.id) + displayFormatsCache.removeValue(forKey: tab.id) + tabManager.removeTab(id: id) + } + + // MARK: - New Tab Intent + + /// Route an `EditorTabPayload` into a new in-window tab on this + /// connection's tab manager. + func handleNewTabIntent(_ payload: EditorTabPayload) { + switch payload.intent { + case .openContent: + applyOpenContentIntent(payload) + case .newEmptyTab: + addNewQueryTab(initialQuery: payload.initialQuery) + } + + if let sourceFileURL = payload.sourceFileURL, let windowId { + WindowLifecycleMonitor.shared.registerSourceFile(sourceFileURL, windowId: windowId) + } + contentWindow?.makeKeyAndOrderFront(nil) + } + + private func applyOpenContentIntent(_ payload: EditorTabPayload) { + let databaseName = payload.databaseName ?? activeDatabaseName + + switch payload.tabType { + case .table: + guard let tableName = payload.tableName else { + tabManager.addTab(databaseName: databaseName) + return + } + do { + if payload.isPreview { + try tabManager.addPreviewTableTab( + tableName: tableName, + databaseType: connection.type, + databaseName: databaseName + ) + } else { + try tabManager.addTableTab( + tableName: tableName, + databaseType: connection.type, + databaseName: databaseName + ) + } + } catch { + tabIntentLogger.error( + "addTableTab failed: \(error.localizedDescription, privacy: .public)" + ) + return + } + if let index = tabManager.selectedTabIndex { + tabManager.mutate(at: index) { tab in + tab.tableContext.isView = payload.isView + tab.tableContext.isEditable = !payload.isView + tab.tableContext.schemaName = payload.schemaName + if payload.showStructure { + tab.display.resultsViewMode = .structure + } + if let initialFilter = payload.initialFilterState { + tab.filterState = initialFilter + } + } + } + toolbarState.isTableTab = true + + case .query: + let hasContent = payload.initialQuery != nil + || payload.tabTitle != nil + || payload.sourceFileURL != nil + guard hasContent else { return } + tabManager.addTab( + initialQuery: payload.initialQuery, + title: payload.tabTitle, + databaseName: databaseName, + sourceFileURL: payload.sourceFileURL + ) + + case .createTable: + tabManager.addCreateTableTab(databaseName: databaseName) + + case .erDiagram: + tabManager.addERDiagramTab( + schemaKey: payload.erDiagramSchemaKey ?? databaseName, + databaseName: databaseName + ) + + case .serverDashboard: + tabManager.addServerDashboardTab() + + case .terminal: + tabManager.addTerminalTab(databaseName: databaseName) + } + } +} diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift index cc9a957d8..47eefe02d 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift @@ -27,6 +27,10 @@ extension MainContentCoordinator { ) } + if oldTabId != nil { + commitOutgoingTabGridState() + } + let saveStart = Date() if let oldId = oldTabId, let oldIndex = tabManager.tabs.firstIndex(where: { $0.id == oldId }) @@ -152,4 +156,9 @@ extension MainContentCoordinator { "[switch] evictInactiveTabs evicted=\(toEvict.count) keptInactive=\(maxInactiveLoaded) elapsedMs=\(Int(Date().timeIntervalSince(start) * 1_000))" ) } + + private func commitOutgoingTabGridState() { + dataTabDelegate?.tableViewCoordinator?.commitActiveCellEdit() + dataTabDelegate?.tableViewCoordinator?.dismissFKPreviewOnColumnChange() + } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift index ab882efe1..2a6a789cf 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift @@ -2,11 +2,6 @@ // MainContentCoordinator+WindowLifecycle.swift // TablePro // -// Window-lifecycle handlers invoked by TabWindowController's NSWindowDelegate -// methods. windowDidBecomeKey is intentionally lightweight (focus state + -// sidebar sync only) per Apple's documentation; visibility-scoped lazy-load -// lives in MainEditorContentView's `.task(id:)` modifier. -// import AppKit import os @@ -16,10 +11,6 @@ import TableProPluginKit extension MainContentCoordinator { // MARK: - Window Delegate Dispatch - /// Called from `TabWindowController.windowDidBecomeKey(_:)`. - /// Updates focus state, refreshes file-based schema if stale, and syncs the - /// sidebar selection to the active tab. No query work runs here — lazy-load - /// is owned by `MainEditorContentView`'s `.task(id:)` modifier. func handleWindowDidBecomeKey() { let t0 = Date() Self.lifecycleLogger.debug( @@ -42,9 +33,6 @@ extension MainContentCoordinator { ) } - /// Called from `TabWindowController.windowDidResignKey(_:)`. - /// Schedules a 5s-delayed eviction of row data in inactive tabs; a fresh - /// `windowDidBecomeKey` cancels the eviction before it fires. func handleWindowDidResignKey() { Self.lifecycleLogger.debug( "[switch] coordinator.handleWindowDidResignKey connId=\(self.connectionId, privacy: .public)" @@ -62,10 +50,6 @@ extension MainContentCoordinator { } } - /// Called from `TabWindowController.windowWillClose(_:)`. - /// Synchronous teardown — no grace period, no delayed Task. Writes tab - /// state to disk, releases SwiftUI-scoped right-panel state, then - /// disconnects the session if this was the last window for the connection. func handleWindowWillClose() { let t0 = Date() Self.lifecycleLogger.info( diff --git a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index 12be58fa8..1f70fd690 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -110,7 +110,7 @@ extension MainContentView { } 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+Modifiers.swift b/TablePro/Views/Main/Extensions/MainContentView+Modifiers.swift index 89952b5c0..f597ac6c1 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Modifiers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Modifiers.swift @@ -47,13 +47,10 @@ struct FocusedCommandActionsModifier: ViewModifier { #Preview("With Connection") { let state = SessionStateFactory.create( - connection: DatabaseConnection.preview, - payload: nil + connection: DatabaseConnection.preview ) MainContentView( connection: DatabaseConnection.preview, - payload: nil, - windowTitle: .constant("SQL Query"), sidebarState: SharedSidebarState(), pendingTruncates: .constant([]), pendingDeletes: .constant([]), diff --git a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index 450c00f36..135a95f9e 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -28,145 +28,64 @@ extension MainContentView { ) }() - guard let payload else { - await handleRestoreOrDefault() - _ = await schemaLoad - return - } - - MainContentView.lifecycleLogger.info( - "[open] initializeAndRestoreTabs intent=\(String(describing: payload.intent), privacy: .public) windowId=\(windowId, privacy: .public) skipAutoExecute=\(payload.skipAutoExecute)" - ) - - switch payload.intent { - case .openContent: - if payload.skipAutoExecute { - _ = await schemaLoad - return - } - if let selectedTab = tabManager.selectedTab, - selectedTab.tabType == .table, - !selectedTab.content.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - { - if let session = DatabaseManager.shared.activeSessions[connection.id], - session.isConnected - { - if !selectedTab.tableContext.databaseName.isEmpty, - selectedTab.tableContext.databaseName != session.activeDatabase - { - await coordinator.switchDatabase(to: selectedTab.tableContext.databaseName) - } else { - if !selectedTab.filterState.appliedFilters.isEmpty, - let tableName = selectedTab.tableContext.tableName, - let tabIndex = tabManager.selectedTabIndex - { - let filteredQuery = coordinator.queryBuilder.buildFilteredQuery( - tableName: tableName, - filters: selectedTab.filterState.appliedFilters, - columns: [], - limit: selectedTab.pagination.pageSize, - offset: selectedTab.pagination.currentOffset - ) - tabManager.mutate(at: tabIndex) { $0.content.query = filteredQuery } - } - if let tableName = selectedTab.tableContext.tableName { - coordinator.restoreLastHiddenColumnsForTable(tableName) - } - coordinator.executeTableTabQueryDirectly() - } - } else { - coordinator.needsLazyLoad = true - } - } - if let sourceURL = payload.sourceFileURL { - WindowLifecycleMonitor.shared.registerSourceFile(sourceURL, windowId: windowId) - } - - case .newEmptyTab: - _ = await schemaLoad - return - - case .restoreOrDefault: - await handleRestoreOrDefault() - } - + await handleRestoreOrDefault() _ = await schemaLoad } private func handleRestoreOrDefault() async { - if WindowLifecycleMonitor.shared.hasOtherWindows(for: connection.id, excluding: windowId) { - MainContentView.lifecycleLogger.info( - "[open] handleRestoreOrDefault short-circuit (other windows exist) windowId=\(windowId, privacy: .public)" - ) - return - } - let restoreStart = Date() let result = await coordinator.persistence.restoreFromDisk() MainContentView.lifecycleLogger.info( "[open] restoreFromDisk done windowId=\(windowId, privacy: .public) tabsRestored=\(result.tabs.count) source=\(String(describing: result.source), privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(restoreStart) * 1_000))" ) guard !result.tabs.isEmpty else { return } - do { - var restoredTabs = result.tabs - for i in restoredTabs.indices where restoredTabs[i].tabType == .table { - if let tableName = restoredTabs[i].tableContext.tableName { - do { - restoredTabs[i].content.query = try QueryTab.buildBaseTableQuery( - tableName: tableName, - databaseType: connection.type, - schemaName: restoredTabs[i].tableContext.schemaName - ) - } catch { - MainContentView.lifecycleLogger.error( - "[open] buildBaseTableQuery failed for restored tab table=\(tableName, privacy: .public): \(error.localizedDescription, privacy: .public)" - ) - } + + var restoredTabs = result.tabs + for i in restoredTabs.indices where restoredTabs[i].tabType == .table { + if let tableName = restoredTabs[i].tableContext.tableName { + do { + restoredTabs[i].content.query = try QueryTab.buildBaseTableQuery( + tableName: tableName, + databaseType: connection.type, + schemaName: restoredTabs[i].tableContext.schemaName + ) + } catch { + MainContentView.lifecycleLogger.error( + "[open] buildBaseTableQuery failed for restored tab table=\(tableName, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) } } + } - 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()) + tabManager.tabs = restoredTabs + let selectedId = result.selectedTabId.flatMap { id in + restoredTabs.contains(where: { $0.id == id }) ? id : nil + } + tabManager.selectedTabId = selectedId ?? restoredTabs.first?.id - if !remainingTabs.isEmpty { - let selectedWasFirst = firstTab.id == selectedId - for tab in remainingTabs { - let restorePayload = EditorTabPayload( - from: tab, connectionId: connection.id, skipAutoExecute: true) - WindowManager.shared.openTab(payload: restorePayload) - } - if selectedWasFirst { - viewWindow?.makeKeyAndOrderFront(nil) - } + for tab in restoredTabs { + if let sourceURL = tab.content.sourceFileURL { + WindowLifecycleMonitor.shared.registerSourceFile(sourceURL, windowId: windowId) } + } - if firstTab.tabType == .table, - !firstTab.content.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - { - if let session = DatabaseManager.shared.activeSessions[connection.id], - session.isConnected - { - if !firstTab.tableContext.databaseName.isEmpty, - firstTab.tableContext.databaseName != session.activeDatabase - { - Task { await coordinator.switchDatabase(to: firstTab.tableContext.databaseName) } - } else { - if let tableName = firstTab.tableContext.tableName { - coordinator.restoreLastHiddenColumnsForTable(tableName) - } - coordinator.executeTableTabQueryDirectly() - } - } else { - coordinator.needsLazyLoad = true + guard let selectedTab = tabManager.selectedTab, + selectedTab.tabType == .table, + !selectedTab.content.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { return } + + if let session = DatabaseManager.shared.activeSessions[connection.id], session.isConnected { + if !selectedTab.tableContext.databaseName.isEmpty, + selectedTab.tableContext.databaseName != session.activeDatabase { + Task { await coordinator.switchDatabase(to: selectedTab.tableContext.databaseName) } + } else { + if let tableName = selectedTab.tableContext.tableName { + coordinator.restoreLastHiddenColumnsForTable(tableName) } + coordinator.executeTableTabQueryDirectly() } + } else { + coordinator.needsLazyLoad = true } } @@ -183,28 +102,8 @@ extension MainContentView { toolbarState.hasPendingChanges = hasDataChanges || hasFileChanges } - /// Update window title, proxy icon, and dirty dot based on the selected tab. func updateWindowTitleAndFileState() { - let selectedTab = tabManager.selectedTab - if selectedTab?.tabType == .serverDashboard { - windowTitle = String(localized: "Server Dashboard") - } else if selectedTab?.tabType == .createTable { - windowTitle = String(localized: "Create Table") - } else if selectedTab?.tabType == .erDiagram { - windowTitle = String(localized: "ER Diagram") - } else if selectedTab?.tabType == .terminal { - windowTitle = String(localized: "Terminal") - } else if let fileURL = selectedTab?.content.sourceFileURL { - windowTitle = selectedTab?.title ?? fileURL.deletingPathExtension().lastPathComponent - } else { - let langName = PluginManager.shared.queryLanguageName(for: connection.type) - let queryLabel = String(format: String(localized: "%@ Query"), langName) - windowTitle = (selectedTab?.tabType == .table ? selectedTab?.tableContext.tableName : nil) - ?? selectedTab?.title - ?? (tabManager.tabs.isEmpty ? connection.name : queryLabel) - } - viewWindow?.representedURL = selectedTab?.content.sourceFileURL - viewWindow?.isDocumentEdited = selectedTab?.content.isFileDirty ?? false + (viewWindow?.windowController as? ConnectionWindowController)?.refreshWindowTitle() } /// Configure the hosting NSWindow — called by WindowAccessor when the window is available. @@ -213,23 +112,13 @@ extension MainContentView { MainContentView.lifecycleLogger.info( "[open] configureWindow start windowId=\(windowId, privacy: .public) connId=\(connection.id, privacy: .public)" ) - let isPreview = tabManager.selectedTab?.isPreview ?? payload?.isPreview ?? false - if isPreview { - window.subtitle = String(format: String(localized: "%@ — Preview"), connection.name) - } else { - window.subtitle = connection.name - } - - let resolvedId = WindowManager.tabbingIdentifier(for: connection.id) - window.tabbingIdentifier = resolvedId - window.tabbingMode = .preferred + window.subtitle = connection.name coordinator.windowId = windowId WindowLifecycleMonitor.shared.register( window: window, connectionId: connection.id, - windowId: windowId, - isPreview: isPreview + windowId: windowId ) viewWindow = window coordinator.contentWindow = window @@ -264,7 +153,7 @@ extension MainContentView { splitVC.installToolbar(coordinator: coordinator) } MainContentView.lifecycleLogger.info( - "[open] configureWindow done windowId=\(windowId, privacy: .public) tabbingId=\(resolvedId, privacy: .public) isPreview=\(isPreview) elapsedMs=\(Int(Date().timeIntervalSince(start) * 1_000))" + "[open] configureWindow done windowId=\(windowId, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(start) * 1_000))" ) } diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index f2b2d3b07..f4e73ac71 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -336,19 +336,7 @@ final class MainContentCommandActions { // MARK: - Tab Operations (Group A — Called Directly) func newTab(initialQuery: String? = nil) { - if let coordinator, coordinator.tabManager.tabs.isEmpty { - coordinator.tabManager.addTab( - initialQuery: initialQuery, - databaseName: coordinator.activeDatabaseName - ) - return - } - let payload = EditorTabPayload( - connectionId: connection.id, - initialQuery: initialQuery, - intent: .newEmptyTab - ) - WindowManager.shared.openTab(payload: payload) + coordinator?.addNewQueryTab(initialQuery: initialQuery) } func closeTab() { @@ -376,30 +364,41 @@ final class MainContentCommandActions { } } - private func performClose() { - let t0 = Date() - guard let window = coordinator?.contentWindow ?? NSApp.keyWindow else { return } - let visibleTabbedWindows = (window.tabbedWindows ?? [window]).filter(\.isVisible) - Self.logger.info("[close] performClose visibleTabs=\(visibleTabbedWindows.count) tabManagerTabs=\(self.coordinator?.tabManager.tabs.count ?? 0)") - - if visibleTabbedWindows.count > 1 { - window.close() - } else if coordinator?.tabManager.tabs.isEmpty == true { - window.close() - } else { - if let coordinator { - for tab in coordinator.tabManager.tabs { - coordinator.tabSessionRegistry.removeTableRows(for: tab.id) - if let url = tab.content.sourceFileURL { - WindowLifecycleMonitor.shared.unregisterSourceFile(url) - } - } - coordinator.tabManager.tabs.removeAll() - coordinator.tabManager.selectedTabId = nil - coordinator.toolbarState.isTableTab = false + func closeTab(id: UUID) { + guard let coordinator else { return } + guard id != coordinator.tabManager.selectedTabId else { + closeTab() + return + } + + guard let tab = coordinator.tabManager.tabs.first(where: { $0.id == id }) else { return } + let tabHasUnsavedChanges = tab.pendingChanges.hasChanges || tab.content.isFileDirty + + guard tabHasUnsavedChanges else { + coordinator.closeTab(id: id) + return + } + + Task { + let result = await AlertHelper.confirmSaveChanges( + message: String(localized: "Your changes will be lost if you don't save them."), + window: NSApp.keyWindow + ) + switch result { + case .save: + coordinator.tabManager.selectTab(id: id) + await saveAndClose() + case .dontSave: + coordinator.tabManager.selectTab(id: id) + discardAndClose() + case .cancel: + break } } - Self.logger.info("[close] performClose done ms=\(Int(Date().timeIntervalSince(t0) * 1_000))") + } + + private func performClose() { + coordinator?.closeCurrentTab() } private func saveAndClose() async { @@ -560,25 +559,11 @@ final class MainContentCommandActions { // MARK: - Tab Navigation (Group A — Called Directly) - /// Selects the Nth native window tab. Wrapping the `selectedWindow` - /// assignment in `NSAnimationContext.runAnimationGroup` with `duration = 0` - /// suppresses AppKit's tab-transition animation, so rapid Cmd+Number - /// presses don't queue up CAAnimations that drain visibly after the user - /// releases the keys. - /// - /// Per-switch AppKit overhead (window-focus change, NSHostingView layout, - /// Window Server roundtrip) is platform-inherent to one-NSWindow-per-tab - /// and is intentionally not coalesced. See `docs/architecture/tab-subsystem-rewrite.md` D2. func selectTab(number: Int) { - guard let keyWindow = NSApp.keyWindow, - let tabGroup = keyWindow.tabGroup else { return } - let windows = tabGroup.windows - guard windows.indices.contains(number - 1) else { return } - let target = windows[number - 1] - NSAnimationContext.runAnimationGroup { context in - context.duration = 0 - tabGroup.selectedWindow = target - } + guard let coordinator else { return } + let tabs = coordinator.tabManager.tabs + guard tabs.indices.contains(number - 1) else { return } + coordinator.tabManager.selectTab(id: tabs[number - 1].id) } // MARK: - Filter Operations (Group A — Called Directly) diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 7ac30be84..2fd46e090 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -204,10 +204,10 @@ final class MainContentCoordinator { /// (e.g. save-then-close). Set before calling `saveChanges`, resumed by `executeCommitStatements`. @ObservationIgnored internal var saveCompletionContinuation: CheckedContinuation? - // MARK: - Window Lifecycle (driven by TabWindowController NSWindowDelegate) + // MARK: - Window Lifecycle (driven by ConnectionWindowController NSWindowDelegate) /// Whether this coordinator's window is the key (focused) window. - /// Updated by TabWindowController delegate methods; consumed by + /// Updated by ConnectionWindowController delegate methods; consumed by /// event handlers (e.g. sidebar table-selection navigation filter). @ObservationIgnored var isKeyWindow = false @@ -262,40 +262,23 @@ final class MainContentCoordinator { Self.activeCoordinators.removeValue(forKey: instanceId) } - /// Collect non-preview tabs for persistence. + /// Collect non-preview tabs for persistence. One coordinator owns all tabs + /// for a connection, so this is that coordinator's tab list. 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 } + activeCoordinators.values + .first { $0.connectionId == connectionId }? + .tabManager.tabs + .filter { !$0.isPreview } ?? [] } - /// Get selected tab ID from any coordinator for a given connectionId. + /// Get the selected tab ID for a connection. static func aggregatedSelectedTabId(for connectionId: UUID) -> UUID? { activeCoordinators.values - .first { $0.connectionId == connectionId && $0.tabManager.selectedTabId != nil }? + .first { $0.connectionId == connectionId }? .tabManager.selectedTabId } - /// Check if this coordinator is the first registered for its connection. + /// Check if this coordinator is the one registered for its connection. private func isFirstCoordinatorForConnection() -> Bool { Self.activeCoordinators.values .first { $0.connectionId == self.connectionId } === self @@ -541,21 +524,18 @@ final class MainContentCoordinator { ) return } - Task { [connectionId = connection.id, routine] in + Task { [weak self, routine] in do { let ddl = try await adapter.fetchRoutineDDL(routine: routine) let titleFormat: String = routine.kind == .procedure ? String(localized: "Procedure: %@") : String(localized: "Function: %@") - let payload = EditorTabPayload( - connectionId: connectionId, - tabType: .query, - initialQuery: ddl, - skipAutoExecute: true, - tabTitle: String(format: titleFormat, routine.name) - ) await MainActor.run { - WindowManager.shared.openTab(payload: payload) + self?.tabManager.addTab( + initialQuery: ddl, + title: String(format: titleFormat, routine.name), + databaseName: self?.activeDatabaseName ?? "" + ) } } catch { await MainActor.run { @@ -863,12 +843,7 @@ final class MainContentCoordinator { $0.hasUserInteraction = true } } else { - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .query, - initialQuery: query - ) - WindowManager.shared.openTab(payload: payload) + tabManager.addTab(initialQuery: query, databaseName: activeDatabaseName) } } @@ -884,15 +859,8 @@ final class MainContentCoordinator { } mutTab.hasUserInteraction = true } - } else if tabManager.tabs.isEmpty { - tabManager.addTab(initialQuery: query, databaseName: activeDatabaseName) } else { - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .query, - initialQuery: query - ) - WindowManager.shared.openTab(payload: payload) + tabManager.addTab(initialQuery: query, databaseName: activeDatabaseName) } } diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index d40933959..2a2f96eb8 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -25,11 +25,8 @@ struct MainContentView: View { // MARK: - Properties let connection: DatabaseConnection - /// Payload identifying what this window-tab should display (nil = default query tab) - let payload: EditorTabPayload? // Shared state from parent - @Binding var windowTitle: String @Bindable var schemaService = SchemaService.shared var sidebarState: SharedSidebarState @Binding var pendingTruncates: Set @@ -67,8 +64,6 @@ struct MainContentView: View { init( connection: DatabaseConnection, - payload: EditorTabPayload?, - windowTitle: Binding, sidebarState: SharedSidebarState, pendingTruncates: Binding>, pendingDeletes: Binding>, @@ -80,8 +75,6 @@ struct MainContentView: View { coordinator: MainContentCoordinator ) { self.connection = connection - self.payload = payload - self._windowTitle = windowTitle self.sidebarState = sidebarState self._pendingTruncates = pendingTruncates self._pendingDeletes = pendingDeletes @@ -274,11 +267,6 @@ struct MainContentView: View { "[open] MainContentView.onAppear start windowId=\(windowId, privacy: .public) connId=\(connection.id, privacy: .public) tabs=\(tabManager.tabs.count)" ) coordinator.markActivated() - - // Set window title for empty state (no tabs restored) - if tabManager.tabs.isEmpty { - windowTitle = connection.name - } setupCommandActions() updateToolbarPendingState() updateInspectorContext() @@ -301,8 +289,8 @@ struct MainContentView: View { private var bodyContentCore: some View { mainContentView - // Phase 3: SwiftUI `.toolbar { ... }` removed — NSToolbar is now - // installed directly on NSWindow by TabWindowController (see + // SwiftUI `.toolbar { ... }` removed. NSToolbar is now installed + // directly on NSWindow by ConnectionWindowController (see // `MainWindowToolbar`). Reuses every existing SwiftUI subview // (ConnectionStatusView, SafeModeBadgeView, popovers, etc.) via // `NSHostingView` inside `NSToolbarItem.view`. Connection color @@ -331,7 +319,7 @@ struct MainContentView: View { Self.lifecycleLogger.debug( "[switch] selectedTabId changed seq=\(seq) from=\(oldTabId?.uuidString ?? "nil", privacy: .public) to=\(newTabId?.uuidString ?? "nil", privacy: .public) windowId=\(windowId, privacy: .public)" ) - (viewWindow?.windowController as? TabWindowController)?.refreshUserActivity() + (viewWindow?.windowController as? ConnectionWindowController)?.refreshUserActivity() handleTabSelectionChange(from: oldTabId, to: newTabId) } .onChange(of: tabManager.tabStructureVersion) { _, _ in diff --git a/TableProTests/Core/Services/WindowLayoutMigrationTests.swift b/TableProTests/Core/Services/WindowLayoutMigrationTests.swift new file mode 100644 index 000000000..006148a42 --- /dev/null +++ b/TableProTests/Core/Services/WindowLayoutMigrationTests.swift @@ -0,0 +1,114 @@ +// +// WindowLayoutMigrationTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("WindowLayoutMigration") +@MainActor +struct WindowLayoutMigrationTests { + private func makeDefaults() -> UserDefaults { + let suiteName = "com.TablePro.tests.\(UUID().uuidString)" + // swiftlint:disable:next force_unwrapping + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + return defaults + } + + @Test("with no legacy keys, marks migration complete and writes nothing") + func noLegacyKeysMarksComplete() { + let defaults = makeDefaults() + let connectionId = UUID() + + WindowLayoutMigration.migrate(defaults: defaults, connectionIds: [connectionId]) + + #expect(defaults.bool(forKey: WindowLayoutMigration.migrationCompleteKey)) + #expect(defaults.object(forKey: WindowLayoutMigration.perConnectionSplitFramesKey(connectionId)) == nil) + #expect(defaults.object(forKey: WindowLayoutMigration.perConnectionInspectorKey(connectionId)) == nil) + #expect(defaults.object(forKey: WindowLayoutMigration.perConnectionWindowFrameKey(connectionId)) == nil) + } + + @Test("seeds the per-connection window frame from the legacy global key") + func seedsWindowFrame() { + let defaults = makeDefaults() + let connectionId = UUID() + let legacyFrame = "100 200 1200 800 0 0 1440 900 " + defaults.set(legacyFrame, forKey: WindowLayoutMigration.windowFrameKey("MainEditorWindow")) + + WindowLayoutMigration.migrate(defaults: defaults, connectionIds: [connectionId]) + + let seeded = defaults.string(forKey: WindowLayoutMigration.perConnectionWindowFrameKey(connectionId)) + #expect(seeded == legacyFrame) + #expect(defaults.object(forKey: WindowLayoutMigration.windowFrameKey("MainEditorWindow")) == nil) + } + + @Test("seeds the per-connection split frames from the legacy global key") + func seedsSplitFrames() { + let defaults = makeDefaults() + let connectionId = UUID() + let legacyFrames = ["frame-a", "frame-b"] + defaults.set(legacyFrames, forKey: WindowLayoutMigration.splitFramesKey("com.TablePro.mainSplit")) + + WindowLayoutMigration.migrate(defaults: defaults, connectionIds: [connectionId]) + + let seeded = defaults.array(forKey: WindowLayoutMigration.perConnectionSplitFramesKey(connectionId)) as? [String] + #expect(seeded == legacyFrames) + #expect(defaults.object(forKey: WindowLayoutMigration.splitFramesKey("com.TablePro.mainSplit")) == nil) + } + + @Test("seeds the per-connection inspector flag from the legacy global key") + func seedsInspectorFlag() { + let defaults = makeDefaults() + let connectionId = UUID() + defaults.set(true, forKey: "com.TablePro.rightPanel.isPresented") + + WindowLayoutMigration.migrate(defaults: defaults, connectionIds: [connectionId]) + + #expect(defaults.bool(forKey: WindowLayoutMigration.perConnectionInspectorKey(connectionId))) + #expect(defaults.object(forKey: "com.TablePro.rightPanel.isPresented") == nil) + } + + @Test("seeds every connection from the shared legacy value") + func seedsEveryConnection() { + let defaults = makeDefaults() + let first = UUID() + let second = UUID() + defaults.set(false, forKey: "com.TablePro.rightPanel.isPresented") + + WindowLayoutMigration.migrate(defaults: defaults, connectionIds: [first, second]) + + #expect(defaults.object(forKey: WindowLayoutMigration.perConnectionInspectorKey(first)) != nil) + #expect(defaults.object(forKey: WindowLayoutMigration.perConnectionInspectorKey(second)) != nil) + #expect(defaults.bool(forKey: WindowLayoutMigration.perConnectionInspectorKey(first)) == false) + } + + @Test("does not overwrite an existing per-connection key") + func doesNotOverwriteExisting() { + let defaults = makeDefaults() + let connectionId = UUID() + defaults.set(true, forKey: "com.TablePro.rightPanel.isPresented") + defaults.set(false, forKey: WindowLayoutMigration.perConnectionInspectorKey(connectionId)) + + WindowLayoutMigration.migrate(defaults: defaults, connectionIds: [connectionId]) + + #expect(defaults.bool(forKey: WindowLayoutMigration.perConnectionInspectorKey(connectionId)) == false) + } + + @Test("is idempotent: a second run does not re-seed") + func idempotentSecondRun() { + let defaults = makeDefaults() + let connectionId = UUID() + defaults.set(true, forKey: "com.TablePro.rightPanel.isPresented") + + WindowLayoutMigration.migrate(defaults: defaults, connectionIds: [connectionId]) + // A stale legacy value reappearing must not be re-applied once complete. + defaults.set(false, forKey: "com.TablePro.rightPanel.isPresented") + WindowLayoutMigration.migrate(defaults: defaults, connectionIds: [connectionId]) + + #expect(defaults.bool(forKey: WindowLayoutMigration.perConnectionInspectorKey(connectionId))) + #expect(defaults.object(forKey: "com.TablePro.rightPanel.isPresented") != nil) + } +} diff --git a/TableProTests/Core/Services/WindowLifecycleMonitorTests.swift b/TableProTests/Core/Services/WindowLifecycleMonitorTests.swift index 4c5012263..953d6e7e5 100644 --- a/TableProTests/Core/Services/WindowLifecycleMonitorTests.swift +++ b/TableProTests/Core/Services/WindowLifecycleMonitorTests.swift @@ -5,9 +5,9 @@ import AppKit import Foundation +@testable import TablePro import TableProPluginKit import Testing -@testable import TablePro @Suite("WindowLifecycleMonitor") @MainActor @@ -80,38 +80,6 @@ struct WindowLifecycleMonitorTests { monitor.unregisterWindow(for: UUID()) } - // MARK: - hasOtherWindows - - @Test("hasOtherWindows — returns true when other windows exist for same connection") - func hasOtherWindowsTrueWhenOthersExist() { - let windowId1 = UUID() - let windowId2 = UUID() - let connectionId = UUID() - - monitor.register(window: NSWindow(), connectionId: connectionId, windowId: windowId1) - monitor.register(window: NSWindow(), connectionId: connectionId, windowId: windowId2) - defer { cleanup(windowId1, windowId2) } - - #expect(monitor.hasOtherWindows(for: connectionId, excluding: windowId1)) - #expect(monitor.hasOtherWindows(for: connectionId, excluding: windowId2)) - } - - @Test("hasOtherWindows — returns false when only the excluded window exists") - func hasOtherWindowsFalseWhenOnlySelf() { - let windowId = UUID() - let connectionId = UUID() - - monitor.register(window: NSWindow(), connectionId: connectionId, windowId: windowId) - defer { cleanup(windowId) } - - #expect(!monitor.hasOtherWindows(for: connectionId, excluding: windowId)) - } - - @Test("hasOtherWindows — returns false when no windows exist") - func hasOtherWindowsFalseWhenEmpty() { - #expect(!monitor.hasOtherWindows(for: UUID(), excluding: UUID())) - } - // MARK: - Multiple connections @Test("Multiple connections — windows are independent") diff --git a/TableProTests/Core/Services/WindowTabGroupingTests.swift b/TableProTests/Core/Services/WindowTabGroupingTests.swift deleted file mode 100644 index 8736097b8..000000000 --- a/TableProTests/Core/Services/WindowTabGroupingTests.swift +++ /dev/null @@ -1,60 +0,0 @@ -// -// WindowTabGroupingTests.swift -// TableProTests -// -// Tests for `WindowManager.tabbingIdentifier(for:)` — the static helper that -// drives macOS native window tab grouping for main editor windows. -// -// The earlier `WindowOpener.pendingPayloads` / `acknowledgePayload` / -// `consumeOldestPendingConnectionId` queue was removed when -// `WindowManager.openTab` started performing tab-group merge synchronously -// at window-creation time. The corresponding tests have been removed. -// - -import Foundation -import TableProPluginKit -import Testing - -@testable import TablePro - -@Suite("WindowTabGrouping") -@MainActor -struct WindowTabGroupingTests { - init() { - // Tests assume per-connection grouping; reset in case a prior suite changed it. - AppSettingsManager.shared.tabs.groupAllConnectionTabs = false - } - - @Test("tabbingIdentifier produces a connection-specific identifier") - func tabbingIdentifierUsesConnectionId() { - let connectionId = UUID() - let expected = "com.TablePro.main.\(connectionId.uuidString)" - - let result = WindowManager.tabbingIdentifier(for: connectionId) - - #expect(result == expected) - } - - @Test("Two connections produce different tabbingIdentifiers") - func twoConnectionsProduceDifferentIdentifiers() { - let connectionA = UUID() - let connectionB = UUID() - - let idA = WindowManager.tabbingIdentifier(for: connectionA) - let idB = WindowManager.tabbingIdentifier(for: connectionB) - - #expect(idA != idB) - #expect(idA.contains(connectionA.uuidString)) - #expect(idB.contains(connectionB.uuidString)) - } - - @Test("Same connection produces same tabbingIdentifier") - func sameConnectionProducesSameIdentifier() { - let connectionId = UUID() - - let id1 = WindowManager.tabbingIdentifier(for: connectionId) - let id2 = WindowManager.tabbingIdentifier(for: connectionId) - - #expect(id1 == id2) - } -} diff --git a/TableProTests/Models/Query/QueryTabManagerTests.swift b/TableProTests/Models/Query/QueryTabManagerTests.swift index c7c789761..7e4dc742d 100644 --- a/TableProTests/Models/Query/QueryTabManagerTests.swift +++ b/TableProTests/Models/Query/QueryTabManagerTests.swift @@ -10,9 +10,9 @@ // import Foundation +@testable import TablePro import TableProPluginKit import Testing -@testable import TablePro @Suite("QueryTabManager.selectedTabAndIndex") @MainActor @@ -59,3 +59,155 @@ struct QueryTabManagerSelectedTabAndIndexTests { #expect(result?.tab.tableContext.tableName == "users") } } + +@Suite("QueryTabManager.removeTab") +@MainActor +struct QueryTabManagerRemoveTabTests { + @Test("removing an unknown id is a no-op") + func removeUnknownIdIsNoOp() { + let manager = QueryTabManager() + manager.addTab(title: "Query 1") + let id = manager.tabs[0].id + + manager.removeTab(id: UUID()) + + #expect(manager.tabs.count == 1) + #expect(manager.selectedTabId == id) + } + + @Test("removing a non-selected tab keeps the selection") + func removeNonSelectedKeepsSelection() { + let manager = QueryTabManager() + manager.addTab(title: "Query 1") + manager.addTab(title: "Query 2") + let firstId = manager.tabs[0].id + let secondId = manager.tabs[1].id + manager.selectedTabId = secondId + + manager.removeTab(id: firstId) + + #expect(manager.tabs.count == 1) + #expect(manager.selectedTabId == secondId) + } + + @Test("removing the selected tab selects the tab at the same index") + func removeSelectedSelectsSameIndex() { + let manager = QueryTabManager() + manager.addTab(title: "Query 1") + manager.addTab(title: "Query 2") + manager.addTab(title: "Query 3") + let secondId = manager.tabs[1].id + let thirdId = manager.tabs[2].id + manager.selectedTabId = secondId + + manager.removeTab(id: secondId) + + #expect(manager.tabs.count == 2) + #expect(manager.selectedTabId == thirdId) + } + + @Test("removing the selected last tab selects the new last tab") + func removeSelectedLastSelectsNewLast() { + let manager = QueryTabManager() + manager.addTab(title: "Query 1") + manager.addTab(title: "Query 2") + let firstId = manager.tabs[0].id + let secondId = manager.tabs[1].id + manager.selectedTabId = secondId + + manager.removeTab(id: secondId) + + #expect(manager.tabs.count == 1) + #expect(manager.selectedTabId == firstId) + } + + @Test("removing the only tab clears the selection") + func removeOnlyTabClearsSelection() { + let manager = QueryTabManager() + manager.addTab(title: "Query 1") + let id = manager.tabs[0].id + + manager.removeTab(id: id) + + #expect(manager.tabs.isEmpty) + #expect(manager.selectedTabId == nil) + } +} + +@Suite("QueryTabManager.selectTab") +@MainActor +struct QueryTabManagerSelectTabTests { + @Test("selecting a known id updates the selection") + func selectKnownIdUpdatesSelection() { + let manager = QueryTabManager() + manager.addTab(title: "Query 1") + manager.addTab(title: "Query 2") + let firstId = manager.tabs[0].id + + manager.selectTab(id: firstId) + + #expect(manager.selectedTabId == firstId) + } + + @Test("selecting an unknown id is a no-op") + func selectUnknownIdIsNoOp() { + let manager = QueryTabManager() + manager.addTab(title: "Query 1") + let id = manager.tabs[0].id + + manager.selectTab(id: UUID()) + + #expect(manager.selectedTabId == id) + } +} + +@Suite("QueryTabManager.moveTab") +@MainActor +struct QueryTabManagerMoveTabTests { + private func makeManager(titles: [String]) -> QueryTabManager { + let manager = QueryTabManager() + for title in titles { + manager.addTab(title: title) + } + return manager + } + + @Test("moving a tab forward reorders the list") + func moveForwardReorders() { + let manager = makeManager(titles: ["A", "B", "C"]) + + manager.moveTab(from: IndexSet(integer: 0), to: 3) + + #expect(manager.tabs.map(\.title) == ["B", "C", "A"]) + } + + @Test("moving a tab backward reorders the list") + func moveBackwardReorders() { + let manager = makeManager(titles: ["A", "B", "C"]) + + manager.moveTab(from: IndexSet(integer: 2), to: 0) + + #expect(manager.tabs.map(\.title) == ["C", "A", "B"]) + } + + @Test("moving a tab to its current position is a no-op") + func moveToSamePositionIsNoOp() { + let manager = makeManager(titles: ["A", "B", "C"]) + + manager.moveTab(from: IndexSet(integer: 1), to: 1) + + #expect(manager.tabs.map(\.title) == ["A", "B", "C"]) + } + + @Test("moving preserves the selected tab id") + func movePreservesSelection() { + let manager = makeManager(titles: ["A", "B", "C"]) + let bId = manager.tabs[1].id + manager.selectedTabId = bId + + manager.moveTab(from: IndexSet(integer: 0), to: 3) + + #expect(manager.selectedTabId == bId) + #expect(manager.tabs.map(\.title) == ["B", "C", "A"]) + } +} diff --git a/TableProTests/Models/SQLFileDeduplicationTests.swift b/TableProTests/Models/SQLFileDeduplicationTests.swift index 990031687..d925564c7 100644 --- a/TableProTests/Models/SQLFileDeduplicationTests.swift +++ b/TableProTests/Models/SQLFileDeduplicationTests.swift @@ -9,8 +9,8 @@ import AppKit import Foundation -import TableProPluginKit @testable import TablePro +import TableProPluginKit import Testing // MARK: - QueryTab sourceFileURL Property Tests @@ -131,11 +131,11 @@ struct EditorTabPayloadSourceFileURLTests { } } -// MARK: - SessionStateFactory sourceFileURL Propagation Tests +// MARK: - handleNewTabIntent sourceFileURL Propagation Tests -@Suite("SessionStateFactory sourceFileURL propagation") -struct SessionStateFactorySourceFileURLTests { - @Test("SessionStateFactory propagates sourceFileURL to tab") +@Suite("handleNewTabIntent sourceFileURL propagation") +struct HandleNewTabIntentSourceFileURLTests { + @Test("handleNewTabIntent propagates sourceFileURL to tab") @MainActor func propagatesSourceFileURL() { let conn = TestFixtures.makeConnection() @@ -147,7 +147,8 @@ struct SessionStateFactorySourceFileURLTests { sourceFileURL: url ) - let state = SessionStateFactory.create(connection: conn, payload: payload) + let state = SessionStateFactory.create(connection: conn) + state.coordinator.handleNewTabIntent(payload) #expect(state.tabManager.tabs.count == 1) #expect(state.tabManager.tabs.first?.content.sourceFileURL == url) diff --git a/TableProTests/Views/Main/CommandActionsDispatchTests.swift b/TableProTests/Views/Main/CommandActionsDispatchTests.swift index 29ebe2e13..e4a275114 100644 --- a/TableProTests/Views/Main/CommandActionsDispatchTests.swift +++ b/TableProTests/Views/Main/CommandActionsDispatchTests.swift @@ -7,10 +7,10 @@ // import Foundation -import TableProPluginKit import SwiftUI -import Testing @testable import TablePro +import TableProPluginKit +import Testing @MainActor @Suite("CommandActions Dispatch") struct CommandActionsDispatchTests { @@ -18,7 +18,7 @@ struct CommandActionsDispatchTests { private func makeSUT() -> (MainContentCommandActions, MainContentCoordinator) { let connection = TestFixtures.makeConnection() - let state = SessionStateFactory.create(connection: connection, payload: nil) + let state = SessionStateFactory.create(connection: connection) let coordinator = state.coordinator var selectedTables: Set = [] diff --git a/TableProTests/Views/Main/CoordinatorEditorLoadTests.swift b/TableProTests/Views/Main/CoordinatorEditorLoadTests.swift index 7b11cd134..1ebb62547 100644 --- a/TableProTests/Views/Main/CoordinatorEditorLoadTests.swift +++ b/TableProTests/Views/Main/CoordinatorEditorLoadTests.swift @@ -62,7 +62,7 @@ struct CoordinatorEditorLoadTests { #expect(tabManager.tabs[0].hasUserInteraction == true) } - @Test("loadQueryIntoEditor does not modify table tab") + @Test("loadQueryIntoEditor does not modify the selected table tab") @MainActor func loadQuerySkipsTableTab() throws { let (coordinator, tabManager) = makeCoordinator() @@ -71,14 +71,14 @@ struct CoordinatorEditorLoadTests { try tabManager.addTableTab(tableName: "users") let originalQuery = tabManager.tabs[0].content.query - // Falls through to WindowOpener path; table tab unchanged + // Selected tab is not a query tab, so a new query tab is added instead. coordinator.loadQueryIntoEditor("SELECT * FROM users") #expect(tabManager.tabs[0].tabType == .table) #expect(tabManager.tabs[0].content.query == originalQuery) } - @Test("loadQueryIntoEditor does nothing when no tabs exist") + @Test("loadQueryIntoEditor adds a query tab when no tabs exist") @MainActor func loadQueryNoTabs() { let (coordinator, tabManager) = makeCoordinator() @@ -86,10 +86,11 @@ struct CoordinatorEditorLoadTests { #expect(tabManager.tabs.isEmpty) - // Falls through to WindowOpener path; no crash coordinator.loadQueryIntoEditor("SELECT 1") - #expect(tabManager.tabs.isEmpty) + #expect(tabManager.tabs.count == 1) + #expect(tabManager.tabs[0].tabType == .query) + #expect(tabManager.tabs[0].content.query == "SELECT 1") } // MARK: - insertQueryFromAI @@ -164,7 +165,7 @@ struct CoordinatorEditorLoadTests { #expect(tabManager.tabs[0].content.query == originalQuery) } - @Test("insertQueryFromAI does nothing when no tabs exist") + @Test("insertQueryFromAI adds a query tab when no tabs exist") @MainActor func insertAiNoTabs() { let (coordinator, tabManager) = makeCoordinator() @@ -174,6 +175,8 @@ struct CoordinatorEditorLoadTests { coordinator.insertQueryFromAI("SELECT 1") - #expect(tabManager.tabs.isEmpty) + #expect(tabManager.tabs.count == 1) + #expect(tabManager.tabs[0].tabType == .query) + #expect(tabManager.tabs[0].content.query == "SELECT 1") } } diff --git a/TableProTests/Views/Main/SaveCompletionTests.swift b/TableProTests/Views/Main/SaveCompletionTests.swift index 9bc56b7f6..f964fabd8 100644 --- a/TableProTests/Views/Main/SaveCompletionTests.swift +++ b/TableProTests/Views/Main/SaveCompletionTests.swift @@ -8,8 +8,8 @@ // import Foundation -import TableProPluginKit @testable import TablePro +import TableProPluginKit import Testing @MainActor @Suite("Save Completion") @@ -22,7 +22,7 @@ struct SaveCompletionTests { ) -> (MainContentCoordinator, QueryTabManager, DataChangeManager) { var conn = TestFixtures.makeConnection(type: type) conn.safeModeLevel = safeModeLevel - let state = SessionStateFactory.create(connection: conn, payload: nil) + let state = SessionStateFactory.create(connection: conn) return (state.coordinator, state.tabManager, state.changeManager) } diff --git a/TableProTests/Views/Main/SessionStateFactoryTests.swift b/TableProTests/Views/Main/SessionStateFactoryTests.swift index 9acec901e..7ecdb7c86 100644 --- a/TableProTests/Views/Main/SessionStateFactoryTests.swift +++ b/TableProTests/Views/Main/SessionStateFactoryTests.swift @@ -2,13 +2,13 @@ // SessionStateFactoryTests.swift // TableProTests // -// Tests for SessionStateFactory, validating session state creation logic -// extracted from MainContentView.init. +// Tests for SessionStateFactory session-state creation and +// MainContentCoordinator.handleNewTabIntent payload routing. // import Foundation -import TableProPluginKit @testable import TablePro +import TableProPluginKit import Testing @Suite("SessionStateFactory") @@ -22,7 +22,8 @@ struct SessionStateFactoryTests { databaseName: String? = nil, initialQuery: String? = nil, isView: Bool = false, - showStructure: Bool = false + showStructure: Bool = false, + intent: TabIntent = .openContent ) -> EditorTabPayload { EditorTabPayload( connectionId: connectionId, @@ -31,59 +32,87 @@ struct SessionStateFactoryTests { databaseName: databaseName, initialQuery: initialQuery, isView: isView, - showStructure: showStructure + showStructure: showStructure, + intent: intent ) } - // MARK: - Tests + // MARK: - Factory - @Test("Payload with tableName creates a table tab") + @Test("create produces an empty tab manager") @MainActor - func payloadWithTableName_createsTableTab() { + func createProducesEmptyTabManager() { let conn = TestFixtures.makeConnection() - let payload = makePayload( - connectionId: conn.id, - tabType: .table, - tableName: "users" - ) - let state = SessionStateFactory.create(connection: conn, payload: payload) + let state = SessionStateFactory.create(connection: conn) + + #expect(state.tabManager.tabs.isEmpty) + } + + @Test("create wires the coordinator to the factory's tab manager") + @MainActor + func coordinatorReceivesCorrectDependencies() { + let conn = TestFixtures.makeConnection() + + let state = SessionStateFactory.create(connection: conn) + + #expect(state.coordinator.tabManager === state.tabManager) + } + + @Test("create is idempotent: two calls produce fresh instances") + @MainActor + func factoryIsIdempotent() { + let conn = TestFixtures.makeConnection() + + let state1 = SessionStateFactory.create(connection: conn) + let state2 = SessionStateFactory.create(connection: conn) + + #expect(state1.tabManager !== state2.tabManager) + #expect(state1.coordinator !== state2.coordinator) + } + + // MARK: - handleNewTabIntent + + @Test("Payload with tableName adds a table tab") + @MainActor + func payloadWithTableName_addsTableTab() { + let conn = TestFixtures.makeConnection() + let state = SessionStateFactory.create(connection: conn) + + state.coordinator.handleNewTabIntent( + makePayload(connectionId: conn.id, tabType: .table, tableName: "users") + ) #expect(state.tabManager.tabs.count == 1) #expect(state.tabManager.tabs.first?.tableContext.tableName == "users") #expect(state.tabManager.tabs.first?.tabType == .table) } - @Test("Payload with initialQuery creates a query tab with that text") + @Test("Payload with initialQuery adds a query tab with that text") @MainActor - func payloadWithQuery_createsQueryTab() { + func payloadWithQuery_addsQueryTab() { let conn = TestFixtures.makeConnection() + let state = SessionStateFactory.create(connection: conn) let query = "SELECT * FROM orders" - let payload = makePayload( - connectionId: conn.id, - tabType: .query, - initialQuery: query - ) - let state = SessionStateFactory.create(connection: conn, payload: payload) + state.coordinator.handleNewTabIntent( + makePayload(connectionId: conn.id, tabType: .query, initialQuery: query) + ) #expect(state.tabManager.tabs.count == 1) #expect(state.tabManager.tabs.first?.content.query == query) #expect(state.tabManager.tabs.first?.tabType == .query) } - @Test("Payload with showStructure sets showStructure on the tab") + @Test("Payload with showStructure sets structure view mode on the tab") @MainActor func payloadWithStructure_setsShowStructure() { let conn = TestFixtures.makeConnection() - let payload = makePayload( - connectionId: conn.id, - tabType: .table, - tableName: "users", - showStructure: true - ) + let state = SessionStateFactory.create(connection: conn) - let state = SessionStateFactory.create(connection: conn, payload: payload) + state.coordinator.handleNewTabIntent( + makePayload(connectionId: conn.id, tabType: .table, tableName: "users", showStructure: true) + ) guard let tab = state.tabManager.tabs.first else { Issue.record("Expected at least one tab") @@ -96,14 +125,11 @@ struct SessionStateFactoryTests { @MainActor func payloadWithView_setsIsViewAndNotEditable() { let conn = TestFixtures.makeConnection() - let payload = makePayload( - connectionId: conn.id, - tabType: .table, - tableName: "user_view", - isView: true - ) + let state = SessionStateFactory.create(connection: conn) - let state = SessionStateFactory.create(connection: conn, payload: payload) + state.coordinator.handleNewTabIntent( + makePayload(connectionId: conn.id, tabType: .table, tableName: "user_view", isView: true) + ) guard let tab = state.tabManager.tabs.first else { Issue.record("Expected at least one tab") @@ -113,73 +139,30 @@ struct SessionStateFactoryTests { #expect(tab.tableContext.isEditable == false) } - @Test("Nil payload creates empty tab manager") - @MainActor - func nilPayload_createsEmptyTabManager() { - let conn = TestFixtures.makeConnection() - - let state = SessionStateFactory.create(connection: conn, payload: nil) - - #expect(state.tabManager.tabs.isEmpty) - } - - @Test("Connection-only payload without isNewTab creates empty tab manager") + @Test("openContent query payload with no content is a no-op") @MainActor - func connectionOnlyPayload_createsEmptyTabManager() { + func openContentQueryWithoutContent_isNoOp() { let conn = TestFixtures.makeConnection() - let payload = makePayload(connectionId: conn.id, tabType: .query) + let state = SessionStateFactory.create(connection: conn) - let state = SessionStateFactory.create(connection: conn, payload: payload) + state.coordinator.handleNewTabIntent( + makePayload(connectionId: conn.id, tabType: .query) + ) #expect(state.tabManager.tabs.isEmpty) } - @Test("Connection-only payload with isNewTab creates a default query tab") + @Test("newEmptyTab intent adds a default query tab") @MainActor - func connectionOnlyPayload_isNewTab_createsDefaultTab() { + func newEmptyTabIntent_addsDefaultQueryTab() { let conn = TestFixtures.makeConnection() - let payload = EditorTabPayload(connectionId: conn.id, tabType: .query, intent: .newEmptyTab) + let state = SessionStateFactory.create(connection: conn) - let state = SessionStateFactory.create(connection: conn, payload: payload) + state.coordinator.handleNewTabIntent( + EditorTabPayload(connectionId: conn.id, tabType: .query, intent: .newEmptyTab) + ) #expect(state.tabManager.tabs.count == 1) #expect(state.tabManager.tabs.first?.tabType == .query) } - - @Test("Factory is idempotent: two calls produce fresh but equivalent instances") - @MainActor - func factoryIsIdempotent() { - let conn = TestFixtures.makeConnection() - let payload = makePayload( - connectionId: conn.id, - tabType: .table, - tableName: "products" - ) - - let state1 = SessionStateFactory.create(connection: conn, payload: payload) - let state2 = SessionStateFactory.create(connection: conn, payload: payload) - - // Different instances - #expect(state1.tabManager !== state2.tabManager) - #expect(state1.coordinator !== state2.coordinator) - - // Equivalent content - #expect(state1.tabManager.tabs.count == state2.tabManager.tabs.count) - #expect(state1.tabManager.tabs.first?.tableContext.tableName == state2.tabManager.tabs.first?.tableContext.tableName) - } - - @Test("Coordinator receives the factory's tabManager") - @MainActor - func coordinatorReceivesCorrectDependencies() { - let conn = TestFixtures.makeConnection() - let payload = makePayload( - connectionId: conn.id, - tabType: .table, - tableName: "items" - ) - - let state = SessionStateFactory.create(connection: conn, payload: payload) - - #expect(state.coordinator.tabManager === state.tabManager) - } } diff --git a/TableProTests/Views/Main/StructureActionHandlerTests.swift b/TableProTests/Views/Main/StructureActionHandlerTests.swift index 9a6e0870b..6e1efe3da 100644 --- a/TableProTests/Views/Main/StructureActionHandlerTests.swift +++ b/TableProTests/Views/Main/StructureActionHandlerTests.swift @@ -6,9 +6,9 @@ // import Foundation +@testable import TablePro import TableProPluginKit import Testing -@testable import TablePro @MainActor @Suite("StructureViewActionHandler") struct StructureActionHandlerTests { @@ -16,7 +16,7 @@ struct StructureActionHandlerTests { private func makeCoordinator() -> MainContentCoordinator { let connection = TestFixtures.makeConnection() - let state = SessionStateFactory.create(connection: connection, payload: nil) + let state = SessionStateFactory.create(connection: connection) return state.coordinator } diff --git a/TableProTests/Views/Main/TabIntentTests.swift b/TableProTests/Views/Main/TabIntentTests.swift new file mode 100644 index 000000000..ddacee285 --- /dev/null +++ b/TableProTests/Views/Main/TabIntentTests.swift @@ -0,0 +1,100 @@ +// +// TabIntentTests.swift +// TableProTests +// +// Covers MainContentCoordinator+TabIntent: addNewQueryTab and closeCurrentTab. +// handleNewTabIntent payload routing is covered in SessionStateFactoryTests. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("MainContentCoordinator addNewQueryTab") +@MainActor +struct AddNewQueryTabTests { + @Test("adds a query tab and selects it") + func addsAndSelects() { + let conn = TestFixtures.makeConnection() + let state = SessionStateFactory.create(connection: conn) + defer { state.coordinator.teardown() } + + state.coordinator.addNewQueryTab() + + #expect(state.tabManager.tabs.count == 1) + #expect(state.tabManager.tabs.first?.tabType == .query) + #expect(state.tabManager.selectedTabId == state.tabManager.tabs.first?.id) + } + + @Test("carries the initial query into the new tab") + func carriesInitialQuery() { + let conn = TestFixtures.makeConnection() + let state = SessionStateFactory.create(connection: conn) + defer { state.coordinator.teardown() } + + state.coordinator.addNewQueryTab(initialQuery: "SELECT 1") + + #expect(state.tabManager.tabs.first?.content.query == "SELECT 1") + } + + @Test("adding multiple tabs produces distinct Query N titles") + func multipleTabsDistinctTitles() { + let conn = TestFixtures.makeConnection() + let state = SessionStateFactory.create(connection: conn) + defer { state.coordinator.teardown() } + + state.coordinator.addNewQueryTab() + state.coordinator.addNewQueryTab() + + #expect(state.tabManager.tabs.count == 2) + let titles = Set(state.tabManager.tabs.map(\.title)) + #expect(titles.count == 2) + } +} + +@Suite("MainContentCoordinator closeCurrentTab") +@MainActor +struct CloseCurrentTabTests { + @Test("removes the selected tab when more than one is open") + func removesSelectedTab() { + let conn = TestFixtures.makeConnection() + let state = SessionStateFactory.create(connection: conn) + defer { state.coordinator.teardown() } + state.coordinator.addNewQueryTab() + state.coordinator.addNewQueryTab() + let firstId = state.tabManager.tabs[0].id + let secondId = state.tabManager.tabs[1].id + state.tabManager.selectedTabId = secondId + + state.coordinator.closeCurrentTab() + + #expect(state.tabManager.tabs.count == 1) + #expect(state.tabManager.tabs.first?.id == firstId) + #expect(state.tabManager.selectedTabId == firstId) + } + + @Test("closing the only tab leaves the tab in place (window-close path)") + func closingOnlyTabKeepsTab() { + let conn = TestFixtures.makeConnection() + let state = SessionStateFactory.create(connection: conn) + defer { state.coordinator.teardown() } + state.coordinator.addNewQueryTab() + + // contentWindow is nil in tests, so the last-tab branch is a safe no-op. + state.coordinator.closeCurrentTab() + + #expect(state.tabManager.tabs.count == 1) + } + + @Test("closing with no selection is a safe no-op") + func closingWithNoSelectionIsNoOp() { + let conn = TestFixtures.makeConnection() + let state = SessionStateFactory.create(connection: conn) + defer { state.coordinator.teardown() } + + state.coordinator.closeCurrentTab() + + #expect(state.tabManager.tabs.isEmpty) + } +} diff --git a/docs/features/keyboard-shortcuts.mdx b/docs/features/keyboard-shortcuts.mdx index 165207435..b2e32370c 100644 --- a/docs/features/keyboard-shortcuts.mdx +++ b/docs/features/keyboard-shortcuts.mdx @@ -19,7 +19,7 @@ TablePro is keyboard-driven. Most actions have shortcuts, and most menu shortcut | Settings | `Cmd+,` | | Quick Switcher | `Cmd+Shift+O` | | Switch database | `Cmd+K` | -| Close window | `Cmd+W` | +| Close tab (or window when last tab) | `Cmd+W` | | Quit | `Cmd+Q` | ## SQL Editor diff --git a/docs/features/tabs.mdx b/docs/features/tabs.mdx index dcab85bff..81ab07535 100644 --- a/docs/features/tabs.mdx +++ b/docs/features/tabs.mdx @@ -5,9 +5,9 @@ description: Each tab keeps its own SQL, results, pagination, sorting, and filte # Query Tabs -Every tab is a native macOS window tab. Each tab is a separate NSWindow in a tab group, managed by the system window tabbing API. This means you get standard macOS tab behavior: drag tabs between windows, merge windows via **Window > Merge All Windows**, and use **Window > Move Tab to New Window** to split them out. +Each connection opens one window with an in-window tab bar. All tabs for a connection live in that single window and share its sidebar and inspector. Open as many tabs as you need. -Each tab is an independent workspace with its own SQL content, results, pagination, sorting, and filter state. Open as many as you need. They persist across app restarts. +Each tab is an independent workspace with its own SQL content, results, pagination, sorting, and filter state. They persist across app restarts. {/* Screenshot: Tab bar showing multiple open tabs */} @@ -40,7 +40,7 @@ Enable **Reuse clean table tab** in **Settings** > **Tabs** for TablePlus-style ### Preview Tabs -Single-clicking a table opens a temporary preview tab that gets replaced when you click a different table (like VS Code's preview tabs). Preview tabs show "Preview" in the window subtitle. +Single-clicking a table opens a temporary preview tab that gets replaced when you click a different table (like VS Code's preview tabs). Preview tabs show their title in italic in the tab bar. A preview tab becomes permanent when you: @@ -159,15 +159,11 @@ Each tab maintains its own pending changes. Switching tabs saves and restores ea | Tab type and table name | Selected rows, sort/filter state | | Pin state | Preview tabs (discarded) | -Tab state auto-saves 500ms after any change. On reconnect, TablePro restores your tabs and re-executes table queries. +Tab state auto-saves 500ms after any change. On reconnect, TablePro restores every tab for the connection into the tab bar and re-executes table queries. -### Multi-Window Restoration +### Window Size and Position -If you had several editor windows open with their own tab groups, TablePro restores every window on next launch. Earlier versions kept tabs from one window and dropped the rest. - -### Window Size, Position, and Zoom - -Each editor window remembers its frame and zoom across launches. Reopen a connection and the window comes back at the same size, position, and zoom state. The first window opens at 1200x800 centered on the active screen. +Each connection window remembers its frame across launches. Reopen a connection and the window comes back at the same size and position. The first window opens at 1200x800 centered on the active screen. ## Pagination