import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:re_editor/re_editor.dart'; import 'package:re_highlight/styles/github-dark.dart'; import 'package:re_highlight/styles/github.dart'; import '../../core/app_controller.dart'; import '../../core/failure.dart'; import '../../l10n/strings.dart'; import '../../services/editor/languages.dart'; import '../../services/editor/sources.dart'; import '../../services/editor/text_codec.dart'; import '../../services/editor/validation.dart'; import '../../services/ssh_service.dart'; import '../../services/vault.dart'; import '../common.dart'; import 'find_panel.dart'; import 'save_as.dart'; /// Files above this size are edited without syntax colouring by default. const highlightLimit = 1024 * 1024; /// Folding by indentation for languages without braces (YAML, Python…): /// a block runs while the following lines are indented deeper. class IndentChunkAnalyzer implements CodeChunkAnalyzer { const IndentChunkAnalyzer(); static int _indent(String s) { var n = 0; for (final c in s.codeUnits) { if (c == 0x20) { n++; } else if (c == 0x09) { n += 4; } else { break; } } return n; } @override List run(CodeLines codeLines) { final chunks = []; final n = codeLines.length; if (n > 200000) return chunks; final indents = List.generate(n, (i) { final t = codeLines[i].text; return t.trim().isEmpty ? null : _indent(t); }); for (var i = 0; i < n; i++) { final base = indents[i]; if (base == null) continue; var end = i, j = i + 1; while (j < n) { final d = indents[j]; if (d != null) { if (d <= base) break; end = j; } j++; } if (end > i) chunks.add(CodeChunk(i, end + 1)); } return chunks; } } /// What the editor holds besides the text. class _Format { TextEncoding encoding; LineBreak lineBreak; final bool mixed, exact; _Format( this.encoding, this.lineBreak, { this.mixed = false, this.exact = true, }); } class EditorPage extends StatefulWidget { final AppController controller; final FileSource source; /// Already loaded content (viewers that offer "open as text"). final Uint8List? bytes; /// A recovered draft: its text replaces the loaded content. final Draft? draft; const EditorPage( this.controller, this.source, { this.bytes, this.draft, super.key, }); @override State createState() => _EditorPageState(); } class _EditorPageState extends State with WidgetsBindingObserver { late FileSource source = widget.source; CodeLineEditingController? editing; CodeFindController? find; final scroll = CodeScrollController(); final focus = FocusNode(); Uint8List? original; _Format? format; Syntax syntax = plainText; bool loading = true, saving = false, dirty = false, highlight = true; String? error, notice; String? reloadBlocked; bool _registered = false; AppController get c => widget.controller; Map get prefs => c.editorPrefs; double get fontSize => (prefs['fontSize'] as num?)?.toDouble() ?? 14; bool get wrap => prefs['wrap'] == true; bool get tabs => prefs['tabs'] == true; int get indent => (prefs['indent'] as int?) ?? 2; bool get lineNumbers => prefs['lineNumbers'] != false; bool get readOnly => !source.canSave && source.kind != 'new'; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); c.draftSources.add(_draft); _registered = true; unawaited(_load()); } @override void dispose() { // Stops the editor's cursor blink timer before its controller goes. focus.unfocus(); WidgetsBinding.instance.removeObserver(this); if (_registered) c.draftSources.remove(_draft); editing?.dispose(); find?.dispose(); focus.dispose(); super.dispose(); } /// The unsaved buffer, kept encrypted across a lock. Draft? _draft() { final e = editing, f = format; if (!dirty || e == null || f == null) return null; return Draft({ 'source': source.toJson(), 'name': source.name, 'location': source.location, 'text': e.text, 'encoding': f.encoding.name, 'lineBreak': f.lineBreak.name, 'syntax': syntax.id, 'savedAt': DateTime.now().toUtc().toIso8601String(), }); } Future _load({TextEncoding? encoding, bool asText = false}) async { setState(() { loading = true; error = null; }); try { final restoring = widget.draft != null && editing == null; final Uint8List bytes; if (restoring) { // The draft text wins; the file is read only to know its current // state for the conflict check, and may be unreachable now. Uint8List? current; try { current = await source.load(); } catch (_) { current = null; } bytes = current ?? Uint8List(0); encoding ??= TextEncoding.values .asNameMap()[widget.draft!.json['encoding']]; if (current != null && looksBinary(current)) encoding = null; } else { bytes = original != null && encoding != null ? original! : widget.bytes ?? await source.load(); } if (bytes.length > maxEditableBytes) throw const Failure('fileTooLarge'); if (!restoring && encoding == null && !asText && looksBinary(bytes)) { original = bytes; if (mounted) { setState(() { loading = false; error = 'binaryFile'; }); } return; } DecodedText decoded; try { decoded = await decodeInBackground(bytes, encoding: encoding); } on UnknownEncoding { if (restoring) { decoded = _empty(encoding); } else { original = bytes; if (!mounted) return; final chosen = await _chooseEncoding(unknown: true); if (chosen == null) { if (mounted) { setState(() { loading = false; error = 'unknownEncoding'; }); } return; } return await _load(encoding: chosen); } } on FormatException { if (restoring) { decoded = _empty(encoding); } else { original = bytes; if (mounted) { setState(() { loading = false; error = 'wrongEncoding'; }); } return; } } original = bytes; final draft = widget.draft; final text = draft != null && editing == null ? draft.json['text'] as String : decoded.text; final f = _Format( draft == null || editing != null ? decoded.encoding : TextEncoding.values.asNameMap()[draft.json['encoding']] ?? decoded.encoding, draft == null || editing != null ? decoded.lineBreaks.dominant : LineBreak.values.asNameMap()[draft.json['lineBreak']] ?? decoded.lineBreaks.dominant, mixed: decoded.lineBreaks.mixed, exact: decoded.exact, ); final lines = text.length > 256 * 1024 ? await text.codeLinesAsync : text.codeLines; if (!mounted) return; final previous = editing; final previousFind = find; final e = CodeLineEditingController( codeLines: lines, options: CodeLineOptions( lineBreak: _break(f.lineBreak), indentSize: indent, ), ); e.addListener(_changed); setState(() { editing = e; find = CodeFindController(e); format = f; syntax = draft != null && previous == null ? syntaxById(draft.json['syntax'] as String? ?? '') : detectSyntax( source.kind == 'remote' ? (source as RemoteSource).path : source.name, content: text.length > 4096 ? text.substring(0, 4096) : text, ); highlight = bytes.length <= highlightLimit; dirty = draft != null && previous == null; loading = false; notice = f.mixed ? 'mixedLineBreaks' : !f.exact ? 'notExactRoundTrip' : null; }); WidgetsBinding.instance.addPostFrameCallback((_) { previous?.dispose(); previousFind?.dispose(); }); } catch (e) { if (mounted) { setState(() { loading = false; error = failureCode(e); }); } } } static DecodedText _empty(TextEncoding? e) => DecodedText( '', e ?? TextEncoding.utf8, const LineBreakInfo(0, 0, 0), false, exact: true, ); static TextLineBreak _break(LineBreak b) => switch (b) { LineBreak.lf => TextLineBreak.lf, LineBreak.crlf => TextLineBreak.crlf, LineBreak.cr => TextLineBreak.cr, }; String? _lastText; void _changed() { final e = editing; if (e == null) return; // Selection moves notify too; only text changes make the file dirty. final lines = e.codeLines; final key = '${lines.length}:${identityHashCode(lines)}'; if (key == _lastText) { setState(() {}); // cursor position in the status bar return; } final first = _lastText == null; _lastText = key; if (first) return; setState(() => dirty = true); } Future _chooseEncoding({bool unknown = false}) => showDialog( context: context, builder: (ctx) => SimpleDialog( title: Text(ctx.tr(unknown ? 'unknownEncoding' : 'reopenEncoding')), children: [ if (unknown) Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: Text(ctx.tr('chooseEncodingAdvice')), ), for (final e in TextEncoding.values) SimpleDialogOption( onPressed: () => Navigator.pop(ctx, e), child: Text(e.label), ), ], ), ); // --------------------------------------------------------------------------- // Saving Future _encoded() async { final e = editing!, f = format!; final text = e.text; try { return await encodeInBackground(text, f.encoding); } on UnencodableText catch (u) { if (!mounted) return null; final utf8 = await confirm( context, 'unencodableTitle', detail: '${context.tr('unencodableBody')}\n' '${u.encoding.label}: "${u.sample}" ×${u.count}', ); if (!utf8) return null; setState(() => f.encoding = TextEncoding.utf8); return encodeText(text, TextEncoding.utf8); } } Future save({bool force = false, bool inPlace = false}) async { if (saving || editing == null) return; if (source.kind == 'new' || !source.canSave) return saveAs(); setState(() => saving = true); try { final bytes = await _encoded(); if (bytes == null) return; await source.save(bytes, force: force, inPlace: inPlace); original = bytes; if (!mounted) return; setState(() { dirty = false; notice = null; }); ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(context.tr('saved')))); } on Failure catch (e) { if (!mounted) return; switch (e.code) { case 'remoteChanged' || 'documentChanged' || 'remoteDeleted': await _conflict(e.code); case 'atomicNotPossible' || 'ownerWouldChange': final ok = await confirm( context, e.code, detail: context.tr('inPlaceAdvice'), ); if (ok && mounted) { setState(() => saving = false); return save(force: true, inPlace: true); } case 'connectToSave' || 'disconnected': await _offline(e.code); default: showFailure(context, e); } } catch (e) { if (!mounted) return; final code = failureCode(e); if (code == 'networkError' || code == 'timeout') { await _offline(code); } else { showFailure(context, e); } } finally { if (mounted) setState(() => saving = false); } } /// The file changed elsewhere since it was opened. Future _conflict(String code) async { final choice = await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(ctx.tr(code)), content: Text(ctx.tr('conflictAdvice')), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), child: Text(ctx.tr('cancel')), ), TextButton( onPressed: () => Navigator.pop(ctx, 'saveAs'), child: Text(ctx.tr('saveAs')), ), TextButton( onPressed: () => Navigator.pop(ctx, 'reload'), child: Text(ctx.tr('reloadDiscard')), ), FilledButton( onPressed: () => Navigator.pop(ctx, 'overwrite'), child: Text(ctx.tr('overwrite')), ), ], ), ); if (!mounted) return; switch (choice) { case 'overwrite': setState(() => saving = false); await save(force: true); case 'reload': await _reload(confirmDiscard: false); case 'saveAs': await saveAs(); } } /// The server is not reachable: the text stays here, to retry later or /// to keep a copy on the device. Future _offline(String code) async { final choice = await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(ctx.tr(code)), content: Text(ctx.tr('offlineAdvice')), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), child: Text(ctx.tr('done')), ), FilledButton( onPressed: () => Navigator.pop(ctx, 'device'), child: Text(ctx.tr('saveCopyToDevice')), ), ], ), ); if (choice == 'device' && mounted) await saveAs(target: 'device'); } Future saveAs({String? target}) async { final e = editing; if (e == null) return; final bytes = await _encoded(); if (bytes == null || !mounted) return; final next = await saveAsDialog( context, c, name: source.name, bytes: bytes, target: target, ); if (next == null || !mounted) return; setState(() { source = next; dirty = false; original = bytes; }); await recordRecent(c, next); if (mounted) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(context.tr('saved')))); } } Future _reload({bool confirmDiscard = true}) async { if (confirmDiscard && dirty) { final ok = await confirm(context, 'discardChanges'); if (!ok) return; } original = null; _lastText = null; final e = editing; editing = null; e?.removeListener(_changed); await _load(); } Future _reopenWith() async { if (dirty) { final ok = await confirm(context, 'discardChanges'); if (!ok || !mounted) return; } final chosen = await _chooseEncoding(); if (chosen == null) return; _lastText = null; editing?.removeListener(_changed); await _load(encoding: chosen); } Future _saveWith() async { final chosen = await showDialog( context: context, builder: (ctx) => SimpleDialog( title: Text(ctx.tr('saveEncoding')), children: [ for (final e in TextEncoding.values) SimpleDialogOption( onPressed: () => Navigator.pop(ctx, e), child: Text(e.label), ), ], ), ); if (chosen == null || chosen == format!.encoding) return; setState(() { format!.encoding = chosen; dirty = true; }); } Future _lineBreak() async { final chosen = await showDialog( context: context, builder: (ctx) => SimpleDialog( title: Text(ctx.tr('lineBreaks')), children: [ for (final b in LineBreak.values) SimpleDialogOption( onPressed: () => Navigator.pop(ctx, b), child: Text(b.label), ), ], ), ); if (chosen == null || chosen == format!.lineBreak) return; final e = editing!; final value = e.value; final next = CodeLineEditingController( codeLines: value.codeLines, options: CodeLineOptions(lineBreak: _break(chosen), indentSize: indent), ); next.selection = value.selection; e.removeListener(_changed); next.addListener(_changed); final oldFind = find; setState(() { editing = next; find = CodeFindController(next); format!.lineBreak = chosen; dirty = true; notice = null; }); WidgetsBinding.instance.addPostFrameCallback((_) { e.dispose(); oldFind?.dispose(); }); } // --------------------------------------------------------------------------- // Tools Future _goTo() async { final e = editing!; final field = TextEditingController(); final result = await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(ctx.tr('goToLine')), content: TextField( controller: field, autofocus: true, keyboardType: TextInputType.text, decoration: InputDecoration( hintText: '${ctx.tr('line')}[:${ctx.tr('column')}]', helperText: '1–${e.lineCount}', ), onSubmitted: (v) => Navigator.pop(ctx, v), ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), child: Text(ctx.tr('cancel')), ), FilledButton( onPressed: () => Navigator.pop(ctx, field.text), child: Text(ctx.tr('goToLine')), ), ], ), ); Future.delayed(const Duration(seconds: 1), field.dispose); if (result == null) return; final parts = result.trim().split(RegExp(r'[:,\s]+')); final line = int.tryParse(parts.first); if (line == null) return; final column = parts.length > 1 ? int.tryParse(parts[1]) ?? 1 : 1; _moveTo(line, column); } void _moveTo(int line, int column) { final e = editing!; final index = (line - 1).clamp(0, e.lineCount - 1); final text = e.codeLines[index].text; final offset = (column - 1).clamp(0, text.length); e.selection = CodeLineSelection.collapsed(index: index, offset: offset); e.makeCursorCenterIfInvisible(); focus.requestFocus(); } Future _validate() async { final problem = validate(syntax.id, editing!.text); if (!mounted) return; if (problem == null) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(context.tr('validOk')))); return; } final go = await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(ctx.tr('validationProblem')), content: SelectableText( '${problem.line == null ? '' : '${ctx.tr('line')} ${problem.line}, ${ctx.tr('column')} ${problem.column}\n'}' '${problem.message}', ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: Text(ctx.tr('done')), ), if (problem.line != null) FilledButton( onPressed: () => Navigator.pop(ctx, true), child: Text(ctx.tr('goToLine')), ), ], ), ); if (go == true) _moveTo(problem.line!, problem.column ?? 1); } Future _format() async { final e = editing!; final formatted = formatText(syntax.id, e.text, indent: indent); if (formatted == null) { if (mounted) showFailure(context, const Failure('formatFailed')); return; } final ok = await confirm( context, 'formatDocument', detail: context.tr('formatAdvice'), ); if (!ok) return; // Undoable, like any edit. e.selectAll(); e.replaceSelection(formatted); } void _setPref(String key, Object value) { setState(() => prefs[key] = value); unawaited(c.saveSettings().catchError((Object _) {})); } Future _settings() async { await showModalBottomSheet( context: context, isScrollControlled: true, builder: (ctx) => StatefulBuilder( builder: (ctx, refresh) { void set(String k, Object v) { _setPref(k, v); refresh(() {}); } return SafeArea( child: SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( ctx.tr('editorSettings'), style: Theme.of(ctx).textTheme.titleMedium, ), ListTile( contentPadding: EdgeInsets.zero, title: Text(ctx.tr('fontSize')), subtitle: Slider( value: fontSize, min: 9, max: 28, divisions: 19, label: fontSize.round().toString(), onChanged: (v) => set('fontSize', v.roundToDouble()), ), ), SwitchListTile.adaptive( contentPadding: EdgeInsets.zero, title: Text(ctx.tr('wordWrap')), value: wrap, onChanged: (v) => set('wrap', v), ), SwitchListTile.adaptive( contentPadding: EdgeInsets.zero, title: Text(ctx.tr('lineNumbers')), value: lineNumbers, onChanged: (v) => set('lineNumbers', v), ), SwitchListTile.adaptive( contentPadding: EdgeInsets.zero, title: Text(ctx.tr('indentWithTabs')), value: tabs, onChanged: (v) => set('tabs', v), ), ListTile( contentPadding: EdgeInsets.zero, title: Text(ctx.tr('indentWidth')), trailing: SegmentedButton( showSelectedIcon: false, segments: const [ ButtonSegment(value: 2, label: Text('2')), ButtonSegment(value: 4, label: Text('4')), ButtonSegment(value: 8, label: Text('8')), ], selected: {indent}, onSelectionChanged: (v) { set('indent', v.first); _applyIndent(); }, ), ), SwitchListTile.adaptive( contentPadding: EdgeInsets.zero, title: Text(ctx.tr('syntaxHighlight')), subtitle: highlight || (original?.length ?? 0) <= highlightLimit ? null : Text(ctx.tr('largeFileHighlight')), value: highlight, onChanged: (v) { setState(() => highlight = v); refresh(() {}); }, ), ], ), ), ); }, ), ); } void _applyIndent() { final e = editing; if (e == null) return; final next = CodeLineEditingController( codeLines: e.codeLines, options: CodeLineOptions( lineBreak: e.options.lineBreak, indentSize: indent, ), ); next.selection = e.selection; e.removeListener(_changed); next.addListener(_changed); final oldFind = find; setState(() { editing = next; find = CodeFindController(next); }); WidgetsBinding.instance.addPostFrameCallback((_) { e.dispose(); oldFind?.dispose(); }); } Future _chooseSyntax() async { final chosen = await showModalBottomSheet( context: context, isScrollControlled: true, builder: (ctx) => SafeArea( child: DraggableScrollableSheet( expand: false, initialChildSize: .6, builder: (ctx, scrollController) => ListView( controller: scrollController, children: [ ListTile(title: Text(ctx.tr('syntax'))), for (final s in syntaxes) ListTile( title: Text(s.label), subtitle: s.highlighted ? null : Text(ctx.tr('noGrammar')), selected: s.id == syntax.id, onTap: () => Navigator.pop(ctx, s), ), ], ), ), ), ); if (chosen != null) setState(() => syntax = chosen); } Future _confirmLeave() async { if (!dirty) return true; final choice = await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(ctx.tr('unsavedChanges')), content: Text(source.location), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), child: Text(ctx.tr('cancel')), ), TextButton( onPressed: () => Navigator.pop(ctx, 'discard'), child: Text(ctx.tr('discard')), ), FilledButton( onPressed: () => Navigator.pop(ctx, 'save'), child: Text(ctx.tr('save')), ), ], ), ); if (choice == 'discard') return true; if (choice == 'save') { await save(); return !dirty; } return false; } // --------------------------------------------------------------------------- // UI CodeEditorStyle _style(BuildContext context) { final scheme = Theme.of(context).colorScheme; final dark = Theme.of(context).brightness == Brightness.dark; final grammar = syntax.grammar; return CodeEditorStyle( fontSize: fontSize, fontFamily: Theme.of(context).platform == TargetPlatform.iOS || Theme.of(context).platform == TargetPlatform.macOS ? 'Menlo' : 'monospace', fontFamilyFallback: const ['Courier New', 'monospace'], textColor: scheme.onSurface, backgroundColor: dark ? const Color(0xff0b1622) : const Color(0xfffcfdff), selectionColor: scheme.primary.withValues(alpha: .3), cursorColor: scheme.primary, cursorLineColor: scheme.primary.withValues(alpha: dark ? .10 : .07), chunkIndicatorColor: scheme.onSurfaceVariant, codeTheme: highlight && grammar != null ? CodeHighlightTheme( languages: { grammar: CodeHighlightThemeMode( mode: grammars[grammar]!, maxSize: maxEditableBytes, maxLineLength: 10000, ), }, theme: dark ? githubDarkTheme : githubTheme, ) : null, ); } Widget _statusBar(BuildContext context) { final e = editing!, f = format!; final sel = e.selection; final muted = TextStyle( fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant, ); Widget chip(String text, VoidCallback? onTap, {String? tooltip}) => Tooltip( message: tooltip ?? text, child: InkWell( onTap: onTap, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), child: Text(text, style: muted), ), ), ); return Material( color: Theme.of(context).colorScheme.surfaceContainer, child: SafeArea( top: false, child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( children: [ chip( '${context.tr('lineShort')} ${sel.extentIndex + 1}, ' '${context.tr('columnShort')} ${sel.extentOffset + 1}', _goTo, tooltip: context.tr('goToLine'), ), chip(syntax.label, _chooseSyntax, tooltip: context.tr('syntax')), chip( f.encoding.label, readOnly ? null : _saveWith, tooltip: context.tr('saveEncoding'), ), chip( f.lineBreak.label, readOnly ? null : _lineBreak, tooltip: context.tr('lineBreaks'), ), chip( tabs ? '${context.tr('tabs')} $indent' : '${context.tr('spaces')} $indent', _settings, ), if (readOnly) chip(context.tr('readOnly'), null) else if (dirty) chip('● ${context.tr('modifiedState')}', null), ], ), ), ), ); } Map> get _overrides => { CodeShortcutSaveIntent: CallbackAction( onInvoke: (_) { if (!readOnly) unawaited(save()); return null; }, ), if (tabs) CodeShortcutIndentIntent: CallbackAction( onInvoke: (_) { final e = editing!; if (e.selection.isSameLine) { e.replaceSelection('\t'); } else { e.applyIndent(); } return null; }, ), }; @override Widget build(BuildContext context) { final e = editing; final wide = MediaQuery.sizeOf(context).width >= 700; final menu = <(String, IconData, VoidCallback?)>[ ('saveAs', Icons.save_as, e == null ? null : saveAs), ('reload', Icons.refresh, () => _reload()), ('goToLine', Icons.format_list_numbered, e == null ? null : _goTo), ('selectAll', Icons.select_all, e == null ? null : () => e.selectAll()), if (validatorFor(syntax.id) != null) ...[ ('validate', Icons.rule, e == null ? null : _validate), if (!readOnly && syntax.id != 'yaml' && syntax.id != 'jsonl') ('formatDocument', Icons.auto_fix_high, e == null ? null : _format), ], ( 'reopenEncoding', Icons.translate, original == null ? null : _reopenWith, ), ('syntax', Icons.code, _chooseSyntax), ('editorSettings', Icons.tune, _settings), ]; return PopScope( canPop: !dirty, onPopInvokedWithResult: (popped, _) async { if (popped) return; if (await _confirmLeave() && context.mounted) { setState(() => dirty = false); Navigator.pop(context); } }, child: Scaffold( appBar: AppBar( title: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '${dirty ? '● ' : ''}${source.name}', maxLines: 1, overflow: TextOverflow.ellipsis, ), Text( source.location, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall, ), ], ), actions: [ if (e != null && !readOnly) IconButton( tooltip: context.tr('save'), onPressed: saving ? null : () => save(), icon: saving ? const SizedBox.square( dimension: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.save), ), if (e != null && !readOnly) ...[ IconButton( tooltip: context.tr('undo'), onPressed: e.canUndo ? e.undo : null, icon: const Icon(Icons.undo), ), IconButton( tooltip: context.tr('redo'), onPressed: e.canRedo ? e.redo : null, icon: const Icon(Icons.redo), ), ], if (e != null) IconButton( tooltip: context.tr('search'), onPressed: () => find?.findMode(), icon: const Icon(Icons.search), ), if (wide && e != null) IconButton( tooltip: context.tr('wordWrap'), onPressed: () => _setPref('wrap', !wrap), icon: Icon(wrap ? Icons.wrap_text : Icons.notes), ), PopupMenuButton( onSelected: (i) => menu[i].$3?.call(), itemBuilder: (ctx) => [ for (var i = 0; i < menu.length; i++) PopupMenuItem( value: i, enabled: menu[i].$3 != null, child: ListTile( dense: true, contentPadding: EdgeInsets.zero, leading: Icon(menu[i].$2), title: Text(ctx.tr(menu[i].$1)), ), ), ], ), ], ), body: SafeArea( bottom: false, child: loading ? const Center(child: CircularProgressIndicator()) : error != null ? _errorView(context) : Column( children: [ if (notice != null) MaterialBanner( content: Text(context.tr(notice!)), actions: [ TextButton( onPressed: () => setState(() => notice = null), child: Text(context.tr('done')), ), ], ), if (readOnly) MaterialBanner( content: Text( context.tr(source.readOnlyReason ?? 'readOnly'), ), actions: [ TextButton( onPressed: saveAs, child: Text(context.tr('saveAs')), ), ], ), Expanded( child: CodeEditor( controller: e, findController: find, scrollController: scroll, focusNode: focus, readOnly: readOnly, wordWrap: wrap, style: _style(context), padding: const EdgeInsets.fromLTRB(4, 8, 8, 64), chunkAnalyzer: switch (syntax.id) { 'yaml' || 'python' || 'markdown' => const IndentChunkAnalyzer(), 'text' || 'log' || 'csv' || 'pem' => const NonCodeChunkAnalyzer(), _ => const DefaultCodeChunkAnalyzer(), }, indicatorBuilder: lineNumbers ? (context, editing, chunks, notifier) => Row( children: [ DefaultCodeLineNumber( controller: editing, notifier: notifier, ), DefaultCodeChunkIndicator( width: 18, controller: chunks, notifier: notifier, ), ], ) : null, findBuilder: (context, controller, readOnly) => FindPanel( controller: controller, readOnly: readOnly, ), toolbarController: MobileSelectionToolbarController( builder: ({ required context, required anchors, required controller, required onDismiss, required onRefresh, }) => AdaptiveTextSelectionToolbar.buttonItems( anchors: anchors, buttonItems: [ if (!readOnly && !controller.selection.isCollapsed) ContextMenuButtonItem( type: ContextMenuButtonType.cut, onPressed: () { controller.cut(); onDismiss(); }, ), if (!controller.selection.isCollapsed) ContextMenuButtonItem( type: ContextMenuButtonType.copy, onPressed: () { controller.copy(); onDismiss(); }, ), if (!readOnly) ContextMenuButtonItem( type: ContextMenuButtonType.paste, onPressed: () { controller.paste(); onDismiss(); }, ), ContextMenuButtonItem( type: ContextMenuButtonType.selectAll, onPressed: () { controller.selectAll(); onRefresh(); }, ), ], ), ), shortcutOverrideActions: _overrides, ), ), _statusBar(context), ], ), ), ), ); } Widget _errorView(BuildContext context) => Center( child: SingleChildScrollView( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon( error == 'binaryFile' ? Icons.memory : Icons.error_outline, size: 48, color: Theme.of(context).colorScheme.primary, ), const SizedBox(height: 16), Text(context.tr(error!), textAlign: TextAlign.center), const SizedBox(height: 16), Wrap( spacing: 8, runSpacing: 8, alignment: WrapAlignment.center, children: [ if (error == 'binaryFile' || error == 'wrongEncoding' || error == 'unknownEncoding') OutlinedButton( onPressed: () async { final chosen = await _chooseEncoding(); if (chosen != null) await _load(encoding: chosen); }, child: Text(context.tr('openAsTextWith')), ), FilledButton( onPressed: () => _reload(confirmDiscard: false), child: Text(context.tr('reload')), ), ], ), ], ), ), ); }