From f86870381674a3e445857cc32297a25f2fc9e1c5 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 12 Aug 2026 13:10:40 +0700 Subject: [PATCH 01/47] refactor(coordinator): give a window a registry of connection workspaces --- .../Infrastructure/ConnectionWorkspace.swift | 67 +++++ .../ConnectionWorkspaceRegistry.swift | 114 +++++++++ .../MainSplitViewController+Connection.swift | 43 ++-- .../MainSplitViewController.swift | 228 ++++++++++++------ .../Infrastructure/WindowManager.swift | 10 +- .../ConnectionWorkspaceRegistryTests.swift | 159 ++++++++++++ 6 files changed, 528 insertions(+), 93 deletions(-) create mode 100644 TablePro/Core/Services/Infrastructure/ConnectionWorkspace.swift create mode 100644 TablePro/Core/Services/Infrastructure/ConnectionWorkspaceRegistry.swift create mode 100644 TableProTests/Core/Services/Infrastructure/ConnectionWorkspaceRegistryTests.swift diff --git a/TablePro/Core/Services/Infrastructure/ConnectionWorkspace.swift b/TablePro/Core/Services/Infrastructure/ConnectionWorkspace.swift new file mode 100644 index 000000000..b2eb0dba2 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/ConnectionWorkspace.swift @@ -0,0 +1,67 @@ +// +// ConnectionWorkspace.swift +// TablePro +// + +import AppKit +import Foundation + +/// Everything a window needs to present one connection. `MainSplitViewController` used to hold +/// these as scalar fields because a window served exactly one connection for its whole life; +/// they live here so a window can hold several and show one at a time. +@MainActor +internal final class ConnectionWorkspace { + internal let connectionId: UUID + internal let payload: EditorTabPayload? + internal let autoConnect: Bool + + internal var payloadConnection: DatabaseConnection? + internal var session: ConnectionSession? + internal var sessionState: SessionStateFactory.SessionState? + internal var rightPanelState: RightPanelState? + internal var attemptToken: UUID? + internal var phase: ConnectionWindowPhase + + /// Each workspace owns its undo stack. Routing through `NSWindow.undoManager` was correct + /// while a window meant one connection; sharing one window between several would let an + /// undo in one connection roll back an edit made in another. + internal let undoManager: UndoManager + + internal init( + connectionId: UUID, + payload: EditorTabPayload?, + autoConnect: Bool, + payloadConnection: DatabaseConnection?, + session: ConnectionSession?, + sessionState: SessionStateFactory.SessionState?, + rightPanelState: RightPanelState?, + phase: ConnectionWindowPhase + ) { + self.connectionId = connectionId + self.payload = payload + self.autoConnect = autoConnect + self.payloadConnection = payloadConnection + self.session = session + self.sessionState = sessionState + self.rightPanelState = rightPanelState + self.phase = phase + self.undoManager = UndoManager() + } + + internal var connection: DatabaseConnection? { + payloadConnection ?? session?.connection + } + + internal var retainsRestoreIntent: Bool { + ConnectionWindowPhaseMachine.retainsRestoreIntent(phase: phase) + } + + internal func teardown() { + rightPanelState?.teardown() + rightPanelState = nil + sessionState?.coordinator.teardown() + sessionState = nil + session = nil + undoManager.removeAllActions() + } +} diff --git a/TablePro/Core/Services/Infrastructure/ConnectionWorkspaceRegistry.swift b/TablePro/Core/Services/Infrastructure/ConnectionWorkspaceRegistry.swift new file mode 100644 index 000000000..f0be0ab81 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/ConnectionWorkspaceRegistry.swift @@ -0,0 +1,114 @@ +// +// ConnectionWorkspaceRegistry.swift +// TablePro +// + +import Foundation +import os + +/// The set of connections one window hosts, and which of them it is showing. A window used to +/// answer this with its own identity, which could only ever name one connection. +@MainActor +internal final class ConnectionWorkspaceRegistry { + private static let logger = Logger(subsystem: "com.TablePro", category: "ConnectionWorkspace") + + private var workspacesById: [UUID: ConnectionWorkspace] = [:] + private(set) var order: [UUID] = [] + private(set) var selectedConnectionId: UUID? + + internal var onSelectionChange: ((UUID?) -> Void)? + internal var onMembershipChange: (() -> Void)? + + internal init() {} + + internal var isEmpty: Bool { order.isEmpty } + + internal var count: Int { order.count } + + internal var connectionIds: [UUID] { order } + + internal var workspaces: [ConnectionWorkspace] { + order.compactMap { workspacesById[$0] } + } + + internal var selected: ConnectionWorkspace? { + guard let selectedConnectionId else { return nil } + return workspacesById[selectedConnectionId] + } + + internal func workspace(for connectionId: UUID) -> ConnectionWorkspace? { + workspacesById[connectionId] + } + + internal func contains(_ connectionId: UUID) -> Bool { + workspacesById[connectionId] != nil + } + + @discardableResult + internal func insert(_ workspace: ConnectionWorkspace, select: Bool = true) -> ConnectionWorkspace { + if let existing = workspacesById[workspace.connectionId] { + if select { self.select(existing.connectionId) } + return existing + } + workspacesById[workspace.connectionId] = workspace + order.append(workspace.connectionId) + Self.logger.info( + "insert connId=\(workspace.connectionId, privacy: .public) count=\(self.order.count, privacy: .public)" + ) + onMembershipChange?() + if select || selectedConnectionId == nil { + self.select(workspace.connectionId) + } + return workspace + } + + /// Returns the workspace so the caller can tear it down after the registry no longer + /// references it. A late connection attempt that finds no entry must discard its work + /// rather than resurrect one, which is why removal is the generation check. + @discardableResult + internal func remove(_ connectionId: UUID) -> ConnectionWorkspace? { + guard let removed = workspacesById.removeValue(forKey: connectionId) else { return nil } + let removedIndex = order.firstIndex(of: connectionId) + order.removeAll { $0 == connectionId } + Self.logger.info( + "remove connId=\(connectionId, privacy: .public) count=\(self.order.count, privacy: .public)" + ) + if selectedConnectionId == connectionId { + selectedConnectionId = nil + select(Self.neighbour(in: order, removedIndex: removedIndex)) + } + onMembershipChange?() + return removed + } + + internal func select(_ connectionId: UUID?) { + guard selectedConnectionId != connectionId else { return } + guard connectionId == nil || workspacesById[connectionId ?? UUID()] != nil else { return } + selectedConnectionId = connectionId + Self.logger.info( + "select connId=\(connectionId?.uuidString ?? "none", privacy: .public)" + ) + onSelectionChange?(connectionId) + } + + internal func cycleSelection(offsetBy offset: Int) { + guard let next = Self.cycled(in: order, from: selectedConnectionId, by: offset) else { return } + select(next) + } + + /// The row that takes the removed row's place, so closing a connection lands on its + /// neighbour instead of dropping the window to an empty pane while others are still open. + internal static func neighbour(in order: [UUID], removedIndex: Int?) -> UUID? { + guard !order.isEmpty else { return nil } + guard let removedIndex else { return order.first } + return order.indices.contains(removedIndex) ? order[removedIndex] : order.last + } + + internal static func cycled(in order: [UUID], from current: UUID?, by offset: Int) -> UUID? { + guard !order.isEmpty else { return nil } + guard let current, let index = order.firstIndex(of: current) else { return order.first } + let count = order.count + let next = ((index + offset) % count + count) % count + return order[next] + } +} diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+Connection.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+Connection.swift index 7a6392e9a..158e6df85 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+Connection.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+Connection.swift @@ -57,11 +57,11 @@ internal extension MainSplitViewController { } func cancelConnectionAttempt() { - attemptToken = nil - transition(to: .unavailable(.cancelled)) - guard let connectionId = payload?.connectionId else { return } - DatabaseManager.shared.invalidateConnectionAttempt(connectionId) - Task { await DatabaseManager.shared.cancelEnsureConnected(connectionId) } + guard let workspace = workspaces.selected else { return } + workspace.attemptToken = nil + transition(to: .unavailable(.cancelled), for: workspace.connectionId) + DatabaseManager.shared.invalidateConnectionAttempt(workspace.connectionId) + Task { await DatabaseManager.shared.cancelEnsureConnected(workspace.connectionId) } } func openConnectionList() { @@ -79,13 +79,17 @@ internal extension MainSplitViewController { } private func connect(_ connection: DatabaseConnection, cancellingPrevious: Bool) { + guard let workspace = workspaces.workspace(for: connection.id) else { return } let token = UUID() - attemptToken = token - transition(to: ConnectionWindowPhaseMachine.onAttemptStarted(phase: phase)) + workspace.attemptToken = token + transition( + to: ConnectionWindowPhaseMachine.onAttemptStarted(phase: workspace.phase), + for: connection.id + ) Task { [weak self] in guard await PreConnectScriptPrompt.confirmIfNeeded(for: connection) else { - self?.finishAttempt(token, outcome: .cancelled) + self?.finishAttempt(token, for: connection.id, outcome: .cancelled) return } if cancellingPrevious { @@ -93,19 +97,27 @@ internal extension MainSplitViewController { } do { try await DatabaseManager.shared.ensureConnected(connection) - self?.finishAttempt(token, outcome: nil) + self?.finishAttempt(token, for: connection.id, outcome: nil) } catch { Self.connectionLogger.error( "Connect failed for \(connection.id, privacy: .public): \(error.localizedDescription, privacy: .public)" ) - self?.finishAttempt(token, outcome: ConnectionFailureClassifier.outcome(for: error)) + self?.finishAttempt( + token, + for: connection.id, + outcome: ConnectionFailureClassifier.outcome(for: error) + ) } } } - private func finishAttempt(_ token: UUID, outcome: ConnectionAttemptOutcome?) { - let isCurrentAttempt = attemptToken == token - if isCurrentAttempt { attemptToken = nil } + /// A connect that outlives the workspace it was started for has nothing to report to. The + /// registry entry going away is the generation check: writing a phase back here would + /// resurrect a connection the user already closed. + private func finishAttempt(_ token: UUID, for connectionId: UUID, outcome: ConnectionAttemptOutcome?) { + guard let workspace = workspaces.workspace(for: connectionId) else { return } + let isCurrentAttempt = workspace.attemptToken == token + if isCurrentAttempt { workspace.attemptToken = nil } guard let outcome else { refreshFromActiveSessions() @@ -114,10 +126,11 @@ internal extension MainSplitViewController { transition( to: ConnectionWindowPhaseMachine.onAttemptFinished( - phase: phase, + phase: workspace.phase, isCurrentAttempt: isCurrentAttempt, outcome: outcome - ) + ), + for: connectionId ) } } diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index 539c8b4d4..36984da3a 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -19,20 +19,46 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi // MARK: - Payload & Session - let payload: EditorTabPayload? + /// The connections this window hosts. A window used to answer this with its own identity, + /// which could only ever name one, so every field below reads through the selected entry. + let workspaces = ConnectionWorkspaceRegistry() + + var payload: EditorTabPayload? { workspaces.selected?.payload } + /// Re-read when the connection record changes, so a rename reaches the window's name and /// the connecting screen instead of freezing whatever the record said at creation. - private(set) var payloadConnection: DatabaseConnection? - private var currentSession: ConnectionSession? - private var sessionState: SessionStateFactory.SessionState? - private var rightPanelState: RightPanelState? + var payloadConnection: DatabaseConnection? { + get { workspaces.selected?.payloadConnection } + set { workspaces.selected?.payloadConnection = newValue } + } - let autoConnect: Bool - var attemptToken: UUID? + private var currentSession: ConnectionSession? { + get { workspaces.selected?.session } + set { workspaces.selected?.session = newValue } + } - private(set) var phase: ConnectionWindowPhase { - didSet { - guard phase != oldValue else { return } + private var sessionState: SessionStateFactory.SessionState? { + get { workspaces.selected?.sessionState } + set { workspaces.selected?.sessionState = newValue } + } + + private var rightPanelState: RightPanelState? { + get { workspaces.selected?.rightPanelState } + set { workspaces.selected?.rightPanelState = newValue } + } + + var autoConnect: Bool { workspaces.selected?.autoConnect ?? false } + + var attemptToken: UUID? { + get { workspaces.selected?.attemptToken } + set { workspaces.selected?.attemptToken = newValue } + } + + var phase: ConnectionWindowPhase { + get { workspaces.selected?.phase ?? .idle } + set { + guard let selected = workspaces.selected, selected.phase != newValue else { return } + selected.phase = newValue applyPhase() } } @@ -89,54 +115,74 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi // MARK: - Init init(payload: EditorTabPayload?, sessionState: SessionStateFactory.SessionState?, autoConnect: Bool = false) { - self.payload = payload - self.autoConnect = autoConnect - if let connectionId = payload?.connectionId { - self.payloadConnection = DatabaseManager.shared.activeSessions[connectionId]?.connection - ?? ConnectionStorage.shared.loadConnections().first { $0.id == connectionId } - } else { - self.payloadConnection = nil - } + self.windowTitle = "" + self.windowSubtitle = "" + + super.init(nibName: nil, bundle: nil) + + adoptWorkspace(payload: payload, autoConnect: autoConnect) + /// AppKit renders a native tab's label even for a tab that is never activated, so the + /// title has to be right at creation rather than at first appearance. + applyWindowTitle() + } + + /// Builds the workspace for one connection and hands it to the registry. A window reaches + /// here once per connection it hosts, so nothing may assume it runs only at construction. + @discardableResult + internal func adoptWorkspace(payload: EditorTabPayload?, autoConnect: Bool) -> ConnectionWorkspace? { var resolvedSession: ConnectionSession? if let connectionId = payload?.connectionId { resolvedSession = DatabaseManager.shared.activeSessions[connectionId] } else if let currentId = DatabaseManager.shared.lastActiveSessionId { resolvedSession = DatabaseManager.shared.activeSessions[currentId] } - self.currentSession = resolvedSession - self.windowTitle = "" - self.windowSubtitle = "" + guard let connectionId = payload?.connectionId ?? resolvedSession?.connection.id else { return nil } + + if let existing = workspaces.workspace(for: connectionId) { + workspaces.select(connectionId) + return existing + } + + let resolvedConnection = DatabaseManager.shared.activeSessions[connectionId]?.connection + ?? ConnectionStorage.shared.loadConnections().first { $0.id == connectionId } + var state: SessionStateFactory.SessionState? + var panelState: RightPanelState? if let session = resolvedSession { - self.rightPanelState = RightPanelState(connectionId: session.connection.id) - let state: SessionStateFactory.SessionState + panelState = RightPanelState(connectionId: session.connection.id) if let payloadId = payload?.id, let pending = SessionStateFactory.consumePending(for: payloadId) { state = pending Self.lifecycleLogger.info( - "[open] MainSplitVC.init consumed pending payloadId=\(payloadId, privacy: .public)" + "[open] MainSplitVC.adoptWorkspace consumed pending payloadId=\(payloadId, privacy: .public)" ) } else { state = SessionStateFactory.create(connection: session.connection, payload: payload) } - self.sessionState = state } + let phase: ConnectionWindowPhase if resolvedSession?.driver != nil { - self.phase = .connected + phase = .connected } else if resolvedSession != nil { - self.phase = .connecting + phase = .connecting } else { - self.phase = .idle - } - - super.init(nibName: nil, bundle: nil) - - /// AppKit renders a native tab's label even for a tab that is never activated, so the - /// title has to be right at creation rather than at first appearance. - applyWindowTitle() + phase = .idle + } + + let workspace = ConnectionWorkspace( + connectionId: connectionId, + payload: payload, + autoConnect: autoConnect, + payloadConnection: resolvedConnection, + session: resolvedSession, + sessionState: state, + rightPanelState: panelState, + phase: phase + ) + return workspaces.insert(workspace) } @available(*, unavailable) @@ -262,11 +308,17 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi /// `nil` is the documented bulk-update payload, so it has to repaint too. private func handleConnectionRecordChange(_ changedId: UUID?) { - guard let connectionId = payload?.connectionId ?? currentSession?.connection.id else { return } - guard changedId == nil || changedId == connectionId else { return } - guard let stored = ConnectionStorage.shared.loadConnections().first(where: { $0.id == connectionId }) - ?? DatabaseManager.shared.activeSessions[connectionId]?.connection else { return } - payloadConnection = stored + let stored = ConnectionStorage.shared.loadConnections() + var repaint = false + for workspace in workspaces.workspaces { + let connectionId = workspace.connectionId + guard changedId == nil || changedId == connectionId else { continue } + guard let record = stored.first(where: { $0.id == connectionId }) + ?? DatabaseManager.shared.activeSessions[connectionId]?.connection else { continue } + workspace.payloadConnection = record + if workspaces.selectedConnectionId == connectionId { repaint = true } + } + guard repaint else { return } applyWindowTitle() rebuildPanes() } @@ -296,15 +348,18 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi // MARK: - Connection Status + /// Every hosted connection reconciles, not only the one on screen. A background workspace + /// that loses its session still has to reach the right phase, or switching to it later + /// would show content for a connection that is already gone. private func handleConnectionStatusChange() { defer { toolbarOwner?.syncSidebarSelection() } - let resolvedId = payload?.connectionId ?? currentSession?.id ?? DatabaseManager.shared.lastActiveSessionId - - guard let sid = resolvedId else { - if currentSession != nil { currentSession = nil } - return + for workspace in workspaces.workspaces { + reconcileStatus(of: workspace) } + } + private func reconcileStatus(of workspace: ConnectionWorkspace) { + let sid = workspace.connectionId let session = DatabaseManager.shared.activeSessions[sid] let snapshot = ConnectionSessionSnapshot( exists: session != nil, @@ -313,51 +368,56 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi wasDisconnectedByUser: DatabaseManager.shared.wasDisconnectedByUser(sid) ) let nextPhase = ConnectionWindowPhaseMachine.onSessionChanged( - phase: phase, + phase: workspace.phase, session: snapshot, - ownsAttempt: attemptToken != nil + ownsAttempt: workspace.attemptToken != nil ) + let isSelected = workspaces.selectedConnectionId == sid if nextPhase == .connected, let session { - let alreadyRendered = currentSession?.isContentViewEquivalent(to: session) ?? false - if alreadyRendered, phase == nextPhase { return } - adoptSession(session) - if phase == nextPhase { rebuildPanes() } - } else if phase == .connected, nextPhase != .connected, !snapshot.exists { - releaseSession(sid) + let alreadyRendered = workspace.session?.isContentViewEquivalent(to: session) ?? false + if alreadyRendered, workspace.phase == nextPhase { return } + adoptSession(session, into: workspace) + if workspace.phase == nextPhase, isSelected { rebuildPanes() } + } else if workspace.phase == .connected, nextPhase != .connected, !snapshot.exists { + releaseSession(workspace) } - phase = nextPhase + transition(to: nextPhase, for: sid) } - private func adoptSession(_ session: ConnectionSession) { - currentSession = session + private func adoptSession(_ session: ConnectionSession, into workspace: ConnectionWorkspace) { + workspace.session = session - if rightPanelState == nil { - rightPanelState = RightPanelState(connectionId: session.connection.id) + if workspace.rightPanelState == nil { + workspace.rightPanelState = RightPanelState(connectionId: session.connection.id) } - if sessionState == nil { - let state = SessionStateFactory.create(connection: session.connection, payload: payload) - sessionState = state + if workspace.sessionState == nil { + let state = SessionStateFactory.create(connection: session.connection, payload: workspace.payload) + workspace.sessionState = state state.coordinator.inspectorProxy = self state.coordinator.splitViewController = self - installToolbar(coordinator: state.coordinator) + if workspaces.selectedConnectionId == workspace.connectionId { + installToolbar(coordinator: state.coordinator) + } } } /// Only called once the session entry is gone. A session that still exists without a driver /// is reconnecting, and tearing the coordinator down for that takes the user's open tabs and /// unsaved query edits with it over a network blip that repairs itself seconds later. - private func releaseSession(_ connectionId: UUID) { + private func releaseSession(_ workspace: ConnectionWorkspace) { Self.lifecycleLogger.info( - "[close] MainSplitVC session removed connId=\(connectionId, privacy: .public)" + "[close] MainSplitVC session removed connId=\(workspace.connectionId, privacy: .public)" ) - rightPanelState?.teardown() - rightPanelState = nil - sessionState?.coordinator.teardown() - sessionState = nil - currentSession = nil - navigationSidebar.objectBrowser.updateSidebarState(nil) + workspace.rightPanelState?.teardown() + workspace.rightPanelState = nil + workspace.sessionState?.coordinator.teardown() + workspace.sessionState = nil + workspace.session = nil + if workspaces.selectedConnectionId == workspace.connectionId { + navigationSidebar.objectBrowser.updateSidebarState(nil) + } } private func applyPhase() { @@ -386,16 +446,40 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi phase = next } + /// A window now hosts several connections, so a phase change has to name the one it belongs + /// to. Repainting is skipped for a workspace the user is not looking at: its state is still + /// correct, it simply is not the thing on screen. + internal func transition(to next: ConnectionWindowPhase, for connectionId: UUID) { + guard let workspace = workspaces.workspace(for: connectionId) else { return } + guard workspace.phase != next else { return } + workspace.phase = next + if workspaces.selectedConnectionId == connectionId { + applyPhase() + } else { + SessionRecoveryTracker.sync() + } + } + internal func refreshFromActiveSessions() { handleConnectionStatusChange() } + /// Closing the window closes every connection it hosts, so each workspace has to reach + /// `.closing` on its own. Leaving a background one behind would let it keep a restore + /// intent for a window that no longer exists. internal func markWindowClosing() { - phase = ConnectionWindowPhaseMachine.onWindowClosing(phase: phase) + for workspace in workspaces.workspaces { + workspace.phase = ConnectionWindowPhaseMachine.onWindowClosing(phase: workspace.phase) + } + applyPhase() } internal var retainsRestoreIntent: Bool { - ConnectionWindowPhaseMachine.retainsRestoreIntent(phase: phase) + workspaces.workspaces.contains { $0.retainsRestoreIntent } + } + + internal var connectionIdsRetainingRestoreIntent: [UUID] { + workspaces.workspaces.filter(\.retainsRestoreIntent).map(\.connectionId) } // MARK: - Pane Construction diff --git a/TablePro/Core/Services/Infrastructure/WindowManager.swift b/TablePro/Core/Services/Infrastructure/WindowManager.swift index a52ac9341..737468623 100644 --- a/TablePro/Core/Services/Infrastructure/WindowManager.swift +++ b/TablePro/Core/Services/Infrastructure/WindowManager.swift @@ -126,12 +126,10 @@ internal final class WindowManager { internal func connectionIdsRetainingRestoreIntent() -> [UUID] { var seen = Set() - return controllers.values.compactMap { controller -> UUID? in - guard let splitVC = controller.window?.contentViewController as? MainSplitViewController, - splitVC.retainsRestoreIntent else { return nil } - let connectionId = controller.payload.connectionId - return seen.insert(connectionId).inserted ? connectionId : nil - } + return controllers.values + .compactMap { $0.window?.contentViewController as? MainSplitViewController } + .flatMap(\.connectionIdsRetainingRestoreIntent) + .filter { seen.insert($0).inserted } } internal func closeWindow(for connectionId: UUID) { diff --git a/TableProTests/Core/Services/Infrastructure/ConnectionWorkspaceRegistryTests.swift b/TableProTests/Core/Services/Infrastructure/ConnectionWorkspaceRegistryTests.swift new file mode 100644 index 000000000..018d16c9b --- /dev/null +++ b/TableProTests/Core/Services/Infrastructure/ConnectionWorkspaceRegistryTests.swift @@ -0,0 +1,159 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("Connection workspace registry") +@MainActor +struct ConnectionWorkspaceRegistryTests { + private static let alpha = UUID(uuidString: "00000000-0000-0000-0000-0000000000A1") + private static let beta = UUID(uuidString: "00000000-0000-0000-0000-0000000000B2") + private static let gamma = UUID(uuidString: "00000000-0000-0000-0000-0000000000C3") + + private func makeWorkspace(_ connectionId: UUID) -> ConnectionWorkspace { + ConnectionWorkspace( + connectionId: connectionId, + payload: nil, + autoConnect: false, + payloadConnection: nil, + session: nil, + sessionState: nil, + rightPanelState: nil, + phase: .idle + ) + } + + @Test("Inserting selects the first workspace and keeps insertion order") + func insertOrdersAndSelects() throws { + let alpha = try #require(Self.alpha) + let beta = try #require(Self.beta) + let registry = ConnectionWorkspaceRegistry() + + registry.insert(makeWorkspace(alpha)) + registry.insert(makeWorkspace(beta)) + + #expect(registry.connectionIds == [alpha, beta]) + #expect(registry.selectedConnectionId == beta) + #expect(registry.count == 2) + } + + @Test("Inserting a connection that is already hosted selects it instead of duplicating") + func insertIsIdempotent() throws { + let alpha = try #require(Self.alpha) + let beta = try #require(Self.beta) + let registry = ConnectionWorkspaceRegistry() + let first = registry.insert(makeWorkspace(alpha)) + registry.insert(makeWorkspace(beta)) + + let second = registry.insert(makeWorkspace(alpha)) + + #expect(second === first) + #expect(registry.connectionIds == [alpha, beta]) + #expect(registry.selectedConnectionId == alpha) + } + + @Test("Each workspace keeps its own phase and attempt token") + func workspacesAreIsolated() throws { + let alpha = try #require(Self.alpha) + let beta = try #require(Self.beta) + let registry = ConnectionWorkspaceRegistry() + let first = registry.insert(makeWorkspace(alpha)) + let second = registry.insert(makeWorkspace(beta)) + + first.phase = .connected + second.phase = .unavailable(.cancelled) + first.attemptToken = UUID() + + #expect(registry.workspace(for: alpha)?.phase == .connected) + #expect(registry.workspace(for: beta)?.phase == .unavailable(.cancelled)) + #expect(registry.workspace(for: beta)?.attemptToken == nil) + } + + @Test("Each workspace owns a distinct undo manager") + func undoManagersAreNotShared() throws { + let alpha = try #require(Self.alpha) + let beta = try #require(Self.beta) + let registry = ConnectionWorkspaceRegistry() + let first = registry.insert(makeWorkspace(alpha)) + let second = registry.insert(makeWorkspace(beta)) + + #expect(first.undoManager !== second.undoManager) + } + + @Test("Removing the selected workspace lands on its neighbour") + func removeSelectsNeighbour() throws { + let alpha = try #require(Self.alpha) + let beta = try #require(Self.beta) + let gamma = try #require(Self.gamma) + let registry = ConnectionWorkspaceRegistry() + registry.insert(makeWorkspace(alpha)) + registry.insert(makeWorkspace(beta)) + registry.insert(makeWorkspace(gamma)) + registry.select(beta) + + registry.remove(beta) + + #expect(registry.connectionIds == [alpha, gamma]) + #expect(registry.selectedConnectionId == gamma) + } + + @Test("Removing the last workspace clears the selection") + func removeLastClearsSelection() throws { + let alpha = try #require(Self.alpha) + let registry = ConnectionWorkspaceRegistry() + registry.insert(makeWorkspace(alpha)) + + registry.remove(alpha) + + #expect(registry.isEmpty) + #expect(registry.selectedConnectionId == nil) + #expect(registry.selected == nil) + } + + @Test("Selecting a connection the registry does not host is ignored") + func selectUnknownIsIgnored() throws { + let alpha = try #require(Self.alpha) + let beta = try #require(Self.beta) + let registry = ConnectionWorkspaceRegistry() + registry.insert(makeWorkspace(alpha)) + + registry.select(beta) + + #expect(registry.selectedConnectionId == alpha) + } + + @Test("A removed workspace is no longer reachable, so a late attempt finds nothing to write") + func removedWorkspaceIsUnreachable() throws { + let alpha = try #require(Self.alpha) + let registry = ConnectionWorkspaceRegistry() + registry.insert(makeWorkspace(alpha)) + + registry.remove(alpha) + + #expect(registry.workspace(for: alpha) == nil) + #expect(registry.contains(alpha) == false) + } + + @Test("Cycling wraps in both directions") + func cycleWraps() throws { + let alpha = try #require(Self.alpha) + let beta = try #require(Self.beta) + let gamma = try #require(Self.gamma) + let order = [alpha, beta, gamma] + + #expect(ConnectionWorkspaceRegistry.cycled(in: order, from: gamma, by: 1) == alpha) + #expect(ConnectionWorkspaceRegistry.cycled(in: order, from: alpha, by: -1) == gamma) + #expect(ConnectionWorkspaceRegistry.cycled(in: order, from: beta, by: 1) == gamma) + #expect(ConnectionWorkspaceRegistry.cycled(in: [], from: alpha, by: 1) == nil) + #expect(ConnectionWorkspaceRegistry.cycled(in: order, from: nil, by: 1) == alpha) + } + + @Test("The neighbour of a removed row is the row that took its place") + func neighbourResolution() throws { + let alpha = try #require(Self.alpha) + let beta = try #require(Self.beta) + + #expect(ConnectionWorkspaceRegistry.neighbour(in: [alpha, beta], removedIndex: 0) == alpha) + #expect(ConnectionWorkspaceRegistry.neighbour(in: [alpha], removedIndex: 1) == alpha) + #expect(ConnectionWorkspaceRegistry.neighbour(in: [], removedIndex: 0) == nil) + } +} From f314e4a67ea51e673a6eea73f3f8a19fed94b3e0 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 12 Aug 2026 13:40:43 +0700 Subject: [PATCH 02/47] feat(tabs): open editor tabs in one window instead of one window per tab --- CHANGELOG.md | 1 + TablePro/Core/Menu/FileMenuBuilder.swift | 4 +- TablePro/Core/Menu/WindowMenuBuilder.swift | 4 +- ...nSplitViewController+FileMenuActions.swift | 19 ++ ...inSplitViewController+MenuValidation.swift | 10 + TablePro/Models/Query/QueryTabManager.swift | 22 +++ .../Models/Query/TabBatchClosePlanner.swift | 88 --------- TablePro/Views/Main/EditorTabStrip.swift | 95 ++++++++++ .../MainContentCommandActions+BulkClose.swift | 119 +++++------- .../Main/MainContentCommandActions.swift | 60 +++--- TablePro/Views/Main/MainContentView.swift | 17 +- .../Query/QueryTabManagerCloseTests.swift | 104 +++++++++++ .../Query/TabBatchClosePlannerTests.swift | 175 ------------------ 13 files changed, 345 insertions(+), 373 deletions(-) delete mode 100644 TablePro/Models/Query/TabBatchClosePlanner.swift create mode 100644 TablePro/Views/Main/EditorTabStrip.swift create mode 100644 TableProTests/Models/Query/QueryTabManagerCloseTests.swift delete mode 100644 TableProTests/Models/Query/TabBatchClosePlannerTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index e18fc5a53..ad9965db2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Opening a table or query on a connection you already have open adds a tab to that window instead of opening another window. A tab strip appears once a connection holds more than one tab. - Mobile keeps remote connections open when you switch apps. - MongoDB shows a standard binary UUID as `UUID("...")` everywhere, including in exports. - Mobile no longer copies database passwords to iCloud Keychain unless you turn on Sync Passwords. Mac already worked this way. diff --git a/TablePro/Core/Menu/FileMenuBuilder.swift b/TablePro/Core/Menu/FileMenuBuilder.swift index 95231e98d..43b4e0c23 100644 --- a/TablePro/Core/Menu/FileMenuBuilder.swift +++ b/TablePro/Core/Menu/FileMenuBuilder.swift @@ -17,7 +17,7 @@ enum FileMenuBuilder { ), MenuItemFactory.item( String(localized: "New Tab"), - action: #selector(NSWindow.newWindowForTab(_:)), + action: #selector(MainSplitViewController.newEditorTab(_:)), shortcut: .newTab, keyboard: keyboard ), @@ -50,7 +50,7 @@ enum FileMenuBuilder { MenuItemFactory.separator, MenuItemFactory.item( String(localized: "Close Tab"), - action: #selector(NSWindow.performClose(_:)), + action: #selector(MainSplitViewController.closeEditorTab(_:)), shortcut: .closeTab, keyboard: keyboard ), diff --git a/TablePro/Core/Menu/WindowMenuBuilder.swift b/TablePro/Core/Menu/WindowMenuBuilder.swift index 0895adb56..af0a8b0b3 100644 --- a/TablePro/Core/Menu/WindowMenuBuilder.swift +++ b/TablePro/Core/Menu/WindowMenuBuilder.swift @@ -27,13 +27,13 @@ enum WindowMenuBuilder { MenuItemFactory.separator, MenuItemFactory.item( String(localized: "Show Previous Tab"), - action: #selector(NSWindow.selectPreviousTab(_:)), + action: #selector(MainSplitViewController.selectPreviousEditorTab(_:)), shortcut: .showPreviousTab, keyboard: keyboard ), MenuItemFactory.item( String(localized: "Show Next Tab"), - action: #selector(NSWindow.selectNextTab(_:)), + action: #selector(MainSplitViewController.selectNextEditorTab(_:)), shortcut: .showNextTab, keyboard: keyboard ), diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+FileMenuActions.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+FileMenuActions.swift index 7023e0ef0..e6525014c 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+FileMenuActions.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+FileMenuActions.swift @@ -18,6 +18,25 @@ extension MainSplitViewController { commandActions?.saveFileAs() } + /// New Tab and Close Tab used to run AppKit's own `newWindowForTab:` and `performClose:`, + /// which named windows because a tab was a window. They act on the tab list now, so the + /// menu and the strip's own controls cannot disagree. + @objc func newEditorTab(_ sender: Any?) { + commandActions?.newTab() + } + + @objc func closeEditorTab(_ sender: Any?) { + commandActions?.closeTab() + } + + @objc func selectNextEditorTab(_ sender: Any?) { + commandActions?.selectTab(offsetBy: 1) + } + + @objc func selectPreviousEditorTab(_ sender: Any?) { + commandActions?.selectTab(offsetBy: -1) + } + @objc func closeOtherTabs(_ sender: Any?) { commandActions?.closeOtherTabs() } diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift index 996deebdf..8d85af070 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift @@ -84,6 +84,16 @@ extension MainSplitViewController: NSMenuItemValidation { case #selector(saveDocumentAs(_:)): return context.isConnected + /// AppKit validated New Tab and Close Tab for free while they were its own selectors. + /// `NSWindow.validateUserInterfaceItem` only speaks to the native ones, so these are + /// ours to enable and disable now. + case #selector(newEditorTab(_:)): + return context.isConnected + case #selector(closeEditorTab(_:)): + return context.isConnected + case #selector(selectNextEditorTab(_:)), #selector(selectPreviousEditorTab(_:)): + return context.isConnected + case #selector(closeOtherTabs(_:)): return context.canCloseOtherTabs case #selector(closeTabsForOtherContainers(_:)): diff --git a/TablePro/Models/Query/QueryTabManager.swift b/TablePro/Models/Query/QueryTabManager.swift index a1e7186e8..8cb703144 100644 --- a/TablePro/Models/Query/QueryTabManager.swift +++ b/TablePro/Models/Query/QueryTabManager.swift @@ -42,6 +42,28 @@ final class QueryTabManager { self.tabSessionRegistry = tabSessionRegistry } + /// Closing a tab used to mean closing its window, because a tab was a window. Selection + /// lands on the tab that took its place so the pane never blanks while others are open. + func closeTab(id: UUID) { + guard let index = tabs.firstIndex(where: { $0.id == id }) else { return } + let wasSelected = selectedTab?.id == id + tabs.remove(at: index) + guard wasSelected else { return } + selectedTabId = tabs.indices.contains(index) ? tabs[index].id : tabs.last?.id + } + + func selectTab(at index: Int) { + guard tabs.indices.contains(index) else { return } + selectedTabId = tabs[index].id + } + + func selectTab(offsetBy offset: Int) { + guard !tabs.isEmpty else { return } + let current = selectedTab.flatMap { tab in tabs.firstIndex { $0.id == tab.id } } ?? 0 + let count = tabs.count + selectedTabId = tabs[((current + offset) % count + count) % count].id + } + func bindTabSessionRegistry(_ registry: TabSessionRegistry) { tabSessionRegistry = registry for tab in tabs where registry.session(for: tab.id) == nil { diff --git a/TablePro/Models/Query/TabBatchClosePlanner.swift b/TablePro/Models/Query/TabBatchClosePlanner.swift deleted file mode 100644 index 14e07c639..000000000 --- a/TablePro/Models/Query/TabBatchClosePlanner.swift +++ /dev/null @@ -1,88 +0,0 @@ -// -// TabBatchClosePlanner.swift -// TablePro -// - -import Foundation - -struct TabBatchCloseTarget: Equatable { - let windowId: ObjectIdentifier - let containerNames: Set -} - -enum TabBatchClosePlanner { - struct Plan: Equatable { - let windowsToCloseOutright: [ObjectIdentifier] - let survivorWindowId: ObjectIdentifier? - - static let empty = Plan(windowsToCloseOutright: [], survivorWindowId: nil) - - var isEmpty: Bool { - windowsToCloseOutright.isEmpty && survivorWindowId == nil - } - } - - static func planCloseAll( - targets: [TabBatchCloseTarget], - currentWindowId: ObjectIdentifier - ) -> Plan { - Plan( - windowsToCloseOutright: windowIds(in: targets, excluding: currentWindowId), - survivorWindowId: currentWindowId - ) - } - - static func planCloseOthers( - targets: [TabBatchCloseTarget], - currentWindowId: ObjectIdentifier - ) -> Plan { - Plan( - windowsToCloseOutright: windowIds(in: targets, excluding: currentWindowId), - survivorWindowId: nil - ) - } - - /// A window closes only when every tab in it names a container other than the active one. - /// An unnamed container means "whatever the connection is pointed at", which is never foreign, - /// and an unknown active container cannot classify anything, so both yield an empty plan. - static func planCloseForOtherContainers( - targets: [TabBatchCloseTarget], - currentWindowId: ObjectIdentifier, - currentContainerName: String - ) -> Plan { - guard !currentContainerName.isEmpty else { return .empty } - let foreign = targets.filter { target in - target.windowId != currentWindowId - && !target.containerNames.isEmpty - && !target.containerNames.contains(currentContainerName) - } - return Plan(windowsToCloseOutright: foreign.map(\.windowId), survivorWindowId: nil) - } - - /// Closing one workspace takes only the windows whose every tab belongs to it. A window - /// holding a tab on another container is shared, so it stays: the rail's promise is that a - /// row disappears when its own work is gone, never that it closes someone else's. - /// - /// An empty name is the single workspace of an engine that switches neither database nor - /// schema, so its windows are the ones whose tabs name no container at all. Treating that as - /// "nothing to close" made the command inert for SQLite, Redis, DuckDB and every other - /// single-container engine. - static func planCloseContainer( - targets: [TabBatchCloseTarget], - containerName: String - ) -> Plan { - let owned = targets.filter { target in - containerName.isEmpty - ? target.containerNames.isEmpty - : target.containerNames == [containerName] - } - return Plan(windowsToCloseOutright: owned.map(\.windowId), survivorWindowId: nil) - } - - private static func windowIds( - in targets: [TabBatchCloseTarget], - excluding currentWindowId: ObjectIdentifier - ) -> [ObjectIdentifier] { - targets.map(\.windowId).filter { $0 != currentWindowId } - } -} diff --git a/TablePro/Views/Main/EditorTabStrip.swift b/TablePro/Views/Main/EditorTabStrip.swift new file mode 100644 index 000000000..8798f6c0e --- /dev/null +++ b/TablePro/Views/Main/EditorTabStrip.swift @@ -0,0 +1,95 @@ +// +// EditorTabStrip.swift +// TablePro +// + +import SwiftUI + +/// The editor tabs for one connection. Native window tabs cannot express this: a window belongs +/// to exactly one tab group and a group's bar shows every window in it, so one window hosting +/// several connections could only ever show all of their tabs interleaved. +internal struct EditorTabStrip: View { + internal let tabManager: QueryTabManager + internal let onClose: (UUID) -> Void + internal let onNewTab: () -> Void + + @Environment(\.colorScheme) private var colorScheme + + internal var body: some View { + HStack(spacing: 0) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 0) { + ForEach(tabManager.tabs) { tab in + EditorTabStripItem( + tab: tab, + isSelected: tabManager.selectedTab?.id == tab.id, + onSelect: { tabManager.selectedTabId = tab.id }, + onClose: { onClose(tab.id) } + ) + Divider().frame(height: Self.dividerHeight) + } + } + } + + Button(action: onNewTab) { + Image(systemName: "plus") + .frame(width: Self.newTabButtonWidth, height: Self.height) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help(Text("New Tab")) + .accessibilityLabel(Text("New Tab")) + } + .frame(height: Self.height) + .background(Color(nsColor: .windowBackgroundColor)) + .overlay(alignment: .bottom) { + Divider() + } + .accessibilityElement(children: .contain) + .accessibilityLabel(Text("Editor Tabs")) + } + + internal static let height: CGFloat = 28 + private static let dividerHeight: CGFloat = 16 + private static let newTabButtonWidth: CGFloat = 28 +} + +private struct EditorTabStripItem: View { + let tab: QueryTab + let isSelected: Bool + let onSelect: () -> Void + let onClose: () -> Void + + @State private var isHovering = false + + var body: some View { + HStack(spacing: 4) { + Text(tab.title) + .lineLimit(1) + .italic(tab.isPreview) + .font(.system(size: 12, weight: isSelected ? .medium : .regular)) + + Button(action: onClose) { + Image(systemName: "xmark") + .font(.system(size: 8, weight: .bold)) + .frame(width: 14, height: 14) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .opacity(isHovering || isSelected ? 1 : 0) + .accessibilityLabel(Text("Close Tab")) + } + .padding(.horizontal, 10) + .frame(height: EditorTabStrip.height) + .frame(minWidth: 80, maxWidth: 200) + .background(isSelected ? Color(nsColor: .selectedContentBackgroundColor).opacity(0.25) : .clear) + .contentShape(Rectangle()) + .onHover { isHovering = $0 } + .onTapGesture(perform: onSelect) + .help(Text(tab.title)) + .accessibilityElement(children: .combine) + .accessibilityLabel(Text(tab.title)) + .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton) + .accessibilityAction(named: Text("Close Tab"), onClose) + } +} diff --git a/TablePro/Views/Main/MainContentCommandActions+BulkClose.swift b/TablePro/Views/Main/MainContentCommandActions+BulkClose.swift index 337cad646..7b3065d72 100644 --- a/TablePro/Views/Main/MainContentCommandActions+BulkClose.swift +++ b/TablePro/Views/Main/MainContentCommandActions+BulkClose.swift @@ -14,9 +14,7 @@ extension MainContentCommandActions { case container(String) } - /// Closes a workspace from the rail: every window of this connection whose tabs all sit in - /// that container. Routed through the same batch machinery as the menu commands, so it - /// inherits the per-window save prompt and the cancel-stops-the-rest behaviour. + /// Closes a workspace from the rail: every tab of this connection sitting in that container. func closeWorkspace(container: String) { Task { await runBatchClose(kind: .container(container)) } } @@ -34,16 +32,16 @@ extension MainContentCommandActions { } var canCloseAllTabs: Bool { - openTabCount > 0 || !batchClosePlan(kind: .all).windowsToCloseOutright.isEmpty + !tabsToClose(kind: .all).isEmpty } var canCloseOtherTabs: Bool { - !batchClosePlan(kind: .others).windowsToCloseOutright.isEmpty + !tabsToClose(kind: .others).isEmpty } var canCloseTabsForOtherDatabases: Bool { guard supportsContainerSwitching else { return false } - return !batchClosePlan(kind: .otherDatabases).windowsToCloseOutright.isEmpty + return !tabsToClose(kind: .otherDatabases).isEmpty } /// The container the connection is browsing, named the way this engine names containers. @@ -65,88 +63,63 @@ extension MainContentCommandActions { } } - /// Closes every window the plan names, one at a time so each keeps the ordinary single-window - /// save prompt, then empties the survivor. Siblings go first: a cancel part-way through then - /// leaves the window the user is actually looking at untouched. + /// Tabs live in one window now, so a batch close is a list edit rather than a walk over + /// sibling windows. Closing every tab is still a window close, which already owns the save + /// prompt and the recovery capture, so that case is handed straight to it. private func runBatchClose(kind: BatchCloseKind) async { - let lookup = closeCandidateLookup(kind: kind) - let plan = batchClosePlan(kind: kind, lookup: lookup) - guard !plan.isEmpty else { return } + guard let coordinator else { return } + let victims = tabsToClose(kind: kind) + guard !victims.isEmpty else { return } - for windowId in plan.windowsToCloseOutright { - guard let actions = lookup[windowId]?.commandActions else { continue } - guard await actions.closeWindowAwaiting(asBatchSurvivor: false) == .closed else { return } + if victims.count == coordinator.tabManager.tabs.count { + await closeWindowAwaiting() + return } - guard plan.survivorWindowId != nil, openTabCount > 0 else { return } - await closeWindowAwaiting(asBatchSurvivor: true) + guard await confirmDiscardingUnsavedWork() else { return } + + for tab in victims { + RecentlyClosedTabStore.shared.push(tab: tab, connection: coordinator.connection) + coordinator.tabManager.closeTab(id: tab.id) + } } - private func batchClosePlan(kind: BatchCloseKind) -> TabBatchClosePlanner.Plan { - batchClosePlan(kind: kind, lookup: closeCandidateLookup(kind: kind)) + /// A partial close leaves the window open, so it cannot lean on the window's own prompt. + /// Unsaved work is tracked for the connection rather than per tab, so the question is asked + /// once for the batch. + private func confirmDiscardingUnsavedWork() async -> Bool { + guard hasUnsavedWorkInWindow else { return true } + + switch await AlertHelper.confirmSaveChanges( + message: String(localized: "Your changes will be lost if you don't save them."), + window: closeAnchorWindow + ) { + case .save: + saveChanges() + return false + case .dontSave: + return true + case .cancel: + return false + } } - private func batchClosePlan( - kind: BatchCloseKind, - lookup: [ObjectIdentifier: MainContentCoordinator] - ) -> TabBatchClosePlanner.Plan { - guard let anchor = closeAnchorWindow else { return .empty } - let currentWindowId = ObjectIdentifier(anchor) + private func tabsToClose(kind: BatchCloseKind) -> [QueryTab] { + guard let coordinator else { return [] } + let tabs = coordinator.tabManager.tabs let target = PluginManager.shared.containerSwitchTarget(for: currentDatabaseType) - let targets = lookup.map { windowId, coordinator in - TabBatchCloseTarget( - windowId: windowId, - containerNames: coordinator.openTabContainerNames(target: target) - ) - } switch kind { case .all: - return TabBatchClosePlanner.planCloseAll(targets: targets, currentWindowId: currentWindowId) + return tabs case .others: - return TabBatchClosePlanner.planCloseOthers(targets: targets, currentWindowId: currentWindowId) + guard let selectedId = coordinator.tabManager.selectedTab?.id else { return [] } + return tabs.filter { $0.id != selectedId } case .otherDatabases: - return TabBatchClosePlanner.planCloseForOtherContainers( - targets: targets, - currentWindowId: currentWindowId, - currentContainerName: browsedContainerName - ) + let current = browsedContainerName + return tabs.filter { WorkspaceAnchoring.containerName(of: $0, target: target) != current } case .container(let container): - return TabBatchClosePlanner.planCloseContainer(targets: targets, containerName: container) + return tabs.filter { WorkspaceAnchoring.containerName(of: $0, target: target) == container } } } - - /// Tab-group scope for the positional commands, because that is the strip the user is looking - /// at. Connection scope for the database command, because a database means nothing across - /// connection: a native tab group belongs to one connection, so the positional commands - /// intersect that group with the connection, while the database command spans the whole - /// connection regardless of which window a tab sits in. - private func closeCandidateLookup(kind: BatchCloseKind) -> [ObjectIdentifier: MainContentCoordinator] { - let coordinators: [MainContentCoordinator] - switch kind { - case .all, .others: - guard let anchor = closeAnchorWindow else { return [:] } - coordinators = (anchor.tabGroup?.windows ?? [anchor]) - .filter(\.isVisible) - .compactMap { MainContentCoordinator.coordinator(forWindow: $0) } - .filter { $0.connectionId == connectionId } - case .otherDatabases, .container: - coordinators = MainContentCoordinator.allActiveCoordinators() - .filter { $0.connectionId == connectionId } - } - - return coordinators.reduce(into: [:]) { result, coordinator in - guard let window = coordinator.contentWindow else { return } - result[ObjectIdentifier(window)] = coordinator - } - } -} - -private extension MainContentCoordinator { - /// Named by the same rule the workspace rail uses, so a close command and the row it - /// removes can never disagree about which container a tab belongs to. Every tab counts - /// here, including an untouched one the rail would not give a row of its own. - func openTabContainerNames(target: ContainerSwitchTarget?) -> Set { - Set(tabManager.tabs.compactMap { WorkspaceAnchoring.containerName(of: $0, target: target) }) - } } diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index ff3bcf058..e653cf744 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -364,7 +364,7 @@ final class MainContentCommandActions { /// Scoped to the whole window, not the selected tab: closing a window closes every tab in it, /// so a tab the user is not looking at must still get its prompt. - private var hasUnsavedWorkInWindow: Bool { + internal var hasUnsavedWorkInWindow: Bool { coordinator?.hasAnyUnsavedWork() ?? false } @@ -398,25 +398,32 @@ final class MainContentCommandActions { // MARK: - Tab Operations (Group A — Called Directly) + /// A new tab joins the connection's own tab list. It used to open another window whenever + /// the list was not empty, which is why two tables meant two windows. func newTab(initialQuery: String? = nil) { - if let coordinator, coordinator.tabManager.tabs.isEmpty { - coordinator.tabManager.addTab( - initialQuery: initialQuery, - databaseName: coordinator.browseDatabaseName, - claimFocus: true - ) - return - } - let payload = EditorTabPayload( - connectionId: connection.id, + guard let coordinator else { return } + coordinator.tabManager.addTab( initialQuery: initialQuery, - intent: .newEmptyTab + databaseName: coordinator.browseDatabaseName, + claimFocus: true ) - WindowManager.shared.openTab(payload: payload) } + func closeTab(id: UUID) { + guard let coordinator else { return } + coordinator.tabManager.closeTab(id: id) + if coordinator.tabManager.tabs.isEmpty { + Task { await closeWindowAwaiting() } + } + } + + /// Closing the last tab closes the window, which is what Cmd+W does everywhere on macOS. func closeTab() { - Task { await closeWindowAwaiting() } + guard let coordinator, let selected = coordinator.tabManager.selectedTab else { + Task { await closeWindowAwaiting() } + return + } + closeTab(id: selected.id) } /// The single close primitive. `asBatchSurvivor` is `nil` for a lone close gesture, which lets @@ -690,25 +697,14 @@ final class MainContentCommandActions { // MARK: - Tab Navigation (Group A — Called Directly) - /// Selects the Nth native window tab. Wrapping the `selectedWindow` - /// assignment in `NSAnimationContext.runAnimationGroup` with `duration = 0` - /// suppresses AppKit's tab-transition animation, so rapid Cmd+Number - /// presses don't queue up CAAnimations that drain visibly after the user - /// releases the keys. - /// - /// Per-switch AppKit overhead (window-focus change, NSHostingView layout, - /// Window Server roundtrip) is platform-inherent to one-NSWindow-per-tab - /// and is intentionally not coalesced. See `docs/architecture/tab-subsystem-rewrite.md` D2. + /// Selects the Nth editor tab of the connection on screen. It used to index the window's + /// native tab group, which named windows rather than tabs. func selectTab(number: Int) { - guard let keyWindow = NSApp.keyWindow, - let tabGroup = keyWindow.tabGroup else { return } - let windows = tabGroup.windows - guard windows.indices.contains(number - 1) else { return } - let target = windows[number - 1] - NSAnimationContext.runAnimationGroup { context in - context.duration = 0 - tabGroup.selectedWindow = target - } + coordinator?.tabManager.selectTab(at: number - 1) + } + + func selectTab(offsetBy offset: Int) { + coordinator?.tabManager.selectTab(offsetBy: offset) } // MARK: - Filter Operations (Group A — Called Directly) diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index f92d1f2a9..4763ff21b 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -344,7 +344,7 @@ struct MainContentView: View { } private var bodyContentCore: some View { - mainContentView + editorTabStripAndContent // Phase 3: SwiftUI `.toolbar { ... }` removed — NSToolbar is now // installed directly on NSWindow by TabWindowController (see // `MainWindowToolbar`). Reuses every existing SwiftUI subview @@ -416,6 +416,21 @@ struct MainContentView: View { // MARK: - Main Content + /// The strip is hidden while a connection holds a single tab, so a window that behaves the + /// way it always did gains no chrome. It appears the moment a second tab exists. + private var editorTabStripAndContent: some View { + VStack(spacing: 0) { + if tabManager.tabs.count > 1 { + EditorTabStrip( + tabManager: tabManager, + onClose: { coordinator.commandActions?.closeTab(id: $0) }, + onNewTab: { coordinator.commandActions?.newTab() } + ) + } + mainContentView + } + } + @ViewBuilder private var mainContentView: some View { MainEditorContentView( diff --git a/TableProTests/Models/Query/QueryTabManagerCloseTests.swift b/TableProTests/Models/Query/QueryTabManagerCloseTests.swift new file mode 100644 index 000000000..18dd9758c --- /dev/null +++ b/TableProTests/Models/Query/QueryTabManagerCloseTests.swift @@ -0,0 +1,104 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("Query tab manager tab list operations") +@MainActor +struct QueryTabManagerCloseTests { + private func makeManager(tabCount: Int) -> QueryTabManager { + let manager = QueryTabManager() + for index in 0.. = []) -> TabBatchCloseTarget { - TabBatchCloseTarget(windowId: ObjectIdentifier(token), containerNames: databases) - } - - // MARK: - Close All - - @Test("closing all keeps the invoking window as the survivor") - func closeAllKeepsCurrentWindowAsSurvivor() { - let plan = TabBatchClosePlanner.planCloseAll( - targets: [target(second), target(current), target(third)], - currentWindowId: currentId - ) - - #expect(plan.windowsToCloseOutright == [secondId, thirdId]) - #expect(plan.survivorWindowId == currentId) - } - - @Test("closing all with only the invoking window still empties it") - func closeAllWithSingleWindowStillHasSurvivor() { - let plan = TabBatchClosePlanner.planCloseAll(targets: [target(current)], currentWindowId: currentId) - - #expect(plan.windowsToCloseOutright.isEmpty) - #expect(plan.survivorWindowId == currentId) - #expect(!plan.isEmpty) - } - - // MARK: - Close Others - - @Test("closing others targets the same windows but keeps no survivor") - func closeOthersHasNoSurvivor() { - let plan = TabBatchClosePlanner.planCloseOthers( - targets: [target(second), target(current), target(third)], - currentWindowId: currentId - ) - - #expect(plan.windowsToCloseOutright == [secondId, thirdId]) - #expect(plan.survivorWindowId == nil) - } - - @Test("closing others is a no-op when the invoking window is alone") - func closeOthersWithSingleWindowIsEmpty() { - let plan = TabBatchClosePlanner.planCloseOthers(targets: [target(current)], currentWindowId: currentId) - - #expect(plan.isEmpty) - } - - // MARK: - Close for other databases - - @Test("only windows whose every tab names another database are closed") - func closeForOtherDatabasesTargetsForeignWindowsOnly() { - let plan = TabBatchClosePlanner.planCloseForOtherContainers( - targets: [target(current, databases: ["db_a"]), target(second, databases: ["db_b"])], - currentWindowId: currentId, - currentContainerName: "db_a" - ) - - #expect(plan.windowsToCloseOutright == [secondId]) - #expect(plan.survivorWindowId == nil) - } - - @Test("a window holding a tab for the active database is kept") - func closeForOtherDatabasesKeepsMixedWindow() { - let plan = TabBatchClosePlanner.planCloseForOtherContainers( - targets: [target(second, databases: ["db_a", "db_b"])], - currentWindowId: currentId, - currentContainerName: "db_a" - ) - - #expect(plan.isEmpty) - } - - @Test("a window with no named database follows the connection and is kept") - func closeForOtherDatabasesKeepsUnnamedWindow() { - let plan = TabBatchClosePlanner.planCloseForOtherContainers( - targets: [target(second)], - currentWindowId: currentId, - currentContainerName: "db_a" - ) - - #expect(plan.isEmpty) - } - - @Test("the invoking window is never closed even when it names another database") - func closeForOtherDatabasesNeverClosesCurrentWindow() { - let plan = TabBatchClosePlanner.planCloseForOtherContainers( - targets: [target(current, databases: ["db_b"])], - currentWindowId: currentId, - currentContainerName: "db_a" - ) - - #expect(plan.isEmpty) - } - - @Test("an unknown active database closes nothing") - func closeForOtherDatabasesWithoutActiveDatabaseIsEmpty() { - let plan = TabBatchClosePlanner.planCloseForOtherContainers( - targets: [target(second, databases: ["db_b"]), target(third, databases: ["db_c"])], - currentWindowId: currentId, - currentContainerName: "" - ) - - #expect(plan.isEmpty) - } - - // MARK: - Close Workspace - - @Test("closing a workspace takes every window that belongs only to it") - func closeContainerTakesItsOwnWindows() { - let plan = TabBatchClosePlanner.planCloseContainer( - targets: [ - target(current, databases: ["db_a"]), - target(second, databases: ["db_a"]), - target(third, databases: ["db_b"]), - ], - containerName: "db_a" - ) - - #expect(Set(plan.windowsToCloseOutright) == [currentId, secondId]) - #expect(plan.survivorWindowId == nil) - } - - @Test("a window shared with another database survives, because its other tab is not ours to close") - func closeContainerSpareSharedWindows() { - let plan = TabBatchClosePlanner.planCloseContainer( - targets: [target(current, databases: ["db_a", "db_b"])], - containerName: "db_a" - ) - - #expect(plan.isEmpty) - } - - @Test("closing a workspace ignores windows holding nothing") - func closeContainerIgnoresUnnamedWindows() { - let plan = TabBatchClosePlanner.planCloseContainer( - targets: [target(current, databases: [])], - containerName: "db_a" - ) - - #expect(plan.isEmpty) - } - - @Test("the unnamed workspace of a single-container engine closes the windows that name nothing") - func closeContainerHandlesTheUnnamedWorkspace() { - let plan = TabBatchClosePlanner.planCloseContainer( - targets: [target(current, databases: []), target(second, databases: ["db_a"])], - containerName: "" - ) - - #expect(plan.windowsToCloseOutright == [currentId]) - } -} From 0c45a2dcd807ae0418c58985a592c0a816c3c30a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 12 Aug 2026 14:13:31 +0700 Subject: [PATCH 03/47] feat(connections): host every open connection in a single window --- CHANGELOG.md | 2 + TablePro/Core/Menu/WindowMenuBuilder.swift | 4 ++ .../MainSplitViewController.swift | 17 +++++ .../Infrastructure/TabWindowController.swift | 14 ++-- .../Infrastructure/WindowManager.swift | 69 +++++++++++++++---- .../WorkspaceRailViewController.swift | 15 ++++ .../Extensions/MainContentView+Setup.swift | 6 +- .../Services/WindowTabGroupingTests.swift | 52 +++++--------- 8 files changed, 118 insertions(+), 61 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad9965db2..70ccd29f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Every open connection now lives in one window. Picking a connection in the workspace rail switches that window to it instead of raising a second window. - Opening a table or query on a connection you already have open adds a tab to that window instead of opening another window. A tab strip appears once a connection holds more than one tab. +- Window tabs follow your "Prefer tabs when opening documents" setting instead of always forcing tabs, and the Window menu gained Merge All Windows. - Mobile keeps remote connections open when you switch apps. - MongoDB shows a standard binary UUID as `UUID("...")` everywhere, including in exports. - Mobile no longer copies database passwords to iCloud Keychain unless you turn on Sync Passwords. Mac already worked this way. diff --git a/TablePro/Core/Menu/WindowMenuBuilder.swift b/TablePro/Core/Menu/WindowMenuBuilder.swift index af0a8b0b3..63c058fac 100644 --- a/TablePro/Core/Menu/WindowMenuBuilder.swift +++ b/TablePro/Core/Menu/WindowMenuBuilder.swift @@ -41,6 +41,10 @@ enum WindowMenuBuilder { String(localized: "Move Tab to New Window"), action: #selector(NSWindow.moveTabToNewWindow(_:)) ), + MenuItemFactory.item( + String(localized: "Merge All Windows"), + action: #selector(NSWindow.mergeAllWindows(_:)) + ), MenuItemFactory.separator ] diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index 36984da3a..a1d45a19e 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -233,6 +233,10 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi /// The saved layout is restored before any phase-driven collapse, so the user's widths /// are already in the live layout. Uncollapsing then returns the pane to the size /// `NSSplitViewItem` remembers, rather than depending on a second restore. + workspaces.onSelectionChange = { [weak self] _ in + self?.applySelectedWorkspace() + } + restoreUserPaneLayout() rebuildPanes() applyPaneChrome() @@ -420,6 +424,19 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi } } + /// Switching workspace repaints the window in place. The rail used to raise a different + /// window instead, which is what made several connections mean several windows. + internal func applySelectedWorkspace() { + if let coordinator = workspaces.selected?.sessionState?.coordinator { + coordinator.inspectorProxy = self + coordinator.splitViewController = self + installToolbar(coordinator: coordinator) + } + rebuildPanes() + applyPaneChrome() + applyWindowTitle() + } + private func applyPhase() { rebuildPanes() applyPaneChrome() diff --git a/TablePro/Core/Services/Infrastructure/TabWindowController.swift b/TablePro/Core/Services/Infrastructure/TabWindowController.swift index 6c80c953b..37cf502cc 100644 --- a/TablePro/Core/Services/Infrastructure/TabWindowController.swift +++ b/TablePro/Core/Services/Infrastructure/TabWindowController.swift @@ -39,10 +39,6 @@ private final class EditorWindow: NSWindow { internal final class TabWindowController: NSWindowController, NSWindowDelegate { private static let lifecycleLogger = Logger(subsystem: "com.TablePro", category: "NativeTabLifecycle") - /// Deliberately one shared slot for every connection. The rail switches workspaces, so - /// every connection's window must occupy the same frame: a rail click then reads as the - /// window changing content rather than a different window being raised. Offsetting them - /// per connection, or cascading, breaks that illusion. internal static let frameAutosaveName: NSWindow.FrameAutosaveName = "MainEditorWindow" internal let payload: EditorTabPayload @@ -70,8 +66,10 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { window.isRestorable = false window.toolbarStyle = .unified window.titleVisibility = .visible - window.tabbingMode = .preferred - window.tabbingIdentifier = WindowManager.tabbingIdentifier(for: payload.connectionId) + /// Apple asks an app that drives tabbing itself to read the user's preference before + /// showing a window rather than forcing tabs, which hard-coding `.preferred` did. + window.tabbingMode = NSWindow.userTabbingPreference == .always ? .preferred : .automatic + window.tabbingIdentifier = WindowManager.mainTabbingIdentifier window.collectionBehavior.insert([.fullScreenPrimary, .managed]) let splitVC = MainSplitViewController( @@ -88,9 +86,7 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { window.isReleasedWhenClosed = false window.delegate = self - if let sibling = NSApp.windows.first(where: { WindowManager.isMainWindow($0) && $0.isVisible }) { - window.setFrame(sibling.frame, display: false) - } else if !window.setFrameUsingName(Self.frameAutosaveName) { + if !window.setFrameUsingName(Self.frameAutosaveName) { let visibleSize = (window.screen ?? NSScreen.main)?.visibleFrame.size ?? NSSize(width: 1_440, height: 900) window.setContentSize(NSSize( diff --git a/TablePro/Core/Services/Infrastructure/WindowManager.swift b/TablePro/Core/Services/Infrastructure/WindowManager.swift index 737468623..36b00a6f1 100644 --- a/TablePro/Core/Services/Infrastructure/WindowManager.swift +++ b/TablePro/Core/Services/Infrastructure/WindowManager.swift @@ -20,7 +20,37 @@ internal final class WindowManager { // MARK: - Open + /// One window hosts every connection, so an open reuses the window that already exists and + /// only adds a workspace to it. A second window is created solely when there is none. internal func openTab(payload: EditorTabPayload, activate: Bool = true, autoConnect: Bool = false) { + if let host = frontmostHost() { + host.adoptWorkspace(payload: payload, autoConnect: autoConnect) + host.workspaces.select(payload.connectionId) + if activate { + host.view.window?.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + Self.lifecycleLogger.info( + "[open] WindowManager adopted into existing window connId=\(payload.connectionId, privacy: .public)" + ) + return + } + openInNewWindow(payload: payload, activate: activate, autoConnect: autoConnect) + } + + /// The window the user is looking at, falling back to any main window so a background open + /// still lands somewhere rather than spawning a second one. + private func frontmostHost() -> MainSplitViewController? { + if let key = NSApp.keyWindow, Self.isMainWindow(key), key.isVisible, + let host = key.contentViewController as? MainSplitViewController { + return host + } + return NSApp.windows + .first { Self.isMainWindow($0) && $0.isVisible }? + .contentViewController as? MainSplitViewController + } + + private func openInNewWindow(payload: EditorTabPayload, activate: Bool, autoConnect: Bool) { let t0 = Date() Self.lifecycleLogger.info( "[open] WindowManager.openTab start payloadId=\(payload.id, privacy: .public) connId=\(payload.connectionId, privacy: .public) intent=\(String(describing: payload.intent), privacy: .public) skipAutoExecute=\(payload.skipAutoExecute) activate=\(activate)" @@ -108,19 +138,28 @@ internal final class WindowManager { // MARK: - Helpers internal func hasOpenWindow(for connectionId: UUID) -> Bool { - controllers.values.contains { $0.payload.connectionId == connectionId } + hosts().contains { $0.workspaces.contains(connectionId) } + } + + private func hosts() -> [MainSplitViewController] { + controllers.values.compactMap { $0.window?.contentViewController as? MainSplitViewController } } /// Every connection window from the moment it is created, including one that has not /// connected yet. `WindowLifecycleMonitor` only learns about a window once its content /// view mounts, which needs a live session. internal func allConnectionIds() -> Set { - Set(controllers.values.map(\.payload.connectionId)) + Set(hosts().flatMap(\.workspaces.connectionIds)) } internal func window(for connectionId: UUID) -> NSWindow? { controllers.values - .first { $0.payload.connectionId == connectionId && $0.window?.isVisible == true }? + .first { controller in + guard controller.window?.isVisible == true else { return false } + guard let host = controller.window?.contentViewController as? MainSplitViewController + else { return false } + return host.workspaces.contains(connectionId) + }? .window } @@ -132,11 +171,19 @@ internal final class WindowManager { .filter { seen.insert($0).inserted } } + /// Closing a connection removes its workspace. The window itself only closes once it has no + /// connection left to show, because it is no longer the connection's window. internal func closeWindow(for connectionId: UUID) { - let matching = controllers.values.filter { $0.payload.connectionId == connectionId } - for controller in matching { + for controller in controllers.values { guard let window = controller.window, window.isVisible else { continue } - window.close() + guard let host = window.contentViewController as? MainSplitViewController else { continue } + guard let removed = host.workspaces.remove(connectionId) else { continue } + removed.teardown() + if host.workspaces.isEmpty { + window.close() + } else { + host.applySelectedWorkspace() + } } } @@ -145,12 +192,10 @@ internal final class WindowManager { return raw == "main" || raw.hasPrefix("main-") } - /// One native tab group per connection, so a window's tab bar only ever lists that - /// connection's tabs. A window hosts exactly one tab group, so a shared identifier would - /// flatten every connection into one bar. - internal static func tabbingIdentifier(for connectionId: UUID) -> String { - "com.TablePro.main.\(connectionId.uuidString)" - } + /// One identifier for every app window. Editor tabs live in the window's own strip now, so + /// the native tab bar is free to mean what AppKit means by it: several app windows the user + /// chose to group. That is also what makes Merge All Windows work. + internal static let mainTabbingIdentifier = "com.TablePro.main" private func findSibling(tabbingIdentifier: String, excluding: NSWindow) -> NSWindow? { NSApp.windows.first { candidate in diff --git a/TablePro/Core/Services/Infrastructure/WorkspaceRailViewController.swift b/TablePro/Core/Services/Infrastructure/WorkspaceRailViewController.swift index 08d7d38d8..c98e700ba 100644 --- a/TablePro/Core/Services/Infrastructure/WorkspaceRailViewController.swift +++ b/TablePro/Core/Services/Infrastructure/WorkspaceRailViewController.swift @@ -250,6 +250,21 @@ internal final class WorkspaceRailViewController: NSViewController { /// saved. Moving between two containers of the same connection stays in one window and /// only moves that window's browse cursor. private func activate(_ workspace: WorkspaceID) { + /// One window hosts every connection, so switching is a selection change in that + /// window's own registry. Raising a different window is what made the rail read as a + /// window switcher rather than a workspace switcher. + if let host = view.window?.contentViewController as? MainSplitViewController, + host.workspaces.contains(workspace.connectionId) { + host.workspaces.select(workspace.connectionId) + moveBrowseCursor(of: host.view.window ?? NSApp.keyWindow ?? NSApp.windows[0], to: workspace) + guard WorkspaceRailStore.shouldRestoreSelection( + after: workspace, + railConnectionId: connectionId + ) else { return } + applySelection() + return + } + let target = entries.first { $0.workspace == workspace }?.containerTarget let showing = MainContentCoordinator.window(showing: workspace, target: target) guard let window = showing diff --git a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index 686aa1f1f..e5bc03e94 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -348,9 +348,7 @@ extension MainContentView { ) let isPreview = tabManager.selectedTab?.isPreview ?? payload?.isPreview ?? false - let resolvedId = WindowManager.tabbingIdentifier(for: connection.id) - window.tabbingIdentifier = resolvedId - window.tabbingMode = .preferred + window.tabbingIdentifier = WindowManager.mainTabbingIdentifier coordinator.windowId = windowId WindowLifecycleMonitor.shared.register( @@ -372,7 +370,7 @@ extension MainContentView { splitVC.installToolbar(coordinator: coordinator) } MainContentView.lifecycleLogger.info( - "[open] configureWindow done windowId=\(windowId, privacy: .public) tabbingId=\(resolvedId, privacy: .public) isPreview=\(isPreview) elapsedMs=\(Int(Date().timeIntervalSince(start) * 1_000))" + "[open] configureWindow done windowId=\(windowId, privacy: .public) isPreview=\(isPreview) elapsedMs=\(Int(Date().timeIntervalSince(start) * 1_000))" ) } diff --git a/TableProTests/Core/Services/WindowTabGroupingTests.swift b/TableProTests/Core/Services/WindowTabGroupingTests.swift index a3857c743..c44a37481 100644 --- a/TableProTests/Core/Services/WindowTabGroupingTests.swift +++ b/TableProTests/Core/Services/WindowTabGroupingTests.swift @@ -2,15 +2,8 @@ // WindowTabGroupingTests.swift // TableProTests // -// Tests for `WindowManager.tabbingIdentifier(for:)` — the static helper that -// drives macOS native window tab grouping for main editor windows. -// -// The earlier `WindowOpener.pendingPayloads` / `acknowledgePayload` / -// `consumeOldestPendingConnectionId` queue was removed when -// `WindowManager.openTab` started performing tab-group merge synchronously -// at window-creation time. The corresponding tests have been removed. -// +import AppKit import Foundation import TableProPluginKit import Testing @@ -20,37 +13,24 @@ import Testing @Suite("WindowTabGrouping") @MainActor struct WindowTabGroupingTests { - @Test("tabbingIdentifier produces a connection-specific identifier") - func tabbingIdentifierUsesConnectionId() { - let connectionId = UUID() - let expected = "com.TablePro.main.\(connectionId.uuidString)" - - let result = WindowManager.tabbingIdentifier(for: connectionId) - - #expect(result == expected) + /// Every app window shares one identifier. A per-connection identifier is what stopped two + /// connections from ever sharing a window, and it is what left Merge All Windows unable to + /// fold two connection windows together. + @Test("Every main window shares one tabbing identifier") + func mainWindowsShareOneIdentifier() { + #expect(WindowManager.mainTabbingIdentifier == "com.TablePro.main") } - @Test("Two connections produce different tabbingIdentifiers") - func twoConnectionsProduceDifferentIdentifiers() { - let connectionA = UUID() - let connectionB = UUID() + @Test("A main window is recognised by its identifier prefix") + func mainWindowIdentification() { + let window = NSWindow() + window.identifier = NSUserInterfaceItemIdentifier("main") + #expect(WindowManager.isMainWindow(window)) - let idA = WindowManager.tabbingIdentifier(for: connectionA) - let idB = WindowManager.tabbingIdentifier(for: connectionB) + window.identifier = NSUserInterfaceItemIdentifier("main-inspector") + #expect(WindowManager.isMainWindow(window)) - #expect(idA != idB) - #expect(idA.contains(connectionA.uuidString)) - #expect(idB.contains(connectionB.uuidString)) + window.identifier = NSUserInterfaceItemIdentifier("welcome") + #expect(WindowManager.isMainWindow(window) == false) } - - @Test("Same connection produces same tabbingIdentifier") - func sameConnectionProducesSameIdentifier() { - let connectionId = UUID() - - let id1 = WindowManager.tabbingIdentifier(for: connectionId) - let id2 = WindowManager.tabbingIdentifier(for: connectionId) - - #expect(id1 == id2) - } - } From 2361abec501c0b32958f51a2d7a6b34f6f773ef1 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 12 Aug 2026 22:40:44 +0700 Subject: [PATCH 04/47] fix(tabs): draw the editor tab strip the way the system tab bar draws --- TablePro/Views/Main/EditorTabStrip.swift | 157 +++++++++++++++++------ 1 file changed, 118 insertions(+), 39 deletions(-) diff --git a/TablePro/Views/Main/EditorTabStrip.swift b/TablePro/Views/Main/EditorTabStrip.swift index 8798f6c0e..9122b800f 100644 --- a/TablePro/Views/Main/EditorTabStrip.swift +++ b/TablePro/Views/Main/EditorTabStrip.swift @@ -5,84 +5,130 @@ import SwiftUI -/// The editor tabs for one connection. Native window tabs cannot express this: a window belongs -/// to exactly one tab group and a group's bar shows every window in it, so one window hosting -/// several connections could only ever show all of their tabs interleaved. +/// The editor tabs for one connection, drawn to match the system tab bar. Native window tabs +/// cannot express this: a window belongs to exactly one tab group and a group's bar shows every +/// window in it, so one window hosting several connections could only ever show all of their +/// tabs interleaved. +/// +/// The geometry follows the system bar rather than inventing one: an inset rounded container, +/// tabs of equal width filling it, the selected tab as a raised card, and a separator only +/// between two unselected neighbours. internal struct EditorTabStrip: View { internal let tabManager: QueryTabManager internal let onClose: (UUID) -> Void internal let onNewTab: () -> Void - @Environment(\.colorScheme) private var colorScheme - internal var body: some View { - HStack(spacing: 0) { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 0) { - ForEach(tabManager.tabs) { tab in - EditorTabStripItem( - tab: tab, - isSelected: tabManager.selectedTab?.id == tab.id, - onSelect: { tabManager.selectedTabId = tab.id }, - onClose: { onClose(tab.id) } - ) - Divider().frame(height: Self.dividerHeight) + HStack(spacing: Metrics.barSpacing) { + GeometryReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 0) { + ForEach(Array(tabManager.tabs.enumerated()), id: \.element.id) { index, tab in + EditorTabStripItem( + tab: tab, + isSelected: isSelected(tab), + showsLeadingSeparator: showsSeparator(before: index), + onSelect: { tabManager.selectedTabId = tab.id }, + onClose: { onClose(tab.id) } + ) + .frame(width: tabWidth(forTotal: proxy.size.width)) + } } } + /// The bar has to read as a recess the selected card sits proud of. + /// `controlColor` renders near white, so the card lost all separation from it. + .background( + RoundedRectangle(cornerRadius: Metrics.cornerRadius, style: .continuous) + .fill(Color(nsColor: .unemphasizedSelectedContentBackgroundColor)) + ) + .clipShape( + RoundedRectangle(cornerRadius: Metrics.cornerRadius, style: .continuous) + ) } + .frame(height: Metrics.barHeight) Button(action: onNewTab) { Image(systemName: "plus") - .frame(width: Self.newTabButtonWidth, height: Self.height) + .font(.system(size: 12, weight: .medium)) + .frame(width: Metrics.barHeight, height: Metrics.barHeight) .contentShape(Rectangle()) } .buttonStyle(.plain) .help(Text("New Tab")) .accessibilityLabel(Text("New Tab")) } - .frame(height: Self.height) - .background(Color(nsColor: .windowBackgroundColor)) - .overlay(alignment: .bottom) { - Divider() - } + .padding(.horizontal, Metrics.barInset) + .padding(.vertical, Metrics.barInset) .accessibilityElement(children: .contain) .accessibilityLabel(Text("Editor Tabs")) } - internal static let height: CGFloat = 28 - private static let dividerHeight: CGFloat = 16 - private static let newTabButtonWidth: CGFloat = 28 + private func isSelected(_ tab: QueryTab) -> Bool { + tabManager.selectedTab?.id == tab.id + } + + /// The system bar rules a line between two plain tabs only. A separator touching the raised + /// card reads as a seam in the card, which is the tell that a bar was drawn by hand. + private func showsSeparator(before index: Int) -> Bool { + guard index > 0 else { return false } + let tabs = tabManager.tabs + guard tabs.indices.contains(index), tabs.indices.contains(index - 1) else { return false } + return !isSelected(tabs[index]) && !isSelected(tabs[index - 1]) + } + + /// Tabs share the bar equally, the way the system bar lays them out, and stop shrinking at a + /// width that still fits a name so a long list scrolls instead of collapsing into slivers. + private func tabWidth(forTotal total: CGFloat) -> CGFloat { + let count = CGFloat(max(tabManager.tabs.count, 1)) + return max(total / count, Metrics.minimumTabWidth) + } + + internal enum Metrics { + internal static let barHeight: CGFloat = 28 + internal static let barInset: CGFloat = 8 + internal static let barSpacing: CGFloat = 4 + internal static let cornerRadius: CGFloat = 9 + internal static let minimumTabWidth: CGFloat = 110 + internal static var totalHeight: CGFloat { barHeight + barInset * 2 } + } } private struct EditorTabStripItem: View { let tab: QueryTab let isSelected: Bool + let showsLeadingSeparator: Bool let onSelect: () -> Void let onClose: () -> Void @State private var isHovering = false var body: some View { - HStack(spacing: 4) { + ZStack { + if showsLeadingSeparator { + HStack { + Divider().frame(height: Self.separatorHeight) + Spacer() + } + } + + selectionBackground + Text(tab.title) .lineLimit(1) + .truncationMode(.tail) .italic(tab.isPreview) - .font(.system(size: 12, weight: isSelected ? .medium : .regular)) + .font(.system(size: 13, weight: .regular)) + .foregroundStyle(isSelected ? Color(nsColor: .labelColor) : Color(nsColor: .secondaryLabelColor)) + .padding(.horizontal, Self.titleInset) - Button(action: onClose) { - Image(systemName: "xmark") - .font(.system(size: 8, weight: .bold)) - .frame(width: 14, height: 14) - .contentShape(Rectangle()) + /// Leading, like every system tab bar, and overlaid so revealing it on hover never + /// shifts the title out from under the pointer. + HStack { + closeButton + Spacer() } - .buttonStyle(.plain) - .opacity(isHovering || isSelected ? 1 : 0) - .accessibilityLabel(Text("Close Tab")) } - .padding(.horizontal, 10) - .frame(height: EditorTabStrip.height) - .frame(minWidth: 80, maxWidth: 200) - .background(isSelected ? Color(nsColor: .selectedContentBackgroundColor).opacity(0.25) : .clear) + .frame(maxWidth: .infinity, maxHeight: .infinity) .contentShape(Rectangle()) .onHover { isHovering = $0 } .onTapGesture(perform: onSelect) @@ -92,4 +138,37 @@ private struct EditorTabStripItem: View { .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton) .accessibilityAction(named: Text("Close Tab"), onClose) } + + @ViewBuilder + private var selectionBackground: some View { + if isSelected { + RoundedRectangle(cornerRadius: Self.cardCornerRadius, style: .continuous) + .fill(Color(nsColor: .controlBackgroundColor)) + .shadow(color: .black.opacity(0.16), radius: 1.5, y: 0.5) + .padding(Self.cardInset) + } + } + + @ViewBuilder + private var closeButton: some View { + if isHovering { + Button(action: onClose) { + Image(systemName: "xmark") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + .frame(width: Self.closeButtonSize, height: Self.closeButtonSize) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .padding(.leading, Self.closeButtonInset) + .accessibilityLabel(Text("Close Tab")) + } + } + + private static let cardCornerRadius: CGFloat = 7 + private static let cardInset: CGFloat = 2 + private static let closeButtonSize: CGFloat = 16 + private static let closeButtonInset: CGFloat = 5 + private static let separatorHeight: CGFloat = 14 + private static let titleInset: CGFloat = 24 } From 91f92358c55b8f39a78711dd64797bf23de2c97b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 12 Aug 2026 23:06:39 +0700 Subject: [PATCH 05/47] fix(tabs): match the system tab bar capsule, hover fill and separator rules --- TablePro/Views/Main/EditorTabStrip.swift | 42 +++++++++++++++++------- 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/TablePro/Views/Main/EditorTabStrip.swift b/TablePro/Views/Main/EditorTabStrip.swift index 9122b800f..9f009cf1a 100644 --- a/TablePro/Views/Main/EditorTabStrip.swift +++ b/TablePro/Views/Main/EditorTabStrip.swift @@ -18,6 +18,8 @@ internal struct EditorTabStrip: View { internal let onClose: (UUID) -> Void internal let onNewTab: () -> Void + @State private var hoveredTabId: UUID? + internal var body: some View { HStack(spacing: Metrics.barSpacing) { GeometryReader { proxy in @@ -27,7 +29,9 @@ internal struct EditorTabStrip: View { EditorTabStripItem( tab: tab, isSelected: isSelected(tab), + isHovered: hoveredTabId == tab.id, showsLeadingSeparator: showsSeparator(before: index), + onHover: { hoveredTabId = $0 ? tab.id : (hoveredTabId == tab.id ? nil : hoveredTabId) }, onSelect: { tabManager.selectedTabId = tab.id }, onClose: { onClose(tab.id) } ) @@ -67,13 +71,18 @@ internal struct EditorTabStrip: View { tabManager.selectedTab?.id == tab.id } - /// The system bar rules a line between two plain tabs only. A separator touching the raised - /// card reads as a seam in the card, which is the tell that a bar was drawn by hand. + /// The system rules a line only between two neighbours that are both plain and both + /// untouched. A separator against the raised card reads as a seam in the card, and one + /// against a hovered tab fights its fill. Two tabs therefore never show one, because one + /// of them is always selected. private func showsSeparator(before index: Int) -> Bool { guard index > 0 else { return false } let tabs = tabManager.tabs guard tabs.indices.contains(index), tabs.indices.contains(index - 1) else { return false } - return !isSelected(tabs[index]) && !isSelected(tabs[index - 1]) + let leading = tabs[index - 1] + let trailing = tabs[index] + guard !isSelected(leading), !isSelected(trailing) else { return false } + return hoveredTabId != leading.id && hoveredTabId != trailing.id } /// Tabs share the bar equally, the way the system bar lays them out, and stop shrinking at a @@ -88,7 +97,7 @@ internal struct EditorTabStrip: View { internal static let barInset: CGFloat = 8 internal static let barSpacing: CGFloat = 4 internal static let cornerRadius: CGFloat = 9 - internal static let minimumTabWidth: CGFloat = 110 + internal static let minimumTabWidth: CGFloat = 120 internal static var totalHeight: CGFloat { barHeight + barInset * 2 } } } @@ -96,17 +105,19 @@ internal struct EditorTabStrip: View { private struct EditorTabStripItem: View { let tab: QueryTab let isSelected: Bool + let isHovered: Bool let showsLeadingSeparator: Bool + let onHover: (Bool) -> Void let onSelect: () -> Void let onClose: () -> Void - @State private var isHovering = false - var body: some View { ZStack { if showsLeadingSeparator { HStack { - Divider().frame(height: Self.separatorHeight) + Rectangle() + .fill(Color(nsColor: .separatorColor)) + .frame(width: 1, height: Self.separatorHeight) Spacer() } } @@ -130,7 +141,7 @@ private struct EditorTabStripItem: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .contentShape(Rectangle()) - .onHover { isHovering = $0 } + .onHover(perform: onHover) .onTapGesture(perform: onSelect) .help(Text(tab.title)) .accessibilityElement(children: .combine) @@ -139,19 +150,26 @@ private struct EditorTabStripItem: View { .accessibilityAction(named: Text("Close Tab"), onClose) } + /// The tab is a capsule, so its radius follows its own height rather than a fixed number. + /// An unselected tab is not inert: hovering fills it a shade deeper than the track, which + /// is what tells the pointer it landed on something. @ViewBuilder private var selectionBackground: some View { if isSelected { - RoundedRectangle(cornerRadius: Self.cardCornerRadius, style: .continuous) + Capsule(style: .continuous) .fill(Color(nsColor: .controlBackgroundColor)) .shadow(color: .black.opacity(0.16), radius: 1.5, y: 0.5) .padding(Self.cardInset) + } else if isHovered { + Capsule(style: .continuous) + .fill(Color(nsColor: .separatorColor).opacity(Self.hoverFillOpacity)) + .padding(Self.cardInset) } } @ViewBuilder private var closeButton: some View { - if isHovering { + if isHovered { Button(action: onClose) { Image(systemName: "xmark") .font(.system(size: 9, weight: .semibold)) @@ -165,10 +183,10 @@ private struct EditorTabStripItem: View { } } - private static let cardCornerRadius: CGFloat = 7 private static let cardInset: CGFloat = 2 private static let closeButtonSize: CGFloat = 16 private static let closeButtonInset: CGFloat = 5 - private static let separatorHeight: CGFloat = 14 + private static let hoverFillOpacity: CGFloat = 0.5 + private static let separatorHeight: CGFloat = 18 private static let titleInset: CGFloat = 24 } From 321638ebfe8727758a66926090b414795e5d59c5 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 13 Aug 2026 13:18:38 +0700 Subject: [PATCH 06/47] fix(tabs): build the tab strip on glass, to the system tab bar's measured geometry --- TablePro/Views/Main/EditorTabStrip.swift | 178 ++++++++++++++--------- 1 file changed, 107 insertions(+), 71 deletions(-) diff --git a/TablePro/Views/Main/EditorTabStrip.swift b/TablePro/Views/Main/EditorTabStrip.swift index 9f009cf1a..37704fba1 100644 --- a/TablePro/Views/Main/EditorTabStrip.swift +++ b/TablePro/Views/Main/EditorTabStrip.swift @@ -5,14 +5,15 @@ import SwiftUI -/// The editor tabs for one connection, drawn to match the system tab bar. Native window tabs -/// cannot express this: a window belongs to exactly one tab group and a group's bar shows every -/// window in it, so one window hosting several connections could only ever show all of their -/// tabs interleaved. +/// The editor tabs for one connection, built to the system tab bar's own geometry. Native window +/// tabs cannot express this: a window belongs to exactly one tab group and a group's bar shows +/// every window in it, so one window hosting several connections could only ever show all of +/// their tabs interleaved. /// -/// The geometry follows the system bar rather than inventing one: an inset rounded container, -/// tabs of equal width filling it, the selected tab as a raised card, and a separator only -/// between two unselected neighbours. +/// Every number here was read off `NSTabBar`'s live view tree rather than guessed. The system +/// nests glass inside glass: a subdued glass track holding a glass capsule per tab. Apple warns +/// against layering glass in general, so the exception is followed only because the control being +/// matched is built that way. internal struct EditorTabStrip: View { internal let tabManager: QueryTabManager internal let onClose: (UUID) -> Void @@ -21,7 +22,7 @@ internal struct EditorTabStrip: View { @State private var hoveredTabId: UUID? internal var body: some View { - HStack(spacing: Metrics.barSpacing) { + HStack(spacing: Metrics.trackSpacing) { GeometryReader { proxy in ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 0) { @@ -31,50 +32,55 @@ internal struct EditorTabStrip: View { isSelected: isSelected(tab), isHovered: hoveredTabId == tab.id, showsLeadingSeparator: showsSeparator(before: index), - onHover: { hoveredTabId = $0 ? tab.id : (hoveredTabId == tab.id ? nil : hoveredTabId) }, + onHover: { hovering in + if hovering { + hoveredTabId = tab.id + } else if hoveredTabId == tab.id { + hoveredTabId = nil + } + }, onSelect: { tabManager.selectedTabId = tab.id }, onClose: { onClose(tab.id) } ) - .frame(width: tabWidth(forTotal: proxy.size.width)) + .frame(width: tabWidth(forTrack: proxy.size.width)) } } } - /// The bar has to read as a recess the selected card sits proud of. - /// `controlColor` renders near white, so the card lost all separation from it. - .background( - RoundedRectangle(cornerRadius: Metrics.cornerRadius, style: .continuous) - .fill(Color(nsColor: .unemphasizedSelectedContentBackgroundColor)) - ) - .clipShape( - RoundedRectangle(cornerRadius: Metrics.cornerRadius, style: .continuous) - ) + .frame(height: Metrics.tabHeight) + .padding(Metrics.trackPadding) + .trackGlass() } - .frame(height: Metrics.barHeight) + .frame(height: Metrics.trackHeight) - Button(action: onNewTab) { - Image(systemName: "plus") - .font(.system(size: 12, weight: .medium)) - .frame(width: Metrics.barHeight, height: Metrics.barHeight) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .help(Text("New Tab")) - .accessibilityLabel(Text("New Tab")) + newTabButton } - .padding(.horizontal, Metrics.barInset) - .padding(.vertical, Metrics.barInset) + .padding(.horizontal, Metrics.stripInset) + .padding(.vertical, Metrics.stripInset) .accessibilityElement(children: .contain) .accessibilityLabel(Text("Editor Tabs")) } + private var newTabButton: some View { + Button(action: onNewTab) { + Image(systemName: "plus") + .font(.system(size: 12, weight: .medium)) + .frame(width: Metrics.trackHeight, height: Metrics.trackHeight) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .trackGlass() + .help(Text("New Tab")) + .accessibilityLabel(Text("New Tab")) + } + private func isSelected(_ tab: QueryTab) -> Bool { tabManager.selectedTab?.id == tab.id } /// The system rules a line only between two neighbours that are both plain and both - /// untouched. A separator against the raised card reads as a seam in the card, and one - /// against a hovered tab fights its fill. Two tabs therefore never show one, because one - /// of them is always selected. + /// untouched. A separator against the raised capsule reads as a seam in it, and one against + /// a hovered tab fights that tab's fill. Two tabs therefore never show one, because one of + /// them is always selected. private func showsSeparator(before index: Int) -> Bool { guard index > 0 else { return false } let tabs = tabManager.tabs @@ -85,20 +91,22 @@ internal struct EditorTabStrip: View { return hoveredTabId != leading.id && hoveredTabId != trailing.id } - /// Tabs share the bar equally, the way the system bar lays them out, and stop shrinking at a + /// Tabs share the track equally, the way the system lays them out, and stop shrinking at a /// width that still fits a name so a long list scrolls instead of collapsing into slivers. - private func tabWidth(forTotal total: CGFloat) -> CGFloat { + private func tabWidth(forTrack width: CGFloat) -> CGFloat { + let usable = width - Metrics.trackPadding * 2 let count = CGFloat(max(tabManager.tabs.count, 1)) - return max(total / count, Metrics.minimumTabWidth) + return max(usable / count, Metrics.minimumTabWidth) } internal enum Metrics { - internal static let barHeight: CGFloat = 28 - internal static let barInset: CGFloat = 8 - internal static let barSpacing: CGFloat = 4 - internal static let cornerRadius: CGFloat = 9 + internal static let trackHeight: CGFloat = 28 + internal static let tabHeight: CGFloat = 24 + internal static let trackPadding: CGFloat = 2 + internal static let stripInset: CGFloat = 8 + internal static let trackSpacing: CGFloat = 4 internal static let minimumTabWidth: CGFloat = 120 - internal static var totalHeight: CGFloat { barHeight + barInset * 2 } + internal static var totalHeight: CGFloat { trackHeight + stripInset * 2 } } } @@ -122,22 +130,27 @@ private struct EditorTabStripItem: View { } } - selectionBackground - - Text(tab.title) - .lineLimit(1) - .truncationMode(.tail) - .italic(tab.isPreview) - .font(.system(size: 13, weight: .regular)) - .foregroundStyle(isSelected ? Color(nsColor: .labelColor) : Color(nsColor: .secondaryLabelColor)) - .padding(.horizontal, Self.titleInset) + background - /// Leading, like every system tab bar, and overlaid so revealing it on hover never - /// shifts the title out from under the pointer. - HStack { + /// The close button occupies the leading end and an equal spacer holds the trailing + /// end, which is how the system keeps a title optically centred while still giving + /// the button a real place in the row. + HStack(spacing: 0) { closeButton - Spacer() + .frame(width: Self.accessoryWidth) + Text(tab.title) + .lineLimit(1) + .truncationMode(.tail) + .italic(tab.isPreview) + .font(.system(size: Self.fontSize)) + .foregroundStyle( + isSelected ? Color(nsColor: .labelColor) : Color(nsColor: .secondaryLabelColor) + ) + .frame(maxWidth: .infinity) + Color.clear + .frame(width: Self.accessoryWidth) } + .padding(.horizontal, Self.accessoryInset) } .frame(maxWidth: .infinity, maxHeight: .infinity) .contentShape(Rectangle()) @@ -150,43 +163,66 @@ private struct EditorTabStripItem: View { .accessibilityAction(named: Text("Close Tab"), onClose) } - /// The tab is a capsule, so its radius follows its own height rather than a fixed number. - /// An unselected tab is not inert: hovering fills it a shade deeper than the track, which - /// is what tells the pointer it landed on something. + /// The selected tab is the glass capsule the system raises out of the track. An unselected + /// one is not inert either: hovering fills it so the pointer has something to land on. @ViewBuilder - private var selectionBackground: some View { + private var background: some View { if isSelected { - Capsule(style: .continuous) - .fill(Color(nsColor: .controlBackgroundColor)) - .shadow(color: .black.opacity(0.16), radius: 1.5, y: 0.5) - .padding(Self.cardInset) + Color.clear.tabGlass() } else if isHovered { Capsule(style: .continuous) .fill(Color(nsColor: .separatorColor).opacity(Self.hoverFillOpacity)) - .padding(Self.cardInset) } } + /// Shown for the tab in front at all times, and for any tab the pointer is over, so closing + /// the visible tab never needs a hunt for its button. @ViewBuilder private var closeButton: some View { - if isHovered { + if isSelected || isHovered { Button(action: onClose) { Image(systemName: "xmark") .font(.system(size: 9, weight: .semibold)) .foregroundStyle(Color(nsColor: .secondaryLabelColor)) - .frame(width: Self.closeButtonSize, height: Self.closeButtonSize) + .frame(width: Self.accessoryWidth, height: Self.accessoryWidth) .contentShape(Rectangle()) } .buttonStyle(.plain) - .padding(.leading, Self.closeButtonInset) .accessibilityLabel(Text("Close Tab")) + } else { + Color.clear } } - private static let cardInset: CGFloat = 2 - private static let closeButtonSize: CGFloat = 16 - private static let closeButtonInset: CGFloat = 5 + private static let accessoryWidth: CGFloat = 16 + private static let accessoryInset: CGFloat = 5 + private static let fontSize: CGFloat = 11 private static let hoverFillOpacity: CGFloat = 0.5 private static let separatorHeight: CGFloat = 18 - private static let titleInset: CGFloat = 24 +} + +private extension View { + /// The track and the new-tab button are the system's subdued glass. Before glass existed the + /// nearest equivalent is a material, which keeps the same read of a recess behind content. + @ViewBuilder + func trackGlass() -> some View { + if #available(macOS 26.0, *) { + glassEffect(.regular, in: Capsule(style: .continuous)) + } else { + background(.regularMaterial, in: Capsule(style: .continuous)) + } + } + + @ViewBuilder + func tabGlass() -> some View { + if #available(macOS 26.0, *) { + glassEffect(.regular.interactive(), in: Capsule(style: .continuous)) + } else { + background( + Capsule(style: .continuous) + .fill(Color(nsColor: .controlBackgroundColor)) + .shadow(color: .black.opacity(0.16), radius: 1.5, y: 0.5) + ) + } + } } From 6c36d666c117fc60aa02c48dbaeac989c18ccc2b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 13 Aug 2026 13:42:09 +0700 Subject: [PATCH 07/47] fix(tabs): keep glass off the tab strip track and fix the dark mode selected tab --- TablePro/Views/Main/EditorTabStrip.swift | 292 +++++++++++------- .../Views/Main/EditorTabStripLayout.swift | 53 ++++ .../Main/EditorTabStripLayoutTests.swift | 137 ++++++++ 3 files changed, 369 insertions(+), 113 deletions(-) create mode 100644 TablePro/Views/Main/EditorTabStripLayout.swift create mode 100644 TableProTests/Views/Main/EditorTabStripLayoutTests.swift diff --git a/TablePro/Views/Main/EditorTabStrip.swift b/TablePro/Views/Main/EditorTabStrip.swift index 37704fba1..a5d63da6e 100644 --- a/TablePro/Views/Main/EditorTabStrip.swift +++ b/TablePro/Views/Main/EditorTabStrip.swift @@ -10,103 +10,93 @@ import SwiftUI /// every window in it, so one window hosting several connections could only ever show all of /// their tabs interleaved. /// -/// Every number here was read off `NSTabBar`'s live view tree rather than guessed. The system -/// nests glass inside glass: a subdued glass track holding a glass capsule per tab. Apple warns -/// against layering glass in general, so the exception is followed only because the control being -/// matched is built that way. +/// Glass is applied where the system applies it and nowhere else. The system's track is a private +/// subdued glass; the public effect is full strength, and the strip sits under a unified toolbar +/// that is itself glass, so the track is a flat fill here. Only the selected tab and the new-tab +/// button carry glass, which is the one pane the system slides along its track. internal struct EditorTabStrip: View { internal let tabManager: QueryTabManager internal let onClose: (UUID) -> Void internal let onNewTab: () -> Void @State private var hoveredTabId: UUID? + @Environment(\.controlActiveState) private var controlActiveState internal var body: some View { - HStack(spacing: Metrics.trackSpacing) { - GeometryReader { proxy in + HStack(spacing: EditorTabStripLayout.trackSpacing) { + track + EditorTabStripNewButton(action: onNewTab, isWindowActive: isWindowActive) + } + .padding(EditorTabStripLayout.stripInset) + .onChange(of: controlActiveState) { _, state in + if state == .inactive { hoveredTabId = nil } + } + .accessibilityElement(children: .contain) + .accessibilityLabel(Text("Editor Tabs")) + } + + private var track: some View { + GeometryReader { proxy in + ScrollViewReader { scroller in ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 0) { ForEach(Array(tabManager.tabs.enumerated()), id: \.element.id) { index, tab in - EditorTabStripItem( - tab: tab, - isSelected: isSelected(tab), - isHovered: hoveredTabId == tab.id, - showsLeadingSeparator: showsSeparator(before: index), - onHover: { hovering in - if hovering { - hoveredTabId = tab.id - } else if hoveredTabId == tab.id { - hoveredTabId = nil - } - }, - onSelect: { tabManager.selectedTabId = tab.id }, - onClose: { onClose(tab.id) } - ) - .frame(width: tabWidth(forTrack: proxy.size.width)) + item(for: tab, at: index) + .frame( + width: EditorTabStripLayout.tabWidth( + forTrack: proxy.size.width, + count: tabManager.tabs.count + ) + ) + .id(tab.id) } } } - .frame(height: Metrics.tabHeight) - .padding(Metrics.trackPadding) - .trackGlass() + /// Cmd+1..9, opening a table from the sidebar and closing a tab can all land on + /// a tab that is scrolled out of sight, so the selection pulls itself into view. + .onChange(of: tabManager.selectedTabId) { _, newValue in + guard let newValue else { return } + withAnimation(.easeOut(duration: 0.15)) { + scroller.scrollTo(newValue, anchor: .center) + } + } } - .frame(height: Metrics.trackHeight) - - newTabButton - } - .padding(.horizontal, Metrics.stripInset) - .padding(.vertical, Metrics.stripInset) - .accessibilityElement(children: .contain) - .accessibilityLabel(Text("Editor Tabs")) - } - - private var newTabButton: some View { - Button(action: onNewTab) { - Image(systemName: "plus") - .font(.system(size: 12, weight: .medium)) - .frame(width: Metrics.trackHeight, height: Metrics.trackHeight) - .contentShape(Circle()) + .frame(height: EditorTabStripLayout.tabHeight) + .padding(EditorTabStripLayout.trackPadding) + .background( + Capsule(style: .continuous) + .fill(Color(nsColor: .quaternaryLabelColor)) + ) } - .buttonStyle(.plain) - .trackGlass() - .help(Text("New Tab")) - .accessibilityLabel(Text("New Tab")) - } - - private func isSelected(_ tab: QueryTab) -> Bool { - tabManager.selectedTab?.id == tab.id - } - - /// The system rules a line only between two neighbours that are both plain and both - /// untouched. A separator against the raised capsule reads as a seam in it, and one against - /// a hovered tab fights that tab's fill. Two tabs therefore never show one, because one of - /// them is always selected. - private func showsSeparator(before index: Int) -> Bool { - guard index > 0 else { return false } - let tabs = tabManager.tabs - guard tabs.indices.contains(index), tabs.indices.contains(index - 1) else { return false } - let leading = tabs[index - 1] - let trailing = tabs[index] - guard !isSelected(leading), !isSelected(trailing) else { return false } - return hoveredTabId != leading.id && hoveredTabId != trailing.id + .frame(height: EditorTabStripLayout.trackHeight) } - /// Tabs share the track equally, the way the system lays them out, and stop shrinking at a - /// width that still fits a name so a long list scrolls instead of collapsing into slivers. - private func tabWidth(forTrack width: CGFloat) -> CGFloat { - let usable = width - Metrics.trackPadding * 2 - let count = CGFloat(max(tabManager.tabs.count, 1)) - return max(usable / count, Metrics.minimumTabWidth) + private func item(for tab: QueryTab, at index: Int) -> some View { + EditorTabStripItem( + tab: tab, + isSelected: tabManager.selectedTab?.id == tab.id, + isHovered: hoveredTabId == tab.id, + isWindowActive: isWindowActive, + showsLeadingSeparator: EditorTabStripLayout.showsSeparator( + before: index, + tabIds: tabManager.tabs.map(\.id), + selectedId: tabManager.selectedTab?.id, + hoveredId: hoveredTabId + ), + onHover: { hovering in + if hovering { + hoveredTabId = tab.id + } else if hoveredTabId == tab.id { + hoveredTabId = nil + } + }, + onSelect: { tabManager.selectedTabId = tab.id }, + onClose: { onClose(tab.id) } + ) } - internal enum Metrics { - internal static let trackHeight: CGFloat = 28 - internal static let tabHeight: CGFloat = 24 - internal static let trackPadding: CGFloat = 2 - internal static let stripInset: CGFloat = 8 - internal static let trackSpacing: CGFloat = 4 - internal static let minimumTabWidth: CGFloat = 120 - internal static var totalHeight: CGFloat { trackHeight + stripInset * 2 } + private var isWindowActive: Bool { + controlActiveState != .inactive } } @@ -114,43 +104,44 @@ private struct EditorTabStripItem: View { let tab: QueryTab let isSelected: Bool let isHovered: Bool + let isWindowActive: Bool let showsLeadingSeparator: Bool let onHover: (Bool) -> Void let onSelect: () -> Void let onClose: () -> Void + @Environment(\.colorScheme) private var colorScheme + var body: some View { ZStack { if showsLeadingSeparator { HStack { Rectangle() .fill(Color(nsColor: .separatorColor)) - .frame(width: 1, height: Self.separatorHeight) + .frame(width: 1, height: EditorTabStripLayout.separatorHeight) Spacer() } } background - /// The close button occupies the leading end and an equal spacer holds the trailing + /// The close button takes the leading end and an equal spacer holds the trailing /// end, which is how the system keeps a title optically centred while still giving /// the button a real place in the row. HStack(spacing: 0) { closeButton - .frame(width: Self.accessoryWidth) + .frame(width: EditorTabStripLayout.accessoryWidth) Text(tab.title) .lineLimit(1) .truncationMode(.tail) .italic(tab.isPreview) - .font(.system(size: Self.fontSize)) - .foregroundStyle( - isSelected ? Color(nsColor: .labelColor) : Color(nsColor: .secondaryLabelColor) - ) + .font(.system(size: EditorTabStripLayout.fontSize)) + .foregroundStyle(titleColor) .frame(maxWidth: .infinity) Color.clear - .frame(width: Self.accessoryWidth) + .frame(width: EditorTabStripLayout.accessoryWidth) } - .padding(.horizontal, Self.accessoryInset) + .padding(.horizontal, EditorTabStripLayout.accessoryInset) } .frame(maxWidth: .infinity, maxHeight: .infinity) .contentShape(Rectangle()) @@ -163,13 +154,21 @@ private struct EditorTabStripItem: View { .accessibilityAction(named: Text("Close Tab"), onClose) } - /// The selected tab is the glass capsule the system raises out of the track. An unselected - /// one is not inert either: hovering fills it so the pointer has something to land on. + private var titleColor: Color { + guard isWindowActive else { + return Color(nsColor: isSelected ? .secondaryLabelColor : .tertiaryLabelColor) + } + return Color(nsColor: isSelected ? .labelColor : .secondaryLabelColor) + } + + /// The selected tab is the one pane of glass the system raises out of the track. An + /// unselected tab is not inert either: hovering fills it so the pointer has something to + /// land on, and a background window shows neither. @ViewBuilder private var background: some View { if isSelected { - Color.clear.tabGlass() - } else if isHovered { + Color.clear.selectedTabSurface(isLightAppearance: colorScheme == .light, isWindowActive: isWindowActive) + } else if isHovered, isWindowActive { Capsule(style: .continuous) .fill(Color(nsColor: .separatorColor).opacity(Self.hoverFillOpacity)) } @@ -179,49 +178,116 @@ private struct EditorTabStripItem: View { /// the visible tab never needs a hunt for its button. @ViewBuilder private var closeButton: some View { - if isSelected || isHovered { - Button(action: onClose) { - Image(systemName: "xmark") - .font(.system(size: 9, weight: .semibold)) - .foregroundStyle(Color(nsColor: .secondaryLabelColor)) - .frame(width: Self.accessoryWidth, height: Self.accessoryWidth) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityLabel(Text("Close Tab")) + if isSelected || (isHovered && isWindowActive) { + EditorTabStripCloseButton(action: onClose, isWindowActive: isWindowActive) } else { Color.clear } } - private static let accessoryWidth: CGFloat = 16 - private static let accessoryInset: CGFloat = 5 - private static let fontSize: CGFloat = 11 private static let hoverFillOpacity: CGFloat = 0.5 - private static let separatorHeight: CGFloat = 18 +} + +private struct EditorTabStripCloseButton: View { + let action: () -> Void + let isWindowActive: Bool + + @State private var isHovering = false + + var body: some View { + Button(action: action) { + Image(systemName: "xmark") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(Color(nsColor: isWindowActive ? .secondaryLabelColor : .tertiaryLabelColor)) + .frame( + width: EditorTabStripLayout.accessoryWidth, + height: EditorTabStripLayout.accessoryWidth + ) + .contentShape(Circle()) + } + .buttonStyle(EditorTabStripCloseButtonStyle(isHovering: isHovering)) + .onHover { isHovering = $0 } + .accessibilityLabel(Text("Close Tab")) + } +} + +private struct EditorTabStripCloseButtonStyle: ButtonStyle { + let isHovering: Bool + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .background( + Circle().fill(Color.primary.opacity(fillOpacity(isPressed: configuration.isPressed))) + ) + } + + private func fillOpacity(isPressed: Bool) -> Double { + if isPressed { return 0.20 } + return isHovering ? 0.10 : 0 + } +} + +private struct EditorTabStripNewButton: View { + let action: () -> Void + let isWindowActive: Bool + + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + Button(action: action) { + Image(systemName: "plus") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(Color(nsColor: isWindowActive ? .secondaryLabelColor : .tertiaryLabelColor)) + .frame( + width: EditorTabStripLayout.newTabButtonSize, + height: EditorTabStripLayout.newTabButtonSize + ) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .newTabSurface(isLightAppearance: colorScheme == .light) + .help(Text("New Tab")) + .accessibilityLabel(Text("New Tab")) + } } private extension View { - /// The track and the new-tab button are the system's subdued glass. Before glass existed the - /// nearest equivalent is a material, which keeps the same read of a recess behind content. + /// Glass on macOS 26 and later, and the flat control fill that preceded it before that. + /// `controlBackgroundColor` is not the fallback: it matches the window background exactly in + /// dark mode, so the raised tab would read as a hole punched in its own track. @ViewBuilder - func trackGlass() -> some View { + func selectedTabSurface(isLightAppearance: Bool, isWindowActive: Bool) -> some View { if #available(macOS 26.0, *) { glassEffect(.regular, in: Capsule(style: .continuous)) } else { - background(.regularMaterial, in: Capsule(style: .continuous)) + background( + Capsule(style: .continuous) + .fill(Color(nsColor: isWindowActive ? .controlColor : .unemphasizedSelectedContentBackgroundColor)) + .shadow( + color: .black.opacity(isLightAppearance ? 0.12 : 0), + radius: isLightAppearance ? 1 : 0, + y: isLightAppearance ? 0.5 : 0 + ) + ) } } + /// The one genuine press target in the strip, so this is where interactive glass belongs. + /// The tab capsule does not take it: the tab a click lands on is an unselected one, which + /// carries no glass to respond. @ViewBuilder - func tabGlass() -> some View { + func newTabSurface(isLightAppearance: Bool) -> some View { if #available(macOS 26.0, *) { - glassEffect(.regular.interactive(), in: Capsule(style: .continuous)) + glassEffect(.regular.interactive(), in: Circle()) } else { background( - Capsule(style: .continuous) - .fill(Color(nsColor: .controlBackgroundColor)) - .shadow(color: .black.opacity(0.16), radius: 1.5, y: 0.5) + Circle() + .fill(Color(nsColor: .controlColor)) + .shadow( + color: .black.opacity(isLightAppearance ? 0.12 : 0), + radius: isLightAppearance ? 1 : 0, + y: isLightAppearance ? 0.5 : 0 + ) ) } } diff --git a/TablePro/Views/Main/EditorTabStripLayout.swift b/TablePro/Views/Main/EditorTabStripLayout.swift new file mode 100644 index 000000000..77580d196 --- /dev/null +++ b/TablePro/Views/Main/EditorTabStripLayout.swift @@ -0,0 +1,53 @@ +// +// EditorTabStripLayout.swift +// TablePro +// + +import CoreGraphics +import Foundation + +/// The geometry and visibility rules of the editor tab strip, kept apart from the view so they +/// can be tested. Both rules were read off the system tab bar rather than designed. +internal enum EditorTabStripLayout { + internal static let trackHeight: CGFloat = 28 + internal static let tabHeight: CGFloat = 24 + internal static let trackPadding: CGFloat = 2 + internal static let stripInset: CGFloat = 8 + internal static let trackSpacing: CGFloat = 4 + internal static let newTabButtonSize: CGFloat = 28 + internal static let minimumTabWidth: CGFloat = 120 + internal static let separatorHeight: CGFloat = 18 + internal static let accessoryWidth: CGFloat = 16 + internal static let accessoryInset: CGFloat = 5 + internal static let fontSize: CGFloat = 11 + + internal static var totalHeight: CGFloat { trackHeight + stripInset * 2 } + + /// Tabs share the track equally, and stop shrinking at a width that still fits a name so a + /// long list scrolls instead of collapsing into slivers. The system staggers widths slightly + /// by an undocumented rule; an equal share is within a couple of points of it. + internal static func tabWidth(forTrack width: CGFloat, count: Int) -> CGFloat { + let usable = width - trackPadding * 2 + let divisor = CGFloat(max(count, 1)) + return max(usable / divisor, minimumTabWidth) + } + + /// A separator is drawn at the leading edge of a tab only when both it and its leading + /// neighbour are plain and untouched. A line against the raised capsule reads as a seam in + /// it, and one against a hovered tab fights that tab's fill. Two tabs therefore never show + /// one, because one of them is always selected. + internal static func showsSeparator( + before index: Int, + tabIds: [UUID], + selectedId: UUID?, + hoveredId: UUID?, + isReordering: Bool = false + ) -> Bool { + guard !isReordering, index > 0 else { return false } + guard tabIds.indices.contains(index), tabIds.indices.contains(index - 1) else { return false } + let leading = tabIds[index - 1] + let trailing = tabIds[index] + guard leading != selectedId, trailing != selectedId else { return false } + return leading != hoveredId && trailing != hoveredId + } +} diff --git a/TableProTests/Views/Main/EditorTabStripLayoutTests.swift b/TableProTests/Views/Main/EditorTabStripLayoutTests.swift new file mode 100644 index 000000000..e6fd49fba --- /dev/null +++ b/TableProTests/Views/Main/EditorTabStripLayoutTests.swift @@ -0,0 +1,137 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("Editor tab strip layout") +struct EditorTabStripLayoutTests { + private static let ids = (0..<5).map { _ in UUID() } + + @Test("Tabs share the track equally once each share clears the minimum") + func equalShare() { + let width = EditorTabStripLayout.tabWidth(forTrack: 604, count: 4) + #expect(width == 150) + } + + @Test("A crowded track stops shrinking at the minimum, so the strip scrolls instead") + func minimumWidthFloor() { + let width = EditorTabStripLayout.tabWidth(forTrack: 404, count: 20) + #expect(width == EditorTabStripLayout.minimumTabWidth) + } + + @Test("An empty tab list does not divide by zero") + func emptyList() { + let width = EditorTabStripLayout.tabWidth(forTrack: 404, count: 0) + #expect(width == 400) + } + + @Test("No separator leads the first tab") + func firstTabHasNoSeparator() { + #expect( + EditorTabStripLayout.showsSeparator( + before: 0, + tabIds: Self.ids, + selectedId: Self.ids[4], + hoveredId: nil + ) == false + ) + } + + @Test("Two plain untouched neighbours get a separator") + func plainNeighboursSeparate() { + #expect( + EditorTabStripLayout.showsSeparator( + before: 2, + tabIds: Self.ids, + selectedId: Self.ids[4], + hoveredId: nil + ) + ) + } + + @Test("A separator is suppressed on both sides of the selected tab") + func selectionSuppressesBothSides() { + #expect( + EditorTabStripLayout.showsSeparator( + before: 2, + tabIds: Self.ids, + selectedId: Self.ids[2], + hoveredId: nil + ) == false + ) + #expect( + EditorTabStripLayout.showsSeparator( + before: 3, + tabIds: Self.ids, + selectedId: Self.ids[2], + hoveredId: nil + ) == false + ) + } + + @Test("A separator is suppressed on both sides of the hovered tab") + func hoverSuppressesBothSides() { + #expect( + EditorTabStripLayout.showsSeparator( + before: 2, + tabIds: Self.ids, + selectedId: Self.ids[4], + hoveredId: Self.ids[2] + ) == false + ) + #expect( + EditorTabStripLayout.showsSeparator( + before: 3, + tabIds: Self.ids, + selectedId: Self.ids[4], + hoveredId: Self.ids[2] + ) == false + ) + } + + /// One of two tabs is always the selected one, so the rule alone removes the separator. + @Test("Two tabs never show a separator") + func twoTabsNeverSeparate() { + let pair = Array(Self.ids.prefix(2)) + #expect( + EditorTabStripLayout.showsSeparator( + before: 1, + tabIds: pair, + selectedId: pair[0], + hoveredId: nil + ) == false + ) + #expect( + EditorTabStripLayout.showsSeparator( + before: 1, + tabIds: pair, + selectedId: pair[1], + hoveredId: nil + ) == false + ) + } + + @Test("A reorder in progress clears every separator") + func reorderSuppresses() { + #expect( + EditorTabStripLayout.showsSeparator( + before: 2, + tabIds: Self.ids, + selectedId: Self.ids[4], + hoveredId: nil, + isReordering: true + ) == false + ) + } + + @Test("An index outside the list is not a separator") + func outOfBoundsIsSafe() { + #expect( + EditorTabStripLayout.showsSeparator( + before: 99, + tabIds: Self.ids, + selectedId: nil, + hoveredId: nil + ) == false + ) + } +} From 7a18157bba610043807e573cb9fb2db67806a2ce Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 13 Aug 2026 14:21:38 +0700 Subject: [PATCH 08/47] fix(tabs): keep the connection open when its last tab closes --- CHANGELOG.md | 1 + .../MainContentCommandActions+BulkClose.swift | 2 +- .../Main/MainContentCommandActions.swift | 23 ++++++++++++++----- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70ccd29f4..872de4588 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Every open connection now lives in one window. Picking a connection in the workspace rail switches that window to it instead of raising a second window. - Opening a table or query on a connection you already have open adds a tab to that window instead of opening another window. A tab strip appears once a connection holds more than one tab. +- Closing the last tab leaves the connection open on its empty state. Close Tab again closes the connection, and the window once that was the last one open. - Window tabs follow your "Prefer tabs when opening documents" setting instead of always forcing tabs, and the Window menu gained Merge All Windows. - Mobile keeps remote connections open when you switch apps. - MongoDB shows a standard binary UUID as `UUID("...")` everywhere, including in exports. diff --git a/TablePro/Views/Main/MainContentCommandActions+BulkClose.swift b/TablePro/Views/Main/MainContentCommandActions+BulkClose.swift index 7b3065d72..1d7363f56 100644 --- a/TablePro/Views/Main/MainContentCommandActions+BulkClose.swift +++ b/TablePro/Views/Main/MainContentCommandActions+BulkClose.swift @@ -87,7 +87,7 @@ extension MainContentCommandActions { /// A partial close leaves the window open, so it cannot lean on the window's own prompt. /// Unsaved work is tracked for the connection rather than per tab, so the question is asked /// once for the batch. - private func confirmDiscardingUnsavedWork() async -> Bool { + func confirmDiscardingUnsavedWork() async -> Bool { guard hasUnsavedWorkInWindow else { return true } switch await AlertHelper.confirmSaveChanges( diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index e653cf744..9c664ae0e 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -409,21 +409,32 @@ final class MainContentCommandActions { ) } + /// Closing the last tab leaves the connection open on its empty state, the same state it is + /// in right after connecting. The window hosts every open connection now, so closing it here + /// would take the other connections' tabs and their unsaved edits with it. func closeTab(id: UUID) { guard let coordinator else { return } - coordinator.tabManager.closeTab(id: id) - if coordinator.tabManager.tabs.isEmpty { - Task { await closeWindowAwaiting() } + if let closing = coordinator.tabManager.tabs.first(where: { $0.id == id }) { + RecentlyClosedTabStore.shared.push(tab: closing, connection: coordinator.connection) } + coordinator.tabManager.closeTab(id: id) } - /// Closing the last tab closes the window, which is what Cmd+W does everywhere on macOS. + /// Cmd+W closes the tab in front. Pressed again with no tabs left it closes the connection, + /// and the window itself only once that was the last connection open in it. func closeTab() { - guard let coordinator, let selected = coordinator.tabManager.selectedTab else { + guard let coordinator else { Task { await closeWindowAwaiting() } return } - closeTab(id: selected.id) + if let selected = coordinator.tabManager.selectedTab { + closeTab(id: selected.id) + return + } + Task { + guard await confirmDiscardingUnsavedWork() else { return } + WindowManager.shared.closeWindow(for: connectionId) + } } /// The single close primitive. `asBatchSurvivor` is `nil` for a lone close gesture, which lets From b1a2447f746871a703b7789ec00c004e50b71d7a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 13 Aug 2026 14:32:30 +0700 Subject: [PATCH 09/47] fix(tabs): open the tab a payload names when its connection is already open --- .../Infrastructure/ConnectionWorkspace.swift | 31 ++++ .../Infrastructure/EditorTabOpener.swift | 136 ++++++++++++++++++ .../MainSplitViewController.swift | 1 + .../Infrastructure/SessionStateFactory.swift | 86 +---------- .../Infrastructure/WindowManager.swift | 11 +- .../Infrastructure/EditorTabOpenerTests.swift | 130 +++++++++++++++++ 6 files changed, 308 insertions(+), 87 deletions(-) create mode 100644 TablePro/Core/Services/Infrastructure/EditorTabOpener.swift create mode 100644 TableProTests/Core/Services/Infrastructure/EditorTabOpenerTests.swift diff --git a/TablePro/Core/Services/Infrastructure/ConnectionWorkspace.swift b/TablePro/Core/Services/Infrastructure/ConnectionWorkspace.swift index b2eb0dba2..78b05b816 100644 --- a/TablePro/Core/Services/Infrastructure/ConnectionWorkspace.swift +++ b/TablePro/Core/Services/Infrastructure/ConnectionWorkspace.swift @@ -48,10 +48,41 @@ internal final class ConnectionWorkspace { self.undoManager = UndoManager() } + /// Payloads that arrived before this workspace had a session to open them in. A connect can + /// take seconds, and a table asked for in the meantime has to survive the wait rather than + /// be dropped. + private var pendingPayloads: [EditorTabPayload] = [] + internal var connection: DatabaseConnection? { payloadConnection ?? session?.connection } + /// Opens what the payload names. Held until the session exists if the connection is still + /// being established. + internal func open(_ payload: EditorTabPayload) { + guard let sessionState, let connection else { + pendingPayloads.append(payload) + return + } + EditorTabOpener.apply( + payload, + to: sessionState.tabManager, + connection: connection, + toolbarState: sessionState.toolbarState + ) + } + + /// Runs once a session has been adopted. The payload the workspace was created with is + /// already applied by `SessionStateFactory`, so only the ones that arrived after it are here. + internal func drainPendingPayloads() { + guard !pendingPayloads.isEmpty else { return } + let queued = pendingPayloads + pendingPayloads.removeAll() + for payload in queued { + open(payload) + } + } + internal var retainsRestoreIntent: Bool { ConnectionWindowPhaseMachine.retainsRestoreIntent(phase: phase) } diff --git a/TablePro/Core/Services/Infrastructure/EditorTabOpener.swift b/TablePro/Core/Services/Infrastructure/EditorTabOpener.swift new file mode 100644 index 000000000..a8a82b777 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/EditorTabOpener.swift @@ -0,0 +1,136 @@ +// +// EditorTabOpener.swift +// TablePro +// + +import Foundation +import os + +/// Turns an `EditorTabPayload` into a tab. This is the single translation from "what the user +/// asked to open" to "a tab in a connection's list", so a payload opens the same tab whether the +/// connection is being created for it or has been open for hours. +@MainActor +internal enum EditorTabOpener { + private static let logger = Logger(subsystem: "com.TablePro", category: "EditorTabOpener") + + internal static func apply( + _ payload: EditorTabPayload, + to tabManager: QueryTabManager, + connection: DatabaseConnection, + toolbarState: ConnectionToolbarState? + ) { + let browseDatabaseName = DatabaseManager.shared.browseDatabaseName(for: connection) + + switch payload.intent { + case .openContent: + applyContent( + payload, + to: tabManager, + connection: connection, + toolbarState: toolbarState, + browseDatabaseName: browseDatabaseName + ) + case .newEmptyTab: + let allTabs = MainContentCoordinator.allTabs(for: connection.id) + tabManager.addTab( + initialQuery: payload.initialQuery, + title: QueryTabManager.nextQueryTitle(existingTabs: allTabs), + databaseName: payload.databaseName ?? browseDatabaseName, + claimFocus: true + ) + case .restoreOrDefault: + break + } + } + + private static func applyContent( + _ payload: EditorTabPayload, + to tabManager: QueryTabManager, + connection: DatabaseConnection, + toolbarState: ConnectionToolbarState?, + browseDatabaseName: String + ) { + switch payload.tabType { + case .table: + toolbarState?.isTableTab = true + applyTable( + payload, + to: tabManager, + connection: connection, + browseDatabaseName: browseDatabaseName + ) + case .query: + let hasContent = payload.initialQuery != nil + || payload.tabTitle != nil + || payload.sourceFileURL != nil + guard hasContent else { return } + tabManager.addTab( + initialQuery: payload.initialQuery, + title: payload.tabTitle, + databaseName: payload.databaseName ?? browseDatabaseName, + sourceFileURL: payload.sourceFileURL, + claimFocus: true + ) + case .createTable: + tabManager.addCreateTableTab(databaseName: payload.databaseName ?? browseDatabaseName) + case .erDiagram: + tabManager.addERDiagramTab( + schemaKey: payload.erDiagramSchemaKey ?? payload.databaseName ?? browseDatabaseName, + databaseName: payload.databaseName ?? browseDatabaseName + ) + case .serverDashboard: + tabManager.addServerDashboardTab() + case .usersRoles: + tabManager.addUsersRolesTab() + } + } + + private static func applyTable( + _ payload: EditorTabPayload, + to tabManager: QueryTabManager, + connection: DatabaseConnection, + browseDatabaseName: String + ) { + let resolvedSchemaName = DatabaseManager.shared.resolvedSchemaName( + payload.schemaName, for: connection.id + ) + + guard let tableName = payload.tableName else { + tabManager.addTab(databaseName: payload.databaseName ?? browseDatabaseName) + return + } + + do { + if payload.isPreview { + try tabManager.addPreviewTableTab( + tableName: tableName, + databaseType: connection.type, + databaseName: payload.databaseName ?? browseDatabaseName, + schemaName: resolvedSchemaName, + isView: payload.isView + ) + } else { + try tabManager.addTableTab( + tableName: tableName, + databaseType: connection.type, + databaseName: payload.databaseName ?? browseDatabaseName, + schemaName: resolvedSchemaName, + isView: payload.isView + ) + } + } catch { + logger.error("create tab for table failed: \(error.localizedDescription, privacy: .public)") + } + + guard let index = tabManager.selectedTabIndex else { return } + tabManager.tabs[index].tableContext.isView = payload.isView + tabManager.tabs[index].tableContext.isEditable = !payload.isView + tabManager.tabs[index].tableContext.schemaName = resolvedSchemaName + if payload.showStructure { + tabManager.tabs[index].display.resultsViewMode = .structure + } + if let initialFilter = payload.initialFilterState { + tabManager.tabs[index].filterState = initialFilter + } + } +} diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index a1d45a19e..85395d832 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -405,6 +405,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi installToolbar(coordinator: state.coordinator) } } + workspace.drainPendingPayloads() } /// Only called once the session entry is gone. A session that still exists without a driver diff --git a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift index abf9bb62b..222eaca68 100644 --- a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift +++ b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift @@ -87,92 +87,8 @@ enum SessionStateFactory { toolbarSt.currentDatabase = String(dbIndex) } - let browseDatabaseName = DatabaseManager.shared.browseDatabaseName(for: connection) - if let payload { - switch payload.intent { - case .openContent: - switch payload.tabType { - case .table: - toolbarSt.isTableTab = true - let resolvedSchemaName = DatabaseManager.shared.resolvedSchemaName( - payload.schemaName, for: connectionId - ) - if let tableName = payload.tableName { - do { - if payload.isPreview { - try tabMgr.addPreviewTableTab( - tableName: tableName, - databaseType: connection.type, - databaseName: payload.databaseName ?? browseDatabaseName, - schemaName: resolvedSchemaName, - isView: payload.isView - ) - } else { - try tabMgr.addTableTab( - tableName: tableName, - databaseType: connection.type, - databaseName: payload.databaseName ?? browseDatabaseName, - schemaName: resolvedSchemaName, - isView: payload.isView - ) - } - } catch { - sessionStateLogger.error("create tab for table failed: \(error.localizedDescription, privacy: .public)") - } - if let index = tabMgr.selectedTabIndex { - tabMgr.tabs[index].tableContext.isView = payload.isView - tabMgr.tabs[index].tableContext.isEditable = !payload.isView - tabMgr.tabs[index].tableContext.schemaName = resolvedSchemaName - if payload.showStructure { - tabMgr.tabs[index].display.resultsViewMode = .structure - } - if let initialFilter = payload.initialFilterState { - tabMgr.tabs[index].filterState = initialFilter - } - } - } else { - tabMgr.addTab(databaseName: payload.databaseName ?? browseDatabaseName) - } - case .query: - let hasContent = payload.initialQuery != nil - || payload.tabTitle != nil - || payload.sourceFileURL != nil - if hasContent { - tabMgr.addTab( - initialQuery: payload.initialQuery, - title: payload.tabTitle, - databaseName: payload.databaseName ?? browseDatabaseName, - sourceFileURL: payload.sourceFileURL, - claimFocus: true - ) - } - case .createTable: - tabMgr.addCreateTableTab( - databaseName: payload.databaseName ?? browseDatabaseName - ) - case .erDiagram: - tabMgr.addERDiagramTab( - schemaKey: payload.erDiagramSchemaKey ?? payload.databaseName ?? browseDatabaseName, - databaseName: payload.databaseName ?? browseDatabaseName - ) - case .serverDashboard: - tabMgr.addServerDashboardTab() - case .usersRoles: - tabMgr.addUsersRolesTab() - } - case .newEmptyTab: - let allTabs = MainContentCoordinator.allTabs(for: connection.id) - let title = QueryTabManager.nextQueryTitle(existingTabs: allTabs) - tabMgr.addTab( - initialQuery: payload.initialQuery, - title: title, - databaseName: payload.databaseName ?? browseDatabaseName, - claimFocus: true - ) - case .restoreOrDefault: - break - } + EditorTabOpener.apply(payload, to: tabMgr, connection: connection, toolbarState: toolbarSt) } let queryExecutor = QueryExecutor(connection: connection) diff --git a/TablePro/Core/Services/Infrastructure/WindowManager.swift b/TablePro/Core/Services/Infrastructure/WindowManager.swift index 36b00a6f1..7832a9725 100644 --- a/TablePro/Core/Services/Infrastructure/WindowManager.swift +++ b/TablePro/Core/Services/Infrastructure/WindowManager.swift @@ -24,8 +24,15 @@ internal final class WindowManager { /// only adds a workspace to it. A second window is created solely when there is none. internal func openTab(payload: EditorTabPayload, activate: Bool = true, autoConnect: Bool = false) { if let host = frontmostHost() { - host.adoptWorkspace(payload: payload, autoConnect: autoConnect) - host.workspaces.select(payload.connectionId) + /// A connection the window already hosts still has to honour the payload, because a + /// payload names a tab to open, not just a connection to show. Adopting the + /// workspace alone would silently drop the table the caller asked for. + if let existing = host.workspaces.workspace(for: payload.connectionId) { + host.workspaces.select(payload.connectionId) + existing.open(payload) + } else { + host.adoptWorkspace(payload: payload, autoConnect: autoConnect) + } if activate { host.view.window?.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) diff --git a/TableProTests/Core/Services/Infrastructure/EditorTabOpenerTests.swift b/TableProTests/Core/Services/Infrastructure/EditorTabOpenerTests.swift new file mode 100644 index 000000000..d3ccabacf --- /dev/null +++ b/TableProTests/Core/Services/Infrastructure/EditorTabOpenerTests.swift @@ -0,0 +1,130 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("Editor tab opener") +@MainActor +struct EditorTabOpenerTests { + private func makeConnection() -> DatabaseConnection { + DatabaseConnection(name: "Opener", type: .mysql) + } + + private func tablePayload(_ connectionId: UUID, table: String) -> EditorTabPayload { + EditorTabPayload( + connectionId: connectionId, + tabType: .table, + tableName: table, + databaseName: "shop", + isView: false + ) + } + + /// The regression this guards: a payload naming a table used to open a tab only while a + /// connection was being created for it, so asking a connection that was already open for a + /// second table did nothing at all. + @Test("A table payload opens a tab in a list that already has one") + func tableOpensIntoPopulatedList() { + let connection = makeConnection() + let manager = QueryTabManager() + manager.addTab(title: "Query 1") + + EditorTabOpener.apply( + tablePayload(connection.id, table: "orders"), + to: manager, + connection: connection, + toolbarState: nil + ) + + #expect(manager.tabs.count == 2) + #expect(manager.selectedTab?.tableContext.tableName == "orders") + } + + @Test("Two different tables open two tabs") + func twoTablesOpenTwoTabs() { + let connection = makeConnection() + let manager = QueryTabManager() + + EditorTabOpener.apply( + tablePayload(connection.id, table: "orders"), + to: manager, + connection: connection, + toolbarState: nil + ) + EditorTabOpener.apply( + tablePayload(connection.id, table: "customers"), + to: manager, + connection: connection, + toolbarState: nil + ) + + #expect(manager.tabs.count == 2) + #expect(manager.selectedTab?.tableContext.tableName == "customers") + } + + @Test("A new empty tab payload adds a query tab") + func newEmptyTabAddsQueryTab() { + let connection = makeConnection() + let manager = QueryTabManager() + + EditorTabOpener.apply( + EditorTabPayload(connectionId: connection.id, intent: .newEmptyTab), + to: manager, + connection: connection, + toolbarState: nil + ) + + #expect(manager.tabs.count == 1) + #expect(manager.selectedTab?.tabType == .query) + } + + /// Restoring a session brings its own tabs back, so the payload must not add one on top. + @Test("A restore payload opens nothing") + func restoreOpensNothing() { + let connection = makeConnection() + let manager = QueryTabManager() + + EditorTabOpener.apply( + EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault), + to: manager, + connection: connection, + toolbarState: nil + ) + + #expect(manager.tabs.isEmpty) + } + + @Test("A query payload with no content opens nothing") + func emptyQueryPayloadOpensNothing() { + let connection = makeConnection() + let manager = QueryTabManager() + + EditorTabOpener.apply( + EditorTabPayload(connectionId: connection.id, tabType: .query), + to: manager, + connection: connection, + toolbarState: nil + ) + + #expect(manager.tabs.isEmpty) + } + + @Test("A query payload carrying a query opens a tab holding it") + func queryPayloadOpensTab() { + let connection = makeConnection() + let manager = QueryTabManager() + + EditorTabOpener.apply( + EditorTabPayload( + connectionId: connection.id, + tabType: .query, + initialQuery: "SELECT 1" + ), + to: manager, + connection: connection, + toolbarState: nil + ) + + #expect(manager.tabs.count == 1) + #expect(manager.selectedTab?.content.query == "SELECT 1") + } +} From bda84c3a8d4b0c2d25842de68033014252001675 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 13 Aug 2026 16:26:42 +0700 Subject: [PATCH 10/47] fix(tabs): clear saved tabs when the user closes the last one --- TablePro/Views/Main/EditorTabStrip.swift | 16 ++++++------ .../MainContentCoordinator+TabClosing.swift | 25 +++++++++++++++++++ .../MainContentCommandActions+BulkClose.swift | 14 +++-------- .../Main/MainContentCommandActions.swift | 6 +---- 4 files changed, 36 insertions(+), 25 deletions(-) create mode 100644 TablePro/Views/Main/Extensions/MainContentCoordinator+TabClosing.swift diff --git a/TablePro/Views/Main/EditorTabStrip.swift b/TablePro/Views/Main/EditorTabStrip.swift index a5d63da6e..e0366db56 100644 --- a/TablePro/Views/Main/EditorTabStrip.swift +++ b/TablePro/Views/Main/EditorTabStrip.swift @@ -56,7 +56,7 @@ internal struct EditorTabStrip: View { /// a tab that is scrolled out of sight, so the selection pulls itself into view. .onChange(of: tabManager.selectedTabId) { _, newValue in guard let newValue else { return } - withAnimation(.easeOut(duration: 0.15)) { + withMotion(.easeOut(duration: 0.15)) { scroller.scrollTo(newValue, anchor: .center) } } @@ -65,7 +65,7 @@ internal struct EditorTabStrip: View { .padding(EditorTabStripLayout.trackPadding) .background( Capsule(style: .continuous) - .fill(Color(nsColor: .quaternaryLabelColor)) + .fill(Color(nsColor: .quaternarySystemFill)) ) } .frame(height: EditorTabStripLayout.trackHeight) @@ -170,7 +170,7 @@ private struct EditorTabStripItem: View { Color.clear.selectedTabSurface(isLightAppearance: colorScheme == .light, isWindowActive: isWindowActive) } else if isHovered, isWindowActive { Capsule(style: .continuous) - .fill(Color(nsColor: .separatorColor).opacity(Self.hoverFillOpacity)) + .fill(Color(nsColor: .quaternarySystemFill)) } } @@ -184,8 +184,6 @@ private struct EditorTabStripItem: View { Color.clear } } - - private static let hoverFillOpacity: CGFloat = 0.5 } private struct EditorTabStripCloseButton: View { @@ -217,13 +215,13 @@ private struct EditorTabStripCloseButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .background( - Circle().fill(Color.primary.opacity(fillOpacity(isPressed: configuration.isPressed))) + Circle().fill(fill(isPressed: configuration.isPressed)) ) } - private func fillOpacity(isPressed: Bool) -> Double { - if isPressed { return 0.20 } - return isHovering ? 0.10 : 0 + private func fill(isPressed: Bool) -> Color { + if isPressed { return Color(nsColor: .tertiarySystemFill) } + return isHovering ? Color(nsColor: .quaternarySystemFill) : .clear } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabClosing.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabClosing.swift new file mode 100644 index 000000000..8944280b0 --- /dev/null +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabClosing.swift @@ -0,0 +1,25 @@ +// +// MainContentCoordinator+TabClosing.swift +// TablePro +// + +import Foundation + +extension MainContentCoordinator { + /// The one path that means "the user closed these tabs themselves", which is the consent + /// `clearForUserClosedAllTabs` requires. Every automatic path leaves the saved state alone, + /// because an empty tab list can equally mean a session was lost or the app is quitting, and + /// treating that as a delete throws away tabs nobody closed. + /// + /// Closing tabs never closes the window: the window hosts every open connection now, so the + /// connection is simply left on its empty state. + func closeTabsByUser(ids: [UUID]) { + for id in ids { + guard let tab = tabManager.tabs.first(where: { $0.id == id }) else { continue } + RecentlyClosedTabStore.shared.push(tab: tab, connection: connection) + tabManager.closeTab(id: id) + } + guard tabManager.tabs.isEmpty else { return } + persistence.clearForUserClosedAllTabs() + } +} diff --git a/TablePro/Views/Main/MainContentCommandActions+BulkClose.swift b/TablePro/Views/Main/MainContentCommandActions+BulkClose.swift index 1d7363f56..343ca6340 100644 --- a/TablePro/Views/Main/MainContentCommandActions+BulkClose.swift +++ b/TablePro/Views/Main/MainContentCommandActions+BulkClose.swift @@ -66,22 +66,14 @@ extension MainContentCommandActions { /// Tabs live in one window now, so a batch close is a list edit rather than a walk over /// sibling windows. Closing every tab is still a window close, which already owns the save /// prompt and the recovery capture, so that case is handed straight to it. + /// Closing every tab leaves the connection on its empty state rather than closing the window, + /// because the window is no longer this connection's window: it hosts all of them. private func runBatchClose(kind: BatchCloseKind) async { guard let coordinator else { return } let victims = tabsToClose(kind: kind) guard !victims.isEmpty else { return } - - if victims.count == coordinator.tabManager.tabs.count { - await closeWindowAwaiting() - return - } - guard await confirmDiscardingUnsavedWork() else { return } - - for tab in victims { - RecentlyClosedTabStore.shared.push(tab: tab, connection: coordinator.connection) - coordinator.tabManager.closeTab(id: tab.id) - } + coordinator.closeTabsByUser(ids: victims.map(\.id)) } /// A partial close leaves the window open, so it cannot lean on the window's own prompt. diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 9c664ae0e..75241caf2 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -413,11 +413,7 @@ final class MainContentCommandActions { /// in right after connecting. The window hosts every open connection now, so closing it here /// would take the other connections' tabs and their unsaved edits with it. func closeTab(id: UUID) { - guard let coordinator else { return } - if let closing = coordinator.tabManager.tabs.first(where: { $0.id == id }) { - RecentlyClosedTabStore.shared.push(tab: closing, connection: coordinator.connection) - } - coordinator.tabManager.closeTab(id: id) + coordinator?.closeTabsByUser(ids: [id]) } /// Cmd+W closes the tab in front. Pressed again with no tabs left it closes the connection, From 5b08c1c1cf5dac32f17a7acc4d4e304af438a015 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 13 Aug 2026 16:49:31 +0700 Subject: [PATCH 11/47] fix(tabs): restore every saved tab into the connection's one tab list --- .../Extensions/MainContentView+Setup.swift | 243 ++++++------------ 1 file changed, 74 insertions(+), 169 deletions(-) diff --git a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index e5bc03e94..002bd9b4c 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -83,118 +83,43 @@ extension MainContentView { _ = await schemaLoad } - private func handleRestoreOrDefault() async { - if let group = RestorationGroupRegistry.consume(for: payload?.id) { - applyRestoredGroup( - group.tabs, - selectedTabId: group.selectedTabId, - loadTiming: group.loadTiming, - consumeDeferredWhenKey: true - ) - return - } - - /// The split view controller owns the window and is wired up before this view is built, unlike - /// `viewWindow`, which arrives from `configureWindow` and can still be nil here. - guard let window = coordinator.splitViewController?.view.window else { - MainContentView.lifecycleLogger.error( - "[open] handleRestoreOrDefault has no window windowId=\(windowId, privacy: .public)" - ) - return - } - let windowIndex = WindowTabGroupOrder.index(of: window) - let openWindowCount = WindowTabGroupOrder.size(containing: window) - - let restoreStart = Date() - let result = await coordinator.persistence.restoreFromDisk() - MainContentView.lifecycleLogger.info( - "[open] restoreFromDisk done windowId=\(windowId, privacy: .public) tabsRestored=\(result.tabs.count) windowIndex=\(windowIndex) openWindowCount=\(openWindowCount) source=\(String(describing: result.source), privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(restoreStart) * 1_000))" - ) - guard !result.tabs.isEmpty else { return } + private func restoreConnectionContext( + for selected: QueryTab, + activeDatabase: String?, + activeSchema: String?, + loadTiming: RestoreLoadTiming, + consumeDeferredWhenKey: Bool + ) { + let isTableTab = selected.tabType == .table + && !selected.content.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - var restoredTabs = result.tabs - for i in restoredTabs.indices where restoredTabs[i].tabType == .table { - if let tableName = restoredTabs[i].tableContext.tableName { - do { - restoredTabs[i].content.query = try QueryTab.buildBaseTableQuery( - tableName: tableName, - databaseType: connection.type, - schemaName: restoredTabs[i].tableContext.schemaName - ) - } catch { - MainContentView.lifecycleLogger.error( - "[open] buildBaseTableQuery failed for restored tab table=\(tableName, privacy: .public): \(error.localizedDescription, privacy: .public)" - ) + guard loadTiming == .immediate else { + if isTableTab { + coordinator.deferredRestoreLoadTabId = selected.id + if consumeDeferredWhenKey { + coordinator.consumeDeferredRestoreLoadIfNeeded() } } + return } - let plan = WindowGroupAssignment.resolve( - windowIndex: windowIndex, - openWindowCount: openWindowCount, - tabs: restoredTabs, - windowGroupIndexByTabId: result.windowGroupIndexByTabId, - selectedTabId: result.selectedTabId - ) - - if openWindowCount == 1 { - restoreAsOnlyWindow(plan, result: result) - } else { - claimOwnTabs(plan, result: result, window: window) - } - } - - /// The connection has one window, so this window restores what it kept and opens a window for every - /// other group. Nothing has focus yet, so the tab the user left selected is brought to the front. - private func restoreAsOnlyWindow(_ plan: WindowGroupAssignment.Plan, result: RestoreResult) { - let frontGroup = RestoreWindowPlan.resolveFrontGroup( - ownTabIds: plan.ownTabs.map(\.id), - orphanedGroups: plan.orphanedGroups.map { ($0.windowGroupIndex, $0.tabs.map(\.id)) }, - selectedId: result.selectedTabId - ) - - applyRestoredGroup( - plan.ownTabs, - selectedTabId: plan.ownSelectedTabId ?? plan.ownTabs.first?.id, - activeDatabase: result.lastActiveDatabase, - activeSchema: result.lastActiveSchema, - loadTiming: frontGroup == .own ? .immediate : .deferred - ) - - for group in plan.orphanedGroups { - let isFront = frontGroup == .orphaned(windowGroupIndex: group.windowGroupIndex) - openRestoredTabWindow( - group.tabs, - selectedTabId: group.selectedTabId, - activate: isFront, - loadTiming: isFront ? .immediate : .deferred - ) - } - if frontGroup == .own, !plan.orphanedGroups.isEmpty { - viewWindow?.makeKeyAndOrderFront(nil) + guard let session = DatabaseManager.shared.activeSessions[connection.id], session.isConnected else { + if isTableTab { coordinator.pendingLoadTrigger = .restore } + return } - } - /// Other windows of this connection are already open and every one of them is restoring its own - /// tabs right now, so this window takes only what was its own and never raises itself: the user is - /// already looking at one of these windows. Only the window the user can see loads its data now, - /// which also keeps a reconnect from firing one query per window. - private func claimOwnTabs(_ plan: WindowGroupAssignment.Plan, result: RestoreResult, window: NSWindow) { - applyRestoredGroup( - plan.ownTabs, - selectedTabId: plan.ownSelectedTabId ?? plan.ownTabs.first?.id, - activeDatabase: result.lastActiveDatabase, - activeSchema: result.lastActiveSchema, - loadTiming: window.isKeyWindow ? .immediate : .deferred - ) + let targetDatabase = activeDatabase.flatMap { $0.isEmpty ? nil : $0 } - for group in plan.orphanedGroups { - openRestoredTabWindow( - group.tabs, - selectedTabId: group.selectedTabId, - activate: false, - loadTiming: .deferred - ) + Task { + if let targetDatabase, targetDatabase != session.resolvedBrowseDatabase { + await coordinator.switchDatabase(to: targetDatabase) + } + if let activeSchema, !activeSchema.isEmpty, activeSchema != session.browseSchema { + await coordinator.switchSchema(to: activeSchema) + } + if isTableTab { + coordinator.lazyLoadCurrentTabIfNeeded(trigger: .restore) + } } } @@ -228,83 +153,63 @@ extension MainContentView { ) } - /// Restore the connection's database and schema, then load the selected tab, in a single - /// sequenced task so the database and schema switches never race each other. A deferred - /// tab records its id and loads only when its window becomes key from a user switch. - /// - /// `consumeDeferredWhenKey` is true only for sibling windows opened by restoration, which - /// may already be key because the user is showing them. The initial window is transiently - /// key at launch before the front window activates, so it must never consume here — it loads - /// its deferred tab through `windowDidBecomeKey` when the user switches back to it. - private func restoreConnectionContext( - for selected: QueryTab, - activeDatabase: String?, - activeSchema: String?, - loadTiming: RestoreLoadTiming, - consumeDeferredWhenKey: Bool - ) { - let isTableTab = selected.tabType == .table - && !selected.content.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - - guard loadTiming == .immediate else { - if isTableTab { - coordinator.deferredRestoreLoadTabId = selected.id - if consumeDeferredWhenKey { - coordinator.consumeDeferredRestoreLoadIfNeeded() - } - } + private func handleRestoreOrDefault() async { + if let group = RestorationGroupRegistry.consume(for: payload?.id) { + applyRestoredGroup( + group.tabs, + selectedTabId: group.selectedTabId, + loadTiming: group.loadTiming, + consumeDeferredWhenKey: true + ) return } - guard let session = DatabaseManager.shared.activeSessions[connection.id], session.isConnected else { - if isTableTab { coordinator.pendingLoadTrigger = .restore } + /// The split view controller owns the window and is wired up before this view is built, unlike + /// `viewWindow`, which arrives from `configureWindow` and can still be nil here. + guard let window = coordinator.splitViewController?.view.window else { + MainContentView.lifecycleLogger.error( + "[open] handleRestoreOrDefault has no window windowId=\(windowId, privacy: .public)" + ) return } + let restoreStart = Date() + let result = await coordinator.persistence.restoreFromDisk() + MainContentView.lifecycleLogger.info( + "[open] restoreFromDisk done windowId=\(windowId, privacy: .public) tabsRestored=\(result.tabs.count) source=\(String(describing: result.source), privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(restoreStart) * 1_000))" + ) + guard !result.tabs.isEmpty else { return } - let targetDatabase = activeDatabase.flatMap { $0.isEmpty ? nil : $0 } - - Task { - if let targetDatabase, targetDatabase != session.resolvedBrowseDatabase { - await coordinator.switchDatabase(to: targetDatabase) - } - if let activeSchema, !activeSchema.isEmpty, activeSchema != session.browseSchema { - await coordinator.switchSchema(to: activeSchema) - } - if isTableTab { - coordinator.lazyLoadCurrentTabIfNeeded(trigger: .restore) + var restoredTabs = result.tabs + for i in restoredTabs.indices where restoredTabs[i].tabType == .table { + if let tableName = restoredTabs[i].tableContext.tableName { + do { + restoredTabs[i].content.query = try QueryTab.buildBaseTableQuery( + tableName: tableName, + databaseType: connection.type, + schemaName: restoredTabs[i].tableContext.schemaName + ) + } catch { + MainContentView.lifecycleLogger.error( + "[open] buildBaseTableQuery failed for restored tab table=\(tableName, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) + } } } - } - /// Opens one window for a whole saved group. The payload describes the tab the window opens on so - /// its native label reads right from creation; the group itself travels through the registry, which - /// is what carries the state a payload cannot express. - private func openRestoredTabWindow( - _ tabs: [QueryTab], - selectedTabId: UUID?, - activate: Bool, - loadTiming: RestoreLoadTiming - ) { - guard let leadTab = tabs.first(where: { $0.id == selectedTabId }) ?? tabs.first else { return } - let restorePayload = EditorTabPayload( - connectionId: connection.id, - tabType: leadTab.tabType, - tableName: leadTab.tableContext.tableName, - databaseName: leadTab.tableContext.databaseName, - schemaName: leadTab.tableContext.schemaName, - isView: leadTab.tableContext.isView, - skipAutoExecute: true, - erDiagramSchemaKey: leadTab.display.erDiagramSchemaKey, - tabTitle: leadTab.title, - intent: .restoreOrDefault - ) - RestorationGroupRegistry.register( - .init(tabs: tabs, selectedTabId: selectedTabId ?? leadTab.id, loadTiming: loadTiming), - for: restorePayload.id + /// One window hosts every connection, so a connection's saved tabs all belong to the one + /// tab list. The old shape split them across windows by a saved group index, which now + /// has nowhere to go: a group handed back to `openTab` restores nothing and the next + /// autosave erases it. + applyRestoredGroup( + restoredTabs, + selectedTabId: result.selectedTabId ?? restoredTabs.first?.id, + activeDatabase: result.lastActiveDatabase, + activeSchema: result.lastActiveSchema, + loadTiming: window.isKeyWindow ? .immediate : .deferred ) - WindowManager.shared.openTab(payload: restorePayload, activate: activate) } + // MARK: - Command Actions Setup func updateToolbarPendingState() { From 888a14b4baac9965b39063df72f4e4ff113dcf35 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 13 Aug 2026 16:52:17 +0700 Subject: [PATCH 12/47] docs(plans): record the window to workspace scoping refactor --- .../plan.md | 521 ++++++++++++++++++ 1 file changed, 521 insertions(+) create mode 100644 plans/20260813-single-window-workspace-scoping/plan.md diff --git a/plans/20260813-single-window-workspace-scoping/plan.md b/plans/20260813-single-window-workspace-scoping/plan.md new file mode 100644 index 000000000..46b0a3812 --- /dev/null +++ b/plans/20260813-single-window-workspace-scoping/plan.md @@ -0,0 +1,521 @@ +# THE SINGLE-WINDOW REFACTOR: ORDERED EXECUTION PLAN + +## 1. THE ONE SENTENCE + +**Make `ConnectionWorkspace` the unit of identity, scope and lifecycle everywhere the code still says "window": every resolver keys on `connectionId`, every genuine window-level event fans out across `MainSplitViewController.workspaces`, and every piece of machinery that existed to reconcile several windows of one connection is deleted rather than rewired.** + +Two corollaries that decide most of the individual calls below: + +- **"Focus" is no longer `makeKeyAndOrderFront`.** It is `select the workspace, select the tab, then raise the window`. One primitive, `WorkspaceLocator.reveal(connectionId:tabId:)`, and nothing else may spell it. +- **"The window closed" is not "a connection closed".** It is N connection closes. Every window-delegate callback that acts on one coordinator is a bug until it iterates workspaces. + +--- + +## 2. BUGS TO FIX NOW + +Ordered by user impact. P0 = irreversible data loss, P1 = data loss recoverable by not quitting, P2 = wrong-target actions, P3 = silent no-ops, P4 = leaks. + +### P0: permanent data loss + +| # | Bug | Location | Fix | +|---|---|---|---| +| B1 | Saved tab groups above index 0 are handed to `openTab` with `.restoreOrDefault`, which hits `case .restoreOrDefault: break`, so they are dropped and the next autosave erases them from disk | `MainContentView+Setup.swift:164-172, 191-198, 282-306` → `WindowManager.swift:25-46` → `EditorTabOpener.swift:41-42` | `handleRestoreOrDefault` restores **every** saved tab in file order into the one `QueryTabManager`; delete `openRestoredTabWindow` | +| B2 | A pre-`windowGroupIndex` file is expanded to one group per tab, so only tab 0 survives and the rest are erased | `WindowGroupAssignment.swift:65-70`, consumed at `TabPersistenceCoordinator.swift:181` and `MainContentView+Setup.swift:132-144` | Stop reading `windowGroupIndex` at all; ignoring the field is the migration for both file shapes | +| B3 | A save carrying only part of a connection's tabs replaces the full saved set and prunes the overflow sidecars of the omitted tabs | `TabQueryOverflowStore.swift:27-48, 81-87` via `TabDiskActor.swift:94-107, 161-189`; reached because `.openContent` never restores (`MainContentView+Setup.swift:41-73`) | A workspace always restores its saved tabs on bootstrap, then applies its payload; `TabPersistenceCoordinator` refuses every write until `hasObservedTabs` is set by an actual restore | +| B4 | Window close persists exactly one arbitrary hosted connection; the rest lose everything since the last periodic save | `TabWindowController.swift:191` → `MainContentCoordinator+Registry.swift:18-20` → `+WindowLifecycle.swift:68-88` | `MainSplitViewController.handleWindowWillClose()` iterates `workspaces.workspaces` and runs save + teardown per workspace | + +### P1: state loss on switch + +| # | Bug | Location | Fix | +|---|---|---|---| +| B5 | The detail pane has no identity keyed to its connection, so `@State hasInitialized`, `windowId`, `viewWindow`, `commandActions`, `cachedChangeManager` carry across a workspace switch (either the second connection never restores, or every switch overwrites live tabs with the disk snapshot at `MainContentView+Setup.swift:210`) | `MainSplitViewController.swift:512, 599-615`; `MainContentView.swift:54-61`; `MainEditorContentView.swift:56, 148, 151` | `.id(currentSession.connection.id)` on the built `MainContentView`, and move restore off view `@State` onto `ConnectionWorkspace.hasBootstrapped` | +| B6 | A cell edit made while connection B is displayed is recorded against A's change manager and dropped when B saves | `MainEditorContentView.swift:56, 148-151` (same root as B5) | Same fix as B5 | +| B7 | Undo/redo run on the shared `NSWindow.undoManager`, so Cmd+Z in B rolls back an edit in A | `MainContentCoordinator.swift:520`; `MainContentCommandActions.swift:1050, 1062`; `+UndoState.swift:14` | Route through `ConnectionWorkspace.undoManager` (`ConnectionWorkspace.swift:28`, currently read by nothing) | +| B8 | Closing the window discards unsaved edits in background connections with no prompt | `MainContentCommandActions.swift:367, 440, 492` | `hasUnsavedWorkInWindow` = `workspaces.workspaces.contains { $0.sessionState?.coordinator.hasAnyUnsavedWork() == true }` | +| B9 | Sample-database failure closes the whole window, taking every other connection with it | `WelcomeViewModel+Sample.swift:128-133` | `WindowManager.shared.closeWindow(for: connectionId)` | +| B10 | Cmd+W on a connecting or failed pane closes the entire window | `MainContentCommandActions.swift:421-425, 492-516` | Close the workspace: `WindowManager.shared.closeWindow(for: connectionId)` | +| B11 | Vim `:q` closes the whole app window | `MainEditorContentView.swift:376` | `coordinator.commandActions?.closeTab()` | + +### P2: actions hit the wrong connection + +| # | Bug | Location | Fix | +|---|---|---|---| +| B12 | `coordinator(forWindow:)` is `activeCoordinators.values.first { $0.contentWindow === window }` and every hosted coordinator matches; it feeds Cmd+W, Cmd+T, `validateMenuItem`, toolbar install, key/resign, window close, Handoff | `MainContentCoordinator+Registry.swift:18-20`; consumers `TabWindowController.swift:13, 22, 34, 141, 164, 191, 216` | Delete it. Resolve through `(window.contentViewController as? MainSplitViewController)?.workspaces.selected?.sessionState?.coordinator` | +| B13 | `TabRouter.openConnection` calls `splitVC.retryConnection()`, which reconnects the **selected** workspace, so clicking a disconnected B tears down and redials the A the user is working in | `TabRouter.swift:103-105` + `MainSplitViewController+Connection.swift:32-35` | `retryConnection(for: connectionId)`, forwarding to the already connection-addressed `connect(_:cancellingPrevious:)` (`+Connection.swift:81`) | +| B14 | The toolbar keeps the coordinator its SwiftUI item views captured at build time: it shows A's name, database, status and spinner while its buttons act on B | `MainSplitViewController.swift:336-347`; `MainWindowToolbar+Delegate.swift:19-141` | A `ToolbarContext` observable box owned by the window; item views read `context.coordinator`; `installToolbar` swaps the box's value instead of hoping `toolbarOwner?.coordinator` is enough | +| B15 | Menu key-equivalent yielding resolves through an arbitrary hosted coordinator, so a grid shortcut can keep or lose its key equivalent regardless of what has focus | `MainMenuBuilder.swift:65-67` | `(NSApp.keyWindow?.contentViewController as? MainSplitViewController)?.commandActions` | +| B16 | Rail context-menu Close resolves `mostRecentWindow` then `coordinator(forWindow:)`, closing tabs in the wrong connection or doing nothing | `WorkspaceRailViewController.swift:319, 375-377` | `host.workspaces.workspace(for:)?.sessionState?.coordinator` | +| B17 | The rail's `connectionId` is frozen at the window's first connection, so clicking row B switches then snaps the highlight back to A | `WorkspaceRailViewController.swift:28`; `NavigationSidebarViewController.swift:27`; `MainSplitViewController.swift:201`; `WorkspaceRailStore.swift:140` | Read `host.workspaces.selectedConnectionId` on every reload | +| B18 | Key-window-only broadcasts fire once per hosted workspace: Open SQL File opens a tab in every connection, Export can open two sheets | `MainContentCommandActions.swift:121, 184, 1157` | `isKeyWindow()` becomes `coordinator.isVisible` (window key **and** this workspace selected) | +| B19 | `frontmostHost()` filters on `isVisible` and casts after the fact, so a miniaturized window or a front `main-inspector` window produces a second host for an already-hosted connection | `WindowManager.swift:50-58, 197-200`; `InspectorWindowController.swift:44` | `hosts().first { $0.workspaces.contains(payload.connectionId) } ?? frontmostHost()`, and select hosts on `contentViewController is MainSplitViewController`, not on the `main-` prefix | +| B20 | Row eviction and Handoff run against whichever coordinator `windowDidResignKey` / `refreshUserActivity` picked | `TabWindowController.swift:164-169, 216-248` | Same resolver as B12 | + +### P3: silent no-ops + +| # | Bug | Location | Fix | +|---|---|---|---| +| B21 | Reopen Closed Tab (Cmd+Shift+T) does nothing whenever the connection has at least one open tab | `RecentlyClosedTabReopener.swift:25-31, 62-82`; `TabRouter.swift:86` | Adopt the reconstructed `QueryTab` into the target `QueryTabManager` directly, then `reveal(connectionId, tabId:)`; delete `openWindowTab` and `RestorationGroupRegistry` | +| B22 | `startActivationConnectIfNeeded` runs only at `viewWillAppear` and `windowDidBecomeKey`, so Reopen Last Session with three connections leaves the middle ones on the Not Connected pane forever | `MainSplitViewController.swift:276`; `+Connection.swift:15-30`; `AppLaunchCoordinator.swift:136-161` | `adoptWorkspace` calls `startActivationConnectIfNeeded(for: workspace.connectionId)`; the phase machine's `allowsActivationConnect` already makes it idempotent | +| B23 | Opening an already-hosted connection raises the window without selecting its workspace, from the connection switcher, welcome list, Dock menu, Handoff and `tablepro://` | `TabRouter.swift:97-110, 236-239, 383-390`; `MainContentCoordinator+WindowLifecycle.swift:104-109` | All of them go through `WorkspaceLocator.reveal` | +| B24 | Show ER Diagram / Users & Roles / Server Dashboard a second time raises an already-front window and never selects the existing tab | `+ERDiagram.swift:19-24`, `+UsersRoles.swift:6-11`, `+ServerDashboard.swift:15-20` | Take the tab id from the match and call `selectTabAndFocusWindow(match.id)` | +| B25 | MCP `focus_query_tab` returns `"focused"` while focusing nothing: it never sets `selectedTabId` and never selects the workspace, and `raised` can never be false | `FocusQueryTabTool.swift:36-53`; `MCPTabSnapshotProvider.swift:41` | `reveal(snapshot.connectionId, tabId: snapshot.tabId)`; derive `raised` from the reveal result | +| B26 | `.sql` file dedupe resolves URL → windowId → NSWindow through a map that is superseded on every workspace mount, so the same file opens a duplicate tab or raises the wrong tab | `WindowLifecycleMonitor.swift:28, 240-259, 53-62`; `TabRouter.swift:353-379`; `+Favorites.swift:42-51, 90-93` | Look the tab up by `content.sourceFileURL` across `workspaces`, then reveal workspace + tab | +| B27 | A failed open for a connection that has a **background** workspace reports nothing: the alert is suppressed and the inline `ConnectionUnavailableView` is only painted for the selected workspace | `LaunchIntentRouter.swift:99-106`; `MainSplitViewController.swift:470-479` | Suppress only when `workspaces.selectedConnectionId == connectionId`; otherwise reveal it first | +| B28 | `openTab` selects the adopted workspace even with `activate: false`, so background opens yank the visible connection away and launch lands on an arbitrary connection | `WindowManager.swift:30-35`; `ConnectionWorkspaceRegistry.swift:47-63` | Thread `select: activate` through `adoptWorkspace` into `insert(_:select:)` | +| B29 | Restored tabs deferred to `windowDidBecomeKey` never load: the gate is `isKeyWindow`, set by a handler dispatched to an arbitrary coordinator | `MainContentView+Setup.swift:239-257`; `+WindowLifecycle.swift:34, 188-194` | Consume the deferred load when the workspace becomes selected | +| B30 | `onMembershipChange` is fired but never assigned, so rail rows appear and disappear only on unrelated events | `ConnectionWorkspaceRegistry.swift:20, 58, 80`; `MainSplitViewController.swift:236` | Wire it to reload the rail and re-run `applyRailVisibility` | +| B31 | The split autosave name is never repointed on switch, so a divider drag while viewing B is persisted under A's key and B's layout never loads | `MainSplitViewController.swift:98-103, 934-946` | One window-level name, `com.TablePro.mainSplit` | +| B32 | `representedURL`, `isDocumentEdited` and the detail pane minimum are not reapplied on switch | `MainSplitViewController.swift:430-439` | `applySelectedWorkspace` pushes all three for the selected workspace's selected tab | +| B33 | A blank persisted `tab.title` renders as an empty strip label, tooltip and accessibility label | `EditorTabStrip.swift:146, 167, 174`; `QueryTabState.swift:95` | Heal at `QueryTab.title` (or at the strip) with the same rules `WindowTitleResolver.sanitizeTitle` applies | + +### P4: leaks and orphaned sessions + +| # | Bug | Location | Fix | +|---|---|---|---| +| B34 | Closing the window disconnects at most one of its connections; the rest keep drivers, SSH tunnels and 30s health pings until quit | `WindowLifecycleMonitor.swift:289-325` | Window close fans out over `workspaces.connectionIds` | +| B35 | N-1 coordinators never leave the strong static `activeCoordinators`, so they never deinit, keep their driver alive, and keep voting themselves into Reopen Last Session and the unsaved-changes alert | `MainContentCoordinator.swift:293, 305-307, 752`; `SessionRecoveryTracker.swift:16-22`; `AppDelegate.swift:147` | Same fan-out; `teardown()` per workspace | +| B36 | A pending connect for any workspace other than the window's original payload is never cancelled on close | `TabWindowController.swift:198-210` | Iterate `splitVC.workspaces.connectionIds`; drop the racy `hasOpenWindow` guard (the controller is still retained during `willClose`) | +| B37 | `WindowLifecycleMonitor.register` evicts every other connection's entry for the same NSWindow, so only the last-mounted connection is findable | `WindowLifecycleMonitor.swift:45-62`; `MainContentView+Setup.swift:354-358` | Delete the monitor (Step 13) | +| B38 | The object browser keeps the previous connection's `SharedSidebarState` when the selected workspace has no session | `MainSplitViewController.swift:506-511` | `updateSidebarState(workspaces.selected.map { SharedSidebarState.forConnection($0.connectionId) })`, unconditional | + +--- + +## 3. THE REFACTOR, IN ORDERED STEPS + +Every step compiles, passes `swiftlint lint --strict`, and leaves the app usable. Run `scripts/generate-project.sh` after any step that adds or deletes a file. + +--- + +### STEP 1: Restore returns one ordered tab list + +**Why first:** it is the only defect that destroys user data irreversibly (B1, B2), and it is a subtraction, not a redesign. + +**Files touched** +- `TablePro/Views/Main/Extensions/MainContentView+Setup.swift` (86-199, 282-306) +- `TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift` (16-19, 181) + +**Changes** +- `handleRestoreOrDefault` drops the `windowIndex` / `openWindowCount` computation (105-106), the `WindowGroupAssignment.resolve` call (132-138) and the `openWindowCount == 1` branch (140-144). It calls `applyRestoredGroup(restoredTabs, selectedTabId: result.selectedTabId, activeDatabase:, activeSchema:, loadTiming: .immediate)` once. +- `RestoreResult` loses `windowGroupIndexByTabId`; `restoreFromDisk` stops calling `normalizedGroupIndices` and returns tabs in file order. + +**Deleted** +- `restoreAsOnlyWindow`, `claimOwnTabs`, `openRestoredTabWindow` (`MainContentView+Setup.swift:147-199, 282-306`) +- `TablePro/Core/Services/Infrastructure/WindowGroupAssignment.swift` (whole file) +- `TablePro/Core/Services/Infrastructure/RestoreWindowPlan.swift` (whole file) +- `TablePro/Core/Services/Infrastructure/WindowTabGroupOrder.swift` (whole file, after removing the two `MainContentCoordinator.swift:330-334, 346-348` uses in Step 2; if that ordering is inconvenient, keep the file one step longer) +- `TableProTests/Core/Services/WindowGroupAssignmentTests.swift`, `RestoreWindowPlanTests.swift`, `WindowTabGroupOrderTests.swift` + +**Proven by unit tests** +- `TabRestoreMigrationTests.fileWithGroupIndicesRestoresEveryTabInFileOrder` +- `TabRestoreMigrationTests.fileWithoutGroupIndicesRestoresEveryTab` + +**Needs the app run:** no. + +--- + +### STEP 2: One connection, one tab list, on the write side too + +**Files touched** +- `MainContentCoordinator.swift:313-348, 352-371, 427-455, 542` +- `TabPersistenceCoordinator.swift:44-46, 50-97` +- `TabPersistenceCoordinator+AggregatedSave.swift:15-50` +- `QueryTabState.swift:42, 110`; `QueryTab.swift:170, 205` + +**Changes** +- `aggregatedTabs(for:)` becomes `coordinator.tabManager.tabs.map(enrichedForPersistence)`. **`enrichedForPersistence` (352-371) survives verbatim**: it is the only place restored sort column names and the selected tab's caret offset are resolved. +- `saveNow(windowedTabs:)` / `saveNowSync(windowedTabs:)` collapse into the existing `saveNow(tabs:selectedTabId:)` / `saveNowSync(tabs:selectedTabId:)` signatures; the tuple overloads go. +- `hasObservedTabs` is set **only** by `restoreFromDisk` (`:163`) and by the revived `markObservedTabs()` (`:44-46`). It is no longer self-armed by a non-empty save at `:63` and `:85`. Every save path guards on it. This is the write-side half of B3. +- `saveOrClearAggregatedSync`'s clear branch is deleted; the save branch stays and is called per workspace by Step 5. Consent lives only in `closeTabsByUser` (`+TabClosing.swift:16-24`). + +**Deleted** +- `windowGroupIndex` on `QueryTab` and `PersistedTab` (extra keys in old JSON decode away harmlessly; nothing needs a version bump) +- `tabGroupPosition`, the `groupOrder` dictionary, `isFirstCoordinatorForConnection` (`:444-449, 451-455`), `WindowTabGroupOrder` +- `TabPersistenceCoordinatorTests.windowGroupIndexRoundTrip` (383-399); `PersistedTabRoundTripTests` 172-198 + +**Proven by unit tests** +- `TabPersistenceWriteGateTests.saveIsRefusedBeforeRestoreCompletes` (B3) +- `TabPersistenceCoordinatorTests` rewritten to the single-list signature +- `TabPersistenceClearGuardTests` unchanged and must stay green + +**Needs the app run:** no. + +--- + +### STEP 3: `WorkspaceLocator`, the one resolver + +**New file:** `TablePro/Core/Services/Infrastructure/WorkspaceLocator.swift` + +``` +@MainActor internal enum WorkspaceLocator { + static func host(for connectionId: UUID) -> MainSplitViewController? + static func workspace(for connectionId: UUID) -> ConnectionWorkspace? + static func coordinator(for connectionId: UUID) -> MainContentCoordinator? + static func selectedCoordinator(in window: NSWindow) -> MainContentCoordinator? + @discardableResult static func reveal(_ connectionId: UUID, tabId: UUID? = nil) -> Bool +} +``` + +`reveal` is the whole point: select the workspace in its host registry, call `applySelectedWorkspace()`, set `tabManager.selectedTabId` when a tab is named, then `makeKeyAndOrderFront` + `NSApp.activate`. It returns false when no host owns the connection, which is what MCP and `LaunchIntentRouter` need to branch on. + +Add `MainSplitViewController.selectedCoordinator` (`workspaces.selected?.sessionState?.coordinator`) next to the existing `commandActions` (`:519`). + +**Purely additive.** Nothing is rewired yet. + +**Proven by unit tests:** `WorkspaceLocatorTests` against a `ConnectionWorkspaceRegistry` fixture: selection ordering, missing connection, tab id ignored when absent. + +**Needs the app run:** no. + +--- + +### STEP 4: Every window-keyed resolver routes through the locator + +**Files touched:** `TabWindowController.swift:13, 22, 34, 141, 164, 191, 216`; `MainMenuBuilder.swift:65-67`; `WorkspaceRailViewController.swift:319, 375`; `MainContentCoordinator+Favorites.swift:43`; `TabRouter.swift:97-110, 228-243, 383-390`; `+ERDiagram.swift:19`, `+UsersRoles.swift:6`, `+ServerDashboard.swift:15`; `+WindowLifecycle.swift:104-109`; `LaunchIntentRouter.swift:99-106`; `WelcomeViewModel+Sample.swift:128-133`; `MainEditorContentView.swift:376`; `MainSplitViewController+Connection.swift:32-35`. + +**Changes:** B9, B11, B12, B13, B15, B16, B20, B23, B24, B27 all land here. `retryConnection()` gains a `connectionId` parameter. `selectTabAndFocusWindow` becomes `reveal(connectionId, tabId:)`. + +**Deleted** +- `MainContentCoordinator.coordinator(forWindow:)` (`+Registry.swift:18-20`) +- `MainContentCoordinator.coordinator(for windowId:)` (`+Registry.swift:14-16`, zero callers even today) + +**Proven by unit tests:** `WindowCommandRoutingTests.selectedWorkspaceOwnsWindowCommands` (two workspaces in one registry, assert the resolved coordinator follows `select()`). + +**Needs the app run:** yes, for B24 and B23 (tab focus is visual). Manual pass: two connections open, Cmd+W closes a tab in the visible one; connection switcher on a background connection switches the pane; Show ER Diagram twice selects the existing tab. + +--- + +### STEP 5: Window close fans out over workspaces + +**Files touched:** `TabWindowController.swift:191, 198-210`; new `MainSplitViewController.handleWindowWillClose()`; `MainContentCoordinator+WindowLifecycle.swift:68-88`; `WindowLifecycleMonitor.swift:289-325`. + +**Changes** +- `MainSplitViewController.handleWindowWillClose()` iterates `workspaces.workspaces` and per workspace: cancel any in-flight attempt (`invalidateConnectionAttempt` + `cancelEnsureConnected`), `coordinator.handleWindowWillClose()` (save + teardown), then `disconnectSession` when no other host holds the connection. +- `TabWindowController.windowWillClose` calls `markWindowClosing()` then `handleWindowWillClose()` and nothing else. `cancelPendingConnectionIfNeeded` is folded into the fan-out and its `payload.connectionId` scoping and racy `hasOpenWindow` guard are gone. +- `WindowLifecycleMonitor.handleWindowClose` stops disconnecting (its remaining bookkeeping dies in Step 13). + +**Fixes:** B4, B34, B35, B36. + +**Proven by unit tests:** `WindowCloseFanOutTests.everyWorkspaceIsPersistedAndTornDown`, `.everyPendingAttemptIsCancelled` (fake coordinators recording calls). + +**Needs the app run:** yes. Open three connections, edit a tab in each, close the window, relaunch, confirm all three restore. + +--- + +### STEP 6: Adoption dials, and background opens do not steal the pane + +**Files touched:** `MainSplitViewController.swift:133-186`; `MainSplitViewController+Connection.swift:15-30`; `WindowManager.swift:25-46`; `ConnectionWorkspaceRegistry.swift:47-63`; `AppLaunchCoordinator.swift:136-161`. + +**Changes:** `adoptWorkspace(payload:autoConnect:select:)` threads `select` into `insert(_:select:)` and ends with `startActivationConnectIfNeeded(for: workspace.connectionId)`. `openTab` resolves the host by membership first (B19) and only selects when `activate` is true (B28). `LastOpenConnections` gains a recorded selected connection so Reopen Last Session lands where the user left off. + +**Fixes:** B19, B22, B28. + +**Proven by unit tests:** `WorkspaceAdoptionTests.backgroundAdoptionDoesNotChangeSelection`; `WindowManagerHostResolutionTests.hostIsTheWindowThatAlreadyHostsTheConnection`. + +**Needs the app run:** yes. Reopen Last Session with four connections: all four dial, the last-selected one is showing. + +--- + +### STEP 7 (riskiest): the workspace owns its bootstrap, and the detail pane gets an identity + +Deliberately placed after Steps 1 to 6, because those steps installed the resolver, the fan-out and the persistence gate that make this safe to move. + +**Files touched:** `ConnectionWorkspace.swift`; `MainSplitViewController.swift:393-412 (adoptSession), 505-514, 587-620`; `MainContentView.swift:54-61, 341-352`; `MainContentView+Setup.swift:15-84`; new `TablePro/Core/Services/Infrastructure/WorkspaceBootstrap.swift`. + +**Changes** +- `ConnectionWorkspace` gains `private(set) var hasBootstrapped: Bool`. `bootstrapIfNeeded()` runs once per workspace: restore saved tabs from disk (always, whatever the payload intent), then apply the payload's own tab. That single rule kills the `.openContent`-never-restores divergence behind B3 and makes `.restoreOrDefault` a workspace concept rather than a payload the tab opener has to understand. +- `ConnectionWorkspace.open(_:)` intercepts `.restoreOrDefault` (bootstrap, do not add a tab) and forwards only `.openContent` / `.newEmptyTab` to `EditorTabOpener`. +- The bootstrap is triggered from `adoptSession(_:into:)` and from `viewDidLoad` for a pre-created session state, never from a SwiftUI `.task`. +- `initializeAndRestoreTabs` moves out of `MainContentView+Setup.swift` into `WorkspaceBootstrap`. `MainContentView` loses `hasInitialized` and the bare `.task`. +- `buildDetailView()` returns `MainContentView(...).id(currentSession.connection.id)`. + +**Fixes:** B3 (behavioural half), B5, B6. + +**Behaviour change to record in CHANGELOG:** opening a table on a connection that is not yet open now brings back that connection's saved tabs alongside it, instead of starting from one tab. + +**Proven by unit tests:** `WorkspaceBootstrapTests.bootstrapRunsOncePerWorkspace`, `.openContentPayloadStillRestoresSavedTabs`, `.secondBootstrapDoesNotOverwriteLiveTabs`. + +**Needs the app run:** yes, and this is the step to test hardest. Switch back and forth between two connections ten times with unsaved query text in each; confirm no tab list is replaced and no edit is attributed to the wrong connection. + +--- + +### STEP 8: Visibility replaces key-window + +**Files touched:** `MainContentCoordinator.swift` (`isKeyWindow` property); `+WindowLifecycle.swift:25-62, 188-194`; `MainContentCommandActions.swift:121, 184, 1157`; `MainSplitViewController.swift:430-439`. + +**Changes:** rename `MainContentCoordinator.isKeyWindow` to `isVisible`, defined as *the hosting window is key **and** this workspace is the registry's selected one*. `applySelectedWorkspace` sets it on the incoming workspace and clears it on the outgoing one. `consumeDeferredRestoreLoadIfNeeded` (B29), the 5s row eviction, and `observeKeyWindowOnly` (B18) all read it. + +**Proven by unit tests:** `WorkspaceVisibilityTests.deferredRestoreLoadFiresOnSelection`; `WorkspaceVisibilityTests.onlyTheSelectedWorkspaceObservesKeyWindowBroadcasts`. + +**Needs the app run:** yes for the eviction timing and Open SQL File. + +--- + +### STEP 9: Per-workspace undo + +**Files touched:** `MainContentCoordinator.swift:520`; `MainContentCommandActions.swift:1050, 1062`; `+UndoState.swift:14`; `MainSplitViewController.swift:133-186` (wire `workspace.undoManager` onto the coordinator when the session state is attached). + +**Fixes:** B7. **Proven by:** `WorkspaceUndoTests.undoTargetsTheWorkspaceThatRegisteredIt`. **Needs the app run:** yes, for the Edit menu titles. + +--- + +### STEP 10: Toolbar and window chrome follow the selected workspace + +**Files touched:** `MainSplitViewController.swift:98-103, 336-347, 430-439, 934-946`; `MainWindowToolbar+Delegate.swift:19-141`; `MainWindowToolbar+Validation.swift:59`. + +**Changes:** introduce a per-window `ToolbarContext` observable box; every SwiftUI toolbar item view reads `context.coordinator` instead of capturing a coordinator by value. `installToolbar` swaps the box. `applySelectedWorkspace` additionally pushes `representedURL`, `isDocumentEdited`, `updateDetailMinimumThickness(for:)` and, one time only, sets `splitView.autosaveName = "com.TablePro.mainSplit"`. + +**Fixes:** B14, B31, B32. Do **not** tear down and reinstall `NSToolbar` on every switch: that flickers and drops item state. + +**Proven by:** unit test on `ToolbarContext` swap semantics only. The visible behaviour needs the app run: switch connections and watch the toolbar name, database, status badge and spinner follow. + +--- + +### STEP 11: The rail reads its host + +**Files touched:** `WorkspaceRailViewController.swift:28, 181, 188, 256-305, 353, 375`; `WorkspaceRailStore.swift:37-38, 120-130, 140, 145`; `MainSplitViewController.swift:201, 236, 296, 304, 724`; `NavigationSidebarViewController.swift:27`. + +**Changes:** drop the frozen `connectionId`; entries and the selected row come from the hosting registry; `onMembershipChange` is wired and drives both the rail reload and `applyRailVisibility(workspaceCount: workspaces.count)`. + +**Fixes:** B17, B30, and the app-global entry count. **Proven by:** `WorkspaceRailStoreTests` extended with a per-host fixture. **Needs the app run:** yes, for the highlight snap-back. + +--- + +### STEP 12: Reopen Closed Tab adopts directly + +**Files touched:** `RecentlyClosedTabReopener.swift:20-83`; `TabRouter.swift:86`. + +**Changes:** one path for every case: reconstruct the `QueryTab`, adopt it into the target connection's `QueryTabManager` (creating the workspace through `LaunchIntentRouter` only when the connection is not open at all), then `WorkspaceLocator.reveal(connectionId, tabId:)`. + +**Deleted:** `openWindowTab`, `emptyWindowCoordinator`, `TablePro/Core/Services/Infrastructure/RestorationGroupRegistry.swift`, `MultiWindowRestorationTests.swift:19-51`. + +**Fixes:** B21. **Proven by:** `RecentlyClosedTabReopenerTests.reopenLandsInAConnectionThatAlreadyHasTabs`. **Needs the app run:** yes, once. + +--- + +### STEP 13: Delete `WindowLifecycleMonitor` + +Every reader has been migrated by now. Source-file dedupe becomes a tab lookup, which is the correct key and needs no separate store: the tabs already carry `content.sourceFileURL`. + +**Files touched:** `TabRouter.swift:354, 384`; `MainContentCoordinator+Favorites.swift:42-51, 90-93`; `MainContentCommandActions.swift:525`; `WorkspaceRailStore.swift:38`; `OperationConfirming.swift:17`; `MainContentCoordinator+Registry.swift:66-79`; `MainContentView+Setup.swift:71-73, 344-358`; `MCPTabSnapshotProvider.swift:39`. + +**New:** `OpenTabLocator.locate(sourceFileURL:) -> (connectionId: UUID, tabId: UUID)?`, scanning hosts' workspaces. + +**Deleted:** `TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift` (whole file), `MainContentCoordinator.windowId` (`:109`), `MainEditorContentView.windowId` (`:30`), `MainContentView.@State windowId` (`:58`), `WindowLifecycleMonitorTests.swift`, `WindowLifecycleMonitorRegistrationTests.swift`, `SQLFileDeduplicationTests.swift:196-265`. + +**Fixes:** B26, B37. **Proven by:** `OpenTabLocatorTests.findsTheTabHoldingASourceFile`, `.returnsNilWhenNoWorkspaceHoldsIt`. **Needs the app run:** yes. Open the same `.sql` file from Finder twice. + +--- + +### STEP 14: Delete the window-close command family + +**Files touched:** `MainContentCommandActions.swift:406-431, 436-661`; `MainContentCommandActions+BulkClose.swift:79-85`; `MainContentCoordinator.swift:198-200`; `+FKNavigation.swift:83`. + +**Deleted:** `closeWindowAwaiting(asBatchSurvivor:)`, `finish`, `clearTabsInPlace`, `saveAndClose`, `discardAndClose`, `selectInTabGroup`, `captureClosingTabsForRecovery`, `WindowCloseOutcome`, the `asBatchSurvivor` parameter, `CommandActionsBulkCloseTests.survivorClearsTabsInPlace` (56-70). + +`closeTab()`'s no-coordinator branch becomes `WindowManager.shared.closeWindow(for: connectionId)` (B10). `openTabInNewWindow` is renamed `openTab` (and its `FKNavigationTests` stub with it, in the same commit per the atomic-API-change rule). + +**Proven by:** `CommandActionsCloseTests.closingTheLastTabLeavesSiblingWorkspacesAlone`. **Needs the app run:** yes for Cmd+W on a failed pane. + +--- + +### STEP 15: MCP wire contract + +**Files touched:** `OpenConnectionWindowTool.swift`, `OpenTableTabTool.swift`, `FocusQueryTabTool.swift`, `ListRecentTabsTool.swift`, `MCPTabSnapshotProvider.swift`. + +Rename `open_connection_window` to `open_connection`; every tool returns `connection_id` and `tab_id`; `window_id` is dropped from results and descriptions. Update `docs/` and add a CHANGELOG entry under `Changed` (it is a breaking wire change for agent integrations). + +**Proven by:** existing MCP tool tests updated. **Needs the app run:** no, but a live MCP smoke test is cheap. + +--- + +### STEP 16: Vocabulary, invariants, docs + +Rename `ConnectionWindowPhase` → `ConnectionWorkspacePhase`, `ConnectionWindowPhaseMachine.onWindowClosing` → `onWorkspaceClosing`, `ConnectionWindowPaneResolver` → `ConnectionWorkspacePaneResolver`, `WindowSidebarState` → `WorkspaceSidebarState`, `forceNewWindowTab` → `forceNewTab`, `TableLoadTracer.noteWindowTabHandoff` → `noteTabHandoff`, the `handoffToNewWindowTab` log string. Cosmetic, mechanical, one commit, no behaviour change. Then the CLAUDE.md edits in section 5, the `docs/` updates, and the CHANGELOG. + +--- + +## 4. DELETIONS + +Each is dead after the step named; the proof is the last remaining caller and where it goes. + +| Type / file | Dead after | Proof | +|---|---|---| +| `WindowGroupAssignment.swift` | Step 1 | Sole call site `MainContentView+Setup.swift:132`; `normalizedGroupIndices`' only consumer is `TabPersistenceCoordinator.swift:181`, deleted in the same step | +| `RestoreWindowPlan.swift` | Step 1 | Sole call site `MainContentView+Setup.swift:150` | +| `WindowTabGroupOrder.swift` | Step 2 | Two call sites: `MainContentView+Setup.swift:105-106` (Step 1) and `MainContentCoordinator.swift:330-334, 346-348` (Step 2) | +| `RestoreResult.windowGroupIndexByTabId` | Step 1 | Read only at `MainContentView+Setup.swift:136` | +| `QueryTab.windowGroupIndex` / `PersistedTab.windowGroupIndex` | Step 2 | Written at `MainContentCoordinator.swift:319-348`, read only through `windowGroupIndexByTabId`, already gone. `decodeIfPresent`, so old files decode; unknown keys in new files are ignored | +| `saveNow(windowedTabs:)` / `saveNowSync(windowedTabs:)` | Step 2 | The tuple overloads' only remaining caller is the aggregated save, converted in the same step | +| `isFirstCoordinatorForConnection`, `tabGroupPosition` | Step 2 | Gate `scheduleDraftSave` (`:427`), `startPeriodicSave` (`:438`) and the willTerminate save (`:542`); trivially true with one coordinator per connection | +| `saveOrClearAggregatedSync`'s clear branch | Step 2 | Real consent is `closeTabsByUser` (`+TabClosing.swift:22-23`) | +| `MainContentCoordinator.coordinator(forWindow:)` | Step 4 | Nine call sites, all listed in B12/B15/B16/B20, all moved to `WorkspaceLocator` | +| `MainContentCoordinator.coordinator(for windowId:)` | Step 4 | Zero callers today (`+Registry.swift:14-16`) | +| `MainSplitViewController.transition(to:)` (the unqualified one, `:463-468`) | Step 4 | Superseded by `transition(to:for:)`; every caller names a connection | +| `RestorationGroupRegistry.swift` | Step 12 | Producers `MainContentView+Setup.swift:301` (deleted Step 1) and `RecentlyClosedTabReopener.swift:77` (deleted Step 12); consumer `MainContentView+Setup.swift:87` (deleted Step 7) | +| `EditorTabPayload` `Codable`, `CodingKeys`, legacy `isNewTab` decoding (`:23, 57-64, 102-145`) | Step 12 | No production encode or decode; only `TableProTests/Models/EditorTabPayloadTests.swift:74, 100`. The payload is passed by reference through `TabWindowController` / `MainSplitViewController` / `ConnectionWorkspace` | +| `WindowLifecycleMonitor.swift` (whole file) | Step 13 | Readers: `WorkspaceRailStore.swift:38` (Step 11), `TabRouter.swift:97, 354, 384` (Steps 4, 13), `WorkspaceRailViewController.swift:271, 375` (Steps 4, 11), `OperationConfirming.swift:17` (Step 13), `MainContentCoordinator+Registry.swift:77` (Step 13), `WelcomeViewModel+Sample.swift:130` (Step 4), `MainContentView+Setup.swift:71-73, 354` (Steps 7, 13). Its `lastFocusedWindowIds` / `resolveWindowId` / `mostRecentWindow` / `activeWindow(for:preferring:)` / `unregisterWindow(for:)` were already dead or single-caller | +| `MainContentCoordinator.windowId`, `MainEditorContentView.windowId`, `MainContentView.@State windowId` | Step 13 | Readers listed in the map: `coordinator(for windowId:)` (none), `registerWindowForSourceFile`, `selectTabAndFocusWindow`, `TabRouter.focusExistingQueryTab`, `MCPTabSnapshotProvider` | +| `closeWindowAwaiting`, `finish`, `clearTabsInPlace`, `saveAndClose`, `discardAndClose`, `selectInTabGroup`, `captureClosingTabsForRecovery`, `WindowCloseOutcome`, `asBatchSurvivor` | Step 14 | Single caller chain rooted at `MainContentCommandActions.swift:423`, plus one test | +| `WindowManager.findSibling` + the `addTabbedWindow` branch (`:93-115, 207-214`) | Step 6 | `openInNewWindow` only runs when no host exists; `findSibling` searches for exactly such a host. `mainTabbingIdentifier` (`:205`) **stays**: it is what makes user-driven Merge All Windows work | +| Test files | per step | `WindowGroupAssignmentTests`, `RestoreWindowPlanTests`, `WindowTabGroupOrderTests`, `MultiWindowRestorationTests:19-51`, `WorkspaceWindowScopeTests` (untracked, both cases unsatisfiable), `WindowLifecycleMonitorTests`, `WindowLifecycleMonitorRegistrationTests:47-66`, `CommandActionsBulkCloseTests:56-70`, `SQLFileDeduplicationTests:196-265` | + +`MultiWindowRestorationTests.swift` is split before deletion: `resolveRestoredSortColumns` (53-76) moves to `RestoredSortColumnTests.swift`, the `LastOpenConnectionsStorage` round-trips (78-108) to `LastOpenConnectionsStorageTests.swift`. + +--- + +## 5. CLAUDE.md INVARIANT EDITS + +### 5.1 Tab replacement guard (line 176) + +**Old:** "`openTableTab` checks for active work (unsaved edits, applied filters, sorting) before replacing the current tab. Tabs with active work open a new native window tab instead. This check runs before the preview tab branch." + +**New:** "`openTableTab` checks for active work (unsaved edits, applied filters, sorting) before replacing the current tab. A tab with active work is left alone and the table opens as a new editor tab in the same window's strip. This check runs before the preview tab branch." + +### 5.2 Window tab titles (line 178) + +Keep the whole paragraph, which is still correct about the app window's titlebar, and append: + +**New paragraph:** "Editor tabs are not windows, so there are two labels with two owners. The window titlebar goes through `WindowTitleResolver` and the guarded `windowTitle` sink. The editor tab label is `Text(tab.title)` in `EditorTabStrip`, with no resolver between it and the string, so the blank-title healing and the `.table` name recomputation must be applied at `QueryTab.title` itself. `PersistedTab`'s decoder defaults a missing or null title but not an empty string. Mutating `tab.title` plus `QueryTabManager.markTabRenamed(_:)` still drives both labels." + +### 5.3 Cancelling a connect does not stop the driver (line 186) + +The driver half is untouched. Replace the second half's scope words: + +**Old:** "...the attempt is fenced by a per-window `attemptToken` plus `DatabaseManager.invalidateConnectionAttempt` so a late failure cannot write into a window that moved on." + +**New:** "...the attempt is fenced by a per-workspace `attemptToken` (`ConnectionWorkspace.attemptToken`) plus `DatabaseManager.invalidateConnectionAttempt`, so a late failure cannot write into a workspace that moved on. The window did not move on; one of the connections it hosts did, which is why the token cannot live on the window." + +**Old:** "...that distinction is `ConnectionWindowPhaseMachine.retainsRestoreIntent`..." + +**New:** "...that distinction is `ConnectionWorkspacePhaseMachine.retainsRestoreIntent`, read per workspace through `ConnectionWorkspace.retainsRestoreIntent` and aggregated per window by `MainSplitViewController.connectionIdsRetainingRestoreIntent`. Closing a window has to cancel the in-flight attempt of **every** workspace it hosts, not just the one its original payload named." + +### 5.4 A connection window's content is a function of its own `ConnectionWindowPhase` (line 188) + +**Old title and first sentence:** "**A connection window's content is a function of its own `ConnectionWindowPhase`, never of `activeSessions` membership**..." + +**New:** "**A workspace's content is a function of its own `ConnectionWorkspacePhase`, never of `activeSessions` membership**: the global session dictionary can only say *present* or *absent*, and that vocabulary cannot tell "never started" from "connecting" from "failed" from "the user cancelled" from "the window is closing"." + +**Old:** "`MainSplitViewController` owns a `phase`, `ConnectionWindowPhaseMachine` owns the transitions..." + +**New:** "`ConnectionWorkspace` owns the `phase`, one per hosted connection. `ConnectionWorkspacePhaseMachine` owns the transitions (pure, exhaustive, `.closing` absorbing) and `ConnectionWorkspacePaneResolver` owns the pane choice (pure). `MainSplitViewController` renders the selected workspace's phase and routes a transition by `connectionId`; it is only an adapter and its `phase` property is a pass-through to `workspaces.selected`." + +**Old (last sentence):** "Only one presenter per failure: `LaunchIntentRouter.presentError` stays silent when a window for that connection exists." + +**New:** "Only one presenter per failure: `LaunchIntentRouter.presentError` stays silent only when that connection is the **selected** workspace, because the inline pane is painted for the selected workspace alone. A window existing says nothing about whether this connection has a pane on screen." + +### 5.5 An emptied tab manager is not the same as "the user closed every tab" (line 192) + +**Old:** "`saveOrClearAggregatedSync()` is the one persistence path where an empty aggregate means *clear*, so it deletes the connection's saved tabs from disk. A coordinator torn down by a lost session has already emptied `tabManager.tabs`, so letting the window-close path run afterwards wipes tabs the user never closed. `handleWindowWillClose` guards on `isTearingDown` for that reason." + +**New:** "Deleting a connection's saved tabs is a statement about user intent, so exactly one path may make it: `MainContentCoordinator.closeTabsByUser`, which clears the moment the connection's own tab list empties through a user-driven close (tab strip X, Cmd+W, Close Other/All Tabs, Close Tabs for Other Databases, close workspace from the rail). Closing a tab never closes the window and closing the window never clears: window close **saves** every workspace it hosts, one save per `ConnectionWorkspace`, and its persistence guards stay in place as the second line of defence. A coordinator torn down by a lost session has already emptied `tabManager.tabs`, which is why `hasObservedTabs` is set only by a completed restore and every save path refuses to write before then, and why `handleWindowWillClose` guards on `isTearingDown`. Both halves of this rule have shipped as bugs: letting window close clear wiped tabs the user never closed, and later, when tab close stopped closing the window, nothing reached the clear at all and closed tabs came back on reconnect." + +### 5.6 Window Close (Cmd+W) section (line 212) + +**Old:** "`EditorWindow` (NSWindow subclass in `TabWindowController.swift`) overrides `performClose:` to route Cmd+W through `closeTab()`. SwiftUI's `.commands { ... }` does NOT replace AppKit's built-in "File > Close"..." + +**New:** "`EditorWindow` (NSWindow subclass in `TabWindowController.swift`) overrides `performClose:` to route Cmd+W through the **selected workspace's** `closeTab()`, resolved as `(window.contentViewController as? MainSplitViewController)?.workspaces.selected?.sessionState?.coordinator`, never by matching `contentWindow` (every hosted coordinator matches the same window). Cmd+W closes the front editor tab; with no tabs left it closes the workspace; the window itself closes only when the last workspace goes. SwiftUI's `.commands { ... }` does NOT replace AppKit's built-in "File > Close"..." + +### 5.7 Two new invariants to add + +**Add after 5.4:** + +"**The workspace, not the window, is the unit of identity**: one window hosts N connections, so any lookup keyed by `NSWindow` or by a per-view `windowId` can only ever name one of them, and `Dictionary.values.first` over a matching predicate picks arbitrarily. Every resolution goes through `WorkspaceLocator` (`connectionId` in, workspace / coordinator / host out) and every "show this to the user" goes through `WorkspaceLocator.reveal(connectionId:tabId:)`, which selects the workspace, selects the tab and *then* raises the window. `makeKeyAndOrderFront` on its own is not focus any more: the window is usually already front and showing a different connection, so the command reads as doing nothing. This shipped as Cmd+W closing a background connection's tab, the connection switcher appearing dead, and MCP `focus_query_tab` reporting success while focusing nothing." + +"**A window-level event is N workspace events**: `windowWillClose` must save, tear down, cancel the in-flight connect for, and disconnect **every** workspace in `MainSplitViewController.workspaces`, not the one an ambiguous lookup returned. `markWindowClosing` already does this correctly and is the reference shape. Dispatching a window callback to a single coordinator loses the other connections' tab edits since the last periodic save (up to 30s), leaks their coordinators in the strong static `activeCoordinators` map so they never deinit, and leaves their drivers, tunnels and health monitors running until quit." + +--- + +## 6. TEST PLAN + +### Unit tests, one per already-found bug + +| Test | Suite (new or existing) | Guards | +|---|---|---| +| `fileWithGroupIndicesRestoresEveryTabInFileOrder` | `TabRestoreMigrationTests` | B1 | +| `fileWithoutGroupIndicesRestoresEveryTab` | `TabRestoreMigrationTests` | B2 | +| `saveIsRefusedBeforeRestoreCompletes` | `TabPersistenceWriteGateTests` | B3 | +| `overflowSidecarsSurviveAPartialSaveAttempt` | `TabPersistenceWriteGateTests` | B3 | +| `everyWorkspaceIsPersistedAndTornDown` | `WindowCloseFanOutTests` | B4, B35 | +| `everyPendingAttemptIsCancelled` | `WindowCloseFanOutTests` | B36 | +| `everyHostedSessionIsDisconnected` | `WindowCloseFanOutTests` | B34 | +| `bootstrapRunsOncePerWorkspace` | `WorkspaceBootstrapTests` | B5 | +| `secondBootstrapDoesNotOverwriteLiveTabs` | `WorkspaceBootstrapTests` | B5 | +| `openContentPayloadStillRestoresSavedTabs` | `WorkspaceBootstrapTests` | B3 | +| `undoTargetsTheWorkspaceThatRegisteredIt` | `WorkspaceUndoTests` | B7 | +| `unsavedWorkIsCheckedAcrossEveryWorkspace` | `WindowCloseFanOutTests` | B8 | +| `selectedWorkspaceOwnsWindowCommands` | `WindowCommandRoutingTests` | B12 | +| `retryTargetsTheRequestedConnection` | `WorkspaceConnectRoutingTests` | B13 | +| `adoptionStartsTheConnectForItsOwnWorkspace` | `WorkspaceAdoptionTests` | B22 | +| `backgroundAdoptionDoesNotChangeSelection` | `WorkspaceAdoptionTests` | B28 | +| `hostIsTheWindowThatAlreadyHostsTheConnection` | `WindowManagerHostResolutionTests` | B19 | +| `revealSelectsWorkspaceThenTabThenRaises` | `WorkspaceLocatorTests` | B23, B24, B25 | +| `revealReturnsFalseWhenNoHostOwnsTheConnection` | `WorkspaceLocatorTests` | B25, B27 | +| `reopenLandsInAConnectionThatAlreadyHasTabs` | `RecentlyClosedTabReopenerTests` | B21 | +| `deferredRestoreLoadFiresOnSelection` | `WorkspaceVisibilityTests` | B29 | +| `onlyTheSelectedWorkspaceObservesKeyWindowBroadcasts` | `WorkspaceVisibilityTests` | B18 | +| `findsTheTabHoldingASourceFile` | `OpenTabLocatorTests` | B26 | +| `membershipChangeReloadsTheRail` | `WorkspaceRailStoreTests` | B30 | +| `railSelectionFollowsTheHostRegistry` | `WorkspaceRailStoreTests` | B17 | +| `closingTheLastTabLeavesSiblingWorkspacesAlone` | `CommandActionsCloseTests` | B10, and bug #1 from the brief, which has never had a test | +| `closingEveryTabByUserClearsSavedState` | `CommandActionsCloseTests` | bug #3 from the brief: close through `closeTabsByUser`, then assert `restoreFromDisk()` returns `.none` | +| `payloadForAnOpenConnectionOpensItsTab` | `EditorTabOpenerTests` (exists as `tableOpensIntoPopulatedList`) | bug #2 from the brief, already covered, keep | +| `blankTitleRendersANonEmptyStripLabel` | `EditorTabStripLayoutTests` | B33 | + +### Tests to rewrite rather than delete + +- `WindowGroupAssignmentTests.legacyFileKeepsOneTabPerWindow` (161-170) becomes `TabRestoreMigrationTests.fileWithoutGroupIndicesRestoresEveryTab`, asserting one list. +- `CommandActionsBulkCloseTests` 74-122: one coordinator holding tabs in two databases, not two coordinators. `canCloseTabsForOtherDatabasesWhenSiblingIsForeign` is currently red and must go. +- `WindowLifecycleMonitorTests`: delete with the type; the pure `resolveWindowId` cases (345-388) have no home left. +- `RecoveryConnectionListTests` 60-94, `TabScopeIsWindowIndependentTests` 5-8, `WorkspaceRailStoreTests` wording: rename tests and doc comments, leave every assertion. `TabScopeIsWindowIndependentTests`' property matters more now, not less: a tab's scope being a pure function of the tab and its connection is what lets one strip hold tabs across several databases. + +### Tests to keep untouched + +`WindowTabGroupingTests`, `ConnectionWindowIdentityTests`, `WindowOpenerTests`, `ConnectionWindowPhaseMachineTests`, `ConnectionWindowPaneResolverTests`, `SessionStateFactoryTests`, `ConnectionWorkspaceRegistryTests`, `EditorTabOpenerTests`, `QueryTabManagerCloseTests`, `EditorTabStripLayoutTests`, `TabPersistenceClearGuardTests`, `QueryTabManagerAdoptTabTests`. + +### UI automation (`TableProUITests`) + +Four flows that unit tests cannot reach, all deterministic against two SQLite connections: + +1. `testSwitchingWorkspacesKeepsEachTabList`: two connections with distinct tabs, switch five times, assert both strips are intact and the toolbar name follows. +2. `testCmdWClosesTheVisibleConnectionsTab`: two connections, Cmd+W, assert the background strip is unchanged. +3. `testWindowCloseRestoresEveryConnectionsTabs`: three connections with tabs, close, relaunch, assert all three restore. +4. `testReopenClosedTabWithOtherTabsOpen`: close one of three tabs, Cmd+Shift+T, assert it returns and is selected. + +Toolbar repointing (B14), undo menu titles (B7) and the rail highlight (B17) are visual and are covered by the manual checks in their steps; they are not deterministic enough for automation, and that should be said in the PR description per the mandatory-tests rule. + +--- + +## 7. WHAT NOT TO DO + +Traps this specific refactor will walk into. Most are already CLAUDE.md invariants; the rest are landmines this branch created. + +1. **Do not delete `enrichedForPersistence`** (`MainContentCoordinator.swift:352-371`) while simplifying `aggregatedTabs`. It looks like part of the aggregation, and it is the only place restored sort column names and the selected query tab's caret offset and length are resolved. Deleting it silently strips sort columns and the caret from every restored tab. + +2. **Do not delete `normalizedGroupIndices` without deleting its read in the same commit.** Its no-index branch turns tab *i* into group *i*, so leaving it while removing the fan-out, or removing the field while leaving it, both keep one tab and drop the rest. Removal is atomic: field, function, `windowGroupIndexByTabId`, `resolve`, fan-out, all in Step 1 and 2. + +3. **Do not add a "clear saved tabs" call anywhere new.** Empty is not consent. The automatic paths already refuse an empty aggregate (`TabPersistenceCoordinator.swift:58-62, 80-84`; `+AggregatedSave.swift:15-16, 27-28`) and that is correct. Exactly one path may clear: `closeTabsByUser`. + +4. **Do not make a refresh clear the cache it is refreshing.** Bootstrap-on-select must fetch first and commit over the old value. Writing `tabManager.tabs = restored` before checking whether the workspace already has live tabs is the same shape as the `SchemaService.runLoad` bug (#1916). + +5. **Do not reintroduce a SwiftUI `App`.** The app runs the AppKit lifecycle; `MainMenuBuilder.install` runs in `applicationWillFinishLaunching`. Any attempt to express the workspace switch through a SwiftUI scene wipes `NSApp.mainMenu` half a second after launch, which is #2057 and had to be reverted as #2071. + +6. **Do not give any split pane a `holdingPriority` at or above 490**, and do not drop `sizingOptions = []` from `detailHosting` / `inspectorHosting` while reworking `rebuildPanes`. Both dead-divider bugs (#1872) live exactly in the code this refactor edits. + +7. **Do not swap `ResizeCursorSplitViewController` back to a plain `NSSplitViewController`** while touching the pane construction. The stock cursor does not fire under an `NSHostingController` (#1905). + +8. **Do not write `window.title` or `NSApp.keyWindow?.title` directly** when making the title follow the workspace. The `windowTitle` `didSet` (`MainSplitViewController.swift:66-72`) is the single guarded sink and it is the only thing keeping a blank restored title off the titlebar. + +9. **Do not version the split autosave key.** `com.TablePro.mainSplit` already exists as the fallback. Moving to it is the correct semantic (one window cannot have per-connection widths); appending a suffix to force a relayout throws away every user's sidebar and inspector geometry and orphans keys in `UserDefaults`. + +10. **Do not assume `Task.cancel()` stopped a connect.** Every workspace that adopts a driver still validates its `ConnectionAttemptRegistry` generation, and a cancelled connect still drops the connection from `LastOpenConnections` while a merely *failed* one keeps its place. Collapsing `retainsRestoreIntent` and `isActivated` back into one flag makes one launch against a stopped server erase the session permanently. This area has shipped the same bug four times. + +11. **Do not switch `MainContentView` to `ForEach($bindable.array)` anywhere** while restructuring the strip or the tab list. Index-based bindings crash out of bounds when the array shrinks during SwiftUI evaluation. + +12. **Do not remove a published `TableProPluginKit` requirement** if the vocabulary rename brushes against it. Nothing in this refactor should touch `Plugins/TableProPluginKit/`, and if a rename tempts you across that line, run `scripts/check-pluginkit-abi.sh` first. + +13. **Do not leave a rename split across commits.** `retryConnection(for:)`, `openTabInNewWindow` → `openTab`, `isKeyWindow` → `isVisible`, `ConnectionWindowPhase` → `ConnectionWorkspacePhase`: each rename updates every caller and every test in the same commit, or `git bisect` gets a broken commit. + +14. **Do not add explanatory comments** while moving `initializeAndRestoreTabs` out of the view. The codebase has none; the doc comments that exist on these types are contract statements, and the stale ones (`SessionTabStatePersister`'s "saveAggregatedSync collects the tabs of every window", `RecentlyClosedTabReopener`'s "Brings a closed tab back into a native window tab", `WindowLifecycleMonitor`'s supersede rationale) must be rewritten or deleted with the code, not left behind to mislead the next reader. + +15. **Do not write `window_id` into any new MCP result** to preserve compatibility. There are two incompatible `window_id` spaces in the tools today (`payload.id` and `coordinator.windowId`), neither names a window, and nothing consumes either. Break it cleanly in Step 15 with a CHANGELOG entry. + +16. **Do not use em dashes, and do not reach for "seamless", "robust" or "comprehensive"** in the CHANGELOG entries, the CLAUDE.md rewrites or the PR body. Run the pre-commit grep from CLAUDE.md before every commit in this series. \ No newline at end of file From 6c347cf7a69b1327dc8ac93cf45a535b8f90f457 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 13 Aug 2026 17:14:42 +0700 Subject: [PATCH 13/47] refactor(tabs): give a connection one tab list on the write side too --- ...ersistenceCoordinator+AggregatedSave.swift | 45 +++------- .../TabPersistenceCoordinator.swift | 85 ++++++++++--------- ...inContentCoordinator+WindowLifecycle.swift | 5 +- .../MainContentView+EventHandlers.swift | 2 +- .../Views/Main/MainContentCoordinator.swift | 44 ++-------- .../TabPersistenceClearGuardTests.swift | 46 ---------- .../TabPersistenceCoordinatorTests.swift | 23 ++--- .../TabPersistenceWriteGateTests.swift | 74 ++++++++++++++++ 8 files changed, 148 insertions(+), 176 deletions(-) delete mode 100644 TableProTests/Core/Services/TabPersistenceClearGuardTests.swift create mode 100644 TableProTests/Core/Services/TabPersistenceWriteGateTests.swift diff --git a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator+AggregatedSave.swift b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator+AggregatedSave.swift index 08e006737..f81ba60b2 100644 --- a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator+AggregatedSave.swift +++ b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator+AggregatedSave.swift @@ -7,45 +7,22 @@ import Foundation import os extension TabPersistenceCoordinator { - /// Save persisted state from the tabs aggregated across all windows for the connection. - /// Prevents the per-window close path from clobbering state when sibling windows still - /// have open tabs. An empty aggregate leaves the saved state alone; only the user closing - /// every tab discards it, through `saveOrClearAggregatedSync()`. + /// Saves the connection's tabs. An empty list leaves the saved state alone: only the user + /// closing every tab discards it, and that consent is expressed by `closeTabsByUser`. func saveAggregated() { - let aggregatedTabs = MainContentCoordinator.aggregatedTabs(for: connectionId) - guard !aggregatedTabs.isEmpty else { return } + let tabs = MainContentCoordinator.aggregatedTabs(for: connectionId) + guard !tabs.isEmpty else { return } let selectedId = MainContentCoordinator.aggregatedSelectedTabId(for: connectionId) - saveNow(windowedTabs: aggregatedTabs, selectedTabId: selectedId) + saveNow(tabs: tabs, selectedTabId: selectedId) } - /// The disconnect path: synchronous like the close path, because the session is about to go - /// away and every coordinator holding these tabs is torn down straight after, and - /// never-clearing like `saveAggregated()`, because the user asked to end a session, not to - /// close their tabs. `saveAggregated()` alone cannot serve this: it defers the write through - /// `scheduleSave`, which cancels the previous task, so a sibling window's save can drop it. + /// The disconnect and window-close paths, which are synchronous because the run loop may not + /// service a Task before everything holding these tabs is torn down. Ending a session is not + /// closing your tabs, so this never clears. func saveAggregatedSync() { - let aggregatedTabs = MainContentCoordinator.aggregatedTabs(for: connectionId) - guard !aggregatedTabs.isEmpty else { return } + let tabs = MainContentCoordinator.aggregatedTabs(for: connectionId) + guard !tabs.isEmpty else { return } let selectedId = MainContentCoordinator.aggregatedSelectedTabId(for: connectionId) - saveNowSync(windowedTabs: aggregatedTabs, selectedTabId: selectedId) - } - - /// Synchronous variant for the window-close path, where the run loop may - /// not be available to service Tasks before the window tears down. This is the one - /// path where an empty aggregate means the user closed everything, so it clears. - func saveOrClearAggregatedSync() { - let aggregatedTabs = MainContentCoordinator.aggregatedTabs(for: connectionId) - if aggregatedTabs.isEmpty { - guard hasObservedTabs else { - Self.logger.info( - "[persist] clear withheld, window never held a tab connId=\(self.connectionId, privacy: .public)" - ) - return - } - clearForUserClosedAllTabs() - } else { - let selectedId = MainContentCoordinator.aggregatedSelectedTabId(for: connectionId) - saveNowSync(windowedTabs: aggregatedTabs, selectedTabId: selectedId) - } + saveNowSync(tabs: tabs, selectedTabId: selectedId) } } diff --git a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift index 280e82f41..724802579 100644 --- a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift +++ b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift @@ -47,55 +47,60 @@ internal final class TabPersistenceCoordinator { // MARK: - Save + /// An automatic save never deletes, and never runs before a restore has told it what is on + /// disk. A workspace that has not bootstrapped yet, a connection still waiting on its driver, + /// and a window torn down during quit all present a tab list that says nothing about what the + /// user wants kept. Only `clearForUserClosedAllTabs()` removes state. internal func saveNow(tabs: [QueryTab], selectedTabId: UUID?) { - saveNow(windowedTabs: tabs.map { (tab: $0, windowGroupIndex: 0) }, selectedTabId: selectedTabId) - } - - /// An automatic save never deletes. A window that has not restored yet, a connection still - /// waiting on its driver, and a window torn down during quit all present an empty tab list - /// that says nothing about what the user wants kept, so treating it as a delete instruction - /// destroys drafts the user never closed. Only `clearForUserClosedAllTabs()` removes state. - internal func saveNow(windowedTabs: [(tab: QueryTab, windowGroupIndex: Int)], selectedTabId: UUID?) { - guard !windowedTabs.isEmpty else { - Self.logger.debug("[persist] saveNow skipped empty tab set connId=\(self.connectionId, privacy: .public)") + guard let payload = writablePayload(tabs: tabs, selectedTabId: selectedTabId, path: "saveNow") else { return } - hasObservedTabs = true - let persisted = windowedTabs.map { $0.tab.toPersistedTab(windowGroupIndex: $0.windowGroupIndex) } - let normalizedSelectedId = windowedTabs.contains(where: { $0.tab.id == selectedTabId }) - ? selectedTabId : windowedTabs.first?.tab.id - let active = currentActiveDatabaseAndSchema() scheduleSave( - tabs: persisted, - selectedTabId: normalizedSelectedId, - lastActiveDatabase: active.database, - lastActiveSchema: active.schema + tabs: payload.tabs, + selectedTabId: payload.selectedTabId, + lastActiveDatabase: payload.database, + lastActiveSchema: payload.schema ) } internal func saveNowSync(tabs: [QueryTab], selectedTabId: UUID?) { - saveNowSync(windowedTabs: tabs.map { (tab: $0, windowGroupIndex: 0) }, selectedTabId: selectedTabId) - } - - internal func saveNowSync(windowedTabs: [(tab: QueryTab, windowGroupIndex: Int)], selectedTabId: UUID?) { - guard !windowedTabs.isEmpty else { - Self.logger.debug("[persist] saveNowSync skipped empty tab set connId=\(self.connectionId, privacy: .public)") + guard let payload = writablePayload(tabs: tabs, selectedTabId: selectedTabId, path: "saveNowSync") else { return } - hasObservedTabs = true - let persisted = windowedTabs.map { $0.tab.toPersistedTab(windowGroupIndex: $0.windowGroupIndex) } - let normalizedSelectedId = windowedTabs.contains(where: { $0.tab.id == selectedTabId }) - ? selectedTabId : windowedTabs.first?.tab.id - let active = currentActiveDatabaseAndSchema() TabDiskActor.saveSync( connectionId: connectionId, - tabs: persisted, - selectedTabId: normalizedSelectedId, - lastActiveDatabase: active.database, - lastActiveSchema: active.schema + tabs: payload.tabs, + selectedTabId: payload.selectedTabId, + lastActiveDatabase: payload.database, + lastActiveSchema: payload.schema ) } + /// The one gate every save passes. Writing a partial list over a full saved set is how tabs + /// that were never restored get erased, so a save is refused until a restore has run. + private func writablePayload( + tabs: [QueryTab], + selectedTabId: UUID?, + path: String + ) -> (tabs: [PersistedTab], selectedTabId: UUID?, database: String?, schema: String?)? { + guard !tabs.isEmpty else { + Self.logger.debug( + "[persist] \(path, privacy: .public) skipped empty tab set connId=\(self.connectionId, privacy: .public)" + ) + return nil + } + guard hasObservedTabs else { + Self.logger.info( + "[persist] \(path, privacy: .public) withheld before restore connId=\(self.connectionId, privacy: .public)" + ) + return nil + } + let normalizedSelectedId = tabs.contains(where: { $0.id == selectedTabId }) + ? selectedTabId : tabs.first?.id + let active = currentActiveDatabaseAndSchema() + return (tabs.map { $0.toPersistedTab() }, normalizedSelectedId, active.database, active.schema) + } + private func currentActiveDatabaseAndSchema() -> (database: String?, schema: String?) { guard let session = DatabaseManager.shared.session(for: connectionId) else { return (nil, nil) } return (session.browseDatabase, session.browseSchema) @@ -153,14 +158,16 @@ internal final class TabPersistenceCoordinator { // MARK: - Restore internal func restoreFromDisk() async -> RestoreResult { - guard let state = await TabDiskActor.shared.load(connectionId: connectionId) else { - return RestoreResult(tabs: [], selectedTabId: nil, source: .none) - } + let state = await TabDiskActor.shared.load(connectionId: connectionId) + + /// The write gate opens once the disk has been read, whether or not it held anything: + /// knowing the file is empty is as much knowledge as knowing what was in it. Opening it + /// only on a non-empty read would refuse every save a brand new connection ever makes. + hasObservedTabs = true - guard !state.tabs.isEmpty else { + guard let state, !state.tabs.isEmpty else { return RestoreResult(tabs: [], selectedTabId: nil, source: .none) } - hasObservedTabs = true let defaultPageSize = AppSettingsManager.shared.dataGrid.defaultPageSize var restoredTabs = state.tabs.map { QueryTab(from: $0, defaultPageSize: defaultPageSize) } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift index 8cf95fe1a..28a5cf48c 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift @@ -71,8 +71,11 @@ extension MainContentCoordinator { "[close] coordinator.handleWindowWillClose connId=\(self.connectionId, privacy: .public) tabs=\(self.tabManager.tabs.count)" ) + /// Never clears: a window closing says nothing about whether the user wants these tabs + /// kept, and every connection in the window reaches here. Discarding saved state is + /// `closeTabsByUser`'s job alone. if !MainContentCoordinator.isAppTerminating, !isTearingDown { - persistence.saveOrClearAggregatedSync() + persistence.saveAggregatedSync() } evictionTask?.cancel() diff --git a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index 2d8565c93..cb52be2a9 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -35,7 +35,7 @@ extension MainContentView { guard !coordinator.isTearingDown else { return } let aggregated = MainContentCoordinator.aggregatedTabs(for: coordinator.connectionId) coordinator.persistence.saveNow( - windowedTabs: aggregated, + tabs: aggregated, selectedTabId: newTabId ) MainContentView.lifecycleLogger.debug( diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index c578f97a4..3f5a589a4 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -310,43 +310,17 @@ final class MainContentCoordinator { _didActivate.withLock { $0 } } - /// Collect tabs across all of a connection's windows for persistence, tagged with - /// the index of the native window group they belong to so tab order restores intact. - /// Tabs stamped with the position of the window holding them, so a window can claim its own back. - /// The position is the window's place in the native tab group, not its rank among the windows that - /// happen to have a coordinator: a window whose session went away has no coordinator but still - /// occupies a tab, and numbering around it would hand every later window the wrong tabs. - static func aggregatedTabs(for connectionId: UUID) -> [(tab: QueryTab, windowGroupIndex: Int)] { - let coordinators = activeCoordinators.values + /// One window hosts every connection and a connection has one coordinator, so a + /// connection's tabs are simply that coordinator's list. Tabs used to be scattered across + /// a connection's windows and had to be gathered and renumbered. + static func aggregatedTabs(for connectionId: UUID) -> [QueryTab] { + activeCoordinators.values .filter { $0.connectionId == connectionId } - - guard let anyWindow = coordinators.compactMap({ $0.contentWindow }).first else { - return coordinators.enumerated().flatMap { groupIndex, coordinator in - coordinator.tabManager.tabs - .map { (tab: coordinator.enrichedForPersistence($0), windowGroupIndex: groupIndex) } - } - } - - let groupOrder = Dictionary(uniqueKeysWithValues: - WindowTabGroupOrder.windows(containing: anyWindow) - .enumerated() - .map { (ObjectIdentifier($0.element), $0.offset) } - ) - return coordinators - .sorted { lhs, rhs in - lhs.tabGroupPosition(in: groupOrder) < rhs.tabGroupPosition(in: groupOrder) - } .flatMap { coordinator in - let groupIndex = coordinator.tabGroupPosition(in: groupOrder) - return coordinator.tabManager.tabs - .map { (tab: coordinator.enrichedForPersistence($0), windowGroupIndex: groupIndex) } + coordinator.tabManager.tabs.map(coordinator.enrichedForPersistence) } } - private func tabGroupPosition(in groupOrder: [ObjectIdentifier: Int]) -> Int { - contentWindow.flatMap { groupOrder[ObjectIdentifier($0)] } ?? Int.max - } - /// Resolve transient view state that only the live coordinator knows about /// (sort column names, editor cursor offset) onto the tab before it is serialized. func enrichedForPersistence(_ tab: QueryTab) -> QueryTab { @@ -539,13 +513,9 @@ final class MainContentCoordinator { // Skip isTearingDown check: during Cmd+Q, onDisappear fires // markTeardownScheduled() before willTerminate, and we still // need to save here. - guard self.isFirstCoordinatorForConnection() else { return } let allTabs = Self.aggregatedTabs(for: self.connectionId) let selectedId = Self.aggregatedSelectedTabId(for: self.connectionId) - self.persistence.saveNowSync( - windowedTabs: allTabs, - selectedTabId: selectedId - ) + self.persistence.saveNowSync(tabs: allTabs, selectedTabId: selectedId) } } diff --git a/TableProTests/Core/Services/TabPersistenceClearGuardTests.swift b/TableProTests/Core/Services/TabPersistenceClearGuardTests.swift deleted file mode 100644 index 0dd29b0d9..000000000 --- a/TableProTests/Core/Services/TabPersistenceClearGuardTests.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// TabPersistenceClearGuardTests.swift -// TableProTests -// -// A window left over from a disconnect holds no tabs, and its empty tab list is not the user saying -// they closed everything. Reading it that way deleted the tabs the disconnect had just saved. -// - -import Foundation -@testable import TablePro -import TableProPluginKit -import Testing - -@Suite("Tab persistence clear guard", .serialized) -@MainActor -struct TabPersistenceClearGuardTests { - @Test("A coordinator that never held a tab does not clear saved state") - func neverHeldTabsWithholdsClear() { - let persistence = TabPersistenceCoordinator(connectionId: UUID()) - - #expect(!persistence.hasObservedTabs) - - persistence.saveOrClearAggregatedSync() - - #expect(!persistence.hasObservedTabs) - } - - @Test("Saving a non-empty tab set earns the right to clear later") - func savingTabsMarksThemObserved() { - let persistence = TabPersistenceCoordinator(connectionId: UUID()) - let tab = QueryTab(id: UUID(), title: "Scratch", query: "SELECT 1", tabType: .table) - - persistence.saveNowSync(tabs: [tab], selectedTabId: tab.id) - - #expect(persistence.hasObservedTabs) - } - - @Test("An empty save neither writes nor earns the right to clear") - func emptySaveIsInert() { - let persistence = TabPersistenceCoordinator(connectionId: UUID()) - - persistence.saveNowSync(tabs: [], selectedTabId: nil) - - #expect(!persistence.hasObservedTabs) - } -} diff --git a/TableProTests/Core/Services/TabPersistenceCoordinatorTests.swift b/TableProTests/Core/Services/TabPersistenceCoordinatorTests.swift index f3d5e8049..1505e4c94 100644 --- a/TableProTests/Core/Services/TabPersistenceCoordinatorTests.swift +++ b/TableProTests/Core/Services/TabPersistenceCoordinatorTests.swift @@ -15,8 +15,12 @@ import Testing struct TabPersistenceCoordinatorTests { // MARK: - Helpers + /// A coordinator that has already consulted the disk, which is what every save path requires. + /// The refusal to write before that is covered on its own by `TabPersistenceWriteGateTests`. private func makeCoordinator() -> TabPersistenceCoordinator { - TabPersistenceCoordinator(connectionId: UUID()) + let coordinator = TabPersistenceCoordinator(connectionId: UUID()) + coordinator.markObservedTabs() + return coordinator } private func makeTabs(count: Int) -> [QueryTab] { @@ -380,21 +384,4 @@ struct TabPersistenceCoordinatorTests { #expect(result.source == .none) } - /// Which window a tab belonged to has to survive the round trip, or a reconnecting window cannot - /// tell its own tabs from a sibling's. - @Test("Window positions survive a save and restore") - func windowGroupIndexRoundTrip() async { - let coordinator = makeCoordinator() - let tabs = makeTabs(count: 3) - coordinator.saveNowSync( - windowedTabs: [(tabs[0], 0), (tabs[1], 1), (tabs[2], 1)], - selectedTabId: tabs[1].id - ) - - let result = await coordinator.restoreFromDisk() - - #expect(result.windowGroupIndexByTabId[tabs[0].id] == 0) - #expect(result.windowGroupIndexByTabId[tabs[1].id] == 1) - #expect(result.windowGroupIndexByTabId[tabs[2].id] == 1) - } } diff --git a/TableProTests/Core/Services/TabPersistenceWriteGateTests.swift b/TableProTests/Core/Services/TabPersistenceWriteGateTests.swift new file mode 100644 index 000000000..83a37f901 --- /dev/null +++ b/TableProTests/Core/Services/TabPersistenceWriteGateTests.swift @@ -0,0 +1,74 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("Tab persistence write gate") +@MainActor +struct TabPersistenceWriteGateTests { + private func makeTab(_ title: String) -> QueryTab { + var tab = QueryTab() + tab.title = title + return tab + } + + /// The regression this guards: a save carrying a partial list used to replace the full saved + /// set, so tabs that had not been restored yet were erased from disk. + @Test("A save before any restore is refused") + func saveIsRefusedBeforeRestoreCompletes() async { + let connectionId = UUID() + let coordinator = TabPersistenceCoordinator(connectionId: connectionId) + defer { TabDiskActor.clearSync(connectionId: connectionId) } + + coordinator.saveNowSync(tabs: [makeTab("Query 1")], selectedTabId: nil) + + let loaded = await TabDiskActor.shared.load(connectionId: connectionId) + #expect(loaded == nil || loaded?.tabs.isEmpty == true) + } + + /// A connection with nothing on disk has still consulted it, so it must be able to save. + @Test("A restore that finds nothing still opens the gate") + func emptyRestoreOpensTheGate() async { + let connectionId = UUID() + let coordinator = TabPersistenceCoordinator(connectionId: connectionId) + defer { TabDiskActor.clearSync(connectionId: connectionId) } + + let restored = await coordinator.restoreFromDisk() + #expect(restored.tabs.isEmpty) + + coordinator.saveNowSync(tabs: [makeTab("Query 1")], selectedTabId: nil) + + let loaded = await TabDiskActor.shared.load(connectionId: connectionId) + #expect(loaded?.tabs.count == 1) + } + + @Test("An empty tab list is never written over saved state") + func emptyListNeverClears() async { + let connectionId = UUID() + let coordinator = TabPersistenceCoordinator(connectionId: connectionId) + defer { TabDiskActor.clearSync(connectionId: connectionId) } + + _ = await coordinator.restoreFromDisk() + coordinator.saveNowSync(tabs: [makeTab("Keep me")], selectedTabId: nil) + + coordinator.saveNowSync(tabs: [], selectedTabId: nil) + + let loaded = await TabDiskActor.shared.load(connectionId: connectionId) + #expect(loaded?.tabs.count == 1) + } + + /// Only the user closing every tab discards saved state. + @Test("The explicit clear removes saved state") + func explicitClearRemovesState() async { + let connectionId = UUID() + let coordinator = TabPersistenceCoordinator(connectionId: connectionId) + defer { TabDiskActor.clearSync(connectionId: connectionId) } + + _ = await coordinator.restoreFromDisk() + coordinator.saveNowSync(tabs: [makeTab("Query 1")], selectedTabId: nil) + + coordinator.clearForUserClosedAllTabs() + + let loaded = await TabDiskActor.shared.load(connectionId: connectionId) + #expect(loaded == nil || loaded?.tabs.isEmpty == true) + } +} From 23122de073fc97037709921989149843f96414eb Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 13 Aug 2026 17:17:59 +0700 Subject: [PATCH 14/47] refactor(tabs): drop the window-group dimension from tab persistence --- .../Infrastructure/RestoreWindowPlan.swift | 29 --- .../TabPersistenceCoordinator.swift | 5 - .../WindowGroupAssignment.swift | 71 ------- .../Infrastructure/WindowTabGroupOrder.swift | 36 ---- TablePro/Models/Query/QueryTab.swift | 5 +- .../MultiWindowRestorationTests.swift | 109 ---------- .../Services/RestoreWindowPlanTests.swift | 57 ------ .../Services/WindowGroupAssignmentTests.swift | 186 ------------------ .../Services/WindowTabGroupOrderTests.swift | 61 ------ 9 files changed, 2 insertions(+), 557 deletions(-) delete mode 100644 TablePro/Core/Services/Infrastructure/RestoreWindowPlan.swift delete mode 100644 TablePro/Core/Services/Infrastructure/WindowGroupAssignment.swift delete mode 100644 TablePro/Core/Services/Infrastructure/WindowTabGroupOrder.swift delete mode 100644 TableProTests/Core/Services/MultiWindowRestorationTests.swift delete mode 100644 TableProTests/Core/Services/RestoreWindowPlanTests.swift delete mode 100644 TableProTests/Core/Services/WindowGroupAssignmentTests.swift delete mode 100644 TableProTests/Core/Services/WindowTabGroupOrderTests.swift diff --git a/TablePro/Core/Services/Infrastructure/RestoreWindowPlan.swift b/TablePro/Core/Services/Infrastructure/RestoreWindowPlan.swift deleted file mode 100644 index 1908751dc..000000000 --- a/TablePro/Core/Services/Infrastructure/RestoreWindowPlan.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// RestoreWindowPlan.swift -// TablePro -// - -import Foundation - -/// Which restored group ends up in front. A group rather than a tab, because a window that held more -/// than one tab restores them all into itself, so the tab the user left selected identifies a window -/// to raise, not a tab to single out. -enum RestoreWindowPlan { - enum FrontGroup: Equatable { - case own - case orphaned(windowGroupIndex: Int) - } - - static func resolveFrontGroup( - ownTabIds: [UUID], - orphanedGroups: [(windowGroupIndex: Int, tabIds: [UUID])], - selectedId: UUID? - ) -> FrontGroup { - guard let selectedId else { return .own } - if ownTabIds.contains(selectedId) { return .own } - if let match = orphanedGroups.first(where: { $0.tabIds.contains(selectedId) }) { - return .orphaned(windowGroupIndex: match.windowGroupIndex) - } - return .own - } -} diff --git a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift index 724802579..8858c7048 100644 --- a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift +++ b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift @@ -13,10 +13,6 @@ internal struct RestoreResult { let source: RestoreSource var lastActiveDatabase: String? var lastActiveSchema: String? - /// Which window each tab belongs to, by tab id. Kept beside the tabs rather than on `QueryTab`, - /// because it describes where a tab was rather than anything about the tab, and it is read once - /// while a window works out which tabs are its own. - var windowGroupIndexByTabId: [UUID: Int] = [:] enum RestoreSource { case disk @@ -185,7 +181,6 @@ internal final class TabPersistenceCoordinator { source: .disk, lastActiveDatabase: state.lastActiveDatabase, lastActiveSchema: state.lastActiveSchema, - windowGroupIndexByTabId: WindowGroupAssignment.normalizedGroupIndices(for: state.tabs) ) } } diff --git a/TablePro/Core/Services/Infrastructure/WindowGroupAssignment.swift b/TablePro/Core/Services/Infrastructure/WindowGroupAssignment.swift deleted file mode 100644 index 2213e3aa1..000000000 --- a/TablePro/Core/Services/Infrastructure/WindowGroupAssignment.swift +++ /dev/null @@ -1,71 +0,0 @@ -// -// WindowGroupAssignment.swift -// TablePro -// - -import Foundation - -/// Which saved tabs a restoring window claims, and which saved groups have no window left to claim -/// them. Every window of a connection asks this independently: the answer depends only on the window's -/// own position and on what is on disk, never on which window got here first. That is what lets a -/// connection with several windows restore without any of them arbitrating, since the windows all -/// react to one session-return broadcast in no defined order. -internal enum WindowGroupAssignment { - internal struct Group: Equatable { - internal let windowGroupIndex: Int - internal let tabs: [QueryTab] - internal let selectedTabId: UUID? - } - - internal struct Plan: Equatable { - internal let ownTabs: [QueryTab] - internal let ownSelectedTabId: UUID? - /// Groups whose window is gone, for the leftmost window to reopen. Empty for every other - /// window, which is what keeps exactly one window fanning out without a claim protocol. - internal let orphanedGroups: [Group] - } - - internal static func resolve( - windowIndex: Int, - openWindowCount: Int, - tabs: [QueryTab], - windowGroupIndexByTabId: [UUID: Int], - selectedTabId: UUID? - ) -> Plan { - let grouped = Dictionary(grouping: tabs) { windowGroupIndexByTabId[$0.id] ?? 0 } - let ownTabs = grouped[windowIndex] ?? [] - let ownSelectedTabId = selectedTabId.flatMap { id in - ownTabs.contains { $0.id == id } ? id : nil - } - - guard windowIndex == 0 else { - return Plan(ownTabs: ownTabs, ownSelectedTabId: ownSelectedTabId, orphanedGroups: []) - } - - let orphanedGroups = grouped.keys - .filter { $0 >= openWindowCount } - .sorted() - .map { index in - let groupTabs = grouped[index] ?? [] - return Group( - windowGroupIndex: index, - tabs: groupTabs, - selectedTabId: selectedTabId.flatMap { id in - groupTabs.contains { $0.id == id } ? id : nil - } - ) - } - - return Plan(ownTabs: ownTabs, ownSelectedTabId: ownSelectedTabId, orphanedGroups: orphanedGroups) - } - - /// A file saved before tabs recorded their window carries no grouping at all. Reading every tab - /// as group zero would pile them into one window, and with no in-window tab bar all but one would - /// be invisible, so each tab keeps its own window the way it always has: its position is its group. - internal static func normalizedGroupIndices(for tabs: [PersistedTab]) -> [UUID: Int] { - guard tabs.contains(where: { $0.windowGroupIndex != nil }) else { - return Dictionary(uniqueKeysWithValues: tabs.enumerated().map { ($0.element.id, $0.offset) }) - } - return Dictionary(uniqueKeysWithValues: tabs.map { ($0.id, $0.windowGroupIndex ?? 0) }) - } -} diff --git a/TablePro/Core/Services/Infrastructure/WindowTabGroupOrder.swift b/TablePro/Core/Services/Infrastructure/WindowTabGroupOrder.swift deleted file mode 100644 index 3e6455531..000000000 --- a/TablePro/Core/Services/Infrastructure/WindowTabGroupOrder.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// WindowTabGroupOrder.swift -// TablePro -// - -import AppKit -import Foundation - -/// Where a window sits in its native tab group, left to right. This is the one definition of window -/// order that both halves of tab persistence use: the save side stamps each tab with its window's -/// position, and a restoring window claims the position it occupies. Deriving the two from different -/// sets would silently misfile tabs, because a window with no live session has no coordinator to be -/// counted among but still occupies a tab. -/// -/// It reads `NSWindow.tabbedWindows` rather than any of TablePro's own registries because AppKit -/// populates that at window creation and a lost session never touches it, while `WindowLifecycleMonitor` -/// only learns about a window once its SwiftUI content mounts, which is mid-flight during a reconnect. -internal enum WindowTabGroupOrder { - internal static func windows(containing window: NSWindow) -> [NSWindow] { - window.tabbedWindows ?? [window] - } - - /// Position of `window` among the windows sharing its tab group. A window that reports no tab - /// group is alone, which is position zero: the same answer as the only window of a connection. - internal static func index(of window: NSWindow) -> Int { - position(of: ObjectIdentifier(window), in: windows(containing: window).map(ObjectIdentifier.init)) - } - - internal static func size(containing window: NSWindow) -> Int { - windows(containing: window).count - } - - internal static func position(of window: ObjectIdentifier, in group: [ObjectIdentifier]) -> Int { - group.firstIndex(of: window) ?? 0 - } -} diff --git a/TablePro/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index dcddae2a3..d60885861 100644 --- a/TablePro/Models/Query/QueryTab.swift +++ b/TablePro/Models/Query/QueryTab.swift @@ -167,7 +167,7 @@ struct QueryTab: Identifiable, Equatable { sortState.isSorting && sortState.source == .user } - func toPersistedTab(windowGroupIndex: Int? = nil) -> PersistedTab { + func toPersistedTab() -> PersistedTab { let persistedQuery = content.query let persistedSort: [PersistedSortColumn]? = { @@ -201,8 +201,7 @@ struct QueryTab: Identifiable, Equatable { from: restoredCursorOffset, in: persistedQuery ), - columnWidths: widths, - windowGroupIndex: windowGroupIndex + columnWidths: widths ) } diff --git a/TableProTests/Core/Services/MultiWindowRestorationTests.swift b/TableProTests/Core/Services/MultiWindowRestorationTests.swift deleted file mode 100644 index af9ca91f7..000000000 --- a/TableProTests/Core/Services/MultiWindowRestorationTests.swift +++ /dev/null @@ -1,109 +0,0 @@ -// -// MultiWindowRestorationTests.swift -// TableProTests -// -// Tests for the restoration group registry and last-open-connections recovery list. -// - -import Foundation -@testable import TablePro -import Testing - -@Suite("Multi-window restoration") -@MainActor -struct MultiWindowRestorationTests { - private func tab(_ title: String) -> QueryTab { - QueryTab(id: UUID(), title: title, query: "SELECT 1", tabType: .query) - } - - @Test("Registry hands back the registered group exactly once") - func registryConsumeReturnsGroupOnce() { - let payloadId = UUID() - let tabs = [tab("A"), tab("B")] - RestorationGroupRegistry.register(.init(tabs: tabs, selectedTabId: tabs[1].id), for: payloadId) - - let consumed = RestorationGroupRegistry.consume(for: payloadId) - #expect(consumed?.tabs.map(\.id) == tabs.map(\.id)) - #expect(consumed?.selectedTabId == tabs[1].id) - - #expect(RestorationGroupRegistry.consume(for: payloadId) == nil) - } - - @Test("Consuming a nil payload id returns nil") - func registryConsumeNilReturnsNil() { - #expect(RestorationGroupRegistry.consume(for: nil) == nil) - } - - @Test("Window group defaults to immediate load timing") - func windowGroupDefaultsToImmediate() { - let group = RestorationGroupRegistry.WindowGroup(tabs: [tab("A")], selectedTabId: nil) - #expect(group.loadTiming == .immediate) - } - - @Test("Registry round-trips deferred load timing") - func registryRoundTripsLoadTiming() { - let payloadId = UUID() - RestorationGroupRegistry.register( - .init(tabs: [tab("A")], selectedTabId: nil, loadTiming: .deferred), - for: payloadId - ) - #expect(RestorationGroupRegistry.consume(for: payloadId)?.loadTiming == .deferred) - } - - @Test("Restored sort columns resolve to indices, preserving order and dropping missing columns") - func resolveRestoredSortColumns() { - let persisted = [ - PersistedSortColumn(columnName: "name", direction: .ascending), - PersistedSortColumn(columnName: "ghost", direction: .descending), - PersistedSortColumn(columnName: "id", direction: .descending) - ] - - let resolved = MainContentCoordinator.resolveRestoredSortColumns(persisted, in: ["id", "email", "name"]) - - #expect(resolved.count == 2) - #expect(resolved[0].columnName == "name") - #expect(resolved[0].columnIndex == 2) - #expect(resolved[0].direction == .ascending) - #expect(resolved[1].columnName == "id") - #expect(resolved[1].columnIndex == 0) - #expect(resolved[1].direction == .descending) - } - - @Test("Resolving sort columns against an empty column set yields nothing") - func resolveRestoredSortColumnsEmpty() { - let persisted = [PersistedSortColumn(columnName: "id", direction: .ascending)] - #expect(MainContentCoordinator.resolveRestoredSortColumns(persisted, in: []).isEmpty) - } - - @Test("Last open connections round-trip through storage") - func connectionListRoundTrip() { - let directory = FileManager.default.temporaryDirectory - .appendingPathComponent("LastOpenConnectionsTests-\(UUID().uuidString)", isDirectory: true) - let storage = LastOpenConnectionsStorage(directory: directory) - let ids = [UUID(), UUID(), UUID()] - - storage.save(connectionIds: ids) - #expect(storage.load() == ids) - - storage.clear() - #expect(storage.load().isEmpty) - } - - @Test("Loading from an empty directory returns no connections") - func connectionListMissingFileReturnsEmpty() { - let directory = FileManager.default.temporaryDirectory - .appendingPathComponent("LastOpenConnectionsTests-\(UUID().uuidString)", isDirectory: true) - #expect(LastOpenConnectionsStorage(directory: directory).load().isEmpty) - } - - @Test("Saving an empty list clears the stored file") - func savingEmptyListClears() { - let directory = FileManager.default.temporaryDirectory - .appendingPathComponent("LastOpenConnectionsTests-\(UUID().uuidString)", isDirectory: true) - let storage = LastOpenConnectionsStorage(directory: directory) - - storage.save(connectionIds: [UUID()]) - storage.save(connectionIds: []) - #expect(storage.load().isEmpty) - } -} diff --git a/TableProTests/Core/Services/RestoreWindowPlanTests.swift b/TableProTests/Core/Services/RestoreWindowPlanTests.swift deleted file mode 100644 index 43dc865eb..000000000 --- a/TableProTests/Core/Services/RestoreWindowPlanTests.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// RestoreWindowPlanTests.swift -// TableProTests -// - -import Foundation -import Testing - -@testable import TablePro - -@Suite("RestoreWindowPlan") -struct RestoreWindowPlanTests { - @Test("Selected tab is one this window kept: this window comes to the front") - func selectedIsOwn() { - let own = UUID() - let front = RestoreWindowPlan.resolveFrontGroup( - ownTabIds: [own], - orphanedGroups: [(windowGroupIndex: 1, tabIds: [UUID()])], - selectedId: own - ) - #expect(front == .own) - } - - @Test("Selected tab belongs to a reopened group: that group comes to the front") - func selectedIsOrphaned() { - let target = UUID() - let front = RestoreWindowPlan.resolveFrontGroup( - ownTabIds: [UUID()], - orphanedGroups: [ - (windowGroupIndex: 1, tabIds: [UUID()]), - (windowGroupIndex: 2, tabIds: [UUID(), target]), - ], - selectedId: target - ) - #expect(front == .orphaned(windowGroupIndex: 2)) - } - - @Test("Selected id matches nothing being restored: this window comes to the front") - func selectedMatchesNeither() { - let front = RestoreWindowPlan.resolveFrontGroup( - ownTabIds: [UUID()], - orphanedGroups: [(windowGroupIndex: 1, tabIds: [UUID()])], - selectedId: UUID() - ) - #expect(front == .own) - } - - @Test("Nothing was selected: this window comes to the front") - func selectedNil() { - let front = RestoreWindowPlan.resolveFrontGroup( - ownTabIds: [UUID()], - orphanedGroups: [(windowGroupIndex: 1, tabIds: [UUID()])], - selectedId: nil - ) - #expect(front == .own) - } -} diff --git a/TableProTests/Core/Services/WindowGroupAssignmentTests.swift b/TableProTests/Core/Services/WindowGroupAssignmentTests.swift deleted file mode 100644 index d88a19df6..000000000 --- a/TableProTests/Core/Services/WindowGroupAssignmentTests.swift +++ /dev/null @@ -1,186 +0,0 @@ -// -// WindowGroupAssignmentTests.swift -// TableProTests -// -// Every window of a connection restores at the same time, in no defined order, so the rule deciding -// which tabs are whose has to depend only on the window's own position. A connection with more than -// one window open used to come back with every window empty, because each one stood down on seeing a -// sibling and none of them claimed the tabs. -// - -import Foundation -@testable import TablePro -import Testing - -@Suite("Window group assignment") -struct WindowGroupAssignmentTests { - private func tab(_ title: String) -> QueryTab { - QueryTab(id: UUID(), title: title, query: "SELECT 1", tabType: .table) - } - - private func indices(_ pairs: [(QueryTab, Int)]) -> [UUID: Int] { - Dictionary(uniqueKeysWithValues: pairs.map { ($0.0.id, $0.1) }) - } - - @Test("A window claims the group saved at its own position") - func claimsOwnGroup() { - let first = tab("First") - let second = tab("Second") - let third = tab("Third") - - let plan = WindowGroupAssignment.resolve( - windowIndex: 1, - openWindowCount: 3, - tabs: [first, second, third], - windowGroupIndexByTabId: indices([(first, 0), (second, 1), (third, 2)]), - selectedTabId: nil - ) - - #expect(plan.ownTabs == [second]) - #expect(plan.orphanedGroups.isEmpty) - } - - /// The property that lets every window decide alone: only the leftmost one reopens anything, so - /// two windows can never both restore the same saved group into a window of their own. - @Test("Only the leftmost window reports orphaned groups") - func onlyLeftmostWindowFansOut() { - let kept = tab("Kept") - let orphaned = tab("Orphaned") - let byId = indices([(kept, 0), (orphaned, 4)]) - - let leftmost = WindowGroupAssignment.resolve( - windowIndex: 0, - openWindowCount: 2, - tabs: [kept, orphaned], - windowGroupIndexByTabId: byId, - selectedTabId: nil - ) - let other = WindowGroupAssignment.resolve( - windowIndex: 1, - openWindowCount: 2, - tabs: [kept, orphaned], - windowGroupIndexByTabId: byId, - selectedTabId: nil - ) - - #expect(leftmost.orphanedGroups.map(\.windowGroupIndex) == [4]) - #expect(other.orphanedGroups.isEmpty) - } - - @Test("A group that still has a window is never reopened") - func liveWindowsAreNotFannedOut() { - let first = tab("First") - let second = tab("Second") - let third = tab("Third") - - let plan = WindowGroupAssignment.resolve( - windowIndex: 0, - openWindowCount: 3, - tabs: [first, second, third], - windowGroupIndexByTabId: indices([(first, 0), (second, 1), (third, 2)]), - selectedTabId: nil - ) - - #expect(plan.ownTabs == [first]) - #expect(plan.orphanedGroups.isEmpty) - } - - @Test("Orphaned groups come back in saved order") - func orphanedGroupsAreOrdered() { - let kept = tab("Kept") - let later = tab("Later") - let earlier = tab("Earlier") - - let plan = WindowGroupAssignment.resolve( - windowIndex: 0, - openWindowCount: 1, - tabs: [kept, later, earlier], - windowGroupIndexByTabId: indices([(kept, 0), (later, 3), (earlier, 1)]), - selectedTabId: nil - ) - - #expect(plan.orphanedGroups.map(\.windowGroupIndex) == [1, 3]) - } - - /// A window that was blank when the session ended comes back blank, rather than being handed - /// somebody else's tab. - @Test("A window with nothing saved at its position stays empty") - func windowWithoutSavedGroupStaysEmpty() { - let first = tab("First") - let third = tab("Third") - - let plan = WindowGroupAssignment.resolve( - windowIndex: 1, - openWindowCount: 3, - tabs: [first, third], - windowGroupIndexByTabId: indices([(first, 0), (third, 2)]), - selectedTabId: nil - ) - - #expect(plan.ownTabs.isEmpty) - #expect(plan.orphanedGroups.isEmpty) - } - - @Test("A window that held several tabs gets all of them back") - func multipleTabsInOneWindow() { - let left = tab("Left") - let right = tab("Right") - - let plan = WindowGroupAssignment.resolve( - windowIndex: 0, - openWindowCount: 1, - tabs: [left, right], - windowGroupIndexByTabId: indices([(left, 0), (right, 0)]), - selectedTabId: right.id - ) - - #expect(plan.ownTabs == [left, right]) - #expect(plan.ownSelectedTabId == right.id) - } - - @Test("The selected tab is only reported by the group that holds it") - func selectionBelongsToOneGroup() { - let kept = tab("Kept") - let orphaned = tab("Orphaned") - - let plan = WindowGroupAssignment.resolve( - windowIndex: 0, - openWindowCount: 1, - tabs: [kept, orphaned], - windowGroupIndexByTabId: indices([(kept, 0), (orphaned, 1)]), - selectedTabId: orphaned.id - ) - - #expect(plan.ownSelectedTabId == nil) - #expect(plan.orphanedGroups.first?.selectedTabId == orphaned.id) - } - - /// A file written before tabs recorded their window has no grouping at all. Reading every tab as - /// window zero would pile them into one window, and with no in-window tab bar all but one would be - /// invisible, so each tab keeps a window of its own exactly as it always has. - @Test("A file saved without window positions gives each tab its own window") - func legacyFileKeepsOneTabPerWindow() { - let first = PersistedTab(id: UUID(), title: "First", query: "SELECT 1", tabType: .table, tableName: nil) - let second = PersistedTab(id: UUID(), title: "Second", query: "SELECT 2", tabType: .table, tableName: nil) - - let normalized = WindowGroupAssignment.normalizedGroupIndices(for: [first, second]) - - #expect(normalized[first.id] == 0) - #expect(normalized[second.id] == 1) - } - - @Test("A file that records window positions keeps them") - func recordedPositionsAreKept() { - let first = PersistedTab( - id: UUID(), title: "First", query: "SELECT 1", tabType: .table, tableName: nil, windowGroupIndex: 2 - ) - let second = PersistedTab( - id: UUID(), title: "Second", query: "SELECT 2", tabType: .table, tableName: nil, windowGroupIndex: 0 - ) - - let normalized = WindowGroupAssignment.normalizedGroupIndices(for: [first, second]) - - #expect(normalized[first.id] == 2) - #expect(normalized[second.id] == 0) - } -} diff --git a/TableProTests/Core/Services/WindowTabGroupOrderTests.swift b/TableProTests/Core/Services/WindowTabGroupOrderTests.swift deleted file mode 100644 index 286cc99da..000000000 --- a/TableProTests/Core/Services/WindowTabGroupOrderTests.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// WindowTabGroupOrderTests.swift -// TableProTests -// -// Saving and restoring tabs both describe a window by its place in the native tab group. They have to -// agree: a window with no live session has no coordinator to be counted among, but it still occupies a -// tab, and numbering around it would hand every window after it somebody else's tabs. -// - -import AppKit -import Foundation -@testable import TablePro -import Testing - -@Suite("Window tab group order") -@MainActor -struct WindowTabGroupOrderTests { - private func makeWindow() -> NSWindow { - NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), - styleMask: [.titled], - backing: .buffered, - defer: true - ) - } - - @Test("A window with no tab group is alone at the first position") - func windowWithoutTabGroupIsFirst() { - let window = makeWindow() - - #expect(WindowTabGroupOrder.index(of: window) == 0) - #expect(WindowTabGroupOrder.size(containing: window) == 1) - #expect(WindowTabGroupOrder.windows(containing: window) == [window]) - } - - @Test("A window reports its position in the group it belongs to") - func positionWithinGroup() { - /// Held in bindings on purpose: an `ObjectIdentifier` taken from a temporary is only unique - /// while that object is alive, and the next allocation can land on the same address. - let firstWindow = makeWindow() - let secondWindow = makeWindow() - let thirdWindow = makeWindow() - let group = [firstWindow, secondWindow, thirdWindow].map(ObjectIdentifier.init) - - #expect(WindowTabGroupOrder.position(of: ObjectIdentifier(firstWindow), in: group) == 0) - #expect(WindowTabGroupOrder.position(of: ObjectIdentifier(secondWindow), in: group) == 1) - #expect(WindowTabGroupOrder.position(of: ObjectIdentifier(thirdWindow), in: group) == 2) - } - - /// A window absent from the list it was asked about is treated as the only one there is, which is - /// the same answer a connection's single window gets. - @Test("A window missing from its group falls back to the first position") - func missingWindowFallsBackToFirst() { - let absentWindow = makeWindow() - let presentWindow = makeWindow() - let absent = ObjectIdentifier(absentWindow) - - #expect(WindowTabGroupOrder.position(of: absent, in: [ObjectIdentifier(presentWindow)]) == 0) - #expect(WindowTabGroupOrder.position(of: absent, in: []) == 0) - } -} From 501faf394775c693e1a98f0de6b5b86faea6049e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 13 Aug 2026 17:20:24 +0700 Subject: [PATCH 15/47] fix(tabs): give each connection its own undo history --- .../Infrastructure/TabWindowController.swift | 38 ++++++++++++------- .../MainContentCommandActions+UndoState.swift | 5 ++- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/TablePro/Core/Services/Infrastructure/TabWindowController.swift b/TablePro/Core/Services/Infrastructure/TabWindowController.swift index 37cf502cc..bf32d8b74 100644 --- a/TablePro/Core/Services/Infrastructure/TabWindowController.swift +++ b/TablePro/Core/Services/Infrastructure/TabWindowController.swift @@ -18,20 +18,20 @@ private final class EditorWindow: NSWindow { } } - override func newWindowForTab(_ sender: Any?) { - guard let coordinator = MainContentCoordinator.coordinator(forWindow: self), - let actions = coordinator.commandActions else { return } - actions.newTab() - } - - /// `NSWindow` implements `newWindowForTab:`, so the responder chain stops here and - /// never reaches the split view controller's validation. Without this the item - /// stays enabled on a window that is connecting, failed, or disconnected. - override func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { - guard menuItem.action == #selector(newWindowForTab(_:)) else { - return super.validateMenuItem(menuItem) - } - return MainContentCoordinator.coordinator(forWindow: self)?.commandActions?.isConnected == true + + func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { + FileDropDestination.acceptedURLs(from: sender.draggingPasteboard).isEmpty ? [] : .copy + } + + func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { + draggingEntered(sender) + } + + func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { + let urls = FileDropDestination.acceptedURLs(from: sender.draggingPasteboard) + guard !urls.isEmpty else { return false } + FileDropDestination.open(urls) + return true } } @@ -78,6 +78,7 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { autoConnect: autoConnect ) window.contentViewController = splitVC + FileDropDestination.register(on: window) window.title = splitVC.windowTitle window.subtitle = splitVC.windowSubtitle @@ -152,6 +153,15 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { Self.lifecycleLogger.debug("[switch] windowDidBecomeKey seq=\(seq) total ms=\(Int(Date().timeIntervalSince(t0) * 1_000))") } + /// `NSWindow.undoManager` resolves through here, so every undo domain that registers on the + /// window (the data grid's change manager, and the query editor's text view) lands in the + /// selected connection's own history. One window hosts every connection now, so sharing the + /// window's manager let Cmd+Z in one connection roll back an edit made in another. + internal func windowWillReturnUndoManager(_ window: NSWindow) -> UndoManager? { + guard let host = window.contentViewController as? MainSplitViewController else { return nil } + return host.workspaces.selected?.undoManager + } + internal func windowDidResignKey(_ notification: Notification) { let seq = MainContentCoordinator.nextSwitchSeq() let t0 = Date() diff --git a/TablePro/Views/Main/MainContentCommandActions+UndoState.swift b/TablePro/Views/Main/MainContentCommandActions+UndoState.swift index 7fddcc0ce..0436c98e2 100644 --- a/TablePro/Views/Main/MainContentCommandActions+UndoState.swift +++ b/TablePro/Views/Main/MainContentCommandActions+UndoState.swift @@ -5,9 +5,10 @@ import AppKit -/// One window hosts three undo domains, picked by the active tab: Users & Roles and +/// A connection hosts three undo domains, picked by the active tab: Users & Roles and /// the structure editor keep their own histories, and everything else registers on -/// the window's `UndoManager`. Availability and the menu title both have to follow +/// the window's `UndoManager`, which `TabWindowController` resolves to the selected +/// connection's own history. Availability and the menu title both have to follow /// whichever one is live, so they resolve through the same branch the commands do. extension MainContentCommandActions { private var windowUndoManager: UndoManager? { From d9d30daa88930235a4c173e071eb9cf533ea052b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 13 Aug 2026 17:21:53 +0700 Subject: [PATCH 16/47] fix(tabs): save and tear down every connection when its window closes --- .../Infrastructure/TabWindowController.swift | 34 +++++++++++++------ 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/TablePro/Core/Services/Infrastructure/TabWindowController.swift b/TablePro/Core/Services/Infrastructure/TabWindowController.swift index bf32d8b74..42f5317bf 100644 --- a/TablePro/Core/Services/Infrastructure/TabWindowController.swift +++ b/TablePro/Core/Services/Infrastructure/TabWindowController.swift @@ -198,25 +198,37 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { splitVC.invalidateToolbar() } - MainContentCoordinator.coordinator(forWindow: window)?.handleWindowWillClose() + /// Every connection the window hosts closes with it, so each one saves and tears down. + /// Resolving a single coordinator from the window persisted whichever one happened to + /// answer and lost the rest since their last periodic save. + if let splitVC = window.contentViewController as? MainSplitViewController { + for workspace in splitVC.workspaces.workspaces { + workspace.sessionState?.coordinator.handleWindowWillClose() + } + } Self.lifecycleLogger.info("[close] windowWillClose seq=\(seq) handleWindowWillClose ms=\(Int(Date().timeIntervalSince(t0) * 1_000))") activity?.invalidate() activity = nil Self.lifecycleLogger.info("[close] windowWillClose seq=\(seq) total ms=\(Int(Date().timeIntervalSince(t0) * 1_000))") } + /// Every connection the window hosts, not just the one it was opened for: a connect still + /// dialing in a background workspace has to be called off too, or it completes into a window + /// that no longer exists. private func cancelPendingConnectionIfNeeded() { - let connectionId = payload.connectionId - let session = DatabaseManager.shared.activeSessions[connectionId] - guard session?.driver == nil else { return } - DatabaseManager.shared.invalidateConnectionAttempt(connectionId) - SessionRecoveryTracker.sync() - Task { - await DatabaseManager.shared.cancelEnsureConnected(connectionId) - guard !WindowManager.shared.hasOpenWindow(for: connectionId) else { return } - guard DatabaseManager.shared.activeSessions[connectionId]?.driver != nil else { return } - await DatabaseManager.shared.disconnectSession(connectionId) + guard let splitVC = window?.contentViewController as? MainSplitViewController else { return } + for connectionId in splitVC.workspaces.connectionIds { + let session = DatabaseManager.shared.activeSessions[connectionId] + guard session?.driver == nil else { continue } + DatabaseManager.shared.invalidateConnectionAttempt(connectionId) + Task { + await DatabaseManager.shared.cancelEnsureConnected(connectionId) + guard !WindowManager.shared.hasOpenWindow(for: connectionId) else { return } + guard DatabaseManager.shared.activeSessions[connectionId]?.driver != nil else { return } + await DatabaseManager.shared.disconnectSession(connectionId) + } } + SessionRecoveryTracker.sync() } // MARK: - NSUserActivity From 8360db23d83d9e5d9b6b0048c0f03188024466e1 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 13 Aug 2026 17:24:40 +0700 Subject: [PATCH 17/47] fix(connections): act on the connection the window is showing --- .../MainSplitViewController+Connection.swift | 10 ++++++++++ TablePro/Core/Services/Infrastructure/TabRouter.swift | 5 +++-- .../Extensions/MainContentCoordinator+Registry.swift | 9 ++++++++- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+Connection.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+Connection.swift index 158e6df85..811ced298 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+Connection.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+Connection.swift @@ -34,6 +34,16 @@ internal extension MainSplitViewController { connect(connection, cancellingPrevious: true) } + /// Reconnects the connection named, not whichever one the window happens to be showing. + /// Clicking a disconnected connection used to redial the selected one instead, tearing down + /// the session the user was working in. + internal func reconnectWorkspace(_ connectionId: UUID) { + guard let workspace = workspaces.workspace(for: connectionId), + let connection = workspace.connection else { return } + workspaces.select(connectionId) + connect(connection, cancellingPrevious: true) + } + /// The window stays open and repaints itself from its own phase once the session entry goes /// away, so this only has to end the session. Every other window on the connection hears the /// same status change and reaches the same phase on its own. diff --git a/TablePro/Core/Services/Infrastructure/TabRouter.swift b/TablePro/Core/Services/Infrastructure/TabRouter.swift index a43387964..49786cff9 100644 --- a/TablePro/Core/Services/Infrastructure/TabRouter.swift +++ b/TablePro/Core/Services/Infrastructure/TabRouter.swift @@ -100,8 +100,9 @@ internal final class TabRouter { NSApp.activate(ignoringOtherApps: true) WindowOpener.shared.closeWelcome() guard DatabaseManager.shared.activeSessions[id]?.driver == nil else { return } - if let splitVC = existing.contentViewController as? MainSplitViewController { - splitVC.retryConnection() + if let splitVC = existing.contentViewController as? MainSplitViewController, + splitVC.workspaces.contains(id) { + splitVC.reconnectWorkspace(id) } else { try await runPreConnectScriptIfNeeded(connection) try await DatabaseManager.shared.ensureConnected(connection) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Registry.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Registry.swift index 19d4ad5fc..fb5f16d4b 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Registry.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Registry.swift @@ -15,8 +15,15 @@ extension MainContentCoordinator { activeCoordinators.values.first { $0.windowId == windowId } } + /// The coordinator the window is currently showing. Every connection the window hosts has a + /// coordinator whose `contentWindow` is this window, so matching on that alone returned an + /// arbitrary one of them and let Cmd+W, Cmd+T and menu validation act on a connection the + /// user was not looking at. static func coordinator(forWindow window: NSWindow) -> MainContentCoordinator? { - activeCoordinators.values.first { $0.contentWindow === window } + guard let host = window.contentViewController as? MainSplitViewController else { + return activeCoordinators.values.first { $0.contentWindow === window } + } + return host.workspaces.selected?.sessionState?.coordinator } static func hasAnyUnsavedChanges() -> Bool { From 8b74aacc63290e376bca6f98dc1fd9aa29a7eeb1 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 13 Aug 2026 17:27:07 +0700 Subject: [PATCH 18/47] fix(connections): close one connection, not the window that hosts them all --- .../MainSplitViewController+FileMenuActions.swift | 10 +++++++++- .../MainSplitViewController+MenuValidation.swift | 10 ++++++++-- TablePro/ViewModels/WelcomeViewModel+Sample.swift | 6 +++--- TablePro/Views/Main/Child/MainEditorContentView.swift | 2 +- 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+FileMenuActions.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+FileMenuActions.swift index e6525014c..c61e80108 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+FileMenuActions.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+FileMenuActions.swift @@ -25,8 +25,16 @@ extension MainSplitViewController { commandActions?.newTab() } + /// A connecting or failed pane has no command surface, so Cmd+W closes the connection + /// itself. Leaving it to `commandActions` made the shortcut inert on exactly the pane a + /// user most wants to dismiss. @objc func closeEditorTab(_ sender: Any?) { - commandActions?.closeTab() + guard let actions = commandActions else { + guard let connectionId = workspaces.selectedConnectionId else { return } + WindowManager.shared.closeWindow(for: connectionId) + return + } + actions.closeTab() } @objc func selectNextEditorTab(_ sender: Any?) { diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift index 8d85af070..aa3a82d7c 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift @@ -12,6 +12,9 @@ struct MenuValidationContext: Equatable { /// Comes from the window's own `ConnectionWindowPhase`, never from the presence of a /// coordinator: the coordinator deliberately outlives a lost session so a reconnect keeps /// the user's tabs, which made every connection-scoped command stay lit while dialing. + /// True whenever the window is showing a connection, connected or not, so a pane that + /// failed to dial can still be dismissed. + var hasSelectedWorkspace = false var isConnected = false var isReadOnly = false var isTableTab = false @@ -90,7 +93,7 @@ extension MainSplitViewController: NSMenuItemValidation { case #selector(newEditorTab(_:)): return context.isConnected case #selector(closeEditorTab(_:)): - return context.isConnected + return context.hasSelectedWorkspace case #selector(selectNextEditorTab(_:)), #selector(selectPreviousEditorTab(_:)): return context.isConnected @@ -178,8 +181,11 @@ extension MainSplitViewController: NSMenuItemValidation { } var menuValidationContext: MenuValidationContext { - guard let actions = commandActions else { return MenuValidationContext() } + guard let actions = commandActions else { + return MenuValidationContext(hasSelectedWorkspace: workspaces.selectedConnectionId != nil) + } return MenuValidationContext( + hasSelectedWorkspace: workspaces.selectedConnectionId != nil, isConnected: isConnected, isReadOnly: actions.isReadOnly, isTableTab: actions.isTableTab, diff --git a/TablePro/ViewModels/WelcomeViewModel+Sample.swift b/TablePro/ViewModels/WelcomeViewModel+Sample.swift index d271b6376..d888d38fe 100644 --- a/TablePro/ViewModels/WelcomeViewModel+Sample.swift +++ b/TablePro/ViewModels/WelcomeViewModel+Sample.swift @@ -127,9 +127,9 @@ internal enum SampleDatabaseLauncher { connectionId: UUID, onError: @MainActor @escaping (Error) -> Void ) { - for window in WindowLifecycleMonitor.shared.windows(for: connectionId) { - window.close() - } + /// Closes this connection only. The window hosts every open connection, so closing it + /// would take the rest down over a sample database that failed to open. + WindowManager.shared.closeWindow(for: connectionId) onError(error) WindowOpener.shared.openWelcome() } diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index bf5958322..b38f8b75f 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -373,7 +373,7 @@ struct MainEditorContentView: View { claimFocusOnAppear: claimFocus, restoredCursorRange: coordinator.restoredCursorRange(for: tab.id), onCloseTab: { - NSApp.keyWindow?.close() + coordinator.commandActions?.closeTab() }, onExecuteQuery: { coordinator.runQuery() }, onExplain: { variant in From 3f20311c5a709304df830e5f14383d4a4efaeb3c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 13 Aug 2026 17:31:42 +0700 Subject: [PATCH 19/47] fix(coordinator): repoint the detail pane and toolbar on a workspace switch --- .../Infrastructure/MainSplitViewController.swift | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index 85395d832..eb1470946 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -333,12 +333,17 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi /// one, and `NSToolbar` asks it once, so a toolbar created early is permanently empty: a /// later coordinator cannot refill it, and validation only speaks to items that already /// exist. A window with no session shows a plain titlebar instead. + /// `NSToolbar` asks its delegate for an item once and keeps what it returns, and every item + /// view captures the coordinator it was built with. Reassigning the owner's coordinator + /// therefore repoints nothing: the toolbar goes on naming, and acting on, the connection it + /// was built for. Switching connection rebuilds it instead. func installToolbar(coordinator: MainContentCoordinator) { guard let window = view.window else { return } + if let owner = toolbarOwner, owner.coordinator !== coordinator { + invalidateToolbar() + } if toolbarOwner == nil { toolbarOwner = MainWindowToolbar(coordinator: coordinator) - } else { - toolbarOwner?.coordinator = coordinator } if let owner = toolbarOwner, window.toolbar !== owner.managedToolbar { window.toolbar = owner.managedToolbar @@ -612,6 +617,11 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi toolbarState: sessionState.toolbarState, coordinator: sessionState.coordinator ) + /// The detail pane is a different view per connection, not one view whose inputs + /// changed. Without an identity its `@State` (whether tabs have been restored, the + /// cached change manager, the window id) survives a workspace switch, so the second + /// connection either never restores or overwrites its live tabs with a disk snapshot. + .id(currentSession.connection.id) .transaction { $0.animation = nil } } else { Color.clear From 534c54d492a9d6014d651b1f9f34cfbd2e6cc410 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 13 Aug 2026 17:33:18 +0700 Subject: [PATCH 20/47] docs(claude-md): correct the invariants that still describe one window per connection --- CLAUDE.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c3efc65d6..9380b6163 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -173,9 +173,9 @@ These have caused real bugs when violated: **WelcomeViewModel tree rebuild**: The welcome screen renders `treeItems` (grouped/filtered), not `connections` directly. Every mutation to `connections` must call `rebuildTree()` afterward, or the UI won't update. -**Tab replacement guard**: `openTableTab` checks for active work (unsaved edits, applied filters, sorting) before replacing the current tab. Tabs with active work open a new native window tab instead. This check runs before the preview tab branch. +**Tab replacement guard**: `openTableTab` checks for active work (unsaved edits, applied filters, sorting) before replacing the current tab. A tab with active work is left alone and the table opens as a new editor tab in the same window's strip. This check runs before the preview tab branch. -**Window tab titles**: The native tab label follows `NSWindow.title`, and AppKit renders it for background tabs too, so the title must be correct from creation, not from first activation. Every title resolves through `WindowTitleResolver` (pure, AppKit-free): `MainSplitViewController.init` for the payload-driven initial title, `updateWindowTitleAndFileState()` in `MainContentView+Setup.swift` for ongoing tab-driven updates. The resolver treats a blank string as absent at every tier and always recomputes a `.table` tab's name from `tableName`+`schemaName` instead of trusting a carried-over title. `TabWindowController.init` pushes the resolved title onto `window.title`/`window.subtitle` right after assigning `contentViewController`, because a joined-but-never-activated tab window never runs `viewWillAppear` or its SwiftUI lifecycle. `MainSplitViewController.windowTitle`'s `didSet` is the single guarded sink and never lets an empty string reach `NSWindow.title`. Never write `window.title` or `NSApp.keyWindow?.title` directly; mutate `tab.title` and call `QueryTabManager.markTabRenamed(_:)` so the resolver re-runs. A restored tab whose persisted title decoded to "" shipped as a blank tab label that only healed on activation. +**Window tab titles**: The native tab label follows `NSWindow.title`, and AppKit renders it for background tabs too, so the title must be correct from creation, not from first activation. Every title resolves through `WindowTitleResolver` (pure, AppKit-free): `MainSplitViewController.init` for the payload-driven initial title, `updateWindowTitleAndFileState()` in `MainContentView+Setup.swift` for ongoing tab-driven updates. The resolver treats a blank string as absent at every tier and always recomputes a `.table` tab's name from `tableName`+`schemaName` instead of trusting a carried-over title. `TabWindowController.init` pushes the resolved title onto `window.title`/`window.subtitle` right after assigning `contentViewController`, because a joined-but-never-activated tab window never runs `viewWillAppear` or its SwiftUI lifecycle. `MainSplitViewController.windowTitle`'s `didSet` is the single guarded sink and never lets an empty string reach `NSWindow.title`. Never write `window.title` or `NSApp.keyWindow?.title` directly; mutate `tab.title` and call `QueryTabManager.markTabRenamed(_:)` so the resolver re-runs. A restored tab whose persisted title decoded to "" shipped as a blank tab label that only healed on activation. Editor tabs are no longer windows, so there are now two labels with two owners: the window titlebar goes through `WindowTitleResolver` and the guarded `windowTitle` sink, while the editor tab label is `Text(tab.title)` in `EditorTabStrip` with no resolver between it and the string. Blank-title healing therefore has to hold at `QueryTab.title` itself. **Schema loading**: `SQLSchemaProvider` (actor) stores an in-flight `loadTask: Task?`. Concurrent callers `await` the same Task instead of firing duplicate `fetchTables()` queries. Never use a boolean `isLoading` guard that returns without data — callers need to await the result. @@ -183,9 +183,9 @@ These have caused real bugs when violated: **Selection indices are display positions**: `GridSelectionState.indices` come from `NSTableView.selectedRowIndexes` and are display-row positions, not indices into `TableRows.rows`. They match array indices only when `displayIDs` (`valueFilteredIDs ?? sortedIDs`) is nil; a per-column value filter makes them diverge. Resolve any selected index through `DisplayRowMapping` (or `TableViewCoordinator.displayRow(at:)` / `tableRowsIndex(forDisplayRow:)`) before reading or mutating a row; never index `TableRows.rows` with a display position. The row details inspector shipped this bug (#1837). -**Cancelling a connect does not stop the driver**: `Task.cancel()` is cooperative, so it cannot interrupt a driver blocked in a C call. A cancelled attempt keeps running and completes late. Two rules follow. First, a driver that blocks on connect must expose its own abort path and poll it (the PostgreSQL driver uses `PQconnectStart`/`PQconnectPoll` with an app-owned deadline and a cancel flag flipped from `withTaskCancellationHandler`; a blocking `PQconnectdb` cannot be cancelled at all). When the driver's C API has no pollable connect (FreeTDS db-lib's `dbopen`), the other valid shape is to resume the awaiting caller on cancel or an app-owned deadline through a resume-once continuation gate (`SingleResumeGate` / `runCancellableBlocking`), keep the blocking call on its own serial queue, and have the late-completing call tear down its own handle (the loser `dbclose`s the `dbproc`) instead of adopting it; a process-global set before the blocking call (e.g. `KRB5CCNAME` for Kerberos) is set and restored inside that queue block so its lifetime tracks the real completion, not the early return (#1889). Second, never assume the losing attempt is gone: every attempt validates its `ConnectionAttemptRegistry` generation before adopting a driver into `activeSessions` or tearing session state down, so a late attempt discards its own driver instead of clobbering the winner. Cancelling also drops the connection from `LastOpenConnections.json` (via `SessionRecoveryTracker.sync()`) so "Reopen Last Session" never replays a connect the user cancelled, but a connect that merely *failed* keeps its place in the list: a database that was down is not a user who gave up. That distinction is `ConnectionWindowPhaseMachine.retainsRestoreIntent`, and it is the whole reason `RecoveryCandidate` carries `retainsRestoreIntent` alongside `isActivated`. Collapsing the two back into one flag makes one launch against a stopped server erase the session permanently. This area shipped the same bug four times (#1185, #1358, #1369). +**Cancelling a connect does not stop the driver**: `Task.cancel()` is cooperative, so it cannot interrupt a driver blocked in a C call. A cancelled attempt keeps running and completes late. Two rules follow. First, a driver that blocks on connect must expose its own abort path and poll it (the PostgreSQL driver uses `PQconnectStart`/`PQconnectPoll` with an app-owned deadline and a cancel flag flipped from `withTaskCancellationHandler`; a blocking `PQconnectdb` cannot be cancelled at all). When the driver's C API has no pollable connect (FreeTDS db-lib's `dbopen`), the other valid shape is to resume the awaiting caller on cancel or an app-owned deadline through a resume-once continuation gate (`SingleResumeGate` / `runCancellableBlocking`), keep the blocking call on its own serial queue, and have the late-completing call tear down its own handle (the loser `dbclose`s the `dbproc`) instead of adopting it; a process-global set before the blocking call (e.g. `KRB5CCNAME` for Kerberos) is set and restored inside that queue block so its lifetime tracks the real completion, not the early return (#1889). Second, never assume the losing attempt is gone: every attempt validates its `ConnectionAttemptRegistry` generation before adopting a driver into `activeSessions` or tearing session state down, so a late attempt discards its own driver instead of clobbering the winner. Cancelling also drops the connection from `LastOpenConnections.json` (via `SessionRecoveryTracker.sync()`) so "Reopen Last Session" never replays a connect the user cancelled, but a connect that merely *failed* keeps its place in the list: a database that was down is not a user who gave up. That distinction is `ConnectionWindowPhaseMachine.retainsRestoreIntent`, read per workspace through `ConnectionWorkspace.retainsRestoreIntent` and aggregated per window by `MainSplitViewController.connectionIdsRetainingRestoreIntent`, and it is the whole reason `RecoveryCandidate` carries `retainsRestoreIntent` alongside `isActivated`. Collapsing the two back into one flag makes one launch against a stopped server erase the session permanently. This area shipped the same bug four times (#1185, #1358, #1369). -**A connection window's content is a function of its own `ConnectionWindowPhase`, never of `activeSessions` membership**: the global session dictionary can only say *present* or *absent*, and that vocabulary cannot tell "never started" from "connecting" from "failed" from "the user cancelled" from "the window is closing". Deriving the pane from it shipped a window that painted a live spinner forever after a failed launch restore, could not be repainted by a later successful connect, and left no route back to the connection list except the Dock icon's context menu. `MainSplitViewController` owns a `phase`, `ConnectionWindowPhaseMachine` owns the transitions (pure, exhaustive, `.closing` absorbing), and `ConnectionWindowPaneResolver` owns the pane choice (pure); the controller is only an adapter. Three rules follow. First, every phase must have an exit: the old `closingSessionId` latch was set once and never cleared, so the controller went permanently deaf to `connectionStatusChanged`. Second, a cancel updates the UI synchronously with the button press and never waits on the driver, because `Task.cancel()` is cooperative and may have no observable effect; the attempt is fenced by a per-window `attemptToken` plus `DatabaseManager.invalidateConnectionAttempt` so a late failure cannot write into a window that moved on. Third, a failure is presented inline through `ConnectionUnavailableView`, never as an alert, per the HIG's rule against alerts at startup and its one-alert-at-a-time rule (N restored connections would mean N modals). Only one presenter per failure: `LaunchIntentRouter.presentError` stays silent when a window for that connection exists. +**A workspace's content is a function of its own `ConnectionWindowPhase`, never of `activeSessions` membership**: the global session dictionary can only say *present* or *absent*, and that vocabulary cannot tell "never started" from "connecting" from "failed" from "the user cancelled" from "the window is closing". Deriving the pane from it shipped a window that painted a live spinner forever after a failed launch restore, could not be repainted by a later successful connect, and left no route back to the connection list except the Dock icon's context menu. `ConnectionWorkspace` owns the `phase`, one per connection the window hosts; `ConnectionWindowPhaseMachine` owns the transitions (pure, exhaustive, `.closing` absorbing), and `ConnectionWindowPaneResolver` owns the pane choice (pure). `MainSplitViewController` renders the selected workspace and routes a transition by `connectionId` through `transition(to:for:)`; it is only an adapter, and its `phase` property is a pass-through to `workspaces.selected`. Three rules follow. First, every phase must have an exit: the old `closingSessionId` latch was set once and never cleared, so the controller went permanently deaf to `connectionStatusChanged`. Second, a cancel updates the UI synchronously with the button press and never waits on the driver, because `Task.cancel()` is cooperative and may have no observable effect; the attempt is fenced by a per-workspace `attemptToken` (`ConnectionWorkspace.attemptToken`) plus `DatabaseManager.invalidateConnectionAttempt`, so a late failure cannot write into a workspace that moved on. The token cannot live on the window, because the window did not move on: one of the connections it hosts did. Closing a window therefore cancels the in-flight attempt of every workspace it hosts, not just the one its original payload named, and a completion that finds its workspace gone discards itself rather than resurrecting it. Third, a failure is presented inline through `ConnectionUnavailableView`, never as an alert, per the HIG's rule against alerts at startup and its one-alert-at-a-time rule (N restored connections would mean N modals). Only one presenter per failure: `LaunchIntentRouter.presentError` stays silent when a window for that connection exists. **The app runs the AppKit lifecycle, and AppKit owns the menu bar**: `main.swift` assigns the delegate before `NSApplicationMain`, and `MainMenuBuilder.install` runs in `applicationWillFinishLaunching`. Do not reintroduce a SwiftUI `App`. SwiftUI reconciles `NSApp.mainMenu` once shortly after launch and removes every item it did not build itself, and no hook can undo it: `NSApp.mainMenu` is not KVO-compliant, `didUpdateNotification`, `didBecomeKeyNotification` and the `applicationDidUpdate(_:)` delegate method never fire under `@NSApplicationDelegateAdaptor`, and `applicationDidBecomeActive` fires before the reconciliation. Only a wall-clock delay worked, which is why #2057 shipped a menu bar that vanished half a second after launch and had to be reverted (#2071). Every window is an `NSWindowController`; the Welcome window is one too, so closing it is an ordinary `close()` and the old "closed, never ordered out" rule no longer applies. From 2d2a8471a0058deaa16ae3fb0ff00b788d2eab63 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 13 Aug 2026 17:34:16 +0700 Subject: [PATCH 21/47] docs(plans): record what the workspace scoping refactor has landed --- .../plan.md | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/plans/20260813-single-window-workspace-scoping/plan.md b/plans/20260813-single-window-workspace-scoping/plan.md index 46b0a3812..084470218 100644 --- a/plans/20260813-single-window-workspace-scoping/plan.md +++ b/plans/20260813-single-window-workspace-scoping/plan.md @@ -1,3 +1,34 @@ +# STATUS (updated 2026-08-13) + +Branch `single-window-connections`, PR #2097. + +**Done and pushed** (all four P0 data-loss bugs are closed): + +- B1 restore returns one ordered tab list; window-group split deleted +- B2 `windowGroupIndex` removed from the model, save path and restore result +- B3 write gate: no save until a restore has read the disk; gate opens even on an empty read +- B4 window close saves and tears down every hosted workspace +- B7 per-connection undo via `TabWindowController.windowWillReturnUndoManager` +- B9 sample failure closes one connection, B10 Cmd+W dismisses a failed pane, B11 vim `:q` closes a tab +- B12 `coordinator(forWindow:)` returns the selected workspace's coordinator +- B13 `reconnectWorkspace(_:)` reconnects the connection named, not the selected one +- B5 detail pane carries `.id(connection.id)` +- B14 toolbar is rebuilt on a workspace switch (the `ToolbarContext` refinement below is still the better shape) +- Deleted: `WindowGroupAssignment`, `RestoreWindowPlan`, `WindowTabGroupOrder` and their four suites +- CLAUDE.md invariants corrected for the workspace model + +**Not done** + +- `WorkspaceLocator` (steps 3-4) and the deletion of `WindowLifecycleMonitor` (step 13, 12+ consumers across 6 files) +- B6 change-manager identity, B8 unsaved-work prompt across workspaces, B18 key-window broadcasts, B19 host selection, B20 row eviction +- Steps 8, 11, 12, 15: visibility over key-window, rail reads its host, Reopen Closed Tab adopts directly, MCP wire contract +- `ToolbarContext` observable box, replacing the toolbar rebuild +- **UI automation asserting two connections share one window.** This is the largest remaining gap: every test on this branch is a unit test, and two shipped bugs (a second table not opening, closed tabs returning after reconnect) were found by hand, not by the suite. + +**Note for whoever continues:** another session has been editing `MainSplitViewController`, `MainWindowToolbar*`, `EditorTabStrip` and `MainContentCommandActions+BulkClose` in the same working tree. Check `git status` before assuming an uncommitted change is yours. + +--- + # THE SINGLE-WINDOW REFACTOR: ORDERED EXECUTION PLAN ## 1. THE ONE SENTENCE From 65b5ac8a2b7112760e650ea634ab2fff248cfb61 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 13 Aug 2026 17:36:43 +0700 Subject: [PATCH 22/47] test(tabs): assert the single-window menu contract --- .../SingleWindowMenuContractUITests.swift | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 TableProUITests/SingleWindowMenuContractUITests.swift diff --git a/TableProUITests/SingleWindowMenuContractUITests.swift b/TableProUITests/SingleWindowMenuContractUITests.swift new file mode 100644 index 000000000..1d37d21df --- /dev/null +++ b/TableProUITests/SingleWindowMenuContractUITests.swift @@ -0,0 +1,69 @@ +import XCTest + +/// The single-window model moved the editor tab commands off AppKit's own window-tab selectors +/// onto the app's tab list, and added the Window menu item the HIG requires once app windows +/// share a tabbing identifier. These are the parts of that contract a UI test can assert without +/// a live database. +final class SingleWindowMenuContractUITests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + override func tearDownWithError() throws { + XCUIApplication().terminate() + } + + private func launchApp() -> XCUIApplication { + let app = XCUIApplication() + app.launchEnvironment["TABLEPRO_UI_TESTING"] = "1" + app.launch() + XCTAssertTrue(app.windows.firstMatch.waitForExistence(timeout: 10)) + return app + } + + /// Every app window now shares one tabbing identifier, so Merge All Windows is the documented + /// route back to a single window. It was the one standard tab item the Window menu omitted. + func testWindowMenuOffersMergeAllWindows() throws { + let app = launchApp() + + let mergeAll = app.menuBars.menuItems["Merge All Windows"] + XCTAssertTrue( + mergeAll.waitForExistence(timeout: 5), + "Window menu must offer Merge All Windows once app windows share a tabbing identifier" + ) + } + + func testWindowMenuKeepsTheStandardTabCommands() throws { + let app = launchApp() + + for title in ["Show Previous Tab", "Show Next Tab", "Move Tab to New Window"] { + XCTAssertTrue( + app.menuBars.menuItems[title].waitForExistence(timeout: 5), + "Window menu must keep the standard tab command \(title)" + ) + } + } + + func testFileMenuOffersTheEditorTabCommands() throws { + let app = launchApp() + + for title in ["New Tab", "Close Tab"] { + XCTAssertTrue( + app.menuBars.menuItems[title].waitForExistence(timeout: 5), + "File menu must offer \(title), which now acts on the editor tab list" + ) + } + } + + /// Launching shows the welcome window and nothing else. A second main window appearing here + /// is the shape of the bug the single-window model exists to prevent. + func testLaunchOpensExactlyOneWindow() throws { + let app = launchApp() + + XCTAssertEqual( + app.windows.count, + 1, + "Launch must open exactly one window, not one per restored connection" + ) + } +} From 869074b512a2c6d598712f1a9b0370069bc79535 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 13 Aug 2026 17:38:43 +0700 Subject: [PATCH 23/47] fix(coordinator): run a broadcast command only in the connection on screen --- .../Views/Main/MainContentCommandActions.swift | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 75241caf2..ce4f11f6b 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -117,10 +117,13 @@ final class MainContentCommandActions { notificationTasks.append(task) } - /// Returns true if this instance's window is the current key window. - private func isKeyWindow() -> Bool { - guard let window = self.window else { return false } - return window.isKeyWindow + /// The window being key is no longer enough: every connection it hosts shares that window, so + /// a broadcast gated on it alone ran once per connection and opened a file in all of them. + /// Only the connection on screen answers. + private func isVisibleInKeyWindow() -> Bool { + guard let window = self.window, window.isKeyWindow else { return false } + guard let host = window.contentViewController as? MainSplitViewController else { return true } + return host.workspaces.selected?.sessionState?.coordinator === coordinator } /// Like `observe(_:handler:)` but only runs the handler when this instance's window is key. @@ -129,7 +132,7 @@ final class MainContentCommandActions { handler: @escaping @MainActor (Notification) -> Void ) { observe(name) { [weak self] notification in - guard self?.isKeyWindow() == true else { return } + guard self?.isVisibleInKeyWindow() == true else { return } handler(notification) } } @@ -142,7 +145,7 @@ final class MainContentCommandActions { publisher .receive(on: RunLoop.main) .sink { [weak self] payload in - guard self?.isKeyWindow() == true else { return } + guard self?.isVisibleInKeyWindow() == true else { return } handler(payload) } .store(in: &eventCancellables) From 190204c18bef2d9a9aba36f2ce1043b9d80b5d96 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 13 Aug 2026 17:39:46 +0700 Subject: [PATCH 24/47] fix(connections): open into the window that already hosts the connection --- .../Infrastructure/WindowManager.swift | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/TablePro/Core/Services/Infrastructure/WindowManager.swift b/TablePro/Core/Services/Infrastructure/WindowManager.swift index 7832a9725..548d3bd80 100644 --- a/TablePro/Core/Services/Infrastructure/WindowManager.swift +++ b/TablePro/Core/Services/Infrastructure/WindowManager.swift @@ -23,7 +23,7 @@ internal final class WindowManager { /// One window hosts every connection, so an open reuses the window that already exists and /// only adds a workspace to it. A second window is created solely when there is none. internal func openTab(payload: EditorTabPayload, activate: Bool = true, autoConnect: Bool = false) { - if let host = frontmostHost() { + if let host = host(for: payload.connectionId) { /// A connection the window already hosts still has to honour the payload, because a /// payload names a tab to open, not just a connection to show. Adopting the /// workspace alone would silently drop the table the caller asked for. @@ -45,16 +45,24 @@ internal final class WindowManager { openInNewWindow(payload: payload, activate: activate, autoConnect: autoConnect) } - /// The window the user is looking at, falling back to any main window so a background open - /// still lands somewhere rather than spawning a second one. + /// A host is any visible window whose content controller can hold workspaces. Selecting by the + /// `main-` identifier prefix also matched the inspector window, whose controller is a different + /// type, so the cast failed and an inspector in front made every open create a second window. private func frontmostHost() -> MainSplitViewController? { - if let key = NSApp.keyWindow, Self.isMainWindow(key), key.isVisible, + if let key = NSApp.keyWindow, key.isVisible, let host = key.contentViewController as? MainSplitViewController { return host } return NSApp.windows - .first { Self.isMainWindow($0) && $0.isVisible }? - .contentViewController as? MainSplitViewController + .filter(\.isVisible) + .compactMap { $0.contentViewController as? MainSplitViewController } + .first + } + + /// The window already hosting this connection wins, so opening a table for it never lands in + /// a different window that merely happened to be in front. + private func host(for connectionId: UUID) -> MainSplitViewController? { + hosts().first { $0.workspaces.contains(connectionId) } ?? frontmostHost() } private func openInNewWindow(payload: EditorTabPayload, activate: Bool, autoConnect: Bool) { From 5a3e1aa25e48f192e2a2c83f621df97dfcee961b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 13 Aug 2026 17:43:21 +0700 Subject: [PATCH 25/47] fix(tabs): prompt for unsaved work in every connection the window closes --- .../MainContentCommandActions+BulkClose.swift | 21 ++++++++++++------- .../Main/MainContentCommandActions.swift | 14 +++++++++++++ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/TablePro/Views/Main/MainContentCommandActions+BulkClose.swift b/TablePro/Views/Main/MainContentCommandActions+BulkClose.swift index 343ca6340..207994ee3 100644 --- a/TablePro/Views/Main/MainContentCommandActions+BulkClose.swift +++ b/TablePro/Views/Main/MainContentCommandActions+BulkClose.swift @@ -9,7 +9,9 @@ import Foundation extension MainContentCommandActions { enum BatchCloseKind: Equatable { case all - case others + /// Anchored explicitly, because a contextual menu acts on the tab under the pointer and + /// that is not always the selected one. + case others(anchor: UUID) case otherDatabases case container(String) } @@ -24,7 +26,12 @@ extension MainContentCommandActions { } func closeOtherTabs() { - Task { await runBatchClose(kind: .others) } + guard let anchor = coordinator?.tabManager.selectedTab?.id else { return } + closeOtherTabs(anchoredOn: anchor) + } + + func closeOtherTabs(anchoredOn anchor: UUID) { + Task { await runBatchClose(kind: .others(anchor: anchor)) } } func closeTabsForOtherDatabases() { @@ -36,7 +43,8 @@ extension MainContentCommandActions { } var canCloseOtherTabs: Bool { - !tabsToClose(kind: .others).isEmpty + guard let anchor = coordinator?.tabManager.selectedTab?.id else { return false } + return !tabsToClose(kind: .others(anchor: anchor)).isEmpty } var canCloseTabsForOtherDatabases: Bool { @@ -80,7 +88,7 @@ extension MainContentCommandActions { /// Unsaved work is tracked for the connection rather than per tab, so the question is asked /// once for the batch. func confirmDiscardingUnsavedWork() async -> Bool { - guard hasUnsavedWorkInWindow else { return true } + guard hasUnsavedWorkInConnection else { return true } switch await AlertHelper.confirmSaveChanges( message: String(localized: "Your changes will be lost if you don't save them."), @@ -104,9 +112,8 @@ extension MainContentCommandActions { switch kind { case .all: return tabs - case .others: - guard let selectedId = coordinator.tabManager.selectedTab?.id else { return [] } - return tabs.filter { $0.id != selectedId } + case .others(let anchor): + return tabs.filter { $0.id != anchor } case .otherDatabases: let current = browsedContainerName return tabs.filter { WorkspaceAnchoring.containerName(of: $0, target: target) != current } diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index ce4f11f6b..cf520f0af 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -367,7 +367,21 @@ final class MainContentCommandActions { /// Scoped to the whole window, not the selected tab: closing a window closes every tab in it, /// so a tab the user is not looking at must still get its prompt. + /// Every connection the window hosts, because closing the window closes all of them. Asking + /// only about the one on screen let a background connection's unsaved edits go without a + /// prompt, which is silent data loss rather than a missing confirmation. internal var hasUnsavedWorkInWindow: Bool { + guard let host = window?.contentViewController as? MainSplitViewController else { + return coordinator?.hasAnyUnsavedWork() ?? false + } + return host.workspaces.workspaces.contains { workspace in + workspace.sessionState?.coordinator.hasAnyUnsavedWork() == true + } + } + + /// This connection only. Closing its tabs says nothing about what another connection in the + /// same window has pending, so prompting about that would ask the wrong question. + internal var hasUnsavedWorkInConnection: Bool { coordinator?.hasAnyUnsavedWork() ?? false } From 2cae652624fb5171b32934d6d744f5a654328c68 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 13 Aug 2026 17:46:59 +0700 Subject: [PATCH 26/47] test(connections): cover the rule that decides which window hosts a connection --- .../Infrastructure/WindowHostSelection.swift | 28 +++++++ .../Infrastructure/WindowManager.swift | 16 +++- .../WindowHostSelectionTests.swift | 77 +++++++++++++++++++ 3 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 TablePro/Core/Services/Infrastructure/WindowHostSelection.swift create mode 100644 TableProTests/Core/Services/Infrastructure/WindowHostSelectionTests.swift diff --git a/TablePro/Core/Services/Infrastructure/WindowHostSelection.swift b/TablePro/Core/Services/Infrastructure/WindowHostSelection.swift new file mode 100644 index 000000000..6f163c168 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/WindowHostSelection.swift @@ -0,0 +1,28 @@ +// +// WindowHostSelection.swift +// TablePro +// + +import Foundation + +/// Which open window an incoming connection belongs in. Kept apart from `WindowManager` because +/// the rule is the single-window model's core promise and every way of getting it wrong shipped a +/// second window: preferring the front window over the one already hosting the connection, or +/// finding no host at all and creating one. +internal enum WindowHostSelection { + /// Indices are positions in the caller's list of candidate hosts, so this stays free of AppKit. + /// `nil` means there is no window to adopt into and one has to be created. + internal static func hostIndex( + forConnection connectionId: UUID, + hostedConnections: [[UUID]], + frontmostIndex: Int? + ) -> Int? { + if let owning = hostedConnections.firstIndex(where: { $0.contains(connectionId) }) { + return owning + } + guard let frontmostIndex, hostedConnections.indices.contains(frontmostIndex) else { + return hostedConnections.isEmpty ? nil : hostedConnections.startIndex + } + return frontmostIndex + } +} diff --git a/TablePro/Core/Services/Infrastructure/WindowManager.swift b/TablePro/Core/Services/Infrastructure/WindowManager.swift index 548d3bd80..2ddb8869e 100644 --- a/TablePro/Core/Services/Infrastructure/WindowManager.swift +++ b/TablePro/Core/Services/Infrastructure/WindowManager.swift @@ -60,9 +60,21 @@ internal final class WindowManager { } /// The window already hosting this connection wins, so opening a table for it never lands in - /// a different window that merely happened to be in front. + /// a different window that merely happened to be in front. The choice itself is + /// `WindowHostSelection`, which is pure and tested. private func host(for connectionId: UUID) -> MainSplitViewController? { - hosts().first { $0.workspaces.contains(connectionId) } ?? frontmostHost() + let candidates = hosts() + guard !candidates.isEmpty else { return nil } + let frontmost = frontmostHost() + let frontmostIndex = frontmost.flatMap { front in + candidates.firstIndex { $0 === front } + } + guard let index = WindowHostSelection.hostIndex( + forConnection: connectionId, + hostedConnections: candidates.map(\.workspaces.connectionIds), + frontmostIndex: frontmostIndex + ) else { return nil } + return candidates.indices.contains(index) ? candidates[index] : nil } private func openInNewWindow(payload: EditorTabPayload, activate: Bool, autoConnect: Bool) { diff --git a/TableProTests/Core/Services/Infrastructure/WindowHostSelectionTests.swift b/TableProTests/Core/Services/Infrastructure/WindowHostSelectionTests.swift new file mode 100644 index 000000000..4e347e930 --- /dev/null +++ b/TableProTests/Core/Services/Infrastructure/WindowHostSelectionTests.swift @@ -0,0 +1,77 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("Window host selection") +struct WindowHostSelectionTests { + private static let alpha = UUID() + private static let beta = UUID() + private static let gamma = UUID() + + /// The regression this guards: an inspector window in front made the host lookup fail, so + /// opening a connection created a second window instead of adopting into the one that had it. + @Test("A connection lands in the window already hosting it, whatever is in front") + func owningHostWins() { + let index = WindowHostSelection.hostIndex( + forConnection: Self.beta, + hostedConnections: [[Self.alpha], [Self.beta, Self.gamma]], + frontmostIndex: 0 + ) + #expect(index == 1) + } + + @Test("A new connection joins the frontmost window") + func newConnectionJoinsFrontmost() { + let index = WindowHostSelection.hostIndex( + forConnection: Self.gamma, + hostedConnections: [[Self.alpha], [Self.beta]], + frontmostIndex: 1 + ) + #expect(index == 1) + } + + /// A window has to be created only when there is genuinely none, which is what keeps the + /// single-window promise honest. + @Test("With no window open there is nothing to adopt into") + func noHostMeansCreate() { + let index = WindowHostSelection.hostIndex( + forConnection: Self.alpha, + hostedConnections: [], + frontmostIndex: nil + ) + #expect(index == nil) + } + + @Test("With no window in front the first one still takes it, rather than a new one opening") + func missingFrontmostFallsBackToFirst() { + let index = WindowHostSelection.hostIndex( + forConnection: Self.gamma, + hostedConnections: [[Self.alpha], [Self.beta]], + frontmostIndex: nil + ) + #expect(index == 0) + } + + @Test("A frontmost index outside the list does not select a window that is not there") + func outOfRangeFrontmostIsSafe() { + let index = WindowHostSelection.hostIndex( + forConnection: Self.gamma, + hostedConnections: [[Self.alpha]], + frontmostIndex: 7 + ) + #expect(index == 0) + } + + @Test("Opening a connection twice keeps landing in the same window") + func repeatedOpensAreStable() { + let hosted = [[Self.alpha], [Self.beta]] + let first = WindowHostSelection.hostIndex( + forConnection: Self.beta, hostedConnections: hosted, frontmostIndex: 0 + ) + let second = WindowHostSelection.hostIndex( + forConnection: Self.beta, hostedConnections: hosted, frontmostIndex: 0 + ) + #expect(first == second) + #expect(first == 1) + } +} From 555e0c8d29cb4d45ca4fd183e148ba25cffda8e2 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 13 Aug 2026 17:48:40 +0700 Subject: [PATCH 27/47] fix(sidebar): repaint the workspace rail when the window switches connection --- .../Services/Infrastructure/MainSplitViewController.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index eb1470946..d03948cef 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -441,6 +441,11 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi rebuildPanes() applyPaneChrome() applyWindowTitle() + + /// The rail redraws from `WorkspaceRailStore.changes`, which listens to session and tab + /// events. Switching workspace in place fires none of them, so without this the rail kept + /// highlighting the connection the user just switched away from. + AppEvents.shared.connectionWindowsChanged.send() } private func applyPhase() { From b786be14fe80e07a2a98fd5b98e1564b77e7970e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 13 Aug 2026 17:50:38 +0700 Subject: [PATCH 28/47] fix(coordinator): hand over key-window state when the window switches connection --- .../Infrastructure/MainSplitViewController.swift | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index d03948cef..0beca3996 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -106,6 +106,10 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi private var toolbarOwner: MainWindowToolbar? + /// The coordinator currently treated as this window's active one, so a workspace switch can + /// hand over key-window state the same way AppKit would between windows. + private weak var lastActiveCoordinator: MainContentCoordinator? + // MARK: - Observers private var connectionStatusCancellable: AnyCancellable? @@ -433,6 +437,17 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi /// Switching workspace repaints the window in place. The rail used to raise a different /// window instead, which is what made several connections mean several windows. internal func applySelectedWorkspace() { + /// Switching workspace is this window's key-window change as far as a coordinator is + /// concerned. Only the selected one receives the real `windowDidBecomeKey`, so without + /// this the outgoing connection keeps `isKeyWindow` true and never schedules the eviction + /// that frees its row buffers. + let incoming = workspaces.selected?.sessionState?.coordinator + if lastActiveCoordinator !== incoming { + lastActiveCoordinator?.handleWindowDidResignKey() + incoming?.handleWindowDidBecomeKey() + lastActiveCoordinator = incoming + } + if let coordinator = workspaces.selected?.sessionState?.coordinator { coordinator.inspectorProxy = self coordinator.splitViewController = self From 31b2cddfb3af4640cdb188e41e582c1eabe076d1 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 13 Aug 2026 17:53:04 +0700 Subject: [PATCH 29/47] docs(plans): bring the refactor status up to date --- .../plan.md | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/plans/20260813-single-window-workspace-scoping/plan.md b/plans/20260813-single-window-workspace-scoping/plan.md index 084470218..9a01cf83a 100644 --- a/plans/20260813-single-window-workspace-scoping/plan.md +++ b/plans/20260813-single-window-workspace-scoping/plan.md @@ -2,21 +2,32 @@ Branch `single-window-connections`, PR #2097. -**Done and pushed** (all four P0 data-loss bugs are closed): +**Done and pushed** (all four P0 data-loss bugs are closed, 15 bugs total): - B1 restore returns one ordered tab list; window-group split deleted - B2 `windowGroupIndex` removed from the model, save path and restore result -- B3 write gate: no save until a restore has read the disk; gate opens even on an empty read +- B3 write gate: no save until a restore has read the disk; the gate opens even on an empty read - B4 window close saves and tears down every hosted workspace +- B5 detail pane carries `.id(connection.id)` - B7 per-connection undo via `TabWindowController.windowWillReturnUndoManager` +- B8 the unsaved-work prompt covers every connection the window closes; tab close still asks only about its own - B9 sample failure closes one connection, B10 Cmd+W dismisses a failed pane, B11 vim `:q` closes a tab - B12 `coordinator(forWindow:)` returns the selected workspace's coordinator - B13 `reconnectWorkspace(_:)` reconnects the connection named, not the selected one -- B5 detail pane carries `.id(connection.id)` - B14 toolbar is rebuilt on a workspace switch (the `ToolbarContext` refinement below is still the better shape) +- B18 broadcast commands run only in the connection on screen +- B19 host selection prefers the window already hosting the connection, via the pure, tested `WindowHostSelection` +- B20 a workspace switch hands over key-window state, so a background connection's row buffers are evicted +- The workspace rail repaints on an in-place switch (`applySelectedWorkspace` publishes `connectionWindowsChanged`) - Deleted: `WindowGroupAssignment`, `RestoreWindowPlan`, `WindowTabGroupOrder` and their four suites - CLAUDE.md invariants corrected for the workspace model +**Watch for this shape.** Four of the bugs above were introduced by an earlier fix in this same +refactor, B20 by B12 most directly: narrowing `coordinator(forWindow:)` to the selected workspace +also stopped background workspaces from ever receiving `windowDidResignKey`. Changing what "window" +means moves consequences into places no test was watching. Trace the callers of anything you +re-scope, and do not trust a green suite here. + **Not done** - `WorkspaceLocator` (steps 3-4) and the deletion of `WindowLifecycleMonitor` (step 13, 12+ consumers across 6 files) From 6d797281f695f21a6851bfb51df782018ad074a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Thu, 13 Aug 2026 18:31:38 +0700 Subject: [PATCH 30/47] fix(hig): replace hand-rolled controls with native appkit and swiftui equivalents (#2104) Claude-Session: https://claude.ai/code/session_01A3rb597qZtq4h5xZxwg43W --- CHANGELOG.md | 43 ++- TablePro/Core/Diff/SplitDiffMarker.swift | 45 +++ TablePro/Core/Menu/WindowMenuBuilder.swift | 8 - .../Registry/PluginInstallTracker.swift | 11 + .../ExternalConnectionPrompting.swift | 27 +- .../Infrastructure/FileDropDestination.swift | 36 +++ .../MainSplitViewController.swift | 63 +++- .../MainWindowToolbar+Actions.swift | 2 +- .../MainWindowToolbar+Buttons.swift | 2 +- .../MainWindowToolbar+Delegate.swift | 49 ++- .../MainWindowToolbar+Items.swift | 17 + .../Infrastructure/MainWindowToolbar.swift | 1 + TablePro/Core/Utilities/UI/AlertHelper.swift | 88 ++++-- .../Utilities/UI/PairingApprovalGate.swift | 54 ++++ TablePro/Extensions/Color+Emphasis.swift | 40 +++ .../Database/TableOperationPrompt.swift | 87 ++++++ TablePro/Theme/MaterialAccessibility.swift | 4 +- TablePro/Theme/MotionAccessibility.swift | 51 +++ TablePro/Theme/ThemeSlotValidation.swift | 35 +++ TablePro/ViewModels/ERDiagramViewModel.swift | 4 +- TablePro/ViewModels/SidebarViewModel.swift | 5 + TablePro/ViewModels/WelcomeViewModel.swift | 4 +- .../AIChat/AIChatComposerImageChip.swift | 2 +- .../Views/AIChat/AIChatImageBlockView.swift | 2 +- TablePro/Views/AIChat/AIChatPanelView.swift | 2 +- .../AIChat/AIChatWalkthroughBlockView.swift | 17 +- .../Views/AIChat/ChatComposerTextView.swift | 20 +- .../Views/AIChat/ChatImageThumbnailView.swift | 2 +- .../AIChat/MentionSuggestionListView.swift | 5 +- .../Views/Components/ColorPaletteView.swift | 23 +- TablePro/Views/Components/DialogFooter.swift | 36 +++ TablePro/Views/Components/ProBadge.swift | 2 +- .../Views/Components/SectionHeaderView.swift | 107 ------- .../Components/TransferResultAlert.swift | 142 +++++++++ .../ConnectionExportOptionsSheet.swift | 3 +- .../Connection/ConnectionGroupPicker.swift | 71 ++--- .../Connection/ConnectionTagEditor.swift | 23 +- .../Connection/DeeplinkImportSheet.swift | 3 +- .../Views/Connection/HostListFieldRow.swift | 26 +- .../Connection/OnboardingContentView.swift | 2 +- TablePro/Views/Connection/TagFilterBar.swift | 40 +-- .../Connection/WelcomeWindowController.swift | 19 ++ .../Views/Connection/WelcomeWindowView.swift | 12 +- .../Components/PluginInstallStatusRow.swift | 46 ++- .../Panes/CloudSQLProxyPaneView.swift | 2 +- .../DatabaseSwitcherPopover.swift | 9 +- .../DatabaseSwitcherSheet.swift | 9 +- TablePro/Views/ERDiagram/ERDiagramView.swift | 16 +- .../Views/Editor/VimModeIndicatorView.swift | 8 +- TablePro/Views/Export/ExportDialog.swift | 93 +----- TablePro/Views/Export/ExportSuccessView.swift | 77 ----- .../Views/Filter/FilterValueTextField.swift | 27 +- TablePro/Views/Import/ImportDialog.swift | 23 +- TablePro/Views/Import/ImportErrorView.swift | 74 ----- TablePro/Views/Import/ImportSuccessView.swift | 110 ------- TablePro/Views/Import/RowImportSheet.swift | 38 ++- .../InspectorDeleteConfirmation.swift | 24 +- .../Inspector/InspectorViewController.swift | 11 +- TablePro/Views/Main/EditorTabStrip.swift | 87 ++++-- TablePro/Views/Main/MainContentView.swift | 29 +- .../QueryPlan/QueryPlanDiagramView.swift | 2 +- .../QuickSwitcherPanelView.swift | 43 ++- .../Results/Cells/DataGridCellView.swift | 4 + TablePro/Views/Results/DataGridRowView.swift | 14 +- .../Results/DateTimePickerContentView.swift | 4 +- .../Views/Results/KeyHandlingTableView.swift | 116 ++++--- .../Selection/GridSelectionController.swift | 3 + .../Views/Results/SortableHeaderCell.swift | 10 +- .../Views/Results/SortableHeaderView.swift | 40 +++ .../ServerDashboardSplitView.swift | 20 ++ TablePro/Views/Settings/AISettingsView.swift | 1 + .../Settings/Appearance/ThemeListView.swift | 27 +- .../Settings/AppearanceSettingsView.swift | 32 +- .../Views/Settings/KeyboardSettingsView.swift | 1 + .../Sections/MCPTokenCreateSheet.swift | 4 +- .../Sections/PairingApprovalSheet.swift | 4 +- TablePro/Views/Settings/SettingsView.swift | 74 ----- .../Settings/SettingsWindowController.swift | 17 +- .../Views/Settings/ShortcutRecorderView.swift | 105 +++++-- .../DatabaseTreeOutlineCoordinator.swift | 17 +- .../Views/Sidebar/DatabaseTreeRowView.swift | 17 +- .../Sidebar/DatabaseTreeTypeSelect.swift | 36 +++ TablePro/Views/Sidebar/FavoritesTabView.swift | 14 +- TablePro/Views/Sidebar/RedisKeyTreeView.swift | 25 ++ .../Views/Sidebar/SidebarContextMenu.swift | 13 +- .../Views/Sidebar/SidebarRowForeground.swift | 33 ++ TablePro/Views/Sidebar/SidebarTint.swift | 2 +- TablePro/Views/Sidebar/SidebarView.swift | 41 ++- .../Views/Sidebar/TableOperationAlert.swift | 109 +++++++ .../Views/Sidebar/TableOperationDialog.swift | 223 ------------- .../Structure/TableStructureView+Schema.swift | 5 + .../Toolbar/ConnectionSwitcherPopover.swift | 2 +- .../Views/Toolbar/TableProToolbarView.swift | 2 +- .../Core/Diff/SplitDiffMarkerTests.swift | 42 +++ .../Services/FileDropDestinationTests.swift | 51 +++ .../Services/PersistedTabRoundTripTests.swift | 3 +- .../AlertWindowResolutionTests.swift | 68 ++++ .../DestructiveAlertDefaultsTests.swift | 84 +++++ .../TableOperationDialogLogicTests.swift | 292 ------------------ .../Models/TableOperationPromptTests.swift | 196 ++++++++++++ .../Theme/LegibleForegroundTests.swift | 48 +++ .../Theme/MotionAccessibilityTests.swift | 35 +++ .../Theme/ThemeSlotValidationTests.swift | 87 ++++++ .../Views/DatabaseTreeTypeSelectTests.swift | 55 ++++ .../Views/SidebarContextMenuLogicTests.swift | 51 ++- .../Views/SidebarRowForegroundTests.swift | 35 +++ 106 files changed, 2501 insertions(+), 1524 deletions(-) create mode 100644 TablePro/Core/Diff/SplitDiffMarker.swift create mode 100644 TablePro/Core/Services/Infrastructure/FileDropDestination.swift create mode 100644 TablePro/Core/Utilities/UI/PairingApprovalGate.swift create mode 100644 TablePro/Extensions/Color+Emphasis.swift create mode 100644 TablePro/Models/Database/TableOperationPrompt.swift create mode 100644 TablePro/Theme/MotionAccessibility.swift create mode 100644 TablePro/Theme/ThemeSlotValidation.swift create mode 100644 TablePro/Views/Components/DialogFooter.swift delete mode 100644 TablePro/Views/Components/SectionHeaderView.swift create mode 100644 TablePro/Views/Components/TransferResultAlert.swift delete mode 100644 TablePro/Views/Export/ExportSuccessView.swift delete mode 100644 TablePro/Views/Import/ImportErrorView.swift delete mode 100644 TablePro/Views/Import/ImportSuccessView.swift create mode 100644 TablePro/Views/Sidebar/DatabaseTreeTypeSelect.swift create mode 100644 TablePro/Views/Sidebar/SidebarRowForeground.swift create mode 100644 TablePro/Views/Sidebar/TableOperationAlert.swift delete mode 100644 TablePro/Views/Sidebar/TableOperationDialog.swift create mode 100644 TableProTests/Core/Diff/SplitDiffMarkerTests.swift create mode 100644 TableProTests/Core/Services/FileDropDestinationTests.swift create mode 100644 TableProTests/Core/Utilities/AlertWindowResolutionTests.swift create mode 100644 TableProTests/Core/Utilities/DestructiveAlertDefaultsTests.swift delete mode 100644 TableProTests/Models/TableOperationDialogLogicTests.swift create mode 100644 TableProTests/Models/TableOperationPromptTests.swift create mode 100644 TableProTests/Theme/LegibleForegroundTests.swift create mode 100644 TableProTests/Theme/MotionAccessibilityTests.swift create mode 100644 TableProTests/Theme/ThemeSlotValidationTests.swift create mode 100644 TableProTests/Views/DatabaseTreeTypeSelectTests.swift create mode 100644 TableProTests/Views/SidebarRowForegroundTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 872de4588..a78011f17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,8 +46,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The MCP connection listing no longer names connections you set to AI Never. - Release and test workflows pin their third-party actions to an exact commit. -### Fixed +### Added +- Typing a name in the database tree jumps to the matching object. +- Editor tabs have a right-click menu for closing tabs. +- The Settings window can be resized. +- Dropping a SQL file on a connection window opens it. +- Redis key rows have a right-click menu for copying a key or namespace prefix. + +### Fixed + +- The drop and truncate confirmation is now a standard system alert with Cancel as the default button, so pressing Return no longer drops a table. +- Right-clicking a table in the sidebar acts on the row under the pointer instead of the previous selection. +- The external link prompt now defaults to Cancel, and deleting a column or row from the inspector no longer runs on Return or without asking. +- Theme, schema, and diagram exports report write failures instead of failing silently, and picking a folder that is already linked says so. +- Selected sidebar rows, quick switcher results, and the database switcher use the system foreground colour, so labels stay readable under any accent colour and with Increase Contrast on. +- Tag badges, the Pro badge, the Vim mode indicator, and the date cell picker pick a readable label colour instead of always using white. +- Cell range selection and the selected column header dim when the window loses focus, matching the rest of the system. +- Tab moves focus out of the data grid when no cell is active, and cell to cell tabbing skips hidden columns. +- The shortcut recorder captures combinations the menu bar already uses, such as Command W, instead of running the menu command. +- Reduce Motion suppresses the remaining animations, and Reduce Transparency now makes the Pro feature overlay fully opaque. +- Editor tabs, quick switcher results, mention suggestions, and the Settings pickers report themselves properly to VoiceOver. +- Moving the pointer no longer changes the highlighted mention suggestion. +- Dialog buttons are grouped at the trailing edge in six sheets instead of splitting Cancel to the left. +- The titlebar breaks at the inspector divider, so the inspector toggle sits over the inspector pane. +- Removed two Window menu items that could never run. +- Main window toolbar buttons are real toolbar items, so icon only mode, display mode customization, and the overflow menu all work. +- The editor tab strip sits in the window titlebar instead of inside the content area. +- Find in the Welcome window runs from the Edit menu and can be rebound in Settings. +- The export dialog no longer asks for a file name twice, and the save panel validates it. +- Import and export results use standard alerts that size to their content. +- The integration pairing prompt is modal, can be closed from its title bar, and sizes to its content. +- Installing a missing database plugin shows download progress instead of nothing. +- A dark theme can no longer be assigned to the light appearance slot. +- Connection tags can be removed from the chip itself and are readable by VoiceOver. +- The connection group selector is a standard pop up button. +- The host list add and remove buttons match the rest of the app and have accessible names. +- VoiceOver follows the data grid cell cursor and reports the selected cell range. +- The filter suggestion list uses the system selection colours and announces when suggestions appear. +- Split view diffs mark added, removed, and changed lines for VoiceOver and for Differentiate Without Colour. +- The query plan diagram redraws after a second EXPLAIN instead of showing the previous run. +- The chat composer placeholder truncates instead of overflowing and is reported to VoiceOver. +- Colour swatches show press, hover, and keyboard focus, and the selection ring follows the accent colour. +- The split column alert grows to fit longer labels in other languages. - MQL export writes a MongoDB `_id` as `ObjectId("...")` and a date as `ISODate("...")`, so running the script inserts the same types back instead of strings. A value nested inside a subdocument is still exported as a string. - MQL export keeps a binary value's BSON subtype and writes it as a `BinData(...)` constructor, so running the script inserts the same bytes back. It wrote Extended JSON that mongosh reads as a plain object, and stamped every value as subtype 0. (#2086) - MongoDB no longer prints a binary field nested inside a document as a UUID when it is not one, or labels a UUID with the wrong byte order. (#2086) diff --git a/TablePro/Core/Diff/SplitDiffMarker.swift b/TablePro/Core/Diff/SplitDiffMarker.swift new file mode 100644 index 000000000..1ea0f299a --- /dev/null +++ b/TablePro/Core/Diff/SplitDiffMarker.swift @@ -0,0 +1,45 @@ +// +// SplitDiffMarker.swift +// TablePro +// + +import Foundation + +/// A split diff carries the change kind in the row tint alone, which says nothing to VoiceOver and +/// nothing to a reader who has turned on Differentiate Without Colour. `changed` exists only on the +/// split side and has no unified counterpart, so it gets its own marker rather than being folded +/// into added or removed. +internal enum SplitDiffMarker: Equatable { + case added + case removed + case changed + + internal var glyph: String { + switch self { + case .added: return "+" + case .removed: return "-" + case .changed: return "~" + } + } + + internal var label: String { + switch self { + case .added: return String(localized: "Added") + case .removed: return String(localized: "Removed") + case .changed: return String(localized: "Changed") + } + } + + internal static var unchangedLabel: String { + String(localized: "Unchanged") + } + + internal static func resolve(kind: DiffPair.Kind, side: SqlWalkthroughAnchor.Side) -> SplitDiffMarker? { + switch (kind, side) { + case (.removed, .before): return .removed + case (.added, .after): return .added + case (.changed, .before), (.changed, .after): return .changed + default: return nil + } + } +} diff --git a/TablePro/Core/Menu/WindowMenuBuilder.swift b/TablePro/Core/Menu/WindowMenuBuilder.swift index 63c058fac..574d19328 100644 --- a/TablePro/Core/Menu/WindowMenuBuilder.swift +++ b/TablePro/Core/Menu/WindowMenuBuilder.swift @@ -37,14 +37,6 @@ enum WindowMenuBuilder { shortcut: .showNextTab, keyboard: keyboard ), - MenuItemFactory.item( - String(localized: "Move Tab to New Window"), - action: #selector(NSWindow.moveTabToNewWindow(_:)) - ), - MenuItemFactory.item( - String(localized: "Merge All Windows"), - action: #selector(NSWindow.mergeAllWindows(_:)) - ), MenuItemFactory.separator ] diff --git a/TablePro/Core/Plugins/Registry/PluginInstallTracker.swift b/TablePro/Core/Plugins/Registry/PluginInstallTracker.swift index 6663c3a82..afbb1c57c 100644 --- a/TablePro/Core/Plugins/Registry/PluginInstallTracker.swift +++ b/TablePro/Core/Plugins/Registry/PluginInstallTracker.swift @@ -57,6 +57,17 @@ final class PluginInstallTracker { activeInstalls.removeValue(forKey: pluginId) } + /// A connection knows its `DatabaseType`, not the registry plugin id, so without this the + /// form has no way to reach the progress the installer is already publishing. + func state(forDatabaseType type: DatabaseType) -> InstallProgress? { + let pluginTypeId = type.pluginTypeId + guard let plugin = PluginManager.registryPlugin( + forTypeId: pluginTypeId, + in: RegistryClient.shared.manifest + ) else { return nil } + return activeInstalls[plugin.id] + } + func state(for pluginId: String) -> InstallProgress? { activeInstalls[pluginId] } diff --git a/TablePro/Core/Services/Infrastructure/ExternalConnectionPrompting.swift b/TablePro/Core/Services/Infrastructure/ExternalConnectionPrompting.swift index e1d625b2e..0cc4781f0 100644 --- a/TablePro/Core/Services/Infrastructure/ExternalConnectionPrompting.swift +++ b/TablePro/Core/Services/Infrastructure/ExternalConnectionPrompting.swift @@ -23,6 +23,18 @@ internal struct ExternalConnectionAlertPrompt: ExternalConnectionPrompting { for connection: DatabaseConnection, offerAlwaysAllow: Bool ) async -> ExternalConnectionDecision { + let response = await present(Self.makeAlert(for: connection, offerAlwaysAllow: offerAlwaysAllow)) + switch response { + case .alertFirstButtonReturn: + return .connect + case .alertThirdButtonReturn where offerAlwaysAllow: + return .alwaysAllow + default: + return .cancel + } + } + + internal static func makeAlert(for connection: DatabaseConnection, offerAlwaysAllow: Bool) -> NSAlert { let alert = NSAlert() alert.messageText = String(localized: "Open External Database Connection?") alert.informativeText = String( @@ -43,20 +55,11 @@ internal struct ExternalConnectionAlertPrompt: ExternalConnectionPrompting { alert.addButton(withTitle: String(localized: "Always Allow")) } alert.buttons[0].keyEquivalent = "" - alert.buttons[1].keyEquivalent = "\u{1b}" - - let response = await present(alert) - switch response { - case .alertFirstButtonReturn: - return .connect - case .alertThirdButtonReturn where offerAlwaysAllow: - return .alwaysAllow - default: - return .cancel - } + alert.buttons[1].keyEquivalent = "\r" + return alert } - private func details(for connection: DatabaseConnection) -> [String] { + private static func details(for connection: DatabaseConnection) -> [String] { var details: [String] = [ String(format: String(localized: "Host: %@"), "\(connection.host):\(connection.port)") ] diff --git a/TablePro/Core/Services/Infrastructure/FileDropDestination.swift b/TablePro/Core/Services/Infrastructure/FileDropDestination.swift new file mode 100644 index 000000000..fdf4dbf64 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/FileDropDestination.swift @@ -0,0 +1,36 @@ +// +// FileDropDestination.swift +// TablePro +// + +import AppKit + +/// Registered on the window rather than its content view, so a text view or chat composer inside +/// keeps first claim on a drag and only what they refuse reaches here. +@MainActor +internal enum FileDropDestination { + internal static func register(on window: NSWindow) { + window.registerForDraggedTypes([.fileURL]) + } + + internal static func acceptedURLs(from pasteboard: NSPasteboard) -> [URL] { + let options: [NSPasteboard.ReadingOptionKey: Any] = [.urlReadingFileURLsOnly: true] + guard let urls = pasteboard.readObjects(forClasses: [NSURL.self], options: options) as? [URL] else { + return [] + } + return urls.filter { isOpenable($0) } + } + + /// A file TablePro cannot open must refuse the drag outright. Accepting it and then failing + /// silently is worse than showing no drop feedback at all. + internal static func isOpenable(_ url: URL) -> Bool { + guard url.isFileURL else { return false } + guard case .some(.success) = URLClassifier.classify(url) else { return false } + return true + } + + internal static func open(_ urls: [URL]) { + guard !urls.isEmpty else { return } + AppLaunchCoordinator.shared.handleOpenURLs(urls) + } +} diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index 0beca3996..66784335c 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -85,6 +85,8 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi private var navigationSidebar: NavigationSidebarViewController! private var detailHosting: NSHostingController! + private var tabStripHosting: NSHostingController? + private var tabStripAccessory: NSTitlebarAccessoryViewController? private var inspectorHosting: NSHostingController! private var chromeState: ChromeState = .unapplied @@ -536,6 +538,61 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi } detailHosting.rootView = AnyView(buildDetailView()) inspectorHosting.rootView = AnyView(buildInspectorView()) + refreshTabStripAccessory() + } + + /// The tab strip belongs in the titlebar, where every document app puts its tabs, not stacked + /// inside the content area. The accessory is per-window while the strip is per-workspace, so + /// switching workspace has to repoint it rather than build a second one. + private func installTabStripAccessoryIfNeeded() { + guard tabStripAccessory == nil, let window = view.window else { return } + let hosting = NSHostingController(rootView: AnyView(Color.clear)) + hosting.sizingOptions = [] + hosting.view.frame = NSRect( + x: 0, + y: 0, + width: window.frame.width, + height: EditorTabStripLayout.totalHeight + ) + + let accessory = NSTitlebarAccessoryViewController() + accessory.addChild(hosting) + accessory.view = hosting.view + accessory.layoutAttribute = .bottom + /// Defaults to true, and means "use the standard system sizing over the view's frame", + /// which is the opposite of what a fixed-height strip wants. + accessory.automaticallyAdjustsSize = false + accessory.fullScreenMinHeight = 0 + accessory.isHidden = true + window.addTitlebarAccessoryViewController(accessory) + + tabStripHosting = hosting + tabStripAccessory = accessory + refreshTabStripAccessory() + } + + private func refreshTabStripAccessory() { + installTabStripAccessoryIfNeeded() + guard let accessory = tabStripAccessory, let hosting = tabStripHosting else { return } + guard currentPane == .content, let sessionState, sessionState.tabManager.tabs.count > 1 else { + accessory.isHidden = true + hosting.rootView = AnyView(Color.clear) + recomputeWindowMinSize() + return + } + hosting.rootView = AnyView(buildTabStripView(sessionState: sessionState)) + accessory.isHidden = false + recomputeWindowMinSize() + } + + private func buildTabStripView(sessionState: SessionStateFactory.SessionState) -> some View { + EditorTabStrip( + tabManager: sessionState.tabManager, + onClose: { [weak self] id in self?.commandActions?.closeTab(id: id) }, + onCloseOthers: { [weak self] id in self?.commandActions?.closeOtherTabs(anchoredOn: id) }, + onCloseAll: { [weak self] in self?.commandActions?.closeAllTabs() }, + onNewTab: { [weak self] in self?.commandActions?.newTab() } + ) } /// The command surface every menu action forwards into. Menu items reach this @@ -900,7 +957,11 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi sidebarMinimum: sidebarSplitItem?.minimumThickness ?? Self.sidebarMinThickness, dividerThickness: splitView.dividerThickness ) - let newMinSize = NSSize(width: resolvedWidth, height: Self.baseWindowMinHeight) + let accessoryHeight = (tabStripAccessory?.isHidden ?? true) ? 0 : EditorTabStripLayout.totalHeight + let newMinSize = NSSize( + width: resolvedWidth, + height: Self.baseWindowMinHeight + accessoryHeight + ) guard window.minSize != newMinSize else { return } window.minSize = newMinSize diff --git a/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Actions.swift b/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Actions.swift index 468ca8bb1..8633bb347 100644 --- a/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Actions.swift +++ b/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Actions.swift @@ -28,7 +28,7 @@ extension MainWindowToolbar { } @objc func performNewTab(_ sender: Any?) { - NSApp.sendAction(#selector(NSWindow.newWindowForTab(_:)), to: nil, from: nil) + NSApp.sendAction(#selector(MainSplitViewController.newEditorTab(_:)), to: nil, from: nil) } @objc func performPreviewSQL(_ sender: Any?) { diff --git a/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Buttons.swift b/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Buttons.swift index 9329c5709..b2e66f17a 100644 --- a/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Buttons.swift +++ b/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Buttons.swift @@ -135,7 +135,7 @@ struct NewTabToolbarButton: View { var body: some View { let state = coordinator.toolbarState Button { - NSApp.sendAction(#selector(NSWindow.newWindowForTab(_:)), to: nil, from: nil) + NSApp.sendAction(#selector(MainSplitViewController.newEditorTab(_:)), to: nil, from: nil) } label: { Label("New Tab", systemImage: "plus.rectangle") } diff --git a/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Delegate.swift b/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Delegate.swift index 864f2d4b2..5fc5c22e2 100644 --- a/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Delegate.swift +++ b/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Delegate.swift @@ -37,7 +37,7 @@ extension MainWindowToolbar { case Self.principal: let item = hostingItem( id: itemIdentifier, - label: "", + label: String(localized: "Status"), symbol: nil, action: nil, keyEquivalent: "", @@ -50,46 +50,43 @@ extension MainWindowToolbar { ) ) item.visibilityPriority = .high + item.toolTip = String(localized: "Connection status") return item case Self.quickSwitcher: - return hostingItem( + return menuOnlyItem( id: itemIdentifier, label: String(localized: "Quick Switcher"), symbol: "magnifyingglass", action: #selector(performOpenQuickSwitcher(_:)), keyEquivalent: "o", - modifiers: [.command, .shift], - content: QuickSwitcherToolbarButton(coordinator: coordinator) + modifiers: [.command, .shift] ) case Self.newTab: - return hostingItem( + return menuOnlyItem( id: itemIdentifier, label: String(localized: "New Tab"), symbol: "plus.rectangle", action: #selector(performNewTab(_:)), keyEquivalent: "t", - modifiers: .command, - content: NewTabToolbarButton(coordinator: coordinator) + modifiers: .command ) case Self.previewSQL: - return hostingItem( + return menuOnlyItem( id: itemIdentifier, label: String(localized: "Preview"), symbol: "eye", action: #selector(performPreviewSQL(_:)), keyEquivalent: "p", - modifiers: [.command, .shift], - content: PreviewSQLToolbarButton(coordinator: coordinator) + modifiers: [.command, .shift] ) case Self.results: - return hostingItem( + return menuOnlyItem( id: itemIdentifier, label: String(localized: "Results"), symbol: "rectangle.bottomhalf.inset.filled", action: #selector(performToggleResults(_:)), keyEquivalent: "r", - modifiers: [.command, .option], - content: ResultsToolbarButton(coordinator: coordinator) + modifiers: [.command, .option] ) case Self.inspector: let item = NSToolbarItem(itemIdentifier: Self.inspector) @@ -97,44 +94,34 @@ extension MainWindowToolbar { item.paletteLabel = String(localized: "Inspector") return item case Self.dashboard: - return hostingItem( + return menuOnlyItem( id: itemIdentifier, label: String(localized: "Dashboard"), symbol: "gauge.with.dots.needle.33percent", action: #selector(performShowDashboard(_:)), keyEquivalent: "", - modifiers: [], - content: DashboardToolbarButton(coordinator: coordinator) + modifiers: [] ) case Self.history: - return hostingItem( + return menuOnlyItem( id: itemIdentifier, label: String(localized: "History"), symbol: "clock", action: #selector(performToggleHistory(_:)), keyEquivalent: "y", - modifiers: .command, - content: HistoryToolbarButton(coordinator: coordinator) + modifiers: .command ) case Self.refreshSaveGroup: - return makeGroup( + return makeNativeGroup( id: itemIdentifier, label: String(localized: "Refresh & Save"), - subitems: [subitemRefresh(), subitemSaveChanges()], - content: HStack(spacing: 4) { - RefreshToolbarButton(coordinator: coordinator) - SaveChangesToolbarButton(coordinator: coordinator) - } + subitems: [subitemRefresh(), subitemSaveChanges()] ) case Self.exportImportGroup: - return makeGroup( + return makeNativeGroup( id: itemIdentifier, label: String(localized: "Export & Import"), - subitems: [subitemExport(), subitemImport()], - content: HStack(spacing: 4) { - ExportToolbarButton(coordinator: coordinator) - ImportToolbarButton(coordinator: coordinator) - } + subitems: [subitemExport(), subitemImport()] ) default: return nil diff --git a/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Items.swift b/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Items.swift index 9d17313f9..80db1b291 100644 --- a/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Items.swift +++ b/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Items.swift @@ -151,7 +151,9 @@ extension MainWindowToolbar { item.target = self item.action = action item.autovalidates = true + item.isBordered = true item.image = NSImage(systemSymbolName: symbol, accessibilityDescription: label) + item.toolTip = label let menuItem = NSMenuItem(title: label, action: action, keyEquivalent: keyEquivalent) menuItem.keyEquivalentModifierMask = modifiers @@ -162,6 +164,21 @@ extension MainWindowToolbar { return item } + /// A group with real subitems and no `view` is drawn by AppKit itself, so it answers display + /// mode changes and collapses into the overflow menu. A hosted view can do neither. + func makeNativeGroup( + id: NSToolbarItem.Identifier, + label: String, + subitems: [NSToolbarItem] + ) -> NSToolbarItemGroup { + let group = NSToolbarItemGroup(itemIdentifier: id) + group.label = label + group.paletteLabel = label + group.controlRepresentation = .automatic + group.subitems = subitems + return group + } + func makeGroup( id: NSToolbarItem.Identifier, label: String, diff --git a/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift b/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift index 7d70f20cd..eef63b926 100644 --- a/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift +++ b/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift @@ -76,6 +76,7 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate { quickSwitcher, newTab, previewSQL, + .inspectorTrackingSeparator, inspector, ] diff --git a/TablePro/Core/Utilities/UI/AlertHelper.swift b/TablePro/Core/Utilities/UI/AlertHelper.swift index f54e09c89..483a16bae 100644 --- a/TablePro/Core/Utilities/UI/AlertHelper.swift +++ b/TablePro/Core/Utilities/UI/AlertHelper.swift @@ -32,6 +32,22 @@ final class AlertHelper { window ?? NSApp.keyWindow ?? NSApp.mainWindow ?? NSApp.windows.first { $0.isVisible } } + /// A sheet the user is meant to read against their work must land on a document window. + /// `resolveWindow`'s last resort accepts any visible window, which includes floating panels + /// such as the Quick Switcher, so a file error can end up attached to a panel that closes + /// the moment it loses focus. + static func resolveContentWindow(_ window: NSWindow?) -> NSWindow? { + if let window { return window } + if let candidate = [NSApp.keyWindow, NSApp.mainWindow].compactMap({ $0 }).first(where: isContentWindow) { + return candidate + } + return NSApp.windows.first { $0.isVisible && isContentWindow($0) } + } + + static func isContentWindow(_ window: NSWindow) -> Bool { + !(window is NSPanel) && window.styleMask.contains(.titled) + } + // MARK: - Destructive Confirmations static func confirmDestructive( @@ -100,45 +116,49 @@ final class AlertHelper { return alert.runModal() == .alertFirstButtonReturn } + /// Pairing is a security decision, so the attached case uses a critical sheet: it must not + /// queue behind whatever sheet the window is already showing. The detached case runs the modal + /// loop directly rather than inside a continuation-installing closure, which would block the + /// main actor while the continuation is still being installed. static func runPairingApproval(request: PairingRequest) async throws -> PairingApproval { - try await withCheckedThrowingContinuation { continuation in - var deliver: ((Result) -> Void)? - let codeExpiresAt = Date.now.addingTimeInterval(PairingExchangeStore.exchangeWindow) - let host = NSHostingController( - rootView: PairingApprovalSheet( - request: request, - codeExpiresAt: codeExpiresAt, - onComplete: { result in deliver?(result) } - ) + let codeExpiresAt = Date.now.addingTimeInterval(PairingExchangeStore.exchangeWindow) + let gate = PairingApprovalGate() + let host = NSHostingController( + rootView: PairingApprovalSheet( + request: request, + codeExpiresAt: codeExpiresAt, + onComplete: { result in gate.deliver(result) } ) - host.view.frame = NSRect(x: 0, y: 0, width: 520, height: 560) - - let parent = resolveWindow(nil) - let sheetWindow = NSWindow(contentViewController: host) - sheetWindow.styleMask = [.titled] - sheetWindow.title = String(localized: "Approve Integration") - sheetWindow.isReleasedWhenClosed = false - - var resolved = false - deliver = { result in - guard !resolved else { return } - resolved = true - if let parent { - parent.endSheet(sheetWindow) - } else { - sheetWindow.close() - } - continuation.resume(with: result) - } + ) + host.sizingOptions = [] + let fitted = host.sizeThatFits(in: NSSize(width: 520, height: CGFloat.greatestFiniteMagnitude)) + host.view.frame = NSRect(origin: .zero, size: fitted) + + let sheetWindow = NSWindow(contentViewController: host) + sheetWindow.styleMask = [.titled, .closable] + sheetWindow.title = String(localized: "Approve Integration") + sheetWindow.isReleasedWhenClosed = false - if let parent { - parent.beginSheet(sheetWindow, completionHandler: nil) - } else { - NSApp.activate(ignoringOtherApps: true) - sheetWindow.center() - sheetWindow.makeKeyAndOrderFront(nil) + guard let parent = resolveContentWindow(nil) else { + let delegate = PairingApprovalWindowDelegate(gate: gate) + sheetWindow.delegate = delegate + gate.onResolve = { [weak sheetWindow] in + NSApp.stopModal() + sheetWindow?.close() } + NSApp.activate(ignoringOtherApps: true) + sheetWindow.center() + defer { withExtendedLifetime(delegate) {} } + NSApp.runModal(for: sheetWindow) + return try gate.result() + } + + gate.onResolve = { [weak sheetWindow] in + guard let sheetWindow else { return } + parent.endSheet(sheetWindow) } + parent.beginCriticalSheet(sheetWindow, completionHandler: nil) + return try await gate.value() } // MARK: - Save Changes Confirmation diff --git a/TablePro/Core/Utilities/UI/PairingApprovalGate.swift b/TablePro/Core/Utilities/UI/PairingApprovalGate.swift new file mode 100644 index 000000000..4c0dfd418 --- /dev/null +++ b/TablePro/Core/Utilities/UI/PairingApprovalGate.swift @@ -0,0 +1,54 @@ +// +// PairingApprovalGate.swift +// TablePro +// + +import AppKit + +/// A pairing prompt can be answered twice: the sheet's own button and the window closing both +/// arrive. Only the first counts, or a continuation resumes more than once and the process traps. +@MainActor +internal final class PairingApprovalGate { + internal var onResolve: (() -> Void)? + + private var outcome: Result? + private var waiter: CheckedContinuation? + + internal func deliver(_ result: Result) { + guard outcome == nil else { return } + outcome = result + onResolve?() + guard let waiter else { return } + self.waiter = nil + waiter.resume(with: result) + } + + internal func cancel() { + deliver(.failure(MCPDataLayerError.userCancelled)) + } + + internal func value() async throws -> PairingApproval { + if let outcome { return try outcome.get() } + return try await withCheckedThrowingContinuation { continuation in + waiter = continuation + } + } + + internal func result() throws -> PairingApproval { + guard let outcome else { throw MCPDataLayerError.userCancelled } + return try outcome.get() + } +} + +@MainActor +internal final class PairingApprovalWindowDelegate: NSObject, NSWindowDelegate { + private let gate: PairingApprovalGate + + internal init(gate: PairingApprovalGate) { + self.gate = gate + } + + internal func windowWillClose(_ notification: Notification) { + gate.cancel() + } +} diff --git a/TablePro/Extensions/Color+Emphasis.swift b/TablePro/Extensions/Color+Emphasis.swift new file mode 100644 index 000000000..f4ce5d738 --- /dev/null +++ b/TablePro/Extensions/Color+Emphasis.swift @@ -0,0 +1,40 @@ +// +// Color+Emphasis.swift +// TablePro +// + +import AppKit +import SwiftUI + +internal extension Color { + /// The foreground AppKit pairs with an emphasized selection fill. Hardcoding white instead + /// breaks the moment the user picks a light accent colour or turns on Increase Contrast, + /// because the fill follows both settings and a literal colour does not. + static let emphasizedSelectionLabel = Color(nsColor: .alternateSelectedControlTextColor) + + /// White is legible only on a dark fill. A user-picked tag colour, or a semantic colour such + /// as orange, can be light enough that white text disappears into it, so the label is derived + /// from the fill rather than assumed. + static func legibleForeground(on fill: Color) -> Color { + NSColor(fill).relativeLuminance > Self.darkLabelThreshold ? .black : .white + } + + /// Above this luminance a fill takes a dark label. The equal-contrast crossover is 0.179, but + /// that puts black on system blue and system red, which no Mac control does. This sits high + /// enough to keep white on the saturated hues and still flips on yellow, mint, teal and + /// orange, where white genuinely disappears. Every palette colour is held to 3:1 by test. + private static let darkLabelThreshold: CGFloat = 0.3 +} + +internal extension NSColor { + var relativeLuminance: CGFloat { + guard let rgb = usingColorSpace(.sRGB) else { return 0 } + return 0.2126 * Self.linearized(rgb.redComponent) + + 0.7152 * Self.linearized(rgb.greenComponent) + + 0.0722 * Self.linearized(rgb.blueComponent) + } + + private static func linearized(_ channel: CGFloat) -> CGFloat { + channel <= 0.03928 ? channel / 12.92 : pow((channel + 0.055) / 1.055, 2.4) + } +} diff --git a/TablePro/Models/Database/TableOperationPrompt.swift b/TablePro/Models/Database/TableOperationPrompt.swift new file mode 100644 index 000000000..99f458f83 --- /dev/null +++ b/TablePro/Models/Database/TableOperationPrompt.swift @@ -0,0 +1,87 @@ +// +// TableOperationPrompt.swift +// TablePro +// + +import Foundation + +internal struct TableOperationPrompt: Equatable { + internal let operationType: TableOperationType + internal let tableName: String + internal let tableCount: Int + internal let cascadeSupported: Bool + internal let foreignKeyDisableSupported: Bool + + internal var messageText: String { + switch operationType { + case .drop: + return tableCount > 1 + ? String(format: String(localized: "Drop %d tables"), tableCount) + : String(format: String(localized: "Drop table '%@'"), tableName) + case .truncate: + return tableCount > 1 + ? String(format: String(localized: "Truncate %d tables"), tableCount) + : String(format: String(localized: "Truncate table '%@'"), tableName) + } + } + + internal var informativeText: String { + guard tableCount > 1 else { return "" } + return String(localized: "Same options will be applied to all selected tables.") + } + + internal var confirmButtonTitle: String { + switch operationType { + case .drop: + return String(localized: "Drop") + case .truncate: + return String(localized: "Truncate") + } + } + + internal var cancelButtonTitle: String { + String(localized: "Cancel") + } + + internal var ignoreForeignKeysTitle: String { + String(localized: "Ignore foreign key checks") + } + + internal var isIgnoreForeignKeysEnabled: Bool { + foreignKeyDisableSupported + } + + internal var ignoreForeignKeysDescription: String? { + guard !foreignKeyDisableSupported else { return nil } + return cascadeSupported + ? String(localized: "Not supported for this database. Use CASCADE instead.") + : String(localized: "Not supported for this database.") + } + + internal var cascadeTitle: String { + String(localized: "Cascade") + } + + internal var isCascadeEnabled: Bool { + cascadeSupported + } + + internal var cascadeDescription: String { + switch operationType { + case .drop: + return String(localized: "Drop all tables that depend on this table") + case .truncate: + guard cascadeSupported else { + return String(localized: "Not supported for TRUNCATE with this database") + } + return String(localized: "Truncate all tables linked by foreign keys") + } + } + + internal func options(ignoreForeignKeys: Bool, cascade: Bool) -> TableOperationOptions { + TableOperationOptions( + ignoreForeignKeys: ignoreForeignKeys && isIgnoreForeignKeysEnabled, + cascade: cascade && isCascadeEnabled + ) + } +} diff --git a/TablePro/Theme/MaterialAccessibility.swift b/TablePro/Theme/MaterialAccessibility.swift index 036c58a19..e1dfec272 100644 --- a/TablePro/Theme/MaterialAccessibility.swift +++ b/TablePro/Theme/MaterialAccessibility.swift @@ -11,10 +11,8 @@ internal enum MaterialRole { switch self { case .banner, .toolbar, .inlineControl: Color(nsColor: .controlBackgroundColor) - case .sidebar: + case .sidebar, .scrim: Color(nsColor: .windowBackgroundColor) - case .scrim: - Color(nsColor: .windowBackgroundColor).opacity(0.95) } } } diff --git a/TablePro/Theme/MotionAccessibility.swift b/TablePro/Theme/MotionAccessibility.swift new file mode 100644 index 000000000..95b99b4b4 --- /dev/null +++ b/TablePro/Theme/MotionAccessibility.swift @@ -0,0 +1,51 @@ +// +// MotionAccessibility.swift +// TablePro +// + +import AppKit +import SwiftUI + +internal enum MotionAccessibility { + /// `withAnimation(nil)` still performs the change, it just does not animate it, so a gated + /// call site never needs a second branch for the reduced case. + internal static func animation(_ animation: Animation?, reduceMotion: Bool) -> Animation? { + reduceMotion ? nil : animation + } + + internal static var systemReduceMotion: Bool { + NSWorkspace.shared.accessibilityDisplayShouldReduceMotion + } +} + +private struct MotionAnimation: ViewModifier { + let animation: Animation? + let value: Value + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + func body(content: Content) -> some View { + content.animation( + MotionAccessibility.animation(animation, reduceMotion: reduceMotion), + value: value + ) + } +} + +internal extension View { + func motionAnimation(_ animation: Animation?, value: Value) -> some View { + modifier(MotionAnimation(animation: animation, value: value)) + } +} + +/// For imperative call sites, where there is no environment to read. `withAnimation` captures the +/// animation at the call, so the setting has to be read here rather than passed down. +internal func withMotion( + _ animation: Animation? = .default, + _ body: () throws -> Result +) rethrows -> Result { + try withAnimation( + MotionAccessibility.animation(animation, reduceMotion: MotionAccessibility.systemReduceMotion), + body + ) +} diff --git a/TablePro/Theme/ThemeSlotValidation.swift b/TablePro/Theme/ThemeSlotValidation.swift new file mode 100644 index 000000000..8b8d05b3b --- /dev/null +++ b/TablePro/Theme/ThemeSlotValidation.swift @@ -0,0 +1,35 @@ +// +// ThemeSlotValidation.swift +// TablePro +// + +import Foundation + +/// `ThemeDefinition.appearance` was declared but never read, so a dark theme could be assigned to +/// the light slot and the app would honour it. Filtering the list is only half the fix: a user +/// already holding a mismatched theme would find their current row missing, so the slot has to be +/// re-anchored to the matching default in the same change. +internal enum ThemeSlotValidation { + internal static func fits(_ appearance: ThemeAppearance, slot: ThemeAppearance) -> Bool { + appearance == .auto || appearance == slot + } + + internal static func eligibleThemes( + _ themes: [ThemeDefinition], + slot: ThemeAppearance + ) -> [ThemeDefinition] { + themes.filter { fits($0.appearance, slot: slot) } + } + + /// Returns the theme id the slot should hold. An id that no longer resolves, or one whose + /// appearance contradicts the slot, falls back to the slot's default. + internal static func resolvedThemeId( + current: String, + slot: ThemeAppearance, + themes: [ThemeDefinition], + defaultId: String + ) -> String { + guard let theme = themes.first(where: { $0.id == current }) else { return defaultId } + return fits(theme.appearance, slot: slot) ? current : defaultId + } +} diff --git a/TablePro/ViewModels/ERDiagramViewModel.swift b/TablePro/ViewModels/ERDiagramViewModel.swift index f866ac0ef..5fca5e825 100644 --- a/TablePro/ViewModels/ERDiagramViewModel.swift +++ b/TablePro/ViewModels/ERDiagramViewModel.swift @@ -501,7 +501,7 @@ final class ERDiagramViewModel { x: (center.x - canvasOffset.x) / magnification, y: (center.y - canvasOffset.y) / magnification ) - withAnimation(.easeOut(duration: 0.2)) { + withMotion(.easeOut(duration: 0.2)) { canvasOffset = CGPoint( x: center.x - canvasPoint.x * clamped, y: center.y - canvasPoint.y * clamped @@ -518,7 +518,7 @@ final class ERDiagramViewModel { let scaleY = (viewportSize.height - padding * 2) / diagramSize.height let fitScale = max(0.25, min(1.0, min(scaleX, scaleY))) - withAnimation(.easeOut(duration: 0.3)) { + withMotion(.easeOut(duration: 0.3)) { magnification = fitScale canvasOffset = CGPoint( x: (viewportSize.width - diagramSize.width * fitScale) / 2, diff --git a/TablePro/ViewModels/SidebarViewModel.swift b/TablePro/ViewModels/SidebarViewModel.swift index c2c4617aa..0d83eb89d 100644 --- a/TablePro/ViewModels/SidebarViewModel.swift +++ b/TablePro/ViewModels/SidebarViewModel.swift @@ -309,6 +309,11 @@ final class SidebarViewModel { } } + func cancelPendingOperation() { + pendingOperationType = nil + pendingOperationTables = [] + } + func confirmOperation(options: TableOperationOptions) { guard let operationType = pendingOperationType else { return } diff --git a/TablePro/ViewModels/WelcomeViewModel.swift b/TablePro/ViewModels/WelcomeViewModel.swift index 227814b97..1cc83a7b8 100644 --- a/TablePro/ViewModels/WelcomeViewModel.swift +++ b/TablePro/ViewModels/WelcomeViewModel.swift @@ -570,7 +570,7 @@ final class WelcomeViewModel { let connection = connections.first(where: { $0.id == id }), let groupId = connection.groupId, expandedGroupIds.contains(groupId) else { return } - withAnimation(.easeInOut(duration: 0.2)) { + withMotion(.easeInOut(duration: 0.2)) { expandedGroupIds.remove(groupId) } } @@ -580,7 +580,7 @@ final class WelcomeViewModel { let connection = connections.first(where: { $0.id == id }), let groupId = connection.groupId, !expandedGroupIds.contains(groupId) else { return } - withAnimation(.easeInOut(duration: 0.2)) { + withMotion(.easeInOut(duration: 0.2)) { expandedGroupIds.insert(groupId) } } diff --git a/TablePro/Views/AIChat/AIChatComposerImageChip.swift b/TablePro/Views/AIChat/AIChatComposerImageChip.swift index 431a6003d..ad64908e4 100644 --- a/TablePro/Views/AIChat/AIChatComposerImageChip.swift +++ b/TablePro/Views/AIChat/AIChatComposerImageChip.swift @@ -18,7 +18,7 @@ struct AIChatComposerImageChip: View { .clipShape(RoundedRectangle(cornerRadius: 6)) .overlay( RoundedRectangle(cornerRadius: 6) - .strokeBorder(Color.secondary.opacity(0.2), lineWidth: 1) + .strokeBorder(Color(nsColor: .separatorColor), lineWidth: 1) ) Button(action: onRemove) { diff --git a/TablePro/Views/AIChat/AIChatImageBlockView.swift b/TablePro/Views/AIChat/AIChatImageBlockView.swift index d3880f771..1b6af319b 100644 --- a/TablePro/Views/AIChat/AIChatImageBlockView.swift +++ b/TablePro/Views/AIChat/AIChatImageBlockView.swift @@ -16,7 +16,7 @@ struct AIChatImageBlockView: View { .clipShape(RoundedRectangle(cornerRadius: 6)) .overlay( RoundedRectangle(cornerRadius: 6) - .strokeBorder(Color.secondary.opacity(0.2), lineWidth: 1) + .strokeBorder(Color(nsColor: .separatorColor), lineWidth: 1) ) } } diff --git a/TablePro/Views/AIChat/AIChatPanelView.swift b/TablePro/Views/AIChat/AIChatPanelView.swift index 3eca88f80..356e209bc 100644 --- a/TablePro/Views/AIChat/AIChatPanelView.swift +++ b/TablePro/Views/AIChat/AIChatPanelView.swift @@ -160,7 +160,7 @@ struct AIChatPanelView: View { if isUserScrolledUp { Button { pinnedToBottom = true - withAnimation(.easeOut(duration: 0.2)) { + withMotion(.easeOut(duration: 0.2)) { bottomVisibleMessageID = lastMessageID } } label: { diff --git a/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift b/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift index a8c34b861..8e0c41471 100644 --- a/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift +++ b/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift @@ -197,20 +197,35 @@ struct AIChatWalkthroughBlockView: View { .frame(maxWidth: .infinity, alignment: .leading) } + @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor + private func splitRow(_ row: SplitRow, side: SqlWalkthroughAnchor.Side) -> some View { + let marker = SplitDiffMarker.resolve(kind: row.kind, side: side) let tint: Color? = switch (row.kind, side) { case (.removed, .before), (.changed, .before): .red.opacity(0.16) case (.added, .after), (.changed, .after): .green.opacity(0.16) default: nil } return HStack(alignment: .top, spacing: 6) { + if differentiateWithoutColor, let glyph = marker?.glyph { + Text(verbatim: glyph) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(width: 10, alignment: .leading) + } Text(row.text ?? " ") .font(.system(.body, design: .monospaced)) .frame(maxWidth: .infinity, alignment: .leading) } .padding(.horizontal, 8) .padding(.vertical, 1) - .background(splitRowBackground(base: tint, side: side, lineNumber: row.lineNumber)) + .background( + differentiateWithoutColor + ? splitRowBackground(base: nil, side: side, lineNumber: row.lineNumber) + : splitRowBackground(base: tint, side: side, lineNumber: row.lineNumber) + ) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(verbatim: "\(marker?.label ?? SplitDiffMarker.unchangedLabel): \(row.text ?? "")")) } private func sourceListing(_ presentation: SqlWalkthroughPresentation) -> some View { diff --git a/TablePro/Views/AIChat/ChatComposerTextView.swift b/TablePro/Views/AIChat/ChatComposerTextView.swift index e7443c8ba..b0a8ddbb4 100644 --- a/TablePro/Views/AIChat/ChatComposerTextView.swift +++ b/TablePro/Views/AIChat/ChatComposerTextView.swift @@ -75,6 +75,7 @@ struct ChatComposerTextView: NSViewRepresentable { if textView.placeholder != placeholder { textView.placeholder = placeholder + textView.setAccessibilityPlaceholderValue(placeholder) textView.needsDisplay = true } @@ -233,8 +234,23 @@ final class ChatComposerNSTextView: NSTextView { .font: font, .foregroundColor: placeholderColor ] - let origin = NSPoint(x: textContainerInset.width, y: textContainerInset.height) - (placeholder as NSString).draw(at: origin, withAttributes: attributes) + let paragraph = NSMutableParagraphStyle() + paragraph.alignment = alignment + paragraph.lineBreakMode = .byTruncatingTail + var truncating = attributes + truncating[.paragraphStyle] = paragraph + let available = NSRect( + x: textContainerInset.width, + y: textContainerInset.height, + width: max(bounds.width - textContainerInset.width * 2, 0), + height: font.boundingRectForFont.height + ) + (placeholder as NSString).draw( + with: available, + options: [.usesLineFragmentOrigin, .truncatesLastVisibleLine], + attributes: truncating, + context: nil + ) } override func paste(_ sender: Any?) { diff --git a/TablePro/Views/AIChat/ChatImageThumbnailView.swift b/TablePro/Views/AIChat/ChatImageThumbnailView.swift index cc6ee8c9f..5471419c9 100644 --- a/TablePro/Views/AIChat/ChatImageThumbnailView.swift +++ b/TablePro/Views/AIChat/ChatImageThumbnailView.swift @@ -39,6 +39,6 @@ struct ChatImageThumbnailView: View { Image(systemName: "photo") .foregroundStyle(.secondary) .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color.secondary.opacity(0.08)) + .background(Color(nsColor: .quaternarySystemFill)) } } diff --git a/TablePro/Views/AIChat/MentionSuggestionListView.swift b/TablePro/Views/AIChat/MentionSuggestionListView.swift index 4c708bcb1..43624286d 100644 --- a/TablePro/Views/AIChat/MentionSuggestionListView.swift +++ b/TablePro/Views/AIChat/MentionSuggestionListView.swift @@ -18,9 +18,8 @@ struct MentionSuggestionListView: View { ) .contentShape(Rectangle()) .onTapGesture { onSelect(index) } - .onHover { hovering in - if hovering { state.selectedIndex = index } - } + .accessibilityElement(children: .combine) + .accessibilityAddTraits(index == state.selectedIndex ? [.isButton, .isSelected] : .isButton) } } .padding(.vertical, 4) diff --git a/TablePro/Views/Components/ColorPaletteView.swift b/TablePro/Views/Components/ColorPaletteView.swift index 798a23306..d842e688c 100644 --- a/TablePro/Views/Components/ColorPaletteView.swift +++ b/TablePro/Views/Components/ColorPaletteView.swift @@ -40,7 +40,8 @@ struct ColorPaletteView: View { Button { selectedColor = color } label: { ColorSwatch(color: color, isSelected: isSelected, size: size) } - .buttonStyle(.plain) + .buttonStyle(ColorSwatchButtonStyle()) + .focusable() .accessibilityLabel(String(format: String(localized: "Color %@"), color.rawValue)) .accessibilityAddTraits(isSelected ? [.isSelected] : []) } @@ -48,6 +49,24 @@ struct ColorPaletteView: View { } } +/// A swatch is a control, so it has to answer the pointer and the click the way one does. A plain +/// button style suppresses every built-in state, which left these with no press and no hover. +private struct ColorSwatchButtonStyle: ButtonStyle { + @State private var isHovering = false + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.88 : 1) + .background( + Circle() + .fill(Color(nsColor: .quaternarySystemFill)) + .opacity(isHovering && !configuration.isPressed ? 1 : 0) + ) + .onHover { isHovering = $0 } + .motionAnimation(.easeOut(duration: 0.12), value: configuration.isPressed) + } +} + private struct ColorSwatch: View { let color: ConnectionColor let isSelected: Bool @@ -70,7 +89,7 @@ private struct ColorSwatch: View { if isSelected { Circle() - .stroke(Color.primary, lineWidth: 2) + .stroke(Color.accentColor, lineWidth: 2) .frame(width: size.selectionRingSize, height: size.selectionRingSize) } } diff --git a/TablePro/Views/Components/DialogFooter.swift b/TablePro/Views/Components/DialogFooter.swift new file mode 100644 index 000000000..35d910582 --- /dev/null +++ b/TablePro/Views/Components/DialogFooter.swift @@ -0,0 +1,36 @@ +// +// DialogFooter.swift +// TablePro +// + +import SwiftUI + +/// Dialog buttons belong together at the trailing edge with the default action last. Splitting +/// Cancel to the leading edge is the pattern macOS reserves for a button that is not part of the +/// dialog's decision, such as Help, so a Cancel parked there reads as unrelated to the sheet. +internal struct DialogFooter: View { + private let auxiliary: Auxiliary + private let actions: Actions + + internal init( + @ViewBuilder auxiliary: () -> Auxiliary, + @ViewBuilder actions: () -> Actions + ) { + self.auxiliary = auxiliary() + self.actions = actions() + } + + internal var body: some View { + HStack(spacing: 12) { + auxiliary + Spacer(minLength: 12) + actions + } + } +} + +internal extension DialogFooter where Auxiliary == EmptyView { + init(@ViewBuilder actions: () -> Actions) { + self.init(auxiliary: { EmptyView() }, actions: actions) + } +} diff --git a/TablePro/Views/Components/ProBadge.swift b/TablePro/Views/Components/ProBadge.swift index 004e9d341..9a5c698c2 100644 --- a/TablePro/Views/Components/ProBadge.swift +++ b/TablePro/Views/Components/ProBadge.swift @@ -9,7 +9,7 @@ struct ProBadge: View { var body: some View { Text("PRO") .font(.caption2.weight(.bold)) - .foregroundStyle(.white) + .foregroundStyle(Color.legibleForeground(on: .orange)) .padding(.horizontal, 6) .padding(.vertical, 2) .background(.orange, in: Capsule()) diff --git a/TablePro/Views/Components/SectionHeaderView.swift b/TablePro/Views/Components/SectionHeaderView.swift deleted file mode 100644 index bdf128a5e..000000000 --- a/TablePro/Views/Components/SectionHeaderView.swift +++ /dev/null @@ -1,107 +0,0 @@ -// -// SectionHeaderView.swift -// TablePro -// -// Reusable section header with collapse/expand, count, and action buttons. -// Provides consistent styling across the app. -// - -import SwiftUI - -struct SectionHeaderView: View { - let title: String - let icon: String? - let count: Int? - let isCollapsible: Bool - @Binding var isExpanded: Bool - let actions: () -> Actions - - init( - title: String, - icon: String? = nil, - count: Int? = nil, - isCollapsible: Bool = false, - isExpanded: Binding = .constant(true), - @ViewBuilder actions: @escaping () -> Actions = { EmptyView() } - ) { - self.title = title - self.icon = icon - self.count = count - self.isCollapsible = isCollapsible - self._isExpanded = isExpanded - self.actions = actions - } - - var body: some View { - if isCollapsible { - Button(action: { isExpanded.toggle() }) { - headerContent - } - .buttonStyle(.plain) - .accessibilityLabel(String(format: String(localized: "%@, %@"), title, isExpanded ? String(localized: "collapse") : String(localized: "expand"))) - } else { - headerContent - } - } - - private var headerContent: some View { - HStack(spacing: 8) { - if isCollapsible { - Image(systemName: "chevron.right") - .font(.caption.weight(.semibold)) - .foregroundStyle(ThemeEngine.shared.colors.ui.tertiaryTextSwiftUI) - .rotationEffect(.degrees(isExpanded ? 90 : 0)) - .animation(.easeInOut(duration: 0.15), value: isExpanded) - } - - if let icon = icon { - Image(systemName: icon) - .font(.body) - .foregroundStyle(ThemeEngine.shared.colors.ui.secondaryTextSwiftUI) - } - - Text(title) - .font(.title3.weight(.semibold)) - .foregroundStyle(ThemeEngine.shared.colors.ui.primaryTextSwiftUI) - - if let count = count { - Text("(\(count))") - .font(.subheadline) - .foregroundStyle(ThemeEngine.shared.colors.ui.tertiaryTextSwiftUI) - } - - Spacer() - - actions() - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background( - isCollapsible ? - ThemeEngine.shared.colors.ui.controlBackgroundSwiftUI.opacity(0.5) : - Color.clear - ) - .clipShape(RoundedRectangle(cornerRadius: 6)) - .contentShape(Rectangle()) - } -} - -// MARK: - Convenience Initializer (No Actions) - -extension SectionHeaderView where Actions == EmptyView { - init( - title: String, - icon: String? = nil, - count: Int? = nil, - isCollapsible: Bool = false, - isExpanded: Binding = .constant(true) - ) { - self.init( - title: title, - icon: icon, - count: count, - isCollapsible: isCollapsible, - isExpanded: isExpanded - ) { EmptyView() } - } -} diff --git a/TablePro/Views/Components/TransferResultAlert.swift b/TablePro/Views/Components/TransferResultAlert.swift new file mode 100644 index 000000000..6b5e423f3 --- /dev/null +++ b/TablePro/Views/Components/TransferResultAlert.swift @@ -0,0 +1,142 @@ +// +// TransferResultAlert.swift +// TablePro +// + +import AppKit +import TableProPluginKit + +/// Import and export results were three bespoke views with fixed widths and hand-picked green, +/// yellow and red badges. `NSAlert` supplies the icon from its style, sizes itself to its content, +/// and is the only one of the two that can carry a real suppression checkbox. +@MainActor +internal enum TransferResultAlert { + internal static let exportSuppressionKey = "hideExportSuccessDialog" + + internal enum ExportChoice { + case openFolder + case close + } + + internal static func presentExportSuccess( + window: NSWindow?, + completion: @escaping @MainActor (ExportChoice) -> Void + ) { + let alert = NSAlert() + alert.messageText = String(localized: "Export completed") + alert.alertStyle = .informational + alert.addButton(withTitle: String(localized: "Open in Finder")) + alert.addButton(withTitle: String(localized: "Done")) + alert.showsSuppressionButton = true + alert.suppressionButton?.title = String(localized: "Do not show this again") + + let deliver: @MainActor (NSApplication.ModalResponse) -> Void = { response in + if alert.suppressionButton?.state == .on { + UserDefaults.standard.set(true, forKey: exportSuppressionKey) + } + completion(response == .alertFirstButtonReturn ? .openFolder : .close) + } + + present(alert, in: window, deliver: deliver) + } + + internal static func presentImportSuccess( + result: PluginImportResult?, + window: NSWindow?, + completion: @escaping @MainActor () -> Void + ) { + let alert = NSAlert() + let skipped = result?.skippedStatements ?? 0 + alert.messageText = skipped > 0 + ? String(localized: "Import completed with errors") + : String(localized: "Import completed") + alert.alertStyle = skipped > 0 ? .warning : .informational + alert.informativeText = importSummary(result) + alert.addButton(withTitle: String(localized: "Done")) + + if let errors = result?.errors, !errors.isEmpty { + let lines = errors.map { failure in + String( + format: String(localized: "Line %1$lld: %2$@"), + Int64(failure.line), + failure.errorMessage + ) + } + alert.accessoryView = scrollingText(lines.joined(separator: "\n")) + alert.layout() + } + + present(alert, in: window) { _ in completion() } + } + + internal static func presentImportFailure( + error: (any Error)?, + window: NSWindow?, + completion: @escaping @MainActor () -> Void + ) { + let alert = NSAlert() + alert.messageText = String(localized: "Import failed") + alert.alertStyle = .critical + alert.addButton(withTitle: String(localized: "Done")) + + if let pluginError = error as? PluginImportError, + case .statementFailed(let statement, let line, let underlyingError) = pluginError { + alert.informativeText = String( + format: String(localized: "Failed at line %lld. %@"), + Int64(line), + underlyingError.localizedDescription + ) + alert.accessoryView = scrollingText(statement) + alert.layout() + } else { + alert.informativeText = error?.localizedDescription ?? String(localized: "Unknown error") + } + + present(alert, in: window) { _ in completion() } + } + + private static func importSummary(_ result: PluginImportResult?) -> String { + guard let result else { return "" } + let counts = result.skippedStatements > 0 + ? String( + format: String(localized: "%1$lld statements executed, %2$lld failed"), + Int64(result.executedStatements), + Int64(result.skippedStatements) + ) + : String( + format: String(localized: "%lld statements executed"), + Int64(result.executedStatements) + ) + let seconds = String( + format: String(localized: "%@ seconds"), + String(format: "%.2f", result.executionTime) + ) + return "\(counts)\n\(seconds)" + } + + private static func scrollingText(_ text: String) -> NSView { + let textView = NSTextView() + textView.string = text + textView.isEditable = false + textView.drawsBackground = false + textView.font = .monospacedSystemFont(ofSize: NSFont.smallSystemFontSize, weight: .regular) + + let scroll = NSScrollView(frame: NSRect(x: 0, y: 0, width: 380, height: 140)) + scroll.hasVerticalScroller = true + scroll.borderType = .bezelBorder + scroll.documentView = textView + return scroll + } + + private static func present( + _ alert: NSAlert, + in window: NSWindow?, + deliver: @escaping @MainActor (NSApplication.ModalResponse) -> Void + ) { + guard let parent = AlertHelper.resolveContentWindow(window) else { + deliver(alert.runModal()) + return + } + alert.beginSheetModal(for: parent, completionHandler: deliver) + } +} diff --git a/TablePro/Views/Connection/ConnectionExportOptionsSheet.swift b/TablePro/Views/Connection/ConnectionExportOptionsSheet.swift index afba59e60..492812e75 100644 --- a/TablePro/Views/Connection/ConnectionExportOptionsSheet.swift +++ b/TablePro/Views/Connection/ConnectionExportOptionsSheet.swift @@ -157,10 +157,9 @@ struct ConnectionExportOptionsSheet: View { } private var footer: some View { - HStack { + DialogFooter { Button("Cancel") { dismiss() } .keyboardShortcut(.cancelAction) - Spacer() Button("Export...") { performExport() } .buttonStyle(.borderedProminent) .keyboardShortcut(.defaultAction) diff --git a/TablePro/Views/Connection/ConnectionGroupPicker.swift b/TablePro/Views/Connection/ConnectionGroupPicker.swift index e2c47fe8c..09d10f4f3 100644 --- a/TablePro/Views/Connection/ConnectionGroupPicker.swift +++ b/TablePro/Views/Connection/ConnectionGroupPicker.swift @@ -20,49 +20,31 @@ struct ConnectionGroupPicker: View { return allGroups.first { $0.id == id } } + /// A pop up button carries the selected value, the checkmark and the menu role for free. + /// Hand-drawn checkmarks reported nothing to VoiceOver, and the indentation was a run of + /// spaces glued onto the name, which reads out loud and collapses in right-to-left. var body: some View { - Menu { - Button { - selectedGroupId = nil - } label: { - HStack { - Text("None") - if selectedGroupId == nil { - Spacer() - Image(systemName: "checkmark") - } - } + HStack(spacing: 6) { + Picker(String(localized: "Group"), selection: $selectedGroupId) { + Text("None").tag(UUID?.none) + Divider() + hierarchicalGroupItems() } - - Divider() - - hierarchicalGroupItems() - - Divider() + .pickerStyle(.menu) + .labelsHidden() + .fixedSize() + .accessibilityLabel(Text("Group")) Button { showingCreateSheet = true } label: { Label("Create New Group...", systemImage: "plus.circle") + .labelStyle(.iconOnly) } - } label: { - HStack(spacing: 6) { - if let group = selectedGroup { - if !group.color.isDefault { - Circle() - .fill(group.color.color) - .frame(width: 8, height: 8) - } - Text(group.name) - .foregroundStyle(.primary) - } else { - Text("None") - .foregroundStyle(.secondary) - } - } + .buttonStyle(.borderless) + .help(Text("Create New Group...")) + .accessibilityLabel(Text("Create New Group...")) } - .menuStyle(.borderlessButton) - .fixedSize() .task { allGroups = groupStorage.loadGroups() } .sheet(isPresented: $showingCreateSheet) { CreateGroupSheet { groupName, groupColor, parentId in @@ -78,23 +60,20 @@ struct ConnectionGroupPicker: View { private func hierarchicalGroupItems() -> some View { let flatGroups = flattenGroupsForMenu(groups: allGroups) ForEach(flatGroups, id: \.group.id) { entry in - Button { - selectedGroupId = entry.group.id - } label: { - HStack { - if !entry.group.color.isDefault { - Image(nsImage: colorDot(entry.group.color.color)) - } - Text(String(repeating: " ", count: entry.depth) + entry.group.name) - if selectedGroupId == entry.group.id { - Spacer() - Image(systemName: "checkmark") - } + Label { + Text(entry.group.name) + } icon: { + if !entry.group.color.isDefault { + Image(nsImage: colorDot(entry.group.color.color)) } } + .padding(.leading, CGFloat(entry.depth) * Self.depthIndent) + .tag(UUID?.some(entry.group.id)) } } + private static let depthIndent: CGFloat = 12 + private func colorDot(_ color: Color) -> NSImage { let size = NSSize(width: 10, height: 10) let image = NSImage(size: size, flipped: false) { rect in diff --git a/TablePro/Views/Connection/ConnectionTagEditor.swift b/TablePro/Views/Connection/ConnectionTagEditor.swift index 00ec05aaf..0f97df4a0 100644 --- a/TablePro/Views/Connection/ConnectionTagEditor.swift +++ b/TablePro/Views/Connection/ConnectionTagEditor.swift @@ -53,14 +53,29 @@ struct ConnectionTagEditor: View { Circle() .fill(tag.color.color) .frame(width: 7, height: 7) + .accessibilityHidden(true) Text(tag.name) .lineLimit(1) + Button { toggle(tag) } label: { + Image(systemName: "xmark") + .imageScale(.small) + .foregroundStyle(.secondary) + } + .buttonStyle(.borderless) + .help(Text(removeLabel(for: tag))) + .accessibilityLabel(Text(removeLabel(for: tag))) } .padding(.leading, 7) - .padding(.trailing, 8) + .padding(.trailing, 5) .padding(.vertical, 2) .background(tag.color.color.opacity(0.14), in: Capsule()) .overlay(Capsule().strokeBorder(tag.color.color.opacity(0.35), lineWidth: 1)) + .accessibilityElement(children: .contain) + .accessibilityLabel(Text(tag.name)) + } + + private func removeLabel(for tag: ConnectionTag) -> String { + String(format: String(localized: "Remove tag %@"), tag.name) } private var tagMenu: some View { @@ -107,9 +122,11 @@ struct ConnectionTagEditor: View { .foregroundStyle(.secondary) .contentShape(Rectangle()) } - .menuStyle(.borderlessButton) - .menuIndicator(.hidden) + .menuStyle(.button) + .buttonStyle(.borderless) .fixedSize() + .help(Text("Add tags")) + .accessibilityLabel(Text("Add tags")) } private func toggle(_ tag: ConnectionTag) { diff --git a/TablePro/Views/Connection/DeeplinkImportSheet.swift b/TablePro/Views/Connection/DeeplinkImportSheet.swift index ad9416fca..99082b10c 100644 --- a/TablePro/Views/Connection/DeeplinkImportSheet.swift +++ b/TablePro/Views/Connection/DeeplinkImportSheet.swift @@ -86,10 +86,9 @@ struct DeeplinkImportSheet: View { Divider() - HStack { + DialogFooter { Button(String(localized: "Cancel")) { dismiss() } .keyboardShortcut(.cancelAction) - Spacer() Button(isDuplicate ? String(localized: "Add as Copy") : String(localized: "Add Connection")) { performImport() } diff --git a/TablePro/Views/Connection/HostListFieldRow.swift b/TablePro/Views/Connection/HostListFieldRow.swift index 5cfab7b9a..48baf6a7e 100644 --- a/TablePro/Views/Connection/HostListFieldRow.swift +++ b/TablePro/Views/Connection/HostListFieldRow.swift @@ -55,20 +55,14 @@ struct HostListFieldRow: View { VStack(spacing: 0) { Divider() HStack(spacing: 0) { - Button { addEntry() } label: { - Image(systemName: "plus") - .frame(width: 24, height: 20) - } - .buttonStyle(.borderless) - - Divider().frame(height: 14) - - Button { removeSelected() } label: { - Image(systemName: "minus") - .frame(width: 24, height: 20) - } - .buttonStyle(.borderless) - .disabled(selectedId.isEmpty || entries.count <= 1) + AddRemoveControlGroup( + addLabel: String(localized: "Add Host"), + removeLabel: String(localized: "Remove Host"), + canRemove: !selectedId.isEmpty && entries.count > 1, + onAdd: { addEntry() }, + onRemove: { removeSelected() } + ) + .controlSize(.small) Spacer() } @@ -87,7 +81,9 @@ struct HostListFieldRow: View { private var listHeight: CGFloat { let rowHeight: CGFloat = 24 let rows = CGFloat(max(entries.count, 1)) - let buttonBarHeight: CGFloat = 28 + /// The control group brings its own intrinsic height, which is taller than the two + /// hand-sized 20pt buttons this row used to draw. + let buttonBarHeight: CGFloat = 32 return min(rows * rowHeight + buttonBarHeight + 8, 140) } diff --git a/TablePro/Views/Connection/OnboardingContentView.swift b/TablePro/Views/Connection/OnboardingContentView.swift index 6c2b8b5d7..154fb1178 100644 --- a/TablePro/Views/Connection/OnboardingContentView.swift +++ b/TablePro/Views/Connection/OnboardingContentView.swift @@ -65,7 +65,7 @@ struct OnboardingContentView: View { private func goToPage(_ page: Int) { navigatingForward = page > currentPage - withAnimation(.easeInOut(duration: 0.35)) { + withMotion(.easeInOut(duration: 0.35)) { currentPage = page } } diff --git a/TablePro/Views/Connection/TagFilterBar.swift b/TablePro/Views/Connection/TagFilterBar.swift index 1a730edad..2f80703c1 100644 --- a/TablePro/Views/Connection/TagFilterBar.swift +++ b/TablePro/Views/Connection/TagFilterBar.swift @@ -34,19 +34,15 @@ struct TagFilterBar: View { Text(tagFilter.mode == .any ? String(localized: "Match Any") : String(localized: "Match All")) .font(.caption) } - .menuStyle(.borderlessButton) + .menuStyle(.button) + .buttonStyle(.borderless) .fixedSize() } + /// `ButtonToggleStyle` reports the on and off value itself, and brings hover, press and the + /// keyboard focus ring that a plain button with a hand-drawn capsule never had. private func tagPill(_ tag: ConnectionTag) -> some View { - let selected = tagFilter.selectedIds.contains(tag.id) - return Button { - if selected { - tagFilter.selectedIds.remove(tag.id) - } else { - tagFilter.selectedIds.insert(tag.id) - } - } label: { + Toggle(isOn: binding(for: tag)) { HStack(spacing: 4) { Circle() .fill(tag.color.color) @@ -54,17 +50,23 @@ struct TagFilterBar: View { Text(tag.name) .font(.caption) } - .padding(.horizontal, 8) - .padding(.vertical, 3) } - .buttonStyle(.plain) - .background(selected ? tag.color.color.opacity(0.18) : Color.clear, in: Capsule()) - .overlay( - Capsule().strokeBorder( - selected ? tag.color.color : Color.secondary.opacity(0.3), - lineWidth: 1 - ) + .toggleStyle(.button) + .controlSize(.small) + .tint(tag.color.color) + .help(Text(tag.name)) + } + + private func binding(for tag: ConnectionTag) -> Binding { + Binding( + get: { tagFilter.selectedIds.contains(tag.id) }, + set: { isOn in + if isOn { + tagFilter.selectedIds.insert(tag.id) + } else { + tagFilter.selectedIds.remove(tag.id) + } + } ) - .accessibilityAddTraits(selected ? .isSelected : []) } } diff --git a/TablePro/Views/Connection/WelcomeWindowController.swift b/TablePro/Views/Connection/WelcomeWindowController.swift index 55836eadb..c9f3e0cba 100644 --- a/TablePro/Views/Connection/WelcomeWindowController.swift +++ b/TablePro/Views/Connection/WelcomeWindowController.swift @@ -49,4 +49,23 @@ internal final class WelcomeWindowController: NSWindowController { window.applyAutosaveName(WindowIdentifier.welcome) self.init(window: window) } + + /// The Welcome window used to bind Command F to a zero-size hidden button, so the Edit menu's + /// own Find item never validated and the shortcut could not be rebound in Settings. Answering + /// the standard selector puts it back on the responder chain where the menu can reach it. + @objc + internal func performFind(_ sender: Any?) { + NotificationCenter.default.post(name: .welcomeWindowFindRequested, object: window) + } +} + +extension WelcomeWindowController: NSMenuItemValidation { + internal func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { + guard menuItem.action == #selector(performFind(_:)) else { return true } + return window?.isKeyWindow == true + } +} + +internal extension Notification.Name { + static let welcomeWindowFindRequested = Notification.Name("com.TablePro.welcomeWindowFindRequested") } diff --git a/TablePro/Views/Connection/WelcomeWindowView.swift b/TablePro/Views/Connection/WelcomeWindowView.swift index 7a3b5a0b3..37ead5b11 100644 --- a/TablePro/Views/Connection/WelcomeWindowView.swift +++ b/TablePro/Views/Connection/WelcomeWindowView.swift @@ -22,7 +22,7 @@ struct WelcomeWindowView: View { ZStack { if vm.showOnboarding { OnboardingContentView { - withAnimation(.easeInOut(duration: 0.45)) { + withMotion(.easeInOut(duration: 0.45)) { vm.showOnboarding = false } } @@ -229,17 +229,9 @@ struct WelcomeWindowView: View { .background(Color(nsColor: .controlBackgroundColor)) .contentShape(Rectangle()) .contextMenu { newConnectionContextMenu } - .background(findShortcut) - } - - private var findShortcut: some View { - Button { + .onReceive(NotificationCenter.default.publisher(for: .welcomeWindowFindRequested)) { _ in searchFocusTrigger += 1 - } label: { - EmptyView() } - .keyboardShortcut("f", modifiers: .command) - .accessibilityHidden(true) } private var newConnectionHelp: String { diff --git a/TablePro/Views/ConnectionForm/Components/PluginInstallStatusRow.swift b/TablePro/Views/ConnectionForm/Components/PluginInstallStatusRow.swift index f156d5ac0..9c2961d24 100644 --- a/TablePro/Views/ConnectionForm/Components/PluginInstallStatusRow.swift +++ b/TablePro/Views/ConnectionForm/Components/PluginInstallStatusRow.swift @@ -8,15 +8,12 @@ import SwiftUI struct PluginInstallStatusRow: View { @Bindable var coordinator: ConnectionFormCoordinator + private var tracker: PluginInstallTracker { PluginInstallTracker.shared } + var body: some View { LabeledContent(String(localized: "Plugin")) { if coordinator.isInstallingPlugin { - HStack(spacing: 6) { - ProgressView() - .controlSize(.small) - Text(String(localized: "Installing...")) - .foregroundStyle(.secondary) - } + installProgress } else if let error = coordinator.pluginInstallError { HStack(spacing: 6) { Label(error, systemImage: "exclamationmark.triangle.fill") @@ -41,4 +38,41 @@ struct PluginInstallStatusRow: View { } } } + + /// An indeterminate spinner says nothing about a download that can take a while. The tracker + /// already publishes a fraction, so the bar shows it, and staging is called out explicitly so + /// a full bar never reads as ready. + @ViewBuilder + private var installProgress: some View { + switch tracker.state(forDatabaseType: coordinator.network.type)?.phase { + case .downloading(let fraction): + ProgressView(value: fraction, total: 1) + .progressViewStyle(.linear) + .frame(width: 160) + .accessibilityLabel(Text("Downloading plugin")) + case .installing: + labelledSpinner(String(localized: "Installing...")) + case .stagedPendingActivation: + Text("Ready after restart") + .foregroundStyle(.secondary) + case .completed: + Text("Installed") + .foregroundStyle(.secondary) + case .failed(let message): + Text(message) + .foregroundStyle(.secondary) + .lineLimit(2) + case .none: + labelledSpinner(String(localized: "Installing...")) + } + } + + private func labelledSpinner(_ title: String) -> some View { + HStack(spacing: 6) { + ProgressView() + .controlSize(.small) + Text(title) + .foregroundStyle(.secondary) + } + } } diff --git a/TablePro/Views/ConnectionForm/Panes/CloudSQLProxyPaneView.swift b/TablePro/Views/ConnectionForm/Panes/CloudSQLProxyPaneView.swift index e5a60b40d..27f0ee985 100644 --- a/TablePro/Views/ConnectionForm/Panes/CloudSQLProxyPaneView.swift +++ b/TablePro/Views/ConnectionForm/Panes/CloudSQLProxyPaneView.swift @@ -92,7 +92,7 @@ struct CloudSQLProxyPaneView: View { .frame(minHeight: 96) .overlay( RoundedRectangle(cornerRadius: 4) - .stroke(Color.secondary.opacity(0.3)) + .stroke(Color(nsColor: .separatorColor)) ) Text("Stored in the macOS Keychain and written to a temporary file only while the proxy runs.") .font(.caption) diff --git a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift index 876bccaab..0d2a46978 100644 --- a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift +++ b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift @@ -160,7 +160,7 @@ struct DatabaseSwitcherPopover: View { } .onChange(of: viewModel.selectedDatabase) { _, newValue in guard let item = newValue else { return } - withAnimation(.easeInOut(duration: 0.15)) { + withMotion(.easeInOut(duration: 0.15)) { proxy.scrollTo(item) } } @@ -172,14 +172,17 @@ struct DatabaseSwitcherPopover: View { return HStack(spacing: 8) { Image(systemName: "checkmark") .font(.body.weight(.semibold)) - .foregroundStyle(Color.accentColor) + .sidebarTint(.accentColor) .opacity(isCurrent ? 1 : 0) .frame(width: 14) + .accessibilityLabel(Text("Current database")) + .accessibilityHidden(!isCurrent) Image(systemName: database.icon) .font(.body) - .foregroundStyle(database.isSystemDatabase ? Color.secondary : Color.accentColor) + .sidebarTint(database.isSystemDatabase ? .secondary : .accentColor) .frame(width: 16) + .accessibilityHidden(true) Text(database.name) .font(.body) diff --git a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift index 9dd71225a..a6ac583df 100644 --- a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift +++ b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift @@ -130,7 +130,7 @@ struct DatabaseSwitcherSheet: View { .focused($focus, equals: .list) .onChange(of: viewModel.selectedDatabase) { _, newValue in guard let item = newValue else { return } - withAnimation(.easeInOut(duration: 0.15)) { + withMotion(.easeInOut(duration: 0.15)) { proxy.scrollTo(item, anchor: .center) } } @@ -146,14 +146,17 @@ struct DatabaseSwitcherSheet: View { return HStack(spacing: 8) { Image(systemName: "checkmark") .font(.body.weight(.semibold)) - .foregroundStyle(Color.accentColor) + .sidebarTint(.accentColor) .opacity(isCurrent ? 1 : 0) .frame(width: 14) + .accessibilityLabel(Text("Current database")) + .accessibilityHidden(!isCurrent) Image(systemName: database.icon) .font(.body) - .foregroundStyle(database.isSystemDatabase ? Color.secondary : Color.accentColor) + .sidebarTint(database.isSystemDatabase ? .secondary : .accentColor) .frame(width: 16) + .accessibilityHidden(true) Text(database.name) .font(.body) diff --git a/TablePro/Views/ERDiagram/ERDiagramView.swift b/TablePro/Views/ERDiagram/ERDiagramView.swift index a40594d6a..7efa3eaf4 100644 --- a/TablePro/Views/ERDiagram/ERDiagramView.swift +++ b/TablePro/Views/ERDiagram/ERDiagramView.swift @@ -279,17 +279,29 @@ struct ERDiagramView: View { panel.title = String(localized: "Export ER Diagram") panel.message = String(localized: "Choose a location to save the diagram as PNG.") - guard let window = NSApp.keyWindow else { return } + guard let window = AlertHelper.resolveContentWindow(nil) else { return } panel.beginSheetModal(for: window) { response in guard response == .OK, let url = panel.url else { return } guard let tiffData = image.tiffRepresentation, let bitmap = NSBitmapImageRep(data: tiffData), let pngData = bitmap.representation(using: .png, properties: [:]) - else { return } + else { + AlertHelper.showErrorSheet( + title: String(localized: "Could not export the diagram"), + message: String(localized: "The diagram could not be converted to a PNG image."), + window: window + ) + return + } do { try pngData.write(to: url) } catch { Self.logger.error("Failed to write PNG: \(error.localizedDescription)") + AlertHelper.showErrorSheet( + title: String(localized: "Could not export the diagram"), + message: error.localizedDescription, + window: window + ) } } } diff --git a/TablePro/Views/Editor/VimModeIndicatorView.swift b/TablePro/Views/Editor/VimModeIndicatorView.swift index 972c51f26..46daad881 100644 --- a/TablePro/Views/Editor/VimModeIndicatorView.swift +++ b/TablePro/Views/Editor/VimModeIndicatorView.swift @@ -34,17 +34,15 @@ struct VimModeIndicatorView: View { private var foregroundColor: Color { switch mode { case .normal: return .secondary - case .insert: return .white - case .replace: return .white - case .visual: return .white - case .commandLine: return .white + case .insert: return .emphasizedSelectionLabel + case .replace, .visual, .commandLine: return .legibleForeground(on: backgroundColor) } } private var backgroundColor: Color { switch mode { case .normal: return Color(nsColor: .controlBackgroundColor) - case .insert: return .accentColor + case .insert: return Color(nsColor: .selectedContentBackgroundColor) case .replace: return .red case .visual: return .orange case .commandLine: return .purple diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift index d54cc300c..236415727 100644 --- a/TablePro/Views/Export/ExportDialog.swift +++ b/TablePro/Views/Export/ExportDialog.swift @@ -145,18 +145,15 @@ struct ExportDialog: View { .interactiveDismissDisabled() .onExitCommand { } } - .sheet(isPresented: $showSuccessDialog) { - ExportSuccessView( - onOpenFolder: { + .onChange(of: showSuccessDialog) { _, isShowing in + guard isShowing else { return } + TransferResultAlert.presentExportSuccess(window: NSApp.keyWindow) { choice in + showSuccessDialog = false + if choice == .openFolder { openContainingFolder() - showSuccessDialog = false - isPresented = false - }, - onClose: { - showSuccessDialog = false - isPresented = false } - ) + isPresented = false + } } } @@ -357,33 +354,6 @@ struct ExportDialog: View { } Spacer(minLength: 0) - - Divider() - - VStack(alignment: .leading, spacing: 6) { - Text("File name") - .font(.subheadline) - .foregroundStyle(.secondary) - - HStack(spacing: 4) { - TextField("export", text: $config.fileName) - .textFieldStyle(.roundedBorder) - .font(.body) - - Text(".\(fileExtension)") - .foregroundStyle(.secondary) - .font(.system(.body, design: .monospaced)) - .lineLimit(1) - .fixedSize() - } - - if let validationError = fileNameValidationError { - Text(validationError) - .font(.subheadline) - .foregroundStyle(.red) - } - } - .padding(16) } } @@ -451,7 +421,7 @@ struct ExportDialog: View { } private var isExportDisabled: Bool { - if isExporting || !isFileNameValid || availableFormats.isEmpty { + if isExporting || availableFormats.isEmpty { return true } if case .streamingQuery = mode { @@ -476,53 +446,6 @@ struct ExportDialog: View { } } - /// Windows reserved device names (case-insensitive) - private static let windowsReservedNames: Set = [ - "CON", "PRN", "AUX", "NUL", - "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", - "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" - ] - - /// Returns a validation error message if the filename is invalid, nil if valid - private var fileNameValidationError: String? { - let name = config.fileName.trimmingCharacters(in: .whitespaces) - - if name.isEmpty { - return String(localized: "Filename cannot be empty") - } - - // Invalid filesystem characters (covers macOS, Windows, and Linux) - let invalidChars = CharacterSet(charactersIn: "/\\:*?\"<>|") - if name.rangeOfCharacter(from: invalidChars) != nil { - return String(localized: "Filename contains invalid characters: / \\ : * ? \" < > |") - } - - // Prevent path traversal attempts and special directory names - if name == "." || name == ".." || - name.hasPrefix("../") || name.hasPrefix("..\\") || - name.hasSuffix("/..") || name.hasSuffix("\\..") || - name.contains("/../") || name.contains("\\..\\") { - return String(localized: "Filename cannot be '.' or '..' or contain path traversal") - } - - let baseName = name.components(separatedBy: ".").first ?? name - if Self.windowsReservedNames.contains(baseName.uppercased()) { - return String(format: String(localized: "'%@' is a reserved Windows device name"), baseName) - } - - // Check filename length (255 bytes is common limit on most filesystems) - if name.utf8.count > 255 { - return String(localized: "Filename is too long (max 255 bytes)") - } - - return nil - } - - /// Validates that the filename is not empty and contains no invalid filesystem characters - private var isFileNameValid: Bool { - fileNameValidationError == nil - } - private func resetOptionValues() { databaseItems = databaseItems.resettingOptionValues(to: currentDefaultOptionValues) } diff --git a/TablePro/Views/Export/ExportSuccessView.swift b/TablePro/Views/Export/ExportSuccessView.swift deleted file mode 100644 index c5fd9c2e0..000000000 --- a/TablePro/Views/Export/ExportSuccessView.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// ExportSuccessView.swift -// TablePro -// -// Success dialog shown after export completes. -// Provides option to open containing folder in Finder. -// - -import SwiftUI - -/// Success dialog shown after export completes -struct ExportSuccessView: View { - let onOpenFolder: () -> Void - let onClose: () -> Void - - @AppStorage("hideExportSuccessDialog") private var dontShowAgain = false - @State private var localDontShowAgain = false - - init(onOpenFolder: @escaping () -> Void, onClose: @escaping () -> Void) { - self.onOpenFolder = onOpenFolder - self.onClose = onClose - } - var body: some View { - VStack(spacing: 20) { - Image(systemName: "checkmark.circle.fill") - .font(.largeTitle) - .imageScale(.large) - .symbolRenderingMode(.hierarchical) - .foregroundStyle(.green) - - VStack(spacing: 6) { - Text("Success") - .font(.title3.weight(.semibold)) - - Text("Export completed successfully") - .font(.body) - .foregroundStyle(.secondary) - } - - VStack(spacing: 10) { - Button("Open containing folder") { - if localDontShowAgain { - dontShowAgain = true - } - onOpenFolder() - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - - Button("Close") { - if localDontShowAgain { - dontShowAgain = true - } - onClose() - } - .controlSize(.large) - } - - Toggle("Don't show this again", isOn: $localDontShowAgain) - .toggleStyle(.checkbox) - .font(.callout) - .foregroundStyle(.secondary) - } - .padding(24) - .frame(width: 300) - .background(Color(nsColor: .windowBackgroundColor)) - } -} - -// MARK: - Preview - -#Preview { - ExportSuccessView( - onOpenFolder: {}, - onClose: {} - ) -} diff --git a/TablePro/Views/Filter/FilterValueTextField.swift b/TablePro/Views/Filter/FilterValueTextField.swift index 0bbee93b1..460c7e985 100644 --- a/TablePro/Views/Filter/FilterValueTextField.swift +++ b/TablePro/Views/Filter/FilterValueTextField.swift @@ -397,6 +397,7 @@ struct FilterValueTextField: NSViewRepresentable { private func showPopover(for textField: NSTextField, items: [SuggestionItem]) { suggestionState.items = items suggestionState.selectedIndex = 0 + announceSuggestions(count: items.count, on: textField) let bounds = textField.bounds let state = suggestionState @@ -419,6 +420,23 @@ struct FilterValueTextField: NSViewRepresentable { suggestionPopover = popover } + /// The completion list never takes focus, so nothing in it is ever the accessibility + /// focus. Without an announcement on the field there is no signal that it opened at all. + private func announceSuggestions(count: Int, on textField: NSTextField) { + guard count > 0 else { return } + NSAccessibility.post( + element: textField, + notification: .announcementRequested, + userInfo: [ + .announcement: String( + format: String(localized: "%lld suggestions available"), + Int64(count) + ), + .priority: NSAccessibilityPriorityLevel.medium.rawValue + ] + ) + } + private func moveSelection(by delta: Int) { let count = suggestionState.items.count guard count > 0 else { return } @@ -532,9 +550,14 @@ struct FilterValueTextField: NSViewRepresentable { .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 6) .padding(.vertical, 3) + .foregroundStyle( + state.selectedIndex == index + ? Color.emphasizedSelectionLabel + : Color.primary + ) .background( state.selectedIndex == index - ? Color.accentColor.opacity(0.18) + ? Color(nsColor: .selectedContentBackgroundColor) : Color.clear ) .clipShape(RoundedRectangle(cornerRadius: 4)) @@ -552,7 +575,7 @@ struct FilterValueTextField: NSViewRepresentable { } .focusable(false) .onChange(of: state.selectedIndex) { _, newIndex in - withAnimation(.easeOut(duration: 0.1)) { + withMotion(.easeOut(duration: 0.1)) { proxy.scrollTo(newIndex, anchor: .center) } } diff --git a/TablePro/Views/Import/ImportDialog.swift b/TablePro/Views/Import/ImportDialog.swift index 43cc14a5c..be2bbb463 100644 --- a/TablePro/Views/Import/ImportDialog.swift +++ b/TablePro/Views/Import/ImportDialog.swift @@ -115,20 +115,17 @@ struct ImportDialog: View { .interactiveDismissDisabled() } } - .sheet(isPresented: $showSuccessDialog, onDismiss: { - isPresented = false - AppCommands.shared.refreshData.send(DataRefreshRequest(connectionId: connection.id)) - }) { - ImportSuccessView( - result: importResult - ) { + .onChange(of: showSuccessDialog) { _, isShowing in + guard isShowing else { return } + TransferResultAlert.presentImportSuccess(result: importResult, window: NSApp.keyWindow) { showSuccessDialog = false + isPresented = false + AppCommands.shared.refreshData.send(DataRefreshRequest(connectionId: connection.id)) } } - .sheet(isPresented: $showErrorDialog) { - ImportErrorView( - error: importError - ) { + .onChange(of: showErrorDialog) { _, isShowing in + guard isShowing else { return } + TransferResultAlert.presentImportFailure(error: importError, window: NSApp.keyWindow) { showErrorDialog = false } } @@ -294,14 +291,12 @@ struct ImportDialog: View { } private var footerView: some View { - HStack { + DialogFooter { Button("Cancel") { isPresented = false } .keyboardShortcut(.cancelAction) - Spacer() - Button("Import") { performImport() } diff --git a/TablePro/Views/Import/ImportErrorView.swift b/TablePro/Views/Import/ImportErrorView.swift deleted file mode 100644 index 1c878d33c..000000000 --- a/TablePro/Views/Import/ImportErrorView.swift +++ /dev/null @@ -1,74 +0,0 @@ -// -// ImportErrorView.swift -// TablePro -// -// Error dialog shown when import fails. -// - -import SwiftUI -import TableProPluginKit - -struct ImportErrorView: View { - let error: (any Error)? - let onClose: () -> Void - - var body: some View { - VStack(spacing: 20) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.largeTitle) - .imageScale(.large) - .symbolRenderingMode(.hierarchical) - .foregroundStyle(.red) - - VStack(spacing: 6) { - Text("Import Failed") - .font(.title3.weight(.semibold)) - - if let pluginError = error as? PluginImportError, - case .statementFailed(let statement, let line, let underlyingError) = pluginError - { - Text("Failed at line \(line)") - .font(.body) - .foregroundStyle(.secondary) - - ScrollView { - VStack(alignment: .leading, spacing: 8) { - Text("Statement:") - .font(.callout.weight(.medium)) - Text(statement) - .font(.system(.subheadline, design: .monospaced)) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - - Text("Error:") - .font(.callout.weight(.medium)) - .padding(.top, 8) - Text(underlyingError.localizedDescription) - .font(.subheadline) - .foregroundStyle(.red) - .frame(maxWidth: .infinity, alignment: .leading) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .frame(height: 150) - .padding(8) - .background(Color(nsColor: .textBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 4)) - } else { - Text(error?.localizedDescription ?? String(localized: "Unknown error")) - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - } - - Button("Close") { - onClose() - } - .buttonStyle(.borderedProminent) - } - .padding(24) - .frame(width: 500) - .background(Color(nsColor: .windowBackgroundColor)) - } -} diff --git a/TablePro/Views/Import/ImportSuccessView.swift b/TablePro/Views/Import/ImportSuccessView.swift deleted file mode 100644 index 54867adfa..000000000 --- a/TablePro/Views/Import/ImportSuccessView.swift +++ /dev/null @@ -1,110 +0,0 @@ -// -// ImportSuccessView.swift -// TablePro -// -// Success dialog shown after successful import. -// - -import SwiftUI -import TableProPluginKit - -struct ImportSuccessView: View { - let result: PluginImportResult? - let onClose: () -> Void - - private var hasErrors: Bool { - guard let result else { return false } - return result.skippedStatements > 0 - } - - var body: some View { - VStack(spacing: 20) { - if hasErrors { - Image(systemName: "exclamationmark.triangle.fill") - .font(.largeTitle) - .imageScale(.large) - .symbolRenderingMode(.hierarchical) - .foregroundStyle(.yellow) - } else { - Image(systemName: "checkmark.circle.fill") - .font(.largeTitle) - .imageScale(.large) - .symbolRenderingMode(.hierarchical) - .foregroundStyle(.green) - } - - VStack(spacing: 6) { - Text(hasErrors ? "Import Completed with Errors" : "Import Successful") - .font(.title3.weight(.semibold)) - - if let result { - if hasErrors { - Text("\(result.executedStatements) statements executed, \(result.skippedStatements) failed") - .font(.body) - .foregroundStyle(.secondary) - } else { - Text("\(result.executedStatements) statements executed") - .font(.body) - .foregroundStyle(.secondary) - } - - let formattedTime = String(format: "%.2f", result.executionTime) - Text(String(format: String(localized: "%@ seconds"), formattedTime)) - .font(.callout) - .foregroundStyle(.secondary) - } - } - - if let result, !result.errors.isEmpty { - errorListView(errors: result.errors) - } - - HStack(spacing: 12) { - if let result, !result.errors.isEmpty { - Button("Copy Errors to Clipboard") { - copyErrorsToClipboard(errors: result.errors) - } - } - - Button("Close") { - onClose() - } - .buttonStyle(.borderedProminent) - } - } - .padding(24) - .frame(width: hasErrors ? 500 : 300) - .background(Color(nsColor: .windowBackgroundColor)) - } - - private func errorListView(errors: [PluginImportResult.ImportStatementError]) -> some View { - ScrollView { - VStack(alignment: .leading, spacing: 12) { - ForEach(Array(errors.enumerated()), id: \.offset) { _, error in - VStack(alignment: .leading, spacing: 4) { - Text("Line \(error.line): \(error.statement)") - .font(.system(.callout, design: .monospaced)) - .lineLimit(2) - - Text(error.errorMessage) - .font(.callout) - .foregroundStyle(.secondary) - } - } - } - .padding(8) - } - .frame(maxHeight: 200) - .background(Color(nsColor: .controlBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 6)) - } - - private func copyErrorsToClipboard(errors: [PluginImportResult.ImportStatementError]) { - let text = errors.map { error in - "Line \(error.line): \(error.statement)\nError: \(error.errorMessage)" - }.joined(separator: "\n\n") - - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(text, forType: .string) - } -} diff --git a/TablePro/Views/Import/RowImportSheet.swift b/TablePro/Views/Import/RowImportSheet.swift index 0c8ddeb7b..4a3ed1027 100644 --- a/TablePro/Views/Import/RowImportSheet.swift +++ b/TablePro/Views/Import/RowImportSheet.swift @@ -107,14 +107,19 @@ struct RowImportSheet: View { .interactiveDismissDisabled() } } - .sheet(isPresented: $showSuccessDialog, onDismiss: { - isPresented = false - AppCommands.shared.refreshData.send(DataRefreshRequest(connectionId: connection.id)) - }) { - ImportSuccessView(result: importResult) { showSuccessDialog = false } + .onChange(of: showSuccessDialog) { _, isShowing in + guard isShowing else { return } + TransferResultAlert.presentImportSuccess(result: importResult, window: NSApp.keyWindow) { + showSuccessDialog = false + isPresented = false + AppCommands.shared.refreshData.send(DataRefreshRequest(connectionId: connection.id)) + } } - .sheet(isPresented: $showErrorDialog) { - ImportErrorView(error: importError) { showErrorDialog = false } + .onChange(of: showErrorDialog) { _, isShowing in + guard isShowing else { return } + TransferResultAlert.presentImportFailure(error: importError, window: NSApp.keyWindow) { + showErrorDialog = false + } } } @@ -190,16 +195,16 @@ struct RowImportSheet: View { } private var footerView: some View { - HStack { - Button("Cancel") { isPresented = false } - .keyboardShortcut(.cancelAction) + DialogFooter { if let message = validationMessage { Text(message) .font(.caption) .foregroundStyle(.red) .lineLimit(2) } - Spacer() + } actions: { + Button("Cancel") { isPresented = false } + .keyboardShortcut(.cancelAction) Button("Import") { performImport() } .buttonStyle(.borderedProminent) .disabled(!canImport) @@ -243,9 +248,10 @@ struct RowImportSheet: View { private var mappingTable: some View { VStack(spacing: 0) { HStack(spacing: 12) { - Toggle("", isOn: allMappingsIncluded) + Toggle(String(localized: "Import all fields"), isOn: allMappingsIncluded) .labelsHidden() .help(String(localized: "Import all fields")) + .accessibilityLabel(Text("Import all fields")) .frame(width: 16) Text("Field") .font(.caption) @@ -314,11 +320,11 @@ struct RowImportSheet: View { Text("Key") .font(.caption) .foregroundStyle(.secondary) - .frame(width: 30) + .frame(minWidth: 30) Text("Null") .font(.caption) .foregroundStyle(.secondary) - .frame(width: 30) + .frame(minWidth: 30) Text("Default") .font(.caption) .foregroundStyle(.secondary) @@ -369,11 +375,11 @@ struct RowImportSheet: View { .disabled(!row.include) Toggle("", isOn: columnBinding(row).isPrimaryKey) .labelsHidden() - .frame(width: 30) + .frame(minWidth: 30) .disabled(!row.include) Toggle("", isOn: columnBinding(row).isNullable) .labelsHidden() - .frame(width: 30) + .frame(minWidth: 30) .disabled(!row.include) TextField("", text: columnBinding(row).defaultValue) .textFieldStyle(.roundedBorder) diff --git a/TablePro/Views/Inspector/InspectorDeleteConfirmation.swift b/TablePro/Views/Inspector/InspectorDeleteConfirmation.swift index a77be1843..51b785597 100644 --- a/TablePro/Views/Inspector/InspectorDeleteConfirmation.swift +++ b/TablePro/Views/Inspector/InspectorDeleteConfirmation.swift @@ -48,22 +48,30 @@ enum InspectorDeleteConfirmation { present(messageText: rowDeleteTitle(count: rowsCells.count), window: window, proceed: proceed) } + static func makeAlert(messageText: String) -> NSAlert { + let alert = NSAlert() + alert.messageText = messageText + alert.alertStyle = .warning + AlertHelper.addConfirmAndCancel( + to: alert, + confirmButton: String(localized: "Delete"), + cancelButton: String(localized: "Cancel") + ) + return alert + } + private static func present( messageText: String, window: NSWindow?, proceed: @escaping @MainActor () -> Void ) { - guard let window else { + let alert = makeAlert(messageText: messageText) + guard let parent = AlertHelper.resolveWindow(window) else { + guard alert.runModal() == .alertFirstButtonReturn else { return } proceed() return } - let alert = NSAlert() - alert.messageText = messageText - alert.alertStyle = .warning - let deleteButton = alert.addButton(withTitle: String(localized: "Delete")) - deleteButton.hasDestructiveAction = true - alert.addButton(withTitle: String(localized: "Cancel")) - alert.beginSheetModal(for: window) { response in + alert.beginSheetModal(for: parent) { response in guard response == .alertFirstButtonReturn else { return } proceed() } diff --git a/TablePro/Views/Inspector/InspectorViewController.swift b/TablePro/Views/Inspector/InspectorViewController.swift index 30164df49..ca60c079a 100644 --- a/TablePro/Views/Inspector/InspectorViewController.swift +++ b/TablePro/Views/Inspector/InspectorViewController.swift @@ -450,6 +450,10 @@ final class InspectorViewController: NSViewController, NSUserInterfaceValidation action: nil ) mode.selectedSegment = 0 + mode.setAccessibilityLabel(String(localized: "Split mode")) + if #available(macOS 27.0, *) { + mode.role = .valueSelection + } let stack = accessoryStack(with: [field, mode]) alert.accessoryView = stack alert.window.initialFirstResponder = field @@ -513,6 +517,8 @@ final class InspectorViewController: NSViewController, NSUserInterfaceValidation alert.beginSheetModal(for: window) } + /// A fixed width truncates a longer localized segment label, and a row count times a guessed + /// row height is not the height the stack actually lays out to. private func accessoryStack(with views: [NSView]) -> NSStackView { let stack = NSStackView(views: views) stack.orientation = .vertical @@ -520,9 +526,10 @@ final class InspectorViewController: NSViewController, NSUserInterfaceValidation stack.spacing = 8 stack.translatesAutoresizingMaskIntoConstraints = false for view in views { - view.widthAnchor.constraint(equalToConstant: 260).isActive = true + view.widthAnchor.constraint(greaterThanOrEqualToConstant: 260).isActive = true } - stack.frame = NSRect(x: 0, y: 0, width: 260, height: CGFloat(views.count) * 32) + stack.layoutSubtreeIfNeeded() + stack.frame = NSRect(origin: .zero, size: stack.fittingSize) return stack } diff --git a/TablePro/Views/Main/EditorTabStrip.swift b/TablePro/Views/Main/EditorTabStrip.swift index e0366db56..153653816 100644 --- a/TablePro/Views/Main/EditorTabStrip.swift +++ b/TablePro/Views/Main/EditorTabStrip.swift @@ -17,6 +17,8 @@ import SwiftUI internal struct EditorTabStrip: View { internal let tabManager: QueryTabManager internal let onClose: (UUID) -> Void + internal let onCloseOthers: (UUID) -> Void + internal let onCloseAll: () -> Void internal let onNewTab: () -> Void @State private var hoveredTabId: UUID? @@ -33,6 +35,7 @@ internal struct EditorTabStrip: View { } .accessibilityElement(children: .contain) .accessibilityLabel(Text("Editor Tabs")) + .accessibilityAddTraits(.isTabBar) } private var track: some View { @@ -83,6 +86,8 @@ internal struct EditorTabStrip: View { selectedId: tabManager.selectedTab?.id, hoveredId: hoveredTabId ), + position: index + 1, + count: tabManager.tabs.count, onHover: { hovering in if hovering { hoveredTabId = tab.id @@ -91,7 +96,9 @@ internal struct EditorTabStrip: View { } }, onSelect: { tabManager.selectedTabId = tab.id }, - onClose: { onClose(tab.id) } + onClose: { onClose(tab.id) }, + onCloseOthers: { onCloseOthers(tab.id) }, + onCloseAll: onCloseAll ) } @@ -106,54 +113,74 @@ private struct EditorTabStripItem: View { let isHovered: Bool let isWindowActive: Bool let showsLeadingSeparator: Bool + let position: Int + let count: Int let onHover: (Bool) -> Void let onSelect: () -> Void let onClose: () -> Void + let onCloseOthers: () -> Void + let onCloseAll: () -> Void @Environment(\.colorScheme) private var colorScheme var body: some View { - ZStack { - if showsLeadingSeparator { - HStack { - Rectangle() - .fill(Color(nsColor: .separatorColor)) - .frame(width: 1, height: EditorTabStripLayout.separatorHeight) - Spacer() + Button(action: onSelect) { + ZStack { + if showsLeadingSeparator { + HStack { + Rectangle() + .fill(Color(nsColor: .separatorColor)) + .frame(width: 1, height: EditorTabStripLayout.separatorHeight) + Spacer() + } } - } - background + background - /// The close button takes the leading end and an equal spacer holds the trailing - /// end, which is how the system keeps a title optically centred while still giving - /// the button a real place in the row. - HStack(spacing: 0) { - closeButton - .frame(width: EditorTabStripLayout.accessoryWidth) - Text(tab.title) - .lineLimit(1) - .truncationMode(.tail) - .italic(tab.isPreview) - .font(.system(size: EditorTabStripLayout.fontSize)) - .foregroundStyle(titleColor) - .frame(maxWidth: .infinity) - Color.clear - .frame(width: EditorTabStripLayout.accessoryWidth) + /// A spacer holds each end so the title stays optically centred. The close button + /// sits in the leading one as a sibling overlay rather than inside this label, + /// because a button nested in a button never receives the click. + HStack(spacing: 0) { + Color.clear + .frame(width: EditorTabStripLayout.accessoryWidth) + Text(tab.title) + .lineLimit(1) + .truncationMode(.tail) + .italic(tab.isPreview) + .font(.system(size: EditorTabStripLayout.fontSize)) + .foregroundStyle(titleColor) + .frame(maxWidth: .infinity) + Color.clear + .frame(width: EditorTabStripLayout.accessoryWidth) + } + .padding(.horizontal, EditorTabStripLayout.accessoryInset) } - .padding(.horizontal, EditorTabStripLayout.accessoryInset) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .overlay(alignment: .leading) { + closeButton + .padding(.leading, EditorTabStripLayout.accessoryInset) } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .contentShape(Rectangle()) .onHover(perform: onHover) - .onTapGesture(perform: onSelect) .help(Text(tab.title)) + .contextMenu { + Button(String(localized: "Close Tab"), action: onClose) + Button(String(localized: "Close Other Tabs"), action: onCloseOthers) + Button(String(localized: "Close All Tabs"), action: onCloseAll) + } .accessibilityElement(children: .combine) .accessibilityLabel(Text(tab.title)) - .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton) + .accessibilityValue(Text(positionDescription)) + .accessibilityAddTraits(isSelected ? .isSelected : []) .accessibilityAction(named: Text("Close Tab"), onClose) } + private var positionDescription: String { + String(format: String(localized: "%1$d of %2$d"), position, count) + } + private var titleColor: Color { guard isWindowActive else { return Color(nsColor: isSelected ? .secondaryLabelColor : .tertiaryLabelColor) diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 4763ff21b..86c27966a 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -329,11 +329,6 @@ struct MainContentView: View { coordinator.aiViewModel = rightPanelState.aiViewModel coordinator.rightPanelState = rightPanelState - // (NSToolbar install moved to `configureWindow(_:)` — at onAppear - // time `viewWindow` is still nil because WindowAccessor fires its - // callback on viewDidMoveToWindow, which runs AFTER SwiftUI's - // onAppear in NSHostingView-hosted content.) - Self.lifecycleLogger.info( "[open] MainContentView.onAppear done windowId=\(windowId, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(start) * 1_000))" ) @@ -344,14 +339,7 @@ struct MainContentView: View { } private var bodyContentCore: some View { - editorTabStripAndContent - // Phase 3: SwiftUI `.toolbar { ... }` removed — NSToolbar is now - // installed directly on NSWindow by TabWindowController (see - // `MainWindowToolbar`). Reuses every existing SwiftUI subview - // (ConnectionStatusView, SafeModeBadgeView, popovers, etc.) via - // `NSHostingView` inside `NSToolbarItem.view`. Connection color - // tint is not yet ported; `ToolbarTintModifier` no-ops under - // NSHostingView so leaving the modifier off has no visible loss. + mainContentView .task { let start = Date() Self.lifecycleLogger.info( @@ -416,21 +404,6 @@ struct MainContentView: View { // MARK: - Main Content - /// The strip is hidden while a connection holds a single tab, so a window that behaves the - /// way it always did gains no chrome. It appears the moment a second tab exists. - private var editorTabStripAndContent: some View { - VStack(spacing: 0) { - if tabManager.tabs.count > 1 { - EditorTabStrip( - tabManager: tabManager, - onClose: { coordinator.commandActions?.closeTab(id: $0) }, - onNewTab: { coordinator.commandActions?.newTab() } - ) - } - mainContentView - } - } - @ViewBuilder private var mainContentView: some View { MainEditorContentView( diff --git a/TablePro/Views/QueryPlan/QueryPlanDiagramView.swift b/TablePro/Views/QueryPlan/QueryPlanDiagramView.swift index 3257449f9..201d7b4fd 100644 --- a/TablePro/Views/QueryPlan/QueryPlanDiagramView.swift +++ b/TablePro/Views/QueryPlan/QueryPlanDiagramView.swift @@ -69,7 +69,7 @@ struct QueryPlanDiagramView: View { zoomControls .padding(12) } - .onAppear { + .task(id: plan.rawText) { let nodes = layoutNodes(plan.rootNode, depth: 0, xOffset: 0, parentId: nil) positioned = nodes canvasSize = calculateCanvasSize(nodes) diff --git a/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift b/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift index d0858a128..666e772c3 100644 --- a/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift +++ b/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift @@ -171,7 +171,8 @@ struct QuickSwitcherPanelContent: View { private var inputFields: some View { HStack(spacing: 10) { Image(systemName: "magnifyingglass") - .font(.system(size: 19, weight: .medium)) + .font(.title2) + .imageScale(.large) .foregroundStyle(.secondary) QuickSwitcherSearchField( @@ -244,7 +245,7 @@ struct QuickSwitcherPanelContent: View { private func sectionHeader(_ title: String) -> some View { Text(title) - .font(.system(size: 11, weight: .semibold)) + .font(.subheadline.weight(.semibold)) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) .padding(.leading, 24) @@ -260,8 +261,8 @@ struct QuickSwitcherPanelContent: View { iconView(for: item, isEmphasized: isEmphasized) Text(highlightedName(for: item)) - .font(.system(size: 15)) - .foregroundStyle(isEmphasized ? Color.white : Color.primary) + .font(.title3) + .foregroundStyle(isEmphasized ? Color.emphasizedSelectionLabel : Color.primary) .lineLimit(1) .truncationMode(.middle) @@ -283,27 +284,35 @@ struct QuickSwitcherPanelContent: View { } } .contentShape(Rectangle()) - .onTapGesture { + .onTapGesture(count: 2) { isNavigating = true viewModel.selectedItemId = item.id - if NSApp.currentEvent?.clickCount == 2 { - onCommit(item, .open) - } + onCommit(item, .open) } + .simultaneousGesture( + TapGesture().onEnded { + isNavigating = true + viewModel.selectedItemId = item.id + } + ) .contextMenu { contextMenuActions(for: item) } + .accessibilityElement(children: .combine) + .accessibilityLabel(Text(item.name)) + .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton) + .accessibilityAction { onCommit(item, .open) } .id(item.id) } private func iconView(for item: QuickSwitcherItem, isEmphasized: Bool) -> some View { Image(systemName: item.iconName) .font(.system(size: 14, weight: .medium)) - .foregroundStyle(isEmphasized ? Color.white : Color.secondary) + .foregroundStyle(isEmphasized ? Color.emphasizedSelectionLabel : Color.secondary) .frame(width: PanelMetrics.iconContainerSize, height: PanelMetrics.iconContainerSize) .background( RoundedRectangle(cornerRadius: 6, style: .continuous) .fill( isEmphasized - ? Color.white.opacity(0.2) + ? Color.emphasizedSelectionLabel.opacity(0.2) : Color(nsColor: .quaternarySystemFill) ) ) @@ -311,11 +320,11 @@ struct QuickSwitcherPanelContent: View { @ViewBuilder private func trailingAccessories(for item: QuickSwitcherItem, isSelected: Bool, isEmphasized: Bool) -> some View { - let secondaryColor = isEmphasized ? Color.white.opacity(0.85) : Color.secondary + let secondaryColor = isEmphasized ? Color.emphasizedSelectionLabel.opacity(0.85) : Color.secondary if item.isOpenInTab, !isSelected { Text(String(localized: "Open")) - .font(.system(size: 10, weight: .medium)) + .font(.caption2.weight(.medium)) .foregroundStyle(secondaryColor) .padding(.horizontal, 5) .padding(.vertical, 1) @@ -324,12 +333,12 @@ struct QuickSwitcherPanelContent: View { if isSelected { Text(commitHint(for: item)) - .font(.system(size: 12)) + .font(.callout) .foregroundStyle(secondaryColor) keycap("↩", isEmphasized: isEmphasized) } else if !item.subtitle.isEmpty { Text(item.subtitle) - .font(.system(size: 12)) + .font(.callout) .foregroundStyle(secondaryColor) .lineLimit(1) } @@ -338,11 +347,11 @@ struct QuickSwitcherPanelContent: View { private func keycap(_ label: String, isEmphasized: Bool) -> some View { Text(label) .font(.system(size: 11, weight: .medium)) - .foregroundStyle(isEmphasized ? Color.white : Color.secondary) + .foregroundStyle(isEmphasized ? Color.emphasizedSelectionLabel : Color.secondary) .frame(width: 24, height: 18) .background( RoundedRectangle(cornerRadius: 4, style: .continuous) - .fill(isEmphasized ? Color.white.opacity(0.25) : Color(nsColor: .quaternarySystemFill)) + .fill(isEmphasized ? Color.emphasizedSelectionLabel.opacity(0.25) : Color(nsColor: .quaternarySystemFill)) ) } @@ -359,7 +368,7 @@ struct QuickSwitcherPanelContent: View { private var noResultsRow: some View { Text(String(format: String(localized: "No results for \"%@\""), viewModel.searchText)) - .font(.system(size: 15)) + .font(.title3) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 24) diff --git a/TablePro/Views/Results/Cells/DataGridCellView.swift b/TablePro/Views/Results/Cells/DataGridCellView.swift index 82fec0215..c70e6182f 100644 --- a/TablePro/Views/Results/Cells/DataGridCellView.swift +++ b/TablePro/Views/Results/Cells/DataGridCellView.swift @@ -165,6 +165,10 @@ final class DataGridCellView: NSView { } } + override func accessibilityValue() -> Any? { + rawValue ?? String(localized: "NULL") + } + override func accessibilityLabel() -> String? { let value = rawValue ?? String(localized: "NULL") return String( diff --git a/TablePro/Views/Results/DataGridRowView.swift b/TablePro/Views/Results/DataGridRowView.swift index 4af3612a2..603622573 100644 --- a/TablePro/Views/Results/DataGridRowView.swift +++ b/TablePro/Views/Results/DataGridRowView.swift @@ -64,6 +64,7 @@ class DataGridRowView: NSTableRowView { didSet { guard isSelected != oldValue else { return } propagateEmphasisToCells() + needsDisplay = true } } @@ -71,6 +72,7 @@ class DataGridRowView: NSTableRowView { didSet { guard isEmphasized != oldValue else { return } propagateEmphasisToCells() + needsDisplay = true } } @@ -106,10 +108,11 @@ class DataGridRowView: NSTableRowView { let columns = selection.columns(in: rowIndex) guard !columns.isEmpty else { return } - let fillColor: NSColor = isSelected - ? NSColor.unemphasizedSelectedContentBackgroundColor - : NSColor.selectedContentBackgroundColor.withAlphaComponent(0.28) - fillColor.setFill() + let base: NSColor = isEmphasized + ? .selectedContentBackgroundColor + : .unemphasizedSelectedContentBackgroundColor + let alpha = isSelected ? Self.rowSelectedCellAlpha : Self.cellOnlySelectionAlpha + base.withAlphaComponent(alpha).setFill() for dataColumn in columns { guard let tableColumnIndex = coordinator.tableColumnIndex(for: dataColumn) else { continue } @@ -120,6 +123,9 @@ class DataGridRowView: NSTableRowView { } } + private static let rowSelectedCellAlpha: CGFloat = 0.55 + private static let cellOnlySelectionAlpha: CGFloat = 0.28 + private func colorsEqual(_ lhs: NSColor?, _ rhs: NSColor?) -> Bool { switch (lhs, rhs) { case (nil, nil): return true diff --git a/TablePro/Views/Results/DateTimePickerContentView.swift b/TablePro/Views/Results/DateTimePickerContentView.swift index ebea4a010..4903e3cbe 100644 --- a/TablePro/Views/Results/DateTimePickerContentView.swift +++ b/TablePro/Views/Results/DateTimePickerContentView.swift @@ -139,7 +139,7 @@ private struct CalendarMonthView: View { .frame(width: cellSize, height: cellSize) .background { if isSelected { - Circle().fill(Color.accentColor) + Circle().fill(Color(nsColor: .selectedContentBackgroundColor)) } else if isToday { Circle().strokeBorder(Color.accentColor, lineWidth: 1) } @@ -154,7 +154,7 @@ private struct CalendarMonthView: View { } private func dayColor(isSelected: Bool, isToday: Bool) -> Color { - if isSelected { return .white } + if isSelected { return .emphasizedSelectionLabel } if isToday { return .accentColor } return .primary } diff --git a/TablePro/Views/Results/KeyHandlingTableView.swift b/TablePro/Views/Results/KeyHandlingTableView.swift index 70b2957fb..789b77a55 100644 --- a/TablePro/Views/Results/KeyHandlingTableView.swift +++ b/TablePro/Views/Results/KeyHandlingTableView.swift @@ -306,15 +306,6 @@ final class KeyHandlingTableView: NSTableView { return } - if key == .tab { - if event.modifierFlags.contains(.shift) { - handleShiftTabKey() - } else { - handleTabKey() - } - return - } - let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) let row = selectedRow @@ -494,50 +485,89 @@ final class KeyHandlingTableView: NSTableView { return !column.isHidden && column.identifier != ColumnIdentitySchema.rowNumberIdentifier } - private func handleTabKey() { - let row = selectedRow - guard row >= 0, DataGridView.isDataTableColumn(focusedColumn) else { return } - - var nextColumn = focusedColumn + 1 - var nextRow = row + /// `NSResponder` declares these two but does not implement them, so calling `super` raises + /// `doesNotRecognizeSelector`. With no cell cursor to move, Tab has to leave the grid the way + /// it leaves any other view, or focus is trapped here for the rest of the session. + /// VoiceOver follows the focused element, and a table view reports itself rather than the + /// cell the grid's own cursor is on, so the cursor was invisible to it. The selected-cells + /// override is clamped to the visible rows: AppKit will happily ask for every cell in a + /// million-row selection otherwise. + /// The cursor moved, so assistive technology is told to re-read where focus now is. The + /// element itself stays the table: `NSTableView`'s own focused-element resolution already + /// walks to the cell, and overriding it in Swift is not available on this type. + internal func postCellCursorMoved() { + guard selectedRow >= 0, DataGridView.isDataTableColumn(focusedColumn) else { return } + guard let cell = view(atColumn: focusedColumn, row: selectedRow, makeIfNecessary: false) else { return } + NSAccessibility.post(element: cell, notification: .focusedUIElementChanged) + } - if nextColumn >= numberOfColumns { - nextColumn = DataGridView.firstDataTableColumnIndex - nextRow += 1 - } - if nextRow >= numberOfRows { - nextRow = numberOfRows - 1 - nextColumn = numberOfColumns - 1 + override func accessibilitySelectedCells() -> [Any]? { + guard let controller = gridSelection, !controller.isEmpty else { + return super.accessibilitySelectedCells() + } + let visible = rows(in: visibleRect) + guard visible.length > 0 else { return [] } + var cells: [Any] = [] + for rectangle in controller.selection.rectangles { + for row in rectangle.rows where NSLocationInRange(row, visible) { + for column in rectangle.columns { + guard let cell = view(atColumn: column, row: row, makeIfNecessary: false) else { continue } + cells.append(cell) + } + } } + return cells + } - selectRowIndexes(IndexSet(integer: nextRow), byExtendingSelection: false) - focusedRow = nextRow - focusedColumn = nextColumn - scrollRowToVisible(nextRow) - scrollColumnToVisible(nextColumn) + override func insertTab(_ sender: Any?) { + guard !moveFocusToNextCell() else { return } + window?.selectKeyView(following: self) } - private func handleShiftTabKey() { - let row = selectedRow - guard row >= 0, DataGridView.isDataTableColumn(focusedColumn) else { return } + override func insertBacktab(_ sender: Any?) { + guard !moveFocusToPreviousCell() else { return } + window?.selectKeyView(preceding: self) + } - var prevColumn = focusedColumn - 1 - var prevRow = row + private func moveFocusToNextCell() -> Bool { + let row = selectedRow + guard row >= 0, DataGridView.isDataTableColumn(focusedColumn) else { return false } - if !DataGridView.isDataTableColumn(prevColumn) { - prevColumn = numberOfColumns - 1 - prevRow -= 1 + var nextColumn = nextVisibleDataColumn(after: focusedColumn) + var nextRow = row + if nextColumn < 0 { + let wrapped = firstVisibleDataColumn() + guard wrapped >= 0, row + 1 < numberOfRows else { return true } + nextColumn = wrapped + nextRow = row + 1 } - if prevRow < 0 { - prevRow = 0 - prevColumn = DataGridView.firstDataTableColumnIndex + focusCell(row: nextRow, column: nextColumn) + return true + } + + private func moveFocusToPreviousCell() -> Bool { + let row = selectedRow + guard row >= 0, DataGridView.isDataTableColumn(focusedColumn) else { return false } + + var previousColumn = previousVisibleDataColumn(before: focusedColumn) + var previousRow = row + if previousColumn < 0 { + let wrapped = lastVisibleDataColumn() + guard wrapped >= 0, row > 0 else { return true } + previousColumn = wrapped + previousRow = row - 1 } + focusCell(row: previousRow, column: previousColumn) + return true + } - selectRowIndexes(IndexSet(integer: prevRow), byExtendingSelection: false) - focusedRow = prevRow - focusedColumn = prevColumn - scrollRowToVisible(prevRow) - scrollColumnToVisible(prevColumn) + private func focusCell(row: Int, column: Int) { + selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) + focusedRow = row + focusedColumn = column + scrollRowToVisible(row) + scrollColumnToVisible(column) + postCellCursorMoved() } override func rightMouseDown(with event: NSEvent) { diff --git a/TablePro/Views/Results/Selection/GridSelectionController.swift b/TablePro/Views/Results/Selection/GridSelectionController.swift index abcd75e56..4340bfde2 100644 --- a/TablePro/Views/Results/Selection/GridSelectionController.swift +++ b/TablePro/Views/Results/Selection/GridSelectionController.swift @@ -64,6 +64,9 @@ final class GridSelectionController { .priority: NSAccessibilityPriorityLevel.medium.rawValue ] ) + /// The announcement is a one-off sentence. This is the notification a table is supposed + /// to post so assistive technology can re-read the selection on its own terms. + NSAccessibility.post(element: tableView, notification: .selectedCellsChanged) } func clear() { diff --git a/TablePro/Views/Results/SortableHeaderCell.swift b/TablePro/Views/Results/SortableHeaderCell.swift index 2eacb4cbe..8c77a9d48 100644 --- a/TablePro/Views/Results/SortableHeaderCell.swift +++ b/TablePro/Views/Results/SortableHeaderCell.swift @@ -10,6 +10,7 @@ final class SortableHeaderCell: NSTableHeaderCell { var sortDirection: SortDirection? var sortPriority: Int? var isColumnSelected: Bool = false + var isEmphasized: Bool = true var isValueFiltered: Bool = false var isFunnelVisible: Bool = false var supportsValueFilter: Bool = true @@ -54,17 +55,20 @@ final class SortableHeaderCell: NSTableHeaderCell { override func drawInterior(withFrame cellFrame: NSRect, in controlView: NSView) { if isColumnSelected { - NSColor.selectedContentBackgroundColor.setFill() + let fill: NSColor = isEmphasized + ? .selectedContentBackgroundColor + : .unemphasizedSelectedContentBackgroundColor + fill.setFill() cellFrame.fill() } - let foreground = foregroundColor(emphasized: isColumnSelected) + let foreground = foregroundColor(emphasized: isColumnSelected && isEmphasized) drawTitle( in: titleRect(forBounds: cellFrame), font: titleFont(isSorted: sortDirection != nil), color: foreground, comment: visibleComment(in: controlView), - commentColor: commentColor(emphasized: isColumnSelected) + commentColor: commentColor(emphasized: isColumnSelected && isEmphasized) ) var trailingCursorX = cellFrame.maxX - Self.indicatorPadding diff --git a/TablePro/Views/Results/SortableHeaderView.swift b/TablePro/Views/Results/SortableHeaderView.swift index d5e40d439..612a10605 100644 --- a/TablePro/Views/Results/SortableHeaderView.swift +++ b/TablePro/Views/Results/SortableHeaderView.swift @@ -100,16 +100,56 @@ final class SortableHeaderView: NSTableHeaderView { return commentsByColumn[column.identifier] } + private var emphasisObservers: [NSObjectProtocol] = [] + override init(frame frameRect: NSRect) { naturalHeight = frameRect.height > 0 ? frameRect.height : Self.fallbackHeight super.init(frame: frameRect) } + deinit { + emphasisObservers.forEach(NotificationCenter.default.removeObserver) + } + required init?(coder: NSCoder) { naturalHeight = Self.fallbackHeight super.init(coder: coder) } + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + emphasisObservers.forEach(NotificationCenter.default.removeObserver) + emphasisObservers.removeAll() + guard let window else { + applyEmphasis(false) + return + } + for name in [NSWindow.didBecomeKeyNotification, NSWindow.didResignKeyNotification] { + let observer = NotificationCenter.default.addObserver( + forName: name, + object: window, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.applyEmphasis(window.isKeyWindow) } + } + emphasisObservers.append(observer) + } + applyEmphasis(window.isKeyWindow) + } + + private func applyEmphasis(_ isEmphasized: Bool) { + guard let tableView else { return } + var changed = false + for column in tableView.tableColumns { + guard let cell = column.headerCell as? SortableHeaderCell, + cell.isEmphasized != isEmphasized else { continue } + cell.isEmphasized = isEmphasized + changed = true + } + guard changed else { return } + needsDisplay = true + } + private func applyHeaderHeight() { let targetHeight = showsComments ? commentHeaderHeight : naturalHeight if frame.height != targetHeight { diff --git a/TablePro/Views/ServerDashboard/ServerDashboardSplitView.swift b/TablePro/Views/ServerDashboard/ServerDashboardSplitView.swift index 4f06942b4..2da1d53d8 100644 --- a/TablePro/Views/ServerDashboard/ServerDashboardSplitView.swift +++ b/TablePro/Views/ServerDashboard/ServerDashboardSplitView.swift @@ -22,6 +22,23 @@ struct ServerDashboardSplitView: NSViewControllerRepresentable { return splitViewController } + /// `NSSplitViewItem.minimumThickness` is a required constraint, so this controller reports + /// the summed minimums as its fitting size. SwiftUI turns that into a `minWidth` that outranks + /// a divider drag, which kills the window's own dividers. + func sizeThatFits( + _ proposal: ProposedViewSize, + nsViewController: NSSplitViewController, + context: Context + ) -> CGSize? { + let resolved = proposal.replacingUnspecifiedDimensions( + by: CGSize(width: Self.naturalSize, height: Self.naturalSize) + ) + guard resolved.width.isFinite, resolved.height.isFinite else { return nil } + return resolved + } + + private static let naturalSize: CGFloat = 480 + func updateNSViewController(_ splitViewController: NSSplitViewController, context: Context) { context.coordinator.sessionsController?.rootView = SessionsTableView(viewModel: viewModel) context.coordinator.metricsController?.rootView = MetricsBarView( @@ -44,6 +61,7 @@ struct ServerDashboardSplitView: NSViewControllerRepresentable { switch panel { case .activeSessions: let controller = NSHostingController(rootView: SessionsTableView(viewModel: viewModel)) + controller.sizingOptions = [] let item = NSSplitViewItem(viewController: controller) item.minimumThickness = 120 item.holdingPriority = .defaultLow @@ -57,6 +75,7 @@ struct ServerDashboardSplitView: NSViewControllerRepresentable { error: viewModel.panelErrors[.serverMetrics] ) ) + controller.sizingOptions = [] let item = NSSplitViewItem(viewController: controller) item.minimumThickness = 76 item.maximumThickness = 200 @@ -71,6 +90,7 @@ struct ServerDashboardSplitView: NSViewControllerRepresentable { error: viewModel.panelErrors[.slowQueries] ) ) + controller.sizingOptions = [] let item = NSSplitViewItem(viewController: controller) item.minimumThickness = 100 item.canCollapse = true diff --git a/TablePro/Views/Settings/AISettingsView.swift b/TablePro/Views/Settings/AISettingsView.swift index 7cbb49bea..b01241632 100644 --- a/TablePro/Views/Settings/AISettingsView.swift +++ b/TablePro/Views/Settings/AISettingsView.swift @@ -33,6 +33,7 @@ struct AISettingsView: View { } } .formStyle(.grouped) + .scrollContentBackground(.hidden) .task { refreshKeyAvailability() } .task { await chatGPTCodexService.refreshAuthState() } .task { await cursorAgentService.refreshStatus() } diff --git a/TablePro/Views/Settings/Appearance/ThemeListView.swift b/TablePro/Views/Settings/Appearance/ThemeListView.swift index a85e4c222..85fa296a7 100644 --- a/TablePro/Views/Settings/Appearance/ThemeListView.swift +++ b/TablePro/Views/Settings/Appearance/ThemeListView.swift @@ -4,6 +4,7 @@ import UniformTypeIdentifiers internal struct ThemeListView: View { @Binding var selectedThemeId: String + internal var slotAppearance: ThemeAppearance = .light private var engine: ThemeEngine { ThemeEngine.shared } @@ -12,15 +13,15 @@ internal struct ThemeListView: View { @State private var showError = false private var builtInThemes: [ThemeDefinition] { - engine.availableThemes.filter(\.isBuiltIn) + ThemeSlotValidation.eligibleThemes(engine.availableThemes.filter(\.isBuiltIn), slot: slotAppearance) } private var registryThemes: [ThemeDefinition] { - engine.registryThemes + ThemeSlotValidation.eligibleThemes(engine.registryThemes, slot: slotAppearance) } private var customThemes: [ThemeDefinition] { - engine.availableThemes.filter(\.isEditable) + ThemeSlotValidation.eligibleThemes(engine.availableThemes.filter(\.isEditable), slot: slotAppearance) } private var selectedTheme: ThemeDefinition? { @@ -81,6 +82,8 @@ internal struct ThemeListView: View { .menuIndicator(.hidden) .buttonStyle(.borderless) .frame(width: 28) + .help(Text("Add Theme")) + .accessibilityLabel(Text("Add Theme")) Button { showDeleteConfirmation = true @@ -90,6 +93,8 @@ internal struct ThemeListView: View { } .buttonStyle(.borderless) .disabled(isDeleteDisabled) + .help(Text("Delete Theme")) + .accessibilityLabel(Text("Delete Theme")) Menu { Button(String(localized: "Duplicate")) { @@ -111,6 +116,8 @@ internal struct ThemeListView: View { .menuIndicator(.hidden) .buttonStyle(.borderless) .frame(width: 28) + .help(Text("Theme Actions")) + .accessibilityLabel(Text("Theme Actions")) Spacer() } @@ -173,19 +180,27 @@ internal struct ThemeListView: View { } private func exportActiveTheme() { - guard let window = NSApp.keyWindow else { return } + guard let window = AlertHelper.resolveContentWindow(nil) else { return } let panel = NSSavePanel() panel.allowedContentTypes = [.json] panel.nameFieldStringValue = engine.activeTheme.name + ".json" panel.canCreateDirectories = true panel.beginSheetModal(for: window) { response in guard response == .OK, let url = panel.url else { return } - try? engine.exportTheme(engine.activeTheme, to: url) + do { + try engine.exportTheme(engine.activeTheme, to: url) + } catch { + AlertHelper.showErrorSheet( + title: String(localized: "Could not export the theme"), + message: error.localizedDescription, + window: window + ) + } } } private func importTheme() { - guard let window = NSApp.keyWindow else { return } + guard let window = AlertHelper.resolveContentWindow(nil) else { return } let panel = NSOpenPanel() panel.allowedContentTypes = [.json] panel.allowsMultipleSelection = false diff --git a/TablePro/Views/Settings/AppearanceSettingsView.swift b/TablePro/Views/Settings/AppearanceSettingsView.swift index d6d9a5990..57bde36ef 100644 --- a/TablePro/Views/Settings/AppearanceSettingsView.swift +++ b/TablePro/Views/Settings/AppearanceSettingsView.swift @@ -23,6 +23,29 @@ struct AppearanceSettingsView: View { chosenSlot ?? (ThemeEngine.shared.effectiveAppearance == .dark ? .dark : .light) } + private var slotAppearance: ThemeAppearance { + editSlot == .dark ? .dark : .light + } + + private var slotDefaultThemeId: String { + editSlot == .dark + ? AppearanceSettings.default.preferredDarkThemeId + : AppearanceSettings.default.preferredLightThemeId + } + + /// A slot that already holds a contradicting theme is re-anchored on read, so filtering the + /// list can never hide the row the user is standing on. + private func validateSlot() { + let resolved = ThemeSlotValidation.resolvedThemeId( + current: slotThemeBinding.wrappedValue, + slot: slotAppearance, + themes: ThemeEngine.shared.availableThemes, + defaultId: slotDefaultThemeId + ) + guard resolved != slotThemeBinding.wrappedValue else { return } + slotThemeBinding.wrappedValue = resolved + } + private var slotThemeBinding: Binding { Binding( get: { @@ -47,12 +70,13 @@ struct AppearanceSettingsView: View { .font(.callout) .foregroundStyle(.secondary) - Picker("", selection: $settings.appearanceMode) { + Picker(String(localized: "Appearance"), selection: $settings.appearanceMode) { ForEach(AppAppearanceMode.allCases, id: \.self) { mode in Text(mode.displayName).tag(mode) } } .pickerStyle(.segmented) + .labelsHidden() .fixedSize() Spacer() @@ -61,11 +85,12 @@ struct AppearanceSettingsView: View { .font(.callout) .foregroundStyle(.secondary) - Picker("", selection: Binding(get: { editSlot }, set: { chosenSlot = $0 })) { + Picker(String(localized: "Editing"), selection: Binding(get: { editSlot }, set: { chosenSlot = $0 })) { Text("Light").tag(ThemeEditSlot.light) Text("Dark").tag(ThemeEditSlot.dark) } .pickerStyle(.segmented) + .labelsHidden() .fixedSize() } .padding(.horizontal, 16) @@ -74,13 +99,14 @@ struct AppearanceSettingsView: View { Divider() HSplitView { - ThemeListView(selectedThemeId: slotThemeBinding) + ThemeListView(selectedThemeId: slotThemeBinding, slotAppearance: slotAppearance) .frame(minWidth: 180, idealWidth: 210, maxWidth: 250) ThemeEditorView(selectedThemeId: slotThemeBinding) .frame(minWidth: 400) } } + .task(id: editSlot) { validateSlot() } } } diff --git a/TablePro/Views/Settings/KeyboardSettingsView.swift b/TablePro/Views/Settings/KeyboardSettingsView.swift index 836322344..a03810ebe 100644 --- a/TablePro/Views/Settings/KeyboardSettingsView.swift +++ b/TablePro/Views/Settings/KeyboardSettingsView.swift @@ -51,6 +51,7 @@ struct KeyboardSettingsView: View { } } .formStyle(.grouped) + .scrollContentBackground(.hidden) } } diff --git a/TablePro/Views/Settings/Sections/MCPTokenCreateSheet.swift b/TablePro/Views/Settings/Sections/MCPTokenCreateSheet.swift index b465ef75c..4806d87ff 100644 --- a/TablePro/Views/Settings/Sections/MCPTokenCreateSheet.swift +++ b/TablePro/Views/Settings/Sections/MCPTokenCreateSheet.swift @@ -113,14 +113,12 @@ struct MCPTokenCreateSheet: View { } private var actionBar: some View { - HStack { + DialogFooter { Button(String(localized: "Cancel"), role: .cancel) { dismiss() } .keyboardShortcut(.cancelAction) - Spacer() - Button(String(localized: "Generate")) { let connectionIds: Set? = connectionAccess == .selected ? selectedConnectionIds : nil onGenerate(tokenName, permissions, connectionIds, resolvedExpirationDate) diff --git a/TablePro/Views/Settings/Sections/PairingApprovalSheet.swift b/TablePro/Views/Settings/Sections/PairingApprovalSheet.swift index 87362576b..115c4e205 100644 --- a/TablePro/Views/Settings/Sections/PairingApprovalSheet.swift +++ b/TablePro/Views/Settings/Sections/PairingApprovalSheet.swift @@ -204,14 +204,12 @@ struct PairingApprovalSheet: View { } private var actionBar: some View { - HStack { + DialogFooter { Button(String(localized: "Deny"), role: .cancel) { onComplete(.failure(MCPDataLayerError.userCancelled)) } .keyboardShortcut(.cancelAction) - Spacer() - Button(String(localized: "Approve")) { let approval = PairingApproval( grantedPermissions: permissions, diff --git a/TablePro/Views/Settings/SettingsView.swift b/TablePro/Views/Settings/SettingsView.swift index 01e46fb05..20a726e92 100644 --- a/TablePro/Views/Settings/SettingsView.swift +++ b/TablePro/Views/Settings/SettingsView.swift @@ -36,77 +36,3 @@ enum SettingsPane: String { } } } - -struct SettingsView: View { - @Bindable private var settingsManager = AppSettingsManager.shared - @Environment(UpdaterBridge.self) var updaterBridge - @AppStorage(PreferenceKeys.selectedSettingsPane.name) private var selectedTab = SettingsPane.general.rawValue - private let pluginManager = PluginManager.shared - - private var pluginAttentionCount: Int { - pluginManager.rejectedPlugins.count + pluginManager.pluginsWithRegistryUpdate.count - } - - private var selection: Binding { - Binding( - get: { SettingsPane(rawValue: selectedTab) == nil ? SettingsPane.general.rawValue : selectedTab }, - set: { selectedTab = $0 } - ) - } - - var body: some View { - TabView(selection: selection) { - GeneralSettingsView( - settings: $settingsManager.general, - tabSettings: $settingsManager.tabs, - updaterBridge: updaterBridge, - onResetAll: { settingsManager.resetToDefaults() } - ) - .tabItem { Label(SettingsPane.general.title, systemImage: SettingsPane.general.symbol) } - .tag(SettingsPane.general.rawValue) - - AppearanceSettingsView(settings: $settingsManager.appearance) - .tabItem { Label(SettingsPane.appearance.title, systemImage: SettingsPane.appearance.symbol) } - .tag(SettingsPane.appearance.rawValue) - - EditorSettingsView(settings: $settingsManager.editor) - .tabItem { Label(SettingsPane.editor.title, systemImage: SettingsPane.editor.symbol) } - .tag(SettingsPane.editor.rawValue) - - DataResultsSettingsView( - dataGrid: $settingsManager.dataGrid, - history: $settingsManager.history, - editor: $settingsManager.editor - ) - .tabItem { Label(SettingsPane.data.title, systemImage: SettingsPane.data.symbol) } - .tag(SettingsPane.data.rawValue) - - KeyboardSettingsView(settings: $settingsManager.keyboard) - .tabItem { Label(SettingsPane.keyboard.title, systemImage: SettingsPane.keyboard.symbol) } - .tag(SettingsPane.keyboard.rawValue) - - AISettingsView(settings: $settingsManager.ai) - .tabItem { Label(SettingsPane.ai.title, systemImage: SettingsPane.ai.symbol) } - .tag(SettingsPane.ai.rawValue) - - MCPSettingsView(settings: $settingsManager.mcp) - .tabItem { Label(SettingsPane.mcp.title, systemImage: SettingsPane.mcp.symbol) } - .tag(SettingsPane.mcp.rawValue) - - PluginsSettingsView() - .tabItem { Label(SettingsPane.plugins.title, systemImage: SettingsPane.plugins.symbol) } - .badge(pluginAttentionCount) - .tag(SettingsPane.plugins.rawValue) - - AccountSettingsView() - .tabItem { Label(SettingsPane.account.title, systemImage: SettingsPane.account.symbol) } - .tag(SettingsPane.account.rawValue) - } - .frame(width: 720, height: 500) - } -} - -#Preview { - SettingsView() - .environment(UpdaterBridge.shared) -} diff --git a/TablePro/Views/Settings/SettingsWindowController.swift b/TablePro/Views/Settings/SettingsWindowController.swift index ec56bcb39..9c0481fb5 100644 --- a/TablePro/Views/Settings/SettingsWindowController.swift +++ b/TablePro/Views/Settings/SettingsWindowController.swift @@ -32,7 +32,7 @@ internal final class SettingsWindowController: NSWindowController { let window = NSWindow(contentViewController: panes) window.title = String(localized: "Settings") window.identifier = NSUserInterfaceItemIdentifier(WindowIdentifier.settings) - window.styleMask = [.titled, .closable] + window.styleMask = [.titled, .closable, .resizable] window.toolbarStyle = .preference window.isRestorable = false window.setContentSize(SettingsPaneTabViewController.paneSize) @@ -42,6 +42,14 @@ internal final class SettingsWindowController: NSWindowController { if !window.setFrameUsingName(WindowIdentifier.settings) { window.center() } + /// A frame saved before the window became resizable can be smaller than any pane can + /// draw, so a restored frame is grown back to the pane minimum. + window.setContentSize( + NSSize( + width: max(window.contentLayoutRect.width, SettingsPaneTabViewController.paneSize.width), + height: max(window.contentLayoutRect.height, SettingsPaneTabViewController.paneSize.height) + ) + ) self.init(window: window) } } @@ -92,7 +100,12 @@ private final class SettingsPaneTabViewController: NSTabViewController { private func makeTabViewItem(for pane: SettingsPane) -> NSTabViewItem { let content = SettingsPaneContent(pane: pane) - .frame(width: Self.paneSize.width, height: Self.paneSize.height) + .frame( + minWidth: Self.paneSize.width, + maxWidth: .infinity, + minHeight: Self.paneSize.height, + maxHeight: .infinity + ) .environment(UpdaterBridge.shared) .environment(\.appServices, .live) let hosting = NSHostingController(rootView: content) diff --git a/TablePro/Views/Settings/ShortcutRecorderView.swift b/TablePro/Views/Settings/ShortcutRecorderView.swift index 4f3bd1f14..1fe5374ec 100644 --- a/TablePro/Views/Settings/ShortcutRecorderView.swift +++ b/TablePro/Views/Settings/ShortcutRecorderView.swift @@ -20,14 +20,28 @@ final class ShortcutRecorderNSView: NSView { /// The currently displayed key combo var currentCombo: BoundKey? { - didSet { needsDisplay = true } + didSet { + needsDisplay = true + NSAccessibility.post(element: self, notification: .valueChanged) + } } /// Whether the view is currently in recording mode private var isRecording = false { - didSet { needsDisplay = true } + didSet { + guard isRecording != oldValue else { return } + if isRecording { startRecordingMonitor() } else { stopRecordingMonitor() } + needsDisplay = true + NSAccessibility.post(element: self, notification: .valueChanged) + } } + /// A menu key equivalent is consumed in `sendEvent` before the responder chain runs, so + /// `keyDown` never sees Command W and the menu command fires instead of being recorded. A + /// local monitor runs earlier than menu dispatch, which is the only place the key is still + /// interceptable. + private var recordingMonitor: Any? + /// Currently held modifier flags during recording (for live display) private var activeModifiers: NSEvent.ModifierFlags = [] @@ -39,7 +53,6 @@ final class ShortcutRecorderNSView: NSView { layer?.cornerRadius = 6 focusRingType = .exterior setAccessibilityRole(.button) - setAccessibilityLabel(String(localized: "Record Shortcut")) } override func drawFocusRingMask() { @@ -87,51 +100,85 @@ final class ShortcutRecorderNSView: NSView { // MARK: - Keyboard Handling override func keyDown(with event: NSEvent) { - guard isRecording else { - let isActivation = event.keyCode == KeyCode.space.rawValue - || event.keyCode == KeyCode.return.rawValue - guard isActivation else { - super.keyDown(with: event) - return - } - isRecording = true - activeModifiers = [] + guard !isRecording else { return } + let isActivation = event.keyCode == KeyCode.space.rawValue + || event.keyCode == KeyCode.return.rawValue + guard isActivation else { + super.keyDown(with: event) return } + activeModifiers = [] + isRecording = true + } + + private func startRecordingMonitor() { + guard recordingMonitor == nil else { return } + recordingMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .flagsChanged]) { [weak self] event in + guard let self else { return event } + return self.handleRecordingEvent(event) + } + } + + private func stopRecordingMonitor() { + guard let monitor = recordingMonitor else { return } + NSEvent.removeMonitor(monitor) + recordingMonitor = nil + } + + /// A local monitor is app-wide, so anything arriving while this view's window is not key + /// belongs to another window and has to pass through untouched. + func handleRecordingEvent(_ event: NSEvent) -> NSEvent? { + guard isRecording, window?.isKeyWindow == true else { return event } + guard event.type != .flagsChanged else { + activeModifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + needsDisplay = true + return nil + } let isBareKey = !event.modifierFlags.contains(.command) && !event.modifierFlags.contains(.control) if event.keyCode == KeyCode.escape.rawValue, isBareKey { - window?.makeFirstResponder(nil) - return + endRecording() + return nil } - if event.keyCode == KeyCode.delete.rawValue, isBareKey { onClear?() - window?.makeFirstResponder(nil) - return + endRecording() + return nil } - - if let combo = BoundKey(from: event) { - onRecord?(combo) - window?.makeFirstResponder(nil) - } else { + guard let combo = BoundKey(from: event) else { NSSound.beep() + return nil } + onRecord?(combo) + endRecording() + return nil } - override func flagsChanged(with event: NSEvent) { - guard isRecording else { return } - activeModifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - needsDisplay = true + private func endRecording() { + isRecording = false + activeModifiers = [] + window?.makeFirstResponder(nil) + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + guard window == nil else { return } + isRecording = false + } + + deinit { + guard let monitor = recordingMonitor else { return } + NSEvent.removeMonitor(monitor) } // MARK: - Drawing override func draw(_ dirtyRect: NSRect) { let bounds = self.bounds + let isArmed = isRecording && window?.isKeyWindow == true && NSApp.isActive - if isRecording { + if isArmed { NSColor.controlAccentColor.withAlphaComponent(0.1).setFill() } else { NSColor.controlBackgroundColor.setFill() @@ -139,7 +186,7 @@ final class ShortcutRecorderNSView: NSView { let bgPath = NSBezierPath(roundedRect: bounds, xRadius: 6, yRadius: 6) bgPath.fill() - if isRecording { + if isArmed { NSColor.controlAccentColor.setStroke() } else { NSColor.separatorColor.setStroke() @@ -149,7 +196,7 @@ final class ShortcutRecorderNSView: NSView { xRadius: 6, yRadius: 6 ) - borderPath.lineWidth = isRecording ? 2.0 : 1.0 + borderPath.lineWidth = isArmed ? 2.0 : 1.0 borderPath.stroke() let text = displayText diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift index 07c52e661..9e481e4f4 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift @@ -652,6 +652,15 @@ extension DatabaseTreeOutlineCoordinator: NSOutlineViewDelegate { (item as? DatabaseTreeNode)?.tableRef != nil } + func outlineView( + _ outlineView: NSOutlineView, + typeSelectStringFor tableColumn: NSTableColumn?, + item: Any + ) -> String? { + guard let node = item as? DatabaseTreeNode else { return nil } + return DatabaseTreeTypeSelect.matchString(for: node.kind) + } + func outlineViewItemWillExpand(_ notification: Notification) { guard let node = notification.userInfo?["NSObject"] as? DatabaseTreeNode else { return } triggerLoad(for: node) @@ -680,12 +689,8 @@ extension DatabaseTreeOutlineCoordinator: NSOutlineViewDelegate { private var isKeyboardDrivenSelection: Bool { guard let outlineView, outlineView.window?.firstResponder === outlineView else { return false } - switch NSApp.currentEvent?.type { - case .keyDown, .keyUp: - return true - default: - return false - } + guard let event = NSApp.currentEvent else { return false } + return DatabaseTreeTypeSelect.isArrowNavigation(type: event.type, keyCode: event.keyCode) } private func scheduleSingleClickOpen(_ ref: DatabaseTreeTableRef) { diff --git a/TablePro/Views/Sidebar/DatabaseTreeRowView.swift b/TablePro/Views/Sidebar/DatabaseTreeRowView.swift index 9a9c3f816..b9f5b0213 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeRowView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeRowView.swift @@ -80,7 +80,7 @@ struct DatabaseTreeRowView: View { isPendingTruncate: context.pendingTruncates.contains(ref.table.name), isPendingDelete: context.pendingDeletes.contains(ref.table.name) ) - .foregroundStyle(isEmphasized ? AnyShapeStyle(.white) : AnyShapeStyle(.primary)) + .foregroundStyle(isEmphasized ? AnyShapeStyle(Color.emphasizedSelectionLabel) : AnyShapeStyle(.primary)) case .database(let metadata): header( text: metadata.name, @@ -102,10 +102,10 @@ struct DatabaseTreeRowView: View { isPendingTruncate: context.pendingTruncates.contains(ref.table.name), isPendingDelete: context.pendingDeletes.contains(ref.table.name) ) - .foregroundStyle(isEmphasized ? AnyShapeStyle(.white) : AnyShapeStyle(.primary)) + .foregroundStyle(isEmphasized ? AnyShapeStyle(Color.emphasizedSelectionLabel) : AnyShapeStyle(.primary)) case .routine(let ref): RoutineRowView(routine: ref.routine) - .foregroundStyle(isEmphasized ? AnyShapeStyle(.white) : AnyShapeStyle(.primary)) + .foregroundStyle(isEmphasized ? AnyShapeStyle(Color.emphasizedSelectionLabel) : AnyShapeStyle(.primary)) case .status(let status): statusRow(status) } @@ -221,9 +221,12 @@ struct DatabaseTreeRowView: View { } private func foreground(isActive: Bool, isSystem: Bool) -> AnyShapeStyle { - if isEmphasized { return AnyShapeStyle(.white) } - if isActive { return AnyShapeStyle(.tint) } - if isSystem { return AnyShapeStyle(.secondary) } - return AnyShapeStyle(.primary) + SidebarRowForeground.style( + for: SidebarRowForeground.role( + isEmphasized: isEmphasized, + isActive: isActive, + isSystem: isSystem + ) + ) } } diff --git a/TablePro/Views/Sidebar/DatabaseTreeTypeSelect.swift b/TablePro/Views/Sidebar/DatabaseTreeTypeSelect.swift new file mode 100644 index 000000000..e93cc70f8 --- /dev/null +++ b/TablePro/Views/Sidebar/DatabaseTreeTypeSelect.swift @@ -0,0 +1,36 @@ +// +// DatabaseTreeTypeSelect.swift +// TablePro +// + +import AppKit + +internal enum DatabaseTreeTypeSelect { + private static let upArrowKeyCode: UInt16 = 126 + private static let downArrowKeyCode: UInt16 = 125 + + /// Type-select delivers one selection change per typed letter. Treating every key event as a + /// deliberate open would fire a query per keystroke, so only the arrow keys, which are how a + /// list is actually walked, count as navigation. + internal static func isArrowNavigation(type: NSEvent.EventType, keyCode: UInt16) -> Bool { + guard type == .keyDown || type == .keyUp else { return false } + return keyCode == upArrowKeyCode || keyCode == downArrowKeyCode + } + + /// Group rows and status rows have no name a user would type, and returning a string for them + /// makes type-select land on a row that cannot be opened. + internal static func matchString(for kind: DatabaseTreeNode.Kind) -> String? { + switch kind { + case .recentSection, .status: + return nil + case .recentTable(let ref), .table(let ref): + return ref.table.name + case .database(let metadata): + return metadata.name + case .schema(_, let schema): + return schema + case .routine(let ref): + return ref.routine.name + } + } +} diff --git a/TablePro/Views/Sidebar/FavoritesTabView.swift b/TablePro/Views/Sidebar/FavoritesTabView.swift index d82f1139b..f385dd12d 100644 --- a/TablePro/Views/Sidebar/FavoritesTabView.swift +++ b/TablePro/Views/Sidebar/FavoritesTabView.swift @@ -637,12 +637,22 @@ internal struct FavoritesTabView: View { panel.allowsMultipleSelection = false panel.message = String(localized: "Choose a folder containing .sql files") - guard let window = NSApp.keyWindow else { return } + guard let window = AlertHelper.resolveContentWindow(nil) else { return } panel.beginSheetModal(for: window) { response in guard response == .OK, let url = panel.url else { return } let path = PathPortability.contractHome(url.path) let existing = LinkedSQLFolderStorage.shared.loadFolders() - guard !existing.contains(where: { $0.path == path }) else { return } + guard !existing.contains(where: { $0.path == path }) else { + AlertHelper.showInfoSheet( + title: String(localized: "This folder is already linked"), + message: String( + format: String(localized: "%@ is already in the favorites list."), + url.lastPathComponent + ), + window: window + ) + return + } LinkedSQLFolderStorage.shared.addFolder(LinkedSQLFolder(path: path)) SQLFolderWatcher.shared.reload() } diff --git a/TablePro/Views/Sidebar/RedisKeyTreeView.swift b/TablePro/Views/Sidebar/RedisKeyTreeView.swift index 1d162a8ca..632b34a90 100644 --- a/TablePro/Views/Sidebar/RedisKeyTreeView.swift +++ b/TablePro/Views/Sidebar/RedisKeyTreeView.swift @@ -61,6 +61,21 @@ internal struct RedisKeyTreeView: View { } } .buttonStyle(.plain) + .contextMenu { + Button(String(localized: "Copy Namespace Prefix")) { + ClipboardService.shared.writeText(fullPrefix) + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel( + Text( + String( + format: String(localized: "%1$@, %2$lld keys"), + name, + Int64(keyCount) + ) + ) + ) case .key(let name, let fullKey, let keyType): Button { onSelectKey?(fullKey, keyType) @@ -76,6 +91,16 @@ internal struct RedisKeyTreeView: View { } } .buttonStyle(.plain) + .contextMenu { + Button(String(localized: "Copy Key")) { + ClipboardService.shared.writeText(fullKey) + } + Button(String(localized: "Open in New Tab")) { + onSelectKey?(fullKey, keyType) + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel(Text(String(format: String(localized: "%1$@, %2$@"), name, keyType))) } } diff --git a/TablePro/Views/Sidebar/SidebarContextMenu.swift b/TablePro/Views/Sidebar/SidebarContextMenu.swift index 358845b86..eba73b9bd 100644 --- a/TablePro/Views/Sidebar/SidebarContextMenu.swift +++ b/TablePro/Views/Sidebar/SidebarContextMenu.swift @@ -15,6 +15,14 @@ enum SidebarContextMenuLogic { clickedTable?.type == .view } + /// AppKit's rule for a contextual menu over a list: a click inside the selection acts on the + /// whole selection, a click outside it acts on the row under the pointer and nothing else. + static func contextTargets(clickedTable: TableInfo?, selectedTables: Set) -> [String] { + guard let clickedTable else { return selectedTables.map(\.name).sorted() } + guard selectedTables.contains(clickedTable) else { return [clickedTable.name] } + return selectedTables.map(\.name).sorted() + } + static func isReadOnlyKind(_ type: TableInfo.TableType?) -> Bool { switch type { case .view, .materializedView, .foreignTable, .systemTable, .externalTable: @@ -72,10 +80,7 @@ struct SidebarContextMenu: View { } private var effectiveTableNames: [String] { - if selectedTables.isEmpty, let table = clickedTable { - return [table.name] - } - return selectedTables.map(\.name).sorted() + SidebarContextMenuLogic.contextTargets(clickedTable: clickedTable, selectedTables: selectedTables) } @MainActor diff --git a/TablePro/Views/Sidebar/SidebarRowForeground.swift b/TablePro/Views/Sidebar/SidebarRowForeground.swift new file mode 100644 index 000000000..e71251cd2 --- /dev/null +++ b/TablePro/Views/Sidebar/SidebarRowForeground.swift @@ -0,0 +1,33 @@ +// +// SidebarRowForeground.swift +// TablePro +// + +import SwiftUI + +internal enum SidebarRowForeground { + internal enum Role: Equatable { + case emphasized + case active + case system + case normal + } + + /// Emphasis outranks the active-object tint. A tinted label on an emphasized fill reads as + /// unselected, so the marker has to give way to legibility while the row is selected. + internal static func role(isEmphasized: Bool, isActive: Bool, isSystem: Bool) -> Role { + if isEmphasized { return .emphasized } + if isActive { return .active } + if isSystem { return .system } + return .normal + } + + internal static func style(for role: Role) -> AnyShapeStyle { + switch role { + case .emphasized: return AnyShapeStyle(Color.emphasizedSelectionLabel) + case .active: return AnyShapeStyle(.tint) + case .system: return AnyShapeStyle(.secondary) + case .normal: return AnyShapeStyle(.primary) + } + } +} diff --git a/TablePro/Views/Sidebar/SidebarTint.swift b/TablePro/Views/Sidebar/SidebarTint.swift index 12664b9a2..fdf524e05 100644 --- a/TablePro/Views/Sidebar/SidebarTint.swift +++ b/TablePro/Views/Sidebar/SidebarTint.swift @@ -10,7 +10,7 @@ private struct SidebarTint: ViewModifier { @Environment(\.backgroundProminence) private var backgroundProminence func body(content: Content) -> some View { - content.foregroundStyle(backgroundProminence == .increased ? Color.white : color) + content.foregroundStyle(backgroundProminence == .increased ? Color.emphasizedSelectionLabel : color) } } diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index c6299f919..b86aad341 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -129,21 +129,34 @@ struct SidebarView: View { coordinator?.toolbarState.databaseVersion = driver.serverVersion } } - .sheet(isPresented: $viewModel.showOperationDialog) { - if let operationType = viewModel.pendingOperationType { - let dialogTables = viewModel.pendingOperationTables - if let firstTable = dialogTables.first { - TableOperationDialog( - isPresented: $viewModel.showOperationDialog, - tableName: firstTable, - tableCount: dialogTables.count, - operationType: operationType, - databaseType: viewModel.databaseType - ) { options in - viewModel.confirmOperation(options: options) - } - } + .onChange(of: viewModel.showOperationDialog) { _, isPresented in + guard isPresented else { return } + presentOperationAlert() + } + } + + private func presentOperationAlert() { + guard let operationType = viewModel.pendingOperationType, + let firstTable = viewModel.pendingOperationTables.first + else { + viewModel.showOperationDialog = false + return + } + let prompt = TableOperationPrompt( + operationType: operationType, + tableName: firstTable, + tableCount: viewModel.pendingOperationTables.count, + cascadeSupported: PluginManager.shared.supportsCascadeDrop(for: viewModel.databaseType), + foreignKeyDisableSupported: PluginManager.shared.supportsForeignKeyDisable(for: viewModel.databaseType) + ) + let model = viewModel + TableOperationAlert.present(prompt: prompt, window: coordinator?.contentWindow) { options in + model.showOperationDialog = false + guard let options else { + model.cancelPendingOperation() + return } + model.confirmOperation(options: options) } } diff --git a/TablePro/Views/Sidebar/TableOperationAlert.swift b/TablePro/Views/Sidebar/TableOperationAlert.swift new file mode 100644 index 000000000..ec24b2a24 --- /dev/null +++ b/TablePro/Views/Sidebar/TableOperationAlert.swift @@ -0,0 +1,109 @@ +// +// TableOperationAlert.swift +// TablePro +// + +import AppKit + +@MainActor +internal enum TableOperationAlert { + private static let accessoryWidth: CGFloat = 280 + private static let descriptionIndent: CGFloat = 20 + + internal static func present( + prompt: TableOperationPrompt, + window: NSWindow?, + completion: @escaping @MainActor (TableOperationOptions?) -> Void + ) { + let ignoreForeignKeys = checkbox( + title: prompt.ignoreForeignKeysTitle, + isEnabled: prompt.isIgnoreForeignKeysEnabled + ) + let cascade = checkbox(title: prompt.cascadeTitle, isEnabled: prompt.isCascadeEnabled) + + let alert = NSAlert() + alert.messageText = prompt.messageText + alert.informativeText = prompt.informativeText + alert.alertStyle = .warning + AlertHelper.addConfirmAndCancel( + to: alert, + confirmButton: prompt.confirmButtonTitle, + cancelButton: prompt.cancelButtonTitle + ) + alert.accessoryView = accessoryView( + prompt: prompt, + ignoreForeignKeys: ignoreForeignKeys, + cascade: cascade + ) + alert.layout() + + let deliver: @MainActor (NSApplication.ModalResponse) -> Void = { response in + guard response == .alertFirstButtonReturn else { + completion(nil) + return + } + completion( + prompt.options( + ignoreForeignKeys: ignoreForeignKeys.state == .on, + cascade: cascade.state == .on + ) + ) + } + + guard let parent = AlertHelper.resolveWindow(window) else { + deliver(alert.runModal()) + return + } + alert.beginSheetModal(for: parent, completionHandler: deliver) + } + + private static func checkbox(title: String, isEnabled: Bool) -> NSButton { + let button = NSButton(checkboxWithTitle: title, target: nil, action: nil) + button.state = .off + button.isEnabled = isEnabled + return button + } + + private static func accessoryView( + prompt: TableOperationPrompt, + ignoreForeignKeys: NSButton, + cascade: NSButton + ) -> NSView { + var rows: [NSView] = [ignoreForeignKeys] + if let description = prompt.ignoreForeignKeysDescription { + rows.append(indented(descriptionLabel(description))) + } + rows.append(cascade) + rows.append(indented(descriptionLabel(prompt.cascadeDescription))) + + let stack = NSStackView(views: rows) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 6 + stack.translatesAutoresizingMaskIntoConstraints = false + for row in rows { + row.widthAnchor.constraint(equalToConstant: accessoryWidth).isActive = true + } + stack.layoutSubtreeIfNeeded() + stack.frame = NSRect(origin: .zero, size: stack.fittingSize) + return stack + } + + private static func descriptionLabel(_ text: String) -> NSTextField { + let label = NSTextField(labelWithString: text) + label.font = .systemFont(ofSize: NSFont.smallSystemFontSize) + label.textColor = .secondaryLabelColor + label.lineBreakMode = .byWordWrapping + label.maximumNumberOfLines = 0 + label.preferredMaxLayoutWidth = accessoryWidth - descriptionIndent + return label + } + + private static func indented(_ view: NSView) -> NSView { + let container = NSStackView(views: [view]) + container.orientation = .horizontal + container.alignment = .top + container.edgeInsets = NSEdgeInsets(top: 0, left: descriptionIndent, bottom: 0, right: 0) + return container + } +} diff --git a/TablePro/Views/Sidebar/TableOperationDialog.swift b/TablePro/Views/Sidebar/TableOperationDialog.swift deleted file mode 100644 index d4573fbb7..000000000 --- a/TablePro/Views/Sidebar/TableOperationDialog.swift +++ /dev/null @@ -1,223 +0,0 @@ -// -// TableOperationDialog.swift -// TablePro -// -// Confirmation dialog for table delete/truncate operations. -// Provides options for foreign key constraint handling and cascade operations. -// - -import os -import SwiftUI - -/// Confirmation dialog for table delete/truncate operations -struct TableOperationDialog: View { - private static let logger = Logger(subsystem: "com.TablePro", category: "TableOperationDialog") - - // MARK: - Properties - - @Binding var isPresented: Bool - let tableName: String - let tableCount: Int - let operationType: TableOperationType - let databaseType: DatabaseType - let onConfirm: (TableOperationOptions) -> Void - - // MARK: - State - - @State private var ignoreForeignKeys = false - @State private var cascade = false - - // MARK: - Computed Properties - - private var title: String { - switch operationType { - case .drop: - return tableCount > 1 - ? String(format: String(localized: "Drop %d tables"), tableCount) - : String(format: String(localized: "Drop table '%@'"), tableName) - case .truncate: - return tableCount > 1 - ? String(format: String(localized: "Truncate %d tables"), tableCount) - : String(format: String(localized: "Truncate table '%@'"), tableName) - } - } - - private var cascadeSupported: Bool { - PluginManager.shared.supportsCascadeDrop(for: databaseType) - } - - private var isMultipleTables: Bool { - tableCount > 1 - } - - private var cascadeDescription: String { - switch operationType { - case .drop: - return String(localized: "Drop all tables that depend on this table") - case .truncate: - if !cascadeSupported { - return String(localized: "Not supported for TRUNCATE with this database") - } - return String(localized: "Truncate all tables linked by foreign keys") - } - } - - private var cascadeDisabled: Bool { - if operationType == .truncate && !cascadeSupported { - return true - } - return !cascadeSupported - } - - private var ignoreFKDisabled: Bool { - !PluginManager.shared.supportsForeignKeyDisable(for: databaseType) - } - - private var ignoreFKDescription: String? { - if !PluginManager.shared.supportsForeignKeyDisable(for: databaseType) { - if cascadeSupported { - return String(localized: "Not supported for this database. Use CASCADE instead.") - } - return String(localized: "Not supported for this database.") - } - return nil - } - - // MARK: - Body - - var body: some View { - VStack(spacing: 0) { - Text(title) - .font(.body.weight(.semibold)) - .padding(.vertical, 16) - .padding(.horizontal, 20) - - Divider() - - VStack(alignment: .leading, spacing: 16) { - if isMultipleTables { - Text("Same options will be applied to all selected tables.") - .font(.subheadline) - .foregroundStyle(.secondary) - } - - VStack(alignment: .leading, spacing: 4) { - Toggle(isOn: $ignoreForeignKeys) { - Text("Ignore foreign key checks") - .font(.body) - } - .toggleStyle(.checkbox) - .disabled(ignoreFKDisabled) - .accessibilityHint(String(localized: "Skips foreign key constraint checks for this operation")) - - if let description = ignoreFKDescription { - Text(description) - .font(.subheadline) - .foregroundStyle(.secondary) - .padding(.leading, 20) - } - } - .opacity(ignoreFKDisabled ? 0.6 : 1.0) - - VStack(alignment: .leading, spacing: 4) { - Toggle(isOn: $cascade) { - Text("Cascade") - .font(.body) - } - .toggleStyle(.checkbox) - .disabled(cascadeDisabled) - .accessibilityHint(cascadeDescription) - - Text(cascadeDescription) - .font(.subheadline) - .foregroundStyle(.secondary) - .padding(.leading, 20) - } - .opacity(cascadeDisabled ? 0.6 : 1.0) - } - .padding(.horizontal, 20) - .padding(.vertical, 20) - - Divider() - - HStack { - Button("Cancel") { - isPresented = false - } - .keyboardShortcut(.cancelAction) - - Spacer() - - Button(operationType == .drop - ? String(localized: "Drop") - : String(localized: "Truncate") - ) { - confirmAndDismiss() - } - .buttonStyle(.borderedProminent) - .keyboardShortcut(.defaultAction) - } - .padding(12) - } - .frame(width: 320) - .background(Color(nsColor: .windowBackgroundColor)) - .onExitCommand { - isPresented = false - } - .onAppear { - ignoreForeignKeys = false - cascade = false - } - } - - private func confirmAndDismiss() { - // Values are already reset when their toggles become disabled, - // so we can pass them directly without override checks - let options = TableOperationOptions( - ignoreForeignKeys: ignoreForeignKeys, - cascade: cascade - ) - onConfirm(options) - isPresented = false - } -} - -// MARK: - Preview - -private let previewLogger = Logger(subsystem: "com.TablePro", category: "TableOperationDialog") - -#Preview("Drop Table - MySQL") { - TableOperationDialog( - isPresented: .constant(true), - tableName: "users", - tableCount: 1, - operationType: .drop, - databaseType: .mysql - ) { options in - previewLogger.debug("Options: \(String(describing: options), privacy: .public)") - } -} - -#Preview("Truncate Table - PostgreSQL") { - TableOperationDialog( - isPresented: .constant(true), - tableName: "orders", - tableCount: 1, - operationType: .truncate, - databaseType: .postgresql - ) { options in - previewLogger.debug("Options: \(String(describing: options), privacy: .public)") - } -} - -#Preview("Drop Table - SQLite") { - TableOperationDialog( - isPresented: .constant(true), - tableName: "products", - tableCount: 1, - operationType: .drop, - databaseType: .sqlite - ) { options in - previewLogger.debug("Options: \(String(describing: options), privacy: .public)") - } -} diff --git a/TablePro/Views/Structure/TableStructureView+Schema.swift b/TablePro/Views/Structure/TableStructureView+Schema.swift index cd2bdddc0..7d4e8c662 100644 --- a/TablePro/Views/Structure/TableStructureView+Schema.swift +++ b/TablePro/Views/Structure/TableStructureView+Schema.swift @@ -250,6 +250,11 @@ extension TableStructureView { try ddlStatement.write(to: url, atomically: true, encoding: .utf8) } catch { Self.logger.error("Failed to export: \(error.localizedDescription, privacy: .public)") + AlertHelper.showErrorSheet( + title: String(localized: "Could not export the schema"), + message: error.localizedDescription, + window: window + ) } } } diff --git a/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift b/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift index ac4595bd0..87a37c399 100644 --- a/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift +++ b/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift @@ -152,7 +152,7 @@ struct ConnectionSwitcherPopover: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .onChange(of: selectedConnectionId) { _, newValue in guard let id = newValue else { return } - withAnimation(.easeInOut(duration: 0.15)) { + withMotion(.easeInOut(duration: 0.15)) { proxy.scrollTo(id) } } diff --git a/TablePro/Views/Toolbar/TableProToolbarView.swift b/TablePro/Views/Toolbar/TableProToolbarView.swift index d0eea5ee7..608f3e33f 100644 --- a/TablePro/Views/Toolbar/TableProToolbarView.swift +++ b/TablePro/Views/Toolbar/TableProToolbarView.swift @@ -98,7 +98,7 @@ struct ToolbarPrincipalContent: View { private func tagBadge(_ tag: ConnectionTag) -> some View { Text(tag.name.uppercased()) .font(.caption.weight(.semibold)) - .foregroundStyle(.white) + .foregroundStyle(Color.legibleForeground(on: tag.color.color)) .lineLimit(1) .padding(.horizontal, 8) .padding(.vertical, 3) diff --git a/TableProTests/Core/Diff/SplitDiffMarkerTests.swift b/TableProTests/Core/Diff/SplitDiffMarkerTests.swift new file mode 100644 index 000000000..b46c9b6a8 --- /dev/null +++ b/TableProTests/Core/Diff/SplitDiffMarkerTests.swift @@ -0,0 +1,42 @@ +// +// SplitDiffMarkerTests.swift +// TableProTests +// + +@testable import TablePro +import Testing + +@Suite("Split diff marker") +struct SplitDiffMarkerTests { + @Test("A removed line is marked only on the before side") + func removedMarksBeforeOnly() { + #expect(SplitDiffMarker.resolve(kind: .removed, side: .before) == .removed) + #expect(SplitDiffMarker.resolve(kind: .removed, side: .after) == nil) + } + + @Test("An added line is marked only on the after side") + func addedMarksAfterOnly() { + #expect(SplitDiffMarker.resolve(kind: .added, side: .after) == .added) + #expect(SplitDiffMarker.resolve(kind: .added, side: .before) == nil) + } + + @Test("A changed line is marked on both sides") + func changedMarksBothSides() { + #expect(SplitDiffMarker.resolve(kind: .changed, side: .before) == .changed) + #expect(SplitDiffMarker.resolve(kind: .changed, side: .after) == .changed) + } + + @Test("An unchanged line carries no marker") + func unchangedHasNoMarker() { + #expect(SplitDiffMarker.resolve(kind: .unchanged, side: .before) == nil) + #expect(SplitDiffMarker.resolve(kind: .unchanged, side: .after) == nil) + } + + @Test("Every marker has a distinct glyph and label") + func markersAreDistinct() { + let markers: [SplitDiffMarker] = [.added, .removed, .changed] + #expect(Set(markers.map(\.glyph)).count == markers.count) + #expect(Set(markers.map(\.label)).count == markers.count) + #expect(markers.map(\.label).contains(SplitDiffMarker.unchangedLabel) == false) + } +} diff --git a/TableProTests/Core/Services/FileDropDestinationTests.swift b/TableProTests/Core/Services/FileDropDestinationTests.swift new file mode 100644 index 000000000..9a7015810 --- /dev/null +++ b/TableProTests/Core/Services/FileDropDestinationTests.swift @@ -0,0 +1,51 @@ +// +// FileDropDestinationTests.swift +// TableProTests +// + +import AppKit +@testable import TablePro +import Testing + +@Suite("File drop destination") +@MainActor +struct FileDropDestinationTests { + @Test("A SQL file is openable") + func sqlFileIsOpenable() { + #expect(FileDropDestination.isOpenable(URL(fileURLWithPath: "/tmp/report.sql"))) + } + + @Test("A binary is refused") + func binaryIsRefused() { + #expect(FileDropDestination.isOpenable(URL(fileURLWithPath: "/tmp/photo.jpeg")) == false) + } + + @Test("A non-file URL is refused") + func remoteURLIsRefused() { + guard let url = URL(string: "https://example.com/report.sql") else { + Issue.record("could not build the test URL") + return + } + #expect(FileDropDestination.isOpenable(url) == false) + } + + @Test("An empty pasteboard yields nothing") + func emptyPasteboardYieldsNothing() { + let pasteboard = NSPasteboard(name: NSPasteboard.Name("com.TablePro.tests.filedrop.empty")) + pasteboard.clearContents() + #expect(FileDropDestination.acceptedURLs(from: pasteboard).isEmpty) + } + + @Test("A pasteboard of mixed files yields only the openable ones") + func mixedPasteboardIsFiltered() { + let pasteboard = NSPasteboard(name: NSPasteboard.Name("com.TablePro.tests.filedrop.mixed")) + pasteboard.clearContents() + pasteboard.writeObjects([ + URL(fileURLWithPath: "/tmp/report.sql") as NSURL, + URL(fileURLWithPath: "/tmp/photo.jpeg") as NSURL + ]) + let accepted = FileDropDestination.acceptedURLs(from: pasteboard) + #expect(accepted.count == 1) + #expect(accepted.first?.pathExtension == "sql") + } +} diff --git a/TableProTests/Core/Services/PersistedTabRoundTripTests.swift b/TableProTests/Core/Services/PersistedTabRoundTripTests.swift index 78a138928..eab2a1237 100644 --- a/TableProTests/Core/Services/PersistedTabRoundTripTests.swift +++ b/TableProTests/Core/Services/PersistedTabRoundTripTests.swift @@ -171,7 +171,8 @@ struct PersistedTabRoundTripTests { @Test("windowGroupIndex encodes and decodes") func windowGroupIndexRoundTrip() throws { - let tab = tableTab().toPersistedTab(windowGroupIndex: 2) + var tab = tableTab().toPersistedTab() + tab.windowGroupIndex = 2 let data = try JSONEncoder().encode(tab) let decoded = try JSONDecoder().decode(PersistedTab.self, from: data) #expect(decoded.windowGroupIndex == 2) diff --git a/TableProTests/Core/Utilities/AlertWindowResolutionTests.swift b/TableProTests/Core/Utilities/AlertWindowResolutionTests.swift new file mode 100644 index 000000000..052b38233 --- /dev/null +++ b/TableProTests/Core/Utilities/AlertWindowResolutionTests.swift @@ -0,0 +1,68 @@ +// +// AlertWindowResolutionTests.swift +// TableProTests +// + +import AppKit +@testable import TablePro +import Testing + +@Suite("Alert window resolution") +@MainActor +struct AlertWindowResolutionTests { + private func makeWindow() -> NSWindow { + NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 200, height: 200), + styleMask: [.titled, .closable], + backing: .buffered, + defer: true + ) + } + + private func makePanel() -> NSPanel { + NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 200, height: 200), + styleMask: [.titled, .nonactivatingPanel], + backing: .buffered, + defer: true + ) + } + + @Test("A titled window is a content window") + func titledWindowQualifies() { + #expect(AlertHelper.isContentWindow(makeWindow())) + } + + @Test("A floating panel is not a content window") + func panelIsRejected() { + #expect(AlertHelper.isContentWindow(makePanel()) == false) + } + + @Test("An explicit window is honoured without a search") + func explicitWindowWins() { + let window = makeWindow() + #expect(AlertHelper.resolveContentWindow(window) === window) + } + + @Test("A titled panel is still rejected") + func titledPanelIsRejected() { + let panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 200, height: 200), + styleMask: [.titled, .closable], + backing: .buffered, + defer: true + ) + #expect(AlertHelper.isContentWindow(panel) == false) + } + + @Test("A borderless window is rejected") + func borderlessWindowIsRejected() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 200, height: 200), + styleMask: [.borderless], + backing: .buffered, + defer: true + ) + #expect(AlertHelper.isContentWindow(window) == false) + } +} diff --git a/TableProTests/Core/Utilities/DestructiveAlertDefaultsTests.swift b/TableProTests/Core/Utilities/DestructiveAlertDefaultsTests.swift new file mode 100644 index 000000000..94ba66aa1 --- /dev/null +++ b/TableProTests/Core/Utilities/DestructiveAlertDefaultsTests.swift @@ -0,0 +1,84 @@ +// +// DestructiveAlertDefaultsTests.swift +// TableProTests +// + +import AppKit +@testable import TablePro +import Testing + +@Suite("Destructive alert defaults") +@MainActor +struct DestructiveAlertDefaultsTests { + private func returnKeyButtonCount(_ alert: NSAlert) -> Int { + alert.buttons.filter { $0.keyEquivalent == "\r" }.count + } + + // MARK: - Inspector Delete + + @Test("Inspector delete alert makes cancel the default button") + func inspectorDeleteDefaultsToCancel() { + let alert = InspectorDeleteConfirmation.makeAlert(messageText: "Delete this row?") + #expect(alert.buttons.count == 2) + #expect(alert.buttons[0].keyEquivalent == "") + #expect(alert.buttons[1].keyEquivalent == "\r") + #expect(returnKeyButtonCount(alert) == 1) + } + + @Test("Inspector delete alert marks the delete button destructive") + func inspectorDeleteIsDestructive() { + let alert = InspectorDeleteConfirmation.makeAlert(messageText: "Delete this column?") + #expect(alert.buttons[0].hasDestructiveAction) + } + + // MARK: - External Connection + + @Test("External connection alert makes cancel the default button") + func externalConnectionDefaultsToCancel() { + let connection = DatabaseConnection(name: "External", type: .mysql) + let alert = ExternalConnectionAlertPrompt.makeAlert(for: connection, offerAlwaysAllow: false) + #expect(alert.buttons.count == 2) + #expect(alert.buttons[0].keyEquivalent == "") + #expect(alert.buttons[1].keyEquivalent == "\r") + #expect(returnKeyButtonCount(alert) == 1) + } + + @Test("Always Allow does not take the return key") + func externalConnectionWithAlwaysAllow() { + let connection = DatabaseConnection(name: "External", type: .postgresql) + let alert = ExternalConnectionAlertPrompt.makeAlert(for: connection, offerAlwaysAllow: true) + #expect(alert.buttons.count == 3) + #expect(alert.buttons[1].keyEquivalent == "\r") + #expect(alert.buttons[2].keyEquivalent == "") + #expect(returnKeyButtonCount(alert) == 1) + } + + @Test("Connecting is not presented as a destructive action") + func externalConnectionIsNotDestructive() { + let connection = DatabaseConnection(name: "External", type: .mysql) + let alert = ExternalConnectionAlertPrompt.makeAlert(for: connection, offerAlwaysAllow: false) + #expect(alert.buttons[0].hasDestructiveAction == false) + } + + // MARK: - Table Operations + + @Test("Drop alert makes cancel the default button") + func dropAlertDefaultsToCancel() { + let alert = NSAlert() + AlertHelper.addConfirmAndCancel( + to: alert, + confirmButton: TableOperationPrompt( + operationType: .drop, + tableName: "users", + tableCount: 1, + cascadeSupported: false, + foreignKeyDisableSupported: false + ).confirmButtonTitle, + cancelButton: String(localized: "Cancel") + ) + #expect(alert.buttons[0].keyEquivalent == "") + #expect(alert.buttons[0].hasDestructiveAction) + #expect(alert.buttons[1].keyEquivalent == "\r") + #expect(returnKeyButtonCount(alert) == 1) + } +} diff --git a/TableProTests/Models/TableOperationDialogLogicTests.swift b/TableProTests/Models/TableOperationDialogLogicTests.swift deleted file mode 100644 index e318be698..000000000 --- a/TableProTests/Models/TableOperationDialogLogicTests.swift +++ /dev/null @@ -1,292 +0,0 @@ -// -// TableOperationDialogLogicTests.swift -// TableProTests -// -// Tests for TableOperationDialog computed property logic and TableOperationOptions model. -// - -import Foundation -import TableProPluginKit -import Testing -@testable import TablePro - -@Suite("TableOperationDialog Logic") -struct TableOperationDialogLogicTests { - - // MARK: - Dialog Logic Helper - - private enum DialogLogic { - static func title(tableName: String, tableCount: Int, operationType: TableOperationType) -> String { - switch operationType { - case .drop: - return tableCount > 1 - ? "Drop \(tableCount) tables" - : "Drop table '\(tableName)'" - case .truncate: - return tableCount > 1 - ? "Truncate \(tableCount) tables" - : "Truncate table '\(tableName)'" - } - } - - static func isMultipleTables(tableCount: Int) -> Bool { - tableCount > 1 - } - - static func cascadeSupported(databaseType: DatabaseType) -> Bool { - databaseType == .postgresql - } - - static func cascadeDisabled(operationType: TableOperationType, databaseType: DatabaseType) -> Bool { - if operationType == .truncate && (databaseType == .mysql || databaseType == .mariadb) { - return true - } - return !cascadeSupported(databaseType: databaseType) - } - - static func ignoreFKDisabled(databaseType: DatabaseType) -> Bool { - databaseType == .postgresql - } - - static func ignoreFKDescription(databaseType: DatabaseType) -> String? { - if databaseType == .postgresql { - return "Not supported for PostgreSQL. Use CASCADE instead." - } - return nil - } - - static func cascadeDescription(operationType: TableOperationType, databaseType: DatabaseType) -> String { - switch operationType { - case .drop: - return "Drop all tables that depend on this table" - case .truncate: - if databaseType == .mysql || databaseType == .mariadb { - return "Not supported for TRUNCATE in MySQL/MariaDB" - } - return "Truncate all tables linked by foreign keys" - } - } - } - - // MARK: - Title Logic - - @Test("Drop single table title") - func testDropSingleTableTitle() { - let result = DialogLogic.title(tableName: "users", tableCount: 1, operationType: .drop) - #expect(result == "Drop table 'users'") - } - - @Test("Drop multiple tables title") - func testDropMultipleTablesTitle() { - let result = DialogLogic.title(tableName: "users", tableCount: 3, operationType: .drop) - #expect(result == "Drop 3 tables") - } - - @Test("Truncate single table title") - func testTruncateSingleTableTitle() { - let result = DialogLogic.title(tableName: "orders", tableCount: 1, operationType: .truncate) - #expect(result == "Truncate table 'orders'") - } - - @Test("Truncate multiple tables title") - func testTruncateMultipleTablesTitle() { - let result = DialogLogic.title(tableName: "orders", tableCount: 5, operationType: .truncate) - #expect(result == "Truncate 5 tables") - } - - // MARK: - isMultipleTables - - @Test("tableCount 1 is not multiple") - func testSingleTableNotMultiple() { - #expect(DialogLogic.isMultipleTables(tableCount: 1) == false) - } - - @Test("tableCount 2 is multiple") - func testTwoTablesIsMultiple() { - #expect(DialogLogic.isMultipleTables(tableCount: 2) == true) - } - - @Test("tableCount 0 is not multiple") - func testZeroTablesNotMultiple() { - #expect(DialogLogic.isMultipleTables(tableCount: 0) == false) - } - - // MARK: - cascadeSupported - - @Test("PostgreSQL supports cascade") - func testPostgreSQLCascadeSupported() { - #expect(DialogLogic.cascadeSupported(databaseType: .postgresql) == true) - } - - @Test("MySQL does not support cascade") - func testMySQLCascadeNotSupported() { - #expect(DialogLogic.cascadeSupported(databaseType: .mysql) == false) - } - - @Test("MariaDB does not support cascade") - func testMariaDBCascadeNotSupported() { - #expect(DialogLogic.cascadeSupported(databaseType: .mariadb) == false) - } - - @Test("SQLite does not support cascade") - func testSQLiteCascadeNotSupported() { - #expect(DialogLogic.cascadeSupported(databaseType: .sqlite) == false) - } - - @Test("MongoDB does not support cascade") - func testMongoDBCascadeNotSupported() { - #expect(DialogLogic.cascadeSupported(databaseType: .mongodb) == false) - } - - // MARK: - cascadeDisabled - - @Test("PostgreSQL drop cascade is enabled") - func testPostgreSQLDropCascadeEnabled() { - #expect(DialogLogic.cascadeDisabled(operationType: .drop, databaseType: .postgresql) == false) - } - - @Test("PostgreSQL truncate cascade is enabled") - func testPostgreSQLTruncateCascadeEnabled() { - #expect(DialogLogic.cascadeDisabled(operationType: .truncate, databaseType: .postgresql) == false) - } - - @Test("MySQL drop cascade is disabled") - func testMySQLDropCascadeDisabled() { - #expect(DialogLogic.cascadeDisabled(operationType: .drop, databaseType: .mysql) == true) - } - - @Test("MySQL truncate cascade is disabled") - func testMySQLTruncateCascadeDisabled() { - #expect(DialogLogic.cascadeDisabled(operationType: .truncate, databaseType: .mysql) == true) - } - - @Test("MariaDB drop cascade is disabled") - func testMariaDBDropCascadeDisabled() { - #expect(DialogLogic.cascadeDisabled(operationType: .drop, databaseType: .mariadb) == true) - } - - @Test("MariaDB truncate cascade is disabled") - func testMariaDBTruncateCascadeDisabled() { - #expect(DialogLogic.cascadeDisabled(operationType: .truncate, databaseType: .mariadb) == true) - } - - @Test("SQLite drop cascade is disabled") - func testSQLiteDropCascadeDisabled() { - #expect(DialogLogic.cascadeDisabled(operationType: .drop, databaseType: .sqlite) == true) - } - - @Test("SQLite truncate cascade is disabled") - func testSQLiteTruncateCascadeDisabled() { - #expect(DialogLogic.cascadeDisabled(operationType: .truncate, databaseType: .sqlite) == true) - } - - // MARK: - ignoreFKDisabled - - @Test("PostgreSQL ignore FK is disabled") - func testPostgreSQLIgnoreFKDisabled() { - #expect(DialogLogic.ignoreFKDisabled(databaseType: .postgresql) == true) - } - - @Test("MySQL ignore FK is enabled") - func testMySQLIgnoreFKEnabled() { - #expect(DialogLogic.ignoreFKDisabled(databaseType: .mysql) == false) - } - - @Test("MariaDB ignore FK is enabled") - func testMariaDBIgnoreFKEnabled() { - #expect(DialogLogic.ignoreFKDisabled(databaseType: .mariadb) == false) - } - - @Test("SQLite ignore FK is enabled") - func testSQLiteIgnoreFKEnabled() { - #expect(DialogLogic.ignoreFKDisabled(databaseType: .sqlite) == false) - } - - // MARK: - ignoreFKDescription - - @Test("PostgreSQL ignore FK description mentions CASCADE") - func testPostgreSQLIgnoreFKDescription() { - let description = DialogLogic.ignoreFKDescription(databaseType: .postgresql) - #expect(description != nil) - #expect(description!.contains("CASCADE")) - } - - @Test("MySQL ignore FK description is nil") - func testMySQLIgnoreFKDescription() { - #expect(DialogLogic.ignoreFKDescription(databaseType: .mysql) == nil) - } - - @Test("SQLite ignore FK description is nil") - func testSQLiteIgnoreFKDescription() { - #expect(DialogLogic.ignoreFKDescription(databaseType: .sqlite) == nil) - } - - // MARK: - cascadeDescription - - @Test("Drop cascade description mentions depend on this table") - func testDropCascadeDescription() { - let result = DialogLogic.cascadeDescription(operationType: .drop, databaseType: .postgresql) - #expect(result.contains("depend on this table")) - } - - @Test("Truncate PostgreSQL cascade description mentions foreign keys") - func testTruncatePostgreSQLCascadeDescription() { - let result = DialogLogic.cascadeDescription(operationType: .truncate, databaseType: .postgresql) - #expect(result.contains("foreign keys")) - } - - @Test("Truncate MySQL cascade description mentions Not supported") - func testTruncateMySQLCascadeDescription() { - let result = DialogLogic.cascadeDescription(operationType: .truncate, databaseType: .mysql) - #expect(result.contains("Not supported")) - } - - @Test("Truncate MariaDB cascade description mentions Not supported") - func testTruncateMariaDBCascadeDescription() { - let result = DialogLogic.cascadeDescription(operationType: .truncate, databaseType: .mariadb) - #expect(result.contains("Not supported")) - } - - // MARK: - TableOperationOptions - - @Test("Default options have ignoreForeignKeys false and cascade false") - func testDefaultOptions() { - let options = TableOperationOptions() - #expect(options.ignoreForeignKeys == false) - #expect(options.cascade == false) - } - - @Test("TableOperationOptions Equatable") - func testOptionsEquatable() { - let a = TableOperationOptions(ignoreForeignKeys: true, cascade: false) - let b = TableOperationOptions(ignoreForeignKeys: true, cascade: false) - let c = TableOperationOptions(ignoreForeignKeys: false, cascade: true) - #expect(a == b) - #expect(a != c) - } - - @Test("TableOperationOptions Codable roundtrip") - func testOptionsCodableRoundtrip() throws { - let original = TableOperationOptions(ignoreForeignKeys: true, cascade: true) - let data = try JSONEncoder().encode(original) - let decoded = try JSONDecoder().decode(TableOperationOptions.self, from: data) - #expect(decoded == original) - } - - // MARK: - TableOperationType - - @Test("TableOperationType raw values") - func testOperationTypeRawValues() { - #expect(TableOperationType.truncate.rawValue == "truncate") - #expect(TableOperationType.drop.rawValue == "drop") - } - - @Test("TableOperationType Codable roundtrip") - func testOperationTypeCodableRoundtrip() throws { - for operationType in [TableOperationType.truncate, TableOperationType.drop] { - let data = try JSONEncoder().encode(operationType) - let decoded = try JSONDecoder().decode(TableOperationType.self, from: data) - #expect(decoded == operationType) - } - } -} diff --git a/TableProTests/Models/TableOperationPromptTests.swift b/TableProTests/Models/TableOperationPromptTests.swift new file mode 100644 index 000000000..126a7d887 --- /dev/null +++ b/TableProTests/Models/TableOperationPromptTests.swift @@ -0,0 +1,196 @@ +// +// TableOperationPromptTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("TableOperationPrompt") +struct TableOperationPromptTests { + private func prompt( + _ operationType: TableOperationType, + tableName: String = "users", + tableCount: Int = 1, + cascadeSupported: Bool = false, + foreignKeyDisableSupported: Bool = false + ) -> TableOperationPrompt { + TableOperationPrompt( + operationType: operationType, + tableName: tableName, + tableCount: tableCount, + cascadeSupported: cascadeSupported, + foreignKeyDisableSupported: foreignKeyDisableSupported + ) + } + + // MARK: - Message Text + + @Test("Drop single table names the table") + func dropSingleTableMessage() { + #expect(prompt(.drop).messageText == "Drop table 'users'") + } + + @Test("Drop multiple tables counts them") + func dropMultipleTablesMessage() { + #expect(prompt(.drop, tableCount: 3).messageText == "Drop 3 tables") + } + + @Test("Truncate single table names the table") + func truncateSingleTableMessage() { + #expect(prompt(.truncate, tableName: "orders").messageText == "Truncate table 'orders'") + } + + @Test("Truncate multiple tables counts them") + func truncateMultipleTablesMessage() { + #expect(prompt(.truncate, tableCount: 5).messageText == "Truncate 5 tables") + } + + // MARK: - Informative Text + + @Test("Single table has no informative text") + func singleTableInformativeText() { + #expect(prompt(.drop).informativeText.isEmpty) + } + + @Test("Multiple tables explain that options are shared") + func multipleTablesInformativeText() { + #expect(prompt(.drop, tableCount: 2).informativeText.isEmpty == false) + } + + // MARK: - Buttons + + @Test("Confirm button carries the operation verb") + func confirmButtonTitles() { + #expect(prompt(.drop).confirmButtonTitle == "Drop") + #expect(prompt(.truncate).confirmButtonTitle == "Truncate") + } + + // MARK: - Cascade + + @Test("Cascade is enabled only when the driver supports it") + func cascadeEnablement() { + #expect(prompt(.drop, cascadeSupported: true).isCascadeEnabled) + #expect(prompt(.drop, cascadeSupported: false).isCascadeEnabled == false) + #expect(prompt(.truncate, cascadeSupported: true).isCascadeEnabled) + #expect(prompt(.truncate, cascadeSupported: false).isCascadeEnabled == false) + } + + @Test("Drop cascade description explains the dependency") + func dropCascadeDescription() { + #expect(prompt(.drop, cascadeSupported: true).cascadeDescription.contains("depend on this table")) + } + + @Test("Truncate cascade description explains foreign keys when supported") + func truncateCascadeDescriptionSupported() { + #expect(prompt(.truncate, cascadeSupported: true).cascadeDescription.contains("foreign keys")) + } + + @Test("Truncate cascade description says unsupported when it is") + func truncateCascadeDescriptionUnsupported() { + #expect(prompt(.truncate, cascadeSupported: false).cascadeDescription.contains("Not supported")) + } + + // MARK: - Ignore Foreign Keys + + @Test("Ignore foreign keys is enabled only when the driver supports it") + func ignoreForeignKeysEnablement() { + #expect(prompt(.drop, foreignKeyDisableSupported: true).isIgnoreForeignKeysEnabled) + #expect(prompt(.drop, foreignKeyDisableSupported: false).isIgnoreForeignKeysEnabled == false) + } + + @Test("Supported drivers show no explanation") + func ignoreForeignKeysDescriptionSupported() { + #expect(prompt(.drop, foreignKeyDisableSupported: true).ignoreForeignKeysDescription == nil) + } + + @Test("Unsupported drivers that cascade point at CASCADE") + func ignoreForeignKeysDescriptionSuggestsCascade() { + let description = prompt(.drop, cascadeSupported: true, foreignKeyDisableSupported: false) + .ignoreForeignKeysDescription + #expect(description?.contains("CASCADE") == true) + } + + @Test("Unsupported drivers without cascade say only that") + func ignoreForeignKeysDescriptionPlain() { + let description = prompt(.drop, cascadeSupported: false, foreignKeyDisableSupported: false) + .ignoreForeignKeysDescription + #expect(description != nil) + #expect(description?.contains("CASCADE") == false) + } + + // MARK: - Options Clamping + + @Test("A checked box on an unsupported option never reaches the driver") + func optionsClampToSupportedCapabilities() { + let unsupported = prompt(.drop, cascadeSupported: false, foreignKeyDisableSupported: false) + let options = unsupported.options(ignoreForeignKeys: true, cascade: true) + #expect(options.ignoreForeignKeys == false) + #expect(options.cascade == false) + } + + @Test("Supported options pass through") + func optionsPassThroughWhenSupported() { + let supported = prompt(.drop, cascadeSupported: true, foreignKeyDisableSupported: true) + let options = supported.options(ignoreForeignKeys: true, cascade: true) + #expect(options.ignoreForeignKeys) + #expect(options.cascade) + } + + @Test("Unchecked boxes stay off") + func optionsRespectUncheckedBoxes() { + let supported = prompt(.drop, cascadeSupported: true, foreignKeyDisableSupported: true) + #expect(supported.options(ignoreForeignKeys: false, cascade: false) == TableOperationOptions()) + } + + @Test("Each option is clamped independently") + func optionsClampIndependently() { + let cascadeOnly = prompt(.drop, cascadeSupported: true, foreignKeyDisableSupported: false) + let options = cascadeOnly.options(ignoreForeignKeys: true, cascade: true) + #expect(options.ignoreForeignKeys == false) + #expect(options.cascade) + } + + // MARK: - TableOperationOptions + + @Test("Default options are both off") + func defaultOptions() { + let options = TableOperationOptions() + #expect(options.ignoreForeignKeys == false) + #expect(options.cascade == false) + } + + @Test("TableOperationOptions is Equatable") + func optionsEquatable() { + let first = TableOperationOptions(ignoreForeignKeys: true, cascade: false) + let second = TableOperationOptions(ignoreForeignKeys: true, cascade: false) + let third = TableOperationOptions(ignoreForeignKeys: false, cascade: true) + #expect(first == second) + #expect(first != third) + } + + @Test("TableOperationOptions survives a Codable roundtrip") + func optionsCodableRoundtrip() throws { + let original = TableOperationOptions(ignoreForeignKeys: true, cascade: true) + let data = try JSONEncoder().encode(original) + #expect(try JSONDecoder().decode(TableOperationOptions.self, from: data) == original) + } + + // MARK: - TableOperationType + + @Test("TableOperationType raw values are stable") + func operationTypeRawValues() { + #expect(TableOperationType.truncate.rawValue == "truncate") + #expect(TableOperationType.drop.rawValue == "drop") + } + + @Test("TableOperationType survives a Codable roundtrip") + func operationTypeCodableRoundtrip() throws { + for operationType in [TableOperationType.truncate, TableOperationType.drop] { + let data = try JSONEncoder().encode(operationType) + #expect(try JSONDecoder().decode(TableOperationType.self, from: data) == operationType) + } + } +} diff --git a/TableProTests/Theme/LegibleForegroundTests.swift b/TableProTests/Theme/LegibleForegroundTests.swift new file mode 100644 index 000000000..4c35b9dc7 --- /dev/null +++ b/TableProTests/Theme/LegibleForegroundTests.swift @@ -0,0 +1,48 @@ +// +// LegibleForegroundTests.swift +// TableProTests +// + +import AppKit +import SwiftUI +@testable import TablePro +import Testing + +@Suite("Legible foreground") +struct LegibleForegroundTests { + private func isBlack(on fill: Color) -> Bool { + NSColor(Color.legibleForeground(on: fill)).relativeLuminance < 0.5 + } + + @Test("Dark fills take white text") + func darkFillsTakeWhite() { + #expect(isBlack(on: .black) == false) + #expect(isBlack(on: .blue) == false) + #expect(isBlack(on: .purple) == false) + } + + @Test("Light fills take black text") + func lightFillsTakeBlack() { + #expect(isBlack(on: .white)) + #expect(isBlack(on: .yellow)) + #expect(isBlack(on: .mint)) + } + + @Test("Every tag palette colour keeps a readable label") + func tagPaletteStaysReadable() { + let palette: [Color] = [.red, .orange, .yellow, .green, .mint, .teal, .blue, .indigo, .purple, .pink, .brown] + for fill in palette { + let foreground = NSColor(Color.legibleForeground(on: fill)).relativeLuminance + let background = NSColor(fill).relativeLuminance + let lighter = max(foreground, background) + 0.05 + let darker = min(foreground, background) + 0.05 + #expect(lighter / darker >= 3.0, "\(fill) does not reach 3:1 against its label") + } + } + + @Test("Relative luminance is ordered") + func luminanceOrdering() { + #expect(NSColor.black.relativeLuminance < NSColor.white.relativeLuminance) + #expect(NSColor.systemYellow.relativeLuminance > NSColor.systemBlue.relativeLuminance) + } +} diff --git a/TableProTests/Theme/MotionAccessibilityTests.swift b/TableProTests/Theme/MotionAccessibilityTests.swift new file mode 100644 index 000000000..850dce30f --- /dev/null +++ b/TableProTests/Theme/MotionAccessibilityTests.swift @@ -0,0 +1,35 @@ +// +// MotionAccessibilityTests.swift +// TableProTests +// + +import SwiftUI +@testable import TablePro +import Testing + +@Suite("Motion accessibility") +struct MotionAccessibilityTests { + @Test("Reduce Motion drops the animation") + func reduceMotionDropsAnimation() { + #expect(MotionAccessibility.animation(.easeOut(duration: 0.2), reduceMotion: true) == nil) + } + + @Test("Without Reduce Motion the animation is kept") + func animationSurvives() { + #expect(MotionAccessibility.animation(.easeOut(duration: 0.2), reduceMotion: false) != nil) + } + + @Test("A nil animation stays nil either way") + func nilStaysNil() { + #expect(MotionAccessibility.animation(nil, reduceMotion: false) == nil) + #expect(MotionAccessibility.animation(nil, reduceMotion: true) == nil) + } + + @Test("The gate is not inverted") + func gateIsNotInverted() { + let reduced = MotionAccessibility.animation(.default, reduceMotion: true) + let normal = MotionAccessibility.animation(.default, reduceMotion: false) + #expect(reduced == nil) + #expect(normal == .default) + } +} diff --git a/TableProTests/Theme/ThemeSlotValidationTests.swift b/TableProTests/Theme/ThemeSlotValidationTests.swift new file mode 100644 index 000000000..9af591cda --- /dev/null +++ b/TableProTests/Theme/ThemeSlotValidationTests.swift @@ -0,0 +1,87 @@ +// +// ThemeSlotValidationTests.swift +// TableProTests +// + +@testable import TablePro +import Testing + +@Suite("Theme slot validation") +struct ThemeSlotValidationTests { + @Test("A matching theme fits its slot") + func matchingThemeFits() { + #expect(ThemeSlotValidation.fits(.light, slot: .light)) + #expect(ThemeSlotValidation.fits(.dark, slot: .dark)) + } + + @Test("A contradicting theme does not fit") + func contradictingThemeDoesNotFit() { + #expect(ThemeSlotValidation.fits(.dark, slot: .light) == false) + #expect(ThemeSlotValidation.fits(.light, slot: .dark) == false) + } + + @Test("An auto theme fits both slots") + func autoFitsBoth() { + #expect(ThemeSlotValidation.fits(.auto, slot: .light)) + #expect(ThemeSlotValidation.fits(.auto, slot: .dark)) + } + + private func theme(_ id: String, _ appearance: ThemeAppearance) -> ThemeDefinition { + var copy = ThemeDefinition.default + copy.id = id + copy.appearance = appearance + return copy + } + + @Test("A contradicting slot re-anchors to the default") + func contradictingSlotReanchors() { + let themes = [theme("light", .light), theme("dark", .dark)] + let resolved = ThemeSlotValidation.resolvedThemeId( + current: "dark", + slot: .light, + themes: themes, + defaultId: "light" + ) + #expect(resolved == "light") + } + + @Test("A matching slot is left alone") + func matchingSlotUntouched() { + let themes = [theme("light", .light), theme("dark", .dark)] + let resolved = ThemeSlotValidation.resolvedThemeId( + current: "light", + slot: .light, + themes: themes, + defaultId: "light" + ) + #expect(resolved == "light") + } + + @Test("An auto theme survives either slot") + func autoThemeSurvives() { + let themes = [theme("auto", .auto)] + #expect( + ThemeSlotValidation.resolvedThemeId( + current: "auto", slot: .dark, themes: themes, defaultId: "dark" + ) == "auto" + ) + } + + @Test("An unknown theme id falls back to the default") + func unknownIdFallsBack() { + let resolved = ThemeSlotValidation.resolvedThemeId( + current: "does.not.exist", + slot: .dark, + themes: [theme("dark", .dark)], + defaultId: "dark" + ) + #expect(resolved == "dark") + } + + @Test("Only fitting themes stay in the list") + func listIsFiltered() { + let themes = [theme("light", .light), theme("dark", .dark), theme("auto", .auto)] + let eligible = ThemeSlotValidation.eligibleThemes(themes, slot: .light) + #expect(eligible.map(\.id) == ["light", "auto"]) + } +} diff --git a/TableProTests/Views/DatabaseTreeTypeSelectTests.swift b/TableProTests/Views/DatabaseTreeTypeSelectTests.swift new file mode 100644 index 000000000..45a899a79 --- /dev/null +++ b/TableProTests/Views/DatabaseTreeTypeSelectTests.swift @@ -0,0 +1,55 @@ +// +// DatabaseTreeTypeSelectTests.swift +// TableProTests +// + +import AppKit +@testable import TablePro +import Testing + +@Suite("Database tree type select") +struct DatabaseTreeTypeSelectTests { + private static let upArrow: UInt16 = 126 + private static let downArrow: UInt16 = 125 + private static let letterO: UInt16 = 31 + + @Test("Arrow keys count as navigation") + func arrowsNavigate() { + #expect(DatabaseTreeTypeSelect.isArrowNavigation(type: .keyDown, keyCode: Self.upArrow)) + #expect(DatabaseTreeTypeSelect.isArrowNavigation(type: .keyDown, keyCode: Self.downArrow)) + #expect(DatabaseTreeTypeSelect.isArrowNavigation(type: .keyUp, keyCode: Self.upArrow)) + } + + @Test("A typed letter does not count as navigation") + func lettersDoNotNavigate() { + #expect(DatabaseTreeTypeSelect.isArrowNavigation(type: .keyDown, keyCode: Self.letterO) == false) + } + + @Test("A mouse event does not count as navigation") + func mouseDoesNotNavigate() { + #expect(DatabaseTreeTypeSelect.isArrowNavigation(type: .leftMouseDown, keyCode: Self.upArrow) == false) + } + + @Test("Group and status rows have no type select string") + func groupRowsHaveNoMatchString() { + #expect(DatabaseTreeTypeSelect.matchString(for: .recentSection) == nil) + #expect(DatabaseTreeTypeSelect.matchString(for: .status(.loading)) == nil) + } + + @Test("A schema row matches on its schema name") + func schemaMatchesOnName() { + let kind = DatabaseTreeNode.Kind.schema(database: "shop", schema: "public") + #expect(DatabaseTreeTypeSelect.matchString(for: kind) == "public") + } + + @Test("A table row matches on its table name") + func tableMatchesOnName() { + let ref = DatabaseTreeTableRef( + database: "shop", + schema: "public", + table: TestFixtures.makeTableInfo(name: "orders") + ) + #expect(DatabaseTreeTypeSelect.matchString(for: .table(ref)) == "orders") + #expect(DatabaseTreeTypeSelect.matchString(for: .recentTable(ref)) == "orders") + } +} diff --git a/TableProTests/Views/SidebarContextMenuLogicTests.swift b/TableProTests/Views/SidebarContextMenuLogicTests.swift index 87fa1eb19..c9ad9dd5c 100644 --- a/TableProTests/Views/SidebarContextMenuLogicTests.swift +++ b/TableProTests/Views/SidebarContextMenuLogicTests.swift @@ -6,13 +6,12 @@ // import SwiftUI +@testable import TablePro import TableProPluginKit import Testing -@testable import TablePro @Suite("SidebarContextMenuLogicTests") struct SidebarContextMenuLogicTests { - // MARK: - hasSelection @Test("hasSelection false when empty selection and no clicked table") @@ -237,4 +236,52 @@ struct SidebarContextMenuLogicTests { func externalTableDeleteLabel() { #expect(SidebarContextMenuLogic.deleteLabel(for: .externalTable) == "Drop External Table") } + + // MARK: - contextTargets + + @Test("Clicking outside the selection targets only the clicked row") + func contextTargetsClickOutsideSelection() { + let selected = TestFixtures.makeTableInfo(name: "users") + let clicked = TestFixtures.makeTableInfo(name: "orders") + let targets = SidebarContextMenuLogic.contextTargets( + clickedTable: clicked, + selectedTables: [selected] + ) + #expect(targets == ["orders"]) + } + + @Test("Clicking inside the selection targets the whole selection") + func contextTargetsClickInsideSelection() { + let users = TestFixtures.makeTableInfo(name: "users") + let orders = TestFixtures.makeTableInfo(name: "orders") + let targets = SidebarContextMenuLogic.contextTargets( + clickedTable: users, + selectedTables: [users, orders] + ) + #expect(targets == ["orders", "users"]) + } + + @Test("Clicking with no selection targets the clicked row") + func contextTargetsNoSelection() { + let clicked = TestFixtures.makeTableInfo(name: "users") + #expect(SidebarContextMenuLogic.contextTargets(clickedTable: clicked, selectedTables: []) == ["users"]) + } + + @Test("No clicked row falls back to the selection") + func contextTargetsNoClickedRow() { + let users = TestFixtures.makeTableInfo(name: "users") + let orders = TestFixtures.makeTableInfo(name: "orders") + let targets = SidebarContextMenuLogic.contextTargets( + clickedTable: nil, + selectedTables: [users, orders] + ) + #expect(targets == ["orders", "users"]) + } + + @Test("A clicked row of a different kind is not treated as selected") + func contextTargetsMatchesOnIdentity() { + let table = TestFixtures.makeTableInfo(name: "users", type: .table) + let view = TestFixtures.makeTableInfo(name: "users", type: .view) + #expect(SidebarContextMenuLogic.contextTargets(clickedTable: view, selectedTables: [table]) == ["users"]) + } } diff --git a/TableProTests/Views/SidebarRowForegroundTests.swift b/TableProTests/Views/SidebarRowForegroundTests.swift new file mode 100644 index 000000000..e88edcac9 --- /dev/null +++ b/TableProTests/Views/SidebarRowForegroundTests.swift @@ -0,0 +1,35 @@ +// +// SidebarRowForegroundTests.swift +// TableProTests +// + +@testable import TablePro +import Testing + +@Suite("Sidebar row foreground") +struct SidebarRowForegroundTests { + @Test("Emphasis outranks the active tint") + func emphasisBeatsActive() { + #expect(SidebarRowForeground.role(isEmphasized: true, isActive: true, isSystem: false) == .emphasized) + } + + @Test("Emphasis outranks the system dimming") + func emphasisBeatsSystem() { + #expect(SidebarRowForeground.role(isEmphasized: true, isActive: false, isSystem: true) == .emphasized) + } + + @Test("The active tint outranks the system dimming") + func activeBeatsSystem() { + #expect(SidebarRowForeground.role(isEmphasized: false, isActive: true, isSystem: true) == .active) + } + + @Test("A system object without emphasis or activity dims") + func systemAlone() { + #expect(SidebarRowForeground.role(isEmphasized: false, isActive: false, isSystem: true) == .system) + } + + @Test("A plain row uses the primary label") + func plainRow() { + #expect(SidebarRowForeground.role(isEmphasized: false, isActive: false, isSystem: false) == .normal) + } +} From b13111746268875998f2baf5da524ec39b7a6165 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 13 Aug 2026 18:38:33 +0700 Subject: [PATCH 31/47] chore: regenerate the string catalog Claude-Session: https://claude.ai/code/session_01A3rb597qZtq4h5xZxwg43W --- TablePro/Resources/Localizable.xcstrings | 188 +++++++++++++++++++++-- 1 file changed, 176 insertions(+), 12 deletions(-) diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index a3bdb057c..85f9ed250 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -140,12 +140,6 @@ } } } - }, - "Do Not Decode" : { - - }, - "Legacy UUID Encoding" : { - }, "—" : { "extractionState" : "stale", @@ -236,6 +230,7 @@ } }, ".%@" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -321,6 +316,7 @@ } }, "'%@' is a reserved Windows device name" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -1687,6 +1683,10 @@ } } }, + "%@ is already in the favorites list." : { + "comment" : "A message that appears when the user tries to link a folder that is already linked. The argument is the name of the folder.", + "isCommentAutoGenerated" : true + }, "%@ is already used by \"%@\" in %@. Reassigning removes it from that action." : { "localizations" : { "en" : { @@ -2259,6 +2259,7 @@ } }, "%@, %@" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2639,6 +2640,14 @@ "%1$@, %2$@" : { "shouldTranslate" : false }, + "%1$@, %2$lld keys" : { + "comment" : "A description of a namespace, including the number of keys it contains. The first argument is the name of the namespace. The second argument is the count of keys in the namespace.", + "isCommentAutoGenerated" : true + }, + "%1$d of %2$d" : { + "comment" : "A description of the current position in a collection of items. The first argument is the current position. The second argument is the total number of items.", + "isCommentAutoGenerated" : true + }, "%1$lld of %2$lld statements were applied. This connection does not roll back user and role changes." : { "localizations" : { "tr" : { @@ -2667,6 +2676,10 @@ } } }, + "%1$lld statements executed, %2$lld failed" : { + "comment" : "The number of statements that were skipped and the number of statements that failed.", + "isCommentAutoGenerated" : true + }, "%1$lld users, %2$lld roles" : { "localizations" : { "tr" : { @@ -4003,6 +4016,7 @@ } }, "%lld statements executed, %lld failed" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -4036,6 +4050,10 @@ } } }, + "%lld suggestions available" : { + "comment" : "A description of the number of suggestions available. The argument is the number of suggestions.", + "isCommentAutoGenerated" : true + }, "%lld table%@ to export" : { "localizations" : { "en" : { @@ -7877,6 +7895,10 @@ } } }, + "Add Host" : { + "comment" : "Label for the \"Add Host\" button in the host list field row.", + "isCommentAutoGenerated" : true + }, "Add Index" : { "localizations" : { "tr" : { @@ -8159,6 +8181,10 @@ } } }, + "Add Theme" : { + "comment" : "Add Theme", + "isCommentAutoGenerated" : true + }, "Add to Favorites" : { "comment" : "A label that describes an action to add an item to a user's favorites.", "isCommentAutoGenerated" : true, @@ -15934,6 +15960,10 @@ } } }, + "Changed" : { + "comment" : "Text for a split diff marker that indicates a change in a split diff.", + "isCommentAutoGenerated" : true + }, "CHANGED" : { "localizations" : { "tr" : { @@ -19453,6 +19483,7 @@ } }, "collapse" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -21862,6 +21893,10 @@ } } }, + "Connection status" : { + "comment" : "Toolbar item tooltip for the status section of the toolbar.", + "isCommentAutoGenerated" : true + }, "Connection Status" : { "extractionState" : "stale", "localizations" : { @@ -23278,6 +23313,7 @@ } }, "Copy Errors to Clipboard" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -23501,6 +23537,10 @@ } } }, + "Copy Namespace Prefix" : { + "comment" : "A button that copies the namespace prefix to the clipboard.", + "isCommentAutoGenerated" : true + }, "Copy Path" : { "localizations" : { "tr" : { @@ -24163,6 +24203,18 @@ } } }, + "Could not export the diagram" : { + "comment" : "Title of an error sheet that appears when the user tries to export the ER diagram but the image could not be created.", + "isCommentAutoGenerated" : true + }, + "Could not export the schema" : { + "comment" : "Title of an alert that appears when an error occurs while exporting the schema.", + "isCommentAutoGenerated" : true + }, + "Could not export the theme" : { + "comment" : "Title of an error sheet when exporting a theme fails.", + "isCommentAutoGenerated" : true + }, "Could not fetch plugin registry" : { "extractionState" : "stale", "localizations" : { @@ -26023,6 +26075,10 @@ } } }, + "Current database" : { + "comment" : "A description of a database that is currently selected.", + "isCommentAutoGenerated" : true + }, "Current database: %@ (⌘K to switch)" : { "extractionState" : "stale", "localizations" : { @@ -30940,6 +30996,13 @@ } } }, + "Do Not Decode" : { + + }, + "Do not show this again" : { + "comment" : "Title of the checkbox that can be checked to suppress the warning dialog.", + "isCommentAutoGenerated" : true + }, "Do you want to save changes?" : { "localizations" : { "tr" : { @@ -31137,6 +31200,7 @@ } }, "Don't show this again" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -31304,6 +31368,10 @@ } } }, + "Downloading plugin" : { + "comment" : "A description of a progress bar that shows a download in progress.", + "isCommentAutoGenerated" : true + }, "Downloads" : { "localizations" : { "tr" : { @@ -31360,6 +31428,10 @@ } } }, + "Driver Options" : { + "comment" : "A section for driver-specific options.", + "isCommentAutoGenerated" : true + }, "Driver plugin not loaded. Open Settings to update." : { "localizations" : { "tr" : { @@ -33487,6 +33559,10 @@ } } }, + "Editor Tabs" : { + "comment" : "A label displayed above the tabs in the editor.", + "isCommentAutoGenerated" : true + }, "Effective" : { "localizations" : { "tr" : { @@ -34959,6 +35035,7 @@ } }, "Error:" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -35801,6 +35878,7 @@ } }, "expand" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -36229,6 +36307,7 @@ } }, "export" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -36648,8 +36727,12 @@ } } } + }, + "Export completed" : { + }, "Export completed successfully" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -37696,6 +37779,7 @@ } }, "Failed at line %lld" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -37723,6 +37807,18 @@ } } }, + "Failed at line %lld. %@" : { + "comment" : "Error message displayed in an alert when a plugin import fails. The first argument is the line number of the failed statement. The second argument is the error message from the underlying error.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Failed at line %1$lld. %2$@" + } + } + } + }, "Failed to compress data" : { "localizations" : { "tr" : { @@ -39580,6 +39676,7 @@ } }, "File name" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -39693,6 +39790,7 @@ } }, "Filename cannot be '.' or '..' or contain path traversal" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -39721,6 +39819,7 @@ } }, "Filename cannot be empty" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -39749,6 +39848,7 @@ } }, "Filename contains invalid characters: / \\ : * ? \" < > |" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -39777,6 +39877,7 @@ } }, "Filename is too long (max 255 bytes)" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -44291,7 +44392,15 @@ } } }, + "Import completed" : { + + }, + "Import completed with errors" : { + "comment" : "Text displayed in an alert when an import has failed with errors.", + "isCommentAutoGenerated" : true + }, "Import Completed with Errors" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -44492,8 +44601,13 @@ }, "Import Data..." : { + }, + "Import failed" : { + "comment" : "Title of the alert when an import fails.", + "isCommentAutoGenerated" : true }, "Import Failed" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -44951,6 +45065,7 @@ } }, "Import Successful" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -49415,6 +49530,9 @@ } } } + }, + "Legacy UUID Encoding" : { + }, "Length" : { "extractionState" : "stale", @@ -50026,7 +50144,12 @@ } } }, + "Line %1$lld: %2$@" : { + "comment" : "A line in the import error log. The first argument is the line number. The second argument is the error message.", + "isCommentAutoGenerated" : true + }, "Line %lld: %@" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -54488,9 +54611,6 @@ } } } - }, - "Move Tab to New Window" : { - }, "Move to" : { "localizations" : { @@ -61289,6 +61409,7 @@ } }, "Open containing folder" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -61599,6 +61720,10 @@ } } }, + "Open in Finder" : { + "comment" : "Button title for the \"Open in Finder\" option in the export success alert.", + "isCommentAutoGenerated" : true + }, "Open in New Tab" : { "localizations" : { "tr" : { @@ -69949,6 +70074,10 @@ } } }, + "Ready after restart" : { + "comment" : "A description of a plugin that is ready to use after the app is restarted.", + "isCommentAutoGenerated" : true + }, "Real" : { "localizations" : { "tr" : { @@ -70458,9 +70587,6 @@ } } } - }, - "Record Shortcut" : { - }, "Recording shortcut" : { "localizations" : { @@ -71906,6 +72032,10 @@ } } }, + "Remove Host" : { + "comment" : "Label for a button that removes the selected host from a list.", + "isCommentAutoGenerated" : true + }, "Remove image" : { "localizations" : { "tr" : { @@ -72131,6 +72261,10 @@ } } }, + "Remove tag %@" : { + "comment" : "A label for a button that removes a tag from a list of selected tags. The argument is the name of the tag to be removed.", + "isCommentAutoGenerated" : true + }, "Removed" : { "localizations" : { "tr" : { @@ -82142,6 +82276,7 @@ } }, "Skips foreign key constraint checks for this operation" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -83130,6 +83265,10 @@ } } }, + "Split mode" : { + "comment" : "Accessibility label for the segmented control in the alert that allows the user to choose between using a delimiter or a regex to split the values in a column.", + "isCommentAutoGenerated" : true + }, "Sponsor" : { "localizations" : { "tr" : { @@ -84896,6 +85035,10 @@ } } }, + "Startup SQL" : { + "comment" : "A section header for the startup SQL commands of a connection.", + "isCommentAutoGenerated" : true + }, "State" : { "localizations" : { "tr" : { @@ -85017,6 +85160,7 @@ } }, "Statement:" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -89733,6 +89877,10 @@ } } }, + "The diagram could not be converted to a PNG image." : { + "comment" : "Error message when the ER diagram could not be exported as PNG.", + "isCommentAutoGenerated" : true + }, "The encrypted file is corrupt or incomplete" : { "extractionState" : "stale", "localizations" : { @@ -90955,6 +91103,10 @@ } } }, + "Theme Actions" : { + "comment" : "A heading displayed above the theme actions.", + "isCommentAutoGenerated" : true + }, "Theme Update Failed" : { "localizations" : { "tr" : { @@ -91236,6 +91388,10 @@ } } }, + "This connection runs SQL every time it connects, using your credentials." : { + "comment" : "A description of the startup SQL for a database connection.", + "isCommentAutoGenerated" : true + }, "This connection was deleted on another device or window. Your changes were not saved." : { "localizations" : { "tr" : { @@ -91723,6 +91879,10 @@ } } }, + "This folder is already linked" : { + "comment" : "Alert title when trying to add a folder that is already linked.", + "isCommentAutoGenerated" : true + }, "This is a built-in theme." : { "localizations" : { "tr" : { @@ -100283,6 +100443,10 @@ } } }, + "Your connections file was changed outside TablePro, so this connection's password source was not run. Open the connection and save it again to confirm the change." : { + "comment" : "Error message when the app is not trusted to access the keychain.", + "isCommentAutoGenerated" : true + }, "Your database schema and query data will be sent to the AI provider for analysis. Allow for this connection?" : { "localizations" : { "tr" : { From a588c01f0f6ca9d89b8ed626076beee76e807d5b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 13 Aug 2026 22:34:54 +0700 Subject: [PATCH 32/47] fix(tabs): show the tab strip as soon as a second tab opens --- .../Infrastructure/MainSplitViewController.swift | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index 66784335c..9568e9434 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -574,6 +574,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi private func refreshTabStripAccessory() { installTabStripAccessoryIfNeeded() guard let accessory = tabStripAccessory, let hosting = tabStripHosting else { return } + defer { observeTabStripSource() } guard currentPane == .content, let sessionState, sessionState.tabManager.tabs.count > 1 else { accessory.isHidden = true hosting.rootView = AnyView(Color.clear) @@ -585,6 +586,21 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi recomputeWindowMinSize() } + /// The strip lived in SwiftUI before, where the tab count was observed for free. An AppKit + /// accessory observes nothing, so opening a second tab left it hidden: `rebuildPanes` is the + /// only caller of the refresh and a tab list change never reaches it. `withObservationTracking` + /// fires once, so each pass re-arms the next. + private func observeTabStripSource() { + guard let manager = workspaces.selected?.sessionState?.tabManager else { return } + withObservationTracking { + _ = manager.tabs.count + } onChange: { [weak self] in + Task { @MainActor [weak self] in + self?.refreshTabStripAccessory() + } + } + } + private func buildTabStripView(sessionState: SessionStateFactory.SessionState) -> some View { EditorTabStrip( tabManager: sessionState.tabManager, From 63a1e8ba21221ed76a0e6f927b8d375c3bb9097d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 14 Aug 2026 16:58:21 +0700 Subject: [PATCH 33/47] refactor(hig): rebuild the sidebar on NSOutlineView and fix the PR #2104 regressions --- CHANGELOG.md | 36 +- .../Core/Coordinators/FilterCoordinator.swift | 6 +- TablePro/Core/Menu/WindowMenuBuilder.swift | 17 +- .../ExternalConnectionPrompting.swift | 8 +- .../MainSplitViewController.swift | 89 +--- .../MainWindowToolbar+Buttons.swift | 184 +------- .../MainWindowToolbar+Delegate.swift | 27 +- .../MainWindowToolbar+Items.swift | 58 ++- .../MainWindowToolbar+Validation.swift | 21 +- .../SidebarContainerViewController.swift | 14 +- .../Infrastructure/StatefulToolbarItem.swift | 34 ++ .../Infrastructure/TabWindowController.swift | 6 +- TablePro/Core/Utilities/UI/AlertHelper.swift | 26 +- .../Models/Query/LinkedFavoriteTransfer.swift | 19 - TablePro/Models/UI/RedisKeyNode.swift | 16 + TablePro/Resources/Localizable.xcstrings | 15 +- TablePro/Theme/ThemeSlotValidation.swift | 26 +- .../FavoritesSidebarViewModel.swift | 76 +-- .../ViewModels/RedisKeyTreeViewModel.swift | 2 +- .../AIChat/AIChatWalkthroughBlockView.swift | 15 +- .../AIChat/MentionSuggestionListView.swift | 27 +- .../Views/Components/ColorPaletteView.swift | 5 +- .../Components/TransferResultAlert.swift | 25 +- .../Connection/ConnectionGroupPicker.swift | 110 +---- .../Views/Connection/GroupPopUpButton.swift | 155 ++++++ TablePro/Views/Connection/TagFilterBar.swift | 6 +- .../DatabaseSwitcherPopover.swift | 64 +-- .../Views/Main/EditorTabStripLayout.swift | 2 - .../MainContentCoordinator+Navigation.swift | 18 +- ...MainContentCoordinator+QuickSwitcher.swift | 12 +- .../Views/Main/MainContentCoordinator.swift | 6 +- TablePro/Views/Main/MainContentView.swift | 24 +- .../QuickSwitcherPanelView.swift | 26 +- TablePro/Views/Results/DataGridRowView.swift | 24 +- .../Results/SortableHeaderEmphasis.swift | 21 + .../Views/Results/SortableHeaderView.swift | 23 +- .../Settings/Appearance/ThemeListView.swift | 18 +- .../Settings/AppearanceSettingsView.swift | 20 - TablePro/Views/Shared/FieldDrivenList.swift | 324 +++++++++++++ .../Views/Shared/FieldDrivenListEntry.swift | 43 ++ .../Views/Sidebar/DatabaseTreeFilter.swift | 20 + TablePro/Views/Sidebar/DatabaseTreeNode.swift | 55 ++- .../DatabaseTreeOutlineCoordinator.swift | 442 ++++++++++++++++-- .../Sidebar/DatabaseTreeOutlineView.swift | 33 +- .../Views/Sidebar/DatabaseTreeRowView.swift | 138 +++++- .../Views/Sidebar/DatabaseTreeSelection.swift | 44 ++ .../Sidebar/DatabaseTreeTypeSelect.swift | 24 +- TablePro/Views/Sidebar/DatabaseTreeView.swift | 5 +- .../Sidebar/FavoriteFolderRenameOverlay.swift | 91 ++++ TablePro/Views/Sidebar/FavoriteRowView.swift | 5 - .../Views/Sidebar/FavoritesExpansion.swift | 33 ++ .../Sidebar/FavoritesOutlineCellView.swift | 33 ++ .../Sidebar/FavoritesOutlineCoordinator.swift | 350 ++++++++++++++ .../Views/Sidebar/FavoritesOutlineNode.swift | 45 ++ .../Sidebar/FavoritesOutlineSelection.swift | 62 +++ .../Views/Sidebar/FavoritesOutlineView.swift | 111 +++++ TablePro/Views/Sidebar/FavoritesTabView.swift | 322 +++++-------- .../Views/Sidebar/FavoritesTreeFilter.swift | 60 +++ .../Views/Sidebar/LinkedFavoriteRowView.swift | 5 - .../Sidebar/RedisKeyTreeTruncation.swift | 14 + TablePro/Views/Sidebar/RedisKeyTreeView.swift | 118 ----- .../Views/Sidebar/SidebarContextMenu.swift | 11 + .../Views/Sidebar/SidebarOutlineView.swift | 46 ++ TablePro/Views/Sidebar/SidebarRootShape.swift | 37 ++ TablePro/Views/Sidebar/SidebarTreeView.swift | 202 ++------ TablePro/Views/Sidebar/SidebarView.swift | 252 +--------- .../Toolbar/ConnectionSwitcherPopover.swift | 96 ++-- .../Views/Toolbar/TableProToolbarView.swift | 10 +- .../DestructiveAlertDefaultsTests.swift | 54 ++- .../Theme/LegibleForegroundTests.swift | 50 +- .../Theme/ThemeSlotValidationTests.swift | 62 +-- .../FavoritesSidebarViewModelTests.swift | 54 +-- .../DataGridCellSelectionFillTests.swift | 79 ++++ .../Views/DatabaseTreeTypeSelectTests.swift | 40 ++ .../Views/FieldDrivenListEntryTests.swift | 81 ++++ .../Views/GroupMenuEntriesTests.swift | 84 ++++ .../Main/CoordinatorEditorLoadTests.swift | 2 +- .../Sidebar/DatabaseTreeFilterTests.swift | 61 +++ .../Views/Sidebar/DatabaseTreeNodeTests.swift | 17 + .../DatabaseTreeSelectionPolicyTests.swift | 85 ++++ ...DatabaseTreeSelectionProjectionTests.swift | 77 +++ .../FavoriteFolderRenameOverlayTests.swift | 151 ++++++ .../FavoritesOutlineSelectionTests.swift | 90 ++++ .../Sidebar/SidebarRecentSelectionTests.swift | 62 +++ .../SidebarRootShapeResolverTests.swift | 81 ++++ .../Views/SortableHeaderEmphasisTests.swift | 71 +++ .../Views/TransferFailureReportTests.swift | 55 +++ docs/customization/appearance.mdx | 2 + docs/features/favorites.mdx | 8 +- docs/features/keyboard-shortcuts.mdx | 4 + docs/features/overview.mdx | 2 +- docs/features/quick-switcher.mdx | 6 +- docs/features/tabs.mdx | 64 +-- docs/features/workspace-rail.mdx | 8 +- 94 files changed, 3973 insertions(+), 1689 deletions(-) create mode 100644 TablePro/Core/Services/Infrastructure/StatefulToolbarItem.swift delete mode 100644 TablePro/Models/Query/LinkedFavoriteTransfer.swift create mode 100644 TablePro/Views/Connection/GroupPopUpButton.swift create mode 100644 TablePro/Views/Results/SortableHeaderEmphasis.swift create mode 100644 TablePro/Views/Shared/FieldDrivenList.swift create mode 100644 TablePro/Views/Shared/FieldDrivenListEntry.swift create mode 100644 TablePro/Views/Sidebar/DatabaseTreeSelection.swift create mode 100644 TablePro/Views/Sidebar/FavoriteFolderRenameOverlay.swift create mode 100644 TablePro/Views/Sidebar/FavoritesExpansion.swift create mode 100644 TablePro/Views/Sidebar/FavoritesOutlineCellView.swift create mode 100644 TablePro/Views/Sidebar/FavoritesOutlineCoordinator.swift create mode 100644 TablePro/Views/Sidebar/FavoritesOutlineNode.swift create mode 100644 TablePro/Views/Sidebar/FavoritesOutlineSelection.swift create mode 100644 TablePro/Views/Sidebar/FavoritesOutlineView.swift create mode 100644 TablePro/Views/Sidebar/FavoritesTreeFilter.swift create mode 100644 TablePro/Views/Sidebar/RedisKeyTreeTruncation.swift delete mode 100644 TablePro/Views/Sidebar/RedisKeyTreeView.swift create mode 100644 TablePro/Views/Sidebar/SidebarOutlineView.swift create mode 100644 TablePro/Views/Sidebar/SidebarRootShape.swift create mode 100644 TableProTests/Views/DataGridCellSelectionFillTests.swift create mode 100644 TableProTests/Views/FieldDrivenListEntryTests.swift create mode 100644 TableProTests/Views/GroupMenuEntriesTests.swift create mode 100644 TableProTests/Views/Sidebar/DatabaseTreeSelectionPolicyTests.swift create mode 100644 TableProTests/Views/Sidebar/DatabaseTreeSelectionProjectionTests.swift create mode 100644 TableProTests/Views/Sidebar/FavoriteFolderRenameOverlayTests.swift create mode 100644 TableProTests/Views/Sidebar/FavoritesOutlineSelectionTests.swift create mode 100644 TableProTests/Views/Sidebar/SidebarRecentSelectionTests.swift create mode 100644 TableProTests/Views/Sidebar/SidebarRootShapeResolverTests.swift create mode 100644 TableProTests/Views/SortableHeaderEmphasisTests.swift create mode 100644 TableProTests/Views/TransferFailureReportTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 013716d1f..d50620a16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,21 +24,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - MongoDB field suggestions include nested paths. A document with `address: { city }` now suggests `address.city`, not just `address`. (#2095) - MongoDB updates accept an options argument, so `db.users.updateOne({...}, {...}, {upsert: true})` upserts instead of ignoring the option. `arrayFilters` and `hint` are passed through too. (#2095) - Format Query follows the editor language. On a MongoDB tab it lays out filters and pipelines by nesting depth instead of running the SQL formatter over them. (#2095) - -### Added - - The MongoDB editor accepts mongosh value constructors in filters and pipelines, so a value copied from the grid pastes straight into a query. Covers `ObjectId`, `ISODate`, `Date`, the `Number*` family, `Timestamp`, `BinData`, `HexData`, `MinKey`, `MaxKey` and the UUID names. (#2086) ### Fixed +- Holding an arrow key in the sidebar no longer opens a tab and runs a query for every object it passes. Arrowing through 20 tables fired 20 queries; it now opens only the object you stop on. +- Clicking a row in the sidebar puts the keyboard on the list. The click moved the selection but sometimes left the keyboard in the filter field above it, so the row drew grey instead of in the accent colour and the arrow keys went to the field. Switching to the Favorites tab left the keyboard nowhere at all, so the first arrow key did nothing. +- Opening a table from somewhere that asks for the grid, such as Favorites or Show Structure, puts the keyboard in the grid. Only the first such table of a session did; every one after it left the keyboard where it was. - A chained method on a MongoDB aggregation no longer goes missing. `db.orders.aggregate([...]).limit(10)` used to drop the limit and return everything the pipeline matched. Chaining a method that a query does not support now reports an error instead of ignoring it. (#2095) - -### Changed - +- Escape closes the drop, truncate, delete and external-link alerts again, and the risky button no longer answers Return. (#2104) +- The line under the toolbar sits under the toolbar again. The tab strip moved back above the editor, where its colours were designed to sit, instead of into the titlebar. (#2104) +- A selected range of cells stays visible when the grid is not the focused view, and the column header no longer stays highlighted after the body has dimmed. (#2104) +- Dropping a SQL file on a connection window opens it. (#2104) +- Opening Settings, Appearance no longer replaces the theme saved for the light or dark slot. A theme that does not match the slot stays listed while it is the one in use. +- The selected tag filter is readable again on light tag colours, and the picked colour swatch keeps its ring whatever accent colour is set. (#2104) +- Toolbar tooltips name what the button does and show the current keyboard shortcut again. Import works from the toolbar, and the Results button shows whether the pane is open. (#2104) +- Nested connection groups show their nesting in the group menus again. (#2104) +- The import result lists the statement that failed, not just the line number. (#2104) +- A tag with no colour is readable in the toolbar instead of drawing white on nothing. +- The Quick Switcher row that Return will open is highlighted from the moment the panel opens, not only after an arrow key. +- Down arrow in the sidebar filter field moves into the object list instead of being swallowed. +- The highlighted row in the connection switcher and the database switcher is drawn as the active selection while you type and arrow through it, the way Spotlight does, instead of looking inactive until you click. +- Routines and Recent entries in the object tree can be selected, so arrow keys reach them and type-to-find lands on them. Clicking a Recent entry now highlights the row it opened. +- The sidebar object list is now a native outline in every layout, so the selected row is drawn as the active selection, arrow keys move between objects, and typing jumps to a name. The flat and schema layouts were SwiftUI lists that could not do any of that. +- Procedures, functions and Redis keys can be selected and reached with the keyboard in the sidebar. +- The Favorites tab is a native outline too, so saved queries, folders and linked SQL files take the keyboard and show a real selection. Team Library entries can be selected for the first time; opening one is now a double-click or Return rather than a single click, and its publisher shows beside the name instead of under it. +- Truncate, Copy Name and Delete act on the sidebar selection in tree layout. They read a selection the tree never published, so they did nothing at all. +- Clicking a table under Recent in the sidebar highlights it. The Recent entries sit at the top of the list and were the only rows that opened a table without ever showing as selected, which read as the highlight working on some tables and not others. + +### Changed + +- Clicking a table in the sidebar opens it right away. It used to wait out the double-click interval, about half a second, to find out whether a second click was coming. Double-click no longer opens a second copy of the table; **Open in New Tab** on the table's contextual menu does that. +- Opening a table from the sidebar leaves the keyboard in the sidebar, so you can keep clicking or arrowing through tables and watch each one load. Click into the grid when you want to work in it. +- Sidebar section titles are real source list headers now, the way Package Dependencies reads in Xcode's navigator: a short grey title with no icon, and the objects under it sitting at the same depth as a database instead of one step in. - Every open connection now lives in one window. Picking a connection in the workspace rail switches that window to it instead of raising a second window. - Opening a table or query on a connection you already have open adds a tab to that window instead of opening another window. A tab strip appears once a connection holds more than one tab. - Closing the last tab leaves the connection open on its empty state. Close Tab again closes the connection, and the window once that was the last one open. -- Window tabs follow your "Prefer tabs when opening documents" setting instead of always forcing tabs, and the Window menu gained Merge All Windows. +- Window tabs follow your "Prefer tabs when opening documents" setting instead of always forcing tabs, and the Window menu carries Move Tab to New Window and Merge All Windows. - Mobile keeps remote connections open when you switch apps. - MongoDB shows a standard binary UUID as `UUID("...")` everywhere, including in exports. - Mobile no longer copies database passwords to iCloud Keychain unless you turn on Sync Passwords. Mac already worked this way. diff --git a/TablePro/Core/Coordinators/FilterCoordinator.swift b/TablePro/Core/Coordinators/FilterCoordinator.swift index e5eb8c455..72483f8de 100644 --- a/TablePro/Core/Coordinators/FilterCoordinator.swift +++ b/TablePro/Core/Coordinators/FilterCoordinator.swift @@ -461,7 +461,7 @@ final class FilterCoordinator { // MARK: - Panel Visibility func toggleFilterPanel() { - withAnimation(.easeInOut(duration: 0.15)) { + withMotion(.easeInOut(duration: 0.15)) { mutateSelectedTabFilterState { state in state.isVisible.toggle() } @@ -469,7 +469,7 @@ final class FilterCoordinator { } func showFilterPanel() { - withAnimation(.easeInOut(duration: 0.15)) { + withMotion(.easeInOut(duration: 0.15)) { mutateSelectedTabFilterState { state in state.isVisible = true } @@ -477,7 +477,7 @@ final class FilterCoordinator { } func closeFilterPanel() { - withAnimation(.easeInOut(duration: 0.15)) { + withMotion(.easeInOut(duration: 0.15)) { mutateSelectedTabFilterState { state in state.isVisible = false } diff --git a/TablePro/Core/Menu/WindowMenuBuilder.swift b/TablePro/Core/Menu/WindowMenuBuilder.swift index 574d19328..7a8ed9de3 100644 --- a/TablePro/Core/Menu/WindowMenuBuilder.swift +++ b/TablePro/Core/Menu/WindowMenuBuilder.swift @@ -5,9 +5,12 @@ import AppKit -/// AppKit appends the open-window list to whichever menu is assigned to -/// `NSApp.windowsMenu`, and adds the native tab items itself for windows that take -/// part in tabbing. Only the items it does not provide are built here. +/// AppKit appends the open-window list to whichever menu is assigned to `NSApp.windowsMenu`, and +/// that is all it appends. It does not contribute the window-tabbing commands: a menu built in code +/// gets the window list and nothing else, measured with two windows actually in one tab group. The +/// app still opts into window tabbing through `NSWindow.tabbingMode`, so the commands that go with +/// it are built here. `NSWindow` implements both and validates them itself, so they dim when the +/// window is not part of a tab group. @MainActor enum WindowMenuBuilder { static let tabNumberRange = 1...9 @@ -37,6 +40,14 @@ enum WindowMenuBuilder { shortcut: .showNextTab, keyboard: keyboard ), + MenuItemFactory.item( + String(localized: "Move Tab to New Window"), + action: #selector(NSWindow.moveTabToNewWindow(_:)) + ), + MenuItemFactory.item( + String(localized: "Merge All Windows"), + action: #selector(NSWindow.mergeAllWindows(_:)) + ), MenuItemFactory.separator ] diff --git a/TablePro/Core/Services/Infrastructure/ExternalConnectionPrompting.swift b/TablePro/Core/Services/Infrastructure/ExternalConnectionPrompting.swift index 0cc4781f0..2ac74ed3c 100644 --- a/TablePro/Core/Services/Infrastructure/ExternalConnectionPrompting.swift +++ b/TablePro/Core/Services/Infrastructure/ExternalConnectionPrompting.swift @@ -49,13 +49,13 @@ internal struct ExternalConnectionAlertPrompt: ExternalConnectionPrompting { details(for: connection).joined(separator: "\n") ) alert.alertStyle = .warning - alert.addButton(withTitle: String(localized: "Connect")) - alert.addButton(withTitle: String(localized: "Cancel")) + /// Connecting is the risky half of this decision, so it gives up Return. Escape stays on + /// Cancel, which is the only binding that dismisses the alert from the keyboard. + alert.addButton(withTitle: String(localized: "Connect")).keyEquivalent = "" + AlertHelper.addCancelButton(to: alert, title: String(localized: "Cancel")) if offerAlwaysAllow { alert.addButton(withTitle: String(localized: "Always Allow")) } - alert.buttons[0].keyEquivalent = "" - alert.buttons[1].keyEquivalent = "\r" return alert } diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index 9568e9434..f150f2473 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -85,8 +85,6 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi private var navigationSidebar: NavigationSidebarViewController! private var detailHosting: NSHostingController! - private var tabStripHosting: NSHostingController? - private var tabStripAccessory: NSTitlebarAccessoryViewController? private var inspectorHosting: NSHostingController! private var chromeState: ChromeState = .unapplied @@ -538,77 +536,6 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi } detailHosting.rootView = AnyView(buildDetailView()) inspectorHosting.rootView = AnyView(buildInspectorView()) - refreshTabStripAccessory() - } - - /// The tab strip belongs in the titlebar, where every document app puts its tabs, not stacked - /// inside the content area. The accessory is per-window while the strip is per-workspace, so - /// switching workspace has to repoint it rather than build a second one. - private func installTabStripAccessoryIfNeeded() { - guard tabStripAccessory == nil, let window = view.window else { return } - let hosting = NSHostingController(rootView: AnyView(Color.clear)) - hosting.sizingOptions = [] - hosting.view.frame = NSRect( - x: 0, - y: 0, - width: window.frame.width, - height: EditorTabStripLayout.totalHeight - ) - - let accessory = NSTitlebarAccessoryViewController() - accessory.addChild(hosting) - accessory.view = hosting.view - accessory.layoutAttribute = .bottom - /// Defaults to true, and means "use the standard system sizing over the view's frame", - /// which is the opposite of what a fixed-height strip wants. - accessory.automaticallyAdjustsSize = false - accessory.fullScreenMinHeight = 0 - accessory.isHidden = true - window.addTitlebarAccessoryViewController(accessory) - - tabStripHosting = hosting - tabStripAccessory = accessory - refreshTabStripAccessory() - } - - private func refreshTabStripAccessory() { - installTabStripAccessoryIfNeeded() - guard let accessory = tabStripAccessory, let hosting = tabStripHosting else { return } - defer { observeTabStripSource() } - guard currentPane == .content, let sessionState, sessionState.tabManager.tabs.count > 1 else { - accessory.isHidden = true - hosting.rootView = AnyView(Color.clear) - recomputeWindowMinSize() - return - } - hosting.rootView = AnyView(buildTabStripView(sessionState: sessionState)) - accessory.isHidden = false - recomputeWindowMinSize() - } - - /// The strip lived in SwiftUI before, where the tab count was observed for free. An AppKit - /// accessory observes nothing, so opening a second tab left it hidden: `rebuildPanes` is the - /// only caller of the refresh and a tab list change never reaches it. `withObservationTracking` - /// fires once, so each pass re-arms the next. - private func observeTabStripSource() { - guard let manager = workspaces.selected?.sessionState?.tabManager else { return } - withObservationTracking { - _ = manager.tabs.count - } onChange: { [weak self] in - Task { @MainActor [weak self] in - self?.refreshTabStripAccessory() - } - } - } - - private func buildTabStripView(sessionState: SessionStateFactory.SessionState) -> some View { - EditorTabStrip( - tabManager: sessionState.tabManager, - onClose: { [weak self] id in self?.commandActions?.closeTab(id: id) }, - onCloseOthers: { [weak self] id in self?.commandActions?.closeOtherTabs(anchoredOn: id) }, - onCloseAll: { [weak self] in self?.commandActions?.closeAllTabs() }, - onNewTab: { [weak self] in self?.commandActions?.newTab() } - ) } /// The command surface every menu action forwards into. Menu items reach this @@ -662,16 +589,6 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi SidebarView( sidebarState: SharedSidebarState.forConnection(currentSession.connection.id), windowState: sessionState.coordinator.windowSidebarState, - onDoubleClick: { [weak self] table in - guard let coordinator = self?.sessionState?.coordinator else { return } - let activeTab = coordinator.tabManager.selectedTab - if activeTab?.tabType == .table, activeTab?.tableContext.tableName == table.name { - coordinator.promotePreviewTab() - coordinator.requestGridFocus() - } else { - coordinator.openTableTab(table, forceNonPreview: true, activateGridFocus: true) - } - }, pendingTruncates: sessionPendingTruncatesBinding, pendingDeletes: sessionPendingDeletesBinding, tableOperationOptions: sessionTableOperationOptionsBinding, @@ -973,11 +890,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi sidebarMinimum: sidebarSplitItem?.minimumThickness ?? Self.sidebarMinThickness, dividerThickness: splitView.dividerThickness ) - let accessoryHeight = (tabStripAccessory?.isHidden ?? true) ? 0 : EditorTabStripLayout.totalHeight - let newMinSize = NSSize( - width: resolvedWidth, - height: Self.baseWindowMinHeight + accessoryHeight - ) + let newMinSize = NSSize(width: resolvedWidth, height: Self.baseWindowMinHeight) guard window.minSize != newMinSize else { return } window.minSize = newMinSize diff --git a/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Buttons.swift b/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Buttons.swift index b2e66f17a..f9cf3c641 100644 --- a/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Buttons.swift +++ b/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Buttons.swift @@ -17,6 +17,10 @@ struct ConnectionToolbarButton: View { } label: { Label("Connection", systemImage: "network") } + /// The toolbar runs icon only, and a hosted view has to honour that itself. The label is a + /// fixed word rather than the connection's name, which the centred status item already + /// shows, so drawing it put a second idiom beside the native icon-only items for nothing. + .labelStyle(.iconOnly) .help(AppSettingsManager.shared.keyboard.shortcutHint(String(localized: "Switch Connection"), for: .switchConnection)) .popover(isPresented: $coordinator.isConnectionSwitcherShown, arrowEdge: .bottom) { ConnectionSwitcherPopover() @@ -37,6 +41,7 @@ struct DatabaseToolbarButton: View { } label: { Label(containerName, systemImage: "cylinder") } + .labelStyle(.iconOnly) .help(AppSettingsManager.shared.keyboard.shortcutHint(String(format: String(localized: "Open %@"), containerName), for: .openDatabase)) .disabled( state.connectionState != .connected @@ -78,182 +83,3 @@ struct SessionContextToolbarButton: View { } } } - -struct RefreshToolbarButton: View { - let coordinator: MainContentCoordinator - - var body: some View { - let state = coordinator.toolbarState - Button { - coordinator.commandActions?.refresh() - } label: { - Label("Refresh", systemImage: "arrow.clockwise") - } - .help(AppSettingsManager.shared.keyboard.shortcutHint(String(localized: "Refresh"), for: .refresh)) - .disabled(state.connectionState != .connected) - } -} - -struct SaveChangesToolbarButton: View { - let coordinator: MainContentCoordinator - - var body: some View { - let state = coordinator.toolbarState - Button { - coordinator.commandActions?.saveChanges() - } label: { - Label("Save Changes", systemImage: "checkmark.circle.fill") - } - .help(AppSettingsManager.shared.keyboard.shortcutHint(String(localized: "Save Changes"), for: .saveChanges)) - .disabled( - !state.hasPendingChanges - || state.connectionState != .connected - || state.safeModeLevel.blocksAllWrites - ) - .tint(.accentColor) - } -} - -struct QuickSwitcherToolbarButton: View { - let coordinator: MainContentCoordinator - - var body: some View { - let state = coordinator.toolbarState - Button { - coordinator.commandActions?.openQuickSwitcher() - } label: { - Label("Quick Switcher", systemImage: "magnifyingglass") - } - .help(AppSettingsManager.shared.keyboard.shortcutHint(String(localized: "Quick Switcher"), for: .quickSwitcher)) - .disabled(state.connectionState != .connected) - } -} - -struct NewTabToolbarButton: View { - let coordinator: MainContentCoordinator - - var body: some View { - let state = coordinator.toolbarState - Button { - NSApp.sendAction(#selector(MainSplitViewController.newEditorTab(_:)), to: nil, from: nil) - } label: { - Label("New Tab", systemImage: "plus.rectangle") - } - .help(AppSettingsManager.shared.keyboard.shortcutHint(String(localized: "New Query Tab"), for: .newTab)) - .disabled(state.connectionState != .connected) - } -} - -struct PreviewSQLToolbarButton: View { - let coordinator: MainContentCoordinator - - var body: some View { - let state = coordinator.toolbarState - let langName = PluginManager.shared.queryLanguageName(for: state.databaseType) - let previewLabel = String(format: String(localized: "Preview %@"), langName) - Button { - coordinator.commandActions?.previewSQL() - } label: { - Label(previewLabel, systemImage: "eye") - } - .help(AppSettingsManager.shared.keyboard.shortcutHint(previewLabel, for: .previewSQL)) - .disabled(!state.hasDataPendingChanges || state.connectionState != .connected) - } -} - -struct ResultsToolbarButton: View { - let coordinator: MainContentCoordinator - - var body: some View { - let state = coordinator.toolbarState - Button { - coordinator.commandActions?.toggleResults() - } label: { - Label( - "Results", - systemImage: state.isResultsCollapsed - ? "rectangle.bottomhalf.inset.filled" - : "rectangle.inset.filled" - ) - } - .help(AppSettingsManager.shared.keyboard.shortcutHint(String(localized: "Toggle Results"), for: .toggleResults)) - .disabled(state.connectionState != .connected || state.isTableTab) - } -} - -struct DashboardToolbarButton: View { - let coordinator: MainContentCoordinator - - var body: some View { - let state = coordinator.toolbarState - let supportsDashboard = coordinator.commandActions?.supportsServerDashboard ?? false - Button { - coordinator.commandActions?.showServerDashboard() - } label: { - Label(String(localized: "Dashboard"), systemImage: "gauge.with.dots.needle.33percent") - } - .help(String(localized: "Server Dashboard")) - .disabled(state.connectionState != .connected || !supportsDashboard) - } -} - -struct HistoryToolbarButton: View { - let coordinator: MainContentCoordinator - - var body: some View { - Button { - coordinator.commandActions?.toggleHistoryPanel() - } label: { - Label("History", systemImage: "clock") - } - .help(AppSettingsManager.shared.keyboard.shortcutHint(String(localized: "Toggle Query History"), for: .toggleHistory)) - } -} - -struct ExportToolbarButton: View { - let coordinator: MainContentCoordinator - - var body: some View { - let state = coordinator.toolbarState - Button { - coordinator.commandActions?.exportTables() - } label: { - Label("Export", systemImage: "square.and.arrow.up") - } - .help(AppSettingsManager.shared.keyboard.shortcutHint(String(localized: "Export Data"), for: .export)) - .disabled(state.connectionState != .connected) - } -} - -struct ImportToolbarButton: View { - let coordinator: MainContentCoordinator - - var body: some View { - let state = coordinator.toolbarState - if PluginManager.shared.supportsImport(for: state.databaseType) { - let formats = PluginManager.shared.importFormatOptions(for: state.databaseType) - let isDisabled = state.connectionState != .connected || state.safeModeLevel.blocksAllWrites - if formats.count <= 1 { - Button { - coordinator.commandActions?.importTables(formatId: formats.first?.id ?? "") - } label: { - Label("Import", systemImage: "square.and.arrow.down") - } - .help(AppSettingsManager.shared.keyboard.shortcutHint(String(localized: "Import Data"), for: .importData)) - .disabled(isDisabled || formats.isEmpty) - } else { - Menu { - ForEach(formats) { format in - Button(format.submenuLabel) { - coordinator.commandActions?.importTables(formatId: format.id) - } - } - } label: { - Label("Import", systemImage: "square.and.arrow.down") - } - .help(AppSettingsManager.shared.keyboard.shortcutHint(String(localized: "Import Data"), for: .importData)) - .disabled(isDisabled) - } - } - } -} diff --git a/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Delegate.swift b/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Delegate.swift index 5fc5c22e2..326b58c5a 100644 --- a/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Delegate.swift +++ b/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Delegate.swift @@ -59,7 +59,8 @@ extension MainWindowToolbar { symbol: "magnifyingglass", action: #selector(performOpenQuickSwitcher(_:)), keyEquivalent: "o", - modifiers: [.command, .shift] + modifiers: [.command, .shift], + shortcut: .quickSwitcher ) case Self.newTab: return menuOnlyItem( @@ -68,7 +69,9 @@ extension MainWindowToolbar { symbol: "plus.rectangle", action: #selector(performNewTab(_:)), keyEquivalent: "t", - modifiers: .command + modifiers: .command, + shortcut: .newTab, + description: String(localized: "New Query Tab") ) case Self.previewSQL: return menuOnlyItem( @@ -77,7 +80,9 @@ extension MainWindowToolbar { symbol: "eye", action: #selector(performPreviewSQL(_:)), keyEquivalent: "p", - modifiers: [.command, .shift] + modifiers: [.command, .shift], + shortcut: .previewSQL, + description: previewDescription ) case Self.results: return menuOnlyItem( @@ -86,7 +91,14 @@ extension MainWindowToolbar { symbol: "rectangle.bottomhalf.inset.filled", action: #selector(performToggleResults(_:)), keyEquivalent: "r", - modifiers: [.command, .option] + modifiers: [.command, .option], + shortcut: .toggleResults, + description: String(localized: "Toggle Results"), + symbolProvider: { [weak coordinator] in + coordinator?.toolbarState.isResultsCollapsed == false + ? "rectangle.inset.filled" + : "rectangle.bottomhalf.inset.filled" + } ) case Self.inspector: let item = NSToolbarItem(itemIdentifier: Self.inspector) @@ -100,7 +112,8 @@ extension MainWindowToolbar { symbol: "gauge.with.dots.needle.33percent", action: #selector(performShowDashboard(_:)), keyEquivalent: "", - modifiers: [] + modifiers: [], + description: String(localized: "Server Dashboard") ) case Self.history: return menuOnlyItem( @@ -109,7 +122,9 @@ extension MainWindowToolbar { symbol: "clock", action: #selector(performToggleHistory(_:)), keyEquivalent: "y", - modifiers: .command + modifiers: .command, + shortcut: .toggleHistory, + description: String(localized: "Toggle Query History") ) case Self.refreshSaveGroup: return makeNativeGroup( diff --git a/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Items.swift b/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Items.swift index 80db1b291..72ccd4fca 100644 --- a/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Items.swift +++ b/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Items.swift @@ -9,6 +9,15 @@ import SwiftUI extension MainWindowToolbar { // MARK: - Subitem Builders + /// The name of the driver's own query language, so the Preview tooltip says "Preview MQL" on + /// MongoDB rather than a generic word the user has to translate. + var previewDescription: String { + let language = coordinator.map { + PluginManager.shared.queryLanguageName(for: $0.toolbarState.databaseType) + } ?? String(localized: "SQL") + return String(format: String(localized: "Preview %@"), language) + } + func subitemConnection() -> NSToolbarItem { menuOnlyItem( id: Self.connection, @@ -16,7 +25,9 @@ extension MainWindowToolbar { symbol: "network", action: #selector(performOpenConnectionSwitcher(_:)), keyEquivalent: "c", - modifiers: [.command, .option] + modifiers: [.command, .option], + shortcut: .switchConnection, + description: String(localized: "Switch Connection") ) } @@ -30,7 +41,9 @@ extension MainWindowToolbar { symbol: "cylinder", action: #selector(performOpenDatabaseSwitcher(_:)), keyEquivalent: "k", - modifiers: .command + modifiers: .command, + shortcut: .openDatabase, + description: String(format: String(localized: "Open %@"), containerName) ) } @@ -41,7 +54,8 @@ extension MainWindowToolbar { symbol: "arrow.clockwise", action: #selector(performRefresh(_:)), keyEquivalent: "r", - modifiers: .command + modifiers: .command, + shortcut: .refresh ) } @@ -52,7 +66,8 @@ extension MainWindowToolbar { symbol: "checkmark.circle.fill", action: #selector(performSaveChanges(_:)), keyEquivalent: "s", - modifiers: .command + modifiers: .command, + shortcut: .saveChanges ) } @@ -63,16 +78,28 @@ extension MainWindowToolbar { symbol: "square.and.arrow.up", action: #selector(performExport(_:)), keyEquivalent: "e", - modifiers: [.command, .shift] + modifiers: [.command, .shift], + shortcut: .export, + description: String(localized: "Export Data") ) } + /// `NSMenuToolbarItem` is the toolbar control that opens a menu. A plain `NSToolbarItem` with a + /// submenu on its `menuFormRepresentation` only shows that menu in the overflow list. + /// + /// It carries no action on purpose. Given one, AppKit splits the control into a body that sends + /// the action and a separate chevron that opens the menu, so clicking the item itself does + /// nothing whenever the driver offers more than one format. With no action the whole control + /// opens the menu, and a single-format driver simply gets a one-item menu. func subitemImport() -> NSToolbarItem { let label = String(localized: "Import") - let item = NSToolbarItem(itemIdentifier: Self.importTables) + let item = NSMenuToolbarItem(itemIdentifier: Self.importTables) item.label = label item.paletteLabel = label + item.toolTip = toolTip(String(localized: "Import Data"), shortcut: .importData) + item.isBordered = true item.image = NSImage(systemSymbolName: "square.and.arrow.down", accessibilityDescription: label) + item.menu = buildImportSubmenu() let menuItem = NSMenuItem(title: label, action: nil, keyEquivalent: "") menuItem.image = item.image @@ -137,23 +164,29 @@ extension MainWindowToolbar { return item } + /// The label is what the customization palette and the overflow menu show, so it stays short. + /// The tooltip is the one place with room to say what the item does and which key runs it. func menuOnlyItem( id: NSToolbarItem.Identifier, label: String, symbol: String, action: Selector, keyEquivalent: String, - modifiers: NSEvent.ModifierFlags + modifiers: NSEvent.ModifierFlags, + shortcut: ShortcutAction? = nil, + description: String? = nil, + symbolProvider: (@MainActor () -> String)? = nil ) -> NSToolbarItem { - let item = NSToolbarItem(itemIdentifier: id) + let item = StatefulToolbarItem(itemIdentifier: id) item.label = label item.paletteLabel = label item.target = self item.action = action item.autovalidates = true item.isBordered = true - item.image = NSImage(systemSymbolName: symbol, accessibilityDescription: label) - item.toolTip = label + item.symbolAccessibilityDescription = label + item.symbolProvider = symbolProvider ?? { symbol } + item.toolTip = toolTip(description ?? label, shortcut: shortcut) let menuItem = NSMenuItem(title: label, action: action, keyEquivalent: keyEquivalent) menuItem.keyEquivalentModifierMask = modifiers @@ -164,6 +197,11 @@ extension MainWindowToolbar { return item } + func toolTip(_ label: String, shortcut: ShortcutAction?) -> String { + guard let shortcut else { return label } + return AppSettingsManager.shared.keyboard.shortcutHint(label, for: shortcut) + } + /// A group with real subitems and no `view` is drawn by AppKit itself, so it answers display /// mode changes and collapses into the overflow menu. A hosted view can do neither. func makeNativeGroup( diff --git a/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Validation.swift b/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Validation.swift index 3695cff9e..8d056fc1e 100644 --- a/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Validation.swift +++ b/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Validation.swift @@ -55,9 +55,9 @@ extension MainWindowToolbar: NSToolbarItemValidation { } } - func validateToolbarItem(_ item: NSToolbarItem) -> Bool { - guard let state = coordinator?.toolbarState else { return false } - let context = ValidationContext( + func validationContext() -> ValidationContext? { + guard let state = coordinator?.toolbarState else { return nil } + return ValidationContext( connected: Self.hasLiveSession(state.connectionState), isTableTab: state.isTableTab, hasPendingChanges: state.hasPendingChanges, @@ -68,6 +68,21 @@ extension MainWindowToolbar: NSToolbarItemValidation { supportsImport: PluginManager.shared.supportsImport(for: state.databaseType), supportsServerDashboard: coordinator?.commandActions?.supportsServerDashboard ?? false ) + } + + func validateToolbarItem(_ item: NSToolbarItem) -> Bool { + guard let context = validationContext() else { return false } return Self.isEnabled(itemIdentifier: item.itemIdentifier, context: context) } } + +/// The Import item carries no action, so `validateToolbarItem(_:)` never reaches it. Its menu items +/// do, and AppKit validates them each time the menu opens, which keeps the safe-mode gate live +/// instead of frozen at the moment the toolbar was built. +extension MainWindowToolbar: NSMenuItemValidation { + func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { + guard menuItem.action == #selector(performImportFormat(_:)) else { return true } + guard let context = validationContext() else { return false } + return Self.isEnabled(itemIdentifier: Self.importTables, context: context) + } +} diff --git a/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift b/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift index 7348e3510..e86a9880e 100644 --- a/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift +++ b/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift @@ -133,10 +133,18 @@ extension SidebarContainerViewController: NSSearchFieldDelegate { writeSearchText("") } + /// Down from the filter field hands focus to the list. The handoff goes through the key view loop, + /// which only ever lands on a view that answers `acceptsFirstResponder`; naming the hosting view + /// directly parked focus on a view that does not, so the selection never moved, the list never drew + /// as focused, and returning true swallowed the key. Returning false when nothing took focus leaves + /// AppKit's own handling in place. func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { - guard commandSelector == #selector(NSResponder.moveDown(_:)) else { return false } - view.window?.makeFirstResponder(hostingController.view) - return true + guard commandSelector == #selector(NSResponder.moveDown(_:)), let window = view.window else { + return false + } + let previous = window.firstResponder + window.selectKeyView(following: searchField) + return window.firstResponder !== previous } private func writeSearchText(_ text: String) { diff --git a/TablePro/Core/Services/Infrastructure/StatefulToolbarItem.swift b/TablePro/Core/Services/Infrastructure/StatefulToolbarItem.swift new file mode 100644 index 000000000..3bc15d516 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/StatefulToolbarItem.swift @@ -0,0 +1,34 @@ +// +// StatefulToolbarItem.swift +// TablePro +// + +import AppKit + +/// A toolbar item whose glyph reflects something that changes while the window is open. +/// +/// `NSToolbarItem.image` is set once when the delegate vends the item, and `autovalidates` only +/// drives `isEnabled`, so a toggle built that way keeps its opening glyph forever. `validate()` is +/// the hook AppKit already calls on every validation pass, which is exactly when the glyph should +/// be reconsidered. +@MainActor +internal final class StatefulToolbarItem: NSToolbarItem { + internal var symbolAccessibilityDescription: String? + + internal var symbolProvider: (@MainActor () -> String)? { + didSet { applySymbol() } + } + + private var appliedSymbol: String? + + override internal func validate() { + super.validate() + applySymbol() + } + + private func applySymbol() { + guard let symbol = symbolProvider?(), symbol != appliedSymbol else { return } + appliedSymbol = symbol + image = NSImage(systemSymbolName: symbol, accessibilityDescription: symbolAccessibilityDescription) + } +} diff --git a/TablePro/Core/Services/Infrastructure/TabWindowController.swift b/TablePro/Core/Services/Infrastructure/TabWindowController.swift index 42f5317bf..8496bf6bb 100644 --- a/TablePro/Core/Services/Infrastructure/TabWindowController.swift +++ b/TablePro/Core/Services/Infrastructure/TabWindowController.swift @@ -7,8 +7,11 @@ import AppKit import os import SwiftUI +/// The conformance is what makes the drag methods reachable. `NSWindow` does not adopt +/// `NSDraggingDestination`, so without it Swift emits no selector for these and AppKit, which +/// dispatches a drag by selector, runs `NSWindow`'s own refusal instead. @MainActor -private final class EditorWindow: NSWindow { +private final class EditorWindow: NSWindow, NSDraggingDestination { override func performClose(_ sender: Any?) { if let coordinator = MainContentCoordinator.coordinator(forWindow: self), let actions = coordinator.commandActions { @@ -18,7 +21,6 @@ private final class EditorWindow: NSWindow { } } - func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { FileDropDestination.acceptedURLs(from: sender.draggingPasteboard).isEmpty ? [] : .copy } diff --git a/TablePro/Core/Utilities/UI/AlertHelper.swift b/TablePro/Core/Utilities/UI/AlertHelper.swift index 483a16bae..73c1c9a4c 100644 --- a/TablePro/Core/Utilities/UI/AlertHelper.swift +++ b/TablePro/Core/Utilities/UI/AlertHelper.swift @@ -8,24 +8,32 @@ import SwiftUI @MainActor final class AlertHelper { - /// The confirming button destroys something, so it takes the destructive treatment and gives - /// up Return, and the cancelling button becomes the default in its place. That is the shape - /// macOS itself uses for a destructive alert: Return does the safe thing, and destroying - /// takes a deliberate click. + /// An `NSButton` holds exactly one key equivalent, so moving Return onto Cancel overwrites the + /// Escape that `NSAlert` puts there and leaves the alert with no way out from the keyboard. + /// Return is taken off the confirming button instead and handed to nobody, which is the shape + /// macOS itself ships for a destructive alert: Escape cancels, and destroying takes a + /// deliberate click. /// - /// Leaving the confirming button as the default made a stray Return delete. Taking Return off - /// it without handing it anywhere left the alert with no default button at all, which reads as - /// unfinished chrome and gives the keyboard no safe way out. + /// `hasDestructiveAction` alone reaches the same state, but only once the alert lays out, so + /// the binding is written here as well to make it true from the moment the alert is built. static func addConfirmAndCancel( to alert: NSAlert, confirmButton: String, cancelButton: String ) { let confirm = alert.addButton(withTitle: confirmButton) - let cancel = alert.addButton(withTitle: cancelButton) confirm.hasDestructiveAction = true confirm.keyEquivalent = "" - cancel.keyEquivalent = "\r" + addCancelButton(to: alert, title: cancelButton) + } + + /// `NSAlert` only recognises a cancel button by its title, which stops matching the moment the + /// title is localized, so the binding is made explicit rather than inferred. + @discardableResult + static func addCancelButton(to alert: NSAlert, title: String) -> NSButton { + let cancel = alert.addButton(withTitle: title) + cancel.keyEquivalent = "\u{1B}" + return cancel } static func resolveWindow(_ window: NSWindow?) -> NSWindow? { diff --git a/TablePro/Models/Query/LinkedFavoriteTransfer.swift b/TablePro/Models/Query/LinkedFavoriteTransfer.swift deleted file mode 100644 index b53d6787f..000000000 --- a/TablePro/Models/Query/LinkedFavoriteTransfer.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// LinkedFavoriteTransfer.swift -// TablePro -// - -import CoreTransferable -import Foundation -import UniformTypeIdentifiers - -internal struct LinkedFavoriteTransfer: Transferable { - let fileURL: URL - - static var transferRepresentation: some TransferRepresentation { - DataRepresentation(exportedContentType: .utf8PlainText) { item in - let loaded = FileTextLoader.load(item.fileURL) - return Data((loaded?.content ?? "").utf8) - } - } -} diff --git a/TablePro/Models/UI/RedisKeyNode.swift b/TablePro/Models/UI/RedisKeyNode.swift index 2888869c5..cf5303411 100644 --- a/TablePro/Models/UI/RedisKeyNode.swift +++ b/TablePro/Models/UI/RedisKeyNode.swift @@ -39,3 +39,19 @@ internal enum RedisKeyNode: Identifiable, Hashable { lhs.id == rhs.id } } + +extension RedisKeyNode { + /// Lifted out of the sidebar view so the outline row and anything else that renders a key can + /// agree on the glyph without importing SwiftUI. + static func iconName(forKeyType type: String) -> String { + switch type.lowercased() { + case "string": return "textformat" + case "hash": return "square.grid.2x2" + case "list": return "list.bullet" + case "set": return "circle.grid.3x3" + case "zset": return "chart.bar" + case "stream": return "waveform" + default: return "key" + } + } +} diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index c6641983a..c1ec203af 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -342,10 +342,6 @@ "%1$@, %2$@": { "shouldTranslate": false }, - "%1$@, %2$lld keys": { - "comment": "A description of a namespace, including the number of keys it contains. The first argument is the name of the namespace. The second argument is the count of keys in the namespace.", - "isCommentAutoGenerated": true - }, "%1$@: %2$@": { "comment": "A list of failed drops, one per line.", "isCommentAutoGenerated": true @@ -51622,6 +51618,7 @@ } } }, + "Merge All Windows": {}, "Merge Columns...": {}, "Merge Columns…": { "localizations": { @@ -80058,30 +80055,30 @@ } } }, - "Showing first 50,000 keys": { + "Showing first %lld keys": { "localizations": { "tr": { "stringUnit": { "state": "translated", - "value": "İlk 50.000 anahtar gösteriliyor" + "value": "İlk %lld anahtar gösteriliyor" } }, "vi": { "stringUnit": { "state": "translated", - "value": "Hiển thị 50.000 khóa đầu tiên" + "value": "Hiển thị %lld khóa đầu tiên" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "显示前 50,000 个键" + "value": "显示前 %lld 个键" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "顯示前 50,000 個鍵" + "value": "顯示前 %lld 個鍵" } } } diff --git a/TablePro/Theme/ThemeSlotValidation.swift b/TablePro/Theme/ThemeSlotValidation.swift index 8b8d05b3b..61343bd96 100644 --- a/TablePro/Theme/ThemeSlotValidation.swift +++ b/TablePro/Theme/ThemeSlotValidation.swift @@ -6,9 +6,12 @@ import Foundation /// `ThemeDefinition.appearance` was declared but never read, so a dark theme could be assigned to -/// the light slot and the app would honour it. Filtering the list is only half the fix: a user -/// already holding a mismatched theme would find their current row missing, so the slot has to be -/// re-anchored to the matching default in the same change. +/// the light slot and the app would honour it. The list is filtered to the themes that suit the +/// slot, and the theme the slot already holds is always kept in it. +/// +/// Rewriting the saved id to make the filter true is not an option: a settings pane is a +/// presentation surface, and the rewrite fired on appear, so opening Appearance silently replaced +/// a theme the user had chosen and then hid it from the list they would have used to put it back. internal enum ThemeSlotValidation { internal static func fits(_ appearance: ThemeAppearance, slot: ThemeAppearance) -> Bool { appearance == .auto || appearance == slot @@ -16,20 +19,9 @@ internal enum ThemeSlotValidation { internal static func eligibleThemes( _ themes: [ThemeDefinition], - slot: ThemeAppearance - ) -> [ThemeDefinition] { - themes.filter { fits($0.appearance, slot: slot) } - } - - /// Returns the theme id the slot should hold. An id that no longer resolves, or one whose - /// appearance contradicts the slot, falls back to the slot's default. - internal static func resolvedThemeId( - current: String, slot: ThemeAppearance, - themes: [ThemeDefinition], - defaultId: String - ) -> String { - guard let theme = themes.first(where: { $0.id == current }) else { return defaultId } - return fits(theme.appearance, slot: slot) ? current : defaultId + keeping selectedId: String? + ) -> [ThemeDefinition] { + themes.filter { fits($0.appearance, slot: slot) || $0.id == selectedId } } } diff --git a/TablePro/ViewModels/FavoritesSidebarViewModel.swift b/TablePro/ViewModels/FavoritesSidebarViewModel.swift index cb2e9ddc7..5ec0de711 100644 --- a/TablePro/ViewModels/FavoritesSidebarViewModel.swift +++ b/TablePro/ViewModels/FavoritesSidebarViewModel.swift @@ -151,7 +151,6 @@ internal extension [FavoriteNode] { internal final class FavoritesSidebarViewModel { var editDialogItem: FavoriteEditItem? var renamingFolderId: UUID? - var renamingFolderName: String = "" var showDeleteConfirmation = false var favoritesToDelete: [SQLFavorite] = [] @@ -319,13 +318,15 @@ internal final class FavoritesSidebarViewModel { } } + /// The editor seeds itself from the folder, so there is no buffer to prime here. func startRenameFolder(_ folder: SQLFavoriteFolder) { renamingFolderId = folder.id - renamingFolderName = folder.name } - func commitRenameFolder(_ folder: SQLFavoriteFolder) { - let newName = renamingFolderName.trimmingCharacters(in: .whitespaces) + /// The name arrives from the editor rather than through observable state, so a keystroke no + /// longer round-trips through the view model on its way to the field. + func commitRenameFolder(_ folder: SQLFavoriteFolder, to proposedName: String) { + let newName = proposedName.trimmingCharacters(in: .whitespaces) renamingFolderId = nil guard !newName.isEmpty, newName != folder.name else { return } Task { @@ -339,71 +340,6 @@ internal final class FavoritesSidebarViewModel { func filteredNodes(searchText: String) -> [FavoriteNode] { let allNodes = nodes guard !searchText.isEmpty else { return allNodes } - return filterTree(allNodes, searchText: searchText) - } - - private func filterTree(_ items: [FavoriteNode], searchText: String) -> [FavoriteNode] { - items.compactMap { node in - switch node.content { - case .favorite(let fav): - if fav.name.localizedCaseInsensitiveContains(searchText) || - (fav.keyword?.localizedCaseInsensitiveContains(searchText) == true) || - fav.query.localizedCaseInsensitiveContains(searchText) { - return node - } - return nil - case .folder(let folder): - let filteredChildren = filterTree(node.children ?? [], searchText: searchText) - if !filteredChildren.isEmpty || - folder.name.localizedCaseInsensitiveContains(searchText) { - return .folder(folder, children: filteredChildren) - } - return nil - case .linkedFavorite(let linked): - if linked.name.localizedCaseInsensitiveContains(searchText) || - (linked.keyword?.localizedCaseInsensitiveContains(searchText) == true) || - linked.relativePath.localizedCaseInsensitiveContains(searchText) { - return node - } - return nil - case .linkedFolder(let folder): - let filteredChildren = filterTree(node.children ?? [], searchText: searchText) - if !filteredChildren.isEmpty || folder.name.localizedCaseInsensitiveContains(searchText) { - return .linkedFolder(folder, children: filteredChildren) - } - return nil - case .linkedSubfolder(let folderId, let displayName, let pathPrefix): - let filteredChildren = filterTree(node.children ?? [], searchText: searchText) - if !filteredChildren.isEmpty || displayName.localizedCaseInsensitiveContains(searchText) { - return .linkedSubfolder( - folderId: folderId, - displayName: displayName, - pathPrefix: pathPrefix, - children: filteredChildren - ) - } - return nil - } - } - } - - func node(forId id: String) -> FavoriteNode? { - findNode(nodes, id: id, extract: { $0 }) - } - - private func findNode( - _ items: [FavoriteNode], - id: String, - extract: (FavoriteNode) -> T? - ) -> T? { - for node in items { - if node.id == id, let value = extract(node) { - return value - } - if let children = node.children, let found = findNode(children, id: id, extract: extract) { - return found - } - } - return nil + return FavoritesTreeFilter.filterTree(allNodes, searchText: searchText) } } diff --git a/TablePro/ViewModels/RedisKeyTreeViewModel.swift b/TablePro/ViewModels/RedisKeyTreeViewModel.swift index f25880ca8..958da388e 100644 --- a/TablePro/ViewModels/RedisKeyTreeViewModel.swift +++ b/TablePro/ViewModels/RedisKeyTreeViewModel.swift @@ -11,7 +11,7 @@ import TableProPluginKit @MainActor @Observable internal final class RedisKeyTreeViewModel { private static let logger = Logger(subsystem: "com.TablePro", category: "RedisKeyTree") - private static let maxKeys = 50_000 + internal static let maxKeys = 50_000 var rootNodes: [RedisKeyNode] = [] var isLoading = false diff --git a/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift b/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift index 8e0c41471..97dd34143 100644 --- a/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift +++ b/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift @@ -9,7 +9,6 @@ struct AIChatWalkthroughBlockView: View { @Bindable var block: ChatContentBlock @Environment(AIChatViewModel.self) private var viewModel - @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.commandActions) private var actions @State private var expandedStepIDs: Set = [] @@ -111,7 +110,7 @@ struct AIChatWalkthroughBlockView: View { .clipShape(RoundedRectangle(cornerRadius: 6)) .onChange(of: scrollTarget) { _, target in guard let target else { return } - withMotion { proxy.scrollTo(target, anchor: .center) } + withMotion(.easeInOut(duration: 0.25)) { proxy.scrollTo(target, anchor: .center) } } } } @@ -256,7 +255,7 @@ struct AIChatWalkthroughBlockView: View { .clipShape(RoundedRectangle(cornerRadius: 6)) .onChange(of: scrollTarget) { _, target in guard let target else { return } - withMotion { proxy.scrollTo(target, anchor: .center) } + withMotion(.easeInOut(duration: 0.25)) { proxy.scrollTo(target, anchor: .center) } } } } @@ -449,7 +448,7 @@ struct AIChatWalkthroughBlockView: View { highlightClearTask = Task { @MainActor in try? await Task.sleep(for: .seconds(1.5)) guard !Task.isCancelled else { return } - withMotion { activeAnchor = nil } + withMotion(.easeInOut(duration: 0.25)) { activeAnchor = nil } } } @@ -511,14 +510,6 @@ struct AIChatWalkthroughBlockView: View { guard expandedStepIDs.isEmpty, let first = steps.first else { return } expandedStepIDs.insert(first.id) } - - private func withMotion(_ change: () -> Void) { - if reduceMotion { - change() - } else { - withAnimation(.easeInOut(duration: 0.25)) { change() } - } - } } private struct ImportancePillView: View { diff --git a/TablePro/Views/AIChat/MentionSuggestionListView.swift b/TablePro/Views/AIChat/MentionSuggestionListView.swift index 43624286d..34a88bbab 100644 --- a/TablePro/Views/AIChat/MentionSuggestionListView.swift +++ b/TablePro/Views/AIChat/MentionSuggestionListView.swift @@ -9,15 +9,28 @@ struct MentionSuggestionListView: View { @Bindable var state: MentionPopoverState let onSelect: (Int) -> Void + /// Hover is its own state rather than a write into `selectedIndex`. Driving the selection from + /// the pointer let a mouse resting over the list silently overwrite an arrow-key choice, and + /// Return then committed a row the user never picked. + @State private var hoveredIndex: Int? + var body: some View { VStack(alignment: .leading, spacing: 0) { ForEach(Array(state.candidates.enumerated()), id: \.element.id) { index, candidate in MentionRowView( candidate: candidate, - isSelected: index == state.selectedIndex + isSelected: index == state.selectedIndex, + isHovered: index == hoveredIndex ) .contentShape(Rectangle()) .onTapGesture { onSelect(index) } + .onHover { hovering in + if hovering { + hoveredIndex = index + } else if hoveredIndex == index { + hoveredIndex = nil + } + } .accessibilityElement(children: .combine) .accessibilityAddTraits(index == state.selectedIndex ? [.isButton, .isSelected] : .isButton) } @@ -30,6 +43,7 @@ struct MentionSuggestionListView: View { private struct MentionRowView: View { let candidate: MentionCandidate let isSelected: Bool + let isHovered: Bool private var primaryTextColor: Color { Color(nsColor: .alternateSelectedControlTextColor) @@ -60,10 +74,11 @@ private struct MentionRowView: View { .padding(.horizontal, 10) .padding(.vertical, 4) .frame(maxWidth: .infinity, alignment: .leading) - .background( - isSelected - ? Color(nsColor: .selectedContentBackgroundColor) - : Color.clear - ) + .background(rowBackground) + } + + private var rowBackground: Color { + if isSelected { return Color(nsColor: .selectedContentBackgroundColor) } + return isHovered ? Color(nsColor: .quaternarySystemFill) : .clear } } diff --git a/TablePro/Views/Components/ColorPaletteView.swift b/TablePro/Views/Components/ColorPaletteView.swift index d842e688c..e274b0873 100644 --- a/TablePro/Views/Components/ColorPaletteView.swift +++ b/TablePro/Views/Components/ColorPaletteView.swift @@ -87,9 +87,12 @@ private struct ColorSwatch: View { .frame(width: size.dotSize, height: size.dotSize) } + /// The ring is the only thing that says which swatch is picked, so it cannot be drawn + /// in a colour the user configures. On a yellow or green accent it measured 1.32:1 + /// against the sheet; the label colour holds 12.49:1 whatever the accent is. if isSelected { Circle() - .stroke(Color.accentColor, lineWidth: 2) + .stroke(Color.primary, lineWidth: 2) .frame(width: size.selectionRingSize, height: size.selectionRingSize) } } diff --git a/TablePro/Views/Components/TransferResultAlert.swift b/TablePro/Views/Components/TransferResultAlert.swift index 6b5e423f3..be1bae7d9 100644 --- a/TablePro/Views/Components/TransferResultAlert.swift +++ b/TablePro/Views/Components/TransferResultAlert.swift @@ -55,14 +55,7 @@ internal enum TransferResultAlert { alert.addButton(withTitle: String(localized: "Done")) if let errors = result?.errors, !errors.isEmpty { - let lines = errors.map { failure in - String( - format: String(localized: "Line %1$lld: %2$@"), - Int64(failure.line), - failure.errorMessage - ) - } - alert.accessoryView = scrollingText(lines.joined(separator: "\n")) + alert.accessoryView = scrollingText(failureReport(for: errors)) alert.layout() } @@ -95,6 +88,22 @@ internal enum TransferResultAlert { present(alert, in: window) { _ in completion() } } + /// A row import names its failing entry `row 12`, which the line number already says, so only + /// a statement that carries something the line number does not is worth repeating. + internal static func failureReport(for failures: [PluginImportResult.ImportStatementError]) -> String { + failures.map { failure in + let heading = String( + format: String(localized: "Line %1$lld: %2$@"), + Int64(failure.line), + failure.errorMessage + ) + let statement = failure.statement.trimmingCharacters(in: .whitespacesAndNewlines) + guard !statement.isEmpty, statement != "row \(failure.line)" else { return heading } + return "\(heading)\n\(statement)" + } + .joined(separator: "\n\n") + } + private static func importSummary(_ result: PluginImportResult?) -> String { guard let result else { return "" } let counts = result.skippedStatements > 0 diff --git a/TablePro/Views/Connection/ConnectionGroupPicker.swift b/TablePro/Views/Connection/ConnectionGroupPicker.swift index 09d10f4f3..d621a3b8a 100644 --- a/TablePro/Views/Connection/ConnectionGroupPicker.swift +++ b/TablePro/Views/Connection/ConnectionGroupPicker.swift @@ -2,12 +2,9 @@ // ConnectionGroupPicker.swift // TablePro // -// Group selector dropdown for connection form -// import SwiftUI -/// Group selection for a connection — single Menu dropdown struct ConnectionGroupPicker: View { @Binding var selectedGroupId: UUID? @State private var allGroups: [ConnectionGroup] = [] @@ -15,25 +12,20 @@ struct ConnectionGroupPicker: View { private let groupStorage = GroupStorage.shared - private var selectedGroup: ConnectionGroup? { - guard let id = selectedGroupId else { return nil } - return allGroups.first { $0.id == id } - } - - /// A pop up button carries the selected value, the checkmark and the menu role for free. - /// Hand-drawn checkmarks reported nothing to VoiceOver, and the indentation was a run of - /// spaces glued onto the name, which reads out loud and collapses in right-to-left. + /// A pop up button carries the selected value, the checkmark, the menu role and the nesting + /// for free. Hand-drawn checkmarks reported nothing to VoiceOver, and a SwiftUI `Picker` + /// lowers every option to a plain `NSMenuItem`, discarding the depth the option carried. var body: some View { HStack(spacing: 6) { - Picker(String(localized: "Group"), selection: $selectedGroupId) { - Text("None").tag(UUID?.none) - Divider() - hierarchicalGroupItems() - } - .pickerStyle(.menu) - .labelsHidden() + GroupPopUpButton( + entries: GroupMenuEntries.forConnection( + groups: allGroups, + noneTitle: String(localized: "None") + ), + selection: $selectedGroupId, + accessibilityLabel: String(localized: "Group") + ) .fixedSize() - .accessibilityLabel(Text("Group")) Button { showingCreateSheet = true @@ -55,35 +47,6 @@ struct ConnectionGroupPicker: View { } } } - - @ViewBuilder - private func hierarchicalGroupItems() -> some View { - let flatGroups = flattenGroupsForMenu(groups: allGroups) - ForEach(flatGroups, id: \.group.id) { entry in - Label { - Text(entry.group.name) - } icon: { - if !entry.group.color.isDefault { - Image(nsImage: colorDot(entry.group.color.color)) - } - } - .padding(.leading, CGFloat(entry.depth) * Self.depthIndent) - .tag(UUID?.some(entry.group.id)) - } - } - - private static let depthIndent: CGFloat = 12 - - private func colorDot(_ color: Color) -> NSImage { - let size = NSSize(width: 10, height: 10) - let image = NSImage(size: size, flipped: false) { rect in - NSColor(color).setFill() - NSBezierPath(ovalIn: rect).fill() - return true - } - image.isTemplate = false - return image - } } // MARK: - Create Group Sheet @@ -162,51 +125,16 @@ private struct ParentGroupPicker: View { let allGroups: [ConnectionGroup] var body: some View { - Menu { - Button { - selectedParentId = nil - } label: { - HStack { - Text("None (Top Level)") - if selectedParentId == nil { - Spacer() - Image(systemName: "checkmark") - } - } - } - - Divider() - - ForEach(allGroups.sorted(by: { $0.name.localizedStandardCompare($1.name) == .orderedAscending })) { group in - let depth = depthOf(groupId: group.id, groups: allGroups) - Button { - selectedParentId = group.id - } label: { - HStack { - Text(String(repeating: " ", count: max(0, depth - 1)) + group.name) - if selectedParentId == group.id { - Spacer() - Image(systemName: "checkmark") - } - } - } - .disabled(depth >= 3) - } - } label: { - Text(parentLabel) - .foregroundStyle(selectedParentId == nil ? .secondary : .primary) - } - .menuStyle(.borderlessButton) + GroupPopUpButton( + entries: GroupMenuEntries.forParent( + groups: allGroups, + noneTitle: String(localized: "None (Top Level)") + ), + selection: $selectedParentId, + accessibilityLabel: String(localized: "Parent Group") + ) .fixedSize() } - - private var parentLabel: String { - guard let pid = selectedParentId, - let group = allGroups.first(where: { $0.id == pid }) else { - return String(localized: "None (Top Level)") - } - return group.name - } } #Preview { diff --git a/TablePro/Views/Connection/GroupPopUpButton.swift b/TablePro/Views/Connection/GroupPopUpButton.swift new file mode 100644 index 000000000..f7d6d894d --- /dev/null +++ b/TablePro/Views/Connection/GroupPopUpButton.swift @@ -0,0 +1,155 @@ +// +// GroupPopUpButton.swift +// TablePro +// + +import AppKit +import SwiftUI + +/// One row of a group pop-up menu. `indentationLevel` is the menu's own way of showing nesting, +/// which a run of leading spaces is not: spaces are read aloud by VoiceOver and lay out on the +/// wrong side in a right-to-left language. +internal struct GroupMenuEntry: Equatable, Identifiable { + internal let id: UUID? + internal let title: String + internal let indentationLevel: Int + internal let color: ConnectionColor + internal let isEnabled: Bool + internal let hasSeparatorAbove: Bool + + internal init( + id: UUID?, + title: String, + indentationLevel: Int = 0, + color: ConnectionColor = .none, + isEnabled: Bool = true, + hasSeparatorAbove: Bool = false + ) { + self.id = id + self.title = title + self.indentationLevel = indentationLevel + self.color = color + self.isEnabled = isEnabled + self.hasSeparatorAbove = hasSeparatorAbove + } +} + +internal enum GroupMenuEntries { + /// The maximum nesting a group is allowed to be moved under, mirroring the tree's own limit. + internal static let maximumDepth = 3 + + internal static func forConnection(groups: [ConnectionGroup], noneTitle: String) -> [GroupMenuEntry] { + var entries = [GroupMenuEntry(id: nil, title: noneTitle)] + entries += flattenGroupsForMenu(groups: groups).enumerated().map { index, entry in + GroupMenuEntry( + id: entry.group.id, + title: entry.group.name, + indentationLevel: entry.depth, + color: entry.group.color, + hasSeparatorAbove: index == 0 + ) + } + return entries + } + + internal static func forParent(groups: [ConnectionGroup], noneTitle: String) -> [GroupMenuEntry] { + var entries = [GroupMenuEntry(id: nil, title: noneTitle)] + entries += flattenGroupsForMenu(groups: groups).enumerated().map { index, entry in + GroupMenuEntry( + id: entry.group.id, + title: entry.group.name, + indentationLevel: entry.depth, + color: entry.group.color, + isEnabled: depthOf(groupId: entry.group.id, groups: groups) < maximumDepth, + hasSeparatorAbove: index == 0 + ) + } + return entries + } +} + +/// `NSPopUpButton` is the control macOS uses to pick one value from a menu. It carries the +/// selection, the checkmark, the menu role and the indentation for free; a SwiftUI `Picker` lowers +/// every option to a plain `NSMenuItem` and drops any layout the option carried. +internal struct GroupPopUpButton: NSViewRepresentable { + internal let entries: [GroupMenuEntry] + @Binding internal var selection: UUID? + internal let accessibilityLabel: String + + internal func makeCoordinator() -> Coordinator { + Coordinator(selection: $selection) + } + + internal func makeNSView(context: Context) -> NSPopUpButton { + let button = NSPopUpButton(frame: .zero, pullsDown: false) + button.target = context.coordinator + button.action = #selector(Coordinator.selectionChanged(_:)) + button.setAccessibilityLabel(accessibilityLabel) + button.setContentHuggingPriority(.defaultHigh, for: .horizontal) + return button + } + + internal func updateNSView(_ button: NSPopUpButton, context: Context) { + context.coordinator.selection = $selection + guard context.coordinator.entries != entries else { + context.coordinator.select(selection, in: button) + return + } + context.coordinator.entries = entries + button.menu = Self.makeMenu(entries: entries) + context.coordinator.select(selection, in: button) + } + + private static func makeMenu(entries: [GroupMenuEntry]) -> NSMenu { + let menu = NSMenu() + /// The pop up button owns the action, so its items carry none. Left on, automatic enabling + /// would find no target for any of them and grey out the whole menu. + menu.autoenablesItems = false + for entry in entries { + if entry.hasSeparatorAbove { + menu.addItem(.separator()) + } + let item = NSMenuItem(title: entry.title, action: nil, keyEquivalent: "") + item.indentationLevel = entry.indentationLevel + item.isEnabled = entry.isEnabled + item.representedObject = entry.id + if !entry.color.isDefault { + item.image = colorDot(entry.color.color) + } + menu.addItem(item) + } + return menu + } + + private static func colorDot(_ color: Color) -> NSImage { + let size = NSSize(width: 10, height: 10) + let image = NSImage(size: size, flipped: false) { rect in + NSColor(color).setFill() + NSBezierPath(ovalIn: rect).fill() + return true + } + image.isTemplate = false + return image + } + + @MainActor + internal final class Coordinator: NSObject { + internal var entries: [GroupMenuEntry] = [] + internal var selection: Binding + + internal init(selection: Binding) { + self.selection = selection + } + + internal func select(_ id: UUID?, in button: NSPopUpButton) { + let match = button.menu?.items.first { ($0.representedObject as? UUID) == id } + ?? button.menu?.items.first { $0.representedObject == nil && !$0.isSeparatorItem } + guard let match, button.selectedItem !== match else { return } + button.select(match) + } + + @objc internal func selectionChanged(_ sender: NSPopUpButton) { + selection.wrappedValue = sender.selectedItem?.representedObject as? UUID + } + } +} diff --git a/TablePro/Views/Connection/TagFilterBar.swift b/TablePro/Views/Connection/TagFilterBar.swift index 2f80703c1..0f799a927 100644 --- a/TablePro/Views/Connection/TagFilterBar.swift +++ b/TablePro/Views/Connection/TagFilterBar.swift @@ -41,6 +41,11 @@ struct TagFilterBar: View { /// `ButtonToggleStyle` reports the on and off value itself, and brings hover, press and the /// keyboard focus ring that a plain button with a hand-drawn capsule never had. + /// + /// The tint stays at the accent colour. Routing the tag's own colour through it turned the + /// on-state from a prominent fill with a legible label into a wash of the tag colour with the + /// label drawn in that same colour, which measured 1.37:1 on yellow. A tint is the accent for + /// a control's selected state, not a decorative fill; the tag's colour belongs in the dot. private func tagPill(_ tag: ConnectionTag) -> some View { Toggle(isOn: binding(for: tag)) { HStack(spacing: 4) { @@ -53,7 +58,6 @@ struct TagFilterBar: View { } .toggleStyle(.button) .controlSize(.small) - .tint(tag.color.color) .help(Text(tag.name)) } diff --git a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift index b0278037b..ae4dd92a9 100644 --- a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift +++ b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift @@ -151,30 +151,21 @@ struct DatabaseSwitcherPopover: View { } } + /// The search field keeps focus for the whole flow, so the list is a presentation of that + /// field's selection rather than a second focusable control. See `FieldDrivenList`. private var list: some View { - ScrollViewReader { proxy in - List(selection: $viewModel.selectedDatabases) { - ForEach(viewModel.filteredDatabases) { db in - row(for: db) - } - } - .listStyle(.inset) - .scrollContentBackground(.hidden) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .contextMenu(forSelectionType: String.self) { selection in - contextMenuItems(for: selection) - } primaryAction: { selection in - guard let name = selection.first else { return } + FieldDrivenList( + sections: [FieldDrivenListSection(id: "databases", items: viewModel.filteredDatabases)], + selection: $viewModel.selectedDatabases, + allowsMultipleSelection: true, + onPrimaryAction: { name in viewModel.selectedDatabase = name commitSelection() - } - .onChange(of: viewModel.primarySelection) { _, newValue in - guard let item = newValue else { return } - withMotion(.easeInOut(duration: 0.15)) { - proxy.scrollTo(item) - } - } - } + }, + menuItems: { contextMenuItems(for: $0) }, + row: { row(for: $0) } + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) } private func row(for database: DatabaseMetadata) -> some View { @@ -201,43 +192,36 @@ struct DatabaseSwitcherPopover: View { Spacer(minLength: 0) } - .padding(.vertical, 1) + .padding(.horizontal, 8) .contentShape(Rectangle()) - .listRowInsets(EdgeInsets(top: 2, leading: 8, bottom: 2, trailing: 8)) - .listRowSeparator(.hidden) - .id(database.name) - .tag(database.name) } - @ViewBuilder - private func contextMenuItems(for selection: Set) -> some View { + private func contextMenuItems(for selection: Set) -> [FieldDrivenMenuItem] { let targets = containerRefs(for: selection) let droppable = ContainerDropEligibility.droppable(targets, context: dropEligibilityContext) + var items: [FieldDrivenMenuItem] = [] if !targets.isEmpty { - Button(targets.count == 1 + let copyTitle = targets.count == 1 ? String(localized: "Copy Name") : String(format: String(localized: "Copy %lld Names"), targets.count) - ) { + items.append(FieldDrivenMenuItem(title: copyTitle) { ClipboardService.shared.writeText(targets.map(\.name).joined(separator: ",")) - } - - Button(String(localized: "Export…")) { + }) + items.append(FieldDrivenMenuItem(title: String(localized: "Export…")) { dismiss() onRequestExport(targets) - } + }) } if !droppable.isEmpty { - Divider() - - Button(role: .destructive) { + items.append(.separator) + items.append(FieldDrivenMenuItem(title: dropMenuTitle(for: droppable)) { dismiss() onRequestDrop(droppable) - } label: { - Label(dropMenuTitle(for: droppable), systemImage: "trash") - } + }) } + return items } private func containerRefs(for selection: Set) -> [DatabaseContainerRef] { diff --git a/TablePro/Views/Main/EditorTabStripLayout.swift b/TablePro/Views/Main/EditorTabStripLayout.swift index 77580d196..748351b1a 100644 --- a/TablePro/Views/Main/EditorTabStripLayout.swift +++ b/TablePro/Views/Main/EditorTabStripLayout.swift @@ -21,8 +21,6 @@ internal enum EditorTabStripLayout { internal static let accessoryInset: CGFloat = 5 internal static let fontSize: CGFloat = 11 - internal static var totalHeight: CGFloat { trackHeight + stripInset * 2 } - /// Tabs share the track equally, and stop shrinking at a width that still fits a name so a /// long list scrolls instead of collapsing into slivers. The system staggers widths slightly /// by an undocumented rule; an equal share is within a couple of points of it. diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 3880e9381..ffd69d9fb 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -27,7 +27,7 @@ extension MainContentCoordinator { showStructure: Bool = false, forceNonPreview: Bool = false, activateGridFocus: Bool = false, - forceNewWindowTab: Bool = false + forceNewTab: Bool = false ) -> WindowTabOpenDisposition? { openTableTab( table.name, @@ -36,7 +36,7 @@ extension MainContentCoordinator { isView: !table.type.allowsRowEditing, forceNonPreview: forceNonPreview, activateGridFocus: activateGridFocus, - forceNewWindowTab: forceNewWindowTab + forceNewTab: forceNewTab ) } @@ -48,7 +48,7 @@ extension MainContentCoordinator { isView: Bool = false, forceNonPreview: Bool = false, activateGridFocus: Bool = false, - forceNewWindowTab: Bool = false + forceNewTab: Bool = false ) -> WindowTabOpenDisposition? { let navigationModel = PluginMetadataRegistry.shared.snapshot( forTypeId: connection.type.pluginTypeId @@ -65,10 +65,10 @@ extension MainContentCoordinator { } let resolvedSchema = DatabaseManager.shared.resolvedSchemaName(schema, for: connectionId) - let createAsPreview = !forceNonPreview && !forceNewWindowTab + let createAsPreview = !forceNonPreview && !forceNewTab && AppSettingsManager.shared.tabs.enablePreviewTabs - if !forceNewWindowTab, let disposition = activateIfAlreadyOpen( + if !forceNewTab, let disposition = activateIfAlreadyOpen( tableName: tableName, databaseName: currentDatabase, schemaName: resolvedSchema, @@ -82,8 +82,12 @@ extension MainContentCoordinator { return disposition } + /// Not a bare flag. `pendingGridFocusOnOpen` is consumed only when the grid view moves into + /// a window, which happens for the first table tab and never again, because every later tab + /// reuses that same view. Setting it directly left the request pending forever and focus in + /// the sidebar from the second table on. if activateGridFocus { - pendingGridFocusOnOpen = true + requestGridFocus() } // During database switch, update the existing tab in-place instead of @@ -160,7 +164,7 @@ extension MainContentCoordinator { } } - if isActiveTabReusable, !forceNewWindowTab { + if isActiveTabReusable, !forceNewTab { let didOpen = reuseActiveTab( for: tableName, currentDatabase: currentDatabase, diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift index a08f41145..bc5096c2e 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift @@ -51,7 +51,7 @@ extension MainContentCoordinator { showStructure: intent == .openStructure, isView: item.isReadOnly, activateGridFocus: true, - forceNewWindowTab: intent == .openInNewWindowTab + forceNewTab: intent == .openInNewWindowTab ) case .view: @@ -61,7 +61,7 @@ extension MainContentCoordinator { showStructure: intent == .openStructure, isView: true, activateGridFocus: true, - forceNewWindowTab: intent == .openInNewWindowTab + forceNewTab: intent == .openInNewWindowTab ) case .database: @@ -77,13 +77,13 @@ extension MainContentCoordinator { case .savedQuery: loadQueryIntoEditor( item.payload ?? item.name, - forceNewWindowTab: intent == .openInNewWindowTab + forceNewTab: intent == .openInNewWindowTab ) case .queryHistory: loadQueryIntoEditor( item.payload ?? item.name, - forceNewWindowTab: intent == .openInNewWindowTab + forceNewTab: intent == .openInNewWindowTab ) } } @@ -99,7 +99,7 @@ extension MainContentCoordinator { schema: target.schemaName, isView: item.kind == .view || item.isReadOnly, activateGridFocus: true, - forceNewWindowTab: intent == .openInNewWindowTab + forceNewTab: intent == .openInNewWindowTab ) switch disposition { case .currentCoordinator: @@ -155,7 +155,7 @@ extension MainContentCoordinator { let disposition = coordinator.loadQueryIntoEditor( query, databaseName: target.databaseName, - forceNewWindowTab: intent == .openInNewWindowTab + forceNewTab: intent == .openInNewWindowTab ) if disposition == .currentCoordinator, let tabId = coordinator.tabManager.selectedTabId { diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 6f0daa79b..1d2a7ce1d 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -971,10 +971,10 @@ final class MainContentCoordinator { func loadQueryIntoEditor( _ query: String, databaseName: String? = nil, - forceNewWindowTab: Bool = false + forceNewTab: Bool = false ) -> WindowTabOpenDisposition { let targetDatabaseName = databaseName ?? browseDatabaseName - if !forceNewWindowTab, + if !forceNewTab, let (tab, tabIndex) = tabManager.selectedTabAndIndex, tab.tabType == .query, databaseName == nil @@ -986,7 +986,7 @@ final class MainContentCoordinator { return .currentCoordinator } - if !forceNewWindowTab, tabManager.tabs.isEmpty { + if !forceNewTab, tabManager.tabs.isEmpty { tabManager.addTab(initialQuery: query, databaseName: targetDatabaseName) return .currentCoordinator } diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 3e4444f20..306be74fd 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -324,7 +324,7 @@ struct MainContentView: View { } private var bodyContentCore: some View { - mainContentView + editorTabStripAndContent .task { let start = Date() Self.lifecycleLogger.info( @@ -389,6 +389,28 @@ struct MainContentView: View { // MARK: - Main Content + /// These tabs belong to one connection's editor pane, not to the window, so they sit above the + /// pane the way Xcode places its editor tabs. The titlebar is where a window's own document + /// tabs go, and putting a pane-level strip there moves the window's base line below it and + /// paints the strip on titlebar material instead of the content background. + /// + /// The strip is hidden while a connection holds a single tab, so a window that behaves the + /// way it always did gains no chrome. It appears the moment a second tab exists. + private var editorTabStripAndContent: some View { + VStack(spacing: 0) { + if tabManager.tabs.count > 1 { + EditorTabStrip( + tabManager: tabManager, + onClose: { coordinator.commandActions?.closeTab(id: $0) }, + onCloseOthers: { coordinator.commandActions?.closeOtherTabs(anchoredOn: $0) }, + onCloseAll: { coordinator.commandActions?.closeAllTabs() }, + onNewTab: { coordinator.commandActions?.newTab() } + ) + } + mainContentView + } + } + @ViewBuilder private var mainContentView: some View { MainEditorContentView( diff --git a/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift b/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift index 87c323253..9cf5032f1 100644 --- a/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift +++ b/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift @@ -87,7 +87,6 @@ struct QuickSwitcherPanelContent: View { @Bindable var viewModel: QuickSwitcherViewModel let onCommit: (QuickSwitcherItem, QuickSwitcherCommitIntent) -> Void - @State private var isNavigating = false @State private var keyMonitor: Any? var body: some View { @@ -101,8 +100,6 @@ struct QuickSwitcherPanelContent: View { } } .frame(width: PanelMetrics.width) - .onChange(of: viewModel.searchText) { _, _ in isNavigating = false } - .onChange(of: viewModel.scope) { _, _ in isNavigating = false } .onAppear { installKeyMonitor() } .onDisappear { removeKeyMonitor() } } @@ -191,14 +188,8 @@ struct QuickSwitcherPanelContent: View { QuickSwitcherSearchField( text: $viewModel.searchText, placeholder: String(localized: "Search tables, views, databases, queries..."), - onMoveUp: { - isNavigating = true - viewModel.moveSelection(by: -1) - }, - onMoveDown: { - isNavigating = true - viewModel.moveSelection(by: 1) - }, + onMoveUp: { viewModel.moveSelection(by: -1) }, + onMoveDown: { viewModel.moveSelection(by: 1) }, onSubmit: { openSelectedItem() } ) } @@ -267,8 +258,11 @@ struct QuickSwitcherPanelContent: View { } private func itemRow(_ item: QuickSwitcherItem) -> some View { + /// The panel closes as soon as it stops being key, so "unfocused" is a state it cannot be in + /// and must not depict. Gating this on whether an arrow key had fired painted the row Return + /// commits as inactive until the user pressed one. let isSelected = item.id == viewModel.selectedItemId - let isEmphasized = isSelected && isNavigating + let isEmphasized = isSelected return HStack(spacing: 12) { iconView(for: item, isEmphasized: isEmphasized) @@ -298,15 +292,11 @@ struct QuickSwitcherPanelContent: View { } .contentShape(Rectangle()) .onTapGesture(count: 2) { - isNavigating = true viewModel.selectedItemId = item.id onCommit(item, .open) } .simultaneousGesture( - TapGesture().onEnded { - isNavigating = true - viewModel.selectedItemId = item.id - } + TapGesture().onEnded { viewModel.selectedItemId = item.id } ) .contextMenu { contextMenuActions(for: item) } .accessibilityElement(children: .combine) @@ -459,11 +449,9 @@ struct QuickSwitcherPanelContent: View { if modifiers == .control { switch characters { case "j", "n": - isNavigating = true viewModel.moveSelection(by: 1) return nil case "k", "p": - isNavigating = true viewModel.moveSelection(by: -1) return nil default: diff --git a/TablePro/Views/Results/DataGridRowView.swift b/TablePro/Views/Results/DataGridRowView.swift index 603622573..079ea1ff1 100644 --- a/TablePro/Views/Results/DataGridRowView.swift +++ b/TablePro/Views/Results/DataGridRowView.swift @@ -100,19 +100,18 @@ class DataGridRowView: NSTableRowView { drawCellSelectionFill(in: dirtyRect) } + /// A cell range on a row the table has selected is already covered by `NSTableRowView`'s own + /// selection fill, which runs after this, so only the remaining rows are painted here. private func drawCellSelectionFill(in dirtyRect: NSRect) { - guard let coordinator, + guard !isSelected, + let coordinator, let tableView = coordinator.tableView else { return } let selection = coordinator.selectionController.selection guard !selection.isEmpty else { return } let columns = selection.columns(in: rowIndex) guard !columns.isEmpty else { return } - let base: NSColor = isEmphasized - ? .selectedContentBackgroundColor - : .unemphasizedSelectedContentBackgroundColor - let alpha = isSelected ? Self.rowSelectedCellAlpha : Self.cellOnlySelectionAlpha - base.withAlphaComponent(alpha).setFill() + cellSelectionFill.setFill() for dataColumn in columns { guard let tableColumnIndex = coordinator.tableColumnIndex(for: dataColumn) else { continue } @@ -123,8 +122,17 @@ class DataGridRowView: NSTableRowView { } } - private static let rowSelectedCellAlpha: CGFloat = 0.55 - private static let cellOnlySelectionAlpha: CGFloat = 0.28 + /// `unemphasizedSelectedContentBackgroundColor` is a background colour and is used as one, the + /// way AppKit fills a selection in a view that does not hold focus. Thinning it out instead + /// left the range at 1.09:1 against a white grid, which is no visible selection at all. The + /// emphasized accent is far too dark to sit behind text these rows do not recolour, so that + /// one goes on as a tint. + private var cellSelectionFill: NSColor { + guard isEmphasized else { return .unemphasizedSelectedContentBackgroundColor } + return NSColor.selectedContentBackgroundColor.withAlphaComponent(Self.emphasizedCellSelectionAlpha) + } + + private static let emphasizedCellSelectionAlpha: CGFloat = 0.28 private func colorsEqual(_ lhs: NSColor?, _ rhs: NSColor?) -> Bool { switch (lhs, rhs) { diff --git a/TablePro/Views/Results/SortableHeaderEmphasis.swift b/TablePro/Views/Results/SortableHeaderEmphasis.swift new file mode 100644 index 000000000..8b761fcfd --- /dev/null +++ b/TablePro/Views/Results/SortableHeaderEmphasis.swift @@ -0,0 +1,21 @@ +// +// SortableHeaderEmphasis.swift +// TablePro +// + +import AppKit + +/// `NSTableRowView.isEmphasized` is key window *and* table focus, and the header has to answer the +/// same question or the two halves of one selection disagree. +internal enum SortableHeaderEmphasis { + internal static func isEmphasized(tableViewHoldsFocus: Bool, isKeyWindow: Bool) -> Bool { + tableViewHoldsFocus && isKeyWindow + } + + /// A cell being edited puts the field editor in the responder chain below the table, so focus + /// is resolved by ancestry rather than by identity. + internal static func holdsFocus(tableView: NSTableView?, in window: NSWindow?) -> Bool { + guard let tableView, let responder = window?.firstResponder as? NSView else { return false } + return responder === tableView || responder.isDescendant(of: tableView) + } +} diff --git a/TablePro/Views/Results/SortableHeaderView.swift b/TablePro/Views/Results/SortableHeaderView.swift index 612a10605..d7c245971 100644 --- a/TablePro/Views/Results/SortableHeaderView.swift +++ b/TablePro/Views/Results/SortableHeaderView.swift @@ -101,6 +101,7 @@ final class SortableHeaderView: NSTableHeaderView { } private var emphasisObservers: [NSObjectProtocol] = [] + private var firstResponderObservation: NSKeyValueObservation? override init(frame frameRect: NSRect) { naturalHeight = frameRect.height > 0 ? frameRect.height : Self.fallbackHeight @@ -120,6 +121,7 @@ final class SortableHeaderView: NSTableHeaderView { super.viewDidMoveToWindow() emphasisObservers.forEach(NotificationCenter.default.removeObserver) emphasisObservers.removeAll() + firstResponderObservation = nil guard let window else { applyEmphasis(false) return @@ -130,11 +132,26 @@ final class SortableHeaderView: NSTableHeaderView { object: window, queue: .main ) { [weak self] _ in - MainActor.assumeIsolated { self?.applyEmphasis(window.isKeyWindow) } + MainActor.assumeIsolated { self?.refreshEmphasis() } } emphasisObservers.append(observer) } - applyEmphasis(window.isKeyWindow) + /// The header and the row bodies are two halves of one selection, so they have to agree on + /// what emphasis means. `NSTableRowView.isEmphasized` is key window *and* table focus, and + /// keying the header on the window alone left a sorted column accent blue over a grey body. + /// AppKit publishes no first-responder notification but does notify KVO by hand. + firstResponderObservation = window.observe(\.firstResponder, options: [.initial, .new]) { [weak self] _, _ in + MainActor.assumeIsolated { self?.refreshEmphasis() } + } + } + + private func refreshEmphasis() { + applyEmphasis( + SortableHeaderEmphasis.isEmphasized( + tableViewHoldsFocus: SortableHeaderEmphasis.holdsFocus(tableView: tableView, in: window), + isKeyWindow: window?.isKeyWindow ?? false + ) + ) } private func applyEmphasis(_ isEmphasized: Bool) { @@ -190,7 +207,7 @@ final class SortableHeaderView: NSTableHeaderView { } override func mouseMoved(with event: NSEvent) { - guard let tableView else { + guard tableView != nil else { super.mouseMoved(with: event) return } diff --git a/TablePro/Views/Settings/Appearance/ThemeListView.swift b/TablePro/Views/Settings/Appearance/ThemeListView.swift index 85fa296a7..29aeee8e0 100644 --- a/TablePro/Views/Settings/Appearance/ThemeListView.swift +++ b/TablePro/Views/Settings/Appearance/ThemeListView.swift @@ -13,15 +13,27 @@ internal struct ThemeListView: View { @State private var showError = false private var builtInThemes: [ThemeDefinition] { - ThemeSlotValidation.eligibleThemes(engine.availableThemes.filter(\.isBuiltIn), slot: slotAppearance) + ThemeSlotValidation.eligibleThemes( + engine.availableThemes.filter(\.isBuiltIn), + slot: slotAppearance, + keeping: selectedThemeId + ) } private var registryThemes: [ThemeDefinition] { - ThemeSlotValidation.eligibleThemes(engine.registryThemes, slot: slotAppearance) + ThemeSlotValidation.eligibleThemes( + engine.registryThemes, + slot: slotAppearance, + keeping: selectedThemeId + ) } private var customThemes: [ThemeDefinition] { - ThemeSlotValidation.eligibleThemes(engine.availableThemes.filter(\.isEditable), slot: slotAppearance) + ThemeSlotValidation.eligibleThemes( + engine.availableThemes.filter(\.isEditable), + slot: slotAppearance, + keeping: selectedThemeId + ) } private var selectedTheme: ThemeDefinition? { diff --git a/TablePro/Views/Settings/AppearanceSettingsView.swift b/TablePro/Views/Settings/AppearanceSettingsView.swift index 57bde36ef..a273c883e 100644 --- a/TablePro/Views/Settings/AppearanceSettingsView.swift +++ b/TablePro/Views/Settings/AppearanceSettingsView.swift @@ -27,25 +27,6 @@ struct AppearanceSettingsView: View { editSlot == .dark ? .dark : .light } - private var slotDefaultThemeId: String { - editSlot == .dark - ? AppearanceSettings.default.preferredDarkThemeId - : AppearanceSettings.default.preferredLightThemeId - } - - /// A slot that already holds a contradicting theme is re-anchored on read, so filtering the - /// list can never hide the row the user is standing on. - private func validateSlot() { - let resolved = ThemeSlotValidation.resolvedThemeId( - current: slotThemeBinding.wrappedValue, - slot: slotAppearance, - themes: ThemeEngine.shared.availableThemes, - defaultId: slotDefaultThemeId - ) - guard resolved != slotThemeBinding.wrappedValue else { return } - slotThemeBinding.wrappedValue = resolved - } - private var slotThemeBinding: Binding { Binding( get: { @@ -106,7 +87,6 @@ struct AppearanceSettingsView: View { .frame(minWidth: 400) } } - .task(id: editSlot) { validateSlot() } } } diff --git a/TablePro/Views/Shared/FieldDrivenList.swift b/TablePro/Views/Shared/FieldDrivenList.swift new file mode 100644 index 000000000..92f484d30 --- /dev/null +++ b/TablePro/Views/Shared/FieldDrivenList.swift @@ -0,0 +1,324 @@ +// +// FieldDrivenList.swift +// TablePro +// + +import AppKit +import SwiftUI + +internal struct FieldDrivenListSection: Identifiable { + internal let id: String + internal let title: String? + internal let items: [Item] + + internal init(id: String, title: String? = nil, items: [Item]) { + self.id = id + self.title = title + self.items = items + } +} + +internal struct FieldDrivenMenuItem { + internal let title: String + internal let isSeparator: Bool + internal let action: () -> Void + + internal init(title: String, action: @escaping () -> Void) { + self.title = title + self.isSeparator = false + self.action = action + } + + private init() { + self.title = "" + self.isSeparator = true + self.action = {} + } + + internal static var separator: FieldDrivenMenuItem { FieldDrivenMenuItem() } +} + +/// A list whose selection belongs to a search field rather than to the list itself. +/// +/// Spotlight, Xcode's Open Quickly and AppKit's own completion window all keep the text field +/// focused and still draw the highlighted row emphasized, because that highlight is the field's +/// navigation state and not a second focus. A SwiftUI `List` cannot express this: it derives +/// emphasis from its own first-responder status, so a list sitting behind a focused field is drawn +/// permanently inactive, and the accent state is reachable only by clicking, which is not the path +/// the design uses. `NSTableRowView.isEmphasized` is AppKit's supported way to declare it. +/// +/// The rows stay SwiftUI. AppKit publishes `NSTableCellView.backgroundStyle` into the hosted view's +/// environment as `backgroundProminence`, so `selectionAwareTint` and friends keep working with no +/// emphasis plumbing of their own. +internal struct FieldDrivenList: NSViewRepresentable where Item.ID: Hashable { + internal let sections: [FieldDrivenListSection] + @Binding internal var selection: Set + internal var allowsMultipleSelection: Bool = false + internal var rowHeight: CGFloat = 28 + internal var usesSourceListStyle: Bool = false + /// A chooser commits on the first click; a browser waits for a double-click. Both are set, + /// because which one a list uses is the list's decision, not this type's. + internal var onSingleClickAction: ((Item.ID) -> Void)? + internal var onPrimaryAction: (Item.ID) -> Void = { _ in } + internal var menuItems: ((Set) -> [FieldDrivenMenuItem])? + @ViewBuilder internal let row: (Item) -> Row + + internal func makeCoordinator() -> Coordinator { + Coordinator(owner: self) + } + + internal func makeNSView(context: Context) -> NSScrollView { + let tableView = FieldDrivenTableView() + tableView.headerView = nil + tableView.rowHeight = rowHeight + tableView.backgroundColor = .clear + tableView.allowsMultipleSelection = allowsMultipleSelection + tableView.allowsEmptySelection = true + tableView.intercellSpacing = NSSize(width: 0, height: 2) + tableView.style = usesSourceListStyle ? .sourceList : .inset + tableView.floatsGroupRows = false + tableView.target = context.coordinator + tableView.action = #selector(Coordinator.handleSingleClick) + tableView.doubleAction = #selector(Coordinator.handleDoubleClick) + + let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("FieldDrivenColumn")) + column.resizingMask = .autoresizingMask + tableView.addTableColumn(column) + + tableView.dataSource = context.coordinator + tableView.delegate = context.coordinator + context.coordinator.tableView = tableView + + let scrollView = NSScrollView() + scrollView.documentView = tableView + scrollView.hasVerticalScroller = true + scrollView.drawsBackground = false + scrollView.automaticallyAdjustsContentInsets = false + return scrollView + } + + internal func updateNSView(_ scrollView: NSScrollView, context: Context) { + context.coordinator.apply(owner: self) + } + + @MainActor + internal final class Coordinator: NSObject, NSTableViewDataSource, NSTableViewDelegate { + private var owner: FieldDrivenList + private var entries: [FieldDrivenListEntry] = [] + private var isApplyingSelection = false + + internal weak var tableView: FieldDrivenTableView? + + internal init(owner: FieldDrivenList) { + self.owner = owner + super.init() + entries = FieldDrivenListEntry.flatten(owner.sections) + } + + internal func apply(owner: FieldDrivenList) { + self.owner = owner + guard let tableView else { return } + let next = FieldDrivenListEntry.flatten(owner.sections) + let identityChanged = next.map(\.identity) != entries.map(\.identity) + entries = next + tableView.allowsMultipleSelection = owner.allowsMultipleSelection + tableView.rowHeight = owner.rowHeight + if identityChanged { + tableView.reloadData() + } else { + reloadItemContents(in: tableView) + } + syncSelection(in: tableView) + } + + /// A refilter that keeps the same rows must not reload them, because reloading throws away + /// the hosted SwiftUI views and their animation state. Only the row contents are refreshed. + private func reloadItemContents(in tableView: NSTableView) { + for (index, entry) in entries.enumerated() { + guard case .item(let item) = entry, + let cell = tableView.view(atColumn: 0, row: index, makeIfNecessary: false) + as? FieldDrivenCellView else { continue } + cell.update(rootView: owner.row(item)) + } + } + + private func syncSelection(in tableView: NSTableView) { + let target = IndexSet( + entries.enumerated().compactMap { index, entry in + entry.itemId.map { owner.selection.contains($0) ? index : nil } ?? nil + } + ) + guard tableView.selectedRowIndexes != target else { return } + isApplyingSelection = true + tableView.selectRowIndexes(target, byExtendingSelection: false) + isApplyingSelection = false + if let first = target.first { + tableView.scrollRowToVisible(first) + } + } + + // MARK: - Data source + + internal func numberOfRows(in tableView: NSTableView) -> Int { entries.count } + + internal func tableView(_ tableView: NSTableView, viewFor column: NSTableColumn?, row: Int) -> NSView? { + guard row < entries.count else { return nil } + switch entries[row] { + case .header(_, let title): + return FieldDrivenHeaderView.make(title: title) + case .item(let item): + let cell = tableView.makeView( + withIdentifier: FieldDrivenCellView.reuseIdentifier, + owner: self + ) as? FieldDrivenCellView ?? FieldDrivenCellView() + cell.update(rootView: owner.row(item)) + return cell + } + } + + internal func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { + FieldDrivenRowView() + } + + internal func tableView(_ tableView: NSTableView, isGroupRow row: Int) -> Bool { + guard row < entries.count else { return false } + return entries[row].isHeader + } + + internal func tableView(_ tableView: NSTableView, shouldSelectRow row: Int) -> Bool { + guard row < entries.count else { return false } + return !entries[row].isHeader + } + + internal func tableViewSelectionDidChange(_ notification: Notification) { + guard !isApplyingSelection, let tableView else { return } + let ids = tableView.selectedRowIndexes.compactMap { entries[$0].itemId } + let next = Set(ids) + guard next != owner.selection else { return } + owner.selection = next + } + + // MARK: - Actions + + @objc internal func handleSingleClick() { + guard let action = owner.onSingleClickAction, let id = clickedItemId() else { return } + action(id) + } + + @objc internal func handleDoubleClick() { + guard let id = clickedItemId() else { return } + owner.onPrimaryAction(id) + } + + private func clickedItemId() -> Item.ID? { + guard let tableView, tableView.clickedRow >= 0, tableView.clickedRow < entries.count else { return nil } + return entries[tableView.clickedRow].itemId + } + + @objc private func performMenuItem(_ sender: NSMenuItem) { + (sender.representedObject as? MenuAction)?.perform() + } + + internal func menu(forRow row: Int) -> NSMenu? { + guard let build = owner.menuItems, row >= 0, row < entries.count, + let id = entries[row].itemId else { return nil } + let targets = owner.selection.contains(id) ? owner.selection : [id] + let descriptors = build(targets) + guard !descriptors.isEmpty else { return nil } + let menu = NSMenu() + for descriptor in descriptors { + if descriptor.isSeparator { + menu.addItem(.separator()) + continue + } + let item = NSMenuItem(title: descriptor.title, action: #selector(performMenuItem(_:)), keyEquivalent: "") + item.target = self + item.representedObject = MenuAction(descriptor.action) + menu.addItem(item) + } + return menu + } + } + + private final class MenuAction { + private let body: () -> Void + init(_ body: @escaping () -> Void) { self.body = body } + func perform() { body() } + } +} + +/// The row draws emphasized whenever the window carrying it is key, because the highlight stands +/// for the search field's selection and that field is the thing holding focus. `window` is read +/// rather than `NSApp.keyWindow`, which does not return a popover's own window. +internal final class FieldDrivenRowView: NSTableRowView { + override internal var isEmphasized: Bool { + get { window?.isKeyWindow ?? false } + set { _ = newValue } + } +} + +/// The table is a presentation of the field's selection, so it never takes focus away from the +/// field. Refusing first responder is what makes that true rather than incidental. +internal final class FieldDrivenTableView: NSTableView { + override internal var acceptsFirstResponder: Bool { false } + + override internal func menu(for event: NSEvent) -> NSMenu? { + let point = convert(event.locationInWindow, from: nil) + let row = row(at: point) + guard row >= 0 else { return nil } + if !selectedRowIndexes.contains(row) { + selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) + } + return (delegate as? (any FieldDrivenMenuProviding))?.menu(forRow: row) + } +} + +internal protocol FieldDrivenMenuProviding: AnyObject { + @MainActor func menu(forRow row: Int) -> NSMenu? +} + +extension FieldDrivenList.Coordinator: FieldDrivenMenuProviding {} + +internal final class FieldDrivenCellView: NSTableCellView { + internal static var reuseIdentifier: NSUserInterfaceItemIdentifier { + NSUserInterfaceItemIdentifier("FieldDrivenCell") + } + + private var hosting: NSHostingView? + + internal func update(rootView: Row) { + identifier = Self.reuseIdentifier + if let hosting { + hosting.rootView = rootView + return + } + let view = NSHostingView(rootView: rootView) + view.translatesAutoresizingMaskIntoConstraints = false + addSubview(view) + NSLayoutConstraint.activate([ + view.leadingAnchor.constraint(equalTo: leadingAnchor), + view.trailingAnchor.constraint(equalTo: trailingAnchor), + view.topAnchor.constraint(equalTo: topAnchor), + view.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + hosting = view + } +} + +internal enum FieldDrivenHeaderView { + internal static func make(title: String) -> NSView { + let label = NSTextField(labelWithString: title) + label.font = .preferredFont(forTextStyle: .caption1) + label.textColor = .secondaryLabelColor + label.translatesAutoresizingMaskIntoConstraints = false + + let container = NSView() + container.addSubview(label) + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 4), + label.trailingAnchor.constraint(lessThanOrEqualTo: container.trailingAnchor), + label.centerYAnchor.constraint(equalTo: container.centerYAnchor), + ]) + return container + } +} diff --git a/TablePro/Views/Shared/FieldDrivenListEntry.swift b/TablePro/Views/Shared/FieldDrivenListEntry.swift new file mode 100644 index 000000000..fd65c1067 --- /dev/null +++ b/TablePro/Views/Shared/FieldDrivenListEntry.swift @@ -0,0 +1,43 @@ +// +// FieldDrivenListEntry.swift +// TablePro +// + +import Foundation + +/// One row of a `FieldDrivenList`, after sections have been flattened into the single index space +/// an `NSTableView` works in. +internal enum FieldDrivenListEntry where Item.ID: Hashable { + case header(id: String, title: String) + case item(Item) + + internal var isHeader: Bool { + guard case .header = self else { return false } + return true + } + + internal var itemId: Item.ID? { + guard case .item(let item) = self else { return nil } + return item.id + } + + /// Identity, not content. A refilter that produces the same rows in the same order reloads + /// nothing, which keeps the hosted SwiftUI views and their state alive. + internal var identity: AnyHashable { + switch self { + case .header(let id, _): return AnyHashable("header:" + id) + case .item(let item): return AnyHashable(item.id) + } + } + + /// A section contributes a header only when it is named and has something under it, so an + /// empty section leaves no stray title behind. + internal static func flatten(_ sections: [FieldDrivenListSection]) -> [FieldDrivenListEntry] { + sections.flatMap { section -> [FieldDrivenListEntry] in + guard !section.items.isEmpty else { return [] } + let rows = section.items.map { FieldDrivenListEntry.item($0) } + guard let title = section.title else { return rows } + return [.header(id: section.id, title: title)] + rows + } + } +} diff --git a/TablePro/Views/Sidebar/DatabaseTreeFilter.swift b/TablePro/Views/Sidebar/DatabaseTreeFilter.swift index c4a159961..371aad476 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeFilter.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeFilter.swift @@ -21,6 +21,26 @@ enum DatabaseTreeFilter { return deduplicated(matched, by: \.id) } + /// A schema whose tables have not loaded yet cannot be judged, so it stays visible. Reading an + /// unloaded schema as an empty one hides it for the whole life of the filter and blanks the + /// pane while the search-driven load is still running. + static func hierarchicalSchemaIsVisible( + _ schema: String, + searchText: String, + isLoaded: Bool, + tables: [TableInfo] + ) -> Bool { + if matches(searchText, schema) { return true } + guard isLoaded else { return true } + return !filteredTables(tables, searchText: searchText).isEmpty + } + + /// A schema the search matched by name shows everything inside it. Filtering its tables by the + /// same query leaves the matched schema reporting no items. + static func hierarchicalTables(_ tables: [TableInfo], schema: String, searchText: String) -> [TableInfo] { + matches(searchText, schema) ? tables : filteredTables(tables, searchText: searchText) + } + static func visibleSchemas( _ schemas: [String], systemSchemas: Set, diff --git a/TablePro/Views/Sidebar/DatabaseTreeNode.swift b/TablePro/Views/Sidebar/DatabaseTreeNode.swift index 7094088be..e51b60c1a 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeNode.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeNode.swift @@ -11,6 +11,8 @@ final class DatabaseTreeNode { case loading case empty case error(String) + /// Results are real but incomplete, which `empty` cannot say. + case truncated(String) } enum Kind { @@ -21,6 +23,14 @@ final class DatabaseTreeNode { case table(DatabaseTreeTableRef) case routine(DatabaseTreeRoutineRef) case status(Status) + + /// Flat shape: one collapsible section per object kind. + case objectKindSection(SidebarObjectKind) + /// Hierarchical shape: a schema with no database above it. + case hierarchicalSchemaSection(schema: String) + /// Flat shape, Redis only. + case redisKeysSection + case redisNode(RedisKeyNode) } let id: String @@ -33,9 +43,16 @@ final class DatabaseTreeNode { var isExpandable: Bool { switch kind { - case .recentSection, .database, .schema: return true - case .table(let ref): return ref.table.type == .partitionedTable - case .recentTable, .routine, .status: return false + case .recentSection, .database, .schema, + .objectKindSection, .hierarchicalSchemaSection, .redisKeysSection: + return true + case .table(let ref): + return ref.table.type == .partitionedTable + case .redisNode(let node): + guard case .namespace = node else { return false } + return true + case .recentTable, .routine, .status: + return false } } @@ -49,10 +66,29 @@ final class DatabaseTreeNode { return nil } + /// A source-list group row: chrome the app invented to bucket objects, not an object the + /// database has. AppKit draws these itself once `isGroupItem` says so, and it stops indenting + /// their children, which is what puts a table at the same depth as a database in the tree. + /// + /// A schema is deliberately not one. It is a real object with its own menu and its own + /// children, so it stays an ordinary container row the way a folder does in Xcode's navigator. + var isSectionHeader: Bool { + switch kind { + case .recentSection, .objectKindSection, .redisKeysSection: + return true + case .database, .schema, .hierarchicalSchemaSection, .recentTable, .table, + .routine, .status, .redisNode: + return false + } + } + var isContainer: Bool { switch kind { - case .database, .schema: return true - case .recentSection, .recentTable, .table, .routine, .status: return false + case .database, .schema: + return true + case .recentSection, .recentTable, .table, .routine, .status, + .objectKindSection, .hierarchicalSchemaSection, .redisKeysSection, .redisNode: + return false } } @@ -62,7 +98,8 @@ final class DatabaseTreeNode { return .database(metadata.name, isSystem: metadata.isSystemDatabase) case .schema(let database, let schema): return .schema(database: database, schema: schema, isSystem: systemSchemas.contains(schema)) - case .recentSection, .recentTable, .table, .routine, .status: + case .recentSection, .recentTable, .table, .routine, .status, + .objectKindSection, .hierarchicalSchemaSection, .redisKeysSection, .redisNode: return nil } } @@ -78,6 +115,12 @@ final class DatabaseTreeNode { case .loading: return "\(parentId)\u{1}status.loading" case .empty: return "\(parentId)\u{1}status.empty" case .error: return "\(parentId)\u{1}status.error" + case .truncated: return "\(parentId)\u{1}status.truncated" } } + + static func objectKindSectionId(_ kind: SidebarObjectKind) -> String { "kindSection\u{1}\(kind.rawValue)" } + static func hierarchicalSchemaSectionId(_ schema: String) -> String { "hschema\u{1}\(schema)" } + static let redisKeysSectionId = "redis-keys-section" + static func redisNodeId(_ node: RedisKeyNode) -> String { "redisnode\u{1}\(node.id)" } } diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift index 4b2ec1990..c49906122 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift @@ -25,12 +25,14 @@ final class DatabaseTreeOutlineCoordinator: NSObject { private var activeSchema: String? private var pendingTruncates: Set = [] private var pendingDeletes: Set = [] + private var showRecentTables = true private var nodeCache: [String: DatabaseTreeNode] = [:] private var childrenCache: [String: [DatabaseTreeNode]] = [:] private var lastSelection: Set = [] private var lastSelectedNodeIds: [String] = [] - private var pendingSingleClickWork: DispatchWorkItem? + private var publishedTables: Set = [] + private var pendingOpenWork: DispatchWorkItem? private var isApplyingExpansion = false private var isSyncingSelection = false private var isReloading = false @@ -38,6 +40,10 @@ final class DatabaseTreeOutlineCoordinator: NSObject { private var reconcileScheduled = false private var observationGeneration = 0 + private let schemaService = SchemaService.shared + private var favoriteTables: Set = [] + private var favoritesObserver: (any NSObjectProtocol)? + private var supportsSchemaLevel: Bool { PluginManager.shared.databaseGroupingStrategy(for: databaseType) == .bySchema } @@ -50,10 +56,27 @@ final class DatabaseTreeOutlineCoordinator: NSObject { func attach(outlineView: NSOutlineView) { self.outlineView = outlineView + favoritesObserver = NotificationCenter.default.addObserver( + forName: .favoriteTablesDidChange, object: nil, queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + guard let self else { return } + self.reloadFavorites() + self.refreshVisibleRows() + } + } + } + + deinit { + if let favoritesObserver { + NotificationCenter.default.removeObserver(favoritesObserver) + } } func update(from view: DatabaseTreeOutlineView) { + let connectionChanged = connectionId != view.connectionId connectionId = view.connectionId + if connectionChanged { reloadFavorites() } databaseType = view.databaseType mainCoordinator = view.coordinator windowState = view.windowState @@ -66,6 +89,7 @@ final class DatabaseTreeOutlineCoordinator: NSObject { || activeChanged || pendingTruncates != view.pendingTruncates || pendingDeletes != view.pendingDeletes + || showRecentTables != view.showRecentTables searchText = view.searchText connectionToken = view.connectionToken @@ -73,6 +97,7 @@ final class DatabaseTreeOutlineCoordinator: NSObject { activeSchema = view.activeSchema pendingTruncates = view.pendingTruncates pendingDeletes = view.pendingDeletes + showRecentTables = view.showRecentTables if !hasRenderedOnce || activeChanged { persistActiveExpansion() @@ -83,6 +108,8 @@ final class DatabaseTreeOutlineCoordinator: NSObject { refresh() } else if changed { refresh() + } else { + syncSelectionToModel() } } @@ -126,6 +153,14 @@ final class DatabaseTreeOutlineCoordinator: NSObject { private func snapshotDependencies() { _ = service.databaseListState(for: connectionId) _ = sidebarState?.recentTables + /// One token covers every table, routine and per-schema load for this connection, which is + /// the whole reactive surface the flat and hierarchical shapes read. + _ = schemaService.generationToken(for: connectionId) + if let keyTree = sidebarState?.redisKeyTreeViewModel { + _ = keyTree.isLoading + _ = keyTree.isTruncated + _ = keyTree.allKeys.count + } for node in nodeCache.values { switch node.kind { case .database(let metadata): @@ -135,11 +170,14 @@ final class DatabaseTreeOutlineCoordinator: NSObject { case .schema(let database, let schema): _ = service.tablesLoadState(connectionId: connectionId, database: database, schema: schema) _ = service.routinesLoadState(connectionId: connectionId, database: database, schema: schema) + case .hierarchicalSchemaSection(let schema): + _ = schemaService.schemaState(for: connectionId, schema: schema) case .table(let ref) where ref.table.type == .partitionedTable: _ = service.partitionsLoadState( connectionId: connectionId, database: ref.database, schema: ref.schema, table: ref.table.name ) - case .recentSection, .recentTable, .table, .routine, .status: + case .recentSection, .recentTable, .table, .routine, .status, + .objectKindSection, .redisKeysSection, .redisNode: break } } @@ -156,6 +194,40 @@ final class DatabaseTreeOutlineCoordinator: NSObject { beginObserving() } + /// A star toggling on or off changes no row and no ordering, so the rows are reconfigured in + /// place. Reloading would throw away every hosted SwiftUI view to repaint one glyph. + private func refreshVisibleRows() { + guard let outlineView else { return } + let context = rowContext() + let actions = rowActions() + for row in 0.. FavoriteTablesStorage.FavoriteEntry { + FavoriteTablesStorage.FavoriteEntry( + connectionId: connectionId, + database: ref.database.isEmpty ? nil : ref.database, + schema: ref.table.schema, + name: ref.table.name + ) + } + + private func toggleFavorite(_ ref: DatabaseTreeTableRef) { + let entry = favoriteEntry(for: ref) + FavoriteTablesStorage.shared.toggle( + name: entry.name, schema: entry.schema, database: entry.database, connectionId: connectionId + ) + } + // MARK: - Node building private func node(id: String, kind: DatabaseTreeNode.Kind) -> DatabaseTreeNode { @@ -191,6 +263,14 @@ final class DatabaseTreeOutlineCoordinator: NSObject { return objectNodes(database: database, schema: schema) case .table(let ref): return ref.table.type == .partitionedTable ? partitionNodes(of: ref) : [] + case .objectKindSection(let kind): + return flatObjectNodes(for: kind) + case .hierarchicalSchemaSection(let schema): + return hierarchicalTableNodes(schema: schema) + case .redisKeysSection: + return redisChildren(of: nil) + case .redisNode(let redisNode): + return redisChildren(of: redisNode) case .recentTable, .routine, .status: return [] } @@ -215,7 +295,25 @@ final class DatabaseTreeOutlineCoordinator: NSObject { } } + /// Which shape the root takes. The three sidebar modes used to be three views; they are one + /// outline now and this is the only thing that still differs between them. + private var rootShape: SidebarRootShape { + SidebarRootShapeResolver.resolve( + groupingStrategy: PluginManager.shared.databaseGroupingStrategy(for: databaseType), + sidebarLayout: sidebarState?.sidebarLayout ?? .flat, + supportsDatabaseTree: PluginManager.shared.supportsDatabaseTree(for: databaseType) + ) + } + private func rootNodes() -> [DatabaseTreeNode] { + switch rootShape { + case .databaseTree: return databaseTreeRootNodes() + case .flat: return flatRootNodes() + case .hierarchicalSchema: return hierarchicalRootNodes() + } + } + + private func databaseTreeRootNodes() -> [DatabaseTreeNode] { var nodes: [DatabaseTreeNode] = [] if !recentTableRefs().isEmpty { nodes.append(node(id: DatabaseTreeNode.recentSectionId, kind: .recentSection)) @@ -233,8 +331,140 @@ final class DatabaseTreeOutlineCoordinator: NSObject { return nodes } + private var browsingDatabase: String? { + let name = mainCoordinator?.browseDatabaseName ?? activeDatabase ?? "" + return name.isEmpty ? nil : name + } + + private func flatRootNodes() -> [DatabaseTreeNode] { + var nodes: [DatabaseTreeNode] = [] + if !recentTableRefs().isEmpty { + nodes.append(node(id: DatabaseTreeNode.recentSectionId, kind: .recentSection)) + } + nodes += visibleObjectKinds().map { + node(id: DatabaseTreeNode.objectKindSectionId($0), kind: .objectKindSection($0)) + } + if sidebarState?.redisKeyTreeViewModel != nil { + nodes.append(node(id: DatabaseTreeNode.redisKeysSectionId, kind: .redisKeysSection)) + } + return nodes + } + + /// The section list is the same rule the flat list used, so a kind that was hidden before stays + /// hidden: Tables always shows, anything else needs both the capability and something in it. + private func visibleObjectKinds() -> [SidebarObjectKind] { + guard let viewModel else { return [] } + let capabilities = viewModel.capabilities(for: connectionId) + return SidebarObjectKind.allCases.filter { kind in + viewModel.sectionShouldRender( + kind: kind, + itemCount: flatItemCount(for: kind), + capabilities: capabilities + ) + } + } + + private func flatItemCount(for kind: SidebarObjectKind) -> Int { + guard let viewModel else { return 0 } + if kind.isRoutine { + return viewModel.filteredRoutines(of: kind, from: schemaService.routines(for: connectionId)).count + } + return viewModel.filteredTables(of: kind, from: schemaService.tables(for: connectionId)).count + } + + private func flatObjectNodes(for kind: SidebarObjectKind) -> [DatabaseTreeNode] { + guard let viewModel else { return [] } + let database = browsingDatabase ?? "" + if kind.isRoutine { + return viewModel.filteredRoutines(of: kind, from: schemaService.routines(for: connectionId)) + .map { routine in + let ref = DatabaseTreeRoutineRef(database: database, schema: routine.schema, routine: routine) + return node(id: DatabaseTreeNode.routineId(ref), kind: .routine(ref)) + } + } + return viewModel.filteredTables(of: kind, from: schemaService.tables(for: connectionId)) + .map { table in + let ref = DatabaseTreeTableRef(database: database, schema: table.schema, table: table) + return node(id: DatabaseTreeNode.tableId(ref), kind: .table(ref)) + } + } + + private func hierarchicalRootNodes() -> [DatabaseTreeNode] { + var nodes: [DatabaseTreeNode] = [] + if !recentTableRefs().isEmpty { + nodes.append(node(id: DatabaseTreeNode.recentSectionId, kind: .recentSection)) + } + let hidden = systemSchemas + nodes += schemaService.schemas(for: connectionId) + .filter { !hidden.contains($0) } + .filter { searchText.isEmpty || hierarchicalSchemaMatches($0) } + .map { + node(id: DatabaseTreeNode.hierarchicalSchemaSectionId($0), kind: .hierarchicalSchemaSection(schema: $0)) + } + return nodes + } + + private func hierarchicalSchemaMatches(_ schema: String) -> Bool { + DatabaseTreeFilter.hierarchicalSchemaIsVisible( + schema, + searchText: searchText, + isLoaded: isSchemaLoaded(schema), + tables: schemaService.tables(for: connectionId, schema: schema) + ) + } + + private func isSchemaLoaded(_ schema: String) -> Bool { + if case .loaded = schemaService.schemaState(for: connectionId, schema: schema) { return true } + return false + } + + private func hierarchicalTableNodes(schema: String) -> [DatabaseTreeNode] { + let parentId = DatabaseTreeNode.hierarchicalSchemaSectionId(schema) + switch schemaService.schemaState(for: connectionId, schema: schema) { + case .idle, .loading: + return [statusNode(parentId: parentId, status: .loading)] + case .failed(let message): + return [statusNode(parentId: parentId, status: .error(message))] + case .loaded: + let tables = DatabaseTreeFilter.hierarchicalTables( + schemaService.tables(for: connectionId, schema: schema), schema: schema, searchText: searchText + ) + guard !tables.isEmpty else { return [statusNode(parentId: parentId, status: .empty)] } + let database = browsingDatabase ?? "" + return tables.map { table in + let ref = DatabaseTreeTableRef(database: database, schema: schema, table: table) + return node(id: DatabaseTreeNode.tableId(ref), kind: .table(ref)) + } + } + } + + private func redisChildren(of parent: RedisKeyNode?) -> [DatabaseTreeNode] { + guard let keyTree = sidebarState?.redisKeyTreeViewModel else { return [] } + if let parent { + guard case .namespace(_, _, let children, _) = parent else { return [] } + return children.map { node(id: DatabaseTreeNode.redisNodeId($0), kind: .redisNode($0)) } + } + if keyTree.isLoading { + return [statusNode(parentId: DatabaseTreeNode.redisKeysSectionId, status: .loading)] + } + let roots = keyTree.displayNodes(searchText: searchText) + guard !roots.isEmpty else { + return [statusNode(parentId: DatabaseTreeNode.redisKeysSectionId, status: .empty)] + } + var nodes = roots.map { node(id: DatabaseTreeNode.redisNodeId($0), kind: .redisNode($0)) } + if keyTree.isTruncated { + nodes.append( + statusNode( + parentId: DatabaseTreeNode.redisKeysSectionId, + status: .truncated(RedisKeyTreeTruncation.message(limit: RedisKeyTreeViewModel.maxKeys)) + ) + ) + } + return nodes + } + private func recentTableRefs() -> [DatabaseTreeTableRef] { - guard let sidebarState, AppSettingsManager.shared.general.showRecentTables else { return [] } + guard let sidebarState, showRecentTables else { return [] } let database = mainCoordinator?.browseDatabaseName ?? activeDatabase ?? "" return sidebarState.recentEntries(inDatabase: database).compactMap { entry -> DatabaseTreeTableRef? in if !searchText.isEmpty, !DatabaseTreeFilter.matches(searchText, entry.name) { return nil } @@ -342,6 +572,23 @@ final class DatabaseTreeOutlineCoordinator: NSObject { for rootNode in resolvedChildren(of: nil) where rootNode.id == DatabaseTreeNode.recentSectionId { setExpanded(rootNode, searching || (viewModel?.isRecentsExpanded ?? true)) } + for sectionNode in resolvedChildren(of: nil) { + switch sectionNode.kind { + case .objectKindSection(let kind): + let hasMatches = flatItemCount(for: kind) > 0 + setExpanded(sectionNode, viewModel?.effectiveExpanded(kind: kind, hasMatches: hasMatches) ?? true) + case .redisKeysSection: + setExpanded(sectionNode, searching || (viewModel?.isRedisKeysExpanded ?? true)) + case .hierarchicalSchemaSection(let schema): + let want = searching + ? hierarchicalSchemaMatches(schema) + : windowState?.expandedTreeSchemas.contains(schema) ?? false + setExpanded(sectionNode, want) + if outlineView.isItemExpanded(sectionNode) { triggerLoad(for: sectionNode) } + default: + break + } + } for databaseNode in resolvedChildren(of: nil) { guard case .database(let metadata) = databaseNode.kind else { continue } let want = searching @@ -394,6 +641,16 @@ final class DatabaseTreeOutlineCoordinator: NSObject { switch node.kind { case .recentSection: viewModel?.isRecentsExpanded = expanded + case .objectKindSection(let kind): + viewModel?.expanded[kind] = expanded + case .redisKeysSection: + viewModel?.isRedisKeysExpanded = expanded + case .hierarchicalSchemaSection(let schema): + if expanded { + windowState?.expandedTreeSchemas.insert(schema) + } else { + windowState?.expandedTreeSchemas.remove(schema) + } case .database(let metadata): if expanded { windowState?.expandedTreeDatabases.insert(metadata.name) @@ -414,7 +671,7 @@ final class DatabaseTreeOutlineCoordinator: NSObject { } else { windowState?.expandedTreeTables.remove(key) } - case .recentTable, .routine, .status: + case .recentTable, .routine, .status, .redisNode: break } } @@ -434,11 +691,21 @@ final class DatabaseTreeOutlineCoordinator: NSObject { loadObjects(database: database, schema: schema) case .table(let ref): loadPartitions(ref) - case .recentSection, .recentTable, .routine, .status: + case .hierarchicalSchemaSection(let schema): + loadHierarchicalSchemaTables(schema) + case .recentSection, .recentTable, .routine, .status, + .objectKindSection, .redisKeysSection, .redisNode: break } } + private func loadHierarchicalSchemaTables(_ schema: String) { + guard case .idle = schemaService.schemaState(for: connectionId, schema: schema), + let driver = DatabaseManager.shared.driver(for: connectionId) else { return } + let connectionId = connectionId + Task { await schemaService.loadSchemaTables(connectionId: connectionId, schema: schema, driver: driver) } + } + private func loadExternalSchemaNames(database: String) { guard let session = DatabaseManager.shared.session(for: connectionId), DatabaseManager.shared.browseDatabaseName(for: session.connection) == database, @@ -484,7 +751,7 @@ final class DatabaseTreeOutlineCoordinator: NSObject { // MARK: - Selection / open private func selectedRefs() -> [DatabaseTreeTableRef] { - selectedNodes().compactMap(\.tableRef) + DatabaseTreeSelection.tableRefs(of: selectedNodes()) } private func selectedContainerRefs() -> [DatabaseContainerRef] { @@ -501,28 +768,65 @@ final class DatabaseTreeOutlineCoordinator: NSObject { private func syncSelectionToModel() { guard let outlineView else { return } + adoptModelSelection() let rows = lastSelectedNodeIds.compactMap { nodeId -> Int? in guard let node = nodeCache[nodeId] else { return nil } let row = outlineView.row(forItem: node) return row >= 0 ? row : nil } + guard outlineView.selectedRowIndexes != IndexSet(rows) else { return } isSyncingSelection = true outlineView.selectRowIndexes(IndexSet(rows), byExtendingSelection: false) isSyncingSelection = false } - private func open(_ ref: DatabaseTreeTableRef, activateGridFocus: Bool, forceNewWindowTab: Bool = false) { + /// The window writes `selectedTables` whenever the active editor tab moves, so the highlight has + /// to follow it the way the `List(selection:)` binding this outline replaced did. Reading it back + /// is also what keeps a click on the row that is still highlighted from being swallowed: AppKit + /// posts no selection change when the selection already holds that row, so a highlight left + /// behind on a table the user has since navigated away from becomes a dead row. + /// + /// A table can be drawn twice, once under Recent and once in its own section. Only the section + /// row is adopted, because that is the row the model's `TableInfo` stands for. + private func adoptModelSelection() { + guard let windowState, windowState.selectedTables != publishedTables else { return } + publishedTables = windowState.selectedTables + let nodes = nodeCache.values.filter { node in + guard case .table(let ref) = node.kind else { return false } + return publishedTables.contains(ref.table) + } + lastSelectedNodeIds = nodes.map(\.id) + lastSelection = Set(DatabaseTreeSelection.tableRefs(of: Array(nodes))) + } + + private func open(_ ref: DatabaseTreeTableRef, activateGridFocus: Bool, forceNewTab: Bool = false) { Task { @MainActor in await activate(ref) mainCoordinator?.openTableTab( ref.table, schema: ref.schema, activateGridFocus: activateGridFocus, - forceNewWindowTab: forceNewWindowTab + forceNewTab: forceNewTab ) + publishSelection() } } + /// The Table menu reads `windowState.selectedTables`, so the tree has to put its own selection + /// there or every command that acts on a selection does nothing in tree layout. + /// + /// Timing is the whole trick. The shared navigation observer also watches this property, and it + /// opens whatever single table appeared. Publishing before the tree's own open landed would race + /// it into opening the table twice, so a selection that navigates publishes only once the tab is + /// already the clicked table, which is exactly the case that observer resolves to skip. + private func publishSelection() { + guard let windowState else { return } + let tables = DatabaseTreeSelection.tableInfos(of: selectedNodes()) + publishedTables = tables + guard windowState.selectedTables != tables else { return } + windowState.selectedTables = tables + } + private func activate(_ ref: DatabaseTreeTableRef) async { if ref.database != activeDatabase { await mainCoordinator?.switchDatabase(to: ref.database) @@ -591,6 +895,15 @@ final class DatabaseTreeOutlineCoordinator: NSObject { database: database, schema: schema ) + }, + objectKindTitle: { [databaseType] kind in + kind == .table + ? PluginManager.shared.tableEntityName(for: databaseType) + : kind.pluralDisplayName + }, + isFavorite: { [weak self] ref in + guard let self else { return false } + return self.favoriteTables.contains(self.favoriteEntry(for: ref)) } ) } @@ -615,29 +928,48 @@ final class DatabaseTreeOutlineCoordinator: NSObject { }, clearRecents: { [weak self] in self?.sidebarState?.clearRecentTables(inDatabase: self?.mainCoordinator?.browseDatabaseName) - } + }, + showAllTablesMetadata: { [weak self] in self?.mainCoordinator?.showAllTablesMetadata() }, + refreshObjectKind: { [weak self] in self?.refreshObjectKind($0) }, + refreshHierarchicalSchema: { [weak self] in self?.reloadHierarchicalSchemaTables($0) }, + openRedisKey: { [weak self] key, keyType in self?.mainCoordinator?.openRedisKey(key, keyType: keyType) }, + toggleFavorite: { [weak self] ref in self?.toggleFavorite(ref) } ) } - @objc - func handleSingleClick() { - guard let outlineView, outlineView.clickedRow >= 0, - let node = outlineView.item(atRow: outlineView.clickedRow) as? DatabaseTreeNode, - let ref = node.recentTableRef else { return } - scheduleSingleClickOpen(ref) + /// A namespace rescopes the browse pattern and a key opens; neither goes through the + /// double-click window a table open needs, because there is no preview tab to promote. + private func openRedis(_ node: RedisKeyNode) { + switch node { + case .namespace(_, let fullPrefix, _, _): + mainCoordinator?.browseRedisNamespace(fullPrefix) + case .key(_, let fullKey, let keyType): + mainCoordinator?.openRedisKey(fullKey, keyType: keyType) + } + } + + private func refreshObjectKind(_ kind: SidebarObjectKind) { + guard let mainCoordinator else { return } + switch kind { + case .procedure: Task { await mainCoordinator.refreshProcedures() } + case .function: Task { await mainCoordinator.refreshFunctions() } + case .table, .view, .materializedView, .foreignTable: Task { await mainCoordinator.refreshTables() } + } + } + + private func reloadHierarchicalSchemaTables(_ schema: String) { + guard let driver = DatabaseManager.shared.driver(for: connectionId) else { return } + let connectionId = connectionId + Task { await schemaService.reloadSchemaTables(connectionId: connectionId, schema: schema, driver: driver) } } + /// Selection already opened whatever was clicked, so the second click is only ever a + /// disclosure gesture. @objc func handleDoubleClick() { guard let outlineView, outlineView.clickedRow >= 0, - let node = outlineView.item(atRow: outlineView.clickedRow) as? DatabaseTreeNode else { return } - if let ref = node.tableRef ?? node.recentTableRef { - pendingSingleClickWork?.cancel() - pendingSingleClickWork = nil - open(ref, activateGridFocus: true, forceNewWindowTab: true) - return - } - guard node.isExpandable else { return } + let node = outlineView.item(atRow: outlineView.clickedRow) as? DatabaseTreeNode, + node.isExpandable else { return } if outlineView.isItemExpanded(node) { outlineView.collapseItem(node) } else { @@ -646,6 +978,15 @@ final class DatabaseTreeOutlineCoordinator: NSObject { } } +extension DatabaseTreeOutlineCoordinator: DatabaseTreeSelectionClearing { + /// Deselecting runs the normal delegate path, which publishes the now empty selection, so the + /// Table menu and the outline agree without a second write. + func clearSelection() { + guard let outlineView, !outlineView.selectedRowIndexes.isEmpty else { return } + outlineView.deselectAll(nil) + } +} + extension DatabaseTreeOutlineCoordinator: NSOutlineViewDataSource { func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int { resolvedChildren(of: item).count @@ -671,7 +1012,16 @@ extension DatabaseTreeOutlineCoordinator: NSOutlineViewDelegate { func outlineView(_ outlineView: NSOutlineView, shouldSelectItem item: Any) -> Bool { guard let node = item as? DatabaseTreeNode else { return false } - return node.tableRef != nil || node.isContainer + return DatabaseTreeSelection.isSelectable(node.kind) + } + + /// Hands the section headers to AppKit. In `.sourceList` a group row is drawn at its own + /// height with its own background and collapse control, and its children are laid out at the + /// depth the group itself sits at rather than one level in. That last part is what makes a + /// table under "Tables" line up with a database, the way a package lines up with the project + /// in Xcode's navigator. + func outlineView(_ outlineView: NSOutlineView, isGroupItem item: Any) -> Bool { + (item as? DatabaseTreeNode)?.isSectionHeader ?? false } func outlineView( @@ -694,36 +1044,60 @@ extension DatabaseTreeOutlineCoordinator: NSOutlineViewDelegate { if !isApplyingExpansion { recordExpansion(node, expanded: false) } } + /// Selection drives the content, which is how a source list works: the navigator picks, the + /// detail follows. Both the mouse and the keyboard land here, so there is one entry point. + /// + /// This deliberately does not use `NSTableView.action`. AppKit sends the action on mouse up, + /// which is a whole gesture later than the selection the user already sees. + /// + /// Nothing is published when a single table was added, because the open publishes it once the + /// tab is already that table. Publishing here would hand the same table to the window's + /// navigation observer first and open it twice. func outlineViewSelectionDidChange(_ notification: Notification) { guard !isSyncingSelection, !isReloading else { return } let nodes = selectedNodes() lastSelectedNodeIds = nodes.map(\.id) - let refs = Set(nodes.compactMap(\.tableRef)) + let refs = Set(DatabaseTreeSelection.tableRefs(of: nodes)) if let added = SelectionDelta.singleAddition(old: lastSelection, new: refs) { + /// A held arrow key is one gesture, so the keyboard waits it out. A click is already + /// the whole gesture and opens now. if isKeyboardDrivenSelection { - pendingSingleClickWork?.cancel() - pendingSingleClickWork = nil - open(added, activateGridFocus: false) + scheduleOpen(added, after: NSEvent.keyRepeatInterval) } else { - scheduleSingleClickOpen(added) + pendingOpenWork?.cancel() + pendingOpenWork = nil + open(added, activateGridFocus: false) } + } else if let redisNode = singleSelectedRedisNode(in: nodes) { + openRedis(redisNode) + } else { + publishSelection() } lastSelection = refs } + private func singleSelectedRedisNode(in nodes: [DatabaseTreeNode]) -> RedisKeyNode? { + guard nodes.count == 1, case .redisNode(let redisNode) = nodes[0].kind else { return nil } + return redisNode + } + private var isKeyboardDrivenSelection: Bool { guard let outlineView, outlineView.window?.firstResponder === outlineView else { return false } guard let event = NSApp.currentEvent else { return false } - return DatabaseTreeTypeSelect.isArrowNavigation(type: event.type, keyCode: event.keyCode) + return DatabaseTreeTypeSelect.isArrowNavigation(event) } - private func scheduleSingleClickOpen(_ ref: DatabaseTreeTableRef) { - pendingSingleClickWork?.cancel() + /// A held arrow key is one gesture, not one open per row it travels over, so a keyboard open + /// waits out `NSEvent.keyRepeatInterval` and each new selection cancels the pending one. The + /// burst collapses to the row the user stopped on. Without it, arrowing down a schema ran a + /// query and opened a tab for every row in between. + private func scheduleOpen(_ ref: DatabaseTreeTableRef, after delay: TimeInterval) { + pendingOpenWork?.cancel() let work = DispatchWorkItem { [weak self] in self?.open(ref, activateGridFocus: false) } - pendingSingleClickWork = work - DispatchQueue.main.asyncAfter(deadline: .now() + NSEvent.doubleClickInterval, execute: work) + pendingOpenWork = work + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work) } private func makeCell() -> DatabaseTreeCellView { diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineView.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineView.swift index e675b8abe..924aff818 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineView.swift @@ -20,18 +20,22 @@ struct DatabaseTreeOutlineView: NSViewRepresentable { let connectionToken: String let activeDatabase: String? let activeSchema: String? + let selectedTables: Set + let showRecentTables: Bool func makeCoordinator() -> DatabaseTreeOutlineCoordinator { DatabaseTreeOutlineCoordinator() } func makeNSView(context: Context) -> NSScrollView { - let outlineView = NSOutlineView() + let outlineView = DatabaseTreeNSOutlineView() outlineView.headerView = nil outlineView.style = .sourceList - outlineView.rowSizeStyle = .default - outlineView.rowHeight = 24 - outlineView.indentationPerLevel = 14 + /// The two metrics come from AppKit rather than from taste: `.small` is the 24pt source + /// list row, and 13 is the indent a source list steps by. A hand-picked number here is the + /// difference between a sidebar that lines up with Finder and Xcode and one that nearly does. + outlineView.rowSizeStyle = .small + outlineView.indentationPerLevel = 13 outlineView.allowsMultipleSelection = true outlineView.allowsEmptySelection = true outlineView.floatsGroupRows = false @@ -46,8 +50,10 @@ struct DatabaseTreeOutlineView: NSViewRepresentable { outlineView.dataSource = context.coordinator outlineView.delegate = context.coordinator outlineView.target = context.coordinator - outlineView.action = #selector(DatabaseTreeOutlineCoordinator.handleSingleClick) + /// No `action`: it arrives on mouse up, a whole gesture after the selection the user can + /// already see. Opening follows the selection instead. `doubleAction` only discloses. outlineView.doubleAction = #selector(DatabaseTreeOutlineCoordinator.handleDoubleClick) + outlineView.selectionClearing = context.coordinator context.coordinator.attach(outlineView: outlineView) context.coordinator.update(from: self) @@ -67,3 +73,20 @@ struct DatabaseTreeOutlineView: NSViewRepresentable { context.coordinator.update(from: self) } } + +/// Escape clears the selection, the first step of the two-step Escape every TablePro list uses. +/// `NSTableView` implements `cancelOperation(_:)` to interrupt type-select and leaves the selection +/// alone, so without this the Table menu stays scoped to a row the user tried to deselect. +final class DatabaseTreeNSOutlineView: SidebarOutlineView { + weak var selectionClearing: (any DatabaseTreeSelectionClearing)? + + override func cancelOperation(_ sender: Any?) { + super.cancelOperation(sender) + selectionClearing?.clearSelection() + } +} + +@MainActor +protocol DatabaseTreeSelectionClearing: AnyObject { + func clearSelection() +} diff --git a/TablePro/Views/Sidebar/DatabaseTreeRowView.swift b/TablePro/Views/Sidebar/DatabaseTreeRowView.swift index a7dfb6aae..52cade162 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeRowView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeRowView.swift @@ -22,6 +22,11 @@ struct DatabaseTreeRowActions { let batchToggleDelete: ([String]) -> Void let removeRecent: (DatabaseTreeTableRef) -> Void let clearRecents: () -> Void + let showAllTablesMetadata: () -> Void + let refreshObjectKind: (SidebarObjectKind) -> Void + let refreshHierarchicalSchema: (String) -> Void + let openRedisKey: (_ key: String, _ keyType: String) -> Void + let toggleFavorite: (DatabaseTreeTableRef) -> Void } struct DatabaseTreeRowContext { @@ -32,6 +37,9 @@ struct DatabaseTreeRowContext { let pendingTruncates: Set let pendingDeletes: Set var isExternalSchema: @MainActor (String, String) -> Bool = { _, _ in false } + /// The plugin decides what a table is called, so a section header cannot hardcode "Tables". + var objectKindTitle: @MainActor (SidebarObjectKind) -> String = { $0.pluralDisplayName } + var isFavorite: @MainActor (DatabaseTreeTableRef) -> Bool = { _ in false } } struct DatabaseTreeRowView: View { @@ -70,19 +78,9 @@ struct DatabaseTreeRowView: View { private var rowContent: some View { switch node.kind { case .recentSection: - header( - text: String(localized: "Recent"), - systemImage: "clock.arrow.circlepath", - isActive: false, - isSystem: false - ) + sectionHeader(String(localized: "Recent")) case .recentTable(let ref): - TableRow( - table: ref.table, - isPendingTruncate: context.pendingTruncates.contains(ref.table.name), - isPendingDelete: context.pendingDeletes.contains(ref.table.name) - ) - .foregroundStyle(isEmphasized ? AnyShapeStyle(Color.emphasizedSelectionLabel) : AnyShapeStyle(.primary)) + tableRow(ref) case .database(let metadata): header( text: metadata.name, @@ -99,17 +97,81 @@ struct DatabaseTreeRowView: View { caption: context.isExternalSchema(database, schema) ? String(localized: "External") : nil ) case .table(let ref): - TableRow( - table: ref.table, - isPendingTruncate: context.pendingTruncates.contains(ref.table.name), - isPendingDelete: context.pendingDeletes.contains(ref.table.name) - ) - .foregroundStyle(isEmphasized ? AnyShapeStyle(Color.emphasizedSelectionLabel) : AnyShapeStyle(.primary)) + tableRow(ref) case .routine(let ref): RoutineRowView(routine: ref.routine) .foregroundStyle(isEmphasized ? AnyShapeStyle(Color.emphasizedSelectionLabel) : AnyShapeStyle(.primary)) case .status(let status): statusRow(status) + case .objectKindSection(let kind): + sectionHeader(context.objectKindTitle(kind)) + case .hierarchicalSchemaSection(let schema): + header( + text: schema, + systemImage: "folder", + isActive: schema == context.activeSchema, + isSystem: false + ) + case .redisKeysSection: + sectionHeader(String(localized: "Keys")) + case .redisNode(let redisNode): + redisRow(redisNode) + } + } + + /// A source list section title carries no icon and no chevron of its own: AppKit draws the + /// group row's background and its collapse control, and an icon here would be a second glyph + /// competing with the one on every object below it. + private func sectionHeader(_ text: String) -> some View { + Text(text) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + private func tableRow(_ ref: DatabaseTreeTableRef) -> some View { + TableRow( + table: ref.table, + isPendingTruncate: context.pendingTruncates.contains(ref.table.name), + isPendingDelete: context.pendingDeletes.contains(ref.table.name), + isFavorite: context.isFavorite(ref), + onToggleFavorite: { actions.toggleFavorite(ref) } + ) + .foregroundStyle(isEmphasized ? AnyShapeStyle(Color.emphasizedSelectionLabel) : AnyShapeStyle(.primary)) + } + + /// Redis rows carry their own count and type rather than reusing `TableRow`, which is built + /// around a `TableInfo` a key does not have. + @ViewBuilder + private func redisRow(_ node: RedisKeyNode) -> some View { + switch node { + case .namespace(let name, _, _, let keyCount): + Label { + HStack(spacing: 6) { + Text(name) + .lineLimit(1) + Text(verbatim: "\(keyCount)") + .font(.caption) + .foregroundStyle(.secondary) + } + } icon: { + Image(systemName: "folder") + } + .foregroundStyle(isEmphasized ? AnyShapeStyle(Color.emphasizedSelectionLabel) : AnyShapeStyle(.primary)) + case .key(let name, _, let keyType): + Label { + HStack(spacing: 6) { + Text(name) + .lineLimit(1) + .truncationMode(.middle) + Text(keyType) + .font(.caption) + .foregroundStyle(.secondary) + } + } icon: { + Image(systemName: RedisKeyNode.iconName(forKeyType: keyType)) + } + .foregroundStyle(isEmphasized ? AnyShapeStyle(Color.emphasizedSelectionLabel) : AnyShapeStyle(.primary)) } } @@ -157,12 +219,17 @@ struct DatabaseTreeRowView: View { .font(.callout) .foregroundStyle(.secondary) .lineLimit(2) + case .truncated(let message): + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) } } private var hasContextMenu: Bool { switch node.kind { - case .status, .recentSection: return false + case .status, .recentSection, .redisKeysSection: return false default: return true } } @@ -207,8 +274,39 @@ struct DatabaseTreeRowView: View { ) case .routine(let ref): RoutineContextMenu(routine: ref.routine, onShowDDL: actions.showRoutineDDL) - case .status: + case .status, .redisKeysSection: EmptyView() + case .objectKindSection(let kind): + Button(String(format: String(localized: "Show All %@"), context.objectKindTitle(kind))) { + actions.showAllTablesMetadata() + } + .disabled(kind != .table) + Button(String(localized: "Refresh")) { + actions.refreshObjectKind(kind) + } + case .hierarchicalSchemaSection(let schema): + Button(String(localized: "Refresh")) { + actions.refreshHierarchicalSchema(schema) + } + case .redisNode(let redisNode): + redisMenuItems(redisNode) + } + } + + @ViewBuilder + private func redisMenuItems(_ node: RedisKeyNode) -> some View { + switch node { + case .namespace(_, let fullPrefix, _, _): + Button(String(localized: "Copy Namespace Prefix")) { + ClipboardService.shared.writeText(fullPrefix) + } + case .key(_, let fullKey, let keyType): + Button(String(localized: "Copy Key")) { + ClipboardService.shared.writeText(fullKey) + } + Button(String(localized: "Open in New Tab")) { + actions.openRedisKey(fullKey, keyType) + } } } diff --git a/TablePro/Views/Sidebar/DatabaseTreeSelection.swift b/TablePro/Views/Sidebar/DatabaseTreeSelection.swift new file mode 100644 index 000000000..25cd68aff --- /dev/null +++ b/TablePro/Views/Sidebar/DatabaseTreeSelection.swift @@ -0,0 +1,44 @@ +// +// DatabaseTreeSelection.swift +// TablePro +// + +import Foundation + +/// What a row in the object tree can be selected for. +/// +/// Refusing selection was how the tree said "this row is not an object". That put routines and +/// Recent entries, which are objects, outside the selection model entirely: arrows skipped them, +/// type-select could not land on them even though the tree publishes a match string for both, and +/// clicking a Recent entry opened one table while the highlight stayed on another. Only the rows +/// that genuinely stand for nothing refuse now. +internal enum DatabaseTreeSelection { + internal static func isSelectable(_ kind: DatabaseTreeNode.Kind) -> Bool { + switch kind { + case .status, .recentSection, .objectKindSection, .hierarchicalSchemaSection, .redisKeysSection: + return false + case .database, .schema, .table, .routine, .recentTable, .redisNode: + return true + } + } + + /// A Recent entry is a second row for a table the tree already lists, so it resolves to the + /// same reference and opens through the same selection-driven path as the table itself. + internal static func tableRef(of node: DatabaseTreeNode) -> DatabaseTreeTableRef? { + node.tableRef ?? node.recentTableRef + } + + internal static func tableRefs(of nodes: [DatabaseTreeNode]) -> [DatabaseTreeTableRef] { + nodes.compactMap(tableRef) + } + + /// What the Table menu acts on. The tree never published its selection, so Truncate, Copy Name + /// and Delete read an always-empty set and did nothing at all in tree layout while the sidebar + /// plainly showed rows selected. + /// + /// A table reachable from two rows, its own and its Recent entry, is one table, so the set + /// collapses them. + internal static func tableInfos(of nodes: [DatabaseTreeNode]) -> Set { + Set(tableRefs(of: nodes).map(\.table)) + } +} diff --git a/TablePro/Views/Sidebar/DatabaseTreeTypeSelect.swift b/TablePro/Views/Sidebar/DatabaseTreeTypeSelect.swift index e93cc70f8..4484b1d59 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeTypeSelect.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeTypeSelect.swift @@ -17,11 +17,25 @@ internal enum DatabaseTreeTypeSelect { return keyCode == upArrowKeyCode || keyCode == downArrowKeyCode } - /// Group rows and status rows have no name a user would type, and returning a string for them - /// makes type-select land on a row that cannot be opened. + /// Reads `keyCode` only once the event is known to be a key event. + /// + /// `NSEvent.keyCode` is documented as valid for key-up and key-down alone, and AppKit raises on + /// anything else. Passing `event.keyCode` as an argument to the test above evaluates it before + /// the type is checked, so a mouse-driven selection raised from inside the selection-changed + /// notification. The exception unwound through AppKit's own mouse tracking, which left the + /// table view's click handling half finished: no open, no mouse up, and a tracking loop that + /// only ended when the next click arrived. + internal static func isArrowNavigation(_ event: NSEvent) -> Bool { + guard event.type == .keyDown || event.type == .keyUp else { return false } + return isArrowNavigation(type: event.type, keyCode: event.keyCode) + } + + /// Type-select finds objects, and a section title or a status line is not one. AppKit already + /// walks past a match it cannot select, so this decides what the search means rather than + /// keeping the selection off a dead row. internal static func matchString(for kind: DatabaseTreeNode.Kind) -> String? { switch kind { - case .recentSection, .status: + case .recentSection, .status, .objectKindSection, .redisKeysSection: return nil case .recentTable(let ref), .table(let ref): return ref.table.name @@ -31,6 +45,10 @@ internal enum DatabaseTreeTypeSelect { return schema case .routine(let ref): return ref.routine.name + case .hierarchicalSchemaSection(let schema): + return schema + case .redisNode(let node): + return node.displayName } } } diff --git a/TablePro/Views/Sidebar/DatabaseTreeView.swift b/TablePro/Views/Sidebar/DatabaseTreeView.swift index 1cd9f9c98..528d1b658 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeView.swift @@ -47,6 +47,7 @@ struct DatabaseTreeView: View { let sidebarState: SharedSidebarState @State private var searchText: String = "" + @State private var settingsManager = AppSettingsManager.shared private var activeDatabase: String? { let name = coordinator?.toolbarState.currentDatabase ?? "" @@ -122,7 +123,9 @@ struct DatabaseTreeView: View { searchText: searchText, connectionToken: connectionToken, activeDatabase: activeDatabase, - activeSchema: activeSchema + activeSchema: activeSchema, + selectedTables: windowState.selectedTables, + showRecentTables: settingsManager.general.showRecentTables ) } diff --git a/TablePro/Views/Sidebar/FavoriteFolderRenameOverlay.swift b/TablePro/Views/Sidebar/FavoriteFolderRenameOverlay.swift new file mode 100644 index 000000000..5a1ee7e45 --- /dev/null +++ b/TablePro/Views/Sidebar/FavoriteFolderRenameOverlay.swift @@ -0,0 +1,91 @@ +// +// FavoriteFolderRenameOverlay.swift +// TablePro +// + +import AppKit + +/// Renaming a folder in place, as a field the coordinator owns and floats over the row rather than +/// one baked into the cell. +/// +/// A cell is pooled and handed to whatever row needs it next, and the outline reloads whenever the +/// favorites change, including from another window. An edit session living inside a pooled cell can +/// be handed to a different row mid-edit; one that is never an outline item cannot. +@MainActor +internal final class FavoriteFolderRenameOverlay: NSObject, NSTextFieldDelegate { + internal var onCommit: ((SQLFavoriteFolder, String) -> Void)? + internal var onCancel: (() -> Void)? + + private var textField: NSTextField? + private var folder: SQLFavoriteFolder? + private var node: FavoritesOutlineNode? + + internal var isActive: Bool { textField != nil } + + internal func begin(node: FavoritesOutlineNode, folder: SQLFavoriteFolder, in outlineView: NSOutlineView) { + dismiss(commit: false) + let row = outlineView.row(forItem: node) + guard row >= 0, let window = outlineView.window else { return } + let field = NSTextField(frame: outlineView.frameOfCell(atColumn: 0, row: row)) + field.stringValue = folder.name + field.delegate = self + field.isBezeled = true + field.bezelStyle = .roundedBezel + field.focusRingType = .default + outlineView.addSubview(field) + window.makeFirstResponder(field) + field.currentEditor()?.selectAll(nil) + textField = field + self.folder = folder + self.node = node + } + + /// A reload can move the row under the field, or remove it. The field is a subview of the + /// outline, so scrolling carries it, but a row that shifted leaves it painted over a neighbour + /// the user is not editing. + internal func reposition(in outlineView: NSOutlineView) { + guard let textField, let node else { return } + let row = outlineView.row(forItem: node) + guard row >= 0 else { + dismiss(commit: false) + return + } + textField.frame = outlineView.frameOfCell(atColumn: 0, row: row) + } + + internal func control(_ control: NSControl, textView: NSTextView, doCommandBy selector: Selector) -> Bool { + if selector == #selector(NSResponder.insertNewline(_:)) { + dismiss(commit: true, restoringFocusTo: control.superview as? NSOutlineView) + return true + } + if selector == #selector(NSResponder.cancelOperation(_:)) { + dismiss(commit: false, restoringFocusTo: control.superview as? NSOutlineView) + return true + } + return false + } + + /// Clicking away commits, the way Finder and the Xcode navigator do. Escape is the only way to + /// discard. The focus already belongs to whatever was clicked, so nothing is restored. + internal func controlTextDidEndEditing(_ notification: Notification) { + dismiss(commit: true) + } + + internal func dismiss(commit: Bool, restoringFocusTo outlineView: NSOutlineView? = nil) { + guard let textField, let folder else { return } + let value = textField.stringValue + textField.delegate = nil + textField.removeFromSuperview() + self.textField = nil + self.folder = nil + node = nil + if let outlineView { + outlineView.window?.makeFirstResponder(outlineView) + } + if commit { + onCommit?(folder, value) + } else { + onCancel?() + } + } +} diff --git a/TablePro/Views/Sidebar/FavoriteRowView.swift b/TablePro/Views/Sidebar/FavoriteRowView.swift index b92c9435a..b3b7033d5 100644 --- a/TablePro/Views/Sidebar/FavoriteRowView.swift +++ b/TablePro/Views/Sidebar/FavoriteRowView.swift @@ -10,11 +10,6 @@ internal struct FavoriteRowView: View { let favorite: SQLFavorite var body: some View { - rowContent - .draggable(favorite.query) - } - - private var rowContent: some View { HStack(spacing: 6) { Image(systemName: "star.fill") .font(.callout) diff --git a/TablePro/Views/Sidebar/FavoritesExpansion.swift b/TablePro/Views/Sidebar/FavoritesExpansion.swift new file mode 100644 index 000000000..088e1785a --- /dev/null +++ b/TablePro/Views/Sidebar/FavoritesExpansion.swift @@ -0,0 +1,33 @@ +// +// FavoritesExpansion.swift +// TablePro +// + +import Foundation + +/// Which store a favorites row's expansion belongs in. User folders are keyed by their identifier, +/// anything mirrored from disk by its node id, and the two stores are not interchangeable. +@MainActor +internal enum FavoritesExpansion { + internal static func isExpanded(_ node: FavoriteNode, connectionId: UUID) -> Bool { + switch node.content { + case .folder(let folder): + return FavoritesExpansionState.shared.isFolderExpanded(folder.id, for: connectionId) + case .linkedFolder, .linkedSubfolder: + return FavoritesExpansionState.shared.isLinkedNodeExpanded(node.id, for: connectionId) + case .favorite, .linkedFavorite: + return false + } + } + + internal static func setExpanded(_ node: FavoriteNode, expanded: Bool, connectionId: UUID) { + switch node.content { + case .folder(let folder): + FavoritesExpansionState.shared.setFolderExpanded(folder.id, expanded: expanded, for: connectionId) + case .linkedFolder, .linkedSubfolder: + FavoritesExpansionState.shared.setLinkedNodeExpanded(node.id, expanded: expanded, for: connectionId) + case .favorite, .linkedFavorite: + break + } + } +} diff --git a/TablePro/Views/Sidebar/FavoritesOutlineCellView.swift b/TablePro/Views/Sidebar/FavoritesOutlineCellView.swift new file mode 100644 index 000000000..94f5e0bf0 --- /dev/null +++ b/TablePro/Views/Sidebar/FavoritesOutlineCellView.swift @@ -0,0 +1,33 @@ +// +// FavoritesOutlineCellView.swift +// TablePro +// + +import AppKit +import SwiftUI + +/// Hosts one SwiftUI row. The hosting view is created once and its root swapped on reuse, so the +/// row keeps its own state instead of being rebuilt on every scroll. +/// +/// No emphasis is threaded through: AppKit publishes `backgroundStyle` into the hosted view's +/// environment as `backgroundProminence`, which is what the row content already reads. +internal final class FavoritesOutlineCellView: NSTableCellView { + private var hosting: NSHostingView? + + internal func update(rootView: Row) { + if let hosting { + hosting.rootView = rootView + return + } + let view = NSHostingView(rootView: rootView) + view.translatesAutoresizingMaskIntoConstraints = false + addSubview(view) + NSLayoutConstraint.activate([ + view.leadingAnchor.constraint(equalTo: leadingAnchor), + view.trailingAnchor.constraint(equalTo: trailingAnchor), + view.topAnchor.constraint(equalTo: topAnchor, constant: 1), + view.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -1), + ]) + hosting = view + } +} diff --git a/TablePro/Views/Sidebar/FavoritesOutlineCoordinator.swift b/TablePro/Views/Sidebar/FavoritesOutlineCoordinator.swift new file mode 100644 index 000000000..c56487d9b --- /dev/null +++ b/TablePro/Views/Sidebar/FavoritesOutlineCoordinator.swift @@ -0,0 +1,350 @@ +// +// FavoritesOutlineCoordinator.swift +// TablePro +// + +import AppKit +import SwiftUI + +@MainActor +internal final class FavoritesOutlineCoordinator: NSObject, NSOutlineViewDataSource, + NSOutlineViewDelegate, FavoritesOutlineKeyHandling { + private static var cellIdentifier: NSUserInterfaceItemIdentifier { + NSUserInterfaceItemIdentifier("FavoritesOutlineCell") + } + + private var owner: FavoritesOutlineView + private weak var outlineView: NSOutlineView? + private let rename = FavoriteFolderRenameOverlay() + + /// Keyed by id and mutated in place, never rebuilt, so `NSOutlineView` keeps the row identity a + /// reload would otherwise throw away along with the user's expansion and selection. + private var nodeCache: [String: FavoritesOutlineNode] = [:] + private var childrenCache: [String: [FavoritesOutlineNode]] = [:] + private var isSyncingSelection = false + private var isReloading = false + private var isApplyingExpansion = false + private var lastInputFingerprint = "" + + internal init(owner: FavoritesOutlineView) { + self.owner = owner + super.init() + } + + internal func attach(outlineView: NSOutlineView) { + self.outlineView = outlineView + rename.onCommit = { [weak self] folder, name in self?.owner.actions.commitRename(folder, name) } + rename.onCancel = { [weak self] in self?.owner.actions.cancelRename() } + lastInputFingerprint = Self.fingerprint(of: owner.input) + reload() + } + + internal func update(owner: FavoritesOutlineView) { + self.owner = owner + let fingerprint = Self.fingerprint(of: owner.input) + if fingerprint != lastInputFingerprint { + lastInputFingerprint = fingerprint + reload() + } else { + refreshVisibleRows() + } + applySelection() + applyRenameState() + } + + /// Rebuilding on every SwiftUI pass would throw away the hosted views on each keystroke, so the + /// outline reloads only when the set of rows or their nesting changed. Depth is part of the + /// fingerprint because moving a favorite into a folder can leave the pre-order id list identical. + private static func fingerprint(of input: FavoritesOutlineInput) -> String { + var parts: [String] = [input.activeDatabase ?? ""] + parts += input.tables.map(\.id) + parts += input.queryNodes.flatMap { Self.identifiers(of: $0, depth: 0) } + parts += input.teamQueries.map(\.id) + return parts.joined(separator: "\u{1}") + } + + private static func identifiers(of node: FavoriteNode, depth: Int) -> [String] { + ["\(depth)|\(node.id)"] + (node.children ?? []).flatMap { Self.identifiers(of: $0, depth: depth + 1) } + } + + private func reload() { + guard let outlineView else { return } + isReloading = true + childrenCache.removeAll() + outlineView.reloadData() + applyExpansion() + applySelection() + rename.reposition(in: outlineView) + isReloading = false + } + + /// The rows are the same rows, but their contents may not be: renaming a folder or editing a + /// saved query keeps every id, so the cached nodes are re-derived from the current input before + /// anything is drawn. Reading the payload back out of the outline would redraw the stale copy. + private func refreshVisibleRows() { + guard let outlineView else { return } + refreshNodePayloads() + for row in 0.. else { continue } + cell.update(rootView: owner.row(node)) + } + } + + private func refreshNodePayloads() { + childrenCache.removeAll() + refreshNodePayloads(of: children(of: nil)) + } + + private func refreshNodePayloads(of nodes: [FavoritesOutlineNode]) { + for node in nodes where node.isExpandable { + refreshNodePayloads(of: children(of: node)) + } + } + + private func node(id: String, kind: FavoritesOutlineNode.Kind) -> FavoritesOutlineNode { + if let existing = nodeCache[id] { + existing.kind = kind + return existing + } + let created = FavoritesOutlineNode(id: id, kind: kind) + nodeCache[id] = created + return created + } + + private func children(of parent: FavoritesOutlineNode?) -> [FavoritesOutlineNode] { + let key = parent?.id ?? "" + if let cached = childrenCache[key] { return cached } + let built = build(children: parent) + childrenCache[key] = built + return built + } + + private func build(children parent: FavoritesOutlineNode?) -> [FavoritesOutlineNode] { + guard let parent else { return rootNodes() } + guard case .query(let favoriteNode) = parent.kind, let kids = favoriteNode.children else { return [] } + return kids.map { node(id: $0.id, kind: .query($0)) } + } + + private func rootNodes() -> [FavoritesOutlineNode] { + var nodes: [FavoritesOutlineNode] = [] + if !owner.input.tables.isEmpty { + nodes.append(node(id: FavoritesOutlineNode.tablesHeaderId, kind: .header(String(localized: "Tables")))) + nodes += owner.input.tables.map { table in + let id = FavoritesOutlineNode.tableId( + database: owner.input.activeDatabase, schema: table.schema, name: table.name + ) + return node(id: id, kind: .table(table)) + } + } + if !owner.input.queryNodes.isEmpty { + nodes.append(node(id: FavoritesOutlineNode.queriesHeaderId, kind: .header(String(localized: "Queries")))) + nodes += owner.input.queryNodes.map { node(id: $0.id, kind: .query($0)) } + } + if !owner.input.teamQueries.isEmpty { + nodes.append(node(id: FavoritesOutlineNode.teamHeaderId, kind: .header(String(localized: "Team Library")))) + nodes += owner.input.teamQueries.map { query in + node( + id: FavoritesOutlineNode.teamQueryId(query.id), + kind: .teamQuery(id: query.id, name: query.name, publishedBy: query.publishedBy) + ) + } + } + return nodes + } + + // MARK: - Expansion + + private func applyExpansion() { + guard let outlineView else { return } + isApplyingExpansion = true + defer { isApplyingExpansion = false } + applyExpansion(to: children(of: nil), in: outlineView) + } + + private func applyExpansion(to nodes: [FavoritesOutlineNode], in outlineView: NSOutlineView) { + for node in nodes where node.isExpandable { + guard case .query(let favoriteNode) = node.kind else { continue } + if FavoritesExpansion.isExpanded(favoriteNode, connectionId: owner.input.connectionId) { + outlineView.expandItem(node) + applyExpansion(to: children(of: node), in: outlineView) + } else { + outlineView.collapseItem(node) + } + } + } + + internal func outlineViewItemDidExpand(_ notification: Notification) { + recordExpansion(from: notification, expanded: true) + } + + internal func outlineViewItemDidCollapse(_ notification: Notification) { + recordExpansion(from: notification, expanded: false) + } + + private func recordExpansion(from notification: Notification, expanded: Bool) { + guard !isApplyingExpansion, + let node = notification.userInfo?["NSObject"] as? FavoritesOutlineNode, + case .query(let favoriteNode) = node.kind else { return } + FavoritesExpansion.setExpanded(favoriteNode, expanded: expanded, connectionId: owner.input.connectionId) + } + + // MARK: - Selection + + private func applySelection() { + guard let outlineView else { return } + guard let selection = owner.selection else { + guard !outlineView.selectedRowIndexes.isEmpty else { return } + isSyncingSelection = true + outlineView.deselectAll(nil) + isSyncingSelection = false + return + } + let targetId = FavoritesOutlineSelection.nodeId(for: selection) + guard let node = nodeCache[targetId] else { return } + let row = outlineView.row(forItem: node) + guard row >= 0, !outlineView.selectedRowIndexes.contains(row) else { return } + isSyncingSelection = true + outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) + isSyncingSelection = false + outlineView.scrollRowToVisible(row) + } + + internal func outlineViewSelectionDidChange(_ notification: Notification) { + guard !isSyncingSelection, !isReloading, let outlineView else { return } + guard let node = outlineView.item(atRow: outlineView.selectedRow) as? FavoritesOutlineNode else { + owner.selection = nil + return + } + /// Selecting is not running. Arrowing through saved queries must never insert or execute + /// them, so the primary action stays behind a double-click or Return. + owner.selection = FavoritesOutlineSelection.selection( + for: node.kind, database: owner.input.activeDatabase + ) + } + + // MARK: - Actions + + @objc internal func handleDoubleClick() { + guard let outlineView, outlineView.clickedRow >= 0, + let node = outlineView.item(atRow: outlineView.clickedRow) as? FavoritesOutlineNode else { return } + if node.isExpandable { + if outlineView.isItemExpanded(node) { + outlineView.collapseItem(node) + } else { + outlineView.expandItem(node) + } + return + } + commitPrimaryAction(for: node) + } + + internal func performPrimaryAction() { + guard let node = selectedNode() else { return } + commitPrimaryAction(for: node) + } + + private func commitPrimaryAction(for node: FavoritesOutlineNode) { + guard FavoritesOutlineSelection.isSelectable(node.kind) else { return } + owner.actions.primaryAction(node.kind) + } + + internal func performDelete() { + guard let node = selectedNode() else { return } + owner.actions.deleteSelection(node.kind) + } + + private func selectedNode() -> FavoritesOutlineNode? { + guard let outlineView, outlineView.selectedRow >= 0 else { return nil } + return outlineView.item(atRow: outlineView.selectedRow) as? FavoritesOutlineNode + } + + // MARK: - Rename + + private func applyRenameState() { + guard let outlineView else { return } + guard let folderId = owner.input.renamingFolderId else { + rename.dismiss(commit: false) + return + } + guard !rename.isActive, + let node = nodeCache["folder-\(folderId)"], + case .query(let favoriteNode) = node.kind, + let folder = favoriteNode.asFolder else { return } + rename.begin(node: node, folder: folder, in: outlineView) + } + + // MARK: - Data source + + internal func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int { + children(of: item as? FavoritesOutlineNode).count + } + + internal func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any { + children(of: item as? FavoritesOutlineNode)[index] + } + + internal func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool { + (item as? FavoritesOutlineNode)?.isExpandable ?? false + } + + internal func outlineView(_ outlineView: NSOutlineView, viewFor column: NSTableColumn?, item: Any) -> NSView? { + guard let node = item as? FavoritesOutlineNode else { return nil } + let cell = outlineView.makeView(withIdentifier: Self.cellIdentifier, owner: self) + as? FavoritesOutlineCellView ?? makeCell() + cell.update(rootView: owner.row(node)) + return cell + } + + internal func outlineView(_ outlineView: NSOutlineView, shouldSelectItem item: Any) -> Bool { + guard let node = item as? FavoritesOutlineNode else { return false } + return FavoritesOutlineSelection.isSelectable(node.kind) + } + + /// Tables, Queries and Team Library are buckets rather than objects, so AppKit draws them as + /// source list group rows. See `DatabaseTreeNode.isSectionHeader` for why that matters beyond + /// the styling. + internal func outlineView(_ outlineView: NSOutlineView, isGroupItem item: Any) -> Bool { + guard case .header = (item as? FavoritesOutlineNode)?.kind else { return false } + return true + } + + internal func outlineView( + _ outlineView: NSOutlineView, + typeSelectStringFor tableColumn: NSTableColumn?, + item: Any + ) -> String? { + guard let node = item as? FavoritesOutlineNode else { return nil } + return FavoritesOutlineSelection.matchString(for: node.kind) + } + + /// Drag out only, which is all the SwiftUI rows offered. Nothing accepts a drop, so no dragged + /// types are registered and no reorder is implied. + internal func outlineView(_ outlineView: NSOutlineView, pasteboardWriterForItem item: Any) -> NSPasteboardWriting? { + guard let node = item as? FavoritesOutlineNode, case .query(let favoriteNode) = node.kind else { return nil } + switch favoriteNode.content { + case .favorite(let favorite): + return Self.pasteboardItem(favorite.query) + case .linkedFavorite(let linked): + /// `FileTextLoader` walks the same encoding chain the row's own badge is derived from, + /// so a file the sidebar already flagged as non-UTF-8 still drags. + guard let loaded = FileTextLoader.load(linked.fileURL) else { return nil } + return Self.pasteboardItem(loaded.content) + case .folder, .linkedFolder, .linkedSubfolder: + return nil + } + } + + private static func pasteboardItem(_ string: String) -> NSPasteboardWriting { + let item = NSPasteboardItem() + item.setString(string, forType: .string) + return item + } + + private func makeCell() -> FavoritesOutlineCellView { + let cell = FavoritesOutlineCellView() + cell.identifier = Self.cellIdentifier + return cell + } +} diff --git a/TablePro/Views/Sidebar/FavoritesOutlineNode.swift b/TablePro/Views/Sidebar/FavoritesOutlineNode.swift new file mode 100644 index 000000000..661e2df0e --- /dev/null +++ b/TablePro/Views/Sidebar/FavoritesOutlineNode.swift @@ -0,0 +1,45 @@ +// +// FavoritesOutlineNode.swift +// TablePro +// + +import Foundation + +/// One row of the Favorites outline. +/// +/// A reference type because `NSOutlineView` tracks rows by object identity, which is what lets a +/// reload keep the expansion and selection a user set. The coordinator caches these by `id` and +/// mutates the cached instance rather than building a new one, exactly as the object tree does. +internal final class FavoritesOutlineNode { + internal enum Kind { + case header(String) + case table(TableInfo) + case query(FavoriteNode) + case teamQuery(id: String, name: String, publishedBy: String?) + } + + internal let id: String + internal var kind: Kind + + internal init(id: String, kind: Kind) { + self.id = id + self.kind = kind + } + + internal var isExpandable: Bool { + guard case .query(let node) = kind else { return false } + return node.isFolder + } + + internal static let tablesHeaderId = "favorites\u{1}header\u{1}tables" + internal static let queriesHeaderId = "favorites\u{1}header\u{1}queries" + internal static let teamHeaderId = "favorites\u{1}header\u{1}team" + + /// Built from the three plain strings the persisted selection carries, so a selection can be + /// restored without a live `TableInfo` to hand. + internal static func tableId(database: String?, schema: String?, name: String) -> String { + ["favtable", database ?? "", schema ?? "", name].joined(separator: "\u{1}") + } + + internal static func teamQueryId(_ clientId: String) -> String { "favteam\u{1}\(clientId)" } +} diff --git a/TablePro/Views/Sidebar/FavoritesOutlineSelection.swift b/TablePro/Views/Sidebar/FavoritesOutlineSelection.swift new file mode 100644 index 000000000..aae0d10e4 --- /dev/null +++ b/TablePro/Views/Sidebar/FavoritesOutlineSelection.swift @@ -0,0 +1,62 @@ +// +// FavoritesOutlineSelection.swift +// TablePro +// + +import Foundation + +/// What a Favorites row can be selected for, and how that maps onto the selection the app persists. +internal enum FavoritesOutlineSelection { + /// Section titles stand for nothing, so they refuse selection. Everything else is a real object, + /// including Team Library rows, which used to be plain buttons the keyboard could never reach. + internal static func isSelectable(_ kind: FavoritesOutlineNode.Kind) -> Bool { + guard case .header = kind else { return true } + return false + } + + internal static func selection( + for kind: FavoritesOutlineNode.Kind, + database: String? + ) -> FavoriteSelection? { + switch kind { + case .header: + return nil + case .table(let table): + return .table(database: database, schema: table.schema, name: table.name) + case .query(let node): + return .node(id: node.id) + case .teamQuery(let id, _, _): + return .node(id: FavoritesOutlineNode.teamQueryId(id)) + } + } + + /// The id a persisted selection points at, so a reload can find the row again. + internal static func nodeId(for selection: FavoriteSelection) -> String { + switch selection { + case .table(let database, let schema, let name): + return FavoritesOutlineNode.tableId(database: database, schema: schema, name: name) + case .node(let id): + return id + } + } + + /// Type-select needs the name a user would actually type. A section title is not one. + internal static func matchString(for kind: FavoritesOutlineNode.Kind) -> String? { + switch kind { + case .header: + return nil + case .table(let table): + return table.name + case .teamQuery(_, let name, _): + return name + case .query(let node): + switch node.content { + case .favorite(let favorite): return favorite.name + case .folder(let folder): return folder.name + case .linkedFolder(let folder): return folder.name + case .linkedSubfolder(_, let displayName, _): return displayName + case .linkedFavorite(let linked): return linked.name + } + } + } +} diff --git a/TablePro/Views/Sidebar/FavoritesOutlineView.swift b/TablePro/Views/Sidebar/FavoritesOutlineView.swift new file mode 100644 index 000000000..32f02010e --- /dev/null +++ b/TablePro/Views/Sidebar/FavoritesOutlineView.swift @@ -0,0 +1,111 @@ +// +// FavoritesOutlineView.swift +// TablePro +// + +import AppKit +import SwiftUI + +/// Everything the outline needs from `FavoritesTabView`, which keeps owning the row content, the +/// menus and the actions. Only the list container moved to AppKit. +internal struct FavoritesOutlineInput { + internal let connectionId: UUID + internal let activeDatabase: String? + internal let tables: [TableInfo] + internal let queryNodes: [FavoriteNode] + internal let teamQueries: [FavoritesOutlineTeamQuery] + internal let renamingFolderId: UUID? +} + +internal struct FavoritesOutlineTeamQuery { + internal let id: String + internal let name: String + internal let publishedBy: String? +} + +/// Both actions carry the row's own kind rather than the persisted `FavoriteSelection`, because a +/// selection cannot name a Team Library row: those queries live outside the favorites tree and have +/// no `FavoriteNode` to look up. +internal struct FavoritesOutlineActions { + internal let primaryAction: (FavoritesOutlineNode.Kind) -> Void + internal let deleteSelection: (FavoritesOutlineNode.Kind) -> Void + internal let commitRename: (SQLFavoriteFolder, String) -> Void + internal let cancelRename: () -> Void +} + +/// The Favorites list as an `NSOutlineView`. +/// +/// A SwiftUI `List` here could not draw an emphasized selection or answer an arrow key, because the +/// app hosts it in a bare `NSHostingController` with no SwiftUI scene above it. Rows stay SwiftUI: +/// AppKit publishes `NSTableCellView.backgroundStyle` into the hosted view's environment, so the +/// existing row content keeps working with no emphasis plumbing of its own. +internal struct FavoritesOutlineView: NSViewRepresentable { + internal let input: FavoritesOutlineInput + @Binding internal var selection: FavoriteSelection? + internal let actions: FavoritesOutlineActions + @ViewBuilder internal let row: (FavoritesOutlineNode) -> Row + + internal func makeCoordinator() -> FavoritesOutlineCoordinator { + FavoritesOutlineCoordinator(owner: self) + } + + internal func makeNSView(context: Context) -> NSScrollView { + let outlineView = FavoritesNSOutlineView() + outlineView.headerView = nil + outlineView.style = .sourceList + /// AppKit's own source list metrics, the same ones the object outline uses. + outlineView.rowSizeStyle = .small + outlineView.indentationPerLevel = 13 + outlineView.allowsMultipleSelection = false + outlineView.allowsEmptySelection = true + outlineView.floatsGroupRows = false + outlineView.autosaveExpandedItems = false + outlineView.backgroundColor = .clear + outlineView.setDraggingSourceOperationMask(.copy, forLocal: false) + + let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("FavoritesColumn")) + column.resizingMask = .autoresizingMask + outlineView.addTableColumn(column) + outlineView.outlineTableColumn = column + + outlineView.dataSource = context.coordinator + outlineView.delegate = context.coordinator + outlineView.target = context.coordinator + outlineView.doubleAction = #selector(FavoritesOutlineCoordinator.handleDoubleClick) + outlineView.favoritesCoordinator = context.coordinator + context.coordinator.attach(outlineView: outlineView) + + let scrollView = NSScrollView() + scrollView.documentView = outlineView + scrollView.hasVerticalScroller = true + scrollView.drawsBackground = false + return scrollView + } + + internal func updateNSView(_ scrollView: NSScrollView, context: Context) { + context.coordinator.update(owner: self) + } +} + +/// Return commits, Delete removes. `NSOutlineView` routes neither on its own. +internal final class FavoritesNSOutlineView: SidebarOutlineView { + internal weak var favoritesCoordinator: (any FavoritesOutlineKeyHandling)? + + override internal func insertNewline(_ sender: Any?) { + favoritesCoordinator?.performPrimaryAction() + } + + override internal func deleteBackward(_ sender: Any?) { + favoritesCoordinator?.performDelete() + } + + override internal func deleteForward(_ sender: Any?) { + favoritesCoordinator?.performDelete() + } +} + +@MainActor +internal protocol FavoritesOutlineKeyHandling: AnyObject { + func performPrimaryAction() + func performDelete() +} diff --git a/TablePro/Views/Sidebar/FavoritesTabView.swift b/TablePro/Views/Sidebar/FavoritesTabView.swift index f23a70fb2..0a72d9e05 100644 --- a/TablePro/Views/Sidebar/FavoritesTabView.swift +++ b/TablePro/Views/Sidebar/FavoritesTabView.swift @@ -11,7 +11,6 @@ internal struct FavoritesTabView: View { @State private var linkedMetadataTarget: LinkedSQLFavorite? @State private var linkedFolderToRemove: LinkedSQLFolder? @State private var showRemoveLinkedFolderAlert = false - @FocusState private var isRenameFocused: Bool let connectionId: UUID @Bindable private var sharedSidebarState: SharedSidebarState let tables: [TableInfo] @@ -181,33 +180,6 @@ internal struct FavoritesTabView: View { ) } - @ViewBuilder - private func teamLibrarySection() -> some View { - if !teamLibraryQueries.isEmpty { - Section(String(localized: "Team Library")) { - ForEach(teamLibraryQueries) { query in - Button { - coordinator?.runFavoriteInNewTab(teamFavorite(from: query)) - } label: { - HStack(spacing: 6) { - Image(systemName: "books.vertical") - .foregroundStyle(.secondary) - VStack(alignment: .leading, spacing: 1) { - Text(query.name) - if let publishedBy = query.publishedBy { - Text(publishedBy) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - } - .buttonStyle(.plain) - } - } - } - } - private func publishSavedQueriesToTeam() { Task { @MainActor in let favorites = await SQLFavoriteManager.shared.fetchFavorites() @@ -246,45 +218,99 @@ internal struct FavoritesTabView: View { ) } + /// The Favorites list is an `NSOutlineView`. A SwiftUI `List` here drew no emphasized selection + /// and answered no arrow key, because the app hosts it in a bare `NSHostingController` with no + /// SwiftUI scene above it. Rows, menus and actions stay exactly where they were; only the list + /// container changed. private func favoritesList( _ items: [FavoriteNode], filteredTables: [TableInfo] ) -> some View { - List(selection: $sharedSidebarState.selectedFavorite) { - if !filteredTables.isEmpty { - Section(String(localized: "Tables")) { - ForEach(filteredTables) { table in - favoriteTableRow(table: table) - } - } - } - if !items.isEmpty { - Section(String(localized: "Queries")) { - ForEach(items) { node in - FavoriteNodeRow( - node: node, - connectionId: connectionId, - viewModel: viewModel, - isRenameFocused: $isRenameFocused - ) - } - } - } - teamLibrarySection() + FavoritesOutlineView( + input: FavoritesOutlineInput( + connectionId: connectionId, + activeDatabase: activeDatabase, + tables: filteredTables, + queryNodes: items, + teamQueries: teamLibraryQueries.map { + FavoritesOutlineTeamQuery(id: $0.id, name: $0.name, publishedBy: $0.publishedBy) + }, + renamingFolderId: viewModel.renamingFolderId + ), + selection: $sharedSidebarState.selectedFavorite, + actions: FavoritesOutlineActions( + primaryAction: { handlePrimaryAction($0) }, + deleteSelection: { deleteNode($0) }, + commitRename: { folder, name in viewModel.commitRenameFolder(folder, to: name) }, + cancelRename: { viewModel.renamingFolderId = nil } + ), + row: { outlineRow($0) } + ) + } + + /// The cell pins its hosted view to the full row width, and `NSHostingView` centers a root view + /// narrower than its bounds, so the row has to claim that width itself or short content drifts + /// to the middle and the context menu goes with it. + private func outlineRow(_ node: FavoritesOutlineNode) -> some View { + rowContent(node) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + + @ViewBuilder + private func rowContent(_ node: FavoritesOutlineNode) -> some View { + switch node.kind { + case .header(let title): + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + case .table(let table): + favoriteTableRow(table: table) + .contextMenu { favoriteTableContextMenu(table) } + case .query(let favoriteNode): + favoriteQueryRow(favoriteNode) + case .teamQuery(_, let name, let publishedBy): + teamQueryRow(name: name, publishedBy: publishedBy) } - .sidebarListLayout() - .onDeleteCommand { - deleteSelectedNode() + } + + @ViewBuilder + private func favoriteQueryRow(_ node: FavoriteNode) -> some View { + switch node.content { + case .favorite(let favorite): + FavoriteRowView(favorite: favorite) + .contextMenu { favoriteContextMenu(favorite) } + case .folder(let folder): + Label(folder.name, systemImage: "folder") + .contextMenu { folderContextMenu(folder) } + case .linkedFolder(let folder): + LinkedFolderRowLabel(folder: folder) + .contextMenu { linkedFolderContextMenu(folder) } + case .linkedSubfolder(_, let displayName, _): + LinkedSubfolderRowLabel(displayName: displayName) + case .linkedFavorite(let linked): + LinkedFavoriteRowView(favorite: linked) + .contextMenu { linkedFavoriteContextMenu(linked) } } - .contextMenu(forSelectionType: FavoriteSelection.self) { selection in - if let selected = selection.first, hasContextMenuItems(for: selected) { - contextMenu(for: selected) - Divider() + } + + /// One line, not two. The outline draws a uniform 24pt row, and the stacked publisher caption + /// the SwiftUI list used would be clipped. + private func teamQueryRow(name: String, publishedBy: String?) -> some View { + Label { + HStack(spacing: 6) { + Text(name) + .lineLimit(1) + if let publishedBy { + Text(publishedBy) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } } - SidebarViewOptionsMenu() - } primaryAction: { selection in - guard let selected = selection.first else { return } - handlePrimaryAction(selected) + } icon: { + Image(systemName: "books.vertical") + .foregroundStyle(.secondary) } } @@ -296,7 +322,6 @@ internal struct FavoritesTabView: View { .selectionAwareTint(Color.accentColor) } .sidebarRowIcon(visible: AppSettingsManager.shared.general.showObjectIcons) - .tag(FavoriteSelection.table(database: activeDatabase, schema: table.schema, name: table.name)) .accessibilityLabel( TableRowLogic.accessibilityLabel(table: table, isPendingDelete: false, isPendingTruncate: false) ) @@ -321,59 +346,13 @@ internal struct FavoritesTabView: View { } } - private func favoriteTable(database: String?, schema: String?, name: String) -> TableInfo? { - guard database == activeDatabase else { return nil } - return availableFavoriteTables.first { $0.name == name && $0.schema == schema } - } - - private func hasContextMenuItems(for selection: FavoriteSelection) -> Bool { - switch selection { - case .table(let database, let schema, let name): - return favoriteTable(database: database, schema: schema, name: name) != nil - case .node(let id): - guard let node = viewModel.node(forId: id) else { return false } - switch node.content { - case .favorite, .linkedFavorite, .folder, .linkedFolder: - return true - case .linkedSubfolder: - return false - } - } - } - - @ViewBuilder - private func contextMenu(for selection: FavoriteSelection) -> some View { - switch selection { - case .table(let database, let schema, let name): - if let table = favoriteTable(database: database, schema: schema, name: name) { - favoriteTableContextMenu(table) - } - case .node(let id): - if let node = viewModel.node(forId: id) { - switch node.content { - case .favorite(let favorite): - favoriteContextMenu(favorite) - case .linkedFavorite(let linked): - linkedFavoriteContextMenu(linked) - case .folder(let folder): - folderContextMenu(folder) - case .linkedFolder(let folder): - linkedFolderContextMenu(folder) - case .linkedSubfolder: - EmptyView() - } - } - } - } - - private func handlePrimaryAction(_ selection: FavoriteSelection) { - switch selection { - case .table(let database, let schema, let name): - if let table = favoriteTable(database: database, schema: schema, name: name) { - coordinator?.openTableTab(table, activateGridFocus: true) - } - case .node(let id): - guard let node = viewModel.node(forId: id) else { return } + private func handlePrimaryAction(_ kind: FavoritesOutlineNode.Kind) { + switch kind { + case .header: + break + case .table(let table): + coordinator?.openTableTab(table, activateGridFocus: true) + case .query(let node): switch node.content { case .favorite(let favorite): coordinator?.insertFavorite(favorite) @@ -382,20 +361,22 @@ internal struct FavoritesTabView: View { case .folder, .linkedFolder, .linkedSubfolder: break } + case .teamQuery(let id, _, _): + guard let query = TeamLibrarySyncCoordinator.shared.library.queries.first(where: { $0.id == id }) + else { return } + coordinator?.runFavoriteInNewTab(teamFavorite(from: query)) } } - private func deleteSelectedNode() { - guard let selection = sharedSidebarState.selectedFavorite else { return } - switch selection { - case .table(let database, let schema, let name): - if let table = favoriteTable(database: database, schema: schema, name: name) { - FavoriteTablesStorage.shared.removeFavorite( - name: table.name, schema: table.schema, database: activeDatabase, connectionId: connectionId - ) - } - case .node(let id): - guard let node = viewModel.node(forId: id) else { return } + private func deleteNode(_ kind: FavoritesOutlineNode.Kind) { + switch kind { + case .header, .teamQuery: + break + case .table(let table): + FavoriteTablesStorage.shared.removeFavorite( + name: table.name, schema: table.schema, database: activeDatabase, connectionId: connectionId + ) + case .query(let node): switch node.content { case .favorite(let favorite): viewModel.deleteFavorite(favorite) @@ -658,94 +639,3 @@ internal struct FavoritesTabView: View { } } } - -private struct FavoriteNodeRow: View { - let node: FavoriteNode - let connectionId: UUID - let viewModel: FavoritesSidebarViewModel - @FocusState.Binding var isRenameFocused: Bool - - var body: some View { - switch node.content { - case .favorite(let favorite): - FavoriteRowView(favorite: favorite) - .tag(FavoriteSelection.node(id: node.id)) - case .folder(let folder): - DisclosureGroup(isExpanded: folderExpansion(folder)) { - childRows - } label: { - folderLabel(folder) - } - .tag(FavoriteSelection.node(id: node.id)) - case .linkedFolder(let linkedFolder): - DisclosureGroup(isExpanded: linkedExpansion) { - childRows - } label: { - LinkedFolderRowLabel(folder: linkedFolder) - } - .tag(FavoriteSelection.node(id: node.id)) - case .linkedSubfolder(_, let displayName, _): - DisclosureGroup(isExpanded: linkedExpansion) { - childRows - } label: { - LinkedSubfolderRowLabel(displayName: displayName) - } - .tag(FavoriteSelection.node(id: node.id)) - case .linkedFavorite(let linked): - LinkedFavoriteRowView(favorite: linked) - .tag(FavoriteSelection.node(id: node.id)) - } - } - - @ViewBuilder - private var childRows: some View { - if let children = node.children { - ForEach(children) { child in - FavoriteNodeRow( - node: child, - connectionId: connectionId, - viewModel: viewModel, - isRenameFocused: $isRenameFocused - ) - } - } - } - - private func folderExpansion(_ folder: SQLFavoriteFolder) -> Binding { - Binding( - get: { FavoritesExpansionState.shared.isFolderExpanded(folder.id, for: connectionId) }, - set: { FavoritesExpansionState.shared.setFolderExpanded(folder.id, expanded: $0, for: connectionId) } - ) - } - - private var linkedExpansion: Binding { - Binding( - get: { FavoritesExpansionState.shared.isLinkedNodeExpanded(node.id, for: connectionId) }, - set: { FavoritesExpansionState.shared.setLinkedNodeExpanded(node.id, expanded: $0, for: connectionId) } - ) - } - - @ViewBuilder - private func folderLabel(_ folder: SQLFavoriteFolder) -> some View { - if viewModel.renamingFolderId == folder.id { - HStack(spacing: 4) { - Image(systemName: "folder") - TextField( - "", - text: Binding( - get: { viewModel.renamingFolderName }, - set: { viewModel.renamingFolderName = $0 } - ) - ) - .textFieldStyle(.roundedBorder) - .accessibilityLabel(String(localized: "Folder name")) - .focused($isRenameFocused) - .onSubmit { viewModel.commitRenameFolder(folder) } - .onExitCommand { viewModel.renamingFolderId = nil } - .onAppear { isRenameFocused = true } - } - } else { - Label(folder.name, systemImage: "folder") - } - } -} diff --git a/TablePro/Views/Sidebar/FavoritesTreeFilter.swift b/TablePro/Views/Sidebar/FavoritesTreeFilter.swift new file mode 100644 index 000000000..6dc2a3664 --- /dev/null +++ b/TablePro/Views/Sidebar/FavoritesTreeFilter.swift @@ -0,0 +1,60 @@ +// +// FavoritesTreeFilter.swift +// TablePro +// + +import Foundation + +/// Filtering the favorites tree, lifted out of the view model so it can be tested directly. The +/// test suite had grown its own hand-copied duplicate of this because the original was private +/// inside a `@MainActor` observable class, and a duplicate is a test that stops proving anything +/// the moment the two drift. +/// +/// A folder survives when its own name matches or when anything under it does, so a hit never +/// leaves the user staring at a collapsed ancestor that looks empty. +internal enum FavoritesTreeFilter { + internal static func filterTree(_ items: [FavoriteNode], searchText: String) -> [FavoriteNode] { + items.compactMap { node in + switch node.content { + case .favorite(let fav): + if fav.name.localizedCaseInsensitiveContains(searchText) || + (fav.keyword?.localizedCaseInsensitiveContains(searchText) == true) || + fav.query.localizedCaseInsensitiveContains(searchText) { + return node + } + return nil + case .folder(let folder): + let filteredChildren = filterTree(node.children ?? [], searchText: searchText) + if !filteredChildren.isEmpty || + folder.name.localizedCaseInsensitiveContains(searchText) { + return .folder(folder, children: filteredChildren) + } + return nil + case .linkedFavorite(let linked): + if linked.name.localizedCaseInsensitiveContains(searchText) || + (linked.keyword?.localizedCaseInsensitiveContains(searchText) == true) || + linked.relativePath.localizedCaseInsensitiveContains(searchText) { + return node + } + return nil + case .linkedFolder(let folder): + let filteredChildren = filterTree(node.children ?? [], searchText: searchText) + if !filteredChildren.isEmpty || folder.name.localizedCaseInsensitiveContains(searchText) { + return .linkedFolder(folder, children: filteredChildren) + } + return nil + case .linkedSubfolder(let folderId, let displayName, let pathPrefix): + let filteredChildren = filterTree(node.children ?? [], searchText: searchText) + if !filteredChildren.isEmpty || displayName.localizedCaseInsensitiveContains(searchText) { + return .linkedSubfolder( + folderId: folderId, + displayName: displayName, + pathPrefix: pathPrefix, + children: filteredChildren + ) + } + return nil + } + } + } +} diff --git a/TablePro/Views/Sidebar/LinkedFavoriteRowView.swift b/TablePro/Views/Sidebar/LinkedFavoriteRowView.swift index 1808a567f..74d80cd1f 100644 --- a/TablePro/Views/Sidebar/LinkedFavoriteRowView.swift +++ b/TablePro/Views/Sidebar/LinkedFavoriteRowView.swift @@ -9,11 +9,6 @@ internal struct LinkedFavoriteRowView: View { let favorite: LinkedSQLFavorite var body: some View { - rowContent - .draggable(LinkedFavoriteTransfer(fileURL: favorite.fileURL)) - } - - private var rowContent: some View { HStack(spacing: 6) { Image(systemName: "doc.text") .font(.callout) diff --git a/TablePro/Views/Sidebar/RedisKeyTreeTruncation.swift b/TablePro/Views/Sidebar/RedisKeyTreeTruncation.swift new file mode 100644 index 000000000..1a788a1dc --- /dev/null +++ b/TablePro/Views/Sidebar/RedisKeyTreeTruncation.swift @@ -0,0 +1,14 @@ +// +// RedisKeyTreeTruncation.swift +// TablePro +// + +import Foundation + +/// The key tree stops at a fixed number of keys, and saying so is not the same as saying the +/// namespace is empty, so it gets its own row rather than reusing the empty placeholder. +internal enum RedisKeyTreeTruncation { + internal static func message(limit: Int) -> String { + String(format: String(localized: "Showing first %lld keys"), Int64(limit)) + } +} diff --git a/TablePro/Views/Sidebar/RedisKeyTreeView.swift b/TablePro/Views/Sidebar/RedisKeyTreeView.swift deleted file mode 100644 index 632b34a90..000000000 --- a/TablePro/Views/Sidebar/RedisKeyTreeView.swift +++ /dev/null @@ -1,118 +0,0 @@ -// -// RedisKeyTreeView.swift -// TablePro -// - -import SwiftUI - -internal struct RedisKeyTreeView: View { - let nodes: [RedisKeyNode] - let isLoading: Bool - let isTruncated: Bool - var onSelectNamespace: ((String) -> Void)? - var onSelectKey: ((String, String) -> Void)? - - var body: some View { - if isLoading { - HStack(spacing: 6) { - ProgressView() - .controlSize(.small) - Text(String(localized: "Loading keys\u{2026}")) - .foregroundStyle(.secondary) - .font(.caption) - } - .padding(.vertical, 4) - } else if nodes.isEmpty { - Text(String(localized: "No keys")) - .foregroundStyle(.secondary) - .font(.caption) - .padding(.vertical, 4) - } else { - OutlineGroup(nodes, children: \.children) { node in - row(for: node) - } - if isTruncated { - Text(String(localized: "Showing first 50,000 keys")) - .foregroundStyle(.secondary) - .font(.caption2) - .padding(.vertical, 2) - } - } - } - - @ViewBuilder - private func row(for node: RedisKeyNode) -> some View { - switch node { - case .namespace(let name, let fullPrefix, _, let keyCount): - Button { - onSelectNamespace?(fullPrefix) - } label: { - HStack { - Label(name, systemImage: "folder") - .sidebarRowIcon(visible: AppSettingsManager.shared.general.showObjectIcons) - .foregroundStyle(.primary) - Spacer() - Text("\(keyCount)") - .font(.caption2) - .foregroundStyle(.secondary) - .padding(.horizontal, 6) - .padding(.vertical, 1) - .background(.quaternary, in: Capsule()) - } - } - .buttonStyle(.plain) - .contextMenu { - Button(String(localized: "Copy Namespace Prefix")) { - ClipboardService.shared.writeText(fullPrefix) - } - } - .accessibilityElement(children: .combine) - .accessibilityLabel( - Text( - String( - format: String(localized: "%1$@, %2$lld keys"), - name, - Int64(keyCount) - ) - ) - ) - case .key(let name, let fullKey, let keyType): - Button { - onSelectKey?(fullKey, keyType) - } label: { - HStack { - Label(name, systemImage: keyTypeIcon(keyType)) - .sidebarRowIcon(visible: AppSettingsManager.shared.general.showObjectIcons) - .foregroundStyle(.primary) - Spacer() - Text(keyType) - .font(.caption2) - .foregroundStyle(.tertiary) - } - } - .buttonStyle(.plain) - .contextMenu { - Button(String(localized: "Copy Key")) { - ClipboardService.shared.writeText(fullKey) - } - Button(String(localized: "Open in New Tab")) { - onSelectKey?(fullKey, keyType) - } - } - .accessibilityElement(children: .combine) - .accessibilityLabel(Text(String(format: String(localized: "%1$@, %2$@"), name, keyType))) - } - } - - private func keyTypeIcon(_ type: String) -> String { - switch type.lowercased() { - case "string": return "textformat" - case "hash": return "square.grid.2x2" - case "list": return "list.bullet" - case "set": return "circle.grid.3x3" - case "zset": return "chart.bar" - case "stream": return "waveform" - default: return "key" - } - } -} diff --git a/TablePro/Views/Sidebar/SidebarContextMenu.swift b/TablePro/Views/Sidebar/SidebarContextMenu.swift index 35b939478..553a91a47 100644 --- a/TablePro/Views/Sidebar/SidebarContextMenu.swift +++ b/TablePro/Views/Sidebar/SidebarContextMenu.swift @@ -106,6 +106,17 @@ struct SidebarContextMenu: View { Divider() if clickedTable != nil { + /// Where pinning a preview tab lives now that one click opens. Double click used to + /// carry it, which only worked because the click waited half a second to find out + /// whether a second one was coming. + Button("Open in New Tab") { + perform { + if let clickedTable { + coordinator?.openTableTab(clickedTable, forceNewTab: true) + } + } + } + if isView { Button("Edit View Definition") { perform { diff --git a/TablePro/Views/Sidebar/SidebarOutlineView.swift b/TablePro/Views/Sidebar/SidebarOutlineView.swift new file mode 100644 index 000000000..f8a238480 --- /dev/null +++ b/TablePro/Views/Sidebar/SidebarOutlineView.swift @@ -0,0 +1,46 @@ +// +// SidebarOutlineView.swift +// TablePro +// + +import AppKit + +/// The focus contract both sidebar lists need, stated once. +/// +/// AppKit expects a view that can hold the keyboard to say so when it is clicked, which is why the +/// data grid's table view opens `mouseDown` with exactly this line (`KeyHandlingTableView`). The +/// sidebar lists were leaving it to `NSTableView`'s own promotion, and in the running app that +/// intermittently did not happen: the click moved the selection while the filter field kept the +/// keyboard, so the row drew unemphasized and the arrow keys went to the field rather than the list. +/// +/// Painting the row emphasized instead would be a lie, because the keyboard really was elsewhere. +/// `FieldDrivenList` may force emphasis on because its table refuses focus by design and its search +/// field forwards the keys; these lists are meant to hold focus, so they take it. +internal class SidebarOutlineView: NSOutlineView { + override internal func mouseDown(with event: NSEvent) { + claimFirstResponder() + super.mouseDown(with: event) + } + + override internal func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + adoptFirstResponderIfVacant() + } + + private func claimFirstResponder() { + guard let window, window.firstResponder !== self else { return } + window.makeFirstResponder(self) + } + + /// A window whose `firstResponder` is the window itself has nobody to hand a key event to, so + /// every keystroke beeps and every list draws its selection unemphasized. + /// + /// AppKit fills that vacancy when a window is first placed on screen, from + /// `initialFirstResponder` or the key view loop. Neither can reach a list SwiftUI has not built + /// yet, so the vacancy outlives the only chance AppKit had to fill it. Only the vacancy is + /// taken: a window that already has a first responder keeps it. + private func adoptFirstResponderIfVacant() { + guard let window, window.firstResponder === window else { return } + window.makeFirstResponder(self) + } +} diff --git a/TablePro/Views/Sidebar/SidebarRootShape.swift b/TablePro/Views/Sidebar/SidebarRootShape.swift new file mode 100644 index 000000000..5ff6935c3 --- /dev/null +++ b/TablePro/Views/Sidebar/SidebarRootShape.swift @@ -0,0 +1,37 @@ +// +// SidebarRootShape.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +/// Which shape the sidebar's object outline takes at its root. +/// +/// The three shapes used to be three separate views, two of them SwiftUI `List`s. A `List` hosted in +/// a bare `NSHostingController`, which is what this app has since it runs an AppKit lifecycle with no +/// SwiftUI scene, never answers a key equivalent and never draws an emphasized selection: its +/// backing view does not even respond to `moveDown:`. So all three shapes are one `NSOutlineView` +/// now, and this is the only thing that differs between them. +internal enum SidebarRootShape: Equatable { + /// Object-kind sections: Tables, Views, Procedures and the rest, plus Recent and Redis keys. + case flat + /// Schema sections with lazily loaded tables, for engines that have no database dimension. + case hierarchicalSchema + /// Databases, then schemas, then objects. + case databaseTree +} + +internal enum SidebarRootShapeResolver { + /// `supportsDatabaseTree` arrives already reduced rather than as its three constituent plugin + /// lookups, so this stays a pure function of plain values and needs no plugin registry to test. + internal static func resolve( + groupingStrategy: GroupingStrategy, + sidebarLayout: SidebarLayout, + supportsDatabaseTree: Bool + ) -> SidebarRootShape { + if groupingStrategy == .hierarchicalSchema { return .hierarchicalSchema } + if supportsDatabaseTree, sidebarLayout == .tree { return .databaseTree } + return .flat + } +} diff --git a/TablePro/Views/Sidebar/SidebarTreeView.swift b/TablePro/Views/Sidebar/SidebarTreeView.swift index 242116712..06294fceb 100644 --- a/TablePro/Views/Sidebar/SidebarTreeView.swift +++ b/TablePro/Views/Sidebar/SidebarTreeView.swift @@ -10,7 +10,6 @@ struct SidebarTreeView: View { var sidebarState: SharedSidebarState @Binding var pendingTruncates: Set @Binding var pendingDeletes: Set - var onDoubleClick: ((TableInfo) -> Void)? weak var coordinator: MainContentCoordinator? @State private var settingsManager = AppSettingsManager.shared @@ -21,12 +20,6 @@ struct SidebarTreeView: View { return name.isEmpty ? nil : name } - private var recentRows: [RecentTableRow] { - guard settingsManager.general.showRecentTables else { return [] } - let infos = sidebarState.recentEntries(inDatabase: activeDatabase).map(\.tableInfo) - return viewModel.filteredRecentTables(infos).map(RecentTableRow.init) - } - private var systemSchemas: Set { Set(PluginManager.shared.systemSchemaNames(for: viewModel.databaseType)) } @@ -44,13 +37,6 @@ struct SidebarTreeView: View { return schemas.filter { schemaIsVisibleDuringSearch($0) } } - private var selectedTablesBinding: Binding> { - Binding( - get: { windowState.selectedTables }, - set: { windowState.selectedTables = $0 } - ) - } - var body: some View { Group { if schemas.isEmpty { @@ -66,144 +52,27 @@ struct SidebarTreeView: View { } } + /// Same outline the other two sidebar shapes use. See `SidebarView.tableList` for why a SwiftUI + /// `List` cannot serve here. private var treeList: some View { - List(selection: selectedTablesBinding) { - recentSection - ForEach(visibleSchemas, id: \.self) { schema in - Section(isExpanded: expansionBinding(for: schema)) { - datasetContent(for: schema) - } header: { - datasetHeader(schema) - } - } - } - .sidebarListLayout() - .contextMenu(forSelectionType: TableInfo.self) { _ in - EmptyView() - } primaryAction: { selection in - guard let table = selection.first else { return } - onDoubleClick?(table) - } - .onExitCommand { - windowState.selectedTables.removeAll() - } - } - - @ViewBuilder - private func datasetContent(for schema: String) -> some View { - switch schemaService.schemaState(for: connectionId, schema: schema) { - case .idle, .loading: - HStack(spacing: 6) { - ProgressView() - .controlSize(.small) - Text(String(localized: "Loading tables\u{2026}")) - .font(.caption) - .foregroundStyle(.secondary) - } - .padding(.vertical, 4) - case .failed(let message): - Label(message, systemImage: "exclamationmark.triangle") - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(2) - .padding(.vertical, 4) - case .loaded: - let tables = tablesToShow(for: schema) - if tables.isEmpty { - Text(String(localized: "No tables")) - .font(.caption) - .foregroundStyle(.secondary) - .padding(.vertical, 4) - } else { - ForEach(tables) { table in - tableRow(table) - } - } - } - } - - private func tableRow(_ table: TableInfo) -> some View { - TableRow( - table: table, - isPendingTruncate: pendingTruncates.contains(table.name), - isPendingDelete: pendingDeletes.contains(table.name) - ) - .tag(table) - .contextMenu { - tableContextMenu(table) - Divider() - SidebarViewOptionsMenu() - } - } - - @ViewBuilder - private func tableContextMenu(_ table: TableInfo) -> some View { - SidebarContextMenu( - clickedTable: table, + DatabaseTreeOutlineView( + connectionId: connectionId, + databaseType: viewModel.databaseType, + coordinator: coordinator, + windowState: windowState, + sidebarState: sidebarState, + viewModel: viewModel, + pendingTruncates: pendingTruncates, + pendingDeletes: pendingDeletes, + searchText: viewModel.filterQuery, + connectionToken: connectionId.uuidString, + activeDatabase: activeDatabase, + activeSchema: coordinator?.toolbarState.currentSchema, selectedTables: windowState.selectedTables, - isReadOnly: coordinator?.safeModeLevel.blocksAllWrites ?? false, - onBatchToggleTruncate: { viewModel.batchToggleTruncate(tableNames: $0) }, - onBatchToggleDelete: { viewModel.batchToggleDelete(tableNames: $0) }, - coordinator: coordinator + showRecentTables: settingsManager.general.showRecentTables ) } - @ViewBuilder - private var recentSection: some View { - let rows = recentRows - if !rows.isEmpty { - Section(isExpanded: recentsExpansionBinding) { - ForEach(rows) { row in - let table = row.table - TableRow( - table: table, - isPendingTruncate: pendingTruncates.contains(table.name), - isPendingDelete: pendingDeletes.contains(table.name) - ) - .selectionDisabled() - .contentShape(Rectangle()) - .onTapGesture { - onDoubleClick?(table) - } - .contextMenu { - tableContextMenu(table) - Divider() - Button(String(localized: "Remove from Recent")) { - sidebarState.removeRecentTable( - database: activeDatabase, schema: table.schema, name: table.name - ) - } - Button(String(localized: "Clear Recent Tables")) { - sidebarState.clearRecentTables(inDatabase: activeDatabase) - } - Divider() - SidebarViewOptionsMenu() - } - } - } header: { - Text(String(localized: "Recent")) - } - } - } - - private var recentsExpansionBinding: Binding { - Binding( - get: { viewModel.isRecentsExpanded }, - set: { viewModel.isRecentsExpanded = $0 } - ) - } - - private func datasetHeader(_ schema: String) -> some View { - Text(schema) - .contextMenu { - Button(String(localized: "Refresh")) { - reloadTables(for: schema) - } - Divider() - SidebarViewOptionsMenu() - } - } - private var emptyDatasetsState: some View { ContentUnavailableView( String(localized: "No Datasets"), @@ -218,36 +87,17 @@ struct SidebarTreeView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } - private func expansionBinding(for schema: String) -> Binding { - Binding( - get: { !searchText.isEmpty || windowState.expandedTreeSchemas.contains(schema) }, - set: { isExpanded in - if isExpanded { - windowState.expandedTreeSchemas.insert(schema) - loadTables(for: schema) - } else { - windowState.expandedTreeSchemas.remove(schema) - } - } - ) - } - - private func tablesToShow(for schema: String) -> [TableInfo] { - let tables = schemaService.tables(for: connectionId, schema: schema) - guard !searchText.isEmpty, !SidebarNameFilter.matches(query: searchText, candidate: schema) else { - return tables - } - return SidebarNameFilter.ranked(tables, query: searchText, name: { $0.name }) - } - + /// The same rule the outline applies, so the empty state and the rows can never disagree about + /// whether a schema survived the filter. private func schemaIsVisibleDuringSearch(_ schema: String) -> Bool { - if SidebarNameFilter.matches(query: searchText, candidate: schema) { return true } - switch schemaService.schemaState(for: connectionId, schema: schema) { - case .loaded: - return !tablesToShow(for: schema).isEmpty - case .idle, .loading, .failed: - return true - } + var isLoaded = false + if case .loaded = schemaService.schemaState(for: connectionId, schema: schema) { isLoaded = true } + return DatabaseTreeFilter.hierarchicalSchemaIsVisible( + schema, + searchText: searchText, + isLoaded: isLoaded, + tables: schemaService.tables(for: connectionId, schema: schema) + ) } private func loadTables(for schema: String) { diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index b86aad341..2ef2836fa 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -10,7 +10,6 @@ import TableProPluginKit struct SidebarView: View { @State private var viewModel: SidebarViewModel - @State private var favoriteTables: Set = [] @State private var settingsManager = AppSettingsManager.shared @State private var showDatabaseFilter: Bool = false @@ -21,7 +20,6 @@ struct SidebarView: View { @Binding var pendingTruncates: Set @Binding var pendingDeletes: Set - var onDoubleClick: ((TableInfo) -> Void)? var connectionId: UUID private weak var coordinator: MainContentCoordinator? @@ -52,17 +50,9 @@ struct SidebarView: View { return groupingStrategy != .hierarchicalSchema && !usesDatabaseTree } - private var selectedTablesBinding: Binding> { - Binding( - get: { windowState.selectedTables }, - set: { windowState.selectedTables = $0 } - ) - } - init( sidebarState: SharedSidebarState, windowState: WindowSidebarState, - onDoubleClick: ((TableInfo) -> Void)? = nil, pendingTruncates: Binding>, pendingDeletes: Binding>, tableOperationOptions: Binding<[String: TableOperationOptions]>, @@ -72,7 +62,6 @@ struct SidebarView: View { ) { self.sidebarState = sidebarState self.windowState = windowState - self.onDoubleClick = onDoubleClick _pendingTruncates = pendingTruncates _pendingDeletes = pendingDeletes let selectedBinding = Binding( @@ -122,6 +111,9 @@ struct SidebarView: View { .onChange(of: sidebarState.searchText) { _, newValue in viewModel.searchText = newValue } + .onChange(of: settingsManager.general.showRecentTables) { _, _ in + sidebarState.reloadRecentTablesFromStore() + } .onAppear { coordinator?.sidebarViewModel = viewModel if let driver = DatabaseManager.shared.driver(for: connectionId), @@ -279,7 +271,6 @@ struct SidebarView: View { sidebarState: sidebarState, pendingTruncates: $pendingTruncates, pendingDeletes: $pendingDeletes, - onDoubleClick: onDoubleClick, coordinator: coordinator ) } @@ -353,235 +344,28 @@ struct SidebarView: View { // MARK: - Table List - private var recentRows: [RecentTableRow] { - guard settingsManager.general.showRecentTables else { return [] } - let infos = sidebarState.recentEntries(inDatabase: activeDatabase).map(\.tableInfo) - return viewModel.filteredRecentTables(infos).map(RecentTableRow.init) - } - private var activeDatabase: String? { let name = coordinator?.browseDatabaseName ?? "" return name.isEmpty ? nil : name } - private func isFavorite(_ table: TableInfo) -> Bool { - favoriteTables.contains(FavoriteTablesStorage.FavoriteEntry( - connectionId: connectionId, - database: activeDatabase, - schema: table.schema, - name: table.name - )) - } - - private func toggleFavorite(_ table: TableInfo) { - FavoriteTablesStorage.shared.toggle( - name: table.name, - schema: table.schema, - database: activeDatabase, - connectionId: connectionId - ) - } - - @ViewBuilder - private func tableSelectionMenu(clicked: TableInfo?, selected: Set) -> some View { - SidebarContextMenu( - clickedTable: clicked, - selectedTables: selected, - isReadOnly: coordinator?.safeModeLevel.blocksAllWrites ?? false, - onBatchToggleTruncate: { viewModel.batchToggleTruncate(tableNames: $0) }, - onBatchToggleDelete: { viewModel.batchToggleDelete(tableNames: $0) }, - coordinator: coordinator - ) - } - - @ViewBuilder - private var recentSection: some View { - let rows = recentRows - if !rows.isEmpty { - Section(isExpanded: $viewModel.isRecentsExpanded) { - ForEach(rows) { row in - let table = row.table - TableRow( - table: table, - isPendingTruncate: pendingTruncates.contains(table.name), - isPendingDelete: pendingDeletes.contains(table.name), - isFavorite: isFavorite(table), - onToggleFavorite: { toggleFavorite(table) } - ) - .selectionDisabled() - .contentShape(Rectangle()) - .onTapGesture { - onDoubleClick?(table) - } - .contextMenu { - tableSelectionMenu(clicked: table, selected: [table]) - Divider() - Button(String(localized: "Remove from Recent")) { - sidebarState.removeRecentTable( - database: activeDatabase, schema: table.schema, name: table.name - ) - } - Button(String(localized: "Clear Recent Tables")) { - sidebarState.clearRecentTables(inDatabase: activeDatabase) - } - Divider() - SidebarViewOptionsMenu() - } - } - } header: { - Text(String(localized: "Recent")) - } - } - } - private var tableList: some View { - List(selection: selectedTablesBinding) { - recentSection - - ForEach(SidebarObjectKind.allCases, id: \.self) { kind in - sectionView(for: kind) - } - - if viewModel.databaseType == .redis, let keyTreeVM = sidebarState.redisKeyTreeViewModel { - Section(isExpanded: $viewModel.isRedisKeysExpanded) { - RedisKeyTreeView( - nodes: keyTreeVM.displayNodes(searchText: viewModel.filterQuery), - isLoading: keyTreeVM.isLoading, - isTruncated: keyTreeVM.isTruncated, - onSelectNamespace: { prefix in - coordinator?.browseRedisNamespace(prefix) - }, - onSelectKey: { key, keyType in - coordinator?.openRedisKey(key, keyType: keyType) - } - ) - } header: { - Text(String(localized: "Keys")) - } - } - } - .sidebarListLayout() - .contextMenu(forSelectionType: TableInfo.self) { selection in - SidebarContextMenu( - clickedTable: selection.first, - selectedTables: selection, - isReadOnly: coordinator?.safeModeLevel.blocksAllWrites ?? false, - onBatchToggleTruncate: { viewModel.batchToggleTruncate(tableNames: $0) }, - onBatchToggleDelete: { viewModel.batchToggleDelete(tableNames: $0) }, - coordinator: coordinator - ) - Divider() - SidebarViewOptionsMenu() - } primaryAction: { selection in - guard let table = selection.first else { return } - onDoubleClick?(table) - } - .onExitCommand { - windowState.selectedTables.removeAll() - } - .onReceive(NotificationCenter.default.publisher(for: .favoriteTablesDidChange)) { _ in - favoriteTables = FavoriteTablesStorage.shared.favorites(for: connectionId) - } - .onChange(of: settingsManager.general.showRecentTables) { _, _ in - sidebarState.reloadRecentTablesFromStore() - } - .onAppear { - favoriteTables = FavoriteTablesStorage.shared.favorites(for: connectionId) - } - } - - // MARK: - Section View - - @ViewBuilder - private func sectionView(for kind: SidebarObjectKind) -> some View { - let count = countFor(kind: kind) - if viewModel.sectionShouldRender(kind: kind, itemCount: count, capabilities: pluginCapabilities) { - let isExpanded = sectionExpandedBinding(kind: kind, hasMatches: count > 0) - Section(isExpanded: isExpanded) { - sectionRows(for: kind) - } header: { - sectionHeader(for: kind) - } - } - } - - private func sectionExpandedBinding(kind: SidebarObjectKind, hasMatches: Bool) -> Binding { - Binding( - get: { viewModel.effectiveExpanded(kind: kind, hasMatches: hasMatches) }, - set: { viewModel.expanded[kind] = $0 } - ) - } - - @ViewBuilder - private func sectionRows(for kind: SidebarObjectKind) -> some View { - if kind.isRoutine { - ForEach(viewModel.filteredRoutines(of: kind, from: routines)) { routine in - RoutineRowView(routine: routine) - .tag(routine) - .contextMenu { - RoutineContextMenu(routine: routine) { selected in - coordinator?.showRoutineDDL(selected) - } - Divider() - SidebarViewOptionsMenu() - } - } - } else { - ForEach(viewModel.filteredTables(of: kind, from: tables)) { table in - TableRow( - table: table, - isPendingTruncate: pendingTruncates.contains(table.name), - isPendingDelete: pendingDeletes.contains(table.name), - isFavorite: isFavorite(table), - onToggleFavorite: { toggleFavorite(table) } - ) - .tag(table) - } - } - } - - private func sectionHeader(for kind: SidebarObjectKind) -> some View { - let title = sectionTitle(for: kind) - let helpLabel = String( - format: String(localized: "Right-click to show all %@"), - title.lowercased() + DatabaseTreeOutlineView( + connectionId: connectionId, + databaseType: viewModel.databaseType, + coordinator: coordinator, + windowState: windowState, + sidebarState: sidebarState, + viewModel: viewModel, + pendingTruncates: pendingTruncates, + pendingDeletes: pendingDeletes, + searchText: viewModel.filterQuery, + connectionToken: connectionId.uuidString, + activeDatabase: activeDatabase, + activeSchema: coordinator?.toolbarState.currentSchema, + selectedTables: windowState.selectedTables, + showRecentTables: settingsManager.general.showRecentTables ) - return Text(title) - .help(helpLabel) - .contextMenu { - sectionHeaderMenu(for: kind, title: title) - Divider() - SidebarViewOptionsMenu() - } - } - - @ViewBuilder - private func sectionHeaderMenu(for kind: SidebarObjectKind, title: String) -> some View { - if !kind.isRoutine { - Button(String(format: String(localized: "Show All %@"), title)) { - if kind == .table { - coordinator?.showAllTablesMetadata() - } - } - .disabled(kind != .table) - } - Button(String(localized: "Refresh")) { - switch kind { - case .procedure: - Task { await coordinator?.refreshProcedures() } - case .function: - Task { await coordinator?.refreshFunctions() } - default: - Task { await coordinator?.refreshTables() } - } - } - } - - private func sectionTitle(for kind: SidebarObjectKind) -> String { - if kind == .table { - return PluginManager.shared.tableEntityName(for: viewModel.databaseType) - } - return kind.pluralDisplayName } private func countFor(kind: SidebarObjectKind) -> Int { diff --git a/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift b/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift index 87a37c399..652d5987a 100644 --- a/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift +++ b/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift @@ -26,6 +26,15 @@ enum ConnectionSwitcherSelection { } } +/// The two sections list different things, a live session and a saved record, but the list shows one +/// kind of row, so they are resolved into one before they reach it. +struct ConnectionSwitcherEntry: Identifiable { + let id: UUID + let connection: DatabaseConnection + let isActive: Bool + let isConnected: Bool +} + struct ConnectionSwitcherPopover: View { @Environment(\.dismiss) private var dismiss @@ -112,51 +121,52 @@ struct ConnectionSwitcherPopover: View { } } - private var list: some View { - ScrollViewReader { proxy in - List(selection: $selectedConnectionId) { - if !filteredSessions.isEmpty { - Section { - ForEach(filteredSessions) { session in - connectionRow( - connection: session.connection, - isActive: session.id == currentSessionId, - isConnected: session.status.isConnected - ) - .tag(session.id) - .id(session.id) - } - } header: { - Text("ACTIVE CONNECTIONS") - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - } + private var sections: [FieldDrivenListSection] { + [ + FieldDrivenListSection( + id: "active", + title: String(localized: "ACTIVE CONNECTIONS"), + items: filteredSessions.map { + ConnectionSwitcherEntry( + id: $0.id, + connection: $0.connection, + isActive: $0.id == currentSessionId, + isConnected: $0.status.isConnected + ) } - - if !filteredSaved.isEmpty { - Section { - ForEach(filteredSaved) { connection in - connectionRow(connection: connection, isActive: false, isConnected: false) - .tag(connection.id) - .id(connection.id) - } - } header: { - Text("SAVED CONNECTIONS") - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - } - } - } - .listStyle(.sidebar) - .scrollContentBackground(.hidden) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .onChange(of: selectedConnectionId) { _, newValue in - guard let id = newValue else { return } - withMotion(.easeInOut(duration: 0.15)) { - proxy.scrollTo(id) + ), + FieldDrivenListSection( + id: "saved", + title: String(localized: "SAVED CONNECTIONS"), + items: filteredSaved.map { + ConnectionSwitcherEntry(id: $0.id, connection: $0, isActive: false, isConnected: false) } + ), + ] + } + + /// The search field keeps focus for the whole flow, so the list is a presentation of that + /// field's selection rather than a second focusable control. See `FieldDrivenList`. + private var list: some View { + FieldDrivenList( + sections: sections, + selection: Binding( + get: { selectedConnectionId.map { [$0] } ?? [] }, + set: { selectedConnectionId = $0.first } + ), + rowHeight: 40, + usesSourceListStyle: true, + onSingleClickAction: { activate(connectionId: $0) }, + onPrimaryAction: { activate(connectionId: $0) }, + row: { entry in + connectionRow( + connection: entry.connection, + isActive: entry.isActive, + isConnected: entry.isConnected + ) } - } + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) } private var emptyState: some View { @@ -251,9 +261,9 @@ struct ConnectionSwitcherPopover: View { .padding(.vertical, 2) .background(Color(nsColor: .separatorColor), in: RoundedRectangle(cornerRadius: 3)) } + .padding(.horizontal, 6) .padding(.vertical, 2) .contentShape(Rectangle()) - .onTapGesture { activate(connectionId: connection.id) } } // MARK: - Selection diff --git a/TablePro/Views/Toolbar/TableProToolbarView.swift b/TablePro/Views/Toolbar/TableProToolbarView.swift index 608f3e33f..5b2225753 100644 --- a/TablePro/Views/Toolbar/TableProToolbarView.swift +++ b/TablePro/Views/Toolbar/TableProToolbarView.swift @@ -95,13 +95,19 @@ struct ToolbarPrincipalContent: View { } } + /// A tag with no colour fills with `clear`, and a label derived from a transparent fill comes + /// back white and disappears, so an uncoloured tag takes the standard control fill instead of + /// a derived one. private func tagBadge(_ tag: ConnectionTag) -> some View { Text(tag.name.uppercased()) .font(.caption.weight(.semibold)) - .foregroundStyle(Color.legibleForeground(on: tag.color.color)) + .foregroundStyle(tag.color.isDefault ? .primary : Color.legibleForeground(on: tag.color.color)) .lineLimit(1) .padding(.horizontal, 8) .padding(.vertical, 3) - .background(tag.color.color, in: Capsule()) + .background( + tag.color.isDefault ? Color(nsColor: .quaternarySystemFill) : tag.color.color, + in: Capsule() + ) } } diff --git a/TableProTests/Core/Utilities/DestructiveAlertDefaultsTests.swift b/TableProTests/Core/Utilities/DestructiveAlertDefaultsTests.swift index 94ba66aa1..e186689bb 100644 --- a/TableProTests/Core/Utilities/DestructiveAlertDefaultsTests.swift +++ b/TableProTests/Core/Utilities/DestructiveAlertDefaultsTests.swift @@ -10,19 +10,23 @@ import Testing @Suite("Destructive alert defaults") @MainActor struct DestructiveAlertDefaultsTests { - private func returnKeyButtonCount(_ alert: NSAlert) -> Int { - alert.buttons.filter { $0.keyEquivalent == "\r" }.count + private static let escape = "\u{1B}" + private static let returnKey = "\r" + + private func buttonCount(_ alert: NSAlert, withKeyEquivalent key: String) -> Int { + alert.buttons.filter { $0.keyEquivalent == key }.count } // MARK: - Inspector Delete - @Test("Inspector delete alert makes cancel the default button") - func inspectorDeleteDefaultsToCancel() { + @Test("Inspector delete alert keeps Escape on cancel and takes Return off delete") + func inspectorDeleteBindings() { let alert = InspectorDeleteConfirmation.makeAlert(messageText: "Delete this row?") #expect(alert.buttons.count == 2) - #expect(alert.buttons[0].keyEquivalent == "") - #expect(alert.buttons[1].keyEquivalent == "\r") - #expect(returnKeyButtonCount(alert) == 1) + #expect(alert.buttons[0].keyEquivalent != Self.returnKey) + #expect(alert.buttons[1].keyEquivalent == Self.escape) + #expect(buttonCount(alert, withKeyEquivalent: Self.returnKey) == 0) + #expect(buttonCount(alert, withKeyEquivalent: Self.escape) == 1) } @Test("Inspector delete alert marks the delete button destructive") @@ -33,24 +37,24 @@ struct DestructiveAlertDefaultsTests { // MARK: - External Connection - @Test("External connection alert makes cancel the default button") - func externalConnectionDefaultsToCancel() { + @Test("External connection alert keeps Escape on cancel and takes Return off connect") + func externalConnectionBindings() { let connection = DatabaseConnection(name: "External", type: .mysql) let alert = ExternalConnectionAlertPrompt.makeAlert(for: connection, offerAlwaysAllow: false) #expect(alert.buttons.count == 2) #expect(alert.buttons[0].keyEquivalent == "") - #expect(alert.buttons[1].keyEquivalent == "\r") - #expect(returnKeyButtonCount(alert) == 1) + #expect(alert.buttons[1].keyEquivalent == Self.escape) + #expect(buttonCount(alert, withKeyEquivalent: Self.returnKey) == 0) } - @Test("Always Allow does not take the return key") + @Test("Always Allow does not take the return key and Escape survives") func externalConnectionWithAlwaysAllow() { let connection = DatabaseConnection(name: "External", type: .postgresql) let alert = ExternalConnectionAlertPrompt.makeAlert(for: connection, offerAlwaysAllow: true) #expect(alert.buttons.count == 3) - #expect(alert.buttons[1].keyEquivalent == "\r") - #expect(alert.buttons[2].keyEquivalent == "") - #expect(returnKeyButtonCount(alert) == 1) + #expect(alert.buttons[1].keyEquivalent == Self.escape) + #expect(alert.buttons[2].keyEquivalent != Self.returnKey) + #expect(buttonCount(alert, withKeyEquivalent: Self.returnKey) == 0) } @Test("Connecting is not presented as a destructive action") @@ -62,8 +66,8 @@ struct DestructiveAlertDefaultsTests { // MARK: - Table Operations - @Test("Drop alert makes cancel the default button") - func dropAlertDefaultsToCancel() { + @Test("Drop alert keeps Escape on cancel and takes Return off drop") + func dropAlertBindings() { let alert = NSAlert() AlertHelper.addConfirmAndCancel( to: alert, @@ -76,9 +80,19 @@ struct DestructiveAlertDefaultsTests { ).confirmButtonTitle, cancelButton: String(localized: "Cancel") ) - #expect(alert.buttons[0].keyEquivalent == "") #expect(alert.buttons[0].hasDestructiveAction) - #expect(alert.buttons[1].keyEquivalent == "\r") - #expect(returnKeyButtonCount(alert) == 1) + #expect(alert.buttons[0].keyEquivalent != Self.returnKey) + #expect(alert.buttons[1].keyEquivalent == Self.escape) + #expect(buttonCount(alert, withKeyEquivalent: Self.returnKey) == 0) + } + + /// The binding is written rather than inferred, because `NSAlert` only recognises a cancel + /// button by its English title and a localized build stops matching. + @Test("A localized cancel title still carries Escape") + func localizedCancelKeepsEscape() { + let alert = NSAlert() + AlertHelper.addConfirmAndCancel(to: alert, confirmButton: "Drop", cancelButton: "Huỷ") + #expect(alert.buttons[1].keyEquivalent == Self.escape) + #expect(buttonCount(alert, withKeyEquivalent: Self.escape) == 1) } } diff --git a/TableProTests/Theme/LegibleForegroundTests.swift b/TableProTests/Theme/LegibleForegroundTests.swift index 4c35b9dc7..757e5443e 100644 --- a/TableProTests/Theme/LegibleForegroundTests.swift +++ b/TableProTests/Theme/LegibleForegroundTests.swift @@ -9,11 +9,25 @@ import SwiftUI import Testing @Suite("Legible foreground") +@MainActor struct LegibleForegroundTests { + private static let appearances: [NSAppearance.Name] = [ + .aqua, + .darkAqua, + .accessibilityHighContrastAqua, + .accessibilityHighContrastDarkAqua, + ] + private func isBlack(on fill: Color) -> Bool { NSColor(Color.legibleForeground(on: fill)).relativeLuminance < 0.5 } + private func contrast(of fill: Color) -> CGFloat { + let foreground = NSColor(Color.legibleForeground(on: fill)).relativeLuminance + let background = NSColor(fill).relativeLuminance + return (max(foreground, background) + 0.05) / (min(foreground, background) + 0.05) + } + @Test("Dark fills take white text") func darkFillsTakeWhite() { #expect(isBlack(on: .black) == false) @@ -28,18 +42,40 @@ struct LegibleForegroundTests { #expect(isBlack(on: .mint)) } - @Test("Every tag palette colour keeps a readable label") - func tagPaletteStaysReadable() { + /// The palette is the one a tag or a connection can actually be given, resolved in every + /// appearance the app can render in. `.gray` used to be the one colour left out, and it is the + /// default a new tag gets. + @Test("Every connection palette colour keeps a readable label in every appearance") + func connectionPaletteStaysReadable() { + for name in Self.appearances { + guard let appearance = NSAppearance(named: name) else { continue } + appearance.performAsCurrentDrawingAppearance { + for color in ConnectionColor.allCases where !color.isDefault { + #expect( + contrast(of: color.color) >= 3.0, + "\(color.rawValue) does not reach 3:1 against its label in \(name.rawValue)" + ) + } + } + } + } + + @Test("Semantic fills used behind derived labels stay readable") + func semanticFillsStayReadable() { let palette: [Color] = [.red, .orange, .yellow, .green, .mint, .teal, .blue, .indigo, .purple, .pink, .brown] for fill in palette { - let foreground = NSColor(Color.legibleForeground(on: fill)).relativeLuminance - let background = NSColor(fill).relativeLuminance - let lighter = max(foreground, background) + 0.05 - let darker = min(foreground, background) + 0.05 - #expect(lighter / darker >= 3.0, "\(fill) does not reach 3:1 against its label") + #expect(contrast(of: fill) >= 3.0, "\(fill) does not reach 3:1 against its label") } } + /// A fill the caller cannot see through is the only thing this function can reason about, so a + /// transparent one has to be kept away from it by the call site rather than guessed at here. + @Test("The uncoloured palette entry is transparent and is excluded by callers") + func defaultPaletteEntryIsTransparent() { + #expect(ConnectionColor.none.isDefault) + #expect(NSColor(ConnectionColor.none.color).alphaComponent == 0) + } + @Test("Relative luminance is ordered") func luminanceOrdering() { #expect(NSColor.black.relativeLuminance < NSColor.white.relativeLuminance) diff --git a/TableProTests/Theme/ThemeSlotValidationTests.swift b/TableProTests/Theme/ThemeSlotValidationTests.swift index 9af591cda..fa5b0167c 100644 --- a/TableProTests/Theme/ThemeSlotValidationTests.swift +++ b/TableProTests/Theme/ThemeSlotValidationTests.swift @@ -33,55 +33,33 @@ struct ThemeSlotValidationTests { return copy } - @Test("A contradicting slot re-anchors to the default") - func contradictingSlotReanchors() { - let themes = [theme("light", .light), theme("dark", .dark)] - let resolved = ThemeSlotValidation.resolvedThemeId( - current: "dark", - slot: .light, - themes: themes, - defaultId: "light" - ) - #expect(resolved == "light") + private var sample: [ThemeDefinition] { + [theme("light", .light), theme("dark", .dark), theme("auto", .auto)] } - @Test("A matching slot is left alone") - func matchingSlotUntouched() { - let themes = [theme("light", .light), theme("dark", .dark)] - let resolved = ThemeSlotValidation.resolvedThemeId( - current: "light", - slot: .light, - themes: themes, - defaultId: "light" - ) - #expect(resolved == "light") + @Test("Only fitting themes stay in the list") + func listIsFiltered() { + let eligible = ThemeSlotValidation.eligibleThemes(sample, slot: .light, keeping: nil) + #expect(eligible.map(\.id) == ["light", "auto"]) } - @Test("An auto theme survives either slot") - func autoThemeSurvives() { - let themes = [theme("auto", .auto)] - #expect( - ThemeSlotValidation.resolvedThemeId( - current: "auto", slot: .dark, themes: themes, defaultId: "dark" - ) == "auto" - ) + /// The row the user is standing on can never be filtered away, because the alternative was to + /// rewrite their saved theme so the filter came out true. + @Test("A contradicting theme stays listed while it is the one selected") + func selectedContradictingThemeIsKept() { + let eligible = ThemeSlotValidation.eligibleThemes(sample, slot: .light, keeping: "dark") + #expect(eligible.map(\.id) == ["light", "dark", "auto"]) } - @Test("An unknown theme id falls back to the default") - func unknownIdFallsBack() { - let resolved = ThemeSlotValidation.resolvedThemeId( - current: "does.not.exist", - slot: .dark, - themes: [theme("dark", .dark)], - defaultId: "dark" - ) - #expect(resolved == "dark") + @Test("Keeping a selection does not duplicate a theme that already fits") + func keptSelectionIsNotDuplicated() { + let eligible = ThemeSlotValidation.eligibleThemes(sample, slot: .light, keeping: "light") + #expect(eligible.map(\.id) == ["light", "auto"]) } - @Test("Only fitting themes stay in the list") - func listIsFiltered() { - let themes = [theme("light", .light), theme("dark", .dark), theme("auto", .auto)] - let eligible = ThemeSlotValidation.eligibleThemes(themes, slot: .light) - #expect(eligible.map(\.id) == ["light", "auto"]) + @Test("An unknown selected id adds nothing to the list") + func unknownSelectionAddsNothing() { + let eligible = ThemeSlotValidation.eligibleThemes(sample, slot: .dark, keeping: "does.not.exist") + #expect(eligible.map(\.id) == ["dark", "auto"]) } } diff --git a/TableProTests/ViewModels/FavoritesSidebarViewModelTests.swift b/TableProTests/ViewModels/FavoritesSidebarViewModelTests.swift index 45229c0c0..601bfd744 100644 --- a/TableProTests/ViewModels/FavoritesSidebarViewModelTests.swift +++ b/TableProTests/ViewModels/FavoritesSidebarViewModelTests.swift @@ -171,7 +171,7 @@ struct FavoriteNodeTests { let fav2 = makeFavorite(name: "Sales Data") let nodes: [FavoriteNode] = [.favorite(fav1), .favorite(fav2)] - let filtered = filterTree(nodes, searchText: "user") + let filtered = FavoritesTreeFilter.filterTree(nodes, searchText: "user") #expect(filtered.count == 1) if let first = filtered.first?.asFavorite { #expect(first.id == fav1.id) @@ -184,7 +184,7 @@ struct FavoriteNodeTests { let fav2 = makeFavorite(name: "B", keyword: "sls") let nodes: [FavoriteNode] = [.favorite(fav1), .favorite(fav2)] - let filtered = filterTree(nodes, searchText: "usr") + let filtered = FavoritesTreeFilter.filterTree(nodes, searchText: "usr") #expect(filtered.count == 1) } @@ -194,7 +194,7 @@ struct FavoriteNodeTests { let fav2 = makeFavorite(name: "B", query: "INSERT INTO logs") let nodes: [FavoriteNode] = [.favorite(fav1), .favorite(fav2)] - let filtered = filterTree(nodes, searchText: "large_table") + let filtered = FavoritesTreeFilter.filterTree(nodes, searchText: "large_table") #expect(filtered.count == 1) } @@ -206,7 +206,7 @@ struct FavoriteNodeTests { .folder(folder, children: [.favorite(fav)]) ] - let filtered = filterTree(nodes, searchText: "matching") + let filtered = FavoritesTreeFilter.filterTree(nodes, searchText: "matching") #expect(filtered.count == 1) if let first = filtered.first, let children = first.children { #expect(children.count == 1) @@ -267,50 +267,4 @@ struct FavoriteNodeTests { #expect(folders.contains { $0.id == folder2.id }) } - // MARK: - Private helpers (duplicated from ViewModel for testing) - - private func filterTree(_ items: [FavoriteNode], searchText: String) -> [FavoriteNode] { - items.compactMap { node in - switch node.content { - case .favorite(let fav): - if fav.name.localizedCaseInsensitiveContains(searchText) || - (fav.keyword?.localizedCaseInsensitiveContains(searchText) == true) || - fav.query.localizedCaseInsensitiveContains(searchText) { - return node - } - return nil - case .folder(let folder): - let filteredChildren = filterTree(node.children ?? [], searchText: searchText) - if !filteredChildren.isEmpty || - folder.name.localizedCaseInsensitiveContains(searchText) { - return .folder(folder, children: filteredChildren) - } - return nil - case .linkedFavorite(let linked): - if linked.name.localizedCaseInsensitiveContains(searchText) || - (linked.keyword?.localizedCaseInsensitiveContains(searchText) == true) || - linked.relativePath.localizedCaseInsensitiveContains(searchText) { - return node - } - return nil - case .linkedFolder(let folder): - let filteredChildren = filterTree(node.children ?? [], searchText: searchText) - if !filteredChildren.isEmpty || folder.name.localizedCaseInsensitiveContains(searchText) { - return .linkedFolder(folder, children: filteredChildren) - } - return nil - case .linkedSubfolder(let folderId, let displayName, let pathPrefix): - let filteredChildren = filterTree(node.children ?? [], searchText: searchText) - if !filteredChildren.isEmpty || displayName.localizedCaseInsensitiveContains(searchText) { - return .linkedSubfolder( - folderId: folderId, - displayName: displayName, - pathPrefix: pathPrefix, - children: filteredChildren - ) - } - return nil - } - } - } } diff --git a/TableProTests/Views/DataGridCellSelectionFillTests.swift b/TableProTests/Views/DataGridCellSelectionFillTests.swift new file mode 100644 index 000000000..09893d4f3 --- /dev/null +++ b/TableProTests/Views/DataGridCellSelectionFillTests.swift @@ -0,0 +1,79 @@ +// +// DataGridCellSelectionFillTests.swift +// TableProTests +// + +import AppKit +@testable import TablePro +import Testing + +/// The cell-range wash has to stay visible when the grid loses focus. Thinning the unemphasized +/// selection colour out to 28% put it at 1.09:1 against a white grid, which reads as no selection +/// at all, so the colour is now used at the opacity AppKit uses it at. +@Suite("Data grid cell selection fill") +@MainActor +struct DataGridCellSelectionFillTests { + /// The fill is built inside the appearance block. A dynamic colour resolves against whatever + /// appearance is current when it is read, so building it outside would mix one appearance's + /// fill with the other's background. + private func contrast( + in name: NSAppearance.Name, + fill makeFill: () -> NSColor + ) -> CGFloat { + var ratio: CGFloat = 0 + NSAppearance(named: name)?.performAsCurrentDrawingAppearance { + let background = NSColor.controlBackgroundColor + let foreground = Self.composite(makeFill(), over: background).relativeLuminance + let backgroundLuminance = background.relativeLuminance + ratio = (max(foreground, backgroundLuminance) + 0.05) / (min(foreground, backgroundLuminance) + 0.05) + } + return ratio + } + + private static func composite(_ fill: NSColor, over background: NSColor) -> NSColor { + guard let source = fill.usingColorSpace(.sRGB), + let destination = background.usingColorSpace(.sRGB) else { return fill } + let alpha = source.alphaComponent + return NSColor( + srgbRed: source.redComponent * alpha + destination.redComponent * (1 - alpha), + green: source.greenComponent * alpha + destination.greenComponent * (1 - alpha), + blue: source.blueComponent * alpha + destination.blueComponent * (1 - alpha), + alpha: 1 + ) + } + + @Test("The unemphasized selection colour is opaque, the way AppKit fills it") + func unemphasizedFillIsOpaque() { + #expect(NSColor.unemphasizedSelectedContentBackgroundColor.alphaComponent == 1) + } + + @Test("An unfocused cell range stays visible in every appearance") + func unfocusedRangeStaysVisible() { + for name in [NSAppearance.Name.aqua, .darkAqua] { + let ratio = contrast(in: name) { .unemphasizedSelectedContentBackgroundColor } + #expect(ratio > 1.3, "unfocused cell range is invisible in \(name.rawValue) at \(ratio)") + } + } + + /// The old shape thinned the same colour to 28%, which is what made it disappear. Keeping the + /// measurement here means a change back to a wash fails instead of shipping. + @Test("Thinning the unemphasized colour is what made it invisible") + func thinnedFillIsInvisible() { + for name in [NSAppearance.Name.aqua, .darkAqua] { + let ratio = contrast(in: name) { + NSColor.unemphasizedSelectedContentBackgroundColor.withAlphaComponent(0.28) + } + #expect(ratio < 1.2, "thinned fill unexpectedly visible in \(name.rawValue) at \(ratio)") + } + } + + @Test("The emphasized accent stays a tint so cell text under it survives") + func emphasizedFillStaysATint() { + for name in [NSAppearance.Name.aqua, .darkAqua] { + let ratio = contrast(in: name) { + NSColor.selectedContentBackgroundColor.withAlphaComponent(0.28) + } + #expect(ratio > 1.2, "emphasized cell range is invisible in \(name.rawValue) at \(ratio)") + } + } +} diff --git a/TableProTests/Views/DatabaseTreeTypeSelectTests.swift b/TableProTests/Views/DatabaseTreeTypeSelectTests.swift index 45a899a79..f4d0a0ad8 100644 --- a/TableProTests/Views/DatabaseTreeTypeSelectTests.swift +++ b/TableProTests/Views/DatabaseTreeTypeSelectTests.swift @@ -30,6 +30,46 @@ struct DatabaseTreeTypeSelectTests { #expect(DatabaseTreeTypeSelect.isArrowNavigation(type: .leftMouseDown, keyCode: Self.upArrow) == false) } + /// Reading `keyCode` off a real mouse event raises, and the raise unwound through AppKit's + /// mouse tracking: clicking a table selected it but never opened it. The pure test above cannot + /// catch that, because it never touches an `NSEvent`. + @Test("A real mouse event is answered without reading its key code") + func realMouseEventDoesNotReadKeyCode() throws { + let event = try #require( + NSEvent.mouseEvent( + with: .leftMouseDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + eventNumber: 0, + clickCount: 1, + pressure: 1 + ) + ) + #expect(DatabaseTreeTypeSelect.isArrowNavigation(event) == false) + } + + @Test("A real arrow key event counts as navigation") + func realArrowEventNavigates() throws { + let event = try #require( + NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "", + charactersIgnoringModifiers: "", + isARepeat: false, + keyCode: Self.downArrow + ) + ) + #expect(DatabaseTreeTypeSelect.isArrowNavigation(event)) + } + @Test("Group and status rows have no type select string") func groupRowsHaveNoMatchString() { #expect(DatabaseTreeTypeSelect.matchString(for: .recentSection) == nil) diff --git a/TableProTests/Views/FieldDrivenListEntryTests.swift b/TableProTests/Views/FieldDrivenListEntryTests.swift new file mode 100644 index 000000000..f4743fb80 --- /dev/null +++ b/TableProTests/Views/FieldDrivenListEntryTests.swift @@ -0,0 +1,81 @@ +// +// FieldDrivenListEntryTests.swift +// TableProTests +// + +@testable import TablePro +import Testing + +@Suite("Field driven list entries") +struct FieldDrivenListEntryTests { + private struct Item: Identifiable, Equatable { + let id: String + } + + private func section(_ id: String, title: String?, _ names: [String]) -> FieldDrivenListSection { + FieldDrivenListSection(id: id, title: title, items: names.map(Item.init)) + } + + @Test("An untitled section contributes its rows and no header") + func untitledSectionHasNoHeader() { + let entries = FieldDrivenListEntry.flatten([section("a", title: nil, ["one", "two"])]) + #expect(entries.count == 2) + #expect(entries.compactMap(\.itemId) == ["one", "two"]) + #expect(entries.filter(\.isHeader).isEmpty) + } + + @Test("A titled section puts its header above its rows") + func titledSectionLeadsWithHeader() { + let entries = FieldDrivenListEntry.flatten([section("a", title: "ACTIVE", ["one"])]) + #expect(entries.count == 2) + #expect(entries[0].isHeader) + #expect(entries[0].itemId == nil) + #expect(entries[1].itemId == "one") + } + + /// A section that filters down to nothing must not leave its title behind. + @Test("An empty section contributes nothing at all") + func emptySectionIsDropped() { + let entries = FieldDrivenListEntry.flatten([ + section("a", title: "ACTIVE", []), + section("b", title: "SAVED", ["one"]), + ]) + #expect(entries.count == 2) + #expect(entries[0].isHeader) + #expect(entries[1].itemId == "one") + } + + @Test("Sections keep their order and their rows keep theirs") + func orderIsPreserved() { + let entries = FieldDrivenListEntry.flatten([ + section("a", title: "ACTIVE", ["one", "two"]), + section("b", title: "SAVED", ["three"]), + ]) + #expect(entries.compactMap(\.itemId) == ["one", "two", "three"]) + #expect(entries.filter(\.isHeader).count == 2) + } + + /// Identity drives whether the table reloads. A refilter that lands on the same rows must + /// compare equal, or every keystroke would throw away the hosted views. + @Test("Identity is stable across rebuilds of the same rows") + func identityIsStable() { + let first = FieldDrivenListEntry.flatten([section("a", title: "ACTIVE", ["one", "two"])]) + let second = FieldDrivenListEntry.flatten([section("a", title: "ACTIVE", ["one", "two"])]) + #expect(first.map(\.identity) == second.map(\.identity)) + } + + @Test("Identity changes when the rows change") + func identityTracksContent() { + let first = FieldDrivenListEntry.flatten([section("a", title: nil, ["one", "two"])]) + let second = FieldDrivenListEntry.flatten([section("a", title: nil, ["one"])]) + #expect(first.map(\.identity) != second.map(\.identity)) + } + + @Test("A header never reports an item id") + func headerHasNoItemId() { + let entries = FieldDrivenListEntry.flatten([section("a", title: "ACTIVE", ["one"])]) + let headers = entries.filter(\.isHeader) + #expect(headers.count == 1) + #expect(headers[0].itemId == nil) + } +} diff --git a/TableProTests/Views/GroupMenuEntriesTests.swift b/TableProTests/Views/GroupMenuEntriesTests.swift new file mode 100644 index 000000000..22828d054 --- /dev/null +++ b/TableProTests/Views/GroupMenuEntriesTests.swift @@ -0,0 +1,84 @@ +// +// GroupMenuEntriesTests.swift +// TableProTests +// + +@testable import TablePro +import Testing + +@Suite("Group menu entries") +struct GroupMenuEntriesTests { + private func group( + _ name: String, + parent: ConnectionGroup? = nil, + color: ConnectionColor = .none + ) -> ConnectionGroup { + ConnectionGroup(name: name, color: color, parentId: parent?.id) + } + + @Test("The uncategorised entry comes first and carries no identifier") + func noneComesFirst() { + let entries = GroupMenuEntries.forConnection(groups: [], noneTitle: "None") + #expect(entries.count == 1) + #expect(entries[0].id == nil) + #expect(entries[0].title == "None") + #expect(entries[0].hasSeparatorAbove == false) + } + + /// Depth is carried as a menu indentation level, which is what `NSMenuItem` understands. + /// Expressing it as padding or as leading spaces in the title both got discarded. + @Test("Nesting is reported as an indentation level") + func nestingBecomesIndentation() { + let root = group("Prod") + let child = group("EU", parent: root) + let grandchild = group("Read replica", parent: child) + let entries = GroupMenuEntries.forConnection( + groups: [root, child, grandchild], + noneTitle: "None" + ) + #expect(entries.map(\.title) == ["None", "Prod", "EU", "Read replica"]) + #expect(entries.map(\.indentationLevel) == [0, 0, 1, 2]) + } + + @Test("A separator sits between the uncategorised entry and the groups") + func separatorSitsAboveFirstGroup() { + let root = group("Prod") + let entries = GroupMenuEntries.forConnection(groups: [root], noneTitle: "None") + #expect(entries.filter(\.hasSeparatorAbove).map(\.title) == ["Prod"]) + } + + @Test("A group's colour rides along with its entry") + func colourIsCarried() { + let root = group("Prod", color: .red) + let entries = GroupMenuEntries.forConnection(groups: [root], noneTitle: "None") + #expect(entries.last?.color == .red) + } + + @Test("A parent picker disables anything already at the nesting limit") + func parentPickerDisablesAtLimit() { + let root = group("A") + let child = group("B", parent: root) + let grandchild = group("C", parent: child) + let entries = GroupMenuEntries.forParent( + groups: [root, child, grandchild], + noneTitle: "None" + ) + let byTitle = Dictionary(uniqueKeysWithValues: entries.map { ($0.title, $0.isEnabled) }) + #expect(byTitle["A"] == true) + #expect(byTitle["B"] == true) + #expect(byTitle["C"] == false) + } + + @Test("A connection picker never disables a group") + func connectionPickerEnablesEverything() { + let root = group("A") + let child = group("B", parent: root) + let grandchild = group("C", parent: child) + let entries = GroupMenuEntries.forConnection( + groups: [root, child, grandchild], + noneTitle: "None" + ) + let disabled = entries.filter { !$0.isEnabled } + #expect(disabled.isEmpty) + } +} diff --git a/TableProTests/Views/Main/CoordinatorEditorLoadTests.swift b/TableProTests/Views/Main/CoordinatorEditorLoadTests.swift index 5a48d0c2c..3fbe6e553 100644 --- a/TableProTests/Views/Main/CoordinatorEditorLoadTests.swift +++ b/TableProTests/Views/Main/CoordinatorEditorLoadTests.swift @@ -143,7 +143,7 @@ struct CoordinatorEditorLoadTests { let disposition = coordinator.loadQueryIntoEditor( "SELECT 2", databaseName: "testdb", - forceNewWindowTab: true + forceNewTab: true ) #expect(disposition == .focusedElsewhere) diff --git a/TableProTests/Views/Sidebar/DatabaseTreeFilterTests.swift b/TableProTests/Views/Sidebar/DatabaseTreeFilterTests.swift index 5e18c400d..ce0327ea0 100644 --- a/TableProTests/Views/Sidebar/DatabaseTreeFilterTests.swift +++ b/TableProTests/Views/Sidebar/DatabaseTreeFilterTests.swift @@ -65,6 +65,67 @@ struct DatabaseTreeFilterTests { #expect(result == ["sales"]) } + /// A search fires a per-schema load, and the pane must not blank out while it runs. + @Test("An unloaded schema stays visible during a search") + func unloadedSchemaStaysVisible() { + #expect( + DatabaseTreeFilter.hierarchicalSchemaIsVisible( + "analytics", searchText: "invoice", isLoaded: false, tables: [] + ) + ) + } + + @Test("A loaded schema is dropped only when nothing inside it matches") + func loadedSchemaNeedsAMatch() { + #expect( + !DatabaseTreeFilter.hierarchicalSchemaIsVisible( + "analytics", searchText: "invoice", isLoaded: true, tables: [table("events")] + ) + ) + #expect( + DatabaseTreeFilter.hierarchicalSchemaIsVisible( + "analytics", searchText: "invoice", isLoaded: true, tables: [table("invoices")] + ) + ) + } + + @Test("A schema whose own name matches stays visible with nothing loaded inside it") + func nameMatchedSchemaStaysVisible() { + #expect( + DatabaseTreeFilter.hierarchicalSchemaIsVisible( + "analytics", searchText: "analy", isLoaded: true, tables: [] + ) + ) + } + + /// Filtering the tables of a schema the query already matched leaves it reporting no items. + @Test("A name-matched schema shows every table it holds") + func nameMatchedSchemaShowsEverything() { + let tables = [table("events"), table("sessions")] + #expect( + DatabaseTreeFilter.hierarchicalTables(tables, schema: "analytics", searchText: "analytics") + .map(\.name) == ["events", "sessions"] + ) + } + + @Test("A schema the query did not match still filters its tables") + func unmatchedSchemaFiltersTables() { + let tables = [table("events"), table("sessions")] + #expect( + DatabaseTreeFilter.hierarchicalTables(tables, schema: "analytics", searchText: "sess") + .map(\.name) == ["sessions"] + ) + } + + @Test("An empty search shows every table") + func emptySearchShowsEverything() { + let tables = [table("events"), table("sessions")] + #expect( + DatabaseTreeFilter.hierarchicalTables(tables, schema: "analytics", searchText: "") + .map(\.name) == ["events", "sessions"] + ) + } + @Test("matches is a case-insensitive substring test, not a subsequence test") func matchesSubstring() { #expect(DatabaseTreeFilter.matches("ser", "users")) diff --git a/TableProTests/Views/Sidebar/DatabaseTreeNodeTests.swift b/TableProTests/Views/Sidebar/DatabaseTreeNodeTests.swift index 7ca901d3e..3a9275215 100644 --- a/TableProTests/Views/Sidebar/DatabaseTreeNodeTests.swift +++ b/TableProTests/Views/Sidebar/DatabaseTreeNodeTests.swift @@ -29,6 +29,23 @@ struct DatabaseTreeNodeTests { #expect(Set([loading, empty, errored, otherParent]).count == 4) } + /// Only the buckets the app invented are group rows. AppKit stops indenting a group row's + /// children, so a real container listed here would flatten the tree under it. + @Test("Invented buckets are section headers, real database objects are not") + func sectionHeaders() { + func node(_ kind: DatabaseTreeNode.Kind) -> DatabaseTreeNode { + DatabaseTreeNode(id: "n", kind: kind) + } + #expect(node(.recentSection).isSectionHeader) + #expect(node(.objectKindSection(.table)).isSectionHeader) + #expect(node(.redisKeysSection).isSectionHeader) + + #expect(node(.schema(database: "shop", schema: "public")).isSectionHeader == false) + #expect(node(.hierarchicalSchemaSection(schema: "analytics")).isSectionHeader == false) + #expect(node(.table(tableRef("users"))).isSectionHeader == false) + #expect(node(.status(.loading)).isSectionHeader == false) + } + private func partitionedRef(_ name: String, schema: String? = "public") -> DatabaseTreeTableRef { DatabaseTreeTableRef( database: "shop", diff --git a/TableProTests/Views/Sidebar/DatabaseTreeSelectionPolicyTests.swift b/TableProTests/Views/Sidebar/DatabaseTreeSelectionPolicyTests.swift new file mode 100644 index 000000000..06747136a --- /dev/null +++ b/TableProTests/Views/Sidebar/DatabaseTreeSelectionPolicyTests.swift @@ -0,0 +1,85 @@ +// +// DatabaseTreeSelectionPolicyTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@Suite("Database tree selection policy") +struct DatabaseTreeSelectionPolicyTests { + private func tableRef(_ name: String) -> DatabaseTreeTableRef { + DatabaseTreeTableRef( + database: "app", + schema: "public", + table: TableInfo(name: name, type: .table, rowCount: nil, schema: "public") + ) + } + + private func routineRef(_ name: String) -> DatabaseTreeRoutineRef { + DatabaseTreeRoutineRef( + database: "app", + schema: "public", + routine: RoutineInfo(name: name, schema: "public", kind: .function, signature: nil) + ) + } + + private func node(_ kind: DatabaseTreeNode.Kind) -> DatabaseTreeNode { + DatabaseTreeNode(id: "n", kind: kind) + } + + @Test("Objects are selectable") + func objectsAreSelectable() { + #expect(DatabaseTreeSelection.isSelectable(.table(tableRef("users")))) + #expect(DatabaseTreeSelection.isSelectable(.routine(routineRef("do_thing")))) + #expect(DatabaseTreeSelection.isSelectable(.recentTable(tableRef("orders")))) + #expect(DatabaseTreeSelection.isSelectable(.schema(database: "app", schema: "public"))) + } + + /// The two rows that stand for nothing: a loading or error placeholder, and the Recent title. + @Test("Rows that are not objects refuse selection") + func placeholdersRefuseSelection() { + #expect(DatabaseTreeSelection.isSelectable(.status(.loading)) == false) + #expect(DatabaseTreeSelection.isSelectable(.status(.error("boom"))) == false) + #expect(DatabaseTreeSelection.isSelectable(.recentSection) == false) + } + + @Test("A table row resolves to its own reference") + func tableResolvesToItself() { + let ref = tableRef("users") + #expect(DatabaseTreeSelection.tableRef(of: node(.table(ref))) == ref) + } + + /// A Recent entry is a second row for a table already in the tree, so selecting it opens the + /// same table through the same path instead of a separate click handler. + @Test("A Recent row resolves to the table it stands for") + func recentResolvesToItsTable() { + let ref = tableRef("orders") + #expect(DatabaseTreeSelection.tableRef(of: node(.recentTable(ref))) == ref) + } + + @Test("Rows that are not tables resolve to no reference") + func nonTablesResolveToNil() { + #expect(DatabaseTreeSelection.tableRef(of: node(.routine(routineRef("f")))) == nil) + #expect(DatabaseTreeSelection.tableRef(of: node(.schema(database: "app", schema: "public"))) == nil) + #expect(DatabaseTreeSelection.tableRef(of: node(.recentSection)) == nil) + } + + /// Selecting a routine alongside a table must not add the routine to what the context menu and + /// the export dialog act on, which is a set of tables. + @Test("A mixed selection yields only the tables in it") + func mixedSelectionYieldsTablesOnly() { + let users = tableRef("users") + let orders = tableRef("orders") + let nodes = [ + node(.table(users)), + node(.routine(routineRef("do_thing"))), + node(.recentTable(orders)), + node(.status(.loading)), + ] + #expect(DatabaseTreeSelection.tableRefs(of: nodes) == [users, orders]) + } +} diff --git a/TableProTests/Views/Sidebar/DatabaseTreeSelectionProjectionTests.swift b/TableProTests/Views/Sidebar/DatabaseTreeSelectionProjectionTests.swift new file mode 100644 index 000000000..25e9fa448 --- /dev/null +++ b/TableProTests/Views/Sidebar/DatabaseTreeSelectionProjectionTests.swift @@ -0,0 +1,77 @@ +// +// DatabaseTreeSelectionProjectionTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +/// The tree publishes this projection into `windowState.selectedTables`, which is what the Table +/// menu's Truncate, Copy Name and Delete commands read. +@Suite("Database tree selection projection") +struct DatabaseTreeSelectionProjectionTests { + private func table(_ name: String, schema: String? = "public") -> TableInfo { + TableInfo(name: name, type: .table, rowCount: nil, schema: schema) + } + + private func ref(_ name: String, database: String = "app", schema: String? = "public") -> DatabaseTreeTableRef { + DatabaseTreeTableRef(database: database, schema: schema, table: table(name, schema: schema)) + } + + private func routineRef(_ name: String) -> DatabaseTreeRoutineRef { + DatabaseTreeRoutineRef( + database: "app", + schema: "public", + routine: RoutineInfo(name: name, schema: "public", kind: .function, signature: nil) + ) + } + + private func node(_ kind: DatabaseTreeNode.Kind) -> DatabaseTreeNode { + DatabaseTreeNode(id: UUID().uuidString, kind: kind) + } + + @Test("An empty selection publishes nothing") + func emptySelection() { + #expect(DatabaseTreeSelection.tableInfos(of: []).isEmpty) + } + + @Test("Every selected table is published") + func tablesArePublished() { + let nodes = [node(.table(ref("users"))), node(.table(ref("orders")))] + #expect(DatabaseTreeSelection.tableInfos(of: nodes) == [table("users"), table("orders")]) + } + + /// The commands act on tables. A routine caught in a mixed selection must never reach a + /// TRUNCATE or DROP batch. + @Test("Routines, containers and placeholders never reach the published set") + func onlyTablesArePublished() { + let nodes = [ + node(.table(ref("users"))), + node(.routine(routineRef("do_thing"))), + node(.schema(database: "app", schema: "public")), + node(.status(.loading)), + node(.recentSection), + ] + #expect(DatabaseTreeSelection.tableInfos(of: nodes) == [table("users")]) + } + + /// A Recent row and the table's own row are two rows for one table, so selecting both must not + /// make the commands act on it twice. + @Test("A table reachable from two rows collapses to one entry") + func duplicateRowsCollapse() { + let nodes = [node(.table(ref("orders"))), node(.recentTable(ref("orders")))] + #expect(DatabaseTreeSelection.tableInfos(of: nodes).count == 1) + } + + @Test("Tables of the same name in different schemas stay distinct") + func schemaKeepsTablesDistinct() { + let nodes = [ + node(.table(ref("users", schema: "public"))), + node(.table(ref("users", schema: "audit"))), + ] + #expect(DatabaseTreeSelection.tableInfos(of: nodes).count == 2) + } +} diff --git a/TableProTests/Views/Sidebar/FavoriteFolderRenameOverlayTests.swift b/TableProTests/Views/Sidebar/FavoriteFolderRenameOverlayTests.swift new file mode 100644 index 000000000..daad8bb37 --- /dev/null +++ b/TableProTests/Views/Sidebar/FavoriteFolderRenameOverlayTests.swift @@ -0,0 +1,151 @@ +// +// FavoriteFolderRenameOverlayTests.swift +// TableProTests +// + +import AppKit +import Testing + +@testable import TablePro + +@Suite("Favorite folder rename overlay") +@MainActor +struct FavoriteFolderRenameOverlayTests { + private final class SingleNodeSource: NSObject, NSOutlineViewDataSource { + var isPresent = true + + private let node: FavoritesOutlineNode + + init(node: FavoritesOutlineNode) { + self.node = node + } + + func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int { + item == nil && isPresent ? 1 : 0 + } + + func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any { + node + } + + func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool { + false + } + } + + @MainActor + private final class Harness { + let overlay = FavoriteFolderRenameOverlay() + let outlineView = NSOutlineView() + let window: NSWindow + let node: FavoritesOutlineNode + let folder: SQLFavoriteFolder + let source: SingleNodeSource + + init() { + folder = SQLFavoriteFolder(name: "Reports") + node = FavoritesOutlineNode(id: folder.id.uuidString, kind: .header("Reports")) + source = SingleNodeSource(node: node) + window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 240, height: 200), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("name")) + outlineView.addTableColumn(column) + outlineView.outlineTableColumn = column + outlineView.dataSource = source + window.contentView = outlineView + outlineView.reloadData() + } + + func editingField() throws -> NSTextField { + try #require(outlineView.subviews.compactMap { $0 as? NSTextField }.first) + } + } + + @Test("Clicking away keeps the typed name") + func endEditingCommitsTypedName() throws { + let harness = Harness() + var committed: (folder: SQLFavoriteFolder, name: String)? + var cancelled = false + harness.overlay.onCommit = { committed = (folder: $0, name: $1) } + harness.overlay.onCancel = { cancelled = true } + + harness.overlay.begin(node: harness.node, folder: harness.folder, in: harness.outlineView) + let field = try harness.editingField() + field.stringValue = "Quarterly Reports" + harness.overlay.controlTextDidEndEditing( + Notification(name: NSControl.textDidEndEditingNotification, object: field) + ) + + #expect(committed?.name == "Quarterly Reports") + #expect(committed?.folder.id == harness.folder.id) + #expect(cancelled == false) + #expect(harness.overlay.isActive == false) + } + + @Test("Escape discards the typed name") + func escapeDiscardsTypedName() throws { + let harness = Harness() + var committed = false + var cancelled = false + harness.overlay.onCommit = { _, _ in committed = true } + harness.overlay.onCancel = { cancelled = true } + + harness.overlay.begin(node: harness.node, folder: harness.folder, in: harness.outlineView) + let field = try harness.editingField() + field.stringValue = "Discarded" + let handled = harness.overlay.control( + field, + textView: NSTextView(), + doCommandBy: #selector(NSResponder.cancelOperation(_:)) + ) + + #expect(handled) + #expect(cancelled) + #expect(committed == false) + #expect(harness.overlay.isActive == false) + } + + @Test("Return keeps the typed name") + func returnCommitsTypedName() throws { + let harness = Harness() + var committed: String? + harness.overlay.onCommit = { committed = $1 } + + harness.overlay.begin(node: harness.node, folder: harness.folder, in: harness.outlineView) + let field = try harness.editingField() + field.stringValue = "Monthly Reports" + let handled = harness.overlay.control( + field, + textView: NSTextView(), + doCommandBy: #selector(NSResponder.insertNewline(_:)) + ) + + #expect(handled) + #expect(committed == "Monthly Reports") + #expect(harness.overlay.isActive == false) + } + + @Test("A row that disappeared during a reload discards the edit") + func vanishedRowDiscardsEdit() throws { + let harness = Harness() + var committed = false + var cancelled = false + harness.overlay.onCommit = { _, _ in committed = true } + harness.overlay.onCancel = { cancelled = true } + + harness.overlay.begin(node: harness.node, folder: harness.folder, in: harness.outlineView) + let field = try harness.editingField() + field.stringValue = "Never Saved" + harness.source.isPresent = false + harness.outlineView.reloadData() + harness.overlay.reposition(in: harness.outlineView) + + #expect(cancelled) + #expect(committed == false) + #expect(harness.overlay.isActive == false) + } +} diff --git a/TableProTests/Views/Sidebar/FavoritesOutlineSelectionTests.swift b/TableProTests/Views/Sidebar/FavoritesOutlineSelectionTests.swift new file mode 100644 index 000000000..4082d5f03 --- /dev/null +++ b/TableProTests/Views/Sidebar/FavoritesOutlineSelectionTests.swift @@ -0,0 +1,90 @@ +// +// FavoritesOutlineSelectionTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@Suite("Favorites outline selection") +@MainActor +struct FavoritesOutlineSelectionTests { + private func table(_ name: String, schema: String? = "public") -> TableInfo { + TableInfo(name: name, type: .table, rowCount: nil, schema: schema) + } + + private func favorite(_ name: String) -> SQLFavorite { + SQLFavorite(name: name, query: "SELECT 1") + } + + @Test("Section titles refuse selection, objects accept it") + func headersRefuseSelection() { + #expect(FavoritesOutlineSelection.isSelectable(.header("Tables")) == false) + #expect(FavoritesOutlineSelection.isSelectable(.table(table("users")))) + #expect(FavoritesOutlineSelection.isSelectable(.query(.favorite(favorite("daily"))))) + #expect(FavoritesOutlineSelection.isSelectable(.teamQuery(id: "t1", name: "Shared", publishedBy: nil))) + } + + @Test("A table row maps to the table selection the app persists") + func tableMapsToSelection() { + let selection = FavoritesOutlineSelection.selection(for: .table(table("users")), database: "app") + #expect(selection == .table(database: "app", schema: "public", name: "users")) + } + + @Test("A saved query maps to its node id") + func queryMapsToNodeId() { + let node = FavoriteNode.favorite(favorite("daily")) + #expect(FavoritesOutlineSelection.selection(for: .query(node), database: nil) == .node(id: node.id)) + } + + /// Team Library rows carried no tag at all before, so the keyboard could never reach them. + @Test("A Team Library row maps to a selection of its own") + func teamQueryMapsToSelection() { + let selection = FavoritesOutlineSelection.selection( + for: .teamQuery(id: "abc", name: "Shared", publishedBy: "sam"), database: nil + ) + #expect(selection == .node(id: FavoritesOutlineNode.teamQueryId("abc"))) + } + + @Test("A header maps to no selection") + func headerMapsToNothing() { + #expect(FavoritesOutlineSelection.selection(for: .header("Queries"), database: "app") == nil) + } + + /// Restoring a persisted selection must find the same row again without a live TableInfo. + @Test("A persisted selection round-trips to the row id") + func selectionRoundTripsToNodeId() { + let selection = FavoriteSelection.table(database: "app", schema: "public", name: "users") + let expected = FavoritesOutlineNode.tableId(database: "app", schema: "public", name: "users") + #expect(FavoritesOutlineSelection.nodeId(for: selection) == expected) + #expect(FavoritesOutlineSelection.nodeId(for: .node(id: "fav-1")) == "fav-1") + } + + @Test("Type-select uses the name a user would type, never a section title") + func typeSelectSkipsHeaders() { + #expect(FavoritesOutlineSelection.matchString(for: .header("Tables")) == nil) + #expect(FavoritesOutlineSelection.matchString(for: .table(table("orders"))) == "orders") + #expect(FavoritesOutlineSelection.matchString(for: .query(.favorite(favorite("daily")))) == "daily") + #expect( + FavoritesOutlineSelection.matchString( + for: .teamQuery(id: "t", name: "Shared", publishedBy: nil) + ) == "Shared" + ) + } + + @Test("Only folders are expandable") + func onlyFoldersExpand() { + let leaf = FavoritesOutlineNode(id: "a", kind: .query(.favorite(favorite("daily")))) + let branch = FavoritesOutlineNode( + id: "b", + kind: .query(.folder(SQLFavoriteFolder(name: "Reports"), children: [])) + ) + let header = FavoritesOutlineNode(id: "c", kind: .header("Queries")) + #expect(leaf.isExpandable == false) + #expect(branch.isExpandable) + #expect(header.isExpandable == false) + } +} diff --git a/TableProTests/Views/Sidebar/SidebarRecentSelectionTests.swift b/TableProTests/Views/Sidebar/SidebarRecentSelectionTests.swift new file mode 100644 index 000000000..820e37ffd --- /dev/null +++ b/TableProTests/Views/Sidebar/SidebarRecentSelectionTests.swift @@ -0,0 +1,62 @@ +// +// SidebarRecentSelectionTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +/// The sidebar's object list selects by `TableInfo`, and a Recent entry keeps its own copy of the +/// table taken when it was opened. These are the properties that make tagging a Recent row with +/// that copy correct: the copy still identifies the same table, so the row highlights and the +/// selection means what the rest of the app expects it to mean. +@Suite("Sidebar recent selection identity") +struct SidebarRecentSelectionTests { + private func table( + _ name: String, + schema: String? = "public", + type: TableInfo.TableType = .table, + rowCount: Int? = nil, + comment: String? = nil + ) -> TableInfo { + TableInfo(name: name, type: type, rowCount: rowCount, schema: schema, comment: comment) + } + + /// A stored Recent copy carries the row count and comment from when it was opened. Identity + /// ignores both, so it still matches the live row and the two rows agree. + @Test("Metadata that drifts does not change identity") + func metadataDoesNotAffectIdentity() { + let stored = table("users", rowCount: nil, comment: nil) + let live = table("users", rowCount: 42, comment: "people") + #expect(stored == live) + #expect(stored.hashValue == live.hashValue) + #expect(Set([stored, live]).count == 1) + } + + @Test("Name, schema and type are all part of identity") + func identityCoversTheQualifiedName() { + #expect(table("users", schema: "app") != table("users", schema: "public")) + #expect(table("users") != table("orders")) + #expect(table("users", type: .table) != table("users", type: .view)) + } + + /// A Recent row and the object-list row for the same table therefore carry the same selection + /// tag, so selecting either shows the table as selected in both places it appears. + @Test("A recent row and its object-list row share one selection value") + func recentAndListRowShareSelection() { + let selection: Set = [table("users", rowCount: nil)] + #expect(selection.contains(table("users", rowCount: 42))) + } + + /// `RecentTableRow` has to stay distinct from the object-list row inside `ForEach`, or SwiftUI + /// would treat the two as one view even though they select as one table. + @Test("A recent row keeps a view identity of its own") + func recentRowHasDistinctViewIdentity() { + let info = table("users") + #expect(RecentTableRow(table: info).id != info.id) + #expect(RecentTableRow(table: info).id.contains(info.id)) + } +} diff --git a/TableProTests/Views/Sidebar/SidebarRootShapeResolverTests.swift b/TableProTests/Views/Sidebar/SidebarRootShapeResolverTests.swift new file mode 100644 index 000000000..4d3e2b409 --- /dev/null +++ b/TableProTests/Views/Sidebar/SidebarRootShapeResolverTests.swift @@ -0,0 +1,81 @@ +// +// SidebarRootShapeResolverTests.swift +// TableProTests +// + +import TableProPluginKit +import Testing + +@testable import TablePro + +/// The one thing that still differs between the three sidebar modes now that they share an outline. +@Suite("Sidebar root shape") +struct SidebarRootShapeResolverTests { + /// Oracle, Snowflake, BigQuery and Trino. They have no database dimension, so the layout + /// preference cannot apply and the schema shape wins outright. + @Test("A hierarchical-schema engine ignores the layout preference") + func hierarchicalWinsOverLayout() { + for layout in SidebarLayout.allCases { + for supportsTree in [true, false] { + #expect( + SidebarRootShapeResolver.resolve( + groupingStrategy: .hierarchicalSchema, + sidebarLayout: layout, + supportsDatabaseTree: supportsTree + ) == .hierarchicalSchema + ) + } + } + } + + @Test("Tree layout gives the database tree when the driver supports one") + func treeLayoutUsesDatabaseTree() { + for grouping in [GroupingStrategy.byDatabase, .bySchema] { + #expect( + SidebarRootShapeResolver.resolve( + groupingStrategy: grouping, + sidebarLayout: .tree, + supportsDatabaseTree: true + ) == .databaseTree + ) + } + } + + @Test("Flat layout stays flat even when the driver supports a tree") + func flatLayoutStaysFlat() { + #expect( + SidebarRootShapeResolver.resolve( + groupingStrategy: .bySchema, + sidebarLayout: .flat, + supportsDatabaseTree: true + ) == .flat + ) + } + + /// Redis, SQLite, MongoDB and the rest. They have no tree to offer, so asking for one changes + /// nothing. + @Test("A driver with no tree stays flat whatever the layout says") + func unsupportedTreeStaysFlat() { + for layout in SidebarLayout.allCases { + #expect( + SidebarRootShapeResolver.resolve( + groupingStrategy: .flat, + sidebarLayout: layout, + supportsDatabaseTree: false + ) == .flat + ) + } + } + + @Test("Every grouping strategy resolves to a shape") + func everyStrategyResolves() { + let shapes = [GroupingStrategy.byDatabase, .bySchema, .flat, .hierarchicalSchema].map { + SidebarRootShapeResolver.resolve( + groupingStrategy: $0, + sidebarLayout: .flat, + supportsDatabaseTree: true + ) + } + #expect(shapes == [.flat, .flat, .flat, .hierarchicalSchema]) + } +} diff --git a/TableProTests/Views/SortableHeaderEmphasisTests.swift b/TableProTests/Views/SortableHeaderEmphasisTests.swift new file mode 100644 index 000000000..1d2d5f808 --- /dev/null +++ b/TableProTests/Views/SortableHeaderEmphasisTests.swift @@ -0,0 +1,71 @@ +// +// SortableHeaderEmphasisTests.swift +// TableProTests +// + +import AppKit +@testable import TablePro +import Testing + +@Suite("Sortable header emphasis") +@MainActor +struct SortableHeaderEmphasisTests { + @Test("Emphasis needs both the key window and table focus") + func requiresBothConditions() { + #expect(SortableHeaderEmphasis.isEmphasized(tableViewHoldsFocus: true, isKeyWindow: true)) + #expect(SortableHeaderEmphasis.isEmphasized(tableViewHoldsFocus: true, isKeyWindow: false) == false) + #expect(SortableHeaderEmphasis.isEmphasized(tableViewHoldsFocus: false, isKeyWindow: true) == false) + #expect(SortableHeaderEmphasis.isEmphasized(tableViewHoldsFocus: false, isKeyWindow: false) == false) + } + + /// An `NSTableView` with no columns answers `acceptsFirstResponder` with false, so a bare one + /// would leave the window itself as the responder and the test would prove nothing. + private func makeWindow() -> (NSWindow, NSTableView) { + let table = NSTableView(frame: NSRect(x: 0, y: 0, width: 200, height: 100)) + table.addTableColumn(NSTableColumn(identifier: NSUserInterfaceItemIdentifier("column"))) + let scrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 200, height: 100)) + scrollView.documentView = table + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 200, height: 100), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.contentView?.addSubview(scrollView) + return (window, table) + } + + @Test("The table itself holding focus counts") + func tableIsFirstResponder() { + let (window, table) = makeWindow() + _ = window.makeFirstResponder(table) + #expect(SortableHeaderEmphasis.holdsFocus(tableView: table, in: window)) + } + + /// A cell edit installs the field editor below the table, so focus has to be resolved by + /// ancestry. Keying on identity alone dropped the header out of emphasis mid-edit. + @Test("A responder inside the table still counts as focus") + func descendantIsFirstResponder() { + let (window, table) = makeWindow() + let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 50, height: 20)) + table.addSubview(field) + _ = window.makeFirstResponder(field) + #expect(SortableHeaderEmphasis.holdsFocus(tableView: table, in: window)) + } + + @Test("A responder outside the table does not count as focus") + func siblingIsFirstResponder() { + let (window, table) = makeWindow() + let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 50, height: 20)) + window.contentView?.addSubview(field) + _ = window.makeFirstResponder(field) + #expect(SortableHeaderEmphasis.holdsFocus(tableView: table, in: window) == false) + } + + @Test("No table and no window resolve to no focus") + func missingPiecesResolveToFalse() { + let (window, table) = makeWindow() + #expect(SortableHeaderEmphasis.holdsFocus(tableView: nil, in: window) == false) + #expect(SortableHeaderEmphasis.holdsFocus(tableView: table, in: nil) == false) + } +} diff --git a/TableProTests/Views/TransferFailureReportTests.swift b/TableProTests/Views/TransferFailureReportTests.swift new file mode 100644 index 000000000..e42426ab6 --- /dev/null +++ b/TableProTests/Views/TransferFailureReportTests.swift @@ -0,0 +1,55 @@ +// +// TransferFailureReportTests.swift +// TableProTests +// + +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("Transfer failure report") +@MainActor +struct TransferFailureReportTests { + private func failure(line: Int, message: String, statement: String) -> PluginImportResult.ImportStatementError { + PluginImportResult.ImportStatementError(statement: statement, line: line, errorMessage: message) + } + + @Test("The failing statement is reported alongside the line and the message") + func statementIsIncluded() { + let report = TransferResultAlert.failureReport(for: [ + failure(line: 12, message: "syntax error", statement: "INSERT INTO users VALUES (") + ]) + #expect(report.contains("12")) + #expect(report.contains("syntax error")) + #expect(report.contains("INSERT INTO users VALUES (")) + } + + /// A row import names its entry `row 12`, which the line number already says. + @Test("A statement that only repeats the line number is left out") + func rowPlaceholderIsSkipped() { + let report = TransferResultAlert.failureReport(for: [ + failure(line: 12, message: "not null violation", statement: "row 12") + ]) + #expect(report.contains("not null violation")) + #expect(report.contains("row 12") == false) + } + + @Test("An empty statement adds no trailing line") + func emptyStatementIsSkipped() { + let report = TransferResultAlert.failureReport(for: [ + failure(line: 3, message: "duplicate key", statement: " ") + ]) + #expect(report.hasSuffix("duplicate key")) + } + + @Test("Failures are separated from one another") + func failuresAreSeparated() { + let report = TransferResultAlert.failureReport(for: [ + failure(line: 1, message: "first", statement: "SELECT 1"), + failure(line: 2, message: "second", statement: "SELECT 2") + ]) + #expect(report.contains("\n\n")) + #expect(report.contains("SELECT 1")) + #expect(report.contains("SELECT 2")) + } +} diff --git a/docs/customization/appearance.mdx b/docs/customization/appearance.mdx index 039e1f38b..fcc076f12 100644 --- a/docs/customization/appearance.mdx +++ b/docs/customization/appearance.mdx @@ -23,6 +23,8 @@ Two segmented controls sit at the top of the tab. **Appearance** picks Light, Da Below the controls, the tab is a split view: - **Left**: theme list grouped into Built-in, Registry, and Custom. Each row shows a preview thumbnail, name, and source. Selecting a theme assigns it to the slot named in the **Editing** control, and it applies right away if that slot is the one in use. + + The list shows the themes that suit the slot you are editing, so a theme declared dark does not appear under **Light**. A theme that declares neither appears under both. The theme a slot already holds always stays listed, even when it contradicts the slot, so a choice you made earlier is never hidden from you and never rewritten on your behalf. - **Right**: the editor, with **Fonts** and **Colors** tabs. It always edits the active theme, not the highlighted row, so switch **Editing** to the slot in use before you change fonts or colors. The action bar below the list: diff --git a/docs/features/favorites.mdx b/docs/features/favorites.mdx index 2bd7f0838..06cea838f 100644 --- a/docs/features/favorites.mdx +++ b/docs/features/favorites.mdx @@ -64,13 +64,15 @@ Each favorite is either **global** (visible in all connections) or **connection- ### Managing favorites -Double-click a favorite in the Favorites tab to insert it into the editor. The right-click menu has **Insert in Editor**, **Run in New Tab**, **Copy Query**, **Edit...**, **Move to** (a folder), and **Delete**. +Double-click a favorite in the Favorites tab, or select it and press Return, to insert it into the editor. Arrow keys move between rows and typing jumps to a name. `Delete` removes the selected favorite. The right-click menu has **Insert in Editor**, **Run in New Tab**, **Copy Query**, **Edit...**, **Move to** (a folder), and **Delete**. -Organize favorites into folders: create them from the **+** menu, right-click to rename or delete, and drag favorites between them. +Organize favorites into folders: create them from the **+** menu, right-click a folder to rename or delete it, and use **Move to** on a favorite to file it. Renaming happens in place, on the row. + +Drag a saved query or a linked `.sql` file out of the sidebar to drop its SQL into the editor or another app. ## Team Library -On a Team license, the Favorites tab shows a **Team Library** section with queries shared by your team. Click one to run it in a new tab. To share yours, click **+** > **Publish Saved Queries to Team...** See [Team Plan](/features/team). +On a Team license, the Favorites tab shows a **Team Library** section with queries shared by your team. Double-click one, or select it and press Return, to run it in a new tab. To share yours, click **+** > **Publish Saved Queries to Team...** See [Team Plan](/features/team). ## Linked SQL Folders diff --git a/docs/features/keyboard-shortcuts.mdx b/docs/features/keyboard-shortcuts.mdx index e5d19d370..5473fc31b 100644 --- a/docs/features/keyboard-shortcuts.mdx +++ b/docs/features/keyboard-shortcuts.mdx @@ -177,6 +177,10 @@ See [Filtering](/features/filtering) for the filter panel itself. | Show previous workspace | `Ctrl+Cmd+Up` | | Show next workspace | `Ctrl+Cmd+Down` | | Focus sidebar filter | `Ctrl+Cmd+Option+F` | +| Move from the sidebar filter into the object list | `Down` | +| Move between objects in the sidebar | `Up` / `Down` | +| Jump to an object by name | type its first letters with the sidebar focused | +| Clear the sidebar selection | `Escape` | | Toggle inspector panel | `Cmd+Option+I` | | Toggle results | `Cmd+Option+R` | | Query history | `Cmd+Y` | diff --git a/docs/features/overview.mdx b/docs/features/overview.mdx index 1bead7b56..2cac96d8c 100644 --- a/docs/features/overview.mdx +++ b/docs/features/overview.mdx @@ -94,7 +94,7 @@ TablePro opens with a welcome window: an actions panel on the left and your save - Native macOS window tabs per connection. + A tab strip per connection, in one window. Cmd+Shift+O fuzzy search across tables, databases, and saved queries. diff --git a/docs/features/quick-switcher.mdx b/docs/features/quick-switcher.mdx index 4fc35396d..e0e184468 100644 --- a/docs/features/quick-switcher.mdx +++ b/docs/features/quick-switcher.mdx @@ -17,7 +17,7 @@ Press `Cmd+Shift+O` (or **Query** > **Quick Switcher**) in a connection window t | Open Quick Switcher | `Cmd+Shift+O` | | Move selection | `Up` / `Down`, or `Ctrl+J`/`Ctrl+N` and `Ctrl+K`/`Ctrl+P` | | Open selected item | `Return` (double-click also works) | -| Open in a new window tab | `Option+Return` | +| Open in a new tab | `Option+Return` | | Switch scope | `Cmd+1` to `Cmd+5` | | Clear the search text | `Escape` | | Dismiss | `Escape` on an empty field, `Cmd+Shift+O` again, or click outside | @@ -46,7 +46,7 @@ The **Queries** scope searches saved queries and up to 200 recent query-history Quick Switcher showing query history from Chinook and Analytics connections -Opening a query brings its connection forward and loads the SQL without running it. The window you opened Quick Switcher from is used whenever it is on the right connection and database, so a query never replaces the editor in a window you were not looking at. Query-history entries keep their recorded database context. If no open window uses that database, TablePro opens a new native window tab instead of loading the SQL into a tab for another database. `Option+Return` always opens a new native window tab. +Opening a query brings its connection forward and loads the SQL without running it. The tab you opened Quick Switcher from is used whenever it is on the right connection and database, so a query never replaces an editor you were not looking at. Query-history entries keep their recorded database context. If no open tab uses that database, TablePro opens a new tab instead of loading the SQL into a tab bound to another database. `Option+Return` always opens a new tab. ## Ranking @@ -61,7 +61,7 @@ The list shows at most 200 results. ## Open badge -Tables already open in a tab show an **Open** badge, and the selected row's hint reads **Switch to Tab**. Committing switches to the existing tab instead of opening a duplicate. `Option+Return` forces a new window tab. +Tables already open in a tab show an **Open** badge, and the selected row's hint reads **Switch to Tab**. Committing switches to the existing tab instead of opening a duplicate. `Option+Return` forces a new tab. What opening does depends on the item: tables and views open a table tab, databases and schemas switch the active database or schema, and queries load into the SQL editor. diff --git a/docs/features/tabs.mdx b/docs/features/tabs.mdx index aee72379f..6841ac30f 100644 --- a/docs/features/tabs.mdx +++ b/docs/features/tabs.mdx @@ -3,13 +3,13 @@ title: Query Tabs description: Each tab keeps its own SQL, results, sorting, and filters, and comes back after a restart --- -Every tab is a native macOS window tab: each tab is an NSWindow in a tab group, managed by the system tabbing API. Standard macOS tab behavior applies. Drag tabs to reorder or move them between windows, use **Window > Merge All Windows** and **Window > Move Tab to New Window**, press `Ctrl+Tab` to cycle, and use **View > Show All Tabs** (`Cmd+Shift+\`) for the tab overview. When more tabs open than fit, the tab bar shrinks them to fit, as in Safari. +Every open connection lives in one window, and each connection has its own set of tabs. A tab strip appears above the editor as soon as a connection holds more than one tab, and lists only that connection's tabs. With a single tab there is no strip, so a window that behaves the way it always did gains no chrome. Each tab is an independent workspace with its own SQL, results, sorting, and filter state. Tabs persist across app restarts. - - Native tab bar with query and table tabs - Native tab bar with query and table tabs + + Tab strip with query and table tabs + Tab strip with query and table tabs ## Tab Types @@ -23,7 +23,7 @@ Table tabs track cell edits as pending changes by default. Query tabs support [c ## Preview Tabs -Single-clicking a table opens a preview tab that is reused when you click a different table, like VS Code's preview tabs. A tab with unsaved edits, an applied filter, or sorting is never replaced: the click opens a new tab instead. A preview tab becomes permanent when you double-click the table in the sidebar or interact with the tab (sort, filter, edit data). A preview tab still open at quit is restored as a permanent tab. +Clicking a table opens it right away, in a preview tab that is reused when you click a different table, like VS Code's preview tabs. A tab with unsaved edits, an applied filter, or sorting is never replaced: the click opens a new tab instead. A preview tab becomes permanent when you interact with it (sort, filter, edit data), or straight away if you use **Open in New Tab** on the table's contextual menu. A preview tab still open at quit is restored as a permanent tab. Turn preview tabs off with **Settings > General > Tabs > Enable preview tabs** if you prefer every click to open a permanent tab. @@ -33,23 +33,25 @@ Turn preview tabs off with **Settings > General > Tabs > Enable preview tabs** i | Action | How | |--------|-----| -| New query tab | `Cmd+T` or the **+** button in the tab bar | -| New table tab | Double-click a table in the sidebar (single-click reuses the preview tab) | -| Close tab | `Cmd+W` or the tab's close button | -| Close other tabs | **File > Close Other Tabs**, or Option-click the close button of the tab you want to keep | -| Close every tab in the window | **File > Close All Tabs** | +| New query tab | `Cmd+T` or the **+** button at the end of the tab strip | +| New table tab | Click a table in the sidebar (the preview tab is reused), or **Open in New Tab** on its contextual menu for a permanent one | +| Close tab | `Cmd+W`, the tab's close button, or **Close Tab** on the tab's contextual menu | +| Close other tabs | **File > Close Other Tabs**, or **Close Other Tabs** on the tab's contextual menu | +| Close every tab for the connection | **File > Close All Tabs**, or **Close All Tabs** on the tab's contextual menu | | Close tabs belonging to other databases | **File > Close Tabs for Other Databases** | +Right-clicking a tab gives you the three close commands for that tab without leaving the strip. + The three bulk commands have no shortcut out of the box. Bind them in **Settings > Keyboard** under Navigation. -**Close All Tabs** leaves the window open and empty rather than closing it, so the connection stays live and you can carry on with `Cmd+T` or a click in the sidebar. +Closing the last tab leaves the connection open on its empty state rather than closing anything. `Cmd+W` again closes the connection, and closes the window once that was the last connection open. So the shortcut reads the same way it does everywhere else: it closes the smallest thing in front of you first. -**Close Tabs for Other Databases** closes only windows whose every tab was opened against a database other than the one the connection is on now. It never touches the tab you are looking at, and it is limited to the current connection. Tabs from different databases are meant to coexist, so switching databases never closes anything on its own. On engines that switch schemas instead of databases, such as BigQuery and Oracle, the command reads **Close Tabs for Other Schemas**. +**Close Tabs for Other Databases** closes only tabs opened against a database other than the one the connection is on now. It never touches the tab you are looking at, and it is limited to the current connection. Tabs from different databases are meant to coexist, so switching databases never closes anything on its own. On engines that switch schemas instead of databases, such as BigQuery and Oracle, the command reads **Close Tabs for Other Schemas**. -Closing a query tab keeps its SQL in Recently Closed, so an accidental close costs nothing, including when you close a whole group at once. TablePro asks before closing only when unsaved work would be lost, such as pending data edits, pending structure changes, or unsaved edits to a `.sql` file on disk. A bulk close asks once per window that has something to lose, and cancelling stops the rest. +Closing a query tab keeps its SQL in Recently Closed, so an accidental close costs nothing, including when you close a whole group at once. TablePro asks before closing only when unsaved work would be lost, such as pending data edits, pending structure changes, or unsaved edits to a `.sql` file on disk. A bulk close asks once, and cancelling stops the rest. -Window tabs cannot be pinned. Pinning exists for result tabs inside a query tab (`Cmd+Option+P`, see [Keyboard Shortcuts](/features/keyboard-shortcuts)). +Tabs cannot be pinned or dragged to reorder. Pinning exists for result tabs inside a query tab (`Cmd+Option+P`, see [Keyboard Shortcuts](/features/keyboard-shortcuts)). ### Reopening Closed Tabs @@ -64,23 +66,35 @@ The last 20 closed query and table tabs are kept for 30 days. A reopened tab com ### Switching Tabs - `Cmd+1` through `Cmd+9` jump to a tab by position -- `Cmd+Shift+[` / `Cmd+Shift+]` for previous and next tab (`Ctrl+Tab` also works) +- `Cmd+Shift+[` / `Cmd+Shift+]` for previous and next tab +- **Window > Show Previous Tab** and **Window > Show Next Tab** do the same from the menu -Each tab keeps its full state when you switch away: SQL, cursor position, results, scroll position, sort and filter state, and pending changes. +Each tab keeps its full state when you switch away: SQL, cursor position, results, scroll position, sort and filter state, and pending changes. Selecting a tab that is scrolled out of sight pulls it back into view. ## Windows and Connections -Each connection gets its own window with its own tab bar, so a tab bar only ever lists the tabs of the connection you are in. A new connection window opens over the one you were in, so switching reads as the window changing content until you move a window somewhere you want it. New windows open at 1200x800; size and position are remembered across launches. +One window hosts every connection you have open. Picking a connection in the [workspace rail](/features/workspace-rail) switches that window to it and returns you to the tab you last used there, rather than raising a second window. Opening a table or query on a connection you already have open adds a tab to that connection instead of opening another window. + +New windows open at 1200x800; size and position are remembered across launches. + +### Separate Windows + +You can still have more than one window, for putting two connections side by side. TablePro follows your **Prefer tabs when opening documents** setting in System Settings > Desktop & Dock rather than forcing a choice. When that setting is **Always**, new windows join a tab group and the standard commands apply: -To move between open connections, use the [workspace rail](/features/workspace-rail) on the leading edge of the window. +| Action | How | +|--------|-----| +| Move the current window out of its tab group | **Window > Move Tab to New Window** | +| Gather every window into one tab group | **Window > Merge All Windows** | + +Both dim when the window is not part of a tab group. These are macOS window tabs, which is a different thing from the tab strip above the editor: window tabs hold whole windows, the strip holds one connection's editors. ### Disconnecting **Database > Disconnect** ends the session without closing the window. The window shows a Reconnect screen in place of its tabs, and the tabs are saved before the session ends. Use **Database > Reconnect**, or the Reconnect button on the screen itself. -Reconnecting puts the tabs back as they were, however many windows the connection has open. Each window takes back the tabs that were its own, and a window whose tabs you had closed stays empty. +Reconnecting puts the tabs back as they were. -Disconnecting asks first only when a window has unsaved changes or a query still running. It applies to the whole connection, so every window and workspace on it shows the same Reconnect screen. Nothing reconnects on its own afterwards: a connection you disconnected is not reopened at the next launch, and clicking back into its window leaves it disconnected until you ask. +Disconnecting asks first only when there are unsaved changes or a query still running. It applies to the whole connection, so every window and workspace on it shows the same Reconnect screen. Nothing reconnects on its own afterwards: a connection you disconnected is not reopened at the next launch, and clicking back into it leaves it disconnected until you ask. You can also disconnect by right-clicking a workspace in the [workspace rail](/features/workspace-rail), or a connection in the connection list. @@ -92,9 +106,7 @@ The sidebar's database selection only controls two things: what the sidebar list To point an existing tab somewhere else, use the database picker in the query editor's toolbar. It rebinds that one tab and reruns it, and leaves the sidebar and every other tab alone. -The window subtitle shows the database, and the schema on engines that have one, that the tab is bound to. Tabs in the same window can be bound to different databases, so the subtitle is how you tell them apart. - -Dragging a tab out of its window or choosing **Window > Move Tab to New Window** does not change any of this: the tab keeps querying the database and schema it was opened on. +The window subtitle shows the database, and the schema on engines that have one, that the frontmost tab is bound to. Tabs for the same connection can be bound to different databases, so the subtitle is how you tell them apart. PostgreSQL, Redshift, and CockroachDB can only change database by reconnecting. A tab bound to a database other than the connection's active one runs its queries on a separate connection for that database, so it does not share temp tables, session variables, or an open transaction with the query editor on the main connection. See [PostgreSQL](/databases/postgresql#cross-database-tabs). @@ -108,7 +120,7 @@ PostgreSQL, Redshift, and CockroachDB can only change database by reconnecting. | Tab type, table name, database, and schema | Pending data changes | | Applied sort and current page | Selected rows | | Column widths | | -| Window and tab order | | +| Tab order and which tab was frontmost | | Per-table filters are stored separately per connection, database, schema, and table, and come back when the table reopens. @@ -122,9 +134,9 @@ Table tabs load one page at a time. The default page size is 1,000 rows, set in ## From External Clients -Raycast, Cursor, Claude Desktop, and other MCP clients can list and focus tabs across windows: +Raycast, Cursor, Claude Desktop, and other MCP clients can list and focus tabs: -- `list_recent_tabs` enumerates open tabs across every window. +- `list_recent_tabs` enumerates open tabs across every connection. - `focus_query_tab` brings an existing tab to the front by id. - `open_connection_window` opens a saved connection. - `open_table_tab` opens a specific table. diff --git a/docs/features/workspace-rail.mdx b/docs/features/workspace-rail.mdx index fc592ed0d..62cea1574 100644 --- a/docs/features/workspace-rail.mdx +++ b/docs/features/workspace-rail.mdx @@ -40,9 +40,9 @@ An entry goes away when its last tab closes and you are no longer browsing it. N ## Switching -Click an entry to go to it. Moving between two connections raises that connection's window, returning you to the tab you last used there rather than an arbitrary one. Moving between two databases of one connection stays in the same window and moves what the sidebar lists. +Click an entry to go to it. Moving between two connections switches the window to that connection, returning you to the tab you last used there rather than an arbitrary one. Moving between two databases of one connection stays in the same window and moves what the sidebar lists. -Each connection is its own window with its own set of tabs, and the tab bar along the top shows only that connection's tabs. A new connection window opens over the one you were in, so switching reads as the window changing content until you move a window somewhere you want it. +One window hosts every connection you have open. Each connection keeps its own set of tabs, and the tab strip above the editor lists only the tabs of the connection you are in. Switching reads as the window changing content, because that is what it is. Open tabs are never closed or retargeted by a switch. A tab keeps the database it was opened against and keeps querying it, whichever workspace you are in. @@ -52,11 +52,11 @@ The rail takes the keyboard too. Click into it and the arrow keys move the highl ## Closing -Right-click a workspace and choose **Close Workspace** to close every tab in it. A window holding a tab in another database stays open, because that tab is not part of the workspace you are closing. Unsaved work is confirmed first, the same as closing a window. +Right-click a workspace and choose **Close Workspace** to close every tab in it. Tabs bound to another database stay open, because they are not part of the workspace you are closing. Unsaved work is confirmed first, the same as closing a window. ## Disconnecting -Right-click a workspace and choose **Disconnect** to end its connection. The item only appears while the connection is live. Unlike Close Workspace, this covers every window and workspace of that connection, and no window closes: each one shows a Reconnect screen in place of its tabs, with the tabs saved before the session ends. See [disconnecting](/features/tabs#disconnecting). +Right-click a workspace and choose **Disconnect** to end its connection. The item only appears while the connection is live. Unlike Close Workspace, this covers every workspace of that connection, and no window closes: the connection shows a Reconnect screen in place of its tabs, with the tabs saved before the session ends. See [disconnecting](/features/tabs#disconnecting). ## Reordering From 831f55423b0844c001743ae2dce92afd7713d19e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 14 Aug 2026 17:14:19 +0700 Subject: [PATCH 34/47] fix(import): lay out the failing statement inside the import error alert --- .../Components/TransferResultAlert.swift | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/TablePro/Views/Components/TransferResultAlert.swift b/TablePro/Views/Components/TransferResultAlert.swift index be1bae7d9..2f7728868 100644 --- a/TablePro/Views/Components/TransferResultAlert.swift +++ b/TablePro/Views/Components/TransferResultAlert.swift @@ -123,16 +123,29 @@ internal enum TransferResultAlert { return "\(counts)\n\(seconds)" } + /// A text view laid inside a scroll view by hand has to be told it may grow and that its text + /// container tracks its width. Left at its default zero-sized container it lays out no text at + /// all, so the accessory reads as an empty box. private static func scrollingText(_ text: String) -> NSView { - let textView = NSTextView() - textView.string = text + let scroll = NSScrollView(frame: NSRect(x: 0, y: 0, width: 380, height: 140)) + scroll.hasVerticalScroller = true + scroll.borderType = .bezelBorder + + let textView = NSTextView(frame: NSRect(origin: .zero, size: scroll.contentSize)) textView.isEditable = false textView.drawsBackground = false textView.font = .monospacedSystemFont(ofSize: NSFont.smallSystemFontSize, weight: .regular) + textView.autoresizingMask = [.width] + textView.isVerticallyResizable = true + textView.isHorizontallyResizable = false + textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) + textView.textContainer?.widthTracksTextView = true + textView.textContainer?.containerSize = NSSize( + width: scroll.contentSize.width, + height: CGFloat.greatestFiniteMagnitude + ) + textView.string = text - let scroll = NSScrollView(frame: NSRect(x: 0, y: 0, width: 380, height: 140)) - scroll.hasVerticalScroller = true - scroll.borderType = .bezelBorder scroll.documentView = textView return scroll } From 76d79091469c83e591d5a46f7c04185e46f6ee6b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 14 Aug 2026 17:14:19 +0700 Subject: [PATCH 35/47] refactor(sidebar): drop dead code and the duplicated search and alert plumbing --- CLAUDE.md | 2 +- .../ConnectionWorkspaceRegistry.swift | 2 +- .../Infrastructure/TabWindowController.swift | 7 +- TablePro/Core/Utilities/UI/AlertHelper.swift | 80 ++++++------------- .../MainContentCoordinator+Navigation.swift | 8 -- .../DatabaseTreeOutlineCoordinator.swift | 15 ++-- .../Sidebar/DatabaseTreeOutlineView.swift | 4 +- TablePro/Views/Sidebar/DatabaseTreeView.swift | 18 +---- TablePro/Views/Sidebar/SidebarTreeView.swift | 13 ++- TablePro/Views/Sidebar/SidebarView.swift | 6 +- 10 files changed, 52 insertions(+), 103 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9380b6163..28679b44f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -153,7 +153,7 @@ When adding a new method to the driver protocol: add to `PluginDatabaseDriver` ( - **`SQLEditorTheme`** — single source of truth for editor colors/fonts - **`TableProEditorTheme`** — adapter to CodeEdit's `EditorTheme` protocol - **`CompletionEngine`** — framework-agnostic; **`SQLCompletionAdapter`** bridges to CodeEdit's `CodeSuggestionDelegate` -- Editor tabs use native NSWindow tabs (`NSWindow.tabbingMode = .preferred` in `TabWindowController`); there is no custom tab bar. +- Editor tabs are drawn by `EditorTabStrip`, not by native window tabs. A window belongs to exactly one `NSWindow` tab group and that group's bar shows every window in it, so a window hosting several connections could only ever show all of their tabs interleaved. Window tabbing itself stays on AppKit's terms: `TabWindowController` leaves `tabbingMode` at `.automatic`, which is the user's own System Settings preference, and never forces `.preferred`. - Cursor model: `cursorPositions: [CursorPosition]` (multi-cursor via CodeEditSourceEditor) ### Change Tracking Flow diff --git a/TablePro/Core/Services/Infrastructure/ConnectionWorkspaceRegistry.swift b/TablePro/Core/Services/Infrastructure/ConnectionWorkspaceRegistry.swift index f0be0ab81..51ba4d129 100644 --- a/TablePro/Core/Services/Infrastructure/ConnectionWorkspaceRegistry.swift +++ b/TablePro/Core/Services/Infrastructure/ConnectionWorkspaceRegistry.swift @@ -83,7 +83,7 @@ internal final class ConnectionWorkspaceRegistry { internal func select(_ connectionId: UUID?) { guard selectedConnectionId != connectionId else { return } - guard connectionId == nil || workspacesById[connectionId ?? UUID()] != nil else { return } + if let connectionId, workspacesById[connectionId] == nil { return } selectedConnectionId = connectionId Self.logger.info( "select connId=\(connectionId?.uuidString ?? "none", privacy: .public)" diff --git a/TablePro/Core/Services/Infrastructure/TabWindowController.swift b/TablePro/Core/Services/Infrastructure/TabWindowController.swift index 8496bf6bb..a7e018cdb 100644 --- a/TablePro/Core/Services/Infrastructure/TabWindowController.swift +++ b/TablePro/Core/Services/Infrastructure/TabWindowController.swift @@ -68,9 +68,10 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { window.isRestorable = false window.toolbarStyle = .unified window.titleVisibility = .visible - /// Apple asks an app that drives tabbing itself to read the user's preference before - /// showing a window rather than forcing tabs, which hard-coding `.preferred` did. - window.tabbingMode = NSWindow.userTabbingPreference == .always ? .preferred : .automatic + /// `.automatic` is AppKit reading the user's own tabbing preference. Hard-coding + /// `.preferred` overrode that setting, which an app that draws its own editor tabs has no + /// reason to do. + window.tabbingMode = .automatic window.tabbingIdentifier = WindowManager.mainTabbingIdentifier window.collectionBehavior.insert([.fullScreenPrimary, .managed]) diff --git a/TablePro/Core/Utilities/UI/AlertHelper.swift b/TablePro/Core/Utilities/UI/AlertHelper.swift index 73c1c9a4c..fbf305bf3 100644 --- a/TablePro/Core/Utilities/UI/AlertHelper.swift +++ b/TablePro/Core/Utilities/UI/AlertHelper.swift @@ -56,6 +56,24 @@ final class AlertHelper { !(window is NSPanel) && window.styleMask.contains(.titled) } + /// An alert belongs to the window the user was working in, so it runs as a sheet whenever one + /// can be resolved and falls back to an application-modal run only when none can. + private static func run(_ alert: NSAlert, in window: NSWindow?) async -> NSApplication.ModalResponse { + guard let parent = resolveWindow(window) else { return alert.runModal() } + return await withCheckedContinuation { continuation in + alert.beginSheetModal(for: parent) { continuation.resume(returning: $0) } + } + } + + /// For the alerts whose only button is an acknowledgement, where nothing waits on the answer. + private static func present(_ alert: NSAlert, in window: NSWindow?) { + guard let parent = resolveWindow(window) else { + alert.runModal() + return + } + alert.beginSheetModal(for: parent) { _ in } + } + // MARK: - Destructive Confirmations static func confirmDestructive( @@ -70,15 +88,7 @@ final class AlertHelper { alert.informativeText = message alert.alertStyle = .warning Self.addConfirmAndCancel(to: alert, confirmButton: confirmButton, cancelButton: cancelButton) - - if let window = resolveWindow(window) { - return await withCheckedContinuation { continuation in - alert.beginSheetModal(for: window) { response in - continuation.resume(returning: response == .alertFirstButtonReturn) - } - } - } - return alert.runModal() == .alertFirstButtonReturn + return await run(alert, in: window) == .alertFirstButtonReturn } // MARK: - Critical Confirmations @@ -95,15 +105,7 @@ final class AlertHelper { alert.informativeText = message alert.alertStyle = .critical Self.addConfirmAndCancel(to: alert, confirmButton: confirmButton, cancelButton: cancelButton) - - if let window = resolveWindow(window) { - return await withCheckedContinuation { continuation in - alert.beginSheetModal(for: window) { response in - continuation.resume(returning: response == .alertFirstButtonReturn) - } - } - } - return alert.runModal() == .alertFirstButtonReturn + return await run(alert, in: window) == .alertFirstButtonReturn } // MARK: - Cross-Process Approval @@ -184,7 +186,7 @@ final class AlertHelper { alert.informativeText = message alert.alertStyle = .warning - // Button order follows NSDocument convention: Save | Cancel | Don't Save (Cmd+D) + /// `NSDocument`'s own order: Save, Cancel, then Don't Save on Cmd+D. alert.addButton(withTitle: String(localized: "Save")) alert.addButton(withTitle: String(localized: "Cancel")) let dontSaveButton = alert.addButton(withTitle: String(localized: "Don't Save")) @@ -192,18 +194,7 @@ final class AlertHelper { dontSaveButton.keyEquivalent = "d" dontSaveButton.keyEquivalentModifierMask = .command - let response: NSApplication.ModalResponse - if let window = resolveWindow(window) { - response = await withCheckedContinuation { continuation in - alert.beginSheetModal(for: window) { resp in - continuation.resume(returning: resp) - } - } - } else { - response = alert.runModal() - } - - switch response { + switch await run(alert, in: window) { case .alertFirstButtonReturn: return .save case .alertThirdButtonReturn: return .dontSave default: return .cancel @@ -228,18 +219,7 @@ final class AlertHelper { alert.addButton(withTitle: second) alert.addButton(withTitle: third) - let response: NSApplication.ModalResponse - if let window = resolveWindow(window) { - response = await withCheckedContinuation { continuation in - alert.beginSheetModal(for: window) { resp in - continuation.resume(returning: resp) - } - } - } else { - response = alert.runModal() - } - - switch response { + switch await run(alert, in: window) { case .alertFirstButtonReturn: return 0 case .alertSecondButtonReturn: return 1 case .alertThirdButtonReturn: return 2 @@ -262,12 +242,7 @@ final class AlertHelper { .joined(separator: "\n\n") alert.alertStyle = .critical alert.addButton(withTitle: String(localized: "OK")) - - if let window = resolveWindow(window) { - alert.beginSheetModal(for: window) { _ in } - } else { - alert.runModal() - } + present(alert, in: window) } static func showInfoSheet( @@ -280,11 +255,6 @@ final class AlertHelper { alert.informativeText = message alert.alertStyle = .informational alert.addButton(withTitle: String(localized: "OK")) - - if let window = resolveWindow(window) { - alert.beginSheetModal(for: window) { _ in } - } else { - alert.runModal() - } + present(alert, in: window) } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index ffd69d9fb..41bebe708 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -396,14 +396,6 @@ extension MainContentCoordinator { WindowManager.shared.openTab(payload: payload) } - private func currentSchemaName(fallback: String) -> String { - if let schemaDriver = DatabaseManager.shared.driver(for: connectionId) as? SchemaSwitchable, - let schema = schemaDriver.escapedSchema { - return schema - } - return fallback - } - private func allTablesMetadataSQL() -> String? { let editorLang = PluginManager.shared.editorLanguage(for: connection.type) // Non-SQL databases: open a command tab instead diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift index c49906122..4cf59fd29 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift @@ -20,7 +20,7 @@ final class DatabaseTreeOutlineCoordinator: NSObject { private var sidebarState: SharedSidebarState? private weak var viewModel: SidebarViewModel? private var searchText = "" - private var connectionToken = "" + private var isConnected = false private var activeDatabase: String? private var activeSchema: String? private var pendingTruncates: Set = [] @@ -85,14 +85,14 @@ final class DatabaseTreeOutlineCoordinator: NSObject { let activeChanged = activeDatabase != view.activeDatabase || activeSchema != view.activeSchema let changed = searchText != view.searchText - || connectionToken != view.connectionToken + || isConnected != view.isConnected || activeChanged || pendingTruncates != view.pendingTruncates || pendingDeletes != view.pendingDeletes || showRecentTables != view.showRecentTables searchText = view.searchText - connectionToken = view.connectionToken + isConnected = view.isConnected activeDatabase = view.activeDatabase activeSchema = view.activeSchema pendingTruncates = view.pendingTruncates @@ -799,15 +799,10 @@ final class DatabaseTreeOutlineCoordinator: NSObject { lastSelection = Set(DatabaseTreeSelection.tableRefs(of: Array(nodes))) } - private func open(_ ref: DatabaseTreeTableRef, activateGridFocus: Bool, forceNewTab: Bool = false) { + private func open(_ ref: DatabaseTreeTableRef, activateGridFocus: Bool) { Task { @MainActor in await activate(ref) - mainCoordinator?.openTableTab( - ref.table, - schema: ref.schema, - activateGridFocus: activateGridFocus, - forceNewTab: forceNewTab - ) + mainCoordinator?.openTableTab(ref.table, schema: ref.schema, activateGridFocus: activateGridFocus) publishSelection() } } diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineView.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineView.swift index 924aff818..633a5760f 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineView.swift @@ -17,7 +17,9 @@ struct DatabaseTreeOutlineView: NSViewRepresentable { let pendingTruncates: Set let pendingDeletes: Set let searchText: String - let connectionToken: String + /// Rebuilds the tree when the session comes back, which is the one thing outside the metadata + /// services that invalidates every node at once. + let isConnected: Bool let activeDatabase: String? let activeSchema: String? let selectedTables: Set diff --git a/TablePro/Views/Sidebar/DatabaseTreeView.swift b/TablePro/Views/Sidebar/DatabaseTreeView.swift index 528d1b658..9b56a7610 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeView.swift @@ -46,7 +46,6 @@ struct DatabaseTreeView: View { let coordinator: MainContentCoordinator? let sidebarState: SharedSidebarState - @State private var searchText: String = "" @State private var settingsManager = AppSettingsManager.shared private var activeDatabase: String? { @@ -62,10 +61,6 @@ struct DatabaseTreeView: View { DatabaseManager.shared.session(for: connectionId)?.status == .connected } - private var connectionToken: String { - isConnected ? "connected" : "down" - } - private var databases: [DatabaseMetadata] { treeService.databases(for: connectionId) } @@ -98,16 +93,9 @@ struct DatabaseTreeView: View { loadingState } } - .task(id: connectionToken) { + .task(id: isConnected) { await treeService.loadDatabases(connectionId: connectionId, databaseType: databaseType) } - .task(id: viewModel.searchText) { - let live = viewModel.searchText - guard !live.isEmpty else { searchText = ""; return } - try? await Task.sleep(nanoseconds: 250_000_000) - guard !Task.isCancelled else { return } - searchText = live - } } private var outline: some View { @@ -120,8 +108,8 @@ struct DatabaseTreeView: View { viewModel: viewModel, pendingTruncates: pendingTruncates, pendingDeletes: pendingDeletes, - searchText: searchText, - connectionToken: connectionToken, + searchText: viewModel.filterQuery, + isConnected: isConnected, activeDatabase: activeDatabase, activeSchema: activeSchema, selectedTables: windowState.selectedTables, diff --git a/TablePro/Views/Sidebar/SidebarTreeView.swift b/TablePro/Views/Sidebar/SidebarTreeView.swift index 06294fceb..e71e08c28 100644 --- a/TablePro/Views/Sidebar/SidebarTreeView.swift +++ b/TablePro/Views/Sidebar/SidebarTreeView.swift @@ -20,6 +20,10 @@ struct SidebarTreeView: View { return name.isEmpty ? nil : name } + private var isConnected: Bool { + DatabaseManager.shared.session(for: connectionId)?.status == .connected + } + private var systemSchemas: Set { Set(PluginManager.shared.systemSchemaNames(for: viewModel.databaseType)) } @@ -65,7 +69,7 @@ struct SidebarTreeView: View { pendingTruncates: pendingTruncates, pendingDeletes: pendingDeletes, searchText: viewModel.filterQuery, - connectionToken: connectionId.uuidString, + isConnected: isConnected, activeDatabase: activeDatabase, activeSchema: coordinator?.toolbarState.currentSchema, selectedTables: windowState.selectedTables, @@ -122,11 +126,4 @@ struct SidebarTreeView: View { } } } - - private func reloadTables(for schema: String) { - guard let driver = DatabaseManager.shared.driver(for: connectionId) else { return } - Task { - await schemaService.reloadSchemaTables(connectionId: connectionId, schema: schema, driver: driver) - } - } } diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 2ef2836fa..01b47b92d 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -349,6 +349,10 @@ struct SidebarView: View { return name.isEmpty ? nil : name } + private var isConnected: Bool { + DatabaseManager.shared.session(for: connectionId)?.status == .connected + } + private var tableList: some View { DatabaseTreeOutlineView( connectionId: connectionId, @@ -360,7 +364,7 @@ struct SidebarView: View { pendingTruncates: pendingTruncates, pendingDeletes: pendingDeletes, searchText: viewModel.filterQuery, - connectionToken: connectionId.uuidString, + isConnected: isConnected, activeDatabase: activeDatabase, activeSchema: coordinator?.toolbarState.currentSchema, selectedTables: windowState.selectedTables, From c8c58c2126ee046e1349d4b96f968166225fde03 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 14 Aug 2026 19:06:31 +0700 Subject: [PATCH 36/47] refactor(hig): give every alert one window resolver and one presentation path --- TablePro/Core/Utilities/UI/AlertHelper.swift | 41 +++++++++---------- .../Components/TransferResultAlert.swift | 18 ++------ TablePro/Views/ERDiagram/ERDiagramView.swift | 2 +- .../InspectorDeleteConfirmation.swift | 8 +--- .../Settings/Appearance/ThemeListView.swift | 4 +- TablePro/Views/Sidebar/FavoritesTabView.swift | 2 +- .../Views/Sidebar/TableOperationAlert.swift | 8 +--- .../AlertWindowResolutionTests.swift | 2 +- 8 files changed, 30 insertions(+), 55 deletions(-) diff --git a/TablePro/Core/Utilities/UI/AlertHelper.swift b/TablePro/Core/Utilities/UI/AlertHelper.swift index fbf305bf3..934ab62e6 100644 --- a/TablePro/Core/Utilities/UI/AlertHelper.swift +++ b/TablePro/Core/Utilities/UI/AlertHelper.swift @@ -36,15 +36,11 @@ final class AlertHelper { return cancel } + /// The window a sheet belongs on. A sheet the user is meant to read against their work must + /// land on a document window, so a floating panel is never a candidate: the Quick Switcher + /// closes the moment it loses focus, taking the sheet with it. An explicit window is honoured + /// as given, and when nothing qualifies the caller runs the alert application-modal instead. static func resolveWindow(_ window: NSWindow?) -> NSWindow? { - window ?? NSApp.keyWindow ?? NSApp.mainWindow ?? NSApp.windows.first { $0.isVisible } - } - - /// A sheet the user is meant to read against their work must land on a document window. - /// `resolveWindow`'s last resort accepts any visible window, which includes floating panels - /// such as the Quick Switcher, so a file error can end up attached to a panel that closes - /// the moment it loses focus. - static func resolveContentWindow(_ window: NSWindow?) -> NSWindow? { if let window { return window } if let candidate = [NSApp.keyWindow, NSApp.mainWindow].compactMap({ $0 }).first(where: isContentWindow) { return candidate @@ -56,22 +52,25 @@ final class AlertHelper { !(window is NSPanel) && window.styleMask.contains(.titled) } - /// An alert belongs to the window the user was working in, so it runs as a sheet whenever one - /// can be resolved and falls back to an application-modal run only when none can. - private static func run(_ alert: NSAlert, in window: NSWindow?) async -> NSApplication.ModalResponse { - guard let parent = resolveWindow(window) else { return alert.runModal() } - return await withCheckedContinuation { continuation in - alert.beginSheetModal(for: parent) { continuation.resume(returning: $0) } + /// The one presentation path for every alert in the app: a sheet on the window the user was + /// working in, and an application-modal run only when no window qualifies. Each presenter used + /// to spell this out for itself, which is how they drifted apart on which window they accepted. + static func present( + _ alert: NSAlert, + in window: NSWindow?, + completion: @escaping @MainActor (NSApplication.ModalResponse) -> Void = { _ in } + ) { + guard let parent = resolveWindow(window) else { + completion(alert.runModal()) + return } + alert.beginSheetModal(for: parent, completionHandler: completion) } - /// For the alerts whose only button is an acknowledgement, where nothing waits on the answer. - private static func present(_ alert: NSAlert, in window: NSWindow?) { - guard let parent = resolveWindow(window) else { - alert.runModal() - return + private static func run(_ alert: NSAlert, in window: NSWindow?) async -> NSApplication.ModalResponse { + await withCheckedContinuation { continuation in + present(alert, in: window) { continuation.resume(returning: $0) } } - alert.beginSheetModal(for: parent) { _ in } } // MARK: - Destructive Confirmations @@ -149,7 +148,7 @@ final class AlertHelper { sheetWindow.title = String(localized: "Approve Integration") sheetWindow.isReleasedWhenClosed = false - guard let parent = resolveContentWindow(nil) else { + guard let parent = resolveWindow(nil) else { let delegate = PairingApprovalWindowDelegate(gate: gate) sheetWindow.delegate = delegate gate.onResolve = { [weak sheetWindow] in diff --git a/TablePro/Views/Components/TransferResultAlert.swift b/TablePro/Views/Components/TransferResultAlert.swift index 2f7728868..79b2e6141 100644 --- a/TablePro/Views/Components/TransferResultAlert.swift +++ b/TablePro/Views/Components/TransferResultAlert.swift @@ -37,7 +37,7 @@ internal enum TransferResultAlert { completion(response == .alertFirstButtonReturn ? .openFolder : .close) } - present(alert, in: window, deliver: deliver) + AlertHelper.present(alert, in: window, completion: deliver) } internal static func presentImportSuccess( @@ -59,7 +59,7 @@ internal enum TransferResultAlert { alert.layout() } - present(alert, in: window) { _ in completion() } + AlertHelper.present(alert, in: window) { _ in completion() } } internal static func presentImportFailure( @@ -85,7 +85,7 @@ internal enum TransferResultAlert { alert.informativeText = error?.localizedDescription ?? String(localized: "Unknown error") } - present(alert, in: window) { _ in completion() } + AlertHelper.present(alert, in: window) { _ in completion() } } /// A row import names its failing entry `row 12`, which the line number already says, so only @@ -149,16 +149,4 @@ internal enum TransferResultAlert { scroll.documentView = textView return scroll } - - private static func present( - _ alert: NSAlert, - in window: NSWindow?, - deliver: @escaping @MainActor (NSApplication.ModalResponse) -> Void - ) { - guard let parent = AlertHelper.resolveContentWindow(window) else { - deliver(alert.runModal()) - return - } - alert.beginSheetModal(for: parent, completionHandler: deliver) - } } diff --git a/TablePro/Views/ERDiagram/ERDiagramView.swift b/TablePro/Views/ERDiagram/ERDiagramView.swift index 7efa3eaf4..c1c2ef296 100644 --- a/TablePro/Views/ERDiagram/ERDiagramView.swift +++ b/TablePro/Views/ERDiagram/ERDiagramView.swift @@ -279,7 +279,7 @@ struct ERDiagramView: View { panel.title = String(localized: "Export ER Diagram") panel.message = String(localized: "Choose a location to save the diagram as PNG.") - guard let window = AlertHelper.resolveContentWindow(nil) else { return } + guard let window = AlertHelper.resolveWindow(nil) else { return } panel.beginSheetModal(for: window) { response in guard response == .OK, let url = panel.url else { return } guard let tiffData = image.tiffRepresentation, diff --git a/TablePro/Views/Inspector/InspectorDeleteConfirmation.swift b/TablePro/Views/Inspector/InspectorDeleteConfirmation.swift index 51b785597..24a1cae86 100644 --- a/TablePro/Views/Inspector/InspectorDeleteConfirmation.swift +++ b/TablePro/Views/Inspector/InspectorDeleteConfirmation.swift @@ -65,13 +65,7 @@ enum InspectorDeleteConfirmation { window: NSWindow?, proceed: @escaping @MainActor () -> Void ) { - let alert = makeAlert(messageText: messageText) - guard let parent = AlertHelper.resolveWindow(window) else { - guard alert.runModal() == .alertFirstButtonReturn else { return } - proceed() - return - } - alert.beginSheetModal(for: parent) { response in + AlertHelper.present(makeAlert(messageText: messageText), in: window) { response in guard response == .alertFirstButtonReturn else { return } proceed() } diff --git a/TablePro/Views/Settings/Appearance/ThemeListView.swift b/TablePro/Views/Settings/Appearance/ThemeListView.swift index 29aeee8e0..ab2e5ddad 100644 --- a/TablePro/Views/Settings/Appearance/ThemeListView.swift +++ b/TablePro/Views/Settings/Appearance/ThemeListView.swift @@ -192,7 +192,7 @@ internal struct ThemeListView: View { } private func exportActiveTheme() { - guard let window = AlertHelper.resolveContentWindow(nil) else { return } + guard let window = AlertHelper.resolveWindow(nil) else { return } let panel = NSSavePanel() panel.allowedContentTypes = [.json] panel.nameFieldStringValue = engine.activeTheme.name + ".json" @@ -212,7 +212,7 @@ internal struct ThemeListView: View { } private func importTheme() { - guard let window = AlertHelper.resolveContentWindow(nil) else { return } + guard let window = AlertHelper.resolveWindow(nil) else { return } let panel = NSOpenPanel() panel.allowedContentTypes = [.json] panel.allowsMultipleSelection = false diff --git a/TablePro/Views/Sidebar/FavoritesTabView.swift b/TablePro/Views/Sidebar/FavoritesTabView.swift index 0a72d9e05..b7fa423ce 100644 --- a/TablePro/Views/Sidebar/FavoritesTabView.swift +++ b/TablePro/Views/Sidebar/FavoritesTabView.swift @@ -618,7 +618,7 @@ internal struct FavoritesTabView: View { panel.allowsMultipleSelection = false panel.message = String(localized: "Choose a folder containing .sql files") - guard let window = AlertHelper.resolveContentWindow(nil) else { return } + guard let window = AlertHelper.resolveWindow(nil) else { return } panel.beginSheetModal(for: window) { response in guard response == .OK, let url = panel.url else { return } let path = PathPortability.contractHome(url.path) diff --git a/TablePro/Views/Sidebar/TableOperationAlert.swift b/TablePro/Views/Sidebar/TableOperationAlert.swift index ec24b2a24..2b1bfff5a 100644 --- a/TablePro/Views/Sidebar/TableOperationAlert.swift +++ b/TablePro/Views/Sidebar/TableOperationAlert.swift @@ -37,7 +37,7 @@ internal enum TableOperationAlert { ) alert.layout() - let deliver: @MainActor (NSApplication.ModalResponse) -> Void = { response in + AlertHelper.present(alert, in: window) { response in guard response == .alertFirstButtonReturn else { completion(nil) return @@ -49,12 +49,6 @@ internal enum TableOperationAlert { ) ) } - - guard let parent = AlertHelper.resolveWindow(window) else { - deliver(alert.runModal()) - return - } - alert.beginSheetModal(for: parent, completionHandler: deliver) } private static func checkbox(title: String, isEnabled: Bool) -> NSButton { diff --git a/TableProTests/Core/Utilities/AlertWindowResolutionTests.swift b/TableProTests/Core/Utilities/AlertWindowResolutionTests.swift index 052b38233..7998c6409 100644 --- a/TableProTests/Core/Utilities/AlertWindowResolutionTests.swift +++ b/TableProTests/Core/Utilities/AlertWindowResolutionTests.swift @@ -41,7 +41,7 @@ struct AlertWindowResolutionTests { @Test("An explicit window is honoured without a search") func explicitWindowWins() { let window = makeWindow() - #expect(AlertHelper.resolveContentWindow(window) === window) + #expect(AlertHelper.resolveWindow(window) === window) } @Test("A titled panel is still rejected") From 8ab036e6bb6a06d34e297773d6b3153dce5a7a2f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 14 Aug 2026 19:06:32 +0700 Subject: [PATCH 37/47] refactor(sidebar): split the tree coordinator and derive its shape once --- CHANGELOG.md | 2 + TablePro/Views/Shared/FieldDrivenList.swift | 15 +- ...baseTreeOutlineCoordinator+Expansion.swift | 194 ++++++ ...DatabaseTreeOutlineCoordinator+Nodes.swift | 343 ++++++++++ .../DatabaseTreeOutlineCoordinator.swift | 606 ++---------------- TablePro/Views/Sidebar/SidebarView.swift | 30 +- 6 files changed, 626 insertions(+), 564 deletions(-) create mode 100644 TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Expansion.swift create mode 100644 TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Nodes.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index d50620a16..4a4306dcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Hiding a database in the sidebar's database filter takes effect straight away. The tree kept listing every database until some other change happened to rebuild it. +- An alert opens on the window you were working in. It could attach itself to a floating panel such as the Quick Switcher, which takes the alert with it when it closes. - Holding an arrow key in the sidebar no longer opens a tab and runs a query for every object it passes. Arrowing through 20 tables fired 20 queries; it now opens only the object you stop on. - Clicking a row in the sidebar puts the keyboard on the list. The click moved the selection but sometimes left the keyboard in the filter field above it, so the row drew grey instead of in the accent colour and the arrow keys went to the field. Switching to the Favorites tab left the keyboard nowhere at all, so the first arrow key did nothing. - Opening a table from somewhere that asks for the grid, such as Favorites or Show Structure, puts the keyboard in the grid. Only the first such table of a session did; every one after it left the keyboard where it was. diff --git a/TablePro/Views/Shared/FieldDrivenList.swift b/TablePro/Views/Shared/FieldDrivenList.swift index 92f484d30..ec99ad89a 100644 --- a/TablePro/Views/Shared/FieldDrivenList.swift +++ b/TablePro/Views/Shared/FieldDrivenList.swift @@ -177,7 +177,8 @@ internal struct FieldDrivenList: NSViewRepresenta } internal func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { - FieldDrivenRowView() + tableView.makeView(withIdentifier: FieldDrivenRowView.reuseIdentifier, owner: self) as? FieldDrivenRowView + ?? FieldDrivenRowView.make() } internal func tableView(_ tableView: NSTableView, isGroupRow row: Int) -> Bool { @@ -251,9 +252,19 @@ internal struct FieldDrivenList: NSViewRepresenta /// for the search field's selection and that field is the thing holding focus. `window` is read /// rather than `NSApp.keyWindow`, which does not return a popover's own window. internal final class FieldDrivenRowView: NSTableRowView { + internal static let reuseIdentifier = NSUserInterfaceItemIdentifier("FieldDrivenRow") + + internal static func make() -> FieldDrivenRowView { + let view = FieldDrivenRowView() + view.identifier = reuseIdentifier + return view + } + + /// `NSTableRowView` declares this settable, so an override has to supply a setter. AppKit is + /// the only caller and it has nothing to tell this row that the key window does not. override internal var isEmphasized: Bool { get { window?.isKeyWindow ?? false } - set { _ = newValue } + set {} } } diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Expansion.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Expansion.swift new file mode 100644 index 000000000..6cceb9360 --- /dev/null +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Expansion.swift @@ -0,0 +1,194 @@ +// +// DatabaseTreeOutlineCoordinator+Expansion.swift +// TablePro +// + +import AppKit +import TableProPluginKit + +/// Which rows are disclosed, where that is remembered, and the loads a disclosure triggers. +extension DatabaseTreeOutlineCoordinator { + internal func applyDesiredExpansion() { + guard let outlineView = self.outlineView else { return } + isApplyingExpansion = true + defer { isApplyingExpansion = false } + let searching = !searchText.isEmpty + for rootNode in resolvedChildren(of: nil) where rootNode.id == DatabaseTreeNode.recentSectionId { + setExpanded(rootNode, searching || (viewModel?.isRecentsExpanded ?? true)) + } + for sectionNode in resolvedChildren(of: nil) { + switch sectionNode.kind { + case .objectKindSection(let kind): + let hasMatches = flatItemCount(for: kind) > 0 + setExpanded(sectionNode, viewModel?.effectiveExpanded(kind: kind, hasMatches: hasMatches) ?? true) + case .redisKeysSection: + setExpanded(sectionNode, searching || (viewModel?.isRedisKeysExpanded ?? true)) + case .hierarchicalSchemaSection(let schema): + let want = searching + ? hierarchicalSchemaMatches(schema) + : windowState?.expandedTreeSchemas.contains(schema) ?? false + setExpanded(sectionNode, want) + if outlineView.isItemExpanded(sectionNode) { triggerLoad(for: sectionNode) } + default: + break + } + } + for databaseNode in resolvedChildren(of: nil) { + guard case .database(let metadata) = databaseNode.kind else { continue } + let want = searching + ? databaseMatchesSearch(metadata) + : windowState?.expandedTreeDatabases.contains(metadata.name) ?? false + setExpanded(databaseNode, want) + guard outlineView.isItemExpanded(databaseNode) else { continue } + triggerLoad(for: databaseNode) + guard supportsSchemaLevel else { + restorePartitionExpansion(under: databaseNode) + continue + } + for schemaNode in resolvedChildren(of: databaseNode) { + guard case .schema(let database, let schema) = schemaNode.kind else { continue } + let wantSchema = searching + ? DatabaseTreeFilter.matches(searchText, schema) || schemaContentMatchesSearch(database: database, schema: schema) + : windowState?.expandedTreeDatabaseSchemas.contains(DatabaseSchemaKey(database: database, schema: schema)) ?? false + setExpanded(schemaNode, wantSchema) + if outlineView.isItemExpanded(schemaNode) { + triggerLoad(for: schemaNode) + restorePartitionExpansion(under: schemaNode) + } + } + } + } + + private func restorePartitionExpansion(under parent: DatabaseTreeNode) { + guard searchText.isEmpty, let outlineView = self.outlineView, let windowState else { return } + for tableNode in resolvedChildren(of: parent) { + guard case .table(let ref) = tableNode.kind, ref.table.type == .partitionedTable else { continue } + let key = DatabaseTableKey(database: ref.database, schema: ref.schema, table: ref.table.name) + guard windowState.expandedTreeTables.contains(key) else { continue } + setExpanded(tableNode, true) + guard outlineView.isItemExpanded(tableNode) else { continue } + triggerLoad(for: tableNode) + restorePartitionExpansion(under: tableNode) + } + } + + private func setExpanded(_ node: DatabaseTreeNode, _ expanded: Bool) { + guard let outlineView = self.outlineView else { return } + if expanded, !outlineView.isItemExpanded(node) { + outlineView.expandItem(node) + } else if !expanded, outlineView.isItemExpanded(node) { + outlineView.collapseItem(node) + } + } + + internal func recordExpansion(_ node: DatabaseTreeNode, expanded: Bool) { + switch node.kind { + case .recentSection: + viewModel?.isRecentsExpanded = expanded + case .objectKindSection(let kind): + viewModel?.expanded[kind] = expanded + case .redisKeysSection: + viewModel?.isRedisKeysExpanded = expanded + case .hierarchicalSchemaSection(let schema): + if expanded { + windowState?.expandedTreeSchemas.insert(schema) + } else { + windowState?.expandedTreeSchemas.remove(schema) + } + case .database(let metadata): + if expanded { + windowState?.expandedTreeDatabases.insert(metadata.name) + } else { + windowState?.expandedTreeDatabases.remove(metadata.name) + } + case .schema(let database, let schema): + let key = DatabaseSchemaKey(database: database, schema: schema) + if expanded { + windowState?.expandedTreeDatabaseSchemas.insert(key) + } else { + windowState?.expandedTreeDatabaseSchemas.remove(key) + } + case .table(let ref): + let key = DatabaseTableKey(database: ref.database, schema: ref.schema, table: ref.table.name) + if expanded { + windowState?.expandedTreeTables.insert(key) + } else { + windowState?.expandedTreeTables.remove(key) + } + case .recentTable, .routine, .status, .redisNode: + break + } + } + + internal func triggerLoad(for node: DatabaseTreeNode) { + switch node.kind { + case .database(let metadata): + if supportsSchemaLevel { + if isIdle(service.schemaListState(connectionId: connectionId, database: metadata.name)) { + Task { await service.loadSchemas(connectionId: connectionId, database: metadata.name) } + } + loadExternalSchemaNames(database: metadata.name) + } else { + loadObjects(database: metadata.name, schema: nil) + } + case .schema(let database, let schema): + loadObjects(database: database, schema: schema) + case .table(let ref): + loadPartitions(ref) + case .hierarchicalSchemaSection(let schema): + loadHierarchicalSchemaTables(schema) + case .recentSection, .recentTable, .routine, .status, + .objectKindSection, .redisKeysSection, .redisNode: + break + } + } + + private func loadHierarchicalSchemaTables(_ schema: String) { + guard case .idle = schemaService.schemaState(for: connectionId, schema: schema), + let driver = DatabaseManager.shared.driver(for: connectionId) else { return } + let connectionId = connectionId + Task { await schemaService.loadSchemaTables(connectionId: connectionId, schema: schema, driver: driver) } + } + + private func loadExternalSchemaNames(database: String) { + guard let session = DatabaseManager.shared.session(for: connectionId), + DatabaseManager.shared.browseDatabaseName(for: session.connection) == database, + let driver = DatabaseManager.shared.driver(for: connectionId) + else { return } + let connectionId = connectionId + Task { + await ExternalSchemaTracker.shared.load( + connectionId: connectionId, + database: database, + driver: driver + ) + } + } + + private func loadPartitions(_ ref: DatabaseTreeTableRef) { + guard ref.table.type == .partitionedTable else { return } + let state = service.partitionsLoadState( + connectionId: connectionId, database: ref.database, schema: ref.schema, table: ref.table.name + ) + guard isIdle(state) else { return } + Task { + await service.loadPartitions( + connectionId: connectionId, database: ref.database, schema: ref.schema, table: ref.table.name + ) + } + } + + private func loadObjects(database: String, schema: String?) { + if isIdle(service.tablesLoadState(connectionId: connectionId, database: database, schema: schema)) { + Task { await service.loadTables(connectionId: connectionId, database: database, schema: schema) } + } + if isIdle(service.routinesLoadState(connectionId: connectionId, database: database, schema: schema)) { + Task { await service.loadRoutines(connectionId: connectionId, database: database, schema: schema) } + } + } + + private func isIdle(_ state: MetadataLoadState) -> Bool { + if case .idle = state { return true } + return false + } +} diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Nodes.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Nodes.swift new file mode 100644 index 000000000..920fea616 --- /dev/null +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Nodes.swift @@ -0,0 +1,343 @@ +// +// DatabaseTreeOutlineCoordinator+Nodes.swift +// TablePro +// + +import AppKit +import TableProPluginKit + +/// Turning the connection's metadata into the rows the outline draws. The three sidebar shapes +/// differ only in what this builds at the root; everything below the root is shared. +extension DatabaseTreeOutlineCoordinator { + private func node(id: String, kind: DatabaseTreeNode.Kind) -> DatabaseTreeNode { + if let existing = nodeCache[id] { + existing.kind = kind + return existing + } + let created = DatabaseTreeNode(id: id, kind: kind) + nodeCache[id] = created + return created + } + + internal func resolvedChildren(of item: Any?) -> [DatabaseTreeNode] { + let key = (item as? DatabaseTreeNode)?.id ?? "" + if let cached = childrenCache[key] { return cached } + let built = buildChildren(of: item as? DatabaseTreeNode) + childrenCache[key] = built + return built + } + + private func buildChildren(of node: DatabaseTreeNode?) -> [DatabaseTreeNode] { + guard let node else { return rootNodes() } + switch node.kind { + case .recentSection: + return recentTableRefs().map { + self.node(id: DatabaseTreeNode.recentTableId($0), kind: .recentTable($0)) + } + case .database(let metadata): + return supportsSchemaLevel + ? schemaNodes(database: metadata.name) + : objectNodes(database: metadata.name, schema: nil) + case .schema(let database, let schema): + return objectNodes(database: database, schema: schema) + case .table(let ref): + return ref.table.type == .partitionedTable ? partitionNodes(of: ref) : [] + case .objectKindSection(let kind): + return flatObjectNodes(for: kind) + case .hierarchicalSchemaSection(let schema): + return hierarchicalTableNodes(schema: schema) + case .redisKeysSection: + return redisChildren(of: nil) + case .redisNode(let redisNode): + return redisChildren(of: redisNode) + case .recentTable, .routine, .status: + return [] + } + } + + private func partitionNodes(of ref: DatabaseTreeTableRef) -> [DatabaseTreeNode] { + let parentId = DatabaseTreeNode.tableId(ref) + let state = service.partitionsLoadState( + connectionId: connectionId, database: ref.database, schema: ref.schema, table: ref.table.name + ) + switch state { + case .idle, .loading: + return [statusNode(parentId: parentId, status: .loading)] + case .failed(let message): + return [statusNode(parentId: parentId, status: .error(message))] + case .loaded(let partitions): + if partitions.isEmpty { return [statusNode(parentId: parentId, status: .empty)] } + return partitions.map { partition in + let childRef = DatabaseTreeTableRef(database: ref.database, schema: ref.schema, table: partition) + return node(id: DatabaseTreeNode.tableId(childRef), kind: .table(childRef)) + } + } + } + + /// Which shape the root takes. The three sidebar modes used to be three views; they are one + /// outline now and this is the only thing that still differs between them. + private var rootShape: SidebarRootShape { + SidebarRootShapeResolver.resolve( + groupingStrategy: PluginManager.shared.databaseGroupingStrategy(for: databaseType), + sidebarLayout: sidebarState?.sidebarLayout ?? .flat, + supportsDatabaseTree: PluginManager.shared.supportsDatabaseTree(for: databaseType) + ) + } + + private func rootNodes() -> [DatabaseTreeNode] { + switch rootShape { + case .databaseTree: return databaseTreeRootNodes() + case .flat: return flatRootNodes() + case .hierarchicalSchema: return hierarchicalRootNodes() + } + } + + private func databaseTreeRootNodes() -> [DatabaseTreeNode] { + var nodes: [DatabaseTreeNode] = [] + if !recentTableRefs().isEmpty { + nodes.append(node(id: DatabaseTreeNode.recentSectionId, kind: .recentSection)) + } + let visible = DatabaseTreeVisibility.visible( + databases: service.databases(for: connectionId), + selected: sidebarState?.databaseFilterSelected ?? [], + activeDatabase: mainCoordinator?.browseDatabaseName ?? activeDatabase + ) + let matched = searchText.isEmpty ? visible : visible.filter { databaseMatchesSearch($0) } + var seen = Set() + nodes += matched + .filter { seen.insert($0.id).inserted } + .map { node(id: DatabaseTreeNode.databaseId($0.name), kind: .database($0)) } + return nodes + } + + private var browsingDatabase: String? { + let name = mainCoordinator?.browseDatabaseName ?? activeDatabase ?? "" + return name.isEmpty ? nil : name + } + + private func flatRootNodes() -> [DatabaseTreeNode] { + var nodes: [DatabaseTreeNode] = [] + if !recentTableRefs().isEmpty { + nodes.append(node(id: DatabaseTreeNode.recentSectionId, kind: .recentSection)) + } + nodes += visibleObjectKinds().map { + node(id: DatabaseTreeNode.objectKindSectionId($0), kind: .objectKindSection($0)) + } + if sidebarState?.redisKeyTreeViewModel != nil { + nodes.append(node(id: DatabaseTreeNode.redisKeysSectionId, kind: .redisKeysSection)) + } + return nodes + } + + /// The section list is the same rule the flat list used, so a kind that was hidden before stays + /// hidden: Tables always shows, anything else needs both the capability and something in it. + private func visibleObjectKinds() -> [SidebarObjectKind] { + guard let viewModel else { return [] } + let capabilities = viewModel.capabilities(for: connectionId) + return SidebarObjectKind.allCases.filter { kind in + viewModel.sectionShouldRender( + kind: kind, + itemCount: flatItemCount(for: kind), + capabilities: capabilities + ) + } + } + + internal func flatItemCount(for kind: SidebarObjectKind) -> Int { + guard let viewModel else { return 0 } + if kind.isRoutine { + return viewModel.filteredRoutines(of: kind, from: schemaService.routines(for: connectionId)).count + } + return viewModel.filteredTables(of: kind, from: schemaService.tables(for: connectionId)).count + } + + private func flatObjectNodes(for kind: SidebarObjectKind) -> [DatabaseTreeNode] { + guard let viewModel else { return [] } + let database = browsingDatabase ?? "" + if kind.isRoutine { + return viewModel.filteredRoutines(of: kind, from: schemaService.routines(for: connectionId)) + .map { routine in + let ref = DatabaseTreeRoutineRef(database: database, schema: routine.schema, routine: routine) + return node(id: DatabaseTreeNode.routineId(ref), kind: .routine(ref)) + } + } + return viewModel.filteredTables(of: kind, from: schemaService.tables(for: connectionId)) + .map { table in + let ref = DatabaseTreeTableRef(database: database, schema: table.schema, table: table) + return node(id: DatabaseTreeNode.tableId(ref), kind: .table(ref)) + } + } + + private func hierarchicalRootNodes() -> [DatabaseTreeNode] { + var nodes: [DatabaseTreeNode] = [] + if !recentTableRefs().isEmpty { + nodes.append(node(id: DatabaseTreeNode.recentSectionId, kind: .recentSection)) + } + let hidden = systemSchemas + nodes += schemaService.schemas(for: connectionId) + .filter { !hidden.contains($0) } + .filter { searchText.isEmpty || hierarchicalSchemaMatches($0) } + .map { + node(id: DatabaseTreeNode.hierarchicalSchemaSectionId($0), kind: .hierarchicalSchemaSection(schema: $0)) + } + return nodes + } + + internal func hierarchicalSchemaMatches(_ schema: String) -> Bool { + DatabaseTreeFilter.hierarchicalSchemaIsVisible( + schema, + searchText: searchText, + isLoaded: isSchemaLoaded(schema), + tables: schemaService.tables(for: connectionId, schema: schema) + ) + } + + private func isSchemaLoaded(_ schema: String) -> Bool { + if case .loaded = schemaService.schemaState(for: connectionId, schema: schema) { return true } + return false + } + + private func hierarchicalTableNodes(schema: String) -> [DatabaseTreeNode] { + let parentId = DatabaseTreeNode.hierarchicalSchemaSectionId(schema) + switch schemaService.schemaState(for: connectionId, schema: schema) { + case .idle, .loading: + return [statusNode(parentId: parentId, status: .loading)] + case .failed(let message): + return [statusNode(parentId: parentId, status: .error(message))] + case .loaded: + let tables = DatabaseTreeFilter.hierarchicalTables( + schemaService.tables(for: connectionId, schema: schema), schema: schema, searchText: searchText + ) + guard !tables.isEmpty else { return [statusNode(parentId: parentId, status: .empty)] } + let database = browsingDatabase ?? "" + return tables.map { table in + let ref = DatabaseTreeTableRef(database: database, schema: schema, table: table) + return node(id: DatabaseTreeNode.tableId(ref), kind: .table(ref)) + } + } + } + + private func redisChildren(of parent: RedisKeyNode?) -> [DatabaseTreeNode] { + guard let keyTree = sidebarState?.redisKeyTreeViewModel else { return [] } + if let parent { + guard case .namespace(_, _, let children, _) = parent else { return [] } + return children.map { node(id: DatabaseTreeNode.redisNodeId($0), kind: .redisNode($0)) } + } + if keyTree.isLoading { + return [statusNode(parentId: DatabaseTreeNode.redisKeysSectionId, status: .loading)] + } + let roots = keyTree.displayNodes(searchText: searchText) + guard !roots.isEmpty else { + return [statusNode(parentId: DatabaseTreeNode.redisKeysSectionId, status: .empty)] + } + var nodes = roots.map { node(id: DatabaseTreeNode.redisNodeId($0), kind: .redisNode($0)) } + if keyTree.isTruncated { + nodes.append( + statusNode( + parentId: DatabaseTreeNode.redisKeysSectionId, + status: .truncated(RedisKeyTreeTruncation.message(limit: RedisKeyTreeViewModel.maxKeys)) + ) + ) + } + return nodes + } + + private func recentTableRefs() -> [DatabaseTreeTableRef] { + guard let sidebarState, showRecentTables else { return [] } + let database = mainCoordinator?.browseDatabaseName ?? activeDatabase ?? "" + return sidebarState.recentEntries(inDatabase: database).compactMap { entry -> DatabaseTreeTableRef? in + if !searchText.isEmpty, !DatabaseTreeFilter.matches(searchText, entry.name) { return nil } + return DatabaseTreeTableRef(database: database, schema: entry.schema, table: entry.tableInfo) + } + } + + private func schemaNodes(database: String) -> [DatabaseTreeNode] { + let parentId = DatabaseTreeNode.databaseId(database) + switch service.schemaListState(connectionId: connectionId, database: database) { + case .idle, .loading: + return [statusNode(parentId: parentId, status: .loading)] + case .failed(let message): + return [statusNode(parentId: parentId, status: .error(message))] + case .loaded(let schemas): + let visible = DatabaseTreeFilter.visibleSchemas( + schemas, + systemSchemas: systemSchemas, + searchText: searchText, + contentMatches: { schemaContentMatchesSearch(database: database, schema: $0) } + ) + if visible.isEmpty { return [statusNode(parentId: parentId, status: .empty)] } + return visible.map { + node(id: DatabaseTreeNode.schemaId(database: database, schema: $0), kind: .schema(database: database, schema: $0)) + } + } + } + + private func objectNodes(database: String, schema: String?) -> [DatabaseTreeNode] { + let parentId = schema.map { DatabaseTreeNode.schemaId(database: database, schema: $0) } + ?? DatabaseTreeNode.databaseId(database) + switch service.tablesLoadState(connectionId: connectionId, database: database, schema: schema) { + case .idle, .loading: + return [statusNode(parentId: parentId, status: .loading)] + case .failed(let message): + return [statusNode(parentId: parentId, status: .error(message))] + case .loaded: + return loadedObjectNodes(database: database, schema: schema, parentId: parentId) + } + } + + private func loadedObjectNodes(database: String, schema: String?, parentId: String) -> [DatabaseTreeNode] { + let tables = DatabaseTreeFilter.filteredTables( + service.tables(connectionId: connectionId, database: database, schema: schema), searchText: searchText + ) + let routines = DatabaseTreeFilter.filteredRoutines( + service.routines(connectionId: connectionId, database: database, schema: schema), searchText: searchText + ) + let routinesState = service.routinesLoadState(connectionId: connectionId, database: database, schema: schema) + + guard !tables.isEmpty || !routines.isEmpty else { + switch routinesState { + case .failed(let message): return [statusNode(parentId: parentId, status: .error(message))] + case .loaded: return [statusNode(parentId: parentId, status: .empty)] + case .idle, .loading: return [statusNode(parentId: parentId, status: .loading)] + } + } + + var nodes: [DatabaseTreeNode] = tables.map { table in + let ref = DatabaseTreeTableRef(database: database, schema: schema, table: table) + return node(id: DatabaseTreeNode.tableId(ref), kind: .table(ref)) + } + nodes += routines.map { routine in + let ref = DatabaseTreeRoutineRef(database: database, schema: schema, routine: routine) + return node(id: DatabaseTreeNode.routineId(ref), kind: .routine(ref)) + } + if case .failed(let message) = routinesState { + nodes.append(statusNode(parentId: parentId, status: .error(message))) + } + return nodes + } + + private func statusNode(parentId: String, status: DatabaseTreeNode.Status) -> DatabaseTreeNode { + node(id: DatabaseTreeNode.statusId(parentId: parentId, status: status), kind: .status(status)) + } + + // MARK: - Search + + internal func databaseMatchesSearch(_ metadata: DatabaseMetadata) -> Bool { + if DatabaseTreeFilter.matches(searchText, metadata.name) { return true } + if case .loaded(let schemas) = service.schemaListState(connectionId: connectionId, database: metadata.name) { + if schemas.contains(where: { DatabaseTreeFilter.matches(searchText, $0) }) { return true } + for schema in schemas where schemaContentMatchesSearch(database: metadata.name, schema: schema) { + return true + } + } + return schemaContentMatchesSearch(database: metadata.name, schema: nil) + } + + internal func schemaContentMatchesSearch(database: String, schema: String?) -> Bool { + if let schema, DatabaseTreeFilter.matches(searchText, schema) { return true } + let tables = service.tables(connectionId: connectionId, database: database, schema: schema) + if tables.contains(where: { DatabaseTreeFilter.matches(searchText, $0.name) }) { return true } + let routines = service.routines(connectionId: connectionId, database: database, schema: schema) + return routines.contains { DatabaseTreeFilter.matches(searchText, $0.name) } + } +} diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift index 4cf59fd29..f35b3df63 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift @@ -9,46 +9,48 @@ import TableProPluginKit @MainActor final class DatabaseTreeOutlineCoordinator: NSObject { - private weak var outlineView: NSOutlineView? - private let service = DatabaseTreeMetadataService.shared + internal weak var outlineView: NSOutlineView? + internal let service = DatabaseTreeMetadataService.shared private static let cellIdentifier = NSUserInterfaceItemIdentifier("DatabaseTreeCell") - private var connectionId = UUID() - private var databaseType: DatabaseType = .mysql - private weak var mainCoordinator: MainContentCoordinator? - private var windowState: WindowSidebarState? - private var sidebarState: SharedSidebarState? - private weak var viewModel: SidebarViewModel? - private var searchText = "" + internal var connectionId = UUID() + internal var databaseType: DatabaseType = .mysql + internal weak var mainCoordinator: MainContentCoordinator? + internal var windowState: WindowSidebarState? + internal var sidebarState: SharedSidebarState? + internal weak var viewModel: SidebarViewModel? + internal var searchText = "" private var isConnected = false - private var activeDatabase: String? + internal var activeDatabase: String? private var activeSchema: String? private var pendingTruncates: Set = [] private var pendingDeletes: Set = [] - private var showRecentTables = true + internal var showRecentTables = true - private var nodeCache: [String: DatabaseTreeNode] = [:] - private var childrenCache: [String: [DatabaseTreeNode]] = [:] + internal var nodeCache: [String: DatabaseTreeNode] = [:] + internal var childrenCache: [String: [DatabaseTreeNode]] = [:] + private var cachedRowContext: DatabaseTreeRowContext? + private var cachedRowActions: DatabaseTreeRowActions? private var lastSelection: Set = [] private var lastSelectedNodeIds: [String] = [] private var publishedTables: Set = [] private var pendingOpenWork: DispatchWorkItem? - private var isApplyingExpansion = false + internal var isApplyingExpansion = false private var isSyncingSelection = false private var isReloading = false private var hasRenderedOnce = false private var reconcileScheduled = false private var observationGeneration = 0 - private let schemaService = SchemaService.shared + internal let schemaService = SchemaService.shared private var favoriteTables: Set = [] private var favoritesObserver: (any NSObjectProtocol)? - private var supportsSchemaLevel: Bool { + internal var supportsSchemaLevel: Bool { PluginManager.shared.databaseGroupingStrategy(for: databaseType) == .bySchema } - private var systemSchemas: Set { + internal var systemSchemas: Set { Set(PluginManager.shared.systemSchemaNames(for: databaseType)) } @@ -84,7 +86,8 @@ final class DatabaseTreeOutlineCoordinator: NSObject { viewModel = view.viewModel let activeChanged = activeDatabase != view.activeDatabase || activeSchema != view.activeSchema - let changed = searchText != view.searchText + let changed = connectionChanged + || searchText != view.searchText || isConnected != view.isConnected || activeChanged || pendingTruncates != view.pendingTruncates @@ -103,14 +106,12 @@ final class DatabaseTreeOutlineCoordinator: NSObject { persistActiveExpansion() } - if !hasRenderedOnce { + guard hasRenderedOnce, !changed else { hasRenderedOnce = true refresh() - } else if changed { - refresh() - } else { - syncSelectionToModel() + return } + syncSelectionToModel() } private func persistActiveExpansion() { @@ -153,6 +154,11 @@ final class DatabaseTreeOutlineCoordinator: NSObject { private func snapshotDependencies() { _ = service.databaseListState(for: connectionId) _ = sidebarState?.recentTables + /// Both feed `rootNodes()`: the layout picks the root's shape and the filter picks which + /// databases survive into it. Left unobserved, hiding a database in the filter popover + /// changed nothing until some other edit happened to rebuild the tree. + _ = sidebarState?.sidebarLayout + _ = sidebarState?.databaseFilterSelected /// One token covers every table, routine and per-schema load for this connection, which is /// the whole reactive surface the flat and hierarchical shapes read. _ = schemaService.generationToken(for: connectionId) @@ -187,6 +193,7 @@ final class DatabaseTreeOutlineCoordinator: NSObject { guard let outlineView else { return } isReloading = true childrenCache.removeAll() + invalidateRowConfiguration() outlineView.reloadData() applyDesiredExpansion() syncSelectionToModel() @@ -198,13 +205,11 @@ final class DatabaseTreeOutlineCoordinator: NSObject { /// place. Reloading would throw away every hosted SwiftUI view to repaint one glyph. private func refreshVisibleRows() { guard let outlineView else { return } - let context = rowContext() - let actions = rowActions() for row in 0.. DatabaseTreeNode { - if let existing = nodeCache[id] { - existing.kind = kind - return existing - } - let created = DatabaseTreeNode(id: id, kind: kind) - nodeCache[id] = created - return created - } - - private func resolvedChildren(of item: Any?) -> [DatabaseTreeNode] { - let key = (item as? DatabaseTreeNode)?.id ?? "" - if let cached = childrenCache[key] { return cached } - let built = buildChildren(of: item as? DatabaseTreeNode) - childrenCache[key] = built - return built - } - - private func buildChildren(of node: DatabaseTreeNode?) -> [DatabaseTreeNode] { - guard let node else { return rootNodes() } - switch node.kind { - case .recentSection: - return recentTableRefs().map { - self.node(id: DatabaseTreeNode.recentTableId($0), kind: .recentTable($0)) - } - case .database(let metadata): - return supportsSchemaLevel - ? schemaNodes(database: metadata.name) - : objectNodes(database: metadata.name, schema: nil) - case .schema(let database, let schema): - return objectNodes(database: database, schema: schema) - case .table(let ref): - return ref.table.type == .partitionedTable ? partitionNodes(of: ref) : [] - case .objectKindSection(let kind): - return flatObjectNodes(for: kind) - case .hierarchicalSchemaSection(let schema): - return hierarchicalTableNodes(schema: schema) - case .redisKeysSection: - return redisChildren(of: nil) - case .redisNode(let redisNode): - return redisChildren(of: redisNode) - case .recentTable, .routine, .status: - return [] - } - } - - private func partitionNodes(of ref: DatabaseTreeTableRef) -> [DatabaseTreeNode] { - let parentId = DatabaseTreeNode.tableId(ref) - let state = service.partitionsLoadState( - connectionId: connectionId, database: ref.database, schema: ref.schema, table: ref.table.name - ) - switch state { - case .idle, .loading: - return [statusNode(parentId: parentId, status: .loading)] - case .failed(let message): - return [statusNode(parentId: parentId, status: .error(message))] - case .loaded(let partitions): - if partitions.isEmpty { return [statusNode(parentId: parentId, status: .empty)] } - return partitions.map { partition in - let childRef = DatabaseTreeTableRef(database: ref.database, schema: ref.schema, table: partition) - return node(id: DatabaseTreeNode.tableId(childRef), kind: .table(childRef)) - } - } - } - - /// Which shape the root takes. The three sidebar modes used to be three views; they are one - /// outline now and this is the only thing that still differs between them. - private var rootShape: SidebarRootShape { - SidebarRootShapeResolver.resolve( - groupingStrategy: PluginManager.shared.databaseGroupingStrategy(for: databaseType), - sidebarLayout: sidebarState?.sidebarLayout ?? .flat, - supportsDatabaseTree: PluginManager.shared.supportsDatabaseTree(for: databaseType) - ) - } - - private func rootNodes() -> [DatabaseTreeNode] { - switch rootShape { - case .databaseTree: return databaseTreeRootNodes() - case .flat: return flatRootNodes() - case .hierarchicalSchema: return hierarchicalRootNodes() - } - } - - private func databaseTreeRootNodes() -> [DatabaseTreeNode] { - var nodes: [DatabaseTreeNode] = [] - if !recentTableRefs().isEmpty { - nodes.append(node(id: DatabaseTreeNode.recentSectionId, kind: .recentSection)) - } - let visible = DatabaseTreeVisibility.visible( - databases: service.databases(for: connectionId), - selected: sidebarState?.databaseFilterSelected ?? [], - activeDatabase: mainCoordinator?.browseDatabaseName ?? activeDatabase - ) - let matched = searchText.isEmpty ? visible : visible.filter { databaseMatchesSearch($0) } - var seen = Set() - nodes += matched - .filter { seen.insert($0.id).inserted } - .map { node(id: DatabaseTreeNode.databaseId($0.name), kind: .database($0)) } - return nodes - } - - private var browsingDatabase: String? { - let name = mainCoordinator?.browseDatabaseName ?? activeDatabase ?? "" - return name.isEmpty ? nil : name - } - - private func flatRootNodes() -> [DatabaseTreeNode] { - var nodes: [DatabaseTreeNode] = [] - if !recentTableRefs().isEmpty { - nodes.append(node(id: DatabaseTreeNode.recentSectionId, kind: .recentSection)) - } - nodes += visibleObjectKinds().map { - node(id: DatabaseTreeNode.objectKindSectionId($0), kind: .objectKindSection($0)) - } - if sidebarState?.redisKeyTreeViewModel != nil { - nodes.append(node(id: DatabaseTreeNode.redisKeysSectionId, kind: .redisKeysSection)) - } - return nodes - } - - /// The section list is the same rule the flat list used, so a kind that was hidden before stays - /// hidden: Tables always shows, anything else needs both the capability and something in it. - private func visibleObjectKinds() -> [SidebarObjectKind] { - guard let viewModel else { return [] } - let capabilities = viewModel.capabilities(for: connectionId) - return SidebarObjectKind.allCases.filter { kind in - viewModel.sectionShouldRender( - kind: kind, - itemCount: flatItemCount(for: kind), - capabilities: capabilities - ) - } - } - - private func flatItemCount(for kind: SidebarObjectKind) -> Int { - guard let viewModel else { return 0 } - if kind.isRoutine { - return viewModel.filteredRoutines(of: kind, from: schemaService.routines(for: connectionId)).count - } - return viewModel.filteredTables(of: kind, from: schemaService.tables(for: connectionId)).count - } - - private func flatObjectNodes(for kind: SidebarObjectKind) -> [DatabaseTreeNode] { - guard let viewModel else { return [] } - let database = browsingDatabase ?? "" - if kind.isRoutine { - return viewModel.filteredRoutines(of: kind, from: schemaService.routines(for: connectionId)) - .map { routine in - let ref = DatabaseTreeRoutineRef(database: database, schema: routine.schema, routine: routine) - return node(id: DatabaseTreeNode.routineId(ref), kind: .routine(ref)) - } - } - return viewModel.filteredTables(of: kind, from: schemaService.tables(for: connectionId)) - .map { table in - let ref = DatabaseTreeTableRef(database: database, schema: table.schema, table: table) - return node(id: DatabaseTreeNode.tableId(ref), kind: .table(ref)) - } - } - - private func hierarchicalRootNodes() -> [DatabaseTreeNode] { - var nodes: [DatabaseTreeNode] = [] - if !recentTableRefs().isEmpty { - nodes.append(node(id: DatabaseTreeNode.recentSectionId, kind: .recentSection)) - } - let hidden = systemSchemas - nodes += schemaService.schemas(for: connectionId) - .filter { !hidden.contains($0) } - .filter { searchText.isEmpty || hierarchicalSchemaMatches($0) } - .map { - node(id: DatabaseTreeNode.hierarchicalSchemaSectionId($0), kind: .hierarchicalSchemaSection(schema: $0)) - } - return nodes - } - - private func hierarchicalSchemaMatches(_ schema: String) -> Bool { - DatabaseTreeFilter.hierarchicalSchemaIsVisible( - schema, - searchText: searchText, - isLoaded: isSchemaLoaded(schema), - tables: schemaService.tables(for: connectionId, schema: schema) - ) - } - - private func isSchemaLoaded(_ schema: String) -> Bool { - if case .loaded = schemaService.schemaState(for: connectionId, schema: schema) { return true } - return false - } - - private func hierarchicalTableNodes(schema: String) -> [DatabaseTreeNode] { - let parentId = DatabaseTreeNode.hierarchicalSchemaSectionId(schema) - switch schemaService.schemaState(for: connectionId, schema: schema) { - case .idle, .loading: - return [statusNode(parentId: parentId, status: .loading)] - case .failed(let message): - return [statusNode(parentId: parentId, status: .error(message))] - case .loaded: - let tables = DatabaseTreeFilter.hierarchicalTables( - schemaService.tables(for: connectionId, schema: schema), schema: schema, searchText: searchText - ) - guard !tables.isEmpty else { return [statusNode(parentId: parentId, status: .empty)] } - let database = browsingDatabase ?? "" - return tables.map { table in - let ref = DatabaseTreeTableRef(database: database, schema: schema, table: table) - return node(id: DatabaseTreeNode.tableId(ref), kind: .table(ref)) - } - } - } - - private func redisChildren(of parent: RedisKeyNode?) -> [DatabaseTreeNode] { - guard let keyTree = sidebarState?.redisKeyTreeViewModel else { return [] } - if let parent { - guard case .namespace(_, _, let children, _) = parent else { return [] } - return children.map { node(id: DatabaseTreeNode.redisNodeId($0), kind: .redisNode($0)) } - } - if keyTree.isLoading { - return [statusNode(parentId: DatabaseTreeNode.redisKeysSectionId, status: .loading)] - } - let roots = keyTree.displayNodes(searchText: searchText) - guard !roots.isEmpty else { - return [statusNode(parentId: DatabaseTreeNode.redisKeysSectionId, status: .empty)] - } - var nodes = roots.map { node(id: DatabaseTreeNode.redisNodeId($0), kind: .redisNode($0)) } - if keyTree.isTruncated { - nodes.append( - statusNode( - parentId: DatabaseTreeNode.redisKeysSectionId, - status: .truncated(RedisKeyTreeTruncation.message(limit: RedisKeyTreeViewModel.maxKeys)) - ) - ) - } - return nodes - } - - private func recentTableRefs() -> [DatabaseTreeTableRef] { - guard let sidebarState, showRecentTables else { return [] } - let database = mainCoordinator?.browseDatabaseName ?? activeDatabase ?? "" - return sidebarState.recentEntries(inDatabase: database).compactMap { entry -> DatabaseTreeTableRef? in - if !searchText.isEmpty, !DatabaseTreeFilter.matches(searchText, entry.name) { return nil } - return DatabaseTreeTableRef(database: database, schema: entry.schema, table: entry.tableInfo) - } - } - - private func schemaNodes(database: String) -> [DatabaseTreeNode] { - let parentId = DatabaseTreeNode.databaseId(database) - switch service.schemaListState(connectionId: connectionId, database: database) { - case .idle, .loading: - return [statusNode(parentId: parentId, status: .loading)] - case .failed(let message): - return [statusNode(parentId: parentId, status: .error(message))] - case .loaded(let schemas): - let visible = DatabaseTreeFilter.visibleSchemas( - schemas, - systemSchemas: systemSchemas, - searchText: searchText, - contentMatches: { schemaContentMatchesSearch(database: database, schema: $0) } - ) - if visible.isEmpty { return [statusNode(parentId: parentId, status: .empty)] } - return visible.map { - node(id: DatabaseTreeNode.schemaId(database: database, schema: $0), kind: .schema(database: database, schema: $0)) - } - } - } - - private func objectNodes(database: String, schema: String?) -> [DatabaseTreeNode] { - let parentId = schema.map { DatabaseTreeNode.schemaId(database: database, schema: $0) } - ?? DatabaseTreeNode.databaseId(database) - switch service.tablesLoadState(connectionId: connectionId, database: database, schema: schema) { - case .idle, .loading: - return [statusNode(parentId: parentId, status: .loading)] - case .failed(let message): - return [statusNode(parentId: parentId, status: .error(message))] - case .loaded: - return loadedObjectNodes(database: database, schema: schema, parentId: parentId) - } - } - - private func loadedObjectNodes(database: String, schema: String?, parentId: String) -> [DatabaseTreeNode] { - let tables = DatabaseTreeFilter.filteredTables( - service.tables(connectionId: connectionId, database: database, schema: schema), searchText: searchText - ) - let routines = DatabaseTreeFilter.filteredRoutines( - service.routines(connectionId: connectionId, database: database, schema: schema), searchText: searchText - ) - let routinesState = service.routinesLoadState(connectionId: connectionId, database: database, schema: schema) - - guard !tables.isEmpty || !routines.isEmpty else { - switch routinesState { - case .failed(let message): return [statusNode(parentId: parentId, status: .error(message))] - case .loaded: return [statusNode(parentId: parentId, status: .empty)] - case .idle, .loading: return [statusNode(parentId: parentId, status: .loading)] - } - } - - var nodes: [DatabaseTreeNode] = tables.map { table in - let ref = DatabaseTreeTableRef(database: database, schema: schema, table: table) - return node(id: DatabaseTreeNode.tableId(ref), kind: .table(ref)) - } - nodes += routines.map { routine in - let ref = DatabaseTreeRoutineRef(database: database, schema: schema, routine: routine) - return node(id: DatabaseTreeNode.routineId(ref), kind: .routine(ref)) - } - if case .failed(let message) = routinesState { - nodes.append(statusNode(parentId: parentId, status: .error(message))) - } - return nodes - } - - private func statusNode(parentId: String, status: DatabaseTreeNode.Status) -> DatabaseTreeNode { - node(id: DatabaseTreeNode.statusId(parentId: parentId, status: status), kind: .status(status)) - } - - // MARK: - Search - - private func databaseMatchesSearch(_ metadata: DatabaseMetadata) -> Bool { - if DatabaseTreeFilter.matches(searchText, metadata.name) { return true } - if case .loaded(let schemas) = service.schemaListState(connectionId: connectionId, database: metadata.name) { - if schemas.contains(where: { DatabaseTreeFilter.matches(searchText, $0) }) { return true } - for schema in schemas where schemaContentMatchesSearch(database: metadata.name, schema: schema) { - return true - } - } - return schemaContentMatchesSearch(database: metadata.name, schema: nil) - } - - private func schemaContentMatchesSearch(database: String, schema: String?) -> Bool { - if let schema, DatabaseTreeFilter.matches(searchText, schema) { return true } - let tables = service.tables(connectionId: connectionId, database: database, schema: schema) - if tables.contains(where: { DatabaseTreeFilter.matches(searchText, $0.name) }) { return true } - let routines = service.routines(connectionId: connectionId, database: database, schema: schema) - return routines.contains { DatabaseTreeFilter.matches(searchText, $0.name) } - } - - // MARK: - Expansion - - private func applyDesiredExpansion() { - guard let outlineView else { return } - isApplyingExpansion = true - defer { isApplyingExpansion = false } - let searching = !searchText.isEmpty - for rootNode in resolvedChildren(of: nil) where rootNode.id == DatabaseTreeNode.recentSectionId { - setExpanded(rootNode, searching || (viewModel?.isRecentsExpanded ?? true)) - } - for sectionNode in resolvedChildren(of: nil) { - switch sectionNode.kind { - case .objectKindSection(let kind): - let hasMatches = flatItemCount(for: kind) > 0 - setExpanded(sectionNode, viewModel?.effectiveExpanded(kind: kind, hasMatches: hasMatches) ?? true) - case .redisKeysSection: - setExpanded(sectionNode, searching || (viewModel?.isRedisKeysExpanded ?? true)) - case .hierarchicalSchemaSection(let schema): - let want = searching - ? hierarchicalSchemaMatches(schema) - : windowState?.expandedTreeSchemas.contains(schema) ?? false - setExpanded(sectionNode, want) - if outlineView.isItemExpanded(sectionNode) { triggerLoad(for: sectionNode) } - default: - break - } - } - for databaseNode in resolvedChildren(of: nil) { - guard case .database(let metadata) = databaseNode.kind else { continue } - let want = searching - ? databaseMatchesSearch(metadata) - : windowState?.expandedTreeDatabases.contains(metadata.name) ?? false - setExpanded(databaseNode, want) - guard outlineView.isItemExpanded(databaseNode) else { continue } - triggerLoad(for: databaseNode) - guard supportsSchemaLevel else { - restorePartitionExpansion(under: databaseNode) - continue - } - for schemaNode in resolvedChildren(of: databaseNode) { - guard case .schema(let database, let schema) = schemaNode.kind else { continue } - let wantSchema = searching - ? DatabaseTreeFilter.matches(searchText, schema) || schemaContentMatchesSearch(database: database, schema: schema) - : windowState?.expandedTreeDatabaseSchemas.contains(DatabaseSchemaKey(database: database, schema: schema)) ?? false - setExpanded(schemaNode, wantSchema) - if outlineView.isItemExpanded(schemaNode) { - triggerLoad(for: schemaNode) - restorePartitionExpansion(under: schemaNode) - } - } - } - } - - private func restorePartitionExpansion(under parent: DatabaseTreeNode) { - guard searchText.isEmpty, let outlineView, let windowState else { return } - for tableNode in resolvedChildren(of: parent) { - guard case .table(let ref) = tableNode.kind, ref.table.type == .partitionedTable else { continue } - let key = DatabaseTableKey(database: ref.database, schema: ref.schema, table: ref.table.name) - guard windowState.expandedTreeTables.contains(key) else { continue } - setExpanded(tableNode, true) - guard outlineView.isItemExpanded(tableNode) else { continue } - triggerLoad(for: tableNode) - restorePartitionExpansion(under: tableNode) - } - } - - private func setExpanded(_ node: DatabaseTreeNode, _ expanded: Bool) { - guard let outlineView else { return } - if expanded, !outlineView.isItemExpanded(node) { - outlineView.expandItem(node) - } else if !expanded, outlineView.isItemExpanded(node) { - outlineView.collapseItem(node) - } - } - - private func recordExpansion(_ node: DatabaseTreeNode, expanded: Bool) { - switch node.kind { - case .recentSection: - viewModel?.isRecentsExpanded = expanded - case .objectKindSection(let kind): - viewModel?.expanded[kind] = expanded - case .redisKeysSection: - viewModel?.isRedisKeysExpanded = expanded - case .hierarchicalSchemaSection(let schema): - if expanded { - windowState?.expandedTreeSchemas.insert(schema) - } else { - windowState?.expandedTreeSchemas.remove(schema) - } - case .database(let metadata): - if expanded { - windowState?.expandedTreeDatabases.insert(metadata.name) - } else { - windowState?.expandedTreeDatabases.remove(metadata.name) - } - case .schema(let database, let schema): - let key = DatabaseSchemaKey(database: database, schema: schema) - if expanded { - windowState?.expandedTreeDatabaseSchemas.insert(key) - } else { - windowState?.expandedTreeDatabaseSchemas.remove(key) - } - case .table(let ref): - let key = DatabaseTableKey(database: ref.database, schema: ref.schema, table: ref.table.name) - if expanded { - windowState?.expandedTreeTables.insert(key) - } else { - windowState?.expandedTreeTables.remove(key) - } - case .recentTable, .routine, .status, .redisNode: - break - } - } - - private func triggerLoad(for node: DatabaseTreeNode) { - switch node.kind { - case .database(let metadata): - if supportsSchemaLevel { - if isIdle(service.schemaListState(connectionId: connectionId, database: metadata.name)) { - Task { await service.loadSchemas(connectionId: connectionId, database: metadata.name) } - } - loadExternalSchemaNames(database: metadata.name) - } else { - loadObjects(database: metadata.name, schema: nil) - } - case .schema(let database, let schema): - loadObjects(database: database, schema: schema) - case .table(let ref): - loadPartitions(ref) - case .hierarchicalSchemaSection(let schema): - loadHierarchicalSchemaTables(schema) - case .recentSection, .recentTable, .routine, .status, - .objectKindSection, .redisKeysSection, .redisNode: - break - } - } - - private func loadHierarchicalSchemaTables(_ schema: String) { - guard case .idle = schemaService.schemaState(for: connectionId, schema: schema), - let driver = DatabaseManager.shared.driver(for: connectionId) else { return } - let connectionId = connectionId - Task { await schemaService.loadSchemaTables(connectionId: connectionId, schema: schema, driver: driver) } - } - - private func loadExternalSchemaNames(database: String) { - guard let session = DatabaseManager.shared.session(for: connectionId), - DatabaseManager.shared.browseDatabaseName(for: session.connection) == database, - let driver = DatabaseManager.shared.driver(for: connectionId) - else { return } - let connectionId = connectionId - Task { - await ExternalSchemaTracker.shared.load( - connectionId: connectionId, - database: database, - driver: driver - ) - } - } - - private func loadPartitions(_ ref: DatabaseTreeTableRef) { - guard ref.table.type == .partitionedTable else { return } - let state = service.partitionsLoadState( - connectionId: connectionId, database: ref.database, schema: ref.schema, table: ref.table.name - ) - guard isIdle(state) else { return } - Task { - await service.loadPartitions( - connectionId: connectionId, database: ref.database, schema: ref.schema, table: ref.table.name - ) - } - } - - private func loadObjects(database: String, schema: String?) { - if isIdle(service.tablesLoadState(connectionId: connectionId, database: database, schema: schema)) { - Task { await service.loadTables(connectionId: connectionId, database: database, schema: schema) } - } - if isIdle(service.routinesLoadState(connectionId: connectionId, database: database, schema: schema)) { - Task { await service.loadRoutines(connectionId: connectionId, database: database, schema: schema) } - } - } - - private func isIdle(_ state: MetadataLoadState) -> Bool { - if case .idle = state { return true } - return false - } - // MARK: - Selection / open private func selectedRefs() -> [DatabaseTreeTableRef] { @@ -876,7 +361,30 @@ final class DatabaseTreeOutlineCoordinator: NSObject { } } - private func rowContext() -> DatabaseTreeRowContext { + /// Every visible row is handed the same context and the same action set, and both are pure + /// functions of the inputs `update(from:)` already tracks, so they are built once per refresh + /// instead of once per row. Rebuilding them in `viewFor` allocated a fresh set of closures for + /// every row the outline drew, on every reload and every scroll. + private var rowContext: DatabaseTreeRowContext { + if let cachedRowContext { return cachedRowContext } + let context = makeRowContext() + cachedRowContext = context + return context + } + + private var rowActions: DatabaseTreeRowActions { + if let cachedRowActions { return cachedRowActions } + let actions = makeRowActions() + cachedRowActions = actions + return actions + } + + private func invalidateRowConfiguration() { + cachedRowContext = nil + cachedRowActions = nil + } + + private func makeRowContext() -> DatabaseTreeRowContext { DatabaseTreeRowContext( databaseType: databaseType, activeDatabase: activeDatabase, @@ -903,7 +411,7 @@ final class DatabaseTreeOutlineCoordinator: NSObject { ) } - private func rowActions() -> DatabaseTreeRowActions { + private func makeRowActions() -> DatabaseTreeRowActions { DatabaseTreeRowActions( coordinator: mainCoordinator, isReadOnly: mainCoordinator?.safeModeLevel.blocksAllWrites ?? false, @@ -1001,7 +509,7 @@ extension DatabaseTreeOutlineCoordinator: NSOutlineViewDelegate { guard let node = item as? DatabaseTreeNode else { return nil } let cell = outlineView.makeView(withIdentifier: Self.cellIdentifier, owner: self) as? DatabaseTreeCellView ?? makeCell() - cell.configure(node: node, context: rowContext(), actions: rowActions()) + cell.configure(node: node, context: rowContext, actions: rowActions) return cell } diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 01b47b92d..c32335fab 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -45,9 +45,20 @@ struct SidebarView: View { PluginManager.shared.databaseGroupingStrategy(for: viewModel.databaseType) } + /// The one derivation of the sidebar's shape. The outline's coordinator calls the same resolver + /// with the same inputs, so the wrapper this view picks and the root the outline builds can + /// never describe different sidebars. + private var rootShape: SidebarRootShape { + SidebarRootShapeResolver.resolve( + groupingStrategy: groupingStrategy, + sidebarLayout: sidebarState.sidebarLayout, + supportsDatabaseTree: PluginManager.shared.supportsDatabaseTree(for: viewModel.databaseType) + ) + } + private var supportsSchemaFooter: Bool { guard PluginManager.shared.supportsSchemaSwitching(for: viewModel.databaseType) else { return false } - return groupingStrategy != .hierarchicalSchema && !usesDatabaseTree + return rootShape == .flat } init( @@ -156,12 +167,10 @@ struct SidebarView: View { @ViewBuilder private var tablesContent: some View { - if groupingStrategy == .hierarchicalSchema { - hierarchicalContent - } else if usesDatabaseTree { - databaseTreeContent - } else { - flatContent + switch rootShape { + case .hierarchicalSchema: hierarchicalContent + case .databaseTree: databaseTreeContent + case .flat: flatContent } } @@ -172,7 +181,7 @@ struct SidebarView: View { Divider() HStack(spacing: 8) { createObjectMenu - if usesDatabaseTree { + if rootShape == .databaseTree { databaseFilterButton } DelayedProgressIndicator(isActive: schemaService.isRefreshing(connectionId: connectionId)) @@ -237,11 +246,6 @@ struct SidebarView: View { .accessibilityIdentifier("sidebar-create-table") } - private var usesDatabaseTree: Bool { - PluginManager.shared.supportsDatabaseTree(for: viewModel.databaseType) - && sidebarState.sidebarLayout == .tree - } - @ViewBuilder private var databaseTreeContent: some View { DatabaseTreeView( From e46ee4dbe23bc02ccf9b06c1e6f35dd9ee2c3587 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 14 Aug 2026 20:18:51 +0700 Subject: [PATCH 38/47] fix(connections): let the workspace rail follow the connection its window is showing --- .../MainSplitViewController+Connection.swift | 12 ++++ .../MainSplitViewController.swift | 19 ++++-- .../NavigationSidebarViewController.swift | 4 +- .../Infrastructure/WorkspaceRailStore.swift | 19 +----- .../WorkspaceRailViewController.swift | 64 +++++++++++-------- TablePro/Resources/Localizable.xcstrings | 3 +- .../ConnectionWorkspaceRegistryTests.swift | 50 +++++++++++++++ .../Services/WorkspaceRailStoreTests.swift | 36 ++++++----- 8 files changed, 141 insertions(+), 66 deletions(-) diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+Connection.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+Connection.swift index 811ced298..c262eb429 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+Connection.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+Connection.swift @@ -7,6 +7,18 @@ import AppKit import Foundation import os +/// The rail asks the window which connection it is showing rather than remembering one, so the +/// window answers from the registry that already knows. +extension MainSplitViewController: WorkspaceRailHost { + internal var hostedConnectionIds: [UUID] { workspaces.connectionIds } + + internal var selectedConnectionId: UUID? { workspaces.selectedConnectionId } + + internal func selectHostedConnection(_ connectionId: UUID) { + workspaces.select(connectionId) + } +} + internal extension MainSplitViewController { private static var connectionLogger: Logger { Logger(subsystem: "com.TablePro", category: "ConnectionWindow") diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index f150f2473..69b110087 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -202,9 +202,8 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi splitView.dividerStyle = .thin splitView.isVertical = true - navigationSidebar = NavigationSidebarViewController( - connectionId: payload?.connectionId ?? currentSession?.connection.id - ) + navigationSidebar = NavigationSidebarViewController() + navigationSidebar.railController.host = self navigationSidebar.railController.onLayoutChange = { [weak self] _ in self?.navigationSidebar.applyRailWidth(animated: false) self?.recomputeWindowMinSize() @@ -241,6 +240,13 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi self?.applySelectedWorkspace() } + /// A connection joining or leaving the window changes what every rail in the app lists, + /// which is what this event is for. Which row is current is a separate question, answered + /// by the rail reading its host back, so a selection change must not come through here. + workspaces.onMembershipChange = { + AppEvents.shared.connectionWindowsChanged.send() + } + restoreUserPaneLayout() rebuildPanes() applyPaneChrome() @@ -457,10 +463,9 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi applyPaneChrome() applyWindowTitle() - /// The rail redraws from `WorkspaceRailStore.changes`, which listens to session and tab - /// events. Switching workspace in place fires none of them, so without this the rail kept - /// highlighting the connection the user just switched away from. - AppEvents.shared.connectionWindowsChanged.send() + /// Only this window's rail moved, and only its highlight. Broadcasting instead made every + /// rail in the app rebuild its whole entry list to answer a question none of them asked. + navigationSidebar?.railController.refreshSelection() } private func applyPhase() { diff --git a/TablePro/Core/Services/Infrastructure/NavigationSidebarViewController.swift b/TablePro/Core/Services/Infrastructure/NavigationSidebarViewController.swift index 358aef1a9..c32af11c7 100644 --- a/TablePro/Core/Services/Infrastructure/NavigationSidebarViewController.swift +++ b/TablePro/Core/Services/Infrastructure/NavigationSidebarViewController.swift @@ -24,8 +24,8 @@ internal final class NavigationSidebarViewController: NSViewController { internal private(set) var isRailVisible = false - internal init(connectionId: UUID?) { - self.railController = WorkspaceRailViewController(connectionId: connectionId) + internal init() { + self.railController = WorkspaceRailViewController() self.objectBrowser = SidebarContainerViewController(rootView: AnyView(Color.clear)) super.init(nibName: nil, bundle: nil) } diff --git a/TablePro/Core/Services/Infrastructure/WorkspaceRailStore.swift b/TablePro/Core/Services/Infrastructure/WorkspaceRailStore.swift index 63f649418..9c74b591e 100644 --- a/TablePro/Core/Services/Infrastructure/WorkspaceRailStore.swift +++ b/TablePro/Core/Services/Infrastructure/WorkspaceRailStore.swift @@ -114,9 +114,9 @@ internal enum WorkspaceRailStore { return WorkspaceID(connectionId: connectionId, container: container) } - /// The row the rail keeps selected. A window whose session has gone still belongs to - /// its connection, so it falls back to that connection's first row rather than showing - /// nothing selected until it reconnects. + /// The row the rail keeps selected, given the connection its window is currently showing. + /// A connection whose session has gone is still the one the window is on, so it falls back to + /// that connection's first row rather than showing nothing selected until it reconnects. internal static func selectedRow( connectionId: UUID?, browsed: WorkspaceID?, @@ -129,19 +129,6 @@ internal enum WorkspaceRailStore { return workspaces.firstIndex { $0.connectionId == connectionId } } - /// A rail always shows its own window's workspace. Dispatching to another connection hands - /// the user to a different window, so the rail that sent them there returns its selection to - /// where it belongs instead of standing on a row that describes somebody else. - /// - /// Nothing else restores it. `connectionWindowsChanged` reports a window becoming key only - /// the first time, so once every window has been focused once a rail left pointing at a - /// foreign row would stay there: the row it now needs to act on is the one it already thinks - /// is selected, and selecting it again is not a change, so the next click would do nothing. - internal static func shouldRestoreSelection(after target: WorkspaceID, railConnectionId: UUID?) -> Bool { - guard let railConnectionId else { return false } - return target.connectionId != railConnectionId - } - internal static var changes: AnyPublisher { let events = AppEvents.shared return Publishers.MergeMany( diff --git a/TablePro/Core/Services/Infrastructure/WorkspaceRailViewController.swift b/TablePro/Core/Services/Infrastructure/WorkspaceRailViewController.swift index c98e700ba..85186d4cf 100644 --- a/TablePro/Core/Services/Infrastructure/WorkspaceRailViewController.swift +++ b/TablePro/Core/Services/Infrastructure/WorkspaceRailViewController.swift @@ -17,6 +17,18 @@ private extension UserDefaults { } } +/// The window a rail belongs to. A window hosts several connections and shows one at a time, so +/// which row the rail highlights is a question only the window can answer, and the answer changes. +/// The rail used to capture the connection its window was created for, which could name the right +/// row exactly once: after the window switched to a second connection the rail kept highlighting +/// the first, and clicking the row it was already standing on did nothing. +@MainActor +internal protocol WorkspaceRailHost: AnyObject { + var hostedConnectionIds: [UUID] { get } + var selectedConnectionId: UUID? { get } + func selectHostedConnection(_ connectionId: UUID) +} + @MainActor internal final class WorkspaceRailViewController: NSViewController { private static let logger = Logger(subsystem: "com.TablePro", category: "WorkspaceRail") @@ -24,8 +36,8 @@ internal final class WorkspaceRailViewController: NSViewController { internal var onLayoutChange: ((WorkspaceRailMetrics.Layout) -> Void)? internal var onEntryCountChange: ((Int) -> Void)? + internal weak var host: (any WorkspaceRailHost)? - private let connectionId: UUID? private let scrollView = NSScrollView() private let tableView = NSTableView() @@ -42,14 +54,13 @@ internal final class WorkspaceRailViewController: NSViewController { private var sizeModeObservation: NSKeyValueObservation? private var contentTopConstraint: NSLayoutConstraint? - /// What the rail last put on screen as selected. A selection that differs from this came - /// from the user, whether by click, arrow key, type-select or VoiceOver, and is the one - /// signal the rail acts on. Recording the applied value rather than raising a re-entrancy - /// flag is what lets AppKit's own selection stand as the model. + /// What the rail last put on screen as selected, which after every `applySelection` is the + /// workspace the host is really showing. A commit for that same workspace has nothing to do, + /// and is the case the arrow keys hit constantly as they move the highlight across a row the + /// window is already on. private var appliedSelection: WorkspaceID? - internal init(connectionId: UUID?) { - self.connectionId = connectionId + internal init() { super.init(nibName: nil, bundle: nil) } @@ -162,7 +173,7 @@ internal final class WorkspaceRailViewController: NSViewController { /// Which window's rail a line came from. Every rail lists every workspace, so without this /// a log of two connections switching back and forth cannot be attributed. private var railName: String { - guard let connectionId else { return "none" } + guard let connectionId = host?.selectedConnectionId else { return "none" } return String(connectionId.uuidString.prefix(8)) } @@ -176,17 +187,23 @@ internal final class WorkspaceRailViewController: NSViewController { onLayoutChange?(resolved) } - /// The browsed container moves, so the selected row is resolved on every reload rather - /// than fixed at init the way the window's connection is. + /// Both halves move: the window switches which connection it shows, and that connection + /// switches which container it browses. Neither can be captured at init. private var activeWorkspace: WorkspaceID? { - guard let connectionId else { return nil } + guard let connectionId = host?.selectedConnectionId else { return nil } return WorkspaceRailStore.browsedWorkspace(for: connectionId) } + /// Called when the window changes which connection it is showing. The entry list is unchanged + /// by that, only which row is current, so this moves the highlight instead of reloading. + internal func refreshSelection() { + applySelection() + } + private func applySelection() { let browsed = activeWorkspace guard let row = WorkspaceRailStore.selectedRow( - connectionId: connectionId, + connectionId: host?.selectedConnectionId, browsed: browsed, in: entries.map(\.id) ) else { @@ -249,18 +266,20 @@ internal final class WorkspaceRailViewController: NSViewController { /// `ConnectionStorage` and would fail for a connection opened from a URL that was never /// saved. Moving between two containers of the same connection stays in one window and /// only moves that window's browse cursor. + /// + /// Both paths end at `applySelection`, which reads the host's own selection back. A workspace + /// this window hosts leaves the highlight on the row the user picked; one belonging to another + /// window leaves it where it was, because this window did not move. The rail needed a rule for + /// when to put its highlight back only while it was guessing at the answer. private func activate(_ workspace: WorkspaceID) { /// One window hosts every connection, so switching is a selection change in that /// window's own registry. Raising a different window is what made the rail read as a /// window switcher rather than a workspace switcher. - if let host = view.window?.contentViewController as? MainSplitViewController, - host.workspaces.contains(workspace.connectionId) { - host.workspaces.select(workspace.connectionId) - moveBrowseCursor(of: host.view.window ?? NSApp.keyWindow ?? NSApp.windows[0], to: workspace) - guard WorkspaceRailStore.shouldRestoreSelection( - after: workspace, - railConnectionId: connectionId - ) else { return } + if let host, host.hostedConnectionIds.contains(workspace.connectionId) { + host.selectHostedConnection(workspace.connectionId) + if let window = view.window { + moveBrowseCursor(of: window, to: workspace) + } applySelection() return } @@ -296,11 +315,6 @@ internal final class WorkspaceRailViewController: NSViewController { window.makeKeyAndOrderFront(nil) NSApp.activate() moveBrowseCursor(of: window, to: workspace) - - guard WorkspaceRailStore.shouldRestoreSelection( - after: workspace, - railConnectionId: connectionId - ) else { return } applySelection() } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index c1ec203af..03afbcce3 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -56506,6 +56506,7 @@ } } }, + "No matching opening bracket": {}, "No matching rows": { "localizations": { "tr": { @@ -68077,7 +68078,6 @@ } } }, - "Record Shortcut": {}, "Recording shortcut": { "localizations": { "tr": { @@ -93875,6 +93875,7 @@ } } }, + "Unterminated comment": {}, "Untitled": { "localizations": { "tr": { diff --git a/TableProTests/Core/Services/Infrastructure/ConnectionWorkspaceRegistryTests.swift b/TableProTests/Core/Services/Infrastructure/ConnectionWorkspaceRegistryTests.swift index 018d16c9b..c6b5819d7 100644 --- a/TableProTests/Core/Services/Infrastructure/ConnectionWorkspaceRegistryTests.swift +++ b/TableProTests/Core/Services/Infrastructure/ConnectionWorkspaceRegistryTests.swift @@ -51,6 +51,56 @@ struct ConnectionWorkspaceRegistryTests { #expect(registry.selectedConnectionId == alpha) } + /// The reported sequence: open a second connection from a window that already has one, watch it + /// fail, retry it. A failed attempt is a phase, not a departure, so the window stays on the + /// connection throughout and can still be switched away from afterwards. + @Test("A failed connection stays selected through its retry and can still be switched away from") + func failedConnectionKeepsItsPlace() throws { + let alpha = try #require(Self.alpha) + let beta = try #require(Self.beta) + let registry = ConnectionWorkspaceRegistry() + registry.insert(makeWorkspace(alpha)) + let second = registry.insert(makeWorkspace(beta)) + + second.phase = .unavailable(.failed(ConnectionFailureInfo(message: "refused"))) + #expect(registry.selectedConnectionId == beta) + + second.phase = .connecting + second.phase = .connected + #expect(registry.selectedConnectionId == beta) + + registry.select(alpha) + #expect(registry.selectedConnectionId == alpha) + registry.select(beta) + #expect(registry.selectedConnectionId == beta) + } + + /// The rail's entry list is rebuilt from this and its highlight from the selection, so a + /// membership change has to announce itself even when the selection lands where it already was. + @Test("Joining and leaving report membership separately from selection") + func membershipAndSelectionAreSeparateSignals() throws { + let alpha = try #require(Self.alpha) + let beta = try #require(Self.beta) + let registry = ConnectionWorkspaceRegistry() + var memberships = 0 + var selections = 0 + registry.onMembershipChange = { memberships += 1 } + registry.onSelectionChange = { _ in selections += 1 } + + registry.insert(makeWorkspace(alpha)) + registry.insert(makeWorkspace(beta)) + #expect(memberships == 2) + #expect(selections == 2) + + registry.select(alpha) + #expect(memberships == 2) + #expect(selections == 3) + + registry.remove(beta) + #expect(memberships == 3) + #expect(selections == 3) + } + @Test("Each workspace keeps its own phase and attempt token") func workspacesAreIsolated() throws { let alpha = try #require(Self.alpha) diff --git a/TableProTests/Core/Services/WorkspaceRailStoreTests.swift b/TableProTests/Core/Services/WorkspaceRailStoreTests.swift index 70672b69c..7153ae3f4 100644 --- a/TableProTests/Core/Services/WorkspaceRailStoreTests.swift +++ b/TableProTests/Core/Services/WorkspaceRailStoreTests.swift @@ -193,24 +193,30 @@ struct WorkspaceRailStoreTests { #expect(row == 1) } - @Test("Sending the user to another connection returns this rail's selection to its own workspace") - func dispatchingToAnotherConnectionRestoresSelection() { - let mine = UUID() - let theirs = WorkspaceID(connectionId: UUID(), container: "app") - #expect(WorkspaceRailStore.shouldRestoreSelection(after: theirs, railConnectionId: mine)) - } + /// The connection passed in is the one the window is showing now, not the one it was opened + /// with. A window hosts several and switches between them, so the same entry list has to + /// resolve to a different row as the window moves. + @Test("The selected row follows the connection the window switched to") + func selectionFollowsTheWindowsCurrentConnection() { + let first = UUID() + let second = UUID() + let opened = WorkspaceID(connectionId: first, container: "app") + let switchedTo = WorkspaceID(connectionId: second, container: "logs") + let entries = [opened, switchedTo] - @Test("Switching container inside this connection leaves the selection where the user put it") - func dispatchingWithinOneConnectionKeepsSelection() { - let mine = UUID() - let sibling = WorkspaceID(connectionId: mine, container: "logs") - #expect(!WorkspaceRailStore.shouldRestoreSelection(after: sibling, railConnectionId: mine)) + #expect(WorkspaceRailStore.selectedRow(connectionId: first, browsed: opened, in: entries) == 0) + #expect(WorkspaceRailStore.selectedRow(connectionId: second, browsed: switchedTo, in: entries) == 1) } - @Test("A rail with no connection of its own restores nothing") - func railWithoutConnectionRestoresNothing() { - let target = WorkspaceID(connectionId: UUID(), container: "app") - #expect(!WorkspaceRailStore.shouldRestoreSelection(after: target, railConnectionId: nil)) + /// A connection that failed and has not been retried yet has no session, so nothing is browsed. + /// The window is still on it, and the rail has to say so. + @Test("A connection with no session still selects its own row") + func selectionHoldsWhileTheSessionIsAbsent() { + let failed = UUID() + let other = WorkspaceID(connectionId: UUID(), container: "app") + let mine = WorkspaceID(connectionId: failed, container: "") + + #expect(WorkspaceRailStore.selectedRow(connectionId: failed, browsed: nil, in: [other, mine]) == 1) } @Test("A window with no connection selects nothing") From bb93cd4b710ff2522afb9f2b4897e7373106b1b0 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 14 Aug 2026 20:55:20 +0700 Subject: [PATCH 39/47] fix(connections): connect a workspace adopted into a window already on screen --- .../MainSplitViewController.swift | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index 69b110087..a846e8ba6 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -186,7 +186,19 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi rightPanelState: panelState, phase: phase ) - return workspaces.insert(workspace) + let adopted = workspaces.insert(workspace) + + /// A workspace adopted into a window that is already on screen has to dial for itself. + /// `viewWillAppear` is what starts the connect for the window's first workspace, and it + /// runs once: every connection opened into that window afterwards reached the registry + /// with its intent to connect recorded and nothing left to act on it, so picking a + /// connection from the toolbar switcher landed on the not-connected pane with a Connect + /// button the user had to press themselves. The guard is the lifecycle, not a special + /// case: before the view loads there are no panes for a phase change to repaint. + if isViewLoaded, view.window != nil { + startActivationConnectIfNeeded() + } + return adopted } @available(*, unavailable) @@ -360,9 +372,12 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi } } + /// The window's toolbar goes too. Dropping only the owner left the built `NSToolbar` on the + /// window with item views still pointing at the coordinator that was just released. func invalidateToolbar() { toolbarOwner?.invalidate() toolbarOwner = nil + if isViewLoaded { view.window?.toolbar = nil } } // MARK: - Connection Status @@ -454,10 +469,17 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi lastActiveCoordinator = incoming } + /// A workspace with no session has no toolbar of its own, and the outgoing one's is not a + /// stand-in: it names the other connection, its database and its schema, and every one of + /// its buttons still acts on that connection. Switching to a connection that has not come + /// up yet showed the previous connection's engine icon and schema over a pane that said + /// the new one was not connected. A plain titlebar is what a sessionless window shows. if let coordinator = workspaces.selected?.sessionState?.coordinator { coordinator.inspectorProxy = self coordinator.splitViewController = self installToolbar(coordinator: coordinator) + } else { + invalidateToolbar() } rebuildPanes() applyPaneChrome() From 011b1c20c97dd9481bc11a222e43fdb8c0363c61 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 14 Aug 2026 21:16:24 +0700 Subject: [PATCH 40/47] chore(perf): trace where a workspace switch spends its time --- .../MainSplitViewController.swift | 19 ++++ .../Infrastructure/WorkspaceSwitchTrace.swift | 90 +++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 TablePro/Core/Services/Infrastructure/WorkspaceSwitchTrace.swift diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index a846e8ba6..365d581ea 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -110,6 +110,10 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi /// hand over key-window state the same way AppKit would between windows. private weak var lastActiveCoordinator: MainContentCoordinator? + /// Set only for the duration of a workspace switch, so the work `rebuildPanes` does can be + /// attributed without every other caller of it paying for a timer. + private var switchTrace: WorkspaceSwitchTrace? + // MARK: - Observers private var connectionStatusCancellable: AnyCancellable? @@ -458,6 +462,13 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi /// Switching workspace repaints the window in place. The rail used to raise a different /// window instead, which is what made several connections mean several windows. internal func applySelectedWorkspace() { + let trace = WorkspaceSwitchTrace(connectionId: workspaces.selectedConnectionId) + switchTrace = trace + defer { + switchTrace = nil + trace.endAfterDisplay() + } + /// Switching workspace is this window's key-window change as far as a coordinator is /// concerned. Only the selected one receives the real `windowDidBecomeKey`, so without /// this the outgoing connection keeps `isKeyWindow` true and never schedules the eviction @@ -468,6 +479,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi incoming?.handleWindowDidBecomeKey() lastActiveCoordinator = incoming } + trace.stage("keyHandover") /// A workspace with no session has no toolbar of its own, and the outgoing one's is not a /// stand-in: it names the other connection, its database and its schema, and every one of @@ -481,13 +493,17 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi } else { invalidateToolbar() } + trace.stage("toolbar") + rebuildPanes() applyPaneChrome() applyWindowTitle() + trace.stage("chrome") /// Only this window's rail moved, and only its highlight. Broadcasting instead made every /// rail in the app rebuild its whole entry list to answer a question none of them asked. navigationSidebar?.railController.refreshSelection() + trace.stage("rail") } private func applyPhase() { @@ -561,8 +577,11 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi SharedSidebarState.forConnection(currentSession.connection.id) ) } + switchTrace?.stage("sidebarPane") detailHosting.rootView = AnyView(buildDetailView()) + switchTrace?.stage("detailPane") inspectorHosting.rootView = AnyView(buildInspectorView()) + switchTrace?.stage("inspectorPane") } /// The command surface every menu action forwards into. Menu items reach this diff --git a/TablePro/Core/Services/Infrastructure/WorkspaceSwitchTrace.swift b/TablePro/Core/Services/Infrastructure/WorkspaceSwitchTrace.swift new file mode 100644 index 000000000..5c1b6e735 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/WorkspaceSwitchTrace.swift @@ -0,0 +1,90 @@ +// +// WorkspaceSwitchTrace.swift +// TablePro +// + +import AppKit +import os + +/// Times one workspace switch, stage by stage. +/// +/// A switch is a single synchronous burst on the main thread, so a total on its own says only that +/// it was slow. What is actionable is which stage inside it is long, and whether the time is spent +/// in the code that runs at the click or in the layout pass that follows it. +/// +/// That second half is why this does not simply bracket the method. Assigning `NSHostingController` +/// a new root view returns immediately and books the real work for the next layout, so a timer that +/// stops at the end of the call reports a switch that costs almost nothing while the window visibly +/// stalls. The interval therefore closes from a `CATransaction` completion block, which runs after +/// the transaction that laid out and drew the new content has committed: click to pixels. +/// +/// Signposts are what Instruments reads. The same numbers are logged so the breakdown can be read +/// in Console with no profiler attached. +@MainActor +internal final class WorkspaceSwitchTrace { + private static let signposter = OSSignposter(subsystem: "com.TablePro", category: "WorkspaceSwitch") + private static let logger = Logger(subsystem: "com.TablePro", category: "WorkspaceSwitch") + + private let signpostId: OSSignpostID + private let interval: OSSignpostIntervalState + private let clock = ContinuousClock() + private let started: ContinuousClock.Instant + private var stageStarted: ContinuousClock.Instant + private let label: String + + internal init(connectionId: UUID?) { + label = connectionId.map { String($0.uuidString.prefix(8)) } ?? "none" + signpostId = Self.signposter.makeSignpostID() + interval = Self.signposter.beginInterval("switch", id: signpostId) + started = clock.now + stageStarted = started + Self.logger.info("switch begin conn=\(self.label, privacy: .public)") + } + + /// Closes the stage that was running and opens the next one. Named after the work that just + /// finished, so a long line names its own culprit. + internal func stage(_ name: StaticString) { + let now = clock.now + let elapsed = Self.milliseconds(from: stageStarted, to: now) + stageStarted = now + Self.signposter.emitEvent(name, id: signpostId) + /// Logged at info rather than debug so `log show` can retrieve a switch after the fact. + /// A switch happens at human speed, so this is a handful of lines per click, not a stream. + Self.logger.info( + "switch stage conn=\(self.label, privacy: .public) \(name, privacy: .public)=\(elapsed, privacy: .public)ms" + ) + } + + /// Ends the synchronous part and hands the rest to the layout pass. The completion block runs + /// once the transaction the switch dirtied has been committed to the screen. + internal func endAfterDisplay() { + let synchronous = Self.milliseconds(from: started, to: clock.now) + let started = started + let clock = clock + let label = label + let interval = interval + /// Chained rather than assigned. `setCompletionBlock` replaces whatever the current + /// transaction already carries, and a diagnostic has no business dropping somebody else's + /// completion handler. + let existing = CATransaction.completionBlock() + CATransaction.setCompletionBlock { + existing?() + MainActor.assumeIsolated { + let total = Self.milliseconds(from: started, to: clock.now) + Self.signposter.endInterval("switch", interval) + Self.logger.info( + """ + switch end conn=\(label, privacy: .public) \ + synchronous=\(synchronous, privacy: .public)ms displayed=\(total, privacy: .public)ms + """ + ) + } + } + } + + private static func milliseconds(from: ContinuousClock.Instant, to: ContinuousClock.Instant) -> Int { + let duration = to - from + return Int(duration.components.seconds * 1_000) + + Int(duration.components.attoseconds / 1_000_000_000_000_000) + } +} From b1f64d694f752bf51b1124934fa3b6b5decab6c0 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 14 Aug 2026 21:19:42 +0700 Subject: [PATCH 41/47] chore(perf): record the switch trace at a level the unified log keeps --- .../WorkspaceRailViewController.swift | 5 ++++ .../Infrastructure/WorkspaceSwitchTrace.swift | 23 +++++++++++++++---- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/TablePro/Core/Services/Infrastructure/WorkspaceRailViewController.swift b/TablePro/Core/Services/Infrastructure/WorkspaceRailViewController.swift index 85186d4cf..e73ff5df1 100644 --- a/TablePro/Core/Services/Infrastructure/WorkspaceRailViewController.swift +++ b/TablePro/Core/Services/Infrastructure/WorkspaceRailViewController.swift @@ -272,6 +272,11 @@ internal final class WorkspaceRailViewController: NSViewController { /// window leaves it where it was, because this window did not move. The rail needed a rule for /// when to put its highlight back only while it was guessing at the answer. private func activate(_ workspace: WorkspaceID) { + WorkspaceSwitchTrace.recordActivation( + connectionId: workspace.connectionId, + isHostedByThisWindow: host?.hostedConnectionIds.contains(workspace.connectionId) ?? false + ) + /// One window hosts every connection, so switching is a selection change in that /// window's own registry. Raising a different window is what made the rail read as a /// window switcher rather than a workspace switcher. diff --git a/TablePro/Core/Services/Infrastructure/WorkspaceSwitchTrace.swift b/TablePro/Core/Services/Infrastructure/WorkspaceSwitchTrace.swift index 5c1b6e735..bc8c0d27e 100644 --- a/TablePro/Core/Services/Infrastructure/WorkspaceSwitchTrace.swift +++ b/TablePro/Core/Services/Infrastructure/WorkspaceSwitchTrace.swift @@ -32,13 +32,24 @@ internal final class WorkspaceSwitchTrace { private var stageStarted: ContinuousClock.Instant private let label: String + /// The click itself, recorded before anything acts on it. Without this a trace that shows no + /// switch cannot say whether the switch was slow, never started, or went to another window. + internal static func recordActivation(connectionId: UUID, isHostedByThisWindow: Bool) { + logger.notice( + """ + rail activate conn=\(String(connectionId.uuidString.prefix(8)), privacy: .public) \ + hosted=\(isHostedByThisWindow, privacy: .public) + """ + ) + } + internal init(connectionId: UUID?) { label = connectionId.map { String($0.uuidString.prefix(8)) } ?? "none" signpostId = Self.signposter.makeSignpostID() interval = Self.signposter.beginInterval("switch", id: signpostId) started = clock.now stageStarted = started - Self.logger.info("switch begin conn=\(self.label, privacy: .public)") + Self.logger.notice("switch begin conn=\(self.label, privacy: .public)") } /// Closes the stage that was running and opens the next one. Named after the work that just @@ -48,9 +59,11 @@ internal final class WorkspaceSwitchTrace { let elapsed = Self.milliseconds(from: stageStarted, to: now) stageStarted = now Self.signposter.emitEvent(name, id: signpostId) - /// Logged at info rather than debug so `log show` can retrieve a switch after the fact. - /// A switch happens at human speed, so this is a handful of lines per click, not a stream. - Self.logger.info( + /// Logged at notice, the lowest level the unified log persists to disk. `debug` and `info` + /// live in a memory buffer that `log show` cannot read back, so a trace written at either + /// one is invisible unless a profiler was already streaming when the switch happened. A + /// switch is a human-speed event, so this is a handful of lines per click, not a stream. + Self.logger.notice( "switch stage conn=\(self.label, privacy: .public) \(name, privacy: .public)=\(elapsed, privacy: .public)ms" ) } @@ -72,7 +85,7 @@ internal final class WorkspaceSwitchTrace { MainActor.assumeIsolated { let total = Self.milliseconds(from: started, to: clock.now) Self.signposter.endInterval("switch", interval) - Self.logger.info( + Self.logger.notice( """ switch end conn=\(label, privacy: .public) \ synchronous=\(synchronous, privacy: .public)ms displayed=\(total, privacy: .public)ms From d86b65b6df3405c1b7659c5d33af5e3868687df4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 14 Aug 2026 21:39:04 +0700 Subject: [PATCH 42/47] fix(coordinator): key the sidebar and inspector panes to the connection they show --- .../Services/Infrastructure/MainSplitViewController.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index 365d581ea..f49e5f225 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -617,11 +617,18 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi payloadConnection ?? currentSession?.connection } + /// Keyed by connection for the same reason the detail pane is. A window shows one of several + /// connections now, so a pane can be asked to change connection in place, and SwiftUI updates a + /// view of unchanged type rather than rebuilding it. `SidebarView` keeps its `SidebarViewModel` + /// in `@State`, and a `State` initial value is discarded on an update, so the sidebar went on + /// answering from the previous connection's view model: its database type, its capabilities and + /// its filter caches, under the new connection's name. @ViewBuilder private func buildSidebarView() -> some View { if currentPane == .content, let currentSession, let sessionState { sidebarBody(currentSession: currentSession, sessionState: sessionState) .transaction { $0.animation = nil } + .id(currentSession.connection.id) } else { Color.clear } @@ -692,6 +699,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi connection: currentSession.connection ) .environment(\.commandActions, commandActions) + .id(currentSession.connection.id) } else { Color.clear } From a9c9d4df7a2676c87b15f39cc174344bf5f05e80 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 14 Aug 2026 21:44:47 +0700 Subject: [PATCH 43/47] fix(hig): drop an inspector control hint that only exists in an unreleased sdk --- TablePro/Views/Inspector/InspectorViewController.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/TablePro/Views/Inspector/InspectorViewController.swift b/TablePro/Views/Inspector/InspectorViewController.swift index ca60c079a..188bc4c7e 100644 --- a/TablePro/Views/Inspector/InspectorViewController.swift +++ b/TablePro/Views/Inspector/InspectorViewController.swift @@ -451,9 +451,6 @@ final class InspectorViewController: NSViewController, NSUserInterfaceValidation ) mode.selectedSegment = 0 mode.setAccessibilityLabel(String(localized: "Split mode")) - if #available(macOS 27.0, *) { - mode.role = .valueSelection - } let stack = accessoryStack(with: [field, mode]) alert.accessoryView = stack alert.window.initialFirstResponder = field From f6bbadbb34e16f20df6a3c9f8705accf12c7a7fa Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 14 Aug 2026 22:13:47 +0700 Subject: [PATCH 44/47] test(tabs): scope the bulk close tests to a connection's one tab list --- .../Main/CommandActionsBulkCloseTests.swift | 32 +++++++++---------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/TableProTests/Views/Main/CommandActionsBulkCloseTests.swift b/TableProTests/Views/Main/CommandActionsBulkCloseTests.swift index 0484a2c63..5329b01b4 100644 --- a/TableProTests/Views/Main/CommandActionsBulkCloseTests.swift +++ b/TableProTests/Views/Main/CommandActionsBulkCloseTests.swift @@ -2,7 +2,7 @@ // CommandActionsBulkCloseTests.swift // TableProTests // -// Covers the bulk tab-close commands: which sibling windows they target and +// Covers the bulk tab-close commands: which of the connection's tabs they target and // that the surviving window empties in place instead of closing (#1972). // @@ -71,40 +71,37 @@ struct CommandActionsBulkCloseTests { // MARK: - Database scope - @Test("a sibling window on another database is offered for closing") - func canCloseTabsForOtherDatabasesWhenSiblingIsForeign() { + /// A connection keeps one tab list now, so the scope of a database-scoped close is that list. + /// These used to spread a connection's tabs over sibling windows and check that the command + /// reached across them, which is a shape the app can no longer be in. + @Test("a tab on another database is offered for closing") + func canCloseTabsForOtherDatabasesWhenATabIsForeign() { let connection = TestFixtures.makeConnection(database: "db_a") let current = makeWindow(connection: connection) - let sibling = makeWindow(connection: connection) - defer { - current.coordinator.teardown() - sibling.coordinator.teardown() - } + defer { current.coordinator.teardown() } current.coordinator.tabManager.addTab(initialQuery: "SELECT 1", databaseName: "db_a") - sibling.coordinator.tabManager.addTab(initialQuery: "SELECT 2", databaseName: "db_b") + current.coordinator.tabManager.addTab(initialQuery: "SELECT 2", databaseName: "db_b") #expect(current.actions.browseDatabaseName == "db_a") #expect(current.actions.canCloseTabsForOtherDatabases) } - @Test("nothing is offered when every sibling is on the active database") + @Test("nothing is offered when every tab is on the active database") func cannotCloseTabsForOtherDatabasesWhenAllMatch() { let connection = TestFixtures.makeConnection(database: "db_a") let current = makeWindow(connection: connection) - let sibling = makeWindow(connection: connection) - defer { - current.coordinator.teardown() - sibling.coordinator.teardown() - } + defer { current.coordinator.teardown() } current.coordinator.tabManager.addTab(initialQuery: "SELECT 1", databaseName: "db_a") - sibling.coordinator.tabManager.addTab(initialQuery: "SELECT 2", databaseName: "db_a") + current.coordinator.tabManager.addTab(initialQuery: "SELECT 2", databaseName: "db_a") #expect(!current.actions.canCloseTabsForOtherDatabases) } - @Test("another connection's window is never a database-scoped target") + /// Another connection's tabs are out of scope by construction rather than by a filter: they + /// live in that connection's own tab list, which this command never reads. + @Test("another connection's tabs are never a database-scoped target") func otherConnectionsAreOutOfScope() { let connection = TestFixtures.makeConnection(database: "db_a") let otherConnection = TestFixtures.makeConnection(database: "db_b") @@ -119,6 +116,7 @@ struct CommandActionsBulkCloseTests { unrelated.coordinator.tabManager.addTab(initialQuery: "SELECT 2", databaseName: "db_b") #expect(!current.actions.canCloseTabsForOtherDatabases) + #expect(unrelated.actions.canCloseTabsForOtherDatabases == false) } // MARK: - Enablement From e89bc0d67795c5016f13bb4857b99faf2bc2b981 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 14 Aug 2026 23:54:06 +0700 Subject: [PATCH 45/47] fix(perf): stop the switch trace ending its signpost interval twice --- .../Infrastructure/WorkspaceSwitchTrace.swift | 40 +++++++++++-------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/TablePro/Core/Services/Infrastructure/WorkspaceSwitchTrace.swift b/TablePro/Core/Services/Infrastructure/WorkspaceSwitchTrace.swift index bc8c0d27e..c0d19b549 100644 --- a/TablePro/Core/Services/Infrastructure/WorkspaceSwitchTrace.swift +++ b/TablePro/Core/Services/Infrastructure/WorkspaceSwitchTrace.swift @@ -31,6 +31,9 @@ internal final class WorkspaceSwitchTrace { private let started: ContinuousClock.Instant private var stageStarted: ContinuousClock.Instant private let label: String + /// An `OSSignpostIntervalState` may be ended once. Core Animation gives no guarantee that a + /// completion block runs exactly once, so the guarantee is made here instead. + private var hasEnded = false /// The click itself, recorded before anything acts on it. Without this a trace that shows no /// switch cannot say whether the switch was slow, never started, or went to another window. @@ -70,31 +73,34 @@ internal final class WorkspaceSwitchTrace { /// Ends the synchronous part and hands the rest to the layout pass. The completion block runs /// once the transaction the switch dirtied has been committed to the screen. + /// + /// The block is assigned, not chained onto whatever the transaction already carries. + /// `CATransaction.completionBlock()` will hand back a block Core Animation has already run, so + /// calling it again ran a previous switch's ending a second time and trapped on an interval + /// that was already closed. Replacing is the safe direction here: nothing else in the app sets + /// a completion block, and `finish` is idempotent so a repeat call costs nothing either way. internal func endAfterDisplay() { let synchronous = Self.milliseconds(from: started, to: clock.now) - let started = started - let clock = clock - let label = label - let interval = interval - /// Chained rather than assigned. `setCompletionBlock` replaces whatever the current - /// transaction already carries, and a diagnostic has no business dropping somebody else's - /// completion handler. - let existing = CATransaction.completionBlock() CATransaction.setCompletionBlock { - existing?() MainActor.assumeIsolated { - let total = Self.milliseconds(from: started, to: clock.now) - Self.signposter.endInterval("switch", interval) - Self.logger.notice( - """ - switch end conn=\(label, privacy: .public) \ - synchronous=\(synchronous, privacy: .public)ms displayed=\(total, privacy: .public)ms - """ - ) + self.finish(synchronous: synchronous) } } } + private func finish(synchronous: Int) { + guard !hasEnded else { return } + hasEnded = true + let total = Self.milliseconds(from: started, to: clock.now) + Self.signposter.endInterval("switch", interval) + Self.logger.notice( + """ + switch end conn=\(self.label, privacy: .public) \ + synchronous=\(synchronous, privacy: .public)ms displayed=\(total, privacy: .public)ms + """ + ) + } + private static func milliseconds(from: ContinuousClock.Instant, to: ContinuousClock.Instant) -> Int { let duration = to - from return Int(duration.components.seconds * 1_000) From 5ffbc32bc58bd6c92e1b48daa2ce1f7db72e1980 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 15 Aug 2026 00:23:33 +0700 Subject: [PATCH 46/47] fix(connections): key window lifecycle by connection, not by window --- CHANGELOG.md | 3 + .../WindowLifecycleMonitor.swift | 80 ++++++++++++++----- .../Infrastructure/WindowManager.swift | 13 ++- .../Infrastructure/WorkspaceRailStore.swift | 11 ++- .../WorkspaceRailViewController.swift | 16 +++- .../MainContentCoordinator+Registry.swift | 8 ++ ...dowLifecycleMonitorRegistrationTests.swift | 49 ++++++++++++ 7 files changed, 152 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34a0cffa0..00a3c6a15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Closing a connection removes it from the workspace rail, ends its session, and closes its tunnel. It stayed listed as connected, could not be clicked, and its database connection stayed open. +- Close Workspace on a rail row acts on the connection the row names. It acted on the connection on screen instead, closing its tabs and losing unsaved editor work. +- Closing a window disconnects every connection it was showing, not just one of them. - Hiding a database in the sidebar's database filter takes effect straight away. The tree kept listing every database until some other change happened to rebuild it. - An alert opens on the window you were working in. It could attach itself to a floating panel such as the Quick Switcher, which takes the alert with it when it closes. - Holding an arrow key in the sidebar no longer opens a tab and runs a query for every object it passes. Arrowing through 20 tables fired 20 queries; it now opens only the object you stop on. diff --git a/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift b/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift index bd12e14b3..265034bba 100644 --- a/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift +++ b/TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift @@ -50,8 +50,14 @@ internal final class WindowLifecycleMonitor { /// window whose content is rebuilt registers again under a new id. Reconnecting rebuilds it. /// Leaving the superseded entry behind makes one window count as two, and everything asking /// this registry how many windows a connection has would believe it. + /// + /// The connection has to match. One window hosts every open connection now, and each one's + /// content registers that same window under its own id, so matching on the window alone made + /// every new connection evict the previous one: the registry held a single connection per + /// window, named whichever mounted last. `hasWindows`, `findWindow` and `mostRecentWindow` + /// then answered nothing for connections that were open and on screen. let supersededIds = entries.compactMap { key, value -> UUID? in - key != windowId && value.window === window ? key : nil + key != windowId && value.window === window && value.connectionId == connectionId ? key : nil } for supersededId in supersededIds { guard let superseded = entries.removeValue(forKey: supersededId) else { continue } @@ -100,6 +106,29 @@ internal final class WindowLifecycleMonitor { AppEvents.shared.connectionWindowsChanged.send() } + /// Forgets every window entry for a connection the app no longer hosts. + /// + /// Cleanup used to ride on `NSWindow.willCloseNotification`, which was enough while closing a + /// connection meant closing its window. A connection can now be closed out of a window that + /// stays open for the others, and nothing told this registry: the entry survived, the workspace + /// rail kept listing a connection with no workspace, and clicking that row reached a window + /// that could not host it. + internal func unregisterWindows(for connectionId: UUID) { + let staleIds = entries.compactMap { key, value -> UUID? in + value.connectionId == connectionId ? key : nil + } + guard !staleIds.isEmpty else { return } + for windowId in staleIds { + unregisterSourceFiles(for: windowId) + guard let entry = entries.removeValue(forKey: windowId) else { continue } + for observer in entry.observers { + NotificationCenter.default.removeObserver(observer) + } + forgetFocus(windowId: windowId, connectionId: entry.connectionId) + } + AppEvents.shared.connectionWindowsChanged.send() + } + /// Remove the UUID mapping for a window. internal func unregisterWindow(for windowId: UUID) { unregisterSourceFiles(for: windowId) @@ -286,39 +315,48 @@ internal final class WindowLifecycleMonitor { lastFocusedWindowIds.removeValue(forKey: connectionId) } + /// Every connection the closing window presented, not the first one found. + /// + /// A window hosts all of them now, so taking one entry left the rest registered against a window + /// that was going away, and left their sessions connected: drivers, SSH tunnels and health + /// monitors running with no window and no workspace behind them until the app quit. private func handleWindowClose(_ closedWindow: NSWindow) { - guard let (windowId, entry) = entries.first(where: { $0.value.window === closedWindow }) else { + let closing = entries.compactMap { key, value -> (UUID, Entry)? in + value.window === closedWindow ? (key, value) : nil + } + guard !closing.isEmpty else { Self.lifecycleLogger.info( "[close] handleWindowClose: unknown window (not in registry)" ) return } - let closedConnectionId = entry.connectionId - Self.lifecycleLogger.info( - "[close] willCloseNotification -> handleWindowClose windowId=\(windowId, privacy: .public) connId=\(closedConnectionId, privacy: .public)" - ) - - for observer in entry.observers { - NotificationCenter.default.removeObserver(observer) + for (windowId, entry) in closing { + Self.lifecycleLogger.info( + "[close] willCloseNotification -> handleWindowClose windowId=\(windowId, privacy: .public) connId=\(entry.connectionId, privacy: .public)" + ) + for observer in entry.observers { + NotificationCenter.default.removeObserver(observer) + } + unregisterSourceFiles(for: windowId) + entries.removeValue(forKey: windowId) + forgetFocus(windowId: windowId, connectionId: entry.connectionId) } - unregisterSourceFiles(for: windowId) - entries.removeValue(forKey: windowId) - forgetFocus(windowId: windowId, connectionId: closedConnectionId) AppEvents.shared.connectionWindowsChanged.send() - let hasRemainingWindows = entries.values.contains { - $0.connectionId == closedConnectionId && $0.window != nil - } - Self.lifecycleLogger.info( - "[close] handleWindowClose post-remove windowId=\(windowId, privacy: .public) remainingForConn=\(hasRemainingWindows) totalEntries=\(self.entries.count)" - ) - if !hasRemainingWindows { + for connectionId in Set(closing.map(\.1.connectionId)) { + let hasRemainingWindows = entries.values.contains { + $0.connectionId == connectionId && $0.window != nil + } + guard !hasRemainingWindows else { continue } + Self.lifecycleLogger.info( + "[close] handleWindowClose disconnecting connId=\(connectionId, privacy: .public) totalEntries=\(self.entries.count)" + ) Task { let t0 = Date() - await DatabaseManager.shared.disconnectSession(closedConnectionId) + await DatabaseManager.shared.disconnectSession(connectionId) Self.lifecycleLogger.info( - "[close] (from handleWindowClose) disconnectSession done connId=\(closedConnectionId, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(t0) * 1_000))" + "[close] (from handleWindowClose) disconnectSession done connId=\(connectionId, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(t0) * 1_000))" ) } } diff --git a/TablePro/Core/Services/Infrastructure/WindowManager.swift b/TablePro/Core/Services/Infrastructure/WindowManager.swift index 2ddb8869e..32ec8fe49 100644 --- a/TablePro/Core/Services/Infrastructure/WindowManager.swift +++ b/TablePro/Core/Services/Infrastructure/WindowManager.swift @@ -200,18 +200,29 @@ internal final class WindowManager { /// Closing a connection removes its workspace. The window itself only closes once it has no /// connection left to show, because it is no longer the connection's window. + /// A miniaturized window still hosts its connections, so `isVisible` is not the test: closing a + /// connection while its window was minimized left the workspace in place with no way to reach it. internal func closeWindow(for connectionId: UUID) { + var closedAnywhere = false for controller in controllers.values { - guard let window = controller.window, window.isVisible else { continue } + guard let window = controller.window else { continue } guard let host = window.contentViewController as? MainSplitViewController else { continue } guard let removed = host.workspaces.remove(connectionId) else { continue } removed.teardown() + closedAnywhere = true if host.workspaces.isEmpty { window.close() } else { host.applySelectedWorkspace() } } + guard closedAnywhere else { return } + + /// Closing a connection ends it. The window used to do that on its way out, which covered + /// this while a connection owned its window; a connection closed out of a window that stays + /// open reached nothing, and its driver, tunnel and health monitor ran on unreferenced. + WindowLifecycleMonitor.shared.unregisterWindows(for: connectionId) + Task { await DatabaseManager.shared.disconnectSession(connectionId) } } internal static func isMainWindow(_ window: NSWindow) -> Bool { diff --git a/TablePro/Core/Services/Infrastructure/WorkspaceRailStore.swift b/TablePro/Core/Services/Infrastructure/WorkspaceRailStore.swift index 9c74b591e..6514f89bd 100644 --- a/TablePro/Core/Services/Infrastructure/WorkspaceRailStore.swift +++ b/TablePro/Core/Services/Infrastructure/WorkspaceRailStore.swift @@ -34,9 +34,16 @@ internal struct WorkspaceRailEntry: Identifiable, Equatable { internal enum WorkspaceRailStore { /// Derived live from the open windows, their sessions and their tabs rather than /// cached, so a refresh can never blank the list it is refreshing. + /// Membership comes from the windows' own workspace registries and nothing else. A connection is + /// in the rail exactly while some window hosts it, which is what a rail row means. + /// + /// `WindowLifecycleMonitor` used to be unioned in here. It tracks mounted content rather than + /// hosted connections, and the two stopped agreeing in both directions: it kept naming a + /// connection whose workspace had been closed, which is the row that would not go away, and it + /// answered nothing for connections that were open. It never held an id `WindowManager` lacked, + /// so the union only ever added wrong answers. internal static var entries: [WorkspaceRailEntry] { - let openIds = WindowLifecycleMonitor.shared.allConnectionIds() - .union(WindowManager.shared.allConnectionIds()) + let openIds = WindowManager.shared.allConnectionIds() guard !openIds.isEmpty else { return [] } let sessions = DatabaseManager.shared.activeSessions diff --git a/TablePro/Core/Services/Infrastructure/WorkspaceRailViewController.swift b/TablePro/Core/Services/Infrastructure/WorkspaceRailViewController.swift index e73ff5df1..6ab11ff94 100644 --- a/TablePro/Core/Services/Infrastructure/WorkspaceRailViewController.swift +++ b/TablePro/Core/Services/Infrastructure/WorkspaceRailViewController.swift @@ -335,7 +335,11 @@ internal final class WorkspaceRailViewController: NSViewController { ) return } - guard let coordinator = MainContentCoordinator.coordinator(forWindow: window) else { + /// Resolved by connection, never by window. `coordinator(forWindow:)` answers with the + /// window's selected workspace, so a row for any other connection moved the browse cursor + /// of the one on screen instead: it switched the visible connection to a database named + /// after a different one, or failed against a database that connection does not have. + guard let coordinator = MainContentCoordinator.coordinator(forConnection: workspace.connectionId) else { Self.logger.error( """ moveBrowseCursor has no coordinator target=\(Self.describe(workspace), privacy: .public) \ @@ -388,11 +392,15 @@ internal final class WorkspaceRailViewController: NSViewController { return menu } + /// The connection the row names, not the one the window is showing. Resolving through the + /// window handed this to the selected connection's coordinator, so Close Workspace on a row for + /// another connection closed the visible connection's tabs in a container of the same name, + /// taking its unsaved editor work with them. @objc private func closeWorkspace(_ sender: NSMenuItem) { - guard let workspace = sender.representedObject as? WorkspaceID else { return } - guard let window = WindowLifecycleMonitor.shared.mostRecentWindow(for: workspace.connectionId), - let coordinator = MainContentCoordinator.coordinator(forWindow: window) else { return } + guard let workspace = sender.representedObject as? WorkspaceID, + let coordinator = MainContentCoordinator.coordinator(forConnection: workspace.connectionId) + else { return } coordinator.commandActions?.closeWorkspace(container: workspace.container) } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Registry.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Registry.swift index fb5f16d4b..6a15c4857 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Registry.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Registry.swift @@ -51,6 +51,14 @@ extension MainContentCoordinator { .flatMap { $0.tabManager.tabs } } + /// The coordinator for a named connection, wherever it is hosted and whether or not its window + /// is currently showing it. Anything acting on a connection the user named, rather than on the + /// one in front of them, has to resolve this way: `coordinator(forWindow:)` answers with the + /// window's *selected* workspace, so using it for a rail row acted on a different connection. + static func coordinator(forConnection connectionId: UUID) -> MainContentCoordinator? { + activeCoordinators.values.first { $0.connectionId == connectionId } + } + static func coordinator( forConnection connectionId: UUID, tabMatching predicate: (QueryTab) -> Bool diff --git a/TableProTests/Core/Services/WindowLifecycleMonitorRegistrationTests.swift b/TableProTests/Core/Services/WindowLifecycleMonitorRegistrationTests.swift index 0185e9a79..6f133c629 100644 --- a/TableProTests/Core/Services/WindowLifecycleMonitorRegistrationTests.swift +++ b/TableProTests/Core/Services/WindowLifecycleMonitorRegistrationTests.swift @@ -44,6 +44,55 @@ struct WindowLifecycleMonitorRegistrationTests { #expect(monitor.window(for: firstId) == nil) } + /// One window hosts every open connection, and each one's content registers that same window + /// under its own id. Superseding on the window alone made every new connection evict the last, + /// so the registry held one connection per window and answered nothing for the rest. + @Test("Two connections in one window both stay registered") + func twoConnectionsShareOneWindow() { + let monitor = WindowLifecycleMonitor.shared + let first = UUID() + let second = UUID() + let window = makeWindow() + let firstId = UUID() + let secondId = UUID() + defer { + monitor.unregisterWindow(for: firstId) + monitor.unregisterWindow(for: secondId) + } + + monitor.register(window: window, connectionId: first, windowId: firstId) + monitor.register(window: window, connectionId: second, windowId: secondId) + + #expect(monitor.windows(for: first).count == 1) + #expect(monitor.windows(for: second).count == 1) + #expect(monitor.allConnectionIds().isSuperset(of: [first, second])) + } + + /// Closing a connection out of a window that stays open for the others is the case that had no + /// cleanup at all: the entry survived and the workspace rail kept listing it. + @Test("Unregistering a connection clears its entries and leaves the others alone") + func unregisteringOneConnectionLeavesTheOther() { + let monitor = WindowLifecycleMonitor.shared + let closed = UUID() + let surviving = UUID() + let window = makeWindow() + let closedId = UUID() + let survivingId = UUID() + defer { + monitor.unregisterWindow(for: closedId) + monitor.unregisterWindow(for: survivingId) + } + + monitor.register(window: window, connectionId: closed, windowId: closedId) + monitor.register(window: window, connectionId: surviving, windowId: survivingId) + + monitor.unregisterWindows(for: closed) + + #expect(monitor.windows(for: closed).isEmpty) + #expect(monitor.allConnectionIds().contains(closed) == false) + #expect(monitor.windows(for: surviving).count == 1) + } + @Test("Two genuine windows on one connection still see each other") func distinctWindowsRemainSiblings() { let monitor = WindowLifecycleMonitor.shared From c03e41bc9f14cbb3e104a0a6d4038d3fe304c709 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 15 Aug 2026 00:39:56 +0700 Subject: [PATCH 47/47] fix(connections): resolve a rail command's coordinator through the window hosting it --- .../Services/Infrastructure/WindowManager.swift | 14 ++++++++++++++ .../WorkspaceRailViewController.swift | 13 +++++++------ .../MainContentCoordinator+Registry.swift | 8 -------- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/TablePro/Core/Services/Infrastructure/WindowManager.swift b/TablePro/Core/Services/Infrastructure/WindowManager.swift index 32ec8fe49..f6b318684 100644 --- a/TablePro/Core/Services/Infrastructure/WindowManager.swift +++ b/TablePro/Core/Services/Infrastructure/WindowManager.swift @@ -168,6 +168,20 @@ internal final class WindowManager { hosts().contains { $0.workspaces.contains(connectionId) } } + /// The coordinator a connection is actually using, found through the window hosting it. + /// + /// `MainContentCoordinator.activeCoordinators` cannot answer this. It is keyed by coordinator + /// instance and also holds throwaway instances SwiftUI built and discarded while re-evaluating a + /// body, so picking the first one with a matching connection id returns one of those about as + /// often as the real one: no tabs, no command surface, and every command silently does nothing. + /// A window's workspace registry names exactly one coordinator per connection. + internal func coordinator(for connectionId: UUID) -> MainContentCoordinator? { + hosts() + .lazy + .compactMap { $0.workspaces.workspace(for: connectionId)?.sessionState?.coordinator } + .first + } + private func hosts() -> [MainSplitViewController] { controllers.values.compactMap { $0.window?.contentViewController as? MainSplitViewController } } diff --git a/TablePro/Core/Services/Infrastructure/WorkspaceRailViewController.swift b/TablePro/Core/Services/Infrastructure/WorkspaceRailViewController.swift index 6ab11ff94..392d33d29 100644 --- a/TablePro/Core/Services/Infrastructure/WorkspaceRailViewController.swift +++ b/TablePro/Core/Services/Infrastructure/WorkspaceRailViewController.swift @@ -335,11 +335,12 @@ internal final class WorkspaceRailViewController: NSViewController { ) return } - /// Resolved by connection, never by window. `coordinator(forWindow:)` answers with the - /// window's selected workspace, so a row for any other connection moved the browse cursor - /// of the one on screen instead: it switched the visible connection to a database named - /// after a different one, or failed against a database that connection does not have. - guard let coordinator = MainContentCoordinator.coordinator(forConnection: workspace.connectionId) else { + /// Resolved by connection through the window that hosts it, never by window alone. + /// `coordinator(forWindow:)` answers with the window's selected workspace, so a row for any + /// other connection moved the browse cursor of the one on screen instead: it switched the + /// visible connection to a database named after a different one, or failed against a + /// database that connection does not have. + guard let coordinator = WindowManager.shared.coordinator(for: workspace.connectionId) else { Self.logger.error( """ moveBrowseCursor has no coordinator target=\(Self.describe(workspace), privacy: .public) \ @@ -399,7 +400,7 @@ internal final class WorkspaceRailViewController: NSViewController { @objc private func closeWorkspace(_ sender: NSMenuItem) { guard let workspace = sender.representedObject as? WorkspaceID, - let coordinator = MainContentCoordinator.coordinator(forConnection: workspace.connectionId) + let coordinator = WindowManager.shared.coordinator(for: workspace.connectionId) else { return } coordinator.commandActions?.closeWorkspace(container: workspace.container) } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Registry.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Registry.swift index 6a15c4857..fb5f16d4b 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Registry.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Registry.swift @@ -51,14 +51,6 @@ extension MainContentCoordinator { .flatMap { $0.tabManager.tabs } } - /// The coordinator for a named connection, wherever it is hosted and whether or not its window - /// is currently showing it. Anything acting on a connection the user named, rather than on the - /// one in front of them, has to resolve this way: `coordinator(forWindow:)` answers with the - /// window's *selected* workspace, so using it for a rail row acted on a different connection. - static func coordinator(forConnection connectionId: UUID) -> MainContentCoordinator? { - activeCoordinators.values.first { $0.connectionId == connectionId } - } - static func coordinator( forConnection connectionId: UUID, tabMatching predicate: (QueryTab) -> Bool