diff --git a/.claude/settings.json b/.claude/settings.json index bac52e64..f6f82c6b 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -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 *)", diff --git a/CHANGELOG.md b/CHANGELOG.md index 51f277cd..bc9740f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, diff --git a/app/lib/builtin/pql/pql.dart b/app/lib/builtin/pql/pql.dart index b968b883..fca71c8b 100644 --- a/app/lib/builtin/pql/pql.dart +++ b/app/lib/builtin/pql/pql.dart @@ -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'; diff --git a/app/lib/builtin/pql/src/backlinks_controller.dart b/app/lib/builtin/pql/src/backlinks_controller.dart new file mode 100644 index 00000000..b2aad7e4 --- /dev/null +++ b/app/lib/builtin/pql/src/backlinks_controller.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().listen(_onEvent); + } + + final DaemonClient ipc; + final EventBus events; + + StreamSubscription? _eventSub; + + String? _activePath; + String? get activePath => _activePath; + + List> _backlinks = const []; + List> get backlinks => _backlinks; + + List> _outlinks = const []; + List> get outlinks => _outlinks; + + bool _loading = false; + bool get loading => _loading; + + String? _error; + String? get error => _error; + + Future 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> _castList(Object? raw) { + if (raw is! List) return const []; + return [for (final e in raw) (e as Map).cast()]; + } + + @override + void dispose() { + _eventSub?.cancel(); + _eventSub = null; + super.dispose(); + } +} diff --git a/app/lib/builtin/pql/src/backlinks_view.dart b/app/lib/builtin/pql/src/backlinks_view.dart new file mode 100644 index 00000000..10de2fb0 --- /dev/null +++ b/app/lib/builtin/pql/src/backlinks_view.dart @@ -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 createState() => _BacklinksViewState(); +} + +class _BacklinksViewState extends State { + 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> 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 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, + ), + ), + ), + ), + ); + } +} diff --git a/app/lib/builtin/pql/src/extension.dart b/app/lib/builtin/pql/src/extension.dart index cd118ae4..075c07a7 100644 --- a/app/lib/builtin/pql/src/extension.dart +++ b/app/lib/builtin/pql/src/extension.dart @@ -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 get dependsOn => const []; + @override - List get contributions => const []; + List 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(), + ), + ]; } diff --git a/app/lib/builtin/pql/src/pql_controller.dart b/app/lib/builtin/pql/src/pql_controller.dart new file mode 100644 index 00000000..b7a6d0d6 --- /dev/null +++ b/app/lib/builtin/pql/src/pql_controller.dart @@ -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> _results = const []; + List> get results => _results; + + Map _planStatus = const {}; + Map 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 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 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 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 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 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> _castList(Object? raw) { + if (raw is! List) return const []; + return [for (final e in raw) (e as Map).cast()]; + } +} diff --git a/app/lib/builtin/pql/src/pql_panel_view.dart b/app/lib/builtin/pql/src/pql_panel_view.dart new file mode 100644 index 00000000..cba8e718 --- /dev/null +++ b/app/lib/builtin/pql/src/pql_panel_view.dart @@ -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 createState() => _PqlPanelViewState(); +} + +class _PqlPanelViewState extends State { + 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 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 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 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 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, + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/app/lib/builtin/problems/problems.dart b/app/lib/builtin/problems/problems.dart index b968b883..cd4ffed1 100644 --- a/app/lib/builtin/problems/problems.dart +++ b/app/lib/builtin/problems/problems.dart @@ -1 +1,3 @@ export 'src/extension.dart'; +export 'src/problems_controller.dart'; +export 'src/problems_view.dart'; diff --git a/app/lib/builtin/problems/src/extension.dart b/app/lib/builtin/problems/src/extension.dart index 7337459b..107e1d2e 100644 --- a/app/lib/builtin/problems/src/extension.dart +++ b/app/lib/builtin/problems/src/extension.dart @@ -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 get dependsOn => const []; + List get dependsOn => const ['builtin.pql']; + @override - List get contributions => const []; + List get contributions => [ + TabContribution( + id: 'problems.panel', + slot: Slots.sidebar, + title: 'Problems', + titleKey: 'tab.title', + i18nNamespace: id, + priority: -50, + build: (_) => const ProblemsView(), + ), + ]; } diff --git a/app/lib/builtin/problems/src/problems_controller.dart b/app/lib/builtin/problems/src/problems_controller.dart new file mode 100644 index 00000000..540a1950 --- /dev/null +++ b/app/lib/builtin/problems/src/problems_controller.dart @@ -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 toJson() => { + 'source': source, + 'message': message, + if (hint != null) 'hint': hint, + }; +} + +class ProblemsController extends ChangeNotifier { + ProblemsController({required this.ipc}); + + final DaemonClient ipc; + + List _problems = const []; + List get problems => _problems; + + bool _loading = false; + bool get loading => _loading; + + String? _error; + String? get error => _error; + + Future refresh() async { + _loading = true; + notifyListeners(); + + final found = []; + + final doctor = await ipc.request('pql.doctor'); + if (doctor.ok) { + final db = (doctor.data['db'] as Map?)?.cast(); + 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(); + if (skill != null) { + final project = + (skill['project'] as Map?)?.cast(); + 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(); + } +} diff --git a/app/lib/builtin/problems/src/problems_view.dart b/app/lib/builtin/problems/src/problems_view.dart new file mode 100644 index 00000000..53cf0c16 --- /dev/null +++ b/app/lib/builtin/problems/src/problems_view.dart @@ -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 createState() => _ProblemsViewState(); +} + +class _ProblemsViewState extends State { + 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, + ), + ), + ], + ), + ); + } +}