Skip to content

Commit 331bfaa

Browse files
wpfleger96Mesh-LLM CI
authored andcommitted
feat(mobile): styled code block rendering with copy button and auto-fence
Desktop's code block support (PR #788) landed with a toolbar button but no equivalent rendering or composer UX on mobile. The sent messages used gpt_markdown's default CodeField with hard-coded Material styling that didn't match the design system. Adds _MessageCodeBlock (via gpt_markdown's codeBuilder callback) with rounded border, muted bg, GeistMono font, horizontal scroll, optional language label, and an always-visible copy-to-clipboard button. Also adds _expandFenceIfNeeded to auto-expand ```lang + Enter into a full fenced block template in the composer. A useRef isModifyingText guard prevents the controller listener from re-entering _expandFenceIfNeeded while applyCodeBlock or the expander itself is mutating the controller, which was causing a StackOverflow crash.
1 parent 7a12e50 commit 331bfaa

2 files changed

Lines changed: 186 additions & 20 deletions

File tree

mobile/lib/features/channels/compose_bar.dart

Lines changed: 73 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,13 @@ class ComposeBar extends HookConsumerWidget {
8989

9090
// Typing indicator broadcast — throttled to one event per 3 seconds.
9191
final lastTypingSentMs = useRef(0);
92+
final isModifyingText = useRef(false);
9293

9394
// Detect @mention query and broadcast typing on text / selection change.
9495
useEffect(() {
9596
void listener() {
97+
if (isModifyingText.value) return;
98+
if (_expandFenceIfNeeded(controller, isModifyingText)) return;
9699
final text = controller.text;
97100
final sel = controller.selection;
98101

@@ -316,26 +319,31 @@ class ComposeBar extends HookConsumerWidget {
316319
final sel = controller.selection;
317320
if (!sel.isValid) return;
318321

319-
if (sel.isCollapsed) {
320-
final offset = sel.baseOffset;
321-
const open = '```\n';
322-
const close = '\n```';
323-
final updated =
324-
'${text.substring(0, offset)}$open$close${text.substring(offset)}';
325-
controller.text = updated;
326-
controller.selection = TextSelection.collapsed(
327-
offset: offset + open.length,
328-
);
329-
} else {
330-
final selected = text.substring(sel.start, sel.end);
331-
const open = '```\n';
332-
const close = '\n```';
333-
final updated =
334-
'${text.substring(0, sel.start)}$open$selected$close${text.substring(sel.end)}';
335-
controller.text = updated;
336-
controller.selection = TextSelection.collapsed(
337-
offset: sel.start + open.length + selected.length + close.length,
338-
);
322+
isModifyingText.value = true;
323+
try {
324+
if (sel.isCollapsed) {
325+
final offset = sel.baseOffset;
326+
const open = '```\n';
327+
const close = '\n```';
328+
final updated =
329+
'${text.substring(0, offset)}$open$close${text.substring(offset)}';
330+
controller.text = updated;
331+
controller.selection = TextSelection.collapsed(
332+
offset: offset + open.length,
333+
);
334+
} else {
335+
final selected = text.substring(sel.start, sel.end);
336+
const open = '```\n';
337+
const close = '\n```';
338+
final updated =
339+
'${text.substring(0, sel.start)}$open$selected$close${text.substring(sel.end)}';
340+
controller.text = updated;
341+
controller.selection = TextSelection.collapsed(
342+
offset: sel.start + open.length + selected.length + close.length,
343+
);
344+
}
345+
} finally {
346+
isModifyingText.value = false;
339347
}
340348
focusNode.requestFocus();
341349
}
@@ -584,6 +592,51 @@ void spliceAndMoveCursor(
584592
focusNode.requestFocus();
585593
}
586594

595+
bool _expandFenceIfNeeded(
596+
TextEditingController controller,
597+
ObjectRef<bool> guard,
598+
) {
599+
final text = controller.text;
600+
final sel = controller.selection;
601+
if (!sel.isValid || !sel.isCollapsed) return false;
602+
final cursor = sel.baseOffset;
603+
if (cursor == 0) return false;
604+
if (text[cursor - 1] != '\n') return false;
605+
606+
var lineStart = 0;
607+
for (var i = cursor - 2; i >= 0; i--) {
608+
if (text[i] == '\n') {
609+
lineStart = i + 1;
610+
break;
611+
}
612+
}
613+
614+
final line = text.substring(lineStart, cursor - 1);
615+
final match = RegExp(r'^```([a-zA-Z+#]*)$').firstMatch(line);
616+
if (match == null) return false;
617+
618+
final before = text.substring(0, lineStart);
619+
var fenceCount = 0;
620+
var searchFrom = 0;
621+
while (true) {
622+
final idx = before.indexOf('```', searchFrom);
623+
if (idx == -1) break;
624+
fenceCount++;
625+
searchFrom = idx + 3;
626+
}
627+
if (fenceCount.isOdd) return false;
628+
629+
final lang = match.group(1)!;
630+
final newText =
631+
'${text.substring(0, lineStart)}```$lang\n\n```${text.substring(cursor)}';
632+
final cursorPos = lineStart + '```$lang\n'.length;
633+
guard.value = true;
634+
controller.text = newText;
635+
controller.selection = TextSelection.collapsed(offset: cursorPos);
636+
guard.value = false;
637+
return true;
638+
}
639+
587640
/// Insert [trigger] (e.g. `@` or `#`) at the cursor position, prefixed with
588641
/// a space if needed for word separation. Used by `triggerMention` and
589642
/// `triggerChannel`.

mobile/lib/features/channels/message_content.dart

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'dart:math' as math;
22

33
import 'package:flutter/material.dart';
4+
import 'package:flutter/services.dart';
45
import 'package:gpt_markdown/gpt_markdown.dart';
56
import 'package:gpt_markdown/custom_widgets/markdown_config.dart';
67
import 'package:lucide_icons_flutter/lucide_icons.dart';
@@ -120,6 +121,8 @@ class MessageContent extends StatelessWidget {
120121
finalContent,
121122
style: style,
122123
followLinkColor: false,
124+
codeBuilder: (context, name, code, closed) =>
125+
_MessageCodeBlock(name: name, code: code),
123126
linkBuilder: (context, linkText, url, linkStyle) =>
124127
_buildLink(context, linkText, url, linkStyle, style),
125128
imageBuilder: (context, imageUrl) =>
@@ -443,6 +446,116 @@ class _MediaPreviewFallback extends StatelessWidget {
443446
}
444447
}
445448

449+
class _MessageCodeBlock extends StatefulWidget {
450+
final String name;
451+
final String code;
452+
453+
const _MessageCodeBlock({required this.name, required this.code});
454+
455+
@override
456+
State<_MessageCodeBlock> createState() => _MessageCodeBlockState();
457+
}
458+
459+
class _MessageCodeBlockState extends State<_MessageCodeBlock> {
460+
bool _copied = false;
461+
462+
Future<void> _handleCopy() async {
463+
await Clipboard.setData(ClipboardData(text: widget.code));
464+
if (!mounted) return;
465+
setState(() => _copied = true);
466+
ScaffoldMessenger.of(context).showSnackBar(
467+
const SnackBar(
468+
content: Text('Copied code to clipboard'),
469+
duration: Duration(seconds: 2),
470+
),
471+
);
472+
Future.delayed(const Duration(seconds: 2), () {
473+
if (mounted) setState(() => _copied = false);
474+
});
475+
}
476+
477+
@override
478+
Widget build(BuildContext context) {
479+
return Container(
480+
margin: const EdgeInsets.only(top: Grid.half),
481+
decoration: BoxDecoration(
482+
color: context.colors.surfaceContainerHighest.withValues(alpha: 0.6),
483+
borderRadius: BorderRadius.circular(12),
484+
border: Border.all(
485+
color: context.colors.outline.withValues(alpha: 0.7),
486+
),
487+
),
488+
child: Column(
489+
crossAxisAlignment: CrossAxisAlignment.stretch,
490+
mainAxisSize: MainAxisSize.min,
491+
children: [
492+
if (widget.name.isNotEmpty)
493+
Padding(
494+
padding: const EdgeInsets.only(
495+
left: Grid.twelve,
496+
top: Grid.half + Grid.quarter,
497+
),
498+
child: Text(
499+
widget.name,
500+
style: context.textTheme.labelSmall?.copyWith(
501+
color: context.colors.onSurfaceVariant,
502+
),
503+
),
504+
),
505+
Stack(
506+
children: [
507+
Padding(
508+
padding: EdgeInsets.fromLTRB(
509+
Grid.twelve,
510+
widget.name.isEmpty ? Grid.half + Grid.quarter : Grid.quarter,
511+
44,
512+
Grid.half + Grid.quarter,
513+
),
514+
child: SingleChildScrollView(
515+
scrollDirection: Axis.horizontal,
516+
child: Text(
517+
widget.code,
518+
softWrap: false,
519+
style: TextStyle(
520+
fontFamily: 'GeistMono',
521+
fontSize: 13,
522+
height: 1.5,
523+
color: context.colors.onSurface,
524+
),
525+
),
526+
),
527+
),
528+
Positioned(
529+
top: 0,
530+
right: Grid.quarter,
531+
child: SizedBox(
532+
width: 28,
533+
height: 28,
534+
child: IconButton(
535+
onPressed: _handleCopy,
536+
padding: EdgeInsets.zero,
537+
visualDensity: VisualDensity.compact,
538+
style: IconButton.styleFrom(
539+
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
540+
),
541+
icon: Icon(
542+
_copied ? LucideIcons.check : LucideIcons.copy,
543+
size: 14,
544+
color: _copied
545+
? context.colors.primary
546+
: context.colors.onSurfaceVariant,
547+
),
548+
),
549+
),
550+
),
551+
],
552+
),
553+
],
554+
),
555+
);
556+
}
557+
}
558+
446559
class _MentionMd extends InlineMd {
447560
final Map<String, String> mentionNames;
448561
late final RegExp _exp = _buildPrefixPattern(

0 commit comments

Comments
 (0)