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
3 changes: 3 additions & 0 deletions apple/TablePro/Core/Events/AppEvents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,9 @@ final class AppEvents {

let versionHistoryRefreshRequested = PassthroughSubject<UUID, Never>()

/// Refresh on a table listing tab, which loads its own rows rather than through the coordinator.
let tableListingRefreshRequested = PassthroughSubject<UUID, Never>()

// MARK: - License & Sync

let licenseStatusDidChange = PassthroughSubject<Void, Never>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ private extension TabType {
case .usersRoles: "usersRoles"
case .objectSource: "objectSource"
case .versionHistory: "versionHistory"
case .tableListing: "tableListing"
}
}
}
1 change: 1 addition & 0 deletions apple/TablePro/Core/Scripting/ScriptEnumerations.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ internal enum ScriptEnumerations {
case .insights: fourCharCode("TPk7")
case .objectSource: fourCharCode("TPk8")
case .versionHistory: fourCharCode("TPk9")
case .tableListing: fourCharCode("TPkl")
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,12 @@ internal enum EditorTabOpener {
case .versionHistory:
guard let subject = payload.versionHistorySubject else { return }
tabManager.addVersionHistoryTab(subject: subject, title: payload.tabTitle ?? QueryTabManager.versionHistoryFallbackTitle)
case .tableListing:
tabManager.addTableListingTab(
title: payload.tabTitle ?? String(localized: "Tables"),
databaseName: payload.databaseName ?? openingDatabaseName,
schemaName: payload.schemaName
)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,8 @@ internal extension MainSplitViewController {
return "curlybraces.square"
case .versionHistory:
return "clock.arrow.circlepath"
case .tableListing:
return "list.bullet.rectangle"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1400,7 +1400,8 @@ internal final class MainSplitViewController: NSSplitViewController {
switch tabType {
case .usersRoles:
return UsersRolesLayoutMetrics.tabMinimumWidth
case .query, .table, .createTable, .erDiagram, .serverDashboard, .insights, .objectSource, .versionHistory:
case .query, .table, .createTable, .erDiagram, .serverDashboard, .insights, .objectSource, .versionHistory,
.tableListing:
return defaultDetailMinThickness
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ internal enum ToolbarContextResolver {
String(localized: "Create Table")
case .usersRoles:
String(localized: "Apply Changes")
case .query, .table, .erDiagram, .serverDashboard, .insights, .objectSource, .versionHistory, nil:
case .query, .table, .erDiagram, .serverDashboard, .insights, .objectSource, .versionHistory, .tableListing, nil:
String(localized: "Save Changes")
}
}
Expand Down Expand Up @@ -93,7 +93,7 @@ internal enum ToolbarContextResolver {
case .createTable:
/// A definition that is not on the server yet has nothing to reload.
return [MainWindowToolbar.refresh]
case .erDiagram, .serverDashboard, .insights, .objectSource, .versionHistory:
case .erDiagram, .serverDashboard, .insights, .objectSource, .versionHistory, .tableListing:
/// None of these can stage a change, so the commit control could only ever be dim.
return [MainWindowToolbar.saveChanges]
case .query, .table, .usersRoles:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,11 @@ enum WindowTitleResolver {
return explicitTitle
}
return QueryTabManager.versionHistoryFallbackTitle
case .tableListing:
if let explicitTitle, !explicitTitle.isBlank {
return explicitTitle
}
return String(localized: "Tables")
default:
break
}
Expand Down
35 changes: 24 additions & 11 deletions apple/TablePro/Core/Services/Query/AllTablesListing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,40 @@
import Foundation
import TableProPluginKit

/// What the sidebar's Show All Tables command opens for an engine.
enum AllTablesListing: Equatable {
/// A shell command, run in this window's new tab like any command the user types.
case shellCommand(String)
/// The plugin's own listing, opened in a tab of its own window.
case statement(String)

/// What the sidebar's Show All Tables command lists for an engine, and for which container.
enum AllTablesListing {
/// The plugin's listing when it builds one. The MongoDB and Redis plugins build none, so their shell's own
/// command stands in, chosen by engine: Elasticsearch, Typesense and Weaviate also highlight as
/// JavaScript and etcd as a command line, and none of them runs these.
static func resolve(databaseType: DatabaseType, pluginListing: String?) -> AllTablesListing? {
static func statement(databaseType: DatabaseType, pluginListing: String?) -> String? {
if let pluginListing {
return .statement(pluginListing)
return pluginListing
}
switch databaseType {
case .mongodb:
return .shellCommand(#"db.runCommand({"listCollections": 1, "nameOnly": false})"#)
return #"db.runCommand({"listCollections": 1, "nameOnly": false})"#
case .redis:
return .shellCommand("SCAN 0 MATCH * COUNT 100")
return "SCAN 0 MATCH * COUNT 100"
default:
return nil
}
}

/// The container a plugin's listing names, taken from the listing's own scope. The shared driver's
/// schema is not it: a cross-database tab moves that and nothing moves it back. A schema goes over
/// escaped as a string literal, the form the plugins have always been handed.
static func container(
slot: EngineNamespaceSlot,
scope: DatabaseScope,
escapeLiteral: (String) -> String
) -> String? {
switch slot {
case .schema:
return scope.schema.map(escapeLiteral)
case .database:
return scope.database.isEmpty ? nil : scope.database
case .unqualified:
return nil
}
}
}
82 changes: 82 additions & 0 deletions apple/TablePro/Core/Services/Query/TableListingLoader.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
//
// TableListingLoader.swift
// TablePro
//

import Foundation
import os

/// Reads one container's table listing over a metadata connection, the way the sidebar reads the
/// tables themselves.
///
/// The statement is built here, from the plugin, every time. A listing tab saves no SQL, so a restored
/// tab never runs text read back from disk, and a plugin that changes its listing is picked up at once.
internal struct TableListingLoader: Sendable {
nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "TableListing")

let databaseType: DatabaseType
let slot: EngineNamespaceSlot

func fetch(
scope: DatabaseScope,
from provider: any ScopedMetadataProviding
) async -> MetadataFetchOutcome<TableListing> {
let databaseType = databaseType
let slot = slot
do {
let result = try await provider.withMetadataDriver(scope: scope) { driver in
let adapter = driver as? PluginDriverAdapter
let container = AllTablesListing.container(slot: slot, scope: scope) { value in
adapter?.escapeStringLiteral(value) ?? value
}
guard let statement = AllTablesListing.statement(
databaseType: databaseType,
pluginListing: adapter?.allTablesMetadataSQL(schema: container)
) else {
throw TableListingError.unavailable
}
return try await driver.execute(query: statement)
}
return .fetched(TableListing(
rows: TableRows.from(queryRows: result.rows, columns: result.columns, columnTypes: result.columnTypes),
isIncomplete: result.isTruncated,
statusMessage: result.statusMessage
))
} catch {
if DatabaseCancellationDiagnosis.isCancellation(error) { return .cancelled }
Self.logger.error("Failed to list tables: \(error.publicLogShape, privacy: .public)")
return .failed(error.localizedDescription)
}
}
}

/// What a listing came back with. A driver that answers in pages, the way Redis answers `SCAN`, says so,
/// and the listing has to say it too rather than pass one page off as every table there is.
internal struct TableListing: Sendable {
let rows: TableRows
let isIncomplete: Bool
/// The driver's own words about the result, such as the cursor of the next page, else a plain
/// statement that the listing is partial.
let notice: String?

init(rows: TableRows, isIncomplete: Bool, statusMessage: String?) {
self.rows = rows
self.isIncomplete = isIncomplete
if let statusMessage, !statusMessage.isEmpty {
notice = statusMessage
} else {
notice = isIncomplete ? String(localized: "The driver returned part of the listing.") : nil
}
}
}

internal enum TableListingError: LocalizedError {
case unavailable

var errorDescription: String? {
switch self {
case .unavailable:
return String(localized: "This connection's driver does not list its tables.")
}
}
}
32 changes: 31 additions & 1 deletion apple/TablePro/Models/Query/QueryTabManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,36 @@ final class QueryTabManager: ObservableObject {
selectedTabId = newTab.id
}

/// One listing per database and schema, so asking again for the same tables returns to the tab
/// already showing them, the way opening the same table does.
@discardableResult
func addTableListingTab(title: String, databaseName: String, schemaName: String?) -> UUID {
let schemaName = schemaName.flatMap { $0.isEmpty ? nil : $0 }
if let existing = tabs.first(where: {
$0.tabType == .tableListing
&& $0.tableContext.databaseName == databaseName
&& $0.tableContext.schemaName == schemaName
}) {
selectedTabId = existing.id
return existing.id
}
var newTab = QueryTab(title: title, tabType: .tableListing)
newTab.tableContext.isEditable = false
newTab.tableContext.databaseName = databaseName
newTab.tableContext.schemaName = schemaName
newTab.hasUserInteraction = true
tabs.append(newTab)
selectedTabId = newTab.id
return newTab.id
}

/// The sidebar section's own name for the kind, qualified by the schema on an engine that lists
/// tables per schema, where two listings in one database would otherwise share a label.
static func tableListingTitle(kindTitle: String, schemaName: String?) -> String {
guard let schemaName, !schemaName.isEmpty else { return kindTitle }
return String(format: String(localized: "%@: %@"), kindTitle, schemaName)
}

static var versionHistoryFallbackTitle: String {
String(localized: "History")
}
Expand Down Expand Up @@ -822,7 +852,7 @@ extension QueryTabManager {
/// Server-wide tabs answer for the whole connection, so they belong to every entry.
private static func bindsToContainer(_ type: TabType) -> Bool {
switch type {
case .query, .table, .createTable, .erDiagram, .objectSource:
case .query, .table, .createTable, .erDiagram, .objectSource, .tableListing:
return true
case .serverDashboard, .usersRoles, .insights, .versionHistory:
return false
Expand Down
3 changes: 3 additions & 0 deletions apple/TablePro/Models/Query/QueryTabState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ enum TabType: Equatable, Codable, Hashable {
case insights
case objectSource
case versionHistory
/// The tables of one database or schema, as a read-only grid. Built from the catalog each time it
/// loads, never from SQL saved with the tab.
case tableListing
}

/// Minimal representation of a tab for persistence
Expand Down
3 changes: 2 additions & 1 deletion apple/TablePro/Models/UI/GridSelectionOwner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ internal enum GridSelectionOwner: Equatable {
/// indices it writes are the data grid's display positions, resolved through
/// `DisplayRowMapping`.
return .dataGrid
case .createTable, .erDiagram, .serverDashboard, .usersRoles, .insights, .objectSource, .versionHistory:
case .createTable, .erDiagram, .serverDashboard, .usersRoles, .insights, .objectSource, .versionHistory,
.tableListing:
return .none
}
}
Expand Down
2 changes: 1 addition & 1 deletion apple/TablePro/Models/UI/PendingChangeKind.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ internal enum PendingChangeKind: Equatable, Hashable, Sendable {
return hasCreateTablePending ? .createTable : nil
case .usersRoles:
return hasPrincipalChanges ? .principals : nil
case .query, .table, .erDiagram, .serverDashboard, .insights, .objectSource, .versionHistory:
case .query, .table, .erDiagram, .serverDashboard, .insights, .objectSource, .versionHistory, .tableListing:
return contentKind(
hasDataChanges: hasDataChanges,
hasStructureChanges: hasStructureChanges,
Expand Down
1 change: 1 addition & 0 deletions apple/TablePro/Resources/TablePro.sdef
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
<enumerator name="query insights" code="TPk7" description="Query history statistics."/>
<enumerator name="object source" code="TPk8" description="The source of a routine or trigger."/>
<enumerator name="version history" code="TPk9" description="The history of a saved query or a linked SQL file."/>
<enumerator name="table listing" code="TPkl" description="The tables of a database or schema, with their sizes."/>
</enumeration>

<enumeration name="connection color" code="TPcl" description="A color the user gave a connection, group or tag.">
Expand Down
Loading
Loading