diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d769fdfe..898c83693 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,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) - Opening a `.sql` file now names the tab after the file instead of showing "SQL Query". (#1220) ## [0.45.0] - 2026-05-26 diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index e76603182..4812863a6 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -20,6 +20,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { func applicationWillFinishLaunching(_ notification: Notification) { _ = InspectorDocumentController() + guard ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil else { return } + PluginManager.shared.loadPlugins() } func application(_ application: NSApplication, open urls: [URL]) { @@ -62,7 +64,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..2fd0c65a8 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) } + self.dismissWelcomeIfMainWindowVisible() } } } @@ -108,12 +109,18 @@ internal final class AppLaunchCoordinator { for intent in intents { await LaunchIntentRouter.shared.route(intent) } + 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) + } +}