Skip to content

Commit 8d8398e

Browse files
Merge pull request #588 from QueryaHub/issue/571-syntax-highlighting-value-panel
feat(value-panel): add syntax highlighting and XML/HTML formatter with validation (#571, #572)
2 parents 9111336 + 0087ae4 commit 8d8398e

3 files changed

Lines changed: 585 additions & 0 deletions

File tree

Lines changed: 396 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,396 @@
1+
import 'dart:convert';
2+
import 'package:flutter/material.dart' as material;
3+
import 'package:flutter/services.dart';
4+
import 'package:querya_desktop/core/editor/querya_code_editor.dart';
5+
import 'package:querya_desktop/core/editor/querya_code_language.dart';
6+
import 'package:shadcn_flutter/shadcn_flutter.dart';
7+
8+
import 'xml_html_formatter.dart';
9+
10+
/// Language mode for Value Panel inspector.
11+
enum ValuePanelLanguage {
12+
auto,
13+
json,
14+
xml,
15+
sql,
16+
text,
17+
}
18+
19+
/// Collapsible right-hand side panel for inspecting cell content in detail.
20+
class DataGridValuePanel extends material.StatefulWidget {
21+
const DataGridValuePanel({
22+
super.key,
23+
required this.columnName,
24+
required this.cellValue,
25+
required this.rowIndex,
26+
required this.onClose,
27+
this.onUpdateValue,
28+
});
29+
30+
final String columnName;
31+
final String cellValue;
32+
final int? rowIndex;
33+
final material.VoidCallback onClose;
34+
final ValueChanged<String>? onUpdateValue;
35+
36+
@override
37+
material.State<DataGridValuePanel> createState() => _DataGridValuePanelState();
38+
}
39+
40+
class _DataGridValuePanelState extends material.State<DataGridValuePanel> {
41+
late final material.TextEditingController _controller;
42+
ValuePanelLanguage _selectedLanguage = ValuePanelLanguage.auto;
43+
String? _validationError;
44+
bool _wordWrap = true;
45+
46+
@override
47+
void initState() {
48+
super.initState();
49+
_controller = material.TextEditingController(text: _formatInitialValue(widget.cellValue));
50+
_controller.addListener(_validateContent);
51+
_validateContent();
52+
}
53+
54+
@override
55+
void didUpdateWidget(covariant DataGridValuePanel oldWidget) {
56+
super.didUpdateWidget(oldWidget);
57+
if (oldWidget.cellValue != widget.cellValue) {
58+
_controller.text = _formatInitialValue(widget.cellValue);
59+
_validateContent();
60+
}
61+
}
62+
63+
@override
64+
void dispose() {
65+
_controller.removeListener(_validateContent);
66+
_controller.dispose();
67+
super.dispose();
68+
}
69+
70+
String _formatInitialValue(String input) {
71+
final trimmed = input.trim();
72+
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) ||
73+
(trimmed.startsWith('[') && trimmed.endsWith(']'))) {
74+
try {
75+
final parsed = jsonDecode(trimmed);
76+
return const JsonEncoder.withIndent(' ').convert(parsed);
77+
} catch (_) {}
78+
} else if (trimmed.startsWith('<') && trimmed.endsWith('>')) {
79+
try {
80+
return XmlHtmlFormatter.format(trimmed);
81+
} catch (_) {}
82+
}
83+
return input;
84+
}
85+
86+
ValuePanelLanguage get _effectiveLanguage {
87+
if (_selectedLanguage != ValuePanelLanguage.auto) {
88+
return _selectedLanguage;
89+
}
90+
final trimmed = _controller.text.trim();
91+
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) ||
92+
(trimmed.startsWith('[') && trimmed.endsWith(']'))) {
93+
return ValuePanelLanguage.json;
94+
}
95+
if (trimmed.startsWith('<') && trimmed.endsWith('>')) {
96+
return ValuePanelLanguage.xml;
97+
}
98+
final upper = trimmed.toUpperCase();
99+
if (upper.startsWith('SELECT ') ||
100+
upper.startsWith('INSERT ') ||
101+
upper.startsWith('UPDATE ') ||
102+
upper.startsWith('CREATE ') ||
103+
upper.startsWith('WITH ')) {
104+
return ValuePanelLanguage.sql;
105+
}
106+
return ValuePanelLanguage.text;
107+
}
108+
109+
void _validateContent() {
110+
final text = _controller.text.trim();
111+
if (text.isEmpty) {
112+
if (_validationError != null) {
113+
setState(() => _validationError = null);
114+
}
115+
return;
116+
}
117+
118+
final lang = _effectiveLanguage;
119+
String? err;
120+
121+
if (lang == ValuePanelLanguage.json) {
122+
try {
123+
jsonDecode(text);
124+
} catch (e) {
125+
err = 'Invalid JSON: $e';
126+
}
127+
} else if (lang == ValuePanelLanguage.xml) {
128+
err = XmlHtmlFormatter.validate(text);
129+
}
130+
131+
if (err != _validationError) {
132+
setState(() => _validationError = err);
133+
}
134+
}
135+
136+
void _formatCode() {
137+
final lang = _effectiveLanguage;
138+
if (lang == ValuePanelLanguage.json) {
139+
try {
140+
final parsed = jsonDecode(_controller.text);
141+
final pretty = const JsonEncoder.withIndent(' ').convert(parsed);
142+
setState(() => _controller.text = pretty);
143+
} catch (_) {}
144+
} else if (lang == ValuePanelLanguage.xml) {
145+
final pretty = XmlHtmlFormatter.format(_controller.text);
146+
setState(() => _controller.text = pretty);
147+
}
148+
}
149+
150+
void _minifyCode() {
151+
final lang = _effectiveLanguage;
152+
if (lang == ValuePanelLanguage.json) {
153+
try {
154+
final parsed = jsonDecode(_controller.text);
155+
final compact = jsonEncode(parsed);
156+
setState(() => _controller.text = compact);
157+
} catch (_) {}
158+
} else if (lang == ValuePanelLanguage.xml) {
159+
final compact = XmlHtmlFormatter.minify(_controller.text);
160+
setState(() => _controller.text = compact);
161+
}
162+
}
163+
164+
QueryaCodeLanguage _toQueryaLanguage(ValuePanelLanguage lang) {
165+
switch (lang) {
166+
case ValuePanelLanguage.json:
167+
return QueryaCodeLanguage.json;
168+
case ValuePanelLanguage.sql:
169+
return QueryaCodeLanguage.sql;
170+
default:
171+
return QueryaCodeLanguage.plain;
172+
}
173+
}
174+
175+
@override
176+
material.Widget build(material.BuildContext context) {
177+
final cs = Theme.of(context).colorScheme;
178+
final activeLang = _effectiveLanguage;
179+
180+
return material.Container(
181+
width: 340,
182+
decoration: material.BoxDecoration(
183+
color: cs.card,
184+
border: material.Border(
185+
left: material.BorderSide(
186+
color: cs.border.withValues(alpha: 0.35),
187+
width: 1,
188+
),
189+
),
190+
),
191+
child: material.Column(
192+
crossAxisAlignment: material.CrossAxisAlignment.stretch,
193+
children: [
194+
// Panel Header
195+
material.Container(
196+
height: 36,
197+
padding: const material.EdgeInsets.symmetric(horizontal: 10),
198+
decoration: material.BoxDecoration(
199+
border: material.Border(
200+
bottom: material.BorderSide(
201+
color: cs.border.withValues(alpha: 0.35),
202+
width: 1,
203+
),
204+
),
205+
),
206+
child: material.Row(
207+
children: [
208+
material.Icon(
209+
material.Icons.data_object_rounded,
210+
size: 15,
211+
color: cs.primary,
212+
),
213+
const Gap(6),
214+
material.Expanded(
215+
child: Text(
216+
'${widget.columnName}${widget.rowIndex != null ? ' [Row ${widget.rowIndex! + 1}]' : ''}',
217+
maxLines: 1,
218+
overflow: material.TextOverflow.ellipsis,
219+
).small().semiBold(),
220+
),
221+
material.IconButton(
222+
icon: const material.Icon(material.Icons.close, size: 14),
223+
padding: material.EdgeInsets.zero,
224+
constraints: const material.BoxConstraints(minWidth: 24, minHeight: 24),
225+
color: cs.mutedForeground,
226+
onPressed: widget.onClose,
227+
),
228+
],
229+
),
230+
),
231+
232+
// Toolbar with language selector and actions
233+
material.Container(
234+
padding: const material.EdgeInsets.symmetric(horizontal: 8, vertical: 4),
235+
color: cs.background.withValues(alpha: 0.4),
236+
child: material.Row(
237+
children: [
238+
// Language Dropdown / Pill
239+
material.DropdownButton<ValuePanelLanguage>(
240+
value: _selectedLanguage,
241+
isDense: true,
242+
underline: const material.SizedBox(),
243+
icon: const material.Icon(material.Icons.arrow_drop_down, size: 16),
244+
style: TextStyle(
245+
fontSize: 11,
246+
color: cs.foreground,
247+
fontWeight: FontWeight.w600,
248+
),
249+
items: const [
250+
material.DropdownMenuItem(
251+
value: ValuePanelLanguage.auto,
252+
child: Text('Auto'),
253+
),
254+
material.DropdownMenuItem(
255+
value: ValuePanelLanguage.json,
256+
child: Text('JSON'),
257+
),
258+
material.DropdownMenuItem(
259+
value: ValuePanelLanguage.xml,
260+
child: Text('XML/HTML'),
261+
),
262+
material.DropdownMenuItem(
263+
value: ValuePanelLanguage.sql,
264+
child: Text('SQL'),
265+
),
266+
material.DropdownMenuItem(
267+
value: ValuePanelLanguage.text,
268+
child: Text('Plain Text'),
269+
),
270+
],
271+
onChanged: (val) {
272+
if (val != null) {
273+
setState(() => _selectedLanguage = val);
274+
_validateContent();
275+
}
276+
},
277+
),
278+
const Gap(6),
279+
if (activeLang == ValuePanelLanguage.json || activeLang == ValuePanelLanguage.xml) ...[
280+
material.TextButton(
281+
onPressed: _formatCode,
282+
style: material.TextButton.styleFrom(
283+
padding: const material.EdgeInsets.symmetric(horizontal: 6, vertical: 2),
284+
minimumSize: material.Size.zero,
285+
tapTargetSize: material.MaterialTapTargetSize.shrinkWrap,
286+
),
287+
child: const Text('Format').small(),
288+
),
289+
const Gap(4),
290+
material.TextButton(
291+
onPressed: _minifyCode,
292+
style: material.TextButton.styleFrom(
293+
padding: const material.EdgeInsets.symmetric(horizontal: 6, vertical: 2),
294+
minimumSize: material.Size.zero,
295+
tapTargetSize: material.MaterialTapTargetSize.shrinkWrap,
296+
),
297+
child: const Text('Minify').small(),
298+
),
299+
],
300+
const material.Spacer(),
301+
material.IconButton(
302+
icon: material.Icon(
303+
_wordWrap ? material.Icons.wrap_text : material.Icons.notes,
304+
size: 14,
305+
),
306+
padding: material.EdgeInsets.zero,
307+
constraints: const material.BoxConstraints(minWidth: 24, minHeight: 24),
308+
color: _wordWrap ? cs.primary : cs.mutedForeground,
309+
onPressed: () => setState(() => _wordWrap = !_wordWrap),
310+
),
311+
material.IconButton(
312+
icon: const material.Icon(material.Icons.copy_rounded, size: 14),
313+
padding: material.EdgeInsets.zero,
314+
constraints: const material.BoxConstraints(minWidth: 24, minHeight: 24),
315+
color: cs.mutedForeground,
316+
onPressed: () {
317+
Clipboard.setData(ClipboardData(text: _controller.text));
318+
},
319+
),
320+
],
321+
),
322+
),
323+
324+
// Validation Error Banner (if any)
325+
if (_validationError != null)
326+
material.Container(
327+
padding: const material.EdgeInsets.symmetric(horizontal: 8, vertical: 4),
328+
color: cs.destructive.withValues(alpha: 0.12),
329+
child: material.Row(
330+
children: [
331+
material.Icon(
332+
material.Icons.warning_amber_rounded,
333+
size: 14,
334+
color: cs.destructive,
335+
),
336+
const Gap(6),
337+
material.Expanded(
338+
child: Text(
339+
_validationError!,
340+
maxLines: 2,
341+
overflow: material.TextOverflow.ellipsis,
342+
style: TextStyle(
343+
fontSize: 10.5,
344+
color: cs.destructive,
345+
fontWeight: FontWeight.w500,
346+
),
347+
),
348+
),
349+
],
350+
),
351+
),
352+
353+
// Code Editor Area with Syntax Highlighting
354+
material.Expanded(
355+
child: material.Padding(
356+
padding: const material.EdgeInsets.all(6),
357+
child: QueryaCodeEditor(
358+
controller: _controller,
359+
language: _toQueryaLanguage(activeLang),
360+
enableHighlighting: true,
361+
fontSize: 12,
362+
variant: QueryaCodeEditorVariant.material,
363+
),
364+
),
365+
),
366+
367+
// Apply button if editable
368+
if (widget.onUpdateValue != null)
369+
material.Container(
370+
padding: const material.EdgeInsets.all(8),
371+
decoration: material.BoxDecoration(
372+
border: material.Border(
373+
top: material.BorderSide(
374+
color: cs.border.withValues(alpha: 0.35),
375+
width: 1,
376+
),
377+
),
378+
),
379+
child: material.ElevatedButton(
380+
onPressed: () {
381+
widget.onUpdateValue!(_controller.text);
382+
},
383+
style: material.ElevatedButton.styleFrom(
384+
backgroundColor: cs.primary,
385+
foregroundColor: cs.primaryForeground,
386+
padding: const material.EdgeInsets.symmetric(vertical: 8),
387+
minimumSize: material.Size.zero,
388+
),
389+
child: const Text('Update Cell Value').small().bold(),
390+
),
391+
),
392+
],
393+
),
394+
);
395+
}
396+
}

0 commit comments

Comments
 (0)