From 7872e66178826f1f37b5216d873aae15e696fe04 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 26 Aug 2026 12:12:21 +0300 Subject: [PATCH] feat(macos): implement native PlatformMenuBar for macOS system menu (#612) --- .../macos/querya_platform_menu_bar.dart | 344 ++++++++++++++++++ lib/features/main_screen/main_screen.dart | 39 +- .../macos/querya_platform_menu_bar_test.dart | 193 ++++++++++ 3 files changed, 570 insertions(+), 6 deletions(-) create mode 100644 lib/features/macos/querya_platform_menu_bar.dart create mode 100644 test/features/macos/querya_platform_menu_bar_test.dart diff --git a/lib/features/macos/querya_platform_menu_bar.dart b/lib/features/macos/querya_platform_menu_bar.dart new file mode 100644 index 0000000..5b77e42 --- /dev/null +++ b/lib/features/macos/querya_platform_menu_bar.dart @@ -0,0 +1,344 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../settings/preferences_dialog.dart'; + +/// Native macOS top menu bar integration via Flutter's [PlatformMenuBar]. +/// +/// Configures system-level application, file, edit, view, window, and help menus +/// with macOS-standard keyboard shortcuts (Command / Meta modifier). +class QueryaPlatformMenuBar extends StatelessWidget { + const QueryaPlatformMenuBar({ + super.key, + required this.child, + this.onNewConnection, + this.onNewQueryTab, + this.onOpenSqlScript, + this.onSaveQuery, + this.onExecuteQuery, + this.onCloseTab, + this.onToggleSidebar, + this.onOpenPreferences, + this.onOpenWelcomeTour, + this.onGoHome, + this.onFocusFilterBar, + this.onToggleGroupings, + }); + + final Widget child; + final VoidCallback? onNewConnection; + final VoidCallback? onNewQueryTab; + final VoidCallback? onOpenSqlScript; + final VoidCallback? onSaveQuery; + final VoidCallback? onExecuteQuery; + final VoidCallback? onCloseTab; + final VoidCallback? onToggleSidebar; + final VoidCallback? onOpenPreferences; + final VoidCallback? onOpenWelcomeTour; + final VoidCallback? onGoHome; + final VoidCallback? onFocusFilterBar; + final VoidCallback? onToggleGroupings; + + static const String repoUrl = 'https://github.com/QueryaHub/Querya-Desktop'; + static const String issuesUrl = + 'https://github.com/QueryaHub/Querya-Desktop/issues/new'; + + @override + Widget build(BuildContext context) { + return PlatformMenuBar( + menus: [ + // 1. Application Menu (macOS App Name) + PlatformMenu( + label: 'Querya', + menus: [ + if (PlatformProvidedMenuItem.hasMenu( + PlatformProvidedMenuItemType.about)) + const PlatformProvidedMenuItem( + type: PlatformProvidedMenuItemType.about) + else + PlatformMenuItem( + label: 'About Querya', + onSelected: onOpenWelcomeTour, + ), + PlatformMenuItemGroup( + members: [ + PlatformMenuItem( + label: 'Preferences...', + shortcut: const SingleActivator( + LogicalKeyboardKey.comma, + meta: true, + ), + onSelected: onOpenPreferences ?? + () => showPreferencesDialog(context), + ), + ], + ), + if (PlatformProvidedMenuItem.hasMenu( + PlatformProvidedMenuItemType.hide)) + const PlatformProvidedMenuItem( + type: PlatformProvidedMenuItemType.hide), + if (PlatformProvidedMenuItem.hasMenu( + PlatformProvidedMenuItemType.hideOtherApplications)) + const PlatformProvidedMenuItem( + type: PlatformProvidedMenuItemType.hideOtherApplications), + if (PlatformProvidedMenuItem.hasMenu( + PlatformProvidedMenuItemType.showAllApplications)) + const PlatformProvidedMenuItem( + type: PlatformProvidedMenuItemType.showAllApplications), + if (PlatformProvidedMenuItem.hasMenu( + PlatformProvidedMenuItemType.quit)) + const PlatformProvidedMenuItem( + type: PlatformProvidedMenuItemType.quit), + ], + ), + + // 2. File Menu + PlatformMenu( + label: 'File', + menus: [ + PlatformMenuItem( + label: 'New Connection...', + shortcut: const SingleActivator( + LogicalKeyboardKey.keyN, + meta: true, + ), + onSelected: onNewConnection, + ), + PlatformMenuItem( + label: 'New Query Tab', + shortcut: const SingleActivator( + LogicalKeyboardKey.keyN, + meta: true, + shift: true, + ), + onSelected: onNewQueryTab, + ), + PlatformMenuItem( + label: 'Open SQL Script...', + shortcut: const SingleActivator( + LogicalKeyboardKey.keyO, + meta: true, + ), + onSelected: onOpenSqlScript, + ), + PlatformMenuItem( + label: 'Save Query', + shortcut: const SingleActivator( + LogicalKeyboardKey.keyS, + meta: true, + ), + onSelected: onSaveQuery, + ), + if (onExecuteQuery != null) + PlatformMenuItem( + label: 'Run Query / Script', + shortcut: const SingleActivator( + LogicalKeyboardKey.enter, + meta: true, + ), + onSelected: onExecuteQuery, + ), + if (onCloseTab != null) + PlatformMenuItem( + label: 'Close Tab', + shortcut: const SingleActivator( + LogicalKeyboardKey.keyW, + meta: true, + ), + onSelected: onCloseTab, + ), + ], + ), + + // 3. Edit Menu + PlatformMenu( + label: 'Edit', + menus: [ + PlatformMenuItem( + label: 'Undo', + shortcut: const SingleActivator( + LogicalKeyboardKey.keyZ, + meta: true, + ), + onSelected: () { + final focusCtx = + FocusManager.instance.primaryFocus?.context ?? context; + Actions.maybeInvoke( + focusCtx, + const UndoTextIntent(SelectionChangedCause.keyboard), + ); + }, + ), + PlatformMenuItem( + label: 'Redo', + shortcut: const SingleActivator( + LogicalKeyboardKey.keyZ, + meta: true, + shift: true, + ), + onSelected: () { + final focusCtx = + FocusManager.instance.primaryFocus?.context ?? context; + Actions.maybeInvoke( + focusCtx, + const RedoTextIntent(SelectionChangedCause.keyboard), + ); + }, + ), + PlatformMenuItemGroup( + members: [ + PlatformMenuItem( + label: 'Cut', + shortcut: const SingleActivator( + LogicalKeyboardKey.keyX, + meta: true, + ), + onSelected: () { + final focusCtx = + FocusManager.instance.primaryFocus?.context ?? context; + Actions.maybeInvoke( + focusCtx, + const CopySelectionTextIntent.cut( + SelectionChangedCause.keyboard), + ); + }, + ), + const PlatformMenuItem( + label: 'Copy', + shortcut: SingleActivator( + LogicalKeyboardKey.keyC, + meta: true, + ), + ), + PlatformMenuItem( + label: 'Paste', + shortcut: const SingleActivator( + LogicalKeyboardKey.keyV, + meta: true, + ), + onSelected: () { + final focusCtx = + FocusManager.instance.primaryFocus?.context ?? context; + Actions.maybeInvoke( + focusCtx, + const PasteTextIntent(SelectionChangedCause.keyboard), + ); + }, + ), + PlatformMenuItem( + label: 'Select All', + shortcut: const SingleActivator( + LogicalKeyboardKey.keyA, + meta: true, + ), + onSelected: () { + final focusCtx = + FocusManager.instance.primaryFocus?.context ?? context; + Actions.maybeInvoke( + focusCtx, + const SelectAllTextIntent(SelectionChangedCause.keyboard), + ); + }, + ), + ], + ), + ], + ), + + // 4. View Menu + PlatformMenu( + label: 'View', + menus: [ + PlatformMenuItem( + label: 'Toggle Sidebar', + shortcut: const SingleActivator( + LogicalKeyboardKey.keyB, + meta: true, + ), + onSelected: onToggleSidebar, + ), + if (onFocusFilterBar != null) + PlatformMenuItem( + label: 'Compound Filter Bar', + shortcut: const SingleActivator( + LogicalKeyboardKey.keyF, + meta: true, + ), + onSelected: onFocusFilterBar, + ), + if (onToggleGroupings != null) + PlatformMenuItem( + label: 'Groupings & Pivot Drawer', + shortcut: const SingleActivator( + LogicalKeyboardKey.keyG, + meta: true, + ), + onSelected: onToggleGroupings, + ), + PlatformMenuItem( + label: 'Return to Start Screen', + shortcut: const SingleActivator( + LogicalKeyboardKey.digit0, + meta: true, + shift: true, + ), + onSelected: onGoHome, + ), + if (PlatformProvidedMenuItem.hasMenu( + PlatformProvidedMenuItemType.toggleFullScreen)) + const PlatformProvidedMenuItem( + type: PlatformProvidedMenuItemType.toggleFullScreen), + ], + ), + + // 5. Window Menu + PlatformMenu( + label: 'Window', + menus: [ + if (PlatformProvidedMenuItem.hasMenu( + PlatformProvidedMenuItemType.minimizeWindow)) + const PlatformProvidedMenuItem( + type: PlatformProvidedMenuItemType.minimizeWindow), + if (PlatformProvidedMenuItem.hasMenu( + PlatformProvidedMenuItemType.zoomWindow)) + const PlatformProvidedMenuItem( + type: PlatformProvidedMenuItemType.zoomWindow), + ], + ), + + // 6. Help Menu + PlatformMenu( + label: 'Help', + menus: [ + PlatformMenuItem( + label: 'Welcome Tour & Guide', + shortcut: const SingleActivator(LogicalKeyboardKey.f1), + onSelected: onOpenWelcomeTour, + ), + PlatformMenuItem( + label: 'Keyboard Shortcuts Cheat Sheet', + shortcut: const SingleActivator( + LogicalKeyboardKey.keyH, + meta: true, + shift: true, + ), + onSelected: onOpenWelcomeTour, + ), + PlatformMenuItem( + label: 'Querya GitHub Repository', + onSelected: () => unawaited(launchUrl(Uri.parse(repoUrl))), + ), + PlatformMenuItem( + label: 'Report an Issue...', + onSelected: () => unawaited(launchUrl(Uri.parse(issuesUrl))), + ), + ], + ), + ], + child: child, + ); + } +} diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index d13196c..0b83130 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -23,10 +23,12 @@ import 'package:querya_desktop/features/connections/connections_panel.dart'; import 'package:querya_desktop/features/connections/sqlite_connection_form.dart'; import 'package:querya_desktop/features/main_screen/connections_panel_width_persist.dart'; import 'package:querya_desktop/features/main_screen/querya_window_title_bar.dart'; -import 'package:querya_desktop/features/onboarding/welcome_tour_dialog.dart'; -import 'package:querya_desktop/shared/widgets/widgets.dart'; +import 'package:querya_desktop/features/macos/querya_platform_menu_bar.dart'; import 'package:querya_desktop/features/mysql/mysql_object_kind.dart'; +import 'package:querya_desktop/features/onboarding/welcome_tour_dialog.dart'; import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart'; +import 'package:querya_desktop/features/settings/preferences_dialog.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'main_screen_workspace_state.dart'; import 'workspace_panel.dart'; @@ -469,10 +471,34 @@ class _MainScreenState extends State { shift: true, ): _onGoHome, }, - child: SqlEditorGlobalActions( - activeConnection: workspace.activeConnection, - onOpenSqlWorkspace: _openSqlWorkspaceForConnection, - child: material.Scaffold( + child: QueryaPlatformMenuBar( + onNewConnection: () => + unawaited(_onNewDatabaseConnectionFromMenu()), + onNewQueryTab: () { + final ctx = + FocusManager.instance.primaryFocus?.context ?? context; + Actions.maybeInvoke(ctx, const NewSqlIntent()); + }, + onOpenSqlScript: () { + final ctx = + FocusManager.instance.primaryFocus?.context ?? context; + Actions.maybeInvoke(ctx, const OpenSqlIntent()); + }, + onSaveQuery: () { + final ctx = + FocusManager.instance.primaryFocus?.context ?? context; + Actions.maybeInvoke(ctx, const SaveSqlIntent()); + }, + onExecuteQuery: () => + SqlEditorCommandBridge.instance.invokeExecute(), + onToggleSidebar: () => _splitKey.currentState?.toggleSidebar(), + onOpenPreferences: () => showPreferencesDialog(context), + onOpenWelcomeTour: () => _onOpenWelcomeTour(), + onGoHome: _onGoHome, + child: SqlEditorGlobalActions( + activeConnection: workspace.activeConnection, + onOpenSqlWorkspace: _openSqlWorkspaceForConnection, + child: material.Scaffold( backgroundColor: wb.canvas, body: WindowBorder( color: wb.borderSubtle.withValues(alpha: 0.35), @@ -565,6 +591,7 @@ class _MainScreenState extends State { ), ), ), + ), ), ), ); diff --git a/test/features/macos/querya_platform_menu_bar_test.dart b/test/features/macos/querya_platform_menu_bar_test.dart new file mode 100644 index 0000000..6212d22 --- /dev/null +++ b/test/features/macos/querya_platform_menu_bar_test.dart @@ -0,0 +1,193 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/macos/querya_platform_menu_bar.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('QueryaPlatformMenuBar', () { + testWidgets('mounts PlatformMenuBar with standard macOS menu hierarchy', + (tester) async { + var newConnectionInvoked = false; + var newQueryTabInvoked = false; + var openSqlInvoked = false; + var saveQueryInvoked = false; + var executeQueryInvoked = false; + var toggleSidebarInvoked = false; + var preferencesInvoked = false; + var tourInvoked = false; + var goHomeInvoked = false; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: QueryaPlatformMenuBar( + onNewConnection: () => newConnectionInvoked = true, + onNewQueryTab: () => newQueryTabInvoked = true, + onOpenSqlScript: () => openSqlInvoked = true, + onSaveQuery: () => saveQueryInvoked = true, + onExecuteQuery: () => executeQueryInvoked = true, + onToggleSidebar: () => toggleSidebarInvoked = true, + onOpenPreferences: () => preferencesInvoked = true, + onOpenWelcomeTour: () => tourInvoked = true, + onGoHome: () => goHomeInvoked = true, + child: const material.Text('Main App Content'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Main App Content'), findsOneWidget); + + final platformMenuBarFinder = find.byType(PlatformMenuBar); + expect(platformMenuBarFinder, findsOneWidget); + + final platformMenuBar = + tester.widget(platformMenuBarFinder); + expect(platformMenuBar.menus.length, 6); + + // 1. App Menu (Querya) + final appMenu = platformMenuBar.menus[0] as PlatformMenu; + expect(appMenu.label, 'Querya'); + + // 2. File Menu + final fileMenu = platformMenuBar.menus[1] as PlatformMenu; + expect(fileMenu.label, 'File'); + final fileItems = fileMenu.menus.whereType().toList(); + expect(fileItems.any((m) => m.label == 'New Connection...'), isTrue); + expect(fileItems.any((m) => m.label == 'New Query Tab'), isTrue); + expect(fileItems.any((m) => m.label == 'Open SQL Script...'), isTrue); + expect(fileItems.any((m) => m.label == 'Save Query'), isTrue); + expect(fileItems.any((m) => m.label == 'Run Query / Script'), isTrue); + + // 3. Edit Menu + final editMenu = platformMenuBar.menus[2] as PlatformMenu; + expect(editMenu.label, 'Edit'); + + // 4. View Menu + final viewMenu = platformMenuBar.menus[3] as PlatformMenu; + expect(viewMenu.label, 'View'); + final viewItems = viewMenu.menus.whereType().toList(); + expect(viewItems.any((m) => m.label == 'Toggle Sidebar'), isTrue); + expect(viewItems.any((m) => m.label == 'Return to Start Screen'), isTrue); + + // 5. Window Menu + final windowMenu = platformMenuBar.menus[4] as PlatformMenu; + expect(windowMenu.label, 'Window'); + + // 6. Help Menu + final helpMenu = platformMenuBar.menus[5] as PlatformMenu; + expect(helpMenu.label, 'Help'); + final helpItems = helpMenu.menus.whereType().toList(); + expect(helpItems.any((m) => m.label == 'Welcome Tour & Guide'), isTrue); + expect(helpItems.any((m) => m.label == 'Keyboard Shortcuts Cheat Sheet'), + isTrue); + + // Invoke callbacks directly + fileItems + .firstWhere((m) => m.label == 'New Connection...') + .onSelected + ?.call(); + expect(newConnectionInvoked, isTrue); + + fileItems + .firstWhere((m) => m.label == 'New Query Tab') + .onSelected + ?.call(); + expect(newQueryTabInvoked, isTrue); + + fileItems + .firstWhere((m) => m.label == 'Open SQL Script...') + .onSelected + ?.call(); + expect(openSqlInvoked, isTrue); + + fileItems.firstWhere((m) => m.label == 'Save Query').onSelected?.call(); + expect(saveQueryInvoked, isTrue); + + fileItems + .firstWhere((m) => m.label == 'Run Query / Script') + .onSelected + ?.call(); + expect(executeQueryInvoked, isTrue); + + viewItems + .firstWhere((m) => m.label == 'Toggle Sidebar') + .onSelected + ?.call(); + expect(toggleSidebarInvoked, isTrue); + + viewItems + .firstWhere((m) => m.label == 'Return to Start Screen') + .onSelected + ?.call(); + expect(goHomeInvoked, isTrue); + + helpItems + .firstWhere((m) => m.label == 'Welcome Tour & Guide') + .onSelected + ?.call(); + expect(tourInvoked, isTrue); + + final prefGroup = appMenu.menus.whereType().first; + final prefItem = prefGroup.members.whereType().first; + prefItem.onSelected?.call(); + expect(preferencesInvoked, isTrue); + }); + + testWidgets('shortcuts verify Command (meta: true) keybindings', + (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const material.Scaffold( + body: QueryaPlatformMenuBar( + child: material.Text('Shortcuts Test Content'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final platformMenuBar = tester.widget( + find.byType(PlatformMenuBar), + ); + + final fileMenu = platformMenuBar.menus[1] as PlatformMenu; + final fileItems = fileMenu.menus.whereType().toList(); + + final newConnItem = + fileItems.firstWhere((m) => m.label == 'New Connection...'); + expect(newConnItem.shortcut, + const SingleActivator(LogicalKeyboardKey.keyN, meta: true)); + + final newTabItem = + fileItems.firstWhere((m) => m.label == 'New Query Tab'); + expect( + newTabItem.shortcut, + const SingleActivator(LogicalKeyboardKey.keyN, + meta: true, shift: true)); + + final openSqlItem = + fileItems.firstWhere((m) => m.label == 'Open SQL Script...'); + expect(openSqlItem.shortcut, + const SingleActivator(LogicalKeyboardKey.keyO, meta: true)); + + final saveQueryItem = + fileItems.firstWhere((m) => m.label == 'Save Query'); + expect(saveQueryItem.shortcut, + const SingleActivator(LogicalKeyboardKey.keyS, meta: true)); + + final viewMenu = platformMenuBar.menus[3] as PlatformMenu; + final viewItems = viewMenu.menus.whereType().toList(); + final sidebarItem = + viewItems.firstWhere((m) => m.label == 'Toggle Sidebar'); + expect(sidebarItem.shortcut, + const SingleActivator(LogicalKeyboardKey.keyB, meta: true)); + }); + }); +}