The editor pane showed a single buffer — opening a second file replaced the first, even though the daemon's EditorRegistry has always been multi-buffer (editor.list / activate / close). This wires the UI up to that: EditorController now tracks the full open-buffer list (via editor.list on hydrate, kept in sync by editor.opened / closed / saved / edited events), and EditorView renders the buffers as tabs through the shared MultitabPane — the same strip the Claude pane uses. The daemon stays the source of truth: the local tab controller is reconciled from it, and tab select / close route back as editor.activate / editor.close. Reorder is disabled for now (daemon order is authoritative). Co-Authored-By: Claude <noreply@anthropic.com>
244 lines
7.8 KiB
Dart
244 lines
7.8 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:clide/clide.dart';
|
|
import 'package:clide/kernel/kernel.dart';
|
|
import 'package:clide/kernel/src/syntax/tree_sitter_service.dart';
|
|
import 'package:clide/widgets/widgets.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter/widgets.dart';
|
|
|
|
import 'editor_controller.dart';
|
|
import 'syntax_text_controller.dart';
|
|
|
|
/// Tier-2 editor pane. Shows one tab per open buffer via the shared
|
|
/// [MultitabPane] (the same strip the Claude pane uses); the body
|
|
/// reflects the daemon's active buffer. The daemon ([EditorRegistry])
|
|
/// is the source of truth for which buffers are open and which is
|
|
/// active — the local [MultitabController] is reconciled from it, and
|
|
/// tab gestures (select / close) are routed back as `editor.activate`
|
|
/// / `editor.close`.
|
|
///
|
|
/// Uses Flutter's low-level `EditableText` so we stay off Material
|
|
/// per D-007. Owning more of the editor stack (line numbers, gutter,
|
|
/// syntax highlighting) lands in later tiers; Tier 2 is plain text.
|
|
class EditorView extends StatefulWidget {
|
|
const EditorView({super.key});
|
|
|
|
@override
|
|
State<EditorView> createState() => _EditorViewState();
|
|
}
|
|
|
|
class _EditorViewState extends State<EditorView> {
|
|
EditorController? _controller;
|
|
final MultitabController<String> _tabs = MultitabController<String>();
|
|
final TreeSitterService _syntax = TreeSitterService.shared;
|
|
late final SyntaxTextController _text;
|
|
late final FocusNode _focus;
|
|
String? _lastRemoteContent;
|
|
|
|
/// Guards the controller→tabstrip reconcile so the tabstrip's own
|
|
/// change notifications (from us mutating it) don't bounce back as
|
|
/// daemon calls.
|
|
bool _applyingRemote = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_text = SyntaxTextController(syntax: _syntax);
|
|
_focus = FocusNode();
|
|
_text.addListener(_onTextChanged);
|
|
_tabs.addListener(_onTabsChanged);
|
|
}
|
|
|
|
@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();
|
|
_tabs.removeListener(_onTabsChanged);
|
|
_tabs.dispose();
|
|
_controller?.removeListener(_onControllerChanged);
|
|
_controller?.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _onControllerChanged() {
|
|
final c = _controller!;
|
|
_syncTabs(c);
|
|
_text.updatePath(c.activePath);
|
|
if (c.content != _lastRemoteContent) {
|
|
_lastRemoteContent = c.content;
|
|
final sel = TextSelection(
|
|
baseOffset: c.selection.start.clamp(0, c.content.length),
|
|
extentOffset: c.selection.end.clamp(0, c.content.length),
|
|
);
|
|
_text.removeListener(_onTextChanged);
|
|
_text.value = TextEditingValue(text: c.content, selection: sel);
|
|
_text.addListener(_onTextChanged);
|
|
}
|
|
setState(() {}); // tab/title refresh
|
|
}
|
|
|
|
/// Reconcile the local tab strip to match the daemon's open-buffer
|
|
/// list + active selection. Membership and order follow the daemon;
|
|
/// titles carry a dirty marker.
|
|
void _syncTabs(EditorController c) {
|
|
_applyingRemote = true;
|
|
final bufs = c.buffers;
|
|
final liveIds = {for (final b in bufs) b.id};
|
|
for (final e in _tabs.entries) {
|
|
if (!liveIds.contains(e.id)) _tabs.remove(e.id);
|
|
}
|
|
for (final b in bufs) {
|
|
final title = _tabTitle(b);
|
|
final existing = _tabs.entries.where((e) => e.id == b.id).toList();
|
|
if (existing.isEmpty) {
|
|
_tabs.add(MultitabEntry<String>(id: b.id, title: title, payload: b.id), activate: false);
|
|
} else if (existing.first.title != title) {
|
|
_tabs.replace(b.id, MultitabEntry<String>(id: b.id, title: title, payload: b.id));
|
|
}
|
|
}
|
|
final act = c.activeId;
|
|
if (act != null && _tabs.activeId != act) _tabs.activate(act);
|
|
_applyingRemote = false;
|
|
}
|
|
|
|
/// User tapped a tab. The tab strip already updated its local active
|
|
/// selection; mirror that choice to the daemon.
|
|
void _onTabsChanged() {
|
|
if (_applyingRemote) return;
|
|
final id = _tabs.activeId;
|
|
if (id != null && id != _controller?.activeId) {
|
|
unawaited(_controller?.activate(id) ?? Future.value());
|
|
}
|
|
}
|
|
|
|
String _tabTitle(OpenBuffer b) {
|
|
final name = b.path.split('/').last;
|
|
return b.dirty ? '$name •' : name;
|
|
}
|
|
|
|
void _onTextChanged() {
|
|
final c = _controller;
|
|
if (c == null || c.activeId == null) return;
|
|
final value = _text.value;
|
|
if (value.text == c.content && value.selection.baseOffset == c.selection.start && value.selection.extentOffset == c.selection.end) {
|
|
return;
|
|
}
|
|
_lastRemoteContent = value.text;
|
|
c.pushLocalEdit(
|
|
newContent: value.text,
|
|
newSelection: Selection(
|
|
start: value.selection.start < 0 ? value.text.length : value.selection.start,
|
|
end: value.selection.end < 0 ? value.text.length : value.selection.end,
|
|
),
|
|
);
|
|
}
|
|
|
|
KeyEventResult _onKey(FocusNode node, KeyEvent event) {
|
|
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
|
final isCmd = HardwareKeyboard.instance.isMetaPressed || HardwareKeyboard.instance.isControlPressed;
|
|
if (isCmd && event.logicalKey == LogicalKeyboardKey.keyS) {
|
|
unawaited(_controller?.save());
|
|
return KeyEventResult.handled;
|
|
}
|
|
return KeyEventResult.ignored;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = _controller;
|
|
final tokens = ClideTheme.of(context).surface;
|
|
_text.tokens = tokens;
|
|
if (c == null) return const SizedBox.shrink();
|
|
|
|
return ListenableBuilder(
|
|
listenable: c,
|
|
builder: (context, _) {
|
|
if (c.buffers.isEmpty) {
|
|
return ClidePaneChrome(
|
|
title: 'editor',
|
|
subtitle: 'no buffer · use `clide open <path>` or pick a file in the tree',
|
|
child: const Center(
|
|
child: ClideText('Open a file to begin editing.', muted: true),
|
|
),
|
|
);
|
|
}
|
|
return MultitabPane<String>(
|
|
controller: _tabs,
|
|
allowReorder: false,
|
|
onCloseRequested: (entry) => unawaited(c.closeBuffer(entry.id)),
|
|
bodyBuilder: (context, _) => Focus(
|
|
onKeyEvent: _onKey,
|
|
child: _TextBody(
|
|
controller: _text,
|
|
focus: _focus,
|
|
background: tokens.panelBackground,
|
|
foreground: tokens.globalForeground,
|
|
accent: tokens.globalFocus,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TextBody extends StatelessWidget {
|
|
const _TextBody({
|
|
required this.controller,
|
|
required this.focus,
|
|
required this.background,
|
|
required this.foreground,
|
|
required this.accent,
|
|
});
|
|
|
|
final TextEditingController controller;
|
|
final FocusNode focus;
|
|
final Color background;
|
|
final Color foreground;
|
|
final Color accent;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Semantics(
|
|
label: 'editor text area',
|
|
textField: true,
|
|
multiline: true,
|
|
child: ColoredBox(
|
|
color: background,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(8),
|
|
child: EditableText(
|
|
controller: controller,
|
|
focusNode: focus,
|
|
style: TextStyle(
|
|
color: foreground,
|
|
fontSize: clideFontMono,
|
|
fontFamily: clideMonoFamily,
|
|
fontFamilyFallback: clideMonoFamilyFallback,
|
|
),
|
|
cursorColor: foreground,
|
|
backgroundCursorColor: foreground.withAlpha(0x44),
|
|
selectionColor: accent.withAlpha(0x55),
|
|
maxLines: null,
|
|
expands: true,
|
|
keyboardType: TextInputType.multiline,
|
|
textAlign: TextAlign.start,
|
|
showCursor: true,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|