From dac40606578f2c69a935acc60191c703bbbf0927 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 16 Jun 2026 22:59:34 +0700 Subject: [PATCH 1/2] feat(structure): add Triggers tab to Show Structure for MySQL, PostgreSQL, and SQLite --- CHANGELOG.md | 1 + Plugins/MySQLDriverPlugin/MySQLPlugin.swift | 1 + .../MySQLDriverPlugin/MySQLPluginDriver.swift | 42 ++++++ .../PostgreSQLPlugin.swift | 1 + .../PostgreSQLPluginDriver.swift | 54 ++++++++ Plugins/SQLiteDriverPlugin/SQLitePlugin.swift | 56 ++++++++ Plugins/TableProPluginKit/DriverPlugin.swift | 2 + .../PluginDatabaseDriver.swift | 3 + .../TableProPluginKit/PluginTriggerInfo.swift | 26 ++++ TablePro/Core/Database/DatabaseDriver.swift | 5 + .../Core/Plugins/PluginDriverAdapter.swift | 14 ++ .../Core/Plugins/PluginMetadataRegistry.swift | 6 + .../StructureChangeManager.swift | 4 +- .../Connection/DatabaseConnection.swift | 4 + TablePro/Models/Query/QueryResult.swift | 26 ++++ TablePro/Models/Schema/StructureTab.swift | 2 + TablePro/Resources/Localizable.xcstrings | 6 + .../Structure/StructureGridDelegate.swift | 27 ++-- .../Structure/StructureRowProvider.swift | 10 +- .../Structure/StructureRowViewWithMenu.swift | 2 +- .../TableStructureView+DataLoading.swift | 7 + .../Views/Structure/TableStructureView.swift | 21 ++- .../Views/Structure/TriggerDetailView.swift | 122 ++++++++++++++++++ .../Database/TriggerInfoMappingTests.swift | 121 +++++++++++++++++ .../StructureGridDelegateAddRowTests.swift | 24 ++++ docs/features/table-structure.mdx | 20 ++- 26 files changed, 581 insertions(+), 26 deletions(-) create mode 100644 Plugins/TableProPluginKit/PluginTriggerInfo.swift create mode 100644 TablePro/Views/Structure/TriggerDetailView.swift create mode 100644 TableProTests/Core/Database/TriggerInfoMappingTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index bd9a84c58..fdb513e3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- The table structure view has a Triggers tab for MySQL, MariaDB, PostgreSQL, and SQLite. It lists each trigger with its timing and event and shows the full definition in a read-only syntax-highlighted viewer. (#1695) - Traditional Chinese (繁體中文) language in Settings > General with full UI translation ## [0.51.1] - 2026-06-16 diff --git a/Plugins/MySQLDriverPlugin/MySQLPlugin.swift b/Plugins/MySQLDriverPlugin/MySQLPlugin.swift index b85817d53..765f0906d 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPlugin.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPlugin.swift @@ -146,6 +146,7 @@ final class MySQLPlugin: NSObject, TableProPlugin, DriverPlugin { ) static let supportsDropDatabase = true + static let supportsTriggers = true func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { MySQLPluginDriver(config: config) diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift index 1a28e3e74..7dd4ea237 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -382,6 +382,48 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return foreignKeys } + func fetchTriggers(table: String, schema: String?) async throws -> [PluginTriggerInfo] { + let dbName = _activeDatabase + let escapedDb = dbName.replacingOccurrences(of: "'", with: "''") + let escapedTable = table.replacingOccurrences(of: "'", with: "''") + + let query = """ + SELECT TRIGGER_NAME, ACTION_TIMING, EVENT_MANIPULATION, + ACTION_STATEMENT, ACTION_ORIENTATION + FROM information_schema.TRIGGERS + WHERE EVENT_OBJECT_SCHEMA = '\(escapedDb)' + AND EVENT_OBJECT_TABLE = '\(escapedTable)' + ORDER BY TRIGGER_NAME + """ + + let result = try await execute(query: query) + + let triggers: [PluginTriggerInfo] = result.rows.compactMap { row in + guard let name = row[safe: 0]?.asText, + let timing = row[safe: 1]?.asText, + let event = row[safe: 2]?.asText, + let body = row[safe: 3]?.asText + else { return nil } + + let orientation = row[safe: 4]?.asText ?? "ROW" + let statement = """ + CREATE TRIGGER \(quoteIdentifier(name)) \(timing) \(event) + ON \(quoteIdentifier(table)) FOR EACH ROW + \(body) + """ + + return PluginTriggerInfo( + name: name, + timing: timing, + event: event, + forEachRow: orientation == "ROW", + statement: statement + ) + } + Self.logger.info("[trigger] mysql fetchTriggers db=\(dbName, privacy: .public) table=\(table, privacy: .public) rows=\(result.rows.count) parsed=\(triggers.count)") + return triggers + } + func fetchAllForeignKeys(schema: String?) async throws -> [String: [PluginForeignKeyInfo]] { let dbName = _activeDatabase let escapedDb = dbName.replacingOccurrences(of: "'", with: "''") diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift index d4476958b..8eff47c11 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift @@ -124,6 +124,7 @@ final class PostgreSQLPlugin: NSObject, TableProPlugin, DriverPlugin { static let requiresReconnectForDatabaseSwitch = true static let parameterStyle: ParameterStyle = .dollar static let supportsDropDatabase = true + static let supportsTriggers = true static let sqlDialect: SQLDialectDescriptor? = SQLDialectDescriptor( identifierQuote: "\"", diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index 3781aacf7..f553e6670 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -287,6 +287,60 @@ final class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { return foreignKeys } + func fetchTriggers(table: String, schema: String?) async throws -> [PluginTriggerInfo] { + let resolvedSchema = schema ?? core.currentSchema + let schemaLiteral = escapeLiteral(resolvedSchema) + let tableLiteral = escapeLiteral(table) + let query = """ + SELECT + t.tgname, + CASE WHEN (t.tgtype & 64) != 0 THEN 'INSTEAD OF' + WHEN (t.tgtype & 2) != 0 THEN 'BEFORE' + ELSE 'AFTER' END AS timing, + CASE WHEN (t.tgtype & 4) != 0 AND (t.tgtype & 8) != 0 AND (t.tgtype & 16) != 0 + THEN 'INSERT OR UPDATE OR DELETE' + WHEN (t.tgtype & 4) != 0 AND (t.tgtype & 8) != 0 THEN 'INSERT OR UPDATE' + WHEN (t.tgtype & 4) != 0 AND (t.tgtype & 16) != 0 THEN 'INSERT OR DELETE' + WHEN (t.tgtype & 8) != 0 AND (t.tgtype & 16) != 0 THEN 'UPDATE OR DELETE' + WHEN (t.tgtype & 4) != 0 THEN 'INSERT' + WHEN (t.tgtype & 8) != 0 THEN 'UPDATE' + WHEN (t.tgtype & 16) != 0 THEN 'DELETE' + WHEN (t.tgtype & 32) != 0 THEN 'TRUNCATE' + ELSE '' END AS event, + (t.tgtype & 1) = 1 AS for_each_row, + pg_get_expr(t.tgqual, t.tgrelid) AS when_clause, + pg_get_triggerdef(t.oid) AS definition + FROM pg_catalog.pg_trigger t + JOIN pg_catalog.pg_class c ON c.oid = t.tgrelid + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE c.relname = '\(tableLiteral)' + AND n.nspname = '\(schemaLiteral)' + AND NOT t.tgisinternal + ORDER BY t.tgname + """ + let result = try await execute(query: query) + let triggers: [PluginTriggerInfo] = result.rows.compactMap { row -> PluginTriggerInfo? in + guard row.count >= 6, + let name = row[0].asText, + let timing = row[1].asText, + let event = row[2].asText, + let definition = row[5].asText + else { return nil } + let forEachRow = row[3].asText == "t" + let whenClause = row[4].asText + return PluginTriggerInfo( + name: name, + timing: timing, + event: event, + forEachRow: forEachRow, + whenClause: whenClause, + statement: definition + ) + } + Self.logger.info("[trigger] postgres fetchTriggers schema=\(resolvedSchema, privacy: .public) table=\(table, privacy: .public) rows=\(result.rows.count) parsed=\(triggers.count)") + return triggers + } + func fetchAllForeignKeys(schema: String?) async throws -> [String: [PluginForeignKeyInfo]] { let schemaLiteral = escapeLiteral(schema ?? core.currentSchema) let query = """ diff --git a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift index a2e468185..292cf1567 100644 --- a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift +++ b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift @@ -35,6 +35,7 @@ final class SQLitePlugin: NSObject, TableProPlugin, DriverPlugin { static let fileExtensions: [String] = ["db", "db3", "s3db", "sl3", "sqlite", "sqlite3", "sqlitedb"] static let brandColorHex = "#003B57" static let supportsDatabaseSwitching = false + static let supportsTriggers = true static let databaseGroupingStrategy: GroupingStrategy = .flat static let columnTypesByCategory: [String: [String]] = [ "Integer": ["INTEGER", "INT", "TINYINT", "SMALLINT", "MEDIUMINT", "BIGINT"], @@ -824,6 +825,61 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable { } } + func fetchTriggers(table: String, schema: String?) async throws -> [PluginTriggerInfo] { + let safeTable = escapeStringLiteral(table) + let query = """ + SELECT name, sql FROM sqlite_master + WHERE type = 'trigger' AND tbl_name = '\(safeTable)' + ORDER BY name + """ + let result = try await execute(query: query) + + return result.rows.compactMap { row -> PluginTriggerInfo? in + guard row.count >= 2, + let name = row[0].asText, + let sql = row[1].asText else { + return nil + } + + let (timing, event) = Self.parseTimingAndEvent(from: sql) + return PluginTriggerInfo( + name: name, + timing: timing, + event: event, + statement: sql + ) + } + } + + private static func parseTimingAndEvent(from sql: String) -> (timing: String, event: String) { + let upper = sql.uppercased() + let headerEnd = upper.range(of: " ON ")?.lowerBound ?? upper.endIndex + let tokens = upper[upper.startIndex.. String { let safeTable = escapeStringLiteral(table) let query = """ diff --git a/Plugins/TableProPluginKit/DriverPlugin.swift b/Plugins/TableProPluginKit/DriverPlugin.swift index 9a3f80f07..5be5cde2a 100644 --- a/Plugins/TableProPluginKit/DriverPlugin.swift +++ b/Plugins/TableProPluginKit/DriverPlugin.swift @@ -23,6 +23,7 @@ public protocol DriverPlugin: TableProPlugin { static var queryLanguageName: String { get } static var editorLanguage: EditorLanguage { get } static var supportsForeignKeys: Bool { get } + static var supportsTriggers: Bool { get } static var supportsSchemaEditing: Bool { get } static var supportsDatabaseSwitching: Bool { get } static var supportsSchemaSwitching: Bool { get } @@ -82,6 +83,7 @@ public extension DriverPlugin { static var queryLanguageName: String { "SQL" } static var editorLanguage: EditorLanguage { .sql } static var supportsForeignKeys: Bool { true } + static var supportsTriggers: Bool { false } static var supportsSchemaEditing: Bool { true } static var supportsDatabaseSwitching: Bool { true } static var supportsSchemaSwitching: Bool { false } diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index 7ea23aafd..30b40acd3 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -88,6 +88,7 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] + func fetchTriggers(table: String, schema: String?) async throws -> [PluginTriggerInfo] func fetchTableDDL(table: String, schema: String?) async throws -> String func fetchViewDefinition(view: String, schema: String?) async throws -> String func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata @@ -197,6 +198,8 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { public extension PluginDatabaseDriver { var capabilities: PluginCapabilities { [] } + func fetchTriggers(table: String, schema: String?) async throws -> [PluginTriggerInfo] { [] } + var supportsSchemas: Bool { false } func fetchSchemas() async throws -> [String] { [] } diff --git a/Plugins/TableProPluginKit/PluginTriggerInfo.swift b/Plugins/TableProPluginKit/PluginTriggerInfo.swift new file mode 100644 index 000000000..a67fb3ea7 --- /dev/null +++ b/Plugins/TableProPluginKit/PluginTriggerInfo.swift @@ -0,0 +1,26 @@ +import Foundation + +public struct PluginTriggerInfo: Codable, Sendable { + public let name: String + public let timing: String + public let event: String + public let forEachRow: Bool + public let whenClause: String? + public let statement: String + + public init( + name: String, + timing: String, + event: String, + forEachRow: Bool = true, + whenClause: String? = nil, + statement: String + ) { + self.name = name + self.timing = timing + self.event = event + self.forEachRow = forEachRow + self.whenClause = whenClause + self.statement = statement + } +} diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index c0fe4ee88..4af66c5e2 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -83,6 +83,9 @@ protocol DatabaseDriver: AnyObject { /// Fetch foreign keys for a specific table func fetchForeignKeys(table: String) async throws -> [ForeignKeyInfo] + /// Fetch triggers for a specific table + func fetchTriggers(table: String) async throws -> [TriggerInfo] + /// Fetch foreign keys for all tables in the current database/schema in bulk. /// Default implementation falls back to per-table fetchForeignKeys. func fetchAllForeignKeys() async throws -> [String: [ForeignKeyInfo]] @@ -244,6 +247,8 @@ extension DatabaseDriver { try await fetchColumns(table: table) } + func fetchTriggers(table: String) async throws -> [TriggerInfo] { [] } + func testConnection() async throws -> Bool { try await connect() disconnect() diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index c82546ddc..54d69692a 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -251,6 +251,20 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { } } + func fetchTriggers(table: String) async throws -> [TriggerInfo] { + let pluginTriggers = try await pluginDriver.fetchTriggers(table: table, schema: pluginDriver.currentSchema) + return pluginTriggers.map { trigger in + TriggerInfo( + name: trigger.name, + timing: trigger.timing, + event: trigger.event, + forEachRow: trigger.forEachRow, + whenClause: trigger.whenClause, + statement: trigger.statement + ) + } + } + func fetchApproximateRowCount(table: String) async throws -> Int? { try await pluginDriver.fetchApproximateRowCount(table: table, schema: pluginDriver.currentSchema) } diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/TablePro/Core/Plugins/PluginMetadataRegistry.swift index 4df60842a..44993deb7 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -54,6 +54,7 @@ struct PluginMetadataSnapshot: Sendable { var supportsAddIndex: Bool = true var supportsDropIndex: Bool = true var supportsModifyPrimaryKey: Bool = true + var supportsTriggers: Bool = false var defaultSSLMode: SSLMode = .disabled var supportsOpportunisticTLS: Bool = true var supportsCloudflareTunnel: Bool = true @@ -449,6 +450,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { requiresReconnectForDatabaseSwitch: false, supportsDropDatabase: true, supportsRenameColumn: true, + supportsTriggers: true, defaultSSLMode: .preferred ), schema: PluginMetadataSnapshot.SchemaInfo( @@ -498,6 +500,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { requiresReconnectForDatabaseSwitch: false, supportsDropDatabase: true, supportsRenameColumn: true, + supportsTriggers: true, defaultSSLMode: .preferred ), schema: PluginMetadataSnapshot.SchemaInfo( @@ -548,6 +551,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { requiresReconnectForDatabaseSwitch: true, supportsDropDatabase: true, supportsRenameColumn: true, + supportsTriggers: true, defaultSSLMode: .preferred ), schema: PluginMetadataSnapshot.SchemaInfo( @@ -709,6 +713,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsModifyColumn: false, supportsRenameColumn: true, supportsModifyPrimaryKey: false, + supportsTriggers: true, supportsCloudflareTunnel: false ), schema: PluginMetadataSnapshot.SchemaInfo( @@ -894,6 +899,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsAddIndex: driverType.supportsAddIndex, supportsDropIndex: driverType.supportsDropIndex, supportsModifyPrimaryKey: driverType.supportsModifyPrimaryKey, + supportsTriggers: driverType.supportsTriggers, defaultSSLMode: existingSnapshot?.capabilities.defaultSSLMode ?? .disabled, supportsOpportunisticTLS: existingSnapshot?.capabilities.supportsOpportunisticTLS ?? true, supportsCloudflareTunnel: driverType.supportsSSH, diff --git a/TablePro/Core/SchemaTracking/StructureChangeManager.swift b/TablePro/Core/SchemaTracking/StructureChangeManager.swift index 8a606edd5..a669de298 100644 --- a/TablePro/Core/SchemaTracking/StructureChangeManager.swift +++ b/TablePro/Core/SchemaTracking/StructureChangeManager.swift @@ -402,7 +402,7 @@ final class StructureChangeManager: ChangeManaging { case .foreignKeys: guard row < workingForeignKeys.count else { return } key = .foreignKey(workingForeignKeys[row].id) - case .ddl, .parts: + case .ddl, .parts, .triggers: return } guard pendingChanges[key]?.isDelete == true else { return } @@ -789,7 +789,7 @@ final class StructureChangeManager: ChangeManaging { let isDeleted = change?.isDelete ?? false let isInserted = !currentForeignKeys.contains(where: { $0.id == fk.id }) return (isDeleted, isInserted) - case .ddl, .parts: + case .ddl, .parts, .triggers: return (false, false) } } diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift index fe59110e8..77a9f29f4 100644 --- a/TablePro/Models/Connection/DatabaseConnection.swift +++ b/TablePro/Models/Connection/DatabaseConnection.swift @@ -196,6 +196,10 @@ extension DatabaseType { PluginMetadataRegistry.shared.snapshot(forTypeId: pluginTypeId)?.supportsForeignKeys ?? true } + var supportsTriggers: Bool { + PluginMetadataRegistry.shared.snapshot(forTypeId: pluginTypeId)?.capabilities.supportsTriggers ?? false + } + var supportsSchemaEditing: Bool { PluginMetadataRegistry.shared.snapshot(forTypeId: rawValue)?.supportsSchemaEditing ?? true } diff --git a/TablePro/Models/Query/QueryResult.swift b/TablePro/Models/Query/QueryResult.swift index 175ce185b..4709e1a5d 100644 --- a/TablePro/Models/Query/QueryResult.swift +++ b/TablePro/Models/Query/QueryResult.swift @@ -217,6 +217,32 @@ struct ForeignKeyInfo: Identifiable, Hashable { } } +struct TriggerInfo: Identifiable, Hashable { + let id = UUID() + let name: String + let timing: String + let event: String + let forEachRow: Bool + let whenClause: String? + let statement: String + + init( + name: String, + timing: String, + event: String, + forEachRow: Bool = true, + whenClause: String? = nil, + statement: String + ) { + self.name = name + self.timing = timing + self.event = event + self.forEachRow = forEachRow + self.whenClause = whenClause + self.statement = statement + } +} + /// Connection status enum ConnectionStatus: Equatable, Sendable { case disconnected diff --git a/TablePro/Models/Schema/StructureTab.swift b/TablePro/Models/Schema/StructureTab.swift index 9262bc934..da9db6cc6 100644 --- a/TablePro/Models/Schema/StructureTab.swift +++ b/TablePro/Models/Schema/StructureTab.swift @@ -12,6 +12,7 @@ enum StructureTab: String, CaseIterable, Hashable { case columns case indexes case foreignKeys + case triggers case ddl case parts @@ -20,6 +21,7 @@ enum StructureTab: String, CaseIterable, Hashable { case .columns: String(localized: "Columns") case .indexes: String(localized: "Indexes") case .foreignKeys: String(localized: "Foreign Keys") + case .triggers: String(localized: "Triggers") case .ddl: "DDL" case .parts: "Parts" } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index fe3ec1c9f..b89bf3968 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -51087,6 +51087,9 @@ } } } + }, + "No triggers" : { + }, "No valid rows found in clipboard data." : { "localizations" : { @@ -79838,6 +79841,9 @@ } } } + }, + "Triggers" : { + }, "true" : { "localizations" : { diff --git a/TablePro/Views/Structure/StructureGridDelegate.swift b/TablePro/Views/Structure/StructureGridDelegate.swift index 4cf64ff52..b761d8b30 100644 --- a/TablePro/Views/Structure/StructureGridDelegate.swift +++ b/TablePro/Views/Structure/StructureGridDelegate.swift @@ -94,7 +94,7 @@ final class StructureGridDelegate: DataGridViewDelegate { StructureEditingSupport.updateForeignKey(&fk, at: column, with: newValue ?? "") structureChangeManager.updateForeignKey(id: fk.id, with: fk) - case .ddl, .parts: + case .ddl, .parts, .triggers: break } @@ -148,7 +148,7 @@ final class StructureGridDelegate: DataGridViewDelegate { let fk = structureChangeManager.workingForeignKeys[row] structureChangeManager.deleteForeignKey(id: fk.id) } - case .parts, .ddl: + case .parts, .ddl, .triggers: onSelectedRowsChanged?([]) return } @@ -174,7 +174,7 @@ final class StructureGridDelegate: DataGridViewDelegate { } func dataGridCopyRows(_ indices: Set) { - guard selectedTab != .ddl, selectedTab != .parts, !indices.isEmpty else { return } + guard selectedTab != .ddl, selectedTab != .parts, selectedTab != .triggers, !indices.isEmpty else { return } let translated = sourceRows(for: indices) var copiedItems: [Any] = [] @@ -195,7 +195,7 @@ final class StructureGridDelegate: DataGridViewDelegate { guard row < structureChangeManager.workingForeignKeys.count else { continue } copiedItems.append(structureChangeManager.workingForeignKeys[row]) } - case .ddl, .parts: + case .ddl, .parts, .triggers: break } @@ -306,13 +306,13 @@ final class StructureGridDelegate: DataGridViewDelegate { structureChangeManager.addForeignKey(newFK) } - case .ddl, .parts: + case .ddl, .parts, .triggers: break } } func dataGridUndo() { - guard selectedTab != .ddl else { return } + guard selectedTab != .ddl, selectedTab != .triggers else { return } structureChangeManager.undo() // Undo can revert any row's content and visual state. The SwiftUI // re-render driven by `reloadVersion` only invalidates the snapshot; @@ -322,7 +322,7 @@ final class StructureGridDelegate: DataGridViewDelegate { } func dataGridRedo() { - guard selectedTab != .ddl else { return } + guard selectedTab != .ddl, selectedTab != .triggers else { return } structureChangeManager.redo() reloadAllVisibleRows() } @@ -338,7 +338,7 @@ final class StructureGridDelegate: DataGridViewDelegate { case .foreignKeys: guard connection.type.supportsForeignKeys else { return } structureChangeManager.addNewForeignKey() - case .ddl, .parts: + case .ddl, .parts, .triggers: break } } @@ -386,7 +386,7 @@ final class StructureGridDelegate: DataGridViewDelegate { let working = structureChangeManager.workingForeignKeys[sourceRow] guard let original = structureChangeManager.currentForeignKeys.first(where: { $0.id == working.id }) else { return [] } return StructureEditingSupport.foreignKeyModifiedIndices(old: original, new: working) - case .ddl, .parts: + case .ddl, .parts, .triggers: return [] } } @@ -462,7 +462,7 @@ final class StructureGridDelegate: DataGridViewDelegate { } private func makeEmptySpaceMenu() -> NSMenu? { - guard selectedTab != .ddl, selectedTab != .parts else { return nil } + guard selectedTab != .ddl, selectedTab != .parts, selectedTab != .triggers else { return nil } guard connection.type.supportsSchemaEditing else { return nil } let menu = NSMenu() @@ -477,7 +477,8 @@ final class StructureGridDelegate: DataGridViewDelegate { case .foreignKeys: guard connection.type.supportsForeignKeys else { return nil } label = String(localized: "Add Foreign Key") - case .ddl, .parts: return nil + case .ddl, .parts, .triggers: + return nil } let target = StructureMenuTarget { [weak self] in self?.dataGridAddRow() } @@ -524,7 +525,7 @@ final class StructureGridDelegate: DataGridViewDelegate { if let sql = driver.generateForeignKeyDefinitionSQL(fk: fk.toPlugin()) { definitions.append(sql) } - case .ddl, .parts: + case .ddl, .parts, .triggers: break } } @@ -618,7 +619,7 @@ final class StructureGridDelegate: DataGridViewDelegate { referencedSchema: copy.referencedSchema, onDelete: copy.onDelete, onUpdate: copy.onUpdate )) - case .ddl, .parts: + case .ddl, .parts, .triggers: break } } diff --git a/TablePro/Views/Structure/StructureRowProvider.swift b/TablePro/Views/Structure/StructureRowProvider.swift index 0727d0b19..fc1828395 100644 --- a/TablePro/Views/Structure/StructureRowProvider.swift +++ b/TablePro/Views/Structure/StructureRowProvider.swift @@ -61,7 +61,7 @@ final class StructureRowProvider { String(localized: "On Delete"), String(localized: "On Update") ] - case .ddl, .parts: + case .ddl, .parts, .triggers: return [] } } @@ -82,7 +82,7 @@ final class StructureRowProvider { return [3] case .foreignKeys: return [] - case .ddl, .parts: + case .ddl, .parts, .triggers: return [] } } @@ -96,7 +96,7 @@ final class StructureRowProvider { case .indexes: let types = EditableIndexDefinition.IndexType.allCases.map(\.rawValue) return [2: types] - case .columns, .ddl, .parts: + case .columns, .ddl, .parts, .triggers: return [:] } } @@ -106,7 +106,7 @@ final class StructureRowProvider { case .columns: if let i = orderedColumnFields.firstIndex(of: .type) { return [i] } return [] - case .indexes, .foreignKeys, .ddl, .parts: + case .indexes, .foreignKeys, .ddl, .parts, .triggers: return [] } } @@ -213,7 +213,7 @@ final class StructureRowProvider { fk.onUpdate.rawValue ]) } - case .ddl, .parts: + case .ddl, .parts, .triggers: return [] } } diff --git a/TablePro/Views/Structure/StructureRowViewWithMenu.swift b/TablePro/Views/Structure/StructureRowViewWithMenu.swift index fb9e6e860..e0e7f8b91 100644 --- a/TablePro/Views/Structure/StructureRowViewWithMenu.swift +++ b/TablePro/Views/Structure/StructureRowViewWithMenu.swift @@ -28,7 +28,7 @@ final class StructureRowViewWithMenu: DataGridRowView { var onUndoDelete: ((Int) -> Void)? override func menu(for event: NSEvent) -> NSMenu? { - guard structureTab != .ddl, structureTab != .parts else { return nil } + guard structureTab != .ddl, structureTab != .parts, structureTab != .triggers else { return nil } let menu = NSMenu() diff --git a/TablePro/Views/Structure/TableStructureView+DataLoading.swift b/TablePro/Views/Structure/TableStructureView+DataLoading.swift index 0244b122f..ea74b6b0b 100644 --- a/TablePro/Views/Structure/TableStructureView+DataLoading.swift +++ b/TablePro/Views/Structure/TableStructureView+DataLoading.swift @@ -79,6 +79,10 @@ extension TableStructureView { } return preamble + "\n" + baseDDL } + case .triggers: + triggers = try await DatabaseManager.shared.withMetadataDriver(connectionId: connection.id) { driver in + try await driver.fetchTriggers(table: tableName) + } case .parts: return } @@ -177,5 +181,8 @@ extension TableStructureView { if selectedTab == .ddl { await fetchTabData(.ddl) } + if selectedTab == .triggers, connection.type.supportsTriggers { + await fetchTabData(.triggers) + } } } diff --git a/TablePro/Views/Structure/TableStructureView.swift b/TablePro/Views/Structure/TableStructureView.swift index 459db63cc..99eeee1c7 100644 --- a/TablePro/Views/Structure/TableStructureView.swift +++ b/TablePro/Views/Structure/TableStructureView.swift @@ -27,6 +27,8 @@ struct TableStructureView: View { @State var columns: [ColumnInfo] = [] @State var indexes: [IndexInfo] = [] @State var foreignKeys: [ForeignKeyInfo] = [] + @State var triggers: [TriggerInfo] = [] + @State private var selectedTriggerName: String? @State var ddlStatement: String = "" @State var ddlFontSize: CGFloat = 13 @State var showCopyConfirmation = false @@ -166,6 +168,9 @@ struct TableStructureView: View { if connection.type != .clickhouse { tabs = tabs.filter { $0 != .parts } } + if !connection.type.supportsTriggers { + tabs = tabs.filter { $0 != .triggers } + } return tabs } @@ -209,7 +214,7 @@ struct TableStructureView: View { case .columns: return connection.type.supportsAddColumn case .indexes: return connection.type.supportsAddIndex case .foreignKeys: return connection.type.supportsForeignKeys - case .ddl, .parts: return false + case .ddl, .parts, .triggers: return false } } @@ -219,7 +224,7 @@ struct TableStructureView: View { case .columns: return connection.type.supportsDropColumn case .indexes: return connection.type.supportsDropIndex case .foreignKeys: return connection.type.supportsForeignKeys - case .ddl, .parts: return false + case .ddl, .parts, .triggers: return false } } @@ -231,7 +236,7 @@ struct TableStructureView: View { return (String(localized: "Add Index"), String(localized: "Remove Index")) case .foreignKeys: return (String(localized: "Add Foreign Key"), String(localized: "Remove Foreign Key")) - case .ddl, .parts: + case .ddl, .parts, .triggers: return nil } } @@ -247,6 +252,8 @@ struct TableStructureView: View { count = loadedTabs.contains(.indexes) ? indexes.count : nil case .foreignKeys: count = loadedTabs.contains(.foreignKeys) ? foreignKeys.count : nil + case .triggers: + count = loadedTabs.contains(.triggers) ? triggers.count : nil case .ddl, .parts: count = nil } @@ -285,6 +292,14 @@ struct TableStructureView: View { } else { structureGrid } + case .triggers: + TriggerDetailView( + triggers: triggers, + selectedName: $selectedTriggerName, + fontSize: $ddlFontSize, + databaseType: connection.type, + isLoading: !loadedTabs.contains(.triggers) + ) case .ddl: ddlView case .parts: diff --git a/TablePro/Views/Structure/TriggerDetailView.swift b/TablePro/Views/Structure/TriggerDetailView.swift new file mode 100644 index 000000000..95aacbf3a --- /dev/null +++ b/TablePro/Views/Structure/TriggerDetailView.swift @@ -0,0 +1,122 @@ +import SwiftUI + +struct TriggerDetailView: View { + let triggers: [TriggerInfo] + @Binding var selectedName: String? + @Binding var fontSize: CGFloat + let databaseType: DatabaseType + let isLoading: Bool + + var body: some View { + if isLoading { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if triggers.isEmpty { + emptyState + } else { + HSplitView { + triggerList + .frame(minWidth: 200, idealWidth: 260) + detailPanel + .frame(minWidth: 320) + } + .onAppear(perform: ensureSelection) + .onChange(of: triggers) { _, _ in ensureSelection() } + } + } + + private var selectedTrigger: TriggerInfo? { + guard let name = selectedName, + let match = triggers.first(where: { $0.name == name }) else { + return triggers.first + } + return match + } + + private func ensureSelection() { + guard let name = selectedName, + triggers.contains(where: { $0.name == name }) else { + selectedName = triggers.first?.name + return + } + } + + private var triggerList: some View { + List(triggers, id: \.name, selection: $selectedName) { trigger in + VStack(alignment: .leading, spacing: 2) { + Text(trigger.name) + HStack(spacing: 6) { + if !trigger.timing.isEmpty { + Text(trigger.timing) + .font(.caption) + .foregroundStyle(.secondary) + } + if !trigger.event.isEmpty { + Text(trigger.event) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .padding(.vertical, 2) + } + .listStyle(.inset) + } + + private var detailPanel: some View { + VStack(spacing: 0) { + if let trigger = selectedTrigger { + detailToolbar(for: trigger) + Divider() + DDLTextView(ddl: trigger.statement, fontSize: $fontSize, databaseType: databaseType) + } else { + Color(nsColor: .textBackgroundColor) + } + } + } + + private func detailToolbar(for trigger: TriggerInfo) -> some View { + HStack(spacing: 12) { + HStack(spacing: 4) { + Button(action: { fontSize = max(10, fontSize - 1) }) { + Image(systemName: "textformat.size.smaller") + .frame(width: 24, height: 24) + } + .accessibilityLabel(String(localized: "Decrease font size")) + Text("\(Int(fontSize))") + .font(.caption) + .foregroundStyle(.secondary) + .frame(width: 24) + Button(action: { fontSize = min(24, fontSize + 1) }) { + Image(systemName: "textformat.size.larger") + .frame(width: 24, height: 24) + } + .accessibilityLabel(String(localized: "Increase font size")) + } + .buttonStyle(.borderless) + + Spacer() + + Button { + ClipboardService.shared.writeText(trigger.statement) + } label: { + Label("Copy", systemImage: "doc.on.doc") + } + .buttonStyle(.bordered) + } + .padding() + .background(Color(nsColor: .controlBackgroundColor)) + } + + private var emptyState: some View { + VStack(spacing: 8) { + Image(systemName: "bolt.slash") + .font(.largeTitle) + .foregroundStyle(.secondary) + .accessibilityHidden(true) + Text("No triggers") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/TableProTests/Core/Database/TriggerInfoMappingTests.swift b/TableProTests/Core/Database/TriggerInfoMappingTests.swift new file mode 100644 index 000000000..e5b22da24 --- /dev/null +++ b/TableProTests/Core/Database/TriggerInfoMappingTests.swift @@ -0,0 +1,121 @@ +// +// TriggerInfoMappingTests.swift +// TableProTests +// +// Tests for PluginTriggerInfo encoding and the PluginDriverAdapter trigger bridge. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +private final class StubTriggerDriver: PluginDatabaseDriver { + var supportsSchemas: Bool { false } + var supportsTransactions: Bool { false } + var currentSchema: String? { nil } + var serverVersion: String? { nil } + + var triggersToReturn: [PluginTriggerInfo] = [] + + func connect() async throws {} + func disconnect() {} + func ping() async throws {} + func execute(query: String) async throws -> PluginQueryResult { + PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) + } + + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } + func fetchTriggers(table: String, schema: String?) async throws -> [PluginTriggerInfo] { triggersToReturn } + func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } + func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { + PluginTableMetadata(tableName: table) + } + + func fetchDatabases() async throws -> [String] { [] } + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + PluginDatabaseMetadata(name: database) + } +} + +@Suite("Trigger info mapping") +struct TriggerInfoMappingTests { + @Test("PluginTriggerInfo encodes and decodes without a when clause") + func codableRoundTripNoWhen() throws { + let original = PluginTriggerInfo( + name: "trg_audit", + timing: "AFTER", + event: "INSERT", + forEachRow: true, + statement: "CREATE TRIGGER trg_audit AFTER INSERT ON t FOR EACH ROW BEGIN END" + ) + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(PluginTriggerInfo.self, from: data) + #expect(decoded.name == original.name) + #expect(decoded.timing == original.timing) + #expect(decoded.event == original.event) + #expect(decoded.forEachRow == original.forEachRow) + #expect(decoded.whenClause == nil) + #expect(decoded.statement == original.statement) + } + + @Test("PluginTriggerInfo preserves a when clause through Codable") + func codableRoundTripWithWhen() throws { + let original = PluginTriggerInfo( + name: "trg_check", + timing: "BEFORE", + event: "UPDATE", + forEachRow: false, + whenClause: "new.amount > 0", + statement: "CREATE TRIGGER trg_check BEFORE UPDATE ON t WHEN new.amount > 0 ..." + ) + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(PluginTriggerInfo.self, from: data) + #expect(decoded.whenClause == "new.amount > 0") + #expect(decoded.forEachRow == false) + } + + @Test("Adapter maps plugin triggers to app TriggerInfo preserving fields") + func adapterMapsTriggers() async throws { + let driver = StubTriggerDriver() + driver.triggersToReturn = [ + PluginTriggerInfo( + name: "trg_audit", + timing: "AFTER", + event: "INSERT OR UPDATE", + forEachRow: true, + whenClause: "new.id IS NOT NULL", + statement: "CREATE TRIGGER trg_audit ..." + ) + ] + let connection = DatabaseConnection(name: "Test", type: .postgresql) + let adapter = PluginDriverAdapter(connection: connection, pluginDriver: driver) + + let triggers = try await adapter.fetchTriggers(table: "t") + #expect(triggers.count == 1) + let trigger = try #require(triggers.first) + #expect(trigger.name == "trg_audit") + #expect(trigger.timing == "AFTER") + #expect(trigger.event == "INSERT OR UPDATE") + #expect(trigger.forEachRow == true) + #expect(trigger.whenClause == "new.id IS NOT NULL") + #expect(trigger.statement == "CREATE TRIGGER trg_audit ...") + } +} + +@Suite("StructureTab triggers") +struct StructureTabTriggersTests { + @Test("Triggers tab is part of the canonical tab set") + func triggersInAllCases() { + #expect(StructureTab.allCases.contains(.triggers)) + } + + @Test("Triggers tab has a localized display name") + func triggersDisplayName() { + #expect(StructureTab.triggers.displayName == "Triggers") + } +} diff --git a/TableProTests/Views/Structure/StructureGridDelegateAddRowTests.swift b/TableProTests/Views/Structure/StructureGridDelegateAddRowTests.swift index ee47e9d87..44cbde0c0 100644 --- a/TableProTests/Views/Structure/StructureGridDelegateAddRowTests.swift +++ b/TableProTests/Views/Structure/StructureGridDelegateAddRowTests.swift @@ -78,6 +78,30 @@ struct StructureGridDelegateAddRowTests { #expect(manager.workingForeignKeys.count == fksBefore) } + @Test("Triggers sub-tab: dataGridAddRow is a no-op") + func triggersTab_isNoOp() { + let (delegate, manager) = makeDelegate(selectedTab: .triggers) + let columnsBefore = manager.workingColumns.count + let indexesBefore = manager.workingIndexes.count + let fksBefore = manager.workingForeignKeys.count + delegate.dataGridAddRow() + #expect(manager.workingColumns.count == columnsBefore) + #expect(manager.workingIndexes.count == indexesBefore) + #expect(manager.workingForeignKeys.count == fksBefore) + } + + @Test("Delete: triggers sub-tab is a no-op") + func triggersTab_deleteIsNoOp() { + let (delegate, manager) = makeDelegate(selectedTab: .triggers) + let columnsBefore = manager.workingColumns.count + let indexesBefore = manager.workingIndexes.count + let fksBefore = manager.workingForeignKeys.count + delegate.dataGridDeleteRows([0]) + #expect(manager.workingColumns.count == columnsBefore) + #expect(manager.workingIndexes.count == indexesBefore) + #expect(manager.workingForeignKeys.count == fksBefore) + } + @Test("Indexes sub-tab on SQLite: dataGridAddRow is a no-op (supportsAddIndex == false)") func sqliteIndexes_isNoOp() { let (delegate, manager) = makeDelegate(selectedTab: .indexes, type: .sqlite) diff --git a/docs/features/table-structure.mdx b/docs/features/table-structure.mdx index 2f9fdfa85..fbc2b7207 100644 --- a/docs/features/table-structure.mdx +++ b/docs/features/table-structure.mdx @@ -1,11 +1,11 @@ --- title: Table Structure -description: Browse column definitions, indexes, foreign keys, and DDL with a visual structure editor +description: Browse column definitions, indexes, foreign keys, triggers, and DDL with a visual structure editor --- # Table Structure -Browse columns, indexes, foreign keys, and DDL for any table. Edit structure visually or with SQL. +Browse columns, indexes, foreign keys, triggers, and DDL for any table. Edit structure visually or with SQL. {/* Screenshot: Table structure view showing columns tab */} @@ -157,6 +157,22 @@ The DDL view uses tree-sitter syntax highlighting with line numbers. Use the too - **Export** as a `.sql` file - **Open in Editor** to send the DDL to a new query tab for editing +## Triggers Tab + +The Triggers tab lists the table's triggers and shows the full definition of the selected one. It is read-only. + +| Property | Description | +|----------|-------------| +| **Name** | Trigger name | +| **Timing** | BEFORE, AFTER, or INSTEAD OF | +| **Event** | INSERT, UPDATE, DELETE (PostgreSQL can combine events, such as INSERT OR UPDATE) | + +Select a trigger to see its full definition in a syntax-highlighted viewer with a **Copy** button. The definition is the `CREATE TRIGGER` statement (MySQL and MariaDB reconstruct it from the trigger body; PostgreSQL uses `pg_get_triggerdef`; SQLite reads the stored statement). + + +Available for MySQL, MariaDB, PostgreSQL, and SQLite. The tab is hidden for databases that do not expose triggers. + + ## Creating a New Table Right-click in the sidebar and select **Create New Table...**. A visual editor opens with: From 531b4552b639c7dfb85669d97232622e82fa51ec Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 16 Jun 2026 23:09:20 +0700 Subject: [PATCH 2/2] refactor(structure): rebuild Triggers tab with native Table and split-pane detail --- TablePro/Models/Query/QueryResult.swift | 2 +- .../Views/Components/EmptyStateView.swift | 9 ++ .../Structure/TableStructureView+Schema.swift | 8 ++ .../Views/Structure/TableStructureView.swift | 7 +- .../Views/Structure/TriggerDetailView.swift | 91 +++++++++---------- 5 files changed, 63 insertions(+), 54 deletions(-) diff --git a/TablePro/Models/Query/QueryResult.swift b/TablePro/Models/Query/QueryResult.swift index 4709e1a5d..e6a310e97 100644 --- a/TablePro/Models/Query/QueryResult.swift +++ b/TablePro/Models/Query/QueryResult.swift @@ -218,7 +218,7 @@ struct ForeignKeyInfo: Identifiable, Hashable { } struct TriggerInfo: Identifiable, Hashable { - let id = UUID() + var id: String { name } let name: String let timing: String let event: String diff --git a/TablePro/Views/Components/EmptyStateView.swift b/TablePro/Views/Components/EmptyStateView.swift index 5b487d2f7..a972b646d 100644 --- a/TablePro/Views/Components/EmptyStateView.swift +++ b/TablePro/Views/Components/EmptyStateView.swift @@ -120,6 +120,15 @@ extension EmptyStateView { ) } + /// Empty state for triggers + static func triggers() -> EmptyStateView { + EmptyStateView( + icon: "bolt", + title: String(localized: "No Triggers"), + description: String(localized: "This table has no triggers. Triggers run automatically when rows are inserted, updated, or deleted.") + ) + } + /// Empty state for check constraints static func checkConstraints(onAdd: @escaping () -> Void) -> EmptyStateView { EmptyStateView( diff --git a/TablePro/Views/Structure/TableStructureView+Schema.swift b/TablePro/Views/Structure/TableStructureView+Schema.swift index 177b18b4b..e8861f631 100644 --- a/TablePro/Views/Structure/TableStructureView+Schema.swift +++ b/TablePro/Views/Structure/TableStructureView+Schema.swift @@ -228,6 +228,14 @@ extension TableStructureView { ) } + func openTriggerInEditor(_ trigger: TriggerInfo) { + guard !trigger.statement.isEmpty else { return } + coordinator?.tabManager.addTab( + initialQuery: trigger.statement, + title: trigger.name + ) + } + private func copyDDL() { ClipboardService.shared.writeText(ddlStatement) diff --git a/TablePro/Views/Structure/TableStructureView.swift b/TablePro/Views/Structure/TableStructureView.swift index 99eeee1c7..1756fce68 100644 --- a/TablePro/Views/Structure/TableStructureView.swift +++ b/TablePro/Views/Structure/TableStructureView.swift @@ -28,7 +28,7 @@ struct TableStructureView: View { @State var indexes: [IndexInfo] = [] @State var foreignKeys: [ForeignKeyInfo] = [] @State var triggers: [TriggerInfo] = [] - @State private var selectedTriggerName: String? + @State private var selectedTriggerID: TriggerInfo.ID? @State var ddlStatement: String = "" @State var ddlFontSize: CGFloat = 13 @State var showCopyConfirmation = false @@ -295,10 +295,11 @@ struct TableStructureView: View { case .triggers: TriggerDetailView( triggers: triggers, - selectedName: $selectedTriggerName, + selectedTriggerID: $selectedTriggerID, fontSize: $ddlFontSize, databaseType: connection.type, - isLoading: !loadedTabs.contains(.triggers) + isLoading: !loadedTabs.contains(.triggers), + onOpenInEditor: openTriggerInEditor ) case .ddl: ddlView diff --git a/TablePro/Views/Structure/TriggerDetailView.swift b/TablePro/Views/Structure/TriggerDetailView.swift index 95aacbf3a..8536ec877 100644 --- a/TablePro/Views/Structure/TriggerDetailView.swift +++ b/TablePro/Views/Structure/TriggerDetailView.swift @@ -2,23 +2,24 @@ import SwiftUI struct TriggerDetailView: View { let triggers: [TriggerInfo] - @Binding var selectedName: String? + @Binding var selectedTriggerID: TriggerInfo.ID? @Binding var fontSize: CGFloat let databaseType: DatabaseType let isLoading: Bool + let onOpenInEditor: (TriggerInfo) -> Void var body: some View { if isLoading { ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) } else if triggers.isEmpty { - emptyState + EmptyStateView.triggers() } else { - HSplitView { - triggerList - .frame(minWidth: 200, idealWidth: 260) - detailPanel - .frame(minWidth: 320) + VSplitView { + triggerTable + .frame(minHeight: 120, idealHeight: 170) + detailPane + .frame(minHeight: 180) } .onAppear(perform: ensureSelection) .onChange(of: triggers) { _, _ in ensureSelection() } @@ -26,49 +27,40 @@ struct TriggerDetailView: View { } private var selectedTrigger: TriggerInfo? { - guard let name = selectedName, - let match = triggers.first(where: { $0.name == name }) else { + guard let id = selectedTriggerID, + let match = triggers.first(where: { $0.id == id }) else { return triggers.first } return match } private func ensureSelection() { - guard let name = selectedName, - triggers.contains(where: { $0.name == name }) else { - selectedName = triggers.first?.name + guard let id = selectedTriggerID, + triggers.contains(where: { $0.id == id }) else { + selectedTriggerID = triggers.first?.id return } } - private var triggerList: some View { - List(triggers, id: \.name, selection: $selectedName) { trigger in - VStack(alignment: .leading, spacing: 2) { - Text(trigger.name) - HStack(spacing: 6) { - if !trigger.timing.isEmpty { - Text(trigger.timing) - .font(.caption) - .foregroundStyle(.secondary) - } - if !trigger.event.isEmpty { - Text(trigger.event) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - .padding(.vertical, 2) + private var triggerTable: some View { + Table(triggers, selection: $selectedTriggerID) { + TableColumn(String(localized: "Name"), value: \.name) + .width(min: 160, ideal: 240) + TableColumn(String(localized: "Timing"), value: \.timing) + .width(min: 70, ideal: 90) + TableColumn(String(localized: "Event"), value: \.event) + .width(min: 90, ideal: 140) } - .listStyle(.inset) } - private var detailPanel: some View { - VStack(spacing: 0) { + private var detailPane: some View { + Group { if let trigger = selectedTrigger { - detailToolbar(for: trigger) - Divider() - DDLTextView(ddl: trigger.statement, fontSize: $fontSize, databaseType: databaseType) + VStack(spacing: 0) { + detailToolbar(for: trigger) + Divider() + DDLTextView(ddl: trigger.statement, fontSize: $fontSize, databaseType: databaseType) + } } else { Color(nsColor: .textBackgroundColor) } @@ -78,7 +70,9 @@ struct TriggerDetailView: View { private func detailToolbar(for trigger: TriggerInfo) -> some View { HStack(spacing: 12) { HStack(spacing: 4) { - Button(action: { fontSize = max(10, fontSize - 1) }) { + Button { + fontSize = max(10, fontSize - 1) + } label: { Image(systemName: "textformat.size.smaller") .frame(width: 24, height: 24) } @@ -87,7 +81,9 @@ struct TriggerDetailView: View { .font(.caption) .foregroundStyle(.secondary) .frame(width: 24) - Button(action: { fontSize = min(24, fontSize + 1) }) { + Button { + fontSize = min(24, fontSize + 1) + } label: { Image(systemName: "textformat.size.larger") .frame(width: 24, height: 24) } @@ -97,6 +93,13 @@ struct TriggerDetailView: View { Spacer() + Button { + onOpenInEditor(trigger) + } label: { + Label("Open in Editor", systemImage: "square.and.pencil") + } + .buttonStyle(.bordered) + Button { ClipboardService.shared.writeText(trigger.statement) } label: { @@ -107,16 +110,4 @@ struct TriggerDetailView: View { .padding() .background(Color(nsColor: .controlBackgroundColor)) } - - private var emptyState: some View { - VStack(spacing: 8) { - Image(systemName: "bolt.slash") - .font(.largeTitle) - .foregroundStyle(.secondary) - .accessibilityHidden(true) - Text("No triggers") - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } }