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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Preview tabs: single-click opens a temporary preview tab, double-click or editing promotes it to a permanent tab
- Import plugin system: SQL import extracted into a `.tableplugin` bundle, matching the export plugin architecture
- `ImportFormatPlugin` protocol in TableProPluginKit for building custom import format plugins
- SQLImportPlugin as the first import format plugin (SQL files and .gz compressed SQL)
Expand Down
24 changes: 12 additions & 12 deletions TablePro.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1735,9 +1735,9 @@
DEVELOPMENT_TEAM = D7HJ5TFYCU;
DYLIB_COMPATIBILITY_VERSION = 1;
DYLIB_CURRENT_VERSION = 1;
DYLIB_INSTALL_NAME_BASE = "@rpath";
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "";
DYLIB_INSTALL_NAME_BASE = "@rpath";
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 14.0;
Expand All @@ -1761,9 +1761,9 @@
DEVELOPMENT_TEAM = D7HJ5TFYCU;
DYLIB_COMPATIBILITY_VERSION = 1;
DYLIB_CURRENT_VERSION = 1;
DYLIB_INSTALL_NAME_BASE = "@rpath";
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "";
DYLIB_INSTALL_NAME_BASE = "@rpath";
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 14.0;
Expand Down Expand Up @@ -2475,51 +2475,51 @@
};
name = Debug;
};
5A86F000600000000 /* Debug */ = {
5A86E000700000000 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = D7HJ5TFYCU;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Plugins/SQLImportPlugin/Info.plist;
INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).SQLImportPlugin";
INFOPLIST_FILE = Plugins/MQLExportPlugin/Info.plist;
INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).MQLExportPlugin";
LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.SQLImportPlugin;
PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.MQLExportPlugin;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = macosx;
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = macosx;
SWIFT_VERSION = 5.9;
WRAPPER_EXTENSION = tableplugin;
};
name = Debug;
name = Release;
};
5A86E000700000000 /* Release */ = {
5A86F000600000000 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = D7HJ5TFYCU;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Plugins/MQLExportPlugin/Info.plist;
INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).MQLExportPlugin";
INFOPLIST_FILE = Plugins/SQLImportPlugin/Info.plist;
INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).SQLImportPlugin";
LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.MQLExportPlugin;
PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.SQLImportPlugin;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = macosx;
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = macosx;
SWIFT_VERSION = 5.9;
WRAPPER_EXTENSION = tableplugin;
};
name = Release;
name = Debug;
};
5A86F000700000000 /* Release */ = {
isa = XCBuildConfiguration;
Expand Down
1 change: 1 addition & 0 deletions TablePro/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
for window in NSApp.windows where isMainWindow(window) {
let hasActiveSession = DatabaseManager.shared.activeSessions.values.contains {
window.subtitle == $0.connection.name
|| window.subtitle == "\($0.connection.name) — Preview"
}
if !hasActiveSession {
window.close()
Expand Down
24 changes: 23 additions & 1 deletion TablePro/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,11 @@ struct ContentView: View {
// for the brief window before registration completes.
let isOurWindow = WindowLifecycleMonitor.shared.windows(for: connectionId)
.contains(where: { $0 === notificationWindow })
|| notificationWindow.subtitle == currentSession?.connection.name
|| {
guard let name = currentSession?.connection.name, !name.isEmpty else { return false }
return notificationWindow.subtitle == name
|| notificationWindow.subtitle == "\(name) — Preview"
}()
guard isOurWindow else { return }

if let session = DatabaseManager.shared.activeSessions[connectionId] {
Expand Down Expand Up @@ -219,6 +223,24 @@ struct ContentView: View {
onShowAllTables: {
showAllTablesMetadata()
},
onDoubleClick: { table in
let isView = table.type == .view
if let preview = WindowLifecycleMonitor.shared.previewWindow(for: currentSession.connection.id),
let previewCoordinator = MainContentCoordinator.coordinator(for: preview.windowId) {
// If the preview tab shows this table, promote it
if previewCoordinator.tabManager.selectedTab?.tableName == table.name {
previewCoordinator.promotePreviewTab()
} else {
// Preview shows a different table — promote it first, then open this table permanently
previewCoordinator.promotePreviewTab()
sessionState.coordinator.openTableTab(table.name, isView: isView)
}
} else {
// No preview tab — promote current if it's a preview, otherwise open permanently
sessionState.coordinator.promotePreviewTab()
sessionState.coordinator.openTableTab(table.name, isView: isView)
}
},
pendingTruncates: sessionPendingTruncatesBinding,
pendingDeletes: sessionPendingDeletesBinding,
tableOperationOptions: sessionTableOperationOptionsBinding,
Expand Down
18 changes: 13 additions & 5 deletions TablePro/Core/Services/Infrastructure/SessionStateFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,19 @@ enum SessionStateFactory {
switch payload.tabType {
case .table:
if let tableName = payload.tableName {
tabMgr.addTableTab(
tableName: tableName,
databaseType: connection.type,
databaseName: payload.databaseName ?? connection.database
)
if payload.isPreview {
tabMgr.addPreviewTableTab(
tableName: tableName,
databaseType: connection.type,
databaseName: payload.databaseName ?? connection.database
)
} else {
tabMgr.addTableTab(
tableName: tableName,
databaseType: connection.type,
databaseName: payload.databaseName ?? connection.database
)
}
if let index = tabMgr.selectedTabIndex {
tabMgr.tabs[index].isView = payload.isView
tabMgr.tabs[index].isEditable = !payload.isView
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,18 @@ internal final class TabPersistenceCoordinator {
/// Save tab state to disk. Called explicitly at named business events
/// (tab switch, window close, quit, etc.).
internal func saveNow(tabs: [QueryTab], selectedTabId: UUID?) {
let persisted = tabs.map { convertToPersistedTab($0) }
let nonPreviewTabs = tabs.filter { !$0.isPreview }
guard !nonPreviewTabs.isEmpty else {
clearSavedState()
return
}
let persisted = nonPreviewTabs.map { convertToPersistedTab($0) }
let connId = connectionId
let selectedId = selectedTabId
let normalizedSelectedId = nonPreviewTabs.contains(where: { $0.id == selectedTabId })
? selectedTabId : nonPreviewTabs.first?.id

Task {
await TabDiskActor.shared.save(connectionId: connId, tabs: persisted, selectedTabId: selectedId)
await TabDiskActor.shared.save(connectionId: connId, tabs: persisted, selectedTabId: normalizedSelectedId)
}
}

Expand All @@ -60,8 +66,15 @@ internal final class TabPersistenceCoordinator {
/// Synchronous save for `applicationWillTerminate` where no run loop
/// remains to service async Tasks. Bypasses the actor and writes directly.
internal func saveNowSync(tabs: [QueryTab], selectedTabId: UUID?) {
let persisted = tabs.map { convertToPersistedTab($0) }
TabDiskActor.saveSync(connectionId: connectionId, tabs: persisted, selectedTabId: selectedTabId)
let nonPreviewTabs = tabs.filter { !$0.isPreview }
guard !nonPreviewTabs.isEmpty else {
TabDiskActor.saveSync(connectionId: connectionId, tabs: [], selectedTabId: nil)
return
}
let persisted = nonPreviewTabs.map { convertToPersistedTab($0) }
let normalizedSelectedId = nonPreviewTabs.contains(where: { $0.id == selectedTabId })
? selectedTabId : nonPreviewTabs.first?.id
TabDiskActor.saveSync(connectionId: connectionId, tabs: persisted, selectedTabId: normalizedSelectedId)
}

// MARK: - Clear
Expand Down
22 changes: 20 additions & 2 deletions TablePro/Core/Services/Infrastructure/WindowLifecycleMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ internal final class WindowLifecycleMonitor {
let connectionId: UUID
let window: NSWindow
var observer: NSObjectProtocol?
var isPreview: Bool = false
}

private var entries: [UUID: Entry] = [:]
Expand All @@ -36,7 +37,7 @@ internal final class WindowLifecycleMonitor {
// MARK: - Registration

/// Register a window and start observing its willCloseNotification.
internal func register(window: NSWindow, connectionId: UUID, windowId: UUID) {
internal func register(window: NSWindow, connectionId: UUID, windowId: UUID, isPreview: Bool = false) {
// Remove any existing entry for this windowId to avoid duplicate observers
if let existing = entries[windowId] {
if let observer = existing.observer {
Expand All @@ -58,7 +59,8 @@ internal final class WindowLifecycleMonitor {
entries[windowId] = Entry(
connectionId: connectionId,
window: window,
observer: observer
observer: observer,
isPreview: isPreview
)
}

Expand Down Expand Up @@ -115,6 +117,22 @@ internal final class WindowLifecycleMonitor {
entries[windowId] != nil
}

/// Find the first preview window for a connection.
internal func previewWindow(for connectionId: UUID) -> (windowId: UUID, window: NSWindow)? {
entries.first { $0.value.connectionId == connectionId && $0.value.isPreview }
.map { ($0.key, $0.value.window) }
}

/// Look up the NSWindow for a given windowId.
internal func window(for windowId: UUID) -> NSWindow? {
entries[windowId]?.window
}

/// Update the preview flag for a registered window.
internal func setPreview(_ isPreview: Bool, for windowId: UUID) {
entries[windowId]?.isPreview = isPreview
}

// MARK: - Private

private func handleWindowClose(_ closedWindow: NSWindow) {
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 @@ -29,6 +29,8 @@ internal struct EditorTabPayload: Codable, Hashable {
internal let showStructure: Bool
/// Whether to skip automatic query execution (used for restored tabs that should lazy-load)
internal let skipAutoExecute: Bool
/// Whether this tab is a preview (temporary) tab
internal let isPreview: Bool

internal init(
id: UUID = UUID(),
Expand All @@ -39,7 +41,8 @@ internal struct EditorTabPayload: Codable, Hashable {
initialQuery: String? = nil,
isView: Bool = false,
showStructure: Bool = false,
skipAutoExecute: Bool = false
skipAutoExecute: Bool = false,
isPreview: Bool = false
) {
self.id = id
self.connectionId = connectionId
Expand All @@ -50,6 +53,7 @@ internal struct EditorTabPayload: Codable, Hashable {
self.isView = isView
self.showStructure = showStructure
self.skipAutoExecute = skipAutoExecute
self.isPreview = isPreview
}

internal init(from decoder: Decoder) throws {
Expand All @@ -63,6 +67,7 @@ internal struct EditorTabPayload: Codable, Hashable {
isView = try container.decodeIfPresent(Bool.self, forKey: .isView) ?? false
showStructure = try container.decodeIfPresent(Bool.self, forKey: .showStructure) ?? false
skipAutoExecute = try container.decodeIfPresent(Bool.self, forKey: .skipAutoExecute) ?? false
isPreview = try container.decodeIfPresent(Bool.self, forKey: .isPreview) ?? false
}

/// Whether this payload is a "connection-only" payload — just a connectionId
Expand All @@ -83,5 +88,6 @@ internal struct EditorTabPayload: Codable, Hashable {
self.isView = tab.isView
self.showStructure = tab.showStructure
self.skipAutoExecute = skipAutoExecute
self.isPreview = false
}
}
27 changes: 26 additions & 1 deletion TablePro/Models/Query/QueryTab.swift
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,9 @@ struct QueryTab: Identifiable, Equatable {
// Per-tab column layout (widths/order persist across reloads within tab session)
var columnLayout: ColumnLayoutState

// Whether this tab is a preview (temporary) tab that gets replaced on next navigation
var isPreview: Bool

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

Expand Down Expand Up @@ -389,6 +392,7 @@ struct QueryTab: Identifiable, Equatable {
self.pagination = PaginationState()
self.filterState = TabFilterState()
self.columnLayout = ColumnLayoutState()
self.isPreview = false
self.resultVersion = 0
self.metadataVersion = 0
}
Expand Down Expand Up @@ -420,6 +424,7 @@ struct QueryTab: Identifiable, Equatable {
self.pagination = PaginationState()
self.filterState = TabFilterState()
self.columnLayout = ColumnLayoutState()
self.isPreview = false
self.resultVersion = 0
self.metadataVersion = 0
}
Expand Down Expand Up @@ -484,6 +489,8 @@ struct QueryTab: Identifiable, Equatable {
&& lhs.isView == rhs.isView
&& lhs.tabType == rhs.tabType
&& lhs.rowsAffected == rhs.rowsAffected
&& lhs.isPreview == rhs.isPreview
&& lhs.hasUserInteraction == rhs.hasUserInteraction
}
}

Expand Down Expand Up @@ -550,13 +557,30 @@ final class QueryTabManager {
selectedTabId = newTab.id
}

func addPreviewTableTab(tableName: String, databaseType: DatabaseType = .mysql, databaseName: String = "") {
let pageSize = AppSettingsManager.shared.dataGrid.defaultPageSize
let query = QueryTab.buildBaseTableQuery(tableName: tableName, databaseType: databaseType)
var newTab = QueryTab(
title: tableName,
query: query,
tabType: .table,
tableName: tableName
)
newTab.pagination = PaginationState(pageSize: pageSize)
newTab.databaseName = databaseName
newTab.isPreview = true
tabs.append(newTab)
selectedTabId = newTab.id
}

/// Replace the currently selected tab's content with a new table.
/// - Returns: `true` if the replacement happened (caller should run the query),
/// `false` if there is no selected tab.
@discardableResult
func replaceTabContent(
tableName: String, databaseType: DatabaseType = .mysql,
isView: Bool = false, databaseName: String = ""
isView: Bool = false, databaseName: String = "",
isPreview: Bool = false
) -> Bool {
guard let selectedId = selectedTabId,
let selectedIndex = tabs.firstIndex(where: { $0.id == selectedId })
Expand Down Expand Up @@ -603,6 +627,7 @@ final class QueryTabManager {
tab.columnLayout = ColumnLayoutState()
tab.pagination = PaginationState(pageSize: pageSize)
tab.databaseName = databaseName
tab.isPreview = isPreview
tabs[selectedIndex] = tab
return true
}
Expand Down
10 changes: 10 additions & 0 deletions TablePro/Models/Settings/AppSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -482,5 +482,15 @@ struct HistorySettings: Codable, Equatable {

/// Tab behavior settings
struct TabSettings: Codable, Equatable {
var enablePreviewTabs: Bool = true
static let `default` = TabSettings()

init(enablePreviewTabs: Bool = true) {
self.enablePreviewTabs = enablePreviewTabs
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
enablePreviewTabs = try container.decodeIfPresent(Bool.self, forKey: .enablePreviewTabs) ?? true
}
}
Loading