add Zed-style application menu bar: File / View / Help (T-48)
A custom in-window menu bar in the hat (no native menu, D-7), built from the command registry so it stays in sync and satisfies D-6 parity. - Menu model + hybrid resolver (menu_model.dart): a curated File/View/Help tree where a MenuAutoFill node sweeps in unplaced view.* commands; titles + keybindings come from the registry/keymap; unregistered or enabledWhen-false items render disabled (greyed), never hidden. - Widgets: MenuBar row in the hat (chrome tokens), anchored MenuDropdown overlay (dropdown tokens), two-column MenuItemRow with inline keybinding. - Full keyboard: Alt+mnemonic opens (hook in _RootShell._onKey), arrows navigate, Enter activates, Esc closes, Left/Right switch menus. - Commands: file.openFolder / file.newWindow / file.closeWorkspace / help.about, registered by MenuBarExtension(services:). File logic lifted out of the project switcher into FileActions (one source of truth; the switcher now dispatches the commands). Ctrl+O / Ctrl+Shift+N are now real keybindings in default.yaml. - Help → About: version/commit/date/repo from build-info + the bundled dependency licenses parsed from assets/licenses.yaml. Edit/Selection menus are deferred to T-271/T-272 (need focused-surface command routing). Tests: resolver + controller + licenses parse (pure); menu-bar widget (open/close/execute/disabled/Esc/arrow/Enter/Left-Right); FileActions + Open dialog; app-level Alt+F, non-repo dialog, and closeWorkspace. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
/// Widget tests for the application menu bar (T-48): open/close, command
|
||||
/// execution, disabled rendering, and keyboard navigation (arrows, Enter, Esc,
|
||||
/// Left/Right between menus).
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/menubar/menubar.dart';
|
||||
import 'package:clide/clide.dart' show IpcResponse, clideVersion;
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
|
||||
void main() {
|
||||
late KernelFixture f;
|
||||
late MenuBarController controller;
|
||||
|
||||
setUp(() async {
|
||||
f = await KernelFixture.create();
|
||||
f.services.extensions.register(MenuBarExtension(services: f.services));
|
||||
await f.services.extensions.activateAll();
|
||||
// A registered View command so the View menu has an enabled item.
|
||||
f.services.commands.register(CommandContribution(
|
||||
id: 'view.zoomIn',
|
||||
command: 'view.zoomIn',
|
||||
title: 'View: Zoom In',
|
||||
run: (_) async => IpcResponse.ok(id: '', data: const {}),
|
||||
));
|
||||
controller = MenuBarController();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
controller.dispose();
|
||||
await f.dispose();
|
||||
});
|
||||
|
||||
Widget harness() => Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: ClideKernel(
|
||||
services: f.services,
|
||||
child: ClideTheme(
|
||||
controller: f.services.theme,
|
||||
child: MediaQuery(
|
||||
data: const MediaQueryData(),
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: SizedBox(
|
||||
width: 900,
|
||||
height: 600,
|
||||
child: DialogHost(
|
||||
router: f.services.dialog,
|
||||
child: Overlay(
|
||||
initialEntries: [
|
||||
OverlayEntry(builder: (_) => Align(alignment: Alignment.topLeft, child: MenuBar(controller: controller))),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> openMenu(WidgetTester tester, String title) async {
|
||||
await tester.tap(find.text(title));
|
||||
await tester.pump(); // toggle → overlay insert
|
||||
await tester.pump(); // dropdown post-frame focus
|
||||
}
|
||||
|
||||
testWidgets('renders File / View / Help buttons', (tester) async {
|
||||
await tester.pumpWidget(harness());
|
||||
await tester.pump();
|
||||
expect(find.text('File'), findsOneWidget);
|
||||
expect(find.text('View'), findsOneWidget);
|
||||
expect(find.text('Help'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('opening File shows its items, incl. a disabled Close Project', (tester) async {
|
||||
await tester.pumpWidget(harness());
|
||||
await tester.pump();
|
||||
await openMenu(tester, 'File');
|
||||
expect(find.text('Open Folder…'), findsOneWidget);
|
||||
expect(find.text('New Window'), findsOneWidget);
|
||||
expect(find.text('Close Project'), findsOneWidget); // present but disabled (no project open)
|
||||
});
|
||||
|
||||
testWidgets('tapping the same top button toggles the menu closed', (tester) async {
|
||||
await tester.pumpWidget(harness());
|
||||
await tester.pump();
|
||||
await openMenu(tester, 'File');
|
||||
expect(find.text('Open Folder…'), findsOneWidget);
|
||||
await tester.tap(find.text('File'));
|
||||
await tester.pump();
|
||||
expect(find.text('Open Folder…'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Esc closes an open menu', (tester) async {
|
||||
await tester.pumpWidget(harness());
|
||||
await tester.pump();
|
||||
await openMenu(tester, 'File');
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||
await tester.pump();
|
||||
expect(find.text('Open Folder…'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('clicking Help → About runs the command and opens the About dialog', (tester) async {
|
||||
await tester.pumpWidget(harness());
|
||||
await tester.pump();
|
||||
await openMenu(tester, 'Help');
|
||||
await tester.tap(find.text('About clide'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
// The dialog renders its build-info synchronously (licenses load async).
|
||||
expect(find.text(clideVersion), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('keyboard: Down highlights, Enter activates (Help → About)', (tester) async {
|
||||
await tester.pumpWidget(harness());
|
||||
await tester.pump();
|
||||
await openMenu(tester, 'Help');
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||
await tester.pump();
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(find.text(clideVersion), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('keyboard: Right/Left switch between top menus', (tester) async {
|
||||
await tester.pumpWidget(harness());
|
||||
await tester.pump();
|
||||
await openMenu(tester, 'File');
|
||||
expect(find.text('Open Folder…'), findsOneWidget);
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); // File → View
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(find.text('Zoom In'), findsOneWidget);
|
||||
expect(find.text('Open Folder…'), findsNothing);
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); // View → File
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(find.text('Open Folder…'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user