diff --git a/CHANGELOG.md b/CHANGELOG.md index cc0e4f14..37c920ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,12 +18,12 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added -- **The editor reads `.editorconfig`.** Opening a file resolves the - workspace's `.editorconfig` rules (own INI parser + glob matcher, directory - walk with `root = true` and nearest-wins precedence — no new dependency), and - saving applies `end_of_line`, `trim_trailing_whitespace`, and - `insert_final_newline`. The resolved indent/ruler settings ride along on the - buffer for the editor surface to honour. (T-29) +- **The editor honours `.editorconfig`.** Opening a file resolves the + workspace rules into a source-agnostic `EditorSettings` (own INI parser + + glob matcher, `root`/nearest-wins precedence — no new dependency). The editor + indents with Tab/Shift+Tab and draws a `max_line_length` ruler; saving applies + `end_of_line`, `trim_trailing_whitespace`, and `insert_final_newline`. Saving + the `.editorconfig` re-resolves open buffers live. (T-29) - **Permission-mode control beside the Claude composer.** An icon-only, per-mode-coloured button opens a menu of the safe modes (default · accept-edits · plan); `bypass` shows disabled. The status-bar mode is now a diff --git a/lib/builtin/editor/src/editor_controller.dart b/lib/builtin/editor/src/editor_controller.dart index c6ecbb81..733853df 100644 --- a/lib/builtin/editor/src/editor_controller.dart +++ b/lib/builtin/editor/src/editor_controller.dart @@ -14,6 +14,7 @@ import 'dart:async'; import 'package:clide/clide.dart'; import 'package:clide/kernel/kernel.dart'; +import 'package:clide/src/editor/editor_settings.dart'; import 'package:flutter/foundation.dart'; /// Lightweight view of one open buffer for the tab strip — the @@ -36,6 +37,7 @@ class EditorController extends ChangeNotifier { Selection _selection = const Selection.collapsed(0); bool _dirty = false; String? _error; + EditorSettings _settings = EditorSettings.empty; /// All open buffers, in daemon order, for the tab strip. List _buffers = const []; @@ -51,6 +53,9 @@ class EditorController extends ChangeNotifier { String? get error => _error; List get buffers => _buffers; + /// Effective editor settings for the active buffer (T-29). + EditorSettings get settings => _settings; + /// On first mount we don't know what's already open. Ask the daemon /// for the buffer list and the active buffer. Future hydrate() async { @@ -128,6 +133,7 @@ class EditorController extends ChangeNotifier { } _activeId = r.data['id']! as String; _activePath = r.data['path']! as String; + _settings = EditorSettings.fromJson(r.data['editorSettings']); _content = (r.data['content'] as String?) ?? ''; final sel = r.data['selection']; _selection = sel is Map ? Selection.fromJson(sel.cast()) : const Selection.collapsed(0); @@ -210,6 +216,13 @@ class EditorController extends ChangeNotifier { _dirty = false; notifyListeners(); } + case 'editor.settings-changed': + // A source (e.g. a saved .editorconfig) re-resolved the buffer's + // settings. Refresh the active buffer's copy so indent/ruler update. + if (e.data['id'] == _activeId) { + _settings = EditorSettings.fromJson(e.data['editorSettings']); + notifyListeners(); + } case 'editor.closed': // A buffer left the set — refresh the tab list. If it was the // active one the daemon promotes another and emits @@ -227,6 +240,7 @@ class EditorController extends ChangeNotifier { _content = ''; _selection = const Selection.collapsed(0); _dirty = false; + _settings = EditorSettings.empty; notifyListeners(); } diff --git a/lib/builtin/editor/src/editor_view.dart b/lib/builtin/editor/src/editor_view.dart index 850e04b0..0c4ce7c0 100644 --- a/lib/builtin/editor/src/editor_view.dart +++ b/lib/builtin/editor/src/editor_view.dart @@ -183,6 +183,17 @@ class _EditorViewState extends State { return KeyEventResult.handled; } + // Tab indents per `.editorconfig` (T-29) — but only when the config has an + // opinion, otherwise leave Flutter's default (focus traversal) alone. Skip + // in Vim command mode, where keys drive motions. + if (event.logicalKey == LogicalKeyboardKey.tab && !isCmd && !hw.isAltPressed && !_vimCommandMode) { + final unit = _controller?.settings.indentUnit; + if (unit != null) { + _indent(unit, dedent: hw.isShiftPressed); + return KeyEventResult.handled; + } + } + final kernel = ClideKernel.of(context); final scope = kernel.keymap.scope; final inNormal = scope['vim.normal'] == true; @@ -221,6 +232,45 @@ class _EditorViewState extends State { } } + /// Apply one indent step at the caret per the resolved settings (T-29). + /// [unit] is the text a Tab inserts (spaces or a tab); [dedent] (Shift+Tab) + /// instead strips up to one unit of leading whitespace from the caret's line. + /// Writes through [_text] so `_onTextChanged` persists it to the daemon. + void _indent(String unit, {required bool dedent}) { + final value = _text.value; + final sel = value.selection; + if (!sel.isValid) return; + final text = value.text; + + if (!dedent) { + final newText = text.replaceRange(sel.start, sel.end, unit); + _text.value = TextEditingValue( + text: newText, + selection: TextSelection.collapsed(offset: sel.start + unit.length), + ); + return; + } + + // Dedent: remove leading whitespace from the start of the caret's line. + final caret = sel.baseOffset < 0 ? text.length : sel.baseOffset; + final lineStart = text.lastIndexOf('\n', caret - 1) + 1; // 0 when on the first line + var remove = 0; + if (unit == '\t') { + if (lineStart < text.length && text[lineStart] == '\t') remove = 1; + } else { + while (remove < unit.length && lineStart + remove < text.length && text[lineStart + remove] == ' ') { + remove++; + } + } + if (remove == 0) return; + final newText = text.replaceRange(lineStart, lineStart + remove, ''); + int shift(int off) => off > lineStart ? (off - remove).clamp(lineStart, newText.length) : off; + _text.value = TextEditingValue( + text: newText, + selection: TextSelection(baseOffset: shift(sel.baseOffset), extentOffset: shift(sel.extentOffset)), + ); + } + void _dispatchVim(Intent intent, int count, KernelServices kernel, {required bool visual}) { if (intent is! InvokeCommandIntent) return; final id = intent.commandId; @@ -274,6 +324,7 @@ class _EditorViewState extends State { background: tokens.panelBackground, foreground: tokens.globalForeground, accent: tokens.globalFocus, + rulerColumn: c.settings.maxLineLength, ), ), ); @@ -290,6 +341,7 @@ class _TextBody extends StatelessWidget { required this.background, required this.foreground, required this.accent, + this.rulerColumn, }); final TextEditingController controller; @@ -299,8 +351,32 @@ class _TextBody extends StatelessWidget { final Color foreground; final Color accent; + /// `max_line_length` from the resolved settings — draws a wrap-guide ruler at + /// that column (T-29). Null hides it. + final int? rulerColumn; + @override Widget build(BuildContext context) { + final style = TextStyle( + color: foreground, + fontSize: clideFontMono, + fontFamily: clideMonoFamily, + fontFamilyFallback: clideMonoFamilyFallback, + ); + final editable = EditableText( + controller: controller, + focusNode: focus, + readOnly: readOnly, + style: style, + cursorColor: foreground, + backgroundCursorColor: foreground.withAlpha(0x44), + selectionColor: accent.withAlpha(0x55), + maxLines: null, + expands: true, + keyboardType: TextInputType.multiline, + textAlign: TextAlign.start, + showCursor: true, + ); return Semantics( label: 'editor text area', textField: true, @@ -309,27 +385,41 @@ class _TextBody extends StatelessWidget { color: background, child: Padding( padding: const EdgeInsets.all(8), - child: EditableText( - controller: controller, - focusNode: focus, - readOnly: readOnly, - 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, + // CustomPaint sizes to and paints behind the EditableText, so the + // ruler shares the text's coordinate space (column 0 at the left edge). + child: CustomPaint( + painter: rulerColumn == null ? null : _RulerPainter(x: _charWidth(style) * rulerColumn!, color: foreground.withAlpha(0x22)), + child: editable, ), ), ), ); } + + /// Advance width of one monospace glyph in [style]. + static double _charWidth(TextStyle style) { + final tp = TextPainter(text: TextSpan(text: '0', style: style), textDirection: TextDirection.ltr)..layout(); + return tp.width; + } +} + +/// A 1px vertical wrap-guide at [x] (content-relative), behind the text. +class _RulerPainter extends CustomPainter { + const _RulerPainter({required this.x, required this.color}); + + final double x; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + canvas.drawLine( + Offset(x, 0), + Offset(x, size.height), + Paint() + ..color = color + ..strokeWidth = 1); + } + + @override + bool shouldRepaint(_RulerPainter old) => old.x != x || old.color != color; } diff --git a/test/builtin/editor/editor_controller_test.dart b/test/builtin/editor/editor_controller_test.dart index ecfaddfe..3bed4ab5 100644 --- a/test/builtin/editor/editor_controller_test.dart +++ b/test/builtin/editor/editor_controller_test.dart @@ -429,4 +429,56 @@ void main() { expect(c.content, 'local'); }); }); + + group('editor settings (T-29)', () { + Future hydrateWith(Map read) async { + ipc.stub( + 'editor.list', + (_) async => _ok({ + 'buffers': [_buf('b_1', 'lib/a.dart')] + })); + ipc.stub( + 'editor.active', + (_) async => _ok({ + 'active': {'id': 'b_1'} + })); + ipc.stub('editor.read', (_) async => _ok(read)); + await c.hydrate(); + } + + test('reading a buffer parses its editorSettings', () async { + await hydrateWith({ + ..._read('b_1', 'lib/a.dart', 'x'), + 'editorSettings': {'indent_style': 'space', 'indent_size': 2, 'max_line_length': 80}, + }); + expect(c.settings.indentUnit, ' '); + expect(c.settings.maxLineLength, 80); + }); + + test('a buffer with no settings exposes empty (no opinion)', () async { + await hydrateWith(_read('b_1', 'lib/a.dart', 'x')); + expect(c.settings.isEmpty, isTrue); + expect(c.settings.indentUnit, isNull); + }); + + test('editor.settings-changed refreshes the active buffer live', () async { + await hydrateWith(_read('b_1', 'lib/a.dart', 'x')); + expect(c.settings.indentSize, isNull); + + emitEditor(bus, 'editor.settings-changed', { + 'id': 'b_1', + 'editorSettings': {'indent_size': 4}, + }); + await pumpEventQueue(); + expect(c.settings.indentSize, 4); + + // An event for some other buffer doesn't touch the active settings. + emitEditor(bus, 'editor.settings-changed', { + 'id': 'b_other', + 'editorSettings': {'indent_size': 8}, + }); + await pumpEventQueue(); + expect(c.settings.indentSize, 4); + }); + }); } diff --git a/test/builtin/editor/editor_view_test.dart b/test/builtin/editor/editor_view_test.dart index cecf6ac7..b1c8f717 100644 --- a/test/builtin/editor/editor_view_test.dart +++ b/test/builtin/editor/editor_view_test.dart @@ -20,14 +20,17 @@ IpcResponse _ok(Map data) => IpcResponse.ok(id: '', data: data) Map _buf(String id, String path, {bool dirty = false}) => {'id': id, 'path': path, 'dirty': dirty}; -Map _read(String id, String path) => { +Map _read(String id, String path, {Map? settings}) => { 'id': id, 'path': path, 'content': 'content of $path', 'selection': {'start': 0, 'end': 0}, 'dirty': false, + if (settings != null) 'editorSettings': settings, }; +Finder _ruler() => find.byWidgetPredicate((w) => w is CustomPaint && w.painter?.runtimeType.toString() == '_RulerPainter'); + void main() { group('EditorView tabs', () { late KernelFixture f; @@ -141,5 +144,104 @@ void main() { expect(saved, 'b_1'); }); + + void stubOne(String path, {Map? settings}) { + f.ipc.stub( + 'editor.list', + (_) async => _ok({ + 'buffers': [_buf('b_1', path)] + })); + f.ipc.stub( + 'editor.active', + (_) async => _ok({ + 'active': {'id': 'b_1'} + })); + f.ipc.stub('editor.read', (_) async => _ok(_read('b_1', path, settings: settings))); + } + + testWidgets('Tab indents per the resolved settings (spaces)', (tester) async { + stubOne('lib/a.dart', settings: {'indent_style': 'space', 'indent_size': 2}); + f.ipc.stub('editor.set-content', (_) async => _ok(const {})); + await tester.pumpWidget(harness(f, const EditorView())); + await tester.pumpAndSettle(); + + final before = tester.widget(find.byType(EditableText)).controller.text; + await tester.tap(find.byType(EditableText)); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + + final after = tester.widget(find.byType(EditableText)).controller.text; + expect(after.length, before.length + 2); // two spaces inserted + expect(after, contains(' ')); // adjacent pair our indent added + }); + + void stubReadContent(String path, String content, Map settings) { + f.ipc.stub( + 'editor.list', + (_) async => _ok({ + 'buffers': [_buf('b_1', path)] + })); + f.ipc.stub( + 'editor.active', + (_) async => _ok({ + 'active': {'id': 'b_1'} + })); + f.ipc.stub( + 'editor.read', + (_) async => _ok({ + 'id': 'b_1', + 'path': path, + 'content': content, + 'selection': {'start': 0, 'end': 0}, + 'dirty': false, + 'editorSettings': settings + })); + f.ipc.stub('editor.set-content', (_) async => _ok(const {})); + } + + testWidgets('Shift+Tab dedents a space-indented line', (tester) async { + stubReadContent('lib/a.dart', ' x', {'indent_style': 'space', 'indent_size': 2}); + await tester.pumpWidget(harness(f, const EditorView())); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(EditableText)); + await tester.pump(); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.pumpAndSettle(); + + expect(tester.widget(find.byType(EditableText)).controller.text, ' x'); // two spaces stripped + }); + + testWidgets('Shift+Tab dedents one leading tab', (tester) async { + stubReadContent('lib/a.dart', '\t\tx', {'indent_style': 'tab', 'tab_width': 4}); + await tester.pumpWidget(harness(f, const EditorView())); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(EditableText)); + await tester.pump(); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.pumpAndSettle(); + + expect(tester.widget(find.byType(EditableText)).controller.text, '\tx'); // one tab stripped + }); + + testWidgets('a max_line_length renders the wrap-guide ruler', (tester) async { + stubOne('lib/a.dart', settings: {'max_line_length': 80}); + await tester.pumpWidget(harness(f, const EditorView())); + await tester.pumpAndSettle(); + expect(_ruler(), findsOneWidget); + }); + + testWidgets('no max_line_length draws no ruler', (tester) async { + stubOne('lib/a.dart'); + await tester.pumpWidget(harness(f, const EditorView())); + await tester.pumpAndSettle(); + expect(_ruler(), findsNothing); + }); }); }