diff --git a/CHANGELOG.md b/CHANGELOG.md index 893fae85..6d745c2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,24 @@ heading, and (b) bumping `project.yaml` `version:` in the same commit. ### Added +- `builtin.editor` — Tier-2 editor tab wired up. Contributes a + single `Editor` workspace tab that renders the daemon's active + buffer via a new `EditorController`. Hydrates on mount + (`editor.active` → `editor.read`), subscribes to + `editor.opened | active-changed | edited | saved | closed`, and + propagates user edits back through `editor.set-content`. Small + echo-suppression guard avoids clobbering the caret when the + daemon's authoritative edit echo comes back. Text surface is + Flutter's `EditableText` primitive — no `TextField` / Material — + so the D-007 "no Material root" stance carries into the editor; + JetBrainsMono via the shared `clideMonoFamily` constants, cursor + + selection colours bind to the theme. + +- File-tree click in `builtin.files` now opens the clicked file in + the editor via `ipc.request('editor.open', {path})`. No local + command hop — the dispatch goes straight to the daemon and the + UI reconciles through the `editor.active-changed` event. + - CLI shortcuts per CLAUDE.md's Tier-2 list: `clide open `, `clide active`, `clide insert `, `clide replace-selection `, `clide save`, `clide tail --events [--filter diff --git a/app/lib/builtin/editor/src/editor_controller.dart b/app/lib/builtin/editor/src/editor_controller.dart new file mode 100644 index 00000000..331e6119 --- /dev/null +++ b/app/lib/builtin/editor/src/editor_controller.dart @@ -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_app/kernel/kernel.dart'; +import 'package:flutter/foundation.dart'; + +class EditorController extends ChangeNotifier { + EditorController({required this.ipc, required EventBus events}) + : _events = events { + _eventSub = events.on().listen(_onEvent); + } + + final DaemonClient ipc; + // ignore: unused_field — kept for future subscription changes + final EventBus _events; + + StreamSubscription? _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 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 _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()) + : 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 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(); + } +} diff --git a/app/lib/builtin/editor/src/editor_view.dart b/app/lib/builtin/editor/src/editor_view.dart new file mode 100644 index 00000000..187fab31 --- /dev/null +++ b/app/lib/builtin/editor/src/editor_view.dart @@ -0,0 +1,198 @@ +import 'dart:async'; + +import 'package:clide/clide.dart'; +import 'package:clide_app/kernel/kernel.dart'; +import 'package:clide_app/widgets/widgets.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import 'editor_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 createState() => _EditorViewState(); +} + +class _EditorViewState extends State { + EditorController? _controller; + late final TextEditingController _text; + late final FocusNode _focus; + String? _lastRemoteContent; + + @override + void initState() { + super.initState(); + _text = TextEditingController(); + _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(); + super.dispose(); + } + + void _onControllerChanged() { + final c = _controller!; + 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; + 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 ` 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, + fontSize: 13, + ), + ) + : 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: 13, + 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, + ), + ), + ), + ); + } +} diff --git a/app/lib/builtin/editor/src/extension.dart b/app/lib/builtin/editor/src/extension.dart index 08eff0cf..41cc8409 100644 --- a/app/lib/builtin/editor/src/extension.dart +++ b/app/lib/builtin/editor/src/extension.dart @@ -1,17 +1,30 @@ +import 'package:clide_app/builtin/editor/src/editor_view.dart'; import 'package:clide_app/extension/extension.dart'; +import 'package:clide_app/kernel/kernel.dart'; -/// Tier-0 stub. Real implementation lands in a later tier; the extension -/// is registered so the extensions-ui surface can list it as "installed, -/// not yet implemented" and its id is reserved. +/// 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.0.0-stub'; + String get version => '0.1.0'; @override List get dependsOn => const []; + @override - List get contributions => const []; + List 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(), + ), + ]; } diff --git a/app/lib/builtin/files/src/file_tree_view.dart b/app/lib/builtin/files/src/file_tree_view.dart index 93b198ec..4ba6b92d 100644 --- a/app/lib/builtin/files/src/file_tree_view.dart +++ b/app/lib/builtin/files/src/file_tree_view.dart @@ -197,11 +197,14 @@ class _FileRow extends StatelessWidget { void _openFile(BuildContext context, String path) { final kernel = ClideKernel.of(context); - // editor.open is a Tier-2 command; the registry's contract takes - // a positional argv, so pass the path as argv[0]. Response is - // ignored — until the editor extension registers the handler, - // execute() returns a not-found error. - unawaited(kernel.commands.execute('editor.open', args: [path])); + // editor.open is a daemon-side IPC handler (lib/src/daemon/ + // editor_commands.dart), not a kernel command. Fire the request + // and let the editor extension's controller pick up the + // editor.active-changed / editor.opened event — no need to await + // or handle the response here. + unawaited( + kernel.ipc.request('editor.open', args: {'path': path}), + ); } } diff --git a/app/lib/kernel/src/i18n/catalog/builtin.editor_en_us.json b/app/lib/kernel/src/i18n/catalog/builtin.editor_en_us.json new file mode 100644 index 00000000..58a96b0f --- /dev/null +++ b/app/lib/kernel/src/i18n/catalog/builtin.editor_en_us.json @@ -0,0 +1,5 @@ +{ + "tab.title": { "translation": "Editor" }, + "empty": { "translation": "Open a file to begin editing." }, + "subtitle.no-buffer": { "translation": "no buffer · use `clide open ` or pick a file in the tree" } +} diff --git a/app/lib/main.dart b/app/lib/main.dart index f749d606..94e2f874 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -125,4 +125,5 @@ const List _tier0Namespaces = [ 'builtin.terminal', 'builtin.files', 'builtin.claude', + 'builtin.editor', ];