wire builtin.pql + builtin.problems panels
test / unit + widget + golden + a11y (push) Failing after 40s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped

PqlExtension contributes a sidebar tab (files, query, decisions,
tickets views) and a context-panel tab (backlinks + outlinks for
the active file, auto-refreshing on editor.active-changed).

ProblemsExtension contributes a sidebar tab aggregating pql.doctor
and decisions.sync diagnostics with actionable hints.

Both upgraded from stubs to 0.1.0.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-04-22 14:03:32 +02:00
co-authored by Claude
parent 93ab6ea3bd
commit e290ae6dbd
12 changed files with 1147 additions and 11 deletions
+22
View File
@@ -3,6 +3,28 @@
"allow": [
"Bash(git add *)",
"Bash(git commit *)",
"Bash(git status *)",
"Bash(git diff *)",
"Bash(git log *)",
"Bash(git show *)",
"Bash(git branch *)",
"Bash(git checkout *)",
"Bash(git switch *)",
"Bash(git merge *)",
"Bash(git rebase *)",
"Bash(git stash *)",
"Bash(git fetch *)",
"Bash(git pull *)",
"Bash(git push *)",
"Bash(git tag *)",
"Bash(git remote *)",
"Bash(git rev-parse *)",
"Bash(git symbolic-ref *)",
"Bash(git ls-remote *)",
"Bash(git config *)",
"Bash(git blame *)",
"Bash(git shortlog *)",
"Bash(git cherry-pick *)",
"Bash(dart *)",
"Bash(flutter *)",
"Bash(make *)",
+17
View File
@@ -25,6 +25,23 @@ heading, and (b) bumping `project.yaml` `version:` in the same commit.
- `Bash(pql)` and `Bash(pql *)` permissions in
`.claude/settings.json`.
- pql daemon subsystem (`lib/src/pql/`). `PqlClient` wraps the pql
CLI per D-003. IPC verbs `pql.files | meta | backlinks | outlinks
| tags | schema | query | doctor | decisions.sync | decisions.list
| decisions.show | decisions.coverage | tickets.list | tickets.show
| tickets.board | plan.status`. 15 new core tests.
- `builtin.pql` — sidebar panel with four views: Files (pql-indexed
file listing), Query (PQL DSL input + results), Decisions (synced
D/Q/R records colour-coded by type), Tickets (kanban board columns).
Context panel tab showing backlinks + outlinks for the active file,
auto-refreshing on `editor.active-changed` events.
- `builtin.problems` — sidebar panel aggregating diagnostics from
`pql.doctor` and `pql.decisions.sync`. Surfaces missing index DB,
stale skill installs, and broken decision cross-references with
actionable hints.
- Git subsystem in the daemon (`lib/src/git/`). Status parser
(`git status --porcelain`), unified-diff parser, and operations
(stage, unstage, stage-hunk, discard, commit, stash, log, pull,
+4
View File
@@ -1 +1,5 @@
export 'src/backlinks_controller.dart';
export 'src/backlinks_view.dart';
export 'src/extension.dart';
export 'src/pql_controller.dart';
export 'src/pql_panel_view.dart';
@@ -0,0 +1,73 @@
/// Tracks the active file and fetches its backlinks + outlinks
/// from pql. Subscribes to editor.active-changed to auto-refresh.
library;
import 'dart:async';
import 'package:clide_app/kernel/kernel.dart';
import 'package:flutter/foundation.dart';
class BacklinksController extends ChangeNotifier {
BacklinksController({required this.ipc, required this.events}) {
_eventSub = events.on<DaemonEvent>().listen(_onEvent);
}
final DaemonClient ipc;
final EventBus events;
StreamSubscription<DaemonEvent>? _eventSub;
String? _activePath;
String? get activePath => _activePath;
List<Map<String, Object?>> _backlinks = const [];
List<Map<String, Object?>> get backlinks => _backlinks;
List<Map<String, Object?>> _outlinks = const [];
List<Map<String, Object?>> get outlinks => _outlinks;
bool _loading = false;
bool get loading => _loading;
String? _error;
String? get error => _error;
Future<void> loadForPath(String path) async {
_activePath = path;
_loading = true;
_error = null;
notifyListeners();
final bl = await ipc.request('pql.backlinks', args: {'path': path});
final ol = await ipc.request('pql.outlinks', args: {'path': path});
_loading = false;
_backlinks = bl.ok ? _castList(bl.data['links']) : const [];
_outlinks = ol.ok ? _castList(ol.data['links']) : const [];
if (!bl.ok && !ol.ok) {
_error = bl.error?.message ?? 'backlinks failed';
}
notifyListeners();
}
void _onEvent(DaemonEvent e) {
if (e.subsystem != 'editor') return;
if (e.kind != 'editor.active-changed') return;
final path = e.data['path'] as String?;
if (path != null && path != _activePath) {
unawaited(loadForPath(path));
}
}
static List<Map<String, Object?>> _castList(Object? raw) {
if (raw is! List) return const [];
return [for (final e in raw) (e as Map).cast<String, Object?>()];
}
@override
void dispose() {
_eventSub?.cancel();
_eventSub = null;
super.dispose();
}
}
+198
View File
@@ -0,0 +1,198 @@
/// Context panel showing backlinks and outlinks for the active file.
library;
import 'dart:async';
import 'package:clide_app/kernel/kernel.dart';
import 'package:clide_app/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
import 'backlinks_controller.dart';
class BacklinksView extends StatefulWidget {
const BacklinksView({super.key});
@override
State<BacklinksView> createState() => _BacklinksViewState();
}
class _BacklinksViewState extends State<BacklinksView> {
BacklinksController? _controller;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_controller != null) return;
final kernel = ClideKernel.of(context);
_controller = BacklinksController(ipc: kernel.ipc, events: kernel.events);
}
@override
void dispose() {
_controller?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final c = _controller;
if (c == null) return const SizedBox.shrink();
return ListenableBuilder(
listenable: c,
builder: (context, _) {
final tokens = ClideTheme.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,
fontSize: 12,
),
);
}
return Semantics(
label: 'backlinks for ${c.activePath}',
container: true,
explicitChildNodes: true,
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 4),
child: ClideText(
c.activePath!.split('/').last,
fontSize: 12,
color: tokens.globalForeground,
),
),
if (c.error != null)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 4),
child: ClideText(
c.error!,
color: tokens.statusError,
fontSize: 11,
),
),
if (c.loading)
const Padding(
padding: EdgeInsets.all(12),
child: ClideText('Loading…', muted: true, fontSize: 12),
),
_LinkGroup(
label: 'Backlinks',
links: c.backlinks,
pathKey: 'source',
),
_LinkGroup(
label: 'Outlinks',
links: c.outlinks,
pathKey: 'target',
),
],
),
),
);
},
);
}
}
class _LinkGroup extends StatelessWidget {
const _LinkGroup({
required this.label,
required this.links,
required this.pathKey,
});
final String label;
final List<Map<String, Object?>> links;
final String pathKey;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding:
const EdgeInsets.only(left: 12, right: 8, top: 8, bottom: 2),
child: ClideText(
'$label (${links.length})',
fontSize: 11,
muted: true,
),
),
if (links.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 20, vertical: 2),
child: ClideText('None', fontSize: 11, muted: true),
),
for (final link in links)
_LinkRow(link: link, pathKey: pathKey),
],
);
}
}
class _LinkRow extends StatefulWidget {
const _LinkRow({required this.link, required this.pathKey});
final Map<String, Object?> link;
final String pathKey;
@override
State<_LinkRow> createState() => _LinkRowState();
}
class _LinkRowState extends State<_LinkRow> {
bool _hover = false;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final target = widget.link[widget.pathKey] as String? ?? '';
final alias = widget.link['alias'] as String?;
final display = alias ?? target;
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
if (!target.startsWith('http')) {
final kernel = ClideKernel.of(context);
unawaited(
kernel.ipc.request('editor.open', args: {'path': target}));
}
},
child: Semantics(
button: true,
label: target,
child: Container(
color: _hover ? tokens.sidebarItemHover : null,
padding:
const EdgeInsets.symmetric(horizontal: 20, vertical: 2),
child: ClideText(
display,
fontSize: 12,
maxLines: 1,
overflow: TextOverflow.ellipsis,
color: target.startsWith('http')
? tokens.statusInfo
: tokens.sidebarForeground,
),
),
),
),
);
}
}
+25 -5
View File
@@ -1,17 +1,37 @@
import 'package:clide_app/builtin/pql/src/backlinks_view.dart';
import 'package:clide_app/builtin/pql/src/pql_panel_view.dart';
import 'package:clide_app/extension/extension.dart';
import 'package:clide_app/kernel/kernel.dart';
/// Tier-0 stub. Real implementation lands in a later tier; the extension
/// is registered so the extensions-ui surface can list it as "installed,
/// not yet implemented" and its id is reserved.
class PqlExtension extends ClideExtension {
@override
String get id => 'builtin.pql';
@override
String get title => 'pql';
@override
String get version => '0.0.0-stub';
String get version => '0.1.0';
@override
List<String> get dependsOn => const [];
@override
List<ContributionPoint> get contributions => const [];
List<ContributionPoint> get contributions => [
TabContribution(
id: 'pql.panel',
slot: Slots.sidebar,
title: 'pql',
titleKey: 'tab.title',
i18nNamespace: id,
priority: -60,
build: (_) => const PqlPanelView(),
),
TabContribution(
id: 'pql.backlinks',
slot: Slots.contextPanel,
title: 'Links',
titleKey: 'tab.links',
i18nNamespace: id,
priority: -80,
build: (_) => const BacklinksView(),
),
];
}
+147
View File
@@ -0,0 +1,147 @@
/// State model for the pql sidebar panel.
///
/// Manages schema cache, query execution, file listing, and
/// decision/ticket views. All data comes through pql.* IPC verbs.
library;
import 'dart:async';
import 'package:clide_app/kernel/kernel.dart';
import 'package:flutter/foundation.dart';
enum PqlView { files, query, decisions, tickets }
class PqlController extends ChangeNotifier {
PqlController({required this.ipc});
final DaemonClient ipc;
PqlView _view = PqlView.files;
PqlView get view => _view;
String? _error;
String? get error => _error;
bool _loading = false;
bool get loading => _loading;
List<Map<String, Object?>> _results = const [];
List<Map<String, Object?>> get results => _results;
Map<String, Object?> _planStatus = const {};
Map<String, Object?> get planStatus => _planStatus;
void switchView(PqlView v) {
if (_view == v) return;
_view = v;
_results = const [];
_error = null;
notifyListeners();
switch (v) {
case PqlView.files:
unawaited(loadFiles());
case PqlView.decisions:
unawaited(loadDecisions());
case PqlView.tickets:
unawaited(loadTickets());
case PqlView.query:
break;
}
}
Future<void> loadFiles({String? glob}) async {
_loading = true;
notifyListeners();
final r = await ipc.request('pql.files', args: {
if (glob != null) 'glob': glob,
'limit': 200,
});
_loading = false;
if (!r.ok) {
_error = r.error?.message;
notifyListeners();
return;
}
_error = null;
_results = _castList(r.data['files']);
notifyListeners();
}
Future<void> runQuery(String dsl) async {
if (dsl.trim().isEmpty) return;
_loading = true;
_error = null;
notifyListeners();
final r = await ipc.request('pql.query', args: {
'query': dsl,
'limit': 200,
});
_loading = false;
if (!r.ok) {
_error = r.error?.message;
_results = const [];
notifyListeners();
return;
}
_results = _castList(r.data['results']);
notifyListeners();
}
Future<void> loadDecisions() async {
_loading = true;
notifyListeners();
await ipc.request('pql.decisions.sync');
final r = await ipc.request('pql.decisions.list');
_loading = false;
if (!r.ok) {
_error = r.error?.message;
notifyListeners();
return;
}
_error = null;
_results = _castList(r.data['decisions']);
notifyListeners();
}
Future<void> loadTickets() async {
_loading = true;
notifyListeners();
final r = await ipc.request('pql.tickets.board');
_loading = false;
if (!r.ok) {
_error = r.error?.message;
notifyListeners();
return;
}
_error = null;
_results = _castList(r.data['columns']);
notifyListeners();
}
Future<void> loadPlanStatus() async {
final r = await ipc.request('pql.plan.status');
if (r.ok) {
_planStatus = r.data;
notifyListeners();
}
}
void clearError() {
if (_error == null) return;
_error = null;
notifyListeners();
}
static List<Map<String, Object?>> _castList(Object? raw) {
if (raw is! List) return const [];
return [for (final e in raw) (e as Map).cast<String, Object?>()];
}
}
+379
View File
@@ -0,0 +1,379 @@
/// Sidebar panel for pql — file listing, DSL query input,
/// decisions list, and ticket board views.
library;
import 'dart:async';
import 'package:clide_app/kernel/kernel.dart';
import 'package:clide_app/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
import 'pql_controller.dart';
class PqlPanelView extends StatefulWidget {
const PqlPanelView({super.key});
@override
State<PqlPanelView> createState() => _PqlPanelViewState();
}
class _PqlPanelViewState extends State<PqlPanelView> {
PqlController? _controller;
final TextEditingController _queryInput = TextEditingController();
final FocusNode _queryFocus = FocusNode();
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_controller != null) return;
final kernel = ClideKernel.of(context);
_controller = PqlController(ipc: kernel.ipc);
unawaited(_controller!.loadFiles());
}
@override
void dispose() {
_controller?.dispose();
_queryInput.dispose();
_queryFocus.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final c = _controller;
if (c == null) return const SizedBox.shrink();
return ListenableBuilder(
listenable: c,
builder: (context, _) {
final tokens = ClideTheme.of(context).surface;
return Semantics(
label: 'pql panel',
container: true,
explicitChildNodes: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_ViewTabs(controller: c),
if (c.view == PqlView.query)
_QueryInput(
input: _queryInput,
focus: _queryFocus,
controller: c,
),
if (c.error != null)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 4),
child: ClideText(
c.error!,
color: tokens.statusError,
fontSize: 11,
maxLines: 3,
),
),
if (c.loading && c.results.isEmpty)
const Padding(
padding: EdgeInsets.all(12),
child: ClideText('Loading…', muted: true, fontSize: 12),
),
if (!c.loading && c.results.isEmpty && c.error == null)
const Padding(
padding: EdgeInsets.all(12),
child: ClideText('No results.', muted: true, fontSize: 12),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (c.view == PqlView.files)
for (final f in c.results) _FileRow(entry: f),
if (c.view == PqlView.query)
for (final r in c.results) _QueryResultRow(entry: r),
if (c.view == PqlView.decisions)
for (final d in c.results) _DecisionRow(entry: d),
if (c.view == PqlView.tickets)
for (final col in c.results) _TicketColumn(column: col),
],
),
),
),
],
),
);
},
);
}
}
class _ViewTabs extends StatelessWidget {
const _ViewTabs({required this.controller});
final PqlController controller;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: tokens.panelBorder)),
),
child: Row(
children: [
for (final v in PqlView.values)
Padding(
padding: const EdgeInsets.only(right: 8),
child: Semantics(
button: true,
toggled: controller.view == v,
label: v.name,
child: GestureDetector(
onTap: () => controller.switchView(v),
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: ClideText(
_tabLabel(v),
fontSize: 11,
color: controller.view == v
? tokens.globalForeground
: tokens.globalTextMuted,
),
),
),
),
),
],
),
);
}
static String _tabLabel(PqlView v) => switch (v) {
PqlView.files => 'Files',
PqlView.query => 'Query',
PqlView.decisions => 'Decisions',
PqlView.tickets => 'Tickets',
};
}
class _QueryInput extends StatelessWidget {
const _QueryInput({
required this.input,
required this.focus,
required this.controller,
});
final TextEditingController input;
final FocusNode focus;
final PqlController controller;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Semantics(
label: 'pql query',
textField: true,
child: Container(
decoration: BoxDecoration(
border: Border.all(color: tokens.globalBorder),
),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: EditableText(
controller: input,
focusNode: focus,
style: TextStyle(
fontFamily: clideMonoFamily,
fontSize: 12,
color: tokens.globalForeground,
),
cursorColor: tokens.globalFocus,
backgroundCursorColor: tokens.globalFocus,
maxLines: 1,
onSubmitted: (_) =>
unawaited(controller.runQuery(input.text)),
),
),
),
);
}
}
class _FileRow extends StatefulWidget {
const _FileRow({required this.entry});
final Map<String, Object?> entry;
@override
State<_FileRow> createState() => _FileRowState();
}
class _FileRowState extends State<_FileRow> {
bool _hover = false;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final path = widget.entry['path'] as String? ?? '';
final name = widget.entry['name'] as String? ?? path.split('/').last;
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
final kernel = ClideKernel.of(context);
unawaited(
kernel.ipc.request('editor.open', args: {'path': path}));
},
child: Semantics(
button: true,
label: 'Open $name',
child: Container(
color: _hover ? tokens.sidebarItemHover : null,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 3),
child: ClideText(
path,
fontSize: 12,
maxLines: 1,
overflow: TextOverflow.ellipsis,
color: tokens.sidebarForeground,
),
),
),
),
);
}
}
class _QueryResultRow extends StatelessWidget {
const _QueryResultRow({required this.entry});
final Map<String, Object?> entry;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final name = entry['name'] as String? ?? entry['path'] as String? ?? '';
final values = entry.entries
.where((e) => e.key != 'name' && e.key != 'path')
.map((e) => '${e.key}: ${e.value}')
.join(' · ');
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
ClideText(name, fontSize: 12, color: tokens.sidebarForeground),
if (values.isNotEmpty)
ClideText(values, fontSize: 10, muted: true, maxLines: 2),
],
),
);
}
}
class _DecisionRow extends StatelessWidget {
const _DecisionRow({required this.entry});
final Map<String, Object?> entry;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final id = entry['id'] as String? ?? '';
final title = entry['title'] as String? ?? '';
final type = entry['type'] as String? ?? '';
final domain = entry['domain'] as String? ?? '';
final Color idColor = switch (type) {
'confirmed' => tokens.statusSuccess,
'question' => tokens.statusWarning,
'rejected' => tokens.statusError,
_ => tokens.sidebarForeground,
};
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
child: Row(
children: [
SizedBox(
width: 44,
child: ClideText(id, fontSize: 11, color: idColor,
fontFamily: clideMonoFamily),
),
const SizedBox(width: 4),
Expanded(
child: ClideText(
title,
fontSize: 12,
maxLines: 1,
overflow: TextOverflow.ellipsis,
color: tokens.sidebarForeground,
),
),
ClideText(domain, fontSize: 10, muted: true),
],
),
);
}
}
class _TicketColumn extends StatelessWidget {
const _TicketColumn({required this.column});
final Map<String, Object?> column;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final status = column['status'] as String? ?? '';
final tickets = (column['tickets'] as List?) ?? const [];
if (tickets.isEmpty) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding:
const EdgeInsets.only(left: 12, right: 8, top: 8, bottom: 2),
child: ClideText(
'$status (${tickets.length})',
fontSize: 11,
muted: true,
),
),
for (final t in tickets)
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 20, vertical: 2),
child: Row(
children: [
SizedBox(
width: 44,
child: ClideText(
(t as Map)['id'] as String? ?? '',
fontSize: 11,
fontFamily: clideMonoFamily,
color: tokens.statusInfo,
),
),
const SizedBox(width: 4),
Expanded(
child: ClideText(
t['title'] as String? ?? '',
fontSize: 12,
maxLines: 1,
overflow: TextOverflow.ellipsis,
color: tokens.sidebarForeground,
),
),
],
),
),
],
);
}
}
+2
View File
@@ -1 +1,3 @@
export 'src/extension.dart';
export 'src/problems_controller.dart';
export 'src/problems_view.dart';
+16 -6
View File
@@ -1,17 +1,27 @@
import 'package:clide_app/builtin/problems/src/problems_view.dart';
import 'package:clide_app/extension/extension.dart';
import 'package:clide_app/kernel/kernel.dart';
/// Tier-0 stub. Real implementation lands in a later tier; the extension
/// is registered so the extensions-ui surface can list it as "installed,
/// not yet implemented" and its id is reserved.
class ProblemsExtension extends ClideExtension {
@override
String get id => 'builtin.problems';
@override
String get title => 'Problems';
@override
String get version => '0.0.0-stub';
String get version => '0.1.0';
@override
List<String> get dependsOn => const [];
List<String> get dependsOn => const ['builtin.pql'];
@override
List<ContributionPoint> get contributions => const [];
List<ContributionPoint> get contributions => [
TabContribution(
id: 'problems.panel',
slot: Slots.sidebar,
title: 'Problems',
titleKey: 'tab.title',
i18nNamespace: id,
priority: -50,
build: (_) => const ProblemsView(),
),
];
}
@@ -0,0 +1,102 @@
/// State model for the problems panel.
///
/// Aggregates diagnostic information from pql.doctor and
/// pql.decisions.validate (via pql.decisions.sync which reports
/// broken refs). Refreshes on demand.
library;
import 'dart:async';
import 'package:clide_app/kernel/kernel.dart';
import 'package:flutter/foundation.dart';
class Problem {
const Problem({required this.source, required this.message, this.hint});
final String source;
final String message;
final String? hint;
Map<String, Object?> toJson() => {
'source': source,
'message': message,
if (hint != null) 'hint': hint,
};
}
class ProblemsController extends ChangeNotifier {
ProblemsController({required this.ipc});
final DaemonClient ipc;
List<Problem> _problems = const [];
List<Problem> get problems => _problems;
bool _loading = false;
bool get loading => _loading;
String? _error;
String? get error => _error;
Future<void> refresh() async {
_loading = true;
notifyListeners();
final found = <Problem>[];
final doctor = await ipc.request('pql.doctor');
if (doctor.ok) {
final db = (doctor.data['db'] as Map?)?.cast<String, Object?>();
if (db != null && db['exists'] == false) {
found.add(const Problem(
source: 'pql',
message: 'pql index database not found',
hint: 'Run pql to build the index.',
));
}
final skill = (doctor.data['skill'] as Map?)?.cast<String, Object?>();
if (skill != null) {
final project =
(skill['project'] as Map?)?.cast<String, Object?>();
if (project != null) {
final state = project['state'] as String?;
if (state == 'stale') {
found.add(const Problem(
source: 'pql',
message: 'pql skill is stale — newer version available',
hint: 'Run: pql skill install',
));
} else if (state == 'missing') {
found.add(const Problem(
source: 'pql',
message: 'pql skill not installed',
hint: 'Run: pql init --with-skill=yes',
));
}
}
}
} else {
found.add(Problem(
source: 'pql',
message: 'pql doctor failed',
hint: doctor.error?.message,
));
}
final sync = await ipc.request('pql.decisions.sync');
if (sync.ok) {
final broken = (sync.data['broken'] as num?)?.toInt() ?? 0;
if (broken > 0) {
found.add(Problem(
source: 'decisions',
message: '$broken broken cross-reference(s) in decisions/',
hint: 'Run: pql decisions validate',
));
}
}
_loading = false;
_error = null;
_problems = found;
notifyListeners();
}
}
@@ -0,0 +1,162 @@
/// Sidebar panel showing project diagnostics from pql.
library;
import 'dart:async';
import 'package:clide_app/kernel/kernel.dart';
import 'package:clide_app/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
import 'problems_controller.dart';
class ProblemsView extends StatefulWidget {
const ProblemsView({super.key});
@override
State<ProblemsView> createState() => _ProblemsViewState();
}
class _ProblemsViewState extends State<ProblemsView> {
ProblemsController? _controller;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_controller != null) return;
final kernel = ClideKernel.of(context);
_controller = ProblemsController(ipc: kernel.ipc);
unawaited(_controller!.refresh());
}
@override
void dispose() {
_controller?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final c = _controller;
if (c == null) return const SizedBox.shrink();
return ListenableBuilder(
listenable: c,
builder: (context, _) {
final tokens = ClideTheme.of(context).surface;
return Semantics(
label: 'problems panel',
container: true,
explicitChildNodes: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 6),
child: Row(
children: [
Expanded(
child: ClideText(
'Problems (${c.problems.length})',
fontSize: 12,
color: tokens.sidebarForeground,
),
),
Semantics(
button: true,
label: 'refresh problems',
child: GestureDetector(
onTap: () => unawaited(c.refresh()),
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: ClideText(
'Refresh',
fontSize: 10,
color: tokens.sidebarForeground,
),
),
),
),
],
),
),
if (c.loading && c.problems.isEmpty)
const Padding(
padding: EdgeInsets.all(12),
child: ClideText('Scanning…', muted: true, fontSize: 12),
),
if (!c.loading && c.problems.isEmpty)
const Padding(
padding: EdgeInsets.all(12),
child: ClideText(
'No problems found.',
muted: true,
fontSize: 12,
),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
for (final p in c.problems) _ProblemRow(problem: p),
],
),
),
),
],
),
);
},
);
}
}
class _ProblemRow extends StatelessWidget {
const _ProblemRow({required this.problem});
final Problem problem;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
ClideText(
problem.source,
fontSize: 10,
color: tokens.statusWarning,
fontFamily: clideMonoFamily,
),
const SizedBox(width: 6),
Expanded(
child: ClideText(
problem.message,
fontSize: 12,
color: tokens.sidebarForeground,
maxLines: 2,
),
),
],
),
if (problem.hint != null)
Padding(
padding: const EdgeInsets.only(left: 44, top: 2),
child: ClideText(
problem.hint!,
fontSize: 10,
muted: true,
fontFamily: clideMonoFamily,
),
),
],
),
);
}
}