Skip to content

Commit 20b1974

Browse files
authored
fix: editor windows remember size, position, and zoom state across launches (#1092)
* fix: editor windows remember size, position, and zoom state across launches * fix: explicitly save window frame on close to capture zoom state * chore: add WindowFrame trace logging to find where frame state drops * refactor: drive frame autosave from NSWindowDelegate methods, no flicker * chore: add frame trace at every init step + lifecycle event for deep debugging * fix: restore frame after content view controller layout pass * fix: open at default 1200x800 centered on first launch * refactor: clamp first-launch size to screen, restore non-obvious comments
1 parent a2f9430 commit 20b1974

5 files changed

Lines changed: 42 additions & 70 deletions

File tree

CHANGELOG.md

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

1617
### Added
1718

TablePro/Core/Services/Infrastructure/MainSplitViewController.swift

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -167,15 +167,6 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi
167167
super.viewWillAppear()
168168
guard let window = view.window else { return }
169169

170-
let defaultSize = NSSize(width: 1_200, height: 800)
171-
if window.frame.width < defaultSize.width || window.frame.height < defaultSize.height {
172-
window.setContentSize(NSSize(
173-
width: max(window.frame.width, defaultSize.width),
174-
height: max(window.frame.height, defaultSize.height)
175-
))
176-
window.center()
177-
}
178-
179170
window.title = windowTitle
180171
if let session = currentSession {
181172
window.subtitle = session.connection.name

TablePro/Core/Services/Infrastructure/TabWindowController.swift

Lines changed: 34 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,11 @@
22
// TabWindowController.swift
33
// TablePro
44
//
5-
// NSWindowController for an editor-tab-window. Replaces the SwiftUI
6-
// `WindowGroup(id: "main", for: EditorTabPayload.self)` scene.
7-
//
8-
// Phase 1 scope: window creation, NSHostingView installation, tabbing
9-
// configuration. Existing MainContentView lifecycle hooks (.onAppear,
10-
// .onDisappear, NSWindow notification observers, .userActivity) continue to
11-
// work unchanged — this controller's job in Phase 1 is limited to replacing
12-
// SwiftUI scene-driven window construction.
13-
//
14-
// Phase 2 will migrate lifecycle responsibilities (markActivated, teardown,
15-
// userActivity, didBecomeKey/didResignKey) into NSWindowDelegate methods
16-
// on this controller.
17-
//
185

196
import AppKit
207
import os
218
import SwiftUI
229

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

35+
internal static let frameAutosaveName: NSWindow.FrameAutosaveName = "MainEditorWindow"
36+
5237
private lazy var dataGridFieldEditor: DataGridFieldEditor = {
5338
let editor = DataGridFieldEditor()
5439
editor.isFieldEditor = true
@@ -57,18 +42,8 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate {
5742

5843
internal let payload: EditorTabPayload
5944

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

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

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

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

10471
super.init(window: window)
10572

106-
// Keep the controller alive after the window closes so NSWindowDelegate
107-
// hooks have time to run teardown. WindowManager drops its strong
108-
// reference on willClose, which triggers dealloc.
10973
window.isReleasedWhenClosed = false
110-
111-
// Become the window's delegate so didBecomeKey/didResignKey/willClose
112-
// dispatch to methods on this controller — eliminates the global
113-
// NotificationCenter fan-out that previously ran every ContentView
114-
// instance's observer per focus change.
11574
window.delegate = self
11675

117-
// Toolbar is installed by MainSplitViewController.viewWillAppear when
118-
// the session state is available. NSSplitViewController does not
119-
// overwrite window.toolbar (unlike NavigationSplitView), so no KVO
120-
// workaround is needed.
76+
if !window.setFrameUsingName(Self.frameAutosaveName) {
77+
let visibleSize = (window.screen ?? NSScreen.main)?.visibleFrame.size
78+
?? NSSize(width: 1_440, height: 900)
79+
window.setContentSize(NSSize(
80+
width: min(1_200, visibleSize.width),
81+
height: min(800, visibleSize.height)
82+
))
83+
window.center()
84+
}
12185

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

103+
internal func windowDidResize(_ notification: Notification) {
104+
guard let window = notification.object as? NSWindow else { return }
105+
guard !window.inLiveResize else { return }
106+
window.saveFrame(usingName: Self.frameAutosaveName)
107+
}
108+
109+
internal func windowDidEndLiveResize(_ notification: Notification) {
110+
guard let window = notification.object as? NSWindow else { return }
111+
window.saveFrame(usingName: Self.frameAutosaveName)
112+
}
113+
114+
internal func windowDidMove(_ notification: Notification) {
115+
guard let window = notification.object as? NSWindow else { return }
116+
window.saveFrame(usingName: Self.frameAutosaveName)
117+
}
118+
139119
internal func windowDidBecomeKey(_ notification: Notification) {
140120
let seq = MainContentCoordinator.nextSwitchSeq()
141121
let t0 = Date()
@@ -180,6 +160,8 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate {
180160
guard let window = notification.object as? NSWindow else { return }
181161
Self.lifecycleLogger.info("[close] windowWillClose seq=\(seq) controllerId=\(self.controllerId, privacy: .public)")
182162

163+
window.saveFrame(usingName: Self.frameAutosaveName)
164+
183165
if let splitVC = window.contentViewController as? MainSplitViewController {
184166
splitVC.invalidateToolbar()
185167
}
@@ -198,10 +180,6 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate {
198180

199181
// MARK: - NSUserActivity
200182

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

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

235-
// Always promote to current. Both call sites (`windowDidBecomeKey` and
236-
// `refreshUserActivity` which guards on `window.isKeyWindow`) only
237-
// invoke this method when the window owns Handoff. The previous
238-
// `becomeCurrent: Bool` parameter dropped Continuity mid-session
239-
// whenever the user switched between table and query tabs in the
240-
// same window — the type-flip branch above invalidated the old
241-
// activity but never promoted the replacement.
211+
// becomeCurrent is unconditional. A previous becomeCurrent: Bool gate
212+
// dropped Continuity mid-session whenever the user switched between
213+
// table and query tabs in the same window, because the activity-type
214+
// flip above invalidates the old activity but never promotes its
215+
// replacement.
242216
activity.becomeCurrent()
243217
}
244218
}

TablePro/Core/Services/Infrastructure/WindowManager.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,6 @@ internal final class WindowManager {
7171
"[open] WindowManager joined existing tab group payloadId=\(payload.id, privacy: .public) tabbingId=\(tabbingId, privacy: .public)"
7272
)
7373
} else {
74-
window.center()
7574
window.makeKeyAndOrderFront(nil)
7675
NSApp.activate(ignoringOtherApps: true)
7776
Self.lifecycleLogger.info(

TablePro/Extensions/NSWindow+FrameAutosave.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@
66
import AppKit
77

88
extension NSWindow {
9+
/// Do not call on a window owned by an `NSWindowController` whose
10+
/// `contentViewController` is an `NSSplitViewController`. The contentVC's
11+
/// intrinsic-size resize during init fires the implicit auto-save observer
12+
/// installed by `setFrameAutosaveName`, overwriting the persisted frame
13+
/// with the small intrinsic size. Use `setFrameUsingName` plus explicit
14+
/// `saveFrame(usingName:)` calls in `NSWindowDelegate` methods instead.
15+
/// See `TabWindowController` for that pattern.
916
func applyAutosaveName(_ name: NSWindow.FrameAutosaveName) {
1017
setFrameAutosaveName(name)
1118
if !setFrameUsingName(name) {

0 commit comments

Comments
 (0)