From 9ad70da18be02a32a9ccd399fbf5f6645c4bdd3d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 25 Oct 2026 19:59:22 +0700 Subject: [PATCH 1/2] perf(editor): build the query tab in AppKit and read the sidebar listing when the filter takes focus --- .../SourceEditor/SourceEditor.swift | 29 +- .../SourceEditor/SourceEditorHost.swift | 71 ++++ .../SourceEditorHostTests.swift | 112 ++++++ .../SidebarContainerViewController.swift | 27 +- .../Models/UI/SharedSidebarState.swift | 1 + .../ViewModels/SidebarViewModel.swift | 30 +- .../CollapsibleSplitController.swift | 79 ++++ .../VerticalCollapsibleSplitView.swift | 121 ------ .../Editor/QueryEditorPaneController.swift | 91 +++++ .../Views/Editor/QueryEditorView.swift | 118 ------ .../Views/Editor/SQLEditorController.swift | 361 ++++++++++++++++++ .../TablePro/Views/Editor/SQLEditorView.swift | 295 -------------- .../Main/Child/EditorAreaController.swift | 174 +++++++++ .../Main/Child/MainEditorContentView.swift | 258 +++++++------ .../Sidebar/DatabaseTreeRenderInputs.swift | 34 +- .../SidebarContainerChromeTests.swift | 86 +++++ .../Query/SidebarSchemaFilterTests.swift | 121 ++++++ .../Editor/QueryEditorPaneLayoutTests.swift | 84 ++++ .../Editor/QueryEditorViewLayoutTests.swift | 65 ---- .../SQLEditorCursorPropagationTests.swift | 82 ++-- .../Main/EditorAreaControllerTests.swift | 98 +++++ .../Sidebar/DatabaseTreeReconcileTests.swift | 50 +++ 22 files changed, 1616 insertions(+), 771 deletions(-) create mode 100644 apple/Packages/TableProEditor/Sources/TableProEditorKit/SourceEditor/SourceEditorHost.swift create mode 100644 apple/Packages/TableProEditor/Tests/TableProEditorKitTests/SourceEditorHostTests.swift create mode 100644 apple/TablePro/Views/Components/CollapsibleSplitController.swift delete mode 100644 apple/TablePro/Views/Components/VerticalCollapsibleSplitView.swift create mode 100644 apple/TablePro/Views/Editor/QueryEditorPaneController.swift delete mode 100644 apple/TablePro/Views/Editor/QueryEditorView.swift create mode 100644 apple/TablePro/Views/Editor/SQLEditorController.swift delete mode 100644 apple/TablePro/Views/Editor/SQLEditorView.swift create mode 100644 apple/TablePro/Views/Main/Child/EditorAreaController.swift create mode 100644 apple/TableProTests/Views/Editor/QueryEditorPaneLayoutTests.swift delete mode 100644 apple/TableProTests/Views/Editor/QueryEditorViewLayoutTests.swift create mode 100644 apple/TableProTests/Views/Main/EditorAreaControllerTests.swift diff --git a/apple/Packages/TableProEditor/Sources/TableProEditorKit/SourceEditor/SourceEditor.swift b/apple/Packages/TableProEditor/Sources/TableProEditorKit/SourceEditor/SourceEditor.swift index 11cf6a09fe..413bc50586 100644 --- a/apple/Packages/TableProEditor/Sources/TableProEditorKit/SourceEditor/SourceEditor.swift +++ b/apple/Packages/TableProEditor/Sources/TableProEditorKit/SourceEditor/SourceEditor.swift @@ -100,12 +100,16 @@ public struct SourceEditor: NSViewControllerRepresentable { public typealias NSViewControllerType = TextViewController public func makeNSViewController(context: Context) -> TextViewController { + makeController(coordinator: context.coordinator) + } + + func makeController(coordinator: Coordinator) -> TextViewController { let controller = TextViewController( string: "", language: language, configuration: configuration, cursorPositions: state.cursorPositions ?? [], - highlightProviders: context.coordinator.highlightProviders, + highlightProviders: coordinator.highlightProviders, foldProvider: foldProvider, undoManager: undoManager, coordinators: coordinators, @@ -124,7 +128,7 @@ public struct SourceEditor: NSViewControllerRepresentable { controller.setCursorPositions(state.cursorPositions ?? []) } - context.coordinator.setController(controller) + coordinator.setController(controller) return controller } @@ -152,24 +156,28 @@ public struct SourceEditor: NSViewControllerRepresentable { } public func updateNSViewController(_ controller: TextViewController, context: Context) { + update(controller, coordinator: context.coordinator) + } + + func update(_ controller: TextViewController, coordinator: Coordinator) { controller.completionDelegate = completionDelegate - context.coordinator.updateHighlightProviders(highlightProviders) + coordinator.updateHighlightProviders(highlightProviders) - context.coordinator.textSync.text = text + coordinator.textSync.text = text if case .binding(let binding) = text { - context.coordinator.textSync.applyRepresentableText(binding.wrappedValue, controller: controller) + coordinator.textSync.applyRepresentableText(binding.wrappedValue, controller: controller) } - if !context.coordinator.phase.consumePendingEditorChange() { - context.coordinator.phase.applyRepresentableValue { - updateControllerWithState(state, controller: controller, coordinator: context.coordinator) + if !coordinator.phase.consumePendingEditorChange() { + coordinator.phase.applyRepresentableValue { + updateControllerWithState(state, controller: controller, coordinator: coordinator) } } // Do manual diffing to reduce the amount of reloads. // This helps a lot in view performance, as it otherwise gets triggered on each environment change. - guard !paramsAreEqual(controller: controller, coordinator: context.coordinator) else { + guard !paramsAreEqual(controller: controller, coordinator: coordinator) else { return } @@ -177,10 +185,9 @@ public struct SourceEditor: NSViewControllerRepresentable { controller.language = language } controller.configuration = configuration - updateHighlighting(controller, coordinator: context.coordinator) + updateHighlighting(controller, coordinator: coordinator) controller.reloadUI() - return } /// Compares against ``Coordinator/lastSyncedState``, never the editor's live state, which runs ahead of the binding diff --git a/apple/Packages/TableProEditor/Sources/TableProEditorKit/SourceEditor/SourceEditorHost.swift b/apple/Packages/TableProEditor/Sources/TableProEditorKit/SourceEditor/SourceEditorHost.swift new file mode 100644 index 0000000000..c748ce3c59 --- /dev/null +++ b/apple/Packages/TableProEditor/Sources/TableProEditorKit/SourceEditor/SourceEditorHost.swift @@ -0,0 +1,71 @@ +// +// SourceEditorHost.swift +// TableProEditorKit +// + +import AppKit +import SwiftUI + +/// ``SourceEditor`` for an AppKit container: the same controller, two-way sync and teardown, with no SwiftUI host +/// around the editor. +@MainActor +public final class SourceEditorHost { + public let controller: TextViewController + /// Runs when the editor writes its state, before the write is applied back, as `onChange` does in SwiftUI. + public var onStateChange: ((_ old: SourceEditorState, _ new: SourceEditorState) -> Void)? + + private let box: StateBox + private let coordinator: SourceEditor.Coordinator + private var editor: SourceEditor + + public var state: SourceEditorState { box.state } + + public init(state: SourceEditorState = SourceEditorState(), _ makeEditor: (Binding) -> SourceEditor) { + let box = StateBox(state: state) + let editor = makeEditor(box.binding) + let coordinator = editor.makeCoordinator() + self.box = box + self.editor = editor + self.coordinator = coordinator + controller = editor.makeController(coordinator: coordinator) + box.onWrite = { [weak self] old, new in self?.stateDidChange(from: old, to: new) } + } + + public func update(_ makeEditor: (Binding) -> SourceEditor) { + editor = makeEditor(box.binding) + editor.update(controller, coordinator: coordinator) + } + + public func dismantle() { + SourceEditor.dismantleNSViewController(controller, coordinator: coordinator) + } + + // A SwiftUI state write leads to an update pass that consumes the editor's change latch; without one the latch + // would swallow the next text the owner pushes. + private func stateDidChange(from old: SourceEditorState, to new: SourceEditorState) { + onStateChange?(old, new) + editor.update(controller, coordinator: coordinator) + } +} + +@MainActor +private final class StateBox { + var state: SourceEditorState + var onWrite: ((SourceEditorState, SourceEditorState) -> Void)? + + init(state: SourceEditorState) { + self.state = state + } + + var binding: Binding { + Binding( + get: { [weak self] in self?.state ?? SourceEditorState() }, + set: { [weak self] newValue in + guard let self else { return } + let old = state + state = newValue + onWrite?(old, newValue) + } + ) + } +} diff --git a/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/SourceEditorHostTests.swift b/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/SourceEditorHostTests.swift new file mode 100644 index 0000000000..10213253ef --- /dev/null +++ b/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/SourceEditorHostTests.swift @@ -0,0 +1,112 @@ +// +// SourceEditorHostTests.swift +// TableProEditorKitTests +// +// An AppKit container gets the editor with the same two-way sync SwiftUI gives it. A state write the editor makes +// latches its change; under SwiftUI the write's update pass consumes the latch, and here the host has to. +// + +import AppKit +import SwiftUI +@testable import TableProEditorKit +import Testing + +@MainActor +private final class Owner { + var text: String + var reportedStates: [SourceEditorState] = [] + let coordinator: RecordingCoordinator + + init(text: String, coordinator: RecordingCoordinator = RecordingCoordinator()) { + self.text = text + self.coordinator = coordinator + } + + var textBinding: Binding { + Binding(get: { self.text }, set: { self.text = $0 }) + } + + func editor(_ state: Binding) -> SourceEditor { + SourceEditor( + textBinding, + language: .default, + configuration: Mock.config(), + state: state, + highlightProviders: [], + coordinators: [coordinator] + ) + } +} + +private final class RecordingCoordinator: TextViewCoordinator { + private(set) var destroyCount = 0 + + func prepareCoordinator(controller: TextViewController) {} + + func destroy() { + destroyCount += 1 + } +} + +@MainActor +struct SourceEditorHostTests { + private func mount(_ owner: Owner) -> (SourceEditorHost, NSWindow) { + let host = SourceEditorHost { owner.editor($0) } + host.onStateChange = { _, new in owner.reportedStates.append(new) } + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 600, height: 400), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.contentView = host.controller.view + window.contentView?.layoutSubtreeIfNeeded() + return (host, window) + } + + @Test("The owner's text reaches the editor, and an edit reaches the owner") + func textSyncsBothWays() { + let owner = Owner(text: "SELECT 1") + let (host, window) = mount(owner) + defer { window.orderOut(nil) } + #expect(host.controller.textView.string == "SELECT 1") + + owner.text = "SELECT 2" + host.update { owner.editor($0) } + #expect(host.controller.textView.string == "SELECT 2") + + host.controller.textView.replaceCharacters(in: NSRange(location: 8, length: 0), with: "0") + #expect(owner.text == "SELECT 20") + } + + @Test("A caret the editor reports is passed on and does not swallow the owner's next text") + func stateWriteKeepsTheNextTextPush() { + let owner = Owner(text: "SELECT 1") + let (host, window) = mount(owner) + defer { window.orderOut(nil) } + + let caret = NSRange(location: 3, length: 0) + host.controller.setCursorPositions([CursorPosition(range: caret)]) + let held = host.state.cursorPositions?.map { $0.range } + let reported = owner.reportedStates.last?.cursorPositions?.map { $0.range } + #expect(held == [caret]) + #expect(reported == [caret]) + + owner.text = "SELECT 42" + host.update { owner.editor($0) } + #expect(host.controller.textView.string == "SELECT 42") + } + + @Test("dismantle destroys each text coordinator once") + func dismantleDestroysCoordinatorsOnce() { + let owner = Owner(text: "SELECT 1") + let (host, window) = mount(owner) + defer { window.orderOut(nil) } + + host.dismantle() + + #expect(owner.coordinator.destroyCount == 1) + #expect(host.controller.textCoordinators.values().isEmpty) + } +} diff --git a/apple/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift b/apple/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift index e043dd0e8c..59965896d2 100644 --- a/apple/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift +++ b/apple/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift @@ -12,7 +12,7 @@ internal final class SidebarContainerViewController: NSViewController { /// Which list the rows below show. Window chrome like the field under it, so it stands through /// a connection switch and follows the connection on screen. private let scopeControl = SidebarScopeControl() - private let searchField = NSSearchField() + private let searchField = SidebarFilterField() /// Sidebar chrome, like the field it shares a row with, so it survives a connection switch and /// writes settings that are not scoped to one. Hidden on the Favorites tab, whose list draws /// none of what these options settle. @@ -121,6 +121,7 @@ internal final class SidebarContainerViewController: NSViewController { searchField.controlSize = .regular searchField.sendsSearchStringImmediately = true searchField.delegate = self + searchField.onFocus = { [weak self] in self?.writeFilterFocus(true) } searchField.setAccessibilityIdentifier("sidebar-filter") searchField.setAccessibilityLabel(String(localized: "Filter")) @@ -234,6 +235,8 @@ internal final class SidebarContainerViewController: NSViewController { /// being repointed at a different one. filterPopover?.close() filterPopover = nil + // The connection the field leaves would never hear its editing end. + if state !== sidebarState { writeFilterFocus(false) } self.sidebarState = state guard let state else { /// `syncFromState` is the only writer of these, and it cannot run without a state, so @@ -328,6 +331,10 @@ extension SidebarContainerViewController: NSSearchFieldDelegate { writeSearchText("") } + func controlTextDidEndEditing(_ obj: Notification) { + writeFilterFocus(false) + } + /// Down from the filter field hands focus to the list. /// /// It names the list rather than asking the key view loop for whatever comes next. With the @@ -350,6 +357,24 @@ extension SidebarContainerViewController: NSSearchFieldDelegate { sidebarState.favoritesSearchText = text } } + + // Favorites filters its own list and never reads the all-schema listing, so only Tables marks focus. + private func writeFilterFocus(_ isFocused: Bool) { + guard let sidebarState, sidebarState.isFilterFieldFocused != isFocused else { return } + guard !isFocused || sidebarState.selectedSidebarTab == .tables else { return } + sidebarState.isFilterFieldFocused = isFocused + } +} + +// The delegate hears of editing only from the first keystroke, and a search starts at focus. +private final class SidebarFilterField: NSSearchField { + var onFocus: (() -> Void)? + + override func becomeFirstResponder() -> Bool { + let became = super.becomeFirstResponder() + if became { onFocus?() } + return became + } } private final class ObservationContinuationBox: @unchecked Sendable { diff --git a/apple/TablePro/Models/UI/SharedSidebarState.swift b/apple/TablePro/Models/UI/SharedSidebarState.swift index 3aa2c71cb8..3b8d902ced 100644 --- a/apple/TablePro/Models/UI/SharedSidebarState.swift +++ b/apple/TablePro/Models/UI/SharedSidebarState.swift @@ -28,6 +28,7 @@ final class SharedSidebarState: ObservableObject { @Published var searchText: String = "" @Published var favoritesSearchText: String = "" + @Published var isFilterFieldFocused = false @Published var recentTables: [RecentTableEntry] = [] diff --git a/apple/TablePro/ViewModels/SidebarViewModel.swift b/apple/TablePro/ViewModels/SidebarViewModel.swift index 11b4a9322e..92725a668d 100644 --- a/apple/TablePro/ViewModels/SidebarViewModel.swift +++ b/apple/TablePro/ViewModels/SidebarViewModel.swift @@ -101,7 +101,8 @@ final class SidebarViewModel: ObservableObject { searchTextObservation = sharedState.onMainActorChange { [weak self] in guard let self else { return } self.scheduleFilterQueryUpdate(oldValue: self.filterQuery) - // The schema filter lives there too, and an edit to it during a search selects another listing. + self.noteFilterFieldFocus() + // The field's focus and the schema filter live there too: focus starts a search, and a filter edit selects another listing. self.loadAllSchemaTablesForSearch() } } @@ -111,11 +112,33 @@ final class SidebarViewModel: ObservableObject { invalidateFilterCaches() if filterQuery.isEmpty { listingMatchesMemo.removeAll() } // Once per search, not per keystroke: a failed or partly unlisted listing was read again on every refinement. - if oldValue.isEmpty { listingDemand.reset() } + if oldValue.isEmpty { + if searchStartedAtFocus { + searchStartedAtFocus = false + } else { + listingDemand.reset() + } + } loadAllSchemaTablesForSearch() } } + private var wasFilterFieldFocused = false + // A search starts at focus or at its first character, whichever comes first, and asks for a listing once. + private var searchStartedAtFocus = false + + private func noteFilterFieldFocus() { + let isFocused = sharedState.isFilterFieldFocused + guard isFocused != wasFilterFieldFocused else { return } + wasFilterFieldFocused = isFocused + if isFocused, filterQuery.isEmpty { + listingDemand.reset() + searchStartedAtFocus = true + } else if !isFocused { + searchStartedAtFocus = false + } + } + private var listingMatchesMemo = DatabaseTreeFilter.SchemaListingMatchesMemo() private var listingDemand = AllSchemaTablesDemand() @@ -570,7 +593,8 @@ final class SidebarViewModel: ObservableObject { /// for them. It is asked for here rather than by the outline, because a flat list with no local /// match shows "No Results" in place of the outline, which then never sees the search at all. private func loadAllSchemaTablesForSearch() { - guard !filterQuery.isEmpty else { return } + // A focused field is a search about to start, and its first keystroke would otherwise wait out the read. + guard !filterQuery.isEmpty || sharedState.isFilterFieldFocused else { return } let service = DatabaseTreeMetadataService.shared let connectionId = connectionId loadAttachedSchemaObjectsForSearch(service: service) diff --git a/apple/TablePro/Views/Components/CollapsibleSplitController.swift b/apple/TablePro/Views/Components/CollapsibleSplitController.swift new file mode 100644 index 0000000000..fa480480e7 --- /dev/null +++ b/apple/TablePro/Views/Components/CollapsibleSplitController.swift @@ -0,0 +1,79 @@ +// +// CollapsibleSplitController.swift +// TablePro +// + +import AppKit + +/// A top pane over a collapsible bottom pane, divider thin and remembered. +@MainActor +internal final class CollapsibleSplitController: ResizeCursorSplitViewController { + nonisolated static let defaultTopMinimumThickness: CGFloat = 100 + nonisolated static let defaultBottomMinimumThickness: CGFloat = 150 + + /// What a split using the defaults needs before its own constraints become unsatisfiable. + /// An enclosing split whose pane hosts one of these must not promise less than this. + nonisolated static var combinedMinimumThickness: CGFloat { + defaultTopMinimumThickness + defaultBottomMinimumThickness + 10 + } + + let topItem: NSSplitViewItem + let bottomItem: NSSplitViewItem + /// The divider is draggable and double-clickable, so AppKit owns the collapsed state as much as the + /// owner does; a change the user makes is reported back rather than snapped away on the next update. + var onUserCollapseChange: ((Bool) -> Void)? + + private var lastCollapsedState: Bool + private var collapseObservation: NSKeyValueObservation? + private var isApplyingProgrammatically = false + + init( + top: NSViewController, + bottom: NSViewController, + autosaveName: String, + topMinimumThickness: CGFloat = defaultTopMinimumThickness, + bottomMinimumThickness: CGFloat = defaultBottomMinimumThickness, + isBottomCollapsed: Bool + ) { + topItem = NSSplitViewItem(viewController: top) + topItem.minimumThickness = topMinimumThickness + bottomItem = NSSplitViewItem(viewController: bottom) + bottomItem.canCollapse = true + bottomItem.minimumThickness = bottomMinimumThickness + lastCollapsedState = isBottomCollapsed + super.init(nibName: nil, bundle: nil) + + splitView.isVertical = false + splitView.dividerStyle = .thin + addSplitViewItem(topItem) + addSplitViewItem(bottomItem) + + // After the items are added; set earlier it does not record the divider, and adjustSubviews then resets it. + splitView.autosaveName = SplitViewAutosaveName.current(autosaveName) + + collapseObservation = bottomItem.observe(\.isCollapsed, options: [.new]) { [weak self] observed, _ in + nonisolated(unsafe) let observedItem = observed + MainActor.assumeIsolated { + guard let self, !self.isApplyingProgrammatically else { return } + self.lastCollapsedState = observedItem.isCollapsed + self.onUserCollapseChange?(observedItem.isCollapsed) + } + } + + // Assigned either way, so the owner's state wins over whatever the autosave record restored. + bottomItem.isCollapsed = isBottomCollapsed + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setBottomCollapsed(_ collapsed: Bool) { + guard collapsed != lastCollapsedState else { return } + lastCollapsedState = collapsed + isApplyingProgrammatically = true + defer { isApplyingProgrammatically = false } + bottomItem.animator().isCollapsed = collapsed + } +} diff --git a/apple/TablePro/Views/Components/VerticalCollapsibleSplitView.swift b/apple/TablePro/Views/Components/VerticalCollapsibleSplitView.swift deleted file mode 100644 index 1cc0d3e8dc..0000000000 --- a/apple/TablePro/Views/Components/VerticalCollapsibleSplitView.swift +++ /dev/null @@ -1,121 +0,0 @@ -import AppKit -import SwiftUI - -struct VerticalCollapsibleSplitView: NSViewControllerRepresentable { - static var defaultTopMinimumThickness: CGFloat { 100 } - static var defaultBottomMinimumThickness: CGFloat { 150 } - - /// What a split using the defaults needs before its own constraints become unsatisfiable. - /// An enclosing split whose pane hosts one of these must not promise less than this. - static var combinedMinimumThickness: CGFloat { - defaultTopMinimumThickness + defaultBottomMinimumThickness + 10 - } - - @Binding var isBottomCollapsed: Bool - var autosaveName: String - var topMinimumThickness: CGFloat = VerticalCollapsibleSplitView.defaultTopMinimumThickness - var bottomMinimumThickness: CGFloat = VerticalCollapsibleSplitView.defaultBottomMinimumThickness - @ViewBuilder var topContent: TopContent - @ViewBuilder var bottomContent: BottomContent - - func makeCoordinator() -> Coordinator { - Coordinator() - } - - func makeNSViewController(context: Context) -> NSSplitViewController { - let splitViewController = ResizeCursorSplitViewController() - splitViewController.splitView.isVertical = false - splitViewController.splitView.dividerStyle = .thin - - let topController = NSHostingController(rootView: topContent) - let topItem = NSSplitViewItem(viewController: topController) - topItem.minimumThickness = topMinimumThickness - - let bottomController = NSHostingController(rootView: bottomContent) - let bottomItem = NSSplitViewItem(viewController: bottomController) - bottomItem.canCollapse = true - bottomItem.minimumThickness = bottomMinimumThickness - - // Without this the hosting controllers report their content's ideal size as a - // preferredContentSize, which this split view forwards to the window: wide results - // content then outranks a divider drag and the editor/results divider stops moving. - topController.sizingOptions = [] - bottomController.sizingOptions = [] - - splitViewController.addSplitViewItem(topItem) - splitViewController.addSplitViewItem(bottomItem) - - // autosaveName is assigned after the items are added; setting it earlier does not record - // the divider, and adjustSubviews then resets it. - splitViewController.splitView.autosaveName = SplitViewAutosaveName.current(autosaveName) - - context.coordinator.topController = topController - context.coordinator.bottomController = bottomController - context.coordinator.bottomItem = bottomItem - context.coordinator.lastCollapsedState = isBottomCollapsed - context.coordinator.onUserCollapseChange = { collapsed in - guard isBottomCollapsed != collapsed else { return } - isBottomCollapsed = collapsed - } - context.coordinator.observeCollapse(of: bottomItem) - - /// Assigned either way, so the tab's own state wins over whatever the autosave record - /// restored. Collapsing only when the binding says so let a record that was saved collapsed - /// leave a pane collapsed against a tab whose `isResultsCollapsed` is false, and nothing - /// afterwards expanded it. - bottomItem.isCollapsed = isBottomCollapsed - - return splitViewController - } - - func updateNSViewController(_ splitViewController: NSSplitViewController, context: Context) { - context.coordinator.topController?.rootView = topContent - context.coordinator.bottomController?.rootView = bottomContent - - guard let bottomItem = context.coordinator.bottomItem else { return } - let wasCollapsed = context.coordinator.lastCollapsedState - - context.coordinator.onUserCollapseChange = { collapsed in - guard isBottomCollapsed != collapsed else { return } - isBottomCollapsed = collapsed - } - - guard isBottomCollapsed != wasCollapsed else { return } - context.coordinator.lastCollapsedState = isBottomCollapsed - context.coordinator.applyProgrammatically { - bottomItem.animator().isCollapsed = isBottomCollapsed - } - } - - /// The divider is draggable and double-clickable, so AppKit owns this state as much as - /// SwiftUI does. Observing it back keeps the persisted value honest instead of letting the - /// two drift until the next programmatic toggle snaps the pane back. - @MainActor - final class Coordinator { - var topController: NSHostingController? - var bottomController: NSHostingController? - var bottomItem: NSSplitViewItem? - var lastCollapsedState = false - var onUserCollapseChange: ((Bool) -> Void)? - - private var collapseObservation: NSKeyValueObservation? - private var isApplyingProgrammatically = false - - func observeCollapse(of item: NSSplitViewItem) { - collapseObservation = item.observe(\.isCollapsed, options: [.new]) { [weak self] observed, _ in - nonisolated(unsafe) let observedItem = observed - MainActor.assumeIsolated { - guard let self, !self.isApplyingProgrammatically else { return } - self.lastCollapsedState = observedItem.isCollapsed - self.onUserCollapseChange?(observedItem.isCollapsed) - } - } - } - - func applyProgrammatically(_ body: () -> Void) { - isApplyingProgrammatically = true - defer { isApplyingProgrammatically = false } - body() - } - } -} diff --git a/apple/TablePro/Views/Editor/QueryEditorPaneController.swift b/apple/TablePro/Views/Editor/QueryEditorPaneController.swift new file mode 100644 index 0000000000..0e2ad9d64e --- /dev/null +++ b/apple/TablePro/Views/Editor/QueryEditorPaneController.swift @@ -0,0 +1,91 @@ +// +// QueryEditorPaneController.swift +// TablePro +// + +import AppKit +import Combine +import SwiftUI +import TableProEditorKit + +/// What the query tab's editor pane shows: the header above the editor, and the editor's input. +struct QueryEditorPaneInput { + /// The banner, the command bar and the parameter panel, given the Vim mode to show or nil. + var header: (VimMode?) -> AnyView + var editor: SQLEditorInput +} + +@MainActor +final class QueryEditorPaneModel: ObservableObject { + @Published var vimMode: VimMode = .normal +} + +struct QueryEditorHeader: View { + @ObservedObject private var settingsManager = AppSettingsManager.shared + @ObservedObject var model: QueryEditorPaneModel + let content: (VimMode?) -> AnyView + + var body: some View { + content(settingsManager.editor.keyBindings == .vim ? model.vimMode : nil) + } +} + +/// The SQL editor under its command bar. The editor takes whatever height the header leaves, with no minimum of its +/// own: the pane bottoms out at the query split's top minimum, and an editor that also insisted on one overflowed it. +@MainActor +final class QueryEditorPaneController: NSViewController { + let editor: SQLEditorController + private let model = QueryEditorPaneModel() + let header: NSHostingView + + init(input: QueryEditorPaneInput) { + editor = SQLEditorController(input: input.editor) + header = NSHostingView(rootView: QueryEditorHeader(model: model, content: input.header)) + super.init(nibName: nil, bundle: nil) + editor.onVimModeChange = { [weak self] mode in self?.model.vimMode = mode } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + let root = NSView() + header.sizingOptions = [.intrinsicContentSize] + header.translatesAutoresizingMaskIntoConstraints = false + header.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + header.setContentHuggingPriority(.defaultLow, for: .horizontal) + header.setContentHuggingPriority(.required, for: .vertical) + root.addSubview(header) + + let textController = editor.textViewController + addChild(textController) + let editorView = textController.view + editorView.translatesAutoresizingMaskIntoConstraints = false + if #available(macOS 14.0, *) { + editorView.clipsToBounds = true + } + root.addSubview(editorView) + + NSLayoutConstraint.activate([ + header.topAnchor.constraint(equalTo: root.topAnchor), + header.leadingAnchor.constraint(equalTo: root.leadingAnchor), + header.trailingAnchor.constraint(equalTo: root.trailingAnchor), + editorView.topAnchor.constraint(equalTo: header.bottomAnchor), + editorView.leadingAnchor.constraint(equalTo: root.leadingAnchor), + editorView.trailingAnchor.constraint(equalTo: root.trailingAnchor), + editorView.bottomAnchor.constraint(equalTo: root.bottomAnchor) + ]) + view = root + } + + func apply(_ input: QueryEditorPaneInput) { + header.rootView = QueryEditorHeader(model: model, content: input.header) + editor.apply(input.editor) + } + + func teardown() { + editor.teardown() + } +} diff --git a/apple/TablePro/Views/Editor/QueryEditorView.swift b/apple/TablePro/Views/Editor/QueryEditorView.swift deleted file mode 100644 index bf5710de0c..0000000000 --- a/apple/TablePro/Views/Editor/QueryEditorView.swift +++ /dev/null @@ -1,118 +0,0 @@ -// -// QueryEditorView.swift -// TablePro -// - -import SwiftUI -import TableProEditorKit -import TableProPluginKit - -/// The SQL editor, its command bar, and the banners that belong to the document it holds. -struct QueryEditorView: View { - @ObservedObject private var settingsManager = AppSettingsManager.shared - @Binding var queryText: String - @Binding var cursorPositions: [CursorPosition] - @Binding var parameters: [QueryParameter] - @Binding var isParameterPanelVisible: Bool - var schemaProvider: SQLSchemaProvider? - var databaseType: DatabaseType? - var databaseScope: DatabaseScope? - var connectionId: UUID? - var tabID: UUID? - var sourceFileURL: URL? - var claimFocusOnAppear: Bool = false - var onFocusClaimed: (() -> Void)? - var restoredCursorRange: NSRange? - var pendingStatementJump: StatementAnchor? - var onStatementJumpHandled: (() -> Void)? - var restoredFoldRanges: [Range]? - var onFoldRangesChanged: (([Range]) -> Void)? - var onCloseTab: (() -> Void)? - var onExecuteQuery: (() -> Void)? - var onRunStatement: ((String, Int) -> Bool)? - var isExecuting: Bool = false - var currentAIAvailability: (() -> AIQueryActionAvailability)? - var onAIAction: ((AIQueryAction, AIQueryTarget) -> Void)? - var onSaveAsFavorite: ((String) -> Void)? - - let scope: QueryScopeBarModel - let commands: QueryCommandAvailability - var showsHistoryTip: Bool = false - var onRun: () -> Void - var onRunAllStatements: () -> Void - var onRunWithoutLimit: () -> Void - var onStop: () -> Void - var onExplain: (ExplainVariant?) -> Void - var onFormat: () -> Void - var onSaveAsFavoriteCommand: () -> Void - var onClearQuery: () -> Void - var onClearResults: () -> Void - var onContainerChanged: (String) -> Void - - @State private var vimMode: VimMode = .normal - - /// The editor takes whatever height the bar above it leaves, with no minimum of its own. The - /// query split's editor pane bottoms out at `VerticalCollapsibleSplitView.defaultTopMinimumThickness`, - /// and an editor that also insisted on that much under a bar asked for more than the pane can - /// ever be: at the pane's minimum the stack overflowed it, the bar was cut off at the top and the - /// editor's text ran up over it. - var body: some View { - VStack(alignment: .leading, spacing: 0) { - QueryEditorBar( - scope: scope, - commands: commands, - isExecuting: isExecuting, - vimMode: settingsManager.editor.keyBindings == .vim ? vimMode : nil, - showsHistoryTip: showsHistoryTip, - onAIAction: { action in onAIAction?(action, .selectionOrStatementAtCursor) }, - onRun: onRun, - onRunAllStatements: onRunAllStatements, - onRunWithoutLimit: onRunWithoutLimit, - onStop: onStop, - onExplain: onExplain, - onFormat: onFormat, - onSaveAsFavorite: onSaveAsFavoriteCommand, - onClearQuery: onClearQuery, - onClearResults: onClearResults, - onContainerChanged: onContainerChanged - ) - - Divider() - - if isParameterPanelVisible && !parameters.isEmpty { - QueryParameterPanelView( - parameters: $parameters, - onDismiss: { isParameterPanelVisible = false } - ) - Divider() - } - - SQLEditorView( - text: $queryText, - cursorPositions: $cursorPositions, - schemaProvider: schemaProvider, - databaseType: databaseType, - databaseScope: databaseScope, - connectionId: connectionId, - tabID: tabID, - sourceFileURL: sourceFileURL, - claimFocusOnAppear: claimFocusOnAppear, - onFocusClaimed: onFocusClaimed, - restoredCursorRange: restoredCursorRange, - pendingStatementJump: pendingStatementJump, - onStatementJumpHandled: onStatementJumpHandled, - restoredFoldRanges: restoredFoldRanges, - onFoldRangesChanged: onFoldRangesChanged, - vimMode: $vimMode, - onCloseTab: onCloseTab, - onExecuteQuery: onExecuteQuery, - onRunStatement: onRunStatement, - isExecuting: isExecuting, - currentAIAvailability: currentAIAvailability, - onAIAction: onAIAction, - onSaveAsFavorite: onSaveAsFavorite - ) - .clipped() - } - } -} diff --git a/apple/TablePro/Views/Editor/SQLEditorController.swift b/apple/TablePro/Views/Editor/SQLEditorController.swift new file mode 100644 index 0000000000..5afcebd0d1 --- /dev/null +++ b/apple/TablePro/Views/Editor/SQLEditorController.swift @@ -0,0 +1,361 @@ +// +// SQLEditorController.swift +// TablePro +// + +import AppKit +import Combine +import SwiftUI +import TableProEditorKit +import TableProGrammars +import TableProPluginKit +import TableProTextEngine + +/// What a query tab hands the SQL editor on every update. +struct SQLEditorInput { + var text: Binding + var cursorPositions: Binding<[CursorPosition]> + var schemaProvider: SQLSchemaProvider? + var databaseType: DatabaseType? + var databaseScope: DatabaseScope? + var connectionId: UUID? + var tabID: UUID? + var sourceFileURL: URL? + var claimFocusOnAppear = false + /// Called once the editor has latched a focus claim, so the owner's one-shot intent is cleared. + var onFocusClaimed: (() -> Void)? + var restoredCursorRange: NSRange? + var pendingStatementJump: StatementAnchor? + var onStatementJumpHandled: (() -> Void)? + var restoredFoldRanges: [Range]? + var onFoldRangesChanged: (([Range]) -> Void)? + var onCloseTab: (() -> Void)? + var onExecuteQuery: (() -> Void)? + var onRunStatement: ((String, Int) -> Bool)? + /// A tab runs one thing at a time, so the gutter's run controls go dim for the length of a query. + var isExecuting = false + var currentAIAvailability: (() -> AIQueryActionAvailability)? + var onAIAction: ((AIQueryAction, AIQueryTarget) -> Void)? + var onSaveAsFavorite: ((String) -> Void)? +} + +/// The SQL editor in AppKit, so the query tab hosts it with no SwiftUI host around it: each one above an editor +/// forced the window to lay out every hosting view again as the editor's views went in. +@MainActor +final class SQLEditorController { + let coordinator = SQLEditorCoordinator() + /// Reports Vim's mode to whatever draws it beside the editor. + var onVimModeChange: ((VimMode) -> Void)? + + private let completionAdapter = QueryCompletionAdapter(schemaProvider: nil, databaseType: nil) + private var host: SourceEditorHost? + private var input: SQLEditorInput + private var configuration = SQLEditorController.makeConfiguration() + private var completionProfile: QueryCompletionProfile? + private var observedProfileRevision = 0 + private var profileRequest: CompletionProfileRequest? + private var profileTask: Task? + private var revisionSubscription: AnyCancellable? + private var favoritesCancellables: Set = [] + private var pendingCaret: [CursorPosition]? + private var cancellables: Set = [] + private var isTornDown = false + + var textViewController: TextViewController { + guard let host else { preconditionFailure("SQLEditorController used before init finished") } + return host.controller + } + + init(input: SQLEditorInput) { + self.input = input + applyInput(input) + let host = SourceEditorHost { state in self.makeEditor(state: state) } + host.onStateChange = { [weak self] old, new in self?.editorStateDidChange(from: old, to: new) } + self.host = host + + let view = host.controller.view + view.setAccessibilityElement(true) + view.setAccessibilityRole(.group) + view.setAccessibilityLabel(String(localized: "SQL query editor")) + view.setAccessibilityIdentifier("sql-editor-textview") + + observeEditorSettings() + observeVimMode() + configureCompletion() + setupFavoritesObserver() + subscribeToCompletionRevision() + resolveCompletionProfileIfNeeded() + } + + func apply(_ newInput: SQLEditorInput) { + guard !isTornDown else { return } + let old = input + input = newInput + applyInput(newInput) + pushEditor() + + if newInput.pendingStatementJump != old.pendingStatementJump, let jump = newInput.pendingStatementJump { + coordinator.jumpToStatement(jump) + newInput.onStatementJumpHandled?() + } + if newInput.tabID != old.tabID { + pendingCaret = nil + coordinator.repointFolds(to: newInput.restoredFoldRanges) + } + if newInput.connectionId != old.connectionId { + configureCompletion() + setupFavoritesObserver() + } + /// A tab rebound to another database keeps its editor and its connection, so only the scope moves. Without + /// this the editor keeps completing against the previous database's provider until the profile resolution + /// returns, which leases a metadata driver and on a non-poolable engine can queue behind a running query. + if newInput.databaseScope != old.databaseScope { + completionProfile = nil + configureCompletion() + subscribeToCompletionRevision() + } + resolveCompletionProfileIfNeeded() + } + + /// The editor's terminal teardown, run when its query tab area goes away for good, never when it only leaves + /// the window. + func teardown() { + guard !isTornDown else { return } + isTornDown = true + profileTask?.cancel() + profileTask = nil + revisionSubscription = nil + favoritesCancellables.removeAll() + cancellables.removeAll() + host?.dismantle() + } + + private func applyInput(_ input: SQLEditorInput) { + coordinator.onCloseTab = input.onCloseTab + coordinator.onExecuteQuery = input.onExecuteQuery + coordinator.onRunStatement = input.onRunStatement + coordinator.setStatementRunControlsEnabled(!input.isExecuting) + coordinator.setStatementHighlightEnabled(AppSettingsManager.shared.editor.highlightCurrentStatement) + coordinator.currentAIAvailability = input.currentAIAvailability + coordinator.onAIAction = input.onAIAction + coordinator.onSaveAsFavorite = input.onSaveAsFavorite + coordinator.schemaProvider = input.schemaProvider + coordinator.databaseType = input.databaseType + coordinator.tabID = input.tabID + coordinator.connectionId = input.connectionId + coordinator.sourceFileURL = input.sourceFileURL + if input.claimFocusOnAppear { + coordinator.scheduleEditorFocusClaim() + input.onFocusClaimed?() + } + if let restoredCursorRange = input.restoredCursorRange { + coordinator.scheduleCursorRestore(restoredCursorRange) + } + if let restoredFoldRanges = input.restoredFoldRanges { + coordinator.scheduleFoldRestore(restoredFoldRanges) + } + } + + private func makeEditor(state: Binding) -> SourceEditor { + let databaseType = input.databaseType ?? .mysql + let text = Binding( + get: { [weak self] in self?.input.text.wrappedValue ?? "" }, + set: { [weak self] newValue in + self?.input.text.wrappedValue = newValue + self?.passPendingCaretOn() + } + ) + return SourceEditor( + text, + language: PluginManager.shared.editorLanguage(for: databaseType).treeSitterLanguage, + configuration: configuration, + state: state, + foldProvider: FoldProviderResolver.provider(for: databaseType), + coordinators: [coordinator], + completionDelegate: completionAdapter + ) + } + + private func pushEditor() { + host?.update { state in self.makeEditor(state: state) } + } + + private func editorStateDidChange(from old: SourceEditorState, to new: SourceEditorState) { + if new.cursorPositions != old.cursorPositions, let positions = new.cursorPositions { + pendingCaret = positions + passPendingCaretOn() + } + if new.collapsedFoldRanges != old.collapsedFoldRanges { + input.onFoldRangesChanged?(new.collapsedFoldRanges ?? []) + } + } + + // An edit reports its caret before its text is written back, so the caret waits for the text; one from a document + // the binding does not hold, as across a tab switch, is never passed on. + private func passPendingCaretOn() { + guard let positions = pendingCaret else { return } + let editorLength = (textViewController.textView.string as NSString).length + guard editorLength == (input.text.wrappedValue as NSString).length else { return } + pendingCaret = nil + input.cursorPositions.wrappedValue = positions + } + + // MARK: - Settings + + private func observeEditorSettings() { + // `$editor` publishes before the value is stored; the theme reads the stored settings too. + AppSettingsManager.shared.$editor + .dropFirst() + .receive(on: RunLoop.main) + .sink { [weak self] editor in + guard let self else { return } + self.coordinator.setStatementHighlightEnabled(editor.highlightCurrentStatement) + self.reloadConfiguration() + } + .store(in: &cancellables) + AppEvents.shared.accessibilityTextSizeChanged + .sink { [weak self] _ in self?.reloadConfiguration() } + .store(in: &cancellables) + AppEvents.shared.themeChanged + .sink { [weak self] _ in self?.reloadConfiguration() } + .store(in: &cancellables) + } + + private func reloadConfiguration() { + guard !isTornDown else { return } + configuration = Self.makeConfiguration() + pushEditor() + } + + private func observeVimMode() { + coordinator.$vimMode + .dropFirst() + .removeDuplicates() + .sink { [weak self] mode in self?.onVimModeChange?(mode) } + .store(in: &cancellables) + } + + private static func makeConfiguration() -> SourceEditorConfiguration { + SourceEditorConfiguration( + appearance: .init( + theme: ThemeEngine.shared.makeEditorTheme(), + font: ThemeEngine.shared.editorFonts.font, + wrapLines: AppSettingsManager.shared.editor.wordWrap, + tabWidth: AppSettingsManager.shared.editor.clampedTabWidth + ), + behavior: .init( + indentOption: .spaces(count: AppSettingsManager.shared.editor.clampedTabWidth) + ), + layout: .init( + contentInsets: NSEdgeInsets(top: 0, left: 0, bottom: 8, right: 0) + ), + peripherals: EditorPeripherals.editor( + lineNumbers: AppSettingsManager.shared.editor.showLineNumbers, + folding: AppSettingsManager.shared.editor.codeFoldingEnabled, + statementRunControls: AppSettingsManager.shared.editor.showStatementRunControls, + invisibleCharacters: AppSettingsManager.shared.editor.showInvisibleCharacters + ) + ) + } + + // MARK: - Completion + + /// The one place the completion service is configured. Configuring without a profile anywhere else put the + /// editor back on the app's own dialect after the resolver had run, with nothing scheduled to correct it. + private func configureCompletion() { + completionAdapter.configure( + schemaProvider: input.schemaProvider, + databaseType: input.databaseType, + profile: completionProfile, + connectionId: input.connectionId + ) + } + + /// This scope's box alone, never the registry: the registry publishes on every fetch, and resolving again for + /// each of them would run while the user types. A `@Published` publisher delivers its current value on + /// subscribe, so the revision starts in step with the box. + private func subscribeToCompletionRevision() { + revisionSubscription = nil + guard let scope = input.databaseScope else { return } + revisionSubscription = QueryCompletionProfileRegistry.shared.revisionBox(for: scope).$revision + .sink { [weak self] revision in + guard let self else { return } + self.observedProfileRevision = revision + self.resolveCompletionProfileIfNeeded() + } + } + + private var completionProfileRequest: CompletionProfileRequest? { + guard let databaseScope = input.databaseScope, let databaseType = input.databaseType else { return nil } + return CompletionProfileRequest( + scope: databaseScope, + databaseType: databaseType, + profileRevision: observedProfileRevision + ) + } + + /// The revision is part of the request, so an invalidation of this scope restarts the resolution. + private func resolveCompletionProfileIfNeeded() { + let request = completionProfileRequest + guard request != profileRequest, !isTornDown else { return } + profileRequest = request + profileTask?.cancel() + guard let request else { + profileTask = nil + return + } + profileTask = Task { [weak self] in + let profile = await QueryCompletionProfileRegistry.shared.profile( + for: request.scope, + databaseType: request.databaseType + ) + guard !Task.isCancelled, let self else { return } + self.completionProfile = profile + self.configureCompletion() + } + } + + // MARK: - Favorites + + private func setupFavoritesObserver() { + favoritesCancellables.removeAll() + refreshFavoriteKeywords() + let adapter = completionAdapter + let connId = input.connectionId + let refresh: () -> Void = { + Task { @MainActor in + let keywords = await SQLFavoriteManager.shared.fetchKeywordMap(connectionId: connId) + adapter.updateFavoriteKeywords(keywords) + } + } + AppEvents.shared.sqlFavoritesDidUpdate + .receive(on: RunLoop.main) + .sink { payload in + guard payload == nil || payload == connId else { return } + refresh() + } + .store(in: &favoritesCancellables) + AppEvents.shared.linkedSQLFoldersDidUpdate + .receive(on: RunLoop.main) + .sink { payload in + guard payload == nil || payload == connId else { return } + refresh() + } + .store(in: &favoritesCancellables) + } + + private func refreshFavoriteKeywords() { + let connId = input.connectionId + let adapter = completionAdapter + Task { @MainActor in + let keywords = await SQLFavoriteManager.shared.fetchKeywordMap(connectionId: connId) + adapter.updateFavoriteKeywords(keywords) + } + } +} + +private struct CompletionProfileRequest: Hashable { + let scope: DatabaseScope + let databaseType: DatabaseType + let profileRevision: Int +} diff --git a/apple/TablePro/Views/Editor/SQLEditorView.swift b/apple/TablePro/Views/Editor/SQLEditorView.swift deleted file mode 100644 index 0892631e30..0000000000 --- a/apple/TablePro/Views/Editor/SQLEditorView.swift +++ /dev/null @@ -1,295 +0,0 @@ -// -// SQLEditorView.swift -// TablePro -// -// SwiftUI wrapper for TableProEditorKit-based SQL editor -// - -import AppKit -import Combine -import SwiftUI -import TableProEditorKit -import TableProGrammars -import TableProPluginKit -import TableProTextEngine - -// MARK: - SQLEditorView - -/// SwiftUI SQL editor powered by TableProEditorKit -struct SQLEditorView: View { - @ObservedObject private var settingsManager = AppSettingsManager.shared - @ObservedObject private var themeEngine = ThemeEngine.shared - @Binding var text: String - @Binding var cursorPositions: [CursorPosition] - @State private var completionProfile: QueryCompletionProfile? - @State private var observedProfileRevision = 0 - var schemaProvider: SQLSchemaProvider? - var databaseType: DatabaseType? - var databaseScope: DatabaseScope? - var connectionId: UUID? - var tabID: UUID? - var sourceFileURL: URL? - var claimFocusOnAppear: Bool = false - /// Called once the editor has latched a focus claim. The owner's one-shot intent is cleared - /// here rather than on its own `onAppear`, which fires before this subtree renders. - var onFocusClaimed: (() -> Void)? - var restoredCursorRange: NSRange? - var pendingStatementJump: StatementAnchor? - var onStatementJumpHandled: (() -> Void)? - var restoredFoldRanges: [Range]? - var onFoldRangesChanged: (([Range]) -> Void)? - @Binding var vimMode: VimMode - var onCloseTab: (() -> Void)? - var onExecuteQuery: (() -> Void)? - var onRunStatement: ((String, Int) -> Bool)? - /// A tab runs one thing at a time, so the gutter's run controls go dim for the length of a query. - var isExecuting: Bool = false - var currentAIAvailability: (() -> AIQueryActionAvailability)? - var onAIAction: ((AIQueryAction, AIQueryTarget) -> Void)? - var onSaveAsFavorite: ((String) -> Void)? - - @State private var editorState = SourceEditorState() - @State private var completionAdapter = QueryCompletionAdapter(schemaProvider: nil, databaseType: nil) - @StateObject private var coordinator = SQLEditorCoordinator() - @State private var editorConfiguration = makeConfiguration() - @State private var favoritesCancellables: Set = [] - - var body: some View { - // Keep callbacks fresh on every parent re-render - coordinator.onCloseTab = onCloseTab - coordinator.onExecuteQuery = onExecuteQuery - coordinator.onRunStatement = onRunStatement - coordinator.setStatementRunControlsEnabled(!isExecuting) - coordinator.setStatementHighlightEnabled(settingsManager.editor.highlightCurrentStatement) - coordinator.currentAIAvailability = currentAIAvailability - coordinator.onAIAction = onAIAction - coordinator.onSaveAsFavorite = onSaveAsFavorite - coordinator.schemaProvider = schemaProvider - coordinator.databaseType = databaseType - coordinator.tabID = tabID - coordinator.connectionId = connectionId - coordinator.sourceFileURL = sourceFileURL - if claimFocusOnAppear { - coordinator.scheduleEditorFocusClaim() - onFocusClaimed?() - } - if let restoredCursorRange { - coordinator.scheduleCursorRestore(restoredCursorRange) - } - if let restoredFoldRanges { - coordinator.scheduleFoldRestore(restoredFoldRanges) - } - - return SourceEditor( - $text, - language: PluginManager.shared.editorLanguage(for: databaseType ?? .mysql).treeSitterLanguage, - configuration: editorConfiguration, - state: $editorState, - foldProvider: FoldProviderResolver.provider(for: databaseType ?? .mysql), - coordinators: [coordinator], - completionDelegate: completionAdapter - ) - .accessibilityLabel(String(localized: "SQL query editor")) - .accessibilityIdentifier("sql-editor-textview") - /// Applied on change rather than while building the view: this is an event, and an editor that is already - /// mounted never rebuilds from scratch to notice a new value. Cleared whether or not the statement was still - /// there, so a request that cannot be honoured does not sit pending and block the next one. - .onChange(of: pendingStatementJump) { newValue in - guard let newValue else { return } - coordinator.jumpToStatement(newValue) - onStatementJumpHandled?() - } - .onChange(of: editorState.cursorPositions) { newValue in - guard let positions = newValue else { return } - // Guard against stale propagation during tab switch (.id() recreation): - // verify the editor's text still matches the binding before propagating. - // Use O(1) length pre-check to avoid O(n) string comparison on large docs. - if let controller = coordinator.controller { - let currentString = controller.textView.string as NSString - let bindingString = text as NSString - if currentString.length != bindingString.length { - return - } - } - cursorPositions = positions - } - .onChange(of: editorState.collapsedFoldRanges) { newValue in - onFoldRangesChanged?(newValue ?? []) - } - .onChange(of: tabID) { _ in - coordinator.repointFolds(to: restoredFoldRanges) - } - .onChange(of: connectionId) { _ in - configureCompletion() - setupFavoritesObserver() - } - /// A tab rebound to another database keeps its view and its connection, so only the scope - /// moves. Without this the editor keeps completing against the previous database's - /// provider until the profile resolution returns, which leases a metadata driver and on a - /// non-poolable engine can queue behind a running query. - .onChange(of: databaseScope) { _ in - completionProfile = nil - configureCompletion() - } - .onReceive(completionRevisionChanges) { revision in - observedProfileRevision = revision - } - .task(id: completionProfileRequest) { - await resolveCompletionProfile() - } - .onChange(of: settingsManager.editor) { _ in - editorConfiguration = Self.makeConfiguration() - } - .onReceive(AppEvents.shared.accessibilityTextSizeChanged) { _ in - editorConfiguration = Self.makeConfiguration() - } - .onReceive(AppEvents.shared.themeChanged) { _ in - editorConfiguration = Self.makeConfiguration() - } - .onAppear { - initializeEditor() - } - .onDisappear { - teardownFavoritesObserver() - } - .onChange(of: coordinator.vimMode) { newMode in - vimMode = newMode - } - } - - // MARK: - Initialization - - private func initializeEditor() { - configureCompletion() - setupFavoritesObserver() - } - - /// The one place the completion service is configured. Appear and the connection change used - /// to configure without a profile, so whichever of them ran after the resolver silently put - /// the editor back on the app's own dialect, with nothing scheduled to correct it: the - /// resolving `.task` re-runs only when its request identity changes. - private func configureCompletion() { - completionAdapter.configure( - schemaProvider: schemaProvider, - databaseType: databaseType, - profile: completionProfile, - connectionId: connectionId - ) - } - - /// This scope's box alone, never the registry. The registry publishes on every fetch, and an - /// editor redrawn for each of them would redraw while the user types. A `@Published` publisher - /// delivers its current value on subscribe, so the key starts in step with the box. - private var completionRevisionChanges: AnyPublisher { - guard let databaseScope else { return Empty().eraseToAnyPublisher() } - return QueryCompletionProfileRegistry.shared.revisionBox(for: databaseScope).$revision - .eraseToAnyPublisher() - } - - /// The revision is part of the key, so an invalidation of this scope restarts the resolution. - /// It is the value `completionRevisionChanges` last delivered, not a read of the box: the box is - /// an `ObservableObject`, and reading it from a body subscribes nothing. - private var completionProfileRequest: CompletionProfileRequest? { - guard let databaseScope, let databaseType else { return nil } - return CompletionProfileRequest( - scope: databaseScope, - databaseType: databaseType, - profileRevision: observedProfileRevision - ) - } - - private func resolveCompletionProfile() async { - guard let request = completionProfileRequest else { return } - let profile = await QueryCompletionProfileRegistry.shared.profile( - for: request.scope, - databaseType: request.databaseType - ) - guard !Task.isCancelled else { return } - completionProfile = profile - configureCompletion() - } - - // MARK: - Favorites - - private func setupFavoritesObserver() { - teardownFavoritesObserver() - refreshFavoriteKeywords() - let adapter = completionAdapter - let connId = connectionId - let refresh: () -> Void = { - Task { @MainActor in - let keywords = await SQLFavoriteManager.shared.fetchKeywordMap(connectionId: connId) - adapter.updateFavoriteKeywords(keywords) - } - } - AppEvents.shared.sqlFavoritesDidUpdate - .receive(on: RunLoop.main) - .sink { payload in - guard payload == nil || payload == connectionId else { return } - refresh() - } - .store(in: &favoritesCancellables) - AppEvents.shared.linkedSQLFoldersDidUpdate - .receive(on: RunLoop.main) - .sink { payload in - guard payload == nil || payload == connectionId else { return } - refresh() - } - .store(in: &favoritesCancellables) - } - - private func refreshFavoriteKeywords() { - let connId = connectionId - Task { @MainActor in - let keywords = await SQLFavoriteManager.shared.fetchKeywordMap(connectionId: connId) - completionAdapter.updateFavoriteKeywords(keywords) - } - } - - private func teardownFavoritesObserver() { - favoritesCancellables.removeAll() - } - - // MARK: - Configuration - - private static func makeConfiguration() -> SourceEditorConfiguration { - SourceEditorConfiguration( - appearance: .init( - theme: ThemeEngine.shared.makeEditorTheme(), - font: ThemeEngine.shared.editorFonts.font, - wrapLines: AppSettingsManager.shared.editor.wordWrap, - tabWidth: AppSettingsManager.shared.editor.clampedTabWidth - ), - behavior: .init( - indentOption: .spaces(count: AppSettingsManager.shared.editor.clampedTabWidth) - ), - layout: .init( - contentInsets: NSEdgeInsets(top: 0, left: 0, bottom: 8, right: 0) - ), - peripherals: EditorPeripherals.editor( - lineNumbers: AppSettingsManager.shared.editor.showLineNumbers, - folding: AppSettingsManager.shared.editor.codeFoldingEnabled, - statementRunControls: AppSettingsManager.shared.editor.showStatementRunControls, - invisibleCharacters: AppSettingsManager.shared.editor.showInvisibleCharacters - ) - ) - } -} - -private struct CompletionProfileRequest: Hashable { - let scope: DatabaseScope - let databaseType: DatabaseType - let profileRevision: Int -} - -// MARK: - Preview - -#Preview { - SQLEditorView( - text: .constant("SELECT * FROM users\nWHERE active = true;"), - cursorPositions: .constant([]), - databaseType: .mysql, - vimMode: .constant(.normal) - ) - .frame(width: 500, height: 200) -} diff --git a/apple/TablePro/Views/Main/Child/EditorAreaController.swift b/apple/TablePro/Views/Main/Child/EditorAreaController.swift new file mode 100644 index 0000000000..0039adc6ac --- /dev/null +++ b/apple/TablePro/Views/Main/Child/EditorAreaController.swift @@ -0,0 +1,174 @@ +// +// EditorAreaController.swift +// TablePro +// + +import AppKit +import SwiftUI + +/// What the connection's editor area shows above the history drawer. +enum EditorAreaContent { + case query(QueryTabInput) + case view(AnyView) +} + +struct QueryTabInput { + var pane: QueryEditorPaneInput + var results: AnyView + var isResultsCollapsed: Binding + var resultsAutosaveName: String +} + +struct EditorAreaInput { + var content: EditorAreaContent + var history: AnyView + var isHistoryCollapsed: Binding + var historyAutosaveName: String + var contentMinimumHeight: CGFloat +} + +/// The query tab in AppKit: the editor pane over a collapsible results pane, SwiftUI only in the panes' leaves. +@MainActor +final class QueryTabSplitController { + let split: CollapsibleSplitController + let pane: QueryEditorPaneController + private let results: NSHostingController + private var isResultsCollapsed: Binding + + init(input: QueryTabInput) { + pane = QueryEditorPaneController(input: input.pane) + results = NSHostingController(rootView: input.results) + results.sizingOptions = [] + isResultsCollapsed = input.isResultsCollapsed + split = CollapsibleSplitController( + top: pane, + bottom: results, + autosaveName: input.resultsAutosaveName, + isBottomCollapsed: input.isResultsCollapsed.wrappedValue + ) + split.onUserCollapseChange = { [weak self] collapsed in + guard let self, self.isResultsCollapsed.wrappedValue != collapsed else { return } + self.isResultsCollapsed.wrappedValue = collapsed + } + } + + func apply(_ input: QueryTabInput) { + isResultsCollapsed = input.isResultsCollapsed + pane.apply(input.pane) + results.rootView = input.results + split.setBottomCollapsed(input.isResultsCollapsed.wrappedValue) + } + + func teardown() { + pane.teardown() + } +} + +/// The connection's editor area: the selected tab's content over the history drawer. It is AppKit down to the +/// panes because every SwiftUI host above the SQL editor made the window lay out each host again, nested, as the +/// editor's views went in; opening a connection on a saved query tab stalled the main thread for that. +@MainActor +final class EditorAreaController { + let drawer: CollapsibleSplitController + private let contentHost = WorkspacePaneHost() + private let history: NSHostingController + private var isHistoryCollapsed: Binding + private var querySplit: QueryTabSplitController? + private var otherContent: NSHostingController? + + init(input: EditorAreaInput) { + history = NSHostingController(rootView: input.history) + history.sizingOptions = [] + isHistoryCollapsed = input.isHistoryCollapsed + drawer = CollapsibleSplitController( + top: contentHost, + bottom: history, + autosaveName: input.historyAutosaveName, + topMinimumThickness: input.contentMinimumHeight, + bottomMinimumThickness: 180, + isBottomCollapsed: input.isHistoryCollapsed.wrappedValue + ) + drawer.onUserCollapseChange = { [weak self] collapsed in + guard let self, self.isHistoryCollapsed.wrappedValue != collapsed else { return } + self.isHistoryCollapsed.wrappedValue = collapsed + } + show(input.content) + } + + func apply(_ input: EditorAreaInput) { + isHistoryCollapsed = input.isHistoryCollapsed + show(input.content) + history.rootView = input.history + drawer.setBottomCollapsed(input.isHistoryCollapsed.wrappedValue) + } + + func teardown() { + querySplit?.teardown() + querySplit = nil + contentHost.show(nil) + otherContent = nil + } + + private func show(_ content: EditorAreaContent) { + switch content { + case .query(let input): + if let querySplit { + querySplit.apply(input) + } else { + querySplit = QueryTabSplitController(input: input) + } + contentHost.show(querySplit?.split) + otherContent = nil + case .view(let view): + if let otherContent { + otherContent.rootView = view + } else { + let host = NSHostingController(rootView: view) + host.sizingOptions = [] + otherContent = host + } + contentHost.show(otherContent) + // Torn down rather than kept, as before: the editor latches a new query tab's focus and saved selection + // only while it goes into the window, so a kept editor would leave both unapplied. + querySplit?.teardown() + querySplit = nil + } + } +} + +/// The one SwiftUI boundary of the editor area. +struct EditorAreaHost: NSViewControllerRepresentable { + let input: EditorAreaInput + + final class Coordinator { + var area: EditorAreaController? + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeNSViewController(context: Context) -> CollapsibleSplitController { + let area = EditorAreaController(input: input) + context.coordinator.area = area + return area.drawer + } + + func updateNSViewController(_ controller: CollapsibleSplitController, context: Context) { + context.coordinator.area?.apply(input) + } + + static func dismantleNSViewController(_ controller: CollapsibleSplitController, coordinator: Coordinator) { + coordinator.area?.teardown() + coordinator.area = nil + } + + // The area fills what it is offered; measuring it would lay out every pane it holds. + func sizeThatFits( + _ proposal: ProposedViewSize, + nsViewController: CollapsibleSplitController, + context: Context + ) -> CGSize? { + proposal.replacingUnspecifiedDimensions() + } +} diff --git a/apple/TablePro/Views/Main/Child/MainEditorContentView.swift b/apple/TablePro/Views/Main/Child/MainEditorContentView.swift index b7cf9fb64a..370ecef4c2 100644 --- a/apple/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/apple/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -39,7 +39,7 @@ struct MainEditorContentView: View { /// A query tab nests its own editor/results split, whose two minimums are required constraints. /// The drawer's own minimum has to clear their sum, or dragging the drawer down asks AppKit to /// satisfy a height the content it contains cannot reach. - static let tabContentMinimumHeight = VerticalCollapsibleSplitView.combinedMinimumThickness + static let tabContentMinimumHeight = CollapsibleSplitController.combinedMinimumThickness // MARK: - Dependencies @@ -114,25 +114,33 @@ struct MainEditorContentView: View { // MARK: - Body var body: some View { - VerticalCollapsibleSplitView( - isBottomCollapsed: Binding( + EditorAreaHost(input: EditorAreaInput( + content: editorAreaContent, + history: AnyView(HistoryPanelView(coordinator: coordinator)), + isHistoryCollapsed: Binding( get: { !historyState.isVisible }, set: { historyState.isVisible = !$0 } ), - autosaveName: SplitViewAutosaveName.historyDrawer(connectionId: connectionId), - topMinimumThickness: Self.tabContentMinimumHeight, - bottomMinimumThickness: 180, - topContent: { - if let tab = tabManager.selectedTab { - tabContent(for: tab) - } else { - emptyStateView - } - }, - bottomContent: { - HistoryPanelView(coordinator: coordinator) + historyAutosaveName: SplitViewAutosaveName.historyDrawer(connectionId: connectionId), + contentMinimumHeight: Self.tabContentMinimumHeight + )) + .onAppear { + if let tabId = selectedQueryTab?.id { + coordinator.clearRestoredCursor(for: tabId) } - ) + } + // The editor applies a tab's saved selection when the query tab appears; it is then spent. + .onChange(of: selectedQueryTab?.id == nil) { isNotQuery in + guard !isNotQuery, let tabId = selectedQueryTab?.id else { return } + coordinator.clearRestoredCursor(for: tabId) + } + .task(id: selectedQueryScope) { + guard let selectedQueryScope else { return } + await SchemaProviderRegistry.shared.prepare( + for: selectedQueryScope, + connection: coordinator.connection + ) + } .onAppear { if historyState.isVisible { if #available(macOS 14.0, *) { @@ -227,11 +235,28 @@ struct MainEditorContentView: View { // MARK: - Tab Content + private var selectedQueryTab: QueryTab? { + guard let tab = tabManager.selectedTab, tab.tabType == .query else { return nil } + return tab + } + + private var selectedQueryScope: DatabaseScope? { + selectedQueryTab.flatMap { coordinator.scope(for: $0) } + } + + private var editorAreaContent: EditorAreaContent { + guard let tab = tabManager.selectedTab else { return .view(AnyView(emptyStateView)) } + if tab.tabType == .query { + return .query(queryTabInput(tab: tab)) + } + return .view(AnyView(tabContent(for: tab))) + } + @ViewBuilder private func tabContent(for tab: QueryTab) -> some View { switch tab.tabType { case .query: - queryTabContent(tab: tab) + EmptyView() case .table: tableTabContent(tab: tab) case .createTable: @@ -498,103 +523,6 @@ struct MainEditorContentView: View { coordinator.runQuery(viewport: .firstRow, extraCapabilities: .confirmsWrites) } - // MARK: - Query Tab Content - - @ViewBuilder - private func queryTabContent(tab: QueryTab) -> some View { - let claimFocus = coordinator.tabManager.pendingFocusTabId == tab.id - let queryScope = coordinator.scope(for: tab) - VerticalCollapsibleSplitView( - isBottomCollapsed: Binding( - get: { tab.display.isResultsCollapsed }, - set: { collapsed in - _ = coordinator.tabManager.mutate(tabId: tab.id) { $0.display.isResultsCollapsed = collapsed } - } - ), - autosaveName: SplitViewAutosaveName.querySplit(connectionId: connectionId), - topContent: { - VStack(spacing: 0) { - if let change = tab.content.diskChange, - let url = tab.content.sourceFileURL { - SourceFileDiskChangeBanner( - fileName: url.lastPathComponent, - change: change, - onReload: { coordinator.commandActions?.reloadFileFromDisk(tabId: tab.id, url: url) }, - onSaveAs: { coordinator.commandActions?.saveFileAs() }, - onDismiss: { dismissDiskChangeBanner(tabId: tab.id) } - ) - Divider() - } - QueryEditorView( - queryText: queryTextBinding(for: tab), - cursorPositions: $coordinator.cursorPositions, - parameters: parameterBinding(for: tab), - isParameterPanelVisible: parameterVisibilityBinding(for: tab), - schemaProvider: queryScope.map { SchemaProviderRegistry.shared.getOrCreate(for: $0) }, - databaseType: coordinator.connection.type, - databaseScope: queryScope, - connectionId: coordinator.connection.id, - tabID: tab.id, - sourceFileURL: tab.content.sourceFileURL, - claimFocusOnAppear: claimFocus, - onFocusClaimed: { - if coordinator.tabManager.pendingFocusTabId == tab.id { - coordinator.tabManager.pendingFocusTabId = nil - } - }, - restoredCursorRange: coordinator.restoredCursorRange(for: tab.id), - pendingStatementJump: coordinator.pendingStatementJump(for: tab.id), - onStatementJumpHandled: { coordinator.clearPendingStatementJump(for: tab.id) }, - restoredFoldRanges: coordinator.foldRanges(for: tab.id), - onFoldRangesChanged: { ranges in - coordinator.recordFoldRanges(ranges, for: tab.id) - }, - onCloseTab: { - coordinator.commandActions?.closeTab() - }, - onExecuteQuery: { coordinator.runQuery(viewport: .firstRow) }, - onRunStatement: { sql, offset in coordinator.runStatement(sql, sourceOffset: offset) }, - isExecuting: coordinator.tabExecution.isExecuting(tab.id), - currentAIAvailability: { coordinator.aiQueryActionAvailability }, - onAIAction: { action, target in coordinator.runAIQueryAction(action, target: target) }, - onSaveAsFavorite: { text in - guard !text.isEmpty else { return } - coordinator.favoriteDialogQuery = FavoriteDialogQuery(query: text) - }, - scope: scopeBarModel(for: tab), - commands: commandAvailability(for: tab), - showsHistoryTip: showsHistoryTip, - onRun: { coordinator.runQuery(viewport: .firstRow) }, - onRunAllStatements: { coordinator.runAllStatements() }, - onRunWithoutLimit: { coordinator.runQuery(viewport: .firstRow, bypassRowLimit: true) }, - onStop: { coordinator.stopExecution(for: tab.id) }, - onExplain: { variant in coordinator.runExplain(variant: variant) }, - onFormat: { EditorEventRouter.shared.performFormatSQLForKeyWindow() }, - onSaveAsFavoriteCommand: { coordinator.saveCurrentQueryAsFavorite() }, - onClearQuery: { coordinator.commandActions?.clearQuery() }, - onClearResults: { coordinator.clearActiveQueryResults() }, - onContainerChanged: { name in changeContainer(for: tab, to: name) } - ) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - }, - bottomContent: { - resultsSection(tab: tab) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - ) - .onAppear { - coordinator.clearRestoredCursor(for: tab.id) - } - .task(id: queryScope) { - guard let queryScope else { return } - await SchemaProviderRegistry.shared.prepare( - for: queryScope, - connection: coordinator.connection - ) - } - } - private func dismissDiskChangeBanner(tabId: UUID) { coordinator.tabManager.mutate(tabId: tabId) { FileTabBaseline.dismissDiskChange(in: &$0.content) } } @@ -1378,3 +1306,105 @@ fileprivate extension MainEditorContentView { .id(tab.id) } } + +// MARK: - Query Tab Content + +extension MainEditorContentView { + private func queryTabInput(tab: QueryTab) -> QueryTabInput { + let claimFocus = coordinator.tabManager.pendingFocusTabId == tab.id + let queryScope = coordinator.scope(for: tab) + let scope = scopeBarModel(for: tab) + let commands = commandAvailability(for: tab) + let isExecuting = coordinator.tabExecution.isExecuting(tab.id) + let showsHistoryTip = showsHistoryTip + let parameters = parameterBinding(for: tab) + let isParameterPanelVisible = parameterVisibilityBinding(for: tab) + let header: (VimMode?) -> AnyView = { vimMode in + AnyView(VStack(alignment: .leading, spacing: 0) { + if let change = tab.content.diskChange, + let url = tab.content.sourceFileURL { + SourceFileDiskChangeBanner( + fileName: url.lastPathComponent, + change: change, + onReload: { coordinator.commandActions?.reloadFileFromDisk(tabId: tab.id, url: url) }, + onSaveAs: { coordinator.commandActions?.saveFileAs() }, + onDismiss: { dismissDiskChangeBanner(tabId: tab.id) } + ) + Divider() + } + QueryEditorBar( + scope: scope, + commands: commands, + isExecuting: isExecuting, + vimMode: vimMode, + showsHistoryTip: showsHistoryTip, + onAIAction: { action in coordinator.runAIQueryAction(action, target: .selectionOrStatementAtCursor) }, + onRun: { coordinator.runQuery(viewport: .firstRow) }, + onRunAllStatements: { coordinator.runAllStatements() }, + onRunWithoutLimit: { coordinator.runQuery(viewport: .firstRow, bypassRowLimit: true) }, + onStop: { coordinator.stopExecution(for: tab.id) }, + onExplain: { variant in coordinator.runExplain(variant: variant) }, + onFormat: { EditorEventRouter.shared.performFormatSQLForKeyWindow() }, + onSaveAsFavorite: { coordinator.saveCurrentQueryAsFavorite() }, + onClearQuery: { coordinator.commandActions?.clearQuery() }, + onClearResults: { coordinator.clearActiveQueryResults() }, + onContainerChanged: { name in changeContainer(for: tab, to: name) } + ) + Divider() + if isParameterPanelVisible.wrappedValue && !parameters.wrappedValue.isEmpty { + QueryParameterPanelView( + parameters: parameters, + onDismiss: { isParameterPanelVisible.wrappedValue = false } + ) + Divider() + } + }) + } + let editor = SQLEditorInput( + text: queryTextBinding(for: tab), + cursorPositions: $coordinator.cursorPositions, + schemaProvider: queryScope.map { SchemaProviderRegistry.shared.getOrCreate(for: $0) }, + databaseType: coordinator.connection.type, + databaseScope: queryScope, + connectionId: coordinator.connection.id, + tabID: tab.id, + sourceFileURL: tab.content.sourceFileURL, + claimFocusOnAppear: claimFocus, + onFocusClaimed: { + if coordinator.tabManager.pendingFocusTabId == tab.id { + coordinator.tabManager.pendingFocusTabId = nil + } + }, + restoredCursorRange: coordinator.restoredCursorRange(for: tab.id), + pendingStatementJump: coordinator.pendingStatementJump(for: tab.id), + onStatementJumpHandled: { coordinator.clearPendingStatementJump(for: tab.id) }, + restoredFoldRanges: coordinator.foldRanges(for: tab.id), + onFoldRangesChanged: { ranges in + coordinator.recordFoldRanges(ranges, for: tab.id) + }, + onCloseTab: { + coordinator.commandActions?.closeTab() + }, + onExecuteQuery: { coordinator.runQuery(viewport: .firstRow) }, + onRunStatement: { sql, offset in coordinator.runStatement(sql, sourceOffset: offset) }, + isExecuting: isExecuting, + currentAIAvailability: { coordinator.aiQueryActionAvailability }, + onAIAction: { action, target in coordinator.runAIQueryAction(action, target: target) }, + onSaveAsFavorite: { text in + guard !text.isEmpty else { return } + coordinator.favoriteDialogQuery = FavoriteDialogQuery(query: text) + } + ) + return QueryTabInput( + pane: QueryEditorPaneInput(header: header, editor: editor), + results: AnyView(resultsSection(tab: tab).frame(maxWidth: .infinity, maxHeight: .infinity)), + isResultsCollapsed: Binding( + get: { tab.display.isResultsCollapsed }, + set: { collapsed in + _ = coordinator.tabManager.mutate(tabId: tab.id) { $0.display.isResultsCollapsed = collapsed } + } + ), + resultsAutosaveName: SplitViewAutosaveName.querySplit(connectionId: connectionId) + ) + } +} diff --git a/apple/TablePro/Views/Sidebar/DatabaseTreeRenderInputs.swift b/apple/TablePro/Views/Sidebar/DatabaseTreeRenderInputs.swift index 746b6580d7..6b60b3c07d 100644 --- a/apple/TablePro/Views/Sidebar/DatabaseTreeRenderInputs.swift +++ b/apple/TablePro/Views/Sidebar/DatabaseTreeRenderInputs.swift @@ -29,10 +29,33 @@ struct DatabaseTreeRenderInputs: Equatable { } struct Listing: Equatable { - let phase: MetadataLoadPhase + // Every reader in the outline takes idle and loading alike, so a read starting draws nothing new. + enum Outcome: Equatable { + case pending + case loaded + case failed + } + + let outcome: Outcome let tables: SameRows let unlisted: Set let covered: CatalogTableListing.SchemaSelection? + + init(_ state: MetadataLoadState, covered: CatalogTableListing.SchemaSelection?) { + switch state { + case .idle, .loading: outcome = .pending + case .loaded: outcome = .loaded + case .failed: outcome = .failed + } + tables = SameRows(rows: state.value?.tables ?? []) + unlisted = state.value?.unlistedSchemas ?? [] + self.covered = covered + } + + // Draws the same as a database with no listing entry. + var isUnread: Bool { + outcome == .pending && covered == nil + } } struct TemporaryTables: Equatable { @@ -91,12 +114,11 @@ struct DatabaseTreeRenderInputs: Equatable { } var listings: [DatabaseKey: Listing] = [:] for (key, state) in treeMetadata.allSchemaTablesState where key.connectionId == connectionId { - listings[key] = Listing( - phase: state.erased, - tables: SameRows(rows: state.value?.tables ?? []), - unlisted: state.value?.unlistedSchemas ?? [], - covered: treeMetadata.allSchemaTablesSelection(connectionId: connectionId, database: key.database) + let listing = Listing( + state, covered: treeMetadata.allSchemaTablesSelection(connectionId: connectionId, database: key.database) ) + // A first read adds its key as it starts, which must not count as a change either. + if !listing.isUnread { listings[key] = listing } } self.databases = Rows(treeMetadata.databaseListState(for: connectionId)) self.schemaLists = Self.slice(treeMetadata.schemaList, where: { $0.connectionId == connectionId }) { Rows($0) } diff --git a/apple/TableProTests/Core/Services/Infrastructure/SidebarContainerChromeTests.swift b/apple/TableProTests/Core/Services/Infrastructure/SidebarContainerChromeTests.swift index a7000501ad..8d75973035 100644 --- a/apple/TableProTests/Core/Services/Infrastructure/SidebarContainerChromeTests.swift +++ b/apple/TableProTests/Core/Services/Infrastructure/SidebarContainerChromeTests.swift @@ -42,4 +42,90 @@ struct SidebarContainerChromeTests { #expect(controller.isFilterEnabled) } + + @MainActor + private struct FilterHost { + let controller: SidebarContainerViewController + let window: NSWindow + + init(state: SharedSidebarState) { + controller = SidebarContainerViewController() + window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 280, height: 600), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + controller.view.frame = NSRect(x: 0, y: 0, width: 280, height: 600) + window.contentView?.addSubview(controller.view) + controller.updateSidebarState(state) + } + + var isFilterEditing: Bool { + controller.view.firstDescendant(of: NSSearchField.self)?.currentEditor() != nil + } + + func tearDown() { + _ = window.makeFirstResponder(nil) + controller.updateSidebarState(nil) + controller.view.removeFromSuperview() + } + } + + @Test("Focusing the filter on Tables marks a search started, and ending the edit clears it") + func tablesFocusIsMarkedUntilEditingEnds() { + let state = SharedSidebarState() + let host = FilterHost(state: state) + defer { host.tearDown() } + + host.controller.focusSearchField() + #expect(host.isFilterEditing) + #expect(state.isFilterFieldFocused) + + #expect(host.window.makeFirstResponder(nil)) + #expect(!state.isFilterFieldFocused) + } + + @Test("Focusing the filter on Favorites marks nothing, since that list never reads the all-schema listing") + func favoritesFocusIsNotMarked() { + let state = SharedSidebarState() + state.selectedSidebarTab = .favorites + let host = FilterHost(state: state) + defer { host.tearDown() } + + host.controller.focusSearchField() + + #expect(host.isFilterEditing) + #expect(!state.isFilterFieldFocused) + } + + @Test("The focus stays through a rebind to the same connection and leaves with the field for another") + func focusLeavesWithTheField() { + let first = SharedSidebarState() + let second = SharedSidebarState() + let host = FilterHost(state: first) + defer { host.tearDown() } + host.controller.focusSearchField() + + host.controller.updateSidebarState(first) + #expect(first.isFilterFieldFocused) + + host.controller.updateSidebarState(second) + #expect(!first.isFilterFieldFocused) + #expect(!second.isFilterFieldFocused) + } + + @Test("Losing the session clears the focus the field marked") + func losingTheSessionClearsTheFocus() { + let state = SharedSidebarState() + let host = FilterHost(state: state) + defer { host.tearDown() } + host.controller.focusSearchField() + #expect(state.isFilterFieldFocused) + + host.controller.updateSidebarState(nil) + + #expect(!state.isFilterFieldFocused) + } } diff --git a/apple/TableProTests/Core/Services/Query/SidebarSchemaFilterTests.swift b/apple/TableProTests/Core/Services/Query/SidebarSchemaFilterTests.swift index 893e0f434e..b0028c93be 100644 --- a/apple/TableProTests/Core/Services/Query/SidebarSchemaFilterTests.swift +++ b/apple/TableProTests/Core/Services/Query/SidebarSchemaFilterTests.swift @@ -3,6 +3,7 @@ // TableProTests // +import AppKit import Foundation import SwiftUI @testable import TablePro @@ -174,4 +175,124 @@ struct SidebarSchemaFilterTests { viewModel.searchText = "" await disconnect(connection) } + + private func makeViewModel(_ connection: DatabaseConnection) -> SidebarViewModel { + SidebarViewModel( + selectedTables: .constant([]), + pendingTruncates: .constant([]), + pendingDeletes: .constant([]), + tableOperationOptions: .constant([:]), + databaseType: .pglite, + connectionId: connection.id + ) + } + + // The window's filter field, bound to the connection and holding the keyboard. + private func focusFilter(of connection: DatabaseConnection) -> (container: SidebarContainerViewController, window: NSWindow) { + let container = SidebarContainerViewController() + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 280, height: 600), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + container.view.frame = NSRect(x: 0, y: 0, width: 280, height: 600) + window.contentView?.addSubview(container.view) + container.updateSidebarState(SharedSidebarState.forConnection(connection.id)) + container.focusSearchField() + return (container, window) + } + + private func release(_ filter: (container: SidebarContainerViewController, window: NSWindow)) { + _ = filter.window.makeFirstResponder(nil) + filter.container.updateSidebarState(nil) + filter.container.view.removeFromSuperview() + } + + private func isListingLoading(_ connection: DatabaseConnection) -> Bool { + if case .loading = service.allSchemaTablesLoadState(connectionId: connection.id, database: "shop") { return true } + return false + } + + // Sinks deliver through `RunLoop.main.perform` and answer on the main actor; both have run once this returns. + private func afterQueuedMainWork() async { + await withCheckedContinuation { continuation in + RunLoop.main.perform { continuation.resume() } + } + await withCheckedContinuation { continuation in + DispatchQueue.main.async { continuation.resume() } + } + } + + @Test("Focusing the filter reads the listing with nothing typed, and the first keystroke joins that read") + func focusReadsTheListingBeforeTheFirstKeystroke() async throws { + let (connection, driver) = connect { driver in + driver.allSchemaTablesToReturn = [table("users", "public"), table("timesheet", "attendance")] + driver.pausesNextAllSchemaTablesFetch = true + } + let viewModel = makeViewModel(connection) + + let filter = focusFilter(of: connection) + try await waitUntil { driver.fetchTablesInAllSchemasCallCount == 1 } + #expect(viewModel.filterQuery.isEmpty) + + viewModel.searchText = "time" + await afterQueuedMainWork() + #expect(isListingLoading(connection)) + #expect(driver.fetchTablesInAllSchemasCallCount == 1) + + driver.resumeAllSchemaTablesFetch() + try await waitUntil { service.allSchemaTablesLoadState(connectionId: connection.id, database: "shop").value != nil } + #expect(driver.fetchTablesInAllSchemasCallCount == 1) + #expect(viewModel.schemaListingMatches(database: "shop", searchText: "time")?.matched.contains("attendance") == true) + viewModel.searchText = "" + release(filter) + await disconnect(connection) + } + + @Test("A listing that failed at focus is asked for again by the next search, not by the first keystroke") + func failedFocusListingIsNotReadAgainByTheFirstKeystroke() async throws { + let (connection, driver) = connect { driver in + driver.allSchemaTablesError = DatabaseError.queryFailed("listing timed out") + } + let viewModel = makeViewModel(connection) + + let filter = focusFilter(of: connection) + try await waitUntil { + if case .failed = service.allSchemaTablesLoadState(connectionId: connection.id, database: "shop") { return true } + return false + } + viewModel.searchText = "t" + try await waitUntil { viewModel.filterQuery == "t" } + await afterQueuedMainWork() + #expect(driver.fetchTablesInAllSchemasCallCount == 1) + + viewModel.searchText = "" + try await waitUntil { viewModel.filterQuery.isEmpty } + viewModel.searchText = "o" + try await waitUntil { driver.fetchTablesInAllSchemasCallCount == 2 } + viewModel.searchText = "" + release(filter) + await disconnect(connection) + } + + @Test("Focusing the filter on Favorites reads no listing") + func favoritesFocusReadsNoListing() async throws { + let (connection, driver) = connect { driver in + driver.allSchemaTablesToReturn = [table("users", "public"), table("timesheet", "attendance")] + } + let viewModel = makeViewModel(connection) + SharedSidebarState.forConnection(connection.id).selectedSidebarTab = .favorites + + let filter = focusFilter(of: connection) + await afterQueuedMainWork() + + #expect(filter.window.firstResponder is NSText) + #expect(service.requestedAllSchemaTablesSelection(connectionId: connection.id, database: "shop") == nil) + #expect(driver.fetchTablesInAllSchemasCallCount == 0) + #expect(viewModel.filterQuery.isEmpty) + release(filter) + await disconnect(connection) + } } diff --git a/apple/TableProTests/Views/Editor/QueryEditorPaneLayoutTests.swift b/apple/TableProTests/Views/Editor/QueryEditorPaneLayoutTests.swift new file mode 100644 index 0000000000..c29a43f22e --- /dev/null +++ b/apple/TableProTests/Views/Editor/QueryEditorPaneLayoutTests.swift @@ -0,0 +1,84 @@ +// +// QueryEditorPaneLayoutTests.swift +// TableProTests +// +// The query split stops its editor pane at a fixed minimum. The bar and the editor have to fit inside it, or the bar +// is cut off at the top and the editor's text runs up over it. +// + +import AppKit +import SwiftUI +import TableProEditorKit +import Testing + +@testable import TablePro + +@MainActor +enum QueryEditorPaneFixture { + static func input(text: Binding = .constant("SELECT 1")) -> QueryEditorPaneInput { + QueryEditorPaneInput( + header: { vimMode in + AnyView(VStack(spacing: 0) { + QueryEditorBar( + scope: QueryScopeBarModel( + containers: [], + selectedName: "shop", + entityName: String(localized: "Database"), + isReadOnly: false, + schemaName: nil + ), + commands: QueryCommandAvailability( + isConnected: true, + hasQueryText: true, + isExecuting: false, + isStoppable: true, + hasResults: false, + explainVariants: [], + shortcutHint: { label, _ in label } + ), + isExecuting: false, + vimMode: vimMode, + showsHistoryTip: false, + onAIAction: { _ in }, + onRun: {}, + onRunAllStatements: {}, + onRunWithoutLimit: {}, + onStop: {}, + onExplain: { _ in }, + onFormat: {}, + onSaveAsFavorite: {}, + onClearQuery: {}, + onClearResults: {}, + onContainerChanged: { _ in } + ) + Divider() + }) + }, + editor: SQLEditorInput( + text: text, + cursorPositions: .constant([]), + databaseType: .sqlite + ) + ) + } +} + +@MainActor +struct QueryEditorPaneLayoutTests { + @Test("The bar keeps its height and the editor fits below it at the pane's minimum height") + func barAndEditorFitThePaneMinimum() { + let paneMinimum = CollapsibleSplitController.defaultTopMinimumThickness + let pane = QueryEditorPaneController(input: QueryEditorPaneFixture.input()) + defer { pane.teardown() } + let view = pane.view + view.frame = NSRect(x: 0, y: 0, width: 900, height: paneMinimum) + view.layoutSubtreeIfNeeded() + + let header = pane.header.frame + let editor = pane.editor.textViewController.view.frame + #expect(header.height == pane.header.intrinsicContentSize.height) + #expect(header.maxY <= paneMinimum) + #expect(editor.maxY <= header.minY + 0.5) + #expect(editor.height > 0) + } +} diff --git a/apple/TableProTests/Views/Editor/QueryEditorViewLayoutTests.swift b/apple/TableProTests/Views/Editor/QueryEditorViewLayoutTests.swift deleted file mode 100644 index 40d321b101..0000000000 --- a/apple/TableProTests/Views/Editor/QueryEditorViewLayoutTests.swift +++ /dev/null @@ -1,65 +0,0 @@ -// -// QueryEditorViewLayoutTests.swift -// TableProTests -// -// The query split stops its editor pane at a fixed minimum. The editor, its bar and the divider -// between them have to fit inside it, or the stack overflows the pane: the bar is cut off at the -// top and the editor's text runs up over it. -// - -import AppKit -import SwiftUI -import Testing - -@testable import TablePro - -@MainActor -struct QueryEditorViewLayoutTests { - private func makeEditor() -> QueryEditorView { - QueryEditorView( - queryText: .constant("SELECT 1"), - cursorPositions: .constant([]), - parameters: .constant([]), - isParameterPanelVisible: .constant(false), - scope: QueryScopeBarModel( - containers: [], - selectedName: "shop", - entityName: String(localized: "Database"), - isReadOnly: false, - schemaName: nil - ), - commands: QueryCommandAvailability( - isConnected: true, - hasQueryText: true, - isExecuting: false, - isStoppable: true, - hasResults: false, - explainVariants: [], - shortcutHint: { label, _ in label } - ), - onRun: {}, - onRunAllStatements: {}, - onRunWithoutLimit: {}, - onStop: {}, - onExplain: { _ in }, - onFormat: {}, - onSaveAsFavoriteCommand: {}, - onClearQuery: {}, - onClearResults: {}, - onContainerChanged: { _ in } - ) - } - - @Test("The editor and its bar fit the editor pane's minimum height") - func editorFitsThePaneMinimum() { - let paneMinimum = VerticalCollapsibleSplitView.defaultTopMinimumThickness - let controller = NSHostingController(rootView: makeEditor()) - - let squeezed = controller.sizeThatFits(in: CGSize(width: 900, height: 1)) - - #expect( - squeezed.height <= paneMinimum, - "the editor stack needs \(squeezed.height)pt inside a pane that can be \(paneMinimum)pt" - ) - } -} diff --git a/apple/TableProTests/Views/Editor/SQLEditorCursorPropagationTests.swift b/apple/TableProTests/Views/Editor/SQLEditorCursorPropagationTests.swift index f4f0473be3..0efb6dc449 100644 --- a/apple/TableProTests/Views/Editor/SQLEditorCursorPropagationTests.swift +++ b/apple/TableProTests/Views/Editor/SQLEditorCursorPropagationTests.swift @@ -2,7 +2,6 @@ // focus is elsewhere (find navigation, a jump to a result's statement) still has to reach it. import AppKit -import Combine import SwiftUI import TableProEditorKit import TableProTestSupport @@ -13,23 +12,9 @@ import Testing @MainActor struct SQLEditorCursorPropagationTests { - private final class Model: ObservableObject { - @Published var text = "SELECT 1 AS a;\nSELECT 2 AS b;\nSELECT 3 AS zz;" - @Published var cursorPositions: [CursorPosition] = [] - @Published var vimMode: VimMode = .normal - } - - private struct Host: View { - @ObservedObject var model: Model - - var body: some View { - SQLEditorView( - text: $model.text, - cursorPositions: $model.cursorPositions, - databaseType: .sqlite, - vimMode: $model.vimMode - ) - } + private final class Model { + var text = "SELECT 1 AS a;\nSELECT 2 AS b;\nSELECT 3 AS zz;" + var cursorPositions: [CursorPosition] = [] } private func settle(_ window: NSWindow, until condition: () -> Bool) async throws { @@ -39,18 +24,12 @@ struct SQLEditorCursorPropagationTests { } } - private func textView(in view: NSView?) -> TextView? { - guard let view else { return nil } - if let textView = view as? TextView { return textView } - for subview in view.subviews { - if let found = textView(in: subview) { return found } - } - return nil - } - - @Test("A caret moved while the editor is not first responder reaches the binding") - func caretMovedWithoutFocusReachesTheBinding() async throws { - let model = Model() + private func mount(_ model: Model) -> (SQLEditorController, NSWindow) { + let editor = SQLEditorController(input: SQLEditorInput( + text: Binding(get: { model.text }, set: { model.text = $0 }), + cursorPositions: Binding(get: { model.cursorPositions }, set: { model.cursorPositions = $0 }), + databaseType: .sqlite + )) let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 600, height: 400), styleMask: [.titled], @@ -58,18 +37,47 @@ struct SQLEditorCursorPropagationTests { defer: false ) window.isReleasedWhenClosed = false - window.contentView = NSHostingView(rootView: Host(model: model)) + window.contentView = editor.textViewController.view + return (editor, window) + } + + @Test("A caret moved while the editor is not first responder reaches the binding") + func caretMovedWithoutFocusReachesTheBinding() async throws { + let model = Model() + let (editor, window) = mount(model) + defer { editor.teardown() } defer { window.orderOut(nil) } - try await settle(window) { textView(in: window.contentView)?.window != nil } + let textView = try #require(editor.textViewController.textView) + try await settle(window) { textView.window != nil } - let editor = try #require(textView(in: window.contentView)) _ = window.makeFirstResponder(nil) - try #require(window.firstResponder !== editor) + try #require(window.firstResponder !== textView) let match = NSRange(location: 42, length: 2) - editor.selectionManager.setSelectedRange(match) - try await settle(window) { model.cursorPositions.map(\.range) == [match] } + textView.selectionManager.setSelectedRange(match) + try await settle(window) { model.cursorPositions.map { $0.range } == [match] } + + #expect(model.cursorPositions.map { $0.range } == [match]) + } + + @Test("Typing and replacing a selection report the caret the edit left, with the edited text") + func editCaretReachesTheBinding() async throws { + let model = Model() + let (editor, window) = mount(model) + defer { editor.teardown() } + defer { window.orderOut(nil) } + let textView = try #require(editor.textViewController.textView) + try await settle(window) { textView.window != nil } + _ = window.makeFirstResponder(textView) + + textView.selectionManager.setSelectedRange(NSRange(location: 14, length: 0)) + textView.replaceCharacters(in: NSRange(location: 14, length: 0), with: "--") + #expect(model.text.hasPrefix("SELECT 1 AS a;--")) + #expect(model.cursorPositions.map { $0.range } == [NSRange(location: 16, length: 0)]) - #expect(model.cursorPositions.map(\.range) == [match]) + textView.selectionManager.setSelectedRange(NSRange(location: 7, length: 1)) + textView.replaceCharacters(in: NSRange(location: 7, length: 1), with: "42") + #expect(model.text.hasPrefix("SELECT 42 AS a;--")) + #expect(model.cursorPositions.map { $0.range } == [NSRange(location: 9, length: 0)]) } } diff --git a/apple/TableProTests/Views/Main/EditorAreaControllerTests.swift b/apple/TableProTests/Views/Main/EditorAreaControllerTests.swift new file mode 100644 index 0000000000..c4aa2c444e --- /dev/null +++ b/apple/TableProTests/Views/Main/EditorAreaControllerTests.swift @@ -0,0 +1,98 @@ +// +// EditorAreaControllerTests.swift +// TableProTests +// + +import AppKit +import SwiftUI +import TableProEditorKit +import TableProTextEngine +import Testing + +@testable import TablePro + +@MainActor +struct EditorAreaControllerTests { + private final class Flags { + var historyCollapsed = true + var resultsCollapsed = false + } + + private func input(_ content: EditorAreaContent, flags: Flags) -> EditorAreaInput { + EditorAreaInput( + content: content, + history: AnyView(Text("History")), + isHistoryCollapsed: Binding(get: { flags.historyCollapsed }, set: { flags.historyCollapsed = $0 }), + historyAutosaveName: "EditorAreaControllerTests.history", + contentMinimumHeight: CollapsibleSplitController.combinedMinimumThickness + ) + } + + private func query(_ flags: Flags, text: String = "SELECT 1") -> EditorAreaContent { + .query(QueryTabInput( + pane: QueryEditorPaneFixture.input(text: .constant(text)), + results: AnyView(Text("Results")), + isResultsCollapsed: Binding(get: { flags.resultsCollapsed }, set: { flags.resultsCollapsed = $0 }), + resultsAutosaveName: "EditorAreaControllerTests.results" + )) + } + + private func editors(in view: NSView) -> [TextView] { + var found: [TextView] = [] + if let textView = view as? TextView { found.append(textView) } + for subview in view.subviews { found += editors(in: subview) } + return found + } + + @Test("Another kind of tab takes the editor down, and the next query tab gets a new one with its own text") + func anotherTabTakesTheEditorDown() throws { + let flags = Flags() + let area = EditorAreaController(input: input(query(flags), flags: flags)) + defer { area.teardown() } + let root = area.drawer.view + root.frame = NSRect(x: 0, y: 0, width: 900, height: 700) + root.layoutSubtreeIfNeeded() + let editor = try #require(editors(in: root).first) + + area.apply(input(.view(AnyView(Text("Table"))), flags: flags)) + root.layoutSubtreeIfNeeded() + #expect(editors(in: root).isEmpty) + + area.apply(input(query(flags, text: "SELECT 2"), flags: flags)) + root.layoutSubtreeIfNeeded() + let next = try #require(editors(in: root).first) + #expect(next !== editor) + #expect(next.string == "SELECT 2") + } + + @Test("A query tab switch keeps the editor and hands it the new tab's text") + func queryTabSwitchKeepsTheEditor() throws { + let flags = Flags() + let area = EditorAreaController(input: input(query(flags), flags: flags)) + defer { area.teardown() } + let root = area.drawer.view + root.frame = NSRect(x: 0, y: 0, width: 900, height: 700) + root.layoutSubtreeIfNeeded() + let editor = try #require(editors(in: root).first) + + area.apply(input(query(flags, text: "SELECT 2"), flags: flags)) + root.layoutSubtreeIfNeeded() + #expect(editors(in: root).first === editor) + #expect(editor.string == "SELECT 2") + } + + @Test("A divider the user collapses is written back, and the owner's own change is not echoed") + func collapseIsReportedOnlyFromTheUser() { + let flags = Flags() + let area = EditorAreaController(input: input(query(flags), flags: flags)) + defer { area.teardown() } + area.drawer.view.frame = NSRect(x: 0, y: 0, width: 900, height: 700) + + area.drawer.bottomItem.isCollapsed = false + #expect(flags.historyCollapsed == false) + + flags.historyCollapsed = true + area.apply(input(query(flags), flags: flags)) + #expect(flags.historyCollapsed) + } +} diff --git a/apple/TableProTests/Views/Sidebar/DatabaseTreeReconcileTests.swift b/apple/TableProTests/Views/Sidebar/DatabaseTreeReconcileTests.swift index 651b0cf089..29debac151 100644 --- a/apple/TableProTests/Views/Sidebar/DatabaseTreeReconcileTests.swift +++ b/apple/TableProTests/Views/Sidebar/DatabaseTreeReconcileTests.swift @@ -162,6 +162,56 @@ struct DatabaseTreeReconcileTests { await tearDown(fixture) } + @Test("An all-schema listing starting to load changes nothing the outline draws, and its rows landing do") + func listingStartingToLoadIsNoChange() async throws { + let connection = TestFixtures.makeConnection(database: "shop", type: .pglite) + let driver = MockDatabaseDriver(connection: connection) + driver.allSchemaTablesToReturn = [TestFixtures.makeTableInfo(name: "timesheet", schema: "attendance")] + driver.pausesNextAllSchemaTablesFetch = true + var session = ConnectionSession(connection: connection, driver: driver) + session.status = .connected + DatabaseManager.shared.injectSession(session, for: connection.id) + let service = DatabaseTreeMetadataService.shared + func inputs() -> DatabaseTreeRenderInputs { + DatabaseTreeRenderInputs( + connectionId: connection.id, + treeMetadata: service, + schemaService: .shared, + temporaryTables: .shared, + sidebarState: nil + ) + } + let unread = inputs() + + let load = Task { await service.loadAllSchemaTables(connectionId: connection.id, database: "shop") } + try await waitUntil { + if case .loading = service.allSchemaTablesLoadState(connectionId: connection.id, database: "shop") { return true } + return false + } + #expect(inputs() == unread) + + try await waitUntil { driver.fetchTablesInAllSchemasCallCount == 1 } + driver.resumeAllSchemaTablesFetch() + await load.value + #expect(service.allSchemaTablesLoadState(connectionId: connection.id, database: "shop").value != nil) + #expect(inputs() != unread) + await service.handleDisconnect(connectionId: connection.id) + DatabaseManager.shared.removeSession(for: connection.id) + } + + @Test("A listing reads alike idle and loading, and apart once it loads or fails") + func listingOutcomes() { + typealias Listing = DatabaseTreeRenderInputs.Listing + let rows = CatalogTableListing.Result(tables: [TestFixtures.makeTableInfo(name: "timesheet", schema: "attendance")], unlistedSchemas: []) + + #expect(Listing(.idle, covered: nil) == Listing(.loading, covered: nil)) + #expect(Listing(.loading, covered: nil).isUnread) + #expect(Listing(.loading, covered: nil) != Listing(.loaded(rows), covered: .all)) + #expect(Listing(.loading, covered: nil) != Listing(.failed("timed out"), covered: nil)) + #expect(Listing(.loaded(rows), covered: .all) != Listing(.failed("timed out"), covered: nil)) + #expect(!Listing(.failed("timed out"), covered: nil).isUnread) + } + @Test("A reload over the same rows hands back the same row nodes") func unchangedRowsKeepTheirNodes() async throws { let fixture = try await makeFixture() From f42d277c6f04eb7b415730da771d304f85f96524 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 25 Oct 2026 19:59:40 +0700 Subject: [PATCH 2/2] docs(changelog): add the #3834 fragments --- apple/changelog.d/3834.fixed.1.md | 1 + apple/changelog.d/3834.fixed.md | 1 + 2 files changed, 2 insertions(+) create mode 100644 apple/changelog.d/3834.fixed.1.md create mode 100644 apple/changelog.d/3834.fixed.md diff --git a/apple/changelog.d/3834.fixed.1.md b/apple/changelog.d/3834.fixed.1.md new file mode 100644 index 0000000000..f759d4c924 --- /dev/null +++ b/apple/changelog.d/3834.fixed.1.md @@ -0,0 +1 @@ +The first search in a large Oracle or PostgreSQL sidebar waited for every schema's tables to load after the keystroke. diff --git a/apple/changelog.d/3834.fixed.md b/apple/changelog.d/3834.fixed.md new file mode 100644 index 0000000000..998b792da6 --- /dev/null +++ b/apple/changelog.d/3834.fixed.md @@ -0,0 +1 @@ +Opening a connection on a saved query tab froze the window while the tab's editor was built.