From 27e9e7d6f3fc773566595fe7ba08812ba256ef7e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 25 Jul 2026 21:32:27 +0700 Subject: [PATCH 1/7] feat(connections): create a connection from a project folder (#1959) --- CHANGELOG.md | 4 + TablePro.xcodeproj/project.pbxproj | 17 + .../xcshareddata/swiftpm/Package.resolved | 11 +- TablePro/Core/Events/AppCommands.swift | 1 + .../AppSettingsJsonExtractor.swift | 114 +++++++ .../DockerComposeExtractor.swift | 225 +++++++++++++ ...tenvConnectionExtractor+DiscreteKeys.swift | 190 +++++++++++ .../DotenvConnectionExtractor.swift | 123 ++++++++ .../DotenvParser+Interpolation.swift | 141 +++++++++ .../DotenvParser+ValueDecoding.swift | 72 +++++ .../Services/ProjectImport/DotenvParser.swift | 167 ++++++++++ .../ParsedConnectionURL+Scanned.swift | 49 +++ .../ProjectImport/PrismaSchemaExtractor.swift | 178 +++++++++++ .../ProjectConfigFileMatcher.swift | 150 +++++++++ .../ProjectFolderFileWalker.swift | 138 ++++++++ .../ProjectImport/ProjectFolderScanner.swift | 149 +++++++++ .../ProjectScanEnvironmentIndex.swift | 53 ++++ .../RailsDatabaseYamlExtractor.swift | 183 +++++++++++ .../ScannedConnectionCandidate.swift | 79 +++++ .../ScannedConnectionFields.swift | 62 ++++ .../ScannedConnectionRanking.swift | 42 +++ .../ScannedConnectionURLBuilder.swift | 96 ++++++ .../ScannedProductionHeuristic.swift | 19 ++ .../ProjectImport/ScannedURLNormalizer.swift | 48 +++ .../SpringPropertiesExtractor.swift | 109 +++++++ .../ProjectImport/SpringYamlExtractor.swift | 62 ++++ .../WordPressConfigExtractor.swift | 81 +++++ .../ProjectImport/YamlMappingSupport.swift | 98 ++++++ TablePro/Info.plist | 6 + TablePro/TableProApp.swift | 4 + .../WelcomeViewModel+ProjectFolder.swift | 59 ++++ TablePro/ViewModels/WelcomeViewModel.swift | 5 + .../ProjectFolderCandidateList.swift | 59 ++++ .../ProjectFolderScanSheet.swift | 146 +++++++++ .../Connection/WelcomeActionsPanel.swift | 8 + .../Views/Connection/WelcomeWindowView.swift | 13 + .../Components/ImportFromURLSheet.swift | 66 +--- .../ParsedConnectionSummaryView.swift | 93 ++++++ .../DotenvConnectionExtractorTests.swift | 171 ++++++++++ .../ProjectImport/DotenvParserTests.swift | 172 ++++++++++ .../ProjectConfigExtractorTests.swift | 282 +++++++++++++++++ .../ProjectConfigFileMatcherTests.swift | 71 +++++ .../ProjectFolderFileWalkerTests.swift | 106 +++++++ .../ProjectFolderScannerTests.swift | 149 +++++++++ .../ProjectYamlExtractorTests.swift | 297 ++++++++++++++++++ docs/databases/connection-urls.mdx | 2 + docs/docs.json | 3 +- docs/features/project-folder-import.mdx | 58 ++++ 48 files changed, 4364 insertions(+), 67 deletions(-) create mode 100644 TablePro/Core/Services/ProjectImport/AppSettingsJsonExtractor.swift create mode 100644 TablePro/Core/Services/ProjectImport/DockerComposeExtractor.swift create mode 100644 TablePro/Core/Services/ProjectImport/DotenvConnectionExtractor+DiscreteKeys.swift create mode 100644 TablePro/Core/Services/ProjectImport/DotenvConnectionExtractor.swift create mode 100644 TablePro/Core/Services/ProjectImport/DotenvParser+Interpolation.swift create mode 100644 TablePro/Core/Services/ProjectImport/DotenvParser+ValueDecoding.swift create mode 100644 TablePro/Core/Services/ProjectImport/DotenvParser.swift create mode 100644 TablePro/Core/Services/ProjectImport/ParsedConnectionURL+Scanned.swift create mode 100644 TablePro/Core/Services/ProjectImport/PrismaSchemaExtractor.swift create mode 100644 TablePro/Core/Services/ProjectImport/ProjectConfigFileMatcher.swift create mode 100644 TablePro/Core/Services/ProjectImport/ProjectFolderFileWalker.swift create mode 100644 TablePro/Core/Services/ProjectImport/ProjectFolderScanner.swift create mode 100644 TablePro/Core/Services/ProjectImport/ProjectScanEnvironmentIndex.swift create mode 100644 TablePro/Core/Services/ProjectImport/RailsDatabaseYamlExtractor.swift create mode 100644 TablePro/Core/Services/ProjectImport/ScannedConnectionCandidate.swift create mode 100644 TablePro/Core/Services/ProjectImport/ScannedConnectionFields.swift create mode 100644 TablePro/Core/Services/ProjectImport/ScannedConnectionRanking.swift create mode 100644 TablePro/Core/Services/ProjectImport/ScannedConnectionURLBuilder.swift create mode 100644 TablePro/Core/Services/ProjectImport/ScannedProductionHeuristic.swift create mode 100644 TablePro/Core/Services/ProjectImport/ScannedURLNormalizer.swift create mode 100644 TablePro/Core/Services/ProjectImport/SpringPropertiesExtractor.swift create mode 100644 TablePro/Core/Services/ProjectImport/SpringYamlExtractor.swift create mode 100644 TablePro/Core/Services/ProjectImport/WordPressConfigExtractor.swift create mode 100644 TablePro/Core/Services/ProjectImport/YamlMappingSupport.swift create mode 100644 TablePro/ViewModels/WelcomeViewModel+ProjectFolder.swift create mode 100644 TablePro/Views/Connection/ProjectFolder/ProjectFolderCandidateList.swift create mode 100644 TablePro/Views/Connection/ProjectFolder/ProjectFolderScanSheet.swift create mode 100644 TablePro/Views/ConnectionForm/Components/ParsedConnectionSummaryView.swift create mode 100644 TableProTests/Core/Services/ProjectImport/DotenvConnectionExtractorTests.swift create mode 100644 TableProTests/Core/Services/ProjectImport/DotenvParserTests.swift create mode 100644 TableProTests/Core/Services/ProjectImport/ProjectConfigExtractorTests.swift create mode 100644 TableProTests/Core/Services/ProjectImport/ProjectConfigFileMatcherTests.swift create mode 100644 TableProTests/Core/Services/ProjectImport/ProjectFolderFileWalkerTests.swift create mode 100644 TableProTests/Core/Services/ProjectImport/ProjectFolderScannerTests.swift create mode 100644 TableProTests/Core/Services/ProjectImport/ProjectYamlExtractorTests.swift create mode 100644 docs/features/project-folder-import.mdx diff --git a/CHANGELOG.md b/CHANGELOG.md index 853e2cc97..bc6419b5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Create a connection from a project folder. Pick one from the welcome screen or File > Open Project Folder..., and TablePro reads the database settings it finds in `.env` files, `wp-config.php`, `prisma/schema.prisma`, `config/database.yml`, `docker-compose.yml`, `application.properties`, `application.yml`, and `appsettings.json`. Review what it found, pick one, and the connection form opens filled in. Nothing is saved or connected until you save it. (#1959) + ### Fixed - The AI chat panel stays inside the right panel when you drag it narrow. The model name, tool names, and code block headers now truncate, long chat messages and code wrap, wide tables scroll inside their own box, and the composer text follows the panel width instead of running under the editor. (#1956) diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index a85e63bcd..262341b00 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -86,6 +86,7 @@ 5AEA8B472F6808CA0040461A /* EtcdHttpClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AEA8B3C2F6808CA0040461A /* EtcdHttpClient.swift */; }; 5AEA8B492F6808E90040461A /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5AEE5B362F5C9B7B00FA84D7 /* OracleNIO in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F00000000000F /* OracleNIO */; }; + 5AYAMS00000000000000A003 /* Yams in Frameworks */ = {isa = PBXBuildFile; productRef = 5AYAMS00000000000000A002 /* Yams */; }; 5E1A500100000000000000A1 /* ElasticsearchConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E1A500200000000000000A1 /* ElasticsearchConnection.swift */; }; 5E1A500100000000000000A2 /* ElasticsearchConsoleParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E1A500200000000000000A2 /* ElasticsearchConsoleParser.swift */; }; 5E1A500100000000000000A3 /* ElasticsearchMappingFlattener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E1A500200000000000000A3 /* ElasticsearchMappingFlattener.swift */; }; @@ -955,6 +956,7 @@ 5A32BBFB2F9D5EAB00BAEB5F /* X509 in Frameworks */, 5ACE00012F4F000000000006 /* CodeEditTextView in Frameworks */, 5ACE00012F4F00000000000A /* Sparkle in Frameworks */, + 5AYAMS00000000000000A003 /* Yams in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1480,6 +1482,7 @@ 5ACE00012F4F000000000010 /* TableProAnalytics */, 5A32BBFA2F9D5EAB00BAEB5F /* X509 */, 5A1MPORT000000000000A010 /* TableProImport */, + 5AYAMS00000000000000A002 /* Yams */, ); productName = TablePro; productReference = 5A1091C72EF17EDC0055EA7C /* TablePro.app */; @@ -2271,6 +2274,7 @@ 5ACE00012F4F00000000000E /* XCRemoteSwiftPackageReference "oracle-nio" */, 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "Packages/TableProCore" */, 5A32BBF92F9D5EAB00BAEB5F /* XCRemoteSwiftPackageReference "swift-certificates" */, + 5AYAMS00000000000000A001 /* XCRemoteSwiftPackageReference "Yams" */, ); preferredProjectObjectVersion = 77; productRefGroup = 5A1091C82EF17EDC0055EA7C /* Products */; @@ -5464,6 +5468,14 @@ revision = 23c77812ed90259243a761d7fdea19db47d3ca41; }; }; + 5AYAMS00000000000000A001 /* XCRemoteSwiftPackageReference "Yams" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/jpsim/Yams"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 5.1.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -5508,6 +5520,11 @@ package = 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "Packages/TableProCore" */; productName = TableProMSSQLCore; }; + 5AYAMS00000000000000A002 /* Yams */ = { + isa = XCSwiftPackageProductDependency; + package = 5AYAMS00000000000000A001 /* XCRemoteSwiftPackageReference "Yams" */; + productName = Yams; + }; AC1D0000000000000000F002 /* TableProTrinoCore */ = { isa = XCSwiftPackageProductDependency; package = 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "Packages/TableProCore" */; diff --git a/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3b2a56f63..3a040924b 100644 --- a/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "e3a3828307ccec4f2ef033715de2df6c1d530d8772905d223ccdb8d7525b77ad", + "originHash" : "d2a64118b7173c5448dbdddd34fc090bd8c35bf8ef198e84a0d2a409f05bd756", "pins" : [ { "identity" : "bigint", @@ -215,6 +215,15 @@ "revision" : "da6fe9beb4f7f67beb75914ca8e0d48ae48d6406", "version" : "0.25.10" } + }, + { + "identity" : "yams", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jpsim/Yams", + "state" : { + "revision" : "3d6871d5b4a5cd519adf233fbb576e0a2af71c17", + "version" : "5.4.0" + } } ], "version" : 3 diff --git a/TablePro/Core/Events/AppCommands.swift b/TablePro/Core/Events/AppCommands.swift index dd5f21add..06a2f2a26 100644 --- a/TablePro/Core/Events/AppCommands.swift +++ b/TablePro/Core/Events/AppCommands.swift @@ -21,6 +21,7 @@ final class AppCommands { let exportConnections = PassthroughSubject() let importConnections = PassthroughSubject() let importConnectionsFromApp = PassthroughSubject() + let openProjectFolder = PassthroughSubject() let exportQueryResults = PassthroughSubject() // MARK: - Window / Sheet Commands diff --git a/TablePro/Core/Services/ProjectImport/AppSettingsJsonExtractor.swift b/TablePro/Core/Services/ProjectImport/AppSettingsJsonExtractor.swift new file mode 100644 index 000000000..0329372f0 --- /dev/null +++ b/TablePro/Core/Services/ProjectImport/AppSettingsJsonExtractor.swift @@ -0,0 +1,114 @@ +// +// AppSettingsJsonExtractor.swift +// TablePro +// + +import Foundation + +enum AppSettingsJsonExtractor { + static func extract(data: Data, relativePath: String) -> [ScannedConnectionCandidate] { + guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return [] + } + guard let strings = connectionStrings(in: root) else { + return [] + } + return strings.sorted { $0.key < $1.key }.compactMap { name, value in + candidate(name: name, connectionString: value, relativePath: relativePath) + } + } + + private static func connectionStrings(in root: [String: Any]) -> [String: String]? { + for (key, value) in root where key.lowercased() == "connectionstrings" { + guard let nested = value as? [String: Any] else { + continue + } + return nested.compactMapValues { $0 as? String } + } + return nil + } + + static func candidate( + name: String, + connectionString: String, + relativePath: String + ) -> ScannedConnectionCandidate? { + let keywords = parseKeywords(connectionString) + guard let type = provider(for: keywords) else { + return nil + } + var fields = ScannedConnectionFields(type: type) + fields.host = first(keywords, ["host", "server", "data source", "address", "addr", "network address"]) ?? "" + fields.database = first(keywords, ["database", "initial catalog"]) ?? "" + fields.username = first(keywords, ["username", "user id", "uid", "user"]) ?? "" + fields.password = first(keywords, ["password", "pwd"]) ?? "" + fields.connectionName = name + let address = splitHostPort(fields.host, type: type) + fields.host = address.host + fields.port = first(keywords, ["port"]).flatMap { Int($0) } ?? address.port ?? type.defaultPort + return ScannedConnectionCandidate( + parsedURL: fields.toParsedConnectionURL(), + sourceRelativePath: relativePath, + sourceKey: "ConnectionStrings:\(name)", + kind: .appSettingsJson, + tier: .configFile + ) + } + + static func parseKeywords(_ connectionString: String) -> [String: String] { + var keywords: [String: String] = [:] + for segment in connectionString.split(separator: ";") { + guard let equals = segment.firstIndex(of: "=") else { + continue + } + let key = String(segment[segment.startIndex.. DatabaseType? { + if keywords["host"] != nil { + return .postgresql + } + if keywords["initial catalog"] != nil { + return .mssql + } + let sqlServerHints = ["trusted_connection", "encrypt", "trustservercertificate", "integrated security"] + if sqlServerHints.contains(where: { keywords[$0] != nil }) { + return .mssql + } + if keywords["uid"] != nil || keywords["pwd"] != nil { + return .mysql + } + return nil + } + + private static func first(_ keywords: [String: String], _ names: [String]) -> String? { + for name in names { + if let value = keywords[name] { + return value + } + } + return nil + } + + private static func splitHostPort(_ value: String, type: DatabaseType) -> (host: String, port: Int?) { + let separator: Character = type == .mssql ? "," : ":" + guard let index = value.firstIndex(of: separator) else { + return (value, nil) + } + let head = String(value[value.startIndex.. [ScannedConnectionCandidate] { + guard let parsed = YamlMappingSupport.loadMapping(contents), + let root = ComposeInterpolator.interpolate(parsed, environment: environment) as? [String: Any], + let services = YamlMappingSupport.mapping(root["services"]) else { + return [] + } + return services.keys.sorted().compactMap { name in + guard let service = YamlMappingSupport.mapping(services[name]) else { + return nil + } + return candidate(name: name, service: service, relativePath: relativePath) + } + } + + static func candidate( + name: String, + service: [String: Any], + relativePath: String + ) -> ScannedConnectionCandidate? { + guard let image = YamlMappingSupport.string(service["image"]), + let database = databaseKind(for: image) else { + return nil + } + let variables = environmentVariables(service["environment"]) + var fields = ScannedConnectionFields(type: database.type) + fields.host = "127.0.0.1" + fields.connectionName = name + applyCredentials(&fields, type: database.type, variables: variables) + var warnings: [String] = [] + if let published = publishedPort(service["ports"], containerPort: database.defaultPort) { + fields.port = published + } else { + fields.port = database.defaultPort + warnings.append(String( + localized: "This service does not publish a port, so it may not be reachable from your Mac." + )) + } + return ScannedConnectionCandidate( + parsedURL: fields.toParsedConnectionURL(), + sourceRelativePath: relativePath, + sourceKey: "services.\(name)", + kind: .dockerCompose, + tier: .configFile, + warnings: warnings + ) + } + + static func databaseKind(for image: String) -> ServiceDatabase? { + let name = image.lowercased() + if name.contains("postgres"), !name.contains("postgrest") { + return ServiceDatabase(type: .postgresql, defaultPort: 5_432) + } + if name.contains("mariadb") { + return ServiceDatabase(type: .mariadb, defaultPort: 3_306) + } + if name.contains("mysql") { + return ServiceDatabase(type: .mysql, defaultPort: 3_306) + } + if name.contains("mongo") { + return ServiceDatabase(type: .mongodb, defaultPort: 27_017) + } + if name.contains("redis") { + return ServiceDatabase(type: .redis, defaultPort: 6_379) + } + if name.contains("clickhouse") { + return ServiceDatabase(type: .clickhouse, defaultPort: 8_123) + } + if name.contains("mssql") || name.contains("sql-server") || name.contains("sqlserver") { + return ServiceDatabase(type: .mssql, defaultPort: 1_433) + } + return nil + } + + static func environmentVariables(_ value: Any?) -> [String: String] { + if let mapping = value as? [String: Any] { + return mapping.compactMapValues { YamlMappingSupport.string($0) } + } + guard let list = value as? [Any] else { + return [:] + } + var variables: [String: String] = [:] + for item in list { + guard let text = YamlMappingSupport.string(item), let equals = text.firstIndex(of: "=") else { + continue + } + let key = String(text[text.startIndex.. Int? { + guard let list = value as? [Any] else { + return nil + } + for item in list { + if let mapping = YamlMappingSupport.mapping(item) { + guard YamlMappingSupport.int(mapping["target"]) == containerPort else { + continue + } + if let published = YamlMappingSupport.int(mapping["published"]) { + return published + } + continue + } + guard let text = YamlMappingSupport.string(item) else { + continue + } + let segments = text.split(separator: ":").map(String.init) + guard segments.count >= 2, Int(segments[segments.count - 1]) == containerPort else { + continue + } + if let published = Int(segments[segments.count - 2]) { + return published + } + } + return nil + } + + private static func applyCredentials( + _ fields: inout ScannedConnectionFields, + type: DatabaseType, + variables: [String: String] + ) { + switch type { + case .postgresql: + fields.username = variables["POSTGRES_USER"] ?? "postgres" + fields.password = variables["POSTGRES_PASSWORD"] ?? "" + fields.database = variables["POSTGRES_DB"] ?? fields.username + case .mariadb, .mysql: + let prefix = variables["MARIADB_PASSWORD"] != nil || variables["MARIADB_DATABASE"] != nil + ? "MARIADB" + : "MYSQL" + fields.username = variables["\(prefix)_USER"] ?? "root" + fields.password = variables["\(prefix)_PASSWORD"] ?? variables["\(prefix)_ROOT_PASSWORD"] ?? "" + fields.database = variables["\(prefix)_DATABASE"] ?? "" + case .mongodb: + fields.username = variables["MONGO_INITDB_ROOT_USERNAME"] ?? "" + fields.password = variables["MONGO_INITDB_ROOT_PASSWORD"] ?? "" + fields.database = variables["MONGO_INITDB_DATABASE"] ?? "" + case .clickhouse: + fields.username = variables["CLICKHOUSE_USER"] ?? "default" + fields.password = variables["CLICKHOUSE_PASSWORD"] ?? "" + fields.database = variables["CLICKHOUSE_DB"] ?? "" + case .mssql: + fields.username = "sa" + fields.password = variables["MSSQL_SA_PASSWORD"] ?? variables["SA_PASSWORD"] ?? "" + default: + fields.password = variables["REDIS_PASSWORD"] ?? "" + } + } +} + +enum ComposeInterpolator { + static func interpolate(_ value: Any, environment: DotenvDocument?) -> Any { + if let text = value as? String { + return interpolate(text: text, environment: environment) + } + if let mapping = value as? [String: Any] { + return mapping.mapValues { interpolate($0, environment: environment) } + } + if let list = value as? [Any] { + return list.map { interpolate($0, environment: environment) } + } + return value + } + + static func interpolate(text contents: String, environment: DotenvDocument?) -> String { + guard contents.contains("$") else { + return contents + } + var result = "" + var remainder = Substring(contents) + while let open = remainder.range(of: "${") { + result += remainder[remainder.startIndex.. String { + let separators = [":-", ":?", "-", "?"] + for separator in separators { + guard let range = reference.range(of: separator) else { + continue + } + let name = String(reference[reference.startIndex.. ScannedConnectionCandidate? { + guard let connection = context.document["DB_CONNECTION"]?.lowercased(), + let type = laravelType(connection) else { + return nil + } + var fields = ScannedConnectionFields(type: type) + guard type != .sqlite else { + fields.database = resolveSQLitePath( + context.document["DB_DATABASE"], + directoryURL: context.directoryURL + ) + return candidate(context, fields: fields, key: "DB_CONNECTION") + } + fields.host = context.document["DB_HOST"] ?? "127.0.0.1" + fields.port = context.document["DB_PORT"].flatMap { Int($0) } ?? type.defaultPort + fields.database = context.document["DB_DATABASE"] ?? "" + fields.username = context.document["DB_USERNAME"] ?? "" + fields.password = context.document["DB_PASSWORD"] ?? "" + return candidate(context, fields: fields, key: "DB_CONNECTION", warnings: serviceHostWarnings(fields.host)) + } + + static func dockerRelationalCandidate(_ context: Context) -> ScannedConnectionCandidate? { + if let postgres = dockerPostgresCandidate(context) { + return postgres + } + if let mysql = dockerMySQLCandidate(context) { + return mysql + } + return dockerMSSQLCandidate(context) + } + + static func libpqCandidate(_ context: Context) -> ScannedConnectionCandidate? { + let document = context.document + guard document["PGHOST"] != nil || document["PGDATABASE"] != nil else { + return nil + } + var fields = ScannedConnectionFields(type: .postgresql) + fields.host = document["PGHOST"] ?? "127.0.0.1" + fields.port = document["PGPORT"].flatMap { Int($0) } ?? 5_432 + fields.database = document["PGDATABASE"] ?? "" + fields.username = document["PGUSER"] ?? "" + fields.password = document["PGPASSWORD"] ?? "" + return candidate(context, fields: fields, key: "PGHOST", warnings: serviceHostWarnings(fields.host)) + } + + static func redisDiscreteCandidate(_ context: Context) -> ScannedConnectionCandidate? { + let document = context.document + guard document["REDIS_HOST"] != nil || document["REDIS_PASSWORD"] != nil else { + return nil + } + var fields = ScannedConnectionFields(type: .redis) + fields.host = document["REDIS_HOST"] ?? "127.0.0.1" + fields.port = document["REDIS_PORT"].flatMap { Int($0) } ?? 6_379 + fields.username = document["REDIS_USERNAME"] ?? "" + fields.password = document["REDIS_PASSWORD"] ?? "" + fields.redisDatabase = document["REDIS_DB"].flatMap { Int($0) } + return candidate(context, fields: fields, key: "REDIS_HOST", warnings: serviceHostWarnings(fields.host)) + } + + static func mongoDiscreteCandidate(_ context: Context) -> ScannedConnectionCandidate? { + let document = context.document + let username = document["MONGO_INITDB_ROOT_USERNAME"] ?? document["MONGODB_INITDB_ROOT_USERNAME"] + let password = document["MONGO_INITDB_ROOT_PASSWORD"] ?? document["MONGODB_INITDB_ROOT_PASSWORD"] + guard username != nil || password != nil else { + return nil + } + var fields = ScannedConnectionFields(type: .mongodb) + fields.host = document["MONGO_HOST"] ?? "127.0.0.1" + fields.port = document["MONGO_PORT"].flatMap { Int($0) } ?? 27_017 + fields.database = document["MONGO_INITDB_DATABASE"] ?? document["MONGODB_INITDB_DATABASE"] ?? "" + fields.username = username ?? "" + fields.password = password ?? "" + return candidate( + context, + fields: fields, + key: "MONGO_INITDB_ROOT_USERNAME", + warnings: serviceHostWarnings(fields.host) + ) + } + + private static func dockerPostgresCandidate(_ context: Context) -> ScannedConnectionCandidate? { + let document = context.document + guard document["POSTGRES_PASSWORD"] != nil + || document["POSTGRES_DB"] != nil + || document["POSTGRES_USER"] != nil else { + return nil + } + var fields = ScannedConnectionFields(type: .postgresql) + fields.host = document["POSTGRES_HOST"] ?? "127.0.0.1" + fields.port = document["POSTGRES_PORT"].flatMap { Int($0) } ?? 5_432 + fields.username = document["POSTGRES_USER"] ?? "postgres" + fields.password = document["POSTGRES_PASSWORD"] ?? "" + fields.database = document["POSTGRES_DB"] ?? fields.username + var warnings = serviceHostWarnings(fields.host) + if document["POSTGRES_HOST"] == nil || document["POSTGRES_PORT"] == nil { + warnings.append(assumedAddressWarning) + } + return candidate(context, fields: fields, key: "POSTGRES_PASSWORD", warnings: warnings) + } + + private static func dockerMySQLCandidate(_ context: Context) -> ScannedConnectionCandidate? { + let document = context.document + let usesMariaDB = document["MARIADB_DATABASE"] != nil + || document["MARIADB_USER"] != nil + || document["MARIADB_PASSWORD"] != nil + || document["MARIADB_ROOT_PASSWORD"] != nil + let prefix = usesMariaDB ? "MARIADB" : "MYSQL" + guard document["\(prefix)_DATABASE"] != nil + || document["\(prefix)_USER"] != nil + || document["\(prefix)_PASSWORD"] != nil + || document["\(prefix)_ROOT_PASSWORD"] != nil else { + return nil + } + var fields = ScannedConnectionFields(type: usesMariaDB ? .mariadb : .mysql) + fields.host = document["MYSQL_HOST"] ?? "127.0.0.1" + fields.port = document["MYSQL_TCP_PORT"].flatMap { Int($0) } ?? 3_306 + fields.database = document["\(prefix)_DATABASE"] ?? "" + fields.username = document["\(prefix)_USER"] ?? "root" + fields.password = document["\(prefix)_PASSWORD"] ?? document["\(prefix)_ROOT_PASSWORD"] ?? "" + var warnings = serviceHostWarnings(fields.host) + if document["MYSQL_HOST"] == nil { + warnings.append(assumedAddressWarning) + } + return candidate(context, fields: fields, key: "\(prefix)_PASSWORD", warnings: warnings) + } + + private static func dockerMSSQLCandidate(_ context: Context) -> ScannedConnectionCandidate? { + let document = context.document + guard let password = document["MSSQL_SA_PASSWORD"] ?? document["SA_PASSWORD"] else { + return nil + } + var fields = ScannedConnectionFields(type: .mssql) + fields.host = "127.0.0.1" + fields.port = document["MSSQL_TCP_PORT"].flatMap { Int($0) } ?? 1_433 + fields.database = document["MSSQL_DB"] ?? "" + fields.username = document["MSSQL_USER"] ?? "sa" + fields.password = password + return candidate(context, fields: fields, key: "MSSQL_SA_PASSWORD", warnings: [assumedAddressWarning]) + } + + private static func laravelType(_ connection: String) -> DatabaseType? { + switch connection { + case "sqlite": + return .sqlite + case "mysql": + return .mysql + case "mariadb": + return .mariadb + case "pgsql": + return .postgresql + case "sqlsrv": + return .mssql + default: + return nil + } + } + + static func resolveSQLitePath(_ value: String?, directoryURL: URL) -> String { + guard let value, !value.isEmpty else { + return directoryURL.appendingPathComponent("database/database.sqlite").path + } + guard value != ":memory:" else { + return value + } + guard !value.hasPrefix("/") else { + return value + } + return directoryURL.appendingPathComponent(value).standardizedFileURL.path + } + + private static var assumedAddressWarning: String { + String(localized: "Host and port were assumed. The published address is set where the container runs.") + } + + private static func serviceHostWarnings(_ host: String) -> [String] { + let serviceNames: Set = ["db", "database", "postgres", "postgresql", "mysql", "mariadb", "mongo", "mongodb", "redis", "mssql", "pgsql"] + guard serviceNames.contains(host.lowercased()) else { + return [] + } + return [String(localized: "This looks like a container service name, which is usually not reachable from your Mac.")] + } +} diff --git a/TablePro/Core/Services/ProjectImport/DotenvConnectionExtractor.swift b/TablePro/Core/Services/ProjectImport/DotenvConnectionExtractor.swift new file mode 100644 index 000000000..840ccce75 --- /dev/null +++ b/TablePro/Core/Services/ProjectImport/DotenvConnectionExtractor.swift @@ -0,0 +1,123 @@ +// +// DotenvConnectionExtractor.swift +// TablePro +// + +import Foundation + +enum DotenvConnectionExtractor { + struct Context { + let document: DotenvDocument + let relativePath: String + let tier: ScannedConnectionTier + let directoryURL: URL + } + + private static let relationalURLKeys = [ + "DATABASE_URL_UNPOOLED", + "POSTGRES_URL_NON_POOLING", + "DATABASE_PUBLIC_URL", + "DATABASE_URL", + "DIRECT_URL", + "DIRECT_DATABASE_URL", + "POSTGRES_URL", + "POSTGRES_PRISMA_URL", + "SUPABASE_DB_URL", + "MYSQL_PUBLIC_URL", + "MYSQL_URL", + "JAWSDB_MARIA_URL", + "JAWSDB_URL", + "CLEARDB_DATABASE_URL", + "JDBC_DATABASE_URL", + "SPRING_DATASOURCE_URL", + "DB_URL", + ] + + private static let mongoURLKeys = ["MONGODB_URI", "MONGO_URL", "MONGODB_URL", "MONGO_URI"] + private static let redisURLKeys = ["REDIS_URL", "REDIS_TLS_URL"] + private static let tursoURLKeys = ["TURSO_DATABASE_URL", "TURSO_CONNECTION_URL", "TURSO_URL"] + + static func extract( + document: DotenvDocument, + relativePath: String, + tier: ScannedConnectionTier, + directoryURL: URL + ) -> [ScannedConnectionCandidate] { + let context = Context( + document: document, + relativePath: relativePath, + tier: tier, + directoryURL: directoryURL + ) + var candidates: [ScannedConnectionCandidate] = [] + if let relational = relationalCandidate(context) { + candidates.append(relational) + } + if let mongo = mongoCandidate(context) { + candidates.append(mongo) + } + if let redis = redisCandidate(context) { + candidates.append(redis) + } + if let turso = firstURLCandidate(context, keys: tursoURLKeys) { + candidates.append(turso) + } + return candidates + } + + static func firstURLCandidate(_ context: Context, keys: [String]) -> ScannedConnectionCandidate? { + for key in keys { + guard let value = context.document[key] else { + continue + } + let candidate = ScannedConnectionURLBuilder.candidate( + fromURL: value, + key: key, + relativePath: context.relativePath, + kind: .dotenv, + tier: context.tier + ) + if let candidate { + return candidate + } + } + return nil + } + + private static func relationalCandidate(_ context: Context) -> ScannedConnectionCandidate? { + if let fromURL = firstURLCandidate(context, keys: relationalURLKeys) { + return fromURL + } + if let laravel = laravelCandidate(context) { + return laravel + } + if let docker = dockerRelationalCandidate(context) { + return docker + } + return libpqCandidate(context) + } + + private static func mongoCandidate(_ context: Context) -> ScannedConnectionCandidate? { + firstURLCandidate(context, keys: mongoURLKeys) ?? mongoDiscreteCandidate(context) + } + + private static func redisCandidate(_ context: Context) -> ScannedConnectionCandidate? { + firstURLCandidate(context, keys: redisURLKeys) ?? redisDiscreteCandidate(context) + } + + static func candidate( + _ context: Context, + fields: ScannedConnectionFields, + key: String, + warnings: [String] = [] + ) -> ScannedConnectionCandidate { + ScannedConnectionCandidate( + parsedURL: fields.toParsedConnectionURL(), + sourceRelativePath: context.relativePath, + sourceKey: key, + kind: .dotenv, + tier: context.tier, + warnings: warnings + ) + } +} diff --git a/TablePro/Core/Services/ProjectImport/DotenvParser+Interpolation.swift b/TablePro/Core/Services/ProjectImport/DotenvParser+Interpolation.swift new file mode 100644 index 000000000..20e8ca80b --- /dev/null +++ b/TablePro/Core/Services/ProjectImport/DotenvParser+Interpolation.swift @@ -0,0 +1,141 @@ +// +// DotenvParser+Interpolation.swift +// TablePro +// + +import Foundation + +private enum DotenvReference { + case valid(name: String, defaultValue: String?, length: Int) + case malformed(length: Int) +} + +extension DotenvParser { + static func resolve( + _ assignment: DotenvAssignment, + document: DotenvDocument, + processEnvironment: [String: String] + ) -> DotenvEntry { + guard assignment.quoting != .single else { + return DotenvEntry( + key: assignment.key, + value: assignment.rawValue, + isSingleQuoted: true, + hasUnresolvedReference: false + ) + } + let characters = Array(assignment.rawValue) + var value = "" + var unresolved = false + var index = 0 + while index < characters.count { + guard let reference = readReference(characters, at: index) else { + value.append(characters[index]) + index += 1 + continue + } + switch reference { + case .malformed(let length): + value.append(literal(characters, from: index, length: length)) + index += length + unresolved = true + case .valid(let name, let defaultValue, let length): + if let resolved = lookUp(name, document: document, processEnvironment: processEnvironment) { + value.append(resolved) + } else if let defaultValue { + value.append(defaultValue) + } else { + value.append(literal(characters, from: index, length: length)) + unresolved = true + } + index += length + } + } + return DotenvEntry( + key: assignment.key, + value: value, + isSingleQuoted: false, + hasUnresolvedReference: unresolved + ) + } + + private static func literal(_ characters: [Character], from start: Int, length: Int) -> String { + String(characters[start.. String? { + if let entry = document.entry(for: name), !entry.hasUnresolvedReference { + return entry.value + } + return processEnvironment[name] + } + + private static func readReference(_ characters: [Character], at start: Int) -> DotenvReference? { + guard characters[start] == "$", start + 1 < characters.count else { + return nil + } + if characters[start + 1] == "{" { + return readBracedReference(characters, at: start) + } + return readBareReference(characters, at: start) + } + + private static func readBracedReference(_ characters: [Character], at start: Int) -> DotenvReference { + var index = start + 2 + var name = "" + while index < characters.count, characters[index] != "}", characters[index] != ":" { + name.append(characters[index]) + index += 1 + } + var defaultValue: String? + if index < characters.count, characters[index] == ":" { + var cursor = index + 1 + if cursor < characters.count, characters[cursor] == "-" { + cursor += 1 + } + var fallback = "" + while cursor < characters.count, characters[cursor] != "}" { + fallback.append(characters[cursor]) + cursor += 1 + } + defaultValue = fallback + index = cursor + } + guard index < characters.count, characters[index] == "}", isValidReferenceName(name) else { + let consumed = min(index + 1, characters.count) - start + return .malformed(length: max(consumed, 2)) + } + return .valid(name: name, defaultValue: defaultValue, length: index + 1 - start) + } + + private static func readBareReference(_ characters: [Character], at start: Int) -> DotenvReference? { + var index = start + 1 + var name = "" + while index < characters.count { + let character = characters[index] + guard character.isLetter || character.isNumber || character == "_" else { + break + } + if name.isEmpty, character.isNumber { + break + } + name.append(character) + index += 1 + } + guard !name.isEmpty else { + return nil + } + return .valid(name: name, defaultValue: nil, length: index - start) + } + + private static func isValidReferenceName(_ name: String) -> Bool { + guard !name.isEmpty else { + return false + } + return name.allSatisfy { $0.isLetter || $0.isNumber || $0 == "_" } + } +} diff --git a/TablePro/Core/Services/ProjectImport/DotenvParser+ValueDecoding.swift b/TablePro/Core/Services/ProjectImport/DotenvParser+ValueDecoding.swift new file mode 100644 index 000000000..0c7a1d1a1 --- /dev/null +++ b/TablePro/Core/Services/ProjectImport/DotenvParser+ValueDecoding.swift @@ -0,0 +1,72 @@ +// +// DotenvParser+ValueDecoding.swift +// TablePro +// + +import Foundation + +extension DotenvParser { + static func readQuoted( + _ characters: [Character], + _ index: inout Int, + delimiter: Character, + decodingEscapes: Bool + ) -> String { + index += 1 + var value = "" + while index < characters.count { + let character = characters[index] + if decodingEscapes, character == "\\", index + 1 < characters.count { + value.append(contentsOf: decodeEscape(characters[index + 1])) + index += 2 + continue + } + if character == delimiter { + index += 1 + skipToLineEnd(characters, &index) + return value + } + value.append(character) + index += 1 + } + return value + } + + static func decodeEscape(_ character: Character) -> String { + switch character { + case "n": + return "\n" + case "r": + return "\r" + case "t": + return "\t" + case "\\": + return "\\" + case "\"": + return "\"" + case "'": + return "'" + default: + return "\\\(character)" + } + } + + static func readUnquoted(_ characters: [Character], _ index: inout Int) -> String { + var value = "" + var previous: Character? + while index < characters.count { + let character = characters[index] + if character == "\n" { + break + } + if character == "#", let previous, previous == " " || previous == "\t" { + break + } + value.append(character) + previous = character + index += 1 + } + skipToLineEnd(characters, &index) + return value.trimmingCharacters(in: .whitespaces) + } +} diff --git a/TablePro/Core/Services/ProjectImport/DotenvParser.swift b/TablePro/Core/Services/ProjectImport/DotenvParser.swift new file mode 100644 index 000000000..d0628860d --- /dev/null +++ b/TablePro/Core/Services/ProjectImport/DotenvParser.swift @@ -0,0 +1,167 @@ +// +// DotenvParser.swift +// TablePro +// + +import Foundation + +struct DotenvEntry: Sendable { + let key: String + let value: String + let isSingleQuoted: Bool + let hasUnresolvedReference: Bool +} + +struct DotenvDocument: Sendable { + private(set) var entries: [DotenvEntry] = [] + private var positions: [String: Int] = [:] + + mutating func upsert(_ entry: DotenvEntry) { + if let existing = positions[entry.key] { + entries[existing] = entry + return + } + positions[entry.key] = entries.count + entries.append(entry) + } + + func entry(for key: String) -> DotenvEntry? { + guard let position = positions[key] else { + return nil + } + return entries[position] + } + + subscript(key: String) -> String? { + guard let entry = entry(for: key), !entry.hasUnresolvedReference else { + return nil + } + let trimmed = entry.value.trimmingCharacters(in: .whitespaces) + return trimmed.isEmpty ? nil : trimmed + } +} + +enum DotenvValueQuoting: Sendable { + case none + case single + case double +} + +struct DotenvAssignment: Sendable { + let key: String + let rawValue: String + let quoting: DotenvValueQuoting +} + +enum DotenvParser { + static let keyAllowedCharacters = CharacterSet( + charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.-" + ) + + static func parse( + _ contents: String, + processEnvironment: [String: String] = ProcessInfo.processInfo.environment + ) -> DotenvDocument { + var document = DotenvDocument() + let characters = Array(normalize(contents)) + var index = 0 + while index < characters.count { + skipBlankLinesAndComments(characters, &index) + guard index < characters.count else { + break + } + guard let assignment = readAssignment(characters, &index) else { + continue + } + document.upsert(resolve(assignment, document: document, processEnvironment: processEnvironment)) + } + return document + } + + static func normalize(_ contents: String) -> String { + var text = contents + if text.hasPrefix("\u{FEFF}") { + text.removeFirst() + } + return text + .replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") + } + + static func skipBlankLinesAndComments(_ characters: [Character], _ index: inout Int) { + while index < characters.count { + let character = characters[index] + if character == "\n" || character == " " || character == "\t" { + index += 1 + continue + } + if character == "#" { + skipToLineEnd(characters, &index) + continue + } + return + } + } + + static func skipToLineEnd(_ characters: [Character], _ index: inout Int) { + while index < characters.count, characters[index] != "\n" { + index += 1 + } + } + + static func readAssignment(_ characters: [Character], _ index: inout Int) -> DotenvAssignment? { + var rawKey = "" + while index < characters.count, characters[index] != "=", characters[index] != "\n" { + rawKey.append(characters[index]) + index += 1 + } + guard index < characters.count, characters[index] == "=" else { + skipToLineEnd(characters, &index) + return nil + } + index += 1 + guard let key = normalizeKey(rawKey) else { + skipToLineEnd(characters, &index) + return nil + } + while index < characters.count, characters[index] == " " || characters[index] == "\t" { + index += 1 + } + let value = readValue(characters, &index) + return DotenvAssignment(key: key, rawValue: value.raw, quoting: value.quoting) + } + + static func normalizeKey(_ rawKey: String) -> String? { + var key = rawKey.trimmingCharacters(in: .whitespaces) + if key.hasPrefix("export") { + let afterExport = key.dropFirst("export".count) + if let first = afterExport.first, first == " " || first == "\t" { + key = String(afterExport).trimmingCharacters(in: .whitespaces) + } + } + guard !key.isEmpty else { + return nil + } + guard key.unicodeScalars.allSatisfy({ keyAllowedCharacters.contains($0) }) else { + return nil + } + return key + } + + static func readValue( + _ characters: [Character], + _ index: inout Int + ) -> (raw: String, quoting: DotenvValueQuoting) { + guard index < characters.count else { + return ("", .none) + } + switch characters[index] { + case "\"": + return (readQuoted(characters, &index, delimiter: "\"", decodingEscapes: true), .double) + case "'": + return (readQuoted(characters, &index, delimiter: "'", decodingEscapes: false), .single) + default: + return (readUnquoted(characters, &index), .none) + } + } +} diff --git a/TablePro/Core/Services/ProjectImport/ParsedConnectionURL+Scanned.swift b/TablePro/Core/Services/ProjectImport/ParsedConnectionURL+Scanned.swift new file mode 100644 index 000000000..ea3437c22 --- /dev/null +++ b/TablePro/Core/Services/ProjectImport/ParsedConnectionURL+Scanned.swift @@ -0,0 +1,49 @@ +// +// ParsedConnectionURL+Scanned.swift +// TablePro +// + +import Foundation + +extension ParsedConnectionURL { + func with( + username overriddenUsername: String? = nil, + password overriddenPassword: String? = nil, + safeModeLevel overriddenSafeModeLevel: Int? = nil + ) -> ParsedConnectionURL { + ParsedConnectionURL( + type: type, + host: host, + port: port, + database: database, + username: overriddenUsername ?? username, + password: overriddenPassword ?? password, + sslMode: sslMode, + authSource: authSource, + sshHost: sshHost, + sshPort: sshPort, + sshUsername: sshUsername, + sshPassword: sshPassword, + usePrivateKey: usePrivateKey, + useSSHAgent: useSSHAgent, + sshNoAuth: sshNoAuth, + agentSocket: agentSocket, + connectionName: connectionName, + redisDatabase: redisDatabase, + statusColor: statusColor, + envTag: envTag, + schema: schema, + tableName: tableName, + isView: isView, + filterColumn: filterColumn, + filterOperation: filterOperation, + filterValue: filterValue, + filterCondition: filterCondition, + oracleServiceName: oracleServiceName, + safeModeLevel: overriddenSafeModeLevel ?? safeModeLevel, + useSrv: useSrv, + mongoQueryParams: mongoQueryParams, + multiHost: multiHost + ) + } +} diff --git a/TablePro/Core/Services/ProjectImport/PrismaSchemaExtractor.swift b/TablePro/Core/Services/ProjectImport/PrismaSchemaExtractor.swift new file mode 100644 index 000000000..48326067a --- /dev/null +++ b/TablePro/Core/Services/ProjectImport/PrismaSchemaExtractor.swift @@ -0,0 +1,178 @@ +// +// PrismaSchemaExtractor.swift +// TablePro +// + +import Foundation + +enum PrismaSchemaExtractor { + static func extract( + contents: String, + relativePath: String, + directoryURL: URL, + projectRootURL: URL, + environment: DotenvDocument?, + fileManager: FileManager + ) -> [ScannedConnectionCandidate] { + guard let block = datasourceBlock(in: contents) else { + return [] + } + guard let provider = value(of: "provider", in: block), + let type = providerType(provider) else { + return [] + } + guard let rawURL = resolvedURL(in: block, environment: environment) else { + return [] + } + if type == .sqlite || rawURL.lowercased().hasPrefix("file:") { + return sqliteCandidate( + rawURL: rawURL, + relativePath: relativePath, + directoryURL: directoryURL, + projectRootURL: projectRootURL, + fileManager: fileManager + ) + } + let candidate = ScannedConnectionURLBuilder.candidate( + fromURL: rawURL, + key: "datasource.url", + relativePath: relativePath, + kind: .prismaSchema, + tier: .configFile + ) + return candidate.map { [$0] } ?? [] + } + + static func datasourceBlock(in contents: String) -> String? { + guard let start = contents.range(of: "datasource", options: .caseInsensitive) else { + return nil + } + guard let open = contents.range(of: "{", range: start.upperBound.. String? { + for rawLine in block.components(separatedBy: .newlines) { + let line = rawLine.trimmingCharacters(in: .whitespaces) + guard line.hasPrefix(key) else { + continue + } + guard let equals = line.firstIndex(of: "=") else { + continue + } + let remainder = line[line.index(after: equals)...].trimmingCharacters(in: .whitespaces) + return remainder.isEmpty ? nil : remainder + } + return nil + } + + private static func resolvedURL(in block: String, environment: DotenvDocument?) -> String? { + guard let raw = value(of: "url", in: block) else { + return nil + } + if let name = environmentVariableName(in: raw) { + return environment?[name] + } + return unquoted(raw) + } + + static func environmentVariableName(in value: String) -> String? { + guard value.hasPrefix("env(") else { + return nil + } + guard let open = value.firstIndex(of: "("), let close = value.lastIndex(of: ")") else { + return nil + } + let inner = String(value[value.index(after: open).. [ScannedConnectionCandidate] { + var path = rawURL + if let colon = path.firstIndex(of: ":"), path.lowercased().hasPrefix("file:") { + path = String(path[path.index(after: colon)...]) + } + if path.hasPrefix("//") { + path = String(path.dropFirst(2)) + } + guard !path.isEmpty else { + return [] + } + var fields = ScannedConnectionFields(type: .sqlite) + fields.database = resolveSQLitePath( + path, + directoryURL: directoryURL, + projectRootURL: projectRootURL, + fileManager: fileManager + ) + let candidate = ScannedConnectionCandidate( + parsedURL: fields.toParsedConnectionURL(), + sourceRelativePath: relativePath, + sourceKey: "datasource.url", + kind: .prismaSchema, + tier: .configFile + ) + return [candidate] + } + + static func resolveSQLitePath( + _ path: String, + directoryURL: URL, + projectRootURL: URL, + fileManager: FileManager + ) -> String { + guard !path.hasPrefix("/") else { + return path + } + let schemaRelative = directoryURL.appendingPathComponent(path).standardizedFileURL + if fileManager.fileExists(atPath: schemaRelative.path) { + return schemaRelative.path + } + let rootRelative = projectRootURL.appendingPathComponent(path).standardizedFileURL + if fileManager.fileExists(atPath: rootRelative.path) { + return rootRelative.path + } + return schemaRelative.path + } + + private static func providerType(_ provider: String) -> DatabaseType? { + switch unquoted(provider).lowercased() { + case "postgresql", "postgres": + return .postgresql + case "mysql": + return .mysql + case "sqlite": + return .sqlite + case "sqlserver": + return .mssql + case "mongodb": + return .mongodb + case "cockroachdb": + return .cockroachdb + default: + return nil + } + } + + private static func unquoted(_ value: String) -> String { + guard value.count >= 2, let first = value.first, let last = value.last else { + return value + } + guard first == last, first == "\"" || first == "'" else { + return value + } + return String(value.dropFirst().dropLast()) + } +} diff --git a/TablePro/Core/Services/ProjectImport/ProjectConfigFileMatcher.swift b/TablePro/Core/Services/ProjectImport/ProjectConfigFileMatcher.swift new file mode 100644 index 000000000..30f9b97d3 --- /dev/null +++ b/TablePro/Core/Services/ProjectImport/ProjectConfigFileMatcher.swift @@ -0,0 +1,150 @@ +// +// ProjectConfigFileMatcher.swift +// TablePro +// + +import Foundation + +enum ProjectConfigFileKind: Sendable { + case dotenv + case wordPressConfig + case prismaSchema + case springProperties + case springYaml + case appSettingsJson + case railsDatabaseYaml + case dockerCompose +} + +enum ScannedConnectionTier: Int, Comparable, Sendable { + case nearCertain + case likelyReal + case configFile + case lowValue + + static func < (lhs: ScannedConnectionTier, rhs: ScannedConnectionTier) -> Bool { + lhs.rawValue < rhs.rawValue + } +} + +struct ProjectConfigFileMatch: Sendable { + let kind: ProjectConfigFileKind + let tier: ScannedConnectionTier +} + +enum ProjectConfigFileMatcher { + private static let placeholderSuffixes = [".example", ".sample", ".template", ".dist", ".schema"] + + private static let nearCertainDotenvNames: Set = [ + ".env", + ".env.local", + ".env.development.local", + ".env.production.local", + ".env.test.local", + ] + + private static let lowValueDotenvNames: Set = [ + ".env.test", + ".env.testing", + ".env.defaults", + ] + + private static let composeNames: Set = [ + "docker-compose.yml", + "docker-compose.yaml", + "compose.yml", + "compose.yaml", + ] + + static func classify(relativePath: String) -> ProjectConfigFileMatch? { + let components = relativePath.split(separator: "/").map(String.init) + guard let fileName = components.last, !fileName.isEmpty else { + return nil + } + let parent = components.count >= 2 ? components[components.count - 2].lowercased() : "" + let lowercased = fileName.lowercased() + + if lowercased == "wp-config.php" { + return ProjectConfigFileMatch(kind: .wordPressConfig, tier: .configFile) + } + if lowercased.hasSuffix(".prisma") { + return ProjectConfigFileMatch(kind: .prismaSchema, tier: .configFile) + } + if isSpringProperties(lowercased) { + return ProjectConfigFileMatch(kind: .springProperties, tier: .configFile) + } + if isSpringYaml(lowercased) { + return ProjectConfigFileMatch(kind: .springYaml, tier: .configFile) + } + if isAppSettings(lowercased) { + return ProjectConfigFileMatch(kind: .appSettingsJson, tier: .configFile) + } + if parent == "config", lowercased == "database.yml" || lowercased == "database.yaml" { + return ProjectConfigFileMatch(kind: .railsDatabaseYaml, tier: .configFile) + } + if isCompose(lowercased) { + return ProjectConfigFileMatch(kind: .dockerCompose, tier: .configFile) + } + if let tier = dotenvTier(fileName) { + return ProjectConfigFileMatch(kind: .dotenv, tier: tier) + } + return nil + } + + private static func dotenvTier(_ fileName: String) -> ScannedConnectionTier? { + guard fileName != ".envrc" else { + return nil + } + guard fileName.hasPrefix(".env") || fileName.hasSuffix(".env") else { + return nil + } + let lowercased = fileName.lowercased() + guard !lowercased.hasSuffix(".bak") else { + return nil + } + guard !placeholderSuffixes.contains(where: { lowercased.hasSuffix($0) }) else { + return nil + } + if nearCertainDotenvNames.contains(lowercased) { + return .nearCertain + } + if lowValueDotenvNames.contains(lowercased) { + return .lowValue + } + return .likelyReal + } + + private static func isSpringProperties(_ fileName: String) -> Bool { + if fileName == "application.properties" { + return true + } + return fileName.hasPrefix("application-") && fileName.hasSuffix(".properties") + } + + private static func isSpringYaml(_ fileName: String) -> Bool { + if fileName == "application.yml" || fileName == "application.yaml" { + return true + } + guard fileName.hasPrefix("application-") else { + return false + } + return fileName.hasSuffix(".yml") || fileName.hasSuffix(".yaml") + } + + private static func isAppSettings(_ fileName: String) -> Bool { + if fileName == "appsettings.json" { + return true + } + return fileName.hasPrefix("appsettings.") && fileName.hasSuffix(".json") + } + + private static func isCompose(_ fileName: String) -> Bool { + if composeNames.contains(fileName) { + return true + } + guard fileName.hasPrefix("docker-compose.") || fileName.hasPrefix("compose.") else { + return false + } + return fileName.hasSuffix(".yml") || fileName.hasSuffix(".yaml") + } +} diff --git a/TablePro/Core/Services/ProjectImport/ProjectFolderFileWalker.swift b/TablePro/Core/Services/ProjectImport/ProjectFolderFileWalker.swift new file mode 100644 index 000000000..d537ca7d1 --- /dev/null +++ b/TablePro/Core/Services/ProjectImport/ProjectFolderFileWalker.swift @@ -0,0 +1,138 @@ +// +// ProjectFolderFileWalker.swift +// TablePro +// + +import Foundation + +struct ProjectScanFile { + let url: URL + let relativePath: String + let match: ProjectConfigFileMatch +} + +enum ProjectFolderFileWalker { + static let maxDepth = 4 + static let maxFileSize = 1_048_576 + static let maxVisitedEntries = 20_000 + + private static let excludedDirectories: Set = [ + "node_modules", + ".git", + "vendor", + "venv", + ".venv", + "virtualenv", + "dist", + "build", + "target", + "Pods", + ".next", + ".nuxt", + ".svelte-kit", + "bower_components", + ".yarn", + ".npm", + ".pnpm-store", + ".svn", + ".hg", + "CVS", + ".terraform", + "DerivedData", + ".gradle", + ".mvn", + ] + + static func walk(root: URL, fileManager: FileManager = .default) -> [ProjectScanFile] { + let rootPath = root.standardizedFileURL.path + let keys: [URLResourceKey] = [.isRegularFileKey, .isSymbolicLinkKey, .isDirectoryKey, .fileSizeKey] + guard let enumerator = fileManager.enumerator( + at: root, + includingPropertiesForKeys: keys, + options: [.skipsPackageDescendants], + errorHandler: { _, _ in true } + ) else { + return [] + } + var files: [ProjectScanFile] = [] + var visited = 0 + for case let url as URL in enumerator { + visited += 1 + guard visited <= maxVisitedEntries else { + break + } + guard let values = try? url.resourceValues(forKeys: Set(keys)) else { + continue + } + if values.isDirectory == true { + if shouldSkipDirectory(named: url.lastPathComponent, level: enumerator.level) { + enumerator.skipDescendants() + } + continue + } + guard let file = scanFile(at: url, values: values, rootPath: rootPath) else { + continue + } + files.append(file) + } + return files.sorted { $0.relativePath < $1.relativePath } + } + + static func isDenied(_ standardizedPath: String) -> Bool { + deniedPrefixes.contains { standardizedPath == $0 || standardizedPath.hasPrefix($0 + "/") } + } + + static func relativePath(of standardizedPath: String, rootPath: String) -> String? { + guard standardizedPath.hasPrefix(rootPath + "/") else { + return nil + } + return String(standardizedPath.dropFirst(rootPath.count + 1)) + } + + private static func scanFile( + at url: URL, + values: URLResourceValues, + rootPath: String + ) -> ProjectScanFile? { + guard values.isRegularFile == true, values.isSymbolicLink != true else { + return nil + } + guard (values.fileSize ?? 0) <= maxFileSize else { + return nil + } + let standardized = url.standardizedFileURL.path + guard !isDenied(standardized) else { + return nil + } + guard let relative = relativePath(of: standardized, rootPath: rootPath) else { + return nil + } + guard let match = ProjectConfigFileMatcher.classify(relativePath: relative) else { + return nil + } + return ProjectScanFile(url: url, relativePath: relative, match: match) + } + + private static func shouldSkipDirectory(named name: String, level: Int) -> Bool { + if level > maxDepth { + return true + } + if excludedDirectories.contains(name) { + return true + } + return name.hasSuffix(".dist-info") + } + + private static var deniedPrefixes: [String] { + let home = FileManager.default.homeDirectoryForCurrentUser.standardizedFileURL.path + return [ + home + "/.ssh", + home + "/.aws", + home + "/.gnupg", + home + "/.docker", + home + "/Library/Keychains", + "/etc", + "/private/etc", + ] + } +} diff --git a/TablePro/Core/Services/ProjectImport/ProjectFolderScanner.swift b/TablePro/Core/Services/ProjectImport/ProjectFolderScanner.swift new file mode 100644 index 000000000..2b363a9db --- /dev/null +++ b/TablePro/Core/Services/ProjectImport/ProjectFolderScanner.swift @@ -0,0 +1,149 @@ +// +// ProjectFolderScanner.swift +// TablePro +// + +import Foundation +import os + +enum ProjectFolderScanError: Error, LocalizedError, Equatable { + case rootUnreadable + + var errorDescription: String? { + switch self { + case .rootUnreadable: + return String(localized: "That folder could not be read.") + } + } +} + +struct ProjectFolderScanResult { + let candidates: [ScannedConnectionCandidate] + let scannedFileCount: Int +} + +enum ProjectFolderScanner { + private static let logger = Logger(subsystem: "com.TablePro", category: "ProjectFolderScanner") + + static func scan( + rootURL: URL, + fileManager: FileManager = .default + ) -> Result { + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: rootURL.path, isDirectory: &isDirectory), isDirectory.boolValue else { + return .failure(.rootUnreadable) + } + let files = ProjectFolderFileWalker.walk(root: rootURL, fileManager: fileManager) + let index = ProjectScanEnvironmentIndex(files: files, rootURL: rootURL) + var candidates: [ScannedConnectionCandidate] = [] + for file in files { + candidates.append(contentsOf: extract( + file: file, + rootURL: rootURL, + index: index, + fileManager: fileManager + )) + } + candidates.append(contentsOf: parentWordPressCandidates(rootURL: rootURL, fileManager: fileManager)) + let ranked = ScannedConnectionRanking.rankAndDeduplicate(candidates) + logger.info( + "Scanned \(files.count, privacy: .public) config files, produced \(ranked.count, privacy: .public) candidates" + ) + return .success(ProjectFolderScanResult(candidates: ranked, scannedFileCount: files.count)) + } + + private static func extract( + file: ProjectScanFile, + rootURL: URL, + index: ProjectScanEnvironmentIndex, + fileManager: FileManager + ) -> [ScannedConnectionCandidate] { + let directoryURL = file.url.deletingLastPathComponent() + switch file.match.kind { + case .dotenv: + guard let document = index.document(forFile: file.url) else { + return [] + } + return DotenvConnectionExtractor.extract( + document: document, + relativePath: file.relativePath, + tier: file.match.tier, + directoryURL: directoryURL + ) + case .wordPressConfig: + guard let contents = ProjectFileReader.text(at: file.url) else { + return [] + } + return WordPressConfigExtractor.extract(contents: contents, relativePath: file.relativePath) + case .prismaSchema: + guard let contents = ProjectFileReader.text(at: file.url) else { + return [] + } + return PrismaSchemaExtractor.extract( + contents: contents, + relativePath: file.relativePath, + directoryURL: directoryURL, + projectRootURL: rootURL, + environment: index.environment(near: file.url), + fileManager: fileManager + ) + case .springProperties: + guard let contents = ProjectFileReader.text(at: file.url) else { + return [] + } + return SpringPropertiesExtractor.extract(contents: contents, relativePath: file.relativePath) + case .springYaml: + guard let contents = ProjectFileReader.text(at: file.url) else { + return [] + } + return SpringYamlExtractor.extract(contents: contents, relativePath: file.relativePath) + case .appSettingsJson: + guard let data = ProjectFileReader.data(at: file.url) else { + return [] + } + return AppSettingsJsonExtractor.extract(data: data, relativePath: file.relativePath) + case .railsDatabaseYaml: + guard let contents = ProjectFileReader.text(at: file.url) else { + return [] + } + return RailsDatabaseYamlExtractor.extract( + contents: contents, + relativePath: file.relativePath, + projectRootURL: rootURL, + environment: index.environment(near: file.url) + ) + case .dockerCompose: + guard let contents = ProjectFileReader.text(at: file.url) else { + return [] + } + return DockerComposeExtractor.extract( + contents: contents, + relativePath: file.relativePath, + environment: index.environment(near: file.url) + ) + } + } + + private static func parentWordPressCandidates( + rootURL: URL, + fileManager: FileManager + ) -> [ScannedConnectionCandidate] { + let parent = rootURL.standardizedFileURL.deletingLastPathComponent() + let candidateURL = parent.appendingPathComponent("wp-config.php") + let path = candidateURL.standardizedFileURL.path + guard !ProjectFolderFileWalker.isDenied(path) else { + return [] + } + let keys: Set = [.isRegularFileKey, .isSymbolicLinkKey, .fileSizeKey] + guard let values = try? candidateURL.resourceValues(forKeys: keys), + values.isRegularFile == true, + values.isSymbolicLink != true, + (values.fileSize ?? 0) <= ProjectFolderFileWalker.maxFileSize else { + return [] + } + guard let contents = ProjectFileReader.text(at: candidateURL) else { + return [] + } + return WordPressConfigExtractor.extract(contents: contents, relativePath: "../wp-config.php") + } +} diff --git a/TablePro/Core/Services/ProjectImport/ProjectScanEnvironmentIndex.swift b/TablePro/Core/Services/ProjectImport/ProjectScanEnvironmentIndex.swift new file mode 100644 index 000000000..b67bfe980 --- /dev/null +++ b/TablePro/Core/Services/ProjectImport/ProjectScanEnvironmentIndex.swift @@ -0,0 +1,53 @@ +// +// ProjectScanEnvironmentIndex.swift +// TablePro +// + +import Foundation + +enum ProjectFileReader { + static func text(at url: URL) -> String? { + if let utf8 = try? String(contentsOf: url, encoding: .utf8) { + return utf8 + } + return try? String(contentsOf: url, encoding: .isoLatin1) + } + + static func data(at url: URL) -> Data? { + try? Data(contentsOf: url) + } +} + +struct ProjectScanEnvironmentIndex { + private var documentsByFile: [String: DotenvDocument] = [:] + private var documentsByDirectory: [String: DotenvDocument] = [:] + private var rootDocument: DotenvDocument? + + init(files: [ProjectScanFile], rootURL: URL) { + let rootPath = rootURL.standardizedFileURL.path + for file in files where file.match.kind == .dotenv { + guard let contents = ProjectFileReader.text(at: file.url) else { + continue + } + let document = DotenvParser.parse(contents) + let filePath = file.url.standardizedFileURL.path + let directory = file.url.deletingLastPathComponent().standardizedFileURL.path + documentsByFile[filePath] = document + if documentsByDirectory[directory] == nil || file.match.tier == .nearCertain { + documentsByDirectory[directory] = document + } + if directory == rootPath, rootDocument == nil || file.match.tier == .nearCertain { + rootDocument = document + } + } + } + + func document(forFile url: URL) -> DotenvDocument? { + documentsByFile[url.standardizedFileURL.path] + } + + func environment(near url: URL) -> DotenvDocument? { + let directory = url.deletingLastPathComponent().standardizedFileURL.path + return documentsByDirectory[directory] ?? rootDocument + } +} diff --git a/TablePro/Core/Services/ProjectImport/RailsDatabaseYamlExtractor.swift b/TablePro/Core/Services/ProjectImport/RailsDatabaseYamlExtractor.swift new file mode 100644 index 000000000..ea9e5ecc3 --- /dev/null +++ b/TablePro/Core/Services/ProjectImport/RailsDatabaseYamlExtractor.swift @@ -0,0 +1,183 @@ +// +// RailsDatabaseYamlExtractor.swift +// TablePro +// + +import Foundation + +enum RailsDatabaseYamlExtractor { + private static let preferredEnvironments = ["development", "primary", "production", "staging", "test"] + + static func extract( + contents: String, + relativePath: String, + projectRootURL: URL, + environment: DotenvDocument? + ) -> [ScannedConnectionCandidate] { + guard let root = YamlMappingSupport.loadMapping(contents) else { + return [] + } + guard let selected = selectEnvironment(root) else { + return [] + } + guard let adapter = YamlMappingSupport.string(selected.mapping["adapter"]), + let type = adapterType(adapter) else { + return [] + } + var warnings: [String] = [] + var fields = ScannedConnectionFields(type: type) + let resolver = ErbValueResolver(environment: environment) + fields.database = resolver.resolve( + YamlMappingSupport.string(selected.mapping["database"]), + warnings: &warnings + ) ?? "" + guard type != .sqlite else { + fields.database = absoluteSQLitePath(fields.database, projectRootURL: projectRootURL) + return [candidate(fields: fields, relativePath: relativePath, key: selected.key, warnings: warnings)] + } + fields.host = resolver.resolve( + YamlMappingSupport.string(selected.mapping["host"]), + warnings: &warnings + ) ?? "127.0.0.1" + fields.username = resolver.resolve( + YamlMappingSupport.string(selected.mapping["username"]), + warnings: &warnings + ) ?? "" + fields.password = resolver.resolve( + YamlMappingSupport.string(selected.mapping["password"]), + warnings: &warnings + ) ?? "" + let port = resolver.resolve(YamlMappingSupport.string(selected.mapping["port"]), warnings: &warnings) + fields.port = port.flatMap { Int($0) } ?? type.defaultPort + return [candidate(fields: fields, relativePath: relativePath, key: selected.key, warnings: warnings)] + } + + static func selectEnvironment(_ root: [String: Any]) -> (key: String, mapping: [String: Any])? { + for name in preferredEnvironments { + guard let mapping = YamlMappingSupport.mapping(root[name]) else { + continue + } + if mapping["adapter"] != nil { + return (name, mapping) + } + if let nested = firstNestedDatabase(mapping) { + return ("\(name).\(nested.key)", nested.mapping) + } + } + return nil + } + + private static func firstNestedDatabase(_ mapping: [String: Any]) -> (key: String, mapping: [String: Any])? { + for key in mapping.keys.sorted() { + guard let nested = YamlMappingSupport.mapping(mapping[key]), nested["adapter"] != nil else { + continue + } + return (key, nested) + } + return nil + } + + private static func candidate( + fields: ScannedConnectionFields, + relativePath: String, + key: String, + warnings: [String] + ) -> ScannedConnectionCandidate { + ScannedConnectionCandidate( + parsedURL: fields.toParsedConnectionURL(), + sourceRelativePath: relativePath, + sourceKey: key, + kind: .railsDatabaseYaml, + tier: .configFile, + warnings: warnings + ) + } + + private static func absoluteSQLitePath(_ path: String, projectRootURL: URL) -> String { + guard !path.isEmpty else { + return projectRootURL.appendingPathComponent("storage/development.sqlite3").path + } + guard !path.hasPrefix("/") else { + return path + } + return projectRootURL.appendingPathComponent(path).standardizedFileURL.path + } + + private static func adapterType(_ adapter: String) -> DatabaseType? { + switch adapter.lowercased() { + case "postgresql", "postgis", "postgres": + return .postgresql + case "mysql2", "mysql", "trilogy": + return .mysql + case "sqlite3", "sqlite": + return .sqlite + case "sqlserver": + return .mssql + default: + return nil + } + } +} + +struct ErbValueResolver { + let environment: DotenvDocument? + + func resolve(_ value: String?, warnings: inout [String]) -> String? { + guard let value else { + return nil + } + guard value.contains("<%") else { + return value + } + guard let name = environmentVariableName(in: value) else { + warnings.append(unresolvedWarning) + return nil + } + if let resolved = environment?[name] { + return resolved + } + if let fallback = fallbackValue(in: value) { + return fallback + } + warnings.append(unresolvedWarning) + return nil + } + + private var unresolvedWarning: String { + String(localized: "Some values come from Ruby code and could not be read. Fill them in before connecting.") + } + + private func environmentVariableName(in value: String) -> String? { + guard let range = value.range(of: #"ENV(?:\.fetch)?\s*[\[(]\s*['"]([^'"]+)['"]"#, options: .regularExpression) else { + return nil + } + let matched = String(value[range]) + guard let quoteStart = matched.range(of: #"['"]"#, options: .regularExpression) else { + return nil + } + let remainder = matched[quoteStart.upperBound...] + guard let quoteEnd = remainder.range(of: #"['"]"#, options: .regularExpression) else { + return nil + } + return String(remainder[remainder.startIndex.. String? { + guard let range = value.range(of: #"ENV\.fetch\s*\(\s*['"][^'"]+['"]\s*,\s*['"]([^'"]*)['"]"#, options: .regularExpression) else { + return nil + } + let matched = String(value[range]) + guard let comma = matched.range(of: ",") else { + return nil + } + let tail = matched[comma.upperBound...] + guard let quoteStart = tail.range(of: #"['"]"#, options: .regularExpression) else { + return nil + } + let remainder = tail[quoteStart.upperBound...] + guard let quoteEnd = remainder.range(of: #"['"]"#, options: .regularExpression) else { + return nil + } + return String(remainder[remainder.startIndex.. = [ + "!changeme!", + "changeme", + "change_me", + "change-me", + "johndoe", + "randompassword", + "username_here", + "password_here", + "database_name_here", + "your_username", + "your_password", + "your_database", + "yourusername", + "yourpassword", + "putyourpasswordhere", + ] + + static func isPlaceholder(username: String, password: String, database: String) -> Bool { + [username, password, database].contains { literals.contains($0.lowercased()) } + } +} diff --git a/TablePro/Core/Services/ProjectImport/ScannedConnectionFields.swift b/TablePro/Core/Services/ProjectImport/ScannedConnectionFields.swift new file mode 100644 index 000000000..05ad4cd3e --- /dev/null +++ b/TablePro/Core/Services/ProjectImport/ScannedConnectionFields.swift @@ -0,0 +1,62 @@ +// +// ScannedConnectionFields.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +struct ScannedConnectionFields { + var type: DatabaseType + var host = "" + var port: Int? + var database = "" + var username = "" + var password = "" + var sslMode: SSLMode? + var oracleServiceName: String? + var redisDatabase: Int? + var connectionName: String? + var safeModeLevel: Int? + + init(type: DatabaseType) { + self.type = type + } + + func toParsedConnectionURL() -> ParsedConnectionURL { + ParsedConnectionURL( + type: type, + host: host, + port: port, + database: database, + username: username, + password: password, + sslMode: sslMode, + authSource: nil, + sshHost: nil, + sshPort: nil, + sshUsername: nil, + sshPassword: nil, + usePrivateKey: nil, + useSSHAgent: nil, + sshNoAuth: nil, + agentSocket: nil, + connectionName: connectionName, + redisDatabase: redisDatabase, + statusColor: nil, + envTag: nil, + schema: nil, + tableName: nil, + isView: false, + filterColumn: nil, + filterOperation: nil, + filterValue: nil, + filterCondition: nil, + oracleServiceName: oracleServiceName, + safeModeLevel: safeModeLevel, + useSrv: false, + mongoQueryParams: [:], + multiHost: nil + ) + } +} diff --git a/TablePro/Core/Services/ProjectImport/ScannedConnectionRanking.swift b/TablePro/Core/Services/ProjectImport/ScannedConnectionRanking.swift new file mode 100644 index 000000000..72a47cf56 --- /dev/null +++ b/TablePro/Core/Services/ProjectImport/ScannedConnectionRanking.swift @@ -0,0 +1,42 @@ +// +// ScannedConnectionRanking.swift +// TablePro +// + +import Foundation + +enum ScannedConnectionRanking { + static func rankAndDeduplicate(_ candidates: [ScannedConnectionCandidate]) -> [ScannedConnectionCandidate] { + let ordered = candidates.sorted { lhs, rhs in + if lhs.tier != rhs.tier { + return lhs.tier < rhs.tier + } + if lhs.sourceRelativePath != rhs.sourceRelativePath { + return lhs.sourceRelativePath < rhs.sourceRelativePath + } + return lhs.sourceKey < rhs.sourceKey + } + var seen: Set = [] + var result: [ScannedConnectionCandidate] = [] + for candidate in ordered { + let key = identity(of: candidate) + guard !seen.contains(key) else { + continue + } + seen.insert(key) + result.append(candidate) + } + return result + } + + static func identity(of candidate: ScannedConnectionCandidate) -> String { + let parsed = candidate.parsedURL + return [ + parsed.type.rawValue, + parsed.host.lowercased(), + String(parsed.port ?? parsed.type.defaultPort), + parsed.database.lowercased(), + parsed.username.lowercased(), + ].joined(separator: "\u{1F}") + } +} diff --git a/TablePro/Core/Services/ProjectImport/ScannedConnectionURLBuilder.swift b/TablePro/Core/Services/ProjectImport/ScannedConnectionURLBuilder.swift new file mode 100644 index 000000000..2ab924679 --- /dev/null +++ b/TablePro/Core/Services/ProjectImport/ScannedConnectionURLBuilder.swift @@ -0,0 +1,96 @@ +// +// ScannedConnectionURLBuilder.swift +// TablePro +// + +import Foundation + +enum ScannedConnectionURLBuilder { + private static let schemeAliases: [String: String] = [ + "pgsql": "postgresql", + "psql": "postgresql", + "postgis": "postgresql", + "timescale": "postgresql", + "timescalegis": "postgresql", + "mysql2": "mysql", + "trilogy": "mysql", + "mysql-connector": "mysql", + "mysqlgis": "mysql", + "sqlite3": "sqlite", + "spatialite": "sqlite", + "sqlsrv": "mssql", + "mssqlms": "mssql", + "cockroach": "cockroachdb", + "oraclegis": "oracle", + ] + + private static let rejectedSchemes: Set = ["prisma", "prisma+postgres"] + + static func parse(_ rawValue: String) -> ParsedConnectionURL? { + guard let prepared = prepare(rawValue) else { + return nil + } + guard case .success(let parsed) = ConnectionURLParser.parse(prepared) else { + return nil + } + return parsed + } + + static func prepare(_ rawValue: String) -> String? { + var value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { + return nil + } + value = strippingQuotes(value) + let lowercased = value.lowercased() + if lowercased.hasPrefix("jdbc:"), + !lowercased.hasPrefix("jdbc:sqlserver:"), + !lowercased.hasPrefix("jdbc:oracle:") { + value = String(value.dropFirst("jdbc:".count)) + } + guard let schemeRange = value.range(of: "://") else { + return nil + } + let scheme = value[value.startIndex.. ScannedConnectionCandidate? { + guard let parsed = parse(rawValue) else { + return nil + } + return ScannedConnectionCandidate( + parsedURL: parsed, + sourceRelativePath: relativePath, + sourceKey: key, + kind: kind, + tier: tier, + warnings: warnings + ) + } + + private static func strippingQuotes(_ value: String) -> String { + guard value.count >= 2 else { + return value + } + let first = value.first + let last = value.last + guard first == last, first == "\"" || first == "'" else { + return value + } + return String(value.dropFirst().dropLast()) + } +} diff --git a/TablePro/Core/Services/ProjectImport/ScannedProductionHeuristic.swift b/TablePro/Core/Services/ProjectImport/ScannedProductionHeuristic.swift new file mode 100644 index 000000000..bc5a700b6 --- /dev/null +++ b/TablePro/Core/Services/ProjectImport/ScannedProductionHeuristic.swift @@ -0,0 +1,19 @@ +// +// ScannedProductionHeuristic.swift +// TablePro +// + +import Foundation + +enum ScannedProductionHeuristic { + private static let markers: Set = ["prod", "production", "live"] + + static func isProduction(relativePath: String, host: String, database: String) -> Bool { + [relativePath, host, database].contains(where: containsMarker) + } + + private static func containsMarker(_ value: String) -> Bool { + let tokens = value.lowercased().split { !$0.isLetter && !$0.isNumber } + return tokens.contains { markers.contains(String($0)) } + } +} diff --git a/TablePro/Core/Services/ProjectImport/ScannedURLNormalizer.swift b/TablePro/Core/Services/ProjectImport/ScannedURLNormalizer.swift new file mode 100644 index 000000000..70ca86d85 --- /dev/null +++ b/TablePro/Core/Services/ProjectImport/ScannedURLNormalizer.swift @@ -0,0 +1,48 @@ +// +// ScannedURLNormalizer.swift +// TablePro +// + +import Foundation + +enum ScannedURLNormalizer { + private static let userInfoAllowed: CharacterSet = { + var allowed = CharacterSet.alphanumerics + allowed.insert(charactersIn: "-._~%") + return allowed + }() + + static func normalize(_ urlString: String) -> String { + let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines) + guard let schemeRange = trimmed.range(of: "://") else { + return trimmed + } + let scheme = String(trimmed[trimmed.startIndex.. String.Index? { + let boundary = remainder.firstIndex { $0 == "?" || $0 == "#" } ?? remainder.endIndex + let searchable = remainder[remainder.startIndex.. String { + guard let colon = userInfo.firstIndex(of: ":") else { + return percentEncoded(userInfo) + } + let username = String(userInfo[userInfo.startIndex.. String { + component.addingPercentEncoding(withAllowedCharacters: userInfoAllowed) ?? component + } +} diff --git a/TablePro/Core/Services/ProjectImport/SpringPropertiesExtractor.swift b/TablePro/Core/Services/ProjectImport/SpringPropertiesExtractor.swift new file mode 100644 index 000000000..82d01e97d --- /dev/null +++ b/TablePro/Core/Services/ProjectImport/SpringPropertiesExtractor.swift @@ -0,0 +1,109 @@ +// +// SpringPropertiesExtractor.swift +// TablePro +// + +import Foundation + +enum SpringPropertiesExtractor { + static func extract( + contents: String, + relativePath: String, + processEnvironment: [String: String] = ProcessInfo.processInfo.environment + ) -> [ScannedConnectionCandidate] { + let properties = parse(contents) + guard let rawURL = properties["spring.datasource.url"] else { + return [] + } + let url = resolvePlaceholders(rawURL, processEnvironment: processEnvironment) + guard let parsed = ScannedConnectionURLBuilder.parse(url) else { + return [] + } + let username = properties["spring.datasource.username"].map { + resolvePlaceholders($0, processEnvironment: processEnvironment) + } ?? "" + let password = properties["spring.datasource.password"].map { + resolvePlaceholders($0, processEnvironment: processEnvironment) + } ?? "" + let merged = parsed.with( + username: parsed.username.isEmpty ? username : nil, + password: parsed.password.isEmpty ? password : nil + ) + let candidate = ScannedConnectionCandidate( + parsedURL: merged, + sourceRelativePath: relativePath, + sourceKey: "spring.datasource.url", + kind: .springProperties, + tier: .configFile + ) + return [candidate] + } + + static func parse(_ contents: String) -> [String: String] { + var properties: [String: String] = [:] + var pending = "" + for rawLine in DotenvParser.normalize(contents).components(separatedBy: "\n") { + let line = rawLine.trimmingCharacters(in: .whitespaces) + if pending.isEmpty, line.isEmpty || line.hasPrefix("#") || line.hasPrefix("!") { + continue + } + if line.hasSuffix("\\") { + pending += String(line.dropLast()) + continue + } + let full = pending + line + pending = "" + guard let pair = splitPair(full) else { + continue + } + properties[pair.key] = pair.value + } + return properties + } + + private static func splitPair(_ line: String) -> (key: String, value: String)? { + let separators: Set = ["=", ":"] + guard let index = line.firstIndex(where: { separators.contains($0) }) else { + return nil + } + let key = String(line[line.startIndex.. String { + guard value.contains("${") else { + return value + } + var result = "" + var remainder = Substring(value) + while let open = remainder.range(of: "${") { + result += remainder[remainder.startIndex.. String { + guard let colon = reference.firstIndex(of: ":") else { + return processEnvironment[reference] ?? "${\(reference)}" + } + let name = String(reference[reference.startIndex.. [ScannedConnectionCandidate] { + for document in YamlMappingSupport.loadDocuments(contents) { + guard let rawURL = value(in: document, path: ["spring", "datasource", "url"]) else { + continue + } + let url = SpringPropertiesExtractor.resolvePlaceholders(rawURL, processEnvironment: processEnvironment) + guard let parsed = ScannedConnectionURLBuilder.parse(url) else { + continue + } + let username = value(in: document, path: ["spring", "datasource", "username"]).map { + SpringPropertiesExtractor.resolvePlaceholders($0, processEnvironment: processEnvironment) + } ?? "" + let password = value(in: document, path: ["spring", "datasource", "password"]).map { + SpringPropertiesExtractor.resolvePlaceholders($0, processEnvironment: processEnvironment) + } ?? "" + let merged = parsed.with( + username: parsed.username.isEmpty ? username : nil, + password: parsed.password.isEmpty ? password : nil + ) + let candidate = ScannedConnectionCandidate( + parsedURL: merged, + sourceRelativePath: relativePath, + sourceKey: "spring.datasource.url", + kind: .springYaml, + tier: .configFile + ) + return [candidate] + } + return [] + } + + static func value(in document: [String: Any], path: [String]) -> String? { + if let flattened = YamlMappingSupport.string(document[path.joined(separator: ".")]) { + return flattened + } + var current: [String: Any] = document + for (offset, key) in path.enumerated() { + let isLast = offset == path.count - 1 + if isLast { + return YamlMappingSupport.string(current[key]) + } + guard let nested = YamlMappingSupport.mapping(current[key]) else { + let remainder = path[offset...].joined(separator: ".") + return YamlMappingSupport.string(current[remainder]) + } + current = nested + } + return nil + } +} diff --git a/TablePro/Core/Services/ProjectImport/WordPressConfigExtractor.swift b/TablePro/Core/Services/ProjectImport/WordPressConfigExtractor.swift new file mode 100644 index 000000000..24471c4ef --- /dev/null +++ b/TablePro/Core/Services/ProjectImport/WordPressConfigExtractor.swift @@ -0,0 +1,81 @@ +// +// WordPressConfigExtractor.swift +// TablePro +// + +import Foundation + +enum WordPressConfigExtractor { + private static let definePattern = #"define\(\s*['"]([A-Za-z_]+)['"]\s*,\s*['"]([^'"]*)['"]\s*\)"# + + static func extract(contents: String, relativePath: String) -> [ScannedConnectionCandidate] { + let constants = parseConstants(contents) + guard let database = constants["DB_NAME"] else { + return [] + } + var fields = ScannedConnectionFields(type: .mysql) + fields.database = database + fields.username = constants["DB_USER"] ?? "" + fields.password = constants["DB_PASSWORD"] ?? "" + var warnings: [String] = [] + let address = splitHost(constants["DB_HOST"] ?? "localhost") + fields.host = address.host + fields.port = address.port ?? 3_306 + if address.isSocket { + warnings.append(String(localized: "The host looks like a socket path. Check it before connecting.")) + } + let candidate = ScannedConnectionCandidate( + parsedURL: fields.toParsedConnectionURL(), + sourceRelativePath: relativePath, + sourceKey: "DB_NAME", + kind: .wordPressConfig, + tier: .configFile, + warnings: warnings + ) + return [candidate] + } + + static func parseConstants(_ contents: String) -> [String: String] { + guard let regex = try? NSRegularExpression(pattern: definePattern) else { + return [:] + } + var constants: [String: String] = [:] + var inBlockComment = false + for rawLine in contents.components(separatedBy: .newlines) { + let line = rawLine.trimmingCharacters(in: .whitespaces) + if inBlockComment { + if line.contains("*/") { + inBlockComment = false + } + continue + } + if line.hasPrefix("/*") { + inBlockComment = !line.contains("*/") + continue + } + if line.hasPrefix("//") || line.hasPrefix("#") || line.hasPrefix("*") { + continue + } + let range = NSRange(line.startIndex.. (host: String, port: Int?, isSocket: Bool) { + guard let colon = value.firstIndex(of: ":") else { + return (value, nil, false) + } + let head = String(value[value.startIndex.. [String: Any]? { + guard let object = try? Yams.load(yaml: contents) else { + return nil + } + guard let mapping = object as? [String: Any] else { + return nil + } + return expandingMergeKeys(mapping) + } + + static func loadDocuments(_ contents: String) -> [[String: Any]] { + guard let objects = try? Yams.load_all(yaml: contents) else { + return [] + } + return objects.compactMap { $0 as? [String: Any] }.map(expandingMergeKeys) + } + + static func expandingMergeKeys(_ mapping: [String: Any]) -> [String: Any] { + var own: [String: Any] = [:] + var inherited: [String: Any] = [:] + for (key, value) in mapping { + guard key == "<<" else { + own[key] = expand(value) + continue + } + for source in mergeSources(value) where !source.isEmpty { + for (mergeKey, mergeValue) in source where inherited[mergeKey] == nil { + inherited[mergeKey] = mergeValue + } + } + } + var result = inherited + for (key, value) in own { + result[key] = value + } + return result + } + + static func mapping(_ value: Any?) -> [String: Any]? { + guard let mapping = value as? [String: Any] else { + return nil + } + return expandingMergeKeys(mapping) + } + + static func string(_ value: Any?) -> String? { + switch value { + case let text as String: + let trimmed = text.trimmingCharacters(in: .whitespaces) + return trimmed.isEmpty ? nil : trimmed + case let number as Int: + return String(number) + case let flag as Bool: + return flag ? "true" : "false" + default: + return nil + } + } + + static func int(_ value: Any?) -> Int? { + if let number = value as? Int { + return number + } + guard let text = string(value) else { + return nil + } + return Int(text) + } + + private static func expand(_ value: Any) -> Any { + if let mapping = value as? [String: Any] { + return expandingMergeKeys(mapping) + } + if let list = value as? [Any] { + return list.map(expand) + } + return value + } + + private static func mergeSources(_ value: Any) -> [[String: Any]] { + if let mapping = value as? [String: Any] { + return [expandingMergeKeys(mapping)] + } + if let list = value as? [Any] { + return list.compactMap { $0 as? [String: Any] }.map(expandingMergeKeys) + } + return [] + } +} diff --git a/TablePro/Info.plist b/TablePro/Info.plist index 86c1f2480..a8716e9f2 100644 --- a/TablePro/Info.plist +++ b/TablePro/Info.plist @@ -278,6 +278,12 @@ + NSDesktopFolderUsageDescription + TablePro reads database settings from the project folder you choose. + NSDocumentsFolderUsageDescription + TablePro reads database settings from the project folder you choose. + NSDownloadsFolderUsageDescription + TablePro reads database settings from the project folder you choose. NSAppTransportSecurity NSAllowsArbitraryLoads diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index 8f26ff4c4..6924c0037 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -366,6 +366,10 @@ struct AppMenuCommands: Commands { AppCommands.shared.importConnectionsFromApp.send(()) } + Button(String(localized: "Open Project Folder...")) { + AppCommands.shared.openProjectFolder.send(()) + } + Divider() Button("Export...") { diff --git a/TablePro/ViewModels/WelcomeViewModel+ProjectFolder.swift b/TablePro/ViewModels/WelcomeViewModel+ProjectFolder.swift new file mode 100644 index 000000000..7470e3644 --- /dev/null +++ b/TablePro/ViewModels/WelcomeViewModel+ProjectFolder.swift @@ -0,0 +1,59 @@ +// +// WelcomeViewModel+ProjectFolder.swift +// TablePro +// + +import AppKit +import Combine +import os + +extension WelcomeViewModel { + private static let projectFolderLogger = Logger( + subsystem: "com.TablePro", + category: "WelcomeProjectFolder" + ) + + func setUpProjectFolderCommand() { + openProjectFolderCancellable = AppCommands.shared.openProjectFolder + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.openProjectFolder() + } + } + + func openProjectFolder() { + Task { @MainActor in + await presentProjectFolderPicker() + } + } + + @MainActor + private func presentProjectFolderPicker() async { + guard let window = Self.hostWindow() else { + Self.projectFolderLogger.error("No window available to present the project folder picker") + return + } + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.prompt = String(localized: "Choose Folder") + panel.message = String(localized: "Choose a project folder to read its database settings.") + let response = await panel.presentAsSheet(for: window) + guard response == .OK, let url = panel.url else { + return + } + NSDocumentController.shared.noteNewRecentDocumentURL(url) + activeSheet = .projectFolderScan(url) + } + + static func hostWindow() -> NSWindow? { + if let key = NSApp.keyWindow { + return key + } + if let main = NSApp.mainWindow { + return main + } + return NSApp.windows.first { $0.isVisible } + } +} diff --git a/TablePro/ViewModels/WelcomeViewModel.swift b/TablePro/ViewModels/WelcomeViewModel.swift index de4d8bf64..8ddf836d3 100644 --- a/TablePro/ViewModels/WelcomeViewModel.swift +++ b/TablePro/ViewModels/WelcomeViewModel.swift @@ -16,6 +16,7 @@ enum WelcomeActiveSheet: Identifiable { case importFile(URL) case exportConnections([DatabaseConnection]) case importFromApp + case projectFolderScan(URL) case deeplinkImport(ExportableConnection) var id: String { @@ -25,6 +26,7 @@ enum WelcomeActiveSheet: Identifiable { case .importFile(let u): "importFile-\(u.absoluteString)" case .exportConnections: "exportConnections" case .importFromApp: "importFromApp" + case .projectFolderScan(let u): "projectFolderScan-\(u.absoluteString)" case .deeplinkImport(let c): "deeplinkImport-\(c.type)-\(c.name)-\(c.host)-\(c.port)" } } @@ -95,6 +97,7 @@ final class WelcomeViewModel { @ObservationIgnored private var exportConnectionsCancellable: AnyCancellable? @ObservationIgnored private var importConnectionsCancellable: AnyCancellable? @ObservationIgnored private var importFromAppCancellable: AnyCancellable? + @ObservationIgnored var openProjectFolderCancellable: AnyCancellable? @ObservationIgnored private var welcomeRouterTask: Task? @ObservationIgnored private var searchDebounceTask: Task? private static let searchDebounceNanoseconds: UInt64 = 150_000_000 @@ -211,6 +214,8 @@ final class WelcomeViewModel { self?.activeSheet = .importFromApp } + setUpProjectFolderCommand() + linkedFoldersCancellable = services.appEvents.linkedFoldersDidUpdate .receive(on: RunLoop.main) .sink { [weak self] _ in diff --git a/TablePro/Views/Connection/ProjectFolder/ProjectFolderCandidateList.swift b/TablePro/Views/Connection/ProjectFolder/ProjectFolderCandidateList.swift new file mode 100644 index 000000000..c258753df --- /dev/null +++ b/TablePro/Views/Connection/ProjectFolder/ProjectFolderCandidateList.swift @@ -0,0 +1,59 @@ +// +// ProjectFolderCandidateList.swift +// TablePro +// + +import SwiftUI + +struct ProjectFolderCandidateList: View { + let candidates: [ScannedConnectionCandidate] + @Binding var selection: UUID? + + var body: some View { + List(candidates, id: \.id, selection: $selection) { candidate in + row(for: candidate) + .padding(.vertical, 4) + .tag(candidate.id) + } + .listStyle(.inset) + .alternatingRowBackgrounds() + } + + private func row(for candidate: ScannedConnectionCandidate) -> some View { + VStack(alignment: .leading, spacing: 4) { + ParsedConnectionSummaryView(parsed: candidate.parsedURL, showsBackground: false) + HStack(spacing: 6) { + Image(systemName: "doc.text") + .font(.caption2) + .foregroundStyle(.tertiary) + Text(candidate.sourceRelativePath) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + Text(candidate.sourceKey) + .font(.caption2) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + if candidate.hasPassword { + Label(String(localized: "Password found"), systemImage: "key.fill") + .font(.caption2) + .foregroundStyle(.secondary) + } + if candidate.placeholderSuspected { + Label( + String(localized: "Looks like a placeholder value"), + systemImage: "questionmark.circle" + ) + .font(.caption2) + .foregroundStyle(.orange) + } + ForEach(candidate.warnings, id: \.self) { warning in + Label(warning, systemImage: "exclamationmark.triangle") + .font(.caption2) + .foregroundStyle(.orange) + } + } + } +} diff --git a/TablePro/Views/Connection/ProjectFolder/ProjectFolderScanSheet.swift b/TablePro/Views/Connection/ProjectFolder/ProjectFolderScanSheet.swift new file mode 100644 index 000000000..1b4601347 --- /dev/null +++ b/TablePro/Views/Connection/ProjectFolder/ProjectFolderScanSheet.swift @@ -0,0 +1,146 @@ +// +// ProjectFolderScanSheet.swift +// TablePro +// + +import SwiftUI + +struct ProjectFolderScanSheet: View { + let rootURL: URL + let onSelect: (ParsedConnectionURL) -> Void + let onChooseAnotherFolder: () -> Void + + @State private var step: Step = .scanning + @State private var selection: UUID? + @Environment(\.dismiss) private var dismiss + + private enum Step { + case scanning + case results([ScannedConnectionCandidate]) + case empty + case error(String) + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + header + content + footer + } + .padding(20) + .frame(width: 560, height: 480) + .task(id: rootURL) { + await runScan() + } + } + + private var projectName: String { + rootURL.lastPathComponent + } + + private var header: some View { + VStack(alignment: .leading, spacing: 4) { + Text(String(localized: "Open Project Folder")) + .font(.headline) + Text(String(format: String(localized: "Connections found in %@"), projectName)) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + + @ViewBuilder + private var content: some View { + switch step { + case .scanning: + centered { + ProgressView() + Text(String(localized: "Looking for database settings…")) + .font(.subheadline) + .foregroundStyle(.secondary) + } + case .results(let candidates): + ProjectFolderCandidateList(candidates: candidates, selection: $selection) + case .empty: + centered { + Image(systemName: "folder.badge.questionmark") + .font(.largeTitle) + .foregroundStyle(.tertiary) + Text(String(localized: "No database settings found in this folder.")) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + case .error(let message): + centered { + Image(systemName: "exclamationmark.triangle.fill") + .font(.largeTitle) + .foregroundStyle(.orange) + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + } + } + + private var footer: some View { + HStack { + Button(String(localized: "Choose Another Folder…")) { + dismiss() + onChooseAnotherFolder() + } + Spacer() + Button(String(localized: "Cancel")) { + dismiss() + } + .keyboardShortcut(.cancelAction) + + Button(String(localized: "Continue")) { + submit() + } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + .disabled(selectedCandidate == nil) + } + } + + private var selectedCandidate: ScannedConnectionCandidate? { + guard case .results(let candidates) = step, let selection else { + return nil + } + return candidates.first { $0.id == selection } + } + + private func centered(@ViewBuilder _ content: () -> Content) -> some View { + VStack(spacing: 10) { + Spacer() + content() + Spacer() + } + .frame(maxWidth: .infinity) + } + + private func submit() { + guard let candidate = selectedCandidate else { + return + } + dismiss() + onSelect(candidate.parsedURL) + } + + private func runScan() async { + step = .scanning + selection = nil + let url = rootURL + let result = await Task.detached(priority: .userInitiated) { + ProjectFolderScanner.scan(rootURL: url) + }.value + switch result { + case .success(let scan): + step = scan.candidates.isEmpty ? .empty : .results(scan.candidates) + selection = scan.candidates.first?.id + case .failure(let error): + step = .error(error.localizedDescription) + } + } +} diff --git a/TablePro/Views/Connection/WelcomeActionsPanel.swift b/TablePro/Views/Connection/WelcomeActionsPanel.swift index e65285b49..e63096b97 100644 --- a/TablePro/Views/Connection/WelcomeActionsPanel.swift +++ b/TablePro/Views/Connection/WelcomeActionsPanel.swift @@ -9,6 +9,7 @@ struct WelcomeActionsPanel: View { let onActivateLicense: () -> Void let onCreateConnection: () -> Void let onImportFromApp: () -> Void + let onOpenProjectFolder: () -> Void let onTrySample: () -> Void private let updaterBridge = UpdaterBridge.shared @@ -51,6 +52,13 @@ struct WelcomeActionsPanel: View { .buttonStyle(.bordered) .controlSize(.large) + Button(action: onOpenProjectFolder) { + Label(String(localized: "Open Project Folder..."), systemImage: "folder.badge.gearshape") + .frame(maxWidth: .infinity, alignment: .center) + } + .buttonStyle(.bordered) + .controlSize(.large) + Button(action: onTrySample) { Label(String(localized: "Try Sample Database"), systemImage: "cylinder.split.1x2") .frame(maxWidth: .infinity, alignment: .center) diff --git a/TablePro/Views/Connection/WelcomeWindowView.swift b/TablePro/Views/Connection/WelcomeWindowView.swift index 5aee3c1ac..f4f5e546e 100644 --- a/TablePro/Views/Connection/WelcomeWindowView.swift +++ b/TablePro/Views/Connection/WelcomeWindowView.swift @@ -109,6 +109,18 @@ struct WelcomeWindowView: View { vm.pendingImportResultCount = count vm.activeSheet = nil } + case .projectFolderScan(let url): + ProjectFolderScanSheet( + rootURL: url, + onSelect: { parsed in + vm.activeSheet = nil + WindowOpener.shared.openConnectionFormFromURL(parsed) + }, + onChooseAnotherFolder: { + vm.activeSheet = nil + vm.openProjectFolder() + } + ) case .deeplinkImport(let exportable): DeeplinkImportSheet(connection: exportable) { vm.loadConnections() @@ -205,6 +217,7 @@ struct WelcomeWindowView: View { onActivateLicense: { vm.activeSheet = .activation }, onCreateConnection: { WindowOpener.shared.openConnectionForm() }, onImportFromApp: { vm.importConnectionsFromApp() }, + onOpenProjectFolder: { vm.openProjectFolder() }, onTrySample: { vm.openSampleDatabase() } ) .frame(width: 240) diff --git a/TablePro/Views/ConnectionForm/Components/ImportFromURLSheet.swift b/TablePro/Views/ConnectionForm/Components/ImportFromURLSheet.swift index aa32c2194..33da7e975 100644 --- a/TablePro/Views/ConnectionForm/Components/ImportFromURLSheet.swift +++ b/TablePro/Views/ConnectionForm/Components/ImportFromURLSheet.swift @@ -38,7 +38,7 @@ struct ImportFromURLSheet: View { .font(.caption) .foregroundStyle(.orange) } else if let parsed = parsedURL { - previewView(parsed) + ParsedConnectionSummaryView(parsed: parsed) } Spacer(minLength: 0) @@ -95,68 +95,4 @@ struct ImportFromURLSheet: View { firstLine.contains("://") else { return } urlString = firstLine.trimmingCharacters(in: .whitespacesAndNewlines) } - - private func previewView(_ parsed: ParsedConnectionURL) -> some View { - let snapshot = PluginMetadataRegistry.shared.snapshot(forTypeId: parsed.type.rawValue) - let mode = snapshot?.connectionMode ?? .network - - return VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 6) { - Image(parsed.type.iconName) - .resizable() - .frame(width: 16, height: 16) - Text(snapshot?.displayName ?? parsed.type.rawValue) - .font(.caption) - .fontWeight(.medium) - } - - switch mode { - case .fileBased: - if !parsed.database.isEmpty { - previewRow(String(localized: "Path"), parsed.database) - } - case .apiOnly: - if !parsed.host.isEmpty { - previewRow(String(localized: "Host"), parsed.host) - } - case .network: - if let multiHost = parsed.multiHost, multiHost.contains(",") { - previewRow(String(localized: "Hosts"), multiHost) - } else if !parsed.host.isEmpty { - let portStr = parsed.port.map { ":\($0)" } ?? "" - previewRow(String(localized: "Host"), parsed.host + portStr) - } - if !parsed.username.isEmpty { - previewRow(String(localized: "User"), parsed.username) - } - if !parsed.database.isEmpty { - previewRow(String(localized: "Database"), parsed.database) - } - if let svc = parsed.oracleServiceName, !svc.isEmpty { - previewRow(String(localized: "Service"), svc) - } - if let sshHost = parsed.sshHost { - previewRow("SSH", sshHost) - } - } - } - .padding(8) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color(nsColor: .controlBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 6)) - } - - private func previewRow(_ label: String, _ value: String) -> some View { - HStack(spacing: 4) { - Text(label) - .font(.caption2) - .foregroundStyle(.tertiary) - .frame(width: 58, alignment: .trailing) - Text(value) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - } - } } diff --git a/TablePro/Views/ConnectionForm/Components/ParsedConnectionSummaryView.swift b/TablePro/Views/ConnectionForm/Components/ParsedConnectionSummaryView.swift new file mode 100644 index 000000000..443b15ff9 --- /dev/null +++ b/TablePro/Views/ConnectionForm/Components/ParsedConnectionSummaryView.swift @@ -0,0 +1,93 @@ +// +// ParsedConnectionSummaryView.swift +// TablePro +// + +import SwiftUI +import TableProPluginKit + +struct ParsedConnectionSummaryView: View { + let parsed: ParsedConnectionURL + var showsBackground = true + + var body: some View { + let snapshot = PluginMetadataRegistry.shared.snapshot(forTypeId: parsed.type.rawValue) + let mode = snapshot?.connectionMode ?? .network + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Image(parsed.type.iconName) + .resizable() + .frame(width: 16, height: 16) + Text(snapshot?.displayName ?? parsed.type.rawValue) + .font(.caption) + .fontWeight(.medium) + } + + switch mode { + case .fileBased: + if !parsed.database.isEmpty { + ParsedConnectionSummaryRow(label: String(localized: "Path"), value: parsed.database) + } + case .apiOnly: + if !parsed.host.isEmpty { + ParsedConnectionSummaryRow(label: String(localized: "Host"), value: parsed.host) + } + case .network: + networkRows + } + } + .padding(showsBackground ? 8 : 0) + .frame(maxWidth: .infinity, alignment: .leading) + .background(background) + } + + @ViewBuilder + private var networkRows: some View { + if let multiHost = parsed.multiHost, multiHost.contains(",") { + ParsedConnectionSummaryRow(label: String(localized: "Hosts"), value: multiHost) + } else if !parsed.host.isEmpty { + let portSuffix = parsed.port.map { ":\($0)" } ?? "" + ParsedConnectionSummaryRow(label: String(localized: "Host"), value: parsed.host + portSuffix) + } + if !parsed.username.isEmpty { + ParsedConnectionSummaryRow(label: String(localized: "User"), value: parsed.username) + } + if !parsed.database.isEmpty { + ParsedConnectionSummaryRow(label: String(localized: "Database"), value: parsed.database) + } + if let service = parsed.oracleServiceName, !service.isEmpty { + ParsedConnectionSummaryRow(label: String(localized: "Service"), value: service) + } + if let sshHost = parsed.sshHost { + ParsedConnectionSummaryRow(label: "SSH", value: sshHost) + } + } + + @ViewBuilder + private var background: some View { + if showsBackground { + RoundedRectangle(cornerRadius: 6) + .fill(Color(nsColor: .controlBackgroundColor)) + } + } +} + +struct ParsedConnectionSummaryRow: View { + let label: String + let value: String + + var body: some View { + HStack(spacing: 4) { + Text(label) + .font(.caption2) + .foregroundStyle(.tertiary) + .frame(width: 58, alignment: .trailing) + Text(value) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } +} diff --git a/TableProTests/Core/Services/ProjectImport/DotenvConnectionExtractorTests.swift b/TableProTests/Core/Services/ProjectImport/DotenvConnectionExtractorTests.swift new file mode 100644 index 000000000..4024feeee --- /dev/null +++ b/TableProTests/Core/Services/ProjectImport/DotenvConnectionExtractorTests.swift @@ -0,0 +1,171 @@ +// +// DotenvConnectionExtractorTests.swift +// TableProTests +// + +import Foundation +import Testing +@testable import TablePro + +@Suite("Dotenv Connection Extractor") +struct DotenvConnectionExtractorTests { + private let directory = URL(fileURLWithPath: "/tmp/project") + + private func extract(_ source: String, tier: ScannedConnectionTier = .nearCertain) -> [ScannedConnectionCandidate] { + let document = DotenvParser.parse(source, processEnvironment: [:]) + return DotenvConnectionExtractor.extract( + document: document, + relativePath: ".env", + tier: tier, + directoryURL: directory + ) + } + + @Test("A DATABASE_URL becomes a candidate") + func testDatabaseURL() { + let candidates = extract("DATABASE_URL=postgresql://admin:secret@db.example.com:5432/mydb") + #expect(candidates.count == 1) + #expect(candidates.first?.parsedURL.type == .postgresql) + #expect(candidates.first?.parsedURL.host == "db.example.com") + #expect(candidates.first?.parsedURL.database == "mydb") + #expect(candidates.first?.parsedURL.username == "admin") + #expect(candidates.first?.hasPassword == true) + } + + @Test("The direct URL is preferred over the pooled one") + func testUnpooledPreferred() { + let source = """ + DATABASE_URL=postgresql://u:p@pooled.example.com:6543/app + DATABASE_URL_UNPOOLED=postgresql://u:p@direct.example.com:5432/app + """ + #expect(extract(source).first?.parsedURL.host == "direct.example.com") + } + + @Test("A raw at sign in the password still parses") + func testRawAtSignInPassword() { + let candidates = extract("DATABASE_URL=postgresql://admin:p@ssw0rd@db.example.com:5432/mydb") + #expect(candidates.first?.parsedURL.host == "db.example.com") + #expect(candidates.first?.parsedURL.password == "p@ssw0rd") + } + + @Test("Unresolved indirection produces no candidate") + func testIndirectionSkipped() { + #expect(extract("DATABASE_URL=${{Postgres.DATABASE_URL}}").isEmpty) + } + + @Test("A Prisma placeholder is offered but flagged") + func testPrismaPlaceholderFlagged() { + let source = "DATABASE_URL=postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public" + let candidate = extract(source).first + #expect(candidate != nil) + #expect(candidate?.placeholderSuspected == true) + } + + @Test("A Symfony placeholder is offered but flagged") + func testSymfonyPlaceholderFlagged() { + let source = "DATABASE_URL=postgresql://app:!ChangeMe!@127.0.0.1:5432/app" + #expect(extract(source).first?.placeholderSuspected == true) + } + + @Test("Laravel discrete keys map the driver name to a database type") + func testLaravelDiscreteKeys() { + let source = """ + DB_CONNECTION=pgsql + DB_HOST=127.0.0.1 + DB_PORT=5432 + DB_DATABASE=laravel + DB_USERNAME=sail + DB_PASSWORD=password + """ + let candidate = extract(source).first + #expect(candidate?.parsedURL.type == .postgresql) + #expect(candidate?.parsedURL.database == "laravel") + #expect(candidate?.parsedURL.username == "sail") + } + + @Test("Laravel sqlserver maps to SQL Server") + func testLaravelSqlsrv() { + #expect(extract("DB_CONNECTION=sqlsrv\nDB_HOST=localhost").first?.parsedURL.type == .mssql) + } + + @Test("A relative Laravel SQLite path resolves against the env file directory") + func testLaravelSQLiteRelativePath() { + let source = """ + DB_CONNECTION=sqlite + DB_DATABASE=database/database.sqlite + """ + let candidate = extract(source).first + #expect(candidate?.parsedURL.type == .sqlite) + #expect(candidate?.parsedURL.database == "/tmp/project/database/database.sqlite") + } + + @Test("Docker Postgres variables assume an address and say so") + func testDockerPostgresAssumedAddress() { + let source = """ + POSTGRES_USER=appuser + POSTGRES_PASSWORD=apppass + POSTGRES_DB=appdb + """ + let candidate = extract(source).first + #expect(candidate?.parsedURL.type == .postgresql) + #expect(candidate?.parsedURL.host == "127.0.0.1") + #expect(candidate?.parsedURL.port == 5432) + #expect(candidate?.warnings.isEmpty == false) + } + + @Test("MariaDB variables win over MySQL ones and yield a single candidate") + func testMariaDBPreferred() { + let source = """ + MARIADB_DATABASE=appdb + MARIADB_USER=appuser + MARIADB_PASSWORD=apppass + MYSQL_DATABASE=ignored + """ + let candidates = extract(source) + #expect(candidates.count == 1) + #expect(candidates.first?.parsedURL.type == .mariadb) + #expect(candidates.first?.parsedURL.database == "appdb") + } + + @Test("A container service hostname is flagged as unreachable") + func testServiceHostnameWarning() { + let source = """ + DB_CONNECTION=mysql + DB_HOST=mysql + DB_DATABASE=app + """ + #expect(extract(source).first?.warnings.isEmpty == false) + } + + @Test("Separate URLs for different engines each produce a candidate") + func testMultipleEngines() { + let source = """ + DATABASE_URL=postgresql://u:p@localhost:5432/app + REDIS_URL=redis://localhost:6379/0 + MONGODB_URI=mongodb://u:p@localhost:27017/app + """ + let types = extract(source).map(\.parsedURL.type) + #expect(types.contains(.postgresql)) + #expect(types.contains(.redis)) + #expect(types.contains(.mongodb)) + } + + @Test("A production named file raises the safe mode level") + func testProductionRaisesSafeMode() { + let document = DotenvParser.parse("DATABASE_URL=postgresql://u:p@db.example.com:5432/app", processEnvironment: [:]) + let candidates = DotenvConnectionExtractor.extract( + document: document, + relativePath: ".env.production", + tier: .likelyReal, + directoryURL: directory + ) + #expect(candidates.first?.parsedURL.safeModeLevel == 1) + } + + @Test("A JDBC prefixed URL is parsed after the prefix is dropped") + func testJdbcPrefixStripped() { + let candidate = extract("JDBC_DATABASE_URL=jdbc:postgresql://localhost:5432/app").first + #expect(candidate?.parsedURL.type == .postgresql) + #expect(candidate?.parsedURL.database == "app") + } +} diff --git a/TableProTests/Core/Services/ProjectImport/DotenvParserTests.swift b/TableProTests/Core/Services/ProjectImport/DotenvParserTests.swift new file mode 100644 index 000000000..864b1bbff --- /dev/null +++ b/TableProTests/Core/Services/ProjectImport/DotenvParserTests.swift @@ -0,0 +1,172 @@ +// +// DotenvParserTests.swift +// TableProTests +// + +import Foundation +import Testing +@testable import TablePro + +@Suite("Dotenv Parser") +struct DotenvParserTests { + + private func value(_ source: String, _ key: String, env: [String: String] = [:]) -> String? { + DotenvParser.parse(source, processEnvironment: env).entry(for: key)?.value + } + + // MARK: - Inline comments + + @Test("A hash with no preceding space stays in the value") + func testHashWithoutSpaceIsValue() { + #expect(value("K=bar#baz", "K") == "bar#baz") + } + + @Test("A hash after whitespace starts a comment") + func testHashAfterWhitespaceIsComment() { + #expect(value("K=bar #baz", "K") == "bar") + #expect(value("K=bar # baz # other", "K") == "bar") + } + + @Test("A value may start with a hash") + func testValueStartingWithHash() { + #expect(value("K=#c", "K") == "#c") + } + + @Test("A password containing a hash survives") + func testPasswordWithHash() { + #expect(value("DB_PASSWORD=p#ssw0rd", "DB_PASSWORD") == "p#ssw0rd") + } + + // MARK: - Quoting and escapes + + @Test("Double quotes decode the supported escapes") + func testDoubleQuoteEscapes() { + #expect(value(#"K="line1\nline2""#, "K") == "line1\nline2") + #expect(value(#"K="a\tb""#, "K") == "a\tb") + #expect(value(#"K="say \"hi\"""#, "K") == "say \"hi\"") + #expect(value(#"K="a\\b""#, "K") == "a\\b") + } + + @Test("An unknown escape keeps its backslash") + func testUnknownEscapePreserved() { + #expect(value(#"K="a\qb""#, "K") == #"a\qb"#) + } + + @Test("Single quotes are literal and block interpolation") + func testSingleQuotesAreLiteral() { + #expect(value(#"K='raw\nstring'"#, "K") == #"raw\nstring"#) + #expect(value("A=1\nK='${A}'", "K") == "${A}") + } + + @Test("A quoted value may span lines") + func testMultilineQuotedValue() { + let source = "K=\"line1\nline2\"\nJ=after" + #expect(value(source, "K") == "line1\nline2") + #expect(value(source, "J") == "after") + } + + @Test("A comment after a quoted value is ignored") + func testTrailingCommentAfterQuotedValue() { + #expect(value(#"K="value" # note"#, "K") == "value") + } + + // MARK: - Keys and structure + + @Test("The export prefix is stripped") + func testExportPrefix() { + #expect(value("export K=value", "K") == "value") + #expect(value("export\tK=value", "K") == "value") + } + + @Test("Keys may contain dots and dashes") + func testKeyCharacterSet() { + #expect(value("MY-KEY=value", "MY-KEY") == "value") + #expect(value("my.key=value", "my.key") == "value") + } + + @Test("A byte order mark is stripped") + func testByteOrderMark() { + #expect(value("\u{FEFF}K=value", "K") == "value") + } + + @Test("CRLF and lone CR are normalized") + func testLineEndings() { + #expect(value("K=value\r\nJ=other", "K") == "value") + #expect(value("K=value\rJ=other", "J") == "other") + } + + @Test("The last duplicate key wins") + func testDuplicateKeyLastWins() { + #expect(value("K=first\nK=second", "K") == "second") + } + + @Test("Whitespace around the equals sign is ignored") + func testWhitespaceAroundEquals() { + #expect(value("K = value", "K") == "value") + } + + @Test("An empty value parses as empty") + func testEmptyValue() { + #expect(value("K=", "K") == "") + } + + @Test("A full line comment is skipped") + func testFullLineComment() { + #expect(value("# K=nope\nK=yes", "K") == "yes") + } + + @Test("One malformed line does not abort the file") + func testMalformedLineRecovery() { + #expect(value("not a pair\nK=value", "K") == "value") + } + + // MARK: - Interpolation + + @Test("References resolve against earlier keys and the process environment") + func testInterpolationSources() { + #expect(value("A=one\nK=${A}-two", "K") == "one-two") + #expect(value("A=one\nK=$A-two", "K") == "one-two") + #expect(value("K=${FROM_ENV}", "K", env: ["FROM_ENV": "outside"]) == "outside") + } + + @Test("A reference default is used when the name is missing") + func testInterpolationDefault() { + #expect(value("K=${MISSING:-fallback}", "K") == "fallback") + } + + @Test("An unresolved reference is flagged and kept literal") + func testUnresolvedReferenceFlagged() { + let document = DotenvParser.parse("K=${NOPE}", processEnvironment: [:]) + #expect(document.entry(for: "K")?.hasUnresolvedReference == true) + #expect(document.entry(for: "K")?.value == "${NOPE}") + #expect(document["K"] == nil) + } + + @Test("A resolved reference is not flagged") + func testResolvedReferenceNotFlagged() { + let document = DotenvParser.parse("A=1\nK=${A}", processEnvironment: [:]) + #expect(document.entry(for: "K")?.hasUnresolvedReference == false) + } + + @Test("Railway style indirection is flagged, not guessed") + func testRailwayIndirectionFlagged() { + let document = DotenvParser.parse("K=${{Postgres.DATABASE_URL}}", processEnvironment: [:]) + #expect(document.entry(for: "K")?.hasUnresolvedReference == true) + #expect(document["K"] == nil) + } + + // MARK: - Security + + @Test("Command substitution is never executed") + func testCommandSubstitutionNotExecuted() { + #expect(value("K=$(whoami)", "K") == "$(whoami)") + #expect(value(#"K="$(whoami)""#, "K") == "$(whoami)") + #expect(value("K=`whoami`", "K") == "`whoami`") + } + + @Test("A URL password containing an at sign is preserved verbatim") + func testAtSignInValue() { + let source = "DATABASE_URL=postgres://u:p@ss@host/db" + #expect(value(source, "DATABASE_URL") == "postgres://u:p@ss@host/db") + } +} diff --git a/TableProTests/Core/Services/ProjectImport/ProjectConfigExtractorTests.swift b/TableProTests/Core/Services/ProjectImport/ProjectConfigExtractorTests.swift new file mode 100644 index 000000000..599483778 --- /dev/null +++ b/TableProTests/Core/Services/ProjectImport/ProjectConfigExtractorTests.swift @@ -0,0 +1,282 @@ +// +// ProjectConfigExtractorTests.swift +// TableProTests +// + +import Foundation +import Testing +@testable import TablePro + +@Suite("Scanned URL Normalizer") +struct ScannedURLNormalizerTests { + + @Test("A raw at sign in the password is encoded using the last separator") + func testRawAtSignEncoded() { + let normalized = ScannedURLNormalizer.normalize("postgresql://user:p@ss@host:5432/db") + #expect(normalized == "postgresql://user:p%40ss@host:5432/db") + } + + @Test("Already encoded credentials are left alone") + func testIdempotent() { + let encoded = "postgresql://user:p%40ss@host:5432/db" + #expect(ScannedURLNormalizer.normalize(encoded) == encoded) + } + + @Test("A URL without credentials is untouched") + func testNoUserInfo() { + let plain = "postgresql://host:5432/db" + #expect(ScannedURLNormalizer.normalize(plain) == plain) + } + + @Test("An at sign in the query string is not mistaken for a separator") + func testAtSignInQueryIgnored() { + let value = "postgresql://host:5432/db?options=a@b" + #expect(ScannedURLNormalizer.normalize(value) == value) + } + + @Test("A slash in the password does not truncate the authority") + func testSlashInPassword() { + let normalized = ScannedURLNormalizer.normalize("postgresql://user:p/ss@host:5432/db") + #expect(normalized == "postgresql://user:p%2Fss@host:5432/db") + } +} + +@Suite("WordPress Config Extractor") +struct WordPressConfigExtractorTests { + + @Test("Standard define calls produce a MySQL candidate") + func testStandardDefines() { + let contents = """ + ScannedConnectionCandidate? { + AppSettingsJsonExtractor.candidate( + name: "Default", + connectionString: connectionString, + relativePath: "appsettings.json" + ) + } + + @Test("An Npgsql connection string maps to PostgreSQL") + func testNpgsql() { + let result = candidate("Host=localhost;Database=appdb;Username=appuser;Password=apppass") + #expect(result?.parsedURL.type == .postgresql) + #expect(result?.parsedURL.database == "appdb") + #expect(result?.parsedURL.username == "appuser") + } + + @Test("A SqlClient connection string maps to SQL Server") + func testSqlClient() { + let result = candidate("Data Source=localhost;Initial Catalog=appdb;User Id=sa;Password=pass") + #expect(result?.parsedURL.type == .mssql) + #expect(result?.parsedURL.database == "appdb") + #expect(result?.parsedURL.port == 1433) + } + + @Test("A MySqlConnector connection string maps to MySQL") + func testMySqlConnector() { + let result = candidate("Server=localhost;Database=appdb;Uid=appuser;Pwd=apppass") + #expect(result?.parsedURL.type == .mysql) + #expect(result?.parsedURL.username == "appuser") + } + + @Test("A Server key alone is not misread as PostgreSQL") + func testAmbiguousStringRejected() { + #expect(candidate("Server=localhost;Database=appdb") == nil) + } + + @Test("Connection strings are read from the ConnectionStrings object") + func testFullDocument() throws { + let json = """ + { + "ConnectionStrings": { + "Default": "Host=localhost;Database=appdb;Username=appuser;Password=apppass" + } + } + """ + let data = try #require(json.data(using: .utf8)) + let candidates = AppSettingsJsonExtractor.extract(data: data, relativePath: "appsettings.json") + #expect(candidates.count == 1) + #expect(candidates.first?.parsedURL.type == .postgresql) + } +} diff --git a/TableProTests/Core/Services/ProjectImport/ProjectConfigFileMatcherTests.swift b/TableProTests/Core/Services/ProjectImport/ProjectConfigFileMatcherTests.swift new file mode 100644 index 000000000..a7fb2134e --- /dev/null +++ b/TableProTests/Core/Services/ProjectImport/ProjectConfigFileMatcherTests.swift @@ -0,0 +1,71 @@ +// +// ProjectConfigFileMatcherTests.swift +// TableProTests +// + +import Foundation +import Testing +@testable import TablePro + +@Suite("Project Config File Matcher") +struct ProjectConfigFileMatcherTests { + + @Test("Real dotenv files are classified with their tier") + func testDotenvTiers() { + #expect(ProjectConfigFileMatcher.classify(relativePath: ".env")?.tier == .nearCertain) + #expect(ProjectConfigFileMatcher.classify(relativePath: ".env.local")?.tier == .nearCertain) + #expect(ProjectConfigFileMatcher.classify(relativePath: ".env.production.local")?.tier == .nearCertain) + #expect(ProjectConfigFileMatcher.classify(relativePath: ".env.production")?.tier == .likelyReal) + #expect(ProjectConfigFileMatcher.classify(relativePath: ".env.test")?.tier == .lowValue) + } + + @Test("Placeholder dotenv files are excluded") + func testPlaceholderFilesExcluded() { + #expect(ProjectConfigFileMatcher.classify(relativePath: ".env.example") == nil) + #expect(ProjectConfigFileMatcher.classify(relativePath: ".env.sample") == nil) + #expect(ProjectConfigFileMatcher.classify(relativePath: ".env.template") == nil) + #expect(ProjectConfigFileMatcher.classify(relativePath: ".env.dist") == nil) + #expect(ProjectConfigFileMatcher.classify(relativePath: ".env.schema") == nil) + } + + @Test("A direnv script is never classified, even though it starts with .env") + func testEnvrcExcluded() { + #expect(ProjectConfigFileMatcher.classify(relativePath: ".envrc") == nil) + } + + @Test("Backup files are excluded") + func testBackupExcluded() { + #expect(ProjectConfigFileMatcher.classify(relativePath: ".env.bak") == nil) + } + + @Test("Suffix form dotenv names are matched, not only the prefix form") + func testSuffixFormMatched() { + #expect(ProjectConfigFileMatcher.classify(relativePath: "production.env")?.kind == .dotenv) + #expect(ProjectConfigFileMatcher.classify(relativePath: "config/db.env")?.kind == .dotenv) + } + + @Test("Framework config files are classified by name and location") + func testFrameworkConfigs() { + #expect(ProjectConfigFileMatcher.classify(relativePath: "wp-config.php")?.kind == .wordPressConfig) + #expect(ProjectConfigFileMatcher.classify(relativePath: "prisma/schema.prisma")?.kind == .prismaSchema) + #expect(ProjectConfigFileMatcher.classify(relativePath: "config/database.yml")?.kind == .railsDatabaseYaml) + #expect(ProjectConfigFileMatcher.classify( + relativePath: "src/main/resources/application.properties" + )?.kind == .springProperties) + #expect(ProjectConfigFileMatcher.classify(relativePath: "application-dev.yml")?.kind == .springYaml) + #expect(ProjectConfigFileMatcher.classify(relativePath: "appsettings.Development.json")?.kind == .appSettingsJson) + #expect(ProjectConfigFileMatcher.classify(relativePath: "docker-compose.yml")?.kind == .dockerCompose) + #expect(ProjectConfigFileMatcher.classify(relativePath: "compose.yaml")?.kind == .dockerCompose) + } + + @Test("A database.yml outside config is not a Rails database file") + func testDatabaseYamlNeedsConfigParent() { + #expect(ProjectConfigFileMatcher.classify(relativePath: "database.yml") == nil) + } + + @Test("Unrelated files are not classified") + func testUnrelatedFiles() { + #expect(ProjectConfigFileMatcher.classify(relativePath: "README.md") == nil) + #expect(ProjectConfigFileMatcher.classify(relativePath: "src/index.ts") == nil) + } +} diff --git a/TableProTests/Core/Services/ProjectImport/ProjectFolderFileWalkerTests.swift b/TableProTests/Core/Services/ProjectImport/ProjectFolderFileWalkerTests.swift new file mode 100644 index 000000000..b1d551ac9 --- /dev/null +++ b/TableProTests/Core/Services/ProjectImport/ProjectFolderFileWalkerTests.swift @@ -0,0 +1,106 @@ +// +// ProjectFolderFileWalkerTests.swift +// TableProTests +// + +import Foundation +import Testing +@testable import TablePro + +@Suite("Project Folder File Walker") +struct ProjectFolderFileWalkerTests { + private let root: URL + private let outside: URL + + init() throws { + let base = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("ProjectWalkerTests-\(UUID().uuidString)") + root = base.appendingPathComponent("project") + outside = base.appendingPathComponent("outside") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: outside, withIntermediateDirectories: true) + } + + private func write(_ contents: String, to relativePath: String) throws { + let url = root.appendingPathComponent(relativePath) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try contents.write(to: url, atomically: true, encoding: .utf8) + } + + private func walk() -> [String] { + ProjectFolderFileWalker.walk(root: root).map(\.relativePath) + } + + @Test("A dotenv file at the project root is found") + func testFindsRootDotenv() throws { + try write("DATABASE_URL=postgres://localhost/app", to: ".env") + #expect(walk().contains(".env")) + } + + @Test("Excluded directories are never descended into") + func testExcludedDirectories() throws { + try write("DATABASE_URL=postgres://localhost/app", to: ".env") + try write("DATABASE_URL=postgres://localhost/pkg", to: "node_modules/pkg/.env") + try write("DATABASE_URL=postgres://localhost/vendored", to: "vendor/thing/.env") + let found = walk() + #expect(found == [".env"]) + } + + @Test("A dotenv symlink pointing outside the project is never read") + func testSymlinkedFileRejected() throws { + let target = outside.appendingPathComponent("secret.txt") + try "SECRET=value".write(to: target, atomically: true, encoding: .utf8) + try FileManager.default.createSymbolicLink( + at: root.appendingPathComponent(".env"), + withDestinationURL: target + ) + #expect(walk().isEmpty) + } + + @Test("A symlinked directory is not descended into") + func testSymlinkedDirectoryNotFollowed() throws { + let nested = outside.appendingPathComponent("hidden") + try FileManager.default.createDirectory(at: nested, withIntermediateDirectories: true) + try "DATABASE_URL=postgres://localhost/x" + .write(to: nested.appendingPathComponent(".env"), atomically: true, encoding: .utf8) + try FileManager.default.createSymbolicLink( + at: root.appendingPathComponent("linked"), + withDestinationURL: nested + ) + #expect(walk().isEmpty) + } + + @Test("Files over the size cap are skipped") + func testFileSizeCap() throws { + let oversized = String(repeating: "A", count: ProjectFolderFileWalker.maxFileSize + 1024) + try write(oversized, to: ".env") + #expect(walk().isEmpty) + } + + @Test("Files deeper than the depth cap are skipped") + func testDepthCap() throws { + try write("DATABASE_URL=postgres://localhost/shallow", to: "a/.env") + try write("DATABASE_URL=postgres://localhost/deep", to: "a/b/c/d/e/.env") + let found = walk() + #expect(found.contains("a/.env")) + #expect(!found.contains("a/b/c/d/e/.env")) + } + + @Test("Placeholder dotenv files never reach the scanner") + func testPlaceholderFileSkipped() throws { + try write("DATABASE_URL=postgres://user:pass@localhost/app", to: ".env.example") + #expect(walk().isEmpty) + } + + @Test("Sensitive home directories are denied by absolute path") + func testDenyList() { + let home = FileManager.default.homeDirectoryForCurrentUser.standardizedFileURL.path + #expect(ProjectFolderFileWalker.isDenied(home + "/.ssh/config")) + #expect(ProjectFolderFileWalker.isDenied(home + "/.aws/credentials")) + #expect(ProjectFolderFileWalker.isDenied("/etc/passwd")) + #expect(!ProjectFolderFileWalker.isDenied(home + "/Developer/project/.env")) + } +} diff --git a/TableProTests/Core/Services/ProjectImport/ProjectFolderScannerTests.swift b/TableProTests/Core/Services/ProjectImport/ProjectFolderScannerTests.swift new file mode 100644 index 000000000..d0f515870 --- /dev/null +++ b/TableProTests/Core/Services/ProjectImport/ProjectFolderScannerTests.swift @@ -0,0 +1,149 @@ +// +// ProjectFolderScannerTests.swift +// TableProTests +// + +import Foundation +import Testing +@testable import TablePro + +@Suite("Project Folder Scanner") +struct ProjectFolderScannerTests { + private let root: URL + + init() throws { + root = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("ProjectScannerTests-\(UUID().uuidString)") + .appendingPathComponent("my-project") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + } + + private func write(_ contents: String, to relativePath: String) throws { + let url = root.appendingPathComponent(relativePath) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try contents.write(to: url, atomically: true, encoding: .utf8) + } + + private func scan() throws -> ProjectFolderScanResult { + switch ProjectFolderScanner.scan(rootURL: root) { + case .success(let result): + return result + case .failure(let error): + throw error + } + } + + @Test("A mixed project yields one candidate per distinct database") + func testMixedProject() throws { + try write("DATABASE_URL=postgresql://appuser:apppass@localhost:5432/appdb", to: ".env") + try write("REDIS_URL=redis://localhost:6379/0", to: ".env") + try write("DATABASE_URL=postgresql://johndoe:randompassword@localhost:5432/leaked", to: ".env.example") + try write("DATABASE_URL=postgresql://u:p@localhost:5432/vendored", to: "node_modules/pkg/.env") + try write( + """ + [ScannedConnectionCandidate] { + RailsDatabaseYamlExtractor.extract( + contents: contents, + relativePath: "config/database.yml", + projectRootURL: root, + environment: environment + ) + } + + @Test("Anchors and merge keys are expanded before reading the environment") + func testAnchorsAndMergeKeys() { + let contents = """ + default: &default + adapter: postgresql + encoding: unicode + host: db.example.com + username: railsuser + password: railspass + + development: + <<: *default + database: myapp_development + + test: + <<: *default + database: myapp_test + """ + let candidate = extract(contents).first + #expect(candidate?.parsedURL.type == .postgresql) + #expect(candidate?.parsedURL.database == "myapp_development") + #expect(candidate?.parsedURL.host == "db.example.com") + #expect(candidate?.parsedURL.username == "railsuser") + } + + @Test("An ERB environment lookup resolves from the project dotenv") + func testErbResolvedFromDotenv() { + let contents = """ + development: + adapter: postgresql + database: myapp + host: <%= ENV["DB_HOST"] %> + """ + let environment = DotenvParser.parse("DB_HOST=resolved.example.com", processEnvironment: [:]) + #expect(extract(contents, environment: environment).first?.parsedURL.host == "resolved.example.com") + } + + @Test("An ERB fetch default is used when the variable is missing") + func testErbFetchDefault() { + let contents = """ + development: + adapter: postgresql + database: myapp + host: <%= ENV.fetch("DB_HOST", "localhost") %> + """ + #expect(extract(contents).first?.parsedURL.host == "localhost") + } + + @Test("An unresolvable ERB value is reported as a warning, not guessed") + func testUnresolvedErbWarns() { + let contents = """ + development: + adapter: postgresql + database: myapp + password: <%= Rails.application.credentials.db_password %> + """ + let candidate = extract(contents).first + #expect(candidate?.parsedURL.password.isEmpty == true) + #expect(candidate?.warnings.isEmpty == false) + } + + @Test("A multi database development section picks the nested entry") + func testMultiDatabaseSection() { + let contents = """ + development: + primary: + adapter: postgresql + database: myapp_development + host: localhost + """ + #expect(extract(contents).first?.parsedURL.database == "myapp_development") + } + + @Test("A sqlite3 adapter resolves its path against the project root") + func testSQLitePath() { + let contents = """ + development: + adapter: sqlite3 + database: storage/development.sqlite3 + """ + let candidate = extract(contents).first + #expect(candidate?.parsedURL.type == .sqlite) + #expect(candidate?.parsedURL.database == "/tmp/rails-project/storage/development.sqlite3") + } +} + +@Suite("Docker Compose Extractor") +struct DockerComposeExtractorTests { + + private func extract(_ contents: String, environment: DotenvDocument? = nil) -> [ScannedConnectionCandidate] { + DockerComposeExtractor.extract( + contents: contents, + relativePath: "docker-compose.yml", + environment: environment + ) + } + + @Test("A published port is used instead of the container port") + func testPublishedPort() { + let contents = """ + services: + db: + image: postgres:16 + environment: + POSTGRES_USER: appuser + POSTGRES_PASSWORD: apppass + POSTGRES_DB: appdb + ports: + - "8001:5432" + """ + let candidate = extract(contents).first + #expect(candidate?.parsedURL.type == .postgresql) + #expect(candidate?.parsedURL.host == "127.0.0.1") + #expect(candidate?.parsedURL.port == 8001) + #expect(candidate?.parsedURL.database == "appdb") + } + + @Test("A service with no published port is flagged as possibly unreachable") + func testUnpublishedPortWarns() { + let contents = """ + services: + db: + image: postgres:16 + environment: + POSTGRES_PASSWORD: apppass + """ + let candidate = extract(contents).first + #expect(candidate?.parsedURL.port == 5432) + #expect(candidate?.warnings.isEmpty == false) + } + + @Test("A list form environment block is read") + func testListFormEnvironment() { + let contents = """ + services: + db: + image: mysql:8 + environment: + - MYSQL_DATABASE=appdb + - MYSQL_USER=appuser + - MYSQL_PASSWORD=apppass + ports: + - "3307:3306" + """ + let candidate = extract(contents).first + #expect(candidate?.parsedURL.type == .mysql) + #expect(candidate?.parsedURL.database == "appdb") + #expect(candidate?.parsedURL.port == 3307) + } + + @Test("Interpolation uses the adjacent dotenv file") + func testInterpolationFromDotenv() { + let contents = """ + services: + db: + image: postgres:16 + environment: + POSTGRES_PASSWORD: ${DB_PASSWORD} + ports: + - "${DB_PORT}:5432" + """ + let environment = DotenvParser.parse("DB_PASSWORD=frompass\nDB_PORT=15432", processEnvironment: [:]) + let candidate = extract(contents, environment: environment).first + #expect(candidate?.parsedURL.port == 15432) + #expect(candidate?.hasPassword == true) + } + + @Test("An interpolation default is used when the variable is missing") + func testInterpolationDefault() { + let contents = """ + services: + db: + image: postgres:16 + environment: + POSTGRES_PASSWORD: ${DB_PASSWORD:-!ChangeMe!} + ports: + - "5433:5432" + """ + #expect(extract(contents).first?.placeholderSuspected == true) + } + + @Test("Credentials parked in an extension field anchor are still found") + func testExtensionFieldAnchor() { + let contents = """ + x-db-env: &db-env + POSTGRES_USER: appuser + POSTGRES_PASSWORD: apppass + POSTGRES_DB: appdb + + services: + db: + image: postgres:16 + environment: + <<: *db-env + ports: + - "5432:5432" + """ + let candidate = extract(contents).first + #expect(candidate?.parsedURL.username == "appuser") + #expect(candidate?.parsedURL.database == "appdb") + } + + @Test("Services that are not databases are ignored") + func testNonDatabaseServiceIgnored() { + let contents = """ + services: + web: + image: nginx:latest + ports: + - "80:80" + """ + #expect(extract(contents).isEmpty) + } + + @Test("The long form port syntax is understood") + func testLongFormPorts() { + let contents = """ + services: + db: + image: mariadb:11 + environment: + MARIADB_DATABASE: appdb + MARIADB_PASSWORD: apppass + ports: + - target: 3306 + published: 3399 + """ + let candidate = extract(contents).first + #expect(candidate?.parsedURL.type == .mariadb) + #expect(candidate?.parsedURL.port == 3399) + } +} + +@Suite("Spring YAML Extractor") +struct SpringYamlExtractorTests { + + @Test("A nested datasource block is read") + func testNestedDatasource() { + let contents = """ + spring: + datasource: + url: jdbc:postgresql://localhost:5432/appdb + username: springuser + password: springpass + """ + let candidate = SpringYamlExtractor.extract( + contents: contents, + relativePath: "application.yml", + processEnvironment: [:] + ).first + #expect(candidate?.parsedURL.type == .postgresql) + #expect(candidate?.parsedURL.database == "appdb") + #expect(candidate?.parsedURL.username == "springuser") + } + + @Test("A multi document file is searched past the first document") + func testMultiDocument() { + let contents = """ + spring: + config: + activate: + on-profile: default + --- + spring: + datasource: + url: jdbc:postgresql://second:5432/appdb + """ + let candidate = SpringYamlExtractor.extract( + contents: contents, + relativePath: "application.yml", + processEnvironment: [:] + ).first + #expect(candidate?.parsedURL.host == "second") + } +} diff --git a/docs/databases/connection-urls.mdx b/docs/databases/connection-urls.mdx index 2bd96a3f7..ef50ca92d 100644 --- a/docs/databases/connection-urls.mdx +++ b/docs/databases/connection-urls.mdx @@ -51,6 +51,8 @@ mariadb+ssh:// ## Standard Format +If the URL you want is already in a project's `.env` file, [Open Project Folder](/features/project-folder-import) reads it for you instead. + ``` scheme://[username[:password]@]host[:port][/database][?param=value&...] ``` diff --git a/docs/docs.json b/docs/docs.json index 146d8c415..eaf42a554 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -153,7 +153,8 @@ "features/safe-mode", "features/ssh-profiles", "features/ssl", - "features/connection-sharing" + "features/connection-sharing", + "features/project-folder-import" ] }, { diff --git a/docs/features/project-folder-import.mdx b/docs/features/project-folder-import.mdx new file mode 100644 index 000000000..027733ff5 --- /dev/null +++ b/docs/features/project-folder-import.mdx @@ -0,0 +1,58 @@ +--- +title: Open Project Folder +description: Create a connection from the database settings already in your project's config files +--- + +# Open Project Folder + +Point TablePro at a project folder and it reads the database settings the project already has, so you do not have to copy them out of a `.env` file by hand. + +## Open a folder + +- **File** > **Open Project Folder...** +- The **Open Project Folder...** button on the welcome screen + +Pick the folder in the panel that appears. TablePro scans it and lists what it found. + +## Pick a connection + +Each row shows the database type, host, port, user, and database, plus the file and key it came from. Select one and click **Continue**. The connection form opens filled in, and nothing is saved or connected until you review it and click **Save**. + +Passwords are never shown. A row says **Password found** when the file has one, and the value goes straight to your Keychain when you save. + +Rows can carry extra notes: + +| Note | Meaning | +|------|---------| +| Password found | The file has a password. It is not displayed. | +| Looks like a placeholder value | The value matches a known template, such as `!ChangeMe!` or `password_here`. | +| Container service name | The host is a name like `db` or `postgres`, which usually only resolves inside Docker. | +| Host and port were assumed | The file had credentials but no address, so `127.0.0.1` and the default port were used. | +| Does not publish a port | A Compose service with no `ports:` mapping, so it may not be reachable from your Mac. | + +A connection found in a file named for production starts at the **Alert** [Safe Mode](/features/safe-mode) level rather than Silent. + +## Files it reads + +| File | What it reads | +|------|---------------| +| `.env` and friends | `DATABASE_URL`, `MONGODB_URI`, `REDIS_URL`, `TURSO_DATABASE_URL` and similar URL keys; Laravel `DB_*` keys; Postgres, MySQL, MariaDB, Mongo and SQL Server container variables; `PG*` variables | +| `wp-config.php` | `DB_NAME`, `DB_USER`, `DB_PASSWORD`, `DB_HOST` | +| `prisma/schema.prisma` | The `datasource` provider and url, including `env("...")` references | +| `config/database.yml` | The Rails development section, following anchors and `<<` merge keys, and resolving `ENV["..."]` | +| `docker-compose.yml`, `compose.yaml` | Database services, using the published host port | +| `application.properties`, `application.yml` | `spring.datasource.url`, username, and password | +| `appsettings.json` | The `ConnectionStrings` entries | + +Template files are skipped, so `.env.example`, `.env.sample`, `.env.template`, and `.env.dist` never appear. `.envrc` is a shell script, so TablePro never reads or runs it. + +The scan stays inside the folder you picked. It does not follow symlinks, skips `node_modules`, `.git`, `vendor`, `dist`, `build` and similar directories, goes four levels deep, and ignores files over 1 MB. + +## When a value cannot be read + +Some settings point somewhere else instead of holding a value, like `${{Postgres.DATABASE_URL}}` on Railway or a Rails credential lookup. TablePro leaves those out rather than guessing, so a row you expected may be missing. Fill those connections in by hand, or paste the URL with [Import from URL](/databases/connection-urls). + +## Related + +- [Connection Sharing](/features/connection-sharing) for importing from other database apps +- [Safe Mode](/features/safe-mode) for guarding production connections From 01a979e6f4f7d7bf12d20f1ffa05490fb1a3242f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 25 Jul 2026 21:51:06 +0700 Subject: [PATCH 2/7] fix(connections): make project folder findings legible when selected --- .../ProjectFolderCandidateList.swift | 70 +++++++++++-------- .../ProjectFolderScanSheet.swift | 2 +- .../ParsedConnectionSummaryView.swift | 4 +- .../Components/SelectionAwareForeground.swift | 29 ++++++++ 4 files changed, 72 insertions(+), 33 deletions(-) create mode 100644 TablePro/Views/ConnectionForm/Components/SelectionAwareForeground.swift diff --git a/TablePro/Views/Connection/ProjectFolder/ProjectFolderCandidateList.swift b/TablePro/Views/Connection/ProjectFolder/ProjectFolderCandidateList.swift index c258753df..d49332c2e 100644 --- a/TablePro/Views/Connection/ProjectFolder/ProjectFolderCandidateList.swift +++ b/TablePro/Views/Connection/ProjectFolder/ProjectFolderCandidateList.swift @@ -10,50 +10,60 @@ struct ProjectFolderCandidateList: View { @Binding var selection: UUID? var body: some View { - List(candidates, id: \.id, selection: $selection) { candidate in - row(for: candidate) - .padding(.vertical, 4) - .tag(candidate.id) + List(selection: $selection) { + ForEach(candidates) { candidate in + ProjectFolderCandidateRow(candidate: candidate) + .tag(candidate.id) + } } .listStyle(.inset) - .alternatingRowBackgrounds() } +} - private func row(for candidate: ScannedConnectionCandidate) -> some View { +struct ProjectFolderCandidateRow: View { + let candidate: ScannedConnectionCandidate + + var body: some View { VStack(alignment: .leading, spacing: 4) { ParsedConnectionSummaryView(parsed: candidate.parsedURL, showsBackground: false) - HStack(spacing: 6) { - Image(systemName: "doc.text") - .font(.caption2) - .foregroundStyle(.tertiary) - Text(candidate.sourceRelativePath) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - Text(candidate.sourceKey) - .font(.caption2) - .foregroundStyle(.tertiary) - .lineLimit(1) - } + sourceLine if candidate.hasPassword { - Label(String(localized: "Password found"), systemImage: "key.fill") - .font(.caption2) - .foregroundStyle(.secondary) + badge(String(localized: "Password found"), systemImage: "key.fill", tint: .secondary) } if candidate.placeholderSuspected { - Label( + badge( String(localized: "Looks like a placeholder value"), - systemImage: "questionmark.circle" + systemImage: "questionmark.circle", + tint: .orange ) - .font(.caption2) - .foregroundStyle(.orange) } ForEach(candidate.warnings, id: \.self) { warning in - Label(warning, systemImage: "exclamationmark.triangle") - .font(.caption2) - .foregroundStyle(.orange) + badge(warning, systemImage: "exclamationmark.triangle", tint: .orange) } } + .padding(.vertical, 4) + } + + private var sourceLine: some View { + HStack(spacing: 6) { + Image(systemName: "doc.text") + .font(.caption2) + .selectionAwareForeground(Color(nsColor: .tertiaryLabelColor), emphasizedOpacity: 0.6) + Text(candidate.sourceRelativePath) + .font(.caption2) + .selectionAwareForeground(Color(nsColor: .secondaryLabelColor), emphasizedOpacity: 0.85) + .lineLimit(1) + .truncationMode(.middle) + Text(candidate.sourceKey) + .font(.caption2) + .selectionAwareForeground(Color(nsColor: .tertiaryLabelColor), emphasizedOpacity: 0.6) + .lineLimit(1) + } + } + + private func badge(_ text: String, systemImage: String, tint: Color) -> some View { + Label(text, systemImage: systemImage) + .font(.caption2) + .selectionAwareForeground(tint, emphasizedOpacity: 0.85) } } diff --git a/TablePro/Views/Connection/ProjectFolder/ProjectFolderScanSheet.swift b/TablePro/Views/Connection/ProjectFolder/ProjectFolderScanSheet.swift index 1b4601347..79cd6b4a6 100644 --- a/TablePro/Views/Connection/ProjectFolder/ProjectFolderScanSheet.swift +++ b/TablePro/Views/Connection/ProjectFolder/ProjectFolderScanSheet.swift @@ -28,7 +28,7 @@ struct ProjectFolderScanSheet: View { footer } .padding(20) - .frame(width: 560, height: 480) + .frame(width: 520, height: 440) .task(id: rootURL) { await runScan() } diff --git a/TablePro/Views/ConnectionForm/Components/ParsedConnectionSummaryView.swift b/TablePro/Views/ConnectionForm/Components/ParsedConnectionSummaryView.swift index 443b15ff9..dea3d6d0c 100644 --- a/TablePro/Views/ConnectionForm/Components/ParsedConnectionSummaryView.swift +++ b/TablePro/Views/ConnectionForm/Components/ParsedConnectionSummaryView.swift @@ -81,11 +81,11 @@ struct ParsedConnectionSummaryRow: View { HStack(spacing: 4) { Text(label) .font(.caption2) - .foregroundStyle(.tertiary) + .selectionAwareForeground(Color(nsColor: .tertiaryLabelColor), emphasizedOpacity: 0.6) .frame(width: 58, alignment: .trailing) Text(value) .font(.caption) - .foregroundStyle(.secondary) + .selectionAwareForeground(Color(nsColor: .secondaryLabelColor), emphasizedOpacity: 0.85) .lineLimit(1) .truncationMode(.middle) } diff --git a/TablePro/Views/ConnectionForm/Components/SelectionAwareForeground.swift b/TablePro/Views/ConnectionForm/Components/SelectionAwareForeground.swift new file mode 100644 index 000000000..06725f49f --- /dev/null +++ b/TablePro/Views/ConnectionForm/Components/SelectionAwareForeground.swift @@ -0,0 +1,29 @@ +// +// SelectionAwareForeground.swift +// TablePro +// + +import SwiftUI + +private struct SelectionAwareForeground: ViewModifier { + let standard: Color + let emphasizedOpacity: Double + @Environment(\.backgroundProminence) private var backgroundProminence + + func body(content: Content) -> some View { + content.foregroundStyle(resolvedStyle) + } + + private var resolvedStyle: Color { + guard backgroundProminence == .increased else { + return standard + } + return Color(nsColor: .alternateSelectedControlTextColor).opacity(emphasizedOpacity) + } +} + +extension View { + func selectionAwareForeground(_ standard: Color, emphasizedOpacity: Double = 1) -> some View { + modifier(SelectionAwareForeground(standard: standard, emphasizedOpacity: emphasizedOpacity)) + } +} From 45126ca585607f1324ba87f39f8bc192892bd15e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 25 Jul 2026 22:27:33 +0700 Subject: [PATCH 3/7] refactor(connections): rebuild the project folder list as a native macOS list --- .../DockerComposeExtractor.swift | 2 +- ...tenvConnectionExtractor+DiscreteKeys.swift | 9 +- .../RailsDatabaseYamlExtractor.swift | 2 +- .../WordPressConfigExtractor.swift | 2 +- .../ProjectFolderCandidateList.swift | 127 +++++++++++++----- .../ProjectFolderScanSheet.swift | 25 +++- .../Components/ImportFromURLSheet.swift | 66 ++++++++- .../ParsedConnectionSummaryView.swift | 93 ------------- .../Components/SelectionAwareForeground.swift | 16 +-- 9 files changed, 199 insertions(+), 143 deletions(-) delete mode 100644 TablePro/Views/ConnectionForm/Components/ParsedConnectionSummaryView.swift diff --git a/TablePro/Core/Services/ProjectImport/DockerComposeExtractor.swift b/TablePro/Core/Services/ProjectImport/DockerComposeExtractor.swift index 79942a4f5..62bc1bde3 100644 --- a/TablePro/Core/Services/ProjectImport/DockerComposeExtractor.swift +++ b/TablePro/Core/Services/ProjectImport/DockerComposeExtractor.swift @@ -49,7 +49,7 @@ enum DockerComposeExtractor { } else { fields.port = database.defaultPort warnings.append(String( - localized: "This service does not publish a port, so it may not be reachable from your Mac." + localized: "No published port, may be unreachable" )) } return ScannedConnectionCandidate( diff --git a/TablePro/Core/Services/ProjectImport/DotenvConnectionExtractor+DiscreteKeys.swift b/TablePro/Core/Services/ProjectImport/DotenvConnectionExtractor+DiscreteKeys.swift index 58a028e9c..601491682 100644 --- a/TablePro/Core/Services/ProjectImport/DotenvConnectionExtractor+DiscreteKeys.swift +++ b/TablePro/Core/Services/ProjectImport/DotenvConnectionExtractor+DiscreteKeys.swift @@ -177,14 +177,17 @@ extension DotenvConnectionExtractor { } private static var assumedAddressWarning: String { - String(localized: "Host and port were assumed. The published address is set where the container runs.") + String(localized: "Host and port assumed") } private static func serviceHostWarnings(_ host: String) -> [String] { - let serviceNames: Set = ["db", "database", "postgres", "postgresql", "mysql", "mariadb", "mongo", "mongodb", "redis", "mssql", "pgsql"] + let serviceNames: Set = [ + "db", "database", "postgres", "postgresql", "mysql", + "mariadb", "mongo", "mongodb", "redis", "mssql", "pgsql", + ] guard serviceNames.contains(host.lowercased()) else { return [] } - return [String(localized: "This looks like a container service name, which is usually not reachable from your Mac.")] + return [String(localized: "Container service name, may be unreachable")] } } diff --git a/TablePro/Core/Services/ProjectImport/RailsDatabaseYamlExtractor.swift b/TablePro/Core/Services/ProjectImport/RailsDatabaseYamlExtractor.swift index ea9e5ecc3..2f2861078 100644 --- a/TablePro/Core/Services/ProjectImport/RailsDatabaseYamlExtractor.swift +++ b/TablePro/Core/Services/ProjectImport/RailsDatabaseYamlExtractor.swift @@ -144,7 +144,7 @@ struct ErbValueResolver { } private var unresolvedWarning: String { - String(localized: "Some values come from Ruby code and could not be read. Fill them in before connecting.") + String(localized: "Some values come from Ruby code and were not read") } private func environmentVariableName(in value: String) -> String? { diff --git a/TablePro/Core/Services/ProjectImport/WordPressConfigExtractor.swift b/TablePro/Core/Services/ProjectImport/WordPressConfigExtractor.swift index 24471c4ef..48737007c 100644 --- a/TablePro/Core/Services/ProjectImport/WordPressConfigExtractor.swift +++ b/TablePro/Core/Services/ProjectImport/WordPressConfigExtractor.swift @@ -22,7 +22,7 @@ enum WordPressConfigExtractor { fields.host = address.host fields.port = address.port ?? 3_306 if address.isSocket { - warnings.append(String(localized: "The host looks like a socket path. Check it before connecting.")) + warnings.append(String(localized: "Host looks like a socket path")) } let candidate = ScannedConnectionCandidate( parsedURL: fields.toParsedConnectionURL(), diff --git a/TablePro/Views/Connection/ProjectFolder/ProjectFolderCandidateList.swift b/TablePro/Views/Connection/ProjectFolder/ProjectFolderCandidateList.swift index d49332c2e..b848130d6 100644 --- a/TablePro/Views/Connection/ProjectFolder/ProjectFolderCandidateList.swift +++ b/TablePro/Views/Connection/ProjectFolder/ProjectFolderCandidateList.swift @@ -4,66 +4,131 @@ // import SwiftUI +import TableProPluginKit struct ProjectFolderCandidateList: View { let candidates: [ScannedConnectionCandidate] + let projectRoot: URL @Binding var selection: UUID? var body: some View { List(selection: $selection) { ForEach(candidates) { candidate in - ProjectFolderCandidateRow(candidate: candidate) + ProjectFolderCandidateRow(candidate: candidate, projectRoot: projectRoot) .tag(candidate.id) } } - .listStyle(.inset) + .listStyle(.bordered) } } struct ProjectFolderCandidateRow: View { let candidate: ScannedConnectionCandidate + let projectRoot: URL var body: some View { - VStack(alignment: .leading, spacing: 4) { - ParsedConnectionSummaryView(parsed: candidate.parsedURL, showsBackground: false) - sourceLine - if candidate.hasPassword { - badge(String(localized: "Password found"), systemImage: "key.fill", tint: .secondary) + HStack(spacing: 8) { + candidate.parsedURL.type.iconImage + .frame(width: 16, height: 16) + + VStack(alignment: .leading, spacing: 1) { + Text(verbatim: destination) + .font(.body) + .lineLimit(1) + .truncationMode(.middle) + .help(helpText) + Text(verbatim: provenance) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) } + + Spacer(minLength: 8) + + accessories + } + .padding(.vertical, 2) + } + + @ViewBuilder + private var accessories: some View { + HStack(spacing: 4) { if candidate.placeholderSuspected { - badge( - String(localized: "Looks like a placeholder value"), - systemImage: "questionmark.circle", + accessory( + "questionmark.circle.fill", + label: String(localized: "Looks like a placeholder value"), tint: .orange ) } - ForEach(candidate.warnings, id: \.self) { warning in - badge(warning, systemImage: "exclamationmark.triangle", tint: .orange) + if let warning = candidate.warnings.first { + accessory("exclamationmark.triangle.fill", label: warning, tint: .orange) + } + if candidate.hasPassword { + accessory("key.fill", label: String(localized: "Password found"), tint: nil) } } - .padding(.vertical, 4) } - private var sourceLine: some View { - HStack(spacing: 6) { - Image(systemName: "doc.text") - .font(.caption2) - .selectionAwareForeground(Color(nsColor: .tertiaryLabelColor), emphasizedOpacity: 0.6) - Text(candidate.sourceRelativePath) - .font(.caption2) - .selectionAwareForeground(Color(nsColor: .secondaryLabelColor), emphasizedOpacity: 0.85) - .lineLimit(1) - .truncationMode(.middle) - Text(candidate.sourceKey) - .font(.caption2) - .selectionAwareForeground(Color(nsColor: .tertiaryLabelColor), emphasizedOpacity: 0.6) - .lineLimit(1) + @ViewBuilder + private func accessory(_ systemName: String, label: String, tint: Color?) -> some View { + let symbol = Image(systemName: systemName) + .imageScale(.medium) + .help(label) + .accessibilityLabel(label) + if let tint { + symbol.selectionAwareForeground(tint) + } else { + symbol.foregroundStyle(.secondary) } } - private func badge(_ text: String, systemImage: String, tint: Color) -> some View { - Label(text, systemImage: systemImage) - .font(.caption2) - .selectionAwareForeground(tint, emphasizedOpacity: 0.85) + private var snapshot: PluginMetadataSnapshot? { + PluginMetadataRegistry.shared.snapshot(forTypeId: candidate.parsedURL.type.rawValue) + } + + private var typeName: String { + snapshot?.displayName ?? candidate.parsedURL.type.rawValue + } + + private var destination: String { + let parsed = candidate.parsedURL + switch snapshot?.connectionMode ?? .network { + case .fileBased: + return displayPath(parsed.database) + case .apiOnly: + return parsed.host.isEmpty ? typeName : parsed.host + case .network: + if let multiHost = parsed.multiHost, multiHost.contains(",") { + return multiHost + } + guard !parsed.host.isEmpty else { + return parsed.database.isEmpty ? typeName : parsed.database + } + return parsed.port.map { "\(parsed.host):\($0)" } ?? parsed.host + } + } + + private var provenance: String { + [typeName, candidate.sourceRelativePath, candidate.sourceKey].joined(separator: " · ") + } + + private var helpText: String { + let parsed = candidate.parsedURL + guard (snapshot?.connectionMode ?? .network) == .fileBased, !parsed.database.isEmpty else { + return provenance + } + return parsed.database + } + + private func displayPath(_ path: String) -> String { + guard !path.isEmpty else { + return typeName + } + let root = projectRoot.standardizedFileURL.path + if path.hasPrefix(root + "/") { + return String(path.dropFirst(root.count + 1)) + } + return (path as NSString).abbreviatingWithTildeInPath } } diff --git a/TablePro/Views/Connection/ProjectFolder/ProjectFolderScanSheet.swift b/TablePro/Views/Connection/ProjectFolder/ProjectFolderScanSheet.swift index 79cd6b4a6..d4f7ae3a2 100644 --- a/TablePro/Views/Connection/ProjectFolder/ProjectFolderScanSheet.swift +++ b/TablePro/Views/Connection/ProjectFolder/ProjectFolderScanSheet.swift @@ -59,7 +59,14 @@ struct ProjectFolderScanSheet: View { .foregroundStyle(.secondary) } case .results(let candidates): - ProjectFolderCandidateList(candidates: candidates, selection: $selection) + VStack(alignment: .leading, spacing: 6) { + ProjectFolderCandidateList( + candidates: candidates, + projectRoot: rootURL, + selection: $selection + ) + detailStrip + } case .empty: centered { Image(systemName: "folder.badge.questionmark") @@ -104,6 +111,22 @@ struct ProjectFolderScanSheet: View { } } + private var detailStrip: some View { + Text(verbatim: selectedCandidate.map(Self.notes) ?? "") + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(2) + .frame(maxWidth: .infinity, minHeight: 30, alignment: .topLeading) + } + + private static func notes(for candidate: ScannedConnectionCandidate) -> String { + var notes = candidate.warnings + if candidate.placeholderSuspected { + notes.insert(String(localized: "Looks like a placeholder value"), at: 0) + } + return notes.joined(separator: " · ") + } + private var selectedCandidate: ScannedConnectionCandidate? { guard case .results(let candidates) = step, let selection else { return nil diff --git a/TablePro/Views/ConnectionForm/Components/ImportFromURLSheet.swift b/TablePro/Views/ConnectionForm/Components/ImportFromURLSheet.swift index 33da7e975..aa32c2194 100644 --- a/TablePro/Views/ConnectionForm/Components/ImportFromURLSheet.swift +++ b/TablePro/Views/ConnectionForm/Components/ImportFromURLSheet.swift @@ -38,7 +38,7 @@ struct ImportFromURLSheet: View { .font(.caption) .foregroundStyle(.orange) } else if let parsed = parsedURL { - ParsedConnectionSummaryView(parsed: parsed) + previewView(parsed) } Spacer(minLength: 0) @@ -95,4 +95,68 @@ struct ImportFromURLSheet: View { firstLine.contains("://") else { return } urlString = firstLine.trimmingCharacters(in: .whitespacesAndNewlines) } + + private func previewView(_ parsed: ParsedConnectionURL) -> some View { + let snapshot = PluginMetadataRegistry.shared.snapshot(forTypeId: parsed.type.rawValue) + let mode = snapshot?.connectionMode ?? .network + + return VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Image(parsed.type.iconName) + .resizable() + .frame(width: 16, height: 16) + Text(snapshot?.displayName ?? parsed.type.rawValue) + .font(.caption) + .fontWeight(.medium) + } + + switch mode { + case .fileBased: + if !parsed.database.isEmpty { + previewRow(String(localized: "Path"), parsed.database) + } + case .apiOnly: + if !parsed.host.isEmpty { + previewRow(String(localized: "Host"), parsed.host) + } + case .network: + if let multiHost = parsed.multiHost, multiHost.contains(",") { + previewRow(String(localized: "Hosts"), multiHost) + } else if !parsed.host.isEmpty { + let portStr = parsed.port.map { ":\($0)" } ?? "" + previewRow(String(localized: "Host"), parsed.host + portStr) + } + if !parsed.username.isEmpty { + previewRow(String(localized: "User"), parsed.username) + } + if !parsed.database.isEmpty { + previewRow(String(localized: "Database"), parsed.database) + } + if let svc = parsed.oracleServiceName, !svc.isEmpty { + previewRow(String(localized: "Service"), svc) + } + if let sshHost = parsed.sshHost { + previewRow("SSH", sshHost) + } + } + } + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(nsColor: .controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + + private func previewRow(_ label: String, _ value: String) -> some View { + HStack(spacing: 4) { + Text(label) + .font(.caption2) + .foregroundStyle(.tertiary) + .frame(width: 58, alignment: .trailing) + Text(value) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } } diff --git a/TablePro/Views/ConnectionForm/Components/ParsedConnectionSummaryView.swift b/TablePro/Views/ConnectionForm/Components/ParsedConnectionSummaryView.swift deleted file mode 100644 index dea3d6d0c..000000000 --- a/TablePro/Views/ConnectionForm/Components/ParsedConnectionSummaryView.swift +++ /dev/null @@ -1,93 +0,0 @@ -// -// ParsedConnectionSummaryView.swift -// TablePro -// - -import SwiftUI -import TableProPluginKit - -struct ParsedConnectionSummaryView: View { - let parsed: ParsedConnectionURL - var showsBackground = true - - var body: some View { - let snapshot = PluginMetadataRegistry.shared.snapshot(forTypeId: parsed.type.rawValue) - let mode = snapshot?.connectionMode ?? .network - - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 6) { - Image(parsed.type.iconName) - .resizable() - .frame(width: 16, height: 16) - Text(snapshot?.displayName ?? parsed.type.rawValue) - .font(.caption) - .fontWeight(.medium) - } - - switch mode { - case .fileBased: - if !parsed.database.isEmpty { - ParsedConnectionSummaryRow(label: String(localized: "Path"), value: parsed.database) - } - case .apiOnly: - if !parsed.host.isEmpty { - ParsedConnectionSummaryRow(label: String(localized: "Host"), value: parsed.host) - } - case .network: - networkRows - } - } - .padding(showsBackground ? 8 : 0) - .frame(maxWidth: .infinity, alignment: .leading) - .background(background) - } - - @ViewBuilder - private var networkRows: some View { - if let multiHost = parsed.multiHost, multiHost.contains(",") { - ParsedConnectionSummaryRow(label: String(localized: "Hosts"), value: multiHost) - } else if !parsed.host.isEmpty { - let portSuffix = parsed.port.map { ":\($0)" } ?? "" - ParsedConnectionSummaryRow(label: String(localized: "Host"), value: parsed.host + portSuffix) - } - if !parsed.username.isEmpty { - ParsedConnectionSummaryRow(label: String(localized: "User"), value: parsed.username) - } - if !parsed.database.isEmpty { - ParsedConnectionSummaryRow(label: String(localized: "Database"), value: parsed.database) - } - if let service = parsed.oracleServiceName, !service.isEmpty { - ParsedConnectionSummaryRow(label: String(localized: "Service"), value: service) - } - if let sshHost = parsed.sshHost { - ParsedConnectionSummaryRow(label: "SSH", value: sshHost) - } - } - - @ViewBuilder - private var background: some View { - if showsBackground { - RoundedRectangle(cornerRadius: 6) - .fill(Color(nsColor: .controlBackgroundColor)) - } - } -} - -struct ParsedConnectionSummaryRow: View { - let label: String - let value: String - - var body: some View { - HStack(spacing: 4) { - Text(label) - .font(.caption2) - .selectionAwareForeground(Color(nsColor: .tertiaryLabelColor), emphasizedOpacity: 0.6) - .frame(width: 58, alignment: .trailing) - Text(value) - .font(.caption) - .selectionAwareForeground(Color(nsColor: .secondaryLabelColor), emphasizedOpacity: 0.85) - .lineLimit(1) - .truncationMode(.middle) - } - } -} diff --git a/TablePro/Views/ConnectionForm/Components/SelectionAwareForeground.swift b/TablePro/Views/ConnectionForm/Components/SelectionAwareForeground.swift index 06725f49f..c1d27cbf7 100644 --- a/TablePro/Views/ConnectionForm/Components/SelectionAwareForeground.swift +++ b/TablePro/Views/ConnectionForm/Components/SelectionAwareForeground.swift @@ -7,23 +7,17 @@ import SwiftUI private struct SelectionAwareForeground: ViewModifier { let standard: Color - let emphasizedOpacity: Double @Environment(\.backgroundProminence) private var backgroundProminence func body(content: Content) -> some View { - content.foregroundStyle(resolvedStyle) - } - - private var resolvedStyle: Color { - guard backgroundProminence == .increased else { - return standard - } - return Color(nsColor: .alternateSelectedControlTextColor).opacity(emphasizedOpacity) + content.foregroundStyle( + backgroundProminence == .increased ? AnyShapeStyle(.secondary) : AnyShapeStyle(standard) + ) } } extension View { - func selectionAwareForeground(_ standard: Color, emphasizedOpacity: Double = 1) -> some View { - modifier(SelectionAwareForeground(standard: standard, emphasizedOpacity: emphasizedOpacity)) + func selectionAwareForeground(_ standard: Color) -> some View { + modifier(SelectionAwareForeground(standard: standard)) } } From e459b601e73ffd946ebaede6f006c44aaae9d710 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 25 Jul 2026 22:51:32 +0700 Subject: [PATCH 4/7] refactor(welcome): group connection sources into a pull-down button --- TablePro/Core/Events/AppCommands.swift | 1 + TablePro/TableProApp.swift | 9 +++ .../Connection/WelcomeActionsPanel.swift | 62 ++++--------------- .../Views/Connection/WelcomeWindowView.swift | 16 ++++- 4 files changed, 36 insertions(+), 52 deletions(-) diff --git a/TablePro/Core/Events/AppCommands.swift b/TablePro/Core/Events/AppCommands.swift index 06a2f2a26..5589f9387 100644 --- a/TablePro/Core/Events/AppCommands.swift +++ b/TablePro/Core/Events/AppCommands.swift @@ -22,6 +22,7 @@ final class AppCommands { let importConnections = PassthroughSubject() let importConnectionsFromApp = PassthroughSubject() let openProjectFolder = PassthroughSubject() + let importConnectionFromURL = PassthroughSubject() let exportQueryResults = PassthroughSubject() // MARK: - Window / Sheet Commands diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index 6924c0037..f358f3211 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -355,18 +355,27 @@ struct AppMenuCommands: Commands { Divider() Button(String(localized: "Export Connections...")) { + WindowOpener.shared.openWelcome() AppCommands.shared.exportConnections.send(()) } Button(String(localized: "Import Connections...")) { + WindowOpener.shared.openWelcome() AppCommands.shared.importConnections.send(()) } + Button(String(localized: "Import from URL...")) { + WindowOpener.shared.openWelcome() + AppCommands.shared.importConnectionFromURL.send(()) + } + Button(String(localized: "Import from Other App...")) { + WindowOpener.shared.openWelcome() AppCommands.shared.importConnectionsFromApp.send(()) } Button(String(localized: "Open Project Folder...")) { + WindowOpener.shared.openWelcome() AppCommands.shared.openProjectFolder.send(()) } diff --git a/TablePro/Views/Connection/WelcomeActionsPanel.swift b/TablePro/Views/Connection/WelcomeActionsPanel.swift index e63096b97..4e9b53bc3 100644 --- a/TablePro/Views/Connection/WelcomeActionsPanel.swift +++ b/TablePro/Views/Connection/WelcomeActionsPanel.swift @@ -8,9 +8,10 @@ import SwiftUI struct WelcomeActionsPanel: View { let onActivateLicense: () -> Void let onCreateConnection: () -> Void + let onImportFromURL: () -> Void let onImportFromApp: () -> Void let onOpenProjectFolder: () -> Void - let onTrySample: () -> Void + let onImportConnectionsFile: () -> Void private let updaterBridge = UpdaterBridge.shared @@ -45,24 +46,17 @@ struct WelcomeActionsPanel: View { .buttonStyle(.borderedProminent) .controlSize(.large) - Button(action: onImportFromApp) { - Label(String(localized: "Import from Other App..."), systemImage: "square.and.arrow.down.on.square") - .frame(maxWidth: .infinity, alignment: .center) - } - .buttonStyle(.bordered) - .controlSize(.large) - - Button(action: onOpenProjectFolder) { - Label(String(localized: "Open Project Folder..."), systemImage: "folder.badge.gearshape") - .frame(maxWidth: .infinity, alignment: .center) - } - .buttonStyle(.bordered) - .controlSize(.large) - - Button(action: onTrySample) { - Label(String(localized: "Try Sample Database"), systemImage: "cylinder.split.1x2") + Menu { + Button(String(localized: "Import from URL..."), action: onImportFromURL) + Button(String(localized: "Import from Other App..."), action: onImportFromApp) + Button(String(localized: "Open Project Folder..."), action: onOpenProjectFolder) + Divider() + Button(String(localized: "Import Connections..."), action: onImportConnectionsFile) + } label: { + Label(String(localized: "Add from Existing"), systemImage: "square.and.arrow.down") .frame(maxWidth: .infinity, alignment: .center) } + .menuStyle(.button) .buttonStyle(.bordered) .controlSize(.large) } @@ -70,18 +64,7 @@ struct WelcomeActionsPanel: View { Spacer() - ViewThatFits(in: .horizontal) { - HStack(spacing: 12) { - SyncStatusIndicator(onActivateLicense: onActivateLicense) - KeyboardHint(keys: "⌘N", label: String(localized: "New")) - KeyboardHint(keys: "⌘,", label: String(localized: "Settings")) - } - HStack(spacing: 8) { - SyncStatusIndicator(onActivateLicense: onActivateLicense) - KeyboardHint(keys: "⌘N", label: String(localized: "New")) - KeyboardHint(keys: "⌘,", label: nil) - } - } + SyncStatusIndicator(onActivateLicense: onActivateLicense) .font(.caption) .foregroundStyle(.tertiary) .padding(.horizontal, 12) @@ -122,24 +105,3 @@ struct WelcomeActionsPanel: View { } } } - -struct KeyboardHint: View { - let keys: String - let label: String? - - var body: some View { - HStack(spacing: 4) { - Text(keys) - .font(.system(.caption, design: .monospaced)) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background( - RoundedRectangle(cornerRadius: 3) - .fill(.tertiary.opacity(0.4)) - ) - if let label { - Text(label) - } - } - } -} diff --git a/TablePro/Views/Connection/WelcomeWindowView.swift b/TablePro/Views/Connection/WelcomeWindowView.swift index f4f5e546e..305bd4fce 100644 --- a/TablePro/Views/Connection/WelcomeWindowView.swift +++ b/TablePro/Views/Connection/WelcomeWindowView.swift @@ -131,6 +131,9 @@ struct WelcomeWindowView: View { chooserState: $welcomeChooserState, urlImportPresented: $urlImportPresented )) + .onReceive(AppCommands.shared.importConnectionFromURL) { _ in + urlImportPresented = true + } .onReceive(AppCommands.shared.presentDatabaseTypeChooser) { payload in welcomeChooserState = WelcomeChooserState( initialType: payload.initialType, @@ -216,9 +219,10 @@ struct WelcomeWindowView: View { WelcomeActionsPanel( onActivateLicense: { vm.activeSheet = .activation }, onCreateConnection: { WindowOpener.shared.openConnectionForm() }, + onImportFromURL: { urlImportPresented = true }, onImportFromApp: { vm.importConnectionsFromApp() }, onOpenProjectFolder: { vm.openProjectFolder() }, - onTrySample: { vm.openSampleDatabase() } + onImportConnectionsFile: { vm.importConnectionsFromFile() } ) .frame(width: 240) .themeMaterial(.sidebar, .regularMaterial) @@ -265,6 +269,14 @@ struct WelcomeWindowView: View { .accessibilityHidden(true) } + private var newConnectionHelp: String { + let binding = AppSettingsManager.shared.keyboard.shortcut(for: .newConnection) + guard let displayString = binding?.displayString, !displayString.isEmpty else { + return String(localized: "New Connection") + } + return String(format: String(localized: "New Connection (%@)"), displayString) + } + private var connectionsHeader: some View { HStack(spacing: 8) { Button { @@ -275,7 +287,7 @@ struct WelcomeWindowView: View { } .buttonStyle(.bordered) .controlSize(.large) - .help(String(localized: "New Connection (⌘N)")) + .help(newConnectionHelp) .accessibilityLabel(String(localized: "New Connection")) Button { From 1c2b40d07e7e4a9045d12b04a21f2ccba81d1c15 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 25 Jul 2026 23:15:33 +0700 Subject: [PATCH 5/7] fix(welcome): show the license activation sheet when it is opened --- CHANGELOG.md | 7 +++++++ TablePro/Views/Settings/LicenseActivationSheet.swift | 1 - 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc6419b5c..ba2d94561 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Create a connection from a project folder. Pick one from the welcome screen or File > Open Project Folder..., and TablePro reads the database settings it finds in `.env` files, `wp-config.php`, `prisma/schema.prisma`, `config/database.yml`, `docker-compose.yml`, `application.properties`, `application.yml`, and `appsettings.json`. Review what it found, pick one, and the connection form opens filled in. Nothing is saved or connected until you save it. (#1959) +### Changed + +- The welcome screen groups the ways to add an existing connection into one **Add from Existing** menu, next to **Create Connection**. Import from URL now has a home in that menu and in the File menu, where it had none. **Try Sample Database** moved to the connection list, which already offers it when the list is empty. + ### Fixed +- The license activation sheet now opens when you click **Activate License**. It was built and then failed to appear, and once that happened further clicks did nothing at all. +- File > Import from Other App..., Open Project Folder..., Import Connections... and Export Connections... now work when no welcome window is open. They used to do nothing. +- The tooltip on the welcome screen's **+** button shows the shortcut you actually have bound for New Connection instead of always claiming ⌘N. - The AI chat panel stays inside the right panel when you drag it narrow. The model name, tool names, and code block headers now truncate, long chat messages and code wrap, wide tables scroll inside their own box, and the composer text follows the panel width instead of running under the editor. (#1956) ## [0.60.1] - 2026-07-25 diff --git a/TablePro/Views/Settings/LicenseActivationSheet.swift b/TablePro/Views/Settings/LicenseActivationSheet.swift index b28e6a4ef..74b83967b 100644 --- a/TablePro/Views/Settings/LicenseActivationSheet.swift +++ b/TablePro/Views/Settings/LicenseActivationSheet.swift @@ -79,7 +79,6 @@ struct LicenseActivationSheet: View { .padding(.bottom, 24) } .frame(width: 400) - .defaultFocus($keyFocused, true) } private func activate() async { From c3e17659f8f45f05a9a4897e3bd639408adbca9c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 25 Jul 2026 23:20:32 +0700 Subject: [PATCH 6/7] fix(mcp): stop forcing focus while the token sheets are being presented --- TablePro/Views/Settings/Sections/MCPTokenCreateSheet.swift | 1 - TablePro/Views/Settings/Sections/MCPTokenRevealSheet.swift | 1 - 2 files changed, 2 deletions(-) diff --git a/TablePro/Views/Settings/Sections/MCPTokenCreateSheet.swift b/TablePro/Views/Settings/Sections/MCPTokenCreateSheet.swift index 11df697bf..b465ef75c 100644 --- a/TablePro/Views/Settings/Sections/MCPTokenCreateSheet.swift +++ b/TablePro/Views/Settings/Sections/MCPTokenCreateSheet.swift @@ -34,7 +34,6 @@ struct MCPTokenCreateSheet: View { .padding() } .frame(minWidth: 480, minHeight: 520) - .defaultFocus($focused, .name) .task { connections = ConnectionStorage.shared.loadConnections() } diff --git a/TablePro/Views/Settings/Sections/MCPTokenRevealSheet.swift b/TablePro/Views/Settings/Sections/MCPTokenRevealSheet.swift index 430bd7501..9ab9bec0a 100644 --- a/TablePro/Views/Settings/Sections/MCPTokenRevealSheet.swift +++ b/TablePro/Views/Settings/Sections/MCPTokenRevealSheet.swift @@ -35,7 +35,6 @@ struct MCPTokenRevealSheet: View { .padding() } .frame(minWidth: 540, minHeight: 520) - .defaultFocus($doneFocused, true) } private var warningBanner: some View { From 80ccaf86670c8ea706dc0402afffa8342fed239a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 25 Jul 2026 23:33:45 +0700 Subject: [PATCH 7/7] fix(connections): keep file paths intact when normalizing scanned URLs --- CHANGELOG.md | 2 +- .../DockerComposeExtractor.swift | 15 +++- .../DotenvConnectionExtractor.swift | 39 +++++++--- .../ProjectFolderFileWalker.swift | 4 +- .../RailsDatabaseYamlExtractor.swift | 2 +- .../ProjectImport/ScannedURLNormalizer.swift | 3 + .../DotenvConnectionExtractorTests.swift | 24 ++++++ .../ProjectConfigExtractorTests.swift | 75 +++++++++++++++++++ .../ProjectYamlExtractorTests.swift | 16 ++++ docs/docs.json | 4 +- docs/features/project-folder-import.mdx | 2 +- 11 files changed, 168 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba2d94561..e7a7637ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Create a connection from a project folder. Pick one from the welcome screen or File > Open Project Folder..., and TablePro reads the database settings it finds in `.env` files, `wp-config.php`, `prisma/schema.prisma`, `config/database.yml`, `docker-compose.yml`, `application.properties`, `application.yml`, and `appsettings.json`. Review what it found, pick one, and the connection form opens filled in. Nothing is saved or connected until you save it. (#1959) +- Create a connection from a project folder. Pick one from the welcome screen or File > Open Project Folder..., and TablePro reads the database settings it finds in `.env` files, `wp-config.php`, `prisma/schema.prisma`, `config/database.yml`, `docker-compose.yml`, `application.properties`, `application.yml`, and `appsettings.json`. A project that uses more than one engine gets a row for each. Review what it found, pick one, and the connection form opens filled in. Nothing is saved or connected until you save it. (#1959) ### Changed diff --git a/TablePro/Core/Services/ProjectImport/DockerComposeExtractor.swift b/TablePro/Core/Services/ProjectImport/DockerComposeExtractor.swift index 62bc1bde3..8f8a049b6 100644 --- a/TablePro/Core/Services/ProjectImport/DockerComposeExtractor.swift +++ b/TablePro/Core/Services/ProjectImport/DockerComposeExtractor.swift @@ -44,6 +44,9 @@ enum DockerComposeExtractor { fields.connectionName = name applyCredentials(&fields, type: database.type, variables: variables) var warnings: [String] = [] + if [fields.username, fields.password, fields.database].contains(where: ComposeInterpolator.isUnresolved) { + warnings.append(String(localized: "Some values are set outside this file")) + } if let published = publishedPort(service["ports"], containerPort: database.defaultPort) { fields.port = published } else { @@ -218,8 +221,16 @@ enum ComposeInterpolator { if let value = environment?[name] { return value } - return separator.hasSuffix("?") ? "" : fallback + return separator.hasSuffix("?") ? unresolvedMarker(reference) : fallback } - return environment?[reference] ?? "" + return environment?[reference] ?? unresolvedMarker(reference) + } + + static func isUnresolved(_ value: String) -> Bool { + value.contains("${") + } + + private static func unresolvedMarker(_ reference: String) -> String { + "${\(reference)}" } } diff --git a/TablePro/Core/Services/ProjectImport/DotenvConnectionExtractor.swift b/TablePro/Core/Services/ProjectImport/DotenvConnectionExtractor.swift index 840ccce75..90ae1e7e8 100644 --- a/TablePro/Core/Services/ProjectImport/DotenvConnectionExtractor.swift +++ b/TablePro/Core/Services/ProjectImport/DotenvConnectionExtractor.swift @@ -50,9 +50,7 @@ enum DotenvConnectionExtractor { directoryURL: directoryURL ) var candidates: [ScannedConnectionCandidate] = [] - if let relational = relationalCandidate(context) { - candidates.append(relational) - } + candidates.append(contentsOf: relationalCandidates(context)) if let mongo = mongoCandidate(context) { candidates.append(mongo) } @@ -84,17 +82,40 @@ enum DotenvConnectionExtractor { return nil } - private static func relationalCandidate(_ context: Context) -> ScannedConnectionCandidate? { - if let fromURL = firstURLCandidate(context, keys: relationalURLKeys) { - return fromURL + private static func relationalCandidates(_ context: Context) -> [ScannedConnectionCandidate] { + var byType: [String: ScannedConnectionCandidate] = [:] + var order: [String] = [] + for key in relationalURLKeys { + guard let value = context.document[key] else { + continue + } + let candidate = ScannedConnectionURLBuilder.candidate( + fromURL: value, + key: key, + relativePath: context.relativePath, + kind: .dotenv, + tier: context.tier + ) + guard let candidate else { + continue + } + let type = candidate.parsedURL.type.rawValue + guard byType[type] == nil else { + continue + } + byType[type] = candidate + order.append(type) + } + guard order.isEmpty else { + return order.compactMap { byType[$0] } } if let laravel = laravelCandidate(context) { - return laravel + return [laravel] } if let docker = dockerRelationalCandidate(context) { - return docker + return [docker] } - return libpqCandidate(context) + return libpqCandidate(context).map { [$0] } ?? [] } private static func mongoCandidate(_ context: Context) -> ScannedConnectionCandidate? { diff --git a/TablePro/Core/Services/ProjectImport/ProjectFolderFileWalker.swift b/TablePro/Core/Services/ProjectImport/ProjectFolderFileWalker.swift index d537ca7d1..bdd76fb9f 100644 --- a/TablePro/Core/Services/ProjectImport/ProjectFolderFileWalker.swift +++ b/TablePro/Core/Services/ProjectImport/ProjectFolderFileWalker.swift @@ -123,7 +123,7 @@ enum ProjectFolderFileWalker { return name.hasSuffix(".dist-info") } - private static var deniedPrefixes: [String] { + private static let deniedPrefixes: [String] = { let home = FileManager.default.homeDirectoryForCurrentUser.standardizedFileURL.path return [ home + "/.ssh", @@ -134,5 +134,5 @@ enum ProjectFolderFileWalker { "/etc", "/private/etc", ] - } + }() } diff --git a/TablePro/Core/Services/ProjectImport/RailsDatabaseYamlExtractor.swift b/TablePro/Core/Services/ProjectImport/RailsDatabaseYamlExtractor.swift index 2f2861078..d5750e50f 100644 --- a/TablePro/Core/Services/ProjectImport/RailsDatabaseYamlExtractor.swift +++ b/TablePro/Core/Services/ProjectImport/RailsDatabaseYamlExtractor.swift @@ -6,7 +6,7 @@ import Foundation enum RailsDatabaseYamlExtractor { - private static let preferredEnvironments = ["development", "primary", "production", "staging", "test"] + private static let preferredEnvironments = ["development", "production", "staging", "test"] static func extract( contents: String, diff --git a/TablePro/Core/Services/ProjectImport/ScannedURLNormalizer.swift b/TablePro/Core/Services/ProjectImport/ScannedURLNormalizer.swift index 70ca86d85..9852e9d7f 100644 --- a/TablePro/Core/Services/ProjectImport/ScannedURLNormalizer.swift +++ b/TablePro/Core/Services/ProjectImport/ScannedURLNormalizer.swift @@ -19,6 +19,9 @@ enum ScannedURLNormalizer { } let scheme = String(trimmed[trimmed.startIndex..