diff --git a/CHANGELOG.md b/CHANGELOG.md index b50c448c3..8138f200b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,10 +75,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Staged structure changes survive switching tabs, and switching a table tab between Data and Structure. Adding a column or an index and then looking at anything else threw the pending change away, with no prompt and nothing in Undo. - A table definition in progress survives switching tabs. Naming a new table and defining its columns, then clicking any other tab, used to discard the whole definition. - Closing a tab with staged structure changes or an unfinished table definition asks before discarding them, and they count as unsaved work when you close the window or quit. - -### Fixed - - Timestamps written with a space before the time zone offset, or with fractional seconds, are now shown in your chosen date format instead of as raw text. PostgreSQL `timestamptz` and MySQL `DATETIME(6)` values used to slip through unformatted while the same instant written in ISO form was formatted. +- Switching to another connection no longer empties the SQL editor. Typing into the blank editor replaced the query you had written, and the same switch cleared undo history, turned off syntax highlighting, and left the editor's own shortcuts (comment, indent, duplicate line, delete line, move line, manual completion) and Vim mode dead for the rest of the tab's life. (#2236) ## [0.66.0] - 2026-08-19 diff --git a/CLAUDE.md b/CLAUDE.md index b79897a50..2c100c931 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -201,6 +201,8 @@ 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. + **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). **Decoding a MongoDB binary UUID is a per-column decision, and the column's type name is load-bearing**: BSON binary subtype 3 is the legacy UUID format, and the Java, C# and Python drivers each wrote it with a different byte order with nothing in the stored bytes to say which. `MongoDBUuidCodec` therefore decodes subtype 3 only when the connection names one (`mongoUuidRepresentation`); subtype 4 is unambiguous and always decodes. The choice is made once per column from `BsonDocumentFlattener.columnKinds`' majority vote, never per value, because a decoded cell is `.text` and an undecoded one is `.bytes`, and `CellDisplayFormatter` runs blob formatting over a `.text` cell whenever its column type is BLOB. One UUID decoded inside a column the app still types `BLOB` renders as `0x4c65676163...`. For the same reason `BsonDocumentFlattener.typeName` must keep `BLOB` as the base name for undecoded binary: `ColumnTypeClassifier` splits a type name at the first `(` and looks the base up, so `BLOB` and `BLOB(3)` both classify as `.blob`, and that classification is the only thing keeping a binary cell out of the inline editor. The parenthesised part carries the BSON subtype so MQL export can write it back; `MongoDBUuidCodec.columnTypeName(forSubtype:)` and `binarySubtype(fromColumnTypeName:)` are the only two places that spelling is produced or read, and MQL export is `supportedDatabaseTypeIds = ["MongoDB"]`, so it never sees another driver's `BLOB`. Once a column does decode, both edit guards (`isBlobType` and `asBytes != nil`) fall together, so every write path must parse the wrapper back to `$binary`: `MongoDBStatementGenerator.jsonValue` and `idValueJson`, `MongoDBQueryBuilder.jsonValue` plus its `=`, `!=` and `IN` arms (a case-insensitive regex can never match a binary field), and `MQLExportHelpers.mqlJsonValue`. An `_id` filter left as wrapper text matches zero documents while the UI reports the save succeeded. (#2086) diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index 22fbdc795..280bbb3d5 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -290,9 +290,15 @@ public class TextViewController: NSViewController { self.gutterView.setNeedsDisplay(self.gutterView.frame) } - /// Release heavy resources (tree-sitter, highlighter, text storage) early, - /// without waiting for deinit. Call when the editor is no longer visible but - /// SwiftUI may keep the controller alive in @State. + /// Release the caches an editor can rebuild (tree-sitter, highlighter, coordinators, observers) + /// without waiting for deinit. Called from ``SourceEditor/dismantleNSViewController(_:coordinator:)`` + /// when SwiftUI removes the editor for good. + /// + /// It frees caches only. The text storage is the document, not a cache, and nothing here + /// rebuilds it: `setUpHighlighter` and `setUpKeyBindings` run in `loadView` alone, so a + /// controller that survives this call has no highlighting and no key bindings for the rest of + /// its life. Discarding the text here blanked the editor whenever the call was reached on a + /// controller that came back. public func releaseHeavyState() { if let highlighter { textView?.removeStorageDelegate(highlighter) @@ -300,8 +306,8 @@ public class TextViewController: NSViewController { highlighter = nil treeSitterClient = nil highlightProviders.removeAll() - // Don't call textCoordinators.destroy() here — the caller (coordinator.destroy()) - // is already a coordinator, so calling back into destroy() causes infinite recursion. + // Don't call textCoordinators.destroy() here. The caller may already be a coordinator, + // so calling back into destroy() causes infinite recursion. textCoordinators.removeAll() cancellables.forEach { $0.cancel() } cancellables.removeAll() @@ -309,7 +315,6 @@ public class TextViewController: NSViewController { NSEvent.removeMonitor(localEventMonitor) } localEventMonitor = nil - textView?.setText("") } deinit { diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor+Coordinator.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor+Coordinator.swift index ebd24febc..957bcda55 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor+Coordinator.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor+Coordinator.swift @@ -20,12 +20,26 @@ extension SourceEditor { private(set) var highlightProviders: [any HighlightProviding] + /// Held strongly, unlike ``TextViewController/textCoordinators``, which holds them weakly. + /// SwiftUI keeps this coordinator alive for as long as the editor exists, so the teardown in + /// ``SourceEditor/dismantleNSViewController(_:coordinator:)`` always has something to + /// destroy. Going through the weak list instead would make teardown depend on SwiftUI + /// releasing the view's `@State` after the dismantle rather than before it, which nothing + /// guarantees. + let textCoordinators: [any TextViewCoordinator] + private var cancellables: Set = [] - init(text: TextAPI, editorState: Binding, highlightProviders: [any HighlightProviding]?) { + init( + text: TextAPI, + editorState: Binding, + highlightProviders: [any HighlightProviding]?, + textCoordinators: [any TextViewCoordinator] + ) { self.textSync = TextBindingSync(text: text, phase: phase) self._editorState = editorState self.highlightProviders = highlightProviders ?? [TreeSitterClient()] + self.textCoordinators = textCoordinators super.init() } diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift index 088761429..762ce4904 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift @@ -125,7 +125,26 @@ public struct SourceEditor: NSViewControllerRepresentable { } public func makeCoordinator() -> Coordinator { - Coordinator(text: text, editorState: $state, highlightProviders: highlightProviders) + Coordinator( + text: text, + editorState: $state, + highlightProviders: highlightProviders, + textCoordinators: coordinators + ) + } + + /// The editor's terminal signal: SwiftUI calls this only when the representable is removed for + /// good, never when its host view merely leaves the window. + /// + /// Appearance is not lifetime. A host that keeps a controller alive while unparenting its view + /// fires `onDisappear` and then `onAppear` again on the same identity, so a teardown driven from + /// `onDisappear` runs on a live editor and cannot be undone. `TextViewController.deinit` is not + /// a usable substitute either, because it is not guaranteed to run promptly. The destroy goes + /// through ``Coordinator/textCoordinators``, and `releaseHeavyState` then empties the + /// controller's weak list so `deinit` cannot destroy the same coordinators twice. + public static func dismantleNSViewController(_ controller: TextViewController, coordinator: Coordinator) { + coordinator.textCoordinators.forEach { $0.destroy() } + controller.releaseHeavyState() } public func updateNSViewController(_ controller: TextViewController, context: Context) { diff --git a/TablePro/Core/Services/Infrastructure/WorkspacePanes.swift b/TablePro/Core/Services/Infrastructure/WorkspacePanes.swift index 24573c280..2475cd6f2 100644 --- a/TablePro/Core/Services/Infrastructure/WorkspacePanes.swift +++ b/TablePro/Core/Services/Infrastructure/WorkspacePanes.swift @@ -50,11 +50,21 @@ internal final class WorkspacePanes { /// retains the `MainContentCoordinator`, which only leaves the app-wide coordinator registry /// when it deinits: a pane left behind keeps a dead session answering questions about open tabs /// and unsaved work for the rest of the app's life. + /// + /// Emptying `rootView` is not enough on its own. SwiftUI reconciles a hosting controller on a + /// layout pass, and by the time this runs the pane is detached: closing a connection removes it + /// from the registry first, which selects a neighbour and unparents this pane. Nobody asks a + /// detached view to lay out, so without the explicit pass the old tree stays mounted, nothing + /// is dismantled, and the dead session this is meant to drop goes on answering the app about + /// open tabs and unsaved work. Clearing before unparenting keeps the same true if a caller ever + /// tears down a pane that is still on screen. A pane that was never parented has nothing + /// mounted to drop, and the cleared `rootView` is enough for it. internal func teardown() { for pane in panes { + pane.rootView = AnyView(Color.clear) + pane.view.layoutSubtreeIfNeeded() pane.view.removeFromSuperview() pane.removeFromParent() - pane.rootView = AnyView(Color.clear) } } } diff --git a/TablePro/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index 10b0874c3..ccb33aac2 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -49,7 +49,7 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { @ObservationIgnored private var didDestroy = false @ObservationIgnored private var focusClaimPending = false - /// Test-only accessor for destroy state + /// One way. `destroy()` runs when the editor is dismantled, which it never comes back from. var isDestroyed: Bool { didDestroy } @ObservationIgnored private var hasInstalledEditorServices = false @@ -254,30 +254,11 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { vimEngine = nil vimCursorManager = nil - controller?.releaseHeavyState() - EditorEventRouter.shared.unregister(self) Self.logger.debug("SQLEditorCoordinator destroyed") cleanupMonitors() } - func revive() { - guard didDestroy else { return } - didDestroy = false - if let controller, let textView = controller.textView { - EditorEventRouter.shared.register(self, textView: textView) - } - if contextMenu == nil, let controller { - installAIContextMenu(controller: controller) - } - if inlineSuggestionManager == nil, let controller { - installInlineSuggestionManager(controller: controller) - } - if let controller { - installEditorSettingsObserver(controller: controller) - } - } - // MARK: - AI Context Menu private func installAIContextMenu(controller: TextViewController) { diff --git a/TablePro/Views/Editor/SQLEditorView.swift b/TablePro/Views/Editor/SQLEditorView.swift index 264a4cec0..df3b5c6e5 100644 --- a/TablePro/Views/Editor/SQLEditorView.swift +++ b/TablePro/Views/Editor/SQLEditorView.swift @@ -111,7 +111,6 @@ struct SQLEditorView: View { } .onDisappear { teardownFavoritesObserver() - coordinator.destroy() } .onChange(of: coordinator.vimMode) { _, newMode in vimMode = newMode @@ -121,9 +120,6 @@ struct SQLEditorView: View { // MARK: - Initialization private func initializeEditor() { - if coordinator.isDestroyed { - coordinator.revive() - } completionAdapter.configure(schemaProvider: schemaProvider, databaseType: databaseType) setupFavoritesObserver() } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 38bd6ee38..5c1b49985 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -781,7 +781,9 @@ final class MainContentCoordinator { } } - /// Explicit cleanup called from `onDisappear`. Releases schema provider + /// Explicit cleanup, called when the connection or the window that hosts it goes away, never + /// from a view's `onDisappear`: a workspace switch unparents a connection's panes, which is a + /// disappearance the connection is expected to come back from. Releases the schema provider /// synchronously on MainActor so we don't depend on deinit + Task scheduling. func teardown() { let start = Date() diff --git a/TableProTests/Views/Editor/EditorControllerFixture.swift b/TableProTests/Views/Editor/EditorControllerFixture.swift new file mode 100644 index 000000000..73ff26048 --- /dev/null +++ b/TableProTests/Views/Editor/EditorControllerFixture.swift @@ -0,0 +1,66 @@ +// +// EditorControllerFixture.swift +// TableProTests +// +// A real, laid-out TextViewController for tests that need the editor's actual +// AppKit behaviour rather than a stand-in. +// + +import AppKit +import CodeEditLanguages +@testable import CodeEditSourceEditor +import CodeEditTextView + +@MainActor +internal enum EditorControllerFixture { + internal static func make( + string: String = "", + coordinators: [TextViewCoordinator] = [] + ) -> TextViewController { + let controller = TextViewController( + string: string, + language: .default, + configuration: configuration, + cursorPositions: [], + highlightProviders: [], + coordinators: coordinators + ) + controller.loadView() + controller.view.frame = NSRect(x: 0, y: 0, width: 1_000, height: 1_000) + controller.view.layoutSubtreeIfNeeded() + return controller + } + + private static var configuration: SourceEditorConfiguration { + SourceEditorConfiguration( + appearance: .init( + theme: theme, + font: .monospacedSystemFont(ofSize: 12, weight: .regular), + lineHeightMultiple: 1.0, + wrapLines: false, + tabWidth: 4 + ) + ) + } + + private static var theme: EditorTheme { + EditorTheme( + text: EditorTheme.Attribute(color: .textColor), + insertionPoint: .textColor, + invisibles: EditorTheme.Attribute(color: .gray), + background: .textBackgroundColor, + lineHighlight: .selectedTextBackgroundColor, + selection: .selectedTextColor, + keywords: EditorTheme.Attribute(color: .systemPink), + commands: EditorTheme.Attribute(color: .systemBlue), + types: EditorTheme.Attribute(color: .systemMint), + attributes: EditorTheme.Attribute(color: .systemTeal), + variables: EditorTheme.Attribute(color: .systemCyan), + values: EditorTheme.Attribute(color: .systemOrange), + numbers: EditorTheme.Attribute(color: .systemYellow), + strings: EditorTheme.Attribute(color: .systemRed), + characters: EditorTheme.Attribute(color: .systemRed), + comments: EditorTheme.Attribute(color: .systemGreen) + ) + } +} diff --git a/TableProTests/Views/Editor/EditorLifecycleTeardownTests.swift b/TableProTests/Views/Editor/EditorLifecycleTeardownTests.swift new file mode 100644 index 000000000..a86d7001a --- /dev/null +++ b/TableProTests/Views/Editor/EditorLifecycleTeardownTests.swift @@ -0,0 +1,165 @@ +// +// EditorLifecycleTeardownTests.swift +// TableProTests +// +// Regression tests for #2236. A connection switch unparents the outgoing connection's pane +// and re-parents it on the way back, which SwiftUI reports as onDisappear then onAppear on a +// live editor. Teardown wired to that pair blanked the SQL editor, cleared its undo stack, and +// left its highlighter and key bindings dead, because only loadView() rebuilds them. +// + +import AppKit +@testable import CodeEditSourceEditor +import CodeEditTextView +import SwiftUI +@testable import TablePro +import Testing + +private final class RecordingCoordinator: TextViewCoordinator { + private(set) var destroyCount = 0 + + func prepareCoordinator(controller: TextViewController) {} + + func destroy() { + destroyCount += 1 + } +} + +private final class DismantleRecorder { + var makeCount = 0 + var dismantleCount = 0 +} + +private struct DismantleProbe: NSViewControllerRepresentable { + let recorder: DismantleRecorder + + func makeNSViewController(context: Context) -> NSViewController { + recorder.makeCount += 1 + let controller = NSViewController() + controller.view = NSView() + return controller + } + + func updateNSViewController(_ controller: NSViewController, context: Context) {} + + static func dismantleNSViewController(_ controller: NSViewController, coordinator: DismantleRecorder) { + coordinator.dismantleCount += 1 + } + + func makeCoordinator() -> DismantleRecorder { + recorder + } +} + +@MainActor +@Suite("Editor lifecycle teardown") +struct EditorLifecycleTeardownTests { + @Test("releaseHeavyState keeps the document") + func releaseHeavyStateKeepsDocument() { + let controller = EditorControllerFixture.make(string: "SELECT * FROM users WHERE id = 1") + + controller.releaseHeavyState() + + #expect(controller.textView.string == "SELECT * FROM users WHERE id = 1") + } + + @Test("releaseHeavyState keeps the undo stack") + func releaseHeavyStateKeepsUndoStack() { + let controller = EditorControllerFixture.make(string: "SELECT 1") + controller.textView.replaceCharacters(in: NSRange(location: 8, length: 0), with: " -- note") + #expect(controller.textView._undoManager?.canUndo == true) + + controller.releaseHeavyState() + + #expect(controller.textView._undoManager?.canUndo == true) + } + + @Test("SQLEditorCoordinator.destroy keeps the document") + func coordinatorDestroyKeepsDocument() { + let coordinator = SQLEditorCoordinator() + let controller = EditorControllerFixture.make(string: "SELECT 1", coordinators: [coordinator]) + + coordinator.destroy() + + #expect(controller.textView.string == "SELECT 1") + } + + @Test("dismantleNSViewController destroys each text coordinator once and empties the list") + func dismantleDestroysCoordinatorsOnce() { + let recording = RecordingCoordinator() + let controller = EditorControllerFixture.make(string: "SELECT 1", coordinators: [recording]) + let coordinator = SourceEditor.Coordinator( + text: .binding(.constant("SELECT 1")), + editorState: .constant(SourceEditorState()), + highlightProviders: [], + textCoordinators: [recording] + ) + + SourceEditor.dismantleNSViewController(controller, coordinator: coordinator) + + #expect(recording.destroyCount == 1) + #expect(controller.textCoordinators.values().isEmpty) + } + + /// The production shape: closing a connection removes it from the registry, which selects a + /// neighbour and unparents this pane, so `teardown()` always runs on a detached hosting + /// controller. Nothing lays a detached view out, so without the explicit layout pass SwiftUI + /// never reconciles the cleared `rootView` and the tree stays mounted. + @Test("WorkspacePanes.teardown dismantles a detached pane's content") + func teardownDismantlesDetachedPaneContent() throws { + let recorder = DismantleRecorder() + let panes = WorkspacePanes() + let window = try mount(panes.detail, showing: DismantleProbe(recorder: recorder)) + defer { window.orderOut(nil) } + + panes.detail.view.removeFromSuperview() + #expect(recorder.dismantleCount == 0) + + panes.teardown() + + #expect(recorder.dismantleCount == 1) + #expect(panes.detail.parent == nil) + } + + @Test("WorkspacePanes.teardown dismantles a pane that is still on screen") + func teardownDismantlesAttachedPaneContent() throws { + let recorder = DismantleRecorder() + let panes = WorkspacePanes() + let window = try mount(panes.detail, showing: DismantleProbe(recorder: recorder)) + defer { window.orderOut(nil) } + + panes.teardown() + + #expect(recorder.dismantleCount == 1) + #expect(panes.detail.view.superview == nil) + #expect(panes.detail.parent == nil) + } + + /// Returns the window so the caller keeps it alive for the length of the test. `NSWindow` + /// defaults `isReleasedWhenClosed` to true, so closing one that ARC also owns over-releases it + /// and takes the whole test host down with it; the window is ordered out instead. A probe that + /// never builds fails the test rather than letting it pass having asserted nothing. + private func mount(_ pane: NSHostingController, showing probe: DismantleProbe) throws -> NSWindow { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + let container = NSView(frame: NSRect(x: 0, y: 0, width: 400, height: 300)) + window.contentView = container + + pane.rootView = AnyView(probe) + pane.view.frame = container.bounds + container.addSubview(pane.view) + + let deadline = Date(timeIntervalSinceNow: 10) + while probe.recorder.makeCount == 0, Date() < deadline { + pane.view.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.02)) + } + try #require(probe.recorder.makeCount == 1, "the probe never mounted, so the test proves nothing") + return window + } +} diff --git a/TableProTests/Views/Editor/GutterHighlightTests.swift b/TableProTests/Views/Editor/GutterHighlightTests.swift index e592e00f5..586ff146a 100644 --- a/TableProTests/Views/Editor/GutterHighlightTests.swift +++ b/TableProTests/Views/Editor/GutterHighlightTests.swift @@ -10,56 +10,13 @@ // import AppKit -import CodeEditLanguages @testable import CodeEditSourceEditor import CodeEditTextView -import TableProPluginKit import Testing @MainActor @Suite("GutterView highlight at end of document") struct GutterHighlightTests { - private func makeController() -> TextViewController { - let theme = EditorTheme( - text: EditorTheme.Attribute(color: .textColor), - insertionPoint: .textColor, - invisibles: EditorTheme.Attribute(color: .gray), - background: .textBackgroundColor, - lineHighlight: .selectedTextBackgroundColor, - selection: .selectedTextColor, - keywords: EditorTheme.Attribute(color: .systemPink), - commands: EditorTheme.Attribute(color: .systemBlue), - types: EditorTheme.Attribute(color: .systemMint), - attributes: EditorTheme.Attribute(color: .systemTeal), - variables: EditorTheme.Attribute(color: .systemCyan), - values: EditorTheme.Attribute(color: .systemOrange), - numbers: EditorTheme.Attribute(color: .systemYellow), - strings: EditorTheme.Attribute(color: .systemRed), - characters: EditorTheme.Attribute(color: .systemRed), - comments: EditorTheme.Attribute(color: .systemGreen) - ) - let configuration = SourceEditorConfiguration( - appearance: .init( - theme: theme, - font: .monospacedSystemFont(ofSize: 12, weight: .regular), - lineHeightMultiple: 1.0, - wrapLines: false, - tabWidth: 4 - ) - ) - let controller = TextViewController( - string: "", - language: .default, - configuration: configuration, - cursorPositions: [], - highlightProviders: [] - ) - controller.loadView() - controller.view.frame = NSRect(x: 0, y: 0, width: 1_000, height: 1_000) - controller.view.layoutSubtreeIfNeeded() - return controller - } - private func setText(_ text: String, on controller: TextViewController) { controller.textView.setText(text) controller.textView.layoutManager.layoutLines(in: NSRect(x: 0, y: 0, width: 1_000, height: 1_000)) @@ -67,7 +24,7 @@ struct GutterHighlightTests { @Test("Caret at end of single-line query highlights the only line") func caretAtEndOfSingleLineHighlightsLine() throws { - let controller = makeController() + let controller = EditorControllerFixture.make() setText("SELECT * FROM users", on: controller) let length = controller.textView.length controller.textView.selectionManager.setSelectedRange(NSRange(location: length, length: 0)) @@ -79,7 +36,7 @@ struct GutterHighlightTests { @Test("Caret at end of multi-line query highlights only the last line") func caretAtEndOfMultiLineHighlightsLastLine() throws { - let controller = makeController() + let controller = EditorControllerFixture.make() setText("abc\ndef", on: controller) let length = controller.textView.length controller.textView.selectionManager.setSelectedRange(NSRange(location: length, length: 0)) @@ -93,7 +50,7 @@ struct GutterHighlightTests { @Test("Caret in middle of line highlights that line") func caretInMiddleOfLineHighlightsThatLine() throws { - let controller = makeController() + let controller = EditorControllerFixture.make() setText("abc\ndef", on: controller) controller.textView.selectionManager.setSelectedRange(NSRange(location: 1, length: 0))