Compare commits
3
Commits
37108230f2
...
681ce3b447
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
681ce3b447 | ||
|
|
50d0143afc | ||
|
|
d32e8fdc17 |
@@ -18,6 +18,47 @@ 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 <path>`,
|
||||
`clide active`, `clide insert <text | ->`, `clide replace-selection
|
||||
<text | ->`, `clide save`, `clide tail --events [--filter
|
||||
SUBSYSTEM[:ID]]`. A lone `-` on insert / replace-selection reads
|
||||
text from stdin (pipe-friendly). `tail` reads the event-broadcast
|
||||
stream and prints JSON lines until SIGINT; `--filter` narrows by
|
||||
subsystem or subsystem+id. 5 new end-to-end CLI tests spin up real
|
||||
daemon subprocesses via a per-test `CLIDE_SOCKET_PATH` override (new
|
||||
env knob on `defaultSocketPath`) so tests run in parallel without
|
||||
colliding.
|
||||
|
||||
- Editor subsystem in the daemon (`lib/src/editor/`). `EditorBuffer`
|
||||
holds path + content + cursor/selection + dirty flag;
|
||||
`EditorRegistry` owns the open-buffer set, active-buffer tracking,
|
||||
and file I/O. IPC verbs land alongside (`editor.open | active |
|
||||
activate | list | read | insert | replace-selection | set-selection
|
||||
| set-content | save | close`) with matching events (`editor.opened
|
||||
| active-changed | selection-changed | edited | saved | closed`).
|
||||
Omitting `id` on mutating verbs targets the active buffer so the
|
||||
tier-2 CLI shortcuts (`clide insert "…"`, `clide replace-selection
|
||||
"…"`) read naturally. 16 new core tests cover the lifecycle +
|
||||
dispatcher round-trips.
|
||||
|
||||
- `builtin.claude` — Tier-1 stub upgraded to the real Claude pane per
|
||||
D-041. Contributes a primary `Claude` tab in the workspace slot that
|
||||
spawns `tmux new-session -A -s clide-claude-<hash> -- claude` via
|
||||
|
||||
@@ -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/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<String> get dependsOn => const [];
|
||||
|
||||
@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) {
|
||||
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}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.files',
|
||||
'builtin.claude',
|
||||
'builtin.editor',
|
||||
];
|
||||
|
||||
+190
-47
@@ -1,12 +1,12 @@
|
||||
// clide — CLI + daemon entry point.
|
||||
//
|
||||
// One binary, two modes (per ADR 0005):
|
||||
// One binary, two modes (per D-005):
|
||||
// * `clide <subcommand>` — one-shot; connects to the daemon socket,
|
||||
// sends a request, prints the response, exits with the ADR-0006
|
||||
// sends a request, prints the response, exits with the D-006
|
||||
// exit code.
|
||||
// * `clide --daemon` — long-running; owns the socket, dispatches
|
||||
// requests. Tier 0 ships `ping` and `version`; feature subsystems
|
||||
// (pane, git, pql, etc.) register handlers as they land.
|
||||
// requests. Subsystems (pane, files, editor, …) register handlers
|
||||
// at boot.
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
@@ -16,8 +16,10 @@ import 'package:clide/clide.dart';
|
||||
// Daemon-only deep imports — these pull in dart:ffi (PTY) and
|
||||
// daemon-subsystem wiring that the Flutter app doesn't need and
|
||||
// can't compile for web. See lib/clide.dart for the barrel split.
|
||||
import 'package:clide/src/daemon/editor_commands.dart';
|
||||
import 'package:clide/src/daemon/files_commands.dart';
|
||||
import 'package:clide/src/daemon/pane_commands.dart';
|
||||
import 'package:clide/src/editor/registry.dart' show EditorRegistry;
|
||||
import 'package:clide/src/panes/registry.dart';
|
||||
|
||||
Future<void> main(List<String> argv) async {
|
||||
@@ -31,22 +33,50 @@ Future<void> main(List<String> argv) async {
|
||||
return;
|
||||
}
|
||||
|
||||
// Tier-2 single-word shortcuts (per CLAUDE.md). Each maps a flat
|
||||
// positional argv into the structured IPC shape of the canonical
|
||||
// editor.* / pane.* verb. Keeps Claude's tool-use pattern short.
|
||||
final rest = argv.sublist(1);
|
||||
switch (argv.first) {
|
||||
case '--version':
|
||||
case 'version':
|
||||
await _runCli('version', const [], exitOnOk: true);
|
||||
await _runCliArgs('version', const {}, exitOnOk: true);
|
||||
case '--help':
|
||||
case '-h':
|
||||
case 'help':
|
||||
_printHelp(stdout);
|
||||
exit(0);
|
||||
case 'ping':
|
||||
await _runCli('ping', argv.sublist(1), exitOnOk: true);
|
||||
await _runCliArgs('ping', const {}, exitOnOk: true);
|
||||
case 'open':
|
||||
if (rest.isEmpty) _die('usage: clide open <path>');
|
||||
await _runCliArgs('editor.open', {'path': rest.first}, exitOnOk: true);
|
||||
case 'active':
|
||||
await _runCliArgs('editor.active', const {}, exitOnOk: true);
|
||||
case 'insert':
|
||||
final text = await _readTextArg(rest);
|
||||
await _runCliArgs('editor.insert', {'text': text}, exitOnOk: true);
|
||||
case 'replace-selection':
|
||||
final text = await _readTextArg(rest);
|
||||
await _runCliArgs(
|
||||
'editor.replace-selection',
|
||||
{'text': text},
|
||||
exitOnOk: true,
|
||||
);
|
||||
case 'save':
|
||||
await _runCliArgs('editor.save', const {}, exitOnOk: true);
|
||||
case 'tail':
|
||||
await _runTail(rest);
|
||||
default:
|
||||
// Unknown-to-the-CLI commands still go over IPC — the daemon is
|
||||
// authoritative about what's registered. Lets extensions add
|
||||
// subcommands without the CLI caring.
|
||||
await _runCli(argv.first, argv.sublist(1), exitOnOk: true);
|
||||
// subcommands without the CLI caring. Args forward as-is under
|
||||
// {argv: [...]} so daemon-side can parse whatever shape it wants.
|
||||
await _runCliArgs(
|
||||
argv.first,
|
||||
{'argv': rest},
|
||||
exitOnOk: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,9 +93,24 @@ Built-in subcommands:
|
||||
version Print the clide version.
|
||||
help Print this help.
|
||||
|
||||
Editor (tier 2):
|
||||
open <path> Open a file in the editor (editor.open).
|
||||
active Print the active buffer (editor.active).
|
||||
insert <text|-> Insert text at the cursor in the active buffer.
|
||||
`-` reads text from stdin.
|
||||
replace-selection <…> Replace the selected text in the active buffer.
|
||||
`-` reads text from stdin.
|
||||
save Save the active buffer (editor.save).
|
||||
|
||||
Event subscription:
|
||||
tail --events [--filter SUBSYSTEM[:ID]]
|
||||
Stream events as JSON lines. --filter keeps
|
||||
only events from one subsystem, optionally
|
||||
narrowed to a single id. Exits on SIGINT.
|
||||
|
||||
Any other subcommand is forwarded to the daemon; registered handlers
|
||||
(e.g. `clide git status` once `builtin.git` lands) resolve there.
|
||||
Matches ADR 0006's exit-code contract:
|
||||
Matches D-006's exit-code contract:
|
||||
0 success · 1 user-error · 2 tool-error · 3 not-found · 4 conflict
|
||||
''');
|
||||
}
|
||||
@@ -85,6 +130,9 @@ Future<void> _runDaemon(List<String> args) async {
|
||||
final files = FilesService.atCwd(events: events);
|
||||
registerFilesCommands(dispatcher, files);
|
||||
|
||||
final editor = EditorRegistry(events: events, workspaceRoot: files.root);
|
||||
registerEditorCommands(dispatcher, editor);
|
||||
|
||||
final stopping = Completer<void>();
|
||||
void shutdown(ProcessSignal sig) {
|
||||
if (!stopping.isCompleted) {
|
||||
@@ -99,6 +147,7 @@ Future<void> _runDaemon(List<String> args) async {
|
||||
await server.start();
|
||||
await stopping.future;
|
||||
await registry.shutdown();
|
||||
await editor.shutdown();
|
||||
await files.shutdown();
|
||||
await server.stop();
|
||||
exit(0);
|
||||
@@ -115,19 +164,33 @@ class _ServerEventSink implements DaemonEventSink {
|
||||
void emit(IpcEvent event) => _server.broadcast(event);
|
||||
}
|
||||
|
||||
Future<void> _runCli(
|
||||
String cmd,
|
||||
List<String> args, {
|
||||
required bool exitOnOk,
|
||||
}) async {
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Read the "text" argument for insert / replace-selection. A lone
|
||||
/// `-` means "slurp stdin"; anything else is concatenated into the
|
||||
/// text body (so `clide insert hello world` emits "hello world").
|
||||
Future<String> _readTextArg(List<String> rest) async {
|
||||
if (rest.isEmpty) _die('usage: clide <verb> <text> (or `-` to read stdin)');
|
||||
if (rest.length == 1 && rest.first == '-') {
|
||||
final bytes = <int>[];
|
||||
await for (final chunk in stdin) {
|
||||
bytes.addAll(chunk);
|
||||
}
|
||||
return utf8.decode(bytes);
|
||||
}
|
||||
return rest.join(' ');
|
||||
}
|
||||
|
||||
Future<Socket> _connectSocket() async {
|
||||
final socketPath = defaultSocketPath();
|
||||
Socket socket;
|
||||
try {
|
||||
socket = await Socket.connect(
|
||||
return await Socket.connect(
|
||||
InternetAddress(socketPath, type: InternetAddressType.unix),
|
||||
0,
|
||||
);
|
||||
} catch (e) {
|
||||
} catch (_) {
|
||||
_emitError(
|
||||
code: IpcExitCode.toolError,
|
||||
kind: IpcErrorKind.toolError,
|
||||
@@ -136,44 +199,53 @@ Future<void> _runCli(
|
||||
);
|
||||
exit(IpcExitCode.toolError);
|
||||
}
|
||||
}
|
||||
|
||||
final request = IpcRequest(
|
||||
id: '1',
|
||||
cmd: cmd,
|
||||
args: {'argv': args},
|
||||
);
|
||||
Future<void> _runCliArgs(
|
||||
String cmd,
|
||||
Map<String, Object?> args, {
|
||||
required bool exitOnOk,
|
||||
}) async {
|
||||
final socket = await _connectSocket();
|
||||
final request = IpcRequest(id: '1', cmd: cmd, args: args);
|
||||
socket.writeln(request.encode());
|
||||
|
||||
final line = await socket
|
||||
// Responses come back on the same socket. Events may be interleaved
|
||||
// (the daemon broadcasts), so we skip events until we see the
|
||||
// response whose id matches our request.
|
||||
final lines = socket
|
||||
.cast<List<int>>()
|
||||
.transform(utf8.decoder)
|
||||
.transform(const LineSplitter())
|
||||
.first;
|
||||
await socket.close();
|
||||
.transform(const LineSplitter());
|
||||
|
||||
try {
|
||||
final msg = IpcMessage.decode(line);
|
||||
if (msg is! IpcResponse) {
|
||||
_emitError(
|
||||
code: IpcExitCode.toolError,
|
||||
kind: IpcErrorKind.toolError,
|
||||
message: 'unexpected message from daemon',
|
||||
);
|
||||
exit(IpcExitCode.toolError);
|
||||
}
|
||||
if (msg.ok) {
|
||||
stdout.writeln(jsonEncode(msg.data));
|
||||
if (exitOnOk) exit(IpcExitCode.ok);
|
||||
} else {
|
||||
final err = msg.error!;
|
||||
_emitError(
|
||||
code: err.code,
|
||||
kind: err.kind,
|
||||
message: err.message,
|
||||
hint: err.hint,
|
||||
);
|
||||
exit(err.code);
|
||||
await for (final line in lines) {
|
||||
if (line.isEmpty) continue;
|
||||
final msg = IpcMessage.decode(line);
|
||||
if (msg is! IpcResponse) continue;
|
||||
if (msg.id != request.id) continue;
|
||||
await socket.close();
|
||||
if (msg.ok) {
|
||||
stdout.writeln(jsonEncode(msg.data));
|
||||
if (exitOnOk) exit(IpcExitCode.ok);
|
||||
return;
|
||||
} else {
|
||||
final err = msg.error!;
|
||||
_emitError(
|
||||
code: err.code,
|
||||
kind: err.kind,
|
||||
message: err.message,
|
||||
hint: err.hint,
|
||||
);
|
||||
exit(err.code);
|
||||
}
|
||||
}
|
||||
_emitError(
|
||||
code: IpcExitCode.toolError,
|
||||
kind: IpcErrorKind.toolError,
|
||||
message: 'daemon closed socket before responding',
|
||||
);
|
||||
exit(IpcExitCode.toolError);
|
||||
} on FormatException catch (e) {
|
||||
_emitError(
|
||||
code: IpcExitCode.toolError,
|
||||
@@ -184,6 +256,68 @@ Future<void> _runCli(
|
||||
}
|
||||
}
|
||||
|
||||
/// `clide tail --events [--filter SUBSYSTEM[:ID]]` — stream events.
|
||||
Future<void> _runTail(List<String> args) async {
|
||||
// Parse flags: --events (required today; keeps us honest when more
|
||||
// modes like --history land), --filter SUBSYSTEM[:ID].
|
||||
var wantEvents = false;
|
||||
String? filterSubsystem;
|
||||
String? filterId;
|
||||
for (var i = 0; i < args.length; i++) {
|
||||
final a = args[i];
|
||||
if (a == '--events') {
|
||||
wantEvents = true;
|
||||
} else if (a == '--filter') {
|
||||
if (i + 1 >= args.length) _die('--filter requires an argument');
|
||||
final spec = args[++i];
|
||||
final colon = spec.indexOf(':');
|
||||
if (colon < 0) {
|
||||
filterSubsystem = spec;
|
||||
} else {
|
||||
filterSubsystem = spec.substring(0, colon);
|
||||
filterId = spec.substring(colon + 1);
|
||||
}
|
||||
} else {
|
||||
_die('unknown argument: $a');
|
||||
}
|
||||
}
|
||||
if (!wantEvents) _die('clide tail: pass --events');
|
||||
|
||||
final socket = await _connectSocket();
|
||||
|
||||
// Shutdown on SIGINT / SIGTERM — close the socket so the stream
|
||||
// drains and we exit cleanly.
|
||||
void quit() {
|
||||
unawaited(socket.close());
|
||||
}
|
||||
ProcessSignal.sigint.watch().listen((_) => quit());
|
||||
ProcessSignal.sigterm.watch().listen((_) => quit());
|
||||
|
||||
final lines = socket
|
||||
.cast<List<int>>()
|
||||
.transform(utf8.decoder)
|
||||
.transform(const LineSplitter());
|
||||
|
||||
try {
|
||||
await for (final line in lines) {
|
||||
if (line.isEmpty) continue;
|
||||
IpcMessage msg;
|
||||
try {
|
||||
msg = IpcMessage.decode(line);
|
||||
} on FormatException {
|
||||
continue;
|
||||
}
|
||||
if (msg is! IpcEvent) continue;
|
||||
if (filterSubsystem != null && msg.subsystem != filterSubsystem) continue;
|
||||
if (filterId != null && msg.data['id'] != filterId) continue;
|
||||
stdout.writeln(line);
|
||||
}
|
||||
} finally {
|
||||
await socket.close();
|
||||
}
|
||||
exit(0);
|
||||
}
|
||||
|
||||
void _emitError({
|
||||
required int code,
|
||||
required String kind,
|
||||
@@ -193,3 +327,12 @@ void _emitError({
|
||||
final err = IpcError(code: code, kind: kind, message: message, hint: hint);
|
||||
stderr.writeln(jsonEncode(err.toJson()));
|
||||
}
|
||||
|
||||
Never _die(String msg) {
|
||||
_emitError(
|
||||
code: IpcExitCode.userError,
|
||||
kind: IpcErrorKind.userError,
|
||||
message: msg,
|
||||
);
|
||||
exit(IpcExitCode.userError);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ library;
|
||||
// they're pure data types that both the app and the daemon reference.
|
||||
|
||||
export 'src/daemon/dispatcher.dart';
|
||||
export 'src/editor/buffer.dart';
|
||||
export 'src/files/ignore.dart';
|
||||
export 'src/files/listing.dart' show FileEntry, listDir;
|
||||
export 'src/ipc/envelope.dart';
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/// Registers `editor.*` command handlers on a [DaemonDispatcher].
|
||||
///
|
||||
/// Verb list matches CLAUDE.md's tier-2 surface:
|
||||
/// editor.open editor.active editor.activate editor.insert
|
||||
/// editor.replace-selection editor.save editor.close editor.list
|
||||
/// editor.read editor.set-selection editor.set-content
|
||||
///
|
||||
/// Single-word CLI shortcuts (`clide open`, `clide active`, …) map
|
||||
/// one-to-one onto these in `bin/clide.dart`.
|
||||
library;
|
||||
|
||||
import '../editor/registry.dart';
|
||||
import '../ipc/envelope.dart';
|
||||
import '../ipc/schema_v1.dart';
|
||||
import 'dispatcher.dart';
|
||||
|
||||
export '../editor/buffer.dart' show Selection;
|
||||
|
||||
void registerEditorCommands(DaemonDispatcher d, EditorRegistry registry) {
|
||||
d.register('editor.open', (req) => _open(req, registry));
|
||||
d.register('editor.active', (req) => _active(req, registry));
|
||||
d.register('editor.activate', (req) => _activate(req, registry));
|
||||
d.register('editor.list', (req) => _list(req, registry));
|
||||
d.register('editor.read', (req) => _read(req, registry));
|
||||
d.register('editor.insert', (req) => _insert(req, registry));
|
||||
d.register('editor.replace-selection', (req) => _replace(req, registry));
|
||||
d.register('editor.set-selection', (req) => _setSelection(req, registry));
|
||||
d.register('editor.set-content', (req) => _setContent(req, registry));
|
||||
d.register('editor.save', (req) => _save(req, registry));
|
||||
d.register('editor.close', (req) => _close(req, registry));
|
||||
}
|
||||
|
||||
IpcResponse _userErr(String id, String msg, {String? hint}) => IpcResponse.err(
|
||||
id: id,
|
||||
error: IpcError(
|
||||
code: IpcExitCode.userError,
|
||||
kind: IpcErrorKind.userError,
|
||||
message: msg,
|
||||
hint: hint,
|
||||
),
|
||||
);
|
||||
|
||||
IpcResponse _notFound(String id, String msg) => IpcResponse.err(
|
||||
id: id,
|
||||
error: IpcError(
|
||||
code: IpcExitCode.notFound,
|
||||
kind: IpcErrorKind.notFound,
|
||||
message: msg,
|
||||
),
|
||||
);
|
||||
|
||||
String? _resolveId(IpcRequest req, EditorRegistry r) {
|
||||
final id = req.args['id'] as String?;
|
||||
if (id != null) return id;
|
||||
// CLI shortcut: omitting `id` means the active buffer.
|
||||
return r.active?.id;
|
||||
}
|
||||
|
||||
Future<IpcResponse> _open(IpcRequest req, EditorRegistry r) async {
|
||||
final path = req.args['path'] as String?;
|
||||
if (path == null || path.isEmpty) {
|
||||
return _userErr(req.id, 'path is required');
|
||||
}
|
||||
try {
|
||||
final buf = await r.open(path);
|
||||
return IpcResponse.ok(id: req.id, data: buf.toJson());
|
||||
} catch (e) {
|
||||
return IpcResponse.err(
|
||||
id: req.id,
|
||||
error: IpcError(
|
||||
code: IpcExitCode.toolError,
|
||||
kind: IpcErrorKind.toolError,
|
||||
message: 'editor.open failed: $e',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<IpcResponse> _active(IpcRequest req, EditorRegistry r) async {
|
||||
final buf = r.active;
|
||||
if (buf == null) {
|
||||
return IpcResponse.ok(id: req.id, data: const {'active': null});
|
||||
}
|
||||
return IpcResponse.ok(id: req.id, data: {'active': buf.toJson()});
|
||||
}
|
||||
|
||||
Future<IpcResponse> _activate(IpcRequest req, EditorRegistry r) async {
|
||||
final id = req.args['id'] as String?;
|
||||
if (id == null) return _userErr(req.id, 'id is required');
|
||||
if (r.get(id) == null) return _notFound(req.id, 'no such buffer: $id');
|
||||
r.activate(id);
|
||||
return IpcResponse.ok(id: req.id, data: {'active': id});
|
||||
}
|
||||
|
||||
Future<IpcResponse> _list(IpcRequest req, EditorRegistry r) async {
|
||||
return IpcResponse.ok(
|
||||
id: req.id,
|
||||
data: {'buffers': [for (final b in r.buffers) b.toJson()]},
|
||||
);
|
||||
}
|
||||
|
||||
Future<IpcResponse> _read(IpcRequest req, EditorRegistry r) async {
|
||||
final id = _resolveId(req, r);
|
||||
if (id == null) return _notFound(req.id, 'no active buffer');
|
||||
final buf = r.get(id);
|
||||
if (buf == null) return _notFound(req.id, 'no such buffer: $id');
|
||||
return IpcResponse.ok(id: req.id, data: buf.toFullJson());
|
||||
}
|
||||
|
||||
Future<IpcResponse> _insert(IpcRequest req, EditorRegistry r) async {
|
||||
final id = _resolveId(req, r);
|
||||
if (id == null) return _notFound(req.id, 'no active buffer');
|
||||
if (r.get(id) == null) return _notFound(req.id, 'no such buffer: $id');
|
||||
final text = EditorRegistry.contentFromArgs(req.args);
|
||||
r.insert(id, text);
|
||||
return IpcResponse.ok(id: req.id, data: {'id': id, 'inserted': text.length});
|
||||
}
|
||||
|
||||
Future<IpcResponse> _replace(IpcRequest req, EditorRegistry r) async {
|
||||
final id = _resolveId(req, r);
|
||||
if (id == null) return _notFound(req.id, 'no active buffer');
|
||||
if (r.get(id) == null) return _notFound(req.id, 'no such buffer: $id');
|
||||
final text = EditorRegistry.contentFromArgs(req.args);
|
||||
r.replaceSelection(id, text);
|
||||
return IpcResponse.ok(id: req.id, data: {'id': id, 'length': text.length});
|
||||
}
|
||||
|
||||
Future<IpcResponse> _setSelection(IpcRequest req, EditorRegistry r) async {
|
||||
final id = _resolveId(req, r);
|
||||
if (id == null) return _notFound(req.id, 'no active buffer');
|
||||
if (r.get(id) == null) return _notFound(req.id, 'no such buffer: $id');
|
||||
final sel = EditorRegistry.selectionFromArgs(req.args['selection']);
|
||||
r.setSelection(id, sel);
|
||||
return IpcResponse.ok(id: req.id, data: {'id': id});
|
||||
}
|
||||
|
||||
Future<IpcResponse> _setContent(IpcRequest req, EditorRegistry r) async {
|
||||
final id = _resolveId(req, r);
|
||||
if (id == null) return _notFound(req.id, 'no active buffer');
|
||||
if (r.get(id) == null) return _notFound(req.id, 'no such buffer: $id');
|
||||
final content = EditorRegistry.contentFromArgs(req.args);
|
||||
final sel = req.args['selection'] == null
|
||||
? null
|
||||
: EditorRegistry.selectionFromArgs(req.args['selection']);
|
||||
r.setContent(id, content, selection: sel);
|
||||
return IpcResponse.ok(id: req.id, data: {'id': id, 'length': content.length});
|
||||
}
|
||||
|
||||
Future<IpcResponse> _save(IpcRequest req, EditorRegistry r) async {
|
||||
final id = _resolveId(req, r);
|
||||
if (id == null) return _notFound(req.id, 'no active buffer');
|
||||
final ok = await r.save(id);
|
||||
if (!ok) return _notFound(req.id, 'no such buffer: $id');
|
||||
return IpcResponse.ok(id: req.id, data: {'id': id, 'saved': true});
|
||||
}
|
||||
|
||||
Future<IpcResponse> _close(IpcRequest req, EditorRegistry r) async {
|
||||
final id = req.args['id'] as String?;
|
||||
if (id == null) return _userErr(req.id, 'id is required');
|
||||
if (r.get(id) == null) return _notFound(req.id, 'no such buffer: $id');
|
||||
r.close(id);
|
||||
return IpcResponse.ok(id: req.id, data: {'id': id});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/// A buffer in the daemon's editor model.
|
||||
///
|
||||
/// Represents an open file: its on-disk path, the authoritative text
|
||||
/// content, the UI's current cursor/selection, and whether it has
|
||||
/// unsaved changes. Thin data class — the [EditorRegistry] owns the
|
||||
/// transitions.
|
||||
library;
|
||||
|
||||
class Selection {
|
||||
const Selection({required this.start, required this.end});
|
||||
|
||||
const Selection.collapsed(int offset)
|
||||
: start = offset,
|
||||
end = offset;
|
||||
|
||||
final int start;
|
||||
final int end;
|
||||
|
||||
bool get isCollapsed => start == end;
|
||||
int get length => end - start;
|
||||
|
||||
Map<String, Object?> toJson() => {'start': start, 'end': end};
|
||||
|
||||
factory Selection.fromJson(Map<String, Object?> j) => Selection(
|
||||
start: (j['start'] as num).toInt(),
|
||||
end: (j['end'] as num).toInt(),
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is Selection && other.start == start && other.end == end;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(start, end);
|
||||
|
||||
@override
|
||||
String toString() => 'Selection($start-$end)';
|
||||
}
|
||||
|
||||
class EditorBuffer {
|
||||
EditorBuffer({
|
||||
required this.id,
|
||||
required this.path,
|
||||
required this.content,
|
||||
Selection? selection,
|
||||
this.dirty = false,
|
||||
}) : selection = selection ?? const Selection.collapsed(0);
|
||||
|
||||
/// Stable daemon-local id (`b_1`, `b_2`, …).
|
||||
final String id;
|
||||
|
||||
/// Repo-relative path. Path is the identity key for "reopening the
|
||||
/// same file" — opening an already-open path returns the existing
|
||||
/// buffer.
|
||||
final String path;
|
||||
|
||||
/// Authoritative text content. UI edits mutate this via IPC
|
||||
/// (`editor.insert`, `editor.replace-selection`); the UI's local
|
||||
/// copy reconciles to match.
|
||||
String content;
|
||||
|
||||
/// Cursor / selection. Offsets are byte offsets into [content] —
|
||||
/// utf-8 characters that span multiple bytes count as multiple
|
||||
/// offsets, same convention Flutter's `TextEditingValue` uses.
|
||||
Selection selection;
|
||||
|
||||
/// True after an edit landed that hasn't been persisted via
|
||||
/// `editor.save` (or `files.save` in future).
|
||||
bool dirty;
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'id': id,
|
||||
'path': path,
|
||||
'length': content.length,
|
||||
'selection': selection.toJson(),
|
||||
'dirty': dirty,
|
||||
};
|
||||
|
||||
/// Full snapshot including [content] — for `editor.read` / tests /
|
||||
/// anything that needs the text explicitly.
|
||||
Map<String, Object?> toFullJson() => {
|
||||
...toJson(),
|
||||
'content': content,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/// [EditorRegistry] — daemon-side state for open editor buffers.
|
||||
///
|
||||
/// Owns a set of [EditorBuffer]s keyed by id. At most one buffer is
|
||||
/// `active` at a time — the UI tells the daemon which one it's
|
||||
/// focused on via `editor.activate`. All state transitions emit
|
||||
/// events through the [DaemonEventSink].
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import '../ipc/envelope.dart';
|
||||
import '../panes/event_sink.dart';
|
||||
import 'buffer.dart';
|
||||
|
||||
class EditorRegistry {
|
||||
EditorRegistry({
|
||||
required this.events,
|
||||
required this.workspaceRoot,
|
||||
});
|
||||
|
||||
final DaemonEventSink events;
|
||||
|
||||
/// Workspace root used to resolve repo-relative paths to disk.
|
||||
final Directory workspaceRoot;
|
||||
|
||||
final Map<String, EditorBuffer> _buffers = {};
|
||||
final Map<String, String> _pathToId = {}; // repo-rel path → id
|
||||
int _nextId = 1;
|
||||
String? _activeId;
|
||||
|
||||
Iterable<EditorBuffer> get buffers => _buffers.values;
|
||||
EditorBuffer? get(String id) => _buffers[id];
|
||||
EditorBuffer? get active =>
|
||||
_activeId == null ? null : _buffers[_activeId!];
|
||||
|
||||
/// Open a file. If [path] is already open, returns the existing
|
||||
/// buffer (no re-read from disk — the in-memory content is the
|
||||
/// source of truth between save points).
|
||||
Future<EditorBuffer> open(String path) async {
|
||||
final existing = _pathToId[path];
|
||||
if (existing != null) {
|
||||
final buf = _buffers[existing]!;
|
||||
_setActive(buf.id);
|
||||
return buf;
|
||||
}
|
||||
|
||||
final absolute = _absolutePathOf(path);
|
||||
final file = File(absolute);
|
||||
String content = '';
|
||||
if (await file.exists()) {
|
||||
content = await file.readAsString();
|
||||
}
|
||||
|
||||
final id = 'b_${_nextId++}';
|
||||
final buf = EditorBuffer(id: id, path: path, content: content);
|
||||
_buffers[id] = buf;
|
||||
_pathToId[path] = id;
|
||||
|
||||
_emit('editor.opened', {
|
||||
...buf.toJson(),
|
||||
'content': buf.content, // snapshot at open time
|
||||
});
|
||||
_setActive(id);
|
||||
return buf;
|
||||
}
|
||||
|
||||
/// Mark [id] as the active buffer. Idempotent.
|
||||
void activate(String id) {
|
||||
if (!_buffers.containsKey(id)) return;
|
||||
_setActive(id);
|
||||
}
|
||||
|
||||
/// Insert [text] at the buffer's cursor (or replace the selection
|
||||
/// if one exists). Advances the cursor past the inserted text.
|
||||
void insert(String id, String text) {
|
||||
final buf = _buffers[id];
|
||||
if (buf == null) return;
|
||||
final sel = buf.selection;
|
||||
final before = buf.content.substring(0, sel.start);
|
||||
final after = buf.content.substring(sel.end);
|
||||
buf.content = '$before$text$after';
|
||||
final newCaret = sel.start + text.length;
|
||||
buf.selection = Selection.collapsed(newCaret);
|
||||
buf.dirty = true;
|
||||
_emit('editor.edited', {
|
||||
'id': id,
|
||||
'kind': 'insert',
|
||||
'inserted': text,
|
||||
'at': sel.start,
|
||||
'replaced': sel.length,
|
||||
'length': buf.content.length,
|
||||
'selection': buf.selection.toJson(),
|
||||
});
|
||||
_emitSelection(buf);
|
||||
}
|
||||
|
||||
/// Replace the current selection (or insert at cursor if no
|
||||
/// selection) with [text]. Same mechanic as [insert] — kept as a
|
||||
/// named verb because the CLI surface exposes it separately per
|
||||
/// CLAUDE.md's tier-2 list.
|
||||
void replaceSelection(String id, String text) => insert(id, text);
|
||||
|
||||
/// Update the UI's cursor / selection for [id]. Broadcasts so other
|
||||
/// subscribers can mirror it.
|
||||
void setSelection(String id, Selection sel) {
|
||||
final buf = _buffers[id];
|
||||
if (buf == null) return;
|
||||
final clamped = Selection(
|
||||
start: sel.start.clamp(0, buf.content.length),
|
||||
end: sel.end.clamp(0, buf.content.length),
|
||||
);
|
||||
if (clamped.start == buf.selection.start && clamped.end == buf.selection.end) {
|
||||
return;
|
||||
}
|
||||
buf.selection = clamped;
|
||||
_emitSelection(buf);
|
||||
}
|
||||
|
||||
/// Overwrite [id]'s content (used when the UI owns authoritative
|
||||
/// text — diff-style editor, paste, etc.) and reconcile the
|
||||
/// registry's view. Emits a single `editor.edited` event with
|
||||
/// kind='replace' so subscribers don't need to diff.
|
||||
void setContent(String id, String content, {Selection? selection}) {
|
||||
final buf = _buffers[id];
|
||||
if (buf == null) return;
|
||||
buf.content = content;
|
||||
if (selection != null) {
|
||||
buf.selection = Selection(
|
||||
start: selection.start.clamp(0, content.length),
|
||||
end: selection.end.clamp(0, content.length),
|
||||
);
|
||||
} else {
|
||||
buf.selection = Selection(
|
||||
start: buf.selection.start.clamp(0, content.length),
|
||||
end: buf.selection.end.clamp(0, content.length),
|
||||
);
|
||||
}
|
||||
buf.dirty = true;
|
||||
_emit('editor.edited', {
|
||||
'id': id,
|
||||
'kind': 'replace',
|
||||
'length': content.length,
|
||||
'selection': buf.selection.toJson(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Persist [id] to disk. Clears the dirty flag on success.
|
||||
Future<bool> save(String id) async {
|
||||
final buf = _buffers[id];
|
||||
if (buf == null) return false;
|
||||
final absolute = _absolutePathOf(buf.path);
|
||||
await File(absolute).writeAsString(buf.content);
|
||||
buf.dirty = false;
|
||||
_emit('editor.saved', {'id': id, 'path': buf.path});
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Close a buffer. Idempotent.
|
||||
void close(String id) {
|
||||
final buf = _buffers.remove(id);
|
||||
if (buf == null) return;
|
||||
_pathToId.remove(buf.path);
|
||||
if (_activeId == id) {
|
||||
_activeId = _buffers.values.isEmpty ? null : _buffers.values.first.id;
|
||||
if (_activeId != null) _emitActive();
|
||||
}
|
||||
_emit('editor.closed', {'id': id, 'path': buf.path});
|
||||
}
|
||||
|
||||
Future<void> shutdown() async {
|
||||
_buffers.clear();
|
||||
_pathToId.clear();
|
||||
_activeId = null;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
void _setActive(String id) {
|
||||
if (_activeId == id) return;
|
||||
_activeId = id;
|
||||
_emitActive();
|
||||
}
|
||||
|
||||
void _emitActive() {
|
||||
final buf = active;
|
||||
_emit('editor.active-changed', {
|
||||
'id': buf?.id,
|
||||
'path': buf?.path,
|
||||
});
|
||||
}
|
||||
|
||||
void _emitSelection(EditorBuffer buf) {
|
||||
_emit('editor.selection-changed', {
|
||||
'id': buf.id,
|
||||
'selection': buf.selection.toJson(),
|
||||
});
|
||||
}
|
||||
|
||||
void _emit(String kind, Map<String, Object?> data) {
|
||||
events.emit(IpcEvent(
|
||||
subsystem: 'editor',
|
||||
kind: kind,
|
||||
timestamp: DateTime.now().toUtc(),
|
||||
data: data,
|
||||
));
|
||||
}
|
||||
|
||||
String _absolutePathOf(String repoRelative) {
|
||||
if (repoRelative.startsWith('/')) return repoRelative;
|
||||
final sep = Platform.pathSeparator;
|
||||
return '${workspaceRoot.absolute.path}$sep${repoRelative.replaceAll('/', sep)}';
|
||||
}
|
||||
|
||||
// Support JSON decode of Selection from IPC args.
|
||||
static Selection selectionFromArgs(Object? raw) {
|
||||
if (raw is! Map) return const Selection.collapsed(0);
|
||||
return Selection.fromJson(raw.cast<String, Object?>());
|
||||
}
|
||||
|
||||
// Support JSON decode of content payloads (base64 for binary safety
|
||||
// or plain text).
|
||||
static String contentFromArgs(Map<String, Object?> args) {
|
||||
final text = args['text'];
|
||||
if (text is String) return text;
|
||||
final b64 = args['content_b64'];
|
||||
if (b64 is String) return utf8.decode(base64Decode(b64));
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,19 @@
|
||||
import 'dart:io';
|
||||
|
||||
/// Resolve the daemon unix-socket path.
|
||||
///
|
||||
/// Precedence (highest first):
|
||||
/// 1. `CLIDE_SOCKET_PATH` — explicit override. Used by tests that
|
||||
/// run multiple daemons in parallel and by power users who want
|
||||
/// their own layout.
|
||||
/// 2. `$XDG_RUNTIME_DIR/clide-<user>.sock` — Linux default; the
|
||||
/// per-user tmpfs lives exactly for this kind of short-lived
|
||||
/// socket and is auto-cleaned on logout.
|
||||
/// 3. `/tmp/clide-<user>.sock` — fallback for environments without
|
||||
/// `XDG_RUNTIME_DIR`.
|
||||
String defaultSocketPath() {
|
||||
final override = Platform.environment['CLIDE_SOCKET_PATH'];
|
||||
if (override != null && override.isNotEmpty) return override;
|
||||
final xdg = Platform.environment['XDG_RUNTIME_DIR'];
|
||||
final user = Platform.environment['USER'] ?? 'anon';
|
||||
final base = (xdg != null && xdg.isNotEmpty) ? xdg : '/tmp';
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
/// End-to-end tests for the tier-2 CLI shortcuts.
|
||||
///
|
||||
/// Each test launches `bin/clide --daemon` as a subprocess, exercises
|
||||
/// a shortcut (`open`, `active`, `insert`, `save`, `tail`), and
|
||||
/// asserts the JSON response shape. Requires a built `bin/clide`
|
||||
/// binary — `ci/test_core.sh` runs `make build` first when needed,
|
||||
/// or here we build on demand.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:test/test.dart';
|
||||
|
||||
const _socketEnv = 'CLIDE_SOCKET_PATH';
|
||||
|
||||
void main() {
|
||||
late Directory sandbox;
|
||||
late Process daemon;
|
||||
late String socketPath;
|
||||
late String clideBin;
|
||||
|
||||
setUpAll(() async {
|
||||
final candidate = File('bin/clide');
|
||||
if (!candidate.existsSync()) {
|
||||
final built = await Process.run('make', const ['build']);
|
||||
if (built.exitCode != 0) {
|
||||
throw StateError('make build failed: ${built.stderr}');
|
||||
}
|
||||
}
|
||||
clideBin = candidate.absolute.path;
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
sandbox = await Directory.systemTemp.createTemp('clide-cli-t2-');
|
||||
await File('${sandbox.path}/doc.md').writeAsString('alpha beta');
|
||||
// Each test gets its own socket path so concurrent test runs don't
|
||||
// collide. Passed through the daemon via env.
|
||||
socketPath = '${sandbox.path}/daemon.sock';
|
||||
daemon = await Process.start(
|
||||
clideBin,
|
||||
const ['--daemon'],
|
||||
workingDirectory: sandbox.path,
|
||||
environment: {
|
||||
...Platform.environment,
|
||||
_socketEnv: socketPath,
|
||||
},
|
||||
);
|
||||
// Wait for the "listening" line on stderr so we know it's ready.
|
||||
final ready = Completer<void>();
|
||||
daemon.stderr
|
||||
.transform(utf8.decoder)
|
||||
.transform(const LineSplitter())
|
||||
.listen((line) {
|
||||
if (!ready.isCompleted && line.contains('listening')) {
|
||||
ready.complete();
|
||||
}
|
||||
});
|
||||
await ready.future.timeout(const Duration(seconds: 5));
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
daemon.kill(ProcessSignal.sigterm);
|
||||
await daemon.exitCode.timeout(const Duration(seconds: 3),
|
||||
onTimeout: () {
|
||||
daemon.kill(ProcessSignal.sigkill);
|
||||
return -1;
|
||||
});
|
||||
if (sandbox.existsSync()) sandbox.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
Future<Map<String, Object?>> run(List<String> args) async {
|
||||
final r = await Process.run(
|
||||
clideBin,
|
||||
args,
|
||||
workingDirectory: sandbox.path,
|
||||
environment: {
|
||||
...Platform.environment,
|
||||
_socketEnv: socketPath,
|
||||
},
|
||||
);
|
||||
expect(r.exitCode, 0, reason: 'stderr: ${r.stderr}');
|
||||
return jsonDecode(r.stdout.toString().trim()) as Map<String, Object?>;
|
||||
}
|
||||
|
||||
test('clide open <path> returns buffer metadata', () async {
|
||||
final r = await run(['open', 'doc.md']);
|
||||
expect(r['id'], startsWith('b_'));
|
||||
expect(r['path'], 'doc.md');
|
||||
});
|
||||
|
||||
test('clide active reflects the most recent open', () async {
|
||||
await run(['open', 'doc.md']);
|
||||
final r = await run(['active']);
|
||||
final active = r['active']! as Map;
|
||||
expect(active['path'], 'doc.md');
|
||||
});
|
||||
|
||||
test('clide insert + clide active round-trip', () async {
|
||||
await run(['open', 'doc.md']);
|
||||
await run(['insert', 'hello ']);
|
||||
final r = await run(['active']);
|
||||
final active = r['active']! as Map;
|
||||
expect(active['dirty'], isTrue);
|
||||
expect((active['length'] as num).toInt(), greaterThan('alpha beta'.length));
|
||||
});
|
||||
|
||||
test('clide save clears dirty + writes to disk', () async {
|
||||
await run(['open', 'doc.md']);
|
||||
await run(['insert', 'X ']);
|
||||
await run(['save']);
|
||||
final active = (await run(['active']))['active']! as Map;
|
||||
expect(active['dirty'], isFalse);
|
||||
final disk = await File('${sandbox.path}/doc.md').readAsString();
|
||||
expect(disk.startsWith('X '), isTrue);
|
||||
});
|
||||
|
||||
test('clide tail --events streams editor.* events', () async {
|
||||
// Start a tail subscriber.
|
||||
final tail = await Process.start(
|
||||
clideBin,
|
||||
const ['tail', '--events', '--filter', 'editor'],
|
||||
workingDirectory: sandbox.path,
|
||||
environment: {
|
||||
...Platform.environment,
|
||||
_socketEnv: socketPath,
|
||||
},
|
||||
);
|
||||
|
||||
final received = <Map<String, Object?>>[];
|
||||
final sub = tail.stdout
|
||||
.transform(utf8.decoder)
|
||||
.transform(const LineSplitter())
|
||||
.listen((line) {
|
||||
if (line.isEmpty) return;
|
||||
received.add(jsonDecode(line) as Map<String, Object?>);
|
||||
});
|
||||
|
||||
// Give the subscriber a beat to connect.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||
|
||||
await run(['open', 'doc.md']);
|
||||
await run(['insert', 'T ']);
|
||||
|
||||
// Wait up to 2s for events.
|
||||
for (var i = 0; i < 20 && received.length < 3; i++) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
|
||||
tail.kill(ProcessSignal.sigint);
|
||||
await tail.exitCode.timeout(const Duration(seconds: 2),
|
||||
onTimeout: () {
|
||||
tail.kill(ProcessSignal.sigkill);
|
||||
return -1;
|
||||
});
|
||||
await sub.cancel();
|
||||
|
||||
final kinds = received.map((e) => e['kind']).toList();
|
||||
expect(kinds, containsAll(['editor.opened', 'editor.edited']));
|
||||
// Confirm filter actually filtered — no pane events made it in.
|
||||
for (final e in received) {
|
||||
expect(e['subsystem'], 'editor');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/src/daemon/editor_commands.dart';
|
||||
import 'package:clide/src/editor/registry.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
late Directory sandbox;
|
||||
late DaemonDispatcher dispatcher;
|
||||
late EditorRegistry reg;
|
||||
|
||||
setUp(() async {
|
||||
sandbox = await Directory.systemTemp.createTemp('clide-ed-cmd-test-');
|
||||
await File('${sandbox.path}/doc.md').writeAsString('alpha beta');
|
||||
final sink = RecordingEventSink();
|
||||
reg = EditorRegistry(events: sink, workspaceRoot: sandbox);
|
||||
dispatcher = DaemonDispatcher();
|
||||
registerEditorCommands(dispatcher, reg);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await reg.shutdown();
|
||||
if (sandbox.existsSync()) sandbox.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
Future<IpcResponse> call(String cmd, [Map<String, Object?> args = const {}]) {
|
||||
return dispatcher.dispatch(IpcRequest(id: '1', cmd: cmd, args: args));
|
||||
}
|
||||
|
||||
test('editor.open requires a path', () async {
|
||||
final r = await call('editor.open');
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error!.kind, 'user_error');
|
||||
});
|
||||
|
||||
test('editor.open + editor.active round-trip', () async {
|
||||
final open = await call('editor.open', {'path': 'doc.md'});
|
||||
expect(open.ok, isTrue);
|
||||
final id = open.data['id']! as String;
|
||||
expect(id, startsWith('b_'));
|
||||
|
||||
final active = await call('editor.active');
|
||||
expect(active.ok, isTrue);
|
||||
final act = active.data['active']! as Map;
|
||||
expect(act['id'], id);
|
||||
expect(act['path'], 'doc.md');
|
||||
});
|
||||
|
||||
test('editor.insert without id targets the active buffer', () async {
|
||||
await call('editor.open', {'path': 'doc.md'});
|
||||
final r = await call('editor.insert', {'text': 'X '});
|
||||
expect(r.ok, isTrue);
|
||||
expect(r.data['inserted'], 2);
|
||||
|
||||
final read = await call('editor.read');
|
||||
expect((read.data['content'] as String).startsWith('X '), isTrue);
|
||||
});
|
||||
|
||||
test('editor.replace-selection swaps selected range', () async {
|
||||
final open = await call('editor.open', {'path': 'doc.md'});
|
||||
final id = open.data['id'] as String?;
|
||||
|
||||
await call('editor.set-selection', {
|
||||
'id': id,
|
||||
'selection': {'start': 0, 'end': 5}, // 'alpha'
|
||||
});
|
||||
final r = await call('editor.replace-selection', {'text': 'gamma'});
|
||||
expect(r.ok, isTrue);
|
||||
|
||||
final read = await call('editor.read');
|
||||
expect(read.data['content'], 'gamma beta');
|
||||
});
|
||||
|
||||
test('editor.save persists to disk', () async {
|
||||
await call('editor.open', {'path': 'doc.md'});
|
||||
await call('editor.insert', {'text': 'Z '});
|
||||
final save = await call('editor.save');
|
||||
expect(save.ok, isTrue);
|
||||
final disk = await File('${sandbox.path}/doc.md').readAsString();
|
||||
expect(disk.startsWith('Z '), isTrue);
|
||||
});
|
||||
|
||||
test('editor.close removes the buffer', () async {
|
||||
final open = await call('editor.open', {'path': 'doc.md'});
|
||||
final id = open.data['id'] as String?;
|
||||
final r = await call('editor.close', {'id': id});
|
||||
expect(r.ok, isTrue);
|
||||
final list = await call('editor.list');
|
||||
expect((list.data['buffers'] as List), isEmpty);
|
||||
});
|
||||
|
||||
test('editor.list includes all open buffers', () async {
|
||||
await File('${sandbox.path}/a.md').writeAsString('a');
|
||||
await File('${sandbox.path}/b.md').writeAsString('b');
|
||||
await call('editor.open', {'path': 'a.md'});
|
||||
await call('editor.open', {'path': 'b.md'});
|
||||
final r = await call('editor.list');
|
||||
final names = [
|
||||
for (final b in (r.data['buffers'] as List).cast<Map>()) b['path'],
|
||||
];
|
||||
expect(names, containsAll(['a.md', 'b.md']));
|
||||
});
|
||||
|
||||
test('insert on unknown id returns not-found', () async {
|
||||
final r = await call('editor.insert', {'id': 'b_404', 'text': 'x'});
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error!.code, IpcExitCode.notFound);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/src/editor/registry.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
late Directory sandbox;
|
||||
late RecordingEventSink sink;
|
||||
late EditorRegistry reg;
|
||||
|
||||
setUp(() async {
|
||||
sandbox = await Directory.systemTemp.createTemp('clide-editor-test-');
|
||||
await File('${sandbox.path}/README.md').writeAsString('# Hello\n\nbody\n');
|
||||
sink = RecordingEventSink();
|
||||
reg = EditorRegistry(events: sink, workspaceRoot: sandbox);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await reg.shutdown();
|
||||
if (sandbox.existsSync()) sandbox.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
test('open loads file content + emits editor.opened + active-changed', () async {
|
||||
final buf = await reg.open('README.md');
|
||||
expect(buf.content, contains('Hello'));
|
||||
expect(buf.dirty, isFalse);
|
||||
expect(reg.active, same(buf));
|
||||
expect(sink.ofKind('editor.opened'), hasLength(1));
|
||||
expect(sink.ofKind('editor.active-changed'), hasLength(1));
|
||||
});
|
||||
|
||||
test('opening the same path returns the existing buffer', () async {
|
||||
final a = await reg.open('README.md');
|
||||
final b = await reg.open('README.md');
|
||||
expect(b.id, a.id);
|
||||
// Still only one open event — re-open is an activate, not a reload.
|
||||
expect(sink.ofKind('editor.opened'), hasLength(1));
|
||||
});
|
||||
|
||||
test('insert at caret appends + advances cursor', () async {
|
||||
final buf = await reg.open('README.md');
|
||||
// caret at 0
|
||||
reg.insert(buf.id, 'PREFIX ');
|
||||
expect(buf.content.startsWith('PREFIX '), isTrue);
|
||||
expect(buf.selection.isCollapsed, isTrue);
|
||||
expect(buf.selection.start, 'PREFIX '.length);
|
||||
expect(buf.dirty, isTrue);
|
||||
expect(sink.ofKind('editor.edited'), hasLength(1));
|
||||
});
|
||||
|
||||
test('replace-selection swaps selected text + resets cursor', () async {
|
||||
final buf = await reg.open('README.md');
|
||||
reg.setSelection(buf.id, const Selection(start: 2, end: 7)); // 'Hello'
|
||||
reg.replaceSelection(buf.id, 'WORLD');
|
||||
expect(buf.content.substring(2, 7), 'WORLD');
|
||||
expect(buf.selection, const Selection(start: 7, end: 7));
|
||||
});
|
||||
|
||||
test('set-selection clamps out-of-range offsets', () async {
|
||||
final buf = await reg.open('README.md');
|
||||
reg.setSelection(buf.id, const Selection(start: -5, end: 99999));
|
||||
expect(buf.selection.start, 0);
|
||||
expect(buf.selection.end, buf.content.length);
|
||||
});
|
||||
|
||||
test('save writes the content back + clears dirty', () async {
|
||||
final buf = await reg.open('README.md');
|
||||
reg.insert(buf.id, 'X');
|
||||
expect(buf.dirty, isTrue);
|
||||
final ok = await reg.save(buf.id);
|
||||
expect(ok, isTrue);
|
||||
expect(buf.dirty, isFalse);
|
||||
final onDisk = await File('${sandbox.path}/README.md').readAsString();
|
||||
expect(onDisk.startsWith('X'), isTrue);
|
||||
expect(sink.ofKind('editor.saved'), hasLength(1));
|
||||
});
|
||||
|
||||
test('close picks a new active buffer when the active one closes',
|
||||
() async {
|
||||
final a = await reg.open('README.md');
|
||||
await File('${sandbox.path}/b.txt').writeAsString('two');
|
||||
final b = await reg.open('b.txt');
|
||||
expect(reg.active, same(b));
|
||||
reg.close(b.id);
|
||||
expect(reg.active, same(a));
|
||||
expect(sink.ofKind('editor.closed'), hasLength(1));
|
||||
// Active changed at least twice: a→b (on open), b→a (after close)
|
||||
expect(sink.ofKind('editor.active-changed').length, greaterThanOrEqualTo(2));
|
||||
});
|
||||
|
||||
test('opening a non-existent path creates an empty buffer', () async {
|
||||
final buf = await reg.open('NEW.md');
|
||||
expect(buf.content, isEmpty);
|
||||
expect(buf.dirty, isFalse);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user