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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- An open tab now keeps running against the database it was opened on, so changing the database in the sidebar no longer breaks it with a "table doesn't exist" error. (#2026)
- Saving a table structure change no longer moves the sidebar and toolbar to that tab's database. (#2026)
- Row edits, fetch all rows, and multi-statement scripts now write to the database the tab is bound to, not whichever database another tab last used. (#2026)
- Switching between tabs no longer changes the connection's saved default database. (#2026)
- Refreshing after a save no longer reloads windows that are browsing a different database. (#2026)
- Hidden columns are now applied on a tab bound to a database other than the one selected in the sidebar. (#2026)
- Asking the AI chat or an MCP client to list tables or run a query no longer changes the database selected in the app. (#2026)
- Exporting now reads from the database the export was started for, and no longer opens a separate connection for every database on the server when listing them. (#2026)
- Exporting a query's remaining rows while disconnected now reports the error instead of leaving the progress sheet up forever. (#2026)
- Stopping a query now cancels the query itself rather than whichever background metadata read finished last. (#2026)
- Reopening a window no longer loses a table tab's saved sort and page when the connection was still connecting. (#2026)

### Changed

- A tab's window subtitle now shows the database it is bound to, for query tabs as well as table tabs. (#2026)
- Changing a tab's database from its toolbar now repoints only that tab and leaves the sidebar where it is. (#2026)
- `describe_table` and `get_table_ddl` now take a `database` argument, in AI chat and over MCP, so a table in another database can be inspected without changing the database selected in the app. `list_schemas` in AI chat takes one too. (#2026)

## [0.63.0] - 2026-08-05

### Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,15 @@ struct ConfirmDestructiveOperationChatTool: ChatTool {

let mcpSettings = await MainActor.run { AppSettingsManager.shared.mcp }
let services = MCPToolServices(connectionBridge: context.bridge, authPolicy: context.authPolicy)
let scope = try await context.bridge.resolveScope(
connectionId: connectionId,
database: nil,
schema: nil
)
let payload = try await ToolQueryExecutor.executeAndLog(
services: services,
query: query,
connectionId: connectionId,
databaseName: meta.databaseName,
scope: scope,
maxRows: 0,
timeoutSeconds: MCPLimitResolver.resolveTimeoutSeconds(requested: nil, settings: mcpSettings),
principalLabel: String(localized: "AI Chat")
Expand Down
11 changes: 9 additions & 2 deletions TablePro/Core/AI/Chat/Tools/DescribeTableChatTool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ struct DescribeTableChatTool: ChatTool {
properties: [
"connection_id": ChatToolSchemaBuilder.connectionId,
"table": ChatToolSchemaBuilder.string(description: "Table or view name"),
"database": ChatToolSchemaBuilder.string(
description: "Database name. Pass null to use current.",
optional: true
),
"schema": ChatToolSchemaBuilder.schemaName
]
)
Expand All @@ -20,12 +24,15 @@ struct DescribeTableChatTool: ChatTool {
func execute(input: JsonValue, context: ChatToolContext) async throws -> ChatToolResult {
let connectionId = try context.resolveConnectionId(input)
let table = try ChatToolArgumentDecoder.requireString(input, key: "table")
let database = ChatToolArgumentDecoder.optionalString(input, key: "database")
let schema = ChatToolArgumentDecoder.optionalString(input, key: "schema")
let payload = try await context.bridge.describeTable(

let scope = try await context.bridge.resolveScope(
connectionId: connectionId,
table: table,
database: database,
schema: schema
)
let payload = try await context.bridge.describeTable(scope: scope, table: table)
return ChatToolResult(content: payload.jsonString(prettyPrinted: true))
}
}
18 changes: 8 additions & 10 deletions TablePro/Core/AI/Chat/Tools/ExecuteQueryChatTool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,11 @@ struct ExecuteQueryChatTool: ChatTool {
optional: true
),
"database": ChatToolSchemaBuilder.string(
description: "Switch to this database before executing. Pass null to use current.",
description: "Run against this database. Pass null to use current.",
optional: true
),
"schema": ChatToolSchemaBuilder.string(
description: "Switch to this schema before executing. Pass null to use current.",
description: "Run against this schema. Pass null to use current.",
optional: true
)
]
Expand Down Expand Up @@ -73,12 +73,11 @@ struct ExecuteQueryChatTool: ChatTool {
)
}

if let database {
_ = try await context.bridge.switchDatabase(connectionId: connectionId, database: database)
}
if let schema {
_ = try await context.bridge.switchSchema(connectionId: connectionId, schema: schema)
}
let scope = try await context.bridge.resolveScope(
connectionId: connectionId,
database: database,
schema: schema
)

try await context.authPolicy.checkSafeModeDialog(
sql: query,
Expand All @@ -91,8 +90,7 @@ struct ExecuteQueryChatTool: ChatTool {
let payload = try await ToolQueryExecutor.executeAndLog(
services: services,
query: query,
connectionId: connectionId,
databaseName: meta.databaseName,
scope: scope,
maxRows: maxRows,
timeoutSeconds: timeoutSeconds,
principalLabel: String(localized: "AI Chat")
Expand Down
11 changes: 9 additions & 2 deletions TablePro/Core/AI/Chat/Tools/GetTableDDLChatTool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ struct GetTableDDLChatTool: ChatTool {
properties: [
"connection_id": ChatToolSchemaBuilder.connectionId,
"table": ChatToolSchemaBuilder.string(description: "Table name"),
"database": ChatToolSchemaBuilder.string(
description: "Database name. Pass null to use current.",
optional: true
),
"schema": ChatToolSchemaBuilder.schemaName
]
)
Expand All @@ -20,12 +24,15 @@ struct GetTableDDLChatTool: ChatTool {
func execute(input: JsonValue, context: ChatToolContext) async throws -> ChatToolResult {
let connectionId = try context.resolveConnectionId(input)
let table = try ChatToolArgumentDecoder.requireString(input, key: "table")
let database = ChatToolArgumentDecoder.optionalString(input, key: "database")
let schema = ChatToolArgumentDecoder.optionalString(input, key: "schema")
let payload = try await context.bridge.getTableDDL(

let scope = try await context.bridge.resolveScope(
connectionId: connectionId,
table: table,
database: database,
schema: schema
)
let payload = try await context.bridge.getTableDDL(scope: scope, table: table)
return ChatToolResult(content: payload.jsonString(prettyPrinted: true))
}
}
14 changes: 12 additions & 2 deletions TablePro/Core/AI/Chat/Tools/ListSchemasChatTool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,24 @@ struct ListSchemasChatTool: ChatTool {
let description = String(localized: "List schemas available in the active database of a connection.")
let inputSchema: JsonValue = ChatToolSchemaBuilder.object(
properties: [
"connection_id": ChatToolSchemaBuilder.connectionId
"connection_id": ChatToolSchemaBuilder.connectionId,
"database": ChatToolSchemaBuilder.string(
description: "Database name. Pass null to use current.",
optional: true
)
]
)
let mode: ChatToolMode = .readOnly

func execute(input: JsonValue, context: ChatToolContext) async throws -> ChatToolResult {
let connectionId = try context.resolveConnectionId(input)
let payload = try await context.bridge.listSchemas(connectionId: connectionId)
let database = ChatToolArgumentDecoder.optionalString(input, key: "database")
let scope = try await context.bridge.resolveScope(
connectionId: connectionId,
database: database,
schema: nil
)
let payload = try await context.bridge.listSchemas(scope: scope)
return ChatToolResult(content: payload.jsonString(prettyPrinted: true))
}
}
14 changes: 6 additions & 8 deletions TablePro/Core/AI/Chat/Tools/ListTablesChatTool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,13 @@ struct ListTablesChatTool: ChatTool {
let schema = ChatToolArgumentDecoder.optionalString(input, key: "schema")
let includeRowCounts = ChatToolArgumentDecoder.optionalBool(input, key: "include_row_counts", default: false)

if let database {
_ = try await context.bridge.switchDatabase(connectionId: connectionId, database: database)
}
if let schema {
_ = try await context.bridge.switchSchema(connectionId: connectionId, schema: schema)
}

let payload = try await context.bridge.listTables(
let scope = try await context.bridge.resolveScope(
connectionId: connectionId,
database: database,
schema: schema
)
let payload = try await context.bridge.listTables(
scope: scope,
includeRowCounts: includeRowCounts
)
return ChatToolResult(content: payload.jsonString(prettyPrinted: true))
Expand Down
2 changes: 1 addition & 1 deletion TablePro/Core/Autocomplete/SQLSchemaProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ actor SQLSchemaProvider {
let capturedConnection = connection
let capturedTables = tables
let (dbName, idQuote, editorLanguage, queryLanguageName) = await MainActor.run {
let resolvedName = DatabaseManager.shared.activeDatabaseName(for: capturedConnection)
let resolvedName = DatabaseManager.shared.browseDatabaseName(for: capturedConnection)
let quote = PluginManager.shared.sqlDialect(for: dbType)?.identifierQuote ?? "\""
let lang = PluginManager.shared.editorLanguage(for: dbType)
let langName = PluginManager.shared.queryLanguageName(for: dbType)
Expand Down
95 changes: 95 additions & 0 deletions TablePro/Core/Concurrency/SessionDriverGate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
//
// SessionDriverGate.swift
// TablePro
//

import Foundation

/// Serialises access to a connection's single shared driver.
///
/// The driver carries one mutable position (its current database and schema), so an
/// operation has to move it before it runs. Without ordering, two windows interleave
/// their moves and each runs against the other's database.
///
/// The body runs inline in the caller's own task rather than in a detached one, so
/// cancellation still reaches the work.
@MainActor
final class SessionDriverGate {
private struct Waiter {
let ticket: UUID
let continuation: CheckedContinuation<Void, Error>
}

private var holders: Set<UUID> = []
private var waiters: [UUID: [Waiter]] = [:]

func withExclusiveAccess<T>(
_ connectionId: UUID,
_ body: () async throws -> T
) async throws -> T {
try await acquire(connectionId)
defer { release(connectionId) }
return try await body()
}

/// Releases a connection that is going away, failing everyone still queued for it.
func drain(connectionId: UUID) {
holders.remove(connectionId)
let pending = waiters.removeValue(forKey: connectionId) ?? []
for waiter in pending {
waiter.continuation.resume(throwing: CancellationError())
}
}

private func acquire(_ connectionId: UUID) async throws {
guard holders.contains(connectionId) else {
holders.insert(connectionId)
return
}
let ticket = UUID()
try await withTaskCancellationHandler(
operation: { try await enqueue(ticket: ticket, connectionId: connectionId) },
onCancel: { [weak self] in
Task { @MainActor in
self?.failWaiter(ticket: ticket, connectionId: connectionId)
}
}
)
}

private func enqueue(ticket: UUID, connectionId: UUID) async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
guard !Task.isCancelled else {
continuation.resume(throwing: CancellationError())
return
}
waiters[connectionId, default: []].append(
Waiter(ticket: ticket, continuation: continuation)
)
}
}

/// Removes the ticket before resuming it, so a cancellation racing a hand-off
/// can only ever find one of them.
private func failWaiter(ticket: UUID, connectionId: UUID) {
guard var pending = waiters[connectionId],
let index = pending.firstIndex(where: { $0.ticket == ticket })
else {
return
}
let waiter = pending.remove(at: index)
waiters[connectionId] = pending.isEmpty ? nil : pending
waiter.continuation.resume(throwing: CancellationError())
}

private func release(_ connectionId: UUID) {
guard var pending = waiters[connectionId], !pending.isEmpty else {
holders.remove(connectionId)
waiters.removeValue(forKey: connectionId)
return
}
let next = pending.removeFirst()
waiters[connectionId] = pending.isEmpty ? nil : pending
next.continuation.resume()
}
}
Loading
Loading