diff --git a/lib/builtin/markdown/src/extension.dart b/lib/builtin/markdown/src/extension.dart index b00b7b41..74ea568c 100644 --- a/lib/builtin/markdown/src/extension.dart +++ b/lib/builtin/markdown/src/extension.dart @@ -3,6 +3,8 @@ import 'dart:async'; import 'package:clide/builtin/markdown/src/markdown_viewer.dart'; import 'package:clide/extension/extension.dart'; import 'package:clide/kernel/kernel.dart'; +import 'package:clide/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; class MarkdownExtension extends ClideExtension { @override @@ -10,51 +12,35 @@ class MarkdownExtension extends ClideExtension { @override String get title => 'Markdown'; @override - String get version => '0.2.0'; + String get version => '0.3.0'; @override - List get dependsOn => const ['builtin.editor']; + List get dependsOn => const []; - ClideExtensionContext? _ctx; - StreamSubscription? _editorSub; - bool _viewerSpawned = false; + StreamSubscription? _sub; @override - List get contributions => const []; + List get contributions => [ + TabContribution( + id: 'markdown.viewer', + slot: Slots.contextPanel, + title: 'Markdown', + icon: PhosphorIcons.fileText, + build: (_) => const MarkdownViewer(), + ), + ]; @override Future activate(ClideExtensionContext ctx) async { - _ctx = ctx; - _editorSub = ctx.events.on().listen((e) { - if (e.subsystem != 'editor') return; - if (e.kind == 'editor.active-changed' || e.kind == 'editor.opened') { - final path = e.data['path'] as String?; - if (path != null && path.endsWith('.md')) { - _spawnViewer(); - } - } + _sub = ctx.messages.subscribe(publisher: id, channel: 'selection').listen((msg) { + final path = msg.data['path'] as String?; + if (path == null) return; + ctx.panels.activateTab(Slots.contextPanel, 'markdown.viewer'); + WidgetsBinding.instance.addPostFrameCallback((_) { + ctx.messages.publish(id, 'load', {'path': path}); + }); }); } @override - Future deactivate() async { - _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'); - } + Future deactivate() async => _sub?.cancel(); } diff --git a/lib/builtin/markdown/src/markdown_viewer.dart b/lib/builtin/markdown/src/markdown_viewer.dart index e8a31ecf..219271f0 100644 --- a/lib/builtin/markdown/src/markdown_viewer.dart +++ b/lib/builtin/markdown/src/markdown_viewer.dart @@ -15,14 +15,19 @@ class _MarkdownViewerState extends State { String? _path; String? _content; String? _error; - StreamSubscription? _eventSub; + StreamSubscription? _selectionSub; + StreamSubscription? _editorSub; @override void didChangeDependencies() { super.didChangeDependencies(); - if (_eventSub != null) return; + if (_selectionSub != null) return; final kernel = ClideKernel.of(context); - _eventSub = kernel.events.on().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().listen((e) { if (e.kind == 'editor.buffer_activated') { final path = e.data['path'] as String?; if (path != null && path.endsWith('.md')) { @@ -30,33 +35,21 @@ class _MarkdownViewerState extends State { } } }); - final activeTab = kernel.panels.activeTabIn(Slots.workspace); - if (activeTab == 'editor.active') { - unawaited(_loadActiveBuffer()); - } } @override void dispose() { - _eventSub?.cancel(); + _selectionSub?.cancel(); + _editorSub?.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) { + kernel.messages.publish('builtin.markdown', 'focus', {'path': path}); setState(() { _path = path; _content = resp.data['content'] as String? ?? ''; @@ -84,7 +77,7 @@ class _MarkdownViewerState extends State { if (_content == null) { return const Padding( 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( diff --git a/lib/builtin/pql/src/pql_controller.dart b/lib/builtin/pql/src/pql_controller.dart index 1ce1056f..e9f6eccf 100644 --- a/lib/builtin/pql/src/pql_controller.dart +++ b/lib/builtin/pql/src/pql_controller.dart @@ -9,14 +9,14 @@ import 'dart:async'; import 'package:clide/kernel/kernel.dart'; import 'package:flutter/foundation.dart'; -enum PqlView { files, query } +enum PqlView { query, markdown } class PqlController extends ChangeNotifier { PqlController({required this.ipc}); final DaemonClient ipc; - PqlView _view = PqlView.files; + PqlView _view = PqlView.query; PqlView get view => _view; String? _error; @@ -38,19 +38,19 @@ class PqlController extends ChangeNotifier { _error = null; notifyListeners(); switch (v) { - case PqlView.files: - unawaited(loadFiles()); + case PqlView.markdown: + unawaited(loadMarkdownFiles()); case PqlView.query: break; } } - Future loadFiles({String? glob}) async { + Future loadMarkdownFiles({String? glob}) async { _loading = true; notifyListeners(); final r = await ipc.request('pql.files', args: { - if (glob != null) 'glob': glob, + 'glob': glob ?? '**/*.md', 'limit': 200, }); diff --git a/lib/builtin/pql/src/pql_panel_view.dart b/lib/builtin/pql/src/pql_panel_view.dart index 38c32dc5..7322ed2f 100644 --- a/lib/builtin/pql/src/pql_panel_view.dart +++ b/lib/builtin/pql/src/pql_panel_view.dart @@ -1,5 +1,4 @@ -/// Sidebar panel for pql — file listing, DSL query input, -/// decisions list, and ticket board views. +/// Sidebar panel for pql — DSL query input and markdown file listing. library; import 'dart:async'; @@ -19,6 +18,9 @@ class PqlPanelView extends StatefulWidget { class _PqlPanelViewState extends State { PqlController? _controller; + String? _focusedPath; + final _focusedKey = GlobalKey(); + StreamSubscription? _focusSub; @override void didChangeDependencies() { @@ -26,11 +28,25 @@ class _PqlPanelViewState extends State { if (_controller != null) return; final kernel = ClideKernel.of(context); _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 void dispose() { + _focusSub?.cancel(); _controller?.dispose(); super.dispose(); } @@ -43,59 +59,52 @@ class _PqlPanelViewState extends State { 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) - ClideFilterBox( - hint: 'PQL query…', - debounce: Duration.zero, - onChanged: (_) {}, - onSubmitted: (v) => unawaited(c.runQuery(v)), - ), - if (c.error != null) - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 4), - child: ClideText( - c.error!, - color: tokens.statusError, - fontSize: clideFontCaption, - maxLines: 3, - ), - ), - if (c.loading && c.results.isEmpty) - const Padding( - padding: EdgeInsets.all(12), - child: ClideText('Loading…', muted: true), - ), - if (!c.loading && c.results.isEmpty && c.error == null) - const Padding( - padding: EdgeInsets.all(12), - child: ClideText('No results.', muted: true), - ), - 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), - ], - ), + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _ViewTabs(controller: c), + if (c.view == PqlView.query) + ClideFilterBox( + hint: 'PQL query…', + debounce: Duration.zero, + onChanged: (_) {}, + onSubmitted: (v) => unawaited(c.runQuery(v)), + ), + if (c.view == PqlView.markdown) + ClideFilterBox( + hint: 'Filter markdown…', + onChanged: (v) => unawaited(c.loadMarkdownFiles(glob: v.isEmpty ? null : '**/*$v*.md')), + ), + if (c.error != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: ClideText(c.error!, color: tokens.statusError, fontSize: clideFontCaption, maxLines: 3), + ), + if (c.loading && c.results.isEmpty) + const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true)), + if (!c.loading && c.results.isEmpty && c.error == null) + const Padding(padding: EdgeInsets.all(12), child: ClideText('No results.', muted: true)), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + if (c.view == PqlView.markdown) + for (final f in c.results) + _FileRow( + entry: f, + focused: (f['path'] as String?) == _focusedPath, + focusKey: (f['path'] as String?) == _focusedPath ? _focusedKey : null, + ), + 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) 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: clideFontCaption, - color: controller.view == v - ? tokens.globalForeground - : tokens.globalTextMuted, - ), + child: GestureDetector( + onTap: () => controller.switchView(v), + child: MouseRegion( + cursor: SystemMouseCursors.click, + 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) { - PqlView.files => 'Files', - PqlView.query => 'Query', + PqlView.query => 'Search', + PqlView.markdown => 'Markdown', }; } class _FileRow extends StatelessWidget { - const _FileRow({required this.entry}); + const _FileRow({required this.entry, this.focused = false, this.focusKey}); final Map entry; + final bool focused; + final GlobalKey? focusKey; @override Widget build(BuildContext context) { final tokens = ClideTheme.of(context).surface; final path = entry['path'] as String? ?? ''; - final name = entry['name'] as String? ?? path.split('/').last; - return Semantics( - button: true, - label: 'Open $name', + return Padding( + key: focusKey, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 1), child: ClideTappable( - onTap: () { - final kernel = ClideKernel.of(context); - unawaited( - kernel.ipc.request('editor.open', args: {'path': path})); - }, + onTap: () => ClideKernel.of(context).messages.publish('builtin.markdown', 'selection', {'path': path}), builder: (context, hovered, _) => Container( - color: hovered ? tokens.sidebarItemHover : null, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 3), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + 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( path, maxLines: 1, overflow: TextOverflow.ellipsis, + fontSize: clideFontCaption, color: tokens.sidebarForeground, ), ), @@ -201,8 +205,7 @@ class _QueryResultRow extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ ClideText(name, color: tokens.sidebarForeground), - if (values.isNotEmpty) - ClideText(values, fontSize: clideFontCaption, muted: true, maxLines: 2), + if (values.isNotEmpty) ClideText(values, fontSize: clideFontCaption, muted: true, maxLines: 2), ], ), );