From de4c626de800bce0d42a36a34300ea7b927af8f5 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 30 Aug 2026 18:17:44 +0300 Subject: [PATCH] feat(workspace): expand rich cell inspector modal with XML/HTML formatting, hex viewer, and shortcut support - Add Format XML/HTML, Format Hex, and Wrap/No-Wrap buttons to GridCellInspectorDialog - Add real-time text statistics bar (lines, characters, byte size) - Support keyboard shortcuts in inspector (Ctrl+Enter / Meta+Enter to apply, Alt+N for NULL) - Add Ctrl+I / Meta+I shortcut in VirtualResultGrid to inspect active cell - Add widget tests for XML, Hex formatting, wrap toggles, and shortcut handling Closes #663 --- .../grid_cell_popover_inspector.dart | 430 ++++++++++++------ lib/features/workspace/result_grid_view.dart | 23 +- .../workspace/grid_cell_editor_test.dart | 117 +++++ 3 files changed, 441 insertions(+), 129 deletions(-) diff --git a/lib/features/workspace/grid_cell_popover_inspector.dart b/lib/features/workspace/grid_cell_popover_inspector.dart index 8f0d744..9d1f7b4 100644 --- a/lib/features/workspace/grid_cell_popover_inspector.dart +++ b/lib/features/workspace/grid_cell_popover_inspector.dart @@ -1,14 +1,16 @@ import 'dart:convert'; import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart'; +import 'package:querya_desktop/features/workspace/xml_html_formatter.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; -/// Opens a rich modal inspector for viewing and editing large text or JSON values. +/// Opens a rich modal inspector for viewing and editing large text, JSON, XML, or BLOB values. Future showGridCellInspectorDialog({ required material.BuildContext context, required String columnName, required String initialValue, int? rowIndex, + String? dataTypeName, }) { return showAppDialog( context: context, @@ -16,6 +18,7 @@ Future showGridCellInspectorDialog({ columnName: columnName, initialValue: initialValue, rowIndex: rowIndex, + dataTypeName: dataTypeName, ), ); } @@ -25,11 +28,13 @@ class _GridCellInspectorDialog extends material.StatefulWidget { required this.columnName, required this.initialValue, this.rowIndex, + this.dataTypeName, }); final String columnName; final String initialValue; final int? rowIndex; + final String? dataTypeName; @override material.State<_GridCellInspectorDialog> createState() => @@ -40,6 +45,7 @@ class _GridCellInspectorDialogState extends material.State<_GridCellInspectorDialog> { late final material.TextEditingController _controller; bool _isNull = false; + bool _wordWrap = true; @override void initState() { @@ -48,10 +54,16 @@ class _GridCellInspectorDialogState _controller = material.TextEditingController( text: _isNull ? '' : widget.initialValue, ); + _controller.addListener(_onTextChanged); + } + + void _onTextChanged() { + if (mounted) setState(() {}); } @override void dispose() { + _controller.removeListener(_onTextChanged); _controller.dispose(); super.dispose(); } @@ -82,6 +94,42 @@ class _GridCellInspectorDialogState } } + void _formatXml() { + try { + final pretty = XmlHtmlFormatter.format(_controller.text); + setState(() { + _isNull = false; + _controller.text = pretty; + }); + } catch (_) {} + } + + void _formatHex() { + var raw = _controller.text.trim(); + var prefix = ''; + if (raw.startsWith(r'\x') || raw.startsWith(r'\X')) { + prefix = r'\x'; + raw = raw.substring(2); + } else if (raw.startsWith('0x') || raw.startsWith('0X')) { + prefix = '0x'; + raw = raw.substring(2); + } + final clean = raw.replaceAll(RegExp(r'\s+'), '').toUpperCase(); + if (clean.isEmpty) return; + + final pairs = []; + for (var i = 0; i < clean.length; i += 2) { + final end = (i + 2 <= clean.length) ? i + 2 : clean.length; + pairs.add(clean.substring(i, end)); + } + final formatted = + prefix.isNotEmpty ? '$prefix ${pairs.join(' ')}' : pairs.join(' '); + setState(() { + _isNull = false; + _controller.text = formatted; + }); + } + void _setNull() { setState(() { _isNull = true; @@ -103,156 +151,282 @@ class _GridCellInspectorDialogState return false; } + bool _isXml() { + final text = _controller.text.trim(); + if (text.startsWith('<') && text.endsWith('>')) { + return XmlHtmlFormatter.validate(text) == null; + } + return false; + } + + bool _isHex() { + final text = _controller.text.trim(); + if (text.length < 4) return false; + var hex = text; + if (hex.startsWith(r'\x') || + hex.startsWith(r'\X') || + hex.startsWith('0x') || + hex.startsWith('0X')) { + hex = hex.substring(2); + } + final clean = hex.replaceAll(RegExp(r'\s+'), ''); + return clean.isNotEmpty && + clean.length.isEven && + RegExp(r'^[0-9a-fA-F]+$').hasMatch(clean); + } + + void _apply() { + final result = _isNull ? 'NULL' : _controller.text; + material.Navigator.of(context).pop(result); + } + @override material.Widget build(material.BuildContext context) { final cs = Theme.of(context).colorScheme; final rowLabel = widget.rowIndex != null ? ' (Row ${widget.rowIndex! + 1})' : ''; + final text = _isNull ? '' : _controller.text; + final linesCount = text.isEmpty ? 0 : '\n'.allMatches(text).length + 1; + final charsCount = text.length; + final bytesCount = utf8.encode(text).length; - return material.Dialog( - backgroundColor: cs.card, - shape: material.RoundedRectangleBorder( - borderRadius: material.BorderRadius.circular(8), - side: material.BorderSide(color: cs.border, width: 1), - ), - child: material.ConstrainedBox( - constraints: const material.BoxConstraints( - minWidth: 500, - maxWidth: 720, - minHeight: 380, - maxHeight: 560, + return material.CallbackShortcuts( + bindings: { + const material.SingleActivator( + LogicalKeyboardKey.enter, + control: true, + ): _apply, + const material.SingleActivator( + LogicalKeyboardKey.enter, + meta: true, + ): _apply, + const material.SingleActivator( + LogicalKeyboardKey.numpadEnter, + control: true, + ): _apply, + const material.SingleActivator( + LogicalKeyboardKey.numpadEnter, + meta: true, + ): _apply, + const material.SingleActivator( + LogicalKeyboardKey.keyN, + alt: true, + ): _setNull, + }, + child: material.Dialog( + backgroundColor: cs.card, + shape: material.RoundedRectangleBorder( + borderRadius: material.BorderRadius.circular(8), + side: material.BorderSide(color: cs.border, width: 1), ), - child: material.Padding( - padding: const material.EdgeInsets.all(16), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - // Header - material.Row( - children: [ - material.Icon( - material.Icons.data_object_rounded, - size: 18, - color: cs.primary, - ), - const Gap(8), - material.Expanded( - child: Text( - 'Edit ${widget.columnName}$rowLabel', - ).semiBold(), - ), - if (_isJson()) ...[ + child: material.ConstrainedBox( + constraints: const material.BoxConstraints( + minWidth: 520, + maxWidth: 760, + minHeight: 400, + maxHeight: 580, + ), + child: material.Padding( + padding: const material.EdgeInsets.all(16), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Header + material.Row( + children: [ + material.Icon( + material.Icons.data_object_rounded, + size: 18, + color: cs.primary, + ), + const Gap(8), + material.Expanded( + child: Text( + 'Edit ${widget.columnName}$rowLabel', + ).semiBold(), + ), + if (_isJson()) ...[ + GhostButton( + density: ButtonDensity.compact, + onPressed: _formatJson, + leading: const material.Icon( + material.Icons.format_align_left_rounded, + size: 14, + ), + child: const Text('Format JSON'), + ), + const Gap(6), + GhostButton( + density: ButtonDensity.compact, + onPressed: _minifyJson, + leading: const material.Icon( + material.Icons.compress_rounded, + size: 14, + ), + child: const Text('Minify'), + ), + const Gap(6), + ], + if (_isXml()) ...[ + GhostButton( + density: ButtonDensity.compact, + onPressed: _formatXml, + leading: const material.Icon( + material.Icons.code_rounded, + size: 14, + ), + child: const Text('Format XML'), + ), + const Gap(6), + ], + if (_isHex()) ...[ + GhostButton( + density: ButtonDensity.compact, + onPressed: _formatHex, + leading: const material.Icon( + material.Icons.grid_view_rounded, + size: 14, + ), + child: const Text('Format Hex'), + ), + const Gap(6), + ], GhostButton( density: ButtonDensity.compact, - onPressed: _formatJson, - leading: const material.Icon( - material.Icons.format_align_left_rounded, + onPressed: () => setState(() => _wordWrap = !_wordWrap), + leading: material.Icon( + _wordWrap + ? material.Icons.wrap_text_rounded + : material.Icons.notes_rounded, size: 14, ), - child: const Text('Format JSON'), + child: Text(_wordWrap ? 'Wrap' : 'No Wrap'), ), const Gap(6), GhostButton( density: ButtonDensity.compact, - onPressed: _minifyJson, - leading: const material.Icon( - material.Icons.compress_rounded, - size: 14, - ), - child: const Text('Minify'), + onPressed: _isNull ? null : _setNull, + child: const Text('Set NULL'), ), - const Gap(6), ], - GhostButton( - density: ButtonDensity.compact, - onPressed: _isNull ? null : _setNull, - child: const Text('Set NULL'), - ), - ], - ), - const Gap(12), + ), + const Gap(12), - // Editor Body - material.Expanded( - child: material.Container( - decoration: material.BoxDecoration( - color: cs.background, - borderRadius: material.BorderRadius.circular(6), - border: material.Border.all( - color: _isNull - ? cs.primary.withValues(alpha: 0.5) - : cs.border, - width: 1, + // Editor Body + material.Expanded( + child: material.Container( + decoration: material.BoxDecoration( + color: cs.background, + borderRadius: material.BorderRadius.circular(6), + border: material.Border.all( + color: _isNull + ? cs.primary.withValues(alpha: 0.5) + : cs.border, + width: 1, + ), ), - ), - child: _isNull - ? material.Center( - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - children: [ - const Text('Value is NULL').muted().semiBold(), - const Gap(8), - GhostButton( - density: ButtonDensity.compact, - onPressed: () => setState(() => _isNull = false), - child: const Text('Enter text value'), + child: _isNull + ? material.Center( + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + const Text('Value is NULL').muted().semiBold(), + const Gap(8), + GhostButton( + density: ButtonDensity.compact, + onPressed: () => + setState(() => _isNull = false), + child: const Text('Enter text value'), + ), + ], + ), + ) + : _wordWrap + ? material.TextField( + controller: _controller, + maxLines: null, + expands: true, + autofocus: true, + style: const material.TextStyle( + fontFamily: 'monospace', + fontSize: 12, + ), + decoration: const material.InputDecoration( + border: material.InputBorder.none, + contentPadding: material.EdgeInsets.all(12), + hintText: 'Enter cell value…', + ), + ) + : material.SingleChildScrollView( + scrollDirection: material.Axis.horizontal, + child: material.SingleChildScrollView( + scrollDirection: material.Axis.vertical, + child: material.SizedBox( + width: 3000, + child: material.TextField( + controller: _controller, + maxLines: null, + autofocus: true, + style: const material.TextStyle( + fontFamily: 'monospace', + fontSize: 12, + ), + decoration: const material.InputDecoration( + border: material.InputBorder.none, + contentPadding: + material.EdgeInsets.all(12), + hintText: 'Enter cell value…', + ), + ), + ), + ), ), - ], - ), - ) - : material.TextField( - controller: _controller, - maxLines: null, - expands: true, - autofocus: true, - style: const material.TextStyle( - fontFamily: 'monospace', - fontSize: 12, - ), - decoration: const material.InputDecoration( - border: material.InputBorder.none, - contentPadding: material.EdgeInsets.all(12), - hintText: 'Enter cell value…', - ), - ), + ), ), - ), - const Gap(12), + const Gap(12), - // Footer - material.Row( - children: [ - GhostButton( - density: ButtonDensity.compact, - onPressed: () { - Clipboard.setData( - ClipboardData(text: _isNull ? 'NULL' : _controller.text), - ); - }, - leading: const material.Icon( - material.Icons.copy_rounded, - size: 14, + // Footer + material.Row( + children: [ + GhostButton( + density: ButtonDensity.compact, + onPressed: () { + Clipboard.setData( + ClipboardData( + text: _isNull ? 'NULL' : _controller.text), + ); + }, + leading: const material.Icon( + material.Icons.copy_rounded, + size: 14, + ), + child: const Text('Copy'), ), - child: const Text('Copy'), - ), - const material.Spacer(), - OutlineButton( - density: ButtonDensity.compact, - onPressed: () => material.Navigator.of(context).pop(null), - child: const Text('Cancel'), - ), - const Gap(8), - PrimaryButton( - density: ButtonDensity.compact, - onPressed: () { - final result = _isNull ? 'NULL' : _controller.text; - material.Navigator.of(context).pop(result); - }, - child: const Text('Apply'), - ), - ], - ), - ], + const Gap(12), + if (!_isNull) + material.Text( + '$linesCount ${linesCount == 1 ? "line" : "lines"} · $charsCount chars · $bytesCount B', + style: material.TextStyle( + fontSize: 11, + color: cs.mutedForeground, + ), + ), + const material.Spacer(), + OutlineButton( + density: ButtonDensity.compact, + onPressed: () => material.Navigator.of(context).pop(null), + child: const Text('Cancel'), + ), + const Gap(8), + PrimaryButton( + density: ButtonDensity.compact, + onPressed: _apply, + child: const Text('Apply'), + ), + ], + ), + ], + ), ), ), ), diff --git a/lib/features/workspace/result_grid_view.dart b/lib/features/workspace/result_grid_view.dart index b28004e..6732d6c 100644 --- a/lib/features/workspace/result_grid_view.dart +++ b/lib/features/workspace/result_grid_view.dart @@ -592,7 +592,12 @@ class _VirtualResultGridState extends material.State { } Future _openInspector(int row, int column) async { - if (row < 0 || row >= _sortedRows.length || column < 0 || column >= widget.columns.length) return; + if (row < 0 || + row >= _sortedRows.length || + column < 0 || + column >= widget.columns.length) { + return; + } final colName = widget.columns[column]; final currentVal = column < _sortedRows[row].length ? _sortedRows[row][column] : ''; final result = await showGridCellInspectorDialog( @@ -1055,6 +1060,22 @@ class _VirtualResultGridState extends material.State { _openInspector(_selection!.startRow, _selection!.startColumn); } }, + const material.SingleActivator( + LogicalKeyboardKey.keyI, + control: true, + ): () { + if (_selection != null && _editingCell == null) { + _openInspector(_selection!.startRow, _selection!.startColumn); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.keyI, + meta: true, + ): () { + if (_selection != null && _editingCell == null) { + _openInspector(_selection!.startRow, _selection!.startColumn); + } + }, const material.SingleActivator( LogicalKeyboardKey.keyN, alt: true, diff --git a/test/features/workspace/grid_cell_editor_test.dart b/test/features/workspace/grid_cell_editor_test.dart index 51b4342..82203dd 100644 --- a/test/features/workspace/grid_cell_editor_test.dart +++ b/test/features/workspace/grid_cell_editor_test.dart @@ -172,6 +172,123 @@ void main() { expect(result, 'NULL'); }); + + testWidgets('formats XML content in dialog', (tester) async { + String? result; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () async { + result = await showGridCellInspectorDialog( + context: context, + columnName: 'payload', + initialValue: 'data', + rowIndex: 0, + ); + }, + child: const Text('Open Dialog'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Dialog')); + await tester.pumpAndSettle(); + + expect(find.text('Format XML'), findsOneWidget); + + await tester.tap(find.text('Format XML')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Apply')); + await tester.pumpAndSettle(); + + expect(result, isNotNull); + expect(result!.contains('\n'), isTrue); + expect(result!.contains(' material.ElevatedButton( + onPressed: () async { + result = await showGridCellInspectorDialog( + context: context, + columnName: 'bin_data', + initialValue: r'\xdeadbeef1234', + rowIndex: 0, + ); + }, + child: const Text('Open Dialog'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Dialog')); + await tester.pumpAndSettle(); + + expect(find.text('Format Hex'), findsOneWidget); + + await tester.tap(find.text('Format Hex')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Apply')); + await tester.pumpAndSettle(); + + expect(result, r'\x DE AD BE EF 12 34'); + }); + + testWidgets('toggles wrap and applies with Ctrl+Enter shortcut', (tester) async { + String? result; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () async { + result = await showGridCellInspectorDialog( + context: context, + columnName: 'notes', + initialValue: 'Hello world', + rowIndex: 0, + ); + }, + child: const Text('Open Dialog'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Dialog')); + await tester.pumpAndSettle(); + + // Toggle wrap + expect(find.text('Wrap'), findsOneWidget); + await tester.tap(find.text('Wrap')); + await tester.pumpAndSettle(); + expect(find.text('No Wrap'), findsOneWidget); + + // Verify metrics info text rendered + expect(find.textContaining('1 line · 11 chars'), findsOneWidget); + + // Send Ctrl+Enter to apply + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + + expect(result, 'Hello world'); + }); }); group('VirtualResultGrid Inline Editing Integration', () {