diff --git a/lib/core/editor/querya_code_editor.dart b/lib/core/editor/querya_code_editor.dart index 0d0741e6..d696fdd9 100644 --- a/lib/core/editor/querya_code_editor.dart +++ b/lib/core/editor/querya_code_editor.dart @@ -63,6 +63,7 @@ class _QueryaCodeEditorState extends State { material.TextEditingController? _internalController; QueryaEditorTheme? _lastEditorTheme; int _lastTokenColorsHash = 0; + String? _lastText; material.TextEditingController get _effectiveController => widget.controller ?? _internalController!; @@ -76,11 +77,24 @@ class _QueryaCodeEditorState extends State { void _initControllerIfNeeded() { if (widget.controller == null) { - _internalController = material.TextEditingController(); + _internalController = _createInternalController(); } } + material.TextEditingController _createInternalController({ + String? text, + }) { + if (!widget.enableHighlighting) { + return material.TextEditingController(text: text); + } + return SyntaxHighlightService.createHighlightController( + language: widget.language, + text: text, + ); + } + void _attachListener() { + _lastText = _effectiveController.text; _effectiveController.addListener(_onTextChanged); } @@ -89,7 +103,10 @@ class _QueryaCodeEditorState extends State { } void _onTextChanged() { - widget.onChanged?.call(_effectiveController.text); + final currentText = _effectiveController.text; + if (currentText == _lastText) return; + _lastText = currentText; + widget.onChanged?.call(currentText); } void _syncThemeIfNeeded( @@ -139,13 +156,18 @@ class _QueryaCodeEditorState extends State { @override void didUpdateWidget(QueryaCodeEditor oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.controller != widget.controller) { + final controllerChanged = oldWidget.controller != widget.controller; + final languageChanged = oldWidget.language != widget.language; + final highlightingChanged = + oldWidget.enableHighlighting != widget.enableHighlighting; + + if (controllerChanged) { _detachListener(oldWidget.controller ?? _internalController); if (oldWidget.controller == null && widget.controller != null) { _internalController?.dispose(); _internalController = null; } else if (widget.controller == null && _internalController == null) { - _internalController = material.TextEditingController(); + _internalController = _createInternalController(); } _lastEditorTheme = null; _lastTokenColorsHash = 0; @@ -154,6 +176,23 @@ class _QueryaCodeEditorState extends State { context.queryaTheme, transitioning: QueryaThemeScope.isTransitioning(context), ); + } else if (widget.controller == null && + (languageChanged || highlightingChanged)) { + final oldController = _internalController!; + final currentText = oldController.text; + final currentSelection = oldController.selection; + _detachListener(oldController); + oldController.dispose(); + _internalController = _createInternalController( + text: currentText, + )..selection = currentSelection; + _lastEditorTheme = null; + _lastTokenColorsHash = 0; + _attachListener(); + _syncThemeIfNeeded( + context.queryaTheme, + transitioning: QueryaThemeScope.isTransitioning(context), + ); } } @@ -209,7 +248,6 @@ class _QueryaCodeEditorState extends State { contentPadding: widget.contentPadding ?? const material.EdgeInsets.all(12), ), - onChanged: widget.onChanged, ); } @@ -222,7 +260,6 @@ class _QueryaCodeEditorState extends State { expands: widget.expands, style: style, placeholder: placeholder, - onChanged: widget.onChanged, ); } } diff --git a/test/core/editor/querya_code_editor_test.dart b/test/core/editor/querya_code_editor_test.dart index 5102cd5f..35000d81 100644 --- a/test/core/editor/querya_code_editor_test.dart +++ b/test/core/editor/querya_code_editor_test.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/editor/querya_code_editor.dart'; import 'package:querya_desktop/core/editor/querya_code_language.dart'; +import 'package:querya_desktop/core/editor/querya_highlight_controller.dart'; import 'package:querya_desktop/core/editor/syntax_highlight_service.dart'; import 'package:querya_desktop/core/theme/querya_theme.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -170,5 +171,145 @@ void main() { await tester.pump(); expect(changeCount, 1); }); + + testWidgets('onChanged fires exactly once per text edit when typing', + (tester) async { + int count = 0; + await tester.pumpWidget( + queryaThemeTestShell( + child: material.SizedBox( + width: 400, + height: 200, + child: QueryaCodeEditor( + language: QueryaCodeLanguage.plain, + onChanged: (_) => count++, + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(material.EditableText), 'abc'); + expect(count, 1); + }); + + testWidgets('cursor movement and selection changes do not trigger onChanged', + (tester) async { + int count = 0; + final controller = material.TextEditingController(text: 'initial text'); + await tester.pumpWidget( + queryaThemeTestShell( + child: material.SizedBox( + width: 400, + height: 200, + child: QueryaCodeEditor( + controller: controller, + language: QueryaCodeLanguage.plain, + onChanged: (_) => count++, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Change selection without changing text + controller.selection = const material.TextSelection( + baseOffset: 0, + extentOffset: 5, + ); + await tester.pump(); + + expect(count, 0); + + // Tap the field to move cursor + await tester.tap(find.byType(material.EditableText)); + await tester.pump(); + + expect(count, 0); + }); + + testWidgets( + 'QueryaCodeEditor creates highlight controller by default for SQL', + (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const material.SizedBox( + width: 400, + height: 200, + child: QueryaCodeEditor( + language: QueryaCodeLanguage.sql, + enableHighlighting: true, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final editable = tester.widget( + find.byType(material.EditableText), + ); + expect(editable.controller, isA()); + }); + + testWidgets( + 'QueryaCodeEditor creates plain controller when enableHighlighting is false', + (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const material.SizedBox( + width: 400, + height: 200, + child: QueryaCodeEditor( + language: QueryaCodeLanguage.sql, + enableHighlighting: false, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final editable = tester.widget( + find.byType(material.EditableText), + ); + expect(editable.controller, isNot(isA())); + }); + + testWidgets( + 'updating language updates internal controller and preserves text', + (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const material.SizedBox( + width: 400, + height: 200, + child: QueryaCodeEditor( + language: QueryaCodeLanguage.plain, + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(material.EditableText), 'SELECT 1;'); + await tester.pump(); + + // Rebuild with SQL language + await tester.pumpWidget( + queryaThemeTestShell( + child: const material.SizedBox( + width: 400, + height: 200, + child: QueryaCodeEditor( + language: QueryaCodeLanguage.sql, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final editable = tester.widget( + find.byType(material.EditableText), + ); + expect(editable.controller, isA()); + expect(editable.controller.text, 'SELECT 1;'); + }); }