Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 43 additions & 6 deletions lib/core/editor/querya_code_editor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ class _QueryaCodeEditorState extends State<QueryaCodeEditor> {
material.TextEditingController? _internalController;
QueryaEditorTheme? _lastEditorTheme;
int _lastTokenColorsHash = 0;
String? _lastText;

material.TextEditingController get _effectiveController =>
widget.controller ?? _internalController!;
Expand All @@ -76,11 +77,24 @@ class _QueryaCodeEditorState extends State<QueryaCodeEditor> {

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);
}

Expand All @@ -89,7 +103,10 @@ class _QueryaCodeEditorState extends State<QueryaCodeEditor> {
}

void _onTextChanged() {
widget.onChanged?.call(_effectiveController.text);
final currentText = _effectiveController.text;
if (currentText == _lastText) return;
_lastText = currentText;
widget.onChanged?.call(currentText);
}

void _syncThemeIfNeeded(
Expand Down Expand Up @@ -139,13 +156,18 @@ class _QueryaCodeEditorState extends State<QueryaCodeEditor> {
@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;
Expand All @@ -154,6 +176,23 @@ class _QueryaCodeEditorState extends State<QueryaCodeEditor> {
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),
);
}
}

Expand Down Expand Up @@ -209,7 +248,6 @@ class _QueryaCodeEditorState extends State<QueryaCodeEditor> {
contentPadding:
widget.contentPadding ?? const material.EdgeInsets.all(12),
),
onChanged: widget.onChanged,
);
}

Expand All @@ -222,7 +260,6 @@ class _QueryaCodeEditorState extends State<QueryaCodeEditor> {
expands: widget.expands,
style: style,
placeholder: placeholder,
onChanged: widget.onChanged,
);
}
}
141 changes: 141 additions & 0 deletions test/core/editor/querya_code_editor_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<material.EditableText>(
find.byType(material.EditableText),
);
expect(editable.controller, isA<QueryaHighlightController>());
});

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<material.EditableText>(
find.byType(material.EditableText),
);
expect(editable.controller, isNot(isA<QueryaHighlightController>()));
});

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<material.EditableText>(
find.byType(material.EditableText),
);
expect(editable.controller, isA<QueryaHighlightController>());
expect(editable.controller.text, 'SELECT 1;');
});
}

Loading