CH-J SM Mobileإدارة الخوادم عبر SSH
القائمة
المصدر المنشور

CH-J SM Mobile

تصفّح الأدلة والملفات الخاصة بإصدار محدد من التطبيق.

تنزيل الشيفرة المصدرية بصيغة ZIP
CH-J / SM Mobile 175 ملفات
sm-mobile-20261016T185235Z-8e93ecc1b59a تاريخ الإضافة: · تاريخ البناء:
editor_page.dart تاريخ الإضافة: لم يُسجَّل التاريخ ٤٠٫٢ KB · 1260 أسطر
الشيفرة تنزيل الملف
1import 'dart:async';
2import 'package:flutter/material.dart';
3import 'package:flutter/services.dart';
4import 'package:re_editor/re_editor.dart';
5import 'package:re_highlight/styles/github-dark.dart';
6import 'package:re_highlight/styles/github.dart';
7import '../../core/app_controller.dart';
8import '../../core/failure.dart';
9import '../../l10n/strings.dart';
10import '../../services/editor/languages.dart';
11import '../../services/editor/sources.dart';
12import '../../services/editor/text_codec.dart';
13import '../../services/editor/validation.dart';
14import '../../services/ssh_service.dart';
15import '../../services/vault.dart';
16import '../common.dart';
17import 'find_panel.dart';
18import 'save_as.dart';
20/// Files above this size are edited without syntax colouring by default.
21const highlightLimit = 1024 * 1024;
23/// Folding by indentation for languages without braces (YAML, Python…):
24/// a block runs while the following lines are indented deeper.
25class IndentChunkAnalyzer implements CodeChunkAnalyzer {
26 const IndentChunkAnalyzer();
27 static int _indent(String s) {
28 var n = 0;
29 for (final c in s.codeUnits) {
30 if (c == 0x20) {
31 n++;
32 } else if (c == 0x09) {
33 n += 4;
34 } else {
35 break;
36 }
37 }
38 return n;
39 }
41 @override
42 List<CodeChunk> run(CodeLines codeLines) {
43 final chunks = <CodeChunk>[];
44 final n = codeLines.length;
45 if (n > 200000) return chunks;
46 final indents = List<int?>.generate(n, (i) {
47 final t = codeLines[i].text;
48 return t.trim().isEmpty ? null : _indent(t);
49 });
50 for (var i = 0; i < n; i++) {
51 final base = indents[i];
52 if (base == null) continue;
53 var end = i, j = i + 1;
54 while (j < n) {
55 final d = indents[j];
56 if (d != null) {
57 if (d <= base) break;
58 end = j;
59 }
60 j++;
61 }
62 if (end > i) chunks.add(CodeChunk(i, end + 1));
63 }
64 return chunks;
65 }
68/// What the editor holds besides the text.
69class _Format {
70 TextEncoding encoding;
71 LineBreak lineBreak;
72 final bool mixed, exact;
73 _Format(
74 this.encoding,
75 this.lineBreak, {
76 this.mixed = false,
77 this.exact = true,
78 });
81class EditorPage extends StatefulWidget {
82 final AppController controller;
83 final FileSource source;
85 /// Already loaded content (viewers that offer "open as text").
86 final Uint8List? bytes;
88 /// A recovered draft: its text replaces the loaded content.
89 final Draft? draft;
90 const EditorPage(
91 this.controller,
92 this.source, {
93 this.bytes,
94 this.draft,
95 super.key,
96 });
97 @override
98 State<EditorPage> createState() => _EditorPageState();
101class _EditorPageState extends State<EditorPage> with WidgetsBindingObserver {
102 late FileSource source = widget.source;
103 CodeLineEditingController? editing;
104 CodeFindController? find;
105 final scroll = CodeScrollController();
106 final focus = FocusNode();
107 Uint8List? original;
108 _Format? format;
109 Syntax syntax = plainText;
110 bool loading = true, saving = false, dirty = false, highlight = true;
111 String? error, notice;
112 String? reloadBlocked;
113 bool _registered = false;
115 AppController get c => widget.controller;
116 Map<String, dynamic> get prefs => c.editorPrefs;
117 double get fontSize => (prefs['fontSize'] as num?)?.toDouble() ?? 14;
118 bool get wrap => prefs['wrap'] == true;
119 bool get tabs => prefs['tabs'] == true;
120 int get indent => (prefs['indent'] as int?) ?? 2;
121 bool get lineNumbers => prefs['lineNumbers'] != false;
123 bool get readOnly => !source.canSave && source.kind != 'new';
125 @override
126 void initState() {
127 super.initState();
128 WidgetsBinding.instance.addObserver(this);
129 c.draftSources.add(_draft);
130 _registered = true;
131 unawaited(_load());
132 }
134 @override
135 void dispose() {
136 // Stops the editor's cursor blink timer before its controller goes.
137 focus.unfocus();
138 WidgetsBinding.instance.removeObserver(this);
139 if (_registered) c.draftSources.remove(_draft);
140 editing?.dispose();
141 find?.dispose();
142 focus.dispose();
143 super.dispose();
144 }
146 /// The unsaved buffer, kept encrypted across a lock.
147 Draft? _draft() {
148 final e = editing, f = format;
149 if (!dirty || e == null || f == null) return null;
150 return Draft({
151 'source': source.toJson(),
152 'name': source.name,
153 'location': source.location,
154 'text': e.text,
155 'encoding': f.encoding.name,
156 'lineBreak': f.lineBreak.name,
157 'syntax': syntax.id,
158 'savedAt': DateTime.now().toUtc().toIso8601String(),
159 });
160 }
162 Future<void> _load({TextEncoding? encoding, bool asText = false}) async {
163 setState(() {
164 loading = true;
165 error = null;
166 });
167 try {
168 final restoring = widget.draft != null && editing == null;
169 final Uint8List bytes;
170 if (restoring) {
171 // The draft text wins; the file is read only to know its current
172 // state for the conflict check, and may be unreachable now.
173 Uint8List? current;
174 try {
175 current = await source.load();
176 } catch (_) {
177 current = null;
178 }
179 bytes = current ?? Uint8List(0);
180 encoding ??= TextEncoding.values
181 .asNameMap()[widget.draft!.json['encoding']];
182 if (current != null && looksBinary(current)) encoding = null;
183 } else {
184 bytes = original != null && encoding != null
185 ? original!
186 : widget.bytes ?? await source.load();
187 }
188 if (bytes.length > maxEditableBytes) throw const Failure('fileTooLarge');
189 if (!restoring && encoding == null && !asText && looksBinary(bytes)) {
190 original = bytes;
191 if (mounted) {
192 setState(() {
193 loading = false;
194 error = 'binaryFile';
195 });
196 }
197 return;
198 }
199 DecodedText decoded;
200 try {
201 decoded = await decodeInBackground(bytes, encoding: encoding);
202 } on UnknownEncoding {
203 if (restoring) {
204 decoded = _empty(encoding);
205 } else {
206 original = bytes;
207 if (!mounted) return;
208 final chosen = await _chooseEncoding(unknown: true);
209 if (chosen == null) {
210 if (mounted) {
211 setState(() {
212 loading = false;
213 error = 'unknownEncoding';
214 });
215 }
216 return;
217 }
218 return await _load(encoding: chosen);
219 }
220 } on FormatException {
221 if (restoring) {
222 decoded = _empty(encoding);
223 } else {
224 original = bytes;
225 if (mounted) {
226 setState(() {
227 loading = false;
228 error = 'wrongEncoding';
229 });
230 }
231 return;
232 }
233 }
234 original = bytes;
235 final draft = widget.draft;
236 final text = draft != null && editing == null
237 ? draft.json['text'] as String
238 : decoded.text;
239 final f = _Format(
240 draft == null || editing != null
241 ? decoded.encoding
242 : TextEncoding.values.asNameMap()[draft.json['encoding']] ??
243 decoded.encoding,
244 draft == null || editing != null
245 ? decoded.lineBreaks.dominant
246 : LineBreak.values.asNameMap()[draft.json['lineBreak']] ??
247 decoded.lineBreaks.dominant,
248 mixed: decoded.lineBreaks.mixed,
249 exact: decoded.exact,
250 );
251 final lines = text.length > 256 * 1024
252 ? await text.codeLinesAsync
253 : text.codeLines;
254 if (!mounted) return;
255 final previous = editing;
256 final previousFind = find;
257 final e = CodeLineEditingController(
258 codeLines: lines,
259 options: CodeLineOptions(
260 lineBreak: _break(f.lineBreak),
261 indentSize: indent,
262 ),
263 );
264 e.addListener(_changed);
265 setState(() {
266 editing = e;
267 find = CodeFindController(e);
268 format = f;
269 syntax = draft != null && previous == null
270 ? syntaxById(draft.json['syntax'] as String? ?? '')
271 : detectSyntax(
272 source.kind == 'remote'
273 ? (source as RemoteSource).path
274 : source.name,
275 content: text.length > 4096 ? text.substring(0, 4096) : text,
276 );
277 highlight = bytes.length <= highlightLimit;
278 dirty = draft != null && previous == null;
279 loading = false;
280 notice = f.mixed
281 ? 'mixedLineBreaks'
282 : !f.exact
283 ? 'notExactRoundTrip'
284 : null;
285 });
286 WidgetsBinding.instance.addPostFrameCallback((_) {
287 previous?.dispose();
288 previousFind?.dispose();
289 });
290 } catch (e) {
291 if (mounted) {
292 setState(() {
293 loading = false;
294 error = failureCode(e);
295 });
296 }
297 }
298 }
300 static DecodedText _empty(TextEncoding? e) => DecodedText(
301 '',
302 e ?? TextEncoding.utf8,
303 const LineBreakInfo(0, 0, 0),
304 false,
305 exact: true,
306 );
308 static TextLineBreak _break(LineBreak b) => switch (b) {
309 LineBreak.lf => TextLineBreak.lf,
310 LineBreak.crlf => TextLineBreak.crlf,
311 LineBreak.cr => TextLineBreak.cr,
312 };
314 String? _lastText;
315 void _changed() {
316 final e = editing;
317 if (e == null) return;
318 // Selection moves notify too; only text changes make the file dirty.
319 final lines = e.codeLines;
320 final key = '${lines.length}:${identityHashCode(lines)}';
321 if (key == _lastText) {
322 setState(() {}); // cursor position in the status bar
323 return;
324 }
325 final first = _lastText == null;
326 _lastText = key;
327 if (first) return;
328 setState(() => dirty = true);
329 }
331 Future<TextEncoding?> _chooseEncoding({bool unknown = false}) =>
332 showDialog<TextEncoding>(
333 context: context,
334 builder: (ctx) => SimpleDialog(
335 title: Text(ctx.tr(unknown ? 'unknownEncoding' : 'reopenEncoding')),
336 children: [
337 if (unknown)
338 Padding(
339 padding: const EdgeInsets.symmetric(horizontal: 24),
340 child: Text(ctx.tr('chooseEncodingAdvice')),
341 ),
342 for (final e in TextEncoding.values)
343 SimpleDialogOption(
344 onPressed: () => Navigator.pop(ctx, e),
345 child: Text(e.label),
346 ),
347 ],
348 ),
349 );
351 // ---------------------------------------------------------------------------
352 // Saving
354 Future<Uint8List?> _encoded() async {
355 final e = editing!, f = format!;
356 final text = e.text;
357 try {
358 return await encodeInBackground(text, f.encoding);
359 } on UnencodableText catch (u) {
360 if (!mounted) return null;
361 final utf8 = await confirm(
362 context,
363 'unencodableTitle',
364 detail:
365 '${context.tr('unencodableBody')}\n'
366 '${u.encoding.label}: "${u.sample}" ×${u.count}',
367 );
368 if (!utf8) return null;
369 setState(() => f.encoding = TextEncoding.utf8);
370 return encodeText(text, TextEncoding.utf8);
371 }
372 }
374 Future<void> save({bool force = false, bool inPlace = false}) async {
375 if (saving || editing == null) return;
376 if (source.kind == 'new' || !source.canSave) return saveAs();
377 setState(() => saving = true);
378 try {
379 final bytes = await _encoded();
380 if (bytes == null) return;
381 await source.save(bytes, force: force, inPlace: inPlace);
382 original = bytes;
383 if (!mounted) return;
384 setState(() {
385 dirty = false;
386 notice = null;
387 });
388 ScaffoldMessenger.of(
389 context,
390 ).showSnackBar(SnackBar(content: Text(context.tr('saved'))));
391 } on Failure catch (e) {
392 if (!mounted) return;
393 switch (e.code) {
394 case 'remoteChanged' || 'documentChanged' || 'remoteDeleted':
395 await _conflict(e.code);
396 case 'atomicNotPossible' || 'ownerWouldChange':
397 final ok = await confirm(
398 context,
399 e.code,
400 detail: context.tr('inPlaceAdvice'),
401 );
402 if (ok && mounted) {
403 setState(() => saving = false);
404 return save(force: true, inPlace: true);
405 }
406 case 'connectToSave' || 'disconnected':
407 await _offline(e.code);
408 default:
409 showFailure(context, e);
410 }
411 } catch (e) {
412 if (!mounted) return;
413 final code = failureCode(e);
414 if (code == 'networkError' || code == 'timeout') {
415 await _offline(code);
416 } else {
417 showFailure(context, e);
418 }
419 } finally {
420 if (mounted) setState(() => saving = false);
421 }
422 }
424 /// The file changed elsewhere since it was opened.
425 Future<void> _conflict(String code) async {
426 final choice = await showDialog<String>(
427 context: context,
428 builder: (ctx) => AlertDialog(
429 title: Text(ctx.tr(code)),
430 content: Text(ctx.tr('conflictAdvice')),
431 actions: [
432 TextButton(
433 onPressed: () => Navigator.pop(ctx),
434 child: Text(ctx.tr('cancel')),
435 ),
436 TextButton(
437 onPressed: () => Navigator.pop(ctx, 'saveAs'),
438 child: Text(ctx.tr('saveAs')),
439 ),
440 TextButton(
441 onPressed: () => Navigator.pop(ctx, 'reload'),
442 child: Text(ctx.tr('reloadDiscard')),
443 ),
444 FilledButton(
445 onPressed: () => Navigator.pop(ctx, 'overwrite'),
446 child: Text(ctx.tr('overwrite')),
447 ),
448 ],
449 ),
450 );
451 if (!mounted) return;
452 switch (choice) {
453 case 'overwrite':
454 setState(() => saving = false);
455 await save(force: true);
456 case 'reload':
457 await _reload(confirmDiscard: false);
458 case 'saveAs':
459 await saveAs();
460 }
461 }
463 /// The server is not reachable: the text stays here, to retry later or
464 /// to keep a copy on the device.
465 Future<void> _offline(String code) async {
466 final choice = await showDialog<String>(
467 context: context,
468 builder: (ctx) => AlertDialog(
469 title: Text(ctx.tr(code)),
470 content: Text(ctx.tr('offlineAdvice')),
471 actions: [
472 TextButton(
473 onPressed: () => Navigator.pop(ctx),
474 child: Text(ctx.tr('done')),
475 ),
476 FilledButton(
477 onPressed: () => Navigator.pop(ctx, 'device'),
478 child: Text(ctx.tr('saveCopyToDevice')),
479 ),
480 ],
481 ),
482 );
483 if (choice == 'device' && mounted) await saveAs(target: 'device');
484 }
486 Future<void> saveAs({String? target}) async {
487 final e = editing;
488 if (e == null) return;
489 final bytes = await _encoded();
490 if (bytes == null || !mounted) return;
491 final next = await saveAsDialog(
492 context,
493 c,
494 name: source.name,
495 bytes: bytes,
496 target: target,
497 );
498 if (next == null || !mounted) return;
499 setState(() {
500 source = next;
501 dirty = false;
502 original = bytes;
503 });
504 await recordRecent(c, next);
505 if (mounted) {
506 ScaffoldMessenger.of(
507 context,
508 ).showSnackBar(SnackBar(content: Text(context.tr('saved'))));
509 }
510 }
512 Future<void> _reload({bool confirmDiscard = true}) async {
513 if (confirmDiscard && dirty) {
514 final ok = await confirm(context, 'discardChanges');
515 if (!ok) return;
516 }
517 original = null;
518 _lastText = null;
519 final e = editing;
520 editing = null;
521 e?.removeListener(_changed);
522 await _load();
523 }
525 Future<void> _reopenWith() async {
526 if (dirty) {
527 final ok = await confirm(context, 'discardChanges');
528 if (!ok || !mounted) return;
529 }
530 final chosen = await _chooseEncoding();
531 if (chosen == null) return;
532 _lastText = null;
533 editing?.removeListener(_changed);
534 await _load(encoding: chosen);
535 }
537 Future<void> _saveWith() async {
538 final chosen = await showDialog<TextEncoding>(
539 context: context,
540 builder: (ctx) => SimpleDialog(
541 title: Text(ctx.tr('saveEncoding')),
542 children: [
543 for (final e in TextEncoding.values)
544 SimpleDialogOption(
545 onPressed: () => Navigator.pop(ctx, e),
546 child: Text(e.label),
547 ),
548 ],
549 ),
550 );
551 if (chosen == null || chosen == format!.encoding) return;
552 setState(() {
553 format!.encoding = chosen;
554 dirty = true;
555 });
556 }
558 Future<void> _lineBreak() async {
559 final chosen = await showDialog<LineBreak>(
560 context: context,
561 builder: (ctx) => SimpleDialog(
562 title: Text(ctx.tr('lineBreaks')),
563 children: [
564 for (final b in LineBreak.values)
565 SimpleDialogOption(
566 onPressed: () => Navigator.pop(ctx, b),
567 child: Text(b.label),
568 ),
569 ],
570 ),
571 );
572 if (chosen == null || chosen == format!.lineBreak) return;
573 final e = editing!;
574 final value = e.value;
575 final next = CodeLineEditingController(
576 codeLines: value.codeLines,
577 options: CodeLineOptions(lineBreak: _break(chosen), indentSize: indent),
578 );
579 next.selection = value.selection;
580 e.removeListener(_changed);
581 next.addListener(_changed);
582 final oldFind = find;
583 setState(() {
584 editing = next;
585 find = CodeFindController(next);
586 format!.lineBreak = chosen;
587 dirty = true;
588 notice = null;
589 });
590 WidgetsBinding.instance.addPostFrameCallback((_) {
591 e.dispose();
592 oldFind?.dispose();
593 });
594 }
596 // ---------------------------------------------------------------------------
597 // Tools
599 Future<void> _goTo() async {
600 final e = editing!;
601 final field = TextEditingController();
602 final result = await showDialog<String>(
603 context: context,
604 builder: (ctx) => AlertDialog(
605 title: Text(ctx.tr('goToLine')),
606 content: TextField(
607 controller: field,
608 autofocus: true,
609 keyboardType: TextInputType.text,
610 decoration: InputDecoration(
611 hintText: '${ctx.tr('line')}[:${ctx.tr('column')}]',
612 helperText: '1–${e.lineCount}',
613 ),
614 onSubmitted: (v) => Navigator.pop(ctx, v),
615 ),
616 actions: [
617 TextButton(
618 onPressed: () => Navigator.pop(ctx),
619 child: Text(ctx.tr('cancel')),
620 ),
621 FilledButton(
622 onPressed: () => Navigator.pop(ctx, field.text),
623 child: Text(ctx.tr('goToLine')),
624 ),
625 ],
626 ),
627 );
628 Future.delayed(const Duration(seconds: 1), field.dispose);
629 if (result == null) return;
630 final parts = result.trim().split(RegExp(r'[:,\s]+'));
631 final line = int.tryParse(parts.first);
632 if (line == null) return;
633 final column = parts.length > 1 ? int.tryParse(parts[1]) ?? 1 : 1;
634 _moveTo(line, column);
635 }
637 void _moveTo(int line, int column) {
638 final e = editing!;
639 final index = (line - 1).clamp(0, e.lineCount - 1);
640 final text = e.codeLines[index].text;
641 final offset = (column - 1).clamp(0, text.length);
642 e.selection = CodeLineSelection.collapsed(index: index, offset: offset);
643 e.makeCursorCenterIfInvisible();
644 focus.requestFocus();
645 }
647 Future<void> _validate() async {
648 final problem = validate(syntax.id, editing!.text);
649 if (!mounted) return;
650 if (problem == null) {
651 ScaffoldMessenger.of(
652 context,
653 ).showSnackBar(SnackBar(content: Text(context.tr('validOk'))));
654 return;
655 }
656 final go = await showDialog<bool>(
657 context: context,
658 builder: (ctx) => AlertDialog(
659 title: Text(ctx.tr('validationProblem')),
660 content: SelectableText(
661 '${problem.line == null ? '' : '${ctx.tr('line')} ${problem.line}, ${ctx.tr('column')} ${problem.column}\n'}'
662 '${problem.message}',
663 ),
664 actions: [
665 TextButton(
666 onPressed: () => Navigator.pop(ctx, false),
667 child: Text(ctx.tr('done')),
668 ),
669 if (problem.line != null)
670 FilledButton(
671 onPressed: () => Navigator.pop(ctx, true),
672 child: Text(ctx.tr('goToLine')),
673 ),
674 ],
675 ),
676 );
677 if (go == true) _moveTo(problem.line!, problem.column ?? 1);
678 }
680 Future<void> _format() async {
681 final e = editing!;
682 final formatted = formatText(syntax.id, e.text, indent: indent);
683 if (formatted == null) {
684 if (mounted) showFailure(context, const Failure('formatFailed'));
685 return;
686 }
687 final ok = await confirm(
688 context,
689 'formatDocument',
690 detail: context.tr('formatAdvice'),
691 );
692 if (!ok) return;
693 // Undoable, like any edit.
694 e.selectAll();
695 e.replaceSelection(formatted);
696 }
698 void _setPref(String key, Object value) {
699 setState(() => prefs[key] = value);
700 unawaited(c.saveSettings().catchError((Object _) {}));
701 }
703 Future<void> _settings() async {
704 await showModalBottomSheet<void>(
705 context: context,
706 isScrollControlled: true,
707 builder: (ctx) => StatefulBuilder(
708 builder: (ctx, refresh) {
709 void set(String k, Object v) {
710 _setPref(k, v);
711 refresh(() {});
712 }
714 return SafeArea(
715 child: SingleChildScrollView(
716 padding: const EdgeInsets.all(16),
717 child: Column(
718 crossAxisAlignment: CrossAxisAlignment.stretch,
719 children: [
720 Text(
721 ctx.tr('editorSettings'),
722 style: Theme.of(ctx).textTheme.titleMedium,
723 ),
724 ListTile(
725 contentPadding: EdgeInsets.zero,
726 title: Text(ctx.tr('fontSize')),
727 subtitle: Slider(
728 value: fontSize,
729 min: 9,
730 max: 28,
731 divisions: 19,
732 label: fontSize.round().toString(),
733 onChanged: (v) => set('fontSize', v.roundToDouble()),
734 ),
735 ),
736 SwitchListTile.adaptive(
737 contentPadding: EdgeInsets.zero,
738 title: Text(ctx.tr('wordWrap')),
739 value: wrap,
740 onChanged: (v) => set('wrap', v),
741 ),
742 SwitchListTile.adaptive(
743 contentPadding: EdgeInsets.zero,
744 title: Text(ctx.tr('lineNumbers')),
745 value: lineNumbers,
746 onChanged: (v) => set('lineNumbers', v),
747 ),
748 SwitchListTile.adaptive(
749 contentPadding: EdgeInsets.zero,
750 title: Text(ctx.tr('indentWithTabs')),
751 value: tabs,
752 onChanged: (v) => set('tabs', v),
753 ),
754 ListTile(
755 contentPadding: EdgeInsets.zero,
756 title: Text(ctx.tr('indentWidth')),
757 trailing: SegmentedButton<int>(
758 showSelectedIcon: false,
759 segments: const [
760 ButtonSegment(value: 2, label: Text('2')),
761 ButtonSegment(value: 4, label: Text('4')),
762 ButtonSegment(value: 8, label: Text('8')),
763 ],
764 selected: {indent},
765 onSelectionChanged: (v) {
766 set('indent', v.first);
767 _applyIndent();
768 },
769 ),
770 ),
771 SwitchListTile.adaptive(
772 contentPadding: EdgeInsets.zero,
773 title: Text(ctx.tr('syntaxHighlight')),
774 subtitle:
775 highlight || (original?.length ?? 0) <= highlightLimit
776 ? null
777 : Text(ctx.tr('largeFileHighlight')),
778 value: highlight,
779 onChanged: (v) {
780 setState(() => highlight = v);
781 refresh(() {});
782 },
783 ),
784 ],
785 ),
786 ),
787 );
788 },
789 ),
790 );
791 }
793 void _applyIndent() {
794 final e = editing;
795 if (e == null) return;
796 final next = CodeLineEditingController(
797 codeLines: e.codeLines,
798 options: CodeLineOptions(
799 lineBreak: e.options.lineBreak,
800 indentSize: indent,
801 ),
802 );
803 next.selection = e.selection;
804 e.removeListener(_changed);
805 next.addListener(_changed);
806 final oldFind = find;
807 setState(() {
808 editing = next;
809 find = CodeFindController(next);
810 });
811 WidgetsBinding.instance.addPostFrameCallback((_) {
812 e.dispose();
813 oldFind?.dispose();
814 });
815 }
817 Future<void> _chooseSyntax() async {
818 final chosen = await showModalBottomSheet<Syntax>(
819 context: context,
820 isScrollControlled: true,
821 builder: (ctx) => SafeArea(
822 child: DraggableScrollableSheet(
823 expand: false,
824 initialChildSize: .6,
825 builder: (ctx, scrollController) => ListView(
826 controller: scrollController,
827 children: [
828 ListTile(title: Text(ctx.tr('syntax'))),
829 for (final s in syntaxes)
830 ListTile(
831 title: Text(s.label),
832 subtitle: s.highlighted ? null : Text(ctx.tr('noGrammar')),
833 selected: s.id == syntax.id,
834 onTap: () => Navigator.pop(ctx, s),
835 ),
836 ],
837 ),
838 ),
839 ),
840 );
841 if (chosen != null) setState(() => syntax = chosen);
842 }
844 Future<bool> _confirmLeave() async {
845 if (!dirty) return true;
846 final choice = await showDialog<String>(
847 context: context,
848 builder: (ctx) => AlertDialog(
849 title: Text(ctx.tr('unsavedChanges')),
850 content: Text(source.location),
851 actions: [
852 TextButton(
853 onPressed: () => Navigator.pop(ctx),
854 child: Text(ctx.tr('cancel')),
855 ),
856 TextButton(
857 onPressed: () => Navigator.pop(ctx, 'discard'),
858 child: Text(ctx.tr('discard')),
859 ),
860 FilledButton(
861 onPressed: () => Navigator.pop(ctx, 'save'),
862 child: Text(ctx.tr('save')),
863 ),
864 ],
865 ),
866 );
867 if (choice == 'discard') return true;
868 if (choice == 'save') {
869 await save();
870 return !dirty;
871 }
872 return false;
873 }
875 // ---------------------------------------------------------------------------
876 // UI
878 CodeEditorStyle _style(BuildContext context) {
879 final scheme = Theme.of(context).colorScheme;
880 final dark = Theme.of(context).brightness == Brightness.dark;
881 final grammar = syntax.grammar;
882 return CodeEditorStyle(
883 fontSize: fontSize,
884 fontFamily:
885 Theme.of(context).platform == TargetPlatform.iOS ||
886 Theme.of(context).platform == TargetPlatform.macOS
887 ? 'Menlo'
888 : 'monospace',
889 fontFamilyFallback: const ['Courier New', 'monospace'],
890 textColor: scheme.onSurface,
891 backgroundColor: dark ? const Color(0xff0b1622) : const Color(0xfffcfdff),
892 selectionColor: scheme.primary.withValues(alpha: .3),
893 cursorColor: scheme.primary,
894 cursorLineColor: scheme.primary.withValues(alpha: dark ? .10 : .07),
895 chunkIndicatorColor: scheme.onSurfaceVariant,
896 codeTheme: highlight && grammar != null
897 ? CodeHighlightTheme(
898 languages: {
899 grammar: CodeHighlightThemeMode(
900 mode: grammars[grammar]!,
901 maxSize: maxEditableBytes,
902 maxLineLength: 10000,
903 ),
904 },
905 theme: dark ? githubDarkTheme : githubTheme,
906 )
907 : null,
908 );
909 }
911 Widget _statusBar(BuildContext context) {
912 final e = editing!, f = format!;
913 final sel = e.selection;
914 final muted = TextStyle(
915 fontSize: 12,
916 color: Theme.of(context).colorScheme.onSurfaceVariant,
917 );
918 Widget chip(String text, VoidCallback? onTap, {String? tooltip}) => Tooltip(
919 message: tooltip ?? text,
920 child: InkWell(
921 onTap: onTap,
922 child: Padding(
923 padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
924 child: Text(text, style: muted),
925 ),
926 ),
927 );
928 return Material(
929 color: Theme.of(context).colorScheme.surfaceContainer,
930 child: SafeArea(
931 top: false,
932 child: SingleChildScrollView(
933 scrollDirection: Axis.horizontal,
934 child: Row(
935 children: [
936 chip(
937 '${context.tr('lineShort')} ${sel.extentIndex + 1}, '
938 '${context.tr('columnShort')} ${sel.extentOffset + 1}',
939 _goTo,
940 tooltip: context.tr('goToLine'),
941 ),
942 chip(syntax.label, _chooseSyntax, tooltip: context.tr('syntax')),
943 chip(
944 f.encoding.label,
945 readOnly ? null : _saveWith,
946 tooltip: context.tr('saveEncoding'),
947 ),
948 chip(
949 f.lineBreak.label,
950 readOnly ? null : _lineBreak,
951 tooltip: context.tr('lineBreaks'),
952 ),
953 chip(
954 tabs
955 ? '${context.tr('tabs')} $indent'
956 : '${context.tr('spaces')} $indent',
957 _settings,
958 ),
959 if (readOnly)
960 chip(context.tr('readOnly'), null)
961 else if (dirty)
962 chip('● ${context.tr('modifiedState')}', null),
963 ],
964 ),
965 ),
966 ),
967 );
968 }
970 Map<Type, Action<Intent>> get _overrides => {
971 CodeShortcutSaveIntent: CallbackAction<CodeShortcutSaveIntent>(
972 onInvoke: (_) {
973 if (!readOnly) unawaited(save());
974 return null;
975 },
976 ),
977 if (tabs)
978 CodeShortcutIndentIntent: CallbackAction<CodeShortcutIndentIntent>(
979 onInvoke: (_) {
980 final e = editing!;
981 if (e.selection.isSameLine) {
982 e.replaceSelection('\t');
983 } else {
984 e.applyIndent();
985 }
986 return null;
987 },
988 ),
989 };
991 @override
992 Widget build(BuildContext context) {
993 final e = editing;
994 final wide = MediaQuery.sizeOf(context).width >= 700;
995 final menu = <(String, IconData, VoidCallback?)>[
996 ('saveAs', Icons.save_as, e == null ? null : saveAs),
997 ('reload', Icons.refresh, () => _reload()),
998 ('goToLine', Icons.format_list_numbered, e == null ? null : _goTo),
999 ('selectAll', Icons.select_all, e == null ? null : () => e.selectAll()),
1000 if (validatorFor(syntax.id) != null) ...[
1001 ('validate', Icons.rule, e == null ? null : _validate),
1002 if (!readOnly && syntax.id != 'yaml' && syntax.id != 'jsonl')
1003 ('formatDocument', Icons.auto_fix_high, e == null ? null : _format),
1004 ],
1006 'reopenEncoding',
1007 Icons.translate,
1008 original == null ? null : _reopenWith,
1009 ),
1010 ('syntax', Icons.code, _chooseSyntax),
1011 ('editorSettings', Icons.tune, _settings),
1012 ];
1013 return PopScope(
1014 canPop: !dirty,
1015 onPopInvokedWithResult: (popped, _) async {
1016 if (popped) return;
1017 if (await _confirmLeave() && context.mounted) {
1018 setState(() => dirty = false);
1019 Navigator.pop(context);
1021 },
1022 child: Scaffold(
1023 appBar: AppBar(
1024 title: Column(
1025 crossAxisAlignment: CrossAxisAlignment.start,
1026 children: [
1027 Text(
1028 '${dirty ? '● ' : ''}${source.name}',
1029 maxLines: 1,
1030 overflow: TextOverflow.ellipsis,
1031 ),
1032 Text(
1033 source.location,
1034 maxLines: 1,
1035 overflow: TextOverflow.ellipsis,
1036 style: Theme.of(context).textTheme.bodySmall,
1037 ),
1038 ],
1039 ),
1040 actions: [
1041 if (e != null && !readOnly)
1042 IconButton(
1043 tooltip: context.tr('save'),
1044 onPressed: saving ? null : () => save(),
1045 icon: saving
1046 ? const SizedBox.square(
1047 dimension: 18,
1048 child: CircularProgressIndicator(strokeWidth: 2),
1050 : const Icon(Icons.save),
1051 ),
1052 if (e != null && !readOnly) ...[
1053 IconButton(
1054 tooltip: context.tr('undo'),
1055 onPressed: e.canUndo ? e.undo : null,
1056 icon: const Icon(Icons.undo),
1057 ),
1058 IconButton(
1059 tooltip: context.tr('redo'),
1060 onPressed: e.canRedo ? e.redo : null,
1061 icon: const Icon(Icons.redo),
1062 ),
1063 ],
1064 if (e != null)
1065 IconButton(
1066 tooltip: context.tr('search'),
1067 onPressed: () => find?.findMode(),
1068 icon: const Icon(Icons.search),
1069 ),
1070 if (wide && e != null)
1071 IconButton(
1072 tooltip: context.tr('wordWrap'),
1073 onPressed: () => _setPref('wrap', !wrap),
1074 icon: Icon(wrap ? Icons.wrap_text : Icons.notes),
1075 ),
1076 PopupMenuButton<int>(
1077 onSelected: (i) => menu[i].$3?.call(),
1078 itemBuilder: (ctx) => [
1079 for (var i = 0; i < menu.length; i++)
1080 PopupMenuItem(
1081 value: i,
1082 enabled: menu[i].$3 != null,
1083 child: ListTile(
1084 dense: true,
1085 contentPadding: EdgeInsets.zero,
1086 leading: Icon(menu[i].$2),
1087 title: Text(ctx.tr(menu[i].$1)),
1088 ),
1089 ),
1090 ],
1091 ),
1092 ],
1093 ),
1094 body: SafeArea(
1095 bottom: false,
1096 child: loading
1097 ? const Center(child: CircularProgressIndicator())
1098 : error != null
1099 ? _errorView(context)
1100 : Column(
1101 children: [
1102 if (notice != null)
1103 MaterialBanner(
1104 content: Text(context.tr(notice!)),
1105 actions: [
1106 TextButton(
1107 onPressed: () => setState(() => notice = null),
1108 child: Text(context.tr('done')),
1109 ),
1110 ],
1111 ),
1112 if (readOnly)
1113 MaterialBanner(
1114 content: Text(
1115 context.tr(source.readOnlyReason ?? 'readOnly'),
1116 ),
1117 actions: [
1118 TextButton(
1119 onPressed: saveAs,
1120 child: Text(context.tr('saveAs')),
1121 ),
1122 ],
1123 ),
1124 Expanded(
1125 child: CodeEditor(
1126 controller: e,
1127 findController: find,
1128 scrollController: scroll,
1129 focusNode: focus,
1130 readOnly: readOnly,
1131 wordWrap: wrap,
1132 style: _style(context),
1133 padding: const EdgeInsets.fromLTRB(4, 8, 8, 64),
1134 chunkAnalyzer: switch (syntax.id) {
1135 'yaml' ||
1136 'python' ||
1137 'markdown' => const IndentChunkAnalyzer(),
1138 'text' ||
1139 'log' ||
1140 'csv' ||
1141 'pem' => const NonCodeChunkAnalyzer(),
1142 _ => const DefaultCodeChunkAnalyzer(),
1143 },
1144 indicatorBuilder: lineNumbers
1145 ? (context, editing, chunks, notifier) => Row(
1146 children: [
1147 DefaultCodeLineNumber(
1148 controller: editing,
1149 notifier: notifier,
1150 ),
1151 DefaultCodeChunkIndicator(
1152 width: 18,
1153 controller: chunks,
1154 notifier: notifier,
1155 ),
1156 ],
1158 : null,
1159 findBuilder: (context, controller, readOnly) =>
1160 FindPanel(
1161 controller: controller,
1162 readOnly: readOnly,
1163 ),
1164 toolbarController: MobileSelectionToolbarController(
1165 builder:
1166 ({
1167 required context,
1168 required anchors,
1169 required controller,
1170 required onDismiss,
1171 required onRefresh,
1172 }) => AdaptiveTextSelectionToolbar.buttonItems(
1173 anchors: anchors,
1174 buttonItems: [
1175 if (!readOnly &&
1176 !controller.selection.isCollapsed)
1177 ContextMenuButtonItem(
1178 type: ContextMenuButtonType.cut,
1179 onPressed: () {
1180 controller.cut();
1181 onDismiss();
1182 },
1183 ),
1184 if (!controller.selection.isCollapsed)
1185 ContextMenuButtonItem(
1186 type: ContextMenuButtonType.copy,
1187 onPressed: () {
1188 controller.copy();
1189 onDismiss();
1190 },
1191 ),
1192 if (!readOnly)
1193 ContextMenuButtonItem(
1194 type: ContextMenuButtonType.paste,
1195 onPressed: () {
1196 controller.paste();
1197 onDismiss();
1198 },
1199 ),
1200 ContextMenuButtonItem(
1201 type: ContextMenuButtonType.selectAll,
1202 onPressed: () {
1203 controller.selectAll();
1204 onRefresh();
1205 },
1206 ),
1207 ],
1208 ),
1209 ),
1210 shortcutOverrideActions: _overrides,
1211 ),
1212 ),
1213 _statusBar(context),
1214 ],
1215 ),
1216 ),
1217 ),
1218 );
1221 Widget _errorView(BuildContext context) => Center(
1222 child: SingleChildScrollView(
1223 padding: const EdgeInsets.all(24),
1224 child: Column(
1225 mainAxisSize: MainAxisSize.min,
1226 children: [
1227 Icon(
1228 error == 'binaryFile' ? Icons.memory : Icons.error_outline,
1229 size: 48,
1230 color: Theme.of(context).colorScheme.primary,
1231 ),
1232 const SizedBox(height: 16),
1233 Text(context.tr(error!), textAlign: TextAlign.center),
1234 const SizedBox(height: 16),
1235 Wrap(
1236 spacing: 8,
1237 runSpacing: 8,
1238 alignment: WrapAlignment.center,
1239 children: [
1240 if (error == 'binaryFile' ||
1241 error == 'wrongEncoding' ||
1242 error == 'unknownEncoding')
1243 OutlinedButton(
1244 onPressed: () async {
1245 final chosen = await _chooseEncoding();
1246 if (chosen != null) await _load(encoding: chosen);
1247 },
1248 child: Text(context.tr('openAsTextWith')),
1249 ),
1250 FilledButton(
1251 onPressed: () => _reload(confirmDiscard: false),
1252 child: Text(context.tr('reload')),
1253 ),
1254 ],
1255 ),
1256 ],
1257 ),
1258 ),
1259 );
SHA-2567ab773041f3320031261c3e64c42dab56ccd77157b0ed971cc0197f7cfe2bc45

CH-J Proprietary Software License 1.14 · تُتاح الشيفرة المصدرية بموجب CH-J Proprietary Software License 1.14. وإتاحتها لا تغيّر شروط الترخيص ولا تمنح حقوقًا إضافية.

SHA-256 للأرشيف46e427a43e87af091004f36145590bda8fe90086dfb0f631afeb5029fb0b5655
الملفات