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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Cloud Firestore and Firebase Realtime Database as a registry plugin over their REST APIs. (#2504)
- Google Cloud Bigtable as a registry plugin over the REST API.
- Warning banner and Test Connection notice for a server warning at login, such as an Oracle password about to expire. (#3401)
- Schema picker in the query editor on servers that switch schemas, such as Oracle, to point a query tab at another schema.

### Changed

Expand Down Expand Up @@ -46,6 +47,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Set Value > Empty and Set EMPTY offered only on columns that can hold an empty value, not on dates, numbers or booleans.
- Clearing a date, number or boolean cell in a NOT NULL column keeps the stored value instead of saving an empty string.
- Clearing a Redis key's TTL in the grid removes its expiry.
- Each database in the connections strip keeps its own tabs. The tab bar shows the tabs of the one you are on. (#3431)
- Query tabs stay on the schema they were opened in on servers that switch schemas, instead of following the sidebar.
- Close Tab with no tab left closes the database's entry in the connections strip, then the connection.

### Removed

Expand Down Expand Up @@ -127,6 +131,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Scroll to latest message button in AI chat never appearing.
- AI reasoning text not updating while it streams.
- Clearing a date, number or boolean field on iPhone and iPad saving an empty string, stored as `0000-00-00 00:00:00` on MySQL, instead of NULL.
- Switching database from the toolbar, sidebar or Open Database left the previous database's tab on screen.
- Quick Switcher marking a table as open when a table of the same name was open in another database.
- Table Info in the inspector showing the numbers of a table of the same name in another database.
- Links, AppleScript and MCP `open_table_tab` focusing a table of the same name in another database.
- Dropped databases staying in the connections strip, where clicking them failed with an error.
- A query tab moved to another database in its database picker keeping the old database's schema.
- Connections strip missing a database whose tabs were in a torn-off window.

### Security

Expand Down
25 changes: 25 additions & 0 deletions TablePro/Core/Database/DatabaseManager+Scope.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,31 @@ extension DatabaseManager {
)
}

/// The table an open by name lands on. The open path binds a missing database to the browse
/// cursor and a missing schema through `resolvedSchemaName`, so a lookup for a tab already
/// showing it resolves the same way. Treating a missing part as "any" matched `tenant_a.orders`
/// for a request that meant the `orders` of the database being browsed.
func requestedTableIdentity(
table: String,
database: String?,
schema: String?,
for connectionId: UUID
) -> TableTabIdentity {
let named = database.flatMap { $0.isEmpty ? nil : $0 }
let resolvedDatabase = named ?? activeSessions[connectionId]?.resolvedBrowseDatabase ?? ""
return TableTabIdentity(
table: table,
database: resolvedDatabase,
schema: resolvedSchemaName(schema, inDatabase: resolvedDatabase, for: connectionId)
)
}

/// The identity of a table tab as it stands, with an unbound database read as the browse
/// cursor, the rule `TabTableContext.resolvedDatabaseName(browsing:)` defines.
func tableIdentity(of tab: QueryTab, for connectionId: UUID) -> TableTabIdentity? {
tab.tableIdentity(browsing: activeSessions[connectionId]?.resolvedBrowseDatabase ?? "")
}

/// Authoritative schema for a table identity. An explicit schema passes through unchanged; a
/// blank or missing one resolves to the live session's current schema and stays nil for
/// schema-less engines. A blank name never reaches a query builder: engines that qualify
Expand Down
3 changes: 3 additions & 0 deletions TablePro/Core/Events/AppEvents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ final class AppEvents {
/// Payload is the connection's id.
let browseContainerChanged = PassthroughSubject<UUID, Never>()

/// A window put another connections-strip entry in front, so the strip's highlight moves.
let visibleContainerChanged = PassthroughSubject<Void, Never>()

// MARK: - Data Sources

/// Query history changed (entry added, deleted, or cleared).
Expand Down
39 changes: 33 additions & 6 deletions TablePro/Core/MCP/Protocol/Tools/MCPTabSnapshotProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,27 +66,54 @@ enum MCPTabSnapshotProvider {
return snapshots.filter { readable.contains($0.connectionId) }
}

/// A database or schema the caller left out means the one being browsed, which is where the
/// open put the tab. Matching the name alone returned whichever same-named table sorted first,
/// so an agent asking for `tenant_b.orders` was handed the id of `tenant_a.orders`.
static func awaitTab(
connectionId: UUID,
tableName: String?,
databaseName: String? = nil,
schemaName: String? = nil,
clock: ContinuousClock = ContinuousClock()
) async -> MCPTabSnapshot? {
let deadline = clock.now.advanced(by: openTimeout)
while clock.now < deadline {
let snapshots = await MainActor.run { collectTabSnapshots() }
let candidates = snapshots.filter { $0.connectionId == connectionId }
if let tableName {
if let match = candidates.first(where: { $0.tableName == tableName }) {
return match
let match = await MainActor.run { () -> MCPTabSnapshot? in
let candidates = collectTabSnapshots().filter { $0.connectionId == connectionId }
guard let tableName else {
return candidates.first(where: \.isActive) ?? candidates.first
}
} else if let match = candidates.first(where: \.isActive) ?? candidates.first {
let databaseManager = DatabaseManager.shared
return firstTable(
in: candidates,
matching: databaseManager.requestedTableIdentity(
table: tableName, database: databaseName, schema: schemaName, for: connectionId
),
browseDatabase: databaseManager.activeSessions[connectionId]?.resolvedBrowseDatabase ?? ""
)
}
if let match {
return match
}
try? await Task.sleep(for: openPollInterval)
}
return nil
}

static func firstTable(
in snapshots: [MCPTabSnapshot],
matching wanted: TableTabIdentity,
browseDatabase: String
) -> MCPTabSnapshot? {
snapshots.first { snapshot in
guard snapshot.tabType == TabType.table.snapshotName,
let table = snapshot.tableName
else { return false }
let database = snapshot.databaseName.flatMap { $0.isEmpty ? nil : $0 } ?? browseDatabase
return TableTabIdentity(table: table, database: database, schema: snapshot.schemaName) == wanted
}
}

static func encode(_ snapshot: MCPTabSnapshot) -> JsonValue {
var fields: [String: JsonValue] = [
"connection_id": .string(snapshot.connectionId.uuidString),
Expand Down
4 changes: 3 additions & 1 deletion TablePro/Core/MCP/Protocol/Tools/OpenTableTabTool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,9 @@ public struct OpenTableTabTool: MCPToolImplementation {

guard let snapshot = await MCPTabSnapshotProvider.awaitTab(
connectionId: connectionId,
tableName: tableName
tableName: tableName,
databaseName: databaseName,
schemaName: schemaName
) else {
throw MCPToolExecutionError.timedOut(
String(localized: "TablePro did not open that table in time.")
Expand Down
23 changes: 16 additions & 7 deletions TablePro/Core/Scripting/ScriptingSnapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -105,19 +105,28 @@ internal enum ScriptingSnapshot {

/// The table name alone is not an identity. Another database or schema on the same connection
/// can have a table of the same name already open, and returning that one hands the script a
/// reference to rows it did not ask for.
/// reference to rows it did not ask for. A database or schema the script left out means the one
/// being browsed, which is where the open put the tab, not any of them.
private static func matches(
_ tab: ScriptTab,
tableName: String,
databaseName: String?,
schemaName: String?
) -> Bool {
guard tab.kind == ScriptEnumerations.code(for: .table), tab.tableName == tableName else {
return false
}
if let databaseName, tab.databaseName != databaseName { return false }
if let schemaName, tab.schemaName != schemaName { return false }
return true
guard tab.kind == ScriptEnumerations.code(for: .table),
let tabTable = tab.tableName,
tabTable == tableName
else { return false }
let databaseManager = DatabaseManager.shared
let wanted = databaseManager.requestedTableIdentity(
table: tableName, database: databaseName, schema: schemaName, for: tab.connectionId
)
let browseDatabase = databaseManager.activeSessions[tab.connectionId]?.resolvedBrowseDatabase ?? ""
return TableTabIdentity(
table: tabTable,
database: tab.databaseName ?? browseDatabase,
schema: tab.schemaName
) == wanted
}

@discardableResult
Expand Down
21 changes: 21 additions & 0 deletions TablePro/Core/Services/Infrastructure/ConnectionWorkspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ internal final class ConnectionWorkspace {
private var browseCancellable: AnyCancellable?
private var statusCancellable: AnyCancellable?
private var tabsCancellable: AnyCancellable?
private var catalogCancellable: AnyCancellable?

/// Set before `teardown` clears anything. Releasing the workspace writes `session = nil`, whose
/// observer would otherwise record the container all over again from the session the manager has
Expand Down Expand Up @@ -159,6 +160,25 @@ internal final class ConnectionWorkspace {
.sink { [weak self] _ in
self?.recordTabContainers()
}
catalogCancellable = AppCommands.shared.containerChanged
.filter { [connectionId] change in change.connectionId == connectionId }
.receive(on: RunLoop.main)
.sink { [weak self] change in
guard let self, let type = self.connection?.type else { return }
self.adoptContainerChange(change, target: PluginManager.shared.containerSwitchTarget(for: type))
}
}

/// A dropped database is not open any more, and a renamed one is open under its new name. Only
/// the user closing an entry took one out of the set, so a dropped database stayed listed and
/// clicking it failed the switch with an error.
internal func adoptContainerChange(_ change: DatabaseContainerChange, target: ContainerSwitchTarget?) {
guard let name = WorkspaceAnchoring.entryName(of: change.container, target: target),
openedContainers.contains(name)
else { return }
closeContainer(name)
guard case .renamed(let newName) = change.kind else { return }
openContainer(newName)
}

private func recordBrowsedContainer() {
Expand Down Expand Up @@ -376,6 +396,7 @@ internal final class ConnectionWorkspace {
browseCancellable = nil
statusCancellable = nil
tabsCancellable = nil
catalogCancellable = nil
openedContainers = []
panes.teardown()
trailingPaneState?.teardown()
Expand Down
21 changes: 14 additions & 7 deletions TablePro/Core/Services/Infrastructure/EditorTabOpener.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ internal enum EditorTabOpener {
toolbarState: ConnectionToolbarState?
) {
let browseDatabaseName = DatabaseManager.shared.browseDatabaseName(for: connection)
/// A table named without a database is the one in the database being browsed, the same answer
/// the lookup for a tab already showing it gives. Anything else opened without one belongs to
/// the entry on screen, which the cursor lags while a switch to it is on its way.
let openingDatabaseName = tabManager.openingDatabase(browsing: browseDatabaseName)

switch payload.intent {
case .openContent:
Expand All @@ -28,14 +32,15 @@ internal enum EditorTabOpener {
to: tabManager,
connection: connection,
toolbarState: toolbarState,
browseDatabaseName: browseDatabaseName
browseDatabaseName: browseDatabaseName,
openingDatabaseName: openingDatabaseName
)
case .newEmptyTab:
let allTabs = MainContentCoordinator.allTabs(for: connection.id)
tabManager.addTab(
initialQuery: payload.initialQuery,
title: QueryTabManager.nextQueryTitle(existingTabs: allTabs),
databaseName: payload.databaseName ?? browseDatabaseName,
databaseName: payload.databaseName ?? openingDatabaseName,
claimFocus: true
)
case .restoreOrDefault:
Expand All @@ -48,7 +53,8 @@ internal enum EditorTabOpener {
to tabManager: QueryTabManager,
connection: DatabaseConnection,
toolbarState: ConnectionToolbarState?,
browseDatabaseName: String
browseDatabaseName: String,
openingDatabaseName: String
) {
switch payload.tabType {
case .table:
Expand All @@ -67,18 +73,19 @@ internal enum EditorTabOpener {
tabManager.addTab(
initialQuery: payload.initialQuery,
title: payload.tabTitle,
databaseName: payload.databaseName ?? browseDatabaseName,
databaseName: payload.databaseName ?? openingDatabaseName,
sourceFileURL: payload.sourceFileURL,
sourceFileStamp: payload.sourceFileStamp,
sourceFileEncoding: payload.sourceFileEncoding,
claimFocus: true
)
case .createTable:
tabManager.addCreateTableTab(databaseName: payload.databaseName ?? browseDatabaseName)
tabManager.addCreateTableTab(databaseName: payload.databaseName ?? openingDatabaseName)
case .erDiagram:
tabManager.addERDiagramTab(
schemaKey: payload.erDiagramSchemaKey ?? payload.databaseName ?? browseDatabaseName,
databaseName: payload.databaseName ?? browseDatabaseName
schemaKey: payload.erDiagramSchemaKey ?? payload.databaseName ?? openingDatabaseName,
databaseName: payload.databaseName ?? openingDatabaseName,
schemaName: payload.schemaName
)
case .serverDashboard:
tabManager.addServerDashboardTab()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ extension MainSplitViewController {
/// action the command does not take.
var closeCommandTitle: String? {
if commandActions?.hasOpenTab == true { return String(localized: "Close Tab") }
if let workspace = workspaces.selected,
let entry = WorkspaceCloseAction.closableEntry(
connectionId: workspace.connectionId,
container: workspace.sessionState?.tabManager.visibleContainer
) {
return WorkspaceRailViewController.containerCloseTitle(for: entry)
}
guard let name = workspaces.selected?.connection?.name else { return nil }
return String(format: String(localized: "Close “%@”"), name)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ internal extension MainSplitViewController {
/// real `tabManager` to observe, and there is no single moment in the lifecycle that is
/// reliably that call.
func applyTabStripVisibility() {
let tabCount = workspaces.selected?.sessionState?.tabManager.tabs.count ?? 0
let tabCount = workspaces.selected?.sessionState?.tabManager.visibleTabs.count ?? 0
tabStripAccessory.setBandVisible(
ConnectionWindowPaneResolver.showsTabStrip(
for: currentPane,
Expand Down Expand Up @@ -71,12 +71,13 @@ internal extension MainSplitViewController {

/// `onMainActorChange` already delivers a turn late, which is what the count needs:
/// the publisher fires before the change lands. It also wakes for any tab mutation
/// rather than only a count change, so the count is compared here.
/// rather than only a count change, so the count is compared here. The count is the
/// strip's, which moving to another entry changes without a tab opening or closing.
guard let manager = workspaces.selected?.sessionState?.tabManager else { return }
var lastCount = manager.tabs.count
var lastCount = manager.visibleTabs.count
tabStripObservation = manager.onMainActorChange { [weak self] in
guard let self, generation == self.tabStripObservationGeneration else { return }
let current = manager.tabs.count
let current = manager.visibleTabs.count
guard current != lastCount else { return }
lastCount = current
self.tabStripObservationIsArmed = false
Expand Down Expand Up @@ -180,7 +181,7 @@ internal extension MainSplitViewController {
/// truncated or duplicated name is told apart.
tooltip: { [weak manager] id in
guard let manager, let tab = manager.tabs.first(where: { $0.id == id }) else { return "" }
let description = EditorTabLabelResolver.resolve(tabs: manager.tabs, target: target)[id]?
let description = EditorTabLabelResolver.resolve(tabs: manager.visibleTabs, target: target)[id]?
.description ?? tab.title
guard tab.isPreview else { return description }
return String(
Expand All @@ -193,7 +194,7 @@ internal extension MainSplitViewController {
/// `EditorTabStripInteraction.adopt(tabIds:overflow:)`; the strip's own `onChange` carries
/// every later change.
interaction.adopt(
tabIds: manager.tabs.map(\.id),
tabIds: manager.visibleTabs.map(\.id),
overflow: AppSettingsManager.shared.tabs.overflow
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -738,7 +738,7 @@ internal final class MainSplitViewController: NSSplitViewController {
agentSessionTitle: agentSession?.title,
connection: paneConnection,
tab: sessionState?.tabManager.selectedTab,
hasTabs: !(sessionState?.tabManager.tabs.isEmpty ?? true),
hasTabs: !(sessionState?.tabManager.visibleTabs.isEmpty ?? true),
queryLanguageName: paneConnection.map { PluginManager.shared.queryLanguageName(for: $0.type) } ?? nil
)
windowTitle = resolved.title
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,14 @@ enum SessionStateFactory {
) -> SessionState {
let connectionId = connection.id
let tabSessionRegistry = TabSessionRegistry()
let containerTarget = PluginManager.shared.containerSwitchTarget(for: connection.type)
/// The entry the window opens on is where the connection is, or will be once it connects.
let openingContainer = DatabaseManager.shared.session(for: connectionId)
.flatMap { WorkspaceAnchoring.browsedContainer(of: $0, target: containerTarget) }
?? WorkspaceAnchoring.defaultContainer(of: connection, target: containerTarget)
let tabMgr = QueryTabManager(
containerTarget: containerTarget,
visibleContainer: openingContainer,
globalTabsProvider: {
MainActor.assumeIsolated { MainContentCoordinator.allTabs(for: connectionId) }
},
Expand Down
Loading
Loading