Skip to content

Commit 5e119ca

Browse files
authored
fix: deduplicate tabs when opening .sql files from Finder (#417) (#421)
* fix: deduplicate tabs when opening .sql files from Finder (#417) * fix: repair broken test target compilation * fix: add cross-window deduplication and restore SSH error tests
1 parent 937321c commit 5e119ca

19 files changed

Lines changed: 466 additions & 1091 deletions

TablePro.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3012,6 +3012,7 @@
30123012
CURRENT_PROJECT_VERSION = 1;
30133013
DEVELOPMENT_TEAM = "";
30143014
GENERATE_INFOPLIST_FILE = YES;
3015+
HEADER_SEARCH_PATHS = "$(SRCROOT)/TablePro/Core/SSH/CLibSSH2/include";
30153016
MACOSX_DEPLOYMENT_TARGET = 26.2;
30163017
MARKETING_VERSION = 1.0;
30173018
PRODUCT_BUNDLE_IDENTIFIER = com.ngoquocdat.TableProTests;
@@ -3020,6 +3021,7 @@
30203021
STRING_CATALOG_GENERATE_SYMBOLS = NO;
30213022
SWIFT_APPROACHABLE_CONCURRENCY = YES;
30223023
SWIFT_EMIT_LOC_STRINGS = NO;
3024+
SWIFT_INCLUDE_PATHS = "$(SRCROOT)/TablePro/Core/SSH/CLibSSH2";
30233025
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
30243026
SWIFT_VERSION = 5.0;
30253027
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TablePro.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/TablePro";
@@ -3034,6 +3036,7 @@
30343036
CURRENT_PROJECT_VERSION = 1;
30353037
DEVELOPMENT_TEAM = "";
30363038
GENERATE_INFOPLIST_FILE = YES;
3039+
HEADER_SEARCH_PATHS = "$(SRCROOT)/TablePro/Core/SSH/CLibSSH2/include";
30373040
MACOSX_DEPLOYMENT_TARGET = 26.2;
30383041
MARKETING_VERSION = 1.0;
30393042
PRODUCT_BUNDLE_IDENTIFIER = com.ngoquocdat.TableProTests;
@@ -3042,6 +3045,7 @@
30423045
STRING_CATALOG_GENERATE_SYMBOLS = NO;
30433046
SWIFT_APPROACHABLE_CONCURRENCY = YES;
30443047
SWIFT_EMIT_LOC_STRINGS = NO;
3048+
SWIFT_INCLUDE_PATHS = "$(SRCROOT)/TablePro/Core/SSH/CLibSSH2";
30453049
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
30463050
SWIFT_VERSION = 5.0;
30473051
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TablePro.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/TablePro";

TablePro/Core/Services/Infrastructure/SessionStateFactory.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,8 @@ enum SessionStateFactory {
8383
case .query:
8484
tabMgr.addTab(
8585
initialQuery: payload.initialQuery,
86-
databaseName: payload.databaseName ?? connection.database
86+
databaseName: payload.databaseName ?? connection.database,
87+
sourceFileURL: payload.sourceFileURL
8788
)
8889
}
8990
}

TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ internal final class WindowLifecycleMonitor {
2222
}
2323

2424
private var entries: [UUID: Entry] = [:]
25+
private var sourceFileWindows: [URL: UUID] = [:]
2526

2627
private init() {}
2728

@@ -66,6 +67,7 @@ internal final class WindowLifecycleMonitor {
6667

6768
/// Remove the UUID mapping for a window.
6869
internal func unregisterWindow(for windowId: UUID) {
70+
unregisterSourceFiles(for: windowId)
6971
guard let entry = entries.removeValue(forKey: windowId) else { return }
7072

7173
if let observer = entry.observer {
@@ -147,6 +149,29 @@ internal final class WindowLifecycleMonitor {
147149
entries[windowId]?.isPreview = isPreview
148150
}
149151

152+
// MARK: - Source File Tracking
153+
154+
internal func registerSourceFile(_ url: URL, windowId: UUID) {
155+
sourceFileWindows[url] = windowId
156+
}
157+
158+
internal func unregisterSourceFile(_ url: URL) {
159+
sourceFileWindows.removeValue(forKey: url)
160+
}
161+
162+
internal func unregisterSourceFiles(for windowId: UUID) {
163+
sourceFileWindows = sourceFileWindows.filter { $0.value != windowId }
164+
}
165+
166+
internal func window(forSourceFile url: URL) -> NSWindow? {
167+
guard let windowId = sourceFileWindows[url] else { return nil }
168+
guard let window = entries[windowId]?.window else {
169+
sourceFileWindows.removeValue(forKey: url)
170+
return nil
171+
}
172+
return window
173+
}
174+
150175
// MARK: - Private
151176

152177
/// Remove entries whose window has already been deallocated.
@@ -172,6 +197,7 @@ internal final class WindowLifecycleMonitor {
172197
if let observer = entry.observer {
173198
NotificationCenter.default.removeObserver(observer)
174199
}
200+
unregisterSourceFiles(for: windowId)
175201
entries.removeValue(forKey: windowId)
176202

177203
let hasRemainingWindows = entries.values.contains {

TablePro/Models/Query/EditorTabPayload.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ internal struct EditorTabPayload: Codable, Hashable {
3333
internal let isPreview: Bool
3434
/// Initial filter state (for FK navigation — pre-applies a WHERE filter)
3535
internal let initialFilterState: TabFilterState?
36+
/// Source file URL for .sql files opened from disk (used for deduplication)
37+
internal let sourceFileURL: URL?
3638

3739
internal init(
3840
id: UUID = UUID(),
@@ -45,7 +47,8 @@ internal struct EditorTabPayload: Codable, Hashable {
4547
showStructure: Bool = false,
4648
skipAutoExecute: Bool = false,
4749
isPreview: Bool = false,
48-
initialFilterState: TabFilterState? = nil
50+
initialFilterState: TabFilterState? = nil,
51+
sourceFileURL: URL? = nil
4952
) {
5053
self.id = id
5154
self.connectionId = connectionId
@@ -58,6 +61,7 @@ internal struct EditorTabPayload: Codable, Hashable {
5861
self.skipAutoExecute = skipAutoExecute
5962
self.isPreview = isPreview
6063
self.initialFilterState = initialFilterState
64+
self.sourceFileURL = sourceFileURL
6165
}
6266

6367
internal init(from decoder: Decoder) throws {
@@ -73,6 +77,7 @@ internal struct EditorTabPayload: Codable, Hashable {
7377
skipAutoExecute = try container.decodeIfPresent(Bool.self, forKey: .skipAutoExecute) ?? false
7478
isPreview = try container.decodeIfPresent(Bool.self, forKey: .isPreview) ?? false
7579
initialFilterState = try container.decodeIfPresent(TabFilterState.self, forKey: .initialFilterState)
80+
sourceFileURL = try container.decodeIfPresent(URL.self, forKey: .sourceFileURL)
7681
}
7782

7883
/// Whether this payload is a "connection-only" payload — just a connectionId
@@ -95,5 +100,6 @@ internal struct EditorTabPayload: Codable, Hashable {
95100
self.skipAutoExecute = skipAutoExecute
96101
self.isPreview = false
97102
self.initialFilterState = nil
103+
self.sourceFileURL = tab.sourceFileURL
98104
}
99105
}

TablePro/Models/Query/QueryTab.swift

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ struct PersistedTab: Codable {
2323
let tabType: TabType
2424
let tableName: String?
2525
var isView: Bool = false
26-
var databaseName: String = "" // Database context for this tab (for multi-database restore)
26+
var databaseName: String = ""
27+
var sourceFileURL: URL?
2728
}
2829

2930
/// Stores pending changes for a tab (used to preserve state when switching tabs)
@@ -358,6 +359,9 @@ struct QueryTab: Identifiable, Equatable {
358359
// Whether this tab is a preview (temporary) tab that gets replaced on next navigation
359360
var isPreview: Bool
360361

362+
// Source file URL for .sql files opened from disk (used for deduplication)
363+
var sourceFileURL: URL?
364+
361365
// Version counter incremented when resultRows changes (used for sort caching)
362366
var resultVersion: Int
363367

@@ -395,6 +399,7 @@ struct QueryTab: Identifiable, Equatable {
395399
self.filterState = TabFilterState()
396400
self.columnLayout = ColumnLayoutState()
397401
self.isPreview = false
402+
self.sourceFileURL = nil
398403
self.resultVersion = 0
399404
self.metadataVersion = 0
400405
}
@@ -427,6 +432,7 @@ struct QueryTab: Identifiable, Equatable {
427432
self.filterState = TabFilterState()
428433
self.columnLayout = ColumnLayoutState()
429434
self.isPreview = false
435+
self.sourceFileURL = persisted.sourceFileURL
430436
self.resultVersion = 0
431437
self.metadataVersion = 0
432438
}
@@ -488,7 +494,8 @@ struct QueryTab: Identifiable, Equatable {
488494
tabType: tabType,
489495
tableName: tableName,
490496
isView: isView,
491-
databaseName: databaseName
497+
databaseName: databaseName,
498+
sourceFileURL: sourceFileURL
492499
)
493500
}
494501

@@ -537,18 +544,27 @@ final class QueryTabManager {
537544

538545
// MARK: - Tab Management
539546

540-
func addTab(initialQuery: String? = nil, title: String? = nil, databaseName: String = "") {
547+
func addTab(initialQuery: String? = nil, title: String? = nil, databaseName: String = "", sourceFileURL: URL? = nil) {
548+
if let sourceFileURL,
549+
let existingIndex = tabs.firstIndex(where: { $0.sourceFileURL == sourceFileURL }) {
550+
if let query = initialQuery {
551+
tabs[existingIndex].query = query
552+
}
553+
selectedTabId = tabs[existingIndex].id
554+
return
555+
}
556+
541557
let queryCount = tabs.count(where: { $0.tabType == .query })
542558
let tabTitle = title ?? "Query \(queryCount + 1)"
543559
var newTab = QueryTab(title: tabTitle, tabType: .query)
544560

545-
// If initialQuery provided, use it; otherwise tab starts empty
546561
if let query = initialQuery {
547562
newTab.query = query
548-
newTab.hasUserInteraction = true // Mark as having content
563+
newTab.hasUserInteraction = true
549564
}
550565

551566
newTab.databaseName = databaseName
567+
newTab.sourceFileURL = sourceFileURL
552568
tabs.append(newTab)
553569
selectedTabId = newTab.id
554570
}

TablePro/Views/Main/MainContentCommandActions.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -574,6 +574,11 @@ final class MainContentCommandActions {
574574

575575
Task { @MainActor in
576576
for url in urls {
577+
if let existingWindow = WindowLifecycleMonitor.shared.window(forSourceFile: url) {
578+
existingWindow.makeKeyAndOrderFront(nil)
579+
continue
580+
}
581+
577582
let content = await Task.detached(priority: .userInitiated) { () -> String? in
578583
do {
579584
return try String(contentsOf: url, encoding: .utf8)
@@ -587,7 +592,8 @@ final class MainContentCommandActions {
587592
let payload = EditorTabPayload(
588593
connectionId: connection.id,
589594
tabType: .query,
590-
initialQuery: content
595+
initialQuery: content,
596+
sourceFileURL: url
591597
)
592598
WindowOpener.shared.openNativeTab(payload)
593599
}

TablePro/Views/Main/MainContentView.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,9 @@ struct MainContentView: View {
511511
coordinator.needsLazyLoad = true
512512
}
513513
}
514+
if let sourceURL = payload.sourceFileURL {
515+
WindowLifecycleMonitor.shared.registerSourceFile(sourceURL, windowId: windowId)
516+
}
514517
return
515518
}
516519

TableProTests/Core/Plugins/PluginModelsTests.swift

Lines changed: 39 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -8,52 +8,61 @@ import TableProPluginKit
88
import Testing
99
@testable import TablePro
1010

11-
@Suite("PluginEntry Computed Properties — Fallback Behavior")
12-
struct PluginEntryFallbackTests {
11+
@Suite("PluginEntry Computed Properties")
12+
struct PluginEntryTests {
1313

14-
private func makeNonPluginEntry() -> PluginEntry {
14+
private func makeEntry(
15+
databaseTypeId: String? = nil,
16+
additionalTypeIds: [String] = [],
17+
pluginIconName: String = "puzzlepiece",
18+
defaultPort: Int? = nil
19+
) -> PluginEntry {
1520
PluginEntry(
16-
id: "test.non-plugin",
21+
id: "test.plugin",
1722
bundle: Bundle.main,
1823
url: Bundle.main.bundleURL,
1924
source: .builtIn,
20-
name: "Non-Plugin Bundle",
25+
name: "Test Plugin",
2126
version: "1.0.0",
22-
pluginDescription: "A bundle whose principalClass is not a DriverPlugin",
27+
pluginDescription: "A test plugin",
2328
capabilities: [.databaseDriver],
24-
isEnabled: true
29+
isEnabled: true,
30+
databaseTypeId: databaseTypeId,
31+
additionalTypeIds: additionalTypeIds,
32+
pluginIconName: pluginIconName,
33+
defaultPort: defaultPort
2534
)
2635
}
2736

28-
@Test("driverPlugin returns nil for a non-plugin bundle")
29-
func driverPluginReturnsNil() {
30-
let entry = makeNonPluginEntry()
31-
#expect(entry.driverPlugin == nil)
32-
}
33-
34-
@Test("iconName falls back to puzzlepiece when driverPlugin is nil")
35-
func iconNameFallback() {
36-
let entry = makeNonPluginEntry()
37-
#expect(entry.iconName == "puzzlepiece")
38-
}
39-
40-
@Test("databaseTypeId returns nil when driverPlugin is nil")
37+
@Test("databaseTypeId returns nil when not set")
4138
func databaseTypeIdNil() {
42-
let entry = makeNonPluginEntry()
39+
let entry = makeEntry()
4340
#expect(entry.databaseTypeId == nil)
4441
}
4542

46-
@Test("additionalTypeIds returns empty array when driverPlugin is nil")
43+
@Test("databaseTypeId returns value when set")
44+
func databaseTypeIdSet() {
45+
let entry = makeEntry(databaseTypeId: "MySQL")
46+
#expect(entry.databaseTypeId == "MySQL")
47+
}
48+
49+
@Test("additionalTypeIds returns empty array by default")
4750
func additionalTypeIdsEmpty() {
48-
let entry = makeNonPluginEntry()
51+
let entry = makeEntry()
4952
#expect(entry.additionalTypeIds.isEmpty)
5053
}
5154

52-
@Test("defaultPort returns nil when driverPlugin is nil")
55+
@Test("defaultPort returns nil when not set")
5356
func defaultPortNil() {
54-
let entry = makeNonPluginEntry()
57+
let entry = makeEntry()
5558
#expect(entry.defaultPort == nil)
5659
}
60+
61+
@Test("pluginIconName returns provided value")
62+
func pluginIconName() {
63+
let entry = makeEntry(pluginIconName: "mysql-icon")
64+
#expect(entry.pluginIconName == "mysql-icon")
65+
}
5766
}
5867

5968
@Suite("PluginSource Enum")
@@ -82,7 +91,11 @@ struct PluginEntryIdentityTests {
8291
version: "0.1.0",
8392
pluginDescription: "",
8493
capabilities: [],
85-
isEnabled: false
94+
isEnabled: false,
95+
databaseTypeId: nil,
96+
additionalTypeIds: [],
97+
pluginIconName: "puzzlepiece",
98+
defaultPort: nil
8699
)
87100
#expect(entry.id == "com.example.test-plugin")
88101
}

0 commit comments

Comments
 (0)