From 1dd6ddfd2829b5a83ce7e68f9034453f8e371157 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 20:53:15 +0700 Subject: [PATCH] fix(hig): separate UI details with commas or space instead of a dot --- CHANGELOG.md | 3 +- .../DynamoDBPluginDriver+Reading.swift | 3 +- .../DynamoDBTableDefinition.swift | 3 +- .../MongoScriptResultBuilder.swift | 8 +- .../MongoDBDriverPlugin/MongoScriptText.swift | 7 + .../TypesenseApiKeys.swift | 3 +- .../Core/Autocomplete/SQLCompletionItem.swift | 4 +- .../MainSplitViewController+RecentTabs.swift | 2 +- .../WorkspaceRailCellView.swift | 2 +- .../Licensing/LicensePresentation.swift | 7 +- .../ScannedConnectionCandidate.swift | 3 +- TablePro/Models/Query/QueryPlanDiff.swift | 9 +- TablePro/Resources/Localizable.xcstrings | 384 ++++-------------- .../AIChatViewModel+SlashCommands.swift | 2 +- .../QuickSwitcherViewModel+QueryItems.swift | 4 +- .../ViewModels/QuickSwitcherViewModel.swift | 2 +- .../AcknowledgementsView.swift | 2 +- .../PluginRejectedBannerModifier.swift | 128 ------ .../ProjectFolderCandidateList.swift | 2 +- .../ProjectFolderScanSheet.swift | 25 +- .../DatabaseTypeChooserSheet.swift | 11 +- .../Views/Editor/QueryContainerPicker.swift | 8 +- .../QueryInsightsGroupList.swift | 4 +- .../QueryPlan/QueryPlanComparisonView.swift | 7 +- .../Views/QueryPlan/QueryPlanResultView.swift | 2 +- .../Views/Results/QueryTimingBreakdown.swift | 2 +- .../Views/Results/ResultChartToolbar.swift | 8 +- .../ServerDashboard/SlowQueryListView.swift | 3 +- .../License/LicenseDevicesSection.swift | 6 +- .../Settings/License/LicenseTeamSection.swift | 2 +- .../Plugins/InstalledPluginsView.swift | 5 +- .../VersionHistoryFormatting.swift | 4 +- .../Intents/ConnectionEntity.swift | 2 +- .../TableProMobile/Localizable.xcstrings | 34 -- .../Intents/ConnectionEntityTests.swift | 16 + TableProTests/Core/AI/SlashCommandTests.swift | 9 + .../SQLCompletionProviderTests.swift | 5 +- .../ProjectYamlExtractorTests.swift | 26 +- .../Services/WorkspaceRailStoreTests.swift | 4 +- .../Helpers/UserFacingSourceScan.swift | 117 ++++++ .../Models/LicensePresentationTests.swift | 3 +- .../Models/Query/QueryPlanDiffTests.swift | 18 + TableProTests/Models/QueryTimingTests.swift | 16 +- .../DynamoDBTableManagementTests.swift | 39 ++ .../Plugins/MongoResultRebuildTests.swift | 9 + .../Plugins/TypesenseDriverTests.swift | 12 + .../QuickSwitcherCrossSchemaTests.swift | 2 +- .../QuickSwitcherViewModelTests.swift | 35 +- .../DatabaseTypeChooserRowTests.swift | 67 +++ .../ProjectFolderScanNotesTests.swift | 27 ++ .../Views/UserFacingEmDashGuardTests.swift | 71 +--- .../Views/UserFacingMiddleDotGuardTests.swift | 90 ++++ .../VersionHistoryFormattingTests.swift | 31 ++ 53 files changed, 693 insertions(+), 605 deletions(-) delete mode 100644 TablePro/Views/Connection/PluginRejectedBannerModifier.swift create mode 100644 TableProMobile/TableProMobileTests/Intents/ConnectionEntityTests.swift create mode 100644 TableProTests/Helpers/UserFacingSourceScan.swift create mode 100644 TableProTests/Views/Connection/DatabaseTypeChooserRowTests.swift create mode 100644 TableProTests/Views/Connection/ProjectFolderScanNotesTests.swift create mode 100644 TableProTests/Views/UserFacingMiddleDotGuardTests.swift create mode 100644 TableProTests/Views/VersionHistory/VersionHistoryFormattingTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a85fbec35..d081bd1c96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Theme file format 3, where every color is optional. Themes written for earlier versions are not read and need to be recreated. - Triggers and ClickHouse Parts in table structure shown as data grids, with multiple selection and Copy. - Filter and maintenance SQL previews highlighted as SQL in the editor font. -- Result bar items separated by space instead of a dot. +- Details in Quick Switcher, autocomplete, Settings, query plans, tooltips and the result bar separated by commas or space instead of a dot. - Settings > Editor grouped into Display, Gutter and Editing. - Editor Font and Data Grid Font moved to Settings > Editor and Settings > Data, and kept per Mac. - Editor font size range of 10 to 24 points everywhere, including zoom. @@ -79,6 +79,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Reopen Last Session forgetting its connections after an MCP client launched TablePro in the background. - GitHub Copilot starting at launch when not in use, and running on after AI was turned off or Copilot was removed. - Settings > AI reading sign-in tokens and running command line tools for providers that were never added. +- Database type icon and checkmark hard to see on the selected row in Choose a Database. ### Security diff --git a/Plugins/DynamoDBDriverPlugin/DynamoDBPluginDriver+Reading.swift b/Plugins/DynamoDBDriverPlugin/DynamoDBPluginDriver+Reading.swift index 99d8f10f28..e596697bc1 100644 --- a/Plugins/DynamoDBDriverPlugin/DynamoDBPluginDriver+Reading.swift +++ b/Plugins/DynamoDBDriverPlugin/DynamoDBPluginDriver+Reading.swift @@ -212,7 +212,8 @@ extension DynamoDBPluginDriver { let units = formatter.string(from: NSNumber(value: stats.readUnits)) ?? "\(stats.readUnits)" parts.append(String(format: String(localized: "%@ RCU"), units)) } - return (parts + extra).joined(separator: " · ") + // A note is a clause with commas of its own, so it follows the counts as a sentence. + return ([parts.joined(separator: ", ")] + extra).joined(separator: ". ") } static func queryResult( diff --git a/Plugins/DynamoDBDriverPlugin/DynamoDBTableDefinition.swift b/Plugins/DynamoDBDriverPlugin/DynamoDBTableDefinition.swift index 971c0245a3..360b46b21f 100644 --- a/Plugins/DynamoDBDriverPlugin/DynamoDBTableDefinition.swift +++ b/Plugins/DynamoDBDriverPlugin/DynamoDBTableDefinition.swift @@ -81,7 +81,8 @@ enum DynamoDBTableDefinition { parts.append(String(format: String(localized: "Status: %@"), status)) } parts.append(String(localized: "Item count is approximate, updated about every six hours")) - return parts.joined(separator: " · ") + // Capacity and the item count note carry commas, so a comma cannot separate the parts. + return parts.joined(separator: "; ") } private static func keySchema(_ keys: DynamoDBKeySchema) -> DynamoDBJSON { diff --git a/Plugins/MongoDBDriverPlugin/MongoScriptResultBuilder.swift b/Plugins/MongoDBDriverPlugin/MongoScriptResultBuilder.swift index 96ad046175..926cfc71ba 100644 --- a/Plugins/MongoDBDriverPlugin/MongoScriptResultBuilder.swift +++ b/Plugins/MongoDBDriverPlugin/MongoScriptResultBuilder.swift @@ -35,7 +35,7 @@ enum MongoScriptResultBuilder { outcome.documents.holdsStoredDocuments ? read.texts.map(MongoDocumentIdentity.locator(inDocument:)) : nil ) guard !outcome.printedLines.isEmpty else { return grid } - return grid.withStatus(printedSummary(outcome.printedLines)) + return grid.withStatus(MongoScriptText.printedSummary(outcome.printedLines)) } if !outcome.printedLines.isEmpty { @@ -69,10 +69,4 @@ enum MongoScriptResultBuilder { private static func note(_ text: String, startTime: Date) -> PluginQueryResult { values([text], column: MongoScriptText.outputColumn, startTime: startTime) } - - private static func printedSummary(_ lines: [String]) -> String { - let joined = lines.joined(separator: " · ") - guard joined.count > 400 else { return joined } - return String(joined.prefix(400)) + "…" - } } diff --git a/Plugins/MongoDBDriverPlugin/MongoScriptText.swift b/Plugins/MongoDBDriverPlugin/MongoScriptText.swift index d98a69fd87..a3f3f92823 100644 --- a/Plugins/MongoDBDriverPlugin/MongoScriptText.swift +++ b/Plugins/MongoDBDriverPlugin/MongoScriptText.swift @@ -81,6 +81,13 @@ enum MongoScriptText { String(format: String(localized: "%d line(s) printed"), count) } + /// Printed lines can hold commas of their own, so a comma would not tell one line from the next. + static func printedSummary(_ lines: [String]) -> String { + let joined = lines.joined(separator: "; ") + guard joined.count > 400 else { return joined } + return String(joined.prefix(400)) + "…" + } + static var outputColumn: String { String(localized: "output") } diff --git a/Plugins/TypesenseDriverPlugin/TypesenseApiKeys.swift b/Plugins/TypesenseDriverPlugin/TypesenseApiKeys.swift index 6c32127d24..98ff4f6e0b 100644 --- a/Plugins/TypesenseDriverPlugin/TypesenseApiKeys.swift +++ b/Plugins/TypesenseDriverPlugin/TypesenseApiKeys.swift @@ -105,7 +105,8 @@ enum TypesenseApiKeys { } else { parts.append(key.collections.joined(separator: ", ")) } - return parts.isEmpty ? nil : parts.joined(separator: " · ") + // The collection list is itself comma separated. + return parts.isEmpty ? nil : parts.joined(separator: "; ") } /// A Typesense key carries actions and a collection list, and the pair is the grant: every diff --git a/TablePro/Core/Autocomplete/SQLCompletionItem.swift b/TablePro/Core/Autocomplete/SQLCompletionItem.swift index 8f14e68b5d..69aa57ae1c 100644 --- a/TablePro/Core/Autocomplete/SQLCompletionItem.swift +++ b/TablePro/Core/Autocomplete/SQLCompletionItem.swift @@ -269,7 +269,6 @@ extension SQLCompletionItem { ) } - /// Create a column completion item static func column( _ name: String, dataType: String?, @@ -279,12 +278,11 @@ extension SQLCompletionItem { defaultValue: String? = nil, comment: String? = nil ) -> SQLCompletionItem { - // Build detail string: "PK · NOT NULL · INT" var detailParts: [String] = [] if isPrimaryKey { detailParts.append("PK") } if !isNullable { detailParts.append("NOT NULL") } if let dataType { detailParts.append(dataType) } - let detail = detailParts.isEmpty ? nil : detailParts.joined(separator: " · ") + let detail = detailParts.isEmpty ? nil : detailParts.joined(separator: ", ") var docParts: [String] = [] if let tableName { docParts.append("Column from \(tableName)") } diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+RecentTabs.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+RecentTabs.swift index 9231cce2b7..692d519595 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+RecentTabs.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+RecentTabs.swift @@ -177,7 +177,7 @@ internal extension MainSplitViewController { } private static func recentTabDetail(container: String?, connectionName: String?) -> String { - [container, connectionName].compactMap { $0 }.joined(separator: " \u{00B7} ") + [container, connectionName].compactMap { $0 }.joined(separator: ", ") } private static func recentTabSymbol(for tab: QueryTab) -> String { diff --git a/TablePro/Core/Services/Infrastructure/WorkspaceRailCellView.swift b/TablePro/Core/Services/Infrastructure/WorkspaceRailCellView.swift index 1d9cca8182..c2c8d3f732 100644 --- a/TablePro/Core/Services/Infrastructure/WorkspaceRailCellView.swift +++ b/TablePro/Core/Services/Infrastructure/WorkspaceRailCellView.swift @@ -320,7 +320,7 @@ internal final class WorkspaceRailCellView: NSTableCellView { if let identity = connection.identityColor { parts.append(String(format: String(localized: "Color: %@"), identity.displayName)) } - return parts.joined(separator: " · ") + return parts.joined(separator: ", ") } internal static func voiceOverLabel(for entry: WorkspaceRailEntry) -> String { diff --git a/TablePro/Core/Services/Licensing/LicensePresentation.swift b/TablePro/Core/Services/Licensing/LicensePresentation.swift index d6bd02dfe8..89083cb3a4 100644 --- a/TablePro/Core/Services/Licensing/LicensePresentation.swift +++ b/TablePro/Core/Services/Licensing/LicensePresentation.swift @@ -198,10 +198,7 @@ internal enum LicensePresentation { String(format: String(localized: "%1$lld of %2$lld members"), used, limit) } - /// What a licence is, in one line under the licensee's email. - /// - /// A one-time licence has no expiry and says so once. Its billing cycle is the server's word for - /// the same thing, "lifetime", so taking both spelled the plan "Team · Lifetime · Lifetime". + /// A one-time licence's "lifetime" cycle repeats "One-time", so it is dropped. static func planDescription(tier: String, billingCycle: String?, expiry: String?) -> String { var parts = [LicenseTier(rawValue: tier).displayName] @@ -214,7 +211,7 @@ internal enum LicensePresentation { parts.append(String(localized: "One-time")) } - return parts.joined(separator: " · ") + return parts.joined(separator: ", ") } /// The first group of a license key, with the rest withheld. diff --git a/TablePro/Core/Services/ProjectImport/ScannedConnectionCandidate.swift b/TablePro/Core/Services/ProjectImport/ScannedConnectionCandidate.swift index 02d7ed0ff8..73be92b0f5 100644 --- a/TablePro/Core/Services/ProjectImport/ScannedConnectionCandidate.swift +++ b/TablePro/Core/Services/ProjectImport/ScannedConnectionCandidate.swift @@ -45,7 +45,8 @@ struct ScannedConnectionCandidate: Identifiable { self.sourceKey = sourceKey self.kind = kind self.tier = tier - self.warnings = warnings + var seen = Set() + self.warnings = warnings.filter { seen.insert($0).inserted } self.placeholderSuspected = PlaceholderCredentialDetector.isPlaceholder( username: parsedURL.username, password: parsedURL.password, diff --git a/TablePro/Models/Query/QueryPlanDiff.swift b/TablePro/Models/Query/QueryPlanDiff.swift index 641de97ad1..6df4bf4f03 100644 --- a/TablePro/Models/Query/QueryPlanDiff.swift +++ b/TablePro/Models/Query/QueryPlanDiff.swift @@ -32,9 +32,14 @@ struct QueryPlanNodeChange: Identifiable, Hashable, Sendable { var id: String { "\(kind.rawValue):\(path)" } + var relationName: String? { + guard let relation, !relation.isEmpty else { return nil } + return relation + } + var title: String { - guard let relation, !relation.isEmpty else { return operation } - return "\(operation) · \(relation)" + guard let relationName else { return operation } + return "\(operation), \(relationName)" } } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 09570084dd..5616ca8975 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -504,46 +504,6 @@ } } }, - "·" : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "·" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "·" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "·" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "·" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "·" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "·" - } - } - } - }, "''" : { "extractionState" : "stale", "localizations" : { @@ -2196,46 +2156,6 @@ } } }, - "%@ · You" : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ · Vous" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ · 나" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ · Siz" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ · Bạn" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ · 你" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ · 你" - } - } - } - }, "%@ (%@@%@)" : { "localizations" : { "en" : { @@ -2398,6 +2318,46 @@ } } }, + "%@ (You)" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ (Vous)" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ (나)" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ (Siz)" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ (Bạn)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ (你)" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ (你)" + } + } + } + }, "%@ %@" : { "localizations" : { "en" : { @@ -6045,46 +6005,6 @@ } } }, - "%1$@ · %2$@" : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$@ · %2$@" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$@ · %2$@" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$@ · %2$@" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$@ · %2$@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$@ · %2$@" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$@ · %2$@" - } - } - } - }, "%1$@ (%2$@)" : { "extractionState" : "manual", "generatesSymbol" : false, @@ -6361,53 +6281,53 @@ } } }, - "%1$@ → %2$@ · %3$@ runs before, %4$@ now" : { + "%1$@ → %2$@, %3$@" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ → %2$@, %3$@" + } + } + } + }, + "%1$@ → %2$@, %3$@ runs before, %4$@ now" : { "comment" : "Regression detail: prior duration, recent duration, prior count, recent count", "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", - "value" : "%1$@ → %2$@ · %3$@ exécutions avant, %4$@ maintenant" + "value" : "%1$@ → %2$@, %3$@ exécutions avant, %4$@ maintenant" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "%1$@ → %2$@ · 이전 실행 %3$@, 현재 %4$@" + "value" : "%1$@ → %2$@, 이전 실행 %3$@, 현재 %4$@" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "%1$@ → %2$@ · önce %3$@ çalışma, şimdi %4$@" + "value" : "%1$@ → %2$@, önce %3$@ çalışma, şimdi %4$@" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "%1$@ → %2$@ · trước %3$@ lần chạy, hiện %4$@" + "value" : "%1$@ → %2$@, trước %3$@ lần chạy, hiện %4$@" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "%1$@ → %2$@ · 之前 %3$@ 次,现在 %4$@ 次" + "value" : "%1$@ → %2$@, 之前 %3$@ 次,现在 %4$@ 次" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "%1$@ → %2$@ · 之前 %3$@ 次,現在 %4$@ 次" - } - } - } - }, - "%1$@ → %2$@, %3$@" : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$@ → %2$@, %3$@" + "value" : "%1$@ → %2$@, 之前 %3$@ 次,現在 %4$@ 次" } } } @@ -131264,52 +131184,6 @@ } } }, - "Plugin not loaded: %@. %@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Plugin not loaded: %1$@. %2$@" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Plugin non chargé : %1$@. %2$@" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "플러그인이 로드되지 않음: %1$@. %2$@" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Eklenti yüklenmedi: %1$@. %2$@" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Plugin chưa được tải: %1$@. %2$@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "插件未加载:%1$@。%2$@" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "外掛未載入:%1$@。%2$@" - } - } - } - }, "Plugin registry requires a newer app version" : { "localizations" : { "fr" : { @@ -146076,16 +145950,6 @@ } } }, - "Restart to activate" : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Redémarrer pour activer" - } - } - } - }, "Restore" : { "localizations" : { "fr" : { @@ -202110,46 +201974,6 @@ } } }, - "Update %@ plugin" : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mettre à jour le plugin %@" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ 플러그인 업데이트" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ eklentisini güncelle" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cập nhật plugin %@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "更新 %@ 插件" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "更新 %@ 外掛" - } - } - } - }, "Update Available…" : { "localizations" : { "fr" : { @@ -202225,46 +202049,6 @@ } } }, - "Update Plugin" : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mettre à jour le plugin" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "플러그인 업데이트" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Eklentiyi Güncelle" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cập nhật plugin" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "更新插件" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "更新外掛" - } - } - } - }, "Update Required" : { }, @@ -204483,128 +204267,128 @@ } } }, - "v%@ · %@" : { + "v%@ available" : { "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "v%1$@ · %2$@" - } - }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "v%1$@ · %2$@" + "value" : "v%@ disponible" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "v%1$@ · %2$@" + "value" : "v%@ 사용 가능" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "v%@ · %@" + "value" : "v%@ mevcut" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "v%@ · %@" + "value" : "Đã có v%@" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "v%@ · %@" + "value" : "v%@ 可用" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "v%1$@ · %2$@" + "value" : "v%@ 可用" } } } }, - "v%@ available" : { + "v%@ ready to activate" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", - "value" : "v%@ disponible" + "value" : "v%@ prête à être activée" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "v%@ 사용 가능" + "value" : "v%@ 활성화 준비됨" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "v%@ mevcut" + "value" : "v%@ etkinleştirilmeye hazır" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Đã có v%@" + "value" : "v%@ sẵn sàng kích hoạt" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "v%@ 可用" + "value" : "v%@ 准备就绪,可激活" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "v%@ 可用" + "value" : "v%@ 已就緒,可啟用" } } } }, - "v%@ ready to activate" : { + "v%@, %@" : { "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "v%1$@, %2$@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "v%@ prête à être activée" + "value" : "v%1$@, %2$@" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "v%@ 활성화 준비됨" + "value" : "v%1$@, %2$@" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "v%@ etkinleştirilmeye hazır" + "value" : "v%@, %@" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "v%@ sẵn sàng kích hoạt" + "value" : "v%@, %@" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "v%@ 准备就绪,可激活" + "value" : "v%@, %@" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "v%@ 已就緒,可啟用" + "value" : "v%1$@, %2$@" } } } diff --git a/TablePro/ViewModels/AIChatViewModel+SlashCommands.swift b/TablePro/ViewModels/AIChatViewModel+SlashCommands.swift index 50f701e8d4..36eb178746 100644 --- a/TablePro/ViewModels/AIChatViewModel+SlashCommands.swift +++ b/TablePro/ViewModels/AIChatViewModel+SlashCommands.swift @@ -9,7 +9,7 @@ import os extension AIChatViewModel { static let helpMarkdown: String = { let lines = SlashCommand.allCommands - .map { "- `/\($0.name)` · \($0.description)" } + .map { "- `/\($0.name)`: \($0.description)" } .joined(separator: "\n") return String(localized: "**Available commands:**") + "\n\n" + lines }() diff --git a/TablePro/ViewModels/QuickSwitcherViewModel+QueryItems.swift b/TablePro/ViewModels/QuickSwitcherViewModel+QueryItems.swift index d89446b04a..c02ee61c14 100644 --- a/TablePro/ViewModels/QuickSwitcherViewModel+QueryItems.swift +++ b/TablePro/ViewModels/QuickSwitcherViewModel+QueryItems.swift @@ -29,7 +29,7 @@ internal extension QuickSwitcherViewModel { guard let target = targets[targetConnectionId] else { return nil } let subtitle = [favorite.keyword, connectionPath(for: target)] .compactMap { value in value.flatMap { $0.isEmpty ? nil : $0 } } - .joined(separator: " · ") + .joined(separator: ", ") return QuickSwitcherItem( frecencyKey: QuickSwitcherFrecencyKey.savedQuery(favorite.id), name: favorite.name, @@ -63,7 +63,7 @@ internal extension QuickSwitcherViewModel { entry.hasMeasuredDuration ? entry.formattedExecutionTime : "" ] .filter { !$0.isEmpty } - .joined(separator: " · "), + .joined(separator: ", "), payload: entry.query, target: target ) diff --git a/TablePro/ViewModels/QuickSwitcherViewModel.swift b/TablePro/ViewModels/QuickSwitcherViewModel.swift index bae1761571..eb19306fe5 100644 --- a/TablePro/ViewModels/QuickSwitcherViewModel.swift +++ b/TablePro/ViewModels/QuickSwitcherViewModel.swift @@ -778,7 +778,7 @@ internal final class QuickSwitcherViewModel: ObservableObject { let otherSchema = SchemaQualifiedName.explicitSchema(table.schema, implicitSchemaName: browseSchema) let subtitle = [otherSchema, presentation.subtitle] .compactMap { $0?.isEmpty == false ? $0 : nil } - .joined(separator: " · ") + .joined(separator: ", ") return QuickSwitcherItem( frecencyKey: frecencyKey, name: table.name, diff --git a/TablePro/Views/Acknowledgements/AcknowledgementsView.swift b/TablePro/Views/Acknowledgements/AcknowledgementsView.swift index de92874f91..edc1973a6f 100644 --- a/TablePro/Views/Acknowledgements/AcknowledgementsView.swift +++ b/TablePro/Views/Acknowledgements/AcknowledgementsView.swift @@ -38,7 +38,7 @@ struct AcknowledgementsSidebar: View { private func row(for component: ThirdPartyComponent) -> some View { VStack(alignment: .leading, spacing: 1) { Text(component.name) - Text(component.isUnverified ? component.version : "\(component.version) · \(component.spdx)") + Text(component.isUnverified ? component.version : "\(component.version), \(component.spdx)") .font(.caption) .foregroundStyle(.secondary) } diff --git a/TablePro/Views/Connection/PluginRejectedBannerModifier.swift b/TablePro/Views/Connection/PluginRejectedBannerModifier.swift deleted file mode 100644 index 812e3e102a..0000000000 --- a/TablePro/Views/Connection/PluginRejectedBannerModifier.swift +++ /dev/null @@ -1,128 +0,0 @@ -// -// PluginRejectedBannerModifier.swift -// TablePro -// - -import SwiftUI - -struct PluginRejectedBannerModifier: ViewModifier { - let databaseType: DatabaseType - /// Observed, all three. The banner shows and hides on `rejectedPlugins` and draws install progress - /// from the other two; held as plain constants it kept the state it had when the form opened, so - /// a plugin updated from the banner went on being reported as rejected. - @ObservedObject private var pluginManager = PluginManager.shared - @ObservedObject private var registryClient = RegistryClient.shared - @ObservedObject private var installTracker = PluginInstallTracker.shared - - @State private var errorMessage: String? - @State private var showError = false - - private var rejectedPlugin: RejectedPlugin? { - let typeId = databaseType.pluginTypeId - return pluginManager.rejectedPlugins.first { rejected in - rejected.bundleId == typeId || rejected.registryId == typeId - } - } - - private var registryEntry: RegistryPlugin? { - guard let rejected = rejectedPlugin else { return nil } - return pluginManager.registryPlugin(for: rejected) - } - - func body(content: Content) -> some View { - VStack(spacing: 0) { - if let plugin = rejectedPlugin { - banner(for: plugin) - Divider() - } - content - } - .alert(String(localized: "Plugin Update Failed"), isPresented: $showError) { - Button(String(localized: "OK")) {} - } message: { - Text(errorMessage ?? "") - } - } - - @ViewBuilder - private func banner(for plugin: RejectedPlugin) -> some View { - HStack(alignment: .top, spacing: 10) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.yellow) - .font(.body) - - VStack(alignment: .leading, spacing: 4) { - Text(String(localized: "Plugin not loaded")) - .font(.callout.weight(.semibold)) - Text(plugin.reason) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - - Spacer(minLength: 8) - - if let registryPlugin = registryEntry { - progressOrButton(registryPlugin: registryPlugin) - } - } - .padding(12) - .background(Color.yellow.opacity(0.08)) - .accessibilityElement(children: .contain) - .accessibilityLabel( - String(format: String(localized: "Plugin not loaded: %@. %@"), plugin.name, plugin.reason) - ) - } - - @ViewBuilder - private func progressOrButton(registryPlugin: RegistryPlugin) -> some View { - if let progress = installTracker.state(for: registryPlugin.id) { - switch progress.phase { - case .downloading(let fraction): - ProgressView(value: fraction) - .frame(width: 60) - .progressViewStyle(.linear) - case .installing: - ProgressView().controlSize(.small) - case .stagedPendingActivation: - Image(systemName: "clock.arrow.circlepath") - .foregroundStyle(.orange) - .accessibilityLabel(String(localized: "Restart to activate")) - case .completed: - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - .accessibilityLabel(String(localized: "Installed")) - case .failed: - updateButton(registryPlugin: registryPlugin) - } - } else { - updateButton(registryPlugin: registryPlugin) - } - } - - @ViewBuilder - private func updateButton(registryPlugin: RegistryPlugin) -> some View { - Button(String(localized: "Update Plugin")) { - triggerUpdate(registryPlugin) - } - .buttonStyle(.borderedProminent) - .controlSize(.small) - .accessibilityLabel(String(format: String(localized: "Update %@ plugin"), registryPlugin.name)) - } - - private func triggerUpdate(_ registryPlugin: RegistryPlugin) { - Task { - let result = await pluginManager.performRegistryUpdate(registryPlugin) - if case .failed(let error) = result { - errorMessage = error.localizedDescription - showError = true - } - } - } -} - -extension View { - func pluginRejectedBanner(for databaseType: DatabaseType) -> some View { - modifier(PluginRejectedBannerModifier(databaseType: databaseType)) - } -} diff --git a/TablePro/Views/Connection/ProjectFolder/ProjectFolderCandidateList.swift b/TablePro/Views/Connection/ProjectFolder/ProjectFolderCandidateList.swift index 5f69757502..efa43315d0 100644 --- a/TablePro/Views/Connection/ProjectFolder/ProjectFolderCandidateList.swift +++ b/TablePro/Views/Connection/ProjectFolder/ProjectFolderCandidateList.swift @@ -110,7 +110,7 @@ struct ProjectFolderCandidateRow: View { } private var provenance: String { - [typeName, candidate.sourceRelativePath, candidate.sourceKey].joined(separator: " · ") + [typeName, candidate.sourceRelativePath, candidate.sourceKey].joined(separator: ", ") } private var helpText: String { diff --git a/TablePro/Views/Connection/ProjectFolder/ProjectFolderScanSheet.swift b/TablePro/Views/Connection/ProjectFolder/ProjectFolderScanSheet.swift index d4f7ae3a2f..193859cfda 100644 --- a/TablePro/Views/Connection/ProjectFolder/ProjectFolderScanSheet.swift +++ b/TablePro/Views/Connection/ProjectFolder/ProjectFolderScanSheet.swift @@ -112,19 +112,15 @@ 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) + ProjectFolderScanNotes(notes: selectedCandidate.map(Self.notes) ?? []) } - private static func notes(for candidate: ScannedConnectionCandidate) -> String { + 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: " · ") + return notes } private var selectedCandidate: ScannedConnectionCandidate? { @@ -167,3 +163,18 @@ struct ProjectFolderScanSheet: View { } } } + +internal struct ProjectFolderScanNotes: View { + let notes: [String] + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + ForEach(notes.indices, id: \.self) { index in + Text(verbatim: notes[index]) + } + } + .font(.footnote) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, minHeight: 30, alignment: .topLeading) + } +} diff --git a/TablePro/Views/Connection/TypeChooser/DatabaseTypeChooserSheet.swift b/TablePro/Views/Connection/TypeChooser/DatabaseTypeChooserSheet.swift index 22b8ba735f..0017c0d240 100644 --- a/TablePro/Views/Connection/TypeChooser/DatabaseTypeChooserSheet.swift +++ b/TablePro/Views/Connection/TypeChooser/DatabaseTypeChooserSheet.swift @@ -151,20 +151,17 @@ struct DatabaseTypeChooserSheet: View { } } -private struct DatabaseTypeChooserRow: View { +internal struct DatabaseTypeChooserRow: View { @ObservedObject private var pluginManager = PluginManager.shared let type: DatabaseType let isCurrent: Bool var body: some View { HStack(spacing: 12) { - /// The row's icon is decoration the name already carries, and left unhidden it - /// publishes its own element, so VoiceOver reads "Cylinder Shape, Filled" ahead of the - /// driver. `Image(decorative:)` cannot cover it, because `DatabaseType.iconImage` also - /// returns an SF Symbol. + /// Hidden here because `Image(decorative:)` cannot cover the SF Symbol `iconImage` may return. type.iconImage .renderingMode(.template) - .foregroundStyle(type.themeColor) + .selectionAwareTint(type.themeColor) .frame(width: 26, height: 26) .accessibilityHidden(true) @@ -183,7 +180,7 @@ private struct DatabaseTypeChooserRow: View { if isCurrent { Image(systemName: "checkmark") - .foregroundStyle(.tint) + .selectionAwareTint(Color.accentColor) } if shouldShowNotInstalledBadge { diff --git a/TablePro/Views/Editor/QueryContainerPicker.swift b/TablePro/Views/Editor/QueryContainerPicker.swift index bfae5178fb..d5048709d8 100644 --- a/TablePro/Views/Editor/QueryContainerPicker.swift +++ b/TablePro/Views/Editor/QueryContainerPicker.swift @@ -2,10 +2,6 @@ // QueryContainerPicker.swift // TablePro // -// Per-tab container (database/schema) selector shown in the query editor -// toolbar. Binds a query tab to the container its SQL runs in, so each tab -// can target a different database without clearing the others. -// import SwiftUI @@ -36,11 +32,11 @@ struct QueryContainerPicker: View { containers.first(where: { $0.name == selectedName })?.icon ?? "cylinder" } - /// What the tab is bound to, spelled the way the sidebar spells a qualified object. + /// Dot-qualified, the way the sidebar and SQL spell a qualified object. private var scopeLabel: String { let base = selectedName.isEmpty ? entityName : selectedName guard let schemaName, !schemaName.isEmpty, !selectedName.isEmpty else { return base } - return "\(base) · \(schemaName)" + return "\(base).\(schemaName)" } private var scopeAccessibilityLabel: String { diff --git a/TablePro/Views/QueryInsights/QueryInsightsGroupList.swift b/TablePro/Views/QueryInsights/QueryInsightsGroupList.swift index 8cdd2d1e60..86029f4fea 100644 --- a/TablePro/Views/QueryInsights/QueryInsightsGroupList.swift +++ b/TablePro/Views/QueryInsights/QueryInsightsGroupList.swift @@ -126,7 +126,7 @@ struct QueryInsightsGroupList: View { group.failureCount.formatted() )) } - return parts.joined(separator: " · ") + return parts.joined(separator: ", ") } private var isFailurePanel: Bool { @@ -190,7 +190,7 @@ struct QueryInsightsRegressionList: View { private func detail(_ regression: QueryInsightsRegression) -> String { String( format: String( - localized: "%1$@ → %2$@ · %3$@ runs before, %4$@ now", + localized: "%1$@ → %2$@, %3$@ runs before, %4$@ now", comment: "Regression detail: prior duration, recent duration, prior count, recent count" ), QueryDurationFormatter.string(from: regression.priorMeanDuration), diff --git a/TablePro/Views/QueryPlan/QueryPlanComparisonView.swift b/TablePro/Views/QueryPlan/QueryPlanComparisonView.swift index 5eeb65e092..ac3e5b0944 100644 --- a/TablePro/Views/QueryPlan/QueryPlanComparisonView.swift +++ b/TablePro/Views/QueryPlan/QueryPlanComparisonView.swift @@ -161,8 +161,13 @@ private struct QueryPlanNodeChangeRow: View { Text(style.label) .font(.caption.weight(.medium)) .foregroundStyle(differentiateWithoutColor ? Color.secondary : style.tint) - Text(change.title) + Text(change.operation) .font(.callout.weight(.medium)) + if let relationName = change.relationName { + Text(relationName) + .font(.callout) + .foregroundStyle(.secondary) + } } ForEach(change.fieldChanges) { field in diff --git a/TablePro/Views/QueryPlan/QueryPlanResultView.swift b/TablePro/Views/QueryPlan/QueryPlanResultView.swift index 21bca1be9c..a261be523f 100644 --- a/TablePro/Views/QueryPlan/QueryPlanResultView.swift +++ b/TablePro/Views/QueryPlan/QueryPlanResultView.swift @@ -305,7 +305,7 @@ struct QueryPlanResultView: View { private func baselineLabel(_ baseline: QueryPlanSnapshotSummary) -> String { let stamp = baseline.capturedAt.formatted(date: .abbreviated, time: .shortened) let duration = QueryDurationFormatter.string(from: baseline.executionTime) - return String(format: String(localized: "%1$@ · %2$@"), stamp, duration) + return String(format: String(localized: "%1$@ (%2$@)"), stamp, duration) } private var fontSizeStepper: some View { diff --git a/TablePro/Views/Results/QueryTimingBreakdown.swift b/TablePro/Views/Results/QueryTimingBreakdown.swift index 747568d96f..0796f947a6 100644 --- a/TablePro/Views/Results/QueryTimingBreakdown.swift +++ b/TablePro/Views/Results/QueryTimingBreakdown.swift @@ -56,7 +56,7 @@ struct QueryTimingBreakdown: Equatable { } self.rows = rows - summary = rows.map { "\($0.label) \($0.value)" }.joined(separator: " · ") + summary = rows.map { "\($0.label) \($0.value)" }.joined(separator: ", ") } } diff --git a/TablePro/Views/Results/ResultChartToolbar.swift b/TablePro/Views/Results/ResultChartToolbar.swift index 3d7834ea3c..f9ad059e97 100644 --- a/TablePro/Views/Results/ResultChartToolbar.swift +++ b/TablePro/Views/Results/ResultChartToolbar.swift @@ -48,12 +48,8 @@ struct ResultChartToolbar: View { HStack(spacing: 6) { ScrollView(.horizontal) { - HStack(spacing: 5) { - ForEach(Array(notices.enumerated()), id: \.offset) { index, notice in - if index > 0 { - Text("·") - .foregroundStyle(.quaternary) - } + HStack(spacing: 12) { + ForEach(Array(notices.enumerated()), id: \.offset) { _, notice in Text(notice) .foregroundStyle(.secondary) } diff --git a/TablePro/Views/ServerDashboard/SlowQueryListView.swift b/TablePro/Views/ServerDashboard/SlowQueryListView.swift index a49a4e90c2..74e2a2e39f 100644 --- a/TablePro/Views/ServerDashboard/SlowQueryListView.swift +++ b/TablePro/Views/ServerDashboard/SlowQueryListView.swift @@ -56,12 +56,11 @@ struct SlowQueryListView: View { .font(.system(.caption, design: .monospaced)) .lineLimit(2) .truncationMode(.tail) - HStack(spacing: 4) { + HStack(spacing: 8) { if !query.user.isEmpty { Text(query.user) } if !query.database.isEmpty { - Text("·") Text(query.database) } } diff --git a/TablePro/Views/Settings/License/LicenseDevicesSection.swift b/TablePro/Views/Settings/License/LicenseDevicesSection.swift index f8df30d95c..39f8f004b0 100644 --- a/TablePro/Views/Settings/License/LicenseDevicesSection.swift +++ b/TablePro/Views/Settings/License/LicenseDevicesSection.swift @@ -158,9 +158,7 @@ struct LicenseDevicesSection: View { // MARK: - Helpers - /// Which Mac you are on, when it last checked in, and what it is running. Last use is the fact - /// somebody actually decides on when choosing a seat to release, so it earns its place over the - /// app version, which is carried only for this Mac where the OS line is already short. + /// Last use is what decides which seat to release, so another Mac shows it where this one says "This Mac". private func secondaryLine(for device: LicenseActivationInfo, isThisMac: Bool) -> String { var parts: [String] = [] @@ -171,7 +169,7 @@ struct LicenseDevicesSection: View { } parts.append(device.osVersion) - return parts.joined(separator: " · ") + return parts.joined(separator: ", ") } private static let lastValidatedFormatter: ISO8601DateFormatter = { diff --git a/TablePro/Views/Settings/License/LicenseTeamSection.swift b/TablePro/Views/Settings/License/LicenseTeamSection.swift index 86c8c184a0..6f96fee428 100644 --- a/TablePro/Views/Settings/License/LicenseTeamSection.swift +++ b/TablePro/Views/Settings/License/LicenseTeamSection.swift @@ -82,6 +82,6 @@ struct LicenseTeamSection: View { private func roleDescription(_ member: LicenseTeamMember) -> String { let role = TeamRole(rawValue: member.role).displayName guard isCurrentUser(member) else { return role } - return String(format: String(localized: "%@ · You"), role) + return String(format: String(localized: "%@ (You)"), role) } } diff --git a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift index 390306adee..e5c9398dc2 100644 --- a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift +++ b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift @@ -328,10 +328,9 @@ struct InstalledPluginsView: View { Text(plugin.name) .lineLimit(1) - HStack(spacing: 4) { + HStack(spacing: 8) { Text("v\(plugin.version)") if let capability = plugin.capabilities.first { - Text("·") Text(capability.displayName) } } @@ -369,7 +368,7 @@ struct InstalledPluginsView: View { Text(selected.name) .font(.title3.weight(.semibold)) - Text("v\(selected.version) · \(selected.source == .builtIn ? String(localized: "Built-in") : String(localized: "User-installed"))") + Text("v\(selected.version), \(selected.source == .builtIn ? String(localized: "Built-in") : String(localized: "User-installed"))") .font(.subheadline) .foregroundStyle(.secondary) diff --git a/TablePro/Views/VersionHistory/VersionHistoryFormatting.swift b/TablePro/Views/VersionHistory/VersionHistoryFormatting.swift index 7aadf83fdf..4663c05bcc 100644 --- a/TablePro/Views/VersionHistory/VersionHistoryFormatting.swift +++ b/TablePro/Views/VersionHistory/VersionHistoryFormatting.swift @@ -31,7 +31,7 @@ internal enum VersionHistoryFormatting { if let revision = entry.reference.shortRevision { parts.append(revision) } - return parts.joined(separator: " · ") + return parts.joined(separator: ", ") } static func rowSubtitle(for entry: VersionHistoryEntry) -> String { @@ -46,7 +46,7 @@ internal enum VersionHistoryFormatting { if !showsDateInTitle, let date = entry.date { parts.append(date.formatted(date: .abbreviated, time: .omitted)) } - return parts.joined(separator: " · ") + return parts.joined(separator: ", ") } static func shortLabel(for entry: VersionHistoryEntry) -> String { diff --git a/TableProMobile/TableProMobile/Intents/ConnectionEntity.swift b/TableProMobile/TableProMobile/Intents/ConnectionEntity.swift index 64b95c810f..c150e996da 100644 --- a/TableProMobile/TableProMobile/Intents/ConnectionEntity.swift +++ b/TableProMobile/TableProMobile/Intents/ConnectionEntity.swift @@ -14,7 +14,7 @@ struct ConnectionEntity: AppEntity { var displayRepresentation: DisplayRepresentation { DisplayRepresentation( title: "\(name)", - subtitle: "\(databaseType) · \(host)" + subtitle: "\(databaseType), \(host)" ) } diff --git a/TableProMobile/TableProMobile/Localizable.xcstrings b/TableProMobile/TableProMobile/Localizable.xcstrings index f73f8a494d..0b472aced0 100644 --- a/TableProMobile/TableProMobile/Localizable.xcstrings +++ b/TableProMobile/TableProMobile/Localizable.xcstrings @@ -325,40 +325,6 @@ } } }, - "%@ · %@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "%1$@ · %2$@" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$@ · %2$@" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$@ · %2$@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$@ · %2$@" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$@ · %2$@" - } - } - } - }, "%@ → %@" : { "extractionState" : "stale", "localizations" : { diff --git a/TableProMobile/TableProMobileTests/Intents/ConnectionEntityTests.swift b/TableProMobile/TableProMobileTests/Intents/ConnectionEntityTests.swift new file mode 100644 index 0000000000..d0a1ac6895 --- /dev/null +++ b/TableProMobile/TableProMobileTests/Intents/ConnectionEntityTests.swift @@ -0,0 +1,16 @@ +import AppIntents +import Foundation +@testable import TableProMobile +import Testing + +@Suite("ConnectionEntity") +struct ConnectionEntityTests { + @Test("the Shortcuts subtitle names the type and the host with a comma") + func subtitleJoinsTypeAndHost() throws { + let entity = ConnectionEntity(id: UUID(), name: "Prod", host: "db.example.com", databaseType: "PostgreSQL") + + let subtitle = try #require(entity.displayRepresentation.subtitle) + + #expect(String(localized: subtitle) == "PostgreSQL, db.example.com") + } +} diff --git a/TableProTests/Core/AI/SlashCommandTests.swift b/TableProTests/Core/AI/SlashCommandTests.swift index aee2134259..71978ad834 100644 --- a/TableProTests/Core/AI/SlashCommandTests.swift +++ b/TableProTests/Core/AI/SlashCommandTests.swift @@ -109,4 +109,13 @@ struct SlashCommandTests { #expect(AIChatViewModel.helpMarkdown.contains("/\(command.name)")) } } + + @Test("Each help line pairs a command with its description through a colon") + @MainActor + func helpLinePairsCommandWithDescription() { + let lines = AIChatViewModel.helpMarkdown.components(separatedBy: "\n") + for command in SlashCommand.allCommands { + #expect(lines.contains("- `/\(command.name)`: \(command.description)")) + } + } } diff --git a/TableProTests/Core/Autocomplete/SQLCompletionProviderTests.swift b/TableProTests/Core/Autocomplete/SQLCompletionProviderTests.swift index 2fc9f42af6..eca99767d0 100644 --- a/TableProTests/Core/Autocomplete/SQLCompletionProviderTests.swift +++ b/TableProTests/Core/Autocomplete/SQLCompletionProviderTests.swift @@ -900,10 +900,7 @@ struct SQLCompletionProviderTests { @Test("Column detail combines PK, NOT NULL, and data type") func testColumnDetailCombined() { let item = SQLCompletionItem.column("id", dataType: "INT", tableName: "users", isPrimaryKey: true, isNullable: false) - let detail = item.detail ?? "" - #expect(detail.contains("PK")) - #expect(detail.contains("NOT NULL")) - #expect(detail.contains("INT")) + #expect(item.detail == "PK, NOT NULL, INT") } @Test("Nullable column does not show NOT NULL") diff --git a/TableProTests/Core/Services/ProjectImport/ProjectYamlExtractorTests.swift b/TableProTests/Core/Services/ProjectImport/ProjectYamlExtractorTests.swift index b132cd6035..f966d1d4ed 100644 --- a/TableProTests/Core/Services/ProjectImport/ProjectYamlExtractorTests.swift +++ b/TableProTests/Core/Services/ProjectImport/ProjectYamlExtractorTests.swift @@ -4,8 +4,8 @@ // import Foundation -import Testing @testable import TablePro +import Testing struct RailsDatabaseYamlExtractorTests { private let root = URL(fileURLWithPath: "/tmp/rails-project") @@ -80,6 +80,18 @@ struct RailsDatabaseYamlExtractorTests { #expect(candidate?.warnings.isEmpty == false) } + @Test("Several unresolvable ERB values are reported once") + func testUnresolvedErbWarnsOnce() { + let contents = """ + development: + adapter: postgresql + database: myapp + username: <%= Rails.application.credentials.db_user %> + password: <%= Rails.application.credentials.db_password %> + """ + #expect(extract(contents).first?.warnings.count == 1) + } + @Test("A multi database development section picks the nested entry") func testMultiDatabaseSection() { let contents = """ @@ -106,7 +118,6 @@ struct RailsDatabaseYamlExtractorTests { } struct DockerComposeExtractorTests { - private func extract(_ contents: String, environment: DotenvDocument? = nil) -> [ScannedConnectionCandidate] { DockerComposeExtractor.extract( contents: contents, @@ -131,7 +142,7 @@ struct DockerComposeExtractorTests { 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.port == 8_001) #expect(candidate?.parsedURL.database == "appdb") } @@ -145,7 +156,7 @@ struct DockerComposeExtractorTests { POSTGRES_PASSWORD: apppass """ let candidate = extract(contents).first - #expect(candidate?.parsedURL.port == 5432) + #expect(candidate?.parsedURL.port == 5_432) #expect(candidate?.warnings.isEmpty == false) } @@ -165,7 +176,7 @@ struct DockerComposeExtractorTests { let candidate = extract(contents).first #expect(candidate?.parsedURL.type == .mysql) #expect(candidate?.parsedURL.database == "appdb") - #expect(candidate?.parsedURL.port == 3307) + #expect(candidate?.parsedURL.port == 3_307) } @Test("TiDB and Databend images map to their own types and ports") @@ -412,7 +423,7 @@ struct DockerComposeExtractorTests { """ 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?.parsedURL.port == 15_432) #expect(candidate?.hasPassword == true) } @@ -494,12 +505,11 @@ struct DockerComposeExtractorTests { """ let candidate = extract(contents).first #expect(candidate?.parsedURL.type == .mariadb) - #expect(candidate?.parsedURL.port == 3399) + #expect(candidate?.parsedURL.port == 3_399) } } struct SpringYamlExtractorTests { - @Test("A nested datasource block is read") func testNestedDatasource() { let contents = """ diff --git a/TableProTests/Core/Services/WorkspaceRailStoreTests.swift b/TableProTests/Core/Services/WorkspaceRailStoreTests.swift index 46223d43e6..d50cb78f30 100644 --- a/TableProTests/Core/Services/WorkspaceRailStoreTests.swift +++ b/TableProTests/Core/Services/WorkspaceRailStoreTests.swift @@ -593,13 +593,13 @@ struct WorkspaceRailCellTextTests { @Test("The tooltip spells out what the truncated labels cannot") func tooltipCarriesFullIdentity() { let text = WorkspaceRailCellView.tooltipText(for: makeEntry()) - #expect(text == "staging · MySQL · db.internal · app") + #expect(text == "staging, MySQL, db.internal, app") } @Test("The tooltip omits parts the connection does not have") func tooltipOmitsMissingParts() { let text = WorkspaceRailCellView.tooltipText(for: makeEntry(host: "", container: "")) - #expect(text == "staging · MySQL") + #expect(text == "staging, MySQL") } @Test("VoiceOver hears the name, the engine, the container, and the connection state") diff --git a/TableProTests/Helpers/UserFacingSourceScan.swift b/TableProTests/Helpers/UserFacingSourceScan.swift new file mode 100644 index 0000000000..5c8f4fd264 --- /dev/null +++ b/TableProTests/Helpers/UserFacingSourceScan.swift @@ -0,0 +1,117 @@ +// +// UserFacingSourceScan.swift +// TableProTests +// + +import Foundation + +enum UserFacingSourceScan { + static func scanAppTarget( + offends: (_ line: String, _ previous: String) -> Bool + ) throws -> (scanned: Int, offenders: [String]) { + let root = try repoRoot().appendingPathComponent("TablePro", isDirectory: true) + guard let enumerator = FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil) else { + throw ScanError.sourceTreeNotFound + } + + var scanned = 0 + var found: [String] = [] + for case let url as URL in enumerator where url.pathExtension == "swift" { + scanned += 1 + let text = try String(contentsOf: url, encoding: .utf8) + var previous = "" + for (index, line) in text.components(separatedBy: .newlines).enumerated() { + defer { if !line.trimmingCharacters(in: .whitespaces).isEmpty { previous = line } } + guard offends(line, previous) else { continue } + found.append("\(url.lastPathComponent):\(index + 1): \(line.trimmingCharacters(in: .whitespaces))") + } + } + return (scanned, found.sorted()) + } + + static func userFacingCode(line: String, previous: String) -> String? { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard !trimmed.hasPrefix("//") else { return nil } + guard !isLogMessage(trimmed, previous: previous.trimmingCharacters(in: .whitespaces)) else { return nil } + return withoutTrailingComment(trimmed) + } + + private static let levels = "(debug|info|notice|warning|error|fault|critical|trace|log)" + private static let logCall = #"(\blogger|Logger|Log)\s*\.\s*"# + levels + #"\s*\(|Logger\([^)]*\)\s*\.\s*"# + + levels + #"\s*\(|\bos_log\s*\("# + private static let chainedLevel = #"^\.\s*"# + levels + #"\s*\("# + private static let logReceiverAtEnd = #"(\blogger|Logger|Log|Logger\([^)]*\))\s*$"# + + /// The level name alone is not a log call: `.info(for:)` and a banner's `.notice(_:)` are user-facing. + private static func isLogMessage(_ line: String, previous: String) -> Bool { + if matches(line, logCall) { return true } + let previousCode = previous.hasPrefix("//") ? "" : withoutTrailingComment(previous) + if matches(line, chainedLevel), matches(previousCode, logReceiverAtEnd) { return true } + return matches(previousCode, logCall) && leavesParenthesisOpen(previousCode) + } + + private static func matches(_ text: String, _ pattern: String) -> Bool { + text.range(of: pattern, options: .regularExpression) != nil + } + + private static func leavesParenthesisOpen(_ line: String) -> Bool { + var depth = 0 + var insideQuotes = false + var escaped = false + for character in line { + if escaped { + escaped = false + continue + } + switch character { + case "\\": escaped = true + case "\"": insideQuotes.toggle() + case "(" where !insideQuotes: depth += 1 + case ")" where !insideQuotes: depth -= 1 + default: break + } + } + return depth > 0 + } + + private static func withoutTrailingComment(_ line: String) -> String { + let characters = Array(line) + var insideQuotes = false + var escaped = false + for index in characters.indices { + let character = characters[index] + if escaped { + escaped = false + continue + } + if character == "\\" { + escaped = true + continue + } + if character == "\"" { + insideQuotes.toggle() + continue + } + if !insideQuotes, character == "/", index + 1 < characters.count, characters[index + 1] == "/" { + return String(characters[.. URL { + var directory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + for _ in 0 ..< 12 { + if FileManager.default.fileExists(atPath: directory.appendingPathComponent("TablePro.xcodeproj").path) { + return directory + } + directory = directory.deletingLastPathComponent() + } + throw ScanError.repoRootNotFound + } + + private enum ScanError: Error { + case repoRootNotFound + case sourceTreeNotFound + } +} diff --git a/TableProTests/Models/LicensePresentationTests.swift b/TableProTests/Models/LicensePresentationTests.swift index 8d5360813d..90080d0795 100644 --- a/TableProTests/Models/LicensePresentationTests.swift +++ b/TableProTests/Models/LicensePresentationTests.swift @@ -184,13 +184,14 @@ struct LicensePresentationTests { ) #expect(line.contains("Yearly")) #expect(line.contains("12 Mar 2027")) + #expect(line == "Starter, Yearly, \(String(format: String(localized: "Expires %@"), "12 Mar 2027"))") } @Test("A license with no billing cycle still reads cleanly") func planWithoutCycle() { let line = LicensePresentation.planDescription(tier: "starter", billingCycle: nil, expiry: "1 Jan 2030") #expect(line.hasPrefix("Starter")) - #expect(line.contains(" · · ") == false, "No empty segment") + #expect(line.contains(", , ") == false, "No empty segment") } // MARK: - Key masking diff --git a/TableProTests/Models/Query/QueryPlanDiffTests.swift b/TableProTests/Models/Query/QueryPlanDiffTests.swift index 6e43d24405..42d75b3371 100644 --- a/TableProTests/Models/Query/QueryPlanDiffTests.swift +++ b/TableProTests/Models/Query/QueryPlanDiffTests.swift @@ -226,6 +226,24 @@ struct QueryPlanDiffTests { #expect(diff.summary.count == QueryPlanSummaryMetric.allCases.count) #expect(diff.summary.allSatisfy { !$0.hasChange }) } + + // MARK: - Node titles + + @Test("A changed node names its relation after a comma, and drops an empty one") + func nodeTitleSeparatesRelationWithComma() { + func change(relation: String?) -> QueryPlanNodeChange { + QueryPlanNodeChange( + kind: .changed, path: "0", operation: "Seq Scan", relation: relation, + schema: nil, alias: nil, fieldChanges: [] + ) + } + + #expect(change(relation: "users").title == "Seq Scan, users") + #expect(change(relation: "users").relationName == "users") + #expect(change(relation: "").title == "Seq Scan") + #expect(change(relation: "").relationName == nil) + #expect(change(relation: nil).title == "Seq Scan") + } } enum QueryPlanFixture { diff --git a/TableProTests/Models/QueryTimingTests.swift b/TableProTests/Models/QueryTimingTests.swift index 0f69ff4433..cdc009a99b 100644 --- a/TableProTests/Models/QueryTimingTests.swift +++ b/TableProTests/Models/QueryTimingTests.swift @@ -52,7 +52,7 @@ struct QueryTimingTests { let firstRow = folded?.firstRow ?? -1 #expect(folded?.total == 3.0) - #expect(abs(firstRow - 0.3) < 0.000_001) + #expect(abs(firstRow - 0.3) < 1e-6) #expect(folded?.server == nil) } @@ -91,4 +91,18 @@ struct QueryTimingBreakdownTests { #expect(breakdown.rows.count == 1) #expect(breakdown.rows[0].id == "elapsed") } + + @Test("The tooltip summary separates the measured parts with a comma") + func summarySeparatesPartsWithComma() { + let breakdown = QueryTimingBreakdown( + timing: PluginQueryTiming(total: 3.4, firstRow: 0.012, server: 0.009) + ) + let rows = breakdown.rows + + #expect(rows.count == 4) + #expect(breakdown.summary == """ + \(rows[0].label) \(rows[0].value), \(rows[1].label) \(rows[1].value), \ + \(rows[2].label) \(rows[2].value), \(rows[3].label) \(rows[3].value) + """) + } } diff --git a/TableProTests/Plugins/DynamoDB/DynamoDBTableManagementTests.swift b/TableProTests/Plugins/DynamoDB/DynamoDBTableManagementTests.swift index 8c4b787223..5e80ee3d69 100644 --- a/TableProTests/Plugins/DynamoDB/DynamoDBTableManagementTests.swift +++ b/TableProTests/Plugins/DynamoDB/DynamoDBTableManagementTests.swift @@ -234,6 +234,45 @@ struct DynamoDBTableManagementTests { ) == nil) } + // MARK: - Summaries + + @Test("The table summary keeps the capacity's own comma inside its part") + func tableSummarySeparatesParts() throws { + let schema = try DynamoDBTableSchema(describeTableResponse: DynamoDBJSON.parse(#""" + {"Table": {"TableName": "Orders", "TableStatus": "ACTIVE", + "KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}], + "AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}], + "ProvisionedThroughput": {"ReadCapacityUnits": 5, "WriteCapacityUnits": 2}, + "TableClassSummary": {"TableClass": "STANDARD"}, "DeletionProtectionEnabled": true}} + """#)) + let expected = [ + String(format: String(localized: "Provisioned: %1$lld read, %2$lld write"), Int64(5), Int64(2)), + String(localized: "Standard"), + String(localized: "Deletion protection on"), + String(localized: "Item count is approximate, updated about every six hours"), + ].joined(separator: "; ") + + #expect(DynamoDBTableDefinition.summary(schema) == expected) + } + + @Test("The read status lists its counts with commas and gives a note its own sentence") + func readStatusSeparatesCountsFromNotes() { + let stats = DynamoDBReadStats(returned: 5, scanned: 12, readUnits: 3, reportedReadUnits: true) + let note = String(localized: "This SELECT reads the whole table") + let counts = [ + String(localized: "Scan"), + String(format: String(localized: "%@ returned"), "5"), + String(format: String(localized: "%@ read"), "12"), + String(format: String(localized: "%@ RCU"), "3"), + ].joined(separator: ", ") + + #expect(DynamoDBPluginDriver.statusMessage(access: String(localized: "Scan"), stats: stats) == counts) + #expect( + DynamoDBPluginDriver.statusMessage(access: String(localized: "Scan"), stats: stats, extra: [note]) + == "\(counts). \(note)" + ) + } + // MARK: - Indexes and drop @Test("An index edited in place is refused, and so is dropping the primary key or a local index") diff --git a/TableProTests/Plugins/MongoResultRebuildTests.swift b/TableProTests/Plugins/MongoResultRebuildTests.swift index 42fefd40da..009a0873dd 100644 --- a/TableProTests/Plugins/MongoResultRebuildTests.swift +++ b/TableProTests/Plugins/MongoResultRebuildTests.swift @@ -56,4 +56,13 @@ struct MongoResultRebuildTests { #expect(result().withRowLocators(nil).rowLocators == nil) #expect(result().withRowLocators(["a", nil, "c"]).rowLocators == ["a", nil, "c"]) } + + @Test("Printed lines in a status are told apart even when a line holds a comma") + func printedSummarySeparatesLines() { + #expect(MongoScriptText.printedSummary(["inserted 2, skipped 1", "done"]) == "inserted 2, skipped 1; done") + #expect(MongoScriptText.printedSummary(["only"]) == "only") + let long = MongoScriptText.printedSummary([String(repeating: "a", count: 300), String(repeating: "b", count: 300)]) + #expect(long.count == 401) + #expect(long.hasSuffix("…")) + } } diff --git a/TableProTests/Plugins/TypesenseDriverTests.swift b/TableProTests/Plugins/TypesenseDriverTests.swift index 1c50b08287..c881e28774 100644 --- a/TableProTests/Plugins/TypesenseDriverTests.swift +++ b/TableProTests/Plugins/TypesenseDriverTests.swift @@ -714,6 +714,18 @@ struct TypesenseApiKeysTests { #expect(wildcard.map(\.scope) == [.server]) } + @Test("A key's comment keeps its prefix apart from a comma separated collection list") + func commentSeparatesPrefixFromCollections() throws { + let prefix = String(format: String(localized: "Starts with %@"), "JrJX") + let scoped = TypesenseApiKey( + id: 1, description: "", actions: [], collections: ["books", "authors"], valuePrefix: "JrJX", expiresAt: nil + ) + #expect(TypesenseApiKeys.comment(for: scoped) == "\(prefix); books, authors") + + let keys = TypesenseApiKeys.keys(from: payload) + #expect(TypesenseApiKeys.comment(for: keys[1]) == String(localized: "All collections")) + } + @Test("A create request carries the description, actions and collections") func buildsACreateRequest() throws { let request = try #require(TypesenseApiKeys.createRequest( diff --git a/TableProTests/ViewModels/QuickSwitcherCrossSchemaTests.swift b/TableProTests/ViewModels/QuickSwitcherCrossSchemaTests.swift index 569413417d..15493b8f29 100644 --- a/TableProTests/ViewModels/QuickSwitcherCrossSchemaTests.swift +++ b/TableProTests/ViewModels/QuickSwitcherCrossSchemaTests.swift @@ -53,7 +53,7 @@ struct QuickSwitcherCrossSchemaTests { func viewNamesSchemaAndKind() { let rows = items([table("active", "attendance", type: .view), table("summary", "public", type: .materializedView)]) - #expect(rows.map(\.subtitle) == ["attendance · View", "Materialized View"]) + #expect(rows.map(\.subtitle) == ["attendance, View", "Materialized View"]) } @Test("A table on an engine without schemas names nothing") diff --git a/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift b/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift index 64aa5f834a..99bb092e37 100644 --- a/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift +++ b/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift @@ -1137,7 +1137,7 @@ struct QuickSwitcherViewModelTests { frecencyKey: "favorite_keyworded", name: "Daily totals", kind: .savedQuery, - subtitle: "prod · Analytics / warehouse", + subtitle: "prod, Analytics / warehouse", keyword: "prod", payload: "SELECT 1" ) @@ -1155,6 +1155,39 @@ struct QuickSwitcherViewModelTests { #expect(vm.flatItems.first?.id == "favorite_keyworded") } + @Test("Saved query and history subtitles separate their facts with a comma") + func crossConnectionQuerySubtitlesUseCommas() { + let connection = UUID() + let target = QuickSwitcherTarget( + connectionId: connection, + connectionName: "Production", + databaseName: "app", + schemaName: nil + ) + let favorite = SQLFavorite(name: "Daily totals", query: "SELECT 1", keyword: "daily", connectionId: connection) + let entry = QueryHistoryEntry( + query: "SELECT 2", + connectionId: connection, + databaseName: "app", + databaseType: .postgresql, + source: .editor, + executedAt: Date(timeIntervalSince1970: 0), + executionTime: 0.25, + rowCount: 1, + wasSuccessful: true + ) + + let items = QuickSwitcherViewModel.makeCrossConnectionQueryItems( + favorites: [favorite], + historyEntries: [entry], + targets: [connection: target], + currentConnectionId: connection + ) + + #expect(items.first { $0.kind == .savedQuery }?.subtitle == "daily, Production / app") + #expect(items.first { $0.kind == .queryHistory }?.subtitle == "Production / app, \(entry.formattedExecutionTime)") + } + @Test("Selecting another connection's result records it against that connection") func frecencyRecordsAgainstOwningConnection() { let suite = makeDefaults() diff --git a/TableProTests/Views/Connection/DatabaseTypeChooserRowTests.swift b/TableProTests/Views/Connection/DatabaseTypeChooserRowTests.swift new file mode 100644 index 0000000000..74eed322e6 --- /dev/null +++ b/TableProTests/Views/Connection/DatabaseTypeChooserRowTests.swift @@ -0,0 +1,67 @@ +// +// DatabaseTypeChooserRowTests.swift +// TableProTests +// + +import AppKit +import SwiftUI +@testable import TablePro +import Testing + +@MainActor +struct DatabaseTypeChooserRowTests { + @available(macOS 14.0, *) + private func pixels( + of type: DatabaseType, + isCurrent: Bool, + prominence: BackgroundProminence, + matching matches: (NSColor) -> Bool + ) -> Int { + // A fixed fill: the selection colour follows the user's accent, and a red accent reads as the glyph. + let row = DatabaseTypeChooserRow(type: type, isCurrent: isCurrent) + .frame(width: 360, height: 44) + .background(Color.blue) + .environment(\.backgroundProminence, prominence) + + let renderer = ImageRenderer(content: row) + renderer.scale = 2 + guard let image = renderer.cgImage else { + Issue.record("The row did not render") + return -1 + } + let bitmap = NSBitmapImageRep(cgImage: image) + var count = 0 + for y in 0 ..< bitmap.pixelsHigh { + for x in 0 ..< bitmap.pixelsWide { + guard let color = bitmap.colorAt(x: x, y: y)?.usingColorSpace(.sRGB) else { continue } + if matches(color) { count += 1 } + } + } + return count + } + + private static func isRed(_ color: NSColor) -> Bool { + color.redComponent > 0.6 && color.greenComponent < 0.45 && color.blueComponent < 0.45 + } + + private static func isWhite(_ color: NSColor) -> Bool { + color.redComponent > 0.9 && color.greenComponent > 0.9 && color.blueComponent > 0.9 + } + + @available(macOS 14.0, *) + @Test("The brand glyph leaves its colour for the selected-content colour on an emphasized row") + func brandGlyphFollowsSelection() { + #expect(pixels(of: .redis, isCurrent: false, prominence: .standard, matching: Self.isRed) > 0) + #expect(pixels(of: .redis, isCurrent: false, prominence: .increased, matching: Self.isRed) == 0) + } + + /// The tint style ignores prominence, so the checkmark stayed accent on the accent fill and vanished. + @available(macOS 14.0, *) + @Test("The current-type checkmark turns to the selected-content colour on an emphasized row") + func checkmarkFollowsSelection() { + let marked = pixels(of: .postgresql, isCurrent: true, prominence: .increased, matching: Self.isWhite) + let unmarked = pixels(of: .postgresql, isCurrent: false, prominence: .increased, matching: Self.isWhite) + + #expect(marked - unmarked > 10) + } +} diff --git a/TableProTests/Views/Connection/ProjectFolderScanNotesTests.swift b/TableProTests/Views/Connection/ProjectFolderScanNotesTests.swift new file mode 100644 index 0000000000..634f101d9f --- /dev/null +++ b/TableProTests/Views/Connection/ProjectFolderScanNotesTests.swift @@ -0,0 +1,27 @@ +// +// ProjectFolderScanNotesTests.swift +// TableProTests +// + +import SwiftUI +@testable import TablePro +import Testing + +@MainActor +struct ProjectFolderScanNotesTests { + private func renderedHeight(_ notes: [String]) -> Int { + let renderer = ImageRenderer(content: ProjectFolderScanNotes(notes: notes)) + renderer.proposedSize = ProposedViewSize(width: 480, height: nil) + renderer.scale = 1 + return renderer.cgImage?.height ?? 0 + } + + @Test("Every note keeps a line of its own, so a third one is not clipped") + func thirdNoteIsShown() { + let two = ["Looks like a placeholder value", "Some values are set outside this file"] + let three = two + ["No published port, may be unreachable"] + + #expect(renderedHeight(two) > 0) + #expect(renderedHeight(three) > renderedHeight(two)) + } +} diff --git a/TableProTests/Views/UserFacingEmDashGuardTests.swift b/TableProTests/Views/UserFacingEmDashGuardTests.swift index 116b54b583..7b03203836 100644 --- a/TableProTests/Views/UserFacingEmDashGuardTests.swift +++ b/TableProTests/Views/UserFacingEmDashGuardTests.swift @@ -20,8 +20,9 @@ struct UserFacingEmDashGuardTests { @Test("No em dash reaches a string literal in the app target") func appTargetStringLiteralsAreClean() throws { - let offenders = try Self.offenders() + let (scanned, offenders) = try UserFacingSourceScan.scanAppTarget(offends: Self.carriesProseEmDash) + #expect(scanned > 500, "Expected to scan the app target, scanned \(scanned) files") #expect( offenders.isEmpty, """ @@ -32,41 +33,9 @@ struct UserFacingEmDashGuardTests { ) } - private static func offenders() throws -> [String] { - let root = try repoRoot().appendingPathComponent("TablePro", isDirectory: true) - guard let enumerator = FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil) else { - throw GuardError.sourceTreeNotFound - } - - var found: [String] = [] - for case let url as URL in enumerator where url.pathExtension == "swift" { - let text = try String(contentsOf: url, encoding: .utf8) - let lines = text.components(separatedBy: .newlines) - var previous = "" - for (index, line) in lines.enumerated() { - defer { if !line.trimmingCharacters(in: .whitespaces).isEmpty { previous = line } } - guard carriesProseEmDash(line: line, previous: previous) else { continue } - found.append("\(url.lastPathComponent):\(index + 1): \(line.trimmingCharacters(in: .whitespaces))") - } - } - return found.sorted() - } - - /// A line offends when an em dash survives inside a double-quoted span after the placeholder - /// glyph is removed, and neither it nor the call it continues is a comment or a log message. static func carriesProseEmDash(line: String, previous: String) -> Bool { - let trimmed = line.trimmingCharacters(in: .whitespaces) - guard !trimmed.hasPrefix("//") else { return false } - guard !isLogging(trimmed), !isLogging(previous.trimmingCharacters(in: .whitespaces)) else { return false } - - let withoutPlaceholders = trimmed.replacingOccurrences(of: placeholderGlyph, with: "") - guard withoutPlaceholders.contains(emDash) else { return false } - return quotedSpansContainEmDash(withoutPlaceholders) - } - - private static func isLogging(_ line: String) -> Bool { - let markers = ["logger.", "Logger(", "os_log", ".debug(", ".info(", ".notice(", ".warning(", ".fault("] - return markers.contains { line.contains($0) } + guard let code = UserFacingSourceScan.userFacingCode(line: line, previous: previous) else { return false } + return quotedSpansContainEmDash(code.replacingOccurrences(of: placeholderGlyph, with: "")) } private static func quotedSpansContainEmDash(_ line: String) -> Bool { @@ -91,22 +60,6 @@ struct UserFacingEmDashGuardTests { } return false } - - private static func repoRoot() throws -> URL { - var directory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() - for _ in 0 ..< 12 { - if FileManager.default.fileExists(atPath: directory.appendingPathComponent("TablePro.xcodeproj").path) { - return directory - } - directory = directory.deletingLastPathComponent() - } - throw GuardError.repoRootNotFound - } - - private enum GuardError: Error { - case repoRootNotFound - case sourceTreeNotFound - } } struct UserFacingEmDashClassifierTests { @@ -132,6 +85,22 @@ struct UserFacingEmDashClassifierTests { #expect(!offends("/// Returns nil when absent \u{2014} the caller retries")) #expect(!offends("logger.warning(\"failed \u{2014} \\(error)\")")) #expect(!offends("\"Startup failed: \\(statement) \u{2014} \\(error)\"", previous: "Self.startupLogger.warning(")) + #expect(!offends("lifecycleLogger.error(\"failed \u{2014} \\(error)\")")) + #expect(!offends(".error(\"a \u{2014} b\")", previous: "Logger(subsystem: \"com.TablePro\", category: \"Driver\")")) + #expect(!offends("let detail = parts.joined(separator: \", \") // was \"a \u{2014} b\"")) + } + + @Test("A finished log call does not cover the line after it") + func lineAfterLogCallOffends() { + #expect(offends("return \"A \u{2014} B\"", previous: "Self.logger.info(\"Loaded\")")) + #expect(offends("Text(\"A \u{2014} B\")", previous: "let info = ConnectionFailureClassifier.info(for: error)")) + } + + @Test("A call named like a log level is user-facing") + func logLevelLookalikesOffend() { + #expect(offends("let info = ConnectionFailureClassifier.info(for: error, hint: \"A \u{2014} B\")")) + #expect(offends("banners.append(.notice(String(localized: \"A \u{2014} B\")))")) + #expect(offends("return .warning(String(localized: \"A \u{2014} B\"))")) } @Test("An em dash outside any quoted span is not a string literal") diff --git a/TableProTests/Views/UserFacingMiddleDotGuardTests.swift b/TableProTests/Views/UserFacingMiddleDotGuardTests.swift new file mode 100644 index 0000000000..74e69a4acc --- /dev/null +++ b/TableProTests/Views/UserFacingMiddleDotGuardTests.swift @@ -0,0 +1,90 @@ +// +// UserFacingMiddleDotGuardTests.swift +// TableProTests +// + +import Foundation +import Testing + +/// The escaped spelling draws the same separator as the character, so both are banned. +struct UserFacingMiddleDotGuardTests { + static let middleDot: Character = "\u{00B7}" + + @Test("No middle dot reaches a string literal in the app target") + func appTargetStringLiteralsAreClean() throws { + let (scanned, offenders) = try UserFacingSourceScan.scanAppTarget(offends: Self.carriesMiddleDot) + + #expect(scanned > 500, "Expected to scan the app target, scanned \(scanned) files") + #expect( + offenders.isEmpty, + """ + A middle dot reached a user-facing string. Join facts with ", ", put a qualifier in \ + parentheses, or lay the parts out as separate labels. + \(offenders.joined(separator: "\n")) + """ + ) + } + + /// Any code on the line counts, not only a quoted span, so a multi-line string literal is covered. + static func carriesMiddleDot(line: String, previous: String) -> Bool { + guard let code = UserFacingSourceScan.userFacingCode(line: line, previous: previous) else { return false } + if code.contains(middleDot) { return true } + return code.range(of: #"\\u\{0*b7\}"#, options: [.regularExpression, .caseInsensitive]) != nil + } +} + +struct UserFacingMiddleDotClassifierTests { + private func offends(_ line: String, previous: String = "") -> Bool { + UserFacingMiddleDotGuardTests.carriesMiddleDot(line: line, previous: previous) + } + + @Test("A middle dot inside a string literal offends") + func literalOffends() { + #expect(offends("Text(\"v\\(version) \u{00B7} \\(source)\")")) + #expect(offends(".joined(separator: \" \u{00B7} \")")) + #expect(offends("Text(\"\u{00B7}\")")) + } + + @Test("The escaped spelling offends in any case and padding") + func escapedSpellingOffends() { + #expect(offends(".joined(separator: \" \\u{00B7} \")")) + #expect(offends(".joined(separator: \" \\u{b7} \")")) + #expect(offends(".joined(separator: \" \\u{00b7} \")")) + } + + @Test("The body of a multi-line string literal is covered") + func multilineBodyOffends() { + #expect(offends(" Ran 5 times \u{00B7} avg 12 ms")) + } + + @Test("Comments and log messages are out of scope") + func commentsAndLogsAreAllowed() { + #expect(!offends("// Build detail string: \"PK \u{00B7} NOT NULL\"")) + #expect(!offends("/// Shipped as \"Team \u{00B7} Lifetime\"")) + #expect(!offends("let detail = parts.joined(separator: \", \") // was \u{00B7}")) + #expect(!offends("logger.debug(\"loaded \\(count) \u{00B7} \\(name)\")")) + #expect(!offends("\"\\(table) \u{00B7} \\(error)\"", previous: "Self.logger.warning(")) + #expect(!offends("lifecycleLogger.error(\"failed \u{00B7} \\(error)\")")) + #expect(!offends(".debug(\"a \u{00B7} b\")", previous: "Logger(subsystem: \"com.TablePro\", category: \"Driver\")")) + } + + @Test("A finished log call does not cover the line after it") + func lineAfterLogCallOffends() { + #expect(offends("return parts.joined(separator: \" \u{00B7} \")", previous: "Self.logger.info(\"Loaded\")")) + #expect(offends("\"A \u{00B7} B\"", previous: "let info = ConnectionFailureClassifier.info(for: error)")) + } + + @Test("A banner case named like a log level is user-facing") + func logLevelLookalikesOffend() { + #expect(offends("banners.append(.notice(String(localized: \"A \u{00B7} B\")))")) + #expect(offends("return .warning(String(localized: \"A \u{00B7} B\"))")) + } + + @Test("A comma, a URL and other escapes are not middle dots") + func lookalikesAreIgnored() { + #expect(!offends(".joined(separator: \", \")")) + #expect(!offends("let url = \"https://tablepro.app/docs\"")) + #expect(!offends("let bullet = \"\\u{2022}\"")) + #expect(!offends("let point = \"\\u{B70}\"")) + } +} diff --git a/TableProTests/Views/VersionHistory/VersionHistoryFormattingTests.swift b/TableProTests/Views/VersionHistory/VersionHistoryFormattingTests.swift new file mode 100644 index 0000000000..661d76ec8b --- /dev/null +++ b/TableProTests/Views/VersionHistory/VersionHistoryFormattingTests.swift @@ -0,0 +1,31 @@ +// +// VersionHistoryFormattingTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +struct VersionHistoryFormattingTests { + @Test("A commit's subtitle separates the author and the revision with a comma") + func commitSubtitleUsesComma() { + let entry = VersionHistoryEntry( + reference: .gitRevision(commit: "abcdef1234", path: "report.sql"), + summary: "Fix totals", + author: "Ada" + ) + + #expect(VersionHistoryFormatting.subtitle(for: entry) == "Ada, abcdef1") + #expect(VersionHistoryFormatting.rowSubtitle(for: entry) == "Ada") + } + + @Test("The current version's subtitle puts uncommitted changes first, then the author") + func currentSubtitleUsesComma() { + let entry = VersionHistoryEntry(reference: .current, author: "Ada", hasUncommittedChanges: true) + let expected = "\(String(localized: "Uncommitted changes")), Ada" + + #expect(VersionHistoryFormatting.subtitle(for: entry) == expected) + #expect(VersionHistoryFormatting.rowSubtitle(for: entry) == expected) + } +}