restructure pql sidebar: Search default, Markdown tab with viewer

Search (PQL DSL query) is now the default left tab; Markdown (filtered
to .md files) on the right.  Clicking a markdown file publishes on
the message bus; the markdown extension activates the context panel
viewer and re-publishes on a load channel after the frame so the
viewer is guaranteed to be mounted.  Viewer subscribes to load channel
and editor buffer events, publishes focus for sidebar highlighting.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-04-23 21:25:52 +02:00
co-authored by Claude
parent 47c2def656
commit bfd304a3f9
4 changed files with 128 additions and 146 deletions
+22 -36
View File
@@ -3,6 +3,8 @@ import 'dart:async';
import 'package:clide/builtin/markdown/src/markdown_viewer.dart'; import 'package:clide/builtin/markdown/src/markdown_viewer.dart';
import 'package:clide/extension/extension.dart'; import 'package:clide/extension/extension.dart';
import 'package:clide/kernel/kernel.dart'; import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
class MarkdownExtension extends ClideExtension { class MarkdownExtension extends ClideExtension {
@override @override
@@ -10,51 +12,35 @@ class MarkdownExtension extends ClideExtension {
@override @override
String get title => 'Markdown'; String get title => 'Markdown';
@override @override
String get version => '0.2.0'; String get version => '0.3.0';
@override @override
List<String> get dependsOn => const ['builtin.editor']; List<String> get dependsOn => const [];
ClideExtensionContext? _ctx; StreamSubscription<Message>? _sub;
StreamSubscription<DaemonEvent>? _editorSub;
bool _viewerSpawned = false;
@override @override
List<ContributionPoint> get contributions => const []; List<ContributionPoint> get contributions => [
TabContribution(
id: 'markdown.viewer',
slot: Slots.contextPanel,
title: 'Markdown',
icon: PhosphorIcons.fileText,
build: (_) => const MarkdownViewer(),
),
];
@override @override
Future<void> activate(ClideExtensionContext ctx) async { Future<void> activate(ClideExtensionContext ctx) async {
_ctx = ctx; _sub = ctx.messages.subscribe(publisher: id, channel: 'selection').listen((msg) {
_editorSub = ctx.events.on<DaemonEvent>().listen((e) { final path = msg.data['path'] as String?;
if (e.subsystem != 'editor') return; if (path == null) return;
if (e.kind == 'editor.active-changed' || e.kind == 'editor.opened') { ctx.panels.activateTab(Slots.contextPanel, 'markdown.viewer');
final path = e.data['path'] as String?; WidgetsBinding.instance.addPostFrameCallback((_) {
if (path != null && path.endsWith('.md')) { ctx.messages.publish(id, 'load', {'path': path});
_spawnViewer(); });
}
}
}); });
} }
@override @override
Future<void> deactivate() async { Future<void> deactivate() async => _sub?.cancel();
_editorSub?.cancel();
if (_viewerSpawned) {
_ctx?.panels.uncontribute('markdown.viewer');
_viewerSpawned = false;
}
}
void _spawnViewer() {
final ctx = _ctx;
if (ctx == null || _viewerSpawned) return;
ctx.panels.contribute(TabContribution(
id: 'markdown.viewer',
slot: Slots.contextPanel,
title: 'Viewer',
priority: -100,
build: (_) => const MarkdownViewer(),
));
_viewerSpawned = true;
ctx.panels.activateTab(Slots.contextPanel, 'markdown.viewer');
}
} }
+12 -19
View File
@@ -15,14 +15,19 @@ class _MarkdownViewerState extends State<MarkdownViewer> {
String? _path; String? _path;
String? _content; String? _content;
String? _error; String? _error;
StreamSubscription<DaemonEvent>? _eventSub; StreamSubscription<Message>? _selectionSub;
StreamSubscription<DaemonEvent>? _editorSub;
@override @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
if (_eventSub != null) return; if (_selectionSub != null) return;
final kernel = ClideKernel.of(context); final kernel = ClideKernel.of(context);
_eventSub = kernel.events.on<DaemonEvent>().listen((e) { _selectionSub = kernel.messages.subscribe(publisher: 'builtin.markdown', channel: 'load').listen((msg) {
final path = msg.data['path'] as String?;
if (path != null) _loadFile(path);
});
_editorSub = kernel.events.on<DaemonEvent>().listen((e) {
if (e.kind == 'editor.buffer_activated') { if (e.kind == 'editor.buffer_activated') {
final path = e.data['path'] as String?; final path = e.data['path'] as String?;
if (path != null && path.endsWith('.md')) { if (path != null && path.endsWith('.md')) {
@@ -30,33 +35,21 @@ class _MarkdownViewerState extends State<MarkdownViewer> {
} }
} }
}); });
final activeTab = kernel.panels.activeTabIn(Slots.workspace);
if (activeTab == 'editor.active') {
unawaited(_loadActiveBuffer());
}
} }
@override @override
void dispose() { void dispose() {
_eventSub?.cancel(); _selectionSub?.cancel();
_editorSub?.cancel();
super.dispose(); super.dispose();
} }
Future<void> _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<void> _loadFile(String path) async { Future<void> _loadFile(String path) async {
final kernel = ClideKernel.of(context); final kernel = ClideKernel.of(context);
final resp = await kernel.ipc.request('files.read', args: {'path': path}); final resp = await kernel.ipc.request('files.read', args: {'path': path});
if (!mounted) return; if (!mounted) return;
if (resp.ok) { if (resp.ok) {
kernel.messages.publish('builtin.markdown', 'focus', {'path': path});
setState(() { setState(() {
_path = path; _path = path;
_content = resp.data['content'] as String? ?? ''; _content = resp.data['content'] as String? ?? '';
@@ -84,7 +77,7 @@ class _MarkdownViewerState extends State<MarkdownViewer> {
if (_content == null) { if (_content == null) {
return const Padding( return const Padding(
padding: EdgeInsets.all(12), padding: EdgeInsets.all(12),
child: ClideText('Open a .md file to preview it here.', muted: true), child: ClideText('Select a .md file to preview it here.', muted: true),
); );
} }
return ClidePaneChrome( return ClidePaneChrome(
+6 -6
View File
@@ -9,14 +9,14 @@ import 'dart:async';
import 'package:clide/kernel/kernel.dart'; import 'package:clide/kernel/kernel.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
enum PqlView { files, query } enum PqlView { query, markdown }
class PqlController extends ChangeNotifier { class PqlController extends ChangeNotifier {
PqlController({required this.ipc}); PqlController({required this.ipc});
final DaemonClient ipc; final DaemonClient ipc;
PqlView _view = PqlView.files; PqlView _view = PqlView.query;
PqlView get view => _view; PqlView get view => _view;
String? _error; String? _error;
@@ -38,19 +38,19 @@ class PqlController extends ChangeNotifier {
_error = null; _error = null;
notifyListeners(); notifyListeners();
switch (v) { switch (v) {
case PqlView.files: case PqlView.markdown:
unawaited(loadFiles()); unawaited(loadMarkdownFiles());
case PqlView.query: case PqlView.query:
break; break;
} }
} }
Future<void> loadFiles({String? glob}) async { Future<void> loadMarkdownFiles({String? glob}) async {
_loading = true; _loading = true;
notifyListeners(); notifyListeners();
final r = await ipc.request('pql.files', args: { final r = await ipc.request('pql.files', args: {
if (glob != null) 'glob': glob, 'glob': glob ?? '**/*.md',
'limit': 200, 'limit': 200,
}); });
+88 -85
View File
@@ -1,5 +1,4 @@
/// Sidebar panel for pql — file listing, DSL query input, /// Sidebar panel for pql — DSL query input and markdown file listing.
/// decisions list, and ticket board views.
library; library;
import 'dart:async'; import 'dart:async';
@@ -19,6 +18,9 @@ class PqlPanelView extends StatefulWidget {
class _PqlPanelViewState extends State<PqlPanelView> { class _PqlPanelViewState extends State<PqlPanelView> {
PqlController? _controller; PqlController? _controller;
String? _focusedPath;
final _focusedKey = GlobalKey();
StreamSubscription<Message>? _focusSub;
@override @override
void didChangeDependencies() { void didChangeDependencies() {
@@ -26,11 +28,25 @@ class _PqlPanelViewState extends State<PqlPanelView> {
if (_controller != null) return; if (_controller != null) return;
final kernel = ClideKernel.of(context); final kernel = ClideKernel.of(context);
_controller = PqlController(ipc: kernel.ipc); _controller = PqlController(ipc: kernel.ipc);
unawaited(_controller!.loadFiles()); _focusSub = kernel.messages.subscribe(publisher: 'builtin.markdown', channel: 'focus').listen((msg) {
final path = msg.data['path'] as String?;
if (path == null || path == _focusedPath) return;
setState(() {
_focusedPath = path;
if (_controller!.view != PqlView.markdown) {
_controller!.switchView(PqlView.markdown);
}
});
WidgetsBinding.instance.addPostFrameCallback((_) {
final ctx = _focusedKey.currentContext;
if (ctx != null) Scrollable.ensureVisible(ctx, duration: const Duration(milliseconds: 200), alignment: 0.3);
});
});
} }
@override @override
void dispose() { void dispose() {
_focusSub?.cancel();
_controller?.dispose(); _controller?.dispose();
super.dispose(); super.dispose();
} }
@@ -43,59 +59,52 @@ class _PqlPanelViewState extends State<PqlPanelView> {
listenable: c, listenable: c,
builder: (context, _) { builder: (context, _) {
final tokens = ClideTheme.of(context).surface; final tokens = ClideTheme.of(context).surface;
return Semantics( return Column(
label: 'pql panel', crossAxisAlignment: CrossAxisAlignment.stretch,
container: true, children: [
explicitChildNodes: true, _ViewTabs(controller: c),
child: Column( if (c.view == PqlView.query)
crossAxisAlignment: CrossAxisAlignment.stretch, ClideFilterBox(
children: [ hint: 'PQL query…',
_ViewTabs(controller: c), debounce: Duration.zero,
if (c.view == PqlView.query) onChanged: (_) {},
ClideFilterBox( onSubmitted: (v) => unawaited(c.runQuery(v)),
hint: 'PQL query…', ),
debounce: Duration.zero, if (c.view == PqlView.markdown)
onChanged: (_) {}, ClideFilterBox(
onSubmitted: (v) => unawaited(c.runQuery(v)), hint: 'Filter markdown…',
), onChanged: (v) => unawaited(c.loadMarkdownFiles(glob: v.isEmpty ? null : '**/*$v*.md')),
if (c.error != null) ),
Padding( if (c.error != null)
padding: const EdgeInsets.symmetric( Padding(
horizontal: 12, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ClideText( child: ClideText(c.error!, color: tokens.statusError, fontSize: clideFontCaption, maxLines: 3),
c.error!, ),
color: tokens.statusError, if (c.loading && c.results.isEmpty)
fontSize: clideFontCaption, const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true)),
maxLines: 3, if (!c.loading && c.results.isEmpty && c.error == null)
), const Padding(padding: EdgeInsets.all(12), child: ClideText('No results.', muted: true)),
), Expanded(
if (c.loading && c.results.isEmpty) child: SingleChildScrollView(
const Padding( padding: const EdgeInsets.symmetric(vertical: 4),
padding: EdgeInsets.all(12), child: Column(
child: ClideText('Loading…', muted: true), crossAxisAlignment: CrossAxisAlignment.stretch,
), mainAxisSize: MainAxisSize.min,
if (!c.loading && c.results.isEmpty && c.error == null) children: [
const Padding( if (c.view == PqlView.markdown)
padding: EdgeInsets.all(12), for (final f in c.results)
child: ClideText('No results.', muted: true), _FileRow(
), entry: f,
Expanded( focused: (f['path'] as String?) == _focusedPath,
child: SingleChildScrollView( focusKey: (f['path'] as String?) == _focusedPath ? _focusedKey : null,
padding: const EdgeInsets.symmetric(vertical: 4), ),
child: Column( if (c.view == PqlView.query)
crossAxisAlignment: CrossAxisAlignment.stretch, for (final r in c.results) _QueryResultRow(entry: r),
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),
],
),
), ),
), ),
], ),
), ],
); );
}, },
); );
@@ -119,21 +128,14 @@ class _ViewTabs extends StatelessWidget {
for (final v in PqlView.values) for (final v in PqlView.values)
Padding( Padding(
padding: const EdgeInsets.only(right: 8), padding: const EdgeInsets.only(right: 8),
child: Semantics( child: GestureDetector(
button: true, onTap: () => controller.switchView(v),
toggled: controller.view == v, child: MouseRegion(
label: v.name, cursor: SystemMouseCursors.click,
child: GestureDetector( child: ClideText(
onTap: () => controller.switchView(v), _tabLabel(v),
child: MouseRegion( fontSize: clideFontCaption,
cursor: SystemMouseCursors.click, color: controller.view == v ? tokens.globalForeground : tokens.globalTextMuted,
child: ClideText(
_tabLabel(v),
fontSize: clideFontCaption,
color: controller.view == v
? tokens.globalForeground
: tokens.globalTextMuted,
),
), ),
), ),
), ),
@@ -144,36 +146,38 @@ class _ViewTabs extends StatelessWidget {
} }
static String _tabLabel(PqlView v) => switch (v) { static String _tabLabel(PqlView v) => switch (v) {
PqlView.files => 'Files', PqlView.query => 'Search',
PqlView.query => 'Query', PqlView.markdown => 'Markdown',
}; };
} }
class _FileRow extends StatelessWidget { class _FileRow extends StatelessWidget {
const _FileRow({required this.entry}); const _FileRow({required this.entry, this.focused = false, this.focusKey});
final Map<String, Object?> entry; final Map<String, Object?> entry;
final bool focused;
final GlobalKey? focusKey;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface; final tokens = ClideTheme.of(context).surface;
final path = entry['path'] as String? ?? ''; final path = entry['path'] as String? ?? '';
final name = entry['name'] as String? ?? path.split('/').last; return Padding(
return Semantics( key: focusKey,
button: true, padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 1),
label: 'Open $name',
child: ClideTappable( child: ClideTappable(
onTap: () { onTap: () => ClideKernel.of(context).messages.publish('builtin.markdown', 'selection', {'path': path}),
final kernel = ClideKernel.of(context);
unawaited(
kernel.ipc.request('editor.open', args: {'path': path}));
},
builder: (context, hovered, _) => Container( builder: (context, hovered, _) => Container(
color: hovered ? tokens.sidebarItemHover : null, padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 3), decoration: BoxDecoration(
color: hovered ? tokens.sidebarItemHover : (focused ? tokens.sidebarItemSelected : null),
borderRadius: BorderRadius.circular(4),
border: focused ? Border.all(color: tokens.globalFocus, width: 1) : null,
),
child: ClideText( child: ClideText(
path, path,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
fontSize: clideFontCaption,
color: tokens.sidebarForeground, color: tokens.sidebarForeground,
), ),
), ),
@@ -201,8 +205,7 @@ class _QueryResultRow extends StatelessWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
ClideText(name, color: tokens.sidebarForeground), ClideText(name, color: tokens.sidebarForeground),
if (values.isNotEmpty) if (values.isNotEmpty) ClideText(values, fontSize: clideFontCaption, muted: true, maxLines: 2),
ClideText(values, fontSize: clideFontCaption, muted: true, maxLines: 2),
], ],
), ),
); );