diff --git a/CHANGELOG.md b/CHANGELOG.md index 298960470..6537bb4e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +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) +- The table structure view has a Triggers tab for MySQL, MariaDB, PostgreSQL, SQLite, SQL Server, Oracle, libSQL, and Cloudflare D1. It lists each trigger with its timing and event (plus enabled state where the engine reports it), with a filter field and sortable columns. Selecting a trigger shows its full definition in a read-only syntax-highlighted viewer. (#1695) - Traditional Chinese (繁體中文) language in Settings > General with full UI translation ### Fixed diff --git a/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift b/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift index b6014ece5..0c079a056 100644 --- a/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift +++ b/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift @@ -26,6 +26,7 @@ final class CloudflareD1Plugin: NSObject, TableProPlugin, DriverPlugin { static let isDownloadable = true static let supportsImport = false static let supportsSchemaEditing = true + static let supportsTriggers = true static let databaseGroupingStrategy: GroupingStrategy = .flat static let brandColorHex = "#F6821F" static let urlSchemes: [String] = ["d1"] diff --git a/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift b/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift index 92cf379bb..0c0572c3c 100644 --- a/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift +++ b/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift @@ -433,6 +433,27 @@ final class CloudflareD1PluginDriver: 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)' + AND name NOT GLOB '_cf_*' + 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) = TriggerSQLParser.timingAndEvent(from: sql) + return PluginTriggerInfo(name: name, timing: timing, event: event, statement: sql) + } + } + func fetchTableDDL(table: String, schema: String?) async throws -> String { let safeTable = escapeStringLiteral(table) let query = """ diff --git a/Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift b/Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift index ee16d1e80..a8b424a46 100644 --- a/Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift +++ b/Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift @@ -28,6 +28,7 @@ final class LibSQLPlugin: NSObject, TableProPlugin, DriverPlugin { static let isDownloadable = true static let supportsImport = false static let supportsSchemaEditing = true + static let supportsTriggers = true static let supportsDropDatabase = false static let supportsDatabaseSwitching = false static let databaseGroupingStrategy: GroupingStrategy = .flat diff --git a/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift b/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift index 62f1e62e2..4db194ec3 100644 --- a/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift +++ b/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift @@ -505,6 +505,26 @@ final class LibSQLPluginDriver: 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) = TriggerSQLParser.timingAndEvent(from: sql) + return PluginTriggerInfo(name: name, timing: timing, event: event, statement: sql) + } + } + func fetchTableDDL(table: String, schema: String?) async throws -> String { let safeTable = escapeStringLiteral(table) let query = """ diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index 468243764..29ff08a19 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -150,6 +150,7 @@ final class MSSQLPlugin: NSObject, TableProPlugin, DriverPlugin { ) static let supportsDropDatabase = true + static let supportsTriggers = true func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { MSSQLPluginDriver(config: config) diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift b/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift index e2b27121d..1aae8bf2d 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift @@ -201,6 +201,48 @@ extension MSSQLPluginDriver { } } + func fetchTriggers(table: String, schema: String?) async throws -> [PluginTriggerInfo] { + let esc = (schema ?? _currentSchema).replacingOccurrences(of: "]", with: "]]") + let bracketedTable = table.replacingOccurrences(of: "]", with: "]]") + let bracketedFull = "[\(esc)].[\(bracketedTable)]" + let sql = """ + SELECT t.name, t.is_disabled, t.is_instead_of_trigger, + OBJECT_DEFINITION(t.object_id) AS definition, + te.type_desc AS event + FROM sys.triggers t + JOIN sys.trigger_events te ON t.object_id = te.object_id + WHERE t.parent_id = OBJECT_ID('\(bracketedFull)') + ORDER BY t.name, te.type_desc + """ + let result = try await execute(query: sql) + + var order: [String] = [] + var byName: [String: (timing: String, definition: String, enabled: Bool, events: [String])] = [:] + for row in result.rows { + guard let name = row[safe: 0]?.asText else { continue } + let event = row[safe: 4]?.asText ?? "" + if byName[name] == nil { + order.append(name) + let timing = (row[safe: 2]?.asText == "1") ? "INSTEAD OF" : "AFTER" + let enabled = (row[safe: 1]?.asText != "1") + byName[name] = (timing: timing, definition: row[safe: 3]?.asText ?? "", enabled: enabled, events: []) + } + if !event.isEmpty { + byName[name]?.events.append(event) + } + } + return order.compactMap { name in + guard let info = byName[name] else { return nil } + return PluginTriggerInfo( + name: name, + timing: info.timing, + event: info.events.joined(separator: " OR "), + statement: info.definition, + enabled: info.enabled + ) + } + } + func fetchAllColumns(schema: String?) async throws -> [String: [PluginColumnInfo]] { let esc = effectiveSchemaEscaped(schema) let sql = """ diff --git a/Plugins/OracleDriverPlugin/OraclePlugin.swift b/Plugins/OracleDriverPlugin/OraclePlugin.swift index c43e1d371..0d780171f 100644 --- a/Plugins/OracleDriverPlugin/OraclePlugin.swift +++ b/Plugins/OracleDriverPlugin/OraclePlugin.swift @@ -44,6 +44,7 @@ final class OraclePlugin: NSObject, TableProPlugin, DriverPlugin, PluginDiagnost // MARK: - UI/Capability Metadata static let isDownloadable = true + static let supportsTriggers = true static let pathFieldRole: PathFieldRole = .serviceName static let supportsForeignKeyDisable = false static let supportsSchemaSwitching = true @@ -467,6 +468,50 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { } } + func fetchTriggers(table: String, schema: String?) async throws -> [PluginTriggerInfo] { + let escapedTable = table.replacingOccurrences(of: "'", with: "''") + let escaped = effectiveSchemaEscaped(schema) + let sql = """ + SELECT TRIGGER_NAME, TRIGGER_TYPE, TRIGGERING_EVENT, STATUS, WHEN_CLAUSE + FROM ALL_TRIGGERS + WHERE TABLE_OWNER = '\(escaped)' + AND TABLE_NAME = '\(escapedTable)' + ORDER BY TRIGGER_NAME + """ + let result = try await execute(query: sql) + return result.rows.compactMap { row -> PluginTriggerInfo? in + guard let name = row[safe: 0]?.asText else { return nil } + let triggerType = (row[safe: 1]?.asText ?? "").uppercased() + let event = row[safe: 2]?.asText ?? "" + let timing: String + if triggerType.contains("INSTEAD OF") { + timing = "INSTEAD OF" + } else if triggerType.hasPrefix("BEFORE") { + timing = "BEFORE" + } else { + timing = "AFTER" + } + let isRowLevel = triggerType.contains("EACH ROW") + let enabled = (row[safe: 3]?.asText ?? "").uppercased() == "ENABLED" + let whenClause = row[safe: 4]?.asText + let quotedName = "\"\(name.replacingOccurrences(of: "\"", with: "\"\""))\"" + let quotedTable = "\"\(table.replacingOccurrences(of: "\"", with: "\"\""))\"" + let forEach = isRowLevel ? " FOR EACH ROW" : "" + let whenLine = (whenClause?.isEmpty == false) ? "\n WHEN (\(whenClause ?? ""))" : "" + let statement = """ + CREATE OR REPLACE TRIGGER \(quotedName) + \(timing) \(event) ON \(quotedTable)\(forEach)\(whenLine) + """ + return PluginTriggerInfo( + name: name, + timing: timing, + event: event, + statement: statement, + enabled: enabled + ) + } + } + func fetchAllColumns(schema: String?) async throws -> [String: [PluginColumnInfo]] { let escaped = effectiveSchemaEscaped(schema) let sql = """ diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index 7bd9ccfcd..22adc504a 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -307,6 +307,7 @@ final class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { WHEN (t.tgtype & 16) != 0 THEN 'DELETE' WHEN (t.tgtype & 32) != 0 THEN 'TRUNCATE' ELSE '' END AS event, + t.tgenabled <> 'D' AS enabled, pg_get_triggerdef(t.oid) AS definition FROM pg_catalog.pg_trigger t JOIN pg_catalog.pg_class c ON c.oid = t.tgrelid @@ -318,17 +319,18 @@ final class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { """ let result = try await execute(query: query) let triggers: [PluginTriggerInfo] = result.rows.compactMap { row -> PluginTriggerInfo? in - guard row.count >= 4, + guard row.count >= 5, let name = row[0].asText, let timing = row[1].asText, let event = row[2].asText, - let definition = row[3].asText + let definition = row[4].asText else { return nil } return PluginTriggerInfo( name: name, timing: timing, event: event, - statement: definition + statement: definition, + enabled: row[3].asText == "t" ) } Self.logger.info("[trigger] postgres fetchTriggers schema=\(resolvedSchema, privacy: .public) table=\(table, privacy: .public) rows=\(result.rows.count) parsed=\(triggers.count)") diff --git a/Plugins/TableProPluginKit/PluginTriggerInfo.swift b/Plugins/TableProPluginKit/PluginTriggerInfo.swift index a990731f8..4687b1f7d 100644 --- a/Plugins/TableProPluginKit/PluginTriggerInfo.swift +++ b/Plugins/TableProPluginKit/PluginTriggerInfo.swift @@ -12,16 +12,19 @@ public struct PluginTriggerInfo: Codable, Sendable { public let timing: String public let event: String public let statement: String + public let enabled: Bool? public init( name: String, timing: String, event: String, - statement: String + statement: String, + enabled: Bool? = nil ) { self.name = name self.timing = timing self.event = event self.statement = statement + self.enabled = enabled } } diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index 1761378dd..c21c37aba 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -258,7 +258,8 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { name: trigger.name, timing: trigger.timing, event: trigger.event, - statement: trigger.statement + statement: trigger.statement, + enabled: trigger.enabled ) } } diff --git a/TablePro/Models/Query/QueryResult.swift b/TablePro/Models/Query/QueryResult.swift index 826b6671e..57472d715 100644 --- a/TablePro/Models/Query/QueryResult.swift +++ b/TablePro/Models/Query/QueryResult.swift @@ -223,17 +223,20 @@ struct TriggerInfo: Identifiable, Hashable { let timing: String let event: String let statement: String + let enabled: Bool? init( name: String, timing: String, event: String, - statement: String + statement: String, + enabled: Bool? = nil ) { self.name = name self.timing = timing self.event = event self.statement = statement + self.enabled = enabled } } diff --git a/TablePro/Views/Components/AutosavingVSplitView.swift b/TablePro/Views/Components/AutosavingVSplitView.swift new file mode 100644 index 000000000..b80380587 --- /dev/null +++ b/TablePro/Views/Components/AutosavingVSplitView.swift @@ -0,0 +1,52 @@ +// +// AutosavingVSplitView.swift +// TablePro +// +// A vertically stacked split view whose divider position persists via NSSplitView.autosaveName. +// + +import AppKit +import SwiftUI + +struct AutosavingVSplitView: NSViewControllerRepresentable { + let autosaveName: String + let topMinimumHeight: CGFloat + let bottomMinimumHeight: CGFloat + @ViewBuilder let top: () -> Top + @ViewBuilder let bottom: () -> Bottom + + func makeCoordinator() -> Coordinator { Coordinator() } + + func makeNSViewController(context: Context) -> NSSplitViewController { + let controller = NSSplitViewController() + controller.splitView.isVertical = false + controller.splitView.dividerStyle = .thin + + let topController = NSHostingController(rootView: top()) + let bottomController = NSHostingController(rootView: bottom()) + context.coordinator.topController = topController + context.coordinator.bottomController = bottomController + + let topItem = NSSplitViewItem(viewController: topController) + topItem.minimumThickness = topMinimumHeight + topItem.canCollapse = false + let bottomItem = NSSplitViewItem(viewController: bottomController) + bottomItem.minimumThickness = bottomMinimumHeight + bottomItem.canCollapse = false + + controller.addSplitViewItem(topItem) + controller.addSplitViewItem(bottomItem) + controller.splitView.autosaveName = NSSplitView.AutosaveName(autosaveName) + return controller + } + + func updateNSViewController(_ controller: NSSplitViewController, context: Context) { + context.coordinator.topController?.rootView = top() + context.coordinator.bottomController?.rootView = bottom() + } + + final class Coordinator { + var topController: NSHostingController? + var bottomController: NSHostingController? + } +} diff --git a/TablePro/Views/Editor/ExplainResultView.swift b/TablePro/Views/Editor/ExplainResultView.swift index 16c47889a..2d49aeecd 100644 --- a/TablePro/Views/Editor/ExplainResultView.swift +++ b/TablePro/Views/Editor/ExplainResultView.swift @@ -18,7 +18,7 @@ struct ExplainResultView: View { let executionTime: TimeInterval? let plan: QueryPlan? - @State private var fontSize: CGFloat = 13 + @State private var fontSize: Double = 13 @State private var showCopyConfirmation = false @State private var copyResetTask: Task? @State private var viewMode: ExplainViewMode = .diagram diff --git a/TablePro/Views/Structure/DDLTextView.swift b/TablePro/Views/Structure/DDLTextView.swift index eef8c7ec4..25a3c24bc 100644 --- a/TablePro/Views/Structure/DDLTextView.swift +++ b/TablePro/Views/Structure/DDLTextView.swift @@ -13,7 +13,7 @@ import TableProPluginKit /// Read-only DDL display with syntax highlighting powered by CodeEditSourceEditor struct DDLTextView: View { let ddl: String - @Binding var fontSize: CGFloat + @Binding var fontSize: Double var databaseType: DatabaseType? @State private var text: String @@ -22,7 +22,7 @@ struct DDLTextView: View { @Environment(\.colorScheme) private var colorScheme /// Primary initializer accepting DDL as a value (read-only display) - init(ddl: String, fontSize: Binding, databaseType: DatabaseType? = nil) { + init(ddl: String, fontSize: Binding, databaseType: DatabaseType? = nil) { self.ddl = ddl self._text = State(wrappedValue: ddl) self._fontSize = fontSize @@ -59,8 +59,8 @@ struct DDLTextView: View { return .sql } - private static func makeConfiguration(fontSize: CGFloat) -> SourceEditorConfiguration { - let font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular) + private static func makeConfiguration(fontSize: Double) -> SourceEditorConfiguration { + let font = NSFont.monospacedSystemFont(ofSize: CGFloat(fontSize), weight: .regular) return SourceEditorConfiguration( appearance: .init( theme: TableProEditorTheme.make(), diff --git a/TablePro/Views/Structure/TableStructureView.swift b/TablePro/Views/Structure/TableStructureView.swift index 1756fce68..e41b47ed1 100644 --- a/TablePro/Views/Structure/TableStructureView.swift +++ b/TablePro/Views/Structure/TableStructureView.swift @@ -28,9 +28,8 @@ struct TableStructureView: View { @State var indexes: [IndexInfo] = [] @State var foreignKeys: [ForeignKeyInfo] = [] @State var triggers: [TriggerInfo] = [] - @State private var selectedTriggerID: TriggerInfo.ID? @State var ddlStatement: String = "" - @State var ddlFontSize: CGFloat = 13 + @AppStorage("structureCodeFontSize") var ddlFontSize: Double = 13 @State var showCopyConfirmation = false @State var copyResetTask: Task? @State var isLoading = true @@ -295,8 +294,6 @@ struct TableStructureView: View { case .triggers: TriggerDetailView( triggers: triggers, - selectedTriggerID: $selectedTriggerID, - fontSize: $ddlFontSize, databaseType: connection.type, isLoading: !loadedTabs.contains(.triggers), onOpenInEditor: openTriggerInEditor diff --git a/TablePro/Views/Structure/TriggerDetailView.swift b/TablePro/Views/Structure/TriggerDetailView.swift index 316dc3155..b61a16e91 100644 --- a/TablePro/Views/Structure/TriggerDetailView.swift +++ b/TablePro/Views/Structure/TriggerDetailView.swift @@ -1,13 +1,48 @@ +// +// TriggerDetailView.swift +// TablePro +// +// Read-only master-detail view of a table's triggers. +// + import SwiftUI +@Observable +final class TriggerInspectorState { + var searchText = "" + var sortOrder: [KeyPathComparator] = [KeyPathComparator(\.name)] + var selectedID: TriggerInfo.ID? + + func displayed(_ triggers: [TriggerInfo]) -> [TriggerInfo] { + let filtered = searchText.isEmpty + ? triggers + : triggers.filter { $0.name.localizedCaseInsensitiveContains(searchText) } + return filtered.sorted(using: sortOrder) + } + + func selectedTrigger(_ triggers: [TriggerInfo]) -> TriggerInfo? { + let list = displayed(triggers) + if let id = selectedID, let match = list.first(where: { $0.id == id }) { + return match + } + return list.first + } + + func ensureSelection(_ triggers: [TriggerInfo]) { + if selectedID == nil || !triggers.contains(where: { $0.id == selectedID }) { + selectedID = triggers.first?.id + } + } +} + struct TriggerDetailView: View { let triggers: [TriggerInfo] - @Binding var selectedTriggerID: TriggerInfo.ID? - @Binding var fontSize: CGFloat let databaseType: DatabaseType let isLoading: Bool let onOpenInEditor: (TriggerInfo) -> Void + @State private var state = TriggerInspectorState() + var body: some View { if isLoading { ProgressView() @@ -16,60 +51,96 @@ struct TriggerDetailView: View { EmptyStateView.triggers() .frame(maxWidth: .infinity, maxHeight: .infinity) } else { - VSplitView { - triggerTable - .frame(minHeight: 120, idealHeight: 170) - detailPane - .frame(minHeight: 180) + AutosavingVSplitView( + autosaveName: "com.TablePro.triggerSplit", + topMinimumHeight: 120, + bottomMinimumHeight: 180 + ) { + TriggerListPane(triggers: triggers, state: state) + } bottom: { + TriggerDetailPane(triggers: triggers, state: state, databaseType: databaseType, onOpenInEditor: onOpenInEditor) } .frame(maxWidth: .infinity, maxHeight: .infinity) - .onAppear(perform: ensureSelection) - .onChange(of: triggers) { _, _ in ensureSelection() } + .onAppear { state.ensureSelection(triggers) } + .onChange(of: triggers) { _, newTriggers in state.ensureSelection(newTriggers) } } } +} + +private struct TriggerListPane: View { + let triggers: [TriggerInfo] + @Bindable var state: TriggerInspectorState - private var selectedTrigger: TriggerInfo? { - guard let id = selectedTriggerID, - let match = triggers.first(where: { $0.id == id }) else { - return triggers.first + private var showEnabled: Bool { triggers.contains { $0.enabled != nil } } + + var body: some View { + VStack(spacing: 0) { + NativeSearchField(text: $state.searchText, placeholder: String(localized: "Filter")) + .padding(.horizontal, 8) + .padding(.vertical, 6) + Divider() + table } - return match } - private func ensureSelection() { - guard let id = selectedTriggerID, - triggers.contains(where: { $0.id == id }) else { - selectedTriggerID = triggers.first?.id - return + @ViewBuilder + private var table: some View { + if showEnabled { + Table(state.displayed(triggers), selection: $state.selectedID, sortOrder: $state.sortOrder) { + TableColumn(String(localized: "Name"), value: \.name) + .width(min: 140, ideal: 220) + TableColumn(String(localized: "Timing"), value: \.timing) + .width(min: 70, ideal: 90) + TableColumn(String(localized: "Event"), value: \.event) + .width(min: 90, ideal: 150) + TableColumn(String(localized: "Enabled")) { trigger in + enabledIndicator(trigger) + } + .width(min: 60, ideal: 70) + } + } else { + Table(state.displayed(triggers), selection: $state.selectedID, sortOrder: $state.sortOrder) { + TableColumn(String(localized: "Name"), value: \.name) + .width(min: 140, ideal: 240) + TableColumn(String(localized: "Timing"), value: \.timing) + .width(min: 70, ideal: 90) + TableColumn(String(localized: "Event"), value: \.event) + .width(min: 90, ideal: 160) + } } } - 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) + @ViewBuilder + private func enabledIndicator(_ trigger: TriggerInfo) -> some View { + if let enabled = trigger.enabled { + Image(systemName: enabled ? "checkmark.circle.fill" : "xmark.circle") + .foregroundStyle(enabled ? Color.green : Color.secondary) + .accessibilityLabel(enabled ? String(localized: "Enabled") : String(localized: "Disabled")) } } +} - private var detailPane: some View { - Group { - if let trigger = selectedTrigger { - VStack(spacing: 0) { - detailToolbar(for: trigger) - Divider() - DDLTextView(ddl: trigger.statement, fontSize: $fontSize, databaseType: databaseType) - } - } else { - Color(nsColor: .textBackgroundColor) +private struct TriggerDetailPane: View { + let triggers: [TriggerInfo] + let state: TriggerInspectorState + let databaseType: DatabaseType + let onOpenInEditor: (TriggerInfo) -> Void + + @AppStorage("structureCodeFontSize") private var fontSize: Double = 13 + + var body: some View { + if let trigger = state.selectedTrigger(triggers) { + VStack(spacing: 0) { + toolbar(for: trigger) + Divider() + DDLTextView(ddl: trigger.statement, fontSize: $fontSize, databaseType: databaseType) } + } else { + Color(nsColor: .textBackgroundColor) } } - private func detailToolbar(for trigger: TriggerInfo) -> some View { + private func toolbar(for trigger: TriggerInfo) -> some View { HStack(spacing: 12) { HStack(spacing: 4) { Button { diff --git a/TableProTests/Core/Database/TriggerInfoMappingTests.swift b/TableProTests/Core/Database/TriggerInfoMappingTests.swift index 98efea922..ed3abbc4c 100644 --- a/TableProTests/Core/Database/TriggerInfoMappingTests.swift +++ b/TableProTests/Core/Database/TriggerInfoMappingTests.swift @@ -68,7 +68,8 @@ struct TriggerInfoMappingTests { name: "trg_audit", timing: "AFTER", event: "INSERT OR UPDATE", - statement: "CREATE TRIGGER trg_audit ..." + statement: "CREATE TRIGGER trg_audit ...", + enabled: false ) ] let connection = DatabaseConnection(name: "Test", type: .postgresql) @@ -81,6 +82,21 @@ struct TriggerInfoMappingTests { #expect(trigger.timing == "AFTER") #expect(trigger.event == "INSERT OR UPDATE") #expect(trigger.statement == "CREATE TRIGGER trg_audit ...") + #expect(trigger.enabled == false) + } + + @Test("PluginTriggerInfo carries enabled state through Codable") + func codableRoundTripWithEnabled() throws { + let original = PluginTriggerInfo( + name: "trg_check", + timing: "BEFORE", + event: "UPDATE", + statement: "CREATE TRIGGER trg_check ...", + enabled: true + ) + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(PluginTriggerInfo.self, from: data) + #expect(decoded.enabled == true) } } diff --git a/docs/features/table-structure.mdx b/docs/features/table-structure.mdx index fbc2b7207..0888420aa 100644 --- a/docs/features/table-structure.mdx +++ b/docs/features/table-structure.mdx @@ -159,18 +159,19 @@ The DDL view uses tree-sitter syntax highlighting with line numbers. Use the too ## Triggers Tab -The Triggers tab lists the table's triggers and shows the full definition of the selected one. It is read-only. +The Triggers tab lists the table's triggers and shows the full definition of the selected one. It is read-only. Use the filter field to narrow the list, and click a column header to sort. | Property | Description | |----------|-------------| | **Name** | Trigger name | | **Timing** | BEFORE, AFTER, or INSTEAD OF | | **Event** | INSERT, UPDATE, DELETE (PostgreSQL can combine events, such as INSERT OR UPDATE) | +| **Enabled** | Whether the trigger is enabled (PostgreSQL, SQL Server, Oracle) | -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). +Select a trigger to see its full definition in a syntax-highlighted viewer with **Copy** and **Open in Editor** buttons. The definition is the `CREATE TRIGGER` statement, which includes the orientation (FOR EACH ROW/STATEMENT) and any WHEN condition. MySQL and MariaDB reconstruct it from the body; PostgreSQL uses `pg_get_triggerdef`; SQLite, libSQL, and Cloudflare D1 read the stored statement; SQL Server uses `OBJECT_DEFINITION`. -Available for MySQL, MariaDB, PostgreSQL, and SQLite. The tab is hidden for databases that do not expose triggers. +Available for MySQL, MariaDB, PostgreSQL, SQLite, SQL Server, Oracle, libSQL, and Cloudflare D1. The tab is hidden for databases that do not expose triggers. Oracle shows trigger metadata only; the body is not retrieved. ## Creating a New Table