From 4fcc78a41e35eafa3aac043e9ff9185a2656846b 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: Tue, 31 Mar 2026 16:58:03 +0700 Subject: [PATCH] refactor: replace GCD dispatch patterns with Swift structured concurrency --- CHANGELOG.md | 4 ++ TablePro/AppDelegate+WindowConfig.swift | 39 +++++++++---------- .../Formatting/SQLFormatterService.swift | 7 +--- .../Services/Query/SQLDialectProvider.swift | 5 ++- .../Views/AIChat/AIChatCodeBlockView.swift | 3 +- .../Views/Components/SQLReviewPopover.swift | 3 +- .../Components/SyncStatusIndicator.swift | 3 +- .../ConnectionExportOptionsSheet.swift | 3 +- .../Views/Connection/WelcomeWindowView.swift | 3 +- TablePro/Views/Editor/EditorEventRouter.swift | 2 +- TablePro/Views/Editor/HistoryPanelView.swift | 3 +- .../Views/Editor/SQLEditorCoordinator.swift | 28 ++++++------- TablePro/Views/Filter/SQLPreviewSheet.swift | 3 +- .../Main/Child/MainEditorContentView.swift | 8 ++-- TablePro/Views/Main/MainContentView.swift | 2 +- .../Views/Results/DataGridCoordinator.swift | 2 +- 16 files changed, 59 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 166356a4a..c60a3fd6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Collapsible results panel (`Cmd+Opt+R`), multiple result tabs for multi-statement queries, result pinning - Inline error banner for query errors +### Changed + +- Replace GCD dispatch patterns with Swift structured concurrency + ### Fixed - SQL Server: Unicode characters (Thai, CJK, etc.) in nvarchar/nchar/ntext columns displaying as question marks diff --git a/TablePro/AppDelegate+WindowConfig.swift b/TablePro/AppDelegate+WindowConfig.swift index 6a0729f1b..ad1a83515 100644 --- a/TablePro/AppDelegate+WindowConfig.swift +++ b/TablePro/AppDelegate+WindowConfig.swift @@ -326,34 +326,31 @@ extension AppDelegate { isAutoReconnecting = true - DispatchQueue.main.async { [weak self] in + Task { @MainActor [weak self] in guard let self else { return } WindowOpener.shared.pendingConnectionId = connection.id NotificationCenter.default.post(name: .openMainWindow, object: connection.id) - Task { @MainActor in - defer { self.isAutoReconnecting = false } - do { - try await DatabaseManager.shared.connectToSession(connection) - - for window in NSApp.windows where self.isWelcomeWindow(window) { - window.close() - } - } catch is CancellationError { - // User cancelled password prompt at startup — return to welcome - for window in NSApp.windows where self.isMainWindow(window) { - window.close() - } - self.openWelcomeWindow() - } catch { - windowLogger.error("Auto-reconnect failed for '\(connection.name)': \(error.localizedDescription)") + defer { self.isAutoReconnecting = false } + do { + try await DatabaseManager.shared.connectToSession(connection) - for window in NSApp.windows where self.isMainWindow(window) { - window.close() - } + for window in NSApp.windows where self.isWelcomeWindow(window) { + window.close() + } + } catch is CancellationError { + for window in NSApp.windows where self.isMainWindow(window) { + window.close() + } + self.openWelcomeWindow() + } catch { + windowLogger.error("Auto-reconnect failed for '\(connection.name)': \(error.localizedDescription)") - self.openWelcomeWindow() + for window in NSApp.windows where self.isMainWindow(window) { + window.close() } + + self.openWelcomeWindow() } } } diff --git a/TablePro/Core/Services/Formatting/SQLFormatterService.swift b/TablePro/Core/Services/Formatting/SQLFormatterService.swift index d6d03f711..d6d31fc6b 100644 --- a/TablePro/Core/Services/Formatting/SQLFormatterService.swift +++ b/TablePro/Core/Services/Formatting/SQLFormatterService.swift @@ -133,12 +133,7 @@ struct SQLFormatterService: SQLFormatterProtocol { } private static func resolveDialectProvider(for dialect: DatabaseType) -> SQLDialectProvider { - if Thread.isMainThread { - return MainActor.assumeIsolated { SQLDialectFactory.createDialect(for: dialect) } - } - return DispatchQueue.main.sync { - MainActor.assumeIsolated { SQLDialectFactory.createDialect(for: dialect) } - } + SQLDialectFactory.createDialect(for: dialect) } // MARK: - Public API diff --git a/TablePro/Core/Services/Query/SQLDialectProvider.swift b/TablePro/Core/Services/Query/SQLDialectProvider.swift index e64ef7b40..38a1f2602 100644 --- a/TablePro/Core/Services/Query/SQLDialectProvider.swift +++ b/TablePro/Core/Services/Query/SQLDialectProvider.swift @@ -36,9 +36,10 @@ private struct EmptyDialect: SQLDialectProvider { // MARK: - Dialect Factory struct SQLDialectFactory { - @MainActor static func createDialect(for databaseType: DatabaseType) -> SQLDialectProvider { - if let descriptor = PluginManager.shared.sqlDialect(for: databaseType) { + if let descriptor = PluginMetadataRegistry.shared.snapshot( + forTypeId: databaseType.pluginTypeId + )?.editor.sqlDialect { return PluginDialectAdapter(descriptor: descriptor) } return EmptyDialect() diff --git a/TablePro/Views/AIChat/AIChatCodeBlockView.swift b/TablePro/Views/AIChat/AIChatCodeBlockView.swift index e759526f9..565a2233f 100644 --- a/TablePro/Views/AIChat/AIChatCodeBlockView.swift +++ b/TablePro/Views/AIChat/AIChatCodeBlockView.swift @@ -43,7 +43,8 @@ struct AIChatCodeBlockView: View { Button { ClipboardService.shared.writeText(code) isCopied = true - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + Task { @MainActor in + try? await Task.sleep(for: .seconds(1.5)) isCopied = false } } label: { diff --git a/TablePro/Views/Components/SQLReviewPopover.swift b/TablePro/Views/Components/SQLReviewPopover.swift index 8673b6d06..a66cded27 100644 --- a/TablePro/Views/Components/SQLReviewPopover.swift +++ b/TablePro/Views/Components/SQLReviewPopover.swift @@ -200,7 +200,8 @@ struct SQLReviewPopover: View { ClipboardService.shared.writeText(joined) copied = true - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + Task { @MainActor in + try? await Task.sleep(for: .seconds(1.5)) copied = false } } diff --git a/TablePro/Views/Components/SyncStatusIndicator.swift b/TablePro/Views/Components/SyncStatusIndicator.swift index bfaf257e9..0960186c1 100644 --- a/TablePro/Views/Components/SyncStatusIndicator.swift +++ b/TablePro/Views/Components/SyncStatusIndicator.swift @@ -120,7 +120,8 @@ struct SyncStatusIndicator: View { showActivationSheet = true default: UserDefaults.standard.set(SettingsTab.sync.rawValue, forKey: "selectedSettingsTab") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(100)) NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) } } diff --git a/TablePro/Views/Connection/ConnectionExportOptionsSheet.swift b/TablePro/Views/Connection/ConnectionExportOptionsSheet.swift index 2d3d82270..fb9b00427 100644 --- a/TablePro/Views/Connection/ConnectionExportOptionsSheet.swift +++ b/TablePro/Views/Connection/ConnectionExportOptionsSheet.swift @@ -95,7 +95,8 @@ struct ConnectionExportOptionsSheet: View { confirmPassphrase = "" dismiss() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(200)) let panel = NSSavePanel() panel.allowedContentTypes = [.tableproConnectionShare] let defaultName = capturedConnections.count == 1 diff --git a/TablePro/Views/Connection/WelcomeWindowView.swift b/TablePro/Views/Connection/WelcomeWindowView.swift index 81579f61e..0752bb528 100644 --- a/TablePro/Views/Connection/WelcomeWindowView.swift +++ b/TablePro/Views/Connection/WelcomeWindowView.swift @@ -78,7 +78,8 @@ struct WelcomeWindowView: View { LicenseActivationSheet() case .importFile(let url): ConnectionImportSheet(fileURL: url) { count in - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(300)) vm.showImportResultAlert(count: count) } } diff --git a/TablePro/Views/Editor/EditorEventRouter.swift b/TablePro/Views/Editor/EditorEventRouter.swift index 1e7f6439a..502df82e5 100644 --- a/TablePro/Views/Editor/EditorEventRouter.swift +++ b/TablePro/Views/Editor/EditorEventRouter.swift @@ -39,7 +39,7 @@ internal final class EditorEventRouter { if textView.window != nil { installWindowObserver(for: key) } else { - DispatchQueue.main.async { [weak self] in + Task { [weak self] in guard let self, self.editors[key]?.windowObserver == nil else { return } self.installWindowObserver(for: key) } diff --git a/TablePro/Views/Editor/HistoryPanelView.swift b/TablePro/Views/Editor/HistoryPanelView.swift index bb3cefd13..166f0b5b1 100644 --- a/TablePro/Views/Editor/HistoryPanelView.swift +++ b/TablePro/Views/Editor/HistoryPanelView.swift @@ -353,7 +353,8 @@ private extension HistoryPanelView { deleteEntry(entry) // After deletion triggers reload, select adjacent entry - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(50)) if let idx = currentIndex, !entries.isEmpty { let newIndex = min(idx, entries.count - 1) if newIndex >= 0, newIndex < entries.count { diff --git a/TablePro/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index 997c12aa4..a3b78f658 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -29,7 +29,7 @@ final class SQLEditorCoordinator: TextViewCoordinator { @ObservationIgnored private var windowKeyObserver: NSObjectProtocol? /// Debounce work item for frame-change notification to avoid /// triggering syntax highlight viewport recalculation on every keystroke. - @ObservationIgnored private var frameChangeWorkItem: DispatchWorkItem? + @ObservationIgnored private var frameChangeTask: Task? @ObservationIgnored private var wasEditorFocused = false @ObservationIgnored private var didDestroy = false @@ -66,7 +66,7 @@ final class SQLEditorCoordinator: TextViewCoordinator { if let observer = windowKeyObserver { NotificationCenter.default.removeObserver(observer) } - frameChangeWorkItem?.cancel() + frameChangeTask?.cancel() } private func cleanupMonitors() { @@ -78,8 +78,8 @@ final class SQLEditorCoordinator: TextViewCoordinator { NotificationCenter.default.removeObserver(observer) windowKeyObserver = nil } - frameChangeWorkItem?.cancel() - frameChangeWorkItem = nil + frameChangeTask?.cancel() + frameChangeTask = nil } // MARK: - TextViewCoordinator @@ -89,7 +89,7 @@ final class SQLEditorCoordinator: TextViewCoordinator { // Deferred to next run loop because prepareCoordinator runs during // TextViewController.init, before the view hierarchy is fully loaded. - DispatchQueue.main.async { [weak self] in + Task { [weak self] in guard let self else { return } self.fixFindPanelHitTesting(controller: controller) self.installAIContextMenu(controller: controller) @@ -123,7 +123,7 @@ final class SQLEditorCoordinator: TextViewCoordinator { vimEngine?.invalidateLineCache() // Notify inline suggestion manager immediately (lightweight) - DispatchQueue.main.async { [weak self] in + Task { [weak self] in self?.inlineSuggestionManager?.handleTextChange() self?.vimCursorManager?.updatePosition() } @@ -131,16 +131,12 @@ final class SQLEditorCoordinator: TextViewCoordinator { // Throttle frame-change notification — during rapid typing, only the // last notification matters. The highlighter recalculates the visible // range on each notification, so coalescing saves redundant layout work. - frameChangeWorkItem?.cancel() - let workItem = DispatchWorkItem { [weak controller] in - guard let controller, let textView = controller.textView else { return } - NotificationCenter.default.post( - name: NSView.frameDidChangeNotification, - object: textView - ) + frameChangeTask?.cancel() + frameChangeTask = Task { [weak controller] in + try? await Task.sleep(for: .milliseconds(50)) + guard !Task.isCancelled, let controller, let textView = controller.textView else { return } + NotificationCenter.default.post(name: NSView.frameDidChangeNotification, object: textView) } - frameChangeWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: workItem) } func textViewDidChangeSelection(controller: TextViewController, newPositions: [CursorPosition]) { @@ -155,7 +151,7 @@ final class SQLEditorCoordinator: TextViewCoordinator { guard let range = newPositions.first?.range, range.location != NSNotFound else { return } // Defer to next run loop to let EmphasisManager finish its work first. - DispatchQueue.main.async { [weak controller] in + Task { [weak controller] in controller?.textView.scrollToRange(range) } } diff --git a/TablePro/Views/Filter/SQLPreviewSheet.swift b/TablePro/Views/Filter/SQLPreviewSheet.swift index 08f41b7a8..e296ebec1 100644 --- a/TablePro/Views/Filter/SQLPreviewSheet.swift +++ b/TablePro/Views/Filter/SQLPreviewSheet.swift @@ -82,7 +82,8 @@ struct SQLPreviewSheet: View { AccessibilityNotification.Announcement(String(localized: "Copied to clipboard")).post() // Reset after delay - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + Task { @MainActor in + try? await Task.sleep(for: .seconds(1.5)) copied = false } } diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 51a3079f1..61a024aa6 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -265,7 +265,7 @@ struct MainEditorContentView: View { // Update window dirty indicator and toolbar for file-backed tabs if tabManager.tabs[index].sourceFileURL != nil { let isDirty = tabManager.tabs[index].isFileDirty - DispatchQueue.main.async { + Task { @MainActor in if let window = NSApp.keyWindow { window.isDocumentEdited = isDirty } @@ -456,7 +456,7 @@ struct MainEditorContentView: View { private func rowProvider(for tab: QueryTab) -> InMemoryRowProvider { if tab.rowBuffer.isEvicted { - DispatchQueue.main.async { tabProviderCache.removeValue(forKey: tab.id) } + Task { @MainActor in tabProviderCache.removeValue(forKey: tab.id) } return makeRowProvider(for: tab) } if let entry = tabProviderCache[tab.id], @@ -467,7 +467,7 @@ struct MainEditorContentView: View { return entry.provider } let provider = makeRowProvider(for: tab) - DispatchQueue.main.async { + Task { @MainActor in tabProviderCache[tab.id] = RowProviderCacheEntry( provider: provider, resultVersion: tab.resultVersion, @@ -620,7 +620,7 @@ struct MainEditorContentView: View { if let index = tabManager.selectedTabIndex { tabManager.tabs[index].columnLayout = newValue } - DispatchQueue.main.async { + Task { @MainActor in coordinator.isUpdatingColumnLayout = false coordinator.saveColumnLayoutForTable() } diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index f4983dee1..0e8196737 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -310,7 +310,7 @@ struct MainContentView: View { isKeyWindow = true evictionTask?.cancel() evictionTask = nil - DispatchQueue.main.async { + Task { @MainActor in syncSidebarToCurrentTab() } // Lazy-load: execute query for restored tabs that skipped auto-execute, diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index c98464e71..920610f77 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -156,7 +156,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData ) { [weak self] _ in guard let self else { return } - DispatchQueue.main.async { [weak self] in + Task { @MainActor [weak self] in guard let self, let tableView = self.tableView else { return } let settings = AppSettingsManager.shared.dataGrid let prev = self.lastDataGridSettings