Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Editor tab strip tests reporting four appearances while running plain Aqua and Dark Aqua twice.
- Icon cut off the entry at the top of a scrolled connections strip. (#2452)
- Connections strip not scrolling to the entry you switch to.
- Background connections stuck on the session preparation screen after connecting. (#2545)
- Blank sidebar, grid and inspector on a connection opened into a window that was already on screen.
- Grid cells left at the old column positions until the next click, after a resize, an auto-fit, a reorder, hiding a column, or a row-number width change. (#2449, #2446)
- The row-number column draggable out of first place, which walked it to the far right on the next refresh.
- A time entered into a date cell discarded when the stored value carried no time.
Expand Down
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,8 @@ To ship one: add the record type or field in CloudKit Console (or `xcrun cktool

**A workspace's content is a function of its own `ConnectionWindowPhase`, never of `activeSessions` membership**: the global session dictionary can only say *present* or *absent*, and that vocabulary cannot tell "never started" from "connecting" from "failed" from "the user cancelled" from "the window is closing". Deriving the pane from it shipped a window that painted a live spinner forever after a failed launch restore, could not be repainted by a later successful connect, and left no route back to the connection list except the Dock icon's context menu. `ConnectionWorkspace` owns the `phase`, one per connection the window hosts; `ConnectionWindowPhaseMachine` owns the transitions (pure, exhaustive, `.closing` absorbing), and `ConnectionWindowPaneResolver` owns the pane choice (pure). `MainSplitViewController` renders the selected workspace and routes a transition by `connectionId` through `transition(to:for:)`; it is only an adapter, and its `phase` property is a pass-through to `workspaces.selected`. Three rules follow. First, every phase must have an exit: the old `closingSessionId` latch was set once and never cleared, so the controller went permanently deaf to `connectionStatusChanged`. Second, a cancel updates the UI synchronously with the button press and never waits on the driver, because `Task.cancel()` is cooperative and may have no observable effect; the attempt is fenced by a per-workspace `attemptToken` (`ConnectionWorkspace.attemptToken`) plus `DatabaseManager.invalidateConnectionAttempt`, so a late failure cannot write into a workspace that moved on. The token cannot live on the window, because the window did not move on: one of the connections it hosts did. Closing a window therefore cancels the in-flight attempt of every workspace it hosts, not just the one its original payload named, and a completion that finds its workspace gone discards itself rather than resurrecting it. Third, a failure is presented inline through `ConnectionUnavailableView`, never as an alert, per the HIG's rule against alerts at startup and its one-alert-at-a-time rule (N restored connections would mean N modals). Only one presenter per failure: `LaunchIntentRouter.presentError` stays silent when a window for that connection exists.

**A workspace's panes are rendered from its final phase, and every workspace's, not just the one on screen**: each connection owns four `NSHostingController`s that outlive every switch, so a pane built from a phase that is about to change stays wrong until something builds it again, and nothing did. `reconcileStatus` used to adopt the session, paint, and only then write the phase, while `transition(to:for:)` repainted the selected workspace alone. So a connection that finished connecting while the user was looking at another one kept its "Preparing the session" screen and its Cancel button for good, over a session the rail and the toolbar both reported as connected (#2545); a background connect that failed, a background session that dropped and a background reconnect were each wrong the same way. Two rules follow. Nothing paints before the phase is final: `transition(to:for:)` is the only phase writer outside `markWindowClosing`'s batch, and it ends in `syncPanes(of:)` for the workspace it names, selected or not, and on an unchanged phase too, because a session can be replaced under one (a database switch does). And the panes carry a `WorkspacePaneRenderKey` of what they were last built from, which `refreshPanes` alone writes: that is what makes a repaint free to ask for from anywhere, including on every workspace switch, where an unconditional rebuild would re-evaluate the whole content tree and give back the work `WorkspacePanes` exists to avoid. The key is why selection can repair a workspace nothing else reached, which is what `adoptWorkspace` needs: it hands over a connection with a live session and no phase change to follow, so its panes still held the empty view they were constructed with and the connection opened blank.

**The app runs the AppKit lifecycle, and AppKit owns the menu bar**: `main.swift` assigns the delegate before `NSApplicationMain`, and `MainMenuBuilder.install` runs in `applicationWillFinishLaunching`. Do not reintroduce a SwiftUI `App`. SwiftUI reconciles `NSApp.mainMenu` once shortly after launch and removes every item it did not build itself, and no hook can undo it: `NSApp.mainMenu` is not KVO-compliant, `didUpdateNotification`, `didBecomeKeyNotification` and the `applicationDidUpdate(_:)` delegate method never fire under `@NSApplicationDelegateAdaptor`, and `applicationDidBecomeActive` fires before the reconciliation. Only a wall-clock delay worked, which is why #2057 shipped a menu bar that vanished half a second after launch and had to be reverted (#2071). Every window is an `NSWindowController`; the Welcome window is one too, so closing it is an ordinary `close()` and the old "closed, never ordered out" rule no longer applies.

**An emptied tab manager is not the same as "the user closed every tab"**: a coordinator torn down by a lost session has already emptied `tabManager.tabs`, so any persistence path that reads "no tabs" as "clear the saved tabs" wipes tabs the user never closed. The fix is that the teardown path cannot clear at all: `TabPersistenceCoordinator.saveAggregatedSync()`, which disconnect and window-close call, opens with `guard !tabs.isEmpty else { return }`. Clearing requires explicit consent and happens on the `closeTabsByUser` path instead. Keep those two paths separate; the moment a teardown path can write an empty aggregate, the bug is back.
Expand All @@ -205,7 +207,7 @@ To ship one: add the record type or field in CloudKit Console (or `xcrun cktool

**A SwiftUI-hosted split view needs an explicit divider cursor**: `NSSplitView` shows the resize cursor over its dividers through AppKit's cursor-rects system, which does not fire once the split view is mounted inside an `NSHostingController` (every tab-content split is, several SwiftUI layers deep under `MainSplitViewController.detailHosting`). The divider still drags because drag hit-testing is independent of cursor rects, but the pointer never changes. Every SwiftUI-hosted split-view controller must subclass `ResizeCursorSplitViewController`, which adds a key-window tracking area to its own split view and sets `NSCursor.columnResize`/`rowResize` (falling back to `resizeLeftRight`/`resizeUpDown` before macOS 15) in `mouseMoved`, the same hand-rolled approach `SortableHeaderView` uses for column resize. It attaches the tracking area to the framework's split view in `viewDidLoad` rather than replacing the split view, so `NSSplitViewController`'s own layout and divider orientation stay intact; replacing the split view through a `loadView` override that skips `super` leaves the controller half-initialized and its panes stack instead of laying out side by side. Do not swap the controller back to a plain `NSSplitViewController` expecting the stock cursor to work; the window's own sidebar and inspector dividers only get the cursor for free because `MainSplitViewController` is the window's `contentViewController` directly, with no SwiftUI host in between. This shipped as Users & Roles, Structure, Server Dashboard, and query editor dividers that dragged but never showed the resize cursor (#1905).

**Appearance is not lifetime, so `onDisappear` is never a destructor**: switching connection unparents the outgoing connection's panes (`WorkspacePaneHost.show`) while `WorkspacePanes` keeps the hosting controllers alive, and SwiftUI reports that as `onDisappear` followed by `onAppear` again on the same view identity with the same `@State`. Measured: `removeFromSuperview` fires the pair, `isHidden` and `window.orderOut` fire neither, and nothing fires on window close. Everything released under a pane's `onDisappear` must therefore be rebuilt by its `onAppear`, and anything that cannot be rebuilt must not be released there. `SQLEditorView` released the editor's text storage, highlighter, tree-sitter client, text coordinators and local key monitor from `onDisappear`, and `TextViewController` installs the last three in `loadView` alone, so one connection switch left a blank editor with no highlighting, no undo, no Cmd+/ or Cmd+[ and no Vim mode, permanently (#2236). Terminal teardown belongs to `NSViewControllerRepresentable.dismantleNSViewController`, which fires only on identity destruction, or to the explicit `ConnectionWorkspace.teardown()` chain. One catch there: SwiftUI reconciles a hosting controller on a layout pass and nothing lays out a detached view, so a `rootView` write on a pane that may be unparented needs `layoutSubtreeIfNeeded()` to take effect at all. `WorkspacePanes.teardown()` is the reference shape; `MainSplitViewController.refreshPanes` still writes `rootView` without one.
**Appearance is not lifetime, so `onDisappear` is never a destructor**: switching connection unparents the outgoing connection's panes (`WorkspacePaneHost.show`) while `WorkspacePanes` keeps the hosting controllers alive, and SwiftUI reports that as `onDisappear` followed by `onAppear` again on the same view identity with the same `@State`. Measured: `removeFromSuperview` fires the pair, `isHidden` and `window.orderOut` fire neither, and nothing fires on window close. Everything released under a pane's `onDisappear` must therefore be rebuilt by its `onAppear`, and anything that cannot be rebuilt must not be released there. `SQLEditorView` released the editor's text storage, highlighter, tree-sitter client, text coordinators and local key monitor from `onDisappear`, and `TextViewController` installs the last three in `loadView` alone, so one connection switch left a blank editor with no highlighting, no undo, no Cmd+/ or Cmd+[ and no Vim mode, permanently (#2236). Terminal teardown belongs to `NSViewControllerRepresentable.dismantleNSViewController`, which fires only on identity destruction, or to the explicit `ConnectionWorkspace.teardown()` chain. One catch there: SwiftUI reconciles a hosting controller on a layout pass and nothing lays out a detached view, so a teardown that has to happen *now* needs `layoutSubtreeIfNeeded()` to force one. That is `WorkspacePanes.teardown()`, whose panes are never parented again. It is not a general rule about writing `rootView` while detached: measured, such a write is deferred rather than lost, the last value written is what mounts when the pane is put back, and `@State`, grid scroll, selection and undo all survive it. `MainSplitViewController.refreshPanes` therefore writes `rootView` without one deliberately. Adding one there would mount every intermediate value instead of letting one run-loop turn settle on the final one, which is exactly the coalescing a phase change and a session adoption in the same turn rely on.

**The data grid header owns all of its own chrome, so nothing may ask AppKit to paint any of it**: `NSTableHeaderCell` and `NSTableHeaderView` both paint a fixed 28pt band that they centre vertically in whatever frame they are given, a 16pt column divider on `midY` and a 1pt rule at `midY + 13`. The data grid grows its header to 42pt for a column comment, so that band lands mid-cell: the rule crosses the comment's descenders and sits 8pt above the real bottom edge. `SortableHeaderChrome` is therefore the single owner of header geometry and colours, `SortableHeaderCell.draw(withFrame:in:)` never calls `super`, and `SortableHeaderView.draw(_:)` fills the background and rules the bottom edge itself. The trap is that the header view paints a second copy of that same band for `NSTableView.highlightedTableColumn`, driven by *state* rather than by a drawing call, so no cell override can reach it: setting it gives the sorted column a stray divider and a rule no other column has. TablePro already draws the sorted-column affordance itself (bold title, chevron, priority number, with `drawSortIndicator` overridden to nothing), so `highlightedTableColumn` is a redundant second channel and must stay unset. All sorted-column presentation goes through `SortableHeaderView.applySortState(_:schema:)`, which publishes the order natively through `tableView.sortDescriptors` (for accessibility; it paints nothing) and updates the cells. `SortableHeaderRenderingTests` rasterises the header and guards this. This shipped as a rule through the comment line and a stray divider on the sorted column (#2017).

Expand Down
32 changes: 31 additions & 1 deletion TablePro/Core/Services/Infrastructure/ConnectionWorkspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,18 @@ internal final class ConnectionWorkspace {
/// lands on has to be recorded like any other: it is the entry the user leaves behind when they
/// switch, and an entry nobody recorded is one that disappears the moment they do.
internal var session: ConnectionSession? {
didSet { recordBrowsedContainer() }
didSet {
sessionRevision &+= 1
recordBrowsedContainer()
}
}

/// Bumped whenever the adopted session is replaced. `ConnectionSession` is a value, so a
/// database switch or a status change produces a different one under an unchanged phase, and
/// the panes have to be rebuilt for it. This is how the render key says so without holding the
/// session, and through it the driver, itself.
internal private(set) var sessionRevision = 0

internal var sessionState: SessionStateFactory.SessionState?
internal var rightPanelState: RightPanelState?
internal var attemptToken: UUID?
Expand Down Expand Up @@ -189,6 +199,26 @@ internal final class ConnectionWorkspace {
payloadConnection ?? session?.connection
}

/// The pane this workspace resolves to right now. `ConnectionWindowPaneResolver` decides it and
/// `MainSplitViewController` renders it; the workspace only supplies the state both read.
internal var resolvedPane: ConnectionWindowPane {
ConnectionWindowPaneResolver.pane(
phase: phase,
hasConnection: connection != nil,
hasRenderableSession: session != nil && rightPanelState != nil && sessionState != nil
)
}

/// Everything the panes are built from, compared against `panes.renderedKey` to decide whether
/// they have to be built at all.
internal var paneRenderKey: WorkspacePaneRenderKey {
WorkspacePaneRenderKey(
pane: resolvedPane,
connection: connection,
sessionRevision: sessionRevision
)
}

/// Opens what the payload names. Held until the session exists if the connection is still
/// being established.
internal func open(_ payload: EditorTabPayload) {
Expand Down
Loading
Loading