wire builtin.editor to the active buffer + open files from the tree
test / unit + widget + golden + a11y (push) Failing after 38s
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
test / unit + widget + golden + a11y (push) Failing after 38s
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
EditorController mirrors the daemon's active buffer into the Flutter
UI. Hydrates on mount (editor.active → editor.read), subscribes to
editor.opened / active-changed / edited / saved / closed, and pushes
local edits back via editor.set-content. A one-shot echo-suppression
flag keeps the daemon's authoritative edit echo from clobbering the
caret the user just moved.
Text surface is EditableText directly — no TextField / Material
import, so D-007's "bare WidgetsApp" guardrail carries into the
editor tab. JetBrainsMono via the shared mono-family constants;
cursor + selection colours come from the theme tokens.
File-tree click now calls ipc.request('editor.open', {path}) instead
of the kernel command registry (which wouldn't have reached the
daemon). The opened file becomes active, the editor tab's controller
sees editor.active-changed, and the text content reconciles.
Linux release bundle still builds; 174 app tests + 74 core tests
stay green.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,24 @@ heading, and (b) bumping `project.yaml` `version:` in the same commit.
|
|||||||
|
|
||||||
### Added
|
### 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 <path>`,
|
- CLI shortcuts per CLAUDE.md's Tier-2 list: `clide open <path>`,
|
||||||
`clide active`, `clide insert <text | ->`, `clide replace-selection
|
`clide active`, `clide insert <text | ->`, `clide replace-selection
|
||||||
<text | ->`, `clide save`, `clide tail --events [--filter
|
<text | ->`, `clide save`, `clide tail --events [--filter
|
||||||
|
|||||||
@@ -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<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,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<EditorView> createState() => _EditorViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EditorViewState extends State<EditorView> {
|
||||||
|
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 <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,
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/extension/extension.dart';
|
||||||
|
import 'package:clide_app/kernel/kernel.dart';
|
||||||
|
|
||||||
/// Tier-0 stub. Real implementation lands in a later tier; the extension
|
/// Tier-2 editor pane. Contributes a single workspace tab that
|
||||||
/// is registered so the extensions-ui surface can list it as "installed,
|
/// renders the daemon's active buffer. Multi-file tabs live in the
|
||||||
/// not yet implemented" and its id is reserved.
|
/// follow-up plan; today the pane is one-at-a-time.
|
||||||
class EditorExtension extends ClideExtension {
|
class EditorExtension extends ClideExtension {
|
||||||
@override
|
@override
|
||||||
String get id => 'builtin.editor';
|
String get id => 'builtin.editor';
|
||||||
@override
|
@override
|
||||||
String get title => 'Editor';
|
String get title => 'Editor';
|
||||||
@override
|
@override
|
||||||
String get version => '0.0.0-stub';
|
String get version => '0.1.0';
|
||||||
@override
|
@override
|
||||||
List<String> get dependsOn => const [];
|
List<String> get dependsOn => const [];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<ContributionPoint> get contributions => const [];
|
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(),
|
||||||
|
),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,11 +197,14 @@ class _FileRow extends StatelessWidget {
|
|||||||
|
|
||||||
void _openFile(BuildContext context, String path) {
|
void _openFile(BuildContext context, String path) {
|
||||||
final kernel = ClideKernel.of(context);
|
final kernel = ClideKernel.of(context);
|
||||||
// editor.open is a Tier-2 command; the registry's contract takes
|
// editor.open is a daemon-side IPC handler (lib/src/daemon/
|
||||||
// a positional argv, so pass the path as argv[0]. Response is
|
// editor_commands.dart), not a kernel command. Fire the request
|
||||||
// ignored — until the editor extension registers the handler,
|
// and let the editor extension's controller pick up the
|
||||||
// execute() returns a not-found error.
|
// editor.active-changed / editor.opened event — no need to await
|
||||||
unawaited(kernel.commands.execute('editor.open', args: [path]));
|
// or handle the response here.
|
||||||
|
unawaited(
|
||||||
|
kernel.ipc.request('editor.open', args: {'path': path}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 <path>` or pick a file in the tree" }
|
||||||
|
}
|
||||||
@@ -125,4 +125,5 @@ const List<String> _tier0Namespaces = [
|
|||||||
'builtin.terminal',
|
'builtin.terminal',
|
||||||
'builtin.files',
|
'builtin.files',
|
||||||
'builtin.claude',
|
'builtin.claude',
|
||||||
|
'builtin.editor',
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user