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>
This commit is contained in:
2026-04-23 21:25:22 +02:00
co-authored by Claude
parent 2f6c11e9a6
commit b236ec439f
3 changed files with 12 additions and 16 deletions
+1 -2
View File
@@ -27,7 +27,7 @@ class EditorView extends StatefulWidget {
class _EditorViewState extends State<EditorView> { class _EditorViewState extends State<EditorView> {
EditorController? _controller; EditorController? _controller;
final TreeSitterService _syntax = TreeSitterService(); final TreeSitterService _syntax = TreeSitterService.shared;
late final SyntaxTextController _text; late final SyntaxTextController _text;
late final FocusNode _focus; late final FocusNode _focus;
String? _lastRemoteContent; String? _lastRemoteContent;
@@ -57,7 +57,6 @@ class _EditorViewState extends State<EditorView> {
_focus.dispose(); _focus.dispose();
_controller?.removeListener(_onControllerChanged); _controller?.removeListener(_onControllerChanged);
_controller?.dispose(); _controller?.dispose();
_syntax.dispose();
super.dispose(); super.dispose();
} }
@@ -42,6 +42,9 @@ class _LoadedGrammar {
} }
class TreeSitterService { class TreeSitterService {
static final TreeSitterService shared = TreeSitterService._();
TreeSitterService._();
final Map<String, _LoadedGrammar> _grammars = {}; final Map<String, _LoadedGrammar> _grammars = {};
final Set<String> _unavailable = {}; final Set<String> _unavailable = {};
+8 -14
View File
@@ -16,7 +16,6 @@ class ClideCodeBlock extends StatefulWidget {
} }
class _ClideCodeBlockState extends State<ClideCodeBlock> { class _ClideCodeBlockState extends State<ClideCodeBlock> {
final TreeSitterService _syntax = TreeSitterService();
List<SyntaxSpan>? _spans; List<SyntaxSpan>? _spans;
@override @override
@@ -33,12 +32,6 @@ class _ClideCodeBlockState extends State<ClideCodeBlock> {
} }
} }
@override
void dispose() {
_syntax.dispose();
super.dispose();
}
Future<void> _highlight() async { Future<void> _highlight() async {
final lang = widget.language; final lang = widget.language;
if (lang == null || lang.isEmpty) { if (lang == null || lang.isEmpty) {
@@ -46,11 +39,11 @@ class _ClideCodeBlockState extends State<ClideCodeBlock> {
return; return;
} }
final path = 'code.$lang'; final path = 'code.$lang';
if (!await _syntax.hasGrammar(path)) { if (!await TreeSitterService.shared.hasGrammar(path)) {
setState(() => _spans = null); setState(() => _spans = null);
return; return;
} }
final result = await _syntax.highlight(path, widget.source); final result = await TreeSitterService.shared.highlight(path, widget.source);
if (mounted) setState(() => _spans = result.spans); if (mounted) setState(() => _spans = result.spans);
} }
@@ -115,14 +108,15 @@ class _ClideCodeBlockState extends State<ClideCodeBlock> {
for (final span in sorted) { for (final span in sorted) {
final sChar = span.start < byteToChar.length ? byteToChar[span.start] : source.length; final sChar = span.start < byteToChar.length ? byteToChar[span.start] : source.length;
final eChar = span.end < byteToChar.length ? byteToChar[span.end] : source.length; final eChar = span.end < byteToChar.length ? byteToChar[span.end] : source.length;
if (sChar > lastChar) { final clippedStart = sChar < lastChar ? lastChar : sChar;
children.add(TextSpan(text: source.substring(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); 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) { if (lastChar < source.length) {
children.add(TextSpan(text: source.substring(lastChar))); children.add(TextSpan(text: source.substring(lastChar)));