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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -168,3 +168,4 @@ Libs/ios/
.analysis/
.docs/
/plans/reports
.worktrees/
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Database context rail: a permanent vertical rail at the left of the editor workspace that represents each open `(connection, database, schema)` context. Native macOS tab groups are now keyed by the full context, ensuring the top tab bar always shows tabs from exactly one context. Only contexts with open tabs are shown. (#2026-08-04-database-context-rail)
- PostgreSQL array columns of a simple type, including arrays of an enum, get a list editor in the data grid. One row per element, with reordering, add and remove, and NULL per element. An empty array and a NULL column stay separate values. Enum arrays pick from the labels the type declares. Arrays of `jsonb`, `bytea` or composite types, and multi-dimensional values, keep the plain text editor.

### Fixed
Expand Down
27 changes: 12 additions & 15 deletions TablePro/Core/Services/Infrastructure/MainSplitViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -155,10 +155,6 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi
navigationSidebar = NavigationSidebarViewController(
connectionId: payload?.connectionId ?? currentSession?.connection.id
)
navigationSidebar.railController.onLayoutChange = { [weak self] _ in
self?.navigationSidebar.applyRailWidth(animated: false)
self?.recomputeWindowMinSize()
}
sidebarSplitItem = NSSplitViewItem(sidebarWithViewController: navigationSidebar)
sidebarSplitItem.canCollapse = true
sidebarSplitItem.minimumThickness = Self.sidebarMinThickness
Expand All @@ -180,9 +176,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi
inspectorSplitItem.maximumThickness = NSSplitViewItem.unspecifiedDimension
addSplitViewItem(inspectorSplitItem)

navigationSidebar.railController.onEntryCountChange = { [weak self] count in
self?.applyRailVisibility(workspaceCount: count)
}
applyRailVisibility(workspaceCount: WorkspaceContextRegistry.shared.contexts.count)

/// 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
Expand Down Expand Up @@ -240,18 +234,21 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi
.sink { [weak self] _ in
self?.handleConnectionStatusChange()
}
railVisibilityCancellable = AppEvents.shared.workspaceRailVisibilityChanged
.receive(on: RunLoop.main)
.sink { [weak self] _ in
self?.applyRailVisibility(workspaceCount: WorkspaceRailStore.entries.count)
}
railVisibilityCancellable = Publishers.Merge(
AppEvents.shared.workspaceRailVisibilityChanged,
AppEvents.shared.workspaceTabsChanged
)
.receive(on: RunLoop.main)
.sink { [weak self] _ in
self?.applyRailVisibility(workspaceCount: WorkspaceContextRegistry.shared.contexts.count)
}
connectionUpdatedCancellable = AppEvents.shared.connectionUpdated
.receive(on: RunLoop.main)
.sink { [weak self] changedId in
self?.handleConnectionRecordChange(changedId)
}
handleConnectionStatusChange()
applyRailVisibility(workspaceCount: WorkspaceRailStore.entries.count)
applyRailVisibility(workspaceCount: WorkspaceContextRegistry.shared.contexts.count)
}

private func removeObservers() {
Expand Down Expand Up @@ -619,7 +616,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi
}

var canToggleWorkspaceRail: Bool {
WorkspaceRailStore.entries.count > 1
WorkspaceContextRegistry.shared.contexts.count > 1
}


Expand Down Expand Up @@ -655,7 +652,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi
}

func activateWorkspace(offsetBy offset: Int) {
navigationSidebar?.railController.activateWorkspace(offsetBy: offset)
navigationSidebar?.activateWorkspace(offsetBy: offset)
}

// MARK: - Sidebar
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import SwiftUI
/// sidebar it expects, and showing or hiding the rail becomes an ordinary layout change.
@MainActor
internal final class NavigationSidebarViewController: NSViewController {
internal let railController: WorkspaceRailViewController
internal let contextRailHosting: NSHostingController<DatabaseContextRailView>
internal let objectBrowser: SidebarContainerViewController

private let separator = NSBox()
Expand All @@ -24,8 +24,17 @@ internal final class NavigationSidebarViewController: NSViewController {

internal private(set) var isRailVisible = false

internal init(connectionId: UUID?) {
self.railController = WorkspaceRailViewController(connectionId: connectionId)
internal static let contextRailWidth: CGFloat = 200

internal init(connectionId _: UUID?) {
self.contextRailHosting = NSHostingController(
rootView: DatabaseContextRailView(
registry: .shared,
activationCoordinator: .shared,
closeCoordinator: .shared
)
)
self.contextRailHosting.sizingOptions = []
self.objectBrowser = SidebarContainerViewController(rootView: AnyView(Color.clear))
super.init(nibName: nil, bundle: nil)
}
Expand All @@ -38,10 +47,10 @@ internal final class NavigationSidebarViewController: NSViewController {
override func loadView() {
view = NSView()

addChild(railController)
addChild(contextRailHosting)
addChild(objectBrowser)

let rail = railController.view
let rail = contextRailHosting.view
let browser = objectBrowser.view
separator.boxType = .separator

Expand Down Expand Up @@ -92,7 +101,7 @@ internal final class NavigationSidebarViewController: NSViewController {
/// which one drives the geometry up to AppKit, so the declared duration is not reliably the
/// one that runs.
internal func applyRailWidth(animated: Bool, alongside: (() -> Void)? = nil) {
let width = isRailVisible ? railController.currentLayout.width : 0
let width = isRailVisible ? Self.contextRailWidth : 0
let separatorWidth: CGFloat = isRailVisible ? 1 : 0
guard railWidthConstraint.constant != width else {
alongside?()
Expand All @@ -111,4 +120,15 @@ internal final class NavigationSidebarViewController: NSViewController {
alongside?()
}
}

internal func activateWorkspace(offsetBy offset: Int) {
let registry = WorkspaceContextRegistry.shared
let keys = registry.contexts.map(\.key)
guard !keys.isEmpty else { return }
let current = registry.selectedKey
let index = current.flatMap { keys.firstIndex(of: $0) } ?? 0
let count = keys.count
let destination = ((index + offset) % count + count) % count
WorkspaceContextActivationCoordinator.shared.activate(keys[destination])
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate {
window.toolbarStyle = .unified
window.titleVisibility = .visible
window.tabbingMode = .preferred
window.tabbingIdentifier = WindowManager.tabbingIdentifier(for: payload.connectionId)
window.tabbingIdentifier = WindowManager.tabbingIdentifier(payload: payload)
window.collectionBehavior.insert([.fullScreenPrimary, .managed])

let splitVC = MainSplitViewController(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ internal final class WindowLifecycleMonitor {
NotificationCenter.default.removeObserver(observer)
}
forgetFocus(windowId: supersededId, connectionId: superseded.connectionId)
WorkspaceContextRegistry.shared.unregister(windowId: supersededId)
}

// Remove any existing entry for this windowId to avoid duplicate observers
Expand Down Expand Up @@ -271,13 +272,15 @@ internal final class WindowLifecycleMonitor {
NotificationCenter.default.removeObserver(observer)
}
forgetFocus(windowId: windowId, connectionId: entry.connectionId)
WorkspaceContextRegistry.shared.unregister(windowId: windowId)
}
}

private func handleWindowDidBecomeKey(windowId: UUID) {
guard let entry = entries[windowId] else { return }
guard lastFocusedWindowIds[entry.connectionId] != windowId else { return }
lastFocusedWindowIds[entry.connectionId] = windowId
WorkspaceContextRegistry.shared.markActive(windowId: windowId)
AppEvents.shared.connectionWindowsChanged.send()
}

Expand Down Expand Up @@ -305,6 +308,7 @@ internal final class WindowLifecycleMonitor {
unregisterSourceFiles(for: windowId)
entries.removeValue(forKey: windowId)
forgetFocus(windowId: windowId, connectionId: closedConnectionId)
WorkspaceContextRegistry.shared.unregister(windowId: windowId)
AppEvents.shared.connectionWindowsChanged.send()

let hasRemainingWindows = entries.values.contains {
Expand Down
17 changes: 14 additions & 3 deletions TablePro/Core/Services/Infrastructure/WindowManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -147,13 +147,24 @@ 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.
/// Fallback when the connection record is not available yet. Prefer
/// `tabbingIdentifier(for: WorkspaceContextKey)` so tabs from different databases
/// or schemas never join the same native group.
internal static func tabbingIdentifier(for connectionId: UUID) -> String {
"com.TablePro.main.\(connectionId.uuidString)"
}

internal static func tabbingIdentifier(for key: WorkspaceContextKey) -> String {
key.tabbingIdentifier
}

internal static func tabbingIdentifier(payload: EditorTabPayload) -> String {
guard let connection = WorkspaceContextResolver.connection(for: payload.connectionId) else {
return tabbingIdentifier(for: payload.connectionId)
}
return tabbingIdentifier(for: WorkspaceContextResolver.resolve(payload: payload, connection: connection))
}

private func findSibling(tabbingIdentifier: String, excluding: NSWindow) -> NSWindow? {
NSApp.windows.first { candidate in
candidate !== excluding
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import AppKit
import Foundation

// WorkspaceContextActivationCoordinator.swift — reconnect/database/schema activation and visible-group switching
// Part of the Database Context Rail feature (Task 3 of the plan).

@MainActor
internal final class WorkspaceContextActivationCoordinator {
private var registry: WorkspaceContextRegistry

internal static let shared = WorkspaceContextActivationCoordinator()

internal init(registry: WorkspaceContextRegistry = .shared) {
self.registry = registry
}

internal func openOrActivate(
connection: DatabaseConnection,
databaseName: String?,
schemaName: String?,
initialQuery _: String? = nil
) {
let key = WorkspaceContextResolver.resolve(
connection: connection,
databaseName: databaseName,
schemaName: schemaName,
session: DatabaseManager.shared.session(for: connection.id)
)
activate(key)
}

internal func activate(
_ key: WorkspaceContextKey,
preferredWindowId: UUID? = nil,
sourceWindow _: NSWindow? = nil
) {
guard let sequence = registry.beginActivation(for: key) else { return }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

context activation only raises a window; it never reconnects or switches to key.databaseName / key.schemaName, so the sidebar and subsequent tabs can remain bound to the previous scope. Switch scope first, then raise and commit only the latest activation request.


let window = windowToRaise(for: key, preferredWindowId: preferredWindowId)
if let window {
if let group = window.tabGroup, group.selectedWindow !== window {
group.selectedWindow = window
}
window.makeKeyAndOrderFront(nil)
NSApp.activate()
}

registry.commitActivation(key, request: sequence)
}

private func windowToRaise(for key: WorkspaceContextKey, preferredWindowId: UUID?) -> NSWindow? {
if let preferredWindowId,
let preferred = MainContentCoordinator.coordinator(for: preferredWindowId)?.contentWindow {
return preferred
}

let registered = registry.windowIds(for: key).compactMap { windowId in
MainContentCoordinator.coordinator(for: windowId)?.contentWindow
}
if let lastFocused = WindowLifecycleMonitor.shared.mostRecentWindow(for: key.connectionId),
registered.contains(where: { $0 === lastFocused }) {
return lastFocused
}
return registered.first
?? WindowLifecycleMonitor.shared.mostRecentWindow(for: key.connectionId)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import AppKit
import Foundation

// WorkspaceContextCloseCoordinator.swift — atomic preflight/save/discard/close of every window in one context
// Part of the Database Context Rail feature (Task 4 of the plan).

@MainActor
internal final class WorkspaceContextCloseCoordinator {
private let registry: WorkspaceContextRegistry
private let confirmWindow: (UUID) async -> Bool
private let closeWindow: (UUID) -> Void
private let activate: (WorkspaceContextKey) -> Void

internal static let shared = WorkspaceContextCloseCoordinator()

internal init(
registry: WorkspaceContextRegistry = .shared,
confirmWindow: ((UUID) async -> Bool)? = nil,
closeWindow: ((UUID) -> Void)? = nil,
activate: ((WorkspaceContextKey) -> Void)? = nil
) {
self.registry = registry
self.confirmWindow = confirmWindow ?? WorkspaceContextCloseCoordinator.confirmRegisteredWindow
self.closeWindow = closeWindow ?? WorkspaceContextCloseCoordinator.commitRegisteredWindow
self.activate = activate ?? { key in
WorkspaceContextActivationCoordinator.shared.activate(key)
}
}

internal func close(key: WorkspaceContextKey, sourceWindow _: NSWindow?) async -> Bool {
let windowIds = registry.windowIds(for: key)

// Confirm every window before any close. A later cancel must leave earlier
// windows open and still registered.
for windowId in windowIds {
guard await confirmWindow(windowId) else { return false }
}

for windowId in windowIds {
closeWindow(windowId)
registry.unregister(windowId: windowId)
}

if registry.contains(key) {
registry.unregisterAll(for: key)
}

if let nextKey = registry.selectedKey {
activate(nextKey)
}

return true
}

/// Unsaved SQL, pending grid edits, and a running query all prompt here.
/// Nothing is closed or unregistered until every window has agreed.
private static func confirmRegisteredWindow(_ windowId: UUID) async -> Bool {
guard let coordinator = MainContentCoordinator.coordinator(for: windowId) else {
return true
}
if let actions = coordinator.commandActions {
return await actions.confirmWindowClose()
}
// A live coordinator without command actions still has work that
// confirmWindowClose would have prompted for. Do not discard it.
if coordinator.hasAnyUnsavedWork() { return false }
if coordinator.toolbarState.isExecuting { return false }
return true
}

private static func commitRegisteredWindow(_ windowId: UUID) {
guard let coordinator = MainContentCoordinator.coordinator(for: windowId) else { return }
if let actions = coordinator.commandActions {
actions.commitWindowClose(asBatchSurvivor: false)
return
}
coordinator.contentWindow?.close()
}
}
Loading