diff --git a/lib/builtin/decisions/src/decisions_view.dart b/lib/builtin/decisions/src/decisions_view.dart index c09cfb19..6b3a4148 100644 --- a/lib/builtin/decisions/src/decisions_view.dart +++ b/lib/builtin/decisions/src/decisions_view.dart @@ -19,8 +19,12 @@ class _DecisionsViewState extends State { String _filter = ''; String? _focusedId; final _focusedKey = GlobalKey(); - final Set _expanded = {'confirmed'}; + final Set _pinned = {'confirmed'}; StreamSubscription? _focusSub; + StreamSubscription? _fileSub; + StreamSubscription? _schedulerSub; + bool _refreshing = false; + bool _pendingRefresh = false; @override void didChangeDependencies() { @@ -28,11 +32,28 @@ class _DecisionsViewState extends State { if (_focusSub == null) { final kernel = ClideKernel.of(context); _focusSub = kernel.messages.subscribe(publisher: 'builtin.decisions', channel: 'focus').listen(_onFocus); + _fileSub = kernel.events.on().where((e) => e.subsystem == 'files' && e.kind == 'files.changed' && _isDecisionPath(e.data['path'] as String? ?? '')).listen((_) => _refresh()); + _schedulerSub = kernel.events.on().where((e) => e.tier == SchedulerTier.oneMinute).listen((_) => _refresh()); } if (!_loading || _decisions.isNotEmpty) return; unawaited(_load()); } + static bool _isDecisionPath(String path) => path.startsWith('decisions/') && path.endsWith('.md'); + + Future _refresh() async { + if (!mounted) return; + if (_refreshing) { _pendingRefresh = true; return; } + _refreshing = true; + _pendingRefresh = false; + await _load(); + _refreshing = false; + if (_pendingRefresh && mounted) { + _pendingRefresh = false; + unawaited(_refresh()); + } + } + Future _load() async { final kernel = ClideKernel.of(context); await kernel.ipc.request('pql.decisions.sync'); @@ -59,18 +80,22 @@ class _DecisionsViewState extends State { @override void dispose() { _focusSub?.cancel(); + _fileSub?.cancel(); + _schedulerSub?.cancel(); super.dispose(); } + bool _isSectionExpanded(String type) { + if (_pinned.contains(type)) return true; + if (_focusedId == null) return false; + final entry = _decisions.where((d) => d.id == _focusedId).firstOrNull; + return (entry?.type ?? 'confirmed') == type; + } + void _onFocus(Message msg) { final id = msg.data['id'] as String?; if (id == null || id == _focusedId) return; - final entry = _decisions.where((d) => d.id == id).firstOrNull; - final section = entry?.type ?? 'confirmed'; - setState(() { - _focusedId = id; - _expanded.add(section); - }); + setState(() => _focusedId = id); WidgetsBinding.instance.addPostFrameCallback((_) { final ctx = _focusedKey.currentContext; if (ctx != null) Scrollable.ensureVisible(ctx, duration: const Duration(milliseconds: 200), alignment: 0.3); @@ -79,10 +104,10 @@ class _DecisionsViewState extends State { void _toggleSection(String key) { setState(() { - if (_expanded.contains(key)) { - _expanded.remove(key); + if (_pinned.contains(key)) { + _pinned.remove(key); } else { - _expanded.add(key); + _pinned.add(key); } }); } @@ -107,31 +132,43 @@ class _DecisionsViewState extends State { return Column( children: [ - ClideFilterBox(hint: 'Filter decisions…', onChanged: (v) => setState(() => _filter = v)), + Row( + children: [ + Expanded(child: ClideFilterBox(hint: 'Filter decisions…', onChanged: (v) => setState(() => _filter = v))), + Padding( + padding: const EdgeInsets.only(right: 8), + child: ClideTappable( + onTap: _refreshing ? null : _refresh, + tooltip: 'Refresh decisions', + builder: (ctx, hovered, _) => ClideIcon(PhosphorIcons.arrowClockwise, size: 13, color: hovered ? tokens.globalForeground : tokens.globalTextMuted), + ), + ), + ], + ), Expanded( child: SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (confirmed.isNotEmpty) _AccordionSection( - label: 'CONFIRMED', count: confirmed.length, tokens: tokens, - color: typeColors.confirmed, - expanded: hasFilter || _expanded.contains('confirmed'), + if (confirmed.isNotEmpty) ClideAccordion( + label: 'CONFIRMED', count: confirmed.length, + leading: Container(width: 8, height: 8, decoration: BoxDecoration(color: typeColors.confirmed, shape: BoxShape.circle)), + expanded: hasFilter || _isSectionExpanded('confirmed'), onToggle: () => _toggleSection('confirmed'), children: [for (final d in confirmed) _DecisionCard(entry: d, tokens: tokens, typeColors: typeColors, focused: d.id == _focusedId, focusKey: d.id == _focusedId ? _focusedKey : null)], ), - if (questions.isNotEmpty) _AccordionSection( - label: 'QUESTIONS', count: questions.length, tokens: tokens, - color: typeColors.question, - expanded: hasFilter || _expanded.contains('question'), + if (questions.isNotEmpty) ClideAccordion( + label: 'QUESTIONS', count: questions.length, + leading: Container(width: 8, height: 8, decoration: BoxDecoration(color: typeColors.question, shape: BoxShape.circle)), + expanded: hasFilter || _isSectionExpanded('question'), onToggle: () => _toggleSection('question'), children: [for (final d in questions) _DecisionCard(entry: d, tokens: tokens, typeColors: typeColors, focused: d.id == _focusedId, focusKey: d.id == _focusedId ? _focusedKey : null)], ), - if (rejected.isNotEmpty) _AccordionSection( - label: 'REJECTED', count: rejected.length, tokens: tokens, - color: typeColors.rejected, - expanded: hasFilter || _expanded.contains('rejected'), + if (rejected.isNotEmpty) ClideAccordion( + label: 'REJECTED', count: rejected.length, + leading: Container(width: 8, height: 8, decoration: BoxDecoration(color: typeColors.rejected, shape: BoxShape.circle)), + expanded: hasFilter || _isSectionExpanded('rejected'), onToggle: () => _toggleSection('rejected'), children: [for (final d in rejected) _DecisionCard(entry: d, tokens: tokens, typeColors: typeColors, focused: d.id == _focusedId, focusKey: d.id == _focusedId ? _focusedKey : null)], ), @@ -161,46 +198,6 @@ class _DecisionEntry { ); } -class _AccordionSection extends StatelessWidget { - const _AccordionSection({ - required this.label, required this.count, required this.tokens, - required this.color, required this.expanded, required this.onToggle, - required this.children, - }); - final String label; - final int count; - final SurfaceTokens tokens; - final Color color; - final bool expanded; - final VoidCallback onToggle; - final List children; - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ClideTappable( - onTap: onToggle, - builder: (ctx, hovered, _) => Padding( - padding: const EdgeInsets.only(left: 4, top: 10, bottom: 4), - child: Row( - children: [ - ClideIcon(expanded ? PhosphorIcons.caretDown : PhosphorIcons.caretRight, size: 10, color: tokens.globalTextMuted), - const SizedBox(width: 6), - Container(width: 8, height: 8, decoration: BoxDecoration(color: color, shape: BoxShape.circle)), - const SizedBox(width: 6), - ClideText('$label · $count', fontSize: clideFontSmall, color: hovered ? tokens.globalForeground : tokens.sidebarSectionHeader, fontFamily: clideMonoFamily), - ], - ), - ), - ), - if (expanded) ...children, - ], - ); - } -} - class _DecisionCard extends StatelessWidget { const _DecisionCard({required this.entry, required this.tokens, required this.typeColors, this.focused = false, this.focusKey}); final _DecisionEntry entry; diff --git a/lib/builtin/pql/src/pql_panel_view.dart b/lib/builtin/pql/src/pql_panel_view.dart index 612f92af..496aaf1c 100644 --- a/lib/builtin/pql/src/pql_panel_view.dart +++ b/lib/builtin/pql/src/pql_panel_view.dart @@ -21,6 +21,7 @@ class _PqlPanelViewState extends State { String? _focusedPath; final _focusedKey = GlobalKey(); StreamSubscription? _focusSub; + StreamSubscription? _fileSub; @override void didChangeDependencies() { @@ -42,11 +43,17 @@ class _PqlPanelViewState extends State { if (ctx != null) Scrollable.ensureVisible(ctx, duration: const Duration(milliseconds: 200), alignment: 0.3); }); }); + _fileSub = kernel.events.on().where((e) => e.subsystem == 'files' && e.kind == 'files.changed' && (e.data['path'] as String? ?? '').endsWith('.md')).listen((_) { + if (_controller?.view == PqlView.markdown) { + unawaited(_controller!.loadMarkdownFiles()); + } + }); } @override void dispose() { _focusSub?.cancel(); + _fileSub?.cancel(); _controller?.dispose(); super.dispose(); } diff --git a/lib/builtin/tickets/src/tickets_view.dart b/lib/builtin/tickets/src/tickets_view.dart index 86311632..d88bcad0 100644 --- a/lib/builtin/tickets/src/tickets_view.dart +++ b/lib/builtin/tickets/src/tickets_view.dart @@ -20,31 +20,40 @@ class _TicketsViewState extends State { String _filter = ''; String? _focusedId; final _focusedKey = GlobalKey(); - final Set _expanded = {'active', 'backlog'}; + final Set _pinned = {'in_progress', 'ready', 'backlog'}; StreamSubscription? _focusSub; + StreamSubscription? _schedulerSub; + StreamSubscription? _changedSub; + bool _refreshing = false; + bool _pendingRefresh = false; + + bool _isSectionExpanded(String status) { + if (_pinned.contains(status)) return true; + if (_focusedId == null) return false; + final entry = _tickets.where((t) => t.id == _focusedId).firstOrNull; + return _sectionForStatus(entry?.status) == status; + } void _toggle(String key) { setState(() { - if (_expanded.contains(key)) _expanded.remove(key); else _expanded.add(key); + if (_pinned.contains(key)) { + _pinned.remove(key); + } else { + _pinned.add(key); + } }); } - static String _sectionForStatus(String? status) => switch (status) { - 'in_progress' => 'active', - 'backlog' || 'ready' => 'backlog', - 'done' => 'done', - _ => 'other', - }; + static String _sectionForStatus(String? status) => status ?? 'backlog'; void _onFocus(Message msg) { final id = msg.data['id'] as String?; if (id == null || id == _focusedId) return; - final entry = _tickets.where((t) => t.id == id).firstOrNull; - final section = _sectionForStatus(entry?.status); - setState(() { - _focusedId = id; - _expanded.add(section); - }); + _scrollToFocused(id); + } + + void _scrollToFocused(String id) { + setState(() => _focusedId = id); WidgetsBinding.instance.addPostFrameCallback((_) { final ctx = _focusedKey.currentContext; if (ctx != null) Scrollable.ensureVisible(ctx, duration: const Duration(milliseconds: 200), alignment: 0.3); @@ -57,6 +66,13 @@ class _TicketsViewState extends State { if (_focusSub == null) { final kernel = ClideKernel.of(context); _focusSub = kernel.messages.subscribe(publisher: 'builtin.tickets', channel: 'focus').listen(_onFocus); + _changedSub = kernel.messages.subscribe(publisher: 'builtin.tickets', channel: 'changed').listen((msg) { + final id = msg.data['id'] as String?; + unawaited(_refresh().then((_) { + if (id != null && mounted) _scrollToFocused(id); + })); + }); + _schedulerSub = kernel.events.on().where((e) => e.tier == SchedulerTier.oneMinute).listen((_) => _refresh()); } if (!_loading || _tickets.isNotEmpty) return; unawaited(_load()); @@ -65,9 +81,24 @@ class _TicketsViewState extends State { @override void dispose() { _focusSub?.cancel(); + _changedSub?.cancel(); + _schedulerSub?.cancel(); super.dispose(); } + Future _refresh() async { + if (!mounted) return; + if (_refreshing) { _pendingRefresh = true; return; } + _refreshing = true; + _pendingRefresh = false; + await _load(); + _refreshing = false; + if (_pendingRefresh && mounted) { + _pendingRefresh = false; + unawaited(_refresh()); + } + } + Future _load() async { final kernel = ClideKernel.of(context); final resp = await kernel.ipc.request('pql.tickets.list'); @@ -101,27 +132,62 @@ class _TicketsViewState extends State { final hasFilter = lf.isNotEmpty; final filtered = hasFilter ? _tickets.where((t) => t.id.toLowerCase().contains(lf) || t.title.toLowerCase().contains(lf) || (t.status ?? '').contains(lf) || (t.type ?? '').contains(lf)).toList() : _tickets; - final active = filtered.where((t) => t.status == 'in_progress').toList(); - final backlog = filtered.where((t) => t.status == 'backlog' || t.status == 'ready').toList(); - final done = filtered.where((t) => t.status == 'done').toList(); - final other = filtered.where((t) => t.status == 'cancelled' || t.status == 'review').toList(); + const sections = [ + ('in_progress', 'IN PROGRESS'), + ('review', 'REVIEW'), + ('ready', 'READY'), + ('backlog', 'BACKLOG'), + ('done', 'DONE'), + ('cancelled', 'CANCELLED'), + ]; + + final byStatus = >{}; + for (final t in filtered) { + (byStatus[t.status ?? 'backlog'] ??= []).add(t); + } final isDark = ClideTheme.of(context).dark; final typeColors = TicketTypeColors.forTheme(dark: isDark); return Column( children: [ - ClideFilterBox(hint: 'Filter tickets…', onChanged: (v) => setState(() => _filter = v)), + Row( + children: [ + Expanded(child: ClideFilterBox(hint: 'Filter tickets…', onChanged: (v) => setState(() => _filter = v))), + Padding( + padding: const EdgeInsets.only(right: 8), + child: ClideTappable( + onTap: _refreshing ? null : _refresh, + tooltip: 'Refresh tickets', + builder: (ctx, hovered, _) => ClideIcon(PhosphorIcons.arrowClockwise, size: 13, color: hovered ? tokens.globalForeground : tokens.globalTextMuted), + ), + ), + ], + ), Expanded( child: SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), 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, focused: t.id == _focusedId, focusKey: t.id == _focusedId ? _focusedKey : null)]), - 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, focusKey: t.id == _focusedId ? _focusedKey : null)]), - 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, focusKey: t.id == _focusedId ? _focusedKey : null)]), - 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, focusKey: t.id == _focusedId ? _focusedKey : null)]), + for (final (status, label) in sections) + if (byStatus[status] case final items? when items.isNotEmpty) + ClideAccordion( + label: label, + count: items.length, + expanded: hasFilter || _isSectionExpanded(status), + onToggle: () => _toggle(status), + children: [ + for (final t in items) + _TicketCard( + entry: t, + tokens: tokens, + typeColors: typeColors, + focused: t.id == _focusedId, + focusKey: t.id == _focusedId ? _focusedKey : null, + ), + ], + ), ], ), ), @@ -150,39 +216,6 @@ class _TicketEntry { ); } -class _AccordionSection extends StatelessWidget { - const _AccordionSection({required this.label, required this.count, required this.tokens, required this.expanded, required this.onToggle, required this.children}); - final String label; - final int count; - final SurfaceTokens tokens; - final bool expanded; - final VoidCallback onToggle; - final List children; - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ClideTappable( - onTap: onToggle, - builder: (ctx, hovered, _) => Padding( - padding: const EdgeInsets.only(left: 4, top: 10, bottom: 4), - child: Row( - children: [ - ClideIcon(expanded ? PhosphorIcons.caretDown : PhosphorIcons.caretRight, size: 10, color: tokens.globalTextMuted), - const SizedBox(width: 6), - ClideText('$label · $count', fontSize: clideFontSmall, color: hovered ? tokens.globalForeground : tokens.sidebarSectionHeader, fontFamily: clideMonoFamily), - ], - ), - ), - ), - if (expanded) ...children, - ], - ); - } -} - class _TicketCard extends StatelessWidget { const _TicketCard({required this.entry, required this.tokens, required this.typeColors, this.focused = false, this.focusKey}); final _TicketEntry entry;