From 8a94fd07a7a3c052d9079370a2d5da81f78077ff Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 22 Apr 2026 23:17:03 +0200 Subject: [PATCH] wire decisions, tickets, markdown viewer, and graph extensions Four stub extensions now contribute real tabs with views: - Decisions panel (sidebar, priority -20): lists confirmed D-records from pql decisions list with ID, title, and domain. - Tickets panel (sidebar, priority -10): lists tickets with status dot color-coded by state (done=green, in_progress=blue, cancelled=red). - Markdown viewer (context, priority -100): shows raw content of the active .md file, listens for editor.buffer_activated events. - Graph view (context, priority -80): lists files with inbound and outbound link counts from pql search --connections. All four fetch data via the daemon's pql.exec IPC surface. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 12 ++ .../builtin/decisions/src/decisions_view.dart | 128 ++++++++++++++++ app/lib/builtin/decisions/src/extension.dart | 20 ++- app/lib/builtin/graph/src/extension.dart | 20 ++- app/lib/builtin/graph/src/graph_view.dart | 122 +++++++++++++++ app/lib/builtin/markdown/src/extension.dart | 20 ++- .../builtin/markdown/src/markdown_viewer.dart | 94 ++++++++++++ app/lib/builtin/tickets/src/extension.dart | 21 ++- app/lib/builtin/tickets/src/tickets_view.dart | 140 ++++++++++++++++++ 9 files changed, 556 insertions(+), 21 deletions(-) create mode 100644 app/lib/builtin/decisions/src/decisions_view.dart create mode 100644 app/lib/builtin/graph/src/graph_view.dart create mode 100644 app/lib/builtin/markdown/src/markdown_viewer.dart create mode 100644 app/lib/builtin/tickets/src/tickets_view.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index b97d7c8e..bff975c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,18 @@ heading, and (b) bumping `project.yaml` `version:` in the same commit. weights. Replaces hand-painted CustomPaint icons in sidebar and context panel icon rails. +- Decisions panel in sidebar — lists confirmed D-records from + `pql decisions list` with ID and title (T-037). + +- Tickets panel in sidebar — lists tickets from `pql ticket list` + with status dot color-coded by state (T-037). + +- Markdown viewer in context panel — shows raw content of the + active .md file, auto-updating on buffer switch (T-038). + +- Graph view in context panel — lists files with inbound/outbound + link counts from `pql search --connections` (T-039). + ### Changed - Workspace renders Claude as the always-visible primary surface diff --git a/app/lib/builtin/decisions/src/decisions_view.dart b/app/lib/builtin/decisions/src/decisions_view.dart new file mode 100644 index 00000000..5754a31d --- /dev/null +++ b/app/lib/builtin/decisions/src/decisions_view.dart @@ -0,0 +1,128 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:clide_app/kernel/kernel.dart'; +import 'package:clide_app/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; + +class DecisionsView extends StatefulWidget { + const DecisionsView({super.key}); + + @override + State createState() => _DecisionsViewState(); +} + +class _DecisionsViewState extends State { + List<_DecisionEntry> _decisions = []; + String? _error; + bool _loading = true; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_loading || _decisions.isNotEmpty) return; + unawaited(_load()); + } + + Future _load() async { + final kernel = ClideKernel.of(context); + final resp = await kernel.ipc.request('pql.exec', args: { + 'argv': ['decisions', 'list', '--type', 'confirmed'], + }); + if (!mounted) return; + if (!resp.ok) { + setState(() { + _error = resp.error?.message ?? 'failed to load decisions'; + _loading = false; + }); + return; + } + final raw = resp.data['stdout'] as String? ?? '[]'; + try { + final list = (jsonDecode(raw) as List).cast>(); + setState(() { + _decisions = list.map(_DecisionEntry.fromJson).toList(); + _loading = false; + }); + } catch (e) { + setState(() { + _error = 'parse error: $e'; + _loading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + if (_loading) { + return const Center(child: ClideText('Loading decisions...', muted: true)); + } + if (_error != null) { + return Padding( + padding: const EdgeInsets.all(12), + child: ClideText(_error!, muted: true), + ); + } + if (_decisions.isEmpty) { + return const Padding( + padding: EdgeInsets.all(12), + child: ClideText('No decisions found.\nRun `pql decisions sync` to index.', muted: true), + ); + } + return ListView.builder( + itemCount: _decisions.length, + itemBuilder: (ctx, i) { + final d = _decisions[i]; + return _DecisionRow(entry: d, tokens: tokens); + }, + ); + } +} + +class _DecisionEntry { + const _DecisionEntry({required this.id, required this.title, this.domain, this.status}); + final String id; + final String title; + final String? domain; + final String? status; + + factory _DecisionEntry.fromJson(Map json) => _DecisionEntry( + id: json['id'] as String? ?? '', + title: json['title'] as String? ?? '', + domain: json['domain'] as String?, + status: json['status'] as String?, + ); +} + +class _DecisionRow extends StatefulWidget { + const _DecisionRow({required this.entry, required this.tokens}); + final _DecisionEntry entry; + final SurfaceTokens tokens; + + @override + State<_DecisionRow> createState() => _DecisionRowState(); +} + +class _DecisionRowState extends State<_DecisionRow> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: Container( + color: _hovered ? widget.tokens.listItemHoverBackground : null, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: Row( + children: [ + ClideText(widget.entry.id, color: widget.tokens.globalTextMuted, fontSize: 12), + const SizedBox(width: 8), + Expanded(child: ClideText(widget.entry.title, fontSize: 13)), + ], + ), + ), + ); + } +} diff --git a/app/lib/builtin/decisions/src/extension.dart b/app/lib/builtin/decisions/src/extension.dart index 7fadbe4d..75ed0ce7 100644 --- a/app/lib/builtin/decisions/src/extension.dart +++ b/app/lib/builtin/decisions/src/extension.dart @@ -1,17 +1,27 @@ +import 'package:clide_app/builtin/decisions/src/decisions_view.dart'; import 'package:clide_app/extension/extension.dart'; +import 'package:clide_app/kernel/kernel.dart'; -/// Tier-reserved stub. Will surface a sidebar tab (filter by domain / -/// status, backlinks from current file) + commands (`decisions.open`, -/// `decisions.claim`, `decisions.amend`). Data source: `pql decisions …`. class DecisionsExtension extends ClideExtension { @override String get id => 'builtin.decisions'; @override String get title => 'Decisions'; @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: 'decisions.panel', + slot: Slots.sidebar, + title: 'Decisions', + titleKey: 'tab.title', + i18nNamespace: id, + priority: -20, + build: (_) => const DecisionsView(), + ), + ]; } diff --git a/app/lib/builtin/graph/src/extension.dart b/app/lib/builtin/graph/src/extension.dart index ca0d9da7..692a084e 100644 --- a/app/lib/builtin/graph/src/extension.dart +++ b/app/lib/builtin/graph/src/extension.dart @@ -1,17 +1,27 @@ +import 'package:clide_app/builtin/graph/src/graph_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 GraphExtension extends ClideExtension { @override String get id => 'builtin.graph'; @override String get title => 'Graph'; @override - String get version => '0.0.0-stub'; + String get version => '0.1.0'; @override List get dependsOn => const ['builtin.pql']; + @override - List get contributions => const []; + List get contributions => [ + TabContribution( + id: 'graph.view', + slot: Slots.contextPanel, + title: 'Graph', + titleKey: 'tab.graph', + i18nNamespace: id, + priority: -80, + build: (_) => const GraphView(), + ), + ]; } diff --git a/app/lib/builtin/graph/src/graph_view.dart b/app/lib/builtin/graph/src/graph_view.dart new file mode 100644 index 00000000..0823caa0 --- /dev/null +++ b/app/lib/builtin/graph/src/graph_view.dart @@ -0,0 +1,122 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:clide_app/kernel/kernel.dart'; +import 'package:clide_app/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; + +class GraphView extends StatefulWidget { + const GraphView({super.key}); + + @override + State createState() => _GraphViewState(); +} + +class _GraphViewState extends State { + List<_GraphNode> _nodes = []; + String? _error; + bool _loading = true; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_loading || _nodes.isNotEmpty) return; + unawaited(_load()); + } + + Future _load() async { + final kernel = ClideKernel.of(context); + final resp = await kernel.ipc.request('pql.exec', args: { + 'argv': ['search', '--connections', '--limit', '50'], + }); + if (!mounted) return; + if (!resp.ok) { + setState(() { + _error = resp.error?.message ?? 'failed to load graph'; + _loading = false; + }); + return; + } + final raw = resp.data['stdout'] as String? ?? '[]'; + try { + final list = (jsonDecode(raw) as List).cast>(); + setState(() { + _nodes = list.map(_GraphNode.fromJson).toList(); + _loading = false; + }); + } catch (e) { + setState(() { + _error = 'parse error: $e'; + _loading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + if (_loading) { + return const Center(child: ClideText('Loading graph...', muted: true)); + } + if (_error != null) { + return Padding(padding: const EdgeInsets.all(12), child: ClideText(_error!, muted: true)); + } + if (_nodes.isEmpty) { + return const Padding( + padding: EdgeInsets.all(12), + child: ClideText('No linked files found.\nAdd wikilinks to your markdown files.', muted: true), + ); + } + return ListView.builder( + itemCount: _nodes.length, + itemBuilder: (ctx, i) { + final n = _nodes[i]; + return _NodeRow(node: n, tokens: tokens); + }, + ); + } +} + +class _GraphNode { + const _GraphNode({required this.path, this.inbound = 0, this.outbound = 0}); + final String path; + final int inbound; + final int outbound; + + factory _GraphNode.fromJson(Map json) => _GraphNode( + path: json['path'] as String? ?? json['relative_path'] as String? ?? '', + inbound: (json['inbound_count'] as num?)?.toInt() ?? 0, + outbound: (json['outbound_count'] as num?)?.toInt() ?? 0, + ); +} + +class _NodeRow extends StatefulWidget { + const _NodeRow({required this.node, required this.tokens}); + final _GraphNode node; + final SurfaceTokens tokens; + + @override + State<_NodeRow> createState() => _NodeRowState(); +} + +class _NodeRowState extends State<_NodeRow> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: Container( + color: _hovered ? widget.tokens.listItemHoverBackground : null, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: Row( + children: [ + Expanded(child: ClideText(widget.node.path, fontSize: 13)), + ClideText('${widget.node.inbound}in ${widget.node.outbound}out', color: widget.tokens.globalTextMuted, fontSize: 11), + ], + ), + ), + ); + } +} diff --git a/app/lib/builtin/markdown/src/extension.dart b/app/lib/builtin/markdown/src/extension.dart index 7b06fa65..fe0fff24 100644 --- a/app/lib/builtin/markdown/src/extension.dart +++ b/app/lib/builtin/markdown/src/extension.dart @@ -1,17 +1,27 @@ +import 'package:clide_app/builtin/markdown/src/markdown_viewer.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 MarkdownExtension extends ClideExtension { @override String get id => 'builtin.markdown'; @override String get title => 'Markdown'; @override - String get version => '0.0.0-stub'; + String get version => '0.1.0'; @override List get dependsOn => const ['builtin.editor']; + @override - List get contributions => const []; + List get contributions => [ + TabContribution( + id: 'markdown.viewer', + slot: Slots.contextPanel, + title: 'Viewer', + titleKey: 'tab.viewer', + i18nNamespace: id, + priority: -100, + build: (_) => const MarkdownViewer(), + ), + ]; } diff --git a/app/lib/builtin/markdown/src/markdown_viewer.dart b/app/lib/builtin/markdown/src/markdown_viewer.dart new file mode 100644 index 00000000..30dbc145 --- /dev/null +++ b/app/lib/builtin/markdown/src/markdown_viewer.dart @@ -0,0 +1,94 @@ +import 'dart:async'; + +import 'package:clide_app/kernel/kernel.dart'; +import 'package:clide_app/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; + +class MarkdownViewer extends StatefulWidget { + const MarkdownViewer({super.key}); + + @override + State createState() => _MarkdownViewerState(); +} + +class _MarkdownViewerState extends State { + String? _path; + String? _content; + String? _error; + StreamSubscription? _eventSub; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_eventSub != null) return; + final kernel = ClideKernel.of(context); + _eventSub = kernel.events.on().listen((e) { + if (e.kind == 'editor.buffer_activated') { + final path = e.data['path'] as String?; + if (path != null && path.endsWith('.md')) { + _loadFile(path); + } + } + }); + final activeTab = kernel.panels.activeTabIn(Slots.workspace); + if (activeTab == 'editor.active') { + unawaited(_loadActiveBuffer()); + } + } + + @override + void dispose() { + _eventSub?.cancel(); + super.dispose(); + } + + Future _loadActiveBuffer() async { + final kernel = ClideKernel.of(context); + final resp = await kernel.ipc.request('editor.active'); + if (!mounted || !resp.ok) return; + final path = resp.data['path'] as String?; + if (path != null && path.endsWith('.md')) { + await _loadFile(path); + } + } + + Future _loadFile(String path) async { + final kernel = ClideKernel.of(context); + final resp = await kernel.ipc.request('files.read', args: {'path': path}); + if (!mounted) return; + if (resp.ok) { + setState(() { + _path = path; + _content = resp.data['content'] as String? ?? ''; + _error = null; + }); + } else { + setState(() => _error = resp.error?.message); + } + } + + @override + Widget build(BuildContext context) { + if (_error != null) { + return Padding(padding: const EdgeInsets.all(12), child: ClideText(_error!, muted: true)); + } + if (_content == null) { + return const Padding( + padding: EdgeInsets.all(12), + child: ClideText('Open a .md file to preview it here.', muted: true), + ); + } + final tokens = ClideTheme.of(context).surface; + return ClidePaneChrome( + title: _path ?? 'viewer', + subtitle: '${_content!.split('\n').length} lines', + child: SingleChildScrollView( + padding: const EdgeInsets.all(12), + child: Text( + _content!, + style: TextStyle(color: tokens.globalForeground, fontSize: 13, fontFamily: clideMonoFamily, fontFamilyFallback: clideMonoFamilyFallback), + ), + ), + ); + } +} diff --git a/app/lib/builtin/tickets/src/extension.dart b/app/lib/builtin/tickets/src/extension.dart index 01113b74..53d610eb 100644 --- a/app/lib/builtin/tickets/src/extension.dart +++ b/app/lib/builtin/tickets/src/extension.dart @@ -1,18 +1,27 @@ +import 'package:clide_app/builtin/tickets/src/tickets_view.dart'; import 'package:clide_app/extension/extension.dart'; +import 'package:clide_app/kernel/kernel.dart'; -/// Tier-reserved stub. Will surface a sidebar tab (filtered list) + a -/// workspace tab (kanban board) + commands (`tickets.open`, -/// `tickets.new`, `tickets.move`, `tickets.block`). Data source: -/// `pql ticket …`. Ticket persistence strategy open at `Q-022`. class TicketsExtension extends ClideExtension { @override String get id => 'builtin.tickets'; @override String get title => 'Tickets'; @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: 'tickets.panel', + slot: Slots.sidebar, + title: 'Tickets', + titleKey: 'tab.title', + i18nNamespace: id, + priority: -10, + build: (_) => const TicketsView(), + ), + ]; } diff --git a/app/lib/builtin/tickets/src/tickets_view.dart b/app/lib/builtin/tickets/src/tickets_view.dart new file mode 100644 index 00000000..8f6c525f --- /dev/null +++ b/app/lib/builtin/tickets/src/tickets_view.dart @@ -0,0 +1,140 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:clide_app/kernel/kernel.dart'; +import 'package:clide_app/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; + +class TicketsView extends StatefulWidget { + const TicketsView({super.key}); + + @override + State createState() => _TicketsViewState(); +} + +class _TicketsViewState extends State { + List<_TicketEntry> _tickets = []; + String? _error; + bool _loading = true; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_loading || _tickets.isNotEmpty) return; + unawaited(_load()); + } + + Future _load() async { + final kernel = ClideKernel.of(context); + final resp = await kernel.ipc.request('pql.exec', args: { + 'argv': ['ticket', 'list'], + }); + if (!mounted) return; + if (!resp.ok) { + setState(() { + _error = resp.error?.message ?? 'failed to load tickets'; + _loading = false; + }); + return; + } + final raw = resp.data['stdout'] as String? ?? '[]'; + try { + final list = (jsonDecode(raw) as List).cast>(); + setState(() { + _tickets = list.map(_TicketEntry.fromJson).toList(); + _loading = false; + }); + } catch (e) { + setState(() { + _error = 'parse error: $e'; + _loading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + if (_loading) { + return const Center(child: ClideText('Loading tickets...', muted: true)); + } + if (_error != null) { + return Padding( + padding: const EdgeInsets.all(12), + child: ClideText(_error!, muted: true), + ); + } + if (_tickets.isEmpty) { + return const Padding( + padding: EdgeInsets.all(12), + child: ClideText('No tickets found.\nRun `pql ticket new` to create one.', muted: true), + ); + } + return ListView.builder( + itemCount: _tickets.length, + itemBuilder: (ctx, i) { + final t = _tickets[i]; + return _TicketRow(entry: t, tokens: tokens); + }, + ); + } +} + +class _TicketEntry { + const _TicketEntry({required this.id, required this.title, this.status, this.priority}); + final String id; + final String title; + final String? status; + final String? priority; + + factory _TicketEntry.fromJson(Map json) => _TicketEntry( + id: json['id'] as String? ?? '', + title: json['title'] as String? ?? '', + status: json['status'] as String?, + priority: json['priority'] as String?, + ); +} + +class _TicketRow extends StatefulWidget { + const _TicketRow({required this.entry, required this.tokens}); + final _TicketEntry entry; + final SurfaceTokens tokens; + + @override + State<_TicketRow> createState() => _TicketRowState(); +} + +class _TicketRowState extends State<_TicketRow> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final statusColor = switch (widget.entry.status) { + 'done' => widget.tokens.statusSuccess, + 'in_progress' => widget.tokens.statusInfo, + 'cancelled' => widget.tokens.statusError, + _ => widget.tokens.globalTextMuted, + }; + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: Container( + color: _hovered ? widget.tokens.listItemHoverBackground : null, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: Row( + children: [ + ClideText(widget.entry.id, color: widget.tokens.globalTextMuted, fontSize: 12), + const SizedBox(width: 6), + Container( + width: 6, + height: 6, + decoration: BoxDecoration(color: statusColor, shape: BoxShape.circle), + ), + const SizedBox(width: 6), + Expanded(child: ClideText(widget.entry.title, fontSize: 13)), + ], + ), + ), + ); + } +}