diff --git a/CHANGELOG.md b/CHANGELOG.md index 20085fb62..e457ed40e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - File picker dialog appears behind the connection form window +- Fix crash when removing jump hosts from SSH tunnel configuration +- Fix crash when export dialog refreshes database list while tree view is displayed +- Use sheet presentation for password and TOTP prompts instead of blocking modal dialogs +- Fix localized strings with interpolation creating untranslatable dynamic keys +- Fix crash when closing window during SSH tunnel connection (use-after-free in libssh2) ### Added diff --git a/TablePro/AppDelegate+FileOpen.swift b/TablePro/AppDelegate+FileOpen.swift index 45058fca4..e56ea4765 100644 --- a/TablePro/AppDelegate+FileOpen.swift +++ b/TablePro/AppDelegate+FileOpen.swift @@ -132,7 +132,7 @@ extension AppDelegate { let preview = (sql as NSString).length > 300 ? String(sql.prefix(300)) + "…" : sql let confirmed = await AlertHelper.confirmDestructive( title: String(localized: "Open Query from Link"), - message: String(localized: "An external link wants to open a query on connection \"\(name)\":\n\n\(preview)"), + message: String(format: String(localized: "An external link wants to open a query on connection \"%@\":\n\n%@"), name, preview), confirmButton: String(localized: "Open Query"), cancelButton: String(localized: "Cancel"), window: NSApp.keyWindow @@ -157,7 +157,7 @@ extension AppDelegate { fileOpenLogger.error("Deep link: no connection named '\(connectionName, privacy: .public)'") AlertHelper.showErrorSheet( title: String(localized: "Connection Not Found"), - message: String(localized: "No saved connection named \"\(connectionName)\"."), + message: String(format: String(localized: "No saved connection named \"%@\"."), connectionName), window: NSApp.keyWindow ) return @@ -191,7 +191,7 @@ extension AppDelegate { { let confirmed = await AlertHelper.confirmDestructive( title: String(localized: "Pre-Connect Script"), - message: String(localized: "Connection \"\(connection.name)\" has a script that will run before connecting:\n\n\(script)"), + message: String(format: String(localized: "Connection \"%@\" has a script that will run before connecting:\n\n%@"), connection.name, script), confirmButton: String(localized: "Run Script"), cancelButton: String(localized: "Cancel"), window: NSApp.keyWindow @@ -221,7 +221,7 @@ extension AppDelegate { let details = "\(type.rawValue)://\(userPart)\(host):\(port)/\(database)" let confirmed = await AlertHelper.confirmDestructive( title: String(localized: "Import Connection from Link"), - message: String(localized: "An external link wants to add a database connection:\n\nName: \(name)\n\(details)"), + message: String(format: String(localized: "An external link wants to add a database connection:\n\nName: %@\n%@"), name, details), confirmButton: String(localized: "Add Connection"), cancelButton: String(localized: "Cancel"), window: NSApp.keyWindow diff --git a/TablePro/Core/AI/AIProvider.swift b/TablePro/Core/AI/AIProvider.swift index 0776d4f32..705a05500 100644 --- a/TablePro/Core/AI/AIProvider.swift +++ b/TablePro/Core/AI/AIProvider.swift @@ -36,22 +36,22 @@ enum AIProviderError: Error, LocalizedError { var errorDescription: String? { switch self { case .invalidEndpoint(let endpoint): - return String(localized: "Invalid endpoint: \(endpoint)") + return String(format: String(localized: "Invalid endpoint: %@"), endpoint) case .authenticationFailed(let detail): if detail.isEmpty { return String(localized: "Authentication failed. Check your API key.") } - return String(localized: "Authentication failed: \(detail)") + return String(format: String(localized: "Authentication failed: %@"), detail) case .rateLimited: return String(localized: "Rate limited. Please try again later.") case .modelNotFound(let model): - return String(localized: "Model not found: \(model)") + return String(format: String(localized: "Model not found: %@"), model) case .serverError(let code, let message): - return String(localized: "Server error (\(code)): \(message)") + return String(format: String(localized: "Server error (%d): %@"), code, message) case .networkError(let message): - return String(localized: "Network error: \(message)") + return String(format: String(localized: "Network error: %@"), message) case .streamingFailed(let message): - return String(localized: "Streaming failed: \(message)") + return String(format: String(localized: "Streaming failed: %@"), message) } } diff --git a/TablePro/Core/Database/DatabaseManager.swift b/TablePro/Core/Database/DatabaseManager.swift index 36135d190..8eea939fe 100644 --- a/TablePro/Core/Database/DatabaseManager.swift +++ b/TablePro/Core/Database/DatabaseManager.swift @@ -140,9 +140,10 @@ final class DatabaseManager { passwordOverride = cached } else { let isApiOnly = PluginManager.shared.connectionMode(for: connection.type) == .apiOnly - guard let prompted = PasswordPromptHelper.prompt( + guard let prompted = await PasswordPromptHelper.prompt( connectionName: connection.name, - isAPIToken: isApiOnly + isAPIToken: isApiOnly, + window: NSApp.keyWindow ) else { removeSessionEntry(for: connection.id) currentSessionId = nil @@ -754,9 +755,10 @@ final class DatabaseManager { var passwordOverride = activeSessions[sessionId]?.cachedPassword if session.connection.promptForPassword && passwordOverride == nil { let isApiOnly = PluginManager.shared.connectionMode(for: session.connection.type) == .apiOnly - guard let prompted = PasswordPromptHelper.prompt( + guard let prompted = await PasswordPromptHelper.prompt( connectionName: session.connection.name, - isAPIToken: isApiOnly + isAPIToken: isApiOnly, + window: NSApp.keyWindow ) else { updateSession(sessionId) { $0.status = .disconnected } return @@ -827,7 +829,7 @@ final class DatabaseManager { Self.logger.error("Manual reconnect failed: \(error.localizedDescription)") updateSession(sessionId) { session in session.status = .error( - String(localized: "Reconnect failed: \(error.localizedDescription)")) + String(format: String(localized: "Reconnect failed: %@"), error.localizedDescription)) session.clearCachedData() } } diff --git a/TablePro/Core/Plugins/PluginError.swift b/TablePro/Core/Plugins/PluginError.swift index 5e644b5e8..ef979cdc3 100644 --- a/TablePro/Core/Plugins/PluginError.swift +++ b/TablePro/Core/Plugins/PluginError.swift @@ -25,15 +25,15 @@ enum PluginError: LocalizedError { var errorDescription: String? { switch self { case .invalidBundle(let reason): - return String(localized: "Invalid plugin bundle: \(reason)") + return String(format: String(localized: "Invalid plugin bundle: %@"), reason) case .signatureInvalid(let detail): - return String(localized: "Plugin code signature verification failed: \(detail)") + return String(format: String(localized: "Plugin code signature verification failed: %@"), detail) case .checksumMismatch: return String(localized: "Plugin checksum does not match expected value") case .incompatibleVersion(let required, let current): - return String(localized: "Plugin requires PluginKit version \(required), but app provides version \(current)") + return String(format: String(localized: "Plugin requires PluginKit version %d, but app provides version %d"), required, current) case .pluginOutdated(let pluginVersion, let requiredVersion): - return String(localized: "Plugin was built with PluginKit version \(pluginVersion), but version \(requiredVersion) is required. Please update the plugin.") + return String(format: String(localized: "Plugin was built with PluginKit version %d, but version %d is required. Please update the plugin."), pluginVersion, requiredVersion) case .cannotUninstallBuiltIn: return String(localized: "Built-in plugins cannot be uninstalled") case .notFound: @@ -41,19 +41,19 @@ enum PluginError: LocalizedError { case .noCompatibleBinary: return String(localized: "Plugin does not contain a compatible binary for this architecture") case .installFailed(let reason): - return String(localized: "Plugin installation failed: \(reason)") + return String(format: String(localized: "Plugin installation failed: %@"), reason) case .pluginConflict(let existingName): - return String(localized: "A built-in plugin \"\(existingName)\" already provides this bundle ID") + return String(format: String(localized: "A built-in plugin \"%@\" already provides this bundle ID"), existingName) case .appVersionTooOld(let minimumRequired, let currentApp): - return String(localized: "Plugin requires app version \(minimumRequired) or later, but current version is \(currentApp)") + return String(format: String(localized: "Plugin requires app version %@ or later, but current version is %@"), minimumRequired, currentApp) case .downloadFailed(let reason): - return String(localized: "Plugin download failed: \(reason)") + return String(format: String(localized: "Plugin download failed: %@"), reason) case .pluginNotInstalled(let databaseType): - return String(localized: "The \(databaseType) plugin is not installed. You can download it from the plugin marketplace.") + return String(format: String(localized: "The %@ plugin is not installed. You can download it from the plugin marketplace."), databaseType) case .incompatibleWithCurrentApp(let minimumRequired): - return String(localized: "This plugin requires TablePro \(minimumRequired) or later") + return String(format: String(localized: "This plugin requires TablePro %@ or later"), minimumRequired) case .invalidDescriptor(let pluginId, let reason): - return String(localized: "Plugin '\(pluginId)' has an invalid descriptor: \(reason)") + return String(format: String(localized: "Plugin '%@' has an invalid descriptor: %@"), pluginId, reason) } } } diff --git a/TablePro/Core/SSH/Auth/PromptTOTPProvider.swift b/TablePro/Core/SSH/Auth/PromptTOTPProvider.swift index d09e2b6a2..c0671945e 100644 --- a/TablePro/Core/SSH/Auth/PromptTOTPProvider.swift +++ b/TablePro/Core/SSH/Auth/PromptTOTPProvider.swift @@ -45,10 +45,7 @@ internal final class PromptTOTPProvider: TOTPProvider, @unchecked Sendable { alert.window.initialFirstResponder = textField let response = alert.runModal() - if response == .alertFirstButtonReturn { - return textField.stringValue - } - return nil + return response == .alertFirstButtonReturn ? textField.stringValue : nil } private func handleResult(_ code: String?) throws -> String { diff --git a/TablePro/Core/SSH/LibSSH2Tunnel.swift b/TablePro/Core/SSH/LibSSH2Tunnel.swift index 888f99ef3..b0d07ab19 100644 --- a/TablePro/Core/SSH/LibSSH2Tunnel.swift +++ b/TablePro/Core/SSH/LibSSH2Tunnel.swift @@ -84,7 +84,7 @@ internal final class LibSSH2Tunnel: @unchecked Sendable { // MARK: - Forwarding func startForwarding(remoteHost: String, remotePort: Int) { - libssh2_session_set_blocking(session, 0) + sessionQueue.sync { libssh2_session_set_blocking(session, 0) } forwardingTask = Task.detached { [weak self] in guard let self else { return } @@ -137,7 +137,7 @@ internal final class LibSSH2Tunnel: @unchecked Sendable { // MARK: - Keep-Alive func startKeepAlive() { - libssh2_keepalive_config(session, 1, 30) + sessionQueue.sync { libssh2_keepalive_config(session, 1, 30) } keepAliveTask = Task.detached { [weak self] in guard let self else { return } @@ -188,24 +188,29 @@ internal final class LibSSH2Tunnel: @unchecked Sendable { // Close listenFD to stop accepting new connections Darwin.close(listenFD) - // Defer session teardown to a detached task that waits for relays to exit. + // Defer session teardown to a detached task that waits for all tasks to exit. + let sessionQueue = self.sessionQueue let session = self.session let socketFD = self.socketFD let jumpChain = self.jumpChain let connectionId = self.connectionId + let forwardingTask = self.forwardingTask + let keepAliveTask = self.keepAliveTask Task.detached { - // Wait for all relay tasks to finish (they'll exit quickly since - // socketFD is shut down and isRunning is false) + // Wait for all tasks to exit before touching the session. + await forwardingTask?.value + await keepAliveTask?.value for task in currentRelayTasks { await task.value } - // Now safe to close the socket and tear down the session - Darwin.close(socketFD) - - libssh2_session_set_blocking(session, 1) - tablepro_libssh2_session_disconnect(session, "Closing tunnel") - libssh2_session_free(session) + // Tear down on sessionQueue to serialize after any pending libssh2 blocks. + sessionQueue.sync { + Darwin.close(socketFD) + libssh2_session_set_blocking(session, 1) + tablepro_libssh2_session_disconnect(session, "Closing tunnel") + libssh2_session_free(session) + } for hop in jumpChain.reversed() { hop.relayTask?.cancel() @@ -286,7 +291,7 @@ internal final class LibSSH2Tunnel: @unchecked Sendable { /// Open a direct-tcpip channel, handling EAGAIN with select(). /// Must be called on `sessionQueue`. private func openDirectTcpipChannel(remoteHost: String, remotePort: Int) -> OpaquePointer? { - while true { + while isRunning { let channel = libssh2_channel_direct_tcpip_ex( session, remoteHost, @@ -308,6 +313,7 @@ internal final class LibSSH2Tunnel: @unchecked Sendable { return nil } } + return nil } /// Bidirectional relay between a client socket and an SSH channel. @@ -332,9 +338,13 @@ internal final class LibSSH2Tunnel: @unchecked Sendable { } } - relayTasks.withLock { tasks in + let shouldCancel = relayTasks.withLock { tasks -> Bool in tasks.removeAll { $0.isCancelled } tasks.append(task) + return !isAlive.withLock { $0 } + } + if shouldCancel { + task.cancel() } } @@ -403,8 +413,11 @@ internal final class LibSSH2Tunnel: @unchecked Sendable { if written > 0 { totalWritten += written } else if written == Int(LIBSSH2_ERROR_EAGAIN) { - _ = self.waitForSocket( - session: self.session, + let directions = sessionQueue.sync { + libssh2_session_block_directions(self.session) + } + _ = self.waitForSocketDirections( + directions: directions, socketFD: self.socketFD, timeoutMs: 1_000 ) @@ -417,9 +430,15 @@ internal final class LibSSH2Tunnel: @unchecked Sendable { } /// Wait for the SSH socket to become ready, based on libssh2's block directions. + /// Must be called on `sessionQueue` (reads session state via `libssh2_session_block_directions`). private func waitForSocket(session: OpaquePointer, socketFD: Int32, timeoutMs: Int32) -> Bool { let directions = libssh2_session_block_directions(session) + return waitForSocketDirections(directions: directions, socketFD: socketFD, timeoutMs: timeoutMs) + } + /// Wait for the SSH socket to become ready with pre-fetched block directions. + /// Safe to call from any queue since it does not access the session. + private func waitForSocketDirections(directions: Int32, socketFD: Int32, timeoutMs: Int32) -> Bool { var events: Int16 = 0 if directions & LIBSSH2_SESSION_BLOCK_INBOUND != 0 { events |= Int16(POLLIN) diff --git a/TablePro/Core/SSH/SSHTunnelManager.swift b/TablePro/Core/SSH/SSHTunnelManager.swift index 81c16ae41..847b2672c 100644 --- a/TablePro/Core/SSH/SSHTunnelManager.swift +++ b/TablePro/Core/SSH/SSHTunnelManager.swift @@ -21,9 +21,9 @@ enum SSHTunnelError: Error, LocalizedError { var errorDescription: String? { switch self { case .tunnelCreationFailed(let message): - return String(localized: "SSH tunnel creation failed: \(message)") + return String(format: String(localized: "SSH tunnel creation failed: %@"), message) case .tunnelAlreadyExists(let id): - return String(localized: "SSH tunnel already exists for connection: \(id.uuidString)") + return String(format: String(localized: "SSH tunnel already exists for connection: %@"), id.uuidString) case .noAvailablePort: return String(localized: "No available local port for SSH tunnel") case .authenticationFailed: diff --git a/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift b/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift index 5ab6212c6..9d098a692 100644 --- a/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift +++ b/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift @@ -48,7 +48,7 @@ struct SchemaStatementGenerator { throw NSError( domain: "SchemaStatementGenerator", code: -1, - userInfo: [NSLocalizedDescriptionKey: String(localized: "Unsupported schema operation: \(change.description)")] + userInfo: [NSLocalizedDescriptionKey: String(format: String(localized: "Unsupported schema operation: %@"), change.description)] ) } let sql = stmt.sql.hasSuffix(";") ? stmt.sql : stmt.sql + ";" diff --git a/TablePro/Core/Services/Export/ConnectionExportCrypto.swift b/TablePro/Core/Services/Export/ConnectionExportCrypto.swift index 211089140..1d5b832c3 100644 --- a/TablePro/Core/Services/Export/ConnectionExportCrypto.swift +++ b/TablePro/Core/Services/Export/ConnectionExportCrypto.swift @@ -21,7 +21,7 @@ enum ConnectionExportCryptoError: LocalizedError { case .corruptData: return String(localized: "The encrypted file is corrupt or incomplete") case .unsupportedVersion(let v): - return String(localized: "Unsupported encryption version \(v)") + return String(format: String(localized: "Unsupported encryption version %d"), Int(v)) } } } diff --git a/TablePro/Core/Services/Export/ConnectionExportService.swift b/TablePro/Core/Services/Export/ConnectionExportService.swift index 10f9f14e1..a76f1d45c 100644 --- a/TablePro/Core/Services/Export/ConnectionExportService.swift +++ b/TablePro/Core/Services/Export/ConnectionExportService.swift @@ -24,19 +24,19 @@ enum ConnectionExportError: LocalizedError { case .encodingFailed: return String(localized: "Failed to encode connection data") case .fileWriteFailed(let path): - return String(localized: "Failed to write file: \(path)") + return String(format: String(localized: "Failed to write file: %@"), path) case .fileReadFailed(let path): - return String(localized: "Failed to read file: \(path)") + return String(format: String(localized: "Failed to read file: %@"), path) case .invalidFormat: return String(localized: "This file is not a valid TablePro export") case .unsupportedVersion(let version): - return String(localized: "This file requires a newer version of TablePro (format version \(version))") + return String(format: String(localized: "This file requires a newer version of TablePro (format version %d)"), version) case .decodingFailed(let detail): - return String(localized: "Failed to parse connection file: \(detail)") + return String(format: String(localized: "Failed to parse connection file: %@"), detail) case .requiresPassphrase: return String(localized: "This file is encrypted and requires a passphrase") case .decryptionFailed(let detail): - return String(localized: "Decryption failed: \(detail)") + return String(format: String(localized: "Decryption failed: %@"), detail) } } } diff --git a/TablePro/Core/Services/Export/ExportService.swift b/TablePro/Core/Services/Export/ExportService.swift index c16fc76e0..610d6f556 100644 --- a/TablePro/Core/Services/Export/ExportService.swift +++ b/TablePro/Core/Services/Export/ExportService.swift @@ -26,15 +26,15 @@ enum ExportError: LocalizedError { case .noTablesSelected: return String(localized: "No tables selected for export") case .exportFailed(let message): - return String(localized: "Export failed: \(message)") + return String(format: String(localized: "Export failed: %@"), message) case .compressionFailed: return String(localized: "Failed to compress data") case .fileWriteFailed(let path): - return String(localized: "Failed to write file: \(path)") + return String(format: String(localized: "Failed to write file: %@"), path) case .encodingFailed: return String(localized: "Failed to encode content as UTF-8") case .formatNotFound(let formatId): - return String(localized: "Export format '\(formatId)' not found") + return String(format: String(localized: "Export format '%@' not found"), formatId) } } } diff --git a/TablePro/Core/Services/Formatting/SQLFormatterTypes.swift b/TablePro/Core/Services/Formatting/SQLFormatterTypes.swift index 0dcba1ed7..5466a0b3e 100644 --- a/TablePro/Core/Services/Formatting/SQLFormatterTypes.swift +++ b/TablePro/Core/Services/Formatting/SQLFormatterTypes.swift @@ -52,11 +52,11 @@ enum SQLFormatterError: LocalizedError { case .emptyInput: return String(localized: "Cannot format empty SQL") case .dialectUnsupported(let type): - return String(localized: "Formatting not supported for \(type.rawValue)") + return String(format: String(localized: "Formatting not supported for %@"), type.rawValue) case .invalidCursorPosition(let pos, let max): - return String(localized: "Cursor position \(pos) exceeds SQL length (\(max))") + return String(format: String(localized: "Cursor position %d exceeds SQL length (%d)"), pos, max) case .internalError(let message): - return String(localized: "Formatter error: \(message)") + return String(format: String(localized: "Formatter error: %@"), message) } } } diff --git a/TablePro/Core/Services/Infrastructure/PreConnectHookRunner.swift b/TablePro/Core/Services/Infrastructure/PreConnectHookRunner.swift index 197ff22e1..88f62be3c 100644 --- a/TablePro/Core/Services/Infrastructure/PreConnectHookRunner.swift +++ b/TablePro/Core/Services/Infrastructure/PreConnectHookRunner.swift @@ -21,9 +21,9 @@ enum PreConnectHookRunner { case let .scriptFailed(exitCode, stderr): let message = stderr.trimmingCharacters(in: .whitespacesAndNewlines) if message.isEmpty { - return String(localized: "Pre-connect script failed with exit code \(exitCode)") + return String(format: String(localized: "Pre-connect script failed with exit code %d"), exitCode) } - return String(localized: "Pre-connect script failed (exit \(exitCode)): \(message)") + return String(format: String(localized: "Pre-connect script failed (exit %d): %@"), exitCode, message) case .timeout: return String(localized: "Pre-connect script timed out after 10 seconds") case .cancelled: diff --git a/TablePro/Core/Services/Infrastructure/SafeModeGuard.swift b/TablePro/Core/Services/Infrastructure/SafeModeGuard.swift index 2a3fb1cde..38360f15f 100644 --- a/TablePro/Core/Services/Infrastructure/SafeModeGuard.swift +++ b/TablePro/Core/Services/Infrastructure/SafeModeGuard.swift @@ -100,7 +100,7 @@ internal final class SafeModeGuard { return await AlertHelper.confirmDestructive( title: operationDescription, - message: String(localized: "Are you sure you want to execute this query?\n\n\(preview)"), + message: String(format: String(localized: "Are you sure you want to execute this query?\n\n%@"), preview), confirmButton: String(localized: "Execute"), cancelButton: String(localized: "Cancel"), window: window diff --git a/TablePro/Core/Services/Infrastructure/SettingsValidation.swift b/TablePro/Core/Services/Infrastructure/SettingsValidation.swift index 5694bb5b4..baeb0b3bd 100644 --- a/TablePro/Core/Services/Infrastructure/SettingsValidation.swift +++ b/TablePro/Core/Services/Infrastructure/SettingsValidation.swift @@ -20,13 +20,13 @@ enum SettingsValidationError: LocalizedError { var errorDescription: String? { switch self { case .stringTooLong(let field, let maxLength): - return String(localized: "\(field) must be \(maxLength) characters or less") + return String(format: String(localized: "%@ must be %d characters or less"), field, maxLength) case .stringEmpty(let field): - return String(localized: "\(field) cannot be empty") + return String(format: String(localized: "%@ cannot be empty"), field) case .intOutOfRange(let field, let min, let max): - return String(localized: "\(field) must be between \(min.formatted()) and \(max.formatted())") + return String(format: String(localized: "%@ must be between %@ and %@"), field, min.formatted(), max.formatted()) case .intNegative(let field): - return String(localized: "\(field) cannot be negative") + return String(format: String(localized: "%@ cannot be negative"), field) } } } diff --git a/TablePro/Core/Sync/SyncError.swift b/TablePro/Core/Sync/SyncError.swift index 3ae0d0807..469c1baca 100644 --- a/TablePro/Core/Sync/SyncError.swift +++ b/TablePro/Core/Sync/SyncError.swift @@ -30,13 +30,13 @@ enum SyncError: LocalizedError, Equatable { case .zoneNotFound: return String(localized: "Sync zone not found. A full sync will be performed.") case .serverError(let message): - return String(localized: "iCloud server error: \(message)") + return String(format: String(localized: "iCloud server error: %@"), message) case .conflictDetected: return String(localized: "A sync conflict was detected and needs to be resolved.") case .encodingFailed(let detail): - return String(localized: "Failed to encode sync data: \(detail)") + return String(format: String(localized: "Failed to encode sync data: %@"), detail) case .unknown(let message): - return String(localized: "An unknown sync error occurred: \(message)") + return String(format: String(localized: "An unknown sync error occurred: %@"), message) } } diff --git a/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift b/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift index bb5b19e8b..6e5e0b535 100644 --- a/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift +++ b/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift @@ -64,7 +64,7 @@ enum ConnectionURLParseError: Error, LocalizedError, Equatable { case .invalidURL: return String(localized: "Invalid connection URL format") case .unsupportedScheme(let scheme): - return String(localized: "Unsupported database scheme: \(scheme)") + return String(format: String(localized: "Unsupported database scheme: %@"), scheme) case .missingHost: return String(localized: "Connection URL must include a host") } diff --git a/TablePro/Core/Utilities/File/FileDecompressor.swift b/TablePro/Core/Utilities/File/FileDecompressor.swift index 42a51059f..387f26157 100644 --- a/TablePro/Core/Utilities/File/FileDecompressor.swift +++ b/TablePro/Core/Utilities/File/FileDecompressor.swift @@ -17,7 +17,7 @@ enum DecompressionError: LocalizedError { case .decompressFailed: return String(localized: "Failed to decompress .gz file") case .fileReadFailed(let message): - return String(localized: "Failed to read file: \(message)") + return String(format: String(localized: "Failed to read file: %@"), message) } } } diff --git a/TablePro/Core/Utilities/UI/PasswordPromptHelper.swift b/TablePro/Core/Utilities/UI/PasswordPromptHelper.swift index 143671306..b887befb9 100644 --- a/TablePro/Core/Utilities/UI/PasswordPromptHelper.swift +++ b/TablePro/Core/Utilities/UI/PasswordPromptHelper.swift @@ -8,10 +8,12 @@ import AppKit enum PasswordPromptHelper { - /// Presents a modal alert with a secure text field to collect a password or API token. - /// Returns the entered value (may be empty for passwordless databases), or `nil` if the user cancels. @MainActor - static func prompt(connectionName: String, isAPIToken: Bool = false) -> String? { + static func prompt( + connectionName: String, + isAPIToken: Bool = false, + window: NSWindow? = nil + ) async -> String? { let alert = NSAlert() alert.messageText = isAPIToken ? String(localized: "API Token Required") @@ -30,6 +32,16 @@ enum PasswordPromptHelper { alert.accessoryView = input alert.window.initialFirstResponder = input + if let window { + let response = await withCheckedContinuation { continuation in + alert.beginSheetModal(for: window) { response in + continuation.resume(returning: response) + } + } + guard response == .alertFirstButtonReturn else { return nil } + return input.stringValue + } + guard alert.runModal() == .alertFirstButtonReturn else { return nil } return input.stringValue } diff --git a/TablePro/Extensions/Binding+SafeLookup.swift b/TablePro/Extensions/Binding+SafeLookup.swift new file mode 100644 index 000000000..ffd819658 --- /dev/null +++ b/TablePro/Extensions/Binding+SafeLookup.swift @@ -0,0 +1,24 @@ +// +// Binding+SafeLookup.swift +// TablePro +// + +import SwiftUI + +extension Binding where Value: MutableCollection & RandomAccessCollection, + Value.Element: Identifiable +{ + func element(_ item: Value.Element) -> Binding { + Binding( + get: { + wrappedValue.first(where: { $0.id == item.id }) ?? item + }, + set: { newValue in + guard let index = wrappedValue.firstIndex(where: { $0.id == newValue.id }) else { + return + } + wrappedValue[index] = newValue + } + ) + } +} diff --git a/TablePro/Models/Connection/ConnectionToolbarState.swift b/TablePro/Models/Connection/ConnectionToolbarState.swift index 667213d5c..ecfd8ccb3 100644 --- a/TablePro/Models/Connection/ConnectionToolbarState.swift +++ b/TablePro/Models/Connection/ConnectionToolbarState.swift @@ -79,7 +79,7 @@ enum ToolbarConnectionState: Equatable { case .connecting: return String(localized: "Connecting...") case .connected: return String(localized: "Connected") case .executing: return String(localized: "Executing...") - case .error(let message): return String(localized: "Error: \(message)") + case .error(let message): return String(format: String(localized: "Error: %@"), message) } } @@ -212,11 +212,11 @@ final class ConnectionToolbarState { var parts: [String] = [connectionState.description] if let latency = latencyMs { - parts.append(String(localized: "Latency: \(latency)ms")) + parts.append(String(format: String(localized: "Latency: %dms"), latency)) } if let lag = replicationLagSeconds { - parts.append(String(localized: "Replication lag: \(lag)s")) + parts.append(String(format: String(localized: "Replication lag: %ds"), lag)) } parts.append(safeModeLevel.displayName) diff --git a/TablePro/Models/Query/ParsedRow.swift b/TablePro/Models/Query/ParsedRow.swift index a14f9e640..09db5dbc9 100644 --- a/TablePro/Models/Query/ParsedRow.swift +++ b/TablePro/Models/Query/ParsedRow.swift @@ -40,9 +40,9 @@ enum RowParseError: LocalizedError { case .noValidRows: return String(localized: "No valid rows found in clipboard data.") case .columnCountMismatch(let expected, let actual, let line): - return String(localized: "Column count mismatch on line \(line): expected \(expected) columns, found \(actual).") + return String(format: String(localized: "Column count mismatch on line %d: expected %d columns, found %d."), line, expected, actual) case .invalidFormat(let reason): - return String(localized: "Invalid data format: \(reason)") + return String(format: String(localized: "Invalid data format: %@"), reason) } } } diff --git a/TablePro/Models/Query/QueryResult.swift b/TablePro/Models/Query/QueryResult.swift index ddb22f678..041b84594 100644 --- a/TablePro/Models/Query/QueryResult.swift +++ b/TablePro/Models/Query/QueryResult.swift @@ -62,7 +62,7 @@ enum DatabaseError: Error, LocalizedError { case .invalidCredentials: return String(localized: "Invalid username or password") case .fileNotFound(let path): - return String(localized: "Database file not found: \(path)") + return String(format: String(localized: "Database file not found: %@"), path) case .notConnected: return String(localized: "Not connected to database") case .unsupportedOperation: diff --git a/TablePro/Models/Settings/AppSettings.swift b/TablePro/Models/Settings/AppSettings.swift index 6181b063c..704a76e10 100644 --- a/TablePro/Models/Settings/AppSettings.swift +++ b/TablePro/Models/Settings/AppSettings.swift @@ -381,7 +381,7 @@ struct DataGridSettings: Codable, Equatable { if sanitized.isEmpty { return String(localized: "NULL display cannot be empty") } else if sanitized.count > maxLength { - return String(localized: "NULL display must be \(maxLength) characters or less") + return String(format: String(localized: "NULL display must be %d characters or less"), maxLength) } else if nullDisplay != sanitized { return String(localized: "NULL display contains invalid characters (newlines/tabs)") } @@ -392,7 +392,7 @@ struct DataGridSettings: Codable, Equatable { var defaultPageSizeValidationError: String? { let range = SettingsValidationRules.defaultPageSizeRange if defaultPageSize < range.lowerBound || defaultPageSize > range.upperBound { - return String(localized: "Page size must be between \(range.lowerBound.formatted()) and \(range.upperBound.formatted())") + return String(format: String(localized: "Page size must be between %@ and %@"), range.lowerBound.formatted(), range.upperBound.formatted()) } return nil } diff --git a/TablePro/Models/Settings/License.swift b/TablePro/Models/Settings/License.swift index 3c972dee0..6d9f102b7 100644 --- a/TablePro/Models/Settings/License.swift +++ b/TablePro/Models/Settings/License.swift @@ -262,11 +262,11 @@ enum LicenseError: LocalizedError { case .notActivated: return String(localized: "This machine is not activated.") case .networkError(let error): - return String(localized: "Network error: \(error.localizedDescription)") + return String(format: String(localized: "Network error: %@"), error.localizedDescription) case .serverError(let code, let message): - return String(localized: "Server error (\(code)): \(message)") + return String(format: String(localized: "Server error (%d): %@"), code, message) case .decodingError(let error): - return String(localized: "Failed to parse server response: \(error.localizedDescription)") + return String(format: String(localized: "Failed to parse server response: %@"), error.localizedDescription) } } @@ -287,7 +287,7 @@ enum LicenseError: LocalizedError { if code == 422 { return String(localized: "Invalid license key format. Check for typos and try again.") } - return String(localized: "Something went wrong (error \(code)). Try again in a moment.") + return String(format: String(localized: "Something went wrong (error %d). Try again in a moment."), code) case .signatureInvalid, .publicKeyNotFound, .publicKeyInvalid: return String(localized: "License verification failed. Try updating the app to the latest version.") case .notActivated: diff --git a/TablePro/ViewModels/WelcomeViewModel.swift b/TablePro/ViewModels/WelcomeViewModel.swift index d21c0a27d..0ebd67a74 100644 --- a/TablePro/ViewModels/WelcomeViewModel.swift +++ b/TablePro/ViewModels/WelcomeViewModel.swift @@ -388,7 +388,7 @@ final class WelcomeViewModel { alert.messageText = String(localized: "Import Complete") alert.informativeText = count == 1 ? String(localized: "1 connection was imported.") - : String(localized: "\(count) connections were imported.") + : String(format: String(localized: "%d connections were imported."), count) alert.icon = NSImage(systemSymbolName: "checkmark.circle.fill", accessibilityDescription: nil)? .withSymbolConfiguration(.init(paletteColors: [.white, .systemGreen])) } else { diff --git a/TablePro/Views/Components/SectionHeaderView.swift b/TablePro/Views/Components/SectionHeaderView.swift index 0dd7989cf..de859a76a 100644 --- a/TablePro/Views/Components/SectionHeaderView.swift +++ b/TablePro/Views/Components/SectionHeaderView.swift @@ -38,7 +38,7 @@ struct SectionHeaderView: View { headerContent } .buttonStyle(.plain) - .accessibilityLabel(String(localized: "\(title), \(isExpanded ? "collapse" : "expand")")) + .accessibilityLabel(String(format: String(localized: "%@, %@"), title, isExpanded ? String(localized: "collapse") : String(localized: "expand"))) } else { headerContent } diff --git a/TablePro/Views/Components/SyncStatusIndicator.swift b/TablePro/Views/Components/SyncStatusIndicator.swift index e6ff781c9..27964e09d 100644 --- a/TablePro/Views/Components/SyncStatusIndicator.swift +++ b/TablePro/Views/Components/SyncStatusIndicator.swift @@ -99,7 +99,7 @@ struct SyncStatusIndicator: View { let formatter = RelativeDateTimeFormatter() formatter.unitsStyle = .full let relative = formatter.localizedString(for: lastSync, relativeTo: Date()) - return String(localized: "Last synced \(relative)") + return String(format: String(localized: "Last synced %@"), relative) } return String(localized: "iCloud Sync is active") case .syncing: diff --git a/TablePro/Views/Connection/ConnectionColorPicker.swift b/TablePro/Views/Connection/ConnectionColorPicker.swift index f5a9adf61..5f2e718e2 100644 --- a/TablePro/Views/Connection/ConnectionColorPicker.swift +++ b/TablePro/Views/Connection/ConnectionColorPicker.swift @@ -21,7 +21,7 @@ struct ConnectionColorPicker: View { ) } .buttonStyle(.plain) - .accessibilityLabel(String(localized: "Color \(color.rawValue)")) + .accessibilityLabel(String(format: String(localized: "Color %@"), color.rawValue)) } } } diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index 84240717a..ec94a77aa 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -1109,7 +1109,7 @@ struct ConnectionFormView: View { let isApiOnly = PluginManager.shared.connectionMode(for: type) == .apiOnly let testPwOverride: String? = promptForPassword ? (password.isEmpty - ? PasswordPromptHelper.prompt(connectionName: name.isEmpty ? host : name, isAPIToken: isApiOnly) + ? await PasswordPromptHelper.prompt(connectionName: name.isEmpty ? host : name, isAPIToken: isApiOnly, window: NSApp.keyWindow) : password) : nil guard !promptForPassword || testPwOverride != nil else { diff --git a/TablePro/Views/Connection/ConnectionGroupPicker.swift b/TablePro/Views/Connection/ConnectionGroupPicker.swift index 1e578f12a..67e61f209 100644 --- a/TablePro/Views/Connection/ConnectionGroupPicker.swift +++ b/TablePro/Views/Connection/ConnectionGroupPicker.swift @@ -251,7 +251,7 @@ private struct GroupColorPicker: View { ) } .buttonStyle(.plain) - .accessibilityLabel(String(localized: "Color \(color.rawValue)")) + .accessibilityLabel(String(format: String(localized: "Color %@"), color.rawValue)) } } } diff --git a/TablePro/Views/Connection/ConnectionSSHTunnelView.swift b/TablePro/Views/Connection/ConnectionSSHTunnelView.swift index eb3df2bb2..8e033f4b8 100644 --- a/TablePro/Views/Connection/ConnectionSSHTunnelView.swift +++ b/TablePro/Views/Connection/ConnectionSSHTunnelView.swift @@ -264,22 +264,23 @@ struct ConnectionSSHTunnelView: View { Section { DisclosureGroup(String(localized: "Jump Hosts")) { - ForEach($jumpHosts) { $jumpHost in + ForEach(jumpHosts) { jumpHost in + let jumpHostBinding = $jumpHosts.element(jumpHost) DisclosureGroup { - TextField(String(localized: "Host"), text: $jumpHost.host, prompt: Text("bastion.example.com")) + TextField(String(localized: "Host"), text: jumpHostBinding.host, prompt: Text("bastion.example.com")) HStack { TextField( String(localized: "Port"), text: Binding( - get: { String(jumpHost.port) }, - set: { jumpHost.port = Int($0) ?? 22 } + get: { String(jumpHostBinding.wrappedValue.port) }, + set: { jumpHostBinding.wrappedValue.port = Int($0) ?? 22 } ), prompt: Text("22") ) .frame(width: 80) - TextField(String(localized: "Username"), text: $jumpHost.username, prompt: Text("admin")) + TextField(String(localized: "Username"), text: jumpHostBinding.username, prompt: Text("admin")) } - Picker(String(localized: "Auth"), selection: $jumpHost.authMethod) { + Picker(String(localized: "Auth"), selection: jumpHostBinding.authMethod) { ForEach(SSHJumpAuthMethod.allCases) { method in Text(method.rawValue).tag(method) } @@ -287,9 +288,9 @@ struct ConnectionSSHTunnelView: View { if jumpHost.authMethod == .privateKey { LabeledContent(String(localized: "Key File")) { HStack { - TextField("", text: $jumpHost.privateKeyPath, prompt: Text("~/.ssh/id_rsa")) + TextField("", text: jumpHostBinding.privateKeyPath, prompt: Text("~/.ssh/id_rsa")) Button(String(localized: "Browse")) { - browseForJumpHostKey(jumpHost: $jumpHost) + browseForJumpHostKey(jumpHost: jumpHostBinding) } .controlSize(.small) } diff --git a/TablePro/Views/Connection/ConnectionTagEditor.swift b/TablePro/Views/Connection/ConnectionTagEditor.swift index bef73d69c..ccd3359bc 100644 --- a/TablePro/Views/Connection/ConnectionTagEditor.swift +++ b/TablePro/Views/Connection/ConnectionTagEditor.swift @@ -197,7 +197,7 @@ private struct TagColorPicker: View { ) } .buttonStyle(.plain) - .accessibilityLabel(String(localized: "Color \(color.rawValue)")) + .accessibilityLabel(String(format: String(localized: "Color %@"), color.rawValue)) } } } diff --git a/TablePro/Views/Connection/PluginInstallModifier.swift b/TablePro/Views/Connection/PluginInstallModifier.swift index 37bc5744b..34b5ef758 100644 --- a/TablePro/Views/Connection/PluginInstallModifier.swift +++ b/TablePro/Views/Connection/PluginInstallModifier.swift @@ -33,7 +33,7 @@ struct PluginInstallModifier: ViewModifier { } } message: { if let conn = connection { - Text(String(localized: "The \(conn.type.rawValue) plugin is not installed. Would you like to download it from the plugin marketplace?")) + Text(String(format: String(localized: "The %@ plugin is not installed. Would you like to download it from the plugin marketplace?"), conn.type.rawValue)) } } .alert( diff --git a/TablePro/Views/Connection/SSHProfileEditorView.swift b/TablePro/Views/Connection/SSHProfileEditorView.swift index 4672fe155..f04324bc9 100644 --- a/TablePro/Views/Connection/SSHProfileEditorView.swift +++ b/TablePro/Views/Connection/SSHProfileEditorView.swift @@ -221,22 +221,23 @@ struct SSHProfileEditorView: View { private var jumpHostsSection: some View { Section { DisclosureGroup(String(localized: "Jump Hosts")) { - ForEach($jumpHosts) { $jumpHost in + ForEach(jumpHosts) { jumpHost in + let jumpHostBinding = $jumpHosts.element(jumpHost) DisclosureGroup { - TextField(String(localized: "Host"), text: $jumpHost.host, prompt: Text("bastion.example.com")) + TextField(String(localized: "Host"), text: jumpHostBinding.host, prompt: Text("bastion.example.com")) HStack { TextField( String(localized: "Port"), text: Binding( - get: { String(jumpHost.port) }, - set: { jumpHost.port = Int($0) ?? 22 } + get: { String(jumpHostBinding.wrappedValue.port) }, + set: { jumpHostBinding.wrappedValue.port = Int($0) ?? 22 } ), prompt: Text("22") ) .frame(width: 80) - TextField(String(localized: "Username"), text: $jumpHost.username, prompt: Text("admin")) + TextField(String(localized: "Username"), text: jumpHostBinding.username, prompt: Text("admin")) } - Picker(String(localized: "Auth"), selection: $jumpHost.authMethod) { + Picker(String(localized: "Auth"), selection: jumpHostBinding.authMethod) { ForEach(SSHJumpAuthMethod.allCases) { method in Text(method.rawValue).tag(method) } @@ -244,9 +245,9 @@ struct SSHProfileEditorView: View { if jumpHost.authMethod == .privateKey { LabeledContent(String(localized: "Key File")) { HStack { - TextField("", text: $jumpHost.privateKeyPath, prompt: Text("~/.ssh/id_rsa")) + TextField("", text: jumpHostBinding.privateKeyPath, prompt: Text("~/.ssh/id_rsa")) Button(String(localized: "Browse")) { - browseForJumpHostKey(jumpHost: $jumpHost) + browseForJumpHostKey(jumpHost: jumpHostBinding) } .controlSize(.small) } diff --git a/TablePro/Views/Connection/WelcomeContextMenus.swift b/TablePro/Views/Connection/WelcomeContextMenus.swift index e02342f56..a9b5afbc3 100644 --- a/TablePro/Views/Connection/WelcomeContextMenus.swift +++ b/TablePro/Views/Connection/WelcomeContextMenus.swift @@ -19,7 +19,7 @@ extension WelcomeWindowView { private func multiSelectionContextMenu(for connection: DatabaseConnection) -> some View { Button { vm.connectSelectedConnections() } label: { Label( - String(localized: "Connect \(vm.selectedConnectionIds.count) Connections"), + String(format: String(localized: "Connect %d Connections"), vm.selectedConnectionIds.count), systemImage: "play.fill" ) } @@ -30,7 +30,7 @@ extension WelcomeWindowView { vm.exportConnections(Array(vm.selectedConnections)) } label: { Label( - String(localized: "Export \(vm.selectedConnectionIds.count) Connections..."), + String(format: String(localized: "Export %d Connections..."), vm.selectedConnectionIds.count), systemImage: "square.and.arrow.up" ) } @@ -53,7 +53,7 @@ extension WelcomeWindowView { vm.showDeleteConfirmation = true } label: { Label( - String(localized: "Delete \(vm.selectedConnectionIds.count) Connections"), + String(format: String(localized: "Delete %d Connections"), vm.selectedConnectionIds.count), systemImage: "trash" ) } diff --git a/TablePro/Views/Connection/WelcomeWindowView.swift b/TablePro/Views/Connection/WelcomeWindowView.swift index 0752bb528..bca242c8f 100644 --- a/TablePro/Views/Connection/WelcomeWindowView.swift +++ b/TablePro/Views/Connection/WelcomeWindowView.swift @@ -42,7 +42,7 @@ struct WelcomeWindowView: View { .confirmationDialog( vm.connectionsToDelete.count == 1 ? String(localized: "Delete Connection") - : String(localized: "Delete \(vm.connectionsToDelete.count) Connections"), + : String(format: String(localized: "Delete %d Connections"), vm.connectionsToDelete.count), isPresented: $vm.showDeleteConfirmation ) { Button(String(localized: "Delete"), role: .destructive) { diff --git a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift index 24c0028b5..f78e10af9 100644 --- a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift +++ b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift @@ -387,8 +387,8 @@ struct DatabaseSwitcherSheet: View { .font(.system(size: ThemeEngine.shared.activeTheme.typography.body, weight: .medium)) Text(isSchemaMode - ? String(localized: "No schemas match \"\(viewModel.searchText)\"") - : String(localized: "No databases match \"\(viewModel.searchText)\"")) + ? String(format: String(localized: "No schemas match \"%@\""), viewModel.searchText) + : String(format: String(localized: "No databases match \"%@\""), viewModel.searchText)) .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) .foregroundStyle(.secondary) } diff --git a/TablePro/Views/Editor/HistoryPanelView.swift b/TablePro/Views/Editor/HistoryPanelView.swift index 166f0b5b1..b1034f90a 100644 --- a/TablePro/Views/Editor/HistoryPanelView.swift +++ b/TablePro/Views/Editor/HistoryPanelView.swift @@ -304,7 +304,7 @@ private extension HistoryPanelView { func buildSecondaryMetadata(_ entry: QueryHistoryEntry) -> String { let executedAt = Self.metadataDateFormatter.string(from: entry.executedAt) - var text = String(localized: "Executed: \(executedAt)") + var text = String(format: String(localized: "Executed: %@"), executedAt) if !entry.wasSuccessful, let error = entry.errorMessage { text += "\nError: \(error)" diff --git a/TablePro/Views/Editor/QuerySuccessView.swift b/TablePro/Views/Editor/QuerySuccessView.swift index 94c2605e8..b1f7bface 100644 --- a/TablePro/Views/Editor/QuerySuccessView.swift +++ b/TablePro/Views/Editor/QuerySuccessView.swift @@ -59,13 +59,13 @@ struct QuerySuccessView: View { private func formatExecutionTime(_ time: TimeInterval) -> String { if time < 0.001 { let ms = String(format: "%.3f", time * 1_000) - return String(localized: "\(ms) ms") + return String(format: String(localized: "%@ ms"), ms) } else if time < 1 { let ms = String(format: "%.2f", time * 1_000) - return String(localized: "\(ms) ms") + return String(format: String(localized: "%@ ms"), ms) } else { let secs = String(format: "%.2f", time) - return String(localized: "\(secs) s") + return String(format: String(localized: "%@ s"), secs) } } } diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift index 2580488b9..8f424a1c5 100644 --- a/TablePro/Views/Export/ExportDialog.swift +++ b/TablePro/Views/Export/ExportDialog.swift @@ -603,7 +603,7 @@ struct ExportDialog: View { isLoading = false AlertHelper.showErrorSheet( title: String(localized: "Export Error"), - message: String(localized: "Failed to load databases: \(error.localizedDescription)"), + message: String(format: String(localized: "Failed to load databases: %@"), error.localizedDescription), window: nil ) } @@ -709,9 +709,9 @@ struct ExportDialog: View { let formatName = currentPlugin.map { type(of: $0).formatDisplayName } ?? config.formatId.uppercased() if isQueryResultsMode { - savePanel.message = String(localized: "Export \(queryResultsRowCount) row(s) to \(formatName)") + savePanel.message = String(format: String(localized: "Export %d row(s) to %@"), queryResultsRowCount, formatName) } else { - savePanel.message = String(localized: "Export \(exportableCount) table(s) to \(formatName)") + savePanel.message = String(format: String(localized: "Export %d table(s) to %@"), exportableCount, formatName) } savePanel.begin { response in diff --git a/TablePro/Views/Export/ExportTableTreeView.swift b/TablePro/Views/Export/ExportTableTreeView.swift index 04b32086d..447afb072 100644 --- a/TablePro/Views/Export/ExportTableTreeView.swift +++ b/TablePro/Views/Export/ExportTableTreeView.swift @@ -26,13 +26,15 @@ struct ExportTableTreeView: View { var body: some View { VStack(spacing: 0) { List { - ForEach($databaseItems) { $database in - DisclosureGroup(isExpanded: $database.isExpanded) { - ForEach($database.tables) { $table in - tableRow(table: $table) + ForEach(databaseItems) { database in + let databaseBinding = $databaseItems.element(database) + DisclosureGroup(isExpanded: databaseBinding.isExpanded) { + ForEach(database.tables) { table in + let tableBinding = databaseBinding.tables.element(table) + tableRow(table: tableBinding) } } label: { - databaseLabel(database: database, allTables: $database.tables) + databaseLabel(database: database, allTables: databaseBinding.tables) } } } diff --git a/TablePro/Views/Import/ImportSuccessView.swift b/TablePro/Views/Import/ImportSuccessView.swift index 0f49c6e60..d726da945 100644 --- a/TablePro/Views/Import/ImportSuccessView.swift +++ b/TablePro/Views/Import/ImportSuccessView.swift @@ -28,7 +28,7 @@ struct ImportSuccessView: View { .foregroundStyle(.secondary) let formattedTime = String(format: "%.2f", result.executionTime) - Text(String(localized: "\(formattedTime) seconds")) + Text(String(format: String(localized: "%@ seconds"), formattedTime)) .font(.system(size: 12)) .foregroundStyle(.secondary) } diff --git a/TablePro/Views/Main/Child/MainStatusBarView.swift b/TablePro/Views/Main/Child/MainStatusBarView.swift index 834e9189c..5adb87671 100644 --- a/TablePro/Views/Main/Child/MainStatusBarView.swift +++ b/TablePro/Views/Main/Child/MainStatusBarView.swift @@ -151,19 +151,19 @@ struct MainStatusBarView: View { if selectedCount > 0 { // Selection mode: "5 of 200 rows selected" if selectedCount == loadedCount { - return String(localized: "All \(loadedCount) rows selected") + return String(format: String(localized: "All %d rows selected"), loadedCount) } else { - return String(localized: "\(selectedCount) of \(loadedCount) rows selected") + return String(format: String(localized: "%d of %d rows selected"), selectedCount, loadedCount) } } else if tab.tabType == .table, let total = total, total > 0 { // Pagination mode (table tabs only): "201-400 of 5,000 rows" let formattedTotal = Self.decimalFormatter.string(from: NSNumber(value: total)) ?? "\(total)" let prefix = pagination.isApproximateRowCount ? "~" : "" - return String(localized: "\(pagination.rangeStart)-\(pagination.rangeEnd) of \(prefix)\(formattedTotal) rows") + return String(format: String(localized: "%d-%d of %@%@ rows"), pagination.rangeStart, pagination.rangeEnd, prefix, formattedTotal) } else if loadedCount > 0 { let formattedCount = Self.decimalFormatter.string(from: NSNumber(value: loadedCount)) ?? "\(loadedCount)" - return String(localized: "\(formattedCount) rows") + return String(format: String(localized: "%@ rows"), formattedCount) } else { return String(localized: "No rows") } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift index 83096e809..0ed5fdcbd 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift @@ -114,7 +114,7 @@ extension MainContentCoordinator { guard PluginManager.shared.supportsImport(for: connection.type) else { AlertHelper.showErrorSheet( title: String(localized: "Import Not Supported"), - message: String(localized: "SQL import is not supported for \(connection.type.rawValue) connections."), + message: String(format: String(localized: "SQL import is not supported for %@ connections."), connection.type.rawValue), window: nil ) return diff --git a/TablePro/Views/Results/DataGridCellFactory.swift b/TablePro/Views/Results/DataGridCellFactory.swift index a7f25b687..55baa8fa8 100644 --- a/TablePro/Views/Results/DataGridCellFactory.swift +++ b/TablePro/Views/Results/DataGridCellFactory.swift @@ -113,7 +113,7 @@ final class DataGridCellFactory { cell.stringValue = "\(row + 1)" cell.textColor = visualState.isDeleted ? ThemeEngine.shared.colors.dataGrid.deletedText : .secondaryLabelColor if Self.cachedVoiceOverEnabled { - cellView.setAccessibilityLabel(String(localized: "Row \(row + 1)")) + cellView.setAccessibilityLabel(String(format: String(localized: "Row %d"), row + 1)) } return cellView @@ -291,7 +291,7 @@ final class DataGridCellFactory { if !isLargeDataset && Self.cachedVoiceOverEnabled { let accessibilityValue = rawValue ?? String(localized: "NULL") cell.setAccessibilityLabel( - String(localized: "Row \(row + 1), column \(columnIndex + 1): \(accessibilityValue)") + String(format: String(localized: "Row %d, column %d: %@"), row + 1, columnIndex + 1, accessibilityValue) ) } diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 3709e1ad0..dd83aafea 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -131,7 +131,7 @@ struct DataGridView: NSViewRepresentable { column.headerToolTip = "\(columnName) (\(typeName))" } column.headerCell.setAccessibilityLabel( - String(localized: "Column: \(columnName)") + String(format: String(localized: "Column: %@"), columnName) ) column.width = context.coordinator.cellFactory.calculateOptimalColumnWidth( for: columnName, @@ -401,7 +401,7 @@ struct DataGridView: NSViewRepresentable { column.headerToolTip = "\(columnName) (\(typeName))" } column.headerCell.setAccessibilityLabel( - String(localized: "Column: \(columnName)") + String(format: String(localized: "Column: %@"), columnName) ) if willRestoreWidths { column.width = columnLayout.columnWidths[columnName] ?? 100 diff --git a/TablePro/Views/Settings/AISettingsView.swift b/TablePro/Views/Settings/AISettingsView.swift index ad9e2f3c0..c09bed3dd 100644 --- a/TablePro/Views/Settings/AISettingsView.swift +++ b/TablePro/Views/Settings/AISettingsView.swift @@ -177,7 +177,7 @@ struct AISettingsView: View { Toggle(String(localized: "Include query results"), isOn: $settings.includeQueryResults) Stepper( - String(localized: "Max schema tables: \(settings.maxSchemaTables)"), + String(format: String(localized: "Max schema tables: %d"), settings.maxSchemaTables), value: $settings.maxSchemaTables, in: 1...100 ) diff --git a/TablePro/Views/Settings/Appearance/ThemeListView.swift b/TablePro/Views/Settings/Appearance/ThemeListView.swift index 803d7d7f1..23f7fce50 100644 --- a/TablePro/Views/Settings/Appearance/ThemeListView.swift +++ b/TablePro/Views/Settings/Appearance/ThemeListView.swift @@ -124,7 +124,7 @@ internal struct ThemeListView: View { Button(String(localized: "Cancel"), role: .cancel) {} } message: { let name = engine.availableThemes.first(where: { $0.id == selectedThemeId })?.name ?? "" - Text(String(localized: "Are you sure you want to delete \"\(name)\"?")) + Text(String(format: String(localized: "Are you sure you want to delete \"%@\"?"), name)) } .alert(String(localized: "Error"), isPresented: $showError) { Button(String(localized: "OK")) {} diff --git a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift index 809b85e12..88aad66fc 100644 --- a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift +++ b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift @@ -126,7 +126,7 @@ struct InstalledPluginsView: View { .buttonStyle(.borderless) .disabled(selectedPlugin == nil || selectedPlugin?.source == .builtIn) .accessibilityLabel( - selectedPlugin.map { String(localized: "Uninstall \($0.name)") } + selectedPlugin.map { String(format: String(localized: "Uninstall %@"), $0.name) } ?? String(localized: "Uninstall plugin") ) @@ -199,7 +199,7 @@ struct InstalledPluginsView: View { .toggleStyle(.switch) .labelsHidden() .controlSize(.small) - .accessibilityLabel(String(localized: "Enable \(selected.name)")) + .accessibilityLabel(String(format: String(localized: "Enable %@"), selected.name)) } Text("v\(selected.version) · \(selected.source == .builtIn ? String(localized: "Built-in") : String(localized: "User-installed"))") @@ -319,7 +319,7 @@ struct InstalledPluginsView: View { Task { @MainActor in let confirmed = await AlertHelper.confirmDestructive( title: String(localized: "Uninstall Plugin?"), - message: String(localized: "\"\(plugin.name)\" will be removed from your system. This action cannot be undone."), + message: String(format: String(localized: "\"%@\" will be removed from your system. This action cannot be undone."), plugin.name), confirmButton: String(localized: "Uninstall"), cancelButton: String(localized: "Cancel") ) diff --git a/TablePro/Views/Settings/Plugins/RegistryPluginDetailView.swift b/TablePro/Views/Settings/Plugins/RegistryPluginDetailView.swift index 42c383cfd..629ad95a5 100644 --- a/TablePro/Views/Settings/Plugins/RegistryPluginDetailView.swift +++ b/TablePro/Views/Settings/Plugins/RegistryPluginDetailView.swift @@ -145,7 +145,7 @@ struct RegistryPluginDetailView: View { private func formattedCount(_ count: Int) -> String { let formatted = Self.decimalFormatter.string(from: NSNumber(value: count)) ?? "\(count)" return count == 1 - ? String(localized: "\(formatted) download") - : String(localized: "\(formatted) downloads") + ? String(format: String(localized: "%@ download"), formatted) + : String(format: String(localized: "%@ downloads"), formatted) } } diff --git a/TablePro/Views/Sidebar/FavoriteRowView.swift b/TablePro/Views/Sidebar/FavoriteRowView.swift index 7c478d1e3..f12f9d3be 100644 --- a/TablePro/Views/Sidebar/FavoriteRowView.swift +++ b/TablePro/Views/Sidebar/FavoriteRowView.swift @@ -40,7 +40,7 @@ internal struct FavoriteRowView: View { private var accessibilityDescription: String { if let keyword = favorite.keyword, !keyword.isEmpty { - return "\(favorite.name), \(String(localized: "keyword: \(keyword)"))" + return "\(favorite.name), \(String(format: String(localized: "keyword: %@"), keyword))" } return favorite.name } diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index adf202b20..6150975b7 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -183,8 +183,8 @@ struct SidebarView: View { private var emptyState: some View { let entityName = PluginManager.shared.tableEntityName(for: viewModel.databaseType) - let noItemsLabel = String(localized: "No \(entityName)") - let noItemsDetail = String(localized: "This database has no \(entityName.lowercased()) yet.") + let noItemsLabel = String(format: String(localized: "No %@"), entityName) + let noItemsDetail = String(format: String(localized: "This database has no %@ yet."), entityName.lowercased()) return VStack(spacing: 6) { Image(systemName: "tablecells") .font(.system(size: 28, weight: .thin)) @@ -205,9 +205,9 @@ struct SidebarView: View { private var tableList: some View { let entityLabel = PluginManager.shared.tableEntityName(for: viewModel.databaseType) - let noMatchLabel = String(localized: "No matching \(entityLabel.lowercased())") - let helpLabel = String(localized: "Right-click to show all \(entityLabel.lowercased())") - let showAllLabel = String(localized: "Show All \(entityLabel)") + let noMatchLabel = String(format: String(localized: "No matching %@"), entityLabel.lowercased()) + let helpLabel = String(format: String(localized: "Right-click to show all %@"), entityLabel.lowercased()) + let showAllLabel = String(format: String(localized: "Show All %@"), entityLabel) return List(selection: selectedTablesBinding) { if filteredTables.isEmpty { ContentUnavailableView( diff --git a/TablePro/Views/Sidebar/TableOperationDialog.swift b/TablePro/Views/Sidebar/TableOperationDialog.swift index fca7f801a..9f1b1d01b 100644 --- a/TablePro/Views/Sidebar/TableOperationDialog.swift +++ b/TablePro/Views/Sidebar/TableOperationDialog.swift @@ -33,12 +33,12 @@ struct TableOperationDialog: View { switch operationType { case .drop: return tableCount > 1 - ? String(localized: "Drop \(tableCount) tables") - : String(localized: "Drop table '\(tableName)'") + ? String(format: String(localized: "Drop %d tables"), tableCount) + : String(format: String(localized: "Drop table '%@'"), tableName) case .truncate: return tableCount > 1 - ? String(localized: "Truncate \(tableCount) tables") - : String(localized: "Truncate table '\(tableName)'") + ? String(format: String(localized: "Truncate %d tables"), tableCount) + : String(format: String(localized: "Truncate table '%@'"), tableName) } } diff --git a/TablePro/Views/Sidebar/TableRowView.swift b/TablePro/Views/Sidebar/TableRowView.swift index 6750e1190..72607180a 100644 --- a/TablePro/Views/Sidebar/TableRowView.swift +++ b/TablePro/Views/Sidebar/TableRowView.swift @@ -11,8 +11,8 @@ import SwiftUI enum TableRowLogic { static func accessibilityLabel(table: TableInfo, isPendingDelete: Bool, isPendingTruncate: Bool) -> String { var label = table.type == .view - ? String(localized: "View: \(table.name)") - : String(localized: "Table: \(table.name)") + ? String(format: String(localized: "View: %@"), table.name) + : String(format: String(localized: "Table: %@"), table.name) if isPendingDelete { label += ", " + String(localized: "pending delete") } else if isPendingTruncate { diff --git a/TablePro/Views/Structure/StructureColumnReorderHandler.swift b/TablePro/Views/Structure/StructureColumnReorderHandler.swift index df274db16..118498d1c 100644 --- a/TablePro/Views/Structure/StructureColumnReorderHandler.swift +++ b/TablePro/Views/Structure/StructureColumnReorderHandler.swift @@ -32,7 +32,7 @@ enum StructureColumnReorderHandler { case .sqlGenerationFailed: return String(localized: "Failed to generate SQL for column reorder") case .executionFailed(let message): - return String(localized: "Column reorder failed: \(message)") + return String(format: String(localized: "Column reorder failed: %@"), message) } } } diff --git a/TablePro/Views/Toolbar/ConnectionStatusView.swift b/TablePro/Views/Toolbar/ConnectionStatusView.swift index 44478f9b0..ff08ae65e 100644 --- a/TablePro/Views/Toolbar/ConnectionStatusView.swift +++ b/TablePro/Views/Toolbar/ConnectionStatusView.swift @@ -46,7 +46,7 @@ struct ConnectionStatusView: View { .truncationMode(.middle) .frame(maxWidth: 280) .accessibilityLabel( - String(localized: "Database type: \(formattedDatabaseInfo)") + String(format: String(localized: "Database type: %@"), formattedDatabaseInfo) ) .help("Database: \(formattedDatabaseInfo)") } @@ -65,8 +65,8 @@ struct ConnectionStatusView: View { } .buttonStyle(.plain) .help(safeModeLevel == .readOnly - ? String(localized: "Current database: \(databaseName) (read-only, ⌘K to switch)") - : String(localized: "Current database: \(databaseName) (⌘K to switch)")) + ? String(format: String(localized: "Current database: %@ (read-only, ⌘K to switch)"), databaseName) + : String(format: String(localized: "Current database: %@ (⌘K to switch)"), databaseName)) } } diff --git a/TablePro/Views/Toolbar/ExecutionIndicatorView.swift b/TablePro/Views/Toolbar/ExecutionIndicatorView.swift index 9438161c1..8e010ca92 100644 --- a/TablePro/Views/Toolbar/ExecutionIndicatorView.swift +++ b/TablePro/Views/Toolbar/ExecutionIndicatorView.swift @@ -34,14 +34,14 @@ struct ExecutionIndicatorView: View { Text(chProgress.formattedSummary) .font(.system(size: ThemeEngine.shared.activeTheme.typography.small, weight: .regular, design: .monospaced)) .foregroundStyle(ThemeEngine.shared.colors.toolbar.tertiaryTextSwiftUI) - .accessibilityLabel(String(localized: "Last query: \(chProgress.formattedSummary)")) + .accessibilityLabel(String(format: String(localized: "Last query: %@"), chProgress.formattedSummary)) .help("Last query execution summary") } else if let duration = lastDuration { Text(formattedDuration(duration)) .font(.system(size: ThemeEngine.shared.activeTheme.typography.small, weight: .regular, design: .monospaced)) .foregroundStyle(ThemeEngine.shared.colors.toolbar.tertiaryTextSwiftUI) .accessibilityLabel( - String(localized: "Last query took \(formattedDuration(duration))") + String(format: String(localized: "Last query took %@"), formattedDuration(duration)) ) .help("Last query execution time") } else { @@ -63,14 +63,14 @@ struct ExecutionIndicatorView: View { return String(localized: "<1ms") } else if duration < 1.0 { let ms = String(format: "%.0f", duration * 1_000) - return String(localized: "\(ms)ms") + return String(format: String(localized: "%@ms"), ms) } else if duration < 60.0 { let secs = String(format: "%.2f", duration) - return String(localized: "\(secs)s") + return String(format: String(localized: "%@s"), secs) } else { let minutes = Int(duration) / 60 let seconds = Int(duration) % 60 - return String(localized: "\(minutes)m \(seconds)s") + return String(format: String(localized: "%dm %ds"), minutes, seconds) } } } diff --git a/TablePro/Views/Toolbar/SafeModeBadgeView.swift b/TablePro/Views/Toolbar/SafeModeBadgeView.swift index 2951886d1..fc0e87c67 100644 --- a/TablePro/Views/Toolbar/SafeModeBadgeView.swift +++ b/TablePro/Views/Toolbar/SafeModeBadgeView.swift @@ -26,7 +26,7 @@ struct SafeModeBadgeView: View { } } .buttonStyle(.plain) - .help(String(localized: "Safe Mode: \(safeModeLevel.displayName)")) + .help(String(format: String(localized: "Safe Mode: %@"), safeModeLevel.displayName)) .popover(isPresented: $showPopover) { VStack(alignment: .leading, spacing: 8) { Text("Safe Mode")