dissolve app/ into repo root (D-056)

Single Flutter package at the repo root. All code, tests, assets,
and platform directories moved from app/ to root. Package renamed
from clide_app to clide — all imports rewritten. Merged pubspec
combines core (ffi) and app (flutter, yaml, xterm) dependencies.
Makefile simplified: no APP_PRESENT conditionals, no cd, no daemon
lifecycle. 317 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 00:37:20 +02:00
co-authored by Claude Opus 4.6
parent a526c5b9b7
commit 46329700d5
394 changed files with 978 additions and 1090 deletions
@@ -0,0 +1,171 @@
/// Flutter-side mirror of the daemon's active-editor state.
///
/// Listens to `editor.*` events over IPC and tracks: the active
/// buffer's id/path/content/selection, and whether the buffer is
/// dirty. The widget layer consumes this via [ListenableBuilder].
///
/// User edits flow the other way — the widget calls into the
/// controller, which calls `editor.set-content` / `editor.save` on
/// the daemon. The daemon is the source of truth; the widget is a
/// reconciled view.
library;
import 'dart:async';
import 'package:clide/clide.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:flutter/foundation.dart';
class EditorController extends ChangeNotifier {
EditorController({required this.ipc, required EventBus events})
: _events = events {
_eventSub = events.on<DaemonEvent>().listen(_onEvent);
}
final DaemonClient ipc;
// ignore: unused_field — kept for future subscription changes
final EventBus _events;
StreamSubscription<DaemonEvent>? _eventSub;
String? _activeId;
String? _activePath;
String _content = '';
Selection _selection = const Selection.collapsed(0);
bool _dirty = false;
String? _error;
bool _suppressNextRemoteEdit = false;
int _pendingLocalEdits = 0;
String? get activeId => _activeId;
String? get activePath => _activePath;
String get content => _content;
Selection get selection => _selection;
bool get dirty => _dirty;
String? get error => _error;
/// On first mount we don't know what (if anything) is already
/// active. Ask the daemon.
Future<void> hydrate() async {
final r = await ipc.request('editor.active');
if (!r.ok) {
_error = r.error?.message;
notifyListeners();
return;
}
final active = r.data['active'];
if (active is! Map) {
_activeId = null;
_activePath = null;
_content = '';
notifyListeners();
return;
}
final id = active['id']! as String;
await _loadBuffer(id);
}
Future<void> _loadBuffer(String id) async {
final r = await ipc.request('editor.read', args: {'id': id});
if (!r.ok) {
_error = r.error?.message;
notifyListeners();
return;
}
_activeId = r.data['id']! as String;
_activePath = r.data['path']! as String;
_content = (r.data['content'] as String?) ?? '';
final sel = r.data['selection'];
_selection = sel is Map
? Selection.fromJson(sel.cast<String, Object?>())
: const Selection.collapsed(0);
_dirty = (r.data['dirty'] as bool?) ?? false;
_error = null;
notifyListeners();
}
/// Called by the widget on every local text edit.
void pushLocalEdit({
required String newContent,
required Selection newSelection,
}) {
final id = _activeId;
if (id == null) return;
_content = newContent;
_selection = newSelection;
_dirty = true;
notifyListeners();
// Mirror to daemon. Use editor.set-content for the first cut —
// it's coarse but simple and avoids diff computation. Future
// tuning: diff + editor.insert / editor.replace-selection for
// large buffers, so event broadcasts stay small.
_pendingLocalEdits++;
_suppressNextRemoteEdit = true;
ipc.request('editor.set-content', args: {
'id': id,
'text': newContent,
'selection': newSelection.toJson(),
}).whenComplete(() => _pendingLocalEdits--);
}
Future<void> save() async {
final id = _activeId;
if (id == null) return;
await ipc.request('editor.save', args: {'id': id});
}
void _onEvent(DaemonEvent e) {
if (e.subsystem != 'editor') return;
switch (e.kind) {
case 'editor.opened':
case 'editor.active-changed':
final id = e.data['id'] as String?;
if (id == null) {
_activeId = null;
_activePath = null;
_content = '';
_selection = const Selection.collapsed(0);
_dirty = false;
notifyListeners();
} else if (id != _activeId) {
_loadBuffer(id);
}
case 'editor.edited':
// Our own set-content echoes back as editor.edited. Skip one
// bounce so we don't clobber the caret the user just moved.
if (_suppressNextRemoteEdit) {
_suppressNextRemoteEdit = false;
return;
}
// Remote edit (another client, or the CLI inserting bytes).
// Reload the authoritative buffer.
final id = e.data['id'] as String?;
if (id != null && id == _activeId && _pendingLocalEdits == 0) {
_loadBuffer(id);
}
case 'editor.saved':
if (e.data['id'] == _activeId) {
_dirty = false;
notifyListeners();
}
case 'editor.closed':
if (e.data['id'] == _activeId) {
_activeId = null;
_activePath = null;
_content = '';
_dirty = false;
notifyListeners();
}
}
}
@override
void dispose() {
_eventSub?.cancel();
_eventSub = null;
super.dispose();
}
}
+203
View File
@@ -0,0 +1,203 @@
import 'dart:async';
import 'package:clide/clide.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/kernel/src/syntax/tree_sitter_service.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'editor_controller.dart';
import 'syntax_text_controller.dart';
/// Tier-2 editor tab. One tab — the content reflects the daemon's
/// active buffer. Multi-file tabs live in the workspace-slot plan but
/// aren't in Tier 2's scope; opening a new file swaps this view's
/// content.
///
/// Uses Flutter's low-level `EditableText` so we stay off Material
/// per D-007. Owning more of the editor stack (line numbers, gutter,
/// syntax highlighting) lands in later tiers; Tier 2 is plain text.
class EditorView extends StatefulWidget {
const EditorView({super.key});
@override
State<EditorView> createState() => _EditorViewState();
}
class _EditorViewState extends State<EditorView> {
EditorController? _controller;
final TreeSitterService _syntax = TreeSitterService();
late final SyntaxTextController _text;
late final FocusNode _focus;
String? _lastRemoteContent;
@override
void initState() {
super.initState();
_text = SyntaxTextController(syntax: _syntax);
_focus = FocusNode();
_text.addListener(_onTextChanged);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_controller != null) return;
final kernel = ClideKernel.of(context);
_controller = EditorController(ipc: kernel.ipc, events: kernel.events)
..addListener(_onControllerChanged);
unawaited(_controller!.hydrate());
}
@override
void dispose() {
_text.removeListener(_onTextChanged);
_text.dispose();
_focus.dispose();
_controller?.removeListener(_onControllerChanged);
_controller?.dispose();
_syntax.dispose();
super.dispose();
}
void _onControllerChanged() {
final c = _controller!;
_text.updatePath(c.activePath);
if (c.content != _lastRemoteContent) {
_lastRemoteContent = c.content;
final sel = TextSelection(
baseOffset: c.selection.start.clamp(0, c.content.length),
extentOffset: c.selection.end.clamp(0, c.content.length),
);
_text.removeListener(_onTextChanged);
_text.value = TextEditingValue(text: c.content, selection: sel);
_text.addListener(_onTextChanged);
}
setState(() {}); // subtitle refresh
}
void _onTextChanged() {
final c = _controller;
if (c == null || c.activeId == null) return;
final value = _text.value;
if (value.text == c.content &&
value.selection.baseOffset == c.selection.start &&
value.selection.extentOffset == c.selection.end) {
return;
}
_lastRemoteContent = value.text;
c.pushLocalEdit(
newContent: value.text,
newSelection: Selection(
start: value.selection.start < 0
? value.text.length
: value.selection.start,
end: value.selection.end < 0
? value.text.length
: value.selection.end,
),
);
}
KeyEventResult _onKey(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent) return KeyEventResult.ignored;
final isCmd = HardwareKeyboard.instance.isMetaPressed ||
HardwareKeyboard.instance.isControlPressed;
if (isCmd && event.logicalKey == LogicalKeyboardKey.keyS) {
unawaited(_controller?.save());
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
final c = _controller;
final tokens = ClideTheme.of(context).surface;
_text.tokens = tokens;
if (c == null) return const SizedBox.shrink();
return ListenableBuilder(
listenable: c,
builder: (context, _) {
final title = c.activePath ?? 'editor';
final subtitle = c.activeId == null
? 'no buffer · use `clide open <path>` or pick a file in the tree'
: '${c.activeId} · ${c.dirty ? 'modified' : 'saved'}'
'${c.error == null ? '' : ' · ${c.error}'}';
return ClidePaneChrome(
title: title,
subtitle: subtitle,
child: c.activeId == null
? const Center(
child: ClideText(
'Open a file to begin editing.',
muted: true,
),
)
: Focus(
onKeyEvent: _onKey,
child: _TextBody(
controller: _text,
focus: _focus,
background: tokens.panelBackground,
foreground: tokens.globalForeground,
accent: tokens.globalFocus,
),
),
);
},
);
}
}
class _TextBody extends StatelessWidget {
const _TextBody({
required this.controller,
required this.focus,
required this.background,
required this.foreground,
required this.accent,
});
final TextEditingController controller;
final FocusNode focus;
final Color background;
final Color foreground;
final Color accent;
@override
Widget build(BuildContext context) {
return Semantics(
label: 'editor text area',
textField: true,
multiline: true,
child: ColoredBox(
color: background,
child: Padding(
padding: const EdgeInsets.all(8),
child: EditableText(
controller: controller,
focusNode: focus,
style: TextStyle(
color: foreground,
fontSize: clideFontMono,
fontFamily: clideMonoFamily,
fontFamilyFallback: clideMonoFamilyFallback,
),
cursorColor: foreground,
backgroundCursorColor: foreground.withAlpha(0x44),
selectionColor: accent.withAlpha(0x55),
maxLines: null,
expands: true,
keyboardType: TextInputType.multiline,
textAlign: TextAlign.start,
showCursor: true,
),
),
),
);
}
}
+30
View File
@@ -0,0 +1,30 @@
import 'package:clide/builtin/editor/src/editor_view.dart';
import 'package:clide/extension/extension.dart';
import 'package:clide/kernel/kernel.dart';
/// Tier-2 editor pane. Contributes a single workspace tab that
/// renders the daemon's active buffer. Multi-file tabs live in the
/// follow-up plan; today the pane is one-at-a-time.
class EditorExtension extends ClideExtension {
@override
String get id => 'builtin.editor';
@override
String get title => 'Editor';
@override
String get version => '0.1.0';
@override
List<String> get dependsOn => const [];
@override
List<ContributionPoint> get contributions => [
TabContribution(
id: 'editor.active',
slot: Slots.workspace,
title: 'Editor',
titleKey: 'tab.title',
i18nNamespace: id,
priority: 80, // between Claude (90) and welcome (-100)
build: (_) => const EditorView(),
),
];
}
@@ -0,0 +1,161 @@
library;
import 'dart:convert';
import 'package:clide/kernel/src/syntax/language_map.dart';
import 'package:clide/kernel/src/syntax/tree_sitter_service.dart';
import 'package:clide/kernel/src/theme/tokens.dart';
import 'package:flutter/widgets.dart';
class SyntaxTextController extends TextEditingController {
SyntaxTextController({required TreeSitterService syntax})
: _syntax = syntax;
final TreeSitterService _syntax;
String? _highlightedPath;
String? _highlightedText;
List<SyntaxSpan> _spans = const [];
SurfaceTokens? _tokens;
bool _highlighting = false;
set tokens(SurfaceTokens value) => _tokens = value;
void updatePath(String? path) {
if (path == _highlightedPath) return;
_highlightedPath = path;
_spans = const [];
_highlightedText = null;
_requestHighlight();
}
void _requestHighlight() {
final path = _highlightedPath;
final source = text;
if (path == null || source.isEmpty || _highlighting) return;
if (grammarForPath(path) == null) return;
if (source == _highlightedText) return;
_highlighting = true;
_syntax.highlight(path, source).then((result) {
_highlighting = false;
if (text != source) {
_requestHighlight();
return;
}
_highlightedText = source;
_spans = result.spans;
notifyListeners();
}, onError: (_) {
_highlighting = false;
});
}
@override
set value(TextEditingValue newValue) {
super.value = newValue;
_requestHighlight();
}
@override
TextSpan buildTextSpan({
required BuildContext context,
TextStyle? style,
required bool withComposing,
}) {
final tokens = _tokens;
if (_spans.isEmpty || tokens == null || text.isEmpty) {
return TextSpan(text: text, style: style);
}
final source = text;
final sourceBytes = utf8.encode(source);
final children = <TextSpan>[];
// Convert byte offsets to character offsets.
// Build a byte-to-char map only up to the max byte we need.
final spans = _spans.where((s) => s.end <= sourceBytes.length).toList()
..sort((a, b) => a.start != b.start ? a.start - b.start : a.end - b.end);
if (spans.isEmpty) {
return TextSpan(text: text, style: style);
}
int maxByte = 0;
for (final s in spans) {
if (s.end > maxByte) maxByte = s.end;
}
// byte offset -> char offset lookup.
final byteToChar = List<int>.filled(maxByte + 1, 0);
int charIdx = 0;
int byteIdx = 0;
while (byteIdx <= maxByte && charIdx <= source.length) {
byteToChar[byteIdx] = charIdx;
if (charIdx < source.length) {
final codeUnit = source.codeUnitAt(charIdx);
// UTF-16 surrogate pair = 4 bytes in UTF-8.
if (codeUnit >= 0xD800 && codeUnit <= 0xDBFF) {
final bytesForPair = utf8.encode(source.substring(charIdx, charIdx + 2)).length;
for (var b = 1; b < bytesForPair && byteIdx + b <= maxByte; b++) {
byteToChar[byteIdx + b] = charIdx;
}
byteIdx += bytesForPair;
charIdx += 2;
} else {
final bytesForChar = utf8.encode(source[charIdx]).length;
for (var b = 1; b < bytesForChar && byteIdx + b <= maxByte; b++) {
byteToChar[byteIdx + b] = charIdx;
}
byteIdx += bytesForChar;
charIdx++;
}
} else {
break;
}
}
int charPos = 0;
for (final span in spans) {
if (span.start >= sourceBytes.length || span.end > sourceBytes.length) {
continue;
}
final spanCharStart = byteToChar[span.start];
final spanCharEnd = span.end <= maxByte
? byteToChar[span.end]
: source.length;
if (spanCharStart < charPos) continue;
// Gap before this span — plain text.
if (spanCharStart > charPos) {
children.add(TextSpan(
text: source.substring(charPos, spanCharStart),
style: style,
));
}
// The highlighted span.
if (spanCharEnd > spanCharStart) {
children.add(TextSpan(
text: source.substring(spanCharStart, spanCharEnd),
style: style?.copyWith(
color: TreeSitterService.colorForRole(span.role, tokens),
),
));
}
charPos = spanCharEnd;
}
// Trailing plain text.
if (charPos < source.length) {
children.add(TextSpan(
text: source.substring(charPos),
style: style,
));
}
return TextSpan(style: style, children: children);
}
}