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

### Changed

- Split DatabaseManager.sessionVersion into fine-grained connectionListVersion and connectionStatusVersion to reduce cascade re-renders
- Extract AppState property reads into local lets in view bodies for explicit granular observation tracking
- Reorganized project directory structure: Services, Utilities, Models split into domain-specific subdirectories
- Database driver code moved from monolithic app binary into independent plugin bundles under `Plugins/`

Expand Down
2 changes: 1 addition & 1 deletion TablePro/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ struct ContentView: View {
columnVisibility = .detailOnly
}
}
.onChange(of: DatabaseManager.shared.sessionVersion, initial: true) { _, _ in
.onChange(of: DatabaseManager.shared.connectionStatusVersion, initial: true) { _, _ in
let sessions = DatabaseManager.shared.activeSessions
let connectionId = payload?.connectionId ?? currentSession?.id ?? DatabaseManager.shared.currentSessionId
guard let sid = connectionId else {
Expand Down
43 changes: 30 additions & 13 deletions TablePro/Core/AI/InlineSuggestionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ final class InlineSuggestionManager {
private var currentTask: Task<Void, Never>?
private let _keyEventMonitor = OSAllocatedUnfairLock<Any?>(initialState: nil)
private let _scrollObserver = OSAllocatedUnfairLock<Any?>(initialState: nil)
private(set) var isEditorFocused = false

deinit {
if let monitor = _keyEventMonitor.withLock({ $0 }) { NSEvent.removeMonitor(monitor) }
Expand Down Expand Up @@ -57,25 +58,35 @@ final class InlineSuggestionManager {
func install(controller: TextViewController, schemaProvider: SQLSchemaProvider?) {
self.controller = controller
self.schemaProvider = schemaProvider
installKeyEventMonitor()
installScrollObserver()
}

func editorDidFocus() {
guard !isEditorFocused else { return }
isEditorFocused = true
installKeyEventMonitor()
}

func editorDidBlur() {
guard isEditorFocused else { return }
isEditorFocused = false
dismissSuggestion()
removeKeyEventMonitor()
}

/// Remove all observers and layers
func uninstall() {
guard !isUninstalled else { return }
isUninstalled = true
isEditorFocused = false

debounceTimer?.invalidate()
debounceTimer = nil
currentTask?.cancel()
currentTask = nil
removeGhostLayer()

if let monitor = _keyEventMonitor.withLock({ $0 }) {
NSEvent.removeMonitor(monitor)
_keyEventMonitor.withLock { $0 = nil }
}
removeKeyEventMonitor()

if let observer = _scrollObserver.withLock({ $0 }) {
NotificationCenter.default.removeObserver(observer)
Expand Down Expand Up @@ -362,13 +373,14 @@ final class InlineSuggestionManager {
// MARK: - Key Event Monitor

private func installKeyEventMonitor() {
removeKeyEventMonitor()
_keyEventMonitor.withLock { $0 = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
guard let self else { return event }
guard let self, self.isEditorFocused else { return event }

guard AppSettingsManager.shared.ai.inlineSuggestEnabled else { return event }

// Only intercept when a suggestion is active
guard self.currentSuggestion != nil else { return event }

// Only intercept when our text view is the first responder
guard let textView = self.controller?.textView,
event.window === textView.window,
textView.window?.firstResponder === textView else { return event }
Expand All @@ -378,25 +390,30 @@ final class InlineSuggestionManager {
Task { @MainActor [weak self] in
self?.acceptSuggestion()
}
return nil // Consume the event
return nil

case 53: // Escape — dismiss suggestion
Task { @MainActor [weak self] in
self?.dismissSuggestion()
}
return nil // Consume the event
return nil

default:
// Any other key — dismiss and pass through
// The text change handler will schedule a new suggestion
Task { @MainActor [weak self] in
self?.dismissSuggestion()
}
return event // Pass through
return event
}
} }
}

private func removeKeyEventMonitor() {
_keyEventMonitor.withLock {
if let monitor = $0 { NSEvent.removeMonitor(monitor) }
$0 = nil
}
}

// MARK: - Scroll Observer

private func installScrollObserver() {
Expand Down
12 changes: 10 additions & 2 deletions TablePro/Core/Autocomplete/SQLContextAnalyzer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -857,10 +857,18 @@ final class SQLContextAnalyzer {
return .select // Column context
}

let upper = textBeforeCursor.uppercased()
// Window to last N chars to avoid O(n) regex on large queries
let windowSize = 5000 // Also referenced by SQLContextAnalyzerWindowingTests
let nsText = textBeforeCursor as NSString
let windowedText: String
if nsText.length > windowSize {
windowedText = nsText.substring(from: nsText.length - windowSize)
} else {
windowedText = textBeforeCursor
}

// Remove string literals and comments for analysis
let cleaned = removeStringsAndComments(from: upper)
let cleaned = removeStringsAndComments(from: windowedText)

// Run regex-based clause detection FIRST — DDL contexts (CREATE TABLE,
// ALTER TABLE, etc.) must take priority over function-arg detection,
Expand Down
18 changes: 14 additions & 4 deletions TablePro/Core/Database/DatabaseManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,22 @@ final class DatabaseManager {

/// All active connection sessions
private(set) var activeSessions: [UUID: ConnectionSession] = [:] {
didSet { sessionVersion &+= 1 }
didSet {
if Set(oldValue.keys) != Set(activeSessions.keys) {
connectionListVersion &+= 1
}
connectionStatusVersion &+= 1
}
}

/// Monotonically increasing counter; incremented on every mutation of activeSessions.
/// Used by views for `.onChange` since `[UUID: ConnectionSession]` is not `Equatable`.
private(set) var sessionVersion: Int = 0
/// Incremented only when sessions are added or removed (keys change).
private(set) var connectionListVersion: Int = 0

/// Incremented when any session state changes (status, driver, metadata, etc.).
private(set) var connectionStatusVersion: Int = 0

/// Backward-compatible alias for views not yet migrated to fine-grained counters.
var sessionVersion: Int { connectionStatusVersion }

/// Currently selected session ID (displayed in UI)
private(set) var currentSessionId: UUID?
Expand Down
22 changes: 15 additions & 7 deletions TablePro/Core/SSH/SSHTunnelManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,26 +71,28 @@ actor SSHTunnelManager {
private func startHealthCheck() {
healthCheckTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(30))
try? await Task.sleep(for: .seconds(90))
guard !Task.isCancelled else { break }
await self?.checkTunnelHealth()
}
}
}

/// Check if tunnels are still alive and attempt reconnection if needed
private func checkTunnelHealth() async {
for (connectionId, tunnel) in tunnels {
// Check if process is still running
if !tunnel.process.isRunning {
Self.logger.warning("SSH tunnel for \(connectionId) died, attempting reconnection...")

// Notify DatabaseManager to reconnect
await notifyTunnelDied(connectionId: connectionId)
Self.logger.warning("SSH tunnel for \(connectionId) died (detected by fallback health check)")
await handleTunnelDeath(connectionId: connectionId)
}
}
}

private func handleTunnelDeath(connectionId: UUID) async {
guard tunnels.removeValue(forKey: connectionId) != nil else { return }
Self.processRegistry.withLock { $0.removeValue(forKey: connectionId) }
await notifyTunnelDied(connectionId: connectionId)
}

/// Notify that a tunnel has died (DatabaseManager should handle reconnection)
private func notifyTunnelDied(connectionId: UUID) async {
await MainActor.run {
Expand Down Expand Up @@ -204,6 +206,12 @@ actor SSHTunnelManager {
tunnels[connectionId] = tunnel
Self.processRegistry.withLock { $0[connectionId] = launch.process }

launch.process.terminationHandler = { [weak self] _ in
Task { [weak self] in
await self?.handleTunnelDeath(connectionId: connectionId)
}
}

return localPort
}

Expand Down
5 changes: 5 additions & 0 deletions TablePro/Core/Services/Infrastructure/UpdaterBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ final class UpdaterBridge {

@ObservationIgnored private var observation: NSKeyValueObservation?

deinit {
observation?.invalidate()
observation = nil
}

init() {
controller = SPUStandardUpdaterController(
startingUpdater: true,
Expand Down
24 changes: 16 additions & 8 deletions TablePro/Core/Services/Query/RowOperationsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -314,26 +314,34 @@ final class RowOperationsManager {
}

let indicesToCopy = isTruncated ? Array(sortedIndices.prefix(Self.maxClipboardRows)) : sortedIndices
var lines: [String] = []

// Add header row if requested
let columnCount = resultRows.first?.values.count ?? 1
let estimatedRowLength = columnCount * 12
var result = ""
result.reserveCapacity(indicesToCopy.count * estimatedRowLength)

if includeHeaders, !columns.isEmpty {
lines.append(columns.joined(separator: "\t"))
for (colIdx, col) in columns.enumerated() {
if colIdx > 0 { result.append("\t") }
result.append(col)
}
}

for rowIndex in indicesToCopy {
guard rowIndex < resultRows.count else { continue }
let row = resultRows[rowIndex]
let line = row.values.map { $0 ?? "NULL" }.joined(separator: "\t")
lines.append(line)
if !result.isEmpty { result.append("\n") }
for (colIdx, value) in row.values.enumerated() {
if colIdx > 0 { result.append("\t") }
result.append(value ?? "NULL")
}
}

if isTruncated {
lines.append("(truncated, showing first \(Self.maxClipboardRows) of \(totalSelected) rows)")
result.append("\n(truncated, showing first \(Self.maxClipboardRows) of \(totalSelected) rows)")
}

let text = lines.joined(separator: "\n")
ClipboardService.shared.writeText(text)
ClipboardService.shared.writeText(result)
}

// MARK: - Paste Rows
Expand Down
50 changes: 33 additions & 17 deletions TablePro/Core/Vim/VimKeyInterceptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ final class VimKeyInterceptor {
private let _monitor = OSAllocatedUnfairLock<Any?>(initialState: nil)
private weak var controller: TextViewController?
private let _popupCloseObserver = OSAllocatedUnfairLock<Any?>(initialState: nil)
private(set) var isEditorFocused = false

deinit {
if let monitor = _monitor.withLock({ $0 }) { NSEvent.removeMonitor(monitor) }
Expand All @@ -28,22 +29,11 @@ final class VimKeyInterceptor {
self.inlineSuggestionManager = inlineSuggestionManager
}

/// Install the key event monitor
/// Install the interceptor on a controller (does not install the event monitor until editor is focused)
func install(controller: TextViewController) {
self.controller = controller
uninstall()

_monitor.withLock {
$0 = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
guard let self else { return event }
return self.handleKeyEvent(event)
}
}

// Observe autocomplete popup close. When SuggestionController's popup
// consumes Escape (closes itself), we also need to exit Insert/Visual mode.
// queue: .main → handler runs synchronously when posted from main thread,
// so NSApp.currentEvent is still the Escape keyDown event.
_popupCloseObserver.withLock { $0 = NotificationCenter.default.addObserver(
forName: NSWindow.willCloseNotification,
object: nil,
Expand All @@ -67,18 +57,44 @@ final class VimKeyInterceptor {
} }
}

/// Remove the key event monitor
func editorDidFocus() {
guard !isEditorFocused else { return }
isEditorFocused = true
installMonitor()
}

func editorDidBlur() {
guard isEditorFocused else { return }
isEditorFocused = false
removeMonitor()
}

/// Remove all monitors and observers
func uninstall() {
_monitor.withLock {
if let monitor = $0 { NSEvent.removeMonitor(monitor) }
$0 = nil
}
isEditorFocused = false
removeMonitor()
_popupCloseObserver.withLock {
if let observer = $0 { NotificationCenter.default.removeObserver(observer) }
$0 = nil
}
}

private func installMonitor() {
_monitor.withLock {
$0 = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
guard let self, self.isEditorFocused else { return event }
return self.handleKeyEvent(event)
}
}
}

private func removeMonitor() {
_monitor.withLock {
if let monitor = $0 { NSEvent.removeMonitor(monitor) }
$0 = nil
}
}

/// Arrow key Unicode scalars → Vim motion characters
private static let arrowToVimKey: [UInt32: Character] = [
0xF700: "k", // Up
Expand Down
Loading