Skip to content

Commit d989084

Browse files
authored
fix: overhaul Import URL dialog (#865)
* fix: overhaul Import URL with dynamic placeholder, preview, and missing field mappings (#853) * fix: address review — multiline clipboard, label width, add parser tests * docs: consolidate Import URL changelog entries
1 parent eedc914 commit d989084

15 files changed

Lines changed: 330 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- Import URL: dynamic placeholder, parsed preview, clipboard auto-paste, libSQL/D1 support, URL schemes for Oracle/ClickHouse/etcd/D1/libSQL
1213
- In-app feedback form for bug reports and feature requests via Help > Report an Issue
1314
- Per-connection "Local only" option to exclude individual connections from iCloud sync
1415
- Filter operator picker shows SQL symbols alongside names for quick visual recognition

TablePro/Core/Utilities/Connection/ConnectionURLParser.swift

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
//
55

66
import Foundation
7+
import TableProPluginKit
78

89
struct ParsedConnectionURL {
910
let type: DatabaseType
@@ -129,10 +130,12 @@ struct ConnectionURLParser {
129130

130131
let isSrv = scheme == "mongodb+srv"
131132

132-
if dbType == .sqlite {
133+
let isFileBased = dbType == .sqlite || dbType == .duckdb
134+
|| PluginMetadataRegistry.shared.snapshot(forTypeId: dbType.pluginTypeId)?.connectionMode == .fileBased
135+
if isFileBased {
133136
let path = String(trimmed[schemeEnd.upperBound...])
134137
return .success(ParsedConnectionURL(
135-
type: .sqlite,
138+
type: dbType,
136139
host: "",
137140
port: nil,
138141
database: path,
@@ -205,6 +208,10 @@ struct ConnectionURLParser {
205208
}
206209
}
207210

211+
if scheme == "etcds" {
212+
sslMode = sslMode ?? .required
213+
}
214+
208215
// Oracle-specific: path component is the service name, not the database name
209216
var oracleServiceName: String?
210217
if dbType == .oracle && !database.isEmpty {

TablePro/Info.plist

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,13 @@
243243
<string>cql</string>
244244
<string>scylladb</string>
245245
<string>scylla</string>
246+
<string>oracle</string>
247+
<string>clickhouse</string>
248+
<string>ch</string>
249+
<string>etcd</string>
250+
<string>etcds</string>
251+
<string>d1</string>
252+
<string>libsql</string>
246253
</array>
247254
<key>CFBundleTypeRole</key>
248255
<string>Viewer</string>

TablePro/Views/Connection/ConnectionFormView+Footer.swift

Lines changed: 111 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,34 +86,70 @@ extension ConnectionFormView {
8686

8787
// MARK: - Import from URL Sheet
8888

89+
private var urlPlaceholder: String {
90+
let snapshot = PluginMetadataRegistry.shared.snapshot(forTypeId: type.pluginTypeId)
91+
let scheme = snapshot?.primaryUrlScheme ?? type.rawValue.lowercased()
92+
let mode = snapshot?.connectionMode ?? .network
93+
94+
switch mode {
95+
case .fileBased:
96+
return "\(scheme):///path/to/database"
97+
case .apiOnly:
98+
if type.pluginTypeId == "libSQL" {
99+
return "libsql://your-database.turso.io"
100+
}
101+
if type.pluginTypeId == "Cloudflare D1" {
102+
return "d1://account-id/database-name"
103+
}
104+
return "\(scheme)://host/database"
105+
case .network:
106+
let port = snapshot?.defaultPort ?? 0
107+
let portStr = port > 0 ? ":\(port)" : ""
108+
return "\(scheme)://user:password@host\(portStr)/database"
109+
}
110+
}
111+
112+
private var parsedPreview: ParsedConnectionURL? {
113+
let trimmed = connectionURL.trimmingCharacters(in: .whitespacesAndNewlines)
114+
guard !trimmed.isEmpty else { return nil }
115+
if case .success(let parsed) = ConnectionURLParser.parse(trimmed) {
116+
return parsed
117+
}
118+
return nil
119+
}
120+
89121
var connectionURLImportSheet: some View {
90122
VStack(spacing: 16) {
91-
Text(String(localized: "Paste a connection URL to auto-fill the form fields."))
123+
Text("Paste a connection URL to auto-fill the form fields.")
92124
.font(.subheadline)
93125
.foregroundStyle(.secondary)
94126

95127
TextField(
96128
String(localized: "Connection URL"),
97129
text: $connectionURL,
98-
prompt: Text("postgresql://user:password@host:5432/database")
130+
prompt: Text(urlPlaceholder)
99131
)
100132
.textFieldStyle(.roundedBorder)
101133

102134
if let urlParseError {
103135
Text(urlParseError)
104136
.font(.caption)
105137
.foregroundStyle(Color(nsColor: .systemRed))
138+
} else if let preview = parsedPreview {
139+
urlPreviewView(preview)
106140
}
107141

108142
HStack {
109-
Button(String(localized: "Cancel")) {
143+
Button("Cancel") {
144+
connectionURL = ""
145+
urlParseError = nil
110146
showURLImport = false
111147
}
112148
.keyboardShortcut(.cancelAction)
113149

114150
Spacer()
115151

116-
Button(String(localized: "Import")) {
152+
Button("Import") {
117153
parseConnectionURL()
118154
if urlParseError == nil && !connectionURL.isEmpty {
119155
connectionURL = ""
@@ -128,5 +164,76 @@ extension ConnectionFormView {
128164
.navigationTitle(String(localized: "Import from URL"))
129165
.padding(20)
130166
.frame(width: 420)
167+
.onAppear {
168+
if connectionURL.isEmpty,
169+
let clipString = NSPasteboard.general.string(forType: .string),
170+
let firstLine = clipString.components(separatedBy: .newlines).first,
171+
firstLine.contains("://")
172+
{
173+
connectionURL = firstLine.trimmingCharacters(in: .whitespacesAndNewlines)
174+
}
175+
}
176+
}
177+
178+
private func urlPreviewView(_ parsed: ParsedConnectionURL) -> some View {
179+
let snapshot = PluginMetadataRegistry.shared.snapshot(forTypeId: parsed.type.pluginTypeId)
180+
let mode = snapshot?.connectionMode ?? .network
181+
182+
return VStack(alignment: .leading, spacing: 4) {
183+
HStack(spacing: 6) {
184+
Image(parsed.type.iconName)
185+
.resizable()
186+
.frame(width: 16, height: 16)
187+
Text(snapshot?.displayName ?? parsed.type.rawValue)
188+
.font(.caption)
189+
.fontWeight(.medium)
190+
}
191+
192+
switch mode {
193+
case .fileBased:
194+
if !parsed.database.isEmpty {
195+
previewRow(String(localized: "Path"), parsed.database)
196+
}
197+
case .apiOnly:
198+
if !parsed.host.isEmpty {
199+
previewRow(String(localized: "Host"), parsed.host)
200+
}
201+
case .network:
202+
if !parsed.host.isEmpty {
203+
let portStr = parsed.port.map { ":\($0)" } ?? ""
204+
previewRow(String(localized: "Host"), parsed.host + portStr)
205+
}
206+
if !parsed.username.isEmpty {
207+
previewRow(String(localized: "User"), parsed.username)
208+
}
209+
if !parsed.database.isEmpty {
210+
previewRow(String(localized: "Database"), parsed.database)
211+
}
212+
if let svc = parsed.oracleServiceName, !svc.isEmpty {
213+
previewRow(String(localized: "Service"), svc)
214+
}
215+
if let sshHost = parsed.sshHost {
216+
previewRow("SSH", sshHost)
217+
}
218+
}
219+
}
220+
.padding(8)
221+
.frame(maxWidth: .infinity, alignment: .leading)
222+
.background(Color(nsColor: .controlBackgroundColor))
223+
.clipShape(RoundedRectangle(cornerRadius: 6))
224+
}
225+
226+
private func previewRow(_ label: String, _ value: String) -> some View {
227+
HStack(spacing: 4) {
228+
Text(label)
229+
.font(.caption2)
230+
.foregroundStyle(.tertiary)
231+
.frame(width: 58, alignment: .trailing)
232+
Text(value)
233+
.font(.caption)
234+
.foregroundStyle(.secondary)
235+
.lineLimit(1)
236+
.truncationMode(.middle)
237+
}
131238
}
132239
}

TablePro/Views/Connection/ConnectionFormView+Helpers.swift

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -570,8 +570,27 @@ extension ConnectionFormView {
570570
additionalFieldValues["mongoParam_\(key)"] = value
571571
}
572572
}
573-
if parsed.type.pluginTypeId == "Redis", !parsed.database.isEmpty {
574-
additionalFieldValues["redisDatabase"] = parsed.database
573+
if parsed.type.pluginTypeId == "Redis", let redisDb = parsed.redisDatabase {
574+
additionalFieldValues["redisDatabase"] = String(redisDb)
575+
}
576+
if let svcName = parsed.oracleServiceName, !svcName.isEmpty {
577+
additionalFieldValues["oracleServiceName"] = svcName
578+
}
579+
if let hex = parsed.statusColor, !hex.isEmpty {
580+
connectionColor = ConnectionURLParser.connectionColor(fromHex: hex)
581+
}
582+
if let env = parsed.envTag, !env.isEmpty {
583+
selectedTagId = ConnectionURLParser.tagId(fromEnvName: env)
584+
}
585+
if parsed.type.pluginTypeId == "libSQL", !parsed.host.isEmpty {
586+
var urlString = "https://\(parsed.host)"
587+
if let port = parsed.port {
588+
urlString += ":\(port)"
589+
}
590+
additionalFieldValues["databaseUrl"] = urlString
591+
}
592+
if parsed.type.pluginTypeId == "Cloudflare D1", !parsed.host.isEmpty {
593+
additionalFieldValues["cfAccountId"] = parsed.host
575594
}
576595
if let connectionName = parsed.connectionName, !connectionName.isEmpty {
577596
name = connectionName

TableProTests/Core/Utilities/ConnectionURLParserTests.swift

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -859,6 +859,62 @@ struct ConnectionURLParserTests {
859859
#expect(parsed.sshPort == 2222)
860860
}
861861

862+
// MARK: - DuckDB (file-based)
863+
864+
@Test("DuckDB absolute file path")
865+
func testDuckDBAbsolutePath() {
866+
let result = ConnectionURLParser.parse("duckdb:///Users/me/analytics.duckdb")
867+
guard case .success(let parsed) = result else {
868+
Issue.record("Expected success"); return
869+
}
870+
#expect(parsed.type == .duckdb)
871+
#expect(parsed.database == "/Users/me/analytics.duckdb")
872+
#expect(parsed.host == "")
873+
}
874+
875+
@Test("DuckDB relative file path")
876+
func testDuckDBRelativePath() {
877+
let result = ConnectionURLParser.parse("duckdb://data.duckdb")
878+
guard case .success(let parsed) = result else {
879+
Issue.record("Expected success"); return
880+
}
881+
#expect(parsed.type == .duckdb)
882+
#expect(parsed.database == "data.duckdb")
883+
}
884+
885+
// MARK: - etcds TLS
886+
887+
@Test("etcds scheme enables SSL")
888+
func testEtcdsSchemeEnablesSSL() {
889+
let result = ConnectionURLParser.parse("etcds://host:2379")
890+
guard case .success(let parsed) = result else {
891+
Issue.record("Expected success"); return
892+
}
893+
#expect(parsed.sslMode == .required)
894+
}
895+
896+
@Test("etcd scheme does not enable SSL")
897+
func testEtcdSchemeNoSSL() {
898+
let result = ConnectionURLParser.parse("etcd://host:2379")
899+
guard case .success(let parsed) = result else {
900+
Issue.record("Expected success"); return
901+
}
902+
#expect(parsed.sslMode == nil)
903+
}
904+
905+
// MARK: - Oracle service name
906+
907+
@Test("Oracle URL extracts service name")
908+
func testOracleServiceName() {
909+
let result = ConnectionURLParser.parse("oracle://user:pass@host:1521/ORCL")
910+
guard case .success(let parsed) = result else {
911+
Issue.record("Expected success"); return
912+
}
913+
#expect(parsed.type == .oracle)
914+
#expect(parsed.oracleServiceName == "ORCL")
915+
#expect(parsed.database == "")
916+
}
917+
862918
@Test("SSH URL with both usePrivateKey and useSSHAgent prefers last")
863919
func testSSHURLWithBothPrivateKeyAndAgent() {
864920
let result = ConnectionURLParser.parse(

docs/databases/cassandra.mdx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,13 @@ Click **New Connection**, select **Cassandra** or **ScyllaDB**, enter host/port/
2121
| **Username** | - | Disable auth for local dev |
2222
| **Password** | - | |
2323

24-
See [Connection URL Reference](/databases/connection-urls#cassandra--scylladb) for URL format.
24+
## Connection URL
25+
26+
```text
27+
cassandra://user:password@host:9042/keyspace
28+
```
29+
30+
See [Connection URL Reference](/databases/connection-urls#cassandra--scylladb) for all parameters.
2531

2632
## Example configurations
2733

docs/databases/clickhouse.mdx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,14 @@ Uses HTTP API (HTTPS on port 8443 with SSL/TLS enabled). Cloud providers typical
3131

3232
**SSL/TLS**: Enable in connection form for HTTPS (port 8443). Cloud requires HTTPS.
3333

34+
## Connection URL
35+
36+
```text
37+
clickhouse://user:password@host:8123/database
38+
```
39+
40+
See [Connection URL Reference](/databases/connection-urls) for all parameters.
41+
3442
## Features
3543

3644
### Database Browsing

0 commit comments

Comments
 (0)