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 @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Fix excessive idle ping traffic (40-50 SELECT 1/sec) caused by AsyncStream iterator recreation and orphaned monitor tasks
- Fix Cmd+W save not persisting data grid changes
- Show error feedback when connection fails from Connection Switcher
- Move theme, AI chat, and SSH config file loading off the main thread
Expand Down
9 changes: 6 additions & 3 deletions TablePro/Core/AI/GeminiProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,12 +107,13 @@ final class GeminiProvider: AIProvider {
}

func fetchAvailableModels() async throws -> [String] {
guard let url = URL(string: "\(endpoint)/v1beta/models?key=\(apiKey)") else {
guard let url = URL(string: "\(endpoint)/v1beta/models") else {
throw AIProviderError.invalidEndpoint(endpoint)
}

var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue(apiKey, forHTTPHeaderField: "x-goog-api-key")

let (data, response) = try await session.data(for: request)

Expand Down Expand Up @@ -147,12 +148,13 @@ final class GeminiProvider: AIProvider {
}

func testConnection() async throws -> Bool {
guard let url = URL(string: "\(endpoint)/v1beta/models?key=\(apiKey)") else {
guard let url = URL(string: "\(endpoint)/v1beta/models") else {
throw AIProviderError.invalidEndpoint(endpoint)
}

var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue(apiKey, forHTTPHeaderField: "x-goog-api-key")

let (data, response) = try await session.data(for: request)

Expand Down Expand Up @@ -183,14 +185,15 @@ final class GeminiProvider: AIProvider {
) throws -> URLRequest {
guard let encodedModel = model.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed),
let url = URL(
string: "\(endpoint)/v1beta/models/\(encodedModel):streamGenerateContent?alt=sse&key=\(apiKey)"
string: "\(endpoint)/v1beta/models/\(encodedModel):streamGenerateContent?alt=sse"
) else {
throw AIProviderError.invalidEndpoint(endpoint)
}

var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(apiKey, forHTTPHeaderField: "x-goog-api-key")

var body: [String: Any] = [
"generationConfig": ["maxOutputTokens": 8_192]
Expand Down
5 changes: 5 additions & 0 deletions TablePro/Core/AI/InlineSuggestionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ final class InlineSuggestionManager {
/// Shared schema provider (passed from coordinator, avoids duplicate schema fetches)
private var schemaProvider: SQLSchemaProvider?

/// Connection-level AI policy — blocks suggestions when `.never`
var connectionPolicy: AIConnectionPolicy?

/// Guard against double-uninstall (deinit + destroy can both call uninstall)
private var isUninstalled = false

Expand Down Expand Up @@ -89,6 +92,7 @@ final class InlineSuggestionManager {
removeScrollObserver()

schemaProvider = nil
connectionPolicy = nil
controller = nil
}

Expand Down Expand Up @@ -132,6 +136,7 @@ final class InlineSuggestionManager {
let settings = AppSettingsManager.shared.ai
guard settings.enabled else { return false }
guard settings.inlineSuggestEnabled else { return false }
if connectionPolicy == .never { return false }
guard let controller else { return false }
guard let textView = controller.textView else { return false }

Expand Down
16 changes: 12 additions & 4 deletions TablePro/Core/Database/ConnectionHealthMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ actor ConnectionHealthMonitor {
try? await Task.sleep(for: .seconds(initialDelay))
guard !Task.isCancelled else { return }

// Create the iterator ONCE — reusing it across loop iterations
// prevents buffered yields from causing back-to-back instant pings.
var wakeIterator = wakeUpStream.makeAsyncIterator()

while !Task.isCancelled {
// Race between the normal ping interval and an early wake-up signal
await withTaskGroup(of: Bool.self) { group in
Expand All @@ -110,8 +114,7 @@ actor ConnectionHealthMonitor {
return false // normal timer fired
}
group.addTask {
var iterator = wakeUpStream.makeAsyncIterator()
_ = await iterator.next()
_ = await wakeIterator.next()
return true // woken up early
}

Expand All @@ -131,12 +134,17 @@ actor ConnectionHealthMonitor {
}

/// Stops periodic health monitoring and cancels any in-flight reconnect attempts.
func stopMonitoring() {
///
/// Awaits the monitoring task's completion to ensure no orphaned tasks
/// continue pinging after a new monitor is started.
func stopMonitoring() async {
Self.logger.trace("Stopping health monitoring for connection \(self.connectionId)")
monitoringTask?.cancel()
let task = monitoringTask
monitoringTask = nil
wakeUpContinuation?.finish()
wakeUpContinuation = nil
task?.cancel()
await task?.value
}

/// Triggers an immediate health check, interrupting the normal 30-second sleep.
Expand Down
6 changes: 5 additions & 1 deletion TablePro/Core/Storage/AIChatStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ actor AIChatStorage {
// Create directory inline since actor init is nonisolated
do {
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
try FileManager.default.setAttributes(
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
ofItemAtPath: dir.path
)
} catch {
Self.logger.error("Failed to create ai_chats directory: \(error.localizedDescription)")
}
Expand All @@ -61,7 +65,7 @@ actor AIChatStorage {

do {
let data = try Self.encoder.encode(conversation)
try data.write(to: fileURL, options: .atomic)
try data.write(to: fileURL, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication])
} catch {
Self.logger.error("Failed to save conversation \(conversation.id): \(error.localizedDescription)")
}
Expand Down
2 changes: 2 additions & 0 deletions TablePro/Views/Editor/QueryEditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ struct QueryEditorView: View {
var schemaProvider: SQLSchemaProvider?
var databaseType: DatabaseType?
var connectionId: UUID?
var connectionAIPolicy: AIConnectionPolicy?
var onCloseTab: (() -> Void)?
var onExecuteQuery: (() -> Void)?
var onExplain: ((ClickHouseExplainVariant?) -> Void)?
Expand Down Expand Up @@ -48,6 +49,7 @@ struct QueryEditorView: View {
schemaProvider: schemaProvider,
databaseType: databaseType,
connectionId: connectionId,
connectionAIPolicy: connectionAIPolicy,
vimMode: $vimMode,
onCloseTab: onCloseTab,
onExecuteQuery: onExecuteQuery,
Expand Down
3 changes: 3 additions & 0 deletions TablePro/Views/Editor/SQLEditorCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ final class SQLEditorCoordinator: TextViewCoordinator {
@ObservationIgnored weak var controller: TextViewController?
/// Shared schema provider for inline AI suggestions (avoids duplicate schema fetches)
@ObservationIgnored var schemaProvider: SQLSchemaProvider?
/// Connection-level AI policy for inline suggestions
@ObservationIgnored var connectionAIPolicy: AIConnectionPolicy?
@ObservationIgnored private var contextMenu: AIEditorContextMenu?
@ObservationIgnored private var inlineSuggestionManager: InlineSuggestionManager?
@ObservationIgnored private var editorSettingsObserver: NSObjectProtocol?
Expand Down Expand Up @@ -218,6 +220,7 @@ final class SQLEditorCoordinator: TextViewCoordinator {

private func installInlineSuggestionManager(controller: TextViewController) {
let manager = InlineSuggestionManager()
manager.connectionPolicy = connectionAIPolicy
manager.install(controller: controller, schemaProvider: schemaProvider)
inlineSuggestionManager = manager
}
Expand Down
3 changes: 3 additions & 0 deletions TablePro/Views/Editor/SQLEditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ struct SQLEditorView: View {
var schemaProvider: SQLSchemaProvider?
var databaseType: DatabaseType?
var connectionId: UUID?
var connectionAIPolicy: AIConnectionPolicy?
@Binding var vimMode: VimMode
var onCloseTab: (() -> Void)?
var onExecuteQuery: (() -> Void)?
Expand Down Expand Up @@ -100,6 +101,7 @@ struct SQLEditorView: View {
completionAdapter = SQLCompletionAdapter(schemaProvider: schemaProvider, databaseType: databaseType)
}
coordinator.schemaProvider = schemaProvider
coordinator.connectionAIPolicy = connectionAIPolicy
coordinator.onCloseTab = onCloseTab
coordinator.onExecuteQuery = onExecuteQuery
coordinator.onAIExplain = onAIExplain
Expand All @@ -115,6 +117,7 @@ struct SQLEditorView: View {
completionAdapter = SQLCompletionAdapter(schemaProvider: schemaProvider, databaseType: databaseType)
}
coordinator.schemaProvider = schemaProvider
coordinator.connectionAIPolicy = connectionAIPolicy
coordinator.onCloseTab = onCloseTab
coordinator.onExecuteQuery = onExecuteQuery
coordinator.onAIExplain = onAIExplain
Expand Down
1 change: 1 addition & 0 deletions TablePro/Views/Main/Child/MainEditorContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ struct MainEditorContentView: View {
schemaProvider: coordinator.schemaProvider,
databaseType: coordinator.connection.type,
connectionId: coordinator.connection.id,
connectionAIPolicy: coordinator.connection.aiPolicy ?? AppSettingsManager.shared.ai.defaultConnectionPolicy,
onCloseTab: {
NSApp.keyWindow?.close()
},
Expand Down
Loading