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

### Fixed

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public final class SuggestionController: NSWindowController {
/// Holds the observer for the window resign notifications
private var windowResignObserver: NSObjectProtocol?
/// Closes autocomplete when first responder changes away from the active text view
private var firstResponderObserver: NSObjectProtocol?
private var firstResponderKVO: NSKeyValueObservation?
private var localEventMonitor: Any?
private var sizeObservers: Set<AnyCancellable> = []

Expand Down Expand Up @@ -136,26 +136,22 @@ public final class SuggestionController: NSWindowController {
self?.close()
}

// Close when the active text view is removed (e.g., tab closed/switched)
if let existingObserver = firstResponderObserver {
NotificationCenter.default.removeObserver(existingObserver)
}
firstResponderObserver = NotificationCenter.default.addObserver(
forName: NSWindow.didUpdateNotification,
object: parentWindow,
queue: .main
) { [weak self] _ in
guard let self else { return }
guard let textView = self.model.activeTextView else {
self.close()
return
}
// Close if text view removed from window or lost first responder
if textView.view.window == nil {
self.close()
} else if let firstResponder = textView.view.window?.firstResponder as? NSView,
!firstResponder.isDescendant(of: textView.view) {
self.close()
// Close when first responder changes away from the active text view
firstResponderKVO?.invalidate()
firstResponderKVO = parentWindow.observe(\.firstResponder, options: [.new]) { [weak self] window, _ in
DispatchQueue.main.async { [weak self] in
guard let self else { return }
guard let textView = self.model.activeTextView else {
self.close()
return
}
if textView.view.window == nil {
self.close()
} else if textView.view.window === window,
let firstResponder = window.firstResponder as? NSView,
!firstResponder.isDescendant(of: textView.view) {
self.close()
}
}
}

Expand All @@ -174,10 +170,8 @@ public final class SuggestionController: NSWindowController {
windowResignObserver = nil
}

if let observer = firstResponderObserver {
NotificationCenter.default.removeObserver(observer)
firstResponderObserver = nil
}
firstResponderKVO?.invalidate()
firstResponderKVO = nil

if popover != nil {
popover?.close()
Expand Down
115 changes: 62 additions & 53 deletions TablePro/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ struct ContentView: View {
let payload: EditorTabPayload?

@State private var currentSession: ConnectionSession?
@State private var closingSessionId: UUID?
@State private var columnVisibility: NavigationSplitViewVisibility = .all
@State private var showNewConnectionSheet = false
@State private var showEditConnectionSheet = false
Expand Down Expand Up @@ -70,6 +71,7 @@ struct ContentView: View {
// Right sidebar toggle is handled by MainContentView (has the binding)
// Left sidebar toggle uses native NSSplitViewController.toggleSidebar via responder chain
.onChange(of: DatabaseManager.shared.currentSessionId, initial: true) { _, newSessionId in
guard closingSessionId == nil else { return }
let ourConnectionId = payload?.connectionId
if ourConnectionId != nil {
guard newSessionId == ourConnectionId else { return }
Expand Down Expand Up @@ -102,59 +104,9 @@ struct ContentView: View {
columnVisibility = .detailOnly
}
}
.onChange(of: (payload?.connectionId ?? currentSession?.id).flatMap { DatabaseManager.shared.connectionStatusVersions[$0] }, initial: true) { _, _ in
let sessions = DatabaseManager.shared.activeSessions
let connectionId = payload?.connectionId ?? currentSession?.id ?? DatabaseManager.shared.currentSessionId
guard let sid = connectionId else {
if currentSession != nil { currentSession = nil }
return
}
guard let newSession = sessions[sid] else {
if currentSession?.id == sid {
rightPanelState?.teardown()
rightPanelState = nil
sessionState?.coordinator.teardown()
sessionState = nil
currentSession = nil
columnVisibility = .detailOnly
AppState.shared.isConnected = false
AppState.shared.safeModeLevel = .silent
AppState.shared.editorLanguage = .sql
AppState.shared.currentDatabaseType = nil
AppState.shared.supportsDatabaseSwitching = true

// Close all native tab windows for this connection and
// force AppKit to deallocate them instead of pooling.
let tabbingId = "com.TablePro.main.\(sid.uuidString)"
DispatchQueue.main.async {
for window in NSApp.windows where window.tabbingIdentifier == tabbingId {
window.isReleasedWhenClosed = true
window.close()
}
}
}
return
}
if let existing = currentSession,
existing.isContentViewEquivalent(to: newSession) {
return
}
currentSession = newSession
if rightPanelState == nil {
rightPanelState = RightPanelState()
}
if sessionState == nil {
sessionState = SessionStateFactory.create(
connection: newSession.connection,
payload: payload
)
}
AppState.shared.isConnected = true
AppState.shared.safeModeLevel = newSession.connection.safeModeLevel
AppState.shared.editorLanguage = PluginManager.shared.editorLanguage(for: newSession.connection.type)
AppState.shared.currentDatabaseType = newSession.connection.type
AppState.shared.supportsDatabaseSwitching = PluginManager.shared.supportsDatabaseSwitching(
for: newSession.connection.type)
.task { handleConnectionStatusChange() }
.onReceive(NotificationCenter.default.publisher(for: .connectionStatusDidChange)) { _ in
handleConnectionStatusChange()
}
.onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification)) { notification in
// Only process notifications for our own window to avoid every
Expand Down Expand Up @@ -378,6 +330,63 @@ struct ContentView: View {
)
}

// MARK: - Connection Status

private func handleConnectionStatusChange() {
guard closingSessionId == nil else { return }
let sessions = DatabaseManager.shared.activeSessions
let connectionId = payload?.connectionId ?? currentSession?.id ?? DatabaseManager.shared.currentSessionId
guard let sid = connectionId else {
if currentSession != nil { currentSession = nil }
return
}
guard let newSession = sessions[sid] else {
if currentSession?.id == sid {
closingSessionId = sid
rightPanelState?.teardown()
rightPanelState = nil
sessionState?.coordinator.teardown()
sessionState = nil
currentSession = nil
columnVisibility = .detailOnly
AppState.shared.isConnected = false
AppState.shared.safeModeLevel = .silent
AppState.shared.editorLanguage = .sql
AppState.shared.currentDatabaseType = nil
AppState.shared.supportsDatabaseSwitching = true

let tabbingId = "com.TablePro.main.\(sid.uuidString)"
DispatchQueue.main.async {
for window in NSApp.windows where window.tabbingIdentifier == tabbingId {
window.isReleasedWhenClosed = true
window.close()
}
}
}
return
}
if let existing = currentSession,
existing.isContentViewEquivalent(to: newSession) {
return
}
currentSession = newSession
if rightPanelState == nil {
rightPanelState = RightPanelState()
}
if sessionState == nil {
sessionState = SessionStateFactory.create(
connection: newSession.connection,
payload: payload
)
}
AppState.shared.isConnected = true
AppState.shared.safeModeLevel = newSession.connection.safeModeLevel
AppState.shared.editorLanguage = PluginManager.shared.editorLanguage(for: newSession.connection.type)
AppState.shared.currentDatabaseType = newSession.connection.type
AppState.shared.supportsDatabaseSwitching = PluginManager.shared.supportsDatabaseSwitching(
for: newSession.connection.type)
}

// MARK: - Actions

private func connectToDatabase(_ connection: DatabaseConnection) {
Expand Down
24 changes: 14 additions & 10 deletions TablePro/Core/AI/InlineSuggestionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@ final class InlineSuggestionManager {
func install(controller: TextViewController, schemaProvider: SQLSchemaProvider?) {
self.controller = controller
self.schemaProvider = schemaProvider
installScrollObserver()
}

func editorDidFocus() {
Expand Down Expand Up @@ -87,11 +86,7 @@ final class InlineSuggestionManager {
removeGhostLayer()

removeKeyEventMonitor()

if let observer = _scrollObserver.withLock({ $0 }) {
NotificationCenter.default.removeObserver(observer)
_scrollObserver.withLock { $0 = nil }
}
removeScrollObserver()

schemaProvider = nil
controller = nil
Expand Down Expand Up @@ -337,6 +332,7 @@ final class InlineSuggestionManager {

textView.layer?.addSublayer(layer)
ghostLayer = layer
installScrollObserver()
}

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

// MARK: - Accept / Dismiss

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

let offset = suggestionOffset
removeGhostLayer()
currentSuggestion = nil
removeScrollObserver()

textView.replaceCharacters(
in: NSRange(location: offset, length: 0),
with: suggestion
)
}

/// Dismiss the current suggestion without inserting
func dismissSuggestion() {
debounceTimer?.invalidate()
currentTask?.cancel()
currentTask = nil
removeGhostLayer()
currentSuggestion = nil
removeScrollObserver()
}

// MARK: - Key Event Monitor
Expand Down Expand Up @@ -421,6 +417,7 @@ final class InlineSuggestionManager {
// MARK: - Scroll Observer

private func installScrollObserver() {
guard _scrollObserver.withLock({ $0 }) == nil else { return }
guard let scrollView = controller?.scrollView else { return }
let contentView = scrollView.contentView

Expand All @@ -430,15 +427,22 @@ final class InlineSuggestionManager {
object: contentView,
queue: .main
) { [weak self] _ in
guard self?.currentSuggestion != nil else { return }
Task { @MainActor [weak self] in
guard let self else { return }
if let suggestion = self.currentSuggestion {
// Reposition the ghost layer after scroll
self.showGhostText(suggestion, at: self.suggestionOffset)
}
}
}
}
}

private func removeScrollObserver() {
_scrollObserver.withLock {
if let observer = $0 {
NotificationCenter.default.removeObserver(observer)
}
$0 = nil
}
}
}
2 changes: 2 additions & 0 deletions TablePro/Core/Database/DatabaseManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -312,12 +312,14 @@ final class DatabaseManager {
private func setSession(_ session: ConnectionSession, for connectionId: UUID) {
activeSessions[connectionId] = session
connectionStatusVersions[connectionId, default: 0] &+= 1
NotificationCenter.default.post(name: .connectionStatusDidChange, object: connectionId)
}

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

#if DEBUG
Expand Down
23 changes: 18 additions & 5 deletions TablePro/Core/Services/Formatting/SQLFormatterService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,13 +108,13 @@ struct SQLFormatterService: SQLFormatterProtocol {
/// Get or create the keyword uppercasing regex for a given database type
private static func keywordRegex(for dialect: DatabaseType) -> NSRegularExpression? {
keywordRegexLock.lock()
defer { keywordRegexLock.unlock() }

if let cached = keywordRegexCache[dialect] {
keywordRegexLock.unlock()
return cached
}
keywordRegexLock.unlock()

let provider = MainActor.assumeIsolated { SQLDialectFactory.createDialect(for: dialect) }
let provider = resolveDialectProvider(for: dialect)
let allKeywords = provider.keywords.union(provider.functions).union(provider.dataTypes)
let escapedKeywords = allKeywords.map { NSRegularExpression.escapedPattern(for: $0) }
let pattern = "\\b(\(escapedKeywords.joined(separator: "|")))\\b"
Expand All @@ -123,10 +123,24 @@ struct SQLFormatterService: SQLFormatterProtocol {
return nil
}

keywordRegexLock.lock()
defer { keywordRegexLock.unlock() }
if let cached = keywordRegexCache[dialect] {
return cached
}
keywordRegexCache[dialect] = regex
return regex
}

private static func resolveDialectProvider(for dialect: DatabaseType) -> SQLDialectProvider {
if Thread.isMainThread {
return MainActor.assumeIsolated { SQLDialectFactory.createDialect(for: dialect) }
}
return DispatchQueue.main.sync {
MainActor.assumeIsolated { SQLDialectFactory.createDialect(for: dialect) }
}
}

// MARK: - Public API

func format(
Expand All @@ -152,8 +166,7 @@ struct SQLFormatterService: SQLFormatterProtocol {
throw SQLFormatterError.invalidCursorPosition(cursor, max: sqlLength)
}

// Get dialect provider
let dialectProvider = MainActor.assumeIsolated { SQLDialectFactory.createDialect(for: dialect) }
let dialectProvider = Self.resolveDialectProvider(for: dialect)

// Format the SQL
let formatted = formatSQL(sql, dialect: dialectProvider, databaseType: dialect, options: options)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ final class AnalyticsService {
while !Task.isCancelled {
await self?.sendHeartbeat()
try? await Task.sleep(for: .seconds(self?.heartbeatInterval ?? 86_400))
guard self != nil else { return }
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ extension Notification.Name {
// MARK: - Connections

static let connectionUpdated = Notification.Name("connectionUpdated")
static let connectionStatusDidChange = Notification.Name("connectionStatusDidChange")
static let databaseDidConnect = Notification.Name("databaseDidConnect")

// MARK: - SQL Favorites
Expand Down
1 change: 1 addition & 0 deletions TablePro/Core/Services/Licensing/LicenseManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ final class LicenseManager {

while !Task.isCancelled {
try? await Task.sleep(for: .seconds(self?.revalidationInterval ?? 604_800))
guard self != nil else { return }
await self?.revalidate()
}
}
Expand Down
Loading
Loading