refactor(i18n): route planning-pane labels through the catalog (T-467)
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>
This commit is contained in:
@@ -5941,3 +5941,4 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDA0YYCJ0NK8C4HMDYKJHRDG', 'status', 'backlog', 'in_progress', NULL, '2026-06-18 13:52:52', '2026-06-18 13:52:52.932', '2026-06-18 13:52:52.932', NULL, 'f0b77fdf0ef68d872155912ef3882cb1', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDA0WCPG458GF5FXTXM9VY98', 'status', 'in_progress', 'done', NULL, '2026-06-18 19:14:41', '2026-06-18 19:14:41.116', '2026-06-18 19:14:41.116', NULL, '2bf6c6fbef7a2e950fc1f31541f8dc22', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDA0XMA9BQV4RHJ55BFN4JR4', 'status', 'in_progress', 'done', NULL, '2026-06-18 19:19:25', '2026-06-18 19:19:25.269', '2026-06-18 19:19:25.269', NULL, 'f1660567ecfe0c7220c23244206bc4d2', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDA0YCY2WGSG8W46RHQ5Z9MW', 'status', 'in_progress', 'done', NULL, '2026-06-18 20:13:50', '2026-06-18 20:13:50.352', '2026-06-18 20:13:50.352', NULL, 'a885fb7b56c053669a8ef5f0e6032feb', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
|
||||
@@ -7343,3 +7343,8 @@ Hotspots from the audit:
|
||||
- lib/builtin/diff/src/diff_view.dart:105 ''No staged changes.''/''No unstaged changes.'', :153 ''Unstaged'', :163 ''Staged''
|
||||
|
||||
Acceptance: no hardcoded user-facing literals in lib/builtin/git/ or lib/builtin/diff/; keys added to builtin.git and builtin.diff catalogs. Depends on T-463 for the builtin.diff catalog file.', 'done', 'medium', NULL, NULL, 'D-21', '2026-06-17 09:57:30.322', '2026-06-18 19:19:25.268', NULL, 'c14bcdd5a816e86b78af9b5ac795b8a7', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDA0YCY2WGSG8W46RHQ5Z9MW', 'task', '06FDA0TPYSWEM10RP0Q76XAP58', 'i18n: migrate planning-pane labels — tickets, pql, decisions, problems (~21 strings)', 'The four pql/planning-surface extensions each have a tab.title-only catalog and hardcode the rest: tickets ~6, pql ~6, decisions ~6, problems ~3. Sweep all four, extend each catalog.
|
||||
|
||||
For each: route hardcoded ClideText/Text/tooltip literals through ctx.i18n.string(..., namespace: ''builtin.<ext>'', placeholder: <current literal>) and add keys.
|
||||
|
||||
Acceptance: no hardcoded user-facing literals remain in lib/builtin/{tickets,pql,decisions,problems}/; keys present in each respective catalog.', 'done', 'medium', NULL, NULL, 'D-21', '2026-06-17 09:57:36.624', '2026-06-18 20:13:50.352', NULL, '919efbfb38b655d5157f73572aa20fad', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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);
|
||||
|
||||
@@ -88,9 +88,21 @@ class _DecisionDetailViewState extends State<DecisionDetailView> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_loading) return const Center(child: ClideText('Loading…', muted: true));
|
||||
if (_loading) {
|
||||
return Center(
|
||||
child: ClideText(ClideSettings.i18n.string(context, 'detail.loading', namespace: 'builtin.decisions', placeholder: 'Loading…'), muted: true),
|
||||
);
|
||||
}
|
||||
final d = _decision;
|
||||
if (d == null) return const Padding(padding: EdgeInsets.all(12), child: ClideText('Select a decision to view details.', muted: true));
|
||||
if (d == null) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: ClideText(
|
||||
ClideSettings.i18n.string(context, 'detail.empty', namespace: 'builtin.decisions', placeholder: 'Select a decision to view details.'),
|
||||
muted: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final isDark = ClideSettings.theme.of(context).dark;
|
||||
@@ -171,7 +183,12 @@ class _DecisionDetailViewState extends State<DecisionDetailView> {
|
||||
if (body != null && body.isNotEmpty) ...[const SizedBox(height: 12), ClideMarkdown(body, onRecordTap: (id) => _navigateToRecord(context, id))],
|
||||
if (refs.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
ClideText('CROSS-REFERENCES', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
ClideText(
|
||||
ClideSettings.i18n.string(context, 'detail.section.refs', namespace: 'builtin.decisions', placeholder: 'CROSS-REFERENCES'),
|
||||
fontSize: clideFontSmall,
|
||||
color: tokens.sidebarSectionHeader,
|
||||
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
for (final ref in refs) _RefCard(ref: ref, tokens: tokens),
|
||||
],
|
||||
|
||||
@@ -74,7 +74,8 @@ class _DecisionsViewState extends State<DecisionsView> {
|
||||
if (!mounted) return;
|
||||
if (!resp.ok) {
|
||||
setState(() {
|
||||
_error = resp.error?.message ?? 'failed to load decisions';
|
||||
_error =
|
||||
resp.error?.message ?? ClideSettings.i18n.string(context, 'error.load', namespace: 'builtin.decisions', placeholder: 'failed to load decisions');
|
||||
_loading = false;
|
||||
});
|
||||
return;
|
||||
@@ -129,10 +130,20 @@ class _DecisionsViewState extends State<DecisionsView> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
if (_loading) return const Center(child: ClideText('Loading decisions...', muted: true));
|
||||
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 const Padding(padding: EdgeInsets.all(12), child: ClideText('No decisions found.\nRun `pql decisions sync` to index.', muted: true));
|
||||
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();
|
||||
@@ -161,13 +172,17 @@ class _DecisionsViewState extends State<DecisionsView> {
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClideFilterBox(address: 'decisions.panel', hint: 'Filter decisions…', onChanged: (v) => setState(() => _filter = v)),
|
||||
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: 'Refresh decisions',
|
||||
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),
|
||||
),
|
||||
@@ -182,7 +197,7 @@ class _DecisionsViewState extends State<DecisionsView> {
|
||||
children: [
|
||||
if (confirmed.isNotEmpty)
|
||||
ClideAccordion(
|
||||
label: 'CONFIRMED',
|
||||
label: ClideSettings.i18n.string(context, 'section.confirmed', namespace: 'builtin.decisions', placeholder: 'CONFIRMED'),
|
||||
count: confirmed.length,
|
||||
leading: Container(
|
||||
width: 8,
|
||||
@@ -204,7 +219,7 @@ class _DecisionsViewState extends State<DecisionsView> {
|
||||
),
|
||||
if (questions.isNotEmpty)
|
||||
ClideAccordion(
|
||||
label: 'QUESTIONS',
|
||||
label: ClideSettings.i18n.string(context, 'section.questions', namespace: 'builtin.decisions', placeholder: 'QUESTIONS'),
|
||||
count: questions.length,
|
||||
leading: Container(
|
||||
width: 8,
|
||||
@@ -226,7 +241,7 @@ class _DecisionsViewState extends State<DecisionsView> {
|
||||
),
|
||||
if (rejected.isNotEmpty)
|
||||
ClideAccordion(
|
||||
label: 'REJECTED',
|
||||
label: ClideSettings.i18n.string(context, 'section.rejected', namespace: 'builtin.decisions', placeholder: 'REJECTED'),
|
||||
count: rejected.length,
|
||||
leading: Container(
|
||||
width: 8,
|
||||
@@ -322,7 +337,12 @@ class _DecisionCard extends StatelessWidget {
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(color: tokens.statusSuccess.withAlpha(0x30), borderRadius: BorderRadius.circular(3)),
|
||||
child: ClideText('resolved', fontSize: clideFontBadge, color: tokens.statusSuccess, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
child: ClideText(
|
||||
ClideSettings.i18n.string(context, 'badge.resolved', namespace: 'builtin.decisions', placeholder: 'resolved'),
|
||||
fontSize: clideFontBadge,
|
||||
color: tokens.statusSuccess,
|
||||
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
@@ -50,6 +50,8 @@ class DecisionsExtension extends ClideExtension {
|
||||
id: 'decisions.detail',
|
||||
slot: Slots.contextPanel,
|
||||
title: 'Decision',
|
||||
titleKey: 'tab.detail.title',
|
||||
i18nNamespace: id,
|
||||
icon: PhosphorIcons.byName('lightbulb'),
|
||||
build: (_) => const DecisionDetailView(),
|
||||
),
|
||||
|
||||
@@ -42,10 +42,22 @@ class _BacklinksViewState extends State<BacklinksView> {
|
||||
builder: (context, _) {
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
if (c.activePath == null) {
|
||||
return const Padding(padding: EdgeInsets.all(12), child: ClideText('Open a file to see its links.', muted: true));
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: ClideText(
|
||||
ClideSettings.i18n.string(context, 'backlinks.empty', namespace: 'builtin.pql', placeholder: 'Open a file to see its links.'),
|
||||
muted: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
return Semantics(
|
||||
label: 'backlinks for ${c.activePath}',
|
||||
label: ClideSettings.i18n.interpolated(
|
||||
context,
|
||||
'backlinks.semantics',
|
||||
namespace: 'builtin.pql',
|
||||
placeholder: 'backlinks for {path}',
|
||||
replacers: [I18nReplacer(from: '{path}', replace: c.activePath!)],
|
||||
),
|
||||
container: true,
|
||||
explicitChildNodes: true,
|
||||
child: SingleChildScrollView(
|
||||
@@ -63,9 +75,21 @@ class _BacklinksViewState extends State<BacklinksView> {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: ClideText(c.error!, color: tokens.statusError, fontSize: clideFontCaption),
|
||||
),
|
||||
if (c.loading) const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true)),
|
||||
_LinkGroup(label: 'Backlinks', links: c.backlinks, pathKey: 'source'),
|
||||
_LinkGroup(label: 'Outlinks', links: c.outlinks, pathKey: 'target'),
|
||||
if (c.loading)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: ClideText(ClideSettings.i18n.string(context, 'backlinks.loading', namespace: 'builtin.pql', placeholder: 'Loading…'), muted: true),
|
||||
),
|
||||
_LinkGroup(
|
||||
label: ClideSettings.i18n.string(context, 'group.backlinks', namespace: 'builtin.pql', placeholder: 'Backlinks'),
|
||||
links: c.backlinks,
|
||||
pathKey: 'source',
|
||||
),
|
||||
_LinkGroup(
|
||||
label: ClideSettings.i18n.string(context, 'group.outlinks', namespace: 'builtin.pql', placeholder: 'Outlinks'),
|
||||
links: c.outlinks,
|
||||
pathKey: 'target',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -90,12 +114,29 @@ class _LinkGroup extends StatelessWidget {
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 12, right: 8, top: 8, bottom: 2),
|
||||
child: ClideText('$label (${links.length})', fontSize: clideFontCaption, muted: true),
|
||||
child: ClideText(
|
||||
ClideSettings.i18n.interpolated(
|
||||
context,
|
||||
'group.label',
|
||||
namespace: 'builtin.pql',
|
||||
placeholder: '{label} ({count})',
|
||||
replacers: [
|
||||
I18nReplacer(from: '{label}', replace: label),
|
||||
I18nReplacer(from: '{count}', replace: '${links.length}'),
|
||||
],
|
||||
),
|
||||
fontSize: clideFontCaption,
|
||||
muted: true,
|
||||
),
|
||||
),
|
||||
if (links.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20, vertical: 2),
|
||||
child: ClideText('None', fontSize: clideFontCaption, muted: true),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 2),
|
||||
child: ClideText(
|
||||
ClideSettings.i18n.string(context, 'group.none', namespace: 'builtin.pql', placeholder: 'None'),
|
||||
fontSize: clideFontCaption,
|
||||
muted: true,
|
||||
),
|
||||
),
|
||||
for (final link in links) _LinkRow(link: link, pathKey: pathKey),
|
||||
],
|
||||
|
||||
@@ -21,6 +21,8 @@ class PqlExtension extends ClideExtension {
|
||||
id: 'pql.backlinks',
|
||||
slot: Slots.contextPanel,
|
||||
title: 'Links',
|
||||
titleKey: 'tab.links.title',
|
||||
i18nNamespace: id,
|
||||
icon: PhosphorIcons.byName('link'),
|
||||
priority: -80,
|
||||
build: (_) => const BacklinksView(),
|
||||
|
||||
@@ -104,7 +104,7 @@ class _PqlSearchBodyState extends State<PqlSearchBody> {
|
||||
if (widget.mode == PqlPaneMode.markdown)
|
||||
ClideFilterBox(
|
||||
address: 'search.pql.markdown',
|
||||
hint: 'Filter markdown…',
|
||||
hint: ClideSettings.i18n.string(context, 'filter.markdown.hint', namespace: 'builtin.pql', placeholder: 'Filter markdown…'),
|
||||
onChanged: (v) => unawaited(c.loadMarkdownFiles(glob: v.isEmpty ? null : '**/*$v*.md')),
|
||||
)
|
||||
else
|
||||
@@ -118,9 +118,19 @@ class _PqlSearchBodyState extends State<PqlSearchBody> {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: ClideText(c.error!, color: tokens.statusError, fontSize: clideFontCaption, maxLines: 3),
|
||||
),
|
||||
if (c.loading && c.results.isEmpty) const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true)),
|
||||
if (c.loading && c.results.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: ClideText(ClideSettings.i18n.string(context, 'loading', namespace: 'builtin.pql', placeholder: 'Loading…'), muted: true),
|
||||
),
|
||||
if (!c.loading && c.results.isEmpty && c.error == null && widget.mode == PqlPaneMode.markdown)
|
||||
const Padding(padding: EdgeInsets.all(12), child: ClideText('No markdown files found.', muted: true)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: ClideText(
|
||||
ClideSettings.i18n.string(context, 'empty.markdown', namespace: 'builtin.pql', placeholder: 'No markdown files found.'),
|
||||
muted: true,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
@@ -160,7 +170,9 @@ class _PqlSearchInput extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return ClideFilterBox(
|
||||
address: address,
|
||||
hint: dsl ? 'PQL query…' : 'Search vault…',
|
||||
hint: dsl
|
||||
? ClideSettings.i18n.string(context, 'search.query.hint', namespace: 'builtin.pql', placeholder: 'PQL query…')
|
||||
: ClideSettings.i18n.string(context, 'search.vault.hint', namespace: 'builtin.pql', placeholder: 'Search vault…'),
|
||||
debounce: dsl ? Duration.zero : const Duration(milliseconds: 300),
|
||||
onChanged: dsl ? (_) {} : (v) => unawaited(controller.search(v)),
|
||||
onSubmitted: dsl ? (v) => unawaited(controller.runQuery(v)) : (v) => unawaited(controller.search(v)),
|
||||
|
||||
@@ -44,7 +44,7 @@ class _ProblemsViewState extends State<ProblemsView> {
|
||||
builder: (context, _) {
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return Semantics(
|
||||
label: 'problems panel',
|
||||
label: ClideSettings.i18n.string(context, 'semantics.panel', namespace: 'builtin.problems', placeholder: 'problems panel'),
|
||||
container: true,
|
||||
explicitChildNodes: true,
|
||||
child: () {
|
||||
@@ -55,30 +55,59 @@ class _ProblemsViewState extends State<ProblemsView> {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ClideFilterBox(address: 'problems.panel', hint: 'Filter problems…', onChanged: (v) => setState(() => _filter = v)),
|
||||
ClideFilterBox(
|
||||
address: 'problems.panel',
|
||||
hint: ClideSettings.i18n.string(context, 'filter.hint', namespace: 'builtin.problems', placeholder: 'Filter problems…'),
|
||||
onChanged: (v) => setState(() => _filter = v),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClideText('Problems (${filtered.length})', fontSize: clideFontCaption, color: tokens.sidebarForeground),
|
||||
child: ClideText(
|
||||
ClideSettings.i18n.interpolated(
|
||||
context,
|
||||
'count',
|
||||
namespace: 'builtin.problems',
|
||||
placeholder: 'Problems ({count})',
|
||||
replacers: [I18nReplacer(from: '{count}', replace: '${filtered.length}')],
|
||||
),
|
||||
fontSize: clideFontCaption,
|
||||
color: tokens.sidebarForeground,
|
||||
),
|
||||
),
|
||||
Semantics(
|
||||
button: true,
|
||||
label: 'refresh problems',
|
||||
label: ClideSettings.i18n.string(context, 'refresh.semantics', namespace: 'builtin.problems', placeholder: 'refresh problems'),
|
||||
child: GestureDetector(
|
||||
onTap: () => unawaited(c.refresh()),
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: ClideText('Refresh', fontSize: clideFontCaption, color: tokens.sidebarForeground),
|
||||
child: ClideText(
|
||||
ClideSettings.i18n.string(context, 'refresh.label', namespace: 'builtin.problems', placeholder: 'Refresh'),
|
||||
fontSize: clideFontCaption,
|
||||
color: tokens.sidebarForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (c.loading && c.problems.isEmpty) const Padding(padding: EdgeInsets.all(12), child: ClideText('Scanning…', muted: true)),
|
||||
if (!c.loading && filtered.isEmpty) const Padding(padding: EdgeInsets.all(12), child: ClideText('No problems found.', muted: true)),
|
||||
if (c.loading && c.problems.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: ClideText(ClideSettings.i18n.string(context, 'scanning', namespace: 'builtin.problems', placeholder: 'Scanning…'), muted: true),
|
||||
),
|
||||
if (!c.loading && filtered.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: ClideText(
|
||||
ClideSettings.i18n.string(context, 'empty', namespace: 'builtin.problems', placeholder: 'No problems found.'),
|
||||
muted: true,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
|
||||
@@ -52,6 +52,8 @@ class TicketsExtension extends ClideExtension {
|
||||
id: 'tickets.detail',
|
||||
slot: Slots.contextPanel,
|
||||
title: 'Ticket',
|
||||
titleKey: 'tab.detail.title',
|
||||
i18nNamespace: id,
|
||||
icon: PhosphorIcons.byName('ticket'),
|
||||
build: (_) => const TicketDetailView(),
|
||||
),
|
||||
|
||||
@@ -62,9 +62,21 @@ class _TicketDetailViewState extends State<TicketDetailView> {
|
||||
return ListenableBuilder(
|
||||
listenable: c,
|
||||
builder: (ctx, _) {
|
||||
if (c.loading) return const Center(child: ClideText('Loading…', muted: true));
|
||||
if (c.loading) {
|
||||
return Center(
|
||||
child: ClideText(ClideSettings.i18n.string(ctx, 'detail.loading', namespace: 'builtin.tickets', placeholder: 'Loading…'), muted: true),
|
||||
);
|
||||
}
|
||||
final d = c.detail;
|
||||
if (d == null) return const Padding(padding: EdgeInsets.all(12), child: ClideText('Select a ticket to view details.', muted: true));
|
||||
if (d == null) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: ClideText(
|
||||
ClideSettings.i18n.string(ctx, 'detail.empty', namespace: 'builtin.tickets', placeholder: 'Select a ticket to view details.'),
|
||||
muted: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final tokens = ClideSettings.theme.of(ctx).surface;
|
||||
final isDark = ClideSettings.theme.of(ctx).dark;
|
||||
@@ -99,13 +111,19 @@ class _TicketDetailViewState extends State<TicketDetailView> {
|
||||
],
|
||||
if (d.parents.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
_SectionLabel(label: 'PARENT TREE', tokens: tokens),
|
||||
_SectionLabel(
|
||||
label: ClideSettings.i18n.string(ctx, 'detail.section.parents', namespace: 'builtin.tickets', placeholder: 'PARENT TREE'),
|
||||
tokens: tokens,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
for (var i = 0; i < d.parents.length; i++) _CompactCard(data: d.parents[i], tokens: tokens, typeColors: typeColors, indent: i),
|
||||
],
|
||||
if (d.decisions.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
_SectionLabel(label: 'REFERENCED DECISIONS', tokens: tokens),
|
||||
_SectionLabel(
|
||||
label: ClideSettings.i18n.string(ctx, 'detail.section.decisions', namespace: 'builtin.tickets', placeholder: 'REFERENCED DECISIONS'),
|
||||
tokens: tokens,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
for (final dec in d.decisions) _DecisionRefCard(data: dec, tokens: tokens),
|
||||
],
|
||||
@@ -158,7 +176,18 @@ class _TicketHeader extends StatelessWidget {
|
||||
ClideText(detail.title, fontSize: 15, fontWeight: FontWeight.w500),
|
||||
if (detail.assignedTo != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
ClideText('assigned: ${detail.assignedTo}', muted: true, fontSize: clideFontSmall, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
ClideText(
|
||||
ClideSettings.i18n.interpolated(
|
||||
context,
|
||||
'detail.assigned',
|
||||
namespace: 'builtin.tickets',
|
||||
placeholder: 'assigned: {name}',
|
||||
replacers: [I18nReplacer(from: '{name}', replace: detail.assignedTo!)],
|
||||
),
|
||||
muted: true,
|
||||
fontSize: clideFontSmall,
|
||||
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -207,7 +236,7 @@ class _StatusControls extends StatelessWidget {
|
||||
border: Border.all(color: active ? tokens.statusInfo : tokens.panelBorder),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: ClideText(_shortLabel(s), fontSize: clideFontBadge, color: color, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
child: ClideText(_shortLabel(ctx, s), fontSize: clideFontBadge, color: color, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -218,12 +247,12 @@ class _StatusControls extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
static String _shortLabel(String s) => switch (s) {
|
||||
'backlog' => 'BACKLOG',
|
||||
'ready' => 'READY',
|
||||
'in_progress' => 'WIP',
|
||||
'review' => 'REVIEW',
|
||||
'done' => 'DONE',
|
||||
static String _shortLabel(BuildContext context, String s) => switch (s) {
|
||||
'backlog' => ClideSettings.i18n.string(context, 'status.backlog', namespace: 'builtin.tickets', placeholder: 'BACKLOG'),
|
||||
'ready' => ClideSettings.i18n.string(context, 'status.ready', namespace: 'builtin.tickets', placeholder: 'READY'),
|
||||
'in_progress' => ClideSettings.i18n.string(context, 'status.in_progress', namespace: 'builtin.tickets', placeholder: 'WIP'),
|
||||
'review' => ClideSettings.i18n.string(context, 'status.review', namespace: 'builtin.tickets', placeholder: 'REVIEW'),
|
||||
'done' => ClideSettings.i18n.string(context, 'status.done', namespace: 'builtin.tickets', placeholder: 'DONE'),
|
||||
_ => s.toUpperCase(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ class _TicketsViewState extends State<TicketsView> {
|
||||
if (!mounted) return;
|
||||
if (!resp.ok) {
|
||||
setState(() {
|
||||
_error = resp.error?.message ?? 'failed to load tickets';
|
||||
_error = resp.error?.message ?? ClideSettings.i18n.string(context, 'error.load', namespace: 'builtin.tickets', placeholder: 'failed to load tickets');
|
||||
_loading = false;
|
||||
});
|
||||
return;
|
||||
@@ -169,9 +169,21 @@ class _TicketsViewState extends State<TicketsView> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
if (_loading) return const Center(child: ClideText('Loading tickets...', muted: true));
|
||||
if (_loading) {
|
||||
return Center(
|
||||
child: ClideText(ClideSettings.i18n.string(context, 'loading', namespace: 'builtin.tickets', placeholder: 'Loading tickets...'), muted: true),
|
||||
);
|
||||
}
|
||||
if (_error != null) return Padding(padding: const EdgeInsets.all(12), child: ClideText(_error!, muted: true));
|
||||
if (_tickets.isEmpty) return const Padding(padding: EdgeInsets.all(12), child: ClideText('No tickets.\nRun `pql ticket new` to create one.', muted: true));
|
||||
if (_tickets.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: ClideText(
|
||||
ClideSettings.i18n.string(context, 'empty', namespace: 'builtin.tickets', placeholder: 'No tickets.\nRun `pql ticket new` to create one.'),
|
||||
muted: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final lf = _filter.toLowerCase();
|
||||
final hasTextFilter = lf.isNotEmpty;
|
||||
@@ -191,6 +203,8 @@ class _TicketsViewState extends State<TicketsView> {
|
||||
('done', 'DONE'),
|
||||
('cancelled', 'CANCELLED'),
|
||||
];
|
||||
String sectionLabel(String status, String fallback) =>
|
||||
ClideSettings.i18n.string(context, 'section.$status', namespace: 'builtin.tickets', placeholder: fallback);
|
||||
|
||||
final byStatus = <String, List<_TicketEntry>>{};
|
||||
for (final t in filtered) {
|
||||
@@ -205,13 +219,17 @@ class _TicketsViewState extends State<TicketsView> {
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClideFilterBox(address: 'tickets.panel', hint: 'Filter tickets…', onChanged: (v) => setState(() => _filter = v)),
|
||||
child: ClideFilterBox(
|
||||
address: 'tickets.panel',
|
||||
hint: ClideSettings.i18n.string(context, 'filter.hint', namespace: 'builtin.tickets', placeholder: 'Filter tickets…'),
|
||||
onChanged: (v) => setState(() => _filter = v),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ClideTappable(
|
||||
onTap: _refreshing ? null : _refresh,
|
||||
tooltip: 'Refresh tickets',
|
||||
tooltip: ClideSettings.i18n.string(context, 'refresh.tooltip', namespace: 'builtin.tickets', placeholder: 'Refresh tickets'),
|
||||
builder: (ctx, hovered, _) =>
|
||||
ClideIcon(PhosphorIcons.byName('arrow-clockwise'), size: 13, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
|
||||
),
|
||||
@@ -227,7 +245,25 @@ class _TicketsViewState extends State<TicketsView> {
|
||||
children: [
|
||||
for (final (type, label) in _typeOrder)
|
||||
_TypeChip(
|
||||
label: label,
|
||||
label: ClideSettings.i18n.string(context, 'type.$type', namespace: 'builtin.tickets', placeholder: label),
|
||||
semanticLabel: ClideSettings.i18n.interpolated(
|
||||
context,
|
||||
'chip.label',
|
||||
namespace: 'builtin.tickets',
|
||||
placeholder: '{type} type filter',
|
||||
replacers: [
|
||||
I18nReplacer(
|
||||
from: '{type}',
|
||||
replace: ClideSettings.i18n.string(context, 'type.$type', namespace: 'builtin.tickets', placeholder: label),
|
||||
),
|
||||
],
|
||||
),
|
||||
tooltip: ClideSettings.i18n.string(
|
||||
context,
|
||||
'chip.tooltip',
|
||||
namespace: 'builtin.tickets',
|
||||
placeholder: 'Click to toggle · double-click to isolate',
|
||||
),
|
||||
color: typeColors.forType(type),
|
||||
active: _enabledTypes.contains(type),
|
||||
onToggle: () => _toggleType(type),
|
||||
@@ -246,7 +282,7 @@ class _TicketsViewState extends State<TicketsView> {
|
||||
for (final (status, label) in sections)
|
||||
if (byStatus[status] case final items? when items.isNotEmpty)
|
||||
ClideAccordion(
|
||||
label: label,
|
||||
label: sectionLabel(status, label),
|
||||
count: items.length,
|
||||
expanded: filtering || _isSectionExpanded(status),
|
||||
onToggle: () => _toggle(status),
|
||||
@@ -275,9 +311,20 @@ class _TicketsViewState extends State<TicketsView> {
|
||||
/// type; double-click isolates it (chart-legend solo). One [GestureDetector]
|
||||
/// owns both so Flutter disambiguates single vs double.
|
||||
class _TypeChip extends StatelessWidget {
|
||||
const _TypeChip({required this.label, required this.color, required this.active, required this.onToggle, required this.onSolo, required this.tokens});
|
||||
const _TypeChip({
|
||||
required this.label,
|
||||
required this.semanticLabel,
|
||||
required this.tooltip,
|
||||
required this.color,
|
||||
required this.active,
|
||||
required this.onToggle,
|
||||
required this.onSolo,
|
||||
required this.tokens,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String semanticLabel;
|
||||
final String tooltip;
|
||||
final Color color;
|
||||
final bool active;
|
||||
final VoidCallback onToggle;
|
||||
@@ -290,9 +337,9 @@ class _TypeChip extends StatelessWidget {
|
||||
return Semantics(
|
||||
button: true,
|
||||
toggled: active,
|
||||
label: '$label type filter',
|
||||
label: semanticLabel,
|
||||
child: ClideTooltip(
|
||||
message: 'Click to toggle · double-click to isolate',
|
||||
message: tooltip,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
@@ -360,7 +407,7 @@ class _TicketCard extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final typeColor = typeColors.forType(entry.type);
|
||||
final statusLabel = _statusLabel(entry.status);
|
||||
final statusLabel = _statusLabel(context, entry.status);
|
||||
|
||||
return Padding(
|
||||
key: focusKey,
|
||||
@@ -436,10 +483,10 @@ class _TicketCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
static String? _statusLabel(String? status) => switch (status) {
|
||||
'in_progress' => 'WIP',
|
||||
'review' => 'REVIEW',
|
||||
'cancelled' => 'CANCELLED',
|
||||
static String? _statusLabel(BuildContext context, String? status) => switch (status) {
|
||||
'in_progress' => ClideSettings.i18n.string(context, 'badge.wip', namespace: 'builtin.tickets', placeholder: 'WIP'),
|
||||
'review' => ClideSettings.i18n.string(context, 'badge.review', namespace: 'builtin.tickets', placeholder: 'REVIEW'),
|
||||
'cancelled' => ClideSettings.i18n.string(context, 'badge.cancelled', namespace: 'builtin.tickets', placeholder: 'CANCELLED'),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
@@ -455,7 +502,7 @@ class _PickUpAction extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClideTappable(
|
||||
tooltip: 'Pick up — hand this ticket to the Claude pane',
|
||||
tooltip: ClideSettings.i18n.string(context, 'pickUp.tooltip', namespace: 'builtin.tickets', placeholder: 'Pick up — hand this ticket to the Claude pane'),
|
||||
onTap: () {
|
||||
final kernel = ClideKernel.of(context);
|
||||
unawaited(() async {
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
{
|
||||
"tab.title": { "translation": "Decisions" }
|
||||
"tab.title": { "translation": "Decisions" },
|
||||
"tab.detail.title": { "translation": "Decision" },
|
||||
"loading": { "translation": "Loading decisions..." },
|
||||
"error.load": { "translation": "failed to load decisions" },
|
||||
"empty": { "translation": "No decisions found.\nRun `pql decisions sync` to index." },
|
||||
"filter.hint": { "translation": "Filter decisions…" },
|
||||
"refresh.tooltip": { "translation": "Refresh decisions" },
|
||||
"section.confirmed": { "translation": "CONFIRMED" },
|
||||
"section.questions": { "translation": "QUESTIONS" },
|
||||
"section.rejected": { "translation": "REJECTED" },
|
||||
"badge.resolved": { "translation": "resolved" },
|
||||
"detail.loading": { "translation": "Loading…" },
|
||||
"detail.empty": { "translation": "Select a decision to view details." },
|
||||
"detail.section.refs": { "translation": "CROSS-REFERENCES" }
|
||||
}
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
{
|
||||
"tab.title": { "translation": "pql" }
|
||||
"tab.title": { "translation": "pql" },
|
||||
"tab.links.title": { "translation": "Links" },
|
||||
"filter.markdown.hint": { "translation": "Filter markdown…" },
|
||||
"loading": { "translation": "Loading…" },
|
||||
"empty.markdown": { "translation": "No markdown files found." },
|
||||
"search.vault.hint": { "translation": "Search vault…" },
|
||||
"search.query.hint": { "translation": "PQL query…" },
|
||||
"backlinks.empty": { "translation": "Open a file to see its links." },
|
||||
"backlinks.loading": { "translation": "Loading…" },
|
||||
"backlinks.semantics": { "translation": "backlinks for {path}" },
|
||||
"group.backlinks": { "translation": "Backlinks" },
|
||||
"group.outlinks": { "translation": "Outlinks" },
|
||||
"group.label": { "translation": "{label} ({count})" },
|
||||
"group.none": { "translation": "None" }
|
||||
}
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
{
|
||||
"tab.title": { "translation": "Problems" }
|
||||
"tab.title": { "translation": "Problems" },
|
||||
"semantics.panel": { "translation": "problems panel" },
|
||||
"filter.hint": { "translation": "Filter problems…" },
|
||||
"count": { "translation": "Problems ({count})" },
|
||||
"refresh.label": { "translation": "Refresh" },
|
||||
"refresh.semantics": { "translation": "refresh problems" },
|
||||
"scanning": { "translation": "Scanning…" },
|
||||
"empty": { "translation": "No problems found." }
|
||||
}
|
||||
|
||||
@@ -1,3 +1,36 @@
|
||||
{
|
||||
"tab.title": { "translation": "Tickets" }
|
||||
"tab.title": { "translation": "Tickets" },
|
||||
"tab.detail.title": { "translation": "Ticket" },
|
||||
"type.initiative": { "translation": "Initiative" },
|
||||
"type.epic": { "translation": "Epic" },
|
||||
"type.story": { "translation": "Story" },
|
||||
"type.task": { "translation": "Task" },
|
||||
"type.bug": { "translation": "Bug" },
|
||||
"loading": { "translation": "Loading tickets..." },
|
||||
"error.load": { "translation": "failed to load tickets" },
|
||||
"empty": { "translation": "No tickets.\nRun `pql ticket new` to create one." },
|
||||
"filter.hint": { "translation": "Filter tickets…" },
|
||||
"refresh.tooltip": { "translation": "Refresh tickets" },
|
||||
"section.in_progress": { "translation": "IN PROGRESS" },
|
||||
"section.review": { "translation": "REVIEW" },
|
||||
"section.ready": { "translation": "READY" },
|
||||
"section.backlog": { "translation": "BACKLOG" },
|
||||
"section.done": { "translation": "DONE" },
|
||||
"section.cancelled": { "translation": "CANCELLED" },
|
||||
"chip.label": { "translation": "{type} type filter" },
|
||||
"chip.tooltip": { "translation": "Click to toggle · double-click to isolate" },
|
||||
"badge.wip": { "translation": "WIP" },
|
||||
"badge.review": { "translation": "REVIEW" },
|
||||
"badge.cancelled": { "translation": "CANCELLED" },
|
||||
"pickUp.tooltip": { "translation": "Pick up — hand this ticket to the Claude pane" },
|
||||
"detail.loading": { "translation": "Loading…" },
|
||||
"detail.empty": { "translation": "Select a ticket to view details." },
|
||||
"detail.assigned": { "translation": "assigned: {name}" },
|
||||
"detail.section.parents": { "translation": "PARENT TREE" },
|
||||
"detail.section.decisions": { "translation": "REFERENCED DECISIONS" },
|
||||
"status.backlog": { "translation": "BACKLOG" },
|
||||
"status.ready": { "translation": "READY" },
|
||||
"status.in_progress": { "translation": "WIP" },
|
||||
"status.review": { "translation": "REVIEW" },
|
||||
"status.done": { "translation": "DONE" }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user