diff --git a/lib/ui/component/search/highlight_text.dart b/lib/ui/component/search/highlight_text.dart index 59748d3..7eda956 100644 --- a/lib/ui/component/search/highlight_text.dart +++ b/lib/ui/component/search/highlight_text.dart @@ -39,7 +39,7 @@ class HighlightTextWidget extends StatelessWidget { return SelectableText.rich( TextSpan(children: spans), showCursor: true, - selectionColor: highlightSelectionColor(context), + // selectionColor: highlightSelectionColor(context), contextMenuBuilder: contextMenuBuilder, ); }, diff --git a/lib/ui/component/search/highlight_text_document.dart b/lib/ui/component/search/highlight_text_document.dart index 80f91aa..6a7bf9e 100644 --- a/lib/ui/component/search/highlight_text_document.dart +++ b/lib/ui/component/search/highlight_text_document.dart @@ -7,7 +7,7 @@ import 'search_controller.dart'; class HighlightTextDocument { final String text; - final TextStyle rootStyle; + final TextStyle? rootStyle; final List segments; final List matches; final List lines; @@ -113,9 +113,12 @@ class HighlightTextDocument { final overlapEnd = match.end < segmentEnd ? match.end : segmentEnd; final matchText = segment.text.substring(localStart, overlapEnd - segmentStart); final isCurrentMatch = match.index == currentMatchIndex; - final highlightedStyle = (segment.style ?? const TextStyle()).copyWith( + + // 复用样式计算,减少对象创建 + final baseStyle = segment.style ?? const TextStyle(); + final highlightedStyle = baseStyle.copyWith( backgroundColor: isCurrentMatch ? colorScheme.primary : colorScheme.inversePrimary, - color: isCurrentMatch ? colorScheme.onPrimary : segment.style?.color, + color: isCurrentMatch ? colorScheme.onPrimary : baseStyle.color, ); _appendTextSpan(spans, matchText, highlightedStyle); @@ -131,7 +134,7 @@ class HighlightTextDocument { List _plainLineSpans(HighlightDocumentLine line) { if (line.segments.isEmpty) { - return [const TextSpan(text: '\u200B', style: TextStyle(color: Colors.transparent))]; + return [const TextSpan(text: '', style: TextStyle(color: Colors.transparent))]; } return [for (final segment in line.segments) TextSpan(text: segment.text, style: segment.style)]; @@ -185,16 +188,6 @@ List buildHighlightBaseSegments( return [HighlightStyledSegment(text: text, style: _stripBackground(style))]; } -Color highlightSelectionColor(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final configured = Theme.of(context).textSelectionTheme.selectionColor; - // Enforce minimum contrast for syntax-colored text selection. - if (configured != null && configured.alpha >= 140) { - return configured; - } - return scheme.primary.withValues(alpha: 0.55); -} - List buildSearchMatches(String text, SearchTextController searchController) { if (!searchController.shouldSearch()) { return const []; @@ -351,4 +344,3 @@ TextStyle? _stripBackground(TextStyle? style) { } return style.copyWith(backgroundColor: null, background: null); } - diff --git a/lib/ui/content/body.dart b/lib/ui/content/body.dart index 0e24448..073c2e6 100644 --- a/lib/ui/content/body.dart +++ b/lib/ui/content/body.dart @@ -36,7 +36,9 @@ import 'package:proxypin/ui/component/utils.dart'; import 'package:proxypin/ui/desktop/setting/request_rewrite.dart'; import 'package:proxypin/ui/mobile/setting/request_rewrite.dart'; import 'package:proxypin/utils/crypto_body_decoder.dart'; +import 'package:proxypin/utils/css_formatter.dart'; import 'package:proxypin/utils/html_formatter.dart'; +import 'package:proxypin/utils/js_formatter.dart'; import 'package:proxypin/utils/lang.dart'; import 'package:proxypin/utils/num.dart'; import 'package:proxypin/utils/platform.dart'; @@ -117,7 +119,9 @@ class HttpBodyState extends State { final message = widget.httpMessage; if (message == null) return; decoded = await CryptoBodyDecoder.maybeDecode(message); - if (mounted) setState(() {}); + if (mounted && decoded != null && decoded!.hasText) { + setState(() {}); + } } @override @@ -489,6 +493,8 @@ class _Body extends StatefulWidget { } class _BodyState extends State<_Body> { + static const int _virtualizedThreshold = 100000; + late ViewType viewType; HttpMessage? message; @@ -532,6 +538,14 @@ class _BodyState extends State<_Body> { return XML.pretty(body); } + if (type == ViewType.css) { + return CSS.pretty(body); + } + + if (type == ViewType.js) { + return JS.pretty(body); + } + if (type == ViewType.jsonText || type == ViewType.json) { var jsonObject = json.decode(body); return const JsonEncoder.withIndent(" ").convert(jsonObject); @@ -673,28 +687,21 @@ class _BodyState extends State<_Body> { HttpMessage? message, }) { final language = _languageForViewType(type, message); - - // bool showVirtualized = text.length > 12000; + final formattedText = language != null ? _formatTextBody(type, text) : text; + // final showVirtualized = formattedText.length > _virtualizedThreshold; // if (showVirtualized) { - // logger.d( - // "Using virtualized viewer for type $type with length ${text.length} and line count ${'\n'.allMatches(text).length + 1}"); // return VirtualizedHighlightText( - // text: text, + // text: formattedText, // language: language, - // searchController: widget.searchController, // contextMenuBuilder: contextMenu, + // searchController: widget.searchController, // scrollController: widget.scrollController, // ); // } - if (type == ViewType.text) { - return HighlightTextWidget( - text: text, searchController: widget.searchController, contextMenuBuilder: contextMenu); - } - return HighlightTextWidget( language: language, - text: _formatTextBody(type, text), + text: formattedText, searchController: widget.searchController, contextMenuBuilder: contextMenu); } @@ -719,6 +726,13 @@ class Tabs { tabs.list.add(ViewType.jsonText); } + if (contentType == ContentType.html || + contentType == ContentType.xml || + contentType == ContentType.js || + contentType == ContentType.css) { + tabs.list.add(ViewType.text); + } + tabs.list.add(ViewType.of(contentType) ?? ViewType.text); //为json时,增加json格式化 @@ -727,10 +741,7 @@ class Tabs { tabs.list.add(ViewType.json); } - if (contentType == ContentType.formUrl || - contentType == ContentType.json || - contentType == ContentType.html || - contentType == ContentType.xml) { + if (contentType == ContentType.formUrl || contentType == ContentType.json) { tabs.list.add(ViewType.text); } diff --git a/lib/utils/css_formatter.dart b/lib/utils/css_formatter.dart new file mode 100644 index 0000000..9e3d3a3 --- /dev/null +++ b/lib/utils/css_formatter.dart @@ -0,0 +1,157 @@ +class CSS { + /// 格式化 CSS 字符串 + static String pretty(String input) { + if (input.trim().isEmpty || !input.contains('{')) return input; + try { + final result = _CssFormatter(input).format(); + return result.isEmpty ? input : result; + } catch (_) { + return input; + } + } +} + +class _CssFormatter { + final String _src; + int _i = 0; + int _depth = 0; + final StringBuffer _buf = StringBuffer(); + static const String _ind = ' '; + + _CssFormatter(this._src); + + String format() { + while (_i < _src.length) { + _step(); + } + return _buf.toString().trim(); + } + + void _step() { + final c = _src[_i]; + + // Block comment + if (c == '/' && _peek(1) == '*') { + _readComment(); + return; + } + + // String literal + if (c == '"' || c == "'") { + _readString(c); + return; + } + + // Open brace + if (c == '{') { + _trimTrailingSpace(); + _buf.write(' {\n'); + _depth++; + _writeIndent(); + _i++; + _skipWs(); + return; + } + + // Close brace + if (c == '}') { + _trimTrailingSpace(); + _depth = (_depth - 1).clamp(0, 100); + _buf.write('\n'); + _writeIndent(); + _buf.write('}\n'); + _i++; + _skipWs(); + if (_depth > 0 && _i < _src.length) { + _buf.writeln(); + _writeIndent(); + } + return; + } + + // Semicolon + if (c == ';') { + _buf.write(';\n'); + _i++; + _skipWs(); + _writeIndent(); + return; + } + + // Whitespace normalization + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') { + final s = _buf.toString(); + if (s.isNotEmpty) { + final last = s[s.length - 1]; + if (last != ' ' && last != '\n') { + _buf.write(' '); + } + } + _i++; + return; + } + + _buf.write(c); + _i++; + } + + void _readComment() { + _buf.write('/*'); + _i += 2; + while (_i < _src.length) { + if (_src[_i] == '*' && _peek(1) == '/') { + _buf.write('*/'); + _i += 2; + break; + } + _buf.write(_src[_i]); + _i++; + } + _buf.writeln(); + _skipWs(); + _writeIndent(); + } + + void _readString(String quote) { + _buf.write(quote); + _i++; + while (_i < _src.length) { + final c = _src[_i]; + if (c == '\\') { + _buf.write(c); + _i++; + if (_i < _src.length) { + _buf.write(_src[_i]); + _i++; + } + continue; + } + _buf.write(c); + _i++; + if (c == quote) break; + } + } + + void _trimTrailingSpace() { + final s = _buf.toString().trimRight(); + _buf.clear(); + _buf.write(s); + } + + void _skipWs() { + while (_i < _src.length && + (_src[_i] == ' ' || _src[_i] == '\t' || _src[_i] == '\n' || _src[_i] == '\r')) { + _i++; + } + } + + void _writeIndent() { + _buf.write(_ind * _depth); + } + + String? _peek(int offset) { + final idx = _i + offset; + return idx < _src.length ? _src[idx] : null; + } +} + diff --git a/lib/utils/js_formatter.dart b/lib/utils/js_formatter.dart new file mode 100644 index 0000000..f7c0906 --- /dev/null +++ b/lib/utils/js_formatter.dart @@ -0,0 +1,211 @@ +class JS { + /// 格式化 JavaScript 字符串(适合还原压缩后的 JS) + static String pretty(String input) { + if (input.trim().isEmpty) return input; + try { + final result = _JsFormatter(input).format(); + return result.isEmpty ? input : result; + } catch (_) { + return input; + } + } +} + +class _JsFormatter { + final String _src; + int _i = 0; + int _depth = 0; + final StringBuffer _buf = StringBuffer(); + static const String _ind = ' '; + + _JsFormatter(this._src); + + String format() { + while (_i < _src.length) { + _step(); + } + return _buf.toString().trim(); + } + + void _step() { + final c = _src[_i]; + + // Line comment + if (c == '/' && _peek(1) == '/') { + _readLineComment(); + return; + } + + // Block comment + if (c == '/' && _peek(1) == '*') { + _readBlockComment(); + return; + } + + // Template literal + if (c == '`') { + _readTemplateLiteral(); + return; + } + + // String literal + if (c == '"' || c == "'") { + _readString(c); + return; + } + + // Open brace + if (c == '{') { + _trimTrailingSpace(); + _buf.write(' {\n'); + _depth++; + _writeIndent(); + _i++; + _skipWs(); + return; + } + + // Close brace + if (c == '}') { + _trimTrailingSpace(); + _depth = (_depth - 1).clamp(0, 100); + _buf.write('\n'); + _writeIndent(); + _buf.write('}'); + _i++; + _skipWs(); + // Don't add newline if followed by ; , ) + if (_i < _src.length && _src[_i] != ';' && _src[_i] != ',' && _src[_i] != ')') { + _buf.writeln(); + if (_depth > 0 && _i < _src.length) _writeIndent(); + } + return; + } + + // Semicolon + if (c == ';') { + _buf.write(';'); + _i++; + _skipWs(); + if (_i < _src.length && _src[_i] != '}') { + _buf.writeln(); + _writeIndent(); + } + return; + } + + // Comma — keep on same line but normalize space after + if (c == ',') { + _buf.write(', '); + _i++; + _skipWs(); + return; + } + + // Whitespace normalization + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') { + final s = _buf.toString(); + if (s.isNotEmpty) { + final last = s[s.length - 1]; + if (last != ' ' && last != '\n') { + _buf.write(' '); + } + } + _i++; + return; + } + + _buf.write(c); + _i++; + } + + void _readLineComment() { + while (_i < _src.length && _src[_i] != '\n') { + _buf.write(_src[_i]); + _i++; + } + _buf.writeln(); + _writeIndent(); + } + + void _readBlockComment() { + _buf.write('/*'); + _i += 2; + while (_i < _src.length) { + if (_src[_i] == '*' && _peek(1) == '/') { + _buf.write('*/'); + _i += 2; + break; + } + _buf.write(_src[_i]); + _i++; + } + } + + void _readString(String quote) { + _buf.write(quote); + _i++; + while (_i < _src.length) { + final c = _src[_i]; + if (c == '\\') { + _buf.write(c); + _i++; + if (_i < _src.length) { + _buf.write(_src[_i]); + _i++; + } + continue; + } + _buf.write(c); + _i++; + if (c == quote) break; + } + } + + void _readTemplateLiteral() { + _buf.write('`'); + _i++; + while (_i < _src.length) { + final c = _src[_i]; + if (c == '\\') { + _buf.write(c); + _i++; + if (_i < _src.length) { + _buf.write(_src[_i]); + _i++; + } + continue; + } + if (c == '`') { + _buf.write(c); + _i++; + break; + } + _buf.write(c); + _i++; + } + } + + void _trimTrailingSpace() { + final s = _buf.toString().trimRight(); + _buf.clear(); + _buf.write(s); + } + + void _skipWs() { + while (_i < _src.length && + (_src[_i] == ' ' || _src[_i] == '\t' || _src[_i] == '\n' || _src[_i] == '\r')) { + _i++; + } + } + + void _writeIndent() { + _buf.write(_ind * _depth); + } + + String? _peek(int offset) { + final idx = _i + offset; + return idx < _src.length ? _src[idx] : null; + } +} + diff --git a/test/highlight_text_test.dart b/test/highlight_text_test.dart index 9e86cfc..b85f011 100644 --- a/test/highlight_text_test.dart +++ b/test/highlight_text_test.dart @@ -5,6 +5,24 @@ import 'package:proxypin/ui/component/search/search_controller.dart'; void main() { group('HighlightTextWidget', () { + testWidgets('does not apply root style when language is empty', (tester) async { + final controller = SearchTextController(); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: HighlightTextWidget( + text: 'plain text body', + searchController: controller, + ), + ), + )); + + final selectable = tester.widget(find.byType(SelectableText)); + expect(selectable.style, isNull); + + await _disposeController(tester, controller); + }); + testWidgets('keeps syntax highlighting while search is active', (tester) async { final controller = SearchTextController(); BuildContext? hostContext;