Skip to content
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- New tab via Cmd+T no longer flashes focus back to the previous tab in the same window group
- Cmd+X with no selection cuts the current line, matching VS Code, Sublime, and Xcode (#1075)
- Cmd+A on a query ending with a newline now highlights every line, not just the first (#1075)
- Editor windows now remember their size, position, and zoom state across launches, instead of always opening at 1200x800

### Added

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,15 +167,6 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi
super.viewWillAppear()
guard let window = view.window else { return }

let defaultSize = NSSize(width: 1_200, height: 800)
if window.frame.width < defaultSize.width || window.frame.height < defaultSize.height {
window.setContentSize(NSSize(
width: max(window.frame.width, defaultSize.width),
height: max(window.frame.height, defaultSize.height)
))
window.center()
}

window.title = windowTitle
if let session = currentSession {
window.subtitle = session.connection.name
Expand Down
94 changes: 34 additions & 60 deletions TablePro/Core/Services/Infrastructure/TabWindowController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,11 @@
// TabWindowController.swift
// TablePro
//
// NSWindowController for an editor-tab-window. Replaces the SwiftUI
// `WindowGroup(id: "main", for: EditorTabPayload.self)` scene.
//
// Phase 1 scope: window creation, NSHostingView installation, tabbing
// configuration. Existing MainContentView lifecycle hooks (.onAppear,
// .onDisappear, NSWindow notification observers, .userActivity) continue to
// work unchanged — this controller's job in Phase 1 is limited to replacing
// SwiftUI scene-driven window construction.
//
// Phase 2 will migrate lifecycle responsibilities (markActivated, teardown,
// userActivity, didBecomeKey/didResignKey) into NSWindowDelegate methods
// on this controller.
//

import AppKit
import os
import SwiftUI

/// NSWindow subclass that routes the standard tab-related responder selectors
/// (`performClose:`, `newWindowForTab:`) through the coordinator. Menus and
/// toolbar buttons reach this via `NSApp.sendAction(_:to:nil:from:)`, the same
/// pattern AppKit uses for `selectNextTab:` and `selectPreviousTab:`.
@MainActor
private final class EditorWindow: NSWindow {
override func performClose(_ sender: Any?) {
Expand All @@ -49,6 +32,8 @@ private final class EditorWindow: NSWindow {
internal final class TabWindowController: NSWindowController, NSWindowDelegate {
private static let lifecycleLogger = Logger(subsystem: "com.TablePro", category: "NativeTabLifecycle")

internal static let frameAutosaveName: NSWindow.FrameAutosaveName = "MainEditorWindow"

private lazy var dataGridFieldEditor: DataGridFieldEditor = {
let editor = DataGridFieldEditor()
editor.isFieldEditor = true
Expand All @@ -57,18 +42,8 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate {

internal let payload: EditorTabPayload

/// Stable identifier for this controller. Distinct from the
/// `MainContentView.@State windowId` used inside WindowLifecycleMonitor —
/// that one remains the authoritative per-view UUID in Phase 1. Phase 2
/// will unify them on this controller's identifier.
internal let controllerId: UUID

/// NSUserActivity published while this window is key, so Handoff and
/// other continuity flows can pick up the connection (and table, if
/// viewing one). Replaces the SwiftUI `.userActivity(...)` modifier we
/// removed in Phase 2 — `.userActivity` requires a Scene context and
/// emitted `Cannot use Scene methods for URL, NSUserActivity...` warnings
/// when used inside an `NSHostingView`.
private var activity: NSUserActivity?

internal init(payload: EditorTabPayload, sessionState: SessionStateFactory.SessionState? = nil) {
Expand All @@ -84,40 +59,29 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate {
window.identifier = NSUserInterfaceItemIdentifier("main")
window.minSize = NSSize(width: 720, height: 480)
window.isRestorable = false
window.applyAutosaveName("MainEditorWindow")
window.toolbarStyle = .unified
// Hide the window title ("Query 1 / TablePro") embedded in the unified
// toolbar — otherwise it claims leading space and pushes our navigation
// items to the right of it. Tab group's tab bar already shows the same
// "Query N" label, so no information is lost. The Principal toolbar item
// continues to show connection name + DB version.
window.titleVisibility = .hidden
window.tabbingMode = .preferred
window.tabbingIdentifier = WindowManager.tabbingIdentifier(for: payload.connectionId)
window.collectionBehavior.insert([.fullScreenPrimary, .managed])

// NSSplitViewController as contentViewController so .toggleSidebar and
// .sidebarTrackingSeparator find the split view via the responder chain.
let splitVC = MainSplitViewController(payload: payload, sessionState: sessionState)
window.contentViewController = splitVC

super.init(window: window)

// Keep the controller alive after the window closes so NSWindowDelegate
// hooks have time to run teardown. WindowManager drops its strong
// reference on willClose, which triggers dealloc.
window.isReleasedWhenClosed = false

// Become the window's delegate so didBecomeKey/didResignKey/willClose
// dispatch to methods on this controller — eliminates the global
// NotificationCenter fan-out that previously ran every ContentView
// instance's observer per focus change.
window.delegate = self

// Toolbar is installed by MainSplitViewController.viewWillAppear when
// the session state is available. NSSplitViewController does not
// overwrite window.toolbar (unlike NavigationSplitView), so no KVO
// workaround is needed.
if !window.setFrameUsingName(Self.frameAutosaveName) {
let visibleSize = (window.screen ?? NSScreen.main)?.visibleFrame.size
?? NSSize(width: 1_440, height: 900)
window.setContentSize(NSSize(
width: min(1_200, visibleSize.width),
height: min(800, visibleSize.height)
))
window.center()
}

Self.lifecycleLogger.info(
"[open] TabWindowController.init payloadId=\(payload.id, privacy: .public) connId=\(payload.connectionId, privacy: .public) controllerId=\(self.controllerId, privacy: .public) eagerToolbar=\(sessionState != nil)"
Expand All @@ -136,6 +100,22 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate {
return dataGridFieldEditor
}

internal func windowDidResize(_ notification: Notification) {
guard let window = notification.object as? NSWindow else { return }
guard !window.inLiveResize else { return }
window.saveFrame(usingName: Self.frameAutosaveName)
}

internal func windowDidEndLiveResize(_ notification: Notification) {
guard let window = notification.object as? NSWindow else { return }
window.saveFrame(usingName: Self.frameAutosaveName)
}

internal func windowDidMove(_ notification: Notification) {
guard let window = notification.object as? NSWindow else { return }
window.saveFrame(usingName: Self.frameAutosaveName)
}

internal func windowDidBecomeKey(_ notification: Notification) {
let seq = MainContentCoordinator.nextSwitchSeq()
let t0 = Date()
Expand Down Expand Up @@ -180,6 +160,8 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate {
guard let window = notification.object as? NSWindow else { return }
Self.lifecycleLogger.info("[close] windowWillClose seq=\(seq) controllerId=\(self.controllerId, privacy: .public)")

window.saveFrame(usingName: Self.frameAutosaveName)

if let splitVC = window.contentViewController as? MainSplitViewController {
splitVC.invalidateToolbar()
}
Expand All @@ -198,10 +180,6 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate {

// MARK: - NSUserActivity

/// Publish (or refresh) this window's NSUserActivity. Called by
/// `windowDidBecomeKey` and by `MainContentView` when the selected tab
/// changes — only the second case is a no-op when the window isn't key
/// (Handoff only cares about the active activity).
internal func refreshUserActivity() {
guard let window, window.isKeyWindow,
let coordinator = MainContentCoordinator.coordinator(forWindow: window)
Expand All @@ -215,8 +193,6 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate {
let tableName: String? = (selectedTab?.tabType == .table) ? selectedTab?.tableContext.tableName : nil
let activityType = tableName != nil ? "com.TablePro.viewTable" : "com.TablePro.viewConnection"

// Recreate when the activity type flips between viewConnection and
// viewTable — NSUserActivity.activityType is immutable.
if activity?.activityType != activityType {
activity?.invalidate()
let newActivity = NSUserActivity(activityType: activityType)
Expand All @@ -232,13 +208,11 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate {
}
activity.userInfo = info

// Always promote to current. Both call sites (`windowDidBecomeKey` and
// `refreshUserActivity` which guards on `window.isKeyWindow`) only
// invoke this method when the window owns Handoff. The previous
// `becomeCurrent: Bool` parameter dropped Continuity mid-session
// whenever the user switched between table and query tabs in the
// same window — the type-flip branch above invalidated the old
// activity but never promoted the replacement.
// 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()
}
}
1 change: 0 additions & 1 deletion TablePro/Core/Services/Infrastructure/WindowManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@ internal final class WindowManager {
"[open] WindowManager joined existing tab group payloadId=\(payload.id, privacy: .public) tabbingId=\(tabbingId, privacy: .public)"
)
} else {
window.center()
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
Self.lifecycleLogger.info(
Expand Down
7 changes: 7 additions & 0 deletions TablePro/Extensions/NSWindow+FrameAutosave.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@
import AppKit

extension NSWindow {
/// Do not call on a window owned by an `NSWindowController` whose
/// `contentViewController` is an `NSSplitViewController`. The contentVC's
/// intrinsic-size resize during init fires the implicit auto-save observer
/// 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.
func applyAutosaveName(_ name: NSWindow.FrameAutosaveName) {
setFrameAutosaveName(name)
if !setFrameUsingName(name) {
Expand Down
Loading