Skip to content

Commit 7e8bf7d

Browse files
committed
fix: reduce idle CPU usage from 79% to near zero (#394)
1 parent fbf0bc9 commit 7e8bf7d

10 files changed

Lines changed: 216 additions & 147 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515

1616
### Fixed
1717

18+
- High CPU usage (79%+) and energy consumption when idle (#394)
1819
- etcd connection failing with 404 when gRPC gateway uses a different API prefix (auto-detects `/v3/`, `/v3beta/`, `/v3alpha/`)
1920
- Data grid editing (delete rows, modify cells, add rows) not working in query tabs (#383)
2021

LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift

Lines changed: 18 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ public final class SuggestionController: NSWindowController {
3737
/// Holds the observer for the window resign notifications
3838
private var windowResignObserver: NSObjectProtocol?
3939
/// Closes autocomplete when first responder changes away from the active text view
40-
private var firstResponderObserver: NSObjectProtocol?
40+
private var firstResponderKVO: NSKeyValueObservation?
4141
private var localEventMonitor: Any?
4242
private var sizeObservers: Set<AnyCancellable> = []
4343

@@ -136,26 +136,21 @@ public final class SuggestionController: NSWindowController {
136136
self?.close()
137137
}
138138

139-
// Close when the active text view is removed (e.g., tab closed/switched)
140-
if let existingObserver = firstResponderObserver {
141-
NotificationCenter.default.removeObserver(existingObserver)
142-
}
143-
firstResponderObserver = NotificationCenter.default.addObserver(
144-
forName: NSWindow.didUpdateNotification,
145-
object: parentWindow,
146-
queue: .main
147-
) { [weak self] _ in
148-
guard let self else { return }
149-
guard let textView = self.model.activeTextView else {
150-
self.close()
151-
return
152-
}
153-
// Close if text view removed from window or lost first responder
154-
if textView.view.window == nil {
155-
self.close()
156-
} else if let firstResponder = textView.view.window?.firstResponder as? NSView,
157-
!firstResponder.isDescendant(of: textView.view) {
158-
self.close()
139+
// Close when first responder changes away from the active text view
140+
firstResponderKVO?.invalidate()
141+
firstResponderKVO = parentWindow.observe(\.firstResponder, options: [.new]) { [weak self] window, _ in
142+
DispatchQueue.main.async { [weak self] in
143+
guard let self else { return }
144+
guard let textView = self.model.activeTextView else {
145+
self.close()
146+
return
147+
}
148+
if textView.view.window == nil {
149+
self.close()
150+
} else if let firstResponder = window.firstResponder as? NSView,
151+
!firstResponder.isDescendant(of: textView.view) {
152+
self.close()
153+
}
159154
}
160155
}
161156

@@ -174,10 +169,8 @@ public final class SuggestionController: NSWindowController {
174169
windowResignObserver = nil
175170
}
176171

177-
if let observer = firstResponderObserver {
178-
NotificationCenter.default.removeObserver(observer)
179-
firstResponderObserver = nil
180-
}
172+
firstResponderKVO?.invalidate()
173+
firstResponderKVO = nil
181174

182175
if popover != nil {
183176
popover?.close()

TablePro/ContentView.swift

Lines changed: 58 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -102,59 +102,9 @@ struct ContentView: View {
102102
columnVisibility = .detailOnly
103103
}
104104
}
105-
.onChange(of: (payload?.connectionId ?? currentSession?.id).flatMap { DatabaseManager.shared.connectionStatusVersions[$0] }, initial: true) { _, _ in
106-
let sessions = DatabaseManager.shared.activeSessions
107-
let connectionId = payload?.connectionId ?? currentSession?.id ?? DatabaseManager.shared.currentSessionId
108-
guard let sid = connectionId else {
109-
if currentSession != nil { currentSession = nil }
110-
return
111-
}
112-
guard let newSession = sessions[sid] else {
113-
if currentSession?.id == sid {
114-
rightPanelState?.teardown()
115-
rightPanelState = nil
116-
sessionState?.coordinator.teardown()
117-
sessionState = nil
118-
currentSession = nil
119-
columnVisibility = .detailOnly
120-
AppState.shared.isConnected = false
121-
AppState.shared.safeModeLevel = .silent
122-
AppState.shared.editorLanguage = .sql
123-
AppState.shared.currentDatabaseType = nil
124-
AppState.shared.supportsDatabaseSwitching = true
125-
126-
// Close all native tab windows for this connection and
127-
// force AppKit to deallocate them instead of pooling.
128-
let tabbingId = "com.TablePro.main.\(sid.uuidString)"
129-
DispatchQueue.main.async {
130-
for window in NSApp.windows where window.tabbingIdentifier == tabbingId {
131-
window.isReleasedWhenClosed = true
132-
window.close()
133-
}
134-
}
135-
}
136-
return
137-
}
138-
if let existing = currentSession,
139-
existing.isContentViewEquivalent(to: newSession) {
140-
return
141-
}
142-
currentSession = newSession
143-
if rightPanelState == nil {
144-
rightPanelState = RightPanelState()
145-
}
146-
if sessionState == nil {
147-
sessionState = SessionStateFactory.create(
148-
connection: newSession.connection,
149-
payload: payload
150-
)
151-
}
152-
AppState.shared.isConnected = true
153-
AppState.shared.safeModeLevel = newSession.connection.safeModeLevel
154-
AppState.shared.editorLanguage = PluginManager.shared.editorLanguage(for: newSession.connection.type)
155-
AppState.shared.currentDatabaseType = newSession.connection.type
156-
AppState.shared.supportsDatabaseSwitching = PluginManager.shared.supportsDatabaseSwitching(
157-
for: newSession.connection.type)
105+
.task { handleConnectionStatusChange() }
106+
.onReceive(NotificationCenter.default.publisher(for: .connectionStatusDidChange)) { _ in
107+
handleConnectionStatusChange()
158108
}
159109
.onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification)) { notification in
160110
// Only process notifications for our own window to avoid every
@@ -378,6 +328,61 @@ struct ContentView: View {
378328
)
379329
}
380330

331+
// MARK: - Connection Status
332+
333+
private func handleConnectionStatusChange() {
334+
let sessions = DatabaseManager.shared.activeSessions
335+
let connectionId = payload?.connectionId ?? currentSession?.id ?? DatabaseManager.shared.currentSessionId
336+
guard let sid = connectionId else {
337+
if currentSession != nil { currentSession = nil }
338+
return
339+
}
340+
guard let newSession = sessions[sid] else {
341+
if currentSession?.id == sid {
342+
rightPanelState?.teardown()
343+
rightPanelState = nil
344+
sessionState?.coordinator.teardown()
345+
sessionState = nil
346+
currentSession = nil
347+
columnVisibility = .detailOnly
348+
AppState.shared.isConnected = false
349+
AppState.shared.safeModeLevel = .silent
350+
AppState.shared.editorLanguage = .sql
351+
AppState.shared.currentDatabaseType = nil
352+
AppState.shared.supportsDatabaseSwitching = true
353+
354+
let tabbingId = "com.TablePro.main.\(sid.uuidString)"
355+
DispatchQueue.main.async {
356+
for window in NSApp.windows where window.tabbingIdentifier == tabbingId {
357+
window.isReleasedWhenClosed = true
358+
window.close()
359+
}
360+
}
361+
}
362+
return
363+
}
364+
if let existing = currentSession,
365+
existing.isContentViewEquivalent(to: newSession) {
366+
return
367+
}
368+
currentSession = newSession
369+
if rightPanelState == nil {
370+
rightPanelState = RightPanelState()
371+
}
372+
if sessionState == nil {
373+
sessionState = SessionStateFactory.create(
374+
connection: newSession.connection,
375+
payload: payload
376+
)
377+
}
378+
AppState.shared.isConnected = true
379+
AppState.shared.safeModeLevel = newSession.connection.safeModeLevel
380+
AppState.shared.editorLanguage = PluginManager.shared.editorLanguage(for: newSession.connection.type)
381+
AppState.shared.currentDatabaseType = newSession.connection.type
382+
AppState.shared.supportsDatabaseSwitching = PluginManager.shared.supportsDatabaseSwitching(
383+
for: newSession.connection.type)
384+
}
385+
381386
// MARK: - Actions
382387

383388
private func connectToDatabase(_ connection: DatabaseConnection) {

TablePro/Core/AI/InlineSuggestionManager.swift

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@ final class InlineSuggestionManager {
5858
func install(controller: TextViewController, schemaProvider: SQLSchemaProvider?) {
5959
self.controller = controller
6060
self.schemaProvider = schemaProvider
61-
installScrollObserver()
6261
}
6362

6463
func editorDidFocus() {
@@ -87,11 +86,7 @@ final class InlineSuggestionManager {
8786
removeGhostLayer()
8887

8988
removeKeyEventMonitor()
90-
91-
if let observer = _scrollObserver.withLock({ $0 }) {
92-
NotificationCenter.default.removeObserver(observer)
93-
_scrollObserver.withLock { $0 = nil }
94-
}
89+
removeScrollObserver()
9590

9691
schemaProvider = nil
9792
controller = nil
@@ -337,6 +332,7 @@ final class InlineSuggestionManager {
337332

338333
textView.layer?.addSublayer(layer)
339334
ghostLayer = layer
335+
installScrollObserver()
340336
}
341337

342338
private func removeGhostLayer() {
@@ -346,28 +342,28 @@ final class InlineSuggestionManager {
346342

347343
// MARK: - Accept / Dismiss
348344

349-
/// Accept the current suggestion by inserting it at the cursor
350345
private func acceptSuggestion() {
351346
guard let suggestion = currentSuggestion,
352347
let textView = controller?.textView else { return }
353348

354349
let offset = suggestionOffset
355350
removeGhostLayer()
356351
currentSuggestion = nil
352+
removeScrollObserver()
357353

358354
textView.replaceCharacters(
359355
in: NSRange(location: offset, length: 0),
360356
with: suggestion
361357
)
362358
}
363359

364-
/// Dismiss the current suggestion without inserting
365360
func dismissSuggestion() {
366361
debounceTimer?.invalidate()
367362
currentTask?.cancel()
368363
currentTask = nil
369364
removeGhostLayer()
370365
currentSuggestion = nil
366+
removeScrollObserver()
371367
}
372368

373369
// MARK: - Key Event Monitor
@@ -421,6 +417,7 @@ final class InlineSuggestionManager {
421417
// MARK: - Scroll Observer
422418

423419
private func installScrollObserver() {
420+
guard _scrollObserver.withLock({ $0 }) == nil else { return }
424421
guard let scrollView = controller?.scrollView else { return }
425422
let contentView = scrollView.contentView
426423

@@ -430,15 +427,22 @@ final class InlineSuggestionManager {
430427
object: contentView,
431428
queue: .main
432429
) { [weak self] _ in
433-
guard self?.currentSuggestion != nil else { return }
434430
Task { @MainActor [weak self] in
435431
guard let self else { return }
436432
if let suggestion = self.currentSuggestion {
437-
// Reposition the ghost layer after scroll
438433
self.showGhostText(suggestion, at: self.suggestionOffset)
439434
}
440435
}
441436
}
442437
}
443438
}
439+
440+
private func removeScrollObserver() {
441+
_scrollObserver.withLock {
442+
if let observer = $0 {
443+
NotificationCenter.default.removeObserver(observer)
444+
}
445+
$0 = nil
446+
}
447+
}
444448
}

TablePro/Core/Database/DatabaseManager.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,12 +312,14 @@ final class DatabaseManager {
312312
private func setSession(_ session: ConnectionSession, for connectionId: UUID) {
313313
activeSessions[connectionId] = session
314314
connectionStatusVersions[connectionId, default: 0] &+= 1
315+
NotificationCenter.default.post(name: .connectionStatusDidChange, object: connectionId)
315316
}
316317

317318
/// Remove a session and clean up its per-connection version counter.
318319
private func removeSessionEntry(for connectionId: UUID) {
319320
activeSessions.removeValue(forKey: connectionId)
320321
connectionStatusVersions.removeValue(forKey: connectionId)
322+
NotificationCenter.default.post(name: .connectionStatusDidChange, object: connectionId)
321323
}
322324

323325
#if DEBUG

TablePro/Core/Services/Infrastructure/AppNotifications.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ extension Notification.Name {
1717
// MARK: - Connections
1818

1919
static let connectionUpdated = Notification.Name("connectionUpdated")
20+
static let connectionStatusDidChange = Notification.Name("connectionStatusDidChange")
2021
static let databaseDidConnect = Notification.Name("databaseDidConnect")
2122

2223
// MARK: - SQL Favorites

0 commit comments

Comments
 (0)