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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- OpenSSL shared as dylib across app and plugins, saving ~15MB in bundle size
- Data grid uses single cell reuse identifier with typed stored properties instead of 3 identifiers and viewWithTag
- Boolean dropdown menu includes Set NULL option for nullable columns
- Tab persistence triggers on a structural counter, not on every tabs write. Cell edits, row mutations, and per-keystroke query text no longer invoke disk I/O.
- Inspector sidebar edit state runs inside the existing 50ms debounce instead of synchronously per row click.
- Row add, delete, duplicate, undo, redo, and paste drive NSTableView insertRows / removeRows directly through the data grid delegate. SwiftUI no longer re-evaluates the editor view tree on row mutations.
- QueryTab.resultVersion split: schemaVersion (column shape) on QueryTab, row mutations through delegate deltas, sort completion through a single delegate replace call. Pin toggle, sort completion, and applyMultiStatementResults no longer fan out a redundant reload signal.
- Row data lives in a per-coordinator RowDataStore keyed by tab.id rather than on QueryTab itself, so SwiftUI's @Observable tracking on tabManager.tabs no longer fires for row writes.
- DataGridConfiguration is Equatable; DataGridIdentity covers tabType, tableName, and primaryKeyColumns so updateNSView short-circuits when nothing structural changed. DataTabGridDelegate properties are wired in onAppear / onChange instead of in the body.

### Fixed

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,21 +120,6 @@ internal final class TabPersistenceCoordinator {
)
}

// MARK: - Last Query

/// Save the editor's last query text for this connection.
internal func saveLastQuery(_ query: String) {
let connId = connectionId
Task {
await TabDiskActor.shared.saveLastQuery(query, for: connId)
}
}

/// Load the editor's last query text for this connection.
internal func loadLastQuery() async -> String? {
await TabDiskActor.shared.loadLastQuery(for: connectionId)
}

// MARK: - Private

private func convertToPersistedTab(_ tab: QueryTab) -> PersistedTab {
Expand Down
44 changes: 44 additions & 0 deletions TablePro/Core/Services/Query/RowDataStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import Foundation

@MainActor
@Observable
final class RowDataStore {
@ObservationIgnored private var store: [UUID: RowBuffer] = [:]

func buffer(for tabId: UUID) -> RowBuffer {
if let existing = store[tabId] {
return existing
}
let buffer = RowBuffer()
store[tabId] = buffer
return buffer
}

func existingBuffer(for tabId: UUID) -> RowBuffer? {
store[tabId]
}

func setBuffer(_ buffer: RowBuffer, for tabId: UUID) {
store[tabId] = buffer
}

func removeBuffer(for tabId: UUID) {
store.removeValue(forKey: tabId)
}

func evict(for tabId: UUID) {
store[tabId]?.evict()
}

func evictAll(except activeTabId: UUID?) {
for (id, buffer) in store where id != activeTabId {
if !buffer.rows.isEmpty && !buffer.isEvicted {
buffer.evict()
}
}
}

func tearDown() {
store.removeAll()
}
}
44 changes: 23 additions & 21 deletions TablePro/Core/Services/Query/RowOperationsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,16 +91,18 @@ final class RowOperationsManager {

// MARK: - Delete Rows

/// Delete selected rows
/// - Parameters:
/// - selectedIndices: Indices of rows to delete
/// - resultRows: Current rows (will be mutated)
/// - Returns: Next row index to select after deletion, or -1 if no rows left
struct DeleteRowsResult {
let nextRowToSelect: Int
let physicallyRemovedIndices: [Int]
}

func deleteSelectedRows(
selectedIndices: Set<Int>,
resultRows: inout [[String?]]
) -> Int {
guard !selectedIndices.isEmpty else { return -1 }
) -> DeleteRowsResult {
guard !selectedIndices.isEmpty else {
return DeleteRowsResult(nextRowToSelect: -1, physicallyRemovedIndices: [])
}

var insertedRowsToDelete: [Int] = []
var existingRowsToDelete: [(rowIndex: Int, originalRow: [String?])] = []
Expand All @@ -118,40 +120,40 @@ final class RowOperationsManager {
}
}

// Process inserted rows deletion
if !insertedRowsToDelete.isEmpty {
let sortedInsertedRows = insertedRowsToDelete.sorted(by: >)
let sortedInsertedRows = insertedRowsToDelete.sorted(by: >)

// Remove from resultRows first (descending order)
if !sortedInsertedRows.isEmpty {
for rowIndex in sortedInsertedRows {
guard rowIndex < resultRows.count else { continue }
resultRows.remove(at: rowIndex)
}

// Update changeManager for ALL deleted inserted rows at once
changeManager.undoBatchRowInsertion(rowIndices: sortedInsertedRows)
}

// Record batch deletion for existing rows (single undo action for all rows)
if !existingRowsToDelete.isEmpty {
changeManager.recordBatchRowDeletion(rows: existingRowsToDelete)
}

// Calculate next row selection, accounting for deleted inserted rows
let totalRows = resultRows.count
let rowsDeleted = insertedRowsToDelete.count
let rowsDeleted = sortedInsertedRows.count
let adjustedMaxRow = maxSelectedRow - rowsDeleted
let adjustedMinRow = minSelectedRow - insertedRowsToDelete.count(where: { $0 < minSelectedRow })
let adjustedMinRow = minSelectedRow - sortedInsertedRows.count(where: { $0 < minSelectedRow })

let nextRow: Int
if adjustedMaxRow + 1 < totalRows {
return min(adjustedMaxRow + 1, totalRows - 1)
nextRow = min(adjustedMaxRow + 1, totalRows - 1)
} else if adjustedMinRow > 0 {
return adjustedMinRow - 1
nextRow = adjustedMinRow - 1
} else if totalRows > 0 {
return 0
nextRow = 0
} else {
return -1
nextRow = -1
}

return DeleteRowsResult(
nextRowToSelect: nextRow,
physicallyRemovedIndices: sortedInsertedRows
)
}

// MARK: - Undo/Redo
Expand Down
108 changes: 11 additions & 97 deletions TablePro/Core/Storage/TabDiskActor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,6 @@ internal struct TabDiskState: Codable {
///
/// Data is stored as individual JSON files per connection in:
/// `~/Library/Application Support/TablePro/TabState/`
///
/// Last-query strings are stored in a sibling directory:
/// `~/Library/Application Support/TablePro/LastQuery/`
internal actor TabDiskActor {
internal static let shared = TabDiskActor()

Expand All @@ -31,39 +28,26 @@ internal actor TabDiskActor {
// MARK: - Legacy UserDefaults Keys (for migration)

private static let legacyTabStateKeyPrefix = "com.TablePro.tabs."
private static let legacyLastQueryKeyPrefix = "com.TablePro.lastquery."
private static let migrationCompleteKey = "com.TablePro.tabStateMigrationComplete"

// MARK: - File Storage

private let tabStateDirectory: URL
private let lastQueryDirectory: URL
private let encoder: JSONEncoder
private let decoder: JSONDecoder

private init() {
tabStateDirectory = Self.resolvedTabStateDirectory()

let baseDirectory = tabStateDirectory.deletingLastPathComponent()
lastQueryDirectory = baseDirectory.appendingPathComponent("LastQuery", isDirectory: true)

let directory = Self.resolvedTabStateDirectory()
tabStateDirectory = directory
encoder = JSONEncoder()
decoder = JSONDecoder()

// Directory creation and migration run synchronously at init.
// Safe because init is the only caller and runs before any concurrent access.
let fm = FileManager.default
for directory in [tabStateDirectory, lastQueryDirectory] {
do {
try fm.createDirectory(at: directory, withIntermediateDirectories: true)
} catch {
Self.logger.error("Failed to create directory \(directory.path): \(error.localizedDescription)")
}
do {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
} catch {
Self.logger.error("Failed to create directory \(directory.path): \(error.localizedDescription)")
}
Self.performMigrationIfNeeded(
tabStateDirectory: tabStateDirectory,
lastQueryDirectory: lastQueryDirectory
)
Self.performMigrationIfNeeded(tabStateDirectory: directory)
}

// MARK: - Public API
Expand Down Expand Up @@ -111,52 +95,6 @@ internal actor TabDiskActor {
}
}

/// Save the last query text for a connection. Skips if query exceeds 500KB.
internal func saveLastQuery(_ query: String, for connectionId: UUID) {
guard (query as NSString).length < TabQueryContent.maxPersistableQuerySize else { return }

let fileURL = lastQueryFileURL(for: connectionId)
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)

if trimmed.isEmpty {
if FileManager.default.fileExists(atPath: fileURL.path) {
do {
try FileManager.default.removeItem(at: fileURL)
} catch {
Self.logger.error(
"Failed to remove last query for \(connectionId): \(error.localizedDescription)"
)
}
}
} else {
do {
let data = Data(trimmed.utf8)
try data.write(to: fileURL, options: .atomic)
} catch {
Self.logger.error(
"Failed to save last query for \(connectionId): \(error.localizedDescription)"
)
}
}
}

/// Load the last query text for a connection.
internal func loadLastQuery(for connectionId: UUID) -> String? {
let fileURL = lastQueryFileURL(for: connectionId)

guard FileManager.default.fileExists(atPath: fileURL.path) else {
return nil
}

do {
let data = try Data(contentsOf: fileURL)
return String(data: data, encoding: .utf8)
} catch {
Self.logger.error("Failed to load last query for \(connectionId): \(error.localizedDescription)")
return nil
}
}

/// List all connection IDs that have saved tab state on disk.
internal func connectionIdsWithSavedState() -> [UUID] {
let fm = FileManager.default
Expand Down Expand Up @@ -217,28 +155,22 @@ internal actor TabDiskActor {
tabStateDirectory.appendingPathComponent("\(connectionId.uuidString).json")
}

private func lastQueryFileURL(for connectionId: UUID) -> URL {
lastQueryDirectory.appendingPathComponent("\(connectionId.uuidString).txt")
}

// MARK: - Migration from UserDefaults

/// One-time migration: reads existing tab state and last-query data from UserDefaults,
/// One-time migration: reads existing tab state from UserDefaults,
/// writes it to file storage, then clears the old UserDefaults keys.
/// This is a static method to avoid actor-isolation issues during init.
private static func performMigrationIfNeeded(tabStateDirectory: URL, lastQueryDirectory: URL) {
private static func performMigrationIfNeeded(tabStateDirectory: URL) {
let defaults = UserDefaults.standard

guard !defaults.bool(forKey: migrationCompleteKey) else { return }

logger.trace("Starting one-time migration of tab state from UserDefaults to file storage")

var migratedTabStates = 0
var migratedLastQueries = 0

let allKeys = defaults.dictionaryRepresentation().keys
let tabStateKeys = allKeys.filter { $0.hasPrefix(legacyTabStateKeyPrefix) }
let lastQueryKeys = allKeys.filter { $0.hasPrefix(legacyLastQueryKeyPrefix) }

for key in tabStateKeys {
let uuidString = String(key.dropFirst(legacyTabStateKeyPrefix.count))
Expand All @@ -255,28 +187,10 @@ internal actor TabDiskActor {
}
}

for key in lastQueryKeys {
let uuidString = String(key.dropFirst(legacyLastQueryKeyPrefix.count))
guard let connectionId = UUID(uuidString: uuidString),
let query = defaults.string(forKey: key) else { continue }

let fileURL = lastQueryDirectory.appendingPathComponent("\(connectionId.uuidString).txt")
do {
let data = Data(query.utf8)
try data.write(to: fileURL, options: .atomic)
defaults.removeObject(forKey: key)
migratedLastQueries += 1
} catch {
logger.error("Failed to migrate last query for \(uuidString): \(error.localizedDescription)")
}
}

defaults.set(true, forKey: migrationCompleteKey)

if migratedTabStates > 0 || migratedLastQueries > 0 {
logger.trace(
"Migration complete: \(migratedTabStates) tab states, \(migratedLastQueries) last queries"
)
if migratedTabStates > 0 {
logger.trace("Migration complete: \(migratedTabStates) tab states")
} else {
logger.trace("Migration complete: no legacy data found")
}
Expand Down
Loading
Loading