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
21 changes: 21 additions & 0 deletions lib/core/editor/querya_highlight_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,14 @@ class QueryaHighlightController extends TextEditingController {
this.darkThemeConfig = darkThemeConfig;
this.wrapperColor = wrapperColor;
_cachedSpan = null;
_cachedSegments = null;
_cachedText = null;
_cachedBrightness = null;
notifyListeners();
}

TextSpan? _cachedSpan;
List<HighlightSegment>? _cachedSegments;
String? _cachedText;
Brightness? _cachedBrightness;
int _highlightGeneration = 0;
Expand Down Expand Up @@ -77,6 +79,7 @@ class QueryaHighlightController extends TextEditingController {
try {
final span = highlighter.highlight(text);
_cachedSpan = span;
_cachedSegments = flattenSpan(span);
_cachedText = text;
_cachedBrightness = brightness;
_debounceTimer?.cancel();
Expand All @@ -97,6 +100,23 @@ class QueryaHighlightController extends TextEditingController {
return TextSpan(style: style, children: [_cachedSpan!]);
}

// Preserve syntax colors during active typing in documents >= 8KB instead
// of dropping to unhighlighted monochrome plain text while the background
// isolate highlighting is in flight (#1351).
if (_cachedSegments != null &&
_cachedText != null &&
_cachedBrightness == brightness) {
final patched = patchHighlightSegments(
oldText: _cachedText!,
oldSegments: _cachedSegments!,
newText: text,
);
_cachedSegments = patched;
_cachedText = text;
_cachedSpan = segmentsToTextSpan(patched, baseStyle: style);
return TextSpan(style: style, children: [_cachedSpan!]);
}

return TextSpan(style: style, text: text);
}

Expand Down Expand Up @@ -140,6 +160,7 @@ class QueryaHighlightController extends TextEditingController {
),
).then((segments) {
if (generation != _highlightGeneration) return;
_cachedSegments = segments;
_cachedSpan = segmentsToTextSpan(segments, baseStyle: style);
_cachedText = text;
_cachedBrightness = brightness;
Expand Down
112 changes: 110 additions & 2 deletions lib/core/editor/syntax_highlight_isolate.dart
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,11 @@ List<HighlightSegment> syntaxHighlightInIsolate(SyntaxHighlightJob job) {
);
final highlighter = Highlighter(language: job.language, theme: theme);
final span = highlighter.highlight(job.code);
return _flattenSpan(span);
return flattenSpan(span);
}

List<HighlightSegment> _flattenSpan(TextSpan span) {
/// Flattens a nested [TextSpan] into a linear list of [HighlightSegment]s.
List<HighlightSegment> flattenSpan(TextSpan span) {
final out = <HighlightSegment>[];
void walk(TextSpan node) {
final style = node.style;
Expand All @@ -80,6 +81,113 @@ List<HighlightSegment> _flattenSpan(TextSpan span) {
return out;
}

/// Patches cached [oldSegments] to match [newText] while preserving formatting
/// and syntax colors for untouched regions. Used during interactive typing in
/// large documents (>= 8KB) while background isolate highlighting is in flight (#1351).
List<HighlightSegment> patchHighlightSegments({
required String oldText,
required List<HighlightSegment> oldSegments,
required String newText,
}) {
if (oldText == newText) return oldSegments;
if (oldSegments.isEmpty) {
return [HighlightSegment(text: newText)];
}
if (newText.isEmpty) {
return const [];
}

var prefixLen = 0;
final minLen =
oldText.length < newText.length ? oldText.length : newText.length;
while (prefixLen < minLen &&
oldText.codeUnitAt(prefixLen) == newText.codeUnitAt(prefixLen)) {
prefixLen++;
}

var suffixLen = 0;
final maxSuffix = minLen - prefixLen;
while (suffixLen < maxSuffix &&
oldText.codeUnitAt(oldText.length - 1 - suffixLen) ==
newText.codeUnitAt(newText.length - 1 - suffixLen)) {
suffixLen++;
}

final result = <HighlightSegment>[];
var offset = 0;

// 1. Preserve prefix segments.
for (var i = 0; i < oldSegments.length; i++) {
final seg = oldSegments[i];
final segLen = seg.text.length;
final segStart = offset;
final segEnd = offset + segLen;
offset = segEnd;

if (segStart >= prefixLen) {
break;
}
if (segEnd <= prefixLen) {
result.add(seg);
} else {
final cutLen = prefixLen - segStart;
if (cutLen > 0) {
result.add(
HighlightSegment(
text: seg.text.substring(0, cutLen),
colorArgb: seg.colorArgb,
fontWeightValue: seg.fontWeightValue,
fontStyleIndex: seg.fontStyleIndex,
),
);
}
break;
}
}

// 2. Insert edited middle text.
final middleLen = (newText.length - suffixLen) - prefixLen;
if (middleLen > 0) {
result.add(
HighlightSegment(
text: newText.substring(prefixLen, newText.length - suffixLen),
),
);
}

// 3. Preserve suffix segments.
final suffixStart = oldText.length - suffixLen;
offset = 0;
for (var i = 0; i < oldSegments.length; i++) {
final seg = oldSegments[i];
final segLen = seg.text.length;
final segStart = offset;
final segEnd = offset + segLen;
offset = segEnd;

if (segEnd <= suffixStart) {
continue;
}
if (segStart >= suffixStart) {
result.add(seg);
} else {
final cutStart = suffixStart - segStart;
if (cutStart < segLen) {
result.add(
HighlightSegment(
text: seg.text.substring(cutStart),
colorArgb: seg.colorArgb,
fontWeightValue: seg.fontWeightValue,
fontStyleIndex: seg.fontStyleIndex,
),
);
}
}
}

return result;
}

TextSpan segmentsToTextSpan(
List<HighlightSegment> segments, {
TextStyle? baseStyle,
Expand Down
83 changes: 80 additions & 3 deletions test/core/editor/querya_highlight_controller_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ void main() {
);

// 1. Initialize controller with small text (< threshold) to populate _cachedSpan synchronously
const smallText = 'SELECT id FROM users;';
const smallText = 'SELECT 1 FROM users; -- test';
final controller = QueryaHighlightController(
text: smallText,
language: QueryaCodeLanguage.sql,
Expand All @@ -57,7 +57,6 @@ void main() {
),
);

// Initial highlight for small text populates _cachedSpan
var span = controller.buildTextSpan(
context: buildContext,
withComposing: false,
Expand All @@ -66,7 +65,7 @@ void main() {
expect(span.children, isNotNull);

// 2. Switch to large text (> 8KB). Stale _cachedSpan from smallText must NOT be returned.
final largeText = List.filled(500, 'SELECT id FROM users; -- row').join('\n');
final largeText = List.filled(500, 'SELECT 1 FROM users; -- test row').join('\n');
expect(largeText.length, greaterThan(kSyntaxHighlightIsolateThreshold));
controller.text = largeText;

Expand All @@ -88,6 +87,17 @@ void main() {
);
expect(span.toPlainText(), modifiedLargeText);
expect(span.toPlainText() == controller.text, isTrue);
// Must NOT fall back to plain monochrome text: children should be present and styled
expect(span.children, isNotNull);
expect(span.children, isNotEmpty);
bool checkColors(InlineSpan node) {
if (node is TextSpan) {
if (node.style?.color != null) return true;
if (node.children != null && node.children!.any(checkColors)) return true;
}
return false;
}
expect(checkColors(span), isTrue, reason: 'Syntax highlighting must be preserved during typing');

// 4. User deletes characters
final deletedText = modifiedLargeText.substring(0, modifiedLargeText.length - 25);
Expand All @@ -99,7 +109,74 @@ void main() {
);
expect(span.toPlainText(), deletedText);
expect(span.toPlainText() == controller.text, isTrue);
expect(span.children, isNotNull);
expect(span.children, isNotEmpty);

controller.dispose();
});

group('patchHighlightSegments (#1351)', () {
test('preserves prefix and suffix colors with accurate length on insertion', () {
const oldText = 'SELECT * FROM users WHERE active = 1;';
const newText = 'SELECT * FROM users WHERE active = 1 AND age > 18;';
final oldSegments = [
const HighlightSegment(text: 'SELECT', colorArgb: 0xFF569CD6),
const HighlightSegment(text: ' * FROM ', colorArgb: null),
const HighlightSegment(text: 'users', colorArgb: 0xFF4EC9B0),
const HighlightSegment(text: ' WHERE active = 1;', colorArgb: null),
];

final patched = patchHighlightSegments(
oldText: oldText,
oldSegments: oldSegments,
newText: newText,
);

final fullText = patched.map((s) => s.text).join();
expect(fullText, newText);
expect(patched.first.text, 'SELECT');
expect(patched.first.colorArgb, 0xFF569CD6);
expect(patched.any((s) => s.text.contains('users') && s.colorArgb == 0xFF4EC9B0), isTrue);
});

test('preserves intact segments on character deletion in the middle', () {
const oldText = 'SELECT id, name, email FROM accounts;';
const newText = 'SELECT id, email FROM accounts;';
final oldSegments = [
const HighlightSegment(text: 'SELECT', colorArgb: 0xFF569CD6),
const HighlightSegment(text: ' id, ', colorArgb: null),
const HighlightSegment(text: 'name', colorArgb: 0xFF9CDCFE),
const HighlightSegment(text: ', email FROM accounts;', colorArgb: null),
];

final patched = patchHighlightSegments(
oldText: oldText,
oldSegments: oldSegments,
newText: newText,
);

final fullText = patched.map((s) => s.text).join();
expect(fullText, newText);
expect(patched.first.text, 'SELECT');
expect(patched.first.colorArgb, 0xFF569CD6);
});

test('handles empty strings and complete replacement cleanly', () {
expect(
patchHighlightSegments(
oldText: 'abc',
oldSegments: [const HighlightSegment(text: 'abc', colorArgb: 123)],
newText: '',
),
isEmpty,
);

final replaced = patchHighlightSegments(
oldText: 'abc',
oldSegments: [const HighlightSegment(text: 'abc', colorArgb: 123)],
newText: 'xyz',
);
expect(replaced.map((s) => s.text).join(), 'xyz');
});
});
}
Loading