Skip to content

Commit 0192d5b

Browse files
authored
Merge pull request #224 from datlechin/perf/cpu-and-lag-fixes
perf: fix idle CPU usage, view cascade re-renders, and editor lag
2 parents d328e9c + 97cb6e3 commit 0192d5b

34 files changed

Lines changed: 2447 additions & 166 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2424

2525
### Changed
2626

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

TablePro/ContentView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ struct ContentView: View {
126126
columnVisibility = .detailOnly
127127
}
128128
}
129-
.onChange(of: DatabaseManager.shared.sessionVersion, initial: true) { _, _ in
129+
.onChange(of: DatabaseManager.shared.connectionStatusVersion, initial: true) { _, _ in
130130
let sessions = DatabaseManager.shared.activeSessions
131131
let connectionId = payload?.connectionId ?? currentSession?.id ?? DatabaseManager.shared.currentSessionId
132132
guard let sid = connectionId else {

TablePro/Core/AI/InlineSuggestionManager.swift

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ final class InlineSuggestionManager {
2424
private var currentTask: Task<Void, Never>?
2525
private let _keyEventMonitor = OSAllocatedUnfairLock<Any?>(initialState: nil)
2626
private let _scrollObserver = OSAllocatedUnfairLock<Any?>(initialState: nil)
27+
private(set) var isEditorFocused = false
2728

2829
deinit {
2930
if let monitor = _keyEventMonitor.withLock({ $0 }) { NSEvent.removeMonitor(monitor) }
@@ -57,25 +58,35 @@ final class InlineSuggestionManager {
5758
func install(controller: TextViewController, schemaProvider: SQLSchemaProvider?) {
5859
self.controller = controller
5960
self.schemaProvider = schemaProvider
60-
installKeyEventMonitor()
6161
installScrollObserver()
6262
}
6363

64+
func editorDidFocus() {
65+
guard !isEditorFocused else { return }
66+
isEditorFocused = true
67+
installKeyEventMonitor()
68+
}
69+
70+
func editorDidBlur() {
71+
guard isEditorFocused else { return }
72+
isEditorFocused = false
73+
dismissSuggestion()
74+
removeKeyEventMonitor()
75+
}
76+
6477
/// Remove all observers and layers
6578
func uninstall() {
6679
guard !isUninstalled else { return }
6780
isUninstalled = true
81+
isEditorFocused = false
6882

6983
debounceTimer?.invalidate()
7084
debounceTimer = nil
7185
currentTask?.cancel()
7286
currentTask = nil
7387
removeGhostLayer()
7488

75-
if let monitor = _keyEventMonitor.withLock({ $0 }) {
76-
NSEvent.removeMonitor(monitor)
77-
_keyEventMonitor.withLock { $0 = nil }
78-
}
89+
removeKeyEventMonitor()
7990

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

364375
private func installKeyEventMonitor() {
376+
removeKeyEventMonitor()
365377
_keyEventMonitor.withLock { $0 = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
366-
guard let self else { return event }
378+
guard let self, self.isEditorFocused else { return event }
379+
380+
guard AppSettingsManager.shared.ai.inlineSuggestEnabled else { return event }
367381

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

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

383395
case 53: // Escape — dismiss suggestion
384396
Task { @MainActor [weak self] in
385397
self?.dismissSuggestion()
386398
}
387-
return nil // Consume the event
399+
return nil
388400

389401
default:
390-
// Any other key — dismiss and pass through
391-
// The text change handler will schedule a new suggestion
392402
Task { @MainActor [weak self] in
393403
self?.dismissSuggestion()
394404
}
395-
return event // Pass through
405+
return event
396406
}
397407
} }
398408
}
399409

410+
private func removeKeyEventMonitor() {
411+
_keyEventMonitor.withLock {
412+
if let monitor = $0 { NSEvent.removeMonitor(monitor) }
413+
$0 = nil
414+
}
415+
}
416+
400417
// MARK: - Scroll Observer
401418

402419
private func installScrollObserver() {

TablePro/Core/Autocomplete/SQLContextAnalyzer.swift

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -857,10 +857,18 @@ final class SQLContextAnalyzer {
857857
return .select // Column context
858858
}
859859

860-
let upper = textBeforeCursor.uppercased()
860+
// Window to last N chars to avoid O(n) regex on large queries
861+
let windowSize = 5000 // Also referenced by SQLContextAnalyzerWindowingTests
862+
let nsText = textBeforeCursor as NSString
863+
let windowedText: String
864+
if nsText.length > windowSize {
865+
windowedText = nsText.substring(from: nsText.length - windowSize)
866+
} else {
867+
windowedText = textBeforeCursor
868+
}
861869

862870
// Remove string literals and comments for analysis
863-
let cleaned = removeStringsAndComments(from: upper)
871+
let cleaned = removeStringsAndComments(from: windowedText)
864872

865873
// Run regex-based clause detection FIRST — DDL contexts (CREATE TABLE,
866874
// ALTER TABLE, etc.) must take priority over function-arg detection,

TablePro/Core/Database/DatabaseManager.swift

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,22 @@ final class DatabaseManager {
1717

1818
/// All active connection sessions
1919
private(set) var activeSessions: [UUID: ConnectionSession] = [:] {
20-
didSet { sessionVersion &+= 1 }
20+
didSet {
21+
if Set(oldValue.keys) != Set(activeSessions.keys) {
22+
connectionListVersion &+= 1
23+
}
24+
connectionStatusVersion &+= 1
25+
}
2126
}
2227

23-
/// Monotonically increasing counter; incremented on every mutation of activeSessions.
24-
/// Used by views for `.onChange` since `[UUID: ConnectionSession]` is not `Equatable`.
25-
private(set) var sessionVersion: Int = 0
28+
/// Incremented only when sessions are added or removed (keys change).
29+
private(set) var connectionListVersion: Int = 0
30+
31+
/// Incremented when any session state changes (status, driver, metadata, etc.).
32+
private(set) var connectionStatusVersion: Int = 0
33+
34+
/// Backward-compatible alias for views not yet migrated to fine-grained counters.
35+
var sessionVersion: Int { connectionStatusVersion }
2636

2737
/// Currently selected session ID (displayed in UI)
2838
private(set) var currentSessionId: UUID?

TablePro/Core/SSH/SSHTunnelManager.swift

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -71,26 +71,28 @@ actor SSHTunnelManager {
7171
private func startHealthCheck() {
7272
healthCheckTask = Task { [weak self] in
7373
while !Task.isCancelled {
74-
try? await Task.sleep(for: .seconds(30))
74+
try? await Task.sleep(for: .seconds(90))
7575
guard !Task.isCancelled else { break }
7676
await self?.checkTunnelHealth()
7777
}
7878
}
7979
}
8080

81-
/// Check if tunnels are still alive and attempt reconnection if needed
8281
private func checkTunnelHealth() async {
8382
for (connectionId, tunnel) in tunnels {
84-
// Check if process is still running
8583
if !tunnel.process.isRunning {
86-
Self.logger.warning("SSH tunnel for \(connectionId) died, attempting reconnection...")
87-
88-
// Notify DatabaseManager to reconnect
89-
await notifyTunnelDied(connectionId: connectionId)
84+
Self.logger.warning("SSH tunnel for \(connectionId) died (detected by fallback health check)")
85+
await handleTunnelDeath(connectionId: connectionId)
9086
}
9187
}
9288
}
9389

90+
private func handleTunnelDeath(connectionId: UUID) async {
91+
guard tunnels.removeValue(forKey: connectionId) != nil else { return }
92+
Self.processRegistry.withLock { $0.removeValue(forKey: connectionId) }
93+
await notifyTunnelDied(connectionId: connectionId)
94+
}
95+
9496
/// Notify that a tunnel has died (DatabaseManager should handle reconnection)
9597
private func notifyTunnelDied(connectionId: UUID) async {
9698
await MainActor.run {
@@ -204,6 +206,12 @@ actor SSHTunnelManager {
204206
tunnels[connectionId] = tunnel
205207
Self.processRegistry.withLock { $0[connectionId] = launch.process }
206208

209+
launch.process.terminationHandler = { [weak self] _ in
210+
Task { [weak self] in
211+
await self?.handleTunnelDeath(connectionId: connectionId)
212+
}
213+
}
214+
207215
return localPort
208216
}
209217

TablePro/Core/Services/Infrastructure/UpdaterBridge.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ final class UpdaterBridge {
1616

1717
@ObservationIgnored private var observation: NSKeyValueObservation?
1818

19+
deinit {
20+
observation?.invalidate()
21+
observation = nil
22+
}
23+
1924
init() {
2025
controller = SPUStandardUpdaterController(
2126
startingUpdater: true,

TablePro/Core/Services/Query/RowOperationsManager.swift

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -314,26 +314,34 @@ final class RowOperationsManager {
314314
}
315315

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

319-
// Add header row if requested
318+
let columnCount = resultRows.first?.values.count ?? 1
319+
let estimatedRowLength = columnCount * 12
320+
var result = ""
321+
result.reserveCapacity(indicesToCopy.count * estimatedRowLength)
322+
320323
if includeHeaders, !columns.isEmpty {
321-
lines.append(columns.joined(separator: "\t"))
324+
for (colIdx, col) in columns.enumerated() {
325+
if colIdx > 0 { result.append("\t") }
326+
result.append(col)
327+
}
322328
}
323329

324330
for rowIndex in indicesToCopy {
325331
guard rowIndex < resultRows.count else { continue }
326332
let row = resultRows[rowIndex]
327-
let line = row.values.map { $0 ?? "NULL" }.joined(separator: "\t")
328-
lines.append(line)
333+
if !result.isEmpty { result.append("\n") }
334+
for (colIdx, value) in row.values.enumerated() {
335+
if colIdx > 0 { result.append("\t") }
336+
result.append(value ?? "NULL")
337+
}
329338
}
330339

331340
if isTruncated {
332-
lines.append("(truncated, showing first \(Self.maxClipboardRows) of \(totalSelected) rows)")
341+
result.append("\n(truncated, showing first \(Self.maxClipboardRows) of \(totalSelected) rows)")
333342
}
334343

335-
let text = lines.joined(separator: "\n")
336-
ClipboardService.shared.writeText(text)
344+
ClipboardService.shared.writeText(result)
337345
}
338346

339347
// MARK: - Paste Rows

TablePro/Core/Vim/VimKeyInterceptor.swift

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ final class VimKeyInterceptor {
1717
private let _monitor = OSAllocatedUnfairLock<Any?>(initialState: nil)
1818
private weak var controller: TextViewController?
1919
private let _popupCloseObserver = OSAllocatedUnfairLock<Any?>(initialState: nil)
20+
private(set) var isEditorFocused = false
2021

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

31-
/// Install the key event monitor
32+
/// Install the interceptor on a controller (does not install the event monitor until editor is focused)
3233
func install(controller: TextViewController) {
3334
self.controller = controller
3435
uninstall()
3536

36-
_monitor.withLock {
37-
$0 = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
38-
guard let self else { return event }
39-
return self.handleKeyEvent(event)
40-
}
41-
}
42-
43-
// Observe autocomplete popup close. When SuggestionController's popup
44-
// consumes Escape (closes itself), we also need to exit Insert/Visual mode.
45-
// queue: .main → handler runs synchronously when posted from main thread,
46-
// so NSApp.currentEvent is still the Escape keyDown event.
4737
_popupCloseObserver.withLock { $0 = NotificationCenter.default.addObserver(
4838
forName: NSWindow.willCloseNotification,
4939
object: nil,
@@ -67,18 +57,44 @@ final class VimKeyInterceptor {
6757
} }
6858
}
6959

70-
/// Remove the key event monitor
60+
func editorDidFocus() {
61+
guard !isEditorFocused else { return }
62+
isEditorFocused = true
63+
installMonitor()
64+
}
65+
66+
func editorDidBlur() {
67+
guard isEditorFocused else { return }
68+
isEditorFocused = false
69+
removeMonitor()
70+
}
71+
72+
/// Remove all monitors and observers
7173
func uninstall() {
72-
_monitor.withLock {
73-
if let monitor = $0 { NSEvent.removeMonitor(monitor) }
74-
$0 = nil
75-
}
74+
isEditorFocused = false
75+
removeMonitor()
7676
_popupCloseObserver.withLock {
7777
if let observer = $0 { NotificationCenter.default.removeObserver(observer) }
7878
$0 = nil
7979
}
8080
}
8181

82+
private func installMonitor() {
83+
_monitor.withLock {
84+
$0 = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
85+
guard let self, self.isEditorFocused else { return event }
86+
return self.handleKeyEvent(event)
87+
}
88+
}
89+
}
90+
91+
private func removeMonitor() {
92+
_monitor.withLock {
93+
if let monitor = $0 { NSEvent.removeMonitor(monitor) }
94+
$0 = nil
95+
}
96+
}
97+
8298
/// Arrow key Unicode scalars → Vim motion characters
8399
private static let arrowToVimKey: [UInt32: Character] = [
84100
0xF700: "k", // Up

0 commit comments

Comments
 (0)