From 38b73a1ba517307942b16f6cf8f4cc5a42bab1ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 8 May 2026 16:55:24 +0700 Subject: [PATCH 1/9] refactor: migrate ServerDashboardViewModel to AppServices --- .../ViewModels/ServerDashboardViewModel.swift | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/TablePro/ViewModels/ServerDashboardViewModel.swift b/TablePro/ViewModels/ServerDashboardViewModel.swift index 3c0d0a17b..13dee3221 100644 --- a/TablePro/ViewModels/ServerDashboardViewModel.swift +++ b/TablePro/ViewModels/ServerDashboardViewModel.swift @@ -51,6 +51,7 @@ final class ServerDashboardViewModel { // MARK: - Private @ObservationIgnored nonisolated(unsafe) private var refreshTask: Task? + @ObservationIgnored private let services: AppServices // MARK: - Computed Properties @@ -72,10 +73,11 @@ final class ServerDashboardViewModel { // MARK: - Initialization - init(connectionId: UUID, databaseType: DatabaseType) { + init(connectionId: UUID, databaseType: DatabaseType, services: AppServices = .live) { self.connectionId = connectionId self.databaseType = databaseType self.provider = ServerDashboardQueryProviderFactory.provider(for: databaseType) + self.services = services } deinit { @@ -120,14 +122,13 @@ final class ServerDashboardViewModel { return } - // Skip silently if connection is not ready yet — the refresh loop will retry - guard DatabaseManager.shared.driver(for: connectionId) != nil else { return } + guard services.databaseManager.driver(for: connectionId) != nil else { return } isRefreshing = true defer { isRefreshing = false } - let execute: (String) async throws -> QueryResult = { [connectionId] query in - guard let driver = DatabaseManager.shared.driver(for: connectionId) else { + let execute: (String) async throws -> QueryResult = { [connectionId, services] query in + guard let driver = services.databaseManager.driver(for: connectionId) else { throw DatabaseError.connectionFailed( String(localized: "No active connection") ) @@ -184,7 +185,7 @@ final class ServerDashboardViewModel { guard let sql = provider?.killSessionSQL(processId: processId) else { return } do { - guard let driver = DatabaseManager.shared.driver(for: connectionId) else { + guard let driver = services.databaseManager.driver(for: connectionId) else { throw DatabaseError.connectionFailed( String(localized: "No active connection") ) @@ -213,7 +214,7 @@ final class ServerDashboardViewModel { guard let sql = provider?.cancelQuerySQL(processId: processId) else { return } do { - guard let driver = DatabaseManager.shared.driver(for: connectionId) else { + guard let driver = services.databaseManager.driver(for: connectionId) else { throw DatabaseError.connectionFailed( String(localized: "No active connection") ) From a34a903142c8c7fbbeaeb5e561db716f44570710 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 8 May 2026 16:56:05 +0700 Subject: [PATCH 2/9] refactor: migrate FavoritesSidebarViewModel to AppServices --- TablePro/ViewModels/FavoritesSidebarViewModel.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/TablePro/ViewModels/FavoritesSidebarViewModel.swift b/TablePro/ViewModels/FavoritesSidebarViewModel.swift index 54bf8baac..04b6653a4 100644 --- a/TablePro/ViewModels/FavoritesSidebarViewModel.swift +++ b/TablePro/ViewModels/FavoritesSidebarViewModel.swift @@ -123,7 +123,8 @@ internal final class FavoritesSidebarViewModel { @ObservationIgnored private let connectionId: UUID @ObservationIgnored private let cache: ConnectionDataCache - @ObservationIgnored private let manager = SQLFavoriteManager.shared + @ObservationIgnored private let services: AppServices + @ObservationIgnored private var manager: SQLFavoriteManager { services.sqlFavoriteManager } var isInitialLoadComplete: Bool { cache.isInitialLoadComplete } @@ -137,8 +138,9 @@ internal final class FavoritesSidebarViewModel { return roots } - init(connectionId: UUID) { + init(connectionId: UUID, services: AppServices = .live) { self.connectionId = connectionId + self.services = services self.cache = ConnectionDataCache.shared(for: connectionId) cache.ensureLoaded() } From 25fb68e7e7bb0c3ba7b098ca3a6ece0fcc66e06d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 8 May 2026 16:57:48 +0700 Subject: [PATCH 3/9] refactor: migrate AIChatViewModel to AppServices --- .../AIChatViewModel+SchemaContext.swift | 20 +++++++++---------- .../AIChatViewModel+SlashCommands.swift | 2 +- .../AIChatViewModel+Streaming.swift | 2 +- .../AIChatViewModel+ToolApproval.swift | 2 +- TablePro/ViewModels/AIChatViewModel.swift | 12 ++++++----- 5 files changed, 20 insertions(+), 18 deletions(-) diff --git a/TablePro/ViewModels/AIChatViewModel+SchemaContext.swift b/TablePro/ViewModels/AIChatViewModel+SchemaContext.swift index 507aebe80..e3883d73d 100644 --- a/TablePro/ViewModels/AIChatViewModel+SchemaContext.swift +++ b/TablePro/ViewModels/AIChatViewModel+SchemaContext.swift @@ -30,7 +30,7 @@ extension AIChatViewModel { return } guard let connection, - let driver = DatabaseManager.shared.driver(for: connection.id) else { return } + let driver = services.databaseManager.driver(for: connection.id) else { return } let task: Task = Task { [weak self] in let columns: [ColumnInfo] do { @@ -73,7 +73,7 @@ extension AIChatViewModel { func ensureSavedQueryLoaded(id: UUID) async { if cachedSavedQueries[id] != nil { return } - if let favorite = await SQLFavoriteManager.shared.fetchFavorite(id: id) { + if let favorite = await services.sqlFavoriteManager.fetchFavorite(id: id) { cachedSavedQueries[id] = favorite } } @@ -93,8 +93,8 @@ extension AIChatViewModel { private func runSchemaLoad() async { guard let connection, - let driver = DatabaseManager.shared.driver(for: connection.id) else { return } - let settings = AppSettingsManager.shared.ai + let driver = services.databaseManager.driver(for: connection.id) else { return } + let settings = services.appSettings.ai let tablesToFetch = Array(tables.prefix(settings.maxSchemaTables)) guard !tablesToFetch.isEmpty else { return } @@ -134,16 +134,16 @@ extension AIChatViewModel { guard let connection else { return nil } return PromptContext( databaseType: connection.type, - databaseName: DatabaseManager.shared.activeDatabaseName(for: connection), + databaseName: services.databaseManager.activeDatabaseName(for: connection), tables: tables, columnsByTable: columnsByTable, foreignKeys: foreignKeysByTable, currentQuery: settings.includeCurrentQuery ? currentQuery : nil, queryResults: settings.includeQueryResults ? queryResults : nil, settings: settings, - identifierQuote: PluginManager.shared.sqlDialect(for: connection.type)?.identifierQuote ?? "\"", - editorLanguage: PluginManager.shared.editorLanguage(for: connection.type), - queryLanguageName: PluginManager.shared.queryLanguageName(for: connection.type), + identifierQuote: services.pluginManager.sqlDialect(for: connection.type)?.identifierQuote ?? "\"", + editorLanguage: services.pluginManager.editorLanguage(for: connection.type), + queryLanguageName: services.pluginManager.queryLanguageName(for: connection.type), connectionRules: connection.aiRules ) } @@ -163,9 +163,9 @@ extension AIChatViewModel { func renderedSchemaSection() -> String? { guard !tables.isEmpty else { return nil } - let settings = AppSettingsManager.shared.ai + let settings = services.appSettings.ai let identifierQuote = connection.flatMap { - PluginManager.shared.sqlDialect(for: $0.type)?.identifierQuote + services.pluginManager.sqlDialect(for: $0.type)?.identifierQuote } ?? "\"" let section = AISchemaContext.buildSchemaSection( tables: tables, diff --git a/TablePro/ViewModels/AIChatViewModel+SlashCommands.swift b/TablePro/ViewModels/AIChatViewModel+SlashCommands.swift index b5a38735f..60aeab06a 100644 --- a/TablePro/ViewModels/AIChatViewModel+SlashCommands.swift +++ b/TablePro/ViewModels/AIChatViewModel+SlashCommands.swift @@ -60,7 +60,7 @@ extension AIChatViewModel { let renderingContext = CustomSlashCommandRenderer.Context( query: currentQuery, schema: needsSchema ? renderedSchemaSection() : nil, - database: connection.flatMap { DatabaseManager.shared.activeDatabaseName(for: $0) }, + database: connection.flatMap { services.databaseManager.activeDatabaseName(for: $0) }, body: body ) let prompt = CustomSlashCommandRenderer.render(command, context: renderingContext) diff --git a/TablePro/ViewModels/AIChatViewModel+Streaming.swift b/TablePro/ViewModels/AIChatViewModel+Streaming.swift index 2c9642f45..5420409eb 100644 --- a/TablePro/ViewModels/AIChatViewModel+Streaming.swift +++ b/TablePro/ViewModels/AIChatViewModel+Streaming.swift @@ -32,7 +32,7 @@ extension AIChatViewModel { func startStreaming() { guard case .idle = streamingState else { return } - let settings = AppSettingsManager.shared.ai + let settings = services.appSettings.ai let resolved = AIProviderFactory.resolve( settings: settings, diff --git a/TablePro/ViewModels/AIChatViewModel+ToolApproval.swift b/TablePro/ViewModels/AIChatViewModel+ToolApproval.swift index 88ab67dde..d824cefc7 100644 --- a/TablePro/ViewModels/AIChatViewModel+ToolApproval.swift +++ b/TablePro/ViewModels/AIChatViewModel+ToolApproval.swift @@ -113,7 +113,7 @@ extension AIChatViewModel { guard !current.aiAlwaysAllowedTools.contains(toolName) else { return } current.aiAlwaysAllowedTools.insert(toolName) connection = current - ConnectionStorage.shared.updateConnection(current) + services.connectionStorage.updateConnection(current) } func dispatchCopilotInvocation( diff --git a/TablePro/ViewModels/AIChatViewModel.swift b/TablePro/ViewModels/AIChatViewModel.swift index a89a2009b..28772c893 100644 --- a/TablePro/ViewModels/AIChatViewModel.swift +++ b/TablePro/ViewModels/AIChatViewModel.swift @@ -37,7 +37,7 @@ final class AIChatViewModel { var tables: [TableInfo] { guard let id = connection?.id else { return [] } - return SchemaService.shared.tables(for: id) + return services.schemaService.tables(for: id) } var columnsByTable: [String: [ColumnInfo]] = [:] @@ -74,13 +74,15 @@ final class AIChatViewModel { @ObservationIgnored nonisolated(unsafe) var streamingTask: Task? @ObservationIgnored var prepTask: Task? - let chatStorage = AIChatStorage.shared + @ObservationIgnored let services: AppServices + var chatStorage: AIChatStorage { services.aiChatStorage } var sessionApprovedConnections: Set = [] @ObservationIgnored var cachedSavedQueries: [UUID: SQLFavorite] = [:] static let maxMessageCount = 200 - init() { + init(services: AppServices = .live) { + self.services = services loadConversations() } @@ -224,7 +226,7 @@ final class AIChatViewModel { } func loadAvailableModels() async { - let settings = AppSettingsManager.shared.ai + let settings = services.appSettings.ai let pending = settings.providers.filter { availableModels[$0.id] == nil } guard !pending.isEmpty else { return } @@ -275,7 +277,7 @@ final class AIChatViewModel { savedQueries = [] return } - let favorites = await SQLFavoriteManager.shared.fetchFavorites(connectionId: connectionId) + let favorites = await services.sqlFavoriteManager.fetchFavorites(connectionId: connectionId) savedQueries = favorites for favorite in favorites { cachedSavedQueries[favorite.id] = favorite From a774540dfbfc8ef344ee6953d51a9bf026ddd164 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 8 May 2026 16:58:32 +0700 Subject: [PATCH 4/9] refactor: migrate ERDiagramViewModel to AppServices --- TablePro/ViewModels/ERDiagramViewModel.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/TablePro/ViewModels/ERDiagramViewModel.swift b/TablePro/ViewModels/ERDiagramViewModel.swift index b1fed3fca..44610964d 100644 --- a/TablePro/ViewModels/ERDiagramViewModel.swift +++ b/TablePro/ViewModels/ERDiagramViewModel.swift @@ -69,11 +69,14 @@ final class ERDiagramViewModel { @ObservationIgnored private var columnCountByNodeId: [UUID: Int] = [:] @ObservationIgnored private var nodeIdToName: [UUID: String] = [:] + @ObservationIgnored private let services: AppServices + // MARK: - Initialization - init(connectionId: UUID, schemaKey: String) { + init(connectionId: UUID, schemaKey: String, services: AppServices = .live) { self.connectionId = connectionId self.schemaKey = schemaKey + self.services = services } deinit { @@ -87,11 +90,11 @@ final class ERDiagramViewModel { guard loadState != .loaded else { return } loadState = .loading - if DatabaseManager.shared.driver(for: connectionId) == nil { + if services.databaseManager.driver(for: connectionId) == nil { await waitForConnection() } - guard let driver = DatabaseManager.shared.driver(for: connectionId) else { + guard let driver = services.databaseManager.driver(for: connectionId) else { loadState = .failed(String(localized: "No database connection")) return } From 7b3c78c5217bb9234f236a9600e7f403ff49d284 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 8 May 2026 16:59:10 +0700 Subject: [PATCH 5/9] refactor: migrate DatabaseSwitcherViewModel to AppServices --- .../ViewModels/DatabaseSwitcherViewModel.swift | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/TablePro/ViewModels/DatabaseSwitcherViewModel.swift b/TablePro/ViewModels/DatabaseSwitcherViewModel.swift index ef76545d0..06414556d 100644 --- a/TablePro/ViewModels/DatabaseSwitcherViewModel.swift +++ b/TablePro/ViewModels/DatabaseSwitcherViewModel.swift @@ -43,6 +43,7 @@ final class DatabaseSwitcherViewModel { private let currentDatabase: String? private let currentSchema: String? private let databaseType: DatabaseType + @ObservationIgnored private let services: AppServices // MARK: - Computed Properties @@ -59,13 +60,14 @@ final class DatabaseSwitcherViewModel { init( connectionId: UUID, currentDatabase: String?, currentSchema: String?, - databaseType: DatabaseType + databaseType: DatabaseType, services: AppServices = .live ) { self.connectionId = connectionId self.currentDatabase = currentDatabase self.currentSchema = currentSchema self.databaseType = databaseType - self.mode = PluginManager.shared.supportsSchemaSwitching(for: databaseType) ? .schema : .database + self.services = services + self.mode = services.pluginManager.supportsSchemaSwitching(for: databaseType) ? .schema : .database } // MARK: - Public Methods @@ -76,7 +78,7 @@ final class DatabaseSwitcherViewModel { errorMessage = nil do { - guard let driver = DatabaseManager.shared.driver(for: connectionId) else { + guard let driver = services.databaseManager.driver(for: connectionId) else { errorMessage = String(localized: "No active connection") isLoading = false return @@ -132,14 +134,14 @@ final class DatabaseSwitcherViewModel { } func loadCreateDatabaseForm() async throws -> CreateDatabaseFormSpec? { - guard let driver = DatabaseManager.shared.driver(for: connectionId) else { + guard let driver = services.databaseManager.driver(for: connectionId) else { throw DatabaseError.notConnected } return try await driver.createDatabaseFormSpec() } func createDatabase(name: String, values: [String: String]) async throws { - guard let driver = DatabaseManager.shared.driver(for: connectionId) else { + guard let driver = services.databaseManager.driver(for: connectionId) else { throw DatabaseError.notConnected } let request = CreateDatabaseRequest(name: name, values: values) @@ -148,7 +150,7 @@ final class DatabaseSwitcherViewModel { /// Drop a database func dropDatabase(name: String) async throws { - guard let driver = DatabaseManager.shared.driver(for: connectionId) else { + guard let driver = services.databaseManager.driver(for: connectionId) else { throw DatabaseError.notConnected } @@ -192,10 +194,10 @@ final class DatabaseSwitcherViewModel { private func isSystemItem(_ name: String) -> Bool { if isSchemaMode { - let schemaNames = PluginManager.shared.systemSchemaNames(for: databaseType) + let schemaNames = services.pluginManager.systemSchemaNames(for: databaseType) return schemaNames.contains(name) } - let dbNames = PluginManager.shared.systemDatabaseNames(for: databaseType) + let dbNames = services.pluginManager.systemDatabaseNames(for: databaseType) return dbNames.contains(name) } } From be150b4371eca4acf698a5555358f61569d0295c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 8 May 2026 17:00:19 +0700 Subject: [PATCH 6/9] refactor: migrate WelcomeViewModel to AppServices --- TablePro/ViewModels/WelcomeViewModel.swift | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/TablePro/ViewModels/WelcomeViewModel.swift b/TablePro/ViewModels/WelcomeViewModel.swift index b37168a0c..ea7da8fc9 100644 --- a/TablePro/ViewModels/WelcomeViewModel.swift +++ b/TablePro/ViewModels/WelcomeViewModel.swift @@ -31,7 +31,8 @@ enum WelcomeActiveSheet: Identifiable { final class WelcomeViewModel { private static let logger = Logger(subsystem: "com.TablePro", category: "WelcomeViewModel") - private let storage = ConnectionStorage.shared + @ObservationIgnored let services: AppServices + private var storage: ConnectionStorage { services.connectionStorage } private let groupStorage = GroupStorage.shared // MARK: - State @@ -140,6 +141,12 @@ final class WelcomeViewModel { return groups.first { $0.id == groupId }?.name } + // MARK: - Initialization + + init(services: AppServices = .live) { + self.services = services + } + // MARK: - Setup & Teardown func setUp() { @@ -536,7 +543,7 @@ final class WelcomeViewModel { storage.saveConnections(connections) if !dirtyIds.isEmpty { - SyncChangeTracker.shared.markDirty(.connection, ids: dirtyIds) + services.syncTracker.markDirty(.connection, ids: dirtyIds) } rebuildTree() } @@ -571,7 +578,7 @@ final class WelcomeViewModel { storage.saveConnections(connections) if !dirtyIds.isEmpty { - SyncChangeTracker.shared.markDirty(.connection, ids: dirtyIds) + services.syncTracker.markDirty(.connection, ids: dirtyIds) } rebuildTree() } From eabe9d5ecb42d0e694bcc70eb92ac9512d1a8470 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 8 May 2026 17:00:58 +0700 Subject: [PATCH 7/9] refactor: migrate FeedbackViewModel to AppServices --- TablePro/Core/Services/AppServices.swift | 4 +++- TablePro/ViewModels/FeedbackViewModel.swift | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/TablePro/Core/Services/AppServices.swift b/TablePro/Core/Services/AppServices.swift index 1007ddd1b..7847d1b7f 100644 --- a/TablePro/Core/Services/AppServices.swift +++ b/TablePro/Core/Services/AppServices.swift @@ -18,6 +18,7 @@ struct AppServices { let aiChatStorage: AIChatStorage let syncTracker: SyncChangeTracker let themeEngine: ThemeEngine + let feedbackAPIClient: FeedbackAPIClient static let live = AppServices( appEvents: .shared, @@ -30,7 +31,8 @@ struct AppServices { sqlFavoriteManager: .shared, aiChatStorage: .shared, syncTracker: .shared, - themeEngine: .shared + themeEngine: .shared, + feedbackAPIClient: .shared ) } diff --git a/TablePro/ViewModels/FeedbackViewModel.swift b/TablePro/ViewModels/FeedbackViewModel.swift index d7b46b9d5..f246dfd8f 100644 --- a/TablePro/ViewModels/FeedbackViewModel.swift +++ b/TablePro/ViewModels/FeedbackViewModel.swift @@ -80,10 +80,12 @@ final class FeedbackViewModel { @ObservationIgnored private var draftSaveTask: Task? @ObservationIgnored private var isLoadingDraft = false @ObservationIgnored var captureTargetWindow: NSWindow? + @ObservationIgnored private let services: AppServices // MARK: - Init - init() { + init(services: AppServices = .live) { + self.services = services self.diagnostics = FeedbackDiagnosticsCollector.collect() loadDraft() } @@ -172,7 +174,7 @@ final class FeedbackViewModel { ) do { - let response = try await FeedbackAPIClient.shared.submitFeedback(request: request) + let response = try await services.feedbackAPIClient.submitFeedback(request: request) if let url = URL(string: response.issueUrl) { submissionResult = .success(issueUrl: url, issueNumber: response.issueNumber) clearDraft() From ca0d2786ec6937cc714be8c1f4b0b3d0536672cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 8 May 2026 17:02:01 +0700 Subject: [PATCH 8/9] refactor: migrate ConnectionFormCoordinator to AppServices --- .../ConnectionFormCoordinator.swift | 55 ++++++++++--------- 1 file changed, 29 insertions(+), 26 deletions(-) diff --git a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift index b1dc4abd6..801ae1124 100644 --- a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift +++ b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift @@ -53,17 +53,18 @@ final class ConnectionFormCoordinator { private var temporaryTestIds: Set = [] - let storage = ConnectionStorage.shared + @ObservationIgnored let services: AppServices + var storage: ConnectionStorage { services.connectionStorage } var dismissAction: (() -> Void)? var isNew: Bool { connectionId == nil } var visiblePanes: [ConnectionFormPane] { var panes: [ConnectionFormPane] = [.general] - if PluginManager.shared.supportsSSH(for: network.type) { + if services.pluginManager.supportsSSH(for: network.type) { panes.append(.ssh) } - if PluginManager.shared.supportsSSL(for: network.type) { + if services.pluginManager.supportsSSL(for: network.type) { panes.append(.ssl) } panes.append(.customization) @@ -87,11 +88,13 @@ final class ConnectionFormCoordinator { init( connectionId: UUID?, initialType: DatabaseType? = nil, - initialParsedURL: ParsedConnectionURL? = nil + initialParsedURL: ParsedConnectionURL? = nil, + services: AppServices = .live ) { self.connectionId = connectionId self.pendingInitialType = initialType self.pendingInitialParsedURL = initialParsedURL + self.services = services self.network = NetworkPaneViewModel() self.auth = AuthPaneViewModel() self.ssh = SSHPaneViewModel() @@ -209,7 +212,7 @@ final class ConnectionFormCoordinator { var finalPort = Int(network.port) ?? network.type.defaultPort let trimmedUsername = auth.username.trimmingCharacters(in: .whitespaces) let finalUsername = - trimmedUsername.isEmpty && PluginManager.shared.requiresAuthentication(for: network.type) + trimmedUsername.isEmpty && services.pluginManager.requiresAuthentication(for: network.type) ? "root" : trimmedUsername let finalId = connectionId ?? UUID() @@ -238,7 +241,7 @@ final class ConnectionFormCoordinator { finalAdditionalFields["promptForPassword"] = auth.promptForPassword ? "true" : nil - let secureFields = PluginManager.shared.additionalConnectionFields(for: network.type) + let secureFields = services.pluginManager.additionalConnectionFields(for: network.type) .filter(\.isSecure) for field in secureFields { if let value = finalAdditionalFields[field.id], !value.isEmpty { @@ -307,7 +310,7 @@ final class ConnectionFormCoordinator { savedConnections.append(connectionToSave) storage.saveConnections(savedConnections) if !connectionToSave.localOnly { - SyncChangeTracker.shared.markDirty(.connection, id: connectionToSave.id.uuidString) + services.syncTracker.markDirty(.connection, id: connectionToSave.id.uuidString) } dismissAction?() NotificationCenter.default.post(name: .connectionUpdated, object: nil) @@ -322,7 +325,7 @@ final class ConnectionFormCoordinator { savedConnections[index] = connectionToSave storage.saveConnections(savedConnections) if !connectionToSave.localOnly { - SyncChangeTracker.shared.markDirty(.connection, id: connectionToSave.id.uuidString) + services.syncTracker.markDirty(.connection, id: connectionToSave.id.uuidString) } dismissAction?() NotificationCenter.default.post(name: .connectionUpdated, object: nil) @@ -395,7 +398,7 @@ final class ConnectionFormCoordinator { var testPort = Int(network.port) ?? network.type.defaultPort let trimmedUsername = auth.username.trimmingCharacters(in: .whitespaces) let finalUsername = - trimmedUsername.isEmpty && PluginManager.shared.requiresAuthentication(for: network.type) + trimmedUsername.isEmpty && services.pluginManager.requiresAuthentication(for: network.type) ? "root" : trimmedUsername var finalAdditionalFields: [String: String] = [:] @@ -452,34 +455,34 @@ final class ConnectionFormCoordinator { testTask = Task { [weak self] in do { if !password.isEmpty && !promptForPassword { - ConnectionStorage.shared.savePassword(password, for: testConn.id) + services.connectionStorage.savePassword(password, for: testConn.id) } if sshState.enabled && sshState.profileId == nil { if (sshState.authMethod == .password || sshState.authMethod == .keyboardInteractive) && !sshState.password.isEmpty { - ConnectionStorage.shared.saveSSHPassword(sshState.password, for: testConn.id) + services.connectionStorage.saveSSHPassword(sshState.password, for: testConn.id) } if sshState.authMethod == .privateKey && !sshState.keyPassphrase.isEmpty { - ConnectionStorage.shared.saveKeyPassphrase(sshState.keyPassphrase, for: testConn.id) + services.connectionStorage.saveKeyPassphrase(sshState.keyPassphrase, for: testConn.id) } if sshState.totpMode == .autoGenerate && !sshState.totpSecret.isEmpty { - ConnectionStorage.shared.saveTOTPSecret(sshState.totpSecret, for: testConn.id) + services.connectionStorage.saveTOTPSecret(sshState.totpSecret, for: testConn.id) } } - for field in PluginManager.shared.additionalConnectionFields(for: connectionType) + for field in services.pluginManager.additionalConnectionFields(for: connectionType) where field.isSecure { if let value = additionalFieldValues[field.id], !value.isEmpty { - ConnectionStorage.shared.savePluginSecureField( + services.connectionStorage.savePluginSecureField( value, fieldId: field.id, for: testConn.id ) } } let sshPasswordForTest = sshState.profileId == nil ? sshState.password : nil - let isApiOnly = PluginManager.shared.connectionMode(for: connectionType) == .apiOnly + let isApiOnly = services.pluginManager.connectionMode(for: connectionType) == .apiOnly let testPwOverride: String? = promptForPassword ? (password.isEmpty ? await PasswordPromptHelper.prompt( @@ -499,7 +502,7 @@ final class ConnectionFormCoordinator { return } - let success = try await DatabaseManager.shared.testConnection( + let success = try await services.databaseManager.testConnection( testConn, sshPassword: sshPasswordForTest, passwordOverride: testPwOverride @@ -543,13 +546,13 @@ final class ConnectionFormCoordinator { } func cleanupTestSecrets(for testId: UUID) { - ConnectionStorage.shared.deletePassword(for: testId) - ConnectionStorage.shared.deleteSSHPassword(for: testId) - ConnectionStorage.shared.deleteKeyPassphrase(for: testId) - ConnectionStorage.shared.deleteTOTPSecret(for: testId) - let secureFieldIds = PluginManager.shared.additionalConnectionFields(for: network.type) + services.connectionStorage.deletePassword(for: testId) + services.connectionStorage.deleteSSHPassword(for: testId) + services.connectionStorage.deleteKeyPassphrase(for: testId) + services.connectionStorage.deleteTOTPSecret(for: testId) + let secureFieldIds = services.pluginManager.additionalConnectionFields(for: network.type) .filter(\.isSecure).map(\.id) - ConnectionStorage.shared.deleteAllPluginSecureFields(for: testId, fieldIds: secureFieldIds) + services.connectionStorage.deleteAllPluginSecureFields(for: testId, fieldIds: secureFieldIds) temporaryTestIds.remove(testId) } @@ -559,11 +562,11 @@ final class ConnectionFormCoordinator { isInstallingPlugin = true Task { [weak self] in do { - try await PluginManager.shared.installMissingPlugin(for: databaseType) { _ in } + try await services.pluginManager.installMissingPlugin(for: databaseType) { _ in } await MainActor.run { guard let self else { return } if self.network.type == databaseType { - for field in PluginManager.shared.additionalConnectionFields(for: databaseType) { + for field in services.pluginManager.additionalConnectionFields(for: databaseType) { if self.targetValues(for: field.section)[field.id] == nil, let defaultValue = field.defaultValue { @@ -708,7 +711,7 @@ final class ConnectionFormCoordinator { } private func writeFieldByRegistry(_ fieldId: String, value: String) { - let registry = PluginManager.shared.additionalConnectionFields(for: network.type) + let registry = services.pluginManager.additionalConnectionFields(for: network.type) guard let field = registry.first(where: { $0.id == fieldId }) else { advanced.additionalFieldValues[fieldId] = value return From 18719e253cb13848bde6f44dc1f8a82f64002dda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 8 May 2026 17:04:11 +0700 Subject: [PATCH 9/9] refactor: migrate MainContentCoordinator main file to AppServices --- .../Views/Main/MainContentCoordinator.swift | 53 ++++++++++--------- 1 file changed, 28 insertions(+), 25 deletions(-) diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index a3a4d25cb..ce3c531f1 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -79,10 +79,11 @@ final class MainContentCoordinator { // MARK: - Dependencies + @ObservationIgnored let services: AppServices let connection: DatabaseConnection var connectionId: UUID { connection.id } var activeDatabaseName: String { - DatabaseManager.shared.activeDatabaseName(for: connection) + services.databaseManager.activeDatabaseName(for: connection) } var safeModeLevel: SafeModeLevel { toolbarState.safeModeLevel } let selectionState = GridSelectionState() @@ -336,9 +337,11 @@ final class MainContentCoordinator { changeManager: DataChangeManager, toolbarState: ConnectionToolbarState, tabSessionRegistry: TabSessionRegistry? = nil, - queryExecutor: QueryExecutor? = nil + queryExecutor: QueryExecutor? = nil, + services: AppServices = .live ) { let initStart = Date() + self.services = services self.connection = connection self.tabManager = tabManager self.changeManager = changeManager @@ -347,7 +350,7 @@ final class MainContentCoordinator { self.tabSessionRegistry = resolvedRegistry tabManager.bindTabSessionRegistry(resolvedRegistry) self.queryExecutor = queryExecutor ?? QueryExecutor(connection: connection) - let dialect = PluginManager.shared.sqlDialect(for: connection.type) + let dialect = services.pluginManager.sqlDialect(for: connection.type) self.queryBuilder = TableQueryBuilder( databaseType: connection.type, dialect: dialect, @@ -439,14 +442,14 @@ final class MainContentCoordinator { /// Start watching the database file for external changes (SQLite, DuckDB). private func startFileWatcherIfNeeded() { - guard PluginManager.shared.connectionMode(for: connection.type) == .fileBased else { return } + guard services.pluginManager.connectionMode(for: connection.type) == .fileBased else { return } let filePath = connection.database guard !filePath.isEmpty else { return } let watcher = DatabaseFileWatcher() watcher.watch(filePath: filePath, connectionId: connectionId) { [weak self] in guard let self else { return } - if case .loading = SchemaService.shared.state(for: self.connectionId) { return } + if case .loading = services.schemaService.state(for: self.connectionId) { return } Task { await self.refreshTables() } } fileWatcher = watcher @@ -455,8 +458,8 @@ final class MainContentCoordinator { /// Refresh schema only if not recently refreshed (avoids redundant work /// when both the file watcher and window focus trigger close together). func refreshTablesIfStale() async { - guard let driver = DatabaseManager.shared.driver(for: connectionId) else { return } - await SchemaService.shared.reloadIfStale( + guard let driver = services.databaseManager.driver(for: connectionId) else { return } + await services.schemaService.reloadIfStale( connectionId: connectionId, driver: driver, connection: connection, @@ -472,7 +475,7 @@ final class MainContentCoordinator { /// Set up the plugin driver for query building dispatch on the query builder and change manager. private func setupPluginDriver() { - guard let driver = DatabaseManager.shared.driver(for: connectionId) else { return } + guard let driver = services.databaseManager.driver(for: connectionId) else { return } let pluginDriver = driver.queryBuildingPluginDriver queryBuilder.setPluginDriver(pluginDriver) changeManager.pluginDriver = pluginDriver @@ -492,8 +495,8 @@ final class MainContentCoordinator { } func refreshTables() async { - guard let driver = DatabaseManager.shared.driver(for: connectionId) else { return } - await SchemaService.shared.reload( + guard let driver = services.databaseManager.driver(for: connectionId) else { return } + await services.schemaService.reload( connectionId: connectionId, driver: driver, connection: connection @@ -504,10 +507,10 @@ final class MainContentCoordinator { /// Push the SchemaService table list into the autocomplete provider and prune sidebar /// state for tables that no longer exist. private func reconcilePostSchemaLoad() async { - guard case .loaded(let tables) = SchemaService.shared.state(for: connectionId) else { return } - if let driver = DatabaseManager.shared.driver(for: connectionId), + guard case .loaded(let tables) = services.schemaService.state(for: connectionId) else { return } + if let driver = services.databaseManager.driver(for: connectionId), let provider = SchemaProviderRegistry.shared.provider(for: connectionId) { - let currentDb = DatabaseManager.shared.session(for: connectionId)?.activeDatabase + let currentDb = services.databaseManager.session(for: connectionId)?.activeDatabase await provider.resetForDatabase(currentDb, tables: tables, driver: driver) } @@ -637,12 +640,12 @@ final class MainContentCoordinator { func initializeToolbar() { toolbarState.update(from: connection) - if let session = DatabaseManager.shared.session(for: connectionId) { + if let session = services.databaseManager.session(for: connectionId) { toolbarState.connectionState = mapSessionStatus(session.status) if let driver = session.driver { toolbarState.databaseVersion = driver.serverVersion } - } else if let driver = DatabaseManager.shared.driver(for: connectionId) { + } else if let driver = services.databaseManager.driver(for: connectionId) { toolbarState.connectionState = .connected toolbarState.databaseVersion = driver.serverVersion } @@ -672,8 +675,8 @@ final class MainContentCoordinator { // MARK: - Schema Loading func loadSchema() async { - guard let driver = DatabaseManager.shared.driver(for: connectionId) else { return } - await SchemaService.shared.load( + guard let driver = services.databaseManager.driver(for: connectionId) else { return } + await services.schemaService.load( connectionId: connectionId, driver: driver, connection: connection @@ -682,7 +685,7 @@ final class MainContentCoordinator { } func loadTableMetadata(tableName: String) async { - guard let driver = DatabaseManager.shared.driver(for: connectionId) else { return } + guard let driver = services.databaseManager.driver(for: connectionId) else { return } do { let metadata = try await driver.fetchTableMetadata(tableName: tableName) @@ -724,7 +727,7 @@ final class MainContentCoordinator { return } - if AppSettingsManager.shared.editor.queryParametersEnabled { + if services.appSettings.editor.queryParametersEnabled { let paramStatements = SQLStatementScanner.allStatements(in: sql) guard !paramStatements.isEmpty else { return } let combinedSQL = paramStatements.joined(separator: "; ") @@ -902,7 +905,7 @@ final class MainContentCoordinator { return } - guard let adapter = DatabaseManager.shared.driver(for: connectionId) as? PluginDriverAdapter, + guard let adapter = services.databaseManager.driver(for: connectionId) as? PluginDriverAdapter, let explainSQL = adapter.buildExplainQuery(stmt) else { if let (_, index) = tabManager.selectedTabAndIndex { tabManager.tabs[index].execution.errorMessage = String(localized: "EXPLAIN is not supported for this database type.") @@ -941,7 +944,7 @@ final class MainContentCoordinator { if currentQueryTask != nil { currentQueryTask?.cancel() do { - try DatabaseManager.shared.driver(for: connectionId)?.cancelQuery() + try services.databaseManager.driver(for: connectionId)?.cancelQuery() } catch { Self.logger.warning("cancelQuery failed: \(error.localizedDescription, privacy: .public)") } @@ -959,7 +962,7 @@ final class MainContentCoordinator { tabManager.tabs[index] = tab toolbarState.setExecuting(true) - if PluginManager.shared.supportsQueryProgress(for: connection.type) { + if services.pluginManager.supportsQueryProgress(for: connection.type) { installClickHouseProgressHandler() } @@ -999,7 +1002,7 @@ final class MainContentCoordinator { await MainActor.run { [weak self] in guard let self else { return } currentQueryTask = nil - if PluginManager.shared.supportsQueryProgress(for: self.connection.type) { + if services.pluginManager.supportsQueryProgress(for: self.connection.type) { self.clearClickHouseProgress() } toolbarState.setExecuting(false) @@ -1085,8 +1088,8 @@ final class MainContentCoordinator { } internal func resolveTableEditability(tab: QueryTab, sql: String) -> (tableName: String?, isEditable: Bool) { - let usesNoSQLBrowsing = PluginManager.shared.editorLanguage(for: connection.type) != .sql - || (DatabaseManager.shared.driver(for: connectionId) as? PluginDriverAdapter)? + let usesNoSQLBrowsing = services.pluginManager.editorLanguage(for: connection.type) != .sql + || (services.databaseManager.driver(for: connectionId) as? PluginDriverAdapter)? .queryBuildingPluginDriver != nil if usesNoSQLBrowsing { let name = tabManager.selectedTab?.tableContext.tableName