Skip to content

Commit fa8645f

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/column-reorder-across-engines
# Conflicts: # CHANGELOG.md # TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift
2 parents 734dad7 + 99bf8fb commit fa8645f

29 files changed

Lines changed: 815 additions & 93 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1919
- Move Tab to New Window on a tab's right-click menu, and by dragging a tab out of the strip. (#2438)
2020
- Column reorder by dragging on ClickHouse and Oracle. (#2479)
2121
- Column reorder on PostgreSQL, SQLite, libSQL, Turso and Cloudflare D1, through a table rebuild shown before anything runs. (#2479)
22+
- Recognition of SQLite and DuckDB databases by their contents, whatever they are named. (#2476)
23+
- `.parquet` files in Finder's Open With, read through DuckDB. (#2476)
24+
- Prompt to install the driver a file needs, before the file opens. (#2476)
2225

2326
### Changed
2427

@@ -27,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2730
- Editor tab presses handled by AppKit rather than SwiftUI gestures. (#2438)
2831
- Connection-first labels with the database or schema on a second line in the connections strip. (#2550)
2932
- Column reorder withheld, with the reason on the row number, where the engine cannot change column order. (#2479)
33+
- File > Open File… as an app command over every file TablePro reads. (#2476)
3034

3135
### Fixed
3236

TablePro/Core/Menu/AppDelegate+MainMenuActions.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,18 @@ extension AppDelegate: NSMenuItemValidation {
4141
WindowOpener.shared.openWelcome()
4242
}
4343

44+
/// A database file, a connection share and a plugin all open without a live connection, so
45+
/// this belongs to the app and not to an editor window that may not exist.
46+
@objc func openFile(_ sender: Any?) {
47+
Task { @MainActor in
48+
guard let urls = await FileOpenPanel.present() else { return }
49+
for url in urls {
50+
guard case .some(.success(let intent)) = URLClassifier.classify(url) else { continue }
51+
await LaunchIntentRouter.shared.route(intent)
52+
}
53+
}
54+
}
55+
4456
@objc func compareAndSyncDatabases(_ sender: Any?) {
4557
CompareSyncLauncher.open()
4658
}

TablePro/Core/Menu/FileMenuBuilder.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ enum FileMenuBuilder {
3333
MenuItemFactory.separator,
3434
MenuItemFactory.item(
3535
String(localized: "Open File…"),
36-
action: #selector(MainSplitViewController.openSQLFile(_:)),
36+
action: #selector(AppDelegate.openFile(_:)),
3737
shortcut: .openFile,
3838
keyboard: keyboard
3939
),
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
//
2+
// MissingDriverPluginPrompt.swift
3+
// TablePro
4+
//
5+
6+
import AppKit
7+
import os
8+
9+
/// Asks for the driver a file needs before the file is opened, rather than raising a window
10+
/// headlined "Could not connect" whose only action leaves for the connection list.
11+
@MainActor
12+
internal enum MissingDriverPluginPrompt {
13+
nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "MissingDriverPluginPrompt")
14+
15+
/// Whether the open may go ahead.
16+
internal static func ensureInstalled(for type: DatabaseType, opening url: URL) async -> Bool {
17+
/// A plugin without `TableProProvidesDatabaseTypeIds` registers on the eager path, which a
18+
/// Finder open beats to the question: launch intents route on a fixed 150ms timer. Asking
19+
/// before the barrier offers to install a plugin the user already has.
20+
if !PluginManager.shared.isDriverInstalled(for: type) {
21+
await PluginManager.shared.waitForInitialLoad()
22+
}
23+
guard !PluginManager.shared.isDriverInstalled(for: type) else { return true }
24+
/// A plugin that ships inside the app and still did not load is disabled or damaged, and
25+
/// downloading cannot fix either. The connect attempt reports what actually went wrong.
26+
guard type.isDownloadablePlugin else { return true }
27+
28+
let displayName = PluginMetadataRegistry.shared.snapshot(for: type)?.displayName ?? type.rawValue
29+
let confirmed = await AlertHelper.confirm(
30+
title: String(
31+
format: String(localized: "Install the %@ plugin to open “%@”?"),
32+
displayName,
33+
url.lastPathComponent
34+
),
35+
message: String(localized: "TablePro reads this file with a driver it downloads from the plugin registry."),
36+
confirmButton: String(localized: "Install")
37+
)
38+
guard confirmed else { return false }
39+
40+
do {
41+
try await PluginManager.shared.installMissingPlugin(for: type) { _ in }
42+
logger.info("Installed \(type.rawValue, privacy: .public) to open a file")
43+
return true
44+
} catch {
45+
logger.error("Install failed for \(type.rawValue, privacy: .public): \(error.localizedDescription, privacy: .public)")
46+
AlertHelper.showErrorSheet(
47+
title: String(localized: "Plugin Installation Failed"),
48+
message: error.localizedDescription,
49+
window: nil
50+
)
51+
return false
52+
}
53+
}
54+
}

TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -653,6 +653,7 @@ extension PluginMetadataRegistry {
653653
systemDatabaseNames: [],
654654
systemSchemaNames: [],
655655
fileExtensions: ["db", "db3", "s3db", "sl3", "sqlite", "sqlite3", "sqlitedb"],
656+
fileSignatures: [.magic("SQLite format 3\u{0}")],
656657
databaseGroupingStrategy: .flat,
657658
structureColumnFields: [
658659
.name, .type, .nullable, .defaultValue, .generated, .generationExpression,
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
//
2+
// PluginMetadataRegistry+DuckDBDefaults.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
import TableProPluginKit
8+
9+
extension PluginMetadataRegistry {
10+
/// The curated snapshot for DuckDB, alongside its connection fields in the sibling file.
11+
func duckdbPluginDefaults(
12+
dialect: SQLDialectDescriptor,
13+
columnTypes: [String: [String]]
14+
) -> [(typeId: String, snapshot: PluginMetadataSnapshot)] {
15+
[
16+
("DuckDB", PluginMetadataSnapshot(
17+
displayName: "DuckDB", iconName: "duckdb-icon", defaultPort: 9_494,
18+
requiresAuthentication: false, supportsForeignKeys: true, supportsSchemaEditing: true,
19+
isDownloadable: true, primaryUrlScheme: "duckdb", parameterStyle: .dollar,
20+
navigationModel: .standard,
21+
explainVariants: [
22+
ExplainVariant(id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN", format: .indentedText),
23+
],
24+
pathFieldRole: .database,
25+
supportsHealthMonitor: false, urlSchemes: ["duckdb", "quack"],
26+
postConnectActions: [.selectSchemaFromLastSession],
27+
brandColorHex: "#FFD900",
28+
queryLanguageName: "SQL", editorLanguage: .sql,
29+
connectionMode: .apiOnly, supportsDatabaseSwitching: true,
30+
capabilities: PluginMetadataSnapshot.CapabilityFlags(
31+
supportsSchemaSwitching: true,
32+
supportsImport: true,
33+
supportsExport: true,
34+
supportsSSH: false,
35+
supportsSSL: false,
36+
supportsCascadeDrop: false,
37+
supportsForeignKeyDisable: true,
38+
supportsReadOnlyMode: true,
39+
supportsQueryProgress: false,
40+
requiresReconnectForDatabaseSwitch: false,
41+
supportsDropDatabase: false,
42+
supportsRenameColumn: true,
43+
supportsConnectionPooling: false,
44+
localFilePathField: .additionalField("duckdbFilePath")
45+
),
46+
schema: PluginMetadataSnapshot.SchemaInfo(
47+
defaultSchemaName: "main",
48+
defaultGroupName: "main",
49+
tableEntityName: "Tables",
50+
containerEntityName: "Database",
51+
defaultPrimaryKeyColumn: nil,
52+
immutableColumns: [],
53+
systemDatabaseNames: ["system", "temp"],
54+
systemSchemaNames: [],
55+
fileExtensions: ["duckdb", "ddb", "parquet", "csv", "tsv", "json", "ndjson"],
56+
/// Only DuckDB's own storage. The data formats above are recognised by name
57+
/// alone, because `duckdb_open` picks their reader from the extension and
58+
/// refuses a Parquet file called anything else.
59+
///
60+
/// `DUCK` sits behind the header's eight-byte checksum, followed by the storage
61+
/// version as a little-endian `uint64`. Four bytes on their own are not enough
62+
/// to name a format, and `SELECT 'DUCK';` spells them at exactly that offset,
63+
/// so the version's high six bytes have to be zero as well. Storage versions
64+
/// are still in the sixties, and a version past 65535 would cost recognition by
65+
/// content rather than break it.
66+
fileSignatures: [.magic("DUCK", at: 8).andZeroes(at: 14, count: 6)],
67+
databaseGroupingStrategy: .bySchema,
68+
structureColumnFields: [.name, .type, .nullable, .defaultValue, .autoIncrement, .comment]
69+
),
70+
editor: PluginMetadataSnapshot.EditorConfig(
71+
sqlDialect: dialect,
72+
statementCompletions: [],
73+
columnTypesByCategory: columnTypes
74+
),
75+
connection: PluginMetadataSnapshot.ConnectionConfig(
76+
additionalConnectionFields: Self.duckdbConnectionFields,
77+
category: .analytical,
78+
tagline: String(localized: "Embedded and remote analytical SQL"),
79+
hidesBuiltInPassword: true,
80+
hidesBuiltInDatabase: true
81+
)
82+
))
83+
]
84+
}
85+
}

TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift

Lines changed: 1 addition & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -687,62 +687,6 @@ extension PluginMetadataRegistry {
687687
tagline: String(localized: "Column-oriented OLAP for big data")
688688
)
689689
)),
690-
("DuckDB", PluginMetadataSnapshot(
691-
displayName: "DuckDB", iconName: "duckdb-icon", defaultPort: 9_494,
692-
requiresAuthentication: false, supportsForeignKeys: true, supportsSchemaEditing: true,
693-
isDownloadable: true, primaryUrlScheme: "duckdb", parameterStyle: .dollar,
694-
navigationModel: .standard,
695-
explainVariants: [
696-
ExplainVariant(id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN", format: .indentedText),
697-
],
698-
pathFieldRole: .database,
699-
supportsHealthMonitor: false, urlSchemes: ["duckdb", "quack"],
700-
postConnectActions: [.selectSchemaFromLastSession],
701-
brandColorHex: "#FFD900",
702-
queryLanguageName: "SQL", editorLanguage: .sql,
703-
connectionMode: .apiOnly, supportsDatabaseSwitching: true,
704-
capabilities: PluginMetadataSnapshot.CapabilityFlags(
705-
supportsSchemaSwitching: true,
706-
supportsImport: true,
707-
supportsExport: true,
708-
supportsSSH: false,
709-
supportsSSL: false,
710-
supportsCascadeDrop: false,
711-
supportsForeignKeyDisable: true,
712-
supportsReadOnlyMode: true,
713-
supportsQueryProgress: false,
714-
requiresReconnectForDatabaseSwitch: false,
715-
supportsDropDatabase: false,
716-
supportsRenameColumn: true,
717-
supportsConnectionPooling: false,
718-
localFilePathField: .additionalField("duckdbFilePath")
719-
),
720-
schema: PluginMetadataSnapshot.SchemaInfo(
721-
defaultSchemaName: "main",
722-
defaultGroupName: "main",
723-
tableEntityName: "Tables",
724-
containerEntityName: "Database",
725-
defaultPrimaryKeyColumn: nil,
726-
immutableColumns: [],
727-
systemDatabaseNames: ["system", "temp"],
728-
systemSchemaNames: [],
729-
fileExtensions: ["duckdb", "ddb", "parquet", "csv", "tsv", "json", "ndjson"],
730-
databaseGroupingStrategy: .bySchema,
731-
structureColumnFields: [.name, .type, .nullable, .defaultValue, .autoIncrement, .comment]
732-
),
733-
editor: PluginMetadataSnapshot.EditorConfig(
734-
sqlDialect: duckdbDialect,
735-
statementCompletions: [],
736-
columnTypesByCategory: duckdbColumnTypes
737-
),
738-
connection: PluginMetadataSnapshot.ConnectionConfig(
739-
additionalConnectionFields: Self.duckdbConnectionFields,
740-
category: .analytical,
741-
tagline: String(localized: "Embedded and remote analytical SQL"),
742-
hidesBuiltInPassword: true,
743-
hidesBuiltInDatabase: true
744-
)
745-
)),
746690
("Beancount", PluginMetadataSnapshot(
747691
displayName: "Beancount", iconName: "beancount-icon", defaultPort: 0,
748692
requiresAuthentication: false, supportsForeignKeys: false, supportsSchemaEditing: false,
@@ -1191,6 +1135,7 @@ extension PluginMetadataRegistry {
11911135
)
11921136
)),
11931137
] + tursoPluginDefaults(dialect: d1Dialect, columnTypes: d1ColumnTypes)
1138+
+ duckdbPluginDefaults(dialect: duckdbDialect, columnTypes: duckdbColumnTypes)
11941139
+ cloudPluginDefaults() + elasticsearchPluginDefaults() + surrealDBPluginDefaults()
11951140
+ kafkaPluginDefaults()
11961141
}

TablePro/Core/Plugins/PluginMetadataRegistry.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,9 @@ struct PluginMetadataSnapshot: Sendable {
124124
let systemDatabaseNames: [String]
125125
let systemSchemaNames: [String]
126126
let fileExtensions: [String]
127+
/// Curated in the app rather than declared by the plugin: claiming a format from the
128+
/// system also needs a `CFBundleDocumentTypes` entry only the app bundle can make.
129+
let fileSignatures: [DatabaseFileSignature]
127130
let databaseGroupingStrategy: GroupingStrategy
128131
let structureColumnFields: [StructureColumnField]
129132

@@ -138,6 +141,7 @@ struct PluginMetadataSnapshot: Sendable {
138141
systemDatabaseNames: [String],
139142
systemSchemaNames: [String],
140143
fileExtensions: [String],
144+
fileSignatures: [DatabaseFileSignature] = [],
141145
databaseGroupingStrategy: GroupingStrategy,
142146
structureColumnFields: [StructureColumnField]
143147
) {
@@ -151,6 +155,7 @@ struct PluginMetadataSnapshot: Sendable {
151155
self.systemDatabaseNames = systemDatabaseNames
152156
self.systemSchemaNames = systemSchemaNames
153157
self.fileExtensions = fileExtensions
158+
self.fileSignatures = fileSignatures
154159
self.databaseGroupingStrategy = databaseGroupingStrategy
155160
self.structureColumnFields = structureColumnFields
156161
}
@@ -317,6 +322,7 @@ struct PluginMetadataSnapshot: Sendable {
317322
systemDatabaseNames: schema.systemDatabaseNames,
318323
systemSchemaNames: schema.systemSchemaNames,
319324
fileExtensions: schema.fileExtensions,
325+
fileSignatures: schema.fileSignatures,
320326
databaseGroupingStrategy: source.schema.databaseGroupingStrategy,
321327
structureColumnFields: schema.structureColumnFields
322328
),
@@ -610,6 +616,7 @@ final class PluginMetadataRegistry: @unchecked Sendable {
610616
systemDatabaseNames: driverType.systemDatabaseNames,
611617
systemSchemaNames: driverType.systemSchemaNames,
612618
fileExtensions: driverType.fileExtensions,
619+
fileSignatures: existingSnapshot?.schema.fileSignatures ?? [],
613620
databaseGroupingStrategy: driverType.databaseGroupingStrategy,
614621
structureColumnFields: driverType.structureColumnFields
615622
),
@@ -701,6 +708,16 @@ final class PluginMetadataRegistry: @unchecked Sendable {
701708
return result
702709
}
703710

711+
func allFileSignatures() -> [String: [DatabaseFileSignature]] {
712+
lock.lock()
713+
defer { lock.unlock() }
714+
var result: [String: [DatabaseFileSignature]] = [:]
715+
for (typeId, snapshot) in snapshots where !snapshot.schema.fileSignatures.isEmpty {
716+
result[typeId] = snapshot.schema.fileSignatures
717+
}
718+
return result
719+
}
720+
704721
func allUrlSchemes() -> [String: String] {
705722
lock.lock()
706723
defer { lock.unlock() }
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
//
2+
// FileOpenPanel.swift
3+
// TablePro
4+
//
5+
6+
import AppKit
7+
8+
/// The panel behind File > Open File…, offering everything TablePro can open rather than SQL alone.
9+
@MainActor
10+
internal enum FileOpenPanel {
11+
internal static func present() async -> [URL]? {
12+
let panel = NSOpenPanel()
13+
panel.allowsMultipleSelection = true
14+
panel.canChooseDirectories = false
15+
panel.message = String(localized: "Select files to open")
16+
17+
let filter = OpenableFileFilter()
18+
panel.delegate = filter
19+
let response = await panel.begin()
20+
withExtendedLifetime(filter) {}
21+
22+
guard response == .OK else { return nil }
23+
return panel.urls
24+
}
25+
}
26+
27+
/// `allowedContentTypes` can only match a name, so it disables the files this panel exists to
28+
/// reach: a SQLite database saved with no extension, or under someone else's.
29+
@MainActor
30+
private final class OpenableFileFilter: NSObject, NSOpenSavePanelDelegate {
31+
/// The panel asks again on every scroll, and deciding costs a read of the file's head.
32+
private var decisions: [URL: Bool] = [:]
33+
34+
func panel(_ sender: Any, shouldEnable url: URL) -> Bool {
35+
if let decided = decisions[url] { return decided }
36+
let enabled = isOpenable(url)
37+
decisions[url] = enabled
38+
return enabled
39+
}
40+
41+
/// A plain folder stays enabled so the panel can be navigated. A package is a file as far as
42+
/// the user is concerned, and `.tableplugin` is one, so it is classified like any other.
43+
///
44+
/// The name is asked first and settles most of a folder without touching its contents. This
45+
/// delegate runs on the main actor, and reading the head of a file on an unavailable mount or
46+
/// an iCloud placeholder takes as long as that mount does, whatever the sixteen bytes suggest.
47+
private func isOpenable(_ url: URL) -> Bool {
48+
let values = try? url.resourceValues(forKeys: [.isDirectoryKey, .isPackageKey])
49+
if values?.isDirectory == true, values?.isPackage != true { return true }
50+
if case .some(.success) = URLClassifier.classifyByName(url) { return true }
51+
return DatabaseFileClassifier.classify(url) != nil
52+
}
53+
}

TablePro/Core/Services/Infrastructure/MainSplitViewController+FileMenuActions.swift

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,6 @@
66
import AppKit
77

88
extension MainSplitViewController {
9-
@objc func openSQLFile(_ sender: Any?) {
10-
commandActions?.openSQLFile()
11-
}
12-
139
@objc func saveDocument(_ sender: Any?) {
1410
commandActions?.saveChanges()
1511
}

0 commit comments

Comments
 (0)