diff --git a/CHANGELOG.md b/CHANGELOG.md index 8155918708..37fcff54ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Panel and status colors in the theme editor, covering the results, inspector, structure, compare and query plan panes. - Themes that name a system color for a slot, so the built-in themes keep the system's own contrast settings. - Reason shown in Settings > Appearance for a theme file that could not be loaded. +- Setting in Settings > General to hide the version and Check for Updates on the Welcome window. - Install Parquet… in the Export dialog's format menu when the Parquet plugin is not installed. ### Changed diff --git a/TablePro/Models/Settings/GeneralSettings.swift b/TablePro/Models/Settings/GeneralSettings.swift index a3d6986ded..ea85b06714 100644 --- a/TablePro/Models/Settings/GeneralSettings.swift +++ b/TablePro/Models/Settings/GeneralSettings.swift @@ -5,7 +5,6 @@ import Foundation -/// Startup behavior when app launches enum StartupBehavior: String, Codable, CaseIterable, Identifiable { case showWelcome = "showWelcome" case reopenLast = "reopenLast" @@ -20,7 +19,6 @@ enum StartupBehavior: String, Codable, CaseIterable, Identifiable { } } -/// App language options enum AppLanguage: String, Codable, CaseIterable, Identifiable { case system = "system" case english = "en" @@ -55,39 +53,21 @@ enum AppLanguage: String, Codable, CaseIterable, Identifiable { } } -/// General app settings struct GeneralSettings: Codable, Equatable { var startupBehavior: StartupBehavior var language: AppLanguage - /// Query execution timeout in seconds (0 = no limit) + // 0 means no limit var queryTimeoutSeconds: Int - - /// Whether to share anonymous usage analytics var shareAnalytics: Bool - - /// Whether the sidebar shows a Recent section with recently opened tables var showRecentTables: Bool - - /// Whether to show database object comments in the sidebar and data grid headers var showObjectComments: Bool - - /// Whether sidebar rows show a type icon before the object name var showObjectIcons: Bool - - /// Whether the sidebar tree and its database filter list system databases and schemas var showSystemContainers: Bool - - /// Whether a partitioned table lists its partitions in the sidebar and shows how many it holds var showPartitions: Bool - - /// Whether the window shows the workspace rail listing every open connection and database var showWorkspaceRail: Bool - - /// How tall sidebar rows are drawn, following the system Appearance setting unless overridden + var showWelcomeVersionInfo: Bool var sidebarRowSize: SidebarRowSizePreference - - /// How often TablePro checks that an open connection still works var connectionHealthCheck: ConnectionHealthCheck static let `default` = GeneralSettings( @@ -101,6 +81,7 @@ struct GeneralSettings: Codable, Equatable { showSystemContainers: false, showPartitions: true, showWorkspaceRail: true, + showWelcomeVersionInfo: true, sidebarRowSize: .matchSystem, connectionHealthCheck: .every30Seconds ) @@ -116,6 +97,7 @@ struct GeneralSettings: Codable, Equatable { showSystemContainers: Bool = false, showPartitions: Bool = true, showWorkspaceRail: Bool = true, + showWelcomeVersionInfo: Bool = true, sidebarRowSize: SidebarRowSizePreference = .matchSystem, connectionHealthCheck: ConnectionHealthCheck = .every30Seconds ) { @@ -129,6 +111,7 @@ struct GeneralSettings: Codable, Equatable { self.showSystemContainers = showSystemContainers self.showPartitions = showPartitions self.showWorkspaceRail = showWorkspaceRail + self.showWelcomeVersionInfo = showWelcomeVersionInfo self.sidebarRowSize = sidebarRowSize self.connectionHealthCheck = connectionHealthCheck } @@ -146,6 +129,7 @@ struct GeneralSettings: Codable, Equatable { showSystemContainers = container.decodeSetting(.showSystemContainers, default: fallback.showSystemContainers) showPartitions = container.decodeSetting(.showPartitions, default: fallback.showPartitions) showWorkspaceRail = container.decodeSetting(.showWorkspaceRail, default: fallback.showWorkspaceRail) + showWelcomeVersionInfo = container.decodeSetting(.showWelcomeVersionInfo, default: fallback.showWelcomeVersionInfo) sidebarRowSize = container.decodeSetting(.sidebarRowSize, default: fallback.sidebarRowSize) connectionHealthCheck = container.decodeSetting(.connectionHealthCheck, default: fallback.connectionHealthCheck) } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 62e4f13e12..e782cbe2e0 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -161773,6 +161773,46 @@ } } }, + "Show version and updates on the Welcome window" : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher la version et les mises à jour dans la fenêtre d’accueil" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "시작 윈도우에 버전 및 업데이트 보기" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Karşılama penceresinde sürümü ve güncellemeleri göster" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hiện phiên bản và cập nhật trên cửa sổ chào mừng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在欢迎窗口显示版本和更新" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "在歡迎視窗顯示版本和更新" + } + } + } + }, "Show Welcome Screen" : { "localizations" : { "fr" : { @@ -162411,6 +162451,46 @@ } } }, + "Shows the version and Check for Updates under the app icon. An available update is shown either way." : { + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Affiche la version et Rechercher les mises à jour sous l’icône de l’app. Une mise à jour disponible s’affiche dans tous les cas." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "앱 아이콘 아래에 버전과 업데이트 확인을 표시합니다. 사용 가능한 업데이트는 어느 경우에나 표시됩니다." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uygulama simgesinin altında sürümü ve Güncellemeleri Kontrol Et bağlantısını gösterir. Mevcut bir güncelleme her durumda gösterilir." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hiện phiên bản và Kiểm tra cập nhật dưới biểu tượng ứng dụng. Bản cập nhật có sẵn vẫn luôn được hiện." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在应用图标下方显示版本和“检查更新”。有可用更新时始终会显示。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "在 App 圖示下方顯示版本和「檢查更新」。有可用更新時一律會顯示。" + } + } + } + }, "Side" : { "localizations" : { "fr" : { diff --git a/TablePro/Views/Settings/GeneralSettingsView.swift b/TablePro/Views/Settings/GeneralSettingsView.swift index b8e796f90c..45c530cd9c 100644 --- a/TablePro/Views/Settings/GeneralSettingsView.swift +++ b/TablePro/Views/Settings/GeneralSettingsView.swift @@ -76,6 +76,10 @@ struct GeneralSettingsView: View { } } + Toggle("Show version and updates on the Welcome window", isOn: $settings.showWelcomeVersionInfo) + .accessibilityIdentifier("welcome-version-info-toggle") + .help("Shows the version and Check for Updates under the app icon. An available update is shown either way.") + Section("Tabs") { Toggle("Enable preview tabs", isOn: $tabSettings.enablePreviewTabs) .help("Single-clicking a table opens a temporary tab that gets replaced on next click.") diff --git a/TablePro/Views/Welcome/WelcomeActionsPanel.swift b/TablePro/Views/Welcome/WelcomeActionsPanel.swift index 71aa6a2224..139cb053ed 100644 --- a/TablePro/Views/Welcome/WelcomeActionsPanel.swift +++ b/TablePro/Views/Welcome/WelcomeActionsPanel.swift @@ -3,10 +3,12 @@ // TablePro // +import Combine import SwiftUI struct WelcomeActionsPanel: View { @ObservedObject private var licenseManager = LicenseManager.shared + @ObservedObject private var updater = SoftwareUpdater.shared let onActivateLicense: () -> Void let onNewConnection: () -> Void let onOpenFile: () -> Void @@ -16,7 +18,12 @@ struct WelcomeActionsPanel: View { let onImportConnectionsFile: () -> Void let onOpenProjectFolder: () -> Void - private let updater = SoftwareUpdater.shared + // One key, not the whole manager: the window lives all session and every settings write would redraw it. + @State private var showsVersionInfo = AppSettingsManager.shared.general.showWelcomeVersionInfo + + private static let versionInfoSetting = AppSettingsManager.shared.$general + .map(\.showWelcomeVersionInfo) + .removeDuplicates() /// Captured once, because the stored value is overwritten on the same appearance that reads /// it. Without the capture the line would replace itself with nothing on the next redraw. @@ -35,16 +42,23 @@ struct WelcomeActionsPanel: View { Text(verbatim: "TablePro") .font(.title2.weight(.semibold)) - Text(String(format: String(localized: "Version %@"), Bundle.main.appVersion)) - .font(.callout) - .foregroundStyle(.secondary) + if showsVersionInfo { + Text(String(format: String(localized: "Version %@"), Bundle.main.appVersion)) + .font(.callout) + .foregroundStyle(.secondary) + .accessibilityIdentifier("welcome-version-line") + } - Button(updater.checkForUpdatesTitle) { - updater.checkForUpdates() + // A deferred update has its gentle reminder here, so hiding the line never hides that. + if showsVersionInfo || updater.hasPendingUpdate { + Button(updater.checkForUpdatesTitle) { + updater.checkForUpdates() + } + .buttonStyle(.link) + .font(.callout) + .disabled(!updater.canCheckForUpdates) + .accessibilityIdentifier("welcome-check-for-updates-link") } - .buttonStyle(.link) - .font(.callout) - .disabled(!updater.canCheckForUpdates) if showsWhatsNew { Button(String(format: String(localized: "What's New in %@"), Bundle.main.appVersion)) { @@ -103,6 +117,7 @@ struct WelcomeActionsPanel: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear(perform: recordAppVersion) + .onReceive(Self.versionInfoSetting) { showsVersionInfo = $0 } } private func recordAppVersion() { diff --git a/TableProTests/Models/GeneralSettingsTests.swift b/TableProTests/Models/GeneralSettingsTests.swift index e5919cb720..7d47fb8710 100644 --- a/TableProTests/Models/GeneralSettingsTests.swift +++ b/TableProTests/Models/GeneralSettingsTests.swift @@ -54,109 +54,57 @@ struct AppLanguageTests { } } -struct GeneralSettingsTests { - @Test("Defaults to off") - func defaultsOff() { - #expect(GeneralSettings.default.showRecentTables == false) - #expect(GeneralSettings().showRecentTables == false) - } - - @Test("Decoding settings without the key keeps recent tables off") - func decodesMissingKeyAsOff() throws { - let json = Data(#"{"startupBehavior":"showWelcome"}"#.utf8) - let decoded = try JSONDecoder().decode(GeneralSettings.self, from: json) - #expect(decoded.showRecentTables == false) - } - - @Test("Round-trips when enabled") - func roundTripsEnabled() throws { - var settings = GeneralSettings() - settings.showRecentTables = true +struct GeneralSettingsBoolSettingTests { + struct BoolSetting: Sendable, CustomTestStringConvertible { + let key: String + let defaultValue: Bool + + var testDescription: String { key } + } + + static let settings = [ + BoolSetting(key: "shareAnalytics", defaultValue: true), + BoolSetting(key: "showRecentTables", defaultValue: false), + BoolSetting(key: "showObjectComments", defaultValue: true), + BoolSetting(key: "showObjectIcons", defaultValue: true), + BoolSetting(key: "showSystemContainers", defaultValue: false), + BoolSetting(key: "showPartitions", defaultValue: true), + BoolSetting(key: "showWorkspaceRail", defaultValue: true), + BoolSetting(key: "showWelcomeVersionInfo", defaultValue: true) + ] + + private static func encoded(_ settings: GeneralSettings) throws -> [String: Any] { let data = try JSONEncoder().encode(settings) - let decoded = try JSONDecoder().decode(GeneralSettings.self, from: data) - #expect(decoded.showRecentTables == true) - } -} - -struct GeneralSettingsObjectIconsTests { - @Test("Defaults to on") - func defaultsOn() { - #expect(GeneralSettings.default.showObjectIcons == true) - #expect(GeneralSettings().showObjectIcons == true) - } - - @Test("Decoding settings saved before the key existed keeps icons on") - func decodesMissingKeyAsOn() throws { - let json = Data(#"{"startupBehavior":"showWelcome"}"#.utf8) - let decoded = try JSONDecoder().decode(GeneralSettings.self, from: json) - #expect(decoded.showObjectIcons == true) - } - - @Test("Round-trips when disabled") - func roundTripsDisabled() throws { - var settings = GeneralSettings() - settings.showObjectIcons = false - let data = try JSONEncoder().encode(settings) - let decoded = try JSONDecoder().decode(GeneralSettings.self, from: data) - #expect(decoded.showObjectIcons == false) - } - - @Test("Icons and comments are independent") - func independentFromComments() throws { - var settings = GeneralSettings() - settings.showObjectIcons = false - let data = try JSONEncoder().encode(settings) - let decoded = try JSONDecoder().decode(GeneralSettings.self, from: data) - #expect(decoded.showObjectIcons == false) - #expect(decoded.showObjectComments == true) + return try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) } -} -struct GeneralSettingsWorkspaceRailTests { - @Test("Defaults to on") - func defaultsOn() { - #expect(GeneralSettings.default.showWorkspaceRail) - #expect(GeneralSettings().showWorkspaceRail) + private static func decoded(_ object: [String: Any]) throws -> GeneralSettings { + try JSONDecoder().decode(GeneralSettings.self, from: JSONSerialization.data(withJSONObject: object)) } - @Test("Settings saved before the rail existed keep it on") - func decodesMissingKeyAsOn() throws { - let json = Data(#"{"startupBehavior":"showWelcome"}"#.utf8) - let decoded = try JSONDecoder().decode(GeneralSettings.self, from: json) - #expect(decoded.showWorkspaceRail) + @Test("Starts at its default", arguments: settings) + func startsAtDefault(_ setting: BoolSetting) throws { + #expect(try Self.encoded(.default)[setting.key] as? Bool == setting.defaultValue) + #expect(try Self.encoded(GeneralSettings())[setting.key] as? Bool == setting.defaultValue) } - @Test("Round-trips when turned off") - func roundTripsDisabled() throws { - var settings = GeneralSettings() - settings.showWorkspaceRail = false - let data = try JSONEncoder().encode(settings) - let decoded = try JSONDecoder().decode(GeneralSettings.self, from: data) - #expect(!decoded.showWorkspaceRail) + @Test("Settings saved before the key existed decode to the default", arguments: settings) + func decodesMissingKeyAsDefault(_ setting: BoolSetting) throws { + let decoded = try Self.decoded(["startupBehavior": "showWelcome"]) + #expect(try Self.encoded(decoded)[setting.key] as? Bool == setting.defaultValue) } -} -struct GeneralSettingsSystemContainersTests { - @Test("Defaults to off") - func defaultsOff() { - #expect(GeneralSettings.default.showSystemContainers == false) - #expect(GeneralSettings().showSystemContainers == false) - } + @Test("Round-trips the other value and leaves every other setting alone", arguments: settings) + func roundTripsTheOtherValue(_ setting: BoolSetting) throws { + var object = try Self.encoded(.default) + object[setting.key] = !setting.defaultValue - @Test("Settings saved before the key existed keep system databases hidden in the sidebar") - func decodesMissingKeyAsOff() throws { - let json = Data(#"{"startupBehavior":"showWelcome"}"#.utf8) - let decoded = try JSONDecoder().decode(GeneralSettings.self, from: json) - #expect(decoded.showSystemContainers == false) - } + let roundTripped = try Self.encoded(Self.decoded(object)) - @Test("Round-trips when turned on") - func roundTripsEnabled() throws { - var settings = GeneralSettings() - settings.showSystemContainers = true - let data = try JSONEncoder().encode(settings) - let decoded = try JSONDecoder().decode(GeneralSettings.self, from: data) - #expect(decoded.showSystemContainers) + #expect(roundTripped[setting.key] as? Bool == !setting.defaultValue) + for other in Self.settings where other.key != setting.key { + #expect(roundTripped[other.key] as? Bool == other.defaultValue, "\(other.key)") + } } } @@ -179,27 +127,3 @@ struct GeneralSettingsUpdatePreferenceTests { #expect(object["automaticallyCheckForUpdates"] == nil) } } - -struct GeneralSettingsPartitionsTests { - @Test("Defaults to on") - func defaultsOn() { - #expect(GeneralSettings.default.showPartitions == true) - #expect(GeneralSettings().showPartitions == true) - } - - @Test("Settings saved before the key existed keep partitions listed") - func decodesMissingKeyAsOn() throws { - let json = Data(#"{"startupBehavior":"showWelcome"}"#.utf8) - let decoded = try JSONDecoder().decode(GeneralSettings.self, from: json) - #expect(decoded.showPartitions == true) - } - - @Test("Round-trips when turned off") - func roundTripsDisabled() throws { - var settings = GeneralSettings() - settings.showPartitions = false - let data = try JSONEncoder().encode(settings) - let decoded = try JSONDecoder().decode(GeneralSettings.self, from: data) - #expect(decoded.showPartitions == false) - } -} diff --git a/TableProUITests/WelcomeWindowUITests.swift b/TableProUITests/WelcomeWindowUITests.swift index 7d2170d8a3..0f6d7a854e 100644 --- a/TableProUITests/WelcomeWindowUITests.swift +++ b/TableProUITests/WelcomeWindowUITests.swift @@ -53,4 +53,40 @@ final class WelcomeWindowUITests: UITestCase { XCTAssertTrue(waitForSampleDatabaseWindow(in: app), "Open Sample Database must open the sample") } + + func testTheVersionSettingHidesTheVersionAndTheUpdateCheckOnTheOpenWindow() throws { + let app = try launchApp(arguments: ["-AppleLanguages", "(en)"]) + let welcome = app.children(matching: .window).matching(identifier: "welcome").firstMatch + XCTAssertTrue(welcome.waitToExist(timeout: 10)) + + let versionLine = welcome.descendants(matching: .any).matching(identifier: "welcome-version-line").firstMatch + let updateCheck = welcome.descendants(matching: .any) + .matching(identifier: "welcome-check-for-updates-link").firstMatch + XCTAssertTrue(versionLine.waitToExist(timeout: 10), "The welcome window must show the version by default") + XCTAssertTrue(updateCheck.exists, "The welcome window must offer the update check by default") + + app.menuBars.menuItems["Settings…"].click() + let settings = app.children(matching: .window).matching(identifier: "settings").firstMatch + XCTAssertTrue(settings.waitToExist(timeout: 10), "Settings must open") + let generalPaneButton = settings.toolbars.buttons["General"] + XCTAssertTrue(generalPaneButton.waitToExist(timeout: 10)) + generalPaneButton.click() + + let toggle = settings.descendants(matching: .any) + .matching(identifier: "welcome-version-info-toggle").firstMatch + XCTAssertTrue(waitUntilHittable(toggle, timeout: 10), "The General pane must offer the setting") + XCTAssertTrue(isOn(toggle), "The setting must start on") + + toggle.click() + XCTAssertTrue( + waitForPredicate(timeout: 10) { !versionLine.exists && !updateCheck.exists }, + "Turning the setting off must hide the version and the update check on the open welcome window" + ) + + toggle.click() + XCTAssertTrue( + waitForPredicate(timeout: 10) { versionLine.exists && updateCheck.exists }, + "Turning the setting back on must restore both" + ) + } }