From 2f34a3988e203c462664b162dca1937eef5015f1 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 12 Apr 2026 15:50:19 +0700 Subject: [PATCH 01/29] feat: browse stored procedures and functions in sidebar (#693) --- CHANGELOG.md | 5 +++ Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift | 29 ++++++++++++++ Plugins/MySQLDriverPlugin/MySQLPlugin.swift | 1 + .../MySQLDriverPlugin/MySQLPluginDriver.swift | 29 ++++++++++++++ .../PostgreSQLPlugin.swift | 1 + .../PostgreSQLPluginDriver.swift | 39 +++++++++++++++++++ Plugins/TableProPluginKit/DriverPlugin.swift | 2 + .../PluginDatabaseDriver.swift | 7 ++++ .../TableProPluginKit/PluginRoutineInfo.swift | 11 ++++++ TablePro/Core/Database/DatabaseDriver.swift | 11 ++++++ .../Core/Plugins/PluginDriverAdapter.swift | 16 ++++++++ .../Plugins/PluginManager+Registration.swift | 5 +++ ...PluginMetadataRegistry+CloudDefaults.swift | 6 ++- ...ginMetadataRegistry+RegistryDefaults.swift | 24 ++++++++---- .../Core/Plugins/PluginMetadataRegistry.swift | 16 +++++--- .../Models/Connection/ConnectionSession.swift | 3 ++ TablePro/Models/Schema/RoutineInfo.swift | 12 ++++++ TablePro/ViewModels/SidebarViewModel.swift | 9 +++++ ...ainContentCoordinator+SidebarActions.swift | 30 ++++++++++++++ .../Views/Main/MainContentCoordinator.swift | 17 ++++++++ TablePro/Views/Sidebar/RoutineRowView.swift | 31 +++++++++++++++ TablePro/Views/Sidebar/SidebarView.swift | 29 +++++++++++++- 22 files changed, 317 insertions(+), 16 deletions(-) create mode 100644 Plugins/TableProPluginKit/PluginRoutineInfo.swift create mode 100644 TablePro/Models/Schema/RoutineInfo.swift create mode 100644 TablePro/Views/Sidebar/RoutineRowView.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d6d45a1c..70a21e799 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Browse stored procedures and functions in the sidebar for MySQL, MariaDB, PostgreSQL, and SQL Server (#693) +- View routine definitions from the sidebar context menu + ## [0.31.1] - 2026-04-12 ### Fixed diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index b77115573..7e1feeadd 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -27,6 +27,7 @@ final class MSSQLPlugin: NSObject, TableProPlugin, DriverPlugin { static let postConnectActions: [PostConnectAction] = [.selectDatabaseFromLastSession] static let brandColorHex = "#E34517" static let systemDatabaseNames: [String] = ["master", "tempdb", "model", "msdb"] + static let supportsRoutines = true static let defaultSchemaName = "dbo" static let databaseGroupingStrategy: GroupingStrategy = .bySchema static let columnTypesByCategory: [String: [String]] = [ @@ -1091,6 +1092,34 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return result.rows.first?.first?.flatMap { $0 } ?? "" } + func fetchRoutines(schema: String?) async throws -> [PluginRoutineInfo] { + let esc = effectiveSchemaEscaped(schema) + let sql = """ + SELECT ROUTINE_NAME, ROUTINE_TYPE + FROM INFORMATION_SCHEMA.ROUTINES + WHERE ROUTINE_SCHEMA = '\(esc)' + ORDER BY ROUTINE_NAME + """ + let result = try await execute(query: sql) + return result.rows.compactMap { row -> PluginRoutineInfo? in + guard let name = row[safe: 0] ?? nil, + let type = row[safe: 1] ?? nil else { return nil } + return PluginRoutineInfo(name: name, type: type) + } + } + + func fetchRoutineDefinition(routine: String, type: String, schema: String?) async throws -> String { + let esc = effectiveSchemaEscaped(schema) + let escapedRoutine = routine.replacingOccurrences(of: "'", with: "''") + let sql = "SELECT OBJECT_DEFINITION(OBJECT_ID('\(esc).\(escapedRoutine)'))" + let result = try await execute(query: sql) + guard let firstRow = result.rows.first, + let ddl = firstRow[safe: 0] ?? nil else { + throw MSSQLPluginError.queryFailed("Failed to fetch definition for \(type.lowercased()) '\(routine)'") + } + return ddl + } + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { let escapedTable = table.replacingOccurrences(of: "'", with: "''") let esc = effectiveSchemaEscaped(schema) diff --git a/Plugins/MySQLDriverPlugin/MySQLPlugin.swift b/Plugins/MySQLDriverPlugin/MySQLPlugin.swift index 1ef0b7d8a..aa3702d11 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPlugin.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPlugin.swift @@ -33,6 +33,7 @@ final class MySQLPlugin: NSObject, TableProPlugin, DriverPlugin { ] static let brandColorHex = "#FF9500" static let systemDatabaseNames: [String] = ["information_schema", "mysql", "performance_schema", "sys"] + static let supportsRoutines = true static let columnTypesByCategory: [String: [String]] = [ "Integer": ["TINYINT", "SMALLINT", "MEDIUMINT", "INT", "INTEGER", "BIGINT"], "Float": ["FLOAT", "DOUBLE", "DECIMAL", "NUMERIC", "REAL"], diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift index 8a2e3d53b..37592d1d2 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -446,6 +446,35 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return ddl } + func fetchRoutines(schema: String?) async throws -> [PluginRoutineInfo] { + let result = try await execute(query: """ + SELECT ROUTINE_NAME, ROUTINE_TYPE + FROM INFORMATION_SCHEMA.ROUTINES + WHERE ROUTINE_SCHEMA = DATABASE() + ORDER BY ROUTINE_NAME + """) + return result.rows.compactMap { row -> PluginRoutineInfo? in + guard let name = row[safe: 0] ?? nil, + let type = row[safe: 1] ?? nil else { return nil } + return PluginRoutineInfo(name: name, type: type) + } + } + + func fetchRoutineDefinition(routine: String, type: String, schema: String?) async throws -> String { + let safeRoutine = routine.replacingOccurrences(of: "`", with: "``") + let keyword = type.uppercased() == "FUNCTION" ? "FUNCTION" : "PROCEDURE" + let result = try await execute(query: "SHOW CREATE \(keyword) `\(safeRoutine)`") + guard let firstRow = result.rows.first, + let ddl = firstRow[safe: 2] ?? nil else { + throw MariaDBPluginError( + code: 0, + message: "Failed to fetch definition for \(keyword.lowercased()) '\(routine)'", + sqlState: nil + ) + } + return ddl.hasSuffix(";") ? ddl : ddl + ";" + } + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { let escapedTable = table.replacingOccurrences(of: "'", with: "''") let result = try await execute(query: "SHOW TABLE STATUS WHERE Name = '\(escapedTable)'") diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift index 23412737e..64d8abd12 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift @@ -43,6 +43,7 @@ final class PostgreSQLPlugin: NSObject, TableProPlugin, DriverPlugin { ExplainVariant(id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN (FORMAT JSON)"), ExplainVariant(id: "analyze", label: "EXPLAIN ANALYZE", sqlPrefix: "EXPLAIN (ANALYZE, FORMAT JSON)"), ] + static let supportsRoutines = true static let databaseGroupingStrategy: GroupingStrategy = .bySchema static let columnTypesByCategory: [String: [String]] = [ "Integer": ["SMALLINT", "INTEGER", "BIGINT", "SERIAL", "BIGSERIAL", "SMALLSERIAL"], diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index 264f459ab..7732616c8 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -604,6 +604,45 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return ddl } + func fetchRoutines(schema: String?) async throws -> [PluginRoutineInfo] { + let query = """ + SELECT p.proname, CASE p.prokind + WHEN 'f' THEN 'FUNCTION' + WHEN 'p' THEN 'PROCEDURE' + END AS routine_type + FROM pg_proc p + JOIN pg_namespace n ON p.pronamespace = n.oid + WHERE n.nspname = '\(escapedSchema)' + AND p.prokind IN ('f', 'p') + ORDER BY p.proname + """ + let result = try await execute(query: query) + return result.rows.compactMap { row -> PluginRoutineInfo? in + guard let name = row[0], let type = row[1] else { return nil } + return PluginRoutineInfo(name: name, type: type) + } + } + + func fetchRoutineDefinition(routine: String, type: String, schema: String?) async throws -> String { + let query = """ + SELECT pg_get_functiondef(p.oid) + FROM pg_proc p + JOIN pg_namespace n ON p.pronamespace = n.oid + WHERE p.proname = '\(escapeLiteral(routine))' + AND n.nspname = '\(escapedSchema)' + LIMIT 1 + """ + let result = try await execute(query: query) + guard let firstRow = result.rows.first, let ddl = firstRow[0] else { + throw LibPQPluginError( + message: "Failed to fetch definition for \(type.lowercased()) '\(routine)'", + sqlState: nil, + detail: nil + ) + } + return ddl + } + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { let query = """ SELECT diff --git a/Plugins/TableProPluginKit/DriverPlugin.swift b/Plugins/TableProPluginKit/DriverPlugin.swift index 466fd3246..46e2b42ea 100644 --- a/Plugins/TableProPluginKit/DriverPlugin.swift +++ b/Plugins/TableProPluginKit/DriverPlugin.swift @@ -46,6 +46,7 @@ public protocol DriverPlugin: TableProPlugin { static var structureColumnFields: [StructureColumnField] { get } static var defaultPrimaryKeyColumn: String? { get } static var supportsQueryProgress: Bool { get } + static var supportsRoutines: Bool { get } static var supportsSSH: Bool { get } static var supportsSSL: Bool { get } static var navigationModel: NavigationModel { get } @@ -106,6 +107,7 @@ public extension DriverPlugin { } static var defaultPrimaryKeyColumn: String? { nil } static var supportsQueryProgress: Bool { false } + static var supportsRoutines: Bool { false } static var supportsSSH: Bool { true } static var supportsSSL: Bool { true } static var navigationModel: NavigationModel { .standard } diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index 783b172c8..c0fea6c9e 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -48,6 +48,8 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] func fetchTableDDL(table: String, schema: String?) async throws -> String func fetchViewDefinition(view: String, schema: String?) async throws -> String + func fetchRoutines(schema: String?) async throws -> [PluginRoutineInfo] + func fetchRoutineDefinition(routine: String, type: String, schema: String?) async throws -> String func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata func fetchDatabases() async throws -> [String] func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata @@ -212,6 +214,11 @@ public extension PluginDatabaseDriver { func fetchDependentTypes(table: String, schema: String?) async throws -> [(name: String, labels: [String])] { [] } func fetchDependentSequences(table: String, schema: String?) async throws -> [(name: String, ddl: String)] { [] } + func fetchRoutines(schema: String?) async throws -> [PluginRoutineInfo] { [] } + func fetchRoutineDefinition(routine: String, type: String, schema: String?) async throws -> String { + throw NSError(domain: "PluginDatabaseDriver", code: -1, userInfo: [NSLocalizedDescriptionKey: "fetchRoutineDefinition not supported"]) + } + func createDatabase(name: String, charset: String, collation: String?) async throws { throw NSError(domain: "PluginDatabaseDriver", code: -1, userInfo: [NSLocalizedDescriptionKey: "createDatabase not supported"]) } diff --git a/Plugins/TableProPluginKit/PluginRoutineInfo.swift b/Plugins/TableProPluginKit/PluginRoutineInfo.swift new file mode 100644 index 000000000..3506287da --- /dev/null +++ b/Plugins/TableProPluginKit/PluginRoutineInfo.swift @@ -0,0 +1,11 @@ +import Foundation + +public struct PluginRoutineInfo: Codable, Sendable { + public let name: String + public let type: String + + public init(name: String, type: String = "PROCEDURE") { + self.name = name + self.type = type + } +} diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 45fa006ce..3ba207fc7 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -105,6 +105,12 @@ protocol DatabaseDriver: AnyObject { /// Fetch the view definition (SELECT statement) for a specific view func fetchViewDefinition(view: String) async throws -> String + /// Fetch all stored procedures and functions in the current schema + func fetchRoutines() async throws -> [RoutineInfo] + + /// Fetch the definition (CREATE PROCEDURE/FUNCTION) for a routine + func fetchRoutineDefinition(routine: String, type: RoutineInfo.RoutineType) async throws -> String + /// Fetch table metadata (size, comment, engine, etc.) func fetchTableMetadata(tableName: String) async throws -> TableMetadata @@ -305,6 +311,11 @@ extension DatabaseDriver { [] } + func fetchRoutines() async throws -> [RoutineInfo] { [] } + func fetchRoutineDefinition(routine: String, type: RoutineInfo.RoutineType) async throws -> String { + throw DatabaseError.connectionFailed("Routine definitions not supported") + } + func fetchAllDependentTypes(forTables tables: [String]) async throws -> [String: [(name: String, labels: [String])]] { var result: [String: [(name: String, labels: [String])]] = [:] for table in tables { diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index e666d1698..381aaac03 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -199,6 +199,22 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { try await pluginDriver.fetchViewDefinition(view: view, schema: pluginDriver.currentSchema) } + func fetchRoutines() async throws -> [RoutineInfo] { + let pluginRoutines = try await pluginDriver.fetchRoutines(schema: pluginDriver.currentSchema) + return pluginRoutines.map { routine in + let routineType: RoutineInfo.RoutineType = routine.type.uppercased() == "FUNCTION" ? .function : .procedure + return RoutineInfo(name: routine.name, type: routineType) + } + } + + func fetchRoutineDefinition(routine: String, type: RoutineInfo.RoutineType) async throws -> String { + try await pluginDriver.fetchRoutineDefinition( + routine: routine, + type: type == .function ? "FUNCTION" : "PROCEDURE", + schema: pluginDriver.currentSchema + ) + } + func fetchTableMetadata(tableName: String) async throws -> TableMetadata { let pluginMeta = try await pluginDriver.fetchTableMetadata( table: tableName, diff --git a/TablePro/Core/Plugins/PluginManager+Registration.swift b/TablePro/Core/Plugins/PluginManager+Registration.swift index c945a6f0c..ffb0cdf5d 100644 --- a/TablePro/Core/Plugins/PluginManager+Registration.swift +++ b/TablePro/Core/Plugins/PluginManager+Registration.swift @@ -276,6 +276,11 @@ extension PluginManager { .capabilities.supportsImport ?? true } + func supportsRoutines(for databaseType: DatabaseType) -> Bool { + PluginMetadataRegistry.shared.snapshot(forTypeId: databaseType.pluginTypeId)? + .capabilities.supportsRoutines ?? false + } + func systemDatabaseNames(for databaseType: DatabaseType) -> [String] { PluginMetadataRegistry.shared.snapshot(forTypeId: databaseType.pluginTypeId)? .schema.systemDatabaseNames ?? [] diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift index 5a569b3bd..34e9c0dda 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift @@ -31,7 +31,8 @@ extension PluginMetadataRegistry { supportsForeignKeyDisable: false, supportsReadOnlyMode: true, supportsQueryProgress: false, - requiresReconnectForDatabaseSwitch: false + requiresReconnectForDatabaseSwitch: false, + supportsRoutines: false ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "", @@ -171,7 +172,8 @@ extension PluginMetadataRegistry { supportsForeignKeyDisable: false, supportsReadOnlyMode: true, supportsQueryProgress: false, - requiresReconnectForDatabaseSwitch: false + requiresReconnectForDatabaseSwitch: false, + supportsRoutines: false ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "", diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index 7575c6dc4..d032d2966 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -527,7 +527,8 @@ extension PluginMetadataRegistry { supportsForeignKeyDisable: false, supportsReadOnlyMode: false, supportsQueryProgress: false, - requiresReconnectForDatabaseSwitch: false + requiresReconnectForDatabaseSwitch: false, + supportsRoutines: false ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", @@ -598,7 +599,8 @@ extension PluginMetadataRegistry { supportsForeignKeyDisable: false, supportsReadOnlyMode: false, supportsQueryProgress: false, - requiresReconnectForDatabaseSwitch: false + requiresReconnectForDatabaseSwitch: false, + supportsRoutines: false ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", @@ -685,7 +687,8 @@ extension PluginMetadataRegistry { supportsForeignKeyDisable: false, supportsReadOnlyMode: true, supportsQueryProgress: false, - requiresReconnectForDatabaseSwitch: false + requiresReconnectForDatabaseSwitch: false, + supportsRoutines: false ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", @@ -741,7 +744,8 @@ extension PluginMetadataRegistry { supportsForeignKeyDisable: true, supportsReadOnlyMode: true, supportsQueryProgress: true, - requiresReconnectForDatabaseSwitch: false + requiresReconnectForDatabaseSwitch: false, + supportsRoutines: false ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", @@ -817,7 +821,8 @@ extension PluginMetadataRegistry { supportsForeignKeyDisable: false, supportsReadOnlyMode: true, supportsQueryProgress: false, - requiresReconnectForDatabaseSwitch: false + requiresReconnectForDatabaseSwitch: false, + supportsRoutines: false ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", @@ -871,7 +876,8 @@ extension PluginMetadataRegistry { supportsForeignKeyDisable: false, supportsReadOnlyMode: true, supportsQueryProgress: false, - requiresReconnectForDatabaseSwitch: false + requiresReconnectForDatabaseSwitch: false, + supportsRoutines: false ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", @@ -924,7 +930,8 @@ extension PluginMetadataRegistry { supportsForeignKeyDisable: false, supportsReadOnlyMode: false, supportsQueryProgress: false, - requiresReconnectForDatabaseSwitch: false + requiresReconnectForDatabaseSwitch: false, + supportsRoutines: false ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", @@ -1006,7 +1013,8 @@ extension PluginMetadataRegistry { supportsForeignKeyDisable: true, supportsReadOnlyMode: true, supportsQueryProgress: false, - requiresReconnectForDatabaseSwitch: false + requiresReconnectForDatabaseSwitch: false, + supportsRoutines: false ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "main", diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/TablePro/Core/Plugins/PluginMetadataRegistry.swift index f8bc43ec0..4ef4bb4c5 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -50,6 +50,7 @@ struct PluginMetadataSnapshot: Sendable { let supportsReadOnlyMode: Bool let supportsQueryProgress: Bool let requiresReconnectForDatabaseSwitch: Bool + let supportsRoutines: Bool static let defaults = CapabilityFlags( supportsSchemaSwitching: false, @@ -61,7 +62,8 @@ struct PluginMetadataSnapshot: Sendable { supportsForeignKeyDisable: true, supportsReadOnlyMode: true, supportsQueryProgress: false, - requiresReconnectForDatabaseSwitch: false + requiresReconnectForDatabaseSwitch: false, + supportsRoutines: false ) } @@ -413,7 +415,8 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsForeignKeyDisable: false, supportsReadOnlyMode: true, supportsQueryProgress: false, - requiresReconnectForDatabaseSwitch: true + requiresReconnectForDatabaseSwitch: true, + supportsRoutines: true ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", @@ -456,7 +459,8 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsForeignKeyDisable: false, supportsReadOnlyMode: true, supportsQueryProgress: false, - requiresReconnectForDatabaseSwitch: true + requiresReconnectForDatabaseSwitch: true, + supportsRoutines: false ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", @@ -499,7 +503,8 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsForeignKeyDisable: true, supportsReadOnlyMode: true, supportsQueryProgress: false, - requiresReconnectForDatabaseSwitch: false + requiresReconnectForDatabaseSwitch: false, + supportsRoutines: false ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", @@ -658,7 +663,8 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsForeignKeyDisable: driverType.supportsForeignKeyDisable, supportsReadOnlyMode: driverType.supportsReadOnlyMode, supportsQueryProgress: driverType.supportsQueryProgress, - requiresReconnectForDatabaseSwitch: driverType.requiresReconnectForDatabaseSwitch + requiresReconnectForDatabaseSwitch: driverType.requiresReconnectForDatabaseSwitch, + supportsRoutines: driverType.supportsRoutines ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: driverType.defaultSchemaName, diff --git a/TablePro/Models/Connection/ConnectionSession.swift b/TablePro/Models/Connection/ConnectionSession.swift index e2a7129b6..d8c13185c 100644 --- a/TablePro/Models/Connection/ConnectionSession.swift +++ b/TablePro/Models/Connection/ConnectionSession.swift @@ -19,6 +19,7 @@ struct ConnectionSession: Identifiable { // Per-connection state var tables: [TableInfo] = [] + var routines: [RoutineInfo] = [] var selectedTables: Set = [] var pendingTruncates: Set = [] var pendingDeletes: Set = [] @@ -64,6 +65,7 @@ struct ConnectionSession: Identifiable { /// Note: `cachedPassword` is intentionally NOT cleared — auto-reconnect needs it after disconnect. mutating func clearCachedData() { tables = [] + routines = [] selectedTables = [] pendingTruncates = [] pendingDeletes = [] @@ -79,6 +81,7 @@ struct ConnectionSession: Identifiable { && status == other.status && connection == other.connection && tables == other.tables + && routines == other.routines && pendingTruncates == other.pendingTruncates && pendingDeletes == other.pendingDeletes && tableOperationOptions == other.tableOperationOptions diff --git a/TablePro/Models/Schema/RoutineInfo.swift b/TablePro/Models/Schema/RoutineInfo.swift new file mode 100644 index 000000000..2439f539f --- /dev/null +++ b/TablePro/Models/Schema/RoutineInfo.swift @@ -0,0 +1,12 @@ +import Foundation + +struct RoutineInfo: Identifiable, Hashable { + var id: String { "\(name)_\(type.rawValue)" } + let name: String + let type: RoutineType + + enum RoutineType: String, Sendable { + case procedure = "PROCEDURE" + case function = "FUNCTION" + } +} diff --git a/TablePro/ViewModels/SidebarViewModel.swift b/TablePro/ViewModels/SidebarViewModel.swift index 42f7a1a94..0b9102d56 100644 --- a/TablePro/ViewModels/SidebarViewModel.swift +++ b/TablePro/ViewModels/SidebarViewModel.swift @@ -34,6 +34,15 @@ final class SidebarViewModel { }() { didSet { UserDefaults.standard.set(isRedisKeysExpanded, forKey: "sidebar.isRedisKeysExpanded") } } + var isRoutinesExpanded: Bool = { + let key = "sidebar.isRoutinesExpanded" + if UserDefaults.standard.object(forKey: key) != nil { + return UserDefaults.standard.bool(forKey: key) + } + return true + }() { + didSet { UserDefaults.standard.set(isRoutinesExpanded, forKey: "sidebar.isRoutinesExpanded") } + } var redisKeyTreeViewModel: RedisKeyTreeViewModel? var showOperationDialog = false var pendingOperationType: TableOperationType? diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift index e5dacead0..b3177cc80 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift @@ -102,6 +102,36 @@ extension MainContentCoordinator { } } + // MARK: - Routine Operations + + func viewRoutineDefinition(_ routineName: String, type: RoutineInfo.RoutineType) { + Task { @MainActor in + do { + guard let driver = DatabaseManager.shared.driver(for: self.connection.id) else { return } + let definition = try await driver.fetchRoutineDefinition(routine: routineName, type: type) + + let payload = EditorTabPayload( + connectionId: connection.id, + tabType: .query, + initialQuery: definition + ) + WindowOpener.shared.openNativeTab(payload) + } catch { + let typeLabel = type == .function + ? String(localized: "function") + : String(localized: "procedure") + let fallbackSQL = "-- " + String(format: String(localized: "Could not fetch %@ definition: %@"), typeLabel, error.localizedDescription) + + let payload = EditorTabPayload( + connectionId: connection.id, + tabType: .query, + initialQuery: fallbackSQL + ) + WindowOpener.shared.openNativeTab(payload) + } + } + } + // MARK: - Export/Import func openExportDialog() { diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index b37508711..d97242291 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -72,6 +72,9 @@ final class MainContentCoordinator { let connection: DatabaseConnection var connectionId: UUID { connection.id } + var routines: [RoutineInfo] { + DatabaseManager.shared.session(for: connectionId)?.routines ?? [] + } /// Live safe mode level — reads from toolbar state (user-editable), /// not from the immutable connection snapshot. var safeModeLevel: SafeModeLevel { toolbarState.safeModeLevel } @@ -389,6 +392,20 @@ final class MainContentCoordinator { } } + // Fetch routines for databases that support them + if PluginManager.shared.supportsRoutines(for: connection.type) { + do { + let routines = try await driver.fetchRoutines() + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + DatabaseManager.shared.updateSession(connectionId) { $0.routines = routines } + } catch { + Self.logger.debug("Failed to fetch routines: \(error.localizedDescription)") + DatabaseManager.shared.updateSession(connectionId) { $0.routines = [] } + } + } else { + DatabaseManager.shared.updateSession(connectionId) { $0.routines = [] } + } + sidebarLoadingState = .loaded } catch { sidebarLoadingState = .error(error.localizedDescription) diff --git a/TablePro/Views/Sidebar/RoutineRowView.swift b/TablePro/Views/Sidebar/RoutineRowView.swift new file mode 100644 index 000000000..19e6e0538 --- /dev/null +++ b/TablePro/Views/Sidebar/RoutineRowView.swift @@ -0,0 +1,31 @@ +// +// RoutineRowView.swift +// TablePro +// +// Row view for a stored procedure or function in the sidebar. +// + +import SwiftUI + +struct RoutineRow: View { + let routine: RoutineInfo + + var body: some View { + HStack(spacing: 8) { + Image(systemName: routine.type == .function ? "f.cursive" : "gearshape.2") + .foregroundStyle(routine.type == .function + ? Color(nsColor: .systemTeal) + : Color(nsColor: .systemGreen)) + .frame(width: ThemeEngine.shared.activeTheme.iconSizes.default) + + Text(routine.name) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.medium, design: .monospaced)) + .lineLimit(1) + } + .padding(.vertical, ThemeEngine.shared.activeTheme.spacing.xxs) + .accessibilityElement(children: .combine) + .accessibilityLabel(routine.type == .function + ? String(format: String(localized: "Function: %@"), routine.name) + : String(format: String(localized: "Procedure: %@"), routine.name)) + } +} diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 44682b23b..20b9f517c 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -28,6 +28,12 @@ struct SidebarView: View { return tables.filter { $0.name.localizedCaseInsensitiveContains(viewModel.debouncedSearchText) } } + private var filteredRoutines: [RoutineInfo] { + let routines = coordinator?.routines ?? [] + guard !viewModel.debouncedSearchText.isEmpty else { return routines } + return routines.filter { $0.name.localizedCaseInsensitiveContains(viewModel.debouncedSearchText) } + } + private var selectedTablesBinding: Binding> { Binding( get: { sidebarState.selectedTables }, @@ -115,6 +121,9 @@ struct SidebarView: View { } .onAppear { coordinator?.sidebarViewModel = viewModel + if coordinator?.sidebarLoadingState == .idle && !tables.isEmpty { + coordinator?.sidebarLoadingState = .loaded + } } .sheet(isPresented: $viewModel.showOperationDialog) { if let operationType = viewModel.pendingOperationType { @@ -138,7 +147,7 @@ struct SidebarView: View { @ViewBuilder private var tablesContent: some View { - switch coordinator?.sidebarLoadingState ?? .idle { + switch coordinator?.sidebarLoadingState ?? (tables.isEmpty ? .idle : .loaded) { case .loading: loadingState case .error(let message): @@ -263,6 +272,24 @@ struct SidebarView: View { Text("Keys") } } + + if !filteredRoutines.isEmpty { + Section(isExpanded: $viewModel.isRoutinesExpanded) { + ForEach(filteredRoutines) { routine in + RoutineRow(routine: routine) + .contextMenu { + Button("View Definition") { + coordinator?.viewRoutineDefinition(routine.name, type: routine.type) + } + Button("Copy Name") { + ClipboardService.shared.writeText(routine.name) + } + } + } + } header: { + Text("Routines") + } + } } } .listStyle(.sidebar) From b3cafb4ab82fc3545f6bc32513a5d71d60ea6c32 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 12 Apr 2026 16:04:41 +0700 Subject: [PATCH 02/29] fix: MySQL/MariaDB routines visibility and PostgreSQL overloaded function handling --- .../PostgreSQLPluginDriver.swift | 7 +++-- .../Core/Plugins/PluginMetadataRegistry.swift | 28 +++++++++++++++++-- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index 7732616c8..a42d6f65d 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -606,7 +606,7 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func fetchRoutines(schema: String?) async throws -> [PluginRoutineInfo] { let query = """ - SELECT p.proname, CASE p.prokind + SELECT DISTINCT ON (p.proname, p.prokind) p.proname, CASE p.prokind WHEN 'f' THEN 'FUNCTION' WHEN 'p' THEN 'PROCEDURE' END AS routine_type @@ -614,7 +614,7 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { JOIN pg_namespace n ON p.pronamespace = n.oid WHERE n.nspname = '\(escapedSchema)' AND p.prokind IN ('f', 'p') - ORDER BY p.proname + ORDER BY p.proname, p.prokind """ let result = try await execute(query: query) return result.rows.compactMap { row -> PluginRoutineInfo? in @@ -624,12 +624,15 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } func fetchRoutineDefinition(routine: String, type: String, schema: String?) async throws -> String { + let prokind = type.uppercased() == "FUNCTION" ? "f" : "p" let query = """ SELECT pg_get_functiondef(p.oid) FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE p.proname = '\(escapeLiteral(routine))' AND n.nspname = '\(escapedSchema)' + AND p.prokind = '\(prokind)' + ORDER BY p.oid LIMIT 1 """ let result = try await execute(query: query) diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/TablePro/Core/Plugins/PluginMetadataRegistry.swift index 4ef4bb4c5..c9b50d296 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -345,7 +345,19 @@ final class PluginMetadataRegistry: @unchecked Sendable { queryLanguageName: "SQL", editorLanguage: .sql, connectionMode: .network, supportsDatabaseSwitching: true, supportsColumnReorder: true, - capabilities: .defaults, + capabilities: PluginMetadataSnapshot.CapabilityFlags( + supportsSchemaSwitching: false, + supportsImport: true, + supportsExport: true, + supportsSSH: true, + supportsSSL: true, + supportsCascadeDrop: false, + supportsForeignKeyDisable: true, + supportsReadOnlyMode: true, + supportsQueryProgress: false, + requiresReconnectForDatabaseSwitch: false, + supportsRoutines: true + ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", defaultGroupName: "main", @@ -375,7 +387,19 @@ final class PluginMetadataRegistry: @unchecked Sendable { queryLanguageName: "SQL", editorLanguage: .sql, connectionMode: .network, supportsDatabaseSwitching: true, supportsColumnReorder: true, - capabilities: .defaults, + capabilities: PluginMetadataSnapshot.CapabilityFlags( + supportsSchemaSwitching: false, + supportsImport: true, + supportsExport: true, + supportsSSH: true, + supportsSSL: true, + supportsCascadeDrop: false, + supportsForeignKeyDisable: true, + supportsReadOnlyMode: true, + supportsQueryProgress: false, + requiresReconnectForDatabaseSwitch: false, + supportsRoutines: true + ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", defaultGroupName: "main", From 727751963fd7dcfd7c684511a9a0dc4482fad6c8 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 12 Apr 2026 16:26:54 +0700 Subject: [PATCH 03/29] fix: view definition opens inline tab when no tabs are open --- ...ainContentCoordinator+SidebarActions.swift | 45 +++++++------------ 1 file changed, 17 insertions(+), 28 deletions(-) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift index b3177cc80..88f7bd015 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift @@ -79,25 +79,13 @@ extension MainContentCoordinator { do { guard let driver = DatabaseManager.shared.driver(for: self.connection.id) else { return } let definition = try await driver.fetchViewDefinition(view: viewName) - - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .query, - initialQuery: definition - ) - WindowOpener.shared.openNativeTab(payload) + openQueryInTab(definition) } catch { let driver = DatabaseManager.shared.driver(for: self.connection.id) let template = driver?.editViewFallbackTemplate(viewName: viewName) ?? "CREATE OR REPLACE VIEW \(viewName) AS\nSELECT * FROM table_name;" let fallbackSQL = "-- Could not fetch view definition: \(error.localizedDescription)\n\(template)" - - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .query, - initialQuery: fallbackSQL - ) - WindowOpener.shared.openNativeTab(payload) + openQueryInTab(fallbackSQL) } } } @@ -109,29 +97,30 @@ extension MainContentCoordinator { do { guard let driver = DatabaseManager.shared.driver(for: self.connection.id) else { return } let definition = try await driver.fetchRoutineDefinition(routine: routineName, type: type) - - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .query, - initialQuery: definition - ) - WindowOpener.shared.openNativeTab(payload) + openQueryInTab(definition) } catch { let typeLabel = type == .function ? String(localized: "function") : String(localized: "procedure") let fallbackSQL = "-- " + String(format: String(localized: "Could not fetch %@ definition: %@"), typeLabel, error.localizedDescription) - - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .query, - initialQuery: fallbackSQL - ) - WindowOpener.shared.openNativeTab(payload) + openQueryInTab(fallbackSQL) } } } + private func openQueryInTab(_ query: String) { + if tabManager.tabs.isEmpty { + tabManager.addTab(initialQuery: query, databaseName: connection.database) + } else { + let payload = EditorTabPayload( + connectionId: connection.id, + tabType: .query, + initialQuery: query + ) + WindowOpener.shared.openNativeTab(payload) + } + } + // MARK: - Export/Import func openExportDialog() { From dcd5a5cd9749d3ddaad60576ee55868829c9a514 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 12 Apr 2026 18:09:22 +0700 Subject: [PATCH 04/29] feat: full routine management - detail view, execute dialog, create/drop --- CHANGELOG.md | 4 + Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift | 58 ++++++ .../MySQLDriverPlugin/MySQLPluginDriver.swift | 55 ++++++ .../PostgreSQLPluginDriver.swift | 130 +++++++++++++ .../PluginDatabaseDriver.swift | 15 ++ .../TableProPluginKit/PluginRoutineInfo.swift | 11 ++ .../PluginRoutineParameterInfo.swift | 18 ++ TablePro/Core/Database/DatabaseDriver.swift | 15 ++ .../Core/Plugins/PluginDriverAdapter.swift | 42 +++++ .../Infrastructure/SessionStateFactory.swift | 4 +- .../Models/Connection/ConnectionSession.swift | 3 + TablePro/Models/Query/EditorTabPayload.swift | 16 +- TablePro/Models/Query/QueryTab.swift | 10 + TablePro/Models/Query/QueryTabState.swift | 2 + TablePro/Models/Schema/RoutineDetailTab.swift | 7 + TablePro/Models/Schema/RoutineInfo.swift | 12 ++ .../Models/Schema/RoutineParameterInfo.swift | 10 + TablePro/ViewModels/SidebarViewModel.swift | 9 + .../Main/Child/MainEditorContentView.swift | 10 +- ...ainContentCoordinator+SidebarActions.swift | 94 ++++++++++ .../Views/Main/MainContentCoordinator.swift | 8 + TablePro/Views/Main/MainContentView.swift | 6 + .../Views/Sidebar/ExecuteRoutineSheet.swift | 172 ++++++++++++++++++ TablePro/Views/Sidebar/RoutineRowView.swift | 21 +++ TablePro/Views/Sidebar/SidebarView.swift | 71 ++++++++ .../RoutineDetailView+DataLoading.swift | 45 +++++ .../Views/Structure/RoutineDetailView.swift | 138 ++++++++++++++ 27 files changed, 983 insertions(+), 3 deletions(-) create mode 100644 Plugins/TableProPluginKit/PluginRoutineInfo.swift create mode 100644 Plugins/TableProPluginKit/PluginRoutineParameterInfo.swift create mode 100644 TablePro/Models/Schema/RoutineDetailTab.swift create mode 100644 TablePro/Models/Schema/RoutineInfo.swift create mode 100644 TablePro/Models/Schema/RoutineParameterInfo.swift create mode 100644 TablePro/Views/Sidebar/ExecuteRoutineSheet.swift create mode 100644 TablePro/Views/Sidebar/RoutineRowView.swift create mode 100644 TablePro/Views/Structure/RoutineDetailView+DataLoading.swift create mode 100644 TablePro/Views/Structure/RoutineDetailView.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d6d45a1c..f86ce6ef1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Stored procedure and function management: browse routines in sidebar, view definitions, inspect parameters, execute with input dialog, create from templates, drop with confirmation (MySQL, MariaDB, PostgreSQL, SQL Server) + ## [0.31.1] - 2026-04-12 ### Fixed diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index b77115573..fe397e277 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -1091,6 +1091,64 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return result.rows.first?.first?.flatMap { $0 } ?? "" } + // MARK: - Routines + + var supportsRoutines: Bool { true } + + func fetchRoutines(schema: String?) async throws -> [PluginRoutineInfo] { + let esc = effectiveSchemaEscaped(schema) + let sql = """ + SELECT ROUTINE_NAME, ROUTINE_TYPE + FROM INFORMATION_SCHEMA.ROUTINES + WHERE ROUTINE_SCHEMA = '\(esc)' + ORDER BY ROUTINE_TYPE, ROUTINE_NAME + """ + let result = try await execute(query: sql) + return result.rows.compactMap { row -> PluginRoutineInfo? in + guard let name = row[safe: 0] ?? nil, + let type = row[safe: 1] ?? nil else { return nil } + return PluginRoutineInfo(name: name, type: type) + } + } + + func fetchRoutineDefinition(routine: String, type: String, schema: String?) async throws -> String { + let esc = effectiveSchemaEscaped(schema) + let escapedRoutine = "\(esc).\(routine.replacingOccurrences(of: "'", with: "''"))" + let sql = "SELECT definition FROM sys.sql_modules WHERE object_id = OBJECT_ID('\(escapedRoutine)')" + let result = try await execute(query: sql) + return result.rows.first?.first?.flatMap { $0 } ?? "" + } + + func fetchRoutineParameters(routine: String, type: String, schema: String?) async throws -> [PluginRoutineParameterInfo] { + let esc = effectiveSchemaEscaped(schema) + let escapedRoutine = routine.replacingOccurrences(of: "'", with: "''") + let sql = """ + SELECT PARAMETER_NAME, PARAMETER_MODE, DATA_TYPE, ORDINAL_POSITION + FROM INFORMATION_SCHEMA.PARAMETERS + WHERE SPECIFIC_SCHEMA = '\(esc)' AND SPECIFIC_NAME = '\(escapedRoutine)' + ORDER BY ORDINAL_POSITION + """ + let result = try await execute(query: sql) + return result.rows.compactMap { row -> PluginRoutineParameterInfo? in + let rawName = row[safe: 0] ?? nil + let name: String? = if let rawName, rawName.hasPrefix("@") { String(rawName.dropFirst()) } else { rawName } + let mode = (row[safe: 1] ?? nil) ?? "IN" + guard let dataType = row[safe: 2] ?? nil, + let posStr = row[safe: 3] ?? nil, + let pos = Int(posStr) else { return nil } + let direction = pos == 0 ? "RETURN" : mode + return PluginRoutineParameterInfo(name: name, dataType: dataType, direction: direction, ordinalPosition: pos) + } + } + + func createProcedureTemplate() -> String? { + "CREATE PROCEDURE procedure_name\n @param1 NVARCHAR(255)\nAS\nBEGIN\n SET NOCOUNT ON;\n -- procedure body\nEND;" + } + + func createFunctionTemplate() -> String? { + "CREATE FUNCTION function_name(@param1 INT)\nRETURNS INT\nAS\nBEGIN\n DECLARE @result INT;\n SET @result = @param1;\n RETURN @result;\nEND;" + } + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { let escapedTable = table.replacingOccurrences(of: "'", with: "''") let esc = effectiveSchemaEscaped(schema) diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift index 8a2e3d53b..97d4162f1 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -446,6 +446,61 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return ddl } + // MARK: - Routines + + var supportsRoutines: Bool { true } + + func fetchRoutines(schema: String?) async throws -> [PluginRoutineInfo] { + let result = try await execute(query: """ + SELECT ROUTINE_NAME, ROUTINE_TYPE + FROM INFORMATION_SCHEMA.ROUTINES + WHERE ROUTINE_SCHEMA = DATABASE() + ORDER BY ROUTINE_TYPE, ROUTINE_NAME + """) + return result.rows.compactMap { row -> PluginRoutineInfo? in + guard let name = row[safe: 0] ?? nil, + let type = row[safe: 1] ?? nil else { return nil } + return PluginRoutineInfo(name: name, type: type) + } + } + + func fetchRoutineDefinition(routine: String, type: String, schema: String?) async throws -> String { + let keyword = type.uppercased() == "FUNCTION" ? "FUNCTION" : "PROCEDURE" + let safeRoutine = routine.replacingOccurrences(of: "`", with: "``") + let result = try await execute(query: "SHOW CREATE \(keyword) `\(safeRoutine)`") + guard let firstRow = result.rows.first, + let ddl = firstRow[safe: 2] ?? nil else { + return "" + } + return ddl + } + + func fetchRoutineParameters(routine: String, type: String, schema: String?) async throws -> [PluginRoutineParameterInfo] { + let escapedRoutine = routine.replacingOccurrences(of: "'", with: "''") + let result = try await execute(query: """ + SELECT PARAMETER_NAME, PARAMETER_MODE, DTD_IDENTIFIER, ORDINAL_POSITION + FROM INFORMATION_SCHEMA.PARAMETERS + WHERE SPECIFIC_SCHEMA = DATABASE() AND SPECIFIC_NAME = '\(escapedRoutine)' + ORDER BY ORDINAL_POSITION + """) + return result.rows.compactMap { row -> PluginRoutineParameterInfo? in + let name = row[safe: 0] ?? nil + let mode = (row[safe: 1] ?? nil) ?? "RETURN" + guard let dataType = row[safe: 2] ?? nil, + let posStr = row[safe: 3] ?? nil, + let pos = Int(posStr) else { return nil } + return PluginRoutineParameterInfo(name: name, dataType: dataType, direction: mode, ordinalPosition: pos) + } + } + + func createProcedureTemplate() -> String? { + "CREATE PROCEDURE procedure_name(IN param1 VARCHAR(255))\nBEGIN\n -- procedure body\nEND;" + } + + func createFunctionTemplate() -> String? { + "CREATE FUNCTION function_name(param1 INT)\nRETURNS INT\nDETERMINISTIC\nBEGIN\n DECLARE result INT;\n SET result = param1;\n RETURN result;\nEND;" + } + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { let escapedTable = table.replacingOccurrences(of: "'", with: "''") let result = try await execute(query: "SHOW TABLE STATUS WHERE Name = '\(escapedTable)'") diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index 264f459ab..0c2b818f3 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -208,6 +208,136 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { "CAST(\(column) AS TEXT)" } + // MARK: - Routines + + var supportsRoutines: Bool { true } + + func fetchRoutines(schema: String?) async throws -> [PluginRoutineInfo] { + let result = try await execute(query: """ + SELECT p.proname, + CASE p.prokind WHEN 'f' THEN 'FUNCTION' WHEN 'p' THEN 'PROCEDURE' ELSE 'FUNCTION' END + FROM pg_proc p + JOIN pg_namespace n ON p.pronamespace = n.oid + WHERE n.nspname = '\(escapedSchema)' + AND p.prokind IN ('f', 'p') + ORDER BY 2, 1 + """) + return result.rows.compactMap { row -> PluginRoutineInfo? in + guard let name = row[safe: 0] ?? nil, + let type = row[safe: 1] ?? nil else { return nil } + return PluginRoutineInfo(name: name, type: type) + } + } + + func fetchRoutineDefinition(routine: String, type: String, schema: String?) async throws -> String { + let prokind = type.uppercased() == "FUNCTION" ? "f" : "p" + let result = try await execute(query: """ + SELECT pg_get_functiondef(p.oid) + FROM pg_proc p + JOIN pg_namespace n ON p.pronamespace = n.oid + WHERE p.proname = '\(escapeLiteral(routine))' + AND n.nspname = '\(escapedSchema)' + AND p.prokind = '\(prokind)' + ORDER BY p.oid LIMIT 1 + """) + return (result.rows.first?[safe: 0] ?? nil) ?? "" + } + + func fetchRoutineParameters(routine: String, type: String, schema: String?) async throws -> [PluginRoutineParameterInfo] { + let prokind = type.uppercased() == "FUNCTION" ? "f" : "p" + let result = try await execute(query: """ + SELECT p.oid, + pg_get_function_arguments(p.oid) AS arguments, + pg_get_function_result(p.oid) AS return_type + FROM pg_proc p + JOIN pg_namespace n ON p.pronamespace = n.oid + WHERE p.proname = '\(escapeLiteral(routine))' + AND n.nspname = '\(escapedSchema)' + AND p.prokind = '\(prokind)' + ORDER BY p.oid LIMIT 1 + """) + guard let firstRow = result.rows.first else { return [] } + + var params: [PluginRoutineParameterInfo] = [] + + if let argsStr = firstRow[safe: 1] ?? nil, !argsStr.isEmpty { + let args = parsePostgreSQLArguments(argsStr) + for (index, arg) in args.enumerated() { + params.append(PluginRoutineParameterInfo( + name: arg.name, dataType: arg.dataType, + direction: arg.direction, ordinalPosition: index + 1, + defaultValue: arg.defaultValue + )) + } + } + + if prokind == "f", let returnType = firstRow[safe: 2] ?? nil, returnType != "void" { + params.insert(PluginRoutineParameterInfo( + name: nil, dataType: returnType, direction: "RETURN", ordinalPosition: 0 + ), at: 0) + } + + return params + } + + private struct ParsedArg { + let name: String? + let dataType: String + let direction: String + let defaultValue: String? + } + + private func parsePostgreSQLArguments(_ argsStr: String) -> [ParsedArg] { + var args: [String] = [] + var current = "" + var depth = 0 + for char in argsStr { + if char == "(" { depth += 1 } else if char == ")" { depth -= 1 } + if char == "," && depth == 0 { + args.append(current.trimmingCharacters(in: .whitespaces)) + current = "" + } else { + current.append(char) + } + } + if !current.trimmingCharacters(in: .whitespaces).isEmpty { + args.append(current.trimmingCharacters(in: .whitespaces)) + } + + return args.map { arg in + var parts = arg.components(separatedBy: " ").filter { !$0.isEmpty } + var direction = "IN" + var defaultValue: String? + + if let defIdx = parts.firstIndex(where: { $0.uppercased() == "DEFAULT" }) { + defaultValue = parts[(defIdx + 1)...].joined(separator: " ") + parts = Array(parts[..= 2 { + let name = parts[0] + let dataType = parts[1...].joined(separator: " ") + return ParsedArg(name: name, dataType: dataType, direction: direction, defaultValue: defaultValue) + } else { + return ParsedArg(name: nil, dataType: parts.joined(separator: " "), direction: direction, defaultValue: defaultValue) + } + } + } + + func createProcedureTemplate() -> String? { + "CREATE OR REPLACE PROCEDURE procedure_name(IN param1 TEXT)\nLANGUAGE plpgsql\nAS $$\nBEGIN\n -- procedure body\nEND;\n$$;" + } + + func createFunctionTemplate() -> String? { + "CREATE OR REPLACE FUNCTION function_name(param1 INTEGER)\nRETURNS INTEGER\nLANGUAGE plpgsql\nAS $$\nBEGIN\n RETURN param1;\nEND;\n$$;" + } + // MARK: - Schema func fetchTables(schema: String?) async throws -> [PluginTableInfo] { diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index 783b172c8..b0b520854 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -129,6 +129,14 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { func editViewFallbackTemplate(viewName: String) -> String? func castColumnToText(_ column: String) -> String + // Routine (stored procedure/function) support + var supportsRoutines: Bool { get } + func fetchRoutines(schema: String?) async throws -> [PluginRoutineInfo] + func fetchRoutineDefinition(routine: String, type: String, schema: String?) async throws -> String + func fetchRoutineParameters(routine: String, type: String, schema: String?) async throws -> [PluginRoutineParameterInfo] + func createProcedureTemplate() -> String? + func createFunctionTemplate() -> String? + // All-tables metadata SQL (optional — returns nil for non-SQL databases) func allTablesMetadataSQL(schema: String?) -> String? @@ -256,6 +264,13 @@ public extension PluginDatabaseDriver { func createViewTemplate() -> String? { nil } func editViewFallbackTemplate(viewName: String) -> String? { nil } func castColumnToText(_ column: String) -> String { column } + + var supportsRoutines: Bool { false } + func fetchRoutines(schema: String?) async throws -> [PluginRoutineInfo] { [] } + func fetchRoutineDefinition(routine: String, type: String, schema: String?) async throws -> String { "" } + func fetchRoutineParameters(routine: String, type: String, schema: String?) async throws -> [PluginRoutineParameterInfo] { [] } + func createProcedureTemplate() -> String? { nil } + func createFunctionTemplate() -> String? { nil } func allTablesMetadataSQL(schema: String?) -> String? { nil } func defaultExportQuery(table: String) -> String? { nil } diff --git a/Plugins/TableProPluginKit/PluginRoutineInfo.swift b/Plugins/TableProPluginKit/PluginRoutineInfo.swift new file mode 100644 index 000000000..4e2a90a08 --- /dev/null +++ b/Plugins/TableProPluginKit/PluginRoutineInfo.swift @@ -0,0 +1,11 @@ +import Foundation + +public struct PluginRoutineInfo: Codable, Sendable { + public let name: String + public let type: String + + public init(name: String, type: String) { + self.name = name + self.type = type + } +} diff --git a/Plugins/TableProPluginKit/PluginRoutineParameterInfo.swift b/Plugins/TableProPluginKit/PluginRoutineParameterInfo.swift new file mode 100644 index 000000000..3b89fe0df --- /dev/null +++ b/Plugins/TableProPluginKit/PluginRoutineParameterInfo.swift @@ -0,0 +1,18 @@ +import Foundation + +public struct PluginRoutineParameterInfo: Codable, Sendable { + public let name: String? + public let dataType: String + public let direction: String + public let ordinalPosition: Int + public let defaultValue: String? + + public init(name: String?, dataType: String, direction: String = "IN", + ordinalPosition: Int, defaultValue: String? = nil) { + self.name = name + self.dataType = dataType + self.direction = direction + self.ordinalPosition = ordinalPosition + self.defaultValue = defaultValue + } +} diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 45fa006ce..6995ef568 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -166,6 +166,14 @@ protocol DatabaseDriver: AnyObject { func editViewFallbackTemplate(viewName: String) -> String? func castColumnToText(_ column: String) -> String + // Routine (stored procedure/function) support + var supportsRoutines: Bool { get } + func fetchRoutines() async throws -> [RoutineInfo] + func fetchRoutineDefinition(routine: String, type: RoutineInfo.RoutineType) async throws -> String + func fetchRoutineParameters(routine: String, type: RoutineInfo.RoutineType) async throws -> [RoutineParameterInfo] + func createProcedureTemplate() -> String? + func createFunctionTemplate() -> String? + func foreignKeyDisableStatements() -> [String]? func foreignKeyEnableStatements() -> [String]? @@ -210,6 +218,13 @@ extension DatabaseDriver { func editViewFallbackTemplate(viewName: String) -> String? { nil } func castColumnToText(_ column: String) -> String { column } + var supportsRoutines: Bool { false } + func fetchRoutines() async throws -> [RoutineInfo] { [] } + func fetchRoutineDefinition(routine: String, type: RoutineInfo.RoutineType) async throws -> String { "" } + func fetchRoutineParameters(routine: String, type: RoutineInfo.RoutineType) async throws -> [RoutineParameterInfo] { [] } + func createProcedureTemplate() -> String? { nil } + func createFunctionTemplate() -> String? { nil } + func foreignKeyDisableStatements() -> [String]? { nil } func foreignKeyEnableStatements() -> [String]? { nil } diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index e666d1698..c9a4aac00 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -428,6 +428,48 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { pluginDriver.buildExplainQuery(sql) } + // MARK: - Routines + + var supportsRoutines: Bool { + pluginDriver.supportsRoutines + } + + func fetchRoutines() async throws -> [RoutineInfo] { + let pluginRoutines = try await pluginDriver.fetchRoutines(schema: pluginDriver.currentSchema) + return pluginRoutines.map { r in + RoutineInfo( + name: r.name, + type: r.type.uppercased() == "FUNCTION" ? .function : .procedure + ) + } + } + + func fetchRoutineDefinition(routine: String, type: RoutineInfo.RoutineType) async throws -> String { + try await pluginDriver.fetchRoutineDefinition( + routine: routine, + type: type == .function ? "FUNCTION" : "PROCEDURE", + schema: pluginDriver.currentSchema + ) + } + + func fetchRoutineParameters(routine: String, type: RoutineInfo.RoutineType) async throws -> [RoutineParameterInfo] { + let pluginParams = try await pluginDriver.fetchRoutineParameters( + routine: routine, + type: type == .function ? "FUNCTION" : "PROCEDURE", + schema: pluginDriver.currentSchema + ) + return pluginParams.map { p in + RoutineParameterInfo( + name: p.name, dataType: p.dataType, + direction: p.direction, ordinalPosition: p.ordinalPosition, + defaultValue: p.defaultValue + ) + } + } + + func createProcedureTemplate() -> String? { pluginDriver.createProcedureTemplate() } + func createFunctionTemplate() -> String? { pluginDriver.createFunctionTemplate() } + // MARK: - View Templates func createViewTemplate() -> String? { diff --git a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift index 85b66b291..e4e75b1b4 100644 --- a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift +++ b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift @@ -70,7 +70,9 @@ enum SessionStateFactory { } if let index = tabMgr.selectedTabIndex { tabMgr.tabs[index].isView = payload.isView - tabMgr.tabs[index].isEditable = !payload.isView + tabMgr.tabs[index].isRoutine = payload.isRoutine + tabMgr.tabs[index].routineType = payload.routineType + tabMgr.tabs[index].isEditable = !payload.isView && !payload.isRoutine tabMgr.tabs[index].schemaName = payload.schemaName if payload.showStructure { tabMgr.tabs[index].showStructure = true diff --git a/TablePro/Models/Connection/ConnectionSession.swift b/TablePro/Models/Connection/ConnectionSession.swift index e2a7129b6..b9e4c1a00 100644 --- a/TablePro/Models/Connection/ConnectionSession.swift +++ b/TablePro/Models/Connection/ConnectionSession.swift @@ -23,6 +23,7 @@ struct ConnectionSession: Identifiable { var pendingTruncates: Set = [] var pendingDeletes: Set = [] var tableOperationOptions: [String: TableOperationOptions] = [:] + var routines: [RoutineInfo] = [] var currentSchema: String? var currentDatabase: String? @@ -64,6 +65,7 @@ struct ConnectionSession: Identifiable { /// Note: `cachedPassword` is intentionally NOT cleared — auto-reconnect needs it after disconnect. mutating func clearCachedData() { tables = [] + routines = [] selectedTables = [] pendingTruncates = [] pendingDeletes = [] @@ -79,6 +81,7 @@ struct ConnectionSession: Identifiable { && status == other.status && connection == other.connection && tables == other.tables + && routines == other.routines && pendingTruncates == other.pendingTruncates && pendingDeletes == other.pendingDeletes && tableOperationOptions == other.tableOperationOptions diff --git a/TablePro/Models/Query/EditorTabPayload.swift b/TablePro/Models/Query/EditorTabPayload.swift index 23d68ddf4..4a5a08fe5 100644 --- a/TablePro/Models/Query/EditorTabPayload.swift +++ b/TablePro/Models/Query/EditorTabPayload.swift @@ -37,6 +37,10 @@ internal struct EditorTabPayload: Codable, Hashable { internal let initialQuery: String? /// Whether this tab displays a database view (read-only) internal let isView: Bool + /// Whether this tab displays a stored procedure or function + internal let isRoutine: Bool + /// The type of routine (procedure or function) + internal let routineType: RoutineInfo.RoutineType? /// Whether to show the structure view instead of data (for "Show Structure" context menu) internal let showStructure: Bool /// Whether to skip automatic query execution (used for restored tabs that should lazy-load) @@ -54,7 +58,7 @@ internal struct EditorTabPayload: Codable, Hashable { private enum CodingKeys: String, CodingKey { case id, connectionId, tabType, tableName, databaseName, schemaName - case initialQuery, isView, showStructure, skipAutoExecute, isPreview + case initialQuery, isView, isRoutine, routineType, showStructure, skipAutoExecute, isPreview case initialFilterState, sourceFileURL, erDiagramSchemaKey, intent // Legacy key for backward decoding only case isNewTab @@ -69,6 +73,8 @@ internal struct EditorTabPayload: Codable, Hashable { schemaName: String? = nil, initialQuery: String? = nil, isView: Bool = false, + isRoutine: Bool = false, + routineType: RoutineInfo.RoutineType? = nil, showStructure: Bool = false, skipAutoExecute: Bool = false, isPreview: Bool = false, @@ -85,6 +91,8 @@ internal struct EditorTabPayload: Codable, Hashable { self.schemaName = schemaName self.initialQuery = initialQuery self.isView = isView + self.isRoutine = isRoutine + self.routineType = routineType self.showStructure = showStructure self.skipAutoExecute = skipAutoExecute self.isPreview = isPreview @@ -104,6 +112,8 @@ internal struct EditorTabPayload: Codable, Hashable { schemaName = try container.decodeIfPresent(String.self, forKey: .schemaName) initialQuery = try container.decodeIfPresent(String.self, forKey: .initialQuery) isView = try container.decodeIfPresent(Bool.self, forKey: .isView) ?? false + isRoutine = try container.decodeIfPresent(Bool.self, forKey: .isRoutine) ?? false + routineType = try container.decodeIfPresent(RoutineInfo.RoutineType.self, forKey: .routineType) showStructure = try container.decodeIfPresent(Bool.self, forKey: .showStructure) ?? false skipAutoExecute = try container.decodeIfPresent(Bool.self, forKey: .skipAutoExecute) ?? false isPreview = try container.decodeIfPresent(Bool.self, forKey: .isPreview) ?? false @@ -128,6 +138,8 @@ internal struct EditorTabPayload: Codable, Hashable { try container.encodeIfPresent(schemaName, forKey: .schemaName) try container.encodeIfPresent(initialQuery, forKey: .initialQuery) try container.encode(isView, forKey: .isView) + try container.encode(isRoutine, forKey: .isRoutine) + try container.encodeIfPresent(routineType, forKey: .routineType) try container.encode(showStructure, forKey: .showStructure) try container.encode(skipAutoExecute, forKey: .skipAutoExecute) try container.encode(isPreview, forKey: .isPreview) @@ -147,6 +159,8 @@ internal struct EditorTabPayload: Codable, Hashable { self.schemaName = tab.schemaName self.initialQuery = tab.query self.isView = tab.isView + self.isRoutine = tab.isRoutine + self.routineType = tab.routineType self.showStructure = tab.showStructure self.skipAutoExecute = skipAutoExecute self.isPreview = false diff --git a/TablePro/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index e27d8ffc3..943ff0bee 100644 --- a/TablePro/Models/Query/QueryTab.swift +++ b/TablePro/Models/Query/QueryTab.swift @@ -69,6 +69,8 @@ struct QueryTab: Identifiable, Equatable { var primaryKeyColumn: String? // Detected PK from schema (set by Phase 2 metadata) var isEditable: Bool var isView: Bool // True for database views (read-only) + var isRoutine: Bool = false + var routineType: RoutineInfo.RoutineType? var databaseName: String // Database this tab was opened in (for multi-database restore) var schemaName: String? // Schema this tab was opened in (for multi-schema restore, e.g. PostgreSQL) var showStructure: Bool // Toggle to show structure view instead of data @@ -161,6 +163,8 @@ struct QueryTab: Identifiable, Equatable { self.primaryKeyColumn = nil self.isEditable = tabType == .table self.isView = false + self.isRoutine = false + self.routineType = nil self.databaseName = "" self.schemaName = nil self.showStructure = false @@ -197,6 +201,8 @@ struct QueryTab: Identifiable, Equatable { self.isExecuting = false self.isEditable = persisted.tabType == .table && !persisted.isView self.isView = persisted.isView + self.isRoutine = persisted.isRoutine + self.routineType = persisted.routineType self.databaseName = persisted.databaseName self.schemaName = persisted.schemaName self.showStructure = false @@ -278,6 +284,8 @@ struct QueryTab: Identifiable, Equatable { tabType: tabType, tableName: tableName, isView: isView, + isRoutine: isRoutine, + routineType: routineType, databaseName: databaseName, schemaName: schemaName, sourceFileURL: sourceFileURL, @@ -298,6 +306,8 @@ struct QueryTab: Identifiable, Equatable { && lhs.showStructure == rhs.showStructure && lhs.isEditable == rhs.isEditable && lhs.isView == rhs.isView + && lhs.isRoutine == rhs.isRoutine + && lhs.routineType == rhs.routineType && lhs.tabType == rhs.tabType && lhs.rowsAffected == rhs.rowsAffected && lhs.isPreview == rhs.isPreview diff --git a/TablePro/Models/Query/QueryTabState.swift b/TablePro/Models/Query/QueryTabState.swift index b6fc74e63..76a00777f 100644 --- a/TablePro/Models/Query/QueryTabState.swift +++ b/TablePro/Models/Query/QueryTabState.swift @@ -22,6 +22,8 @@ struct PersistedTab: Codable { let tabType: TabType let tableName: String? var isView: Bool = false + var isRoutine: Bool = false + var routineType: RoutineInfo.RoutineType? var databaseName: String = "" var schemaName: String? var sourceFileURL: URL? diff --git a/TablePro/Models/Schema/RoutineDetailTab.swift b/TablePro/Models/Schema/RoutineDetailTab.swift new file mode 100644 index 000000000..a5105eda1 --- /dev/null +++ b/TablePro/Models/Schema/RoutineDetailTab.swift @@ -0,0 +1,7 @@ +import Foundation + +enum RoutineDetailTab: String, CaseIterable, Hashable { + case definition = "Definition" + case parameters = "Parameters" + case ddl = "DDL" +} diff --git a/TablePro/Models/Schema/RoutineInfo.swift b/TablePro/Models/Schema/RoutineInfo.swift new file mode 100644 index 000000000..9ede0b9dc --- /dev/null +++ b/TablePro/Models/Schema/RoutineInfo.swift @@ -0,0 +1,12 @@ +import Foundation + +struct RoutineInfo: Identifiable, Hashable { + var id: String { name } + let name: String + let type: RoutineType + + enum RoutineType: String, Codable, Sendable { + case procedure = "PROCEDURE" + case function = "FUNCTION" + } +} diff --git a/TablePro/Models/Schema/RoutineParameterInfo.swift b/TablePro/Models/Schema/RoutineParameterInfo.swift new file mode 100644 index 000000000..fcfc4f1e2 --- /dev/null +++ b/TablePro/Models/Schema/RoutineParameterInfo.swift @@ -0,0 +1,10 @@ +import Foundation + +struct RoutineParameterInfo: Identifiable, Hashable { + var id: String { "\(ordinalPosition)_\(name ?? "")" } + let name: String? + let dataType: String + let direction: String + let ordinalPosition: Int + let defaultValue: String? +} diff --git a/TablePro/ViewModels/SidebarViewModel.swift b/TablePro/ViewModels/SidebarViewModel.swift index 42f7a1a94..0b9102d56 100644 --- a/TablePro/ViewModels/SidebarViewModel.swift +++ b/TablePro/ViewModels/SidebarViewModel.swift @@ -34,6 +34,15 @@ final class SidebarViewModel { }() { didSet { UserDefaults.standard.set(isRedisKeysExpanded, forKey: "sidebar.isRedisKeysExpanded") } } + var isRoutinesExpanded: Bool = { + let key = "sidebar.isRoutinesExpanded" + if UserDefaults.standard.object(forKey: key) != nil { + return UserDefaults.standard.bool(forKey: key) + } + return true + }() { + didSet { UserDefaults.standard.set(isRoutinesExpanded, forKey: "sidebar.isRoutinesExpanded") } + } var redisKeyTreeViewModel: RedisKeyTreeViewModel? var showOperationDialog = false var pendingOperationType: TableOperationType? diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 495050ee4..303e3e329 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -349,7 +349,15 @@ struct MainEditorContentView: View { @ViewBuilder private func resultsSection(tab: QueryTab) -> some View { VStack(spacing: 0) { - if tab.showStructure, let tableName = tab.tableName { + if tab.showStructure, tab.isRoutine, let routineName = tab.tableName, + let routineType = tab.routineType { + RoutineDetailView( + routineName: routineName, routineType: routineType, + connection: connection, coordinator: coordinator + ) + .id(routineName) + .frame(maxHeight: .infinity) + } else if tab.showStructure, let tableName = tab.tableName { TableStructureView( tableName: tableName, connection: connection, toolbarState: coordinator.toolbarState, coordinator: coordinator diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift index e5dacead0..9efc63337 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift @@ -56,6 +56,100 @@ extension MainContentCoordinator { openERDiagramTab() } + // MARK: - Routine Tab Operations + + func openRoutineTab(_ routineName: String, routineType: RoutineInfo.RoutineType) { + if tabManager.tabs.isEmpty { + tabManager.addTab(databaseName: connection.database) + if let idx = tabManager.selectedTabIndex { + tabManager.tabs[idx].tableName = routineName + tabManager.tabs[idx].isRoutine = true + tabManager.tabs[idx].routineType = routineType + tabManager.tabs[idx].showStructure = true + } + } else { + let payload = EditorTabPayload( + connectionId: connection.id, + tabType: .table, + tableName: routineName, + databaseName: connection.database, + isRoutine: true, + routineType: routineType, + showStructure: true + ) + WindowOpener.shared.openNativeTab(payload) + } + } + + func openQueryInTab(_ query: String) { + let payload = EditorTabPayload( + connectionId: connection.id, + tabType: .query, + databaseName: connection.database, + initialQuery: query + ) + WindowOpener.shared.openNativeTab(payload) + } + + func createProcedure() { + guard !safeModeLevel.blocksAllWrites else { return } + let driver = DatabaseManager.shared.driver(for: connection.id) + let template = driver?.createProcedureTemplate() + ?? "CREATE PROCEDURE procedure_name()\nBEGIN\n -- procedure body\nEND;" + openQueryInTab(template) + } + + func createFunction() { + guard !safeModeLevel.blocksAllWrites else { return } + let driver = DatabaseManager.shared.driver(for: connection.id) + let template = driver?.createFunctionTemplate() + ?? "CREATE FUNCTION function_name()\nRETURNS INT\nBEGIN\n RETURN 0;\nEND;" + openQueryInTab(template) + } + + func dropRoutine(_ routineName: String, type: RoutineInfo.RoutineType) { + guard !safeModeLevel.blocksAllWrites else { return } + let keyword = type == .function ? "FUNCTION" : "PROCEDURE" + let typeLabel = type == .function + ? String(localized: "function") : String(localized: "procedure") + + Task { @MainActor in + let confirmed = await AlertHelper.confirmDestructive( + title: String(format: String(localized: "Drop %@ '%@'?"), typeLabel, routineName), + message: String(format: String(localized: "This will permanently delete the %@. This action cannot be undone."), typeLabel), + confirmButton: String(localized: "Drop"), + window: contentWindow + ) + guard confirmed else { return } + + guard let adapter = DatabaseManager.shared.driver(for: connectionId) as? PluginDriverAdapter else { return } + let sql = adapter.dropObjectStatement(name: routineName, objectType: keyword, schema: nil, cascade: false) + + do { + _ = try await adapter.execute(query: sql) + await refreshTables() + } catch { + await AlertHelper.showErrorSheet( + title: String(format: String(localized: "Drop %@ failed"), typeLabel), + message: error.localizedDescription, + window: contentWindow + ) + } + } + } + + func showExecuteRoutineSheet(_ routineName: String, type: RoutineInfo.RoutineType) { + Task { @MainActor in + guard let driver = DatabaseManager.shared.driver(for: self.connection.id) else { return } + do { + let params = try await driver.fetchRoutineParameters(routine: routineName, type: type) + activeSheet = .executeRoutine(name: routineName, type: type, parameters: params) + } catch { + activeSheet = .executeRoutine(name: routineName, type: type, parameters: []) + } + } + } + // MARK: - View Operations func createView() { diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 62b748c42..d0d1e1cb3 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -46,6 +46,7 @@ enum ActiveSheet: Identifiable { case quickSwitcher case exportQueryResults case maintenance(operation: String, tableName: String) + case executeRoutine(name: String, type: RoutineInfo.RoutineType, parameters: [RoutineParameterInfo]) var id: String { switch self { @@ -55,6 +56,7 @@ enum ActiveSheet: Identifiable { case .quickSwitcher: "quickSwitcher" case .exportQueryResults: "exportQueryResults" case .maintenance: "maintenance" + case .executeRoutine: "executeRoutine" } } } @@ -372,6 +374,12 @@ final class MainContentCoordinator { let currentDb = DatabaseManager.shared.session(for: connectionId)?.activeDatabase await schemaProvider.resetForDatabase(currentDb, tables: tables, driver: driver) + // Load routines if supported + if driver.supportsRoutines { + let routines = (try? await driver.fetchRoutines()) ?? [] + DatabaseManager.shared.updateSession(connectionId) { $0.routines = routines } + } + // Clean up stale selections and pending operations for tables that no longer exist if let vm = sidebarViewModel { let validNames = Set(tables.map(\.name)) diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 5ee094b96..8574f2d0e 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -201,6 +201,12 @@ struct MainContentView: View { databaseType: connection.type, onExecute: coordinator.executeMaintenance ) + case .executeRoutine(let name, let type, let parameters): + ExecuteRoutineSheet( + routineName: name, routineType: type, + parameters: parameters, databaseType: connection.type, + onExecute: { sql in coordinator.openQueryInTab(sql) } + ) } } diff --git a/TablePro/Views/Sidebar/ExecuteRoutineSheet.swift b/TablePro/Views/Sidebar/ExecuteRoutineSheet.swift new file mode 100644 index 000000000..34ec142fa --- /dev/null +++ b/TablePro/Views/Sidebar/ExecuteRoutineSheet.swift @@ -0,0 +1,172 @@ +import SwiftUI + +struct ExecuteRoutineSheet: View { + @Environment(\.dismiss) private var dismiss + + let routineName: String + let routineType: RoutineInfo.RoutineType + let parameters: [RoutineParameterInfo] + let databaseType: DatabaseType + let onExecute: (String) -> Void + + @State private var paramValues: [String] = [] + + private var inputParameters: [RoutineParameterInfo] { + parameters.filter { $0.direction.uppercased() != "RETURN" } + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + header + Divider() + parameterInputs + sqlPreview + Divider() + buttons + } + .padding(20) + .frame(width: 460) + .onAppear { + paramValues = Array(repeating: "", count: inputParameters.count) + } + } + + // MARK: - Header + + private var header: some View { + HStack { + Image(systemName: routineType == .function ? "function" : "gearshape.2") + .font(.title2) + .foregroundStyle(routineType == .function ? .purple : .teal) + VStack(alignment: .leading, spacing: 2) { + Text(routineName) + .font(.headline) + Text(routineType == .function + ? String(localized: "Function") + : String(localized: "Procedure")) + .font(.subheadline) + .foregroundStyle(.secondary) + } + Spacer() + } + } + + // MARK: - Parameter Inputs + + @ViewBuilder + private var parameterInputs: some View { + if inputParameters.isEmpty { + Text("No input parameters") + .font(.callout) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 8) { + Text("Parameters") + .font(.caption) + .foregroundStyle(.secondary) + ForEach(Array(inputParameters.enumerated()), id: \.offset) { index, param in + HStack { + Text(param.name ?? "param\(index + 1)") + .font(.system(.body, design: .monospaced)) + .frame(width: 120, alignment: .trailing) + TextField(param.dataType, text: paramValueBinding(at: index)) + .textFieldStyle(.roundedBorder) + .font(.system(.body, design: .monospaced)) + } + } + } + } + } + + private func paramValueBinding(at index: Int) -> Binding { + Binding( + get: { index < paramValues.count ? paramValues[index] : "" }, + set: { newValue in + while paramValues.count <= index { paramValues.append("") } + paramValues[index] = newValue + } + ) + } + + // MARK: - SQL Preview + + private var sqlPreview: some View { + VStack(alignment: .leading, spacing: 4) { + Text(String(localized: "SQL Preview")) + .font(.caption) + .foregroundStyle(.secondary) + Text(generateSQL()) + .font(.system(.body, design: .monospaced)) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(nsColor: .textBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: ThemeEngine.shared.activeTheme.cornerRadius.small)) + } + } + + // MARK: - Buttons + + private var buttons: some View { + HStack { + Spacer() + Button(String(localized: "Cancel")) { dismiss() } + .keyboardShortcut(.cancelAction) + Button(String(localized: "Execute")) { + onExecute(generateSQL()) + dismiss() + } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + } + } + + // MARK: - SQL Generation + + private func generateSQL() -> String { + let values = inputParameters.enumerated().map { index, _ in + let val = index < paramValues.count ? paramValues[index] : "" + return val.isEmpty ? "NULL" : formatValue(val) + } + + switch databaseType { + case .mssql: + return generateMSSQLSQL(values: values) + default: + return generateStandardSQL(values: values) + } + } + + private func generateStandardSQL(values: [String]) -> String { + let argList = values.joined(separator: ", ") + if routineType == .procedure { + return "CALL \(routineName)(\(argList));" + } else { + return "SELECT \(routineName)(\(argList));" + } + } + + private func generateMSSQLSQL(values: [String]) -> String { + if routineType == .procedure { + if inputParameters.isEmpty { + return "EXEC \(routineName);" + } + let assignments = inputParameters.enumerated().map { index, param in + let name = param.name ?? "param\(index + 1)" + let val = index < values.count ? values[index] : "NULL" + return "@\(name) = \(val)" + } + return "EXEC \(routineName) \(assignments.joined(separator: ", "));" + } else { + let argList = values.joined(separator: ", ") + return "SELECT dbo.\(routineName)(\(argList));" + } + } + + private func formatValue(_ value: String) -> String { + if Int64(value) != nil || (Double(value) != nil && value.contains(".")) { + return value + } + let escaped = value.replacingOccurrences(of: "'", with: "''") + return "'\(escaped)'" + } +} diff --git a/TablePro/Views/Sidebar/RoutineRowView.swift b/TablePro/Views/Sidebar/RoutineRowView.swift new file mode 100644 index 000000000..f4205f612 --- /dev/null +++ b/TablePro/Views/Sidebar/RoutineRowView.swift @@ -0,0 +1,21 @@ +import SwiftUI + +struct RoutineRowView: View { + let routine: RoutineInfo + let isActive: Bool + + var body: some View { + HStack(spacing: 6) { + Image(systemName: routine.type == .function ? "function" : "gearshape.2") + .font(.system(size: 12)) + .foregroundStyle(routine.type == .function ? .purple : .teal) + .frame(width: 16) + Text(routine.name) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + } + .padding(.vertical, 1) + .foregroundStyle(isActive ? .primary : .secondary) + } +} diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 2288c7ac5..92a44848b 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -245,6 +245,10 @@ struct SidebarView: View { } } + if let routines = routinesForCurrentConnection, !routines.isEmpty { + routinesSection(routines: routines) + } + if viewModel.databaseType == .redis, let keyTreeVM = sidebarState.redisKeyTreeViewModel { Section(isExpanded: $viewModel.isRedisKeysExpanded) { RedisKeyTreeView( @@ -284,6 +288,73 @@ struct SidebarView: View { sidebarState.selectedTables.removeAll() } } + + // MARK: - Routines + + private var routinesForCurrentConnection: [RoutineInfo]? { + let session = DatabaseManager.shared.session(for: connectionId) + let routines = session?.routines ?? [] + guard !routines.isEmpty else { return nil } + if viewModel.debouncedSearchText.isEmpty { return routines } + return routines.filter { $0.name.localizedCaseInsensitiveContains(viewModel.debouncedSearchText) } + } + + @ViewBuilder + private func routinesSection(routines: [RoutineInfo]) -> some View { + Section(isExpanded: $viewModel.isRoutinesExpanded) { + ForEach(routines) { routine in + RoutineRowView(routine: routine, isActive: false) + .overlay { + DoubleClickDetector { + coordinator?.openRoutineTab(routine.name, routineType: routine.type) + } + } + .contextMenu { + routineContextMenu(routine: routine) + } + } + } header: { + Text("Routines") + .contextMenu { + if !(coordinator?.safeModeLevel.blocksAllWrites ?? true) { + Button("Create Procedure...") { + coordinator?.createProcedure() + } + Button("Create Function...") { + coordinator?.createFunction() + } + } + } + } + } + + @ViewBuilder + private func routineContextMenu(routine: RoutineInfo) -> some View { + Button("View Definition") { + coordinator?.openRoutineTab(routine.name, routineType: routine.type) + } + + Divider() + + Button("Execute...") { + coordinator?.showExecuteRoutineSheet(routine.name, type: routine.type) + } + + Divider() + + let typeLabel = routine.type == .function ? "Function" : "Procedure" + Button("Drop \(typeLabel)", role: .destructive) { + coordinator?.dropRoutine(routine.name, type: routine.type) + } + .disabled(coordinator?.safeModeLevel.blocksAllWrites ?? true) + + Divider() + + Button("Copy Name") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(routine.name, forType: .string) + } + } } // MARK: - Preview diff --git a/TablePro/Views/Structure/RoutineDetailView+DataLoading.swift b/TablePro/Views/Structure/RoutineDetailView+DataLoading.swift new file mode 100644 index 000000000..493a47802 --- /dev/null +++ b/TablePro/Views/Structure/RoutineDetailView+DataLoading.swift @@ -0,0 +1,45 @@ +import Foundation +import os + +extension RoutineDetailView { + @MainActor + func loadInitialData() async { + isLoading = true + errorMessage = nil + + guard let driver = DatabaseManager.shared.driver(for: connection.id) else { + errorMessage = String(localized: "Not connected") + isLoading = false + return + } + + do { + definition = try await driver.fetchRoutineDefinition(routine: routineName, type: routineType) + loadedTabs.insert(.definition) + isLoading = false + } catch { + errorMessage = error.localizedDescription + isLoading = false + } + } + + @MainActor + func loadTabDataIfNeeded(_ tab: RoutineDetailTab) async { + guard !loadedTabs.contains(tab) else { return } + guard let driver = DatabaseManager.shared.driver(for: connection.id) else { return } + + do { + switch tab { + case .definition: + definition = try await driver.fetchRoutineDefinition(routine: routineName, type: routineType) + case .parameters: + parameters = try await driver.fetchRoutineParameters(routine: routineName, type: routineType) + case .ddl: + ddlStatement = try await driver.fetchRoutineDefinition(routine: routineName, type: routineType) + } + loadedTabs.insert(tab) + } catch { + Self.logger.error("Failed to load \(tab.rawValue) for \(routineName): \(error.localizedDescription)") + } + } +} diff --git a/TablePro/Views/Structure/RoutineDetailView.swift b/TablePro/Views/Structure/RoutineDetailView.swift new file mode 100644 index 000000000..c232daa8f --- /dev/null +++ b/TablePro/Views/Structure/RoutineDetailView.swift @@ -0,0 +1,138 @@ +import os +import SwiftUI + +struct RoutineDetailView: View { + static let logger = Logger(subsystem: "com.TablePro", category: "RoutineDetailView") + + let routineName: String + let routineType: RoutineInfo.RoutineType + let connection: DatabaseConnection + let coordinator: MainContentCoordinator? + + @State var selectedTab: RoutineDetailTab = .definition + @State var definition: String = "" + @State var parameters: [RoutineParameterInfo] = [] + @State var ddlStatement: String = "" + @State var ddlFontSize: CGFloat = 13 + @State var isLoading = true + @State var errorMessage: String? + @State var loadedTabs: Set = [] + + var body: some View { + VStack(spacing: 0) { + toolbar + Divider() + contentArea + } + .task { await loadInitialData() } + .onChange(of: selectedTab) { _, newValue in + Task { await loadTabDataIfNeeded(newValue) } + } + } + + // MARK: - Toolbar + + private var toolbar: some View { + HStack { + Spacer() + Picker("", selection: $selectedTab) { + ForEach(RoutineDetailTab.allCases, id: \.self) { tab in + Text(tab.rawValue).tag(tab) + } + } + .pickerStyle(.segmented) + .fixedSize() + Spacer() + } + .padding(.vertical, 6) + .padding(.horizontal, 12) + } + + // MARK: - Content + + @ViewBuilder + private var contentArea: some View { + if isLoading { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error = errorMessage { + VStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle") + .font(.title) + .foregroundStyle(.orange) + Text(error) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } else { + switch selectedTab { + case .definition: + DDLTextView(ddl: definition, fontSize: $ddlFontSize) + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .parameters: + parametersTable + case .ddl: + DDLTextView(ddl: ddlStatement, fontSize: $ddlFontSize) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } + + // MARK: - Parameters Table + + private var parametersTable: some View { + Group { + if parameters.isEmpty { + VStack(spacing: 8) { + Image(systemName: "list.bullet") + .font(.title) + .foregroundStyle(.tertiary) + Text("No parameters") + .font(.body) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + Table(parameters) { + TableColumn("Direction") { param in + Text(param.direction) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(directionColor(param.direction)) + } + .width(min: 60, ideal: 80, max: 100) + + TableColumn("Name") { param in + Text(param.name ?? "(unnamed)") + .font(.system(.body, design: .monospaced)) + .foregroundStyle(param.name == nil ? .secondary : .primary) + } + .width(min: 100, ideal: 160) + + TableColumn("Type") { param in + Text(param.dataType) + .font(.system(.body, design: .monospaced)) + } + .width(min: 100, ideal: 160) + + TableColumn("Default") { param in + Text(param.defaultValue ?? "") + .font(.system(.body, design: .monospaced)) + .foregroundStyle(.secondary) + } + .width(min: 80, ideal: 120) + } + } + } + } + + private func directionColor(_ direction: String) -> Color { + switch direction.uppercased() { + case "RETURN": .purple + case "OUT", "INOUT": .orange + default: .blue + } + } +} From 03e0852aef3facd9a9b61c60361831ce032601e4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 12 Apr 2026 19:30:06 +0700 Subject: [PATCH 05/29] fix: routine tab behavior - replace in-place, hide table UI, fix persistence --- .../Infrastructure/SessionStateFactory.swift | 18 +++-- TablePro/Models/Query/QueryTab.swift | 4 +- .../Views/Main/Child/MainStatusBarView.swift | 12 +-- ...ainContentCoordinator+SidebarActions.swift | 73 ++++++++++++++----- .../Extensions/MainContentView+Setup.swift | 2 +- TablePro/Views/Sidebar/SidebarView.swift | 55 +++++++------- 6 files changed, 102 insertions(+), 62 deletions(-) diff --git a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift index e4e75b1b4..22723fcf9 100644 --- a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift +++ b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift @@ -55,7 +55,17 @@ enum SessionStateFactory { case .table: toolbarSt.isTableTab = true if let tableName = payload.tableName { - if payload.isPreview { + if payload.isRoutine { + // Routines open directly to detail view — no data query + var newTab = QueryTab(title: tableName, tabType: .table, tableName: tableName) + newTab.databaseName = payload.databaseName ?? connection.database + newTab.isRoutine = true + newTab.routineType = payload.routineType + newTab.isEditable = false + newTab.showStructure = true + tabMgr.tabs.append(newTab) + tabMgr.selectedTabId = newTab.id + } else if payload.isPreview { tabMgr.addPreviewTableTab( tableName: tableName, databaseType: connection.type, @@ -68,11 +78,9 @@ enum SessionStateFactory { databaseName: payload.databaseName ?? connection.database ) } - if let index = tabMgr.selectedTabIndex { + if let index = tabMgr.selectedTabIndex, !payload.isRoutine { tabMgr.tabs[index].isView = payload.isView - tabMgr.tabs[index].isRoutine = payload.isRoutine - tabMgr.tabs[index].routineType = payload.routineType - tabMgr.tabs[index].isEditable = !payload.isView && !payload.isRoutine + tabMgr.tabs[index].isEditable = !payload.isView tabMgr.tabs[index].schemaName = payload.schemaName if payload.showStructure { tabMgr.tabs[index].showStructure = true diff --git a/TablePro/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index 943ff0bee..0d662e382 100644 --- a/TablePro/Models/Query/QueryTab.swift +++ b/TablePro/Models/Query/QueryTab.swift @@ -199,13 +199,13 @@ struct QueryTab: Identifiable, Equatable { self.rowsAffected = 0 self.errorMessage = nil self.isExecuting = false - self.isEditable = persisted.tabType == .table && !persisted.isView + self.isEditable = persisted.tabType == .table && !persisted.isView && !persisted.isRoutine self.isView = persisted.isView self.isRoutine = persisted.isRoutine self.routineType = persisted.routineType self.databaseName = persisted.databaseName self.schemaName = persisted.schemaName - self.showStructure = false + self.showStructure = persisted.isRoutine self.erDiagramSchemaKey = persisted.erDiagramSchemaKey self.pendingChanges = TabPendingChanges() self.selectedRowIndices = [] diff --git a/TablePro/Views/Main/Child/MainStatusBarView.swift b/TablePro/Views/Main/Child/MainStatusBarView.swift index 5adb87671..edbff1d69 100644 --- a/TablePro/Views/Main/Child/MainStatusBarView.swift +++ b/TablePro/Views/Main/Child/MainStatusBarView.swift @@ -35,8 +35,8 @@ struct MainStatusBarView: View { var body: some View { HStack { - // Left: Data/Structure toggle for table tabs - if let tab = tab, tab.tabType == .table, tab.tableName != nil { + // Left: Data/Structure toggle for table tabs (not for routines) + if let tab = tab, tab.tabType == .table, tab.tableName != nil, !tab.isRoutine { Picker(String(localized: "View Mode"), selection: $showStructure) { Label("Data", systemImage: "tablecells").tag(false) Label("Structure", systemImage: "list.bullet.rectangle").tag(true) @@ -95,8 +95,8 @@ struct MainStatusBarView: View { } } - // Filters toggle button - if let tab = tab, tab.tabType == .table, tab.tableName != nil { + // Filters toggle button (not for routines) + if let tab = tab, tab.tabType == .table, tab.tableName != nil, !tab.isRoutine { Toggle(isOn: Binding( get: { filterStateManager.isVisible }, set: { _ in filterStateManager.toggle() } @@ -117,8 +117,8 @@ struct MainStatusBarView: View { .help(String(localized: "Toggle Filters (⌘F)")) } - // Pagination controls for table tabs - if let tab = tab, tab.tabType == .table, tab.tableName != nil, + // Pagination controls for table tabs (not for routines) + if let tab = tab, tab.tabType == .table, tab.tableName != nil, !tab.isRoutine, let total = tab.pagination.totalRowCount, total > 0 { PaginationControlsView( pagination: tab.pagination, diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift index 776c47fe1..aab9451fd 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift @@ -59,26 +59,63 @@ extension MainContentCoordinator { // MARK: - Routine Tab Operations func openRoutineTab(_ routineName: String, routineType: RoutineInfo.RoutineType) { - if tabManager.tabs.isEmpty { - tabManager.addTab(databaseName: connection.database) - if let idx = tabManager.selectedTabIndex { - tabManager.tabs[idx].tableName = routineName - tabManager.tabs[idx].isRoutine = true - tabManager.tabs[idx].routineType = routineType - tabManager.tabs[idx].showStructure = true + // Fast path: already viewing this routine + if let current = tabManager.selectedTab, + current.isRoutine && current.tableName == routineName { + return + } + + // Check if this routine is open in current window's tabs + if let existing = tabManager.tabs.first(where: { + $0.isRoutine && $0.tableName == routineName + }) { + tabManager.selectedTabId = existing.id + return + } + + // Check if another native window tab has this routine — switch to it + if let keyWindow = NSApp.keyWindow { + let ownWindows = Set(WindowLifecycleMonitor.shared.windows(for: connectionId).map { ObjectIdentifier($0) }) + let tabbedWindows = keyWindow.tabbedWindows ?? [keyWindow] + for window in tabbedWindows + where window.title == routineName && ownWindows.contains(ObjectIdentifier(window)) { + window.makeKeyAndOrderFront(nil) + return } - } else { - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .table, - tableName: routineName, - databaseName: connection.database, - isRoutine: true, - routineType: routineType, - showStructure: true - ) - WindowOpener.shared.openNativeTab(payload) } + + // Replace current tab if it's also a routine (read-only, no unsaved state) + if let idx = tabManager.selectedTabIndex, tabManager.tabs[idx].isRoutine { + tabManager.tabs[idx].tableName = routineName + tabManager.tabs[idx].routineType = routineType + tabManager.tabs[idx].title = routineName + return + } + + // No tabs open: create inline + if tabManager.tabs.isEmpty { + var newTab = QueryTab(title: routineName, tabType: .table, tableName: routineName) + newTab.databaseName = connection.database + newTab.isRoutine = true + newTab.routineType = routineType + newTab.isEditable = false + newTab.showStructure = true + tabManager.tabs.append(newTab) + tabManager.selectedTabId = newTab.id + return + } + + // Tabs exist but current is not a routine: open new native tab + let payload = EditorTabPayload( + connectionId: connection.id, + tabType: .table, + tableName: routineName, + databaseName: connection.database, + isRoutine: true, + routineType: routineType, + showStructure: true + ) + WindowOpener.shared.openNativeTab(payload) } func openQueryInTab(_ query: String) { diff --git a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index fb96aae26..4a9925364 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -83,7 +83,7 @@ extension MainContentView { let result = await coordinator.persistence.restoreFromDisk() if !result.tabs.isEmpty { var restoredTabs = result.tabs - for i in restoredTabs.indices where restoredTabs[i].tabType == .table { + for i in restoredTabs.indices where restoredTabs[i].tabType == .table && !restoredTabs[i].isRoutine { if let tableName = restoredTabs[i].tableName { restoredTabs[i].query = QueryTab.buildBaseTableQuery( tableName: tableName, diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index ccd3aa7f5..f5bdd076a 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -246,7 +246,31 @@ struct SidebarView: View { } if let routines = routinesForCurrentConnection, !routines.isEmpty { - routinesSection(routines: routines) + Section(isExpanded: $viewModel.isRoutinesExpanded) { + ForEach(routines) { routine in + Button { + coordinator?.openRoutineTab(routine.name, routineType: routine.type) + } label: { + RoutineRowView(routine: routine, isActive: false) + } + .buttonStyle(.plain) + .contextMenu { + routineContextMenu(routine: routine) + } + } + } header: { + Text("Routines") + .contextMenu { + if !(coordinator?.safeModeLevel.blocksAllWrites ?? true) { + Button("Create Procedure...") { + coordinator?.createProcedure() + } + Button("Create Function...") { + coordinator?.createFunction() + } + } + } + } } if viewModel.databaseType == .redis, let keyTreeVM = sidebarState.redisKeyTreeViewModel { @@ -300,35 +324,6 @@ struct SidebarView: View { return routines.filter { $0.name.localizedCaseInsensitiveContains(viewModel.debouncedSearchText) } } - @ViewBuilder - private func routinesSection(routines: [RoutineInfo]) -> some View { - Section(isExpanded: $viewModel.isRoutinesExpanded) { - ForEach(routines) { routine in - RoutineRowView(routine: routine, isActive: false) - .overlay { - DoubleClickDetector { - coordinator?.openRoutineTab(routine.name, routineType: routine.type) - } - } - .contextMenu { - routineContextMenu(routine: routine) - } - } - } header: { - Text("Routines") - .contextMenu { - if !(coordinator?.safeModeLevel.blocksAllWrites ?? true) { - Button("Create Procedure...") { - coordinator?.createProcedure() - } - Button("Create Function...") { - coordinator?.createFunction() - } - } - } - } - } - @ViewBuilder private func routineContextMenu(routine: RoutineInfo) -> some View { Button("View Definition") { From bed13f54dcd0a31cf3c0c7d04412a01930055f39 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 12 Apr 2026 19:32:35 +0700 Subject: [PATCH 06/29] feat: Cmd+Click on routine opens new tab, single click replaces current --- ...ainContentCoordinator+SidebarActions.swift | 35 +++++++++++-------- TablePro/Views/Sidebar/SidebarView.swift | 6 +++- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift index aab9451fd..beeb509a4 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift @@ -58,15 +58,15 @@ extension MainContentCoordinator { // MARK: - Routine Tab Operations - func openRoutineTab(_ routineName: String, routineType: RoutineInfo.RoutineType) { + func openRoutineTab(_ routineName: String, routineType: RoutineInfo.RoutineType, forceNewTab: Bool = false) { // Fast path: already viewing this routine if let current = tabManager.selectedTab, - current.isRoutine && current.tableName == routineName { + current.isRoutine, current.tableName == routineName { return } // Check if this routine is open in current window's tabs - if let existing = tabManager.tabs.first(where: { + if !forceNewTab, let existing = tabManager.tabs.first(where: { $0.isRoutine && $0.tableName == routineName }) { tabManager.selectedTabId = existing.id @@ -74,7 +74,7 @@ extension MainContentCoordinator { } // Check if another native window tab has this routine — switch to it - if let keyWindow = NSApp.keyWindow { + if !forceNewTab, let keyWindow = NSApp.keyWindow { let ownWindows = Set(WindowLifecycleMonitor.shared.windows(for: connectionId).map { ObjectIdentifier($0) }) let tabbedWindows = keyWindow.tabbedWindows ?? [keyWindow] for window in tabbedWindows @@ -84,8 +84,9 @@ extension MainContentCoordinator { } } - // Replace current tab if it's also a routine (read-only, no unsaved state) - if let idx = tabManager.selectedTabIndex, tabManager.tabs[idx].isRoutine { + // Replace current routine tab in-place (single click = browse) + // Cmd+Click skips this to force a new tab + if !forceNewTab, let idx = tabManager.selectedTabIndex, tabManager.tabs[idx].isRoutine { tabManager.tabs[idx].tableName = routineName tabManager.tabs[idx].routineType = routineType tabManager.tabs[idx].title = routineName @@ -94,18 +95,11 @@ extension MainContentCoordinator { // No tabs open: create inline if tabManager.tabs.isEmpty { - var newTab = QueryTab(title: routineName, tabType: .table, tableName: routineName) - newTab.databaseName = connection.database - newTab.isRoutine = true - newTab.routineType = routineType - newTab.isEditable = false - newTab.showStructure = true - tabManager.tabs.append(newTab) - tabManager.selectedTabId = newTab.id + appendRoutineTab(routineName, routineType: routineType) return } - // Tabs exist but current is not a routine: open new native tab + // Tabs exist but current is not a routine (or forceNewTab): open new native tab let payload = EditorTabPayload( connectionId: connection.id, tabType: .table, @@ -118,6 +112,17 @@ extension MainContentCoordinator { WindowOpener.shared.openNativeTab(payload) } + private func appendRoutineTab(_ routineName: String, routineType: RoutineInfo.RoutineType) { + var newTab = QueryTab(title: routineName, tabType: .table, tableName: routineName) + newTab.databaseName = connection.database + newTab.isRoutine = true + newTab.routineType = routineType + newTab.isEditable = false + newTab.showStructure = true + tabManager.tabs.append(newTab) + tabManager.selectedTabId = newTab.id + } + func openQueryInTab(_ query: String) { if tabManager.tabs.isEmpty { tabManager.addTab(initialQuery: query, databaseName: connection.database) diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index f5bdd076a..d49c8e1b3 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -249,7 +249,11 @@ struct SidebarView: View { Section(isExpanded: $viewModel.isRoutinesExpanded) { ForEach(routines) { routine in Button { - coordinator?.openRoutineTab(routine.name, routineType: routine.type) + let forceNewTab = NSEvent.modifierFlags.contains(.command) + coordinator?.openRoutineTab( + routine.name, routineType: routine.type, + forceNewTab: forceNewTab + ) } label: { RoutineRowView(routine: routine, isActive: false) } From dce1a837616aaa33f8df10890dd2803356ec3c97 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 12 Apr 2026 19:50:01 +0700 Subject: [PATCH 07/29] refactor: replace preview tab system with Cmd+Click for new tabs --- CHANGELOG.md | 4 + TablePro/ContentView.swift | 19 -- .../Infrastructure/SessionStateFactory.swift | 18 +- .../MainContentCoordinator+Navigation.swift | 172 ++---------------- .../MainContentView+EventHandlers.swift | 44 ++--- .../Views/Main/SidebarNavigationResult.swift | 18 +- .../Views/Settings/GeneralSettingsView.swift | 6 - TablePro/Views/Sidebar/SidebarView.swift | 8 - TableProTests/Models/PreviewTabTests.swift | 55 +----- .../Views/SidebarNavigationResultTests.swift | 62 ------- 10 files changed, 41 insertions(+), 365 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d6d45a1c..2bc4e7880 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Sidebar table click: single click replaces current table tab in-place, Cmd+Click opens new tab (replaces preview tab system) + ## [0.31.1] - 2026-04-12 ### Fixed diff --git a/TablePro/ContentView.swift b/TablePro/ContentView.swift index 3e30109c5..952f56537 100644 --- a/TablePro/ContentView.swift +++ b/TablePro/ContentView.swift @@ -143,7 +143,6 @@ struct ContentView: View { || { guard let name = currentSession?.connection.name, !name.isEmpty else { return false } return notificationWindow.subtitle == name - || notificationWindow.subtitle == "\(name) — Preview" }() guard isOurWindow else { return } } @@ -161,24 +160,6 @@ struct ContentView: View { tables: sessionTablesBinding, sidebarState: SharedSidebarState.forConnection(currentSession.connection.id), activeTableName: windowTitle, - onDoubleClick: { table in - let isView = table.type == .view - if let preview = WindowLifecycleMonitor.shared.previewWindow(for: currentSession.connection.id), - let previewCoordinator = MainContentCoordinator.coordinator(for: preview.windowId) { - // If the preview tab shows this table, promote it - if previewCoordinator.tabManager.selectedTab?.tableName == table.name { - previewCoordinator.promotePreviewTab() - } else { - // Preview shows a different table — promote it first, then open this table permanently - previewCoordinator.promotePreviewTab() - sessionState.coordinator.openTableTab(table.name, isView: isView) - } - } else { - // No preview tab — promote current if it's a preview, otherwise open permanently - sessionState.coordinator.promotePreviewTab() - sessionState.coordinator.openTableTab(table.name, isView: isView) - } - }, pendingTruncates: sessionPendingTruncatesBinding, pendingDeletes: sessionPendingDeletesBinding, tableOperationOptions: sessionTableOperationOptionsBinding, diff --git a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift index 85b66b291..791ac139b 100644 --- a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift +++ b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift @@ -55,19 +55,11 @@ enum SessionStateFactory { case .table: toolbarSt.isTableTab = true if let tableName = payload.tableName { - if payload.isPreview { - tabMgr.addPreviewTableTab( - tableName: tableName, - databaseType: connection.type, - databaseName: payload.databaseName ?? connection.database - ) - } else { - tabMgr.addTableTab( - tableName: tableName, - databaseType: connection.type, - databaseName: payload.databaseName ?? connection.database - ) - } + tabMgr.addTableTab( + tableName: tableName, + databaseType: connection.type, + databaseName: payload.databaseName ?? connection.database + ) if let index = tabMgr.selectedTabIndex { tabMgr.tabs[index].isView = payload.isView tabMgr.tabs[index].isEditable = !payload.isView diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 5c8e936be..3ec1b841a 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -15,7 +15,7 @@ private let navigationLogger = Logger(subsystem: "com.TablePro", category: "Main extension MainContentCoordinator { // MARK: - Table Tab Opening - func openTableTab(_ tableName: String, showStructure: Bool = false, isView: Bool = false) { + func openTableTab(_ tableName: String, showStructure: Bool = false, isView: Bool = false, forceNewTab: Bool = false) { let navigationModel = PluginMetadataRegistry.shared.snapshot( forTypeId: connection.type.pluginTypeId )?.navigationModel ?? .standard @@ -72,25 +72,12 @@ extension MainContentCoordinator { } // If no tabs exist (empty state), add a table tab directly. - // In preview mode, mark it as preview so subsequent clicks replace it. if tabManager.tabs.isEmpty { - if AppSettingsManager.shared.tabs.enablePreviewTabs { - tabManager.addPreviewTableTab( - tableName: tableName, - databaseType: connection.type, - databaseName: currentDatabase - ) - if let wid = windowId { - WindowLifecycleMonitor.shared.setPreview(true, for: wid) - WindowLifecycleMonitor.shared.window(for: wid)?.subtitle = "\(connection.name) — Preview" - } - } else { - tabManager.addTableTab( - tableName: tableName, - databaseType: connection.type, - databaseName: currentDatabase - ) - } + tabManager.addTableTab( + tableName: tableName, + databaseType: connection.type, + databaseName: currentDatabase + ) if let tabIndex = tabManager.selectedTabIndex { tabManager.tabs[tabIndex].isView = isView tabManager.tabs[tabIndex].isEditable = !isView @@ -140,133 +127,20 @@ extension MainContentCoordinator { let hasActiveWork = changeManager.hasChanges || filterStateManager.hasAppliedFilters || (tabManager.selectedTab?.sortState.isSorting ?? false) - if hasActiveWork { - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .table, - tableName: tableName, - databaseName: currentDatabase, - schemaName: currentSchema, - isView: isView, - showStructure: showStructure - ) - WindowOpener.shared.openNativeTab(payload) - return - } - - // Preview tab mode: reuse or create a preview tab instead of a new native window - if AppSettingsManager.shared.tabs.enablePreviewTabs { - openPreviewTab(tableName, isView: isView, databaseName: currentDatabase, schemaName: currentSchema, showStructure: showStructure) - return - } - - // Default: open table in a new native tab - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .table, - tableName: tableName, - databaseName: currentDatabase, - schemaName: currentSchema, - isView: isView, - showStructure: showStructure - ) - WindowOpener.shared.openNativeTab(payload) - } - - // MARK: - Preview Tabs - - func openPreviewTab( - _ tableName: String, isView: Bool = false, - databaseName: String = "", schemaName: String? = nil, - showStructure: Bool = false - ) { - // Check if a preview window already exists for this connection - if let preview = WindowLifecycleMonitor.shared.previewWindow(for: connectionId) { - if let previewCoordinator = Self.coordinator(for: preview.windowId) { - // Skip if preview tab already shows this table - if let current = previewCoordinator.tabManager.selectedTab, - current.tableName == tableName, - current.databaseName == databaseName { - preview.window.makeKeyAndOrderFront(nil) - return - } - if let oldTab = previewCoordinator.tabManager.selectedTab, - let oldTableName = oldTab.tableName { - previewCoordinator.filterStateManager.saveLastFilters(for: oldTableName) - } - previewCoordinator.tabManager.replaceTabContent( - tableName: tableName, - databaseType: connection.type, - isView: isView, - databaseName: databaseName, - schemaName: schemaName, - isPreview: true - ) - previewCoordinator.filterStateManager.clearAll() - if let tabIndex = previewCoordinator.tabManager.selectedTabIndex { - previewCoordinator.tabManager.tabs[tabIndex].showStructure = showStructure - previewCoordinator.tabManager.tabs[tabIndex].pagination.reset() - previewCoordinator.toolbarState.isTableTab = true - } - preview.window.makeKeyAndOrderFront(nil) - previewCoordinator.restoreColumnLayoutForTable(tableName) - previewCoordinator.restoreFiltersForTable(tableName) - previewCoordinator.runQuery() - return - } - } - // No preview window exists but current tab can be reused: replace in-place. - // This covers: preview tabs, non-preview table tabs with no active work, - // and empty/default query tabs (no user-entered content). - let isReusableTab: Bool = { - guard let tab = tabManager.selectedTab else { return false } - if tab.isPreview { return true } - // Table tab with no active work - if tab.tabType == .table && !changeManager.hasChanges - && !filterStateManager.hasAppliedFilters && !tab.sortState.isSorting { - return true - } - // Empty/default query tab (no user content, no results, never executed) - if tab.tabType == .query && tab.lastExecutedAt == nil - && tab.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return true - } - return false - }() - if let selectedTab = tabManager.selectedTab, isReusableTab { - // Skip if already showing this table - if selectedTab.tableName == tableName, selectedTab.databaseName == databaseName { - return - } - // If preview tab has active work, promote it and open new tab instead - let previewHasWork = changeManager.hasChanges - || filterStateManager.hasAppliedFilters - || selectedTab.sortState.isSorting - if previewHasWork { - promotePreviewTab() - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .table, - tableName: tableName, - databaseName: databaseName, - schemaName: schemaName, - isView: isView, - showStructure: showStructure - ) - WindowOpener.shared.openNativeTab(payload) - return - } - if let oldTableName = selectedTab.tableName { + // When not forced to new tab: replace current table tab in-place if it has no active work + if !forceNewTab && !hasActiveWork, + let currentTab = tabManager.selectedTab, + currentTab.tabType == .table { + if let oldTableName = currentTab.tableName { filterStateManager.saveLastFilters(for: oldTableName) } tabManager.replaceTabContent( tableName: tableName, databaseType: connection.type, isView: isView, - databaseName: databaseName, - schemaName: schemaName, - isPreview: true + databaseName: currentDatabase, + schemaName: currentSchema ) filterStateManager.clearAll() if let tabIndex = tabManager.selectedTabIndex { @@ -280,31 +154,19 @@ extension MainContentCoordinator { return } - // No preview tab anywhere: create a new native preview tab + // Open table in a new native tab (Cmd+Click, active work, or current tab is not a table) let payload = EditorTabPayload( connectionId: connection.id, tabType: .table, tableName: tableName, - databaseName: databaseName, - schemaName: schemaName, + databaseName: currentDatabase, + schemaName: currentSchema, isView: isView, - showStructure: showStructure, - isPreview: true + showStructure: showStructure ) WindowOpener.shared.openNativeTab(payload) } - func promotePreviewTab() { - guard let tabIndex = tabManager.selectedTabIndex, - tabManager.tabs[tabIndex].isPreview else { return } - tabManager.tabs[tabIndex].isPreview = false - - if let wid = windowId { - WindowLifecycleMonitor.shared.setPreview(false, for: wid) - WindowLifecycleMonitor.shared.window(for: wid)?.subtitle = connection.name - } - } - func showAllTablesMetadata() { guard let sql = allTablesMetadataSQL() else { return } diff --git a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index 2bb333dd4..83161887c 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -42,22 +42,12 @@ extension MainContentView { guard !coordinator.isTearingDown else { return } guard !coordinator.isUpdatingColumnLayout else { return } - // Promote preview tab if user has interacted with it - if let tab = tabManager.selectedTab, tab.isPreview, tab.hasUserInteraction { - coordinator.promotePreviewTab() - } - - // Persist tab changes (exclude preview tabs from persistence) - let persistableTabs = newTabs.filter { !$0.isPreview } - if persistableTabs.isEmpty { + if newTabs.isEmpty { coordinator.persistence.clearSavedState() } else { - let normalizedSelectedId = - persistableTabs.contains(where: { $0.id == tabManager.selectedTabId }) - ? tabManager.selectedTabId : persistableTabs.first?.id coordinator.persistence.saveNow( - tabs: persistableTabs, - selectedTabId: normalizedSelectedId + tabs: newTabs, + selectedTabId: tabManager.selectedTabId ) } } @@ -105,28 +95,18 @@ extension MainContentView { return } - let isPreviewMode = AppSettingsManager.shared.tabs.enablePreviewTabs - let hasPreview = WindowLifecycleMonitor.shared.previewWindow(for: connection.id) != nil - - let result = SidebarNavigationResult.resolve( - clickedTableName: tableName, - currentTabTableName: tabManager.selectedTab?.tableName, - hasExistingTabs: !tabManager.tabs.isEmpty, - isPreviewTabMode: isPreviewMode, - hasPreviewTab: hasPreview - ) + // Cmd+Click forces a new native tab (same pattern as routine tabs) + let forceNewTab = NSEvent.modifierFlags.contains(.command) - switch result { - case .skip: - return - case .openInPlace: + if !tabManager.tabs.isEmpty { + // Skip if clicked table matches the active tab + if tabManager.selectedTab?.tableName == tableName { + return + } selectedRowIndices = [] - coordinator.openTableTab(tableName, isView: isView) - case .revertAndOpenNewWindow: - coordinator.openTableTab(tableName, isView: isView) - case .replacePreviewTab, .openNewPreviewTab: - coordinator.openTableTab(tableName, isView: isView) } + + coordinator.openTableTab(tableName, isView: isView, forceNewTab: forceNewTab) } /// Keep sidebar selection in sync with the current window's tab. diff --git a/TablePro/Views/Main/SidebarNavigationResult.swift b/TablePro/Views/Main/SidebarNavigationResult.swift index 7ea14d69c..04e6283c6 100644 --- a/TablePro/Views/Main/SidebarNavigationResult.swift +++ b/TablePro/Views/Main/SidebarNavigationResult.swift @@ -19,10 +19,6 @@ enum SidebarNavigationResult: Equatable { /// Reverting synchronously prevents SwiftUI from rendering the [B] state /// before coalescing back to [A] — eliminating the visible flash. case revertAndOpenNewWindow - /// Preview mode: replace the contents of the existing preview tab. - case replacePreviewTab - /// Preview mode: no preview tab exists yet, so create a new one. - case openNewPreviewTab /// Pure function — no side effects. Determines how a sidebar click should be handled. /// @@ -31,14 +27,10 @@ enum SidebarNavigationResult: Equatable { /// - currentTabTableName: The table name of this window's active tab /// (`nil` when the active tab is a query or create-table tab). /// - hasExistingTabs: `true` when this window already has at least one tab open. - /// - isPreviewTabMode: `true` when preview/temporary tab mode is enabled. - /// - hasPreviewTab: `true` when a preview tab already exists in this window. static func resolve( clickedTableName: String, currentTabTableName: String?, - hasExistingTabs: Bool, - isPreviewTabMode: Bool = false, - hasPreviewTab: Bool = false + hasExistingTabs: Bool ) -> SidebarNavigationResult { // Programmatic sync (e.g. didBecomeKeyNotification): the selection already // reflects the active tab — nothing to do. @@ -46,14 +38,6 @@ enum SidebarNavigationResult: Equatable { // No existing tabs: open the table in-place within this window. if !hasExistingTabs { return .openInPlace } - // Preview tab logic: reuse or create a preview tab instead of opening a new window tab. - if isPreviewTabMode { - if hasPreviewTab { - return .replacePreviewTab - } - return .openNewPreviewTab - } - // Default: revert sidebar synchronously (no flash), then open in a new native tab. return .revertAndOpenNewWindow } diff --git a/TablePro/Views/Settings/GeneralSettingsView.swift b/TablePro/Views/Settings/GeneralSettingsView.swift index 04d93f627..b93c23600 100644 --- a/TablePro/Views/Settings/GeneralSettingsView.swift +++ b/TablePro/Views/Settings/GeneralSettingsView.swift @@ -78,12 +78,6 @@ struct GeneralSettingsView: View { } Section("Tabs") { - Toggle("Enable preview tabs", isOn: $tabSettings.enablePreviewTabs) - - Text("Single-clicking a table opens a temporary tab that gets replaced on next click.") - .font(.caption) - .foregroundStyle(.secondary) - Toggle("Group all connections in one window", isOn: $tabSettings.groupAllConnectionTabs) Text("When enabled, tabs from different connections share the same window instead of opening separate windows.") diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 2288c7ac5..81a07235d 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -19,7 +19,6 @@ struct SidebarView: View { @Binding var pendingDeletes: Set var activeTableName: String? - var onDoubleClick: ((TableInfo) -> Void)? var connectionId: UUID private weak var coordinator: MainContentCoordinator? @@ -39,7 +38,6 @@ struct SidebarView: View { tables: Binding<[TableInfo]>, sidebarState: SharedSidebarState, activeTableName: String? = nil, - onDoubleClick: ((TableInfo) -> Void)? = nil, pendingTruncates: Binding>, pendingDeletes: Binding>, tableOperationOptions: Binding<[String: TableOperationOptions]>, @@ -49,7 +47,6 @@ struct SidebarView: View { ) { _tables = tables self.sidebarState = sidebarState - self.onDoubleClick = onDoubleClick _pendingTruncates = pendingTruncates _pendingDeletes = pendingDeletes let selectedBinding = Binding( @@ -219,11 +216,6 @@ struct SidebarView: View { isPendingDelete: pendingDeletes.contains(table.name) ) .tag(table) - .overlay { - DoubleClickDetector { - onDoubleClick?(table) - } - } .contextMenu { SidebarContextMenu( clickedTable: table, diff --git a/TableProTests/Models/PreviewTabTests.swift b/TableProTests/Models/PreviewTabTests.swift index fbd68eaf4..b830baddd 100644 --- a/TableProTests/Models/PreviewTabTests.swift +++ b/TableProTests/Models/PreviewTabTests.swift @@ -30,43 +30,11 @@ struct PreviewTabTests { #expect(tab.isPreview == false) } - @Test("TabSettings enablePreviewTabs defaults to true") - func tabSettingsDefaultsToTrue() { - let settings = TabSettings.default - #expect(settings.enablePreviewTabs == true) - } - - @Test("Preview table tab can be added via addPreviewTableTab") - @MainActor - func addPreviewTableTab() { - let manager = QueryTabManager() - manager.addPreviewTableTab(tableName: "users", databaseType: .mysql, databaseName: "mydb") - #expect(manager.tabs.count == 1) - #expect(manager.selectedTab?.isPreview == true) - #expect(manager.selectedTab?.tableName == "users") - } - - @Test("replaceTabContent can set isPreview flag") - @MainActor - func replaceTabContentSetsPreview() { - let manager = QueryTabManager() - manager.addPreviewTableTab(tableName: "users", databaseType: .mysql, databaseName: "mydb") - let replaced = manager.replaceTabContent( - tableName: "orders", - databaseType: .mysql, - databaseName: "mydb", - isPreview: true - ) - #expect(replaced == true) - #expect(manager.selectedTab?.isPreview == true) - #expect(manager.selectedTab?.tableName == "orders") - } - @Test("replaceTabContent defaults to non-preview") @MainActor func replaceTabContentDefaultsNonPreview() { let manager = QueryTabManager() - manager.addPreviewTableTab(tableName: "users", databaseType: .mysql, databaseName: "mydb") + manager.addTableTab(tableName: "users", databaseType: .mysql, databaseName: "mydb") let replaced = manager.replaceTabContent( tableName: "orders", databaseType: .mysql, @@ -74,20 +42,7 @@ struct PreviewTabTests { ) #expect(replaced == true) #expect(manager.selectedTab?.isPreview == false) - } - - @Test("TabSettings decodes with missing enablePreviewTabs key (backward compat)") - func tabSettingsBackwardCompatDecoding() throws { - let json = Data("{}".utf8) - let decoded = try JSONDecoder().decode(TabSettings.self, from: json) - #expect(decoded.enablePreviewTabs == true) - } - - @Test("TabSettings decodes with enablePreviewTabs set to false") - func tabSettingsDecodesExplicitFalse() throws { - let json = Data(#"{"enablePreviewTabs":false}"#.utf8) - let decoded = try JSONDecoder().decode(TabSettings.self, from: json) - #expect(decoded.enablePreviewTabs == false) + #expect(manager.selectedTab?.tableName == "orders") } @Test("EditorTabPayload isPreview defaults to false") @@ -95,10 +50,4 @@ struct PreviewTabTests { let payload = EditorTabPayload(connectionId: UUID()) #expect(payload.isPreview == false) } - - @Test("EditorTabPayload isPreview can be set to true") - func editorTabPayloadCanBePreview() { - let payload = EditorTabPayload(connectionId: UUID(), isPreview: true) - #expect(payload.isPreview == true) - } } diff --git a/TableProTests/Views/SidebarNavigationResultTests.swift b/TableProTests/Views/SidebarNavigationResultTests.swift index 815be79fe..affaa88f5 100644 --- a/TableProTests/Views/SidebarNavigationResultTests.swift +++ b/TableProTests/Views/SidebarNavigationResultTests.swift @@ -274,66 +274,4 @@ struct SidebarNavigationResultTests { ) #expect(result == .openInPlace) } - - // MARK: - Preview tab mode - - @Test("Preview mode disabled returns existing behavior") - func previewModeDisabledReturnsExistingBehavior() { - let result = SidebarNavigationResult.resolve( - clickedTableName: "orders", - currentTabTableName: "users", - hasExistingTabs: true, - isPreviewTabMode: false, - hasPreviewTab: false - ) - #expect(result == .revertAndOpenNewWindow) - } - - @Test("Preview mode enabled with existing preview tab returns replacePreviewTab") - func previewModeWithExistingPreviewTab() { - let result = SidebarNavigationResult.resolve( - clickedTableName: "orders", - currentTabTableName: "users", - hasExistingTabs: true, - isPreviewTabMode: true, - hasPreviewTab: true - ) - #expect(result == .replacePreviewTab) - } - - @Test("Preview mode enabled without preview tab returns openNewPreviewTab") - func previewModeWithoutPreviewTab() { - let result = SidebarNavigationResult.resolve( - clickedTableName: "orders", - currentTabTableName: "users", - hasExistingTabs: true, - isPreviewTabMode: true, - hasPreviewTab: false - ) - #expect(result == .openNewPreviewTab) - } - - @Test("Preview mode skip still works when table matches") - func previewModeSkipWhenTableMatches() { - let result = SidebarNavigationResult.resolve( - clickedTableName: "users", - currentTabTableName: "users", - hasExistingTabs: true, - isPreviewTabMode: true, - hasPreviewTab: true - ) - #expect(result == .skip) - } - - @Test("Preview mode with no existing tabs still opens in-place") - func previewModeNoExistingTabsOpensInPlace() { - let result = SidebarNavigationResult.resolve( - clickedTableName: "orders", - currentTabTableName: nil, - hasExistingTabs: false, - isPreviewTabMode: true, - hasPreviewTab: false - ) - #expect(result == .openInPlace) - } } From 39433576f4f872f2d97b79bcce82956125095bed Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 12 Apr 2026 20:05:19 +0700 Subject: [PATCH 08/29] fix: Show Structure not working when opening table from empty state --- .../Main/Extensions/MainContentCoordinator+Navigation.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 3ec1b841a..675e109c3 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -82,11 +82,10 @@ extension MainContentCoordinator { tabManager.tabs[tabIndex].isView = isView tabManager.tabs[tabIndex].isEditable = !isView tabManager.tabs[tabIndex].schemaName = currentSchema + tabManager.tabs[tabIndex].showStructure = showStructure tabManager.tabs[tabIndex].pagination.reset() toolbarState.isTableTab = true } - // In-place navigation needs selectRedisDatabaseAndQuery to ensure the correct - // database is SELECTed and session state is updated before querying. restoreColumnLayoutForTable(tableName) restoreFiltersForTable(tableName) if navigationModel == .inPlace, let dbIndex = Int(currentDatabase) { From 646fa76dcfdec2697e60a692f2737e4b7ecc0a8e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 12 Apr 2026 20:10:26 +0700 Subject: [PATCH 09/29] fix: sidebar table list flickering when opening routine tabs --- .../Views/Main/Extensions/MainContentView+EventHandlers.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index 83161887c..f65596713 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -114,6 +114,9 @@ extension MainContentView { /// Navigation safety is guaranteed by `SidebarNavigationResult.resolve` returning `.skip` /// when the selected table matches the current tab. func syncSidebarToCurrentTab() { + // Routine tabs don't participate in table sidebar selection + if tabManager.selectedTab?.isRoutine == true { return } + let target: Set if let currentTableName = tabManager.selectedTab?.tableName, let match = tables.first(where: { $0.name == currentTableName }) From 2efe9a711b4958f82697537931432709d8e964c4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 12 Apr 2026 20:15:03 +0700 Subject: [PATCH 10/29] fix: routine Cmd+Click uses in-window tab instead of native window tab --- ...ainContentCoordinator+SidebarActions.swift | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift index beeb509a4..18e31a7a0 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift @@ -93,23 +93,8 @@ extension MainContentCoordinator { return } - // No tabs open: create inline - if tabManager.tabs.isEmpty { - appendRoutineTab(routineName, routineType: routineType) - return - } - - // Tabs exist but current is not a routine (or forceNewTab): open new native tab - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .table, - tableName: routineName, - databaseName: connection.database, - isRoutine: true, - routineType: routineType, - showStructure: true - ) - WindowOpener.shared.openNativeTab(payload) + // Append to current window's tab manager + appendRoutineTab(routineName, routineType: routineType) } private func appendRoutineTab(_ routineName: String, routineType: RoutineInfo.RoutineType) { From 7dc65ab656e530c54519dee7460a8adf1f3f4598 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 12 Apr 2026 20:18:05 +0700 Subject: [PATCH 11/29] fix: use Option+Click for new tab (Cmd+Click conflicts with List selection) --- .../Views/Main/Extensions/MainContentView+EventHandlers.swift | 4 ++-- TablePro/Views/Sidebar/SidebarView.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index f65596713..b685159e7 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -95,8 +95,8 @@ extension MainContentView { return } - // Cmd+Click forces a new native tab (same pattern as routine tabs) - let forceNewTab = NSEvent.modifierFlags.contains(.command) + // Option+Click forces a new native tab (same pattern as routine tabs) + let forceNewTab = NSEvent.modifierFlags.contains(.option) if !tabManager.tabs.isEmpty { // Skip if clicked table matches the active tab diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 44b2d2935..ee7f806de 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -241,7 +241,7 @@ struct SidebarView: View { Section(isExpanded: $viewModel.isRoutinesExpanded) { ForEach(routines) { routine in Button { - let forceNewTab = NSEvent.modifierFlags.contains(.command) + let forceNewTab = NSEvent.modifierFlags.contains(.option) coordinator?.openRoutineTab( routine.name, routineType: routine.type, forceNewTab: forceNewTab From d20122dcbb989fdd0ddfbfe950925120cfeda1c4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 12 Apr 2026 20:20:48 +0700 Subject: [PATCH 12/29] feat: add "Open in New Tab" to table and routine context menus --- TablePro/Views/Sidebar/SidebarContextMenu.swift | 7 +++++++ TablePro/Views/Sidebar/SidebarView.swift | 10 +++++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/TablePro/Views/Sidebar/SidebarContextMenu.swift b/TablePro/Views/Sidebar/SidebarContextMenu.swift index 4cf979303..bdc76eb8a 100644 --- a/TablePro/Views/Sidebar/SidebarContextMenu.swift +++ b/TablePro/Views/Sidebar/SidebarContextMenu.swift @@ -77,6 +77,13 @@ struct SidebarContextMenu: View { } .disabled(clickedTable == nil) + Button("Open in New Tab") { + if let tableName = clickedTable?.name { + coordinator?.openTableTab(tableName, isView: isView, forceNewTab: true) + } + } + .disabled(clickedTable == nil) + Button(String(localized: "View ER Diagram")) { coordinator?.showERDiagram() } diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index ee7f806de..70d226eb2 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -241,11 +241,7 @@ struct SidebarView: View { Section(isExpanded: $viewModel.isRoutinesExpanded) { ForEach(routines) { routine in Button { - let forceNewTab = NSEvent.modifierFlags.contains(.option) - coordinator?.openRoutineTab( - routine.name, routineType: routine.type, - forceNewTab: forceNewTab - ) + coordinator?.openRoutineTab(routine.name, routineType: routine.type) } label: { RoutineRowView(routine: routine, isActive: false) } @@ -326,6 +322,10 @@ struct SidebarView: View { coordinator?.openRoutineTab(routine.name, routineType: routine.type) } + Button("Open in New Tab") { + coordinator?.openRoutineTab(routine.name, routineType: routine.type, forceNewTab: true) + } + Divider() Button("Execute...") { From 3593e96131a06ccb74d2bcdc94757f638a0e74a0 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 12 Apr 2026 20:23:08 +0700 Subject: [PATCH 13/29] fix: "Open in New Tab" skips fast path when forceNewTab is true --- .../Extensions/MainContentCoordinator+SidebarActions.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift index 18e31a7a0..deed8f75e 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift @@ -59,8 +59,8 @@ extension MainContentCoordinator { // MARK: - Routine Tab Operations func openRoutineTab(_ routineName: String, routineType: RoutineInfo.RoutineType, forceNewTab: Bool = false) { - // Fast path: already viewing this routine - if let current = tabManager.selectedTab, + // Fast path: already viewing this routine (skip if forcing new tab) + if !forceNewTab, let current = tabManager.selectedTab, current.isRoutine, current.tableName == routineName { return } From c7e51f56402c638f418ce169ca88758485712fe0 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 12 Apr 2026 20:25:59 +0700 Subject: [PATCH 14/29] fix: routine "Open in New Tab" uses native window tab correctly --- ...ainContentCoordinator+SidebarActions.swift | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift index deed8f75e..e0569a2f4 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift @@ -93,19 +93,30 @@ extension MainContentCoordinator { return } - // Append to current window's tab manager - appendRoutineTab(routineName, routineType: routineType) - } + // No tabs: create inline in current window + if tabManager.tabs.isEmpty { + var newTab = QueryTab(title: routineName, tabType: .table, tableName: routineName) + newTab.databaseName = connection.database + newTab.isRoutine = true + newTab.routineType = routineType + newTab.isEditable = false + newTab.showStructure = true + tabManager.tabs.append(newTab) + tabManager.selectedTabId = newTab.id + return + } - private func appendRoutineTab(_ routineName: String, routineType: RoutineInfo.RoutineType) { - var newTab = QueryTab(title: routineName, tabType: .table, tableName: routineName) - newTab.databaseName = connection.database - newTab.isRoutine = true - newTab.routineType = routineType - newTab.isEditable = false - newTab.showStructure = true - tabManager.tabs.append(newTab) - tabManager.selectedTabId = newTab.id + // Open new native window tab + let payload = EditorTabPayload( + connectionId: connection.id, + tabType: .table, + tableName: routineName, + databaseName: connection.database, + isRoutine: true, + routineType: routineType, + showStructure: true + ) + WindowOpener.shared.openNativeTab(payload) } func openQueryInTab(_ query: String) { From 796ff6e63364cd741acce20802233ae81a6f4ee2 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 12 Apr 2026 20:30:52 +0700 Subject: [PATCH 15/29] feat: Cmd+Click on tables and routines opens new tab --- .../MainContentView+EventHandlers.swift | 4 ++-- TablePro/Views/Sidebar/SidebarView.swift | 21 +++++++++++-------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index b685159e7..092a691b0 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -95,8 +95,8 @@ extension MainContentView { return } - // Option+Click forces a new native tab (same pattern as routine tabs) - let forceNewTab = NSEvent.modifierFlags.contains(.option) + // Cmd+Click forces a new native tab + let forceNewTab = NSEvent.modifierFlags.contains(.command) if !tabManager.tabs.isEmpty { // Skip if clicked table matches the active tab diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 70d226eb2..02c0327f1 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -240,15 +240,18 @@ struct SidebarView: View { if let routines = routinesForCurrentConnection, !routines.isEmpty { Section(isExpanded: $viewModel.isRoutinesExpanded) { ForEach(routines) { routine in - Button { - coordinator?.openRoutineTab(routine.name, routineType: routine.type) - } label: { - RoutineRowView(routine: routine, isActive: false) - } - .buttonStyle(.plain) - .contextMenu { - routineContextMenu(routine: routine) - } + RoutineRowView(routine: routine, isActive: false) + .contentShape(Rectangle()) + .onTapGesture { + let forceNewTab = NSEvent.modifierFlags.contains(.command) + coordinator?.openRoutineTab( + routine.name, routineType: routine.type, + forceNewTab: forceNewTab + ) + } + .contextMenu { + routineContextMenu(routine: routine) + } } } header: { Text("Routines") From a79777c36e619136ebf3261a06f830a166260776 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 12 Apr 2026 20:42:02 +0700 Subject: [PATCH 16/29] fix: prevent auto-query execution on routine tab restore and tab switch --- .../Main/Extensions/MainContentCoordinator+TabSwitch.swift | 2 +- TablePro/Views/Main/Extensions/MainContentView+Setup.swift | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift index e2200831a..dc22efe5c 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift @@ -103,7 +103,7 @@ extension MainContentCoordinator { } let isEvicted = newTab.rowBuffer.isEvicted - let needsLazyQuery = newTab.tabType == .table + let needsLazyQuery = newTab.tabType == .table && !newTab.isRoutine && (newTab.resultRows.isEmpty || isEvicted) && (newTab.lastExecutedAt == nil || isEvicted) && newTab.errorMessage == nil diff --git a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index 4a9925364..be83c5c18 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -25,7 +25,7 @@ extension MainContentView { case .openContent: if payload.skipAutoExecute { return } if let selectedTab = tabManager.selectedTab, - selectedTab.tabType == .table, + selectedTab.tabType == .table, !selectedTab.isRoutine, !selectedTab.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { if let session = DatabaseManager.shared.activeSessions[connection.id], @@ -115,7 +115,7 @@ extension MainContentView { } } - if selectedTab.tabType == .table, + if selectedTab.tabType == .table, !selectedTab.isRoutine, !selectedTab.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { if let session = DatabaseManager.shared.activeSessions[connection.id], From 1b359478ac32b20eadf82939f5f924af116f2643 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 12 Apr 2026 20:44:11 +0700 Subject: [PATCH 17/29] fix: prevent routine tab auto-query on window focus (didBecomeKey) --- TablePro/Views/Main/MainContentView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 8574f2d0e..153bcda5e 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -372,7 +372,7 @@ struct MainContentView: View { DatabaseManager.shared.activeSessions[connection.id]?.isConnected ?? false let needsLazyLoad = tabManager.selectedTab.map { tab in - tab.tabType == .table + tab.tabType == .table && !tab.isRoutine && (tab.resultRows.isEmpty || tab.rowBuffer.isEvicted) && (tab.lastExecutedAt == nil || tab.rowBuffer.isEvicted) && tab.errorMessage == nil From 4907210a841bced7b889fcd9b9677c458e839146 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 12 Apr 2026 20:48:53 +0700 Subject: [PATCH 18/29] fix: prevent routine tab auto-query on connection status change (needsLazyLoad) --- TablePro/Views/Main/Extensions/MainContentView+Helpers.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift b/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift index d9d9e4f9d..cfd2350c6 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift @@ -27,6 +27,8 @@ extension MainContentView { || (tabManager.selectedTab?.pendingChanges.hasChanges ?? false) guard !hasPendingEdits else { return } coordinator.needsLazyLoad = false + // Routine tabs don't need query execution — they load their own data + if tabManager.selectedTab?.isRoutine == true { return } if let selectedTab = tabManager.selectedTab, !selectedTab.databaseName.isEmpty, selectedTab.databaseName != session.activeDatabase From b4e62e85e2c107d0410c73790acd0b0365474738 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 12 Apr 2026 20:50:38 +0700 Subject: [PATCH 19/29] fix: guard executeTableTabQueryDirectly against routine tabs --- TablePro/Views/Main/MainContentCoordinator.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 4ee3c77c9..3429c7d89 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -710,6 +710,7 @@ final class MainContentCoordinator { /// checks but still respect safe mode levels that apply to all queries. func executeTableTabQueryDirectly() { guard let index = tabManager.selectedTabIndex else { return } + guard !tabManager.tabs[index].isRoutine else { return } guard !tabManager.tabs[index].isExecuting else { return } let sql = tabManager.tabs[index].query From ca7bbff3d0282dae2214191e6c9ea089b360c00e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 12 Apr 2026 20:52:21 +0700 Subject: [PATCH 20/29] fix: guard runQuery against routine tabs - definitive fix --- TablePro/Views/Main/MainContentCoordinator.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 3429c7d89..f29d4297c 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -605,6 +605,7 @@ final class MainContentCoordinator { func runQuery() { guard let index = tabManager.selectedTabIndex else { return } + guard !tabManager.tabs[index].isRoutine else { return } guard !tabManager.tabs[index].isExecuting else { return } let fullQuery = tabManager.tabs[index].query From 05fbdf438461c834d0ed635a50844001f2f58374 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 12 Apr 2026 21:02:45 +0700 Subject: [PATCH 21/29] fix: routine tabs persist and restore with empty query to prevent execution --- TablePro/Models/Query/QueryTab.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/TablePro/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index 0d662e382..51c2fc9e8 100644 --- a/TablePro/Models/Query/QueryTab.swift +++ b/TablePro/Models/Query/QueryTab.swift @@ -186,7 +186,7 @@ struct QueryTab: Identifiable, Equatable { init(from persisted: PersistedTab) { self.id = persisted.id self.title = persisted.title - self.query = persisted.query + self.query = persisted.isRoutine ? "" : persisted.query self.tabType = persisted.tabType self.tableName = persisted.tableName self.primaryKeyColumn = nil @@ -269,9 +269,11 @@ struct QueryTab: Identifiable, Equatable { /// Convert tab to persisted format for storage func toPersistedTab() -> PersistedTab { - // Truncate very large queries to prevent JSON encoding from blocking main thread + // Routine tabs have no query to persist. Truncate very large queries to prevent JSON freeze. let persistedQuery: String - if (query as NSString).length > Self.maxPersistableQuerySize { + if isRoutine { + persistedQuery = "" + } else if (query as NSString).length > Self.maxPersistableQuerySize { persistedQuery = "" } else { persistedQuery = query From 0b4da89f348b3bfde64323f0108be9838e478120 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 12 Apr 2026 21:11:37 +0700 Subject: [PATCH 22/29] fix: detect routine tabs by routineType during restore for stale persisted data --- TablePro/Views/Main/Extensions/MainContentView+Setup.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index be83c5c18..90284f477 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -83,8 +83,11 @@ extension MainContentView { let result = await coordinator.persistence.restoreFromDisk() if !result.tabs.isEmpty { var restoredTabs = result.tabs - for i in restoredTabs.indices where restoredTabs[i].tabType == .table && !restoredTabs[i].isRoutine { - if let tableName = restoredTabs[i].tableName { + for i in restoredTabs.indices where restoredTabs[i].tabType == .table { + if restoredTabs[i].isRoutine || restoredTabs[i].routineType != nil { + restoredTabs[i].isRoutine = true + restoredTabs[i].query = "" + } else if let tableName = restoredTabs[i].tableName { restoredTabs[i].query = QueryTab.buildBaseTableQuery( tableName: tableName, databaseType: connection.type, From 269ab52d7764e394abb8c93bdc50192ff4a9b3a5 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 12 Apr 2026 21:42:16 +0700 Subject: [PATCH 23/29] fix: block openTableTab for routine names - definitive guard at entry point --- .../Main/Extensions/MainContentCoordinator+Navigation.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 675e109c3..73af4a570 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -16,6 +16,11 @@ extension MainContentCoordinator { // MARK: - Table Tab Opening func openTableTab(_ tableName: String, showStructure: Bool = false, isView: Bool = false, forceNewTab: Bool = false) { + // Routines must use openRoutineTab, not openTableTab + if routines.contains(where: { $0.name == tableName }) { + Self.logger.warning("openTableTab called for routine '\(tableName)' — redirecting to openRoutineTab") + return + } let navigationModel = PluginMetadataRegistry.shared.snapshot( forTypeId: connection.type.pluginTypeId )?.navigationModel ?? .standard From 483e17d349efd8dd7c8626e382122ed823bc9d6b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 12 Apr 2026 21:49:43 +0700 Subject: [PATCH 24/29] fix: prevent replaceTabContent from overwriting routine tabs --- TablePro/Models/Query/QueryTabManager.swift | 3 ++- .../Main/Extensions/MainContentCoordinator+Navigation.swift | 5 ++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/TablePro/Models/Query/QueryTabManager.swift b/TablePro/Models/Query/QueryTabManager.swift index 2a57506ed..6fd1dd011 100644 --- a/TablePro/Models/Query/QueryTabManager.swift +++ b/TablePro/Models/Query/QueryTabManager.swift @@ -186,7 +186,8 @@ final class QueryTabManager { quoteIdentifier: ((String) -> String)? = nil ) -> Bool { guard let selectedId = selectedTabId, - let selectedIndex = tabs.firstIndex(where: { $0.id == selectedId }) + let selectedIndex = tabs.firstIndex(where: { $0.id == selectedId }), + !tabs[selectedIndex].isRoutine else { return false } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 73af4a570..85f5fd8c8 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -16,9 +16,8 @@ extension MainContentCoordinator { // MARK: - Table Tab Opening func openTableTab(_ tableName: String, showStructure: Bool = false, isView: Bool = false, forceNewTab: Bool = false) { - // Routines must use openRoutineTab, not openTableTab - if routines.contains(where: { $0.name == tableName }) { - Self.logger.warning("openTableTab called for routine '\(tableName)' — redirecting to openRoutineTab") + // Don't replace a routine tab with a table tab + if let current = tabManager.selectedTab, current.isRoutine, current.tableName == tableName { return } let navigationModel = PluginMetadataRegistry.shared.snapshot( From ce4e20ecce43c2aba0a2e1ff3d28ce30eb821f37 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 12 Apr 2026 21:59:11 +0700 Subject: [PATCH 25/29] debug: add logging to trace routine tab creation, persistence, and query execution --- TablePro/Models/Query/QueryTab.swift | 4 ++++ .../MainContentCoordinator+Navigation.swift | 2 ++ .../MainContentCoordinator+SidebarActions.swift | 2 ++ TablePro/Views/Main/MainContentCoordinator.swift | 13 +++++++++---- 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/TablePro/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index 51c2fc9e8..9ec25c56a 100644 --- a/TablePro/Models/Query/QueryTab.swift +++ b/TablePro/Models/Query/QueryTab.swift @@ -269,6 +269,10 @@ struct QueryTab: Identifiable, Equatable { /// Convert tab to persisted format for storage func toPersistedTab() -> PersistedTab { + if tabType == .table { + Logger(subsystem: "com.TablePro", category: "QueryTab") + .info("[toPersistedTab] title='\(title)' isRoutine=\(isRoutine) routineType=\(String(describing: routineType)) query='\((query as NSString).substring(to: min(40, (query as NSString).length)))'") + } // Routine tabs have no query to persist. Truncate very large queries to prevent JSON freeze. let persistedQuery: String if isRoutine { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 85f5fd8c8..fe4689efa 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -16,8 +16,10 @@ extension MainContentCoordinator { // MARK: - Table Tab Opening func openTableTab(_ tableName: String, showStructure: Bool = false, isView: Bool = false, forceNewTab: Bool = false) { + Self.logger.info("[openTableTab] called for '\(tableName)' showStructure=\(showStructure) forceNewTab=\(forceNewTab)") // Don't replace a routine tab with a table tab if let current = tabManager.selectedTab, current.isRoutine, current.tableName == tableName { + Self.logger.info("[openTableTab] blocked — current tab is routine '\(tableName)'") return } let navigationModel = PluginMetadataRegistry.shared.snapshot( diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift index e0569a2f4..78d62daa2 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift @@ -7,6 +7,7 @@ import AppKit import Foundation +import os import UniformTypeIdentifiers extension MainContentCoordinator { @@ -103,6 +104,7 @@ extension MainContentCoordinator { newTab.showStructure = true tabManager.tabs.append(newTab) tabManager.selectedTabId = newTab.id + Self.logger.info("[Routine] Created inline tab '\(routineName)' isRoutine=\(newTab.isRoutine)") return } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index f29d4297c..927c4aa0d 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -605,14 +605,19 @@ final class MainContentCoordinator { func runQuery() { guard let index = tabManager.selectedTabIndex else { return } - guard !tabManager.tabs[index].isRoutine else { return } - guard !tabManager.tabs[index].isExecuting else { return } + let tab = tabManager.tabs[index] + Self.logger.info("[runQuery] tab='\(tab.title)' tabType=\(String(describing: tab.tabType)) isRoutine=\(tab.isRoutine) query='\((tab.query as NSString).substring(to: min(60, (tab.query as NSString).length)))'") + guard !tab.isRoutine else { + Self.logger.info("[runQuery] blocked — routine tab") + return + } + guard !tab.isExecuting else { return } - let fullQuery = tabManager.tabs[index].query + let fullQuery = tab.query // For table tabs, use the full query. For query tabs, extract at cursor let sql: String - if tabManager.tabs[index].tabType == .table { + if tab.tabType == .table { sql = fullQuery } else if let firstCursor = cursorPositions.first, firstCursor.range.length > 0 { From e95ca36f5457142cbd3d3fd9947c0d4ea903444b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 12 Apr 2026 22:07:54 +0700 Subject: [PATCH 26/29] fix: convertToPersistedTab now uses toPersistedTab (was missing isRoutine/routineType) --- .../TabPersistenceCoordinator.swift | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift index f2266d486..81c5937de 100644 --- a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift +++ b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift @@ -133,23 +133,6 @@ internal final class TabPersistenceCoordinator { // MARK: - Private private func convertToPersistedTab(_ tab: QueryTab) -> PersistedTab { - let persistedQuery: String - if (tab.query as NSString).length > QueryTab.maxPersistableQuerySize { - persistedQuery = "" - } else { - persistedQuery = tab.query - } - - return PersistedTab( - id: tab.id, - title: tab.title, - query: persistedQuery, - tabType: tab.tabType, - tableName: tab.tableName, - isView: tab.isView, - databaseName: tab.databaseName, - schemaName: tab.schemaName, - sourceFileURL: tab.sourceFileURL - ) + tab.toPersistedTab() } } From 6fa607547c4fa172ce0f1614937bca254940d9d0 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 12 Apr 2026 22:15:50 +0700 Subject: [PATCH 27/29] fix: routine detail view waits for connection on tab restore --- .../Structure/RoutineDetailView+DataLoading.swift | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/TablePro/Views/Structure/RoutineDetailView+DataLoading.swift b/TablePro/Views/Structure/RoutineDetailView+DataLoading.swift index 493a47802..b6c1f548d 100644 --- a/TablePro/Views/Structure/RoutineDetailView+DataLoading.swift +++ b/TablePro/Views/Structure/RoutineDetailView+DataLoading.swift @@ -7,7 +7,17 @@ extension RoutineDetailView { isLoading = true errorMessage = nil - guard let driver = DatabaseManager.shared.driver(for: connection.id) else { + // Wait for connection to be ready (tab may restore before connection is established) + var driver = DatabaseManager.shared.driver(for: connection.id) + if driver == nil { + for _ in 0..<20 { + try? await Task.sleep(for: .milliseconds(250)) + driver = DatabaseManager.shared.driver(for: connection.id) + if driver != nil { break } + } + } + + guard let driver else { errorMessage = String(localized: "Not connected") isLoading = false return From 04ba14c4e33e3f9da11dd2def14bc376f19e4ab5 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 12 Apr 2026 22:28:26 +0700 Subject: [PATCH 28/29] refactor: cleanup review findings - remove debug logs, fix id collision, extract factory, deduplicate DDL fetch --- .../Infrastructure/SessionStateFactory.swift | 11 +++++------ TablePro/Models/Query/QueryTab.swift | 15 ++++++++++----- TablePro/Models/Schema/RoutineInfo.swift | 2 +- .../MainContentCoordinator+Navigation.swift | 2 -- .../MainContentCoordinator+SidebarActions.swift | 10 ++-------- TablePro/Views/Main/MainContentCoordinator.swift | 10 ++++------ .../Structure/RoutineDetailView+DataLoading.swift | 6 +++++- 7 files changed, 27 insertions(+), 29 deletions(-) diff --git a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift index 27a114333..5f539ad5c 100644 --- a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift +++ b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift @@ -56,12 +56,11 @@ enum SessionStateFactory { toolbarSt.isTableTab = true if let tableName = payload.tableName { if payload.isRoutine { - var newTab = QueryTab(title: tableName, tabType: .table, tableName: tableName) - newTab.databaseName = payload.databaseName ?? connection.database - newTab.isRoutine = true - newTab.routineType = payload.routineType - newTab.isEditable = false - newTab.showStructure = true + let newTab = QueryTab.makeRoutineTab( + name: tableName, + routineType: payload.routineType ?? .procedure, + databaseName: payload.databaseName ?? connection.database + ) tabMgr.tabs.append(newTab) tabMgr.selectedTabId = newTab.id } else { diff --git a/TablePro/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index 9ec25c56a..2175a6002 100644 --- a/TablePro/Models/Query/QueryTab.swift +++ b/TablePro/Models/Query/QueryTab.swift @@ -7,7 +7,6 @@ import Foundation import Observation -import os import TableProPluginKit /// Represents a single tab (query or table) @@ -221,6 +220,16 @@ struct QueryTab: Identifiable, Equatable { self.paginationVersion = 0 } + static func makeRoutineTab(name: String, routineType: RoutineInfo.RoutineType, databaseName: String) -> QueryTab { + var tab = QueryTab(title: name, tabType: .table, tableName: name) + tab.databaseName = databaseName + tab.isRoutine = true + tab.routineType = routineType + tab.isEditable = false + tab.showStructure = true + return tab + } + /// Build a clean base query for a table tab (no filters/sort). /// Used when restoring table tabs from persistence to avoid stale WHERE clauses. @MainActor static func buildBaseTableQuery( @@ -269,10 +278,6 @@ struct QueryTab: Identifiable, Equatable { /// Convert tab to persisted format for storage func toPersistedTab() -> PersistedTab { - if tabType == .table { - Logger(subsystem: "com.TablePro", category: "QueryTab") - .info("[toPersistedTab] title='\(title)' isRoutine=\(isRoutine) routineType=\(String(describing: routineType)) query='\((query as NSString).substring(to: min(40, (query as NSString).length)))'") - } // Routine tabs have no query to persist. Truncate very large queries to prevent JSON freeze. let persistedQuery: String if isRoutine { diff --git a/TablePro/Models/Schema/RoutineInfo.swift b/TablePro/Models/Schema/RoutineInfo.swift index 9ede0b9dc..2f939a8b4 100644 --- a/TablePro/Models/Schema/RoutineInfo.swift +++ b/TablePro/Models/Schema/RoutineInfo.swift @@ -1,7 +1,7 @@ import Foundation struct RoutineInfo: Identifiable, Hashable { - var id: String { name } + var id: String { "\(type.rawValue)_\(name)" } let name: String let type: RoutineType diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index fe4689efa..85f5fd8c8 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -16,10 +16,8 @@ extension MainContentCoordinator { // MARK: - Table Tab Opening func openTableTab(_ tableName: String, showStructure: Bool = false, isView: Bool = false, forceNewTab: Bool = false) { - Self.logger.info("[openTableTab] called for '\(tableName)' showStructure=\(showStructure) forceNewTab=\(forceNewTab)") // Don't replace a routine tab with a table tab if let current = tabManager.selectedTab, current.isRoutine, current.tableName == tableName { - Self.logger.info("[openTableTab] blocked — current tab is routine '\(tableName)'") return } let navigationModel = PluginMetadataRegistry.shared.snapshot( diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift index 78d62daa2..8b706dab4 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift @@ -7,7 +7,6 @@ import AppKit import Foundation -import os import UniformTypeIdentifiers extension MainContentCoordinator { @@ -96,15 +95,9 @@ extension MainContentCoordinator { // No tabs: create inline in current window if tabManager.tabs.isEmpty { - var newTab = QueryTab(title: routineName, tabType: .table, tableName: routineName) - newTab.databaseName = connection.database - newTab.isRoutine = true - newTab.routineType = routineType - newTab.isEditable = false - newTab.showStructure = true + let newTab = QueryTab.makeRoutineTab(name: routineName, routineType: routineType, databaseName: connection.database) tabManager.tabs.append(newTab) tabManager.selectedTabId = newTab.id - Self.logger.info("[Routine] Created inline tab '\(routineName)' isRoutine=\(newTab.isRoutine)") return } @@ -166,6 +159,7 @@ extension MainContentCoordinator { ) guard confirmed else { return } + // Downcast required: dropObjectStatement is on PluginDriverAdapter, not DatabaseDriver protocol guard let adapter = DatabaseManager.shared.driver(for: connectionId) as? PluginDriverAdapter else { return } let sql = adapter.dropObjectStatement(name: routineName, objectType: keyword, schema: nil, cascade: false) diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 927c4aa0d..58ae533b0 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -411,7 +411,9 @@ final class MainContentCoordinator { DatabaseManager.shared.updateSession(connectionId) { $0.routines = [] } } } else { - DatabaseManager.shared.updateSession(connectionId) { $0.routines = [] } + if !(DatabaseManager.shared.session(for: connectionId)?.routines.isEmpty ?? true) { + DatabaseManager.shared.updateSession(connectionId) { $0.routines = [] } + } } sidebarLoadingState = .loaded @@ -606,11 +608,7 @@ final class MainContentCoordinator { func runQuery() { guard let index = tabManager.selectedTabIndex else { return } let tab = tabManager.tabs[index] - Self.logger.info("[runQuery] tab='\(tab.title)' tabType=\(String(describing: tab.tabType)) isRoutine=\(tab.isRoutine) query='\((tab.query as NSString).substring(to: min(60, (tab.query as NSString).length)))'") - guard !tab.isRoutine else { - Self.logger.info("[runQuery] blocked — routine tab") - return - } + guard !tab.isRoutine else { return } guard !tab.isExecuting else { return } let fullQuery = tab.query diff --git a/TablePro/Views/Structure/RoutineDetailView+DataLoading.swift b/TablePro/Views/Structure/RoutineDetailView+DataLoading.swift index b6c1f548d..8baf2d5a8 100644 --- a/TablePro/Views/Structure/RoutineDetailView+DataLoading.swift +++ b/TablePro/Views/Structure/RoutineDetailView+DataLoading.swift @@ -11,11 +11,13 @@ extension RoutineDetailView { var driver = DatabaseManager.shared.driver(for: connection.id) if driver == nil { for _ in 0..<20 { + guard !Task.isCancelled else { return } try? await Task.sleep(for: .milliseconds(250)) driver = DatabaseManager.shared.driver(for: connection.id) if driver != nil { break } } } + guard !Task.isCancelled else { return } guard let driver else { errorMessage = String(localized: "Not connected") @@ -45,7 +47,9 @@ extension RoutineDetailView { case .parameters: parameters = try await driver.fetchRoutineParameters(routine: routineName, type: routineType) case .ddl: - ddlStatement = try await driver.fetchRoutineDefinition(routine: routineName, type: routineType) + ddlStatement = definition.isEmpty + ? try await driver.fetchRoutineDefinition(routine: routineName, type: routineType) + : definition } loadedTabs.insert(tab) } catch { From d6704381cb33421bb98aaa820cd13e56f4c03300 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Tue, 14 Apr 2026 16:51:58 +0700 Subject: [PATCH 29/29] fix: remove accidentally committed worktrees and external repos --- .claude/worktrees/agent-a02da604 | 1 - .claude/worktrees/agent-a06814bf | 1 - .claude/worktrees/agent-a13b8fb4 | 1 - .claude/worktrees/agent-a2a2a913 | 1 - .claude/worktrees/agent-a41e2327 | 1 - .claude/worktrees/agent-a47f90ba | 1 - .claude/worktrees/agent-a8a367e1 | 1 - .claude/worktrees/agent-a9a38d69 | 1 - .claude/worktrees/agent-aa738c4e | 1 - .claude/worktrees/agent-ac6f6f4e | 1 - .claude/worktrees/agent-ac7da043 | 1 - .claude/worktrees/agent-ace4a0c9 | 1 - .claude/worktrees/agent-ae1632d0 | 1 - Sequel-Ace | 1 - beekeeper-studio | 1 - docs/development/ai-chat-audit.md | 271 ------------------------------ licenseapp | 1 - pgadmin4 | 1 - 18 files changed, 288 deletions(-) delete mode 160000 .claude/worktrees/agent-a02da604 delete mode 160000 .claude/worktrees/agent-a06814bf delete mode 160000 .claude/worktrees/agent-a13b8fb4 delete mode 160000 .claude/worktrees/agent-a2a2a913 delete mode 160000 .claude/worktrees/agent-a41e2327 delete mode 160000 .claude/worktrees/agent-a47f90ba delete mode 160000 .claude/worktrees/agent-a8a367e1 delete mode 160000 .claude/worktrees/agent-a9a38d69 delete mode 160000 .claude/worktrees/agent-aa738c4e delete mode 160000 .claude/worktrees/agent-ac6f6f4e delete mode 160000 .claude/worktrees/agent-ac7da043 delete mode 160000 .claude/worktrees/agent-ace4a0c9 delete mode 160000 .claude/worktrees/agent-ae1632d0 delete mode 160000 Sequel-Ace delete mode 160000 beekeeper-studio delete mode 100644 docs/development/ai-chat-audit.md delete mode 160000 licenseapp delete mode 160000 pgadmin4 diff --git a/.claude/worktrees/agent-a02da604 b/.claude/worktrees/agent-a02da604 deleted file mode 160000 index 569d962ba..000000000 --- a/.claude/worktrees/agent-a02da604 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 569d962ba6d51d26cdfa5f6054d8a66be91864cb diff --git a/.claude/worktrees/agent-a06814bf b/.claude/worktrees/agent-a06814bf deleted file mode 160000 index ab8e26944..000000000 --- a/.claude/worktrees/agent-a06814bf +++ /dev/null @@ -1 +0,0 @@ -Subproject commit ab8e26944de4caad732ef0877ee3a2c3ad24ff7d diff --git a/.claude/worktrees/agent-a13b8fb4 b/.claude/worktrees/agent-a13b8fb4 deleted file mode 160000 index 330a37a21..000000000 --- a/.claude/worktrees/agent-a13b8fb4 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 330a37a217a852d2627d5703885440a79b5e5bc0 diff --git a/.claude/worktrees/agent-a2a2a913 b/.claude/worktrees/agent-a2a2a913 deleted file mode 160000 index 330a37a21..000000000 --- a/.claude/worktrees/agent-a2a2a913 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 330a37a217a852d2627d5703885440a79b5e5bc0 diff --git a/.claude/worktrees/agent-a41e2327 b/.claude/worktrees/agent-a41e2327 deleted file mode 160000 index 6f873b445..000000000 --- a/.claude/worktrees/agent-a41e2327 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 6f873b445f335a65358a21538368192b1e7da8a4 diff --git a/.claude/worktrees/agent-a47f90ba b/.claude/worktrees/agent-a47f90ba deleted file mode 160000 index 6f873b445..000000000 --- a/.claude/worktrees/agent-a47f90ba +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 6f873b445f335a65358a21538368192b1e7da8a4 diff --git a/.claude/worktrees/agent-a8a367e1 b/.claude/worktrees/agent-a8a367e1 deleted file mode 160000 index 569d962ba..000000000 --- a/.claude/worktrees/agent-a8a367e1 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 569d962ba6d51d26cdfa5f6054d8a66be91864cb diff --git a/.claude/worktrees/agent-a9a38d69 b/.claude/worktrees/agent-a9a38d69 deleted file mode 160000 index 330a37a21..000000000 --- a/.claude/worktrees/agent-a9a38d69 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 330a37a217a852d2627d5703885440a79b5e5bc0 diff --git a/.claude/worktrees/agent-aa738c4e b/.claude/worktrees/agent-aa738c4e deleted file mode 160000 index 732264db0..000000000 --- a/.claude/worktrees/agent-aa738c4e +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 732264db0237e8234ae2a31de6e1abdffbeca060 diff --git a/.claude/worktrees/agent-ac6f6f4e b/.claude/worktrees/agent-ac6f6f4e deleted file mode 160000 index c873885d3..000000000 --- a/.claude/worktrees/agent-ac6f6f4e +++ /dev/null @@ -1 +0,0 @@ -Subproject commit c873885d31d532dd735581a6e77f2011d0383bef diff --git a/.claude/worktrees/agent-ac7da043 b/.claude/worktrees/agent-ac7da043 deleted file mode 160000 index 732264db0..000000000 --- a/.claude/worktrees/agent-ac7da043 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 732264db0237e8234ae2a31de6e1abdffbeca060 diff --git a/.claude/worktrees/agent-ace4a0c9 b/.claude/worktrees/agent-ace4a0c9 deleted file mode 160000 index c873885d3..000000000 --- a/.claude/worktrees/agent-ace4a0c9 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit c873885d31d532dd735581a6e77f2011d0383bef diff --git a/.claude/worktrees/agent-ae1632d0 b/.claude/worktrees/agent-ae1632d0 deleted file mode 160000 index 569d962ba..000000000 --- a/.claude/worktrees/agent-ae1632d0 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 569d962ba6d51d26cdfa5f6054d8a66be91864cb diff --git a/Sequel-Ace b/Sequel-Ace deleted file mode 160000 index 407546521..000000000 --- a/Sequel-Ace +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 407546521865f4ad28b6507f2e5185e2ca2df567 diff --git a/beekeeper-studio b/beekeeper-studio deleted file mode 160000 index ad6029d8d..000000000 --- a/beekeeper-studio +++ /dev/null @@ -1 +0,0 @@ -Subproject commit ad6029d8d28421e3ed114e53209c1107e9985bcc diff --git a/docs/development/ai-chat-audit.md b/docs/development/ai-chat-audit.md deleted file mode 100644 index ad5349d3f..000000000 --- a/docs/development/ai-chat-audit.md +++ /dev/null @@ -1,271 +0,0 @@ -# AI Chat Feature — Production Readiness Audit - -Comprehensive analysis of the AI chat feature across security, data integrity, concurrency, performance, and enterprise readiness. Conducted on 2026-04-14 against PR #738 (`fix/ai-chat-hang-735`). - -## Table of Contents - -- [Security](#security) -- [Data Integrity & Loss Prevention](#data-integrity--loss-prevention) -- [Concurrency & Stability](#concurrency--stability) -- [Performance & Scalability](#performance--scalability) -- [Enterprise Production Readiness](#enterprise-production-readiness) -- [Priority Actions](#priority-actions) - ---- - -## Security - -| ID | Severity | Issue | Location | Status | -|----|----------|-------|----------|--------| -| S1 | CRITICAL | Ollama stream logged with `privacy: .public` — leaks schema/query data to system log | `OpenAICompatibleProvider.swift:70` | Open | -| S2 | CRITICAL | Query result rows sent to AI without clear disclosure in consent prompt | `AISchemaContext.swift`, consent dialog | Open | -| S3 | HIGH | AI API keys sync to iCloud Keychain when password sync enabled | `KeychainHelper.swift`, `AIKeyStorage.swift` | Open | -| S4 | HIGH | Raw provider error bodies surfaced to UI — leaks endpoint/infra details | `AIProvider.swift:59-70` | Open | -| S5 | HIGH | API keys cached in heap for app lifetime, never evicted on disconnect | `AIProviderFactory.swift:20-56` | Open | -| S6 | MEDIUM | No HTTPS enforcement for custom provider endpoints (HTTP allowed) | `AIProviderFactory.swift` | Open | -| S7 | MEDIUM | Connection names persist in chat history with no retention policy | `AIConversation.swift` | Open | -| S8 | LOW | Session consent not invalidated when data-sharing settings change | `AIChatViewModel.swift` | Open | - -### S1 — Ollama stream logged with `privacy: .public` - -The `privacy: .public` annotation opts data out of OSLog's automatic redaction. On any machine where a log stream is captured (Console.app, `log stream`, MDM log collection), the first 200 characters of every Ollama response line — which may contain AI-generated SQL with table names, column names, and data values — are emitted in plaintext. - -**Remediation:** Remove this log statement or change to `privacy: .private`. - -### S2 — Query result rows sent without disclosure - -`buildQueryResultsSummary()` sends the first 10 rows of active query results to the AI provider. The `askEachTime` consent dialog says "Your database schema and query data will be sent" but does not specify live row data. The setting defaults to off but users who enable it have no visibility into what rows are included. - -**Remediation:** Enumerate data categories in consent prompt. Add row/column redaction or exclusion options. - -### S3 — AI API keys sync to iCloud Keychain - -`AIKeyStorage` uses `KeychainHelper` which has iCloud sync mode. When enabled, AI provider API keys (Anthropic, OpenAI, Gemini) sync to all Macs signed into the same Apple ID. - -**Remediation:** Force `kSecAttrSynchronizable: false` for AI key items regardless of global sync preference. - -### S4 — Raw error bodies surfaced to UI - -`mapHTTPError(statusCode:body:)` passes raw server response into `AIProviderError.serverError`. Provider error bodies can contain internal endpoint paths, rate limit quota details, or upstream infrastructure details. - -**Remediation:** Truncate error bodies to 200 chars and strip URL-like substrings before displaying. - -### S5 — API keys cached indefinitely in memory - -Provider cache holds `(apiKey: String?, provider: AIProvider)` for the entire app lifetime. `clearSessionData()` does not invalidate it. - -**Remediation:** Call `AIProviderFactory.invalidateCache()` from `clearSessionData()`. - -### S6 — No HTTPS enforcement for custom endpoints - -Custom provider endpoints accept `http://` URLs. Schema data and conversation history can be transmitted over plaintext HTTP. - -**Remediation:** Validate `https://` prefix for all non-Ollama provider endpoints. - -### S7 — Connection names persist without retention - -`AIConversation.connectionName` persists in `~/Library/Application Support/TablePro/ai_chats/*.json` indefinitely. Connection names often encode hostname/environment info. - -**Remediation:** Add configurable max conversation age with auto-delete. - -### S8 — Session consent not invalidated on settings change - -If a user approves AI access with `includeQueryResults: false`, then enables it in settings, the cached approval is reused without re-prompting. - -**Remediation:** Invalidate `sessionApprovedConnections` when `includeSchema`/`includeQueryResults` settings change. - ---- - -## Data Integrity & Loss Prevention - -| ID | Severity | Issue | Location | Status | -|----|----------|-------|----------|--------| -| D1 | CRITICAL | No persistence on app termination — streaming conversation lost on force-quit | `AIChatViewModel.swift`, `AppDelegate` | Open | -| D2 | CRITICAL | Rapid cancel+resend can send stale message history to AI provider | `AIChatViewModel.swift:380` | Open | -| D3 | HIGH | `editMessage` destroys all subsequent messages with no undo | `AIChatViewModel.swift:77-84` | Open | -| D4 | HIGH | Async save task pre-empted by force-quit → disk rollback on next launch | `AIChatViewModel.swift:285-310` | Open | -| D5 | HIGH | `clearConversation` `deleteAll()` can race with immediate new `save()` | `AIChatViewModel.swift:156-163` | Open | -| D6 | MEDIUM | `deleteConversation` can lose to a queued `save` — deleted conversation reappears | `AIChatViewModel.swift:275-282` | Open | - -### D1 — No persistence on app termination - -`persistCurrentConversation()` is only called after stream completion, cancel, or conversation switch. `applicationWillTerminate` does not persist in-flight AI conversations. Force-quit during streaming loses all unsaved messages. - -**Remediation:** In `applicationWillTerminate`, resolve all live `AIChatViewModel` instances and call `persistCurrentConversation()` synchronously. - -### D2 — Stale message history on rapid cancel+resend - -The `chatMessages` snapshot is captured before `Task.detached`. If `startStreaming` is called rapidly (cancel previous + start new), the cancel path and new stream's `MainActor.run` blocks can interleave, potentially sending a snapshot that includes a partially-written assistant message from the cancelled stream. - -**Remediation:** Ensure `chatMessages` is captured only after all prior `MainActor.run` blocks from the cancelled task have drained. - -### D3 — `editMessage` is destructive with no undo - -`messages.removeSubrange(idx...)` followed by immediate `persistCurrentConversation()` permanently destroys all subsequent messages including assistant responses. No undo stack, no confirmation dialog. - -**Remediation:** Add confirmation dialog for destructive edits, or implement undo support. - -### D4 — Async save pre-empted by force-quit - -`persistCurrentConversation()` updates `conversations[index]` synchronously but queues `Task { await chatStorage.save(conversation) }` asynchronously. Force-quit between the two operations causes disk to rollback on next launch. - -**Remediation:** Use synchronous save in critical paths, or batch with a write-ahead log. - -### D5 — `deleteAll` races with new `save` - -`clearConversation()` fires `Task { await chatStorage.deleteAll() }` then allows immediate new conversation creation. If `save()` for the new conversation arrives on the actor queue before `deleteAll()` finishes, the new file is deleted. - -**Remediation:** `await` the `deleteAll()` before allowing new conversations, or use a generation counter to skip stale deletes. - -### D6 — Deleted conversation reappears - -If a `save` task is queued for a conversation ID before `delete` is called, the actor processes them in FIFO order. If `delete` runs before the pending `save`, the save recreates the file. - -**Remediation:** Track deleted IDs in a set and skip saves for deleted conversations. - ---- - -## Concurrency & Stability - -| ID | Severity | Issue | Location | Status | -|----|----------|-------|----------|--------| -| C1 | CRITICAL | Non-Sendable `DatabaseDriver` captured in `Task.detached` — data race | `AIChatViewModel.swift:484-539` | Open | -| C2 | CRITICAL | `nonisolated(unsafe) streamingTask` written from detached task AND `deinit` | `AIChatViewModel.swift:95,409,429` | Open | -| C3 | HIGH | `AIProvider` protocol not `Sendable` — latent strict-concurrency error | `AIProviderFactory.swift`, `AIChatViewModel.swift` | Open | -| C4 | HIGH | `schemaFetchTask` not cancelled in `deinit`, not `nonisolated(unsafe)` | `AIChatViewModel.swift:96,109` | Open | -| C5 | MEDIUM | Partial response persisted after cancellation via racing `MainActor.run` | `AIChatViewModel.swift:405-412` | Open | - -### C1 — Non-Sendable `DatabaseDriver` across isolation boundary - -`DatabaseDriver` is `AnyObject` without `Sendable`. It is captured in `Task.detached` for schema fetching. The underlying plugin driver uses a serial GCD queue (safe in practice), but Swift 6 strict concurrency would reject this. `ConnectionHealthMonitor` already uses closure-based injection to avoid this exact problem. - -**Remediation:** Either mark `DatabaseDriver: Sendable` (after auditing all implementations) or use closure-based injection like `ConnectionHealthMonitor`. - -### C2 — `nonisolated(unsafe)` race on `streamingTask` - -`streamingTask` is written from `@MainActor` and from `MainActor.run` inside `Task.detached`. `deinit` reads it without isolation. If `AIChatViewModel` is released from a non-main context, this is a write-write race. - -**Remediation:** Remove `streamingTask = nil` writes from inside the detached task's `MainActor.run` blocks, or ensure `deinit` always runs on the main actor. - -### C3 — `AIProvider` not `Sendable` - -Concrete implementations are effectively Sendable (all `let` properties + thread-safe URLSession) but the protocol lacks the annotation. - -**Remediation:** Add `Sendable` to `AIProvider` protocol, mark implementations `@unchecked Sendable`. - -### C4 — `schemaFetchTask` not cancelled in `deinit` - -Detached task continues running after view model deallocation, wasting resources. Also not marked `nonisolated(unsafe)` for `deinit` access. - -**Remediation:** Mark `nonisolated(unsafe)`, cancel in `deinit`. - -### C5 — Partial response persisted after cancel - -After `cancelStream()` completes, a racing `MainActor.run` from the detached task can still call `persistCurrentConversation()` with partial content. - -**Remediation:** Check `Task.isCancelled` inside the `MainActor.run` completion block, not just before it. - ---- - -## Performance & Scalability - -| ID | Severity | Issue | Location | Status | -|----|----------|-------|----------|--------| -| P1 | CRITICAL | O(n) `firstIndex` + COW string copy in streaming hot loop (per token) | `AIChatViewModel.swift:391-402` | Open | -| P2 | CRITICAL | Per-token `MainActor.run` hop + full Markdown re-parse (80/sec at fast models) | `AIChatViewModel.swift:390-403` | Open | -| P3 | HIGH | `loadAll()` reads all conversation files serially on startup, no cap or pagination | `AIChatStorage.swift:75-100` | Open | -| P4 | HIGH | `persistCurrentConversation` encodes full JSON on MainActor at stream end | `AIChatViewModel.swift:285-311` | Open | -| P5 | MEDIUM | No per-message content size cap (unbounded memory despite 200 message limit) | `AIChatViewModel.swift:89` | Open | -| P6 | MEDIUM | JSON encoder uses `.prettyPrinted \| .sortedKeys` — 2-3x size inflation | `AIChatStorage.swift:19-25` | Open | - -### P1 + P2 — Per-token overhead in streaming loop - -Every token triggers: (1) `firstIndex(where:)` O(n) scan over messages array, (2) `content += token` COW copy on `@Observable` property, (3) `MainActor.run` actor hop, (4) SwiftUI observation notification, (5) `Markdown()` full re-parse. At 80 tokens/sec (Claude Haiku, GPT-4o), this is ~16,000 UUID comparisons/sec plus 80 full markdown parses. - -**Remediation:** Batch tokens in a non-isolated buffer, flush to `@MainActor` on a 50-100ms timer. Cache the message index before the loop. Use a separate `streamingContent: String` property instead of mutating the messages array per-token. - -### P3 — Unbounded conversation loading on startup - -`loadAll()` reads every `.json` file in `ai_chats/` directory serially. With 100+ conversations of 200 messages each, this is 10+ MB of JSON decoded at startup. - -**Remediation:** Pass `[.contentModificationDateKey]` to `contentsOfDirectory`, sort by date, load only most recent N files. Add startup pruning. - -### P4 — JSON encoding on MainActor - -`persistCurrentConversation()` builds `AIConversation`, then fires `Task { await chatStorage.save(conversation) }`. The `AIConversation` struct creation with full message array copy happens on `@MainActor` synchronously. - -**Remediation:** Move encode+write entirely into the actor. - -### P5 — No message content size cap - -200 messages × 50KB (large schema summary) = 10MB in memory. Plus duplicates in `conversations` array and API request snapshot. - -**Remediation:** Cap message content at 500KB (matching tab persistence limit). - -### P6 — Verbose JSON encoding - -`.prettyPrinted | .sortedKeys` inflates file size 2-3x. Files are machine-read, not human-inspected. - -**Remediation:** Remove formatting options for production. - ---- - -## Enterprise Production Readiness - -| Category | Status | Severity | Notes | -|----------|--------|----------|-------| -| Audit Trail | FAIL | Critical | No structured log of AI interactions for compliance | -| Data Governance | PARTIAL | High | Per-connection policy exists but is user-overridable, not admin-lockable | -| Network Controls (Proxy/CA) | FAIL | Critical | `URLSession(.ephemeral)` ignores system proxy and corporate CA | -| Offline Mode | PARTIAL | Medium | Error shown but no actionable diagnostics | -| Client-Side Rate Limiting | FAIL | Medium | No throttle on chat sends — can exhaust shared API key | -| Error Recovery | PASS | — | Cancellation and retry paths work correctly | -| Accessibility (VoiceOver) | FAIL | High | Only 1 of ~10 interactive elements has `accessibilityLabel` | -| Localization | PARTIAL | Medium | Some hardcoded English strings in `AIChatMessageView` | -| Telemetry/Analytics | PASS | — | No telemetry or third-party tracking | -| MDM/Config Profiles | FAIL | High | No managed preferences support for enterprise deployment | -| Feature Flags | PARTIAL | Medium | Global `enabled` flag exists but not admin-lockable | -| Disk Cleanup | FAIL | Medium | No max conversation count, age limit, or auto-prune | -| Memory Cleanup | PASS | — | `clearSessionData()` is comprehensive | -| Schema Migration | FAIL | Medium | No version field in persisted conversations | -| Multi-Window | PASS | — | Per-window instances, actor-safe storage | - ---- - -## Priority Actions - -### Immediate (this PR / hotfix) - -- [ ] **S1** — Remove Ollama `privacy: .public` log line (1 line) -- [ ] **C4** — Mark `schemaFetchTask` as `nonisolated(unsafe)`, cancel in `deinit` (3 lines) -- [ ] **S5** — Call `AIProviderFactory.invalidateCache()` from `clearSessionData()` (1 line) - -### Short-term (next sprint) - -- [ ] **P1/P2** — Token batching: accumulate off-main, flush on 50-100ms timer -- [ ] **D1** — Persist conversations on `applicationWillTerminate` -- [ ] **P3** — `loadAll()` pagination: load only recent N conversations -- [ ] **C3** — Mark `AIProvider: Sendable`, implementations `@unchecked Sendable` -- [ ] **S4** — Sanitize/truncate error bodies before displaying to user -- [ ] **Accessibility** — Add `accessibilityLabel` to all icon-only buttons - -### Medium-term (roadmap) - -- [ ] **Enterprise: Audit trail** — Append-only log of AI interactions -- [ ] **Enterprise: MDM support** — Read managed preferences for AI settings -- [ ] **Enterprise: Network** — Replace `.ephemeral` with `.default` URLSession for proxy/CA -- [ ] **S6** — HTTPS enforcement for non-Ollama endpoints -- [ ] **S3** — Exclude AI keys from iCloud Keychain sync -- [ ] **D5/D6** — Fix actor task ordering races in conversation delete/clear -- [ ] **Enterprise: Disk cleanup** — Max conversation count + age-based auto-prune -- [ ] **Enterprise: Schema migration** — Version field in `AIConversation` -- [ ] **P5** — Cap message content at 500KB -- [ ] **D3** — Confirmation dialog for destructive `editMessage` - -### Long-term (future releases) - -- [ ] **Enterprise: Data governance** — Admin-lockable connection policies via MDM -- [ ] **Enterprise: Rate limiting** — Per-session request throttle -- [ ] **Enterprise: Feature flags** — Per-team/per-user AI access control -- [ ] **C1** — `DatabaseDriver: Sendable` audit across all plugin implementations -- [ ] **Swift 6 readiness** — Enable `SWIFT_STRICT_CONCURRENCY=complete`, fix all violations diff --git a/licenseapp b/licenseapp deleted file mode 160000 index de1f0daf8..000000000 --- a/licenseapp +++ /dev/null @@ -1 +0,0 @@ -Subproject commit de1f0daf82df2c7acf2a65d1db428bfbb884153a diff --git a/pgadmin4 b/pgadmin4 deleted file mode 160000 index d8a078af5..000000000 --- a/pgadmin4 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit d8a078af5323d84f0970adf957673bde19026804