From de321bfe92648b38e51b41a88a6a48cdbd6d7916 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 23 Apr 2026 16:59:21 +0200 Subject: [PATCH] bidirectional focus: detail broadcasts, list highlights MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detail controller publishes builtin.tickets/focus with the loaded ticket ID after every load. Ticket list subscribes and highlights the matching card with selected background and accent border. Works regardless of what triggered the detail load — sidebar click, parent tree navigation, or future command palette. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tickets/src/ticket_detail_controller.dart | 1 + lib/builtin/tickets/src/tickets_view.dart | 31 ++++++++++++++----- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/lib/builtin/tickets/src/ticket_detail_controller.dart b/lib/builtin/tickets/src/ticket_detail_controller.dart index 6b06c763..451a6d0f 100644 --- a/lib/builtin/tickets/src/ticket_detail_controller.dart +++ b/lib/builtin/tickets/src/ticket_detail_controller.dart @@ -85,6 +85,7 @@ class TicketDetailController extends ChangeNotifier { _detail = TicketDetail(ticket: ticket, parents: parents, decisions: decisions); _loading = false; + messages.publish('builtin.tickets', 'focus', {'id': id}); notifyListeners(); } diff --git a/lib/builtin/tickets/src/tickets_view.dart b/lib/builtin/tickets/src/tickets_view.dart index e18907f9..5fcab9c2 100644 --- a/lib/builtin/tickets/src/tickets_view.dart +++ b/lib/builtin/tickets/src/tickets_view.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:clide/builtin/tickets/src/ticket_colors.dart'; import 'package:clide/kernel/kernel.dart'; +import 'package:clide/kernel/src/events/message_bus.dart'; import 'package:clide/widgets/widgets.dart'; import 'package:flutter/widgets.dart'; @@ -17,7 +18,9 @@ class _TicketsViewState extends State { String? _error; bool _loading = true; String _filter = ''; + String? _focusedId; final Set _expanded = {'active', 'backlog'}; + StreamSubscription? _focusSub; void _toggle(String key) { setState(() { @@ -28,10 +31,23 @@ class _TicketsViewState extends State { @override void didChangeDependencies() { super.didChangeDependencies(); + if (_focusSub == null) { + final kernel = ClideKernel.of(context); + _focusSub = kernel.messages.subscribe(publisher: 'builtin.tickets', channel: 'focus').listen((msg) { + final id = msg.data['id'] as String?; + if (id != _focusedId) setState(() => _focusedId = id); + }); + } if (!_loading || _tickets.isNotEmpty) return; unawaited(_load()); } + @override + void dispose() { + _focusSub?.cancel(); + super.dispose(); + } + Future _load() async { final kernel = ClideKernel.of(context); final resp = await kernel.ipc.request('pql.tickets.list'); @@ -82,10 +98,10 @@ class _TicketsViewState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (active.isNotEmpty) _AccordionSection(label: 'ACTIVE', count: active.length, tokens: tokens, expanded: hasFilter || _expanded.contains('active'), onToggle: () => _toggle('active'), children: [for (final t in active) _TicketCard(entry: t, tokens: tokens, typeColors: typeColors)]), - if (backlog.isNotEmpty) _AccordionSection(label: 'BACKLOG', count: backlog.length, tokens: tokens, expanded: hasFilter || _expanded.contains('backlog'), onToggle: () => _toggle('backlog'), children: [for (final t in backlog) _TicketCard(entry: t, tokens: tokens, typeColors: typeColors)]), - if (done.isNotEmpty) _AccordionSection(label: 'DONE', count: done.length, tokens: tokens, expanded: hasFilter || _expanded.contains('done'), onToggle: () => _toggle('done'), children: [for (final t in done) _TicketCard(entry: t, tokens: tokens, typeColors: typeColors)]), - if (other.isNotEmpty) _AccordionSection(label: 'OTHER', count: other.length, tokens: tokens, expanded: hasFilter || _expanded.contains('other'), onToggle: () => _toggle('other'), children: [for (final t in other) _TicketCard(entry: t, tokens: tokens, typeColors: typeColors)]), + if (active.isNotEmpty) _AccordionSection(label: 'ACTIVE', count: active.length, tokens: tokens, expanded: hasFilter || _expanded.contains('active'), onToggle: () => _toggle('active'), children: [for (final t in active) _TicketCard(entry: t, tokens: tokens, typeColors: typeColors, focused: t.id == _focusedId)]), + if (backlog.isNotEmpty) _AccordionSection(label: 'BACKLOG', count: backlog.length, tokens: tokens, expanded: hasFilter || _expanded.contains('backlog'), onToggle: () => _toggle('backlog'), children: [for (final t in backlog) _TicketCard(entry: t, tokens: tokens, typeColors: typeColors, focused: t.id == _focusedId)]), + if (done.isNotEmpty) _AccordionSection(label: 'DONE', count: done.length, tokens: tokens, expanded: hasFilter || _expanded.contains('done'), onToggle: () => _toggle('done'), children: [for (final t in done) _TicketCard(entry: t, tokens: tokens, typeColors: typeColors, focused: t.id == _focusedId)]), + if (other.isNotEmpty) _AccordionSection(label: 'OTHER', count: other.length, tokens: tokens, expanded: hasFilter || _expanded.contains('other'), onToggle: () => _toggle('other'), children: [for (final t in other) _TicketCard(entry: t, tokens: tokens, typeColors: typeColors, focused: t.id == _focusedId)]), ], ), ), @@ -148,10 +164,11 @@ class _AccordionSection extends StatelessWidget { } class _TicketCard extends StatelessWidget { - const _TicketCard({required this.entry, required this.tokens, required this.typeColors}); + const _TicketCard({required this.entry, required this.tokens, required this.typeColors, this.focused = false}); final _TicketEntry entry; final SurfaceTokens tokens; final TicketTypeColors typeColors; + final bool focused; @override Widget build(BuildContext context) { @@ -165,9 +182,9 @@ class _TicketCard extends StatelessWidget { builder: (ctx, hovered, _) => Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: hovered ? tokens.sidebarItemHover : tokens.panelBackground, + color: hovered ? tokens.sidebarItemHover : (focused ? tokens.sidebarItemSelected : tokens.panelBackground), borderRadius: BorderRadius.circular(4), - border: Border.all(color: hovered ? tokens.panelActiveBorder : tokens.panelBorder, width: 1), + border: Border.all(color: focused ? tokens.globalFocus : (hovered ? tokens.panelActiveBorder : tokens.panelBorder), width: 1), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start,