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