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
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:
@@ -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 *)",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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?>()];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1 +1,3 @@
|
||||
export 'src/extension.dart';
|
||||
export 'src/problems_controller.dart';
|
||||
export 'src/problems_view.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<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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user