From e0fba4a3bc86e0e22bf08bdfd628053fd299d17b Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 7 Jun 2026 17:39:56 +0200 Subject: [PATCH] add Zed-style application menu bar: File / View / Help (T-48) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 7 + assets/keymaps/default.yaml | 6 + lib/app.dart | 223 +++--------------- lib/builtin/menubar/menubar.dart | 5 + lib/builtin/menubar/src/about_dialog.dart | 126 ++++++++++ lib/builtin/menubar/src/extension.dart | 92 ++++++++ lib/builtin/menubar/src/file_actions.dart | 207 ++++++++++++++++ lib/builtin/menubar/src/licenses_loader.dart | 60 +++++ lib/builtin/menubar/src/menu_bar.dart | 207 ++++++++++++++++ lib/builtin/menubar/src/menu_dropdown.dart | 141 +++++++++++ lib/builtin/menubar/src/menu_item_row.dart | 51 ++++ lib/builtin/menubar/src/menu_model.dart | 166 +++++++++++++ lib/main.dart | 2 + test/app_test.dart | 50 ++++ test/builtin/menubar/file_actions_test.dart | 83 +++++++ .../builtin/menubar/licenses_loader_test.dart | 64 +++++ test/builtin/menubar/menu_bar_test.dart | 149 ++++++++++++ test/builtin/menubar/menu_model_test.dart | 129 ++++++++++ 18 files changed, 1581 insertions(+), 187 deletions(-) create mode 100644 lib/builtin/menubar/menubar.dart create mode 100644 lib/builtin/menubar/src/about_dialog.dart create mode 100644 lib/builtin/menubar/src/extension.dart create mode 100644 lib/builtin/menubar/src/file_actions.dart create mode 100644 lib/builtin/menubar/src/licenses_loader.dart create mode 100644 lib/builtin/menubar/src/menu_bar.dart create mode 100644 lib/builtin/menubar/src/menu_dropdown.dart create mode 100644 lib/builtin/menubar/src/menu_item_row.dart create mode 100644 lib/builtin/menubar/src/menu_model.dart create mode 100644 test/builtin/menubar/file_actions_test.dart create mode 100644 test/builtin/menubar/licenses_loader_test.dart create mode 100644 test/builtin/menubar/menu_bar_test.dart create mode 100644 test/builtin/menubar/menu_model_test.dart 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); + }); + }); +}