From 084e437fb29f443523be9d602c9dff49fd912fc5 Mon Sep 17 00:00:00 2001 From: "yannan.chen" Date: Thu, 23 Jul 2026 14:55:24 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=80=20release:=20=E5=8F=91=E5=B8=83=20?= =?UTF-8?q?v1.10.2=20=E8=B6=85=E9=95=BF=20JSON=20=E6=80=A7=E8=83=BD?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/releases/v1.10.2.md | 22 ++ lib/l10n/app_en.arb | 15 + lib/l10n/app_zh.arb | 15 + lib/l10n/generated/app_localizations.dart | 18 ++ lib/l10n/generated/app_localizations_en.dart | 13 + lib/l10n/generated/app_localizations_zh.dart | 13 + lib/services/json_formatter_service.dart | 63 +++++ lib/ui/tools/json_formatter_tool.dart | 266 +++++++++++++----- lib/ui/tools/json_large_document_view.dart | 136 +++++++++ lib/ui/widgets/json_tree_view.dart | 190 +++++++++---- pubspec.yaml | 2 +- .../services/json_formatter_service_test.dart | 58 ++++ test/widgets/all_features_smoke_test.dart | 42 ++- .../json_large_document_view_test.dart | 21 ++ .../json_tree_view_performance_test.dart | 43 +++ windows/installer.iss | 2 +- 16 files changed, 786 insertions(+), 133 deletions(-) create mode 100644 docs/releases/v1.10.2.md create mode 100644 lib/services/json_formatter_service.dart create mode 100644 lib/ui/tools/json_large_document_view.dart create mode 100644 test/services/json_formatter_service_test.dart create mode 100644 test/widgets/json_large_document_view_test.dart create mode 100644 test/widgets/json_tree_view_performance_test.dart diff --git a/docs/releases/v1.10.2.md b/docs/releases/v1.10.2.md new file mode 100644 index 0000000..38dec35 --- /dev/null +++ b/docs/releases/v1.10.2.md @@ -0,0 +1,22 @@ +## 修复 + +- 修复约 10 MB、十万级节点 JSON 在格式化时长时间阻塞界面的问题,将解析、格式化和压缩移至后台 isolate。 +- 修复树形视图在节点折叠时仍一次性创建全部子节点的问题,改为按展开状态懒加载,并以每页 100 项渐进展示。 +- 修复超大 JSON 自动写入本地偏好设置、导致应用下次启动再次读取并卡顿的问题;超过 1 MB 的临时内容不再持久化。 + +## 优化 + +- 保留原文件的 CRLF 换行风格;已经符合四空格格式的超大文件不再触发无意义的整段文本替换和重排。 +- 大文件粘贴、输入解析和启动恢复统一使用可取消的后台任务,新的输入会使旧结果失效,避免异步结果覆盖当前内容。 +- 树形视图搜索导航可直接补充显示当前命中路径,不需要提前渲染该分支的全部兄弟节点。 + +## 验证 + +- 使用实际 10,745,012 字符、122,985 条记录的 JSON 验证:后台格式化约 268 ms,主事件循环保持响应,输出与已格式化原文一致。 +- 使用真实 macOS Release 应用验收:粘贴进入大文件模式约 743 ms,格式化完成约 1.24 秒,压缩完成约 927 ms,搜索返回约 704 ms;复制回读的 10,745,012 字符与原文完全一致。 +- 已通过 Flutter 静态分析、Lab 自定义规范检查以及服务、模型、工具、ViewModel、Widget 自动化测试。 +- 已新增十万级 JSON 后台转换回归测试和万级折叠分支懒加载 Widget 测试。 +- 已通过全新 macOS 1.10.2 Release 构建、严格代码签名校验和真实 worker IPC `hello → accepted → status → stopped` 冒烟验证。 +- GitHub Actions 将在 Ubuntu 执行完整测试和 golden 视觉回归,并在 Windows、macOS、Linux 完成 Release 构建与真实工作进程 IPC 冒烟验证。 + +**完整更新日志:** [v1.10.1...v1.10.2](https://github.com/bcblr1993/iot_devkit_flutter/compare/v1.10.1...v1.10.2) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b386652..e3de8f9 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -94,6 +94,21 @@ "inputLabel": "Input", "treeViewLabel": "Tree View", "itemsLabel": "items", + "loadMoreJsonItems": "Load more ({visible}/{total})", + "@loadMoreJsonItems": { + "placeholders": { + "visible": {"type": "int"}, + "total": {"type": "int"} + } + }, + "largeJsonModeTitle": "Large document mode", + "largeJsonModeDescription": "Full content is {size}. Showing a bounded preview of up to {lines} lines; copy, format, minify, search, and tree view still use the complete document.", + "@largeJsonModeDescription": { + "placeholders": { + "size": {"type": "String"}, + "lines": {"type": "int"} + } + }, "enterJsonHint": "Enter JSON to view tree", "invalidJson": "Invalid JSON", "exportConfig": "Export Config", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 65a427e..c166ad2 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -112,6 +112,21 @@ "inputLabel": "输入", "treeViewLabel": "树形视图", "itemsLabel": "项", + "loadMoreJsonItems": "加载更多({visible}/{total})", + "@loadMoreJsonItems": { + "placeholders": { + "visible": {"type": "int"}, + "total": {"type": "int"} + } + }, + "largeJsonModeTitle": "大文件模式", + "largeJsonModeDescription": "完整内容大小为 {size},当前显示最多前 {lines} 行的限量预览;复制、格式化、压缩、搜索和树形视图仍使用完整文档。", + "@largeJsonModeDescription": { + "placeholders": { + "size": {"type": "String"}, + "lines": {"type": "int"} + } + }, "enterJsonHint": "输入 JSON 以查看树形结构", "invalidJson": "无效的 JSON", "exportConfig": "导出配置", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 9d63b1e..b5df90d 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -614,6 +614,24 @@ abstract class AppLocalizations { /// **'items'** String get itemsLabel; + /// No description provided for @loadMoreJsonItems. + /// + /// In en, this message translates to: + /// **'Load more ({visible}/{total})'** + String loadMoreJsonItems(int visible, int total); + + /// No description provided for @largeJsonModeTitle. + /// + /// In en, this message translates to: + /// **'Large document mode'** + String get largeJsonModeTitle; + + /// No description provided for @largeJsonModeDescription. + /// + /// In en, this message translates to: + /// **'Full content is {size}. Showing a bounded preview of up to {lines} lines; copy, format, minify, search, and tree view still use the complete document.'** + String largeJsonModeDescription(String size, int lines); + /// No description provided for @enterJsonHint. /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 5c19d34..05c4d76 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -275,6 +275,19 @@ class AppLocalizationsEn extends AppLocalizations { @override String get itemsLabel => 'items'; + @override + String loadMoreJsonItems(int visible, int total) { + return 'Load more ($visible/$total)'; + } + + @override + String get largeJsonModeTitle => 'Large document mode'; + + @override + String largeJsonModeDescription(String size, int lines) { + return 'Full content is $size. Showing a bounded preview of up to $lines lines; copy, format, minify, search, and tree view still use the complete document.'; + } + @override String get enterJsonHint => 'Enter JSON to view tree'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index ad14de0..af8140d 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -268,6 +268,19 @@ class AppLocalizationsZh extends AppLocalizations { @override String get itemsLabel => '项'; + @override + String loadMoreJsonItems(int visible, int total) { + return '加载更多($visible/$total)'; + } + + @override + String get largeJsonModeTitle => '大文件模式'; + + @override + String largeJsonModeDescription(String size, int lines) { + return '完整内容大小为 $size,当前显示最多前 $lines 行的限量预览;复制、格式化、压缩、搜索和树形视图仍使用完整文档。'; + } + @override String get enterJsonHint => '输入 JSON 以查看树形结构'; diff --git a/lib/services/json_formatter_service.dart b/lib/services/json_formatter_service.dart new file mode 100644 index 0000000..b79faab --- /dev/null +++ b/lib/services/json_formatter_service.dart @@ -0,0 +1,63 @@ +import 'dart:convert'; +import 'dart:isolate'; + +enum JsonOutputStyle { pretty, compact } + +class JsonFormatResult { + const JsonFormatResult({ + required this.output, + required this.data, + }); + + final String output; + final dynamic data; +} + +class JsonFormatterService { + const JsonFormatterService._(); + + static Future transform( + String source, + JsonOutputStyle style, + ) { + return Isolate.run(() => transformSync(source, style)); + } + + static Future parse(String source) { + return Isolate.run(() => jsonDecode(source)); + } + + static JsonFormatResult transformSync( + String source, + JsonOutputStyle style, + ) { + final data = jsonDecode(source); + final encoder = style == JsonOutputStyle.pretty + ? const JsonEncoder.withIndent(' ') + : const JsonEncoder(); + var output = encoder.convert(data); + + // Avoid replacing an already-formatted large document solely because the + // encoder uses LF while the source file uses CRLF. + if (style == JsonOutputStyle.pretty && _usesOnlyCrLf(source)) { + output = output.replaceAll('\n', '\r\n'); + } + + return JsonFormatResult(output: output, data: data); + } + + static bool _usesOnlyCrLf(String source) { + final firstNewline = source.indexOf('\n'); + if (firstNewline < 0) { + return false; + } + + for (var index = firstNewline; index < source.length; index++) { + if (source.codeUnitAt(index) == 0x0A && + (index == 0 || source.codeUnitAt(index - 1) != 0x0D)) { + return false; + } + } + return true; + } +} diff --git a/lib/ui/tools/json_formatter_tool.dart b/lib/ui/tools/json_formatter_tool.dart index 9715543..13b2b1b 100644 --- a/lib/ui/tools/json_formatter_tool.dart +++ b/lib/ui/tools/json_formatter_tool.dart @@ -4,6 +4,8 @@ import 'dart:async'; // For Timer import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:shared_preferences/shared_preferences.dart'; // Import +import '../../services/json_formatter_service.dart'; +import 'json_large_document_view.dart'; import '../widgets/json_tree_view.dart'; import '../../l10n/generated/app_localizations.dart'; import '../lab/lab.dart'; @@ -35,7 +37,18 @@ class _JsonFormatterToolState extends State { // Persistence Timer? _saveTimer; + Timer? _parseTimer; static const String _storageKey = 'json_formatter_content'; + static const int _backgroundParseThreshold = 128 * 1024; + static const int _largeDocumentThreshold = 512 * 1024; + static const int _maxPersistedContentLength = 1024 * 1024; + int _parseGeneration = 0; + bool _isProcessing = false; + String? _largeDocumentText; + + String get _documentText => _largeDocumentText ?? _inputController.text; + + bool get _isLargeDocument => _largeDocumentText != null; @override void initState() { @@ -51,6 +64,10 @@ class _JsonFormatterToolState extends State { @override void dispose() { _saveTimer?.cancel(); + _parseTimer?.cancel(); + _inputController.dispose(); + _scrollController.dispose(); + _searchController.dispose(); _treeControlNotifier.dispose(); super.dispose(); } @@ -59,15 +76,13 @@ class _JsonFormatterToolState extends State { final prefs = await SharedPreferences.getInstance(); final content = prefs.getString(_storageKey); if (content != null && content.isNotEmpty) { + if (content.length > _maxPersistedContentLength) { + await prefs.remove(_storageKey); + return; + } if (mounted) { - setState(() { - _inputController.text = content; - }); - // Parse it - try { - final obj = json.decode(content); - _updateParsedData(obj, save: false); // Don't save again immediately - } catch (_) {} + _replaceDocumentText(content); + _parseInput(content, immediate: true); } } } @@ -81,75 +96,161 @@ class _JsonFormatterToolState extends State { Future _saveState() async { final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_storageKey, _inputController.text); + final content = _documentText; + if (content.length > _maxPersistedContentLength) { + await prefs.remove(_storageKey); + return; + } + await prefs.setString(_storageKey, content); } - void _formatJson() { - final text = _inputController.text; - if (text.isEmpty) return; - final l10n = AppLocalizations.of(context)!; + Future _formatJson() async { + await _transformJson(JsonOutputStyle.pretty); + } - try { - final object = json.decode(text); - final prettyString = const JsonEncoder.withIndent(' ').convert(object); - _inputController.text = prettyString; - _setStatus(l10n.formatSuccess, Colors.green); - _updateParsedData(object); - } catch (e) { - _setStatus( - '${l10n.invalidJson}: $e', Theme.of(context).colorScheme.error); - } + Future _minifyJson() async { + await _transformJson(JsonOutputStyle.compact); } - void _minifyJson() { - final text = _inputController.text; - if (text.isEmpty) return; + Future _transformJson(JsonOutputStyle style) async { + final text = _documentText; + if (text.isEmpty || _isProcessing) return; final l10n = AppLocalizations.of(context)!; + final generation = ++_parseGeneration; + _parseTimer?.cancel(); + setState(() => _isProcessing = true); try { - final object = json.decode(text); - final miniString = json.encode(object); - _inputController.text = miniString; - _setStatus(l10n.minifySuccess, Colors.green); - _updateParsedData(object); + final result = await JsonFormatterService.transform(text, style); + if (!mounted || generation != _parseGeneration) return; + + if (_documentText != result.output) { + _replaceDocumentText(result.output); + } + _setStatus( + style == JsonOutputStyle.pretty + ? l10n.formatSuccess + : l10n.minifySuccess, + Colors.green, + ); + _updateParsedData(result.data); } catch (e) { + if (!mounted || generation != _parseGeneration) return; _setStatus( '${l10n.invalidJson}: $e', Theme.of(context).colorScheme.error); + } finally { + if (mounted && generation == _parseGeneration) { + setState(() => _isProcessing = false); + } } } void _clear() { + _parseGeneration++; + _parseTimer?.cancel(); _inputController.clear(); setState(() { + _largeDocumentText = null; _parsedData = null; _matches = []; _currentMatchIndex = -1; _searchQuery = ''; _searchController.clear(); + _isProcessing = false; }); _saveState(); // Save empty _setStatus(AppLocalizations.of(context)!.ready, Colors.grey); } - void _copyInput() { + Future _copyInput() async { final l10n = AppLocalizations.of(context)!; - if (_inputController.text.isEmpty) return; - Clipboard.setData(ClipboardData(text: _inputController.text)); + if (_documentText.isEmpty) return; + await Clipboard.setData(ClipboardData(text: _documentText)); + if (!mounted) return; _setStatus(l10n.copySuccess, Colors.green); } void _paste() async { final data = await Clipboard.getData(Clipboard.kTextPlain); if (data?.text != null) { - _inputController.text = data!.text!; - _saveState(); // Save immediately on paste - // Try parse + _replaceDocumentText(data!.text!); + _saveState(); + _parseInput(data.text!, immediate: true); + } + } + + void _acceptLargeDocument(String source) { + if (!mounted) return; + _replaceDocumentText(source); + _saveState(); + _parseInput(source, immediate: true); + } + + void _replaceDocumentText(String source) { + if (source.length > _largeDocumentThreshold) { + _inputController.clear(); + if (_largeDocumentText != source) { + setState(() => _largeDocumentText = source); + } + return; + } + + if (_largeDocumentText != null) { + setState(() => _largeDocumentText = null); + } + if (_inputController.text != source) { + _inputController.value = TextEditingValue( + text: source, + selection: const TextSelection.collapsed(offset: 0), + ); + } + } + + void _parseInput(String source, {bool immediate = false}) { + final generation = ++_parseGeneration; + _parseTimer?.cancel(); + + if (source.trim().isEmpty) { + setState(() { + _parsedData = null; + _isProcessing = false; + }); + return; + } + + if (source.length < _backgroundParseThreshold) { try { - final obj = json.decode(data.text!); - _updateParsedData(obj, save: false); - } catch (e) { - // ignore + _updateParsedData(jsonDecode(source), save: false); + } catch (_) {} + if (_isProcessing) { + setState(() => _isProcessing = false); } + return; + } + + setState(() { + _parsedData = null; + _isProcessing = true; + }); + + Future parse() async { + try { + final data = await JsonFormatterService.parse(source); + if (!mounted || generation != _parseGeneration) return; + _updateParsedData(data, save: false); + } catch (_) { + // Invalid input is expected while the user is editing. + } finally { + if (mounted && generation == _parseGeneration) { + setState(() => _isProcessing = false); + } + } + } + + if (immediate) { + parse(); + } else { + _parseTimer = Timer(const Duration(milliseconds: 250), parse); } } @@ -342,13 +443,18 @@ class _JsonFormatterToolState extends State { icon: Icons.format_align_left, variant: LabButtonVariant.ghost, size: LabButtonSize.sm, - onPressed: _formatJson), + loading: _isProcessing, + onPressed: _isProcessing + ? null + : _formatJson), LabButton( label: l10n.minifyAction, icon: Icons.compress, variant: LabButtonVariant.ghost, size: LabButtonSize.sm, - onPressed: _minifyJson), + onPressed: _isProcessing + ? null + : _minifyJson), ], ), ), @@ -359,30 +465,42 @@ class _JsonFormatterToolState extends State { ), const Divider(height: 1), Expanded( - child: TextField( - controller: _inputController, - scrollController: _scrollController, - expands: true, - maxLines: null, - minLines: null, - keyboardType: TextInputType.multiline, - textAlignVertical: TextAlignVertical.top, - style: const TextStyle( - fontFamily: 'Courier', fontSize: 13), - decoration: const InputDecoration( - hintText: 'Paste or type JSON here...', - border: InputBorder.none, - contentPadding: EdgeInsets.all(16), - ), - onChanged: (val) { - _saveDelayed(); // Debounce save - try { - final obj = json.decode(val); - _updateParsedData(obj, - save: false); // Already saving - } catch (e) {/* user format */} - }, - ), + child: _isLargeDocument + ? LargeJsonDocumentPreview( + content: _documentText, + title: l10n.largeJsonModeTitle, + description: l10n.largeJsonModeDescription( + '${(_documentText.length / (1024 * 1024)).toStringAsFixed(1)} MB', + 400, + ), + ) + : TextField( + controller: _inputController, + scrollController: _scrollController, + expands: true, + maxLines: null, + minLines: null, + keyboardType: TextInputType.multiline, + textAlignVertical: TextAlignVertical.top, + style: const TextStyle( + fontFamily: 'Courier', fontSize: 13), + decoration: const InputDecoration( + hintText: 'Paste or type JSON here...', + border: InputBorder.none, + contentPadding: EdgeInsets.all(16), + ), + inputFormatters: [ + LargeJsonTextInputFormatter( + maxEditableCharacters: + _largeDocumentThreshold, + onLargeText: _acceptLargeDocument, + ), + ], + onChanged: (val) { + _saveDelayed(); + _parseInput(val); + }, + ), ), ], ), @@ -570,19 +688,15 @@ class _JsonFormatterToolState extends State { } Widget _buildTreeContent(AppLocalizations l10n, ThemeData theme) { + if (_isProcessing && _parsedData == null) { + return const Center(child: CircularProgressIndicator()); + } + if (_parsedData == null) { - if (_inputController.text.trim().isEmpty) { + if (_documentText.trim().isEmpty) { return Center( child: Text(l10n.enterJsonHint, style: TextStyle(color: theme.colorScheme.onSurfaceVariant))); - } else { - // Try to parse once if not parsed - try { - final obj = json.decode(_inputController.text); - WidgetsBinding.instance.addPostFrameCallback((_) { - _updateParsedData(obj, save: false); - }); - } catch (_) {} } } diff --git a/lib/ui/tools/json_large_document_view.dart b/lib/ui/tools/json_large_document_view.dart new file mode 100644 index 0000000..0dab3a1 --- /dev/null +++ b/lib/ui/tools/json_large_document_view.dart @@ -0,0 +1,136 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../lab/lab.dart'; + +class LargeJsonTextInputFormatter extends TextInputFormatter { + LargeJsonTextInputFormatter({ + required this.onLargeText, + this.maxEditableCharacters = 512 * 1024, + }); + + final ValueChanged onLargeText; + final int maxEditableCharacters; + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + if (newValue.text.length <= maxEditableCharacters) { + return newValue; + } + + final largeText = newValue.text; + scheduleMicrotask(() => onLargeText(largeText)); + return oldValue; + } +} + +class LargeJsonDocumentPreview extends StatelessWidget { + const LargeJsonDocumentPreview({ + super.key, + required this.content, + required this.title, + required this.description, + this.previewLineLimit = 400, + this.previewCharacterLimit = 4 * 1024, + }); + + final String content; + final String title; + final String description; + final int previewLineLimit; + final int previewCharacterLimit; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final tokens = LabTokens.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + color: theme.colorScheme.surfaceContainerHighest, + padding: EdgeInsets.all(tokens.sLg), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.article_outlined, + color: theme.colorScheme.primary, + size: 20, + ), + SizedBox(width: tokens.sMd), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.onSurface, + ), + ), + SizedBox(height: tokens.sXxs), + Text( + description, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: SingleChildScrollView( + padding: EdgeInsets.all(tokens.sXl), + child: Text( + _preview( + content, + lineLimit: previewLineLimit, + characterLimit: previewCharacterLimit, + ), + style: TextStyle( + color: theme.colorScheme.onSurface, + fontFamily: tokens.monoFamily, + fontSize: 13, + ), + ), + ), + ), + ], + ); + } + + static String _preview( + String content, { + required int lineLimit, + required int characterLimit, + }) { + if (content.isEmpty || lineLimit <= 0 || characterLimit <= 0) { + return ''; + } + + final scanLength = + content.length < characterLimit ? content.length : characterLimit; + var lines = 0; + for (var index = 0; index < scanLength; index++) { + if (content.codeUnitAt(index) != 0x0A) { + continue; + } + lines++; + if (lines == lineLimit) { + return content.substring(0, index); + } + } + return content.substring(0, scanLength); + } +} diff --git a/lib/ui/widgets/json_tree_view.dart b/lib/ui/widgets/json_tree_view.dart index 2a37970..d8351d8 100644 --- a/lib/ui/widgets/json_tree_view.dart +++ b/lib/ui/widgets/json_tree_view.dart @@ -38,7 +38,10 @@ class JsonTreeView extends StatefulWidget { } class _JsonTreeViewState extends State { + static const int _childPageSize = 100; + bool _isExpanded = false; + int _visibleChildCount = _childPageSize; int _localControlVersion = 0; int _searchExpandVersion = 0; // Forces rebuild when search triggers expansion @@ -62,6 +65,9 @@ class _JsonTreeViewState extends State { @override void didUpdateWidget(JsonTreeView oldWidget) { super.didUpdateWidget(oldWidget); + if (!identical(widget.data, oldWidget.data)) { + _visibleChildCount = _childPageSize; + } if (widget.searchQuery != oldWidget.searchQuery) { _checkSearchExpansion(); } @@ -246,32 +252,23 @@ class _JsonTreeViewState extends State { onExpansionChanged: (val) { setState(() => _isExpanded = val); }, - children: [ - Container( - decoration: BoxDecoration( - border: Border( - left: - BorderSide(color: theme.colorScheme.outline, width: 1.0)), - ), - margin: const EdgeInsets.only(left: _lineMargin), - padding: const EdgeInsets.only(left: 14.5), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: map.entries.map((e) { - final newPath = [...widget.currentParamsPath, e.key]; - return JsonTreeView( - data: e.value, - keyName: e.key, - searchQuery: widget.searchQuery, - isArrayItem: false, - currentParamsPath: newPath, - activeMatchPath: widget.activeMatchPath, - expandAllNotifier: widget.expandAllNotifier, - ); - }).toList(), - ), - ) - ], + children: _isExpanded + ? [ + Container( + decoration: BoxDecoration( + border: Border( + left: BorderSide( + color: theme.colorScheme.outline, width: 1.0)), + ), + margin: const EdgeInsets.only(left: _lineMargin), + padding: const EdgeInsets.only(left: 14.5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: _buildObjectChildren(map, l10n), + ), + ) + ] + : const [], ), ); } @@ -311,36 +308,125 @@ class _JsonTreeViewState extends State { onExpansionChanged: (val) { setState(() => _isExpanded = val); }, - children: [ - Container( - decoration: BoxDecoration( - border: Border( - left: - BorderSide(color: theme.colorScheme.outline, width: 1.0)), - ), - margin: const EdgeInsets.only(left: _lineMargin), - padding: const EdgeInsets.only(left: 14.5), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: list.asMap().entries.map((e) { - final newPath = [...widget.currentParamsPath, e.key]; - return JsonTreeView( - data: e.value, - keyName: e.key.toString(), - searchQuery: widget.searchQuery, - isArrayItem: true, - currentParamsPath: newPath, - activeMatchPath: widget.activeMatchPath, - expandAllNotifier: widget.expandAllNotifier, - ); - }).toList(), - ), - ) - ], + children: _isExpanded + ? [ + Container( + decoration: BoxDecoration( + border: Border( + left: BorderSide( + color: theme.colorScheme.outline, width: 1.0)), + ), + margin: const EdgeInsets.only(left: _lineMargin), + padding: const EdgeInsets.only(left: 14.5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: _buildArrayChildren(list, l10n), + ), + ) + ] + : const [], ), ); } + List _buildObjectChildren( + Map map, + AppLocalizations? l10n, + ) { + final visibleCount = _visibleChildCount.clamp(0, map.length); + final visibleEntries = map.entries.take(visibleCount).toList(); + final activeSegment = _activeChildSegment(); + + if (activeSegment is String && + !map.keys.take(visibleCount).contains(activeSegment)) { + final activeValue = map[activeSegment]; + if (map.containsKey(activeSegment)) { + visibleEntries.add(MapEntry(activeSegment, activeValue)); + } + } + + return [ + for (final entry in visibleEntries) + JsonTreeView( + data: entry.value, + keyName: entry.key, + searchQuery: widget.searchQuery, + isArrayItem: false, + currentParamsPath: [...widget.currentParamsPath, entry.key], + activeMatchPath: widget.activeMatchPath, + expandAllNotifier: widget.expandAllNotifier, + ), + if (visibleCount < map.length) + _buildLoadMoreButton(visibleCount, map.length, l10n), + ]; + } + + List _buildArrayChildren( + List list, + AppLocalizations? l10n, + ) { + final visibleCount = _visibleChildCount.clamp(0, list.length); + final indices = [ + for (var index = 0; index < visibleCount; index++) index + ]; + final activeSegment = _activeChildSegment(); + + if (activeSegment is int && + activeSegment >= visibleCount && + activeSegment < list.length) { + indices.add(activeSegment); + } + + return [ + for (final index in indices) + JsonTreeView( + data: list[index], + keyName: index.toString(), + searchQuery: widget.searchQuery, + isArrayItem: true, + currentParamsPath: [...widget.currentParamsPath, index], + activeMatchPath: widget.activeMatchPath, + expandAllNotifier: widget.expandAllNotifier, + ), + if (visibleCount < list.length) + _buildLoadMoreButton(visibleCount, list.length, l10n), + ]; + } + + dynamic _activeChildSegment() { + final activePath = widget.activeMatchPath; + if (activePath == null || + activePath.length <= widget.currentParamsPath.length) { + return null; + } + + for (var index = 0; index < widget.currentParamsPath.length; index++) { + if (activePath[index] != widget.currentParamsPath[index]) { + return null; + } + } + return activePath[widget.currentParamsPath.length]; + } + + Widget _buildLoadMoreButton( + int visibleCount, + int totalCount, + AppLocalizations? l10n, + ) { + final label = l10n?.loadMoreJsonItems(visibleCount, totalCount) ?? + 'Load more ($visibleCount/$totalCount)'; + return TextButton.icon( + onPressed: () { + setState(() { + _visibleChildCount = + (_visibleChildCount + _childPageSize).clamp(0, totalCount); + }); + }, + icon: const Icon(Icons.expand_more), + label: Text(label), + ); + } + Widget _buildTitle(BuildContext context, String open, String close, int count, String itemsLabel) { final theme = Theme.of(context); diff --git a/pubspec.yaml b/pubspec.yaml index c3204ef..a295125 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: iot_devkit description: A new Flutter project for IoT MQTT Device Simulation. publish_to: "none" -version: 1.10.1 +version: 1.10.2 environment: sdk: ">=3.0.0 <4.0.0" diff --git a/test/services/json_formatter_service_test.dart b/test/services/json_formatter_service_test.dart new file mode 100644 index 0000000..c4a0ae7 --- /dev/null +++ b/test/services/json_formatter_service_test.dart @@ -0,0 +1,58 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:iot_devkit/services/json_formatter_service.dart'; + +void main() { + group('JsonFormatterService', () { + test('formats and minifies JSON in a background isolate', () async { + const source = '{"device":"dev-1","temperature":23}'; + + final pretty = await JsonFormatterService.transform( + source, + JsonOutputStyle.pretty, + ); + final compact = await JsonFormatterService.transform( + pretty.output, + JsonOutputStyle.compact, + ); + + expect(pretty.output, contains('\n')); + expect(pretty.output, contains(' "temperature": 23')); + expect(compact.output, source); + expect(compact.data, jsonDecode(source)); + }); + + test('preserves CRLF for an already formatted document', () async { + const source = '{\r\n "value": 1\r\n}'; + + final result = await JsonFormatterService.transform( + source, + JsonOutputStyle.pretty, + ); + + expect(result.output, source); + }); + + test('handles a large JSON document without changing its data', () async { + const itemCount = 122985; + final source = jsonEncode({ + 'values': [ + for (var index = 0; index < itemCount; index++) + {'ts': index, 'value': '$index'}, + ], + }); + + final result = await JsonFormatterService.transform( + source, + JsonOutputStyle.pretty, + ); + + expect( + (result.data as Map)['values'], + hasLength(itemCount), + ); + expect(result.output, contains('"value": "122984"')); + }); + }); +} diff --git a/test/widgets/all_features_smoke_test.dart b/test/widgets/all_features_smoke_test.dart index f5b59e9..b2a2ff7 100644 --- a/test/widgets/all_features_smoke_test.dart +++ b/test/widgets/all_features_smoke_test.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -56,7 +58,8 @@ void main() { final enableToggle = find.byKey(const ValueKey('enable_subscriptions_toggle')); expect(enableToggle, findsOneWidget); - await tester.tap(find.descendant(of: enableToggle, matching: find.byType(Switch))); + await tester + .tap(find.descendant(of: enableToggle, matching: find.byType(Switch))); await tester.pumpAndSettle(); // Tap the "ThingsBoard RPC" preset icon (its tooltip identifies it). @@ -119,8 +122,11 @@ void main() { await tester.pump(const Duration(milliseconds: 180)); await _pressButton(tester, 'Format'); - clearLabToasts(); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 100)), + ); await tester.pump(); + clearLabToasts(); final formattedInput = tester .widget(_textFieldWithHint('Paste or type JSON here...')); @@ -132,8 +138,11 @@ void main() { expect(find.text('1/1'), findsOneWidget); await _pressButton(tester, 'Minify'); - clearLabToasts(); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 100)), + ); await tester.pump(); + clearLabToasts(); final minifiedInput = tester .widget(_textFieldWithHint('Paste or type JSON here...')); @@ -143,6 +152,33 @@ void main() { ); }); + testWidgets('json large document smoke uses a bounded preview', + (tester) async { + await _pumpSmokeApp(tester); + await _selectRailDestination(tester, 2); + + final largeJson = jsonEncode({ + 'values': [ + for (var index = 0; index < 25000; index++) + {'ts': index, 'value': '$index'}, + ], + }); + expect(largeJson.length, greaterThan(512 * 1024)); + await tester.enterText( + _textFieldWithHint('Paste or type JSON here...'), + largeJson, + ); + await tester.pump(); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 300)), + ); + await tester.pump(); + + expect(find.text('Large document mode'), findsOneWidget); + expect(find.textContaining('25,000'), findsNothing); + expect(find.byType(CircularProgressIndicator), findsNothing); + }); + testWidgets('certificate smoke: previews ThingsBoard SSL package', (tester) async { await _pumpSmokeApp(tester); diff --git a/test/widgets/json_large_document_view_test.dart b/test/widgets/json_large_document_view_test.dart new file mode 100644 index 0000000..84abbd6 --- /dev/null +++ b/test/widgets/json_large_document_view_test.dart @@ -0,0 +1,21 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:iot_devkit/ui/tools/json_large_document_view.dart'; + +void main() { + test('large text formatter keeps oversized content out of the text field', + () async { + String? capturedText; + final formatter = LargeJsonTextInputFormatter( + maxEditableCharacters: 10, + onLargeText: (text) => capturedText = text, + ); + const oldValue = TextEditingValue(text: '{"a":1}'); + const newValue = TextEditingValue(text: '{"oversized":true}'); + + final result = formatter.formatEditUpdate(oldValue, newValue); + await Future.delayed(Duration.zero); + + expect(result, oldValue); + expect(capturedText, newValue.text); + }); +} diff --git a/test/widgets/json_tree_view_performance_test.dart b/test/widgets/json_tree_view_performance_test.dart new file mode 100644 index 0000000..1f2249c --- /dev/null +++ b/test/widgets/json_tree_view_performance_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:iot_devkit/l10n/generated/app_localizations.dart'; +import 'package:iot_devkit/ui/widgets/json_tree_view.dart'; + +void main() { + testWidgets('large collapsed branches do not eagerly build their children', + (tester) async { + final data = { + 'values': [ + for (var index = 0; index < 10000; index++) + {'ts': index, 'value': '$index'}, + ], + }; + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SingleChildScrollView( + child: JsonTreeView(data: data, isRoot: true), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(JsonTreeView), findsNWidgets(2)); + + await tester.tap(find.byType(ExpansionTile).last); + await tester.pumpAndSettle(); + + expect(find.byType(JsonTreeView), findsNWidgets(102)); + expect(find.text('Load more (100/10000)'), findsOneWidget); + }); +} diff --git a/windows/installer.iss b/windows/installer.iss index 90ce7aa..1fcc6d2 100644 --- a/windows/installer.iss +++ b/windows/installer.iss @@ -2,7 +2,7 @@ ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! #define MyAppName "IoT DevKit" -#define MyAppVersion "1.10.0" +#define MyAppVersion "1.10.2" #define MyAppPublisher "Chen Xu" #define MyAppURL "https://github.com/bcblr1993/iot_devkit_flutter" #define MyAppExeName "iot_devkit.exe"