diff --git a/TablePro/Core/Events/AppCommands.swift b/TablePro/Core/Events/AppCommands.swift index 2ab038d6f..995e7e291 100644 --- a/TablePro/Core/Events/AppCommands.swift +++ b/TablePro/Core/Events/AppCommands.swift @@ -10,14 +10,6 @@ import Foundation final class AppCommands { static let shared = AppCommands() - // MARK: - Row Commands - - let deleteSelectedRows = PassthroughSubject() - let addNewRow = PassthroughSubject() - let duplicateRow = PassthroughSubject() - let copySelectedRows = PassthroughSubject() - let pasteRows = PassthroughSubject() - // MARK: - Refresh let refreshData = PassthroughSubject() diff --git a/TablePro/Core/KeyboardHandling/PasteboardActionRouter.swift b/TablePro/Core/KeyboardHandling/PasteboardActionRouter.swift deleted file mode 100644 index 5891645eb..000000000 --- a/TablePro/Core/KeyboardHandling/PasteboardActionRouter.swift +++ /dev/null @@ -1,55 +0,0 @@ -// -// PasteboardActionRouter.swift -// TablePro -// -// Routes pasteboard commands (Copy/Paste) to the correct action based on -// the current first responder type and application state. -// - -import AppKit -import CodeEditTextView - -enum CopyAction { - case textCopy - case copyRows - case copyTableNames -} - -enum PasteAction { - case textPaste - case pasteRows -} - -enum PasteboardActionRouter { - static func resolveCopyAction( - firstResponder: NSResponder?, - hasRowSelection: Bool, - hasTableSelection: Bool - ) -> CopyAction { - if let responder = firstResponder, - responder is NSTextView || responder is TextView { - return .textCopy - } - if hasRowSelection { - return .copyRows - } - if hasTableSelection { - return .copyTableNames - } - return .textCopy - } - - static func resolvePasteAction( - firstResponder: NSResponder?, - isCurrentTabEditable: Bool - ) -> PasteAction { - if let responder = firstResponder, - responder is NSTextView || responder is TextView { - return .textPaste - } else if isCurrentTabEditable { - return .pasteRows - } else { - return .textPaste - } - } -} diff --git a/TablePro/Core/KeyboardHandling/ResponderChainActions.swift b/TablePro/Core/KeyboardHandling/ResponderChainActions.swift index 1941ef3d9..33305b05e 100644 --- a/TablePro/Core/KeyboardHandling/ResponderChainActions.swift +++ b/TablePro/Core/KeyboardHandling/ResponderChainActions.swift @@ -1,243 +1,7 @@ -// -// ResponderChainActions.swift -// TablePro -// -// Documentation protocol listing all responder chain actions used in TablePro. -// This is a reference guide, not implemented by any class directly. -// -// ## Architecture Pattern -// -// TablePro uses three mechanisms for keyboard shortcuts and commands: -// -// 1. **Responder Chain** (Apple Standard): -// - Standard edit actions: copy, paste, undo, delete, cancelOperation (ESC) -// - Context-aware: First responder handles action appropriately -// - Commands send via `NSApp.sendAction(#selector(...), to: nil, from: nil)` -// -// 2. **@FocusedValue** (Menu/Toolbar → single handler): -// - Most menu commands call `MainContentCommandActions` directly -// - Toolbar buttons also use `@FocusedValue` for direct calls -// - Clean method calls, no global event bus -// - Commands are automatically nil (disabled) when no connection is active -// -// 3. **AppCommands** (Multi-listener broadcasts only): -// - `refreshData` (Sidebar + Coordinator + StructureView) -// - Non-menu commands from AppKit views (DataGrid, SidebarView context menus) -// - Typed Combine publishers for broadcasts where multiple views respond -// -// ## Example Flow -// -// ``` -// User presses: Cmd+Delete -// ↓ -// SwiftUI Command: .keyboardShortcut(.delete, modifiers: .command) -// ↓ -// TableProApp: NSApp.sendAction(#selector(delete(_:)), to: nil, from: nil) -// ↓ -// Responder Chain: First Responder (KeyHandlingTableView) -// ↓ -// KeyHandlingTableView: @objc func delete(_ sender: Any?) { ... } -// ``` -// -// ## Reference Files -// - `TableProApp.swift` - SwiftUI Commands that define shortcuts -// - `KeyHandlingTableView.swift` - Data grid keyboard handling -// - `HistoryPanelView.swift` - SwiftUI history panel (uses onDeleteCommand) -// - `EditorTextView.swift` - SQL editor keyboard handling -// - import AppKit -/// Documentation protocol listing all responder chain actions in TablePro. -/// -/// **IMPORTANT**: This protocol is for documentation only. Do NOT implement it -/// on any classes. Instead, add individual `@objc` methods as needed. -/// -/// Responders should implement: -/// 1. The `@objc` action method (e.g., `@objc func delete(_ sender: Any?)`) -/// 2. Validation via `NSUserInterfaceValidations` or `NSMenuItemValidation` -/// @objc protocol TableProResponderActions { - // MARK: - Standard Edit Menu Actions - - /// Delete the selected items - /// - Standard AppKit selector for Delete/Backspace key - /// - Triggered by: Delete key, Cmd+Delete, or Edit > Delete menu - @objc optional func delete(_ sender: Any?) - - /// Copy selected content to clipboard - /// - Standard AppKit selector for Cmd+C - @objc optional func copy(_ sender: Any?) - - /// Paste clipboard content - /// - Standard AppKit selector for Cmd+V - @objc optional func paste(_ sender: Any?) - - /// Cut selected content to clipboard - /// - Standard AppKit selector for Cmd+X - @objc optional func cut(_ sender: Any?) - - /// Select all items - /// - Standard AppKit selector for Cmd+A - @objc optional func selectAll(_ sender: Any?) - - /// Undo last action - /// - Standard AppKit selector for Cmd+Z @objc optional func undo(_ sender: Any?) - - /// Redo last undone action - /// - Standard AppKit selector for Cmd+Shift+Z @objc optional func redo(_ sender: Any?) - - // MARK: - Standard Navigation Actions - - /// Move selection up - /// - Standard AppKit selector for Up Arrow - @objc optional func moveUp(_ sender: Any?) - - /// Move selection down - /// - Standard AppKit selector for Down Arrow - @objc optional func moveDown(_ sender: Any?) - - /// Move selection left - /// - Standard AppKit selector for Left Arrow - @objc optional func moveLeft(_ sender: Any?) - - /// Move selection right - /// - Standard AppKit selector for Right Arrow - @objc optional func moveRight(_ sender: Any?) - - /// Insert newline (Enter/Return key) - /// - Standard AppKit selector for Return key - @objc optional func insertNewline(_ sender: Any?) - - /// Cancel current operation (ESC key) - /// - Standard AppKit selector for Escape key - /// - Automatically called by `.onExitCommand` in SwiftUI - @objc optional func cancelOperation(_ sender: Any?) - - // MARK: - App-Specific Database Actions - - /// Add a new row to the current table - /// - Custom action for Cmd+N in data grid - @objc optional func addRow(_ sender: Any?) - - /// Duplicate the selected row - /// - Custom action for Cmd+D - @objc optional func duplicateRow(_ sender: Any?) - - /// Save pending changes to database - /// - Custom action for Cmd+S - @objc optional func saveChanges(_ sender: Any?) - - /// Refresh data from database - /// - Custom action for Cmd+R - @objc optional func refreshData(_ sender: Any?) - - /// Execute SQL query - /// - Custom action for Cmd+Enter in editor - @objc optional func executeQuery(_ sender: Any?) - - /// Clear current selection - /// - Custom action for Cmd+Esc - @objc optional func clearSelection(_ sender: Any?) - - // MARK: - View Actions - - /// Toggle table browser visibility - /// - Custom action for Cmd+B - @objc optional func toggleTableBrowser(_ sender: Any?) - - /// Toggle inspector panel - /// - Custom action for Cmd+I - @objc optional func toggleInspector(_ sender: Any?) - - /// Toggle filters panel - /// - Custom action for Cmd+F - @objc optional func toggleFilters(_ sender: Any?) - - /// Toggle query history panel - /// - Custom action for Cmd+H - @objc optional func toggleHistory(_ sender: Any?) + @objc optional func copyRowsAsTSV(_ sender: Any?) } - -// MARK: - Implementation Guide - -/* - - ## How to Implement Responder Chain Actions - - ### Step 1: Add @objc Method to Your Responder - - ```swift - final class MyTableView: NSTableView { - override var acceptsFirstResponder: Bool { true } - - @objc func delete(_ sender: Any?) { - // Your delete logic here - logger.debug("Deleting selected rows") - } - } - ``` - - ### Step 2: Add Validation (Optional but Recommended) - - ```swift - extension MyTableView: NSUserInterfaceValidations { - func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool { - switch item.action { - case #selector(delete(_:)): - // Enable Delete only when rows are selected - return !selectedRowIndexes.isEmpty - default: - return false - } - } - } - ``` - - ### Step 3: Register Command in TableProApp.swift - - ```swift - .commands { - CommandGroup(after: .newItem) { - Button("Delete Row") { - NSApp.sendAction(#selector(TableProResponderActions.delete(_:)), - to: nil, from: nil) - } - .keyboardShortcut(.delete, modifiers: .command) - } - } - ``` - - ### Step 4: Use interpretKeyEvents for Bare Keys (Optional) - - For non-modifier keys (arrows, Return, ESC), use `interpretKeyEvents`: - - ```swift - override func keyDown(with event: NSEvent) { - interpretKeyEvents([event]) - } - - @objc override func moveUp(_ sender: Any?) { - // Custom up arrow handling - } - ``` - - ## Benefits of Responder Chain - - ✅ **Automatic validation** - Menu items enable/disable based on context - ✅ **No manual routing** - macOS finds the right handler automatically - ✅ **Standard behavior** - Users expect Cmd+C/V/Z to work everywhere - ✅ **VoiceOver support** - Accessibility built-in - ✅ **Easy to extend** - Just add @objc methods, no global event bus - - ## Anti-Patterns to Avoid - - ❌ **NotificationCenter for commands** - Bypasses validation, hard to debug - ❌ **Magic keyCode numbers** - Use KeyCode enum instead - ❌ **performKeyEquivalent for bare keys** - Only for Cmd+ shortcuts - ❌ **Custom ESC systems** - Use cancelOperation(_:) selector - ❌ **Manual keyDown switches** - Use interpretKeyEvents + selectors - - */ diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index d4be35c93..d7f694557 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -5,7 +5,6 @@ // Created by Ngo Quoc Dat on 16/12/25. // -import CodeEditTextView import Combine import Observation import os @@ -37,26 +36,19 @@ struct PasteboardCommands: Commands { .optionalKeyboardShortcut(shortcut(for: .cut)) Button("Copy") { - let action = PasteboardActionRouter.resolveCopyAction( - firstResponder: NSApp.keyWindow?.firstResponder, - hasRowSelection: actions?.hasRowSelection ?? false, - hasTableSelection: actions?.hasTableSelection ?? false - ) - switch action { - case .textCopy: - NSApp.sendAction(#selector(NSText.copy(_:)), to: nil, from: nil) - case .copyRows: - if !NSApp.sendAction(#selector(NSText.copy(_:)), to: nil, from: nil) { - actions?.copySelectedRows() - } - case .copyTableNames: + if NSApp.sendAction(#selector(NSText.copy(_:)), to: nil, from: nil) { + return + } + if actions?.hasRowSelection == true { + actions?.copySelectedRows() + } else if actions?.hasTableSelection == true { actions?.copyTableNames() } } .optionalKeyboardShortcut(shortcut(for: .copy)) Button("Copy Rows") { - if !NSApp.sendAction(#selector(KeyHandlingTableView.copyRowsAsTSV(_:)), to: nil, from: nil) { + if !NSApp.sendAction(#selector(TableProResponderActions.copyRowsAsTSV(_:)), to: nil, from: nil) { actions?.copySelectedRows() } } @@ -76,14 +68,10 @@ struct PasteboardCommands: Commands { .disabled(!(actions?.hasRowSelection ?? false)) Button("Paste") { - let action = PasteboardActionRouter.resolvePasteAction( - firstResponder: NSApp.keyWindow?.firstResponder, - isCurrentTabEditable: actions?.isCurrentTabEditable ?? false - ) - switch action { - case .textPaste: - NSApp.sendAction(#selector(NSText.paste(_:)), to: nil, from: nil) - case .pasteRows: + if NSApp.sendAction(#selector(NSText.paste(_:)), to: nil, from: nil) { + return + } + if actions?.isCurrentTabEditable == true { actions?.pasteRows() } } diff --git a/TablePro/Views/Main/Child/DataTabGridDelegate.swift b/TablePro/Views/Main/Child/DataTabGridDelegate.swift index fc7908bab..bcd2d48c8 100644 --- a/TablePro/Views/Main/Child/DataTabGridDelegate.swift +++ b/TablePro/Views/Main/Child/DataTabGridDelegate.swift @@ -69,10 +69,6 @@ final class DataTabGridDelegate: DataGridViewDelegate { AppCommands.shared.exportQueryResults.send(()) } - func dataGridUndo() {} - - func dataGridRedo() {} - func dataGridNavigateFK(value: String, fkInfo: ForeignKeyInfo) { coordinator?.navigateToFKReference(value: value, fkInfo: fkInfo) } diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index c170d06d9..c49d97c8a 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -158,27 +158,8 @@ final class MainContentCommandActions { setupFileOpenObservers() } - /// Observers for notifications still posted by non-menu views (DataGrid, SidebarView, - /// context menus, QueryEditorView, ConnectionStatusView). These bridge AppKit/non-menu - /// notification posts to the same command action methods used by @FocusedValue callers. private func setupNonMenuNotificationObservers() { - observeKeyWindowOnly(AppCommands.shared.addNewRow) { [weak self] _ in self?.addNewRow() } - - observeKeyWindowOnly(AppCommands.shared.deleteSelectedRows) { [weak self] _ in - self?.deleteSelectedRows() - } - - observeKeyWindowOnly(AppCommands.shared.duplicateRow) { [weak self] _ in self?.duplicateRow() } - observeKeyWindowOnly(AppCommands.shared.exportQueryResults) { [weak self] _ in self?.exportQueryResults() } - - observeKeyWindowOnly(AppCommands.shared.copySelectedRows) { [weak self] _ in - self?.copySelectedRows() - } - - observeKeyWindowOnly(AppCommands.shared.pasteRows) { [weak self] _ in - self?.coordinator?.pasteRows() - } } // MARK: - Row Operations (Group A — Called Directly) diff --git a/TableProTests/Core/KeyboardHandling/PasteboardActionRouterTests.swift b/TableProTests/Core/KeyboardHandling/PasteboardActionRouterTests.swift deleted file mode 100644 index a7ae8a00f..000000000 --- a/TableProTests/Core/KeyboardHandling/PasteboardActionRouterTests.swift +++ /dev/null @@ -1,132 +0,0 @@ -// -// PasteboardActionRouterTests.swift -// TableProTests -// - -import AppKit -import CodeEditTextView -import TableProPluginKit -import Testing -@testable import TablePro - -@MainActor -@Suite("PasteboardActionRouter") -struct PasteboardActionRouterTests { - - // MARK: - Copy Action Tests - - @Test("NSTextView first responder returns textCopy") - func copyWithNsTextView() { - let textView = NSTextView() - let action = PasteboardActionRouter.resolveCopyAction( - firstResponder: textView, - hasRowSelection: true, - hasTableSelection: true - ) - #expect(action == .textCopy) - } - - @Test("CodeEditTextView.TextView first responder returns textCopy") - func copyWithCodeEditTextView() { - let textView = TextView(string: "") - let action = PasteboardActionRouter.resolveCopyAction( - firstResponder: textView, - hasRowSelection: true, - hasTableSelection: true - ) - #expect(action == .textCopy) - } - - @Test("No text responder with row selection returns copyRows") - func copyWithRowSelection() { - let action = PasteboardActionRouter.resolveCopyAction( - firstResponder: nil, - hasRowSelection: true, - hasTableSelection: false - ) - #expect(action == .copyRows) - } - - @Test("No text responder with table selection returns copyTableNames") - func copyWithTableSelection() { - let action = PasteboardActionRouter.resolveCopyAction( - firstResponder: nil, - hasRowSelection: false, - hasTableSelection: true - ) - #expect(action == .copyTableNames) - } - - @Test("No text responder and no selection returns textCopy fallback") - func copyFallback() { - let action = PasteboardActionRouter.resolveCopyAction( - firstResponder: nil, - hasRowSelection: false, - hasTableSelection: false - ) - #expect(action == .textCopy) - } - - // MARK: - Paste Action Tests - - @Test("NSTextView first responder returns textPaste") - func pasteWithNsTextView() { - let textView = NSTextView() - let action = PasteboardActionRouter.resolvePasteAction( - firstResponder: textView, - isCurrentTabEditable: true - ) - #expect(action == .textPaste) - } - - @Test("CodeEditTextView.TextView first responder returns textPaste") - func pasteWithCodeEditTextView() { - let textView = TextView(string: "") - let action = PasteboardActionRouter.resolvePasteAction( - firstResponder: textView, - isCurrentTabEditable: true - ) - #expect(action == .textPaste) - } - - @Test("No text responder with editable tab returns pasteRows") - func pasteWithEditableTab() { - let action = PasteboardActionRouter.resolvePasteAction( - firstResponder: nil, - isCurrentTabEditable: true - ) - #expect(action == .pasteRows) - } - - @Test("No text responder with non-editable tab returns textPaste fallback") - func pasteFallback() { - let action = PasteboardActionRouter.resolvePasteAction( - firstResponder: nil, - isCurrentTabEditable: false - ) - #expect(action == .textPaste) - } - - // MARK: - Edge Case Tests - - @Test("Non-text responder with row selection returns copyRows") - func copyWithNonTextResponder() { - let button = NSButton() - let action = PasteboardActionRouter.resolveCopyAction( - firstResponder: button, - hasRowSelection: true, - hasTableSelection: false - ) - #expect(action == .copyRows) - } - - @Test("Non-text responder with editable tab returns pasteRows") - func pasteWithNonTextResponder() { - let button = NSButton() - let action = PasteboardActionRouter.resolvePasteAction( - firstResponder: button, - isCurrentTabEditable: true - ) - #expect(action == .pasteRows) - } -}