Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
2ccd677
refactor(datagrid): collapse cell hierarchy to single DataGridCellView
datlechin May 8, 2026
e64c2c9
refactor(datagrid): bound display cache via NSCache and add O(1) RowI…
datlechin May 8, 2026
9803534
refactor(datagrid): incremental row visual state via RowVisualIndex
datlechin May 8, 2026
522bb48
refactor(datagrid): off-main JSON parse and cancellable Task.sleep co…
datlechin May 8, 2026
8fa7779
fix(datagrid): use NSButton for cell accessory clicks and brighten ch…
datlechin May 8, 2026
90a93dd
refactor(datagrid): add typeSelect, animated undo insert, defensive r…
datlechin May 8, 2026
f505edc
fix(datagrid): row tint refresh on mark-delete and focus-follow on pr…
datlechin May 8, 2026
fee0430
fix(datagrid): force focus overlay refresh on every selection change
datlechin May 8, 2026
36cecab
fix(datagrid): keep focus overlay on top via zPosition and defer key-…
datlechin May 8, 2026
1f523fc
fix(datagrid): defer focus overlay refresh through every reload path
datlechin May 8, 2026
e774ec7
refactor(datagrid): cell-owned focus border replaces FocusOverlayView…
datlechin May 8, 2026
ed2df66
chore(datagrid): delete dead code surfaced by audit
datlechin May 8, 2026
2d005e5
refactor(datagrid): snapshot theme palette per render pass and weak-c…
datlechin May 8, 2026
e8b5d85
refactor(quickswitcher): replace .sheet with NSPanel for Spotlight pa…
datlechin May 8, 2026
6628ab0
fix(switcher): ESC clears search if non-empty otherwise bubbles to di…
datlechin May 8, 2026
60bddee
chore(datagrid): inline single-caller TypePicker and drop TableViewCo…
datlechin May 8, 2026
8964a55
refactor(window): replace custom restoration with NSWindowRestoration…
datlechin May 8, 2026
0fb724d
refactor(hig): honor Reduce Transparency and Increase Contrast for ma…
datlechin May 8, 2026
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 @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Internal: AI chat tools declare their access mode (read-only, write, agent-only) rather than relying on a hardcoded allowlist; new tools are picked up automatically
- AI providers: Anthropic test connection uses the configured model, known model list updated through Claude 4.7, and Ollama detection now logs the actual error category instead of swallowing every failure as 'not running'
- AI Chat views: replace custom pill buttons with native `.borderless` styles, switch hardcoded text colors to semantic system colors, use relative font sizing in Markdown rendering, align spacing to the 8-pt grid, and add accessibility labels to icon-only buttons
- Translucent backgrounds (Welcome sidebar, settings banners, ER diagram toolbar, JSON editor controls, Pro feature scrim) honor the system Reduce Transparency and Increase Contrast accessibility settings, swapping the material for a solid surface color when either is on

### Fixed

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ When adding a new method to the driver protocol: add to `PluginDatabaseDriver` (
- **`SQLEditorTheme`** — single source of truth for editor colors/fonts
- **`TableProEditorTheme`** — adapter to CodeEdit's `EditorTheme` protocol
- **`CompletionEngine`** — framework-agnostic; **`SQLCompletionAdapter`** bridges to CodeEdit's `CodeSuggestionDelegate`
- **`EditorTabBar`** — pure SwiftUI tab bar
- Editor tabs use native NSWindow tabs (`NSWindow.tabbingMode = .preferred` in `TabWindowController`); there is no custom tab bar.
- Cursor model: `cursorPositions: [CursorPosition]` (multi-cursor via CodeEditSourceEditor)

### Change Tracking Flow
Expand Down
4 changes: 4 additions & 0 deletions TablePro/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
}

func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool {
true
}

func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
let hasUnsaved = MainContentCoordinator.hasAnyUnsavedChanges()
if hasUnsaved {
Expand Down
6 changes: 1 addition & 5 deletions TablePro/Core/ChangeTracking/DataChangeManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,7 @@ final class DataChangeManager: ChangeManaging {
var databaseType: DatabaseType = .mysql
var pluginDriver: (any PluginDatabaseDriver)?

private var _columnsStorage: [String] = []
var columns: [String] {
get { _columnsStorage }
set { _columnsStorage = newValue.map { String($0) } }
}
var columns: [String] = []

var undoManagerProvider: (() -> UndoManager?)?
var onUndoApplied: ((UndoResult) -> Void)?
Expand Down
31 changes: 31 additions & 0 deletions TablePro/Core/DataGrid/RowDisplayBox.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//
// RowDisplayBox.swift
// TablePro
//

import Foundation

final class RowIDKey: NSObject {
let id: RowID

init(_ id: RowID) {
self.id = id
super.init()
}

override func isEqual(_ object: Any?) -> Bool {
guard let other = object as? RowIDKey else { return false }
return other.id == id
}

override var hash: Int { id.hashValue }
}

final class RowDisplayBox: NSObject {
var values: ContiguousArray<String?>

init(_ values: ContiguousArray<String?>) {
self.values = values
super.init()
}
}
3 changes: 0 additions & 3 deletions TablePro/Core/Database/DatabaseManager+Sessions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,6 @@ extension DatabaseManager {
setSession(session, for: connection.id)
}

appSettingsStorage.saveLastConnectionId(connection.id)

MacAnalyticsProvider.shared.markConnectionSucceeded()
AppEvents.shared.databaseDidConnect.send(DatabaseDidConnect(connectionId: connection.id))

Expand Down Expand Up @@ -345,7 +343,6 @@ extension DatabaseManager {
switchToSession(nextSessionId)
} else {
currentSessionId = nil
appSettingsStorage.saveLastConnectionId(nil)
}
}
lifecycleLogger.info(
Expand Down
7 changes: 0 additions & 7 deletions TablePro/Core/Database/DatabaseManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ final class DatabaseManager {
didSet {
if Set(oldValue.keys) != Set(activeSessions.keys) {
connectionListVersion &+= 1
persistOpenConnectionIds()
}
connectionStatusVersion &+= 1
}
Expand Down Expand Up @@ -105,10 +104,4 @@ final class DatabaseManager {
self.pluginManager = pluginManager
}

private func persistOpenConnectionIds() {
let connections = connectionStorage.loadConnections()
let activeKeys = Set(activeSessions.keys)
let ids = connections.filter { activeKeys.contains($0.id) }.map(\.id)
appSettingsStorage.saveLastOpenConnectionIds(ids)
}
}
2 changes: 1 addition & 1 deletion TablePro/Core/Plugins/QueryResultExportDataSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ final class QueryResultExportDataSource: PluginExportDataSource, @unchecked Send
self.driver = driver
self.columns = tableRows.columns
self.columnTypeNames = tableRows.columnTypes.map { $0.rawType ?? "" }
self.rows = tableRows.rows.map(\.values)
self.rows = tableRows.rows.map { Array($0.values) }
}

func streamRows(table: String, databaseName: String) -> AsyncThrowingStream<PluginStreamElement, Error> {
Expand Down
82 changes: 5 additions & 77 deletions TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -115,30 +115,12 @@ internal final class AppLaunchCoordinator {
}

private func runStartupBehaviorIfNeeded(skipping intents: [LaunchIntent]) {
guard intents.isEmpty else {
closeRestoredMainWindowsExcept(intents: intents)
return
}
guard intents.isEmpty else { return }

let general = AppSettingsStorage.shared.loadGeneral()
guard general.startupBehavior == .reopenLast else {
closeRestoredMainWindowsExcept(intents: intents)
return
}
let openIds = AppSettingsStorage.shared.loadLastOpenConnectionIds()
if !openIds.isEmpty {
attemptAutoReconnect(connectionIds: openIds)
return
}
if let lastId = AppSettingsStorage.shared.loadLastConnectionId() {
attemptAutoReconnect(connectionIds: [lastId])
return
}
Task { [weak self] in
let diskIds = await TabDiskActor.shared.connectionIdsWithSavedState()
if !diskIds.isEmpty {
self?.attemptAutoReconnect(connectionIds: diskIds)
} else {
self?.closeRestoredMainWindowsExcept(intents: [])
if general.startupBehavior == .showWelcome {
for window in NSApp.windows where Self.isMainWindow(window) {
window.close()
}
}
}
Expand All @@ -149,60 +131,6 @@ internal final class AppLaunchCoordinator {
showWelcomeWindow()
}

private func closeRestoredMainWindowsExcept(intents: [LaunchIntent]) {
let preserved = Set(intents.compactMap { $0.targetConnectionId })
for window in NSApp.windows where Self.isMainWindow(window) {
if let id = WindowLifecycleMonitor.shared.connectionId(forWindow: window),
preserved.contains(id) {
continue
}
window.close()
}
}

private func attemptAutoReconnect(connectionIds: [UUID]) {
let saved = ConnectionStorage.shared.loadConnections()
let valid = connectionIds.compactMap { id in
saved.first(where: { $0.id == id })
}
guard !valid.isEmpty else {
AppSettingsStorage.shared.saveLastOpenConnectionIds([])
AppSettingsStorage.shared.saveLastConnectionId(nil)
closeRestoredMainWindowsExcept(intents: [])
showWelcomeWindow()
return
}
for window in NSApp.windows where Self.isWelcomeWindow(window) {
window.orderOut(nil)
}
Task { [weak self] in
for connection in valid {
let payload = EditorTabPayload(
connectionId: connection.id, intent: .restoreOrDefault
)
WindowManager.shared.openTab(payload: payload)
do {
try await DatabaseManager.shared.ensureConnected(connection)
} catch is CancellationError {
for window in WindowLifecycleMonitor.shared.windows(for: connection.id) {
window.close()
}
} catch {
Self.logger.error("Auto-reconnect failed for '\(connection.name, privacy: .public)': \(error.localizedDescription, privacy: .public)")
for window in WindowLifecycleMonitor.shared.windows(for: connection.id) {
window.close()
}
}
}
for window in NSApp.windows where Self.isWelcomeWindow(window) {
window.close()
}
if !NSApp.windows.contains(where: { Self.isMainWindow($0) && $0.isVisible }) {
self?.showWelcomeWindow()
}
}
}

// MARK: - Window Identification

internal static func isMainWindow(_ window: NSWindow) -> Bool {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate {
)
window.identifier = NSUserInterfaceItemIdentifier("main")
window.minSize = NSSize(width: 720, height: 480)
window.isRestorable = false
window.isRestorable = AppSettingsStorage.shared.loadGeneral().startupBehavior == .reopenLast
window.restorationClass = TabWindowRestoration.self
window.toolbarStyle = .unified
window.titleVisibility = .hidden
window.tabbingMode = .preferred
Expand Down Expand Up @@ -93,6 +94,11 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate {
fatalError("TabWindowController does not support NSCoder init")
}

override func encodeRestorableState(with coder: NSCoder) {
super.encodeRestorableState(with: coder)
coder.encode(payload.connectionId.uuidString as NSString, forKey: TabWindowRestoration.connectionIdKey)
}

// MARK: - NSWindowDelegate

func windowWillReturnFieldEditor(_ sender: NSWindow, to client: Any?) -> Any? {
Expand Down
81 changes: 81 additions & 0 deletions TablePro/Core/Services/Infrastructure/TabWindowRestoration.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
//
// TabWindowRestoration.swift
// TablePro
//

import AppKit
import os

@MainActor
final class TabWindowRestoration: NSObject, NSWindowRestoration {
private nonisolated static let logger = Logger(subsystem: "com.TablePro", category: "WindowRestoration")
nonisolated static let connectionIdKey = "TablePro.connectionId"

nonisolated static func restoreWindow(
withIdentifier identifier: NSUserInterfaceItemIdentifier,
state: NSCoder,
completionHandler: @escaping (NSWindow?, Error?) -> Void
) {
let uuidString = state.decodeObject(of: NSString.self, forKey: connectionIdKey) as String?

Task { @MainActor in
guard let uuidString,
let connectionId = UUID(uuidString: uuidString) else {
logger.warning("[restore] Missing or invalid connectionId in state")
completionHandler(nil, restorationError(.missingConnectionId))
return
}

let connections = ConnectionStorage.shared.loadConnections()
guard let connection = connections.first(where: { $0.id == connectionId }) else {
logger.warning("[restore] Connection \(uuidString, privacy: .public) no longer exists")
completionHandler(nil, restorationError(.connectionNotFound))
return
}

let payload = EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault)
WindowManager.shared.openTab(payload: payload)

let restored = NSApp.windows.first { candidate in
guard candidate.isVisible,
let controller = candidate.windowController as? TabWindowController
else { return false }
return controller.payload.connectionId == connection.id
}

if let restored {
logger.info(
"[restore] connId=\(connection.id, privacy: .public) name=\(connection.name, privacy: .public)"
)
completionHandler(restored, nil)

Task {
do {
try await DatabaseManager.shared.ensureConnected(connection)
} catch {
logger.error(
"[restore] connect failed for \(connection.id, privacy: .public): \(error.localizedDescription, privacy: .public)"
)
}
}
} else {
logger.error("[restore] WindowManager opened tab but no window found")
completionHandler(nil, restorationError(.windowNotCreated))
}
}
}

private enum RestorationFailure: Int {
case missingConnectionId = 1
case connectionNotFound = 2
case windowNotCreated = 3
}

private nonisolated static func restorationError(_ failure: RestorationFailure) -> NSError {
NSError(
domain: "com.TablePro.WindowRestoration",
code: failure.rawValue,
userInfo: [NSLocalizedDescriptionKey: "Window restoration failed (\(failure))"]
)
}
}
4 changes: 2 additions & 2 deletions TablePro/Core/Services/Query/RowOperationsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ final class RowOperationsManager {
) -> AddNewRowResult? {
guard sourceRowIndex >= 0, sourceRowIndex < tableRows.count else { return nil }

var newValues = tableRows.rows[sourceRowIndex].values
var newValues = Array(tableRows.rows[sourceRowIndex].values)

for pkColumn in changeManager.primaryKeyColumns {
if let pkIndex = columns.firstIndex(of: pkColumn), pkIndex < newValues.count {
Expand Down Expand Up @@ -110,7 +110,7 @@ final class RowOperationsManager {
insertedRowsToDelete.append(rowIndex)
} else if !changeManager.isRowDeleted(rowIndex) {
if rowIndex < tableRows.count {
existingRowsToDelete.append((rowIndex: rowIndex, originalRow: tableRows.rows[rowIndex].values))
existingRowsToDelete.append((rowIndex: rowIndex, originalRow: Array(tableRows.rows[rowIndex].values)))
}
}
}
Expand Down
40 changes: 0 additions & 40 deletions TablePro/Core/Storage/AppSettingsStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,6 @@ final class AppSettingsStorage {
static let sync = "com.TablePro.settings.sync"
static let terminal = "com.TablePro.settings.terminal"
static let mcp = "com.TablePro.settings.mcp"
static let lastConnectionId = "com.TablePro.settings.lastConnectionId"
static let lastOpenConnectionIds = "com.TablePro.settings.lastOpenConnectionIds"
static let hasCompletedOnboarding = "com.TablePro.settings.hasCompletedOnboarding"
}

Expand Down Expand Up @@ -152,44 +150,6 @@ final class AppSettingsStorage {
save(settings, key: Keys.mcp)
}

// MARK: - Last Connection (for Reopen Last Session)

/// Load the last used connection ID
func loadLastConnectionId() -> UUID? {
guard let uuidString = defaults.string(forKey: Keys.lastConnectionId) else {
return nil
}
return UUID(uuidString: uuidString)
}

/// Save the last used connection ID
func saveLastConnectionId(_ connectionId: UUID?) {
if let connectionId = connectionId {
defaults.set(connectionId.uuidString, forKey: Keys.lastConnectionId)
} else {
defaults.removeObject(forKey: Keys.lastConnectionId)
}
}

// MARK: - Last Open Connections (for multi-session restore)

/// Load all connection IDs that were open when the app last quit
func loadLastOpenConnectionIds() -> [UUID] {
guard let strings = defaults.stringArray(forKey: Keys.lastOpenConnectionIds) else {
return []
}
return strings.compactMap { UUID(uuidString: $0) }
}

/// Save all currently open connection IDs for restoration on next launch
func saveLastOpenConnectionIds(_ connectionIds: [UUID]) {
if connectionIds.isEmpty {
defaults.removeObject(forKey: Keys.lastOpenConnectionIds)
} else {
defaults.set(connectionIds.map(\.uuidString), forKey: Keys.lastOpenConnectionIds)
}
}

// MARK: - Last Selected Database (per connection)

func saveLastDatabase(_ database: String?, for connectionId: UUID) {
Expand Down
Loading
Loading