diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5593586e..a1d55da1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
` drives a pane's filter as typing would, and `clide
ui filter ` reads it back. Addresses are box ids from `clide pane
diff --git a/assets/keymaps/default.yaml b/assets/keymaps/default.yaml
index 768f8c1f..e9d53230 100644
--- a/assets/keymaps/default.yaml
+++ b/assets/keymaps/default.yaml
@@ -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]
diff --git a/lib/app.dart b/lib/app.dart
index 86b80923..8b4c7f44 100644
--- a/lib/app.dart
+++ b/lib/app.dart
@@ -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+` 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((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 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 _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;
diff --git a/lib/builtin/menubar/menubar.dart b/lib/builtin/menubar/menubar.dart
new file mode 100644
index 00000000..c219fab5
--- /dev/null
+++ b/lib/builtin/menubar/menubar.dart
@@ -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;
diff --git a/lib/builtin/menubar/src/about_dialog.dart b/lib/builtin/menubar/src/about_dialog.dart
new file mode 100644
index 00000000..49ff198b
--- /dev/null
+++ b/lib/builtin/menubar/src/about_dialog.dart
@@ -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(
+ 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';
+}
diff --git a/lib/builtin/menubar/src/extension.dart b/lib/builtin/menubar/src/extension.dart
new file mode 100644
index 00000000..5b63dc88
--- /dev/null
+++ b/lib/builtin/menubar/src/extension.dart
@@ -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 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((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 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'),
+ ]),
+ ];
diff --git a/lib/builtin/menubar/src/file_actions.dart b/lib/builtin/menubar/src/file_actions.dart
new file mode 100644
index 00000000..56ec893d
--- /dev/null
+++ b/lib/builtin/menubar/src/file_actions.dart
@@ -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 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 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((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 Function(String path) onOpen;
+ final VoidCallback onCancel;
+
+ @override
+ State createState() => _OpenFolderDialogState();
+}
+
+class _OpenFolderDialogState extends State {
+ 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 _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()),
+ ],
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/builtin/menubar/src/licenses_loader.dart b/lib/builtin/menubar/src/licenses_loader.dart
new file mode 100644
index 00000000..039d6b49
--- /dev/null
+++ b/lib/builtin/menubar/src/licenses_loader.dart
@@ -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 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 = [];
+ 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 loadLicenses() async {
+ final cached = _cache;
+ if (cached != null) return cached;
+ final raw = await rootBundle.loadString('assets/licenses.yaml');
+ return _cache = parseLicenses(raw);
+}
diff --git a/lib/builtin/menubar/src/menu_bar.dart b/lib/builtin/menubar/src/menu_bar.dart
new file mode 100644
index 00000000..e9b957b6
--- /dev/null
+++ b/lib/builtin/menubar/src/menu_bar.dart
@@ -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 _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 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,
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/builtin/menubar/src/menu_dropdown.dart b/lib/builtin/menubar/src/menu_dropdown.dart
new file mode 100644
index 00000000..b0abb1fe
--- /dev/null
+++ b/lib/builtin/menubar/src/menu_dropdown.dart
@@ -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 createState() => _MenuDropdownState();
+}
+
+class _MenuDropdownState extends State {
+ 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 get _navigable {
+ final out = [];
+ 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),
+ ),
+ };
+ }
+}
diff --git a/lib/builtin/menubar/src/menu_item_row.dart b/lib/builtin/menubar/src/menu_item_row.dart
new file mode 100644
index 00000000..bda4de4a
--- /dev/null
+++ b/lib/builtin/menubar/src/menu_item_row.dart
@@ -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),
+ ],
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/builtin/menubar/src/menu_model.dart b/lib/builtin/menubar/src/menu_model.dart
new file mode 100644
index 00000000..e0d8b071
--- /dev/null
+++ b/lib/builtin/menubar/src/menu_model.dart
@@ -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 nodes;
+
+ /// The lower-case mnemonic character, for matching `Alt+`.
+ 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 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 resolveMenus(
+ List tree,
+ CommandRegistry registry,
+ KernelServices services, {
+ String? Function(String commandId)? bindingLabel,
+}) {
+ final placed = {
+ 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 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 _autoFill(CommandRegistry registry, String prefix, Set 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);
+}
diff --git a/lib/main.dart b/lib/main.dart
index 3d362efe..bc917eec 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -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 main() async {
..register(GraphExtension())
// UI extensions
..register(ViewExtension(textZoom: services.textZoom))
+ ..register(MenuBarExtension(services: services))
..register(SettingsUiExtension())
..register(ExtensionsUiExtension())
..register(KeybindingsUiExtension())
diff --git a/test/app_test.dart b/test/app_test.dart
index ac32a4a7..2be79604 100644
--- a/test/app_test.dart
+++ b/test/app_test.dart
@@ -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.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);
+ });
}
diff --git a/test/builtin/menubar/file_actions_test.dart b/test/builtin/menubar/file_actions_test.dart
new file mode 100644
index 00000000..378a51fd
--- /dev/null
+++ b/test/builtin/menubar/file_actions_test.dart
@@ -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);
+ });
+}
diff --git a/test/builtin/menubar/licenses_loader_test.dart b/test/builtin/menubar/licenses_loader_test.dart
new file mode 100644
index 00000000..2c601255
--- /dev/null
+++ b/test/builtin/menubar/licenses_loader_test.dart
@@ -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
+''';
diff --git a/test/builtin/menubar/menu_bar_test.dart b/test/builtin/menubar/menu_bar_test.dart
new file mode 100644
index 00000000..5710aa66
--- /dev/null
+++ b/test/builtin/menubar/menu_bar_test.dart
@@ -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 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);
+ });
+}
diff --git a/test/builtin/menubar/menu_model_test.dart b/test/builtin/menubar/menu_model_test.dart
new file mode 100644
index 00000000..2c882f48
--- /dev/null
+++ b/test/builtin/menubar/menu_model_test.dart
@@ -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());
+ 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().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 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();
+ }
+
+ 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);
+ });
+ });
+}