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:
@@ -22,6 +22,7 @@ import 'dart:io';
|
||||
|
||||
import 'package:clide/app.dart';
|
||||
import 'package:clide/builtin/default_layout/default_layout.dart';
|
||||
import 'package:clide/builtin/menubar/menubar.dart';
|
||||
import 'package:clide/clide.dart' show clideName;
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
@@ -40,6 +41,9 @@ void main() {
|
||||
// The classic preset makes all four slots visible + sized, and registers
|
||||
// keybindings/commands so the keymap resolves real bindings.
|
||||
f.services.extensions.register(DefaultLayoutExtension());
|
||||
// The menu-bar extension owns the File/Help commands the hat menu and the
|
||||
// project switcher dispatch (T-48).
|
||||
f.services.extensions.register(MenuBarExtension(services: f.services));
|
||||
await f.services.extensions.activateAll();
|
||||
// Disposed LAST (LIFO) — after any per-test teardown unmounts the tree.
|
||||
addTearDown(() async => f.dispose());
|
||||
@@ -284,4 +288,50 @@ void main() {
|
||||
expect(find.text('Open project'), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('Open Folder on a non-repo path surfaces the "no git repo" dialog', (tester) async {
|
||||
final tmp = await Directory.systemTemp.createTemp('clide-not-a-repo-');
|
||||
addTearDown(() => tmp.delete(recursive: true));
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
const MethodChannel('clide/window'),
|
||||
(call) async => call.method == 'pickDirectory' ? tmp.path : null,
|
||||
);
|
||||
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(const MethodChannel('clide/window'), null));
|
||||
|
||||
await pumpApp(tester);
|
||||
await tester.tap(find.text(clideName)); // switcher (no project open)
|
||||
await tester.pump();
|
||||
await tester.runAsync(() async {
|
||||
await tester.tap(find.text('Open Local Project')); // picks tmp → not a repo
|
||||
// Let the (unawaited) command run pickDirectory + git rev-parse.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
});
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(find.text('No git repo found'), findsOneWidget);
|
||||
await tester.tap(find.text('OK'));
|
||||
await tester.pump();
|
||||
expect(find.text('No git repo found'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Alt+F opens the application File menu', (tester) async {
|
||||
await pumpApp(tester);
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyF);
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(find.text('Open Folder…'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('file.closeWorkspace command closes the active project', (tester) async {
|
||||
final repo = Directory.current.path;
|
||||
await tester.runAsync(() async => f.services.project.open(repo));
|
||||
expect(f.services.project.isOpen, isTrue);
|
||||
await pumpApp(tester);
|
||||
await tester.runAsync(() async => f.services.commands.execute('file.closeWorkspace'));
|
||||
await tester.pump();
|
||||
expect(f.services.project.isOpen, isFalse);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/// Tests for FileActions + the typed-path Open dialog (T-48). The open/close
|
||||
/// paths drive real `git` via project.open, so they run as plain async tests
|
||||
/// (no fake-async).
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/builtin/menubar/src/file_actions.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
|
||||
void main() {
|
||||
late KernelFixture f;
|
||||
|
||||
setUp(() async {
|
||||
f = await KernelFixture.create();
|
||||
// A landing tab so openPath's activateTab has a real target.
|
||||
f.services.panels.contribute(TabContribution(id: 'claude.primary', slot: Slots.workspace, title: 'Claude', build: (_) => const SizedBox()));
|
||||
});
|
||||
tearDown(() => f.dispose());
|
||||
|
||||
test('openPath opens a git repo and activates the landing tab', () async {
|
||||
final ok = await FileActions(f.services).openPath(Directory.current.path);
|
||||
expect(ok, isTrue);
|
||||
expect(f.services.project.isOpen, isTrue);
|
||||
expect(f.services.panels.activeTabIn(Slots.workspace), 'claude.primary');
|
||||
});
|
||||
|
||||
test('openPath returns false for a non-repo directory', () async {
|
||||
final tmp = await Directory.systemTemp.createTemp('clide-fa-');
|
||||
addTearDown(() => tmp.delete(recursive: true));
|
||||
expect(await FileActions(f.services).openPath(tmp.path), isFalse);
|
||||
});
|
||||
|
||||
test('closeWorkspace closes the active project', () async {
|
||||
final fa = FileActions(f.services);
|
||||
await fa.openPath(Directory.current.path);
|
||||
expect(f.services.project.isOpen, isTrue);
|
||||
fa.closeWorkspace();
|
||||
expect(f.services.project.isOpen, isFalse);
|
||||
});
|
||||
|
||||
Widget harness(Widget child) => 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: child),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
testWidgets('OpenFolderDialog submits the typed path via onOpen', (tester) async {
|
||||
String? opened;
|
||||
await tester.pumpWidget(harness(OpenFolderDialog(
|
||||
onOpen: (p) async => opened = p,
|
||||
onCancel: () {},
|
||||
)));
|
||||
await tester.enterText(find.byType(EditableText), '/some/repo');
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pump();
|
||||
expect(opened, '/some/repo');
|
||||
});
|
||||
|
||||
testWidgets('OpenFolderDialog surfaces an error when onOpen throws', (tester) async {
|
||||
await tester.pumpWidget(harness(OpenFolderDialog(
|
||||
onOpen: (_) async => throw StateError('not a repo'),
|
||||
onCancel: () {},
|
||||
)));
|
||||
await tester.enterText(find.byType(EditableText), '/bad');
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pump();
|
||||
expect(find.text('Not a git repository'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/// Pure parse tests for the About-dialog licenses manifest (T-48).
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/menubar/src/licenses_loader.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
test('parses self + dependency entries', () {
|
||||
const yaml = '''
|
||||
self:
|
||||
name: clide
|
||||
version: "2.1.0"
|
||||
license: MIT
|
||||
dependencies:
|
||||
- name: Foo
|
||||
version: "1.0"
|
||||
license: MIT
|
||||
- name: Bar
|
||||
version: "2.0"
|
||||
license: OFL-1.1
|
||||
''';
|
||||
final m = parseLicenses(yaml);
|
||||
expect(m.self.name, 'clide');
|
||||
expect(m.self.version, '2.1.0');
|
||||
expect(m.self.license, 'MIT');
|
||||
expect(m.dependencies, hasLength(2));
|
||||
expect(m.dependencies[0].name, 'Foo');
|
||||
expect(m.dependencies[1].license, 'OFL-1.1');
|
||||
});
|
||||
|
||||
test('missing fields degrade to a dash rather than throwing', () {
|
||||
final m = parseLicenses('dependencies:\n - name: X\n');
|
||||
expect(m.self.name, '—');
|
||||
expect(m.dependencies.single.version, '—');
|
||||
expect(m.dependencies.single.license, '—');
|
||||
});
|
||||
|
||||
test('empty document yields an empty manifest', () {
|
||||
final m = parseLicenses('');
|
||||
expect(m.self.name, '—');
|
||||
expect(m.dependencies, isEmpty);
|
||||
});
|
||||
|
||||
test('the bundled assets/licenses.yaml is real and non-trivial', () {
|
||||
final m = parseLicenses(_bundled);
|
||||
expect(m.self.name, 'clide');
|
||||
expect(m.dependencies, isNotEmpty);
|
||||
});
|
||||
}
|
||||
|
||||
// A trimmed copy of the real manifest shape, to assert parseLicenses handles
|
||||
// the comment-heavy, quoted-value document the app ships.
|
||||
const _bundled = '''
|
||||
schema_version: 1
|
||||
self:
|
||||
name: clide
|
||||
version: "2.1.0"
|
||||
license: MIT
|
||||
dependencies:
|
||||
- name: JetBrains Mono
|
||||
kind: font
|
||||
version: "2.304"
|
||||
license: OFL-1.1
|
||||
''';
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/// Resolver + controller tests for the application menu (T-48). Pure-ish: uses
|
||||
/// the kernel fixture for a real CommandRegistry but no widgets.
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/menubar/menubar.dart';
|
||||
import 'package:clide/builtin/menubar/src/menu_model.dart';
|
||||
import 'package:clide/clide.dart' show IpcResponse;
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
|
||||
void main() {
|
||||
late KernelFixture f;
|
||||
setUp(() async => f = await KernelFixture.create());
|
||||
tearDown(() => f.dispose());
|
||||
|
||||
void cmd(String id, {String? title, String? binding}) {
|
||||
f.services.commands.register(CommandContribution(
|
||||
id: id,
|
||||
command: id,
|
||||
title: title,
|
||||
defaultBinding: binding,
|
||||
run: (_) async => IpcResponse.ok(id: '', data: const {}),
|
||||
));
|
||||
}
|
||||
|
||||
group('resolveMenus', () {
|
||||
test('curated order: strips "Category:" titles, separators pass through, defaultBinding shows', () {
|
||||
cmd('a.one', title: 'A: One', binding: 'ctrl+1');
|
||||
final tree = [
|
||||
TopMenu(title: 'A', mnemonic: 0, nodes: const [
|
||||
MenuCommandItem('a.one'),
|
||||
MenuSeparator(),
|
||||
MenuCommandItem('a.missing', fallbackTitle: 'Missing'),
|
||||
]),
|
||||
];
|
||||
final items = resolveMenus(tree, f.services.commands, f.services).single.items;
|
||||
|
||||
expect(items, hasLength(3));
|
||||
final one = items[0] as ResolvedItem;
|
||||
expect(one.title, 'One'); // "A: One" → "One"
|
||||
expect(one.enabled, isTrue);
|
||||
expect(one.keybinding, 'Ctrl+1');
|
||||
expect(items[1], isA<ResolvedSeparator>());
|
||||
final missing = items[2] as ResolvedItem;
|
||||
expect(missing.title, 'Missing'); // fallback used (unregistered)
|
||||
expect(missing.enabled, isFalse); // unregistered → disabled
|
||||
expect(missing.keybinding, isNull);
|
||||
});
|
||||
|
||||
test('auto-fill appends unplaced prefixed commands sorted, excluding placed', () {
|
||||
cmd('view.zoomIn', title: 'View: Zoom In');
|
||||
cmd('view.beta', title: 'View: Beta');
|
||||
cmd('view.alpha', title: 'View: Alpha');
|
||||
final tree = [
|
||||
TopMenu(title: 'View', mnemonic: 0, nodes: const [
|
||||
MenuCommandItem('view.zoomIn'),
|
||||
MenuSeparator(),
|
||||
MenuAutoFill('view.'),
|
||||
]),
|
||||
];
|
||||
final items = resolveMenus(tree, f.services.commands, f.services).single.items.whereType<ResolvedItem>().toList();
|
||||
// zoomIn (placed) first; then auto-filled Alpha, Beta sorted by title;
|
||||
// zoomIn NOT duplicated by the auto-fill.
|
||||
expect(items.map((i) => i.title).toList(), ['Zoom In', 'Alpha', 'Beta']);
|
||||
});
|
||||
|
||||
test('enabledWhen gates enablement independently of registration', () {
|
||||
cmd('x.cmd', title: 'X: Cmd');
|
||||
List<ResolvedItem> resolve(bool Function(KernelServices) when) {
|
||||
final tree = [
|
||||
TopMenu(title: 'X', mnemonic: 0, nodes: [MenuCommandItem('x.cmd', enabledWhen: when)]),
|
||||
];
|
||||
return resolveMenus(tree, f.services.commands, f.services).single.items.cast<ResolvedItem>();
|
||||
}
|
||||
|
||||
expect(resolve((_) => false).single.enabled, isFalse);
|
||||
expect(resolve((_) => true).single.enabled, isTrue);
|
||||
});
|
||||
|
||||
test('keymap binding label overrides the contribution defaultBinding', () {
|
||||
cmd('k.cmd', title: 'K: Cmd', binding: 'ctrl+1');
|
||||
final tree = [
|
||||
TopMenu(title: 'K', mnemonic: 0, nodes: const [MenuCommandItem('k.cmd')]),
|
||||
];
|
||||
final item = resolveMenus(
|
||||
tree,
|
||||
f.services.commands,
|
||||
f.services,
|
||||
bindingLabel: (id) => id == 'k.cmd' ? 'Ctrl+K' : null,
|
||||
).single.items.first as ResolvedItem;
|
||||
expect(item.keybinding, 'Ctrl+K');
|
||||
});
|
||||
});
|
||||
|
||||
group('buildClideMenuTree', () {
|
||||
test('is File / View / Help with first-letter mnemonics', () {
|
||||
final tree = buildClideMenuTree();
|
||||
expect(tree.map((m) => m.title).toList(), ['File', 'View', 'Help']);
|
||||
expect(tree.map((m) => m.mnemonicChar).toList(), ['f', 'v', 'h']);
|
||||
});
|
||||
});
|
||||
|
||||
group('MenuBarController', () {
|
||||
test('open / close / toggle track a single open index', () {
|
||||
final c = MenuBarController();
|
||||
expect(c.isOpen, isFalse);
|
||||
c.open(1);
|
||||
expect(c.openIndex, 1);
|
||||
c.toggle(1); // same index → close
|
||||
expect(c.isOpen, isFalse);
|
||||
c.toggle(2); // different → open
|
||||
expect(c.openIndex, 2);
|
||||
});
|
||||
|
||||
test('mnemonic lookup + openNext/openPrev wrap', () {
|
||||
final c = MenuBarController()..setMnemonics(['f', 'v', 'h']);
|
||||
expect(c.indexForMnemonic('V'), 1);
|
||||
expect(c.indexForMnemonic('z'), isNull);
|
||||
c.open(2);
|
||||
c.openNext(); // wraps 2 → 0
|
||||
expect(c.openIndex, 0);
|
||||
c.openPrev(); // wraps 0 → 2
|
||||
expect(c.openIndex, 2);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user