From 38947f74874d2d351a5e124fbb76f44729ac282a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 6 Nov 2026 11:42:49 +0700 Subject: [PATCH 1/3] feat: add a keyboard shortcut for Export Results --- apple/TablePro/Core/Events/AppCommands.swift | 1 - .../Events/KeyWindowCommandSubscription.swift | 9 - .../TablePro/Core/Menu/FileMenuBuilder.swift | 4 +- .../MainWindowToolbar+Items.swift | 1 + .../ConnectionActionsMenuResolver.swift | 3 +- .../Models/UI/KeyboardShortcutModels.swift | 7 +- .../TablePro/Resources/Localizable.xcstrings | 6 + .../DataFileSplitViewController.swift | 6 - .../Views/Main/Child/DataTabGridAdapter.swift | 3 +- .../Main/MainContentCommandActions.swift | 17 +- .../Sidebar/Menu/SidebarMenuShortcut.swift | 8 +- .../KeyWindowCommandSubscriptionTests.swift | 30 -- .../Core/Menu/ChromeSnapshot.json | 373 ------------------ .../Core/Menu/MainMenuBuilderTests.swift | 24 ++ .../Models/ShortcutUniquenessTests.swift | 15 +- .../MainWindowToolbarShortcutHintTests.swift | 14 +- .../Main/CommandActionsDispatchTests.swift | 39 ++ .../Sidebar/DatabaseTreeMenuSpecTests.swift | 7 + apple/TableProUITests/ExportUITests.swift | 45 +++ .../TableProUITests/Support/AppShortcut.swift | 2 + .../app/src/application/accelerators.rs | 2 + .../app/src/ui/browse_tab/browse_actions.rs | 2 +- linux/crates/app/src/ui/browse_tab/layout.rs | 22 +- linux/crates/app/src/ui/browse_tab/mod.rs | 2 +- .../crates/app/src/ui/browse_tab/tests/mod.rs | 1 + .../crates/app/src/ui/data_grid/grid_menu.rs | 7 +- .../app/src/ui/data_grid/tests/menus.rs | 5 + linux/crates/app/src/ui/editor/mod.rs | 13 + linux/crates/app/src/ui/editor/tests.rs | 45 +++ linux/crates/app/src/ui/key_tab/mod.rs | 9 + linux/crates/app/src/ui/key_tab/tests.rs | 28 ++ linux/crates/app/src/ui/workspace/mod.rs | 4 +- .../app/src/ui/workspace/tests/shell.rs | 96 ++++- .../app/src/ui/workspace/workspace_actions.rs | 15 +- .../app/src/ui/workspace/workspace_tabs.rs | 37 +- linux/data/resources/shortcuts-dialog.ui | 6 + linux/data/resources/ui/sql-editor.ui | 4 + linux/po/vi.po | 8 + 38 files changed, 464 insertions(+), 456 deletions(-) delete mode 100644 apple/TableProTests/Core/Menu/ChromeSnapshot.json diff --git a/apple/TablePro/Core/Events/AppCommands.swift b/apple/TablePro/Core/Events/AppCommands.swift index 58d2665709..e7fc785a16 100644 --- a/apple/TablePro/Core/Events/AppCommands.swift +++ b/apple/TablePro/Core/Events/AppCommands.swift @@ -102,7 +102,6 @@ final class AppCommands { // MARK: - File / Connection Import-Export let openSQLFiles = PassthroughSubject<[URL], Never>() - let exportQueryResults = PassthroughSubject() private init() {} } diff --git a/apple/TablePro/Core/Events/KeyWindowCommandSubscription.swift b/apple/TablePro/Core/Events/KeyWindowCommandSubscription.swift index 6e3bee4921..5b8fc95ab0 100644 --- a/apple/TablePro/Core/Events/KeyWindowCommandSubscription.swift +++ b/apple/TablePro/Core/Events/KeyWindowCommandSubscription.swift @@ -3,7 +3,6 @@ // TablePro // -import AppKit import Combine import Foundation @@ -21,12 +20,4 @@ internal enum KeyWindowCommandSubscription { handler(payload) } } - - internal static func sink( - _ publisher: PassthroughSubject, - whileKey window: @escaping @MainActor () -> NSWindow?, - perform handler: @escaping @MainActor (Payload) -> Void - ) -> AnyCancellable { - sink(publisher, when: { window()?.isKeyWindow == true }, perform: handler) - } } diff --git a/apple/TablePro/Core/Menu/FileMenuBuilder.swift b/apple/TablePro/Core/Menu/FileMenuBuilder.swift index 0c01e01829..888331b772 100644 --- a/apple/TablePro/Core/Menu/FileMenuBuilder.swift +++ b/apple/TablePro/Core/Menu/FileMenuBuilder.swift @@ -272,7 +272,9 @@ enum FileMenuBuilder { ), MenuItemFactory.item( String(localized: "Export Results…"), - action: #selector(MainSplitViewController.exportQueryResults(_:)) + action: #selector(MainSplitViewController.exportQueryResults(_:)), + shortcut: .exportResults, + keyboard: keyboard ), MenuItemFactory.item( String(localized: "Export Data Dictionary…"), diff --git a/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Items.swift b/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Items.swift index 30e8fe2a80..4b2be68d61 100644 --- a/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Items.swift +++ b/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Items.swift @@ -273,6 +273,7 @@ extension MainWindowToolbar { label: String(localized: "Restore Previous Values"), symbol: "clock.arrow.circlepath", action: #selector(performRestorePreviousValues(_:)), + shortcut: .restorePreviousValues, description: String(localized: "Restore the previous values of a save") ) } diff --git a/apple/TablePro/Core/Services/Infrastructure/Toolbar/ConnectionActionsMenuResolver.swift b/apple/TablePro/Core/Services/Infrastructure/Toolbar/ConnectionActionsMenuResolver.swift index 4d62d841bf..0b8820ed47 100644 --- a/apple/TablePro/Core/Services/Infrastructure/Toolbar/ConnectionActionsMenuResolver.swift +++ b/apple/TablePro/Core/Services/Infrastructure/Toolbar/ConnectionActionsMenuResolver.swift @@ -150,7 +150,8 @@ internal enum ConnectionActionsMenuResolver { entries.append( ActionsMenuEntry( title: String(localized: "Export Results…"), - selector: NSSelectorFromString("exportQueryResults:") + selector: NSSelectorFromString("exportQueryResults:"), + shortcut: .exportResults ) ) } diff --git a/apple/TablePro/Models/UI/KeyboardShortcutModels.swift b/apple/TablePro/Models/UI/KeyboardShortcutModels.swift index 675283e65d..81f7d56b94 100644 --- a/apple/TablePro/Models/UI/KeyboardShortcutModels.swift +++ b/apple/TablePro/Models/UI/KeyboardShortcutModels.swift @@ -136,6 +136,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { case lastPage case refresh case export + case exportResults case importData case jumpToColumn @@ -211,7 +212,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { .setValueEmpty, .setValueNull, .setValueDefault, .generateUUIDv4, .generateUUIDv7, .setCellsToValue, .restorePreviousValues, .truncateTable, .toggleHeaderRow, .previewFKReference, .saveAsFavorite, .previousPage, - .nextPage, .firstPage, .lastPage, .refresh, .export, .importData, .jumpToColumn: + .nextPage, .firstPage, .lastPage, .refresh, .export, .exportResults, .importData, .jumpToColumn: return .dataGrid case .navigateBack, .navigateForward, .newTab, .closeTab, .closeOtherTabs, .closeTabsForOtherDatabases, .closeAllTabs, @@ -332,7 +333,8 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { case .findNext: return String(localized: "Find Next") case .findPrevious: return String(localized: "Find Previous") case .useSelectionForFind: return String(localized: "Use Selection for Find") - case .export: return String(localized: "Export") + case .export: return String(localized: "Export Tables") + case .exportResults: return String(localized: "Export Results") case .importData: return String(localized: "Import") case .jumpToColumn: return String(localized: "Jump to Column") case .quickSwitcher: return String(localized: "Open Quickly") @@ -696,6 +698,7 @@ struct KeyboardSettings: Codable, Equatable { .aiExplainQuery: .character("l", command: true), .aiOptimizeQuery: .character("l", command: true, option: true), .export: .character("e", command: true, shift: true), + .exportResults: .character("e", command: true, shift: true, option: true), .importData: .character("i", command: true, shift: true), // Data Grid diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index 86cc154a9d..f693169ae1 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -78280,6 +78280,9 @@ } } } + }, + "Export Results" : { + }, "Export Results…" : { "localizations" : { @@ -78360,6 +78363,9 @@ } } } + }, + "Export Tables" : { + }, "Export Tables…" : { "localizations" : { diff --git a/apple/TablePro/Views/DataFiles/DataFileSplitViewController.swift b/apple/TablePro/Views/DataFiles/DataFileSplitViewController.swift index 5938f6871e..108a5f6bc3 100644 --- a/apple/TablePro/Views/DataFiles/DataFileSplitViewController.swift +++ b/apple/TablePro/Views/DataFiles/DataFileSplitViewController.swift @@ -45,12 +45,6 @@ final class DataFileSplitViewController: NSSplitViewController { addSplitViewItem(contentItem) addSplitViewItem(inspectorItem) - KeyWindowCommandSubscription - .sink(AppCommands.shared.exportQueryResults, whileKey: { [weak self] in self?.view.window }) { [weak self] _ in - self?.presentExport() - } - .store(in: &cancellables) - controller.$isInspectorVisible .removeDuplicates() .sink { [weak self] visible in diff --git a/apple/TablePro/Views/Main/Child/DataTabGridAdapter.swift b/apple/TablePro/Views/Main/Child/DataTabGridAdapter.swift index e6da4abdb8..a53466d541 100644 --- a/apple/TablePro/Views/Main/Child/DataTabGridAdapter.swift +++ b/apple/TablePro/Views/Main/Child/DataTabGridAdapter.swift @@ -180,7 +180,8 @@ final class DataTabGridAdapter: DataGridDataSource, DataGridActions { } func gridExportResults() { - AppCommands.shared.exportQueryResults.send(()) + guard ownsWindowState else { return } + coordinator?.commandActions?.exportQueryResults() } func gridClearResults() { diff --git a/apple/TablePro/Views/Main/MainContentCommandActions.swift b/apple/TablePro/Views/Main/MainContentCommandActions.swift index cbd24a5d90..ee0a6abd59 100644 --- a/apple/TablePro/Views/Main/MainContentCommandActions.swift +++ b/apple/TablePro/Views/Main/MainContentCommandActions.swift @@ -123,16 +123,6 @@ final class MainContentCommandActions: ObservableObject { return host.workspaces.selected?.sessionState?.coordinator === coordinator } - private func observeKeyWindowOnly( - _ name: Notification.Name, - handler: @escaping @MainActor (Notification) -> Void - ) { - observe(name) { [weak self] notification in - guard self?.isVisibleInKeyWindow() == true else { return } - handler(notification) - } - } - private func observeKeyWindowOnly( _ publisher: PassthroughSubject, handler: @escaping @MainActor (Payload) -> Void @@ -170,17 +160,12 @@ final class MainContentCommandActions: ObservableObject { // MARK: - Observer Setup private func setupObservers() { - setupNonMenuNotificationObservers() setupDataBroadcastObservers() setupDatabaseBroadcastObservers() setupWindowObservers() setupFileOpenObservers() } - private func setupNonMenuNotificationObservers() { - observeKeyWindowOnly(AppCommands.shared.exportQueryResults) { [weak self] _ in self?.exportQueryResults() } - } - // MARK: - Row Operations (Group A: Called Directly) func addNewRow() { @@ -473,7 +458,7 @@ final class MainContentCommandActions: ObservableObject { } } - // Save As and Export Results gate on this, not isConnected, which left them lit over a handler that did nothing. + // Save As gates on this, not isConnected, which left it lit over a handler that did nothing. var isQueryTab: Bool { coordinator?.tabManager.selectedTab?.tabType == .query } diff --git a/apple/TablePro/Views/Sidebar/Menu/SidebarMenuShortcut.swift b/apple/TablePro/Views/Sidebar/Menu/SidebarMenuShortcut.swift index 2c994246a7..31a8fa0f7e 100644 --- a/apple/TablePro/Views/Sidebar/Menu/SidebarMenuShortcut.swift +++ b/apple/TablePro/Views/Sidebar/Menu/SidebarMenuShortcut.swift @@ -22,9 +22,11 @@ internal protocol SidebarMenuShortcutProviding { extension SidebarMenuCommand: SidebarMenuShortcutProviding { /// Only the commands whose menu-bar twin actually carries a `ShortcutAction` today: Database > - /// Refresh, Database > Truncate Table, File > Import Data and File > Export. Drop is left out + /// Refresh, Database > Truncate Table, File > Import Data and File > Export Tables. Drop is left out /// deliberately, because the menu bar's Delete is the data grid's row delete and pointing the - /// sidebar's object drop at it would show a shortcut that deletes something else. + /// sidebar's object drop at it would show a shortcut that deletes something else. A database or + /// schema's Export… is left out for the same reason: Export Tables… exports the selected tables, + /// so its key would open the dialog with nothing in that container chosen. internal var shortcutAction: ShortcutAction? { switch self { case .refreshContainers, .refreshObjectKind, .refreshContainerObjectKind, .refreshHierarchicalSchema: @@ -33,7 +35,7 @@ extension SidebarMenuCommand: SidebarMenuShortcutProviding { return .truncateTable case .importTables: return .importData - case .exportTables, .exportContainers: + case .exportTables: return .export default: return nil diff --git a/apple/TableProTests/Core/Events/KeyWindowCommandSubscriptionTests.swift b/apple/TableProTests/Core/Events/KeyWindowCommandSubscriptionTests.swift index f58a12089a..911d698629 100644 --- a/apple/TableProTests/Core/Events/KeyWindowCommandSubscriptionTests.swift +++ b/apple/TableProTests/Core/Events/KeyWindowCommandSubscriptionTests.swift @@ -1,4 +1,3 @@ -import AppKit import Combine import Foundation @testable import TablePro @@ -33,33 +32,4 @@ struct KeyWindowCommandSubscriptionTests { backgroundSubscription.cancel() frontmostSubscription.cancel() } - - @Test("A window that is not key does not run the command") - func windowThatIsNotKeyDoesNotRun() async throws { - let subject = PassthroughSubject() - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 10, height: 10), - styleMask: [.titled], - backing: .buffered, - defer: true - ) - window.isReleasedWhenClosed = false - let received = Received() - let subscription = KeyWindowCommandSubscription.sink(subject, whileKey: { window }) { _ in - received.payloads.append(1) - } - // Subscribed after, so its delivery on the main run loop comes after the window's. - let control = Received() - let controlSubscription = KeyWindowCommandSubscription.sink(subject, when: { true }) { _ in - control.payloads.append(1) - } - - subject.send(()) - try await waitUntil { !control.payloads.isEmpty } - - #expect(!window.isKeyWindow) - #expect(received.payloads.isEmpty) - subscription.cancel() - controlSubscription.cancel() - } } diff --git a/apple/TableProTests/Core/Menu/ChromeSnapshot.json b/apple/TableProTests/Core/Menu/ChromeSnapshot.json deleted file mode 100644 index 697dee9ff0..0000000000 --- a/apple/TableProTests/Core/Menu/ChromeSnapshot.json +++ /dev/null @@ -1,373 +0,0 @@ -{ - "menuBar": [ - {"path": "TablePro"}, - {"path": "TablePro > About TablePro", "action": "showAboutPanel:"}, - {"path": "TablePro > Check for Updates…", "action": "checkForUpdates:"}, - {"path": "TablePro", "separator": true}, - {"path": "TablePro > MCP Activity…", "action": "openMCPActivity:"}, - {"path": "TablePro", "separator": true}, - {"path": "TablePro > Settings…", "action": "openSettings:", "key": ",", "modifiers": ["command"]}, - {"path": "TablePro", "separator": true}, - {"path": "TablePro > Services"}, - {"path": "TablePro", "separator": true}, - {"path": "TablePro > Hide TablePro", "action": "hide:", "key": "h", "modifiers": ["command"]}, - {"path": "TablePro > Hide Others", "action": "hideOtherApplications:", "key": "h", "modifiers": ["option", "command"]}, - {"path": "TablePro > Show All", "action": "unhideAllApplications:"}, - {"path": "TablePro", "separator": true}, - {"path": "TablePro > Quit TablePro", "action": "terminate:", "key": "q", "modifiers": ["command"]}, - {"path": "File"}, - {"path": "File > New Connection…", "identifier": "shortcut.newConnection", "action": "newConnection:", "key": "n", "modifiers": ["command"]}, - {"path": "File > New Group…", "action": "newConnectionGroup:"}, - {"path": "File > New Tab", "identifier": "shortcut.newTab", "action": "newEditorTab:", "key": "t", "modifiers": ["command"]}, - {"path": "File > Session"}, - {"path": "File > Session > New Session", "identifier": "shortcut.newAgentSession", "action": "newAgentSession:"}, - {"path": "File > Session > Open Session", "identifier": "shortcut.openAgentSession", "action": "openAgentSession:"}, - {"path": "File > Session > Recent Sessions"}, - {"path": "File > Session > Close Session", "identifier": "shortcut.closeAgentSession", "action": "closeAgentSession:"}, - {"path": "File > Session > Delete Session…", "identifier": "shortcut.deleteAgentSession", "action": "deleteAgentSession:"}, - {"path": "File > Session", "separator": true}, - {"path": "File > Session > New Conversation", "identifier": "shortcut.newAIConversation", "action": "newAIConversation:"}, - {"path": "File > Session > Conversation History"}, - {"path": "File > Session > Clear Recents…", "action": "clearAIConversations:"}, - {"path": "File > Manage Connections", "identifier": "shortcut.manageConnections", "action": "manageConnections:"}, - {"path": "File", "separator": true}, - {"path": "File > Open File…", "identifier": "shortcut.openFile", "action": "openFile:", "key": "o", "modifiers": ["command"]}, - {"path": "File > Open Quickly…", "identifier": "shortcut.quickSwitcher", "action": "openQuickSwitcher:", "key": "o", "modifiers": ["shift", "command"]}, - {"path": "File > Rename", "action": "renameConnectionListSelection:"}, - {"path": "File", "separator": true}, - {"path": "File > Save", "identifier": "shortcut.saveChanges", "action": "saveDocument:", "key": "s", "modifiers": ["command"]}, - {"path": "File > Save As…", "identifier": "shortcut.saveAs", "action": "saveDocumentAs:", "key": "s", "modifiers": ["shift", "command"]}, - {"path": "File", "separator": true}, - {"path": "File > Close Window", "identifier": "shortcut.closeTab", "action": "performClose:", "key": "w", "modifiers": ["command"]}, - {"path": "File > Close Other Tabs", "identifier": "shortcut.closeOtherTabs", "action": "closeOtherTabs:"}, - {"path": "File > Close Tabs for Other Databases", "identifier": "shortcut.closeTabsForOtherDatabases", "action": "closeTabsForOtherContainers:"}, - {"path": "File > Close All Tabs", "identifier": "shortcut.closeAllTabs", "action": "closeAllTabs:"}, - {"path": "File > Close Connection", "action": "closeConnection:"}, - {"path": "File > Reopen Closed Tab", "identifier": "shortcut.reopenClosedTab", "action": "reopenClosedTab:", "key": "t", "modifiers": ["shift", "command"]}, - {"path": "File", "separator": true}, - {"path": "File > Import"}, - {"path": "File > Import > Import Data…", "identifier": "shortcut.importData", "action": "importData:", "key": "i", "modifiers": ["shift", "command"]}, - {"path": "File > Import > Import Data From"}, - {"path": "File > Import", "separator": true}, - {"path": "File > Import > Import Connections…", "action": "importConnections:"}, - {"path": "File > Import > Import from URL…", "action": "importFromURL:"}, - {"path": "File > Import > Import from Other App…", "action": "importFromOtherApp:"}, - {"path": "File > Import > Import from AWS…", "action": "importFromAWS:"}, - {"path": "File > Import", "separator": true}, - {"path": "File > Import > Open Project Folder…", "action": "openProjectFolder:"}, - {"path": "File > Export"}, - {"path": "File > Export > Export Tables…", "identifier": "shortcut.export", "action": "exportTables:", "key": "e", "modifiers": ["shift", "command"]}, - {"path": "File > Export > Export Results…", "action": "exportQueryResults:"}, - {"path": "File > Export > Export Data Dictionary…", "action": "exportDataDictionary:"}, - {"path": "File > Export", "separator": true}, - {"path": "File > Export > Export Connections…", "action": "exportConnections:"}, - {"path": "File", "separator": true}, - {"path": "File > Backup Dump…", "action": "backupDatabase:"}, - {"path": "File > Restore Dump…", "action": "restoreDatabase:"}, - {"path": "Edit"}, - {"path": "Edit > Undo", "identifier": "shortcut.undo", "action": "undo:", "key": "z", "modifiers": ["command"]}, - {"path": "Edit > Redo", "identifier": "shortcut.redo", "action": "redo:", "key": "z", "modifiers": ["shift", "command"]}, - {"path": "Edit", "separator": true}, - {"path": "Edit > Cut", "identifier": "shortcut.cut", "action": "cut:", "key": "x", "modifiers": ["command"]}, - {"path": "Edit > Copy", "identifier": "shortcut.copy", "action": "copy:", "key": "c", "modifiers": ["command"]}, - {"path": "Edit > Copy Special"}, - {"path": "Edit > Copy Special > Copy Rows", "identifier": "shortcut.copyRowsExplicit", "action": "copySelectedRows:", "key": "c", "modifiers": ["shift", "command"]}, - {"path": "Edit > Copy Special > Copy with Headers", "identifier": "shortcut.copyWithHeaders", "action": "copyRowsWithHeaders:", "key": "c", "modifiers": ["option", "command"]}, - {"path": "Edit > Copy Special > Copy as JSON", "identifier": "shortcut.copyAsJson", "action": "copyRowsAsJson:", "key": "j", "modifiers": ["option", "command"]}, - {"path": "Edit > Paste", "identifier": "shortcut.paste", "action": "paste:", "key": "v", "modifiers": ["command"]}, - {"path": "Edit > Delete", "identifier": "shortcut.delete", "action": "delete:", "key": "backspace", "modifiers": ["command"]}, - {"path": "Edit", "separator": true}, - {"path": "Edit > Select All", "identifier": "shortcut.selectAll", "action": "selectAll:", "key": "a", "modifiers": ["command"]}, - {"path": "Edit > Clear Selection", "identifier": "shortcut.clearSelection", "action": "clearSelection:"}, - {"path": "Edit", "separator": true}, - {"path": "Edit > Find"}, - {"path": "Edit > Find > Find…", "identifier": "shortcut.find", "action": "performFind:", "key": "f", "modifiers": ["command"]}, - {"path": "Edit > Find > Find and Replace…", "identifier": "shortcut.findAndReplace", "action": "performFindAndReplace:", "key": "f", "modifiers": ["option", "command"]}, - {"path": "Edit > Find > Find Next", "identifier": "shortcut.findNext", "action": "findNext:", "key": "g", "modifiers": ["command"]}, - {"path": "Edit > Find > Find Previous", "identifier": "shortcut.findPrevious", "action": "findPrevious:", "key": "g", "modifiers": ["shift", "command"]}, - {"path": "Edit > Find > Use Selection for Find", "identifier": "shortcut.useSelectionForFind", "action": "useSelectionForFind:", "key": "e", "modifiers": ["command"]}, - {"path": "Edit > Find", "separator": true}, - {"path": "Edit > Find > Jump to Column…", "identifier": "shortcut.jumpToColumn", "action": "jumpToColumn:", "key": "j", "modifiers": ["shift", "command"]}, - {"path": "Edit", "separator": true}, - {"path": "Edit > Add Row", "identifier": "shortcut.addRow", "action": "addRow:", "key": "n", "modifiers": ["shift", "command"]}, - {"path": "Edit > Duplicate Row", "identifier": "shortcut.duplicateRow", "action": "duplicateRow:", "key": "d", "modifiers": ["shift", "command"]}, - {"path": "Edit > Edit Document…", "action": "editDocument:"}, - {"path": "Edit > Insert Document…", "action": "insertDocument:"}, - {"path": "Edit", "separator": true}, - {"path": "Edit > Restore Previous Values…", "identifier": "shortcut.restorePreviousValues", "action": "restorePreviousValues:"}, - {"path": "Edit", "separator": true}, - {"path": "Edit > Data", "identifier": "edit.data", "hidden": true}, - {"path": "Edit > Data > Insert Row Above", "action": "dataFileInsertRowAbove:", "hidden": true}, - {"path": "Edit > Data > Insert Row Below", "action": "dataFileInsertRowBelow:", "hidden": true}, - {"path": "Edit > Data > Delete Rows", "action": "dataFileDeleteSelectedRows:", "hidden": true}, - {"path": "Edit > Data", "separator": true}, - {"path": "Edit > Data > Add Column…", "action": "dataFileAddColumn:", "hidden": true}, - {"path": "Edit > Data > Insert Column Left", "action": "dataFileInsertColumnLeft:", "hidden": true}, - {"path": "Edit > Data > Insert Column Right", "action": "dataFileInsertColumnRight:", "hidden": true}, - {"path": "Edit > Data > Rename Column…", "action": "dataFileRenameColumn:", "hidden": true}, - {"path": "Edit > Data > Split Column…", "action": "dataFileSplitColumn:", "hidden": true}, - {"path": "Edit > Data > Merge Columns…", "action": "dataFileMergeColumns:", "hidden": true}, - {"path": "Edit > Data > Delete Column", "action": "dataFileDeleteColumn:", "hidden": true}, - {"path": "Edit > Data", "separator": true}, - {"path": "Edit > Data > Fill Down", "action": "dataFileFillDown:", "hidden": true}, - {"path": "Edit > Data > Set Cells to Value…", "action": "dataFileSetCellsToValue:", "hidden": true}, - {"path": "Edit > Data > Trim Whitespace", "action": "dataFileTrimWhitespace:", "hidden": true}, - {"path": "Edit > Data > Change Case", "hidden": true}, - {"path": "Edit > Data > Change Case > UPPERCASE", "action": "dataFileChangeCase:", "hidden": true}, - {"path": "Edit > Data > Change Case > lowercase", "action": "dataFileChangeCase:", "hidden": true}, - {"path": "Edit > Data > Change Case > Title Case", "action": "dataFileChangeCase:", "hidden": true}, - {"path": "Edit > Data > Replace in Column…", "action": "dataFileReplaceInColumn:", "hidden": true}, - {"path": "Edit > Data > Remove Duplicate Rows…", "action": "dataFileRemoveDuplicates:", "hidden": true}, - {"path": "Edit > Data", "separator": true}, - {"path": "Edit > Data > Column Statistics…", "action": "dataFileShowStatistics:", "hidden": true}, - {"path": "Edit > Data > Search All Columns", "action": "dataFileSearchAllColumns:", "hidden": true}, - {"path": "Edit > Data", "separator": true}, - {"path": "Edit > Data > Switch First Row Between Header and Data", "identifier": "shortcut.toggleHeaderRow", "action": "dataFileToggleHeaderRow:", "key": "h", "modifiers": ["shift", "command"], "hidden": true}, - {"path": "Edit > Data > File Properties…", "action": "dataFileShowProperties:", "hidden": true}, - {"path": "Edit > Data > Import into Table…", "action": "dataFileImportIntoTable:", "hidden": true}, - {"path": "View"}, - {"path": "View > Show Sidebar", "identifier": "shortcut.toggleTableBrowser", "action": "toggleSidebar:", "key": "0", "modifiers": ["command"]}, - {"path": "View > Show Inspector", "identifier": "shortcut.toggleInspector", "action": "toggleInspector:", "key": "i", "modifiers": ["option", "command"]}, - {"path": "View > Show Assistant", "identifier": "shortcut.toggleAssistant", "action": "toggleAssistant:", "key": "a", "modifiers": ["option", "command"]}, - {"path": "View > Show Connections", "identifier": "shortcut.toggleWorkspaceRail", "action": "toggleWorkspaceRail:", "key": "0", "modifiers": ["option", "command"]}, - {"path": "View > Mode"}, - {"path": "View > Mode > Browse", "action": "setContentModeFromMenu:"}, - {"path": "View > Mode > Agent", "action": "setContentModeFromMenu:"}, - {"path": "View > Mode", "separator": true}, - {"path": "View > Mode > Toggle Agent Mode", "identifier": "shortcut.toggleAgentMode", "action": "toggleContentModeFromMenu:", "key": "a", "modifiers": ["option", "shift", "command"]}, - {"path": "View", "separator": true}, - {"path": "View > Show Tables", "identifier": "shortcut.showTablesList", "action": "showTablesSidebarTab:"}, - {"path": "View > Show Favorites", "identifier": "shortcut.showFavoritesList", "action": "showFavoritesSidebarTab:"}, - {"path": "View > Sort Connections By"}, - {"path": "View > Sort Connections By > Manual", "action": "sortConnectionList:"}, - {"path": "View > Sort Connections By > Name", "action": "sortConnectionList:"}, - {"path": "View > Sort Connections By > Database Type", "action": "sortConnectionList:"}, - {"path": "View > Sort Connections By > Last Connected", "action": "sortConnectionList:"}, - {"path": "View", "separator": true}, - {"path": "View > Sidebar Layout"}, - {"path": "View > Sidebar Layout > Sidebar as List", "action": "useFlatSidebarLayout:"}, - {"path": "View > Sidebar Layout > Sidebar as Tree", "action": "useTreeSidebarLayout:"}, - {"path": "View > Focus"}, - {"path": "View > Focus > Focus Sidebar Filter", "identifier": "shortcut.focusSidebarSearch", "action": "focusSidebarFilter:", "key": "f", "modifiers": ["control", "option", "command"]}, - {"path": "View > Focus > Focus Object List", "identifier": "shortcut.focusObjectList", "action": "focusObjectList:", "key": "l", "modifiers": ["control", "option", "command"]}, - {"path": "View > Focus > Focus Editor", "identifier": "shortcut.focusEditor", "action": "focusEditor:", "key": "e", "modifiers": ["control", "option", "command"]}, - {"path": "View > Focus > Focus Results", "identifier": "shortcut.focusResults", "action": "focusResults:", "key": "r", "modifiers": ["control", "option", "command"]}, - {"path": "View > Focus > Focus Inspector", "identifier": "shortcut.focusInspector", "action": "focusInspector:", "key": "i", "modifiers": ["control", "option", "command"]}, - {"path": "View > Focus > Focus Assistant", "identifier": "shortcut.focusAssistant", "action": "focusAssistant:", "key": "a", "modifiers": ["control", "option", "command"]}, - {"path": "View > Filter Databases…", "action": "filterDatabases:"}, - {"path": "View > Show All Databases", "action": "showAllDatabases:"}, - {"path": "View > Filter Schemas…", "action": "filterSchemas:"}, - {"path": "View > Show All Schemas", "action": "showAllSchemas:"}, - {"path": "View", "separator": true}, - {"path": "View > Show Filter Bar", "identifier": "shortcut.toggleFilters", "action": "toggleFilterBar:", "key": "f", "modifiers": ["shift", "command"]}, - {"path": "View > Highlight Rules…", "action": "showHighlightRules:"}, - {"path": "View > Show Query History", "identifier": "shortcut.toggleHistory", "action": "toggleQueryHistory:", "key": "y", "modifiers": ["command"]}, - {"path": "View > Result View"}, - {"path": "View > Result View > Data", "action": "setResultView:"}, - {"path": "View > Result View > Structure", "action": "setResultView:"}, - {"path": "View > Result View > JSON", "action": "setResultView:"}, - {"path": "View > Result View > Chart", "action": "setResultView:"}, - {"path": "View > Result View > Map", "action": "setResultView:"}, - {"path": "View > Result View > Output", "action": "setResultView:"}, - {"path": "View > Show Results", "identifier": "shortcut.toggleResults", "action": "toggleResults:", "key": "r", "modifiers": ["option", "command"]}, - {"path": "View", "separator": true}, - {"path": "View > Previous Result", "identifier": "shortcut.previousResultTab", "action": "showPreviousResult:", "key": "[", "modifiers": ["option", "command"]}, - {"path": "View > Next Result", "identifier": "shortcut.nextResultTab", "action": "showNextResult:", "key": "]", "modifiers": ["option", "command"]}, - {"path": "View > Pin Result", "identifier": "shortcut.pinResultTab", "action": "pinResult:", "key": "p", "modifiers": ["option", "command"]}, - {"path": "View > Close Result Tab", "identifier": "shortcut.closeResultTab", "action": "closeResultTab:", "key": "w", "modifiers": ["shift", "command"]}, - {"path": "View", "separator": true}, - {"path": "View > Back", "identifier": "shortcut.navigateBack", "action": "navigateBack:", "key": "[", "modifiers": ["control", "command"]}, - {"path": "View > Forward", "identifier": "shortcut.navigateForward", "action": "navigateForward:", "key": "]", "modifiers": ["control", "command"]}, - {"path": "View", "separator": true}, - {"path": "View > Show Previous Connection", "identifier": "shortcut.showPreviousWorkspace", "action": "showPreviousWorkspace:", "key": "up", "modifiers": ["control", "command"]}, - {"path": "View > Show Next Connection", "identifier": "shortcut.showNextWorkspace", "action": "showNextWorkspace:", "key": "down", "modifiers": ["control", "command"]}, - {"path": "View", "separator": true}, - {"path": "View > Zoom In", "action": "zoomIn:", "key": "=", "modifiers": ["command"]}, - {"path": "View > Zoom Out", "action": "zoomOut:", "key": "-", "modifiers": ["command"]}, - {"path": "View", "separator": true}, - {"path": "View > Show Toolbar", "action": "toggleToolbarShown:", "key": "t", "modifiers": ["option", "command"]}, - {"path": "View > Customize Toolbar…", "action": "runToolbarCustomizationPalette:"}, - {"path": "View", "separator": true}, - {"path": "View > Enter Full Screen", "action": "toggleFullScreen:", "key": "f", "modifiers": ["control", "command"]}, - {"path": "Database"}, - {"path": "Database > Switch Connection…", "identifier": "shortcut.switchConnection", "action": "switchConnection:", "key": "c", "modifiers": ["control", "command"]}, - {"path": "Database > Move Tab to Connection…", "identifier": "shortcut.moveTabToConnection", "action": "moveTabToConnection:", "key": "c", "modifiers": ["control", "option", "command"]}, - {"path": "Database > Open Database…", "identifier": "shortcut.openDatabase", "action": "openContainerSwitcher:", "key": "k", "modifiers": ["command"]}, - {"path": "Database", "separator": true}, - {"path": "Database > Refresh", "identifier": "shortcut.refresh", "action": "refreshDatabase:", "key": "r", "modifiers": ["command"]}, - {"path": "Database", "separator": true}, - {"path": "Database > New Database…", "action": "createNewDatabase:"}, - {"path": "Database > New Table…", "action": "createNewTable:"}, - {"path": "Database > New View…", "action": "createNewView:"}, - {"path": "Database > New Folder", "action": "createTableFolder:"}, - {"path": "Database", "separator": true}, - {"path": "Database > Search Database…", "action": "searchDatabase:"}, - {"path": "Database > Copy To…", "action": "copyObjectsToDatabase:"}, - {"path": "Database > Duplicate Database…", "action": "duplicateCurrentDatabase:"}, - {"path": "Database", "separator": true}, - {"path": "Database > Show Table Structure", "action": "showTableStructure:"}, - {"path": "Database > Edit View Definition…", "action": "editViewDefinition:"}, - {"path": "Database > Show DDL", "action": "showObjectDDL:"}, - {"path": "Database > Copy DDL", "action": "copyObjectDDL:"}, - {"path": "Database > Refresh Materialized View…", "action": "refreshMaterializedView:"}, - {"path": "Database > Edit Comment…", "action": "editObjectComment:"}, - {"path": "Database > Schema"}, - {"path": "Database > Session Context"}, - {"path": "Database > Safe Mode Level"}, - {"path": "Database > Favorite Database"}, - {"path": "Database > Table Maintenance"}, - {"path": "Database > Truncate Table", "identifier": "shortcut.truncateTable", "action": "truncateTable:", "key": "backspace", "modifiers": ["option"]}, - {"path": "Database", "separator": true}, - {"path": "Database > View ER Diagram", "action": "showERDiagram:"}, - {"path": "Database > Build Join Query…", "action": "buildJoinQuery:"}, - {"path": "Database > Server Dashboard", "identifier": "shortcut.showServerDashboard", "action": "showServerDashboard:", "key": "d", "modifiers": ["option", "shift", "command"]}, - {"path": "Database > Users & Roles", "action": "showUsersAndRoles:"}, - {"path": "Database > Query Insights", "identifier": "shortcut.showQueryInsights", "action": "showQueryInsights:", "key": "i", "modifiers": ["option", "shift", "command"]}, - {"path": "Database", "separator": true}, - {"path": "Database > Release File Lock", "action": "releaseFileLock:"}, - {"path": "Database > Disconnect", "action": "requestDisconnect"}, - {"path": "Database > Reconnect", "action": "retryConnection"}, - {"path": "Database", "separator": true}, - {"path": "Database > Compare"}, - {"path": "Database > Compare > Compare & Sync Databases…", "action": "compareAndSyncDatabases:"}, - {"path": "Database > Compare", "separator": true}, - {"path": "Database > Compare > Save Comparison…", "action": "saveComparison:"}, - {"path": "Database > Compare", "separator": true}, - {"path": "Database > Compare > Compare Now", "action": "runComparison:"}, - {"path": "Database > Compare > Swap Source and Target", "action": "swapEndpoints:"}, - {"path": "Database > Compare > Comparison Options…", "action": "showOptions:"}, - {"path": "Database > Compare", "separator": true}, - {"path": "Database > Compare > Generate Script", "action": "generateScript:"}, - {"path": "Database > Compare > Apply to Target…", "action": "applyToTarget:"}, - {"path": "Database > Compare > Stop Comparison", "action": "stopComparison:"}, - {"path": "Query"}, - {"path": "Query > Execute Query", "identifier": "shortcut.executeQuery", "action": "executeQuery:", "key": "return", "modifiers": ["command"]}, - {"path": "Query > Execute All Statements", "identifier": "shortcut.executeAllStatements", "action": "executeAllStatements:", "key": "return", "modifiers": ["shift", "command"]}, - {"path": "Query > Execute Query Without Limit", "identifier": "shortcut.executeQueryWithoutLimit", "action": "executeQueryWithoutLimit:", "key": "return", "modifiers": ["option", "command"]}, - {"path": "Query > Cancel Query", "identifier": "shortcut.cancelQuery", "action": "cancelQuery:", "key": ".", "modifiers": ["command"]}, - {"path": "Query", "separator": true}, - {"path": "Query > Clear Query", "action": "clearQuery:"}, - {"path": "Query > Clear Results", "action": "clearResults:"}, - {"path": "Query", "separator": true}, - {"path": "Query > Explain Query", "identifier": "shortcut.explainQuery", "action": "explainQuery:", "key": "e", "modifiers": ["option", "command"]}, - {"path": "Query > Format Query", "identifier": "shortcut.formatQuery", "action": "formatQuery:", "key": "l", "modifiers": ["shift", "command"]}, - {"path": "Query > Remove Invisible Characters", "identifier": "shortcut.removeInvisibleCharacters", "action": "removeInvisibleCharacters:"}, - {"path": "Query > Preview SQL", "identifier": "shortcut.previewSQL", "action": "previewSQL:", "key": "p", "modifiers": ["shift", "command"]}, - {"path": "Query", "separator": true}, - {"path": "Query > Previous Statement", "identifier": "shortcut.previousStatement", "action": "goToPreviousStatement:", "key": "left", "modifiers": ["control", "command"]}, - {"path": "Query > Next Statement", "identifier": "shortcut.nextStatement", "action": "goToNextStatement:", "key": "right", "modifiers": ["control", "command"]}, - {"path": "Query > Run Statement and Advance", "identifier": "shortcut.runStatementAndAdvance", "action": "runStatementAndAdvance:", "key": "return", "modifiers": ["control", "command"]}, - {"path": "Query", "separator": true}, - {"path": "Query > Toggle Fold", "identifier": "shortcut.toggleFold", "action": "toggleFold:", "key": "left", "modifiers": ["option", "command"]}, - {"path": "Query > Fold All", "identifier": "shortcut.foldAll", "action": "foldAll:", "key": "left", "modifiers": ["option", "shift", "command"]}, - {"path": "Query > Unfold All", "identifier": "shortcut.unfoldAll", "action": "unfoldAll:", "key": "right", "modifiers": ["option", "shift", "command"]}, - {"path": "Query", "separator": true}, - {"path": "Query > Save as Favorite…", "identifier": "shortcut.saveAsFavorite", "action": "saveAsFavorite:", "key": "d", "modifiers": ["command"]}, - {"path": "Query > Preview FK Reference", "identifier": "shortcut.previewFKReference", "action": "previewFKReference:"}, - {"path": "Query", "separator": true}, - {"path": "Query > First Page", "identifier": "shortcut.firstPage", "action": "goToFirstPage:", "key": "up", "modifiers": ["option", "command"]}, - {"path": "Query > Previous Page", "identifier": "shortcut.previousPage", "action": "goToPreviousPage:", "key": "[", "modifiers": ["command"]}, - {"path": "Query > Next Page", "identifier": "shortcut.nextPage", "action": "goToNextPage:", "key": "]", "modifiers": ["command"]}, - {"path": "Query > Last Page", "identifier": "shortcut.lastPage", "action": "goToLastPage:", "key": "down", "modifiers": ["option", "command"]}, - {"path": "Query", "separator": true}, - {"path": "Query > Review with AI", "identifier": "shortcut.aiReviewQuery", "action": "reviewQueryWithAI:", "key": "l", "modifiers": ["option", "shift", "command"]}, - {"path": "Query > Explain with AI", "identifier": "shortcut.aiExplainQuery", "action": "explainQueryWithAI:", "key": "l", "modifiers": ["command"]}, - {"path": "Query > Optimize with AI", "identifier": "shortcut.aiOptimizeQuery", "action": "optimizeQueryWithAI:", "key": "l", "modifiers": ["option", "command"]}, - {"path": "Window"}, - {"path": "Window > Minimize", "action": "performMiniaturize:", "key": "m", "modifiers": ["command"]}, - {"path": "Window > Zoom", "action": "performZoom:"}, - {"path": "Window", "separator": true}, - {"path": "Window > Show Previous Tab", "identifier": "shortcut.showPreviousTab", "action": "selectPreviousEditorTab:", "key": "[", "modifiers": ["shift", "command"]}, - {"path": "Window > Show Next Tab", "identifier": "shortcut.showNextTab", "action": "selectNextEditorTab:", "key": "]", "modifiers": ["shift", "command"]}, - {"path": "Window > Switch to Recent Tab", "identifier": "shortcut.switchToRecentTab", "action": "switchToRecentTab:", "key": "tab", "modifiers": ["control"]}, - {"path": "Window > Switch to Least Recent Tab", "identifier": "shortcut.switchToLeastRecentTab", "action": "switchToLeastRecentTab:", "key": "tab", "modifiers": ["control", "shift"]}, - {"path": "Window", "separator": true}, - {"path": "Window > Show Previous Window Tab", "identifier": "shortcut.showPreviousWindowTab", "action": "selectPreviousTab:"}, - {"path": "Window > Show Next Window Tab", "identifier": "shortcut.showNextWindowTab", "action": "selectNextTab:"}, - {"path": "Window > Move Tab to New Window", "action": "moveTabToNewWindow:"}, - {"path": "Window > Merge All Windows", "action": "mergeAllWindows:"}, - {"path": "Window", "separator": true}, - {"path": "Window > Select Tab 1", "action": "selectNumberedTab:", "key": "1", "modifiers": ["command"]}, - {"path": "Window > Select Tab 2", "action": "selectNumberedTab:", "key": "2", "modifiers": ["command"]}, - {"path": "Window > Select Tab 3", "action": "selectNumberedTab:", "key": "3", "modifiers": ["command"]}, - {"path": "Window > Select Tab 4", "action": "selectNumberedTab:", "key": "4", "modifiers": ["command"]}, - {"path": "Window > Select Tab 5", "action": "selectNumberedTab:", "key": "5", "modifiers": ["command"]}, - {"path": "Window > Select Tab 6", "action": "selectNumberedTab:", "key": "6", "modifiers": ["command"]}, - {"path": "Window > Select Tab 7", "action": "selectNumberedTab:", "key": "7", "modifiers": ["command"]}, - {"path": "Window > Select Tab 8", "action": "selectNumberedTab:", "key": "8", "modifiers": ["command"]}, - {"path": "Window > Select Tab 9", "action": "selectNumberedTab:", "key": "9", "modifiers": ["command"]}, - {"path": "Window", "separator": true}, - {"path": "Window > Bring All to Front", "action": "arrangeInFront:"}, - {"path": "Help"}, - {"path": "Help > TablePro Website", "action": "openWebsite:"}, - {"path": "Help > Documentation", "action": "openDocumentation:"}, - {"path": "Help > Integrations", "action": "openIntegrationsDirectory:"}, - {"path": "Help > GitHub Repository", "action": "openGitHubRepository:"}, - {"path": "Help > What's New", "action": "openChangelog:"}, - {"path": "Help", "separator": true}, - {"path": "Help > Getting Started", "action": "showWelcomeSheet:"}, - {"path": "Help > Open Sample Database", "action": "openSampleDatabase:"}, - {"path": "Help > Reset Sample Database…", "action": "resetSampleDatabase:"}, - {"path": "Help", "separator": true}, - {"path": "Help > Report an Issue", "action": "reportAnIssue:"}, - {"path": "Help", "separator": true}, - {"path": "Help > Support TablePro", "action": "showSupport:"}, - {"path": "Help > Acknowledgements", "action": "showAcknowledgements:"} - ], - "mainWindowToolbar": { - "default": [ - "NSToolbarToggleSidebarItem", - "NSToolbarSidebarTrackingSeparatorItemIdentifier", - "NSToolbarFlexibleSpaceItem", - "com.TablePro.toolbar.connection", - "com.TablePro.toolbar.database", - "NSToolbarFlexibleSpaceItem", - "com.TablePro.toolbar.refresh", - "com.TablePro.toolbar.actions", - "com.TablePro.toolbar.safeMode", - "com.TablePro.toolbar.saveChanges", - "NSToolbarInspectorTrackingSeparatorItemIdentifier", - "NSToolbarFlexibleSpaceItem", - "NSToolbarToggleInspectorItem" - ], - "allowed": [ - "NSToolbarToggleSidebarItem", - "NSToolbarSidebarTrackingSeparatorItemIdentifier", - "NSToolbarFlexibleSpaceItem", - "com.TablePro.toolbar.connection", - "com.TablePro.toolbar.database", - "NSToolbarFlexibleSpaceItem", - "com.TablePro.toolbar.refresh", - "com.TablePro.toolbar.actions", - "com.TablePro.toolbar.safeMode", - "com.TablePro.toolbar.saveChanges", - "NSToolbarInspectorTrackingSeparatorItemIdentifier", - "NSToolbarFlexibleSpaceItem", - "NSToolbarToggleInspectorItem", - "com.TablePro.toolbar.backForwardGroup", - "com.TablePro.toolbar.previewSQL", - "com.TablePro.toolbar.results", - "com.TablePro.toolbar.export", - "com.TablePro.toolbar.import", - "com.TablePro.toolbar.history", - "com.TablePro.toolbar.addRow", - "com.TablePro.toolbar.restorePreviousValues", - "com.TablePro.toolbar.newTab", - "com.TablePro.toolbar.quickSwitcher" - ] - }, - "settingsPanes": [ - {"id": "general", "title": "General"}, - {"id": "appearance", "title": "Appearance"}, - {"id": "editor", "title": "Editor"}, - {"id": "data", "title": "Data"}, - {"id": "keyboard", "title": "Keyboard"}, - {"id": "profiles", "title": "Profiles"}, - {"id": "notifications", "title": "Notifications"}, - {"id": "ai", "title": "AI"}, - {"id": "mcp", "title": "MCP"}, - {"id": "plugins", "title": "Plugins"}, - {"id": "sync", "title": "Sync"}, - {"id": "account", "title": "Account"} - ] -} diff --git a/apple/TableProTests/Core/Menu/MainMenuBuilderTests.swift b/apple/TableProTests/Core/Menu/MainMenuBuilderTests.swift index cf97f14665..ea2b6f671f 100644 --- a/apple/TableProTests/Core/Menu/MainMenuBuilderTests.swift +++ b/apple/TableProTests/Core/Menu/MainMenuBuilderTests.swift @@ -359,6 +359,30 @@ struct MainMenuShortcutCoverageTests { #expect(item?.keyEquivalent == "j") #expect(item?.keyEquivalentModifierMask == [.command, .shift]) } + + @Test("Export Results… sits in File > Export on Option-Shift-Command-E") + func exportResultsLivesUnderExport() { + let file = buildMenu().items.first { $0.title == String(localized: "File") }?.submenu + let export = file?.items.first { $0.title == String(localized: "Export") }?.submenu + let item = export?.items.first { $0.identifier == MenuItemFactory.identifier(for: .exportResults) } + + #expect(item?.title == String(localized: "Export Results…")) + #expect(item?.action == #selector(MainSplitViewController.exportQueryResults(_:))) + #expect(item?.keyEquivalent == "e") + #expect(item?.keyEquivalentModifierMask == [.command, .shift, .option]) + } + + @Test("A rebound Export Results reaches its menu item") + func reboundExportResultsApplies() { + let menu = buildMenu() + var keyboard = KeyboardSettings() + keyboard.setShortcut(.character("r", command: true, shift: true, option: true), for: .exportResults) + MainMenuKeyEquivalentSync.apply(keyboard: keyboard, to: menu) + + let item = flatten(menu).first { $0.identifier == MenuItemFactory.identifier(for: .exportResults) } + #expect(item?.keyEquivalent == "r") + #expect(item?.keyEquivalentModifierMask == [.command, .shift, .option]) + } } // Sessions and conversations had no menu-bar home, so their seven commands couldn't be searched, rebound or reached. diff --git a/apple/TableProTests/Models/ShortcutUniquenessTests.swift b/apple/TableProTests/Models/ShortcutUniquenessTests.swift index 3bc64bbb25..56c209ebbd 100644 --- a/apple/TableProTests/Models/ShortcutUniquenessTests.swift +++ b/apple/TableProTests/Models/ShortcutUniquenessTests.swift @@ -76,7 +76,7 @@ struct ShortcutUniquenessTests { ShortcutAction.showTablesList, .showFavoritesList, .restorePreviousValues, .newAgentSession, .openAgentSession, .closeAgentSession, .deleteAgentSession, .newAIConversation, .copyPath, .copyRelativePath, .setCellsToValue, .pinTab, - .showServerDashboard, .showQueryInsights, + .showServerDashboard, .showQueryInsights, .exportResults, ]) func newlyRebindableCommandsAreListable(action: ShortcutAction) { #expect(!action.displayName.isEmpty) @@ -84,6 +84,19 @@ struct ShortcutUniquenessTests { } // Left to the context switch's `default:` it would be `.global`, and the recorder would call a grid combo free for it. + // The raw value keys every stored and synced override of Export Tables, so only the name in Settings changes. + @Test("Export Tables and Export Results are separate rows, each on its own key") + func exportCommandsAreSeparateRows() { + #expect(ShortcutAction.export.rawValue == "export") + #expect(ShortcutAction.export.displayName == String(localized: "Export Tables")) + #expect(ShortcutAction.exportResults.displayName == String(localized: "Export Results")) + #expect(ShortcutAction.exportResults.category == ShortcutAction.export.category) + #expect(ShortcutAction.exportResults.context == .global) + #expect( + KeyboardSettings.default.shortcut(for: .exportResults) == .character("e", command: true, shift: true, option: true) + ) + } + @Test("Restore Previous Values is a data-grid command") func restorePreviousValuesIsAGridCommand() { #expect(ShortcutAction.restorePreviousValues.context == .dataGrid) diff --git a/apple/TableProTests/Services/MainWindowToolbarShortcutHintTests.swift b/apple/TableProTests/Services/MainWindowToolbarShortcutHintTests.swift index 0d14502087..fc2c42e3d8 100644 --- a/apple/TableProTests/Services/MainWindowToolbarShortcutHintTests.swift +++ b/apple/TableProTests/Services/MainWindowToolbarShortcutHintTests.swift @@ -85,12 +85,22 @@ extension GlobalStateSuites { @Test("An item with no shortcut keeps a plain tooltip and no key equivalent") func itemWithoutShortcutHasNoKeyEquivalent() { - let owner = MainWindowToolbar() - let item = vend(MainWindowToolbar.restorePreviousValues, from: owner) + let item = MainWindowToolbar().makeDebugItem(MainWindowToolbar.debugStop) #expect(item?.menuFormRepresentation?.keyEquivalent == "") #expect(item?.toolTip?.isEmpty == false) } + @Test( + "Restore Previous Values takes the binding the user gave it", + .appSettings { $0.keyboard.setShortcut(.character("z", command: true, control: true), for: .restorePreviousValues) } + ) + func restorePreviousValuesFollowsItsBinding() { + let item = vend(MainWindowToolbar.restorePreviousValues, from: MainWindowToolbar()) + #expect(item?.menuFormRepresentation?.keyEquivalent == "z") + #expect(item?.menuFormRepresentation?.keyEquivalentModifierMask == [.command, .control]) + #expect(item?.toolTip?.contains("⌃⌘Z") == true) + } + /// Back is reachable from `allItems()` only while it is installed in its group, and `allItems()` /// is the only channel that re-applies a rebind to a toolbar item. A commit that hid the pair by /// emptying the group took both arrows out of that walk, so a rebind reached the menu bar and diff --git a/apple/TableProTests/Views/Main/CommandActionsDispatchTests.swift b/apple/TableProTests/Views/Main/CommandActionsDispatchTests.swift index 0e44f56a86..e37149a0c3 100644 --- a/apple/TableProTests/Views/Main/CommandActionsDispatchTests.swift +++ b/apple/TableProTests/Views/Main/CommandActionsDispatchTests.swift @@ -63,6 +63,45 @@ extension GlobalStateSuites { #expect(actions.canSaveAsFavorite) } + // MARK: - Export Results from the grid + + private func gridHoldingRows( + _ actions: MainContentCommandActions, + _ coordinator: MainContentCoordinator + ) throws -> DataTabGridAdapter { + coordinator.commandActions = actions + coordinator.tabManager.addTab(initialQuery: "SELECT 1", databaseName: "testdb") + let tabId = try #require(coordinator.tabManager.selectedTabId) + coordinator.tabSessionRegistry.setTableRows( + TableRows.from(queryRows: [[.text("1")]], columns: ["id"], columnTypes: [.text(rawType: nil)]), + for: tabId + ) + _ = coordinator.gridModel(forTab: tabId) + return try #require(coordinator.dataGridEntries[tabId]?.adapter) + } + + // It went out as a broadcast only the key window answered, so with TablePro in the background it did nothing. + @Test("The grid's Export Results… opens on its own window when no window is key") + func gridExportResultsReachesItsOwnWindow() throws { + let (actions, coordinator) = makeSUT() + let adapter = try gridHoldingRows(actions, coordinator) + + adapter.gridExportResults() + + #expect(coordinator.activeSheet?.id == ActiveSheet.exportQueryResults.id) + } + + @Test("A grid whose tab is not in front does not export the front tab's rows") + func gridExportResultsNeedsItsTabInFront() throws { + let (actions, coordinator) = makeSUT() + let adapter = try gridHoldingRows(actions, coordinator) + coordinator.tabManager.addTab(initialQuery: "SELECT 2", databaseName: "testdb") + + adapter.gridExportResults() + + #expect(coordinator.activeSheet == nil) + } + // MARK: - loadQueryIntoEditor @Test("loadQueryIntoEditor forwards query to coordinator and updates tab") diff --git a/apple/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift b/apple/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift index faa2af5102..37660fb554 100644 --- a/apple/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift +++ b/apple/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift @@ -991,6 +991,13 @@ struct DatabaseTreeMenuSpecTests { #expect(SidebarMenuCommand.refreshRedisKeys.shortcutAction == nil) } + // Export Tables… exports the selected tables, so its key never exported the database or schema it was shown on. + @Test("A database or schema's Export… shows no shortcut") + func containerExportShowsNoShortcut() { + #expect(SidebarMenuCommand.exportContainers([.database("analytics")]).shortcutAction == nil) + #expect(SidebarMenuCommand.exportContainers([.schema(database: "app", schema: "public")]).shortcutAction == nil) + } + /// Elasticsearch, Typesense and Weaviate have no listing, and the item used to run a MongoDB command /// against them. @Test("Show All Tables is offered only when the engine has a listing") diff --git a/apple/TableProUITests/ExportUITests.swift b/apple/TableProUITests/ExportUITests.swift index 63207dd5d0..333c15fa53 100644 --- a/apple/TableProUITests/ExportUITests.swift +++ b/apple/TableProUITests/ExportUITests.swift @@ -43,6 +43,51 @@ final class ExportUITests: UITestCase { ) } + func testExportingQueryResultsFromTheKeyboardWritesACSVFile() throws { + let launch = try launchWithFixture() + let window = launch.window + let app = launch.app + let destination = try XCTUnwrap(sandboxRoot) + let exported = destination.appendingPathComponent("album-results.csv") + + app.typeShortcut(.newTab) + typeQuery("SELECT id, title FROM album ORDER BY id;", in: window) + app.typeShortcut(.runQuery) + // A disabled menu item still takes its key equivalent, so the rows must be there first. + XCTAssertTrue(waitForClickableRows(in: dataGrid(in: window)), "Cmd+Return must run the query into a grid") + app.typeShortcut(.exportResults) + + let sheet = window.children(matching: .sheet).firstMatch + XCTAssertTrue( + sheet.staticTexts["6 rows to export"].waitToExist(timeout: 30), + "Option-Shift-Command-E must open Export Results on the query's rows" + ) + let format = element(sheet.popUpButtons, labelEndingWith: "Format") + XCTAssertTrue(format.waitToExist(timeout: 10), "The export sheet must offer a format menu") + if (format.value as? String) != "CSV" { + format.click() + let csv = app.menuItems["CSV"].firstMatch + XCTAssertTrue(csv.waitToExist(timeout: 10), "CSV must be an export format") + csv.click() + } + XCTAssertTrue(waitForPredicate(timeout: 10) { (format.value as? String) == "CSV" }, "The format must be CSV") + + save(from: sheet, to: exported, in: app) + + XCTAssertTrue( + waitForPredicate(timeout: 30) { FileManager.default.fileExists(atPath: exported.path) }, + "Export must write \(exported.lastPathComponent) into the chosen folder" + ) + let lines = try String(contentsOf: exported, encoding: .utf8) + .split(separator: "\n") + .map(String.init) + XCTAssertEqual( + lines, + ["id,title", "1,North", "2,South", "3,East", "4,West", "5,Up", "6,Down"], + "The file must hold the query's header and every row it returned" + ) + } + func testExportingADataDictionaryWritesMarkdown() throws { let launch = try launchWithFixture() let window = launch.window diff --git a/apple/TableProUITests/Support/AppShortcut.swift b/apple/TableProUITests/Support/AppShortcut.swift index b15d4f453f..eed662bae0 100644 --- a/apple/TableProUITests/Support/AppShortcut.swift +++ b/apple/TableProUITests/Support/AppShortcut.swift @@ -23,6 +23,7 @@ internal enum AppShortcut { case deleteRow case refresh case export + case exportResults case toggleFilters case toggleHistory case toggleInspector @@ -53,6 +54,7 @@ internal enum AppShortcut { case .deleteRow: (XCUIKeyboardKey.delete.rawValue, .command) case .refresh: ("r", .command) case .export: ("e", [.command, .shift]) + case .exportResults: ("e", [.command, .shift, .option]) case .toggleFilters: ("f", [.command, .shift]) case .toggleHistory: ("y", .command) case .toggleInspector: ("i", [.command, .option]) diff --git a/linux/crates/app/src/application/accelerators.rs b/linux/crates/app/src/application/accelerators.rs index 1b0e0794e4..a59a763415 100644 --- a/linux/crates/app/src/application/accelerators.rs +++ b/linux/crates/app/src/application/accelerators.rs @@ -73,6 +73,8 @@ pub const ACCELERATORS: &[Accelerator] = &[ application("app.new-window", &["n"], General), application("app.preferences", &["comma"], General), application("win.show-history", &["h"], General), + // As in GIMP and Inkscape. + application("win.export-results", &["e"], General), // As in GNOME Files. application("win.show-sidebar", &["F9"], General), application("win.show-assistant", &["a"], General), diff --git a/linux/crates/app/src/ui/browse_tab/browse_actions.rs b/linux/crates/app/src/ui/browse_tab/browse_actions.rs index c51a908c00..42ebc3c9dc 100644 --- a/linux/crates/app/src/ui/browse_tab/browse_actions.rs +++ b/linux/crates/app/src/ui/browse_tab/browse_actions.rs @@ -94,7 +94,7 @@ pub(super) fn describe_buttons(tab: &BrowseTab) { gettext("Delete Selected Rows"), Some("grid.delete-rows"), ), - (&imp.export_button, gettext("Export Rows…"), None), + (&imp.export_button, gettext("Export Rows…"), Some("win.export-results")), ] { describe_icon_button(&button.get(), &label, action); } diff --git a/linux/crates/app/src/ui/browse_tab/layout.rs b/linux/crates/app/src/ui/browse_tab/layout.rs index 50076171f7..95a8522f33 100644 --- a/linux/crates/app/src/ui/browse_tab/layout.rs +++ b/linux/crates/app/src/ui/browse_tab/layout.rs @@ -64,9 +64,14 @@ pub(super) fn build(tab: &BrowseTab, init: &LayoutInit<'_>) -> BrowseTabWidgets /// What the bottom bar holds past the tab's breakpoint, where it has no /// room for it. fn more_menu(page_sizes: &[u64]) -> gio::Menu { + let export = gio::MenuItem::new(Some(&gettext("_Export Rows…")), Some("browse.export")); + // The window's Export Results key reaches this tab's Export. + if let Some(key) = crate::application::triggers_for("win.export-results").first() { + export.set_attribute_value("accel", Some(&key.to_variant())); + } let actions = gio::Menu::new(); actions.append(Some(&gettext("_Filter Rows")), Some("browse.filter")); - actions.append(Some(&gettext("_Export Rows…")), Some("browse.export")); + actions.append_item(&export); actions.append(Some(&gettext("_Count Rows")), Some("browse.count-rows")); let sizes = gio::Menu::new(); for size in page_sizes { @@ -291,6 +296,21 @@ mod tests { crate::test_support::assert_access_keys(&more_menu(&[])); } + #[test] + fn the_more_menus_export_shows_the_export_results_key() { + let actions = more_menu(&[]).item_link(0, "section").expect("the menu's actions"); + let read = |item: i32, attribute: &str| { + actions + .item_attribute_value(item, attribute, None) + .and_then(|value| value.get::()) + }; + let export = (0..actions.n_items()) + .find(|&item| read(item, "action").as_deref() == Some("browse.export")) + .expect("Export Rows in the More menu"); + + assert_eq!(read(export, "accel").as_deref(), Some("e")); + } + #[gtk4::test] fn the_dropdown_shows_a_page_size_set_in_preferences() { let dropdown = gtk::DropDown::default(); diff --git a/linux/crates/app/src/ui/browse_tab/mod.rs b/linux/crates/app/src/ui/browse_tab/mod.rs index 0904faf590..3992ec5199 100644 --- a/linux/crates/app/src/ui/browse_tab/mod.rs +++ b/linux/crates/app/src/ui/browse_tab/mod.rs @@ -70,7 +70,7 @@ use self::browse_tab_widgets::BrowseTabWidgets; use self::column_recheck::ColumnRecheck; pub(super) use self::columns_fetch::{ColumnFreshness, load_columns}; pub(crate) use self::data_input::DataInput; -use self::grid_input::GridInput; +pub(crate) use self::grid_input::GridInput; pub(crate) use self::page_reply::PageReply; use self::page_state::PageState; #[cfg(test)] diff --git a/linux/crates/app/src/ui/browse_tab/tests/mod.rs b/linux/crates/app/src/ui/browse_tab/tests/mod.rs index 398762cd79..f9b9de38a4 100644 --- a/linux/crates/app/src/ui/browse_tab/tests/mod.rs +++ b/linux/crates/app/src/ui/browse_tab/tests/mod.rs @@ -1255,6 +1255,7 @@ fn browse_buttons_announce_their_keys() { for (name, button) in [ ("insert row", imp.insert_button.get().upcast::()), ("delete rows", imp.delete_button.get().upcast()), + ("export rows", imp.export_button.get().upcast()), ("row details", imp.details_toggle.get().upcast()), ("filter", imp.filter_button.get().upcast()), ("previous page", imp.pager_bar.imp().prev_button.get().upcast()), diff --git a/linux/crates/app/src/ui/data_grid/grid_menu.rs b/linux/crates/app/src/ui/data_grid/grid_menu.rs index f48a74f80b..a245acb32c 100644 --- a/linux/crates/app/src/ui/data_grid/grid_menu.rs +++ b/linux/crates/app/src/ui/data_grid/grid_menu.rs @@ -175,7 +175,12 @@ fn cell_menu(grid: &DataGrid, references: &gio::Menu, expressions: &gio::Menu) - ]), ); menu.append_section(None, &column_section(sorts)); - let rows = section([item(&gettext("E_xport…"), "grid.export")]); + let export = item(&gettext("E_xport…"), "grid.export"); + // The window's Export Results key reaches the grid of the tab in front. + if let Some(key) = crate::application::triggers_for("win.export-results").first() { + export.set_attribute_value("accel", Some(&key.to_variant())); + } + let rows = section([export]); if editable { rows.append_item(&item(&gettext("_Insert Row"), "grid.insert-row")); rows.append_item(&item(&gettext("D_uplicate"), "grid.duplicate-rows")); diff --git a/linux/crates/app/src/ui/data_grid/tests/menus.rs b/linux/crates/app/src/ui/data_grid/tests/menus.rs index 553d71ee96..f21b25f678 100644 --- a/linux/crates/app/src/ui/data_grid/tests/menus.rs +++ b/linux/crates/app/src/ui/data_grid/tests/menus.rs @@ -160,6 +160,11 @@ fn the_cell_menu_shows_the_cell_keys() { ); assert_eq!(accel_of(&menu, "grid.edit"), Some(Some("F2".to_owned()))); assert_eq!(accel_of(&menu, "grid.set-empty"), Some(None)); + assert_eq!( + accel_of(&menu, "grid.export"), + Some(Some("e".to_owned())), + "Export does not show the window's Export Results key" + ); } #[gtk4::test] diff --git a/linux/crates/app/src/ui/editor/mod.rs b/linux/crates/app/src/ui/editor/mod.rs index b5d2a30eb8..fd70597bfe 100644 --- a/linux/crates/app/src/ui/editor/mod.rs +++ b/linux/crates/app/src/ui/editor/mod.rs @@ -896,6 +896,19 @@ impl SqlEditor { ) } + pub(crate) fn export_shown_result(&self) -> bool { + let results = &self.imp().results; + let Some(index) = results.selected() else { + return false; + }; + // Only a result set's page has a grid. + let Some(grid) = results.kept_grid(index).filter(|grid| grid.row_count() > 0) else { + return false; + }; + self.offer_export(index, grid.export_source()); + true + } + /// Ask the window to export the result on line `index`: the rows /// its grid shows, or every row its statement returns when run /// again. diff --git a/linux/crates/app/src/ui/editor/tests.rs b/linux/crates/app/src/ui/editor/tests.rs index 431c2a6458..ce7c335ef7 100644 --- a/linux/crates/app/src/ui/editor/tests.rs +++ b/linux/crates/app/src/ui/editor/tests.rs @@ -929,6 +929,51 @@ fn export_all_uses_originating_statement_of_second_result() { assert_eq!(offer.origin.preselected(), crate::ui::export::ScopeSource::Query); } +#[gtk4::test] +fn export_shown_result_offers_the_result_on_show() { + let mut runs = Replies::default(); + runs.when_sql_contains("second", result_set(2, Some(2), Truncation::Complete)); + runs.always(result_set(3, Some(3), Truncation::Complete)); + let harness = editor_with(runs, "SELECT n FROM first;\nSELECT n FROM second;"); + let offers = SignalLog::connect(&harness.editor, "export-results", |values| { + values[1] + .get::() + .expect("an offer") + .borrow::() + .clone() + }); + run_and_wait(&harness); + harness.editor.imp().results.select(1); + + assert!(harness.editor.export_shown_result()); + + let offers = offers.take(); + let [offer] = offers.as_slice() else { + panic!("one offer, not {}", offers.len()); + }; + let ExportOrigin::Editor { + shown, + query: ExportQuery::Editor { sql }, + .. + } = &offer.origin + else { + panic!("not an editor result: {:?}", offer.origin); + }; + assert_eq!(sql.as_str(), "SELECT n FROM second"); + assert_eq!(shown.row_count(), Some(2)); +} + +#[gtk4::test] +fn export_shown_result_offers_nothing_without_rows() { + let harness = editor_with(answering(), "SELECT 1"); + let offers = SignalLog::connect(&harness.editor, "export-results", |_| ()); + + assert!(!harness.editor.export_shown_result(), "an editor that never ran"); + run_and_wait(&harness); + assert!(!harness.editor.export_shown_result(), "a command offered rows"); + assert_eq!(offers.count(), 0); +} + #[gtk4::test] fn history_records_none_for_capped_result() { let root = tempfile::tempdir().expect("tempdir"); diff --git a/linux/crates/app/src/ui/key_tab/mod.rs b/linux/crates/app/src/ui/key_tab/mod.rs index a1e3c75561..2a80fb94e0 100644 --- a/linux/crates/app/src/ui/key_tab/mod.rs +++ b/linux/crates/app/src/ui/key_tab/mod.rs @@ -242,6 +242,15 @@ impl KeyTab { reload(&Rc::downgrade(&self.inner)); } + pub(crate) fn export_shown(&self) -> bool { + let grid = &self.inner.grid; + if grid.row_count() == 0 || self.inner.state.borrow().shown.is_none() { + return false; + } + self.inner.export(grid.export_source()); + true + } + /// Drop what a read still on its way would show. pub(crate) fn shutdown(&self) { self.inner.shut_down.set(true); diff --git a/linux/crates/app/src/ui/key_tab/tests.rs b/linux/crates/app/src/ui/key_tab/tests.rs index bb491aac75..da954a4e36 100644 --- a/linux/crates/app/src/ui/key_tab/tests.rs +++ b/linux/crates/app/src/ui/key_tab/tests.rs @@ -423,4 +423,32 @@ fn a_module_type_is_named_and_left_to_the_editor() { assert_eq!(tab.type_text(), "Other"); assert_eq!(tab.summary_text(), "No expiry"); assert!(!tab.offers_more()); + assert!(!tab.export_shown(), "a value the tab does not read offered rows"); +} + +#[gtk4::test] +fn export_shown_offers_the_entries_on_screen() { + let mut script = ConnectionScript::default(); + script.read_key.always(FakeReply::ok(Some(list_page(0, 2, None)))); + let (tab, _fake, _runtime) = opened(script); + let offers: Rc>> = Rc::default(); + let sink = offers.clone(); + tab.connect_events(move |event| { + if let KeyTabEvent::Export(offer) = event { + sink.borrow_mut().push(*offer); + } + }); + wait_until(WAIT, || tab.rows() == 2).expect("the first window"); + + assert!(tab.export_shown()); + + let offers = offers.take(); + let [offer] = offers.as_slice() else { + panic!("one offer, not {}", offers.len()); + }; + let ExportOrigin::Shown { rows } = &offer.origin else { + panic!("not the rows shown: {:?}", offer.origin); + }; + assert_eq!(rows.row_count(), Some(2)); + assert_eq!(offer.name, "queue"); } diff --git a/linux/crates/app/src/ui/workspace/mod.rs b/linux/crates/app/src/ui/workspace/mod.rs index a1c63b2386..6e4edd63b6 100644 --- a/linux/crates/app/src/ui/workspace/mod.rs +++ b/linux/crates/app/src/ui/workspace/mod.rs @@ -545,7 +545,7 @@ pub(crate) enum WorkspaceInput { DraftsNotSaved, ShowHistory, ShowSavedQueries, - SaveActiveQuery, + ActiveTab(workspace_tabs::ActiveTabCommand), SaveQueryNamed { name: String, query: String, @@ -1404,7 +1404,7 @@ impl WorkspaceModel { bytes, } => self.on_import_csv_ready(schema, table, columns, bytes), WorkspaceInput::ImportCsvRun(import) => self.on_import_csv_run(*import), - WorkspaceInput::SaveActiveQuery => self.on_save_active_query(), + WorkspaceInput::ActiveTab(command) => self.on_active_tab_command(command), WorkspaceInput::SaveQueryNamed { name, query } => self.on_save_query_named(name, query), WorkspaceInput::OpenHistoryQuery(text) => self.open_editor_tab(Some(text)), WorkspaceInput::ReplaceActiveTabQuery(text) => self.on_replace_active_tab_query(text), diff --git a/linux/crates/app/src/ui/workspace/tests/shell.rs b/linux/crates/app/src/ui/workspace/tests/shell.rs index 33d5447b98..b264b074a4 100644 --- a/linux/crates/app/src/ui/workspace/tests/shell.rs +++ b/linux/crates/app/src/ui/workspace/tests/shell.rs @@ -1,6 +1,7 @@ //! The shell's chrome: the header bars, the sidebar, the breakpoints and //! the window floor, the overview and the main menu. +use std::sync::Arc; use std::time::Duration; use gtk4 as gtk; @@ -8,11 +9,14 @@ use gtk4::prelude::*; use gtk4::subclass::prelude::*; use libadwaita as adw; use libadwaita::prelude::*; +use tablepro_core::column::{ColumnType, ResultColumn}; +use tablepro_core::{EditorSession, ResultEvent, Truncation, Value}; +use tablepro_test_support::{FakeEditorSession, FakeReply, FakeRun, Replies}; use super::{served_table, shell_of}; use crate::test_support::{descendants, wait_until}; use crate::ui::SessionWindowInput; -use crate::ui::session_window::tests::{Script, TestWindow, WAIT}; +use crate::ui::session_window::tests::{Script, TestWindow, WAIT, editors}; use crate::ui::workspace::{WorkspaceShell, WorkspaceTab}; fn overview_new_tab_button(overview: &adw::TabOverview) -> gtk::Button { @@ -397,6 +401,96 @@ fn f9_toggles_sidebar() { assert_eq!(test.app.accels_for_action("win.show-sidebar"), ["F9"]); } +#[gtk4::test] +fn ctrl_shift_e_exports_results() { + use crate::test_support::shortcut_keys::keystroke; + let test = TestWindow::picker("CtrlShiftE"); + + let installed = test.app.accels_for_action("win.export-results"); + let keys: Vec<_> = installed.iter().map(|accel| keystroke(accel)).collect(); + assert_eq!(keys, [keystroke("e")]); +} + +fn two_rows() -> Arc { + let columns: Arc<[ResultColumn]> = vec![ResultColumn::new("n", ColumnType::untyped_text("text"))].into(); + let mut runs = Replies::default(); + runs.always(FakeRun::completed(vec![ + ResultEvent::ResultSetStarted { columns }, + ResultEvent::Rows(vec![vec![Value::Int(1)], vec![Value::Int(2)]]), + ResultEvent::ResultSetFinished { + retained: 2, + total_rows: Some(2), + truncation: Truncation::Complete, + }, + ])); + Arc::new(FakeEditorSession::new(runs)) +} + +fn export_results(test: &TestWindow) { + WidgetExt::activate_action(&test.window, "win.export-results", None).expect("win.export-results"); +} + +fn wait_for_export_dialog(test: &TestWindow) { + wait_until(WAIT, || test.window.visible_dialog().is_some()).expect("the export dialog"); + let dialog = test.window.visible_dialog().expect("the export dialog"); + assert_eq!(dialog.title(), "Export Rows"); +} + +#[gtk4::test] +fn export_results_offers_the_result_an_editor_shows() { + let session = two_rows(); + let test = TestWindow::connected_with("ExportResultsEditor", |script| { + script.open_editor_session.always(FakeReply::ok(session)); + }); + test.run_in_new_editor_tab(); + + export_results(&test); + + wait_for_export_dialog(&test); +} + +#[gtk4::test] +fn export_results_offers_the_rows_a_table_tab_shows() { + use crate::ui::browse_tab::BrowseTab; + let test = TestWindow::connected_with("ExportResultsTable", |script| *script = served_table()); + let page = test + .workspace() + .with_model(|model| { + model.open_tab(super::super::tab_spec::TabSpec::Table { + table: tablepro_core::meta::TableRef::new(Some("public".to_owned()), "customers"), + page: super::super::tab_spec::TablePage::first(1_000), + filter: crate::ui::browse_tab::TableFilter::Remembered, + }) + }) + .flatten() + .expect("a table tab"); + let tab = page.child().downcast::().expect("a browse tab"); + wait_until(WAIT, || tab.grid().row_count() > 0).expect("the tab's rows"); + + export_results(&test); + + wait_for_export_dialog(&test); +} + +#[gtk4::test] +fn export_results_says_when_the_tab_in_front_has_no_rows() { + let test = TestWindow::connected("ExportResultsNothing"); + WidgetExt::activate_action(&test.window, "win.new-editor-tab", None).expect("win.new-editor-tab"); + wait_until(WAIT, || editors(&test.window).len() == 1).expect("an editor tab that has not run"); + let overlay = shell_of(&test.workspace()).toast_overlay(); + + export_results(&test); + + wait_until(WAIT, || { + descendants(&overlay) + .iter() + .filter_map(|widget| widget.downcast_ref::()) + .any(|label| label.label() == "Nothing to export") + }) + .expect("the toast says there is nothing to export"); + assert_eq!(test.window.visible_dialog(), None, "a dialog opened with no rows"); +} + /// A session that comes forward with a tab it opened while handling /// its own input still gives the overview its new tab. #[gtk4::test] diff --git a/linux/crates/app/src/ui/workspace/workspace_actions.rs b/linux/crates/app/src/ui/workspace/workspace_actions.rs index caf79077e7..6314971252 100644 --- a/linux/crates/app/src/ui/workspace/workspace_actions.rs +++ b/linux/crates/app/src/ui/workspace/workspace_actions.rs @@ -10,6 +10,7 @@ use gtk4::prelude::*; use gtk4::subclass::prelude::*; use libadwaita as adw; +use super::workspace_tabs::ActiveTabCommand; use super::{TransferInput, WorkspaceInput, WorkspaceShell}; use crate::runtime::DeliverInput; use crate::services::history_availability::HistoryAvailability; @@ -22,7 +23,7 @@ type ActionInput = fn() -> WorkspaceInput; /// they are not left greyed out with no reason. const HISTORY_ACTIONS: [&str; 2] = ["show-history", "show-saved-queries"]; -const ENTRIES: [(&str, ActionInput); 8] = [ +const ENTRIES: [(&str, ActionInput); 9] = [ ("new-editor-tab", || WorkspaceInput::NewEditorTab), (EXPORT_DATABASE, || { WorkspaceInput::Transfer(TransferInput::ExportDatabase) @@ -31,7 +32,10 @@ const ENTRIES: [(&str, ActionInput); 8] = [ WorkspaceInput::Transfer(TransferInput::ImportSqlFile) }), ("refresh-tables", || WorkspaceInput::RefreshCatalog), - ("save-query", || WorkspaceInput::SaveActiveQuery), + ("save-query", || WorkspaceInput::ActiveTab(ActiveTabCommand::SaveQuery)), + ("export-results", || { + WorkspaceInput::ActiveTab(ActiveTabCommand::ExportResults) + }), ("show-history", || WorkspaceInput::ShowHistory), ("show-saved-queries", || WorkspaceInput::ShowSavedQueries), (super::health_banner::REVIEW_HOST_KEY, || WorkspaceInput::ReviewHostKey), @@ -209,10 +213,17 @@ mod tests { install(&window, &shell); + assert!( + window + .lookup_action("export-results") + .is_some_and(|action| action.is_enabled()), + "Export Results is not on the window, or greyed out" + ); // With no session in front the action has nowhere to go. WidgetExt::activate_action(&*window, "win.new-editor-tab", None).expect("win resolves to the window's own map"); remove(&window); assert!(window.lookup_action("new-editor-tab").is_none()); + assert!(window.lookup_action("export-results").is_none()); assert!(window.lookup_action(REOPEN_CLOSED_TAB).is_none()); assert!(window.lookup_action(CLOSE_ALL_TABS).is_none()); assert!(window.lookup_action(SWITCH_RECENT_TAB).is_none()); diff --git a/linux/crates/app/src/ui/workspace/workspace_tabs.rs b/linux/crates/app/src/ui/workspace/workspace_tabs.rs index e114c58452..fdf824331e 100644 --- a/linux/crates/app/src/ui/workspace/workspace_tabs.rs +++ b/linux/crates/app/src/ui/workspace/workspace_tabs.rs @@ -13,7 +13,7 @@ use tablepro_session::{Session, SessionKey}; use tablepro_storage::{DraftId, DraftScope}; use uuid::Uuid; -use crate::ui::browse_tab::{BrowseTabInput, PagingInput, SaveInput, TableFilter}; +use crate::ui::browse_tab::{BrowseTabInput, GridInput, PagingInput, SaveInput, TableFilter}; use crate::ui::editor::SqlEditor; use crate::ui::structure_tab::StructureMode; use crate::workspace::{ @@ -671,6 +671,41 @@ impl WorkspaceModel { _ => None, } } + + pub(super) fn on_active_tab_command(&self, command: ActiveTabCommand) { + match command { + ActiveTabCommand::SaveQuery => self.on_save_active_query(), + ActiveTabCommand::ExportResults => self.export_active_results(), + } + } + + fn export_active_results(&self) { + let offered = self + .selected_workspace_tab_id() + .is_some_and(|id| self.offer_shown_rows(id)); + if !offered { + self.show_toast(&crate::i18n::gettext("Nothing to export")); + } + } + + fn offer_shown_rows(&self, id: Uuid) -> bool { + match self.workspace_tabs.borrow().get(&id) { + // A table tab says itself when it has nothing to export. + Some(WorkspaceTab::Table(slot)) => { + slot.browse.send(GridInput::Export); + true + } + Some(WorkspaceTab::Editor(slot)) => slot.editor.export_shown_result(), + Some(WorkspaceTab::Key(slot)) => slot.tab.export_shown(), + Some(WorkspaceTab::Structure(_) | WorkspaceTab::Diagram(_)) | None => false, + } + } +} + +#[derive(Debug)] +pub(crate) enum ActiveTabCommand { + SaveQuery, + ExportResults, } /// The tab view's own signals, as the workspace's inputs. The view is diff --git a/linux/data/resources/shortcuts-dialog.ui b/linux/data/resources/shortcuts-dialog.ui index a65a47d81f..0ee1676eb7 100644 --- a/linux/data/resources/shortcuts-dialog.ui +++ b/linux/data/resources/shortcuts-dialog.ui @@ -33,6 +33,12 @@ win.show-history + + + Export Results + win.export-results + + Show or Hide the Sidebar diff --git a/linux/data/resources/ui/sql-editor.ui b/linux/data/resources/ui/sql-editor.ui index 915c431572..8e497c885c 100644 --- a/linux/data/resources/ui/sql-editor.ui +++ b/linux/data/resources/ui/sql-editor.ui @@ -26,6 +26,10 @@ Save _Query… win.save-query + + E_xport Results… + win.export-results + Forma_t editor.format diff --git a/linux/po/vi.po b/linux/po/vi.po index fecc85ec88..10d354ce03 100644 --- a/linux/po/vi.po +++ b/linux/po/vi.po @@ -6242,6 +6242,14 @@ msgstr "Lư_u thành…" msgid "Save _Query…" msgstr "Lưu t_ruy vấn…" +#: data/resources/ui/sql-editor.ui +msgid "E_xport Results…" +msgstr "_Xuất kết quả…" + +#: data/resources/shortcuts-dialog.ui +msgid "Export Results" +msgstr "Xuất kết quả" + #: data/resources/ui/sql-editor.ui msgid "_Find…" msgstr "_Tìm…" From 397941e55163f364d08dd123f0665baf2f460645 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 6 Nov 2026 11:43:24 +0700 Subject: [PATCH 2/3] docs: add changelog fragments for #4066 --- apple/changelog.d/4066.added.md | 1 + apple/changelog.d/4066.fixed.1.md | 1 + apple/changelog.d/4066.fixed.md | 1 + linux/changelog.d/4066.added.md | 1 + 4 files changed, 4 insertions(+) create mode 100644 apple/changelog.d/4066.added.md create mode 100644 apple/changelog.d/4066.fixed.1.md create mode 100644 apple/changelog.d/4066.fixed.md create mode 100644 linux/changelog.d/4066.added.md diff --git a/apple/changelog.d/4066.added.md b/apple/changelog.d/4066.added.md new file mode 100644 index 0000000000..62c4302825 --- /dev/null +++ b/apple/changelog.d/4066.added.md @@ -0,0 +1 @@ +Export Results… has a keyboard shortcut, ⌥⇧⌘E, which you can change in Settings > Keyboard. diff --git a/apple/changelog.d/4066.fixed.1.md b/apple/changelog.d/4066.fixed.1.md new file mode 100644 index 0000000000..566e3b1dfd --- /dev/null +++ b/apple/changelog.d/4066.fixed.1.md @@ -0,0 +1 @@ +The Restore Previous Values toolbar button ignored a shortcut assigned to it in Settings > Keyboard. diff --git a/apple/changelog.d/4066.fixed.md b/apple/changelog.d/4066.fixed.md new file mode 100644 index 0000000000..7532936e0b --- /dev/null +++ b/apple/changelog.d/4066.fixed.md @@ -0,0 +1 @@ +A database or schema's Export… in the sidebar showed ⇧⌘E, which exports the selected tables instead. diff --git a/linux/changelog.d/4066.added.md b/linux/changelog.d/4066.added.md new file mode 100644 index 0000000000..5102e72822 --- /dev/null +++ b/linux/changelog.d/4066.added.md @@ -0,0 +1 @@ +Ctrl+Shift+E exports the results or rows shown in the tab in front. From 078eaf01cbf14b6e7f4d2b505b7ddaef5bbcf7a6 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 6 Nov 2026 12:08:38 +0700 Subject: [PATCH 3/3] fix(i18n): translate the Export Tables and Export Results shortcut names --- .../TablePro/Resources/Localizable.xcstrings | 80 ++++++++++++++++++- 1 file changed, 77 insertions(+), 3 deletions(-) diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index f693169ae1..8ecb808b3c 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -78282,7 +78282,44 @@ } }, "Export Results" : { - + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exporter les résultats" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "결과 내보내기" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sonuçları Dışa Aktar" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xuất kết quả" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "导出结果" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "匯出結果" + } + } + } }, "Export Results…" : { "localizations" : { @@ -78307,7 +78344,7 @@ "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Export Kết Quả…" + "value" : "Xuất kết quả…" } }, "zh-Hans" : { @@ -78365,7 +78402,44 @@ } }, "Export Tables" : { - + "localizations" : { + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exporter les tables" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "테이블 내보내기" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tabloları Dışa Aktar" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xuất bảng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "导出表" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "匯出資料表" + } + } + } }, "Export Tables…" : { "localizations" : {