From e45320e26e027d93c0b1de44ff6fbae27ccf61b5 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: Thu, 28 May 2026 12:03:09 +0700 Subject: [PATCH 1/2] fix(launch): open files passed at cold launch instead of the welcome screen (#1443) --- CHANGELOG.md | 1 + TablePro/AppDelegate.swift | 2 +- .../Core/Services/Infrastructure/AppLaunchCoordinator.swift | 4 ++++ 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09b2f6533..e75fe3c7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - iOS: running a query that returns a very large result no longer crashes the app. The query editor keeps the first rows it loads, stops before memory runs low, and tells you to add LIMIT to fetch more. - iOS: Safe Mode "Confirm Writes" now prompts before saving a row edit or inserting a row, matching the query editor. Previously grid edits and inserts saved with no confirmation. - Redshift: schema switching now works, along with the contains, starts with, and ends with filters and table search. All previously failed with a SQL syntax error. (#1439) +- Double-clicking a CSV or TSV file when TablePro is closed now opens the file directly, instead of showing the welcome screen. (#1443) ## [0.45.0] - 2026-05-26 diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index e76603182..4c3fa6e28 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -20,6 +20,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { func applicationWillFinishLaunching(_ notification: Notification) { _ = InspectorDocumentController() + PluginManager.shared.loadPlugins() } func application(_ application: NSApplication, open urls: [URL]) { @@ -62,7 +63,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { Task { await CloudflareTunnelManager.shared.sweepStalePidsIfNeeded() } MemoryPressureAdvisor.startMonitoring() - PluginManager.shared.loadPlugins() UNUserNotificationCenter.current().delegate = self PluginNotificationService.shared.setUp() ChatToolBootstrap.register() diff --git a/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift b/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift index 66b9a601c..b87a5390f 100644 --- a/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift +++ b/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift @@ -93,6 +93,7 @@ internal final class AppLaunchCoordinator { for intent in intents { await LaunchIntentRouter.shared.route(intent) } + WindowOpener.shared.orderOutWelcome() } } } @@ -108,6 +109,9 @@ internal final class AppLaunchCoordinator { for intent in intents { await LaunchIntentRouter.shared.route(intent) } + if !intents.isEmpty { + WindowOpener.shared.orderOutWelcome() + } self.runStartupBehaviorIfNeeded(skipping: intents) self.phase = .ready self.finalizeWindowsIfNoVisibleMain(intents: intents) From aa2728e7892c56c562f9a9c08234165ea6734a56 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: Thu, 28 May 2026 12:17:33 +0700 Subject: [PATCH 2/2] refactor(launch): dismiss welcome only after inspector window is visible (#1443) --- TablePro/AppDelegate.swift | 1 + .../Infrastructure/AppLaunchCoordinator.swift | 11 ++-- .../Infrastructure/LaunchIntentRouter.swift | 28 +++++----- .../Infrastructure/URLClassifierTests.swift | 52 +++++++++++++++++++ 4 files changed, 73 insertions(+), 19 deletions(-) create mode 100644 TableProTests/Core/Services/Infrastructure/URLClassifierTests.swift diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index 4c3fa6e28..4812863a6 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -20,6 +20,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { func applicationWillFinishLaunching(_ notification: Notification) { _ = InspectorDocumentController() + guard ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil else { return } PluginManager.shared.loadPlugins() } diff --git a/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift b/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift index b87a5390f..2fd0c65a8 100644 --- a/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift +++ b/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift @@ -93,7 +93,7 @@ internal final class AppLaunchCoordinator { for intent in intents { await LaunchIntentRouter.shared.route(intent) } - WindowOpener.shared.orderOutWelcome() + self.dismissWelcomeIfMainWindowVisible() } } } @@ -109,15 +109,18 @@ internal final class AppLaunchCoordinator { for intent in intents { await LaunchIntentRouter.shared.route(intent) } - if !intents.isEmpty { - WindowOpener.shared.orderOutWelcome() - } + self.dismissWelcomeIfMainWindowVisible() self.runStartupBehaviorIfNeeded(skipping: intents) self.phase = .ready self.finalizeWindowsIfNoVisibleMain(intents: intents) } } + private func dismissWelcomeIfMainWindowVisible() { + guard NSApp.windows.contains(where: { Self.isMainWindow($0) && $0.isVisible }) else { return } + WindowOpener.shared.orderOutWelcome() + } + private func runStartupBehaviorIfNeeded(skipping intents: [LaunchIntent]) { guard intents.isEmpty else { return } diff --git a/TablePro/Core/Services/Infrastructure/LaunchIntentRouter.swift b/TablePro/Core/Services/Infrastructure/LaunchIntentRouter.swift index 189eea214..0a0d099f7 100644 --- a/TablePro/Core/Services/Infrastructure/LaunchIntentRouter.swift +++ b/TablePro/Core/Services/Infrastructure/LaunchIntentRouter.swift @@ -28,7 +28,7 @@ internal final class LaunchIntentRouter { case .openInspectorFile(let url): Self.logger.debug("LaunchIntentRouter.route(.openInspectorFile(\(url.lastPathComponent, privacy: .public)))") - openInspectorDocument(at: url) + try await openInspectorDocument(at: url) case .importConnection(let exportable): WelcomeRouter.shared.routeImport(exportable) @@ -57,21 +57,19 @@ internal final class LaunchIntentRouter { } } - private func openInspectorDocument(at url: URL) { + private func openInspectorDocument(at url: URL) async throws { Self.logger.debug("LaunchIntentRouter.openInspectorDocument - calling NSDocumentController.shared (\(String(describing: Swift.type(of: NSDocumentController.shared)), privacy: .public)).openDocument for \(url.lastPathComponent, privacy: .public)") - NSDocumentController.shared.openDocument(withContentsOf: url, display: true) { document, alreadyOpen, error in - Self.logger.debug("LaunchIntentRouter.openInspectorDocument completion - document=\(document == nil ? "nil" : "present", privacy: .public) alreadyOpen=\(alreadyOpen, privacy: .public) error=\(error?.localizedDescription ?? "nil", privacy: .public)") - if let error { - Self.logger.error("Failed to open inspector document at \(url.lastPathComponent, privacy: .public): \(error.localizedDescription, privacy: .public)") - AlertHelper.showErrorSheet( - title: String(localized: "Could Not Open File"), - message: error.localizedDescription, - window: NSApp.keyWindow - ) - return - } - if document == nil { - Self.logger.warning("NSDocumentController returned no document for \(url.lastPathComponent, privacy: .public)") + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + NSDocumentController.shared.openDocument(withContentsOf: url, display: true) { document, alreadyOpen, error in + Self.logger.debug("LaunchIntentRouter.openInspectorDocument completion - document=\(document == nil ? "nil" : "present", privacy: .public) alreadyOpen=\(alreadyOpen, privacy: .public) error=\(error?.localizedDescription ?? "nil", privacy: .public)") + if let error { + continuation.resume(throwing: error) + return + } + if document == nil { + Self.logger.warning("NSDocumentController returned no document for \(url.lastPathComponent, privacy: .public)") + } + continuation.resume() } } } diff --git a/TableProTests/Core/Services/Infrastructure/URLClassifierTests.swift b/TableProTests/Core/Services/Infrastructure/URLClassifierTests.swift new file mode 100644 index 000000000..24f0a0b18 --- /dev/null +++ b/TableProTests/Core/Services/Infrastructure/URLClassifierTests.swift @@ -0,0 +1,52 @@ +// +// URLClassifierTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("URLClassifier file extension routing", .serialized) +@MainActor +struct URLClassifierTests { + private func withInspectorState( + lazy: [String: URL], + active: [String: any DocumentInspectorPlugin] = [:], + body: () throws -> T + ) rethrows -> T { + let originalLazy = PluginManager.shared.lazyInspectorFileExtensions + let originalActive = PluginManager.shared.inspectorPlugins + defer { + PluginManager.shared.lazyInspectorFileExtensions = originalLazy + PluginManager.shared.inspectorPlugins = originalActive + } + PluginManager.shared.lazyInspectorFileExtensions = lazy + PluginManager.shared.inspectorPlugins = active + return try body() + } + + @Test("CSV routes to openInspectorFile when the extension is registered") + func routesCSVWhenExtensionRegistered() { + let csvURL = URL(fileURLWithPath: "/tmp/sample.csv") + let stubPluginURL = URL(fileURLWithPath: "/tmp/stub.tableplugin") + let intent = withInspectorState(lazy: ["csv": stubPluginURL]) { + URLClassifier.classify(csvURL) + } + guard case .some(.success(.openInspectorFile(let routed))) = intent else { + Issue.record("Expected .openInspectorFile, got \(String(describing: intent))") + return + } + #expect(routed == csvURL) + } + + @Test("CSV returns nil when no inspector plugin registers the extension") + func returnsNilWhenExtensionMissing() { + let csvURL = URL(fileURLWithPath: "/tmp/sample.csv") + let intent = withInspectorState(lazy: [:]) { + URLClassifier.classify(csvURL) + } + #expect(intent == nil) + } +}