Migrate the user-facing strings in builtin.tickets, builtin.pql, builtin.decisions, and builtin.problems (list/detail views, section headers, filters, empty/loading/error states, type chips) to ClideSettings.i18n; extend each extension's catalog. The detail/links tabs that had a bare title now wire titleKey + i18nNamespace so the tab title resolves too. Context threaded into the static label helpers. No en_US behaviour change (D-21). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
355 lines
14 KiB
Dart
355 lines
14 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:clide/builtin/decisions/src/decision_colors.dart';
|
|
import 'package:clide/kernel/kernel.dart';
|
|
import 'package:clide/widgets/widgets.dart';
|
|
import 'package:flutter/widgets.dart';
|
|
|
|
class DecisionsView extends StatefulWidget {
|
|
const DecisionsView({super.key});
|
|
|
|
@override
|
|
State<DecisionsView> createState() => _DecisionsViewState();
|
|
}
|
|
|
|
class _DecisionsViewState extends State<DecisionsView> {
|
|
List<_DecisionEntry> _decisions = [];
|
|
String? _error;
|
|
bool _loading = true;
|
|
String _filter = '';
|
|
String? _focusedId;
|
|
final _focusedKey = GlobalKey();
|
|
final Set<String> _pinned = {'confirmed'};
|
|
StreamSubscription<Message>? _focusSub;
|
|
StreamSubscription<DaemonEvent>? _fileSub;
|
|
StreamSubscription<SchedulerTick>? _schedulerSub;
|
|
StreamSubscription<ProjectOpened>? _projectSub;
|
|
bool _refreshing = false;
|
|
bool _pendingRefresh = false;
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
if (_focusSub == null) {
|
|
final kernel = ClideKernel.of(context);
|
|
_focusSub = kernel.messages.subscribe(publisher: 'builtin.decisions', channel: 'focus').listen(_onFocus);
|
|
_fileSub = kernel.events
|
|
.on<DaemonEvent>()
|
|
.where((e) => e.subsystem == 'files' && e.kind == 'files.changed' && _isDecisionPath(e.data['path'] as String? ?? ''))
|
|
.listen((_) => _refresh());
|
|
_schedulerSub = kernel.events.on<SchedulerTick>().where((e) => e.tier == SchedulerTier.oneMinute).listen((_) => _refresh());
|
|
// The first load can fire before the project's workspace is wired into
|
|
// the daemon (the boot workDir is the launch CWD, not the repo), so pql
|
|
// runs against the wrong/old DB and the list errors. Re-fetch once the
|
|
// workspace is actually open — ProjectOpened fires after the IPC server
|
|
// swaps to the project workRoot. (T-352)
|
|
_projectSub = kernel.events.on<ProjectOpened>().listen((_) => _refresh());
|
|
}
|
|
if (!_loading || _decisions.isNotEmpty) return;
|
|
unawaited(_load());
|
|
}
|
|
|
|
static bool _isDecisionPath(String path) => path.startsWith('decisions/') && path.endsWith('.md');
|
|
|
|
Future<void> _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<void> _load() async {
|
|
final kernel = ClideKernel.of(context);
|
|
await kernel.ipc.request('pql.decisions.sync');
|
|
final resp = await kernel.ipc.request('pql.decisions.list');
|
|
if (!mounted) return;
|
|
if (!resp.ok) {
|
|
setState(() {
|
|
_error =
|
|
resp.error?.message ?? ClideSettings.i18n.string(context, 'error.load', namespace: 'builtin.decisions', placeholder: 'failed to load decisions');
|
|
_loading = false;
|
|
});
|
|
return;
|
|
}
|
|
final raw = resp.data['decisions'];
|
|
if (raw is List) {
|
|
setState(() {
|
|
_decisions = [for (final e in raw) _DecisionEntry.fromJson((e as Map).cast<String, dynamic>())];
|
|
_loading = false;
|
|
});
|
|
} else {
|
|
setState(() => _loading = false);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_focusSub?.cancel();
|
|
_fileSub?.cancel();
|
|
_schedulerSub?.cancel();
|
|
_projectSub?.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;
|
|
setState(() => _focusedId = id);
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
final ctx = _focusedKey.currentContext;
|
|
if (ctx != null) Scrollable.ensureVisible(ctx, duration: const Duration(milliseconds: 200), alignment: 0.3);
|
|
});
|
|
}
|
|
|
|
void _toggleSection(String key) {
|
|
setState(() {
|
|
if (_pinned.contains(key)) {
|
|
_pinned.remove(key);
|
|
} else {
|
|
_pinned.add(key);
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final tokens = ClideSettings.theme.of(context).surface;
|
|
if (_loading) {
|
|
return Center(
|
|
child: ClideText(ClideSettings.i18n.string(context, 'loading', namespace: 'builtin.decisions', placeholder: 'Loading decisions...'), muted: true),
|
|
);
|
|
}
|
|
if (_error != null) return Padding(padding: const EdgeInsets.all(12), child: ClideText(_error!, muted: true));
|
|
if (_decisions.isEmpty) {
|
|
return Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: ClideText(
|
|
ClideSettings.i18n.string(context, 'empty', namespace: 'builtin.decisions', placeholder: 'No decisions found.\nRun `pql decisions sync` to index.'),
|
|
muted: true,
|
|
),
|
|
);
|
|
}
|
|
|
|
final lf = _filter.toLowerCase();
|
|
final hasFilter = lf.isNotEmpty;
|
|
final filtered = hasFilter
|
|
? _decisions
|
|
.where(
|
|
(d) =>
|
|
d.id.toLowerCase().contains(lf) ||
|
|
d.title.toLowerCase().contains(lf) ||
|
|
(d.domain ?? '').toLowerCase().contains(lf) ||
|
|
(d.type ?? '').contains(lf),
|
|
)
|
|
.toList()
|
|
: _decisions;
|
|
|
|
final confirmed = filtered.where((d) => d.type == 'confirmed').toList();
|
|
final questions = filtered.where((d) => d.type == 'question').toList();
|
|
final rejected = filtered.where((d) => d.type == 'rejected').toList();
|
|
|
|
final isDark = ClideSettings.theme.of(context).dark;
|
|
final typeColors = DecisionTypeColors.forTheme(dark: isDark);
|
|
|
|
return Column(
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: ClideFilterBox(
|
|
address: 'decisions.panel',
|
|
hint: ClideSettings.i18n.string(context, 'filter.hint', namespace: 'builtin.decisions', placeholder: 'Filter decisions…'),
|
|
onChanged: (v) => setState(() => _filter = v),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: 8),
|
|
child: ClideTappable(
|
|
onTap: _refreshing ? null : _refresh,
|
|
tooltip: ClideSettings.i18n.string(context, 'refresh.tooltip', namespace: 'builtin.decisions', placeholder: 'Refresh decisions'),
|
|
builder: (ctx, hovered, _) =>
|
|
ClideIcon(PhosphorIcons.byName('arrow-clockwise'), 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)
|
|
ClideAccordion(
|
|
label: ClideSettings.i18n.string(context, 'section.confirmed', namespace: 'builtin.decisions', placeholder: '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)
|
|
ClideAccordion(
|
|
label: ClideSettings.i18n.string(context, 'section.questions', namespace: 'builtin.decisions', placeholder: '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)
|
|
ClideAccordion(
|
|
label: ClideSettings.i18n.string(context, 'section.rejected', namespace: 'builtin.decisions', placeholder: '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,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DecisionEntry {
|
|
const _DecisionEntry({required this.id, required this.title, this.type, this.domain, this.status});
|
|
final String id;
|
|
final String title;
|
|
final String? type;
|
|
final String? domain;
|
|
final String? status;
|
|
|
|
factory _DecisionEntry.fromJson(Map<String, dynamic> json) => _DecisionEntry(
|
|
id: json['id'] as String? ?? '',
|
|
title: json['title'] as String? ?? '',
|
|
type: json['type'] as String?,
|
|
domain: json['domain'] as String?,
|
|
status: json['status'] as String?,
|
|
);
|
|
}
|
|
|
|
class _DecisionCard extends StatelessWidget {
|
|
const _DecisionCard({required this.entry, required this.tokens, required this.typeColors, this.focused = false, this.focusKey});
|
|
final _DecisionEntry entry;
|
|
final SurfaceTokens tokens;
|
|
final DecisionTypeColors typeColors;
|
|
final bool focused;
|
|
final GlobalKey? focusKey;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final typeColor = typeColors.forType(entry.type);
|
|
return Padding(
|
|
key: focusKey,
|
|
padding: const EdgeInsets.only(bottom: 4),
|
|
child: ClideTappable(
|
|
onTap: () => ClideKernel.of(context).messages.publish('builtin.decisions', 'selection', {'id': entry.id}),
|
|
builder: (ctx, hovered, _) => Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: hovered ? tokens.sidebarItemHover : (focused ? tokens.sidebarItemSelected : tokens.panelBackground),
|
|
borderRadius: BorderRadius.circular(4),
|
|
border: Border.all(color: focused ? tokens.globalFocus : (hovered ? tokens.panelActiveBorder : tokens.panelBorder), width: 1),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
ClideTooltip(
|
|
message: entry.type ?? 'confirmed',
|
|
child: Container(
|
|
width: 8,
|
|
height: 8,
|
|
decoration: BoxDecoration(color: typeColor, shape: BoxShape.circle),
|
|
),
|
|
),
|
|
const SizedBox(width: 6),
|
|
ClideText(entry.id, fontSize: clideFontSmall, color: tokens.globalTextMuted, fontFamily: ClideSettings.fonts.monoOf(context)),
|
|
const Spacer(),
|
|
if (entry.domain != null)
|
|
ClideText(entry.domain!, fontSize: clideFontBadge, color: tokens.globalTextMuted, fontFamily: ClideSettings.fonts.monoOf(context)),
|
|
],
|
|
),
|
|
const SizedBox(height: 4),
|
|
ClideText(entry.title, fontSize: clideFontCaption),
|
|
if (entry.status == 'resolved') ...[
|
|
const SizedBox(height: 6),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
|
decoration: BoxDecoration(color: tokens.statusSuccess.withAlpha(0x30), borderRadius: BorderRadius.circular(3)),
|
|
child: ClideText(
|
|
ClideSettings.i18n.string(context, 'badge.resolved', namespace: 'builtin.decisions', placeholder: 'resolved'),
|
|
fontSize: clideFontBadge,
|
|
color: tokens.statusSuccess,
|
|
fontFamily: ClideSettings.fonts.monoOf(context),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|