diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 02120d5f..75a48084 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -879,3 +879,5 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-275', 'status', 'ready', 'in_progress', NULL, '2026-06-08 15:45:13', '2026-06-08 15:45:13', '2026-06-08 15:45:13', NULL, '2745334e049523152af749102abe31e5', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-275', 'parent_id', 'T-276', 'T-286', NULL, '2026-06-08 16:30:47', '2026-06-08 16:30:47', '2026-06-08 16:30:47', NULL, 'e0d912feca8121cdeb7a6e39b17e5eb0', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-275', 'status', 'in_progress', 'done', NULL, '2026-06-08 16:52:13', '2026-06-08 16:52:13', '2026-06-08 16:52:13', NULL, '6ac483536876a274c0f76a4f4bb5fc69', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-286', 'status', 'backlog', 'ready', NULL, '2026-06-08 17:34:29', '2026-06-08 17:34:29', '2026-06-08 17:34:29', NULL, '01a873c4191650aa6faa3e7f63926439', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-286', 'status', 'ready', 'in_progress', NULL, '2026-06-08 17:34:31', '2026-06-08 17:34:31', '2026-06-08 17:34:31', NULL, 'b2ff3d5dbf5d72be72ba59b2d7e7c4d8', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 4d077cba..888a14af 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -2309,3 +2309,5 @@ Impact: low — console noise at CRITICAL level; no observed functional breakage Investigation: run with G_DEBUG=fatal-warnings (or gdb break on g_log/g_logv) to capture the stack at the warning and identify the library/plugin frame; check whether a Flutter plugin (file_selector, path_provider, url_launcher) or GTK icon/mime loading is responsible. If upstream/GTK, document + suppress-from-our-side or pin; if a plugin, file upstream. Env: Fedora, GTK Linux embedder, flutter run.', 'backlog', 'low', NULL, NULL, NULL, '2026-06-08 17:12:49', '2026-06-08 17:12:49', NULL, 'ff6e04e755a889ef090b498e560b83f1', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-286', 'epic', 'T-276', 'Anchored popover + menu primitive; migrate anchored surfaces', 'Extract a clide-owned anchored-popover + menu widget primitive (lib/widgets/, no Material) and migrate every anchored surface onto it. Nine surfaces hand-roll the same anchored-overlay + row-list + barrier + keyboard-nav pattern. Two layers: ClideAnchoredOverlay (positioning/lifecycle: LayerLink/follower or centered, barrier, Esc, focus capture, autoFlip) + ClideMenu / ClideMenuListController (rows + reusable nav). Modal DialogRouter pickers (session/project/branch) stay modal. Built on T-275''s permission-mode picker first, then migrate menu bar, theme picker, @-mention, slash typeahead, quick-open. See decision (architecture domain) + plan. Children: primitive, T-275 picker, one per migration.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-08 16:30:27', '2026-06-08 17:34:29', NULL, 'ce3217282170e264b73c83eeb862d68d', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-286', 'epic', 'T-276', 'Anchored popover + menu primitive; migrate anchored surfaces', 'Extract a clide-owned anchored-popover + menu widget primitive (lib/widgets/, no Material) and migrate every anchored surface onto it. Nine surfaces hand-roll the same anchored-overlay + row-list + barrier + keyboard-nav pattern. Two layers: ClideAnchoredOverlay (positioning/lifecycle: LayerLink/follower or centered, barrier, Esc, focus capture, autoFlip) + ClideMenu / ClideMenuListController (rows + reusable nav). Modal DialogRouter pickers (session/project/branch) stay modal. Built on T-275''s permission-mode picker first, then migrate menu bar, theme picker, @-mention, slash typeahead, quick-open. See decision (architecture domain) + plan. Children: primitive, T-275 picker, one per migration.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-08 16:30:27', '2026-06-08 17:34:31', NULL, '943af41d72d9e982968f0468c36a51d3', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/lib/builtin/menubar/src/menu_bar.dart b/lib/builtin/menubar/src/menu_bar.dart index e9b957b6..41e5d3a0 100644 --- a/lib/builtin/menubar/src/menu_bar.dart +++ b/lib/builtin/menubar/src/menu_bar.dart @@ -5,7 +5,6 @@ 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 @@ -102,87 +101,83 @@ class _TopMenuButton extends StatefulWidget { State<_TopMenuButton> createState() => _TopMenuButtonState(); } -class _TopMenuButtonState extends State<_TopMenuButton> { - final LayerLink _link = LayerLink(); - OverlayEntry? _entry; - - @override - void initState() { - super.initState(); - widget.controller.addListener(_sync); +/// Adapts the shared [MenuBarController] (single-open across buttons) to the +/// per-button [ClideOverlayController] the primitive drives. Reads/writes go +/// straight through to the bar so there's one source of truth; it notifies when +/// this button's open state flips so the anchored overlay opens/closes in step. +class _MenuOverlayAdapter extends ClideOverlayController { + _MenuOverlayAdapter(this._bar, this._index) : _last = _bar.openIndex == _index { + _bar.addListener(_onBar); } - @override - void didUpdateWidget(_TopMenuButton old) { - super.didUpdateWidget(old); - if (!identical(old.controller, widget.controller)) { - old.controller.removeListener(_sync); - widget.controller.addListener(_sync); + final MenuBarController _bar; + final int _index; + bool _last; + + void _onBar() { + final now = _bar.openIndex == _index; + if (now != _last) { + _last = now; + notifyListeners(); } - // 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 + bool get isOpen => _bar.openIndex == _index; + @override + void open() => _bar.open(_index); + @override + void close() => _bar.close(); + @override + void toggle() => _bar.toggle(_index); + @override void dispose() { - widget.controller.removeListener(_sync); - _entry?.remove(); - _entry = null; + _bar.removeListener(_onBar); 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; - } - } +class _TopMenuButtonState extends State<_TopMenuButton> { + late final _MenuOverlayAdapter _overlay = _MenuOverlayAdapter(widget.controller, widget.index); - 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 + void dispose() { + _overlay.dispose(); + super.dispose(); } @override Widget build(BuildContext context) { final tokens = ClideTheme.of(context).surface; final open = widget.controller.openIndex == widget.index; - return CompositedTransformTarget( - link: _link, - child: MouseRegion( + return ClideAnchoredOverlay( + controller: _overlay, + side: ClideAnchorSide.below, + align: ClideAnchorAlign.start, + offset: const Offset(0, 2), + autoFlip: false, // the bar sits at the top; menus always open downward + rootOverlay: true, + overlayBuilder: (ctx, ctrl) => ClideMenu( + onClose: ctrl.close, + onArrowLeft: widget.controller.openPrev, + onArrowRight: widget.controller.openNext, + entries: [ + for (final node in widget.menu.items) + if (node is ResolvedItem) + ClideMenuItem( + label: node.title, + enabled: node.enabled, + trailing: node.keybinding != null + ? ClideText(node.keybinding!, fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: ClideTheme.of(ctx).surface.globalTextMuted) + : null, + onSelect: () => unawaited(widget.kernel.commands.execute(node.commandId)), + ) + else + const ClideMenuSeparator(), + ], + ), + anchor: MouseRegion( // Once a menu is open, hovering a sibling switches to it (Zed behavior). onEnter: (_) { if (widget.controller.isOpen) widget.controller.open(widget.index); diff --git a/lib/builtin/menubar/src/menu_dropdown.dart b/lib/builtin/menubar/src/menu_dropdown.dart deleted file mode 100644 index b0abb1fe..00000000 --- a/lib/builtin/menubar/src/menu_dropdown.dart +++ /dev/null @@ -1,141 +0,0 @@ -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 deleted file mode 100644 index bda4de4a..00000000 --- a/lib/builtin/menubar/src/menu_item_row.dart +++ /dev/null @@ -1,51 +0,0 @@ -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), - ], - ], - ), - ), - ), - ); - } -}