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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Tab rows in Settings > General > Tabs, wrapping the strip instead of scrolling it. (#2438)
- Autoscrolling while dragging a tab, so a tab can be moved past the run currently on screen. (#2438)
- Move Tab to New Window on a tab's right-click menu, and by dragging a tab out of the strip. (#2438)
- Recognition of SQLite and DuckDB databases by their contents, whatever they are named. (#2476)
- `.parquet` files in Finder's Open With, read through DuckDB. (#2476)
- Prompt to install the driver a file needs, before the file opens. (#2476)

### Changed

- MongoDB statements split as JavaScript rather than at every semicolon.
- MongoDB editor diagnostics report JavaScript syntax errors rather than unsupported method names.
- Editor tab presses handled by AppKit rather than SwiftUI gestures. (#2438)
- Connection-first labels with the database or schema on a second line in the connections strip. (#2550)
- File > Open File… as an app command over every file TablePro reads. (#2476)

### Fixed

Expand Down
12 changes: 12 additions & 0 deletions TablePro/Core/Menu/AppDelegate+MainMenuActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,18 @@ extension AppDelegate: NSMenuItemValidation {
WindowOpener.shared.openWelcome()
}

/// A database file, a connection share and a plugin all open without a live connection, so
/// this belongs to the app and not to an editor window that may not exist.
@objc func openFile(_ sender: Any?) {
Task { @MainActor in
guard let urls = await FileOpenPanel.present() else { return }
for url in urls {
guard case .some(.success(let intent)) = URLClassifier.classify(url) else { continue }
await LaunchIntentRouter.shared.route(intent)
}
}
}

@objc func compareAndSyncDatabases(_ sender: Any?) {
CompareSyncLauncher.open()
}
Expand Down
2 changes: 1 addition & 1 deletion TablePro/Core/Menu/FileMenuBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ enum FileMenuBuilder {
MenuItemFactory.separator,
MenuItemFactory.item(
String(localized: "Open File…"),
action: #selector(MainSplitViewController.openSQLFile(_:)),
action: #selector(AppDelegate.openFile(_:)),
shortcut: .openFile,
keyboard: keyboard
),
Expand Down
54 changes: 54 additions & 0 deletions TablePro/Core/Plugins/MissingDriverPluginPrompt.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
//
// MissingDriverPluginPrompt.swift
// TablePro
//

import AppKit
import os

/// Asks for the driver a file needs before the file is opened, rather than raising a window
/// headlined "Could not connect" whose only action leaves for the connection list.
@MainActor
internal enum MissingDriverPluginPrompt {
nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "MissingDriverPluginPrompt")

/// Whether the open may go ahead.
internal static func ensureInstalled(for type: DatabaseType, opening url: URL) async -> Bool {
/// A plugin without `TableProProvidesDatabaseTypeIds` registers on the eager path, which a
/// Finder open beats to the question: launch intents route on a fixed 150ms timer. Asking
/// before the barrier offers to install a plugin the user already has.
if !PluginManager.shared.isDriverInstalled(for: type) {
await PluginManager.shared.waitForInitialLoad()
}
guard !PluginManager.shared.isDriverInstalled(for: type) else { return true }
/// A plugin that ships inside the app and still did not load is disabled or damaged, and
/// downloading cannot fix either. The connect attempt reports what actually went wrong.
guard type.isDownloadablePlugin else { return true }

let displayName = PluginMetadataRegistry.shared.snapshot(for: type)?.displayName ?? type.rawValue
let confirmed = await AlertHelper.confirm(
title: String(
format: String(localized: "Install the %@ plugin to open “%@”?"),
displayName,
url.lastPathComponent
),
message: String(localized: "TablePro reads this file with a driver it downloads from the plugin registry."),
confirmButton: String(localized: "Install")
)
guard confirmed else { return false }

do {
try await PluginManager.shared.installMissingPlugin(for: type) { _ in }
logger.info("Installed \(type.rawValue, privacy: .public) to open a file")
return true
} catch {
logger.error("Install failed for \(type.rawValue, privacy: .public): \(error.localizedDescription, privacy: .public)")
AlertHelper.showErrorSheet(
title: String(localized: "Plugin Installation Failed"),
message: error.localizedDescription,
window: nil
)
return false
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,7 @@ extension PluginMetadataRegistry {
systemDatabaseNames: [],
systemSchemaNames: [],
fileExtensions: ["db", "db3", "s3db", "sl3", "sqlite", "sqlite3", "sqlitedb"],
fileSignatures: [.magic("SQLite format 3\u{0}")],
databaseGroupingStrategy: .flat,
structureColumnFields: [
.name, .type, .nullable, .defaultValue, .generated, .generationExpression,
Expand Down
86 changes: 86 additions & 0 deletions TablePro/Core/Plugins/PluginMetadataRegistry+DuckDBDefaults.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
//
// PluginMetadataRegistry+DuckDBDefaults.swift
// TablePro
//

import Foundation
import TableProPluginKit

extension PluginMetadataRegistry {
/// The curated snapshot for DuckDB, alongside its connection fields in the sibling file.
func duckdbPluginDefaults(
dialect: SQLDialectDescriptor,
columnTypes: [String: [String]]
) -> [(typeId: String, snapshot: PluginMetadataSnapshot)] {
[
("DuckDB", PluginMetadataSnapshot(
displayName: "DuckDB", iconName: "duckdb-icon", defaultPort: 9_494,
requiresAuthentication: false, supportsForeignKeys: true, supportsSchemaEditing: true,
isDownloadable: true, primaryUrlScheme: "duckdb", parameterStyle: .dollar,
navigationModel: .standard,
explainVariants: [
ExplainVariant(id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN", format: .indentedText),
],
pathFieldRole: .database,
supportsHealthMonitor: false, urlSchemes: ["duckdb", "quack"],
postConnectActions: [.selectSchemaFromLastSession],
brandColorHex: "#FFD900",
queryLanguageName: "SQL", editorLanguage: .sql,
connectionMode: .apiOnly, supportsDatabaseSwitching: true,
supportsColumnReorder: false,
capabilities: PluginMetadataSnapshot.CapabilityFlags(
supportsSchemaSwitching: true,
supportsImport: true,
supportsExport: true,
supportsSSH: false,
supportsSSL: false,
supportsCascadeDrop: false,
supportsForeignKeyDisable: true,
supportsReadOnlyMode: true,
supportsQueryProgress: false,
requiresReconnectForDatabaseSwitch: false,
supportsDropDatabase: false,
supportsRenameColumn: true,
supportsConnectionPooling: false,
localFilePathField: .additionalField("duckdbFilePath")
),
schema: PluginMetadataSnapshot.SchemaInfo(
defaultSchemaName: "main",
defaultGroupName: "main",
tableEntityName: "Tables",
containerEntityName: "Database",
defaultPrimaryKeyColumn: nil,
immutableColumns: [],
systemDatabaseNames: ["system", "temp"],
systemSchemaNames: [],
fileExtensions: ["duckdb", "ddb", "parquet", "csv", "tsv", "json", "ndjson"],
/// Only DuckDB's own storage. The data formats above are recognised by name
/// alone, because `duckdb_open` picks their reader from the extension and
/// refuses a Parquet file called anything else.
///
/// `DUCK` sits behind the header's eight-byte checksum, followed by the storage
/// version as a little-endian `uint64`. Four bytes on their own are not enough
/// to name a format, and `SELECT 'DUCK';` spells them at exactly that offset,
/// so the version's high six bytes have to be zero as well. Storage versions
/// are still in the sixties, and a version past 65535 would cost recognition by
/// content rather than break it.
fileSignatures: [.magic("DUCK", at: 8).andZeroes(at: 14, count: 6)],
databaseGroupingStrategy: .bySchema,
structureColumnFields: [.name, .type, .nullable, .defaultValue, .autoIncrement, .comment]
),
editor: PluginMetadataSnapshot.EditorConfig(
sqlDialect: dialect,
statementCompletions: [],
columnTypesByCategory: columnTypes
),
connection: PluginMetadataSnapshot.ConnectionConfig(
additionalConnectionFields: Self.duckdbConnectionFields,
category: .analytical,
tagline: String(localized: "Embedded and remote analytical SQL"),
hidesBuiltInPassword: true,
hidesBuiltInDatabase: true
)
))
]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -693,63 +693,6 @@ extension PluginMetadataRegistry {
tagline: String(localized: "Column-oriented OLAP for big data")
)
)),
("DuckDB", PluginMetadataSnapshot(
displayName: "DuckDB", iconName: "duckdb-icon", defaultPort: 9_494,
requiresAuthentication: false, supportsForeignKeys: true, supportsSchemaEditing: true,
isDownloadable: true, primaryUrlScheme: "duckdb", parameterStyle: .dollar,
navigationModel: .standard,
explainVariants: [
ExplainVariant(id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN", format: .indentedText),
],
pathFieldRole: .database,
supportsHealthMonitor: false, urlSchemes: ["duckdb", "quack"],
postConnectActions: [.selectSchemaFromLastSession],
brandColorHex: "#FFD900",
queryLanguageName: "SQL", editorLanguage: .sql,
connectionMode: .apiOnly, supportsDatabaseSwitching: true,
supportsColumnReorder: false,
capabilities: PluginMetadataSnapshot.CapabilityFlags(
supportsSchemaSwitching: true,
supportsImport: true,
supportsExport: true,
supportsSSH: false,
supportsSSL: false,
supportsCascadeDrop: false,
supportsForeignKeyDisable: true,
supportsReadOnlyMode: true,
supportsQueryProgress: false,
requiresReconnectForDatabaseSwitch: false,
supportsDropDatabase: false,
supportsRenameColumn: true,
supportsConnectionPooling: false,
localFilePathField: .additionalField("duckdbFilePath")
),
schema: PluginMetadataSnapshot.SchemaInfo(
defaultSchemaName: "main",
defaultGroupName: "main",
tableEntityName: "Tables",
containerEntityName: "Database",
defaultPrimaryKeyColumn: nil,
immutableColumns: [],
systemDatabaseNames: ["system", "temp"],
systemSchemaNames: [],
fileExtensions: ["duckdb", "ddb", "parquet", "csv", "tsv", "json", "ndjson"],
databaseGroupingStrategy: .bySchema,
structureColumnFields: [.name, .type, .nullable, .defaultValue, .autoIncrement, .comment]
),
editor: PluginMetadataSnapshot.EditorConfig(
sqlDialect: duckdbDialect,
statementCompletions: [],
columnTypesByCategory: duckdbColumnTypes
),
connection: PluginMetadataSnapshot.ConnectionConfig(
additionalConnectionFields: Self.duckdbConnectionFields,
category: .analytical,
tagline: String(localized: "Embedded and remote analytical SQL"),
hidesBuiltInPassword: true,
hidesBuiltInDatabase: true
)
)),
("Beancount", PluginMetadataSnapshot(
displayName: "Beancount", iconName: "beancount-icon", defaultPort: 0,
requiresAuthentication: false, supportsForeignKeys: false, supportsSchemaEditing: false,
Expand Down Expand Up @@ -1202,6 +1145,7 @@ extension PluginMetadataRegistry {
)
)),
] + tursoPluginDefaults(dialect: d1Dialect, columnTypes: d1ColumnTypes)
+ duckdbPluginDefaults(dialect: duckdbDialect, columnTypes: duckdbColumnTypes)
+ cloudPluginDefaults() + elasticsearchPluginDefaults() + surrealDBPluginDefaults()
+ kafkaPluginDefaults()
}
Expand Down
17 changes: 17 additions & 0 deletions TablePro/Core/Plugins/PluginMetadataRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,9 @@ struct PluginMetadataSnapshot: Sendable {
let systemDatabaseNames: [String]
let systemSchemaNames: [String]
let fileExtensions: [String]
/// Curated in the app rather than declared by the plugin: claiming a format from the
/// system also needs a `CFBundleDocumentTypes` entry only the app bundle can make.
let fileSignatures: [DatabaseFileSignature]
let databaseGroupingStrategy: GroupingStrategy
let structureColumnFields: [StructureColumnField]

Expand All @@ -138,6 +141,7 @@ struct PluginMetadataSnapshot: Sendable {
systemDatabaseNames: [String],
systemSchemaNames: [String],
fileExtensions: [String],
fileSignatures: [DatabaseFileSignature] = [],
databaseGroupingStrategy: GroupingStrategy,
structureColumnFields: [StructureColumnField]
) {
Expand All @@ -151,6 +155,7 @@ struct PluginMetadataSnapshot: Sendable {
self.systemDatabaseNames = systemDatabaseNames
self.systemSchemaNames = systemSchemaNames
self.fileExtensions = fileExtensions
self.fileSignatures = fileSignatures
self.databaseGroupingStrategy = databaseGroupingStrategy
self.structureColumnFields = structureColumnFields
}
Expand Down Expand Up @@ -317,6 +322,7 @@ struct PluginMetadataSnapshot: Sendable {
systemDatabaseNames: schema.systemDatabaseNames,
systemSchemaNames: schema.systemSchemaNames,
fileExtensions: schema.fileExtensions,
fileSignatures: schema.fileSignatures,
databaseGroupingStrategy: source.schema.databaseGroupingStrategy,
structureColumnFields: schema.structureColumnFields
),
Expand Down Expand Up @@ -610,6 +616,7 @@ final class PluginMetadataRegistry: @unchecked Sendable {
systemDatabaseNames: driverType.systemDatabaseNames,
systemSchemaNames: driverType.systemSchemaNames,
fileExtensions: driverType.fileExtensions,
fileSignatures: existingSnapshot?.schema.fileSignatures ?? [],
databaseGroupingStrategy: driverType.databaseGroupingStrategy,
structureColumnFields: driverType.structureColumnFields
),
Expand Down Expand Up @@ -701,6 +708,16 @@ final class PluginMetadataRegistry: @unchecked Sendable {
return result
}

func allFileSignatures() -> [String: [DatabaseFileSignature]] {
lock.lock()
defer { lock.unlock() }
var result: [String: [DatabaseFileSignature]] = [:]
for (typeId, snapshot) in snapshots where !snapshot.schema.fileSignatures.isEmpty {
result[typeId] = snapshot.schema.fileSignatures
}
return result
}

func allUrlSchemes() -> [String: String] {
lock.lock()
defer { lock.unlock() }
Expand Down
53 changes: 53 additions & 0 deletions TablePro/Core/Services/Infrastructure/FileOpenPanel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
//
// FileOpenPanel.swift
// TablePro
//

import AppKit

/// The panel behind File > Open File…, offering everything TablePro can open rather than SQL alone.
@MainActor
internal enum FileOpenPanel {
internal static func present() async -> [URL]? {
let panel = NSOpenPanel()
panel.allowsMultipleSelection = true
panel.canChooseDirectories = false
panel.message = String(localized: "Select files to open")

let filter = OpenableFileFilter()
panel.delegate = filter
let response = await panel.begin()
withExtendedLifetime(filter) {}

guard response == .OK else { return nil }
return panel.urls
}
}

/// `allowedContentTypes` can only match a name, so it disables the files this panel exists to
/// reach: a SQLite database saved with no extension, or under someone else's.
@MainActor
private final class OpenableFileFilter: NSObject, NSOpenSavePanelDelegate {
/// The panel asks again on every scroll, and deciding costs a read of the file's head.
private var decisions: [URL: Bool] = [:]

func panel(_ sender: Any, shouldEnable url: URL) -> Bool {
if let decided = decisions[url] { return decided }
let enabled = isOpenable(url)
decisions[url] = enabled
return enabled
}

/// A plain folder stays enabled so the panel can be navigated. A package is a file as far as
/// the user is concerned, and `.tableplugin` is one, so it is classified like any other.
///
/// The name is asked first and settles most of a folder without touching its contents. This
/// delegate runs on the main actor, and reading the head of a file on an unavailable mount or
/// an iCloud placeholder takes as long as that mount does, whatever the sixteen bytes suggest.
private func isOpenable(_ url: URL) -> Bool {
let values = try? url.resourceValues(forKeys: [.isDirectoryKey, .isPackageKey])
if values?.isDirectory == true, values?.isPackage != true { return true }
if case .some(.success) = URLClassifier.classifyByName(url) { return true }
return DatabaseFileClassifier.classify(url) != nil
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,6 @@
import AppKit

extension MainSplitViewController {
@objc func openSQLFile(_ sender: Any?) {
commandActions?.openSQLFile()
}

@objc func saveDocument(_ sender: Any?) {
commandActions?.saveChanges()
}
Expand Down
Loading
Loading