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
74 changes: 11 additions & 63 deletions TablePro/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// Created by Ngo Quoc Dat on 16/12/25.
//

import AppKit
import os
import SwiftUI

Expand All @@ -19,10 +20,6 @@ struct ContentView: View {
@State private var connectionToEdit: DatabaseConnection?
@State private var connectionToDelete: DatabaseConnection?
@State private var showDeleteConfirmation = false
@State private var showUnsavedChangesAlert = false
@State private var pendingCloseSessionId: UUID?
@State private var showDisconnectConfirmation = false
@State private var pendingDisconnectSessionId: UUID?
@State private var hasLoaded = false
@State private var isInspectorPresented = false // Right sidebar (inspector) visibility

Expand Down Expand Up @@ -59,59 +56,6 @@ struct ContentView: View {
} message: { connection in
Text("Are you sure you want to delete \"\(connection.name)\"?")
}
.alert(
"Unsaved Changes",
isPresented: $showUnsavedChangesAlert
) {
Button("Cancel", role: .cancel) {
pendingCloseSessionId = nil
}
Button("Close Without Saving", role: .destructive) {
if let sessionId = pendingCloseSessionId {
Task {
await dbManager.disconnectSession(sessionId)
}
}
pendingCloseSessionId = nil
}
} message: {
Text("This connection has unsaved changes. Are you sure you want to close it?")
}
.alert(
"Disconnect",
isPresented: $showDisconnectConfirmation
) {
Button("Cancel", role: .cancel) {
pendingDisconnectSessionId = nil
}
Button("Disconnect", role: .destructive) {
if let sessionId = pendingDisconnectSessionId {
Task {
await dbManager.disconnectSession(sessionId)
}
}
pendingDisconnectSessionId = nil
}
Button("Don't Ask Again") {
// Disable future confirmations
AppSettingsManager.shared.general.confirmBeforeDisconnecting = false
// Then disconnect
if let sessionId = pendingDisconnectSessionId {
Task {
await dbManager.disconnectSession(sessionId)
}
}
pendingDisconnectSessionId = nil
}
} message: {
Text("Are you sure you want to disconnect from this database?")
}
.onChange(of: showDisconnectConfirmation) { _, isShowing in
// Reset pending state when alert is dismissed (e.g., by Cmd+W or ESC)
if !isShowing {
pendingDisconnectSessionId = nil
}
}
.onAppear {
loadConnections()
}
Expand All @@ -120,12 +64,16 @@ struct ContentView: View {
}
.onReceive(NotificationCenter.default.publisher(for: .deselectConnection)) { _ in
if let sessionId = dbManager.currentSessionId {
// Check if confirmation is required
if AppSettingsManager.shared.general.confirmBeforeDisconnecting {
pendingDisconnectSessionId = sessionId
showDisconnectConfirmation = true
} else {
Task {
// Always confirm before disconnecting
Task { @MainActor in
let confirmed = AlertHelper.confirmDestructive(
title: "Disconnect",
message: "Are you sure you want to disconnect from this database?",
confirmButton: "Disconnect",
cancelButton: "Cancel"
)

if confirmed {
await dbManager.disconnectSession(sessionId)
}
}
Expand Down
50 changes: 40 additions & 10 deletions TablePro/Core/Autocomplete/SQLSchemaProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,20 +38,52 @@ actor SQLSchemaProvider {
// Fetch all tables
tables = try await driver.fetchTables()

// Pre-load columns for common tables (up to 5)
for table in tables.prefix(5) {
let columns = try await driver.fetchColumns(table: table.name)
columnCache[table.name.lowercased()] = columns
// Pre-load columns for ALL tables asynchronously
// Use TaskGroup with concurrency limit to avoid overwhelming the database
let maxConcurrentTasks = 20 // Limit parallel requests

await withTaskGroup(of: (String, [ColumnInfo]?).self) { group in
var index = 0

// Seed initial batch
for table in tables.prefix(maxConcurrentTasks) {
group.addTask {
do {
let columns = try await driver.fetchColumns(table: table.name)
return (table.name.lowercased(), columns)
} catch {
return (table.name.lowercased(), nil)
}
}
index += 1
}

// Process results and spawn new tasks
for await (tableName, columns) in group {
if let columns = columns {
columnCache[tableName] = columns
}

// Add next task if available
if index < tables.count {
let table = tables[index]
index += 1
group.addTask {
do {
let columns = try await driver.fetchColumns(table: table.name)
return (table.name.lowercased(), columns)
} catch {
return (table.name.lowercased(), nil)
}
}
}
}
}

// Clear remaining column cache
isLoading = false

// Driver will be disconnected by caller, we'll reconnect for additional column loading
} catch {
lastLoadError = error
isLoading = false
print("[SQLSchemaProvider] Failed to load schema: \(error)")
}
}

Expand All @@ -69,7 +101,6 @@ actor SQLSchemaProvider {

// Use the cached driver from loadSchema() to ensure we're querying the correct connection
guard let driver = cachedDriver else {
print("[SQLSchemaProvider] No cached driver for column loading")
return []
}

Expand All @@ -78,7 +109,6 @@ actor SQLSchemaProvider {
columnCache[tableName.lowercased()] = columns
return columns
} catch {
print("[SQLSchemaProvider] Failed to load columns for \(tableName): \(error)")
return []
}
}
Expand Down
119 changes: 119 additions & 0 deletions TablePro/Core/ChangeTracking/AnyChangeManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
//
// AnyChangeManager.swift
// TablePro
//
// Type-erased wrapper for change managers (data and structure).
// Allows DataGridView to work with both DataChangeManager and StructureChangeManager.
//

import Combine
import Foundation

/// Type-erased change manager wrapper
@MainActor
final class AnyChangeManager: ObservableObject {
@Published var hasChanges: Bool = false
@Published var reloadVersion: Int = 0

private var cancellables: Set<AnyCancellable> = []
private let _isRowDeleted: (Int) -> Bool
private let _getChanges: () -> [Any]
private let _recordCellChange: ((Int, Int, String, String?, String?, [String?]) -> Void)?
private let _undoRowDeletion: ((Int) -> Void)?
private let _undoRowInsertion: ((Int) -> Void)?
private let _consumeChangedRowIndices: (() -> Set<Int>)?

// MARK: - Initializers

/// Wrap a DataChangeManager
init(dataManager: DataChangeManager) {
self._isRowDeleted = { rowIndex in
dataManager.isRowDeleted(rowIndex)
}
self._getChanges = {
dataManager.changes
}
self._recordCellChange = { rowIndex, columnIndex, columnName, oldValue, newValue, originalRow in
dataManager.recordCellChange(
rowIndex: rowIndex,
columnIndex: columnIndex,
columnName: columnName,
oldValue: oldValue,
newValue: newValue,
originalRow: originalRow
)
}
self._undoRowDeletion = { rowIndex in
dataManager.undoRowDeletion(rowIndex: rowIndex)
}
self._undoRowInsertion = { rowIndex in
dataManager.undoRowInsertion(rowIndex: rowIndex)
}
self._consumeChangedRowIndices = {
dataManager.consumeChangedRowIndices()
}

// Sync published properties - store in cancellables to prevent retain cycles
dataManager.$hasChanges
.assign(to: \.hasChanges, on: self)
.store(in: &cancellables)
dataManager.$reloadVersion
.assign(to: \.reloadVersion, on: self)
.store(in: &cancellables)
}

/// Wrap a StructureChangeManager
init(structureManager: StructureChangeManager) {
self._isRowDeleted = { _ in false } // Structure doesn't track row deletions
self._getChanges = {
Array(structureManager.pendingChanges.values)
}
self._recordCellChange = nil // Structure uses custom editing logic
self._undoRowDeletion = nil
self._undoRowInsertion = nil
self._consumeChangedRowIndices = {
structureManager.consumeChangedRowIndices()
}

// Sync published properties - store in cancellables to prevent retain cycles
structureManager.$hasChanges
.assign(to: \.hasChanges, on: self)
.store(in: &cancellables)
structureManager.$reloadVersion
.assign(to: \.reloadVersion, on: self)
.store(in: &cancellables)
}

// MARK: - Public API

func isRowDeleted(_ rowIndex: Int) -> Bool {
_isRowDeleted(rowIndex)
}

var changes: [Any] {
_getChanges()
}

func recordCellChange(
rowIndex: Int,
columnIndex: Int,
columnName: String,
oldValue: String?,
newValue: String?,
originalRow: [String?]
) {
_recordCellChange?(rowIndex, columnIndex, columnName, oldValue, newValue, originalRow)
}

func undoRowDeletion(rowIndex: Int) {
_undoRowDeletion?(rowIndex)
}

func undoRowInsertion(rowIndex: Int) {
_undoRowInsertion?(rowIndex)
}

func consumeChangedRowIndices() -> Set<Int> {
_consumeChangedRowIndices?() ?? []
}
}
18 changes: 18 additions & 0 deletions TablePro/Core/ChangeTracking/DataChangeManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ final class DataChangeManager: ObservableObject {
@Published var changes: [RowChange] = []
@Published var hasChanges: Bool = false
@Published var reloadVersion: Int = 0 // Incremented to trigger table reload

// Track which rows changed since last reload for granular updates
private(set) var changedRowIndices: Set<Int> = []

var tableName: String = ""
var primaryKeyColumn: String?
Expand Down Expand Up @@ -57,6 +60,13 @@ final class DataChangeManager: ObservableObject {
private func cellKey(rowIndex: Int, columnIndex: Int) -> String {
"\(rowIndex)-\(columnIndex)"
}

/// Consume and clear changed row indices (for granular table reloads)
func consumeChangedRowIndices() -> Set<Int> {
let indices = changedRowIndices
changedRowIndices.removeAll()
return indices
}

// MARK: - Configuration

Expand All @@ -67,6 +77,7 @@ final class DataChangeManager: ObservableObject {
insertedRowIndices.removeAll()
modifiedCells.removeAll()
insertedRowData.removeAll()
changedRowIndices.removeAll()
undoManager.clearAll()
hasChanges = false
reloadVersion += 1
Expand All @@ -88,6 +99,7 @@ final class DataChangeManager: ObservableObject {
insertedRowIndices.removeAll()
modifiedCells.removeAll()
insertedRowData.removeAll()
changedRowIndices.removeAll()
undoManager.clearAll()

changes.removeAll()
Expand Down Expand Up @@ -156,6 +168,7 @@ final class DataChangeManager: ObservableObject {
previousValue: oldValue,
newValue: newValue
))
changedRowIndices.insert(rowIndex)
hasChanges = !changes.isEmpty
return
}
Expand Down Expand Up @@ -188,6 +201,7 @@ final class DataChangeManager: ObservableObject {
changes[existingIndex].cellChanges.append(cellChange)
modifiedCells.insert(key)
}
changedRowIndices.insert(rowIndex)
} else {
let rowChange = RowChange(
rowIndex: rowIndex,
Expand All @@ -197,6 +211,7 @@ final class DataChangeManager: ObservableObject {
)
changes.append(rowChange)
modifiedCells.insert(key)
changedRowIndices.insert(rowIndex)
}

pushUndo(.cellEdit(
Expand All @@ -216,6 +231,7 @@ final class DataChangeManager: ObservableObject {
let rowChange = RowChange(rowIndex: rowIndex, type: .delete, originalRow: originalRow)
changes.append(rowChange)
deletedRowIndices.insert(rowIndex)
changedRowIndices.insert(rowIndex) // Track for granular reload
pushUndo(.rowDeletion(rowIndex: rowIndex, originalRow: originalRow))
hasChanges = true
reloadVersion += 1
Expand All @@ -238,6 +254,7 @@ final class DataChangeManager: ObservableObject {
let rowChange = RowChange(rowIndex: rowIndex, type: .delete, originalRow: originalRow)
changes.append(rowChange)
deletedRowIndices.insert(rowIndex)
changedRowIndices.insert(rowIndex) // Track for granular reload
batchData.append((rowIndex: rowIndex, originalRow: originalRow))
}

Expand All @@ -251,6 +268,7 @@ final class DataChangeManager: ObservableObject {
let rowChange = RowChange(rowIndex: rowIndex, type: .insert, cellChanges: [])
changes.append(rowChange)
insertedRowIndices.insert(rowIndex)
changedRowIndices.insert(rowIndex) // Track for granular reload
pushUndo(.rowInsertion(rowIndex: rowIndex))
hasChanges = true
}
Expand Down
Loading