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:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user