4 Commits
Author SHA1 Message Date
jpmschweitzerandClaude 2f7f797288 update changelog for pql restructure, tree-sitter fix, files.read
test / unit + widget + golden + a11y (push) Failing after 28s
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
Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-23 21:26:03 +02:00
jpmschweitzerandClaude bfd304a3f9 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>
2026-04-23 21:25:52 +02:00
jpmschweitzerandClaude 47c2def656 add files.read IPC, fix pane chrome layout, raise context panel max
Register files.read command for reading file content by relative path.
Remove top padding on context panel container and mainAxisSize.min
from ClidePaneChrome so the header sits flush and the pane fills
available height.  Context panel max raised from 420px to 1000px.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-23 21:25:36 +02:00
jpmschweitzerandClaude b236ec439f make TreeSitterService a shared singleton
Each ClideCodeBlock and EditorView created its own TreeSitterService,
each allocating a WASM engine, store, parser, and cursor via FFI.
When widgets were disposed, multiple instances freeing the same
underlying native resources caused double-free corruption on startup.

Single shared instance for the app lifetime.  Also fixes overlapping
tree-sitter spans that caused duplicated text in code blocks — spans
are now clipped to avoid re-emitting already-rendered characters.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-23 21:25:22 +02:00
12 changed files with 171 additions and 166 deletions
+15 -1
View File
@@ -84,6 +84,13 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
`clideFontBadge` (11) for sidebar metadata and status badges.
`clideLineHeight` (1.25) applied app-wide via `DefaultTextStyle`.
- `files.read` IPC command for reading file content by path.
- pql sidebar restructured: Search tab (PQL DSL query) is the
default left tab; Markdown tab (filtered to `.md` files) on the
right. Clicking a markdown file opens it in the context panel
markdown viewer with bidirectional focus highlighting.
- Graph view in context panel — lists files with inbound/outbound
link counts from `pql search --connections` (T-39).
@@ -113,7 +120,8 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
`clideFontMono` to match surrounding text weight.
- Default sidebar and context panel widths widened to 400px and
420px respectively (previous maximums).
420px respectively (previous maximums). Context panel max raised
to 1000px.
- Ticket descriptions render through ClideMarkdown instead of plain
text, with clickable DQRT record links.
@@ -121,6 +129,12 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
- Decision detail view subscribes to the message bus for navigation
(same pattern as tickets), enabling navigation from any source.
- TreeSitterService is now a shared singleton — eliminates native
double-free crashes from multiple WASM engine instances.
- Code block syntax highlighting fixes overlapping tree-sitter spans
that caused duplicated text (e.g. `makemake` instead of `make`).
- Context panel drag resize fixed — dragging left now correctly
grows the right panel instead of shrinking it.
+1 -1
View File
@@ -892,7 +892,7 @@ class _ContextSlot extends StatelessWidget {
return Container(
color: tokens.panelBackground,
alignment: Alignment.topLeft,
padding: const EdgeInsets.fromLTRB(0, 2, 2, 0),
padding: const EdgeInsets.only(right: 2),
child: active.build(context),
);
}
+1 -2
View File
@@ -27,7 +27,7 @@ class EditorView extends StatefulWidget {
class _EditorViewState extends State<EditorView> {
EditorController? _controller;
final TreeSitterService _syntax = TreeSitterService();
final TreeSitterService _syntax = TreeSitterService.shared;
late final SyntaxTextController _text;
late final FocusNode _focus;
String? _lastRemoteContent;
@@ -57,7 +57,6 @@ class _EditorViewState extends State<EditorView> {
_focus.dispose();
_controller?.removeListener(_onControllerChanged);
_controller?.dispose();
_syntax.dispose();
super.dispose();
}
+22 -36
View File
@@ -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<String> get dependsOn => const ['builtin.editor'];
List<String> get dependsOn => const [];
ClideExtensionContext? _ctx;
StreamSubscription<DaemonEvent>? _editorSub;
bool _viewerSpawned = false;
StreamSubscription<Message>? _sub;
@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
Future<void> activate(ClideExtensionContext ctx) async {
_ctx = ctx;
_editorSub = ctx.events.on<DaemonEvent>().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<void> 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<void> deactivate() async => _sub?.cancel();
}
+12 -19
View File
@@ -15,14 +15,19 @@ class _MarkdownViewerState extends State<MarkdownViewer> {
String? _path;
String? _content;
String? _error;
StreamSubscription<DaemonEvent>? _eventSub;
StreamSubscription<Message>? _selectionSub;
StreamSubscription<DaemonEvent>? _editorSub;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_eventSub != null) return;
if (_selectionSub != null) return;
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') {
final path = e.data['path'] as String?;
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
void dispose() {
_eventSub?.cancel();
_selectionSub?.cancel();
_editorSub?.cancel();
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 {
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<MarkdownViewer> {
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(
+6 -6
View File
@@ -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<void> loadFiles({String? glob}) async {
Future<void> 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,
});
+88 -85
View File
@@ -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<PqlPanelView> {
PqlController? _controller;
String? _focusedPath;
final _focusedKey = GlobalKey();
StreamSubscription<Message>? _focusSub;
@override
void didChangeDependencies() {
@@ -26,11 +28,25 @@ class _PqlPanelViewState extends State<PqlPanelView> {
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<PqlPanelView> {
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<String, Object?> 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),
],
),
);
+1 -1
View File
@@ -30,7 +30,7 @@ LayoutPresetContribution classicPreset() => const LayoutPresetContribution(
position: SlotPosition.right,
defaultSize: 420,
minSize: 220,
maxSize: 420,
maxSize: 1000,
),
LayoutSlot(
slot: Slots.statusbar,
@@ -42,6 +42,9 @@ class _LoadedGrammar {
}
class TreeSitterService {
static final TreeSitterService shared = TreeSitterService._();
TreeSitterService._();
final Map<String, _LoadedGrammar> _grammars = {};
final Set<String> _unavailable = {};
+14
View File
@@ -8,6 +8,7 @@ import '../files/ignore.dart';
import '../files/listing.dart';
import '../files/watcher.dart';
import '../ipc/envelope.dart';
import '../ipc/schema_v1.dart';
import '../panes/event_sink.dart';
import 'dispatcher.dart';
@@ -63,6 +64,19 @@ void registerFilesCommands(DaemonDispatcher d, FilesService files) {
},
));
d.register('files.read', (req) async {
final path = req.args['path'] as String?;
if (path == null || path.isEmpty) {
return IpcResponse.err(id: req.id, error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'files.read requires a path'));
}
final file = File('${files.root.absolute.path}/$path');
if (!file.existsSync()) {
return IpcResponse.err(id: req.id, error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'file not found: $path'));
}
final content = file.readAsStringSync();
return IpcResponse.ok(id: req.id, data: {'path': path, 'content': content});
});
d.register('files.ls', (req) async {
final dir = (req.args['path'] as String?) ?? '';
final entries = await listDir(
+8 -14
View File
@@ -16,7 +16,6 @@ class ClideCodeBlock extends StatefulWidget {
}
class _ClideCodeBlockState extends State<ClideCodeBlock> {
final TreeSitterService _syntax = TreeSitterService();
List<SyntaxSpan>? _spans;
@override
@@ -33,12 +32,6 @@ class _ClideCodeBlockState extends State<ClideCodeBlock> {
}
}
@override
void dispose() {
_syntax.dispose();
super.dispose();
}
Future<void> _highlight() async {
final lang = widget.language;
if (lang == null || lang.isEmpty) {
@@ -46,11 +39,11 @@ class _ClideCodeBlockState extends State<ClideCodeBlock> {
return;
}
final path = 'code.$lang';
if (!await _syntax.hasGrammar(path)) {
if (!await TreeSitterService.shared.hasGrammar(path)) {
setState(() => _spans = null);
return;
}
final result = await _syntax.highlight(path, widget.source);
final result = await TreeSitterService.shared.highlight(path, widget.source);
if (mounted) setState(() => _spans = result.spans);
}
@@ -115,14 +108,15 @@ class _ClideCodeBlockState extends State<ClideCodeBlock> {
for (final span in sorted) {
final sChar = span.start < byteToChar.length ? byteToChar[span.start] : source.length;
final eChar = span.end < byteToChar.length ? byteToChar[span.end] : source.length;
if (sChar > lastChar) {
children.add(TextSpan(text: source.substring(lastChar, sChar)));
final clippedStart = sChar < lastChar ? lastChar : sChar;
if (clippedStart > lastChar) {
children.add(TextSpan(text: source.substring(lastChar, clippedStart)));
}
if (eChar > sChar) {
if (eChar > clippedStart) {
final color = TreeSitterService.colorForRole(span.role, tokens);
children.add(TextSpan(text: source.substring(sChar, eChar), style: base.copyWith(color: color)));
children.add(TextSpan(text: source.substring(clippedStart, eChar), style: base.copyWith(color: color)));
}
lastChar = eChar;
if (eChar > lastChar) lastChar = eChar;
}
if (lastChar < source.length) {
children.add(TextSpan(text: source.substring(lastChar)));
-1
View File
@@ -51,7 +51,6 @@ class ClidePaneChrome extends StatelessWidget {
return ColoredBox(
color: tokens.panelBackground,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_Header(
title: title,