Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions TablePro.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -3012,6 +3012,7 @@
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
HEADER_SEARCH_PATHS = "$(SRCROOT)/TablePro/Core/SSH/CLibSSH2/include";
MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.ngoquocdat.TableProTests;
Expand All @@ -3020,6 +3021,7 @@
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_INCLUDE_PATHS = "$(SRCROOT)/TablePro/Core/SSH/CLibSSH2";
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TablePro.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/TablePro";
Expand All @@ -3034,6 +3036,7 @@
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
HEADER_SEARCH_PATHS = "$(SRCROOT)/TablePro/Core/SSH/CLibSSH2/include";
MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.ngoquocdat.TableProTests;
Expand All @@ -3042,6 +3045,7 @@
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_INCLUDE_PATHS = "$(SRCROOT)/TablePro/Core/SSH/CLibSSH2";
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TablePro.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/TablePro";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ enum SessionStateFactory {
case .query:
tabMgr.addTab(
initialQuery: payload.initialQuery,
databaseName: payload.databaseName ?? connection.database
databaseName: payload.databaseName ?? connection.database,
sourceFileURL: payload.sourceFileURL
)
}
}
Expand Down
26 changes: 26 additions & 0 deletions TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ internal final class WindowLifecycleMonitor {
}

private var entries: [UUID: Entry] = [:]
private var sourceFileWindows: [URL: UUID] = [:]

private init() {}

Expand Down Expand Up @@ -66,6 +67,7 @@ internal final class WindowLifecycleMonitor {

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

if let observer = entry.observer {
Expand Down Expand Up @@ -147,6 +149,29 @@ internal final class WindowLifecycleMonitor {
entries[windowId]?.isPreview = isPreview
}

// MARK: - Source File Tracking

internal func registerSourceFile(_ url: URL, windowId: UUID) {
sourceFileWindows[url] = windowId
}

internal func unregisterSourceFile(_ url: URL) {
sourceFileWindows.removeValue(forKey: url)
}

internal func unregisterSourceFiles(for windowId: UUID) {
sourceFileWindows = sourceFileWindows.filter { $0.value != windowId }
}

internal func window(forSourceFile url: URL) -> NSWindow? {
guard let windowId = sourceFileWindows[url] else { return nil }
guard let window = entries[windowId]?.window else {
sourceFileWindows.removeValue(forKey: url)
return nil
}
return window
}

// MARK: - Private

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

let hasRemainingWindows = entries.values.contains {
Expand Down
8 changes: 7 additions & 1 deletion TablePro/Models/Query/EditorTabPayload.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ internal struct EditorTabPayload: Codable, Hashable {
internal let isPreview: Bool
/// Initial filter state (for FK navigation — pre-applies a WHERE filter)
internal let initialFilterState: TabFilterState?
/// Source file URL for .sql files opened from disk (used for deduplication)
internal let sourceFileURL: URL?

internal init(
id: UUID = UUID(),
Expand All @@ -45,7 +47,8 @@ internal struct EditorTabPayload: Codable, Hashable {
showStructure: Bool = false,
skipAutoExecute: Bool = false,
isPreview: Bool = false,
initialFilterState: TabFilterState? = nil
initialFilterState: TabFilterState? = nil,
sourceFileURL: URL? = nil
) {
self.id = id
self.connectionId = connectionId
Expand All @@ -58,6 +61,7 @@ internal struct EditorTabPayload: Codable, Hashable {
self.skipAutoExecute = skipAutoExecute
self.isPreview = isPreview
self.initialFilterState = initialFilterState
self.sourceFileURL = sourceFileURL
}

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

/// Whether this payload is a "connection-only" payload — just a connectionId
Expand All @@ -95,5 +100,6 @@ internal struct EditorTabPayload: Codable, Hashable {
self.skipAutoExecute = skipAutoExecute
self.isPreview = false
self.initialFilterState = nil
self.sourceFileURL = tab.sourceFileURL
}
}
26 changes: 21 additions & 5 deletions TablePro/Models/Query/QueryTab.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ struct PersistedTab: Codable {
let tabType: TabType
let tableName: String?
var isView: Bool = false
var databaseName: String = "" // Database context for this tab (for multi-database restore)
var databaseName: String = ""
var sourceFileURL: URL?
}

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

// Source file URL for .sql files opened from disk (used for deduplication)
var sourceFileURL: URL?

// Version counter incremented when resultRows changes (used for sort caching)
var resultVersion: Int

Expand Down Expand Up @@ -395,6 +399,7 @@ struct QueryTab: Identifiable, Equatable {
self.filterState = TabFilterState()
self.columnLayout = ColumnLayoutState()
self.isPreview = false
self.sourceFileURL = nil
self.resultVersion = 0
self.metadataVersion = 0
}
Expand Down Expand Up @@ -427,6 +432,7 @@ struct QueryTab: Identifiable, Equatable {
self.filterState = TabFilterState()
self.columnLayout = ColumnLayoutState()
self.isPreview = false
self.sourceFileURL = persisted.sourceFileURL
self.resultVersion = 0
self.metadataVersion = 0
}
Expand Down Expand Up @@ -488,7 +494,8 @@ struct QueryTab: Identifiable, Equatable {
tabType: tabType,
tableName: tableName,
isView: isView,
databaseName: databaseName
databaseName: databaseName,
sourceFileURL: sourceFileURL
)
}

Expand Down Expand Up @@ -537,18 +544,27 @@ final class QueryTabManager {

// MARK: - Tab Management

func addTab(initialQuery: String? = nil, title: String? = nil, databaseName: String = "") {
func addTab(initialQuery: String? = nil, title: String? = nil, databaseName: String = "", sourceFileURL: URL? = nil) {
if let sourceFileURL,
let existingIndex = tabs.firstIndex(where: { $0.sourceFileURL == sourceFileURL }) {
if let query = initialQuery {
tabs[existingIndex].query = query
}
selectedTabId = tabs[existingIndex].id
return
}

let queryCount = tabs.count(where: { $0.tabType == .query })
let tabTitle = title ?? "Query \(queryCount + 1)"
var newTab = QueryTab(title: tabTitle, tabType: .query)

// If initialQuery provided, use it; otherwise tab starts empty
if let query = initialQuery {
newTab.query = query
newTab.hasUserInteraction = true // Mark as having content
newTab.hasUserInteraction = true
}

newTab.databaseName = databaseName
newTab.sourceFileURL = sourceFileURL
tabs.append(newTab)
selectedTabId = newTab.id
}
Expand Down
8 changes: 7 additions & 1 deletion TablePro/Views/Main/MainContentCommandActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,11 @@ final class MainContentCommandActions {

Task { @MainActor in
for url in urls {
if let existingWindow = WindowLifecycleMonitor.shared.window(forSourceFile: url) {
existingWindow.makeKeyAndOrderFront(nil)
continue
}

let content = await Task.detached(priority: .userInitiated) { () -> String? in
do {
return try String(contentsOf: url, encoding: .utf8)
Expand All @@ -587,7 +592,8 @@ final class MainContentCommandActions {
let payload = EditorTabPayload(
connectionId: connection.id,
tabType: .query,
initialQuery: content
initialQuery: content,
sourceFileURL: url
)
WindowOpener.shared.openNativeTab(payload)
}
Expand Down
3 changes: 3 additions & 0 deletions TablePro/Views/Main/MainContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,9 @@ struct MainContentView: View {
coordinator.needsLazyLoad = true
}
}
if let sourceURL = payload.sourceFileURL {
WindowLifecycleMonitor.shared.registerSourceFile(sourceURL, windowId: windowId)
}
return
}

Expand Down
65 changes: 39 additions & 26 deletions TableProTests/Core/Plugins/PluginModelsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,52 +8,61 @@ import TableProPluginKit
import Testing
@testable import TablePro

@Suite("PluginEntry Computed Properties — Fallback Behavior")
struct PluginEntryFallbackTests {
@Suite("PluginEntry Computed Properties")
struct PluginEntryTests {

private func makeNonPluginEntry() -> PluginEntry {
private func makeEntry(
databaseTypeId: String? = nil,
additionalTypeIds: [String] = [],
pluginIconName: String = "puzzlepiece",
defaultPort: Int? = nil
) -> PluginEntry {
PluginEntry(
id: "test.non-plugin",
id: "test.plugin",
bundle: Bundle.main,
url: Bundle.main.bundleURL,
source: .builtIn,
name: "Non-Plugin Bundle",
name: "Test Plugin",
version: "1.0.0",
pluginDescription: "A bundle whose principalClass is not a DriverPlugin",
pluginDescription: "A test plugin",
capabilities: [.databaseDriver],
isEnabled: true
isEnabled: true,
databaseTypeId: databaseTypeId,
additionalTypeIds: additionalTypeIds,
pluginIconName: pluginIconName,
defaultPort: defaultPort
)
}

@Test("driverPlugin returns nil for a non-plugin bundle")
func driverPluginReturnsNil() {
let entry = makeNonPluginEntry()
#expect(entry.driverPlugin == nil)
}

@Test("iconName falls back to puzzlepiece when driverPlugin is nil")
func iconNameFallback() {
let entry = makeNonPluginEntry()
#expect(entry.iconName == "puzzlepiece")
}

@Test("databaseTypeId returns nil when driverPlugin is nil")
@Test("databaseTypeId returns nil when not set")
func databaseTypeIdNil() {
let entry = makeNonPluginEntry()
let entry = makeEntry()
#expect(entry.databaseTypeId == nil)
}

@Test("additionalTypeIds returns empty array when driverPlugin is nil")
@Test("databaseTypeId returns value when set")
func databaseTypeIdSet() {
let entry = makeEntry(databaseTypeId: "MySQL")
#expect(entry.databaseTypeId == "MySQL")
}

@Test("additionalTypeIds returns empty array by default")
func additionalTypeIdsEmpty() {
let entry = makeNonPluginEntry()
let entry = makeEntry()
#expect(entry.additionalTypeIds.isEmpty)
}

@Test("defaultPort returns nil when driverPlugin is nil")
@Test("defaultPort returns nil when not set")
func defaultPortNil() {
let entry = makeNonPluginEntry()
let entry = makeEntry()
#expect(entry.defaultPort == nil)
}

@Test("pluginIconName returns provided value")
func pluginIconName() {
let entry = makeEntry(pluginIconName: "mysql-icon")
#expect(entry.pluginIconName == "mysql-icon")
}
}

@Suite("PluginSource Enum")
Expand Down Expand Up @@ -82,7 +91,11 @@ struct PluginEntryIdentityTests {
version: "0.1.0",
pluginDescription: "",
capabilities: [],
isEnabled: false
isEnabled: false,
databaseTypeId: nil,
additionalTypeIds: [],
pluginIconName: "puzzlepiece",
defaultPort: nil
)
#expect(entry.id == "com.example.test-plugin")
}
Expand Down
Loading
Loading