Skip to content

Commit c79a136

Browse files
committed
fix: add cross-window deduplication and restore SSH error tests
1 parent 52713dd commit c79a136

5 files changed

Lines changed: 167 additions & 0 deletions

File tree

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/Views/Main/MainContentCommandActions.swift

Lines changed: 5 additions & 0 deletions
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)

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

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
//
2+
// SSHTunnelErrorTests.swift
3+
// TableProTests
4+
//
5+
// Tests for SSHTunnelError descriptions and isLocalPortBindFailure classification.
6+
//
7+
8+
import Foundation
9+
@testable import TablePro
10+
import Testing
11+
12+
@Suite("SSHTunnelError")
13+
struct SSHTunnelErrorTests {
14+
// MARK: - Port Bind Failure Classification
15+
16+
@Test("isLocalPortBindFailure detects 'already in use' pattern")
17+
func bindFailureAlreadyInUse() {
18+
#expect(SSHTunnelManager.isLocalPortBindFailure("Address already in use"))
19+
}
20+
21+
@Test("isLocalPortBindFailure is case-insensitive")
22+
func bindFailureCaseInsensitive() {
23+
#expect(SSHTunnelManager.isLocalPortBindFailure("ADDRESS ALREADY IN USE"))
24+
}
25+
26+
@Test("isLocalPortBindFailure returns false for unrelated SSH errors")
27+
func nonBindFailures() {
28+
#expect(!SSHTunnelManager.isLocalPortBindFailure("Permission denied"))
29+
#expect(!SSHTunnelManager.isLocalPortBindFailure("Connection refused"))
30+
#expect(!SSHTunnelManager.isLocalPortBindFailure("Host key verification failed"))
31+
#expect(!SSHTunnelManager.isLocalPortBindFailure(""))
32+
}
33+
34+
// MARK: - Error Descriptions
35+
36+
@Test("SSHTunnelError.noAvailablePort has a localized description")
37+
func noAvailablePortDescription() {
38+
let error = SSHTunnelError.noAvailablePort
39+
#expect(error.errorDescription != nil)
40+
#expect(error.errorDescription?.isEmpty == false)
41+
}
42+
43+
@Test("SSHTunnelError.authenticationFailed has a localized description")
44+
func authenticationFailedDescription() {
45+
let error = SSHTunnelError.authenticationFailed
46+
#expect(error.errorDescription != nil)
47+
}
48+
49+
@Test("SSHTunnelError.tunnelAlreadyExists includes connection ID in description")
50+
func tunnelAlreadyExistsDescription() {
51+
let id = UUID()
52+
let error = SSHTunnelError.tunnelAlreadyExists(id)
53+
#expect(error.errorDescription?.contains(id.uuidString) == true)
54+
}
55+
56+
@Test("SSHTunnelError.connectionTimeout has a localized description")
57+
func connectionTimeoutDescription() {
58+
let error = SSHTunnelError.connectionTimeout
59+
#expect(error.errorDescription != nil)
60+
}
61+
}

TableProTests/Models/SQLFileDeduplicationTests.swift

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
// and deduplication logic in QueryTabManager.
88
//
99

10+
import AppKit
1011
import Foundation
1112
@testable import TablePro
1213
import Testing
@@ -190,3 +191,74 @@ struct PersistedTabSourceFileURLTests {
190191
#expect(decoded.sourceFileURL == nil)
191192
}
192193
}
194+
195+
// MARK: - WindowLifecycleMonitor Source File Tracking Tests
196+
197+
@Suite("WindowLifecycleMonitor source file tracking")
198+
@MainActor
199+
struct WindowLifecycleMonitorSourceFileTests {
200+
@Test("window(forSourceFile:) returns nil for unregistered URL")
201+
func unregisteredURLReturnsNil() {
202+
let url = URL(fileURLWithPath: "/tmp/unknown.sql")
203+
#expect(WindowLifecycleMonitor.shared.window(forSourceFile: url) == nil)
204+
}
205+
206+
@Test("registerSourceFile and window(forSourceFile:) round-trip when window is alive")
207+
func registerAndFindSourceFile() {
208+
let url = URL(fileURLWithPath: "/tmp/registered.sql")
209+
let windowId = UUID()
210+
let window = NSWindow()
211+
212+
WindowLifecycleMonitor.shared.register(
213+
window: window,
214+
connectionId: UUID(),
215+
windowId: windowId
216+
)
217+
WindowLifecycleMonitor.shared.registerSourceFile(url, windowId: windowId)
218+
219+
#expect(WindowLifecycleMonitor.shared.window(forSourceFile: url) === window)
220+
221+
WindowLifecycleMonitor.shared.unregisterSourceFile(url)
222+
WindowLifecycleMonitor.shared.unregisterWindow(for: windowId)
223+
}
224+
225+
@Test("unregisterSourceFiles(for:) removes all files for a window")
226+
func unregisterAllFilesForWindow() {
227+
let url1 = URL(fileURLWithPath: "/tmp/file1.sql")
228+
let url2 = URL(fileURLWithPath: "/tmp/file2.sql")
229+
let windowId = UUID()
230+
let window = NSWindow()
231+
232+
WindowLifecycleMonitor.shared.register(
233+
window: window,
234+
connectionId: UUID(),
235+
windowId: windowId
236+
)
237+
WindowLifecycleMonitor.shared.registerSourceFile(url1, windowId: windowId)
238+
WindowLifecycleMonitor.shared.registerSourceFile(url2, windowId: windowId)
239+
240+
WindowLifecycleMonitor.shared.unregisterSourceFiles(for: windowId)
241+
242+
#expect(WindowLifecycleMonitor.shared.window(forSourceFile: url1) == nil)
243+
#expect(WindowLifecycleMonitor.shared.window(forSourceFile: url2) == nil)
244+
245+
WindowLifecycleMonitor.shared.unregisterWindow(for: windowId)
246+
}
247+
248+
@Test("window(forSourceFile:) returns nil after window is unregistered")
249+
func returnsNilAfterWindowUnregistered() {
250+
let url = URL(fileURLWithPath: "/tmp/closed.sql")
251+
let windowId = UUID()
252+
let window = NSWindow()
253+
254+
WindowLifecycleMonitor.shared.register(
255+
window: window,
256+
connectionId: UUID(),
257+
windowId: windowId
258+
)
259+
WindowLifecycleMonitor.shared.registerSourceFile(url, windowId: windowId)
260+
WindowLifecycleMonitor.shared.unregisterWindow(for: windowId)
261+
262+
#expect(WindowLifecycleMonitor.shared.window(forSourceFile: url) == nil)
263+
}
264+
}

0 commit comments

Comments
 (0)