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:
2026-06-07 17:39:56 +02:00
co-authored by Claude Opus 4.8
parent c28d3d31d3
commit e0fba4a3bc
18 changed files with 1581 additions and 187 deletions
+7
View File
@@ -18,6 +18,13 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Added
- A Zed-style **application menu bar** in the hat — **File**, **View**, and
**Help** menus built from custom widgets (no native menu, D-7). Items are
pulled from the command registry with their keybindings shown inline;
unplaced `view.*` commands auto-fill the View menu. Full keyboard support:
`Alt`+mnemonic opens a menu, arrows navigate, Enter activates, Esc closes.
Help → About shows version + bundled licenses. `Ctrl+O` (Open Folder) and
`Ctrl+Shift+N` (New Window) are now real keybindings. (T-48)
- The sidebar/dock **filter boxes are now CLI-addressable** (D-6 parity): `clide
ui filter <address> <text>` drives a pane's filter as typing would, and `clide
ui filter <address>` reads it back. Addresses are box ids from `clide pane
+6
View File
@@ -89,3 +89,9 @@ bindings:
keys: [ctrl+minus, meta+minus]
- intent: text.scaleReset
keys: [ctrl+0, meta+0]
# -- File (application menu, T-48) -------------------------------------
- intent: command:file.openFolder
keys: [ctrl+o, meta+o]
- intent: command:file.newWindow
keys: [ctrl+shift+n, meta+shift+n]
+36 -187
View File
@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:io' show Platform, Process, ProcessStartMode;
import 'dart:io' show Platform;
import 'package:clide/builtin/menubar/menubar.dart';
import 'package:clide/builtin/welcome/src/welcome_view.dart';
import 'package:clide/clide.dart' show clideName;
import 'package:clide/extension/src/contribution.dart';
@@ -56,6 +57,7 @@ class _RootShell extends StatefulWidget {
class _RootShellState extends State<_RootShell> {
late final FocusNode _keyFocus;
final MenuBarController _menuBar = MenuBarController();
@override
void initState() {
@@ -67,6 +69,7 @@ class _RootShellState extends State<_RootShell> {
@override
void dispose() {
widget.services.textZoom.removeListener(_onZoom);
_menuBar.dispose();
_keyFocus.dispose();
super.dispose();
}
@@ -156,7 +159,7 @@ class _RootShellState extends State<_RootShell> {
windowControls: widget.services.window,
child: Column(
children: [
_HatBar(kernel: widget.services),
_HatBar(kernel: widget.services, menuBar: _menuBar),
Expanded(
child: DialogHost(
router: widget.services.dialog,
@@ -182,6 +185,7 @@ class _RootShellState extends State<_RootShell> {
}
void _onKey(KeyEvent event) {
if (_handleMenuMnemonic(event)) return;
final intent = widget.services.keymap.resolveEvent(event, HardwareKeyboard.instance);
if (intent == null) return;
// Dispatch the intent. Try the focused context first so feature
@@ -191,6 +195,18 @@ class _RootShellState extends State<_RootShell> {
final ctx = FocusManager.instance.primaryFocus?.context ?? context;
Actions.maybeInvoke(ctx, intent);
}
/// `Alt+<mnemonic>` opens (or toggles) the matching application menu (T-48).
/// Returns true when consumed so it never falls through to keymap resolution.
bool _handleMenuMnemonic(KeyEvent event) {
if (event is! KeyDownEvent || !HardwareKeyboard.instance.isAltPressed) return false;
final label = event.logicalKey.keyLabel.toLowerCase();
if (label.length != 1) return false;
final idx = _menuBar.indexForMnemonic(label);
if (idx == null) return false;
_menuBar.toggle(idx);
return true;
}
}
class RootLayout extends StatelessWidget {
@@ -306,8 +322,9 @@ class RootLayout extends StatelessWidget {
}
class _HatBar extends StatelessWidget {
const _HatBar({required this.kernel});
const _HatBar({required this.kernel, required this.menuBar});
final KernelServices kernel;
final MenuBarController menuBar;
@override
Widget build(BuildContext context) {
@@ -324,6 +341,7 @@ class _HatBar extends StatelessWidget {
child: Row(
children: [
_LeftHatContent(tokens: tokens, wc: kernel.window),
MenuBar(controller: menuBar),
Expanded(
child: Center(
child: _ProjectSwitcherButton(kernel: kernel, tokens: tokens),
@@ -461,49 +479,13 @@ class _ProjectSwitcherDropdownState extends State<_ProjectSwitcherDropdown> {
}
}
void _closeWorkspace() {
widget.kernel.project.close();
// File actions now live as commands (file.openFolder / file.newWindow /
// file.closeWorkspace) owned by the menu-bar extension (T-48). The switcher
// dismisses itself and dispatches the command so both surfaces share one
// implementation.
void _runFileCommand(String command) {
widget.onDismiss();
}
void _newWindow() {
Process.start(Platform.resolvedExecutable, [], mode: ProcessStartMode.detached);
widget.onDismiss();
}
void _openFolder() async {
widget.onDismiss();
try {
final picked = await widget.kernel.window.pickDirectory();
if (picked != null) {
final ok = await widget.kernel.project.open(picked);
if (ok) {
widget.kernel.panels.activateTab(Slots.workspace, 'claude.primary');
} else {
widget.kernel.dialog.show((ctx, dismiss) => _NotARepoDialog(
path: picked,
onDismiss: () => dismiss(),
));
}
}
return;
} on MissingPluginException {
// Fall through to text dialog.
}
widget.kernel.dialog.show<String>((ctx, dismiss) {
return _OpenFolderDialog(
onOpen: (path) async {
final ok = await widget.kernel.project.open(path);
if (ok) {
widget.kernel.panels.activateTab(Slots.workspace, 'claude.primary');
dismiss(path);
}
},
onCancel: () => dismiss(),
);
});
unawaited(widget.kernel.commands.execute(command));
}
KeyEventResult _onKey(FocusNode node, KeyEvent event) {
@@ -560,9 +542,15 @@ class _ProjectSwitcherDropdownState extends State<_ProjectSwitcherDropdown> {
decoration: BoxDecoration(border: Border(top: BorderSide(color: tokens.dividerColor))),
child: Column(
children: [
_ActionRow(label: 'Open Local Project', shortcut: Platform.isMacOS ? '⌘O' : 'Ctrl+O', tokens: tokens, onTap: _openFolder),
_ActionRow(label: 'New Window', shortcut: Platform.isMacOS ? '⌘⇧N' : 'Ctrl+Shift+N', tokens: tokens, onTap: _newWindow),
if (widget.kernel.project.isOpen) _ActionRow(label: 'Close Project', shortcut: '', tokens: tokens, onTap: _closeWorkspace),
_ActionRow(
label: 'Open Local Project',
shortcut: Platform.isMacOS ? '⌘O' : 'Ctrl+O',
tokens: tokens,
onTap: () => _runFileCommand('file.openFolder')),
_ActionRow(
label: 'New Window', shortcut: Platform.isMacOS ? '⌘⇧N' : 'Ctrl+Shift+N', tokens: tokens, onTap: () => _runFileCommand('file.newWindow')),
if (widget.kernel.project.isOpen)
_ActionRow(label: 'Close Project', shortcut: '', tokens: tokens, onTap: () => _runFileCommand('file.closeWorkspace')),
],
),
),
@@ -648,145 +636,6 @@ class _ActionRow extends StatelessWidget {
}
}
class _OpenFolderDialog extends StatefulWidget {
const _OpenFolderDialog({required this.onOpen, required this.onCancel});
final Future<void> Function(String path) onOpen;
final VoidCallback onCancel;
@override
State<_OpenFolderDialog> createState() => _OpenFolderDialogState();
}
class _OpenFolderDialogState extends State<_OpenFolderDialog> {
final _controller = TextEditingController();
final _focus = FocusNode();
String? _error;
bool _loading = false;
@override
void initState() {
super.initState();
_focus.requestFocus();
}
@override
void dispose() {
_controller.dispose();
_focus.dispose();
super.dispose();
}
Future<void> _submit() async {
final path = _controller.text.trim();
if (path.isEmpty) return;
setState(() {
_loading = true;
_error = null;
});
try {
await widget.onOpen(path);
} catch (_) {
if (mounted) setState(() => _error = 'Not a git repository');
}
if (mounted) setState(() => _loading = false);
}
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return Container(
width: 420,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: tokens.modalSurfaceBackground,
border: Border.all(color: tokens.modalSurfaceBorder),
borderRadius: BorderRadius.circular(6),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ClideText('Open project', fontSize: 16, fontWeight: FontWeight.w600),
const SizedBox(height: 4),
const ClideText('Enter the path to a git repository.', muted: true, fontSize: 13),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: tokens.panelBackground,
border: Border.all(color: tokens.globalBorder),
borderRadius: BorderRadius.circular(4),
),
child: EditableText(
controller: _controller,
focusNode: _focus,
style: TextStyle(color: tokens.globalForeground, fontSize: 14, fontFamily: clideMonoFamily, fontFamilyFallback: clideMonoFamilyFallback),
cursorColor: tokens.globalForeground,
backgroundCursorColor: tokens.globalTextMuted,
onSubmitted: (_) => unawaited(_submit()),
),
),
if (_error != null) ...[
const SizedBox(height: 8),
ClideText(_error!, color: tokens.statusError, fontSize: 12),
],
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ClideButton(label: 'Cancel', onPressed: widget.onCancel),
const SizedBox(width: 8),
ClideButton(label: _loading ? 'Opening…' : 'Open', onPressed: _loading ? null : _submit),
],
),
],
),
);
}
}
class _NotARepoDialog extends StatelessWidget {
const _NotARepoDialog({required this.path, required this.onDismiss});
final String path;
final VoidCallback onDismiss;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return Container(
width: 420,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: tokens.modalSurfaceBackground,
border: Border.all(color: tokens.modalSurfaceBorder),
borderRadius: BorderRadius.circular(6),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ClideText('No git repo found', fontSize: 16, fontWeight: FontWeight.w600),
const SizedBox(height: 8),
ClideText(path, muted: true, fontSize: 13),
const SizedBox(height: 8),
const ClideText(
'A clide project root requires a git repository.',
muted: true,
fontSize: 13,
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ClideButton(label: 'OK', onPressed: () => onDismiss()),
],
),
],
),
);
}
}
class SlotHost extends StatefulWidget {
const SlotHost({super.key, required this.slot});
final SlotId slot;
+5
View File
@@ -0,0 +1,5 @@
/// Application menu bar (T-48): a Zed-style File / View / Help menu in the hat.
library;
export 'src/extension.dart' show MenuBarExtension, buildClideMenuTree;
export 'src/menu_bar.dart' show MenuBar, MenuBarController;
+126
View File
@@ -0,0 +1,126 @@
import 'package:clide/clide.dart' show clideName, clideTagline, clideVersion, clideRepository, clideCommit, clideDate;
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
import 'licenses_loader.dart';
/// The Help → About dialog (T-48): clide identity + build info, plus the
/// bundled-dependency licenses parsed from `assets/licenses.yaml`.
class AboutDialog extends StatelessWidget {
const AboutDialog({super.key, required this.onDismiss});
final VoidCallback onDismiss;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return Container(
width: 480,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: tokens.modalSurfaceBackground,
border: Border.all(color: tokens.modalSurfaceBorder),
borderRadius: BorderRadius.circular(6),
),
child: Column(
mainAxisSize: MainAxisSize.min,
// Stretch so the build-info rows and the licenses list get a bounded
// width (the Container is width-fixed); the labels stay left-aligned.
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const ClideText(clideName, fontSize: 20, fontWeight: FontWeight.w600),
const SizedBox(height: 2),
const ClideText(clideTagline, muted: true, fontSize: 13),
const SizedBox(height: 16),
_Kv(label: 'Version', value: clideVersion, tokens: tokens),
_Kv(label: 'Commit', value: clideCommit, tokens: tokens),
_Kv(label: 'Built', value: _formatDate(clideDate), tokens: tokens),
_Kv(label: 'Repository', value: clideRepository, tokens: tokens),
const SizedBox(height: 16),
ClideText('Bundled dependencies', fontSize: clideFontCaption, color: tokens.globalTextMuted),
const SizedBox(height: 6),
_Licenses(tokens: tokens),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [ClideButton(label: 'Close', onPressed: onDismiss)],
),
],
),
);
}
}
/// A label/value row in the build-info block.
class _Kv extends StatelessWidget {
const _Kv({required this.label, required this.value, required this.tokens});
final String label;
final String value;
final SurfaceTokens tokens;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(width: 90, child: ClideText(label, fontSize: 13, color: tokens.globalTextMuted)),
Expanded(child: ClideText(value, fontSize: 13, fontFamily: clideMonoFamily, maxLines: 1, overflow: TextOverflow.ellipsis)),
],
),
);
}
}
/// The bundled-dependency list, loaded lazily from `assets/licenses.yaml`.
class _Licenses extends StatelessWidget {
const _Licenses({required this.tokens});
final SurfaceTokens tokens;
@override
Widget build(BuildContext context) {
return FutureBuilder<LicensesManifest>(
future: loadLicenses(),
builder: (context, snap) {
if (!snap.hasData) {
return ClideText(snap.hasError ? 'Licenses unavailable.' : 'Loading…', fontSize: 12, color: tokens.globalTextMuted);
}
final deps = snap.data!.dependencies;
return Container(
constraints: const BoxConstraints(maxHeight: 260),
decoration: BoxDecoration(border: Border.all(color: tokens.globalBorder), borderRadius: BorderRadius.circular(4)),
child: ListView.builder(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: deps.length,
itemBuilder: (ctx, i) {
final d = deps[i];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3),
child: ClideText(
'${d.name} ${d.version} · ${d.license}',
fontSize: 12,
fontFamily: clideMonoFamily,
color: tokens.globalTextMuted,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
);
},
),
);
},
);
}
}
/// `2026-06-07T11:45:29Z` → `2026-06-07 11:45 UTC`. Falls back to the raw
/// string if it doesn't parse.
String _formatDate(String iso) {
final dt = DateTime.tryParse(iso);
if (dt == null) return iso;
final u = dt.toUtc();
String two(int n) => n.toString().padLeft(2, '0');
return '${u.year}-${two(u.month)}-${two(u.day)} ${two(u.hour)}:${two(u.minute)} UTC';
}
+92
View File
@@ -0,0 +1,92 @@
import 'package:clide/clide.dart' show IpcResponse;
import 'package:clide/extension/extension.dart';
import 'package:clide/kernel/kernel.dart';
import 'about_dialog.dart';
import 'file_actions.dart';
import 'menu_model.dart';
/// Application-menu extension (T-48). Registers the File/Help commands the menu
/// bar invokes (View commands already exist elsewhere) and owns the curated
/// menu tree. Constructed with [KernelServices] — like `ViewExtension` — because
/// the File actions need window/project/panels/dialog, which the extension
/// context doesn't expose.
class MenuBarExtension extends ClideExtension {
MenuBarExtension({required this.services});
final KernelServices services;
late final FileActions _file = FileActions(services);
@override
String get id => 'builtin.menubar';
@override
String get title => 'Application Menu';
@override
String get version => '0.1.0';
@override
List<ContributionPoint> get contributions => [
CommandContribution(
id: 'file.openFolder',
command: 'file.openFolder',
title: 'File: Open Folder…',
run: (_) async {
await _file.openFolder();
return IpcResponse.ok(id: '', data: const {});
},
),
CommandContribution(
id: 'file.newWindow',
command: 'file.newWindow',
title: 'File: New Window',
run: (_) async {
_file.newWindow();
return IpcResponse.ok(id: '', data: const {});
},
),
CommandContribution(
id: 'file.closeWorkspace',
command: 'file.closeWorkspace',
title: 'File: Close Project',
run: (_) async {
_file.closeWorkspace();
return IpcResponse.ok(id: '', data: const {});
},
),
CommandContribution(
id: 'help.about',
command: 'help.about',
title: 'Help: About clide',
run: (_) async {
services.dialog.show<Object>((ctx, dismiss) => AboutDialog(onDismiss: () => dismiss()));
return IpcResponse.ok(id: '', data: const {});
},
),
];
}
/// The curated File / View / Help tree (T-48). View ends with a `view.*`
/// auto-fill so newly-registered view commands surface without edits here.
List<TopMenu> buildClideMenuTree() => [
TopMenu(title: 'File', mnemonic: 0, nodes: [
const MenuCommandItem('file.openFolder', fallbackTitle: 'Open Folder…'),
const MenuCommandItem('file.newWindow', fallbackTitle: 'New Window'),
const MenuSeparator(),
MenuCommandItem('file.closeWorkspace', fallbackTitle: 'Close Project', enabledWhen: (s) => s.project.isOpen),
]),
TopMenu(title: 'View', mnemonic: 0, nodes: const [
MenuCommandItem('view.zoomIn'),
MenuCommandItem('view.zoomOut'),
MenuCommandItem('view.zoomReset'),
MenuSeparator(),
MenuCommandItem('sidebar.collapse'),
MenuCommandItem('context.collapse'),
MenuCommandItem('dock.toggle'),
MenuCommandItem('panel.focusMode'),
MenuSeparator(),
MenuAutoFill('view.'),
]),
TopMenu(title: 'Help', mnemonic: 0, nodes: const [
MenuCommandItem('help.about', fallbackTitle: 'About clide'),
]),
];
+207
View File
@@ -0,0 +1,207 @@
import 'dart:async';
import 'dart:io' show Platform, Process, ProcessStartMode;
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/services.dart' show MissingPluginException;
import 'package:flutter/widgets.dart';
/// Workspace/file actions, service-driven so both the application menu (T-48)
/// and the project-switcher dropdown drive the same logic through registered
/// commands rather than duplicating it. Lifted out of `app.dart` so the menu
/// extension can reach it without depending on private widget state.
class FileActions {
const FileActions(this.services);
final KernelServices services;
/// The workspace tab activated after a project opens, so a freshly-opened
/// repo lands on the Claude pane (matches the historical switcher behavior).
static const _landingTab = 'claude.primary';
/// Open the git repo at [path]; on success activate the landing tab.
Future<bool> openPath(String path) async {
final ok = await services.project.open(path);
if (ok) services.panels.activateTab(Slots.workspace, _landingTab);
return ok;
}
/// Launch a second clide window as a detached process.
void newWindow() {
Process.start(Platform.resolvedExecutable, const [], mode: ProcessStartMode.detached);
}
/// Close the current workspace (back to the welcome screen).
void closeWorkspace() => services.project.close();
/// Pick a folder via the native directory picker and open it. Falls back to
/// a typed-path dialog when no native picker is available
/// ([MissingPluginException]); surfaces a "not a repo" dialog when the chosen
/// directory isn't a git repository.
Future<void> openFolder() async {
try {
final picked = await services.window.pickDirectory();
if (picked != null) {
final ok = await openPath(picked);
if (!ok) {
services.dialog.show((ctx, dismiss) => NotARepoDialog(path: picked, onDismiss: () => dismiss()));
}
}
return;
} on MissingPluginException {
// No native picker — fall through to the typed-path dialog.
}
services.dialog.show<String>((ctx, dismiss) {
return OpenFolderDialog(
onOpen: (path) async {
if (await openPath(path)) dismiss(path);
},
onCancel: () => dismiss(),
);
});
}
}
/// Typed-path fallback for opening a project when no native directory picker is
/// available. (Moved verbatim from `app.dart` so [FileActions] owns it.)
class OpenFolderDialog extends StatefulWidget {
const OpenFolderDialog({super.key, required this.onOpen, required this.onCancel});
final Future<void> Function(String path) onOpen;
final VoidCallback onCancel;
@override
State<OpenFolderDialog> createState() => _OpenFolderDialogState();
}
class _OpenFolderDialogState extends State<OpenFolderDialog> {
final _controller = TextEditingController();
final _focus = FocusNode();
String? _error;
bool _loading = false;
@override
void initState() {
super.initState();
_focus.requestFocus();
}
@override
void dispose() {
_controller.dispose();
_focus.dispose();
super.dispose();
}
Future<void> _submit() async {
final path = _controller.text.trim();
if (path.isEmpty) return;
setState(() {
_loading = true;
_error = null;
});
try {
await widget.onOpen(path);
} catch (_) {
if (mounted) setState(() => _error = 'Not a git repository');
}
if (mounted) setState(() => _loading = false);
}
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return Container(
width: 420,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: tokens.modalSurfaceBackground,
border: Border.all(color: tokens.modalSurfaceBorder),
borderRadius: BorderRadius.circular(6),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ClideText('Open project', fontSize: 16, fontWeight: FontWeight.w600),
const SizedBox(height: 4),
const ClideText('Enter the path to a git repository.', muted: true, fontSize: 13),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: tokens.panelBackground,
border: Border.all(color: tokens.globalBorder),
borderRadius: BorderRadius.circular(4),
),
child: EditableText(
controller: _controller,
focusNode: _focus,
style: TextStyle(color: tokens.globalForeground, fontSize: 14, fontFamily: clideMonoFamily, fontFamilyFallback: clideMonoFamilyFallback),
cursorColor: tokens.globalForeground,
backgroundCursorColor: tokens.globalTextMuted,
onSubmitted: (_) => unawaited(_submit()),
),
),
if (_error != null) ...[
const SizedBox(height: 8),
ClideText(_error!, color: tokens.statusError, fontSize: 12),
],
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ClideButton(label: 'Cancel', onPressed: widget.onCancel),
const SizedBox(width: 8),
ClideButton(label: _loading ? 'Opening…' : 'Open', onPressed: _loading ? null : _submit),
],
),
],
),
);
}
}
/// Shown when a chosen directory isn't a git repository. (Moved verbatim from
/// `app.dart`.)
class NotARepoDialog extends StatelessWidget {
const NotARepoDialog({super.key, required this.path, required this.onDismiss});
final String path;
final VoidCallback onDismiss;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return Container(
width: 420,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: tokens.modalSurfaceBackground,
border: Border.all(color: tokens.modalSurfaceBorder),
borderRadius: BorderRadius.circular(6),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ClideText('No git repo found', fontSize: 16, fontWeight: FontWeight.w600),
const SizedBox(height: 8),
ClideText(path, muted: true, fontSize: 13),
const SizedBox(height: 8),
const ClideText(
'A clide project root requires a git repository.',
muted: true,
fontSize: 13,
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ClideButton(label: 'OK', onPressed: () => onDismiss()),
],
),
],
),
);
}
}
@@ -0,0 +1,60 @@
/// Loads the bundled `assets/licenses.yaml` manifest for the About dialog
/// (T-48 / D-42). The runtime `dependencies:` list is what the About screen
/// renders; `self:` carries clide's own license.
library;
import 'package:flutter/services.dart' show rootBundle;
import 'package:yaml/yaml.dart';
class SelfEntry {
const SelfEntry({required this.name, required this.version, required this.license});
final String name;
final String version;
final String license;
}
class DepEntry {
const DepEntry({required this.name, required this.version, required this.license});
final String name;
final String version;
final String license;
}
class LicensesManifest {
const LicensesManifest({required this.self, required this.dependencies});
final SelfEntry self;
final List<DepEntry> dependencies;
}
/// Parse the licenses-manifest YAML text. Pure (no asset bundle) so it's
/// directly unit-testable. Missing fields degrade to '—' rather than throwing.
LicensesManifest parseLicenses(String yamlText) {
final doc = loadYaml(yamlText);
final map = doc is YamlMap ? doc : const {};
String s(Object? v) => v == null ? '' : '$v';
final selfRaw = map['self'];
final selfMap = selfRaw is YamlMap ? selfRaw : const {};
final self = SelfEntry(name: s(selfMap['name']), version: s(selfMap['version']), license: s(selfMap['license']));
final deps = <DepEntry>[];
final list = map['dependencies'];
if (list is YamlList) {
for (final e in list) {
if (e is YamlMap) {
deps.add(DepEntry(name: s(e['name']), version: s(e['version']), license: s(e['license'])));
}
}
}
return LicensesManifest(self: self, dependencies: deps);
}
LicensesManifest? _cache;
/// Load + parse the bundled manifest, cached after the first read.
Future<LicensesManifest> loadLicenses() async {
final cached = _cache;
if (cached != null) return cached;
final raw = await rootBundle.loadString('assets/licenses.yaml');
return _cache = parseLicenses(raw);
}
+207
View File
@@ -0,0 +1,207 @@
import 'dart:async';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
import 'extension.dart' show buildClideMenuTree;
import 'menu_dropdown.dart';
import 'menu_model.dart';
/// Open/close state for the application menu bar (T-48). Owned by the root
/// shell so the Alt-mnemonic key hook and the bar widget share one source of
/// truth. Only one menu is open at a time.
class MenuBarController extends ChangeNotifier {
int? _openIndex;
List<String> _mnemonics = const [];
int? get openIndex => _openIndex;
bool get isOpen => _openIndex != null;
/// Set by the bar each build so the Alt hook can map a letter → menu index
/// without knowing the menu structure. Does not notify (called during build).
void setMnemonics(List<String> m) => _mnemonics = m;
/// The menu index whose mnemonic is [ch] (case-insensitive), or null.
int? indexForMnemonic(String ch) {
final i = _mnemonics.indexOf(ch.toLowerCase());
return i < 0 ? null : i;
}
void open(int i) {
if (_openIndex == i) return;
_openIndex = i;
notifyListeners();
}
void close() {
if (_openIndex == null) return;
_openIndex = null;
notifyListeners();
}
void toggle(int i) => _openIndex == i ? close() : open(i);
void openNext() {
if (_openIndex == null || _mnemonics.isEmpty) return;
open((_openIndex! + 1) % _mnemonics.length);
}
void openPrev() {
if (_openIndex == null || _mnemonics.isEmpty) return;
open((_openIndex! - 1 + _mnemonics.length) % _mnemonics.length);
}
}
/// The application menu bar: a row of top-level menu buttons (File / View /
/// Help) embedded in the hat. Resolves the curated tree against the live
/// command set on every relevant change so titles, enablement, and keybindings
/// stay current.
class MenuBar extends StatelessWidget {
const MenuBar({super.key, required this.controller});
final MenuBarController controller;
@override
Widget build(BuildContext context) {
final kernel = ClideKernel.of(context);
// Note: we READ the keymap for binding labels but don't LISTEN to it —
// it notifies during other widgets' builds (scope-flag changes when a
// dialog/overlay opens), which would mark this sibling dirty mid-build.
// Labels refresh on the next command/project rebuild, which is enough
// since keybindings don't change at runtime.
return ListenableBuilder(
listenable: Listenable.merge([controller, kernel.commands, kernel.project]),
builder: (ctx, _) {
final menus = resolveMenus(
buildClideMenuTree(),
kernel.commands,
kernel,
bindingLabel: (id) => keymapBindingLabel(kernel.keymap, id),
);
controller.setMnemonics([for (final m in menus) m.title[m.mnemonic].toLowerCase()]);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < menus.length; i++) _TopMenuButton(index: i, menu: menus[i], controller: controller, kernel: kernel),
],
);
},
);
}
}
class _TopMenuButton extends StatefulWidget {
const _TopMenuButton({required this.index, required this.menu, required this.controller, required this.kernel});
final int index;
final ResolvedMenu menu;
final MenuBarController controller;
final KernelServices kernel;
@override
State<_TopMenuButton> createState() => _TopMenuButtonState();
}
class _TopMenuButtonState extends State<_TopMenuButton> {
final LayerLink _link = LayerLink();
OverlayEntry? _entry;
@override
void initState() {
super.initState();
widget.controller.addListener(_sync);
}
@override
void didUpdateWidget(_TopMenuButton old) {
super.didUpdateWidget(old);
if (!identical(old.controller, widget.controller)) {
old.controller.removeListener(_sync);
widget.controller.addListener(_sync);
}
// NB: do NOT markNeedsBuild the open entry here — didUpdateWidget runs
// during the parent's build, and marking an overlay entry mid-build is
// illegal. The menu set doesn't change while a menu is open in practice;
// the panel is rebuilt fresh on the next open.
}
@override
void dispose() {
widget.controller.removeListener(_sync);
_entry?.remove();
_entry = null;
super.dispose();
}
void _sync() {
final shouldOpen = widget.controller.openIndex == widget.index;
if (shouldOpen && _entry == null) {
_entry = OverlayEntry(builder: _buildOverlay);
Overlay.of(context, rootOverlay: true).insert(_entry!);
} else if (!shouldOpen && _entry != null) {
_entry!.remove();
_entry = null;
}
}
void _activate(String commandId) {
widget.controller.close();
unawaited(widget.kernel.commands.execute(commandId));
}
Widget _buildOverlay(BuildContext context) {
return Stack(
children: [
Positioned.fill(
child: GestureDetector(behavior: HitTestBehavior.opaque, onTap: widget.controller.close),
),
CompositedTransformFollower(
link: _link,
showWhenUnlinked: false,
targetAnchor: Alignment.bottomLeft,
followerAnchor: Alignment.topLeft,
offset: const Offset(0, 2),
child: Align(
alignment: Alignment.topLeft,
child: MenuDropdown(
menu: widget.menu,
onActivate: _activate,
onClose: widget.controller.close,
onPrevMenu: widget.controller.openPrev,
onNextMenu: widget.controller.openNext,
),
),
),
],
);
}
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final open = widget.controller.openIndex == widget.index;
return CompositedTransformTarget(
link: _link,
child: MouseRegion(
// Once a menu is open, hovering a sibling switches to it (Zed behavior).
onEnter: (_) {
if (widget.controller.isOpen) widget.controller.open(widget.index);
},
child: ClideTappable(
onTap: () => widget.controller.toggle(widget.index),
builder: (ctx, hovered, _) => Container(
height: hatHeight,
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: clideInsetStandard),
color: open || hovered ? tokens.listItemHoverBackground : null,
child: ClideText(
widget.menu.title,
fontSize: 12,
color: open || hovered ? tokens.globalForeground : tokens.chromeForeground,
),
),
),
),
);
}
}
+141
View File
@@ -0,0 +1,141 @@
import 'package:clide/kernel/kernel.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'menu_item_row.dart';
import 'menu_model.dart';
/// The open-menu panel (T-48): a `dropdown`-token surface listing the resolved
/// items, with full keyboard navigation (Up/Down skip disabled rows +
/// separators, Enter activates, Esc closes, Left/Right switch top menus).
class MenuDropdown extends StatefulWidget {
const MenuDropdown({
super.key,
required this.menu,
required this.onActivate,
required this.onClose,
required this.onPrevMenu,
required this.onNextMenu,
});
final ResolvedMenu menu;
final void Function(String commandId) onActivate;
final VoidCallback onClose;
final VoidCallback onPrevMenu;
final VoidCallback onNextMenu;
@override
State<MenuDropdown> createState() => _MenuDropdownState();
}
class _MenuDropdownState extends State<MenuDropdown> {
final FocusNode _focus = FocusNode(debugLabel: 'menu-dropdown');
int _highlight = -1;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _focus.requestFocus();
});
}
@override
void dispose() {
_focus.dispose();
super.dispose();
}
/// Indices into `menu.items` that are enabled command rows (navigable).
List<int> get _navigable {
final out = <int>[];
for (var i = 0; i < widget.menu.items.length; i++) {
final it = widget.menu.items[i];
if (it is ResolvedItem && it.enabled) out.add(i);
}
return out;
}
void _move(int dir) {
final nav = _navigable;
if (nav.isEmpty) return;
final pos = nav.indexOf(_highlight);
final next = pos < 0 ? (dir > 0 ? 0 : nav.length - 1) : (pos + dir) % nav.length;
setState(() => _highlight = nav[(next + nav.length) % nav.length]);
}
void _activateHighlighted() {
if (_highlight < 0) return;
final it = widget.menu.items[_highlight];
if (it is ResolvedItem && it.enabled) widget.onActivate(it.commandId);
}
KeyEventResult _onKey(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) return KeyEventResult.ignored;
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowDown:
_move(1);
return KeyEventResult.handled;
case LogicalKeyboardKey.arrowUp:
_move(-1);
return KeyEventResult.handled;
case LogicalKeyboardKey.enter:
case LogicalKeyboardKey.numpadEnter:
case LogicalKeyboardKey.space:
_activateHighlighted();
return KeyEventResult.handled;
case LogicalKeyboardKey.escape:
widget.onClose();
return KeyEventResult.handled;
case LogicalKeyboardKey.arrowLeft:
widget.onPrevMenu();
return KeyEventResult.handled;
case LogicalKeyboardKey.arrowRight:
widget.onNextMenu();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return Focus(
focusNode: _focus,
onKeyEvent: _onKey,
child: IntrinsicWidth(
child: Container(
constraints: const BoxConstraints(minWidth: 220, maxWidth: 420),
decoration: BoxDecoration(
color: tokens.dropdownBackground,
border: Border.all(color: tokens.dropdownBorder),
borderRadius: BorderRadius.circular(6),
boxShadow: [BoxShadow(color: tokens.shadowAmbient, blurRadius: 12, offset: const Offset(0, 4))],
),
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < widget.menu.items.length; i++) _row(i, widget.menu.items[i], tokens),
],
),
),
),
);
}
Widget _row(int index, ResolvedNode node, SurfaceTokens tokens) {
return switch (node) {
ResolvedSeparator() => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Container(height: 1, color: tokens.dividerColor),
),
ResolvedItem() => MenuItemRow(
item: node,
highlighted: index == _highlight,
onActivate: () => widget.onActivate(node.commandId),
),
};
}
}
@@ -0,0 +1,51 @@
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
import 'menu_model.dart';
/// One command row in an open menu (T-48): label on the left, inline keybinding
/// on the right (two-column control pattern). Disabled items render greyed and
/// inert; the keyboard-highlighted row uses the hover background.
class MenuItemRow extends StatelessWidget {
const MenuItemRow({super.key, required this.item, required this.highlighted, required this.onActivate});
final ResolvedItem item;
final bool highlighted;
final VoidCallback onActivate;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final enabled = item.enabled;
return Semantics(
button: true,
enabled: enabled,
label: item.title,
child: ClideTappable(
onTap: enabled ? onActivate : null,
builder: (context, hovered, _) => Container(
color: enabled && (highlighted || hovered) ? tokens.listItemHoverBackground : null,
padding: const EdgeInsets.symmetric(horizontal: clideInsetText, vertical: 6),
child: Row(
children: [
Expanded(
child: ClideText(
item.title,
fontSize: clideFontCaption,
color: enabled ? tokens.dropdownForeground : tokens.globalTextMuted,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (item.keybinding != null) ...[
const SizedBox(width: clideGapSection),
ClideText(item.keybinding!, fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: tokens.globalTextMuted),
],
],
),
),
),
);
}
}
+166
View File
@@ -0,0 +1,166 @@
/// Menu model + resolver for the application menu bar (T-48).
///
/// The menu is **hybrid**: a hand-authored [TopMenu] tree fixes the curated
/// placement (ordering, grouping, separators), and a [MenuAutoFill] node sweeps
/// in any registered command sharing a prefix that wasn't explicitly placed.
/// Titles and keybindings are pulled from the [CommandRegistry] / keymap at
/// resolve time, so the menu always reflects the live command set. Items whose
/// command isn't registered, or whose [MenuCommandItem.enabledWhen] predicate
/// fails, resolve to **disabled** (greyed) — visible, never hidden.
library;
import 'package:clide/extension/extension.dart' show CommandContribution;
import 'package:clide/kernel/kernel.dart';
// ---------------------------------------------------------------------------
// Authoring model (the curated tree)
// ---------------------------------------------------------------------------
/// A node in a curated menu.
sealed class MenuNode {
const MenuNode();
}
/// An explicit placement of a registry command. Title + keybinding come from
/// the registry at resolve time; [fallbackTitle] is used only when the command
/// isn't registered (in which case the item is disabled).
class MenuCommandItem extends MenuNode {
const MenuCommandItem(this.commandId, {this.fallbackTitle, this.enabledWhen});
final String commandId;
final String? fallbackTitle;
/// Extra enablement predicate (beyond "is registered"). When it returns
/// false the item shows greyed. Used e.g. to grey "Close Project" when no
/// project is open.
final bool Function(KernelServices)? enabledWhen;
}
/// A horizontal rule between groups.
class MenuSeparator extends MenuNode {
const MenuSeparator();
}
/// Sweep in every registered command whose id starts with [prefix] and isn't
/// explicitly placed elsewhere in the tree, sorted by title.
class MenuAutoFill extends MenuNode {
const MenuAutoFill(this.prefix);
final String prefix;
}
/// A top-level menu (File / View / Help).
class TopMenu {
const TopMenu({required this.title, required this.mnemonic, required this.nodes});
final String title;
/// Index into [title] of the Alt-mnemonic letter (e.g. 0 → 'F' in "File").
final int mnemonic;
final List<MenuNode> nodes;
/// The lower-case mnemonic character, for matching `Alt+<letter>`.
String get mnemonicChar => title[mnemonic].toLowerCase();
}
// ---------------------------------------------------------------------------
// Resolved model (render-ready)
// ---------------------------------------------------------------------------
sealed class ResolvedNode {
const ResolvedNode();
}
class ResolvedItem extends ResolvedNode {
const ResolvedItem({required this.commandId, required this.title, required this.enabled, this.keybinding});
final String commandId;
final String title;
final bool enabled;
final String? keybinding;
}
class ResolvedSeparator extends ResolvedNode {
const ResolvedSeparator();
}
class ResolvedMenu {
const ResolvedMenu({required this.title, required this.mnemonic, required this.items});
final String title;
final int mnemonic;
final List<ResolvedNode> items;
}
// ---------------------------------------------------------------------------
// Resolver
// ---------------------------------------------------------------------------
/// The human-readable keybinding label for [commandId] from the live keymap
/// (respecting user overrides), or null if unbound. Joins multi-chord
/// sequences with spaces.
String? keymapBindingLabel(KeymapService keymap, String commandId) {
for (final b in keymap.effectiveBindings) {
final i = b.intent;
if (i is InvokeCommandIntent && i.commandId == commandId && b.sequence.isNotEmpty) {
return b.sequence.map((c) => c.display).join(' ');
}
}
return null;
}
/// Resolve the curated [tree] against the live command set into render-ready
/// menus. [bindingLabel] supplies the keybinding string for a command id
/// (typically [keymapBindingLabel] bound to the keymap); when it returns null
/// the command's own `defaultBinding` is used as a fallback.
List<ResolvedMenu> resolveMenus(
List<TopMenu> tree,
CommandRegistry registry,
KernelServices services, {
String? Function(String commandId)? bindingLabel,
}) {
final placed = <String>{
for (final m in tree)
for (final n in m.nodes)
if (n is MenuCommandItem) n.commandId,
};
String? label(String id) {
final fromKeymap = bindingLabel?.call(id);
if (fromKeymap != null) return fromKeymap;
final db = registry.get(id)?.defaultBinding;
if (db == null || db.isEmpty) return null;
try {
return KeyChord.parse(db).display;
} catch (_) {
return db;
}
}
ResolvedItem resolveItem(MenuCommandItem item) {
final cmd = registry.get(item.commandId);
final enabled = cmd != null && (item.enabledWhen?.call(services) ?? true);
final raw = cmd?.title ?? item.fallbackTitle ?? item.commandId;
return ResolvedItem(commandId: item.commandId, title: _stripCategory(raw), enabled: enabled, keybinding: label(item.commandId));
}
List<ResolvedNode> expand(MenuNode n) => switch (n) {
MenuCommandItem() => [resolveItem(n)],
MenuSeparator() => const [ResolvedSeparator()],
MenuAutoFill(:final prefix) => [
for (final c in _autoFill(registry, prefix, placed)) resolveItem(MenuCommandItem(c.command)),
],
};
return [
for (final m in tree) ResolvedMenu(title: m.title, mnemonic: m.mnemonic, items: [for (final n in m.nodes) ...expand(n)]),
];
}
List<CommandContribution> _autoFill(CommandRegistry registry, String prefix, Set<String> placed) {
final hits = registry.all.where((c) => c.command.startsWith(prefix) && !placed.contains(c.command)).toList();
hits.sort((a, b) => (a.title ?? a.command).toLowerCase().compareTo((b.title ?? b.command).toLowerCase()));
return hits;
}
/// Strip a leading "Category: " prefix so "View: Zoom In" reads "Zoom In"
/// under the View menu. Only strips a single capitalised word + colon + space.
String _stripCategory(String title) {
final m = RegExp(r'^[A-Z][A-Za-z]*:\s').firstMatch(title);
return m == null ? title : title.substring(m.end);
}
+2
View File
@@ -16,6 +16,7 @@ import 'package:clide/builtin/git/git.dart';
import 'package:clide/builtin/search/search.dart';
import 'package:clide/builtin/grammars_core/grammars_core.dart';
import 'package:clide/builtin/graph/graph.dart';
import 'package:clide/builtin/menubar/menubar.dart';
import 'package:clide/builtin/output/output.dart';
import 'package:clide/builtin/keybindings_ui/keybindings_ui.dart';
import 'package:clide/builtin/markdown/markdown.dart';
@@ -384,6 +385,7 @@ Future<void> main() async {
..register(GraphExtension())
// UI extensions
..register(ViewExtension(textZoom: services.textZoom))
..register(MenuBarExtension(services: services))
..register(SettingsUiExtension())
..register(ExtensionsUiExtension())
..register(KeybindingsUiExtension())
+50
View File
@@ -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
''';
+149
View File
@@ -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);
});
}
+129
View File
@@ -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);
});
});
}