Skip to content
Merged
47 changes: 1 addition & 46 deletions TablePro/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ struct ContentView: View {
@State private var showDisconnectConfirmation = false
@State private var pendingDisconnectSessionId: UUID?
@State private var hasLoaded = false
@State private var escapeKeyMonitor: Any?
@State private var isInspectorPresented = false // Right sidebar (inspector) visibility

@Environment(\.openWindow)
Expand Down Expand Up @@ -108,23 +107,13 @@ struct ContentView: View {
Text("Are you sure you want to disconnect from this database?")
}
.onChange(of: showDisconnectConfirmation) { _, isShowing in
// Track alert state in AppState so menu commands can check it
AppState.shared.isSheetPresented = isShowing
// Reset pending state when alert is dismissed (e.g., by Cmd+W or ESC)
if !isShowing {
pendingDisconnectSessionId = nil
}
}
.onChange(of: showUnsavedChangesAlert) { _, isShowing in
// Track alert state in AppState so menu commands can check it
AppState.shared.isSheetPresented = isShowing
}
.onAppear {
loadConnections()
setupEscapeKeyMonitor()
}
.onDisappear {
removeEscapeKeyMonitor()
}
.onReceive(NotificationCenter.default.publisher(for: .newConnection)) { _ in
openWindow(id: "connection-form", value: nil as UUID?)
Expand Down Expand Up @@ -363,45 +352,11 @@ struct ContentView: View {
storage.deleteConnection(connection)
storage.saveConnections(connections)
}

// MARK: - Escape Key Monitor

private func setupEscapeKeyMonitor() {
escapeKeyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
// Escape key code is 53
if event.keyCode == 53 {
// CRITICAL: Check if completion window is visible first
if let frontmostWindow = NSApp.keyWindow,
frontmostWindow.level == .popUpMenu,
frontmostWindow.isVisible {
// Let the completion window handle Escape
return event
}

// Don't consume ESC if a sheet is presented - let it dismiss the sheet
if AppState.shared.isSheetPresented {
return event
}

NotificationCenter.default.post(name: .clearSelection, object: nil)
// Return nil to consume the event, or return event to let it propagate
return nil
}
return event
}
}


private func showAllTablesMetadata() {
// Post notification for MainContentView to handle
NotificationCenter.default.post(name: .showAllTables, object: nil)
}

private func removeEscapeKeyMonitor() {
if let monitor = escapeKeyMonitor {
NSEvent.removeMonitor(monitor)
escapeKeyMonitor = nil
}
}
}

#Preview {
Expand Down
6 changes: 6 additions & 0 deletions TablePro/Core/Database/DatabaseDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ protocol DatabaseDriver: AnyObject {

/// Fetch list of all databases on the server
func fetchDatabases() async throws -> [String]

/// Fetch metadata for a specific database (table count, size, etc.)
func fetchDatabaseMetadata(_ database: String) async throws -> DatabaseMetadata

/// Create a new database
func createDatabase(name: String, charset: String, collation: String?) async throws
}

/// Default implementation for common operations
Expand Down
76 changes: 76 additions & 0 deletions TablePro/Core/Database/MySQLDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -502,4 +502,80 @@ final class MySQLDriver: DatabaseDriver {
let result = try await execute(query: "SHOW DATABASES")
return result.rows.compactMap { row in row.first.flatMap { $0 } }
}

/// Escape a value for safe use in a single-quoted SQL string literal.
///
/// This helper is intended *only* for contexts where the value will be placed
/// inside single quotes (e.g. `WHERE TABLE_SCHEMA = '...'`) and should not be
/// used for identifiers (such as database, table, or column names).
private func escapeForSQLStringLiteral(_ value: String) -> String {
// Escape single quotes by doubling them, per SQL standard.
return value.replacingOccurrences(of: "'", with: "''")
}

/// Fetch metadata for a specific database
func fetchDatabaseMetadata(_ database: String) async throws -> DatabaseMetadata {
// Escape database name for use as a SQL string literal in information_schema queries
let escapedDbLiteral = escapeForSQLStringLiteral(database)

// Query for table count
let countQuery = """
SELECT COUNT(*) as table_count
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = '\(escapedDbLiteral)'
"""
let countResult = try await execute(query: countQuery)
let tableCount = Int(countResult.rows.first?[0] ?? "0") ?? 0

// Query for size
let sizeQuery = """
SELECT SUM(DATA_LENGTH + INDEX_LENGTH) as size
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = '\(escapedDbLiteral)'
"""
let sizeResult = try await execute(query: sizeQuery)
let sizeString = sizeResult.rows.first?[0] ?? "0"
let sizeBytes = Int64(sizeString) ?? 0

// Determine if system database
let systemDatabases = ["information_schema", "mysql", "performance_schema", "sys"]
let isSystem = systemDatabases.contains(database)

return DatabaseMetadata(
id: database,
name: database,
tableCount: tableCount,
sizeBytes: sizeBytes,
lastAccessed: nil, // Could track separately if needed
isSystemDatabase: isSystem,
icon: isSystem ? "gearshape.fill" : "cylinder.fill"
)
}

/// Create a new database
func createDatabase(name: String, charset: String, collation: String?) async throws {
// Escape backticks in database name
let escapedName = name.replacingOccurrences(of: "`", with: "``")

// Validate charset (basic validation - should be expanded)
let validCharsets = ["utf8mb4", "utf8", "latin1", "ascii"]
guard validCharsets.contains(charset) else {
throw DatabaseError.queryFailed("Invalid character set: \(charset)")
}

var query = "CREATE DATABASE `\(escapedName)` CHARACTER SET \(charset)"
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The charset parameter is validated against a whitelist but is not escaped when interpolated into the SQL query. While the validation provides some protection, it would be safer to escape the charset value or use parameterized queries to fully prevent potential SQL injection if the validation logic is ever modified or bypassed.

Copilot uses AI. Check for mistakes.

// Validate collation if provided
if let collation = collation {
// Collation must match charset prefix and only contain safe identifier characters
let allowedChars = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "_"))
let isSafe = collation.unicodeScalars.allSatisfy { allowedChars.contains($0) }
guard collation.hasPrefix(charset), isSafe else {
throw DatabaseError.queryFailed("Invalid collation for charset")
}
query += " COLLATE \(collation)"
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The collation is validated but not escaped before being interpolated into the SQL query. While character set validation is in place, adding proper escaping would provide defense in depth against potential SQL injection.

Copilot uses AI. Check for mistakes.
}

_ = try await execute(query: query)
}
}
67 changes: 67 additions & 0 deletions TablePro/Core/Database/PostgreSQLDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -423,4 +423,71 @@ final class PostgreSQLDriver: DatabaseDriver {
let result = try await execute(query: "SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname")
return result.rows.compactMap { row in row.first.flatMap { $0 } }
}

/// Fetch metadata for a specific database
func fetchDatabaseMetadata(_ database: String) async throws -> DatabaseMetadata {
// Escape single quotes for SQL string literals
let escapedDbLiteral = database.replacingOccurrences(of: "'", with: "''")
Comment thread
datlechin marked this conversation as resolved.

// Query for table count
let countQuery = """
SELECT COUNT(*)
FROM information_schema.tables
WHERE table_schema = 'public' AND table_catalog = '\(escapedDbLiteral)'
"""
let countResult = try await execute(query: countQuery)
let tableCount = Int(countResult.rows.first?[0] ?? "0") ?? 0

// Query for size
let sizeQuery = """
SELECT pg_database_size('\(escapedDbLiteral)')
"""
let sizeResult = try await execute(query: sizeQuery)
let sizeString = sizeResult.rows.first?[0] ?? "0"
let sizeBytes = Int64(sizeString) ?? 0

// Determine if system database
let systemDatabases = ["postgres", "template0", "template1"]
let isSystem = systemDatabases.contains(database)

return DatabaseMetadata(
id: database,
name: database,
tableCount: tableCount,
sizeBytes: sizeBytes,
lastAccessed: nil,
isSystemDatabase: isSystem,
icon: isSystem ? "gearshape.fill" : "cylinder.fill"
)
}

/// Create a new database
func createDatabase(name: String, charset: String, collation: String?) async throws {
// Escape double quotes in database name (PostgreSQL identifiers)
let escapedName = name.replacingOccurrences(of: "\"", with: "\"\"")

// Validate charset (basic validation)
let validCharsets = ["UTF8", "LATIN1", "SQL_ASCII"]
let normalizedCharset = charset.uppercased()
guard validCharsets.contains(normalizedCharset) else {
throw DatabaseError.queryFailed("Invalid encoding: \(charset)")
}

var query = "CREATE DATABASE \"\(escapedName)\" ENCODING '\(normalizedCharset)'"
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The encoding/charset is validated but not escaped when interpolated into the query. Even with validation, proper escaping would provide an additional layer of protection against SQL injection.

Copilot uses AI. Check for mistakes.

// Validate and add collation if provided
if let collation = collation {
// Strict validation: allow only typical locale/collation characters
let allowedCollationChars = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.-")
let isValidCollation = collation.unicodeScalars.allSatisfy { allowedCollationChars.contains($0) }
guard isValidCollation else {
throw DatabaseError.queryFailed("Invalid collation")
}
// Escape single quotes for safe SQL literal usage
let escapedCollation = collation.replacingOccurrences(of: "'", with: "''")
query += " LC_COLLATE '\(escapedCollation)'"
}

_ = try await execute(query: query)
}
}
18 changes: 18 additions & 0 deletions TablePro/Core/Database/SQLiteDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -546,4 +546,22 @@ final class SQLiteDriver: DatabaseDriver {
// Each SQLite file is a separate database
[]
}

/// SQLite is file-based, return minimal metadata
func fetchDatabaseMetadata(_ database: String) async throws -> DatabaseMetadata {
DatabaseMetadata(
id: database,
name: database,
tableCount: nil,
sizeBytes: nil,
lastAccessed: nil,
isSystemDatabase: false,
icon: "doc.fill"
)
}

/// SQLite databases are created as files, not via SQL
func createDatabase(name: String, charset: String, collation: String?) async throws {
throw DatabaseError.unsupportedOperation
}
}
109 changes: 109 additions & 0 deletions TablePro/Core/KeyboardHandling/EscapeKeyCoordinator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
//
// EscapeKeyCoordinator.swift
// TablePro
//
// Coordinates ESC key handling across the app using SwiftUI environment.
//

import AppKit
import Combine
import SwiftUI

// MARK: - Coordinator

/// Manages global ESC key handling and coordinates with SwiftUI environment
@MainActor
public final class EscapeKeyCoordinator: ObservableObject {
public static let shared = EscapeKeyCoordinator()

/// The current environment context (updated by root view)
@Published private(set) var currentContext: EscapeKeyContext = EscapeKeyContext()

/// NSEvent monitor for capturing ESC key globally
private var eventMonitor: Any?

private init() {}

// MARK: - Setup

/// Install global ESC key monitor
/// Should be called once at app startup
public func install() {
guard eventMonitor == nil else { return }

eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
guard let self = self else { return event }

// Check if it's the ESC key (keyCode 53)
if event.keyCode == 53 {
// Process through environment handlers
if self.handleEscape() {
return nil // Consumed
}
}

return event // Pass through
}
}

/// Remove global ESC key monitor
public func uninstall() {
if let monitor = eventMonitor {
NSEvent.removeMonitor(monitor)
eventMonitor = nil
}
}

// MARK: - Context Management

/// Update the current context (called by root view's environment reader)
public func updateContext(_ context: EscapeKeyContext) {
self.currentContext = context
}

// MARK: - Processing

/// Process ESC key through all registered handlers
/// Returns true if handled, false if ignored by all
public func handleEscape() -> Bool {
// Check for special cases first (popups, autocomplete)
if shouldIgnoreForSpecialWindows() {
return false
}

// Process handlers in priority order (highest first)
let handlers = currentContext.sortedHandlers()

for handler in handlers {
let result = handler.handle()

switch result {
case .handled:
// Handler consumed the ESC key, stop propagation
return true

case .ignored:
// Handler didn't process, try next
continue
}
}

// No handler processed the ESC key
return false
}

// MARK: - Special Cases

/// Check if ESC should be ignored for special windows (autocomplete, popups, etc.)
private func shouldIgnoreForSpecialWindows() -> Bool {
// Check if autocomplete/popup window is visible
if let frontmostWindow = NSApp.keyWindow,
frontmostWindow.level == .popUpMenu,
frontmostWindow.isVisible {
// Let the popup handle ESC
return true
}

return false
}
}
Loading