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

### Fixed

- Fix MySQL/MariaDB getting `BEGIN` instead of `START TRANSACTION` in table operations and SQL preview
- Replace `.onTapGesture` with `Button` in color pickers, section headers, group headers, and connection switcher for VoiceOver accessibility
- Fix data race on `isAppTerminating` static var in `MainContentCoordinator` using `OSAllocatedUnfairLock`
- Fix `MainActor.assumeIsolated` crash risk in `VimKeyInterceptor` notification observer
Expand Down
14 changes: 7 additions & 7 deletions TablePro.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,13 @@
};
/* End XCConfigurationList section */

/* Begin XCLocalSwiftPackageReference section */
5A0000012F4F000000000100 /* XCLocalSwiftPackageReference "LocalPackages/CodeEditLanguages" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = LocalPackages/CodeEditLanguages;
};
/* End XCLocalSwiftPackageReference section */

/* Begin XCRemoteSwiftPackageReference section */
5ACE00012F4F000000000001 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */ = {
isa = XCRemoteSwiftPackageReference;
Expand Down Expand Up @@ -679,13 +686,6 @@
};
/* End XCRemoteSwiftPackageReference section */

/* Begin XCLocalSwiftPackageReference section */
5A0000012F4F000000000100 /* XCLocalSwiftPackageReference "LocalPackages/CodeEditLanguages" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = LocalPackages/CodeEditLanguages;
};
/* End XCLocalSwiftPackageReference section */

/* Begin XCSwiftPackageProductDependency section */
5ACE00012F4F000000000002 /* CodeEditSourceEditor */ = {
isa = XCSwiftPackageProductDependency;
Expand Down
29 changes: 29 additions & 0 deletions TablePro/Core/AI/AIProviderFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,33 @@ enum AIProviderFactory {
)
}
}

static func resolveProvider(
for feature: AIFeature,
settings: AISettings
) -> (AIProviderConfig, String?)? {
if let route = settings.featureRouting[feature.rawValue],
let config = settings.providers.first(where: { $0.id == route.providerID && $0.isEnabled }) {
let apiKey = AIKeyStorage.shared.loadAPIKey(for: config.id)
return (config, apiKey)
}

guard let config = settings.providers.first(where: { $0.isEnabled }) else {
return nil
}

let apiKey = AIKeyStorage.shared.loadAPIKey(for: config.id)
return (config, apiKey)
}

static func resolveModel(
for feature: AIFeature,
config: AIProviderConfig,
settings: AISettings
) -> String {
if let route = settings.featureRouting[feature.rawValue], !route.model.isEmpty {
return route.model
}
return config.model
}
}
37 changes: 2 additions & 35 deletions TablePro/Core/AI/InlineSuggestionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -204,11 +204,11 @@ final class InlineSuggestionManager {
private func fetchSuggestion(textBefore: String, fullQuery: String) async throws -> String {
let settings = AppSettingsManager.shared.ai

guard let (config, apiKey) = resolveProvider(for: .inlineSuggest, settings: settings) else {
guard let (config, apiKey) = AIProviderFactory.resolveProvider(for: .inlineSuggest, settings: settings) else {
throw AIProviderError.networkError("No AI provider configured")
}

let model = resolveModel(for: .inlineSuggest, config: config, settings: settings)
let model = AIProviderFactory.resolveModel(for: .inlineSuggest, config: config, settings: settings)
let provider = AIProviderFactory.createProvider(for: config, apiKey: apiKey)

let userMessage = AIPromptTemplates.inlineSuggest(textBefore: textBefore, fullQuery: fullQuery)
Expand Down Expand Up @@ -247,39 +247,6 @@ final class InlineSuggestionManager {
return accumulated
}

// MARK: - Provider Resolution (mirrors AIChatViewModel)

private func resolveProvider(
for feature: AIFeature,
settings: AISettings
) -> (AIProviderConfig, String?)? {
// Check feature routing first
if let route = settings.featureRouting[feature.rawValue],
let config = settings.providers.first(where: { $0.id == route.providerID && $0.isEnabled }) {
let apiKey = AIKeyStorage.shared.loadAPIKey(for: config.id)
return (config, apiKey)
}

// Fall back to first enabled provider
guard let config = settings.providers.first(where: { $0.isEnabled }) else {
return nil
}

let apiKey = AIKeyStorage.shared.loadAPIKey(for: config.id)
return (config, apiKey)
}

private func resolveModel(
for feature: AIFeature,
config: AIProviderConfig,
settings: AISettings
) -> String {
if let route = settings.featureRouting[feature.rawValue], !route.model.isEmpty {
return route.model
}
return config.model
}

/// Clean the AI suggestion: strip thinking blocks, leading newlines,
/// and trailing whitespace, but preserve leading spaces.
private func cleanSuggestion(_ raw: String) -> String {
Expand Down
78 changes: 4 additions & 74 deletions TablePro/Core/Autocomplete/SQLContextAnalyzer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -303,9 +303,11 @@ final class SQLContextAnalyzer {
let safePosition = min(cursorPosition, nsQuery.length)

// Extract the current statement for multi-statement queries
let (currentStatement, statementOffset) = extractCurrentStatement(
from: nsQuery, cursorPosition: safePosition
let located = SQLStatementScanner.locatedStatementAtCursor(
in: nsQuery as String, cursorPosition: safePosition
)
let currentStatement = located.sql
let statementOffset = located.offset
let adjustedPosition = safePosition - statementOffset

let nsStatement = currentStatement as NSString
Expand Down Expand Up @@ -402,78 +404,6 @@ final class SQLContextAnalyzer {
)
}

// MARK: - Multi-Statement Support

/// Extract the current SQL statement containing the cursor.
/// Uses NSString UTF-16 character access for O(1) per character instead of
/// O(n) Swift String.index(offsetBy:).
private func extractCurrentStatement(
from nsQuery: NSString,
cursorPosition: Int
) -> (statement: String, offset: Int) {
let length = nsQuery.length
guard length > 0 else { return ("", 0) }

// Scan through to find semicolons not inside strings/comments
var statementStart = 0
var inString = false
var inComment = false
var prevChar: UInt16 = 0

// Track the statement that contains the cursor
var foundStatement: String?
var foundOffset = 0

for i in 0..<length {
let ch = nsQuery.character(at: i)

// Track string state
if ch == Self.singleQuote && prevChar != Self.backslash && !inComment {
inString.toggle()
}

// Track line comment state
if ch == Self.dash && prevChar == Self.dash && !inString {
inComment = true
}
if ch == Self.newline && inComment {
inComment = false
}

// Found statement boundary
if ch == Self.semicolon && !inString && !inComment {
let stmtEnd = i + 1
let stmtRange = NSRange(location: statementStart, length: stmtEnd - statementStart)

// Check if cursor is within this statement
if cursorPosition >= statementStart && cursorPosition < stmtEnd {
foundStatement = nsQuery.substring(with: stmtRange)
foundOffset = statementStart
break
}
statementStart = stmtEnd
}

prevChar = ch
}

// If found during scan, return it
if let stmt = foundStatement {
return (stmt, foundOffset)
}

// Check the last statement (may not end with ;)
if statementStart < length {
let stmtRange = NSRange(location: statementStart, length: length - statementStart)
if cursorPosition >= statementStart {
return (nsQuery.substring(with: stmtRange), statementStart)
}
}

// Fallback: return entire query
return (nsQuery as String, 0)
}

// MARK: - CTE Support

/// Extract CTE (Common Table Expression) names from the query
Expand Down
8 changes: 0 additions & 8 deletions TablePro/Core/Database/ConnectionHealthMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,6 @@ extension ConnectionHealthMonitor {
}
}

// MARK: - Notification

extension Notification.Name {
/// Posted when a connection's health state changes.
/// userInfo: ["connectionId": UUID, "state": ConnectionHealthMonitor.HealthState]
static let connectionHealthStateChanged = Notification.Name("connectionHealthStateChanged")
}

// MARK: - ConnectionHealthMonitor

/// Monitors a single database connection's health via periodic pings and
Expand Down
16 changes: 1 addition & 15 deletions TablePro/Core/Database/DatabaseDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -286,21 +286,7 @@ extension DatabaseDriver {

/// Default transaction implementation using database-specific SQL
func beginTransaction() async throws {
let sql: String
switch connection.type {
case .mysql, .mariadb:
sql = "START TRANSACTION"
case .postgresql, .redshift:
sql = "BEGIN"
case .sqlite:
sql = "BEGIN"
case .mongodb:
sql = "" // MongoDB transactions not supported in default impl
case .redis:
sql = "" // Redis transactions handled by RedisDriver directly
case .mssql:
sql = "BEGIN TRANSACTION"
}
let sql = connection.type.beginTransactionSQL
guard !sql.isEmpty else { return }
_ = try await execute(query: sql)
}
Expand Down
4 changes: 0 additions & 4 deletions TablePro/Core/Database/DatabaseManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,6 @@ import Foundation
import Observation
import os

extension Notification.Name {
static let databaseDidConnect = Notification.Name("databaseDidConnect")
}

/// Manages database connections and active drivers
@MainActor @Observable
final class DatabaseManager {
Expand Down
4 changes: 0 additions & 4 deletions TablePro/Core/SSH/SSHTunnelManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,6 @@
import Foundation
import os

extension Notification.Name {
static let sshTunnelDied = Notification.Name("sshTunnelDied")
}

/// Error types for SSH tunnel operations
enum SSHTunnelError: Error, LocalizedError {
case tunnelCreationFailed(String)
Expand Down
36 changes: 36 additions & 0 deletions TablePro/Core/Services/AppNotifications.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//
// AppNotifications.swift
// TablePro
//
// Centralized notification names used across the app.
// Domain-specific collections remain in OpenTableApp.swift
// and SettingsNotifications.swift.
//

import Foundation

extension Notification.Name {
// MARK: - Query Editor

static let formatQueryRequested = Notification.Name("formatQueryRequested")
static let sendAIPrompt = Notification.Name("sendAIPrompt")
static let aiFixError = Notification.Name("aiFixError")
static let aiExplainSelection = Notification.Name("aiExplainSelection")
static let aiOptimizeSelection = Notification.Name("aiOptimizeSelection")

// MARK: - Query History

static let queryHistoryDidUpdate = Notification.Name("queryHistoryDidUpdate")
static let loadQueryIntoEditor = Notification.Name("loadQueryIntoEditor")
static let insertQueryFromAI = Notification.Name("insertQueryFromAI")

// MARK: - Connections

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

// MARK: - SSH

static let sshTunnelDied = Notification.Name("sshTunnelDied")
}
15 changes: 1 addition & 14 deletions TablePro/Core/Services/ImportService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ final class ImportService {

// 5. Begin transaction (if enabled)
if config.wrapInTransaction {
let beginStmt = beginTransactionStatement(for: connection.type)
let beginStmt = connection.type.beginTransactionSQL
if !beginStmt.isEmpty {
_ = try await driver.execute(query: beginStmt)
}
Expand Down Expand Up @@ -311,19 +311,6 @@ final class ImportService {
}
}

private func beginTransactionStatement(for dbType: DatabaseType) -> String {
switch dbType {
case .mysql, .mariadb:
return "START TRANSACTION"
case .postgresql, .redshift, .sqlite:
return "BEGIN"
case .mssql:
return "BEGIN TRANSACTION"
case .mongodb, .redis:
return ""
}
}

private func commitStatement(for dbType: DatabaseType) -> String {
switch dbType {
case .mongodb, .redis:
Expand Down
15 changes: 7 additions & 8 deletions TablePro/Core/Storage/QueryHistoryManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,23 @@
import Combine
import Foundation

/// Notification names for query history updates
extension Notification.Name {
static let queryHistoryDidUpdate = Notification.Name("queryHistoryDidUpdate")
static let loadQueryIntoEditor = Notification.Name("loadQueryIntoEditor")
static let insertQueryFromAI = Notification.Name("insertQueryFromAI")
}

/// Thread-safe manager for query history
/// NOT an ObservableObject - uses NotificationCenter for UI communication
final class QueryHistoryManager {
static let shared = QueryHistoryManager()

private let storage = QueryHistoryStorage.shared
private let storage: QueryHistoryStorage

// Settings observer for immediate cleanup when settings change
private var settingsObserver: AnyCancellable?

/// Creates an isolated manager with its own storage. For testing only.
init(isolatedStorage: QueryHistoryStorage) {
self.storage = isolatedStorage
}

private init() {
self.storage = QueryHistoryStorage.shared
// Subscribe to history settings changes for immediate cleanup
settingsObserver = NotificationCenter.default.publisher(for: .historySettingsDidChange)
.receive(on: DispatchQueue.main)
Expand Down
Loading