From 3ad2c953021fe6a25e4f7d3031013654c3e156c2 Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 21 Aug 2026 01:06:06 -0700 Subject: [PATCH 01/38] Refactor document content rendering in workspace screen --- .../presentation/workspace_screen.dart | 23 ++++----- test/src/app_smoke_test.dart | 47 +++++++++++++++---- 2 files changed, 49 insertions(+), 21 deletions(-) diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index 90cc81e3..4ea9ffc9 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -9257,17 +9257,18 @@ class _PreviewPane extends StatelessWidget { return DecoratedBox( decoration: BoxDecoration(color: colors.view), child: SelectionArea( - child: BusyMarkDocumentContentFrame( - layout: documentLayout, - contentKey: const ValueKey('preview-document-content'), - child: ScrollablePositionedList.builder( - key: const ValueKey('preview-document-scroll'), - itemScrollController: controller, - itemPositionsListener: itemPositionsListener, - padding: documentLayout.scrollPadding, - itemCount: document.blocks.length, - itemBuilder: (context, index) => - _keyedPreviewBlock(context, index, document.blocks[index]), + child: ScrollablePositionedList.builder( + key: const ValueKey('preview-document-scroll'), + itemScrollController: controller, + itemPositionsListener: itemPositionsListener, + padding: documentLayout.scrollPadding, + itemCount: document.blocks.length, + itemBuilder: (context, index) => BusyMarkDocumentContentFrame( + layout: documentLayout, + contentKey: index == 0 + ? const ValueKey('preview-document-content') + : null, + child: _keyedPreviewBlock(context, index, document.blocks[index]), ), ), ), diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index 849c13ee..1d1229b3 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -3911,6 +3911,9 @@ void main() { expect(editorContent, findsOneWidget); expect(editorScroll, findsOneWidget); final editorRect = tester.getRect(editorContent); + final editorScrollRect = tester.getRect(editorScroll); + expect(editorScrollRect.width, greaterThan(editorRect.width)); + expect(editorScrollRect.right, greaterThan(editorRect.right)); final editorHeadingRect = tester.getRect( find.descendant(of: editorContent, matching: find.byType(TextField)), ); @@ -3946,6 +3949,11 @@ void main() { expect(previewContent, findsOneWidget); expect(previewScroll, findsOneWidget); final previewRect = tester.getRect(previewContent); + final previewScrollRect = tester.getRect(previewScroll); + expect(previewScrollRect.width, closeTo(editorScrollRect.width, 0.1)); + expect(previewScrollRect.right, closeTo(editorScrollRect.right, 0.1)); + expect(previewScrollRect.width, greaterThan(previewRect.width)); + expect(previewScrollRect.right, greaterThan(previewRect.right)); final previewHeading = find.descendant( of: previewContent, matching: find.byWidgetPredicate( @@ -3957,7 +3965,7 @@ void main() { expect(previewHeading, findsOneWidget); final previewHeadingRect = tester.getRect(previewHeading); final previewParagraph = find.descendant( - of: previewContent, + of: previewScroll, matching: find.byWidgetPredicate( (widget) => widget is Text && @@ -3995,9 +4003,30 @@ void main() { final splitPaneRect = tester.getRect(previewScroll); final splitContentRect = tester.getRect(previewContent); - expect(splitContentRect.left, splitPaneRect.left); - expect(splitContentRect.right, splitPaneRect.right); - expect(splitContentRect.top, splitPaneRect.top); + expect( + splitContentRect.left, + closeTo( + splitPaneRect.left + + BusyMarkDocumentLayoutSpec.splitPreview.minimumInsets.left, + 0.1, + ), + ); + expect( + splitContentRect.right, + closeTo( + splitPaneRect.right - + BusyMarkDocumentLayoutSpec.splitPreview.minimumInsets.right, + 0.1, + ), + ); + expect( + splitContentRect.top, + closeTo( + splitPaneRect.top + + BusyMarkDocumentLayoutSpec.splitPreview.scrollPadding.top, + 0.1, + ), + ); expect( tester.widget(previewScroll).padding, BusyMarkDocumentLayoutSpec.splitPreview.scrollPadding, @@ -4305,7 +4334,7 @@ void main() { expect(previewTextWidget.textSpan?.toPlainText(), code); expect(Directionality.of(tester.element(previewText)), TextDirection.ltr); final previewArabic = find.descendant( - of: find.byKey(const ValueKey('preview-document-content')), + of: find.byKey(const ValueKey('preview-document-scroll')), matching: find.byWidgetPredicate( (widget) => widget is RichText && widget.text.toPlainText() == 'مرحبا', ), @@ -4482,11 +4511,9 @@ After break. .setDocumentViewMode(DocumentViewModePreference.preview); await tester.pump(const Duration(milliseconds: 100)); - final previewContent = find.byKey( - const ValueKey('preview-document-content'), - ); + final previewScroll = find.byKey(const ValueKey('preview-document-scroll')); Finder previewHeading(String text) => find.descendant( - of: previewContent, + of: previewScroll, matching: find.byWidgetPredicate( (widget) => widget is Text && widget.textSpan?.toPlainText() == text, ), @@ -4508,7 +4535,7 @@ After break. expect(previewStyle?.height, editorStyle?.height); } final previewBody = find.descendant( - of: previewContent, + of: previewScroll, matching: find.byWidgetPredicate( (widget) => widget is Text && From a893ef6dcf750f26277484df57199dd31804365b Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 21 Aug 2026 01:40:09 -0700 Subject: [PATCH 02/38] Add multi-select sidebar actions --- lib/l10n/app_ar.arb | 1 + lib/l10n/app_de.arb | 1 + lib/l10n/app_en.arb | 2 + lib/l10n/app_es.arb | 1 + lib/l10n/app_et.arb | 1 + lib/l10n/app_fa.arb | 1 + lib/l10n/app_fr.arb | 1 + lib/l10n/app_hi.arb | 1 + lib/l10n/app_it.arb | 1 + lib/l10n/app_nb.arb | 1 + lib/l10n/app_pl.arb | 1 + lib/l10n/app_pt.arb | 1 + lib/l10n/app_ru.arb | 1 + lib/l10n/app_uk.arb | 1 + lib/l10n/generated/app_localizations.dart | 6 + lib/l10n/generated/app_localizations_ar.dart | 3 + lib/l10n/generated/app_localizations_de.dart | 3 + lib/l10n/generated/app_localizations_en.dart | 3 + lib/l10n/generated/app_localizations_es.dart | 3 + lib/l10n/generated/app_localizations_et.dart | 3 + lib/l10n/generated/app_localizations_fa.dart | 3 + lib/l10n/generated/app_localizations_fr.dart | 3 + lib/l10n/generated/app_localizations_hi.dart | 3 + lib/l10n/generated/app_localizations_it.dart | 3 + lib/l10n/generated/app_localizations_nb.dart | 3 + lib/l10n/generated/app_localizations_pl.dart | 3 + lib/l10n/generated/app_localizations_pt.dart | 3 + lib/l10n/generated/app_localizations_ru.dart | 3 + lib/l10n/generated/app_localizations_uk.dart | 3 + lib/src/ai/ai_edit_ui.dart | 38 +- .../presentation/workspace_screen.dart | 1030 ++++++++++++++--- lib/src/workspace/workspace_controller.dart | 35 + lib/src/workspace/workspace_service.dart | 40 + lib/src/writerside/writerside_toc_editor.dart | 195 ++++ test/src/ai_edit_ui_test.dart | 50 + test/src/app_smoke_test.dart | 95 +- test/src/localization_audit_test.dart | 1 + test/src/source_audit_test.dart | 2 +- test/src/writerside_toc_editor_test.dart | 130 ++- 39 files changed, 1430 insertions(+), 249 deletions(-) diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 57647d80..2e5ec10c 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -2216,6 +2216,7 @@ "gitFileHistory": "الملف الحالي", "gitAdditionsDeletions": "\u2068+{additions} -{deletions}\u2069", "fileActions": "إجراءات الملف", + "actions": "إجراءات", "gitStatusAdded": "مضاف", "gitStatusDeleted": "محذوف", "gitStatusRenamed": "أُعيدت تسميته", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index eb444146..f6278f18 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -2214,6 +2214,7 @@ "gitFileHistory": "Aktuelle Datei", "gitAdditionsDeletions": "+{additions} -{deletions}", "fileActions": "Dateiaktionen", + "actions": "Aktionen", "gitStatusAdded": "Hinzugefügt", "gitStatusDeleted": "Gelöscht", "gitStatusRenamed": "Umbenannt", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 649a31b2..aabc3c39 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1565,6 +1565,8 @@ }, "fileActions": "File actions", "@fileActions": {"description": "Tooltip for a file action menu."}, + "actions": "Actions", + "@actions": {"description": "Tooltip for a general action menu."}, "gitStatusAdded": "Added", "@gitStatusAdded": {"description": "Git file status label."}, "gitStatusDeleted": "Deleted", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 82e0f1c4..5fa18fdd 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -2214,6 +2214,7 @@ "gitFileHistory": "Archivo actual", "gitAdditionsDeletions": "+{additions} -{deletions}", "fileActions": "Acciones de archivo", + "actions": "Acciones", "gitStatusAdded": "Añadido", "gitStatusDeleted": "Eliminado", "gitStatusRenamed": "Renombrado", diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index ffaadeac..38663f1f 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -1492,6 +1492,7 @@ }, "fileActions": "Failitoimingud", "@fileActions": {"description": "Tooltip for a file action menu."}, + "actions": "Toimingud", "gitStatusAdded": "Lisatud", "@gitStatusAdded": {"description": "Git file status label."}, "gitStatusDeleted": "Kustutatud", diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index a1c2080b..8e311ce2 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -2235,6 +2235,7 @@ } }, "fileActions": "عملیات فایل", + "actions": "عملیات", "gitStatusAdded": "افزوده‌شده", "gitStatusDeleted": "حذف‌شده", "gitStatusRenamed": "تغییرنام‌یافته", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 628153e6..93072c3c 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -2214,6 +2214,7 @@ "gitFileHistory": "Fichier actuel", "gitAdditionsDeletions": "+{additions} -{deletions}", "fileActions": "Actions sur le fichier", + "actions": "Actions", "gitStatusAdded": "Ajouté", "gitStatusDeleted": "Supprimé", "gitStatusRenamed": "Renommé", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index ef3e88ca..9082d3cf 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -2216,6 +2216,7 @@ "gitFileHistory": "मौजूदा फ़ाइल", "gitAdditionsDeletions": "+{additions} -{deletions}", "fileActions": "फ़ाइल कार्रवाइयाँ", + "actions": "कार्रवाइयाँ", "gitStatusAdded": "जोड़ा गया", "gitStatusDeleted": "हटाया गया", "gitStatusRenamed": "नाम बदला गया", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 43ef29b3..3eae3306 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -2214,6 +2214,7 @@ "gitFileHistory": "File corrente", "gitAdditionsDeletions": "+{additions} -{deletions}", "fileActions": "Azioni sul file", + "actions": "Azioni", "gitStatusAdded": "Aggiunto", "gitStatusDeleted": "Eliminato", "gitStatusRenamed": "Rinominato", diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb index 3cd9cc49..c3041f9a 100644 --- a/lib/l10n/app_nb.arb +++ b/lib/l10n/app_nb.arb @@ -2214,6 +2214,7 @@ "gitFileHistory": "Gjeldende fil", "gitAdditionsDeletions": "+{additions} -{deletions}", "fileActions": "Filhandlinger", + "actions": "Handlinger", "gitStatusAdded": "Lagt til", "gitStatusDeleted": "Slettet", "gitStatusRenamed": "Gitt nytt navn", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 353876d2..1aabd60c 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -2232,6 +2232,7 @@ "gitFileHistory": "Bieżący plik", "gitAdditionsDeletions": "+{additions} -{deletions}", "fileActions": "Działania na pliku", + "actions": "Działania", "gitStatusAdded": "Dodany", "gitStatusDeleted": "Usunięty", "gitStatusRenamed": "Nazwa zmieniona", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 9180362c..59040c74 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -2214,6 +2214,7 @@ "gitFileHistory": "Arquivo atual", "gitAdditionsDeletions": "+{additions} -{deletions}", "fileActions": "Ações do arquivo", + "actions": "Ações", "gitStatusAdded": "Adicionado", "gitStatusDeleted": "Excluído", "gitStatusRenamed": "Renomeado", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index eb5f132e..67d813b3 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -2232,6 +2232,7 @@ "gitFileHistory": "Текущий файл", "gitAdditionsDeletions": "+{additions} -{deletions}", "fileActions": "Действия с файлом", + "actions": "Действия", "gitStatusAdded": "Добавлен", "gitStatusDeleted": "Удалён", "gitStatusRenamed": "Переименован", diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index 302e2742..6684f030 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -2232,6 +2232,7 @@ "gitFileHistory": "Поточний файл", "gitAdditionsDeletions": "+{additions} -{deletions}", "fileActions": "Дії з файлом", + "actions": "Дії", "gitStatusAdded": "Додано", "gitStatusDeleted": "Видалено", "gitStatusRenamed": "Перейменовано", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 61153dd9..5e5c27db 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -3818,6 +3818,12 @@ abstract class AppLocalizations { /// **'File actions'** String get fileActions; + /// Tooltip for a general action menu. + /// + /// In en, this message translates to: + /// **'Actions'** + String get actions; + /// Git file status label. /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index dbe25f7f..f79fb216 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -2298,6 +2298,9 @@ class AppLocalizationsAr extends AppLocalizations { @override String get fileActions => 'إجراءات الملف'; + @override + String get actions => 'إجراءات'; + @override String get gitStatusAdded => 'مضاف'; diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index b03f3fdb..0d0d69fd 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -2299,6 +2299,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get fileActions => 'Dateiaktionen'; + @override + String get actions => 'Aktionen'; + @override String get gitStatusAdded => 'Hinzugefügt'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index a7b87870..71e0a682 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -2272,6 +2272,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get fileActions => 'File actions'; + @override + String get actions => 'Actions'; + @override String get gitStatusAdded => 'Added'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index f4d5a9eb..c50f9526 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -2299,6 +2299,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get fileActions => 'Acciones de archivo'; + @override + String get actions => 'Acciones'; + @override String get gitStatusAdded => 'Añadido'; diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index f254d8f0..cc744993 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -2273,6 +2273,9 @@ class AppLocalizationsEt extends AppLocalizations { @override String get fileActions => 'Failitoimingud'; + @override + String get actions => 'Toimingud'; + @override String get gitStatusAdded => 'Lisatud'; diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index e877aaca..c60f17f7 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -2330,6 +2330,9 @@ class AppLocalizationsFa extends AppLocalizations { @override String get fileActions => 'عملیات فایل'; + @override + String get actions => 'عملیات'; + @override String get gitStatusAdded => 'افزوده‌شده'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 3847a519..87b17d30 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -2294,6 +2294,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get fileActions => 'Actions sur le fichier'; + @override + String get actions => 'Actions'; + @override String get gitStatusAdded => 'Ajouté'; diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 650b781f..32f03913 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -2267,6 +2267,9 @@ class AppLocalizationsHi extends AppLocalizations { @override String get fileActions => 'फ़ाइल कार्रवाइयाँ'; + @override + String get actions => 'कार्रवाइयाँ'; + @override String get gitStatusAdded => 'जोड़ा गया'; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index b7eeb560..3ef2a4fd 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -2292,6 +2292,9 @@ class AppLocalizationsIt extends AppLocalizations { @override String get fileActions => 'Azioni sul file'; + @override + String get actions => 'Azioni'; + @override String get gitStatusAdded => 'Aggiunto'; diff --git a/lib/l10n/generated/app_localizations_nb.dart b/lib/l10n/generated/app_localizations_nb.dart index d4800a21..7754270c 100644 --- a/lib/l10n/generated/app_localizations_nb.dart +++ b/lib/l10n/generated/app_localizations_nb.dart @@ -2273,6 +2273,9 @@ class AppLocalizationsNb extends AppLocalizations { @override String get fileActions => 'Filhandlinger'; + @override + String get actions => 'Handlinger'; + @override String get gitStatusAdded => 'Lagt til'; diff --git a/lib/l10n/generated/app_localizations_pl.dart b/lib/l10n/generated/app_localizations_pl.dart index be543a91..d97f8173 100644 --- a/lib/l10n/generated/app_localizations_pl.dart +++ b/lib/l10n/generated/app_localizations_pl.dart @@ -2311,6 +2311,9 @@ class AppLocalizationsPl extends AppLocalizations { @override String get fileActions => 'Działania na pliku'; + @override + String get actions => 'Działania'; + @override String get gitStatusAdded => 'Dodany'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 46680aae..c0aab881 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -2288,6 +2288,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get fileActions => 'Ações do arquivo'; + @override + String get actions => 'Ações'; + @override String get gitStatusAdded => 'Adicionado'; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 139da0da..f702f730 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -2304,6 +2304,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get fileActions => 'Действия с файлом'; + @override + String get actions => 'Действия'; + @override String get gitStatusAdded => 'Добавлен'; diff --git a/lib/l10n/generated/app_localizations_uk.dart b/lib/l10n/generated/app_localizations_uk.dart index 3fe9349a..e1a1b722 100644 --- a/lib/l10n/generated/app_localizations_uk.dart +++ b/lib/l10n/generated/app_localizations_uk.dart @@ -2313,6 +2313,9 @@ class AppLocalizationsUk extends AppLocalizations { @override String get fileActions => 'Дії з файлом'; + @override + String get actions => 'Дії'; + @override String get gitStatusAdded => 'Додано'; diff --git a/lib/src/ai/ai_edit_ui.dart b/lib/src/ai/ai_edit_ui.dart index 9d96422b..f1c63a8d 100644 --- a/lib/src/ai/ai_edit_ui.dart +++ b/lib/src/ai/ai_edit_ui.dart @@ -22,13 +22,16 @@ import 'ai_providers.dart'; Future showBusyMarkAiEdit( BuildContext context, WidgetRef ref, - AiEditorSnapshot snapshot, -) async { + AiEditorSnapshot snapshot, { + AiEditTargetKind? fixedTarget, +}) async { final configuration = await showBusyMarkModalEditorDialog<_AiEditConfiguration>( context, - builder: (dialogContext) => - _AiEditConfigurationDialog(snapshot: snapshot), + builder: (dialogContext) => _AiEditConfigurationDialog( + snapshot: snapshot, + fixedTarget: fixedTarget, + ), ); if (configuration == null || !context.mounted) { return null; @@ -175,9 +178,10 @@ class _AiEditConfiguration { } class _AiEditConfigurationDialog extends StatefulWidget { - const _AiEditConfigurationDialog({required this.snapshot}); + const _AiEditConfigurationDialog({required this.snapshot, this.fixedTarget}); final AiEditorSnapshot snapshot; + final AiEditTargetKind? fixedTarget; @override State<_AiEditConfigurationDialog> createState() => @@ -199,7 +203,14 @@ class _AiEditConfigurationDialogState @override void initState() { super.initState(); - if (widget.snapshot.hasSelection) { + if (widget.fixedTarget case final fixedTarget?) { + _target = fixedTarget; + _context = fixedTarget == AiEditTargetKind.document + ? AiEditContextKind.document + : widget.snapshot.hasSelection + ? AiEditContextKind.selection + : AiEditContextKind.document; + } else if (widget.snapshot.hasSelection) { _target = AiEditTargetKind.selection; _context = AiEditContextKind.selection; } else if (_blockTargetAvailable) { @@ -319,13 +330,14 @@ class _AiEditConfigurationDialogState List get _availableTargets => [ for (final value in AiEditTargetKind.values) - if (switch (value) { - AiEditTargetKind.selection => widget.snapshot.hasSelection, - AiEditTargetKind.insertAfterBlock || - AiEditTargetKind.block || - AiEditTargetKind.section => _blockTargetAvailable, - AiEditTargetKind.document => true, - }) + if ((widget.fixedTarget == null || widget.fixedTarget == value) && + switch (value) { + AiEditTargetKind.selection => widget.snapshot.hasSelection, + AiEditTargetKind.insertAfterBlock || + AiEditTargetKind.block || + AiEditTargetKind.section => _blockTargetAvailable, + AiEditTargetKind.document => true, + }) value, ]; diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index 4ea9ffc9..f9d73ce7 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -13,6 +13,7 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:yaru/yaru.dart'; import '../../ai/ai_edit_ui.dart'; +import '../../ai/ai_models.dart'; import '../../app/app_settings.dart'; import '../../app/app_router.dart'; import '../../app/busymark_dialogs.dart'; @@ -60,6 +61,7 @@ import '../../platform/linux_header_bar_service.dart'; import '../../visualization/visualization_card.dart'; import '../../visualization/visualization_models.dart'; import '../../writerside/writerside_model.dart'; +import '../../writerside/writerside_toc_editor.dart'; import '../../writerside/writerside_topic_creator.dart'; import '../../writerside/writerside_topic_removal_service.dart'; import '../workspace_controller.dart'; @@ -1459,8 +1461,11 @@ Future _performWorkspacePathAction( required String name, required String path, required _PathMenuAction action, + VoidCallback? onRefineWithAi, }) async { - if (path.isEmpty && action != _PathMenuAction.copyName) { + if (path.isEmpty && + action != _PathMenuAction.copyName && + action != _PathMenuAction.refineWithAi) { return; } switch (action) { @@ -1470,15 +1475,18 @@ Future _performWorkspacePathAction( await _copyToClipboard(path); case _PathMenuAction.openInFiles: await _openInFiles(context, path); + case _PathMenuAction.refineWithAi: + onRefineWithAi?.call(); } } -enum _PathMenuAction { copyName, copyPath, openInFiles } +enum _PathMenuAction { copyName, copyPath, openInFiles, refineWithAi } List> _sidebarPathMenuItems( BuildContext context, { String? copyNameLabel, bool pathActionsEnabled = true, + bool showRefineWithAi = false, }) { return [ BusyMarkPopupMenuItem( @@ -1499,6 +1507,13 @@ List> _sidebarPathMenuItems( icon: BusyMarkGlyphs.folderOpen, enabled: pathActionsEnabled, ), + if (showRefineWithAi) const PopupMenuDivider(height: BusyMarkSpacing.sm), + if (showRefineWithAi) + BusyMarkPopupMenuItem( + value: _PathMenuAction.refineWithAi, + label: context.l10n.aiRefineWithAi, + icon: BusyMarkGlyphs.ai, + ), ]; } @@ -1869,6 +1884,8 @@ class _SidebarState extends ConsumerState<_Sidebar> { _loadWorkspaceGitMenuItems(menuContext, ref, repository), onGitAction: (menuContext, action) => _performWorkspaceGitAction(menuContext, ref, action), + onRefineActiveDocument: () => + unawaited(_refineActiveDocumentWithAi(context)), ), Expanded( child: widget.searchState.active @@ -1951,6 +1968,20 @@ class _SidebarState extends ConsumerState<_Sidebar> { } } + Future _refineActiveDocumentWithAi(BuildContext context) async { + final state = ref.read(workspaceControllerProvider); + final workspace = state.workspace; + if (workspace == null || + !(_activeWorkspaceDocumentKind(workspace)?.supportsAiMarkdownEditing ?? + false) || + state.activeText.isEmpty) { + return; + } + await _refineActiveSourceRangesWithAi(context, ref, const [ + _SourceTextRange.fullDocument(), + ]); + } + Future _showFileHistory(DocumentFile file) async { if (widget.workspace.activeFilePath != file.absolutePath) { if (!await saveOrConfirmSafeToChangeActiveFile(context, ref) || @@ -2276,6 +2307,7 @@ class _SidebarHeader extends StatelessWidget { required this.onSelectTab, required this.loadGitMenuItems, required this.onGitAction, + required this.onRefineActiveDocument, }); final Workspace workspace; @@ -2291,6 +2323,7 @@ class _SidebarHeader extends StatelessWidget { loadGitMenuItems; final Future Function(BuildContext context, _GitMenuAction action) onGitAction; + final VoidCallback onRefineActiveDocument; @override Widget build(BuildContext context) { @@ -2442,7 +2475,7 @@ class _SidebarHeader extends StatelessWidget { const SizedBox(width: BusyMarkSpacing.sm), BusyMarkHeaderPopupMenuButton<_PathMenuAction>( key: const ValueKey('workspace-sidebar-outline-file-menu'), - tooltip: context.l10n.fileActions, + tooltip: context.l10n.actions, icon: BusyMarkGlyphs.menuVertical, transparent: true, borderRadius: BusyMarkRadius.nativeHeaderButton, @@ -2451,6 +2484,7 @@ class _SidebarHeader extends StatelessWidget { menuContext, copyNameLabel: menuContext.l10n.copyFileName, pathActionsEnabled: hasActiveDocumentPath, + showRefineWithAi: true, ), onSelected: (action) => unawaited( _performWorkspacePathAction( @@ -2458,6 +2492,7 @@ class _SidebarHeader extends StatelessWidget { name: activeDocumentName, path: activeDocumentPath, action: action, + onRefineWithAi: onRefineActiveDocument, ), ), ), @@ -4437,7 +4472,9 @@ class _TocTabState extends ConsumerState<_TocTab> { late Set _expandedNodeKeys; late final FocusNode _treeFocusNode; String? _selectedNodePathKey; - _TocTreeClipboardEntry? _cutEntry; + Set _selectedNodePathKeys = {}; + String? _selectionAnchorPathKey; + List<_TocTreeClipboardEntry> _cutEntries = []; @override void initState() { @@ -4478,7 +4515,9 @@ class _TocTabState extends ConsumerState<_TocTab> { widget.workspace, treePath: _selectedInstanceTreePath, ); - _cutEntry = null; + _cutEntries = []; + _selectedNodePathKeys = {}; + _selectionAnchorPathKey = null; return; } if (nextStructureKey != _tocStructureKey) { @@ -4493,7 +4532,9 @@ class _TocTabState extends ConsumerState<_TocTab> { widget.workspace, treePath: _selectedInstanceTreePath, ); - _cutEntry = null; + _cutEntries = []; + _selectedNodePathKeys = {}; + _selectionAnchorPathKey = null; _selectedNodePathKey = _activeTocNodePathKey( widget.workspace, treePath: _selectedInstanceTreePath, @@ -4595,14 +4636,26 @@ class _TocTabState extends ConsumerState<_TocTab> { _RemoveSelectedTocEntryIntent: CallbackAction<_RemoveSelectedTocEntryIntent>( onInvoke: (_) { - final entry = selectedEntry; - if (entry != null) { + final selectedEntries = entries + .where( + (entry) => _selectedNodePathKeys.isEmpty + ? entry.pathKey == selectedEntry?.pathKey + : _selectedNodePathKeys.contains(entry.pathKey), + ) + .toList(); + if (selectedEntries.isNotEmpty) { unawaited( - _removeTocEntry( - context, - instanceTreePath: instance.sourceTreePath, - entry: entry, - ), + selectedEntries.length == 1 + ? _removeTocEntry( + context, + instanceTreePath: instance.sourceTreePath, + entry: selectedEntries.single, + ) + : _removeTocEntries( + context, + instanceTreePath: instance.sourceTreePath, + entries: selectedEntries, + ), ); } return null; @@ -4634,7 +4687,9 @@ class _TocTabState extends ConsumerState<_TocTab> { widget.workspace, treePath: treePath, ); - _cutEntry = null; + _selectedNodePathKeys = {}; + _selectionAnchorPathKey = null; + _cutEntries = []; }); final selected = _tocInstanceForTreePath(module, treePath); if (selected != null) { @@ -4684,11 +4739,22 @@ class _TocTabState extends ConsumerState<_TocTab> { final topicPath = writersideTopic?.filePath; final rawLabel = _tocNodeLabel(context, node); final label = _tocNodeDisplayLabel(context, node); - void selectEntry() { + _TreeSelectionModifiers selectEntry() { _treeFocusNode.requestFocus(); - if (_selectedNodePathKey != entry.pathKey) { - setState(() => _selectedNodePathKey = entry.pathKey); - } + final modifiers = _treeSelectionModifiers(); + final update = _updatedTreeSelection( + visibleKeys: [for (final item in entries) item.pathKey], + selectedKeys: _selectedNodePathKeys, + anchorKey: _selectionAnchorPathKey, + clickedIndex: index - 1, + modifiers: modifiers, + ); + setState(() { + _selectedNodePathKey = entry.pathKey; + _selectedNodePathKeys = update.selectedKeys; + _selectionAnchorPathKey = update.anchorKey; + }); + return modifiers; } void toggle() { @@ -4702,9 +4768,12 @@ class _TocTabState extends ConsumerState<_TocTab> { } return _SidebarTreeRow( + key: ValueKey('workspace-sidebar-toc-row-${entry.pathKey}'), title: label, enabled: true, - selected: _selectedNodePathKey == null + selected: _selectedNodePathKeys.isNotEmpty + ? _selectedNodePathKeys.contains(entry.pathKey) + : _selectedNodePathKey == null ? topicPath == widget.workspace.activeFilePath : entry.pathKey == _selectedNodePathKey, depth: entry.depth, @@ -4721,7 +4790,10 @@ class _TocTabState extends ConsumerState<_TocTab> { onToggle: hasChildren ? toggle : null, onTap: topicPath != null ? () async { - selectEntry(); + final modifiers = selectEntry(); + if (modifiers.control || modifiers.shift) { + return; + } final canOpen = await saveOrConfirmSafeToChangeActiveFile( context, @@ -4739,19 +4811,35 @@ class _TocTabState extends ConsumerState<_TocTab> { } : hasChildren ? () { - selectEntry(); + final modifiers = selectEntry(); + if (modifiers.control || modifiers.shift) { + return; + } toggle(); } : () { selectEntry(); }, onSecondaryTapUp: (details) { - selectEntry(); + if (!_selectedNodePathKeys.contains(entry.pathKey)) { + setState(() { + _selectedNodePathKey = entry.pathKey; + _selectedNodePathKeys = {entry.pathKey}; + _selectionAnchorPathKey = entry.pathKey; + }); + } + final selectedEntries = [ + for (final item in entries) + if (_selectedNodePathKeys.contains(item.pathKey)) item, + ]; unawaited( _showTopicContextMenu( context, instanceTreePath: instance.sourceTreePath, entry: entry, + selectedEntries: selectedEntries.isEmpty + ? [entry] + : selectedEntries, topic: writersideTopic, rawLabel: rawLabel, canEditStructure: @@ -4808,8 +4896,10 @@ class _TocTabState extends ConsumerState<_TocTab> { return; } setState(() { - _cutEntry = null; + _cutEntries = []; _selectedNodePathKey = null; + _selectedNodePathKeys = {}; + _selectionAnchorPathKey = null; }); _clearGitDetailSelection(ref); return; @@ -4838,16 +4928,143 @@ class _TocTabState extends ConsumerState<_TocTab> { return; } setState(() { - _cutEntry = null; + _cutEntries = []; _selectedNodePathKey = null; + _selectedNodePathKeys = {}; + _selectionAnchorPathKey = null; }); _clearGitDetailSelection(ref); } + Future _removeTocEntries( + BuildContext context, { + required String instanceTreePath, + required List<_TocTreeEntry> entries, + }) async { + if (entries.isEmpty || + entries.any((entry) => !entry.canEditStructureIn(instanceTreePath)) || + entries.any( + (entry) => !_tocTreeEntryStillMatches( + widget.workspace, + instanceTreePath, + entry, + ), + )) { + return; + } + final requests = []; + for (final entry in entries) { + final rawNode = _rawTocNodeForEntry( + widget.workspace, + instanceTreePath, + entry, + ); + if (rawNode == null || entry.editPath == null) { + return; + } + requests.add( + WritersideTocRemovalRequest( + entryPath: entry.editPath!, + expectedIdentity: WritersideTocNodeIdentity.fromNode(rawNode), + ), + ); + } + final labels = entries + .map((entry) => _tocNodeLabel(context, entry.node)) + .join(', '); + final confirmed = await _confirmRemoveTocEntry(context, ref, name: labels); + if (!confirmed || !context.mounted || !mounted) { + return; + } + final canRemove = await saveOrConfirmSafeToChangeActiveFile(context, ref); + if (!canRemove || !context.mounted || !mounted) { + return; + } + final removed = await ref + .read(workspaceControllerProvider.notifier) + .removeWritersideTocEntries( + treePath: instanceTreePath, + requests: requests, + ); + if (!mounted || !removed) { + return; + } + setState(() { + _cutEntries = []; + _selectedNodePathKey = null; + _selectedNodePathKeys = {}; + _selectionAnchorPathKey = null; + }); + _clearGitDetailSelection(ref); + } + + Future _copyTocEntries(List<_TocTreeEntry> entries) async { + final module = widget.workspace.writersideModule; + final pieces = []; + final state = ref.read(workspaceControllerProvider); + final activePath = state.workspace?.activeFilePath; + for (final entry in entries) { + final reference = entry.node.topicReference; + final topic = reference == null || entry.node.origin != null + ? null + : module?.topicByReference(reference); + if (topic == null) { + pieces.add(_tocNodeLabel(context, entry.node)); + } else if (activePath != null && p.equals(activePath, topic.filePath)) { + pieces.add(state.activeText); + } else { + pieces.add( + await ref.read(workspaceServiceProvider).loadText(topic.filePath), + ); + } + } + await _copyToClipboard(pieces.join('\n')); + } + + Future _refineTocTopicsWithAi( + BuildContext context, + List topics, + ) async { + final uniqueTopics = { + for (final topic in topics) topic.filePath: topic, + }.values.toList(); + for (final topic in uniqueTopics) { + if (!context.mounted || !mounted) { + return; + } + final currentPath = ref + .read(workspaceControllerProvider) + .workspace + ?.activeFilePath; + if (currentPath == null || !p.equals(currentPath, topic.filePath)) { + if (!await saveOrConfirmSafeToChangeActiveFile(context, ref) || + !context.mounted || + !mounted) { + return; + } + final opened = await ref + .read(workspaceControllerProvider.notifier) + .openActiveFile(topic.filePath); + if (!opened || !mounted || !context.mounted) { + return; + } + } + final refined = await _refineActiveSourceRangesWithAi( + context, + ref, + const [_SourceTextRange.fullDocument()], + ); + if (!refined) { + return; + } + } + } + Future _showTopicContextMenu( BuildContext context, { required String instanceTreePath, required _TocTreeEntry entry, + required List<_TocTreeEntry> selectedEntries, required WritersideTopic? topic, required String rawLabel, required bool canEditStructure, @@ -4860,16 +5077,32 @@ class _TocTabState extends ConsumerState<_TocTab> { final gitRelativePath = topicPath == null ? null : _gitRelativePathForFileTreeEntry(ref, topicPath); - final cutEntry = _cutEntry; final rawNode = canEditStructure ? _rawTocNodeForEntry(widget.workspace, instanceTreePath, entry) : null; final canPaste = - cutEntry != null && - _tocClipboardEntryStillMatches(widget.workspace, cutEntry) && + _cutEntries.isNotEmpty && + _cutEntries.every( + (source) => _tocClipboardEntryStillMatches(widget.workspace, source), + ) && canEditStructure && rawNode != null && - _canPasteTocTreeEntry(cutEntry, instanceTreePath, entry.editPath!); + _canPasteTocTreeEntries(_cutEntries, instanceTreePath, entry.editPath!); + final selectedTopics = [ + for (final selected in selectedEntries) + if (selected.node.topicReference case final reference?) + if (selected.node.origin == null) + widget.workspace.writersideModule?.topicByReference(reference), + ].nonNulls.toList(); + final canRefineSelection = + selectedTopics.length == selectedEntries.length && + selectedTopics.every( + (selectedTopic) => + selectedTopic.format == WritersideTopicFormat.markdown, + ); + final canEditSelection = selectedEntries.every( + (selected) => selected.canEditStructureIn(instanceTreePath), + ); final action = await _showTocTreeMenu( context, position, @@ -4878,6 +5111,9 @@ class _TocTabState extends ConsumerState<_TocTab> { showPaste: canPaste, enableGitActions: gitRelativePath != null, canEditStructure: canEditStructure, + multipleSelection: selectedEntries.length > 1, + canRefineSelection: canRefineSelection, + canEditSelection: canEditSelection, ); if (!mounted || !context.mounted || action == null) { return; @@ -4888,6 +5124,20 @@ class _TocTabState extends ConsumerState<_TocTab> { return; } switch (action) { + case _TocTreeAction.copy: + await _copyTocEntries(selectedEntries); + case _TocTreeAction.refineWithAi: + if (canRefineSelection) { + await _refineTocTopicsWithAi(context, selectedTopics); + } + case _TocTreeAction.deleteSelection: + if (canEditSelection) { + await _removeTocEntries( + context, + instanceTreePath: instanceTreePath, + entries: selectedEntries, + ); + } case _TocTreeAction.newSiblingTopic: if (!canEditStructure) { return; @@ -4936,31 +5186,48 @@ class _TocTabState extends ConsumerState<_TocTab> { return; } if (renamed) { - setState(() => _cutEntry = null); + setState(() => _cutEntries = []); _clearGitDetailSelection(ref); } case _TocTreeAction.cut: - if (!canEditStructure || rawNode == null) { + if (!canEditSelection) { return; } - setState(() { - _cutEntry = _TocTreeClipboardEntry( - treePath: instanceTreePath, - nodePath: entry.editPath!, - nodeFingerprint: _tocNodeFingerprint(rawNode), - nodeIdentity: WritersideTocNodeIdentity.fromNode(rawNode), + final clipboardEntries = <_TocTreeClipboardEntry>[]; + for (final selected in _topLevelTocEntries(selectedEntries)) { + final selectedRawNode = _rawTocNodeForEntry( + widget.workspace, + instanceTreePath, + selected, + ); + if (selectedRawNode == null || selected.editPath == null) { + return; + } + clipboardEntries.add( + _TocTreeClipboardEntry( + treePath: instanceTreePath, + nodePath: selected.editPath!, + nodeFingerprint: _tocNodeFingerprint(selectedRawNode), + nodeIdentity: WritersideTocNodeIdentity.fromNode(selectedRawNode), + ), ); + } + setState(() { + _cutEntries = clipboardEntries; }); case _TocTreeAction.pasteAfter: case _TocTreeAction.pasteAsChild: if (!canEditStructure || rawNode == null) { return; } - final source = _cutEntry; - if (source == null || - !_tocClipboardEntryStillMatches(widget.workspace, source)) { - if (source != null && mounted) { - setState(() => _cutEntry = null); + final sources = List<_TocTreeClipboardEntry>.of(_cutEntries); + if (sources.isEmpty || + sources.any( + (source) => + !_tocClipboardEntryStillMatches(widget.workspace, source), + )) { + if (sources.isNotEmpty && mounted) { + setState(() => _cutEntries = []); } return; } @@ -4969,28 +5236,47 @@ class _TocTabState extends ConsumerState<_TocTab> { !mounted || !context.mounted || !entryIsCurrent() || - !_tocClipboardEntryStillMatches(widget.workspace, source)) { + sources.any( + (source) => + !_tocClipboardEntryStillMatches(widget.workspace, source), + )) { return; } - final moved = await ref - .read(workspaceControllerProvider.notifier) - .moveWritersideTocEntry( - treePath: instanceTreePath, - sourcePath: source.nodePath, - placement: action == _TocTreeAction.pasteAsChild - ? WritersideTopicCreatePlacement.child - : WritersideTopicCreatePlacement.sibling, - referencePath: entry.editPath!, - sourceIdentity: source.nodeIdentity, - referenceIdentity: WritersideTocNodeIdentity.fromNode(rawNode), - ); + final placement = action == _TocTreeAction.pasteAsChild + ? WritersideTopicCreatePlacement.child + : WritersideTopicCreatePlacement.sibling; + final controller = ref.read(workspaceControllerProvider.notifier); + final moved = sources.length == 1 + ? await controller.moveWritersideTocEntry( + treePath: instanceTreePath, + sourcePath: sources.single.nodePath, + placement: placement, + referencePath: entry.editPath!, + sourceIdentity: sources.single.nodeIdentity, + referenceIdentity: WritersideTocNodeIdentity.fromNode(rawNode), + ) + : await controller.moveWritersideTocEntries( + treePath: instanceTreePath, + sources: [ + for (final source in sources) + WritersideTocMoveEntry( + sourcePath: source.nodePath, + sourceIdentity: source.nodeIdentity, + ), + ], + placement: placement, + referencePath: entry.editPath!, + referenceIdentity: WritersideTocNodeIdentity.fromNode(rawNode), + ); if (!mounted) { return; } if (moved) { setState(() { - _cutEntry = null; + _cutEntries = []; _selectedNodePathKey = null; + _selectedNodePathKeys = {}; + _selectionAnchorPathKey = null; }); _clearGitDetailSelection(ref); } @@ -5016,8 +5302,10 @@ class _TocTabState extends ConsumerState<_TocTab> { ); if (mounted && result != null) { setState(() { - _cutEntry = null; + _cutEntries = []; _selectedNodePathKey = null; + _selectedNodePathKeys = {}; + _selectionAnchorPathKey = null; }); _clearGitDetailSelection(ref); } @@ -5095,7 +5383,9 @@ class _TocTabState extends ConsumerState<_TocTab> { setState(() { _selectedInstanceTreePath = result.treePath; _selectedNodePathKey = null; - _cutEntry = null; + _selectedNodePathKeys = {}; + _selectionAnchorPathKey = null; + _cutEntries = []; }); } } @@ -5166,15 +5456,31 @@ class _TocTreeClipboardEntry { final WritersideTocNodeIdentity nodeIdentity; } +List<_TocTreeEntry> _topLevelTocEntries(List<_TocTreeEntry> entries) { + final result = <_TocTreeEntry>[]; + for (final entry in entries) { + if (result.any( + (candidate) => _tocPathContains(candidate.path, entry.path), + )) { + continue; + } + result.add(entry); + } + return result; +} + class _RemoveSelectedTocEntryIntent extends Intent { const _RemoveSelectedTocEntryIntent(); } enum _TocTreeAction { + copy, newSiblingTopic, newChildTopic, rename, cut, + refineWithAi, + deleteSelection, pasteAfter, pasteAsChild, removeFromToc, @@ -5194,94 +5500,141 @@ Future<_TocTreeAction?> _showTocTreeMenu( required bool showPaste, required bool enableGitActions, required bool canEditStructure, + required bool multipleSelection, + required bool canRefineSelection, + required bool canEditSelection, }) { return _showSidebarTreeMenu<_TocTreeAction>( context, position, items: [ - BusyMarkPopupMenuItem( - value: _TocTreeAction.newSiblingTopic, - label: context.l10n.newSiblingTopic, - icon: BusyMarkGlyphs.newDocument, - enabled: canEditStructure, - ), - BusyMarkPopupMenuItem( - value: _TocTreeAction.newChildTopic, - label: context.l10n.newChildTopic, - icon: BusyMarkGlyphs.tree, - enabled: canEditStructure, - ), - const PopupMenuDivider(height: BusyMarkSpacing.sm), - BusyMarkPopupMenuItem( - value: _TocTreeAction.rename, - label: context.l10n.renameTopicFile, - icon: BusyMarkGlyphs.edit, - enabled: hasTopicFile, - ), - BusyMarkPopupMenuItem( - value: _TocTreeAction.cut, - label: context.l10n.cut, - icon: BusyMarkGlyphs.cut, - enabled: canEditStructure, - ), - BusyMarkPopupMenuItem( - value: _TocTreeAction.pasteAfter, - label: context.l10n.pasteAfterTopic, - icon: BusyMarkGlyphs.paste, - enabled: showPaste, - ), - BusyMarkPopupMenuItem( - value: _TocTreeAction.pasteAsChild, - label: context.l10n.pasteAsChildTopic, - icon: BusyMarkGlyphs.tree, - enabled: showPaste, - ), - BusyMarkPopupMenuItem( - value: _TocTreeAction.removeFromToc, - label: context.l10n.removeTocElement, - icon: BusyMarkGlyphs.outdentFor(Directionality.of(context)), - shortcut: BusyMarkTreeShortcutLabels.deleteSelection, - enabled: canEditStructure, - ), - BusyMarkPopupMenuItem( - value: _TocTreeAction.delete, - label: context.l10n.safeDeleteTopicFile, - icon: BusyMarkGlyphs.delete, - enabled: hasTopicFile, - ), - const PopupMenuDivider(height: BusyMarkSpacing.sm), - BusyMarkPopupMenuItem( - value: _TocTreeAction.copyName, - label: context.l10n.copyName, - icon: BusyMarkGlyphs.copy, - ), - BusyMarkPopupMenuItem( - value: _TocTreeAction.copyPath, - label: context.l10n.copyPath, - icon: BusyMarkGlyphs.copy, - enabled: hasTopicFile, - ), - const PopupMenuDivider(height: BusyMarkSpacing.sm), - BusyMarkPopupMenuItem( - value: _TocTreeAction.openInFiles, - label: context.l10n.openInFiles, - icon: BusyMarkGlyphs.folderOpen, - enabled: hasTopicFile, - ), - BusyMarkPopupMenuItem( - value: _TocTreeAction.addToGit, - label: context.l10n.addToGit, - icon: BusyMarkGlyphs.branch, - enabled: enableGitActions, - ), - if (showHistory) const PopupMenuDivider(height: BusyMarkSpacing.sm), - if (showHistory) + if (multipleSelection) ...[ BusyMarkPopupMenuItem( - value: _TocTreeAction.fileHistory, - label: context.l10n.fileHistory, - icon: BusyMarkGlyphs.documentHistory, + value: _TocTreeAction.copy, + label: context.l10n.copy, + icon: BusyMarkGlyphs.copy, + ), + BusyMarkPopupMenuItem( + value: _TocTreeAction.cut, + label: context.l10n.cut, + icon: BusyMarkGlyphs.cut, + enabled: canEditSelection, + ), + BusyMarkPopupMenuItem( + value: _TocTreeAction.refineWithAi, + label: context.l10n.aiRefineWithAi, + icon: BusyMarkGlyphs.ai, + enabled: canRefineSelection, + ), + const PopupMenuDivider(height: BusyMarkSpacing.sm), + BusyMarkPopupMenuItem( + value: _TocTreeAction.deleteSelection, + label: context.l10n.delete, + icon: BusyMarkGlyphs.delete, + enabled: canEditSelection, + ), + ] else ...[ + BusyMarkPopupMenuItem( + value: _TocTreeAction.copy, + label: context.l10n.copy, + icon: BusyMarkGlyphs.copy, + ), + BusyMarkPopupMenuItem( + value: _TocTreeAction.refineWithAi, + label: context.l10n.aiRefineWithAi, + icon: BusyMarkGlyphs.ai, + enabled: canRefineSelection, + ), + BusyMarkPopupMenuItem( + value: _TocTreeAction.copyName, + label: context.l10n.copyName, + icon: BusyMarkGlyphs.copy, + ), + BusyMarkPopupMenuItem( + value: _TocTreeAction.copyPath, + label: context.l10n.copyPath, + icon: BusyMarkGlyphs.copy, + enabled: hasTopicFile, + ), + const PopupMenuDivider(height: BusyMarkSpacing.sm), + BusyMarkPopupMenuItem( + value: _TocTreeAction.newSiblingTopic, + label: context.l10n.newSiblingTopic, + icon: BusyMarkGlyphs.newDocument, + enabled: canEditStructure, + ), + BusyMarkPopupMenuItem( + value: _TocTreeAction.newChildTopic, + label: context.l10n.newChildTopic, + icon: BusyMarkGlyphs.tree, + enabled: canEditStructure, + ), + const PopupMenuDivider(height: BusyMarkSpacing.sm), + BusyMarkPopupMenuItem( + value: _TocTreeAction.rename, + label: context.l10n.renameTopicFile, + icon: BusyMarkGlyphs.edit, + enabled: hasTopicFile, + ), + BusyMarkPopupMenuItem( + value: _TocTreeAction.cut, + label: context.l10n.cut, + icon: BusyMarkGlyphs.cut, + enabled: canEditStructure, + ), + BusyMarkPopupMenuItem( + value: _TocTreeAction.pasteAfter, + label: context.l10n.pasteAfterTopic, + icon: BusyMarkGlyphs.paste, + enabled: showPaste, + ), + BusyMarkPopupMenuItem( + value: _TocTreeAction.pasteAsChild, + label: context.l10n.pasteAsChildTopic, + icon: BusyMarkGlyphs.tree, + enabled: showPaste, + ), + BusyMarkPopupMenuItem( + value: _TocTreeAction.removeFromToc, + label: context.l10n.removeTocElement, + icon: BusyMarkGlyphs.outdentFor(Directionality.of(context)), + shortcut: BusyMarkTreeShortcutLabels.deleteSelection, + enabled: canEditStructure, + ), + BusyMarkPopupMenuItem( + value: _TocTreeAction.delete, + label: context.l10n.safeDeleteTopicFile, + icon: BusyMarkGlyphs.delete, + enabled: hasTopicFile, + ), + BusyMarkPopupMenuItem( + value: _TocTreeAction.deleteSelection, + label: context.l10n.delete, + icon: BusyMarkGlyphs.delete, + enabled: canEditSelection, + ), + const PopupMenuDivider(height: BusyMarkSpacing.sm), + BusyMarkPopupMenuItem( + value: _TocTreeAction.openInFiles, + label: context.l10n.openInFiles, + icon: BusyMarkGlyphs.folderOpen, + enabled: hasTopicFile, + ), + BusyMarkPopupMenuItem( + value: _TocTreeAction.addToGit, + label: context.l10n.addToGit, + icon: BusyMarkGlyphs.branch, enabled: enableGitActions, ), + if (showHistory) const PopupMenuDivider(height: BusyMarkSpacing.sm), + if (showHistory) + BusyMarkPopupMenuItem( + value: _TocTreeAction.fileHistory, + label: context.l10n.fileHistory, + icon: BusyMarkGlyphs.documentHistory, + enabled: enableGitActions, + ), + ], ], ); } @@ -5298,6 +5651,14 @@ bool _canPasteTocTreeEntry( return !_tocPathContains(source.nodePath, targetPath); } +bool _canPasteTocTreeEntries( + List<_TocTreeClipboardEntry> sources, + String targetTreePath, + List targetPath, +) => sources.every( + (source) => _canPasteTocTreeEntry(source, targetTreePath, targetPath), +); + bool _tocClipboardEntryStillMatches( Workspace workspace, _TocTreeClipboardEntry source, @@ -6270,6 +6631,196 @@ WritersideInstance _defaultWritersideInstance(WritersideModule module) { module.instances.first; } +typedef _TreeSelectionModifiers = ({bool control, bool shift}); +typedef _TreeSelectionUpdate = ({Set selectedKeys, String anchorKey}); + +_TreeSelectionModifiers _treeSelectionModifiers() { + final keyboard = HardwareKeyboard.instance; + return ( + control: keyboard.isControlPressed || keyboard.isMetaPressed, + shift: keyboard.isShiftPressed, + ); +} + +_TreeSelectionUpdate _updatedTreeSelection({ + required List visibleKeys, + required Set selectedKeys, + required String? anchorKey, + required int clickedIndex, + required _TreeSelectionModifiers modifiers, +}) { + final clickedKey = visibleKeys[clickedIndex]; + if (modifiers.shift) { + final anchorIndex = anchorKey == null ? -1 : visibleKeys.indexOf(anchorKey); + final rangeStart = math.min( + anchorIndex < 0 ? clickedIndex : anchorIndex, + clickedIndex, + ); + final rangeEnd = math.max( + anchorIndex < 0 ? clickedIndex : anchorIndex, + clickedIndex, + ); + final range = visibleKeys.getRange(rangeStart, rangeEnd + 1); + return ( + selectedKeys: modifiers.control + ? ({...selectedKeys, ...range}) + : range.toSet(), + anchorKey: anchorIndex < 0 ? clickedKey : anchorKey!, + ); + } + if (modifiers.control) { + final next = {...selectedKeys}; + if (!next.remove(clickedKey)) { + next.add(clickedKey); + } + return (selectedKeys: next, anchorKey: clickedKey); + } + return (selectedKeys: {clickedKey}, anchorKey: clickedKey); +} + +class _SourceTextRange { + const _SourceTextRange(this.start, this.end) : fullDocument = false; + + const _SourceTextRange.fullDocument() + : start = 0, + end = 0, + fullDocument = true; + + final int start; + final int end; + final bool fullDocument; +} + +List<_SourceTextRange> _mergeSourceTextRanges( + Iterable<_SourceTextRange> ranges, +) { + final sorted = ranges.toList() + ..sort((left, right) => left.start.compareTo(right.start)); + final merged = <_SourceTextRange>[]; + for (final range in sorted) { + if (range.end <= range.start) { + continue; + } + if (merged.isEmpty || range.start > merged.last.end) { + merged.add(range); + continue; + } + final previous = merged.removeLast(); + merged.add( + _SourceTextRange(previous.start, math.max(previous.end, range.end)), + ); + } + return merged; +} + +String _sourceTextForRanges(String source, List<_SourceTextRange> ranges) => + ranges.map((range) => source.substring(range.start, range.end)).join(); + +String _sourceWithoutRanges(String source, List<_SourceTextRange> ranges) { + var result = source; + for (final range in ranges.reversed) { + result = result.replaceRange(range.start, range.end, ''); + } + return result; +} + +List _selectedMarkdownSections( + String source, + List headings, + Set selectedIndexes, +) { + final sections = []; + final sortedIndexes = selectedIndexes.toList()..sort(); + for (final index in sortedIndexes) { + final section = MarkdownSectionEditor.fromHeadings( + source: source, + headings: headings, + headingIndex: index, + ); + if (sections.isNotEmpty && section.startOffset < sections.last.endOffset) { + continue; + } + sections.add(section); + } + return sections; +} + +Future _refineActiveSourceRangesWithAi( + BuildContext context, + WidgetRef ref, + List<_SourceTextRange> requestedRanges, +) async { + for (final requestedRange in requestedRanges.reversed) { + if (!context.mounted) { + return false; + } + final state = ref.read(workspaceControllerProvider); + final workspace = state.workspace; + if (workspace == null) { + return false; + } + final path = workspace.activeFilePath ?? workspace.markdown?.filePath; + final source = state.activeText; + final start = requestedRange.fullDocument ? 0 : requestedRange.start; + final end = requestedRange.fullDocument + ? source.length + : requestedRange.end; + if (start < 0 || end <= start || end > source.length) { + return false; + } + final result = await showBusyMarkAiEdit( + context, + ref, + AiEditorSnapshot( + documentSource: source, + selectionStart: start, + selectionEnd: end, + anchorOffset: start, + sourceRevision: ref + .read(workspaceControllerProvider.notifier) + .editRevision, + targetId: path ?? 'untitled', + documentPath: path, + blockTargetAvailable: false, + ), + fixedTarget: requestedRange.fullDocument + ? AiEditTargetKind.document + : AiEditTargetKind.selection, + ); + if (result == null || !context.mounted) { + return false; + } + final invocation = result.invocation; + final replacementStart = invocation.replacementStart; + final replacementEnd = invocation.replacementEnd; + if (replacementStart == null || + replacementEnd == null || + replacementStart < 0 || + replacementEnd < replacementStart || + replacementEnd > source.length || + invocation.documentSource != source || + !_isSameActiveDocument( + ref.read(workspaceControllerProvider), + workspaceId: workspace.id, + activePath: path, + source: source, + )) { + return false; + } + ref + .read(workspaceControllerProvider.notifier) + .updateActiveText( + source.replaceRange( + replacementStart, + replacementEnd, + result.replacement, + ), + sourceFilePath: path, + ); + } + return true; +} + class _OutlineTab extends ConsumerStatefulWidget { const _OutlineTab({required this.workspace, required this.headings}); @@ -6288,6 +6839,8 @@ class _OutlineTabState extends ConsumerState<_OutlineTab> { late Set _expandedNodeKeys; final _treeScrollController = ScrollController(); String? _revealedActiveNodeKey; + Set _selectedNodeKeys = {}; + String? _selectionAnchorKey; @override void initState() { @@ -6307,6 +6860,8 @@ class _OutlineTabState extends ConsumerState<_OutlineTab> { _outlineStateKey = nextKey; _expandedNodeKeys = _initialExpandedOutlineNodeKeys(widget.headings); _revealedActiveNodeKey = null; + _selectedNodeKeys = {}; + _selectionAnchorKey = null; } } @@ -6356,28 +6911,57 @@ class _OutlineTabState extends ConsumerState<_OutlineTab> { } Future _showSectionMenu( - DocumentOutlineHeading heading, - int headingIndex, + List<_OutlineTreeEntry> entries, + Map headingIndexes, + int clickedEntryIndex, Offset position, ) async { - final capabilities = _outlineSectionCapabilities( - widget.headings, - headingIndex, - ); + final clickedHeading = entries[clickedEntryIndex].node.heading; + final clickedKey = _outlineNodeKey(clickedHeading); + if (!_selectedNodeKeys.contains(clickedKey)) { + setState(() { + _selectedNodeKeys = {clickedKey}; + _selectionAnchorKey = clickedKey; + }); + } + final selectedHeadings = <({DocumentOutlineHeading heading, int index})>[ + for (final entry in entries) + if (_selectedNodeKeys.contains(_outlineNodeKey(entry.node.heading))) + ( + heading: entry.node.heading, + index: headingIndexes[_outlineNodeKey(entry.node.heading)]!, + ), + ]; + if (selectedHeadings.isEmpty) { + selectedHeadings.add(( + heading: clickedHeading, + index: headingIndexes[clickedKey]!, + )); + } + final multiple = selectedHeadings.length > 1; + final capabilities = multiple + ? null + : _outlineSectionCapabilities( + widget.headings, + selectedHeadings.single.index, + ); final action = await showBusyMarkContextMenu<_OutlineSectionAction>( context, position, - items: _outlineSectionMenuItems(context, capabilities), + items: _outlineSectionMenuItems( + context, + capabilities, + multipleSelection: multiple, + ), ); if (action == null || !mounted) { return; } - await _runSectionAction(heading, headingIndex, action); + await _runSectionAction(selectedHeadings, action); } Future _runSectionAction( - DocumentOutlineHeading heading, - int preferredIndex, + List<({DocumentOutlineHeading heading, int index})> selectedHeadings, _OutlineSectionAction action, ) async { final initialState = ref.read(workspaceControllerProvider); @@ -6409,45 +6993,62 @@ class _OutlineTabState extends ConsumerState<_OutlineTab> { )) { return; } - final headingIndex = _resolveParsedOutlineHeadingIndex( + final resolvedIndexes = {}; + for (final selected in selectedHeadings) { + final index = _resolveParsedOutlineHeadingIndex( + parsed.headings, + selected.heading, + selected.index, + ); + if (index >= 0) { + resolvedIndexes.add(index); + } + } + if (resolvedIndexes.isEmpty) { + return; + } + final sections = _selectedMarkdownSections( + source, parsed.headings, - heading, - preferredIndex, + resolvedIndexes, ); - if (headingIndex < 0) { + final ranges = _mergeSourceTextRanges([ + for (final section in sections) + _SourceTextRange(section.startOffset, section.endOffset), + ]); + if (ranges.isEmpty) { return; } - final section = MarkdownSectionEditor.fromHeadings( - source: source, - headings: parsed.headings, - headingIndex: headingIndex, - ); + final singleSection = sections.length == 1 ? sections.single : null; String? updatedSource; switch (action) { case _OutlineSectionAction.copy: - await _copyToClipboard(section.sectionText); + await _copyToClipboard(_sourceTextForRanges(source, ranges)); return; case _OutlineSectionAction.cut: - await _copyToClipboard(section.sectionText); - updatedSource = section.withoutSection(); + await _copyToClipboard(_sourceTextForRanges(source, ranges)); + updatedSource = _sourceWithoutRanges(source, ranges); + case _OutlineSectionAction.refineWithAi: + await _refineActiveSourceRangesWithAi(context, ref, ranges); + return; case _OutlineSectionAction.delete: final confirmed = await _confirmDeleteOutlineSection( context, ref, - heading.text, + selectedHeadings.map((selected) => selected.heading.text).join(', '), ); if (!confirmed || !mounted) { return; } - updatedSource = section.withoutSection(); + updatedSource = _sourceWithoutRanges(source, ranges); case _OutlineSectionAction.promote: - updatedSource = section.promote(); + updatedSource = singleSection?.promote(); case _OutlineSectionAction.demote: - updatedSource = section.demote(); + updatedSource = singleSection?.demote(); case _OutlineSectionAction.moveUp: - updatedSource = section.moveUp(); + updatedSource = singleSection?.moveUp(); case _OutlineSectionAction.moveDown: - updatedSource = section.moveDown(); + updatedSource = singleSection?.moveDown(); } if (!mounted || updatedSource == null || @@ -6502,6 +7103,9 @@ class _OutlineTabState extends ConsumerState<_OutlineTab> { final headingIndex = headingIndexes[key]!; final expanded = _expandedNodeKeys.contains(key); final hasChildren = node.children.isNotEmpty; + final selected = _selectedNodeKeys.isEmpty + ? key == activeNodeKey + : _selectedNodeKeys.contains(key); void toggle() { setState(() { _revealedActiveNodeKey = null; @@ -6522,9 +7126,14 @@ class _OutlineTabState extends ConsumerState<_OutlineTab> { leading: _HeadingBadge(level: heading.level), hasChildren: hasChildren, expanded: expanded, - selected: key == activeNodeKey, + selected: selected, onToggle: hasChildren ? toggle : null, onTap: () { + final modifiers = _treeSelectionModifiers(); + _updateOutlineSelection(entries, index, modifiers); + if (modifiers.control || modifiers.shift) { + return; + } _setOutlineViewportTarget( ref, workspace: widget.workspace, @@ -6543,18 +7152,45 @@ class _OutlineTabState extends ConsumerState<_OutlineTab> { ); }, onSecondaryTapUp: (details) => unawaited( - _showSectionMenu(heading, headingIndex, details.globalPosition), + _showSectionMenu( + entries, + headingIndexes, + index, + details.globalPosition, + ), ), ), ); }, ); } + + void _updateOutlineSelection( + List<_OutlineTreeEntry> entries, + int clickedIndex, + _TreeSelectionModifiers modifiers, + ) { + final keys = [ + for (final entry in entries) _outlineNodeKey(entry.node.heading), + ]; + final update = _updatedTreeSelection( + visibleKeys: keys, + selectedKeys: _selectedNodeKeys, + anchorKey: _selectionAnchorKey, + clickedIndex: clickedIndex, + modifiers: modifiers, + ); + setState(() { + _selectedNodeKeys = update.selectedKeys; + _selectionAnchorKey = update.anchorKey; + }); + } } enum _OutlineSectionAction { copy, cut, + refineWithAi, delete, promote, demote, @@ -6603,8 +7239,9 @@ _OutlineSectionCapabilities _outlineSectionCapabilities( List> _outlineSectionMenuItems( BuildContext context, - _OutlineSectionCapabilities capabilities, -) { + _OutlineSectionCapabilities? capabilities, { + required bool multipleSelection, +}) { final direction = Directionality.of(context); return [ BusyMarkPopupMenuItem( @@ -6617,32 +7254,39 @@ List> _outlineSectionMenuItems( label: context.l10n.cut, icon: BusyMarkGlyphs.cut, ), - const PopupMenuDivider(height: BusyMarkSpacing.sm), - BusyMarkPopupMenuItem( - value: _OutlineSectionAction.promote, - label: context.l10n.promoteSection, - icon: BusyMarkGlyphs.outdentFor(direction), - enabled: capabilities.canPromote, - ), - BusyMarkPopupMenuItem( - value: _OutlineSectionAction.demote, - label: context.l10n.demoteSection, - icon: BusyMarkGlyphs.indentFor(direction), - enabled: capabilities.canDemote, - ), - const PopupMenuDivider(height: BusyMarkSpacing.sm), - BusyMarkPopupMenuItem( - value: _OutlineSectionAction.moveUp, - label: context.l10n.moveSectionUp, - icon: BusyMarkGlyphs.upArrow, - enabled: capabilities.canMoveUp, - ), BusyMarkPopupMenuItem( - value: _OutlineSectionAction.moveDown, - label: context.l10n.moveSectionDown, - icon: BusyMarkGlyphs.downArrow, - enabled: capabilities.canMoveDown, + value: _OutlineSectionAction.refineWithAi, + label: context.l10n.aiRefineWithAi, + icon: BusyMarkGlyphs.ai, ), + if (!multipleSelection) ...[ + const PopupMenuDivider(height: BusyMarkSpacing.sm), + BusyMarkPopupMenuItem( + value: _OutlineSectionAction.promote, + label: context.l10n.promoteSection, + icon: BusyMarkGlyphs.outdentFor(direction), + enabled: capabilities!.canPromote, + ), + BusyMarkPopupMenuItem( + value: _OutlineSectionAction.demote, + label: context.l10n.demoteSection, + icon: BusyMarkGlyphs.indentFor(direction), + enabled: capabilities.canDemote, + ), + const PopupMenuDivider(height: BusyMarkSpacing.sm), + BusyMarkPopupMenuItem( + value: _OutlineSectionAction.moveUp, + label: context.l10n.moveSectionUp, + icon: BusyMarkGlyphs.upArrow, + enabled: capabilities.canMoveUp, + ), + BusyMarkPopupMenuItem( + value: _OutlineSectionAction.moveDown, + label: context.l10n.moveSectionDown, + icon: BusyMarkGlyphs.downArrow, + enabled: capabilities.canMoveDown, + ), + ], const PopupMenuDivider(height: BusyMarkSpacing.sm), BusyMarkPopupMenuItem( value: _OutlineSectionAction.delete, diff --git a/lib/src/workspace/workspace_controller.dart b/lib/src/workspace/workspace_controller.dart index c0f16790..b331f1f9 100644 --- a/lib/src/workspace/workspace_controller.dart +++ b/lib/src/workspace/workspace_controller.dart @@ -14,6 +14,7 @@ import '../writerside/writerside_project_creator.dart'; import '../writerside/writerside_instance_service.dart'; import '../writerside/writerside_topic_removal_service.dart'; import '../writerside/writerside_topic_creator.dart'; +import '../writerside/writerside_toc_editor.dart'; import 'workspace_model.dart'; import 'workspace_message.dart'; import 'workspace_service.dart'; @@ -495,6 +496,26 @@ class WorkspaceController extends Notifier { }); } + Future moveWritersideTocEntries({ + required String treePath, + required List sources, + required WritersideTopicCreatePlacement placement, + required List? referencePath, + WritersideTocNodeIdentity? referenceIdentity, + }) { + return _runWorkspaceFileOperation((workspace) async { + await _service.moveWritersideTocEntries( + workspace, + treePath: treePath, + sources: sources, + placement: placement, + referencePath: referencePath, + referenceIdentity: referenceIdentity, + ); + return null; + }); + } + Future removeWritersideTocEntry({ required String treePath, required List nodePath, @@ -511,6 +532,20 @@ class WorkspaceController extends Notifier { }); } + Future removeWritersideTocEntries({ + required String treePath, + required List requests, + }) { + return _runWorkspaceFileOperation((workspace) async { + await _service.removeWritersideTocEntries( + workspace, + treePath: treePath, + requests: requests, + ); + return null; + }); + } + Future renameWritersideTopicFile(String topicPath, String newFileName) { final activeFilePath = state.workspace?.activeFilePath; return _runWorkspaceFileOperation((workspace) async { diff --git a/lib/src/workspace/workspace_service.dart b/lib/src/workspace/workspace_service.dart index 1251f597..d302645f 100644 --- a/lib/src/workspace/workspace_service.dart +++ b/lib/src/workspace/workspace_service.dart @@ -190,6 +190,30 @@ class WorkspaceService { ); } + Future moveWritersideTocEntries( + Workspace workspace, { + required String treePath, + required List sources, + required WritersideTopicCreatePlacement placement, + required List? referencePath, + WritersideTocNodeIdentity? referenceIdentity, + }) async { + final module = await _currentWritersideModule(workspace); + final instance = _writersideInstanceForTree(module, treePath); + await writersideTocEditor.moveSubtrees( + WritersideTocEditTarget( + rootPath: module.rootPath, + treePath: instance.sourceTreePath, + ), + WritersideTocBatchMoveRequest( + sources: sources, + placement: placement, + referencePath: referencePath, + referenceIdentity: referenceIdentity, + ), + ); + } + Future removeWritersideTocEntry( Workspace workspace, { required String treePath, @@ -208,6 +232,22 @@ class WorkspaceService { ); } + Future removeWritersideTocEntries( + Workspace workspace, { + required String treePath, + required List requests, + }) async { + final module = await _currentWritersideModule(workspace); + final instance = _writersideInstanceForTree(module, treePath); + await writersideTocEditor.removeEntries( + WritersideTocEditTarget( + rootPath: module.rootPath, + treePath: instance.sourceTreePath, + ), + requests, + ); + } + Future renameWritersideTopicFile( Workspace workspace, String topicPath, diff --git a/lib/src/writerside/writerside_toc_editor.dart b/lib/src/writerside/writerside_toc_editor.dart index 4a7661fe..016b936b 100644 --- a/lib/src/writerside/writerside_toc_editor.dart +++ b/lib/src/writerside/writerside_toc_editor.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:math' as math; import 'package:path/path.dart' as p; import 'package:xml/xml.dart'; @@ -44,6 +45,27 @@ class WritersideTocMoveRequest { final WritersideTocNodeIdentity? referenceIdentity; } +class WritersideTocMoveEntry { + const WritersideTocMoveEntry({required this.sourcePath, this.sourceIdentity}); + + final List sourcePath; + final WritersideTocNodeIdentity? sourceIdentity; +} + +class WritersideTocBatchMoveRequest { + const WritersideTocBatchMoveRequest({ + required this.sources, + required this.placement, + this.referencePath, + this.referenceIdentity, + }); + + final List sources; + final WritersideTopicCreatePlacement placement; + final List? referencePath; + final WritersideTocNodeIdentity? referenceIdentity; +} + class WritersideTocMutationResult { const WritersideTocMutationResult({required this.treePath, this.entryPath}); @@ -53,6 +75,16 @@ class WritersideTocMutationResult { final List? entryPath; } +class WritersideTocRemovalRequest { + const WritersideTocRemovalRequest({ + required this.entryPath, + this.expectedIdentity, + }); + + final List entryPath; + final WritersideTocNodeIdentity? expectedIdentity; +} + /// Performs structural Writerside TOC mutations inside a guarded module root. class WritersideTocEditor { const WritersideTocEditor({ @@ -133,6 +165,105 @@ class WritersideTocEditor { ); } + /// Moves several complete subtrees as one ordered group. + Future moveSubtrees( + WritersideTocEditTarget target, + WritersideTocBatchMoveRequest request, + ) async { + if (request.sources.isEmpty) { + throw const BusyMarkException('writerside.toc.path-invalid'); + } + for (final source in request.sources) { + _validatePath(source.sourcePath, role: 'source'); + } + for (var left = 0; left < request.sources.length; left += 1) { + for (var right = left + 1; right < request.sources.length; right += 1) { + final leftPath = request.sources[left].sourcePath; + final rightPath = request.sources[right].sourcePath; + if (_isSameOrDescendant(leftPath, rightPath) || + _isSameOrDescendant(rightPath, leftPath)) { + throw const BusyMarkException('writerside.toc.move-invalid-target'); + } + } + } + final referencePath = request.referencePath; + if (request.placement == WritersideTopicCreatePlacement.root) { + if (referencePath != null) { + throw _invalidPath(referencePath, role: 'destination'); + } + } else if (referencePath == null) { + throw const BusyMarkException('writerside.toc.destination-required'); + } else { + _validatePath(referencePath, role: 'destination'); + for (final source in request.sources) { + if (_isSameOrDescendant(source.sourcePath, referencePath)) { + throw BusyMarkException( + 'writerside.toc.move-invalid-target', + args: { + 'source': _pathLabel(source.sourcePath), + 'destination': _pathLabel(referencePath), + }, + ); + } + } + } + + final session = await _load(target); + final elements = []; + for (final source in request.sources) { + final element = _elementAtPath( + session.root, + source.sourcePath, + role: 'source', + ); + if (!(source.sourceIdentity?.matches(element) ?? true)) { + throw _invalidPath(source.sourcePath, role: 'source'); + } + elements.add(element); + } + if (elements.toSet().length != elements.length) { + throw const BusyMarkException('writerside.toc.move-invalid-target'); + } + final reference = referencePath == null + ? null + : _elementAtPath(session.root, referencePath, role: 'destination'); + if (reference != null && + !(request.referenceIdentity?.matches(reference) ?? true)) { + throw _invalidPath(referencePath!, role: 'destination'); + } + for (final element in elements) { + final parent = element.parent; + if (parent is! XmlElement || !parent.children.remove(element)) { + throw const BusyMarkException('writerside.toc.move-invalid-target'); + } + } + switch (request.placement) { + case WritersideTopicCreatePlacement.root: + session.root.children.addAll(elements); + case WritersideTopicCreatePlacement.sibling: + final parent = reference!.parent; + if (parent is! XmlElement) { + throw const BusyMarkException('writerside.toc.move-invalid-target'); + } + final index = parent.children.indexOf(reference); + if (index < 0) { + throw const BusyMarkException('writerside.toc.move-invalid-target'); + } + parent.children.insertAll(index + 1, elements); + case WritersideTopicCreatePlacement.child: + reference!.children.addAll(elements); + } + final firstPath = _pathOfElement(session.root, elements.first); + if (firstPath == null) { + throw const BusyMarkException('writerside.toc.move-invalid-target'); + } + await _write(session); + return WritersideTocMutationResult( + treePath: session.treePath, + entryPath: firstPath, + ); + } + /// Removes one TOC entry while retaining its direct `toc-element` children. /// /// Promoted children are inserted at the removed entry's position and keep @@ -167,6 +298,70 @@ class WritersideTocEditor { return WritersideTocMutationResult(treePath: session.treePath); } + /// Removes several exact TOC entries in one guarded tree-file update. + /// + /// Descendants are removed before their selected ancestors, and siblings + /// are removed from the end backwards so every request keeps referring to + /// the tree snapshot the user selected. + Future removeEntries( + WritersideTocEditTarget target, + List requests, + ) async { + if (requests.isEmpty) { + throw const BusyMarkException('writerside.toc.path-invalid'); + } + for (final request in requests) { + _validatePath(request.entryPath, role: 'source'); + } + final session = await _load(target); + final entries = <({List path, XmlElement element})>[]; + for (final request in requests) { + final entry = _elementAtPath( + session.root, + request.entryPath, + role: 'source', + ); + if (!(request.expectedIdentity?.matches(entry) ?? true)) { + throw _invalidPath(request.entryPath, role: 'source'); + } + entries.add((path: request.entryPath, element: entry)); + } + entries.sort((left, right) { + final depth = right.path.length.compareTo(left.path.length); + if (depth != 0) { + return depth; + } + final length = math.min(left.path.length, right.path.length); + for (var index = 0; index < length; index += 1) { + final order = right.path[index].compareTo(left.path[index]); + if (order != 0) { + return order; + } + } + return 0; + }); + for (final (:element, :path) in entries) { + final parent = element.parent; + if (parent is! XmlElement) { + throw _invalidPath(path, role: 'source'); + } + final rawIndex = parent.children.indexOf(element); + if (rawIndex < 0) { + throw _invalidPath(path, role: 'source'); + } + final promotedChildren = element.childElements + .where(_isTocElement) + .toList(); + for (final child in promotedChildren) { + element.children.remove(child); + } + parent.children.removeAt(rawIndex); + parent.children.insertAll(rawIndex, promotedChildren); + } + await _write(session); + return WritersideTocMutationResult(treePath: session.treePath); + } + Future<_TocEditSession> _load(WritersideTocEditTarget target) async { final rootPath = normalizePath(target.rootPath); final CanonicalPathAnchor anchor; diff --git a/test/src/ai_edit_ui_test.dart b/test/src/ai_edit_ui_test.dart index 66664cf4..d30aebcc 100644 --- a/test/src/ai_edit_ui_test.dart +++ b/test/src/ai_edit_ui_test.dart @@ -140,6 +140,56 @@ void main() { expect(contextSelector.dy, lessThan(sharedContent.dy)); }); + testWidgets('fixed AI target cannot widen a sidebar selection', ( + tester, + ) async { + const source = '# First\n\nSelected section.\n\n# Last\n'; + final start = source.indexOf('# First'); + final end = source.indexOf('# Last'); + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Consumer( + builder: (context, ref, child) => ElevatedButton( + onPressed: () => unawaited( + showBusyMarkAiEdit( + context, + ref, + AiEditorSnapshot( + documentSource: source, + selectionStart: start, + selectionEnd: end, + anchorOffset: start, + sourceRevision: 1, + targetId: 'outline.md', + documentPath: 'outline.md', + blockTargetAvailable: false, + ), + fixedTarget: AiEditTargetKind.selection, + ), + ), + child: const Text('Open fixed AI'), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open fixed AI')); + await tester.pumpAndSettle(); + + final selector = tester.widget>( + find.byType(BusyMarkComboRow), + ); + expect(selector.values, [AiEditTargetKind.selection]); + expect(selector.selected, AiEditTargetKind.selection); + expect(find.textContaining('Selected section.'), findsNWidgets(2)); + }); + testWidgets('proposal Apply refuses stale external source content', ( tester, ) async { diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index 1d1229b3..0ad0f2a2 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -1439,6 +1439,11 @@ void main() { await tester.pumpAndSettle(); } + Finder popupMenuItem(String label) => find.byWidgetPredicate( + (widget) => + widget is BusyMarkPopupMenuItem && widget.label == label, + ); + await openPopup(find.byTooltip(l10n.sidebarViewMenu)); await tester.tap(find.text(l10n.files)); await tester.pump(const Duration(milliseconds: 300)); @@ -1616,6 +1621,8 @@ void main() { await openPopup(find.text('Nested entry'), buttons: kSecondaryButton); for (final label in [ + l10n.copy, + l10n.aiRefineWithAi, l10n.newSiblingTopic, l10n.newChildTopic, l10n.renameTopicFile, @@ -1624,13 +1631,14 @@ void main() { l10n.pasteAsChildTopic, l10n.removeTocElement, l10n.safeDeleteTopicFile, + l10n.delete, l10n.copyName, l10n.copyPath, l10n.openInFiles, l10n.addToGit, l10n.fileHistory, ]) { - expect(find.text(label), findsOneWidget); + expect(popupMenuItem(label), findsOneWidget); } await tester.tap(find.text(l10n.newChildTopic)); @@ -1668,6 +1676,39 @@ void main() { ); expect(controller.createdTopicRequest!.referenceTocPath, [0, 0]); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.tap(find.byKey(const ValueKey('workspace-sidebar-toc-row-1'))); + await tester.tap(find.byKey(const ValueKey('workspace-sidebar-toc-row-2'))); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + await openPopup(find.text('target.md'), buttons: kSecondaryButton); + for (final label in [ + l10n.copy, + l10n.cut, + l10n.aiRefineWithAi, + l10n.delete, + ]) { + expect(popupMenuItem(label), findsOneWidget); + } + expect(find.text(l10n.newSiblingTopic), findsNothing); + expect(find.text(l10n.copyName), findsNothing); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.tap(find.byKey(const ValueKey('workspace-sidebar-toc-row-1'))); + await tester.tap(find.byKey(const ValueKey('workspace-sidebar-toc-row-1'))); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.tap(find.byKey(const ValueKey('workspace-sidebar-toc-row-2'))); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.pump(); + await openPopup(find.text('target.md'), buttons: kSecondaryButton); + expect(find.text(l10n.aiRefineWithAi), findsOneWidget); + expect(find.text(l10n.newSiblingTopic), findsNothing); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + await openPopup(find.text('Nested entry'), buttons: kSecondaryButton); await tester.tap(find.text(l10n.cut)); @@ -2702,19 +2743,20 @@ void main() { ); expect(outlineFileMenu, findsOneWidget); expect(find.byTooltip(first.path), findsOneWidget); - expect(find.byTooltip(l10n.fileActions), findsOneWidget); + expect(find.byTooltip(l10n.actions), findsOneWidget); - await tester.tap(find.byTooltip(l10n.fileActions)); + await tester.tap(find.byTooltip(l10n.actions)); await tester.pumpAndSettle(); expect(find.text(l10n.copyFileName), findsOneWidget); expect(find.text(l10n.copyPath), findsOneWidget); expect(find.text(l10n.openInFiles), findsOneWidget); + expect(find.text(l10n.aiRefineWithAi), findsOneWidget); await tester.tap(find.text(l10n.copyFileName)); await tester.pumpAndSettle(); expect(clipboardText, 'Intro.md'); - await tester.tap(find.byTooltip(l10n.fileActions)); + await tester.tap(find.byTooltip(l10n.actions)); await tester.pumpAndSettle(); await tester.tap(find.text(l10n.copyPath)); await tester.pumpAndSettle(); @@ -5034,7 +5076,7 @@ After break. find.byKey(const ValueKey('workspace-sidebar-outline-file-menu')), findsOneWidget, ); - expect(find.byTooltip(l10n.fileActions), findsOneWidget); + expect(find.byTooltip(l10n.actions), findsOneWidget); final primarySidebarLabel = find.descendant( of: find.byKey(const ValueKey('workspace-sidebar-primary-label')), matching: find.byType(Text), @@ -5203,6 +5245,10 @@ Child body. Beta body. +'''; + const gammaSection = '''### Gamma + +Gamma body. '''; String? clipboardText; tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( @@ -5303,6 +5349,7 @@ Beta body. for (final label in [ l10n.copy, l10n.cut, + l10n.aiRefineWithAi, l10n.promoteSection, l10n.demoteSection, l10n.moveSectionUp, @@ -5361,6 +5408,44 @@ Beta body. expect(find.text(l10n.confirmDeleteSectionMessage('Beta')), findsOneWidget); await tester.tap(find.widgetWithText(BusyMarkDialogButton, l10n.delete)); await expectSource(source.replaceFirst(betaSection, '')); + + await resetSource(); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.tap(headingRow('Alpha')); + await tester.tap(headingRow('Gamma')); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + await openMenu('Gamma'); + expect(menuItem(l10n.copy), findsOneWidget); + expect(menuItem(l10n.cut), findsOneWidget); + expect(menuItem(l10n.aiRefineWithAi), findsOneWidget); + expect(menuItem(l10n.delete), findsOneWidget); + expect(menuItem(l10n.promoteSection), findsNothing); + await tester.tap(find.text(l10n.copy)); + await tester.pumpAndSettle(); + expect(clipboardText, '$alphaSection$gammaSection'); + + await openMenu('Gamma'); + await tester.tap(find.text(l10n.cut)); + await expectSource( + source.replaceFirst(alphaSection, '').replaceFirst(gammaSection, ''), + ); + expect(clipboardText, '$alphaSection$gammaSection'); + + await resetSource(); + await tester.tap(headingRow('Beta')); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.tap(headingRow('Gamma')); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.pump(); + await openMenu('Gamma'); + await tester.tap(find.text(l10n.delete)); + await tester.pumpAndSettle(); + expect(find.text(l10n.confirmDeleteSectionTitle), findsOneWidget); + await tester.tap(find.widgetWithText(BusyMarkDialogButton, l10n.delete)); + await expectSource( + source.replaceFirst(betaSection, '').replaceFirst(gammaSection, ''), + ); }); testWidgets('outline highlights the heading at the document viewport', ( diff --git a/test/src/localization_audit_test.dart b/test/src/localization_audit_test.dart index 83a8ce5b..1b5324c3 100644 --- a/test/src/localization_audit_test.dart +++ b/test/src/localization_audit_test.dart @@ -651,6 +651,7 @@ const _localeSpecificEnglishMatches = >{ 'shortcutGroupGeneral', }, 'fr': { + 'actions', 'source', 'validation', 'fileTypeImages', diff --git a/test/src/source_audit_test.dart b/test/src/source_audit_test.dart index 2fda56e2..ba1a32f4 100644 --- a/test/src/source_audit_test.dart +++ b/test/src/source_audit_test.dart @@ -1098,7 +1098,7 @@ void main() { contains("ValueKey('workspace-sidebar-outline-file-menu')"), ); expect(workspace, contains('copyNameLabel: menuContext.l10n.copyFileName')); - expect(workspace, contains('tooltip: context.l10n.fileActions')); + expect(workspace, contains('tooltip: context.l10n.actions')); expect(workspace, isNot(contains('tooltip: context.l10n.openInFiles'))); expect(workspace, contains('icon: WorkspaceGlyphs.branch')); expect(workspace, isNot(contains('boldLeadingIcon'))); diff --git a/test/src/writerside_toc_editor_test.dart b/test/src/writerside_toc_editor_test.dart index cf6a05e8..92d5a4d0 100644 --- a/test/src/writerside_toc_editor_test.dart +++ b/test/src/writerside_toc_editor_test.dart @@ -264,6 +264,54 @@ void main() { }, ); + test( + 'removes multiple entries atomically against one tree snapshot', + () async { + final root = await tempModule(); + + await editor.removeEntries(targetFor(root), const [ + WritersideTocRemovalRequest(entryPath: [0, 1]), + WritersideTocRemovalRequest(entryPath: [2]), + ]); + + final tree = readTree(root); + expect(rootIds(tree), ['a', 'b']); + expect(childIds(byId(tree, 'a')), ['a1']); + }, + ); + + test('batch removal handles a selected descendant and ancestor', () async { + final root = await tempModule(); + + await editor.removeEntries(targetFor(root), const [ + WritersideTocRemovalRequest(entryPath: [0]), + WritersideTocRemovalRequest(entryPath: [0, 0]), + ]); + + final tree = readTree(root); + expect(rootIds(tree), ['a1x', 'a2', 'b', 'c']); + }); + + test('moves multiple subtrees as an ordered group', () async { + final root = await tempModule(); + + await editor.moveSubtrees( + targetFor(root), + const WritersideTocBatchMoveRequest( + sources: [ + WritersideTocMoveEntry(sourcePath: [0, 1]), + WritersideTocMoveEntry(sourcePath: [2]), + ], + placement: WritersideTopicCreatePlacement.sibling, + referencePath: [1], + ), + ); + + final tree = readTree(root); + expect(rootIds(tree), ['a', 'b', 'a2', 'c']); + expect(childIds(byId(tree, 'a')), ['a1']); + }); + test('rejects a tree outside the guarded module root', () async { final root = await tempModule(); final outside = await Directory.systemTemp.createTemp( @@ -298,36 +346,40 @@ void main() { expect(outsideTree.readAsStringSync(), original); }); - test('rejects a symlinked tree without mutating its target', () async { - final root = await tempModule(); - final outside = await Directory.systemTemp.createTemp( - 'busymark-toc-editor-link-target-', - ); - addTearDown(() async { - if (await outside.exists()) { - await outside.delete(recursive: true); - } - }); - final outsideTree = File(p.join(outside.path, 'outside.tree')) - ..writeAsStringSync(_treeSource); - final original = outsideTree.readAsStringSync(); - final treePath = p.join(root.path, 'guide.tree'); - await File(treePath).delete(); - await Link(treePath).create(outsideTree.path); - - await expectLater( - editor.removeEntry(targetFor(root), const [0]), - throwsA( - isA().having( - (error) => error.code, - 'code', - 'writerside.topic.tree-file-missing', + test( + 'rejects a symlinked tree without mutating its target', + () async { + final root = await tempModule(); + final outside = await Directory.systemTemp.createTemp( + 'busymark-toc-editor-link-target-', + ); + addTearDown(() async { + if (await outside.exists()) { + await outside.delete(recursive: true); + } + }); + final outsideTree = File(p.join(outside.path, 'outside.tree')) + ..writeAsStringSync(_treeSource); + final original = outsideTree.readAsStringSync(); + final treePath = p.join(root.path, 'guide.tree'); + await File(treePath).delete(); + await Link(treePath).create(outsideTree.path); + + await expectLater( + editor.removeEntry(targetFor(root), const [0]), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'writerside.topic.tree-file-missing', + ), ), - ), - ); + ); - expect(outsideTree.readAsStringSync(), original); - }, skip: Platform.isWindows ? 'POSIX symlink behavior only.' : false); + expect(outsideTree.readAsStringSync(), original); + }, + skip: Platform.isWindows ? 'POSIX symlink behavior only.' : false, + ); test('does not overwrite a tree changed before atomic publication', () async { final root = await tempModule(); @@ -366,17 +418,21 @@ void main() { expect(temporaryFiles, isEmpty); }); - test('atomic replacement preserves the tree POSIX mode', () async { - final root = await tempModule(); - final treeFile = File(p.join(root.path, 'guide.tree')); - final chmod = await Process.run('chmod', ['640', treeFile.path]); - expect(chmod.exitCode, 0, reason: '${chmod.stderr}'); - final originalMode = (await treeFile.stat()).mode & 0xfff; + test( + 'atomic replacement preserves the tree POSIX mode', + () async { + final root = await tempModule(); + final treeFile = File(p.join(root.path, 'guide.tree')); + final chmod = await Process.run('chmod', ['640', treeFile.path]); + expect(chmod.exitCode, 0, reason: '${chmod.stderr}'); + final originalMode = (await treeFile.stat()).mode & 0xfff; - await editor.removeEntry(targetFor(root), const [0]); + await editor.removeEntry(targetFor(root), const [0]); - expect((await treeFile.stat()).mode & 0xfff, originalMode); - }, skip: Platform.isWindows ? 'POSIX permissions only.' : false); + expect((await treeFile.stat()).mode & 0xfff, originalMode); + }, + skip: Platform.isWindows ? 'POSIX permissions only.' : false, + ); } const _treeSource = ''' From e0ea98b855b640da0aedc00185692d303d9b9a7b Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 21 Aug 2026 03:35:53 -0700 Subject: [PATCH 03/38] Add professional document workflows --- lib/l10n/app_ar.arb | 38 +- lib/l10n/app_de.arb | 38 +- lib/l10n/app_en.arb | 85 ++ lib/l10n/app_es.arb | 38 +- lib/l10n/app_et.arb | 38 +- lib/l10n/app_fa.arb | 38 +- lib/l10n/app_fr.arb | 38 +- lib/l10n/app_hi.arb | 38 +- lib/l10n/app_it.arb | 38 +- lib/l10n/app_nb.arb | 38 +- lib/l10n/app_pl.arb | 38 +- lib/l10n/app_pt.arb | 38 +- lib/l10n/app_ru.arb | 38 +- lib/l10n/app_uk.arb | 38 +- lib/l10n/generated/app_localizations.dart | 216 +++ lib/l10n/generated/app_localizations_ar.dart | 123 ++ lib/l10n/generated/app_localizations_de.dart | 126 ++ lib/l10n/generated/app_localizations_en.dart | 124 ++ lib/l10n/generated/app_localizations_es.dart | 125 ++ lib/l10n/generated/app_localizations_et.dart | 123 ++ lib/l10n/generated/app_localizations_fa.dart | 124 ++ lib/l10n/generated/app_localizations_fr.dart | 127 ++ lib/l10n/generated/app_localizations_hi.dart | 124 ++ lib/l10n/generated/app_localizations_it.dart | 125 ++ lib/l10n/generated/app_localizations_nb.dart | 125 ++ lib/l10n/generated/app_localizations_pl.dart | 124 ++ lib/l10n/generated/app_localizations_pt.dart | 125 ++ lib/l10n/generated/app_localizations_ru.dart | 124 ++ lib/l10n/generated/app_localizations_uk.dart | 124 ++ lib/src/app/busymark_app.dart | 301 ++-- lib/src/app/busymark_dialogs.dart | 46 + lib/src/app/busymark_main_menu.dart | 44 +- lib/src/app/busymark_shortcuts.dart | 16 + lib/src/app/command_palette.dart | 110 ++ lib/src/app/command_registry.dart | 505 +++++++ lib/src/app/window_control_service.dart | 5 + lib/src/assets/asset_ingestion_service.dart | 341 +++++ lib/src/assets/asset_input_service.dart | 57 + lib/src/editor/editor_text_context_menu.dart | 48 +- lib/src/editor/source/source_editor.dart | 393 ++++- .../editor/wysiwyg/wysiwyg_block_widgets.dart | 85 +- .../wysiwyg/wysiwyg_document_controller.dart | 104 +- lib/src/editor/wysiwyg/wysiwyg_editor.dart | 286 +++- lib/src/markdown/busymark_document.dart | 20 + .../busymark_markdown_serializer.dart | 24 +- lib/src/search/search_replace_service.dart | 459 ++++++ lib/src/workspace/document_buffer.dart | 269 ++++ .../presentation/settings_screen.dart | 2 + .../presentation/welcome_screen.dart | 17 + .../presentation/workspace_screen.dart | 1113 ++++++++++++--- lib/src/workspace/recovery_persistence.dart | 220 +++ lib/src/workspace/session_persistence.dart | 187 +++ lib/src/workspace/text_format_metadata.dart | 188 +++ lib/src/workspace/workspace_controller.dart | 1264 +++++++++++++++-- lib/src/workspace/workspace_file_monitor.dart | 120 ++ .../workspace/workspace_file_snapshot.dart | 48 + lib/src/workspace/workspace_model.dart | 91 +- lib/src/workspace/workspace_safety.dart | 74 +- lib/src/workspace/workspace_service.dart | 66 +- lib/src/workspace/workspace_tabs.dart | 47 +- linux/runner/my_application.cc | 94 ++ test/src/app_smoke_test.dart | 106 +- test/src/asset_ingestion_service_test.dart | 146 ++ test/src/busymark_document_test.dart | 89 ++ test/src/command_registry_test.dart | 106 ++ test/src/document_persistence_test.dart | 105 ++ test/src/editor_ui_primitives_audit_test.dart | 6 +- test/src/native_headerbar_audit_test.dart | 51 +- test/src/search_replace_service_test.dart | 200 +++ test/src/source_editor_widget_test.dart | 66 + test/src/text_format_metadata_test.dart | 54 + test/src/workspace_controller_test.dart | 92 +- ...wysiwyg_visualization_diagnostic_test.dart | 153 +- 73 files changed, 9606 insertions(+), 750 deletions(-) create mode 100644 lib/src/app/command_palette.dart create mode 100644 lib/src/app/command_registry.dart create mode 100644 lib/src/assets/asset_ingestion_service.dart create mode 100644 lib/src/assets/asset_input_service.dart create mode 100644 lib/src/search/search_replace_service.dart create mode 100644 lib/src/workspace/document_buffer.dart create mode 100644 lib/src/workspace/recovery_persistence.dart create mode 100644 lib/src/workspace/session_persistence.dart create mode 100644 lib/src/workspace/text_format_metadata.dart create mode 100644 lib/src/workspace/workspace_file_monitor.dart create mode 100644 lib/src/workspace/workspace_file_snapshot.dart create mode 100644 test/src/asset_ingestion_service_test.dart create mode 100644 test/src/command_registry_test.dart create mode 100644 test/src/document_persistence_test.dart create mode 100644 test/src/search_replace_service_test.dart create mode 100644 test/src/text_format_metadata_test.dart diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 2e5ec10c..e1ac956c 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -2666,5 +2666,41 @@ "diagnosticMarkdownHeadingSkippedLevel": "يلي عنوان المستوى ⁨{previousLevel}⁩ عنوان من المستوى ⁨{level}⁩؛ راجع تداخل الأقسام.", "diagnosticMarkdownLinkEmptyText": "نص الرابط فارغ؛ أدخل اسمًا ميسّرًا يصف الغرض منه.", "diagnosticMarkdownLinkReviewText": "راجع ما إذا كان نص الرابط «⁨{text}⁩» يصف غرضه ضمن السياق.", - "diagnosticMarkdownTableEmptyHeader": "يجب أن تعرّف رؤوس الجدول أعمدتها؛ أكمل كل رأس فارغ." + "diagnosticMarkdownTableEmptyHeader": "يجب أن تعرّف رؤوس الجدول أعمدتها؛ أكمل كل رأس فارغ.", + "commandPalette": "لوحة الأوامر", + "commandPaletteHint": "اكتب أمرًا", + "commandPaletteEmpty": "لا توجد أوامر مطابقة", + "tableAlignmentUnspecified": "المحاذاة: غير محددة", + "tableAlignmentLeft": "المحاذاة: إلى اليسار", + "tableAlignmentCenter": "المحاذاة: إلى الوسط", + "tableAlignmentRight": "المحاذاة: إلى اليمين", + "sourceSearchReplacement": "استبدال بـ", + "sourceSearchReplaceCurrent": "استبدال المطابقة الحالية", + "sourceSearchReplaceAndFindNext": "استبدال والبحث عن التالي", + "sourceSearchReplaceAll": "استبدال الكل", + "workspaceReplace": "استبدال في مساحة العمل", + "reviewReplacements": "مراجعة الاستبدالات", + "applyReplacements": "تطبيق الاستبدالات", + "skippedFiles": "الملفات المتخطاة", + "workspaceReplaceDirtyBuffer": "محتوى المحرر غير المحفوظ", + "workspaceReplaceDiskContent": "المحتوى المحفوظ على القرص", + "selectFileMatches": "تحديد كل المطابقات وعددها {count}", + "workspaceReplaceApplied": "تم استبدال {matches} مطابقة في {files} ملفًا؛ وتم تخطي {skipped}.", + "normalizeLineEndings": "توحيد نهايات الأسطر", + "workspaceReplaceMixedLineEndings": "يستخدم الملف ⁨{fileName}⁩ نهايات أسطر مختلطة. اختر التنسيق قبل الاستبدال.", + "mixedLineEndingsSavePrompt": "يحتوي هذا المستند على نهايات أسطر مختلطة. اختر تنسيقًا.", + "workspaceReplaceIssueOversized": "تم تخطي ملف يتجاوز الحجم المسموح.", + "workspaceReplaceIssueUnreadable": "تم تخطي ملف تعذرت قراءته.", + "workspaceReplaceIssueInvalidUtf8": "تم تخطي ملف بترميز UTF-8 غير صالح.", + "workspaceReplaceIssueTruncated": "تم اقتطاع معاينة الاستبدال.", + "workspaceReplaceIssueFileChanged": "تم تخطي ملف تغيّر بعد المعاينة.", + "workspaceReplaceIssueBufferChanged": "تم تخطي مخزن محرر مؤقت تغيّر بعد المعاينة.", + "workspaceReplaceIssueNormalizationRequired": "اختر توحيد LF أو CRLF قبل الاستبدال.", + "externalChangesTitle": "تغييرات خارجية — ⁨{fileName}⁩", + "externalFileDeleted": "تم حذف هذا الملف من القرص.", + "externalFileChanged": "تغيّر هذا الملف على القرص بينما لديك تعديلات غير محفوظة.", + "compare": "مقارنة", + "reloadFromDisk": "إعادة التحميل من القرص", + "keepMine": "الاحتفاظ بنسختي", + "saveAs": "حفظ باسم" } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index f6278f18..d801ae50 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -2687,5 +2687,41 @@ "diagnosticMarkdownHeadingSkippedLevel": "Auf Überschriftenebene {previousLevel} folgt Ebene {level}; prüfen Sie die Abschnittsverschachtelung.", "diagnosticMarkdownLinkEmptyText": "Der Linktext ist leer; geben Sie einen zugänglichen Namen an, der den Zweck beschreibt.", "diagnosticMarkdownLinkReviewText": "Prüfen Sie, ob der Linktext „{text}“ seinen Zweck im Kontext beschreibt.", - "diagnosticMarkdownTableEmptyHeader": "Tabellenüberschriften müssen ihre Spalten bezeichnen; füllen Sie jede leere Überschrift aus." + "diagnosticMarkdownTableEmptyHeader": "Tabellenüberschriften müssen ihre Spalten bezeichnen; füllen Sie jede leere Überschrift aus.", + "commandPalette": "Befehlspalette", + "commandPaletteHint": "Befehl eingeben", + "commandPaletteEmpty": "Keine passenden Befehle", + "tableAlignmentUnspecified": "Ausrichtung: Nicht festgelegt", + "tableAlignmentLeft": "Ausrichtung: Links", + "tableAlignmentCenter": "Ausrichtung: Mitte", + "tableAlignmentRight": "Ausrichtung: Rechts", + "sourceSearchReplacement": "Ersetzen durch", + "sourceSearchReplaceCurrent": "Aktuellen Treffer ersetzen", + "sourceSearchReplaceAndFindNext": "Ersetzen und weitersuchen", + "sourceSearchReplaceAll": "Alle ersetzen", + "workspaceReplace": "Im Arbeitsbereich ersetzen", + "reviewReplacements": "Ersetzungen prüfen", + "applyReplacements": "Ersetzungen anwenden", + "skippedFiles": "Übersprungene Dateien", + "workspaceReplaceDirtyBuffer": "Ungespeicherter Editorinhalt", + "workspaceReplaceDiskContent": "Gespeicherter Festplatteninhalt", + "selectFileMatches": "Alle {count} Treffer auswählen", + "workspaceReplaceApplied": "{matches} Treffer in {files} Dateien ersetzt; {skipped} übersprungen.", + "normalizeLineEndings": "Zeilenenden normalisieren", + "workspaceReplaceMixedLineEndings": "{fileName} verwendet gemischte Zeilenenden. Wählen Sie vor dem Ersetzen das gewünschte Format.", + "mixedLineEndingsSavePrompt": "Dieses Dokument enthält gemischte Zeilenenden. Wählen Sie ein Format.", + "workspaceReplaceIssueOversized": "Eine zu große Datei wurde übersprungen.", + "workspaceReplaceIssueUnreadable": "Eine nicht lesbare Datei wurde übersprungen.", + "workspaceReplaceIssueInvalidUtf8": "Eine Datei ohne gültige UTF-8-Codierung wurde übersprungen.", + "workspaceReplaceIssueTruncated": "Die Ersetzungsvorschau wurde gekürzt.", + "workspaceReplaceIssueFileChanged": "Eine Datei, die sich nach der Vorschau geändert hat, wurde übersprungen.", + "workspaceReplaceIssueBufferChanged": "Ein Editorpuffer, der sich nach der Vorschau geändert hat, wurde übersprungen.", + "workspaceReplaceIssueNormalizationRequired": "Wählen Sie vor dem Ersetzen die Normalisierung auf LF oder CRLF.", + "externalChangesTitle": "Externe Änderungen — {fileName}", + "externalFileDeleted": "Diese Datei wurde auf dem Datenträger gelöscht.", + "externalFileChanged": "Diese Datei wurde auf dem Datenträger geändert, während ungespeicherte Änderungen vorliegen.", + "compare": "Vergleichen", + "reloadFromDisk": "Vom Datenträger neu laden", + "keepMine": "Meine Version behalten", + "saveAs": "Speichern unter" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index aabc3c39..4bfabfce 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -135,6 +135,12 @@ "insert": "Insert", "@insert": {"description": "Insert button label."}, "keyboardShortcuts": "Keyboard Shortcuts", + "commandPalette": "Command Palette", + "@commandPalette": {"description": "Title and command label for the searchable command palette."}, + "commandPaletteHint": "Type a command", + "@commandPaletteHint": {"description": "Search hint in the command palette."}, + "commandPaletteEmpty": "No matching commands", + "@commandPaletteEmpty": {"description": "Empty state in the command palette."}, "@keyboardShortcuts": {"description": "Keyboard shortcuts dialog title and menu item."}, "lightTheme": "Light", "@lightTheme": {"description": "Light theme option."}, @@ -566,6 +572,14 @@ "@insertColumnRight": {"description": "Menu item for inserting a table column to the right."}, "deleteColumn": "Delete column", "@deleteColumn": {"description": "Menu item for deleting a table column."}, + "tableAlignmentUnspecified": "Alignment: Unspecified", + "@tableAlignmentUnspecified": {"description": "Menu item for using unspecified Markdown table-column alignment."}, + "tableAlignmentLeft": "Alignment: Left", + "@tableAlignmentLeft": {"description": "Menu item for left-aligning a Markdown table column."}, + "tableAlignmentCenter": "Alignment: Center", + "@tableAlignmentCenter": {"description": "Menu item for centering a Markdown table column."}, + "tableAlignmentRight": "Alignment: Right", + "@tableAlignmentRight": {"description": "Menu item for right-aligning a Markdown table column."}, "tableRowNumber": "Row {rowNumber}", "@tableRowNumber": { "description": "Tooltip for a table row control.", @@ -857,6 +871,77 @@ "@sourceSearchWholeWord": {"description": "Tooltip for toggling whole-word source search."}, "sourceSearchRegex": "Regex", "@sourceSearchRegex": {"description": "Tooltip for toggling regular-expression source search."}, + "sourceSearchReplacement": "Replace with", + "@sourceSearchReplacement": {"description": "Placeholder for the active-document replacement field."}, + "sourceSearchReplaceCurrent": "Replace current", + "@sourceSearchReplaceCurrent": {"description": "Tooltip for replacing the current search match."}, + "sourceSearchReplaceAndFindNext": "Replace and find next", + "@sourceSearchReplaceAndFindNext": {"description": "Tooltip for replacing the current match and moving to the next."}, + "sourceSearchReplaceAll": "Replace all", + "@sourceSearchReplaceAll": {"description": "Tooltip for replacing every active-document search match."}, + "workspaceReplace": "Replace in Workspace", + "@workspaceReplace": {"description": "Action that previews replacements across the workspace."}, + "reviewReplacements": "Review replacements", + "@reviewReplacements": {"description": "Action and dialog title for reviewing workspace replacements."}, + "applyReplacements": "Apply replacements", + "@applyReplacements": {"description": "Action that applies selected workspace replacements."}, + "skippedFiles": "Skipped files", + "@skippedFiles": {"description": "Heading for files excluded from a workspace replacement."}, + "workspaceReplaceDirtyBuffer": "Unsaved editor content", + "@workspaceReplaceDirtyBuffer": {"description": "Source label for a workspace replacement preview using a dirty document buffer."}, + "workspaceReplaceDiskContent": "Saved disk content", + "@workspaceReplaceDiskContent": {"description": "Source label for a workspace replacement preview using disk content."}, + "selectFileMatches": "Select all {count} matches", + "@selectFileMatches": { + "description": "Checkbox label for selecting every replacement match in a file.", + "placeholders": {"count": {"type": "int"}} + }, + "workspaceReplaceApplied": "Replaced {matches} matches in {files} files; skipped {skipped}.", + "@workspaceReplaceApplied": { + "description": "Summary shown after applying workspace replacements.", + "placeholders": { + "matches": {"type": "int"}, + "files": {"type": "int"}, + "skipped": {"type": "int"} + } + }, + "normalizeLineEndings": "Normalize line endings", + "@normalizeLineEndings": {"description": "Dialog title for selecting a line-ending style."}, + "mixedLineEndingsSavePrompt": "This document contains mixed line endings. Choose a format.", + "@mixedLineEndingsSavePrompt": {"description": "Prompt shown before saving a document with mixed line endings."}, + "workspaceReplaceMixedLineEndings": "{fileName} uses mixed line endings. Choose the format to use before replacing.", + "@workspaceReplaceMixedLineEndings": { + "description": "Prompt shown before replacing content in a mixed-line-ending file.", + "placeholders": {"fileName": {"type": "String"}} + }, + "workspaceReplaceIssueOversized": "Skipped an oversized file.", + "@workspaceReplaceIssueOversized": {"description": "Workspace replacement issue for a file above the size limit."}, + "workspaceReplaceIssueUnreadable": "Skipped a file that could not be read.", + "@workspaceReplaceIssueUnreadable": {"description": "Workspace replacement issue for an unreadable file."}, + "workspaceReplaceIssueInvalidUtf8": "Skipped a file that is not valid UTF-8.", + "@workspaceReplaceIssueInvalidUtf8": {"description": "Workspace replacement issue for invalid UTF-8 content."}, + "workspaceReplaceIssueTruncated": "The replacement preview was truncated.", + "@workspaceReplaceIssueTruncated": {"description": "Workspace replacement issue when the match limit is reached."}, + "workspaceReplaceIssueFileChanged": "Skipped a file that changed after the preview.", + "@workspaceReplaceIssueFileChanged": {"description": "Workspace replacement issue for stale disk content."}, + "workspaceReplaceIssueBufferChanged": "Skipped an editor buffer that changed after the preview.", + "@workspaceReplaceIssueBufferChanged": {"description": "Workspace replacement issue for stale in-memory content."}, + "workspaceReplaceIssueNormalizationRequired": "Choose LF or CRLF normalization before replacing.", + "@workspaceReplaceIssueNormalizationRequired": {"description": "Workspace replacement issue for mixed line endings without a selected format."}, + "externalChangesTitle": "External changes — {fileName}", + "@externalChangesTitle": {"description": "Title of the external-file comparison dialog.", "placeholders": {"fileName": {"type": "String"}}}, + "externalFileDeleted": "This file was deleted on disk.", + "@externalFileDeleted": {"description": "Persistent banner shown when an open file is deleted externally."}, + "externalFileChanged": "This file changed on disk while you have unsaved edits.", + "@externalFileChanged": {"description": "Persistent banner shown when a dirty file changes externally."}, + "compare": "Compare", + "@compare": {"description": "Action that compares the editor buffer with the disk version."}, + "reloadFromDisk": "Reload from Disk", + "@reloadFromDisk": {"description": "Action that replaces the editor buffer with the disk version."}, + "keepMine": "Keep Mine", + "@keepMine": {"description": "Action that keeps the editor buffer after an external change."}, + "saveAs": "Save As", + "@saveAs": {"description": "Action that saves the current document to another path."}, "sourceSearchInvalidRegex": "Invalid regular expression", "@sourceSearchInvalidRegex": {"description": "Source search status shown when the regular expression is invalid."}, "sourceLargeFileFeaturesPaused": "Large file: highlighting and folding are paused", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 5fa18fdd..8f097ae2 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -2687,5 +2687,41 @@ "diagnosticMarkdownHeadingSkippedLevel": "El encabezado de nivel {level} sigue al nivel {previousLevel}; revise la jerarquía de las secciones.", "diagnosticMarkdownLinkEmptyText": "El texto del enlace está vacío; proporcione un nombre accesible que describa su propósito.", "diagnosticMarkdownLinkReviewText": "Revise si el texto del enlace «{text}» describe su propósito en contexto.", - "diagnosticMarkdownTableEmptyHeader": "Los encabezados de tabla deben identificar sus columnas; complete cada encabezado vacío." + "diagnosticMarkdownTableEmptyHeader": "Los encabezados de tabla deben identificar sus columnas; complete cada encabezado vacío.", + "commandPalette": "Paleta de comandos", + "commandPaletteHint": "Escribe un comando", + "commandPaletteEmpty": "No hay comandos coincidentes", + "tableAlignmentUnspecified": "Alineación: Sin especificar", + "tableAlignmentLeft": "Alineación: Izquierda", + "tableAlignmentCenter": "Alineación: Centro", + "tableAlignmentRight": "Alineación: Derecha", + "sourceSearchReplacement": "Reemplazar por", + "sourceSearchReplaceCurrent": "Reemplazar actual", + "sourceSearchReplaceAndFindNext": "Reemplazar y buscar siguiente", + "sourceSearchReplaceAll": "Reemplazar todo", + "workspaceReplace": "Reemplazar en el espacio de trabajo", + "reviewReplacements": "Revisar reemplazos", + "applyReplacements": "Aplicar reemplazos", + "skippedFiles": "Archivos omitidos", + "workspaceReplaceDirtyBuffer": "Contenido no guardado del editor", + "workspaceReplaceDiskContent": "Contenido guardado en disco", + "selectFileMatches": "Seleccionar las {count} coincidencias", + "workspaceReplaceApplied": "Se reemplazaron {matches} coincidencias en {files} archivos; se omitieron {skipped}.", + "normalizeLineEndings": "Normalizar finales de línea", + "workspaceReplaceMixedLineEndings": "{fileName} usa finales de línea mezclados. Elige el formato antes de reemplazar.", + "mixedLineEndingsSavePrompt": "Este documento contiene finales de línea mezclados. Elige un formato.", + "workspaceReplaceIssueOversized": "Se omitió un archivo demasiado grande.", + "workspaceReplaceIssueUnreadable": "Se omitió un archivo que no se pudo leer.", + "workspaceReplaceIssueInvalidUtf8": "Se omitió un archivo que no contiene UTF-8 válido.", + "workspaceReplaceIssueTruncated": "La vista previa de reemplazos se truncó.", + "workspaceReplaceIssueFileChanged": "Se omitió un archivo que cambió después de la vista previa.", + "workspaceReplaceIssueBufferChanged": "Se omitió un búfer del editor que cambió después de la vista previa.", + "workspaceReplaceIssueNormalizationRequired": "Elige la normalización LF o CRLF antes de reemplazar.", + "externalChangesTitle": "Cambios externos — {fileName}", + "externalFileDeleted": "Este archivo se eliminó del disco.", + "externalFileChanged": "Este archivo cambió en el disco mientras tienes cambios sin guardar.", + "compare": "Comparar", + "reloadFromDisk": "Recargar desde el disco", + "keepMine": "Conservar mi versión", + "saveAs": "Guardar como" } diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index 38663f1f..32869b30 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -1875,5 +1875,41 @@ "diagnosticMarkdownHeadingSkippedLevel": "Taseme {level} pealkiri järgneb tasemele {previousLevel}; kontrolli jaotiste pesastust.", "diagnosticMarkdownLinkEmptyText": "Lingi tekst on tühi; lisa ligipääsetav nimi, mis kirjeldab selle otstarvet.", "diagnosticMarkdownLinkReviewText": "Kontrolli, kas lingi tekst „{text}” kirjeldab kontekstis selle otstarvet.", - "diagnosticMarkdownTableEmptyHeader": "Tabelipäised peavad veerge kirjeldama; täida kõik tühjad päised." + "diagnosticMarkdownTableEmptyHeader": "Tabelipäised peavad veerge kirjeldama; täida kõik tühjad päised.", + "commandPalette": "Käskude palett", + "commandPaletteHint": "Sisesta käsk", + "commandPaletteEmpty": "Sobivaid käske pole", + "tableAlignmentUnspecified": "Joondus: määramata", + "tableAlignmentLeft": "Joondus: vasakule", + "tableAlignmentCenter": "Joondus: keskele", + "tableAlignmentRight": "Joondus: paremale", + "sourceSearchReplacement": "Asenda väärtusega", + "sourceSearchReplaceCurrent": "Asenda praegune vaste", + "sourceSearchReplaceAndFindNext": "Asenda ja leia järgmine", + "sourceSearchReplaceAll": "Asenda kõik", + "workspaceReplace": "Asenda tööruumis", + "reviewReplacements": "Vaata asendused üle", + "applyReplacements": "Rakenda asendused", + "skippedFiles": "Vahele jäetud failid", + "workspaceReplaceDirtyBuffer": "Salvestamata redaktori sisu", + "workspaceReplaceDiskContent": "Kettale salvestatud sisu", + "selectFileMatches": "Vali kõik {count} vastet", + "workspaceReplaceApplied": "Asendati {matches} vastet {files} failis; vahele jäeti {skipped}.", + "normalizeLineEndings": "Normaliseeri reavahetused", + "workspaceReplaceMixedLineEndings": "Fail {fileName} kasutab eri tüüpi reavahetusi. Vali enne asendamist vorming.", + "mixedLineEndingsSavePrompt": "See dokument sisaldab eri tüüpi reavahetusi. Vali vorming.", + "workspaceReplaceIssueOversized": "Liiga suur fail jäeti vahele.", + "workspaceReplaceIssueUnreadable": "Fail, mida ei saanud lugeda, jäeti vahele.", + "workspaceReplaceIssueInvalidUtf8": "Fail, mis pole korrektne UTF-8, jäeti vahele.", + "workspaceReplaceIssueTruncated": "Asenduste eelvaade kärbiti.", + "workspaceReplaceIssueFileChanged": "Pärast eelvaadet muutunud fail jäeti vahele.", + "workspaceReplaceIssueBufferChanged": "Pärast eelvaadet muutunud redaktoripuhver jäeti vahele.", + "workspaceReplaceIssueNormalizationRequired": "Vali enne asendamist LF- või CRLF-normaliseerimine.", + "externalChangesTitle": "Välised muudatused — {fileName}", + "externalFileDeleted": "See fail kustutati kettalt.", + "externalFileChanged": "See fail muutus kettal ajal, kui sul on salvestamata muudatusi.", + "compare": "Võrdle", + "reloadFromDisk": "Laadi kettalt uuesti", + "keepMine": "Säilita minu versioon", + "saveAs": "Salvesta nimega" } diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 8e311ce2..5e45688d 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -2685,5 +2685,41 @@ "diagnosticMarkdownHeadingSkippedLevel": "عنوان سطح ⁨{level}⁩ پس از سطح ⁨{previousLevel}⁩ آمده است؛ تودرتویی بخش‌ها را بازبینی کنید.", "diagnosticMarkdownLinkEmptyText": "متن پیوند خالی است؛ نام دسترس‌پذیری وارد کنید که هدف آن را توضیح دهد.", "diagnosticMarkdownLinkReviewText": "بررسی کنید که آیا متن پیوند «⁨{text}⁩» هدف آن را در زمینه توضیح می‌دهد.", - "diagnosticMarkdownTableEmptyHeader": "سرستون‌های جدول باید ستون‌های خود را مشخص کنند؛ هر سرستون خالی را تکمیل کنید." + "diagnosticMarkdownTableEmptyHeader": "سرستون‌های جدول باید ستون‌های خود را مشخص کنند؛ هر سرستون خالی را تکمیل کنید.", + "commandPalette": "پالت فرمان", + "commandPaletteHint": "یک فرمان وارد کنید", + "commandPaletteEmpty": "هیچ فرمان منطبقی وجود ندارد", + "tableAlignmentUnspecified": "تراز: مشخص‌نشده", + "tableAlignmentLeft": "تراز: چپ", + "tableAlignmentCenter": "تراز: وسط", + "tableAlignmentRight": "تراز: راست", + "sourceSearchReplacement": "جایگزینی با", + "sourceSearchReplaceCurrent": "جایگزینی مورد فعلی", + "sourceSearchReplaceAndFindNext": "جایگزینی و یافتن بعدی", + "sourceSearchReplaceAll": "جایگزینی همه", + "workspaceReplace": "جایگزینی در فضای کاری", + "reviewReplacements": "بازبینی جایگزینی‌ها", + "applyReplacements": "اعمال جایگزینی‌ها", + "skippedFiles": "فایل‌های نادیده‌گرفته‌شده", + "workspaceReplaceDirtyBuffer": "محتوای ذخیره‌نشدهٔ ویرایشگر", + "workspaceReplaceDiskContent": "محتوای ذخیره‌شده روی دیسک", + "selectFileMatches": "انتخاب هر {count} مورد", + "workspaceReplaceApplied": "{matches} مورد در {files} فایل جایگزین شد؛ {skipped} مورد نادیده گرفته شد.", + "normalizeLineEndings": "یکسان‌سازی پایان خط‌ها", + "workspaceReplaceMixedLineEndings": "فایل ⁨{fileName}⁩ پایان خط‌های ترکیبی دارد. پیش از جایگزینی قالب را انتخاب کنید.", + "mixedLineEndingsSavePrompt": "این سند پایان خط‌های ترکیبی دارد. یک قالب انتخاب کنید.", + "workspaceReplaceIssueOversized": "یک فایل بیش‌ازحد بزرگ نادیده گرفته شد.", + "workspaceReplaceIssueUnreadable": "فایلی که خوانده نمی‌شد نادیده گرفته شد.", + "workspaceReplaceIssueInvalidUtf8": "فایلی با UTF-8 نامعتبر نادیده گرفته شد.", + "workspaceReplaceIssueTruncated": "پیش‌نمایش جایگزینی کوتاه شد.", + "workspaceReplaceIssueFileChanged": "فایلی که پس از پیش‌نمایش تغییر کرده بود نادیده گرفته شد.", + "workspaceReplaceIssueBufferChanged": "بافر ویرایشگری که پس از پیش‌نمایش تغییر کرده بود نادیده گرفته شد.", + "workspaceReplaceIssueNormalizationRequired": "پیش از جایگزینی، یکسان‌سازی LF یا CRLF را انتخاب کنید.", + "externalChangesTitle": "تغییرات بیرونی — ⁨{fileName}⁩", + "externalFileDeleted": "این فایل از روی دیسک حذف شد.", + "externalFileChanged": "هنگامی که تغییرات ذخیره‌نشده داشتید، این فایل روی دیسک تغییر کرد.", + "compare": "مقایسه", + "reloadFromDisk": "بارگیری دوباره از دیسک", + "keepMine": "نگه‌داشتن نسخهٔ من", + "saveAs": "ذخیره با نام" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 93072c3c..e9222fdc 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -2687,5 +2687,41 @@ "diagnosticMarkdownHeadingSkippedLevel": "Le titre de niveau {level} suit le niveau {previousLevel} ; vérifiez l’imbrication des sections.", "diagnosticMarkdownLinkEmptyText": "Le texte du lien est vide ; fournissez un nom accessible qui décrit son objectif.", "diagnosticMarkdownLinkReviewText": "Vérifiez si le texte du lien « {text} » décrit son objectif dans le contexte.", - "diagnosticMarkdownTableEmptyHeader": "Les en-têtes de tableau doivent identifier leurs colonnes ; complétez chaque en-tête vide." + "diagnosticMarkdownTableEmptyHeader": "Les en-têtes de tableau doivent identifier leurs colonnes ; complétez chaque en-tête vide.", + "commandPalette": "Palette de commandes", + "commandPaletteHint": "Saisissez une commande", + "commandPaletteEmpty": "Aucune commande correspondante", + "tableAlignmentUnspecified": "Alignement : non spécifié", + "tableAlignmentLeft": "Alignement : gauche", + "tableAlignmentCenter": "Alignement : centre", + "tableAlignmentRight": "Alignement : droite", + "sourceSearchReplacement": "Remplacer par", + "sourceSearchReplaceCurrent": "Remplacer la sélection actuelle", + "sourceSearchReplaceAndFindNext": "Remplacer et rechercher le suivant", + "sourceSearchReplaceAll": "Tout remplacer", + "workspaceReplace": "Remplacer dans l’espace de travail", + "reviewReplacements": "Vérifier les remplacements", + "applyReplacements": "Appliquer les remplacements", + "skippedFiles": "Fichiers ignorés", + "workspaceReplaceDirtyBuffer": "Contenu non enregistré de l’éditeur", + "workspaceReplaceDiskContent": "Contenu enregistré sur le disque", + "selectFileMatches": "Sélectionner les {count} occurrences", + "workspaceReplaceApplied": "{matches} occurrences remplacées dans {files} fichiers ; {skipped} ignorées.", + "normalizeLineEndings": "Normaliser les fins de ligne", + "workspaceReplaceMixedLineEndings": "{fileName} utilise plusieurs types de fins de ligne. Choisissez le format avant le remplacement.", + "mixedLineEndingsSavePrompt": "Ce document contient plusieurs types de fins de ligne. Choisissez un format.", + "workspaceReplaceIssueOversized": "Un fichier trop volumineux a été ignoré.", + "workspaceReplaceIssueUnreadable": "Un fichier illisible a été ignoré.", + "workspaceReplaceIssueInvalidUtf8": "Un fichier dont l’encodage UTF-8 est incorrect a été ignoré.", + "workspaceReplaceIssueTruncated": "L’aperçu des remplacements a été tronqué.", + "workspaceReplaceIssueFileChanged": "Un fichier modifié après l’aperçu a été ignoré.", + "workspaceReplaceIssueBufferChanged": "Un tampon d’éditeur modifié après l’aperçu a été ignoré.", + "workspaceReplaceIssueNormalizationRequired": "Choisissez la normalisation LF ou CRLF avant le remplacement.", + "externalChangesTitle": "Modifications externes — {fileName}", + "externalFileDeleted": "Ce fichier a été supprimé du disque.", + "externalFileChanged": "Ce fichier a été modifié sur le disque alors que vous avez des modifications non enregistrées.", + "compare": "Comparer", + "reloadFromDisk": "Recharger depuis le disque", + "keepMine": "Conserver ma version", + "saveAs": "Enregistrer sous" } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 9082d3cf..ef456178 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -2666,5 +2666,41 @@ "diagnosticMarkdownHeadingSkippedLevel": "स्तर {level} का शीर्षक स्तर {previousLevel} के बाद है; अनुभागों का नेस्टिंग जाँचें।", "diagnosticMarkdownLinkEmptyText": "लिंक टेक्स्ट खाली है; उसके उद्देश्य का वर्णन करने वाला सुलभ नाम दें।", "diagnosticMarkdownLinkReviewText": "जाँचें कि लिंक टेक्स्ट “{text}” संदर्भ में उसके उद्देश्य का वर्णन करता है या नहीं।", - "diagnosticMarkdownTableEmptyHeader": "तालिका शीर्षकों को अपने कॉलम पहचानने चाहिए; हर खाली शीर्षक पूरा करें।" + "diagnosticMarkdownTableEmptyHeader": "तालिका शीर्षकों को अपने कॉलम पहचानने चाहिए; हर खाली शीर्षक पूरा करें।", + "commandPalette": "कमांड पैलेट", + "commandPaletteHint": "कमांड लिखें", + "commandPaletteEmpty": "कोई मेल खाता कमांड नहीं", + "tableAlignmentUnspecified": "संरेखण: अनिर्दिष्ट", + "tableAlignmentLeft": "संरेखण: बायाँ", + "tableAlignmentCenter": "संरेखण: मध्य", + "tableAlignmentRight": "संरेखण: दायाँ", + "sourceSearchReplacement": "इससे बदलें", + "sourceSearchReplaceCurrent": "वर्तमान मिलान बदलें", + "sourceSearchReplaceAndFindNext": "बदलें और अगला खोजें", + "sourceSearchReplaceAll": "सभी बदलें", + "workspaceReplace": "वर्कस्पेस में बदलें", + "reviewReplacements": "बदलावों की समीक्षा करें", + "applyReplacements": "बदलाव लागू करें", + "skippedFiles": "छोड़ी गई फ़ाइलें", + "workspaceReplaceDirtyBuffer": "सहेजी न गई संपादक सामग्री", + "workspaceReplaceDiskContent": "डिस्क पर सहेजी सामग्री", + "selectFileMatches": "सभी {count} मिलान चुनें", + "workspaceReplaceApplied": "{files} फ़ाइलों में {matches} मिलान बदले गए; {skipped} छोड़े गए।", + "normalizeLineEndings": "पंक्ति अंत सामान्य करें", + "workspaceReplaceMixedLineEndings": "{fileName} में मिले-जुले पंक्ति अंत हैं। बदलने से पहले प्रारूप चुनें।", + "mixedLineEndingsSavePrompt": "इस दस्तावेज़ में मिले-जुले पंक्ति अंत हैं। कोई प्रारूप चुनें।", + "workspaceReplaceIssueOversized": "बहुत बड़ी फ़ाइल छोड़ दी गई।", + "workspaceReplaceIssueUnreadable": "पढ़ी न जा सकने वाली फ़ाइल छोड़ दी गई।", + "workspaceReplaceIssueInvalidUtf8": "अमान्य UTF-8 वाली फ़ाइल छोड़ दी गई।", + "workspaceReplaceIssueTruncated": "बदलाव पूर्वावलोकन छोटा कर दिया गया।", + "workspaceReplaceIssueFileChanged": "पूर्वावलोकन के बाद बदली फ़ाइल छोड़ दी गई।", + "workspaceReplaceIssueBufferChanged": "पूर्वावलोकन के बाद बदला संपादक बफ़र छोड़ दिया गया।", + "workspaceReplaceIssueNormalizationRequired": "बदलने से पहले LF या CRLF सामान्यीकरण चुनें।", + "externalChangesTitle": "बाहरी बदलाव — {fileName}", + "externalFileDeleted": "यह फ़ाइल डिस्क से हटा दी गई।", + "externalFileChanged": "आपके सहेजे न गए बदलावों के दौरान यह फ़ाइल डिस्क पर बदल गई।", + "compare": "तुलना करें", + "reloadFromDisk": "डिस्क से फिर लोड करें", + "keepMine": "मेरा संस्करण रखें", + "saveAs": "इस रूप में सहेजें" } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 3eae3306..4415ee4e 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -2664,5 +2664,41 @@ "diagnosticMarkdownHeadingSkippedLevel": "Il titolo di livello {level} segue il livello {previousLevel}; verifica la struttura delle sezioni.", "diagnosticMarkdownLinkEmptyText": "Il testo del collegamento è vuoto; fornisci un nome accessibile che ne descriva lo scopo.", "diagnosticMarkdownLinkReviewText": "Verifica se il testo del collegamento “{text}” ne descrive lo scopo nel contesto.", - "diagnosticMarkdownTableEmptyHeader": "Le intestazioni della tabella devono identificare le colonne; completa ogni intestazione vuota." + "diagnosticMarkdownTableEmptyHeader": "Le intestazioni della tabella devono identificare le colonne; completa ogni intestazione vuota.", + "commandPalette": "Tavolozza dei comandi", + "commandPaletteHint": "Digita un comando", + "commandPaletteEmpty": "Nessun comando corrispondente", + "tableAlignmentUnspecified": "Allineamento: non specificato", + "tableAlignmentLeft": "Allineamento: sinistra", + "tableAlignmentCenter": "Allineamento: centro", + "tableAlignmentRight": "Allineamento: destra", + "sourceSearchReplacement": "Sostituisci con", + "sourceSearchReplaceCurrent": "Sostituisci corrente", + "sourceSearchReplaceAndFindNext": "Sostituisci e trova successivo", + "sourceSearchReplaceAll": "Sostituisci tutto", + "workspaceReplace": "Sostituisci nell’area di lavoro", + "reviewReplacements": "Rivedi sostituzioni", + "applyReplacements": "Applica sostituzioni", + "skippedFiles": "File ignorati", + "workspaceReplaceDirtyBuffer": "Contenuto dell’editor non salvato", + "workspaceReplaceDiskContent": "Contenuto salvato su disco", + "selectFileMatches": "Seleziona tutte le {count} corrispondenze", + "workspaceReplaceApplied": "Sostituite {matches} corrispondenze in {files} file; {skipped} ignorate.", + "normalizeLineEndings": "Normalizza terminatori di riga", + "workspaceReplaceMixedLineEndings": "{fileName} usa terminatori di riga misti. Scegli il formato prima di sostituire.", + "mixedLineEndingsSavePrompt": "Questo documento contiene terminatori di riga misti. Scegli un formato.", + "workspaceReplaceIssueOversized": "È stato ignorato un file troppo grande.", + "workspaceReplaceIssueUnreadable": "È stato ignorato un file illeggibile.", + "workspaceReplaceIssueInvalidUtf8": "È stato ignorato un file che non è UTF-8 valido.", + "workspaceReplaceIssueTruncated": "L’anteprima delle sostituzioni è stata troncata.", + "workspaceReplaceIssueFileChanged": "È stato ignorato un file modificato dopo l’anteprima.", + "workspaceReplaceIssueBufferChanged": "È stato ignorato un buffer modificato dopo l’anteprima.", + "workspaceReplaceIssueNormalizationRequired": "Scegli la normalizzazione LF o CRLF prima di sostituire.", + "externalChangesTitle": "Modifiche esterne — {fileName}", + "externalFileDeleted": "Questo file è stato eliminato dal disco.", + "externalFileChanged": "Questo file è cambiato sul disco mentre sono presenti modifiche non salvate.", + "compare": "Confronta", + "reloadFromDisk": "Ricarica dal disco", + "keepMine": "Mantieni la mia versione", + "saveAs": "Salva con nome" } diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb index c3041f9a..d6137406 100644 --- a/lib/l10n/app_nb.arb +++ b/lib/l10n/app_nb.arb @@ -2664,5 +2664,41 @@ "diagnosticMarkdownHeadingSkippedLevel": "Overskrift på nivå {level} følger nivå {previousLevel}; kontroller seksjonsnestingen.", "diagnosticMarkdownLinkEmptyText": "Lenketeksten er tom. Oppgi et tilgjengelig navn som beskriver formålet.", "diagnosticMarkdownLinkReviewText": "Kontroller om lenketeksten «{text}» beskriver formålet i konteksten.", - "diagnosticMarkdownTableEmptyHeader": "Tabelloverskrifter må identifisere kolonnene. Fyll ut alle tomme overskrifter." + "diagnosticMarkdownTableEmptyHeader": "Tabelloverskrifter må identifisere kolonnene. Fyll ut alle tomme overskrifter.", + "commandPalette": "Kommandopalett", + "commandPaletteHint": "Skriv inn en kommando", + "commandPaletteEmpty": "Ingen samsvarende kommandoer", + "tableAlignmentUnspecified": "Justering: ikke angitt", + "tableAlignmentLeft": "Justering: venstre", + "tableAlignmentCenter": "Justering: midtstilt", + "tableAlignmentRight": "Justering: høyre", + "sourceSearchReplacement": "Erstatt med", + "sourceSearchReplaceCurrent": "Erstatt gjeldende treff", + "sourceSearchReplaceAndFindNext": "Erstatt og finn neste", + "sourceSearchReplaceAll": "Erstatt alle", + "workspaceReplace": "Erstatt i arbeidsområdet", + "reviewReplacements": "Se gjennom erstatninger", + "applyReplacements": "Bruk erstatninger", + "skippedFiles": "Filer som ble hoppet over", + "workspaceReplaceDirtyBuffer": "Ulagret redigeringsinnhold", + "workspaceReplaceDiskContent": "Innhold lagret på disk", + "selectFileMatches": "Velg alle {count} treff", + "workspaceReplaceApplied": "Erstattet {matches} treff i {files} filer; hoppet over {skipped}.", + "normalizeLineEndings": "Normaliser linjeslutt", + "workspaceReplaceMixedLineEndings": "{fileName} bruker blandede linjeslutt. Velg format før du erstatter.", + "mixedLineEndingsSavePrompt": "Dette dokumentet inneholder blandede linjeslutt. Velg et format.", + "workspaceReplaceIssueOversized": "En for stor fil ble hoppet over.", + "workspaceReplaceIssueUnreadable": "En fil som ikke kunne leses, ble hoppet over.", + "workspaceReplaceIssueInvalidUtf8": "En fil som ikke er gyldig UTF-8, ble hoppet over.", + "workspaceReplaceIssueTruncated": "Erstatningsforhåndsvisningen ble avkortet.", + "workspaceReplaceIssueFileChanged": "En fil som ble endret etter forhåndsvisningen, ble hoppet over.", + "workspaceReplaceIssueBufferChanged": "En redigeringsbuffer som ble endret etter forhåndsvisningen, ble hoppet over.", + "workspaceReplaceIssueNormalizationRequired": "Velg LF- eller CRLF-normalisering før du erstatter.", + "externalChangesTitle": "Eksterne endringer — {fileName}", + "externalFileDeleted": "Denne filen ble slettet fra disken.", + "externalFileChanged": "Denne filen ble endret på disken mens du har ulagrede endringer.", + "compare": "Sammenlign", + "reloadFromDisk": "Last inn fra disk på nytt", + "keepMine": "Behold min versjon", + "saveAs": "Lagre som" } diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 1aabd60c..d547e603 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -2682,5 +2682,41 @@ "diagnosticMarkdownHeadingSkippedLevel": "Nagłówek poziomu {level} występuje po poziomie {previousLevel}; sprawdź zagnieżdżenie sekcji.", "diagnosticMarkdownLinkEmptyText": "Tekst odnośnika jest pusty; podaj dostępną nazwę opisującą jego cel.", "diagnosticMarkdownLinkReviewText": "Sprawdź, czy tekst odnośnika „{text}” opisuje jego cel w kontekście.", - "diagnosticMarkdownTableEmptyHeader": "Nagłówki tabeli muszą identyfikować kolumny; uzupełnij każdy pusty nagłówek." + "diagnosticMarkdownTableEmptyHeader": "Nagłówki tabeli muszą identyfikować kolumny; uzupełnij każdy pusty nagłówek.", + "commandPalette": "Paleta poleceń", + "commandPaletteHint": "Wpisz polecenie", + "commandPaletteEmpty": "Brak pasujących poleceń", + "tableAlignmentUnspecified": "Wyrównanie: nieokreślone", + "tableAlignmentLeft": "Wyrównanie: do lewej", + "tableAlignmentCenter": "Wyrównanie: do środka", + "tableAlignmentRight": "Wyrównanie: do prawej", + "sourceSearchReplacement": "Zamień na", + "sourceSearchReplaceCurrent": "Zamień bieżące", + "sourceSearchReplaceAndFindNext": "Zamień i znajdź następne", + "sourceSearchReplaceAll": "Zamień wszystko", + "workspaceReplace": "Zamień w obszarze roboczym", + "reviewReplacements": "Przejrzyj zamiany", + "applyReplacements": "Zastosuj zamiany", + "skippedFiles": "Pominięte pliki", + "workspaceReplaceDirtyBuffer": "Niezapisana zawartość edytora", + "workspaceReplaceDiskContent": "Zawartość zapisana na dysku", + "selectFileMatches": "Wybierz wszystkie dopasowania ({count})", + "workspaceReplaceApplied": "Zamieniono {matches} dopasowań w {files} plikach; pominięto {skipped}.", + "normalizeLineEndings": "Normalizuj zakończenia wierszy", + "workspaceReplaceMixedLineEndings": "{fileName} używa mieszanych zakończeń wierszy. Wybierz format przed zamianą.", + "mixedLineEndingsSavePrompt": "Ten dokument zawiera mieszane zakończenia wierszy. Wybierz format.", + "workspaceReplaceIssueOversized": "Pominięto zbyt duży plik.", + "workspaceReplaceIssueUnreadable": "Pominięto plik, którego nie można odczytać.", + "workspaceReplaceIssueInvalidUtf8": "Pominięto plik z nieprawidłowym kodowaniem UTF-8.", + "workspaceReplaceIssueTruncated": "Podgląd zamian został skrócony.", + "workspaceReplaceIssueFileChanged": "Pominięto plik zmieniony po utworzeniu podglądu.", + "workspaceReplaceIssueBufferChanged": "Pominięto bufor edytora zmieniony po utworzeniu podglądu.", + "workspaceReplaceIssueNormalizationRequired": "Przed zamianą wybierz normalizację LF lub CRLF.", + "externalChangesTitle": "Zmiany zewnętrzne — {fileName}", + "externalFileDeleted": "Ten plik został usunięty z dysku.", + "externalFileChanged": "Ten plik zmienił się na dysku, gdy masz niezapisane zmiany.", + "compare": "Porównaj", + "reloadFromDisk": "Wczytaj ponownie z dysku", + "keepMine": "Zachowaj moją wersję", + "saveAs": "Zapisz jako" } diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 59040c74..286aa2e0 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -2664,5 +2664,41 @@ "diagnosticMarkdownHeadingSkippedLevel": "O título de nível {level} vem após o nível {previousLevel}; revise o aninhamento das seções.", "diagnosticMarkdownLinkEmptyText": "O texto do link está vazio; forneça um nome acessível que descreva sua finalidade.", "diagnosticMarkdownLinkReviewText": "Verifique se o texto do link “{text}” descreve sua finalidade no contexto.", - "diagnosticMarkdownTableEmptyHeader": "Os cabeçalhos da tabela devem identificar suas colunas; preencha cada cabeçalho vazio." + "diagnosticMarkdownTableEmptyHeader": "Os cabeçalhos da tabela devem identificar suas colunas; preencha cada cabeçalho vazio.", + "commandPalette": "Paleta de comandos", + "commandPaletteHint": "Digite um comando", + "commandPaletteEmpty": "Nenhum comando correspondente", + "tableAlignmentUnspecified": "Alinhamento: não especificado", + "tableAlignmentLeft": "Alinhamento: esquerda", + "tableAlignmentCenter": "Alinhamento: centro", + "tableAlignmentRight": "Alinhamento: direita", + "sourceSearchReplacement": "Substituir por", + "sourceSearchReplaceCurrent": "Substituir atual", + "sourceSearchReplaceAndFindNext": "Substituir e localizar próximo", + "sourceSearchReplaceAll": "Substituir tudo", + "workspaceReplace": "Substituir no espaço de trabalho", + "reviewReplacements": "Revisar substituições", + "applyReplacements": "Aplicar substituições", + "skippedFiles": "Arquivos ignorados", + "workspaceReplaceDirtyBuffer": "Conteúdo não salvo do editor", + "workspaceReplaceDiskContent": "Conteúdo salvo no disco", + "selectFileMatches": "Selecionar todas as {count} correspondências", + "workspaceReplaceApplied": "Foram substituídas {matches} correspondências em {files} arquivos; {skipped} ignoradas.", + "normalizeLineEndings": "Normalizar finais de linha", + "workspaceReplaceMixedLineEndings": "{fileName} usa finais de linha mistos. Escolha o formato antes de substituir.", + "mixedLineEndingsSavePrompt": "Este documento contém finais de linha mistos. Escolha um formato.", + "workspaceReplaceIssueOversized": "Um arquivo grande demais foi ignorado.", + "workspaceReplaceIssueUnreadable": "Um arquivo que não pôde ser lido foi ignorado.", + "workspaceReplaceIssueInvalidUtf8": "Um arquivo que não é UTF-8 válido foi ignorado.", + "workspaceReplaceIssueTruncated": "A prévia de substituições foi truncada.", + "workspaceReplaceIssueFileChanged": "Um arquivo alterado após a prévia foi ignorado.", + "workspaceReplaceIssueBufferChanged": "Um buffer do editor alterado após a prévia foi ignorado.", + "workspaceReplaceIssueNormalizationRequired": "Escolha a normalização LF ou CRLF antes de substituir.", + "externalChangesTitle": "Alterações externas — {fileName}", + "externalFileDeleted": "Este arquivo foi excluído do disco.", + "externalFileChanged": "Este arquivo mudou no disco enquanto você tem alterações não salvas.", + "compare": "Comparar", + "reloadFromDisk": "Recarregar do disco", + "keepMine": "Manter minha versão", + "saveAs": "Salvar como" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 67d813b3..920f5b09 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -2682,5 +2682,41 @@ "diagnosticMarkdownHeadingSkippedLevel": "За заголовком уровня {previousLevel} следует уровень {level}; проверьте вложенность разделов.", "diagnosticMarkdownLinkEmptyText": "Текст ссылки пуст; укажите доступное имя, описывающее её назначение.", "diagnosticMarkdownLinkReviewText": "Проверьте, описывает ли текст ссылки «{text}» её назначение в контексте.", - "diagnosticMarkdownTableEmptyHeader": "Заголовки таблицы должны обозначать столбцы; заполните каждый пустой заголовок." + "diagnosticMarkdownTableEmptyHeader": "Заголовки таблицы должны обозначать столбцы; заполните каждый пустой заголовок.", + "commandPalette": "Палитра команд", + "commandPaletteHint": "Введите команду", + "commandPaletteEmpty": "Нет подходящих команд", + "tableAlignmentUnspecified": "Выравнивание: не задано", + "tableAlignmentLeft": "Выравнивание: по левому краю", + "tableAlignmentCenter": "Выравнивание: по центру", + "tableAlignmentRight": "Выравнивание: по правому краю", + "sourceSearchReplacement": "Заменить на", + "sourceSearchReplaceCurrent": "Заменить текущее", + "sourceSearchReplaceAndFindNext": "Заменить и найти следующее", + "sourceSearchReplaceAll": "Заменить всё", + "workspaceReplace": "Заменить в рабочей области", + "reviewReplacements": "Проверить замены", + "applyReplacements": "Применить замены", + "skippedFiles": "Пропущенные файлы", + "workspaceReplaceDirtyBuffer": "Несохранённое содержимое редактора", + "workspaceReplaceDiskContent": "Содержимое, сохранённое на диске", + "selectFileMatches": "Выбрать все совпадения: {count}", + "workspaceReplaceApplied": "Заменено совпадений: {matches} в файлах: {files}; пропущено: {skipped}.", + "normalizeLineEndings": "Нормализовать окончания строк", + "workspaceReplaceMixedLineEndings": "В {fileName} используются смешанные окончания строк. Выберите формат перед заменой.", + "mixedLineEndingsSavePrompt": "В документе используются смешанные окончания строк. Выберите формат.", + "workspaceReplaceIssueOversized": "Слишком большой файл пропущен.", + "workspaceReplaceIssueUnreadable": "Нечитаемый файл пропущен.", + "workspaceReplaceIssueInvalidUtf8": "Файл с недопустимой кодировкой UTF-8 пропущен.", + "workspaceReplaceIssueTruncated": "Предпросмотр замен был сокращён.", + "workspaceReplaceIssueFileChanged": "Файл, изменённый после предпросмотра, пропущен.", + "workspaceReplaceIssueBufferChanged": "Буфер редактора, изменённый после предпросмотра, пропущен.", + "workspaceReplaceIssueNormalizationRequired": "Перед заменой выберите нормализацию LF или CRLF.", + "externalChangesTitle": "Внешние изменения — {fileName}", + "externalFileDeleted": "Этот файл был удалён с диска.", + "externalFileChanged": "Этот файл изменился на диске, пока у вас были несохранённые изменения.", + "compare": "Сравнить", + "reloadFromDisk": "Перезагрузить с диска", + "keepMine": "Оставить мою версию", + "saveAs": "Сохранить как" } diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index 6684f030..6f97e8d2 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -2682,5 +2682,41 @@ "diagnosticMarkdownHeadingSkippedLevel": "Після заголовка рівня {previousLevel} іде рівень {level}; перевірте вкладеність розділів.", "diagnosticMarkdownLinkEmptyText": "Текст посилання порожній; укажіть доступну назву, що описує його призначення.", "diagnosticMarkdownLinkReviewText": "Перевірте, чи описує текст посилання «{text}» його призначення в контексті.", - "diagnosticMarkdownTableEmptyHeader": "Заголовки таблиці мають позначати стовпці; заповніть кожен порожній заголовок." + "diagnosticMarkdownTableEmptyHeader": "Заголовки таблиці мають позначати стовпці; заповніть кожен порожній заголовок.", + "commandPalette": "Палітра команд", + "commandPaletteHint": "Введіть команду", + "commandPaletteEmpty": "Немає відповідних команд", + "tableAlignmentUnspecified": "Вирівнювання: не вказано", + "tableAlignmentLeft": "Вирівнювання: ліворуч", + "tableAlignmentCenter": "Вирівнювання: по центру", + "tableAlignmentRight": "Вирівнювання: праворуч", + "sourceSearchReplacement": "Замінити на", + "sourceSearchReplaceCurrent": "Замінити поточне", + "sourceSearchReplaceAndFindNext": "Замінити й знайти наступне", + "sourceSearchReplaceAll": "Замінити все", + "workspaceReplace": "Замінити в робочій області", + "reviewReplacements": "Переглянути заміни", + "applyReplacements": "Застосувати заміни", + "skippedFiles": "Пропущені файли", + "workspaceReplaceDirtyBuffer": "Незбережений вміст редактора", + "workspaceReplaceDiskContent": "Вміст, збережений на диску", + "selectFileMatches": "Вибрати всі збіги: {count}", + "workspaceReplaceApplied": "Замінено збігів: {matches} у файлах: {files}; пропущено: {skipped}.", + "normalizeLineEndings": "Нормалізувати закінчення рядків", + "workspaceReplaceMixedLineEndings": "У {fileName} використовуються змішані закінчення рядків. Виберіть формат перед заміною.", + "mixedLineEndingsSavePrompt": "У документі використовуються змішані закінчення рядків. Виберіть формат.", + "workspaceReplaceIssueOversized": "Завеликий файл пропущено.", + "workspaceReplaceIssueUnreadable": "Файл, який не вдалося прочитати, пропущено.", + "workspaceReplaceIssueInvalidUtf8": "Файл із неприпустимим UTF-8 пропущено.", + "workspaceReplaceIssueTruncated": "Попередній перегляд замін було скорочено.", + "workspaceReplaceIssueFileChanged": "Файл, змінений після попереднього перегляду, пропущено.", + "workspaceReplaceIssueBufferChanged": "Буфер редактора, змінений після попереднього перегляду, пропущено.", + "workspaceReplaceIssueNormalizationRequired": "Перед заміною виберіть нормалізацію LF або CRLF.", + "externalChangesTitle": "Зовнішні зміни — {fileName}", + "externalFileDeleted": "Цей файл було видалено з диска.", + "externalFileChanged": "Цей файл змінився на диску, поки у вас були незбережені зміни.", + "compare": "Порівняти", + "reloadFromDisk": "Перезавантажити з диска", + "keepMine": "Залишити мою версію", + "saveAs": "Зберегти як" } diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 5e5c27db..e71e0c5e 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -513,6 +513,24 @@ abstract class AppLocalizations { /// **'Keyboard Shortcuts'** String get keyboardShortcuts; + /// Title and command label for the searchable command palette. + /// + /// In en, this message translates to: + /// **'Command Palette'** + String get commandPalette; + + /// Search hint in the command palette. + /// + /// In en, this message translates to: + /// **'Type a command'** + String get commandPaletteHint; + + /// Empty state in the command palette. + /// + /// In en, this message translates to: + /// **'No matching commands'** + String get commandPaletteEmpty; + /// Light theme option. /// /// In en, this message translates to: @@ -1743,6 +1761,30 @@ abstract class AppLocalizations { /// **'Delete column'** String get deleteColumn; + /// Menu item for using unspecified Markdown table-column alignment. + /// + /// In en, this message translates to: + /// **'Alignment: Unspecified'** + String get tableAlignmentUnspecified; + + /// Menu item for left-aligning a Markdown table column. + /// + /// In en, this message translates to: + /// **'Alignment: Left'** + String get tableAlignmentLeft; + + /// Menu item for centering a Markdown table column. + /// + /// In en, this message translates to: + /// **'Alignment: Center'** + String get tableAlignmentCenter; + + /// Menu item for right-aligning a Markdown table column. + /// + /// In en, this message translates to: + /// **'Alignment: Right'** + String get tableAlignmentRight; + /// Tooltip for a table row control. /// /// In en, this message translates to: @@ -2451,6 +2493,180 @@ abstract class AppLocalizations { /// **'Regex'** String get sourceSearchRegex; + /// Placeholder for the active-document replacement field. + /// + /// In en, this message translates to: + /// **'Replace with'** + String get sourceSearchReplacement; + + /// Tooltip for replacing the current search match. + /// + /// In en, this message translates to: + /// **'Replace current'** + String get sourceSearchReplaceCurrent; + + /// Tooltip for replacing the current match and moving to the next. + /// + /// In en, this message translates to: + /// **'Replace and find next'** + String get sourceSearchReplaceAndFindNext; + + /// Tooltip for replacing every active-document search match. + /// + /// In en, this message translates to: + /// **'Replace all'** + String get sourceSearchReplaceAll; + + /// Action that previews replacements across the workspace. + /// + /// In en, this message translates to: + /// **'Replace in Workspace'** + String get workspaceReplace; + + /// Action and dialog title for reviewing workspace replacements. + /// + /// In en, this message translates to: + /// **'Review replacements'** + String get reviewReplacements; + + /// Action that applies selected workspace replacements. + /// + /// In en, this message translates to: + /// **'Apply replacements'** + String get applyReplacements; + + /// Heading for files excluded from a workspace replacement. + /// + /// In en, this message translates to: + /// **'Skipped files'** + String get skippedFiles; + + /// Source label for a workspace replacement preview using a dirty document buffer. + /// + /// In en, this message translates to: + /// **'Unsaved editor content'** + String get workspaceReplaceDirtyBuffer; + + /// Source label for a workspace replacement preview using disk content. + /// + /// In en, this message translates to: + /// **'Saved disk content'** + String get workspaceReplaceDiskContent; + + /// Checkbox label for selecting every replacement match in a file. + /// + /// In en, this message translates to: + /// **'Select all {count} matches'** + String selectFileMatches(int count); + + /// Summary shown after applying workspace replacements. + /// + /// In en, this message translates to: + /// **'Replaced {matches} matches in {files} files; skipped {skipped}.'** + String workspaceReplaceApplied(int matches, int files, int skipped); + + /// Dialog title for selecting a line-ending style. + /// + /// In en, this message translates to: + /// **'Normalize line endings'** + String get normalizeLineEndings; + + /// Prompt shown before saving a document with mixed line endings. + /// + /// In en, this message translates to: + /// **'This document contains mixed line endings. Choose a format.'** + String get mixedLineEndingsSavePrompt; + + /// Prompt shown before replacing content in a mixed-line-ending file. + /// + /// In en, this message translates to: + /// **'{fileName} uses mixed line endings. Choose the format to use before replacing.'** + String workspaceReplaceMixedLineEndings(String fileName); + + /// Workspace replacement issue for a file above the size limit. + /// + /// In en, this message translates to: + /// **'Skipped an oversized file.'** + String get workspaceReplaceIssueOversized; + + /// Workspace replacement issue for an unreadable file. + /// + /// In en, this message translates to: + /// **'Skipped a file that could not be read.'** + String get workspaceReplaceIssueUnreadable; + + /// Workspace replacement issue for invalid UTF-8 content. + /// + /// In en, this message translates to: + /// **'Skipped a file that is not valid UTF-8.'** + String get workspaceReplaceIssueInvalidUtf8; + + /// Workspace replacement issue when the match limit is reached. + /// + /// In en, this message translates to: + /// **'The replacement preview was truncated.'** + String get workspaceReplaceIssueTruncated; + + /// Workspace replacement issue for stale disk content. + /// + /// In en, this message translates to: + /// **'Skipped a file that changed after the preview.'** + String get workspaceReplaceIssueFileChanged; + + /// Workspace replacement issue for stale in-memory content. + /// + /// In en, this message translates to: + /// **'Skipped an editor buffer that changed after the preview.'** + String get workspaceReplaceIssueBufferChanged; + + /// Workspace replacement issue for mixed line endings without a selected format. + /// + /// In en, this message translates to: + /// **'Choose LF or CRLF normalization before replacing.'** + String get workspaceReplaceIssueNormalizationRequired; + + /// Title of the external-file comparison dialog. + /// + /// In en, this message translates to: + /// **'External changes — {fileName}'** + String externalChangesTitle(String fileName); + + /// Persistent banner shown when an open file is deleted externally. + /// + /// In en, this message translates to: + /// **'This file was deleted on disk.'** + String get externalFileDeleted; + + /// Persistent banner shown when a dirty file changes externally. + /// + /// In en, this message translates to: + /// **'This file changed on disk while you have unsaved edits.'** + String get externalFileChanged; + + /// Action that compares the editor buffer with the disk version. + /// + /// In en, this message translates to: + /// **'Compare'** + String get compare; + + /// Action that replaces the editor buffer with the disk version. + /// + /// In en, this message translates to: + /// **'Reload from Disk'** + String get reloadFromDisk; + + /// Action that keeps the editor buffer after an external change. + /// + /// In en, this message translates to: + /// **'Keep Mine'** + String get keepMine; + + /// Action that saves the current document to another path. + /// + /// In en, this message translates to: + /// **'Save As'** + String get saveAs; + /// Source search status shown when the regular expression is invalid. /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index f79fb216..17b4cd52 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -217,6 +217,15 @@ class AppLocalizationsAr extends AppLocalizations { @override String get keyboardShortcuts => 'اختصارات لوحة المفاتيح'; + @override + String get commandPalette => 'لوحة الأوامر'; + + @override + String get commandPaletteHint => 'اكتب أمرًا'; + + @override + String get commandPaletteEmpty => 'لا توجد أوامر مطابقة'; + @override String get lightTheme => 'فاتح'; @@ -885,6 +894,18 @@ class AppLocalizationsAr extends AppLocalizations { @override String get deleteColumn => 'حذف العمود'; + @override + String get tableAlignmentUnspecified => 'المحاذاة: غير محددة'; + + @override + String get tableAlignmentLeft => 'المحاذاة: إلى اليسار'; + + @override + String get tableAlignmentCenter => 'المحاذاة: إلى الوسط'; + + @override + String get tableAlignmentRight => 'المحاذاة: إلى اليمين'; + @override String tableRowNumber(int rowNumber) { return 'الصف $rowNumber'; @@ -1324,6 +1345,108 @@ class AppLocalizationsAr extends AppLocalizations { @override String get sourceSearchRegex => 'تعبير نمطي'; + @override + String get sourceSearchReplacement => 'استبدال بـ'; + + @override + String get sourceSearchReplaceCurrent => 'استبدال المطابقة الحالية'; + + @override + String get sourceSearchReplaceAndFindNext => 'استبدال والبحث عن التالي'; + + @override + String get sourceSearchReplaceAll => 'استبدال الكل'; + + @override + String get workspaceReplace => 'استبدال في مساحة العمل'; + + @override + String get reviewReplacements => 'مراجعة الاستبدالات'; + + @override + String get applyReplacements => 'تطبيق الاستبدالات'; + + @override + String get skippedFiles => 'الملفات المتخطاة'; + + @override + String get workspaceReplaceDirtyBuffer => 'محتوى المحرر غير المحفوظ'; + + @override + String get workspaceReplaceDiskContent => 'المحتوى المحفوظ على القرص'; + + @override + String selectFileMatches(int count) { + return 'تحديد كل المطابقات وعددها $count'; + } + + @override + String workspaceReplaceApplied(int matches, int files, int skipped) { + return 'تم استبدال $matches مطابقة في $files ملفًا؛ وتم تخطي $skipped.'; + } + + @override + String get normalizeLineEndings => 'توحيد نهايات الأسطر'; + + @override + String get mixedLineEndingsSavePrompt => + 'يحتوي هذا المستند على نهايات أسطر مختلطة. اختر تنسيقًا.'; + + @override + String workspaceReplaceMixedLineEndings(String fileName) { + return 'يستخدم الملف ⁨$fileName⁩ نهايات أسطر مختلطة. اختر التنسيق قبل الاستبدال.'; + } + + @override + String get workspaceReplaceIssueOversized => + 'تم تخطي ملف يتجاوز الحجم المسموح.'; + + @override + String get workspaceReplaceIssueUnreadable => 'تم تخطي ملف تعذرت قراءته.'; + + @override + String get workspaceReplaceIssueInvalidUtf8 => + 'تم تخطي ملف بترميز UTF-8 غير صالح.'; + + @override + String get workspaceReplaceIssueTruncated => 'تم اقتطاع معاينة الاستبدال.'; + + @override + String get workspaceReplaceIssueFileChanged => + 'تم تخطي ملف تغيّر بعد المعاينة.'; + + @override + String get workspaceReplaceIssueBufferChanged => + 'تم تخطي مخزن محرر مؤقت تغيّر بعد المعاينة.'; + + @override + String get workspaceReplaceIssueNormalizationRequired => + 'اختر توحيد LF أو CRLF قبل الاستبدال.'; + + @override + String externalChangesTitle(String fileName) { + return 'تغييرات خارجية — ⁨$fileName⁩'; + } + + @override + String get externalFileDeleted => 'تم حذف هذا الملف من القرص.'; + + @override + String get externalFileChanged => + 'تغيّر هذا الملف على القرص بينما لديك تعديلات غير محفوظة.'; + + @override + String get compare => 'مقارنة'; + + @override + String get reloadFromDisk => 'إعادة التحميل من القرص'; + + @override + String get keepMine => 'الاحتفاظ بنسختي'; + + @override + String get saveAs => 'حفظ باسم'; + @override String get sourceSearchInvalidRegex => 'تعبير نمطي غير صالح'; diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 0d0d69fd..58791b72 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -221,6 +221,15 @@ class AppLocalizationsDe extends AppLocalizations { @override String get keyboardShortcuts => 'Tastaturkürzel'; + @override + String get commandPalette => 'Befehlspalette'; + + @override + String get commandPaletteHint => 'Befehl eingeben'; + + @override + String get commandPaletteEmpty => 'Keine passenden Befehle'; + @override String get lightTheme => 'Hell'; @@ -906,6 +915,18 @@ class AppLocalizationsDe extends AppLocalizations { @override String get deleteColumn => 'Spalte löschen'; + @override + String get tableAlignmentUnspecified => 'Ausrichtung: Nicht festgelegt'; + + @override + String get tableAlignmentLeft => 'Ausrichtung: Links'; + + @override + String get tableAlignmentCenter => 'Ausrichtung: Mitte'; + + @override + String get tableAlignmentRight => 'Ausrichtung: Rechts'; + @override String tableRowNumber(int rowNumber) { return 'Zeile $rowNumber'; @@ -1336,6 +1357,111 @@ class AppLocalizationsDe extends AppLocalizations { @override String get sourceSearchRegex => 'Regulärer Ausdruck'; + @override + String get sourceSearchReplacement => 'Ersetzen durch'; + + @override + String get sourceSearchReplaceCurrent => 'Aktuellen Treffer ersetzen'; + + @override + String get sourceSearchReplaceAndFindNext => 'Ersetzen und weitersuchen'; + + @override + String get sourceSearchReplaceAll => 'Alle ersetzen'; + + @override + String get workspaceReplace => 'Im Arbeitsbereich ersetzen'; + + @override + String get reviewReplacements => 'Ersetzungen prüfen'; + + @override + String get applyReplacements => 'Ersetzungen anwenden'; + + @override + String get skippedFiles => 'Übersprungene Dateien'; + + @override + String get workspaceReplaceDirtyBuffer => 'Ungespeicherter Editorinhalt'; + + @override + String get workspaceReplaceDiskContent => 'Gespeicherter Festplatteninhalt'; + + @override + String selectFileMatches(int count) { + return 'Alle $count Treffer auswählen'; + } + + @override + String workspaceReplaceApplied(int matches, int files, int skipped) { + return '$matches Treffer in $files Dateien ersetzt; $skipped übersprungen.'; + } + + @override + String get normalizeLineEndings => 'Zeilenenden normalisieren'; + + @override + String get mixedLineEndingsSavePrompt => + 'Dieses Dokument enthält gemischte Zeilenenden. Wählen Sie ein Format.'; + + @override + String workspaceReplaceMixedLineEndings(String fileName) { + return '$fileName verwendet gemischte Zeilenenden. Wählen Sie vor dem Ersetzen das gewünschte Format.'; + } + + @override + String get workspaceReplaceIssueOversized => + 'Eine zu große Datei wurde übersprungen.'; + + @override + String get workspaceReplaceIssueUnreadable => + 'Eine nicht lesbare Datei wurde übersprungen.'; + + @override + String get workspaceReplaceIssueInvalidUtf8 => + 'Eine Datei ohne gültige UTF-8-Codierung wurde übersprungen.'; + + @override + String get workspaceReplaceIssueTruncated => + 'Die Ersetzungsvorschau wurde gekürzt.'; + + @override + String get workspaceReplaceIssueFileChanged => + 'Eine Datei, die sich nach der Vorschau geändert hat, wurde übersprungen.'; + + @override + String get workspaceReplaceIssueBufferChanged => + 'Ein Editorpuffer, der sich nach der Vorschau geändert hat, wurde übersprungen.'; + + @override + String get workspaceReplaceIssueNormalizationRequired => + 'Wählen Sie vor dem Ersetzen die Normalisierung auf LF oder CRLF.'; + + @override + String externalChangesTitle(String fileName) { + return 'Externe Änderungen — $fileName'; + } + + @override + String get externalFileDeleted => + 'Diese Datei wurde auf dem Datenträger gelöscht.'; + + @override + String get externalFileChanged => + 'Diese Datei wurde auf dem Datenträger geändert, während ungespeicherte Änderungen vorliegen.'; + + @override + String get compare => 'Vergleichen'; + + @override + String get reloadFromDisk => 'Vom Datenträger neu laden'; + + @override + String get keepMine => 'Meine Version behalten'; + + @override + String get saveAs => 'Speichern unter'; + @override String get sourceSearchInvalidRegex => 'Ungültiger regulärer Ausdruck'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 71e0a682..51dc287d 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -218,6 +218,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get keyboardShortcuts => 'Keyboard Shortcuts'; + @override + String get commandPalette => 'Command Palette'; + + @override + String get commandPaletteHint => 'Type a command'; + + @override + String get commandPaletteEmpty => 'No matching commands'; + @override String get lightTheme => 'Light'; @@ -888,6 +897,18 @@ class AppLocalizationsEn extends AppLocalizations { @override String get deleteColumn => 'Delete column'; + @override + String get tableAlignmentUnspecified => 'Alignment: Unspecified'; + + @override + String get tableAlignmentLeft => 'Alignment: Left'; + + @override + String get tableAlignmentCenter => 'Alignment: Center'; + + @override + String get tableAlignmentRight => 'Alignment: Right'; + @override String tableRowNumber(int rowNumber) { return 'Row $rowNumber'; @@ -1313,6 +1334,109 @@ class AppLocalizationsEn extends AppLocalizations { @override String get sourceSearchRegex => 'Regex'; + @override + String get sourceSearchReplacement => 'Replace with'; + + @override + String get sourceSearchReplaceCurrent => 'Replace current'; + + @override + String get sourceSearchReplaceAndFindNext => 'Replace and find next'; + + @override + String get sourceSearchReplaceAll => 'Replace all'; + + @override + String get workspaceReplace => 'Replace in Workspace'; + + @override + String get reviewReplacements => 'Review replacements'; + + @override + String get applyReplacements => 'Apply replacements'; + + @override + String get skippedFiles => 'Skipped files'; + + @override + String get workspaceReplaceDirtyBuffer => 'Unsaved editor content'; + + @override + String get workspaceReplaceDiskContent => 'Saved disk content'; + + @override + String selectFileMatches(int count) { + return 'Select all $count matches'; + } + + @override + String workspaceReplaceApplied(int matches, int files, int skipped) { + return 'Replaced $matches matches in $files files; skipped $skipped.'; + } + + @override + String get normalizeLineEndings => 'Normalize line endings'; + + @override + String get mixedLineEndingsSavePrompt => + 'This document contains mixed line endings. Choose a format.'; + + @override + String workspaceReplaceMixedLineEndings(String fileName) { + return '$fileName uses mixed line endings. Choose the format to use before replacing.'; + } + + @override + String get workspaceReplaceIssueOversized => 'Skipped an oversized file.'; + + @override + String get workspaceReplaceIssueUnreadable => + 'Skipped a file that could not be read.'; + + @override + String get workspaceReplaceIssueInvalidUtf8 => + 'Skipped a file that is not valid UTF-8.'; + + @override + String get workspaceReplaceIssueTruncated => + 'The replacement preview was truncated.'; + + @override + String get workspaceReplaceIssueFileChanged => + 'Skipped a file that changed after the preview.'; + + @override + String get workspaceReplaceIssueBufferChanged => + 'Skipped an editor buffer that changed after the preview.'; + + @override + String get workspaceReplaceIssueNormalizationRequired => + 'Choose LF or CRLF normalization before replacing.'; + + @override + String externalChangesTitle(String fileName) { + return 'External changes — $fileName'; + } + + @override + String get externalFileDeleted => 'This file was deleted on disk.'; + + @override + String get externalFileChanged => + 'This file changed on disk while you have unsaved edits.'; + + @override + String get compare => 'Compare'; + + @override + String get reloadFromDisk => 'Reload from Disk'; + + @override + String get keepMine => 'Keep Mine'; + + @override + String get saveAs => 'Save As'; + @override String get sourceSearchInvalidRegex => 'Invalid regular expression'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index c50f9526..129ad485 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -220,6 +220,15 @@ class AppLocalizationsEs extends AppLocalizations { @override String get keyboardShortcuts => 'Atajos de teclado'; + @override + String get commandPalette => 'Paleta de comandos'; + + @override + String get commandPaletteHint => 'Escribe un comando'; + + @override + String get commandPaletteEmpty => 'No hay comandos coincidentes'; + @override String get lightTheme => 'Claro'; @@ -903,6 +912,18 @@ class AppLocalizationsEs extends AppLocalizations { @override String get deleteColumn => 'Eliminar columna'; + @override + String get tableAlignmentUnspecified => 'Alineación: Sin especificar'; + + @override + String get tableAlignmentLeft => 'Alineación: Izquierda'; + + @override + String get tableAlignmentCenter => 'Alineación: Centro'; + + @override + String get tableAlignmentRight => 'Alineación: Derecha'; + @override String tableRowNumber(int rowNumber) { return 'Fila $rowNumber'; @@ -1333,6 +1354,110 @@ class AppLocalizationsEs extends AppLocalizations { @override String get sourceSearchRegex => 'Expresión regular'; + @override + String get sourceSearchReplacement => 'Reemplazar por'; + + @override + String get sourceSearchReplaceCurrent => 'Reemplazar actual'; + + @override + String get sourceSearchReplaceAndFindNext => 'Reemplazar y buscar siguiente'; + + @override + String get sourceSearchReplaceAll => 'Reemplazar todo'; + + @override + String get workspaceReplace => 'Reemplazar en el espacio de trabajo'; + + @override + String get reviewReplacements => 'Revisar reemplazos'; + + @override + String get applyReplacements => 'Aplicar reemplazos'; + + @override + String get skippedFiles => 'Archivos omitidos'; + + @override + String get workspaceReplaceDirtyBuffer => 'Contenido no guardado del editor'; + + @override + String get workspaceReplaceDiskContent => 'Contenido guardado en disco'; + + @override + String selectFileMatches(int count) { + return 'Seleccionar las $count coincidencias'; + } + + @override + String workspaceReplaceApplied(int matches, int files, int skipped) { + return 'Se reemplazaron $matches coincidencias en $files archivos; se omitieron $skipped.'; + } + + @override + String get normalizeLineEndings => 'Normalizar finales de línea'; + + @override + String get mixedLineEndingsSavePrompt => + 'Este documento contiene finales de línea mezclados. Elige un formato.'; + + @override + String workspaceReplaceMixedLineEndings(String fileName) { + return '$fileName usa finales de línea mezclados. Elige el formato antes de reemplazar.'; + } + + @override + String get workspaceReplaceIssueOversized => + 'Se omitió un archivo demasiado grande.'; + + @override + String get workspaceReplaceIssueUnreadable => + 'Se omitió un archivo que no se pudo leer.'; + + @override + String get workspaceReplaceIssueInvalidUtf8 => + 'Se omitió un archivo que no contiene UTF-8 válido.'; + + @override + String get workspaceReplaceIssueTruncated => + 'La vista previa de reemplazos se truncó.'; + + @override + String get workspaceReplaceIssueFileChanged => + 'Se omitió un archivo que cambió después de la vista previa.'; + + @override + String get workspaceReplaceIssueBufferChanged => + 'Se omitió un búfer del editor que cambió después de la vista previa.'; + + @override + String get workspaceReplaceIssueNormalizationRequired => + 'Elige la normalización LF o CRLF antes de reemplazar.'; + + @override + String externalChangesTitle(String fileName) { + return 'Cambios externos — $fileName'; + } + + @override + String get externalFileDeleted => 'Este archivo se eliminó del disco.'; + + @override + String get externalFileChanged => + 'Este archivo cambió en el disco mientras tienes cambios sin guardar.'; + + @override + String get compare => 'Comparar'; + + @override + String get reloadFromDisk => 'Recargar desde el disco'; + + @override + String get keepMine => 'Conservar mi versión'; + + @override + String get saveAs => 'Guardar como'; + @override String get sourceSearchInvalidRegex => 'Expresión regular no válida'; diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index cc744993..d3f24431 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -217,6 +217,15 @@ class AppLocalizationsEt extends AppLocalizations { @override String get keyboardShortcuts => 'Kiirklahvid'; + @override + String get commandPalette => 'Käskude palett'; + + @override + String get commandPaletteHint => 'Sisesta käsk'; + + @override + String get commandPaletteEmpty => 'Sobivaid käske pole'; + @override String get lightTheme => 'Hele'; @@ -892,6 +901,18 @@ class AppLocalizationsEt extends AppLocalizations { @override String get deleteColumn => 'Kustuta veerg'; + @override + String get tableAlignmentUnspecified => 'Joondus: määramata'; + + @override + String get tableAlignmentLeft => 'Joondus: vasakule'; + + @override + String get tableAlignmentCenter => 'Joondus: keskele'; + + @override + String get tableAlignmentRight => 'Joondus: paremale'; + @override String tableRowNumber(int rowNumber) { return 'Rida $rowNumber'; @@ -1317,6 +1338,108 @@ class AppLocalizationsEt extends AppLocalizations { @override String get sourceSearchRegex => 'Regulaaravaldis'; + @override + String get sourceSearchReplacement => 'Asenda väärtusega'; + + @override + String get sourceSearchReplaceCurrent => 'Asenda praegune vaste'; + + @override + String get sourceSearchReplaceAndFindNext => 'Asenda ja leia järgmine'; + + @override + String get sourceSearchReplaceAll => 'Asenda kõik'; + + @override + String get workspaceReplace => 'Asenda tööruumis'; + + @override + String get reviewReplacements => 'Vaata asendused üle'; + + @override + String get applyReplacements => 'Rakenda asendused'; + + @override + String get skippedFiles => 'Vahele jäetud failid'; + + @override + String get workspaceReplaceDirtyBuffer => 'Salvestamata redaktori sisu'; + + @override + String get workspaceReplaceDiskContent => 'Kettale salvestatud sisu'; + + @override + String selectFileMatches(int count) { + return 'Vali kõik $count vastet'; + } + + @override + String workspaceReplaceApplied(int matches, int files, int skipped) { + return 'Asendati $matches vastet $files failis; vahele jäeti $skipped.'; + } + + @override + String get normalizeLineEndings => 'Normaliseeri reavahetused'; + + @override + String get mixedLineEndingsSavePrompt => + 'See dokument sisaldab eri tüüpi reavahetusi. Vali vorming.'; + + @override + String workspaceReplaceMixedLineEndings(String fileName) { + return 'Fail $fileName kasutab eri tüüpi reavahetusi. Vali enne asendamist vorming.'; + } + + @override + String get workspaceReplaceIssueOversized => 'Liiga suur fail jäeti vahele.'; + + @override + String get workspaceReplaceIssueUnreadable => + 'Fail, mida ei saanud lugeda, jäeti vahele.'; + + @override + String get workspaceReplaceIssueInvalidUtf8 => + 'Fail, mis pole korrektne UTF-8, jäeti vahele.'; + + @override + String get workspaceReplaceIssueTruncated => 'Asenduste eelvaade kärbiti.'; + + @override + String get workspaceReplaceIssueFileChanged => + 'Pärast eelvaadet muutunud fail jäeti vahele.'; + + @override + String get workspaceReplaceIssueBufferChanged => + 'Pärast eelvaadet muutunud redaktoripuhver jäeti vahele.'; + + @override + String get workspaceReplaceIssueNormalizationRequired => + 'Vali enne asendamist LF- või CRLF-normaliseerimine.'; + + @override + String externalChangesTitle(String fileName) { + return 'Välised muudatused — $fileName'; + } + + @override + String get externalFileDeleted => 'See fail kustutati kettalt.'; + + @override + String get externalFileChanged => + 'See fail muutus kettal ajal, kui sul on salvestamata muudatusi.'; + + @override + String get compare => 'Võrdle'; + + @override + String get reloadFromDisk => 'Laadi kettalt uuesti'; + + @override + String get keepMine => 'Säilita minu versioon'; + + @override + String get saveAs => 'Salvesta nimega'; + @override String get sourceSearchInvalidRegex => 'Vigane regulaaravaldis'; diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index c60f17f7..1dcf0fa3 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -217,6 +217,15 @@ class AppLocalizationsFa extends AppLocalizations { @override String get keyboardShortcuts => 'میانبرهای صفحه‌کلید'; + @override + String get commandPalette => 'پالت فرمان'; + + @override + String get commandPaletteHint => 'یک فرمان وارد کنید'; + + @override + String get commandPaletteEmpty => 'هیچ فرمان منطبقی وجود ندارد'; + @override String get lightTheme => 'روشن'; @@ -898,6 +907,18 @@ class AppLocalizationsFa extends AppLocalizations { @override String get deleteColumn => 'حذف ستون'; + @override + String get tableAlignmentUnspecified => 'تراز: مشخص‌نشده'; + + @override + String get tableAlignmentLeft => 'تراز: چپ'; + + @override + String get tableAlignmentCenter => 'تراز: وسط'; + + @override + String get tableAlignmentRight => 'تراز: راست'; + @override String tableRowNumber(int rowNumber) { final intl.NumberFormat rowNumberNumberFormat = @@ -1354,6 +1375,109 @@ class AppLocalizationsFa extends AppLocalizations { @override String get sourceSearchRegex => 'عبارت منظم'; + @override + String get sourceSearchReplacement => 'جایگزینی با'; + + @override + String get sourceSearchReplaceCurrent => 'جایگزینی مورد فعلی'; + + @override + String get sourceSearchReplaceAndFindNext => 'جایگزینی و یافتن بعدی'; + + @override + String get sourceSearchReplaceAll => 'جایگزینی همه'; + + @override + String get workspaceReplace => 'جایگزینی در فضای کاری'; + + @override + String get reviewReplacements => 'بازبینی جایگزینی‌ها'; + + @override + String get applyReplacements => 'اعمال جایگزینی‌ها'; + + @override + String get skippedFiles => 'فایل‌های نادیده‌گرفته‌شده'; + + @override + String get workspaceReplaceDirtyBuffer => 'محتوای ذخیره‌نشدهٔ ویرایشگر'; + + @override + String get workspaceReplaceDiskContent => 'محتوای ذخیره‌شده روی دیسک'; + + @override + String selectFileMatches(int count) { + return 'انتخاب هر $count مورد'; + } + + @override + String workspaceReplaceApplied(int matches, int files, int skipped) { + return '$matches مورد در $files فایل جایگزین شد؛ $skipped مورد نادیده گرفته شد.'; + } + + @override + String get normalizeLineEndings => 'یکسان‌سازی پایان خط‌ها'; + + @override + String get mixedLineEndingsSavePrompt => + 'این سند پایان خط‌های ترکیبی دارد. یک قالب انتخاب کنید.'; + + @override + String workspaceReplaceMixedLineEndings(String fileName) { + return 'فایل ⁨$fileName⁩ پایان خط‌های ترکیبی دارد. پیش از جایگزینی قالب را انتخاب کنید.'; + } + + @override + String get workspaceReplaceIssueOversized => + 'یک فایل بیش‌ازحد بزرگ نادیده گرفته شد.'; + + @override + String get workspaceReplaceIssueUnreadable => + 'فایلی که خوانده نمی‌شد نادیده گرفته شد.'; + + @override + String get workspaceReplaceIssueInvalidUtf8 => + 'فایلی با UTF-8 نامعتبر نادیده گرفته شد.'; + + @override + String get workspaceReplaceIssueTruncated => 'پیش‌نمایش جایگزینی کوتاه شد.'; + + @override + String get workspaceReplaceIssueFileChanged => + 'فایلی که پس از پیش‌نمایش تغییر کرده بود نادیده گرفته شد.'; + + @override + String get workspaceReplaceIssueBufferChanged => + 'بافر ویرایشگری که پس از پیش‌نمایش تغییر کرده بود نادیده گرفته شد.'; + + @override + String get workspaceReplaceIssueNormalizationRequired => + 'پیش از جایگزینی، یکسان‌سازی LF یا CRLF را انتخاب کنید.'; + + @override + String externalChangesTitle(String fileName) { + return 'تغییرات بیرونی — ⁨$fileName⁩'; + } + + @override + String get externalFileDeleted => 'این فایل از روی دیسک حذف شد.'; + + @override + String get externalFileChanged => + 'هنگامی که تغییرات ذخیره‌نشده داشتید، این فایل روی دیسک تغییر کرد.'; + + @override + String get compare => 'مقایسه'; + + @override + String get reloadFromDisk => 'بارگیری دوباره از دیسک'; + + @override + String get keepMine => 'نگه‌داشتن نسخهٔ من'; + + @override + String get saveAs => 'ذخیره با نام'; + @override String get sourceSearchInvalidRegex => 'عبارت منظم نامعتبر است'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 87b17d30..849d3e46 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -220,6 +220,15 @@ class AppLocalizationsFr extends AppLocalizations { @override String get keyboardShortcuts => 'Raccourcis clavier'; + @override + String get commandPalette => 'Palette de commandes'; + + @override + String get commandPaletteHint => 'Saisissez une commande'; + + @override + String get commandPaletteEmpty => 'Aucune commande correspondante'; + @override String get lightTheme => 'Clair'; @@ -902,6 +911,18 @@ class AppLocalizationsFr extends AppLocalizations { @override String get deleteColumn => 'Supprimer la colonne'; + @override + String get tableAlignmentUnspecified => 'Alignement : non spécifié'; + + @override + String get tableAlignmentLeft => 'Alignement : gauche'; + + @override + String get tableAlignmentCenter => 'Alignement : centre'; + + @override + String get tableAlignmentRight => 'Alignement : droite'; + @override String tableRowNumber(int rowNumber) { return 'Ligne $rowNumber'; @@ -1332,6 +1353,112 @@ class AppLocalizationsFr extends AppLocalizations { @override String get sourceSearchRegex => 'Expression régulière'; + @override + String get sourceSearchReplacement => 'Remplacer par'; + + @override + String get sourceSearchReplaceCurrent => 'Remplacer la sélection actuelle'; + + @override + String get sourceSearchReplaceAndFindNext => + 'Remplacer et rechercher le suivant'; + + @override + String get sourceSearchReplaceAll => 'Tout remplacer'; + + @override + String get workspaceReplace => 'Remplacer dans l’espace de travail'; + + @override + String get reviewReplacements => 'Vérifier les remplacements'; + + @override + String get applyReplacements => 'Appliquer les remplacements'; + + @override + String get skippedFiles => 'Fichiers ignorés'; + + @override + String get workspaceReplaceDirtyBuffer => + 'Contenu non enregistré de l’éditeur'; + + @override + String get workspaceReplaceDiskContent => 'Contenu enregistré sur le disque'; + + @override + String selectFileMatches(int count) { + return 'Sélectionner les $count occurrences'; + } + + @override + String workspaceReplaceApplied(int matches, int files, int skipped) { + return '$matches occurrences remplacées dans $files fichiers ; $skipped ignorées.'; + } + + @override + String get normalizeLineEndings => 'Normaliser les fins de ligne'; + + @override + String get mixedLineEndingsSavePrompt => + 'Ce document contient plusieurs types de fins de ligne. Choisissez un format.'; + + @override + String workspaceReplaceMixedLineEndings(String fileName) { + return '$fileName utilise plusieurs types de fins de ligne. Choisissez le format avant le remplacement.'; + } + + @override + String get workspaceReplaceIssueOversized => + 'Un fichier trop volumineux a été ignoré.'; + + @override + String get workspaceReplaceIssueUnreadable => + 'Un fichier illisible a été ignoré.'; + + @override + String get workspaceReplaceIssueInvalidUtf8 => + 'Un fichier dont l’encodage UTF-8 est incorrect a été ignoré.'; + + @override + String get workspaceReplaceIssueTruncated => + 'L’aperçu des remplacements a été tronqué.'; + + @override + String get workspaceReplaceIssueFileChanged => + 'Un fichier modifié après l’aperçu a été ignoré.'; + + @override + String get workspaceReplaceIssueBufferChanged => + 'Un tampon d’éditeur modifié après l’aperçu a été ignoré.'; + + @override + String get workspaceReplaceIssueNormalizationRequired => + 'Choisissez la normalisation LF ou CRLF avant le remplacement.'; + + @override + String externalChangesTitle(String fileName) { + return 'Modifications externes — $fileName'; + } + + @override + String get externalFileDeleted => 'Ce fichier a été supprimé du disque.'; + + @override + String get externalFileChanged => + 'Ce fichier a été modifié sur le disque alors que vous avez des modifications non enregistrées.'; + + @override + String get compare => 'Comparer'; + + @override + String get reloadFromDisk => 'Recharger depuis le disque'; + + @override + String get keepMine => 'Conserver ma version'; + + @override + String get saveAs => 'Enregistrer sous'; + @override String get sourceSearchInvalidRegex => 'Expression régulière non valide'; diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 32f03913..f3f42a91 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -219,6 +219,15 @@ class AppLocalizationsHi extends AppLocalizations { @override String get keyboardShortcuts => 'कीबोर्ड शॉर्टकट'; + @override + String get commandPalette => 'कमांड पैलेट'; + + @override + String get commandPaletteHint => 'कमांड लिखें'; + + @override + String get commandPaletteEmpty => 'कोई मेल खाता कमांड नहीं'; + @override String get lightTheme => 'लाइट'; @@ -884,6 +893,18 @@ class AppLocalizationsHi extends AppLocalizations { @override String get deleteColumn => 'कॉलम हटाएँ'; + @override + String get tableAlignmentUnspecified => 'संरेखण: अनिर्दिष्ट'; + + @override + String get tableAlignmentLeft => 'संरेखण: बायाँ'; + + @override + String get tableAlignmentCenter => 'संरेखण: मध्य'; + + @override + String get tableAlignmentRight => 'संरेखण: दायाँ'; + @override String tableRowNumber(int rowNumber) { return 'पंक्ति $rowNumber'; @@ -1310,6 +1331,109 @@ class AppLocalizationsHi extends AppLocalizations { @override String get sourceSearchRegex => 'रेगुलर एक्सप्रेशन'; + @override + String get sourceSearchReplacement => 'इससे बदलें'; + + @override + String get sourceSearchReplaceCurrent => 'वर्तमान मिलान बदलें'; + + @override + String get sourceSearchReplaceAndFindNext => 'बदलें और अगला खोजें'; + + @override + String get sourceSearchReplaceAll => 'सभी बदलें'; + + @override + String get workspaceReplace => 'वर्कस्पेस में बदलें'; + + @override + String get reviewReplacements => 'बदलावों की समीक्षा करें'; + + @override + String get applyReplacements => 'बदलाव लागू करें'; + + @override + String get skippedFiles => 'छोड़ी गई फ़ाइलें'; + + @override + String get workspaceReplaceDirtyBuffer => 'सहेजी न गई संपादक सामग्री'; + + @override + String get workspaceReplaceDiskContent => 'डिस्क पर सहेजी सामग्री'; + + @override + String selectFileMatches(int count) { + return 'सभी $count मिलान चुनें'; + } + + @override + String workspaceReplaceApplied(int matches, int files, int skipped) { + return '$files फ़ाइलों में $matches मिलान बदले गए; $skipped छोड़े गए।'; + } + + @override + String get normalizeLineEndings => 'पंक्ति अंत सामान्य करें'; + + @override + String get mixedLineEndingsSavePrompt => + 'इस दस्तावेज़ में मिले-जुले पंक्ति अंत हैं। कोई प्रारूप चुनें।'; + + @override + String workspaceReplaceMixedLineEndings(String fileName) { + return '$fileName में मिले-जुले पंक्ति अंत हैं। बदलने से पहले प्रारूप चुनें।'; + } + + @override + String get workspaceReplaceIssueOversized => 'बहुत बड़ी फ़ाइल छोड़ दी गई।'; + + @override + String get workspaceReplaceIssueUnreadable => + 'पढ़ी न जा सकने वाली फ़ाइल छोड़ दी गई।'; + + @override + String get workspaceReplaceIssueInvalidUtf8 => + 'अमान्य UTF-8 वाली फ़ाइल छोड़ दी गई।'; + + @override + String get workspaceReplaceIssueTruncated => + 'बदलाव पूर्वावलोकन छोटा कर दिया गया।'; + + @override + String get workspaceReplaceIssueFileChanged => + 'पूर्वावलोकन के बाद बदली फ़ाइल छोड़ दी गई।'; + + @override + String get workspaceReplaceIssueBufferChanged => + 'पूर्वावलोकन के बाद बदला संपादक बफ़र छोड़ दिया गया।'; + + @override + String get workspaceReplaceIssueNormalizationRequired => + 'बदलने से पहले LF या CRLF सामान्यीकरण चुनें।'; + + @override + String externalChangesTitle(String fileName) { + return 'बाहरी बदलाव — $fileName'; + } + + @override + String get externalFileDeleted => 'यह फ़ाइल डिस्क से हटा दी गई।'; + + @override + String get externalFileChanged => + 'आपके सहेजे न गए बदलावों के दौरान यह फ़ाइल डिस्क पर बदल गई।'; + + @override + String get compare => 'तुलना करें'; + + @override + String get reloadFromDisk => 'डिस्क से फिर लोड करें'; + + @override + String get keepMine => 'मेरा संस्करण रखें'; + + @override + String get saveAs => 'इस रूप में सहेजें'; + @override String get sourceSearchInvalidRegex => 'अमान्य रेगुलर एक्सप्रेशन'; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index 3ef2a4fd..e3867486 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -219,6 +219,15 @@ class AppLocalizationsIt extends AppLocalizations { @override String get keyboardShortcuts => 'Scorciatoie da tastiera'; + @override + String get commandPalette => 'Tavolozza dei comandi'; + + @override + String get commandPaletteHint => 'Digita un comando'; + + @override + String get commandPaletteEmpty => 'Nessun comando corrispondente'; + @override String get lightTheme => 'Chiaro'; @@ -900,6 +909,18 @@ class AppLocalizationsIt extends AppLocalizations { @override String get deleteColumn => 'Elimina colonna'; + @override + String get tableAlignmentUnspecified => 'Allineamento: non specificato'; + + @override + String get tableAlignmentLeft => 'Allineamento: sinistra'; + + @override + String get tableAlignmentCenter => 'Allineamento: centro'; + + @override + String get tableAlignmentRight => 'Allineamento: destra'; + @override String tableRowNumber(int rowNumber) { return 'Riga $rowNumber'; @@ -1329,6 +1350,110 @@ class AppLocalizationsIt extends AppLocalizations { @override String get sourceSearchRegex => 'Espressione regolare'; + @override + String get sourceSearchReplacement => 'Sostituisci con'; + + @override + String get sourceSearchReplaceCurrent => 'Sostituisci corrente'; + + @override + String get sourceSearchReplaceAndFindNext => 'Sostituisci e trova successivo'; + + @override + String get sourceSearchReplaceAll => 'Sostituisci tutto'; + + @override + String get workspaceReplace => 'Sostituisci nell’area di lavoro'; + + @override + String get reviewReplacements => 'Rivedi sostituzioni'; + + @override + String get applyReplacements => 'Applica sostituzioni'; + + @override + String get skippedFiles => 'File ignorati'; + + @override + String get workspaceReplaceDirtyBuffer => 'Contenuto dell’editor non salvato'; + + @override + String get workspaceReplaceDiskContent => 'Contenuto salvato su disco'; + + @override + String selectFileMatches(int count) { + return 'Seleziona tutte le $count corrispondenze'; + } + + @override + String workspaceReplaceApplied(int matches, int files, int skipped) { + return 'Sostituite $matches corrispondenze in $files file; $skipped ignorate.'; + } + + @override + String get normalizeLineEndings => 'Normalizza terminatori di riga'; + + @override + String get mixedLineEndingsSavePrompt => + 'Questo documento contiene terminatori di riga misti. Scegli un formato.'; + + @override + String workspaceReplaceMixedLineEndings(String fileName) { + return '$fileName usa terminatori di riga misti. Scegli il formato prima di sostituire.'; + } + + @override + String get workspaceReplaceIssueOversized => + 'È stato ignorato un file troppo grande.'; + + @override + String get workspaceReplaceIssueUnreadable => + 'È stato ignorato un file illeggibile.'; + + @override + String get workspaceReplaceIssueInvalidUtf8 => + 'È stato ignorato un file che non è UTF-8 valido.'; + + @override + String get workspaceReplaceIssueTruncated => + 'L’anteprima delle sostituzioni è stata troncata.'; + + @override + String get workspaceReplaceIssueFileChanged => + 'È stato ignorato un file modificato dopo l’anteprima.'; + + @override + String get workspaceReplaceIssueBufferChanged => + 'È stato ignorato un buffer modificato dopo l’anteprima.'; + + @override + String get workspaceReplaceIssueNormalizationRequired => + 'Scegli la normalizzazione LF o CRLF prima di sostituire.'; + + @override + String externalChangesTitle(String fileName) { + return 'Modifiche esterne — $fileName'; + } + + @override + String get externalFileDeleted => 'Questo file è stato eliminato dal disco.'; + + @override + String get externalFileChanged => + 'Questo file è cambiato sul disco mentre sono presenti modifiche non salvate.'; + + @override + String get compare => 'Confronta'; + + @override + String get reloadFromDisk => 'Ricarica dal disco'; + + @override + String get keepMine => 'Mantieni la mia versione'; + + @override + String get saveAs => 'Salva con nome'; + @override String get sourceSearchInvalidRegex => 'Espressione regolare non valida'; diff --git a/lib/l10n/generated/app_localizations_nb.dart b/lib/l10n/generated/app_localizations_nb.dart index 7754270c..f358ca53 100644 --- a/lib/l10n/generated/app_localizations_nb.dart +++ b/lib/l10n/generated/app_localizations_nb.dart @@ -220,6 +220,15 @@ class AppLocalizationsNb extends AppLocalizations { @override String get keyboardShortcuts => 'Tastatursnarveier'; + @override + String get commandPalette => 'Kommandopalett'; + + @override + String get commandPaletteHint => 'Skriv inn en kommando'; + + @override + String get commandPaletteEmpty => 'Ingen samsvarende kommandoer'; + @override String get lightTheme => 'Lys'; @@ -892,6 +901,18 @@ class AppLocalizationsNb extends AppLocalizations { @override String get deleteColumn => 'Slett kolonne'; + @override + String get tableAlignmentUnspecified => 'Justering: ikke angitt'; + + @override + String get tableAlignmentLeft => 'Justering: venstre'; + + @override + String get tableAlignmentCenter => 'Justering: midtstilt'; + + @override + String get tableAlignmentRight => 'Justering: høyre'; + @override String tableRowNumber(int rowNumber) { return 'Rad $rowNumber'; @@ -1319,6 +1340,110 @@ class AppLocalizationsNb extends AppLocalizations { @override String get sourceSearchRegex => 'Regulært uttrykk'; + @override + String get sourceSearchReplacement => 'Erstatt med'; + + @override + String get sourceSearchReplaceCurrent => 'Erstatt gjeldende treff'; + + @override + String get sourceSearchReplaceAndFindNext => 'Erstatt og finn neste'; + + @override + String get sourceSearchReplaceAll => 'Erstatt alle'; + + @override + String get workspaceReplace => 'Erstatt i arbeidsområdet'; + + @override + String get reviewReplacements => 'Se gjennom erstatninger'; + + @override + String get applyReplacements => 'Bruk erstatninger'; + + @override + String get skippedFiles => 'Filer som ble hoppet over'; + + @override + String get workspaceReplaceDirtyBuffer => 'Ulagret redigeringsinnhold'; + + @override + String get workspaceReplaceDiskContent => 'Innhold lagret på disk'; + + @override + String selectFileMatches(int count) { + return 'Velg alle $count treff'; + } + + @override + String workspaceReplaceApplied(int matches, int files, int skipped) { + return 'Erstattet $matches treff i $files filer; hoppet over $skipped.'; + } + + @override + String get normalizeLineEndings => 'Normaliser linjeslutt'; + + @override + String get mixedLineEndingsSavePrompt => + 'Dette dokumentet inneholder blandede linjeslutt. Velg et format.'; + + @override + String workspaceReplaceMixedLineEndings(String fileName) { + return '$fileName bruker blandede linjeslutt. Velg format før du erstatter.'; + } + + @override + String get workspaceReplaceIssueOversized => + 'En for stor fil ble hoppet over.'; + + @override + String get workspaceReplaceIssueUnreadable => + 'En fil som ikke kunne leses, ble hoppet over.'; + + @override + String get workspaceReplaceIssueInvalidUtf8 => + 'En fil som ikke er gyldig UTF-8, ble hoppet over.'; + + @override + String get workspaceReplaceIssueTruncated => + 'Erstatningsforhåndsvisningen ble avkortet.'; + + @override + String get workspaceReplaceIssueFileChanged => + 'En fil som ble endret etter forhåndsvisningen, ble hoppet over.'; + + @override + String get workspaceReplaceIssueBufferChanged => + 'En redigeringsbuffer som ble endret etter forhåndsvisningen, ble hoppet over.'; + + @override + String get workspaceReplaceIssueNormalizationRequired => + 'Velg LF- eller CRLF-normalisering før du erstatter.'; + + @override + String externalChangesTitle(String fileName) { + return 'Eksterne endringer — $fileName'; + } + + @override + String get externalFileDeleted => 'Denne filen ble slettet fra disken.'; + + @override + String get externalFileChanged => + 'Denne filen ble endret på disken mens du har ulagrede endringer.'; + + @override + String get compare => 'Sammenlign'; + + @override + String get reloadFromDisk => 'Last inn fra disk på nytt'; + + @override + String get keepMine => 'Behold min versjon'; + + @override + String get saveAs => 'Lagre som'; + @override String get sourceSearchInvalidRegex => 'Ugyldig regulært uttrykk'; diff --git a/lib/l10n/generated/app_localizations_pl.dart b/lib/l10n/generated/app_localizations_pl.dart index d97f8173..b9b52db0 100644 --- a/lib/l10n/generated/app_localizations_pl.dart +++ b/lib/l10n/generated/app_localizations_pl.dart @@ -219,6 +219,15 @@ class AppLocalizationsPl extends AppLocalizations { @override String get keyboardShortcuts => 'Skróty klawiaturowe'; + @override + String get commandPalette => 'Paleta poleceń'; + + @override + String get commandPaletteHint => 'Wpisz polecenie'; + + @override + String get commandPaletteEmpty => 'Brak pasujących poleceń'; + @override String get lightTheme => 'Jasny'; @@ -903,6 +912,18 @@ class AppLocalizationsPl extends AppLocalizations { @override String get deleteColumn => 'Usuń kolumnę'; + @override + String get tableAlignmentUnspecified => 'Wyrównanie: nieokreślone'; + + @override + String get tableAlignmentLeft => 'Wyrównanie: do lewej'; + + @override + String get tableAlignmentCenter => 'Wyrównanie: do środka'; + + @override + String get tableAlignmentRight => 'Wyrównanie: do prawej'; + @override String tableRowNumber(int rowNumber) { return 'Wiersz $rowNumber'; @@ -1339,6 +1360,109 @@ class AppLocalizationsPl extends AppLocalizations { @override String get sourceSearchRegex => 'Wyrażenie regularne'; + @override + String get sourceSearchReplacement => 'Zamień na'; + + @override + String get sourceSearchReplaceCurrent => 'Zamień bieżące'; + + @override + String get sourceSearchReplaceAndFindNext => 'Zamień i znajdź następne'; + + @override + String get sourceSearchReplaceAll => 'Zamień wszystko'; + + @override + String get workspaceReplace => 'Zamień w obszarze roboczym'; + + @override + String get reviewReplacements => 'Przejrzyj zamiany'; + + @override + String get applyReplacements => 'Zastosuj zamiany'; + + @override + String get skippedFiles => 'Pominięte pliki'; + + @override + String get workspaceReplaceDirtyBuffer => 'Niezapisana zawartość edytora'; + + @override + String get workspaceReplaceDiskContent => 'Zawartość zapisana na dysku'; + + @override + String selectFileMatches(int count) { + return 'Wybierz wszystkie dopasowania ($count)'; + } + + @override + String workspaceReplaceApplied(int matches, int files, int skipped) { + return 'Zamieniono $matches dopasowań w $files plikach; pominięto $skipped.'; + } + + @override + String get normalizeLineEndings => 'Normalizuj zakończenia wierszy'; + + @override + String get mixedLineEndingsSavePrompt => + 'Ten dokument zawiera mieszane zakończenia wierszy. Wybierz format.'; + + @override + String workspaceReplaceMixedLineEndings(String fileName) { + return '$fileName używa mieszanych zakończeń wierszy. Wybierz format przed zamianą.'; + } + + @override + String get workspaceReplaceIssueOversized => 'Pominięto zbyt duży plik.'; + + @override + String get workspaceReplaceIssueUnreadable => + 'Pominięto plik, którego nie można odczytać.'; + + @override + String get workspaceReplaceIssueInvalidUtf8 => + 'Pominięto plik z nieprawidłowym kodowaniem UTF-8.'; + + @override + String get workspaceReplaceIssueTruncated => + 'Podgląd zamian został skrócony.'; + + @override + String get workspaceReplaceIssueFileChanged => + 'Pominięto plik zmieniony po utworzeniu podglądu.'; + + @override + String get workspaceReplaceIssueBufferChanged => + 'Pominięto bufor edytora zmieniony po utworzeniu podglądu.'; + + @override + String get workspaceReplaceIssueNormalizationRequired => + 'Przed zamianą wybierz normalizację LF lub CRLF.'; + + @override + String externalChangesTitle(String fileName) { + return 'Zmiany zewnętrzne — $fileName'; + } + + @override + String get externalFileDeleted => 'Ten plik został usunięty z dysku.'; + + @override + String get externalFileChanged => + 'Ten plik zmienił się na dysku, gdy masz niezapisane zmiany.'; + + @override + String get compare => 'Porównaj'; + + @override + String get reloadFromDisk => 'Wczytaj ponownie z dysku'; + + @override + String get keepMine => 'Zachowaj moją wersję'; + + @override + String get saveAs => 'Zapisz jako'; + @override String get sourceSearchInvalidRegex => 'Nieprawidłowe wyrażenie regularne'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index c0aab881..7aaec936 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -220,6 +220,15 @@ class AppLocalizationsPt extends AppLocalizations { @override String get keyboardShortcuts => 'Atalhos de teclado'; + @override + String get commandPalette => 'Paleta de comandos'; + + @override + String get commandPaletteHint => 'Digite um comando'; + + @override + String get commandPaletteEmpty => 'Nenhum comando correspondente'; + @override String get lightTheme => 'Claro'; @@ -899,6 +908,18 @@ class AppLocalizationsPt extends AppLocalizations { @override String get deleteColumn => 'Excluir coluna'; + @override + String get tableAlignmentUnspecified => 'Alinhamento: não especificado'; + + @override + String get tableAlignmentLeft => 'Alinhamento: esquerda'; + + @override + String get tableAlignmentCenter => 'Alinhamento: centro'; + + @override + String get tableAlignmentRight => 'Alinhamento: direita'; + @override String tableRowNumber(int rowNumber) { return 'Linha $rowNumber'; @@ -1328,6 +1349,110 @@ class AppLocalizationsPt extends AppLocalizations { @override String get sourceSearchRegex => 'Expressão regular'; + @override + String get sourceSearchReplacement => 'Substituir por'; + + @override + String get sourceSearchReplaceCurrent => 'Substituir atual'; + + @override + String get sourceSearchReplaceAndFindNext => 'Substituir e localizar próximo'; + + @override + String get sourceSearchReplaceAll => 'Substituir tudo'; + + @override + String get workspaceReplace => 'Substituir no espaço de trabalho'; + + @override + String get reviewReplacements => 'Revisar substituições'; + + @override + String get applyReplacements => 'Aplicar substituições'; + + @override + String get skippedFiles => 'Arquivos ignorados'; + + @override + String get workspaceReplaceDirtyBuffer => 'Conteúdo não salvo do editor'; + + @override + String get workspaceReplaceDiskContent => 'Conteúdo salvo no disco'; + + @override + String selectFileMatches(int count) { + return 'Selecionar todas as $count correspondências'; + } + + @override + String workspaceReplaceApplied(int matches, int files, int skipped) { + return 'Foram substituídas $matches correspondências em $files arquivos; $skipped ignoradas.'; + } + + @override + String get normalizeLineEndings => 'Normalizar finais de linha'; + + @override + String get mixedLineEndingsSavePrompt => + 'Este documento contém finais de linha mistos. Escolha um formato.'; + + @override + String workspaceReplaceMixedLineEndings(String fileName) { + return '$fileName usa finais de linha mistos. Escolha o formato antes de substituir.'; + } + + @override + String get workspaceReplaceIssueOversized => + 'Um arquivo grande demais foi ignorado.'; + + @override + String get workspaceReplaceIssueUnreadable => + 'Um arquivo que não pôde ser lido foi ignorado.'; + + @override + String get workspaceReplaceIssueInvalidUtf8 => + 'Um arquivo que não é UTF-8 válido foi ignorado.'; + + @override + String get workspaceReplaceIssueTruncated => + 'A prévia de substituições foi truncada.'; + + @override + String get workspaceReplaceIssueFileChanged => + 'Um arquivo alterado após a prévia foi ignorado.'; + + @override + String get workspaceReplaceIssueBufferChanged => + 'Um buffer do editor alterado após a prévia foi ignorado.'; + + @override + String get workspaceReplaceIssueNormalizationRequired => + 'Escolha a normalização LF ou CRLF antes de substituir.'; + + @override + String externalChangesTitle(String fileName) { + return 'Alterações externas — $fileName'; + } + + @override + String get externalFileDeleted => 'Este arquivo foi excluído do disco.'; + + @override + String get externalFileChanged => + 'Este arquivo mudou no disco enquanto você tem alterações não salvas.'; + + @override + String get compare => 'Comparar'; + + @override + String get reloadFromDisk => 'Recarregar do disco'; + + @override + String get keepMine => 'Manter minha versão'; + + @override + String get saveAs => 'Salvar como'; + @override String get sourceSearchInvalidRegex => 'Expressão regular inválida'; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index f702f730..3df638ac 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -220,6 +220,15 @@ class AppLocalizationsRu extends AppLocalizations { @override String get keyboardShortcuts => 'Сочетания клавиш'; + @override + String get commandPalette => 'Палитра команд'; + + @override + String get commandPaletteHint => 'Введите команду'; + + @override + String get commandPaletteEmpty => 'Нет подходящих команд'; + @override String get lightTheme => 'Светлая'; @@ -898,6 +907,18 @@ class AppLocalizationsRu extends AppLocalizations { @override String get deleteColumn => 'Удалить столбец'; + @override + String get tableAlignmentUnspecified => 'Выравнивание: не задано'; + + @override + String get tableAlignmentLeft => 'Выравнивание: по левому краю'; + + @override + String get tableAlignmentCenter => 'Выравнивание: по центру'; + + @override + String get tableAlignmentRight => 'Выравнивание: по правому краю'; + @override String tableRowNumber(int rowNumber) { return 'Строка $rowNumber'; @@ -1333,6 +1354,109 @@ class AppLocalizationsRu extends AppLocalizations { @override String get sourceSearchRegex => 'Регулярное выражение'; + @override + String get sourceSearchReplacement => 'Заменить на'; + + @override + String get sourceSearchReplaceCurrent => 'Заменить текущее'; + + @override + String get sourceSearchReplaceAndFindNext => 'Заменить и найти следующее'; + + @override + String get sourceSearchReplaceAll => 'Заменить всё'; + + @override + String get workspaceReplace => 'Заменить в рабочей области'; + + @override + String get reviewReplacements => 'Проверить замены'; + + @override + String get applyReplacements => 'Применить замены'; + + @override + String get skippedFiles => 'Пропущенные файлы'; + + @override + String get workspaceReplaceDirtyBuffer => + 'Несохранённое содержимое редактора'; + + @override + String get workspaceReplaceDiskContent => 'Содержимое, сохранённое на диске'; + + @override + String selectFileMatches(int count) { + return 'Выбрать все совпадения: $count'; + } + + @override + String workspaceReplaceApplied(int matches, int files, int skipped) { + return 'Заменено совпадений: $matches в файлах: $files; пропущено: $skipped.'; + } + + @override + String get normalizeLineEndings => 'Нормализовать окончания строк'; + + @override + String get mixedLineEndingsSavePrompt => + 'В документе используются смешанные окончания строк. Выберите формат.'; + + @override + String workspaceReplaceMixedLineEndings(String fileName) { + return 'В $fileName используются смешанные окончания строк. Выберите формат перед заменой.'; + } + + @override + String get workspaceReplaceIssueOversized => 'Слишком большой файл пропущен.'; + + @override + String get workspaceReplaceIssueUnreadable => 'Нечитаемый файл пропущен.'; + + @override + String get workspaceReplaceIssueInvalidUtf8 => + 'Файл с недопустимой кодировкой UTF-8 пропущен.'; + + @override + String get workspaceReplaceIssueTruncated => + 'Предпросмотр замен был сокращён.'; + + @override + String get workspaceReplaceIssueFileChanged => + 'Файл, изменённый после предпросмотра, пропущен.'; + + @override + String get workspaceReplaceIssueBufferChanged => + 'Буфер редактора, изменённый после предпросмотра, пропущен.'; + + @override + String get workspaceReplaceIssueNormalizationRequired => + 'Перед заменой выберите нормализацию LF или CRLF.'; + + @override + String externalChangesTitle(String fileName) { + return 'Внешние изменения — $fileName'; + } + + @override + String get externalFileDeleted => 'Этот файл был удалён с диска.'; + + @override + String get externalFileChanged => + 'Этот файл изменился на диске, пока у вас были несохранённые изменения.'; + + @override + String get compare => 'Сравнить'; + + @override + String get reloadFromDisk => 'Перезагрузить с диска'; + + @override + String get keepMine => 'Оставить мою версию'; + + @override + String get saveAs => 'Сохранить как'; + @override String get sourceSearchInvalidRegex => 'Некорректное регулярное выражение'; diff --git a/lib/l10n/generated/app_localizations_uk.dart b/lib/l10n/generated/app_localizations_uk.dart index e1a1b722..5120bcdc 100644 --- a/lib/l10n/generated/app_localizations_uk.dart +++ b/lib/l10n/generated/app_localizations_uk.dart @@ -219,6 +219,15 @@ class AppLocalizationsUk extends AppLocalizations { @override String get keyboardShortcuts => 'Комбінації клавіш'; + @override + String get commandPalette => 'Палітра команд'; + + @override + String get commandPaletteHint => 'Введіть команду'; + + @override + String get commandPaletteEmpty => 'Немає відповідних команд'; + @override String get lightTheme => 'Світла'; @@ -904,6 +913,18 @@ class AppLocalizationsUk extends AppLocalizations { @override String get deleteColumn => 'Видалити стовпець'; + @override + String get tableAlignmentUnspecified => 'Вирівнювання: не вказано'; + + @override + String get tableAlignmentLeft => 'Вирівнювання: ліворуч'; + + @override + String get tableAlignmentCenter => 'Вирівнювання: по центру'; + + @override + String get tableAlignmentRight => 'Вирівнювання: праворуч'; + @override String tableRowNumber(int rowNumber) { return 'Рядок $rowNumber'; @@ -1341,6 +1362,109 @@ class AppLocalizationsUk extends AppLocalizations { @override String get sourceSearchRegex => 'Регулярний вираз'; + @override + String get sourceSearchReplacement => 'Замінити на'; + + @override + String get sourceSearchReplaceCurrent => 'Замінити поточне'; + + @override + String get sourceSearchReplaceAndFindNext => 'Замінити й знайти наступне'; + + @override + String get sourceSearchReplaceAll => 'Замінити все'; + + @override + String get workspaceReplace => 'Замінити в робочій області'; + + @override + String get reviewReplacements => 'Переглянути заміни'; + + @override + String get applyReplacements => 'Застосувати заміни'; + + @override + String get skippedFiles => 'Пропущені файли'; + + @override + String get workspaceReplaceDirtyBuffer => 'Незбережений вміст редактора'; + + @override + String get workspaceReplaceDiskContent => 'Вміст, збережений на диску'; + + @override + String selectFileMatches(int count) { + return 'Вибрати всі збіги: $count'; + } + + @override + String workspaceReplaceApplied(int matches, int files, int skipped) { + return 'Замінено збігів: $matches у файлах: $files; пропущено: $skipped.'; + } + + @override + String get normalizeLineEndings => 'Нормалізувати закінчення рядків'; + + @override + String get mixedLineEndingsSavePrompt => + 'У документі використовуються змішані закінчення рядків. Виберіть формат.'; + + @override + String workspaceReplaceMixedLineEndings(String fileName) { + return 'У $fileName використовуються змішані закінчення рядків. Виберіть формат перед заміною.'; + } + + @override + String get workspaceReplaceIssueOversized => 'Завеликий файл пропущено.'; + + @override + String get workspaceReplaceIssueUnreadable => + 'Файл, який не вдалося прочитати, пропущено.'; + + @override + String get workspaceReplaceIssueInvalidUtf8 => + 'Файл із неприпустимим UTF-8 пропущено.'; + + @override + String get workspaceReplaceIssueTruncated => + 'Попередній перегляд замін було скорочено.'; + + @override + String get workspaceReplaceIssueFileChanged => + 'Файл, змінений після попереднього перегляду, пропущено.'; + + @override + String get workspaceReplaceIssueBufferChanged => + 'Буфер редактора, змінений після попереднього перегляду, пропущено.'; + + @override + String get workspaceReplaceIssueNormalizationRequired => + 'Перед заміною виберіть нормалізацію LF або CRLF.'; + + @override + String externalChangesTitle(String fileName) { + return 'Зовнішні зміни — $fileName'; + } + + @override + String get externalFileDeleted => 'Цей файл було видалено з диска.'; + + @override + String get externalFileChanged => + 'Цей файл змінився на диску, поки у вас були незбережені зміни.'; + + @override + String get compare => 'Порівняти'; + + @override + String get reloadFromDisk => 'Перезавантажити з диска'; + + @override + String get keepMine => 'Залишити мою версію'; + + @override + String get saveAs => 'Зберегти як'; + @override String get sourceSearchInvalidRegex => 'Некоректний регулярний вираз'; diff --git a/lib/src/app/busymark_app.dart b/lib/src/app/busymark_app.dart index dec330b1..8776e360 100644 --- a/lib/src/app/busymark_app.dart +++ b/lib/src/app/busymark_app.dart @@ -22,6 +22,8 @@ import 'app_router.dart'; import 'app_locale.dart'; import 'app_settings.dart'; import 'busymark_shortcuts.dart'; +import 'command_palette.dart'; +import 'command_registry.dart'; import 'app_theme.dart'; import 'busymark_dialogs.dart'; import 'busymark_design.dart'; @@ -82,59 +84,81 @@ class BusyMarkApp extends ConsumerWidget { ], supportedLocales: AppLocalizations.supportedLocales, builder: (context, child) { + final commandIntents = { + BusyMarkCommandIds.newDocument: const _NewWorkspaceIntent(), + BusyMarkCommandIds.open: const _OpenWorkspaceIntent(), + BusyMarkCommandIds.save: const _SaveActiveIntent(), + BusyMarkCommandIds.exportPdf: const _ExportPdfIntent(), + BusyMarkCommandIds.fullScreen: const _ToggleFullScreenIntent(), + BusyMarkCommandIds.back: const _BackIntent(), + BusyMarkCommandIds.search: const _OpenSearchIntent(), + BusyMarkCommandIds.keyboardShortcuts: + const _KeyboardShortcutsIntent(), + BusyMarkCommandIds.commandPalette: const _CommandPaletteIntent(), + BusyMarkCommandIds.markdownAndHtml: const _MarkdownAndHtmlIntent(), + BusyMarkCommandIds.settings: const _SettingsIntent(), + BusyMarkCommandIds.nextTab: const _NextTabIntent(), + BusyMarkCommandIds.previousTab: const _PreviousTabIntent(), + BusyMarkCommandIds.closeTab: const _CloseTabIntent(), + BusyMarkCommandIds.closeAllTabs: const _CloseAllTabsIntent(), + BusyMarkCommandIds.toggleSidebar: const _ToggleSidebarIntent(), + BusyMarkCommandIds.viewEditor: const _DocumentViewModeIntent( + DocumentViewModePreference.editor, + ), + BusyMarkCommandIds.viewSource: const _DocumentViewModeIntent( + DocumentViewModePreference.source, + ), + BusyMarkCommandIds.viewReading: const _DocumentViewModeIntent( + DocumentViewModePreference.preview, + ), + BusyMarkCommandIds.viewSplit: const _DocumentViewModeIntent( + DocumentViewModePreference.split, + ), + }; + final commandRegistry = BusyMarkCommandCatalog.create( + executions: { + for (final entry in commandIntents.entries) + entry.key: () { + final target = rootNavigatorKey.currentContext; + if (target != null) { + Actions.maybeInvoke(target, entry.value); + } + }, + }, + enabled: { + BusyMarkCommandIds.save: () => + ref.read(workspaceControllerProvider).workspace != null, + BusyMarkCommandIds.exportPdf: () => + canExportWorkspacePdf(ref.read(workspaceControllerProvider)), + BusyMarkCommandIds.search: () => + ref.read(workspaceControllerProvider).workspace != null, + }, + ); final headerBarDefaults = _nativeHeaderBarDefaults( context, settings, + commandRegistry, fullScreen: windowControls.isFullScreen, ); return HeaderBarConfigurationDefaults( configuration: headerBarDefaults, child: _BusyMarkWindowLifecycle( child: Shortcuts( - shortcuts: { - BusyMarkAppShortcutActivators.newDocument: - const _NewWorkspaceIntent(), - BusyMarkAppShortcutActivators.open: - const _OpenWorkspaceIntent(), - BusyMarkAppShortcutActivators.save: const _SaveActiveIntent(), - BusyMarkAppShortcutActivators.exportPdf: - const _ExportPdfIntent(), - BusyMarkAppShortcutActivators.fullScreen: - const _ToggleFullScreenIntent(), - BusyMarkAppShortcutActivators.back: const _BackIntent(), - BusyMarkAppShortcutActivators.keyboardShortcuts: - const _KeyboardShortcutsIntent(), - BusyMarkAppShortcutActivators.settings: const _SettingsIntent(), - BusyMarkAppShortcutActivators.markdownAndHtml: - const _MarkdownAndHtmlIntent(), - BusyMarkAppShortcutActivators.nextTab: const _NextTabIntent(), - BusyMarkAppShortcutActivators.previousTab: - const _PreviousTabIntent(), - BusyMarkAppShortcutActivators.closeTab: const _CloseTabIntent(), - BusyMarkAppShortcutActivators.closeAllTabs: - const _CloseAllTabsIntent(), - BusyMarkAppShortcutActivators.search: const _OpenSearchIntent(), - BusyMarkAppShortcutActivators.toggleSidebar: - const _ToggleSidebarIntent(), - BusyMarkDocumentViewShortcutActivators.editor: - const _DocumentViewModeIntent( - DocumentViewModePreference.editor, - ), - BusyMarkDocumentViewShortcutActivators.source: - const _DocumentViewModeIntent( - DocumentViewModePreference.source, - ), - BusyMarkDocumentViewShortcutActivators.reading: - const _DocumentViewModeIntent( - DocumentViewModePreference.preview, - ), - BusyMarkDocumentViewShortcutActivators.split: - const _DocumentViewModeIntent( - DocumentViewModePreference.split, - ), - }, + shortcuts: commandRegistry.shortcutIntents( + scopes: const { + BusyMarkCommandScope.application, + BusyMarkCommandScope.documentView, + }, + intentFor: BusyMarkCommandIntent.new, + ), child: Actions( actions: { + BusyMarkCommandIntent: CallbackAction( + onInvoke: (intent) { + unawaited(commandRegistry.execute(intent.commandId)); + return null; + }, + ), _NewWorkspaceIntent: CallbackAction<_NewWorkspaceIntent>( onInvoke: (intent) { final navigatorContext = rootNavigatorKey.currentContext; @@ -209,6 +233,20 @@ class BusyMarkApp extends ConsumerWidget { return null; }, ), + _CommandPaletteIntent: CallbackAction<_CommandPaletteIntent>( + onInvoke: (intent) { + final navigatorContext = rootNavigatorKey.currentContext; + if (navigatorContext != null) { + unawaited( + showBusyMarkCommandPalette( + navigatorContext, + commandRegistry, + ), + ); + } + return null; + }, + ), _SettingsIntent: CallbackAction<_SettingsIntent>( onInvoke: (intent) { final navigatorContext = rootNavigatorKey.currentContext; @@ -309,6 +347,9 @@ class BusyMarkApp extends ConsumerWidget { _DocumentViewModeIntent: CallbackAction<_DocumentViewModeIntent>( onInvoke: (intent) { + ref + .read(workspaceControllerProvider.notifier) + .updateActiveEditorMode(intent.mode); unawaited( ref .read(appSettingsControllerProvider.notifier) @@ -487,9 +528,6 @@ class BusyMarkApp extends ConsumerWidget { if (choice == null || !context.mounted) { return; } - if (!await confirmSafeToContinue(context, ref) || !context.mounted) { - return; - } switch (choice) { case _CreateMarkdownFile(): await ref @@ -499,6 +537,9 @@ class BusyMarkApp extends ConsumerWidget { router.go('/workspace'); } case _CreateWritersideProject(): + if (!await confirmSafeToContinue(context, ref) || !context.mounted) { + return; + } await _createWritersideProject(context, ref, router); } } @@ -552,19 +593,21 @@ class BusyMarkApp extends ConsumerWidget { WidgetRef ref, { required bool next, }) async { - final workspace = ref.read(workspaceControllerProvider).workspace; + final workspaceState = ref.read(workspaceControllerProvider); + final workspace = workspaceState.workspace; final gitState = ref.read(gitControllerProvider); if (workspace == null) { return; } - final tabs = workspaceTabEntries(workspace: workspace, gitState: gitState); + final tabs = workspaceTabEntries( + workspace: workspace, + gitState: gitState, + documentBuffers: workspaceState.documentBuffers, + activeBufferId: workspaceState.activeBufferId, + ); if (tabs.length < 2) { return; } - if (!await saveOrConfirmSafeToChangeActiveFile(context, ref) || - !context.mounted) { - return; - } final activeIndex = activeWorkspaceTabIndex(tabs); final nextIndex = activeIndex < 0 ? 0 @@ -577,20 +620,26 @@ class BusyMarkApp extends ConsumerWidget { BuildContext context, WidgetRef ref, ) async { - final workspace = ref.read(workspaceControllerProvider).workspace; + final workspaceState = ref.read(workspaceControllerProvider); + final workspace = workspaceState.workspace; final gitState = ref.read(gitControllerProvider); if (workspace == null) { return; } - final tabs = workspaceTabEntries(workspace: workspace, gitState: gitState); + final tabs = workspaceTabEntries( + workspace: workspace, + gitState: gitState, + documentBuffers: workspaceState.documentBuffers, + activeBufferId: workspaceState.activeBufferId, + ); final activeIndex = activeWorkspaceTabIndex(tabs); if (activeIndex < 0) { return; } final activeTab = tabs[activeIndex]; if (activeTab.kind == WorkspaceTabKind.file) { - if (!await saveOrConfirmSafeToChangeActiveFile(context, ref) || - !context.mounted) { + if (activeTab.dirty && + (!await confirmSafeToContinue(context, ref) || !context.mounted)) { return; } } @@ -601,10 +650,16 @@ class BusyMarkApp extends ConsumerWidget { BuildContext context, WidgetRef ref, ) async { - final workspace = ref.read(workspaceControllerProvider).workspace; + final workspaceState = ref.read(workspaceControllerProvider); + final workspace = workspaceState.workspace; final gitState = ref.read(gitControllerProvider); if (workspace == null || - workspaceTabEntries(workspace: workspace, gitState: gitState).isEmpty) { + workspaceTabEntries( + workspace: workspace, + gitState: gitState, + documentBuffers: workspaceState.documentBuffers, + activeBufferId: workspaceState.activeBufferId, + ).isEmpty) { return; } if (!await saveOrConfirmSafeToChangeActiveFile(context, ref) || @@ -624,7 +679,7 @@ class BusyMarkApp extends ConsumerWidget { case WorkspaceTabKind.file: await ref .read(workspaceControllerProvider.notifier) - .openActiveFile(tab.path); + .activateDocumentBuffer(tab.bufferId!); gitController.deactivateDiffFile(); case WorkspaceTabKind.gitDiff: if (tab.path.isEmpty) { @@ -640,7 +695,7 @@ class BusyMarkApp extends ConsumerWidget { case WorkspaceTabKind.file: await ref .read(workspaceControllerProvider.notifier) - .closeOpenFileTab(tab.path); + .closeDocumentBuffer(tab.bufferId!); gitController.deactivateDiffFile(); case WorkspaceTabKind.gitDiff: if (tab.path.isEmpty) { @@ -718,54 +773,60 @@ class BusyMarkApp extends ConsumerWidget { HeaderBarConfiguration _nativeHeaderBarDefaults( BuildContext context, - AppSettings settings, { + AppSettings settings, + BusyMarkCommandRegistry commandRegistry, { required bool fullScreen, }) { final material = MaterialLocalizations.of(context); final l10n = context.l10n; final theme = HeaderBarTheme.fromContext(context); final textDirection = Directionality.maybeOf(context) ?? TextDirection.ltr; + BusyMarkCommand command(String id) => commandRegistry[id]!; + String label(String id) => command(id).label(context); + String shortcut(String id) => command(id).shortcut!.label; + String accelerator(String id) => command(id).shortcut!.gtkAccelerator!; final labels = HeaderBarLabels( - editor: l10n.editor, - source: l10n.source, - preview: l10n.reading, - split: l10n.split, + editor: label(BusyMarkCommandIds.viewEditor), + source: label(BusyMarkCommandIds.viewSource), + preview: label(BusyMarkCommandIds.viewReading), + split: label(BusyMarkCommandIds.viewSplit), viewMode: l10n.viewMode, - editorShortcut: BusyMarkDocumentViewShortcutLabels.editor, - editorGtkAccelerator: BusyMarkDocumentViewShortcutGtkAccelerators.editor, - sourceShortcut: BusyMarkDocumentViewShortcutLabels.source, - sourceGtkAccelerator: BusyMarkDocumentViewShortcutGtkAccelerators.source, - previewShortcut: BusyMarkDocumentViewShortcutLabels.reading, - previewGtkAccelerator: - BusyMarkDocumentViewShortcutGtkAccelerators.reading, - splitShortcut: BusyMarkDocumentViewShortcutLabels.split, - splitGtkAccelerator: BusyMarkDocumentViewShortcutGtkAccelerators.split, + editorShortcut: shortcut(BusyMarkCommandIds.viewEditor), + editorGtkAccelerator: accelerator(BusyMarkCommandIds.viewEditor), + sourceShortcut: shortcut(BusyMarkCommandIds.viewSource), + sourceGtkAccelerator: accelerator(BusyMarkCommandIds.viewSource), + previewShortcut: shortcut(BusyMarkCommandIds.viewReading), + previewGtkAccelerator: accelerator(BusyMarkCommandIds.viewReading), + splitShortcut: shortcut(BusyMarkCommandIds.viewSplit), + splitGtkAccelerator: accelerator(BusyMarkCommandIds.viewSplit), search: material.searchFieldLabel, - searchShortcut: BusyMarkAppShortcutLabels.search, + searchShortcut: shortcut(BusyMarkCommandIds.search), refresh: l10n.validate, menu: l10n.mainMenu, sidebar: settings.sidebarVisible ? l10n.hideSidebar : l10n.showSidebar, - sidebarShortcut: BusyMarkSidebarShortcutLabels.toggleSidebar, + sidebarShortcut: shortcut(BusyMarkCommandIds.toggleSidebar), back: material.backButtonTooltip, - backShortcut: BusyMarkAppShortcutLabels.back, - save: l10n.save, - exportPdf: l10n.exportAsPdf, - exportPdfShortcut: BusyMarkAppShortcutLabels.exportPdf, - exportPdfGtkAccelerator: BusyMarkAppShortcutGtkAccelerators.exportPdf, - fullScreen: l10n.fullScreen, - fullScreenShortcut: BusyMarkAppShortcutLabels.fullScreen, - fullScreenGtkAccelerator: BusyMarkAppShortcutGtkAccelerators.fullScreen, - settings: l10n.settings, - settingsShortcut: BusyMarkAppShortcutLabels.settings, - settingsGtkAccelerator: BusyMarkAppShortcutGtkAccelerators.settings, - keyboardShortcuts: l10n.keyboardShortcuts, - keyboardShortcutsShortcut: BusyMarkAppShortcutLabels.keyboardShortcuts, - keyboardShortcutsGtkAccelerator: - BusyMarkAppShortcutGtkAccelerators.keyboardShortcuts, - markdownAndHtml: l10n.markdownAndHtml, - markdownAndHtmlShortcut: BusyMarkAppShortcutLabels.markdownAndHtml, - markdownAndHtmlGtkAccelerator: - BusyMarkAppShortcutGtkAccelerators.markdownAndHtml, + backShortcut: shortcut(BusyMarkCommandIds.back), + save: label(BusyMarkCommandIds.save), + exportPdf: label(BusyMarkCommandIds.exportPdf), + exportPdfShortcut: shortcut(BusyMarkCommandIds.exportPdf), + exportPdfGtkAccelerator: accelerator(BusyMarkCommandIds.exportPdf), + fullScreen: label(BusyMarkCommandIds.fullScreen), + fullScreenShortcut: shortcut(BusyMarkCommandIds.fullScreen), + fullScreenGtkAccelerator: accelerator(BusyMarkCommandIds.fullScreen), + settings: label(BusyMarkCommandIds.settings), + settingsShortcut: shortcut(BusyMarkCommandIds.settings), + settingsGtkAccelerator: accelerator(BusyMarkCommandIds.settings), + keyboardShortcuts: label(BusyMarkCommandIds.keyboardShortcuts), + keyboardShortcutsShortcut: shortcut(BusyMarkCommandIds.keyboardShortcuts), + keyboardShortcutsGtkAccelerator: accelerator( + BusyMarkCommandIds.keyboardShortcuts, + ), + markdownAndHtml: label(BusyMarkCommandIds.markdownAndHtml), + markdownAndHtmlShortcut: shortcut(BusyMarkCommandIds.markdownAndHtml), + markdownAndHtmlGtkAccelerator: accelerator( + BusyMarkCommandIds.markdownAndHtml, + ), reportIssue: l10n.reportIssue, aboutBusyMark: l10n.aboutBusyMark, ); @@ -836,22 +897,41 @@ class _BusyMarkWindowLifecycleState final settings = ref.read(appSettingsControllerProvider); var workspace = ref.read(workspaceControllerProvider); if (settings.autoSave && workspace.hasUnsavedChanges) { - await ref - .read(workspaceControllerProvider.notifier) - .autoSaveActiveIfNeeded(); + await ref.read(workspaceControllerProvider.notifier).saveAll(); workspace = ref.read(workspaceControllerProvider); } + final controller = ref.read(workspaceControllerProvider.notifier); await _windowControlService.handleCloseRequest( hasUnsavedChanges: workspace.hasUnsavedChanges, confirmCloseWithUnsavedChanges: settings.confirmCloseWithUnsavedChanges, showCloseDialog: () => _showWindowCloseDialog(context), - saveChanges: () => saveActiveWithOverwriteConfirmation(context, ref), + saveChanges: () => _saveAllDirtyDocuments(context), + beforeClose: controller.markCleanShutdown, + discardChanges: controller.discardRecoveryForShutdown, ); } + Future _saveAllDirtyDocuments(BuildContext context) async { + final controller = ref.read(workspaceControllerProvider.notifier); + final dirtyIds = [ + for (final buffer + in ref.read(workspaceControllerProvider).documentBuffers) + if (buffer.isDirty) buffer.id, + ]; + for (final bufferId in dirtyIds) { + if (!await controller.activateDocumentBuffer(bufferId) || + !context.mounted || + !await saveActiveWithOverwriteConfirmation(context, ref)) { + return false; + } + } + return !ref.read(workspaceControllerProvider).hasUnsavedChanges; + } + Future _showWindowCloseDialog(BuildContext context) { final l10n = AppLocalizations.of(context); final headerBar = ref.read(linuxHeaderBarServiceProvider); + final dirtyBuffers = ref.read(workspaceControllerProvider).dirtyBuffers; return showBusyMarkModalDialog( context, headerBarService: headerBar.isAvailable ? headerBar : null, @@ -877,7 +957,21 @@ class _BusyMarkWindowLifecycleState onPressed: () => Navigator.pop(context, WindowCloseAction.save), ), ], - children: [Text(_closeUnsavedChangesMessage(context))], + children: [ + Text(_closeUnsavedChangesMessage(context)), + const SizedBox(height: BusyMarkSpacing.md), + BusyMarkGroupedList( + filled: true, + children: [ + for (final buffer in dirtyBuffers) + BusyMarkActionRow( + title: buffer.displayName, + subtitle: buffer.filePath, + leading: const Icon(BusyMarkGlyphs.document), + ), + ], + ), + ], ), ); } @@ -885,8 +979,9 @@ class _BusyMarkWindowLifecycleState String _closeUnsavedChangesMessage(BuildContext context) { final l10n = AppLocalizations.of(context); final state = ref.read(workspaceControllerProvider); - if (!state.hasUnsavedChanges) { - return l10n.closeUnsavedChangesMultipleMessage(0); + final count = state.dirtyBuffers.length; + if (count != 1) { + return l10n.closeUnsavedChangesMultipleMessage(count); } return l10n.closeUnsavedChangesSingleMessage; } @@ -1005,6 +1100,10 @@ class _KeyboardShortcutsIntent extends Intent { const _KeyboardShortcutsIntent(); } +class _CommandPaletteIntent extends Intent { + const _CommandPaletteIntent(); +} + class _SettingsIntent extends Intent { const _SettingsIntent(); } diff --git a/lib/src/app/busymark_dialogs.dart b/lib/src/app/busymark_dialogs.dart index c5fbe137..7b49d80c 100644 --- a/lib/src/app/busymark_dialogs.dart +++ b/lib/src/app/busymark_dialogs.dart @@ -9,6 +9,7 @@ import '../platform/linux_header_bar_service.dart'; import 'app_metadata.dart'; import 'busymark_dialog_identity.dart'; import 'busymark_shortcuts.dart'; +import 'command_registry.dart'; import 'busymark_design.dart'; import 'busymark_glyphs.dart'; import 'localization.dart'; @@ -367,6 +368,51 @@ Future _openApacheLicense() async { } void showBusyMarkKeyboardShortcutsDialog(BuildContext context) { + final registry = BusyMarkCommandCatalog.create(); + final headerBar = LinuxHeaderBarService.instance; + unawaited( + showBusyMarkModalDialog( + context, + headerBarService: headerBar.isAvailable ? headerBar : null, + builder: (context) { + final grouped = >{}; + final shortcutLabels = {}; + for (final command in registry.commands) { + final shortcut = command.shortcut; + if (shortcut == null || !shortcutLabels.add(shortcut.label)) { + continue; + } + grouped.putIfAbsent(command.category(context), () => []).add(command); + } + return _BusyMarkInfoDialog( + title: context.l10n.keyboardShortcuts, + icon: BusyMarkGlyphs.keyboard, + maxWidth: 460, + children: [ + for (final entry in grouped.entries) + BusyMarkGroupedList( + title: entry.key, + filled: true, + children: [ + for (final command in entry.value) + BusyMarkActionRow( + title: command.label(context), + subtitle: command.description?.call(context), + leading: const Icon(BusyMarkGlyphs.keyboard), + trailing: _KeyboardShortcutBadge(command.shortcut!.label), + ), + ], + ), + ], + ); + }, + ), + ); +} + +// Retained temporarily as a layout reference while every shortcut consumer is +// migrated to the registry-backed presentation above. +void showLegacyBusyMarkKeyboardShortcutsDialog(BuildContext context) { final headerBar = LinuxHeaderBarService.instance; unawaited( showBusyMarkModalDialog( diff --git a/lib/src/app/busymark_main_menu.dart b/lib/src/app/busymark_main_menu.dart index bf20c766..46aae2c5 100644 --- a/lib/src/app/busymark_main_menu.dart +++ b/lib/src/app/busymark_main_menu.dart @@ -3,7 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'busymark_design.dart'; import 'busymark_glyphs.dart'; -import 'busymark_shortcuts.dart'; +import 'command_registry.dart'; import 'localization.dart'; import 'window_control_service.dart'; @@ -13,6 +13,7 @@ enum BusyMarkMainMenuAction { fullScreen, settings, keyboardShortcuts, + commandPalette, markdownAndHtml, reportIssue, aboutBusyMark, @@ -33,6 +34,8 @@ class BusyMarkMainMenuButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final l10n = context.l10n; + final commands = BusyMarkCommandCatalog.create(); + BusyMarkCommand command(String id) => commands[id]!; final fullScreen = ref.watch( windowControlServiceProvider.select((service) => service.isFullScreen), ); @@ -42,9 +45,9 @@ class BusyMarkMainMenuButton extends ConsumerWidget { itemBuilder: (context) => [ BusyMarkPopupMenuItem( value: BusyMarkMainMenuAction.exportPdf, - label: l10n.exportAsPdf, + label: command(BusyMarkCommandIds.exportPdf).label(context), icon: BusyMarkGlyphs.exportPdf, - shortcut: BusyMarkAppShortcutLabels.exportPdf, + shortcut: command(BusyMarkCommandIds.exportPdf).shortcut?.label, enabled: canExportPdf, ), BusyMarkPopupMenuItem( @@ -55,30 +58,38 @@ class BusyMarkMainMenuButton extends ConsumerWidget { ), BusyMarkPopupMenuItem( value: BusyMarkMainMenuAction.fullScreen, - label: l10n.fullScreen, + label: command(BusyMarkCommandIds.fullScreen).label(context), icon: BusyMarkGlyphs.fullScreen, - shortcut: BusyMarkAppShortcutLabels.fullScreen, + shortcut: command(BusyMarkCommandIds.fullScreen).shortcut?.label, checked: fullScreen, trailingCheck: true, mutuallyExclusive: false, ), BusyMarkPopupMenuItem( value: BusyMarkMainMenuAction.settings, - label: l10n.settings, + label: command(BusyMarkCommandIds.settings).label(context), icon: BusyMarkGlyphs.settings, - shortcut: BusyMarkAppShortcutLabels.settings, + shortcut: command(BusyMarkCommandIds.settings).shortcut?.label, ), BusyMarkPopupMenuItem( value: BusyMarkMainMenuAction.keyboardShortcuts, - label: l10n.keyboardShortcuts, + label: command(BusyMarkCommandIds.keyboardShortcuts).label(context), icon: BusyMarkGlyphs.keyboard, - shortcut: BusyMarkAppShortcutLabels.keyboardShortcuts, + shortcut: command( + BusyMarkCommandIds.keyboardShortcuts, + ).shortcut?.label, + ), + BusyMarkPopupMenuItem( + value: BusyMarkMainMenuAction.commandPalette, + label: command(BusyMarkCommandIds.commandPalette).label(context), + icon: BusyMarkGlyphs.search, + shortcut: command(BusyMarkCommandIds.commandPalette).shortcut?.label, ), BusyMarkPopupMenuItem( value: BusyMarkMainMenuAction.markdownAndHtml, - label: l10n.markdownAndHtml, + label: command(BusyMarkCommandIds.markdownAndHtml).label(context), icon: BusyMarkGlyphs.markdownFile, - shortcut: BusyMarkAppShortcutLabels.markdownAndHtml, + shortcut: command(BusyMarkCommandIds.markdownAndHtml).shortcut?.label, ), BusyMarkPopupMenuItem( value: BusyMarkMainMenuAction.reportIssue, @@ -91,7 +102,16 @@ class BusyMarkMainMenuButton extends ConsumerWidget { icon: BusyMarkGlyphs.info, ), ], - onSelected: onSelected, + onSelected: (action) { + if (action == BusyMarkMainMenuAction.commandPalette) { + Actions.maybeInvoke( + context, + const BusyMarkCommandIntent(BusyMarkCommandIds.commandPalette), + ); + return; + } + onSelected(action); + }, ); } } diff --git a/lib/src/app/busymark_shortcuts.dart b/lib/src/app/busymark_shortcuts.dart index 28506c77..586021f2 100644 --- a/lib/src/app/busymark_shortcuts.dart +++ b/lib/src/app/busymark_shortcuts.dart @@ -27,6 +27,7 @@ enum BusyMarkAppShortcutAction { back, search, keyboardShortcuts, + commandPalette, markdownAndHtml, settings, nextTab, @@ -47,6 +48,7 @@ abstract final class BusyMarkAppShortcuts { static const backLabel = 'Alt+Left'; static const searchLabel = 'Ctrl+F'; static const keyboardShortcutsLabel = 'Ctrl+Alt+K'; + static const commandPaletteLabel = 'Ctrl+Shift+P'; static const markdownAndHtmlLabel = 'Ctrl+Alt+M'; static const settingsLabel = 'Ctrl+Alt+S'; static const nextTabLabel = 'Ctrl+Tab'; @@ -63,6 +65,7 @@ abstract final class BusyMarkAppShortcuts { static const backGtkAccelerator = 'Left'; static const searchGtkAccelerator = 'f'; static const keyboardShortcutsGtkAccelerator = 'k'; + static const commandPaletteGtkAccelerator = 'p'; static const markdownAndHtmlGtkAccelerator = 'm'; static const settingsGtkAccelerator = 's'; static const nextTabGtkAccelerator = 'Tab'; @@ -123,6 +126,15 @@ abstract final class BusyMarkAppShortcuts { ), gtkAccelerator: keyboardShortcutsGtkAccelerator, ); + static const commandPalette = BusyMarkShortcutDefinition( + label: commandPaletteLabel, + activator: SingleActivator( + LogicalKeyboardKey.keyP, + control: true, + shift: true, + ), + gtkAccelerator: commandPaletteGtkAccelerator, + ); static const markdownAndHtml = BusyMarkShortcutDefinition( label: markdownAndHtmlLabel, activator: SingleActivator( @@ -185,6 +197,7 @@ abstract final class BusyMarkAppShortcuts { BusyMarkAppShortcutAction.back: back, BusyMarkAppShortcutAction.search: search, BusyMarkAppShortcutAction.keyboardShortcuts: keyboardShortcuts, + BusyMarkAppShortcutAction.commandPalette: commandPalette, BusyMarkAppShortcutAction.markdownAndHtml: markdownAndHtml, BusyMarkAppShortcutAction.settings: settings, BusyMarkAppShortcutAction.nextTab: nextTab, @@ -206,6 +219,7 @@ abstract final class BusyMarkAppShortcutLabels { static const back = BusyMarkAppShortcuts.backLabel; static const search = BusyMarkAppShortcuts.searchLabel; static const keyboardShortcuts = BusyMarkAppShortcuts.keyboardShortcutsLabel; + static const commandPalette = BusyMarkAppShortcuts.commandPaletteLabel; static const markdownAndHtml = BusyMarkAppShortcuts.markdownAndHtmlLabel; static const settings = BusyMarkAppShortcuts.settingsLabel; static const nextTab = BusyMarkAppShortcuts.nextTabLabel; @@ -229,6 +243,8 @@ abstract final class BusyMarkAppShortcutActivators { static ShortcutActivator get search => BusyMarkAppShortcuts.search.activator; static ShortcutActivator get keyboardShortcuts => BusyMarkAppShortcuts.keyboardShortcuts.activator; + static ShortcutActivator get commandPalette => + BusyMarkAppShortcuts.commandPalette.activator; static ShortcutActivator get markdownAndHtml => BusyMarkAppShortcuts.markdownAndHtml.activator; static ShortcutActivator get settings => diff --git a/lib/src/app/command_palette.dart b/lib/src/app/command_palette.dart new file mode 100644 index 00000000..1b7ef3a6 --- /dev/null +++ b/lib/src/app/command_palette.dart @@ -0,0 +1,110 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../platform/linux_header_bar_service.dart'; +import 'busymark_design.dart'; +import 'busymark_dialogs.dart'; +import 'busymark_glyphs.dart'; +import 'command_registry.dart'; +import 'localization.dart'; + +Future showBusyMarkCommandPalette( + BuildContext context, + BusyMarkCommandRegistry registry, +) async { + final headerBar = LinuxHeaderBarService.instance; + await showBusyMarkModalDialog( + context, + headerBarService: headerBar.isAvailable ? headerBar : null, + builder: (context) => _BusyMarkCommandPalette(registry: registry), + ); +} + +class _BusyMarkCommandPalette extends StatefulWidget { + const _BusyMarkCommandPalette({required this.registry}); + + final BusyMarkCommandRegistry registry; + + @override + State<_BusyMarkCommandPalette> createState() => + _BusyMarkCommandPaletteState(); +} + +class _BusyMarkCommandPaletteState extends State<_BusyMarkCommandPalette> { + var _query = ''; + + List get _commands { + final query = _query.trim().toLowerCase(); + return widget.registry.visibleCommands().where((command) { + if (command.execute == null) { + return false; + } + if (query.isEmpty) { + return true; + } + return command.label(context).toLowerCase().contains(query) || + command.category(context).toLowerCase().contains(query) || + command.id.toLowerCase().contains(query); + }).toList(); + } + + @override + Widget build(BuildContext context) { + final commands = _commands; + return BusyMarkDialogShell( + title: context.l10n.commandPalette, + maxWidth: BusyMarkSizes.dialogCompact, + children: [ + BusyMarkGroupedList( + filled: true, + children: [ + BusyMarkGroupedTextEntry( + label: context.l10n.commandPaletteHint, + autofocus: true, + onChanged: (value) => setState(() => _query = value), + onSubmitted: (_) { + if (commands.isNotEmpty && commands.first.canExecute) { + _execute(commands.first); + } + }, + ), + ], + ), + const SizedBox(height: BusyMarkSpacing.md), + if (commands.isEmpty) + Padding( + padding: const EdgeInsets.all(BusyMarkSpacing.lg), + child: Text( + context.l10n.commandPaletteEmpty, + textAlign: TextAlign.center, + ), + ) + else + BusyMarkGroupedList( + filled: true, + children: [ + for (final command in commands) + BusyMarkActionRow( + title: command.label(context), + subtitle: command.category(context), + leading: const Icon(BusyMarkGlyphs.search), + trailing: command.shortcut == null + ? null + : Text(command.shortcut!.label), + enabled: command.enabled(), + onTap: command.enabled() ? () => _execute(command) : null, + ), + ], + ), + ], + ); + } + + void _execute(BusyMarkCommand command) { + Navigator.pop(context); + unawaited( + Future.microtask(() => widget.registry.execute(command.id)), + ); + } +} diff --git a/lib/src/app/command_registry.dart b/lib/src/app/command_registry.dart new file mode 100644 index 00000000..f613faed --- /dev/null +++ b/lib/src/app/command_registry.dart @@ -0,0 +1,505 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'busymark_shortcuts.dart'; +import 'localization.dart'; + +typedef BusyMarkCommandCallback = FutureOr Function(); +typedef BusyMarkCommandPredicate = bool Function(); +typedef BusyMarkCommandLabel = String Function(BuildContext context); +typedef BusyMarkCommandDescription = String? Function(BuildContext context); + +class BusyMarkCommandIntent extends Intent { + const BusyMarkCommandIntent(this.commandId); + + final String commandId; +} + +enum BusyMarkCommandScope { + application, + documentView, + textEditing, + editor, + sidebar, + tree, +} + +@immutable +class BusyMarkCommand { + const BusyMarkCommand({ + required this.id, + required this.label, + required this.category, + required this.scope, + this.shortcut, + this.description, + this.execute, + this.enabled = _always, + this.visible = _always, + }); + + final String id; + final BusyMarkCommandLabel label; + final BusyMarkCommandLabel category; + final BusyMarkCommandScope scope; + final BusyMarkShortcutDefinition? shortcut; + final BusyMarkCommandDescription? description; + final BusyMarkCommandCallback? execute; + final BusyMarkCommandPredicate enabled; + final BusyMarkCommandPredicate visible; + + bool get canExecute => execute != null && enabled(); + + static bool _always() => true; +} + +class BusyMarkCommandRegistryValidationException implements Exception { + const BusyMarkCommandRegistryValidationException(this.message); + + final String message; + + @override + String toString() => message; +} + +class BusyMarkCommandRegistry { + BusyMarkCommandRegistry(Iterable commands) + : commands = List.unmodifiable(commands) { + _validate(this.commands); + _byId = {for (final command in this.commands) command.id: command}; + } + + final List commands; + late final Map _byId; + + BusyMarkCommand? operator [](String id) => _byId[id]; + + List visibleCommands() => [ + for (final command in commands) + if (command.visible()) command, + ]; + + Future execute(String id) async { + final command = _byId[id]; + if (command == null || !command.canExecute) { + return false; + } + await command.execute!(); + return true; + } + + Map shortcutIntents({ + required Set scopes, + required Intent Function(String commandId) intentFor, + }) { + return { + for (final command in commands) + if (scopes.contains(command.scope) && command.shortcut != null) + command.shortcut!.activator: intentFor(command.id), + }; + } + + static void _validate(List commands) { + final ids = {}; + final shortcuts = <(BusyMarkCommandScope, ShortcutActivator), String>{}; + for (final command in commands) { + if (!RegExp( + r'^[a-z][A-Za-z0-9]*(?:\.[a-z][A-Za-z0-9]*)+$', + ).hasMatch(command.id)) { + throw BusyMarkCommandRegistryValidationException( + 'Invalid command ID: ${command.id}', + ); + } + if (!ids.add(command.id)) { + throw BusyMarkCommandRegistryValidationException( + 'Duplicate command ID: ${command.id}', + ); + } + final shortcut = command.shortcut; + if (shortcut == null) { + continue; + } + final key = (command.scope, shortcut.activator); + if (shortcuts[key] case final existing?) { + throw BusyMarkCommandRegistryValidationException( + 'Shortcut conflict in ${command.scope.name}: $existing and ${command.id}', + ); + } + shortcuts[key] = command.id; + } + } +} + +abstract final class BusyMarkCommandIds { + static const newDocument = 'file.newDocument'; + static const open = 'file.open'; + static const save = 'file.save'; + static const exportPdf = 'file.exportPdf'; + static const fullScreen = 'view.fullScreen'; + static const back = 'navigation.back'; + static const search = 'search.find'; + static const keyboardShortcuts = 'help.keyboardShortcuts'; + static const commandPalette = 'view.commandPalette'; + static const markdownAndHtml = 'help.markdownAndHtml'; + static const settings = 'application.settings'; + static const nextTab = 'tabs.next'; + static const previousTab = 'tabs.previous'; + static const closeTab = 'tabs.close'; + static const closeAllTabs = 'tabs.closeAll'; + static const toggleSidebar = 'view.toggleSidebar'; + static const viewEditor = 'view.editor'; + static const viewSource = 'view.source'; + static const viewReading = 'view.reading'; + static const viewSplit = 'view.split'; + static const textSelectAll = 'text.selectAll'; + static const textCut = 'text.cut'; + static const textCopy = 'text.copy'; + static const textPaste = 'text.paste'; + static const editorRefineWithAi = 'editor.refineWithAi'; +} + +abstract final class BusyMarkCommandCatalog { + static BusyMarkCommandRegistry create({ + Map executions = const {}, + Map enabled = const {}, + Map visible = const {}, + }) { + BusyMarkCommand command({ + required String id, + required BusyMarkCommandLabel label, + required BusyMarkCommandLabel category, + required BusyMarkCommandScope scope, + required BusyMarkShortcutDefinition shortcut, + BusyMarkCommandDescription? description, + }) { + return BusyMarkCommand( + id: id, + label: label, + category: category, + scope: scope, + shortcut: shortcut, + description: description, + execute: executions[id], + enabled: enabled[id] ?? BusyMarkCommand._always, + visible: visible[id] ?? BusyMarkCommand._always, + ); + } + + final commands = [ + for (final entry in BusyMarkAppShortcuts.definitions.entries) + command( + id: _appId(entry.key), + label: (context) => _appLabel(context, entry.key), + category: (context) => _appCategory(context, entry.key), + scope: BusyMarkCommandScope.application, + shortcut: entry.value, + description: (context) => _appDescription(context, entry.key), + ), + for (final entry in BusyMarkDocumentViewShortcuts.definitions.entries) + command( + id: switch (entry.key) { + BusyMarkDocumentViewShortcutAction.editor => + BusyMarkCommandIds.viewEditor, + BusyMarkDocumentViewShortcutAction.source => + BusyMarkCommandIds.viewSource, + BusyMarkDocumentViewShortcutAction.reading => + BusyMarkCommandIds.viewReading, + BusyMarkDocumentViewShortcutAction.split => + BusyMarkCommandIds.viewSplit, + }, + label: (context) => _viewLabel(context, entry.key), + category: (context) => context.l10n.viewMode, + scope: BusyMarkCommandScope.documentView, + shortcut: entry.value, + ), + for (final entry in BusyMarkTextEditingShortcuts.definitions.entries) + command( + id: 'text.${entry.key.name}', + label: (context) => _textLabel(context, entry.key), + category: (context) => context.l10n.shortcutGroupTextEditing, + scope: BusyMarkCommandScope.textEditing, + shortcut: entry.value, + description: (context) => _textDescription(context, entry.key), + ), + for (final entry in BusyMarkEditorShortcuts.definitions.entries) + command( + id: 'editor.${entry.key.name}', + label: (context) => _editorLabel(context, entry.key), + category: (context) => _editorCategory(context, entry.key), + scope: BusyMarkCommandScope.editor, + shortcut: entry.value, + description: (context) => _editorDescription(context, entry.key), + ), + for (final entry in BusyMarkSidebarShortcuts.definitions.entries) + command( + id: 'sidebar.${entry.key.name}', + label: (context) => _sidebarLabel(context, entry.key), + category: (context) => context.l10n.shortcutGroupSidebar, + scope: BusyMarkCommandScope.sidebar, + shortcut: entry.value, + ), + for (final entry in BusyMarkTreeShortcuts.definitions.entries) + command( + id: 'tree.${entry.key.name}', + label: (context) => context.l10n.delete, + category: (context) => context.l10n.shortcutGroupSidebar, + scope: BusyMarkCommandScope.tree, + shortcut: entry.value, + description: (context) => + context.l10n.shortcutDeleteTreeItemDescription, + ), + ]; + return BusyMarkCommandRegistry(commands); + } + + static String _appId(BusyMarkAppShortcutAction action) => switch (action) { + BusyMarkAppShortcutAction.newDocument => BusyMarkCommandIds.newDocument, + BusyMarkAppShortcutAction.open => BusyMarkCommandIds.open, + BusyMarkAppShortcutAction.save => BusyMarkCommandIds.save, + BusyMarkAppShortcutAction.exportPdf => BusyMarkCommandIds.exportPdf, + BusyMarkAppShortcutAction.fullScreen => BusyMarkCommandIds.fullScreen, + BusyMarkAppShortcutAction.back => BusyMarkCommandIds.back, + BusyMarkAppShortcutAction.search => BusyMarkCommandIds.search, + BusyMarkAppShortcutAction.keyboardShortcuts => + BusyMarkCommandIds.keyboardShortcuts, + BusyMarkAppShortcutAction.commandPalette => + BusyMarkCommandIds.commandPalette, + BusyMarkAppShortcutAction.markdownAndHtml => + BusyMarkCommandIds.markdownAndHtml, + BusyMarkAppShortcutAction.settings => BusyMarkCommandIds.settings, + BusyMarkAppShortcutAction.nextTab => BusyMarkCommandIds.nextTab, + BusyMarkAppShortcutAction.previousTab => BusyMarkCommandIds.previousTab, + BusyMarkAppShortcutAction.closeTab => BusyMarkCommandIds.closeTab, + BusyMarkAppShortcutAction.closeAllTabs => BusyMarkCommandIds.closeAllTabs, + BusyMarkAppShortcutAction.toggleSidebar => BusyMarkCommandIds.toggleSidebar, + }; + + static String _appLabel( + BuildContext context, + BusyMarkAppShortcutAction action, + ) => switch (action) { + BusyMarkAppShortcutAction.newDocument => context.l10n.shortcutNewDocument, + BusyMarkAppShortcutAction.open => context.l10n.open, + BusyMarkAppShortcutAction.save => context.l10n.save, + BusyMarkAppShortcutAction.exportPdf => context.l10n.exportAsPdf, + BusyMarkAppShortcutAction.fullScreen => context.l10n.fullScreen, + BusyMarkAppShortcutAction.back => context.l10n.back, + BusyMarkAppShortcutAction.search => context.l10n.search, + BusyMarkAppShortcutAction.keyboardShortcuts => + context.l10n.keyboardShortcuts, + BusyMarkAppShortcutAction.commandPalette => context.l10n.commandPalette, + BusyMarkAppShortcutAction.markdownAndHtml => context.l10n.markdownAndHtml, + BusyMarkAppShortcutAction.settings => context.l10n.settings, + BusyMarkAppShortcutAction.nextTab => context.l10n.shortcutNextTab, + BusyMarkAppShortcutAction.previousTab => context.l10n.shortcutPreviousTab, + BusyMarkAppShortcutAction.closeTab => context.l10n.shortcutCloseTab, + BusyMarkAppShortcutAction.closeAllTabs => context.l10n.shortcutCloseAllTabs, + BusyMarkAppShortcutAction.toggleSidebar => context.l10n.toggleSidebar, + }; + + static String _appCategory( + BuildContext context, + BusyMarkAppShortcutAction action, + ) => switch (action) { + BusyMarkAppShortcutAction.nextTab || + BusyMarkAppShortcutAction.previousTab || + BusyMarkAppShortcutAction.closeTab || + BusyMarkAppShortcutAction.closeAllTabs => context.l10n.tabs, + BusyMarkAppShortcutAction.toggleSidebar => + context.l10n.shortcutGroupSidebar, + _ => context.l10n.shortcutGroupGeneral, + }; + + static String _viewLabel( + BuildContext context, + BusyMarkDocumentViewShortcutAction action, + ) => switch (action) { + BusyMarkDocumentViewShortcutAction.editor => context.l10n.editor, + BusyMarkDocumentViewShortcutAction.source => context.l10n.source, + BusyMarkDocumentViewShortcutAction.reading => context.l10n.reading, + BusyMarkDocumentViewShortcutAction.split => context.l10n.split, + }; + + static String? _appDescription( + BuildContext context, + BusyMarkAppShortcutAction action, + ) => switch (action) { + BusyMarkAppShortcutAction.newDocument => + context.l10n.shortcutNewDocumentDescription, + BusyMarkAppShortcutAction.open => context.l10n.shortcutOpenDescription, + BusyMarkAppShortcutAction.save => context.l10n.shortcutSaveDescription, + BusyMarkAppShortcutAction.exportPdf => + context.l10n.shortcutExportPdfDescription, + BusyMarkAppShortcutAction.search => context.l10n.shortcutSearchDescription, + BusyMarkAppShortcutAction.keyboardShortcuts => + context.l10n.shortcutKeyboardShortcutsDescription, + BusyMarkAppShortcutAction.markdownAndHtml => + context.l10n.shortcutMarkdownAndHtmlDescription, + BusyMarkAppShortcutAction.settings => + context.l10n.shortcutSettingsDescription, + BusyMarkAppShortcutAction.nextTab => + context.l10n.shortcutNextTabDescription, + BusyMarkAppShortcutAction.previousTab => + context.l10n.shortcutPreviousTabDescription, + BusyMarkAppShortcutAction.closeTab => + context.l10n.shortcutCloseTabDescription, + BusyMarkAppShortcutAction.closeAllTabs => + context.l10n.shortcutCloseAllTabsDescription, + _ => null, + }; + + static String _textLabel( + BuildContext context, + BusyMarkTextEditingShortcutAction action, + ) => switch (action) { + BusyMarkTextEditingShortcutAction.selectAll => context.l10n.selectAll, + BusyMarkTextEditingShortcutAction.cut => context.l10n.cut, + BusyMarkTextEditingShortcutAction.copy => context.l10n.copy, + BusyMarkTextEditingShortcutAction.paste => context.l10n.paste, + BusyMarkTextEditingShortcutAction.pastePlainText => + context.l10n.pasteWithoutFormatting, + BusyMarkTextEditingShortcutAction.undo => context.l10n.undo, + BusyMarkTextEditingShortcutAction.redo => context.l10n.redo, + BusyMarkTextEditingShortcutAction.insertIndentation => + context.l10n.shortcutInsertIndentation, + BusyMarkTextEditingShortcutAction.outdentSource => + context.l10n.shortcutOutdentSource, + BusyMarkTextEditingShortcutAction.escape => context.l10n.shortcutEscape, + }; + + static String _editorLabel( + BuildContext context, + BusyMarkEditorShortcutAction action, + ) => switch (action) { + BusyMarkEditorShortcutAction.refineWithAi => context.l10n.aiRefineWithAi, + BusyMarkEditorShortcutAction.bold => context.l10n.bold, + BusyMarkEditorShortcutAction.italic => context.l10n.italic, + BusyMarkEditorShortcutAction.underline => context.l10n.underline, + BusyMarkEditorShortcutAction.strikethrough => context.l10n.strikethrough, + BusyMarkEditorShortcutAction.inlineCode => context.l10n.inlineCode, + BusyMarkEditorShortcutAction.link => context.l10n.link, + BusyMarkEditorShortcutAction.paragraph => context.l10n.paragraph, + BusyMarkEditorShortcutAction.heading1 => context.l10n.heading1, + BusyMarkEditorShortcutAction.heading2 => context.l10n.heading2, + BusyMarkEditorShortcutAction.heading3 => context.l10n.heading3, + BusyMarkEditorShortcutAction.heading4 => context.l10n.heading4, + BusyMarkEditorShortcutAction.heading5 => context.l10n.heading5, + BusyMarkEditorShortcutAction.heading6 => context.l10n.heading6, + BusyMarkEditorShortcutAction.orderedList => context.l10n.numberedList, + BusyMarkEditorShortcutAction.unorderedList => context.l10n.bulletedList, + BusyMarkEditorShortcutAction.taskList => context.l10n.checklist, + BusyMarkEditorShortcutAction.toggleTask => context.l10n.checklist, + BusyMarkEditorShortcutAction.indent => context.l10n.indentListItem, + BusyMarkEditorShortcutAction.outdent => context.l10n.outdentListItem, + BusyMarkEditorShortcutAction.blockquote => context.l10n.blockquote, + BusyMarkEditorShortcutAction.codeBlock => context.l10n.codeBlock, + BusyMarkEditorShortcutAction.codeBlockLanguage => context.l10n.language, + BusyMarkEditorShortcutAction.image => context.l10n.image, + BusyMarkEditorShortcutAction.inlineImage => context.l10n.inlineImage, + BusyMarkEditorShortcutAction.table => context.l10n.table, + BusyMarkEditorShortcutAction.htmlBlock => context.l10n.htmlBlock, + BusyMarkEditorShortcutAction.thematicBreak => context.l10n.thematicBreak, + BusyMarkEditorShortcutAction.hardLineBreak => context.l10n.hardLineBreak, + BusyMarkEditorShortcutAction.pastePlainText => + context.l10n.pasteWithoutFormatting, + }; + + static String? _textDescription( + BuildContext context, + BusyMarkTextEditingShortcutAction action, + ) => switch (action) { + BusyMarkTextEditingShortcutAction.selectAll => + context.l10n.shortcutSelectAllDescription, + BusyMarkTextEditingShortcutAction.cut => + context.l10n.shortcutCutDescription, + BusyMarkTextEditingShortcutAction.copy => + context.l10n.shortcutCopyDescription, + BusyMarkTextEditingShortcutAction.paste => + context.l10n.shortcutPasteDescription, + BusyMarkTextEditingShortcutAction.pastePlainText => + context.l10n.shortcutPastePlainTextDescription, + BusyMarkTextEditingShortcutAction.undo => + context.l10n.shortcutUndoDescription, + BusyMarkTextEditingShortcutAction.redo => + context.l10n.shortcutRedoDescription, + BusyMarkTextEditingShortcutAction.insertIndentation => + context.l10n.shortcutInsertIndentationDescription, + BusyMarkTextEditingShortcutAction.outdentSource => + context.l10n.shortcutOutdentSourceDescription, + BusyMarkTextEditingShortcutAction.escape => + context.l10n.shortcutEscapeDescription, + }; + + static String? _editorDescription( + BuildContext context, + BusyMarkEditorShortcutAction action, + ) => switch (action) { + BusyMarkEditorShortcutAction.bold => context.l10n.shortcutBoldDescription, + BusyMarkEditorShortcutAction.italic => + context.l10n.shortcutItalicDescription, + BusyMarkEditorShortcutAction.underline => + context.l10n.shortcutUnderlineDescription, + BusyMarkEditorShortcutAction.link => context.l10n.shortcutLinkDescription, + BusyMarkEditorShortcutAction.inlineCode => + context.l10n.shortcutInlineCodeDescription, + BusyMarkEditorShortcutAction.strikethrough => + context.l10n.shortcutStrikethroughDescription, + BusyMarkEditorShortcutAction.paragraph => + context.l10n.shortcutParagraphDescription, + BusyMarkEditorShortcutAction.heading1 => + context.l10n.shortcutHeading1Description, + BusyMarkEditorShortcutAction.heading2 => + context.l10n.shortcutHeading2Description, + BusyMarkEditorShortcutAction.heading3 => + context.l10n.shortcutHeading3Description, + BusyMarkEditorShortcutAction.heading4 => + context.l10n.shortcutHeading4Description, + BusyMarkEditorShortcutAction.heading5 => + context.l10n.shortcutHeading5Description, + BusyMarkEditorShortcutAction.heading6 => + context.l10n.shortcutHeading6Description, + BusyMarkEditorShortcutAction.orderedList => + context.l10n.shortcutNumberedListDescription, + BusyMarkEditorShortcutAction.unorderedList => + context.l10n.shortcutBulletedListDescription, + BusyMarkEditorShortcutAction.taskList => + context.l10n.shortcutChecklistDescription, + _ => null, + }; + + static String _editorCategory( + BuildContext context, + BusyMarkEditorShortcutAction action, + ) => switch (action) { + BusyMarkEditorShortcutAction.bold || + BusyMarkEditorShortcutAction.italic || + BusyMarkEditorShortcutAction.underline || + BusyMarkEditorShortcutAction.strikethrough || + BusyMarkEditorShortcutAction.inlineCode || + BusyMarkEditorShortcutAction.link => context.l10n.shortcutGroupFormatting, + BusyMarkEditorShortcutAction.orderedList || + BusyMarkEditorShortcutAction.unorderedList || + BusyMarkEditorShortcutAction.taskList || + BusyMarkEditorShortcutAction.toggleTask || + BusyMarkEditorShortcutAction.indent || + BusyMarkEditorShortcutAction.outdent => context.l10n.shortcutGroupLists, + BusyMarkEditorShortcutAction.image || + BusyMarkEditorShortcutAction.inlineImage || + BusyMarkEditorShortcutAction.table || + BusyMarkEditorShortcutAction.htmlBlock || + BusyMarkEditorShortcutAction.thematicBreak || + BusyMarkEditorShortcutAction.hardLineBreak => context.l10n.insert, + _ => context.l10n.shortcutGroupBlocks, + }; + + static String _sidebarLabel( + BuildContext context, + BusyMarkSidebarShortcutAction action, + ) => switch (action) { + BusyMarkSidebarShortcutAction.files => context.l10n.files, + BusyMarkSidebarShortcutAction.toc => context.l10n.toc, + BusyMarkSidebarShortcutAction.outline => context.l10n.outline, + BusyMarkSidebarShortcutAction.git => context.l10n.git, + }; +} diff --git a/lib/src/app/window_control_service.dart b/lib/src/app/window_control_service.dart index f1d5a93f..924aa0c5 100644 --- a/lib/src/app/window_control_service.dart +++ b/lib/src/app/window_control_service.dart @@ -130,11 +130,14 @@ class WindowControlService extends ChangeNotifier { required bool confirmCloseWithUnsavedChanges, required Future Function() showCloseDialog, required Future Function() saveChanges, + Future Function()? beforeClose, + Future Function()? discardChanges, }) async { if (_closeInProgress) { return; } if (!hasUnsavedChanges || !confirmCloseWithUnsavedChanges) { + await beforeClose?.call(); await closeWindow(); return; } @@ -142,9 +145,11 @@ class WindowControlService extends ChangeNotifier { final action = await showCloseDialog(); switch (action) { case WindowCloseAction.discard: + await discardChanges?.call(); await closeWindow(); case WindowCloseAction.save: if (await saveChanges()) { + await beforeClose?.call(); await closeWindow(); } else { await _nativeWindow.setPreventClose(true); diff --git a/lib/src/assets/asset_ingestion_service.dart b/lib/src/assets/asset_ingestion_service.dart new file mode 100644 index 00000000..d5d6d7fb --- /dev/null +++ b/lib/src/assets/asset_ingestion_service.dart @@ -0,0 +1,341 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:path/path.dart' as p; +import 'package:xml/xml.dart' as xml; + +enum AssetIngestionOrigin { + imagePicker, + screenshotPaste, + clipboardImageFile, + dragAndDrop, +} + +enum AssetWorkspaceKind { writerside, markdownWorkspace, standalone } + +class AssetIngestionException implements Exception { + const AssetIngestionException(this.code, this.message); + + final String code; + final String message; + + @override + String toString() => message; +} + +class AssetSaveRequiredException extends AssetIngestionException { + const AssetSaveRequiredException() + : super( + 'asset.document-save-required', + 'Save the document before adding an image.', + ); +} + +class AssetIngestionRequest { + const AssetIngestionRequest({ + required this.documentFilePath, + required this.workspaceKind, + this.workspaceRoot, + this.writersideRoot, + this.imagesDir = 'images', + }); + + final String documentFilePath; + final AssetWorkspaceKind workspaceKind; + final String? workspaceRoot; + final String? writersideRoot; + final String imagesDir; +} + +class IngestedAsset { + const IngestedAsset({ + required this.absolutePath, + required this.markdownPath, + required this.mimeType, + required this.reusedExisting, + required this.origin, + }); + + final String absolutePath; + final String markdownPath; + final String mimeType; + final bool reusedExisting; + final AssetIngestionOrigin origin; +} + +class AssetIngestionService { + const AssetIngestionService({this.maximumAssetBytes = 100 * 1024 * 1024}); + + final int maximumAssetBytes; + + Future ingestFile({ + required String sourcePath, + required AssetIngestionRequest request, + required AssetIngestionOrigin origin, + }) async { + final source = File(p.normalize(p.absolute(sourcePath))); + final stat = await source.stat(); + if (stat.type != FileSystemEntityType.file) { + throw const AssetIngestionException( + 'asset.source-not-file', + 'The selected image is not a regular file.', + ); + } + if (stat.size <= 0 || stat.size > maximumAssetBytes) { + throw const AssetIngestionException( + 'asset.invalid-size', + 'The image is empty or exceeds the supported size limit.', + ); + } + return ingestBytes( + bytes: await source.readAsBytes(), + suggestedFileName: p.basename(source.path), + request: request, + origin: origin, + ); + } + + Future ingestBytes({ + required Uint8List bytes, + required String suggestedFileName, + required AssetIngestionRequest request, + required AssetIngestionOrigin origin, + }) async { + if (request.documentFilePath.trim().isEmpty) { + throw const AssetSaveRequiredException(); + } + if (bytes.isEmpty || bytes.length > maximumAssetBytes) { + throw const AssetIngestionException( + 'asset.invalid-size', + 'The image is empty or exceeds the supported size limit.', + ); + } + final imageType = _detectImageType(bytes); + if (imageType == null) { + throw const AssetIngestionException( + 'asset.invalid-image-type', + 'The selected file is not a supported PNG, JPEG, GIF, WebP, or SVG image.', + ); + } + final destination = await _destinationDirectory(request); + final contentHash = sha256.convert(bytes).toString(); + final existing = await _identicalAsset( + destination, + bytes.length, + contentHash, + ); + late final File published; + var reused = existing != null; + if (existing != null) { + published = existing; + } else { + final stem = _safeStem(p.basenameWithoutExtension(suggestedFileName)); + published = await _publishUnique( + destination, + stem: stem, + extension: imageType.extension, + bytes: bytes, + ); + reused = false; + } + return IngestedAsset( + absolutePath: published.path, + markdownPath: p + .relative(published.path, from: p.dirname(request.documentFilePath)) + .replaceAll(p.separator, '/'), + mimeType: imageType.mimeType, + reusedExisting: reused, + origin: origin, + ); + } + + Future _destinationDirectory(AssetIngestionRequest request) async { + final documentPath = p.normalize(p.absolute(request.documentFilePath)); + late final String allowedRootPath; + late final String destinationPath; + switch (request.workspaceKind) { + case AssetWorkspaceKind.writerside: + final writersideRoot = request.writersideRoot; + if (writersideRoot == null || writersideRoot.trim().isEmpty) { + throw const AssetIngestionException( + 'asset.writerside-root-missing', + 'The Writerside project image directory is unavailable.', + ); + } + final relativeImagesDir = _safeRelativeDirectory(request.imagesDir); + allowedRootPath = p.normalize(p.absolute(writersideRoot)); + destinationPath = p.join(allowedRootPath, relativeImagesDir); + case AssetWorkspaceKind.markdownWorkspace: + final workspaceRoot = request.workspaceRoot; + if (workspaceRoot == null || workspaceRoot.trim().isEmpty) { + throw const AssetIngestionException( + 'asset.workspace-root-missing', + 'The workspace image directory is unavailable.', + ); + } + allowedRootPath = p.normalize(p.absolute(workspaceRoot)); + destinationPath = p.join(allowedRootPath, 'images'); + case AssetWorkspaceKind.standalone: + allowedRootPath = p.dirname(documentPath); + destinationPath = p.join(allowedRootPath, 'images'); + } + final allowedRoot = Directory(allowedRootPath); + if (!await allowedRoot.exists()) { + throw const AssetIngestionException( + 'asset.destination-root-missing', + 'The document asset root no longer exists.', + ); + } + final canonicalRoot = p.normalize(await allowedRoot.resolveSymbolicLinks()); + final destination = Directory(destinationPath); + await destination.create(recursive: true); + final canonicalDestination = p.normalize( + await destination.resolveSymbolicLinks(), + ); + if (!p.equals(canonicalRoot, canonicalDestination) && + !p.isWithin(canonicalRoot, canonicalDestination)) { + throw const AssetIngestionException( + 'asset.destination-outside-workspace', + 'The configured image directory resolves outside the project.', + ); + } + return Directory(canonicalDestination); + } + + String _safeRelativeDirectory(String value) { + final normalized = p.normalize(value.trim()); + if (normalized.isEmpty || + normalized == '.' || + p.isAbsolute(normalized) || + normalized == '..' || + normalized.startsWith('..${p.separator}')) { + throw const AssetIngestionException( + 'asset.images-dir-invalid', + 'The configured Writerside images directory is invalid.', + ); + } + return normalized; + } + + Future _identicalAsset( + Directory directory, + int size, + String expectedHash, + ) async { + var inspected = 0; + await for (final entity in directory.list(followLinks: false)) { + if (++inspected > 10000) { + break; + } + if (entity is! File) { + continue; + } + final stat = await entity.stat(); + if (stat.size != size) { + continue; + } + final digest = sha256.convert(await entity.readAsBytes()).toString(); + if (digest == expectedHash) { + return entity; + } + } + return null; + } + + Future _publishUnique( + Directory directory, { + required String stem, + required String extension, + required Uint8List bytes, + }) async { + var suffix = 1; + late File target; + while (true) { + final name = suffix == 1 + ? '$stem.$extension' + : '$stem-$suffix.$extension'; + target = File(p.join(directory.path, name)); + if (!await target.exists()) { + break; + } + suffix++; + } + final staging = File( + p.join( + directory.path, + '.busymark-asset-$pid-${DateTime.now().microsecondsSinceEpoch}', + ), + ); + try { + await staging.writeAsBytes(bytes, flush: true); + return await staging.rename(target.path); + } finally { + if (await staging.exists()) { + await staging.delete(); + } + } + } + + String _safeStem(String value) { + final sanitized = value + .replaceAll(RegExp(r'[^A-Za-z0-9._-]+'), '-') + .replaceAll(RegExp(r'-+'), '-') + .replaceAll(RegExp(r'^[.\-_]+|[.\-_]+$'), ''); + if (sanitized.isEmpty) { + return 'image'; + } + return sanitized.length <= 80 ? sanitized : sanitized.substring(0, 80); + } + + _DetectedImageType? _detectImageType(Uint8List bytes) { + if (bytes.length >= 8 && + bytes[0] == 0x89 && + bytes[1] == 0x50 && + bytes[2] == 0x4e && + bytes[3] == 0x47 && + bytes[4] == 0x0d && + bytes[5] == 0x0a && + bytes[6] == 0x1a && + bytes[7] == 0x0a) { + return const _DetectedImageType('png', 'image/png'); + } + if (bytes.length >= 3 && + bytes[0] == 0xff && + bytes[1] == 0xd8 && + bytes[2] == 0xff) { + return const _DetectedImageType('jpg', 'image/jpeg'); + } + if (bytes.length >= 6) { + final signature = ascii.decode(bytes.sublist(0, 6), allowInvalid: true); + if (signature == 'GIF87a' || signature == 'GIF89a') { + return const _DetectedImageType('gif', 'image/gif'); + } + } + if (bytes.length >= 12 && + ascii.decode(bytes.sublist(0, 4), allowInvalid: true) == 'RIFF' && + ascii.decode(bytes.sublist(8, 12), allowInvalid: true) == 'WEBP') { + return const _DetectedImageType('webp', 'image/webp'); + } + try { + final source = utf8.decode(bytes); + final document = xml.XmlDocument.parse( + source.startsWith('\uFEFF') ? source.substring(1) : source, + ); + if (document.rootElement.name.local.toLowerCase() == 'svg') { + return const _DetectedImageType('svg', 'image/svg+xml'); + } + } on Object { + // Binary or malformed XML is not SVG. + } + return null; + } +} + +class _DetectedImageType { + const _DetectedImageType(this.extension, this.mimeType); + + final String extension; + final String mimeType; +} diff --git a/lib/src/assets/asset_input_service.dart b/lib/src/assets/asset_input_service.dart new file mode 100644 index 00000000..a657180f --- /dev/null +++ b/lib/src/assets/asset_input_service.dart @@ -0,0 +1,57 @@ +import 'dart:async'; +import 'package:flutter/services.dart'; + +class AssetInputService { + AssetInputService({ + MethodChannel channel = const MethodChannel('com.busymark.app/asset_input'), + }) : _channel = channel { + _channel.setMethodCallHandler(_handleNativeCall); + } + + final MethodChannel _channel; + final _droppedFiles = StreamController>.broadcast(); + + Stream> get droppedFiles => _droppedFiles.stream; + + Future> readClipboardImageFiles() async { + try { + final paths = await _channel.invokeListMethod( + 'readClipboardImageFiles', + ); + return paths ?? const []; + } on MissingPluginException { + return const []; + } on PlatformException { + return const []; + } + } + + Future readClipboardImagePng() async { + try { + return await _channel.invokeMethod('readClipboardImagePng'); + } on MissingPluginException { + return null; + } on PlatformException { + return null; + } + } + + Future _handleNativeCall(MethodCall call) async { + if (call.method != 'assetFilesDropped') { + throw MissingPluginException('Unknown asset input method ${call.method}'); + } + final paths = (call.arguments as List?)?.whereType().toList( + growable: false, + ); + if (paths != null && paths.isNotEmpty && !_droppedFiles.isClosed) { + _droppedFiles.add(paths); + } + } + + Future dispose() async { + _channel.setMethodCallHandler(null); + await _droppedFiles.close(); + } +} + +final busyMarkAssetInputService = AssetInputService(); diff --git a/lib/src/editor/editor_text_context_menu.dart b/lib/src/editor/editor_text_context_menu.dart index 8f8afc74..8dff3a56 100644 --- a/lib/src/editor/editor_text_context_menu.dart +++ b/lib/src/editor/editor_text_context_menu.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; import '../app/busymark_design.dart'; import '../app/busymark_glyphs.dart'; -import '../app/busymark_shortcuts.dart'; +import '../app/command_registry.dart'; Widget buildBusyMarkEditorTextContextMenu( BuildContext context, @@ -86,15 +86,25 @@ class _BusyMarkEditorTextContextMenuState List> _menuItems(BuildContext context) { final editable = widget.editableTextState; + final commands = BusyMarkCommandCatalog.create(); final items = >[ - for (final item in editable.contextMenuButtonItems) - BusyMarkPopupMenuItem( - value: item.onPressed ?? () {}, - label: AdaptiveTextSelectionToolbar.getButtonLabel(context, item), - icon: _iconFor(item.type), - shortcut: _shortcutFor(item.type), - enabled: item.onPressed != null, - ), + for (final item in editable.contextMenuButtonItems) ...[ + if (_commandIdFor(item.type) case final commandId?) + BusyMarkPopupMenuItem( + value: item.onPressed ?? () {}, + label: commands[commandId]!.label(context), + icon: _iconFor(item.type), + shortcut: commands[commandId]!.shortcut?.label, + enabled: item.onPressed != null, + ) + else + BusyMarkPopupMenuItem( + value: item.onPressed ?? () {}, + label: AdaptiveTextSelectionToolbar.getButtonLabel(context, item), + icon: _iconFor(item.type), + enabled: item.onPressed != null, + ), + ], ]; final selection = editable.textEditingValue.selection; final refineWithAi = widget.onRefineWithAi; @@ -102,9 +112,12 @@ class _BusyMarkEditorTextContextMenuState items.add( BusyMarkPopupMenuItem( value: refineWithAi, - label: widget.refineWithAiLabel, + label: + commands[BusyMarkCommandIds.editorRefineWithAi]?.label(context) ?? + widget.refineWithAiLabel, icon: BusyMarkGlyphs.ai, - shortcut: BusyMarkEditorShortcutLabels.refineWithAi, + shortcut: + commands[BusyMarkCommandIds.editorRefineWithAi]?.shortcut?.label, ), ); } @@ -127,14 +140,13 @@ IconData? _iconFor(ContextMenuButtonType type) { }; } -String? _shortcutFor(ContextMenuButtonType type) { +String? _commandIdFor(ContextMenuButtonType type) { return switch (type) { - ContextMenuButtonType.cut => BusyMarkTextEditingShortcutLabels.cut, - ContextMenuButtonType.copy => BusyMarkTextEditingShortcutLabels.copy, - ContextMenuButtonType.paste => BusyMarkTextEditingShortcutLabels.paste, - ContextMenuButtonType.selectAll => - BusyMarkTextEditingShortcutLabels.selectAll, - ContextMenuButtonType.delete => 'Delete', + ContextMenuButtonType.cut => BusyMarkCommandIds.textCut, + ContextMenuButtonType.copy => BusyMarkCommandIds.textCopy, + ContextMenuButtonType.paste => BusyMarkCommandIds.textPaste, + ContextMenuButtonType.selectAll => BusyMarkCommandIds.textSelectAll, + ContextMenuButtonType.delete => null, ContextMenuButtonType.lookUp || ContextMenuButtonType.searchWeb || ContextMenuButtonType.share || diff --git a/lib/src/editor/source/source_editor.dart b/lib/src/editor/source/source_editor.dart index 73146e6e..359172fc 100644 --- a/lib/src/editor/source/source_editor.dart +++ b/lib/src/editor/source/source_editor.dart @@ -8,9 +8,11 @@ import 'package:yaru/yaru.dart'; import '../../ai/ai_models.dart'; import '../../app/busymark_design.dart'; +import '../../app/busymark_glyphs.dart'; import '../../app/busymark_shortcuts.dart'; import '../../app/localization.dart'; import '../../core/diagnostic.dart'; +import '../../search/search_replace_service.dart'; import '../editor_text_context_menu.dart'; import '../source_folding.dart'; import 'source_commands.dart'; @@ -22,41 +24,66 @@ import 'source_search.dart'; typedef BusyMarkSourceChanged = void Function(String fullText, String? sourceFilePath); +typedef BusyMarkSourceSessionChanged = + void Function( + TextSelection selection, + double scrollOffset, + Set foldedRegionKeys, + ); + class BusyMarkSourceEditor extends StatefulWidget { const BusyMarkSourceEditor({ super.key, required this.text, required this.language, required this.filePath, + this.documentId, required this.diagnostics, required this.editorFontSize, required this.wordWrap, required this.searchActive, required this.searchOptions, required this.onSearchOptionsChanged, + this.searchReplacement = '', + this.onSearchReplacementChanged, required this.onChanged, + this.onUndo, + this.onRedo, required this.onOpenSearch, required this.onCloseSearch, this.onVisibleLineChanged, this.onAiEdit, this.editRevision = 0, + this.initialSelection, + this.initialScrollOffset = 0, + this.initialFoldedRegionKeys = const {}, + this.onSessionChanged, }); final String text; final SourceSyntaxLanguage language; final String? filePath; + final String? documentId; final Iterable diagnostics; final double editorFontSize; final bool wordWrap; final bool searchActive; final SourceSearchOptions searchOptions; final ValueChanged onSearchOptionsChanged; + final String searchReplacement; + final ValueChanged? onSearchReplacementChanged; final BusyMarkSourceChanged onChanged; + final String? Function()? onUndo; + final String? Function()? onRedo; final VoidCallback onOpenSearch; final VoidCallback onCloseSearch; final ValueChanged? onVisibleLineChanged; final BusyMarkAiEditCallback? onAiEdit; final int editRevision; + final TextSelection? initialSelection; + final double initialScrollOffset; + final Set initialFoldedRegionKeys; + final BusyMarkSourceSessionChanged? onSessionChanged; @override State createState() => BusyMarkSourceEditorState(); @@ -70,6 +97,7 @@ class BusyMarkSourceEditorState extends State { final _sourceEditorKey = GlobalKey(); final _foldedRegionKeys = {}; final _searchController = SourceSearchController(); + final _replacementService = const SearchReplacementService(); final _lineLayoutCache = SourceLineLayoutCache(); List _foldRegions = const []; String _lastPath = ''; @@ -84,29 +112,37 @@ class BusyMarkSourceEditorState extends State { _focusNode = FocusNode(onKeyEvent: _handleKeyEvent); _scrollController = ScrollController(); _undoController = UndoHistoryController(); - _lastPath = widget.filePath ?? ''; + _lastPath = widget.documentId ?? widget.filePath ?? ''; _recomputeFoldRegions(resetCollapsed: true); + _restoreSessionState(); _syncSearchOptions(); + _controller.addListener(_publishSessionState); + _scrollController.addListener(_publishSessionState); } @override void didUpdateWidget(covariant BusyMarkSourceEditor oldWidget) { super.didUpdateWidget(oldWidget); - final path = widget.filePath ?? ''; + final path = widget.documentId ?? widget.filePath ?? ''; final pathChanged = path != _lastPath; final languageChanged = widget.language != oldWidget.language; if (pathChanged) { _lastPath = path; _foldedRegionKeys.clear(); - _replaceController(text: widget.text, language: widget.language); - _recomputeFoldRegions(resetCollapsed: true); + _withoutSessionPublication(() { + _replaceController(text: widget.text, language: widget.language); + _recomputeFoldRegions(resetCollapsed: true); + _restoreSessionState(); + }); } else if ((widget.text != oldWidget.text && !_focusNode.hasFocus) || languageChanged) { - _controller.replaceFullTextAndLanguage( - text: widget.text, - language: widget.language, - ); - _recomputeFoldRegions(resetCollapsed: languageChanged); + _withoutSessionPublication(() { + _controller.replaceFullTextAndLanguage( + text: widget.text, + language: widget.language, + ); + _recomputeFoldRegions(resetCollapsed: languageChanged); + }); } if (widget.searchActive != oldWidget.searchActive || widget.searchOptions != oldWidget.searchOptions || @@ -179,6 +215,20 @@ class BusyMarkSourceEditorState extends State { widget.onOpenSearch(); return KeyEventResult.handled; } + if (BusyMarkTextEditingShortcutActivators.undo.accepts(event, keyboard)) { + final text = widget.onUndo?.call(); + if (text != null) { + _applyOwnedUndoText(text); + return KeyEventResult.handled; + } + } + if (BusyMarkTextEditingShortcutActivators.redo.accepts(event, keyboard)) { + final text = widget.onRedo?.call(); + if (text != null) { + _applyOwnedUndoText(text); + return KeyEventResult.handled; + } + } if (BusyMarkTextEditingShortcutActivators.insertIndentation.accepts( event, keyboard, @@ -250,7 +300,7 @@ class BusyMarkSourceEditorState extends State { child: SizedBox( key: _sourceEditorKey, child: KeyedSubtree( - key: ValueKey(widget.filePath), + key: ValueKey(widget.documentId ?? widget.filePath), child: Shortcuts( shortcuts: BusyMarkEditorShortcutActivators.intentMap( _SourceEditorShortcutIntent.new, @@ -352,6 +402,13 @@ class BusyMarkSourceEditorState extends State { regex: !widget.searchOptions.regex, ), ), + replacement: widget.searchReplacement, + onReplacementChanged: + widget.onSearchReplacementChanged ?? (_) {}, + onReplaceCurrent: _replaceCurrentSearchMatch, + onReplaceAndFindNext: () => + _replaceCurrentSearchMatch(findNext: true), + onReplaceAll: _replaceAllSearchMatches, onClose: widget.onCloseSearch, ), ), @@ -470,6 +527,74 @@ class BusyMarkSourceEditorState extends State { _revealSearchMatch(match); } + void _replaceCurrentSearchMatch({bool findNext = false}) { + if (_searchController.result.invalidRegex) { + return; + } + var currentIndex = _searchController.result.currentMatchIndex; + if (currentIndex == null) { + _searchController.next(_controller.document); + currentIndex = _searchController.result.currentMatchIndex; + } + if (currentIndex == null) { + return; + } + final preview = _replacementService.previewText( + source: _controller.fullText, + options: widget.searchOptions, + replacement: widget.searchReplacement, + ); + if (preview.invalidRegex || currentIndex >= preview.matches.length) { + return; + } + final match = preview.matches[currentIndex]; + final nextText = preview.source.replaceRange( + match.start, + match.end, + match.replacement, + ); + _applyFullEditingValue( + TextEditingValue( + text: nextText, + selection: TextSelection( + baseOffset: match.start, + extentOffset: match.start + match.replacement.length, + ), + ), + ); + _refreshSearch( + currentIndex: math.min( + currentIndex, + math.max(0, preview.matches.length - 2), + ), + ); + if (findNext) { + _nextSearchMatch(); + } + } + + void _replaceAllSearchMatches() { + final preview = _replacementService.previewText( + source: _controller.fullText, + options: widget.searchOptions, + replacement: widget.searchReplacement, + ); + if (preview.invalidRegex || preview.matches.isEmpty) { + return; + } + final nextText = preview.apply(); + final selectionOffset = _controller.fullSelection.extentOffset + .clamp(0, nextText.length) + .toInt(); + _applyFullEditingValue( + TextEditingValue( + text: nextText, + selection: TextSelection.collapsed(offset: selectionOffset), + ), + ); + _refreshSearch(); + } + void _revealSearchMatch(SourceSearchMatch? initialMatch) { var match = initialMatch; if (match == null) { @@ -547,6 +672,7 @@ class BusyMarkSourceEditorState extends State { _applyFoldedRegions(); _refreshSearch(currentIndex: _searchController.result.currentMatchIndex); }); + _publishSessionState(); } void _unfoldSourceLine(int line) { @@ -592,6 +718,59 @@ class BusyMarkSourceEditorState extends State { _refreshSearch(currentIndex: _searchController.result.currentMatchIndex); }); widget.onChanged(_controller.fullText, widget.filePath); + _publishSessionState(); + } + + void _restoreSessionState() { + final validKeys = {for (final region in _foldRegions) region.key}; + _foldedRegionKeys + ..clear() + ..addAll(widget.initialFoldedRegionKeys.where(validKeys.contains)); + _applyFoldedRegions(); + final selection = widget.initialSelection; + if (selection != null) { + _controller.fullSelection = TextSelection( + baseOffset: selection.baseOffset + .clamp(0, _controller.fullText.length) + .toInt(), + extentOffset: selection.extentOffset + .clamp(0, _controller.fullText.length) + .toInt(), + ); + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_scrollController.hasClients) { + return; + } + _scrollController.jumpTo( + widget.initialScrollOffset + .clamp(0, _scrollController.position.maxScrollExtent) + .toDouble(), + ); + }); + } + + void _publishSessionState() { + if (_suppressSessionPublication) { + return; + } + widget.onSessionChanged?.call( + _controller.fullSelection, + _scrollController.hasClients ? _scrollController.offset : 0, + Set.unmodifiable(_foldedRegionKeys), + ); + } + + bool _suppressSessionPublication = false; + + void _withoutSessionPublication(VoidCallback callback) { + final wasSuppressed = _suppressSessionPublication; + _suppressSessionPublication = true; + try { + callback(); + } finally { + _suppressSessionPublication = wasSuppressed; + } } TextEditingValue _fullEditingValue() { @@ -607,6 +786,16 @@ class BusyMarkSourceEditorState extends State { _handleSourceChanged(); } + void _applyOwnedUndoText(String text) { + _controller.replaceFullTextAndLanguage( + text: text, + language: widget.language, + ); + _recomputeFoldRegions(); + _refreshSearch(); + setState(() {}); + } + void _applyShortcutAction(BusyMarkEditorShortcutAction action) { switch (action) { case BusyMarkEditorShortcutAction.refineWithAi: @@ -803,6 +992,7 @@ class BusyMarkSourceEditorState extends State { }) { final previous = _controller; _controller = BusyMarkSourceController(text: text, language: language); + _controller.addListener(_publishSessionState); _resetUndoHistory(); WidgetsBinding.instance.addPostFrameCallback((_) { previous.dispose(); @@ -1258,7 +1448,7 @@ class _CollapsedSourceLine extends StatelessWidget { } } -class _SourceSearchPanel extends StatelessWidget { +class _SourceSearchPanel extends StatefulWidget { const _SourceSearchPanel({ required this.result, required this.onPrevious, @@ -1266,6 +1456,11 @@ class _SourceSearchPanel extends StatelessWidget { required this.onToggleCaseSensitive, required this.onToggleWholeWord, required this.onToggleRegex, + required this.replacement, + required this.onReplacementChanged, + required this.onReplaceCurrent, + required this.onReplaceAndFindNext, + required this.onReplaceAll, required this.onClose, }); @@ -1275,11 +1470,45 @@ class _SourceSearchPanel extends StatelessWidget { final VoidCallback onToggleCaseSensitive; final VoidCallback onToggleWholeWord; final VoidCallback onToggleRegex; + final String replacement; + final ValueChanged onReplacementChanged; + final VoidCallback onReplaceCurrent; + final VoidCallback onReplaceAndFindNext; + final VoidCallback onReplaceAll; final VoidCallback onClose; + @override + State<_SourceSearchPanel> createState() => _SourceSearchPanelState(); +} + +class _SourceSearchPanelState extends State<_SourceSearchPanel> { + late final TextEditingController _replacementController; + + @override + void initState() { + super.initState(); + _replacementController = TextEditingController(text: widget.replacement); + } + + @override + void didUpdateWidget(covariant _SourceSearchPanel oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.replacement != oldWidget.replacement && + widget.replacement != _replacementController.text) { + _replacementController.text = widget.replacement; + } + } + + @override + void dispose() { + _replacementController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final colors = BusyMarkSurfaceColors.of(context); + final result = widget.result; final status = result.invalidRegex ? context.l10n.sourceSearchInvalidRegex : result.totalMatchCount == 0 @@ -1292,54 +1521,106 @@ class _SourceSearchPanel extends StatelessWidget { horizontal: BusyMarkSpacing.xs, vertical: BusyMarkSpacing.xxs, ), - child: Row( + child: Column( mainAxisSize: MainAxisSize.min, children: [ - Text( - status, - textDirection: result.invalidRegex - ? Directionality.of(context) - : TextDirection.ltr, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: result.invalidRegex - ? Theme.of(context).colorScheme.error - : colors.mutedForeground, - fontFeatures: const [FontFeature.tabularFigures()], - ), - ), - const SizedBox(width: BusyMarkSpacing.xs), - _SearchPanelIconButton( - tooltip: context.l10n.sourceSearchPreviousMatch, - icon: YaruIcons.pan_up, - onPressed: result.totalMatchCount == 0 ? null : onPrevious, - ), - _SearchPanelIconButton( - tooltip: context.l10n.sourceSearchNextMatch, - icon: YaruIcons.pan_down, - onPressed: result.totalMatchCount == 0 ? null : onNext, - ), - _SearchOptionButton( - label: 'Aa', - tooltip: context.l10n.sourceSearchCaseSensitive, - selected: result.options.caseSensitive, - onPressed: onToggleCaseSensitive, - ), - _SearchOptionButton( - label: 'W', - tooltip: context.l10n.sourceSearchWholeWord, - selected: result.options.wholeWord, - onPressed: onToggleWholeWord, - ), - _SearchOptionButton( - label: '.*', - tooltip: context.l10n.sourceSearchRegex, - selected: result.options.regex, - onPressed: onToggleRegex, + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + status, + textDirection: result.invalidRegex + ? Directionality.of(context) + : TextDirection.ltr, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: result.invalidRegex + ? Theme.of(context).colorScheme.error + : colors.mutedForeground, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + const SizedBox(width: BusyMarkSpacing.xs), + _SearchPanelIconButton( + tooltip: context.l10n.sourceSearchPreviousMatch, + icon: YaruIcons.pan_up, + onPressed: result.totalMatchCount == 0 + ? null + : widget.onPrevious, + ), + _SearchPanelIconButton( + tooltip: context.l10n.sourceSearchNextMatch, + icon: YaruIcons.pan_down, + onPressed: result.totalMatchCount == 0 ? null : widget.onNext, + ), + _SearchOptionButton( + label: 'Aa', + tooltip: context.l10n.sourceSearchCaseSensitive, + selected: result.options.caseSensitive, + onPressed: widget.onToggleCaseSensitive, + ), + _SearchOptionButton( + label: 'W', + tooltip: context.l10n.sourceSearchWholeWord, + selected: result.options.wholeWord, + onPressed: widget.onToggleWholeWord, + ), + _SearchOptionButton( + label: '.*', + tooltip: context.l10n.sourceSearchRegex, + selected: result.options.regex, + onPressed: widget.onToggleRegex, + ), + _SearchPanelIconButton( + tooltip: context.l10n.close, + icon: YaruIcons.window_close, + onPressed: widget.onClose, + ), + ], ), - _SearchPanelIconButton( - tooltip: context.l10n.close, - icon: YaruIcons.window_close, - onPressed: onClose, + const SizedBox(height: BusyMarkSpacing.xxs), + SizedBox( + width: 410, + height: 30, + child: Row( + children: [ + Expanded( + child: TextField( + key: const ValueKey('source-search-replacement'), + controller: _replacementController, + onChanged: widget.onReplacementChanged, + decoration: InputDecoration( + isDense: true, + hintText: context.l10n.sourceSearchReplacement, + contentPadding: const EdgeInsets.symmetric( + horizontal: BusyMarkSpacing.sm, + vertical: BusyMarkSpacing.xs, + ), + ), + ), + ), + _SearchPanelIconButton( + tooltip: context.l10n.sourceSearchReplaceCurrent, + icon: BusyMarkGlyphs.edit, + onPressed: result.totalMatchCount == 0 + ? null + : widget.onReplaceCurrent, + ), + _SearchPanelIconButton( + tooltip: context.l10n.sourceSearchReplaceAndFindNext, + icon: BusyMarkGlyphs.forwardFor(Directionality.of(context)), + onPressed: result.totalMatchCount == 0 + ? null + : widget.onReplaceAndFindNext, + ), + _SearchPanelIconButton( + tooltip: context.l10n.sourceSearchReplaceAll, + icon: BusyMarkGlyphs.searchUnavailable, + onPressed: result.totalMatchCount == 0 + ? null + : widget.onReplaceAll, + ), + ], + ), ), ], ), diff --git a/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart b/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart index 755786c9..b8454efd 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart @@ -147,6 +147,7 @@ class BusyMarkWysiwygBlockField extends StatelessWidget { required this.onTableRowDeleted, required this.onTableColumnInserted, required this.onTableColumnDeleted, + required this.onTableColumnAlignmentChanged, required this.onTableDeleted, required this.onImageEditRequested, required this.onHtmlEditRequested, @@ -180,6 +181,8 @@ class BusyMarkWysiwygBlockField extends StatelessWidget { final void Function(int columnIndex, {required bool after}) onTableColumnInserted; final ValueChanged onTableColumnDeleted; + final void Function(int columnIndex, BusyTableAlignment alignment) + onTableColumnAlignmentChanged; final VoidCallback onTableDeleted; final VoidCallback onImageEditRequested; final VoidCallback onHtmlEditRequested; @@ -328,6 +331,7 @@ class BusyMarkWysiwygBlockField extends StatelessWidget { onRowDeleted: onTableRowDeleted, onColumnInserted: onTableColumnInserted, onColumnDeleted: onTableColumnDeleted, + onColumnAlignmentChanged: onTableColumnAlignmentChanged, onTableDeleted: onTableDeleted, ), ); @@ -1234,6 +1238,7 @@ class _TableBlockEditor extends StatelessWidget { required this.onRowDeleted, required this.onColumnInserted, required this.onColumnDeleted, + required this.onColumnAlignmentChanged, required this.onTableDeleted, }); @@ -1246,6 +1251,8 @@ class _TableBlockEditor extends StatelessWidget { final ValueChanged onRowDeleted; final void Function(int columnIndex, {required bool after}) onColumnInserted; final ValueChanged onColumnDeleted; + final void Function(int columnIndex, BusyTableAlignment alignment) + onColumnAlignmentChanged; final VoidCallback onTableDeleted; @override @@ -1284,8 +1291,10 @@ class _TableBlockEditor extends StatelessWidget { for (var column = 0; column < columnCount; column++) _TableColumnControlCell( columnIndex: column, + alignment: _alignmentForColumn(rows, column), onInserted: onColumnInserted, onDeleted: onColumnDeleted, + onAlignmentChanged: onColumnAlignmentChanged, ), ], ), @@ -1333,9 +1342,31 @@ class _TableBlockEditor extends StatelessWidget { bool _isHeaderRow(BusyBlock row, int index) { return index == 0 || row.attributes['header'] == 'true'; } + + BusyTableAlignment _alignmentForColumn(List rows, int column) { + for (final row in rows) { + if (column < row.children.length) { + final alignment = busyTableAlignmentFromAttribute( + row.children[column].attributes['align'], + ); + if (alignment != BusyTableAlignment.unspecified) { + return alignment; + } + } + } + return BusyTableAlignment.unspecified; + } } -enum _TableControlAction { insertBefore, insertAfter, delete } +enum _TableControlAction { + insertBefore, + insertAfter, + alignUnspecified, + alignLeft, + alignCenter, + alignRight, + delete, +} class _TableCornerCell extends StatelessWidget { const _TableCornerCell({required this.onTableDeleted}); @@ -1361,13 +1392,18 @@ class _TableCornerCell extends StatelessWidget { class _TableColumnControlCell extends StatelessWidget { const _TableColumnControlCell({ required this.columnIndex, + required this.alignment, required this.onInserted, required this.onDeleted, + required this.onAlignmentChanged, }); final int columnIndex; + final BusyTableAlignment alignment; final void Function(int columnIndex, {required bool after}) onInserted; final ValueChanged onDeleted; + final void Function(int columnIndex, BusyTableAlignment alignment) + onAlignmentChanged; @override Widget build(BuildContext context) { @@ -1377,12 +1413,27 @@ class _TableColumnControlCell extends StatelessWidget { beforeLabel: context.l10n.insertColumnLeft, afterLabel: context.l10n.insertColumnRight, deleteLabel: context.l10n.deleteColumn, + alignment: alignment, + alignmentLabels: ( + unspecified: context.l10n.tableAlignmentUnspecified, + left: context.l10n.tableAlignmentLeft, + center: context.l10n.tableAlignmentCenter, + right: context.l10n.tableAlignmentRight, + ), onSelected: (action) { switch (action) { case _TableControlAction.insertBefore: onInserted(columnIndex, after: false); case _TableControlAction.insertAfter: onInserted(columnIndex, after: true); + case _TableControlAction.alignUnspecified: + onAlignmentChanged(columnIndex, BusyTableAlignment.unspecified); + case _TableControlAction.alignLeft: + onAlignmentChanged(columnIndex, BusyTableAlignment.left); + case _TableControlAction.alignCenter: + onAlignmentChanged(columnIndex, BusyTableAlignment.center); + case _TableControlAction.alignRight: + onAlignmentChanged(columnIndex, BusyTableAlignment.right); case _TableControlAction.delete: onDeleted(columnIndex); } @@ -1416,6 +1467,11 @@ class _TableRowControlCell extends StatelessWidget { onInserted(rowIndex, after: false); case _TableControlAction.insertAfter: onInserted(rowIndex, after: true); + case _TableControlAction.alignUnspecified || + _TableControlAction.alignLeft || + _TableControlAction.alignCenter || + _TableControlAction.alignRight: + return; case _TableControlAction.delete: onDeleted(rowIndex); } @@ -1432,6 +1488,8 @@ class _TableControlMenuButton extends StatelessWidget { required this.afterLabel, required this.deleteLabel, required this.onSelected, + this.alignment, + this.alignmentLabels, }); final String tooltip; @@ -1440,6 +1498,9 @@ class _TableControlMenuButton extends StatelessWidget { final String afterLabel; final String deleteLabel; final ValueChanged<_TableControlAction> onSelected; + final BusyTableAlignment? alignment; + final ({String unspecified, String left, String center, String right})? + alignmentLabels; @override Widget build(BuildContext context) { @@ -1455,6 +1516,28 @@ class _TableControlMenuButton extends StatelessWidget { value: _TableControlAction.insertAfter, label: afterLabel, ), + if (alignmentLabels case final labels?) ...[ + BusyMarkPopupMenuItem( + value: _TableControlAction.alignUnspecified, + label: labels.unspecified, + checked: alignment == BusyTableAlignment.unspecified, + ), + BusyMarkPopupMenuItem( + value: _TableControlAction.alignLeft, + label: labels.left, + checked: alignment == BusyTableAlignment.left, + ), + BusyMarkPopupMenuItem( + value: _TableControlAction.alignCenter, + label: labels.center, + checked: alignment == BusyTableAlignment.center, + ), + BusyMarkPopupMenuItem( + value: _TableControlAction.alignRight, + label: labels.right, + checked: alignment == BusyTableAlignment.right, + ), + ], BusyMarkPopupMenuItem( value: _TableControlAction.delete, label: deleteLabel, diff --git a/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart b/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart index 9bde3424..3370ee43 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart @@ -418,10 +418,14 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { ? 0 : rowIndex.clamp(0, rows.length - 1).toInt(); final insertIndex = rows.isEmpty ? 0 : safeRow + (after ? 1 : 0); + final alignments = [ + for (var column = 0; column < columnCount; column++) + _tableColumnAlignment(block, column), + ]; final nextRows = [...rows] ..insert( insertIndex.clamp(0, rows.length).toInt(), - _newTableRow(columnCount), + _newTableRow(columnCount, alignments: alignments), ); return block.copyWith( children: _normalizedTableRows(nextRows), @@ -525,6 +529,59 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { }); } + BusyTableAlignment tableColumnAlignment( + String tableBlockId, + int columnIndex, + ) { + final table = blockById(tableBlockId); + if (table == null || table.kind != BusyBlockKind.table) { + return BusyTableAlignment.unspecified; + } + return _tableColumnAlignment(table, columnIndex); + } + + void setTableColumnAlignment( + String tableBlockId, + int columnIndex, + BusyTableAlignment alignment, + ) { + _replaceBlock(tableBlockId, (block) { + if (block.kind != BusyBlockKind.table) { + return block; + } + final columnCount = _tableColumnCount(block); + final safeColumn = columnIndex.clamp(0, columnCount - 1).toInt(); + final attribute = busyTableAlignmentAttribute(alignment); + return block.copyWith( + children: [ + for (final row in block.children) + row.copyWith( + children: [ + for (final (index, cell) in _cellsPaddedTo( + row, + columnCount, + ).indexed) + if (index == safeColumn) + cell.copyWith( + attributes: { + for (final entry in cell.attributes.entries) + if (entry.key != 'align') entry.key: entry.value, + if (attribute != null) 'align': attribute, + }, + dirty: true, + ) + else + cell, + ], + dirty: true, + ), + ], + preserveRaw: false, + dirty: true, + ); + }); + } + void deleteTable(String tableBlockId) { final nextDocument = BusyDocument( filePath: _document.filePath, @@ -586,7 +643,15 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { _tableCellText(template, rowIndex, column) ?? (header ? headerTextForColumn(column + 1) : cellText), ), - attributes: {'cell': header ? 'th' : 'td'}, + attributes: { + 'cell': header ? 'th' : 'td', + if (template != null) + if (busyTableAlignmentAttribute( + _tableColumnAlignment(template, column), + ) + case final alignment?) + 'align': alignment, + }, dirty: true, ), ], @@ -608,26 +673,55 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { ); } - BusyBlock _newTableRow(int columns) { + BusyBlock _newTableRow( + int columns, { + List alignments = const [], + }) { return BusyBlock( id: _nextGeneratedBlockId('table-row'), kind: BusyBlockKind.table, children: [ - for (var column = 0; column < columns; column++) _newTableCell(), + for (var column = 0; column < columns; column++) + _newTableCell( + alignment: column < alignments.length + ? alignments[column] + : BusyTableAlignment.unspecified, + ), ], dirty: true, ); } - BusyBlock _newTableCell() { + BusyBlock _newTableCell({ + BusyTableAlignment alignment = BusyTableAlignment.unspecified, + }) { return BusyBlock( id: _nextGeneratedBlockId('table-cell'), kind: BusyBlockKind.paragraph, inlines: _textInlines(''), + attributes: { + if (busyTableAlignmentAttribute(alignment) case final value?) + 'align': value, + }, dirty: true, ); } + BusyTableAlignment _tableColumnAlignment(BusyBlock table, int column) { + for (final row in table.children) { + if (column >= row.children.length) { + continue; + } + final alignment = busyTableAlignmentFromAttribute( + row.children[column].attributes['align'], + ); + if (alignment != BusyTableAlignment.unspecified) { + return alignment; + } + } + return BusyTableAlignment.unspecified; + } + int _tableColumnCount(BusyBlock table) { var count = 1; for (final row in table.children) { diff --git a/lib/src/editor/wysiwyg/wysiwyg_editor.dart b/lib/src/editor/wysiwyg/wysiwyg_editor.dart index 4d2e3558..97a847b3 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_editor.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_editor.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'dart:math' as math; import 'package:file_selector/file_selector.dart'; @@ -7,9 +8,12 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:html/parser.dart' as html_parser; +import 'package:path/path.dart' as p; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import '../../ai/ai_models.dart'; +import '../../assets/asset_ingestion_service.dart'; +import '../../assets/asset_input_service.dart'; import '../../app/app_settings.dart'; import '../../app/busymark_dialogs.dart'; import '../../app/busymark_design.dart'; @@ -44,6 +48,10 @@ class BusyMarkWysiwygEditor extends StatefulWidget { this.workspaceRoot, this.writersideRoot, this.imagesDir = 'images', + this.assetWorkspaceKind, + this.assetIngestionService = const AssetIngestionService(), + this.assetInputService, + this.onAssetSaveRequired, this.allowRemoteImages = false, this.onRemoteImageBlocked, this.toolbarPlacement = EditorToolbarPlacement.topLeft, @@ -57,6 +65,8 @@ class BusyMarkWysiwygEditor extends StatefulWidget { this.onVisibleHeadingChanged, this.onOpenSearch, this.onCloseSearch, + this.onUndo, + this.onRedo, this.headerBarService, this.documentLayout, this.visualizationRevision = 0, @@ -69,6 +79,10 @@ class BusyMarkWysiwygEditor extends StatefulWidget { final String? workspaceRoot; final String? writersideRoot; final String imagesDir; + final AssetWorkspaceKind? assetWorkspaceKind; + final AssetIngestionService assetIngestionService; + final AssetInputService? assetInputService; + final VoidCallback? onAssetSaveRequired; final bool allowRemoteImages; final VoidCallback? onRemoteImageBlocked; final EditorToolbarPlacement toolbarPlacement; @@ -82,6 +96,8 @@ class BusyMarkWysiwygEditor extends StatefulWidget { final ValueChanged? onVisibleHeadingChanged; final VoidCallback? onOpenSearch; final VoidCallback? onCloseSearch; + final VoidCallback? onUndo; + final VoidCallback? onRedo; final LinuxHeaderBarService? headerBarService; final BusyMarkDocumentLayoutSpec? documentLayout; final int visualizationRevision; @@ -98,6 +114,7 @@ class _BusyMarkWysiwygEditorState extends State { final _textControllers = {}; final _textUndoControllers = {}; final _focusNodes = {}; + StreamSubscription>? _assetDropSubscription; final _blockKeys = {}; final _undoStack = []; final _redoStack = []; @@ -133,6 +150,7 @@ class _BusyMarkWysiwygEditorState extends State { _documentController = BusyMarkWysiwygDocumentController( document: widget.document, )..addListener(_handleDocumentControllerChanged); + _listenForDroppedAssets(); _itemPositionsListener.itemPositions.addListener( _handleVisibleItemsChanged, ); @@ -146,6 +164,10 @@ class _BusyMarkWysiwygEditorState extends State { @override void didUpdateWidget(covariant BusyMarkWysiwygEditor oldWidget) { super.didUpdateWidget(oldWidget); + if (oldWidget.assetInputService != widget.assetInputService) { + unawaited(_assetDropSubscription?.cancel()); + _listenForDroppedAssets(); + } final fileChanged = oldWidget.document.filePath != widget.document.filePath; final sourceChanged = oldWidget.document.source != widget.document.source; if (fileChanged || (sourceChanged && !_internalChange)) { @@ -173,6 +195,7 @@ class _BusyMarkWysiwygEditorState extends State { @override void dispose() { + unawaited(_assetDropSubscription?.cancel()); _itemPositionsListener.itemPositions.removeListener( _handleVisibleItemsChanged, ); @@ -375,13 +398,17 @@ class _BusyMarkWysiwygEditorState extends State { ), _UndoEditorIntent: CallbackAction<_UndoEditorIntent>( onInvoke: (intent) { - _undoEditorChange(); + if (!_undoEditorChange()) { + widget.onUndo?.call(); + } return null; }, ), _RedoEditorIntent: CallbackAction<_RedoEditorIntent>( onInvoke: (intent) { - _redoEditorChange(); + if (!_redoEditorChange()) { + widget.onRedo?.call(); + } return null; }, ), @@ -624,6 +651,8 @@ class _BusyMarkWysiwygEditorState extends State { _handleTableColumnInserted(block.id, columnIndex, after: after), onTableColumnDeleted: (columnIndex) => _handleTableColumnDeleted(block.id, columnIndex), + onTableColumnAlignmentChanged: (columnIndex, alignment) => + _handleTableColumnAlignmentChanged(block.id, columnIndex, alignment), onTableDeleted: () => _handleTableDeleted(block.id), onImageEditRequested: () => unawaited(_handleImageBlockEditRequested(block.id)), @@ -1063,6 +1092,22 @@ class _BusyMarkWysiwygEditorState extends State { _emitMarkdown(); } + void _handleTableColumnAlignmentChanged( + String tableBlockId, + int columnIndex, + BusyTableAlignment alignment, + ) { + _clearBlockSelection(); + _setActiveBlock(tableBlockId); + _recordUndoSnapshot(); + _documentController.setTableColumnAlignment( + tableBlockId, + columnIndex, + alignment, + ); + _emitMarkdown(); + } + void _handleTableDeleted(String tableBlockId) { _clearBlockSelection(); _recordUndoSnapshot(); @@ -2342,16 +2387,43 @@ class _BusyMarkWysiwygEditorState extends State { Future _pasteIntoActiveBlock() async { final data = await Clipboard.getData(Clipboard.kTextPlain); final text = data?.text; - if (text == null || text.isEmpty) { + if (text != null && text.isNotEmpty) { + final internalClipboard = _internalClipboard; + if (internalClipboard != null && + internalClipboard.text == text && + _pasteInternalClipboardIntoActiveBlock(internalClipboard)) { + return; + } + final filePath = _localFilePathFromClipboardText(text); + if (filePath != null && + await _ingestExternalImageFile( + filePath, + AssetIngestionOrigin.clipboardImageFile, + reportInvalidImage: false, + )) { + return; + } + await _pastePlainTextIntoActiveBlock(textOverride: text); return; } - final internalClipboard = _internalClipboard; - if (internalClipboard != null && - internalClipboard.text == text && - _pasteInternalClipboardIntoActiveBlock(internalClipboard)) { + final assetInput = widget.assetInputService ?? busyMarkAssetInputService; + final clipboardFiles = await assetInput.readClipboardImageFiles(); + if (clipboardFiles.isNotEmpty && + await _ingestExternalImageFile( + clipboardFiles.first, + AssetIngestionOrigin.clipboardImageFile, + )) { return; } - await _pastePlainTextIntoActiveBlock(textOverride: text); + final png = await assetInput.readClipboardImagePng(); + if (png != null && png.isNotEmpty) { + await _ingestExternalImageBytes( + png, + suggestedFileName: + 'screenshot-${DateTime.now().millisecondsSinceEpoch}.png', + origin: AssetIngestionOrigin.screenshotPaste, + ); + } } bool _pasteInternalClipboardIntoActiveBlock( @@ -2808,10 +2880,165 @@ class _BusyMarkWysiwygEditorState extends State { initialSource: initialSource, initialAlt: initialAlt, submitLabel: submitLabel, + ingestSelectedImage: _ingestSelectedImage, + onSaveRequired: widget.onAssetSaveRequired, ), ); } + Future _ingestSelectedImage(String sourcePath) async { + final asset = await widget.assetIngestionService.ingestFile( + sourcePath: sourcePath, + request: _assetIngestionRequest, + origin: AssetIngestionOrigin.imagePicker, + ); + return asset.markdownPath; + } + + AssetIngestionRequest get _assetIngestionRequest => AssetIngestionRequest( + documentFilePath: _documentController.document.filePath, + workspaceKind: + widget.assetWorkspaceKind ?? + (widget.writersideRoot != null + ? AssetWorkspaceKind.writerside + : widget.workspaceRoot != null + ? AssetWorkspaceKind.markdownWorkspace + : AssetWorkspaceKind.standalone), + workspaceRoot: widget.workspaceRoot, + writersideRoot: widget.writersideRoot, + imagesDir: widget.imagesDir, + ); + + void _listenForDroppedAssets() { + final input = widget.assetInputService ?? busyMarkAssetInputService; + _assetDropSubscription = input.droppedFiles.listen((paths) { + unawaited(_ingestDroppedAssetFiles(paths)); + }); + } + + Future _ingestDroppedAssetFiles(List paths) async { + for (final path in paths) { + if (!mounted) { + return; + } + await _ingestExternalImageFile(path, AssetIngestionOrigin.dragAndDrop); + } + } + + String? _localFilePathFromClipboardText(String value) { + final trimmed = value.trim(); + if (trimmed.contains('\n') || trimmed.contains('\r')) { + return null; + } + final uri = Uri.tryParse(trimmed); + final candidate = uri?.scheme == 'file' ? File.fromUri(uri!).path : trimmed; + return File(candidate).existsSync() ? candidate : null; + } + + Future _ingestExternalImageFile( + String sourcePath, + AssetIngestionOrigin origin, { + bool reportInvalidImage = true, + }) async { + try { + final asset = await widget.assetIngestionService.ingestFile( + sourcePath: sourcePath, + request: _assetIngestionRequest, + origin: origin, + ); + await _requestAltAndInsertAsset( + asset, + suggestedAlt: p.basenameWithoutExtension(sourcePath), + ); + return true; + } on AssetSaveRequiredException { + widget.onAssetSaveRequired?.call(); + return true; + } on AssetIngestionException catch (error) { + if (reportInvalidImage && mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(error.message))); + } + return false; + } on FileSystemException { + return false; + } + } + + Future _ingestExternalImageBytes( + Uint8List bytes, { + required String suggestedFileName, + required AssetIngestionOrigin origin, + }) async { + try { + final asset = await widget.assetIngestionService.ingestBytes( + bytes: bytes, + suggestedFileName: suggestedFileName, + request: _assetIngestionRequest, + origin: origin, + ); + await _requestAltAndInsertAsset(asset, suggestedAlt: 'Screenshot'); + return true; + } on AssetSaveRequiredException { + widget.onAssetSaveRequired?.call(); + return true; + } on AssetIngestionException catch (error) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(error.message))); + } + return false; + } + } + + Future _requestAltAndInsertAsset( + IngestedAsset asset, { + required String suggestedAlt, + }) async { + if (!mounted) { + return; + } + final fallbackAltText = context.l10n.image; + final target = _captureDialogTarget(); + final result = await _showImageDialog( + context, + title: context.l10n.image, + initialSource: asset.markdownPath, + initialAlt: suggestedAlt, + submitLabel: context.l10n.insert, + ); + if (!_isDialogTargetCurrent(target) || result == null) { + if (!asset.reusedExisting) { + try { + await File(asset.absolutePath).delete(); + } on FileSystemException { + // A cancelled dialog should not make the editor unusable. + } + } + return; + } + final blockId = _activeBlockId; + final controller = blockId == null ? null : _textControllers[blockId]; + if (blockId == null || controller == null) { + return; + } + final selection = controller.selection.isValid + ? controller.selection + : TextSelection.collapsed(offset: controller.text.length); + _recordUndoSnapshot(); + _documentController.insertInlineImage( + blockId, + selectionStart: selection.start, + selectionEnd: selection.end, + source: result.source, + alt: result.alt, + fallbackAltText: fallbackAltText, + ); + _emitMarkdown(); + } + Future<_TableDialogResult?> _showTableDialog( BuildContext context, { required int initialColumns, @@ -4488,12 +4715,16 @@ class _ImageDialog extends StatefulWidget { this.initialSource = '', this.initialAlt = '', required this.submitLabel, + required this.ingestSelectedImage, + this.onSaveRequired, }); final String title; final String initialSource; final String initialAlt; final String submitLabel; + final Future Function(String sourcePath) ingestSelectedImage; + final VoidCallback? onSaveRequired; @override State<_ImageDialog> createState() => _ImageDialogState(); @@ -4502,6 +4733,7 @@ class _ImageDialog extends StatefulWidget { class _ImageDialogState extends State<_ImageDialog> { final _sourceController = TextEditingController(); final _altController = TextEditingController(); + String? _errorMessage; @override void initState() { @@ -4552,6 +4784,21 @@ class _ImageDialogState extends State<_ImageDialog> { onPressed: _chooseImage, ), ), + if (_errorMessage case final message?) + Padding( + padding: const EdgeInsets.fromLTRB( + BusyMarkSpacing.md, + BusyMarkSpacing.xs, + BusyMarkSpacing.md, + BusyMarkSpacing.sm, + ), + child: Text( + message, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ), + ), BusyMarkGroupedTextEntry( key: BusyMarkImageDialogKeys.alt, label: context.l10n.altText, @@ -4587,9 +4834,26 @@ class _ImageDialogState extends State<_ImageDialog> { if (file == null || !mounted) { return; } - _sourceController.text = file.path; - if (_altController.text.trim().isEmpty) { - _altController.text = file.name; + try { + final markdownPath = await widget.ingestSelectedImage(file.path); + if (!mounted) { + return; + } + _sourceController.text = markdownPath; + if (_altController.text.trim().isEmpty) { + _altController.text = p.basenameWithoutExtension(file.name); + } + setState(() => _errorMessage = null); + } on AssetSaveRequiredException { + if (!mounted) { + return; + } + Navigator.pop(context); + widget.onSaveRequired?.call(); + } on AssetIngestionException catch (error) { + if (mounted) { + setState(() => _errorMessage = error.message); + } } } diff --git a/lib/src/markdown/busymark_document.dart b/lib/src/markdown/busymark_document.dart index 32fe5c41..85dd4c7c 100644 --- a/lib/src/markdown/busymark_document.dart +++ b/lib/src/markdown/busymark_document.dart @@ -5,6 +5,26 @@ import 'markdown_model.dart'; /// Marks an empty WYSIWYG paragraph that must remain a source blank line. const busyMarkPreserveEmptyParagraphAttribute = 'preserveEmptyParagraph'; +enum BusyTableAlignment { unspecified, left, center, right } + +BusyTableAlignment busyTableAlignmentFromAttribute(String? value) { + return switch (value?.toLowerCase()) { + 'left' => BusyTableAlignment.left, + 'center' => BusyTableAlignment.center, + 'right' => BusyTableAlignment.right, + _ => BusyTableAlignment.unspecified, + }; +} + +String? busyTableAlignmentAttribute(BusyTableAlignment alignment) { + return switch (alignment) { + BusyTableAlignment.unspecified => null, + BusyTableAlignment.left => 'left', + BusyTableAlignment.center => 'center', + BusyTableAlignment.right => 'right', + }; +} + class BusyDocument { const BusyDocument({ required this.filePath, diff --git a/lib/src/markdown/busymark_markdown_serializer.dart b/lib/src/markdown/busymark_markdown_serializer.dart index ba2e87b4..8c7c1841 100644 --- a/lib/src/markdown/busymark_markdown_serializer.dart +++ b/lib/src/markdown/busymark_markdown_serializer.dart @@ -281,13 +281,35 @@ class BusyMarkMarkdownSerializer { final body = rows.skip(1); final buffer = StringBuffer() ..writeln('| ${header.join(' | ')} |') - ..writeln('| ${header.map((_) => '---').join(' | ')} |'); + ..writeln( + '| ${[for (var column = 0; column < header.length; column++) _tableColumnDelimiter(block, column)].join(' | ')} |', + ); for (final row in body) { buffer.writeln('| ${row.children.map(_tableCellMarkdown).join(' | ')} |'); } return buffer.toString().trimRight(); } + String _tableColumnDelimiter(BusyBlock table, int column) { + final alignment = table.children + .where((row) => column < row.children.length) + .map( + (row) => busyTableAlignmentFromAttribute( + row.children[column].attributes['align'], + ), + ) + .firstWhere( + (value) => value != BusyTableAlignment.unspecified, + orElse: () => BusyTableAlignment.unspecified, + ); + return switch (alignment) { + BusyTableAlignment.unspecified => '---', + BusyTableAlignment.left => ':---', + BusyTableAlignment.center => ':---:', + BusyTableAlignment.right => '---:', + }; + } + String _tableCellMarkdown(BusyBlock cell) { return _inlineMarkdown( cell.inlines, diff --git a/lib/src/search/search_replace_service.dart b/lib/src/search/search_replace_service.dart new file mode 100644 index 00000000..b94496c6 --- /dev/null +++ b/lib/src/search/search_replace_service.dart @@ -0,0 +1,459 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import '../editor/source/source_document.dart'; +import '../editor/source/source_search.dart'; +import '../workspace/text_format_metadata.dart'; +import '../workspace/workspace_model.dart'; +import '../workspace/workspace_service.dart'; + +class TextReplacementMatch { + const TextReplacementMatch({ + required this.id, + required this.start, + required this.end, + required this.original, + required this.replacement, + }); + + final String id; + final int start; + final int end; + final String original; + final String replacement; +} + +class TextReplacementPreview { + const TextReplacementPreview({ + required this.source, + required this.options, + required this.replacement, + required this.matches, + this.invalidRegex = false, + }); + + final String source; + final SourceSearchOptions options; + final String replacement; + final List matches; + final bool invalidRegex; + + String apply({Set? selectedMatchIds}) { + var result = source; + for (final match in matches.reversed) { + if (selectedMatchIds != null && !selectedMatchIds.contains(match.id)) { + continue; + } + result = result.replaceRange(match.start, match.end, match.replacement); + } + return result; + } +} + +enum WorkspaceReplacementSourceKind { disk, dirtyBuffer } + +enum WorkspaceReplacementIssueKind { + oversized, + unreadable, + invalidUtf8, + truncated, + changedSincePreview, + bufferRevisionChanged, + normalizationRequired, +} + +class WorkspaceReplacementIssue { + const WorkspaceReplacementIssue({required this.kind, required this.filePath}); + + final WorkspaceReplacementIssueKind kind; + final String filePath; +} + +class WorkspaceReplacementFilePreview { + const WorkspaceReplacementFilePreview({ + required this.filePath, + required this.relativePath, + required this.sourceKind, + required this.originalText, + required this.matches, + required this.format, + this.bufferId, + this.bufferRevision, + this.diskSnapshot, + }); + + final String filePath; + final String relativePath; + final WorkspaceReplacementSourceKind sourceKind; + final String originalText; + final List matches; + final TextFormatMetadata format; + final String? bufferId; + final int? bufferRevision; + final WorkspaceFileSnapshot? diskSnapshot; +} + +class WorkspaceReplacementPreview { + const WorkspaceReplacementPreview({ + required this.options, + required this.replacement, + required this.files, + required this.issues, + }); + + final SourceSearchOptions options; + final String replacement; + final List files; + final List issues; + + int get matchCount => + files.fold(0, (total, file) => total + file.matches.length); +} + +class WorkspaceReplacementApplyResult { + const WorkspaceReplacementApplyResult({ + required this.appliedFiles, + required this.appliedMatches, + required this.issues, + }); + + final int appliedFiles; + final int appliedMatches; + final List issues; +} + +class SearchReplacementService { + const SearchReplacementService({ + this.maximumFileBytes = 1024 * 1024, + this.maximumMatches = 5000, + }); + + final int maximumFileBytes; + final int maximumMatches; + + TextReplacementPreview previewText({ + required String source, + required SourceSearchOptions options, + required String replacement, + String idPrefix = 'match', + }) { + final search = searchSourceDocument( + SourceDocument(fullText: source), + options, + ); + if (search.invalidRegex) { + return TextReplacementPreview( + source: source, + options: options, + replacement: replacement, + matches: const [], + invalidRegex: true, + ); + } + RegExp? expression; + if (options.regex) { + expression = RegExp( + options.query, + caseSensitive: options.caseSensitive, + multiLine: true, + ); + } + final matches = []; + for (final (index, match) in search.matches.indexed) { + final original = source.substring(match.fullStart, match.fullEnd); + var renderedReplacement = replacement; + if (expression != null) { + final regexMatch = expression.matchAsPrefix(source, match.fullStart); + if (regexMatch is RegExpMatch && regexMatch.end == match.fullEnd) { + renderedReplacement = _expandRegexReplacement( + replacement, + regexMatch, + ); + } + } + matches.add( + TextReplacementMatch( + id: '$idPrefix:$index:${match.fullStart}:${match.fullEnd}', + start: match.fullStart, + end: match.fullEnd, + original: original, + replacement: renderedReplacement, + ), + ); + } + return TextReplacementPreview( + source: source, + options: options, + replacement: replacement, + matches: List.unmodifiable(matches), + ); + } + + Future previewWorkspace({ + required WorkspaceState state, + required WorkspaceService workspaceService, + required SourceSearchOptions options, + required String replacement, + }) async { + final workspace = state.workspace; + if (workspace == null || options.query.isEmpty) { + return WorkspaceReplacementPreview( + options: options, + replacement: replacement, + files: const [], + issues: const [], + ); + } + final files = []; + final issues = []; + final candidates = { + for (final file in workspace.files) + if (_isReplaceableTextPath(file.absolutePath)) + file.absolutePath: file.relativePath, + for (final buffer in state.documentBuffers) + if (buffer.filePath case final path?) + path: p.relative(path, from: workspace.rootPath), + }; + var remaining = maximumMatches; + final sortedPaths = candidates.keys.toList()..sort(); + for (final path in sortedPaths) { + final buffer = state.bufferForPath(path); + late final String text; + late final TextFormatMetadata format; + WorkspaceFileSnapshot? snapshot; + if (buffer != null) { + text = buffer.text; + format = buffer.format; + snapshot = buffer.diskSnapshot; + } else { + final metadata = workspace.files + .where((file) => p.equals(file.absolutePath, path)) + .firstOrNull; + if (metadata != null && metadata.size > maximumFileBytes) { + issues.add( + WorkspaceReplacementIssue( + kind: WorkspaceReplacementIssueKind.oversized, + filePath: path, + ), + ); + continue; + } + try { + final loaded = await workspaceService.loadTextWithSnapshot(path); + text = loaded.text; + format = loaded.format; + snapshot = loaded.snapshot; + } on FormatException { + issues.add( + WorkspaceReplacementIssue( + kind: WorkspaceReplacementIssueKind.invalidUtf8, + filePath: path, + ), + ); + continue; + } on FileSystemException { + issues.add( + WorkspaceReplacementIssue( + kind: WorkspaceReplacementIssueKind.unreadable, + filePath: path, + ), + ); + continue; + } + } + final preview = previewText( + source: text, + options: options, + replacement: replacement, + idPrefix: path, + ); + if (preview.matches.isEmpty) { + continue; + } + final retained = preview.matches.take(remaining).toList(growable: false); + files.add( + WorkspaceReplacementFilePreview( + filePath: path, + relativePath: candidates[path]!, + sourceKind: buffer?.isDirty == true + ? WorkspaceReplacementSourceKind.dirtyBuffer + : WorkspaceReplacementSourceKind.disk, + originalText: text, + matches: retained, + format: format, + bufferId: buffer?.id, + bufferRevision: buffer?.revision, + diskSnapshot: snapshot, + ), + ); + remaining -= retained.length; + if (remaining <= 0) { + issues.add( + WorkspaceReplacementIssue( + kind: WorkspaceReplacementIssueKind.truncated, + filePath: path, + ), + ); + break; + } + } + return WorkspaceReplacementPreview( + options: options, + replacement: replacement, + files: List.unmodifiable(files), + issues: List.unmodifiable(issues), + ); + } + + Future applyWorkspace({ + required WorkspaceReplacementPreview preview, + required Set selectedMatchIds, + required WorkspaceState Function() currentState, + required void Function(String bufferId, String text) updateBuffer, + required WorkspaceService workspaceService, + Map mixedLineEndingNormalizations = + const {}, + }) async { + final issues = []; + var appliedFiles = 0; + var appliedMatches = 0; + for (final file in preview.files) { + final selected = { + for (final match in file.matches) + if (selectedMatchIds.contains(match.id)) match.id, + }; + if (selected.isEmpty) { + continue; + } + final currentBuffer = file.bufferId == null + ? null + : currentState().documentBuffers + .where((buffer) => buffer.id == file.bufferId) + .firstOrNull; + if (file.bufferId != null) { + if (currentBuffer == null || + currentBuffer.revision != file.bufferRevision || + currentBuffer.text != file.originalText) { + issues.add( + WorkspaceReplacementIssue( + kind: WorkspaceReplacementIssueKind.bufferRevisionChanged, + filePath: file.filePath, + ), + ); + continue; + } + } else if (await workspaceService.fileChangedSince( + file.filePath, + file.diskSnapshot, + )) { + issues.add( + WorkspaceReplacementIssue( + kind: WorkspaceReplacementIssueKind.changedSincePreview, + filePath: file.filePath, + ), + ); + continue; + } + final normalization = mixedLineEndingNormalizations[file.filePath]; + if (file.bufferId == null && + file.format.hasMixedLineEndings && + normalization == null) { + issues.add( + WorkspaceReplacementIssue( + kind: WorkspaceReplacementIssueKind.normalizationRequired, + filePath: file.filePath, + ), + ); + continue; + } + final textPreview = TextReplacementPreview( + source: file.originalText, + options: preview.options, + replacement: preview.replacement, + matches: file.matches, + ); + final nextText = textPreview.apply(selectedMatchIds: selected); + if (file.bufferId case final bufferId?) { + updateBuffer(bufferId, nextText); + } else { + await workspaceService.saveFormattedText( + file.filePath, + nextText, + format: file.format, + mixedNormalization: normalization, + ); + } + appliedFiles++; + appliedMatches += selected.length; + } + return WorkspaceReplacementApplyResult( + appliedFiles: appliedFiles, + appliedMatches: appliedMatches, + issues: List.unmodifiable(issues), + ); + } + + String _expandRegexReplacement(String replacement, RegExpMatch match) { + final output = StringBuffer(); + for (var index = 0; index < replacement.length; index++) { + final character = replacement[index]; + if (character != r'$' || index + 1 >= replacement.length) { + output.write(character); + continue; + } + final next = replacement[index + 1]; + if (next == r'$') { + output.write(r'$'); + index++; + continue; + } + if (next == '&') { + output.write(match.group(0) ?? ''); + index++; + continue; + } + if (next == '{') { + final close = replacement.indexOf('}', index + 2); + if (close > index + 2) { + final name = replacement.substring(index + 2, close); + try { + output.write(match.namedGroup(name) ?? ''); + index = close; + continue; + } on ArgumentError { + // Preserve an unknown capture reference literally. + } + } + } + if (_isDigit(next)) { + var end = index + 1; + while (end < replacement.length && + end < index + 3 && + _isDigit(replacement[end])) { + end++; + } + final group = int.parse(replacement.substring(index + 1, end)); + if (group <= match.groupCount) { + output.write(match.group(group) ?? ''); + index = end - 1; + continue; + } + } + output.write(r'$'); + } + return output.toString(); + } + + bool _isDigit(String value) { + final unit = value.codeUnitAt(0); + return unit >= 48 && unit <= 57; + } + + bool _isReplaceableTextPath(String path) { + return switch (p.extension(path).toLowerCase()) { + '.md' || '.markdown' || '.xml' || '.topic' || '.tree' || '.html' => true, + _ => false, + }; + } +} diff --git a/lib/src/workspace/document_buffer.dart b/lib/src/workspace/document_buffer.dart new file mode 100644 index 00000000..7d04d564 --- /dev/null +++ b/lib/src/workspace/document_buffer.dart @@ -0,0 +1,269 @@ +import 'dart:math' as math; + +import 'package:flutter/services.dart'; + +import '../app/app_settings.dart'; +import '../editor/source/source_search.dart'; +import 'text_format_metadata.dart'; +import 'workspace_file_snapshot.dart'; + +const Object _bufferUnset = Object(); + +enum DocumentDiskState { present, changed, deleted, conflict } + +class DocumentUndoState { + const DocumentUndoState({this.undo = const [], this.redo = const []}); + + static const historyLimit = 100; + + final List undo; + final List redo; + + DocumentUndoState push(String text) => DocumentUndoState( + undo: List.unmodifiable( + [...undo, text].skip(math.max(0, undo.length + 1 - historyLimit)), + ), + redo: const [], + ); + + DocumentUndoState afterUndo(String currentText) => DocumentUndoState( + undo: List.unmodifiable(undo.take(undo.length - 1)), + redo: List.unmodifiable( + [...redo, currentText].skip(math.max(0, redo.length + 1 - historyLimit)), + ), + ); + + DocumentUndoState afterRedo(String currentText) => DocumentUndoState( + undo: List.unmodifiable( + [...undo, currentText].skip(math.max(0, undo.length + 1 - historyLimit)), + ), + redo: List.unmodifiable(redo.take(redo.length - 1)), + ); +} + +class DocumentEditorState { + const DocumentEditorState({ + this.mode = DocumentViewModePreference.editor, + this.selection = const TextSelection.collapsed(offset: 0), + this.scrollOffset = 0, + this.foldedRegionKeys = const {}, + this.searchOptions = const SourceSearchOptions(), + this.searchReplacement = '', + this.searchCurrentMatchIndex, + this.undoState = const DocumentUndoState(), + }); + + final DocumentViewModePreference mode; + final TextSelection selection; + final double scrollOffset; + final Set foldedRegionKeys; + final SourceSearchOptions searchOptions; + final String searchReplacement; + final int? searchCurrentMatchIndex; + final DocumentUndoState undoState; + + DocumentEditorState copyWith({ + DocumentViewModePreference? mode, + TextSelection? selection, + double? scrollOffset, + Set? foldedRegionKeys, + SourceSearchOptions? searchOptions, + String? searchReplacement, + Object? searchCurrentMatchIndex = _bufferUnset, + DocumentUndoState? undoState, + }) { + return DocumentEditorState( + mode: mode ?? this.mode, + selection: selection ?? this.selection, + scrollOffset: scrollOffset ?? this.scrollOffset, + foldedRegionKeys: Set.unmodifiable( + foldedRegionKeys ?? this.foldedRegionKeys, + ), + searchOptions: searchOptions ?? this.searchOptions, + searchReplacement: searchReplacement ?? this.searchReplacement, + searchCurrentMatchIndex: identical(searchCurrentMatchIndex, _bufferUnset) + ? this.searchCurrentMatchIndex + : searchCurrentMatchIndex as int?, + undoState: undoState ?? this.undoState, + ); + } + + Map toJson() => { + 'mode': mode.name, + 'selectionBase': selection.baseOffset, + 'selectionExtent': selection.extentOffset, + 'scrollOffset': scrollOffset, + 'foldedRegionKeys': foldedRegionKeys.toList(), + 'searchQuery': searchOptions.query, + 'searchCaseSensitive': searchOptions.caseSensitive, + 'searchWholeWord': searchOptions.wholeWord, + 'searchRegex': searchOptions.regex, + 'searchReplacement': searchReplacement, + 'searchCurrentMatchIndex': searchCurrentMatchIndex, + }; + + factory DocumentEditorState.fromJson(Map json) { + return DocumentEditorState( + mode: DocumentViewModePreference.values.firstWhere( + (value) => value.name == json['mode'], + orElse: () => DocumentViewModePreference.editor, + ), + selection: TextSelection( + baseOffset: (json['selectionBase'] as num?)?.toInt() ?? 0, + extentOffset: (json['selectionExtent'] as num?)?.toInt() ?? 0, + ), + scrollOffset: (json['scrollOffset'] as num?)?.toDouble() ?? 0, + foldedRegionKeys: + (json['foldedRegionKeys'] as List?) + ?.map((value) => value.toString()) + .toSet() ?? + const {}, + searchOptions: SourceSearchOptions( + query: json['searchQuery']?.toString() ?? '', + caseSensitive: json['searchCaseSensitive'] as bool? ?? false, + wholeWord: json['searchWholeWord'] as bool? ?? false, + regex: json['searchRegex'] as bool? ?? false, + ), + searchReplacement: json['searchReplacement']?.toString() ?? '', + searchCurrentMatchIndex: (json['searchCurrentMatchIndex'] as num?) + ?.toInt(), + ); + } +} + +class DocumentBuffer { + const DocumentBuffer({ + required this.id, + required this.text, + required this.lastSavedText, + required this.dirty, + this.filePath, + this.untitledName, + this.diskSnapshot, + this.format = TextFormatMetadata.utf8Lf, + this.editorState = const DocumentEditorState(), + this.revision = 0, + this.diskState = DocumentDiskState.present, + this.diskVersionText, + this.diskVersionSnapshot, + this.recovered = false, + }); + + factory DocumentBuffer.file({ + required String id, + required String filePath, + required String text, + required WorkspaceFileSnapshot snapshot, + required TextFormatMetadata format, + DocumentViewModePreference mode = DocumentViewModePreference.editor, + }) { + return DocumentBuffer( + id: id, + filePath: filePath, + text: text, + lastSavedText: text, + dirty: false, + diskSnapshot: snapshot, + format: format, + editorState: DocumentEditorState(mode: mode), + ); + } + + factory DocumentBuffer.untitled({ + required String id, + required String name, + String text = '', + DocumentViewModePreference mode = DocumentViewModePreference.editor, + }) { + return DocumentBuffer( + id: id, + untitledName: name, + text: text, + lastSavedText: '', + dirty: true, + editorState: DocumentEditorState(mode: mode), + ); + } + + final String id; + final String? filePath; + final String? untitledName; + final String text; + final String lastSavedText; + final bool dirty; + final WorkspaceFileSnapshot? diskSnapshot; + final TextFormatMetadata format; + final DocumentEditorState editorState; + final int revision; + final DocumentDiskState diskState; + final String? diskVersionText; + final WorkspaceFileSnapshot? diskVersionSnapshot; + final bool recovered; + + bool get isUntitled => filePath == null; + bool get isDirty => dirty; + bool get hasConflict => diskState == DocumentDiskState.conflict; + bool get deletedOnDisk => diskState == DocumentDiskState.deleted; + String get identity => filePath ?? id; + String get displayName => untitledName ?? filePath?.split('/').last ?? id; + + DocumentBuffer edited(String nextText) { + if (nextText == text) { + return this; + } + return copyWith( + text: nextText, + dirty: nextText != lastSavedText || isUntitled, + format: isUntitled + ? format.copyWith(hasFinalNewline: nextText.endsWith('\n')) + : format, + revision: revision + 1, + editorState: editorState.copyWith( + undoState: editorState.undoState.push(text), + ), + ); + } + + DocumentBuffer copyWith({ + Object? filePath = _bufferUnset, + Object? untitledName = _bufferUnset, + String? text, + String? lastSavedText, + bool? dirty, + Object? diskSnapshot = _bufferUnset, + TextFormatMetadata? format, + DocumentEditorState? editorState, + int? revision, + DocumentDiskState? diskState, + Object? diskVersionText = _bufferUnset, + Object? diskVersionSnapshot = _bufferUnset, + bool? recovered, + }) { + return DocumentBuffer( + id: id, + filePath: identical(filePath, _bufferUnset) + ? this.filePath + : filePath as String?, + untitledName: identical(untitledName, _bufferUnset) + ? this.untitledName + : untitledName as String?, + text: text ?? this.text, + lastSavedText: lastSavedText ?? this.lastSavedText, + dirty: dirty ?? this.dirty, + diskSnapshot: identical(diskSnapshot, _bufferUnset) + ? this.diskSnapshot + : diskSnapshot as WorkspaceFileSnapshot?, + format: format ?? this.format, + editorState: editorState ?? this.editorState, + revision: revision ?? this.revision, + diskState: diskState ?? this.diskState, + diskVersionText: identical(diskVersionText, _bufferUnset) + ? this.diskVersionText + : diskVersionText as String?, + diskVersionSnapshot: identical(diskVersionSnapshot, _bufferUnset) + ? this.diskVersionSnapshot + : diskVersionSnapshot as WorkspaceFileSnapshot?, + recovered: recovered ?? this.recovered, + ); + } +} diff --git a/lib/src/workspace/presentation/settings_screen.dart b/lib/src/workspace/presentation/settings_screen.dart index 1d2eb1dc..df200c29 100644 --- a/lib/src/workspace/presentation/settings_screen.dart +++ b/lib/src/workspace/presentation/settings_screen.dart @@ -341,6 +341,8 @@ class _SettingsScreenState extends ConsumerState { _selectPage(SettingsPage.appearance); case BusyMarkMainMenuAction.keyboardShortcuts: showBusyMarkKeyboardShortcutsDialog(context); + case BusyMarkMainMenuAction.commandPalette: + return; case BusyMarkMainMenuAction.markdownAndHtml: showBusyMarkMarkdownHtmlDialog(context); case BusyMarkMainMenuAction.reportIssue: diff --git a/lib/src/workspace/presentation/welcome_screen.dart b/lib/src/workspace/presentation/welcome_screen.dart index a5874987..d6b8d57a 100644 --- a/lib/src/workspace/presentation/welcome_screen.dart +++ b/lib/src/workspace/presentation/welcome_screen.dart @@ -43,6 +43,7 @@ class _WelcomeScreenState extends ConsumerState { mimeTypes: ['text/markdown', 'text/x-markdown'], ); var _startupPathConsumed = false; + var _restoreAttempted = false; @override Widget build(BuildContext context) { @@ -67,6 +68,20 @@ class _WelcomeScreenState extends ConsumerState { unawaited(_openPath(startupPath)); } }); + } else if (!_restoreAttempted && + (startupPath == null || startupPath.isEmpty)) { + _restoreAttempted = true; + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted) { + return; + } + final restored = await ref + .read(workspaceControllerProvider.notifier) + .restorePreviousSession(); + if (restored && mounted) { + this.context.go('/workspace'); + } + }); } final welcomeMainColor = colors.view; @@ -272,6 +287,8 @@ class _WelcomeScreenState extends ConsumerState { context.go(settingsLocation(SettingsReturnTarget.welcome)); case BusyMarkMainMenuAction.keyboardShortcuts: showBusyMarkKeyboardShortcutsDialog(context); + case BusyMarkMainMenuAction.commandPalette: + return; case BusyMarkMainMenuAction.markdownAndHtml: showBusyMarkMarkdownHtmlDialog(context); case BusyMarkMainMenuAction.reportIssue: diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index f9d73ce7..c2e1be7c 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -14,6 +14,7 @@ import 'package:yaru/yaru.dart'; import '../../ai/ai_edit_ui.dart'; import '../../ai/ai_models.dart'; +import '../../assets/asset_ingestion_service.dart'; import '../../app/app_settings.dart'; import '../../app/app_router.dart'; import '../../app/busymark_dialogs.dart'; @@ -58,6 +59,7 @@ import '../../markdown/markdown_section_editor.dart'; import '../../markdown/markdown_toc_generator.dart'; import '../../markdown/preview_model.dart'; import '../../platform/linux_header_bar_service.dart'; +import '../../search/search_replace_service.dart'; import '../../visualization/visualization_card.dart'; import '../../visualization/visualization_models.dart'; import '../../writerside/writerside_model.dart'; @@ -65,6 +67,8 @@ import '../../writerside/writerside_toc_editor.dart'; import '../../writerside/writerside_topic_creator.dart'; import '../../writerside/writerside_topic_removal_service.dart'; import '../workspace_controller.dart'; +import '../document_buffer.dart'; +import '../text_format_metadata.dart'; import '../workspace_glyphs.dart'; import '../workspace_model.dart'; import '../workspace_message.dart'; @@ -146,6 +150,14 @@ class _WorkspaceSearchController extends Notifier<_WorkspaceSearchState> { _WorkspaceSearchState build() { _loadText = ref.read(workspaceServiceProvider).loadText; ref.listen(workspaceControllerProvider, (previous, next) { + if (previous?.activeBufferId != next.activeBufferId) { + final options = next.activeBuffer?.editorState.searchOptions; + if (options != null) { + state = state + .withOptions(options) + .copyWith(matches: const [], searching: false); + } + } refresh(next); }); ref.onDispose(() { @@ -167,6 +179,18 @@ class _WorkspaceSearchController extends Notifier<_WorkspaceSearchState> { matches: inputChanged ? const [] : state.matches, searching: false, ); + final buffer = ref.read(workspaceControllerProvider).activeBuffer; + if (buffer != null && + !_sameSourceSearchOptions( + buffer.editorState.searchOptions, + state.options, + )) { + ref + .read(workspaceControllerProvider.notifier) + .updateActiveEditorState( + buffer.editorState.copyWith(searchOptions: state.options), + ); + } _schedule(ref.read(workspaceControllerProvider)); } @@ -375,6 +399,16 @@ class _WorkspaceSearchState { } } +bool _sameSourceSearchOptions( + SourceSearchOptions first, + SourceSearchOptions second, +) { + return first.query == second.query && + first.caseSensitive == second.caseSensitive && + first.wholeWord == second.wholeWord && + first.regex == second.regex; +} + class _SearchNavigationTarget { const _SearchNavigationTarget({ required this.filePath, @@ -419,6 +453,8 @@ class WorkspaceScreen extends ConsumerWidget { return const WelcomeScreen(); } final searchState = ref.watch(_workspaceSearchProvider); + final documentViewMode = + state.activeBuffer?.editorState.mode ?? settings.documentViewMode; final gitState = ref.watch(gitControllerProvider); final searchResults = _workspaceSearchResults(context, searchState.matches); @@ -442,14 +478,14 @@ class WorkspaceScreen extends ConsumerWidget { final workspaceContent = Expanded( child: Column( children: [ - if (_shouldShowEditorTabs(workspace, gitState)) + if (_shouldShowEditorTabs(state, gitState)) _EditorTabStrip(state: state, gitState: gitState), Expanded( child: gitState.selectedDiffForDisplay == null ? _EditorPreviewSplit( state: state, outline: documentOutline, - viewMode: settings.documentViewMode, + viewMode: documentViewMode, editorFontSize: settings.editorFontSize, editorToolbarPlacement: settings.editorToolbarPlacement, editorToolbarDirection: settings.editorToolbarDirection, @@ -470,7 +506,7 @@ class WorkspaceScreen extends ConsumerWidget { : null, openFilePath: gitState.selectedDiffOpenFilePath, workspace: workspace, - viewMode: settings.documentViewMode, + viewMode: documentViewMode, hasUnsavedEditorChanges: state.isDirty, editorFontSize: settings.editorFontSize, onOpenFile: (relativePath) => @@ -682,12 +718,8 @@ class WorkspaceScreen extends ConsumerWidget { DocumentViewModePreference >( tooltip: context.l10n.viewMode, - icon: _documentViewModeIcon( - settings.documentViewMode, - ), - shortcut: _documentViewModeShortcut( - settings.documentViewMode, - ), + icon: _documentViewModeIcon(documentViewMode), + shortcut: _documentViewModeShortcut(documentViewMode), itemBuilder: (context) => [ for (final mode in DocumentViewModePreference.values) @@ -696,12 +728,18 @@ class WorkspaceScreen extends ConsumerWidget { label: _documentViewModeLabel(context, mode), icon: _documentViewModeIcon(mode), shortcut: _documentViewModeShortcut(mode), - checked: mode == settings.documentViewMode, + checked: mode == documentViewMode, trailingCheck: true, ), ], - onSelected: (mode) => + onSelected: (mode) { + ref + .read(workspaceControllerProvider.notifier) + .updateActiveEditorMode(mode); + unawaited( settingsController.setDocumentViewMode(mode), + ); + }, ), BusyMarkMainMenuButton( canExportPdf: canExportPdf, @@ -730,6 +768,8 @@ class WorkspaceScreen extends ConsumerWidget { children: workspaceChildren, ), ), + if (state.activeBuffer case final buffer?) + _DocumentStatusBar(buffer: buffer), ], ), ), @@ -860,24 +900,36 @@ class WorkspaceScreen extends ConsumerWidget { case HeaderBarAction.aboutBusyMark: showBusyMarkAboutDialog(context); case HeaderBarAction.viewModeEditor: + ref + .read(workspaceControllerProvider.notifier) + .updateActiveEditorMode(DocumentViewModePreference.editor); unawaited( settingsController.setDocumentViewMode( DocumentViewModePreference.editor, ), ); case HeaderBarAction.viewModeSource: + ref + .read(workspaceControllerProvider.notifier) + .updateActiveEditorMode(DocumentViewModePreference.source); unawaited( settingsController.setDocumentViewMode( DocumentViewModePreference.source, ), ); case HeaderBarAction.viewModePreview: + ref + .read(workspaceControllerProvider.notifier) + .updateActiveEditorMode(DocumentViewModePreference.preview); unawaited( settingsController.setDocumentViewMode( DocumentViewModePreference.preview, ), ); case HeaderBarAction.viewModeSplit: + ref + .read(workspaceControllerProvider.notifier) + .updateActiveEditorMode(DocumentViewModePreference.split); unawaited( settingsController.setDocumentViewMode( DocumentViewModePreference.split, @@ -914,6 +966,8 @@ class WorkspaceScreen extends ConsumerWidget { context.go(settingsLocation(SettingsReturnTarget.workspace)); case BusyMarkMainMenuAction.keyboardShortcuts: showBusyMarkKeyboardShortcutsDialog(context); + case BusyMarkMainMenuAction.commandPalette: + return; case BusyMarkMainMenuAction.markdownAndHtml: showBusyMarkMarkdownHtmlDialog(context); case BusyMarkMainMenuAction.reportIssue: @@ -1048,10 +1102,6 @@ class WorkspaceScreen extends ConsumerWidget { } final activePath = workspace.activeFilePath ?? workspace.markdown?.filePath; if (activePath != result.filePath) { - if (!await saveOrConfirmSafeToChangeActiveFile(context, ref) || - !context.mounted) { - return; - } await ref .read(workspaceControllerProvider.notifier) .openActiveFile(result.filePath); @@ -1150,11 +1200,11 @@ Future _openGitDiffFile( ); return; } - if (!await saveOrConfirmSafeToChangeActiveFile(context, ref) || - !context.mounted) { + final fileInWorkspace = workspaceFile != null; + if (!fileInWorkspace && + (!await confirmSafeToContinue(context, ref) || !context.mounted)) { return; } - final fileInWorkspace = workspaceFile != null; final controller = ref.read(workspaceControllerProvider.notifier); if (fileInWorkspace) { await controller.openActiveFile(absolutePath); @@ -1894,6 +1944,7 @@ class _SidebarState extends ConsumerState<_Sidebar> { results: widget.searchResults, searching: widget.searchState.searching, onOpenResult: widget.onOpenSearchResult, + onReviewReplacement: _reviewWorkspaceReplacement, ) : _topicUsageReview != null ? _WritersideTopicUsagesSidebar( @@ -1984,10 +2035,6 @@ class _SidebarState extends ConsumerState<_Sidebar> { Future _showFileHistory(DocumentFile file) async { if (widget.workspace.activeFilePath != file.absolutePath) { - if (!await saveOrConfirmSafeToChangeActiveFile(context, ref) || - !mounted) { - return; - } final opened = await ref .read(workspaceControllerProvider.notifier) .openActiveFile(file.absolutePath); @@ -2137,15 +2184,10 @@ class _SidebarState extends ConsumerState<_Sidebar> { BuildContext context, WritersideTopicUsage usage, ) async { - if (!await saveOrConfirmSafeToChangeActiveFile(context, ref) || - !mounted || - !context.mounted) { - return; - } final opened = await ref .read(workspaceControllerProvider.notifier) .openActiveFile(usage.filePath); - if (!opened || !mounted) { + if (!opened || !mounted || !context.mounted) { return; } ref @@ -2168,6 +2210,170 @@ class _SidebarState extends ConsumerState<_Sidebar> { } } + Future _reviewWorkspaceReplacement() async { + var replacement = ''; + final headerBar = ref.read(linuxHeaderBarServiceProvider); + final requested = await showBusyMarkModalEditorDialog( + context, + headerBarService: headerBar.isAvailable ? headerBar : null, + maxWidth: BusyMarkSizes.dialogCompact, + builder: (dialogContext) => BusyMarkModalEditorScaffold( + title: context.l10n.workspaceReplace, + cancelLabel: context.l10n.cancel, + saveLabel: context.l10n.reviewReplacements, + onCancel: () => Navigator.pop(dialogContext), + onSave: () => Navigator.pop(dialogContext, replacement), + children: [ + BusyMarkGroupedList( + filled: true, + children: [ + BusyMarkGroupedTextEntry( + label: context.l10n.sourceSearchReplacement, + autofocus: true, + onChanged: (value) => replacement = value, + onSubmitted: (value) => Navigator.pop(dialogContext, value), + ), + ], + ), + const SizedBox(height: BusyMarkSpacing.lg), + ], + ), + ); + if (requested == null || !mounted) { + return; + } + final service = const SearchReplacementService(); + final preview = await service.previewWorkspace( + state: ref.read(workspaceControllerProvider), + workspaceService: ref.read(workspaceServiceProvider), + options: widget.searchState.options, + replacement: requested, + ); + if (!mounted) { + return; + } + if (preview.files.isEmpty && preview.issues.isEmpty) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(context.l10n.noResults))); + return; + } + final selected = await showBusyMarkModalEditorDialog>( + context, + headerBarService: headerBar.isAvailable ? headerBar : null, + maxWidth: BusyMarkSizes.dialogWide, + maxHeight: 800, + builder: (dialogContext) => + _WorkspaceReplacementReviewDialog(preview: preview), + ); + if (selected == null || selected.isEmpty || !mounted) { + return; + } + final normalizations = {}; + final mixedFiles = preview.files.where( + (file) => + file.bufferId == null && + file.format.hasMixedLineEndings && + file.matches.any((match) => selected.contains(match.id)), + ); + for (final file in mixedFiles) { + final normalization = await _chooseReplacementLineEnding( + context, + file.relativePath, + ); + if (normalization == null || !mounted) { + return; + } + normalizations[file.filePath] = normalization; + } + final controller = ref.read(workspaceControllerProvider.notifier); + final result = await service.applyWorkspace( + preview: preview, + selectedMatchIds: selected, + currentState: () => ref.read(workspaceControllerProvider), + updateBuffer: controller.updateDocumentText, + workspaceService: ref.read(workspaceServiceProvider), + mixedLineEndingNormalizations: normalizations, + ); + await controller.refreshWorkspaceFromDiskPreservingOpenTabs(); + await controller.validateActive(); + await ref.read(gitControllerProvider.notifier).refresh(); + ref + .read(_workspaceSearchProvider.notifier) + .refresh(ref.read(workspaceControllerProvider)); + if (!mounted) { + return; + } + final summary = context.l10n.workspaceReplaceApplied( + result.appliedMatches, + result.appliedFiles, + result.issues.length, + ); + if (result.issues.isNotEmpty) { + await showBusyMarkModalDialog( + context, + headerBarService: headerBar.isAvailable ? headerBar : null, + builder: (dialogContext) => BusyMarkDialogShell( + title: context.l10n.workspaceReplace, + maxWidth: BusyMarkSizes.dialogWide, + actions: [ + BusyMarkDialogButton( + label: context.l10n.close, + onPressed: () => Navigator.pop(dialogContext), + ), + ], + children: [ + Text(summary), + const SizedBox(height: BusyMarkSpacing.md), + BusyMarkGroupedList( + title: context.l10n.skippedFiles, + filled: true, + children: [ + for (final issue in result.issues) + BusyMarkActionRow( + title: busyMarkLtrIsolateFor(context, issue.filePath), + subtitle: _workspaceReplacementIssueLabel(context, issue), + leading: const Icon(BusyMarkGlyphs.warning), + ), + ], + ), + ], + ), + ); + return; + } + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(summary))); + } + + Future _chooseReplacementLineEnding( + BuildContext context, + String fileName, + ) { + return showBusyMarkModalDialog( + context, + builder: (dialogContext) => BusyMarkDialogShell( + title: context.l10n.normalizeLineEndings, + actions: [ + BusyMarkPushButton.standard( + onPressed: () => + Navigator.pop(dialogContext, LineEndingNormalization.lf), + child: Text(LineEndingNormalization.lf.name.toUpperCase()), + ), + BusyMarkPushButton.suggested( + onPressed: () => + Navigator.pop(dialogContext, LineEndingNormalization.crlf), + child: Text(LineEndingNormalization.crlf.name.toUpperCase()), + ), + ], + children: [ + Text(context.l10n.workspaceReplaceMixedLineEndings(fileName)), + ], + ), + ); + } + int _initialSidebarTabIndex(Workspace workspace) { final tabs = _sidebarTabsFor(workspace.kind); final request = ref.read(_sidebarShortcutRequestProvider); @@ -3482,13 +3688,10 @@ class _FilesTabState extends ConsumerState<_FilesTab> { : openable ? () async { selectEntry(); - if (await saveOrConfirmSafeToChangeActiveFile( - context, - ref, - )) { - await ref - .read(workspaceControllerProvider.notifier) - .openActiveFile(file.absolutePath); + await ref + .read(workspaceControllerProvider.notifier) + .openActiveFile(file.absolutePath); + if (mounted) { _clearGitDetailSelection(ref); } } @@ -4794,14 +4997,6 @@ class _TocTabState extends ConsumerState<_TocTab> { if (modifiers.control || modifiers.shift) { return; } - final canOpen = - await saveOrConfirmSafeToChangeActiveFile( - context, - ref, - ); - if (!canOpen || !mounted || !context.mounted) { - return; - } await ref .read(workspaceControllerProvider.notifier) .openActiveFile(topicPath); @@ -7628,12 +7823,12 @@ class _SidebarEmptyState extends StatelessWidget { } } -bool _shouldShowEditorTabs(Workspace workspace, GitState gitState) { - return switch (workspace.kind) { - WorkspaceKind.markdownFolder || WorkspaceKind.writersideModule => true, - WorkspaceKind.untitledMarkdown || WorkspaceKind.singleMarkdown => false, - } && - (workspace.openFilePaths.isNotEmpty || +bool _shouldShowEditorTabs(WorkspaceState state, GitState gitState) { + final workspace = state.workspace!; + return (state.documentBuffers.length > 1 || + workspace.kind == WorkspaceKind.markdownFolder || + workspace.kind == WorkspaceKind.writersideModule) && + (state.documentBuffers.isNotEmpty || gitState.openDiffFilePaths.isNotEmpty || gitState.selectedDiffForDisplay != null); } @@ -7653,6 +7848,8 @@ class _EditorTabStrip extends ConsumerWidget { final entries = workspaceTabEntries( workspace: workspace, gitState: gitState, + documentBuffers: state.documentBuffers, + activeBufferId: state.activeBufferId, ); if (entries.isEmpty) { return const SizedBox.shrink(); @@ -7699,7 +7896,8 @@ class _EditorTabStrip extends ConsumerWidget { WorkspaceTabEntry entry, ) { return switch (entry.kind) { - WorkspaceTabKind.file => _relativeDocumentPath(workspace, entry.path), + WorkspaceTabKind.file => + entry.untitledName ?? _relativeDocumentPath(workspace, entry.path), WorkspaceTabKind.gitDiff => entry.path.isEmpty ? context.l10n.gitDiff : _diffTabTitle(entry.path), }; @@ -7709,14 +7907,14 @@ class _EditorTabStrip extends ConsumerWidget { if (entry.kind == WorkspaceTabKind.gitDiff) { return null; } - final file = _documentFileForPath(workspace, entry.path); + final file = entry.path.isEmpty + ? null + : _documentFileForPath(workspace, entry.path); return _documentKindIcon(file?.kind ?? DocumentKind.markdown); } bool _tabDirty(Workspace workspace, WorkspaceTabEntry entry) { - return entry.kind == WorkspaceTabKind.file && - entry.path == workspace.activeFilePath && - state.isDirty; + return entry.kind == WorkspaceTabKind.file && entry.dirty; } Future _selectTab( @@ -7728,17 +7926,13 @@ class _EditorTabStrip extends ConsumerWidget { final gitController = ref.read(gitControllerProvider.notifier); switch (entry.kind) { case WorkspaceTabKind.file: - if (entry.path == workspace.activeFilePath) { + if (entry.bufferId == state.activeBufferId) { gitController.deactivateDiffFile(); return; } - if (!await saveOrConfirmSafeToChangeActiveFile(context, ref) || - !context.mounted) { - return; - } await ref .read(workspaceControllerProvider.notifier) - .openActiveFile(entry.path); + .activateDocumentBuffer(entry.bufferId!); gitController.deactivateDiffFile(); case WorkspaceTabKind.gitDiff: if (entry.path.isEmpty) { @@ -7757,15 +7951,18 @@ class _EditorTabStrip extends ConsumerWidget { final gitController = ref.read(gitControllerProvider.notifier); switch (entry.kind) { case WorkspaceTabKind.file: - final currentWorkspaceFile = entry.path == workspace.activeFilePath; - if (currentWorkspaceFile && - (!await saveOrConfirmSafeToChangeActiveFile(context, ref) || - !context.mounted)) { - return; + final controller = ref.read(workspaceControllerProvider.notifier); + if (entry.dirty) { + if (entry.bufferId != state.activeBufferId) { + await controller.activateDocumentBuffer(entry.bufferId!); + } + if (!context.mounted || + !await confirmSafeToContinue(context, ref) || + !context.mounted) { + return; + } } - await ref - .read(workspaceControllerProvider.notifier) - .closeOpenFileTab(entry.path); + await controller.closeDocumentBuffer(entry.bufferId!); gitController.deactivateDiffFile(); case WorkspaceTabKind.gitDiff: if (entry.path.isEmpty) { @@ -9258,127 +9455,329 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { ); final activeEditorPath = _activeEditorPath(); final searchState = ref.watch(_workspaceSearchProvider); + final activeBuffer = widget.state.activeBuffer; return DecoratedBox( decoration: BoxDecoration(color: colors.view), - child: Row( + child: Column( children: [ - if (wysiwygVisible) - Expanded( - child: BusyMarkWysiwygEditor( - document: wysiwygDocument, - headerBarService: headerBar, - workspaceRoot: _imageWorkspaceRoot(widget.state.workspace), - writersideRoot: - widget.state.workspace?.writersideModule?.rootPath, - imagesDir: - widget - .state - .workspace - ?.writersideModule - ?.effectiveImagesDir ?? - 'images', - allowRemoteImages: allowRemoteImages, - onRemoteImageBlocked: () => - unawaited(_showRemoteImagesPrompt(context, ref)), - onDocumentChanged: _cacheWysiwygDocument, - onSourceChanged: _handleWysiwygSourceChanged, - toolbarPlacement: widget.editorToolbarPlacement, - toolbarDirection: widget.editorToolbarDirection, - onToolbarPlacementChanged: ref - .read(appSettingsControllerProvider.notifier) - .setEditorToolbarPlacement, - onToolbarDirectionChanged: ref - .read(appSettingsControllerProvider.notifier) - .setEditorToolbarDirection, - scrollToHeadingId: _wysiwygScrollHeadingId, - scrollToBlockId: _wysiwygScrollBlockId, - scrollToSearchQuery: _wysiwygSearchQuery, - scrollRequest: _wysiwygScrollRequest, - onVisibleHeadingChanged: _handleWysiwygVisibleHeadingChanged, - documentLayout: standaloneDocumentLayout, - visualizationRevision: ref - .read(workspaceControllerProvider.notifier) - .editRevision, - onOpenSearch: () => ref - .read(workspaceSearchOpenRequestProvider.notifier) - .request(), - onCloseSearch: () => ref - .read(workspaceSearchCloseRequestProvider.notifier) - .request(), - onAiEdit: - (_activeDocumentKind( - widget.state.workspace, - )?.supportsAiMarkdownEditing ?? - false) - ? (snapshot) => showBusyMarkAiEdit(context, ref, snapshot) - : null, - ), + if (activeBuffer != null && + activeBuffer.diskState != DocumentDiskState.present && + activeBuffer.diskState != DocumentDiskState.changed) + _ExternalFileBanner( + buffer: activeBuffer, + onCompare: activeBuffer.diskVersionText == null + ? null + : () => _showExternalFileComparison(context, activeBuffer), + onReload: activeBuffer.filePath == null + ? null + : () => unawaited( + ref + .read(workspaceControllerProvider.notifier) + .reloadBufferFromDisk(activeBuffer.id), + ), + onKeepMine: () => ref + .read(workspaceControllerProvider.notifier) + .keepBufferVersion(activeBuffer.id), + onSaveAs: () => unawaited(saveActiveToNewLocation(context, ref)), ), - if (sourceVisible) - Expanded( - child: BusyMarkSourceEditor( - key: _sourceEditorKey, - text: widget.state.activeText, - language: _sourceSyntaxLanguage(widget.state.workspace), - filePath: activeEditorPath, - diagnostics: - widget.state.workspace?.diagnostics ?? const [], - editorFontSize: widget.editorFontSize, - wordWrap: widget.wordWrap, - searchActive: searchState.active, - searchOptions: searchState.options, - onSearchOptionsChanged: (options) { - final current = ref.read(_workspaceSearchProvider); - ref - .read(_workspaceSearchProvider.notifier) - .set(current.withOptions(options)); - }, - onOpenSearch: () => ref - .read(workspaceSearchOpenRequestProvider.notifier) - .request(), - onCloseSearch: () => ref - .read(workspaceSearchCloseRequestProvider.notifier) - .request(), - onVisibleLineChanged: _handleSourceVisibleLineChanged, - onChanged: _handleSourceChanged, - editRevision: ref - .read(workspaceControllerProvider.notifier) - .editRevision, - onAiEdit: - (_activeDocumentKind( - widget.state.workspace, - )?.supportsAiMarkdownEditing ?? - false) - ? (snapshot) => showBusyMarkAiEdit(context, ref, snapshot) - : null, - ), + Expanded( + child: Row( + children: [ + if (wysiwygVisible) + Expanded( + child: BusyMarkWysiwygEditor( + document: wysiwygDocument, + headerBarService: headerBar, + workspaceRoot: _imageWorkspaceRoot( + widget.state.workspace, + ), + writersideRoot: + widget.state.workspace?.writersideModule?.rootPath, + imagesDir: + widget + .state + .workspace + ?.writersideModule + ?.effectiveImagesDir ?? + 'images', + assetWorkspaceKind: + switch (widget.state.workspace?.kind) { + WorkspaceKind.writersideModule => + AssetWorkspaceKind.writerside, + WorkspaceKind.markdownFolder => + AssetWorkspaceKind.markdownWorkspace, + WorkspaceKind.singleMarkdown => + AssetWorkspaceKind.standalone, + WorkspaceKind.untitledMarkdown || + null => AssetWorkspaceKind.standalone, + }, + onAssetSaveRequired: () => + unawaited(saveActiveToNewLocation(context, ref)), + allowRemoteImages: allowRemoteImages, + onRemoteImageBlocked: () => + unawaited(_showRemoteImagesPrompt(context, ref)), + onDocumentChanged: _cacheWysiwygDocument, + onSourceChanged: _handleWysiwygSourceChanged, + toolbarPlacement: widget.editorToolbarPlacement, + toolbarDirection: widget.editorToolbarDirection, + onToolbarPlacementChanged: ref + .read(appSettingsControllerProvider.notifier) + .setEditorToolbarPlacement, + onToolbarDirectionChanged: ref + .read(appSettingsControllerProvider.notifier) + .setEditorToolbarDirection, + scrollToHeadingId: _wysiwygScrollHeadingId, + scrollToBlockId: _wysiwygScrollBlockId, + scrollToSearchQuery: _wysiwygSearchQuery, + scrollRequest: _wysiwygScrollRequest, + onVisibleHeadingChanged: + _handleWysiwygVisibleHeadingChanged, + documentLayout: standaloneDocumentLayout, + visualizationRevision: ref + .read(workspaceControllerProvider.notifier) + .editRevision, + onOpenSearch: () => ref + .read(workspaceSearchOpenRequestProvider.notifier) + .request(), + onCloseSearch: () => ref + .read(workspaceSearchCloseRequestProvider.notifier) + .request(), + onUndo: () => ref + .read(workspaceControllerProvider.notifier) + .undoActiveBuffer(), + onRedo: () => ref + .read(workspaceControllerProvider.notifier) + .redoActiveBuffer(), + onAiEdit: + (_activeDocumentKind( + widget.state.workspace, + )?.supportsAiMarkdownEditing ?? + false) + ? (snapshot) => + showBusyMarkAiEdit(context, ref, snapshot) + : null, + ), + ), + if (sourceVisible) + Expanded( + child: BusyMarkSourceEditor( + key: _sourceEditorKey, + text: widget.state.activeText, + language: _sourceSyntaxLanguage(widget.state.workspace), + filePath: activeEditorPath, + documentId: activeBuffer?.id, + diagnostics: + widget.state.workspace?.diagnostics ?? + const [], + editorFontSize: widget.editorFontSize, + wordWrap: widget.wordWrap, + searchActive: searchState.active, + searchOptions: searchState.options, + searchReplacement: + activeBuffer?.editorState.searchReplacement ?? '', + onSearchReplacementChanged: activeBuffer == null + ? null + : (replacement) { + final latest = ref + .read(workspaceControllerProvider) + .documentBuffers + .where( + (candidate) => + candidate.id == activeBuffer.id, + ) + .firstOrNull; + if (latest != null) { + ref + .read(workspaceControllerProvider.notifier) + .updateDocumentEditorState( + latest.id, + latest.editorState.copyWith( + searchReplacement: replacement, + ), + ); + } + }, + onSearchOptionsChanged: (options) { + final current = ref.read(_workspaceSearchProvider); + ref + .read(_workspaceSearchProvider.notifier) + .set(current.withOptions(options)); + }, + initialSelection: activeBuffer?.editorState.selection, + initialScrollOffset: + activeBuffer?.editorState.scrollOffset ?? 0, + initialFoldedRegionKeys: + activeBuffer?.editorState.foldedRegionKeys ?? + const {}, + onSessionChanged: activeBuffer == null + ? null + : (selection, scrollOffset, foldedRegionKeys) { + final latest = ref + .read(workspaceControllerProvider) + .documentBuffers + .where( + (candidate) => + candidate.id == activeBuffer.id, + ) + .firstOrNull; + if (latest == null) { + return; + } + ref + .read(workspaceControllerProvider.notifier) + .updateDocumentEditorState( + latest.id, + latest.editorState.copyWith( + selection: selection, + scrollOffset: scrollOffset, + foldedRegionKeys: foldedRegionKeys, + ), + ); + }, + onOpenSearch: () => ref + .read(workspaceSearchOpenRequestProvider.notifier) + .request(), + onCloseSearch: () => ref + .read(workspaceSearchCloseRequestProvider.notifier) + .request(), + onVisibleLineChanged: _handleSourceVisibleLineChanged, + onChanged: _handleSourceChanged, + onUndo: () { + final controller = ref.read( + workspaceControllerProvider.notifier, + ); + return controller.undoActiveBuffer() + ? ref.read(workspaceControllerProvider).activeText + : null; + }, + onRedo: () { + final controller = ref.read( + workspaceControllerProvider.notifier, + ); + return controller.redoActiveBuffer() + ? ref.read(workspaceControllerProvider).activeText + : null; + }, + editRevision: ref + .read(workspaceControllerProvider.notifier) + .editRevision, + onAiEdit: + (_activeDocumentKind( + widget.state.workspace, + )?.supportsAiMarkdownEditing ?? + false) + ? (snapshot) => + showBusyMarkAiEdit(context, ref, snapshot) + : null, + ), + ), + if (sourceVisible && previewVisible) + VerticalDivider( + width: BusyMarkStroke.hairline, + color: colors.subtleBorder, + ), + if (previewVisible) + Expanded( + child: _PreviewPane( + preview: widget.state.preview, + workspace: widget.state.workspace, + activeSource: widget.state.activeText, + editRevision: ref + .read(workspaceControllerProvider.notifier) + .editRevision, + visualizationsEnabled: true, + onVisualizationDiagnostic: _openVisualizationSourceLine, + onEditVisualizationSource: _openVisualizationSourceLine, + controller: _previewScrollController, + itemPositionsListener: _previewItemPositionsListener, + onBlockContextAvailable: _rememberPreviewBlockContext, + onBlockContextUnavailable: _forgetPreviewBlockContext, + documentLayout: sourceVisible + ? BusyMarkDocumentLayoutSpec.splitPreview + : standaloneDocumentLayout, + ), + ), + ], ), - if (sourceVisible && previewVisible) - VerticalDivider( - width: BusyMarkStroke.hairline, - color: colors.subtleBorder, + ), + ], + ), + ); + } + + Future _showExternalFileComparison( + BuildContext context, + DocumentBuffer buffer, + ) async { + final diskText = buffer.diskVersionText; + if (diskText == null) { + return; + } + final path = buffer.filePath ?? buffer.displayName; + final diskLines = diskText.split('\n'); + final mineLines = buffer.text.split('\n'); + final lines = [ + for (final (index, line) in diskLines.indexed) + GitDiffLine( + kind: GitDiffLineKind.removed, + content: line, + oldLineNumber: index + 1, + ), + for (final (index, line) in mineLines.indexed) + GitDiffLine( + kind: GitDiffLineKind.added, + content: line, + newLineNumber: index + 1, + ), + ]; + final diff = GitDiff( + title: context.l10n.externalChangesTitle(p.basename(path)), + rawPatch: '', + hasBinaryFiles: false, + files: [ + GitDiffFile( + oldPath: path, + newPath: path, + status: GitDiffFileStatus.modified, + binary: false, + additions: mineLines.length, + deletions: diskLines.length, + hunks: [ + GitDiffHunk( + oldStart: 1, + oldCount: diskLines.length, + newStart: 1, + newCount: mineLines.length, + heading: '', + lines: lines, ), - if (previewVisible) - Expanded( - child: _PreviewPane( - preview: widget.state.preview, - workspace: widget.state.workspace, - activeSource: widget.state.activeText, - editRevision: ref - .read(workspaceControllerProvider.notifier) - .editRevision, - visualizationsEnabled: true, - onVisualizationDiagnostic: _openVisualizationSourceLine, - onEditVisualizationSource: _openVisualizationSourceLine, - controller: _previewScrollController, - itemPositionsListener: _previewItemPositionsListener, - onBlockContextAvailable: _rememberPreviewBlockContext, - onBlockContextUnavailable: _forgetPreviewBlockContext, - documentLayout: sourceVisible - ? BusyMarkDocumentLayoutSpec.splitPreview - : standaloneDocumentLayout, - ), + ], + ), + ], + ); + await showBusyMarkModalDialog( + context, + builder: (context) => BusyMarkDialogShell( + title: diff.title, + maxWidth: BusyMarkSizes.dialogWide, + actions: [ + BusyMarkDialogButton( + label: context.l10n.close, + onPressed: () => Navigator.pop(context), + ), + ], + children: [ + SizedBox( + height: MediaQuery.sizeOf(context).height * 0.65, + child: GitDiffViewer( + diff: diff, + hasUnsavedEditorChanges: false, + showHeader: false, + showFileActions: false, + onOpenFile: (_) {}, + onClose: () {}, ), + ), ], ), ); @@ -9858,6 +10257,116 @@ DocumentOutlineHeading? _outlineHeadingAtOrBeforeLine( return result; } +class _ExternalFileBanner extends StatelessWidget { + const _ExternalFileBanner({ + required this.buffer, + required this.onCompare, + required this.onReload, + required this.onKeepMine, + required this.onSaveAs, + }); + + final DocumentBuffer buffer; + final VoidCallback? onCompare; + final VoidCallback? onReload; + final VoidCallback onKeepMine; + final VoidCallback onSaveAs; + + @override + Widget build(BuildContext context) { + final colors = BusyMarkSurfaceColors.of(context); + final deleted = buffer.deletedOnDisk; + return DecoratedBox( + decoration: BoxDecoration( + color: colors.admonitionWarning, + border: Border(bottom: BorderSide(color: colors.subtleBorder)), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: BusyMarkSpacing.md, + vertical: BusyMarkSpacing.sm, + ), + child: Row( + children: [ + Icon( + deleted ? BusyMarkGlyphs.delete : BusyMarkGlyphs.warning, + size: BusyMarkSizes.iconSm, + ), + const SizedBox(width: BusyMarkSpacing.sm), + Expanded( + child: Text( + deleted + ? context.l10n.externalFileDeleted + : context.l10n.externalFileChanged, + ), + ), + if (onCompare != null) + BusyMarkPushButton.standard( + onPressed: onCompare, + child: Text(context.l10n.compare), + ), + if (onReload != null) ...[ + const SizedBox(width: BusyMarkSpacing.xs), + BusyMarkPushButton.standard( + onPressed: onReload, + child: Text(context.l10n.reloadFromDisk), + ), + ], + const SizedBox(width: BusyMarkSpacing.xs), + BusyMarkPushButton.standard( + onPressed: onKeepMine, + child: Text(context.l10n.keepMine), + ), + const SizedBox(width: BusyMarkSpacing.xs), + BusyMarkPushButton.standard( + onPressed: onSaveAs, + child: Text(context.l10n.saveAs), + ), + ], + ), + ), + ); + } +} + +class _DocumentStatusBar extends StatelessWidget { + const _DocumentStatusBar({required this.buffer}); + + final DocumentBuffer buffer; + + @override + Widget build(BuildContext context) { + final colors = BusyMarkSurfaceColors.of(context); + final format = buffer.format; + final labels = [ + 'UTF-8${format.hasUtf8Bom ? ' BOM' : ''}', + format.statusLabel, + format.hasFinalNewline ? 'Final newline' : 'No final newline', + ]; + return DecoratedBox( + decoration: BoxDecoration( + color: colors.headerbarFlat, + border: Border(top: BorderSide(color: colors.subtleBorder)), + ), + child: SizedBox( + height: BusyMarkSizes.paneHeaderHeight * 0.7, + child: Align( + alignment: AlignmentDirectional.centerEnd, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: BusyMarkSpacing.md), + child: Text( + labels.join(' • '), + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: colors.mutedForeground), + ), + ), + ), + ), + ); + } +} + class _PreviewPane extends StatelessWidget { const _PreviewPane({ required this.preview, @@ -11246,10 +11755,6 @@ Future _openPreviewLink( return; } if (workspace.activeFilePath != file.absolutePath) { - if (!await saveOrConfirmSafeToChangeActiveFile(context, ref) || - !context.mounted) { - return; - } await ref .read(workspaceControllerProvider.notifier) .openActiveFile(file.absolutePath); @@ -11459,12 +11964,14 @@ class _SearchSidebar extends StatelessWidget { required this.results, required this.searching, required this.onOpenResult, + required this.onReviewReplacement, }); final String query; final List<_WorkspaceSearchResult> results; final bool searching; final Future Function(_WorkspaceSearchResult result) onOpenResult; + final Future Function() onReviewReplacement; @override Widget build(BuildContext context) { @@ -11490,44 +11997,189 @@ class _SearchSidebar extends StatelessWidget { ); } final groups = _workspaceSearchFileGroups(results); - return ListView( - padding: BusyMarkInsets.sidebarList, + return Column( children: [ - for (final group in groups) ...[ - Padding( - padding: const EdgeInsets.fromLTRB( - BusyMarkSpacing.sm, - BusyMarkSpacing.sm, - BusyMarkSpacing.sm, - BusyMarkSpacing.xxs, + Padding( + padding: const EdgeInsets.fromLTRB( + BusyMarkSpacing.sm, + BusyMarkSpacing.sm, + BusyMarkSpacing.sm, + 0, + ), + child: SizedBox( + width: double.infinity, + child: BusyMarkPushButton.standard( + onPressed: () => unawaited(onReviewReplacement()), + child: Text(context.l10n.workspaceReplace), ), - child: Text( - busyMarkLtrIsolateFor(context, group.relativePath), - maxLines: 1, + ), + ), + Expanded( + child: ListView( + padding: BusyMarkInsets.sidebarList, + children: [ + for (final group in groups) ...[ + Padding( + padding: const EdgeInsets.fromLTRB( + BusyMarkSpacing.sm, + BusyMarkSpacing.sm, + BusyMarkSpacing.sm, + BusyMarkSpacing.xxs, + ), + child: Text( + busyMarkLtrIsolateFor(context, group.relativePath), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colors.mutedForeground, + fontWeight: FontWeight.w700, + letterSpacing: 0, + ), + ), + ), + for (final result in group.results) ...[ + _SearchResultRow( + result: result, + onOpen: () => onOpenResult(result), + ), + Divider( + height: BusyMarkStroke.hairline, + color: colors.subtleBorder, + ), + ], + ], + ], + ), + ), + ], + ); + } +} + +class _WorkspaceReplacementReviewDialog extends StatefulWidget { + const _WorkspaceReplacementReviewDialog({required this.preview}); + + final WorkspaceReplacementPreview preview; + + @override + State<_WorkspaceReplacementReviewDialog> createState() => + _WorkspaceReplacementReviewDialogState(); +} + +class _WorkspaceReplacementReviewDialogState + extends State<_WorkspaceReplacementReviewDialog> { + late final Set _selected = { + for (final file in widget.preview.files) + for (final match in file.matches) match.id, + }; + + @override + Widget build(BuildContext context) { + return BusyMarkModalEditorScaffold( + title: context.l10n.reviewReplacements, + cancelLabel: context.l10n.cancel, + saveLabel: context.l10n.applyReplacements, + onCancel: () => Navigator.pop(context), + onSave: _selected.isEmpty + ? null + : () => Navigator.pop(context, Set.unmodifiable(_selected)), + children: [ + for (final file in widget.preview.files) + _replacementFileGroup(context, file), + if (widget.preview.issues.isNotEmpty) + BusyMarkGroupedList( + title: context.l10n.skippedFiles, + filled: true, + children: [ + for (final issue in widget.preview.issues) + BusyMarkActionRow( + title: busyMarkLtrIsolateFor(context, issue.filePath), + subtitle: _workspaceReplacementIssueLabel(context, issue), + leading: const Icon(BusyMarkGlyphs.warning), + ), + ], + ), + const SizedBox(height: BusyMarkSpacing.lg), + ], + ); + } + + Widget _replacementFileGroup( + BuildContext context, + WorkspaceReplacementFilePreview file, + ) { + final ids = file.matches.map((match) => match.id).toSet(); + final selectedCount = ids.intersection(_selected).length; + return BusyMarkGroupedList( + title: busyMarkLtrIsolateFor(context, file.relativePath), + description: file.sourceKind == WorkspaceReplacementSourceKind.dirtyBuffer + ? context.l10n.workspaceReplaceDirtyBuffer + : context.l10n.workspaceReplaceDiskContent, + filled: true, + children: [ + CheckboxListTile( + value: selectedCount == 0 + ? false + : selectedCount == ids.length + ? true + : null, + tristate: true, + title: Text(context.l10n.selectFileMatches(file.matches.length)), + onChanged: (selected) { + setState(() { + if (selected == true) { + _selected.addAll(ids); + } else { + _selected.removeAll(ids); + } + }); + }, + ), + for (final match in file.matches) + CheckboxListTile( + value: _selected.contains(match.id), + title: Text( + '${match.original} → ${match.replacement}', + maxLines: 2, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colors.mutedForeground, - fontWeight: FontWeight.w700, - letterSpacing: 0, - ), ), + onChanged: (selected) { + setState(() { + if (selected == true) { + _selected.add(match.id); + } else { + _selected.remove(match.id); + } + }); + }, ), - for (final result in group.results) ...[ - _SearchResultRow( - result: result, - onOpen: () => onOpenResult(result), - ), - Divider( - height: BusyMarkStroke.hairline, - color: colors.subtleBorder, - ), - ], - ], ], ); } } +String _workspaceReplacementIssueLabel( + BuildContext context, + WorkspaceReplacementIssue issue, +) { + return switch (issue.kind) { + WorkspaceReplacementIssueKind.oversized => + context.l10n.workspaceReplaceIssueOversized, + WorkspaceReplacementIssueKind.unreadable => + context.l10n.workspaceReplaceIssueUnreadable, + WorkspaceReplacementIssueKind.invalidUtf8 => + context.l10n.workspaceReplaceIssueInvalidUtf8, + WorkspaceReplacementIssueKind.truncated => + context.l10n.workspaceReplaceIssueTruncated, + WorkspaceReplacementIssueKind.changedSincePreview => + context.l10n.workspaceReplaceIssueFileChanged, + WorkspaceReplacementIssueKind.bufferRevisionChanged => + context.l10n.workspaceReplaceIssueBufferChanged, + WorkspaceReplacementIssueKind.normalizationRequired => + context.l10n.workspaceReplaceIssueNormalizationRequired, + }; +} + class _SearchResultRow extends StatelessWidget { const _SearchResultRow({required this.result, required this.onOpen}); @@ -11976,23 +12628,24 @@ class _DiagnosticRow extends ConsumerWidget { child: InkWell( hoverColor: busyMarkRowHoverColor(context), onTap: () async { - if (await saveOrConfirmSafeToChangeActiveFile(context, ref)) { - await ref - .read(workspaceControllerProvider.notifier) - .openActiveFile(diagnostic.filePath); - final line = diagnostic.line; - if (line != null) { - ref - .read(_sourceNavigationTargetProvider.notifier) - .set( - _SourceNavigationTarget( - filePath: diagnostic.filePath, - line: line, - ), - ); - } - _clearGitDetailSelection(ref); + final opened = await ref + .read(workspaceControllerProvider.notifier) + .openActiveFile(diagnostic.filePath); + if (!opened || !context.mounted) { + return; } + final line = diagnostic.line; + if (line != null) { + ref + .read(_sourceNavigationTargetProvider.notifier) + .set( + _SourceNavigationTarget( + filePath: diagnostic.filePath, + line: line, + ), + ); + } + _clearGitDetailSelection(ref); }, child: Padding( padding: BusyMarkInsets.searchResultRow, diff --git a/lib/src/workspace/recovery_persistence.dart b/lib/src/workspace/recovery_persistence.dart new file mode 100644 index 00000000..755a7e08 --- /dev/null +++ b/lib/src/workspace/recovery_persistence.dart @@ -0,0 +1,220 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import 'document_buffer.dart'; +import 'session_persistence.dart'; +import 'text_format_metadata.dart'; +import 'workspace_file_snapshot.dart'; + +class DocumentRecoveryEntry { + const DocumentRecoveryEntry({ + required this.id, + required this.workspacePath, + required this.filePath, + required this.untitledName, + required this.text, + required this.lastSavedText, + required this.diskSnapshot, + required this.format, + required this.editorState, + required this.revision, + }); + + factory DocumentRecoveryEntry.fromBuffer( + DocumentBuffer buffer, { + required String? workspacePath, + }) { + return DocumentRecoveryEntry( + id: buffer.id, + workspacePath: workspacePath, + filePath: buffer.filePath, + untitledName: buffer.untitledName, + text: buffer.text, + lastSavedText: buffer.lastSavedText, + diskSnapshot: buffer.diskSnapshot, + format: buffer.format, + editorState: buffer.editorState, + revision: buffer.revision, + ); + } + + final String id; + final String? workspacePath; + final String? filePath; + final String? untitledName; + final String text; + final String lastSavedText; + final WorkspaceFileSnapshot? diskSnapshot; + final TextFormatMetadata format; + final DocumentEditorState editorState; + final int revision; + + Map toJson() => { + 'id': id, + 'workspacePath': workspacePath, + 'filePath': filePath, + 'untitledName': untitledName, + 'text': text, + 'lastSavedText': lastSavedText, + 'diskSnapshot': diskSnapshot?.toJson(), + 'format': format.toJson(), + 'editorState': editorState.toJson(), + 'revision': revision, + }; + + factory DocumentRecoveryEntry.fromJson(Map json) { + final snapshot = json['diskSnapshot']; + return DocumentRecoveryEntry( + id: json['id']?.toString() ?? '', + workspacePath: json['workspacePath']?.toString(), + filePath: json['filePath']?.toString(), + untitledName: json['untitledName']?.toString(), + text: json['text']?.toString() ?? '', + lastSavedText: json['lastSavedText']?.toString() ?? '', + diskSnapshot: snapshot is Map + ? WorkspaceFileSnapshot.fromJson(snapshot.cast()) + : null, + format: TextFormatMetadata.fromJson( + (json['format'] as Map?)?.cast() ?? const {}, + ), + editorState: DocumentEditorState.fromJson( + (json['editorState'] as Map?)?.cast() ?? const {}, + ), + revision: (json['revision'] as num?)?.toInt() ?? 0, + ); + } +} + +class RecoverySnapshot { + const RecoverySnapshot({required this.cleanShutdown, required this.entries}); + + final bool cleanShutdown; + final List entries; +} + +abstract interface class DocumentRecoveryStore { + Future beginRun(); + + Future writeEntries(List entries); + + Future markCleanShutdown(); + + Future clear(); +} + +class MemoryDocumentRecoveryStore implements DocumentRecoveryStore { + RecoverySnapshot value = const RecoverySnapshot( + cleanShutdown: true, + entries: [], + ); + + @override + Future beginRun() async { + final previous = value; + value = RecoverySnapshot(cleanShutdown: false, entries: previous.entries); + return previous; + } + + @override + Future writeEntries(List entries) async { + value = RecoverySnapshot(cleanShutdown: false, entries: entries); + } + + @override + Future markCleanShutdown() async { + value = RecoverySnapshot(cleanShutdown: true, entries: value.entries); + } + + @override + Future clear() async { + value = const RecoverySnapshot(cleanShutdown: true, entries: []); + } +} + +class JsonDocumentRecoveryStore implements DocumentRecoveryStore { + JsonDocumentRecoveryStore({this.filePathOverride}) + : _fallbackDirectory = Directory( + p.join( + Directory.systemTemp.path, + 'busymark-test-$pid-${DateTime.now().microsecondsSinceEpoch}', + ), + ); + + final String? filePathOverride; + final Directory _fallbackDirectory; + + @override + Future beginRun() async { + final current = await _load(); + await _write(cleanShutdown: false, entries: current.entries); + return current; + } + + @override + Future writeEntries(List entries) { + return _write(cleanShutdown: false, entries: entries); + } + + @override + Future markCleanShutdown() async { + final current = await _load(); + await _write(cleanShutdown: true, entries: current.entries); + } + + @override + Future clear() => _write(cleanShutdown: true, entries: const []); + + Future _load() async { + final file = await _file(); + if (!await file.exists()) { + return const RecoverySnapshot(cleanShutdown: true, entries: []); + } + try { + final decoded = (jsonDecode(await file.readAsString()) as Map) + .cast(); + return RecoverySnapshot( + cleanShutdown: decoded['cleanShutdown'] as bool? ?? false, + entries: + (decoded['entries'] as List?) + ?.whereType() + .map( + (entry) => DocumentRecoveryEntry.fromJson( + entry.cast(), + ), + ) + .where((entry) => entry.id.isNotEmpty) + .toList() ?? + const [], + ); + } on Object { + return const RecoverySnapshot(cleanShutdown: false, entries: []); + } + } + + Future _write({ + required bool cleanShutdown, + required List entries, + }) async { + await writeAtomicJson(await _file(), { + 'version': 1, + 'cleanShutdown': cleanShutdown, + 'entries': entries.map((entry) => entry.toJson()).toList(), + }); + } + + Future _file() async { + if (filePathOverride case final path?) { + return File(path); + } + late final Directory directory; + try { + directory = await getApplicationSupportDirectory(); + } on Object { + directory = _fallbackDirectory; + } + return File(p.join(directory.path, 'recovery.json')); + } +} diff --git a/lib/src/workspace/session_persistence.dart b/lib/src/workspace/session_persistence.dart new file mode 100644 index 00000000..5f8608ab --- /dev/null +++ b/lib/src/workspace/session_persistence.dart @@ -0,0 +1,187 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import 'document_buffer.dart'; + +class DocumentSessionEntry { + const DocumentSessionEntry({ + required this.id, + required this.filePath, + required this.untitledName, + required this.editorState, + }); + + final String id; + final String? filePath; + final String? untitledName; + final DocumentEditorState editorState; + + Map toJson() => { + 'id': id, + 'filePath': filePath, + 'untitledName': untitledName, + 'editorState': editorState.toJson(), + }; + + factory DocumentSessionEntry.fromJson(Map json) { + return DocumentSessionEntry( + id: json['id']?.toString() ?? '', + filePath: json['filePath']?.toString(), + untitledName: json['untitledName']?.toString(), + editorState: DocumentEditorState.fromJson( + (json['editorState'] as Map?)?.cast() ?? const {}, + ), + ); + } +} + +class WorkspaceSessionSnapshot { + const WorkspaceSessionSnapshot({ + required this.workspacePath, + required this.tabs, + required this.activeBufferId, + }); + + final String? workspacePath; + final List tabs; + final String? activeBufferId; + + Map toJson() => { + 'version': 1, + 'workspacePath': workspacePath, + 'activeBufferId': activeBufferId, + 'tabs': tabs.map((entry) => entry.toJson()).toList(), + }; + + factory WorkspaceSessionSnapshot.fromJson(Map json) { + return WorkspaceSessionSnapshot( + workspacePath: json['workspacePath']?.toString(), + tabs: + (json['tabs'] as List?) + ?.whereType() + .map( + (entry) => DocumentSessionEntry.fromJson( + entry.cast(), + ), + ) + .where((entry) => entry.id.isNotEmpty) + .toList() ?? + const [], + activeBufferId: json['activeBufferId']?.toString(), + ); + } +} + +abstract interface class DocumentSessionStore { + Future load(); + + Future save(WorkspaceSessionSnapshot snapshot); + + Future clear(); +} + +class MemoryDocumentSessionStore implements DocumentSessionStore { + WorkspaceSessionSnapshot? value; + + @override + Future load() async => value; + + @override + Future save(WorkspaceSessionSnapshot snapshot) async { + value = snapshot; + } + + @override + Future clear() async { + value = null; + } +} + +class JsonDocumentSessionStore implements DocumentSessionStore { + JsonDocumentSessionStore({this.filePathOverride}) + : _fallbackDirectory = Directory( + p.join( + Directory.systemTemp.path, + 'busymark-test-$pid-${DateTime.now().microsecondsSinceEpoch}', + ), + ); + + final String? filePathOverride; + final Directory _fallbackDirectory; + + @override + Future load() async { + final file = await _file(); + if (!await file.exists()) { + return null; + } + final source = await file.readAsString(); + if (source.trim().isEmpty) { + return null; + } + return WorkspaceSessionSnapshot.fromJson( + (jsonDecode(source) as Map).cast(), + ); + } + + @override + Future save(WorkspaceSessionSnapshot snapshot) async { + await _writeAtomic(await _file(), snapshot.toJson()); + } + + @override + Future clear() async { + final file = await _file(); + if (await file.exists()) { + await file.delete(); + } + } + + Future _file() async { + if (filePathOverride case final path?) { + return File(path); + } + late final Directory directory; + try { + directory = await getApplicationSupportDirectory(); + } on Object { + directory = _fallbackDirectory; + } + return File(p.join(directory.path, 'session.json')); + } +} + +Future writeAtomicJson(File target, Map json) { + return _writeAtomic(target, json); +} + +Future _writeAtomic(File target, Map json) async { + await target.parent.create(recursive: true); + final staging = await target.parent.createTemp('.busymark-state-'); + final staged = File(p.join(staging.path, p.basename(target.path))); + try { + await staged.writeAsString( + const JsonEncoder.withIndent(' ').convert(json), + flush: true, + ); + await staged.rename(target.path); + } finally { + try { + if (await staged.exists()) { + await staged.delete(); + } + } on Object { + // Cleanup must not hide the persistence result. + } + try { + if (await staging.exists()) { + await staging.delete(recursive: true); + } + } on Object { + // Cleanup must not hide the persistence result. + } + } +} diff --git a/lib/src/workspace/text_format_metadata.dart b/lib/src/workspace/text_format_metadata.dart new file mode 100644 index 00000000..1678f693 --- /dev/null +++ b/lib/src/workspace/text_format_metadata.dart @@ -0,0 +1,188 @@ +import 'dart:convert'; + +enum DocumentLineEnding { none, lf, crlf, mixed } + +enum LineEndingNormalization { lf, crlf } + +class MixedLineEndingNormalizationRequired implements Exception { + const MixedLineEndingNormalizationRequired(); + + @override + String toString() => + 'A document with mixed line endings must be normalized before saving.'; +} + +class TextFormatMetadata { + const TextFormatMetadata({ + required this.hasUtf8Bom, + required this.lineEnding, + required this.hasFinalNewline, + this.lfCount = 0, + this.crlfCount = 0, + this.crCount = 0, + }); + + static const utf8Lf = TextFormatMetadata( + hasUtf8Bom: false, + lineEnding: DocumentLineEnding.lf, + hasFinalNewline: false, + ); + + final bool hasUtf8Bom; + final DocumentLineEnding lineEnding; + final bool hasFinalNewline; + final int lfCount; + final int crlfCount; + final int crCount; + + bool get hasMixedLineEndings => lineEnding == DocumentLineEnding.mixed; + + TextFormatMetadata copyWith({ + bool? hasUtf8Bom, + DocumentLineEnding? lineEnding, + bool? hasFinalNewline, + int? lfCount, + int? crlfCount, + int? crCount, + }) { + return TextFormatMetadata( + hasUtf8Bom: hasUtf8Bom ?? this.hasUtf8Bom, + lineEnding: lineEnding ?? this.lineEnding, + hasFinalNewline: hasFinalNewline ?? this.hasFinalNewline, + lfCount: lfCount ?? this.lfCount, + crlfCount: crlfCount ?? this.crlfCount, + crCount: crCount ?? this.crCount, + ); + } + + String get statusLabel => switch (lineEnding) { + DocumentLineEnding.none || DocumentLineEnding.lf => 'LF', + DocumentLineEnding.crlf => 'CRLF', + DocumentLineEnding.mixed => 'Mixed', + }; + + TextFormatMetadata normalized(LineEndingNormalization normalization) { + return TextFormatMetadata( + hasUtf8Bom: hasUtf8Bom, + lineEnding: switch (normalization) { + LineEndingNormalization.lf => DocumentLineEnding.lf, + LineEndingNormalization.crlf => DocumentLineEnding.crlf, + }, + hasFinalNewline: hasFinalNewline, + lfCount: normalization == LineEndingNormalization.lf + ? lfCount + crlfCount + crCount + : 0, + crlfCount: normalization == LineEndingNormalization.crlf + ? lfCount + crlfCount + crCount + : 0, + ); + } + + List encode( + String canonicalText, { + LineEndingNormalization? mixedNormalization, + }) { + return utf8.encode( + formattedText(canonicalText, mixedNormalization: mixedNormalization), + ); + } + + String formattedText( + String canonicalText, { + LineEndingNormalization? mixedNormalization, + }) { + var effective = this; + if (hasMixedLineEndings) { + if (mixedNormalization == null) { + throw const MixedLineEndingNormalizationRequired(); + } + effective = normalized(mixedNormalization); + } + var text = canonicalText.replaceAll('\r\n', '\n').replaceAll('\r', '\n'); + if (effective.hasFinalNewline) { + if (!text.endsWith('\n')) { + text = '$text\n'; + } + } else { + text = text.replaceFirst(RegExp(r'\n+$'), ''); + } + if (effective.lineEnding == DocumentLineEnding.crlf) { + text = text.replaceAll('\n', '\r\n'); + } + return effective.hasUtf8Bom ? '\uFEFF$text' : text; + } + + Map toJson() => { + 'hasUtf8Bom': hasUtf8Bom, + 'lineEnding': lineEnding.name, + 'hasFinalNewline': hasFinalNewline, + 'lfCount': lfCount, + 'crlfCount': crlfCount, + 'crCount': crCount, + }; + + factory TextFormatMetadata.fromJson(Map json) { + return TextFormatMetadata( + hasUtf8Bom: json['hasUtf8Bom'] as bool? ?? false, + lineEnding: DocumentLineEnding.values.firstWhere( + (value) => value.name == json['lineEnding'], + orElse: () => DocumentLineEnding.lf, + ), + hasFinalNewline: json['hasFinalNewline'] as bool? ?? false, + lfCount: (json['lfCount'] as num?)?.toInt() ?? 0, + crlfCount: (json['crlfCount'] as num?)?.toInt() ?? 0, + crCount: (json['crCount'] as num?)?.toInt() ?? 0, + ); + } +} + +class DecodedUtf8Document { + const DecodedUtf8Document({required this.text, required this.format}); + + final String text; + final TextFormatMetadata format; +} + +DecodedUtf8Document decodeUtf8Document(List bytes) { + final hasBom = + bytes.length >= 3 && + bytes[0] == 0xef && + bytes[1] == 0xbb && + bytes[2] == 0xbf; + final decoded = utf8.decode(hasBom ? bytes.sublist(3) : bytes); + var lf = 0; + var crlf = 0; + var cr = 0; + for (var index = 0; index < decoded.length; index++) { + final unit = decoded.codeUnitAt(index); + if (unit == 13) { + if (index + 1 < decoded.length && decoded.codeUnitAt(index + 1) == 10) { + crlf++; + index++; + } else { + cr++; + } + } else if (unit == 10) { + lf++; + } + } + final styles = [if (lf > 0) 'lf', if (crlf > 0) 'crlf', if (cr > 0) 'cr']; + final lineEnding = styles.isEmpty + ? DocumentLineEnding.none + : styles.length > 1 || cr > 0 + ? DocumentLineEnding.mixed + : crlf > 0 + ? DocumentLineEnding.crlf + : DocumentLineEnding.lf; + return DecodedUtf8Document( + text: decoded.replaceAll('\r\n', '\n').replaceAll('\r', '\n'), + format: TextFormatMetadata( + hasUtf8Bom: hasBom, + lineEnding: lineEnding, + hasFinalNewline: decoded.endsWith('\n') || decoded.endsWith('\r'), + lfCount: lf, + crlfCount: crlf, + crCount: cr, + ), + ); +} diff --git a/lib/src/workspace/workspace_controller.dart b/lib/src/workspace/workspace_controller.dart index b331f1f9..4afe626d 100644 --- a/lib/src/workspace/workspace_controller.dart +++ b/lib/src/workspace/workspace_controller.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'dart:math' as math; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -15,14 +16,41 @@ import '../writerside/writerside_instance_service.dart'; import '../writerside/writerside_topic_removal_service.dart'; import '../writerside/writerside_topic_creator.dart'; import '../writerside/writerside_toc_editor.dart'; +import 'document_buffer.dart'; +import 'recovery_persistence.dart'; +import 'session_persistence.dart'; +import 'text_format_metadata.dart'; import 'workspace_model.dart'; import 'workspace_message.dart'; +import 'workspace_file_monitor.dart'; import 'workspace_service.dart'; final workspaceServiceProvider = Provider( (ref) => const WorkspaceService(), ); +final documentSessionStoreProvider = Provider( + (ref) => _runningUnderFlutterTest + ? MemoryDocumentSessionStore() + : JsonDocumentSessionStore(), +); + +final documentRecoveryStoreProvider = Provider( + (ref) => _runningUnderFlutterTest + ? MemoryDocumentRecoveryStore() + : JsonDocumentRecoveryStore(), +); + +final _runningUnderFlutterTest = Platform.environment.containsKey( + 'FLUTTER_TEST', +); + +final workspaceFileMonitorProvider = Provider((ref) { + final monitor = WorkspaceFileMonitor(); + ref.onDispose(() => unawaited(monitor.dispose())); + return monitor; +}); + final workspaceControllerProvider = NotifierProvider( WorkspaceController.new, @@ -48,26 +76,49 @@ final workspaceSearchCloseRequestProvider = class ActiveDocumentSaveTarget { const ActiveDocumentSaveTarget._({ required this.workspaceId, + required this.bufferId, required this.path, required this.documentRevision, required this.editRevision, required this.snapshot, required this.text, required this.workspaceKind, + required this.format, }); final String workspaceId; + final String bufferId; final String? path; final int documentRevision; final int editRevision; final WorkspaceFileSnapshot? snapshot; final String text; final WorkspaceKind workspaceKind; + final TextFormatMetadata format; bool get needsSaveLocation => workspaceKind == WorkspaceKind.untitledMarkdown || path == null; } +class SaveAllResult { + const SaveAllResult({ + this.savedBufferIds = const [], + this.failedBufferIds = const [], + this.conflictBufferIds = const [], + this.normalizationRequiredBufferIds = const [], + }); + + final List savedBufferIds; + final List failedBufferIds; + final List conflictBufferIds; + final List normalizationRequiredBufferIds; + + bool get succeeded => + failedBufferIds.isEmpty && + conflictBufferIds.isEmpty && + normalizationRequiredBufferIds.isEmpty; +} + class WorkspaceSearchRequestController extends Notifier { @override int build() => 0; @@ -82,7 +133,13 @@ class WorkspaceController extends Notifier { late WorkspaceService _service; late AppSettingsController _settingsController; + late DocumentSessionStore _sessionStore; + late DocumentRecoveryStore _recoveryStore; + late WorkspaceFileMonitor _fileMonitor; + StreamSubscription? _fileMonitorSubscription; Timer? _autoSaveDebounce; + Timer? _persistenceDebounce; + Timer? _workspaceRefreshDebounce; Future? _activeSave; ActiveDocumentSaveTarget? _activeSaveTarget; var _derivedRefreshRunning = false; @@ -90,34 +147,445 @@ class WorkspaceController extends Notifier { var _pendingPreviewRefresh = false; var _editRevision = 0; var _activeDocumentRevision = 0; + var _untitledSequence = 0; + final _intentionallyRemovedPaths = {}; + late Future _recoveryStart; + Future _persistenceWrites = Future.value(); - int get editRevision => _editRevision; + int get editRevision => state.activeBuffer?.revision ?? _editRevision; @override WorkspaceState build() { _service = ref.read(workspaceServiceProvider); _settingsController = ref.read(appSettingsControllerProvider.notifier); + _sessionStore = ref.read(documentSessionStoreProvider); + _recoveryStore = ref.read(documentRecoveryStoreProvider); + _fileMonitor = ref.read(workspaceFileMonitorProvider); + _fileMonitorSubscription = _fileMonitor.events.listen( + (event) => unawaited(_handleFileMonitorEvent(event)), + ); + _recoveryStart = _recoveryStore.beginRun(); ref.listen(appSettingsControllerProvider, (previous, next) { if (!next.autoSave) { _autoSaveDebounce?.cancel(); return; } - if (state.isDirty) { + if (state.dirtyBuffers.any((buffer) => buffer.filePath != null)) { _scheduleAutoSave(); } }); ref.onDispose(() { _cancelPendingDerivedRefresh(); _autoSaveDebounce?.cancel(); + _persistenceDebounce?.cancel(); + _workspaceRefreshDebounce?.cancel(); + unawaited(_fileMonitorSubscription?.cancel()); }); return const WorkspaceState(); } + Future restorePreviousSession() async { + if (state.workspace != null) { + return state.documentBuffers.isNotEmpty; + } + final recovery = await _recoveryStart; + final session = await _sessionStore.load(); + final recoverEntries = recovery.cleanShutdown + ? const [] + : recovery.entries; + if (session == null && recoverEntries.isEmpty) { + return false; + } + final workspacePath = + session?.workspacePath ?? + recoverEntries + .map((entry) => entry.workspacePath) + .whereType() + .firstOrNull ?? + recoverEntries + .map((entry) => entry.filePath) + .whereType() + .firstOrNull; + try { + final workspace = workspacePath == null + ? _service.createUntitledMarkdown() + : await _service.openPath(workspacePath); + final recoveryById = { + for (final entry in recoverEntries) entry.id: entry, + }; + final recoveryByPath = { + for (final entry in recoverEntries) + if (entry.filePath != null) entry.filePath!: entry, + }; + final buffers = []; + final sessionEntries = session?.tabs ?? const []; + for (final entry in sessionEntries) { + final recovered = + recoveryById[entry.id] ?? + (entry.filePath == null ? null : recoveryByPath[entry.filePath]); + final buffer = await _restoreSessionBuffer(entry, recovered); + if (buffer != null) { + buffers.add(buffer); + } + } + for (final recovered in recoverEntries) { + if (buffers.any((buffer) => buffer.id == recovered.id)) { + continue; + } + final buffer = await _restoreRecoveryBuffer(recovered); + if (buffer != null) { + buffers.add(buffer); + } + } + if (buffers.isEmpty) { + return false; + } + final activeId = + buffers.any((buffer) => buffer.id == session?.activeBufferId) + ? session!.activeBufferId! + : buffers.first.id; + final active = buffers.firstWhere((buffer) => buffer.id == activeId); + final openPaths = [ + for (final buffer in buffers) + if (buffer.filePath != null) buffer.filePath!, + ]; + final nextWorkspace = workspace.copyWith( + activeFilePath: active.filePath, + activeFileSnapshot: active.diskSnapshot, + openFilePaths: openPaths, + ); + final reparsed = await _service.reparseActive(nextWorkspace, active.text); + state = WorkspaceState( + workspace: reparsed, + preview: _safePreview(reparsed, active.text), + documentBuffers: buffers, + activeBufferId: active.id, + ); + _editRevision = active.revision; + await _startMonitoring(reparsed); + await _settingsController.setDocumentViewMode(active.editorState.mode); + _schedulePersistence(); + return true; + } on Object catch (error, stackTrace) { + busyMarkDebugLogError( + '[BusyMark] Session restore failed', + error, + stackTrace, + ); + return false; + } + } + + Future _restoreSessionBuffer( + DocumentSessionEntry session, + DocumentRecoveryEntry? recovery, + ) async { + if (recovery != null) { + return _restoreRecoveryBuffer(recovery, editorState: session.editorState); + } + final path = session.filePath; + if (path == null || !await _service.pathExists(path)) { + return null; + } + final load = await _service.loadTextWithSnapshot(path); + return _fileBuffer(path, load).copyWith(editorState: session.editorState); + } + + Future _restoreRecoveryBuffer( + DocumentRecoveryEntry recovery, { + DocumentEditorState? editorState, + }) async { + final path = recovery.filePath; + if (path == null) { + return DocumentBuffer.untitled( + id: recovery.id, + name: recovery.untitledName ?? 'Untitled', + text: recovery.text, + mode: (editorState ?? recovery.editorState).mode, + ).copyWith( + editorState: editorState ?? recovery.editorState, + format: recovery.format, + revision: recovery.revision, + recovered: true, + ); + } + if (!await _service.pathExists(path)) { + return DocumentBuffer( + id: recovery.id, + filePath: path, + text: recovery.text, + lastSavedText: recovery.lastSavedText, + dirty: true, + diskSnapshot: recovery.diskSnapshot, + format: recovery.format, + editorState: editorState ?? recovery.editorState, + revision: recovery.revision, + diskState: DocumentDiskState.deleted, + recovered: true, + ); + } + final disk = await _service.loadTextWithSnapshot(path); + final conflict = + recovery.diskSnapshot == null || + disk.snapshot.differsFrom(recovery.diskSnapshot!); + return DocumentBuffer( + id: recovery.id, + filePath: path, + text: recovery.text, + lastSavedText: recovery.lastSavedText, + dirty: true, + diskSnapshot: recovery.diskSnapshot, + format: recovery.format, + editorState: editorState ?? recovery.editorState, + revision: recovery.revision, + diskState: conflict + ? DocumentDiskState.conflict + : DocumentDiskState.present, + diskVersionText: conflict ? disk.text : null, + diskVersionSnapshot: conflict ? disk.snapshot : null, + recovered: true, + ); + } + + void _schedulePersistence() { + _persistenceDebounce?.cancel(); + if (_runningUnderFlutterTest) { + unawaited(flushPersistence()); + return; + } + _persistenceDebounce = Timer( + const Duration(milliseconds: 700), + () => unawaited(flushPersistence()), + ); + } + + Future flushPersistence() { + _persistenceDebounce?.cancel(); + final snapshot = state; + final prior = _persistenceWrites.then((_) {}, onError: (_, _) {}); + final write = prior.then((_) => _persistSnapshot(snapshot)); + _persistenceWrites = write; + return write; + } + + Future _persistSnapshot(WorkspaceState snapshot) async { + await _recoveryStart; + final workspace = snapshot.workspace; + if (workspace == null) { + await _sessionStore.clear(); + await _recoveryStore.writeEntries(const []); + return; + } + final workspacePath = switch (workspace.kind) { + WorkspaceKind.untitledMarkdown => null, + WorkspaceKind.singleMarkdown => + snapshot.documentBuffers.firstOrNull?.filePath ?? workspace.rootPath, + WorkspaceKind.markdownFolder || + WorkspaceKind.writersideModule => workspace.rootPath, + }; + await _sessionStore.save( + WorkspaceSessionSnapshot( + workspacePath: workspacePath, + activeBufferId: snapshot.activeBufferId, + tabs: [ + for (final buffer in snapshot.documentBuffers) + DocumentSessionEntry( + id: buffer.id, + filePath: buffer.filePath, + untitledName: buffer.untitledName, + editorState: buffer.editorState, + ), + ], + ), + ); + await _recoveryStore.writeEntries([ + for (final buffer in snapshot.documentBuffers) + if (buffer.isDirty || buffer.isUntitled) + DocumentRecoveryEntry.fromBuffer( + buffer, + workspacePath: workspacePath, + ), + ]); + } + + Future markCleanShutdown() async { + await flushPersistence(); + await _recoveryStore.markCleanShutdown(); + } + + Future discardRecoveryForShutdown() async { + await flushPersistence(); + await _recoveryStore.clear(); + } + + Future _startMonitoring(Workspace workspace) async { + if (workspace.kind == WorkspaceKind.untitledMarkdown || + workspace.rootPath.isEmpty || + !Directory(workspace.rootPath).existsSync()) { + await _fileMonitor.stop(); + return; + } + await _fileMonitor.start( + rootPath: workspace.rootPath, + openFilePaths: state.documentBuffers + .map((buffer) => buffer.filePath) + .whereType(), + ); + } + + Future _handleFileMonitorEvent(WorkspaceFileMonitorEvent event) async { + final matching = state.documentBuffers.where((buffer) { + final path = buffer.filePath; + return path != null && + (p.equals(path, event.path) || + (event.destinationPath != null && + p.equals(path, event.destinationPath!))); + }).toList(); + for (final buffer in matching) { + await _applyExternalFileState(buffer, event); + } + _workspaceRefreshDebounce?.cancel(); + _workspaceRefreshDebounce = Timer( + const Duration(milliseconds: 250), + () => unawaited(refreshWorkspaceFromDiskPreservingOpenTabs()), + ); + } + + Future _applyExternalFileState( + DocumentBuffer original, + WorkspaceFileMonitorEvent event, + ) async { + final current = state.documentBuffers + .where((buffer) => buffer.id == original.id) + .firstOrNull; + final path = current?.filePath; + if (current == null || path == null) { + return; + } + if (event.kind == WorkspaceFileEventKind.deleted && + !await _service.pathExists(path)) { + _updateBufferFromMonitor( + current.copyWith(diskState: DocumentDiskState.deleted), + ); + return; + } + try { + final disk = await _service.loadTextWithSnapshot(path); + if (_sameFileSnapshot(current.diskSnapshot, disk.snapshot)) { + return; + } + if (current.isDirty) { + _updateBufferFromMonitor( + current.copyWith( + diskState: DocumentDiskState.conflict, + diskVersionText: disk.text, + diskVersionSnapshot: disk.snapshot, + ), + ); + return; + } + final reloaded = current.copyWith( + text: disk.text, + lastSavedText: disk.text, + dirty: false, + diskSnapshot: disk.snapshot, + format: disk.format, + revision: current.revision + 1, + diskState: DocumentDiskState.present, + diskVersionText: null, + diskVersionSnapshot: null, + ); + _updateBufferFromMonitor(reloaded); + if (state.activeBufferId == reloaded.id && state.workspace != null) { + final workspace = state.workspace!.copyWith( + activeFileSnapshot: disk.snapshot, + ); + final reparsed = await _service.reparseActive(workspace, disk.text); + if (state.activeBufferId == reloaded.id && + state.activeBuffer?.revision == reloaded.revision) { + state = state.copyWith( + workspace: reparsed, + preview: _safePreview(reparsed, disk.text), + ); + } + } + } on FileSystemException { + _updateBufferFromMonitor( + current.copyWith(diskState: DocumentDiskState.deleted), + ); + } on FormatException { + // Invalid UTF-8 remains on disk and must not replace an editable buffer. + } + } + + void _updateBufferFromMonitor(DocumentBuffer buffer) { + state = state.copyWith( + documentBuffers: _replaceBuffer(state.documentBuffers, buffer), + workspace: state.activeBufferId == buffer.id + ? state.workspace?.copyWith(activeFileSnapshot: buffer.diskSnapshot) + : state.workspace, + ); + _schedulePersistence(); + } + + Future reloadBufferFromDisk(String bufferId) async { + final buffer = state.documentBuffers + .where((candidate) => candidate.id == bufferId) + .firstOrNull; + final path = buffer?.filePath; + if (buffer == null || path == null || !await _service.pathExists(path)) { + return false; + } + final disk = await _service.loadTextWithSnapshot(path); + final reloaded = buffer.copyWith( + text: disk.text, + lastSavedText: disk.text, + dirty: false, + diskSnapshot: disk.snapshot, + format: disk.format, + revision: buffer.revision + 1, + diskState: DocumentDiskState.present, + diskVersionText: null, + diskVersionSnapshot: null, + recovered: false, + ); + _updateBufferFromMonitor(reloaded); + if (state.activeBufferId == bufferId && state.workspace != null) { + final workspace = await _service.reparseActive( + state.workspace!.copyWith(activeFileSnapshot: disk.snapshot), + disk.text, + ); + state = state.copyWith( + workspace: workspace, + preview: _safePreview(workspace, disk.text), + ); + } + return true; + } + + void keepBufferVersion(String bufferId) { + final buffer = state.documentBuffers + .where((candidate) => candidate.id == bufferId) + .firstOrNull; + if (buffer == null) { + return; + } + final snapshot = buffer.diskVersionSnapshot ?? buffer.diskSnapshot; + _updateBufferFromMonitor( + buffer.copyWith( + diskSnapshot: snapshot, + diskState: buffer.filePath == null + ? DocumentDiskState.present + : DocumentDiskState.changed, + diskVersionText: null, + diskVersionSnapshot: null, + ), + ); + } + bool get activeDocumentNeedsSaveLocation { final workspace = state.workspace; - return workspace != null && - (workspace.kind == WorkspaceKind.untitledMarkdown || - workspace.activeFilePath == null); + return workspace != null && state.activeBuffer?.filePath == null; } ActiveDocumentSaveTarget? captureActiveDocumentSaveTarget() { @@ -125,14 +593,20 @@ class WorkspaceController extends Notifier { if (workspace == null) { return null; } + final buffer = state.activeBuffer; + if (buffer == null) { + return null; + } return ActiveDocumentSaveTarget._( workspaceId: workspace.id, - path: workspace.activeFilePath, + bufferId: buffer.id, + path: buffer.filePath, documentRevision: _activeDocumentRevision, - editRevision: _editRevision, - snapshot: workspace.activeFileSnapshot, - text: state.activeText, + editRevision: buffer.revision, + snapshot: buffer.diskSnapshot, + text: buffer.text, workspaceKind: workspace.kind, + format: buffer.format, ); } @@ -144,9 +618,10 @@ class WorkspaceController extends Notifier { activeFilePath: target.path, ) && workspace != null && - _editRevision == target.editRevision && - state.activeText == target.text && - _sameFileSnapshot(workspace.activeFileSnapshot, target.snapshot); + state.activeBuffer?.id == target.bufferId && + state.activeBuffer?.revision == target.editRevision && + state.activeBuffer?.text == target.text && + _sameFileSnapshot(state.activeBuffer?.diskSnapshot, target.snapshot); } Future createMarkdownFile() async { @@ -155,13 +630,34 @@ class WorkspaceController extends Notifier { _invalidateActiveDocumentOperations(); _resetSaveTracking(dirty: true); final viewModeChange = _showEditorForNewFile(); - final workspace = _service.createUntitledMarkdown(); + final currentWorkspace = state.workspace; + final untitledWorkspace = _service.createUntitledMarkdown(); + final workspace = currentWorkspace == null + ? untitledWorkspace + : currentWorkspace.copyWith( + activeFilePath: null, + activeFileSnapshot: null, + markdown: untitledWorkspace.markdown, + ); + final sequence = ++_untitledSequence; + final buffer = DocumentBuffer.untitled( + id: 'untitled:${DateTime.now().microsecondsSinceEpoch}:$sequence', + name: 'Untitled $sequence', + mode: + _settingsController.state.documentViewMode == + DocumentViewModePreference.preview + ? DocumentViewModePreference.editor + : _settingsController.state.documentViewMode, + ); state = WorkspaceState( workspace: workspace, preview: _safePreview(workspace, ''), - isDirty: true, + documentBuffers: [...state.documentBuffers, buffer], + activeBufferId: buffer.id, isLoading: false, ); + await _startMonitoring(workspace); + _schedulePersistence(); await viewModeChange; } @@ -185,11 +681,22 @@ class WorkspaceController extends Notifier { if (!_isCurrentActiveDocumentOperation(operationRevision)) { return; } + final buffer = load == null || active == null + ? null + : _fileBuffer( + active, + load, + mode: _settingsController.state.documentViewMode, + ); state = WorkspaceState( workspace: loadedWorkspace, activeText: text, preview: preview, + documentBuffers: buffer == null ? const [] : [buffer], + activeBufferId: buffer?.id, ); + await _startMonitoring(loadedWorkspace); + _schedulePersistence(); _resetSaveTracking(); final recentPath = workspace.kind == WorkspaceKind.singleMarkdown ? workspace.activeFilePath ?? workspace.rootPath @@ -239,11 +746,22 @@ class WorkspaceController extends Notifier { if (!_isCurrentActiveDocumentOperation(operationRevision)) { return false; } + final buffer = load == null || active == null + ? null + : _fileBuffer( + active, + load, + mode: _settingsController.state.documentViewMode, + ); state = WorkspaceState( workspace: loadedWorkspace, activeText: text, preview: preview, + documentBuffers: buffer == null ? const [] : [buffer], + activeBufferId: buffer?.id, ); + await _startMonitoring(loadedWorkspace); + _schedulePersistence(); _resetSaveTracking(); await _settingsController.recordOpenedWorkspace( path: workspace.rootPath, @@ -311,11 +829,30 @@ class WorkspaceController extends Notifier { if (!_isCurrentActiveDocumentOperation(operationRevision)) { return false; } + final existing = active == null ? null : state.bufferForPath(active); + final buffer = + existing ?? + (load == null || active == null + ? null + : _fileBuffer( + active, + load, + mode: _settingsController.state.documentViewMode, + )); + final buffers = buffer == null + ? state.documentBuffers + : existing != null + ? state.documentBuffers + : [...state.documentBuffers, buffer]; state = WorkspaceState( workspace: tabbedWorkspace, activeText: text, preview: _safePreview(tabbedWorkspace, text), + documentBuffers: buffers, + activeBufferId: buffer?.id, ); + await _startMonitoring(tabbedWorkspace); + _schedulePersistence(); _resetSaveTracking(); return true; } on Object catch (error, stackTrace) { @@ -398,10 +935,6 @@ class WorkspaceController extends Notifier { Future openFolder(String path) => openPath(path); Future openActiveFile(String path) async { - final workspace = state.workspace; - if (workspace?.activeFilePath != path && !await autoSaveActiveIfNeeded()) { - return false; - } return _openActiveFile(path); } @@ -409,6 +942,22 @@ class WorkspaceController extends Notifier { Future activatePreviousOpenFileTab() => _activateOpenFileTab(-1); + Future activateDocumentBuffer(String bufferId) async { + final workspace = state.workspace; + final buffer = state.documentBuffers + .where((candidate) => candidate.id == bufferId) + .firstOrNull; + if (workspace == null || buffer == null) { + return false; + } + return _activateBuffer( + workspace, + buffer, + documentBuffers: state.documentBuffers, + openFilePaths: workspace.openFilePaths, + ); + } + Future closeActiveOpenFileTab() async { final activeFilePath = state.workspace?.activeFilePath; if (activeFilePath == null) { @@ -419,10 +968,10 @@ class WorkspaceController extends Notifier { Future closeAllOpenFileTabs() async { final workspace = state.workspace; - if (workspace == null || workspace.openFilePaths.isEmpty) { + if (workspace == null || state.documentBuffers.isEmpty) { return false; } - if (!await autoSaveActiveIfNeeded()) { + if (state.hasUnsavedChanges) { return false; } _clearOpenFileTabs(workspace); @@ -468,10 +1017,15 @@ class WorkspaceController extends Notifier { } Future deleteWorkspaceEntity(String path) async { - return _runWorkspaceFileOperation((workspace) async { - await _service.deleteEntity(workspace, path); - return null; - }); + _intentionallyRemovedPaths.add(p.normalize(path)); + try { + return await _runWorkspaceFileOperation((workspace) async { + await _service.deleteEntity(workspace, path); + return null; + }); + } finally { + _intentionallyRemovedPaths.remove(p.normalize(path)); + } } Future moveWritersideTocEntry({ @@ -638,9 +1192,11 @@ class WorkspaceController extends Notifier { Future closeOpenFileTab(String path) async { final workspace = state.workspace; - if (workspace == null || - !_supportsOpenFileTabs(workspace) || - !workspace.openFilePaths.contains(path)) { + if (workspace == null || !workspace.openFilePaths.contains(path)) { + return false; + } + final closingBuffer = state.bufferForPath(path); + if (closingBuffer?.isDirty == true) { return false; } final closedIndex = workspace.openFilePaths.indexOf(path); @@ -648,16 +1204,28 @@ class WorkspaceController extends Notifier { for (final openPath in workspace.openFilePaths) if (openPath != path) openPath, ]; - if (workspace.activeFilePath == path && !await autoSaveActiveIfNeeded()) { - return false; - } + final remainingBuffers = [ + for (final buffer in state.documentBuffers) + if (buffer.filePath != path) buffer, + ]; if (nextOpenFilePaths.isEmpty) { - _clearOpenFileTabs(workspace); + final nextUntitled = remainingBuffers.firstOrNull; + if (nextUntitled == null) { + _clearOpenFileTabs(workspace); + } else { + await _activateBuffer( + workspace, + nextUntitled, + documentBuffers: remainingBuffers, + openFilePaths: nextOpenFilePaths, + ); + } return true; } if (workspace.activeFilePath != path) { state = state.copyWith( workspace: workspace.copyWith(openFilePaths: nextOpenFilePaths), + documentBuffers: remainingBuffers, clearMessage: true, ); return true; @@ -668,27 +1236,70 @@ class WorkspaceController extends Notifier { return _openActiveFile( nextOpenFilePaths[nextIndex], openFilePaths: nextOpenFilePaths, + documentBuffers: remainingBuffers, + ); + } + + Future closeDocumentBuffer( + String bufferId, { + bool discard = false, + }) async { + final buffer = state.documentBuffers + .where((candidate) => candidate.id == bufferId) + .firstOrNull; + if (buffer == null || (buffer.isDirty && !discard)) { + return false; + } + if (buffer.filePath case final path?) { + if (buffer.isDirty) { + state = state.copyWith( + documentBuffers: _replaceBuffer( + state.documentBuffers, + buffer.copyWith(dirty: false), + ), + ); + } + return closeOpenFileTab(path); + } + final workspace = state.workspace; + if (workspace == null) { + return false; + } + final remaining = [ + for (final candidate in state.documentBuffers) + if (candidate.id != bufferId) candidate, + ]; + if (remaining.isEmpty) { + state = const WorkspaceState(); + _fileMonitor.updateOpenFilePaths(const []); + _schedulePersistence(); + return true; + } + return _activateBuffer( + workspace, + remaining.last, + documentBuffers: remaining, + openFilePaths: workspace.openFilePaths, ); } Future _activateOpenFileTab(int delta) async { final workspace = state.workspace; - if (workspace == null || - !_supportsOpenFileTabs(workspace) || - workspace.openFilePaths.length < 2) { + if (workspace == null || state.documentBuffers.length < 2) { return false; } - final activeFilePath = workspace.activeFilePath; - final activeIndex = activeFilePath == null + final activeIndex = state.activeBufferId == null ? -1 - : workspace.openFilePaths.indexOf(activeFilePath); + : state.documentBuffers.indexWhere( + (buffer) => buffer.id == state.activeBufferId, + ); final nextIndex = activeIndex < 0 ? 0 - : (activeIndex + delta) % workspace.openFilePaths.length; + : (activeIndex + delta) % state.documentBuffers.length; final normalizedIndex = nextIndex < 0 - ? nextIndex + workspace.openFilePaths.length + ? nextIndex + state.documentBuffers.length : nextIndex; - return openActiveFile(workspace.openFilePaths[normalizedIndex]); + return activateDocumentBuffer(state.documentBuffers[normalizedIndex].id); } void _clearOpenFileTabs(Workspace workspace) { @@ -717,19 +1328,35 @@ class WorkspaceController extends Notifier { ), activeText: '', preview: null, - isDirty: false, + documentBuffers: const [], + activeBufferId: null, clearMessage: true, ); + _fileMonitor.updateOpenFilePaths(const []); + _schedulePersistence(); } Future _openActiveFile( String path, { List? openFilePaths, + List? documentBuffers, }) async { final workspace = state.workspace; if (workspace == null) { return false; } + final buffers = documentBuffers ?? state.documentBuffers; + final existing = buffers + .where((buffer) => buffer.filePath == path) + .firstOrNull; + if (existing != null) { + return _activateBuffer( + workspace, + existing, + documentBuffers: buffers, + openFilePaths: openFilePaths ?? _openFileTabPaths(workspace, path), + ); + } _cancelPendingDerivedRefresh(); _autoSaveDebounce?.cancel(); final operationRevision = _invalidateActiveDocumentOperations(); @@ -747,13 +1374,25 @@ class WorkspaceController extends Notifier { if (!_isCurrentActiveDocumentOperation(operationRevision)) { return false; } + final buffer = _fileBuffer( + path, + load, + mode: _settingsController.state.documentViewMode, + ); state = state.copyWith( workspace: reparsed, activeText: load.text, preview: _safePreview(reparsed, load.text), - isDirty: false, + documentBuffers: [...buffers, buffer], + activeBufferId: buffer.id, clearMessage: true, ); + _fileMonitor.updateOpenFilePaths( + [ + ...buffers, + buffer, + ].map((candidate) => candidate.filePath).whereType(), + ); _resetSaveTracking(); return true; } on Object catch (error, stackTrace) { @@ -775,6 +1414,146 @@ class WorkspaceController extends Notifier { } } + Future _activateBuffer( + Workspace workspace, + DocumentBuffer buffer, { + required List documentBuffers, + required List openFilePaths, + }) async { + _cancelPendingDerivedRefresh(); + _autoSaveDebounce?.cancel(); + final operationRevision = _invalidateActiveDocumentOperations(); + final nextWorkspace = workspace.copyWith( + activeFilePath: buffer.filePath, + activeFileSnapshot: buffer.diskSnapshot, + openFilePaths: openFilePaths, + markdown: buffer.filePath == null ? workspace.markdown : null, + ); + final reparsed = await _service.reparseActive(nextWorkspace, buffer.text); + if (!_isCurrentActiveDocumentOperation(operationRevision)) { + return false; + } + state = state.copyWith( + workspace: reparsed, + preview: _safePreview(reparsed, buffer.text), + documentBuffers: documentBuffers, + activeBufferId: buffer.id, + clearMessage: true, + ); + _fileMonitor.updateOpenFilePaths( + documentBuffers + .map((candidate) => candidate.filePath) + .whereType(), + ); + _schedulePersistence(); + _editRevision = buffer.revision; + unawaited(_settingsController.setDocumentViewMode(buffer.editorState.mode)); + return true; + } + + void updateActiveEditorState(DocumentEditorState editorState) { + final buffer = state.activeBuffer; + if (buffer == null) { + return; + } + updateDocumentEditorState(buffer.id, editorState); + } + + void updateDocumentEditorState( + String bufferId, + DocumentEditorState editorState, + ) { + final buffer = state.documentBuffers + .where((candidate) => candidate.id == bufferId) + .firstOrNull; + if (buffer == null) { + return; + } + state = state.copyWith( + documentBuffers: _replaceBuffer( + state.documentBuffers, + buffer.copyWith(editorState: editorState), + ), + ); + _schedulePersistence(); + } + + bool updateDocumentText(String bufferId, String text) { + final buffer = state.documentBuffers + .where((candidate) => candidate.id == bufferId) + .firstOrNull; + if (buffer == null) { + return false; + } + if (state.activeBufferId == bufferId) { + updateActiveText(text, sourceFilePath: buffer.filePath); + return true; + } + final next = buffer.edited(text); + if (identical(next, buffer)) { + return true; + } + state = state.copyWith( + documentBuffers: _replaceBuffer(state.documentBuffers, next), + ); + _schedulePersistence(); + return true; + } + + void updateActiveEditorMode(DocumentViewModePreference mode) { + final buffer = state.activeBuffer; + if (buffer == null || buffer.editorState.mode == mode) { + return; + } + updateActiveEditorState(buffer.editorState.copyWith(mode: mode)); + } + + bool undoActiveBuffer() { + final buffer = state.activeBuffer; + if (buffer == null || buffer.editorState.undoState.undo.isEmpty) { + return false; + } + final undo = buffer.editorState.undoState; + final text = undo.undo.last; + final next = buffer.copyWith( + text: text, + dirty: text != buffer.lastSavedText || buffer.isUntitled, + revision: buffer.revision + 1, + editorState: buffer.editorState.copyWith( + undoState: undo.afterUndo(buffer.text), + ), + ); + state = state.copyWith( + documentBuffers: _replaceBuffer(state.documentBuffers, next), + ); + _requestDerivedRefresh(rebuildPreview: true); + _schedulePersistence(); + return true; + } + + bool redoActiveBuffer() { + final buffer = state.activeBuffer; + if (buffer == null || buffer.editorState.undoState.redo.isEmpty) { + return false; + } + final undo = buffer.editorState.undoState; + final text = undo.redo.last; + final next = buffer.copyWith( + text: text, + dirty: text != buffer.lastSavedText || buffer.isUntitled, + revision: buffer.revision + 1, + editorState: buffer.editorState.copyWith( + undoState: undo.afterRedo(buffer.text), + ), + ); + state = state.copyWith( + documentBuffers: _replaceBuffer(state.documentBuffers, next), + ); + _requestDerivedRefresh(rebuildPreview: true); + _schedulePersistence(); + return true; + } + void updateActiveText(String text, {String? sourceFilePath}) { _updateActiveText( text, @@ -823,9 +1602,17 @@ class WorkspaceController extends Notifier { if (sourceFilePath != null && activeEditorPath != sourceFilePath) { return; } - _editRevision++; + final activeBuffer = state.activeBuffer; + if (activeBuffer == null) { + return; + } + final nextBuffer = activeBuffer.edited(text); + if (identical(nextBuffer, activeBuffer)) { + return; + } + _editRevision = nextBuffer.revision; state = state.copyWith( - activeText: text, + documentBuffers: _replaceBuffer(state.documentBuffers, nextBuffer), liveOutline: workspace == null || liveOutline == null ? null : ActiveDocumentOutline( @@ -834,8 +1621,8 @@ class WorkspaceController extends Notifier { source: text, headings: liveOutline, ), - isDirty: true, ); + _schedulePersistence(); _requestDerivedRefresh(rebuildPreview: rebuildPreview); _scheduleAutoSave(); } @@ -888,7 +1675,7 @@ class WorkspaceController extends Notifier { final workspaceId = workspace.id; final activeFilePath = workspace.activeFilePath; final text = state.activeText; - final editRevision = _editRevision; + final editRevision = state.activeBuffer?.revision ?? _editRevision; final operationRevision = _activeDocumentRevision; try { final preview = await _service.buildPreviewAsync(workspace, text); @@ -898,7 +1685,7 @@ class WorkspaceController extends Notifier { activeFilePath: activeFilePath, ) || state.activeText != text || - _editRevision != editRevision) { + state.activeBuffer?.revision != editRevision) { return; } state = state.copyWith(preview: preview); @@ -926,6 +1713,7 @@ class WorkspaceController extends Notifier { Future saveActive({ bool overwriteExternalChanges = false, ActiveDocumentSaveTarget? target, + LineEndingNormalization? mixedLineEndingNormalization, }) async { final operationTarget = target ?? captureActiveDocumentSaveTarget(); if (operationTarget == null || @@ -953,12 +1741,14 @@ class WorkspaceController extends Notifier { return saveActive( overwriteExternalChanges: overwriteExternalChanges, target: refreshedTarget, + mixedLineEndingNormalization: mixedLineEndingNormalization, ); } late final Future operation; operation = _saveActiveNow( operationTarget, overwriteExternalChanges: overwriteExternalChanges, + mixedLineEndingNormalization: mixedLineEndingNormalization, ); _activeSave = operation; _activeSaveTarget = operationTarget; @@ -976,6 +1766,7 @@ class WorkspaceController extends Notifier { Future _saveActiveNow( ActiveDocumentSaveTarget target, { required bool overwriteExternalChanges, + required LineEndingNormalization? mixedLineEndingNormalization, }) async { final active = target.path; if (active == null) { @@ -1009,7 +1800,13 @@ class WorkspaceController extends Notifier { return false; } try { - final snapshot = await _service.saveText(active, target.text); + final snapshot = await _service.saveText( + active, + target.format.formattedText( + target.text, + mixedNormalization: mixedLineEndingNormalization, + ), + ); final currentWorkspace = state.workspace; if (!_isCurrentActiveDocument( target.documentRevision, @@ -1032,8 +1829,27 @@ class WorkspaceController extends Notifier { latestWorkspace == null) { return false; } - if (_editRevision == target.editRevision && - state.activeText == target.text) { + final currentBuffer = state.activeBuffer; + if (currentBuffer == null || currentBuffer.id != target.bufferId) { + return false; + } + final savedFormat = + target.format.hasMixedLineEndings && + mixedLineEndingNormalization != null + ? target.format.normalized(mixedLineEndingNormalization) + : target.format; + if (currentBuffer.revision == target.editRevision && + currentBuffer.text == target.text) { + final savedBuffer = currentBuffer.copyWith( + lastSavedText: target.text, + dirty: false, + diskSnapshot: snapshot, + format: savedFormat, + diskState: DocumentDiskState.present, + diskVersionText: null, + diskVersionSnapshot: null, + recovered: false, + ); final nextWorkspace = reparsed.copyWith( activeFileSnapshot: snapshot, openFilePaths: latestWorkspace.openFilePaths, @@ -1042,17 +1858,28 @@ class WorkspaceController extends Notifier { state = state.copyWith( workspace: nextWorkspace, preview: _safePreview(nextWorkspace, target.text), - isDirty: false, + documentBuffers: _replaceBuffer(state.documentBuffers, savedBuffer), clearMessage: true, ); } else { + final updatedBuffer = currentBuffer.copyWith( + lastSavedText: target.text, + dirty: currentBuffer.text != target.text, + diskSnapshot: snapshot, + format: savedFormat, + diskState: DocumentDiskState.present, + diskVersionText: null, + diskVersionSnapshot: null, + recovered: false, + ); state = state.copyWith( workspace: latestWorkspace.copyWith(activeFileSnapshot: snapshot), - isDirty: true, + documentBuffers: _replaceBuffer(state.documentBuffers, updatedBuffer), clearMessage: true, ); _scheduleAutoSave(); } + _schedulePersistence(); return true; } on Object catch (error) { if (_isCurrentActiveDocument( @@ -1071,10 +1898,84 @@ class WorkspaceController extends Notifier { } } + Future saveAll({ + Map mixedLineEndingNormalizations = + const {}, + }) async { + _autoSaveDebounce?.cancel(); + final saved = []; + final failed = []; + final conflicts = []; + final normalizationRequired = []; + final targets = [ + for (final buffer in state.documentBuffers) + if (buffer.isDirty && buffer.filePath != null) buffer, + ]; + for (final target in targets) { + if (target.format.hasMixedLineEndings && + !mixedLineEndingNormalizations.containsKey(target.id)) { + normalizationRequired.add(target.id); + continue; + } + final path = target.filePath!; + if (await _service.fileChangedSince(path, target.diskSnapshot)) { + conflicts.add(target.id); + continue; + } + try { + final snapshot = await _service.saveText( + path, + target.format.formattedText( + target.text, + mixedNormalization: mixedLineEndingNormalizations[target.id], + ), + ); + final current = state.documentBuffers + .where((buffer) => buffer.id == target.id) + .firstOrNull; + if (current == null) { + failed.add(target.id); + continue; + } + final unchanged = current.revision == target.revision; + final normalization = mixedLineEndingNormalizations[target.id]; + final next = current.copyWith( + lastSavedText: target.text, + dirty: !unchanged, + diskSnapshot: snapshot, + format: target.format.hasMixedLineEndings && normalization != null + ? target.format.normalized(normalization) + : target.format, + diskState: DocumentDiskState.present, + diskVersionText: null, + diskVersionSnapshot: null, + recovered: false, + ); + state = state.copyWith( + documentBuffers: _replaceBuffer(state.documentBuffers, next), + workspace: state.activeBufferId == target.id + ? state.workspace?.copyWith(activeFileSnapshot: snapshot) + : state.workspace, + ); + saved.add(target.id); + } on Object { + failed.add(target.id); + } + } + _schedulePersistence(); + return SaveAllResult( + savedBufferIds: List.unmodifiable(saved), + failedBufferIds: List.unmodifiable(failed), + conflictBufferIds: List.unmodifiable(conflicts), + normalizationRequiredBufferIds: List.unmodifiable(normalizationRequired), + ); + } + Future saveActiveAs( String path, { ActiveDocumentSaveTarget? target, bool overwriteExisting = false, + LineEndingNormalization? mixedLineEndingNormalization, }) async { final operationTarget = target ?? captureActiveDocumentSaveTarget(); if (operationTarget == null || @@ -1085,22 +1986,69 @@ class WorkspaceController extends Notifier { _cancelPendingDerivedRefresh(); final operationRevision = _invalidateActiveDocumentOperations(); try { + late final WorkspaceFileSnapshot savedSnapshot; if (overwriteExisting) { - await _service.saveTextReplacingPath(path, operationTarget.text); + savedSnapshot = await _service.saveTextReplacingPath( + path, + operationTarget.format.formattedText( + operationTarget.text, + mixedNormalization: mixedLineEndingNormalization, + ), + ); } else { - await _service.saveNewText(path, operationTarget.text); + savedSnapshot = await _service.saveNewText( + path, + operationTarget.format.formattedText( + operationTarget.text, + mixedNormalization: mixedLineEndingNormalization, + ), + ); } if (!_isSaveAsSourceDocumentCurrent(operationRevision, operationTarget)) { return false; } - final savedWorkspace = await _service.openPath(path); + final currentWorkspace = state.workspace!; + final replaceWorkspace = + currentWorkspace.kind == WorkspaceKind.untitledMarkdown && + state.documentBuffers.length == 1; + var savedWorkspace = replaceWorkspace + ? await _service.openPath(path) + : currentWorkspace.copyWith( + activeFilePath: path, + activeFileSnapshot: savedSnapshot, + openFilePaths: _openFileTabPaths(currentWorkspace, path), + ); if (!_isSaveAsSourceDocumentCurrent(operationRevision, operationTarget)) { return false; } final latestText = state.activeText; + final currentBuffer = state.activeBuffer; + if (currentBuffer == null || + currentBuffer.id != operationTarget.bufferId) { + return false; + } final hasNewerEdits = - _editRevision != operationTarget.editRevision || + currentBuffer.revision != operationTarget.editRevision || latestText != operationTarget.text; + final savedFormat = + operationTarget.format.hasMixedLineEndings && + mixedLineEndingNormalization != null + ? operationTarget.format.normalized(mixedLineEndingNormalization) + : operationTarget.format; + final savedBuffer = currentBuffer.copyWith( + filePath: path, + untitledName: null, + lastSavedText: operationTarget.text, + dirty: hasNewerEdits, + diskSnapshot: savedSnapshot, + format: savedFormat, + diskState: DocumentDiskState.present, + recovered: false, + ); + savedWorkspace = await _service.reparseActive( + savedWorkspace, + hasNewerEdits ? latestText : operationTarget.text, + ); _cancelPendingDerivedRefresh(); state = WorkspaceState( workspace: savedWorkspace, @@ -1109,8 +2057,10 @@ class WorkspaceController extends Notifier { savedWorkspace, hasNewerEdits ? latestText : operationTarget.text, ), - isDirty: hasNewerEdits, + documentBuffers: _replaceBuffer(state.documentBuffers, savedBuffer), + activeBufferId: savedBuffer.id, ); + await _startMonitoring(savedWorkspace); if (hasNewerEdits) { if (_settingsController.state.validateOnEdit) { unawaited(validateActive()); @@ -1123,6 +2073,7 @@ class WorkspaceController extends Notifier { path: path, kind: savedWorkspace.kind.name, ); + _schedulePersistence(); return true; } on Object catch (error) { if (_isSaveAsSourceDocumentCurrent(operationRevision, operationTarget)) { @@ -1172,8 +2123,14 @@ class WorkspaceController extends Notifier { } final active = operationTarget.path; final operationRevision = _invalidateActiveDocumentOperations(); - if (workspace.kind == WorkspaceKind.untitledMarkdown || active == null) { - state = const WorkspaceState(); + if (active == null) { + if (state.documentBuffers.length == 1) { + state = const WorkspaceState(); + _fileMonitor.updateOpenFilePaths(const []); + _schedulePersistence(); + } else { + await closeDocumentBuffer(operationTarget.bufferId, discard: true); + } _resetSaveTracking(); return true; } @@ -1189,13 +2146,30 @@ class WorkspaceController extends Notifier { if (!_isPinnedOperationCurrent(operationRevision, operationTarget)) { return false; } + final currentBuffer = state.activeBuffer; + if (currentBuffer == null || + currentBuffer.id != operationTarget.bufferId) { + return false; + } + final discardedBuffer = currentBuffer.copyWith( + text: load.text, + lastSavedText: load.text, + dirty: false, + diskSnapshot: load.snapshot, + format: load.format, + revision: currentBuffer.revision + 1, + diskState: DocumentDiskState.present, + diskVersionText: null, + diskVersionSnapshot: null, + recovered: false, + ); state = state.copyWith( workspace: reparsed, - activeText: load.text, preview: _safePreview(reparsed, load.text), - isDirty: false, + documentBuffers: _replaceBuffer(state.documentBuffers, discardedBuffer), clearMessage: true, ); + _schedulePersistence(); _resetSaveTracking(); return true; } on Object catch (error, stackTrace) { @@ -1207,7 +2181,6 @@ class WorkspaceController extends Notifier { ); if (_isPinnedOperationCurrent(operationRevision, operationTarget)) { state = state.copyWith( - isDirty: true, message: WorkspaceMessage( WorkspaceMessageCode.couldNotOpenFile, error: error, @@ -1220,7 +2193,7 @@ class WorkspaceController extends Notifier { Future refreshWorkspaceFromDiskPreservingOpenTabs() async { final workspace = state.workspace; - if (workspace == null || state.isDirty) { + if (workspace == null) { return false; } final operationRevision = _invalidateActiveDocumentOperations(); @@ -1235,45 +2208,80 @@ class WorkspaceController extends Notifier { final existingFiles = { for (final file in refreshed.files) file.absolutePath: file, }; - final retainedTabs = [ - for (final path in workspace.openFilePaths) - if (existingFiles.containsKey(path)) path, + final buffers = []; + for (final buffer in state.documentBuffers) { + final path = buffer.filePath; + if (path == null) { + buffers.add(buffer); + continue; + } + if (_intentionallyRemovedPaths.any( + (removed) => p.equals(path, removed) || p.isWithin(removed, path), + )) { + continue; + } + if (!existingFiles.containsKey(path)) { + buffers.add(buffer.copyWith(diskState: DocumentDiskState.deleted)); + continue; + } + if (buffer.isDirty) { + buffers.add(buffer); + continue; + } + final load = await _service.loadTextWithSnapshot(path); + buffers.add( + buffer.copyWith( + text: load.text, + lastSavedText: load.text, + dirty: false, + diskSnapshot: load.snapshot, + format: load.format, + diskState: DocumentDiskState.present, + ), + ); + } + var activeBuffer = buffers + .where((buffer) => buffer.id == state.activeBufferId) + .firstOrNull; + activeBuffer ??= buffers.firstOrNull; + if (activeBuffer == null && refreshed.activeFilePath != null) { + final load = await _service.loadTextWithSnapshot( + refreshed.activeFilePath!, + ); + activeBuffer = _fileBuffer( + refreshed.activeFilePath!, + load, + mode: _settingsController.state.documentViewMode, + ); + buffers.add(activeBuffer); + } + final active = activeBuffer?.filePath; + final tabPaths = [ + for (final buffer in buffers) + if (buffer.filePath != null) buffer.filePath!, ]; - final previousActive = workspace.activeFilePath; - final active = - previousActive != null && existingFiles.containsKey(previousActive) - ? previousActive - : retainedTabs.isNotEmpty - ? retainedTabs.first - : refreshed.activeFilePath; - final load = active == null - ? null - : await _service.loadTextWithSnapshot(active); - final tabPaths = _supportsOpenFileTabs(refreshed) - ? _retainedOpenFileTabPaths( - current: workspace, - refreshed: refreshed, - activeFilePath: active, - ) - : active == null - ? const [] - : [active]; final nextWorkspace = refreshed.copyWith( activeFilePath: active, - activeFileSnapshot: load?.snapshot, + activeFileSnapshot: activeBuffer?.diskSnapshot, openFilePaths: tabPaths, ); - final reparsed = load == null + final reparsed = activeBuffer == null ? nextWorkspace.copyWith(markdown: null) - : await _service.reparseActive(nextWorkspace, load.text); + : await _service.reparseActive(nextWorkspace, activeBuffer.text); if (!_isCurrentActiveDocumentOperation(operationRevision)) { return false; } state = WorkspaceState( workspace: reparsed, - activeText: load?.text ?? '', - preview: load == null ? null : _safePreview(reparsed, load.text), + activeText: activeBuffer?.text ?? '', + preview: activeBuffer == null + ? null + : _safePreview(reparsed, activeBuffer.text), + documentBuffers: buffers, + activeBufferId: activeBuffer?.id, ); + _fileMonitor.updateOpenFilePaths(tabPaths); + _schedulePersistence(); _resetSaveTracking(); return true; } on Object catch (error, stackTrace) { @@ -1359,6 +2367,14 @@ class WorkspaceController extends Notifier { activeFilePath: remappedActive ?? activeFilePath, openFilePaths: remappedTabs, ), + documentBuffers: [ + for (final buffer in state.documentBuffers) + if (_remapMovedPath(buffer.filePath, sourcePath, targetPath) + case final remapped?) + buffer.copyWith(filePath: remapped) + else + buffer, + ], ); } @@ -1370,7 +2386,7 @@ class WorkspaceController extends Notifier { final workspaceId = workspace.id; final activeFilePath = workspace.activeFilePath; final text = state.activeText; - final editRevision = _editRevision; + final editRevision = state.activeBuffer?.revision ?? _editRevision; final operationRevision = _activeDocumentRevision; try { final reparsed = await _service.reparseActive(workspace, text); @@ -1382,7 +2398,7 @@ class WorkspaceController extends Notifier { ) || currentWorkspace == null || state.activeText != text || - _editRevision != editRevision) { + state.activeBuffer?.revision != editRevision) { return; } final currentSnapshot = currentWorkspace.activeFileSnapshot; @@ -1402,7 +2418,7 @@ class WorkspaceController extends Notifier { activeFilePath: activeFilePath, ) && state.activeText == text && - _editRevision == editRevision) { + state.activeBuffer?.revision == editRevision) { state = state.copyWith( message: WorkspaceMessage( WorkspaceMessageCode.validationFailed, @@ -1447,8 +2463,7 @@ class WorkspaceController extends Notifier { } bool _canAutoSaveActive() { - final workspace = state.workspace; - return workspace != null && workspace.activeFilePath != null; + return state.activeBuffer?.filePath != null; } void _resetSaveTracking({bool dirty = false}) { @@ -1487,9 +2502,10 @@ class WorkspaceController extends Notifier { activeFilePath: target.path, ) && workspace != null && - _editRevision == target.editRevision && - state.activeText == target.text && - _sameFileSnapshot(workspace.activeFileSnapshot, target.snapshot); + state.activeBuffer?.id == target.bufferId && + state.activeBuffer?.revision == target.editRevision && + state.activeBuffer?.text == target.text && + _sameFileSnapshot(state.activeBuffer?.diskSnapshot, target.snapshot); } bool _isSaveAsSourceDocumentCurrent( @@ -1503,7 +2519,8 @@ class WorkspaceController extends Notifier { activeFilePath: target.path, ) && workspace != null && - _sameFileSnapshot(workspace.activeFileSnapshot, target.snapshot); + state.activeBuffer?.id == target.bufferId && + _sameFileSnapshot(state.activeBuffer?.diskSnapshot, target.snapshot); } bool _sameSaveTarget( @@ -1512,6 +2529,7 @@ class WorkspaceController extends Notifier { ) { return first != null && first.workspaceId == second.workspaceId && + first.bufferId == second.bufferId && first.path == second.path && first.documentRevision == second.documentRevision && first.editRevision == second.editRevision && @@ -1530,23 +2548,65 @@ class WorkspaceController extends Notifier { activeFilePath: target.path, ) || workspace == null || - _editRevision != target.editRevision || - state.activeText != target.text || + state.activeBuffer?.id != target.bufferId || + state.activeBuffer?.revision != target.editRevision || + state.activeBuffer?.text != target.text || workspace.kind != target.workspaceKind) { return null; } return ActiveDocumentSaveTarget._( workspaceId: target.workspaceId, + bufferId: target.bufferId, path: target.path, documentRevision: target.documentRevision, editRevision: target.editRevision, - snapshot: workspace.activeFileSnapshot, + snapshot: state.activeBuffer?.diskSnapshot, text: target.text, workspaceKind: target.workspaceKind, + format: state.activeBuffer?.format ?? target.format, ); } } +DocumentBuffer _fileBuffer( + String path, + WorkspaceFileLoad load, { + DocumentViewModePreference mode = DocumentViewModePreference.editor, +}) { + final format = + load.format.lfCount == 0 && + load.format.crlfCount == 0 && + load.format.crCount == 0 && + load.text.endsWith('\n') + ? load.format.copyWith( + lineEnding: DocumentLineEnding.lf, + hasFinalNewline: true, + ) + : load.format; + return DocumentBuffer.file( + id: 'file:$path', + filePath: path, + text: load.text, + snapshot: load.snapshot, + format: format, + mode: mode, + ); +} + +List _replaceBuffer( + List buffers, + DocumentBuffer replacement, +) { + return List.unmodifiable([ + for (final buffer in buffers) + if (buffer.id == replacement.id) replacement else buffer, + ]); +} + +extension _ControllerFirstOrNull on Iterable { + T? get firstOrNull => isEmpty ? null : first; +} + bool _sameFileSnapshot( WorkspaceFileSnapshot? first, WorkspaceFileSnapshot? second, diff --git a/lib/src/workspace/workspace_file_monitor.dart b/lib/src/workspace/workspace_file_monitor.dart new file mode 100644 index 00000000..65f8895d --- /dev/null +++ b/lib/src/workspace/workspace_file_monitor.dart @@ -0,0 +1,120 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:path/path.dart' as p; + +enum WorkspaceFileEventKind { changed, deleted, moved, workspaceChanged } + +class WorkspaceFileMonitorEvent { + const WorkspaceFileMonitorEvent({ + required this.kind, + required this.path, + this.destinationPath, + this.isDirectory = false, + }); + + final WorkspaceFileEventKind kind; + final String path; + final String? destinationPath; + final bool isDirectory; +} + +class WorkspaceFileMonitor { + WorkspaceFileMonitor({this.debounce = const Duration(milliseconds: 180)}); + + final Duration debounce; + final _controller = StreamController.broadcast(); + StreamSubscription? _subscription; + Timer? _debounceTimer; + final _pending = {}; + Set _openFilePaths = const {}; + + Stream get events => _controller.stream; + + Future start({ + required String rootPath, + required Iterable openFilePaths, + }) async { + await stop(); + final normalizedRoot = p.normalize(p.absolute(rootPath)); + _openFilePaths = { + for (final path in openFilePaths) p.normalize(p.absolute(path)), + }; + final directory = Directory(normalizedRoot); + if (!directory.existsSync()) { + return; + } + _subscription = directory + .watch(recursive: true, events: FileSystemEvent.all) + .listen(_receive, onError: (_) {}); + } + + void updateOpenFilePaths(Iterable paths) { + _openFilePaths = {for (final path in paths) p.normalize(p.absolute(path))}; + } + + Future stop() async { + _debounceTimer?.cancel(); + _debounceTimer = null; + _pending.clear(); + await _subscription?.cancel(); + _subscription = null; + } + + Future dispose() async { + await stop(); + await _controller.close(); + } + + void _receive(FileSystemEvent event) { + final path = p.normalize(p.absolute(event.path)); + if (_isBusyMarkTemporaryPath(path)) { + return; + } + final destination = + event is FileSystemMoveEvent && event.destination != null + ? p.normalize(p.absolute(event.destination!)) + : null; + final openPath = + _openFilePaths.contains(path) || + (destination != null && _openFilePaths.contains(destination)); + final kind = event is FileSystemDeleteEvent + ? WorkspaceFileEventKind.deleted + : event is FileSystemMoveEvent + ? WorkspaceFileEventKind.moved + : openPath + ? WorkspaceFileEventKind.changed + : WorkspaceFileEventKind.workspaceChanged; + _pending[path] = WorkspaceFileMonitorEvent( + kind: kind, + path: path, + destinationPath: destination, + isDirectory: event.isDirectory, + ); + _debounceTimer?.cancel(); + _debounceTimer = Timer(debounce, _flush); + } + + void _flush() { + final events = _pending.values.toList(growable: false); + _pending.clear(); + for (final event in events) { + if (!_controller.isClosed) { + _controller.add(event); + } + } + } + + bool _isBusyMarkTemporaryPath(String path) { + final basename = p.basename(path); + return basename.contains('.busymark-save-') || + path + .split(p.separator) + .any( + (part) => + part.startsWith('.busymark-state-') || + part.startsWith('.busymark-settings-') || + part.startsWith('.busymark-export-'), + ); + } +} diff --git a/lib/src/workspace/workspace_file_snapshot.dart b/lib/src/workspace/workspace_file_snapshot.dart new file mode 100644 index 00000000..a8193eee --- /dev/null +++ b/lib/src/workspace/workspace_file_snapshot.dart @@ -0,0 +1,48 @@ +import 'text_format_metadata.dart'; + +class WorkspaceFileSnapshot { + const WorkspaceFileSnapshot({ + required this.modifiedAt, + required this.size, + required this.contentHash, + }); + + final DateTime modifiedAt; + final int size; + final String contentHash; + + bool differsFrom(WorkspaceFileSnapshot other) { + if (contentHash.isNotEmpty && other.contentHash.isNotEmpty) { + return contentHash != other.contentHash; + } + return modifiedAt != other.modifiedAt || size != other.size; + } + + Map toJson() => { + 'modifiedAt': modifiedAt.toIso8601String(), + 'size': size, + 'contentHash': contentHash, + }; + + factory WorkspaceFileSnapshot.fromJson(Map json) { + return WorkspaceFileSnapshot( + modifiedAt: + DateTime.tryParse(json['modifiedAt']?.toString() ?? '') ?? + DateTime.fromMillisecondsSinceEpoch(0), + size: (json['size'] as num?)?.toInt() ?? 0, + contentHash: json['contentHash']?.toString() ?? '', + ); + } +} + +class WorkspaceFileLoad { + const WorkspaceFileLoad({ + required this.text, + required this.snapshot, + this.format = TextFormatMetadata.utf8Lf, + }); + + final String text; + final WorkspaceFileSnapshot snapshot; + final TextFormatMetadata format; +} diff --git a/lib/src/workspace/workspace_model.dart b/lib/src/workspace/workspace_model.dart index f3378c88..2b88954c 100644 --- a/lib/src/workspace/workspace_model.dart +++ b/lib/src/workspace/workspace_model.dart @@ -3,7 +3,11 @@ import '../markdown/document_outline.dart'; import '../markdown/markdown_model.dart'; import '../markdown/preview_model.dart'; import '../writerside/writerside_model.dart'; +import 'document_buffer.dart'; import 'workspace_message.dart'; +import 'workspace_file_snapshot.dart'; + +export 'workspace_file_snapshot.dart'; const Object _copyWithUnset = _CopyWithUnset(); @@ -58,35 +62,6 @@ class ActiveDocumentOutline { } } -class WorkspaceFileSnapshot { - const WorkspaceFileSnapshot({ - required this.modifiedAt, - required this.size, - required this.contentHash, - }); - - final DateTime modifiedAt; - final int size; - final String contentHash; - - bool differsFrom(WorkspaceFileSnapshot other) { - // A timestamp-only change cannot lose user content. Prefer the hashes when - // both snapshots have one so metadata touches and filesystem timestamp - // rounding do not produce false external-edit conflicts. - if (contentHash.isNotEmpty && other.contentHash.isNotEmpty) { - return contentHash != other.contentHash; - } - return modifiedAt != other.modifiedAt || size != other.size; - } -} - -class WorkspaceFileLoad { - const WorkspaceFileLoad({required this.text, required this.snapshot}); - - final String text; - final WorkspaceFileSnapshot snapshot; -} - class DocumentFile { const DocumentFile({ required this.absolutePath, @@ -215,23 +190,59 @@ List _normalizedOpenFilePaths( class WorkspaceState { const WorkspaceState({ this.workspace, - this.activeText = '', + String activeText = '', this.preview, this.liveOutline, - this.isDirty = false, + bool isDirty = false, + this.documentBuffers = const [], + this.activeBufferId, this.isLoading = false, this.message, - }); + }) : _legacyActiveText = activeText, + _legacyIsDirty = isDirty; final Workspace? workspace; - final String activeText; + final String _legacyActiveText; final PreviewDocument? preview; final ActiveDocumentOutline? liveOutline; - final bool isDirty; + final bool _legacyIsDirty; + final List documentBuffers; + final String? activeBufferId; final bool isLoading; final WorkspaceMessage? message; - bool get hasUnsavedChanges => isDirty; + DocumentBuffer? get activeBuffer { + final id = activeBufferId; + if (id == null) { + return null; + } + for (final buffer in documentBuffers) { + if (buffer.id == id) { + return buffer; + } + } + return null; + } + + String get activeText => activeBuffer?.text ?? _legacyActiveText; + + bool get isDirty => activeBuffer?.isDirty ?? _legacyIsDirty; + + bool get hasUnsavedChanges => documentBuffers.isEmpty + ? _legacyIsDirty + : documentBuffers.any((buffer) => buffer.isDirty); + + List get dirtyBuffers => + List.unmodifiable(documentBuffers.where((buffer) => buffer.isDirty)); + + DocumentBuffer? bufferForPath(String path) { + for (final buffer in documentBuffers) { + if (buffer.filePath == path) { + return buffer; + } + } + return null; + } WorkspaceState copyWith({ Workspace? workspace, @@ -239,6 +250,8 @@ class WorkspaceState { Object? preview = _copyWithUnset, Object? liveOutline = _copyWithUnset, bool? isDirty, + List? documentBuffers, + Object? activeBufferId = _copyWithUnset, bool? isLoading, WorkspaceMessage? message, bool clearMessage = false, @@ -254,10 +267,14 @@ class WorkspaceState { : this.liveOutline; return WorkspaceState( workspace: workspace ?? this.workspace, - activeText: activeText ?? this.activeText, + activeText: activeText ?? _legacyActiveText, preview: nextPreview, liveOutline: nextLiveOutline, - isDirty: isDirty ?? this.isDirty, + isDirty: isDirty ?? _legacyIsDirty, + documentBuffers: documentBuffers ?? this.documentBuffers, + activeBufferId: identical(activeBufferId, _copyWithUnset) + ? this.activeBufferId + : activeBufferId as String?, isLoading: isLoading ?? this.isLoading, message: clearMessage ? null : message ?? this.message, ); diff --git a/lib/src/workspace/workspace_safety.dart b/lib/src/workspace/workspace_safety.dart index d9b8b7b7..afc4d8a1 100644 --- a/lib/src/workspace/workspace_safety.dart +++ b/lib/src/workspace/workspace_safety.dart @@ -11,6 +11,7 @@ import '../app/localization.dart'; import '../platform/linux_header_bar_service.dart'; import 'workspace_controller.dart'; import 'workspace_message.dart'; +import 'text_format_metadata.dart'; enum _UnsavedChangesAction { cancel, discard, save } @@ -107,9 +108,20 @@ Future saveActiveWithOverwriteConfirmation( if (operationTarget.needsSaveLocation) { return _saveActiveAs(context, ref, operationTarget); } + final normalization = await _chooseMixedLineEndingNormalization( + context, + ref, + operationTarget, + ); + if (operationTarget.format.hasMixedLineEndings && normalization == null) { + return false; + } // The controller owns both the disk check and save serialization so a // separate preflight cannot race an already-running write. - final saved = await controller.saveActive(target: operationTarget); + final saved = await controller.saveActive( + target: operationTarget, + mixedLineEndingNormalization: normalization, + ); if (saved) { return true; } @@ -149,9 +161,22 @@ Future saveActiveWithOverwriteConfirmation( return controller.saveActive( overwriteExternalChanges: true, target: operationTarget, + mixedLineEndingNormalization: normalization, ); } +Future saveActiveToNewLocation( + BuildContext context, + WidgetRef ref, +) async { + final controller = ref.read(workspaceControllerProvider.notifier); + final target = controller.captureActiveDocumentSaveTarget(); + if (target == null) { + return false; + } + return _saveActiveAs(context, ref, target); +} + Future _saveActiveAs( BuildContext context, WidgetRef ref, @@ -161,6 +186,17 @@ Future _saveActiveAs( if (!controller.isActiveDocumentSaveTargetCurrent(target)) { return false; } + final normalization = await _chooseMixedLineEndingNormalization( + context, + ref, + target, + ); + if (target.format.hasMixedLineEndings && normalization == null) { + return false; + } + if (!context.mounted) { + return false; + } final activePath = target.path; final location = await getSaveLocation( acceptedTypeGroups: [_markdownSaveType(context)], @@ -196,6 +232,42 @@ Future _saveActiveAs( savePath, target: target, overwriteExisting: overwriteExisting, + mixedLineEndingNormalization: normalization, + ); +} + +Future _chooseMixedLineEndingNormalization( + BuildContext context, + WidgetRef ref, + ActiveDocumentSaveTarget target, +) async { + if (!target.format.hasMixedLineEndings) { + return null; + } + final headerBar = ref.read(linuxHeaderBarServiceProvider); + return showBusyMarkModalDialog( + context, + headerBarService: headerBar.isAvailable ? headerBar : null, + builder: (context) => BusyMarkDialogShell( + title: context.l10n.normalizeLineEndings, + maxWidth: BusyMarkSizes.dialog, + actions: [ + BusyMarkDialogButton( + label: context.l10n.cancel, + onPressed: () => Navigator.pop(context), + ), + BusyMarkDialogButton( + label: LineEndingNormalization.lf.name.toUpperCase(), + onPressed: () => Navigator.pop(context, LineEndingNormalization.lf), + ), + BusyMarkDialogButton( + label: LineEndingNormalization.crlf.name.toUpperCase(), + suggested: true, + onPressed: () => Navigator.pop(context, LineEndingNormalization.crlf), + ), + ], + children: [Text(context.l10n.mixedLineEndingsSavePrompt)], + ), ); } diff --git a/lib/src/workspace/workspace_service.dart b/lib/src/workspace/workspace_service.dart index d302645f..54e39108 100644 --- a/lib/src/workspace/workspace_service.dart +++ b/lib/src/workspace/workspace_service.dart @@ -22,6 +22,7 @@ import '../writerside/writerside_topic_creator.dart'; import '../writerside/writerside_topic_file_editor.dart'; import '../writerside/writerside_topic_removal_service.dart'; import 'workspace_model.dart'; +import 'text_format_metadata.dart'; class WorkspaceService { const WorkspaceService({ @@ -407,9 +408,11 @@ class WorkspaceService { final file = File(path); final bytes = await file.readAsBytes(); final stat = await file.stat(); + final decoded = decodeUtf8Document(bytes); return WorkspaceFileLoad( - text: utf8.decode(bytes), + text: decoded.text, snapshot: _snapshotFromBytes(stat, bytes), + format: decoded.format, ); } @@ -451,8 +454,21 @@ class WorkspaceService { /// published with Linux's atomic no-replace rename operation (or an atomic /// hard-link fallback). It fails if any filesystem entity already has the /// final name. - Future saveNewText(String path, String text) async { - final bytes = utf8.encode(text); + Future saveNewText(String path, String text) { + return saveNewFormattedText(path, text); + } + + Future saveNewFormattedText( + String path, + String text, { + TextFormatMetadata? format, + LineEndingNormalization? mixedNormalization, + }) async { + final bytes = _encodeDocumentText( + text, + format: format, + mixedNormalization: mixedNormalization, + ); final staged = await _stageNewSave(path, bytes); try { final stat = await staged.file.stat(); @@ -470,8 +486,21 @@ class WorkspaceService { Future saveTextReplacingPath( String path, String text, - ) async { - final bytes = utf8.encode(text); + ) { + return saveFormattedTextReplacingPath(path, text); + } + + Future saveFormattedTextReplacingPath( + String path, + String text, { + TextFormatMetadata? format, + LineEndingNormalization? mixedNormalization, + }) async { + final bytes = _encodeDocumentText( + text, + format: format, + mixedNormalization: mixedNormalization, + ); final staged = await _stageNewSave(path, bytes); try { final targetType = await FileSystemEntity.type(path, followLinks: false); @@ -486,11 +515,24 @@ class WorkspaceService { } } - Future saveText(String path, String text) async { + Future saveText(String path, String text) { + return saveFormattedText(path, text); + } + + Future saveFormattedText( + String path, + String text, { + TextFormatMetadata? format, + LineEndingNormalization? mixedNormalization, + }) async { final savePath = await _saveTargetPath(path); final target = File(savePath); final existingStat = await target.stat(); - final bytes = utf8.encode(text); + final bytes = _encodeDocumentText( + text, + format: format, + mixedNormalization: mixedNormalization, + ); final temp = _temporarySaveFile(savePath); var renamed = false; try { @@ -1439,6 +1481,16 @@ class WorkspaceService { } } +List _encodeDocumentText( + String text, { + TextFormatMetadata? format, + LineEndingNormalization? mixedNormalization, +}) { + return format == null + ? utf8.encode(text) + : format.encode(text, mixedNormalization: mixedNormalization); +} + class _StagedSave { const _StagedSave({required this.directory, required this.file}); diff --git a/lib/src/workspace/workspace_tabs.dart b/lib/src/workspace/workspace_tabs.dart index 01c24fe6..62e3fa9f 100644 --- a/lib/src/workspace/workspace_tabs.dart +++ b/lib/src/workspace/workspace_tabs.dart @@ -1,4 +1,5 @@ import '../git/application/git_controller.dart'; +import 'document_buffer.dart'; import 'workspace_model.dart'; enum WorkspaceTabKind { file, gitDiff } @@ -8,10 +9,25 @@ class WorkspaceTabEntry { required this.kind, required this.path, required this.active, + this.bufferId, + this.untitledName, + this.dirty = false, }); - const WorkspaceTabEntry.file({required String path, required bool active}) - : this._(kind: WorkspaceTabKind.file, path: path, active: active); + const WorkspaceTabEntry.file({ + required String path, + required bool active, + String? bufferId, + String? untitledName, + bool dirty = false, + }) : this._( + kind: WorkspaceTabKind.file, + path: path, + active: active, + bufferId: bufferId, + untitledName: untitledName, + dirty: dirty, + ); const WorkspaceTabEntry.gitDiff({required String path, required bool active}) : this._(kind: WorkspaceTabKind.gitDiff, path: path, active: active); @@ -19,21 +35,36 @@ class WorkspaceTabEntry { final WorkspaceTabKind kind; final String path; final bool active; + final String? bufferId; + final String? untitledName; + final bool dirty; - String get key => '${kind.name}:$path'; + String get key => '${kind.name}:${bufferId ?? path}'; } List workspaceTabEntries({ required Workspace workspace, required GitState gitState, + List? documentBuffers, + String? activeBufferId, }) { final diffActive = gitState.selectedDiffForDisplay != null; return [ - for (final path in workspace.openFilePaths) - WorkspaceTabEntry.file( - path: path, - active: !diffActive && path == workspace.activeFilePath, - ), + if (documentBuffers != null) + for (final buffer in documentBuffers) + WorkspaceTabEntry.file( + path: buffer.filePath ?? '', + bufferId: buffer.id, + untitledName: buffer.untitledName, + dirty: buffer.isDirty, + active: !diffActive && buffer.id == activeBufferId, + ) + else + for (final path in workspace.openFilePaths) + WorkspaceTabEntry.file( + path: path, + active: !diffActive && path == workspace.activeFilePath, + ), for (final path in gitState.openDiffFilePaths) WorkspaceTabEntry.gitDiff( path: path, diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index f2d9331d..b034eeef 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -16,6 +16,7 @@ constexpr char kApplicationDisplayName[] = "BusyMark"; constexpr char kHeaderBarChannel[] = "com.busymark.app/headerbar"; constexpr char kNativeMenuChannel[] = "busymark/native_menus"; +constexpr char kAssetInputChannel[] = "com.busymark.app/asset_input"; constexpr gint kHeaderButtonHeight = 32; constexpr gint kHeaderButtonSpacing = 8; constexpr gint kHeaderSidebarInset = 8; @@ -81,6 +82,7 @@ struct _MyApplication { char** dart_entrypoint_arguments; FlMethodChannel* header_bar_channel; FlMethodChannel* native_menu_channel; + FlMethodChannel* asset_input_channel; FlMethodChannel* secure_credential_channel; BusyMarkWebRenderHost* visualization_host; GtkCssProvider* header_bar_css_provider; @@ -2789,6 +2791,95 @@ static void register_native_menu_channel(MyApplication* self, FlView* view) { native_menu_handler_data_free); } +static FlValue* local_paths_from_uris(gchar** uris) { + FlValue* paths = fl_value_new_list(); + if (uris == nullptr) { + return paths; + } + for (gchar** current = uris; *current != nullptr; current++) { + g_autoptr(GError) error = nullptr; + g_autofree gchar* path = g_filename_from_uri(*current, nullptr, &error); + if (path != nullptr) { + fl_value_append_take(paths, fl_value_new_string(path)); + } + } + return paths; +} + +static void asset_input_method_call_cb(FlMethodChannel*, + FlMethodCall* method_call, + gpointer) { + const gchar* method = fl_method_call_get_name(method_call); + GtkClipboard* clipboard = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); + if (strcmp(method, "readClipboardImageFiles") == 0) { + g_auto(GStrv) uris = gtk_clipboard_wait_for_uris(clipboard); + g_autoptr(FlValue) paths = local_paths_from_uris(uris); + fl_method_call_respond_success(method_call, paths, nullptr); + return; + } + if (strcmp(method, "readClipboardImagePng") == 0) { + g_autoptr(GdkPixbuf) pixbuf = gtk_clipboard_wait_for_image(clipboard); + if (pixbuf == nullptr) { + g_autoptr(FlValue) result = fl_value_new_null(); + fl_method_call_respond_success(method_call, result, nullptr); + return; + } + gchar* buffer = nullptr; + gsize length = 0; + g_autoptr(GError) error = nullptr; + if (!gdk_pixbuf_save_to_buffer(pixbuf, &buffer, &length, "png", &error, + nullptr)) { + g_autoptr(FlValue) details = fl_value_new_null(); + fl_method_call_respond_error( + method_call, "asset.clipboard-encode-failed", + error != nullptr ? error->message : "Could not encode clipboard image.", + details, nullptr); + return; + } + g_autoptr(GBytes) bytes = g_bytes_new_take(buffer, length); + g_autoptr(FlValue) result = fl_value_new_uint8_list_from_bytes(bytes); + fl_method_call_respond_success(method_call, result, nullptr); + return; + } + fl_method_call_respond_not_implemented(method_call, nullptr); +} + +static void asset_drag_data_received_cb(GtkWidget*, + GdkDragContext* context, + gint, + gint, + GtkSelectionData* selection, + guint, + guint time, + gpointer user_data) { + auto* self = MY_APPLICATION(user_data); + g_auto(GStrv) uris = gtk_selection_data_get_uris(selection); + g_autoptr(FlValue) paths = local_paths_from_uris(uris); + const gboolean accepted = fl_value_get_length(paths) > 0; + if (accepted && self->asset_input_channel != nullptr) { + fl_method_channel_invoke_method(self->asset_input_channel, + "assetFilesDropped", paths, nullptr, + nullptr, nullptr); + } + gtk_drag_finish(context, accepted, FALSE, time); +} + +static void register_asset_input_channel(MyApplication* self, FlView* view) { + g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); + self->asset_input_channel = fl_method_channel_new( + fl_engine_get_binary_messenger(fl_view_get_engine(view)), + kAssetInputChannel, FL_METHOD_CODEC(codec)); + fl_method_channel_set_method_call_handler( + self->asset_input_channel, asset_input_method_call_cb, self, nullptr); + GtkTargetEntry targets[] = { + {const_cast("text/uri-list"), 0, 0}, + }; + gtk_drag_dest_set(GTK_WIDGET(view), GTK_DEST_DEFAULT_ALL, targets, 1, + GDK_ACTION_COPY); + g_signal_connect(view, "drag-data-received", + G_CALLBACK(asset_drag_data_received_cb), self); +} + // Called when first Flutter frame received. static void first_frame_cb(MyApplication* self, FlView* view) { gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); @@ -2856,6 +2947,7 @@ static void my_application_activate(GApplication* application) { fl_register_plugins(FL_PLUGIN_REGISTRY(view)); register_header_bar_channel(self, view); register_native_menu_channel(self, view); + register_asset_input_channel(self, view); self->secure_credential_channel = busymark_secure_credential_channel_new(view); self->visualization_host = @@ -2917,6 +3009,7 @@ static void my_application_dispose(GObject* object) { g_clear_object(&self->header_bar_css_provider); g_clear_object(&self->header_bar_channel); g_clear_object(&self->native_menu_channel); + g_clear_object(&self->asset_input_channel); g_clear_object(&self->secure_credential_channel); if (self->visualization_host != nullptr) { busymark_web_render_host_shutdown(self->visualization_host); @@ -2955,6 +3048,7 @@ static void my_application_init(MyApplication* self) { self->dart_entrypoint_arguments = nullptr; self->header_bar_channel = nullptr; self->native_menu_channel = nullptr; + self->asset_input_channel = nullptr; self->secure_credential_channel = nullptr; self->visualization_host = nullptr; self->header_bar_css_provider = nullptr; diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index 0ad0f2a2..6c0645bc 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -1228,7 +1228,7 @@ void main() { expect(find.text(l10n.workspaceKindUnsavedMarkdown), findsWidgets); }); - testWidgets('Ctrl+N with unsaved changes opens confirmation dialog', ( + testWidgets('Ctrl+N keeps unsaved documents in independent tabs', ( tester, ) async { final service = _StartupWorkspaceService(); @@ -1294,14 +1294,19 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.text(l10n.unsavedChanges), findsOneWidget); - - await tester.sendKeyEvent(LogicalKeyboardKey.escape); - await tester.pumpAndSettle(); - expect(find.text(l10n.unsavedChanges), findsNothing); - expect(service.untitledCount, 0); - expect(container.read(workspaceControllerProvider).isDirty, isTrue); + expect(service.untitledCount, 1); + expect( + container.read(workspaceControllerProvider).documentBuffers, + hasLength(2), + ); + expect( + container + .read(workspaceControllerProvider) + .documentBuffers + .where((buffer) => buffer.isDirty), + hasLength(2), + ); await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); await tester.sendKeyDownEvent(LogicalKeyboardKey.keyN); @@ -1317,20 +1322,13 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.text(l10n.unsavedChanges), findsOneWidget); - - await tester.tap(find.text(l10n.discard)); - await tester.pumpAndSettle(); - for ( - var attempt = 0; - attempt < 10 && service.untitledCount == 0; - attempt++ - ) { - await tester.pump(const Duration(milliseconds: 100)); - } - - expect(service.untitledCount, 1); - expect(find.text(l10n.workspaceKindUnsavedMarkdown), findsWidgets); + expect(find.text(l10n.unsavedChanges), findsNothing); + expect(service.untitledCount, 2); + expect( + container.read(workspaceControllerProvider).documentBuffers, + hasLength(3), + ); + await tester.pump(const Duration(milliseconds: 800)); }); testWidgets('Topics defaults creation to root and exposes file-style menu', ( @@ -1905,9 +1903,7 @@ void main() { await pressControlShortcut(LogicalKeyboardKey.tab); expect(find.text(l10n.unsavedChanges), findsNothing); - expect(service.saveCount, 1); - expect(service.savedPath, third.path); - expect(service.savedText, '# Edited third\n'); + expect(service.saveCount, 0); expect( container.read(workspaceControllerProvider).workspace?.activeFilePath, first.path, @@ -1922,6 +1918,10 @@ void main() { await pressControlShortcut(LogicalKeyboardKey.keyW); + expect(find.text(l10n.unsavedChanges), findsOneWidget); + await tester.tap(find.text(l10n.discard)); + await tester.pumpAndSettle(); + expect( container.read(workspaceControllerProvider).workspace?.activeFilePath, second.path, @@ -1930,7 +1930,6 @@ void main() { container.read(workspaceControllerProvider).workspace?.openFilePaths, [first.path, second.path], ); - await pressControlShortcut(LogicalKeyboardKey.keyW, shift: true); expect( @@ -1942,6 +1941,7 @@ void main() { isEmpty, ); expect(find.text(l10n.noOpenFile), findsWidgets); + await tester.pump(const Duration(milliseconds: 800)); }); testWidgets('Git diff files are shown as separate editor tabs', ( @@ -2088,6 +2088,9 @@ void main() { expect(find.byTooltip(l10n.gitBehindCount(3)), findsOneWidget); expect(find.byTooltip(l10n.gitAheadCount(2)), findsOneWidget); + container + .read(workspaceControllerProvider.notifier) + .updateActiveEditorMode(DocumentViewModePreference.preview); await container .read(appSettingsControllerProvider.notifier) .setDocumentViewMode(DocumentViewModePreference.preview); @@ -2136,6 +2139,9 @@ void main() { expect(find.byTooltip(l10n.sourceSearchNextMatch), findsOneWidget); expect(find.byType(TextField), findsOneWidget); + container + .read(workspaceControllerProvider.notifier) + .updateActiveEditorMode(DocumentViewModePreference.editor); await container .read(appSettingsControllerProvider.notifier) .setDocumentViewMode(DocumentViewModePreference.editor); @@ -2173,6 +2179,9 @@ void main() { ); expect(find.byType(TextField), findsOneWidget); + container + .read(workspaceControllerProvider.notifier) + .updateActiveEditorMode(DocumentViewModePreference.split); await container .read(appSettingsControllerProvider.notifier) .setDocumentViewMode(DocumentViewModePreference.split); @@ -2218,6 +2227,9 @@ void main() { expect(find.byTooltip(l10n.sourceSearchPreviousMatch), findsOneWidget); expect(find.byTooltip(l10n.sourceSearchNextMatch), findsOneWidget); + container + .read(workspaceControllerProvider.notifier) + .updateActiveEditorMode(DocumentViewModePreference.source); await container .read(appSettingsControllerProvider.notifier) .setDocumentViewMode(DocumentViewModePreference.source); @@ -3477,7 +3489,14 @@ void main() { } } - final sourceField = find.byType(TextField).last; + final sourceField = find.descendant( + of: find.byType(BusyMarkSourceEditor), + matching: find.byType(TextField), + ); + expect( + tester.widget(sourceField).controller?.text, + '# Introduction.md\n', + ); await tester.tap(sourceField); await tester.enterText(sourceField, '# Edited Introduction\n'); await tester.pump(); @@ -3979,6 +3998,9 @@ void main() { ); expect(editorPadding, expectedStandalone.scrollPadding); + container + .read(workspaceControllerProvider.notifier) + .updateActiveEditorMode(DocumentViewModePreference.preview); await container .read(appSettingsControllerProvider.notifier) .setDocumentViewMode(DocumentViewModePreference.preview); @@ -4038,6 +4060,9 @@ void main() { editorPadding, ); + container + .read(workspaceControllerProvider.notifier) + .updateActiveEditorMode(DocumentViewModePreference.split); await container .read(appSettingsControllerProvider.notifier) .setDocumentViewMode(DocumentViewModePreference.split); @@ -4153,6 +4178,9 @@ void main() { expect(editorCheckedMarker.color, editorPrimary); expect(editorUncheckedMarker.color, editorMarkerColors.foreground); + container + .read(workspaceControllerProvider.notifier) + .updateActiveEditorMode(DocumentViewModePreference.preview); await container .read(appSettingsControllerProvider.notifier) .setDocumentViewMode(DocumentViewModePreference.preview); @@ -4245,6 +4273,9 @@ void main() { final editorTextRect = tester.getRect(editorText); final editorIconRect = tester.getRect(editorIcon); + container + .read(workspaceControllerProvider.notifier) + .updateActiveEditorMode(DocumentViewModePreference.preview); await container .read(appSettingsControllerProvider.notifier) .setDocumentViewMode(DocumentViewModePreference.preview); @@ -4351,6 +4382,9 @@ void main() { final editorTextRect = tester.getRect(editorField); final editorStyle = editorTextField.style; + container + .read(workspaceControllerProvider.notifier) + .updateActiveEditorMode(DocumentViewModePreference.preview); await container .read(appSettingsControllerProvider.notifier) .setDocumentViewMode(DocumentViewModePreference.preview); @@ -4548,6 +4582,9 @@ After break. ), ); + container + .read(workspaceControllerProvider.notifier) + .updateActiveEditorMode(DocumentViewModePreference.preview); await container .read(appSettingsControllerProvider.notifier) .setDocumentViewMode(DocumentViewModePreference.preview); @@ -4667,6 +4704,9 @@ After break. expect(editorImageWidget.width, 320); expect(editorImageWidget.maxWidth, 320); + container + .read(workspaceControllerProvider.notifier) + .updateActiveEditorMode(DocumentViewModePreference.preview); await container .read(appSettingsControllerProvider.notifier) .setDocumentViewMode(DocumentViewModePreference.preview); @@ -4839,6 +4879,9 @@ After break. } } expect(container.read(workspaceControllerProvider).workspace, isNotNull); + container + .read(workspaceControllerProvider.notifier) + .updateActiveEditorMode(DocumentViewModePreference.editor); await container .read(appSettingsControllerProvider.notifier) .setDocumentViewMode(DocumentViewModePreference.editor); @@ -5610,6 +5653,9 @@ Gamma body. ); expectSelectedOutlineRow(2); + container + .read(workspaceControllerProvider.notifier) + .updateActiveEditorMode(DocumentViewModePreference.editor); await container .read(appSettingsControllerProvider.notifier) .setDocumentViewMode(DocumentViewModePreference.editor); @@ -5656,6 +5702,9 @@ Gamma body. ); expectSelectedOutlineRow(1); + container + .read(workspaceControllerProvider.notifier) + .updateActiveEditorMode(DocumentViewModePreference.source); await container .read(appSettingsControllerProvider.notifier) .setDocumentViewMode(DocumentViewModePreference.source); @@ -7003,6 +7052,9 @@ Draft paragraph. expect(editorMarkerText.style?.fontWeight, FontWeight.w600); expect(editorAfterListGap, greaterThan(editorItemGap + BusyMarkSpacing.xs)); + container + .read(workspaceControllerProvider.notifier) + .updateActiveEditorMode(DocumentViewModePreference.preview); await container .read(appSettingsControllerProvider.notifier) .setDocumentViewMode(DocumentViewModePreference.preview); diff --git a/test/src/asset_ingestion_service_test.dart b/test/src/asset_ingestion_service_test.dart new file mode 100644 index 00000000..b3c7730e --- /dev/null +++ b/test/src/asset_ingestion_service_test.dart @@ -0,0 +1,146 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:busymark/src/assets/asset_ingestion_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; + +void main() { + final png = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + ); + + test('ingests workspace images with safe names and portable paths', () async { + final workspace = await Directory.systemTemp.createTemp('busymark-assets-'); + addTearDown(() => workspace.delete(recursive: true)); + final document = File(p.join(workspace.path, 'docs', 'guide.md')); + await document.parent.create(recursive: true); + await document.writeAsString('# Guide\n'); + final source = File(p.join(workspace.path, 'Screen shot (1).txt')); + await source.writeAsBytes(png); + const service = AssetIngestionService(); + + final result = await service.ingestFile( + sourcePath: source.path, + request: AssetIngestionRequest( + documentFilePath: document.path, + workspaceKind: AssetWorkspaceKind.markdownWorkspace, + workspaceRoot: workspace.path, + ), + origin: AssetIngestionOrigin.imagePicker, + ); + + expect( + result.absolutePath, + p.join(workspace.path, 'images', 'Screen-shot-1.png'), + ); + expect(result.markdownPath, '../images/Screen-shot-1.png'); + expect(result.mimeType, 'image/png'); + expect(result.reusedExisting, isFalse); + expect(await File(result.absolutePath).readAsBytes(), png); + }); + + test('reuses identical assets and resolves name collisions', () async { + final workspace = await Directory.systemTemp.createTemp('busymark-assets-'); + addTearDown(() => workspace.delete(recursive: true)); + final document = File(p.join(workspace.path, 'note.md')) + ..writeAsStringSync(''); + const service = AssetIngestionService(); + final request = AssetIngestionRequest( + documentFilePath: document.path, + workspaceKind: AssetWorkspaceKind.markdownWorkspace, + workspaceRoot: workspace.path, + ); + + final first = await service.ingestBytes( + bytes: png, + suggestedFileName: 'image.png', + request: request, + origin: AssetIngestionOrigin.screenshotPaste, + ); + final reused = await service.ingestBytes( + bytes: png, + suggestedFileName: 'different.png', + request: request, + origin: AssetIngestionOrigin.clipboardImageFile, + ); + await File(first.absolutePath).writeAsBytes([...png.take(8), 1]); + final collision = await service.ingestBytes( + bytes: png, + suggestedFileName: 'image.png', + request: request, + origin: AssetIngestionOrigin.dragAndDrop, + ); + + expect(reused.absolutePath, first.absolutePath); + expect(reused.reusedExisting, isTrue); + expect(p.basename(collision.absolutePath), 'image-2.png'); + }); + + test('uses the configured Writerside images directory', () async { + final project = await Directory.systemTemp.createTemp( + 'busymark-ws-assets-', + ); + addTearDown(() => project.delete(recursive: true)); + final document = File(p.join(project.path, 'topics', 'guide.md')); + await document.parent.create(recursive: true); + await document.writeAsString(''); + + final result = await const AssetIngestionService().ingestBytes( + bytes: png, + suggestedFileName: 'diagram.png', + request: AssetIngestionRequest( + documentFilePath: document.path, + workspaceKind: AssetWorkspaceKind.writerside, + writersideRoot: project.path, + imagesDir: 'media/images', + ), + origin: AssetIngestionOrigin.imagePicker, + ); + + expect( + result.absolutePath, + p.join(project.path, 'media', 'images', 'diagram.png'), + ); + expect(result.markdownPath, '../media/images/diagram.png'); + }); + + test('requires saving untitled documents and rejects non-images', () async { + const service = AssetIngestionService(); + const untitled = AssetIngestionRequest( + documentFilePath: '', + workspaceKind: AssetWorkspaceKind.standalone, + ); + + expect( + () => service.ingestBytes( + bytes: png, + suggestedFileName: 'image.png', + request: untitled, + origin: AssetIngestionOrigin.imagePicker, + ), + throwsA(isA()), + ); + + final directory = await Directory.systemTemp.createTemp('busymark-assets-'); + addTearDown(() => directory.delete(recursive: true)); + expect( + () => service.ingestBytes( + bytes: utf8.encode('not an image'), + suggestedFileName: 'fake.png', + request: AssetIngestionRequest( + documentFilePath: p.join(directory.path, 'note.md'), + workspaceKind: AssetWorkspaceKind.standalone, + ), + origin: AssetIngestionOrigin.imagePicker, + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'asset.invalid-image-type', + ), + ), + ); + }); +} diff --git a/test/src/busymark_document_test.dart b/test/src/busymark_document_test.dart index d7699de2..755b2d1a 100644 --- a/test/src/busymark_document_test.dart +++ b/test/src/busymark_document_test.dart @@ -5564,6 +5564,85 @@ void main() {} expect(controller.markdown, ''); }); + test('table alignment survives parsing, edits, and structural changes', () { + const source = + '| Left | Center | Right | Default |\n' + '| :--- | :---: | ---: | --- |\n' + '| a | b | c | d |\n'; + final parsed = parser.parse(filePath: 'topic.md', source: source); + final table = parsed.busyDocument.blocks.single; + final header = table.children.first.children; + + expect(header.map((cell) => cell.attributes['align']), [ + 'left', + 'center', + 'right', + isNull, + ]); + + final controller = BusyMarkWysiwygDocumentController( + document: parsed.busyDocument, + ); + String tableId() => controller.document.blocks.single.id; + final firstBodyCell = table.children[1].children.first.id; + controller.updateTableCellText(tableId(), firstBodyCell, 'edited'); + controller.insertTableRow(tableId(), 0, after: false); + controller.insertTableColumn(tableId(), 1, after: true); + + expect( + controller.markdown, + '| | | | | |\n' + '| :--- | :---: | --- | ---: | --- |\n' + '| Left | Center | | Right | Default |\n' + '| edited | b | | c | d |\n', + ); + + controller.deleteTableColumn(tableId(), 2); + expect( + controller.markdown, + '| | | | |\n' + '| :--- | :---: | ---: | --- |\n' + '| Left | Center | Right | Default |\n' + '| edited | b | c | d |\n', + ); + + final roundTrip = parser.parse( + filePath: 'topic.md', + source: controller.markdown, + ); + expect( + const BusyMarkMarkdownSerializer().serialize(roundTrip.busyDocument), + controller.markdown, + ); + }); + + test('table column alignment command supports all Markdown alignments', () { + final parsed = parser.parse( + filePath: 'topic.md', + source: '| A | B |\n| --- | --- |\n| a | b |\n', + ); + final controller = BusyMarkWysiwygDocumentController( + document: parsed.busyDocument, + ); + final tableId = controller.document.blocks.single.id; + + controller.setTableColumnAlignment(tableId, 0, BusyTableAlignment.left); + controller.setTableColumnAlignment(tableId, 1, BusyTableAlignment.center); + expect(controller.markdown, contains('| :--- | :---: |')); + + controller.setTableColumnAlignment(tableId, 0, BusyTableAlignment.right); + controller.setTableColumnAlignment( + tableId, + 1, + BusyTableAlignment.unspecified, + ); + expect(controller.markdown, contains('| ---: | --- |')); + expect( + controller.tableColumnAlignment(tableId, 0), + BusyTableAlignment.right, + ); + }); + testWidgets('WYSIWYG table cells are formatted and editable', (tester) async { final parsed = parser.parse( filePath: 'topic.md', @@ -5647,6 +5726,16 @@ void main() {} '| Alice | Cell |\n', ); + await tester.tap(find.byTooltip('Column 1')); + await tester.pumpAndSettle(); + expect(find.text('Alignment: Unspecified'), findsOneWidget); + expect(find.text('Alignment: Left'), findsOneWidget); + expect(find.text('Alignment: Center'), findsOneWidget); + expect(find.text('Alignment: Right'), findsOneWidget); + await tester.tap(find.text('Alignment: Left')); + await tester.pumpAndSettle(); + expect(markdown, contains('| :--- | --- |')); + await tester.tap(deleteTableFinder); await tester.pump(); diff --git a/test/src/command_registry_test.dart b/test/src/command_registry_test.dart new file mode 100644 index 00000000..c8c8956b --- /dev/null +++ b/test/src/command_registry_test.dart @@ -0,0 +1,106 @@ +import 'package:busymark/src/app/command_registry.dart'; +import 'package:busymark/src/app/busymark_shortcuts.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/widgets.dart'; + +void main() { + BusyMarkCommand command( + String id, { + BusyMarkShortcutDefinition? shortcut, + BusyMarkCommandScope scope = BusyMarkCommandScope.application, + }) { + return BusyMarkCommand( + id: id, + label: (_) => id, + category: (_) => 'Test', + scope: scope, + shortcut: shortcut, + ); + } + + test('catalog exposes stable unique IDs for all migrated shortcuts', () { + final registry = BusyMarkCommandCatalog.create(); + + expect(registry.commands, isNotEmpty); + expect(registry[BusyMarkCommandIds.save]?.shortcut?.label, 'Ctrl+S'); + expect( + registry[BusyMarkCommandIds.commandPalette]?.shortcut?.label, + 'Ctrl+Shift+P', + ); + expect( + registry.commands.map((command) => command.id).toSet().length, + registry.commands.length, + ); + }); + + test('rejects duplicate command IDs', () { + expect( + () => BusyMarkCommandRegistry([ + command('test.duplicate'), + command('test.duplicate'), + ]), + throwsA(isA()), + ); + }); + + test('rejects shortcut conflicts in the same command scope', () { + const shortcut = BusyMarkShortcutDefinition( + label: 'Ctrl+J', + activator: SingleActivator(LogicalKeyboardKey.keyJ, control: true), + ); + + expect( + () => BusyMarkCommandRegistry([ + command('test.first', shortcut: shortcut), + command('test.second', shortcut: shortcut), + ]), + throwsA(isA()), + ); + }); + + test('allows the same shortcut in distinct focus scopes', () { + const shortcut = BusyMarkShortcutDefinition( + label: 'Ctrl+J', + activator: SingleActivator(LogicalKeyboardKey.keyJ, control: true), + ); + + expect( + () => BusyMarkCommandRegistry([ + command('test.first', shortcut: shortcut), + command( + 'test.second', + shortcut: shortcut, + scope: BusyMarkCommandScope.editor, + ), + ]), + returnsNormally, + ); + }); + + test('executes only visible enabled bound commands', () async { + var calls = 0; + final registry = BusyMarkCommandRegistry([ + BusyMarkCommand( + id: 'test.run', + label: (_) => 'Run', + category: (_) => 'Test', + scope: BusyMarkCommandScope.application, + execute: () => calls++, + ), + BusyMarkCommand( + id: 'test.disabled', + label: (_) => 'Disabled', + category: (_) => 'Test', + scope: BusyMarkCommandScope.application, + enabled: () => false, + execute: () => calls++, + ), + ]); + + expect(await registry.execute('test.run'), isTrue); + expect(await registry.execute('test.disabled'), isFalse); + expect(await registry.execute('test.missing'), isFalse); + expect(calls, 1); + }); +} diff --git a/test/src/document_persistence_test.dart b/test/src/document_persistence_test.dart new file mode 100644 index 00000000..5026aa32 --- /dev/null +++ b/test/src/document_persistence_test.dart @@ -0,0 +1,105 @@ +import 'dart:io'; + +import 'package:busymark/src/app/app_settings.dart'; +import 'package:busymark/src/workspace/document_buffer.dart'; +import 'package:busymark/src/workspace/recovery_persistence.dart'; +import 'package:busymark/src/workspace/session_persistence.dart'; +import 'package:busymark/src/workspace/text_format_metadata.dart'; +import 'package:busymark/src/workspace/workspace_file_snapshot.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; + +void main() { + test('session store round-trips ordered tabs and editor state', () async { + final directory = await Directory.systemTemp.createTemp( + 'busymark-session-', + ); + addTearDown(() => directory.delete(recursive: true)); + final store = JsonDocumentSessionStore( + filePathOverride: p.join(directory.path, 'session.json'), + ); + final snapshot = WorkspaceSessionSnapshot( + workspacePath: '/workspace', + activeBufferId: 'second', + tabs: [ + DocumentSessionEntry( + id: 'first', + filePath: '/workspace/first.md', + untitledName: null, + editorState: const DocumentEditorState( + mode: DocumentViewModePreference.split, + selection: TextSelection(baseOffset: 2, extentOffset: 8), + scrollOffset: 42, + foldedRegionKeys: {'heading:2'}, + ), + ), + const DocumentSessionEntry( + id: 'second', + filePath: null, + untitledName: 'Untitled 2', + editorState: DocumentEditorState(), + ), + ], + ); + + await store.save(snapshot); + final restored = await store.load(); + + expect(restored?.workspacePath, '/workspace'); + expect(restored?.activeBufferId, 'second'); + expect(restored?.tabs.map((entry) => entry.id), ['first', 'second']); + expect( + restored?.tabs.first.editorState.mode, + DocumentViewModePreference.split, + ); + expect( + restored?.tabs.first.editorState.selection, + const TextSelection(baseOffset: 2, extentOffset: 8), + ); + expect(restored?.tabs.first.editorState.scrollOffset, 42); + expect(restored?.tabs.first.editorState.foldedRegionKeys, {'heading:2'}); + }); + + test('recovery store distinguishes clean and unclean runs', () async { + final directory = await Directory.systemTemp.createTemp( + 'busymark-recovery-', + ); + addTearDown(() => directory.delete(recursive: true)); + final path = p.join(directory.path, 'recovery.json'); + final store = JsonDocumentRecoveryStore(filePathOverride: path); + final buffer = DocumentBuffer( + id: 'file:note', + filePath: '/workspace/note.md', + text: '# Unsaved\n', + lastSavedText: '# Saved\n', + dirty: true, + diskSnapshot: WorkspaceFileSnapshot( + modifiedAt: DateTime.utc(2026), + size: 8, + contentHash: 'saved', + ), + format: const TextFormatMetadata( + hasUtf8Bom: true, + lineEnding: DocumentLineEnding.crlf, + hasFinalNewline: true, + ), + ); + + expect((await store.beginRun()).cleanShutdown, isTrue); + await store.writeEntries([ + DocumentRecoveryEntry.fromBuffer(buffer, workspacePath: '/workspace'), + ]); + + final afterCrash = JsonDocumentRecoveryStore(filePathOverride: path); + final recovered = await afterCrash.beginRun(); + expect(recovered.cleanShutdown, isFalse); + expect(recovered.entries.single.text, '# Unsaved\n'); + expect(recovered.entries.single.diskSnapshot?.contentHash, 'saved'); + expect(recovered.entries.single.format.hasUtf8Bom, isTrue); + + await afterCrash.markCleanShutdown(); + final normalStart = JsonDocumentRecoveryStore(filePathOverride: path); + expect((await normalStart.beginRun()).cleanShutdown, isTrue); + }); +} diff --git a/test/src/editor_ui_primitives_audit_test.dart b/test/src/editor_ui_primitives_audit_test.dart index 437b239a..78b89903 100644 --- a/test/src/editor_ui_primitives_audit_test.dart +++ b/test/src/editor_ui_primitives_audit_test.dart @@ -54,7 +54,11 @@ void main() { expect(htmlBlock, isNot(matches(RegExp(r'(? match.original), ['cat', 'Cat']); + expect(preview.apply(), 'dog scatter dog'); + }); + + test('expands numbered and named regex capture groups', () { + final numbered = replacementService.previewText( + source: 'Ada Lovelace; Grace Hopper', + options: const SourceSearchOptions(query: r'(\w+) (\w+)', regex: true), + replacement: r'$2, $1', + ); + final named = replacementService.previewText( + source: 'x=42', + options: const SourceSearchOptions( + query: r'(?\w+)=(?\d+)', + regex: true, + ), + replacement: r'${value}:${name}:$$:$&', + ); + + expect(numbered.apply(), 'Lovelace, Ada; Hopper, Grace'); + expect(named.apply(), r'42:x:$:x=42'); + }); + + test('applies only selected preview matches', () { + final preview = replacementService.previewText( + source: 'one one one', + options: const SourceSearchOptions(query: 'one'), + replacement: 'two', + ); + + expect( + preview.apply(selectedMatchIds: {preview.matches[1].id}), + 'one two one', + ); + }); + + test( + 'workspace preview uses dirty buffers and writes closed files', + () async { + final directory = await Directory.systemTemp.createTemp( + 'busymark-replace-', + ); + addTearDown(() => directory.delete(recursive: true)); + final openFile = File(p.join(directory.path, 'open.md')); + final closedFile = File(p.join(directory.path, 'closed.md')); + await openFile.writeAsString('disk cat'); + await closedFile.writeAsString('closed cat'); + const workspaceService = WorkspaceService(); + final openLoad = await workspaceService.loadTextWithSnapshot( + openFile.path, + ); + final buffer = DocumentBuffer.file( + id: 'open', + filePath: openFile.path, + text: openLoad.text, + snapshot: openLoad.snapshot, + format: openLoad.format, + ).edited('dirty cat'); + final workspace = Workspace( + id: directory.path, + rootPath: directory.path, + kind: WorkspaceKind.markdownFolder, + openedAt: DateTime(2026), + activeFilePath: openFile.path, + openFilePaths: [openFile.path], + files: [ + await _documentFile(openFile, directory.path), + await _documentFile(closedFile, directory.path), + ], + diagnostics: const [], + ); + var state = WorkspaceState( + workspace: workspace, + documentBuffers: [buffer], + activeBufferId: buffer.id, + ); + + final preview = await replacementService.previewWorkspace( + state: state, + workspaceService: workspaceService, + options: const SourceSearchOptions(query: 'cat'), + replacement: 'dog', + ); + + expect(preview.files, hasLength(2)); + expect( + preview.files + .singleWhere((file) => file.filePath == openFile.path) + .sourceKind, + WorkspaceReplacementSourceKind.dirtyBuffer, + ); + final result = await replacementService.applyWorkspace( + preview: preview, + selectedMatchIds: { + for (final file in preview.files) + for (final match in file.matches) match.id, + }, + currentState: () => state, + updateBuffer: (bufferId, text) { + state = state.copyWith( + documentBuffers: [state.documentBuffers.single.edited(text)], + ); + }, + workspaceService: workspaceService, + ); + + expect(result.appliedFiles, 2); + expect(state.documentBuffers.single.text, 'dirty dog'); + expect(await openFile.readAsString(), 'disk cat'); + expect(await closedFile.readAsString(), 'closed dog'); + }, + ); + + test('workspace apply skips stale dirty buffers', () async { + final directory = await Directory.systemTemp.createTemp( + 'busymark-replace-', + ); + addTearDown(() => directory.delete(recursive: true)); + final file = File(p.join(directory.path, 'open.md')); + await file.writeAsString('cat'); + const workspaceService = WorkspaceService(); + final load = await workspaceService.loadTextWithSnapshot(file.path); + final buffer = DocumentBuffer.file( + id: 'open', + filePath: file.path, + text: load.text, + snapshot: load.snapshot, + format: load.format, + ).edited('cat dirty'); + final workspace = Workspace( + id: directory.path, + rootPath: directory.path, + kind: WorkspaceKind.markdownFolder, + openedAt: DateTime(2026), + activeFilePath: file.path, + openFilePaths: [file.path], + files: [await _documentFile(file, directory.path)], + diagnostics: const [], + ); + var state = WorkspaceState( + workspace: workspace, + documentBuffers: [buffer], + activeBufferId: buffer.id, + ); + final preview = await replacementService.previewWorkspace( + state: state, + workspaceService: workspaceService, + options: const SourceSearchOptions(query: 'cat'), + replacement: 'dog', + ); + state = state.copyWith( + documentBuffers: [state.documentBuffers.single.edited('changed again')], + ); + + final result = await replacementService.applyWorkspace( + preview: preview, + selectedMatchIds: {preview.files.single.matches.single.id}, + currentState: () => state, + updateBuffer: (_, _) => fail('stale buffer must not be updated'), + workspaceService: workspaceService, + ); + + expect(result.appliedFiles, 0); + expect( + result.issues.single.kind, + WorkspaceReplacementIssueKind.bufferRevisionChanged, + ); + }); +} + +Future _documentFile(File file, String root) async { + final stat = await file.stat(); + return DocumentFile( + absolutePath: file.path, + relativePath: p.relative(file.path, from: root), + kind: DocumentKind.markdown, + size: stat.size, + lastModified: stat.modified, + ); +} diff --git a/test/src/source_editor_widget_test.dart b/test/src/source_editor_widget_test.dart index 8608fedd..6e792444 100644 --- a/test/src/source_editor_widget_test.dart +++ b/test/src/source_editor_widget_test.dart @@ -491,6 +491,72 @@ void main() { expect(find.byTooltip(en.expandKind(en.foldKindSection)), findsOneWidget); }); + testWidgets('source Replace All is one editor operation', (tester) async { + final en = AppLocalizationsEn(); + var replacement = ''; + var currentText = 'cat cat'; + String? undoText; + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) => SizedBox( + width: 900, + height: 600, + child: BusyMarkSourceEditor( + text: currentText, + language: SourceSyntaxLanguage.markdown, + filePath: '/project/topic.md', + diagnostics: const [], + editorFontSize: 14, + wordWrap: true, + searchActive: true, + searchOptions: const SourceSearchOptions(query: 'cat'), + searchReplacement: replacement, + onSearchReplacementChanged: (value) => + setState(() => replacement = value), + onSearchOptionsChanged: (_) {}, + onChanged: (text, _) { + undoText = currentText; + setState(() => currentText = text); + }, + onUndo: () { + final previous = undoText; + if (previous == null) { + return null; + } + undoText = null; + setState(() => currentText = previous); + return previous; + }, + onOpenSearch: () {}, + onCloseSearch: () {}, + ), + ), + ), + ), + ), + ); + await tester.enterText( + find.byKey(const ValueKey('source-search-replacement')), + 'dog', + ); + await tester.pump(); + await tester.tap(find.byTooltip(en.sourceSearchReplaceAll)); + await tester.pump(); + + expect(currentText, 'dog dog'); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + + expect(currentText, 'cat cat'); + }); + testWidgets( 'source editor gives glyphs, caret, and selection breathing room', (tester) async { diff --git a/test/src/text_format_metadata_test.dart b/test/src/text_format_metadata_test.dart new file mode 100644 index 00000000..328b689a --- /dev/null +++ b/test/src/text_format_metadata_test.dart @@ -0,0 +1,54 @@ +import 'dart:convert'; + +import 'package:busymark/src/workspace/text_format_metadata.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('detects and preserves UTF-8 BOM, CRLF, and final newline', () { + final bytes = [0xef, 0xbb, 0xbf, ...utf8.encode('one\r\ntwo\r\n')]; + + final decoded = decodeUtf8Document(bytes); + + expect(decoded.text, 'one\ntwo\n'); + expect(decoded.format.hasUtf8Bom, isTrue); + expect(decoded.format.lineEnding, DocumentLineEnding.crlf); + expect(decoded.format.hasFinalNewline, isTrue); + expect(decoded.format.encode(decoded.text), bytes); + }); + + test('preserves a missing final newline when saving edited text', () { + final decoded = decodeUtf8Document(utf8.encode('one\ntwo')); + + expect(decoded.format.hasFinalNewline, isFalse); + expect( + utf8.decode(decoded.format.encode('changed\ntext\n')), + 'changed\ntext', + ); + }); + + test('requires an explicit normalization for mixed line endings', () { + final decoded = decodeUtf8Document(utf8.encode('one\r\ntwo\nthree')); + + expect(decoded.format.lineEnding, DocumentLineEnding.mixed); + expect( + () => decoded.format.encode(decoded.text), + throwsA(isA()), + ); + expect( + utf8.decode( + decoded.format.encode( + decoded.text, + mixedNormalization: LineEndingNormalization.crlf, + ), + ), + 'one\r\ntwo\r\nthree', + ); + }); + + test('rejects invalid UTF-8 instead of replacing bytes', () { + expect( + () => decodeUtf8Document(const [0xc3, 0x28]), + throwsA(isA()), + ); + }); +} diff --git a/test/src/workspace_controller_test.dart b/test/src/workspace_controller_test.dart index a6d89386..c40a4716 100644 --- a/test/src/workspace_controller_test.dart +++ b/test/src/workspace_controller_test.dart @@ -439,36 +439,40 @@ void main() { }, ); - test('save as explicit overwrite replaces the final symlink only', () async { - final directory = await Directory.systemTemp.createTemp( - 'busymark-save-as-symlink-', - ); - final target = File('${directory.path}/target.md'); - final link = Link('${directory.path}/note.md'); - await target.writeAsString('# Target\n'); - await link.create(target.path); - final harness = await _createControllerHarness(); - final settingsController = harness.settingsController; - final controller = harness.controller; + test( + 'save as explicit overwrite replaces the final symlink only', + () async { + final directory = await Directory.systemTemp.createTemp( + 'busymark-save-as-symlink-', + ); + final target = File('${directory.path}/target.md'); + final link = Link('${directory.path}/note.md'); + await target.writeAsString('# Target\n'); + await link.create(target.path); + final harness = await _createControllerHarness(); + final settingsController = harness.settingsController; + final controller = harness.controller; - await controller.createMarkdownFile(); - controller.updateActiveText('# Draft\n'); + await controller.createMarkdownFile(); + controller.updateActiveText('# Draft\n'); - expect( - await controller.saveActiveAs(link.path, overwriteExisting: true), - isTrue, - ); - expect( - await FileSystemEntity.type(link.path, followLinks: false), - FileSystemEntityType.file, - ); - expect(await File(link.path).readAsString(), '# Draft\n'); - expect(await target.readAsString(), '# Target\n'); + expect( + await controller.saveActiveAs(link.path, overwriteExisting: true), + isTrue, + ); + expect( + await FileSystemEntity.type(link.path, followLinks: false), + FileSystemEntityType.file, + ); + expect(await File(link.path).readAsString(), '# Draft\n'); + expect(await target.readAsString(), '# Target\n'); - controller.dispose(); - settingsController.dispose(); - await directory.delete(recursive: true); - }, skip: Platform.isWindows ? 'POSIX symlink behavior only.' : false); + controller.dispose(); + settingsController.dispose(); + await directory.delete(recursive: true); + }, + skip: Platform.isWindows ? 'POSIX symlink behavior only.' : false, + ); test( 'save as preserves source edits for an untitled Markdown file', @@ -714,6 +718,38 @@ void main() { settingsController.dispose(); }); + test('Save All writes every dirty file-backed buffer', () async { + final directory = await Directory.systemTemp.createTemp( + 'busymark-save-all-', + ); + addTearDown(() => directory.delete(recursive: true)); + final first = File(p.join(directory.path, 'a.md')) + ..writeAsStringSync('# A\n'); + final second = File(p.join(directory.path, 'b.md')) + ..writeAsStringSync('# B\n'); + final harness = await _createControllerHarness(); + final settingsController = harness.settingsController; + final controller = harness.controller; + + await controller.openPath(directory.path); + controller.updateActiveText('# Edited A\n'); + expect(await controller.openActiveFile(second.path), isTrue); + controller.updateActiveText('# Edited B\n'); + + final result = await controller.saveAll(); + + expect(result.savedBufferIds, hasLength(2)); + expect(result.failedBufferIds, isEmpty); + expect(result.conflictBufferIds, isEmpty); + expect(controller.state.dirtyBuffers, isEmpty); + expect(controller.state.workspace?.activeFilePath, second.path); + expect(first.readAsStringSync(), '# Edited A\n'); + expect(second.readAsStringSync(), '# Edited B\n'); + + controller.dispose(); + settingsController.dispose(); + }); + test('closing active file tabs selects a neighboring tab', () async { final harness = await _createControllerHarness(); final settingsController = harness.settingsController; @@ -1126,6 +1162,8 @@ class _WorkspaceControllerDriver { Future saveActiveAs(String path, {bool overwriteExisting = false}) => _notifier.saveActiveAs(path, overwriteExisting: overwriteExisting); + Future saveAll() => _notifier.saveAll(); + Future autoSaveActiveIfNeeded() => _notifier.autoSaveActiveIfNeeded(); Future discardActiveChanges() => _notifier.discardActiveChanges(); diff --git a/test/src/wysiwyg_visualization_diagnostic_test.dart b/test/src/wysiwyg_visualization_diagnostic_test.dart index 680f2659..7d8bd327 100644 --- a/test/src/wysiwyg_visualization_diagnostic_test.dart +++ b/test/src/wysiwyg_visualization_diagnostic_test.dart @@ -59,88 +59,91 @@ void main() { ); }); - testWidgets('WYSIWYG diagnostic selects its actual source line', ( - tester, - ) async { - final coordinator = VisualizationCoordinator( - renderers: const [_DiagnosticRenderer()], - cache: _MemoryVisualizationCache(cacheDirectory), - ); - addTearDown(coordinator.dispose); - final controller = BusyMarkWysiwygTextController( - text: 'first\nsecond\nthird', - ranges: const [], - ); - final undoController = UndoHistoryController(); - final focusNode = FocusNode(); - addTearDown(controller.dispose); - addTearDown(undoController.dispose); - addTearDown(focusNode.dispose); - var focusCalls = 0; - - await tester.pumpWidget( - ProviderScope( - overrides: [ - visualizationCoordinatorProvider.overrideWithValue(coordinator), - ], - child: MaterialApp( - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: Scaffold( - body: SingleChildScrollView( - child: BusyMarkWysiwygBlockField( - block: const BusyBlock( - id: 'diagram', - kind: BusyBlockKind.codeBlock, - attributes: {'language': 'mermaid'}, - inlines: [ - BusyInline( - kind: BusyInlineKind.text, - text: 'first\nsecond\nthird', + testWidgets( + 'WYSIWYG diagnostic selects its actual source line', + (tester) async { + final coordinator = VisualizationCoordinator( + renderers: const [_DiagnosticRenderer()], + cache: _MemoryVisualizationCache(cacheDirectory), + ); + addTearDown(coordinator.dispose); + final controller = BusyMarkWysiwygTextController( + text: 'first\nsecond\nthird', + ranges: const [], + ); + final undoController = UndoHistoryController(); + final focusNode = FocusNode(); + addTearDown(controller.dispose); + addTearDown(undoController.dispose); + addTearDown(focusNode.dispose); + var focusCalls = 0; + + await tester.pumpWidget( + ProviderScope( + overrides: [ + visualizationCoordinatorProvider.overrideWithValue(coordinator), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SingleChildScrollView( + child: BusyMarkWysiwygBlockField( + block: const BusyBlock( + id: 'diagram', + kind: BusyBlockKind.codeBlock, + attributes: {'language': 'mermaid'}, + inlines: [ + BusyInline( + kind: BusyInlineKind.text, + text: 'first\nsecond\nthird', + ), + ], + sourceSpan: SourceSpan( + filePath: '/workspace/demo.md', + startOffset: 20, + endOffset: 55, + startLine: 5, + startColumn: 1, + endLine: 9, + endColumn: 4, ), - ], - sourceSpan: SourceSpan( - filePath: '/workspace/demo.md', - startOffset: 20, - endOffset: 55, - startLine: 5, - startColumn: 1, - endLine: 9, - endColumn: 4, ), + documentFilePath: '/workspace/demo.md', + workspaceRoot: '/workspace', + allowRemoteImages: false, + controller: controller, + undoController: undoController, + focusNode: focusNode, + onChanged: (_) {}, + onTableCellChanged: (_, _) {}, + onTableRowInserted: (_, {required after}) {}, + onTableRowDeleted: (_) {}, + onTableColumnInserted: (_, {required after}) {}, + onTableColumnDeleted: (_) {}, + onTableColumnAlignmentChanged: (_, _) {}, + onTableDeleted: () {}, + onImageEditRequested: () {}, + onHtmlEditRequested: () {}, + onTaskChanged: (_) {}, + onFocused: () => focusCalls++, ), - documentFilePath: '/workspace/demo.md', - workspaceRoot: '/workspace', - allowRemoteImages: false, - controller: controller, - undoController: undoController, - focusNode: focusNode, - onChanged: (_) {}, - onTableCellChanged: (_, _) {}, - onTableRowInserted: (_, {required after}) {}, - onTableRowDeleted: (_) {}, - onTableColumnInserted: (_, {required after}) {}, - onTableColumnDeleted: (_) {}, - onTableDeleted: () {}, - onImageEditRequested: () {}, - onHtmlEditRequested: () {}, - onTaskChanged: (_) {}, - onFocused: () => focusCalls++, ), ), ), ), - ), - ); - - await _pumpUntilFound(tester, find.text('Broken third line')); - await tester.tap(find.text('Broken third line')); - await tester.pump(); - - expect(focusCalls, 1); - expect(focusNode.hasFocus, isTrue); - expect(controller.selection, const TextSelection.collapsed(offset: 13)); - }, timeout: const Timeout(Duration(seconds: 10))); + ); + + await _pumpUntilFound(tester, find.text('Broken third line')); + await tester.tap(find.text('Broken third line')); + await tester.pump(); + + expect(focusCalls, 1); + expect(focusNode.hasFocus, isTrue); + expect(controller.selection, const TextSelection.collapsed(offset: 13)); + }, + timeout: const Timeout(Duration(seconds: 10)), + ); } Future _pumpUntilFound(WidgetTester tester, Finder finder) async { From 0937f1668945344e5fea66f0be390cbde1650569 Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 21 Aug 2026 05:11:42 -0700 Subject: [PATCH 04/38] Add offline MathJax document support --- README.md | 8 + assets/export/markdown.typ | 51 ++ docs/math.md | 91 ++++ docs/visualizations.md | 7 +- lib/l10n/app_ar.arb | 5 +- lib/l10n/app_de.arb | 5 +- lib/l10n/app_en.arb | 8 +- lib/l10n/app_es.arb | 5 +- lib/l10n/app_et.arb | 5 +- lib/l10n/app_fa.arb | 5 +- lib/l10n/app_fr.arb | 5 +- lib/l10n/app_hi.arb | 5 +- lib/l10n/app_it.arb | 5 +- lib/l10n/app_nb.arb | 5 +- lib/l10n/app_pl.arb | 5 +- lib/l10n/app_pt.arb | 5 +- lib/l10n/app_ru.arb | 5 +- lib/l10n/app_uk.arb | 5 +- lib/l10n/generated/app_localizations.dart | 18 + lib/l10n/generated/app_localizations_ar.dart | 9 + lib/l10n/generated/app_localizations_de.dart | 10 + lib/l10n/generated/app_localizations_en.dart | 10 + lib/l10n/generated/app_localizations_es.dart | 10 + lib/l10n/generated/app_localizations_et.dart | 9 + lib/l10n/generated/app_localizations_fa.dart | 9 + lib/l10n/generated/app_localizations_fr.dart | 10 + lib/l10n/generated/app_localizations_hi.dart | 9 + lib/l10n/generated/app_localizations_it.dart | 10 + lib/l10n/generated/app_localizations_nb.dart | 10 + lib/l10n/generated/app_localizations_pl.dart | 10 + lib/l10n/generated/app_localizations_pt.dart | 10 + lib/l10n/generated/app_localizations_ru.dart | 10 + lib/l10n/generated/app_localizations_uk.dart | 9 + lib/src/app/busymark_glyphs.dart | 1 + lib/src/core/diagnostic_localizations.dart | 6 + lib/src/editor/source_highlighter.dart | 114 +++++ .../editor/wysiwyg/wysiwyg_block_widgets.dart | 109 ++++ .../wysiwyg/wysiwyg_document_controller.dart | 103 +++- lib/src/editor/wysiwyg/wysiwyg_editor.dart | 69 ++- .../wysiwyg/wysiwyg_inline_controller.dart | 28 +- lib/src/editor/wysiwyg/wysiwyg_toolbar.dart | 16 + lib/src/export/markdown_export_document.dart | 36 ++ lib/src/export/markdown_export_mapper.dart | 20 + lib/src/export/markdown_math_export.dart | 335 +++++++++++++ .../export/markdown_pdf_export_service.dart | 40 +- lib/src/export/markdown_pdf_export_ui.dart | 5 + lib/src/export/markdown_pdf_models.dart | 2 + lib/src/export/typst_payload_builder.dart | 1 + lib/src/markdown/busymark_document.dart | 2 + .../busymark_markdown_serializer.dart | 33 ++ lib/src/markdown/markdown_ast_adapter.dart | 92 +++- lib/src/markdown/markdown_parser.dart | 67 ++- lib/src/markdown/math_syntax.dart | 308 ++++++++++++ lib/src/markdown/preview_model.dart | 73 ++- lib/src/math/math_cache.dart | 30 ++ lib/src/math/math_coordinator.dart | 198 ++++++++ lib/src/math/math_models.dart | 131 +++++ lib/src/math/math_providers.dart | 13 + lib/src/math/math_renderer.dart | 205 ++++++++ lib/src/math/math_svg_preprocessor.dart | 152 ++++++ lib/src/math/math_widget.dart | 250 ++++++++++ .../visualization_release_smoke.dart | 144 ++++++ lib/src/visualization/web_render_host.dart | 20 + .../presentation/workspace_screen.dart | 50 +- lib/src/workspace/workspace_service.dart | 45 +- linux/CMakeLists.txt | 3 + linux/runner/web_render_host.cc | 3 +- .../basic_project/topics/math.topic | 5 + test/src/busymark_design_test.dart | 4 + test/src/d2_renderer_test.dart | 6 + test/src/markdown_math_export_test.dart | 179 +++++++ test/src/math_parser_test.dart | 247 ++++++++++ test/src/math_renderer_test.dart | 287 +++++++++++ test/src/math_widget_test.dart | 209 ++++++++ .../src/openapi_dependency_resolver_test.dart | 6 + test/src/source_audit_test.dart | 2 + test/src/source_highlighter_test.dart | 22 + test/src/visualization_card_test.dart | 6 + .../visualization_packaging_audit_test.dart | 56 ++- .../src/visualization_raster_sizing_test.dart | 6 + test/src/web_render_host_test.dart | 23 + test/src/writerside_test.dart | 26 + test/src/wysiwyg_math_test.dart | 215 ++++++++ tools/fetch_visualization_web.sh | 50 +- tools/visualization/build_render_engines.js | 144 ++++++ tools/visualization/generate_notices.js | 61 ++- tools/visualization/mathjax_renderer.js | 297 +++++++++++ tools/visualization/mermaid_math_disabled.js | 9 + tools/visualization/package-lock.json | 465 ++++++++++++++++-- tools/visualization/package.json | 26 +- tools/visualization/render_engines.js | 3 + tools/visualization_smoke.py | 248 ++++++++++ 92 files changed, 5533 insertions(+), 156 deletions(-) create mode 100644 docs/math.md create mode 100644 lib/src/export/markdown_math_export.dart create mode 100644 lib/src/markdown/math_syntax.dart create mode 100644 lib/src/math/math_cache.dart create mode 100644 lib/src/math/math_coordinator.dart create mode 100644 lib/src/math/math_models.dart create mode 100644 lib/src/math/math_providers.dart create mode 100644 lib/src/math/math_renderer.dart create mode 100644 lib/src/math/math_svg_preprocessor.dart create mode 100644 lib/src/math/math_widget.dart create mode 100644 test/fixtures/writerside/basic_project/topics/math.topic create mode 100644 test/src/markdown_math_export_test.dart create mode 100644 test/src/math_parser_test.dart create mode 100644 test/src/math_renderer_test.dart create mode 100644 test/src/math_widget_test.dart create mode 100644 test/src/wysiwyg_math_test.dart create mode 100644 tools/visualization/build_render_engines.js create mode 100644 tools/visualization/mathjax_renderer.js create mode 100644 tools/visualization/mermaid_math_disabled.js diff --git a/README.md b/README.md index 8bb51fdd..be4fea2a 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ projects. - Edit and save local files. - Read rendered Markdown without editing it. - Render Mermaid, PlantUML, D2, and fenced OpenAPI content locally and offline. +- Typeset inline and display mathematics locally and offline with bundled + MathJax, including Writerside math forms and vector PDF output. - Edit Markdown with free-form AI instructions, an explicit change target, and explicitly selected context through Ollama, OpenAI, or Gemini, with diff-before-apply review. @@ -103,6 +105,12 @@ compiler; users do not install or configure a separate program. Local PNG, JPEG, GIF, and safe SVG images are included. Remote images are deliberately not downloaded during export and are represented by their alternative text. +Inline and display equations use the same bundled MathJax semantics in preview +and PDF export. Safe generated equations remain self-contained vector SVG, with +inline baseline metrics carried into Typst. See [mathematical expressions](docs/math.md) +for supported Markdown and Writerside forms, the scientific TeX package profile, +editing behavior, and offline security boundaries. + Mermaid and PlantUML fences are exported as vector diagrams. D2 uses normalized SVG where possible and a local high-resolution raster fallback for browser-only labels. OpenAPI fences become static, selectable API reference content. Failed diff --git a/assets/export/markdown.typ b/assets/export/markdown.typ index b9effeb1..2d0c32c7 100644 --- a/assets/export/markdown.typ +++ b/assets/export/markdown.typ @@ -35,6 +35,53 @@ #let value-or(item, key, default) = item.at(key, default: default) +#let render-math(item, inline: false) = { + let asset = value-or(item, "asset", "") + let source = value-or(item, "text", "") + if asset == "" { + if inline { raw(source) } else { + block( + width: 100%, + fill: rgb("fff4e5"), + inset: 7pt, + radius: 3pt, + raw(source, block: true), + ) + } + } else { + let natural-width = float(value-or(item, "width", "1")) * 1pt + let natural-height = float(value-or(item, "height", "1")) * 1pt + let depth = float(value-or(item, "depth", "0")) * 1pt + if inline { + box( + width: natural-width, + height: natural-height, + baseline: depth, + image( + asset, + width: natural-width, + height: natural-height, + fit: "contain", + alt: source, + ), + ) + } else { + block( + width: 100%, + above: 0.8em, + below: 0.8em, + breakable: false, + align(center, layout(size => image( + asset, + width: calc.min(natural-width, size.width), + fit: "contain", + alt: source, + ))), + ) + } + } +} + #let render-inlines(items) = { for item in items { let kind = item.kind @@ -74,6 +121,8 @@ } else { image(asset, width: 1.25em, height: 1.25em, fit: "contain", alt: alt) } + } else if kind == "math" { + render-math(item, inline: true) } else if kind == "softBreak" { text(" ") } else if kind == "hardBreak" { @@ -190,6 +239,8 @@ raw(value-or(block-data, "text", ""), block: true, lang: language) }, ) + } else if kind == "math" { + render-math(block-data) } else if kind == "list" { render-list(block-data, render-block) } else if kind == "blockquote" { diff --git a/docs/math.md b/docs/math.md new file mode 100644 index 00000000..d6f05b46 --- /dev/null +++ b/docs/math.md @@ -0,0 +1,91 @@ +# Mathematical expressions + +BusyMark renders mathematical expressions with its bundled MathJax 4 engine. +Rendering is local and works without an Internet connection in the preview, +Editor view, and Markdown PDF export. BusyMark supports TeX expressions inside +Markdown; it is not a complete TeX or LaTeX document compiler. + +## Supported source forms + +Normal Markdown and Writerside Markdown support inline dollar math: + +```markdown +Euler's identity is $e^{i\pi}+1=0$. +``` + +GitHub's dollar/backtick form is supported when the expression needs to contain +Markdown-sensitive characters: + +```markdown +The value is $`\sqrt{x^2+y^2}`$. +``` + +Display math can use double dollars or a `math` fence: + +````markdown +$$ +\int_0^1 x^2\,dx = \frac{1}{3} +$$ + +```math +\begin{aligned} +a &= b + c \\ +d &= e + f +\end{aligned} +``` +```` + +Writerside Markdown additionally treats a `tex` fence as display math and +supports inline semantic markup: + +````markdown +```tex +\ce{2H2 + O2 -> 2H2O} +``` + +The domain is \mathbb{R}. +```` + +The same `` element is recognized in Writerside XML `.topic` files. +Outside Writerside mode, a `tex` fence remains an ordinary code block. +BusyMark does not add `\(...\)` or `\[...\]` as Markdown delimiters. + +## Scientific TeX profile + +The pinned profile contains MathJax's base TeX support plus AMS mathematics, +`newcommand`, `mathtools`, `mhchem`, `boldsymbol`, `braket`, `cancel`, `cases`, +`empheq`, `gensymb`, `units`, and `upgreek`. A command declared with +`newcommand` applies only inside that expression; formulas do not share mutable +TeX state. + +BusyMark deliberately does not enable `physics`, because that extension +redefines standard commands. Dynamic package loading and HTML-oriented +extensions—including `autoload`, `require`, `setoptions`, and `texhtml`—are +disabled. A document cannot request another MathJax package or remote font. + +## Editing and export + +An unfocused Editor-view block shows rendered math. Activating a block that +contains inline math switches that block to its exact Markdown source, including +the delimiters. Leaving the block reparses it and restores rendered math. This +keeps equation source available for selection, copy, undo, and normal text +editing without representing equations as hidden replacement characters. + +Inline equations use MathJax's returned depth to align with the surrounding +text baseline. Display equations are centered; equations wider than the +preview remain available through horizontal scrolling. Invalid expressions +stay visible as source instead of removing the surrounding content. + +Markdown PDF export uses the same MathJax package and font profile. BusyMark +stages sanitized, self-contained SVG equations for Typst, including inline +baseline metadata, so safe equations remain vector content in the PDF. + +## Offline and security behavior + +BusyMark ships MathJax 4.1.3 and New Computer Modern 4.1.3 as a deterministic +browser bundle. NewCM's dynamic glyph tables are bundled as well; uncommon, +calligraphic, and double-struck glyphs do not trigger downloads. The reusable +WebKit renderer has no network connectivity, uses a restrictive content security +policy, accepts only fixed packaged resources and operations, limits expression +and batch sizes and render time, applies MathJax safe processing, and passes +every SVG through BusyMark's generated-SVG normalizer before display or export. diff --git a/docs/visualizations.md b/docs/visualizations.md index 3e1ad75f..ead38818 100644 --- a/docs/visualizations.md +++ b/docs/visualizations.md @@ -111,7 +111,7 @@ warning; it does not abort the document export. | Component | Version | Verified artifact SHA-256 | | --- | --- | --- | -| Mermaid | 11.16.1 | `ebd9885111092c78cefc79a76f6c1dc34ed5b834b02ae8f338227ce79c003de4` | +| Mermaid source | 11.16.1 | `0ee99b3bb82766e5d6c34b8cc768b8530ce8f1aaa13790ae368aebeef3de9d11` | | `@plantuml/core` | 1.2026.6 | `798f99592eb03a6446519d2becf78e6f1008d0d25c75d60b37a0f46e39e3c413` | | `@scalar/openapi-parser` | 0.28.14 | `993bb7ebb3480cc574665b0eac52d9cd4a817fdf5b4444894bb70e174880513d` | | `@scalar/api-reference` | 1.65.1 | `68b6f22ca530ac50e3cd034c5189d89cc5457c3c2d325b44e90db05c9f08c573` | @@ -129,6 +129,11 @@ recipe uses the official `node/24/stable` build snap. Node.js and npm are build tools only. Runtime rendering does not require Node.js, Chromium, Java, a public rendering service, or a first-run download. +BusyMark builds the complete Mermaid source profile with Mermaid's internal +math rendering disabled. This keeps the existing diagram families while +removing KaTeX from both the locked dependency graph and the shipped web +bundle; mathematical document content is rendered only by MathJax. + D2 is packaged only for Linux amd64. BusyMark must not advertise another architecture until the Snap platform, upstream artifact, and full corpus are all added and tested for it. diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index e1ac956c..51e60930 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -2702,5 +2702,8 @@ "compare": "مقارنة", "reloadFromDisk": "إعادة التحميل من القرص", "keepMine": "الاحتفاظ بنسختي", - "saveAs": "حفظ باسم" + "saveAs": "حفظ باسم", + "mathRenderFailed": "تعذر عرض التعبير الرياضي.", + "inlineMath": "رياضيات مضمنة", + "displayMath": "رياضيات معروضة" } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index d801ae50..3be088bb 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -2723,5 +2723,8 @@ "compare": "Vergleichen", "reloadFromDisk": "Vom Datenträger neu laden", "keepMine": "Meine Version behalten", - "saveAs": "Speichern unter" + "saveAs": "Speichern unter", + "mathRenderFailed": "Der mathematische Ausdruck konnte nicht dargestellt werden.", + "inlineMath": "Mathematik im Text", + "displayMath": "Mathematische Formel als Block" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 4bfabfce..22cd8874 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -2238,6 +2238,12 @@ "diagnosticMarkdownLinkReviewText": "Review whether the link text “{text}” describes its purpose in context.", "@diagnosticMarkdownLinkReviewText": {"description": "Accessibility hint for potentially non-descriptive Markdown link text.", "placeholders": {"text": {"type": "String"}}}, "diagnosticMarkdownTableEmptyHeader": "Table header cells must identify their columns; complete each empty header.", - "@diagnosticMarkdownTableEmptyHeader": {"description": "Accessibility diagnostic for an empty Markdown table header cell."} + "@diagnosticMarkdownTableEmptyHeader": {"description": "Accessibility diagnostic for an empty Markdown table header cell."}, + "mathRenderFailed": "The mathematical expression could not be rendered.", + "@mathRenderFailed": {"description": "Tooltip and diagnostic text shown when a mathematical expression cannot be rendered."}, + "inlineMath": "Inline math", + "@inlineMath": {"description": "Editor action that inserts an inline mathematical expression."}, + "displayMath": "Display math", + "@displayMath": {"description": "Editor action that inserts a display mathematical expression."} } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 8f097ae2..be0717b2 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -2723,5 +2723,8 @@ "compare": "Comparar", "reloadFromDisk": "Recargar desde el disco", "keepMine": "Conservar mi versión", - "saveAs": "Guardar como" + "saveAs": "Guardar como", + "mathRenderFailed": "No se pudo representar la expresión matemática.", + "inlineMath": "Matemáticas en línea", + "displayMath": "Matemáticas en bloque" } diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index 32869b30..92e68da2 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -1911,5 +1911,8 @@ "compare": "Võrdle", "reloadFromDisk": "Laadi kettalt uuesti", "keepMine": "Säilita minu versioon", - "saveAs": "Salvesta nimega" + "saveAs": "Salvesta nimega", + "mathRenderFailed": "Matemaatilist avaldist ei saanud kuvada.", + "inlineMath": "Reasisene matemaatika", + "displayMath": "Plokina matemaatika" } diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 5e45688d..27143e78 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -2721,5 +2721,8 @@ "compare": "مقایسه", "reloadFromDisk": "بارگیری دوباره از دیسک", "keepMine": "نگه‌داشتن نسخهٔ من", - "saveAs": "ذخیره با نام" + "saveAs": "ذخیره با نام", + "mathRenderFailed": "عبارت ریاضی قابل نمایش نبود.", + "inlineMath": "ریاضی درون‌خطی", + "displayMath": "ریاضی نمایشی" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index e9222fdc..7c61306d 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -2723,5 +2723,8 @@ "compare": "Comparer", "reloadFromDisk": "Recharger depuis le disque", "keepMine": "Conserver ma version", - "saveAs": "Enregistrer sous" + "saveAs": "Enregistrer sous", + "mathRenderFailed": "Impossible d’afficher l’expression mathématique.", + "inlineMath": "Formule en ligne", + "displayMath": "Formule en bloc" } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index ef456178..4b0acc1e 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -2702,5 +2702,8 @@ "compare": "तुलना करें", "reloadFromDisk": "डिस्क से फिर लोड करें", "keepMine": "मेरा संस्करण रखें", - "saveAs": "इस रूप में सहेजें" + "saveAs": "इस रूप में सहेजें", + "mathRenderFailed": "गणितीय व्यंजक रेंडर नहीं किया जा सका।", + "inlineMath": "इनलाइन गणित", + "displayMath": "डिस्प्ले गणित" } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 4415ee4e..07e3ea38 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -2700,5 +2700,8 @@ "compare": "Confronta", "reloadFromDisk": "Ricarica dal disco", "keepMine": "Mantieni la mia versione", - "saveAs": "Salva con nome" + "saveAs": "Salva con nome", + "mathRenderFailed": "Impossibile visualizzare l’espressione matematica.", + "inlineMath": "Formula in linea", + "displayMath": "Formula in blocco" } diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb index d6137406..5adb6407 100644 --- a/lib/l10n/app_nb.arb +++ b/lib/l10n/app_nb.arb @@ -2700,5 +2700,8 @@ "compare": "Sammenlign", "reloadFromDisk": "Last inn fra disk på nytt", "keepMine": "Behold min versjon", - "saveAs": "Lagre som" + "saveAs": "Lagre som", + "mathRenderFailed": "Det matematiske uttrykket kunne ikke gjengis.", + "inlineMath": "Integrert matematikk", + "displayMath": "Matematikkblokk" } diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index d547e603..44646d59 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -2718,5 +2718,8 @@ "compare": "Porównaj", "reloadFromDisk": "Wczytaj ponownie z dysku", "keepMine": "Zachowaj moją wersję", - "saveAs": "Zapisz jako" + "saveAs": "Zapisz jako", + "mathRenderFailed": "Nie udało się wyrenderować wyrażenia matematycznego.", + "inlineMath": "Matematyka w tekście", + "displayMath": "Matematyka blokowa" } diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 286aa2e0..6a9a16c8 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -2700,5 +2700,8 @@ "compare": "Comparar", "reloadFromDisk": "Recarregar do disco", "keepMine": "Manter minha versão", - "saveAs": "Salvar como" + "saveAs": "Salvar como", + "mathRenderFailed": "Não foi possível renderizar a expressão matemática.", + "inlineMath": "Matemática em linha", + "displayMath": "Matemática em bloco" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 920f5b09..fa133453 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -2718,5 +2718,8 @@ "compare": "Сравнить", "reloadFromDisk": "Перезагрузить с диска", "keepMine": "Оставить мою версию", - "saveAs": "Сохранить как" + "saveAs": "Сохранить как", + "mathRenderFailed": "Не удалось отобразить математическое выражение.", + "inlineMath": "Формула в строке", + "displayMath": "Формула отдельным блоком" } diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index 6f97e8d2..c9a0391c 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -2718,5 +2718,8 @@ "compare": "Порівняти", "reloadFromDisk": "Перезавантажити з диска", "keepMine": "Залишити мою версію", - "saveAs": "Зберегти як" + "saveAs": "Зберегти як", + "mathRenderFailed": "Не вдалося відобразити математичний вираз.", + "inlineMath": "Формула в рядку", + "displayMath": "Формула окремим блоком" } diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index e71e0c5e..c414d1f5 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -5852,6 +5852,24 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Table header cells must identify their columns; complete each empty header.'** String get diagnosticMarkdownTableEmptyHeader; + + /// Tooltip and diagnostic text shown when a mathematical expression cannot be rendered. + /// + /// In en, this message translates to: + /// **'The mathematical expression could not be rendered.'** + String get mathRenderFailed; + + /// Editor action that inserts an inline mathematical expression. + /// + /// In en, this message translates to: + /// **'Inline math'** + String get inlineMath; + + /// Editor action that inserts a display mathematical expression. + /// + /// In en, this message translates to: + /// **'Display math'** + String get displayMath; } class _AppLocalizationsDelegate diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index 17b4cd52..b5adb132 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -3475,4 +3475,13 @@ class AppLocalizationsAr extends AppLocalizations { @override String get diagnosticMarkdownTableEmptyHeader => 'يجب أن تعرّف رؤوس الجدول أعمدتها؛ أكمل كل رأس فارغ.'; + + @override + String get mathRenderFailed => 'تعذر عرض التعبير الرياضي.'; + + @override + String get inlineMath => 'رياضيات مضمنة'; + + @override + String get displayMath => 'رياضيات معروضة'; } diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 58791b72..b4c398e0 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -3494,4 +3494,14 @@ class AppLocalizationsDe extends AppLocalizations { @override String get diagnosticMarkdownTableEmptyHeader => 'Tabellenüberschriften müssen ihre Spalten bezeichnen; füllen Sie jede leere Überschrift aus.'; + + @override + String get mathRenderFailed => + 'Der mathematische Ausdruck konnte nicht dargestellt werden.'; + + @override + String get inlineMath => 'Mathematik im Text'; + + @override + String get displayMath => 'Mathematische Formel als Block'; } diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 51dc287d..b1f27c27 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -3452,4 +3452,14 @@ class AppLocalizationsEn extends AppLocalizations { @override String get diagnosticMarkdownTableEmptyHeader => 'Table header cells must identify their columns; complete each empty header.'; + + @override + String get mathRenderFailed => + 'The mathematical expression could not be rendered.'; + + @override + String get inlineMath => 'Inline math'; + + @override + String get displayMath => 'Display math'; } diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 129ad485..a7798a26 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -3495,4 +3495,14 @@ class AppLocalizationsEs extends AppLocalizations { @override String get diagnosticMarkdownTableEmptyHeader => 'Los encabezados de tabla deben identificar sus columnas; complete cada encabezado vacío.'; + + @override + String get mathRenderFailed => + 'No se pudo representar la expresión matemática.'; + + @override + String get inlineMath => 'Matemáticas en línea'; + + @override + String get displayMath => 'Matemáticas en bloque'; } diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index d3f24431..60246992 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -3457,4 +3457,13 @@ class AppLocalizationsEt extends AppLocalizations { @override String get diagnosticMarkdownTableEmptyHeader => 'Tabelipäised peavad veerge kirjeldama; täida kõik tühjad päised.'; + + @override + String get mathRenderFailed => 'Matemaatilist avaldist ei saanud kuvada.'; + + @override + String get inlineMath => 'Reasisene matemaatika'; + + @override + String get displayMath => 'Plokina matemaatika'; } diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index 1dcf0fa3..12e75616 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -3507,4 +3507,13 @@ class AppLocalizationsFa extends AppLocalizations { @override String get diagnosticMarkdownTableEmptyHeader => 'سرستون‌های جدول باید ستون‌های خود را مشخص کنند؛ هر سرستون خالی را تکمیل کنید.'; + + @override + String get mathRenderFailed => 'عبارت ریاضی قابل نمایش نبود.'; + + @override + String get inlineMath => 'ریاضی درون‌خطی'; + + @override + String get displayMath => 'ریاضی نمایشی'; } diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 849d3e46..40a5b812 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -3488,4 +3488,14 @@ class AppLocalizationsFr extends AppLocalizations { @override String get diagnosticMarkdownTableEmptyHeader => 'Les en-têtes de tableau doivent identifier leurs colonnes ; complétez chaque en-tête vide.'; + + @override + String get mathRenderFailed => + 'Impossible d’afficher l’expression mathématique.'; + + @override + String get inlineMath => 'Formule en ligne'; + + @override + String get displayMath => 'Formule en bloc'; } diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index f3f42a91..dad8776a 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -3452,4 +3452,13 @@ class AppLocalizationsHi extends AppLocalizations { @override String get diagnosticMarkdownTableEmptyHeader => 'तालिका शीर्षकों को अपने कॉलम पहचानने चाहिए; हर खाली शीर्षक पूरा करें।'; + + @override + String get mathRenderFailed => 'गणितीय व्यंजक रेंडर नहीं किया जा सका।'; + + @override + String get inlineMath => 'इनलाइन गणित'; + + @override + String get displayMath => 'डिस्प्ले गणित'; } diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index e3867486..d14a0b65 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -3484,4 +3484,14 @@ class AppLocalizationsIt extends AppLocalizations { @override String get diagnosticMarkdownTableEmptyHeader => 'Le intestazioni della tabella devono identificare le colonne; completa ogni intestazione vuota.'; + + @override + String get mathRenderFailed => + 'Impossibile visualizzare l’espressione matematica.'; + + @override + String get inlineMath => 'Formula in linea'; + + @override + String get displayMath => 'Formula in blocco'; } diff --git a/lib/l10n/generated/app_localizations_nb.dart b/lib/l10n/generated/app_localizations_nb.dart index f358ca53..80e16148 100644 --- a/lib/l10n/generated/app_localizations_nb.dart +++ b/lib/l10n/generated/app_localizations_nb.dart @@ -3458,4 +3458,14 @@ class AppLocalizationsNb extends AppLocalizations { @override String get diagnosticMarkdownTableEmptyHeader => 'Tabelloverskrifter må identifisere kolonnene. Fyll ut alle tomme overskrifter.'; + + @override + String get mathRenderFailed => + 'Det matematiske uttrykket kunne ikke gjengis.'; + + @override + String get inlineMath => 'Integrert matematikk'; + + @override + String get displayMath => 'Matematikkblokk'; } diff --git a/lib/l10n/generated/app_localizations_pl.dart b/lib/l10n/generated/app_localizations_pl.dart index b9b52db0..cc2ee0cd 100644 --- a/lib/l10n/generated/app_localizations_pl.dart +++ b/lib/l10n/generated/app_localizations_pl.dart @@ -3498,4 +3498,14 @@ class AppLocalizationsPl extends AppLocalizations { @override String get diagnosticMarkdownTableEmptyHeader => 'Nagłówki tabeli muszą identyfikować kolumny; uzupełnij każdy pusty nagłówek.'; + + @override + String get mathRenderFailed => + 'Nie udało się wyrenderować wyrażenia matematycznego.'; + + @override + String get inlineMath => 'Matematyka w tekście'; + + @override + String get displayMath => 'Matematyka blokowa'; } diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 7aaec936..95120332 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -3478,4 +3478,14 @@ class AppLocalizationsPt extends AppLocalizations { @override String get diagnosticMarkdownTableEmptyHeader => 'Os cabeçalhos da tabela devem identificar suas colunas; preencha cada cabeçalho vazio.'; + + @override + String get mathRenderFailed => + 'Não foi possível renderizar a expressão matemática.'; + + @override + String get inlineMath => 'Matemática em linha'; + + @override + String get displayMath => 'Matemática em bloco'; } diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 3df638ac..811f8b0a 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -3493,4 +3493,14 @@ class AppLocalizationsRu extends AppLocalizations { @override String get diagnosticMarkdownTableEmptyHeader => 'Заголовки таблицы должны обозначать столбцы; заполните каждый пустой заголовок.'; + + @override + String get mathRenderFailed => + 'Не удалось отобразить математическое выражение.'; + + @override + String get inlineMath => 'Формула в строке'; + + @override + String get displayMath => 'Формула отдельным блоком'; } diff --git a/lib/l10n/generated/app_localizations_uk.dart b/lib/l10n/generated/app_localizations_uk.dart index 5120bcdc..88f06ffc 100644 --- a/lib/l10n/generated/app_localizations_uk.dart +++ b/lib/l10n/generated/app_localizations_uk.dart @@ -3502,4 +3502,13 @@ class AppLocalizationsUk extends AppLocalizations { @override String get diagnosticMarkdownTableEmptyHeader => 'Заголовки таблиці мають позначати стовпці; заповніть кожен порожній заголовок.'; + + @override + String get mathRenderFailed => 'Не вдалося відобразити математичний вираз.'; + + @override + String get inlineMath => 'Формула в рядку'; + + @override + String get displayMath => 'Формула окремим блоком'; } diff --git a/lib/src/app/busymark_glyphs.dart b/lib/src/app/busymark_glyphs.dart index a83c2570..2d445073 100644 --- a/lib/src/app/busymark_glyphs.dart +++ b/lib/src/app/busymark_glyphs.dart @@ -57,6 +57,7 @@ abstract final class BusyMarkGlyphs { static const IconData keyboard = YaruIcons.keyboard_shortcuts; static const IconData link = YaruIcons.insert_link; static const IconData markdownFile = YaruIcons.text_editor; + static const IconData math = Icons.functions; static const IconData menuHorizontal = YaruIcons.view_more_horizontal; static const IconData menuVertical = YaruIcons.view_more; static const IconData newDocument = YaruIcons.document_new; diff --git a/lib/src/core/diagnostic_localizations.dart b/lib/src/core/diagnostic_localizations.dart index 5a5f8465..36df8dc0 100644 --- a/lib/src/core/diagnostic_localizations.dart +++ b/lib/src/core/diagnostic_localizations.dart @@ -62,6 +62,12 @@ String localizeDiagnostic(BuildContext context, Diagnostic diagnostic) { value('destination'), ), 'markdown.table.empty-header' => l10n.diagnosticMarkdownTableEmptyHeader, + 'math.invalidTex' || + 'math.resourceLimit' || + 'math.timeout' || + 'math.rendererUnavailable' || + 'math.unsafeOutput' || + 'math.cancelled' => l10n.mathRenderFailed, 'writerside.config.invalid-xml' || 'writerside.build-profiles.invalid-xml' || 'writerside.instance-groups.invalid-xml' || diff --git a/lib/src/editor/source_highlighter.dart b/lib/src/editor/source_highlighter.dart index b24a1455..4e9d4654 100644 --- a/lib/src/editor/source_highlighter.dart +++ b/lib/src/editor/source_highlighter.dart @@ -528,6 +528,7 @@ List _highlightMarkdown( MarkdownFence? openFence; var fenceLanguage = ''; var inFrontMatter = source.startsWith('---\n') || source == '---'; + var inDisplayMath = false; for (final line in source.split('\n')) { final lineStart = offset; @@ -548,6 +549,20 @@ List _highlightMarkdown( continue; } + if (inDisplayMath) { + _addRange( + ranges, + lineStart, + lineEnd, + baseStyle.copyWith(color: palette.literal), + ); + if (line.trimRight().endsWith(r'$$')) { + inDisplayMath = false; + } + offset = lineEnd + 1; + continue; + } + final activeFence = openFence; if (activeFence != null) { if (!activeFence.closes(line)) { @@ -595,6 +610,20 @@ List _highlightMarkdown( continue; } + final displayStart = RegExp(r'^\s{0,3}\$\$').firstMatch(line); + if (displayStart != null) { + _addRange( + ranges, + lineStart, + lineEnd, + baseStyle.copyWith(color: palette.literal), + ); + final remainder = line.substring(displayStart.end); + inDisplayMath = !remainder.contains(r'$$'); + offset = lineEnd + 1; + continue; + } + final heading = _markdownHeadingPattern.firstMatch(line); if (heading != null) { final marker = heading.group(1)!; @@ -667,6 +696,13 @@ List _highlightMarkdown( closingLength: 1, markerStyle: inlineMarkerStyle, ); + _addMarkdownInlineMathRanges( + ranges, + lineStart, + line, + baseStyle.copyWith(color: palette.literal), + inlineMarkerStyle, + ); _addLinkLabelMatches( ranges, lineStart, @@ -744,6 +780,84 @@ List _highlightMarkdown( ); } +void _addMarkdownInlineMathRanges( + List<_HighlightRange> ranges, + int lineStart, + String line, + TextStyle expressionStyle, + TextStyle markerStyle, +) { + var index = 0; + while (index < line.length) { + final start = line.indexOf(r'$', index); + if (start < 0) return; + final globalStart = lineStart + start; + if (_isEscapedAt(line, start) || + _positionInsideRange(ranges, globalStart) || + (start + 1 < line.length && line[start + 1] == r'$')) { + index = start + 1; + continue; + } + final github = start + 1 < line.length && line[start + 1] == '`'; + final closeToken = github ? '`\$' : r'$'; + var close = line.indexOf(closeToken, start + (github ? 2 : 1)); + while (close >= 0 && !github && _isEscapedAt(line, close)) { + close = line.indexOf(closeToken, close + 1); + } + if (close < 0 || close == start + 1) { + index = start + 1; + continue; + } + final end = close + closeToken.length; + if ((!github && _positionInsideRange(ranges, lineStart + close)) || + (!github && _looksLikeCurrencyRange(line, start, close))) { + index = end; + continue; + } + final openingLength = github ? 2 : 1; + _addRange( + ranges, + globalStart, + globalStart + openingLength, + markerStyle, + priority: 10, + ); + _addRange( + ranges, + globalStart + openingLength, + lineStart + close, + expressionStyle, + priority: 10, + ); + _addRange( + ranges, + lineStart + close, + lineStart + end, + markerStyle, + priority: 10, + ); + index = end; + } +} + +bool _isEscapedAt(String source, int offset) { + var backslashes = 0; + for (var index = offset - 1; index >= 0 && source[index] == r'\'; index--) { + backslashes++; + } + return backslashes.isOdd; +} + +bool _looksLikeCurrencyRange(String line, int start, int close) { + if (start + 1 >= close || + !RegExp(r'\d').hasMatch(line[start + 1]) || + close + 1 >= line.length || + !RegExp(r'\d').hasMatch(line[close + 1])) { + return false; + } + return true; +} + bool _addFencedCodeLineRanges( List<_HighlightRange> ranges, int lineStart, diff --git a/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart b/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart index b8454efd..e47ff67c 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart @@ -12,6 +12,7 @@ import '../document_text_direction.dart'; import '../document_thematic_break.dart'; import '../markdown_image_view.dart'; import '../../markdown/busymark_document.dart'; +import '../../math/math_widget.dart'; import '../../visualization/visualization_card.dart'; import '../../visualization/visualization_models.dart'; import '../editor_text_context_menu.dart'; @@ -198,6 +199,13 @@ class BusyMarkWysiwygBlockField extends StatelessWidget { @override Widget build(BuildContext context) { + return ListenableBuilder( + listenable: focusNode, + builder: (context, _) => _buildBlock(context), + ); + } + + Widget _buildBlock(BuildContext context) { final style = _textStyle(context); final prefix = _prefix(context); final readOnly = _readOnly; @@ -348,6 +356,21 @@ class BusyMarkWysiwygBlockField extends StatelessWidget { onEdit: _editHtmlBlock, ); } + if (busyMarkWysiwygBlockContainsMath(block) && !focusNode.hasFocus) { + return Focus( + focusNode: focusNode, + child: GestureDetector( + key: ValueKey('wysiwyg-rendered-math-${block.id}'), + behavior: HitTestBehavior.translucent, + onTap: _focusBlock, + child: _RenderedMathBlock( + block: block, + editRevision: editRevision, + style: style, + ), + ), + ); + } return Directionality( textDirection: textDirection, child: Row( @@ -543,6 +566,8 @@ class BusyMarkWysiwygBlockField extends StatelessWidget { final level = int.tryParse(block.attributes['level'] ?? '') ?? 0; return switch (block.kind) { BusyBlockKind.heading => busyMarkDocumentHeadingTextStyle(context, level), + _ when busyMarkWysiwygBlockContainsMath(block) && focusNode.hasFocus => + busyMarkDocumentCodeTextStyle(context), BusyBlockKind.codeBlock => busyMarkDocumentCodeTextStyle(context), _ => busyMarkDocumentBodyTextStyle(context), }; @@ -600,6 +625,90 @@ class BusyMarkWysiwygBlockField extends StatelessWidget { } } +class _RenderedMathBlock extends StatelessWidget { + const _RenderedMathBlock({ + required this.block, + required this.editRevision, + required this.style, + }); + + final BusyBlock block; + final int editRevision; + final TextStyle style; + + @override + Widget build(BuildContext context) { + if (block.kind == BusyBlockKind.math) { + return BusyMarkDisplayMath( + expression: block.attributes['mathExpression'] ?? block.plainText, + expressionId: 'wysiwyg-display-${block.id}', + editRevision: editRevision, + ); + } + return Text.rich( + TextSpan( + style: style, + children: [ + for (final (index, inline) in block.inlines.indexed) + _span(inline, style, editRevision, 'i$index'), + ], + ), + ); + } + + InlineSpan _span( + BusyInline inline, + TextStyle inherited, + int revision, + String path, + ) { + final nextStyle = switch (inline.kind) { + BusyInlineKind.strong => inherited.copyWith(fontWeight: FontWeight.w700), + BusyInlineKind.emphasis => inherited.copyWith( + fontStyle: FontStyle.italic, + ), + BusyInlineKind.underline => inherited.copyWith( + decoration: TextDecoration.underline, + ), + BusyInlineKind.strikethrough => inherited.copyWith( + decoration: TextDecoration.lineThrough, + ), + BusyInlineKind.code => inherited.copyWith( + fontFamily: BusyMarkTypography.monoFontFamily, + ), + _ => inherited, + }; + if (inline.kind == BusyInlineKind.math) { + return WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: BusyMarkInlineMath( + expression: inline.text, + expressionId: 'wysiwyg-inline-${block.id}-$path', + editRevision: revision, + textStyle: nextStyle, + ), + ); + } + if (inline.kind == BusyInlineKind.hardBreak) { + return const TextSpan(text: '\n'); + } + if (inline.kind == BusyInlineKind.softBreak) { + return const TextSpan(text: ' '); + } + if (inline.children.isNotEmpty) { + return TextSpan( + style: nextStyle, + children: [ + for (final (index, child) in inline.children.indexed) + _span(child, nextStyle, revision, '$path.i$index'), + ], + ); + } + return TextSpan(text: inline.text, style: nextStyle); + } +} + class BusyMarkWysiwygSelectionRange { const BusyMarkWysiwygSelectionRange({required this.start, required this.end}); diff --git a/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart b/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart index 3370ee43..55c2841c 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart @@ -4,6 +4,8 @@ import '../../core/path_utils.dart'; import '../../core/source_span.dart'; import '../../markdown/busymark_document.dart'; import '../../markdown/busymark_markdown_serializer.dart'; +import '../../markdown/markdown_parser.dart'; +import '../../markdown/math_syntax.dart'; import '../../markdown/raw_html_adapter.dart'; import 'wysiwyg_commands.dart'; import 'wysiwyg_inline_controller.dart'; @@ -30,7 +32,106 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { String blockText(String blockId) { final block = blockById(blockId); - return block?.plainText ?? ''; + return block == null ? '' : busyMarkWysiwygEditableText(block); + } + + void updateMathSource(String blockId, String source) { + final current = blockById(blockId); + if (current == null) { + return; + } + final parsed = const MarkdownParser().parse( + filePath: _document.filePath, + source: source, + mode: _document.mode, + validateLocalReferences: false, + ); + final parsedBlocks = parsed.busyDocument.blocks + .where( + (block) => + block.kind != BusyBlockKind.frontMatter && !block.isSourceOnly, + ) + .toList(growable: false); + final replacements = parsedBlocks.isEmpty + ? [ + BusyBlock( + id: current.id, + kind: BusyBlockKind.paragraph, + inlines: [BusyInline(kind: BusyInlineKind.text, text: source)], + sourceSpan: current.sourceSpan, + dirty: true, + ), + ] + : [ + for (final (index, parsedBlock) in parsedBlocks.indexed) + BusyBlock( + id: index == 0 + ? current.id + : _nextGeneratedBlockId('math-edit'), + kind: parsedBlock.kind, + inlines: parsedBlock.inlines, + children: parsedBlock.children, + attributes: { + ...parsedBlock.attributes, + 'wysiwygMathSource': 'true', + }, + rawSource: parsedBlock.rawSource, + sourceSpan: index == 0 ? current.sourceSpan : null, + preserveRaw: false, + dirty: true, + ), + ]; + _document = _document.copyWith( + blocks: _replaceBlockWithMany(_document.blocks, blockId, replacements), + ); + notifyListeners(); + } + + String? insertDisplayMathAfter(String blockId, {String expression = 'x'}) { + if (blockById(blockId) == null) { + return null; + } + final mathId = _nextGeneratedBlockId('math'); + final paragraphId = _nextGeneratedBlockId('paragraph'); + _document = _document.copyWith( + blocks: _insertBlocksAfter(_document.blocks, blockId, [ + BusyBlock( + id: mathId, + kind: BusyBlockKind.math, + inlines: [ + BusyInline( + kind: BusyInlineKind.math, + text: expression, + attributes: { + busyMarkMathExpressionAttribute: expression, + busyMarkMathDisplayAttribute: 'true', + busyMarkMathSourceFormAttribute: + BusyMathSourceForm.doubleDollarDisplay.name, + }, + ), + ], + attributes: { + busyMarkMathExpressionAttribute: expression, + busyMarkMathDisplayAttribute: 'true', + busyMarkMathSourceFormAttribute: + BusyMathSourceForm.doubleDollarDisplay.name, + 'wysiwygMathSource': 'true', + }, + rawSource: '\$\$\n$expression\n\$\$', + preserveRaw: false, + dirty: true, + ), + BusyBlock( + id: paragraphId, + kind: BusyBlockKind.paragraph, + inlines: _textInlines(''), + attributes: const {busyMarkPreserveEmptyParagraphAttribute: 'true'}, + dirty: true, + ), + ]), + ); + notifyListeners(); + return mathId; } BusyBlock? blockById(String blockId) { diff --git a/lib/src/editor/wysiwyg/wysiwyg_editor.dart b/lib/src/editor/wysiwyg/wysiwyg_editor.dart index 97a847b3..50af24ec 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_editor.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_editor.dart @@ -472,6 +472,8 @@ class _BusyMarkWysiwygEditorState extends State { onBlockCommand: _applyBlockCommand, onInlineCommand: _applyInlineCommand, onLinkCommand: () => unawaited(_applyLinkCommand()), + onInlineMathCommand: _applyInlineMathCommand, + onDisplayMathCommand: _applyDisplayMathCommand, onImageCommand: () => unawaited(_applyImageCommand()), onInlineImageCommand: () => unawaited(_applyInlineImageCommand()), @@ -738,8 +740,10 @@ class _BusyMarkWysiwygEditorState extends State { final controller = _textControllers.putIfAbsent( block.id, () => BusyMarkWysiwygTextController( - text: block.plainText, - ranges: busyInlineStyleRanges(block.inlines), + text: busyMarkWysiwygEditableText(block), + ranges: busyMarkWysiwygBlockContainsMath(block) + ? const [] + : busyInlineStyleRanges(block.inlines), ), ); controller.updateFromBlock(block); @@ -985,6 +989,13 @@ class _BusyMarkWysiwygEditorState extends State { return; } _recordUndoSnapshot(); + final currentBlock = _documentController.blockById(blockId); + if (currentBlock != null && + busyMarkWysiwygBlockContainsMath(currentBlock)) { + _documentController.updateMathSource(blockId, value); + _emitMarkdown(); + return; + } final controller = _textControllers[blockId]; final offset = controller?.selection.extentOffset.clamp(0, value.length).toInt() ?? @@ -1171,9 +1182,17 @@ class _BusyMarkWysiwygEditorState extends State { } _initialFocusScheduled = true; WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _focusActiveOrFirstBlock(initialSelectionOffset: 0); + if (!mounted) { + return; + } + final firstPlainBlock = _focusableBlocks() + .where((block) => !busyMarkWysiwygBlockContainsMath(block)) + .firstOrNull; + if (firstPlainBlock == null) { + return; } + _activeBlockId = firstPlainBlock.id; + _focusActiveOrFirstBlock(initialSelectionOffset: 0); }); } @@ -2359,6 +2378,48 @@ class _BusyMarkWysiwygEditorState extends State { _emitMarkdown(); } + void _applyInlineMathCommand() { + final blockId = _activeBlockId; + final controller = blockId == null ? null : _textControllers[blockId]; + if (blockId == null || controller == null) { + return; + } + final selection = controller.selection.isValid + ? controller.selection + : TextSelection.collapsed(offset: controller.text.length); + final start = math.min(selection.start, selection.end); + final end = math.max(selection.start, selection.end); + final selected = controller.text.substring(start, end); + final expression = selected.isEmpty ? 'x' : selected; + final insertion = '\$$expression\$'; + final nextText = controller.text.replaceRange(start, end, insertion); + _recordUndoSnapshot(); + controller.value = TextEditingValue( + text: nextText, + selection: TextSelection( + baseOffset: start + 1, + extentOffset: start + 1 + expression.length, + ), + ); + _documentController.updateMathSource(blockId, nextText); + _emitMarkdown(); + _focusBlockAfterFrame(blockId, offset: start + 1 + expression.length); + } + + void _applyDisplayMathCommand() { + final blockId = _activeBlockId; + if (blockId == null) { + return; + } + _recordUndoSnapshot(); + final mathId = _documentController.insertDisplayMathAfter(blockId); + if (mathId == null) { + return; + } + _emitMarkdown(); + _focusBlockAfterFrame(mathId, offset: 4); + } + Future _applyImageCommand() async { final blockId = _activeBlockId; if (blockId == null) { diff --git a/lib/src/editor/wysiwyg/wysiwyg_inline_controller.dart b/lib/src/editor/wysiwyg/wysiwyg_inline_controller.dart index ee709d2d..331603c8 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_inline_controller.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_inline_controller.dart @@ -2,6 +2,27 @@ import 'package:flutter/material.dart'; import '../../app/busymark_design.dart'; import '../../markdown/busymark_document.dart'; +import '../../markdown/busymark_markdown_serializer.dart'; + +bool busyMarkWysiwygBlockContainsMath(BusyBlock block) { + bool contains(List inlines) => inlines.any( + (inline) => inline.kind == BusyInlineKind.math || contains(inline.children), + ); + return block.attributes['wysiwygMathSource'] == 'true' || + block.kind == BusyBlockKind.math || + contains(block.inlines) || + block.children.any(busyMarkWysiwygBlockContainsMath); +} + +String busyMarkWysiwygEditableText(BusyBlock block) { + if (!busyMarkWysiwygBlockContainsMath(block)) { + return block.plainText; + } + final source = + block.rawSource ?? + const BusyMarkMarkdownSerializer().serializeBlock(block); + return source.replaceFirst(RegExp(r'(?:\r\n|\r|\n)$'), ''); +} class BusyInlineStyleRange { const BusyInlineStyleRange({ @@ -27,8 +48,11 @@ class BusyMarkWysiwygTextController extends TextEditingController { List _ranges; void updateFromBlock(BusyBlock block) { - final nextText = block.plainText; - final nextRanges = busyInlineStyleRanges(block.inlines); + final sourceEditing = busyMarkWysiwygBlockContainsMath(block); + final nextText = busyMarkWysiwygEditableText(block); + final nextRanges = sourceEditing + ? const [] + : busyInlineStyleRanges(block.inlines); final rangesChanged = !_inlineStyleRangesEqual(_ranges, nextRanges); if (text != nextText) { final previousSelection = selection; diff --git a/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart b/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart index 84d99f2f..653230f4 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart @@ -12,6 +12,8 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { required this.onBlockCommand, required this.onInlineCommand, required this.onLinkCommand, + required this.onInlineMathCommand, + required this.onDisplayMathCommand, required this.onImageCommand, required this.onInlineImageCommand, required this.onTableCommand, @@ -27,6 +29,8 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { final ValueChanged onBlockCommand; final ValueChanged onInlineCommand; final VoidCallback onLinkCommand; + final VoidCallback onInlineMathCommand; + final VoidCallback onDisplayMathCommand; final VoidCallback onImageCommand; final VoidCallback onInlineImageCommand; final VoidCallback onTableCommand; @@ -106,6 +110,12 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { shortcut: BusyMarkEditorShortcutLabels.link, onPressed: onLinkCommand, ), + _button( + context, + tooltip: context.l10n.inlineMath, + icon: BusyMarkGlyphs.math, + onPressed: onInlineMathCommand, + ), _button( context, tooltip: context.l10n.hardLineBreak, @@ -131,6 +141,12 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { onPressed: () => onBlockCommand(BusyWysiwygBlockCommand.codeBlock), ), + _button( + context, + tooltip: context.l10n.displayMath, + icon: BusyMarkGlyphs.math, + onPressed: onDisplayMathCommand, + ), _button( context, tooltip: context.l10n.htmlBlock, diff --git a/lib/src/export/markdown_export_document.dart b/lib/src/export/markdown_export_document.dart index 38750b84..00506646 100644 --- a/lib/src/export/markdown_export_document.dart +++ b/lib/src/export/markdown_export_document.dart @@ -14,6 +14,7 @@ enum MarkdownExportBlockKind { tableCell, rawText, group, + math, visualization, openApiReference, } @@ -29,6 +30,7 @@ enum MarkdownExportInlineKind { image, softBreak, hardBreak, + math, } @immutable @@ -63,6 +65,13 @@ class MarkdownExportDocument { final MarkdownExportMetadata metadata; final List blocks; + MarkdownExportDocument copyWith({List? blocks}) { + return MarkdownExportDocument( + metadata: metadata, + blocks: blocks ?? this.blocks, + ); + } + Iterable get imageDestinations sync* { for (final block in blocks) { yield* block.imageDestinations; @@ -86,6 +95,20 @@ class MarkdownExportBlock { final Map attributes; final String text; + MarkdownExportBlock copyWith({ + List? inlines, + List? children, + Map? attributes, + }) { + return MarkdownExportBlock( + kind: kind, + inlines: inlines ?? this.inlines, + children: children ?? this.children, + attributes: attributes ?? this.attributes, + text: text, + ); + } + Iterable get imageDestinations sync* { for (final inline in inlines) { yield* inline.imageDestinations; @@ -112,6 +135,19 @@ class MarkdownExportInline { final List children; final Map attributes; + MarkdownExportInline copyWith({ + List? children, + Map? attributes, + }) { + return MarkdownExportInline( + kind: kind, + text: text, + destination: destination, + children: children ?? this.children, + attributes: attributes ?? this.attributes, + ); + } + Iterable get imageDestinations sync* { if (kind == MarkdownExportInlineKind.image && destination != null && diff --git a/lib/src/export/markdown_export_mapper.dart b/lib/src/export/markdown_export_mapper.dart index ba7604a5..b11e8f25 100644 --- a/lib/src/export/markdown_export_mapper.dart +++ b/lib/src/export/markdown_export_mapper.dart @@ -139,6 +139,16 @@ class MarkdownExportMapper { kind: MarkdownExportBlockKind.paragraph, inlines: _mapInlines(block.inlines), ), + BusyBlockKind.math => MarkdownExportBlock( + kind: MarkdownExportBlockKind.math, + text: block.plainText, + attributes: { + 'mathId': block.id, + 'display': true, + if (block.attributes['mathSourceForm'] case final sourceForm?) + 'sourceForm': sourceForm, + }, + ), BusyBlockKind.codeBlock => MarkdownExportBlock( kind: MarkdownExportBlockKind.code, text: block.plainText, @@ -271,6 +281,16 @@ class MarkdownExportMapper { kind: MarkdownExportInlineKind.code, text: inline.text, ), + BusyInlineKind.math => MarkdownExportInline( + kind: MarkdownExportInlineKind.math, + text: inline.text, + attributes: { + 'mathId': inline.attributes['expressionId'] ?? inline.text, + 'display': 'false', + if (inline.attributes['mathSourceForm'] case final sourceForm?) + 'sourceForm': sourceForm, + }, + ), BusyInlineKind.link => MarkdownExportInline( kind: MarkdownExportInlineKind.link, text: inline.text, diff --git a/lib/src/export/markdown_math_export.dart b/lib/src/export/markdown_math_export.dart new file mode 100644 index 00000000..ff9bddec --- /dev/null +++ b/lib/src/export/markdown_math_export.dart @@ -0,0 +1,335 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:path/path.dart' as p; + +import '../math/math_coordinator.dart'; +import '../math/math_models.dart'; +import '../visualization/generated_svg_normalizer.dart'; +import 'markdown_export_document.dart'; +import 'markdown_pdf_models.dart'; + +class MarkdownMathExportPreparation { + const MarkdownMathExportPreparation({ + required this.document, + required this.warnings, + }); + + final MarkdownExportDocument document; + final List warnings; +} + +class MarkdownMathExportRenderer { + const MarkdownMathExportRenderer({ + required this.coordinator, + this.svgNormalizer = const GeneratedSvgNormalizer( + maximumBytes: busyMarkMaximumMathSvgBytes, + ), + this.maximumExpressions = 512, + this.maximumGeneratedBytes = 64 * 1024 * 1024, + }); + + final MathCoordinator coordinator; + final GeneratedSvgNormalizer svgNormalizer; + final int maximumExpressions; + final int maximumGeneratedBytes; + + Future prepare({ + required MarkdownExportDocument document, + required Directory exportRoot, + required double containerWidth, + required MarkdownPdfCancellationToken cancellationToken, + }) async { + final annotated = _annotate(document); + final candidates = _mathCandidates(annotated).toList(growable: false); + if (candidates.isEmpty) { + return MarkdownMathExportPreparation( + document: document, + warnings: const [], + ); + } + final selected = candidates + .take(maximumExpressions) + .toList(growable: false); + final blockKeys = [ + for (final item in selected) 'pdf-math:${item.renderKey}', + ]; + cancellationToken.attach(() { + for (final key in blockKeys) { + coordinator.cancel(key); + } + }); + final results = {}; + try { + final rendered = await coordinator.renderAll([ + for (final (index, item) in selected.indexed) + MathRenderRequest( + expressionId: item.renderKey, + expression: item.expression, + display: item.display, + blockKey: blockKeys[index], + editRevision: 0, + em: 10.5, + ex: 5.25, + containerWidth: containerWidth, + renderProfile: 'pdf', + ), + ]); + for (final result in rendered) { + results[result.expressionId] = result; + } + } on Object { + cancellationToken.throwIfCancelled(); + } finally { + cancellationToken.detach(); + for (final key in blockKeys) { + coordinator.cancel(key); + } + } + cancellationToken.throwIfCancelled(); + + final assets = {}; + final warnings = [ + if (candidates.length > maximumExpressions) + MarkdownPdfWarning( + MarkdownPdfWarningCode.mathLimitReached, + '${candidates.length - maximumExpressions} math expressions', + ), + ]; + final generatedDirectory = Directory( + p.join(exportRoot.path, 'generated-assets'), + ); + var generatedBytes = 0; + for (final item in selected) { + cancellationToken.throwIfCancelled(); + final result = results[item.renderKey]; + if (result is! RenderedMathResult) { + warnings.add( + MarkdownPdfWarning( + MarkdownPdfWarningCode.mathRenderFailed, + item.expression, + ), + ); + continue; + } + try { + final normalized = svgNormalizer.normalize(result.vectorSvg); + final svg = normalized.vectorSafeSvg; + if (svg == null) { + throw const GeneratedSvgException( + 'math.unsafeOutput', + 'Math SVG is not vector safe.', + ); + } + final bytes = utf8.encode(svg); + if (bytes.length > maximumGeneratedBytes - generatedBytes) { + warnings.add( + MarkdownPdfWarning( + MarkdownPdfWarningCode.mathRenderFailed, + item.expression, + ), + ); + continue; + } + await generatedDirectory.create(recursive: true); + final digest = sha256.convert(bytes).toString(); + final filename = '$digest.svg'; + final target = File(p.join(generatedDirectory.path, filename)); + if (!await target.exists()) { + await target.writeAsBytes(bytes, flush: true); + generatedBytes += bytes.length; + } + assets[item.renderKey] = _PreparedMathAsset( + path: p.posix.join('generated-assets', filename), + width: result.width, + height: result.height, + depth: result.depth, + ); + } on GeneratedSvgException { + warnings.add( + MarkdownPdfWarning( + MarkdownPdfWarningCode.mathRenderFailed, + item.expression, + ), + ); + } + } + return MarkdownMathExportPreparation( + document: _applyAssets(annotated, assets), + warnings: List.unmodifiable(warnings), + ); + } + + MarkdownExportDocument _annotate(MarkdownExportDocument document) { + MarkdownExportInline inline(MarkdownExportInline value, String path) { + final children = [ + for (final (index, child) in value.children.indexed) + inline(child, '$path.i$index'), + ]; + if (value.kind != MarkdownExportInlineKind.math) { + return value.copyWith(children: children); + } + return value.copyWith( + children: children, + attributes: { + ...value.attributes, + 'mathRenderKey': _renderKey(path, value.text, false), + }, + ); + } + + MarkdownExportBlock block(MarkdownExportBlock value, String path) { + final inlines = [ + for (final (index, child) in value.inlines.indexed) + inline(child, '$path.i$index'), + ]; + final children = [ + for (final (index, child) in value.children.indexed) + block(child, '$path.b$index'), + ]; + if (value.kind != MarkdownExportBlockKind.math) { + return value.copyWith(inlines: inlines, children: children); + } + return value.copyWith( + inlines: inlines, + children: children, + attributes: { + ...value.attributes, + 'mathRenderKey': _renderKey(path, value.text, true), + }, + ); + } + + return document.copyWith( + blocks: [ + for (final (index, value) in document.blocks.indexed) + block(value, 'b$index'), + ], + ); + } + + Iterable<_MathExportCandidate> _mathCandidates( + MarkdownExportDocument document, + ) sync* { + Iterable<_MathExportCandidate> inlines( + List values, + ) sync* { + for (final value in values) { + if (value.kind == MarkdownExportInlineKind.math) { + yield _MathExportCandidate( + renderKey: value.attributes['mathRenderKey']!, + expression: value.text, + display: false, + ); + } + yield* inlines(value.children); + } + } + + Iterable<_MathExportCandidate> blocks( + List values, + ) sync* { + for (final value in values) { + if (value.kind == MarkdownExportBlockKind.math) { + yield _MathExportCandidate( + renderKey: value.attributes['mathRenderKey']! as String, + expression: value.text, + display: true, + ); + } + yield* inlines(value.inlines); + yield* blocks(value.children); + } + } + + yield* blocks(document.blocks); + } + + MarkdownExportDocument _applyAssets( + MarkdownExportDocument document, + Map assets, + ) { + Map inlineAttributes(Map source) { + final asset = assets[source['mathRenderKey']]; + return { + ...source, + if (asset != null) ...{ + 'asset': asset.path, + 'width': '${asset.width}', + 'height': '${asset.height}', + 'depth': '${asset.depth}', + 'vector': 'true', + } else + 'failed': 'true', + }; + } + + MarkdownExportInline inline(MarkdownExportInline value) { + return value.copyWith( + children: value.children.map(inline).toList(growable: false), + attributes: value.kind == MarkdownExportInlineKind.math + ? inlineAttributes(value.attributes) + : value.attributes, + ); + } + + MarkdownExportBlock block(MarkdownExportBlock value) { + final key = value.attributes['mathRenderKey'] as String?; + final asset = assets[key]; + return value.copyWith( + inlines: value.inlines.map(inline).toList(growable: false), + children: value.children.map(block).toList(growable: false), + attributes: value.kind == MarkdownExportBlockKind.math + ? { + ...value.attributes, + if (asset != null) ...{ + 'asset': asset.path, + 'width': '${asset.width}', + 'height': '${asset.height}', + 'depth': '${asset.depth}', + 'vector': 'true', + } else + 'failed': 'true', + } + : value.attributes, + ); + } + + return document.copyWith( + blocks: document.blocks.map(block).toList(growable: false), + ); + } + + String _renderKey(String path, String expression, bool display) { + return sha256 + .convert(utf8.encode('$path\u0000$display\u0000$expression')) + .toString(); + } +} + +class _MathExportCandidate { + const _MathExportCandidate({ + required this.renderKey, + required this.expression, + required this.display, + }); + + final String renderKey; + final String expression; + final bool display; +} + +class _PreparedMathAsset { + const _PreparedMathAsset({ + required this.path, + required this.width, + required this.height, + required this.depth, + }); + + final String path; + final double width; + final double height; + final double depth; +} diff --git a/lib/src/export/markdown_pdf_export_service.dart b/lib/src/export/markdown_pdf_export_service.dart index 7b602ba6..d4798dd2 100644 --- a/lib/src/export/markdown_pdf_export_service.dart +++ b/lib/src/export/markdown_pdf_export_service.dart @@ -8,6 +8,7 @@ import '../core/atomic_file_writer.dart'; import '../markdown/markdown_parser.dart'; import 'markdown_export_assets.dart'; import 'markdown_export_mapper.dart'; +import 'markdown_math_export.dart'; import 'markdown_pdf_models.dart'; import 'markdown_visualization_export.dart'; import 'typst_compiler.dart'; @@ -28,6 +29,7 @@ class MarkdownPdfExportService { this.compileTimeout = const Duration(seconds: 45), this.maximumPdfBytes = 100 * 1024 * 1024, this.visualizationRenderer, + this.mathRenderer, }); final MarkdownParser parser; @@ -41,6 +43,7 @@ class MarkdownPdfExportService { final Duration compileTimeout; final int maximumPdfBytes; final MarkdownVisualizationExportRenderer? visualizationRenderer; + final MarkdownMathExportRenderer? mathRenderer; Future export( MarkdownPdfExportRequest request, { @@ -90,10 +93,23 @@ class MarkdownPdfExportService { cancellationToken: token, ); token.throwIfCancelled(); - final document = mapper.map( + final mappedDocument = mapper.map( parsed.busyDocument, blockOverrides: visualizationPreparation.blockOverrides, ); + final mathPreparation = mathRenderer == null + ? MarkdownMathExportPreparation( + document: mappedDocument, + warnings: const [], + ) + : await mathRenderer!.prepare( + document: mappedDocument, + exportRoot: exportRoot, + containerWidth: _pdfContentWidth(request.options), + cancellationToken: token, + ); + token.throwIfCancelled(); + final document = mathPreparation.document; final stagedAssets = await assetStager.stage( document: document, exportRoot: exportRoot, @@ -175,6 +191,7 @@ class MarkdownPdfExportService { pageCount: _pageCount(pdfBytes), warnings: [ ...visualizationPreparation.warnings, + ...mathPreparation.warnings, ...stagedAssets.warnings, ], ); @@ -191,6 +208,27 @@ class MarkdownPdfExportService { } } + double _pdfContentWidth(MarkdownPdfOptions options) { + final pageWidth = switch (options.pageSize) { + MarkdownPdfPageSize.a4 => 595.28, + MarkdownPdfPageSize.letter => 612.0, + }; + final pageHeight = switch (options.pageSize) { + MarkdownPdfPageSize.a4 => 841.89, + MarkdownPdfPageSize.letter => 792.0, + }; + final horizontalMargin = switch (options.margin) { + MarkdownPdfMargin.narrow => 36.0, + MarkdownPdfMargin.normal => 57.0, + MarkdownPdfMargin.wide => 78.0, + }; + final orientedWidth = + options.orientation == MarkdownPdfOrientation.landscape + ? pageHeight + : pageWidth; + return orientedWidth - 2 * horizontalMargin; + } + static Future _loadBundledTemplate() { return rootBundle.loadString('assets/export/markdown.typ'); } diff --git a/lib/src/export/markdown_pdf_export_ui.dart b/lib/src/export/markdown_pdf_export_ui.dart index b3a44c8c..a44a611d 100644 --- a/lib/src/export/markdown_pdf_export_ui.dart +++ b/lib/src/export/markdown_pdf_export_ui.dart @@ -12,10 +12,12 @@ import '../app/busymark_design.dart'; import '../app/busymark_glyphs.dart'; import '../app/localization.dart'; import '../platform/linux_header_bar_service.dart'; +import '../math/math_providers.dart'; import '../workspace/workspace_model.dart'; import '../workspace/workspace_controller.dart'; import '../visualization/visualization_providers.dart'; import 'markdown_visualization_export.dart'; +import 'markdown_math_export.dart'; import 'markdown_pdf_export_service.dart'; import 'markdown_pdf_models.dart'; import 'writerside_pdf_export_ui.dart'; @@ -25,6 +27,9 @@ final markdownPdfExportServiceProvider = Provider( visualizationRenderer: MarkdownVisualizationExportRenderer( coordinator: ref.watch(visualizationCoordinatorProvider), ), + mathRenderer: MarkdownMathExportRenderer( + coordinator: ref.watch(mathCoordinatorProvider), + ), ), ); diff --git a/lib/src/export/markdown_pdf_models.dart b/lib/src/export/markdown_pdf_models.dart index 99bcede5..007f6f29 100644 --- a/lib/src/export/markdown_pdf_models.dart +++ b/lib/src/export/markdown_pdf_models.dart @@ -59,6 +59,8 @@ enum MarkdownPdfWarningCode { imageReadFailed, visualizationRenderFailed, visualizationLimitReached, + mathRenderFailed, + mathLimitReached, } @immutable diff --git a/lib/src/export/typst_payload_builder.dart b/lib/src/export/typst_payload_builder.dart index dd25d94a..70f33831 100644 --- a/lib/src/export/typst_payload_builder.dart +++ b/lib/src/export/typst_payload_builder.dart @@ -51,6 +51,7 @@ class TypstPayloadBuilder { 'children': [ for (final child in inline.children) _inline(child, assets), ], + ...inline.attributes, }; } } diff --git a/lib/src/markdown/busymark_document.dart b/lib/src/markdown/busymark_document.dart index 85dd4c7c..1e94d4aa 100644 --- a/lib/src/markdown/busymark_document.dart +++ b/lib/src/markdown/busymark_document.dart @@ -70,6 +70,7 @@ class BusyDocument { enum BusyBlockKind { heading, paragraph, + math, codeBlock, unorderedListItem, orderedListItem, @@ -151,6 +152,7 @@ class BusyBlock { enum BusyInlineKind { text, + math, strong, emphasis, underline, diff --git a/lib/src/markdown/busymark_markdown_serializer.dart b/lib/src/markdown/busymark_markdown_serializer.dart index 8c7c1841..3753a753 100644 --- a/lib/src/markdown/busymark_markdown_serializer.dart +++ b/lib/src/markdown/busymark_markdown_serializer.dart @@ -1,5 +1,6 @@ import '../core/source_span.dart'; import 'busymark_document.dart'; +import 'math_syntax.dart'; class BusyMarkMarkdownSerializer { const BusyMarkMarkdownSerializer(); @@ -38,6 +39,7 @@ class BusyMarkMarkdownSerializer { return switch (block.kind) { BusyBlockKind.heading => _heading(block), BusyBlockKind.paragraph => _inlineMarkdown(block.inlines), + BusyBlockKind.math => _mathBlock(block), BusyBlockKind.codeBlock => _codeBlock(block), BusyBlockKind.unorderedListItem => _listItem(block, '-'), BusyBlockKind.orderedListItem => _listItem( @@ -173,6 +175,22 @@ class BusyMarkMarkdownSerializer { return '$fence$infoSeparator$language\n$text\n$fence'; } + String _mathBlock(BusyBlock block) { + final expression = + block.attributes[busyMarkMathExpressionAttribute] ?? block.plainText; + final form = busyMathSourceFormFromName( + block.attributes[busyMarkMathSourceFormAttribute], + ); + return switch (form) { + BusyMathSourceForm.mathFence => '```math\n$expression\n```', + BusyMathSourceForm.writersideTexFence => '```tex\n$expression\n```', + BusyMathSourceForm.writersideElement => '$expression', + BusyMathSourceForm.doubleDollarDisplay || + BusyMathSourceForm.dollarInline || + BusyMathSourceForm.githubDollarBacktick => '\$\$\n$expression\n\$\$', + }; + } + String _listItem(BusyBlock block, String marker, {String? contentPrefix}) { final text = _inlineMarkdown(block.inlines); final content = [ @@ -337,6 +355,7 @@ class BusyMarkMarkdownSerializer { : _inlineMarkdown(inline.children, tableCell: tableCell); return switch (inline.kind) { BusyInlineKind.text => _escapeInlineText(inline.text), + BusyInlineKind.math => _mathInline(inline), BusyInlineKind.strong => '**$children**', BusyInlineKind.emphasis => '*$children*', BusyInlineKind.underline => '$children', @@ -356,6 +375,20 @@ class BusyMarkMarkdownSerializer { }; } + String _mathInline(BusyInline inline) { + final form = busyMathSourceFormFromName( + inline.attributes[busyMarkMathSourceFormAttribute], + ); + return switch (form) { + BusyMathSourceForm.githubDollarBacktick => '\$`${inline.text}`\$', + BusyMathSourceForm.writersideElement => '${inline.text}', + BusyMathSourceForm.dollarInline || + BusyMathSourceForm.doubleDollarDisplay || + BusyMathSourceForm.mathFence || + BusyMathSourceForm.writersideTexFence => '\$${inline.text}\$', + }; + } + String _codeSpan(String text) { final delimiter = '`' * _delimiterLength(text, '`'); final touchesDelimiter = text.startsWith('`') || text.endsWith('`'); diff --git a/lib/src/markdown/markdown_ast_adapter.dart b/lib/src/markdown/markdown_ast_adapter.dart index 3604dfb4..6782a730 100644 --- a/lib/src/markdown/markdown_ast_adapter.dart +++ b/lib/src/markdown/markdown_ast_adapter.dart @@ -4,6 +4,7 @@ import '../core/path_utils.dart'; import 'busymark_document.dart'; import 'markdown_fence.dart'; import 'markdown_model.dart'; +import 'math_syntax.dart'; import 'raw_html_adapter.dart'; import 'raw_html_policy.dart'; @@ -56,7 +57,7 @@ class MarkdownAstAdapter { required MarkdownMode mode, }) { final blocks = []; - for (final segment in _rawHtmlAwareSegments(source)) { + for (final segment in _rawHtmlAwareSegments(source, mode)) { if (segment.rawHtml) { final html = _rawHtmlAdapter.parseRawHtmlBlock(segment.text, nextId); if (html != null) { @@ -89,10 +90,7 @@ class MarkdownAstAdapter { final packageSource = _protectProseHyphenLines( _protectImageDestinationsWithSpaces(source), ); - final document = md.Document( - extensionSet: md.ExtensionSet.gitHubWeb, - encodeHtml: false, - ); + final document = busyMarkMarkdownDocument(mode); final nodes = document.parse(packageSource); return [ for (final node in nodes) @@ -165,6 +163,24 @@ class MarkdownAstAdapter { final tag = node.tag.toLowerCase(); final children = node.children ?? const []; + if (tag == busyMarkMathBlockTag) { + return [ + BusyBlock( + id: nextId(), + kind: BusyBlockKind.math, + inlines: [ + BusyInline( + kind: BusyInlineKind.math, + text: + node.attributes[busyMarkMathExpressionAttribute] ?? + node.textContent, + attributes: node.attributes, + ), + ], + attributes: node.attributes, + ), + ]; + } if (_headingLevel(tag) case final level?) { final rawText = node.textContent.trim(); final attrId = _attributeValue(rawText, 'id'); @@ -215,6 +231,39 @@ class MarkdownAstAdapter { final language = className.startsWith('language-') ? className.substring('language-'.length) : ''; + final normalizedLanguage = language.trim().toLowerCase(); + final mathSourceForm = switch (normalizedLanguage) { + 'math' => BusyMathSourceForm.mathFence, + 'tex' when mode == MarkdownMode.writersideMarkdown => + BusyMathSourceForm.writersideTexFence, + _ => null, + }; + if (mathSourceForm != null) { + final expression = _codeBlockText(code.textContent); + return [ + BusyBlock( + id: nextId(), + kind: BusyBlockKind.math, + inlines: [ + BusyInline( + kind: BusyInlineKind.math, + text: expression, + attributes: { + busyMarkMathExpressionAttribute: expression, + busyMarkMathDisplayAttribute: 'true', + busyMarkMathSourceFormAttribute: mathSourceForm.name, + }, + ), + ], + attributes: { + busyMarkMathExpressionAttribute: expression, + busyMarkMathDisplayAttribute: 'true', + busyMarkMathSourceFormAttribute: mathSourceForm.name, + 'language': normalizedLanguage, + }, + ), + ]; + } return [ BusyBlock( id: nextId(), @@ -235,7 +284,12 @@ class MarkdownAstAdapter { } if (tag == 'ul' || tag == 'ol') { - return _listBlocksFromNode(node, ordered: tag == 'ol', nextId: nextId); + return _listBlocksFromNode( + node, + ordered: tag == 'ol', + nextId: nextId, + mode: mode, + ); } if (tag == 'blockquote') { @@ -319,6 +373,7 @@ class MarkdownAstAdapter { md.Element node, { required bool ordered, required String Function() nextId, + required MarkdownMode mode, }) { final result = []; final items = @@ -341,7 +396,7 @@ class MarkdownAstAdapter { child.tag == 'pre' || child.tag == 'table')) { nestedBlocks.addAll( - _blocksFromNode(child, nextId: nextId, mode: MarkdownMode.gfm), + _blocksFromNode(child, nextId: nextId, mode: mode), ); } else { inlineNodes.add(child); @@ -392,6 +447,15 @@ class MarkdownAstAdapter { final children = _inlinesFromNodes(node.children ?? const []); final text = node.textContent; return switch (tag) { + busyMarkMathInlineTag => [ + BusyInline( + kind: BusyInlineKind.math, + text: + node.attributes[busyMarkMathExpressionAttribute] ?? + node.textContent, + attributes: node.attributes, + ), + ], 'strong' || 'b' => [ BusyInline(kind: BusyInlineKind.strong, text: text, children: children), ], @@ -898,7 +962,10 @@ class MarkdownAstAdapter { return lines.join('\n'); } - List<_MarkdownSourceSegment> _rawHtmlAwareSegments(String source) { + List<_MarkdownSourceSegment> _rawHtmlAwareSegments( + String source, + MarkdownMode mode, + ) { final lines = _markdownSourceLines(source); final segments = <_MarkdownSourceSegment>[]; var segmentStart = 0; @@ -921,6 +988,15 @@ class MarkdownAstAdapter { continue; } + if (mode == MarkdownMode.writersideMarkdown && + RegExp( + r'^\s{0,3}]*)?>', + caseSensitive: false, + ).hasMatch(line)) { + index += 1; + continue; + } + final htmlEndIndex = _rawHtmlContainerEndIndex(lines, index); if (htmlEndIndex == null) { index += 1; diff --git a/lib/src/markdown/markdown_parser.dart b/lib/src/markdown/markdown_parser.dart index 190f7b6d..a6eba551 100644 --- a/lib/src/markdown/markdown_parser.dart +++ b/lib/src/markdown/markdown_parser.dart @@ -13,6 +13,7 @@ import 'busymark_document.dart'; import 'markdown_ast_adapter.dart'; import 'markdown_fence.dart'; import 'markdown_model.dart'; +import 'math_syntax.dart'; import 'raw_html_policy.dart'; // Cross-file diagnostics are intentionally scoped to Markdown files that @@ -669,6 +670,7 @@ class MarkdownParser { ); break; case BusyInlineKind.text: + case BusyInlineKind.math: case BusyInlineKind.strong: case BusyInlineKind.emphasis: case BusyInlineKind.underline: @@ -822,6 +824,13 @@ class MarkdownParser { continue; } + final displayMathEnd = _displayMathEndIndex(indexedLines, index); + if (displayMathEnd != null) { + addChunk(startIndex, displayMathEnd); + index = displayMathEnd; + continue; + } + final htmlEndIndex = _rawHtmlContainerSourceEndIndex(indexedLines, index); if (htmlEndIndex != null) { addChunk(startIndex, htmlEndIndex); @@ -862,6 +871,7 @@ class MarkdownParser { final line = indexedLines[index]; if (line.trimmed.isEmpty || MarkdownFence.parse(line.line) != null || + _startsDisplayMath(line.line) || _isAtxHeading(line.line) || (!startsWithBlockquote && _isBlockquoteStart(line.line)) || _listItemIndent(line.line) != null) { @@ -1067,10 +1077,59 @@ class MarkdownParser { } md.Document _markdownDocument() { - return md.Document( - extensionSet: md.ExtensionSet.gitHubWeb, - encodeHtml: false, - ); + return busyMarkMarkdownDocument(MarkdownMode.commonMark); + } + + int? _displayMathEndIndex(List<_ScannedSourceLine> lines, int startIndex) { + if (!_startsDisplayMath(lines[startIndex].line)) { + return null; + } + final first = lines[startIndex].line; + final opening = first.indexOf(r'$$'); + final tail = first.substring(opening + 2); + final firstClose = _displayMathCloseIndex(tail); + if (firstClose != null) { + return tail.substring(0, firstClose).trim().isEmpty + ? null + : startIndex + 1; + } + final expression = StringBuffer(tail); + for (var index = startIndex + 1; index < lines.length; index++) { + final line = lines[index].line; + final close = _displayMathCloseIndex(line); + if (close != null) { + if (expression.isNotEmpty) expression.writeln(); + expression.write(line.substring(0, close)); + return expression.toString().trim().isEmpty ? null : index + 1; + } + if (expression.isNotEmpty) expression.writeln(); + expression.write(line); + } + return null; + } + + bool _startsDisplayMath(String line) { + return RegExp(r'^ {0,3}\$\$(?!\$)').hasMatch(line); + } + + int? _displayMathCloseIndex(String line) { + for (var index = 0; index + 1 < line.length; index++) { + if (!line.startsWith(r'$$', index)) { + continue; + } + var backslashes = 0; + for ( + var cursor = index - 1; + cursor >= 0 && line.codeUnitAt(cursor) == 0x5c; + cursor-- + ) { + backslashes += 1; + } + if (backslashes.isEven && line.substring(index + 2).trim().isEmpty) { + return index; + } + } + return null; } int _consumedLineCount(md.BlockParser parser, List lines) { diff --git a/lib/src/markdown/math_syntax.dart b/lib/src/markdown/math_syntax.dart new file mode 100644 index 00000000..c2c1265b --- /dev/null +++ b/lib/src/markdown/math_syntax.dart @@ -0,0 +1,308 @@ +import 'package:markdown/markdown.dart' as md; + +import 'markdown_model.dart'; + +const busyMarkMathInlineTag = 'busymark-math-inline'; +const busyMarkMathBlockTag = 'busymark-math-block'; +const busyMarkMathExpressionAttribute = 'mathExpression'; +const busyMarkMathDisplayAttribute = 'mathDisplay'; +const busyMarkMathSourceFormAttribute = 'mathSourceForm'; + +enum BusyMathSourceForm { + dollarInline, + githubDollarBacktick, + doubleDollarDisplay, + mathFence, + writersideTexFence, + writersideElement, +} + +BusyMathSourceForm busyMathSourceFormFromName(String? value) { + return BusyMathSourceForm.values.firstWhere( + (form) => form.name == value, + orElse: () => BusyMathSourceForm.dollarInline, + ); +} + +md.Document busyMarkMarkdownDocument(MarkdownMode mode) { + return md.Document( + blockSyntaxes: const [BusyDisplayMathSyntax()], + inlineSyntaxes: [ + BusyDollarMathSyntax(), + if (mode == MarkdownMode.writersideMarkdown) BusyWritersideMathSyntax(), + ], + extensionSet: md.ExtensionSet.gitHubWeb, + encodeHtml: false, + ); +} + +class BusyDollarMathSyntax extends md.InlineSyntax { + BusyDollarMathSyntax() : super(r'\$', startCharacter: 0x24); + + @override + bool onMatch(md.InlineParser parser, Match match) { + final source = parser.source; + final start = match.start; + if (_isEscaped(source, start)) { + parser.addNode(md.Text(r'$')); + parser.consume(1); + return false; + } + + if (source.startsWith(r'$`', start)) { + final end = _findGithubClose(source, start + 2); + if (end != null) { + final expression = source.substring(start + 2, end); + if (expression.isNotEmpty) { + parser.addNode( + _mathElement( + busyMarkMathInlineTag, + expression, + BusyMathSourceForm.githubDollarBacktick, + display: false, + ), + ); + parser.consume(end + 2 - start); + return false; + } + } + } + + // A double-dollar run belongs to the display block syntax. If it occurs + // in ordinary paragraph text, leave it literal rather than accidentally + // interpreting the second dollar as an inline opener. + if (source.startsWith(r'$$', start)) { + parser.addNode(md.Text(r'$$')); + parser.consume(2); + return false; + } + + final end = _findDollarClose(source, start + 1); + if (end != null) { + final expression = source.substring(start + 1, end); + parser.addNode( + _mathElement( + busyMarkMathInlineTag, + expression, + BusyMathSourceForm.dollarInline, + display: false, + ), + ); + parser.consume(end + 1 - start); + return false; + } + + parser.addNode(md.Text(r'$')); + parser.consume(1); + return false; + } + + int? _findGithubClose(String source, int expressionStart) { + var index = expressionStart; + while (index + 1 < source.length) { + if (source.codeUnitAt(index) == 0x0a || + source.codeUnitAt(index) == 0x0d) { + return null; + } + if (source.startsWith(r'`$', index) && !_isEscaped(source, index)) { + return index; + } + index += 1; + } + return null; + } + + int? _findDollarClose(String source, int expressionStart) { + if (expressionStart >= source.length || + _isWhitespace(source.codeUnitAt(expressionStart))) { + return null; + } + var index = expressionStart; + while (index < source.length) { + final unit = source.codeUnitAt(index); + if (unit == 0x0a || unit == 0x0d) { + return null; + } + if (unit == 0x60) { + return null; + } + if (unit == 0x24 && !_isEscaped(source, index)) { + if (index == expressionStart || + _isWhitespace(source.codeUnitAt(index - 1))) { + // This dollar can begin a later expression, so it terminates the + // current candidate instead of allowing currency to swallow it. + return null; + } + final next = index + 1 < source.length + ? source.codeUnitAt(index + 1) + : null; + // This avoids consuming currency ranges such as `$5-$10`. + if (next != null && next >= 0x30 && next <= 0x39) { + return null; + } + return index; + } + index += 1; + } + return null; + } +} + +class BusyWritersideMathSyntax extends md.InlineSyntax { + BusyWritersideMathSyntax() + : super(r']*)?>', startCharacter: 0x3c, caseSensitive: false); + + @override + bool onMatch(md.InlineParser parser, Match match) { + final close = RegExp( + r'', + caseSensitive: false, + ).matchAsPrefix(parser.source, match.end); + final end = + close ?? + RegExp( + r'', + caseSensitive: false, + ).firstMatch(parser.source.substring(match.end)); + if (end == null) { + parser.addNode(md.Text(match.group(0)!)); + return true; + } + final closeStart = close == null ? match.end + end.start : end.start; + final closeEnd = close == null ? match.end + end.end : end.end; + final expression = parser.source.substring(match.end, closeStart); + if (expression.isEmpty || expression.contains('\n')) { + parser.addNode(md.Text(match.group(0)!)); + return true; + } + parser.addNode( + _mathElement( + busyMarkMathInlineTag, + expression, + BusyMathSourceForm.writersideElement, + display: false, + ), + ); + parser.consume(closeEnd - match.start); + return false; + } +} + +class BusyDisplayMathSyntax extends md.BlockSyntax { + const BusyDisplayMathSyntax(); + + @override + RegExp get pattern => RegExp(r'^ {0,3}\$\$(?!\$)'); + + @override + bool canParse(md.BlockParser parser) { + if (!pattern.hasMatch(parser.current.content)) { + return false; + } + final first = parser.current.content; + final opening = first.indexOf(r'$$'); + final firstClose = _displayClose(first, opening + 2); + if (firstClose != null) { + return first.substring(opening + 2, firstClose).trim().isNotEmpty; + } + final expression = StringBuffer(first.substring(opening + 2)); + var ahead = 1; + while (true) { + final line = parser.peek(ahead); + if (line == null) { + break; + } + final close = _displayClose(line.content, 0); + if (close != null) { + if (expression.isNotEmpty) expression.writeln(); + expression.write(line.content.substring(0, close)); + return expression.toString().trim().isNotEmpty; + } + if (expression.isNotEmpty) expression.writeln(); + expression.write(line.content); + ahead += 1; + } + // An unclosed delimiter remains ordinary Markdown text. + return false; + } + + @override + md.Node parse(md.BlockParser parser) { + final first = parser.current.content; + final opening = first.indexOf(r'$$'); + final tail = first.substring(opening + 2); + final lines = []; + final firstClose = _displayClose(tail, 0); + if (firstClose != null) { + lines.add(tail.substring(0, firstClose)); + parser.advance(); + } else { + if (tail.isNotEmpty) { + lines.add(tail); + } + parser.advance(); + while (!parser.isDone) { + final line = parser.current.content; + final close = _displayClose(line, 0); + if (close != null) { + if (close > 0) { + lines.add(line.substring(0, close)); + } + parser.advance(); + break; + } + lines.add(line); + parser.advance(); + } + } + return _mathElement( + busyMarkMathBlockTag, + lines.join('\n'), + BusyMathSourceForm.doubleDollarDisplay, + display: true, + ); + } + + @override + bool canEndBlock(md.BlockParser parser) => true; +} + +md.Element _mathElement( + String tag, + String expression, + BusyMathSourceForm sourceForm, { + required bool display, +}) { + return md.Element.text(tag, expression) + ..attributes[busyMarkMathExpressionAttribute] = expression + ..attributes[busyMarkMathDisplayAttribute] = '$display' + ..attributes[busyMarkMathSourceFormAttribute] = sourceForm.name; +} + +int? _displayClose(String source, int start) { + var index = start; + while (index + 1 < source.length) { + if (source.startsWith(r'$$', index) && !_isEscaped(source, index)) { + final trailing = source.substring(index + 2); + return trailing.trim().isEmpty ? index : null; + } + index += 1; + } + return null; +} + +bool _isEscaped(String source, int index) { + var backslashes = 0; + for ( + var cursor = index - 1; + cursor >= 0 && source.codeUnitAt(cursor) == 0x5c; + cursor -= 1 + ) { + backslashes += 1; + } + return backslashes.isOdd; +} + +bool _isWhitespace(int unit) { + return unit == 0x20 || unit == 0x09 || unit == 0x0a || unit == 0x0d; +} diff --git a/lib/src/markdown/preview_model.dart b/lib/src/markdown/preview_model.dart index c88ace3b..1995852b 100644 --- a/lib/src/markdown/preview_model.dart +++ b/lib/src/markdown/preview_model.dart @@ -4,10 +4,12 @@ import '../visualization/visualization_models.dart'; import 'busymark_document.dart'; import 'document_outline.dart'; import 'markdown_model.dart'; +import 'math_syntax.dart'; enum PreviewBlockKind { heading, paragraph, + math, code, list, quote, @@ -62,6 +64,7 @@ enum PreviewInlineKind { code, link, image, + math, } class PreviewInline { @@ -70,12 +73,14 @@ class PreviewInline { required this.text, this.destination, this.children = const [], + this.attributes = const {}, }); final PreviewInlineKind kind; final String text; final String? destination; final List children; + final Map attributes; } class PreviewDocument { @@ -156,19 +161,19 @@ class BusyMarkPreviewBuilder { List buildBlocks(BusyDocument document) { return [ - for (final block in document.blocks) + for (final (index, block) in document.blocks.indexed) if (block.kind != BusyBlockKind.frontMatter && !block.isSourceOnly) - _block(block), + _block(block, 'b$index'), ]; } - PreviewBlock _block(BusyBlock block) { + PreviewBlock _block(BusyBlock block, String path) { final preview = switch (block.kind) { BusyBlockKind.heading => PreviewBlock( kind: PreviewBlockKind.heading, text: _plainText(block.inlines), level: int.tryParse(block.attributes['level'] ?? ''), - inlines: _inlines(block.inlines), + inlines: _inlines(block.inlines, '$path.i'), attributes: { ...block.attributes, if (block.attributes['id'] case final id?) 'id': id, @@ -178,9 +183,21 @@ class BusyMarkPreviewBuilder { BusyBlockKind.paragraph => PreviewBlock( kind: PreviewBlockKind.paragraph, text: _plainText(block.inlines), - inlines: _inlines(block.inlines), + inlines: _inlines(block.inlines, '$path.i'), attributes: block.attributes, ), + BusyBlockKind.math => PreviewBlock( + kind: PreviewBlockKind.math, + text: + block.attributes[busyMarkMathExpressionAttribute] ?? + block.plainText, + inlines: _inlines(block.inlines, '$path.i'), + attributes: { + ...block.attributes, + 'expressionId': 'block-${block.id}', + 'editorBlockId': block.id, + }, + ), BusyBlockKind.codeBlock => PreviewBlock( kind: PreviewBlockKind.code, text: block.plainText, @@ -195,8 +212,11 @@ class BusyMarkPreviewBuilder { BusyBlockKind.taskListItem => PreviewBlock( kind: PreviewBlockKind.list, text: _plainText(block.inlines), - inlines: _inlines(block.inlines), - children: block.children.map(_block).toList(), + inlines: _inlines(block.inlines, '$path.i'), + children: [ + for (final (index, child) in block.children.indexed) + _block(child, '$path.b$index'), + ], attributes: block.attributes, ), BusyBlockKind.blockquote => PreviewBlock( @@ -207,11 +227,14 @@ class BusyMarkPreviewBuilder { inlines: block.children.length == 1 && block.children.single.kind == BusyBlockKind.paragraph - ? _inlines(block.children.single.inlines) + ? _inlines(block.children.single.inlines, '$path.b0.i') : block.children.isEmpty - ? _inlines(block.inlines) + ? _inlines(block.inlines, '$path.i') : const [], - children: block.children.map(_block).toList(), + children: [ + for (final (index, child) in block.children.indexed) + _block(child, '$path.b$index'), + ], attributes: block.attributes, ), BusyBlockKind.thematicBreak => const PreviewBlock( @@ -223,19 +246,22 @@ class BusyMarkPreviewBuilder { text: block.inlines.isEmpty ? block.plainText : block.inlines.first.text, - inlines: _inlines(block.inlines), + inlines: _inlines(block.inlines, '$path.i'), attributes: block.attributes, ), BusyBlockKind.table => PreviewBlock( kind: PreviewBlockKind.table, text: '', - children: block.children.map(_block).toList(), + children: [ + for (final (index, child) in block.children.indexed) + _block(child, '$path.b$index'), + ], attributes: block.attributes, ), BusyBlockKind.writersideAdmonition => PreviewBlock( kind: PreviewBlockKind.admonition, text: _plainText(block.inlines), - inlines: _inlines(block.inlines), + inlines: _inlines(block.inlines, '$path.i'), attributes: { ...block.attributes, 'style': block.attributes['element'] ?? 'note', @@ -256,7 +282,10 @@ class BusyMarkPreviewBuilder { BusyBlockKind.htmlBlock when block.children.isNotEmpty => PreviewBlock( kind: PreviewBlockKind.container, text: block.children.map((child) => child.plainText).join('\n'), - children: block.children.map(_block).toList(), + children: [ + for (final (index, child) in block.children.indexed) + _block(child, '$path.b$index'), + ], attributes: block.attributes, ), BusyBlockKind.htmlBlock || @@ -294,11 +323,14 @@ class BusyMarkPreviewBuilder { ); } - List _inlines(List inlines) { - return [for (final inline in inlines) _inline(inline)]; + List _inlines(List inlines, String path) { + return [ + for (final (index, inline) in inlines.indexed) + _inline(inline, '$path$index'), + ]; } - PreviewInline _inline(BusyInline inline) { + PreviewInline _inline(BusyInline inline, String path) { return PreviewInline( kind: switch (inline.kind) { BusyInlineKind.text || @@ -307,6 +339,7 @@ class BusyMarkPreviewBuilder { BusyInlineKind.writersideVariable || BusyInlineKind.html || BusyInlineKind.unknown => PreviewInlineKind.text, + BusyInlineKind.math => PreviewInlineKind.math, BusyInlineKind.strong => PreviewInlineKind.strong, BusyInlineKind.emphasis => PreviewInlineKind.emphasis, BusyInlineKind.underline => PreviewInlineKind.underline, @@ -317,7 +350,11 @@ class BusyMarkPreviewBuilder { }, text: inline.kind == BusyInlineKind.softBreak ? ' ' : inline.text, destination: inline.destination, - children: _inlines(inline.children), + children: _inlines(inline.children, '$path.i'), + attributes: { + ...inline.attributes, + if (inline.kind == BusyInlineKind.math) 'expressionId': 'inline-$path', + }, ); } diff --git a/lib/src/math/math_cache.dart b/lib/src/math/math_cache.dart new file mode 100644 index 00000000..7c212f82 --- /dev/null +++ b/lib/src/math/math_cache.dart @@ -0,0 +1,30 @@ +import 'dart:collection'; + +import 'math_models.dart'; + +class MathRenderCache { + MathRenderCache({this.maximumEntries = 256}); + + final int maximumEntries; + final LinkedHashMap _entries = LinkedHashMap(); + + RenderedMathResult? get(String key) { + final result = _entries.remove(key); + if (result != null) { + _entries[key] = result; + } + return result; + } + + void put(String key, RenderedMathResult result) { + _entries.remove(key); + _entries[key] = result; + while (_entries.length > maximumEntries) { + _entries.remove(_entries.keys.first); + } + } + + int get length => _entries.length; + + void clear() => _entries.clear(); +} diff --git a/lib/src/math/math_coordinator.dart b/lib/src/math/math_coordinator.dart new file mode 100644 index 00000000..b18954c4 --- /dev/null +++ b/lib/src/math/math_coordinator.dart @@ -0,0 +1,198 @@ +import 'dart:async'; + +import '../visualization/visualization_renderer.dart'; +import 'math_cache.dart'; +import 'math_models.dart'; +import 'math_renderer.dart'; +import 'math_svg_preprocessor.dart'; + +class MathCoordinator { + MathCoordinator({ + required this.renderer, + MathRenderCache? cache, + this.preprocessor = const MathSvgPreprocessor(), + }) : cache = cache ?? MathRenderCache(); + + final MathRenderer renderer; + final MathRenderCache cache; + final MathSvgPreprocessor preprocessor; + final List<_PendingMathRender> _pending = []; + final Map _latestRevisions = {}; + final Map _activeTokens = {}; + var _flushScheduled = false; + var _disposed = false; + var _instanceSequence = 0; + + Future render(MathRenderRequest request) { + if (_disposed) { + throw StateError('MathCoordinator has been disposed.'); + } + final latest = _latestRevisions[request.blockKey]; + if (latest != null && request.editRevision < latest) { + return Future.error(const MathSupersededException()); + } + _latestRevisions[request.blockKey] = request.editRevision; + _activeTokens.remove(request.blockKey)?.cancel(); + final token = VisualizationCancellationToken(); + _activeTokens[request.blockKey] = token; + + final cached = cache.get(request.cacheKey); + if (cached != null) { + _activeTokens.remove(request.blockKey); + return Future.value(_forInstance(cached, request)); + } + final completer = Completer(); + _pending.add( + _PendingMathRender(request: request, token: token, completer: completer), + ); + if (!_flushScheduled) { + _flushScheduled = true; + scheduleMicrotask(_flush); + } + return completer.future; + } + + Future> renderAll( + Iterable requests, + ) { + return Future.wait([for (final request in requests) render(request)]); + } + + void cancel(String blockKey) { + _latestRevisions.remove(blockKey); + _activeTokens.remove(blockKey)?.cancel(); + } + + void dispose() { + if (_disposed) { + return; + } + _disposed = true; + for (final token in _activeTokens.values) { + token.cancel(); + } + _activeTokens.clear(); + for (final item in _pending) { + if (!item.completer.isCompleted) { + item.completer.completeError(const MathSupersededException()); + } + } + _pending.clear(); + } + + Future _flush() async { + _flushScheduled = false; + if (_disposed) { + return; + } + while (_pending.isNotEmpty) { + final batch = <_PendingMathRender>[]; + var aggregateCharacters = 0; + for (final item in _pending) { + if (batch.length >= busyMarkMaximumMathBatchExpressions || + (batch.isNotEmpty && + aggregateCharacters + item.request.expression.length > + busyMarkMaximumMathBatchCharacters)) { + break; + } + batch.add(item); + aggregateCharacters += item.request.expression.length; + } + _pending.removeRange(0, batch.length); + final active = batch.where((item) => !item.token.isCancelled).toList(); + for (final item in batch.where((item) => item.token.isCancelled)) { + _supersede(item); + } + if (active.isEmpty) { + continue; + } + final batchToken = VisualizationCancellationToken(); + void cancelBatchWhenObsolete() { + if (active.every((item) => item.token.isCancelled)) { + batchToken.cancel(); + } + } + + for (final item in active) { + item.token.onCancel(cancelBatchWhenObsolete); + } + try { + final results = await renderer.renderBatch([ + for (final item in active) item.request, + ], batchToken); + for (var index = 0; index < active.length; index++) { + final item = active[index]; + if (_isSuperseded(item)) { + _supersede(item); + continue; + } + final result = results[index]; + if (result is RenderedMathResult) { + cache.put(item.request.cacheKey, result); + } + if (!item.completer.isCompleted) { + item.completer.complete( + result is RenderedMathResult + ? _forInstance(result, item.request) + : result, + ); + } + if (identical(_activeTokens[item.request.blockKey], item.token)) { + _activeTokens.remove(item.request.blockKey); + } + } + } on VisualizationCancelledException { + for (final item in active) { + _supersede(item); + } + } on Object catch (error, stackTrace) { + for (final item in active) { + if (!item.completer.isCompleted) { + item.completer.completeError(error, stackTrace); + } + } + } finally { + for (final item in active) { + item.token.removeListener(cancelBatchWhenObsolete); + } + } + } + } + + bool _isSuperseded(_PendingMathRender item) { + return item.token.isCancelled || + _latestRevisions[item.request.blockKey] != item.request.editRevision || + !identical(_activeTokens[item.request.blockKey], item.token); + } + + void _supersede(_PendingMathRender item) { + if (!item.completer.isCompleted) { + item.completer.completeError(const MathSupersededException()); + } + } + + RenderedMathResult _forInstance( + RenderedMathResult result, + MathRenderRequest request, + ) { + final prefix = + 'bm-${request.expressionId}-${request.editRevision}-${_instanceSequence++}'; + return result.copyWith( + expressionId: request.expressionId, + svg: preprocessor.rebaseLocalIds(result.svg, prefix), + vectorSvg: preprocessor.rebaseLocalIds(result.vectorSvg, prefix), + ); + } +} + +class _PendingMathRender { + const _PendingMathRender({ + required this.request, + required this.token, + required this.completer, + }); + + final MathRenderRequest request; + final VisualizationCancellationToken token; + final Completer completer; +} diff --git a/lib/src/math/math_models.dart b/lib/src/math/math_models.dart new file mode 100644 index 00000000..d4a59ed8 --- /dev/null +++ b/lib/src/math/math_models.dart @@ -0,0 +1,131 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; + +const busyMarkMathJaxVersion = '4.1.3'; +const busyMarkMathJaxFontVersion = '4.1.3'; +const busyMarkMathPackageProfileVersion = 'busymark-math-v1'; +const busyMarkMathMacroProfileVersion = 'busymark-macros-v1'; +const busyMarkMathSvgNormalizationVersion = 'generated-svg-v1'; +const busyMarkMaximumMathExpressionCharacters = 16 * 1024; +const busyMarkMaximumMathBatchExpressions = 128; +const busyMarkMaximumMathBatchCharacters = 256 * 1024; +const busyMarkMaximumMathSvgBytes = 2 * 1024 * 1024; + +@immutable +class MathRenderRequest { + const MathRenderRequest({ + required this.expressionId, + required this.expression, + required this.display, + required this.blockKey, + required this.editRevision, + required this.em, + required this.ex, + required this.containerWidth, + this.renderProfile = 'preview', + }); + + final String expressionId; + final String expression; + final bool display; + final String blockKey; + final int editRevision; + final double em; + final double ex; + final double containerWidth; + final String renderProfile; + + double get widthBucket => (containerWidth / 16).round() * 16.0; + + String get cacheKey => sha256 + .convert( + utf8.encode( + [ + busyMarkMathJaxVersion, + busyMarkMathJaxFontVersion, + busyMarkMathPackageProfileVersion, + busyMarkMathMacroProfileVersion, + busyMarkMathSvgNormalizationVersion, + expression, + '$display', + renderProfile, + em.toStringAsFixed(3), + ex.toStringAsFixed(3), + widthBucket.toStringAsFixed(0), + ].join('\u0000'), + ), + ) + .toString(); +} + +enum MathRenderErrorKind { + invalidTex, + resourceLimit, + timeout, + rendererUnavailable, + unsafeOutput, + cancelled, +} + +sealed class MathRenderResult { + const MathRenderResult({required this.expressionId}); + + final String expressionId; + bool get isSuccessful => this is RenderedMathResult; +} + +@immutable +class RenderedMathResult extends MathRenderResult { + const RenderedMathResult({ + required super.expressionId, + required this.svg, + required this.vectorSvg, + required this.width, + required this.height, + required this.depth, + required this.baseline, + }); + + final String svg; + final String vectorSvg; + final double width; + final double height; + final double depth; + final double baseline; + + RenderedMathResult copyWith({ + String? expressionId, + String? svg, + String? vectorSvg, + }) { + return RenderedMathResult( + expressionId: expressionId ?? this.expressionId, + svg: svg ?? this.svg, + vectorSvg: vectorSvg ?? this.vectorSvg, + width: width, + height: height, + depth: depth, + baseline: baseline, + ); + } +} + +@immutable +class FailedMathResult extends MathRenderResult { + const FailedMathResult({ + required super.expressionId, + required this.kind, + required this.code, + this.debugDetail, + }); + + final MathRenderErrorKind kind; + final String code; + final String? debugDetail; +} + +class MathSupersededException implements Exception { + const MathSupersededException(); +} diff --git a/lib/src/math/math_providers.dart b/lib/src/math/math_providers.dart new file mode 100644 index 00000000..7db11fe0 --- /dev/null +++ b/lib/src/math/math_providers.dart @@ -0,0 +1,13 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../visualization/visualization_providers.dart'; +import 'math_coordinator.dart'; +import 'math_renderer.dart'; + +final mathCoordinatorProvider = Provider((ref) { + final coordinator = MathCoordinator( + renderer: MathRenderer(host: ref.watch(webRenderHostProvider)), + ); + ref.onDispose(coordinator.dispose); + return coordinator; +}); diff --git a/lib/src/math/math_renderer.dart b/lib/src/math/math_renderer.dart new file mode 100644 index 00000000..2d1900ad --- /dev/null +++ b/lib/src/math/math_renderer.dart @@ -0,0 +1,205 @@ +import 'dart:async'; + +import '../visualization/generated_svg_normalizer.dart'; +import '../visualization/visualization_renderer.dart'; +import '../visualization/web_render_host.dart'; +import 'math_models.dart'; +import 'math_svg_preprocessor.dart'; + +class MathRenderer { + const MathRenderer({ + required this.host, + this.preprocessor = const MathSvgPreprocessor(), + this.svgNormalizer = const GeneratedSvgNormalizer( + maximumBytes: busyMarkMaximumMathSvgBytes, + ), + }); + + final WebRenderHost host; + final MathSvgPreprocessor preprocessor; + final GeneratedSvgNormalizer svgNormalizer; + + Future> renderBatch( + List requests, + VisualizationCancellationToken cancellationToken, + ) async { + if (requests.isEmpty) { + return const []; + } + if (requests.length > busyMarkMaximumMathBatchExpressions || + requests.fold( + 0, + (total, request) => total + request.expression.length, + ) > + busyMarkMaximumMathBatchCharacters) { + return [ + for (final request in requests) + _failure(request.expressionId, 'math.resourceLimit'), + ]; + } + + final valid = []; + final immediate = {}; + for (final request in requests) { + if (request.expression.isEmpty || + request.expression.length > busyMarkMaximumMathExpressionCharacters) { + immediate[request.expressionId] = _failure( + request.expressionId, + request.expression.isEmpty ? 'math.invalidTex' : 'math.resourceLimit', + ); + } else { + valid.add(request); + } + } + if (valid.isEmpty) { + return [for (final request in requests) immediate[request.expressionId]!]; + } + + try { + final hostIds = {}; + final payload = >[]; + for (final (index, request) in valid.indexed) { + final hostId = 'math-$index-${request.cacheKey.substring(0, 12)}'; + hostIds[hostId] = request; + payload.add({ + 'id': hostId, + 'expression': request.expression, + 'display': request.display, + 'em': request.em, + 'ex': request.ex, + 'containerWidth': request.widthBucket, + 'renderProfile': request.renderProfile, + 'svgIdPrefix': 'bm-${request.cacheKey.substring(0, 16)}', + }); + } + final response = await host.renderMathBatch( + expressions: payload, + cancellationToken: cancellationToken, + ); + cancellationToken.throwIfCancelled(); + final byExpression = {...immediate}; + final rawResults = response['results']; + if (rawResults is! List) { + throw const WebRenderHostException( + 'math.rendererUnavailable', + 'The MathJax host returned an invalid response.', + ); + } + for (final raw in rawResults.whereType>()) { + final hostId = raw['id'] as String? ?? ''; + final request = hostIds[hostId]; + if (request == null) { + continue; + } + byExpression[request.expressionId] = _decodeResult(request, raw); + } + return [ + for (final request in requests) + byExpression[request.expressionId] ?? + _failure(request.expressionId, 'math.rendererUnavailable'), + ]; + } on VisualizationCancelledException { + rethrow; + } on TimeoutException { + return [ + for (final request in requests) + FailedMathResult( + expressionId: request.expressionId, + kind: MathRenderErrorKind.timeout, + code: 'math.timeout', + ), + ]; + } on WebRenderHostException catch (error) { + return [ + for (final request in requests) + FailedMathResult( + expressionId: request.expressionId, + kind: MathRenderErrorKind.rendererUnavailable, + code: 'math.rendererUnavailable', + debugDetail: error.toString(), + ), + ]; + } on Object catch (error) { + return [ + for (final request in requests) + FailedMathResult( + expressionId: request.expressionId, + kind: MathRenderErrorKind.rendererUnavailable, + code: 'math.rendererUnavailable', + debugDetail: error.toString(), + ), + ]; + } + } + + MathRenderResult _decodeResult( + MathRenderRequest request, + Map raw, + ) { + final rawError = raw['error']; + if (rawError is Map) { + return _failure( + request.expressionId, + rawError['code'] as String? ?? 'math.invalidTex', + debugDetail: + rawError['detail'] as String? ?? rawError['message'] as String?, + ); + } + final source = raw['svg']; + if (source is! String || source.isEmpty) { + return _failure(request.expressionId, 'math.rendererUnavailable'); + } + try { + final prepared = preprocessor.preprocess( + source, + ex: request.ex, + reportedDepth: (raw['depth'] as num?)?.toDouble(), + ); + final normalized = svgNormalizer.normalize(prepared.svg); + final vector = normalized.vectorSafeSvg; + if (vector == null) { + return _failure(request.expressionId, 'math.unsafeOutput'); + } + final width = (raw['width'] as num?)?.toDouble() ?? 1; + final height = (raw['height'] as num?)?.toDouble() ?? 1; + final depth = prepared.depth.clamp(0, height).toDouble(); + return RenderedMathResult( + expressionId: request.expressionId, + svg: normalized.browserSafeSvg, + vectorSvg: vector, + width: width > 0 && width.isFinite ? width : 1, + height: height > 0 && height.isFinite ? height : 1, + depth: depth, + baseline: height - depth, + ); + } on Object catch (error) { + return FailedMathResult( + expressionId: request.expressionId, + kind: MathRenderErrorKind.unsafeOutput, + code: 'math.unsafeOutput', + debugDetail: error.toString(), + ); + } + } + + FailedMathResult _failure( + String expressionId, + String code, { + String? debugDetail, + }) { + final kind = switch (code) { + 'math.resourceLimit' => MathRenderErrorKind.resourceLimit, + 'math.timeout' => MathRenderErrorKind.timeout, + 'math.unsafeOutput' => MathRenderErrorKind.unsafeOutput, + 'math.cancelled' => MathRenderErrorKind.cancelled, + 'math.rendererUnavailable' => MathRenderErrorKind.rendererUnavailable, + _ => MathRenderErrorKind.invalidTex, + }; + return FailedMathResult( + expressionId: expressionId, + kind: kind, + code: code, + debugDetail: debugDetail, + ); + } +} diff --git a/lib/src/math/math_svg_preprocessor.dart b/lib/src/math/math_svg_preprocessor.dart new file mode 100644 index 00000000..073a71ea --- /dev/null +++ b/lib/src/math/math_svg_preprocessor.dart @@ -0,0 +1,152 @@ +import 'package:xml/xml.dart'; + +class MathSvgPreprocessing { + const MathSvgPreprocessing({required this.svg, required this.depth}); + + final String svg; + final double depth; +} + +class MathSvgPreprocessor { + const MathSvgPreprocessor(); + + static const _maximumStandaloneViewBoxDimension = 16000.0; + + MathSvgPreprocessing preprocess( + String source, { + required double ex, + double? reportedDepth, + }) { + final document = XmlDocument.parse(source); + final root = document.rootElement; + if (root.name.local.toLowerCase() != 'svg') { + throw const FormatException('MathJax output is not SVG.'); + } + final declarations = _styleDeclarations(root.getAttribute('style') ?? ''); + final verticalAlign = declarations.remove('vertical-align'); + if (declarations.isEmpty) { + root.removeAttribute('style'); + } else { + root.setAttribute( + 'style', + declarations.entries + .map((entry) => '${entry.key}:${entry.value}') + .join(';'), + ); + } + _normalizeCoordinateRange(root); + final extractedDepth = _depthFromVerticalAlign(verticalAlign, ex); + return MathSvgPreprocessing( + svg: document.toXmlString(pretty: false), + depth: reportedDepth ?? extractedDepth, + ); + } + + void _normalizeCoordinateRange(XmlElement root) { + final values = root + .getAttribute('viewBox') + ?.trim() + .split(RegExp(r'[\s,]+')) + .map(double.tryParse) + .toList(growable: false); + if (values == null || + values.length != 4 || + values.any((value) => value == null || !value.isFinite)) { + return; + } + final width = values[2]!.abs(); + final height = values[3]!.abs(); + final scale = width > height + ? width / _maximumStandaloneViewBoxDimension + : height / _maximumStandaloneViewBoxDimension; + if (scale <= 1) { + return; + } + final originalChildren = root.children.toList(growable: false); + root.children.clear(); + root.children.add( + XmlElement(XmlName.parts('g'), [ + XmlAttribute(XmlName.parts('transform'), 'scale(${1 / scale})'), + ], originalChildren), + ); + root.setAttribute( + 'viewBox', + values.map((value) => value! / scale).join(' '), + ); + } + + String rebaseLocalIds(String source, String requestedPrefix) { + final document = XmlDocument.parse(source); + final prefix = requestedPrefix + .replaceAll(RegExp(r'[^A-Za-z0-9_.:-]'), '-') + .replaceFirst(RegExp(r'^[^A-Za-z_]'), 'm-'); + final ids = {}; + var sequence = 0; + for (final element in [ + document.rootElement, + ...document.rootElement.descendants.whereType(), + ]) { + final oldId = element.getAttribute('id'); + if (oldId == null || oldId.isEmpty) { + continue; + } + final replacement = '$prefix-${sequence++}'; + ids[oldId] = replacement; + element.setAttribute('id', replacement); + } + if (ids.isEmpty) { + return document.toXmlString(pretty: false); + } + for (final element in [ + document.rootElement, + ...document.rootElement.descendants.whereType(), + ]) { + for (final attribute in element.attributes) { + var value = attribute.value; + for (final entry in ids.entries) { + if (value == '#${entry.key}') { + value = '#${entry.value}'; + } + value = value.replaceAll( + 'url(#${entry.key})', + 'url(#${entry.value})', + ); + } + attribute.value = value; + } + } + return document.toXmlString(pretty: false); + } + + Map _styleDeclarations(String source) { + final result = {}; + for (final declaration in source.split(';')) { + final separator = declaration.indexOf(':'); + if (separator <= 0) { + continue; + } + result[declaration.substring(0, separator).trim().toLowerCase()] = + declaration.substring(separator + 1).trim(); + } + return result; + } + + double _depthFromVerticalAlign(String? value, double ex) { + if (value == null) { + return 0; + } + final match = RegExp( + r'^(-?(?:\d+(?:\.\d*)?|\.\d+))(ex|em|px)?$', + ).firstMatch(value.trim()); + if (match == null) { + return 0; + } + final amount = double.tryParse(match.group(1)!) ?? 0; + final pixels = switch (match.group(2)) { + 'ex' => amount * ex, + 'em' => amount * ex * 2, + _ => amount, + }; + return pixels < 0 ? -pixels : 0; + } +} diff --git a/lib/src/math/math_widget.dart b/lib/src/math/math_widget.dart new file mode 100644 index 00000000..cd76de07 --- /dev/null +++ b/lib/src/math/math_widget.dart @@ -0,0 +1,250 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +import '../app/busymark_design.dart'; +import '../app/localization.dart'; +import 'math_models.dart'; +import 'math_providers.dart'; + +var _nextMathWidgetInstance = 0; + +class BusyMarkInlineMath extends StatelessWidget { + const BusyMarkInlineMath({ + super.key, + required this.expression, + required this.expressionId, + required this.editRevision, + required this.textStyle, + this.containerWidth = BusyMarkSizes.documentContentWidth, + this.onFailure, + }); + + final String expression; + final String expressionId; + final int editRevision; + final TextStyle textStyle; + final double containerWidth; + final ValueChanged? onFailure; + + @override + Widget build(BuildContext context) { + final fontSize = + textStyle.fontSize ?? DefaultTextStyle.of(context).style.fontSize ?? 16; + return _MathFormula( + expression: expression, + expressionId: expressionId, + editRevision: editRevision, + display: false, + em: fontSize, + ex: fontSize / 2, + containerWidth: containerWidth, + textStyle: textStyle, + onFailure: onFailure, + ); + } +} + +class BusyMarkDisplayMath extends StatelessWidget { + const BusyMarkDisplayMath({ + super.key, + required this.expression, + required this.expressionId, + required this.editRevision, + this.onFailure, + }); + + final String expression; + final String expressionId; + final int editRevision; + final ValueChanged? onFailure; + + @override + Widget build(BuildContext context) { + final style = DefaultTextStyle.of(context).style; + final fontSize = style.fontSize ?? 16; + return LayoutBuilder( + builder: (context, constraints) { + final availableWidth = constraints.maxWidth.isFinite + ? constraints.maxWidth + : BusyMarkSizes.documentContentWidth; + return _MathFormula( + expression: expression, + expressionId: expressionId, + editRevision: editRevision, + display: true, + em: fontSize, + ex: fontSize / 2, + containerWidth: availableWidth, + textStyle: style, + onFailure: onFailure, + ); + }, + ); + } +} + +class _MathFormula extends ConsumerStatefulWidget { + const _MathFormula({ + required this.expression, + required this.expressionId, + required this.editRevision, + required this.display, + required this.em, + required this.ex, + required this.containerWidth, + required this.textStyle, + this.onFailure, + }); + + final String expression; + final String expressionId; + final int editRevision; + final bool display; + final double em; + final double ex; + final double containerWidth; + final TextStyle textStyle; + final ValueChanged? onFailure; + + @override + ConsumerState<_MathFormula> createState() => _MathFormulaState(); +} + +class _MathFormulaState extends ConsumerState<_MathFormula> { + late final String _blockKey = 'math-widget-${_nextMathWidgetInstance++}'; + late final _coordinator = ref.read(mathCoordinatorProvider); + Future? _render; + + @override + void initState() { + super.initState(); + _scheduleRender(); + } + + @override + void didUpdateWidget(covariant _MathFormula oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.expression != widget.expression || + oldWidget.display != widget.display || + oldWidget.editRevision != widget.editRevision || + oldWidget.em != widget.em || + oldWidget.ex != widget.ex || + oldWidget.containerWidth != widget.containerWidth) { + _scheduleRender(); + } + } + + @override + void dispose() { + _coordinator.cancel(_blockKey); + super.dispose(); + } + + void _scheduleRender() { + _render = _coordinator.render( + MathRenderRequest( + expressionId: widget.expressionId, + expression: widget.expression, + display: widget.display, + blockKey: _blockKey, + editRevision: widget.editRevision, + em: widget.em, + ex: widget.ex, + containerWidth: widget.containerWidth, + ), + ); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _render, + builder: (context, snapshot) { + final result = snapshot.data; + if (result is RenderedMathResult) { + return _rendered(context, result); + } + if (result is FailedMathResult) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) widget.onFailure?.call(result); + }); + } + return _fallback(context, failed: result is FailedMathResult); + }, + ); + } + + Widget _rendered(BuildContext context, RenderedMathResult result) { + final foreground = + widget.textStyle.color ?? DefaultTextStyle.of(context).style.color; + final picture = Semantics( + image: true, + label: widget.expression, + child: ExcludeSemantics( + child: SvgPicture.string( + result.svg, + width: result.width, + height: result.height, + fit: BoxFit.fill, + colorFilter: foreground == null + ? null + : ColorFilter.mode(foreground, BlendMode.srcIn), + ), + ), + ); + if (!widget.display) { + return Baseline( + baseline: result.baseline, + baselineType: TextBaseline.alphabetic, + child: SizedBox( + width: math.max(1, result.width), + height: math.max(1, result.height), + child: picture, + ), + ); + } + final formula = SizedBox( + width: math.max(1, result.width), + height: math.max(1, result.height), + child: picture, + ); + if (result.width <= widget.containerWidth) { + return Center(child: formula); + } + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: formula, + ); + } + + Widget _fallback(BuildContext context, {required bool failed}) { + final colors = BusyMarkSurfaceColors.of(context); + final style = widget.textStyle.copyWith( + fontFamily: BusyMarkTypography.monoFontFamily, + color: failed + ? Theme.of(context).colorScheme.error + : colors.mutedForeground, + backgroundColor: failed ? colors.admonitionWarning : colors.control, + ); + final fallback = !widget.display + ? Text( + widget.expression, + style: style, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ) + : Container( + width: double.infinity, + padding: BusyMarkInsets.documentCodeBlock, + color: failed ? colors.admonitionWarning : colors.control, + child: SelectableText(widget.expression, style: style), + ); + return failed + ? Tooltip(message: context.l10n.mathRenderFailed, child: fallback) + : fallback; + } +} diff --git a/lib/src/visualization/visualization_release_smoke.dart b/lib/src/visualization/visualization_release_smoke.dart index 4f999e26..b61eb893 100644 --- a/lib/src/visualization/visualization_release_smoke.dart +++ b/lib/src/visualization/visualization_release_smoke.dart @@ -7,7 +7,11 @@ import 'package:path/path.dart' as p; import '../export/markdown_pdf_export_service.dart'; import '../export/markdown_pdf_models.dart'; +import '../export/markdown_math_export.dart'; import '../export/markdown_visualization_export.dart'; +import '../math/math_coordinator.dart'; +import '../math/math_models.dart'; +import '../math/math_renderer.dart'; import 'd2_renderer.dart'; import 'visualization_cache.dart'; import 'visualization_coordinator.dart'; @@ -54,6 +58,7 @@ Future runVisualizationReleaseSmoke(String reportPath) async { ), maximumConcurrentRenders: 1, ); + final mathCoordinator = MathCoordinator(renderer: MathRenderer(host: host)); final checks = {}; Future checkpoint(String phase) async { await _writeReport(reportFile, { @@ -86,10 +91,118 @@ Future runVisualizationReleaseSmoke(String reportPath) async { ); checks['mermaidFormat'] = await _expectDiagram(host, mermaid, 'Mermaid'); + await checkpoint('rendering sequential NewCM math'); + final doubleStruck = await _renderMath( + mathCoordinator, + expression: r'\mathbb{R}', + key: 'release-smoke-mathbb', + ); + final calligraphic = await _renderMath( + mathCoordinator, + expression: r'\mathcal{L}', + key: 'release-smoke-mathcal', + ); + final scientific = await _renderMath( + mathCoordinator, + expression: + r'\ce{2H2 + O2 -> 2H2O}\quad ' + r'\Braket{\psi|\phi}+\cancel{x}+\upalpha+a\coloneqq b+\units{m}', + key: 'release-smoke-scientific', + display: true, + ); + final bold = await _renderMath( + mathCoordinator, + expression: r'\boldsymbol{\alpha}', + key: 'release-smoke-boldsymbol', + ); + final cases = await _renderMath( + mathCoordinator, + expression: r'f(x)=\begin{cases}x&x>0\\0&x\leq0\end{cases}', + key: 'release-smoke-cases', + display: true, + ); + final generatedSymbol = await _renderMath( + mathCoordinator, + expression: r'90\degree', + key: 'release-smoke-gensymb', + ); + final emphasizedEquation = await _renderMath( + mathCoordinator, + expression: r'\begin{empheq}{align}E&=mc^2\end{empheq}', + key: 'release-smoke-empheq', + display: true, + ); + final ams = await _renderMath( + mathCoordinator, + expression: r'\begin{align}a&=b\end{align}', + key: 'release-smoke-ams', + display: true, + ); + for (final entry in { + 'double-struck': doubleStruck, + 'calligraphic': calligraphic, + 'scientific profile': scientific, + 'boldsymbol': bold, + 'cases': cases, + 'gensymb': generatedSymbol, + 'empheq': emphasizedEquation, + 'AMS': ams, + }.entries) { + final name = entry.key; + final result = entry.value; + if (result is! RenderedMathResult || !result.vectorSvg.contains(' runVisualizationReleaseSmoke(String reportPath) async { visualizationRenderer: MarkdownVisualizationExportRenderer( coordinator: coordinator, ), + mathRenderer: MarkdownMathExportRenderer( + coordinator: mathCoordinator, + ), ).export( MarkdownPdfExportRequest( source: _pdfSource, @@ -186,6 +302,7 @@ Future runVisualizationReleaseSmoke(String reportPath) async { return 1; } finally { coordinator.dispose(); + mathCoordinator.dispose(); try { await workingDirectory.delete(recursive: true); } on FileSystemException { @@ -194,6 +311,27 @@ Future runVisualizationReleaseSmoke(String reportPath) async { } } +Future _renderMath( + MathCoordinator coordinator, { + required String expression, + required String key, + bool display = false, +}) { + return coordinator.render( + MathRenderRequest( + expressionId: key, + expression: expression, + display: display, + blockKey: key, + editRevision: 1, + em: 16, + ex: 8, + containerWidth: 720, + renderProfile: 'release-smoke', + ), + ); +} + Future _render( VisualizationCoordinator coordinator, Directory workingDirectory, { @@ -322,6 +460,12 @@ const _pdfSource = ''' # Visualization release smoke +Inline math remains in the sentence: \$x^2\$, \$\\frac{a}{b}\$, and \$\\Braket{\\psi|\\phi}\$. + +\$\$ +\\ce{2H2 + O2 -> 2H2O} +\$\$ + ```mermaid flowchart LR source[Markdown] --> preview[Preview] diff --git a/lib/src/visualization/web_render_host.dart b/lib/src/visualization/web_render_host.dart index 45c09c76..f9134554 100644 --- a/lib/src/visualization/web_render_host.dart +++ b/lib/src/visualization/web_render_host.dart @@ -24,6 +24,11 @@ class OpenApiSourceReference { } abstract interface class WebRenderHost { + Future> renderMathBatch({ + required List> expressions, + required VisualizationCancellationToken cancellationToken, + }); + Future> renderMermaid({ required String source, required VisualizationTheme theme, @@ -74,11 +79,26 @@ class PlatformWebRenderHost implements WebRenderHost { ), this.renderTimeout = const Duration(seconds: 20), this.rasterTimeout = const Duration(seconds: 20), + this.mathTimeout = const Duration(seconds: 10), }) : _channel = channel; final MethodChannel _channel; final Duration renderTimeout; final Duration rasterTimeout; + final Duration mathTimeout; + + @override + Future> renderMathBatch({ + required List> expressions, + required VisualizationCancellationToken cancellationToken, + }) { + return _invokeMap( + 'renderMathBatch', + {'expressions': expressions}, + mathTimeout, + cancellationToken, + ); + } /// Release verification hook. The Linux runner accepts this operation only /// when `BUSYMARK_RELEASE_SMOKE=1` is present in its environment. diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index c2e1be7c..98c5182c 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -58,6 +58,7 @@ import '../../markdown/markdown_parser.dart'; import '../../markdown/markdown_section_editor.dart'; import '../../markdown/markdown_toc_generator.dart'; import '../../markdown/preview_model.dart'; +import '../../math/math_widget.dart'; import '../../platform/linux_header_bar_service.dart'; import '../../search/search_replace_service.dart'; import '../../visualization/visualization_card.dart'; @@ -10560,6 +10561,19 @@ class _PreviewBlockView extends StatelessWidget { displayBlock, busyMarkDocumentHeadingTextStyle(context, displayBlock.level), ), + editRevision: editRevision, + ), + ), + PreviewBlockKind.math => Padding( + padding: first + ? BusyMarkInsets.documentParagraphBlock.copyWith(top: 0) + : BusyMarkInsets.documentParagraphBlock, + child: BusyMarkDisplayMath( + expression: displayBlock.text, + expressionId: + displayBlock.attributes['expressionId'] ?? + 'display-${displayBlock.sourceStartOffset ?? 0}', + editRevision: editRevision, ), ), PreviewBlockKind.code @@ -10585,6 +10599,7 @@ class _PreviewBlockView extends StatelessWidget { child: _PreviewInlineText( block: displayBlock, style: _diffPreviewTextStyle(context, displayBlock, null), + editRevision: editRevision, ), ), PreviewBlockKind.tabs => BusyMarkDocumentCallout( @@ -10622,6 +10637,7 @@ class _PreviewBlockView extends StatelessWidget { child: _PreviewInlineText( block: displayBlock, style: _diffPreviewTextStyle(context, displayBlock, null), + editRevision: editRevision, ), ), ], @@ -10642,11 +10658,15 @@ class _PreviewBlockView extends StatelessWidget { ? _PreviewInlineText( block: displayBlock, style: _diffPreviewTextStyle(context, displayBlock, null), + editRevision: editRevision, ) : _previewChildBlocks(displayBlock.children, first: true), ), PreviewBlockKind.thematicBreak => const BusyMarkDocumentThematicBreak(), - PreviewBlockKind.table => _PreviewTable(block: displayBlock), + PreviewBlockKind.table => _PreviewTable( + block: displayBlock, + editRevision: editRevision, + ), PreviewBlockKind.container when displayBlock.attributes['htmlTag'] == 'figure' => _PreviewFigure(block: displayBlock, workspace: workspace, first: first), @@ -10671,6 +10691,7 @@ class _PreviewBlockView extends StatelessWidget { child: _PreviewInlineText( block: displayBlock, style: _diffPreviewTextStyle(context, displayBlock, null), + editRevision: editRevision, ), ), }; @@ -10891,9 +10912,10 @@ String _previewDirectionalText(PreviewBlock block) { } class _PreviewTable extends StatelessWidget { - const _PreviewTable({required this.block}); + const _PreviewTable({required this.block, this.editRevision = 0}); final PreviewBlock block; + final int editRevision; @override Widget build(BuildContext context) { @@ -10931,6 +10953,7 @@ class _PreviewTable extends StatelessWidget { child: index < row.children.length ? _PreviewInlineText( block: row.children[index], + editRevision: editRevision, style: row.attributes['header'] == 'true' ? busyMarkDocumentBodyTextStyle( context, @@ -10949,10 +10972,16 @@ class _PreviewTable extends StatelessWidget { } class _PreviewInlineText extends ConsumerWidget { - const _PreviewInlineText({super.key, required this.block, this.style}); + const _PreviewInlineText({ + super.key, + required this.block, + this.style, + this.editRevision = 0, + }); final PreviewBlock block; final TextStyle? style; + final int editRevision; @override Widget build(BuildContext context, WidgetRef ref) { @@ -10986,6 +11015,7 @@ class _PreviewInlineText extends ConsumerWidget { unawaited(_showRemoteImagesPrompt(context, ref)), onLinkTap: (destination) => _openPreviewLink(context, ref, destination), + editRevision: editRevision, ), ], ), @@ -11411,6 +11441,7 @@ InlineSpan _previewInlineSpan( required bool allowRemoteImages, required VoidCallback? onRemoteImageBlocked, required Future Function(String destination) onLinkTap, + required int editRevision, String? inheritedLinkDestination, TextStyle? inheritedStyle, }) { @@ -11450,6 +11481,7 @@ InlineSpan _previewInlineSpan( allowRemoteImages: allowRemoteImages, onRemoteImageBlocked: onRemoteImageBlocked, onLinkTap: onLinkTap, + editRevision: editRevision, inheritedLinkDestination: linkDestination, inheritedStyle: style, ); @@ -11578,6 +11610,18 @@ InlineSpan _previewInlineSpan( TextStyle(color: colors.mutedForeground, fontStyle: FontStyle.italic), ), ), + PreviewInlineKind.math => WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: BusyMarkInlineMath( + expression: inline.text, + expressionId: + inline.attributes['expressionId'] ?? + 'inline-${Object.hash(inline.text, inline.attributes)}', + editRevision: editRevision, + textStyle: mergeStyle(null) ?? DefaultTextStyle.of(context).style, + ), + ), }; } diff --git a/lib/src/workspace/workspace_service.dart b/lib/src/workspace/workspace_service.dart index 54e39108..fc2f1a4b 100644 --- a/lib/src/workspace/workspace_service.dart +++ b/lib/src/workspace/workspace_service.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:crypto/crypto.dart' as crypto; import 'package:path/path.dart' as p; +import 'package:xml/xml.dart'; import '../core/anchored_path_guard.dart'; import '../core/busymark_exception.dart'; @@ -12,6 +13,7 @@ import '../core/linux_atomic_file_api.dart'; import '../core/path_utils.dart'; import '../markdown/markdown_model.dart'; import '../markdown/markdown_parser.dart'; +import '../markdown/math_syntax.dart'; import '../markdown/preview_model.dart'; import '../writerside/writerside_module_service.dart'; import '../writerside/writerside_instance_service.dart'; @@ -1429,6 +1431,7 @@ class WorkspaceService { 'code-block', 'img', 'a', + 'math', }.contains(name); } @@ -1463,6 +1466,20 @@ class WorkspaceService { } List _xmlPreviewBlocks(String source, String? title) { + final mathExpressions = []; + try { + final document = XmlDocument.parse(source); + mathExpressions.addAll( + document.descendants + .whereType() + .where((element) => element.name.local == 'math') + .map((element) => element.innerText), + ); + } on XmlParserException { + // The ordinary semantic-element fallback below still provides a useful + // preview while the user repairs malformed topic XML. + } + var mathIndex = 0; final names = RegExp( r'<\s*([A-Za-z][A-Za-z0-9_-]*)\b', ).allMatches(source).map((match) => match.group(1)!).toList(); @@ -1472,11 +1489,29 @@ class WorkspaceService { return [ for (final name in names) if (_visibleSemanticElement(name)) - PreviewBlock( - kind: _semanticKind(name), - text: _semanticText(name, title), - attributes: {'element': name}, - ), + if (name == 'math' && mathIndex < mathExpressions.length) + PreviewBlock( + kind: PreviewBlockKind.paragraph, + text: mathExpressions[mathIndex], + inlines: [ + PreviewInline( + kind: PreviewInlineKind.math, + text: mathExpressions[mathIndex], + attributes: { + busyMarkMathSourceFormAttribute: + BusyMathSourceForm.writersideElement.name, + 'expressionId': 'topic-math-${mathIndex++}', + }, + ), + ], + attributes: const {'element': 'math'}, + ) + else + PreviewBlock( + kind: _semanticKind(name), + text: _semanticText(name, title), + attributes: {'element': name}, + ), ]; } } diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index 20adbe77..ff692ddd 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -119,6 +119,9 @@ add_custom_command( "${CMAKE_CURRENT_SOURCE_DIR}/../tools/visualization/package.json" "${CMAKE_CURRENT_SOURCE_DIR}/../tools/visualization/package-lock.json" "${CMAKE_CURRENT_SOURCE_DIR}/../tools/visualization/render_engines.js" + "${CMAKE_CURRENT_SOURCE_DIR}/../tools/visualization/mathjax_renderer.js" + "${CMAKE_CURRENT_SOURCE_DIR}/../tools/visualization/mermaid_math_disabled.js" + "${CMAKE_CURRENT_SOURCE_DIR}/../tools/visualization/build_render_engines.js" "${CMAKE_CURRENT_SOURCE_DIR}/../tools/visualization/reference.js" "${CMAKE_CURRENT_SOURCE_DIR}/../tools/visualization/bootstrap.js" "${CMAKE_CURRENT_SOURCE_DIR}/../tools/visualization/harness.html" diff --git a/linux/runner/web_render_host.cc b/linux/runner/web_render_host.cc index 07f15cec..1b977857 100644 --- a/linux/runner/web_render_host.cc +++ b/linux/runner/web_render_host.cc @@ -711,7 +711,8 @@ gchar* encode_arguments(FlValue* args, GError** error) { } gboolean is_render_operation(const gchar* method) { - return g_strcmp0(method, "renderMermaid") == 0 || + return g_strcmp0(method, "renderMathBatch") == 0 || + g_strcmp0(method, "renderMermaid") == 0 || g_strcmp0(method, "renderPlantUml") == 0 || g_strcmp0(method, "inspectOpenApi") == 0 || g_strcmp0(method, "parseOpenApi") == 0 || diff --git a/test/fixtures/writerside/basic_project/topics/math.topic b/test/fixtures/writerside/basic_project/topics/math.topic new file mode 100644 index 00000000..0d22d543 --- /dev/null +++ b/test/fixtures/writerside/basic_project/topics/math.topic @@ -0,0 +1,5 @@ + +

Euler's identity is e^{i\pi}+1=0.

+
diff --git a/test/src/busymark_design_test.dart b/test/src/busymark_design_test.dart index 91510893..89f6eae1 100644 --- a/test/src/busymark_design_test.dart +++ b/test/src/busymark_design_test.dart @@ -1324,6 +1324,8 @@ void main() { onBlockCommand: (_) {}, onInlineCommand: (_) {}, onLinkCommand: () {}, + onInlineMathCommand: () {}, + onDisplayMathCommand: () {}, onImageCommand: () {}, onInlineImageCommand: () {}, onTableCommand: () {}, @@ -1374,9 +1376,11 @@ void main() { l10n.strikethrough, l10n.inlineCode, l10n.link, + l10n.inlineMath, l10n.hardLineBreak, l10n.blockquote, l10n.codeBlock, + l10n.displayMath, l10n.htmlBlock, l10n.thematicBreak, l10n.unorderedList, diff --git a/test/src/d2_renderer_test.dart b/test/src/d2_renderer_test.dart index f5aed9f6..8ff451d5 100644 --- a/test/src/d2_renderer_test.dart +++ b/test/src/d2_renderer_test.dart @@ -257,6 +257,12 @@ class _RasterHost implements WebRenderHost { double? lastScale; String? lastSvg; + @override + Future> renderMathBatch({ + required List> expressions, + required VisualizationCancellationToken cancellationToken, + }) => throw UnimplementedError(); + @override Future copyPngToClipboard(Uint8List pngBytes) async {} diff --git a/test/src/markdown_math_export_test.dart b/test/src/markdown_math_export_test.dart new file mode 100644 index 00000000..35aa2701 --- /dev/null +++ b/test/src/markdown_math_export_test.dart @@ -0,0 +1,179 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:busymark/src/export/markdown_export_document.dart'; +import 'package:busymark/src/export/markdown_export_mapper.dart'; +import 'package:busymark/src/export/markdown_math_export.dart'; +import 'package:busymark/src/export/markdown_pdf_export_service.dart'; +import 'package:busymark/src/export/markdown_pdf_models.dart'; +import 'package:busymark/src/export/typst_compiler.dart'; +import 'package:busymark/src/export/typst_payload_builder.dart'; +import 'package:busymark/src/markdown/markdown_parser.dart'; +import 'package:busymark/src/math/math_coordinator.dart'; +import 'package:busymark/src/math/math_renderer.dart'; +import 'package:busymark/src/visualization/visualization_renderer.dart'; +import 'package:busymark/src/visualization/web_render_host.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; + +void main() { + test( + 'prepares inline and display math as deterministic vector assets', + () async { + final root = await Directory.systemTemp.createTemp( + 'busymark-math-export-', + ); + addTearDown(() => root.delete(recursive: true)); + final coordinator = MathCoordinator( + renderer: MathRenderer(host: _PdfMathHost()), + ); + addTearDown(coordinator.dispose); + final parsed = const MarkdownParser().parse( + filePath: '/workspace/math.md', + source: r''' +Text before $x^2$ and $\frac{a}{b}$ after. + +$$ +\ce{2H2 + O2 -> 2H2O} +$$ +''', + validateLocalReferences: false, + ); + final mapped = const MarkdownExportMapper().map(parsed.busyDocument); + + final preparation = + await MarkdownMathExportRenderer(coordinator: coordinator).prepare( + document: mapped, + exportRoot: root, + containerWidth: 480, + cancellationToken: MarkdownPdfCancellationToken(), + ); + + expect(preparation.warnings, isEmpty); + final paragraph = preparation.document.blocks.firstWhere( + (block) => block.kind == MarkdownExportBlockKind.paragraph, + ); + final inlineMath = paragraph.inlines + .where((inline) => inline.kind == MarkdownExportInlineKind.math) + .toList(); + expect(inlineMath, hasLength(2)); + expect( + inlineMath, + everyElement( + predicate((inline) { + return inline.attributes['vector'] == 'true' && + inline.attributes['depth'] == '2.0' && + inline.attributes['asset']!.endsWith('.svg'); + }), + ), + ); + final display = preparation.document.blocks.firstWhere( + (block) => block.kind == MarkdownExportBlockKind.math, + ); + expect(display.attributes['vector'], 'true'); + expect(display.attributes['depth'], '2.0'); + final generated = Directory(p.join(root.path, 'generated-assets')); + expect( + await generated + .list() + .where((entry) => entry.path.endsWith('.svg')) + .length, + 3, + ); + + final payload = const TypstPayloadBuilder().build( + document: preparation.document, + options: const MarkdownPdfOptions(), + assets: const {}, + ); + final json = jsonEncode(payload); + expect(json, contains('"kind":"math"')); + expect(json, contains('"depth":"2.0"')); + expect(json, contains('generated-assets/')); + }, + ); + + final typstPath = Platform.environment['BUSYMARK_TYPST_PATH']; + final canRunTypst = typstPath != null && File(typstPath).existsSync(); + test( + 'Typst keeps MathJax SVG inline and exports failed math visibly', + () async { + final root = await Directory.systemTemp.createTemp('busymark-math-pdf-'); + addTearDown(() => root.delete(recursive: true)); + final coordinator = MathCoordinator( + renderer: MathRenderer(host: _PdfMathHost()), + ); + addTearDown(coordinator.dispose); + final destination = p.join(root.path, 'math.pdf'); + final result = + await MarkdownPdfExportService( + compilerLocator: TypstCompilerLocator( + environment: {'BUSYMARK_TYPST_PATH': typstPath!}, + ), + mathRenderer: MarkdownMathExportRenderer(coordinator: coordinator), + templateLoader: () => + File('assets/export/markdown.typ').readAsString(), + ).export( + MarkdownPdfExportRequest( + source: r''' +Text before $x^2$ and $\frac{a}{b}$ after. + +$$\ce{2H2 + O2 -> 2H2O}$$ + +Failed but visible: $BAD$. +''', + filePath: p.join(root.path, 'math.md'), + workspaceRoot: root.path, + destinationPath: destination, + options: const MarkdownPdfOptions(), + overwrite: false, + ), + ); + + final bytes = await File(destination).readAsBytes(); + expect(bytes.take(5), [37, 80, 68, 70, 45]); + expect( + result.warnings.map((warning) => warning.code), + contains(MarkdownPdfWarningCode.mathRenderFailed), + ); + }, + skip: canRunTypst ? false : 'Set BUSYMARK_TYPST_PATH to run Typst math.', + ); +} + +class _PdfMathHost implements WebRenderHost { + @override + Future> renderMathBatch({ + required List> expressions, + required VisualizationCancellationToken cancellationToken, + }) async { + return { + 'results': [ + for (final item in expressions) + if (item['expression'] == 'BAD') + { + 'id': item['id'], + 'error': { + 'code': 'math.invalidTex', + 'message': 'The expression contains invalid TeX.', + }, + } + else + { + 'id': item['id'], + 'svg': ''' + + + ''', + 'width': 30, + 'height': 14, + 'depth': 2, + }, + ], + }; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/src/math_parser_test.dart b/test/src/math_parser_test.dart new file mode 100644 index 00000000..ac5f2168 --- /dev/null +++ b/test/src/math_parser_test.dart @@ -0,0 +1,247 @@ +import 'package:busymark/src/markdown/busymark_document.dart'; +import 'package:busymark/src/markdown/busymark_markdown_serializer.dart'; +import 'package:busymark/src/markdown/markdown_model.dart'; +import 'package:busymark/src/markdown/markdown_parser.dart'; +import 'package:busymark/src/markdown/math_syntax.dart'; +import 'package:busymark/src/markdown/preview_model.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const parser = MarkdownParser(); + const serializer = BusyMarkMarkdownSerializer(); + + BusyDocument parse(String source, {bool writerside = false}) { + return parser + .parse( + filePath: writerside ? 'topic.md' : 'document.md', + source: source, + mode: writerside + ? MarkdownMode.writersideMarkdown + : MarkdownMode.commonMark, + validateLocalReferences: false, + ) + .busyDocument; + } + + test('parses several dollar formulas as semantic inline math', () { + final document = parse(r'Text $x$ then $\frac{a}{b}$ and **$z^2$**.'); + final math = document.blocks + .expand(allInlines) + .where((inline) => inline.kind == BusyInlineKind.math) + .toList(); + + expect(math.map((inline) => inline.text), ['x', r'\frac{a}{b}', 'z^2']); + expect( + math.map((inline) => inline.attributes[busyMarkMathSourceFormAttribute]), + everyElement(BusyMathSourceForm.dollarInline.name), + ); + }); + + test('parses the GitHub dollar-backtick form without delimiters', () { + final document = parse(r'Before $`\sqrt{x}`$ after.'); + final math = document.blocks.single.inlines.singleWhere( + (inline) => inline.kind == BusyInlineKind.math, + ); + + expect(math.text, r'\sqrt{x}'); + expect( + math.attributes[busyMarkMathSourceFormAttribute], + BusyMathSourceForm.githubDollarBacktick.name, + ); + }); + + test('parses single-line and multiline display math as blocks', () { + final document = parse(r''' +Paragraph before. + +$$x^2 + y^2$$ + +$$ +\begin{aligned} +a &= b \\ +c &= d +\end{aligned} +$$ + +Paragraph after. +'''); + final math = document.blocks + .where((block) => block.kind == BusyBlockKind.math) + .toList(); + + expect(math, hasLength(2)); + expect(math.first.plainText, 'x^2 + y^2'); + expect(math.last.plainText, contains(r'\begin{aligned}')); + expect(math.every((block) => block.sourceSpan != null), isTrue); + }); + + test('math scanner stays aligned around every neighboring block kind', () { + final source = ''' +Paragraph. + +\$\$x\$\$ + +# Heading + +- List + +> Quote + +```dart +code(); +``` + +--- + +\$\$y\$\$ + +Final paragraph. +'''; + final document = parse(source); + + expect( + document.blocks.where((b) => b.kind == BusyBlockKind.math), + hasLength(2), + ); + expect(document.blocks.every((block) => block.sourceSpan != null), isTrue); + expect(serializer.serialize(document), source); + }); + + test('math and Writerside tex fences follow document compatibility', () { + const source = ''' +```math +E = mc^2 +``` + +```tex +F = ma +``` +'''; + final markdown = parse(source); + final writerside = parse(source, writerside: true); + + expect(markdown.blocks.map((block) => block.kind), [ + BusyBlockKind.math, + BusyBlockKind.codeBlock, + ]); + expect(writerside.blocks.map((block) => block.kind), [ + BusyBlockKind.math, + BusyBlockKind.math, + ]); + expect(serializer.serialize(markdown), source); + expect(serializer.serialize(writerside), source); + }); + + test('Writerside semantic math is inline and remains source-preserving', () { + const source = 'The result is \\mathbb{R} here.\n'; + final document = parse(source, writerside: true); + final math = document.blocks.single.inlines.singleWhere( + (inline) => inline.kind == BusyInlineKind.math, + ); + + expect(math.text, r'\mathbb{R}'); + expect( + math.attributes[busyMarkMathSourceFormAttribute], + BusyMathSourceForm.writersideElement.name, + ); + expect(serializer.serialize(document), source); + }); + + test( + 'escaped dollars, code, currency, empty and malformed forms stay text', + () { + final document = parse( + r'Cost is \$5, range $5-$10, code `$x$`, empty $$, and open $x.', + ); + + expect( + document.blocks + .expand(allInlines) + .where((inline) => inline.kind == BusyInlineKind.math), + isEmpty, + ); + expect(serializer.serialize(document), contains(r'open $x')); + + final emptyDisplay = parse(r'''$$ + +$$ +'''); + expect( + emptyDisplay.blocks.where((block) => block.kind == BusyBlockKind.math), + isEmpty, + ); + + final mixedCurrency = parse( + r'It costs $5 and the variable is $x$; another price is US$10.00.', + ); + final math = mixedCurrency.blocks + .expand(allInlines) + .where((inline) => inline.kind == BusyInlineKind.math) + .toList(); + expect(math.map((inline) => inline.text), ['x']); + expect( + serializer.serialize(mixedCurrency), + r'It costs $5 and the variable is $x$; another price is US$10.00.', + ); + }, + ); + + test('inline math survives list, blockquote, and table structure', () { + final document = parse(r''' +- Item $x$ + +> Quote $y$ + +| Value | +| --- | +| $z$ | +'''); + final expressions = document.blocks + .expand(allInlines) + .where((inline) => inline.kind == BusyInlineKind.math) + .map((inline) => inline.text); + + expect(expressions, containsAll(['x', 'y', 'z'])); + }); + + test('CRLF math input and every original form round-trip untouched', () { + const source = + 'Inline \$x\$ and \$`y`\$.\r\n\r\n\$\$\r\nz^2\r\n\$\$\r\n\r\n```math\r\na+b\r\n```\r\n'; + final document = parse(source); + + expect(serializer.serialize(document), source); + }); + + test('preview builder creates first-class inline and display math nodes', () { + final preview = const BusyMarkPreviewBuilder().build( + parse('Inline \$x\$ and another \$x\$.\n\n\$\$y\$\$\n'), + ); + + expect(preview.blocks.last.kind, PreviewBlockKind.math); + final inlineMath = preview.blocks.first.inlines + .where((inline) => inline.kind == PreviewInlineKind.math) + .toList(); + expect(inlineMath, hasLength(2)); + expect( + inlineMath.map((inline) => inline.attributes['expressionId']).toSet(), + hasLength(2), + ); + }); +} + +Iterable descendants(BusyInline inline) sync* { + for (final child in inline.children) { + yield child; + yield* descendants(child); + } +} + +Iterable allInlines(BusyBlock block) sync* { + yield* block.inlines; + for (final inline in block.inlines) { + yield* descendants(inline); + } + for (final child in block.children) { + yield* allInlines(child); + } +} diff --git a/test/src/math_renderer_test.dart b/test/src/math_renderer_test.dart new file mode 100644 index 00000000..9eec6fb6 --- /dev/null +++ b/test/src/math_renderer_test.dart @@ -0,0 +1,287 @@ +import 'dart:async'; + +import 'package:busymark/src/math/math_coordinator.dart'; +import 'package:busymark/src/math/math_models.dart'; +import 'package:busymark/src/math/math_renderer.dart'; +import 'package:busymark/src/math/math_svg_preprocessor.dart'; +import 'package:busymark/src/visualization/generated_svg_normalizer.dart'; +import 'package:busymark/src/visualization/visualization_renderer.dart'; +import 'package:busymark/src/visualization/web_render_host.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:xml/xml.dart'; + +void main() { + test( + 'renders multiple expressions in one batch and isolates failure', + () async { + final host = _MathHost(); + final renderer = MathRenderer(host: host); + final requests = [ + _request('one', r'\mathbb{R}', blockKey: 'one'), + _request('bad', r'\frac{', blockKey: 'bad'), + ]; + + final results = await renderer.renderBatch( + requests, + VisualizationCancellationToken(), + ); + + expect(host.calls, 1); + expect(host.batches.single, hasLength(2)); + expect(results[0], isA()); + expect(results[0].expressionId, 'one'); + expect((results[0] as RenderedMathResult).depth, 2); + expect(results[1], isA()); + expect( + (results[1] as FailedMathResult).kind, + MathRenderErrorKind.invalidTex, + ); + }, + ); + + test( + 'coalesces a large page, caches successes, and rebases local IDs', + () async { + final host = _MathHost(); + final coordinator = MathCoordinator(renderer: MathRenderer(host: host)); + addTearDown(coordinator.dispose); + final requests = [ + for (var index = 0; index < 100; index++) + _request('expression-$index', 'x_$index', blockKey: 'block-$index'), + ]; + + final first = await coordinator.renderAll(requests); + + expect(host.calls, 1, reason: 'one page must use one WebKit batch'); + expect(first, everyElement(isA())); + final firstSvg = (first.first as RenderedMathResult).svg; + final cached = await coordinator.render( + _request('cached-instance', 'x_0', blockKey: 'cached-block'), + ); + expect(host.calls, 1); + expect(cached, isA()); + expect((cached as RenderedMathResult).svg, isNot(firstSvg)); + expect(cached.svg, contains('cached-instance')); + }, + ); + + test('discards an obsolete block revision', () async { + final host = _MathHost(delay: const Duration(milliseconds: 20)); + final coordinator = MathCoordinator(renderer: MathRenderer(host: host)); + addTearDown(coordinator.dispose); + + final obsolete = coordinator.render( + _request('old', 'x', blockKey: 'same', revision: 1), + ); + final current = coordinator.render( + _request('new', 'y', blockKey: 'same', revision: 2), + ); + + await expectLater(obsolete, throwsA(isA())); + expect(await current, isA()); + expect(host.batches.single, hasLength(1)); + }); + + test('cancels WebKit work after a sole request is superseded', () async { + final host = _MathHost( + delay: const Duration(seconds: 1), + waitForCancellation: true, + ); + final coordinator = MathCoordinator(renderer: MathRenderer(host: host)); + addTearDown(coordinator.dispose); + + final obsolete = coordinator.render( + _request('old', 'x', blockKey: 'same', revision: 1), + ); + await Future.delayed(Duration.zero); + coordinator.cancel('same'); + + await expectLater(obsolete, throwsA(isA())); + expect(host.sawCancellation, isTrue); + }); + + test('maps timeout and unavailable hosts to local math failures', () async { + final timeout = + await MathRenderer( + host: _ThrowingMathHost(TimeoutException('slow')), + ).renderBatch([ + _request('timeout', 'x', blockKey: 'timeout'), + ], VisualizationCancellationToken()); + final unavailable = + await MathRenderer( + host: _ThrowingMathHost(StateError('restarting')), + ).renderBatch([ + _request('unavailable', 'x', blockKey: 'unavailable'), + ], VisualizationCancellationToken()); + + expect( + (timeout.single as FailedMathResult).kind, + MathRenderErrorKind.timeout, + ); + expect( + (unavailable.single as FailedMathResult).kind, + MathRenderErrorKind.rendererUnavailable, + ); + }); + + test('rejects oversized input before invoking WebKit', () async { + final host = _MathHost(); + final renderer = MathRenderer(host: host); + + final result = await renderer.renderBatch([ + _request( + 'large', + 'x' * (busyMarkMaximumMathExpressionCharacters + 1), + blockKey: 'large', + ), + ], VisualizationCancellationToken()); + + expect(host.calls, 0); + expect( + (result.single as FailedMathResult).kind, + MathRenderErrorKind.resourceLimit, + ); + }); + + test('extracts baseline style and preserves vector-safe MathJax SVG', () { + const source = ''' + + + + +'''; + const preprocessor = MathSvgPreprocessor(); + + final prepared = preprocessor.preprocess(source, ex: 8); + final rebased = preprocessor.rebaseLocalIds(prepared.svg, 'formula-two'); + + expect(prepared.depth, 2); + expect(prepared.svg, isNot(contains('vertical-align'))); + expect(prepared.svg, contains('')); + expect(rebased, contains('id="formula-two-0"')); + expect(rebased, contains('href="#formula-two-0"')); + expect(rebased, contains('currentColor')); + }); + + test('rebases wide MathJax coordinates before secure normalization', () { + const source = ''' + + + + +'''; + const preprocessor = MathSvgPreprocessor(); + + final prepared = preprocessor.preprocess(source, ex: 8); + final document = XmlDocument.parse(prepared.svg); + final viewBox = document.rootElement + .getAttribute('viewBox')! + .split(' ') + .map(double.parse) + .toList(); + + expect(viewBox[2], 16000); + expect(prepared.svg, contains('transform="scale(')); + expect( + const GeneratedSvgNormalizer().normalize(prepared.svg).vectorSafeSvg, + isNotNull, + ); + }); +} + +MathRenderRequest _request( + String id, + String expression, { + required String blockKey, + int revision = 1, +}) { + return MathRenderRequest( + expressionId: id, + expression: expression, + display: false, + blockKey: blockKey, + editRevision: revision, + em: 16, + ex: 8, + containerWidth: 800, + ); +} + +class _MathHost implements WebRenderHost { + _MathHost({this.delay = Duration.zero, this.waitForCancellation = false}); + + final Duration delay; + final bool waitForCancellation; + int calls = 0; + final List>> batches = []; + bool sawCancellation = false; + + @override + Future> renderMathBatch({ + required List> expressions, + required VisualizationCancellationToken cancellationToken, + }) async { + calls++; + batches.add(expressions); + if (waitForCancellation) { + while (!cancellationToken.isCancelled) { + await Future.delayed(const Duration(milliseconds: 5)); + } + sawCancellation = true; + } else if (delay > Duration.zero) { + await Future.delayed(delay); + } + cancellationToken.throwIfCancelled(); + return { + 'results': [ + for (final item in expressions) + if ((item['expression'] as String).contains(r'\frac{')) + { + 'id': item['id'], + 'error': { + 'code': 'math.invalidTex', + 'message': 'The expression contains invalid TeX.', + }, + } + else + { + 'id': item['id'], + 'svg': _svg(item['svgIdPrefix'] as String), + 'width': 16, + 'height': 12, + 'depth': 2, + }, + ], + }; + } + + String _svg(String prefix) => + ''' + + + +'''; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _ThrowingMathHost implements WebRenderHost { + const _ThrowingMathHost(this.error); + + final Object error; + + @override + Future> renderMathBatch({ + required List> expressions, + required VisualizationCancellationToken cancellationToken, + }) { + return Future.error(error); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/src/math_widget_test.dart b/test/src/math_widget_test.dart new file mode 100644 index 00000000..f2502a89 --- /dev/null +++ b/test/src/math_widget_test.dart @@ -0,0 +1,209 @@ +import 'package:busymark/l10n/generated/app_localizations.dart'; +import 'package:busymark/src/app/busymark_glyphs.dart'; +import 'package:busymark/src/math/math_widget.dart'; +import 'package:busymark/src/visualization/visualization_providers.dart'; +import 'package:busymark/src/visualization/visualization_renderer.dart'; +import 'package:busymark/src/visualization/web_render_host.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'inline math applies MathJax baseline and surrounding text metrics', + (tester) async { + final host = _MathWidgetHost(width: 24, height: 18, depth: 5); + + await tester.pumpWidget( + ProviderScope( + overrides: [webRenderHostProvider.overrideWithValue(host)], + child: const _InlineThemeHarness(), + ), + ); + await _pumpRenderedMath(tester); + + expect(find.byType(SvgPicture), findsOneWidget); + expect(find.bySemanticsLabel(r'x^2'), findsOneWidget); + expect(tester.widget(find.byType(Baseline)).baseline, 13); + expect(host.calls, 1); + expect(host.lastExpression?['em'], 20); + expect(host.lastExpression?['ex'], 10); + + await tester.tap(find.byKey(const ValueKey('toggle-math-theme'))); + await tester.pump(); + expect( + host.calls, + 1, + reason: 'currentColor SVG must be reusable when the UI theme changes', + ); + }, + ); + + testWidgets('wide display math scrolls horizontally without clipping', ( + tester, + ) async { + final host = _MathWidgetHost(width: 900, height: 40, depth: 4); + + await tester.pumpWidget( + ProviderScope( + overrides: [webRenderHostProvider.overrideWithValue(host)], + child: _localizedApp( + const SizedBox( + width: 200, + child: BusyMarkDisplayMath( + expression: r'\sum_{i=1}^{100} a_i', + expressionId: 'wide', + editRevision: 1, + ), + ), + ), + ), + ); + await _pumpRenderedMath(tester); + + final scroller = tester.widget( + find.byType(SingleChildScrollView), + ); + expect(scroller.scrollDirection, Axis.horizontal); + expect( + find.byWidgetPredicate( + (widget) => widget is SizedBox && widget.width == 900, + ), + findsOneWidget, + ); + expect(host.lastExpression?['containerWidth'], 208); + }); + + testWidgets('failed formulas retain source and a localized explanation', ( + tester, + ) async { + final host = _MathWidgetHost(fail: true); + + await tester.pumpWidget( + ProviderScope( + overrides: [webRenderHostProvider.overrideWithValue(host)], + child: _localizedApp( + const BusyMarkDisplayMath( + expression: r'\frac{', + expressionId: 'invalid', + editRevision: 1, + ), + ), + ), + ); + await tester.pump(); + await tester.pump(); + + expect(find.text(r'\frac{'), findsOneWidget); + expect( + find.byTooltip('The mathematical expression could not be rendered.'), + findsOneWidget, + ); + expect(find.byType(SvgPicture), findsNothing); + }); +} + +Widget _localizedApp(Widget child) { + return MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: child), + ); +} + +class _InlineThemeHarness extends StatefulWidget { + const _InlineThemeHarness(); + + @override + State<_InlineThemeHarness> createState() => _InlineThemeHarnessState(); +} + +class _InlineThemeHarnessState extends State<_InlineThemeHarness> { + var dark = false; + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData.light(), + darkTheme: ThemeData.dark(), + themeMode: dark ? ThemeMode.dark : ThemeMode.light, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Column( + children: [ + IconButton( + key: const ValueKey('toggle-math-theme'), + onPressed: () => setState(() => dark = !dark), + icon: const Icon(BusyMarkGlyphs.appearance), + ), + const BusyMarkInlineMath( + expression: r'x^2', + expressionId: 'inline', + editRevision: 1, + textStyle: TextStyle(fontSize: 20), + ), + ], + ), + ), + ); + } +} + +Future _pumpRenderedMath(WidgetTester tester) async { + for (var attempt = 0; attempt < 20; attempt++) { + await tester.pump(const Duration(milliseconds: 10)); + if (find.byType(SvgPicture).evaluate().isNotEmpty) return; + } +} + +class _MathWidgetHost implements WebRenderHost { + _MathWidgetHost({ + this.width = 20, + this.height = 14, + this.depth = 2, + this.fail = false, + }); + + final double width; + final double height; + final double depth; + final bool fail; + int calls = 0; + Map? lastExpression; + + @override + Future> renderMathBatch({ + required List> expressions, + required VisualizationCancellationToken cancellationToken, + }) async { + calls++; + lastExpression = expressions.single; + cancellationToken.throwIfCancelled(); + return { + 'results': [ + if (fail) + { + 'id': expressions.single['id'], + 'error': {'code': 'math.invalidTex', 'message': 'Invalid TeX'}, + } + else + { + 'id': expressions.single['id'], + 'svg': ''' + + + ''', + 'width': width, + 'height': height, + 'depth': depth, + }, + ], + }; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/src/openapi_dependency_resolver_test.dart b/test/src/openapi_dependency_resolver_test.dart index 9e1a395e..dcc3456a 100644 --- a/test/src/openapi_dependency_resolver_test.dart +++ b/test/src/openapi_dependency_resolver_test.dart @@ -235,6 +235,12 @@ class _ReferenceHost implements WebRenderHost { final Map> references; final List inspectedSources = []; + @override + Future> renderMathBatch({ + required List> expressions, + required VisualizationCancellationToken cancellationToken, + }) => throw UnimplementedError(); + @override Future copyPngToClipboard(Uint8List pngBytes) => throw UnimplementedError(); diff --git a/test/src/source_audit_test.dart b/test/src/source_audit_test.dart index ba1a32f4..bf812af8 100644 --- a/test/src/source_audit_test.dart +++ b/test/src/source_audit_test.dart @@ -150,6 +150,8 @@ void main() { orderedEquals([ 'Icons' '.fork_right', + 'Icons' + '.functions', ]), ); }); diff --git a/test/src/source_highlighter_test.dart b/test/src/source_highlighter_test.dart index 07e82c7c..0d2983b8 100644 --- a/test/src/source_highlighter_test.dart +++ b/test/src/source_highlighter_test.dart @@ -623,6 +623,28 @@ void main() { expect(_spanStyle(spans, 'value')?.fontStyle, FontStyle.italic); }); + + testWidgets('markdown highlighter recognizes math without damaging code', ( + tester, + ) async { + final spans = await _highlightMarkdown( + tester, + r'Formula $x^2$ and $`a_b`$; code `$not$`; escaped \$5 and $5-$10.' + '\n' + r'$$' + '\n' + r'\int_0^1 x\,dx' + '\n' + r'$$' + '\n', + ); + + final mathStyle = _spanStyle(spans, 'x^2'); + expect(mathStyle?.color, isNotNull); + expect(_spanStyle(spans, 'a_b')?.color, mathStyle?.color); + expect(_spanStyle(spans, r'$not$')?.fontFamily, 'Ubuntu Mono'); + expect(_spanStyle(spans, r'\int_0^1 x\,dx')?.color, mathStyle?.color); + }); } Future> _highlightMarkdown( diff --git a/test/src/visualization_card_test.dart b/test/src/visualization_card_test.dart index f66268e9..cbf255b8 100644 --- a/test/src/visualization_card_test.dart +++ b/test/src/visualization_card_test.dart @@ -306,6 +306,12 @@ class _OpenReferenceHost implements WebRenderHost { String? lastTitle; Uint8List copiedPng = Uint8List(0); + @override + Future> renderMathBatch({ + required List> expressions, + required VisualizationCancellationToken cancellationToken, + }) => throw UnimplementedError(); + @override Future copyPngToClipboard(Uint8List pngBytes) async { copiedPng = pngBytes; diff --git a/test/src/visualization_packaging_audit_test.dart b/test/src/visualization_packaging_audit_test.dart index 7257bfec..3a9be1e6 100644 --- a/test/src/visualization_packaging_audit_test.dart +++ b/test/src/visualization_packaging_audit_test.dart @@ -41,22 +41,35 @@ void main() { ) as Map; final dependencies = package['dependencies'] as Map; + final lock = File( + 'tools/visualization/package-lock.json', + ).readAsStringSync(); + final webBuild = File( + 'tools/visualization/build_render_engines.js', + ).readAsStringSync(); final cmake = File('linux/CMakeLists.txt').readAsStringSync(); - expect(dependencies['mermaid'], '11.16.1'); + expect(dependencies, isNot(contains('mermaid'))); + expect(dependencies['@mermaid-js/parser'], '1.2.0'); expect(dependencies['@plantuml/core'], '1.2026.6'); expect(dependencies['@scalar/openapi-parser'], '0.28.14'); expect(dependencies['@scalar/api-reference'], '1.65.1'); expect(dependencies['@scalar/json-magic'], '0.13.0'); expect(dependencies['yaml'], '2.9.0'); + expect(dependencies['@mathjax/src'], '4.1.3'); + expect(dependencies['@mathjax/mathjax-newcm-font'], '4.1.3'); + expect(dependencies, isNot(contains('katex'))); + expect(lock, isNot(contains('node_modules/katex'))); expect((package['engines'] as Map)['node'], '>=22'); for (final checksum in [ - 'ebd9885111092c78cefc79a76f6c1dc34ed5b834b02ae8f338227ce79c003de4', + '0ee99b3bb82766e5d6c34b8cc768b8530ce8f1aaa13790ae368aebeef3de9d11', '798f99592eb03a6446519d2becf78e6f1008d0d25c75d60b37a0f46e39e3c413', '993bb7ebb3480cc574665b0eac52d9cd4a817fdf5b4444894bb70e174880513d', '68b6f22ca530ac50e3cd034c5189d89cc5457c3c2d325b44e90db05c9f08c573', 'f1adefc461f3594afd4ad16974820a5a88b271f7e8051045c2ac7a34eb974d33', '008fa204cb1ba700e0272ba045abbf09a6ffe63456e8146ba97cac6c2ad1ef91', + '4611bed26b338dfc4b5757b8ed2d7ba82a85bcbd05d2729fb3465fba17b8896c', + '87d7b869c6a2a6169d9a53acc4eab6c846a9cbe11752738226461bb5070c8b88', ]) { expect(fetch, contains(checksum)); } @@ -65,6 +78,14 @@ void main() { expect(fetch, contains('--ignore-scripts')); expect(fetch, contains('NODE_MAJOR < 22')); expect(fetch, contains('THIRD_PARTY_NOTICES.md')); + expect(fetch, contains(r'mermaid@${MERMAID_VERSION}.tar.gz')); + expect(fetch, contains('packages/mermaid')); + expect(fetch, contains('build_render_engines.js')); + expect(webBuild, contains('katex: disabledMathModule')); + expect( + webBuild, + contains("path.join(mermaidSource, 'src', 'mermaid.ts')"), + ); expect(cmake, contains('share/busymark/visualization')); expect(cmake, contains('bootstrap.js')); }, @@ -144,6 +165,12 @@ void main() { final bootstrap = File( 'tools/visualization/bootstrap.js', ).readAsStringSync(); + final math = File( + 'tools/visualization/mathjax_renderer.js', + ).readAsStringSync(); + final renderEngines = File( + 'tools/visualization/render_engines.js', + ).readAsStringSync(); final snapcraft = File('snap/snapcraft.yaml').readAsStringSync(); expect(native, contains('webkit_web_context_new_ephemeral')); @@ -163,6 +190,7 @@ void main() { expect(native, contains('render_process_terminated_cb')); expect(native, contains('recreate_render_view')); expect(native, contains('terminateWebProcessForReleaseSmoke')); + expect(native, contains('"renderMathBatch"')); expect(native, contains('BUSYMARK_RELEASE_SMOKE')); expect(native, contains('gtk_widget_get_allocated_width')); expect(native, contains('snapshot_allocation_attempts')); @@ -175,6 +203,30 @@ void main() { expect(html, isNot(contains('cdn.'))); } expect(bootstrap, contains('createMemoryStorage')); + expect(renderEngines, contains("case 'renderMathBatch':")); + expect(math, contains("export const mathJaxVersion = '4.1.3'")); + expect(math, contains("export const mathJaxFontVersion = '4.1.3'")); + expect(math, contains('mathjax.asyncLoad = () => Promise.resolve()')); + expect(math, contains("URLs: 'none'")); + expect(math, contains("classes: 'none'")); + expect(math, contains("cssIDs: 'none'")); + expect(math, contains("styles: 'none'")); + expect(math, contains('maxBuffer: 20 * 1024')); + expect(math, contains('maxMacros: 500')); + expect(math, contains('maxTemplateSubtitutions: 2000')); + expect(math, contains("fontCache: 'local'")); + expect(math, contains('useXlink: false')); + expect(math, contains('clearExpressionDefinitions')); + expect(math, contains('tex.reset(0)')); + for (final disabledPackage in [ + 'AutoloadConfiguration', + 'RequireConfiguration', + 'SetOptionsConfiguration', + 'TexHtmlConfiguration', + 'PhysicsConfiguration', + ]) { + expect(math, isNot(contains(disabledPackage))); + } expect(scalar, contains('telemetry: false')); expect(scalar, contains('persistAuth: false')); expect(scalar, contains('hideTestRequestButton: true')); diff --git a/test/src/visualization_raster_sizing_test.dart b/test/src/visualization_raster_sizing_test.dart index ffd6e1f5..7388ea6f 100644 --- a/test/src/visualization_raster_sizing_test.dart +++ b/test/src/visualization_raster_sizing_test.dart @@ -154,6 +154,12 @@ class _LimitEnforcingRasterHost implements WebRenderHost { int? pixelWidth; int? pixelHeight; + @override + Future> renderMathBatch({ + required List> expressions, + required VisualizationCancellationToken cancellationToken, + }) => throw UnimplementedError(); + @override Future rasterizeSvg({ required String svg, diff --git a/test/src/web_render_host_test.dart b/test/src/web_render_host_test.dart index 14a63fd3..072c12ea 100644 --- a/test/src/web_render_host_test.dart +++ b/test/src/web_render_host_test.dart @@ -38,6 +38,29 @@ void main() { expect(arguments['requestId'], isA()); }); + test('sends a MathJax batch in one cancellable host request', () async { + MethodCall? received; + messenger.setMockMethodCallHandler(channel, (call) async { + received = call; + return {'results': []}; + }); + const host = PlatformWebRenderHost(channel: channel); + final expressions = >[ + {'id': 'one', 'expression': 'x', 'display': false}, + {'id': 'two', 'expression': r'\mathbb{R}', 'display': true}, + ]; + + await host.renderMathBatch( + expressions: expressions, + cancellationToken: VisualizationCancellationToken(), + ); + + expect(received?.method, 'renderMathBatch'); + final arguments = received?.arguments as Map; + expect(arguments['expressions'], expressions); + expect(arguments['requestId'], isA()); + }); + test( 'cancels the matching native request and rejects a late success', () async { diff --git a/test/src/writerside_test.dart b/test/src/writerside_test.dart index 820fc06d..0082eb2e 100644 --- a/test/src/writerside_test.dart +++ b/test/src/writerside_test.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:path/path.dart' as p; import 'package:busymark/src/core/diagnostic.dart'; import 'package:busymark/src/core/path_utils.dart'; +import 'package:busymark/src/markdown/preview_model.dart'; import 'package:busymark/src/workspace/workspace_model.dart'; import 'package:busymark/src/workspace/workspace_service.dart'; import 'package:busymark/src/writerside/writerside_module_service.dart'; @@ -171,6 +172,31 @@ void main() { expect(instance.topicFileSet, containsAll(['intro.md', 'install.topic'])); }); + test( + 'Writerside topic XML exposes semantic math to the shared preview', + () async { + final workspace = await workspaceService.openPath( + 'test/fixtures/writerside/basic_project', + ); + final topicPath = p.normalize( + p.absolute('test/fixtures/writerside/basic_project/topics/math.topic'), + ); + final source = File(topicPath).readAsStringSync(); + final preview = workspaceService.buildPreview( + workspace.copyWith(activeFilePath: topicPath), + source, + ); + + final math = preview!.blocks + .expand((block) => block.inlines) + .where((inline) => inline.kind == PreviewInlineKind.math) + .single; + expect(math.text, r'e^{i\pi}+1=0'); + expect(math.attributes['mathSourceForm'], 'writersideElement'); + expect(source, contains(r'e^{i\pi}+1=0')); + }, + ); + test( 'loads basic Writerside project with topics and no missing topic diagnostics', () async { diff --git a/test/src/wysiwyg_math_test.dart b/test/src/wysiwyg_math_test.dart new file mode 100644 index 00000000..96dd52fd --- /dev/null +++ b/test/src/wysiwyg_math_test.dart @@ -0,0 +1,215 @@ +import 'package:busymark/l10n/generated/app_localizations.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_editor.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_document_controller.dart'; +import 'package:busymark/src/markdown/markdown_parser.dart'; +import 'package:busymark/src/visualization/visualization_providers.dart'; +import 'package:busymark/src/visualization/visualization_renderer.dart'; +import 'package:busymark/src/visualization/web_render_host.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('math source edits cannot discard additional Markdown blocks', () { + final document = const MarkdownParser() + .parse( + filePath: 'math.md', + source: + r'Before $x$ after.' + '\n', + ) + .busyDocument; + final controller = BusyMarkWysiwygDocumentController(document: document); + + controller.updateMathSource( + document.blocks.single.id, + r'Before $y$ after.' + '\n\n' + 'A second paragraph.', + ); + + expect(controller.document.blocks, hasLength(2)); + expect(controller.markdown, contains(r'Before $y$ after.')); + expect(controller.markdown, contains('A second paragraph.')); + }); + + testWidgets( + 'WYSIWYG renders math, edits exact source, and reparses without corruption', + (tester) async { + const original = + r'Text before $x^2$ and text after.' + '\n'; + final document = const MarkdownParser() + .parse(filePath: 'math.md', source: original) + .busyDocument; + var markdown = original; + + await tester.pumpWidget( + ProviderScope( + overrides: [ + webRenderHostProvider.overrideWithValue(_WysiwygMathHost()), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SizedBox( + width: 900, + height: 600, + child: BusyMarkWysiwygEditor( + document: document, + onSourceChanged: (_, source) => markdown = source, + ), + ), + ), + ), + ), + ); + await _pumpMath(tester); + + expect(find.byType(SvgPicture), findsOneWidget); + expect(find.byType(TextField), findsNothing); + + await tester.tap( + find.byKey( + ValueKey('wysiwyg-rendered-math-${document.blocks.single.id}'), + ), + ); + await tester.pump(); + final sourceField = tester.widget(find.byType(TextField)); + expect(sourceField.controller?.text, original.trimRight()); + + const edited = r'Changed before $\frac{a}{b}$ and after.'; + await tester.enterText(find.byType(TextField), edited); + await tester.pump(); + expect(markdown, '$edited\n'); + + await _sendUndo(tester); + expect(markdown, original); + await _sendRedo(tester); + expect(markdown, '$edited\n'); + + tester.widget(find.byType(TextField)).focusNode?.unfocus(); + await _pumpMath(tester); + expect(find.byType(SvgPicture), findsOneWidget); + expect(find.textContaining(r'\frac{a}{b}'), findsNothing); + + await tester.tap( + find.byKey( + ValueKey('wysiwyg-rendered-math-${document.blocks.single.id}'), + ), + ); + await tester.pump(); + expect( + tester.widget(find.byType(TextField)).controller?.text, + edited, + ); + }, + ); + + testWidgets('WYSIWYG toolbar inserts inline and display math source', ( + tester, + ) async { + final document = const MarkdownParser() + .parse(filePath: 'math.md', source: 'Velocity\n') + .busyDocument; + var markdown = document.source; + + await tester.pumpWidget( + ProviderScope( + overrides: [ + webRenderHostProvider.overrideWithValue(_WysiwygMathHost()), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SizedBox( + width: 1100, + height: 600, + child: BusyMarkWysiwygEditor( + document: document, + onSourceChanged: (_, source) => markdown = source, + ), + ), + ), + ), + ), + ); + await tester.pump(); + final controller = tester + .widget(find.byType(TextField)) + .controller!; + controller.selection = const TextSelection(baseOffset: 0, extentOffset: 8); + + await tester.ensureVisible(find.byTooltip('Inline math')); + await tester.tap(find.byTooltip('Inline math')); + await tester.pump(); + expect( + markdown, + r'$Velocity$' + '\n', + ); + + await tester.ensureVisible(find.byTooltip('Display math')); + await tester.tap(find.byTooltip('Display math')); + await tester.pump(); + expect(markdown, contains('\$\$\nx\n\$\$')); + }); +} + +Future _sendUndo(WidgetTester tester) async { + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); +} + +Future _sendRedo(WidgetTester tester) async { + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); +} + +Future _pumpMath(WidgetTester tester) async { + for (var attempt = 0; attempt < 20; attempt++) { + await tester.pump(const Duration(milliseconds: 10)); + if (find.byType(SvgPicture).evaluate().isNotEmpty) return; + } +} + +class _WysiwygMathHost implements WebRenderHost { + @override + Future> renderMathBatch({ + required List> expressions, + required VisualizationCancellationToken cancellationToken, + }) async { + cancellationToken.throwIfCancelled(); + return { + 'results': [ + for (final item in expressions) + { + 'id': item['id'], + 'svg': ''' + + + ''', + 'width': 20, + 'height': 14, + 'depth': 2, + }, + ], + }; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/tools/fetch_visualization_web.sh b/tools/fetch_visualization_web.sh index 7062e3d0..3240c4c4 100755 --- a/tools/fetch_visualization_web.sh +++ b/tools/fetch_visualization_web.sh @@ -3,7 +3,7 @@ set -euo pipefail MERMAID_VERSION="11.16.1" -MERMAID_SHA256="ebd9885111092c78cefc79a76f6c1dc34ed5b834b02ae8f338227ce79c003de4" +MERMAID_SOURCE_SHA256="0ee99b3bb82766e5d6c34b8cc768b8530ce8f1aaa13790ae368aebeef3de9d11" PLANTUML_VERSION="1.2026.6" PLANTUML_SHA256="798f99592eb03a6446519d2becf78e6f1008d0d25c75d60b37a0f46e39e3c413" SCALAR_PARSER_VERSION="0.28.14" @@ -14,6 +14,10 @@ SCALAR_JSON_MAGIC_VERSION="0.13.0" SCALAR_JSON_MAGIC_SHA256="f1adefc461f3594afd4ad16974820a5a88b271f7e8051045c2ac7a34eb974d33" YAML_VERSION="2.9.0" YAML_SHA256="008fa204cb1ba700e0272ba045abbf09a6ffe63456e8146ba97cac6c2ad1ef91" +MATHJAX_VERSION="4.1.3" +MATHJAX_SHA256="4611bed26b338dfc4b5757b8ed2d7ba82a85bcbd05d2729fb3465fba17b8896c" +MATHJAX_NEWCM_VERSION="4.1.3" +MATHJAX_NEWCM_SHA256="87d7b869c6a2a6169d9a53acc4eab6c846a9cbe11752738226461bb5070c8b88" ESBUILD_VERSION="0.28.2" SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" @@ -22,16 +26,20 @@ SOURCE_DIR="${SCRIPT_DIR}/visualization" OUTPUT_DIR="${1:-${PROJECT_DIR}/build/visualization/web}" SOURCE_FINGERPRINT="$({ sha256sum \ + "${BASH_SOURCE[0]}" \ "${SOURCE_DIR}/package.json" \ "${SOURCE_DIR}/package-lock.json" \ "${SOURCE_DIR}/render_engines.js" \ + "${SOURCE_DIR}/mathjax_renderer.js" \ + "${SOURCE_DIR}/mermaid_math_disabled.js" \ + "${SOURCE_DIR}/build_render_engines.js" \ "${SOURCE_DIR}/reference.js" \ "${SOURCE_DIR}/bootstrap.js" \ "${SOURCE_DIR}/harness.html" \ "${SOURCE_DIR}/reference.html" \ "${SOURCE_DIR}/generate_notices.js" } | sha256sum | cut -d ' ' -f 1)" -VERSION_FINGERPRINT="mermaid=${MERMAID_VERSION};plantuml=${PLANTUML_VERSION};scalar-parser=${SCALAR_PARSER_VERSION};scalar-reference=${SCALAR_REFERENCE_VERSION};scalar-json-magic=${SCALAR_JSON_MAGIC_VERSION};yaml=${YAML_VERSION};esbuild=${ESBUILD_VERSION};sources=${SOURCE_FINGERPRINT}" +VERSION_FINGERPRINT="mermaid=${MERMAID_VERSION};plantuml=${PLANTUML_VERSION};scalar-parser=${SCALAR_PARSER_VERSION};scalar-reference=${SCALAR_REFERENCE_VERSION};scalar-json-magic=${SCALAR_JSON_MAGIC_VERSION};yaml=${YAML_VERSION};mathjax=${MATHJAX_VERSION};mathjax-newcm=${MATHJAX_NEWCM_VERSION};esbuild=${ESBUILD_VERSION};sources=${SOURCE_FINGERPRINT}" if [[ -f "${OUTPUT_DIR}/VERSION" ]] && [[ "$(<"${OUTPUT_DIR}/VERSION")" == "${VERSION_FINGERPRINT}" ]] && @@ -61,6 +69,8 @@ TOOL_DIR="${TEMP_DIR}/tool" mkdir -p -- "${TOOL_DIR}" cp -- "${SOURCE_DIR}/package.json" "${SOURCE_DIR}/package-lock.json" "${TOOL_DIR}/" cp -- "${SOURCE_DIR}/render_engines.js" "${TOOL_DIR}/" +cp -- "${SOURCE_DIR}/mathjax_renderer.js" "${TOOL_DIR}/" +cp -- "${SOURCE_DIR}/build_render_engines.js" "${TOOL_DIR}/" npm ci \ --prefix "${TOOL_DIR}" \ --ignore-scripts \ @@ -83,9 +93,9 @@ verify_package() { } verify_package \ - mermaid \ - "https://registry.npmjs.org/mermaid/-/mermaid-${MERMAID_VERSION}.tgz" \ - "${MERMAID_SHA256}" + mermaid-source \ + "https://github.com/mermaid-js/mermaid/archive/refs/tags/mermaid@${MERMAID_VERSION}.tar.gz" \ + "${MERMAID_SOURCE_SHA256}" verify_package \ plantuml \ "https://registry.npmjs.org/@plantuml/core/-/core-${PLANTUML_VERSION}.tgz" \ @@ -106,16 +116,25 @@ verify_package \ yaml \ "https://registry.npmjs.org/yaml/-/yaml-${YAML_VERSION}.tgz" \ "${YAML_SHA256}" +verify_package \ + mathjax \ + "https://registry.npmjs.org/@mathjax/src/-/src-${MATHJAX_VERSION}.tgz" \ + "${MATHJAX_SHA256}" +verify_package \ + mathjax-newcm \ + "https://registry.npmjs.org/@mathjax/mathjax-newcm-font/-/mathjax-newcm-font-${MATHJAX_NEWCM_VERSION}.tgz" \ + "${MATHJAX_NEWCM_SHA256}" BUILD_DIR="${TEMP_DIR}/output" mkdir -p -- "${BUILD_DIR}" -node "${TOOL_DIR}/node_modules/esbuild/bin/esbuild" \ +cp -R -- \ + "${TEMP_DIR}/mermaid-source/mermaid-mermaid-${MERMAID_VERSION}/packages/mermaid" \ + "${TOOL_DIR}/mermaid-source" +node "${TOOL_DIR}/build_render_engines.js" \ "${TOOL_DIR}/render_engines.js" \ - --bundle \ - --format=esm \ - --platform=browser \ - --target=safari16 \ - --outfile="${BUILD_DIR}/render-engines.js" + "${BUILD_DIR}/render-engines.js" \ + "${TOOL_DIR}/mermaid-source" \ + "${SOURCE_DIR}/mermaid_math_disabled.js" install -m 0644 "${SOURCE_DIR}/harness.html" "${BUILD_DIR}/harness.html" install -m 0644 "${SOURCE_DIR}/reference.html" "${BUILD_DIR}/reference.html" @@ -132,7 +151,11 @@ printf '%s\n' "${VERSION_FINGERPRINT}" > "${BUILD_DIR}/VERSION" LICENSE_DIR="${BUILD_DIR}/licenses" node "${SOURCE_DIR}/generate_notices.js" \ "${TOOL_DIR}/node_modules" \ - "${LICENSE_DIR}/npm" + "${LICENSE_DIR}/npm" \ + "${TEMP_DIR}/mermaid-source/mermaid-mermaid-${MERMAID_VERSION}/packages/mermaid" +install -D -m 0644 \ + "${TEMP_DIR}/mermaid-source/mermaid-mermaid-${MERMAID_VERSION}/LICENSE" \ + "${LICENSE_DIR}/mermaid/LICENSE" # @scalar/api-reference and @scalar/openapi-parser are released from the same # Scalar repository. The reference package omits LICENSE from its npm files; # preserve the repository's distributed MIT text from the parser package. @@ -144,5 +167,8 @@ install -D -m 0644 \ "${LICENSE_DIR}/package-lock.json" mkdir -p -- "${OUTPUT_DIR}" +if [[ -d "${OUTPUT_DIR}/licenses" ]]; then + mv -- "${OUTPUT_DIR}/licenses" "${TEMP_DIR}/previous-licenses" +fi cp -R -- "${BUILD_DIR}/." "${OUTPUT_DIR}/" echo "Prepared offline visualization web assets in ${OUTPUT_DIR}" diff --git a/tools/visualization/build_render_engines.js b/tools/visualization/build_render_engines.js new file mode 100644 index 00000000..94e4196f --- /dev/null +++ b/tools/visualization/build_render_engines.js @@ -0,0 +1,144 @@ +import assert from 'node:assert' +import { readFile } from 'node:fs/promises' +import path from 'node:path' + +import Ajv2019 from 'ajv/dist/2019.js' +import { build } from 'esbuild' +import jison from 'jison' +import { JSON_SCHEMA, load } from 'js-yaml' + +const [entryPoint, outfile, mermaidSource, disabledMathModule] = process.argv.slice(2) +if (!entryPoint || !outfile || !mermaidSource || !disabledMathModule) { + throw new Error( + 'Usage: build_render_engines.js ENTRY OUTFILE MERMAID_SOURCE DISABLED_MATH_MODULE', + ) +} + +const diagramConfigKeys = [ + 'flowchart', + 'swimlane', + 'sequence', + 'gantt', + 'journey', + 'class', + 'state', + 'er', + 'pie', + 'quadrantChart', + 'xyChart', + 'requirement', + 'mindmap', + 'ishikawa', + 'kanban', + 'timeline', + 'gitGraph', + 'c4', + 'sankey', + 'block', + 'packet', + 'treeView', + 'architecture', + 'eventmodeling', + 'radar', + 'venn', + 'cynefin', +] + +function generateDefaults(schema) { + const ajv = new Ajv2019({ + useDefaults: true, + allowUnionTypes: true, + strict: true, + }) + ajv.addKeyword({ keyword: 'meta:enum', errors: false }) + ajv.addKeyword({ keyword: 'tsType', errors: false }) + + assert.ok(schema.$defs) + const baseDiagramConfig = schema.$defs.BaseDiagramConfig + const defaults = {} + for (const key of diagramConfigKeys) { + const reference = schema.properties[key].$ref + const [root, definitions, definitionName] = reference.split('/') + assert.strictEqual(root, '#') + assert.strictEqual(definitions, '$defs') + const subSchema = { + $schema: schema.$schema, + $defs: schema.$defs, + ...schema.$defs[definitionName], + } + const validate = ajv.compile(subSchema) + defaults[key] = {} + for (const required of subSchema.required ?? []) { + if ( + subSchema.properties[required] === undefined && + baseDiagramConfig.properties[required] + ) { + defaults[key][required] = baseDiagramConfig.properties[required].default + } + } + if (!validate(defaults[key])) { + throw new Error(`Invalid Mermaid defaults for ${key}: ${JSON.stringify(validate.errors)}`) + } + } + + const validate = ajv.compile(schema) + if (!validate(defaults)) { + throw new Error(`Invalid Mermaid defaults: ${JSON.stringify(validate.errors)}`) + } + return defaults +} + +const jisonPlugin = { + name: 'jison', + setup(buildContext) { + buildContext.onLoad({ filter: /\.jison$/ }, async ({ path: filename }) => { + const source = await readFile(filename, 'utf8') + const parser = new jison.Generator(source, { + moduleType: 'js', + 'token-stack': true, + }) + const generated = parser.generate({ moduleMain: '() => {}' }) + return { + contents: `${generated}\nparser.parser = parser;\nexport { parser };\nexport default parser;`, + loader: 'js', + } + }) + }, +} + +const schemaPlugin = { + name: 'mermaid-config-schema', + setup(buildContext) { + buildContext.onLoad({ filter: /config\.schema\.yaml$/ }, async (args) => { + const source = await readFile(args.path, 'utf8') + const schema = load(source, { filename: args.path, schema: JSON_SCHEMA }) + const value = args.suffix.includes('only-defaults') ? generateDefaults(schema) : schema + return { contents: `export default ${JSON.stringify(value)};`, loader: 'js' } + }) + }, +} + +const mermaidManifest = JSON.parse( + await readFile(path.join(mermaidSource, 'package.json'), 'utf8'), +) + +await build({ + entryPoints: [entryPoint], + outfile, + bundle: true, + format: 'esm', + platform: 'browser', + target: 'safari16', + minify: true, + resolveExtensions: ['.ts', '.js', '.json', '.jison', '.yaml'], + alias: { + mermaid: path.join(mermaidSource, 'src', 'mermaid.ts'), + katex: disabledMathModule, + }, + define: { + 'injected.includeLargeFeatures': 'true', + 'injected.version': JSON.stringify(String(mermaidManifest.version)), + 'import.meta.vitest': 'undefined', + }, + plugins: [jisonPlugin, schemaPlugin], +}) diff --git a/tools/visualization/generate_notices.js b/tools/visualization/generate_notices.js index ce2b76b4..e6566660 100644 --- a/tools/visualization/generate_notices.js +++ b/tools/visualization/generate_notices.js @@ -1,7 +1,7 @@ import fs from 'node:fs' import path from 'node:path' -const [nodeModulesRoot, outputRoot] = process.argv.slice(2) +const [nodeModulesRoot, outputRoot, ...additionalPackageRoots] = process.argv.slice(2) if (!nodeModulesRoot || !outputRoot) { throw new Error('Usage: generate_notices.js NODE_MODULES OUTPUT') } @@ -16,37 +16,44 @@ function visit(directory) { visit(child) continue } - const manifestPath = path.join(child, 'package.json') - if (!fs.existsSync(manifestPath)) continue - const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) - const name = String(manifest.name ?? entry.name) - const version = String(manifest.version ?? '') - const license = typeof manifest.license === 'string' ? manifest.license : 'SEE PACKAGE' - const homepage = typeof manifest.homepage === 'string' - ? manifest.homepage - : typeof manifest.repository?.url === 'string' - ? manifest.repository.url - : '' - const destination = path.join(outputRoot, name.replaceAll('/', '__')) - fs.mkdirSync(destination, { recursive: true }) - const licenseFiles = fs.readdirSync(child).filter((filename) => - /^(?:licen[cs]e|copying|notice)(?:\..*)?$/i.test(filename), - ) - for (const filename of licenseFiles) { - fs.copyFileSync(path.join(child, filename), path.join(destination, filename)) - } - fs.writeFileSync( - path.join(destination, 'PACKAGE'), - `${name}\n${version}\n${license}\n${homepage}\n`, - ) - packages.push({ name, version, license, homepage }) - const nested = path.join(child, 'node_modules') - if (fs.existsSync(nested)) visit(nested) + collectPackage(child, entry.name) } } +function collectPackage(child, fallbackName) { + const manifestPath = path.join(child, 'package.json') + if (!fs.existsSync(manifestPath)) return + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) + const name = String(manifest.name ?? fallbackName) + const version = String(manifest.version ?? '') + const license = typeof manifest.license === 'string' ? manifest.license : 'SEE PACKAGE' + const homepage = typeof manifest.homepage === 'string' + ? manifest.homepage + : typeof manifest.repository?.url === 'string' + ? manifest.repository.url + : '' + const destination = path.join(outputRoot, name.replaceAll('/', '__')) + fs.mkdirSync(destination, { recursive: true }) + const licenseFiles = fs.readdirSync(child).filter((filename) => + /^(?:licen[cs]e|copying|notice)(?:\..*)?$/i.test(filename), + ) + for (const filename of licenseFiles) { + fs.copyFileSync(path.join(child, filename), path.join(destination, filename)) + } + fs.writeFileSync( + path.join(destination, 'PACKAGE'), + `${name}\n${version}\n${license}\n${homepage}\n`, + ) + packages.push({ name, version, license, homepage }) + const nested = path.join(child, 'node_modules') + if (fs.existsSync(nested)) visit(nested) +} + fs.mkdirSync(outputRoot, { recursive: true }) visit(nodeModulesRoot) +for (const packageRoot of additionalPackageRoots) { + collectPackage(packageRoot, path.basename(packageRoot)) +} packages.sort((left, right) => left.name.localeCompare(right.name) || left.version.localeCompare(right.version)) fs.writeFileSync( path.join(outputRoot, 'THIRD_PARTY_NOTICES.md'), diff --git a/tools/visualization/mathjax_renderer.js b/tools/visualization/mathjax_renderer.js new file mode 100644 index 00000000..40935081 --- /dev/null +++ b/tools/visualization/mathjax_renderer.js @@ -0,0 +1,297 @@ +import { mathjax } from '@mathjax/src/js/mathjax.js' +import { browserAdaptor } from '@mathjax/src/js/adaptors/browserAdaptor.js' +import { RegisterHTMLHandler } from '@mathjax/src/js/handlers/html.js' +import { TeX } from '@mathjax/src/js/input/tex.js' +import { MapHandler } from '@mathjax/src/js/input/tex/MapHandler.js' +import { NewcommandTables } from '@mathjax/src/js/input/tex/newcommand/NewcommandUtil.js' +import { SVG } from '@mathjax/src/js/output/svg.js' +import { SafeHandler } from '@mathjax/src/js/ui/safe/SafeHandler.js' +import { MathJaxNewcmFont } from '@mathjax/mathjax-newcm-font/js/svg.js' + +import '@mathjax/src/js/input/tex/ams/AmsConfiguration.js' +import '@mathjax/src/js/input/tex/newcommand/NewcommandConfiguration.js' +import '@mathjax/src/js/input/tex/mathtools/MathtoolsConfiguration.js' +import '@mathjax/src/js/input/tex/mhchem/MhchemConfiguration.js' +import '@mathjax/src/js/input/tex/boldsymbol/BoldsymbolConfiguration.js' +import '@mathjax/src/js/input/tex/braket/BraketConfiguration.js' +import '@mathjax/src/js/input/tex/cancel/CancelConfiguration.js' +import '@mathjax/src/js/input/tex/cases/CasesConfiguration.js' +import '@mathjax/src/js/input/tex/empheq/EmpheqConfiguration.js' +import '@mathjax/src/js/input/tex/gensymb/GensymbConfiguration.js' +import '@mathjax/src/js/input/tex/units/UnitsConfiguration.js' +import '@mathjax/src/js/input/tex/upgreek/UpgreekConfiguration.js' + +// NewCM's dynamic SVG tables are all statically included in BusyMark's one +// deterministic bundle. MathJax still uses its asynchronous retry path, but +// the loader below only activates already-bundled table registrations. +import '@mathjax/mathjax-newcm-font/js/svg/dynamic/PUA.js' +import '@mathjax/mathjax-newcm-font/js/svg/dynamic/accents-b-i.js' +import '@mathjax/mathjax-newcm-font/js/svg/dynamic/accents.js' +import '@mathjax/mathjax-newcm-font/js/svg/dynamic/arabic.js' +import '@mathjax/mathjax-newcm-font/js/svg/dynamic/arrows.js' +import '@mathjax/mathjax-newcm-font/js/svg/dynamic/braille-d.js' +import '@mathjax/mathjax-newcm-font/js/svg/dynamic/braille.js' +import '@mathjax/mathjax-newcm-font/js/svg/dynamic/calligraphic.js' +import '@mathjax/mathjax-newcm-font/js/svg/dynamic/cherokee.js' +import '@mathjax/mathjax-newcm-font/js/svg/dynamic/cyrillic-ss.js' +import '@mathjax/mathjax-newcm-font/js/svg/dynamic/cyrillic.js' +import '@mathjax/mathjax-newcm-font/js/svg/dynamic/devanagari.js' +import '@mathjax/mathjax-newcm-font/js/svg/dynamic/double-struck.js' +import '@mathjax/mathjax-newcm-font/js/svg/dynamic/fraktur.js' +import '@mathjax/mathjax-newcm-font/js/svg/dynamic/greek-ss.js' +import '@mathjax/mathjax-newcm-font/js/svg/dynamic/greek.js' +import '@mathjax/mathjax-newcm-font/js/svg/dynamic/hebrew.js' +import '@mathjax/mathjax-newcm-font/js/svg/dynamic/latin-b.js' +import '@mathjax/mathjax-newcm-font/js/svg/dynamic/latin-bi.js' +import '@mathjax/mathjax-newcm-font/js/svg/dynamic/latin-i.js' +import '@mathjax/mathjax-newcm-font/js/svg/dynamic/latin.js' +import '@mathjax/mathjax-newcm-font/js/svg/dynamic/marrows.js' +import '@mathjax/mathjax-newcm-font/js/svg/dynamic/math.js' +import '@mathjax/mathjax-newcm-font/js/svg/dynamic/monospace-ex.js' +import '@mathjax/mathjax-newcm-font/js/svg/dynamic/monospace-l.js' +import '@mathjax/mathjax-newcm-font/js/svg/dynamic/monospace.js' +import '@mathjax/mathjax-newcm-font/js/svg/dynamic/mshapes.js' +import '@mathjax/mathjax-newcm-font/js/svg/dynamic/phonetics-ss.js' +import '@mathjax/mathjax-newcm-font/js/svg/dynamic/phonetics.js' +import '@mathjax/mathjax-newcm-font/js/svg/dynamic/sans-serif-b.js' +import '@mathjax/mathjax-newcm-font/js/svg/dynamic/sans-serif-bi.js' +import '@mathjax/mathjax-newcm-font/js/svg/dynamic/sans-serif-ex.js' +import '@mathjax/mathjax-newcm-font/js/svg/dynamic/sans-serif-i.js' +import '@mathjax/mathjax-newcm-font/js/svg/dynamic/sans-serif-r.js' +import '@mathjax/mathjax-newcm-font/js/svg/dynamic/sans-serif.js' +import '@mathjax/mathjax-newcm-font/js/svg/dynamic/script.js' +import '@mathjax/mathjax-newcm-font/js/svg/dynamic/shapes.js' +import '@mathjax/mathjax-newcm-font/js/svg/dynamic/symbols-b-i.js' +import '@mathjax/mathjax-newcm-font/js/svg/dynamic/symbols.js' +import '@mathjax/mathjax-newcm-font/js/svg/dynamic/variants.js' + +export const mathJaxVersion = '4.1.3' +export const mathJaxFontVersion = '4.1.3' +export const mathPackageProfileVersion = 'busymark-math-v1' + +export const mathPackages = Object.freeze([ + 'base', + 'ams', + 'newcommand', + 'mathtools', + 'mhchem', + 'boldsymbol', + 'braket', + 'cancel', + 'cases', + 'empheq', + 'gensymb', + 'units', + 'upgreek', +]) + +const maximumExpressionCharacters = 16 * 1024 +const maximumBatchExpressions = 128 +const maximumBatchCharacters = 256 * 1024 +const maximumSvgBytes = 2 * 1024 * 1024 + +const adaptor = browserAdaptor() +const handler = RegisterHTMLHandler(adaptor) +SafeHandler(handler) + +// Every dynamic font module above has already registered its setup callback. +// No URL or filesystem resolution is permitted from TeX input. +mathjax.asyncLoad = () => Promise.resolve() + +const tex = new TeX({ + packages: mathPackages, + maxBuffer: 20 * 1024, + maxMacros: 500, + maxTemplateSubtitutions: 2000, + formatError: (_jax, error) => { throw error }, +}) + +const svg = new SVG({ + fontData: MathJaxNewcmFont, + fontCache: 'local', + localID: 'busymark-math', + useXlink: false, + displayOverflow: 'overflow', + linebreaks: { + inline: false, + width: '100%', + lineleading: 0.2, + }, +}) + +const mathDocument = mathjax.document('', { + InputJax: tex, + OutputJax: svg, + safeOptions: { + allow: { + URLs: 'none', + classes: 'none', + cssIDs: 'none', + styles: 'none', + }, + safeProtocols: { + http: false, + https: false, + file: false, + javascript: false, + data: false, + }, + }, +}) + +const mathReady = svg.font.loadDynamicFiles() + +function clearExpressionDefinitions() { + // The newcommand package intentionally stores definitions in three mutable + // maps. BusyMark supports declarations within an expression, but formulas + // are independent document atoms: one expression must never provide macros + // or environments to a later expression. These maps are part of the exact, + // pinned MathJax source API bundled above. + for (const name of [ + NewcommandTables.NEW_DELIMITER, + NewcommandTables.NEW_COMMAND, + NewcommandTables.NEW_ENVIRONMENT, + ]) { + const map = MapHandler.getMap(name) + map?.map?.clear() + } +} + +function finiteMetric(value, fallback, minimum, maximum) { + const number = Number(value) + return Number.isFinite(number) + ? Math.min(maximum, Math.max(minimum, number)) + : fallback +} + +function parseExLength(value, ex) { + const match = String(value ?? '').trim().match(/^(-?(?:\d+(?:\.\d*)?|\.\d+))(ex|em|px)?$/) + if (!match) return 0 + const number = Number.parseFloat(match[1]) + if (!Number.isFinite(number)) return 0 + if (match[2] === 'em') return number * ex * 2 + if (match[2] === 'px' || !match[2]) return number + return number * ex +} + +function mathError(error) { + const detail = String(error?.message ?? error ?? 'Math rendering failed.').slice(0, 1000) + const lowered = detail.toLowerCase() + const resourceLimit = lowered.includes('maximum') + || lowered.includes('maxbuffer') + || lowered.includes('substitution') + || lowered.includes('recursion') + || lowered.includes('stack') + return { + code: resourceLimit ? 'math.resourceLimit' : 'math.invalidTex', + message: resourceLimit + ? 'The expression exceeded BusyMark’s math processing limits.' + : 'The expression contains unsupported or invalid TeX.', + detail, + } +} + +async function renderExpression(item) { + const id = String(item?.id ?? '') + const expression = String(item?.expression ?? '') + if (!id || id.length > 128 || !/^[A-Za-z0-9_.:-]+$/.test(id)) { + return { id, error: { code: 'math.invalidRequest', message: 'The expression identifier is invalid.' } } + } + if (!expression || expression.length > maximumExpressionCharacters) { + return { + id, + error: { + code: expression ? 'math.resourceLimit' : 'math.invalidTex', + message: expression + ? 'The expression exceeds BusyMark’s size limit.' + : 'The expression is empty.', + }, + } + } + + const em = finiteMetric(item.em, 16, 4, 256) + const ex = finiteMetric(item.ex, em / 2, 2, 128) + const containerWidth = finiteMetric(item.containerWidth, 800, 32, 10000) + const localID = String(item.svgIdPrefix ?? `busymark-math-${id}`) + .replace(/[^A-Za-z0-9_.:-]/g, '-') + .slice(0, 128) + svg.options.localID = localID + try { + // convertPromise is the direct-document equivalent of tex2svgPromise and + // handles MathJax's asynchronous NewCM retry protocol. + const container = await mathDocument.convertPromise(expression, { + display: item.display === true, + em, + ex, + containerWidth, + }) + const roots = adaptor.tags(container, 'svg') + const root = roots[0] + if (!root) throw new Error('MathJax did not return SVG output.') + const width = parseExLength(adaptor.getAttribute(root, 'width'), ex) + const height = parseExLength(adaptor.getAttribute(root, 'height'), ex) + const verticalAlign = adaptor.getStyle(root, 'vertical-align') + const depth = Math.max(0, -parseExLength(verticalAlign, ex)) + adaptor.setStyle(root, 'vertical-align', '') + if (!adaptor.allStyles(root).trim()) adaptor.removeAttribute(root, 'style') + const standaloneSvg = adaptor.outerHTML(root) + if (new TextEncoder().encode(standaloneSvg).length > maximumSvgBytes) { + return { + id, + error: { code: 'math.resourceLimit', message: 'The rendered expression exceeds BusyMark’s output limit.' }, + } + } + return { + id, + svg: standaloneSvg, + width, + height, + depth, + baseline: Math.max(0, height - depth), + } + } catch (error) { + return { id, error: mathError(error) } + } finally { + // TeX.reset clears accumulated equation counters, labels, IDs, and parse + // state. Each Markdown math node is an independent render unit. + tex.reset(0) + clearExpressionDefinitions() + } +} + +export async function renderMathBatch(request) { + const expressions = Array.isArray(request?.expressions) ? request.expressions : [] + if (expressions.length === 0 || expressions.length > maximumBatchExpressions) { + return { + code: 'math.resourceLimit', + message: 'The math batch is empty or exceeds BusyMark’s expression limit.', + results: [], + } + } + const aggregate = expressions.reduce( + (total, item) => total + String(item?.expression ?? '').length, + 0, + ) + if (aggregate > maximumBatchCharacters) { + return { + code: 'math.resourceLimit', + message: 'The math batch exceeds BusyMark’s aggregate input limit.', + results: expressions.map((item) => ({ + id: String(item?.id ?? ''), + error: { code: 'math.resourceLimit', message: 'The math batch is too large.' }, + })), + } + } + + await mathReady + const results = [] + for (const expression of expressions) { + results.push(await renderExpression(expression)) + } + return { + mathJaxVersion, + fontVersion: mathJaxFontVersion, + packageProfileVersion: mathPackageProfileVersion, + results, + } +} diff --git a/tools/visualization/mermaid_math_disabled.js b/tools/visualization/mermaid_math_disabled.js new file mode 100644 index 00000000..592fcffe --- /dev/null +++ b/tools/visualization/mermaid_math_disabled.js @@ -0,0 +1,9 @@ +// BusyMark owns mathematical document rendering through its semantic MathJax +// path. Mermaid's optional dollar-label renderer is deliberately unavailable, +// which prevents Mermaid's transitive alternate math engine from entering the +// offline runtime bundle. +export default Object.freeze({ + renderToString() { + throw new Error('Math labels inside Mermaid diagrams are not supported.') + }, +}) diff --git a/tools/visualization/package-lock.json b/tools/visualization/package-lock.json index a29e8d14..2ab96888 100644 --- a/tools/visualization/package-lock.json +++ b/tools/visualization/package-lock.json @@ -7,19 +7,43 @@ "": { "name": "busymark-visualization-build", "version": "1.0.0", - "engines": { - "node": ">=22" - }, "dependencies": { + "@braintree/sanitize-url": "7.1.2", + "@iconify/utils": "3.1.4", + "@mathjax/mathjax-newcm-font": "4.1.3", + "@mathjax/src": "4.1.3", + "@mermaid-js/parser": "1.2.0", "@plantuml/core": "1.2026.6", "@scalar/api-reference": "1.65.1", "@scalar/json-magic": "0.13.0", "@scalar/openapi-parser": "0.28.14", - "mermaid": "11.16.1", + "@types/d3": "7.4.3", + "@upsetjs/venn.js": "2.0.0", + "ajv": "8.20.0", + "cytoscape": "3.34.1", + "cytoscape-cose-bilkent": "4.1.0", + "cytoscape-fcose": "2.2.0", + "d3": "7.9.0", + "d3-sankey": "0.12.3", + "dagre-d3-es": "7.0.14", + "dayjs": "1.11.23", + "dompurify": "3.4.13", + "es-toolkit": "1.51.0", + "jison": "0.4.18", + "js-yaml": "4.1.1", + "khroma": "2.1.0", + "marked": "16.4.2", + "roughjs": "4.6.6", + "stylis": "4.4.0", + "ts-dedent": "2.3.0", + "uuid": "14.0.1", "yaml": "2.9.0" }, "devDependencies": { "esbuild": "0.28.2" + }, + "engines": { + "node": ">=22" } }, "node_modules/@ai-sdk/gateway": { @@ -990,6 +1014,24 @@ "integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==", "license": "MIT" }, + "node_modules/@mathjax/mathjax-newcm-font": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@mathjax/mathjax-newcm-font/-/mathjax-newcm-font-4.1.3.tgz", + "integrity": "sha512-gzAB3dFHilHX1l5x2xUqRL+1jDQt3Fyza1DkEMVXWC4E8SvsGdlgEza47HYi2WhVcgfkvf4zgUGzuhbq3Pjlew==", + "license": "Apache-2.0" + }, + "node_modules/@mathjax/src": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@mathjax/src/-/src-4.1.3.tgz", + "integrity": "sha512-rIrWquuBSoJuoMBdC/1qD+AUHTorlccPicoVy6P2xbUgnuDBpCcpbHtOAsB8L3hdCHtNBg92lF8e3Fz+pkcQbw==", + "license": "Apache-2.0", + "dependencies": { + "@mathjax/mathjax-newcm-font": "4.1.3", + "mhchemparser": "^4.2.1", + "mj-context-menu": "^1.0.0", + "speech-rule-engine": "5.0.0-rc.4" + } + }, "node_modules/@mermaid-js/parser": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.0.tgz", @@ -2110,6 +2152,15 @@ "vue": "^3.5.0" } }, + "node_modules/@xmldom/xmldom": { + "version": "0.9.11", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.11.tgz", + "integrity": "sha512-tW8bcK3hsG0/uqSnNz6TK4BkcuZSezoU7DlnYssILmZDktPnSHHuDJJFM0AJv+13gz2r0iGdrj6qqKeUnxXEDg==", + "license": "MIT", + "engines": { + "node": ">=14.6" + } + }, "node_modules/ai": { "version": "6.0.33", "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.33.tgz", @@ -2175,6 +2226,22 @@ } } }, + "node_modules/amdefine": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz", + "integrity": "sha512-S2Hw0TtNkMJhIabBwIojKL9YHO5T0n5eNqWJ7Lrlel/zDbftQpxpapi8tZs3X1HWa+u+QeydGmzzNU0m09+Rcg==", + "license": "BSD-3-Clause OR MIT", + "optional": true, + "engines": { + "node": ">=0.4.2" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, "node_modules/aria-hidden": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", @@ -2197,6 +2264,27 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -2249,6 +2337,17 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/cjson": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/cjson/-/cjson-0.3.0.tgz", + "integrity": "sha512-bBRQcCIHzI1IVH59fR0bwGrFmi3Btb/JNwM/n401i1DnYgWndpsUBiQRAddLflkZage20A2d25OAWZZk0vBRlA==", + "dependencies": { + "jsonlint": "1.6.0" + }, + "engines": { + "node": ">= 0.3.0" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -2258,6 +2357,14 @@ "node": ">=6" } }, + "node_modules/colors": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/colors/-/colors-0.5.1.tgz", + "integrity": "sha512-XjsuUwpDeY98+yz959OlUK6m7mLBM+1MEG5oaenfuQnNnrQk1WvtcvFgN3FNDP3f2NmZ211t0mNEfSEN1h0eIg==", + "engines": { + "node": ">=0.1.90" + } + }, "node_modules/comma-separated-tokens": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -2911,6 +3018,12 @@ "@types/trusted-types": "^2.0.7" } }, + "node_modules/ebnf-parser": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/ebnf-parser/-/ebnf-parser-0.1.10.tgz", + "integrity": "sha512-urvSxVQ6XJcoTpc+/x2pWhhuOX4aljCNQpwzw+ifZvV1andZkAmiJc3Rq1oGEAQmcjiLceyMXOy1l8ms8qs2fQ==", + "license": "MIT" + }, "node_modules/entities": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", @@ -2989,12 +3102,60 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/escodegen": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.3.3.tgz", + "integrity": "sha512-z9FWgKc48wjMlpzF5ymKS1AF8OIgnKLp9VyN7KbdtyrP/9lndwUFqCtMm+TAJmJf7KJFFYc4cFJfVTTGkKEwsA==", + "dependencies": { + "esprima": "~1.1.1", + "estraverse": "~1.5.0", + "esutils": "~1.0.0" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=0.10.0" + }, + "optionalDependencies": { + "source-map": "~0.1.33" + } + }, + "node_modules/esprima": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-1.1.1.tgz", + "integrity": "sha512-qxxB994/7NtERxgXdFgLHIs9M6bhLXc6qtUmWZ3L8+gTQ9qaoyki2887P2IqAYsoENyr8SUbTutStDniOHSDHg==", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/estraverse": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-1.5.1.tgz", + "integrity": "sha512-FpCjJDfmo3vsc/1zKSeqR5k42tcIhxFIlvq+h9j0fO2q/h2uLKyweq7rYJ+0CoVvrGQOxIS5wyBrW/+vF58BUQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "license": "MIT" }, + "node_modules/esutils": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-1.0.0.tgz", + "integrity": "sha512-x/iYH53X3quDwfHRz4y8rn4XcEwwCJeWsul9pF1zldMbGtgOtMNBEOuYWwB1EQlK2LRa1fev3YAgym/RElp5Cg==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/eventsource-parser": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", @@ -3084,6 +3245,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/guess-json-indent": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/guess-json-indent/-/guess-json-indent-3.0.1.tgz", @@ -3529,12 +3707,61 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/jison": { + "version": "0.4.18", + "resolved": "https://registry.npmjs.org/jison/-/jison-0.4.18.tgz", + "integrity": "sha512-FKkCiJvozgC7VTHhMJ00a0/IApSxhlGsFIshLW6trWJ8ONX2TQJBBz6DlcO1Gffy4w9LT+uL+PA+CVnUSJMF7w==", + "license": "MIT", + "dependencies": { + "cjson": "0.3.0", + "ebnf-parser": "0.1.10", + "escodegen": "1.3.x", + "esprima": "1.1.x", + "jison-lex": "0.3.x", + "JSONSelect": "0.4.0", + "lex-parser": "~0.1.3", + "nomnom": "1.5.2" + }, + "bin": { + "jison": "lib/cli.js" + }, + "engines": { + "node": ">=0.4" + } + }, + "node_modules/jison-lex": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/jison-lex/-/jison-lex-0.3.4.tgz", + "integrity": "sha512-EBh5wrXhls1cUwROd5DcDHR1sG7CdsCFSqY1027+YA1RGxz+BX2TDLAhdsQf40YEtFDGoiO0Qm8PpnBl2EzDJw==", + "dependencies": { + "lex-parser": "0.1.x", + "nomnom": "1.5.2" + }, + "bin": { + "jison-lex": "cli.js" + }, + "engines": { + "node": ">=0.4" + } + }, "node_modules/js-base64": { "version": "3.9.3", "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.9.3.tgz", "integrity": "sha512-uwYQp+VJ38FVvtim6qNbit6e9uT6dwWQ4Y1+H9TxhW5hcHjpHwoxlR0nMpqUmIFOmu4VqMxwdJA88gIVuZJQ/g==", "license": "BSD-3-Clause" }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/json-schema": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", @@ -3553,6 +3780,21 @@ "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", "license": "MIT" }, + "node_modules/jsonlint": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/jsonlint/-/jsonlint-1.6.0.tgz", + "integrity": "sha512-x6YLBe6NjdpmIeiklwQOxsZuYj/SOWkT33GlTpaG1UdFGjdWjPcxJ1CWZAX3wA7tarz8E2YHF6KiW5HTapPlXw==", + "dependencies": { + "JSV": ">= 4.0.x", + "nomnom": ">= 1.5.x" + }, + "bin": { + "jsonlint": "lib/cli.js" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/jsonpointer": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", @@ -3562,29 +3804,20 @@ "node": ">=0.10.0" } }, - "node_modules/katex": { - "version": "0.16.47", - "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", - "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", - "funding": [ - "https://opencollective.com/katex", - "https://github.com/sponsors/katex" - ], - "license": "MIT", - "dependencies": { - "commander": "^8.3.0" - }, - "bin": { - "katex": "cli.js" + "node_modules/JSONSelect": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/JSONSelect/-/JSONSelect-0.4.0.tgz", + "integrity": "sha512-VRLR3Su35MH+XV2lrvh9O7qWoug/TUyj9tLDjn9rtpUCNnILLrHjgd/tB0KrhugCxUpj3UqoLqfYb3fLJdIQQQ==", + "engines": { + "node": ">=0.4.7" } }, - "node_modules/katex/node_modules/commander": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", - "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", - "license": "MIT", + "node_modules/JSV": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/JSV/-/JSV-4.0.2.tgz", + "integrity": "sha512-ZJ6wx9xaKJ3yFUhq5/sk82PJMuUyLk277I8mQeyDgCTjGdjWJIvPfaU5LIXaMuaN2UO1X3kZH4+lgphublZUHw==", "engines": { - "node": ">= 12" + "node": "*" } }, "node_modules/khroma": { @@ -3610,6 +3843,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lex-parser": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/lex-parser/-/lex-parser-0.1.4.tgz", + "integrity": "sha512-DuAEISsr1H4LOpmFLkyMc8YStiRWZCO8hMsoXAXSbgyfvs2WQhSt0+/FBv3ZU/JBFZMGcE+FWzEBSzwUU7U27w==", + "license": "MIT" + }, "node_modules/lodash-es": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", @@ -3650,6 +3889,15 @@ "node": ">=12.0.0" } }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -3920,34 +4168,11 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/mermaid": { - "version": "11.16.1", - "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.16.1.tgz", - "integrity": "sha512-TQsq6u22fAn3rek5VOubrhKPo1g5hwC3FXUN9hiyupTckcYiGuuKGkNQrKYwGJkXUxZdojwRG46gsSCFZMDp4g==", - "license": "MIT", - "dependencies": { - "@braintree/sanitize-url": "^7.1.2", - "@iconify/utils": "^3.0.2", - "@mermaid-js/parser": "^1.2.0", - "@types/d3": "^7.4.3", - "@upsetjs/venn.js": "^2.0.0", - "cytoscape": "^3.33.3", - "cytoscape-cose-bilkent": "^4.1.0", - "cytoscape-fcose": "^2.2.0", - "d3": "^7.9.0", - "d3-sankey": "^0.12.3", - "dagre-d3-es": "7.0.14", - "dayjs": "^1.11.20", - "dompurify": "^3.3.3", - "es-toolkit": "^1.45.1", - "katex": "^0.16.45", - "khroma": "^2.1.0", - "marked": "^16.3.0", - "roughjs": "^4.6.6", - "stylis": "^4.3.6", - "ts-dedent": "^2.2.0", - "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" - } + "node_modules/mhchemparser": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/mhchemparser/-/mhchemparser-4.2.1.tgz", + "integrity": "sha512-kYmyrCirqJf3zZ9t/0wGgRZ4/ZJw//VwaRVGA75C4nhE60vtnIzhl9J9ndkX/h6hxSN7pjg/cE0VxbnNM+bnDQ==", + "license": "Apache-2.0" }, "node_modules/microdiff": { "version": "1.6.0", @@ -4518,6 +4743,39 @@ ], "license": "MIT" }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mj-context-menu": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/mj-context-menu/-/mj-context-menu-1.0.0.tgz", + "integrity": "sha512-OSgBFQRCVhrZwRa9lhqnKcJU92dd/YXgBjup0uyeuj9bOaVsf4myOyrjU4PfhYkdDk6AqVUTov7v5uFMvIbduA==", + "license": "Apache-2.0", + "dependencies": { + "rimraf": "^6.0.1" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4548,6 +4806,19 @@ "integrity": "sha512-vVdkelrLxaow/fdWDumzNBO+jwm6X8bxeLJc34THtpj70u0C5QBkcV6CRCu2X726km7XD45N0A3QtYCla4RvKw==", "license": "MIT" }, + "node_modules/nomnom": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/nomnom/-/nomnom-1.5.2.tgz", + "integrity": "sha512-fiVbT7BqxiQqjlR9U3FDGOSERFCKoXVCdxV2FwZuNN7/cmJ42iQx35nUFOAFDcyvemu9Adp+IlsCGlKQYLmBKw==", + "deprecated": "Package no longer supported. Contact support@npmjs.com for more info.", + "dependencies": { + "colors": "0.5.x", + "underscore": "1.1.x" + }, + "engines": { + "node": "*" + } + }, "node_modules/p-event": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/p-event/-/p-event-6.0.1.tgz", @@ -4575,6 +4846,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, "node_modules/package-manager-detector": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.8.0.tgz", @@ -4611,6 +4888,22 @@ "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", "license": "MIT" }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -5004,6 +5297,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/rimraf": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.3", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/robust-predicates": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", @@ -5040,6 +5352,18 @@ "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", "license": "MIT" }, + "node_modules/source-map": { + "version": "0.1.43", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.1.43.tgz", + "integrity": "sha512-VtCvB9SIQhk3aF6h+N85EaqIaBFIAfZ9Cu+NJHHVvc8BbEcnvDcFw6sqQ2dQrT6SlOrZq3tIvyD9+EGq/lJryQ==", + "optional": true, + "dependencies": { + "amdefine": ">=0.0.4" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -5059,6 +5383,29 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/speech-rule-engine": { + "version": "5.0.0-rc.4", + "resolved": "https://registry.npmjs.org/speech-rule-engine/-/speech-rule-engine-5.0.0-rc.4.tgz", + "integrity": "sha512-3dFcH2QtQNhtvUUc09TxoaEK4WG03B9Z57krFG9jocrKykKGu35BhIkWr0vgEAxZZomEWkeluff69y/EbYzP4Q==", + "license": "Apache-2.0", + "dependencies": { + "@xmldom/xmldom": "^0.9.10", + "commander": "^14.0.3", + "wicked-good-xpath": "^1.3.0" + }, + "bin": { + "sre": "bin/sre" + } + }, + "node_modules/speech-rule-engine/node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/string-byte-length": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/string-byte-length/-/string-byte-length-3.0.1.tgz", @@ -5270,6 +5617,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/underscore": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.1.7.tgz", + "integrity": "sha512-w4QtCHoLBXw1mjofIDoMyexaEdWGMedWNDhlWTtT1V1lCRqi65Pnoygkh6+WRdr+Bm8ldkBNkNeCsXGMlQS9HQ==", + "engines": { + "node": "*" + } + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", @@ -5499,6 +5854,12 @@ "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==", "license": "Apache-2.0" }, + "node_modules/wicked-good-xpath": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/wicked-good-xpath/-/wicked-good-xpath-1.3.0.tgz", + "integrity": "sha512-Gd9+TUn5nXdwj/hFsPVx5cuHHiF5Bwuc30jZ4+ronF1qHK5O7HD0sgmXWSEgwKquT3ClLoKPVbO6qGwVwLzvAw==", + "license": "MIT" + }, "node_modules/yaml": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", diff --git a/tools/visualization/package.json b/tools/visualization/package.json index 849263eb..61ae7d9f 100644 --- a/tools/visualization/package.json +++ b/tools/visualization/package.json @@ -7,11 +7,35 @@ "node": ">=22" }, "dependencies": { + "@braintree/sanitize-url": "7.1.2", + "@iconify/utils": "3.1.4", + "@mathjax/mathjax-newcm-font": "4.1.3", + "@mathjax/src": "4.1.3", + "@mermaid-js/parser": "1.2.0", "@plantuml/core": "1.2026.6", "@scalar/api-reference": "1.65.1", "@scalar/json-magic": "0.13.0", "@scalar/openapi-parser": "0.28.14", - "mermaid": "11.16.1", + "@types/d3": "7.4.3", + "@upsetjs/venn.js": "2.0.0", + "ajv": "8.20.0", + "cytoscape": "3.34.1", + "cytoscape-cose-bilkent": "4.1.0", + "cytoscape-fcose": "2.2.0", + "d3": "7.9.0", + "d3-sankey": "0.12.3", + "dagre-d3-es": "7.0.14", + "dayjs": "1.11.23", + "dompurify": "3.4.13", + "es-toolkit": "1.51.0", + "khroma": "2.1.0", + "jison": "0.4.18", + "js-yaml": "4.1.1", + "marked": "16.4.2", + "roughjs": "4.6.6", + "stylis": "4.4.0", + "ts-dedent": "2.3.0", + "uuid": "14.0.1", "yaml": "2.9.0" }, "devDependencies": { diff --git a/tools/visualization/render_engines.js b/tools/visualization/render_engines.js index c7281570..52ed16b9 100644 --- a/tools/visualization/render_engines.js +++ b/tools/visualization/render_engines.js @@ -6,6 +6,7 @@ import { bundle } from '@scalar/json-magic/bundle' import { normalize, validate } from '@scalar/openapi-parser' import mermaid from 'mermaid' import { LineCounter, parseDocument } from 'yaml' +import { renderMathBatch } from './mathjax_renderer.js' const httpMethods = new Set([ 'get', @@ -500,6 +501,8 @@ async function handleRequest(request) { return renderMermaid(request.source, request.theme) case 'renderPlantUml': return renderPlantUml(request.source, request.theme) + case 'renderMathBatch': + return renderMathBatch(request) case 'inspectOpenApi': { try { const sourceMap = createSourceMap('document.openapi', request.source, true) diff --git a/tools/visualization_smoke.py b/tools/visualization_smoke.py index 74eb1488..7e2ebd92 100755 --- a/tools/visualization_smoke.py +++ b/tools/visualization_smoke.py @@ -240,6 +240,57 @@ def expect_svg(response: dict[str, object]) -> None: raise AssertionError(response.get("message", "SVG was not returned")) +def expect_math_all_svg(response: dict[str, object]) -> None: + if response.get("mathJaxVersion") != "4.1.3": + raise AssertionError(f"Unexpected MathJax version: {response}") + if response.get("fontVersion") != "4.1.3": + raise AssertionError(f"Unexpected MathJax font version: {response}") + results = response.get("results") + if not isinstance(results, list) or not results: + raise AssertionError(f"MathJax returned no results: {response}") + for item in results: + if not isinstance(item, dict) or not isinstance(item.get("svg"), str): + raise AssertionError(f"MathJax did not return SVG: {item}") + if " None: + results = response.get("results") + if not isinstance(results, list) or len(results) != 2: + raise AssertionError(f"Unexpected math batch: {response}") + if not isinstance(results[0], dict) or " None: + results = response.get("results") + if not isinstance(results, list) or len(results) != 1: + raise AssertionError(f"Unexpected math error batch: {response}") + error = results[0].get("error") if isinstance(results[0], dict) else None + if not isinstance(error, dict) or error.get("code") != "math.invalidTex": + raise AssertionError(f"Expression unexpectedly inherited TeX state: {response}") + + +def expect_math_rejected(response: dict[str, object]) -> None: + results = response.get("results") + if not isinstance(results, list) or len(results) != 4: + raise AssertionError(f"Unexpected unsafe-math batch: {response}") + for item in results: + error = item.get("error") if isinstance(item, dict) else None + if not isinstance(error, dict) or error.get("code") not in { + "math.invalidTex", + "math.resourceLimit", + }: + raise AssertionError(f"Unsafe TeX was not rejected: {item}") + + def expect_openapi(response: dict[str, object]) -> None: reference = response.get("reference") if not isinstance(reference, dict) or not reference.get("valid"): @@ -419,6 +470,203 @@ def main() -> int: }, "validator": expect_svg, }, + { + "name": "MathJax NewCM double-struck", + "uri": "busymark-render://app/harness.html", + "request": { + "operation": "renderMathBatch", + "expressions": [ + { + "id": "mathbb", + "expression": r"\mathbb{R}", + "display": False, + "em": 16, + "ex": 8, + "containerWidth": 720, + "svgIdPrefix": "smoke-mathbb", + } + ], + }, + "validator": expect_math_all_svg, + }, + { + "name": "MathJax NewCM sequential calligraphic", + "uri": "busymark-render://app/harness.html", + "request": { + "operation": "renderMathBatch", + "expressions": [ + { + "id": "mathcal", + "expression": r"\mathcal{L}", + "display": False, + "em": 16, + "ex": 8, + "containerWidth": 720, + "svgIdPrefix": "smoke-mathcal", + } + ], + }, + "validator": expect_math_all_svg, + }, + { + "name": "MathJax scientific package profile", + "uri": "busymark-render://app/harness.html", + "request": { + "operation": "renderMathBatch", + "expressions": [ + { + "id": "scientific", + "expression": ( + r"\ce{2H2 + O2 -> 2H2O}\quad" + r"\Braket{\psi|\phi}+\cancel{x}+\upalpha" + r"+a\coloneqq b+\units{m}" + ), + "display": True, + "em": 16, + "ex": 8, + "containerWidth": 720, + "svgIdPrefix": "smoke-scientific", + }, + { + "id": "boldsymbol", + "expression": r"\boldsymbol{\alpha}", + "display": False, + "em": 16, + "ex": 8, + "containerWidth": 720, + "svgIdPrefix": "smoke-boldsymbol", + }, + { + "id": "cases", + "expression": r"f(x)=\begin{cases}x&x>0\\0&x\leq0\end{cases}", + "display": True, + "em": 16, + "ex": 8, + "containerWidth": 720, + "svgIdPrefix": "smoke-cases", + }, + { + "id": "gensymb", + "expression": r"90\degree", + "display": False, + "em": 16, + "ex": 8, + "containerWidth": 720, + "svgIdPrefix": "smoke-gensymb", + }, + { + "id": "empheq", + "expression": r"\begin{empheq}{align}E&=mc^2\end{empheq}", + "display": True, + "em": 16, + "ex": 8, + "containerWidth": 720, + "svgIdPrefix": "smoke-empheq", + }, + { + "id": "ams", + "expression": r"\begin{align}a&=b\end{align}", + "display": True, + "em": 16, + "ex": 8, + "containerWidth": 720, + "svgIdPrefix": "smoke-ams", + }, + ], + }, + "validator": expect_math_all_svg, + }, + { + "name": "MathJax partial failure", + "uri": "busymark-render://app/harness.html", + "request": { + "operation": "renderMathBatch", + "expressions": [ + { + "id": "valid", + "expression": r"\sqrt{x^2+y^2}", + "display": False, + "em": 16, + "ex": 8, + "containerWidth": 720, + "svgIdPrefix": "smoke-valid", + }, + { + "id": "invalid", + "expression": r"\frac{", + "display": False, + "em": 16, + "ex": 8, + "containerWidth": 720, + "svgIdPrefix": "smoke-invalid", + }, + ], + }, + "validator": expect_math_partial_failure, + }, + { + "name": "MathJax unsafe and dynamic commands", + "uri": "busymark-render://app/harness.html", + "request": { + "operation": "renderMathBatch", + "expressions": [ + { + "id": name, + "expression": expression, + "display": False, + "em": 16, + "ex": 8, + "containerWidth": 720, + "svgIdPrefix": f"smoke-{name}", + } + for name, expression in [ + ("url", r"\href{javascript:alert(1)}{x}"), + ("dynamic", r"\require{physics}"), + ("style", r"\style{position:fixed}{x}"), + ("recursive", r"\newcommand{\loop}{\loop}\loop"), + ] + ], + }, + "validator": expect_math_rejected, + }, + { + "name": "MathJax local newcommand", + "uri": "busymark-render://app/harness.html", + "request": { + "operation": "renderMathBatch", + "expressions": [ + { + "id": "macro-definition", + "expression": r"\newcommand{\busyisolated}{z}\busyisolated", + "display": False, + "em": 16, + "ex": 8, + "containerWidth": 720, + "svgIdPrefix": "smoke-macro-definition", + } + ], + }, + "validator": expect_math_all_svg, + }, + { + "name": "MathJax expression isolation", + "uri": "busymark-render://app/harness.html", + "request": { + "operation": "renderMathBatch", + "expressions": [ + { + "id": "macro-isolation", + "expression": r"\busyisolated", + "display": False, + "em": 16, + "ex": 8, + "containerWidth": 720, + "svgIdPrefix": "smoke-macro-isolation", + } + ], + }, + "validator": expect_math_error, + }, *( [ { From aa4260083c804040e97d9025510601e7b798df9d Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 21 Aug 2026 15:41:43 -0700 Subject: [PATCH 05/38] Fix multi-document safety and recovery --- lib/l10n/app_ar.arb | 6 + lib/l10n/app_de.arb | 6 + lib/l10n/app_en.arb | 12 + lib/l10n/app_es.arb | 6 + lib/l10n/app_et.arb | 6 + lib/l10n/app_fa.arb | 6 + lib/l10n/app_fr.arb | 6 + lib/l10n/app_hi.arb | 6 + lib/l10n/app_it.arb | 6 + lib/l10n/app_nb.arb | 6 + lib/l10n/app_pl.arb | 6 + lib/l10n/app_pt.arb | 6 + lib/l10n/app_ru.arb | 6 + lib/l10n/app_uk.arb | 6 + lib/l10n/generated/app_localizations.dart | 36 ++ lib/l10n/generated/app_localizations_ar.dart | 28 ++ lib/l10n/generated/app_localizations_de.dart | 28 ++ lib/l10n/generated/app_localizations_en.dart | 51 +++ lib/l10n/generated/app_localizations_es.dart | 28 ++ lib/l10n/generated/app_localizations_et.dart | 28 ++ lib/l10n/generated/app_localizations_fa.dart | 28 ++ lib/l10n/generated/app_localizations_fr.dart | 28 ++ lib/l10n/generated/app_localizations_hi.dart | 28 ++ lib/l10n/generated/app_localizations_it.dart | 28 ++ lib/l10n/generated/app_localizations_nb.dart | 28 ++ lib/l10n/generated/app_localizations_pl.dart | 28 ++ lib/l10n/generated/app_localizations_pt.dart | 28 ++ lib/l10n/generated/app_localizations_ru.dart | 28 ++ lib/l10n/generated/app_localizations_uk.dart | 28 ++ lib/src/app/busymark_app.dart | 129 ++++--- lib/src/app/busymark_dialogs.dart | 24 +- lib/src/app/busymark_main_menu.dart | 31 +- lib/src/app/command_palette.dart | 38 +- lib/src/app/command_registry.dart | 212 +++++++++-- lib/src/editor/editor_text_context_menu.dart | 4 +- lib/src/editor/source/source_editor.dart | 67 +++- lib/src/editor/wysiwyg/wysiwyg_editor.dart | 338 +++++++++++++++--- .../editor/wysiwyg/wysiwyg_session_state.dart | 41 +++ lib/src/search/search_replace_service.dart | 110 +++++- lib/src/workspace/document_buffer.dart | 13 +- .../presentation/welcome_screen.dart | 5 +- .../presentation/workspace_screen.dart | 315 +++++++++++----- lib/src/workspace/recovery_persistence.dart | 61 +++- lib/src/workspace/session_persistence.dart | 43 +++ lib/src/workspace/workspace_controller.dart | 204 ++++++++++- lib/src/workspace/workspace_message.dart | 8 + lib/src/workspace/workspace_safety.dart | 175 ++++++++- lib/src/workspace/workspace_service.dart | 177 +++++++++ test/src/command_registry_test.dart | 57 +++ test/src/document_persistence_test.dart | 92 ++++- test/src/search_replace_service_test.dart | 89 +++++ test/src/source_audit_test.dart | 10 +- test/src/workspace_controller_test.dart | 229 ++++++++++++ test/src/workspace_safety_test.dart | 64 ++++ test/src/wysiwyg_session_test.dart | 120 +++++++ 55 files changed, 2879 insertions(+), 318 deletions(-) create mode 100644 lib/src/editor/wysiwyg/wysiwyg_session_state.dart create mode 100644 test/src/wysiwyg_session_test.dart diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 51e60930..bfc01df3 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -2703,6 +2703,12 @@ "reloadFromDisk": "إعادة التحميل من القرص", "keepMine": "الاحتفاظ بنسختي", "saveAs": "حفظ باسم", + "unsavedChangesMultipleMessage": "يحتوي {count} من المستندات على تغييرات غير محفوظة. هل تريد حفظها قبل المتابعة؟", + "workspaceReplaceIssueApplyFailed": "لم تُطبّق أي استبدالات لأن المجموعة التي تمت مراجعتها تعذر حفظها بأمان.", + "workspaceRecoveryRestored": "تمت استعادة {count} من المستندات غير المحفوظة. راجع كل مستند تمت استعادته قبل المتابعة.", + "workspaceRecoveryDamaged": "تعذرت استعادة {count} من سجلات الاستعادة التالفة. تظل المستندات الصالحة التي تمت استعادتها متاحة.", + "recoveredDocumentReview": "تمت استعادة المحتوى غير المحفوظ للملف {fileName}. راجعه، ثم احفظه أو احفظه باسم جديد أو تجاهله.", + "commandUnavailableInContext": "هذا الأمر غير متاح في السياق الحالي.", "mathRenderFailed": "تعذر عرض التعبير الرياضي.", "inlineMath": "رياضيات مضمنة", "displayMath": "رياضيات معروضة" diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 3be088bb..312d052a 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -2724,6 +2724,12 @@ "reloadFromDisk": "Vom Datenträger neu laden", "keepMine": "Meine Version behalten", "saveAs": "Speichern unter", + "unsavedChangesMultipleMessage": "{count} Dokumente enthalten ungespeicherte Änderungen. Vor dem Fortfahren speichern?", + "workspaceReplaceIssueApplyFailed": "Es wurden keine Ersetzungen vorgenommen, da die geprüfte Auswahl nicht sicher gespeichert werden konnte.", + "workspaceRecoveryRestored": "{count} ungespeicherte Dokumente wurden wiederhergestellt. Prüfen Sie jedes wiederhergestellte Dokument, bevor Sie fortfahren.", + "workspaceRecoveryDamaged": "{count} beschädigte Wiederherstellungsdatensätze konnten nicht wiederhergestellt werden. Gültige wiederhergestellte Dokumente bleiben verfügbar.", + "recoveredDocumentReview": "Ungespeicherter Inhalt für {fileName} wurde wiederhergestellt. Prüfen Sie ihn und speichern Sie ihn, speichern Sie ihn unter einem neuen Namen oder verwerfen Sie ihn.", + "commandUnavailableInContext": "Dieser Befehl ist im aktuellen Kontext nicht verfügbar.", "mathRenderFailed": "Der mathematische Ausdruck konnte nicht dargestellt werden.", "inlineMath": "Mathematik im Text", "displayMath": "Mathematische Formel als Block" diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 22cd8874..b565d88a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -141,6 +141,8 @@ "@commandPaletteHint": {"description": "Search hint in the command palette."}, "commandPaletteEmpty": "No matching commands", "@commandPaletteEmpty": {"description": "Empty state in the command palette."}, + "commandUnavailableInContext": "Unavailable in the current editor context", + "@commandUnavailableInContext": {"description":"Reason shown for a disabled contextual command."}, "@keyboardShortcuts": {"description": "Keyboard shortcuts dialog title and menu item."}, "lightTheme": "Light", "@lightTheme": {"description": "Light theme option."}, @@ -479,6 +481,8 @@ "description": "Unsaved changes confirmation message.", "placeholders": {"fileName": {"type": "String"}} }, + "unsavedChangesMultipleMessage": "{count, plural, =1{1 document has unsaved changes. Save it before continuing?} other{{count} documents have unsaved changes. Save them before continuing?}}", + "@unsavedChangesMultipleMessage": {"description":"Confirmation message before replacing workspace state when several documents are dirty.","placeholders":{"count":{"type":"int"}}}, "fileChangedOnDisk": "File changed on disk", "@fileChangedOnDisk": {"description": "File changed confirmation dialog title."}, "fileChangedOnDiskMessage": "This file changed on disk since you opened it. Overwrite it?", @@ -928,12 +932,16 @@ "@workspaceReplaceIssueBufferChanged": {"description": "Workspace replacement issue for stale in-memory content."}, "workspaceReplaceIssueNormalizationRequired": "Choose LF or CRLF normalization before replacing.", "@workspaceReplaceIssueNormalizationRequired": {"description": "Workspace replacement issue for mixed line endings without a selected format."}, + "workspaceReplaceIssueApplyFailed": "The reviewed replacement could not be committed; no files were changed.", + "@workspaceReplaceIssueApplyFailed": {"description": "Workspace replacement issue when the transactional file commit fails."}, "externalChangesTitle": "External changes — {fileName}", "@externalChangesTitle": {"description": "Title of the external-file comparison dialog.", "placeholders": {"fileName": {"type": "String"}}}, "externalFileDeleted": "This file was deleted on disk.", "@externalFileDeleted": {"description": "Persistent banner shown when an open file is deleted externally."}, "externalFileChanged": "This file changed on disk while you have unsaved edits.", "@externalFileChanged": {"description": "Persistent banner shown when a dirty file changes externally."}, + "recoveredDocumentReview": "Recovered unsaved content for {fileName}. Inspect it, then save, save as, or discard it.", + "@recoveredDocumentReview": {"description":"Persistent recovery review banner.","placeholders":{"fileName":{"type":"String"}}}, "compare": "Compare", "@compare": {"description": "Action that compares the editor buffer with the disk version."}, "reloadFromDisk": "Reload from Disk", @@ -1050,6 +1058,10 @@ "description": "Workspace error message shown when validation fails.", "placeholders": {"error": {"type": "String"}} }, + "workspaceRecoveryRestored": "{count, plural, =1{Recovered 1 unsaved document. Review it before saving or discarding it.} other{Recovered {count} unsaved documents. Review each one before saving or discarding it.}}", + "@workspaceRecoveryRestored": {"description":"Notice shown after crash recovery restores documents.","placeholders":{"count":{"type":"int"}}}, + "workspaceRecoveryDamaged": "{count, plural, =1{One damaged recovery record could not be restored. The original recovery file was preserved for inspection.} other{{count} damaged recovery records could not be restored. Valid recovery records remain available.}}", + "@workspaceRecoveryDamaged": {"description":"Notice shown when recovery data is partly or wholly malformed.","placeholders":{"count":{"type":"int"}}}, "errorPathDoesNotExist": "Path does not exist: {path}", "@errorPathDoesNotExist": { diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index be0717b2..70f0f880 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -2724,6 +2724,12 @@ "reloadFromDisk": "Recargar desde el disco", "keepMine": "Conservar mi versión", "saveAs": "Guardar como", + "unsavedChangesMultipleMessage": "Hay {count} documentos con cambios sin guardar. ¿Desea guardarlos antes de continuar?", + "workspaceReplaceIssueApplyFailed": "No se aplicó ningún reemplazo porque el conjunto revisado no pudo guardarse de forma segura.", + "workspaceRecoveryRestored": "Se recuperaron {count} documentos sin guardar. Revise cada documento recuperado antes de continuar.", + "workspaceRecoveryDamaged": "No se pudieron restaurar {count} registros de recuperación dañados. Los documentos recuperados válidos siguen disponibles.", + "recoveredDocumentReview": "Se recuperó contenido sin guardar de {fileName}. Revíselo y después guárdelo, use Guardar como o descártelo.", + "commandUnavailableInContext": "Este comando no está disponible en el contexto actual.", "mathRenderFailed": "No se pudo representar la expresión matemática.", "inlineMath": "Matemáticas en línea", "displayMath": "Matemáticas en bloque" diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index 92e68da2..09594954 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -1912,6 +1912,12 @@ "reloadFromDisk": "Laadi kettalt uuesti", "keepMine": "Säilita minu versioon", "saveAs": "Salvesta nimega", + "unsavedChangesMultipleMessage": "{count} dokumendis on salvestamata muudatusi. Kas salvestada need enne jätkamist?", + "workspaceReplaceIssueApplyFailed": "Asendusi ei rakendatud, sest läbivaadatud kogumit ei saanud turvaliselt salvestada.", + "workspaceRecoveryRestored": "Taastati {count} salvestamata dokumenti. Vaadake iga taastatud dokument enne jätkamist üle.", + "workspaceRecoveryDamaged": "{count} rikutud taastekirjet ei saanud taastada. Kehtivad taastatud dokumendid on endiselt saadaval.", + "recoveredDocumentReview": "Faili {fileName} salvestamata sisu taastati. Vaadake see üle ning salvestage, salvestage nimega või hüljake.", + "commandUnavailableInContext": "See käsk pole praeguses kontekstis saadaval.", "mathRenderFailed": "Matemaatilist avaldist ei saanud kuvada.", "inlineMath": "Reasisene matemaatika", "displayMath": "Plokina matemaatika" diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 27143e78..7342d338 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -2722,6 +2722,12 @@ "reloadFromDisk": "بارگیری دوباره از دیسک", "keepMine": "نگه‌داشتن نسخهٔ من", "saveAs": "ذخیره با نام", + "unsavedChangesMultipleMessage": "تعداد {count} سند تغییرات ذخیره‌نشده دارند. پیش از ادامه ذخیره شوند؟", + "workspaceReplaceIssueApplyFailed": "هیچ جایگزینی اعمال نشد، زیرا مجموعهٔ بازبینی‌شده را نمی‌شد با ایمنی ذخیره کرد.", + "workspaceRecoveryRestored": "تعداد {count} سند ذخیره‌نشده بازیابی شد. پیش از ادامه هر سند بازیابی‌شده را بررسی کنید.", + "workspaceRecoveryDamaged": "تعداد {count} رکورد بازیابی آسیب‌دیده قابل بازیابی نبود. سندهای معتبر بازیابی‌شده همچنان در دسترس‌اند.", + "recoveredDocumentReview": "محتوای ذخیره‌نشدهٔ {fileName} بازیابی شده است. آن را بررسی کنید و سپس ذخیره، ذخیره با نام یا رد کنید.", + "commandUnavailableInContext": "این فرمان در زمینهٔ فعلی در دسترس نیست.", "mathRenderFailed": "عبارت ریاضی قابل نمایش نبود.", "inlineMath": "ریاضی درون‌خطی", "displayMath": "ریاضی نمایشی" diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 7c61306d..338e0bc9 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -2724,6 +2724,12 @@ "reloadFromDisk": "Recharger depuis le disque", "keepMine": "Conserver ma version", "saveAs": "Enregistrer sous", + "unsavedChangesMultipleMessage": "{count} documents contiennent des modifications non enregistrées. Les enregistrer avant de continuer ?", + "workspaceReplaceIssueApplyFailed": "Aucun remplacement n’a été appliqué, car l’ensemble vérifié n’a pas pu être enregistré en toute sécurité.", + "workspaceRecoveryRestored": "{count} documents non enregistrés ont été récupérés. Vérifiez chaque document récupéré avant de continuer.", + "workspaceRecoveryDamaged": "{count} enregistrements de récupération endommagés n’ont pas pu être restaurés. Les documents valides récupérés restent disponibles.", + "recoveredDocumentReview": "Le contenu non enregistré de {fileName} a été récupéré. Vérifiez-le, puis enregistrez-le, enregistrez-le sous un autre nom ou ignorez-le.", + "commandUnavailableInContext": "Cette commande n’est pas disponible dans le contexte actuel.", "mathRenderFailed": "Impossible d’afficher l’expression mathématique.", "inlineMath": "Formule en ligne", "displayMath": "Formule en bloc" diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 4b0acc1e..450f368c 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -2703,6 +2703,12 @@ "reloadFromDisk": "डिस्क से फिर लोड करें", "keepMine": "मेरा संस्करण रखें", "saveAs": "इस रूप में सहेजें", + "unsavedChangesMultipleMessage": "{count} दस्तावेज़ों में सहेजे नहीं गए बदलाव हैं। जारी रखने से पहले इन्हें सहेजें?", + "workspaceReplaceIssueApplyFailed": "कोई प्रतिस्थापन लागू नहीं किया गया क्योंकि समीक्षा किए गए समूह को सुरक्षित रूप से सहेजा नहीं जा सका।", + "workspaceRecoveryRestored": "{count} सहेजे नहीं गए दस्तावेज़ पुनर्प्राप्त किए गए। जारी रखने से पहले प्रत्येक पुनर्प्राप्त दस्तावेज़ की समीक्षा करें।", + "workspaceRecoveryDamaged": "{count} क्षतिग्रस्त पुनर्प्राप्ति रिकॉर्ड बहाल नहीं किए जा सके। मान्य पुनर्प्राप्त दस्तावेज़ उपलब्ध हैं।", + "recoveredDocumentReview": "{fileName} की सहेजी नहीं गई सामग्री पुनर्प्राप्त हुई। इसकी समीक्षा करें, फिर सहेजें, इस रूप में सहेजें या छोड़ दें।", + "commandUnavailableInContext": "यह आदेश वर्तमान संदर्भ में उपलब्ध नहीं है।", "mathRenderFailed": "गणितीय व्यंजक रेंडर नहीं किया जा सका।", "inlineMath": "इनलाइन गणित", "displayMath": "डिस्प्ले गणित" diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 07e3ea38..856ca0f8 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -2701,6 +2701,12 @@ "reloadFromDisk": "Ricarica dal disco", "keepMine": "Mantieni la mia versione", "saveAs": "Salva con nome", + "unsavedChangesMultipleMessage": "{count} documenti contengono modifiche non salvate. Salvarli prima di continuare?", + "workspaceReplaceIssueApplyFailed": "Non è stata applicata alcuna sostituzione perché non è stato possibile salvare in sicurezza l’insieme verificato.", + "workspaceRecoveryRestored": "Sono stati recuperati {count} documenti non salvati. Controllare ogni documento recuperato prima di continuare.", + "workspaceRecoveryDamaged": "Non è stato possibile ripristinare {count} record di recupero danneggiati. I documenti recuperati validi restano disponibili.", + "recoveredDocumentReview": "È stato recuperato il contenuto non salvato di {fileName}. Controllarlo, quindi salvarlo, salvarlo con nome o eliminarlo.", + "commandUnavailableInContext": "Questo comando non è disponibile nel contesto corrente.", "mathRenderFailed": "Impossibile visualizzare l’espressione matematica.", "inlineMath": "Formula in linea", "displayMath": "Formula in blocco" diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb index 5adb6407..c2979df1 100644 --- a/lib/l10n/app_nb.arb +++ b/lib/l10n/app_nb.arb @@ -2701,6 +2701,12 @@ "reloadFromDisk": "Last inn fra disk på nytt", "keepMine": "Behold min versjon", "saveAs": "Lagre som", + "unsavedChangesMultipleMessage": "{count} dokumenter har ulagrede endringer. Lagre dem før du fortsetter?", + "workspaceReplaceIssueApplyFailed": "Ingen erstatninger ble utført fordi det gjennomgåtte settet ikke kunne lagres på en trygg måte.", + "workspaceRecoveryRestored": "{count} ulagrede dokumenter ble gjenopprettet. Se gjennom hvert gjenopprettet dokument før du fortsetter.", + "workspaceRecoveryDamaged": "{count} skadede gjenopprettingsoppføringer kunne ikke gjenopprettes. Gyldige gjenopprettede dokumenter er fortsatt tilgjengelige.", + "recoveredDocumentReview": "Ulagret innhold for {fileName} ble gjenopprettet. Se gjennom det, og lagre, lagre som eller forkast det.", + "commandUnavailableInContext": "Denne kommandoen er ikke tilgjengelig i gjeldende kontekst.", "mathRenderFailed": "Det matematiske uttrykket kunne ikke gjengis.", "inlineMath": "Integrert matematikk", "displayMath": "Matematikkblokk" diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 44646d59..6e46e2b3 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -2719,6 +2719,12 @@ "reloadFromDisk": "Wczytaj ponownie z dysku", "keepMine": "Zachowaj moją wersję", "saveAs": "Zapisz jako", + "unsavedChangesMultipleMessage": "{count} dokumenty zawierają niezapisane zmiany. Zapisać je przed kontynuowaniem?", + "workspaceReplaceIssueApplyFailed": "Nie zastosowano żadnych zamian, ponieważ sprawdzonego zestawu nie można było bezpiecznie zapisać.", + "workspaceRecoveryRestored": "Odzyskano {count} niezapisanych dokumentów. Przejrzyj każdy odzyskany dokument przed kontynuowaniem.", + "workspaceRecoveryDamaged": "Nie udało się przywrócić {count} uszkodzonych rekordów odzyskiwania. Prawidłowe odzyskane dokumenty są nadal dostępne.", + "recoveredDocumentReview": "Odzyskano niezapisaną treść pliku {fileName}. Przejrzyj ją, a następnie zapisz, zapisz jako lub odrzuć.", + "commandUnavailableInContext": "To polecenie nie jest dostępne w bieżącym kontekście.", "mathRenderFailed": "Nie udało się wyrenderować wyrażenia matematycznego.", "inlineMath": "Matematyka w tekście", "displayMath": "Matematyka blokowa" diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 6a9a16c8..660e456f 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -2701,6 +2701,12 @@ "reloadFromDisk": "Recarregar do disco", "keepMine": "Manter minha versão", "saveAs": "Salvar como", + "unsavedChangesMultipleMessage": "Há {count} documentos com alterações não salvas. Deseja salvá-los antes de continuar?", + "workspaceReplaceIssueApplyFailed": "Nenhuma substituição foi aplicada porque o conjunto revisado não pôde ser salvo com segurança.", + "workspaceRecoveryRestored": "Foram recuperados {count} documentos não salvos. Revise cada documento recuperado antes de continuar.", + "workspaceRecoveryDamaged": "Não foi possível restaurar {count} registros de recuperação danificados. Os documentos recuperados válidos continuam disponíveis.", + "recoveredDocumentReview": "O conteúdo não salvo de {fileName} foi recuperado. Revise-o e depois salve, salve como ou descarte-o.", + "commandUnavailableInContext": "Este comando não está disponível no contexto atual.", "mathRenderFailed": "Não foi possível renderizar a expressão matemática.", "inlineMath": "Matemática em linha", "displayMath": "Matemática em bloco" diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index fa133453..ed42e4e4 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -2719,6 +2719,12 @@ "reloadFromDisk": "Перезагрузить с диска", "keepMine": "Оставить мою версию", "saveAs": "Сохранить как", + "unsavedChangesMultipleMessage": "В документах ({count}) есть несохранённые изменения. Сохранить их перед продолжением?", + "workspaceReplaceIssueApplyFailed": "Замены не применены, поскольку проверенный набор не удалось безопасно сохранить.", + "workspaceRecoveryRestored": "Восстановлено несохранённых документов: {count}. Проверьте каждый восстановленный документ перед продолжением.", + "workspaceRecoveryDamaged": "Не удалось восстановить повреждённые записи ({count}). Корректные восстановленные документы остаются доступными.", + "recoveredDocumentReview": "Восстановлено несохранённое содержимое файла {fileName}. Проверьте его, затем сохраните, сохраните как новый файл или отбросьте.", + "commandUnavailableInContext": "Эта команда недоступна в текущем контексте.", "mathRenderFailed": "Не удалось отобразить математическое выражение.", "inlineMath": "Формула в строке", "displayMath": "Формула отдельным блоком" diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index c9a0391c..da74ff0a 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -2719,6 +2719,12 @@ "reloadFromDisk": "Перезавантажити з диска", "keepMine": "Залишити мою версію", "saveAs": "Зберегти як", + "unsavedChangesMultipleMessage": "У документах ({count}) є незбережені зміни. Зберегти їх перед продовженням?", + "workspaceReplaceIssueApplyFailed": "Заміни не застосовано, оскільки перевірений набір не вдалося безпечно зберегти.", + "workspaceRecoveryRestored": "Відновлено незбережених документів: {count}. Перегляньте кожен відновлений документ перед продовженням.", + "workspaceRecoveryDamaged": "Не вдалося відновити пошкоджені записи ({count}). Коректні відновлені документи залишаються доступними.", + "recoveredDocumentReview": "Відновлено незбережений вміст файлу {fileName}. Перегляньте його, потім збережіть, збережіть як новий файл або відкиньте.", + "commandUnavailableInContext": "Ця команда недоступна в поточному контексті.", "mathRenderFailed": "Не вдалося відобразити математичний вираз.", "inlineMath": "Формула в рядку", "displayMath": "Формула окремим блоком" diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index c414d1f5..1a0bfd44 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -531,6 +531,12 @@ abstract class AppLocalizations { /// **'No matching commands'** String get commandPaletteEmpty; + /// Reason shown for a disabled contextual command. + /// + /// In en, this message translates to: + /// **'Unavailable in the current editor context'** + String get commandUnavailableInContext; + /// Light theme option. /// /// In en, this message translates to: @@ -1503,6 +1509,12 @@ abstract class AppLocalizations { /// **'You have unsaved changes in {fileName}. Save them before continuing?'** String unsavedChangesMessage(String fileName); + /// Confirmation message before replacing workspace state when several documents are dirty. + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 document has unsaved changes. Save it before continuing?} other{{count} documents have unsaved changes. Save them before continuing?}}'** + String unsavedChangesMultipleMessage(int count); + /// File changed confirmation dialog title. /// /// In en, this message translates to: @@ -2625,6 +2637,12 @@ abstract class AppLocalizations { /// **'Choose LF or CRLF normalization before replacing.'** String get workspaceReplaceIssueNormalizationRequired; + /// Workspace replacement issue when the transactional file commit fails. + /// + /// In en, this message translates to: + /// **'The reviewed replacement could not be committed; no files were changed.'** + String get workspaceReplaceIssueApplyFailed; + /// Title of the external-file comparison dialog. /// /// In en, this message translates to: @@ -2643,6 +2661,12 @@ abstract class AppLocalizations { /// **'This file changed on disk while you have unsaved edits.'** String get externalFileChanged; + /// Persistent recovery review banner. + /// + /// In en, this message translates to: + /// **'Recovered unsaved content for {fileName}. Inspect it, then save, save as, or discard it.'** + String recoveredDocumentReview(String fileName); + /// Action that compares the editor buffer with the disk version. /// /// In en, this message translates to: @@ -2889,6 +2913,18 @@ abstract class AppLocalizations { /// **'Validation failed: {error}'** String workspaceErrorValidationFailed(String error); + /// Notice shown after crash recovery restores documents. + /// + /// In en, this message translates to: + /// **'{count, plural, =1{Recovered 1 unsaved document. Review it before saving or discarding it.} other{Recovered {count} unsaved documents. Review each one before saving or discarding it.}}'** + String workspaceRecoveryRestored(int count); + + /// Notice shown when recovery data is partly or wholly malformed. + /// + /// In en, this message translates to: + /// **'{count, plural, =1{One damaged recovery record could not be restored. The original recovery file was preserved for inspection.} other{{count} damaged recovery records could not be restored. Valid recovery records remain available.}}'** + String workspaceRecoveryDamaged(int count); + /// Detail for a missing path error. /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index b5adb132..9928a190 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -226,6 +226,10 @@ class AppLocalizationsAr extends AppLocalizations { @override String get commandPaletteEmpty => 'لا توجد أوامر مطابقة'; + @override + String get commandUnavailableInContext => + 'هذا الأمر غير متاح في السياق الحالي.'; + @override String get lightTheme => 'فاتح'; @@ -760,6 +764,11 @@ class AppLocalizationsAr extends AppLocalizations { return 'لديك تغييرات غير محفوظة في ⁨$fileName⁩. هل تريد حفظها قبل المتابعة؟'; } + @override + String unsavedChangesMultipleMessage(int count) { + return 'يحتوي $count من المستندات على تغييرات غير محفوظة. هل تريد حفظها قبل المتابعة؟'; + } + @override String get fileChangedOnDisk => 'تغيّر الملف على القرص'; @@ -1423,6 +1432,10 @@ class AppLocalizationsAr extends AppLocalizations { String get workspaceReplaceIssueNormalizationRequired => 'اختر توحيد LF أو CRLF قبل الاستبدال.'; + @override + String get workspaceReplaceIssueApplyFailed => + 'لم تُطبّق أي استبدالات لأن المجموعة التي تمت مراجعتها تعذر حفظها بأمان.'; + @override String externalChangesTitle(String fileName) { return 'تغييرات خارجية — ⁨$fileName⁩'; @@ -1435,6 +1448,11 @@ class AppLocalizationsAr extends AppLocalizations { String get externalFileChanged => 'تغيّر هذا الملف على القرص بينما لديك تعديلات غير محفوظة.'; + @override + String recoveredDocumentReview(String fileName) { + return 'تمت استعادة المحتوى غير المحفوظ للملف $fileName. راجعه، ثم احفظه أو احفظه باسم جديد أو تجاهله.'; + } + @override String get compare => 'مقارنة'; @@ -1584,6 +1602,16 @@ class AppLocalizationsAr extends AppLocalizations { return 'فشل التحقق: ⁨$error⁩'; } + @override + String workspaceRecoveryRestored(int count) { + return 'تمت استعادة $count من المستندات غير المحفوظة. راجع كل مستند تمت استعادته قبل المتابعة.'; + } + + @override + String workspaceRecoveryDamaged(int count) { + return 'تعذرت استعادة $count من سجلات الاستعادة التالفة. تظل المستندات الصالحة التي تمت استعادتها متاحة.'; + } + @override String errorPathDoesNotExist(String path) { return 'المسار غير موجود: ⁨$path⁩'; diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index b4c398e0..7d1b3888 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -230,6 +230,10 @@ class AppLocalizationsDe extends AppLocalizations { @override String get commandPaletteEmpty => 'Keine passenden Befehle'; + @override + String get commandUnavailableInContext => + 'Dieser Befehl ist im aktuellen Kontext nicht verfügbar.'; + @override String get lightTheme => 'Hell'; @@ -780,6 +784,11 @@ class AppLocalizationsDe extends AppLocalizations { return 'Für $fileName liegen ungespeicherte Änderungen vor. Vor dem Fortfahren speichern?'; } + @override + String unsavedChangesMultipleMessage(int count) { + return '$count Dokumente enthalten ungespeicherte Änderungen. Vor dem Fortfahren speichern?'; + } + @override String get fileChangedOnDisk => 'Datei auf dem Datenträger geändert'; @@ -1437,6 +1446,10 @@ class AppLocalizationsDe extends AppLocalizations { String get workspaceReplaceIssueNormalizationRequired => 'Wählen Sie vor dem Ersetzen die Normalisierung auf LF oder CRLF.'; + @override + String get workspaceReplaceIssueApplyFailed => + 'Es wurden keine Ersetzungen vorgenommen, da die geprüfte Auswahl nicht sicher gespeichert werden konnte.'; + @override String externalChangesTitle(String fileName) { return 'Externe Änderungen — $fileName'; @@ -1450,6 +1463,11 @@ class AppLocalizationsDe extends AppLocalizations { String get externalFileChanged => 'Diese Datei wurde auf dem Datenträger geändert, während ungespeicherte Änderungen vorliegen.'; + @override + String recoveredDocumentReview(String fileName) { + return 'Ungespeicherter Inhalt für $fileName wurde wiederhergestellt. Prüfen Sie ihn und speichern Sie ihn, speichern Sie ihn unter einem neuen Namen oder verwerfen Sie ihn.'; + } + @override String get compare => 'Vergleichen'; @@ -1599,6 +1617,16 @@ class AppLocalizationsDe extends AppLocalizations { return 'Validierung fehlgeschlagen: $error'; } + @override + String workspaceRecoveryRestored(int count) { + return '$count ungespeicherte Dokumente wurden wiederhergestellt. Prüfen Sie jedes wiederhergestellte Dokument, bevor Sie fortfahren.'; + } + + @override + String workspaceRecoveryDamaged(int count) { + return '$count beschädigte Wiederherstellungsdatensätze konnten nicht wiederhergestellt werden. Gültige wiederhergestellte Dokumente bleiben verfügbar.'; + } + @override String errorPathDoesNotExist(String path) { return 'Pfad existiert nicht: $path'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index b1f27c27..7eaaa103 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -227,6 +227,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get commandPaletteEmpty => 'No matching commands'; + @override + String get commandUnavailableInContext => + 'Unavailable in the current editor context'; + @override String get lightTheme => 'Light'; @@ -763,6 +767,18 @@ class AppLocalizationsEn extends AppLocalizations { return 'You have unsaved changes in $fileName. Save them before continuing?'; } + @override + String unsavedChangesMultipleMessage(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: + '$count documents have unsaved changes. Save them before continuing?', + one: '1 document has unsaved changes. Save it before continuing?', + ); + return '$_temp0'; + } + @override String get fileChangedOnDisk => 'File changed on disk'; @@ -1413,6 +1429,10 @@ class AppLocalizationsEn extends AppLocalizations { String get workspaceReplaceIssueNormalizationRequired => 'Choose LF or CRLF normalization before replacing.'; + @override + String get workspaceReplaceIssueApplyFailed => + 'The reviewed replacement could not be committed; no files were changed.'; + @override String externalChangesTitle(String fileName) { return 'External changes — $fileName'; @@ -1425,6 +1445,11 @@ class AppLocalizationsEn extends AppLocalizations { String get externalFileChanged => 'This file changed on disk while you have unsaved edits.'; + @override + String recoveredDocumentReview(String fileName) { + return 'Recovered unsaved content for $fileName. Inspect it, then save, save as, or discard it.'; + } + @override String get compare => 'Compare'; @@ -1574,6 +1599,32 @@ class AppLocalizationsEn extends AppLocalizations { return 'Validation failed: $error'; } + @override + String workspaceRecoveryRestored(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: + 'Recovered $count unsaved documents. Review each one before saving or discarding it.', + one: + 'Recovered 1 unsaved document. Review it before saving or discarding it.', + ); + return '$_temp0'; + } + + @override + String workspaceRecoveryDamaged(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: + '$count damaged recovery records could not be restored. Valid recovery records remain available.', + one: + 'One damaged recovery record could not be restored. The original recovery file was preserved for inspection.', + ); + return '$_temp0'; + } + @override String errorPathDoesNotExist(String path) { return 'Path does not exist: $path'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index a7798a26..28b08dac 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -229,6 +229,10 @@ class AppLocalizationsEs extends AppLocalizations { @override String get commandPaletteEmpty => 'No hay comandos coincidentes'; + @override + String get commandUnavailableInContext => + 'Este comando no está disponible en el contexto actual.'; + @override String get lightTheme => 'Claro'; @@ -778,6 +782,11 @@ class AppLocalizationsEs extends AppLocalizations { return 'Hay cambios sin guardar en $fileName. ¿Guardarlos antes de continuar?'; } + @override + String unsavedChangesMultipleMessage(int count) { + return 'Hay $count documentos con cambios sin guardar. ¿Desea guardarlos antes de continuar?'; + } + @override String get fileChangedOnDisk => 'Archivo modificado en disco'; @@ -1434,6 +1443,10 @@ class AppLocalizationsEs extends AppLocalizations { String get workspaceReplaceIssueNormalizationRequired => 'Elige la normalización LF o CRLF antes de reemplazar.'; + @override + String get workspaceReplaceIssueApplyFailed => + 'No se aplicó ningún reemplazo porque el conjunto revisado no pudo guardarse de forma segura.'; + @override String externalChangesTitle(String fileName) { return 'Cambios externos — $fileName'; @@ -1446,6 +1459,11 @@ class AppLocalizationsEs extends AppLocalizations { String get externalFileChanged => 'Este archivo cambió en el disco mientras tienes cambios sin guardar.'; + @override + String recoveredDocumentReview(String fileName) { + return 'Se recuperó contenido sin guardar de $fileName. Revíselo y después guárdelo, use Guardar como o descártelo.'; + } + @override String get compare => 'Comparar'; @@ -1596,6 +1614,16 @@ class AppLocalizationsEs extends AppLocalizations { return 'Error de validación: $error'; } + @override + String workspaceRecoveryRestored(int count) { + return 'Se recuperaron $count documentos sin guardar. Revise cada documento recuperado antes de continuar.'; + } + + @override + String workspaceRecoveryDamaged(int count) { + return 'No se pudieron restaurar $count registros de recuperación dañados. Los documentos recuperados válidos siguen disponibles.'; + } + @override String errorPathDoesNotExist(String path) { return 'La ruta no existe: $path'; diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index 60246992..c65181dd 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -226,6 +226,10 @@ class AppLocalizationsEt extends AppLocalizations { @override String get commandPaletteEmpty => 'Sobivaid käske pole'; + @override + String get commandUnavailableInContext => + 'See käsk pole praeguses kontekstis saadaval.'; + @override String get lightTheme => 'Hele'; @@ -767,6 +771,11 @@ class AppLocalizationsEt extends AppLocalizations { return 'Failis $fileName on salvestamata muudatusi. Kas salvestada need enne jätkamist?'; } + @override + String unsavedChangesMultipleMessage(int count) { + return '$count dokumendis on salvestamata muudatusi. Kas salvestada need enne jätkamist?'; + } + @override String get fileChangedOnDisk => 'Faili on kettal muudetud'; @@ -1416,6 +1425,10 @@ class AppLocalizationsEt extends AppLocalizations { String get workspaceReplaceIssueNormalizationRequired => 'Vali enne asendamist LF- või CRLF-normaliseerimine.'; + @override + String get workspaceReplaceIssueApplyFailed => + 'Asendusi ei rakendatud, sest läbivaadatud kogumit ei saanud turvaliselt salvestada.'; + @override String externalChangesTitle(String fileName) { return 'Välised muudatused — $fileName'; @@ -1428,6 +1441,11 @@ class AppLocalizationsEt extends AppLocalizations { String get externalFileChanged => 'See fail muutus kettal ajal, kui sul on salvestamata muudatusi.'; + @override + String recoveredDocumentReview(String fileName) { + return 'Faili $fileName salvestamata sisu taastati. Vaadake see üle ning salvestage, salvestage nimega või hüljake.'; + } + @override String get compare => 'Võrdle'; @@ -1578,6 +1596,16 @@ class AppLocalizationsEt extends AppLocalizations { return 'Valideerimine nurjus: $error'; } + @override + String workspaceRecoveryRestored(int count) { + return 'Taastati $count salvestamata dokumenti. Vaadake iga taastatud dokument enne jätkamist üle.'; + } + + @override + String workspaceRecoveryDamaged(int count) { + return '$count rikutud taastekirjet ei saanud taastada. Kehtivad taastatud dokumendid on endiselt saadaval.'; + } + @override String errorPathDoesNotExist(String path) { return 'Teed pole olemas: $path'; diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index 12e75616..74bb78f2 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -226,6 +226,10 @@ class AppLocalizationsFa extends AppLocalizations { @override String get commandPaletteEmpty => 'هیچ فرمان منطبقی وجود ندارد'; + @override + String get commandUnavailableInContext => + 'این فرمان در زمینهٔ فعلی در دسترس نیست.'; + @override String get lightTheme => 'روشن'; @@ -763,6 +767,11 @@ class AppLocalizationsFa extends AppLocalizations { return 'در ⁨$fileName⁩ تغییرات ذخیره‌نشده دارید. قبل از ادامه ذخیره شوند؟'; } + @override + String unsavedChangesMultipleMessage(int count) { + return 'تعداد $count سند تغییرات ذخیره‌نشده دارند. پیش از ادامه ذخیره شوند؟'; + } + @override String get fileChangedOnDisk => 'فایل روی دیسک تغییر کرده است'; @@ -1454,6 +1463,10 @@ class AppLocalizationsFa extends AppLocalizations { String get workspaceReplaceIssueNormalizationRequired => 'پیش از جایگزینی، یکسان‌سازی LF یا CRLF را انتخاب کنید.'; + @override + String get workspaceReplaceIssueApplyFailed => + 'هیچ جایگزینی اعمال نشد، زیرا مجموعهٔ بازبینی‌شده را نمی‌شد با ایمنی ذخیره کرد.'; + @override String externalChangesTitle(String fileName) { return 'تغییرات بیرونی — ⁨$fileName⁩'; @@ -1466,6 +1479,11 @@ class AppLocalizationsFa extends AppLocalizations { String get externalFileChanged => 'هنگامی که تغییرات ذخیره‌نشده داشتید، این فایل روی دیسک تغییر کرد.'; + @override + String recoveredDocumentReview(String fileName) { + return 'محتوای ذخیره‌نشدهٔ $fileName بازیابی شده است. آن را بررسی کنید و سپس ذخیره، ذخیره با نام یا رد کنید.'; + } + @override String get compare => 'مقایسه'; @@ -1620,6 +1638,16 @@ class AppLocalizationsFa extends AppLocalizations { return 'اعتبارسنجی ناموفق بود: ⁨$error⁩'; } + @override + String workspaceRecoveryRestored(int count) { + return 'تعداد $count سند ذخیره‌نشده بازیابی شد. پیش از ادامه هر سند بازیابی‌شده را بررسی کنید.'; + } + + @override + String workspaceRecoveryDamaged(int count) { + return 'تعداد $count رکورد بازیابی آسیب‌دیده قابل بازیابی نبود. سندهای معتبر بازیابی‌شده همچنان در دسترس‌اند.'; + } + @override String errorPathDoesNotExist(String path) { return 'مسیر وجود ندارد: ⁨$path⁩'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 40a5b812..cf150f4d 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -229,6 +229,10 @@ class AppLocalizationsFr extends AppLocalizations { @override String get commandPaletteEmpty => 'Aucune commande correspondante'; + @override + String get commandUnavailableInContext => + 'Cette commande n’est pas disponible dans le contexte actuel.'; + @override String get lightTheme => 'Clair'; @@ -777,6 +781,11 @@ class AppLocalizationsFr extends AppLocalizations { return 'Des modifications non enregistrées sont présentes dans $fileName. Les enregistrer avant de continuer ?'; } + @override + String unsavedChangesMultipleMessage(int count) { + return '$count documents contiennent des modifications non enregistrées. Les enregistrer avant de continuer ?'; + } + @override String get fileChangedOnDisk => 'Fichier modifié sur le disque'; @@ -1435,6 +1444,10 @@ class AppLocalizationsFr extends AppLocalizations { String get workspaceReplaceIssueNormalizationRequired => 'Choisissez la normalisation LF ou CRLF avant le remplacement.'; + @override + String get workspaceReplaceIssueApplyFailed => + 'Aucun remplacement n’a été appliqué, car l’ensemble vérifié n’a pas pu être enregistré en toute sécurité.'; + @override String externalChangesTitle(String fileName) { return 'Modifications externes — $fileName'; @@ -1447,6 +1460,11 @@ class AppLocalizationsFr extends AppLocalizations { String get externalFileChanged => 'Ce fichier a été modifié sur le disque alors que vous avez des modifications non enregistrées.'; + @override + String recoveredDocumentReview(String fileName) { + return 'Le contenu non enregistré de $fileName a été récupéré. Vérifiez-le, puis enregistrez-le, enregistrez-le sous un autre nom ou ignorez-le.'; + } + @override String get compare => 'Comparer'; @@ -1596,6 +1614,16 @@ class AppLocalizationsFr extends AppLocalizations { return 'Échec de la validation : $error'; } + @override + String workspaceRecoveryRestored(int count) { + return '$count documents non enregistrés ont été récupérés. Vérifiez chaque document récupéré avant de continuer.'; + } + + @override + String workspaceRecoveryDamaged(int count) { + return '$count enregistrements de récupération endommagés n’ont pas pu être restaurés. Les documents valides récupérés restent disponibles.'; + } + @override String errorPathDoesNotExist(String path) { return 'Le chemin n’existe pas : $path'; diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index dad8776a..cdf11328 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -228,6 +228,10 @@ class AppLocalizationsHi extends AppLocalizations { @override String get commandPaletteEmpty => 'कोई मेल खाता कमांड नहीं'; + @override + String get commandUnavailableInContext => + 'यह आदेश वर्तमान संदर्भ में उपलब्ध नहीं है।'; + @override String get lightTheme => 'लाइट'; @@ -759,6 +763,11 @@ class AppLocalizationsHi extends AppLocalizations { return '$fileName में न सहेजे गए बदलाव हैं। जारी रखने से पहले उन्हें सहेजें?'; } + @override + String unsavedChangesMultipleMessage(int count) { + return '$count दस्तावेज़ों में सहेजे नहीं गए बदलाव हैं। जारी रखने से पहले इन्हें सहेजें?'; + } + @override String get fileChangedOnDisk => 'डिस्क पर फ़ाइल बदल गई'; @@ -1410,6 +1419,10 @@ class AppLocalizationsHi extends AppLocalizations { String get workspaceReplaceIssueNormalizationRequired => 'बदलने से पहले LF या CRLF सामान्यीकरण चुनें।'; + @override + String get workspaceReplaceIssueApplyFailed => + 'कोई प्रतिस्थापन लागू नहीं किया गया क्योंकि समीक्षा किए गए समूह को सुरक्षित रूप से सहेजा नहीं जा सका।'; + @override String externalChangesTitle(String fileName) { return 'बाहरी बदलाव — $fileName'; @@ -1422,6 +1435,11 @@ class AppLocalizationsHi extends AppLocalizations { String get externalFileChanged => 'आपके सहेजे न गए बदलावों के दौरान यह फ़ाइल डिस्क पर बदल गई।'; + @override + String recoveredDocumentReview(String fileName) { + return '$fileName की सहेजी नहीं गई सामग्री पुनर्प्राप्त हुई। इसकी समीक्षा करें, फिर सहेजें, इस रूप में सहेजें या छोड़ दें।'; + } + @override String get compare => 'तुलना करें'; @@ -1571,6 +1589,16 @@ class AppLocalizationsHi extends AppLocalizations { return 'सत्यापन विफल रहा: $error'; } + @override + String workspaceRecoveryRestored(int count) { + return '$count सहेजे नहीं गए दस्तावेज़ पुनर्प्राप्त किए गए। जारी रखने से पहले प्रत्येक पुनर्प्राप्त दस्तावेज़ की समीक्षा करें।'; + } + + @override + String workspaceRecoveryDamaged(int count) { + return '$count क्षतिग्रस्त पुनर्प्राप्ति रिकॉर्ड बहाल नहीं किए जा सके। मान्य पुनर्प्राप्त दस्तावेज़ उपलब्ध हैं।'; + } + @override String errorPathDoesNotExist(String path) { return 'पाथ मौजूद नहीं है: $path'; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index d14a0b65..fe4e0fc2 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -228,6 +228,10 @@ class AppLocalizationsIt extends AppLocalizations { @override String get commandPaletteEmpty => 'Nessun comando corrispondente'; + @override + String get commandUnavailableInContext => + 'Questo comando non è disponibile nel contesto corrente.'; + @override String get lightTheme => 'Chiaro'; @@ -774,6 +778,11 @@ class AppLocalizationsIt extends AppLocalizations { return 'Sono presenti modifiche non salvate in $fileName. Salvarle prima di continuare?'; } + @override + String unsavedChangesMultipleMessage(int count) { + return '$count documenti contengono modifiche non salvate. Salvarli prima di continuare?'; + } + @override String get fileChangedOnDisk => 'File modificato su disco'; @@ -1430,6 +1439,10 @@ class AppLocalizationsIt extends AppLocalizations { String get workspaceReplaceIssueNormalizationRequired => 'Scegli la normalizzazione LF o CRLF prima di sostituire.'; + @override + String get workspaceReplaceIssueApplyFailed => + 'Non è stata applicata alcuna sostituzione perché non è stato possibile salvare in sicurezza l’insieme verificato.'; + @override String externalChangesTitle(String fileName) { return 'Modifiche esterne — $fileName'; @@ -1442,6 +1455,11 @@ class AppLocalizationsIt extends AppLocalizations { String get externalFileChanged => 'Questo file è cambiato sul disco mentre sono presenti modifiche non salvate.'; + @override + String recoveredDocumentReview(String fileName) { + return 'È stato recuperato il contenuto non salvato di $fileName. Controllarlo, quindi salvarlo, salvarlo con nome o eliminarlo.'; + } + @override String get compare => 'Confronta'; @@ -1592,6 +1610,16 @@ class AppLocalizationsIt extends AppLocalizations { return 'Convalida non riuscita: $error'; } + @override + String workspaceRecoveryRestored(int count) { + return 'Sono stati recuperati $count documenti non salvati. Controllare ogni documento recuperato prima di continuare.'; + } + + @override + String workspaceRecoveryDamaged(int count) { + return 'Non è stato possibile ripristinare $count record di recupero danneggiati. I documenti recuperati validi restano disponibili.'; + } + @override String errorPathDoesNotExist(String path) { return 'Il percorso non esiste: $path'; diff --git a/lib/l10n/generated/app_localizations_nb.dart b/lib/l10n/generated/app_localizations_nb.dart index 80e16148..f8b5392e 100644 --- a/lib/l10n/generated/app_localizations_nb.dart +++ b/lib/l10n/generated/app_localizations_nb.dart @@ -229,6 +229,10 @@ class AppLocalizationsNb extends AppLocalizations { @override String get commandPaletteEmpty => 'Ingen samsvarende kommandoer'; + @override + String get commandUnavailableInContext => + 'Denne kommandoen er ikke tilgjengelig i gjeldende kontekst.'; + @override String get lightTheme => 'Lys'; @@ -766,6 +770,11 @@ class AppLocalizationsNb extends AppLocalizations { return 'Du har ulagrede endringer i $fileName. Vil du lagre dem før du fortsetter?'; } + @override + String unsavedChangesMultipleMessage(int count) { + return '$count dokumenter har ulagrede endringer. Lagre dem før du fortsetter?'; + } + @override String get fileChangedOnDisk => 'Fil endret på disken'; @@ -1420,6 +1429,10 @@ class AppLocalizationsNb extends AppLocalizations { String get workspaceReplaceIssueNormalizationRequired => 'Velg LF- eller CRLF-normalisering før du erstatter.'; + @override + String get workspaceReplaceIssueApplyFailed => + 'Ingen erstatninger ble utført fordi det gjennomgåtte settet ikke kunne lagres på en trygg måte.'; + @override String externalChangesTitle(String fileName) { return 'Eksterne endringer — $fileName'; @@ -1432,6 +1445,11 @@ class AppLocalizationsNb extends AppLocalizations { String get externalFileChanged => 'Denne filen ble endret på disken mens du har ulagrede endringer.'; + @override + String recoveredDocumentReview(String fileName) { + return 'Ulagret innhold for $fileName ble gjenopprettet. Se gjennom det, og lagre, lagre som eller forkast det.'; + } + @override String get compare => 'Sammenlign'; @@ -1581,6 +1599,16 @@ class AppLocalizationsNb extends AppLocalizations { return 'Validering mislyktes: $error'; } + @override + String workspaceRecoveryRestored(int count) { + return '$count ulagrede dokumenter ble gjenopprettet. Se gjennom hvert gjenopprettet dokument før du fortsetter.'; + } + + @override + String workspaceRecoveryDamaged(int count) { + return '$count skadede gjenopprettingsoppføringer kunne ikke gjenopprettes. Gyldige gjenopprettede dokumenter er fortsatt tilgjengelige.'; + } + @override String errorPathDoesNotExist(String path) { return 'Stien finnes ikke: $path'; diff --git a/lib/l10n/generated/app_localizations_pl.dart b/lib/l10n/generated/app_localizations_pl.dart index cc2ee0cd..1d0295c8 100644 --- a/lib/l10n/generated/app_localizations_pl.dart +++ b/lib/l10n/generated/app_localizations_pl.dart @@ -228,6 +228,10 @@ class AppLocalizationsPl extends AppLocalizations { @override String get commandPaletteEmpty => 'Brak pasujących poleceń'; + @override + String get commandUnavailableInContext => + 'To polecenie nie jest dostępne w bieżącym kontekście.'; + @override String get lightTheme => 'Jasny'; @@ -778,6 +782,11 @@ class AppLocalizationsPl extends AppLocalizations { return 'Masz niezapisane zmiany w $fileName. Zapisać je przed kontynuowaniem?'; } + @override + String unsavedChangesMultipleMessage(int count) { + return '$count dokumenty zawierają niezapisane zmiany. Zapisać je przed kontynuowaniem?'; + } + @override String get fileChangedOnDisk => 'Plik zmieniony na dysku'; @@ -1439,6 +1448,10 @@ class AppLocalizationsPl extends AppLocalizations { String get workspaceReplaceIssueNormalizationRequired => 'Przed zamianą wybierz normalizację LF lub CRLF.'; + @override + String get workspaceReplaceIssueApplyFailed => + 'Nie zastosowano żadnych zamian, ponieważ sprawdzonego zestawu nie można było bezpiecznie zapisać.'; + @override String externalChangesTitle(String fileName) { return 'Zmiany zewnętrzne — $fileName'; @@ -1451,6 +1464,11 @@ class AppLocalizationsPl extends AppLocalizations { String get externalFileChanged => 'Ten plik zmienił się na dysku, gdy masz niezapisane zmiany.'; + @override + String recoveredDocumentReview(String fileName) { + return 'Odzyskano niezapisaną treść pliku $fileName. Przejrzyj ją, a następnie zapisz, zapisz jako lub odrzuć.'; + } + @override String get compare => 'Porównaj'; @@ -1601,6 +1619,16 @@ class AppLocalizationsPl extends AppLocalizations { return 'Sprawdzanie poprawności nie powiodło się: $error'; } + @override + String workspaceRecoveryRestored(int count) { + return 'Odzyskano $count niezapisanych dokumentów. Przejrzyj każdy odzyskany dokument przed kontynuowaniem.'; + } + + @override + String workspaceRecoveryDamaged(int count) { + return 'Nie udało się przywrócić $count uszkodzonych rekordów odzyskiwania. Prawidłowe odzyskane dokumenty są nadal dostępne.'; + } + @override String errorPathDoesNotExist(String path) { return 'Ścieżka nie istnieje: $path'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 95120332..365022b8 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -229,6 +229,10 @@ class AppLocalizationsPt extends AppLocalizations { @override String get commandPaletteEmpty => 'Nenhum comando correspondente'; + @override + String get commandUnavailableInContext => + 'Este comando não está disponível no contexto atual.'; + @override String get lightTheme => 'Claro'; @@ -774,6 +778,11 @@ class AppLocalizationsPt extends AppLocalizations { return 'Você tem alterações não salvas em $fileName. Salvá-las antes de continuar?'; } + @override + String unsavedChangesMultipleMessage(int count) { + return 'Há $count documentos com alterações não salvas. Deseja salvá-los antes de continuar?'; + } + @override String get fileChangedOnDisk => 'Arquivo alterado no disco'; @@ -1429,6 +1438,10 @@ class AppLocalizationsPt extends AppLocalizations { String get workspaceReplaceIssueNormalizationRequired => 'Escolha a normalização LF ou CRLF antes de substituir.'; + @override + String get workspaceReplaceIssueApplyFailed => + 'Nenhuma substituição foi aplicada porque o conjunto revisado não pôde ser salvo com segurança.'; + @override String externalChangesTitle(String fileName) { return 'Alterações externas — $fileName'; @@ -1441,6 +1454,11 @@ class AppLocalizationsPt extends AppLocalizations { String get externalFileChanged => 'Este arquivo mudou no disco enquanto você tem alterações não salvas.'; + @override + String recoveredDocumentReview(String fileName) { + return 'O conteúdo não salvo de $fileName foi recuperado. Revise-o e depois salve, salve como ou descarte-o.'; + } + @override String get compare => 'Comparar'; @@ -1591,6 +1609,16 @@ class AppLocalizationsPt extends AppLocalizations { return 'Falha na validação: $error'; } + @override + String workspaceRecoveryRestored(int count) { + return 'Foram recuperados $count documentos não salvos. Revise cada documento recuperado antes de continuar.'; + } + + @override + String workspaceRecoveryDamaged(int count) { + return 'Não foi possível restaurar $count registros de recuperação danificados. Os documentos recuperados válidos continuam disponíveis.'; + } + @override String errorPathDoesNotExist(String path) { return 'O caminho não existe: $path'; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 811f8b0a..30643fac 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -229,6 +229,10 @@ class AppLocalizationsRu extends AppLocalizations { @override String get commandPaletteEmpty => 'Нет подходящих команд'; + @override + String get commandUnavailableInContext => + 'Эта команда недоступна в текущем контексте.'; + @override String get lightTheme => 'Светлая'; @@ -773,6 +777,11 @@ class AppLocalizationsRu extends AppLocalizations { return 'В файле $fileName есть несохранённые изменения. Сохранить их перед продолжением?'; } + @override + String unsavedChangesMultipleMessage(int count) { + return 'В документах ($count) есть несохранённые изменения. Сохранить их перед продолжением?'; + } + @override String get fileChangedOnDisk => 'Файл изменён на диске'; @@ -1433,6 +1442,10 @@ class AppLocalizationsRu extends AppLocalizations { String get workspaceReplaceIssueNormalizationRequired => 'Перед заменой выберите нормализацию LF или CRLF.'; + @override + String get workspaceReplaceIssueApplyFailed => + 'Замены не применены, поскольку проверенный набор не удалось безопасно сохранить.'; + @override String externalChangesTitle(String fileName) { return 'Внешние изменения — $fileName'; @@ -1445,6 +1458,11 @@ class AppLocalizationsRu extends AppLocalizations { String get externalFileChanged => 'Этот файл изменился на диске, пока у вас были несохранённые изменения.'; + @override + String recoveredDocumentReview(String fileName) { + return 'Восстановлено несохранённое содержимое файла $fileName. Проверьте его, затем сохраните, сохраните как новый файл или отбросьте.'; + } + @override String get compare => 'Сравнить'; @@ -1594,6 +1612,16 @@ class AppLocalizationsRu extends AppLocalizations { return 'Проверка не удалась: $error'; } + @override + String workspaceRecoveryRestored(int count) { + return 'Восстановлено несохранённых документов: $count. Проверьте каждый восстановленный документ перед продолжением.'; + } + + @override + String workspaceRecoveryDamaged(int count) { + return 'Не удалось восстановить повреждённые записи ($count). Корректные восстановленные документы остаются доступными.'; + } + @override String errorPathDoesNotExist(String path) { return 'Путь не существует: $path'; diff --git a/lib/l10n/generated/app_localizations_uk.dart b/lib/l10n/generated/app_localizations_uk.dart index 88f06ffc..72ef9550 100644 --- a/lib/l10n/generated/app_localizations_uk.dart +++ b/lib/l10n/generated/app_localizations_uk.dart @@ -228,6 +228,10 @@ class AppLocalizationsUk extends AppLocalizations { @override String get commandPaletteEmpty => 'Немає відповідних команд'; + @override + String get commandUnavailableInContext => + 'Ця команда недоступна в поточному контексті.'; + @override String get lightTheme => 'Світла'; @@ -778,6 +782,11 @@ class AppLocalizationsUk extends AppLocalizations { return '$fileName містить незбережені зміни. Зберегти їх перед продовженням?'; } + @override + String unsavedChangesMultipleMessage(int count) { + return 'У документах ($count) є незбережені зміни. Зберегти їх перед продовженням?'; + } + @override String get fileChangedOnDisk => 'Файл змінено на диску'; @@ -1441,6 +1450,10 @@ class AppLocalizationsUk extends AppLocalizations { String get workspaceReplaceIssueNormalizationRequired => 'Перед заміною виберіть нормалізацію LF або CRLF.'; + @override + String get workspaceReplaceIssueApplyFailed => + 'Заміни не застосовано, оскільки перевірений набір не вдалося безпечно зберегти.'; + @override String externalChangesTitle(String fileName) { return 'Зовнішні зміни — $fileName'; @@ -1453,6 +1466,11 @@ class AppLocalizationsUk extends AppLocalizations { String get externalFileChanged => 'Цей файл змінився на диску, поки у вас були незбережені зміни.'; + @override + String recoveredDocumentReview(String fileName) { + return 'Відновлено незбережений вміст файлу $fileName. Перегляньте його, потім збережіть, збережіть як новий файл або відкиньте.'; + } + @override String get compare => 'Порівняти'; @@ -1602,6 +1620,16 @@ class AppLocalizationsUk extends AppLocalizations { return 'Помилка перевірки: $error'; } + @override + String workspaceRecoveryRestored(int count) { + return 'Відновлено незбережених документів: $count. Перегляньте кожен відновлений документ перед продовженням.'; + } + + @override + String workspaceRecoveryDamaged(int count) { + return 'Не вдалося відновити пошкоджені записи ($count). Коректні відновлені документи залишаються доступними.'; + } + @override String errorPathDoesNotExist(String path) { return 'Шлях не існує: $path'; diff --git a/lib/src/app/busymark_app.dart b/lib/src/app/busymark_app.dart index 8776e360..e1ceeb44 100644 --- a/lib/src/app/busymark_app.dart +++ b/lib/src/app/busymark_app.dart @@ -21,7 +21,6 @@ import '../workspace/workspace_tabs.dart'; import 'app_router.dart'; import 'app_locale.dart'; import 'app_settings.dart'; -import 'busymark_shortcuts.dart'; import 'command_palette.dart'; import 'command_registry.dart'; import 'app_theme.dart'; @@ -32,6 +31,60 @@ import 'localization.dart'; import 'system_accent.dart'; import 'window_control_service.dart'; +final busyMarkCommandRegistryProvider = Provider(( + ref, +) { + final commandIntents = { + BusyMarkCommandIds.newDocument: const _NewWorkspaceIntent(), + BusyMarkCommandIds.open: const _OpenWorkspaceIntent(), + BusyMarkCommandIds.save: const _SaveActiveIntent(), + BusyMarkCommandIds.exportPdf: const _ExportPdfIntent(), + BusyMarkCommandIds.fullScreen: const _ToggleFullScreenIntent(), + BusyMarkCommandIds.back: const _BackIntent(), + BusyMarkCommandIds.search: const _OpenSearchIntent(), + BusyMarkCommandIds.keyboardShortcuts: const _KeyboardShortcutsIntent(), + BusyMarkCommandIds.commandPalette: const _CommandPaletteIntent(), + BusyMarkCommandIds.markdownAndHtml: const _MarkdownAndHtmlIntent(), + BusyMarkCommandIds.settings: const _SettingsIntent(), + BusyMarkCommandIds.nextTab: const _NextTabIntent(), + BusyMarkCommandIds.previousTab: const _PreviousTabIntent(), + BusyMarkCommandIds.closeTab: const _CloseTabIntent(), + BusyMarkCommandIds.closeAllTabs: const _CloseAllTabsIntent(), + BusyMarkCommandIds.toggleSidebar: const _ToggleSidebarIntent(), + BusyMarkCommandIds.viewEditor: const _DocumentViewModeIntent( + DocumentViewModePreference.editor, + ), + BusyMarkCommandIds.viewSource: const _DocumentViewModeIntent( + DocumentViewModePreference.source, + ), + BusyMarkCommandIds.viewReading: const _DocumentViewModeIntent( + DocumentViewModePreference.preview, + ), + BusyMarkCommandIds.viewSplit: const _DocumentViewModeIntent( + DocumentViewModePreference.split, + ), + }; + return BusyMarkCommandCatalog.create( + executions: { + for (final entry in commandIntents.entries) + entry.key: () { + final target = rootNavigatorKey.currentContext; + if (target != null) { + Actions.maybeInvoke(target, entry.value); + } + }, + }, + enabled: { + BusyMarkCommandIds.save: () => + ref.read(workspaceControllerProvider).workspace != null, + BusyMarkCommandIds.exportPdf: () => + canExportWorkspacePdf(ref.read(workspaceControllerProvider)), + BusyMarkCommandIds.search: () => + ref.read(workspaceControllerProvider).workspace != null, + }, + ); +}); + class BusyMarkApp extends ConsumerWidget { const BusyMarkApp({super.key}); @@ -84,63 +137,14 @@ class BusyMarkApp extends ConsumerWidget { ], supportedLocales: AppLocalizations.supportedLocales, builder: (context, child) { - final commandIntents = { - BusyMarkCommandIds.newDocument: const _NewWorkspaceIntent(), - BusyMarkCommandIds.open: const _OpenWorkspaceIntent(), - BusyMarkCommandIds.save: const _SaveActiveIntent(), - BusyMarkCommandIds.exportPdf: const _ExportPdfIntent(), - BusyMarkCommandIds.fullScreen: const _ToggleFullScreenIntent(), - BusyMarkCommandIds.back: const _BackIntent(), - BusyMarkCommandIds.search: const _OpenSearchIntent(), - BusyMarkCommandIds.keyboardShortcuts: - const _KeyboardShortcutsIntent(), - BusyMarkCommandIds.commandPalette: const _CommandPaletteIntent(), - BusyMarkCommandIds.markdownAndHtml: const _MarkdownAndHtmlIntent(), - BusyMarkCommandIds.settings: const _SettingsIntent(), - BusyMarkCommandIds.nextTab: const _NextTabIntent(), - BusyMarkCommandIds.previousTab: const _PreviousTabIntent(), - BusyMarkCommandIds.closeTab: const _CloseTabIntent(), - BusyMarkCommandIds.closeAllTabs: const _CloseAllTabsIntent(), - BusyMarkCommandIds.toggleSidebar: const _ToggleSidebarIntent(), - BusyMarkCommandIds.viewEditor: const _DocumentViewModeIntent( - DocumentViewModePreference.editor, - ), - BusyMarkCommandIds.viewSource: const _DocumentViewModeIntent( - DocumentViewModePreference.source, - ), - BusyMarkCommandIds.viewReading: const _DocumentViewModeIntent( - DocumentViewModePreference.preview, - ), - BusyMarkCommandIds.viewSplit: const _DocumentViewModeIntent( - DocumentViewModePreference.split, - ), - }; - final commandRegistry = BusyMarkCommandCatalog.create( - executions: { - for (final entry in commandIntents.entries) - entry.key: () { - final target = rootNavigatorKey.currentContext; - if (target != null) { - Actions.maybeInvoke(target, entry.value); - } - }, - }, - enabled: { - BusyMarkCommandIds.save: () => - ref.read(workspaceControllerProvider).workspace != null, - BusyMarkCommandIds.exportPdf: () => - canExportWorkspacePdf(ref.read(workspaceControllerProvider)), - BusyMarkCommandIds.search: () => - ref.read(workspaceControllerProvider).workspace != null, - }, - ); + final commandRegistry = ref.read(busyMarkCommandRegistryProvider); final headerBarDefaults = _nativeHeaderBarDefaults( context, settings, commandRegistry, fullScreen: windowControls.isFullScreen, ); - return HeaderBarConfigurationDefaults( + final appContent = HeaderBarConfigurationDefaults( configuration: headerBarDefaults, child: _BusyMarkWindowLifecycle( child: Shortcuts( @@ -369,6 +373,10 @@ class BusyMarkApp extends ConsumerWidget { ), ), ); + return BusyMarkCommandRegistryScope( + registry: commandRegistry, + child: appContent, + ); }, routerConfig: router, ); @@ -639,7 +647,8 @@ class BusyMarkApp extends ConsumerWidget { final activeTab = tabs[activeIndex]; if (activeTab.kind == WorkspaceTabKind.file) { if (activeTab.dirty && - (!await confirmSafeToContinue(context, ref) || !context.mounted)) { + (!await confirmSafeToCloseActiveDocument(context, ref) || + !context.mounted)) { return; } } @@ -662,8 +671,7 @@ class BusyMarkApp extends ConsumerWidget { ).isEmpty) { return; } - if (!await saveOrConfirmSafeToChangeActiveFile(context, ref) || - !context.mounted) { + if (!await confirmSafeToContinue(context, ref) || !context.mounted) { return; } await ref.read(workspaceControllerProvider.notifier).closeAllOpenFileTabs(); @@ -1021,14 +1029,21 @@ class _BusyMarkSearchShortcutHandlerState return false; } final keyboard = HardwareKeyboard.instance; - if (BusyMarkAppShortcutActivators.search.accepts(event, keyboard)) { + final commands = + BusyMarkCommandRegistryScope.read(context) ?? + BusyMarkCommandCatalog.metadata; + if (commands.shortcutAccepts(BusyMarkCommandIds.search, event, keyboard)) { if (rootNavigatorKey.currentState?.canPop() ?? false) { return false; } ref.read(workspaceSearchOpenRequestProvider.notifier).request(); return true; } - if (BusyMarkTextEditingShortcutActivators.escape.accepts(event, keyboard)) { + if (commands.shortcutAccepts( + BusyMarkCommandIds.textEscape, + event, + keyboard, + )) { if (rootNavigatorKey.currentState?.canPop() ?? false) { return false; } diff --git a/lib/src/app/busymark_dialogs.dart b/lib/src/app/busymark_dialogs.dart index 7b49d80c..98629078 100644 --- a/lib/src/app/busymark_dialogs.dart +++ b/lib/src/app/busymark_dialogs.dart @@ -36,16 +36,16 @@ class _DismissBusyMarkModalIntent extends Intent { } final _busyMarkModalShortcuts = { - for (final shortcut in BusyMarkAppShortcuts.definitions.values) - shortcut.activator: const DoNothingAndStopPropagationIntent(), - for (final shortcut in BusyMarkDocumentViewShortcuts.definitions.values) - shortcut.activator: const DoNothingAndStopPropagationIntent(), - for (final entry in BusyMarkEditorShortcuts.definitions.entries) - if (entry.key != BusyMarkEditorShortcutAction.pastePlainText) - entry.value.activator: const DoNothingAndStopPropagationIntent(), - for (final shortcut in BusyMarkSidebarShortcuts.definitions.values) - shortcut.activator: const DoNothingAndStopPropagationIntent(), - BusyMarkTextEditingShortcutActivators.escape: + for (final command in BusyMarkCommandCatalog.metadata.commands) + if (command.shortcut != null && + command.id != BusyMarkCommandIds.textPastePlainText && + command.scope != BusyMarkCommandScope.tree && + command.scope != BusyMarkCommandScope.textEditing) + command.shortcut!.activator: const DoNothingAndStopPropagationIntent(), + BusyMarkCommandCatalog + .metadata[BusyMarkCommandIds.textEscape]! + .shortcut! + .activator: const _DismissBusyMarkModalIntent(), }; @@ -368,7 +368,9 @@ Future _openApacheLicense() async { } void showBusyMarkKeyboardShortcutsDialog(BuildContext context) { - final registry = BusyMarkCommandCatalog.create(); + final registry = + BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog.metadata; final headerBar = LinuxHeaderBarService.instance; unawaited( showBusyMarkModalDialog( diff --git a/lib/src/app/busymark_main_menu.dart b/lib/src/app/busymark_main_menu.dart index 46aae2c5..f0adf673 100644 --- a/lib/src/app/busymark_main_menu.dart +++ b/lib/src/app/busymark_main_menu.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -34,7 +36,9 @@ class BusyMarkMainMenuButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final l10n = context.l10n; - final commands = BusyMarkCommandCatalog.create(); + final commands = + BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog.metadata; BusyMarkCommand command(String id) => commands[id]!; final fullScreen = ref.watch( windowControlServiceProvider.select((service) => service.isFullScreen), @@ -103,14 +107,25 @@ class BusyMarkMainMenuButton extends ConsumerWidget { ), ], onSelected: (action) { - if (action == BusyMarkMainMenuAction.commandPalette) { - Actions.maybeInvoke( - context, - const BusyMarkCommandIntent(BusyMarkCommandIds.commandPalette), - ); - return; + final commandId = switch (action) { + BusyMarkMainMenuAction.exportPdf => BusyMarkCommandIds.exportPdf, + BusyMarkMainMenuAction.fullScreen => BusyMarkCommandIds.fullScreen, + BusyMarkMainMenuAction.settings => BusyMarkCommandIds.settings, + BusyMarkMainMenuAction.keyboardShortcuts => + BusyMarkCommandIds.keyboardShortcuts, + BusyMarkMainMenuAction.commandPalette => + BusyMarkCommandIds.commandPalette, + BusyMarkMainMenuAction.markdownAndHtml => + BusyMarkCommandIds.markdownAndHtml, + BusyMarkMainMenuAction.generateMarkdownToc || + BusyMarkMainMenuAction.reportIssue || + BusyMarkMainMenuAction.aboutBusyMark => null, + }; + if (commandId != null && commands[commandId]?.execute != null) { + unawaited(commands.execute(commandId)); + } else { + onSelected(action); } - onSelected(action); }, ); } diff --git a/lib/src/app/command_palette.dart b/lib/src/app/command_palette.dart index 1b7ef3a6..131985f2 100644 --- a/lib/src/app/command_palette.dart +++ b/lib/src/app/command_palette.dart @@ -14,17 +14,25 @@ Future showBusyMarkCommandPalette( BusyMarkCommandRegistry registry, ) async { final headerBar = LinuxHeaderBarService.instance; + final commandTarget = FocusManager.instance.primaryFocus?.context; await showBusyMarkModalDialog( context, headerBarService: headerBar.isAvailable ? headerBar : null, - builder: (context) => _BusyMarkCommandPalette(registry: registry), + builder: (context) => _BusyMarkCommandPalette( + registry: registry, + commandTarget: commandTarget, + ), ); } class _BusyMarkCommandPalette extends StatefulWidget { - const _BusyMarkCommandPalette({required this.registry}); + const _BusyMarkCommandPalette({ + required this.registry, + required this.commandTarget, + }); final BusyMarkCommandRegistry registry; + final BuildContext? commandTarget; @override State<_BusyMarkCommandPalette> createState() => @@ -37,9 +45,6 @@ class _BusyMarkCommandPaletteState extends State<_BusyMarkCommandPalette> { List get _commands { final query = _query.trim().toLowerCase(); return widget.registry.visibleCommands().where((command) { - if (command.execute == null) { - return false; - } if (query.isEmpty) { return true; } @@ -64,7 +69,7 @@ class _BusyMarkCommandPaletteState extends State<_BusyMarkCommandPalette> { autofocus: true, onChanged: (value) => setState(() => _query = value), onSubmitted: (_) { - if (commands.isNotEmpty && commands.first.canExecute) { + if (commands.isNotEmpty && _enabled(commands.first)) { _execute(commands.first); } }, @@ -87,13 +92,16 @@ class _BusyMarkCommandPaletteState extends State<_BusyMarkCommandPalette> { for (final command in commands) BusyMarkActionRow( title: command.label(context), - subtitle: command.category(context), + subtitle: _enabled(command) + ? command.category(context) + : command.disabledReason?.call(context) ?? + command.category(context), leading: const Icon(BusyMarkGlyphs.search), trailing: command.shortcut == null ? null : Text(command.shortcut!.label), - enabled: command.enabled(), - onTap: command.enabled() ? () => _execute(command) : null, + enabled: _enabled(command), + onTap: _enabled(command) ? () => _execute(command) : null, ), ], ), @@ -104,7 +112,17 @@ class _BusyMarkCommandPaletteState extends State<_BusyMarkCommandPalette> { void _execute(BusyMarkCommand command) { Navigator.pop(context); unawaited( - Future.microtask(() => widget.registry.execute(command.id)), + Future.microtask( + () => + widget.registry.executeInContext(command.id, widget.commandTarget), + ), + ); + } + + bool _enabled(BusyMarkCommand command) { + return widget.registry.canExecuteInContext( + command.id, + widget.commandTarget, ); } } diff --git a/lib/src/app/command_registry.dart b/lib/src/app/command_registry.dart index f613faed..ca5f745e 100644 --- a/lib/src/app/command_registry.dart +++ b/lib/src/app/command_registry.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'busymark_shortcuts.dart'; import 'localization.dart'; @@ -16,6 +17,34 @@ class BusyMarkCommandIntent extends Intent { final String commandId; } +class BusyMarkContextCommandIntent extends Intent { + const BusyMarkContextCommandIntent(this.commandId); + + final String commandId; +} + +class BusyMarkContextCommandAction + extends Action { + BusyMarkContextCommandAction({ + required this.isCommandEnabled, + required this.onCommand, + }); + + final bool Function(String commandId) isCommandEnabled; + final void Function(String commandId) onCommand; + + @override + bool isEnabled(BusyMarkContextCommandIntent intent) { + return isCommandEnabled(intent.commandId); + } + + @override + Object? invoke(BusyMarkContextCommandIntent intent) { + onCommand(intent.commandId); + return null; + } +} + enum BusyMarkCommandScope { application, documentView, @@ -34,6 +63,7 @@ class BusyMarkCommand { required this.scope, this.shortcut, this.description, + this.disabledReason, this.execute, this.enabled = _always, this.visible = _always, @@ -45,6 +75,7 @@ class BusyMarkCommand { final BusyMarkCommandScope scope; final BusyMarkShortcutDefinition? shortcut; final BusyMarkCommandDescription? description; + final BusyMarkCommandDescription? disabledReason; final BusyMarkCommandCallback? execute; final BusyMarkCommandPredicate enabled; final BusyMarkCommandPredicate visible; @@ -54,6 +85,34 @@ class BusyMarkCommand { static bool _always() => true; } +class BusyMarkCommandRegistryScope extends InheritedWidget { + const BusyMarkCommandRegistryScope({ + super.key, + required this.registry, + required super.child, + }); + + final BusyMarkCommandRegistry registry; + + static BusyMarkCommandRegistry? maybeOf(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType() + ?.registry; + } + + static BusyMarkCommandRegistry? read(BuildContext context) { + final widget = context + .getElementForInheritedWidgetOfExactType() + ?.widget; + return widget is BusyMarkCommandRegistryScope ? widget.registry : null; + } + + @override + bool updateShouldNotify(BusyMarkCommandRegistryScope oldWidget) { + return !identical(registry, oldWidget.registry); + } +} + class BusyMarkCommandRegistryValidationException implements Exception { const BusyMarkCommandRegistryValidationException(this.message); @@ -89,6 +148,56 @@ class BusyMarkCommandRegistry { return true; } + bool canExecuteInContext(String id, BuildContext? contextTarget) { + final command = _byId[id]; + if (command == null || command.execute == null) { + return false; + } + if (!_isContextual(command) || contextTarget == null) { + return command.enabled(); + } + final action = Actions.maybeFind( + contextTarget, + ); + return action?.isEnabled(BusyMarkContextCommandIntent(id)) ?? false; + } + + Future executeInContext(String id, BuildContext? contextTarget) async { + final command = _byId[id]; + if (command == null || !canExecuteInContext(id, contextTarget)) { + return false; + } + if (_isContextual(command) && contextTarget != null) { + Actions.maybeInvoke(contextTarget, BusyMarkContextCommandIntent(id)); + return true; + } + await command.execute!(); + return true; + } + + bool _isContextual(BusyMarkCommand command) { + return command.scope == BusyMarkCommandScope.editor || + command.scope == BusyMarkCommandScope.textEditing; + } + + bool shortcutAccepts(String id, KeyEvent event, HardwareKeyboard keyboard) { + return _byId[id]?.shortcut?.activator.accepts(event, keyboard) ?? false; + } + + String? matchingCommandId( + KeyEvent event, + HardwareKeyboard keyboard, { + required BusyMarkCommandScope scope, + }) { + for (final command in commands) { + if (command.scope == scope && + command.shortcut?.activator.accepts(event, keyboard) == true) { + return command.id; + } + } + return null; + } + Map shortcutIntents({ required Set scopes, required Intent Function(String commandId) intentFor, @@ -156,10 +265,23 @@ abstract final class BusyMarkCommandIds { static const textCut = 'text.cut'; static const textCopy = 'text.copy'; static const textPaste = 'text.paste'; + static const textPastePlainText = 'text.pastePlainText'; + static const textUndo = 'text.undo'; + static const textRedo = 'text.redo'; + static const textInsertIndentation = 'text.insertIndentation'; + static const textOutdentSource = 'text.outdentSource'; + static const textEscape = 'text.escape'; static const editorRefineWithAi = 'editor.refineWithAi'; + static const sidebarFiles = 'sidebar.files'; + static const sidebarToc = 'sidebar.toc'; + static const sidebarOutline = 'sidebar.outline'; + static const sidebarGit = 'sidebar.git'; + static const treeDeleteSelection = 'tree.deleteSelection'; } abstract final class BusyMarkCommandCatalog { + static final BusyMarkCommandRegistry metadata = create(); + static BusyMarkCommandRegistry create({ Map executions = const {}, Map enabled = const {}, @@ -170,9 +292,12 @@ abstract final class BusyMarkCommandCatalog { required BusyMarkCommandLabel label, required BusyMarkCommandLabel category, required BusyMarkCommandScope scope, - required BusyMarkShortcutDefinition shortcut, + BusyMarkShortcutDefinition? shortcut, BusyMarkCommandDescription? description, }) { + final contextual = + scope == BusyMarkCommandScope.editor || + scope == BusyMarkCommandScope.textEditing; return BusyMarkCommand( id: id, label: label, @@ -180,25 +305,32 @@ abstract final class BusyMarkCommandCatalog { scope: scope, shortcut: shortcut, description: description, - execute: executions[id], - enabled: enabled[id] ?? BusyMarkCommand._always, + disabledReason: (context) => context.l10n.commandUnavailableInContext, + execute: + executions[id] ?? + (contextual ? () => _executeContextCommand(id) : null), + enabled: + enabled[id] ?? + (contextual + ? () => _contextCommandAvailable(id) + : BusyMarkCommand._always), visible: visible[id] ?? BusyMarkCommand._always, ); } final commands = [ - for (final entry in BusyMarkAppShortcuts.definitions.entries) + for (final action in BusyMarkAppShortcutAction.values) command( - id: _appId(entry.key), - label: (context) => _appLabel(context, entry.key), - category: (context) => _appCategory(context, entry.key), + id: _appId(action), + label: (context) => _appLabel(context, action), + category: (context) => _appCategory(context, action), scope: BusyMarkCommandScope.application, - shortcut: entry.value, - description: (context) => _appDescription(context, entry.key), + shortcut: BusyMarkAppShortcuts.definitions[action], + description: (context) => _appDescription(context, action), ), - for (final entry in BusyMarkDocumentViewShortcuts.definitions.entries) + for (final action in BusyMarkDocumentViewShortcutAction.values) command( - id: switch (entry.key) { + id: switch (action) { BusyMarkDocumentViewShortcutAction.editor => BusyMarkCommandIds.viewEditor, BusyMarkDocumentViewShortcutAction.source => @@ -208,44 +340,44 @@ abstract final class BusyMarkCommandCatalog { BusyMarkDocumentViewShortcutAction.split => BusyMarkCommandIds.viewSplit, }, - label: (context) => _viewLabel(context, entry.key), + label: (context) => _viewLabel(context, action), category: (context) => context.l10n.viewMode, scope: BusyMarkCommandScope.documentView, - shortcut: entry.value, + shortcut: BusyMarkDocumentViewShortcuts.definitions[action], ), - for (final entry in BusyMarkTextEditingShortcuts.definitions.entries) + for (final action in BusyMarkTextEditingShortcutAction.values) command( - id: 'text.${entry.key.name}', - label: (context) => _textLabel(context, entry.key), + id: 'text.${action.name}', + label: (context) => _textLabel(context, action), category: (context) => context.l10n.shortcutGroupTextEditing, scope: BusyMarkCommandScope.textEditing, - shortcut: entry.value, - description: (context) => _textDescription(context, entry.key), + shortcut: BusyMarkTextEditingShortcuts.definitions[action], + description: (context) => _textDescription(context, action), ), - for (final entry in BusyMarkEditorShortcuts.definitions.entries) + for (final action in BusyMarkEditorShortcutAction.values) command( - id: 'editor.${entry.key.name}', - label: (context) => _editorLabel(context, entry.key), - category: (context) => _editorCategory(context, entry.key), + id: 'editor.${action.name}', + label: (context) => _editorLabel(context, action), + category: (context) => _editorCategory(context, action), scope: BusyMarkCommandScope.editor, - shortcut: entry.value, - description: (context) => _editorDescription(context, entry.key), + shortcut: BusyMarkEditorShortcuts.definitions[action], + description: (context) => _editorDescription(context, action), ), - for (final entry in BusyMarkSidebarShortcuts.definitions.entries) + for (final action in BusyMarkSidebarShortcutAction.values) command( - id: 'sidebar.${entry.key.name}', - label: (context) => _sidebarLabel(context, entry.key), + id: 'sidebar.${action.name}', + label: (context) => _sidebarLabel(context, action), category: (context) => context.l10n.shortcutGroupSidebar, scope: BusyMarkCommandScope.sidebar, - shortcut: entry.value, + shortcut: BusyMarkSidebarShortcuts.definitions[action], ), - for (final entry in BusyMarkTreeShortcuts.definitions.entries) + for (final action in BusyMarkTreeShortcutAction.values) command( - id: 'tree.${entry.key.name}', + id: 'tree.${action.name}', label: (context) => context.l10n.delete, category: (context) => context.l10n.shortcutGroupSidebar, scope: BusyMarkCommandScope.tree, - shortcut: entry.value, + shortcut: BusyMarkTreeShortcuts.definitions[action], description: (context) => context.l10n.shortcutDeleteTreeItemDescription, ), @@ -502,4 +634,22 @@ abstract final class BusyMarkCommandCatalog { BusyMarkSidebarShortcutAction.outline => context.l10n.outline, BusyMarkSidebarShortcutAction.git => context.l10n.git, }; + + static BuildContext? get _focusedContext => + FocusManager.instance.primaryFocus?.context; + + static bool _contextCommandAvailable(String commandId) { + final context = _focusedContext; + final action = context == null + ? null + : Actions.maybeFind(context); + return action?.isEnabled(BusyMarkContextCommandIntent(commandId)) ?? false; + } + + static void _executeContextCommand(String commandId) { + final context = _focusedContext; + if (context != null) { + Actions.maybeInvoke(context, BusyMarkContextCommandIntent(commandId)); + } + } } diff --git a/lib/src/editor/editor_text_context_menu.dart b/lib/src/editor/editor_text_context_menu.dart index 8dff3a56..a3e6380b 100644 --- a/lib/src/editor/editor_text_context_menu.dart +++ b/lib/src/editor/editor_text_context_menu.dart @@ -86,7 +86,9 @@ class _BusyMarkEditorTextContextMenuState List> _menuItems(BuildContext context) { final editable = widget.editableTextState; - final commands = BusyMarkCommandCatalog.create(); + final commands = + BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog.metadata; final items = >[ for (final item in editable.contextMenuButtonItems) ...[ if (_commandIdFor(item.type) case final commandId?) diff --git a/lib/src/editor/source/source_editor.dart b/lib/src/editor/source/source_editor.dart index 359172fc..8fca8906 100644 --- a/lib/src/editor/source/source_editor.dart +++ b/lib/src/editor/source/source_editor.dart @@ -10,6 +10,7 @@ import '../../ai/ai_models.dart'; import '../../app/busymark_design.dart'; import '../../app/busymark_glyphs.dart'; import '../../app/busymark_shortcuts.dart'; +import '../../app/command_registry.dart'; import '../../app/localization.dart'; import '../../core/diagnostic.dart'; import '../../search/search_replace_service.dart'; @@ -211,32 +212,45 @@ class BusyMarkSourceEditorState extends State { } final keyboard = HardwareKeyboard.instance; final key = event.logicalKey; - if (BusyMarkAppShortcutActivators.search.accepts(event, keyboard)) { + final commands = + BusyMarkCommandRegistryScope.read(context) ?? + BusyMarkCommandCatalog.metadata; + if (commands.shortcutAccepts(BusyMarkCommandIds.search, event, keyboard)) { widget.onOpenSearch(); return KeyEventResult.handled; } - if (BusyMarkTextEditingShortcutActivators.undo.accepts(event, keyboard)) { + if (commands.shortcutAccepts( + BusyMarkCommandIds.textUndo, + event, + keyboard, + )) { final text = widget.onUndo?.call(); if (text != null) { _applyOwnedUndoText(text); return KeyEventResult.handled; } } - if (BusyMarkTextEditingShortcutActivators.redo.accepts(event, keyboard)) { + if (commands.shortcutAccepts( + BusyMarkCommandIds.textRedo, + event, + keyboard, + )) { final text = widget.onRedo?.call(); if (text != null) { _applyOwnedUndoText(text); return KeyEventResult.handled; } } - if (BusyMarkTextEditingShortcutActivators.insertIndentation.accepts( + if (commands.shortcutAccepts( + BusyMarkCommandIds.textInsertIndentation, event, keyboard, )) { _insertTab(); return KeyEventResult.handled; } - if (BusyMarkTextEditingShortcutActivators.outdentSource.accepts( + if (commands.shortcutAccepts( + BusyMarkCommandIds.textOutdentSource, event, keyboard, )) { @@ -247,14 +261,24 @@ class BusyMarkSourceEditorState extends State { _applyFullEditingValue(SourceCommands.smartEnter(_fullEditingValue())); return KeyEventResult.handled; } - if (BusyMarkTextEditingShortcutActivators.escape.accepts(event, keyboard)) { + if (commands.shortcutAccepts( + BusyMarkCommandIds.textEscape, + event, + keyboard, + )) { widget.onCloseSearch(); return KeyEventResult.handled; } - final shortcutAction = BusyMarkEditorShortcutActivators.actionForKeyEvent( + final commandId = commands.matchingCommandId( event, keyboard, + scope: BusyMarkCommandScope.editor, ); + final shortcutAction = commandId == null + ? null + : BusyMarkEditorShortcutAction.values + .where((action) => commandId == 'editor.${action.name}') + .firstOrNull; if (shortcutAction != null) { if (shortcutAction == BusyMarkEditorShortcutAction.refineWithAi && !_canRefineWithAi) { @@ -302,11 +326,34 @@ class BusyMarkSourceEditorState extends State { child: KeyedSubtree( key: ValueKey(widget.documentId ?? widget.filePath), child: Shortcuts( - shortcuts: BusyMarkEditorShortcutActivators.intentMap( - _SourceEditorShortcutIntent.new, - ), + shortcuts: + (BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog.metadata) + .shortcutIntents( + scopes: const {BusyMarkCommandScope.editor}, + intentFor: BusyMarkContextCommandIntent.new, + ), child: Actions( actions: { + BusyMarkContextCommandIntent: + BusyMarkContextCommandAction( + isCommandEnabled: (commandId) => + commandId.startsWith('editor.'), + onCommand: (commandId) { + final name = commandId.substring( + 'editor.'.length, + ); + final action = BusyMarkEditorShortcutAction + .values + .where( + (candidate) => candidate.name == name, + ) + .firstOrNull; + if (action != null) { + _applyShortcutAction(action); + } + }, + ), _SourceEditorShortcutIntent: CallbackAction<_SourceEditorShortcutIntent>( onInvoke: (intent) { diff --git a/lib/src/editor/wysiwyg/wysiwyg_editor.dart b/lib/src/editor/wysiwyg/wysiwyg_editor.dart index 50af24ec..15aa7e84 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_editor.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_editor.dart @@ -19,6 +19,7 @@ import '../../app/busymark_dialogs.dart'; import '../../app/busymark_design.dart'; import '../../app/busymark_glyphs.dart'; import '../../app/busymark_shortcuts.dart'; +import '../../app/command_registry.dart'; import '../../app/localization.dart'; import '../../core/source_span.dart'; import '../../markdown/busymark_document.dart'; @@ -34,16 +35,23 @@ import 'wysiwyg_block_widgets.dart'; import 'wysiwyg_commands.dart'; import 'wysiwyg_document_controller.dart'; import 'wysiwyg_inline_controller.dart'; +import 'wysiwyg_session_state.dart'; import 'wysiwyg_toolbar.dart'; typedef BusyMarkWysiwygSourceChanged = void Function(String filePath, String source); +typedef BusyMarkWysiwygSessionChanged = + void Function(String documentId, WysiwygEditorSessionState state); class BusyMarkWysiwygEditor extends StatefulWidget { const BusyMarkWysiwygEditor({ super.key, required this.document, required this.onSourceChanged, + this.documentId, + this.initialSessionState = const WysiwygEditorSessionState(), + this.onSessionChanged, + this.useExternalUndoHistory = false, this.onDocumentChanged, this.workspaceRoot, this.writersideRoot, @@ -75,6 +83,10 @@ class BusyMarkWysiwygEditor extends StatefulWidget { final BusyDocument document; final BusyMarkWysiwygSourceChanged onSourceChanged; + final String? documentId; + final WysiwygEditorSessionState initialSessionState; + final BusyMarkWysiwygSessionChanged? onSessionChanged; + final bool useExternalUndoHistory; final ValueChanged? onDocumentChanged; final String? workspaceRoot; final String? writersideRoot; @@ -122,6 +134,7 @@ class _BusyMarkWysiwygEditorState extends State { final _itemPositionsListener = ItemPositionsListener.create(); List<({int itemIndex, DocumentOutlineHeading heading})> _viewportOutlineStops = const []; + List _viewportBlockIds = const []; String? _reportedVisibleHeadingKey; bool _hasReportedVisibleHeading = false; late final FocusNode _selectionFocusNode; @@ -139,6 +152,9 @@ class _BusyMarkWysiwygEditorState extends State { bool _initialFocusScheduled = false; var _toolbarVisible = true; var _documentGeneration = 0; + bool _sessionReportScheduled = false; + + String get _documentId => widget.documentId ?? widget.document.filePath; @override void initState() { @@ -155,7 +171,7 @@ class _BusyMarkWysiwygEditorState extends State { _handleVisibleItemsChanged, ); _syncBlockControllers(); - _scheduleInitialFocus(); + _scheduleSessionRestore(widget.initialSessionState); _scheduleHeadingScroll(); _scheduleSearchScroll(); _scheduleVisibleHeadingReport(); @@ -168,19 +184,24 @@ class _BusyMarkWysiwygEditorState extends State { unawaited(_assetDropSubscription?.cancel()); _listenForDroppedAssets(); } - final fileChanged = oldWidget.document.filePath != widget.document.filePath; + final oldDocumentId = oldWidget.documentId ?? oldWidget.document.filePath; + final fileChanged = oldDocumentId != _documentId; final sourceChanged = oldWidget.document.source != widget.document.source; if (fileChanged || (sourceChanged && !_internalChange)) { _hasReportedVisibleHeading = false; - _undoStack.clear(); - _redoStack.clear(); if (fileChanged) { + _reportSessionNow( + callback: oldWidget.onSessionChanged, + documentId: oldDocumentId, + ); + _undoStack.clear(); + _redoStack.clear(); _internalChange = false; _resetPerDocumentState(); } _documentController.replaceDocument(widget.document); _initialFocusScheduled = false; - _scheduleInitialFocus(); + _scheduleSessionRestore(widget.initialSessionState); _scheduleVisibleHeadingReport(); } else if (oldWidget.onVisibleHeadingChanged != widget.onVisibleHeadingChanged) { @@ -244,7 +265,7 @@ class _BusyMarkWysiwygEditorState extends State { } void _handleVisibleItemsChanged() { - if (!mounted || _viewportOutlineStops.isEmpty) { + if (!mounted) { return; } int? firstVisible; @@ -259,6 +280,10 @@ class _BusyMarkWysiwygEditorState extends State { if (firstVisible == null) { return; } + _scheduleSessionReport(); + if (_viewportOutlineStops.isEmpty) { + return; + } var low = 0; var high = _viewportOutlineStops.length - 1; DocumentOutlineHeading? heading; @@ -287,6 +312,9 @@ class _BusyMarkWysiwygEditorState extends State { @override Widget build(BuildContext context) { final colors = BusyMarkSurfaceColors.of(context); + final commandRegistry = + BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog.metadata; final entries = _editableBlockEntries(_documentController.document.blocks); final renderEntries = _editorRenderEntries( _documentController.document.blocks, @@ -306,6 +334,7 @@ class _BusyMarkWysiwygEditorState extends State { case final heading?) (itemIndex: index, heading: heading), ]; + _viewportBlockIds = [for (final entry in renderEntries) entry.block.id]; final blocks = entries.map((entry) => entry.block).toList(); final blockSelectionActive = _hasBlockSelection; final selectionRangesByBlockId = { @@ -326,19 +355,23 @@ class _BusyMarkWysiwygEditorState extends State { final selectedBlockIds = _fullySelectedBlockIds(blocks); return Shortcuts( shortcuts: { - ...BusyMarkEditorShortcutActivators.intentMap( - _EditorShortcutIntent.new, + ...commandRegistry.shortcutIntents( + scopes: const {BusyMarkCommandScope.editor}, + intentFor: BusyMarkContextCommandIntent.new, ), - BusyMarkTextEditingShortcutActivators.paste: const _PasteTextIntent(), - BusyMarkTextEditingShortcutActivators.selectAll: + commandRegistry[BusyMarkCommandIds.textPaste]!.shortcut!.activator: + const _PasteTextIntent(), + commandRegistry[BusyMarkCommandIds.textSelectAll]!.shortcut!.activator: const _SelectAllTextIntent(), - BusyMarkTextEditingShortcutActivators.undo: const _UndoEditorIntent(), - BusyMarkTextEditingShortcutActivators.redo: const _RedoEditorIntent(), + commandRegistry['text.undo']!.shortcut!.activator: + const _UndoEditorIntent(), + commandRegistry['text.redo']!.shortcut!.activator: + const _RedoEditorIntent(), if (blockSelectionActive) - BusyMarkTextEditingShortcutActivators.copy: + commandRegistry[BusyMarkCommandIds.textCopy]!.shortcut!.activator: const _CopyBlockSelectionIntent(), if (blockSelectionActive) - BusyMarkTextEditingShortcutActivators.cut: + commandRegistry[BusyMarkCommandIds.textCut]!.shortcut!.activator: const _CutBlockSelectionIntent(), if (blockSelectionActive) const SingleActivator(LogicalKeyboardKey.backspace): @@ -347,11 +380,15 @@ class _BusyMarkWysiwygEditorState extends State { const SingleActivator(LogicalKeyboardKey.delete): const _DeleteBlockSelectionIntent(), if (blockSelectionActive) - BusyMarkTextEditingShortcutActivators.escape: + commandRegistry['text.escape']!.shortcut!.activator: const _ClearBlockSelectionIntent(), }, child: Actions( actions: { + BusyMarkContextCommandIntent: BusyMarkContextCommandAction( + isCommandEnabled: _canApplyContextCommand, + onCommand: _applyContextCommand, + ), _EditorShortcutIntent: CallbackAction<_EditorShortcutIntent>( onInvoke: (intent) { _applyEditorShortcutAction(intent.action); @@ -398,7 +435,7 @@ class _BusyMarkWysiwygEditorState extends State { ), _UndoEditorIntent: CallbackAction<_UndoEditorIntent>( onInvoke: (intent) { - if (!_undoEditorChange()) { + if (widget.useExternalUndoHistory || !_undoEditorChange()) { widget.onUndo?.call(); } return null; @@ -406,7 +443,7 @@ class _BusyMarkWysiwygEditorState extends State { ), _RedoEditorIntent: CallbackAction<_RedoEditorIntent>( onInvoke: (intent) { - if (!_redoEditorChange()) { + if (widget.useExternalUndoHistory || !_redoEditorChange()) { widget.onRedo?.call(); } return null; @@ -737,15 +774,16 @@ class _BusyMarkWysiwygEditorState extends State { } BusyMarkWysiwygTextController _textControllerFor(BusyBlock block) { - final controller = _textControllers.putIfAbsent( - block.id, - () => BusyMarkWysiwygTextController( + final controller = _textControllers.putIfAbsent(block.id, () { + final created = BusyMarkWysiwygTextController( text: busyMarkWysiwygEditableText(block), ranges: busyMarkWysiwygBlockContainsMath(block) ? const [] : busyInlineStyleRanges(block.inlines), - ), - ); + ); + created.addListener(_scheduleSessionReport); + return created; + }); controller.updateFromBlock(block); return controller; } @@ -857,6 +895,7 @@ class _BusyMarkWysiwygEditorState extends State { void _setActiveBlock(String blockId) { _activeBlockId = blockId; + _scheduleSessionReport(); } KeyEventResult _handleDocumentSelectionKeyEvent( @@ -1196,6 +1235,141 @@ class _BusyMarkWysiwygEditorState extends State { }); } + void _scheduleSessionRestore(WysiwygEditorSessionState session) { + if (session.activeBlockId == null && + session.anchorBlockId == null && + session.viewportBlockId == null) { + _scheduleInitialFocus(); + return; + } + _initialFocusScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + final activeBlock = session.activeBlockId == null + ? null + : _documentController.blockById(session.activeBlockId!); + _activeBlockId = activeBlock?.id ?? _focusableBlocks().firstOrNull?.id; + final anchorBlockId = session.anchorBlockId; + final extentBlockId = session.extentBlockId; + if (anchorBlockId != null && extentBlockId != null) { + final anchor = _documentController.blockById(anchorBlockId); + final extent = _documentController.blockById(extentBlockId); + if (anchor != null && extent != null) { + if (anchorBlockId == extentBlockId) { + final controller = _textControllers[anchorBlockId]; + if (controller != null) { + controller.selection = TextSelection( + baseOffset: session.anchorOffset + .clamp(0, controller.text.length) + .toInt(), + extentOffset: session.extentOffset + .clamp(0, controller.text.length) + .toInt(), + ); + } + } else { + _documentSelection = _DocumentTextSelection( + anchor: _DocumentTextPosition( + blockId: anchorBlockId, + offset: session.anchorOffset + .clamp(0, anchor.plainText.length) + .toInt(), + ), + extent: _DocumentTextPosition( + blockId: extentBlockId, + offset: session.extentOffset + .clamp(0, extent.plainText.length) + .toInt(), + ), + ); + } + } + } + final viewportBlockId = session.viewportBlockId; + if (viewportBlockId != null) { + _jumpToBlock( + viewportBlockId, + alignment: session.viewportAlignment.clamp(0.0, 1.0), + ); + } + _focusActiveOrFirstBlock(); + if (mounted) { + setState(() {}); + } + }); + } + + void _scheduleSessionReport() { + if (_sessionReportScheduled || widget.onSessionChanged == null) { + return; + } + _sessionReportScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _sessionReportScheduled = false; + if (mounted) { + _reportSessionNow(); + } + }); + } + + void _reportSessionNow({ + BusyMarkWysiwygSessionChanged? callback, + String? documentId, + }) { + final report = callback ?? widget.onSessionChanged; + if (report == null) { + return; + } + String? anchorBlockId; + String? extentBlockId; + var anchorOffset = 0; + var extentOffset = 0; + final documentSelection = _documentSelection; + if (documentSelection != null) { + anchorBlockId = documentSelection.anchor.blockId; + anchorOffset = documentSelection.anchor.offset; + extentBlockId = documentSelection.extent.blockId; + extentOffset = documentSelection.extent.offset; + } else if (_activeBlockId case final blockId?) { + final selection = _textControllers[blockId]?.selection; + if (selection != null && selection.isValid) { + anchorBlockId = blockId; + anchorOffset = selection.baseOffset; + extentBlockId = blockId; + extentOffset = selection.extentOffset; + } + } + String? viewportBlockId; + var viewportAlignment = 0.0; + final positions = + _itemPositionsListener.itemPositions.value + .where( + (position) => + position.itemTrailingEdge > 0 && position.itemLeadingEdge < 1, + ) + .toList() + ..sort((left, right) => left.index.compareTo(right.index)); + if (positions.isNotEmpty && + positions.first.index < _viewportBlockIds.length) { + viewportBlockId = _viewportBlockIds[positions.first.index]; + viewportAlignment = positions.first.itemLeadingEdge.clamp(0.0, 1.0); + } + report( + documentId ?? _documentId, + WysiwygEditorSessionState( + activeBlockId: _activeBlockId, + anchorBlockId: anchorBlockId, + anchorOffset: anchorOffset, + extentBlockId: extentBlockId, + extentOffset: extentOffset, + viewportBlockId: viewportBlockId, + viewportAlignment: viewportAlignment, + ), + ); + } + void _scheduleHeadingScroll() { final headingId = widget.scrollToHeadingId; final editorBlockId = widget.scrollToBlockId; @@ -1357,6 +1531,9 @@ class _BusyMarkWysiwygEditorState extends State { KeyEventResult _handleBlockKeyEvent(String blockId, KeyEvent event) { final keyboard = HardwareKeyboard.instance; final key = event.logicalKey; + final commands = + BusyMarkCommandRegistryScope.read(context) ?? + BusyMarkCommandCatalog.metadata; if ((event is KeyDownEvent || event is KeyRepeatEvent) && key == LogicalKeyboardKey.tab && !_hasCommandModifierPressed()) { @@ -1372,7 +1549,8 @@ class _BusyMarkWysiwygEditorState extends State { } } if ((event is KeyDownEvent || event is KeyRepeatEvent) && - BusyMarkTextEditingShortcutActivators.insertIndentation.accepts( + commands.shortcutAccepts( + BusyMarkCommandIds.textInsertIndentation, event, keyboard, )) { @@ -1391,48 +1569,76 @@ class _BusyMarkWysiwygEditorState extends State { return KeyEventResult.ignored; } _activeBlockId = blockId; - if (keyboard.isControlPressed && key == LogicalKeyboardKey.keyA) { + if (commands.shortcutAccepts( + BusyMarkCommandIds.textSelectAll, + event, + keyboard, + )) { _selectAllForBlock(blockId); return KeyEventResult.handled; } - if (keyboard.isControlPressed && - key == LogicalKeyboardKey.keyC && + if (commands.shortcutAccepts( + BusyMarkCommandIds.textCopy, + event, + keyboard, + ) && _copyCurrentSelection()) { return KeyEventResult.handled; } - if (keyboard.isControlPressed && - key == LogicalKeyboardKey.keyX && + if (commands.shortcutAccepts(BusyMarkCommandIds.textCut, event, keyboard) && _cutCurrentSelection()) { return KeyEventResult.handled; } - if (keyboard.isControlPressed && - !keyboard.isShiftPressed && - key == LogicalKeyboardKey.keyV) { + if (commands.shortcutAccepts( + BusyMarkCommandIds.textPaste, + event, + keyboard, + )) { unawaited(_pasteIntoActiveBlock()); return KeyEventResult.handled; } - if (keyboard.isControlPressed && - keyboard.isShiftPressed && - key == LogicalKeyboardKey.keyZ) { - _redoEditorChange(); + if (commands.shortcutAccepts( + BusyMarkCommandIds.textRedo, + event, + keyboard, + )) { + if (widget.useExternalUndoHistory || !_redoEditorChange()) { + widget.onRedo?.call(); + } return KeyEventResult.handled; } - if (keyboard.isControlPressed && key == LogicalKeyboardKey.keyZ) { - _undoEditorChange(); + if (commands.shortcutAccepts( + BusyMarkCommandIds.textUndo, + event, + keyboard, + )) { + if (widget.useExternalUndoHistory || !_undoEditorChange()) { + widget.onUndo?.call(); + } return KeyEventResult.handled; } - if (BusyMarkAppShortcutActivators.search.accepts(event, keyboard)) { + if (commands.shortcutAccepts(BusyMarkCommandIds.search, event, keyboard)) { widget.onOpenSearch?.call(); return KeyEventResult.handled; } - if (BusyMarkTextEditingShortcutActivators.escape.accepts(event, keyboard)) { + if (commands.shortcutAccepts( + BusyMarkCommandIds.textEscape, + event, + keyboard, + )) { widget.onCloseSearch?.call(); return KeyEventResult.handled; } - final shortcutAction = BusyMarkEditorShortcutActivators.actionForKeyEvent( + final commandId = commands.matchingCommandId( event, keyboard, + scope: BusyMarkCommandScope.editor, ); + final shortcutAction = commandId == null + ? null + : BusyMarkEditorShortcutAction.values + .where((action) => commandId == 'editor.${action.name}') + .firstOrNull; if (shortcutAction != null) { if (shortcutAction == BusyMarkEditorShortcutAction.refineWithAi && (widget.onAiEdit == null || _currentSelectionRanges().isEmpty)) { @@ -2268,6 +2474,58 @@ class _BusyMarkWysiwygEditorState extends State { } } + bool _canApplyContextCommand(String commandId) { + if (commandId.startsWith('editor.')) { + final name = commandId.substring('editor.'.length); + return BusyMarkEditorShortcutAction.values.any( + (action) => action.name == name, + ); + } + return switch (commandId) { + BusyMarkCommandIds.textSelectAll || + BusyMarkCommandIds.textCut || + BusyMarkCommandIds.textCopy || + BusyMarkCommandIds.textPaste || + 'text.pastePlainText' || + 'text.undo' || + 'text.redo' => true, + _ => false, + }; + } + + void _applyContextCommand(String commandId) { + if (commandId.startsWith('editor.')) { + final name = commandId.substring('editor.'.length); + final action = BusyMarkEditorShortcutAction.values + .where((candidate) => candidate.name == name) + .firstOrNull; + if (action != null) { + _applyEditorShortcutAction(action); + } + return; + } + switch (commandId) { + case BusyMarkCommandIds.textSelectAll: + _selectAllForActiveBlock(); + case BusyMarkCommandIds.textCut: + _cutCurrentSelection(); + case BusyMarkCommandIds.textCopy: + _copyCurrentSelection(); + case BusyMarkCommandIds.textPaste: + unawaited(_pasteIntoActiveBlock()); + case 'text.pastePlainText': + unawaited(_pastePlainTextIntoActiveBlock()); + case 'text.undo': + if (widget.useExternalUndoHistory || !_undoEditorChange()) { + widget.onUndo?.call(); + } + case 'text.redo': + if (widget.useExternalUndoHistory || !_redoEditorChange()) { + widget.onRedo?.call(); + } + } + } + Set _activeInlineKindsAt(String blockId, int offset) { final block = _documentController.blockById(blockId); final active = {...?_pendingInlineKindsByBlockId[blockId]}; diff --git a/lib/src/editor/wysiwyg/wysiwyg_session_state.dart b/lib/src/editor/wysiwyg/wysiwyg_session_state.dart new file mode 100644 index 00000000..de3d6482 --- /dev/null +++ b/lib/src/editor/wysiwyg/wysiwyg_session_state.dart @@ -0,0 +1,41 @@ +class WysiwygEditorSessionState { + const WysiwygEditorSessionState({ + this.activeBlockId, + this.anchorBlockId, + this.anchorOffset = 0, + this.extentBlockId, + this.extentOffset = 0, + this.viewportBlockId, + this.viewportAlignment = 0, + }); + + final String? activeBlockId; + final String? anchorBlockId; + final int anchorOffset; + final String? extentBlockId; + final int extentOffset; + final String? viewportBlockId; + final double viewportAlignment; + + Map toJson() => { + 'activeBlockId': activeBlockId, + 'anchorBlockId': anchorBlockId, + 'anchorOffset': anchorOffset, + 'extentBlockId': extentBlockId, + 'extentOffset': extentOffset, + 'viewportBlockId': viewportBlockId, + 'viewportAlignment': viewportAlignment, + }; + + factory WysiwygEditorSessionState.fromJson(Map json) { + return WysiwygEditorSessionState( + activeBlockId: json['activeBlockId']?.toString(), + anchorBlockId: json['anchorBlockId']?.toString(), + anchorOffset: (json['anchorOffset'] as num?)?.toInt() ?? 0, + extentBlockId: json['extentBlockId']?.toString(), + extentOffset: (json['extentOffset'] as num?)?.toInt() ?? 0, + viewportBlockId: json['viewportBlockId']?.toString(), + viewportAlignment: (json['viewportAlignment'] as num?)?.toDouble() ?? 0, + ); + } +} diff --git a/lib/src/search/search_replace_service.dart b/lib/src/search/search_replace_service.dart index b94496c6..0278b5c4 100644 --- a/lib/src/search/search_replace_service.dart +++ b/lib/src/search/search_replace_service.dart @@ -61,6 +61,7 @@ enum WorkspaceReplacementIssueKind { changedSincePreview, bufferRevisionChanged, normalizationRequired, + applyFailed, } class WorkspaceReplacementIssue { @@ -109,6 +110,10 @@ class WorkspaceReplacementPreview { int get matchCount => files.fold(0, (total, file) => total + file.matches.length); + + bool get isComplete => !issues.any( + (issue) => issue.kind == WorkspaceReplacementIssueKind.truncated, + ); } class WorkspaceReplacementApplyResult { @@ -316,8 +321,18 @@ class SearchReplacementService { const {}, }) async { final issues = []; - var appliedFiles = 0; - var appliedMatches = 0; + if (!preview.isComplete) { + return WorkspaceReplacementApplyResult( + appliedFiles: 0, + appliedMatches: 0, + issues: List.unmodifiable([ + for (final issue in preview.issues) + if (issue.kind == WorkspaceReplacementIssueKind.truncated) issue, + ]), + ); + } + final state = currentState(); + final operations = <_WorkspaceReplacementOperation>[]; for (final file in preview.files) { final selected = { for (final match in file.matches) @@ -328,7 +343,7 @@ class SearchReplacementService { } final currentBuffer = file.bufferId == null ? null - : currentState().documentBuffers + : state.documentBuffers .where((buffer) => buffer.id == file.bufferId) .firstOrNull; if (file.bufferId != null) { @@ -374,22 +389,71 @@ class SearchReplacementService { matches: file.matches, ); final nextText = textPreview.apply(selectedMatchIds: selected); - if (file.bufferId case final bufferId?) { - updateBuffer(bufferId, nextText); - } else { - await workspaceService.saveFormattedText( - file.filePath, - nextText, - format: file.format, - mixedNormalization: normalization, - ); + operations.add( + _WorkspaceReplacementOperation( + file: file, + nextText: nextText, + selectedMatchCount: selected.length, + normalization: normalization, + ), + ); + } + if (issues.isNotEmpty) { + return WorkspaceReplacementApplyResult( + appliedFiles: 0, + appliedMatches: 0, + issues: List.unmodifiable(issues), + ); + } + final diskOperations = operations + .where((operation) => operation.file.bufferId == null) + .toList(growable: false); + try { + await workspaceService.saveFormattedTextBatch([ + for (final operation in diskOperations) + WorkspaceBatchTextWrite( + path: operation.file.filePath, + text: operation.nextText, + expectedSnapshot: operation.file.diskSnapshot!, + format: operation.file.format, + mixedNormalization: operation.normalization, + ), + ]); + } on WorkspaceBatchWriteConflict catch (error) { + return WorkspaceReplacementApplyResult( + appliedFiles: 0, + appliedMatches: 0, + issues: [ + WorkspaceReplacementIssue( + kind: WorkspaceReplacementIssueKind.changedSincePreview, + filePath: error.path, + ), + ], + ); + } on Object { + return WorkspaceReplacementApplyResult( + appliedFiles: 0, + appliedMatches: 0, + issues: [ + for (final operation in diskOperations) + WorkspaceReplacementIssue( + kind: WorkspaceReplacementIssueKind.applyFailed, + filePath: operation.file.filePath, + ), + ], + ); + } + for (final operation in operations) { + if (operation.file.bufferId case final bufferId?) { + updateBuffer(bufferId, operation.nextText); } - appliedFiles++; - appliedMatches += selected.length; } return WorkspaceReplacementApplyResult( - appliedFiles: appliedFiles, - appliedMatches: appliedMatches, + appliedFiles: operations.length, + appliedMatches: operations.fold( + 0, + (total, operation) => total + operation.selectedMatchCount, + ), issues: List.unmodifiable(issues), ); } @@ -457,3 +521,17 @@ class SearchReplacementService { }; } } + +class _WorkspaceReplacementOperation { + const _WorkspaceReplacementOperation({ + required this.file, + required this.nextText, + required this.selectedMatchCount, + required this.normalization, + }); + + final WorkspaceReplacementFilePreview file; + final String nextText; + final int selectedMatchCount; + final LineEndingNormalization? normalization; +} diff --git a/lib/src/workspace/document_buffer.dart b/lib/src/workspace/document_buffer.dart index 7d04d564..fc2ed8cb 100644 --- a/lib/src/workspace/document_buffer.dart +++ b/lib/src/workspace/document_buffer.dart @@ -4,6 +4,7 @@ import 'package:flutter/services.dart'; import '../app/app_settings.dart'; import '../editor/source/source_search.dart'; +import '../editor/wysiwyg/wysiwyg_session_state.dart'; import 'text_format_metadata.dart'; import 'workspace_file_snapshot.dart'; @@ -51,6 +52,7 @@ class DocumentEditorState { this.searchReplacement = '', this.searchCurrentMatchIndex, this.undoState = const DocumentUndoState(), + this.wysiwygState = const WysiwygEditorSessionState(), }); final DocumentViewModePreference mode; @@ -61,6 +63,7 @@ class DocumentEditorState { final String searchReplacement; final int? searchCurrentMatchIndex; final DocumentUndoState undoState; + final WysiwygEditorSessionState wysiwygState; DocumentEditorState copyWith({ DocumentViewModePreference? mode, @@ -71,6 +74,7 @@ class DocumentEditorState { String? searchReplacement, Object? searchCurrentMatchIndex = _bufferUnset, DocumentUndoState? undoState, + WysiwygEditorSessionState? wysiwygState, }) { return DocumentEditorState( mode: mode ?? this.mode, @@ -85,6 +89,7 @@ class DocumentEditorState { ? this.searchCurrentMatchIndex : searchCurrentMatchIndex as int?, undoState: undoState ?? this.undoState, + wysiwygState: wysiwygState ?? this.wysiwygState, ); } @@ -100,6 +105,7 @@ class DocumentEditorState { 'searchRegex': searchOptions.regex, 'searchReplacement': searchReplacement, 'searchCurrentMatchIndex': searchCurrentMatchIndex, + 'wysiwygState': wysiwygState.toJson(), }; factory DocumentEditorState.fromJson(Map json) { @@ -127,6 +133,9 @@ class DocumentEditorState { searchReplacement: json['searchReplacement']?.toString() ?? '', searchCurrentMatchIndex: (json['searchCurrentMatchIndex'] as num?) ?.toInt(), + wysiwygState: WysiwygEditorSessionState.fromJson( + (json['wysiwygState'] as Map?)?.cast() ?? const {}, + ), ); } } @@ -214,9 +223,7 @@ class DocumentBuffer { return copyWith( text: nextText, dirty: nextText != lastSavedText || isUntitled, - format: isUntitled - ? format.copyWith(hasFinalNewline: nextText.endsWith('\n')) - : format, + format: format.copyWith(hasFinalNewline: nextText.endsWith('\n')), revision: revision + 1, editorState: editorState.copyWith( undoState: editorState.undoState.push(text), diff --git a/lib/src/workspace/presentation/welcome_screen.dart b/lib/src/workspace/presentation/welcome_screen.dart index d6b8d57a..51b04be6 100644 --- a/lib/src/workspace/presentation/welcome_screen.dart +++ b/lib/src/workspace/presentation/welcome_screen.dart @@ -882,8 +882,9 @@ BusyMarkStatusKind busyMarkWorkspaceMessageStatusKind( return switch (code) { WorkspaceMessageCode.chooseWhereToSaveMarkdown => BusyMarkStatusKind.information, - WorkspaceMessageCode.saveBlockedFileChangedOnDisk => - BusyMarkStatusKind.warning, + WorkspaceMessageCode.recoveryRestored => BusyMarkStatusKind.information, + WorkspaceMessageCode.saveBlockedFileChangedOnDisk || + WorkspaceMessageCode.recoveryDamaged => BusyMarkStatusKind.warning, WorkspaceMessageCode.openFailed || WorkspaceMessageCode.createWritersideProjectFailed || WorkspaceMessageCode.createWritersideTopicFailed || diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index 98c5182c..d4e4ac7d 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -22,7 +22,7 @@ import '../../app/busymark_design.dart'; import '../../app/busymark_glyphs.dart'; import '../../app/busymark_main_menu.dart'; import '../../app/busymark_search_field.dart'; -import '../../app/busymark_shortcuts.dart'; +import '../../app/command_registry.dart'; import '../../app/localization.dart'; import '../../app/window_control_service.dart'; import '../../core/diagnostic.dart'; @@ -44,6 +44,7 @@ import '../../editor/source/source_document.dart'; import '../../editor/source/source_editor.dart'; import '../../editor/source/source_search.dart'; import '../../editor/wysiwyg/wysiwyg_editor.dart'; +import '../../editor/wysiwyg/wysiwyg_session_state.dart'; import '../../feedback/presentation/feedback_dialog.dart'; import '../../export/markdown_pdf_export_ui.dart'; import '../../git/application/git_controller.dart'; @@ -596,6 +597,9 @@ class WorkspaceScreen extends ConsumerWidget { sidebarToggleVisible: hasSidebar, backVisible: true, ); + final commandRegistry = + BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog.metadata; return HeaderBarConfigurationPublisher( synchronizer: headerBar.configurationSynchronizer, @@ -603,23 +607,25 @@ class WorkspaceScreen extends ConsumerWidget { enabled: headerBar.isAvailable, child: Shortcuts( shortcuts: { - BusyMarkAppShortcutActivators.search: const _OpenSearchIntent(), - BusyMarkSidebarShortcutActivators.files: + commandRegistry[BusyMarkCommandIds.search]!.shortcut!.activator: + const _OpenSearchIntent(), + commandRegistry[BusyMarkCommandIds.sidebarFiles]!.shortcut!.activator: const _SelectSidebarTabIntent(_SidebarTab.files), const SingleActivator(LogicalKeyboardKey.numpad1, control: true): const _SelectSidebarTabIntent(_SidebarTab.files), - BusyMarkSidebarShortcutActivators.toc: const _SelectSidebarTabIntent( - _SidebarTab.toc, - ), + commandRegistry[BusyMarkCommandIds.sidebarToc]!.shortcut!.activator: + const _SelectSidebarTabIntent(_SidebarTab.toc), const SingleActivator(LogicalKeyboardKey.numpad2, control: true): const _SelectSidebarTabIntent(_SidebarTab.toc), - BusyMarkSidebarShortcutActivators.outline: - const _SelectSidebarTabIntent(_SidebarTab.outline), + commandRegistry[BusyMarkCommandIds.sidebarOutline]! + .shortcut! + .activator: const _SelectSidebarTabIntent( + _SidebarTab.outline, + ), const SingleActivator(LogicalKeyboardKey.numpad3, control: true): const _SelectSidebarTabIntent(_SidebarTab.outline), - BusyMarkSidebarShortcutActivators.git: const _SelectSidebarTabIntent( - _SidebarTab.git, - ), + commandRegistry[BusyMarkCommandIds.sidebarGit]!.shortcut!.activator: + const _SelectSidebarTabIntent(_SidebarTab.git), const SingleActivator(LogicalKeyboardKey.numpad4, control: true): const _SelectSidebarTabIntent(_SidebarTab.git), }, @@ -655,7 +661,9 @@ class WorkspaceScreen extends ConsumerWidget { child: BusyMarkHeaderIconButton( tooltip: context.l10n.welcome, icon: BusyMarkGlyphs.home, - shortcut: BusyMarkAppShortcutLabels.back, + shortcut: commandRegistry[BusyMarkCommandIds.back] + ?.shortcut + ?.label, onPressed: () async { final router = GoRouter.of(context); if (await confirmSafeToContinue(context, ref)) { @@ -697,7 +705,10 @@ class WorkspaceScreen extends ConsumerWidget { : context.l10n.showSidebar, icon: BusyMarkGlyphs.sidebar, selected: settings.sidebarVisible, - shortcut: BusyMarkSidebarShortcutLabels.toggleSidebar, + shortcut: + commandRegistry[BusyMarkCommandIds.toggleSidebar] + ?.shortcut + ?.label, onPressed: () { final visible = !settings.sidebarVisible; if (!visible) { @@ -712,7 +723,9 @@ class WorkspaceScreen extends ConsumerWidget { tooltip: context.l10n.search, icon: BusyMarkGlyphs.search, selected: searchState.active, - shortcut: BusyMarkAppShortcutLabels.search, + shortcut: commandRegistry[BusyMarkCommandIds.search] + ?.shortcut + ?.label, onPressed: () => _toggleSearch(ref), ), BusyMarkHeaderPopupMenuButton< @@ -720,7 +733,10 @@ class WorkspaceScreen extends ConsumerWidget { >( tooltip: context.l10n.viewMode, icon: _documentViewModeIcon(documentViewMode), - shortcut: _documentViewModeShortcut(documentViewMode), + shortcut: _documentViewModeShortcut( + documentViewMode, + commandRegistry, + ), itemBuilder: (context) => [ for (final mode in DocumentViewModePreference.values) @@ -728,7 +744,10 @@ class WorkspaceScreen extends ConsumerWidget { value: mode, label: _documentViewModeLabel(context, mode), icon: _documentViewModeIcon(mode), - shortcut: _documentViewModeShortcut(mode), + shortcut: _documentViewModeShortcut( + mode, + commandRegistry, + ), checked: mode == documentViewMode, trailingCheck: true, ), @@ -863,35 +882,29 @@ class WorkspaceScreen extends ConsumerWidget { WidgetRef ref, HeaderBarAction action, ) { - final settings = ref.read(appSettingsControllerProvider); - final settingsController = ref.read(appSettingsControllerProvider.notifier); + final commands = + BusyMarkCommandRegistryScope.read(context) ?? + BusyMarkCommandCatalog.metadata; + void execute(String id) => unawaited(commands.execute(id)); switch (action) { case HeaderBarAction.back: - unawaited(() async { - if (await confirmSafeToContinue(context, ref) && context.mounted) { - context.go('/'); - } - }()); + execute(BusyMarkCommandIds.back); case HeaderBarAction.sidebarToggle: - final visible = !settings.sidebarVisible; - if (!visible) { - _clearGitDetailSelection(ref); - } - unawaited(settingsController.setSidebarVisible(visible)); + execute(BusyMarkCommandIds.toggleSidebar); case HeaderBarAction.refresh: unawaited(_validateActiveAndShowProblems(context, ref)); case HeaderBarAction.save: - break; + execute(BusyMarkCommandIds.save); case HeaderBarAction.exportPdf: - unawaited(exportWorkspaceToPdf(context, ref)); + execute(BusyMarkCommandIds.exportPdf); case HeaderBarAction.fullScreen: - break; + execute(BusyMarkCommandIds.fullScreen); case HeaderBarAction.settings: - context.go(settingsLocation(SettingsReturnTarget.workspace)); + execute(BusyMarkCommandIds.settings); case HeaderBarAction.keyboardShortcuts: - showBusyMarkKeyboardShortcutsDialog(context); + execute(BusyMarkCommandIds.keyboardShortcuts); case HeaderBarAction.markdownAndHtml: - showBusyMarkMarkdownHtmlDialog(context); + execute(BusyMarkCommandIds.markdownAndHtml); case HeaderBarAction.reportIssue: final headerBar = ref.read(linuxHeaderBarServiceProvider); showBusyMarkFeedbackDialog( @@ -901,41 +914,13 @@ class WorkspaceScreen extends ConsumerWidget { case HeaderBarAction.aboutBusyMark: showBusyMarkAboutDialog(context); case HeaderBarAction.viewModeEditor: - ref - .read(workspaceControllerProvider.notifier) - .updateActiveEditorMode(DocumentViewModePreference.editor); - unawaited( - settingsController.setDocumentViewMode( - DocumentViewModePreference.editor, - ), - ); + execute(BusyMarkCommandIds.viewEditor); case HeaderBarAction.viewModeSource: - ref - .read(workspaceControllerProvider.notifier) - .updateActiveEditorMode(DocumentViewModePreference.source); - unawaited( - settingsController.setDocumentViewMode( - DocumentViewModePreference.source, - ), - ); + execute(BusyMarkCommandIds.viewSource); case HeaderBarAction.viewModePreview: - ref - .read(workspaceControllerProvider.notifier) - .updateActiveEditorMode(DocumentViewModePreference.preview); - unawaited( - settingsController.setDocumentViewMode( - DocumentViewModePreference.preview, - ), - ); + execute(BusyMarkCommandIds.viewReading); case HeaderBarAction.viewModeSplit: - ref - .read(workspaceControllerProvider.notifier) - .updateActiveEditorMode(DocumentViewModePreference.split); - unawaited( - settingsController.setDocumentViewMode( - DocumentViewModePreference.split, - ), - ); + execute(BusyMarkCommandIds.viewSplit); case HeaderBarAction.sidebarFiles: _selectSidebarShortcut(ref, _SidebarTab.files); case HeaderBarAction.sidebarToc: @@ -945,7 +930,7 @@ class WorkspaceScreen extends ConsumerWidget { case HeaderBarAction.sidebarGit: _selectSidebarShortcut(ref, _SidebarTab.git); case HeaderBarAction.search: - _toggleSearch(ref); + execute(BusyMarkCommandIds.search); case HeaderBarAction.menu: break; } @@ -1079,17 +1064,17 @@ class WorkspaceScreen extends ConsumerWidget { }; } - String _documentViewModeShortcut(DocumentViewModePreference mode) { - return switch (mode) { - DocumentViewModePreference.editor => - BusyMarkDocumentViewShortcutLabels.editor, - DocumentViewModePreference.source => - BusyMarkDocumentViewShortcutLabels.source, - DocumentViewModePreference.preview => - BusyMarkDocumentViewShortcutLabels.reading, - DocumentViewModePreference.split => - BusyMarkDocumentViewShortcutLabels.split, + String _documentViewModeShortcut( + DocumentViewModePreference mode, + BusyMarkCommandRegistry commands, + ) { + final id = switch (mode) { + DocumentViewModePreference.editor => BusyMarkCommandIds.viewEditor, + DocumentViewModePreference.source => BusyMarkCommandIds.viewSource, + DocumentViewModePreference.preview => BusyMarkCommandIds.viewReading, + DocumentViewModePreference.split => BusyMarkCommandIds.viewSplit, }; + return commands[id]!.shortcut!.label; } Future _openSearchResult( @@ -2442,13 +2427,17 @@ IconData _sidebarTabIcon(_SidebarTab tab, TextDirection direction) { }; } -String? _sidebarTabShortcut(_SidebarTab tab) { - return switch (tab) { - _SidebarTab.files => BusyMarkSidebarShortcutLabels.files, - _SidebarTab.toc => BusyMarkSidebarShortcutLabels.toc, - _SidebarTab.outline => BusyMarkSidebarShortcutLabels.outline, - _SidebarTab.git => BusyMarkSidebarShortcutLabels.git, +String? _sidebarTabShortcut(BuildContext context, _SidebarTab tab) { + final commands = + BusyMarkCommandRegistryScope.read(context) ?? + BusyMarkCommandCatalog.metadata; + final id = switch (tab) { + _SidebarTab.files => BusyMarkCommandIds.sidebarFiles, + _SidebarTab.toc => BusyMarkCommandIds.sidebarToc, + _SidebarTab.outline => BusyMarkCommandIds.sidebarOutline, + _SidebarTab.git => BusyMarkCommandIds.sidebarGit, }; + return commands[id]?.shortcut?.label; } String? _gitBranchLabel(BuildContext context, GitRepositoryInfo? repository) { @@ -2594,7 +2583,7 @@ class _SidebarHeader extends StatelessWidget { tab, Directionality.of(context), ), - shortcut: _sidebarTabShortcut(tab), + shortcut: _sidebarTabShortcut(context, tab), checked: tab == selectedTab, trailingCheck: true, ), @@ -3616,7 +3605,11 @@ class _FilesTabState extends ConsumerState<_FilesTab> { } return Shortcuts( shortcuts: { - BusyMarkTreeShortcutActivators.deleteSelection: + (BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog.metadata)[BusyMarkCommandIds + .treeDeleteSelection]! + .shortcut! + .activator: const _DeleteSelectedFileTreeEntryIntent(), }, child: Actions( @@ -4016,7 +4009,12 @@ Future<_FileTreeAction?> _showFileTreeMenu( ? context.l10n.safeDeleteTopicFile : context.l10n.delete, icon: BusyMarkGlyphs.delete, - shortcut: BusyMarkTreeShortcutLabels.deleteSelection, + shortcut: + (BusyMarkCommandRegistryScope.read(context) ?? + BusyMarkCommandCatalog.metadata)[BusyMarkCommandIds + .treeDeleteSelection] + ?.shortcut + ?.label, ), const PopupMenuDivider(height: BusyMarkSpacing.sm), BusyMarkPopupMenuItem( @@ -4832,7 +4830,11 @@ class _TocTabState extends ConsumerState<_TocTab> { } return Shortcuts( shortcuts: { - BusyMarkTreeShortcutActivators.deleteSelection: + (BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog.metadata)[BusyMarkCommandIds + .treeDeleteSelection]! + .shortcut! + .activator: const _RemoveSelectedTocEntryIntent(), }, child: Actions( @@ -5794,7 +5796,12 @@ Future<_TocTreeAction?> _showTocTreeMenu( value: _TocTreeAction.removeFromToc, label: context.l10n.removeTocElement, icon: BusyMarkGlyphs.outdentFor(Directionality.of(context)), - shortcut: BusyMarkTreeShortcutLabels.deleteSelection, + shortcut: + (BusyMarkCommandRegistryScope.read(context) ?? + BusyMarkCommandCatalog.metadata)[BusyMarkCommandIds + .treeDeleteSelection] + ?.shortcut + ?.label, enabled: canEditStructure, ), BusyMarkPopupMenuItem( @@ -9461,6 +9468,25 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { decoration: BoxDecoration(color: colors.view), child: Column( children: [ + if (activeBuffer?.recovered == true) + _RecoveredDocumentBanner( + buffer: activeBuffer!, + onSave: () => unawaited( + (BusyMarkCommandRegistryScope.read(context) ?? + BusyMarkCommandCatalog.metadata) + .execute(BusyMarkCommandIds.save), + ), + onSaveAs: () => unawaited(saveActiveToNewLocation(context, ref)), + onDiscard: () => unawaited( + activeBuffer.deletedOnDisk + ? ref + .read(workspaceControllerProvider.notifier) + .closeDocumentBuffer(activeBuffer.id, discard: true) + : ref + .read(workspaceControllerProvider.notifier) + .discardActiveChanges(), + ), + ), if (activeBuffer != null && activeBuffer.diskState != DocumentDiskState.present && activeBuffer.diskState != DocumentDiskState.changed) @@ -9479,6 +9505,11 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { onKeepMine: () => ref .read(workspaceControllerProvider.notifier) .keepBufferVersion(activeBuffer.id), + onSave: () => unawaited( + (BusyMarkCommandRegistryScope.read(context) ?? + BusyMarkCommandCatalog.metadata) + .execute(BusyMarkCommandIds.save), + ), onSaveAs: () => unawaited(saveActiveToNewLocation(context, ref)), ), Expanded( @@ -9488,6 +9519,33 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { Expanded( child: BusyMarkWysiwygEditor( document: wysiwygDocument, + documentId: activeBuffer?.id, + initialSessionState: + activeBuffer?.editorState.wysiwygState ?? + const WysiwygEditorSessionState(), + useExternalUndoHistory: true, + onSessionChanged: (documentId, session) { + final latest = ref + .read(workspaceControllerProvider) + .documentBuffers + .where((buffer) => buffer.id == documentId) + .firstOrNull; + if (latest == null || + _sameWysiwygSession( + latest.editorState.wysiwygState, + session, + )) { + return; + } + ref + .read(workspaceControllerProvider.notifier) + .updateDocumentEditorState( + documentId, + latest.editorState.copyWith( + wysiwygState: session, + ), + ); + }, headerBarService: headerBar, workspaceRoot: _imageWorkspaceRoot( widget.state.workspace, @@ -10258,12 +10316,26 @@ DocumentOutlineHeading? _outlineHeadingAtOrBeforeLine( return result; } +bool _sameWysiwygSession( + WysiwygEditorSessionState left, + WysiwygEditorSessionState right, +) { + return left.activeBlockId == right.activeBlockId && + left.anchorBlockId == right.anchorBlockId && + left.anchorOffset == right.anchorOffset && + left.extentBlockId == right.extentBlockId && + left.extentOffset == right.extentOffset && + left.viewportBlockId == right.viewportBlockId && + (left.viewportAlignment - right.viewportAlignment).abs() < 0.001; +} + class _ExternalFileBanner extends StatelessWidget { const _ExternalFileBanner({ required this.buffer, required this.onCompare, required this.onReload, required this.onKeepMine, + required this.onSave, required this.onSaveAs, }); @@ -10271,6 +10343,7 @@ class _ExternalFileBanner extends StatelessWidget { final VoidCallback? onCompare; final VoidCallback? onReload; final VoidCallback onKeepMine; + final VoidCallback onSave; final VoidCallback onSaveAs; @override @@ -10319,6 +10392,11 @@ class _ExternalFileBanner extends StatelessWidget { child: Text(context.l10n.keepMine), ), const SizedBox(width: BusyMarkSpacing.xs), + BusyMarkPushButton.standard( + onPressed: onSave, + child: Text(context.l10n.save), + ), + const SizedBox(width: BusyMarkSpacing.xs), BusyMarkPushButton.standard( onPressed: onSaveAs, child: Text(context.l10n.saveAs), @@ -10330,6 +10408,63 @@ class _ExternalFileBanner extends StatelessWidget { } } +class _RecoveredDocumentBanner extends StatelessWidget { + const _RecoveredDocumentBanner({ + required this.buffer, + required this.onSave, + required this.onSaveAs, + required this.onDiscard, + }); + + final DocumentBuffer buffer; + final VoidCallback onSave; + final VoidCallback onSaveAs; + final VoidCallback onDiscard; + + @override + Widget build(BuildContext context) { + final colors = BusyMarkSurfaceColors.of(context); + return DecoratedBox( + decoration: BoxDecoration( + color: colors.admonitionTip, + border: Border(bottom: BorderSide(color: colors.subtleBorder)), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: BusyMarkSpacing.md, + vertical: BusyMarkSpacing.sm, + ), + child: Row( + children: [ + const Icon(BusyMarkGlyphs.history, size: BusyMarkSizes.iconSm), + const SizedBox(width: BusyMarkSpacing.sm), + Expanded( + child: Text( + context.l10n.recoveredDocumentReview(buffer.displayName), + ), + ), + BusyMarkPushButton.standard( + onPressed: onSave, + child: Text(context.l10n.save), + ), + const SizedBox(width: BusyMarkSpacing.xs), + BusyMarkPushButton.standard( + onPressed: onSaveAs, + child: Text(context.l10n.saveAs), + ), + const SizedBox(width: BusyMarkSpacing.xs), + BusyMarkPushButton.destructive( + context: context, + onPressed: onDiscard, + child: Text(context.l10n.discard), + ), + ], + ), + ), + ); + } +} + class _DocumentStatusBar extends StatelessWidget { const _DocumentStatusBar({required this.buffer}); @@ -12124,7 +12259,7 @@ class _WorkspaceReplacementReviewDialogState cancelLabel: context.l10n.cancel, saveLabel: context.l10n.applyReplacements, onCancel: () => Navigator.pop(context), - onSave: _selected.isEmpty + onSave: _selected.isEmpty || !widget.preview.isComplete ? null : () => Navigator.pop(context, Set.unmodifiable(_selected)), children: [ @@ -12221,6 +12356,8 @@ String _workspaceReplacementIssueLabel( context.l10n.workspaceReplaceIssueBufferChanged, WorkspaceReplacementIssueKind.normalizationRequired => context.l10n.workspaceReplaceIssueNormalizationRequired, + WorkspaceReplacementIssueKind.applyFailed => + context.l10n.workspaceReplaceIssueApplyFailed, }; } diff --git a/lib/src/workspace/recovery_persistence.dart b/lib/src/workspace/recovery_persistence.dart index 755a7e08..d9281656 100644 --- a/lib/src/workspace/recovery_persistence.dart +++ b/lib/src/workspace/recovery_persistence.dart @@ -89,10 +89,15 @@ class DocumentRecoveryEntry { } class RecoverySnapshot { - const RecoverySnapshot({required this.cleanShutdown, required this.entries}); + const RecoverySnapshot({ + required this.cleanShutdown, + required this.entries, + this.readErrors = 0, + }); final bool cleanShutdown; final List entries; + final int readErrors; } abstract interface class DocumentRecoveryStore { @@ -175,22 +180,52 @@ class JsonDocumentRecoveryStore implements DocumentRecoveryStore { try { final decoded = (jsonDecode(await file.readAsString()) as Map) .cast(); + final entries = []; + var readErrors = 0; + final encodedEntries = decoded['entries']; + if (encodedEntries is List) { + for (final encodedEntry in encodedEntries) { + try { + if (encodedEntry is! Map || encodedEntry['text'] is! String) { + throw const FormatException('Invalid recovery entry'); + } + final entry = DocumentRecoveryEntry.fromJson( + encodedEntry.cast(), + ); + if (entry.id.isEmpty) { + throw const FormatException('Recovery entry has no identity'); + } + entries.add(entry); + } on Object { + readErrors++; + } + } + } else if (encodedEntries != null) { + readErrors++; + } return RecoverySnapshot( cleanShutdown: decoded['cleanShutdown'] as bool? ?? false, - entries: - (decoded['entries'] as List?) - ?.whereType() - .map( - (entry) => DocumentRecoveryEntry.fromJson( - entry.cast(), - ), - ) - .where((entry) => entry.id.isNotEmpty) - .toList() ?? - const [], + entries: List.unmodifiable(entries), + readErrors: readErrors, + ); + } on Object { + await _quarantineMalformedFile(file); + return const RecoverySnapshot( + cleanShutdown: false, + entries: [], + readErrors: 1, ); + } + } + + Future _quarantineMalformedFile(File file) async { + final quarantine = File( + '${file.path}.corrupt-${DateTime.now().microsecondsSinceEpoch}', + ); + try { + await file.rename(quarantine.path); } on Object { - return const RecoverySnapshot(cleanShutdown: false, entries: []); + // The read error is still returned to the controller for user notice. } } diff --git a/lib/src/workspace/session_persistence.dart b/lib/src/workspace/session_persistence.dart index 5f8608ab..150ef4b5 100644 --- a/lib/src/workspace/session_persistence.dart +++ b/lib/src/workspace/session_persistence.dart @@ -5,6 +5,8 @@ import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'document_buffer.dart'; +import 'text_format_metadata.dart'; +import 'workspace_file_snapshot.dart'; class DocumentSessionEntry { const DocumentSessionEntry({ @@ -12,21 +14,32 @@ class DocumentSessionEntry { required this.filePath, required this.untitledName, required this.editorState, + this.lastKnownText, + this.diskSnapshot, + this.format, }); final String id; final String? filePath; final String? untitledName; final DocumentEditorState editorState; + final String? lastKnownText; + final WorkspaceFileSnapshot? diskSnapshot; + final TextFormatMetadata? format; Map toJson() => { 'id': id, 'filePath': filePath, 'untitledName': untitledName, 'editorState': editorState.toJson(), + 'lastKnownText': lastKnownText, + 'diskSnapshot': diskSnapshot?.toJson(), + 'format': format?.toJson(), }; factory DocumentSessionEntry.fromJson(Map json) { + final diskSnapshot = json['diskSnapshot']; + final format = json['format']; return DocumentSessionEntry( id: json['id']?.toString() ?? '', filePath: json['filePath']?.toString(), @@ -34,6 +47,13 @@ class DocumentSessionEntry { editorState: DocumentEditorState.fromJson( (json['editorState'] as Map?)?.cast() ?? const {}, ), + lastKnownText: json['lastKnownText']?.toString(), + diskSnapshot: diskSnapshot is Map + ? WorkspaceFileSnapshot.fromJson(diskSnapshot.cast()) + : null, + format: format is Map + ? TextFormatMetadata.fromJson(format.cast()) + : null, ); } } @@ -160,14 +180,18 @@ Future writeAtomicJson(File target, Map json) { Future _writeAtomic(File target, Map json) async { await target.parent.create(recursive: true); + await _setPrivatePermissions(target.parent, directory: true); final staging = await target.parent.createTemp('.busymark-state-'); + await _setPrivatePermissions(staging, directory: true); final staged = File(p.join(staging.path, p.basename(target.path))); try { await staged.writeAsString( const JsonEncoder.withIndent(' ').convert(json), flush: true, ); + await _setPrivatePermissions(staged, directory: false); await staged.rename(target.path); + await _setPrivatePermissions(target, directory: false); } finally { try { if (await staged.exists()) { @@ -185,3 +209,22 @@ Future _writeAtomic(File target, Map json) async { } } } + +Future _setPrivatePermissions( + FileSystemEntity entity, { + required bool directory, +}) async { + if (Platform.isWindows) { + return; + } + final result = await Process.run('chmod', [ + directory ? '700' : '600', + entity.path, + ]); + if (result.exitCode != 0) { + throw FileSystemException( + 'Could not restrict application state permissions: ${result.stderr}', + entity.path, + ); + } +} diff --git a/lib/src/workspace/workspace_controller.dart b/lib/src/workspace/workspace_controller.dart index 4afe626d..da6e17fa 100644 --- a/lib/src/workspace/workspace_controller.dart +++ b/lib/src/workspace/workspace_controller.dart @@ -190,10 +190,19 @@ class WorkspaceController extends Notifier { } final recovery = await _recoveryStart; final session = await _sessionStore.load(); - final recoverEntries = recovery.cleanShutdown - ? const [] - : recovery.entries; + // Entries are authoritative even if the last shutdown was marked clean. + // This protects users when close confirmation is disabled while dirty + // buffers still exist. + final recoverEntries = recovery.entries; if (session == null && recoverEntries.isEmpty) { + if (recovery.readErrors > 0) { + state = state.copyWith( + message: WorkspaceMessage( + WorkspaceMessageCode.recoveryDamaged, + error: recovery.readErrors, + ), + ); + } return false; } final workspacePath = @@ -206,10 +215,54 @@ class WorkspaceController extends Notifier { .map((entry) => entry.filePath) .whereType() .firstOrNull; + final sessionEntries = session?.tabs ?? const []; try { - final workspace = workspacePath == null - ? _service.createUntitledMarkdown() - : await _service.openPath(workspacePath); + late final Workspace workspace; + if (workspacePath == null) { + workspace = _service.createUntitledMarkdown(); + } else if (await _service.pathExists(workspacePath)) { + workspace = await _service.openPath(workspacePath); + } else { + final activeEntry = sessionEntries + .where((entry) => entry.id == session?.activeBufferId) + .firstOrNull; + final activeRecovery = recoverEntries + .where((entry) => entry.id == session?.activeBufferId) + .firstOrNull; + final activePath = + activeEntry?.filePath ?? + activeRecovery?.filePath ?? + sessionEntries + .map((entry) => entry.filePath) + .whereType() + .firstOrNull ?? + recoverEntries + .map((entry) => entry.filePath) + .whereType() + .firstOrNull; + final seedText = + activeRecovery?.text ?? activeEntry?.lastKnownText ?? ''; + final parsed = _service.createUntitledMarkdown(source: seedText); + final standalone = p.extension(workspacePath).isNotEmpty; + workspace = Workspace( + id: 'missing:$workspacePath', + rootPath: workspacePath, + kind: standalone + ? WorkspaceKind.singleMarkdown + : WorkspaceKind.markdownFolder, + openedAt: DateTime.now(), + activeFilePath: activePath, + openFilePaths: [ + for (final entry in sessionEntries) + if (entry.filePath != null) entry.filePath!, + for (final entry in recoverEntries) + if (entry.filePath != null) entry.filePath!, + ], + files: const [], + diagnostics: parsed.diagnostics, + markdown: parsed.markdown, + ); + } final recoveryById = { for (final entry in recoverEntries) entry.id: entry, }; @@ -218,7 +271,6 @@ class WorkspaceController extends Notifier { if (entry.filePath != null) entry.filePath!: entry, }; final buffers = []; - final sessionEntries = session?.tabs ?? const []; for (final entry in sessionEntries) { final recovered = recoveryById[entry.id] ?? @@ -260,6 +312,17 @@ class WorkspaceController extends Notifier { preview: _safePreview(reparsed, active.text), documentBuffers: buffers, activeBufferId: active.id, + message: recovery.readErrors > 0 + ? WorkspaceMessage( + WorkspaceMessageCode.recoveryDamaged, + error: recovery.readErrors, + ) + : recoverEntries.isNotEmpty + ? WorkspaceMessage( + WorkspaceMessageCode.recoveryRestored, + error: recoverEntries.length, + ) + : null, ); _editRevision = active.revision; await _startMonitoring(reparsed); @@ -284,9 +347,23 @@ class WorkspaceController extends Notifier { return _restoreRecoveryBuffer(recovery, editorState: session.editorState); } final path = session.filePath; - if (path == null || !await _service.pathExists(path)) { + if (path == null) { return null; } + if (!await _service.pathExists(path)) { + final text = session.lastKnownText ?? ''; + return DocumentBuffer( + id: session.id, + filePath: path, + text: text, + lastSavedText: text, + dirty: false, + diskSnapshot: session.diskSnapshot, + format: session.format ?? TextFormatMetadata.utf8Lf, + editorState: session.editorState, + diskState: DocumentDiskState.deleted, + ); + } final load = await _service.loadTextWithSnapshot(path); return _fileBuffer(path, load).copyWith(editorState: session.editorState); } @@ -394,6 +471,9 @@ class WorkspaceController extends Notifier { filePath: buffer.filePath, untitledName: buffer.untitledName, editorState: buffer.editorState, + lastKnownText: buffer.text, + diskSnapshot: buffer.diskSnapshot, + format: buffer.format, ), ], ), @@ -410,6 +490,12 @@ class WorkspaceController extends Notifier { Future markCleanShutdown() async { await flushPersistence(); + if (state.documentBuffers.any( + (buffer) => buffer.isDirty || buffer.isUntitled, + )) { + // Keep the run unclean while recovery data is still needed. + return; + } await _recoveryStore.markCleanShutdown(); } @@ -462,6 +548,13 @@ class WorkspaceController extends Notifier { if (current == null || path == null) { return; } + final destinationPath = event.destinationPath; + if (event.kind == WorkspaceFileEventKind.moved && + destinationPath != null && + p.equals(path, event.path)) { + await _applyExternalMove(current, destinationPath); + return; + } if (event.kind == WorkspaceFileEventKind.deleted && !await _service.pathExists(path)) { _updateBufferFromMonitor( @@ -518,6 +611,93 @@ class WorkspaceController extends Notifier { } } + Future _applyExternalMove( + DocumentBuffer current, + String destinationPath, + ) async { + final oldPath = current.filePath; + if (oldPath == null) { + return; + } + try { + final disk = await _service.loadTextWithSnapshot(destinationPath); + final contentChanged = !_sameFileSnapshot( + current.diskSnapshot, + disk.snapshot, + ); + final remapped = current.copyWith( + filePath: destinationPath, + text: current.isDirty || !contentChanged ? current.text : disk.text, + lastSavedText: contentChanged && !current.isDirty + ? disk.text + : current.lastSavedText, + dirty: current.isDirty, + diskSnapshot: contentChanged && !current.isDirty + ? disk.snapshot + : current.diskSnapshot, + format: contentChanged && !current.isDirty + ? disk.format + : current.format, + revision: contentChanged && !current.isDirty + ? current.revision + 1 + : current.revision, + diskState: current.isDirty && contentChanged + ? DocumentDiskState.conflict + : DocumentDiskState.present, + diskVersionText: current.isDirty && contentChanged ? disk.text : null, + diskVersionSnapshot: current.isDirty && contentChanged + ? disk.snapshot + : null, + ); + final workspace = state.workspace; + final remappedTabs = workspace == null + ? const [] + : [ + for (final openPath in workspace.openFilePaths) + p.equals(openPath, oldPath) ? destinationPath : openPath, + ]; + final active = state.activeBufferId == current.id; + state = state.copyWith( + documentBuffers: _replaceBuffer(state.documentBuffers, remapped), + workspace: workspace?.copyWith( + activeFilePath: active ? destinationPath : workspace.activeFilePath, + activeFileSnapshot: active + ? remapped.diskSnapshot + : workspace.activeFileSnapshot, + openFilePaths: remappedTabs, + ), + ); + _fileMonitor.updateOpenFilePaths( + state.documentBuffers + .map((buffer) => buffer.filePath) + .whereType(), + ); + if (active && state.workspace != null) { + final reparsed = await _service.reparseActive( + state.workspace!, + remapped.text, + ); + if (state.activeBufferId == current.id && + state.activeBuffer?.filePath == destinationPath) { + state = state.copyWith( + workspace: reparsed.copyWith( + activeFileSnapshot: remapped.diskSnapshot, + openFilePaths: remappedTabs, + ), + preview: _safePreview(reparsed, remapped.text), + ); + } + } + _schedulePersistence(); + } on FileSystemException { + _updateBufferFromMonitor( + current.copyWith(diskState: DocumentDiskState.deleted), + ); + } on FormatException { + // Keep the old buffer and path when the move target cannot be decoded. + } + } + void _updateBufferFromMonitor(DocumentBuffer buffer) { state = state.copyWith( documentBuffers: _replaceBuffer(state.documentBuffers, buffer), @@ -570,10 +750,8 @@ class WorkspaceController extends Notifier { if (buffer == null) { return; } - final snapshot = buffer.diskVersionSnapshot ?? buffer.diskSnapshot; _updateBufferFromMonitor( buffer.copyWith( - diskSnapshot: snapshot, diskState: buffer.filePath == null ? DocumentDiskState.present : DocumentDiskState.changed, @@ -1518,6 +1696,7 @@ class WorkspaceController extends Notifier { final next = buffer.copyWith( text: text, dirty: text != buffer.lastSavedText || buffer.isUntitled, + format: buffer.format.copyWith(hasFinalNewline: text.endsWith('\n')), revision: buffer.revision + 1, editorState: buffer.editorState.copyWith( undoState: undo.afterUndo(buffer.text), @@ -1541,6 +1720,7 @@ class WorkspaceController extends Notifier { final next = buffer.copyWith( text: text, dirty: text != buffer.lastSavedText || buffer.isUntitled, + format: buffer.format.copyWith(hasFinalNewline: text.endsWith('\n')), revision: buffer.revision + 1, editorState: buffer.editorState.copyWith( undoState: undo.afterRedo(buffer.text), @@ -2463,7 +2643,9 @@ class WorkspaceController extends Notifier { } bool _canAutoSaveActive() { - return state.activeBuffer?.filePath != null; + final buffer = state.activeBuffer; + return buffer?.filePath != null && + buffer!.diskState == DocumentDiskState.present; } void _resetSaveTracking({bool dirty = false}) { diff --git a/lib/src/workspace/workspace_message.dart b/lib/src/workspace/workspace_message.dart index 95194c7c..1f345e67 100644 --- a/lib/src/workspace/workspace_message.dart +++ b/lib/src/workspace/workspace_message.dart @@ -13,6 +13,8 @@ enum WorkspaceMessageCode { saveFailed, fileOperationFailed, validationFailed, + recoveryRestored, + recoveryDamaged, } class WorkspaceMessage { @@ -45,6 +47,12 @@ String localizeWorkspaceMessage( l10n.workspaceErrorFileOperationFailed(error), WorkspaceMessageCode.validationFailed => l10n.workspaceErrorValidationFailed(error), + WorkspaceMessageCode.recoveryRestored => l10n.workspaceRecoveryRestored( + (message.error as num?)?.toInt() ?? 0, + ), + WorkspaceMessageCode.recoveryDamaged => l10n.workspaceRecoveryDamaged( + (message.error as num?)?.toInt() ?? 0, + ), }; } diff --git a/lib/src/workspace/workspace_safety.dart b/lib/src/workspace/workspace_safety.dart index afc4d8a1..4dfb8d93 100644 --- a/lib/src/workspace/workspace_safety.dart +++ b/lib/src/workspace/workspace_safety.dart @@ -28,12 +28,46 @@ Future confirmSafeToContinue(BuildContext context, WidgetRef ref) async { if (!state.hasUnsavedChanges) { return true; } - final controller = ref.read(workspaceControllerProvider.notifier); - final target = controller.captureActiveDocumentSaveTarget(); - if (target == null) { - return false; + return _confirmUnsavedChanges( + context, + ref, + dirtyBufferIds: state.dirtyBuffers.map((buffer) => buffer.id).toList(), + ); +} + +Future confirmSafeToCloseActiveDocument( + BuildContext context, + WidgetRef ref, +) async { + final active = ref.read(workspaceControllerProvider).activeBuffer; + if (active == null || !active.isDirty) { + return true; } - final fileName = target.path?.split('/').last ?? context.l10n.currentFile; + return _confirmUnsavedChanges(context, ref, dirtyBufferIds: [active.id]); +} + +Future _confirmUnsavedChanges( + BuildContext context, + WidgetRef ref, { + required List dirtyBufferIds, +}) async { + final initialState = ref.read(workspaceControllerProvider); + final dirtyBuffers = [ + for (final id in dirtyBufferIds) + if (initialState.documentBuffers + .where((buffer) => buffer.id == id && buffer.isDirty) + .firstOrNull + case final buffer?) + buffer, + ]; + if (dirtyBuffers.isEmpty) { + return true; + } + final initialWorkspaceId = initialState.workspace?.id; + final initialActiveBufferId = initialState.activeBufferId; + final initialRevisions = { + for (final buffer in dirtyBuffers) buffer.id: buffer.revision, + }; final headerBar = ref.read(linuxHeaderBarServiceProvider); final action = await showBusyMarkModalDialog<_UnsavedChangesAction>( context, @@ -61,32 +95,153 @@ Future confirmSafeToContinue(BuildContext context, WidgetRef ref) async { onPressed: () => Navigator.pop(context, _UnsavedChangesAction.save), ), ], - children: [Text(context.l10n.unsavedChangesMessage(fileName))], + children: [ + Text( + dirtyBuffers.length == 1 + ? context.l10n.unsavedChangesMessage( + dirtyBuffers.single.displayName, + ) + : context.l10n.unsavedChangesMultipleMessage(dirtyBuffers.length), + ), + if (dirtyBuffers.length > 1) ...[ + const SizedBox(height: BusyMarkSpacing.md), + BusyMarkGroupedList( + filled: true, + children: [ + for (final buffer in dirtyBuffers) + BusyMarkActionRow( + title: buffer.displayName, + subtitle: buffer.filePath, + leading: const Icon(BusyMarkGlyphs.document), + ), + ], + ), + ], + ], ), ); + final currentState = ref.read(workspaceControllerProvider); + final currentDirtyIds = currentState.dirtyBuffers + .map((buffer) => buffer.id) + .toSet(); + if (action != null && + action != _UnsavedChangesAction.cancel && + (currentState.workspace?.id != initialWorkspaceId || + currentState.activeBufferId != initialActiveBufferId || + currentDirtyIds.length != initialRevisions.length || + !currentDirtyIds.containsAll(initialRevisions.keys) || + initialRevisions.entries.any((entry) { + final current = currentState.documentBuffers + .where((buffer) => buffer.id == entry.key) + .firstOrNull; + return current == null || current.revision != entry.value; + }))) { + return false; + } + if (action == _UnsavedChangesAction.discard) { - return controller.discardActiveChanges(target: target); + return _discardDirtyDocuments(ref, dirtyBufferIds); } if (action == _UnsavedChangesAction.save) { if (!context.mounted) { return false; } - return saveActiveWithOverwriteConfirmation(context, ref, target: target); + return _saveDirtyDocuments(context, ref, dirtyBufferIds); } return false; } +Future _saveDirtyDocuments( + BuildContext context, + WidgetRef ref, + List bufferIds, +) async { + final controller = ref.read(workspaceControllerProvider.notifier); + final originalActiveId = ref.read(workspaceControllerProvider).activeBufferId; + for (final bufferId in bufferIds) { + final current = ref + .read(workspaceControllerProvider) + .documentBuffers + .where((buffer) => buffer.id == bufferId) + .firstOrNull; + if (current == null || !current.isDirty) { + continue; + } + if (!await controller.activateDocumentBuffer(bufferId) || + !context.mounted || + !await saveActiveWithOverwriteConfirmation(context, ref)) { + await _restoreActiveBuffer(ref, controller, originalActiveId); + return false; + } + } + await _restoreActiveBuffer(ref, controller, originalActiveId); + return bufferIds.every((id) { + final buffer = ref + .read(workspaceControllerProvider) + .documentBuffers + .where((candidate) => candidate.id == id) + .firstOrNull; + return buffer == null || !buffer.isDirty; + }); +} + +Future _discardDirtyDocuments( + WidgetRef ref, + List bufferIds, +) async { + final controller = ref.read(workspaceControllerProvider.notifier); + final originalActiveId = ref.read(workspaceControllerProvider).activeBufferId; + for (final bufferId in bufferIds) { + final current = ref + .read(workspaceControllerProvider) + .documentBuffers + .where((buffer) => buffer.id == bufferId) + .firstOrNull; + if (current == null || !current.isDirty) { + continue; + } + if (!await controller.activateDocumentBuffer(bufferId)) { + await _restoreActiveBuffer(ref, controller, originalActiveId); + return false; + } + final target = controller.captureActiveDocumentSaveTarget(); + final discarded = current.deletedOnDisk + ? await controller.closeDocumentBuffer(bufferId, discard: true) + : await controller.discardActiveChanges(target: target); + if (!discarded) { + await _restoreActiveBuffer(ref, controller, originalActiveId); + return false; + } + } + await _restoreActiveBuffer(ref, controller, originalActiveId); + return true; +} + +Future _restoreActiveBuffer( + WidgetRef ref, + WorkspaceController controller, + String? bufferId, +) async { + if (bufferId != null && + ref + .read(workspaceControllerProvider) + .documentBuffers + .any((buffer) => buffer.id == bufferId)) { + await controller.activateDocumentBuffer(bufferId); + } +} + Future saveOrConfirmSafeToChangeActiveFile( BuildContext context, WidgetRef ref, ) async { final state = ref.read(workspaceControllerProvider); - if (!state.hasUnsavedChanges) { + if (!state.isDirty) { return true; } if (!ref.read(appSettingsControllerProvider).autoSave) { - return confirmSafeToContinue(context, ref); + return confirmSafeToCloseActiveDocument(context, ref); } return ref .read(workspaceControllerProvider.notifier) diff --git a/lib/src/workspace/workspace_service.dart b/lib/src/workspace/workspace_service.dart index fc2f1a4b..7cfde891 100644 --- a/lib/src/workspace/workspace_service.dart +++ b/lib/src/workspace/workspace_service.dart @@ -26,6 +26,28 @@ import '../writerside/writerside_topic_removal_service.dart'; import 'workspace_model.dart'; import 'text_format_metadata.dart'; +class WorkspaceBatchTextWrite { + const WorkspaceBatchTextWrite({ + required this.path, + required this.text, + required this.expectedSnapshot, + required this.format, + this.mixedNormalization, + }); + + final String path; + final String text; + final WorkspaceFileSnapshot expectedSnapshot; + final TextFormatMetadata format; + final LineEndingNormalization? mixedNormalization; +} + +class WorkspaceBatchWriteConflict implements Exception { + const WorkspaceBatchWriteConflict(this.path); + + final String path; +} + class WorkspaceService { const WorkspaceService({ this.markdownParser = const MarkdownParser(), @@ -554,6 +576,143 @@ class WorkspaceService { } } + /// Replaces a group of existing files as one recoverable transaction. + /// + /// Every source snapshot is checked before any target is changed. Staged + /// files are atomically exchanged with their targets; if a later exchange + /// fails, already-exchanged files are rolled back. + Future> saveFormattedTextBatch( + List writes, + ) async { + if (writes.isEmpty) { + return const {}; + } + if (!Platform.isLinux || !LinuxAtomicFileApi.instance.isAvailable) { + throw UnsupportedError( + 'Transactional workspace replacement requires Linux renameat2.', + ); + } + final paths = {}; + final staged = <_StagedBatchTextWrite>[]; + final committed = <_StagedBatchTextWrite>[]; + try { + for (final write in writes) { + final savePath = await _saveTargetPath(write.path); + if (!paths.add(p.normalize(p.absolute(savePath)))) { + throw ArgumentError('Duplicate batch write path: ${write.path}'); + } + final target = File(savePath); + final originalBytes = await target.readAsBytes(); + final originalStat = await target.stat(); + final originalSnapshot = _snapshotFromBytes( + originalStat, + originalBytes, + ); + if (originalSnapshot.differsFrom(write.expectedSnapshot)) { + throw WorkspaceBatchWriteConflict(write.path); + } + final bytes = _encodeDocumentText( + write.text, + format: write.format, + mixedNormalization: write.mixedNormalization, + ); + final directory = await target.parent.createTemp( + '.busymark-save-batch-', + ); + final stagedFile = File(p.join(directory.path, 'contents')); + await stagedFile.writeAsBytes(bytes, flush: true); + await _copyFileMode(originalStat, stagedFile); + staged.add( + _StagedBatchTextWrite( + requestPath: write.path, + target: target, + directory: directory, + stagedFile: stagedFile, + expectedSnapshot: write.expectedSnapshot, + bytes: bytes, + ), + ); + } + // Close the validation/staging race before the first exchange. + for (final write in staged) { + final current = await fileSnapshot(write.target.path); + if (current.differsFrom(write.expectedSnapshot)) { + throw WorkspaceBatchWriteConflict(write.requestPath); + } + } + for (final write in staged) { + final error = LinuxAtomicFileApi.instance.exchange( + write.stagedFile.absolute.path, + write.target.absolute.path, + ); + if (error != null) { + throw FileSystemException( + 'Could not commit workspace replacement batch', + write.requestPath, + OSError('atomic exchange failed', error), + ); + } + final displacedBytes = await write.stagedFile.readAsBytes(); + final displacedSnapshot = _snapshotFromBytes( + await write.stagedFile.stat(), + displacedBytes, + ); + if (displacedSnapshot.differsFrom(write.expectedSnapshot)) { + final rollbackError = LinuxAtomicFileApi.instance.exchange( + write.stagedFile.absolute.path, + write.target.absolute.path, + ); + if (rollbackError != null) { + throw FileSystemException( + 'Could not roll back a concurrently changed replacement file', + write.requestPath, + OSError('atomic exchange rollback failed', rollbackError), + ); + } + throw WorkspaceBatchWriteConflict(write.requestPath); + } + committed.add(write); + } + return { + for (final write in staged) + write.requestPath: _snapshotFromBytes( + await write.target.stat(), + write.bytes, + ), + }; + } on Object { + Object? rollbackError; + for (final write in committed.reversed) { + final error = LinuxAtomicFileApi.instance.exchange( + write.stagedFile.absolute.path, + write.target.absolute.path, + ); + if (error != null) { + rollbackError ??= FileSystemException( + 'Could not roll back workspace replacement batch', + write.requestPath, + OSError('atomic exchange rollback failed', error), + ); + } + } + if (rollbackError != null) { + throw rollbackError; + } + rethrow; + } finally { + for (final write in staged) { + await _deleteSaveArtifactBestEffort(write.stagedFile); + try { + if (await write.directory.exists()) { + await write.directory.delete(); + } + } on Object { + // Cleanup must not hide a commit or rollback result. + } + } + } + } + Future createFile( Workspace workspace, String directoryPath, @@ -1533,6 +1692,24 @@ class _StagedSave { final File file; } +class _StagedBatchTextWrite { + const _StagedBatchTextWrite({ + required this.requestPath, + required this.target, + required this.directory, + required this.stagedFile, + required this.expectedSnapshot, + required this.bytes, + }); + + final String requestPath; + final File target; + final Directory directory; + final File stagedFile; + final WorkspaceFileSnapshot expectedSnapshot; + final List bytes; +} + extension _FirstOrNull on Iterable { T? get firstOrNull => isEmpty ? null : first; } diff --git a/test/src/command_registry_test.dart b/test/src/command_registry_test.dart index c8c8956b..c22a5b9e 100644 --- a/test/src/command_registry_test.dart +++ b/test/src/command_registry_test.dart @@ -5,6 +5,16 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/widgets.dart'; void main() { + test('catalog includes every editor command with an executable callback', () { + final registry = BusyMarkCommandCatalog.create(); + + for (final action in BusyMarkEditorShortcutAction.values) { + final command = registry['editor.${action.name}']; + expect(command, isNotNull, reason: action.name); + expect(command!.execute, isNotNull, reason: action.name); + } + }); + BusyMarkCommand command( String id, { BusyMarkShortcutDefinition? shortcut, @@ -32,6 +42,11 @@ void main() { registry.commands.map((command) => command.id).toSet().length, registry.commands.length, ); + expect(registry[BusyMarkCommandIds.editorRefineWithAi]?.execute, isNotNull); + expect( + registry[BusyMarkCommandIds.editorRefineWithAi]?.disabledReason, + isNotNull, + ); }); test('rejects duplicate command IDs', () { @@ -103,4 +118,46 @@ void main() { expect(await registry.execute('test.missing'), isFalse); expect(calls, 1); }); + + testWidgets('contextual commands execute against the captured editor', ( + tester, + ) async { + final registry = BusyMarkCommandCatalog.create(); + var calls = 0; + late BuildContext editorContext; + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Actions( + actions: { + BusyMarkContextCommandIntent: BusyMarkContextCommandAction( + isCommandEnabled: (id) => id == 'editor.bold', + onCommand: (_) => calls++, + ), + }, + child: Focus( + focusNode: focusNode, + child: Builder( + builder: (context) { + editorContext = context; + return const SizedBox(); + }, + ), + ), + ), + ), + ); + focusNode.requestFocus(); + await tester.pump(); + + expect(registry.canExecuteInContext('editor.bold', editorContext), isTrue); + expect( + registry.canExecuteInContext('editor.italic', editorContext), + isFalse, + ); + expect(await registry.executeInContext('editor.bold', editorContext), true); + expect(calls, 1); + }); } diff --git a/test/src/document_persistence_test.dart b/test/src/document_persistence_test.dart index 5026aa32..2bc024ff 100644 --- a/test/src/document_persistence_test.dart +++ b/test/src/document_persistence_test.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:io'; import 'package:busymark/src/app/app_settings.dart'; @@ -11,6 +12,32 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; void main() { + test('file-backed edits update final-newline metadata', () { + final buffer = DocumentBuffer.file( + id: 'file:note', + filePath: '/workspace/note.md', + text: 'Saved\n', + snapshot: WorkspaceFileSnapshot( + modifiedAt: DateTime.utc(2026), + size: 6, + contentHash: 'saved', + ), + format: const TextFormatMetadata( + hasUtf8Bom: false, + lineEnding: DocumentLineEnding.lf, + hasFinalNewline: true, + ), + ); + + final removed = buffer.edited('Saved'); + final restored = removed.edited('Saved\n'); + + expect(removed.format.hasFinalNewline, isFalse); + expect(removed.format.formattedText(removed.text), 'Saved'); + expect(restored.format.hasFinalNewline, isTrue); + expect(restored.format.formattedText(restored.text), 'Saved\n'); + }); + test('session store round-trips ordered tabs and editor state', () async { final directory = await Directory.systemTemp.createTemp( 'busymark-session-', @@ -33,6 +60,17 @@ void main() { scrollOffset: 42, foldedRegionKeys: {'heading:2'}, ), + lastKnownText: '# First\n', + diskSnapshot: WorkspaceFileSnapshot( + modifiedAt: DateTime.utc(2026), + size: 8, + contentHash: 'first', + ), + format: const TextFormatMetadata( + hasUtf8Bom: false, + lineEnding: DocumentLineEnding.crlf, + hasFinalNewline: true, + ), ), const DocumentSessionEntry( id: 'second', @@ -59,6 +97,9 @@ void main() { ); expect(restored?.tabs.first.editorState.scrollOffset, 42); expect(restored?.tabs.first.editorState.foldedRegionKeys, {'heading:2'}); + expect(restored?.tabs.first.lastKnownText, '# First\n'); + expect(restored?.tabs.first.diskSnapshot?.contentHash, 'first'); + expect(restored?.tabs.first.format?.lineEnding, DocumentLineEnding.crlf); }); test('recovery store distinguishes clean and unclean runs', () async { @@ -100,6 +141,55 @@ void main() { await afterCrash.markCleanShutdown(); final normalStart = JsonDocumentRecoveryStore(filePathOverride: path); - expect((await normalStart.beginRun()).cleanShutdown, isTrue); + final cleanRecovery = await normalStart.beginRun(); + expect(cleanRecovery.cleanShutdown, isTrue); + expect(cleanRecovery.entries.single.text, '# Unsaved\n'); }); + + test( + 'recovery keeps valid entries when another record is malformed', + () async { + final directory = await Directory.systemTemp.createTemp( + 'busymark-recovery-partial-', + ); + addTearDown(() => directory.delete(recursive: true)); + final path = p.join(directory.path, 'recovery.json'); + final store = JsonDocumentRecoveryStore(filePathOverride: path); + final buffer = DocumentBuffer.untitled( + id: 'untitled:1', + name: 'Untitled 1', + text: 'Keep me', + ); + await store.writeEntries([ + DocumentRecoveryEntry.fromBuffer(buffer, workspacePath: null), + ]); + final decoded = jsonDecode(await File(path).readAsString()) as Map; + (decoded['entries'] as List).add({'id': 'broken', 'text': 42}); + await File(path).writeAsString(jsonEncode(decoded)); + + final recovered = await store.beginRun(); + + expect(recovered.entries.single.id, 'untitled:1'); + expect(recovered.entries.single.text, 'Keep me'); + expect(recovered.readErrors, 1); + }, + ); + + test( + 'recovery state is written with private POSIX permissions', + () async { + final directory = await Directory.systemTemp.createTemp( + 'busymark-recovery-permissions-', + ); + addTearDown(() => directory.delete(recursive: true)); + final path = p.join(directory.path, 'recovery.json'); + final store = JsonDocumentRecoveryStore(filePathOverride: path); + + await store.writeEntries(const []); + + expect((await File(path).stat()).mode & 0x1ff, 0x180); + expect((await directory.stat()).mode & 0x1ff, 0x1c0); + }, + skip: Platform.isWindows ? 'POSIX permissions only.' : false, + ); } diff --git a/test/src/search_replace_service_test.dart b/test/src/search_replace_service_test.dart index 40aaf273..a2afab6b 100644 --- a/test/src/search_replace_service_test.dart +++ b/test/src/search_replace_service_test.dart @@ -186,6 +186,95 @@ void main() { WorkspaceReplacementIssueKind.bufferRevisionChanged, ); }); + + test('truncated workspace previews cannot be applied', () async { + final directory = await Directory.systemTemp.createTemp( + 'busymark-replace-truncated-', + ); + addTearDown(() => directory.delete(recursive: true)); + final file = File(p.join(directory.path, 'note.md')); + await file.writeAsString('cat cat'); + const workspaceService = WorkspaceService(); + const limitedService = SearchReplacementService(maximumMatches: 1); + final workspace = Workspace( + id: directory.path, + rootPath: directory.path, + kind: WorkspaceKind.markdownFolder, + openedAt: DateTime(2026), + files: [await _documentFile(file, directory.path)], + diagnostics: const [], + ); + final state = WorkspaceState(workspace: workspace); + final preview = await limitedService.previewWorkspace( + state: state, + workspaceService: workspaceService, + options: const SourceSearchOptions(query: 'cat'), + replacement: 'dog', + ); + + expect(preview.isComplete, isFalse); + final result = await limitedService.applyWorkspace( + preview: preview, + selectedMatchIds: {preview.files.single.matches.single.id}, + currentState: () => state, + updateBuffer: (_, _) => fail('no buffers should be updated'), + workspaceService: workspaceService, + ); + + expect(result.appliedFiles, 0); + expect(result.issues.single.kind, WorkspaceReplacementIssueKind.truncated); + expect(await file.readAsString(), 'cat cat'); + }); + + test('stale later files abort before earlier files are changed', () async { + final directory = await Directory.systemTemp.createTemp( + 'busymark-replace-preflight-', + ); + addTearDown(() => directory.delete(recursive: true)); + final first = File(p.join(directory.path, 'a.md')); + final second = File(p.join(directory.path, 'b.md')); + await first.writeAsString('cat first'); + await second.writeAsString('cat second'); + const workspaceService = WorkspaceService(); + final workspace = Workspace( + id: directory.path, + rootPath: directory.path, + kind: WorkspaceKind.markdownFolder, + openedAt: DateTime(2026), + files: [ + await _documentFile(first, directory.path), + await _documentFile(second, directory.path), + ], + diagnostics: const [], + ); + final state = WorkspaceState(workspace: workspace); + final preview = await replacementService.previewWorkspace( + state: state, + workspaceService: workspaceService, + options: const SourceSearchOptions(query: 'cat'), + replacement: 'dog', + ); + await second.writeAsString('changed after preview'); + + final result = await replacementService.applyWorkspace( + preview: preview, + selectedMatchIds: { + for (final file in preview.files) + for (final match in file.matches) match.id, + }, + currentState: () => state, + updateBuffer: (_, _) => fail('no buffers should be updated'), + workspaceService: workspaceService, + ); + + expect(result.appliedFiles, 0); + expect( + result.issues.single.kind, + WorkspaceReplacementIssueKind.changedSincePreview, + ); + expect(await first.readAsString(), 'cat first'); + expect(await second.readAsString(), 'changed after preview'); + }); } Future _documentFile(File file, String root) async { diff --git a/test/src/source_audit_test.dart b/test/src/source_audit_test.dart index bf812af8..301025ce 100644 --- a/test/src/source_audit_test.dart +++ b/test/src/source_audit_test.dart @@ -902,7 +902,7 @@ void main() { ), ), ); - expect(workspace, contains('shortcut: _sidebarTabShortcut(tab)')); + expect(workspace, contains('shortcut: _sidebarTabShortcut(context, tab)')); expect( workspace, isNot(contains('BusyMarkSidebarShortcutActivators.history')), @@ -1366,15 +1366,15 @@ void main() { expect(workspace, contains('onSecondaryTapUp')); expect( RegExp( - r'BusyMarkTreeShortcutActivators\.deleteSelection', + r'BusyMarkCommandIds\s*\.treeDeleteSelection', ).allMatches(workspace).length, - greaterThanOrEqualTo(2), + greaterThanOrEqualTo(4), ); expect( RegExp( - r'shortcut: BusyMarkTreeShortcutLabels\.deleteSelection', + r'BusyMarkCommandRegistryScope\.(?:read|maybeOf)\(context\)', ).allMatches(workspace).length, - greaterThanOrEqualTo(2), + greaterThanOrEqualTo(4), ); expect( RegExp( diff --git a/test/src/workspace_controller_test.dart b/test/src/workspace_controller_test.dart index c40a4716..3ebfb40a 100644 --- a/test/src/workspace_controller_test.dart +++ b/test/src/workspace_controller_test.dart @@ -2,7 +2,12 @@ import 'dart:async'; import 'dart:io'; import 'package:busymark/src/app/app_settings.dart'; +import 'package:busymark/src/workspace/document_buffer.dart'; +import 'package:busymark/src/workspace/recovery_persistence.dart'; +import 'package:busymark/src/workspace/session_persistence.dart'; +import 'package:busymark/src/workspace/text_format_metadata.dart'; import 'package:busymark/src/workspace/workspace_controller.dart'; +import 'package:busymark/src/workspace/workspace_file_monitor.dart'; import 'package:busymark/src/workspace/workspace_message.dart'; import 'package:busymark/src/workspace/workspace_model.dart'; import 'package:busymark/src/workspace/workspace_service.dart'; @@ -1052,15 +1057,232 @@ void main() { settingsController.dispose(); await directory.delete(recursive: true); }); + + test('saving preserves a manually removed final newline', () async { + final directory = await Directory.systemTemp.createTemp( + 'busymark-final-newline-', + ); + addTearDown(() => directory.delete(recursive: true)); + final file = File(p.join(directory.path, 'note.md')); + await file.writeAsString('Saved\n'); + final harness = await _createControllerHarness(); + + await harness.controller.openPath(file.path); + harness.controller.updateActiveText('Saved'); + + expect(await harness.controller.saveActive(), isTrue); + expect(await file.readAsString(), 'Saved'); + expect( + harness.controller.state.activeBuffer?.format.hasFinalNewline, + false, + ); + }); + + test( + 'Keep Mine retains the conflict snapshot until explicit overwrite', + () async { + final directory = await Directory.systemTemp.createTemp( + 'busymark-keep-mine-', + ); + addTearDown(() => directory.delete(recursive: true)); + File(p.join(directory.path, 'a.md')).writeAsStringSync('Original\n'); + File(p.join(directory.path, 'b.md')).writeAsStringSync('Other\n'); + final monitor = WorkspaceFileMonitor( + debounce: const Duration(milliseconds: 10), + ); + addTearDown(monitor.dispose); + final harness = await _createControllerHarness(fileMonitor: monitor); + + await harness.controller.openPath(directory.path); + final path = harness.controller.state.activeBuffer!.filePath!; + final originalSnapshot = + harness.controller.state.activeBuffer!.diskSnapshot; + harness.controller.updateActiveText('Mine\n'); + await File(path).writeAsString('External\n'); + await _waitFor( + () => harness.controller.state.activeBuffer?.hasConflict == true, + ); + + harness.controller.keepBufferVersion( + harness.controller.state.activeBuffer!.id, + ); + + expect( + harness.controller.state.activeBuffer?.diskSnapshot, + same(originalSnapshot), + ); + expect( + harness.controller.state.activeBuffer?.diskState, + DocumentDiskState.changed, + ); + expect(await harness.controller.saveActive(), isFalse); + expect(await File(path).readAsString(), 'External\n'); + }, + ); + + test('external file moves remap the open document buffer', () async { + final directory = await Directory.systemTemp.createTemp( + 'busymark-external-move-', + ); + addTearDown(() => directory.delete(recursive: true)); + File(p.join(directory.path, 'a.md')).writeAsStringSync('A\n'); + File(p.join(directory.path, 'b.md')).writeAsStringSync('B\n'); + final monitor = WorkspaceFileMonitor( + debounce: const Duration(milliseconds: 10), + ); + addTearDown(monitor.dispose); + final harness = await _createControllerHarness(fileMonitor: monitor); + + await harness.controller.openPath(directory.path); + final oldPath = harness.controller.state.activeBuffer!.filePath!; + final newPath = p.join(directory.path, 'moved.md'); + await File(oldPath).rename(newPath); + await _waitFor( + () => harness.controller.state.activeBuffer?.filePath == newPath, + ); + + expect(harness.controller.state.workspace?.activeFilePath, newPath); + expect( + harness.controller.state.workspace?.openFilePaths, + contains(newPath), + ); + expect( + harness.controller.state.activeBuffer?.diskState, + DocumentDiskState.present, + ); + }); + + test('restored sessions retain tabs whose files are missing', () async { + final directory = await Directory.systemTemp.createTemp( + 'busymark-missing-session-file-', + ); + addTearDown(() => directory.delete(recursive: true)); + final missingPath = p.join(directory.path, 'missing.md'); + final sessionStore = MemoryDocumentSessionStore() + ..value = WorkspaceSessionSnapshot( + workspacePath: directory.path, + activeBufferId: 'missing', + tabs: [ + DocumentSessionEntry( + id: 'missing', + filePath: missingPath, + untitledName: null, + editorState: const DocumentEditorState(), + lastKnownText: 'Last disk contents\n', + format: const TextFormatMetadata( + hasUtf8Bom: false, + lineEnding: DocumentLineEnding.lf, + hasFinalNewline: true, + ), + ), + ], + ); + final harness = await _createControllerHarness(sessionStore: sessionStore); + + expect(await harness.controller.restorePreviousSession(), isTrue); + expect(harness.controller.state.activeBuffer?.filePath, missingPath); + expect(harness.controller.state.activeBuffer?.text, 'Last disk contents\n'); + expect(harness.controller.state.activeBuffer?.deletedOnDisk, isTrue); + }); + + test('restored standalone sessions retain a missing document', () async { + final directory = await Directory.systemTemp.createTemp( + 'busymark-missing-standalone-session-', + ); + addTearDown(() => directory.delete(recursive: true)); + final missingPath = p.join(directory.path, 'missing.md'); + final sessionStore = MemoryDocumentSessionStore() + ..value = WorkspaceSessionSnapshot( + workspacePath: missingPath, + activeBufferId: 'missing', + tabs: [ + DocumentSessionEntry( + id: 'missing', + filePath: missingPath, + untitledName: null, + editorState: const DocumentEditorState(), + lastKnownText: 'Standalone contents\n', + format: const TextFormatMetadata( + hasUtf8Bom: false, + lineEnding: DocumentLineEnding.lf, + hasFinalNewline: true, + ), + ), + ], + ); + final harness = await _createControllerHarness(sessionStore: sessionStore); + + expect(await harness.controller.restorePreviousSession(), isTrue); + expect( + harness.controller.state.workspace?.kind, + WorkspaceKind.singleMarkdown, + ); + expect(harness.controller.state.activeBuffer?.filePath, missingPath); + expect( + harness.controller.state.activeBuffer?.text, + 'Standalone contents\n', + ); + expect(harness.controller.state.activeBuffer?.deletedOnDisk, isTrue); + }); + + test('clean marker cannot hide remaining recovery entries', () async { + final recoveryStore = MemoryDocumentRecoveryStore(); + final sessionStore = MemoryDocumentSessionStore(); + final recoveredBuffer = DocumentBuffer.untitled( + id: 'untitled:recovered', + name: 'Recovered', + text: 'Unsaved recovery', + ); + recoveryStore.value = RecoverySnapshot( + cleanShutdown: true, + entries: [ + DocumentRecoveryEntry.fromBuffer(recoveredBuffer, workspacePath: null), + ], + ); + final harness = await _createControllerHarness( + sessionStore: sessionStore, + recoveryStore: recoveryStore, + ); + + expect(await harness.controller.restorePreviousSession(), isTrue); + expect(harness.controller.state.activeBuffer?.recovered, isTrue); + expect( + harness.controller.state.message?.code, + WorkspaceMessageCode.recoveryRestored, + ); + + await harness.controller.markCleanShutdown(); + expect(recoveryStore.value.cleanShutdown, isFalse); + expect(recoveryStore.value.entries, isNotEmpty); + }); +} + +Future _waitFor(bool Function() condition) async { + final deadline = DateTime.now().add(const Duration(seconds: 3)); + while (!condition()) { + if (DateTime.now().isAfter(deadline)) { + fail('Timed out waiting for workspace state'); + } + await Future.delayed(const Duration(milliseconds: 20)); + } } Future<_WorkspaceControllerHarness> _createControllerHarness({ WorkspaceService service = const WorkspaceService(), + WorkspaceFileMonitor? fileMonitor, + DocumentSessionStore? sessionStore, + DocumentRecoveryStore? recoveryStore, }) async { final container = ProviderContainer( overrides: [ localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), workspaceServiceProvider.overrideWithValue(service), + if (fileMonitor != null) + workspaceFileMonitorProvider.overrideWithValue(fileMonitor), + if (sessionStore != null) + documentSessionStoreProvider.overrideWithValue(sessionStore), + if (recoveryStore != null) + documentRecoveryStoreProvider.overrideWithValue(recoveryStore), ], ); addTearDown(container.dispose); @@ -1168,8 +1390,15 @@ class _WorkspaceControllerDriver { Future discardActiveChanges() => _notifier.discardActiveChanges(); + void keepBufferVersion(String bufferId) => + _notifier.keepBufferVersion(bufferId); + Future validateActive() => _notifier.validateActive(); + Future restorePreviousSession() => _notifier.restorePreviousSession(); + + Future markCleanShutdown() => _notifier.markCleanShutdown(); + void dispose() {} } diff --git a/test/src/workspace_safety_test.dart b/test/src/workspace_safety_test.dart index 0b936227..cf10dbc3 100644 --- a/test/src/workspace_safety_test.dart +++ b/test/src/workspace_safety_test.dart @@ -873,6 +873,70 @@ void main() { ); expect(widgetRef.read(workspaceControllerProvider).isDirty, isTrue); }); + + testWidgets('workspace continuation resolves inactive dirty documents too', ( + tester, + ) async { + final service = _IdentityWorkspaceService(); + bool? safeToContinue; + late WidgetRef widgetRef; + await tester.pumpWidget( + ProviderScope( + overrides: [ + localSettingsStoreProvider.overrideWithValue( + _MemorySettingsStore() + ..value = AppSettings.defaults() + .copyWith(autoSave: false) + .toJson(), + ), + workspaceServiceProvider.overrideWithValue(service), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: buildBusyMarkTheme( + brightness: Brightness.light, + accentColor: Colors.green, + ), + home: Scaffold( + body: Consumer( + builder: (context, ref, child) { + widgetRef = ref; + return TextButton( + onPressed: () async { + safeToContinue = await confirmSafeToContinue(context, ref); + }, + child: const Text('Navigate'), + ); + }, + ), + ), + ), + ), + ); + + final controller = widgetRef.read(workspaceControllerProvider.notifier); + await controller.openPath(service.rootPath); + controller.updateActiveText('# Edited A\n'); + await controller.openActiveFile(service.secondPath); + controller.updateActiveText('# Edited B\n'); + expect( + widgetRef.read(workspaceControllerProvider).dirtyBuffers, + hasLength(2), + ); + + await tester.tap(find.text('Navigate')); + await tester.pumpAndSettle(); + expect(find.text('a.md'), findsOneWidget); + expect(find.text('b.md'), findsOneWidget); + await tester.tap(find.text(l10n.discard)); + await tester.pumpAndSettle(); + + expect(safeToContinue, isTrue); + expect(widgetRef.read(workspaceControllerProvider).dirtyBuffers, isEmpty); + expect(service.documents[service.firstPath], '# External A\n'); + expect(service.documents[service.secondPath], '# Original B\n'); + }); } double _contrastRatio(Color foreground, Color background) { diff --git a/test/src/wysiwyg_session_test.dart b/test/src/wysiwyg_session_test.dart new file mode 100644 index 00000000..0a519a07 --- /dev/null +++ b/test/src/wysiwyg_session_test.dart @@ -0,0 +1,120 @@ +import 'package:busymark/l10n/generated/app_localizations.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_editor.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_session_state.dart'; +import 'package:busymark/src/markdown/markdown_parser.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const parser = MarkdownParser(); + + testWidgets('restores WYSIWYG selection from document session state', ( + tester, + ) async { + final document = parser + .parse(filePath: 'one.md', source: 'Alpha beta\n') + .busyDocument; + final blockId = document.blocks.single.id; + + await tester.pumpWidget( + _app( + BusyMarkWysiwygEditor( + document: document, + documentId: 'one', + initialSessionState: WysiwygEditorSessionState( + activeBlockId: blockId, + anchorBlockId: blockId, + anchorOffset: 1, + extentBlockId: blockId, + extentOffset: 5, + ), + onSourceChanged: (_, _) {}, + ), + ), + ); + await tester.pump(); + await tester.pump(); + + final field = tester.widget(find.byType(TextField).first); + expect( + field.controller?.selection, + const TextSelection(baseOffset: 1, extentOffset: 5), + ); + }); + + testWidgets('reports the old document session before switching tabs', ( + tester, + ) async { + final first = parser + .parse(filePath: 'one.md', source: 'First document\n') + .busyDocument; + final second = parser + .parse(filePath: 'two.md', source: 'Second document\n') + .busyDocument; + final sessions = {}; + + Widget editor(String id, dynamic document) => _app( + BusyMarkWysiwygEditor( + document: document, + documentId: id, + onSessionChanged: (documentId, state) { + sessions[documentId] = state; + }, + onSourceChanged: (_, _) {}, + ), + ); + + await tester.pumpWidget(editor('one', first)); + await tester.pump(); + final field = tester.widget(find.byType(TextField).first); + field.controller!.selection = const TextSelection.collapsed(offset: 4); + await tester.pump(); + + await tester.pumpWidget(editor('two', second)); + await tester.pump(); + + expect(sessions['one']?.activeBlockId, first.blocks.single.id); + expect(sessions['one']?.anchorOffset, 4); + expect(sessions['one']?.extentOffset, 4); + }); + + testWidgets('workspace mode delegates WYSIWYG undo to buffer history', ( + tester, + ) async { + final document = parser + .parse(filePath: 'one.md', source: 'Original\n') + .busyDocument; + var undoCalls = 0; + + await tester.pumpWidget( + _app( + BusyMarkWysiwygEditor( + document: document, + documentId: 'one', + useExternalUndoHistory: true, + onUndo: () => undoCalls++, + onSourceChanged: (_, _) {}, + ), + ), + ); + await tester.pump(); + await tester.enterText(find.byType(TextField).first, 'Edited'); + await tester.pump(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + + expect(undoCalls, 1); + }); +} + +Widget _app(Widget child) { + return MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: SizedBox(width: 900, height: 640, child: child)), + ); +} From 91670c7f7c507da65bfc2b4d0716a6e95852b61e Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 21 Aug 2026 16:09:19 -0700 Subject: [PATCH 06/38] Fix math editing and rendering regressions --- assets/export/markdown.typ | 2 + .../editor/wysiwyg/wysiwyg_block_widgets.dart | 318 +++++++++++------- .../wysiwyg/wysiwyg_document_controller.dart | 49 ++- lib/src/editor/wysiwyg/wysiwyg_editor.dart | 3 + .../wysiwyg/wysiwyg_inline_controller.dart | 13 +- lib/src/export/markdown_export_mapper.dart | 55 ++- lib/src/export/markdown_math_export.dart | 18 +- .../busymark_markdown_serializer.dart | 11 +- lib/src/markdown/math_syntax.dart | 54 ++- lib/src/markdown/preview_model.dart | 19 +- lib/src/math/math_coordinator.dart | 91 +++-- lib/src/math/math_svg_preprocessor.dart | 14 + lib/src/math/math_widget.dart | 54 ++- lib/src/visualization/web_render_host.dart | 14 +- .../presentation/workspace_screen.dart | 94 +++++- lib/src/workspace/workspace_controller.dart | 56 +++ lib/src/workspace/workspace_model.dart | 6 + .../basic_project/topics/math.topic | 2 +- test/src/markdown_math_export_test.dart | 47 +++ test/src/math_parser_test.dart | 33 ++ test/src/math_renderer_test.dart | 43 +++ test/src/math_widget_test.dart | 5 +- test/src/web_render_host_test.dart | 46 +++ test/src/workspace_controller_test.dart | 63 ++++ test/src/writerside_test.dart | 7 +- test/src/wysiwyg_math_test.dart | 193 +++++++++++ 26 files changed, 1120 insertions(+), 190 deletions(-) diff --git a/assets/export/markdown.typ b/assets/export/markdown.typ index 2d0c32c7..d7fc57a2 100644 --- a/assets/export/markdown.typ +++ b/assets/export/markdown.typ @@ -31,6 +31,8 @@ #show heading.where(level: 2): set text(size: 17pt, weight: "bold") #show heading.where(level: 3): set text(size: 13.5pt, weight: "bold") #show heading.where(level: 4): set text(size: 11.5pt, weight: "bold") +#show heading.where(level: 5): set text(size: 10.5pt, weight: "bold") +#show heading.where(level: 6): set text(size: 10.5pt, weight: "bold") #show link: set text(fill: rgb("2563a5")) #let value-or(item, key, default) = item.at(key, default: default) diff --git a/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart b/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart index e47ff67c..258d0d8b 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../../app/busymark_design.dart'; import '../../app/busymark_glyphs.dart'; import '../../app/localization.dart'; +import '../../core/source_span.dart'; import '../document_callout.dart'; import '../document_code_block.dart'; import '../document_list_marker.dart'; @@ -19,6 +20,9 @@ import '../editor_text_context_menu.dart'; import 'wysiwyg_inline_controller.dart'; import 'wysiwyg_visualization_navigation.dart'; +typedef BusyMarkWysiwygMathDiagnosticCallback = + void Function(String expressionId, String? code, SourceSpan? sourceSpan); + TextDirection busyMarkWysiwygBlockTextDirection( BusyBlock block, { required TextDirection fallback, @@ -139,6 +143,7 @@ class BusyMarkWysiwygBlockField extends StatelessWidget { this.imagesDir = 'images', required this.allowRemoteImages, this.onRemoteImageBlocked, + this.onMathDiagnostic, required this.controller, required this.undoController, required this.focusNode, @@ -172,6 +177,7 @@ class BusyMarkWysiwygBlockField extends StatelessWidget { final String imagesDir; final bool allowRemoteImages; final VoidCallback? onRemoteImageBlocked; + final BusyMarkWysiwygMathDiagnosticCallback? onMathDiagnostic; final BusyMarkWysiwygTextController controller; final UndoHistoryController undoController; final FocusNode focusNode; @@ -341,6 +347,8 @@ class BusyMarkWysiwygBlockField extends StatelessWidget { onColumnDeleted: onTableColumnDeleted, onColumnAlignmentChanged: onTableColumnAlignmentChanged, onTableDeleted: onTableDeleted, + editRevision: editRevision, + onMathDiagnostic: onMathDiagnostic, ), ); } @@ -356,21 +364,23 @@ class BusyMarkWysiwygBlockField extends StatelessWidget { onEdit: _editHtmlBlock, ); } - if (busyMarkWysiwygBlockContainsMath(block) && !focusNode.hasFocus) { - return Focus( - focusNode: focusNode, - child: GestureDetector( - key: ValueKey('wysiwyg-rendered-math-${block.id}'), - behavior: HitTestBehavior.translucent, - onTap: _focusBlock, - child: _RenderedMathBlock( - block: block, - editRevision: editRevision, - style: style, - ), - ), - ); - } + final renderedMath = + busyMarkWysiwygBlockContainsMath(block) && !focusNode.hasFocus + ? Focus( + focusNode: focusNode, + child: GestureDetector( + key: ValueKey('wysiwyg-rendered-math-${block.id}'), + behavior: HitTestBehavior.translucent, + onTap: _focusBlock, + child: _RenderedMathBlock( + block: block, + editRevision: editRevision, + style: style, + onMathDiagnostic: onMathDiagnostic, + ), + ), + ) + : null; return Directionality( textDirection: textDirection, child: Row( @@ -381,101 +391,105 @@ class BusyMarkWysiwygBlockField extends StatelessWidget { const SizedBox(width: BusyMarkSpacing.sm), ], Expanded( - child: block.kind == BusyBlockKind.image - ? _ImageBlockEditor( - block: block, - documentFilePath: documentFilePath, - workspaceRoot: workspaceRoot, - writersideRoot: writersideRoot, - imagesDir: imagesDir, - allowRemoteImages: allowRemoteImages, - onRemoteImageBlocked: onRemoteImageBlocked, - ) - : readOnly - ? SelectableText( - _readOnlyText, - textDirection: textDirection, - style: style.copyWith( - color: colors.mutedForeground, - fontFamily: BusyMarkTypography.monoFontFamily, - fontFamilyFallback: - BusyMarkTypography.monoFontFamilyFallback, - ), - ) - : Stack( - children: [ - Positioned.fill( - child: IgnorePointer( - child: CustomPaint( - painter: _WysiwygSelectionPainter( - text: controller.text, - style: style, - selectionRange: selectionRange, - color: - DefaultSelectionStyle.of( - context, - ).selectionColor ?? - Theme.of( - context, - ).colorScheme.primary.withValues( - alpha: BusyMarkDocumentTextGeometry - .fallbackSelectionAlpha, - ), - textDirection: textDirection, - textScaler: MediaQuery.textScalerOf(context), - locale: Localizations.maybeLocaleOf(context), - layoutWidthInset: BusyMarkDocumentTextGeometry - .editableLayoutInset, - ), - ), + child: + renderedMath ?? + (block.kind == BusyBlockKind.image + ? _ImageBlockEditor( + block: block, + documentFilePath: documentFilePath, + workspaceRoot: workspaceRoot, + writersideRoot: writersideRoot, + imagesDir: imagesDir, + allowRemoteImages: allowRemoteImages, + onRemoteImageBlocked: onRemoteImageBlocked, + ) + : readOnly + ? SelectableText( + _readOnlyText, + textDirection: textDirection, + style: style.copyWith( + color: colors.mutedForeground, + fontFamily: BusyMarkTypography.monoFontFamily, + fontFamilyFallback: + BusyMarkTypography.monoFontFamilyFallback, ), - ), - TextSelectionTheme( - data: selectionRange == null - ? Theme.of(context).textSelectionTheme - : Theme.of(context).textSelectionTheme.copyWith( - selectionColor: - BusyMarkLinuxPalette.transparent, + ) + : Stack( + children: [ + Positioned.fill( + child: IgnorePointer( + child: CustomPaint( + painter: _WysiwygSelectionPainter( + text: controller.text, + style: style, + selectionRange: selectionRange, + color: + DefaultSelectionStyle.of( + context, + ).selectionColor ?? + Theme.of( + context, + ).colorScheme.primary.withValues( + alpha: BusyMarkDocumentTextGeometry + .fallbackSelectionAlpha, + ), + textDirection: textDirection, + textScaler: MediaQuery.textScalerOf(context), + locale: Localizations.maybeLocaleOf(context), + layoutWidthInset: BusyMarkDocumentTextGeometry + .editableLayoutInset, + ), ), - child: TextField( - key: ValueKey( - 'wysiwyg-field-$documentFilePath-${block.id}', - ), - controller: controller, - undoController: undoController, - focusNode: focusNode, - maxLines: null, - minLines: 1, - textDirection: textDirection, - style: style, - cursorWidth: - BusyMarkDocumentTextGeometry.editableCursorWidth, - selectionHeightStyle: - BusyMarkDocumentTextGeometry.selectionHeightStyle, - selectionWidthStyle: - BusyMarkDocumentTextGeometry.selectionWidthStyle, - decoration: const InputDecoration( - isCollapsed: true, - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - filled: false, - hoverColor: BusyMarkLinuxPalette.transparent, - contentPadding: EdgeInsets.zero, + ), ), - contextMenuBuilder: (context, editableTextState) => - buildBusyMarkEditorTextContextMenu( - context, - editableTextState, - refineWithAiLabel: context.l10n.aiRefineWithAi, - onRefineWithAi: onRefineWithAi, + TextSelectionTheme( + data: selectionRange == null + ? Theme.of(context).textSelectionTheme + : Theme.of(context).textSelectionTheme.copyWith( + selectionColor: + BusyMarkLinuxPalette.transparent, + ), + child: TextField( + key: ValueKey( + 'wysiwyg-field-$documentFilePath-${block.id}', ), - onTap: onFocused, - onChanged: onChanged, - ), - ), - ], - ), + controller: controller, + undoController: undoController, + focusNode: focusNode, + maxLines: null, + minLines: 1, + textDirection: textDirection, + style: style, + cursorWidth: BusyMarkDocumentTextGeometry + .editableCursorWidth, + selectionHeightStyle: BusyMarkDocumentTextGeometry + .selectionHeightStyle, + selectionWidthStyle: BusyMarkDocumentTextGeometry + .selectionWidthStyle, + decoration: const InputDecoration( + isCollapsed: true, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + filled: false, + hoverColor: BusyMarkLinuxPalette.transparent, + contentPadding: EdgeInsets.zero, + ), + contextMenuBuilder: + (context, editableTextState) => + buildBusyMarkEditorTextContextMenu( + context, + editableTextState, + refineWithAiLabel: + context.l10n.aiRefineWithAi, + onRefineWithAi: onRefineWithAi, + ), + onTap: onFocused, + onChanged: onChanged, + ), + ), + ], + )), ), ], ), @@ -630,19 +644,29 @@ class _RenderedMathBlock extends StatelessWidget { required this.block, required this.editRevision, required this.style, + this.onMathDiagnostic, }); final BusyBlock block; final int editRevision; final TextStyle style; + final BusyMarkWysiwygMathDiagnosticCallback? onMathDiagnostic; @override Widget build(BuildContext context) { if (block.kind == BusyBlockKind.math) { + final expressionId = 'block-${block.id}'; return BusyMarkDisplayMath( expression: block.attributes['mathExpression'] ?? block.plainText, - expressionId: 'wysiwyg-display-${block.id}', + expressionId: expressionId, editRevision: editRevision, + onFailure: (failure) => onMathDiagnostic?.call( + expressionId, + failure.code, + block.sourceSpan, + ), + onSuccess: () => + onMathDiagnostic?.call(expressionId, null, block.sourceSpan), ); } return Text.rich( @@ -679,14 +703,22 @@ class _RenderedMathBlock extends StatelessWidget { _ => inherited, }; if (inline.kind == BusyInlineKind.math) { + final expressionId = 'inline-block-${block.id}.$path'; return WidgetSpan( alignment: PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic, child: BusyMarkInlineMath( expression: inline.text, - expressionId: 'wysiwyg-inline-${block.id}-$path', + expressionId: expressionId, editRevision: revision, textStyle: nextStyle, + onFailure: (failure) => onMathDiagnostic?.call( + expressionId, + failure.code, + block.sourceSpan, + ), + onSuccess: () => + onMathDiagnostic?.call(expressionId, null, block.sourceSpan), ), ); } @@ -1349,6 +1381,8 @@ class _TableBlockEditor extends StatelessWidget { required this.onColumnDeleted, required this.onColumnAlignmentChanged, required this.onTableDeleted, + required this.editRevision, + this.onMathDiagnostic, }); static const double _controlSize = BusyMarkSizes.tableControl; @@ -1363,6 +1397,8 @@ class _TableBlockEditor extends StatelessWidget { final void Function(int columnIndex, BusyTableAlignment alignment) onColumnAlignmentChanged; final VoidCallback onTableDeleted; + final int editRevision; + final BusyMarkWysiwygMathDiagnosticCallback? onMathDiagnostic; @override Widget build(BuildContext context) { @@ -1429,6 +1465,9 @@ class _TableBlockEditor extends StatelessWidget { style: busyMarkDocumentBodyTextStyle(context), onFocused: onFocused, onChanged: onCellChanged, + editRevision: editRevision, + sourceSpan: block.sourceSpan, + onMathDiagnostic: onMathDiagnostic, ), ], ), @@ -1664,6 +1703,9 @@ class _TableCellEditor extends StatefulWidget { required this.style, required this.onFocused, required this.onChanged, + required this.editRevision, + this.sourceSpan, + this.onMathDiagnostic, }); final BusyBlock? cell; @@ -1671,6 +1713,9 @@ class _TableCellEditor extends StatefulWidget { final TextStyle style; final VoidCallback onFocused; final void Function(String cellId, String text) onChanged; + final int editRevision; + final SourceSpan? sourceSpan; + final BusyMarkWysiwygMathDiagnosticCallback? onMathDiagnostic; @override State<_TableCellEditor> createState() => _TableCellEditorState(); @@ -1680,13 +1725,15 @@ class _TableCellEditorState extends State<_TableCellEditor> { late final TextEditingController _controller; late final FocusNode _focusNode; String? _cellId; + bool _sourceEditing = false; @override void initState() { super.initState(); _cellId = widget.cell?.id; - _controller = TextEditingController(text: widget.cell?.plainText ?? ''); + _controller = TextEditingController(text: _editableText(widget.cell)); _focusNode = FocusNode(debugLabel: 'BusyMark table cell $_cellId'); + _focusNode.addListener(_handleFocusChanged); } @override @@ -1695,23 +1742,42 @@ class _TableCellEditorState extends State<_TableCellEditor> { final cell = widget.cell; if (cell?.id != _cellId) { _cellId = cell?.id; - _controller.text = cell?.plainText ?? ''; + _controller.text = _editableText(cell); return; } - if (!_focusNode.hasFocus && - cell != null && - cell.plainText != _controller.text) { - _controller.text = cell.plainText; + final nextText = _editableText(cell); + if (!_focusNode.hasFocus && nextText != _controller.text) { + _controller.text = nextText; + } + } + + String _editableText(BusyBlock? cell) { + if (cell == null) { + return ''; } + return busyMarkWysiwygBlockContainsMath(cell) + ? busyMarkWysiwygEditableText(cell) + : cell.plainText; } @override void dispose() { + _focusNode.removeListener(_handleFocusChanged); _controller.dispose(); _focusNode.dispose(); super.dispose(); } + void _handleFocusChanged() { + if (mounted) { + setState(() { + if (!_focusNode.hasFocus) { + _sourceEditing = false; + } + }); + } + } + @override Widget build(BuildContext context) { final colors = BusyMarkSurfaceColors.of(context); @@ -1722,6 +1788,30 @@ class _TableCellEditorState extends State<_TableCellEditor> { if (cell == null) { return const SizedBox.shrink(); } + if (busyMarkWysiwygBlockContainsMath(cell) && !_sourceEditing) { + return Padding( + padding: BusyMarkInsets.documentTableCell, + child: GestureDetector( + key: ValueKey('wysiwyg-rendered-math-${cell.id}'), + behavior: HitTestBehavior.translucent, + onTap: () { + widget.onFocused(); + setState(() => _sourceEditing = true); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _focusNode.requestFocus(); + } + }); + }, + child: _RenderedMathBlock( + block: cell.copyWith(sourceSpan: widget.sourceSpan), + editRevision: widget.editRevision, + style: textStyle, + onMathDiagnostic: widget.onMathDiagnostic, + ), + ), + ); + } return Padding( padding: BusyMarkInsets.documentTableCell, child: TextField( diff --git a/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart b/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart index 55c2841c..bc05b1a3 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart @@ -35,6 +35,22 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { return block == null ? '' : busyMarkWysiwygEditableText(block); } + String _sourceWithBlockStructure(BusyBlock block, String source) { + final prefix = switch (block.kind) { + BusyBlockKind.heading => + '${'#' * (int.tryParse(block.attributes['level'] ?? '') ?? 1)} ', + BusyBlockKind.unorderedListItem => + '${block.attributes['marker'] ?? '-'} ', + BusyBlockKind.orderedListItem => '${block.attributes['marker'] ?? '1.'} ', + BusyBlockKind.taskListItem => + '${block.attributes['ordered'] == 'true' ? block.attributes['marker'] ?? '1.' : '-'} ' + '[${block.attributes['task'] == 'true' ? 'x' : ' '}] ', + BusyBlockKind.blockquote => '> ', + _ => '', + }; + return '$prefix$source'; + } + void updateMathSource(String blockId, String source) { final current = blockById(blockId); if (current == null) { @@ -42,7 +58,7 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { } final parsed = const MarkdownParser().parse( filePath: _document.filePath, - source: source, + source: _sourceWithBlockStructure(current, source), mode: _document.mode, validateLocalReferences: false, ); @@ -71,10 +87,7 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { kind: parsedBlock.kind, inlines: parsedBlock.inlines, children: parsedBlock.children, - attributes: { - ...parsedBlock.attributes, - 'wysiwygMathSource': 'true', - }, + attributes: parsedBlock.attributes, rawSource: parsedBlock.rawSource, sourceSpan: index == 0 ? current.sourceSpan : null, preserveRaw: false, @@ -115,7 +128,6 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { busyMarkMathDisplayAttribute: 'true', busyMarkMathSourceFormAttribute: BusyMathSourceForm.doubleDollarDisplay.name, - 'wysiwygMathSource': 'true', }, rawSource: '\$\$\n$expression\n\$\$', preserveRaw: false, @@ -171,7 +183,7 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { final cells = []; for (final cell in row.children) { if (cell.id == cellId) { - cells.add(_blockWithEditedText(cell, text)); + cells.add(_tableCellWithEditedSource(cell, text)); rowChanged = true; changed = true; } else { @@ -193,6 +205,29 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { } } + BusyBlock _tableCellWithEditedSource(BusyBlock cell, String source) { + final parsed = const MarkdownParser().parse( + filePath: _document.filePath, + source: '$source\n', + mode: _document.mode, + validateLocalReferences: false, + ); + final parsedBlock = parsed.busyDocument.blocks + .where( + (block) => + block.kind != BusyBlockKind.frontMatter && !block.isSourceOnly, + ) + .firstOrNull; + final inlines = parsedBlock?.inlines; + return cell.copyWith( + inlines: inlines == null || inlines.isEmpty + ? _textInlines(source) + : inlines, + preserveRaw: false, + dirty: true, + ); + } + BusyWysiwygTextSplitResult? replaceBlockTextWithParagraphs( String blockId, String text, diff --git a/lib/src/editor/wysiwyg/wysiwyg_editor.dart b/lib/src/editor/wysiwyg/wysiwyg_editor.dart index 15aa7e84..0a1989c5 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_editor.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_editor.dart @@ -79,6 +79,7 @@ class BusyMarkWysiwygEditor extends StatefulWidget { this.documentLayout, this.visualizationRevision = 0, this.onAiEdit, + this.onMathDiagnostic, }); final BusyDocument document; @@ -114,6 +115,7 @@ class BusyMarkWysiwygEditor extends StatefulWidget { final BusyMarkDocumentLayoutSpec? documentLayout; final int visualizationRevision; final BusyMarkAiEditCallback? onAiEdit; + final BusyMarkWysiwygMathDiagnosticCallback? onMathDiagnostic; @override State createState() => _BusyMarkWysiwygEditorState(); @@ -662,6 +664,7 @@ class _BusyMarkWysiwygEditorState extends State { imagesDir: widget.imagesDir, allowRemoteImages: widget.allowRemoteImages, onRemoteImageBlocked: widget.onRemoteImageBlocked, + onMathDiagnostic: widget.onMathDiagnostic, controller: _textControllerFor(block), undoController: _textUndoControllerFor(block), focusNode: _focusNodeFor(block), diff --git a/lib/src/editor/wysiwyg/wysiwyg_inline_controller.dart b/lib/src/editor/wysiwyg/wysiwyg_inline_controller.dart index 331603c8..e9647261 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_inline_controller.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_inline_controller.dart @@ -8,8 +8,7 @@ bool busyMarkWysiwygBlockContainsMath(BusyBlock block) { bool contains(List inlines) => inlines.any( (inline) => inline.kind == BusyInlineKind.math || contains(inline.children), ); - return block.attributes['wysiwygMathSource'] == 'true' || - block.kind == BusyBlockKind.math || + return block.kind == BusyBlockKind.math || contains(block.inlines) || block.children.any(busyMarkWysiwygBlockContainsMath); } @@ -18,6 +17,16 @@ String busyMarkWysiwygEditableText(BusyBlock block) { if (!busyMarkWysiwygBlockContainsMath(block)) { return block.plainText; } + if (block.kind != BusyBlockKind.math && block.inlines.isNotEmpty) { + return const BusyMarkMarkdownSerializer().serializeBlock( + BusyBlock( + id: 'wysiwyg-inline-source', + kind: BusyBlockKind.paragraph, + inlines: block.inlines, + dirty: true, + ), + ); + } final source = block.rawSource ?? const BusyMarkMarkdownSerializer().serializeBlock(block); diff --git a/lib/src/export/markdown_export_mapper.dart b/lib/src/export/markdown_export_mapper.dart index b11e8f25..611ede5a 100644 --- a/lib/src/export/markdown_export_mapper.dart +++ b/lib/src/export/markdown_export_mapper.dart @@ -4,6 +4,17 @@ import '../core/uri_utils.dart'; import '../markdown/busymark_document.dart'; import 'markdown_export_document.dart'; +const double busyMarkPdfBodyTextSize = 10.5; + +double busyMarkPdfHeadingTextSize(int level) => switch (level.clamp(1, 6)) { + 1 => 22, + 2 => 17, + 3 => 13.5, + 4 => 11.5, + 5 || 6 => busyMarkPdfBodyTextSize, + _ => busyMarkPdfBodyTextSize, +}; + class MarkdownExportMapper { const MarkdownExportMapper(); @@ -126,15 +137,7 @@ class MarkdownExportMapper { Map blockOverrides, ) { return switch (block.kind) { - BusyBlockKind.heading => MarkdownExportBlock( - kind: MarkdownExportBlockKind.heading, - inlines: _mapInlines(block.inlines), - attributes: { - 'level': - int.tryParse(block.attributes['level'] ?? '')?.clamp(1, 6) ?? 1, - if (_safeAnchor(block.attributes['id']) case final id?) 'id': id, - }, - ), + BusyBlockKind.heading => _mapHeading(block), BusyBlockKind.paragraph => MarkdownExportBlock( kind: MarkdownExportBlockKind.paragraph, inlines: _mapInlines(block.inlines), @@ -196,6 +199,22 @@ class MarkdownExportMapper { }; } + MarkdownExportBlock _mapHeading(BusyBlock block) { + final level = + int.tryParse(block.attributes['level'] ?? '')?.clamp(1, 6) ?? 1; + return MarkdownExportBlock( + kind: MarkdownExportBlockKind.heading, + inlines: _mapInlines( + block.inlines, + mathEm: busyMarkPdfHeadingTextSize(level), + ), + attributes: { + 'level': level, + if (_safeAnchor(block.attributes['id']) case final id?) 'id': id, + }, + ); + } + MarkdownExportBlock _mapTable( BusyBlock table, Map blockOverrides, @@ -246,12 +265,20 @@ class MarkdownExportMapper { ); } - List _mapInlines(List inlines) { - return List.unmodifiable(inlines.map(_mapInline)); + List _mapInlines( + List inlines, { + double mathEm = busyMarkPdfBodyTextSize, + }) { + return List.unmodifiable( + inlines.map((inline) => _mapInline(inline, mathEm: mathEm)), + ); } - MarkdownExportInline _mapInline(BusyInline inline) { - final children = _mapInlines(inline.children); + MarkdownExportInline _mapInline( + BusyInline inline, { + double mathEm = busyMarkPdfBodyTextSize, + }) { + final children = _mapInlines(inline.children, mathEm: mathEm); return switch (inline.kind) { BusyInlineKind.text => MarkdownExportInline( kind: MarkdownExportInlineKind.text, @@ -287,6 +314,8 @@ class MarkdownExportMapper { attributes: { 'mathId': inline.attributes['expressionId'] ?? inline.text, 'display': 'false', + 'renderEm': '$mathEm', + 'renderEx': '${mathEm / 2}', if (inline.attributes['mathSourceForm'] case final sourceForm?) 'sourceForm': sourceForm, }, diff --git a/lib/src/export/markdown_math_export.dart b/lib/src/export/markdown_math_export.dart index ff9bddec..e02d70a1 100644 --- a/lib/src/export/markdown_math_export.dart +++ b/lib/src/export/markdown_math_export.dart @@ -6,6 +6,7 @@ import 'package:path/path.dart' as p; import '../math/math_coordinator.dart'; import '../math/math_models.dart'; +import '../math/math_svg_preprocessor.dart'; import '../visualization/generated_svg_normalizer.dart'; import 'markdown_export_document.dart'; import 'markdown_pdf_models.dart'; @@ -34,6 +35,7 @@ class MarkdownMathExportRenderer { final GeneratedSvgNormalizer svgNormalizer; final int maximumExpressions; final int maximumGeneratedBytes; + static const _svgPreprocessor = MathSvgPreprocessor(); Future prepare({ required MarkdownExportDocument document, @@ -70,8 +72,8 @@ class MarkdownMathExportRenderer { display: item.display, blockKey: blockKeys[index], editRevision: 0, - em: 10.5, - ex: 5.25, + em: item.em, + ex: item.ex, containerWidth: containerWidth, renderProfile: 'pdf', ), @@ -114,7 +116,9 @@ class MarkdownMathExportRenderer { continue; } try { - final normalized = svgNormalizer.normalize(result.vectorSvg); + final normalized = svgNormalizer.normalize( + _svgPreprocessor.resolveCurrentColor(result.vectorSvg, '#000000'), + ); final svg = normalized.vectorSafeSvg; if (svg == null) { throw const GeneratedSvgException( @@ -221,6 +225,8 @@ class MarkdownMathExportRenderer { renderKey: value.attributes['mathRenderKey']!, expression: value.text, display: false, + em: double.tryParse(value.attributes['renderEm'] ?? '') ?? 10.5, + ex: double.tryParse(value.attributes['renderEx'] ?? '') ?? 5.25, ); } yield* inlines(value.children); @@ -236,6 +242,8 @@ class MarkdownMathExportRenderer { renderKey: value.attributes['mathRenderKey']! as String, expression: value.text, display: true, + em: 10.5, + ex: 5.25, ); } yield* inlines(value.inlines); @@ -313,11 +321,15 @@ class _MathExportCandidate { required this.renderKey, required this.expression, required this.display, + required this.em, + required this.ex, }); final String renderKey; final String expression; final bool display; + final double em; + final double ex; } class _PreparedMathAsset { diff --git a/lib/src/markdown/busymark_markdown_serializer.dart b/lib/src/markdown/busymark_markdown_serializer.dart index 3753a753..d209377e 100644 --- a/lib/src/markdown/busymark_markdown_serializer.dart +++ b/lib/src/markdown/busymark_markdown_serializer.dart @@ -381,7 +381,8 @@ class BusyMarkMarkdownSerializer { ); return switch (form) { BusyMathSourceForm.githubDollarBacktick => '\$`${inline.text}`\$', - BusyMathSourceForm.writersideElement => '${inline.text}', + BusyMathSourceForm.writersideElement => + '${_writersideMathExpression(inline)}', BusyMathSourceForm.dollarInline || BusyMathSourceForm.doubleDollarDisplay || BusyMathSourceForm.mathFence || @@ -389,6 +390,14 @@ class BusyMarkMarkdownSerializer { }; } + String _writersideMathExpression(BusyInline inline) { + final raw = inline.attributes[busyMarkMathRawExpressionAttribute]; + if (raw != null && busyMarkDecodeXmlMathText(raw) == inline.text) { + return raw; + } + return busyMarkEncodeXmlMathText(inline.text); + } + String _codeSpan(String text) { final delimiter = '`' * _delimiterLength(text, '`'); final touchesDelimiter = text.startsWith('`') || text.endsWith('`'); diff --git a/lib/src/markdown/math_syntax.dart b/lib/src/markdown/math_syntax.dart index c2c1265b..900eac30 100644 --- a/lib/src/markdown/math_syntax.dart +++ b/lib/src/markdown/math_syntax.dart @@ -7,6 +7,7 @@ const busyMarkMathBlockTag = 'busymark-math-block'; const busyMarkMathExpressionAttribute = 'mathExpression'; const busyMarkMathDisplayAttribute = 'mathDisplay'; const busyMarkMathSourceFormAttribute = 'mathSourceForm'; +const busyMarkMathRawExpressionAttribute = 'mathRawExpression'; enum BusyMathSourceForm { dollarInline, @@ -170,17 +171,19 @@ class BusyWritersideMathSyntax extends md.InlineSyntax { } final closeStart = close == null ? match.end + end.start : end.start; final closeEnd = close == null ? match.end + end.end : end.end; - final expression = parser.source.substring(match.end, closeStart); - if (expression.isEmpty || expression.contains('\n')) { + final rawExpression = parser.source.substring(match.end, closeStart); + if (rawExpression.isEmpty || rawExpression.contains('\n')) { parser.addNode(md.Text(match.group(0)!)); return true; } + final expression = busyMarkDecodeXmlMathText(rawExpression); parser.addNode( _mathElement( busyMarkMathInlineTag, expression, BusyMathSourceForm.writersideElement, display: false, + rawExpression: rawExpression, ), ); parser.consume(closeEnd - match.start); @@ -272,13 +275,58 @@ md.Element _mathElement( String expression, BusyMathSourceForm sourceForm, { required bool display, + String? rawExpression, }) { return md.Element.text(tag, expression) ..attributes[busyMarkMathExpressionAttribute] = expression ..attributes[busyMarkMathDisplayAttribute] = '$display' - ..attributes[busyMarkMathSourceFormAttribute] = sourceForm.name; + ..attributes[busyMarkMathSourceFormAttribute] = sourceForm.name + ..attributes.addAll({ + if (rawExpression != null) + busyMarkMathRawExpressionAttribute: rawExpression, + }); } +String busyMarkDecodeXmlMathText(String source) { + return source.replaceAllMapped( + RegExp(r'&(?:lt|gt|amp|quot|apos|#[0-9]+|#[xX][0-9A-Fa-f]+);'), + (match) { + final entity = match.group(0)!; + final named = switch (entity) { + '<' => '<', + '>' => '>', + '&' => '&', + '"' => '"', + ''' => "'", + _ => null, + }; + if (named != null) { + return named; + } + final hexadecimal = entity.startsWith('&#x') || entity.startsWith('&#X'); + final digits = entity.substring(hexadecimal ? 3 : 2, entity.length - 1); + final codePoint = int.tryParse(digits, radix: hexadecimal ? 16 : 10); + final validXmlCharacter = + codePoint != null && + (codePoint == 0x09 || + codePoint == 0x0a || + codePoint == 0x0d || + (codePoint >= 0x20 && codePoint <= 0xd7ff) || + (codePoint >= 0xe000 && codePoint <= 0xfffd) || + (codePoint >= 0x10000 && codePoint <= 0x10ffff)); + if (!validXmlCharacter) { + return entity; + } + return String.fromCharCode(codePoint); + }, + ); +} + +String busyMarkEncodeXmlMathText(String source) => source + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>'); + int? _displayClose(String source, int start) { var index = start; while (index + 1 < source.length) { diff --git a/lib/src/markdown/preview_model.dart b/lib/src/markdown/preview_model.dart index 1995852b..fd762914 100644 --- a/lib/src/markdown/preview_model.dart +++ b/lib/src/markdown/preview_model.dart @@ -173,7 +173,7 @@ class BusyMarkPreviewBuilder { kind: PreviewBlockKind.heading, text: _plainText(block.inlines), level: int.tryParse(block.attributes['level'] ?? ''), - inlines: _inlines(block.inlines, '$path.i'), + inlines: _inlines(block.inlines, 'block-${block.id}.i'), attributes: { ...block.attributes, if (block.attributes['id'] case final id?) 'id': id, @@ -183,7 +183,7 @@ class BusyMarkPreviewBuilder { BusyBlockKind.paragraph => PreviewBlock( kind: PreviewBlockKind.paragraph, text: _plainText(block.inlines), - inlines: _inlines(block.inlines, '$path.i'), + inlines: _inlines(block.inlines, 'block-${block.id}.i'), attributes: block.attributes, ), BusyBlockKind.math => PreviewBlock( @@ -191,7 +191,7 @@ class BusyMarkPreviewBuilder { text: block.attributes[busyMarkMathExpressionAttribute] ?? block.plainText, - inlines: _inlines(block.inlines, '$path.i'), + inlines: _inlines(block.inlines, 'block-${block.id}.i'), attributes: { ...block.attributes, 'expressionId': 'block-${block.id}', @@ -212,7 +212,7 @@ class BusyMarkPreviewBuilder { BusyBlockKind.taskListItem => PreviewBlock( kind: PreviewBlockKind.list, text: _plainText(block.inlines), - inlines: _inlines(block.inlines, '$path.i'), + inlines: _inlines(block.inlines, 'block-${block.id}.i'), children: [ for (final (index, child) in block.children.indexed) _block(child, '$path.b$index'), @@ -227,9 +227,12 @@ class BusyMarkPreviewBuilder { inlines: block.children.length == 1 && block.children.single.kind == BusyBlockKind.paragraph - ? _inlines(block.children.single.inlines, '$path.b0.i') + ? _inlines( + block.children.single.inlines, + 'block-${block.children.single.id}.i', + ) : block.children.isEmpty - ? _inlines(block.inlines, '$path.i') + ? _inlines(block.inlines, 'block-${block.id}.i') : const [], children: [ for (final (index, child) in block.children.indexed) @@ -246,7 +249,7 @@ class BusyMarkPreviewBuilder { text: block.inlines.isEmpty ? block.plainText : block.inlines.first.text, - inlines: _inlines(block.inlines, '$path.i'), + inlines: _inlines(block.inlines, 'block-${block.id}.i'), attributes: block.attributes, ), BusyBlockKind.table => PreviewBlock( @@ -261,7 +264,7 @@ class BusyMarkPreviewBuilder { BusyBlockKind.writersideAdmonition => PreviewBlock( kind: PreviewBlockKind.admonition, text: _plainText(block.inlines), - inlines: _inlines(block.inlines, '$path.i'), + inlines: _inlines(block.inlines, 'block-${block.id}.i'), attributes: { ...block.attributes, 'style': block.attributes['element'] ?? 'note', diff --git a/lib/src/math/math_coordinator.dart b/lib/src/math/math_coordinator.dart index b18954c4..b22bb63d 100644 --- a/lib/src/math/math_coordinator.dart +++ b/lib/src/math/math_coordinator.dart @@ -106,53 +106,68 @@ class MathCoordinator { if (active.isEmpty) { continue; } + final uncached = <_PendingMathRender>[]; + for (final item in active) { + final cached = cache.get(item.request.cacheKey); + if (cached == null) { + uncached.add(item); + } else { + _completeRendered(item, cached); + } + } + if (uncached.isEmpty) { + continue; + } + final groups = >{}; + for (final item in uncached) { + groups.putIfAbsent(item.request.cacheKey, () => []).add(item); + } + final leaders = [for (final group in groups.values) group.first]; final batchToken = VisualizationCancellationToken(); void cancelBatchWhenObsolete() { - if (active.every((item) => item.token.isCancelled)) { + if (uncached.every((item) => item.token.isCancelled)) { batchToken.cancel(); } } - for (final item in active) { + for (final item in uncached) { item.token.onCancel(cancelBatchWhenObsolete); } try { final results = await renderer.renderBatch([ - for (final item in active) item.request, + for (final item in leaders) item.request, ], batchToken); - for (var index = 0; index < active.length; index++) { - final item = active[index]; - if (_isSuperseded(item)) { - _supersede(item); - continue; - } + for (var index = 0; index < leaders.length; index++) { + final leader = leaders[index]; final result = results[index]; if (result is RenderedMathResult) { - cache.put(item.request.cacheKey, result); - } - if (!item.completer.isCompleted) { - item.completer.complete( - result is RenderedMathResult - ? _forInstance(result, item.request) - : result, - ); + cache.put(leader.request.cacheKey, result); } - if (identical(_activeTokens[item.request.blockKey], item.token)) { - _activeTokens.remove(item.request.blockKey); + for (final item in groups[leader.request.cacheKey]!) { + if (_isSuperseded(item)) { + _supersede(item); + continue; + } + if (result is RenderedMathResult) { + _completeRendered(item, result); + } else if (!item.completer.isCompleted) { + item.completer.complete(_failureForInstance(result, item)); + _removeActiveToken(item); + } } } } on VisualizationCancelledException { - for (final item in active) { + for (final item in uncached) { _supersede(item); } } on Object catch (error, stackTrace) { - for (final item in active) { + for (final item in uncached) { if (!item.completer.isCompleted) { item.completer.completeError(error, stackTrace); } } } finally { - for (final item in active) { + for (final item in uncached) { item.token.removeListener(cancelBatchWhenObsolete); } } @@ -171,6 +186,38 @@ class MathCoordinator { } } + void _completeRendered(_PendingMathRender item, RenderedMathResult result) { + if (_isSuperseded(item)) { + _supersede(item); + return; + } + if (!item.completer.isCompleted) { + item.completer.complete(_forInstance(result, item.request)); + } + _removeActiveToken(item); + } + + MathRenderResult _failureForInstance( + MathRenderResult result, + _PendingMathRender item, + ) { + if (result is FailedMathResult) { + return FailedMathResult( + expressionId: item.request.expressionId, + kind: result.kind, + code: result.code, + debugDetail: result.debugDetail, + ); + } + return result; + } + + void _removeActiveToken(_PendingMathRender item) { + if (identical(_activeTokens[item.request.blockKey], item.token)) { + _activeTokens.remove(item.request.blockKey); + } + } + RenderedMathResult _forInstance( RenderedMathResult result, MathRenderRequest request, diff --git a/lib/src/math/math_svg_preprocessor.dart b/lib/src/math/math_svg_preprocessor.dart index 073a71ea..074f6b33 100644 --- a/lib/src/math/math_svg_preprocessor.dart +++ b/lib/src/math/math_svg_preprocessor.dart @@ -118,6 +118,20 @@ class MathSvgPreprocessor { return document.toXmlString(pretty: false); } + String resolveCurrentColor(String source, String color) { + final document = XmlDocument.parse(source); + final currentColor = RegExp(r'\bcurrentColor\b', caseSensitive: false); + for (final element in [ + document.rootElement, + ...document.rootElement.descendants.whereType(), + ]) { + for (final attribute in element.attributes) { + attribute.value = attribute.value.replaceAll(currentColor, color); + } + } + return document.toXmlString(pretty: false); + } + Map _styleDeclarations(String source) { final result = {}; for (final declaration in source.split(';')) { diff --git a/lib/src/math/math_widget.dart b/lib/src/math/math_widget.dart index cd76de07..92f3e81e 100644 --- a/lib/src/math/math_widget.dart +++ b/lib/src/math/math_widget.dart @@ -9,6 +9,7 @@ import '../app/busymark_design.dart'; import '../app/localization.dart'; import 'math_models.dart'; import 'math_providers.dart'; +import 'math_svg_preprocessor.dart'; var _nextMathWidgetInstance = 0; @@ -21,6 +22,7 @@ class BusyMarkInlineMath extends StatelessWidget { required this.textStyle, this.containerWidth = BusyMarkSizes.documentContentWidth, this.onFailure, + this.onSuccess, }); final String expression; @@ -29,6 +31,7 @@ class BusyMarkInlineMath extends StatelessWidget { final TextStyle textStyle; final double containerWidth; final ValueChanged? onFailure; + final VoidCallback? onSuccess; @override Widget build(BuildContext context) { @@ -44,6 +47,7 @@ class BusyMarkInlineMath extends StatelessWidget { containerWidth: containerWidth, textStyle: textStyle, onFailure: onFailure, + onSuccess: onSuccess, ); } } @@ -55,12 +59,14 @@ class BusyMarkDisplayMath extends StatelessWidget { required this.expressionId, required this.editRevision, this.onFailure, + this.onSuccess, }); final String expression; final String expressionId; final int editRevision; final ValueChanged? onFailure; + final VoidCallback? onSuccess; @override Widget build(BuildContext context) { @@ -81,6 +87,7 @@ class BusyMarkDisplayMath extends StatelessWidget { containerWidth: availableWidth, textStyle: style, onFailure: onFailure, + onSuccess: onSuccess, ); }, ); @@ -98,6 +105,7 @@ class _MathFormula extends ConsumerStatefulWidget { required this.containerWidth, required this.textStyle, this.onFailure, + this.onSuccess, }); final String expression; @@ -109,6 +117,7 @@ class _MathFormula extends ConsumerStatefulWidget { final double containerWidth; final TextStyle textStyle; final ValueChanged? onFailure; + final VoidCallback? onSuccess; @override ConsumerState<_MathFormula> createState() => _MathFormulaState(); @@ -118,6 +127,7 @@ class _MathFormulaState extends ConsumerState<_MathFormula> { late final String _blockKey = 'math-widget-${_nextMathWidgetInstance++}'; late final _coordinator = ref.read(mathCoordinatorProvider); Future? _render; + String? _reportedOutcome; @override void initState() { @@ -145,6 +155,7 @@ class _MathFormulaState extends ConsumerState<_MathFormula> { } void _scheduleRender() { + _reportedOutcome = null; _render = _coordinator.render( MathRenderRequest( expressionId: widget.expressionId, @@ -166,33 +177,48 @@ class _MathFormulaState extends ConsumerState<_MathFormula> { builder: (context, snapshot) { final result = snapshot.data; if (result is RenderedMathResult) { + _reportOutcome('success', widget.onSuccess); return _rendered(context, result); } if (result is FailedMathResult) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) widget.onFailure?.call(result); - }); + _reportOutcome( + 'failure:${result.code}', + widget.onFailure == null ? null : () => widget.onFailure!(result), + ); } return _fallback(context, failed: result is FailedMathResult); }, ); } + void _reportOutcome(String outcome, VoidCallback? callback) { + if (_reportedOutcome == outcome) { + return; + } + _reportedOutcome = outcome; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) callback?.call(); + }); + } + Widget _rendered(BuildContext context, RenderedMathResult result) { final foreground = widget.textStyle.color ?? DefaultTextStyle.of(context).style.color; + final svg = foreground == null + ? result.svg + : const MathSvgPreprocessor().resolveCurrentColor( + result.svg, + _svgColor(foreground), + ); final picture = Semantics( image: true, label: widget.expression, child: ExcludeSemantics( child: SvgPicture.string( - result.svg, + svg, width: result.width, height: result.height, fit: BoxFit.fill, - colorFilter: foreground == null - ? null - : ColorFilter.mode(foreground, BlendMode.srcIn), ), ), ); @@ -248,3 +274,17 @@ class _MathFormulaState extends ConsumerState<_MathFormula> { : fallback; } } + +String _svgColor(Color color) { + final value = color.toARGB32(); + final red = (value >> 16) & 0xff; + final green = (value >> 8) & 0xff; + final blue = value & 0xff; + final alpha = ((value >> 24) & 0xff) / 255; + if (alpha >= 1) { + return '#${red.toRadixString(16).padLeft(2, '0')}' + '${green.toRadixString(16).padLeft(2, '0')}' + '${blue.toRadixString(16).padLeft(2, '0')}'; + } + return 'rgba($red,$green,$blue,${alpha.toStringAsFixed(3)})'; +} diff --git a/lib/src/visualization/web_render_host.dart b/lib/src/visualization/web_render_host.dart index f9134554..4ec3ab11 100644 --- a/lib/src/visualization/web_render_host.dart +++ b/lib/src/visualization/web_render_host.dart @@ -80,12 +80,24 @@ class PlatformWebRenderHost implements WebRenderHost { this.renderTimeout = const Duration(seconds: 20), this.rasterTimeout = const Duration(seconds: 20), this.mathTimeout = const Duration(seconds: 10), + this.mathTimeoutPerAdditionalExpression = const Duration(milliseconds: 250), + this.maximumMathTimeout = const Duration(seconds: 45), }) : _channel = channel; final MethodChannel _channel; final Duration renderTimeout; final Duration rasterTimeout; final Duration mathTimeout; + final Duration mathTimeoutPerAdditionalExpression; + final Duration maximumMathTimeout; + + Duration mathBatchTimeoutForExpressionCount(int expressionCount) { + final additional = + mathTimeoutPerAdditionalExpression * + (expressionCount - 1).clamp(0, 1 << 20); + final scaled = mathTimeout + additional; + return scaled > maximumMathTimeout ? maximumMathTimeout : scaled; + } @override Future> renderMathBatch({ @@ -95,7 +107,7 @@ class PlatformWebRenderHost implements WebRenderHost { return _invokeMap( 'renderMathBatch', {'expressions': expressions}, - mathTimeout, + mathBatchTimeoutForExpressionCount(expressions.length), cancellationToken, ); } diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index d4e4ac7d..7e8bee93 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -29,6 +29,7 @@ import '../../core/diagnostic.dart'; import '../../core/diagnostic_localizations.dart'; import '../../core/path_utils.dart' show isTextDocumentationPath, slugForHeading; +import '../../core/source_span.dart'; import '../../core/uri_utils.dart'; import '../../editor/document_callout.dart'; import '../../editor/document_code_block.dart'; @@ -1117,7 +1118,7 @@ class WorkspaceScreen extends ConsumerWidget { return; } final headerBar = ref.read(linuxHeaderBarServiceProvider); - final count = workspace.diagnostics.length; + final count = workspace.allDiagnostics.length; unawaited( showBusyMarkModalDialog( context, @@ -9615,6 +9616,13 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { ? (snapshot) => showBusyMarkAiEdit(context, ref, snapshot) : null, + onMathDiagnostic: (expressionId, code, sourceSpan) => ref + .read(workspaceControllerProvider.notifier) + .updateMathRenderDiagnostic( + expressionId: expressionId, + code: code, + sourceSpan: sourceSpan, + ), ), ), if (sourceVisible) @@ -9626,7 +9634,7 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { filePath: activeEditorPath, documentId: activeBuffer?.id, diagnostics: - widget.state.workspace?.diagnostics ?? + widget.state.workspace?.allDiagnostics ?? const [], editorFontSize: widget.editorFontSize, wordWrap: widget.wordWrap, @@ -10649,7 +10657,7 @@ class _PreviewBlockContextAnchorState } } -class _PreviewBlockView extends StatelessWidget { +class _PreviewBlockView extends ConsumerWidget { const _PreviewBlockView( this.block, { required this.workspace, @@ -10675,7 +10683,7 @@ class _PreviewBlockView extends StatelessWidget { final ValueChanged? onEditVisualizationSource; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final colors = BusyMarkSurfaceColors.of(context); final displayBlock = _localizedPreviewBlock(context, block); final inheritedDirection = Directionality.of(context); @@ -10709,6 +10717,20 @@ class _PreviewBlockView extends StatelessWidget { displayBlock.attributes['expressionId'] ?? 'display-${displayBlock.sourceStartOffset ?? 0}', editRevision: editRevision, + onFailure: (failure) => _reportMathDiagnostic( + ref, + displayBlock, + displayBlock.attributes['expressionId'] ?? + 'display-${displayBlock.sourceStartOffset ?? 0}', + failure.code, + ), + onSuccess: () => _reportMathDiagnostic( + ref, + displayBlock, + displayBlock.attributes['expressionId'] ?? + 'display-${displayBlock.sourceStartOffset ?? 0}', + null, + ), ), ), PreviewBlockKind.code @@ -10835,6 +10857,21 @@ class _PreviewBlockView extends StatelessWidget { : Directionality(textDirection: blockDirection, child: child); } + void _reportMathDiagnostic( + WidgetRef ref, + PreviewBlock sourceBlock, + String expressionId, + String? code, + ) { + ref + .read(workspaceControllerProvider.notifier) + .updateMathRenderDiagnostic( + expressionId: expressionId, + code: code, + sourceSpan: _previewMathSourceSpan(workspace, sourceBlock), + ); + } + Color _diffPreviewCodeBackground(BuildContext context, PreviewBlock block) { final colors = BusyMarkSurfaceColors.of(context); if (_diffPreviewCodeLineTones(block).isNotEmpty) { @@ -11131,6 +11168,16 @@ class _PreviewInlineText extends ConsumerWidget { final inlines = block.inlines.isEmpty ? [PreviewInline(kind: PreviewInlineKind.text, text: block.text)] : block.inlines; + void reportMathDiagnostic(String expressionId, String? code) { + ref + .read(workspaceControllerProvider.notifier) + .updateMathRenderDiagnostic( + expressionId: expressionId, + code: code, + sourceSpan: _previewMathSourceSpan(workspace, block), + ); + } + return Padding( padding: const EdgeInsetsDirectional.only( end: BusyMarkDocumentTextGeometry.editableLayoutInset, @@ -11151,6 +11198,7 @@ class _PreviewInlineText extends ConsumerWidget { onLinkTap: (destination) => _openPreviewLink(context, ref, destination), editRevision: editRevision, + onMathDiagnostic: reportMathDiagnostic, ), ], ), @@ -11577,6 +11625,7 @@ InlineSpan _previewInlineSpan( required VoidCallback? onRemoteImageBlocked, required Future Function(String destination) onLinkTap, required int editRevision, + required void Function(String expressionId, String? code) onMathDiagnostic, String? inheritedLinkDestination, TextStyle? inheritedStyle, }) { @@ -11617,6 +11666,7 @@ InlineSpan _previewInlineSpan( onRemoteImageBlocked: onRemoteImageBlocked, onLinkTap: onLinkTap, editRevision: editRevision, + onMathDiagnostic: onMathDiagnostic, inheritedLinkDestination: linkDestination, inheritedStyle: style, ); @@ -11755,11 +11805,45 @@ InlineSpan _previewInlineSpan( 'inline-${Object.hash(inline.text, inline.attributes)}', editRevision: editRevision, textStyle: mergeStyle(null) ?? DefaultTextStyle.of(context).style, + onFailure: (failure) => onMathDiagnostic( + inline.attributes['expressionId'] ?? + 'inline-${Object.hash(inline.text, inline.attributes)}', + failure.code, + ), + onSuccess: () => onMathDiagnostic( + inline.attributes['expressionId'] ?? + 'inline-${Object.hash(inline.text, inline.attributes)}', + null, + ), ), ), }; } +SourceSpan? _previewMathSourceSpan(Workspace? workspace, PreviewBlock block) { + final filePath = workspace?.activeFilePath; + final startOffset = block.sourceStartOffset; + final endOffset = block.sourceEndOffset; + final startLine = block.sourceStartLine; + final endLine = block.sourceEndLine; + if (filePath == null || + startOffset == null || + endOffset == null || + startLine == null || + endLine == null) { + return null; + } + return SourceSpan( + filePath: filePath, + startOffset: startOffset, + endOffset: endOffset, + startLine: startLine, + startColumn: 1, + endLine: endLine, + endColumn: 1, + ); +} + InlineSpan _previewInlineImageSpan( BuildContext context, PreviewInline inline, @@ -12119,7 +12203,7 @@ class _ProblemsList extends StatelessWidget { @override Widget build(BuildContext context) { - final diagnostics = workspace.diagnostics; + final diagnostics = workspace.allDiagnostics; return BusyMarkGroupedSurface( child: diagnostics.isEmpty ? _EmptyPane( diff --git a/lib/src/workspace/workspace_controller.dart b/lib/src/workspace/workspace_controller.dart index da6e17fa..dce3d49a 100644 --- a/lib/src/workspace/workspace_controller.dart +++ b/lib/src/workspace/workspace_controller.dart @@ -8,6 +8,7 @@ import 'package:path/path.dart' as p; import '../app/app_settings.dart'; import '../core/debug_log.dart'; import '../core/diagnostic.dart'; +import '../core/source_span.dart'; import '../markdown/busymark_document.dart'; import '../markdown/document_outline.dart'; import '../markdown/preview_model.dart'; @@ -45,6 +46,21 @@ final _runningUnderFlutterTest = Platform.environment.containsKey( 'FLUTTER_TEST', ); +bool _sameRuntimeDiagnostics(List left, List right) { + if (left.length != right.length) { + return false; + } + for (var index = 0; index < left.length; index++) { + if (left[index].code != right[index].code || + left[index].filePath != right[index].filePath || + left[index].args['runtimeMathKey'] != + right[index].args['runtimeMathKey']) { + return false; + } + } + return true; +} + final workspaceFileMonitorProvider = Provider((ref) { final monitor = WorkspaceFileMonitor(); ref.onDispose(() => unawaited(monitor.dispose())); @@ -154,6 +170,40 @@ class WorkspaceController extends Notifier { int get editRevision => state.activeBuffer?.revision ?? _editRevision; + void updateMathRenderDiagnostic({ + required String expressionId, + required String? code, + SourceSpan? sourceSpan, + }) { + final workspace = state.workspace; + if (workspace == null) { + return; + } + final filePath = + sourceSpan?.filePath ?? workspace.activeFilePath ?? workspace.rootPath; + final runtimeKey = '$filePath\u0000$expressionId'; + final diagnostics = [ + for (final diagnostic in workspace.runtimeDiagnostics) + if (diagnostic.args['runtimeMathKey'] != runtimeKey) diagnostic, + if (code != null) + Diagnostic( + code: code, + severity: DiagnosticSeverity.error, + filePath: filePath, + sourceSpan: sourceSpan, + args: {'runtimeMathKey': runtimeKey}, + ), + ]; + if (_sameRuntimeDiagnostics(workspace.runtimeDiagnostics, diagnostics)) { + return; + } + state = state.copyWith( + workspace: workspace.copyWith( + runtimeDiagnostics: List.unmodifiable(diagnostics), + ), + ); + } + @override WorkspaceState build() { _service = ref.read(workspaceServiceProvider); @@ -1792,6 +1842,12 @@ class WorkspaceController extends Notifier { } _editRevision = nextBuffer.revision; state = state.copyWith( + workspace: workspace?.copyWith( + runtimeDiagnostics: [ + for (final diagnostic in workspace.runtimeDiagnostics) + if (diagnostic.filePath != activeEditorPath) diagnostic, + ], + ), documentBuffers: _replaceBuffer(state.documentBuffers, nextBuffer), liveOutline: workspace == null || liveOutline == null ? null diff --git a/lib/src/workspace/workspace_model.dart b/lib/src/workspace/workspace_model.dart index 2b88954c..f78ec113 100644 --- a/lib/src/workspace/workspace_model.dart +++ b/lib/src/workspace/workspace_model.dart @@ -96,6 +96,7 @@ class Workspace { required this.openedAt, required this.files, required this.diagnostics, + this.runtimeDiagnostics = const [], this.directories = const [], List openFilePaths = const [], this.activeFilePath, @@ -118,6 +119,9 @@ class Workspace { final List files; final List directories; final List diagnostics; + final List runtimeDiagnostics; + List get allDiagnostics => + sortDiagnostics([...diagnostics, ...runtimeDiagnostics]); final ParsedMarkdownDocument? markdown; final WritersideModule? writersideModule; @@ -129,6 +133,7 @@ class Workspace { List? files, List? directories, List? diagnostics, + List? runtimeDiagnostics, Object? markdown = _copyWithUnset, Object? writersideModule = _copyWithUnset, }) { @@ -161,6 +166,7 @@ class Workspace { files: files ?? this.files, directories: directories ?? this.directories, diagnostics: diagnostics ?? this.diagnostics, + runtimeDiagnostics: runtimeDiagnostics ?? this.runtimeDiagnostics, markdown: nextMarkdown, writersideModule: nextWritersideModule, ); diff --git a/test/fixtures/writerside/basic_project/topics/math.topic b/test/fixtures/writerside/basic_project/topics/math.topic index 0d22d543..231d91f6 100644 --- a/test/fixtures/writerside/basic_project/topics/math.topic +++ b/test/fixtures/writerside/basic_project/topics/math.topic @@ -1,5 +1,5 @@ -

Euler's identity is e^{i\pi}+1=0.

+

Euler's identity is e^{i\pi}+1=0 \land x < y.

diff --git a/test/src/markdown_math_export_test.dart b/test/src/markdown_math_export_test.dart index 35aa2701..52cd9248 100644 --- a/test/src/markdown_math_export_test.dart +++ b/test/src/markdown_math_export_test.dart @@ -17,6 +17,43 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; void main() { + test('uses each heading level effective text size for inline math', () async { + final root = await Directory.systemTemp.createTemp( + 'busymark-heading-math-export-', + ); + addTearDown(() => root.delete(recursive: true)); + final host = _PdfMathHost(); + final coordinator = MathCoordinator(renderer: MathRenderer(host: host)); + addTearDown(coordinator.dispose); + final source = [ + for (var level = 1; level <= 6; level++) + '${'#' * level} Heading \$h$level\$\n', + ].join('\n'); + final mapped = const MarkdownExportMapper().map( + const MarkdownParser() + .parse(filePath: '/workspace/headings.md', source: source) + .busyDocument, + ); + + await MarkdownMathExportRenderer(coordinator: coordinator).prepare( + document: mapped, + exportRoot: root, + containerWidth: 480, + cancellationToken: MarkdownPdfCancellationToken(), + ); + + final requests = host.batches.expand((batch) => batch).toList(); + expect(requests, hasLength(6)); + for (var level = 1; level <= 6; level++) { + final request = requests.singleWhere( + (item) => item['expression'] == 'h$level', + ); + final expected = busyMarkPdfHeadingTextSize(level); + expect(request['em'], expected, reason: 'heading level $level'); + expect(request['ex'], expected / 2, reason: 'heading level $level'); + } + }); + test( 'prepares inline and display math as deterministic vector assets', () async { @@ -80,6 +117,13 @@ $$ .length, 3, ); + await for (final asset in generated.list().where( + (entry) => entry.path.endsWith('.svg'), + )) { + final svg = await File(asset.path).readAsString(); + expect(svg, isNot(contains('currentColor'))); + expect(svg, contains('#000000')); + } final payload = const TypstPayloadBuilder().build( document: preparation.document, @@ -142,11 +186,14 @@ Failed but visible: $BAD$. } class _PdfMathHost implements WebRenderHost { + final List>> batches = []; + @override Future> renderMathBatch({ required List> expressions, required VisualizationCancellationToken cancellationToken, }) async { + batches.add(expressions); return { 'results': [ for (final item in expressions) diff --git a/test/src/math_parser_test.dart b/test/src/math_parser_test.dart index ac5f2168..fa3f4fab 100644 --- a/test/src/math_parser_test.dart +++ b/test/src/math_parser_test.dart @@ -147,6 +147,39 @@ F = ma expect(serializer.serialize(document), source); }); + test( + 'Writerside semantic math decodes XML entities but preserves source', + () { + const cases = <(String, String)>[ + ('<', '<'), + ('>', '>'), + ('&', '&'), + ('<', '<'), + ('>', '>'), + ('&unknown;', '&unknown;'), + ('', ''), + ('�', '�'), + ('<', '<'), + ]; + + for (final (encoded, decoded) in cases) { + final source = 'Before x $encoded y after.\n'; + final document = parse(source, writerside: true); + final math = document.blocks.single.inlines.singleWhere( + (inline) => inline.kind == BusyInlineKind.math, + ); + + expect(math.text, 'x $decoded y', reason: encoded); + expect( + math.attributes[busyMarkMathRawExpressionAttribute], + 'x $encoded y', + reason: encoded, + ); + expect(serializer.serialize(document), source, reason: encoded); + } + }, + ); + test( 'escaped dollars, code, currency, empty and malformed forms stay text', () { diff --git a/test/src/math_renderer_test.dart b/test/src/math_renderer_test.dart index 9eec6fb6..f9d26758 100644 --- a/test/src/math_renderer_test.dart +++ b/test/src/math_renderer_test.dart @@ -65,6 +65,27 @@ void main() { }, ); + test('deduplicates identical formulas pending in the same batch', () async { + final host = _MathHost(); + final coordinator = MathCoordinator(renderer: MathRenderer(host: host)); + addTearDown(coordinator.dispose); + + final results = await coordinator.renderAll([ + _request('first-instance', r'\mathbb{R}', blockKey: 'first-block'), + _request('second-instance', r'\mathbb{R}', blockKey: 'second-block'), + ]); + + expect(host.calls, 1); + expect(host.batches.single, hasLength(1)); + final first = results[0] as RenderedMathResult; + final second = results[1] as RenderedMathResult; + expect(first.expressionId, 'first-instance'); + expect(second.expressionId, 'second-instance'); + expect(first.svg, isNot(second.svg)); + expect(first.svg, contains('first-instance')); + expect(second.svg, contains('second-instance')); + }); + test('discards an obsolete block revision', () async { final host = _MathHost(delay: const Duration(milliseconds: 20)); final coordinator = MathCoordinator(renderer: MathRenderer(host: host)); @@ -164,6 +185,28 @@ void main() { expect(rebased, contains('currentColor')); }); + test('resolves currentColor without flattening explicit SVG colors', () { + const source = ''' + + + + +'''; + const preprocessor = MathSvgPreprocessor(); + + final resolved = preprocessor.resolveCurrentColor(source, '#123456'); + + expect(resolved, isNot(contains('currentColor'))); + expect(resolved, isNot(contains('CURRENTCOLOR'))); + expect(resolved, contains('fill="#123456"')); + expect(resolved, contains('stroke="#ff0000"')); + expect(resolved, contains('stroke:#00ff00')); + expect( + const GeneratedSvgNormalizer().normalize(resolved).vectorSafeSvg, + isNotNull, + ); + }); + test('rebases wide MathJax coordinates before secure normalization', () { const source = ''' failureCode = failure.code, ), ), ), @@ -101,6 +103,7 @@ void main() { findsOneWidget, ); expect(find.byType(SvgPicture), findsNothing); + expect(failureCode, 'math.invalidTex'); }); } diff --git a/test/src/web_render_host_test.dart b/test/src/web_render_host_test.dart index 072c12ea..8eec18b2 100644 --- a/test/src/web_render_host_test.dart +++ b/test/src/web_render_host_test.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:busymark/src/visualization/visualization_models.dart'; +import 'package:busymark/src/math/math_models.dart'; import 'package:busymark/src/visualization/visualization_renderer.dart'; import 'package:busymark/src/visualization/web_render_host.dart'; import 'package:flutter/services.dart'; @@ -61,6 +62,51 @@ void main() { expect(arguments['requestId'], isA()); }); + test('scales the MathJax timeout for sequential batch conversion', () { + const host = PlatformWebRenderHost(channel: channel); + + expect( + host.mathBatchTimeoutForExpressionCount(1), + const Duration(seconds: 10), + ); + expect( + host.mathBatchTimeoutForExpressionCount(128), + const Duration(milliseconds: 41750), + ); + expect( + host.mathBatchTimeoutForExpressionCount(1000), + const Duration(seconds: 45), + ); + }); + + test('applies the scaled timeout to a maximum accepted batch', () async { + messenger.setMockMethodCallHandler(channel, (call) async { + if (call.method == 'renderMathBatch') { + await Future.delayed(const Duration(milliseconds: 40)); + return {'results': []}; + } + return null; + }); + const host = PlatformWebRenderHost( + channel: channel, + mathTimeout: Duration(milliseconds: 5), + mathTimeoutPerAdditionalExpression: Duration(milliseconds: 2), + maximumMathTimeout: Duration(seconds: 1), + ); + final expressions = [ + for (var index = 0; index < busyMarkMaximumMathBatchExpressions; index++) + {'id': '$index', 'expression': 'x_$index'}, + ]; + + await expectLater( + host.renderMathBatch( + expressions: expressions, + cancellationToken: VisualizationCancellationToken(), + ), + completes, + ); + }); + test( 'cancels the matching native request and rejects a late success', () async { diff --git a/test/src/workspace_controller_test.dart b/test/src/workspace_controller_test.dart index 3ebfb40a..ea026905 100644 --- a/test/src/workspace_controller_test.dart +++ b/test/src/workspace_controller_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:busymark/src/app/app_settings.dart'; +import 'package:busymark/src/core/source_span.dart'; import 'package:busymark/src/workspace/document_buffer.dart'; import 'package:busymark/src/workspace/recovery_persistence.dart'; import 'package:busymark/src/workspace/session_persistence.dart'; @@ -879,6 +880,56 @@ void main() { settingsController.dispose(); }); + test( + 'math renderer failures become source-linked runtime diagnostics', + () async { + final harness = await _createControllerHarness(); + final controller = harness.controller; + await controller.openPath('test/fixtures/markdown/basic.md'); + final path = controller.state.workspace!.activeFilePath!; + final span = SourceSpan.fromOffsets( + filePath: path, + source: controller.state.activeText, + startOffset: 0, + endOffset: 4, + ); + + controller.updateMathRenderDiagnostic( + expressionId: 'inline-b0-i0', + code: 'math.invalidTex', + sourceSpan: span, + ); + + final workspace = controller.state.workspace!; + expect(workspace.runtimeDiagnostics.single.code, 'math.invalidTex'); + expect(workspace.runtimeDiagnostics.single.sourceSpan, same(span)); + expect( + workspace.allDiagnostics, + contains(workspace.runtimeDiagnostics.single), + ); + + controller.updateMathRenderDiagnostic( + expressionId: 'inline-b0-i0', + code: null, + sourceSpan: span, + ); + expect(controller.state.workspace!.runtimeDiagnostics, isEmpty); + + controller.updateMathRenderDiagnostic( + expressionId: 'inline-b0-i0', + code: 'math.invalidTex', + sourceSpan: span, + ); + controller.updateActiveText('${controller.state.activeText}\n'); + expect(controller.state.workspace!.runtimeDiagnostics, isEmpty); + await _waitFor( + () => + controller.state.workspace?.markdown?.source == + controller.state.activeText, + ); + }, + ); + test('validate on edit setting controls live diagnostics only', () async { final harness = await _createControllerHarness(); final settingsController = harness.settingsController; @@ -1378,6 +1429,18 @@ class _WorkspaceControllerDriver { _notifier.updateActiveText(text, sourceFilePath: sourceFilePath); } + void updateMathRenderDiagnostic({ + required String expressionId, + required String? code, + SourceSpan? sourceSpan, + }) { + _notifier.updateMathRenderDiagnostic( + expressionId: expressionId, + code: code, + sourceSpan: sourceSpan, + ); + } + Future saveActive({bool overwriteExternalChanges = false}) => _notifier.saveActive(overwriteExternalChanges: overwriteExternalChanges); diff --git a/test/src/writerside_test.dart b/test/src/writerside_test.dart index 0082eb2e..f779776e 100644 --- a/test/src/writerside_test.dart +++ b/test/src/writerside_test.dart @@ -191,9 +191,12 @@ void main() { .expand((block) => block.inlines) .where((inline) => inline.kind == PreviewInlineKind.math) .single; - expect(math.text, r'e^{i\pi}+1=0'); + expect(math.text, r'e^{i\pi}+1=0 \land x < y'); expect(math.attributes['mathSourceForm'], 'writersideElement'); - expect(source, contains(r'e^{i\pi}+1=0')); + expect( + source, + contains(r'e^{i\pi}+1=0 \land x < y'), + ); }, ); diff --git a/test/src/wysiwyg_math_test.dart b/test/src/wysiwyg_math_test.dart index 96dd52fd..d9ddce25 100644 --- a/test/src/wysiwyg_math_test.dart +++ b/test/src/wysiwyg_math_test.dart @@ -1,6 +1,9 @@ import 'package:busymark/l10n/generated/app_localizations.dart'; +import 'package:busymark/src/editor/document_list_marker.dart'; import 'package:busymark/src/editor/wysiwyg/wysiwyg_editor.dart'; import 'package:busymark/src/editor/wysiwyg/wysiwyg_document_controller.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_inline_controller.dart'; +import 'package:busymark/src/markdown/busymark_document.dart'; import 'package:busymark/src/markdown/markdown_parser.dart'; import 'package:busymark/src/visualization/visualization_providers.dart'; import 'package:busymark/src/visualization/visualization_renderer.dart'; @@ -12,6 +15,97 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + test('inline math insertion preserves structural block kinds', () { + const cases = <(String, String, BusyBlockKind, String)>[ + ( + '# Energy\n', + r'$Energy$', + BusyBlockKind.heading, + r'# $Energy$' + '\n', + ), + ( + '- Item\n', + r'$Item$', + BusyBlockKind.unorderedListItem, + r'- $Item$' + '\n', + ), + ( + '3. Item\n', + r'$Item$', + BusyBlockKind.orderedListItem, + r'3. $Item$' + '\n', + ), + ( + '- [ ] Task\n', + r'$Task$', + BusyBlockKind.taskListItem, + r'- [ ] $Task$' + '\n', + ), + ]; + + for (final (source, edit, kind, expected) in cases) { + final document = const MarkdownParser() + .parse(filePath: 'math.md', source: source) + .busyDocument; + final controller = BusyMarkWysiwygDocumentController(document: document); + + controller.updateMathSource(document.blocks.single.id, edit); + + expect(controller.document.blocks.single.kind, kind, reason: source); + expect(controller.markdown, expected, reason: source); + } + }); + + test('removing final formula leaves normal editing mode', () { + final document = const MarkdownParser() + .parse( + filePath: 'math.md', + source: + r'# Energy $E$' + '\n', + ) + .busyDocument; + final controller = BusyMarkWysiwygDocumentController(document: document); + + expect(controller.blockText(document.blocks.single.id), r'Energy $E$'); + controller.updateMathSource(document.blocks.single.id, 'Energy'); + + final block = controller.document.blocks.single; + expect(block.kind, BusyBlockKind.heading); + expect(busyMarkWysiwygBlockContainsMath(block), isFalse); + expect(controller.blockText(block.id), 'Energy'); + expect(block.attributes, isNot(contains('wysiwygMathSource'))); + }); + + test('table-cell source editing retains and removes semantic math', () { + final document = const MarkdownParser() + .parse( + filePath: 'math.md', + source: + '| Formula |\n| --- |\n' + r'| before $x$ after |' + '\n', + ) + .busyDocument; + final controller = BusyMarkWysiwygDocumentController(document: document); + final table = controller.document.blocks.single; + final cell = table.children[1].children.single; + + controller.updateTableCellText(table.id, cell.id, r'before $y^2$ after'); + var edited = controller.blockById(cell.id)!; + expect(busyMarkWysiwygBlockContainsMath(edited), isTrue); + expect(controller.markdown, contains(r'before $y^2$ after')); + + controller.updateTableCellText(table.id, cell.id, 'plain text'); + edited = controller.blockById(cell.id)!; + expect(busyMarkWysiwygBlockContainsMath(edited), isFalse); + expect(controller.markdown, contains('| plain text |')); + }); + test('math source edits cannot discard additional Markdown blocks', () { final document = const MarkdownParser() .parse( @@ -33,6 +127,10 @@ void main() { expect(controller.document.blocks, hasLength(2)); expect(controller.markdown, contains(r'Before $y$ after.')); expect(controller.markdown, contains('A second paragraph.')); + expect( + busyMarkWysiwygBlockContainsMath(controller.document.blocks.last), + isFalse, + ); }); testWidgets( @@ -158,6 +256,101 @@ void main() { await tester.pump(); expect(markdown, contains('\$\$\nx\n\$\$')); }); + + testWidgets('rendered and focused math list items keep one marker', ( + tester, + ) async { + final document = const MarkdownParser() + .parse( + filePath: 'math.md', + source: + r'- Item $x$' + '\n', + ) + .busyDocument; + + await tester.pumpWidget( + ProviderScope( + overrides: [ + webRenderHostProvider.overrideWithValue(_WysiwygMathHost()), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: BusyMarkWysiwygEditor( + document: document, + onSourceChanged: (_, _) {}, + ), + ), + ), + ), + ); + await _pumpMath(tester); + expect(find.byType(BusyMarkDocumentListMarker), findsOneWidget); + + await tester.tap( + find.byKey( + ValueKey('wysiwyg-rendered-math-${document.blocks.single.id}'), + ), + ); + await tester.pump(); + + expect(find.byType(BusyMarkDocumentListMarker), findsOneWidget); + expect( + tester.widget(find.byType(TextField)).controller?.text, + r'Item $x$', + ); + }); + + testWidgets('table math renders unfocused and edits delimiter source', ( + tester, + ) async { + final document = const MarkdownParser() + .parse( + filePath: 'math.md', + source: + '| Formula |\n| --- |\n' + r'| before $x$ after |' + '\n', + ) + .busyDocument; + final cell = document.blocks.single.children[1].children.single; + var markdown = document.source!; + + await tester.pumpWidget( + ProviderScope( + overrides: [ + webRenderHostProvider.overrideWithValue(_WysiwygMathHost()), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: BusyMarkWysiwygEditor( + document: document, + onSourceChanged: (_, source) => markdown = source, + ), + ), + ), + ), + ); + await _pumpMath(tester); + final rendered = find.byKey(ValueKey('wysiwyg-rendered-math-${cell.id}')); + expect(rendered, findsOneWidget); + + await tester.tap(rendered); + await tester.pump(); + final field = find.byKey(ValueKey(cell.id)); + expect( + tester.widget(field).controller?.text, + r'before $x$ after', + ); + + await tester.enterText(field, r'before $y^2$ after'); + await tester.pump(); + expect(markdown, contains(r'| before $y^2$ after |')); + }); } Future _sendUndo(WidgetTester tester) async { From 63ee144d5c712e9aff400dfcb27d72208739c859 Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 21 Aug 2026 16:24:34 -0700 Subject: [PATCH 07/38] Remove AI privacy notice --- lib/l10n/app_ar.arb | 3 --- lib/l10n/app_de.arb | 3 --- lib/l10n/app_en.arb | 6 ------ lib/l10n/app_es.arb | 3 --- lib/l10n/app_et.arb | 3 --- lib/l10n/app_fa.arb | 3 --- lib/l10n/app_fr.arb | 3 --- lib/l10n/app_hi.arb | 3 --- lib/l10n/app_it.arb | 3 --- lib/l10n/app_nb.arb | 3 --- lib/l10n/app_pl.arb | 3 --- lib/l10n/app_pt.arb | 3 --- lib/l10n/app_ru.arb | 3 --- lib/l10n/app_uk.arb | 3 --- lib/l10n/generated/app_localizations.dart | 18 ------------------ lib/l10n/generated/app_localizations_ar.dart | 13 ------------- lib/l10n/generated/app_localizations_de.dart | 13 ------------- lib/l10n/generated/app_localizations_en.dart | 13 ------------- lib/l10n/generated/app_localizations_es.dart | 13 ------------- lib/l10n/generated/app_localizations_et.dart | 13 ------------- lib/l10n/generated/app_localizations_fa.dart | 13 ------------- lib/l10n/generated/app_localizations_fr.dart | 13 ------------- lib/l10n/generated/app_localizations_hi.dart | 13 ------------- lib/l10n/generated/app_localizations_it.dart | 13 ------------- lib/l10n/generated/app_localizations_nb.dart | 13 ------------- lib/l10n/generated/app_localizations_pl.dart | 13 ------------- lib/l10n/generated/app_localizations_pt.dart | 13 ------------- lib/l10n/generated/app_localizations_ru.dart | 13 ------------- lib/l10n/generated/app_localizations_uk.dart | 13 ------------- .../presentation/settings_screen.dart | 14 -------------- 30 files changed, 259 deletions(-) diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index bfc01df3..214a6191 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -2631,9 +2631,6 @@ "aiReviewExactContent": "مراجعة المحتوى الدقيق", "aiContentToChange": "المحتوى المراد تغييره", "aiContentSentToAi": "المحتوى المُرسل إلى الذكاء الاصطناعي", - "aiPrivacyDisabled": "الذكاء الاصطناعي معطّل. لا يرسل BusyMark محتوى المستند مطلقًا من دون إجراء صريح للذكاء الاصطناعي.", - "aiPrivacyLocal": "لا يرسل BusyMark إلا السياق المعروض في مربع حوار المراجعة إلى خدمة Ollama المحلية المضبوطة. لا تُطبّق الاقتراحات مطلقًا من دون مراجعة.", - "aiPrivacyCloud": "لا يرسل BusyMark إلا السياق المعروض في مربع حوار المراجعة إلى ⁨{provider}⁩. الطلبات عديمة الحالة، ولا تُطبّق الاقتراحات مطلقًا من دون مراجعة.", "aiApiKey": "مفتاح API", "aiApiKeyStoredHint": "يوجد مفتاح محفوظ في مخزن بيانات الاعتماد في النظام", "aiApiKeyEnterHint": "أدخل مفتاح API للمزوّد", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 312d052a..7810d9c9 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -2652,9 +2652,6 @@ "aiReviewExactContent": "Genaue Inhalte prüfen", "aiContentToChange": "Zu ändernder Inhalt", "aiContentSentToAi": "An KI gesendeter Inhalt", - "aiPrivacyDisabled": "KI ist deaktiviert. BusyMark sendet Dokumentinhalte niemals ohne eine ausdrückliche KI-Aktion.", - "aiPrivacyLocal": "BusyMark sendet nur den im Prüfdialog angezeigten Kontext an den konfigurierten lokalen Ollama-Dienst. Vorschläge werden nie ohne Prüfung übernommen.", - "aiPrivacyCloud": "BusyMark sendet nur den im Prüfdialog angezeigten Kontext an {provider}. Anfragen sind zustandslos, und Vorschläge werden nie ohne Prüfung übernommen.", "aiApiKey": "API-Schlüssel", "aiApiKeyStoredHint": "Ein Schlüssel ist in der systemweiten Anmeldeinformationsverwaltung gespeichert", "aiApiKeyEnterHint": "API-Schlüssel des Anbieters eingeben", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b565d88a..2187f56d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -2179,12 +2179,6 @@ "@aiContentToChange": {"description": "Label for the exact Markdown that an AI proposal may change."}, "aiContentSentToAi": "Content sent to AI", "@aiContentSentToAi": {"description": "Label for the exact document context that will be sent to the configured AI provider."}, - "aiPrivacyDisabled": "AI is disabled. BusyMark never sends document content without an explicit AI action.", - "@aiPrivacyDisabled": {"description": "Privacy notice when AI is disabled."}, - "aiPrivacyLocal": "BusyMark sends only the context shown in the review dialog to the configured loopback Ollama service. Proposals are never applied without review.", - "@aiPrivacyLocal": {"description": "Privacy notice for local Ollama."}, - "aiPrivacyCloud": "BusyMark sends only the context shown in the review dialog to {provider}. Requests are stateless and proposals are never applied without review.", - "@aiPrivacyCloud": {"description": "Privacy notice for a selected cloud provider.", "placeholders": {"provider": {"type": "String"}}}, "aiApiKey": "API key", "@aiApiKey": {"description": "Label for a cloud AI provider API key."}, "aiApiKeyStoredHint": "A key is stored in the system credential store", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 70f0f880..66c4466c 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -2652,9 +2652,6 @@ "aiReviewExactContent": "Revisar contenido exacto", "aiContentToChange": "Contenido que se modificará", "aiContentSentToAi": "Contenido enviado a la IA", - "aiPrivacyDisabled": "La IA está desactivada. BusyMark nunca envía contenido del documento sin una acción de IA explícita.", - "aiPrivacyLocal": "BusyMark solo envía el contexto mostrado en el diálogo de revisión al servicio Ollama local configurado. Las propuestas nunca se aplican sin revisión.", - "aiPrivacyCloud": "BusyMark solo envía el contexto mostrado en el diálogo de revisión a {provider}. Las solicitudes no conservan estado y las propuestas nunca se aplican sin revisión.", "aiApiKey": "Clave de API", "aiApiKeyStoredHint": "Hay una clave guardada en el almacén de credenciales del sistema", "aiApiKeyEnterHint": "Introduzca una clave de API del proveedor", diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index 09594954..f52a341b 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -1840,9 +1840,6 @@ "aiReviewExactContent": "Vaata täpne sisu üle", "aiContentToChange": "Muudetav sisu", "aiContentSentToAi": "TI-le saadetav sisu", - "aiPrivacyDisabled": "Tehisintellekt on keelatud. BusyMark ei saada dokumendi sisu kunagi ilma selgesõnalise TI-toiminguta.", - "aiPrivacyLocal": "BusyMark saadab ülevaatusdialoogis kuvatud konteksti ainult seadistatud kohalikule Ollama teenusele. Ettepanekuid ei rakendata kunagi ilma ülevaatuseta.", - "aiPrivacyCloud": "BusyMark saadab ülevaatusdialoogis kuvatud konteksti ainult teenusele {provider}. Päringud on olekuta ja ettepanekuid ei rakendata kunagi ilma ülevaatuseta.", "aiApiKey": "API-võti", "aiApiKeyStoredHint": "Võti on salvestatud süsteemi mandaadihoidlasse", "aiApiKeyEnterHint": "Sisesta teenusepakkuja API-võti", diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 7342d338..d2d85a2b 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -2650,9 +2650,6 @@ "aiReviewExactContent": "بازبینی محتوای دقیق", "aiContentToChange": "محتوایی که تغییر می‌کند", "aiContentSentToAi": "محتوای ارسال‌شده به هوش مصنوعی", - "aiPrivacyDisabled": "هوش مصنوعی غیرفعال است. BusyMark هرگز بدون یک اقدام صریح هوش مصنوعی محتوای سند را ارسال نمی‌کند.", - "aiPrivacyLocal": "BusyMark فقط زمینهٔ نمایش‌داده‌شده در کادر بازبینی را به سرویس محلی Ollama پیکربندی‌شده می‌فرستد. پیشنهادها هرگز بدون بازبینی اعمال نمی‌شوند.", - "aiPrivacyCloud": "BusyMark فقط زمینهٔ نمایش‌داده‌شده در کادر بازبینی را به ⁨{provider}⁩ می‌فرستد. درخواست‌ها بدون حالت هستند و پیشنهادها هرگز بدون بازبینی اعمال نمی‌شوند.", "aiApiKey": "کلید API", "aiApiKeyStoredHint": "یک کلید در مخزن اعتبارنامهٔ سیستم ذخیره شده است", "aiApiKeyEnterHint": "کلید API ارائه‌دهنده را وارد کنید", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 338e0bc9..4b512c52 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -2652,9 +2652,6 @@ "aiReviewExactContent": "Vérifier le contenu exact", "aiContentToChange": "Contenu à modifier", "aiContentSentToAi": "Contenu envoyé à l’IA", - "aiPrivacyDisabled": "L’IA est désactivée. BusyMark n’envoie jamais le contenu du document sans action d’IA explicite.", - "aiPrivacyLocal": "BusyMark envoie uniquement le contexte affiché dans la boîte de dialogue de validation au service Ollama local configuré. Les propositions ne sont jamais appliquées sans validation.", - "aiPrivacyCloud": "BusyMark envoie uniquement le contexte affiché dans la boîte de dialogue de validation à {provider}. Les requêtes sont sans état et les propositions ne sont jamais appliquées sans validation.", "aiApiKey": "Clé API", "aiApiKeyStoredHint": "Une clé est enregistrée dans le trousseau d’identifiants du système", "aiApiKeyEnterHint": "Saisissez une clé API du fournisseur", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 450f368c..5e363f5f 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -2631,9 +2631,6 @@ "aiReviewExactContent": "सटीक सामग्री की समीक्षा करें", "aiContentToChange": "बदली जाने वाली सामग्री", "aiContentSentToAi": "AI को भेजी गई सामग्री", - "aiPrivacyDisabled": "AI अक्षम है। BusyMark किसी स्पष्ट AI कार्रवाई के बिना दस्तावेज़ की सामग्री कभी नहीं भेजता।", - "aiPrivacyLocal": "BusyMark समीक्षा संवाद में दिखाया गया संदर्भ केवल कॉन्फ़िगर की गई स्थानीय Ollama सेवा को भेजता है। प्रस्ताव समीक्षा के बिना कभी लागू नहीं होते।", - "aiPrivacyCloud": "BusyMark समीक्षा संवाद में दिखाया गया संदर्भ केवल {provider} को भेजता है। अनुरोध स्टेटलेस होते हैं और प्रस्ताव समीक्षा के बिना कभी लागू नहीं होते।", "aiApiKey": "API कुंजी", "aiApiKeyStoredHint": "एक कुंजी सिस्टम क्रेडेंशियल स्टोर में सुरक्षित है", "aiApiKeyEnterHint": "प्रदाता की API कुंजी दर्ज करें", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 856ca0f8..711500bb 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -2629,9 +2629,6 @@ "aiReviewExactContent": "Esamina contenuto esatto", "aiContentToChange": "Contenuto da modificare", "aiContentSentToAi": "Contenuto inviato all’IA", - "aiPrivacyDisabled": "L’IA è disabilitata. BusyMark non invia mai il contenuto del documento senza un’azione IA esplicita.", - "aiPrivacyLocal": "BusyMark invia solo il contesto mostrato nella finestra di revisione al servizio Ollama locale configurato. Le proposte non vengono mai applicate senza revisione.", - "aiPrivacyCloud": "BusyMark invia solo il contesto mostrato nella finestra di revisione a {provider}. Le richieste sono senza stato e le proposte non vengono mai applicate senza revisione.", "aiApiKey": "Chiave API", "aiApiKeyStoredHint": "Una chiave è salvata nell’archivio credenziali di sistema", "aiApiKeyEnterHint": "Inserisci una chiave API del fornitore", diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb index c2979df1..e07932e6 100644 --- a/lib/l10n/app_nb.arb +++ b/lib/l10n/app_nb.arb @@ -2629,9 +2629,6 @@ "aiReviewExactContent": "Se gjennom nøyaktig innhold", "aiContentToChange": "Innhold som skal endres", "aiContentSentToAi": "Innhold sendt til KI", - "aiPrivacyDisabled": "KI er deaktivert. BusyMark sender aldri dokumentinnhold uten en eksplisitt KI-handling.", - "aiPrivacyLocal": "BusyMark sender bare konteksten som vises i gjennomgangsdialogen, til den konfigurerte lokale Ollama-tjenesten. Forslag brukes aldri uten gjennomgang.", - "aiPrivacyCloud": "BusyMark sender bare konteksten som vises i gjennomgangsdialogen, til {provider}. Forespørsler er tilstandsløse, og forslag brukes aldri uten gjennomgang.", "aiApiKey": "API-nøkkel", "aiApiKeyStoredHint": "En nøkkel er lagret i systemets legitimasjonslager", "aiApiKeyEnterHint": "Skriv inn en API-nøkkel for leverandøren", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 6e46e2b3..68bb292c 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -2647,9 +2647,6 @@ "aiReviewExactContent": "Przejrzyj dokładną treść", "aiContentToChange": "Treść do zmiany", "aiContentSentToAi": "Treść wysyłana do SI", - "aiPrivacyDisabled": "SI jest wyłączona. BusyMark nigdy nie wysyła treści dokumentu bez jawnego działania SI.", - "aiPrivacyLocal": "BusyMark wysyła tylko kontekst pokazany w oknie przeglądu do skonfigurowanej lokalnej usługi Ollama. Propozycje nigdy nie są stosowane bez sprawdzenia.", - "aiPrivacyCloud": "BusyMark wysyła tylko kontekst pokazany w oknie przeglądu do {provider}. Żądania są bezstanowe, a propozycje nigdy nie są stosowane bez sprawdzenia.", "aiApiKey": "Klucz API", "aiApiKeyStoredHint": "Klucz jest zapisany w systemowym magazynie poświadczeń", "aiApiKeyEnterHint": "Wprowadź klucz API dostawcy", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 660e456f..60c866ac 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -2629,9 +2629,6 @@ "aiReviewExactContent": "Revisar conteúdo exato", "aiContentToChange": "Conteúdo a alterar", "aiContentSentToAi": "Conteúdo enviado à IA", - "aiPrivacyDisabled": "A IA está desativada. O BusyMark nunca envia o conteúdo do documento sem uma ação explícita de IA.", - "aiPrivacyLocal": "O BusyMark envia apenas o contexto exibido na caixa de diálogo de revisão ao serviço Ollama local configurado. As propostas nunca são aplicadas sem revisão.", - "aiPrivacyCloud": "O BusyMark envia apenas o contexto exibido na caixa de diálogo de revisão para {provider}. As solicitações não mantêm estado e as propostas nunca são aplicadas sem revisão.", "aiApiKey": "Chave de API", "aiApiKeyStoredHint": "Uma chave está armazenada no cofre de credenciais do sistema", "aiApiKeyEnterHint": "Insira uma chave de API do provedor", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index ed42e4e4..524a888b 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -2647,9 +2647,6 @@ "aiReviewExactContent": "Просмотреть точное содержимое", "aiContentToChange": "Содержимое для изменения", "aiContentSentToAi": "Содержимое, отправляемое ИИ", - "aiPrivacyDisabled": "ИИ отключён. BusyMark никогда не отправляет содержимое документа без явного действия с ИИ.", - "aiPrivacyLocal": "BusyMark отправляет только контекст, показанный в диалоге проверки, настроенной локальной службе Ollama. Предложения никогда не применяются без проверки.", - "aiPrivacyCloud": "BusyMark отправляет только контекст, показанный в диалоге проверки, поставщику {provider}. Запросы не сохраняют состояние, а предложения никогда не применяются без проверки.", "aiApiKey": "Ключ API", "aiApiKeyStoredHint": "Ключ сохранён в системном хранилище учётных данных", "aiApiKeyEnterHint": "Введите ключ API поставщика", diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index da74ff0a..a78e85cb 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -2647,9 +2647,6 @@ "aiReviewExactContent": "Переглянути точний вміст", "aiContentToChange": "Вміст для зміни", "aiContentSentToAi": "Вміст, що надсилається ШІ", - "aiPrivacyDisabled": "ШІ вимкнено. BusyMark ніколи не надсилає вміст документа без явної дії з ШІ.", - "aiPrivacyLocal": "BusyMark надсилає лише контекст, показаний у діалозі перевірки, налаштованій локальній службі Ollama. Пропозиції ніколи не застосовуються без перевірки.", - "aiPrivacyCloud": "BusyMark надсилає лише контекст, показаний у діалозі перевірки, постачальнику {provider}. Запити не зберігають стан, а пропозиції ніколи не застосовуються без перевірки.", "aiApiKey": "Ключ API", "aiApiKeyStoredHint": "Ключ збережено в системному сховищі облікових даних", "aiApiKeyEnterHint": "Введіть ключ API постачальника", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 1a0bfd44..ad4d389b 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -5673,24 +5673,6 @@ abstract class AppLocalizations { /// **'Content sent to AI'** String get aiContentSentToAi; - /// Privacy notice when AI is disabled. - /// - /// In en, this message translates to: - /// **'AI is disabled. BusyMark never sends document content without an explicit AI action.'** - String get aiPrivacyDisabled; - - /// Privacy notice for local Ollama. - /// - /// In en, this message translates to: - /// **'BusyMark sends only the context shown in the review dialog to the configured loopback Ollama service. Proposals are never applied without review.'** - String get aiPrivacyLocal; - - /// Privacy notice for a selected cloud provider. - /// - /// In en, this message translates to: - /// **'BusyMark sends only the context shown in the review dialog to {provider}. Requests are stateless and proposals are never applied without review.'** - String aiPrivacyCloud(String provider); - /// Label for a cloud AI provider API key. /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index 9928a190..be9daf77 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -3369,19 +3369,6 @@ class AppLocalizationsAr extends AppLocalizations { @override String get aiContentSentToAi => 'المحتوى المُرسل إلى الذكاء الاصطناعي'; - @override - String get aiPrivacyDisabled => - 'الذكاء الاصطناعي معطّل. لا يرسل BusyMark محتوى المستند مطلقًا من دون إجراء صريح للذكاء الاصطناعي.'; - - @override - String get aiPrivacyLocal => - 'لا يرسل BusyMark إلا السياق المعروض في مربع حوار المراجعة إلى خدمة Ollama المحلية المضبوطة. لا تُطبّق الاقتراحات مطلقًا من دون مراجعة.'; - - @override - String aiPrivacyCloud(String provider) { - return 'لا يرسل BusyMark إلا السياق المعروض في مربع حوار المراجعة إلى ⁨$provider⁩. الطلبات عديمة الحالة، ولا تُطبّق الاقتراحات مطلقًا من دون مراجعة.'; - } - @override String get aiApiKey => 'مفتاح API'; diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 7d1b3888..661eddf2 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -3384,19 +3384,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get aiContentSentToAi => 'An KI gesendeter Inhalt'; - @override - String get aiPrivacyDisabled => - 'KI ist deaktiviert. BusyMark sendet Dokumentinhalte niemals ohne eine ausdrückliche KI-Aktion.'; - - @override - String get aiPrivacyLocal => - 'BusyMark sendet nur den im Prüfdialog angezeigten Kontext an den konfigurierten lokalen Ollama-Dienst. Vorschläge werden nie ohne Prüfung übernommen.'; - - @override - String aiPrivacyCloud(String provider) { - return 'BusyMark sendet nur den im Prüfdialog angezeigten Kontext an $provider. Anfragen sind zustandslos, und Vorschläge werden nie ohne Prüfung übernommen.'; - } - @override String get aiApiKey => 'API-Schlüssel'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 7eaaa103..52a7e6d6 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -3368,19 +3368,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get aiContentSentToAi => 'Content sent to AI'; - @override - String get aiPrivacyDisabled => - 'AI is disabled. BusyMark never sends document content without an explicit AI action.'; - - @override - String get aiPrivacyLocal => - 'BusyMark sends only the context shown in the review dialog to the configured loopback Ollama service. Proposals are never applied without review.'; - - @override - String aiPrivacyCloud(String provider) { - return 'BusyMark sends only the context shown in the review dialog to $provider. Requests are stateless and proposals are never applied without review.'; - } - @override String get aiApiKey => 'API key'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 28b08dac..52b0031a 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -3386,19 +3386,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get aiContentSentToAi => 'Contenido enviado a la IA'; - @override - String get aiPrivacyDisabled => - 'La IA está desactivada. BusyMark nunca envía contenido del documento sin una acción de IA explícita.'; - - @override - String get aiPrivacyLocal => - 'BusyMark solo envía el contexto mostrado en el diálogo de revisión al servicio Ollama local configurado. Las propuestas nunca se aplican sin revisión.'; - - @override - String aiPrivacyCloud(String provider) { - return 'BusyMark solo envía el contexto mostrado en el diálogo de revisión a $provider. Las solicitudes no conservan estado y las propuestas nunca se aplican sin revisión.'; - } - @override String get aiApiKey => 'Clave de API'; diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index c65181dd..5b4b270e 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -3350,19 +3350,6 @@ class AppLocalizationsEt extends AppLocalizations { @override String get aiContentSentToAi => 'TI-le saadetav sisu'; - @override - String get aiPrivacyDisabled => - 'Tehisintellekt on keelatud. BusyMark ei saada dokumendi sisu kunagi ilma selgesõnalise TI-toiminguta.'; - - @override - String get aiPrivacyLocal => - 'BusyMark saadab ülevaatusdialoogis kuvatud konteksti ainult seadistatud kohalikule Ollama teenusele. Ettepanekuid ei rakendata kunagi ilma ülevaatuseta.'; - - @override - String aiPrivacyCloud(String provider) { - return 'BusyMark saadab ülevaatusdialoogis kuvatud konteksti ainult teenusele $provider. Päringud on olekuta ja ettepanekuid ei rakendata kunagi ilma ülevaatuseta.'; - } - @override String get aiApiKey => 'API-võti'; diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index 74bb78f2..58850d3f 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -3400,19 +3400,6 @@ class AppLocalizationsFa extends AppLocalizations { @override String get aiContentSentToAi => 'محتوای ارسال‌شده به هوش مصنوعی'; - @override - String get aiPrivacyDisabled => - 'هوش مصنوعی غیرفعال است. BusyMark هرگز بدون یک اقدام صریح هوش مصنوعی محتوای سند را ارسال نمی‌کند.'; - - @override - String get aiPrivacyLocal => - 'BusyMark فقط زمینهٔ نمایش‌داده‌شده در کادر بازبینی را به سرویس محلی Ollama پیکربندی‌شده می‌فرستد. پیشنهادها هرگز بدون بازبینی اعمال نمی‌شوند.'; - - @override - String aiPrivacyCloud(String provider) { - return 'BusyMark فقط زمینهٔ نمایش‌داده‌شده در کادر بازبینی را به ⁨$provider⁩ می‌فرستد. درخواست‌ها بدون حالت هستند و پیشنهادها هرگز بدون بازبینی اعمال نمی‌شوند.'; - } - @override String get aiApiKey => 'کلید API'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index cf150f4d..eb77e3fd 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -3379,19 +3379,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get aiContentSentToAi => 'Contenu envoyé à l’IA'; - @override - String get aiPrivacyDisabled => - 'L’IA est désactivée. BusyMark n’envoie jamais le contenu du document sans action d’IA explicite.'; - - @override - String get aiPrivacyLocal => - 'BusyMark envoie uniquement le contexte affiché dans la boîte de dialogue de validation au service Ollama local configuré. Les propositions ne sont jamais appliquées sans validation.'; - - @override - String aiPrivacyCloud(String provider) { - return 'BusyMark envoie uniquement le contexte affiché dans la boîte de dialogue de validation à $provider. Les requêtes sont sans état et les propositions ne sont jamais appliquées sans validation.'; - } - @override String get aiApiKey => 'Clé API'; diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index cdf11328..8275be09 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -3345,19 +3345,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get aiContentSentToAi => 'AI को भेजी गई सामग्री'; - @override - String get aiPrivacyDisabled => - 'AI अक्षम है। BusyMark किसी स्पष्ट AI कार्रवाई के बिना दस्तावेज़ की सामग्री कभी नहीं भेजता।'; - - @override - String get aiPrivacyLocal => - 'BusyMark समीक्षा संवाद में दिखाया गया संदर्भ केवल कॉन्फ़िगर की गई स्थानीय Ollama सेवा को भेजता है। प्रस्ताव समीक्षा के बिना कभी लागू नहीं होते।'; - - @override - String aiPrivacyCloud(String provider) { - return 'BusyMark समीक्षा संवाद में दिखाया गया संदर्भ केवल $provider को भेजता है। अनुरोध स्टेटलेस होते हैं और प्रस्ताव समीक्षा के बिना कभी लागू नहीं होते।'; - } - @override String get aiApiKey => 'API कुंजी'; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index fe4e0fc2..98852e3c 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -3376,19 +3376,6 @@ class AppLocalizationsIt extends AppLocalizations { @override String get aiContentSentToAi => 'Contenuto inviato all’IA'; - @override - String get aiPrivacyDisabled => - 'L’IA è disabilitata. BusyMark non invia mai il contenuto del documento senza un’azione IA esplicita.'; - - @override - String get aiPrivacyLocal => - 'BusyMark invia solo il contesto mostrato nella finestra di revisione al servizio Ollama locale configurato. Le proposte non vengono mai applicate senza revisione.'; - - @override - String aiPrivacyCloud(String provider) { - return 'BusyMark invia solo il contesto mostrato nella finestra di revisione a $provider. Le richieste sono senza stato e le proposte non vengono mai applicate senza revisione.'; - } - @override String get aiApiKey => 'Chiave API'; diff --git a/lib/l10n/generated/app_localizations_nb.dart b/lib/l10n/generated/app_localizations_nb.dart index f8b5392e..0857db0f 100644 --- a/lib/l10n/generated/app_localizations_nb.dart +++ b/lib/l10n/generated/app_localizations_nb.dart @@ -3349,19 +3349,6 @@ class AppLocalizationsNb extends AppLocalizations { @override String get aiContentSentToAi => 'Innhold sendt til KI'; - @override - String get aiPrivacyDisabled => - 'KI er deaktivert. BusyMark sender aldri dokumentinnhold uten en eksplisitt KI-handling.'; - - @override - String get aiPrivacyLocal => - 'BusyMark sender bare konteksten som vises i gjennomgangsdialogen, til den konfigurerte lokale Ollama-tjenesten. Forslag brukes aldri uten gjennomgang.'; - - @override - String aiPrivacyCloud(String provider) { - return 'BusyMark sender bare konteksten som vises i gjennomgangsdialogen, til $provider. Forespørsler er tilstandsløse, og forslag brukes aldri uten gjennomgang.'; - } - @override String get aiApiKey => 'API-nøkkel'; diff --git a/lib/l10n/generated/app_localizations_pl.dart b/lib/l10n/generated/app_localizations_pl.dart index 1d0295c8..1e67be7d 100644 --- a/lib/l10n/generated/app_localizations_pl.dart +++ b/lib/l10n/generated/app_localizations_pl.dart @@ -3392,19 +3392,6 @@ class AppLocalizationsPl extends AppLocalizations { @override String get aiContentSentToAi => 'Treść wysyłana do SI'; - @override - String get aiPrivacyDisabled => - 'SI jest wyłączona. BusyMark nigdy nie wysyła treści dokumentu bez jawnego działania SI.'; - - @override - String get aiPrivacyLocal => - 'BusyMark wysyła tylko kontekst pokazany w oknie przeglądu do skonfigurowanej lokalnej usługi Ollama. Propozycje nigdy nie są stosowane bez sprawdzenia.'; - - @override - String aiPrivacyCloud(String provider) { - return 'BusyMark wysyła tylko kontekst pokazany w oknie przeglądu do $provider. Żądania są bezstanowe, a propozycje nigdy nie są stosowane bez sprawdzenia.'; - } - @override String get aiApiKey => 'Klucz API'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 365022b8..e958d249 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -3370,19 +3370,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get aiContentSentToAi => 'Conteúdo enviado à IA'; - @override - String get aiPrivacyDisabled => - 'A IA está desativada. O BusyMark nunca envia o conteúdo do documento sem uma ação explícita de IA.'; - - @override - String get aiPrivacyLocal => - 'O BusyMark envia apenas o contexto exibido na caixa de diálogo de revisão ao serviço Ollama local configurado. As propostas nunca são aplicadas sem revisão.'; - - @override - String aiPrivacyCloud(String provider) { - return 'O BusyMark envia apenas o contexto exibido na caixa de diálogo de revisão para $provider. As solicitações não mantêm estado e as propostas nunca são aplicadas sem revisão.'; - } - @override String get aiApiKey => 'Chave de API'; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 30643fac..d0fd8371 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -3385,19 +3385,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get aiContentSentToAi => 'Содержимое, отправляемое ИИ'; - @override - String get aiPrivacyDisabled => - 'ИИ отключён. BusyMark никогда не отправляет содержимое документа без явного действия с ИИ.'; - - @override - String get aiPrivacyLocal => - 'BusyMark отправляет только контекст, показанный в диалоге проверки, настроенной локальной службе Ollama. Предложения никогда не применяются без проверки.'; - - @override - String aiPrivacyCloud(String provider) { - return 'BusyMark отправляет только контекст, показанный в диалоге проверки, поставщику $provider. Запросы не сохраняют состояние, а предложения никогда не применяются без проверки.'; - } - @override String get aiApiKey => 'Ключ API'; diff --git a/lib/l10n/generated/app_localizations_uk.dart b/lib/l10n/generated/app_localizations_uk.dart index 72ef9550..df0f17d6 100644 --- a/lib/l10n/generated/app_localizations_uk.dart +++ b/lib/l10n/generated/app_localizations_uk.dart @@ -3394,19 +3394,6 @@ class AppLocalizationsUk extends AppLocalizations { @override String get aiContentSentToAi => 'Вміст, що надсилається ШІ'; - @override - String get aiPrivacyDisabled => - 'ШІ вимкнено. BusyMark ніколи не надсилає вміст документа без явної дії з ШІ.'; - - @override - String get aiPrivacyLocal => - 'BusyMark надсилає лише контекст, показаний у діалозі перевірки, налаштованій локальній службі Ollama. Пропозиції ніколи не застосовуються без перевірки.'; - - @override - String aiPrivacyCloud(String provider) { - return 'BusyMark надсилає лише контекст, показаний у діалозі перевірки, постачальнику $provider. Запити не зберігають стан, а пропозиції ніколи не застосовуються без перевірки.'; - } - @override String get aiApiKey => 'Ключ API'; diff --git a/lib/src/workspace/presentation/settings_screen.dart b/lib/src/workspace/presentation/settings_screen.dart index df200c29..6e33885c 100644 --- a/lib/src/workspace/presentation/settings_screen.dart +++ b/lib/src/workspace/presentation/settings_screen.dart @@ -1022,13 +1022,6 @@ class _AiSettingsPageState extends ConsumerState<_AiSettingsPage> { title: context.l10n.ai, filled: true, children: [ - Padding( - padding: const EdgeInsets.all(BusyMarkSpacing.md), - child: BusyMarkStatusBox( - message: _privacyDescription(providerKind), - kind: BusyMarkStatusKind.information, - ), - ), BusyMarkActionRow( title: context.l10n.aiProvider, leading: const Icon(BusyMarkGlyphs.ai), @@ -1437,13 +1430,6 @@ class _AiSettingsPageState extends ConsumerState<_AiSettingsPage> { AiProviderPreference.gemini => 'Google Gemini', }; - String _privacyDescription(AiProviderKind? provider) => switch (provider) { - null => context.l10n.aiPrivacyDisabled, - AiProviderKind.ollamaLocal => context.l10n.aiPrivacyLocal, - AiProviderKind.openAi || - AiProviderKind.gemini => context.l10n.aiPrivacyCloud(provider.displayName), - }; - Future _saveEndpoint(String value) async { try { final endpoint = AiPolicy.validateLocalOllamaEndpoint(value); From f517c7e30ccf613c03cbf18914a023a8c5dd96be Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 21 Aug 2026 17:25:15 -0700 Subject: [PATCH 08/38] Support multiple AI providers --- lib/l10n/app_ar.arb | 5 +- lib/l10n/app_de.arb | 5 +- lib/l10n/app_en.arb | 12 +- lib/l10n/app_es.arb | 5 +- lib/l10n/app_et.arb | 5 +- lib/l10n/app_fa.arb | 5 +- lib/l10n/app_fr.arb | 5 +- lib/l10n/app_hi.arb | 5 +- lib/l10n/app_it.arb | 5 +- lib/l10n/app_nb.arb | 5 +- lib/l10n/app_pl.arb | 5 +- lib/l10n/app_pt.arb | 5 +- lib/l10n/app_ru.arb | 5 +- lib/l10n/app_uk.arb | 5 +- lib/l10n/generated/app_localizations.dart | 24 ++- lib/l10n/generated/app_localizations_ar.dart | 11 +- lib/l10n/generated/app_localizations_de.dart | 12 +- lib/l10n/generated/app_localizations_en.dart | 12 +- lib/l10n/generated/app_localizations_es.dart | 12 +- lib/l10n/generated/app_localizations_et.dart | 12 +- lib/l10n/generated/app_localizations_fa.dart | 12 +- lib/l10n/generated/app_localizations_fr.dart | 12 +- lib/l10n/generated/app_localizations_hi.dart | 12 +- lib/l10n/generated/app_localizations_it.dart | 12 +- lib/l10n/generated/app_localizations_nb.dart | 12 +- lib/l10n/generated/app_localizations_pl.dart | 12 +- lib/l10n/generated/app_localizations_pt.dart | 12 +- lib/l10n/generated/app_localizations_ru.dart | 12 +- lib/l10n/generated/app_localizations_uk.dart | 12 +- lib/src/ai/ai_configuration.dart | 2 +- lib/src/ai/ai_edit_ui.dart | 132 ++++++++++++- lib/src/ai/ai_providers.dart | 6 +- lib/src/git/presentation/git_sidebar_tab.dart | 5 + .../presentation/settings_screen.dart | 180 +++++++++++------- test/src/ai_edit_ui_test.dart | 179 ++++++++++++++++- test/src/app_settings_test.dart | 4 + 36 files changed, 645 insertions(+), 131 deletions(-) diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 214a6191..b8779b35 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -2594,12 +2594,15 @@ "aiDisabled": "معطّل", "aiLocalOnlyDescription": "لا يبدأ التحرير بالذكاء الاصطناعي إلا بإجراء صريح. لا يرسل BusyMark إلا السياق المعروض إلى المزوّد المحدد، ولا يطبّق أي اقتراح من دون مراجعته.", "aiProvider": "موفّر الذكاء الاصطناعي", + "aiDefaultProvider": "موفّر الذكاء الاصطناعي الافتراضي", + "aiConfigureProvider": "تهيئة موفّر الذكاء الاصطناعي", + "aiChooseProvider": "اختر موفّر الذكاء الاصطناعي", "aiOllamaEndpoint": "نقطة نهاية Ollama", "aiOllamaModel": "نموذج Ollama", "aiTestConnection": "اختبار الاتصال", "aiTestingConnection": "جارٍ الاختبار…", "aiConnectionReady": "تم الاتصال. عُثر على \u2068{count}\u2069 من النماذج المثبّتة.", - "aiNoModels": "يعمل Ollama، لكن لم يُعثر على نماذج مثبّتة.", + "aiNoModels": "لم يتم تحديد نموذج.", "aiConnectionFailed": "تعذّر على BusyMark التحقق من إنشاء النص بالذكاء الاصطناعي.", "aiConfigureFirst": "فعّل مزوّد ذكاء اصطناعي وتحقق من نموذج في الإعدادات ← الذكاء الاصطناعي.", "aiEditWithAi": "تحرير باستخدام الذكاء الاصطناعي", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 7810d9c9..e6c12196 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -2615,12 +2615,15 @@ "aiDisabled": "Deaktiviert", "aiLocalOnlyDescription": "KI-Bearbeitung erfolgt nur auf ausdrücklichen Befehl. BusyMark sendet ausschließlich den angezeigten Kontext an den ausgewählten Anbieter und übernimmt keinen Vorschlag ohne Prüfung.", "aiProvider": "KI-Anbieter", + "aiDefaultProvider": "Standardanbieter", + "aiConfigureProvider": "Anbieter konfigurieren", + "aiChooseProvider": "KI-Anbieter auswählen", "aiOllamaEndpoint": "Ollama-Endpunkt", "aiOllamaModel": "Ollama-Modell", "aiTestConnection": "Verbindung testen", "aiTestingConnection": "Wird getestet…", "aiConnectionReady": "Verbunden. {count} installierte(s) Modell(e) gefunden.", - "aiNoModels": "Ollama wird ausgeführt, aber es wurden keine installierten Modelle gefunden.", + "aiNoModels": "Kein Modell ausgewählt.", "aiConnectionFailed": "BusyMark konnte die KI-Textgenerierung nicht überprüfen.", "aiConfigureFirst": "Aktivieren Sie unter Einstellungen → KI einen KI-Anbieter und überprüfen Sie ein Modell.", "aiEditWithAi": "Mit KI bearbeiten", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 2187f56d..893c46ed 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -2118,7 +2118,13 @@ "aiLocalOnlyDescription": "AI editing is explicit. BusyMark sends only the context shown for the selected provider and never applies a proposal without review.", "@aiLocalOnlyDescription": {"description": "Privacy description for BusyMark local AI."}, "aiProvider": "AI provider", - "@aiProvider": {"description": "Settings label for the active AI provider."}, + "@aiProvider": {"description": "Label for an AI provider selection."}, + "aiDefaultProvider": "Default provider", + "@aiDefaultProvider": {"description": "Settings label for the provider selected by default for AI actions."}, + "aiConfigureProvider": "Configure provider", + "@aiConfigureProvider": {"description": "Settings label for choosing which AI provider configuration to edit."}, + "aiChooseProvider": "Choose AI provider", + "@aiChooseProvider": {"description": "Dialog title shown before selecting the AI provider for an action."}, "aiOllamaEndpoint": "Ollama endpoint", "@aiOllamaEndpoint": {"description": "Settings label for the local Ollama origin."}, "aiOllamaModel": "Ollama model", @@ -2129,8 +2135,8 @@ "@aiTestingConnection": {"description": "Status while BusyMark verifies the configured AI provider and model."}, "aiConnectionReady": "Connected. {count} installed model(s) found.", "@aiConnectionReady": {"description": "Successful Ollama connection status.", "placeholders": {"count": {"type": "int"}}}, - "aiNoModels": "Ollama is running, but no installed models were found.", - "@aiNoModels": {"description": "Ollama connection status when no model is installed."}, + "aiNoModels": "No model selected.", + "@aiNoModels": {"description": "AI model setting shown before a model has been selected or discovered."}, "aiConnectionFailed": "BusyMark could not verify AI text generation.", "@aiConnectionFailed": {"description": "Generic failure shown while testing AI generation."}, "aiConfigureFirst": "Enable an AI provider and verify a model in Settings → AI.", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 66c4466c..5111e293 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -2615,12 +2615,15 @@ "aiDisabled": "Desactivado", "aiLocalOnlyDescription": "La edición con IA solo se ejecuta de forma explícita. BusyMark envía únicamente el contexto mostrado al proveedor seleccionado y nunca aplica una propuesta sin revisarla.", "aiProvider": "Proveedor de IA", + "aiDefaultProvider": "Proveedor predeterminado", + "aiConfigureProvider": "Configurar proveedor", + "aiChooseProvider": "Elegir proveedor de IA", "aiOllamaEndpoint": "Punto de conexión de Ollama", "aiOllamaModel": "Modelo de Ollama", "aiTestConnection": "Probar conexión", "aiTestingConnection": "Probando…", "aiConnectionReady": "Conectado. Se encontraron {count} modelo(s) instalado(s).", - "aiNoModels": "Ollama está en ejecución, pero no se encontraron modelos instalados.", + "aiNoModels": "Ningún modelo seleccionado.", "aiConnectionFailed": "BusyMark no pudo verificar la generación de texto con IA.", "aiConfigureFirst": "Active un proveedor de IA y verifique un modelo en Configuración → IA.", "aiEditWithAi": "Editar con IA", diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index f52a341b..448a6ef4 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -1803,12 +1803,15 @@ "aiDisabled": "Keelatud", "aiLocalOnlyDescription": "Tehisintellektiga redigeerimine käivitatakse ainult selgesõnaliselt. BusyMark saadab valitud teenusepakkujale üksnes kuvatud konteksti ega rakenda ettepanekut ilma ülevaatuseta.", "aiProvider": "TI-teenuse pakkuja", + "aiDefaultProvider": "Vaikimisi teenusepakkuja", + "aiConfigureProvider": "Seadista teenusepakkujat", + "aiChooseProvider": "Vali TI-teenuse pakkuja", "aiOllamaEndpoint": "Ollama lõpp-punkt", "aiOllamaModel": "Ollama mudel", "aiTestConnection": "Testi ühendust", "aiTestingConnection": "Testimine…", "aiConnectionReady": "Ühendatud. Leiti {count} installitud mudelit.", - "aiNoModels": "Ollama töötab, kuid installitud mudeleid ei leitud.", + "aiNoModels": "Mudelit pole valitud.", "aiConnectionFailed": "BusyMark ei saanud tehisintellekti tekstiloomet kontrollida.", "aiConfigureFirst": "Luba jaotises Sätted → TI teenusepakkuja ning kontrolli mudelit.", "aiEditWithAi": "Redigeeri TI abil", diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index d2d85a2b..ff6e97df 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -2613,12 +2613,15 @@ "aiDisabled": "غیرفعال", "aiLocalOnlyDescription": "ویرایش با هوش مصنوعی فقط با اقدام صریح آغاز می‌شود. BusyMark تنها زمینهٔ نمایش‌داده‌شده را برای ارائه‌دهندهٔ انتخابی می‌فرستد و هیچ پیشنهادی را بدون بازبینی اعمال نمی‌کند.", "aiProvider": "ارائه‌دهندهٔ هوش مصنوعی", + "aiDefaultProvider": "ارائه‌دهندهٔ پیش‌فرض", + "aiConfigureProvider": "پیکربندی ارائه‌دهنده", + "aiChooseProvider": "انتخاب ارائه‌دهندهٔ هوش مصنوعی", "aiOllamaEndpoint": "نقطهٔ پایانی Ollama", "aiOllamaModel": "مدل Ollama", "aiTestConnection": "آزمایش اتصال", "aiTestingConnection": "در حال آزمایش…", "aiConnectionReady": "متصل شد. \u2068{count}\u2069 مدل نصب‌شده پیدا شد.", - "aiNoModels": "Ollama در حال اجرا است، اما هیچ مدل نصب‌شده‌ای پیدا نشد.", + "aiNoModels": "هیچ مدلی انتخاب نشده است.", "aiConnectionFailed": "BusyMark نتوانست تولید متن با هوش مصنوعی را تأیید کند.", "aiConfigureFirst": "ابتدا یک ارائه‌دهندهٔ هوش مصنوعی را فعال و مدلی را در تنظیمات ← هوش مصنوعی تأیید کنید.", "aiEditWithAi": "ویرایش با هوش مصنوعی", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 4b512c52..33d77b98 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -2615,12 +2615,15 @@ "aiDisabled": "Désactivé", "aiLocalOnlyDescription": "L’édition par IA est déclenchée explicitement. BusyMark envoie uniquement le contexte affiché au fournisseur sélectionné et n’applique jamais une proposition sans validation.", "aiProvider": "Fournisseur d’IA", + "aiDefaultProvider": "Fournisseur par défaut", + "aiConfigureProvider": "Configurer le fournisseur", + "aiChooseProvider": "Choisir un fournisseur d’IA", "aiOllamaEndpoint": "Point de terminaison Ollama", "aiOllamaModel": "Modèle Ollama", "aiTestConnection": "Tester la connexion", "aiTestingConnection": "Test en cours…", "aiConnectionReady": "Connecté. {count} modèle(s) installé(s) trouvé(s).", - "aiNoModels": "Ollama est en cours d’exécution, mais aucun modèle installé n’a été trouvé.", + "aiNoModels": "Aucun modèle sélectionné.", "aiConnectionFailed": "BusyMark n’a pas pu vérifier la génération de texte par IA.", "aiConfigureFirst": "Activez un fournisseur d’IA et vérifiez un modèle dans Paramètres → IA.", "aiEditWithAi": "Modifier avec l’IA", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 5e363f5f..5424d47b 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -2594,12 +2594,15 @@ "aiDisabled": "अक्षम", "aiLocalOnlyDescription": "AI संपादन केवल स्पष्ट कार्रवाई से शुरू होता है। BusyMark चयनित प्रदाता को केवल दिखाया गया संदर्भ भेजता है और समीक्षा के बिना किसी प्रस्ताव को लागू नहीं करता।", "aiProvider": "एआई प्रदाता", + "aiDefaultProvider": "डिफ़ॉल्ट प्रदाता", + "aiConfigureProvider": "प्रदाता कॉन्फ़िगर करें", + "aiChooseProvider": "एआई प्रदाता चुनें", "aiOllamaEndpoint": "Ollama एंडपॉइंट", "aiOllamaModel": "Ollama मॉडल", "aiTestConnection": "कनेक्शन जाँचें", "aiTestingConnection": "जाँच जारी…", "aiConnectionReady": "कनेक्ट हो गया। {count} इंस्टॉल किए गए मॉडल मिले।", - "aiNoModels": "Ollama चल रहा है, लेकिन कोई इंस्टॉल किया गया मॉडल नहीं मिला।", + "aiNoModels": "कोई मॉडल नहीं चुना गया।", "aiConnectionFailed": "BusyMark AI टेक्स्ट जनरेशन को सत्यापित नहीं कर सका।", "aiConfigureFirst": "पहले सेटिंग्स → AI में किसी AI प्रदाता को सक्षम करें और मॉडल सत्यापित करें।", "aiEditWithAi": "AI से संपादित करें", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 711500bb..a5427a36 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -2592,12 +2592,15 @@ "aiDisabled": "Disabilitato", "aiLocalOnlyDescription": "La modifica con IA viene avviata solo esplicitamente. BusyMark invia esclusivamente il contesto mostrato al fornitore selezionato e non applica mai una proposta senza revisione.", "aiProvider": "Provider IA", + "aiDefaultProvider": "Provider predefinito", + "aiConfigureProvider": "Configura provider", + "aiChooseProvider": "Scegli provider IA", "aiOllamaEndpoint": "Endpoint Ollama", "aiOllamaModel": "Modello Ollama", "aiTestConnection": "Verifica connessione", "aiTestingConnection": "Verifica in corso…", "aiConnectionReady": "Connesso. Trovati {count} modelli installati.", - "aiNoModels": "Ollama è in esecuzione, ma non sono stati trovati modelli installati.", + "aiNoModels": "Nessun modello selezionato.", "aiConnectionFailed": "BusyMark non è riuscito a verificare la generazione di testo con IA.", "aiConfigureFirst": "Abilita un fornitore di IA e verifica un modello in Impostazioni → IA.", "aiEditWithAi": "Modifica con l’IA", diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb index e07932e6..bb89f461 100644 --- a/lib/l10n/app_nb.arb +++ b/lib/l10n/app_nb.arb @@ -2592,12 +2592,15 @@ "aiDisabled": "Deaktivert", "aiLocalOnlyDescription": "KI-redigering startes bare eksplisitt. BusyMark sender kun den viste konteksten til den valgte leverandøren og bruker aldri et forslag uten gjennomgang.", "aiProvider": "KI-leverandør", + "aiDefaultProvider": "Standardleverandør", + "aiConfigureProvider": "Konfigurer leverandør", + "aiChooseProvider": "Velg KI-leverandør", "aiOllamaEndpoint": "Ollama-endepunkt", "aiOllamaModel": "Ollama-modell", "aiTestConnection": "Test tilkobling", "aiTestingConnection": "Tester…", "aiConnectionReady": "Tilkoblet. Fant {count} installert(e) modell(er).", - "aiNoModels": "Ollama kjører, men ingen installerte modeller ble funnet.", + "aiNoModels": "Ingen modell er valgt.", "aiConnectionFailed": "BusyMark kunne ikke bekrefte KI-tekstgenerering.", "aiConfigureFirst": "Aktiver en KI-leverandør og bekreft en modell under Innstillinger → KI.", "aiEditWithAi": "Rediger med KI", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 68bb292c..fe3b49fb 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -2610,12 +2610,15 @@ "aiDisabled": "Wyłączone", "aiLocalOnlyDescription": "Edycja z użyciem SI jest uruchamiana wyłącznie jawnie. BusyMark wysyła do wybranego dostawcy tylko pokazany kontekst i nigdy nie stosuje propozycji bez jej sprawdzenia.", "aiProvider": "Dostawca SI", + "aiDefaultProvider": "Domyślny dostawca", + "aiConfigureProvider": "Skonfiguruj dostawcę", + "aiChooseProvider": "Wybierz dostawcę SI", "aiOllamaEndpoint": "Punkt końcowy Ollama", "aiOllamaModel": "Model Ollama", "aiTestConnection": "Testuj połączenie", "aiTestingConnection": "Testowanie…", "aiConnectionReady": "Połączono. Znaleziono zainstalowane modele: {count}.", - "aiNoModels": "Ollama działa, ale nie znaleziono zainstalowanych modeli.", + "aiNoModels": "Nie wybrano modelu.", "aiConnectionFailed": "BusyMark nie mógł zweryfikować generowania tekstu przez SI.", "aiConfigureFirst": "Najpierw włącz dostawcę SI i zweryfikuj model w Ustawienia → SI.", "aiEditWithAi": "Edytuj za pomocą SI", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 60c866ac..b69df221 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -2592,12 +2592,15 @@ "aiDisabled": "Desativado", "aiLocalOnlyDescription": "A edição com IA é iniciada apenas de forma explícita. O BusyMark envia somente o contexto exibido ao provedor selecionado e nunca aplica uma proposta sem revisão.", "aiProvider": "Provedor de IA", + "aiDefaultProvider": "Provedor predefinido", + "aiConfigureProvider": "Configurar provedor", + "aiChooseProvider": "Escolher provedor de IA", "aiOllamaEndpoint": "Endpoint do Ollama", "aiOllamaModel": "Modelo do Ollama", "aiTestConnection": "Testar conexão", "aiTestingConnection": "Testando…", "aiConnectionReady": "Conectado. {count} modelo(s) instalado(s) encontrado(s).", - "aiNoModels": "O Ollama está em execução, mas nenhum modelo instalado foi encontrado.", + "aiNoModels": "Nenhum modelo selecionado.", "aiConnectionFailed": "O BusyMark não conseguiu verificar a geração de texto por IA.", "aiConfigureFirst": "Ative um provedor de IA e verifique um modelo em Configurações → IA.", "aiEditWithAi": "Editar com IA", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 524a888b..6b01ccce 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -2610,12 +2610,15 @@ "aiDisabled": "Отключено", "aiLocalOnlyDescription": "Редактирование с помощью ИИ запускается только явно. BusyMark отправляет выбранному поставщику только показанный контекст и никогда не применяет предложение без проверки.", "aiProvider": "Поставщик ИИ", + "aiDefaultProvider": "Поставщик по умолчанию", + "aiConfigureProvider": "Настроить поставщика", + "aiChooseProvider": "Выберите поставщика ИИ", "aiOllamaEndpoint": "Конечная точка Ollama", "aiOllamaModel": "Модель Ollama", "aiTestConnection": "Проверить подключение", "aiTestingConnection": "Проверка…", "aiConnectionReady": "Подключено. Найдено установленных моделей: {count}.", - "aiNoModels": "Ollama запущен, но установленные модели не найдены.", + "aiNoModels": "Модель не выбрана.", "aiConnectionFailed": "BusyMark не удалось проверить генерацию текста с помощью ИИ.", "aiConfigureFirst": "Включите поставщика ИИ и проверьте модель в разделе «Настройки → ИИ».", "aiEditWithAi": "Редактировать с помощью ИИ", diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index a78e85cb..33b68810 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -2610,12 +2610,15 @@ "aiDisabled": "Вимкнено", "aiLocalOnlyDescription": "Редагування за допомогою ШІ запускається лише явно. BusyMark надсилає вибраному постачальнику тільки показаний контекст і ніколи не застосовує пропозицію без перевірки.", "aiProvider": "Постачальник ШІ", + "aiDefaultProvider": "Постачальник за замовчуванням", + "aiConfigureProvider": "Налаштувати постачальника", + "aiChooseProvider": "Виберіть постачальника ШІ", "aiOllamaEndpoint": "Кінцева точка Ollama", "aiOllamaModel": "Модель Ollama", "aiTestConnection": "Перевірити підключення", "aiTestingConnection": "Перевірка…", "aiConnectionReady": "Підключено. Знайдено встановлених моделей: {count}.", - "aiNoModels": "Ollama запущено, але встановлених моделей не знайдено.", + "aiNoModels": "Модель не вибрана.", "aiConnectionFailed": "BusyMark не вдалося перевірити генерування тексту за допомогою ШІ.", "aiConfigureFirst": "Увімкніть постачальника ШІ та перевірте модель у розділі «Налаштування → ШІ».", "aiEditWithAi": "Редагувати за допомогою ШІ", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index ad4d389b..d7c4f887 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -5445,12 +5445,30 @@ abstract class AppLocalizations { /// **'AI editing is explicit. BusyMark sends only the context shown for the selected provider and never applies a proposal without review.'** String get aiLocalOnlyDescription; - /// Settings label for the active AI provider. + /// Label for an AI provider selection. /// /// In en, this message translates to: /// **'AI provider'** String get aiProvider; + /// Settings label for the provider selected by default for AI actions. + /// + /// In en, this message translates to: + /// **'Default provider'** + String get aiDefaultProvider; + + /// Settings label for choosing which AI provider configuration to edit. + /// + /// In en, this message translates to: + /// **'Configure provider'** + String get aiConfigureProvider; + + /// Dialog title shown before selecting the AI provider for an action. + /// + /// In en, this message translates to: + /// **'Choose AI provider'** + String get aiChooseProvider; + /// Settings label for the local Ollama origin. /// /// In en, this message translates to: @@ -5481,10 +5499,10 @@ abstract class AppLocalizations { /// **'Connected. {count} installed model(s) found.'** String aiConnectionReady(int count); - /// Ollama connection status when no model is installed. + /// AI model setting shown before a model has been selected or discovered. /// /// In en, this message translates to: - /// **'Ollama is running, but no installed models were found.'** + /// **'No model selected.'** String get aiNoModels; /// Generic failure shown while testing AI generation. diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index be9daf77..1032c3ed 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -3248,6 +3248,15 @@ class AppLocalizationsAr extends AppLocalizations { @override String get aiProvider => 'موفّر الذكاء الاصطناعي'; + @override + String get aiDefaultProvider => 'موفّر الذكاء الاصطناعي الافتراضي'; + + @override + String get aiConfigureProvider => 'تهيئة موفّر الذكاء الاصطناعي'; + + @override + String get aiChooseProvider => 'اختر موفّر الذكاء الاصطناعي'; + @override String get aiOllamaEndpoint => 'نقطة نهاية Ollama'; @@ -3266,7 +3275,7 @@ class AppLocalizationsAr extends AppLocalizations { } @override - String get aiNoModels => 'يعمل Ollama، لكن لم يُعثر على نماذج مثبّتة.'; + String get aiNoModels => 'لم يتم تحديد نموذج.'; @override String get aiConnectionFailed => diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 661eddf2..3b0dc373 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -3262,6 +3262,15 @@ class AppLocalizationsDe extends AppLocalizations { @override String get aiProvider => 'KI-Anbieter'; + @override + String get aiDefaultProvider => 'Standardanbieter'; + + @override + String get aiConfigureProvider => 'Anbieter konfigurieren'; + + @override + String get aiChooseProvider => 'KI-Anbieter auswählen'; + @override String get aiOllamaEndpoint => 'Ollama-Endpunkt'; @@ -3280,8 +3289,7 @@ class AppLocalizationsDe extends AppLocalizations { } @override - String get aiNoModels => - 'Ollama wird ausgeführt, aber es wurden keine installierten Modelle gefunden.'; + String get aiNoModels => 'Kein Modell ausgewählt.'; @override String get aiConnectionFailed => diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 52a7e6d6..4631bbef 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -3246,6 +3246,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get aiProvider => 'AI provider'; + @override + String get aiDefaultProvider => 'Default provider'; + + @override + String get aiConfigureProvider => 'Configure provider'; + + @override + String get aiChooseProvider => 'Choose AI provider'; + @override String get aiOllamaEndpoint => 'Ollama endpoint'; @@ -3264,8 +3273,7 @@ class AppLocalizationsEn extends AppLocalizations { } @override - String get aiNoModels => - 'Ollama is running, but no installed models were found.'; + String get aiNoModels => 'No model selected.'; @override String get aiConnectionFailed => diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 52b0031a..0758f3c9 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -3264,6 +3264,15 @@ class AppLocalizationsEs extends AppLocalizations { @override String get aiProvider => 'Proveedor de IA'; + @override + String get aiDefaultProvider => 'Proveedor predeterminado'; + + @override + String get aiConfigureProvider => 'Configurar proveedor'; + + @override + String get aiChooseProvider => 'Elegir proveedor de IA'; + @override String get aiOllamaEndpoint => 'Punto de conexión de Ollama'; @@ -3282,8 +3291,7 @@ class AppLocalizationsEs extends AppLocalizations { } @override - String get aiNoModels => - 'Ollama está en ejecución, pero no se encontraron modelos instalados.'; + String get aiNoModels => 'Ningún modelo seleccionado.'; @override String get aiConnectionFailed => diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index 5b4b270e..2870ad12 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -3228,6 +3228,15 @@ class AppLocalizationsEt extends AppLocalizations { @override String get aiProvider => 'TI-teenuse pakkuja'; + @override + String get aiDefaultProvider => 'Vaikimisi teenusepakkuja'; + + @override + String get aiConfigureProvider => 'Seadista teenusepakkujat'; + + @override + String get aiChooseProvider => 'Vali TI-teenuse pakkuja'; + @override String get aiOllamaEndpoint => 'Ollama lõpp-punkt'; @@ -3246,8 +3255,7 @@ class AppLocalizationsEt extends AppLocalizations { } @override - String get aiNoModels => - 'Ollama töötab, kuid installitud mudeleid ei leitud.'; + String get aiNoModels => 'Mudelit pole valitud.'; @override String get aiConnectionFailed => diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index 58850d3f..5866b6a1 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -3278,6 +3278,15 @@ class AppLocalizationsFa extends AppLocalizations { @override String get aiProvider => 'ارائه‌دهندهٔ هوش مصنوعی'; + @override + String get aiDefaultProvider => 'ارائه‌دهندهٔ پیش‌فرض'; + + @override + String get aiConfigureProvider => 'پیکربندی ارائه‌دهنده'; + + @override + String get aiChooseProvider => 'انتخاب ارائه‌دهندهٔ هوش مصنوعی'; + @override String get aiOllamaEndpoint => 'نقطهٔ پایانی Ollama'; @@ -3296,8 +3305,7 @@ class AppLocalizationsFa extends AppLocalizations { } @override - String get aiNoModels => - 'Ollama در حال اجرا است، اما هیچ مدل نصب‌شده‌ای پیدا نشد.'; + String get aiNoModels => 'هیچ مدلی انتخاب نشده است.'; @override String get aiConnectionFailed => diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index eb77e3fd..5c25aba2 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -3257,6 +3257,15 @@ class AppLocalizationsFr extends AppLocalizations { @override String get aiProvider => 'Fournisseur d’IA'; + @override + String get aiDefaultProvider => 'Fournisseur par défaut'; + + @override + String get aiConfigureProvider => 'Configurer le fournisseur'; + + @override + String get aiChooseProvider => 'Choisir un fournisseur d’IA'; + @override String get aiOllamaEndpoint => 'Point de terminaison Ollama'; @@ -3275,8 +3284,7 @@ class AppLocalizationsFr extends AppLocalizations { } @override - String get aiNoModels => - 'Ollama est en cours d’exécution, mais aucun modèle installé n’a été trouvé.'; + String get aiNoModels => 'Aucun modèle sélectionné.'; @override String get aiConnectionFailed => diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 8275be09..9722d7ba 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -3223,6 +3223,15 @@ class AppLocalizationsHi extends AppLocalizations { @override String get aiProvider => 'एआई प्रदाता'; + @override + String get aiDefaultProvider => 'डिफ़ॉल्ट प्रदाता'; + + @override + String get aiConfigureProvider => 'प्रदाता कॉन्फ़िगर करें'; + + @override + String get aiChooseProvider => 'एआई प्रदाता चुनें'; + @override String get aiOllamaEndpoint => 'Ollama एंडपॉइंट'; @@ -3241,8 +3250,7 @@ class AppLocalizationsHi extends AppLocalizations { } @override - String get aiNoModels => - 'Ollama चल रहा है, लेकिन कोई इंस्टॉल किया गया मॉडल नहीं मिला।'; + String get aiNoModels => 'कोई मॉडल नहीं चुना गया।'; @override String get aiConnectionFailed => diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index 98852e3c..69d0c89c 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -3254,6 +3254,15 @@ class AppLocalizationsIt extends AppLocalizations { @override String get aiProvider => 'Provider IA'; + @override + String get aiDefaultProvider => 'Provider predefinito'; + + @override + String get aiConfigureProvider => 'Configura provider'; + + @override + String get aiChooseProvider => 'Scegli provider IA'; + @override String get aiOllamaEndpoint => 'Endpoint Ollama'; @@ -3272,8 +3281,7 @@ class AppLocalizationsIt extends AppLocalizations { } @override - String get aiNoModels => - 'Ollama è in esecuzione, ma non sono stati trovati modelli installati.'; + String get aiNoModels => 'Nessun modello selezionato.'; @override String get aiConnectionFailed => diff --git a/lib/l10n/generated/app_localizations_nb.dart b/lib/l10n/generated/app_localizations_nb.dart index 0857db0f..83212a62 100644 --- a/lib/l10n/generated/app_localizations_nb.dart +++ b/lib/l10n/generated/app_localizations_nb.dart @@ -3227,6 +3227,15 @@ class AppLocalizationsNb extends AppLocalizations { @override String get aiProvider => 'KI-leverandør'; + @override + String get aiDefaultProvider => 'Standardleverandør'; + + @override + String get aiConfigureProvider => 'Konfigurer leverandør'; + + @override + String get aiChooseProvider => 'Velg KI-leverandør'; + @override String get aiOllamaEndpoint => 'Ollama-endepunkt'; @@ -3245,8 +3254,7 @@ class AppLocalizationsNb extends AppLocalizations { } @override - String get aiNoModels => - 'Ollama kjører, men ingen installerte modeller ble funnet.'; + String get aiNoModels => 'Ingen modell er valgt.'; @override String get aiConnectionFailed => diff --git a/lib/l10n/generated/app_localizations_pl.dart b/lib/l10n/generated/app_localizations_pl.dart index 1e67be7d..6016199a 100644 --- a/lib/l10n/generated/app_localizations_pl.dart +++ b/lib/l10n/generated/app_localizations_pl.dart @@ -3270,6 +3270,15 @@ class AppLocalizationsPl extends AppLocalizations { @override String get aiProvider => 'Dostawca SI'; + @override + String get aiDefaultProvider => 'Domyślny dostawca'; + + @override + String get aiConfigureProvider => 'Skonfiguruj dostawcę'; + + @override + String get aiChooseProvider => 'Wybierz dostawcę SI'; + @override String get aiOllamaEndpoint => 'Punkt końcowy Ollama'; @@ -3288,8 +3297,7 @@ class AppLocalizationsPl extends AppLocalizations { } @override - String get aiNoModels => - 'Ollama działa, ale nie znaleziono zainstalowanych modeli.'; + String get aiNoModels => 'Nie wybrano modelu.'; @override String get aiConnectionFailed => diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index e958d249..72e2eb16 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -3248,6 +3248,15 @@ class AppLocalizationsPt extends AppLocalizations { @override String get aiProvider => 'Provedor de IA'; + @override + String get aiDefaultProvider => 'Provedor predefinido'; + + @override + String get aiConfigureProvider => 'Configurar provedor'; + + @override + String get aiChooseProvider => 'Escolher provedor de IA'; + @override String get aiOllamaEndpoint => 'Endpoint do Ollama'; @@ -3266,8 +3275,7 @@ class AppLocalizationsPt extends AppLocalizations { } @override - String get aiNoModels => - 'O Ollama está em execução, mas nenhum modelo instalado foi encontrado.'; + String get aiNoModels => 'Nenhum modelo selecionado.'; @override String get aiConnectionFailed => diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index d0fd8371..4bfb3eb4 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -3263,6 +3263,15 @@ class AppLocalizationsRu extends AppLocalizations { @override String get aiProvider => 'Поставщик ИИ'; + @override + String get aiDefaultProvider => 'Поставщик по умолчанию'; + + @override + String get aiConfigureProvider => 'Настроить поставщика'; + + @override + String get aiChooseProvider => 'Выберите поставщика ИИ'; + @override String get aiOllamaEndpoint => 'Конечная точка Ollama'; @@ -3281,8 +3290,7 @@ class AppLocalizationsRu extends AppLocalizations { } @override - String get aiNoModels => - 'Ollama запущен, но установленные модели не найдены.'; + String get aiNoModels => 'Модель не выбрана.'; @override String get aiConnectionFailed => diff --git a/lib/l10n/generated/app_localizations_uk.dart b/lib/l10n/generated/app_localizations_uk.dart index df0f17d6..c7807edf 100644 --- a/lib/l10n/generated/app_localizations_uk.dart +++ b/lib/l10n/generated/app_localizations_uk.dart @@ -3272,6 +3272,15 @@ class AppLocalizationsUk extends AppLocalizations { @override String get aiProvider => 'Постачальник ШІ'; + @override + String get aiDefaultProvider => 'Постачальник за замовчуванням'; + + @override + String get aiConfigureProvider => 'Налаштувати постачальника'; + + @override + String get aiChooseProvider => 'Виберіть постачальника ШІ'; + @override String get aiOllamaEndpoint => 'Кінцева точка Ollama'; @@ -3290,8 +3299,7 @@ class AppLocalizationsUk extends AppLocalizations { } @override - String get aiNoModels => - 'Ollama запущено, але встановлених моделей не знайдено.'; + String get aiNoModels => 'Модель не вибрана.'; @override String get aiConnectionFailed => diff --git a/lib/src/ai/ai_configuration.dart b/lib/src/ai/ai_configuration.dart index b6572cbb..66b66c51 100644 --- a/lib/src/ai/ai_configuration.dart +++ b/lib/src/ai/ai_configuration.dart @@ -3,7 +3,7 @@ import 'ai_models.dart'; import 'ai_provider.dart'; extension AiSettingsConfiguration on AppSettings { - AiProviderKind? get aiProviderKind => switch (aiProviderPreference) { + AiProviderKind? get defaultAiProviderKind => switch (aiProviderPreference) { AiProviderPreference.disabled => null, AiProviderPreference.ollamaLocal => AiProviderKind.ollamaLocal, AiProviderPreference.openAi => AiProviderKind.openAi, diff --git a/lib/src/ai/ai_edit_ui.dart b/lib/src/ai/ai_edit_ui.dart index f1c63a8d..48ddeb05 100644 --- a/lib/src/ai/ai_edit_ui.dart +++ b/lib/src/ai/ai_edit_ui.dart @@ -25,12 +25,16 @@ Future showBusyMarkAiEdit( AiEditorSnapshot snapshot, { AiEditTargetKind? fixedTarget, }) async { + final defaultProvider = ref + .read(appSettingsControllerProvider) + .defaultAiProviderKind; final configuration = await showBusyMarkModalEditorDialog<_AiEditConfiguration>( context, builder: (dialogContext) => _AiEditConfigurationDialog( snapshot: snapshot, fixedTarget: fixedTarget, + initialProvider: defaultProvider ?? AiProviderKind.ollamaLocal, ), ); if (configuration == null || !context.mounted) { @@ -56,7 +60,12 @@ Future showBusyMarkAiEdit( replacementSuffix: target.replacementSuffix, trimReplacementOutput: target.trimReplacementOutput, ); - final output = await showBusyMarkAiProposal(context, ref, invocation); + final output = await showBusyMarkAiProposal( + context, + ref, + invocation, + providerKind: configuration.provider, + ); return output == null ? null : AiEditApplication(invocation: invocation, output: output); @@ -66,23 +75,27 @@ Future showBusyMarkAiProposal( BuildContext context, WidgetRef ref, AiEditInvocation invocation, { + AiProviderKind? providerKind, Future Function()? validateBeforeApply, String? staleMessage, }) async { final settings = ref.read(appSettingsControllerProvider); - final providerKind = settings.aiProviderKind; - if (providerKind == null) { + final defaultProvider = settings.defaultAiProviderKind; + if (defaultProvider == null) { await _showAiMessage(context, context.l10n.aiConfigureFirst); return null; } - if (!settings.hasCloudConsent(providerKind)) { + final selectedProvider = providerKind ?? defaultProvider; + if (!settings.hasCloudConsent(selectedProvider)) { await _showAiMessage( context, - context.l10n.aiCloudConsentRequired(providerKind.displayName), + context.l10n.aiCloudConsentRequired(selectedProvider.displayName), ); return null; } - final provider = ref.read(aiProviderRegistryProvider).require(providerKind); + final provider = ref + .read(aiProviderRegistryProvider) + .require(selectedProvider); final modelCandidates = settings.modelCandidatesFor( invocation.feature, provider, @@ -92,7 +105,7 @@ Future showBusyMarkAiProposal( return null; } try { - if (providerKind == AiProviderKind.ollamaLocal) { + if (selectedProvider == AiProviderKind.ollamaLocal) { AiPolicy.validateLocalOllamaEndpoint(settings.aiOllamaEndpoint); } } on AiException catch (error) { @@ -107,7 +120,7 @@ Future showBusyMarkAiProposal( request = AiPromptBuilder.build( id: const Uuid().v4(), targetId: invocation.targetId, - provider: providerKind, + provider: selectedProvider, feature: invocation.feature, scope: invocation.scope, input: invocation.input, @@ -124,7 +137,7 @@ Future showBusyMarkAiProposal( replacementPrefix: invocation.replacementPrefix, replacementSuffix: invocation.replacementSuffix, trimReplacementOutput: invocation.trimReplacementOutput, - deadline: providerKind == AiProviderKind.ollamaLocal + deadline: selectedProvider == AiProviderKind.ollamaLocal ? const Duration(minutes: 5) : const Duration(minutes: 2), ); @@ -149,6 +162,27 @@ Future showBusyMarkAiProposal( ); } +Future chooseBusyMarkAiProvider( + BuildContext context, + WidgetRef ref, +) async { + final defaultProvider = ref + .read(appSettingsControllerProvider) + .defaultAiProviderKind; + if (defaultProvider == null) { + await _showAiMessage(context, context.l10n.aiConfigureFirst); + return null; + } + if (!context.mounted) { + return null; + } + return showBusyMarkModalDialog( + context, + builder: (dialogContext) => + _AiProviderChoiceDialog(initialProvider: defaultProvider), + ); +} + Future _showAiMessage(BuildContext context, String message) { return showBusyMarkModalDialog( context, @@ -171,16 +205,23 @@ class _AiEditConfiguration { const _AiEditConfiguration({ required this.instruction, required this.resolvedTarget, + required this.provider, }); final String instruction; final AiMarkdownEditTarget resolvedTarget; + final AiProviderKind provider; } class _AiEditConfigurationDialog extends StatefulWidget { - const _AiEditConfigurationDialog({required this.snapshot, this.fixedTarget}); + const _AiEditConfigurationDialog({ + required this.snapshot, + required this.initialProvider, + this.fixedTarget, + }); final AiEditorSnapshot snapshot; + final AiProviderKind initialProvider; final AiEditTargetKind? fixedTarget; @override @@ -193,6 +234,7 @@ class _AiEditConfigurationDialogState final _controller = TextEditingController(); late AiEditTargetKind _target; late AiEditContextKind _context; + late AiProviderKind _provider; AiMarkdownEditTarget? _resolvedTarget; String? _resolutionError; @@ -203,6 +245,7 @@ class _AiEditConfigurationDialogState @override void initState() { super.initState(); + _provider = widget.initialProvider; if (widget.fixedTarget case final fixedTarget?) { _target = fixedTarget; _context = fixedTarget == AiEditTargetKind.document @@ -246,12 +289,21 @@ class _AiEditConfigurationDialogState _AiEditConfiguration( instruction: _controller.text.trim(), resolvedTarget: _resolvedTarget!, + provider: _provider, ), ), children: [ BusyMarkGroupedList( filled: true, children: [ + BusyMarkComboRow( + key: const ValueKey('ai-edit-provider'), + title: context.l10n.aiProvider, + values: AiProviderKind.values, + selected: _provider, + labelFor: (provider) => _providerLabel(context, provider), + onSelected: (provider) => setState(() => _provider = provider), + ), BusyMarkGroupedTextEntry( key: const ValueKey('ai-edit-instruction'), label: context.l10n.aiInstruction, @@ -383,6 +435,66 @@ class _AiEditConfigurationDialogState } } +class _AiProviderChoiceDialog extends StatefulWidget { + const _AiProviderChoiceDialog({required this.initialProvider}); + + final AiProviderKind initialProvider; + + @override + State<_AiProviderChoiceDialog> createState() => + _AiProviderChoiceDialogState(); +} + +class _AiProviderChoiceDialogState extends State<_AiProviderChoiceDialog> { + late AiProviderKind _provider; + + @override + void initState() { + super.initState(); + _provider = widget.initialProvider; + } + + @override + Widget build(BuildContext context) { + return BusyMarkDialogShell( + title: context.l10n.aiChooseProvider, + actions: [ + BusyMarkDialogButton( + label: context.l10n.cancel, + onPressed: () => Navigator.pop(context), + ), + BusyMarkDialogButton( + label: context.l10n.aiGenerateProposal, + suggested: true, + onPressed: () => Navigator.pop(context, _provider), + ), + ], + children: [ + BusyMarkGroupedList( + filled: true, + children: [ + BusyMarkComboRow( + key: const ValueKey('ai-provider-choice'), + title: context.l10n.aiProvider, + values: AiProviderKind.values, + selected: _provider, + labelFor: (provider) => _providerLabel(context, provider), + onSelected: (provider) => setState(() => _provider = provider), + ), + ], + ), + ], + ); + } +} + +String _providerLabel(BuildContext context, AiProviderKind provider) => + switch (provider) { + AiProviderKind.ollamaLocal => context.l10n.aiLocalOllama, + AiProviderKind.openAi => 'OpenAI', + AiProviderKind.gemini => 'Google Gemini', + }; + class _AiContentDisclosure extends StatefulWidget { const _AiContentDisclosure({required this.title, required this.content}); diff --git a/lib/src/ai/ai_providers.dart b/lib/src/ai/ai_providers.dart index 626f2bb4..9478de9c 100644 --- a/lib/src/ai/ai_providers.dart +++ b/lib/src/ai/ai_providers.dart @@ -42,9 +42,11 @@ final aiProviderRegistryProvider = Provider((ref) { ]); }); -final aiProviderProvider = Provider((ref) { +final defaultAiProviderProvider = Provider((ref) { final kind = ref.watch( - appSettingsControllerProvider.select((value) => value.aiProviderKind), + appSettingsControllerProvider.select( + (value) => value.defaultAiProviderKind, + ), ); if (kind == null) { throw const AiException( diff --git a/lib/src/git/presentation/git_sidebar_tab.dart b/lib/src/git/presentation/git_sidebar_tab.dart index eec76a20..7d15ae6e 100644 --- a/lib/src/git/presentation/git_sidebar_tab.dart +++ b/lib/src/git/presentation/git_sidebar_tab.dart @@ -164,6 +164,10 @@ class GitSidebarTab extends ConsumerWidget { if (stagedDiff == null || !context.mounted) { return null; } + final provider = await chooseBusyMarkAiProvider(context, ref); + if (provider == null || !context.mounted) { + return null; + } final repository = ref.read(gitControllerProvider).repositoryInfo; return showBusyMarkAiProposal( context, @@ -179,6 +183,7 @@ class GitSidebarTab extends ConsumerWidget { contentFormat: AiContentFormat.plainText, enforceDocumentRevision: false, ), + providerKind: provider, validateBeforeApply: () => controller.stagedDiffMatches(stagedDiff.fingerprint), staleMessage: context.l10n.gitAiStagedChangesChanged, diff --git a/lib/src/workspace/presentation/settings_screen.dart b/lib/src/workspace/presentation/settings_screen.dart index 6e33885c..148f0a03 100644 --- a/lib/src/workspace/presentation/settings_screen.dart +++ b/lib/src/workspace/presentation/settings_screen.dart @@ -973,7 +973,8 @@ class _AiSettingsPage extends ConsumerStatefulWidget { class _AiSettingsPageState extends ConsumerState<_AiSettingsPage> { late final TextEditingController _endpointController; late final TextEditingController _apiKeyController; - List _models = const []; + late AiProviderKind _configurationProvider; + final Map> _modelsByProvider = {}; String? _status; BusyMarkStatusKind _statusKind = BusyMarkStatusKind.information; var _testing = false; @@ -982,11 +983,14 @@ class _AiSettingsPageState extends ConsumerState<_AiSettingsPage> { @override void initState() { super.initState(); + final settings = ref.read(appSettingsControllerProvider); + _configurationProvider = + settings.defaultAiProviderKind ?? AiProviderKind.ollamaLocal; _endpointController = TextEditingController( - text: ref.read(appSettingsControllerProvider).aiOllamaEndpoint, + text: settings.aiOllamaEndpoint, ); _apiKeyController = TextEditingController(); - unawaited(_loadCredentialState()); + unawaited(_loadCredentialState(_configurationProvider)); } @override @@ -1000,22 +1004,21 @@ class _AiSettingsPageState extends ConsumerState<_AiSettingsPage> { Widget build(BuildContext context) { final settings = ref.watch(appSettingsControllerProvider); final controller = ref.read(appSettingsControllerProvider.notifier); - final providerKind = settings.aiProviderKind; - final enabled = providerKind != null; + final enabled = settings.defaultAiProviderKind != null; + final providerKind = _configurationProvider; final local = providerKind == AiProviderKind.ollamaLocal; - final cloud = providerKind?.isCloud ?? false; - final provider = providerKind == null - ? null - : ref.watch(aiProviderRegistryProvider).require(providerKind); - final selectedModel = providerKind == null - ? '' - : settings.selectedAiModel(providerKind); + final cloud = providerKind.isCloud; + final provider = ref + .watch(aiProviderRegistryProvider) + .require(providerKind); + final selectedModel = settings.selectedAiModel(providerKind); final modelNames = { if (selectedModel.isNotEmpty) selectedModel, - if (provider != null) - for (final values in provider.capabilities.recommendedModels.values) - ...values, - for (final model in _models) model.name, + for (final values in provider.capabilities.recommendedModels.values) + ...values, + for (final model + in _modelsByProvider[providerKind] ?? const []) + model.name, }.toList(growable: false); final usage = ref.watch(aiMonthlyUsageProvider).value; return BusyMarkGroupedList( @@ -1023,14 +1026,14 @@ class _AiSettingsPageState extends ConsumerState<_AiSettingsPage> { filled: true, children: [ BusyMarkActionRow( - title: context.l10n.aiProvider, + title: context.l10n.aiDefaultProvider, leading: const Icon(BusyMarkGlyphs.ai), trailing: SizedBox( width: BusyMarkSizes.controlRowWidth, child: BusyMarkPopupSelector( value: settings.aiProviderPreference, label: _providerLabel(settings.aiProviderPreference), - tooltip: context.l10n.aiProvider, + tooltip: context.l10n.aiDefaultProvider, options: [ BusyMarkPopupSelectorOption( value: AiProviderPreference.disabled, @@ -1050,7 +1053,29 @@ class _AiSettingsPageState extends ConsumerState<_AiSettingsPage> { ), ], onSelected: (preference) => - unawaited(_selectProvider(preference)), + unawaited(_selectDefaultProvider(preference)), + ), + ), + ), + BusyMarkActionRow( + title: context.l10n.aiConfigureProvider, + leading: const Icon(BusyMarkGlyphs.settings), + trailing: SizedBox( + width: BusyMarkSizes.controlRowWidth, + child: BusyMarkPopupSelector( + value: providerKind, + label: _providerKindLabel(providerKind), + tooltip: context.l10n.aiConfigureProvider, + options: [ + for (final kind in AiProviderKind.values) + BusyMarkPopupSelectorOption( + value: kind, + label: _providerKindLabel(kind), + ), + ], + onSelected: !_testing + ? (kind) => unawaited(_selectConfigurationProvider(kind)) + : (_) {}, ), ), ), @@ -1065,7 +1090,7 @@ class _AiSettingsPageState extends ConsumerState<_AiSettingsPage> { ), if (cloud) ...[ BusyMarkGroupedTextEntry( - key: ValueKey('ai-api-key-${providerKind!.id}'), + key: ValueKey('ai-api-key-${providerKind.id}'), label: context.l10n.aiApiKey, hintText: _credentialConfigured ? context.l10n.aiApiKeyStoredHint @@ -1152,9 +1177,8 @@ class _AiSettingsPageState extends ConsumerState<_AiSettingsPage> { for (final model in modelNames) BusyMarkPopupSelectorOption(value: model, label: model), ], - onSelected: enabled - ? (model) => _saveSelectedModel(providerKind, model) - : (_) {}, + onSelected: (model) => + _saveSelectedModel(providerKind, model), ), ), ), @@ -1168,7 +1192,7 @@ class _AiSettingsPageState extends ConsumerState<_AiSettingsPage> { child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(BusyMarkGlyphs.refresh), - onTap: enabled && !_testing && (!cloud || _credentialConfigured) + onTap: !_testing && (!cloud || _credentialConfigured) ? _testConnection : null, ), @@ -1204,12 +1228,13 @@ class _AiSettingsPageState extends ConsumerState<_AiSettingsPage> { ); try { var settings = ref.read(appSettingsControllerProvider); - final providerKind = settings.aiProviderKind; - if (providerKind == null) { - throw AiException( - AiFailureCode.invalidConfiguration, - l10n.aiEnableProvider, - ); + final providerKind = _configurationProvider; + if (providerKind.isCloud && !settings.hasCloudConsent(providerKind)) { + final confirmed = await _confirmCloudConsent(providerKind); + if (!confirmed) { + return; + } + settings = ref.read(appSettingsControllerProvider); } if (providerKind == AiProviderKind.ollamaLocal) { final endpoint = AiPolicy.validateLocalOllamaEndpoint( @@ -1263,7 +1288,7 @@ class _AiSettingsPageState extends ConsumerState<_AiSettingsPage> { return; } setState(() { - _models = health!.models; + _modelsByProvider[providerKind] = health!.models; final verified = l10n.aiGenerationVerified( health.model.displayName ?? health.model.name, health.models.length, @@ -1296,7 +1321,7 @@ class _AiSettingsPageState extends ConsumerState<_AiSettingsPage> { } } - Future _selectProvider(AiProviderPreference preference) async { + Future _selectDefaultProvider(AiProviderPreference preference) async { final kind = switch (preference) { AiProviderPreference.disabled => null, AiProviderPreference.ollamaLocal => AiProviderKind.ollamaLocal, @@ -1305,30 +1330,9 @@ class _AiSettingsPageState extends ConsumerState<_AiSettingsPage> { }; final settings = ref.read(appSettingsControllerProvider); if (kind?.isCloud == true && !settings.hasCloudConsent(kind!)) { - final confirmed = await showBusyMarkModalDialog( - context, - builder: (dialogContext) => BusyMarkDialogShell( - title: dialogContext.l10n.aiCloudConsentTitle(kind.displayName), - actions: [ - BusyMarkDialogButton( - label: dialogContext.l10n.cancel, - onPressed: () => Navigator.pop(dialogContext, false), - ), - BusyMarkDialogButton( - label: dialogContext.l10n.aiCloudConsentEnable(kind.displayName), - suggested: true, - onPressed: () => Navigator.pop(dialogContext, true), - ), - ], - children: [Text(dialogContext.l10n.aiCloudConsentMessage)], - ), - ); - if (confirmed != true || !mounted) { + if (!await _confirmCloudConsent(kind) || !mounted) { return; } - await ref - .read(appSettingsControllerProvider.notifier) - .grantAiCloudProviderConsent(kind.id); } await ref .read(appSettingsControllerProvider.notifier) @@ -1336,31 +1340,35 @@ class _AiSettingsPageState extends ConsumerState<_AiSettingsPage> { if (!mounted) { return; } + } + + Future _selectConfigurationProvider(AiProviderKind provider) async { + if (provider == _configurationProvider) { + return; + } _apiKeyController.clear(); setState(() { - _models = const []; + _configurationProvider = provider; _status = null; _credentialConfigured = false; }); - await _loadCredentialState(); + await _loadCredentialState(provider); } - Future _loadCredentialState() async { - final kind = ref.read(appSettingsControllerProvider).aiProviderKind; - if (kind?.isCloud != true) { - if (mounted) { + Future _loadCredentialState(AiProviderKind kind) async { + if (!kind.isCloud) { + if (mounted && _configurationProvider == kind) { setState(() => _credentialConfigured = false); } return; } try { - final stored = await ref.read(aiSecretStoreProvider).read(kind!); - if (mounted && - ref.read(appSettingsControllerProvider).aiProviderKind == kind) { + final stored = await ref.read(aiSecretStoreProvider).read(kind); + if (mounted && _configurationProvider == kind) { setState(() => _credentialConfigured = stored != null); } } on AiException catch (error) { - if (mounted) { + if (mounted && _configurationProvider == kind) { setState(() { _status = error.message; _statusKind = BusyMarkStatusKind.error; @@ -1374,7 +1382,7 @@ class _AiSettingsPageState extends ConsumerState<_AiSettingsPage> { await ref .read(aiSecretStoreProvider) .write(provider, _apiKeyController.text); - if (mounted) { + if (mounted && _configurationProvider == provider) { _apiKeyController.clear(); setState(() { _credentialConfigured = true; @@ -1383,7 +1391,7 @@ class _AiSettingsPageState extends ConsumerState<_AiSettingsPage> { }); } } on AiException catch (error) { - if (mounted) { + if (mounted && _configurationProvider == provider) { setState(() { _status = error.message; _statusKind = BusyMarkStatusKind.error; @@ -1395,7 +1403,7 @@ class _AiSettingsPageState extends ConsumerState<_AiSettingsPage> { Future _removeApiKey(AiProviderKind provider) async { try { await ref.read(aiSecretStoreProvider).delete(provider); - if (mounted) { + if (mounted && _configurationProvider == provider) { _apiKeyController.clear(); setState(() { _credentialConfigured = false; @@ -1404,7 +1412,7 @@ class _AiSettingsPageState extends ConsumerState<_AiSettingsPage> { }); } } on AiException catch (error) { - if (mounted) { + if (mounted && _configurationProvider == provider) { setState(() { _status = error.message; _statusKind = BusyMarkStatusKind.error; @@ -1430,6 +1438,42 @@ class _AiSettingsPageState extends ConsumerState<_AiSettingsPage> { AiProviderPreference.gemini => 'Google Gemini', }; + String _providerKindLabel(AiProviderKind provider) => switch (provider) { + AiProviderKind.ollamaLocal => context.l10n.aiLocalOllama, + AiProviderKind.openAi => 'OpenAI', + AiProviderKind.gemini => 'Google Gemini', + }; + + Future _confirmCloudConsent(AiProviderKind provider) async { + final confirmed = await showBusyMarkModalDialog( + context, + builder: (dialogContext) => BusyMarkDialogShell( + title: dialogContext.l10n.aiCloudConsentTitle(provider.displayName), + actions: [ + BusyMarkDialogButton( + label: dialogContext.l10n.cancel, + onPressed: () => Navigator.pop(dialogContext, false), + ), + BusyMarkDialogButton( + label: dialogContext.l10n.aiCloudConsentEnable( + provider.displayName, + ), + suggested: true, + onPressed: () => Navigator.pop(dialogContext, true), + ), + ], + children: [Text(dialogContext.l10n.aiCloudConsentMessage)], + ), + ); + if (confirmed != true || !mounted) { + return false; + } + await ref + .read(appSettingsControllerProvider.notifier) + .grantAiCloudProviderConsent(provider.id); + return mounted; + } + Future _saveEndpoint(String value) async { try { final endpoint = AiPolicy.validateLocalOllamaEndpoint(value); diff --git a/test/src/ai_edit_ui_test.dart b/test/src/ai_edit_ui_test.dart index d30aebcc..bc6015a6 100644 --- a/test/src/ai_edit_ui_test.dart +++ b/test/src/ai_edit_ui_test.dart @@ -113,6 +113,21 @@ void main() { await tester.pumpAndSettle(); expect(find.byType(BusyMarkModalEditorScaffold), findsOneWidget); + final providerSelector = tester.widget>( + find.byKey(const ValueKey('ai-edit-provider')), + ); + expect(providerSelector.values, AiProviderKind.values); + expect(providerSelector.selected, AiProviderKind.ollamaLocal); + providerSelector.onSelected(AiProviderKind.gemini); + await tester.pump(); + expect( + tester + .widget>( + find.byKey(const ValueKey('ai-edit-provider')), + ) + .selected, + AiProviderKind.gemini, + ); expect(find.byType(BusyMarkComboRow), findsOneWidget); expect(find.byType(BusyMarkComboRow), findsOneWidget); expect(find.byType(DropdownButtonFormField), findsNothing); @@ -140,6 +155,138 @@ void main() { expect(contextSelector.dy, lessThan(sharedContent.dy)); }); + testWidgets('AI proposal uses an explicitly selected provider', ( + tester, + ) async { + final settings = AppSettings.defaults().copyWith( + aiProviderPreference: AiProviderPreference.ollamaLocal, + aiOllamaModel: 'local-model', + aiGeminiModel: 'gemini-model', + aiModelRoutingPreference: AiModelRoutingPreference.fixed, + aiCloudProviderConsentIds: [AiProviderKind.gemini.id], + ); + final local = _ImmediateAiProvider( + kind: AiProviderKind.ollamaLocal, + model: 'local-model', + ); + final gemini = _ImmediateAiProvider( + kind: AiProviderKind.gemini, + model: 'gemini-model', + ); + final container = ProviderContainer( + overrides: [ + localSettingsStoreProvider.overrideWithValue( + _MemorySettingsStore(settings.toJson()), + ), + aiProviderRegistryProvider.overrideWithValue( + AiProviderRegistry([local, gemini]), + ), + ], + ); + addTearDown(container.dispose); + container.read(appSettingsControllerProvider); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Consumer( + builder: (context, ref, child) => ElevatedButton( + onPressed: () => unawaited( + showBusyMarkAiProposal( + context, + ref, + const AiEditInvocation( + feature: AiFeature.draftCommitMessage, + scope: AiScope.gitDiff, + input: 'diff --git a/guide.md b/guide.md', + replacementOriginal: '', + sourceRevision: 0, + targetId: 'git-commit:/repo', + documentPath: null, + contentFormat: AiContentFormat.plainText, + enforceDocumentRevision: false, + ), + providerKind: AiProviderKind.gemini, + ), + ), + child: const Text('Generate'), + ), + ), + ), + ), + ), + ); + await _pumpSettings(tester, container); + + await tester.tap(find.text('Generate')); + await tester.pumpAndSettle(); + + expect(local.requests, isEmpty); + expect(gemini.requests, hasLength(1)); + expect(gemini.requests.single.provider, AiProviderKind.gemini); + expect(find.textContaining('Google Gemini'), findsWidgets); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + }); + + testWidgets('provider chooser exposes every supported provider kind', ( + tester, + ) async { + final settings = AppSettings.defaults().copyWith( + aiProviderPreference: AiProviderPreference.ollamaLocal, + ); + final container = ProviderContainer( + overrides: [ + localSettingsStoreProvider.overrideWithValue( + _MemorySettingsStore(settings.toJson()), + ), + ], + ); + addTearDown(container.dispose); + container.read(appSettingsControllerProvider); + AiProviderKind? selected; + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Consumer( + builder: (context, ref, child) => ElevatedButton( + onPressed: () async { + selected = await chooseBusyMarkAiProvider(context, ref); + }, + child: const Text('Choose provider'), + ), + ), + ), + ), + ), + ); + await _pumpSettings(tester, container); + + await tester.tap(find.text('Choose provider')); + await tester.pumpAndSettle(); + final selector = tester.widget>( + find.byKey(const ValueKey('ai-provider-choice')), + ); + expect(selector.values, AiProviderKind.values); + expect(selector.selected, AiProviderKind.ollamaLocal); + selector.onSelected(AiProviderKind.openAi); + await tester.pump(); + await tester.tap(find.text('Generate proposal')); + await tester.pumpAndSettle(); + + expect(selected, AiProviderKind.openAi); + }); + testWidgets('fixed AI target cannot widen a sidebar selection', ( tester, ) async { @@ -287,13 +434,36 @@ void main() { }); } +Future _pumpSettings( + WidgetTester tester, + ProviderContainer container, +) async { + for (var index = 0; index < 20; index += 1) { + await tester.pump(); + if (container.read(appSettingsControllerProvider).aiProviderPreference != + AiProviderPreference.disabled) { + return; + } + } + fail('App settings did not finish loading.'); +} + class _ImmediateAiProvider implements AiProvider { + _ImmediateAiProvider({ + this.kind = AiProviderKind.ollamaLocal, + this.model = 'test-model', + }); + + final AiProviderKind kind; + final String model; + final List requests = []; + @override - String get id => AiProviderKind.ollamaLocal.id; + String get id => kind.id; @override - AiProviderCapabilities get capabilities => const AiProviderCapabilities( - kind: AiProviderKind.ollamaLocal, + AiProviderCapabilities get capabilities => AiProviderCapabilities( + kind: kind, streaming: true, modelDiscovery: false, maximumConcurrentRequests: 1, @@ -305,7 +475,8 @@ class _ImmediateAiProvider implements AiProvider { AiRequest request, { required AiCancellationToken cancellationToken, }) async* { - yield const AiStarted(providerId: 'ollama-local', model: 'test-model'); + requests.add(request); + yield AiStarted(providerId: kind.id, model: model); yield const AiTextDelta('Improve documentation'); yield const AiCompleted(); } diff --git a/test/src/app_settings_test.dart b/test/src/app_settings_test.dart index 45497582..4343c4b0 100644 --- a/test/src/app_settings_test.dart +++ b/test/src/app_settings_test.dart @@ -46,8 +46,10 @@ void main() { defaults .copyWith( aiProviderPreference: AiProviderPreference.openAi, + aiOllamaEndpoint: 'http://localhost:11434', aiOllamaModel: 'local-model', aiOpenAiModel: 'gpt-5.6-sol', + aiGeminiModel: 'gemini-custom', aiModelRoutingPreference: AiModelRoutingPreference.fixed, aiCloudProviderConsentIds: const ['openai'], ) @@ -55,8 +57,10 @@ void main() { ); expect(reloaded.aiProviderPreference, AiProviderPreference.openAi); + expect(reloaded.aiOllamaEndpoint, 'http://localhost:11434'); expect(reloaded.aiOllamaModel, 'local-model'); expect(reloaded.aiOpenAiModel, 'gpt-5.6-sol'); + expect(reloaded.aiGeminiModel, 'gemini-custom'); expect(reloaded.aiModelRoutingPreference, AiModelRoutingPreference.fixed); expect(reloaded.aiCloudProviderConsentIds, ['openai']); final serialized = jsonEncode(reloaded.toJson()).toLowerCase(); From 28451ec1ce27eb67c502e303c3b52ba37e5c48f9 Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 21 Aug 2026 17:36:53 -0700 Subject: [PATCH 09/38] Harden workspace persistence --- lib/l10n/app_ar.arb | 1 + lib/l10n/app_de.arb | 1 + lib/l10n/app_en.arb | 2 + lib/l10n/app_es.arb | 1 + lib/l10n/app_et.arb | 1 + lib/l10n/app_fa.arb | 1 + lib/l10n/app_fr.arb | 1 + lib/l10n/app_hi.arb | 1 + lib/l10n/app_it.arb | 1 + lib/l10n/app_nb.arb | 1 + lib/l10n/app_pl.arb | 1 + lib/l10n/app_pt.arb | 1 + lib/l10n/app_ru.arb | 1 + lib/l10n/app_uk.arb | 1 + lib/l10n/generated/app_localizations.dart | 6 + lib/l10n/generated/app_localizations_ar.dart | 4 + lib/l10n/generated/app_localizations_de.dart | 4 + lib/l10n/generated/app_localizations_en.dart | 4 + lib/l10n/generated/app_localizations_es.dart | 4 + lib/l10n/generated/app_localizations_et.dart | 4 + lib/l10n/generated/app_localizations_fa.dart | 4 + lib/l10n/generated/app_localizations_fr.dart | 4 + lib/l10n/generated/app_localizations_hi.dart | 4 + lib/l10n/generated/app_localizations_it.dart | 4 + lib/l10n/generated/app_localizations_nb.dart | 4 + lib/l10n/generated/app_localizations_pl.dart | 4 + lib/l10n/generated/app_localizations_pt.dart | 4 + lib/l10n/generated/app_localizations_ru.dart | 4 + lib/l10n/generated/app_localizations_uk.dart | 4 + lib/src/search/search_replace_service.dart | 21 ++- .../presentation/workspace_screen.dart | 3 + lib/src/workspace/session_persistence.dart | 20 --- lib/src/workspace/workspace_controller.dart | 25 ++-- lib/src/workspace/workspace_service.dart | 126 ++++++++++++++---- test/src/document_persistence_test.dart | 30 ++--- test/src/search_replace_service_test.dart | 68 ++++++++++ test/src/workspace_controller_test.dart | 59 +++++--- 37 files changed, 341 insertions(+), 88 deletions(-) diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index b8779b35..51336894 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -2704,6 +2704,7 @@ "keepMine": "الاحتفاظ بنسختي", "saveAs": "حفظ باسم", "unsavedChangesMultipleMessage": "يحتوي {count} من المستندات على تغييرات غير محفوظة. هل تريد حفظها قبل المتابعة؟", + "workspaceReplaceIssuePartialConflict": "توقف التراجع لأن الملف تغير بالتزامن. قد تبقى بعض الاستبدالات؛ حُفظ المحتوى المُزاح في المسار أدناه.", "workspaceReplaceIssueApplyFailed": "لم تُطبّق أي استبدالات لأن المجموعة التي تمت مراجعتها تعذر حفظها بأمان.", "workspaceRecoveryRestored": "تمت استعادة {count} من المستندات غير المحفوظة. راجع كل مستند تمت استعادته قبل المتابعة.", "workspaceRecoveryDamaged": "تعذرت استعادة {count} من سجلات الاستعادة التالفة. تظل المستندات الصالحة التي تمت استعادتها متاحة.", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index e6c12196..291fac22 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -2725,6 +2725,7 @@ "keepMine": "Meine Version behalten", "saveAs": "Speichern unter", "unsavedChangesMultipleMessage": "{count} Dokumente enthalten ungespeicherte Änderungen. Vor dem Fortfahren speichern?", + "workspaceReplaceIssuePartialConflict": "Das Zurücksetzen wurde abgebrochen, da die Datei gleichzeitig geändert wurde. Einige Ersetzungen können bestehen bleiben; verdrängte Inhalte wurden unter dem folgenden Pfad gesichert.", "workspaceReplaceIssueApplyFailed": "Es wurden keine Ersetzungen vorgenommen, da die geprüfte Auswahl nicht sicher gespeichert werden konnte.", "workspaceRecoveryRestored": "{count} ungespeicherte Dokumente wurden wiederhergestellt. Prüfen Sie jedes wiederhergestellte Dokument, bevor Sie fortfahren.", "workspaceRecoveryDamaged": "{count} beschädigte Wiederherstellungsdatensätze konnten nicht wiederhergestellt werden. Gültige wiederhergestellte Dokumente bleiben verfügbar.", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 893c46ed..322f1fe6 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -932,6 +932,8 @@ "@workspaceReplaceIssueBufferChanged": {"description": "Workspace replacement issue for stale in-memory content."}, "workspaceReplaceIssueNormalizationRequired": "Choose LF or CRLF normalization before replacing.", "@workspaceReplaceIssueNormalizationRequired": {"description": "Workspace replacement issue for mixed line endings without a selected format."}, + "workspaceReplaceIssuePartialConflict": "Rollback stopped because the file changed concurrently. Some replacements may remain; displaced content was preserved at the path below.", + "@workspaceReplaceIssuePartialConflict": {"description": "Workspace replacement issue when a concurrent edit prevents safe transactional rollback."}, "workspaceReplaceIssueApplyFailed": "The reviewed replacement could not be committed; no files were changed.", "@workspaceReplaceIssueApplyFailed": {"description": "Workspace replacement issue when the transactional file commit fails."}, "externalChangesTitle": "External changes — {fileName}", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 5111e293..3fcad913 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -2725,6 +2725,7 @@ "keepMine": "Conservar mi versión", "saveAs": "Guardar como", "unsavedChangesMultipleMessage": "Hay {count} documentos con cambios sin guardar. ¿Desea guardarlos antes de continuar?", + "workspaceReplaceIssuePartialConflict": "La reversión se detuvo porque el archivo cambió al mismo tiempo. Es posible que queden algunos reemplazos; el contenido desplazado se conservó en la ruta indicada abajo.", "workspaceReplaceIssueApplyFailed": "No se aplicó ningún reemplazo porque el conjunto revisado no pudo guardarse de forma segura.", "workspaceRecoveryRestored": "Se recuperaron {count} documentos sin guardar. Revise cada documento recuperado antes de continuar.", "workspaceRecoveryDamaged": "No se pudieron restaurar {count} registros de recuperación dañados. Los documentos recuperados válidos siguen disponibles.", diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index 448a6ef4..6ab616ee 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -1913,6 +1913,7 @@ "keepMine": "Säilita minu versioon", "saveAs": "Salvesta nimega", "unsavedChangesMultipleMessage": "{count} dokumendis on salvestamata muudatusi. Kas salvestada need enne jätkamist?", + "workspaceReplaceIssuePartialConflict": "Tagasivõtmine peatati, sest faili muudeti samal ajal. Mõned asendused võivad alles jääda; väljatõrjutud sisu säilitati alloleval teel.", "workspaceReplaceIssueApplyFailed": "Asendusi ei rakendatud, sest läbivaadatud kogumit ei saanud turvaliselt salvestada.", "workspaceRecoveryRestored": "Taastati {count} salvestamata dokumenti. Vaadake iga taastatud dokument enne jätkamist üle.", "workspaceRecoveryDamaged": "{count} rikutud taastekirjet ei saanud taastada. Kehtivad taastatud dokumendid on endiselt saadaval.", diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index ff6e97df..69b8984d 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -2723,6 +2723,7 @@ "keepMine": "نگه‌داشتن نسخهٔ من", "saveAs": "ذخیره با نام", "unsavedChangesMultipleMessage": "تعداد {count} سند تغییرات ذخیره‌نشده دارند. پیش از ادامه ذخیره شوند؟", + "workspaceReplaceIssuePartialConflict": "بازگردانی متوقف شد، زیرا فایل هم‌زمان تغییر کرد. ممکن است برخی جایگزینی‌ها باقی مانده باشند؛ محتوای جابه‌جا‌شده در مسیر زیر حفظ شد.", "workspaceReplaceIssueApplyFailed": "هیچ جایگزینی اعمال نشد، زیرا مجموعهٔ بازبینی‌شده را نمی‌شد با ایمنی ذخیره کرد.", "workspaceRecoveryRestored": "تعداد {count} سند ذخیره‌نشده بازیابی شد. پیش از ادامه هر سند بازیابی‌شده را بررسی کنید.", "workspaceRecoveryDamaged": "تعداد {count} رکورد بازیابی آسیب‌دیده قابل بازیابی نبود. سندهای معتبر بازیابی‌شده همچنان در دسترس‌اند.", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 33d77b98..3dce80cf 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -2725,6 +2725,7 @@ "keepMine": "Conserver ma version", "saveAs": "Enregistrer sous", "unsavedChangesMultipleMessage": "{count} documents contiennent des modifications non enregistrées. Les enregistrer avant de continuer ?", + "workspaceReplaceIssuePartialConflict": "L’annulation a été interrompue car le fichier a été modifié simultanément. Certains remplacements peuvent subsister ; le contenu déplacé a été conservé à l’emplacement ci-dessous.", "workspaceReplaceIssueApplyFailed": "Aucun remplacement n’a été appliqué, car l’ensemble vérifié n’a pas pu être enregistré en toute sécurité.", "workspaceRecoveryRestored": "{count} documents non enregistrés ont été récupérés. Vérifiez chaque document récupéré avant de continuer.", "workspaceRecoveryDamaged": "{count} enregistrements de récupération endommagés n’ont pas pu être restaurés. Les documents valides récupérés restent disponibles.", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 5424d47b..872441b5 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -2704,6 +2704,7 @@ "keepMine": "मेरा संस्करण रखें", "saveAs": "इस रूप में सहेजें", "unsavedChangesMultipleMessage": "{count} दस्तावेज़ों में सहेजे नहीं गए बदलाव हैं। जारी रखने से पहले इन्हें सहेजें?", + "workspaceReplaceIssuePartialConflict": "रोलबैक रोक दिया गया क्योंकि फ़ाइल उसी समय बदल गई थी। कुछ प्रतिस्थापन बने रह सकते हैं; हटाई गई सामग्री नीचे दिए गए पथ पर सुरक्षित रखी गई है।", "workspaceReplaceIssueApplyFailed": "कोई प्रतिस्थापन लागू नहीं किया गया क्योंकि समीक्षा किए गए समूह को सुरक्षित रूप से सहेजा नहीं जा सका।", "workspaceRecoveryRestored": "{count} सहेजे नहीं गए दस्तावेज़ पुनर्प्राप्त किए गए। जारी रखने से पहले प्रत्येक पुनर्प्राप्त दस्तावेज़ की समीक्षा करें।", "workspaceRecoveryDamaged": "{count} क्षतिग्रस्त पुनर्प्राप्ति रिकॉर्ड बहाल नहीं किए जा सके। मान्य पुनर्प्राप्त दस्तावेज़ उपलब्ध हैं।", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index a5427a36..44879c94 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -2702,6 +2702,7 @@ "keepMine": "Mantieni la mia versione", "saveAs": "Salva con nome", "unsavedChangesMultipleMessage": "{count} documenti contengono modifiche non salvate. Salvarli prima di continuare?", + "workspaceReplaceIssuePartialConflict": "Il rollback è stato interrotto perché il file è stato modificato contemporaneamente. Alcune sostituzioni potrebbero rimanere; il contenuto spostato è stato conservato nel percorso seguente.", "workspaceReplaceIssueApplyFailed": "Non è stata applicata alcuna sostituzione perché non è stato possibile salvare in sicurezza l’insieme verificato.", "workspaceRecoveryRestored": "Sono stati recuperati {count} documenti non salvati. Controllare ogni documento recuperato prima di continuare.", "workspaceRecoveryDamaged": "Non è stato possibile ripristinare {count} record di recupero danneggiati. I documenti recuperati validi restano disponibili.", diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb index bb89f461..a4dbef1a 100644 --- a/lib/l10n/app_nb.arb +++ b/lib/l10n/app_nb.arb @@ -2702,6 +2702,7 @@ "keepMine": "Behold min versjon", "saveAs": "Lagre som", "unsavedChangesMultipleMessage": "{count} dokumenter har ulagrede endringer. Lagre dem før du fortsetter?", + "workspaceReplaceIssuePartialConflict": "Tilbakerullingen ble stoppet fordi filen ble endret samtidig. Noen erstatninger kan fortsatt være utført; forskjøvet innhold ble bevart på banen nedenfor.", "workspaceReplaceIssueApplyFailed": "Ingen erstatninger ble utført fordi det gjennomgåtte settet ikke kunne lagres på en trygg måte.", "workspaceRecoveryRestored": "{count} ulagrede dokumenter ble gjenopprettet. Se gjennom hvert gjenopprettet dokument før du fortsetter.", "workspaceRecoveryDamaged": "{count} skadede gjenopprettingsoppføringer kunne ikke gjenopprettes. Gyldige gjenopprettede dokumenter er fortsatt tilgjengelige.", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index fe3b49fb..5e216889 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -2720,6 +2720,7 @@ "keepMine": "Zachowaj moją wersję", "saveAs": "Zapisz jako", "unsavedChangesMultipleMessage": "{count} dokumenty zawierają niezapisane zmiany. Zapisać je przed kontynuowaniem?", + "workspaceReplaceIssuePartialConflict": "Wycofywanie zatrzymano, ponieważ plik został jednocześnie zmieniony. Niektóre zamiany mogą pozostać; zastąpioną zawartość zachowano w poniższej ścieżce.", "workspaceReplaceIssueApplyFailed": "Nie zastosowano żadnych zamian, ponieważ sprawdzonego zestawu nie można było bezpiecznie zapisać.", "workspaceRecoveryRestored": "Odzyskano {count} niezapisanych dokumentów. Przejrzyj każdy odzyskany dokument przed kontynuowaniem.", "workspaceRecoveryDamaged": "Nie udało się przywrócić {count} uszkodzonych rekordów odzyskiwania. Prawidłowe odzyskane dokumenty są nadal dostępne.", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index b69df221..3cc73b95 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -2702,6 +2702,7 @@ "keepMine": "Manter minha versão", "saveAs": "Salvar como", "unsavedChangesMultipleMessage": "Há {count} documentos com alterações não salvas. Deseja salvá-los antes de continuar?", + "workspaceReplaceIssuePartialConflict": "A reversão foi interrompida porque o ficheiro foi alterado em simultâneo. Algumas substituições podem permanecer; o conteúdo deslocado foi preservado no caminho abaixo.", "workspaceReplaceIssueApplyFailed": "Nenhuma substituição foi aplicada porque o conjunto revisado não pôde ser salvo com segurança.", "workspaceRecoveryRestored": "Foram recuperados {count} documentos não salvos. Revise cada documento recuperado antes de continuar.", "workspaceRecoveryDamaged": "Não foi possível restaurar {count} registros de recuperação danificados. Os documentos recuperados válidos continuam disponíveis.", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 6b01ccce..83b62249 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -2720,6 +2720,7 @@ "keepMine": "Оставить мою версию", "saveAs": "Сохранить как", "unsavedChangesMultipleMessage": "В документах ({count}) есть несохранённые изменения. Сохранить их перед продолжением?", + "workspaceReplaceIssuePartialConflict": "Откат остановлен, поскольку файл был одновременно изменён. Некоторые замены могли сохраниться; вытесненное содержимое сохранено по указанному ниже пути.", "workspaceReplaceIssueApplyFailed": "Замены не применены, поскольку проверенный набор не удалось безопасно сохранить.", "workspaceRecoveryRestored": "Восстановлено несохранённых документов: {count}. Проверьте каждый восстановленный документ перед продолжением.", "workspaceRecoveryDamaged": "Не удалось восстановить повреждённые записи ({count}). Корректные восстановленные документы остаются доступными.", diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index 33b68810..b8d703be 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -2720,6 +2720,7 @@ "keepMine": "Залишити мою версію", "saveAs": "Зберегти як", "unsavedChangesMultipleMessage": "У документах ({count}) є незбережені зміни. Зберегти їх перед продовженням?", + "workspaceReplaceIssuePartialConflict": "Відкат зупинено, оскільки файл було одночасно змінено. Деякі заміни могли залишитися; витіснений вміст збережено за шляхом нижче.", "workspaceReplaceIssueApplyFailed": "Заміни не застосовано, оскільки перевірений набір не вдалося безпечно зберегти.", "workspaceRecoveryRestored": "Відновлено незбережених документів: {count}. Перегляньте кожен відновлений документ перед продовженням.", "workspaceRecoveryDamaged": "Не вдалося відновити пошкоджені записи ({count}). Коректні відновлені документи залишаються доступними.", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index d7c4f887..36a7287a 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -2637,6 +2637,12 @@ abstract class AppLocalizations { /// **'Choose LF or CRLF normalization before replacing.'** String get workspaceReplaceIssueNormalizationRequired; + /// Workspace replacement issue when a concurrent edit prevents safe transactional rollback. + /// + /// In en, this message translates to: + /// **'Rollback stopped because the file changed concurrently. Some replacements may remain; displaced content was preserved at the path below.'** + String get workspaceReplaceIssuePartialConflict; + /// Workspace replacement issue when the transactional file commit fails. /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index 1032c3ed..e816e153 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -1432,6 +1432,10 @@ class AppLocalizationsAr extends AppLocalizations { String get workspaceReplaceIssueNormalizationRequired => 'اختر توحيد LF أو CRLF قبل الاستبدال.'; + @override + String get workspaceReplaceIssuePartialConflict => + 'توقف التراجع لأن الملف تغير بالتزامن. قد تبقى بعض الاستبدالات؛ حُفظ المحتوى المُزاح في المسار أدناه.'; + @override String get workspaceReplaceIssueApplyFailed => 'لم تُطبّق أي استبدالات لأن المجموعة التي تمت مراجعتها تعذر حفظها بأمان.'; diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 3b0dc373..31d2c734 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -1446,6 +1446,10 @@ class AppLocalizationsDe extends AppLocalizations { String get workspaceReplaceIssueNormalizationRequired => 'Wählen Sie vor dem Ersetzen die Normalisierung auf LF oder CRLF.'; + @override + String get workspaceReplaceIssuePartialConflict => + 'Das Zurücksetzen wurde abgebrochen, da die Datei gleichzeitig geändert wurde. Einige Ersetzungen können bestehen bleiben; verdrängte Inhalte wurden unter dem folgenden Pfad gesichert.'; + @override String get workspaceReplaceIssueApplyFailed => 'Es wurden keine Ersetzungen vorgenommen, da die geprüfte Auswahl nicht sicher gespeichert werden konnte.'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 4631bbef..b106d6ce 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -1429,6 +1429,10 @@ class AppLocalizationsEn extends AppLocalizations { String get workspaceReplaceIssueNormalizationRequired => 'Choose LF or CRLF normalization before replacing.'; + @override + String get workspaceReplaceIssuePartialConflict => + 'Rollback stopped because the file changed concurrently. Some replacements may remain; displaced content was preserved at the path below.'; + @override String get workspaceReplaceIssueApplyFailed => 'The reviewed replacement could not be committed; no files were changed.'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 0758f3c9..abff3051 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -1443,6 +1443,10 @@ class AppLocalizationsEs extends AppLocalizations { String get workspaceReplaceIssueNormalizationRequired => 'Elige la normalización LF o CRLF antes de reemplazar.'; + @override + String get workspaceReplaceIssuePartialConflict => + 'La reversión se detuvo porque el archivo cambió al mismo tiempo. Es posible que queden algunos reemplazos; el contenido desplazado se conservó en la ruta indicada abajo.'; + @override String get workspaceReplaceIssueApplyFailed => 'No se aplicó ningún reemplazo porque el conjunto revisado no pudo guardarse de forma segura.'; diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index 2870ad12..6909deb4 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -1425,6 +1425,10 @@ class AppLocalizationsEt extends AppLocalizations { String get workspaceReplaceIssueNormalizationRequired => 'Vali enne asendamist LF- või CRLF-normaliseerimine.'; + @override + String get workspaceReplaceIssuePartialConflict => + 'Tagasivõtmine peatati, sest faili muudeti samal ajal. Mõned asendused võivad alles jääda; väljatõrjutud sisu säilitati alloleval teel.'; + @override String get workspaceReplaceIssueApplyFailed => 'Asendusi ei rakendatud, sest läbivaadatud kogumit ei saanud turvaliselt salvestada.'; diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index 5866b6a1..8d335b94 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -1463,6 +1463,10 @@ class AppLocalizationsFa extends AppLocalizations { String get workspaceReplaceIssueNormalizationRequired => 'پیش از جایگزینی، یکسان‌سازی LF یا CRLF را انتخاب کنید.'; + @override + String get workspaceReplaceIssuePartialConflict => + 'بازگردانی متوقف شد، زیرا فایل هم‌زمان تغییر کرد. ممکن است برخی جایگزینی‌ها باقی مانده باشند؛ محتوای جابه‌جا‌شده در مسیر زیر حفظ شد.'; + @override String get workspaceReplaceIssueApplyFailed => 'هیچ جایگزینی اعمال نشد، زیرا مجموعهٔ بازبینی‌شده را نمی‌شد با ایمنی ذخیره کرد.'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 5c25aba2..6c68cec2 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -1444,6 +1444,10 @@ class AppLocalizationsFr extends AppLocalizations { String get workspaceReplaceIssueNormalizationRequired => 'Choisissez la normalisation LF ou CRLF avant le remplacement.'; + @override + String get workspaceReplaceIssuePartialConflict => + 'L’annulation a été interrompue car le fichier a été modifié simultanément. Certains remplacements peuvent subsister ; le contenu déplacé a été conservé à l’emplacement ci-dessous.'; + @override String get workspaceReplaceIssueApplyFailed => 'Aucun remplacement n’a été appliqué, car l’ensemble vérifié n’a pas pu être enregistré en toute sécurité.'; diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 9722d7ba..1112aaf5 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -1419,6 +1419,10 @@ class AppLocalizationsHi extends AppLocalizations { String get workspaceReplaceIssueNormalizationRequired => 'बदलने से पहले LF या CRLF सामान्यीकरण चुनें।'; + @override + String get workspaceReplaceIssuePartialConflict => + 'रोलबैक रोक दिया गया क्योंकि फ़ाइल उसी समय बदल गई थी। कुछ प्रतिस्थापन बने रह सकते हैं; हटाई गई सामग्री नीचे दिए गए पथ पर सुरक्षित रखी गई है।'; + @override String get workspaceReplaceIssueApplyFailed => 'कोई प्रतिस्थापन लागू नहीं किया गया क्योंकि समीक्षा किए गए समूह को सुरक्षित रूप से सहेजा नहीं जा सका।'; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index 69d0c89c..8a15e859 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -1439,6 +1439,10 @@ class AppLocalizationsIt extends AppLocalizations { String get workspaceReplaceIssueNormalizationRequired => 'Scegli la normalizzazione LF o CRLF prima di sostituire.'; + @override + String get workspaceReplaceIssuePartialConflict => + 'Il rollback è stato interrotto perché il file è stato modificato contemporaneamente. Alcune sostituzioni potrebbero rimanere; il contenuto spostato è stato conservato nel percorso seguente.'; + @override String get workspaceReplaceIssueApplyFailed => 'Non è stata applicata alcuna sostituzione perché non è stato possibile salvare in sicurezza l’insieme verificato.'; diff --git a/lib/l10n/generated/app_localizations_nb.dart b/lib/l10n/generated/app_localizations_nb.dart index 83212a62..c235044d 100644 --- a/lib/l10n/generated/app_localizations_nb.dart +++ b/lib/l10n/generated/app_localizations_nb.dart @@ -1429,6 +1429,10 @@ class AppLocalizationsNb extends AppLocalizations { String get workspaceReplaceIssueNormalizationRequired => 'Velg LF- eller CRLF-normalisering før du erstatter.'; + @override + String get workspaceReplaceIssuePartialConflict => + 'Tilbakerullingen ble stoppet fordi filen ble endret samtidig. Noen erstatninger kan fortsatt være utført; forskjøvet innhold ble bevart på banen nedenfor.'; + @override String get workspaceReplaceIssueApplyFailed => 'Ingen erstatninger ble utført fordi det gjennomgåtte settet ikke kunne lagres på en trygg måte.'; diff --git a/lib/l10n/generated/app_localizations_pl.dart b/lib/l10n/generated/app_localizations_pl.dart index 6016199a..38dc304d 100644 --- a/lib/l10n/generated/app_localizations_pl.dart +++ b/lib/l10n/generated/app_localizations_pl.dart @@ -1448,6 +1448,10 @@ class AppLocalizationsPl extends AppLocalizations { String get workspaceReplaceIssueNormalizationRequired => 'Przed zamianą wybierz normalizację LF lub CRLF.'; + @override + String get workspaceReplaceIssuePartialConflict => + 'Wycofywanie zatrzymano, ponieważ plik został jednocześnie zmieniony. Niektóre zamiany mogą pozostać; zastąpioną zawartość zachowano w poniższej ścieżce.'; + @override String get workspaceReplaceIssueApplyFailed => 'Nie zastosowano żadnych zamian, ponieważ sprawdzonego zestawu nie można było bezpiecznie zapisać.'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 72e2eb16..dc4bcbcb 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -1438,6 +1438,10 @@ class AppLocalizationsPt extends AppLocalizations { String get workspaceReplaceIssueNormalizationRequired => 'Escolha a normalização LF ou CRLF antes de substituir.'; + @override + String get workspaceReplaceIssuePartialConflict => + 'A reversão foi interrompida porque o ficheiro foi alterado em simultâneo. Algumas substituições podem permanecer; o conteúdo deslocado foi preservado no caminho abaixo.'; + @override String get workspaceReplaceIssueApplyFailed => 'Nenhuma substituição foi aplicada porque o conjunto revisado não pôde ser salvo com segurança.'; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 4bfb3eb4..592687ed 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -1442,6 +1442,10 @@ class AppLocalizationsRu extends AppLocalizations { String get workspaceReplaceIssueNormalizationRequired => 'Перед заменой выберите нормализацию LF или CRLF.'; + @override + String get workspaceReplaceIssuePartialConflict => + 'Откат остановлен, поскольку файл был одновременно изменён. Некоторые замены могли сохраниться; вытесненное содержимое сохранено по указанному ниже пути.'; + @override String get workspaceReplaceIssueApplyFailed => 'Замены не применены, поскольку проверенный набор не удалось безопасно сохранить.'; diff --git a/lib/l10n/generated/app_localizations_uk.dart b/lib/l10n/generated/app_localizations_uk.dart index c7807edf..60465ff8 100644 --- a/lib/l10n/generated/app_localizations_uk.dart +++ b/lib/l10n/generated/app_localizations_uk.dart @@ -1450,6 +1450,10 @@ class AppLocalizationsUk extends AppLocalizations { String get workspaceReplaceIssueNormalizationRequired => 'Перед заміною виберіть нормалізацію LF або CRLF.'; + @override + String get workspaceReplaceIssuePartialConflict => + 'Відкат зупинено, оскільки файл було одночасно змінено. Деякі заміни могли залишитися; витіснений вміст збережено за шляхом нижче.'; + @override String get workspaceReplaceIssueApplyFailed => 'Заміни не застосовано, оскільки перевірений набір не вдалося безпечно зберегти.'; diff --git a/lib/src/search/search_replace_service.dart b/lib/src/search/search_replace_service.dart index 0278b5c4..81dab222 100644 --- a/lib/src/search/search_replace_service.dart +++ b/lib/src/search/search_replace_service.dart @@ -61,14 +61,20 @@ enum WorkspaceReplacementIssueKind { changedSincePreview, bufferRevisionChanged, normalizationRequired, + partialApplicationConflict, applyFailed, } class WorkspaceReplacementIssue { - const WorkspaceReplacementIssue({required this.kind, required this.filePath}); + const WorkspaceReplacementIssue({ + required this.kind, + required this.filePath, + this.preservedPath, + }); final WorkspaceReplacementIssueKind kind; final String filePath; + final String? preservedPath; } class WorkspaceReplacementFilePreview { @@ -430,6 +436,19 @@ class SearchReplacementService { ), ], ); + } on WorkspaceBatchPartialApplicationConflict catch (error) { + return WorkspaceReplacementApplyResult( + appliedFiles: 0, + appliedMatches: 0, + issues: [ + for (final file in error.files) + WorkspaceReplacementIssue( + kind: WorkspaceReplacementIssueKind.partialApplicationConflict, + filePath: file.targetPath, + preservedPath: file.preservedPath, + ), + ], + ); } on Object { return WorkspaceReplacementApplyResult( appliedFiles: 0, diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index 7e8bee93..99ff1f78 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -12440,6 +12440,9 @@ String _workspaceReplacementIssueLabel( context.l10n.workspaceReplaceIssueBufferChanged, WorkspaceReplacementIssueKind.normalizationRequired => context.l10n.workspaceReplaceIssueNormalizationRequired, + WorkspaceReplacementIssueKind.partialApplicationConflict => + '${context.l10n.workspaceReplaceIssuePartialConflict}' + '${issue.preservedPath == null ? '' : '\n${busyMarkLtrIsolateFor(context, issue.preservedPath!)}'}', WorkspaceReplacementIssueKind.applyFailed => context.l10n.workspaceReplaceIssueApplyFailed, }; diff --git a/lib/src/workspace/session_persistence.dart b/lib/src/workspace/session_persistence.dart index 150ef4b5..d0025e83 100644 --- a/lib/src/workspace/session_persistence.dart +++ b/lib/src/workspace/session_persistence.dart @@ -5,8 +5,6 @@ import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'document_buffer.dart'; -import 'text_format_metadata.dart'; -import 'workspace_file_snapshot.dart'; class DocumentSessionEntry { const DocumentSessionEntry({ @@ -14,32 +12,21 @@ class DocumentSessionEntry { required this.filePath, required this.untitledName, required this.editorState, - this.lastKnownText, - this.diskSnapshot, - this.format, }); final String id; final String? filePath; final String? untitledName; final DocumentEditorState editorState; - final String? lastKnownText; - final WorkspaceFileSnapshot? diskSnapshot; - final TextFormatMetadata? format; Map toJson() => { 'id': id, 'filePath': filePath, 'untitledName': untitledName, 'editorState': editorState.toJson(), - 'lastKnownText': lastKnownText, - 'diskSnapshot': diskSnapshot?.toJson(), - 'format': format?.toJson(), }; factory DocumentSessionEntry.fromJson(Map json) { - final diskSnapshot = json['diskSnapshot']; - final format = json['format']; return DocumentSessionEntry( id: json['id']?.toString() ?? '', filePath: json['filePath']?.toString(), @@ -47,13 +34,6 @@ class DocumentSessionEntry { editorState: DocumentEditorState.fromJson( (json['editorState'] as Map?)?.cast() ?? const {}, ), - lastKnownText: json['lastKnownText']?.toString(), - diskSnapshot: diskSnapshot is Map - ? WorkspaceFileSnapshot.fromJson(diskSnapshot.cast()) - : null, - format: format is Map - ? TextFormatMetadata.fromJson(format.cast()) - : null, ); } } diff --git a/lib/src/workspace/workspace_controller.dart b/lib/src/workspace/workspace_controller.dart index dce3d49a..625f6695 100644 --- a/lib/src/workspace/workspace_controller.dart +++ b/lib/src/workspace/workspace_controller.dart @@ -290,8 +290,7 @@ class WorkspaceController extends Notifier { .map((entry) => entry.filePath) .whereType() .firstOrNull; - final seedText = - activeRecovery?.text ?? activeEntry?.lastKnownText ?? ''; + final seedText = activeRecovery?.text ?? ''; final parsed = _service.createUntitledMarkdown(source: seedText); final standalone = p.extension(workspacePath).isNotEmpty; workspace = Workspace( @@ -401,15 +400,14 @@ class WorkspaceController extends Notifier { return null; } if (!await _service.pathExists(path)) { - final text = session.lastKnownText ?? ''; + const text = ''; return DocumentBuffer( id: session.id, filePath: path, text: text, lastSavedText: text, dirty: false, - diskSnapshot: session.diskSnapshot, - format: session.format ?? TextFormatMetadata.utf8Lf, + format: TextFormatMetadata.utf8Lf, editorState: session.editorState, diskState: DocumentDiskState.deleted, ); @@ -521,9 +519,6 @@ class WorkspaceController extends Notifier { filePath: buffer.filePath, untitledName: buffer.untitledName, editorState: buffer.editorState, - lastKnownText: buffer.text, - diskSnapshot: buffer.diskSnapshot, - format: buffer.format, ), ], ), @@ -1804,6 +1799,7 @@ class WorkspaceController extends Notifier { sourceFilePath: sourceFilePath, rebuildPreview: false, liveOutline: document.outline, + preserveFinalNewline: true, ); } @@ -1825,6 +1821,7 @@ class WorkspaceController extends Notifier { required bool rebuildPreview, String? sourceFilePath, List? liveOutline, + bool preserveFinalNewline = false, }) { final workspace = state.workspace; final activeEditorPath = @@ -1836,7 +1833,10 @@ class WorkspaceController extends Notifier { if (activeBuffer == null) { return; } - final nextBuffer = activeBuffer.edited(text); + final effectiveText = preserveFinalNewline + ? _withFinalNewlinePolicy(text, activeBuffer.format.hasFinalNewline) + : text; + final nextBuffer = activeBuffer.edited(effectiveText); if (identical(nextBuffer, activeBuffer)) { return; } @@ -2841,6 +2841,13 @@ List _replaceBuffer( ]); } +String _withFinalNewlinePolicy(String text, bool hasFinalNewline) { + if (hasFinalNewline) { + return text.endsWith('\n') ? text : '$text\n'; + } + return text.replaceFirst(RegExp(r'\n+$'), ''); +} + extension _ControllerFirstOrNull on Iterable { T? get firstOrNull => isEmpty ? null : first; } diff --git a/lib/src/workspace/workspace_service.dart b/lib/src/workspace/workspace_service.dart index 7cfde891..48d8b9dc 100644 --- a/lib/src/workspace/workspace_service.dart +++ b/lib/src/workspace/workspace_service.dart @@ -48,6 +48,26 @@ class WorkspaceBatchWriteConflict implements Exception { final String path; } +class WorkspaceBatchPartialApplicationFile { + const WorkspaceBatchPartialApplicationFile({ + required this.targetPath, + required this.preservedPath, + }); + + final String targetPath; + final String preservedPath; +} + +class WorkspaceBatchPartialApplicationConflict implements Exception { + const WorkspaceBatchPartialApplicationConflict({ + required this.files, + required this.cause, + }); + + final List files; + final Object cause; +} + class WorkspaceService { const WorkspaceService({ this.markdownParser = const MarkdownParser(), @@ -61,9 +81,12 @@ class WorkspaceService { this.writersideTopicRemovalService = const WritersideTopicRemovalService(), this.scanOptions = const WorkspaceScanOptions(), Future Function(String targetPath)? beforeNewFilePublish, + Future Function(String targetPath, int committedCount)? + afterBatchFileCommit, }) : writersideService = writersideService ?? const WritersideModuleService(), _useWorkspaceScanOptionsForWriterside = writersideService == null, - _beforeNewFilePublish = beforeNewFilePublish; + _beforeNewFilePublish = beforeNewFilePublish, + _afterBatchFileCommit = afterBatchFileCommit; final MarkdownParser markdownParser; final MarkdownPreviewBuilder previewBuilder; @@ -77,6 +100,8 @@ class WorkspaceService { final WorkspaceScanOptions scanOptions; final bool _useWorkspaceScanOptionsForWriterside; final Future Function(String targetPath)? _beforeNewFilePublish; + final Future Function(String targetPath, int committedCount)? + _afterBatchFileCommit; Workspace createUntitledMarkdown({String source = ''}) { const fileName = ''; @@ -595,6 +620,7 @@ class WorkspaceService { final paths = {}; final staged = <_StagedBatchTextWrite>[]; final committed = <_StagedBatchTextWrite>[]; + final preservedArtifacts = {}; try { for (final write in writes) { final savePath = await _saveTargetPath(write.path); @@ -658,20 +684,18 @@ class WorkspaceService { displacedBytes, ); if (displacedSnapshot.differsFrom(write.expectedSnapshot)) { - final rollbackError = LinuxAtomicFileApi.instance.exchange( - write.stagedFile.absolute.path, - write.target.absolute.path, - ); - if (rollbackError != null) { - throw FileSystemException( - 'Could not roll back a concurrently changed replacement file', - write.requestPath, - OSError('atomic exchange rollback failed', rollbackError), + final conflict = await _rollbackBatchWrite(write); + if (conflict != null) { + preservedArtifacts.add(conflict.preservedPath); + throw WorkspaceBatchPartialApplicationConflict( + files: [conflict], + cause: WorkspaceBatchWriteConflict(write.requestPath), ); } throw WorkspaceBatchWriteConflict(write.requestPath); } committed.add(write); + await _afterBatchFileCommit?.call(write.target.path, committed.length); } return { for (final write in staged) @@ -680,27 +704,35 @@ class WorkspaceService { write.bytes, ), }; - } on Object { - Object? rollbackError; + } on Object catch (error, stackTrace) { + final conflicts = [ + if (error case WorkspaceBatchPartialApplicationConflict partial) + ...partial.files, + ]; + preservedArtifacts.addAll( + conflicts.map((conflict) => conflict.preservedPath), + ); for (final write in committed.reversed) { - final error = LinuxAtomicFileApi.instance.exchange( - write.stagedFile.absolute.path, - write.target.absolute.path, - ); - if (error != null) { - rollbackError ??= FileSystemException( - 'Could not roll back workspace replacement batch', - write.requestPath, - OSError('atomic exchange rollback failed', error), - ); + final conflict = await _rollbackBatchWrite(write); + if (conflict != null) { + conflicts.add(conflict); + preservedArtifacts.add(conflict.preservedPath); } } - if (rollbackError != null) { - throw rollbackError; + if (conflicts.isNotEmpty) { + throw WorkspaceBatchPartialApplicationConflict( + files: List.unmodifiable(conflicts), + cause: error is WorkspaceBatchPartialApplicationConflict + ? error.cause + : error, + ); } - rethrow; + Error.throwWithStackTrace(error, stackTrace); } finally { for (final write in staged) { + if (preservedArtifacts.contains(write.stagedFile.path)) { + continue; + } await _deleteSaveArtifactBestEffort(write.stagedFile); try { if (await write.directory.exists()) { @@ -713,6 +745,50 @@ class WorkspaceService { } } + Future _rollbackBatchWrite( + _StagedBatchTextWrite write, + ) async { + final conflict = WorkspaceBatchPartialApplicationFile( + targetPath: write.requestPath, + preservedPath: write.stagedFile.path, + ); + if (!await _fileMatchesBytes(write.target, write.bytes)) { + return conflict; + } + final rollbackError = LinuxAtomicFileApi.instance.exchange( + write.stagedFile.absolute.path, + write.target.absolute.path, + ); + if (rollbackError != null) { + return conflict; + } + if (await _fileMatchesBytes(write.stagedFile, write.bytes)) { + return null; + } + + // The target changed after validation but before the exchange. Put that + // concurrent version back when possible, and preserve the displaced data. + LinuxAtomicFileApi.instance.exchange( + write.stagedFile.absolute.path, + write.target.absolute.path, + ); + return conflict; + } + + Future _fileMatchesBytes(File file, List expected) async { + try { + final type = await FileSystemEntity.type(file.path, followLinks: false); + if (type != FileSystemEntityType.file) { + return false; + } + final actual = await file.readAsBytes(); + return actual.length == expected.length && + crypto.sha256.convert(actual) == crypto.sha256.convert(expected); + } on Object { + return false; + } + } + Future createFile( Workspace workspace, String directoryPath, diff --git a/test/src/document_persistence_test.dart b/test/src/document_persistence_test.dart index 2bc024ff..b2522a3a 100644 --- a/test/src/document_persistence_test.dart +++ b/test/src/document_persistence_test.dart @@ -38,7 +38,7 @@ void main() { expect(restored.format.formattedText(restored.text), 'Saved\n'); }); - test('session store round-trips ordered tabs and editor state', () async { + test('session store keeps only tab identity and editor state', () async { final directory = await Directory.systemTemp.createTemp( 'busymark-session-', ); @@ -60,17 +60,6 @@ void main() { scrollOffset: 42, foldedRegionKeys: {'heading:2'}, ), - lastKnownText: '# First\n', - diskSnapshot: WorkspaceFileSnapshot( - modifiedAt: DateTime.utc(2026), - size: 8, - contentHash: 'first', - ), - format: const TextFormatMetadata( - hasUtf8Bom: false, - lineEnding: DocumentLineEnding.crlf, - hasFinalNewline: true, - ), ), const DocumentSessionEntry( id: 'second', @@ -97,9 +86,20 @@ void main() { ); expect(restored?.tabs.first.editorState.scrollOffset, 42); expect(restored?.tabs.first.editorState.foldedRegionKeys, {'heading:2'}); - expect(restored?.tabs.first.lastKnownText, '# First\n'); - expect(restored?.tabs.first.diskSnapshot?.contentHash, 'first'); - expect(restored?.tabs.first.format?.lineEnding, DocumentLineEnding.crlf); + final persisted = + jsonDecode( + await File(p.join(directory.path, 'session.json')).readAsString(), + ) + as Map; + final firstTab = ((persisted['tabs'] as List).first as Map) + .cast(); + expect(firstTab, isNot(contains('lastKnownText'))); + expect(firstTab, isNot(contains('diskSnapshot'))); + expect(firstTab, isNot(contains('format'))); + expect( + await File(p.join(directory.path, 'session.json')).readAsString(), + isNot(contains('# First')), + ); }); test('recovery store distinguishes clean and unclean runs', () async { diff --git a/test/src/search_replace_service_test.dart b/test/src/search_replace_service_test.dart index a2afab6b..e0820179 100644 --- a/test/src/search_replace_service_test.dart +++ b/test/src/search_replace_service_test.dart @@ -275,6 +275,74 @@ void main() { expect(await first.readAsString(), 'cat first'); expect(await second.readAsString(), 'changed after preview'); }); + + test( + 'rollback preserves a concurrent edit and reports displaced content', + () async { + if (!Platform.isLinux) { + return; + } + final directory = await Directory.systemTemp.createTemp( + 'busymark-replace-rollback-conflict-', + ); + addTearDown(() => directory.delete(recursive: true)); + final first = File(p.join(directory.path, 'a.md')); + final second = File(p.join(directory.path, 'b.md')); + await first.writeAsString('cat first'); + await second.writeAsString('cat second'); + var injected = false; + final workspaceService = WorkspaceService( + afterBatchFileCommit: (targetPath, committedCount) async { + if (!injected && committedCount == 1) { + injected = true; + await first.writeAsString('external first'); + await second.delete(); + } + }, + ); + final workspace = Workspace( + id: directory.path, + rootPath: directory.path, + kind: WorkspaceKind.markdownFolder, + openedAt: DateTime(2026), + files: [ + await _documentFile(first, directory.path), + await _documentFile(second, directory.path), + ], + diagnostics: const [], + ); + final state = WorkspaceState(workspace: workspace); + final preview = await replacementService.previewWorkspace( + state: state, + workspaceService: workspaceService, + options: const SourceSearchOptions(query: 'cat'), + replacement: 'dog', + ); + + final result = await replacementService.applyWorkspace( + preview: preview, + selectedMatchIds: { + for (final file in preview.files) + for (final match in file.matches) match.id, + }, + currentState: () => state, + updateBuffer: (_, _) => fail('no buffers should be updated'), + workspaceService: workspaceService, + ); + + expect(result.appliedFiles, 0); + expect(result.issues, hasLength(1)); + expect( + result.issues.single.kind, + WorkspaceReplacementIssueKind.partialApplicationConflict, + ); + expect(result.issues.single.filePath, first.path); + expect(await first.readAsString(), 'external first'); + final preservedPath = result.issues.single.preservedPath; + expect(preservedPath, isNotNull); + expect(await File(preservedPath!).readAsString(), 'cat first'); + }, + ); } Future _documentFile(File file, String root) async { diff --git a/test/src/workspace_controller_test.dart b/test/src/workspace_controller_test.dart index ea026905..7517541a 100644 --- a/test/src/workspace_controller_test.dart +++ b/test/src/workspace_controller_test.dart @@ -3,10 +3,10 @@ import 'dart:io'; import 'package:busymark/src/app/app_settings.dart'; import 'package:busymark/src/core/source_span.dart'; +import 'package:busymark/src/markdown/busymark_document.dart'; import 'package:busymark/src/workspace/document_buffer.dart'; import 'package:busymark/src/workspace/recovery_persistence.dart'; import 'package:busymark/src/workspace/session_persistence.dart'; -import 'package:busymark/src/workspace/text_format_metadata.dart'; import 'package:busymark/src/workspace/workspace_controller.dart'; import 'package:busymark/src/workspace/workspace_file_monitor.dart'; import 'package:busymark/src/workspace/workspace_message.dart'; @@ -1129,6 +1129,32 @@ void main() { ); }); + test('WYSIWYG list edits preserve a missing final newline', () async { + final directory = await Directory.systemTemp.createTemp( + 'busymark-wysiwyg-final-newline-', + ); + addTearDown(() => directory.delete(recursive: true)); + final file = File(p.join(directory.path, 'note.md')); + await file.writeAsString('- original'); + final harness = await _createControllerHarness(); + + await harness.controller.openPath(file.path); + final document = harness.controller.state.workspace!.markdown!.busyDocument; + harness.controller.updateActiveWysiwygText( + '- changed\n', + document: document, + sourceFilePath: file.path, + ); + + expect(harness.controller.state.activeBuffer?.text, '- changed'); + expect( + harness.controller.state.activeBuffer?.format.hasFinalNewline, + isFalse, + ); + expect(await harness.controller.saveActive(), isTrue); + expect(await file.readAsString(), '- changed'); + }); + test( 'Keep Mine retains the conflict snapshot until explicit overwrite', () async { @@ -1219,12 +1245,6 @@ void main() { filePath: missingPath, untitledName: null, editorState: const DocumentEditorState(), - lastKnownText: 'Last disk contents\n', - format: const TextFormatMetadata( - hasUtf8Bom: false, - lineEnding: DocumentLineEnding.lf, - hasFinalNewline: true, - ), ), ], ); @@ -1232,7 +1252,7 @@ void main() { expect(await harness.controller.restorePreviousSession(), isTrue); expect(harness.controller.state.activeBuffer?.filePath, missingPath); - expect(harness.controller.state.activeBuffer?.text, 'Last disk contents\n'); + expect(harness.controller.state.activeBuffer?.text, isEmpty); expect(harness.controller.state.activeBuffer?.deletedOnDisk, isTrue); }); @@ -1252,12 +1272,6 @@ void main() { filePath: missingPath, untitledName: null, editorState: const DocumentEditorState(), - lastKnownText: 'Standalone contents\n', - format: const TextFormatMetadata( - hasUtf8Bom: false, - lineEnding: DocumentLineEnding.lf, - hasFinalNewline: true, - ), ), ], ); @@ -1269,10 +1283,7 @@ void main() { WorkspaceKind.singleMarkdown, ); expect(harness.controller.state.activeBuffer?.filePath, missingPath); - expect( - harness.controller.state.activeBuffer?.text, - 'Standalone contents\n', - ); + expect(harness.controller.state.activeBuffer?.text, isEmpty); expect(harness.controller.state.activeBuffer?.deletedOnDisk, isTrue); }); @@ -1429,6 +1440,18 @@ class _WorkspaceControllerDriver { _notifier.updateActiveText(text, sourceFilePath: sourceFilePath); } + void updateActiveWysiwygText( + String text, { + required BusyDocument document, + String? sourceFilePath, + }) { + _notifier.updateActiveWysiwygText( + text, + document: document, + sourceFilePath: sourceFilePath, + ); + } + void updateMathRenderDiagnostic({ required String expressionId, required String? code, From 97c4cc12df7f69c28e48212223311485a6d6cbcf Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 21 Aug 2026 17:46:12 -0700 Subject: [PATCH 10/38] Preserve WYSIWYG math structure --- .../editor/wysiwyg/wysiwyg_block_widgets.dart | 4 +- .../wysiwyg/wysiwyg_document_controller.dart | 143 +++++++- lib/src/editor/wysiwyg/wysiwyg_editor.dart | 17 + .../wysiwyg/wysiwyg_inline_controller.dart | 12 +- test/src/wysiwyg_math_test.dart | 312 ++++++++++++++++++ 5 files changed, 482 insertions(+), 6 deletions(-) diff --git a/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart b/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart index 258d0d8b..d51a6eae 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart @@ -1788,7 +1788,9 @@ class _TableCellEditorState extends State<_TableCellEditor> { if (cell == null) { return const SizedBox.shrink(); } - if (busyMarkWysiwygBlockContainsMath(cell) && !_sourceEditing) { + if (busyMarkWysiwygBlockContainsMath(cell) && + !_sourceEditing && + !_focusNode.hasFocus) { return Padding( padding: BusyMarkInsets.documentTableCell, child: GestureDetector( diff --git a/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart b/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart index bc05b1a3..34c272aa 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart @@ -86,8 +86,12 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { : _nextGeneratedBlockId('math-edit'), kind: parsedBlock.kind, inlines: parsedBlock.inlines, - children: parsedBlock.children, - attributes: parsedBlock.attributes, + children: index == 0 ? current.children : parsedBlock.children, + attributes: _mathEditedBlockAttributes( + current, + parsedBlock, + firstReplacement: index == 0, + ), rawSource: parsedBlock.rawSource, sourceSpan: index == 0 ? current.sourceSpan : null, preserveRaw: false, @@ -100,6 +104,64 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { notifyListeners(); } + ({int selectionStart, int selectionEnd})? insertInlineMath( + String blockId, + int selectionStart, + int selectionEnd, { + String fallbackExpression = 'x', + }) { + final current = blockById(blockId); + if (current == null || busyMarkWysiwygBlockContainsMath(current)) { + return null; + } + final text = current.plainText; + final rawStart = selectionStart < selectionEnd + ? selectionStart + : selectionEnd; + final rawEnd = selectionStart < selectionEnd + ? selectionEnd + : selectionStart; + final start = rawStart.clamp(0, text.length).toInt(); + final end = rawEnd.clamp(start, text.length).toInt(); + final expression = start == end + ? fallbackExpression + : text.substring(start, end); + final before = _sliceInlines(current.inlines, 0, start); + final after = _sliceInlines(current.inlines, end, text.length); + final math = BusyInline( + kind: BusyInlineKind.math, + text: expression, + attributes: { + busyMarkMathExpressionAttribute: expression, + busyMarkMathDisplayAttribute: 'false', + busyMarkMathSourceFormAttribute: BusyMathSourceForm.dollarInline.name, + }, + ); + final inlines = [...before, math, ...after]; + final updated = current.copyWith( + inlines: inlines, + attributes: _attributesAfterInlineMathEdit(current, inlines), + preserveRaw: false, + dirty: true, + ); + _document = _document.copyWith( + blocks: _replaceInBlocks(_document.blocks, blockId, (_) => updated), + ); + final prefixSource = _serializer.serializeBlock( + BusyBlock( + id: 'wysiwyg-math-prefix', + kind: BusyBlockKind.paragraph, + inlines: before, + dirty: true, + ), + ); + notifyListeners(); + return ( + selectionStart: prefixSource.length + 1, + selectionEnd: prefixSource.length + 1 + expression.length, + ); + } + String? insertDisplayMathAfter(String blockId, {String expression = 'x'}) { if (blockById(blockId) == null) { return null; @@ -1765,6 +1827,83 @@ Map _attributesForText( return updated; } +Map _mathEditedBlockAttributes( + BusyBlock current, + BusyBlock parsed, { + required bool firstReplacement, +}) { + final attributes = {...parsed.attributes}; + if (firstReplacement && + current.kind == BusyBlockKind.heading && + parsed.kind == BusyBlockKind.heading && + current.attributes['generatedId'] == 'false') { + final explicitId = current.attributes['id']; + if (explicitId != null && explicitId.isNotEmpty) { + attributes['id'] = explicitId; + attributes['generatedId'] = 'false'; + } + } + return attributes; +} + +Map _attributesAfterInlineMathEdit( + BusyBlock block, + List inlines, +) { + final attributes = {...block.attributes}; + if (block.kind == BusyBlockKind.heading && + attributes['generatedId'] != 'false') { + attributes['id'] = slugForHeading( + inlines.map((inline) => inline.plainText).join().trim(), + ); + attributes['generatedId'] = 'true'; + } + return attributes; +} + +List _sliceInlines(List inlines, int start, int end) { + if (end <= start) { + return const []; + } + final result = []; + var offset = 0; + for (final inline in inlines) { + final length = inline.plainText.length; + final inlineEnd = offset + length; + if (inlineEnd > start && offset < end) { + final localStart = (start - offset).clamp(0, length).toInt(); + final localEnd = (end - offset).clamp(localStart, length).toInt(); + final sliced = _sliceInline(inline, localStart, localEnd); + if (sliced != null) { + result.add(sliced); + } + } + offset = inlineEnd; + } + return result; +} + +BusyInline? _sliceInline(BusyInline inline, int start, int end) { + final length = inline.plainText.length; + if (end <= start || length == 0) { + return null; + } + if (start == 0 && end == length) { + return inline; + } + if (inline.children.isNotEmpty) { + final children = _sliceInlines(inline.children, start, end); + if (children.isEmpty) { + return null; + } + return inline.copyWith( + text: children.map((child) => child.plainText).join(), + children: children, + ); + } + return inline.copyWith(text: inline.text.substring(start, end)); +} + bool _shouldSplitNewlines(BusyBlockKind kind) { return switch (kind) { BusyBlockKind.paragraph || diff --git a/lib/src/editor/wysiwyg/wysiwyg_editor.dart b/lib/src/editor/wysiwyg/wysiwyg_editor.dart index 0a1989c5..2acb55ff 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_editor.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_editor.dart @@ -2652,6 +2652,23 @@ class _BusyMarkWysiwygEditorState extends State { final end = math.max(selection.start, selection.end); final selected = controller.text.substring(start, end); final expression = selected.isEmpty ? 'x' : selected; + final currentBlock = _documentController.blockById(blockId); + if (currentBlock != null && + !busyMarkWysiwygBlockContainsMath(currentBlock)) { + _recordUndoSnapshot(); + final insertion = _documentController.insertInlineMath( + blockId, + start, + end, + fallbackExpression: expression, + ); + if (insertion == null) { + return; + } + _emitMarkdown(); + _focusBlockAfterFrame(blockId, offset: insertion.selectionEnd); + return; + } final insertion = '\$$expression\$'; final nextText = controller.text.replaceRange(start, end, insertion); _recordUndoSnapshot(); diff --git a/lib/src/editor/wysiwyg/wysiwyg_inline_controller.dart b/lib/src/editor/wysiwyg/wysiwyg_inline_controller.dart index e9647261..1d4526bf 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_inline_controller.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_inline_controller.dart @@ -8,9 +8,15 @@ bool busyMarkWysiwygBlockContainsMath(BusyBlock block) { bool contains(List inlines) => inlines.any( (inline) => inline.kind == BusyInlineKind.math || contains(inline.children), ); - return block.kind == BusyBlockKind.math || - contains(block.inlines) || - block.children.any(busyMarkWysiwygBlockContainsMath); + return block.kind == BusyBlockKind.math || contains(block.inlines); +} + +bool busyMarkWysiwygBlockDescendantsContainMath(BusyBlock block) { + return block.children.any( + (child) => + busyMarkWysiwygBlockContainsMath(child) || + busyMarkWysiwygBlockDescendantsContainMath(child), + ); } String busyMarkWysiwygEditableText(BusyBlock block) { diff --git a/test/src/wysiwyg_math_test.dart b/test/src/wysiwyg_math_test.dart index d9ddce25..f7a89c7a 100644 --- a/test/src/wysiwyg_math_test.dart +++ b/test/src/wysiwyg_math_test.dart @@ -81,6 +81,181 @@ void main() { expect(block.attributes, isNot(contains('wysiwygMathSource'))); }); + test('math source edits preserve nested list children', () { + const sources = [ + '- Parent \$p\$\n - Child \$x\$\n', + '1. Parent \$p\$\n 1. Child \$x\$\n', + '- [ ] Parent \$p\$\n - [x] Child \$x\$\n', + ]; + + for (final source in sources) { + final document = const MarkdownParser() + .parse(filePath: 'math.md', source: source) + .busyDocument; + final parent = document.blocks.single; + final child = parent.children.single; + final controller = BusyMarkWysiwygDocumentController(document: document); + + expect(busyMarkWysiwygBlockContainsMath(parent), isTrue, reason: source); + controller.updateMathSource(parent.id, r'Changed $q$'); + + final edited = controller.blockById(parent.id)!; + expect(edited.children, hasLength(1), reason: source); + expect(edited.children.single.id, child.id, reason: source); + expect(edited.children.single.plainText, child.plainText, reason: source); + expect(controller.markdown, contains(r'Child $x$'), reason: source); + } + }); + + test('descendant math does not put a plain list parent in source mode', () { + const sources = [ + '- Parent\n - Child \$x\$\n', + '1. Parent\n 1. Child \$x\$\n', + '- [ ] Parent\n - [x] Child \$x\$\n', + ]; + + for (final source in sources) { + final document = const MarkdownParser() + .parse(filePath: 'math.md', source: source) + .busyDocument; + final parent = document.blocks.single; + final child = parent.children.single; + final controller = BusyMarkWysiwygDocumentController(document: document); + + expect(busyMarkWysiwygBlockContainsMath(parent), isFalse, reason: source); + expect( + busyMarkWysiwygBlockDescendantsContainMath(parent), + isTrue, + reason: source, + ); + expect(controller.blockText(parent.id), 'Parent', reason: source); + controller.updateBlockText(parent.id, 'Changed parent'); + + final edited = controller.blockById(parent.id)!; + expect(edited.children.single.id, child.id, reason: source); + expect(edited.children.single.plainText, child.plainText, reason: source); + expect(controller.markdown, contains(r'Child $x$'), reason: source); + } + }); + + test('first inline math insertion preserves unaffected inline AST', () { + const source = + '**Important** [documentation](docs.md) `code` ' + '***nested*** velocity *after* ![diagram](image.png)\n'; + final document = const MarkdownParser() + .parse(filePath: 'math.md', source: source) + .busyDocument; + final controller = BusyMarkWysiwygDocumentController(document: document); + final block = document.blocks.single; + final start = block.plainText.indexOf('velocity'); + + controller.insertInlineMath(block.id, start, start + 'velocity'.length); + + final edited = controller.blockById(block.id)!; + final inlines = _flattenInlines(edited.inlines).toList(); + expect(inlines.any((inline) => inline.kind == BusyInlineKind.strong), true); + expect( + inlines.any( + (inline) => + inline.kind == BusyInlineKind.link && + inline.destination == 'docs.md', + ), + true, + ); + expect( + inlines.any( + (inline) => inline.kind == BusyInlineKind.code && inline.text == 'code', + ), + true, + ); + expect( + inlines.any( + (inline) => + inline.kind == BusyInlineKind.image && + inline.destination == 'image.png', + ), + true, + ); + expect( + inlines.any((inline) => inline.kind == BusyInlineKind.emphasis), + true, + ); + expect( + _containsNestedKinds( + edited.inlines, + BusyInlineKind.strong, + BusyInlineKind.emphasis, + ) || + _containsNestedKinds( + edited.inlines, + BusyInlineKind.emphasis, + BusyInlineKind.strong, + ), + true, + ); + expect( + inlines.singleWhere((inline) => inline.kind == BusyInlineKind.math).text, + 'velocity', + ); + expect(controller.markdown, contains(r'$velocity$')); + expect(controller.markdown, contains('[documentation](docs.md)')); + expect(controller.markdown, contains('![diagram](image.png)')); + }); + + test('math edits retain explicit heading IDs', () { + const existingMath = '# Energy \$E=mc^2\$ {id="energy-equation"}\n'; + final existingDocument = const MarkdownParser() + .parse(filePath: 'math.md', source: existingMath) + .busyDocument; + final existing = BusyMarkWysiwygDocumentController( + document: existingDocument, + ); + existing.updateMathSource( + existingDocument.blocks.single.id, + r'Changed $E=mc^2$', + ); + _expectExplicitHeadingId(existing, 'energy-equation'); + + const firstMath = '# Energy formula {id="energy-equation"}\n'; + final firstDocument = const MarkdownParser() + .parse(filePath: 'math.md', source: firstMath) + .busyDocument; + final first = BusyMarkWysiwygDocumentController(document: firstDocument); + final firstBlock = firstDocument.blocks.single; + final formulaStart = firstBlock.plainText.indexOf('formula'); + first.insertInlineMath( + firstBlock.id, + formulaStart, + formulaStart + 'formula'.length, + ); + _expectExplicitHeadingId(first, 'energy-equation'); + + final removed = BusyMarkWysiwygDocumentController( + document: existingDocument, + ); + removed.updateMathSource(existingDocument.blocks.single.id, 'Energy'); + _expectExplicitHeadingId(removed, 'energy-equation'); + }); + + test('first math insertion recalculates a generated heading ID', () { + final document = const MarkdownParser() + .parse(filePath: 'math.md', source: '# Old heading\n') + .busyDocument; + final controller = BusyMarkWysiwygDocumentController(document: document); + final heading = document.blocks.single; + + controller.insertInlineMath( + heading.id, + heading.plainText.length, + heading.plainText.length, + fallbackExpression: ' New', + ); + + final edited = controller.document.blocks.single; + expect(edited.attributes['generatedId'], 'true'); + expect(edited.attributes['id'], 'old-heading-new'); + }); + test('table-cell source editing retains and removes semantic math', () { final document = const MarkdownParser() .parse( @@ -257,6 +432,59 @@ void main() { expect(markdown, contains('\$\$\nx\n\$\$')); }); + testWidgets('inline math toolbar preserves existing inline structure', ( + tester, + ) async { + const source = + '**Important** [documentation](docs.md) `code` ' + '***nested*** velocity *after* ![diagram](image.png)\n'; + final document = const MarkdownParser() + .parse(filePath: 'math.md', source: source) + .busyDocument; + var markdown = source; + + await tester.pumpWidget( + ProviderScope( + overrides: [ + webRenderHostProvider.overrideWithValue(_WysiwygMathHost()), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SizedBox( + width: 1100, + height: 600, + child: BusyMarkWysiwygEditor( + document: document, + onSourceChanged: (_, source) => markdown = source, + ), + ), + ), + ), + ), + ); + await tester.pump(); + final field = find.byType(TextField); + final textController = tester.widget(field).controller!; + final start = textController.text.indexOf('velocity'); + textController.selection = TextSelection( + baseOffset: start, + extentOffset: start + 'velocity'.length, + ); + + await tester.ensureVisible(find.byTooltip('Inline math')); + await tester.tap(find.byTooltip('Inline math')); + await tester.pump(); + + expect(markdown, contains('**Important**')); + expect(markdown, contains('[documentation](docs.md)')); + expect(markdown, contains('`code`')); + expect(markdown, contains(r'$velocity$')); + expect(markdown, contains('*after*')); + expect(markdown, contains('![diagram](image.png)')); + }); + testWidgets('rendered and focused math list items keep one marker', ( tester, ) async { @@ -351,6 +579,90 @@ void main() { await tester.pump(); expect(markdown, contains(r'| before $y^2$ after |')); }); + + testWidgets('focused plain table cell stays editable after math is typed', ( + tester, + ) async { + final document = const MarkdownParser() + .parse( + filePath: 'math.md', + source: '| Formula |\n| --- |\n| before |\n', + ) + .busyDocument; + final cell = document.blocks.single.children[1].children.single; + var markdown = document.source!; + + await tester.pumpWidget( + ProviderScope( + overrides: [ + webRenderHostProvider.overrideWithValue(_WysiwygMathHost()), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: BusyMarkWysiwygEditor( + document: document, + onSourceChanged: (_, source) => markdown = source, + ), + ), + ), + ), + ); + await tester.pump(); + final field = find.byKey(ValueKey(cell.id)); + await tester.tap(field); + await tester.enterText(field, r'before $x$'); + await tester.pump(); + + expect(field, findsOneWidget); + expect(tester.widget(field).focusNode?.hasFocus, isTrue); + await tester.enterText(field, r'before $x$ after'); + await tester.pump(); + expect(markdown, contains(r'| before $x$ after |')); + + tester.widget(field).focusNode?.unfocus(); + await _pumpMath(tester); + expect( + find.byKey(ValueKey('wysiwyg-rendered-math-${cell.id}')), + findsOneWidget, + ); + }); +} + +Iterable _flattenInlines(List inlines) sync* { + for (final inline in inlines) { + yield inline; + yield* _flattenInlines(inline.children); + } +} + +bool _containsNestedKinds( + List inlines, + BusyInlineKind outer, + BusyInlineKind inner, +) { + for (final inline in inlines) { + if (inline.kind == outer && + _flattenInlines(inline.children).any((child) => child.kind == inner)) { + return true; + } + if (_containsNestedKinds(inline.children, outer, inner)) { + return true; + } + } + return false; +} + +void _expectExplicitHeadingId( + BusyMarkWysiwygDocumentController controller, + String id, +) { + final heading = controller.document.blocks.single; + expect(heading.kind, BusyBlockKind.heading); + expect(heading.attributes['id'], id); + expect(heading.attributes['generatedId'], 'false'); + expect(controller.markdown, contains('{id="$id"}')); } Future _sendUndo(WidgetTester tester) async { From 38fe7da0a68f2299227b1858429dc0a583464bc1 Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 21 Aug 2026 17:51:11 -0700 Subject: [PATCH 11/38] Filter AI provider choices --- lib/src/ai/ai_edit_ui.dart | 111 ++++++++++++++++++++++++---- test/src/ai_edit_ui_test.dart | 131 +++++++++++++++++++++++++++++++--- 2 files changed, 220 insertions(+), 22 deletions(-) diff --git a/lib/src/ai/ai_edit_ui.dart b/lib/src/ai/ai_edit_ui.dart index 48ddeb05..690193ea 100644 --- a/lib/src/ai/ai_edit_ui.dart +++ b/lib/src/ai/ai_edit_ui.dart @@ -25,16 +25,37 @@ Future showBusyMarkAiEdit( AiEditorSnapshot snapshot, { AiEditTargetKind? fixedTarget, }) async { - final defaultProvider = ref - .read(appSettingsControllerProvider) - .defaultAiProviderKind; + final settings = ref.read(appSettingsControllerProvider); + final List availableProviders; + try { + availableProviders = await _availableAiProviders(ref, settings); + } on AiException catch (error) { + if (context.mounted) { + await _showAiMessage(context, error.message); + } + return null; + } + if (availableProviders.isEmpty) { + if (context.mounted) { + await _showAiMessage(context, context.l10n.aiConfigureFirst); + } + return null; + } + if (!context.mounted) { + return null; + } + final defaultProvider = settings.defaultAiProviderKind; + final initialProvider = availableProviders.contains(defaultProvider) + ? defaultProvider! + : availableProviders.first; final configuration = await showBusyMarkModalEditorDialog<_AiEditConfiguration>( context, builder: (dialogContext) => _AiEditConfigurationDialog( snapshot: snapshot, fixedTarget: fixedTarget, - initialProvider: defaultProvider ?? AiProviderKind.ollamaLocal, + availableProviders: availableProviders, + initialProvider: initialProvider, ), ); if (configuration == null || !context.mounted) { @@ -166,23 +187,79 @@ Future chooseBusyMarkAiProvider( BuildContext context, WidgetRef ref, ) async { - final defaultProvider = ref - .read(appSettingsControllerProvider) - .defaultAiProviderKind; - if (defaultProvider == null) { - await _showAiMessage(context, context.l10n.aiConfigureFirst); + final settings = ref.read(appSettingsControllerProvider); + final List availableProviders; + try { + availableProviders = await _availableAiProviders(ref, settings); + } on AiException catch (error) { + if (context.mounted) { + await _showAiMessage(context, error.message); + } + return null; + } + if (availableProviders.isEmpty) { + if (context.mounted) { + await _showAiMessage(context, context.l10n.aiConfigureFirst); + } return null; } if (!context.mounted) { return null; } + final defaultProvider = settings.defaultAiProviderKind; + final initialProvider = availableProviders.contains(defaultProvider) + ? defaultProvider! + : availableProviders.first; return showBusyMarkModalDialog( context, - builder: (dialogContext) => - _AiProviderChoiceDialog(initialProvider: defaultProvider), + builder: (dialogContext) => _AiProviderChoiceDialog( + availableProviders: availableProviders, + initialProvider: initialProvider, + ), ); } +Future> _availableAiProviders( + WidgetRef ref, + AppSettings settings, +) async { + final defaultProvider = settings.defaultAiProviderKind; + if (defaultProvider == null) { + return const []; + } + + final available = {}; + if (defaultProvider == AiProviderKind.ollamaLocal || + settings.aiOllamaModel.trim().isNotEmpty) { + available.add(AiProviderKind.ollamaLocal); + } + + final secretStore = ref.read(aiSecretStoreProvider); + AiException? defaultCredentialError; + for (final provider in AiProviderKind.values.where( + (provider) => provider.isCloud, + )) { + try { + if (await secretStore.read(provider) != null) { + available.add(provider); + } + } on AiException catch (error) { + if (provider == defaultProvider) { + defaultCredentialError = error; + } + } + } + if (available.isEmpty && defaultCredentialError != null) { + throw defaultCredentialError; + } + + return [ + if (available.contains(defaultProvider)) defaultProvider, + for (final provider in AiProviderKind.values) + if (provider != defaultProvider && available.contains(provider)) provider, + ]; +} + Future _showAiMessage(BuildContext context, String message) { return showBusyMarkModalDialog( context, @@ -216,11 +293,13 @@ class _AiEditConfiguration { class _AiEditConfigurationDialog extends StatefulWidget { const _AiEditConfigurationDialog({ required this.snapshot, + required this.availableProviders, required this.initialProvider, this.fixedTarget, }); final AiEditorSnapshot snapshot; + final List availableProviders; final AiProviderKind initialProvider; final AiEditTargetKind? fixedTarget; @@ -299,7 +378,7 @@ class _AiEditConfigurationDialogState BusyMarkComboRow( key: const ValueKey('ai-edit-provider'), title: context.l10n.aiProvider, - values: AiProviderKind.values, + values: widget.availableProviders, selected: _provider, labelFor: (provider) => _providerLabel(context, provider), onSelected: (provider) => setState(() => _provider = provider), @@ -436,8 +515,12 @@ class _AiEditConfigurationDialogState } class _AiProviderChoiceDialog extends StatefulWidget { - const _AiProviderChoiceDialog({required this.initialProvider}); + const _AiProviderChoiceDialog({ + required this.availableProviders, + required this.initialProvider, + }); + final List availableProviders; final AiProviderKind initialProvider; @override @@ -476,7 +559,7 @@ class _AiProviderChoiceDialogState extends State<_AiProviderChoiceDialog> { BusyMarkComboRow( key: const ValueKey('ai-provider-choice'), title: context.l10n.aiProvider, - values: AiProviderKind.values, + values: widget.availableProviders, selected: _provider, labelFor: (provider) => _providerLabel(context, provider), onSelected: (provider) => setState(() => _provider = provider), diff --git a/test/src/ai_edit_ui_test.dart b/test/src/ai_edit_ui_test.dart index bc6015a6..e0554412 100644 --- a/test/src/ai_edit_ui_test.dart +++ b/test/src/ai_edit_ui_test.dart @@ -6,6 +6,7 @@ import 'package:busymark/src/ai/ai_models.dart'; import 'package:busymark/src/ai/ai_provider.dart'; import 'package:busymark/src/ai/ai_provider_registry.dart'; import 'package:busymark/src/ai/ai_providers.dart'; +import 'package:busymark/src/ai/ai_secret_store.dart'; import 'package:busymark/src/app/app_settings.dart'; import 'package:busymark/src/app/app_theme.dart'; import 'package:busymark/src/app/busymark_design.dart'; @@ -18,8 +19,11 @@ void main() { testWidgets('document-only AI snapshot opens a usable configuration', ( tester, ) async { + final container = _aiContainer(); + addTearDown(container.dispose); await tester.pumpWidget( - ProviderScope( + UncontrolledProviderScope( + container: container, child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, @@ -53,6 +57,7 @@ void main() { ), ), ); + await _pumpSettings(tester, container); await tester.tap(find.text('Open AI')); await tester.pumpAndSettle(); @@ -74,8 +79,13 @@ void main() { ) async { const source = '# Guide\n\nText to refine.\n'; final selectionStart = source.indexOf('Text'); + final container = _aiContainer( + secrets: {AiProviderKind.gemini: 'gemini-key'}, + ); + addTearDown(container.dispose); await tester.pumpWidget( - ProviderScope( + UncontrolledProviderScope( + container: container, child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, @@ -108,6 +118,7 @@ void main() { ), ), ); + await _pumpSettings(tester, container); await tester.tap(find.text('Open AI')); await tester.pumpAndSettle(); @@ -116,7 +127,10 @@ void main() { final providerSelector = tester.widget>( find.byKey(const ValueKey('ai-edit-provider')), ); - expect(providerSelector.values, AiProviderKind.values); + expect(providerSelector.values, [ + AiProviderKind.ollamaLocal, + AiProviderKind.gemini, + ]); expect(providerSelector.selected, AiProviderKind.ollamaLocal); providerSelector.onSelected(AiProviderKind.gemini); await tester.pump(); @@ -234,17 +248,21 @@ void main() { await tester.pumpAndSettle(); }); - testWidgets('provider chooser exposes every supported provider kind', ( + testWidgets('provider chooser exposes only configured providers', ( tester, ) async { final settings = AppSettings.defaults().copyWith( aiProviderPreference: AiProviderPreference.ollamaLocal, + aiOllamaModel: 'local-model', ); final container = ProviderContainer( overrides: [ localSettingsStoreProvider.overrideWithValue( _MemorySettingsStore(settings.toJson()), ), + aiSecretStoreProvider.overrideWithValue( + _MemoryAiSecretStore({AiProviderKind.gemini: 'gemini-key'}), + ), ], ); addTearDown(container.dispose); @@ -277,24 +295,84 @@ void main() { final selector = tester.widget>( find.byKey(const ValueKey('ai-provider-choice')), ); - expect(selector.values, AiProviderKind.values); + expect(selector.values, [ + AiProviderKind.ollamaLocal, + AiProviderKind.gemini, + ]); expect(selector.selected, AiProviderKind.ollamaLocal); - selector.onSelected(AiProviderKind.openAi); + selector.onSelected(AiProviderKind.gemini); await tester.pump(); await tester.tap(find.text('Generate proposal')); await tester.pumpAndSettle(); - expect(selected, AiProviderKind.openAi); + expect(selected, AiProviderKind.gemini); }); + testWidgets( + 'configured local provider remains available when Gemini is default', + (tester) async { + final settings = AppSettings.defaults().copyWith( + aiProviderPreference: AiProviderPreference.gemini, + aiOllamaModel: 'local-model', + aiCloudProviderConsentIds: [AiProviderKind.gemini.id], + ); + final container = ProviderContainer( + overrides: [ + localSettingsStoreProvider.overrideWithValue( + _MemorySettingsStore(settings.toJson()), + ), + aiSecretStoreProvider.overrideWithValue( + _MemoryAiSecretStore({AiProviderKind.gemini: 'gemini-key'}), + ), + ], + ); + addTearDown(container.dispose); + container.read(appSettingsControllerProvider); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Consumer( + builder: (context, ref, child) => ElevatedButton( + onPressed: () => + unawaited(chooseBusyMarkAiProvider(context, ref)), + child: const Text('Choose provider'), + ), + ), + ), + ), + ), + ); + await _pumpSettings(tester, container); + + await tester.tap(find.text('Choose provider')); + await tester.pumpAndSettle(); + final selector = tester.widget>( + find.byKey(const ValueKey('ai-provider-choice')), + ); + expect(selector.values, [ + AiProviderKind.gemini, + AiProviderKind.ollamaLocal, + ]); + expect(selector.selected, AiProviderKind.gemini); + }, + ); + testWidgets('fixed AI target cannot widen a sidebar selection', ( tester, ) async { const source = '# First\n\nSelected section.\n\n# Last\n'; final start = source.indexOf('# First'); final end = source.indexOf('# Last'); + final container = _aiContainer(); + addTearDown(container.dispose); await tester.pumpWidget( - ProviderScope( + UncontrolledProviderScope( + container: container, child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, @@ -325,6 +403,7 @@ void main() { ), ), ); + await _pumpSettings(tester, container); await tester.tap(find.text('Open fixed AI')); await tester.pumpAndSettle(); @@ -506,3 +585,39 @@ class _MemorySettingsStore implements LocalSettingsStore { value = json; } } + +class _MemoryAiSecretStore implements AiSecretStore { + _MemoryAiSecretStore([Map? values]) + : _values = {...?values}; + + final Map _values; + + @override + Future delete(AiProviderKind provider) async { + _values.remove(provider); + } + + @override + Future read(AiProviderKind provider) async => _values[provider]; + + @override + Future write(AiProviderKind provider, String secret) async { + _values[provider] = secret; + } +} + +ProviderContainer _aiContainer({Map? secrets}) { + final settings = AppSettings.defaults().copyWith( + aiProviderPreference: AiProviderPreference.ollamaLocal, + ); + final container = ProviderContainer( + overrides: [ + localSettingsStoreProvider.overrideWithValue( + _MemorySettingsStore(settings.toJson()), + ), + aiSecretStoreProvider.overrideWithValue(_MemoryAiSecretStore(secrets)), + ], + ); + container.read(appSettingsControllerProvider); + return container; +} From a2a5a6b9bc4c09fd2bfdd458973f3b67a745f518 Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 21 Aug 2026 18:05:01 -0700 Subject: [PATCH 12/38] Add per-request AI model selection --- lib/l10n/app_ar.arb | 1 + lib/l10n/app_de.arb | 1 + lib/l10n/app_en.arb | 2 + lib/l10n/app_es.arb | 1 + lib/l10n/app_et.arb | 1 + lib/l10n/app_fa.arb | 1 + lib/l10n/app_fr.arb | 1 + lib/l10n/app_hi.arb | 1 + lib/l10n/app_it.arb | 1 + lib/l10n/app_nb.arb | 1 + lib/l10n/app_pl.arb | 1 + lib/l10n/app_pt.arb | 1 + lib/l10n/app_ru.arb | 1 + lib/l10n/app_uk.arb | 1 + lib/l10n/generated/app_localizations.dart | 6 + lib/l10n/generated/app_localizations_ar.dart | 3 + lib/l10n/generated/app_localizations_de.dart | 3 + lib/l10n/generated/app_localizations_en.dart | 3 + lib/l10n/generated/app_localizations_es.dart | 3 + lib/l10n/generated/app_localizations_et.dart | 3 + lib/l10n/generated/app_localizations_fa.dart | 3 + lib/l10n/generated/app_localizations_fr.dart | 3 + lib/l10n/generated/app_localizations_hi.dart | 3 + lib/l10n/generated/app_localizations_it.dart | 3 + lib/l10n/generated/app_localizations_nb.dart | 3 + lib/l10n/generated/app_localizations_pl.dart | 3 + lib/l10n/generated/app_localizations_pt.dart | 3 + lib/l10n/generated/app_localizations_ru.dart | 3 + lib/l10n/generated/app_localizations_uk.dart | 3 + lib/src/ai/ai_edit_ui.dart | 128 +++++++++++++++-- test/src/ai_edit_ui_test.dart | 138 ++++++++++++++++++- test/src/localization_audit_test.dart | 2 +- 32 files changed, 317 insertions(+), 14 deletions(-) diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 51336894..fdc66275 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -2646,6 +2646,7 @@ "aiAutomaticRouting": "تلقائي حسب المهمة", "aiFixedModelRouting": "استخدام النموذج المحدد", "aiPreferredModel": "النموذج المفضّل", + "aiModel": "النموذج", "aiUsageThisMonth": "⁨{requests}⁩ طلبات · ⁨{input}⁩ رموز إدخال · ⁨{output}⁩ رموز إخراج", "aiCloudConsentTitle": "هل تريد إرسال المحتوى إلى ⁨{provider}⁩؟", "aiCloudConsentEnable": "تفعيل ⁨{provider}⁩", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 291fac22..6a7a7368 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -2667,6 +2667,7 @@ "aiAutomaticRouting": "Automatisch nach Aufgabe", "aiFixedModelRouting": "Ausgewähltes Modell verwenden", "aiPreferredModel": "Bevorzugtes Modell", + "aiModel": "Modell", "aiUsageThisMonth": "{requests} Anfragen · {input} Eingabetoken · {output} Ausgabetoken", "aiCloudConsentTitle": "Inhalte an {provider} senden?", "aiCloudConsentEnable": "{provider} aktivieren", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 322f1fe6..cc57783d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -2211,6 +2211,8 @@ "@aiFixedModelRouting": {"description": "AI model routing option that always uses the preferred model."}, "aiPreferredModel": "Preferred model", "@aiPreferredModel": {"description": "Settings label for a preferred cloud AI model."}, + "aiModel": "Model", + "@aiModel": {"description": "Label for selecting a model for one AI request."}, "aiUsageThisMonth": "{requests} requests · {input} input tokens · {output} output tokens", "@aiUsageThisMonth": {"description": "Local monthly AI usage summary.", "placeholders": {"requests": {"type": "int"}, "input": {"type": "int"}, "output": {"type": "int"}}}, "aiCloudConsentTitle": "Send content to {provider}?", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 3fcad913..ab56aa32 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -2667,6 +2667,7 @@ "aiAutomaticRouting": "Automática según la tarea", "aiFixedModelRouting": "Usar el modelo seleccionado", "aiPreferredModel": "Modelo preferido", + "aiModel": "Modelo", "aiUsageThisMonth": "{requests} solicitudes · {input} tokens de entrada · {output} tokens de salida", "aiCloudConsentTitle": "¿Enviar contenido a {provider}?", "aiCloudConsentEnable": "Activar {provider}", diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index 6ab616ee..ec067208 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -1855,6 +1855,7 @@ "aiAutomaticRouting": "Automaatselt ülesande järgi", "aiFixedModelRouting": "Kasuta valitud mudelit", "aiPreferredModel": "Eelistatud mudel", + "aiModel": "Mudel", "aiUsageThisMonth": "{requests} päringut · {input} sisendmärgendit · {output} väljundmärgendit", "aiCloudConsentTitle": "Kas saata sisu teenusele {provider}?", "aiCloudConsentEnable": "Luba {provider}", diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 69b8984d..83b45fa0 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -2665,6 +2665,7 @@ "aiAutomaticRouting": "خودکار بر اساس کار", "aiFixedModelRouting": "استفاده از مدل انتخابی", "aiPreferredModel": "مدل ترجیحی", + "aiModel": "مدل", "aiUsageThisMonth": "⁨{requests}⁩ درخواست · ⁨{input}⁩ توکن ورودی · ⁨{output}⁩ توکن خروجی", "aiCloudConsentTitle": "محتوا برای ⁨{provider}⁩ ارسال شود؟", "aiCloudConsentEnable": "فعال‌کردن ⁨{provider}⁩", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 3dce80cf..cb2c5568 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -2667,6 +2667,7 @@ "aiAutomaticRouting": "Automatique selon la tâche", "aiFixedModelRouting": "Utiliser le modèle sélectionné", "aiPreferredModel": "Modèle préféré", + "aiModel": "Modèle", "aiUsageThisMonth": "{requests} requêtes · {input} jetons d’entrée · {output} jetons de sortie", "aiCloudConsentTitle": "Envoyer du contenu à {provider} ?", "aiCloudConsentEnable": "Activer {provider}", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 872441b5..f0863535 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -2646,6 +2646,7 @@ "aiAutomaticRouting": "कार्य के अनुसार स्वचालित", "aiFixedModelRouting": "चयनित मॉडल का उपयोग करें", "aiPreferredModel": "पसंदीदा मॉडल", + "aiModel": "मॉडल", "aiUsageThisMonth": "{requests} अनुरोध · {input} इनपुट टोकन · {output} आउटपुट टोकन", "aiCloudConsentTitle": "सामग्री {provider} को भेजें?", "aiCloudConsentEnable": "{provider} सक्षम करें", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 44879c94..d6e66e6b 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -2644,6 +2644,7 @@ "aiAutomaticRouting": "Automatica in base all’attività", "aiFixedModelRouting": "Usa il modello selezionato", "aiPreferredModel": "Modello preferito", + "aiModel": "Modello", "aiUsageThisMonth": "{requests} richieste · {input} token di input · {output} token di output", "aiCloudConsentTitle": "Inviare contenuti a {provider}?", "aiCloudConsentEnable": "Abilita {provider}", diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb index a4dbef1a..d82db70a 100644 --- a/lib/l10n/app_nb.arb +++ b/lib/l10n/app_nb.arb @@ -2644,6 +2644,7 @@ "aiAutomaticRouting": "Automatisk etter oppgave", "aiFixedModelRouting": "Bruk valgt modell", "aiPreferredModel": "Foretrukket modell", + "aiModel": "Modell", "aiUsageThisMonth": "{requests} forespørsler · {input} inndata-tokener · {output} utdata-tokener", "aiCloudConsentTitle": "Sende innhold til {provider}?", "aiCloudConsentEnable": "Aktiver {provider}", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 5e216889..f35d23aa 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -2662,6 +2662,7 @@ "aiAutomaticRouting": "Automatycznie według zadania", "aiFixedModelRouting": "Użyj wybranego modelu", "aiPreferredModel": "Preferowany model", + "aiModel": "Model", "aiUsageThisMonth": "{requests} żądań · {input} tokenów wejściowych · {output} tokenów wyjściowych", "aiCloudConsentTitle": "Wysłać treść do {provider}?", "aiCloudConsentEnable": "Włącz {provider}", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 3cc73b95..2766368a 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -2644,6 +2644,7 @@ "aiAutomaticRouting": "Automática conforme a tarefa", "aiFixedModelRouting": "Usar o modelo selecionado", "aiPreferredModel": "Modelo preferido", + "aiModel": "Modelo", "aiUsageThisMonth": "{requests} solicitações · {input} tokens de entrada · {output} tokens de saída", "aiCloudConsentTitle": "Enviar conteúdo para {provider}?", "aiCloudConsentEnable": "Ativar {provider}", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 83b62249..2959b19e 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -2662,6 +2662,7 @@ "aiAutomaticRouting": "Автоматически по задаче", "aiFixedModelRouting": "Использовать выбранную модель", "aiPreferredModel": "Предпочитаемая модель", + "aiModel": "Модель", "aiUsageThisMonth": "{requests} запросов · {input} входных токенов · {output} выходных токенов", "aiCloudConsentTitle": "Отправить содержимое поставщику {provider}?", "aiCloudConsentEnable": "Включить {provider}", diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index b8d703be..4f7f260b 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -2662,6 +2662,7 @@ "aiAutomaticRouting": "Автоматично за завданням", "aiFixedModelRouting": "Використовувати вибрану модель", "aiPreferredModel": "Бажана модель", + "aiModel": "Модель", "aiUsageThisMonth": "{requests} запитів · {input} вхідних токенів · {output} вихідних токенів", "aiCloudConsentTitle": "Надіслати вміст постачальнику {provider}?", "aiCloudConsentEnable": "Увімкнути {provider}", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 36a7287a..8e04e1b8 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -5769,6 +5769,12 @@ abstract class AppLocalizations { /// **'Preferred model'** String get aiPreferredModel; + /// Label for selecting a model for one AI request. + /// + /// In en, this message translates to: + /// **'Model'** + String get aiModel; + /// Local monthly AI usage summary. /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index e816e153..bce05930 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -3420,6 +3420,9 @@ class AppLocalizationsAr extends AppLocalizations { @override String get aiPreferredModel => 'النموذج المفضّل'; + @override + String get aiModel => 'النموذج'; + @override String aiUsageThisMonth(int requests, int input, int output) { return '⁨$requests⁩ طلبات · ⁨$input⁩ رموز إدخال · ⁨$output⁩ رموز إخراج'; diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 31d2c734..a383efa6 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -3435,6 +3435,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get aiPreferredModel => 'Bevorzugtes Modell'; + @override + String get aiModel => 'Modell'; + @override String aiUsageThisMonth(int requests, int input, int output) { return '$requests Anfragen · $input Eingabetoken · $output Ausgabetoken'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index b106d6ce..67c4d8c4 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -3418,6 +3418,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get aiPreferredModel => 'Preferred model'; + @override + String get aiModel => 'Model'; + @override String aiUsageThisMonth(int requests, int input, int output) { return '$requests requests · $input input tokens · $output output tokens'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index abff3051..2c9633e9 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -3436,6 +3436,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get aiPreferredModel => 'Modelo preferido'; + @override + String get aiModel => 'Modelo'; + @override String aiUsageThisMonth(int requests, int input, int output) { return '$requests solicitudes · $input tokens de entrada · $output tokens de salida'; diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index 6909deb4..a2260760 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -3400,6 +3400,9 @@ class AppLocalizationsEt extends AppLocalizations { @override String get aiPreferredModel => 'Eelistatud mudel'; + @override + String get aiModel => 'Mudel'; + @override String aiUsageThisMonth(int requests, int input, int output) { return '$requests päringut · $input sisendmärgendit · $output väljundmärgendit'; diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index 8d335b94..6dd13c2f 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -3450,6 +3450,9 @@ class AppLocalizationsFa extends AppLocalizations { @override String get aiPreferredModel => 'مدل ترجیحی'; + @override + String get aiModel => 'مدل'; + @override String aiUsageThisMonth(int requests, int input, int output) { return '⁨$requests⁩ درخواست · ⁨$input⁩ توکن ورودی · ⁨$output⁩ توکن خروجی'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 6c68cec2..445a7137 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -3429,6 +3429,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get aiPreferredModel => 'Modèle préféré'; + @override + String get aiModel => 'Modèle'; + @override String aiUsageThisMonth(int requests, int input, int output) { return '$requests requêtes · $input jetons d’entrée · $output jetons de sortie'; diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 1112aaf5..9024a824 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -3395,6 +3395,9 @@ class AppLocalizationsHi extends AppLocalizations { @override String get aiPreferredModel => 'पसंदीदा मॉडल'; + @override + String get aiModel => 'मॉडल'; + @override String aiUsageThisMonth(int requests, int input, int output) { return '$requests अनुरोध · $input इनपुट टोकन · $output आउटपुट टोकन'; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index 8a15e859..f1434296 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -3426,6 +3426,9 @@ class AppLocalizationsIt extends AppLocalizations { @override String get aiPreferredModel => 'Modello preferito'; + @override + String get aiModel => 'Modello'; + @override String aiUsageThisMonth(int requests, int input, int output) { return '$requests richieste · $input token di input · $output token di output'; diff --git a/lib/l10n/generated/app_localizations_nb.dart b/lib/l10n/generated/app_localizations_nb.dart index c235044d..a1a3ec1f 100644 --- a/lib/l10n/generated/app_localizations_nb.dart +++ b/lib/l10n/generated/app_localizations_nb.dart @@ -3399,6 +3399,9 @@ class AppLocalizationsNb extends AppLocalizations { @override String get aiPreferredModel => 'Foretrukket modell'; + @override + String get aiModel => 'Modell'; + @override String aiUsageThisMonth(int requests, int input, int output) { return '$requests forespørsler · $input inndata-tokener · $output utdata-tokener'; diff --git a/lib/l10n/generated/app_localizations_pl.dart b/lib/l10n/generated/app_localizations_pl.dart index 38dc304d..34de0401 100644 --- a/lib/l10n/generated/app_localizations_pl.dart +++ b/lib/l10n/generated/app_localizations_pl.dart @@ -3442,6 +3442,9 @@ class AppLocalizationsPl extends AppLocalizations { @override String get aiPreferredModel => 'Preferowany model'; + @override + String get aiModel => 'Model'; + @override String aiUsageThisMonth(int requests, int input, int output) { return '$requests żądań · $input tokenów wejściowych · $output tokenów wyjściowych'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index dc4bcbcb..451c4794 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -3420,6 +3420,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get aiPreferredModel => 'Modelo preferido'; + @override + String get aiModel => 'Modelo'; + @override String aiUsageThisMonth(int requests, int input, int output) { return '$requests solicitações · $input tokens de entrada · $output tokens de saída'; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 592687ed..1094c420 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -3435,6 +3435,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get aiPreferredModel => 'Предпочитаемая модель'; + @override + String get aiModel => 'Модель'; + @override String aiUsageThisMonth(int requests, int input, int output) { return '$requests запросов · $input входных токенов · $output выходных токенов'; diff --git a/lib/l10n/generated/app_localizations_uk.dart b/lib/l10n/generated/app_localizations_uk.dart index 60465ff8..fafc0cff 100644 --- a/lib/l10n/generated/app_localizations_uk.dart +++ b/lib/l10n/generated/app_localizations_uk.dart @@ -3444,6 +3444,9 @@ class AppLocalizationsUk extends AppLocalizations { @override String get aiPreferredModel => 'Бажана модель'; + @override + String get aiModel => 'Модель'; + @override String aiUsageThisMonth(int requests, int input, int output) { return '$requests запитів · $input вхідних токенів · $output вихідних токенів'; diff --git a/lib/src/ai/ai_edit_ui.dart b/lib/src/ai/ai_edit_ui.dart index 690193ea..2e76da9a 100644 --- a/lib/src/ai/ai_edit_ui.dart +++ b/lib/src/ai/ai_edit_ui.dart @@ -86,6 +86,7 @@ Future showBusyMarkAiEdit( ref, invocation, providerKind: configuration.provider, + modelCandidates: configuration.modelCandidates, ); return output == null ? null @@ -97,6 +98,7 @@ Future showBusyMarkAiProposal( WidgetRef ref, AiEditInvocation invocation, { AiProviderKind? providerKind, + List? modelCandidates, Future Function()? validateBeforeApply, String? staleMessage, }) async { @@ -117,11 +119,10 @@ Future showBusyMarkAiProposal( final provider = ref .read(aiProviderRegistryProvider) .require(selectedProvider); - final modelCandidates = settings.modelCandidatesFor( - invocation.feature, - provider, - ); - if (modelCandidates.isEmpty) { + final resolvedModelCandidates = + modelCandidates ?? + settings.modelCandidatesFor(invocation.feature, provider); + if (resolvedModelCandidates.isEmpty) { await _showAiMessage(context, context.l10n.aiConfigureFirst); return null; } @@ -145,7 +146,7 @@ Future showBusyMarkAiProposal( feature: invocation.feature, scope: invocation.scope, input: invocation.input, - modelCandidates: modelCandidates, + modelCandidates: resolvedModelCandidates, sourceRevision: invocation.sourceRevision, contentFormat: invocation.contentFormat, editTarget: invocation.editTarget, @@ -283,14 +284,16 @@ class _AiEditConfiguration { required this.instruction, required this.resolvedTarget, required this.provider, + required this.modelCandidates, }); final String instruction; final AiMarkdownEditTarget resolvedTarget; final AiProviderKind provider; + final List modelCandidates; } -class _AiEditConfigurationDialog extends StatefulWidget { +class _AiEditConfigurationDialog extends ConsumerStatefulWidget { const _AiEditConfigurationDialog({ required this.snapshot, required this.availableProviders, @@ -304,13 +307,19 @@ class _AiEditConfigurationDialog extends StatefulWidget { final AiEditTargetKind? fixedTarget; @override - State<_AiEditConfigurationDialog> createState() => + ConsumerState<_AiEditConfigurationDialog> createState() => _AiEditConfigurationDialogState(); } class _AiEditConfigurationDialogState - extends State<_AiEditConfigurationDialog> { + extends ConsumerState<_AiEditConfigurationDialog> { + static const _automaticModel = ''; + final _controller = TextEditingController(); + final _modelsByProvider = >{}; + final _modelSelectionByProvider = {}; + final _modelDiscoveryTokens = {}; + final _modelDiscoveryCompleted = {}; late AiEditTargetKind _target; late AiEditContextKind _context; late AiProviderKind _provider; @@ -325,6 +334,24 @@ class _AiEditConfigurationDialogState void initState() { super.initState(); _provider = widget.initialProvider; + final settings = ref.read(appSettingsControllerProvider); + final registry = ref.read(aiProviderRegistryProvider); + for (final providerKind in widget.availableProviders) { + final provider = registry.require(providerKind); + final selectedModel = settings.selectedAiModel(providerKind).trim(); + _modelsByProvider[providerKind] = { + if (selectedModel.isNotEmpty) selectedModel, + for (final models in provider.capabilities.recommendedModels.values) + ...models, + }.toList(growable: false); + _modelSelectionByProvider[providerKind] = + settings.aiModelRoutingPreference == + AiModelRoutingPreference.automatic || + selectedModel.isEmpty + ? _automaticModel + : selectedModel; + } + unawaited(_discoverModels(_provider)); if (widget.fixedTarget case final fixedTarget?) { _target = fixedTarget; _context = fixedTarget == AiEditTargetKind.document @@ -349,6 +376,9 @@ class _AiEditConfigurationDialogState @override void dispose() { + for (final token in _modelDiscoveryTokens.values) { + token.cancel(); + } _controller.dispose(); super.dispose(); } @@ -356,6 +386,7 @@ class _AiEditConfigurationDialogState @override Widget build(BuildContext context) { final resolvedTarget = _resolvedTarget; + final providerKind = _provider; return BusyMarkModalEditorScaffold( title: context.l10n.aiRefineWithAi, cancelLabel: context.l10n.cancel, @@ -369,6 +400,7 @@ class _AiEditConfigurationDialogState instruction: _controller.text.trim(), resolvedTarget: _resolvedTarget!, provider: _provider, + modelCandidates: _selectedModelCandidates, ), ), children: [ @@ -381,7 +413,18 @@ class _AiEditConfigurationDialogState values: widget.availableProviders, selected: _provider, labelFor: (provider) => _providerLabel(context, provider), - onSelected: (provider) => setState(() => _provider = provider), + onSelected: _selectProvider, + ), + BusyMarkComboRow( + key: const ValueKey('ai-edit-model'), + title: context.l10n.aiModel, + values: [_automaticModel, ..._modelsByProvider[providerKind]!], + selected: _modelSelectionByProvider[providerKind]!, + labelFor: (model) => + model.isEmpty ? context.l10n.aiAutomaticRouting : model, + onSelected: (model) => setState( + () => _modelSelectionByProvider[providerKind] = model, + ), ), BusyMarkGroupedTextEntry( key: const ValueKey('ai-edit-instruction'), @@ -455,6 +498,10 @@ class _AiEditConfigurationDialogState kind: BusyMarkStatusKind.information, ), ], + const SizedBox( + key: ValueKey('ai-context-disclosure-bottom-spacing'), + height: BusyMarkSpacing.lg, + ), ], ); } @@ -483,6 +530,67 @@ class _AiEditConfigurationDialogState value, ]; + List get _selectedModelCandidates { + final selection = _modelSelectionByProvider[_provider]!; + if (selection.isNotEmpty) { + return [selection]; + } + final settings = ref.read(appSettingsControllerProvider); + final provider = ref.read(aiProviderRegistryProvider).require(_provider); + return { + ...provider.capabilities.modelsFor( + AiFeature.editDocument.spec.modelClass, + ), + if (settings.selectedAiModel(_provider).trim().isNotEmpty) + settings.selectedAiModel(_provider).trim(), + if (provider.capabilities.modelDiscovery) + ..._modelsByProvider[_provider]!, + }.toList(growable: false); + } + + void _selectProvider(AiProviderKind provider) { + if (provider == _provider) { + return; + } + setState(() => _provider = provider); + unawaited(_discoverModels(provider)); + } + + Future _discoverModels(AiProviderKind providerKind) async { + final provider = ref.read(aiProviderRegistryProvider).require(providerKind); + if (!provider.capabilities.modelDiscovery || + _modelDiscoveryCompleted.contains(providerKind) || + _modelDiscoveryTokens.containsKey(providerKind)) { + return; + } + final token = AiCancellationToken(); + _modelDiscoveryTokens[providerKind] = token; + try { + final models = await provider.listModels(cancellationToken: token); + if (!mounted || token.isCancelled) { + return; + } + final discovered = [ + for (final model in models) + if (model.supportsTextGeneration) model.name.trim(), + ]..removeWhere((model) => model.isEmpty); + setState(() { + _modelsByProvider[providerKind] = { + ..._modelsByProvider[providerKind]!, + ...discovered, + }.toList(growable: false); + _modelDiscoveryCompleted.add(providerKind); + }); + } on AiException { + // The configured model remains usable when optional discovery fails. + } finally { + if (identical(_modelDiscoveryTokens[providerKind], token)) { + _modelDiscoveryTokens.remove(providerKind); + } + await token.dispose(); + } + } + void _resolveChoices({bool notify = true}) { late final VoidCallback update; try { diff --git a/test/src/ai_edit_ui_test.dart b/test/src/ai_edit_ui_test.dart index e0554412..9ac66c79 100644 --- a/test/src/ai_edit_ui_test.dart +++ b/test/src/ai_edit_ui_test.dart @@ -132,6 +132,11 @@ void main() { AiProviderKind.gemini, ]); expect(providerSelector.selected, AiProviderKind.ollamaLocal); + final localModelSelector = tester.widget>( + find.byKey(const ValueKey('ai-edit-model')), + ); + expect(localModelSelector.values, ['', 'test-model']); + expect(localModelSelector.selected, ''); providerSelector.onSelected(AiProviderKind.gemini); await tester.pump(); expect( @@ -142,6 +147,25 @@ void main() { .selected, AiProviderKind.gemini, ); + final geminiModelSelector = tester.widget>( + find.byKey(const ValueKey('ai-edit-model')), + ); + expect(geminiModelSelector.values, [ + '', + 'gemini-3.6-flash', + 'gemini-3.5-flash-lite', + 'gemini-3.5-flash', + ]); + geminiModelSelector.onSelected('gemini-3.5-flash'); + await tester.pump(); + expect( + tester + .widget>( + find.byKey(const ValueKey('ai-edit-model')), + ) + .selected, + 'gemini-3.5-flash', + ); expect(find.byType(BusyMarkComboRow), findsOneWidget); expect(find.byType(BusyMarkComboRow), findsOneWidget); expect(find.byType(DropdownButtonFormField), findsNothing); @@ -167,6 +191,89 @@ void main() { expect(changeSelector.dy, lessThan(changeContent.dy)); expect(changeContent.dy, lessThan(contextSelector.dy)); expect(contextSelector.dy, lessThan(sharedContent.dy)); + final bottomSpacing = tester.widget( + find.byKey(const ValueKey('ai-context-disclosure-bottom-spacing')), + ); + expect(bottomSpacing.height, BusyMarkSpacing.lg); + }); + + testWidgets('Refine with AI sends the explicitly selected model', ( + tester, + ) async { + const source = 'Text to refine.\n'; + final settings = AppSettings.defaults().copyWith( + aiProviderPreference: AiProviderPreference.ollamaLocal, + aiOllamaModel: 'preferred-model', + aiModelRoutingPreference: AiModelRoutingPreference.automatic, + ); + final provider = _ImmediateAiProvider(model: 'alternate-model'); + final container = ProviderContainer( + overrides: [ + localSettingsStoreProvider.overrideWithValue( + _MemorySettingsStore(settings.toJson()), + ), + aiSecretStoreProvider.overrideWithValue(_MemoryAiSecretStore()), + aiProviderRegistryProvider.overrideWithValue( + AiProviderRegistry([provider]), + ), + ], + ); + addTearDown(container.dispose); + container.read(appSettingsControllerProvider); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Consumer( + builder: (context, ref, child) => ElevatedButton( + onPressed: () => unawaited( + showBusyMarkAiEdit( + context, + ref, + const AiEditorSnapshot( + documentSource: source, + selectionStart: 0, + selectionEnd: 15, + anchorOffset: 0, + sourceRevision: 1, + targetId: 'guide.md', + documentPath: 'guide.md', + ), + ), + ), + child: const Text('Open AI'), + ), + ), + ), + ), + ), + ); + await _pumpSettings(tester, container); + + await tester.tap(find.text('Open AI')); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const ValueKey('ai-edit-instruction')), + 'Improve this text.', + ); + final modelSelector = tester.widget>( + find.byKey(const ValueKey('ai-edit-model')), + ); + expect(modelSelector.values, ['', 'preferred-model', 'alternate-model']); + modelSelector.onSelected('alternate-model'); + await tester.pump(); + await tester.tap(find.text('Generate proposal')); + await tester.pumpAndSettle(); + + expect(provider.requests, hasLength(1)); + expect(provider.requests.single.modelCandidates, ['alternate-model']); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); }); testWidgets('AI proposal uses an explicitly selected provider', ( @@ -544,9 +651,21 @@ class _ImmediateAiProvider implements AiProvider { AiProviderCapabilities get capabilities => AiProviderCapabilities( kind: kind, streaming: true, - modelDiscovery: false, + modelDiscovery: kind == AiProviderKind.ollamaLocal, maximumConcurrentRequests: 1, - recommendedModels: {}, + recommendedModels: switch (kind) { + AiProviderKind.ollamaLocal => {}, + AiProviderKind.openAi => const { + AiModelClass.fast: ['gpt-5.6-luna'], + AiModelClass.balanced: ['gpt-5.6-terra'], + AiModelClass.strong: ['gpt-5.6-sol'], + }, + AiProviderKind.gemini => const { + AiModelClass.fast: ['gemini-3.5-flash-lite'], + AiModelClass.balanced: ['gemini-3.6-flash'], + AiModelClass.strong: ['gemini-3.5-flash'], + }, + }, ); @override @@ -563,7 +682,7 @@ class _ImmediateAiProvider implements AiProvider { @override Future> listModels({ AiCancellationToken? cancellationToken, - }) async => const [AiModelInfo(name: 'test-model')]; + }) async => [AiModelInfo(name: model)]; @override Future checkHealth({ @@ -616,6 +735,19 @@ ProviderContainer _aiContainer({Map? secrets}) { _MemorySettingsStore(settings.toJson()), ), aiSecretStoreProvider.overrideWithValue(_MemoryAiSecretStore(secrets)), + aiProviderRegistryProvider.overrideWithValue( + AiProviderRegistry([ + _ImmediateAiProvider(), + _ImmediateAiProvider( + kind: AiProviderKind.openAi, + model: 'gpt-5.6-terra', + ), + _ImmediateAiProvider( + kind: AiProviderKind.gemini, + model: 'gemini-3.6-flash', + ), + ]), + ), ], ); container.read(appSettingsControllerProvider); diff --git a/test/src/localization_audit_test.dart b/test/src/localization_audit_test.dart index 1b5324c3..b53aed77 100644 --- a/test/src/localization_audit_test.dart +++ b/test/src/localization_audit_test.dart @@ -679,7 +679,7 @@ const _localeSpecificEnglishMatches = >{ 'gitCommit', }, 'nb': {'systemTheme', 'systemLanguage', 'gitCommit', 'instanceStatus'}, - 'pl': {'folder', 'foldKindTag'}, + 'pl': {'folder', 'foldKindTag', 'aiModel'}, 'pt': { 'editor', 'link', From fb9b3214b8d7afd7416b427d39255a8c3340164c Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 21 Aug 2026 18:47:12 -0700 Subject: [PATCH 13/38] Compact document format status --- lib/l10n/app_ar.arb | 2 + lib/l10n/app_de.arb | 2 + lib/l10n/app_en.arb | 4 + lib/l10n/app_es.arb | 2 + lib/l10n/app_et.arb | 2 + lib/l10n/app_fa.arb | 2 + lib/l10n/app_fr.arb | 2 + lib/l10n/app_hi.arb | 2 + lib/l10n/app_it.arb | 2 + lib/l10n/app_nb.arb | 2 + lib/l10n/app_pl.arb | 2 + lib/l10n/app_pt.arb | 2 + lib/l10n/app_ru.arb | 2 + lib/l10n/app_uk.arb | 2 + lib/l10n/generated/app_localizations.dart | 12 +++ lib/l10n/generated/app_localizations_ar.dart | 10 +++ lib/l10n/generated/app_localizations_de.dart | 10 +++ lib/l10n/generated/app_localizations_en.dart | 10 +++ lib/l10n/generated/app_localizations_es.dart | 10 +++ lib/l10n/generated/app_localizations_et.dart | 10 +++ lib/l10n/generated/app_localizations_fa.dart | 10 +++ lib/l10n/generated/app_localizations_fr.dart | 10 +++ lib/l10n/generated/app_localizations_hi.dart | 10 +++ lib/l10n/generated/app_localizations_it.dart | 10 +++ lib/l10n/generated/app_localizations_nb.dart | 10 +++ lib/l10n/generated/app_localizations_pl.dart | 10 +++ lib/l10n/generated/app_localizations_pt.dart | 10 +++ lib/l10n/generated/app_localizations_ru.dart | 10 +++ lib/l10n/generated/app_localizations_uk.dart | 10 +++ .../document_format_indicator.dart | 52 +++++++++++ .../presentation/workspace_screen.dart | 53 +++-------- test/src/document_format_indicator_test.dart | 90 +++++++++++++++++++ 32 files changed, 337 insertions(+), 40 deletions(-) create mode 100644 lib/src/workspace/presentation/document_format_indicator.dart create mode 100644 test/src/document_format_indicator_test.dart diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index fdc66275..402cded5 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -2687,6 +2687,8 @@ "workspaceReplaceDiskContent": "المحتوى المحفوظ على القرص", "selectFileMatches": "تحديد كل المطابقات وعددها {count}", "workspaceReplaceApplied": "تم استبدال {matches} مطابقة في {files} ملفًا؛ وتم تخطي {skipped}.", + "documentFormatWithFinalNewline": "⁨{encoding}⁩ · ⁨{lineEnding}⁩ · سطر جديد نهائي", + "documentFormatWithoutFinalNewline": "⁨{encoding}⁩ · ⁨{lineEnding}⁩ · بلا سطر جديد نهائي", "normalizeLineEndings": "توحيد نهايات الأسطر", "workspaceReplaceMixedLineEndings": "يستخدم الملف ⁨{fileName}⁩ نهايات أسطر مختلطة. اختر التنسيق قبل الاستبدال.", "mixedLineEndingsSavePrompt": "يحتوي هذا المستند على نهايات أسطر مختلطة. اختر تنسيقًا.", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 6a7a7368..297174e1 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -2708,6 +2708,8 @@ "workspaceReplaceDiskContent": "Gespeicherter Festplatteninhalt", "selectFileMatches": "Alle {count} Treffer auswählen", "workspaceReplaceApplied": "{matches} Treffer in {files} Dateien ersetzt; {skipped} übersprungen.", + "documentFormatWithFinalNewline": "{encoding} · {lineEnding} · Abschließender Zeilenumbruch", + "documentFormatWithoutFinalNewline": "{encoding} · {lineEnding} · Kein abschließender Zeilenumbruch", "normalizeLineEndings": "Zeilenenden normalisieren", "workspaceReplaceMixedLineEndings": "{fileName} verwendet gemischte Zeilenenden. Wählen Sie vor dem Ersetzen das gewünschte Format.", "mixedLineEndingsSavePrompt": "Dieses Dokument enthält gemischte Zeilenenden. Wählen Sie ein Format.", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index cc57783d..ed41fc7e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -909,6 +909,10 @@ "skipped": {"type": "int"} } }, + "documentFormatWithFinalNewline": "{encoding} · {lineEnding} · Final newline", + "@documentFormatWithFinalNewline": {"description": "Document format tooltip when the file ends with a newline.", "placeholders": {"encoding": {"type": "String"}, "lineEnding": {"type": "String"}}}, + "documentFormatWithoutFinalNewline": "{encoding} · {lineEnding} · No final newline", + "@documentFormatWithoutFinalNewline": {"description": "Document format tooltip when the file does not end with a newline.", "placeholders": {"encoding": {"type": "String"}, "lineEnding": {"type": "String"}}}, "normalizeLineEndings": "Normalize line endings", "@normalizeLineEndings": {"description": "Dialog title for selecting a line-ending style."}, "mixedLineEndingsSavePrompt": "This document contains mixed line endings. Choose a format.", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index ab56aa32..a6e0f59f 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -2708,6 +2708,8 @@ "workspaceReplaceDiskContent": "Contenido guardado en disco", "selectFileMatches": "Seleccionar las {count} coincidencias", "workspaceReplaceApplied": "Se reemplazaron {matches} coincidencias en {files} archivos; se omitieron {skipped}.", + "documentFormatWithFinalNewline": "{encoding} · {lineEnding} · Salto de línea final", + "documentFormatWithoutFinalNewline": "{encoding} · {lineEnding} · Sin salto de línea final", "normalizeLineEndings": "Normalizar finales de línea", "workspaceReplaceMixedLineEndings": "{fileName} usa finales de línea mezclados. Elige el formato antes de reemplazar.", "mixedLineEndingsSavePrompt": "Este documento contiene finales de línea mezclados. Elige un formato.", diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index ec067208..7459215e 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -1896,6 +1896,8 @@ "workspaceReplaceDiskContent": "Kettale salvestatud sisu", "selectFileMatches": "Vali kõik {count} vastet", "workspaceReplaceApplied": "Asendati {matches} vastet {files} failis; vahele jäeti {skipped}.", + "documentFormatWithFinalNewline": "{encoding} · {lineEnding} · Lõpus on reavahetus", + "documentFormatWithoutFinalNewline": "{encoding} · {lineEnding} · Lõpus pole reavahetust", "normalizeLineEndings": "Normaliseeri reavahetused", "workspaceReplaceMixedLineEndings": "Fail {fileName} kasutab eri tüüpi reavahetusi. Vali enne asendamist vorming.", "mixedLineEndingsSavePrompt": "See dokument sisaldab eri tüüpi reavahetusi. Vali vorming.", diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 83b45fa0..36cafc8e 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -2706,6 +2706,8 @@ "workspaceReplaceDiskContent": "محتوای ذخیره‌شده روی دیسک", "selectFileMatches": "انتخاب هر {count} مورد", "workspaceReplaceApplied": "{matches} مورد در {files} فایل جایگزین شد؛ {skipped} مورد نادیده گرفته شد.", + "documentFormatWithFinalNewline": "⁨{encoding}⁩ · ⁨{lineEnding}⁩ · خط جدید پایانی", + "documentFormatWithoutFinalNewline": "⁨{encoding}⁩ · ⁨{lineEnding}⁩ · بدون خط جدید پایانی", "normalizeLineEndings": "یکسان‌سازی پایان خط‌ها", "workspaceReplaceMixedLineEndings": "فایل ⁨{fileName}⁩ پایان خط‌های ترکیبی دارد. پیش از جایگزینی قالب را انتخاب کنید.", "mixedLineEndingsSavePrompt": "این سند پایان خط‌های ترکیبی دارد. یک قالب انتخاب کنید.", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index cb2c5568..1d23fe16 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -2708,6 +2708,8 @@ "workspaceReplaceDiskContent": "Contenu enregistré sur le disque", "selectFileMatches": "Sélectionner les {count} occurrences", "workspaceReplaceApplied": "{matches} occurrences remplacées dans {files} fichiers ; {skipped} ignorées.", + "documentFormatWithFinalNewline": "{encoding} · {lineEnding} · Saut de ligne final", + "documentFormatWithoutFinalNewline": "{encoding} · {lineEnding} · Aucun saut de ligne final", "normalizeLineEndings": "Normaliser les fins de ligne", "workspaceReplaceMixedLineEndings": "{fileName} utilise plusieurs types de fins de ligne. Choisissez le format avant le remplacement.", "mixedLineEndingsSavePrompt": "Ce document contient plusieurs types de fins de ligne. Choisissez un format.", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index f0863535..d8878385 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -2687,6 +2687,8 @@ "workspaceReplaceDiskContent": "डिस्क पर सहेजी सामग्री", "selectFileMatches": "सभी {count} मिलान चुनें", "workspaceReplaceApplied": "{files} फ़ाइलों में {matches} मिलान बदले गए; {skipped} छोड़े गए।", + "documentFormatWithFinalNewline": "{encoding} · {lineEnding} · अंतिम नई पंक्ति", + "documentFormatWithoutFinalNewline": "{encoding} · {lineEnding} · अंतिम नई पंक्ति नहीं", "normalizeLineEndings": "पंक्ति अंत सामान्य करें", "workspaceReplaceMixedLineEndings": "{fileName} में मिले-जुले पंक्ति अंत हैं। बदलने से पहले प्रारूप चुनें।", "mixedLineEndingsSavePrompt": "इस दस्तावेज़ में मिले-जुले पंक्ति अंत हैं। कोई प्रारूप चुनें।", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index d6e66e6b..a53e69e9 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -2685,6 +2685,8 @@ "workspaceReplaceDiskContent": "Contenuto salvato su disco", "selectFileMatches": "Seleziona tutte le {count} corrispondenze", "workspaceReplaceApplied": "Sostituite {matches} corrispondenze in {files} file; {skipped} ignorate.", + "documentFormatWithFinalNewline": "{encoding} · {lineEnding} · A capo finale", + "documentFormatWithoutFinalNewline": "{encoding} · {lineEnding} · Nessun a capo finale", "normalizeLineEndings": "Normalizza terminatori di riga", "workspaceReplaceMixedLineEndings": "{fileName} usa terminatori di riga misti. Scegli il formato prima di sostituire.", "mixedLineEndingsSavePrompt": "Questo documento contiene terminatori di riga misti. Scegli un formato.", diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb index d82db70a..7867a01f 100644 --- a/lib/l10n/app_nb.arb +++ b/lib/l10n/app_nb.arb @@ -2685,6 +2685,8 @@ "workspaceReplaceDiskContent": "Innhold lagret på disk", "selectFileMatches": "Velg alle {count} treff", "workspaceReplaceApplied": "Erstattet {matches} treff i {files} filer; hoppet over {skipped}.", + "documentFormatWithFinalNewline": "{encoding} · {lineEnding} · Avsluttende linjeskift", + "documentFormatWithoutFinalNewline": "{encoding} · {lineEnding} · Ingen avsluttende linjeskift", "normalizeLineEndings": "Normaliser linjeslutt", "workspaceReplaceMixedLineEndings": "{fileName} bruker blandede linjeslutt. Velg format før du erstatter.", "mixedLineEndingsSavePrompt": "Dette dokumentet inneholder blandede linjeslutt. Velg et format.", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index f35d23aa..793ad771 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -2703,6 +2703,8 @@ "workspaceReplaceDiskContent": "Zawartość zapisana na dysku", "selectFileMatches": "Wybierz wszystkie dopasowania ({count})", "workspaceReplaceApplied": "Zamieniono {matches} dopasowań w {files} plikach; pominięto {skipped}.", + "documentFormatWithFinalNewline": "{encoding} · {lineEnding} · Końcowy znak nowego wiersza", + "documentFormatWithoutFinalNewline": "{encoding} · {lineEnding} · Brak końcowego znaku nowego wiersza", "normalizeLineEndings": "Normalizuj zakończenia wierszy", "workspaceReplaceMixedLineEndings": "{fileName} używa mieszanych zakończeń wierszy. Wybierz format przed zamianą.", "mixedLineEndingsSavePrompt": "Ten dokument zawiera mieszane zakończenia wierszy. Wybierz format.", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 2766368a..9611d125 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -2685,6 +2685,8 @@ "workspaceReplaceDiskContent": "Conteúdo salvo no disco", "selectFileMatches": "Selecionar todas as {count} correspondências", "workspaceReplaceApplied": "Foram substituídas {matches} correspondências em {files} arquivos; {skipped} ignoradas.", + "documentFormatWithFinalNewline": "{encoding} · {lineEnding} · Quebra de linha final", + "documentFormatWithoutFinalNewline": "{encoding} · {lineEnding} · Sem quebra de linha final", "normalizeLineEndings": "Normalizar finais de linha", "workspaceReplaceMixedLineEndings": "{fileName} usa finais de linha mistos. Escolha o formato antes de substituir.", "mixedLineEndingsSavePrompt": "Este documento contém finais de linha mistos. Escolha um formato.", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 2959b19e..ce71c32b 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -2703,6 +2703,8 @@ "workspaceReplaceDiskContent": "Содержимое, сохранённое на диске", "selectFileMatches": "Выбрать все совпадения: {count}", "workspaceReplaceApplied": "Заменено совпадений: {matches} в файлах: {files}; пропущено: {skipped}.", + "documentFormatWithFinalNewline": "{encoding} · {lineEnding} · Конечный перевод строки", + "documentFormatWithoutFinalNewline": "{encoding} · {lineEnding} · Нет конечного перевода строки", "normalizeLineEndings": "Нормализовать окончания строк", "workspaceReplaceMixedLineEndings": "В {fileName} используются смешанные окончания строк. Выберите формат перед заменой.", "mixedLineEndingsSavePrompt": "В документе используются смешанные окончания строк. Выберите формат.", diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index 4f7f260b..7d8bc3c4 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -2703,6 +2703,8 @@ "workspaceReplaceDiskContent": "Вміст, збережений на диску", "selectFileMatches": "Вибрати всі збіги: {count}", "workspaceReplaceApplied": "Замінено збігів: {matches} у файлах: {files}; пропущено: {skipped}.", + "documentFormatWithFinalNewline": "{encoding} · {lineEnding} · Кінцеве перенесення рядка", + "documentFormatWithoutFinalNewline": "{encoding} · {lineEnding} · Немає кінцевого перенесення рядка", "normalizeLineEndings": "Нормалізувати закінчення рядків", "workspaceReplaceMixedLineEndings": "У {fileName} використовуються змішані закінчення рядків. Виберіть формат перед заміною.", "mixedLineEndingsSavePrompt": "У документі використовуються змішані закінчення рядків. Виберіть формат.", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 8e04e1b8..7bac235c 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -2577,6 +2577,18 @@ abstract class AppLocalizations { /// **'Replaced {matches} matches in {files} files; skipped {skipped}.'** String workspaceReplaceApplied(int matches, int files, int skipped); + /// Document format tooltip when the file ends with a newline. + /// + /// In en, this message translates to: + /// **'{encoding} · {lineEnding} · Final newline'** + String documentFormatWithFinalNewline(String encoding, String lineEnding); + + /// Document format tooltip when the file does not end with a newline. + /// + /// In en, this message translates to: + /// **'{encoding} · {lineEnding} · No final newline'** + String documentFormatWithoutFinalNewline(String encoding, String lineEnding); + /// Dialog title for selecting a line-ending style. /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index bce05930..f91c8c83 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -1394,6 +1394,16 @@ class AppLocalizationsAr extends AppLocalizations { return 'تم استبدال $matches مطابقة في $files ملفًا؛ وتم تخطي $skipped.'; } + @override + String documentFormatWithFinalNewline(String encoding, String lineEnding) { + return '⁨$encoding⁩ · ⁨$lineEnding⁩ · سطر جديد نهائي'; + } + + @override + String documentFormatWithoutFinalNewline(String encoding, String lineEnding) { + return '⁨$encoding⁩ · ⁨$lineEnding⁩ · بلا سطر جديد نهائي'; + } + @override String get normalizeLineEndings => 'توحيد نهايات الأسطر'; diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index a383efa6..76f20fb7 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -1406,6 +1406,16 @@ class AppLocalizationsDe extends AppLocalizations { return '$matches Treffer in $files Dateien ersetzt; $skipped übersprungen.'; } + @override + String documentFormatWithFinalNewline(String encoding, String lineEnding) { + return '$encoding · $lineEnding · Abschließender Zeilenumbruch'; + } + + @override + String documentFormatWithoutFinalNewline(String encoding, String lineEnding) { + return '$encoding · $lineEnding · Kein abschließender Zeilenumbruch'; + } + @override String get normalizeLineEndings => 'Zeilenenden normalisieren'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 67c4d8c4..2e083d4f 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -1390,6 +1390,16 @@ class AppLocalizationsEn extends AppLocalizations { return 'Replaced $matches matches in $files files; skipped $skipped.'; } + @override + String documentFormatWithFinalNewline(String encoding, String lineEnding) { + return '$encoding · $lineEnding · Final newline'; + } + + @override + String documentFormatWithoutFinalNewline(String encoding, String lineEnding) { + return '$encoding · $lineEnding · No final newline'; + } + @override String get normalizeLineEndings => 'Normalize line endings'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 2c9633e9..ef16bc17 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -1403,6 +1403,16 @@ class AppLocalizationsEs extends AppLocalizations { return 'Se reemplazaron $matches coincidencias en $files archivos; se omitieron $skipped.'; } + @override + String documentFormatWithFinalNewline(String encoding, String lineEnding) { + return '$encoding · $lineEnding · Salto de línea final'; + } + + @override + String documentFormatWithoutFinalNewline(String encoding, String lineEnding) { + return '$encoding · $lineEnding · Sin salto de línea final'; + } + @override String get normalizeLineEndings => 'Normalizar finales de línea'; diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index a2260760..ed610950 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -1387,6 +1387,16 @@ class AppLocalizationsEt extends AppLocalizations { return 'Asendati $matches vastet $files failis; vahele jäeti $skipped.'; } + @override + String documentFormatWithFinalNewline(String encoding, String lineEnding) { + return '$encoding · $lineEnding · Lõpus on reavahetus'; + } + + @override + String documentFormatWithoutFinalNewline(String encoding, String lineEnding) { + return '$encoding · $lineEnding · Lõpus pole reavahetust'; + } + @override String get normalizeLineEndings => 'Normaliseeri reavahetused'; diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index 6dd13c2f..eb8a6d9a 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -1424,6 +1424,16 @@ class AppLocalizationsFa extends AppLocalizations { return '$matches مورد در $files فایل جایگزین شد؛ $skipped مورد نادیده گرفته شد.'; } + @override + String documentFormatWithFinalNewline(String encoding, String lineEnding) { + return '⁨$encoding⁩ · ⁨$lineEnding⁩ · خط جدید پایانی'; + } + + @override + String documentFormatWithoutFinalNewline(String encoding, String lineEnding) { + return '⁨$encoding⁩ · ⁨$lineEnding⁩ · بدون خط جدید پایانی'; + } + @override String get normalizeLineEndings => 'یکسان‌سازی پایان خط‌ها'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 445a7137..5f18d45b 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -1404,6 +1404,16 @@ class AppLocalizationsFr extends AppLocalizations { return '$matches occurrences remplacées dans $files fichiers ; $skipped ignorées.'; } + @override + String documentFormatWithFinalNewline(String encoding, String lineEnding) { + return '$encoding · $lineEnding · Saut de ligne final'; + } + + @override + String documentFormatWithoutFinalNewline(String encoding, String lineEnding) { + return '$encoding · $lineEnding · Aucun saut de ligne final'; + } + @override String get normalizeLineEndings => 'Normaliser les fins de ligne'; diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 9024a824..c1e9e481 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -1380,6 +1380,16 @@ class AppLocalizationsHi extends AppLocalizations { return '$files फ़ाइलों में $matches मिलान बदले गए; $skipped छोड़े गए।'; } + @override + String documentFormatWithFinalNewline(String encoding, String lineEnding) { + return '$encoding · $lineEnding · अंतिम नई पंक्ति'; + } + + @override + String documentFormatWithoutFinalNewline(String encoding, String lineEnding) { + return '$encoding · $lineEnding · अंतिम नई पंक्ति नहीं'; + } + @override String get normalizeLineEndings => 'पंक्ति अंत सामान्य करें'; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index f1434296..cb25a059 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -1399,6 +1399,16 @@ class AppLocalizationsIt extends AppLocalizations { return 'Sostituite $matches corrispondenze in $files file; $skipped ignorate.'; } + @override + String documentFormatWithFinalNewline(String encoding, String lineEnding) { + return '$encoding · $lineEnding · A capo finale'; + } + + @override + String documentFormatWithoutFinalNewline(String encoding, String lineEnding) { + return '$encoding · $lineEnding · Nessun a capo finale'; + } + @override String get normalizeLineEndings => 'Normalizza terminatori di riga'; diff --git a/lib/l10n/generated/app_localizations_nb.dart b/lib/l10n/generated/app_localizations_nb.dart index a1a3ec1f..879aa17b 100644 --- a/lib/l10n/generated/app_localizations_nb.dart +++ b/lib/l10n/generated/app_localizations_nb.dart @@ -1389,6 +1389,16 @@ class AppLocalizationsNb extends AppLocalizations { return 'Erstattet $matches treff i $files filer; hoppet over $skipped.'; } + @override + String documentFormatWithFinalNewline(String encoding, String lineEnding) { + return '$encoding · $lineEnding · Avsluttende linjeskift'; + } + + @override + String documentFormatWithoutFinalNewline(String encoding, String lineEnding) { + return '$encoding · $lineEnding · Ingen avsluttende linjeskift'; + } + @override String get normalizeLineEndings => 'Normaliser linjeslutt'; diff --git a/lib/l10n/generated/app_localizations_pl.dart b/lib/l10n/generated/app_localizations_pl.dart index 34de0401..88096634 100644 --- a/lib/l10n/generated/app_localizations_pl.dart +++ b/lib/l10n/generated/app_localizations_pl.dart @@ -1409,6 +1409,16 @@ class AppLocalizationsPl extends AppLocalizations { return 'Zamieniono $matches dopasowań w $files plikach; pominięto $skipped.'; } + @override + String documentFormatWithFinalNewline(String encoding, String lineEnding) { + return '$encoding · $lineEnding · Końcowy znak nowego wiersza'; + } + + @override + String documentFormatWithoutFinalNewline(String encoding, String lineEnding) { + return '$encoding · $lineEnding · Brak końcowego znaku nowego wiersza'; + } + @override String get normalizeLineEndings => 'Normalizuj zakończenia wierszy'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 451c4794..20ae1e3b 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -1398,6 +1398,16 @@ class AppLocalizationsPt extends AppLocalizations { return 'Foram substituídas $matches correspondências em $files arquivos; $skipped ignoradas.'; } + @override + String documentFormatWithFinalNewline(String encoding, String lineEnding) { + return '$encoding · $lineEnding · Quebra de linha final'; + } + + @override + String documentFormatWithoutFinalNewline(String encoding, String lineEnding) { + return '$encoding · $lineEnding · Sem quebra de linha final'; + } + @override String get normalizeLineEndings => 'Normalizar finais de linha'; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 1094c420..3b38a64d 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -1404,6 +1404,16 @@ class AppLocalizationsRu extends AppLocalizations { return 'Заменено совпадений: $matches в файлах: $files; пропущено: $skipped.'; } + @override + String documentFormatWithFinalNewline(String encoding, String lineEnding) { + return '$encoding · $lineEnding · Конечный перевод строки'; + } + + @override + String documentFormatWithoutFinalNewline(String encoding, String lineEnding) { + return '$encoding · $lineEnding · Нет конечного перевода строки'; + } + @override String get normalizeLineEndings => 'Нормализовать окончания строк'; diff --git a/lib/l10n/generated/app_localizations_uk.dart b/lib/l10n/generated/app_localizations_uk.dart index fafc0cff..d31bb0de 100644 --- a/lib/l10n/generated/app_localizations_uk.dart +++ b/lib/l10n/generated/app_localizations_uk.dart @@ -1411,6 +1411,16 @@ class AppLocalizationsUk extends AppLocalizations { return 'Замінено збігів: $matches у файлах: $files; пропущено: $skipped.'; } + @override + String documentFormatWithFinalNewline(String encoding, String lineEnding) { + return '$encoding · $lineEnding · Кінцеве перенесення рядка'; + } + + @override + String documentFormatWithoutFinalNewline(String encoding, String lineEnding) { + return '$encoding · $lineEnding · Немає кінцевого перенесення рядка'; + } + @override String get normalizeLineEndings => 'Нормалізувати закінчення рядків'; diff --git a/lib/src/workspace/presentation/document_format_indicator.dart b/lib/src/workspace/presentation/document_format_indicator.dart new file mode 100644 index 00000000..56a85ef3 --- /dev/null +++ b/lib/src/workspace/presentation/document_format_indicator.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; + +import '../../app/busymark_design.dart'; +import '../../app/localization.dart'; +import '../text_format_metadata.dart'; + +class BusyMarkDocumentFormatIndicator extends StatelessWidget { + const BusyMarkDocumentFormatIndicator({super.key, required this.format}); + + final TextFormatMetadata format; + + @override + Widget build(BuildContext context) { + final colors = BusyMarkSurfaceColors.of(context); + final lineEnding = switch (format.lineEnding) { + DocumentLineEnding.none || DocumentLineEnding.lf => 'LF', + DocumentLineEnding.crlf => 'CRLF', + DocumentLineEnding.mixed => 'LF/CRLF', + }; + final encoding = format.hasUtf8Bom ? 'UTF-8 BOM' : 'UTF-8'; + final details = format.hasFinalNewline + ? context.l10n.documentFormatWithFinalNewline(encoding, lineEnding) + : context.l10n.documentFormatWithoutFinalNewline(encoding, lineEnding); + + return Tooltip( + message: details, + child: Semantics( + label: details, + child: DecoratedBox( + decoration: BoxDecoration( + color: colors.headerbarFlat, + border: Border.all(color: colors.subtleBorder), + borderRadius: BorderRadius.circular(BusyMarkRadius.pill), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: BusyMarkSpacing.sm, + vertical: BusyMarkSpacing.xs, + ), + child: Text( + lineEnding, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colors.mutedForeground, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index 99ff1f78..2ad539b3 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -77,6 +77,7 @@ import '../workspace_model.dart'; import '../workspace_message.dart'; import '../workspace_safety.dart'; import '../workspace_tabs.dart'; +import 'document_format_indicator.dart'; import 'welcome_screen.dart'; import 'writerside_instance_dialog.dart'; @@ -789,8 +790,6 @@ class WorkspaceScreen extends ConsumerWidget { children: workspaceChildren, ), ), - if (state.activeBuffer case final buffer?) - _DocumentStatusBar(buffer: buffer), ], ), ), @@ -7887,6 +7886,12 @@ class _EditorTabStrip extends ConsumerWidget { diff: entry.kind == WorkspaceTabKind.gitDiff, active: entry.active, dirty: _tabDirty(workspace, entry), + format: entry.active && entry.bufferId != null + ? state.documentBuffers + .where((buffer) => buffer.id == entry.bufferId) + .firstOrNull + ?.format + : null, onSelected: () => _selectTab(context, ref, workspace, entry), onClose: () => _closeTab(context, ref, workspace, entry), ); @@ -7990,6 +7995,7 @@ class _WorkspaceTabButton extends StatelessWidget { required this.diff, required this.active, required this.dirty, + required this.format, required this.onSelected, required this.onClose, }); @@ -7999,6 +8005,7 @@ class _WorkspaceTabButton extends StatelessWidget { final bool diff; final bool active; final bool dirty; + final TextFormatMetadata? format; final VoidCallback onSelected; final VoidCallback onClose; @@ -8067,6 +8074,10 @@ class _WorkspaceTabButton extends StatelessWidget { ), ), const SizedBox(width: BusyMarkSpacing.xs), + if (format case final format?) ...[ + BusyMarkDocumentFormatIndicator(format: format), + const SizedBox(width: BusyMarkSpacing.xs), + ], BusyMarkCompactIconButton( tooltip: MaterialLocalizations.of(context).closeButtonTooltip, icon: BusyMarkGlyphs.clear, @@ -10473,44 +10484,6 @@ class _RecoveredDocumentBanner extends StatelessWidget { } } -class _DocumentStatusBar extends StatelessWidget { - const _DocumentStatusBar({required this.buffer}); - - final DocumentBuffer buffer; - - @override - Widget build(BuildContext context) { - final colors = BusyMarkSurfaceColors.of(context); - final format = buffer.format; - final labels = [ - 'UTF-8${format.hasUtf8Bom ? ' BOM' : ''}', - format.statusLabel, - format.hasFinalNewline ? 'Final newline' : 'No final newline', - ]; - return DecoratedBox( - decoration: BoxDecoration( - color: colors.headerbarFlat, - border: Border(top: BorderSide(color: colors.subtleBorder)), - ), - child: SizedBox( - height: BusyMarkSizes.paneHeaderHeight * 0.7, - child: Align( - alignment: AlignmentDirectional.centerEnd, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: BusyMarkSpacing.md), - child: Text( - labels.join(' • '), - style: Theme.of( - context, - ).textTheme.labelSmall?.copyWith(color: colors.mutedForeground), - ), - ), - ), - ), - ); - } -} - class _PreviewPane extends StatelessWidget { const _PreviewPane({ required this.preview, diff --git a/test/src/document_format_indicator_test.dart b/test/src/document_format_indicator_test.dart new file mode 100644 index 00000000..a84f09be --- /dev/null +++ b/test/src/document_format_indicator_test.dart @@ -0,0 +1,90 @@ +import 'package:busymark/l10n/generated/app_localizations.dart'; +import 'package:busymark/src/app/app_theme.dart'; +import 'package:busymark/src/app/busymark_design.dart'; +import 'package:busymark/src/workspace/presentation/document_format_indicator.dart'; +import 'package:busymark/src/workspace/text_format_metadata.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('shows only a compact line-ending label', (tester) async { + await _pumpIndicator( + tester, + const TextFormatMetadata( + hasUtf8Bom: false, + lineEnding: DocumentLineEnding.lf, + hasFinalNewline: true, + lfCount: 2, + crlfCount: 0, + crCount: 0, + ), + ); + + expect(find.text('LF'), findsOneWidget); + expect(find.textContaining('UTF-8'), findsNothing); + expect(find.textContaining('Final newline'), findsNothing); + expect( + tester.widget(find.byType(Tooltip)).message, + 'UTF-8 · LF · Final newline', + ); + final size = tester.getSize(find.byType(BusyMarkDocumentFormatIndicator)); + expect(size.width, lessThan(80)); + expect(size.height, lessThan(32)); + }); + + testWidgets('keeps encoding and final-newline details in the tooltip', ( + tester, + ) async { + await _pumpIndicator( + tester, + const TextFormatMetadata( + hasUtf8Bom: true, + lineEnding: DocumentLineEnding.crlf, + hasFinalNewline: false, + lfCount: 0, + crlfCount: 2, + crCount: 0, + ), + ); + + expect(find.text('CRLF'), findsOneWidget); + expect( + tester.widget(find.byType(Tooltip)).message, + 'UTF-8 BOM · CRLF · No final newline', + ); + }); + + testWidgets('uses a compact technical label for mixed line endings', ( + tester, + ) async { + await _pumpIndicator( + tester, + const TextFormatMetadata( + hasUtf8Bom: false, + lineEnding: DocumentLineEnding.mixed, + hasFinalNewline: true, + lfCount: 1, + crlfCount: 1, + crCount: 0, + ), + ); + + expect(find.text('LF/CRLF'), findsOneWidget); + }); +} + +Future _pumpIndicator(WidgetTester tester, TextFormatMetadata format) { + return tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: buildBusyMarkTheme( + brightness: Brightness.dark, + accentColor: BusyMarkLinuxPalette.blueAccent, + ), + home: Scaffold( + body: Center(child: BusyMarkDocumentFormatIndicator(format: format)), + ), + ), + ); +} From 85fa56676e728acb7747918b8f89d8d7eaf43359 Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 21 Aug 2026 18:47:13 -0700 Subject: [PATCH 14/38] Persist recovery before session state --- lib/src/workspace/workspace_controller.dart | 18 ++++---- test/src/workspace_controller_test.dart | 51 +++++++++++++++++++++ 2 files changed, 60 insertions(+), 9 deletions(-) diff --git a/lib/src/workspace/workspace_controller.dart b/lib/src/workspace/workspace_controller.dart index 625f6695..8d4ee073 100644 --- a/lib/src/workspace/workspace_controller.dart +++ b/lib/src/workspace/workspace_controller.dart @@ -497,8 +497,8 @@ class WorkspaceController extends Notifier { await _recoveryStart; final workspace = snapshot.workspace; if (workspace == null) { - await _sessionStore.clear(); await _recoveryStore.writeEntries(const []); + await _sessionStore.clear(); return; } final workspacePath = switch (workspace.kind) { @@ -508,6 +508,14 @@ class WorkspaceController extends Notifier { WorkspaceKind.markdownFolder || WorkspaceKind.writersideModule => workspace.rootPath, }; + await _recoveryStore.writeEntries([ + for (final buffer in snapshot.documentBuffers) + if (buffer.isDirty || buffer.isUntitled) + DocumentRecoveryEntry.fromBuffer( + buffer, + workspacePath: workspacePath, + ), + ]); await _sessionStore.save( WorkspaceSessionSnapshot( workspacePath: workspacePath, @@ -523,14 +531,6 @@ class WorkspaceController extends Notifier { ], ), ); - await _recoveryStore.writeEntries([ - for (final buffer in snapshot.documentBuffers) - if (buffer.isDirty || buffer.isUntitled) - DocumentRecoveryEntry.fromBuffer( - buffer, - workspacePath: workspacePath, - ), - ]); } Future markCleanShutdown() async { diff --git a/test/src/workspace_controller_test.dart b/test/src/workspace_controller_test.dart index 7517541a..9da7d3de 100644 --- a/test/src/workspace_controller_test.dart +++ b/test/src/workspace_controller_test.dart @@ -1317,6 +1317,43 @@ void main() { expect(recoveryStore.value.cleanShutdown, isFalse); expect(recoveryStore.value.entries, isNotEmpty); }); + + test( + 'recovery text remains restorable when the later session save fails', + () async { + final recoveryStore = MemoryDocumentRecoveryStore(); + final sessionStore = _FailingDocumentSessionStore(); + final harness = await _createControllerHarness( + sessionStore: sessionStore, + recoveryStore: recoveryStore, + ); + + await harness.controller.createMarkdownFile(); + harness.controller.updateActiveText('Latest unsaved text'); + await harness.controller.flushPersistence(); + + // Simulate an older or absent recovery file immediately before the + // persistence attempt whose session write fails. + await recoveryStore.writeEntries(const []); + sessionStore.failSave = true; + + await expectLater( + harness.controller.flushPersistence(), + throwsA(isA()), + ); + + expect(recoveryStore.value.entries, hasLength(1)); + expect(recoveryStore.value.entries.single.text, 'Latest unsaved text'); + + final restored = await _createControllerHarness( + sessionStore: MemoryDocumentSessionStore(), + recoveryStore: recoveryStore, + ); + expect(await restored.controller.restorePreviousSession(), isTrue); + expect(restored.controller.state.activeText, 'Latest unsaved text'); + expect(restored.controller.state.activeBuffer?.recovered, isTrue); + }, + ); } Future _waitFor(bool Function() condition) async { @@ -1485,6 +1522,8 @@ class _WorkspaceControllerDriver { Future markCleanShutdown() => _notifier.markCleanShutdown(); + Future flushPersistence() => _notifier.flushPersistence(); + void dispose() {} } @@ -1521,6 +1560,18 @@ class _MemorySettingsStore implements LocalSettingsStore { } } +class _FailingDocumentSessionStore extends MemoryDocumentSessionStore { + bool failSave = false; + + @override + Future save(WorkspaceSessionSnapshot snapshot) { + if (failSave) { + throw StateError('simulated session save failure'); + } + return super.save(snapshot); + } +} + class _DelayedSaveAsWorkspaceService extends WorkspaceService { _DelayedSaveAsWorkspaceService({ this.pauseWrite = false, From ee79a31e178d036890b5182c8c21956f43b48105 Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 21 Aug 2026 19:32:56 -0700 Subject: [PATCH 15/38] Add Writerside video support --- README.md | 3 + assets/export/markdown.typ | 22 ++ docs/videos.md | 61 ++++ lib/l10n/app_ar.arb | 8 + lib/l10n/app_de.arb | 8 + lib/l10n/app_en.arb | 11 + lib/l10n/app_es.arb | 8 + lib/l10n/app_et.arb | 8 + lib/l10n/app_fa.arb | 8 + lib/l10n/app_fr.arb | 8 + lib/l10n/app_hi.arb | 8 + lib/l10n/app_it.arb | 8 + lib/l10n/app_nb.arb | 8 + lib/l10n/app_pl.arb | 8 + lib/l10n/app_pt.arb | 8 + lib/l10n/app_ru.arb | 8 + lib/l10n/app_uk.arb | 8 + lib/l10n/generated/app_localizations.dart | 48 +++ lib/l10n/generated/app_localizations_ar.dart | 30 ++ lib/l10n/generated/app_localizations_de.dart | 31 ++ lib/l10n/generated/app_localizations_en.dart | 31 ++ lib/l10n/generated/app_localizations_es.dart | 31 ++ lib/l10n/generated/app_localizations_et.dart | 31 ++ lib/l10n/generated/app_localizations_fa.dart | 31 ++ lib/l10n/generated/app_localizations_fr.dart | 31 ++ lib/l10n/generated/app_localizations_hi.dart | 31 ++ lib/l10n/generated/app_localizations_it.dart | 31 ++ lib/l10n/generated/app_localizations_nb.dart | 31 ++ lib/l10n/generated/app_localizations_pl.dart | 31 ++ lib/l10n/generated/app_localizations_pt.dart | 31 ++ lib/l10n/generated/app_localizations_ru.dart | 31 ++ lib/l10n/generated/app_localizations_uk.dart | 31 ++ lib/src/app/busymark_glyphs.dart | 8 + lib/src/core/diagnostic_localizations.dart | 8 + lib/src/core/local_image_resolver.dart | 18 + lib/src/editor/writerside_video_view.dart | 196 +++++++++++ .../editor/wysiwyg/wysiwyg_block_widgets.dart | 32 +- lib/src/editor/wysiwyg/wysiwyg_editor.dart | 1 + lib/src/export/markdown_export_document.dart | 7 + lib/src/export/markdown_export_mapper.dart | 19 ++ lib/src/export/typst_payload_builder.dart | 2 + lib/src/markdown/busymark_document.dart | 1 + .../busymark_markdown_serializer.dart | 1 + lib/src/markdown/markdown_ast_adapter.dart | 51 ++- lib/src/markdown/markdown_parser.dart | 10 + lib/src/markdown/preview_model.dart | 6 + lib/src/markdown/raw_html_policy.dart | 1 + .../presentation/workspace_screen.dart | 51 +++ lib/src/workspace/workspace_service.dart | 86 +++-- .../writerside_instance_service.dart | 27 ++ lib/src/writerside/writerside_model.dart | 22 ++ .../writerside/writerside_module_service.dart | 64 ++++ lib/src/writerside/writerside_parsers.dart | 70 +++- lib/src/writerside/writerside_video.dart | 87 +++++ test/src/localization_audit_test.dart | 1 + .../src/markdown_pdf_export_service_test.dart | 64 ++++ .../src/writerside_instance_service_test.dart | 26 +- test/src/writerside_video_test.dart | 322 ++++++++++++++++++ test/src/writerside_video_widget_test.dart | 106 ++++++ 59 files changed, 1931 insertions(+), 38 deletions(-) create mode 100644 docs/videos.md create mode 100644 lib/src/editor/writerside_video_view.dart create mode 100644 lib/src/writerside/writerside_video.dart create mode 100644 test/src/writerside_video_test.dart create mode 100644 test/src/writerside_video_widget_test.dart diff --git a/README.md b/README.md index be4fea2a..c8846062 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,9 @@ build settings, Markdown import, status, ID refactoring, instance groups, conditional and reusable TOC sections, and cross-instance topic references. See [Writerside instances](docs/writerside-instances.md) for behavior, safety rules, an openable example, and the authoritative JetBrains references. +Writerside `" + "" + "" + ""; + constexpr char kTail[] = ""; + g_autofree gchar* head = g_strdup_printf(kHeadTemplate, border_effect); + if (kind == PlayerKind::kLocalFile) { + g_autofree gchar* escaped_uri = g_markup_escape_text(local_uri, -1); + if (mini_player) { + g_autofree gchar* escaped_play_label = + g_markup_escape_text(play_label, -1); + g_autofree gchar* escaped_pause_label = + g_markup_escape_text(pause_label, -1); + return g_strdup_printf( + "%s" + "%s", + head, escaped_uri, escaped_play_label, escaped_play_label, + escaped_pause_label, kTail); + } + return g_strdup_printf( + "%s%s", + head, escaped_uri, kTail); + } + if (kind == PlayerKind::kYoutube) { + return g_strdup_printf( + "%s%s", + head, value, mini_player ? 0 : 1, kTail); + } + return g_strdup_printf( + "%s%s", + head, value, mini_player ? 0 : 1, kTail); +} + +} // namespace + +struct _BusyMarkVideoPlayerHost { + GObject parent_instance; + GtkWidget* overlay; + FlMethodChannel* channel; + WebKitWebContext* context; + GHashTable* players; + gboolean shutting_down; +}; + +G_DEFINE_TYPE(BusyMarkVideoPlayerHost, + busymark_video_player_host, + G_TYPE_OBJECT) + +namespace { + +VideoPlayer* create_player(BusyMarkVideoPlayerHost* self, + PlayerKind kind, + const gchar* value, + gchar* local_uri, + gboolean mini_player, + const gchar* play_label, + const gchar* pause_label, + const gchar* border_effect) { + auto* player = g_new0(VideoPlayer, 1); + player->kind = kind; + player->allowed_local_uri = local_uri; + player->web_view = webkit_web_view_new_with_context(self->context); + g_autoptr(WebKitSettings) settings = create_player_settings(); + webkit_web_view_set_settings(WEBKIT_WEB_VIEW(player->web_view), settings); + GdkRGBA black = {}; + gdk_rgba_parse(&black, "#000000"); + webkit_web_view_set_background_color(WEBKIT_WEB_VIEW(player->web_view), + &black); + g_signal_connect(player->web_view, "decide-policy", + G_CALLBACK(decide_policy_cb), player); + g_signal_connect(player->web_view, "create", G_CALLBACK(create_web_view_cb), + player); + g_signal_connect(player->web_view, "permission-request", + G_CALLBACK(permission_request_cb), player); + g_signal_connect(player->web_view, "context-menu", + G_CALLBACK(context_menu_cb), player); + g_signal_connect(player->web_view, "resource-load-started", + G_CALLBACK(resource_load_started_cb), player); + g_autofree gchar* html = + player_html(kind, value, local_uri, mini_player, play_label, pause_label, + border_effect); + webkit_web_view_load_html(WEBKIT_WEB_VIEW(player->web_view), html, + kind == PlayerKind::kLocalFile ? "file:///" + : "about:blank"); + return player; +} + +gboolean show_player(BusyMarkVideoPlayerHost* self, + FlValue* args, + const gchar** error_message) { + const gchar* player_id = lookup_string(args, "playerId"); + const gchar* kind_value = lookup_string(args, "kind"); + const gchar* value = lookup_string(args, "value"); + const gchar* play_label = lookup_string(args, "playLabel"); + const gchar* pause_label = lookup_string(args, "pauseLabel"); + const gchar* border_effect = lookup_string(args, "borderEffect"); + gboolean mini_player = FALSE; + PlayerGeometry geometry = {}; + if (!is_valid_player_id(player_id) || kind_value == nullptr || + value == nullptr || strlen(value) > kMaximumSourceLength || + play_label == nullptr || play_label[0] == '\0' || + strlen(play_label) > kMaximumLabelLength || + !g_utf8_validate(play_label, -1, nullptr) || pause_label == nullptr || + pause_label[0] == '\0' || strlen(pause_label) > kMaximumLabelLength || + !g_utf8_validate(pause_label, -1, nullptr) || + border_effect == nullptr || + (g_strcmp0(border_effect, "none") != 0 && + g_strcmp0(border_effect, "line") != 0 && + g_strcmp0(border_effect, "rounded") != 0) || + !lookup_bool(args, "miniPlayer", &mini_player) || + !decode_geometry(args, &geometry)) { + *error_message = "The video player request is invalid."; + return FALSE; + } + + PlayerKind kind; + gchar* local_uri = nullptr; + if (g_strcmp0(kind_value, "localFile") == 0) { + kind = PlayerKind::kLocalFile; + local_uri = validated_local_uri(value); + if (local_uri == nullptr) { + *error_message = "The local video path is unavailable or unsafe."; + return FALSE; + } + } else if (g_strcmp0(kind_value, "youtube") == 0 && + is_valid_youtube_id(value)) { + kind = PlayerKind::kYoutube; + } else if (g_strcmp0(kind_value, "vimeo") == 0 && + is_valid_vimeo_id(value)) { + kind = PlayerKind::kVimeo; + } else { + *error_message = "The hosted video identifier is invalid."; + return FALSE; + } + + g_hash_table_remove(self->players, player_id); + VideoPlayer* player = + create_player(self, kind, value, local_uri, mini_player, play_label, + pause_label, border_effect); + apply_geometry(player->web_view, geometry); + gtk_overlay_add_overlay(GTK_OVERLAY(self->overlay), player->web_view); + gtk_overlay_set_overlay_pass_through(GTK_OVERLAY(self->overlay), + player->web_view, FALSE); + gtk_widget_show(player->web_view); + g_hash_table_insert(self->players, g_strdup(player_id), player); + return TRUE; +} + +void method_call_cb(FlMethodChannel*, + FlMethodCall* method_call, + gpointer user_data) { + auto* self = BUSYMARK_VIDEO_PLAYER_HOST(user_data); + const gchar* method = fl_method_call_get_name(method_call); + FlValue* args = fl_method_call_get_args(method_call); + if (self->shutting_down) { + respond_bool(method_call, FALSE); + return; + } + if (g_strcmp0(method, "show") == 0) { + const gchar* message = nullptr; + if (!show_player(self, args, &message)) { + respond_error(method_call, "video.invalidRequest", message); + return; + } + respond_bool(method_call, TRUE); + return; + } + const gchar* player_id = lookup_string(args, "playerId"); + if (!is_valid_player_id(player_id)) { + respond_error(method_call, "video.invalidRequest", + "The video player identifier is invalid."); + return; + } + if (g_strcmp0(method, "hide") == 0) { + g_hash_table_remove(self->players, player_id); + respond_bool(method_call, TRUE); + return; + } + if (g_strcmp0(method, "update") == 0) { + PlayerGeometry geometry = {}; + auto* player = static_cast( + g_hash_table_lookup(self->players, player_id)); + if (player == nullptr || !decode_geometry(args, &geometry)) { + respond_bool(method_call, FALSE); + return; + } + apply_geometry(player->web_view, geometry); + respond_bool(method_call, TRUE); + return; + } + fl_method_call_respond_not_implemented(method_call, nullptr); +} + +} // namespace + +BusyMarkVideoPlayerHost* busymark_video_player_host_new(GtkWidget* overlay) { + g_return_val_if_fail(GTK_IS_OVERLAY(overlay), nullptr); + auto* self = BUSYMARK_VIDEO_PLAYER_HOST( + g_object_new(busymark_video_player_host_get_type(), nullptr)); + self->overlay = overlay; + self->context = webkit_web_context_new_ephemeral(); + webkit_web_context_set_cache_model(self->context, + WEBKIT_CACHE_MODEL_DOCUMENT_VIEWER); + webkit_web_context_set_spell_checking_enabled(self->context, FALSE); + const gchar* snap_root = g_getenv("SNAP"); + const gboolean strictly_confined_snap = + snap_root != nullptr && snap_root[0] != '\0'; + webkit_web_context_set_sandbox_enabled(self->context, + !strictly_confined_snap); + WebKitCookieManager* cookie_manager = + webkit_web_context_get_cookie_manager(self->context); + webkit_cookie_manager_set_accept_policy(cookie_manager, + WEBKIT_COOKIE_POLICY_ACCEPT_NEVER); + return self; +} + +void busymark_video_player_host_register_channel( + BusyMarkVideoPlayerHost* self, + FlView* view) { + g_return_if_fail(BUSYMARK_IS_VIDEO_PLAYER_HOST(self)); + g_return_if_fail(FL_IS_VIEW(view)); + g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); + self->channel = fl_method_channel_new( + fl_engine_get_binary_messenger(fl_view_get_engine(view)), kChannelName, + FL_METHOD_CODEC(codec)); + fl_method_channel_set_method_call_handler(self->channel, method_call_cb, + self, nullptr); +} + +void busymark_video_player_host_shutdown(BusyMarkVideoPlayerHost* self) { + g_return_if_fail(BUSYMARK_IS_VIDEO_PLAYER_HOST(self)); + if (self->shutting_down) { + return; + } + self->shutting_down = TRUE; + g_hash_table_remove_all(self->players); +} + +static void busymark_video_player_host_dispose(GObject* object) { + auto* self = BUSYMARK_VIDEO_PLAYER_HOST(object); + busymark_video_player_host_shutdown(self); + g_clear_object(&self->channel); + g_clear_object(&self->context); + self->overlay = nullptr; + G_OBJECT_CLASS(busymark_video_player_host_parent_class)->dispose(object); +} + +static void busymark_video_player_host_finalize(GObject* object) { + auto* self = BUSYMARK_VIDEO_PLAYER_HOST(object); + g_clear_pointer(&self->players, g_hash_table_unref); + G_OBJECT_CLASS(busymark_video_player_host_parent_class)->finalize(object); +} + +static void busymark_video_player_host_class_init( + BusyMarkVideoPlayerHostClass* klass) { + GObjectClass* object_class = G_OBJECT_CLASS(klass); + object_class->dispose = busymark_video_player_host_dispose; + object_class->finalize = busymark_video_player_host_finalize; +} + +static void busymark_video_player_host_init(BusyMarkVideoPlayerHost* self) { + self->players = + g_hash_table_new_full(g_str_hash, g_str_equal, g_free, video_player_free); +} diff --git a/linux/runner/video_player_host.h b/linux/runner/video_player_host.h new file mode 100644 index 00000000..248c20ca --- /dev/null +++ b/linux/runner/video_player_host.h @@ -0,0 +1,21 @@ +#ifndef BUSYMARK_VIDEO_PLAYER_HOST_H_ +#define BUSYMARK_VIDEO_PLAYER_HOST_H_ + +#include +#include + +G_DECLARE_FINAL_TYPE(BusyMarkVideoPlayerHost, + busymark_video_player_host, + BUSYMARK, + VIDEO_PLAYER_HOST, + GObject) + +BusyMarkVideoPlayerHost* busymark_video_player_host_new(GtkWidget* overlay); + +void busymark_video_player_host_register_channel( + BusyMarkVideoPlayerHost* self, + FlView* view); + +void busymark_video_player_host_shutdown(BusyMarkVideoPlayerHost* self); + +#endif // BUSYMARK_VIDEO_PLAYER_HOST_H_ diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 7e638221..cc313785 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -39,6 +39,7 @@ apps: - desktop - desktop-legacy - browser-support + - audio-playback - gsettings - opengl - wayland @@ -72,6 +73,11 @@ parts: - libhandy-1-0 - libsecret-1-0 - libwebkit2gtk-4.1-0 + - gstreamer1.0-libav + - gstreamer1.0-plugins-bad + - gstreamer1.0-plugins-base + - gstreamer1.0-plugins-good + - gstreamer1.0-plugins-ugly - libx11-6 - libxdamage1 - libxext6 diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index 6c0645bc..86f92815 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -4059,6 +4059,9 @@ void main() { tester.widget(previewScroll).padding, editorPadding, ); + final previewControllerBeforeSplit = tester + .widget(previewScroll) + .itemScrollController; container .read(workspaceControllerProvider.notifier) @@ -4068,6 +4071,14 @@ void main() { .setDocumentViewMode(DocumentViewModePreference.split); await tester.pump(const Duration(milliseconds: 100)); + expect(tester.takeException(), isNull); + expect( + tester + .widget(previewScroll) + .itemScrollController, + same(previewControllerBeforeSplit), + ); + final splitPaneRect = tester.getRect(previewScroll); final splitContentRect = tester.getRect(previewContent); expect( diff --git a/test/src/writerside_video_player_host_test.dart b/test/src/writerside_video_player_host_test.dart new file mode 100644 index 00000000..3de50b73 --- /dev/null +++ b/test/src/writerside_video_player_host_test.dart @@ -0,0 +1,110 @@ +import 'dart:io'; + +import 'package:busymark/src/editor/writerside_video_player_host.dart'; +import 'package:busymark/src/writerside/writerside_video.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test( + 'platform host sends reduced source and geometry over one channel', + () async { + const channel = MethodChannel(writersideVideoPlayerChannelName); + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return call.method == 'hide' ? null : true; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null), + ); + const host = PlatformWritersideVideoPlayerHost(channel: channel); + const rect = Rect.fromLTWH(12, 24, 640, 360); + + expect( + await host.show( + const WritersideVideoPlayerRequest( + playerId: 'video-1', + source: WritersideVideoPlaybackSource( + kind: WritersideVideoPlaybackKind.youtube, + value: 'BeJu9bMPLGU', + ), + rect: rect, + miniPlayer: false, + playLabel: 'Play video', + pauseLabel: 'Pause video', + borderEffect: 'rounded', + ), + ), + isTrue, + ); + expect( + await host.update('video-1', rect.shift(const Offset(1, 2))), + isTrue, + ); + await host.hide('video-1'); + + expect(calls.map((call) => call.method), ['show', 'update', 'hide']); + expect(calls.first.arguments, { + 'playerId': 'video-1', + 'kind': 'youtube', + 'value': 'BeJu9bMPLGU', + 'x': 12.0, + 'y': 24.0, + 'width': 640.0, + 'height': 360.0, + 'miniPlayer': false, + 'playLabel': 'Play video', + 'pauseLabel': 'Pause video', + 'borderEffect': 'rounded', + }); + }, + ); + + test('Linux player keeps interactive media in a restricted WebKit host', () { + final native = File('linux/runner/video_player_host.cc').readAsStringSync(); + final application = File( + 'linux/runner/my_application.cc', + ).readAsStringSync(); + final cmake = File('linux/runner/CMakeLists.txt').readAsStringSync(); + final renderHost = File( + 'linux/runner/web_render_host.cc', + ).readAsStringSync(); + final snap = File('snap/snapcraft.yaml').readAsStringSync(); + + expect(native, contains('webkit_web_context_new_ephemeral()')); + expect(native, contains('WEBKIT_COOKIE_POLICY_ACCEPT_NEVER')); + expect(native, contains('webkit_permission_request_deny(request)')); + expect( + native, + contains('webkit_settings_set_enable_media(settings, TRUE)'), + ); + expect( + native, + contains('set_media_playback_requires_user_gesture(settings, FALSE)'), + ); + expect(native, contains('autoplay=1')); + expect(native, contains('resource_send_request_cb')); + expect(native, contains('youtube-nocookie.com')); + expect(native, contains('player.vimeo.com')); + expect(native, contains("connect-src 'none'")); + expect(native, isNot(contains('javascript:'))); + expect(application, contains('busymark_video_player_host_new')); + expect(application, contains('gtk_overlay_new')); + expect(native, contains('gtk_overlay_add_overlay')); + expect(cmake, contains('video_player_host.cc')); + + // Interactive video is separate from the offline generated-content host. + expect( + renderHost, + contains('webkit_settings_set_enable_media(settings, FALSE)'), + ); + expect(snap, contains('- audio-playback')); + expect(snap, contains('gstreamer1.0-plugins-good')); + expect(snap, contains('gstreamer1.0-libav')); + }); +} diff --git a/test/src/writerside_video_test.dart b/test/src/writerside_video_test.dart index 4db4a842..5aa70e7b 100644 --- a/test/src/writerside_video_test.dart +++ b/test/src/writerside_video_test.dart @@ -219,6 +219,28 @@ After. )?.host, 'vimeo.com', ); + for (final entry in { + 'https://www.youtube.com/watch?v=BeJu9bMPLGU': ( + WritersideVideoPlaybackKind.youtube, + 'BeJu9bMPLGU', + ), + 'https://www.youtube.com/embed/BeJu9bMPLGU': ( + WritersideVideoPlaybackKind.youtube, + 'BeJu9bMPLGU', + ), + 'https://youtube.com/shorts/BeJu9bMPLGU': ( + WritersideVideoPlaybackKind.youtube, + 'BeJu9bMPLGU', + ), + 'https://player.vimeo.com/video/76979871': ( + WritersideVideoPlaybackKind.vimeo, + '76979871', + ), + }.entries) { + final playback = resolveWritersideHostedVideoSource(entry.key); + expect(playback?.kind, entry.value.$1, reason: entry.key); + expect(playback?.value, entry.value.$2, reason: entry.key); + } expect( resolveWritersideVideoUri( source: 'sample.mp4', @@ -232,6 +254,10 @@ After. for (final unsafe in [ 'http://youtu.be/BeJu9bMPLGU', 'https://example.com/sample.mp4', + 'https://youtube.com/watch', + 'https://youtube.com.evil.example/watch?v=BeJu9bMPLGU', + 'https://user@youtube.com/watch?v=BeJu9bMPLGU', + 'https://vimeo.com/not-a-video', 'javascript:alert(1)', '/tmp/sample.mp4', '../outside.mp4', diff --git a/test/src/writerside_video_widget_test.dart b/test/src/writerside_video_widget_test.dart index ff1ee951..386738fa 100644 --- a/test/src/writerside_video_widget_test.dart +++ b/test/src/writerside_video_widget_test.dart @@ -1,15 +1,22 @@ +import 'dart:io'; + import 'package:busymark/l10n/generated/app_localizations.dart'; import 'package:busymark/src/app/busymark_glyphs.dart'; +import 'package:busymark/src/editor/writerside_video_player_host.dart'; import 'package:busymark/src/editor/writerside_video_view.dart'; import 'package:busymark/src/editor/wysiwyg/wysiwyg_editor.dart'; import 'package:busymark/src/markdown/markdown_model.dart'; import 'package:busymark/src/markdown/markdown_parser.dart'; +import 'package:busymark/src/writerside/writerside_video.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; void main() { - testWidgets('video card launches a supported remote source', (tester) async { - Uri? launched; + testWidgets('video card embeds a supported YouTube source in place', ( + tester, + ) async { + final host = _FakeVideoPlayerHost(); await tester.pumpWidget( MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, @@ -23,10 +30,7 @@ void main() { writersideRoot: '/workspace', imagesDir: 'images', allowRemoteImages: false, - launcher: (uri) async { - launched = uri; - return true; - }, + playerHost: host, ), ), ), @@ -36,10 +40,24 @@ void main() { expect(find.byIcon(BusyMarkGlyphs.play), findsOneWidget); await tester.tap(find.byType(BusyMarkWritersideVideoView)); await tester.pump(); - expect(launched, Uri.parse('https://youtu.be/BeJu9bMPLGU')); + await tester.pump(); + + expect(host.shown, hasLength(1)); + expect(host.shown.single.source.kind, WritersideVideoPlaybackKind.youtube); + expect(host.shown.single.source.value, 'BeJu9bMPLGU'); + expect(host.shown.single.miniPlayer, isFalse); + expect(host.shown.single.rect.size, const Size(700, 393.75)); + expect(find.text('YouTube'), findsNothing); + expect(find.byIcon(BusyMarkGlyphs.play), findsNothing); + + await tester.pumpWidget(const SizedBox.shrink()); + expect(host.hidden, contains(host.shown.single.playerId)); }); - testWidgets('mini-player card hides the source label', (tester) async { + testWidgets('mini-player embeds Vimeo with the reduced video ID', ( + tester, + ) async { + final host = _FakeVideoPlayerHost(); await tester.pumpWidget( MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, @@ -54,7 +72,7 @@ void main() { imagesDir: 'images', allowRemoteImages: false, miniPlayer: true, - launcher: (_) async => true, + playerHost: host, ), ), ), @@ -62,6 +80,133 @@ void main() { expect(find.text('Vimeo'), findsNothing); expect(find.byIcon(BusyMarkGlyphs.play), findsOneWidget); + await tester.tap(find.byType(BusyMarkWritersideVideoView)); + await tester.pump(); + await tester.pump(); + + expect(host.shown.single.source.kind, WritersideVideoPlaybackKind.vimeo); + expect(host.shown.single.source.value, '76979871'); + expect(host.shown.single.miniPlayer, isTrue); + await tester.pumpWidget(const SizedBox.shrink()); + }); + + testWidgets('local video player receives only a canonical media path', ( + tester, + ) async { + final root = Directory.systemTemp.createTempSync('busymark-video-widget-'); + addTearDown(() => root.deleteSync(recursive: true)); + final topics = Directory(p.join(root.path, 'topics'))..createSync(); + final images = Directory(p.join(root.path, 'images'))..createSync(); + final topicPath = p.join(topics.path, 'video.md'); + File(topicPath).writeAsStringSync(''); + final video = File(p.join(images.path, 'demo.mp4')) + ..writeAsBytesSync([0, 0, 0, 0]); + final host = _FakeVideoPlayerHost(); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: BusyMarkWritersideVideoView( + source: 'demo.mp4', + previewSource: null, + activeFilePath: topicPath, + workspaceRoot: topics.path, + writersideRoot: root.path, + imagesDir: 'images', + allowRemoteImages: false, + playerHost: host, + width: 640, + ), + ), + ), + ); + + await tester.tap(find.byType(BusyMarkWritersideVideoView)); + await tester.pump(); + await tester.pump(); + + expect( + host.shown.single.source.kind, + WritersideVideoPlaybackKind.localFile, + ); + expect(host.shown.single.source.value, video.resolveSymbolicLinksSync()); + expect(host.shown.single.rect.size, const Size(640, 360)); + await tester.pumpWidget(const SizedBox.shrink()); + }); + + testWidgets('video dimensions preserve ratio and scale to available width', ( + tester, + ) async { + Future render({double? width, double? height}) async { + final host = _FakeVideoPlayerHost(); + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SizedBox( + width: 500, + child: BusyMarkWritersideVideoView( + key: ValueKey('video-$width-$height'), + source: 'https://youtu.be/BeJu9bMPLGU', + previewSource: null, + activeFilePath: '/workspace/topics/video.md', + workspaceRoot: '/workspace/topics', + writersideRoot: '/workspace', + imagesDir: 'images', + allowRemoteImages: false, + playerHost: host, + width: width, + height: height, + ), + ), + ), + ), + ); + await tester.tap(find.byIcon(BusyMarkGlyphs.play)); + await tester.pump(); + await tester.pump(); + return host.shown.single.rect.size; + } + + expect(await render(width: 320), const Size(320, 180)); + expect(await render(height: 180), const Size(320, 180)); + expect(await render(width: 640, height: 400), const Size(500, 312.5)); + }); + + testWidgets('failed native player leaves a usable poster fallback', ( + tester, + ) async { + final host = _FakeVideoPlayerHost()..showResult = false; + var failures = 0; + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: BusyMarkWritersideVideoView( + source: 'https://youtu.be/BeJu9bMPLGU', + previewSource: null, + activeFilePath: '/workspace/topics/video.md', + workspaceRoot: '/workspace/topics', + writersideRoot: '/workspace', + imagesDir: 'images', + allowRemoteImages: false, + playerHost: host, + onOpenFailed: () => failures += 1, + ), + ), + ), + ); + + await tester.tap(find.byType(BusyMarkWritersideVideoView)); + await tester.pump(); + await tester.pump(); + + expect(failures, 1); + expect(find.byIcon(BusyMarkGlyphs.play), findsOneWidget); }); testWidgets('WYSIWYG renders a Writerside video without rewriting source', ( @@ -104,3 +249,28 @@ void main() { expect(emitted, source); }); } + +class _FakeVideoPlayerHost implements WritersideVideoPlayerHost { + final shown = []; + final updates = []; + final hidden = []; + bool showResult = true; + bool updateResult = true; + + @override + Future hide(String playerId) async { + hidden.add(playerId); + } + + @override + Future show(WritersideVideoPlayerRequest request) async { + shown.add(request); + return showResult; + } + + @override + Future update(String playerId, Rect rect) async { + updates.add(rect); + return updateResult; + } +} From c088bb1ad2ae1c78f7ee3af40b6ce5d4558793eb Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 21 Aug 2026 21:02:08 -0700 Subject: [PATCH 18/38] Remove document format badges --- lib/l10n/app_ar.arb | 2 - lib/l10n/app_de.arb | 2 - lib/l10n/app_en.arb | 4 - lib/l10n/app_es.arb | 2 - lib/l10n/app_et.arb | 2 - lib/l10n/app_fa.arb | 2 - lib/l10n/app_fr.arb | 2 - lib/l10n/app_hi.arb | 2 - lib/l10n/app_it.arb | 2 - lib/l10n/app_nb.arb | 2 - lib/l10n/app_pl.arb | 2 - lib/l10n/app_pt.arb | 2 - lib/l10n/app_ru.arb | 2 - lib/l10n/app_uk.arb | 2 - lib/l10n/generated/app_localizations.dart | 12 --- lib/l10n/generated/app_localizations_ar.dart | 10 --- lib/l10n/generated/app_localizations_de.dart | 10 --- lib/l10n/generated/app_localizations_en.dart | 10 --- lib/l10n/generated/app_localizations_es.dart | 10 --- lib/l10n/generated/app_localizations_et.dart | 10 --- lib/l10n/generated/app_localizations_fa.dart | 10 --- lib/l10n/generated/app_localizations_fr.dart | 10 --- lib/l10n/generated/app_localizations_hi.dart | 10 --- lib/l10n/generated/app_localizations_it.dart | 10 --- lib/l10n/generated/app_localizations_nb.dart | 10 --- lib/l10n/generated/app_localizations_pl.dart | 10 --- lib/l10n/generated/app_localizations_pt.dart | 10 --- lib/l10n/generated/app_localizations_ru.dart | 10 --- lib/l10n/generated/app_localizations_uk.dart | 10 --- .../document_format_indicator.dart | 52 ----------- .../presentation/workspace_screen.dart | 13 --- test/src/app_smoke_test.dart | 2 + test/src/document_format_indicator_test.dart | 90 ------------------- 33 files changed, 2 insertions(+), 337 deletions(-) delete mode 100644 lib/src/workspace/presentation/document_format_indicator.dart delete mode 100644 test/src/document_format_indicator_test.dart diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 0b1f47d3..8587a82e 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -2696,8 +2696,6 @@ "workspaceReplaceDiskContent": "المحتوى المحفوظ على القرص", "selectFileMatches": "تحديد كل المطابقات وعددها {count}", "workspaceReplaceApplied": "تم استبدال {matches} مطابقة في {files} ملفًا؛ وتم تخطي {skipped}.", - "documentFormatWithFinalNewline": "⁨{encoding}⁩ · ⁨{lineEnding}⁩ · سطر جديد نهائي", - "documentFormatWithoutFinalNewline": "⁨{encoding}⁩ · ⁨{lineEnding}⁩ · بلا سطر جديد نهائي", "normalizeLineEndings": "توحيد نهايات الأسطر", "workspaceReplaceMixedLineEndings": "يستخدم الملف ⁨{fileName}⁩ نهايات أسطر مختلطة. اختر التنسيق قبل الاستبدال.", "mixedLineEndingsSavePrompt": "يحتوي هذا المستند على نهايات أسطر مختلطة. اختر تنسيقًا.", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index f30d07c6..aaf07272 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -2717,8 +2717,6 @@ "workspaceReplaceDiskContent": "Gespeicherter Festplatteninhalt", "selectFileMatches": "Alle {count} Treffer auswählen", "workspaceReplaceApplied": "{matches} Treffer in {files} Dateien ersetzt; {skipped} übersprungen.", - "documentFormatWithFinalNewline": "{encoding} · {lineEnding} · Abschließender Zeilenumbruch", - "documentFormatWithoutFinalNewline": "{encoding} · {lineEnding} · Kein abschließender Zeilenumbruch", "normalizeLineEndings": "Zeilenenden normalisieren", "workspaceReplaceMixedLineEndings": "{fileName} verwendet gemischte Zeilenenden. Wählen Sie vor dem Ersetzen das gewünschte Format.", "mixedLineEndingsSavePrompt": "Dieses Dokument enthält gemischte Zeilenenden. Wählen Sie ein Format.", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 22d1549a..3ae40d73 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -921,10 +921,6 @@ "skipped": {"type": "int"} } }, - "documentFormatWithFinalNewline": "{encoding} · {lineEnding} · Final newline", - "@documentFormatWithFinalNewline": {"description": "Document format tooltip when the file ends with a newline.", "placeholders": {"encoding": {"type": "String"}, "lineEnding": {"type": "String"}}}, - "documentFormatWithoutFinalNewline": "{encoding} · {lineEnding} · No final newline", - "@documentFormatWithoutFinalNewline": {"description": "Document format tooltip when the file does not end with a newline.", "placeholders": {"encoding": {"type": "String"}, "lineEnding": {"type": "String"}}}, "normalizeLineEndings": "Normalize line endings", "@normalizeLineEndings": {"description": "Dialog title for selecting a line-ending style."}, "mixedLineEndingsSavePrompt": "This document contains mixed line endings. Choose a format.", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 635b7a08..ec6bd541 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -2717,8 +2717,6 @@ "workspaceReplaceDiskContent": "Contenido guardado en disco", "selectFileMatches": "Seleccionar las {count} coincidencias", "workspaceReplaceApplied": "Se reemplazaron {matches} coincidencias en {files} archivos; se omitieron {skipped}.", - "documentFormatWithFinalNewline": "{encoding} · {lineEnding} · Salto de línea final", - "documentFormatWithoutFinalNewline": "{encoding} · {lineEnding} · Sin salto de línea final", "normalizeLineEndings": "Normalizar finales de línea", "workspaceReplaceMixedLineEndings": "{fileName} usa finales de línea mezclados. Elige el formato antes de reemplazar.", "mixedLineEndingsSavePrompt": "Este documento contiene finales de línea mezclados. Elige un formato.", diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index bb601536..70125781 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -1905,8 +1905,6 @@ "workspaceReplaceDiskContent": "Kettale salvestatud sisu", "selectFileMatches": "Vali kõik {count} vastet", "workspaceReplaceApplied": "Asendati {matches} vastet {files} failis; vahele jäeti {skipped}.", - "documentFormatWithFinalNewline": "{encoding} · {lineEnding} · Lõpus on reavahetus", - "documentFormatWithoutFinalNewline": "{encoding} · {lineEnding} · Lõpus pole reavahetust", "normalizeLineEndings": "Normaliseeri reavahetused", "workspaceReplaceMixedLineEndings": "Fail {fileName} kasutab eri tüüpi reavahetusi. Vali enne asendamist vorming.", "mixedLineEndingsSavePrompt": "See dokument sisaldab eri tüüpi reavahetusi. Vali vorming.", diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index bef07bf1..c00b856e 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -2715,8 +2715,6 @@ "workspaceReplaceDiskContent": "محتوای ذخیره‌شده روی دیسک", "selectFileMatches": "انتخاب هر {count} مورد", "workspaceReplaceApplied": "{matches} مورد در {files} فایل جایگزین شد؛ {skipped} مورد نادیده گرفته شد.", - "documentFormatWithFinalNewline": "⁨{encoding}⁩ · ⁨{lineEnding}⁩ · خط جدید پایانی", - "documentFormatWithoutFinalNewline": "⁨{encoding}⁩ · ⁨{lineEnding}⁩ · بدون خط جدید پایانی", "normalizeLineEndings": "یکسان‌سازی پایان خط‌ها", "workspaceReplaceMixedLineEndings": "فایل ⁨{fileName}⁩ پایان خط‌های ترکیبی دارد. پیش از جایگزینی قالب را انتخاب کنید.", "mixedLineEndingsSavePrompt": "این سند پایان خط‌های ترکیبی دارد. یک قالب انتخاب کنید.", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 2442a32f..b4ca82a1 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -2717,8 +2717,6 @@ "workspaceReplaceDiskContent": "Contenu enregistré sur le disque", "selectFileMatches": "Sélectionner les {count} occurrences", "workspaceReplaceApplied": "{matches} occurrences remplacées dans {files} fichiers ; {skipped} ignorées.", - "documentFormatWithFinalNewline": "{encoding} · {lineEnding} · Saut de ligne final", - "documentFormatWithoutFinalNewline": "{encoding} · {lineEnding} · Aucun saut de ligne final", "normalizeLineEndings": "Normaliser les fins de ligne", "workspaceReplaceMixedLineEndings": "{fileName} utilise plusieurs types de fins de ligne. Choisissez le format avant le remplacement.", "mixedLineEndingsSavePrompt": "Ce document contient plusieurs types de fins de ligne. Choisissez un format.", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index ef37b32b..1f3bbf0a 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -2696,8 +2696,6 @@ "workspaceReplaceDiskContent": "डिस्क पर सहेजी सामग्री", "selectFileMatches": "सभी {count} मिलान चुनें", "workspaceReplaceApplied": "{files} फ़ाइलों में {matches} मिलान बदले गए; {skipped} छोड़े गए।", - "documentFormatWithFinalNewline": "{encoding} · {lineEnding} · अंतिम नई पंक्ति", - "documentFormatWithoutFinalNewline": "{encoding} · {lineEnding} · अंतिम नई पंक्ति नहीं", "normalizeLineEndings": "पंक्ति अंत सामान्य करें", "workspaceReplaceMixedLineEndings": "{fileName} में मिले-जुले पंक्ति अंत हैं। बदलने से पहले प्रारूप चुनें।", "mixedLineEndingsSavePrompt": "इस दस्तावेज़ में मिले-जुले पंक्ति अंत हैं। कोई प्रारूप चुनें।", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 9d5e7138..a13f1f89 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -2694,8 +2694,6 @@ "workspaceReplaceDiskContent": "Contenuto salvato su disco", "selectFileMatches": "Seleziona tutte le {count} corrispondenze", "workspaceReplaceApplied": "Sostituite {matches} corrispondenze in {files} file; {skipped} ignorate.", - "documentFormatWithFinalNewline": "{encoding} · {lineEnding} · A capo finale", - "documentFormatWithoutFinalNewline": "{encoding} · {lineEnding} · Nessun a capo finale", "normalizeLineEndings": "Normalizza terminatori di riga", "workspaceReplaceMixedLineEndings": "{fileName} usa terminatori di riga misti. Scegli il formato prima di sostituire.", "mixedLineEndingsSavePrompt": "Questo documento contiene terminatori di riga misti. Scegli un formato.", diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb index c84561b6..93e7eeda 100644 --- a/lib/l10n/app_nb.arb +++ b/lib/l10n/app_nb.arb @@ -2694,8 +2694,6 @@ "workspaceReplaceDiskContent": "Innhold lagret på disk", "selectFileMatches": "Velg alle {count} treff", "workspaceReplaceApplied": "Erstattet {matches} treff i {files} filer; hoppet over {skipped}.", - "documentFormatWithFinalNewline": "{encoding} · {lineEnding} · Avsluttende linjeskift", - "documentFormatWithoutFinalNewline": "{encoding} · {lineEnding} · Ingen avsluttende linjeskift", "normalizeLineEndings": "Normaliser linjeslutt", "workspaceReplaceMixedLineEndings": "{fileName} bruker blandede linjeslutt. Velg format før du erstatter.", "mixedLineEndingsSavePrompt": "Dette dokumentet inneholder blandede linjeslutt. Velg et format.", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index c47b1ecf..97afd2e3 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -2712,8 +2712,6 @@ "workspaceReplaceDiskContent": "Zawartość zapisana na dysku", "selectFileMatches": "Wybierz wszystkie dopasowania ({count})", "workspaceReplaceApplied": "Zamieniono {matches} dopasowań w {files} plikach; pominięto {skipped}.", - "documentFormatWithFinalNewline": "{encoding} · {lineEnding} · Końcowy znak nowego wiersza", - "documentFormatWithoutFinalNewline": "{encoding} · {lineEnding} · Brak końcowego znaku nowego wiersza", "normalizeLineEndings": "Normalizuj zakończenia wierszy", "workspaceReplaceMixedLineEndings": "{fileName} używa mieszanych zakończeń wierszy. Wybierz format przed zamianą.", "mixedLineEndingsSavePrompt": "Ten dokument zawiera mieszane zakończenia wierszy. Wybierz format.", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 81023dde..7a257932 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -2694,8 +2694,6 @@ "workspaceReplaceDiskContent": "Conteúdo salvo no disco", "selectFileMatches": "Selecionar todas as {count} correspondências", "workspaceReplaceApplied": "Foram substituídas {matches} correspondências em {files} arquivos; {skipped} ignoradas.", - "documentFormatWithFinalNewline": "{encoding} · {lineEnding} · Quebra de linha final", - "documentFormatWithoutFinalNewline": "{encoding} · {lineEnding} · Sem quebra de linha final", "normalizeLineEndings": "Normalizar finais de linha", "workspaceReplaceMixedLineEndings": "{fileName} usa finais de linha mistos. Escolha o formato antes de substituir.", "mixedLineEndingsSavePrompt": "Este documento contém finais de linha mistos. Escolha um formato.", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 6c8e949b..f5010d0f 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -2712,8 +2712,6 @@ "workspaceReplaceDiskContent": "Содержимое, сохранённое на диске", "selectFileMatches": "Выбрать все совпадения: {count}", "workspaceReplaceApplied": "Заменено совпадений: {matches} в файлах: {files}; пропущено: {skipped}.", - "documentFormatWithFinalNewline": "{encoding} · {lineEnding} · Конечный перевод строки", - "documentFormatWithoutFinalNewline": "{encoding} · {lineEnding} · Нет конечного перевода строки", "normalizeLineEndings": "Нормализовать окончания строк", "workspaceReplaceMixedLineEndings": "В {fileName} используются смешанные окончания строк. Выберите формат перед заменой.", "mixedLineEndingsSavePrompt": "В документе используются смешанные окончания строк. Выберите формат.", diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index 00649a84..cb6facbe 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -2712,8 +2712,6 @@ "workspaceReplaceDiskContent": "Вміст, збережений на диску", "selectFileMatches": "Вибрати всі збіги: {count}", "workspaceReplaceApplied": "Замінено збігів: {matches} у файлах: {files}; пропущено: {skipped}.", - "documentFormatWithFinalNewline": "{encoding} · {lineEnding} · Кінцеве перенесення рядка", - "documentFormatWithoutFinalNewline": "{encoding} · {lineEnding} · Немає кінцевого перенесення рядка", "normalizeLineEndings": "Нормалізувати закінчення рядків", "workspaceReplaceMixedLineEndings": "У {fileName} використовуються змішані закінчення рядків. Виберіть формат перед заміною.", "mixedLineEndingsSavePrompt": "У документі використовуються змішані закінчення рядків. Виберіть формат.", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index c0b06e75..9b0c70d1 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -2631,18 +2631,6 @@ abstract class AppLocalizations { /// **'Replaced {matches} matches in {files} files; skipped {skipped}.'** String workspaceReplaceApplied(int matches, int files, int skipped); - /// Document format tooltip when the file ends with a newline. - /// - /// In en, this message translates to: - /// **'{encoding} · {lineEnding} · Final newline'** - String documentFormatWithFinalNewline(String encoding, String lineEnding); - - /// Document format tooltip when the file does not end with a newline. - /// - /// In en, this message translates to: - /// **'{encoding} · {lineEnding} · No final newline'** - String documentFormatWithoutFinalNewline(String encoding, String lineEnding); - /// Dialog title for selecting a line-ending style. /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index 873035cc..c11ff348 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -1427,16 +1427,6 @@ class AppLocalizationsAr extends AppLocalizations { return 'تم استبدال $matches مطابقة في $files ملفًا؛ وتم تخطي $skipped.'; } - @override - String documentFormatWithFinalNewline(String encoding, String lineEnding) { - return '⁨$encoding⁩ · ⁨$lineEnding⁩ · سطر جديد نهائي'; - } - - @override - String documentFormatWithoutFinalNewline(String encoding, String lineEnding) { - return '⁨$encoding⁩ · ⁨$lineEnding⁩ · بلا سطر جديد نهائي'; - } - @override String get normalizeLineEndings => 'توحيد نهايات الأسطر'; diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index a8e23b01..a82c113f 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -1440,16 +1440,6 @@ class AppLocalizationsDe extends AppLocalizations { return '$matches Treffer in $files Dateien ersetzt; $skipped übersprungen.'; } - @override - String documentFormatWithFinalNewline(String encoding, String lineEnding) { - return '$encoding · $lineEnding · Abschließender Zeilenumbruch'; - } - - @override - String documentFormatWithoutFinalNewline(String encoding, String lineEnding) { - return '$encoding · $lineEnding · Kein abschließender Zeilenumbruch'; - } - @override String get normalizeLineEndings => 'Zeilenenden normalisieren'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 1487e3bd..11eced20 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -1424,16 +1424,6 @@ class AppLocalizationsEn extends AppLocalizations { return 'Replaced $matches matches in $files files; skipped $skipped.'; } - @override - String documentFormatWithFinalNewline(String encoding, String lineEnding) { - return '$encoding · $lineEnding · Final newline'; - } - - @override - String documentFormatWithoutFinalNewline(String encoding, String lineEnding) { - return '$encoding · $lineEnding · No final newline'; - } - @override String get normalizeLineEndings => 'Normalize line endings'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index e9c431ee..8507728c 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -1437,16 +1437,6 @@ class AppLocalizationsEs extends AppLocalizations { return 'Se reemplazaron $matches coincidencias en $files archivos; se omitieron $skipped.'; } - @override - String documentFormatWithFinalNewline(String encoding, String lineEnding) { - return '$encoding · $lineEnding · Salto de línea final'; - } - - @override - String documentFormatWithoutFinalNewline(String encoding, String lineEnding) { - return '$encoding · $lineEnding · Sin salto de línea final'; - } - @override String get normalizeLineEndings => 'Normalizar finales de línea'; diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index 3955a7b5..d203973b 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -1421,16 +1421,6 @@ class AppLocalizationsEt extends AppLocalizations { return 'Asendati $matches vastet $files failis; vahele jäeti $skipped.'; } - @override - String documentFormatWithFinalNewline(String encoding, String lineEnding) { - return '$encoding · $lineEnding · Lõpus on reavahetus'; - } - - @override - String documentFormatWithoutFinalNewline(String encoding, String lineEnding) { - return '$encoding · $lineEnding · Lõpus pole reavahetust'; - } - @override String get normalizeLineEndings => 'Normaliseeri reavahetused'; diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index f2c4497d..38a80ee8 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -1458,16 +1458,6 @@ class AppLocalizationsFa extends AppLocalizations { return '$matches مورد در $files فایل جایگزین شد؛ $skipped مورد نادیده گرفته شد.'; } - @override - String documentFormatWithFinalNewline(String encoding, String lineEnding) { - return '⁨$encoding⁩ · ⁨$lineEnding⁩ · خط جدید پایانی'; - } - - @override - String documentFormatWithoutFinalNewline(String encoding, String lineEnding) { - return '⁨$encoding⁩ · ⁨$lineEnding⁩ · بدون خط جدید پایانی'; - } - @override String get normalizeLineEndings => 'یکسان‌سازی پایان خط‌ها'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 445d2985..7b9d3cd5 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -1438,16 +1438,6 @@ class AppLocalizationsFr extends AppLocalizations { return '$matches occurrences remplacées dans $files fichiers ; $skipped ignorées.'; } - @override - String documentFormatWithFinalNewline(String encoding, String lineEnding) { - return '$encoding · $lineEnding · Saut de ligne final'; - } - - @override - String documentFormatWithoutFinalNewline(String encoding, String lineEnding) { - return '$encoding · $lineEnding · Aucun saut de ligne final'; - } - @override String get normalizeLineEndings => 'Normaliser les fins de ligne'; diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 9ae6fb74..5f6fc608 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -1414,16 +1414,6 @@ class AppLocalizationsHi extends AppLocalizations { return '$files फ़ाइलों में $matches मिलान बदले गए; $skipped छोड़े गए।'; } - @override - String documentFormatWithFinalNewline(String encoding, String lineEnding) { - return '$encoding · $lineEnding · अंतिम नई पंक्ति'; - } - - @override - String documentFormatWithoutFinalNewline(String encoding, String lineEnding) { - return '$encoding · $lineEnding · अंतिम नई पंक्ति नहीं'; - } - @override String get normalizeLineEndings => 'पंक्ति अंत सामान्य करें'; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index 030a6b4c..ee864d67 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -1433,16 +1433,6 @@ class AppLocalizationsIt extends AppLocalizations { return 'Sostituite $matches corrispondenze in $files file; $skipped ignorate.'; } - @override - String documentFormatWithFinalNewline(String encoding, String lineEnding) { - return '$encoding · $lineEnding · A capo finale'; - } - - @override - String documentFormatWithoutFinalNewline(String encoding, String lineEnding) { - return '$encoding · $lineEnding · Nessun a capo finale'; - } - @override String get normalizeLineEndings => 'Normalizza terminatori di riga'; diff --git a/lib/l10n/generated/app_localizations_nb.dart b/lib/l10n/generated/app_localizations_nb.dart index 8153d3fc..9d5a2396 100644 --- a/lib/l10n/generated/app_localizations_nb.dart +++ b/lib/l10n/generated/app_localizations_nb.dart @@ -1423,16 +1423,6 @@ class AppLocalizationsNb extends AppLocalizations { return 'Erstattet $matches treff i $files filer; hoppet over $skipped.'; } - @override - String documentFormatWithFinalNewline(String encoding, String lineEnding) { - return '$encoding · $lineEnding · Avsluttende linjeskift'; - } - - @override - String documentFormatWithoutFinalNewline(String encoding, String lineEnding) { - return '$encoding · $lineEnding · Ingen avsluttende linjeskift'; - } - @override String get normalizeLineEndings => 'Normaliser linjeslutt'; diff --git a/lib/l10n/generated/app_localizations_pl.dart b/lib/l10n/generated/app_localizations_pl.dart index f72af799..ac1c8a5a 100644 --- a/lib/l10n/generated/app_localizations_pl.dart +++ b/lib/l10n/generated/app_localizations_pl.dart @@ -1443,16 +1443,6 @@ class AppLocalizationsPl extends AppLocalizations { return 'Zamieniono $matches dopasowań w $files plikach; pominięto $skipped.'; } - @override - String documentFormatWithFinalNewline(String encoding, String lineEnding) { - return '$encoding · $lineEnding · Końcowy znak nowego wiersza'; - } - - @override - String documentFormatWithoutFinalNewline(String encoding, String lineEnding) { - return '$encoding · $lineEnding · Brak końcowego znaku nowego wiersza'; - } - @override String get normalizeLineEndings => 'Normalizuj zakończenia wierszy'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 46c4ea4f..5b6656ae 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -1432,16 +1432,6 @@ class AppLocalizationsPt extends AppLocalizations { return 'Foram substituídas $matches correspondências em $files arquivos; $skipped ignoradas.'; } - @override - String documentFormatWithFinalNewline(String encoding, String lineEnding) { - return '$encoding · $lineEnding · Quebra de linha final'; - } - - @override - String documentFormatWithoutFinalNewline(String encoding, String lineEnding) { - return '$encoding · $lineEnding · Sem quebra de linha final'; - } - @override String get normalizeLineEndings => 'Normalizar finais de linha'; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 855ba591..989a58e4 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -1438,16 +1438,6 @@ class AppLocalizationsRu extends AppLocalizations { return 'Заменено совпадений: $matches в файлах: $files; пропущено: $skipped.'; } - @override - String documentFormatWithFinalNewline(String encoding, String lineEnding) { - return '$encoding · $lineEnding · Конечный перевод строки'; - } - - @override - String documentFormatWithoutFinalNewline(String encoding, String lineEnding) { - return '$encoding · $lineEnding · Нет конечного перевода строки'; - } - @override String get normalizeLineEndings => 'Нормализовать окончания строк'; diff --git a/lib/l10n/generated/app_localizations_uk.dart b/lib/l10n/generated/app_localizations_uk.dart index 6b38c834..60be85f1 100644 --- a/lib/l10n/generated/app_localizations_uk.dart +++ b/lib/l10n/generated/app_localizations_uk.dart @@ -1445,16 +1445,6 @@ class AppLocalizationsUk extends AppLocalizations { return 'Замінено збігів: $matches у файлах: $files; пропущено: $skipped.'; } - @override - String documentFormatWithFinalNewline(String encoding, String lineEnding) { - return '$encoding · $lineEnding · Кінцеве перенесення рядка'; - } - - @override - String documentFormatWithoutFinalNewline(String encoding, String lineEnding) { - return '$encoding · $lineEnding · Немає кінцевого перенесення рядка'; - } - @override String get normalizeLineEndings => 'Нормалізувати закінчення рядків'; diff --git a/lib/src/workspace/presentation/document_format_indicator.dart b/lib/src/workspace/presentation/document_format_indicator.dart deleted file mode 100644 index 56a85ef3..00000000 --- a/lib/src/workspace/presentation/document_format_indicator.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../app/busymark_design.dart'; -import '../../app/localization.dart'; -import '../text_format_metadata.dart'; - -class BusyMarkDocumentFormatIndicator extends StatelessWidget { - const BusyMarkDocumentFormatIndicator({super.key, required this.format}); - - final TextFormatMetadata format; - - @override - Widget build(BuildContext context) { - final colors = BusyMarkSurfaceColors.of(context); - final lineEnding = switch (format.lineEnding) { - DocumentLineEnding.none || DocumentLineEnding.lf => 'LF', - DocumentLineEnding.crlf => 'CRLF', - DocumentLineEnding.mixed => 'LF/CRLF', - }; - final encoding = format.hasUtf8Bom ? 'UTF-8 BOM' : 'UTF-8'; - final details = format.hasFinalNewline - ? context.l10n.documentFormatWithFinalNewline(encoding, lineEnding) - : context.l10n.documentFormatWithoutFinalNewline(encoding, lineEnding); - - return Tooltip( - message: details, - child: Semantics( - label: details, - child: DecoratedBox( - decoration: BoxDecoration( - color: colors.headerbarFlat, - border: Border.all(color: colors.subtleBorder), - borderRadius: BorderRadius.circular(BusyMarkRadius.pill), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: BusyMarkSpacing.sm, - vertical: BusyMarkSpacing.xs, - ), - child: Text( - lineEnding, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colors.mutedForeground, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - ), - ); - } -} diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index c257bd43..40edb546 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -79,7 +79,6 @@ import '../workspace_model.dart'; import '../workspace_message.dart'; import '../workspace_safety.dart'; import '../workspace_tabs.dart'; -import 'document_format_indicator.dart'; import 'welcome_screen.dart'; import 'writerside_instance_dialog.dart'; @@ -7888,12 +7887,6 @@ class _EditorTabStrip extends ConsumerWidget { diff: entry.kind == WorkspaceTabKind.gitDiff, active: entry.active, dirty: _tabDirty(workspace, entry), - format: entry.active && entry.bufferId != null - ? state.documentBuffers - .where((buffer) => buffer.id == entry.bufferId) - .firstOrNull - ?.format - : null, onSelected: () => _selectTab(context, ref, workspace, entry), onClose: () => _closeTab(context, ref, workspace, entry), ); @@ -7997,7 +7990,6 @@ class _WorkspaceTabButton extends StatelessWidget { required this.diff, required this.active, required this.dirty, - required this.format, required this.onSelected, required this.onClose, }); @@ -8007,7 +7999,6 @@ class _WorkspaceTabButton extends StatelessWidget { final bool diff; final bool active; final bool dirty; - final TextFormatMetadata? format; final VoidCallback onSelected; final VoidCallback onClose; @@ -8076,10 +8067,6 @@ class _WorkspaceTabButton extends StatelessWidget { ), ), const SizedBox(width: BusyMarkSpacing.xs), - if (format case final format?) ...[ - BusyMarkDocumentFormatIndicator(format: format), - const SizedBox(width: BusyMarkSpacing.xs), - ], BusyMarkCompactIconButton( tooltip: MaterialLocalizations.of(context).closeButtonTooltip, icon: BusyMarkGlyphs.clear, diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index 86f92815..66c5a595 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -1888,6 +1888,8 @@ void main() { await controller.openActiveFile(third.path); await tester.pump(const Duration(milliseconds: 100)); + expect(find.text('LF'), findsNothing); + expect(find.text('CRLF'), findsNothing); expect( container.read(workspaceControllerProvider).workspace?.openFilePaths, [first.path, second.path, third.path], diff --git a/test/src/document_format_indicator_test.dart b/test/src/document_format_indicator_test.dart deleted file mode 100644 index a84f09be..00000000 --- a/test/src/document_format_indicator_test.dart +++ /dev/null @@ -1,90 +0,0 @@ -import 'package:busymark/l10n/generated/app_localizations.dart'; -import 'package:busymark/src/app/app_theme.dart'; -import 'package:busymark/src/app/busymark_design.dart'; -import 'package:busymark/src/workspace/presentation/document_format_indicator.dart'; -import 'package:busymark/src/workspace/text_format_metadata.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - testWidgets('shows only a compact line-ending label', (tester) async { - await _pumpIndicator( - tester, - const TextFormatMetadata( - hasUtf8Bom: false, - lineEnding: DocumentLineEnding.lf, - hasFinalNewline: true, - lfCount: 2, - crlfCount: 0, - crCount: 0, - ), - ); - - expect(find.text('LF'), findsOneWidget); - expect(find.textContaining('UTF-8'), findsNothing); - expect(find.textContaining('Final newline'), findsNothing); - expect( - tester.widget(find.byType(Tooltip)).message, - 'UTF-8 · LF · Final newline', - ); - final size = tester.getSize(find.byType(BusyMarkDocumentFormatIndicator)); - expect(size.width, lessThan(80)); - expect(size.height, lessThan(32)); - }); - - testWidgets('keeps encoding and final-newline details in the tooltip', ( - tester, - ) async { - await _pumpIndicator( - tester, - const TextFormatMetadata( - hasUtf8Bom: true, - lineEnding: DocumentLineEnding.crlf, - hasFinalNewline: false, - lfCount: 0, - crlfCount: 2, - crCount: 0, - ), - ); - - expect(find.text('CRLF'), findsOneWidget); - expect( - tester.widget(find.byType(Tooltip)).message, - 'UTF-8 BOM · CRLF · No final newline', - ); - }); - - testWidgets('uses a compact technical label for mixed line endings', ( - tester, - ) async { - await _pumpIndicator( - tester, - const TextFormatMetadata( - hasUtf8Bom: false, - lineEnding: DocumentLineEnding.mixed, - hasFinalNewline: true, - lfCount: 1, - crlfCount: 1, - crCount: 0, - ), - ); - - expect(find.text('LF/CRLF'), findsOneWidget); - }); -} - -Future _pumpIndicator(WidgetTester tester, TextFormatMetadata format) { - return tester.pumpWidget( - MaterialApp( - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - theme: buildBusyMarkTheme( - brightness: Brightness.dark, - accentColor: BusyMarkLinuxPalette.blueAccent, - ), - home: Scaffold( - body: Center(child: BusyMarkDocumentFormatIndicator(format: format)), - ), - ), - ); -} From 3c2253a0b5d7d1f5c777745a2cc6b6e0a05ea049 Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 21 Aug 2026 21:38:10 -0700 Subject: [PATCH 19/38] Add Writerside admonitions --- README.md | 3 + assets/export/markdown.typ | 20 + docs/admonitions.md | 44 +++ lib/l10n/app_ar.arb | 2 + lib/l10n/app_de.arb | 2 + lib/l10n/app_en.arb | 4 + lib/l10n/app_es.arb | 2 + lib/l10n/app_et.arb | 2 + lib/l10n/app_fa.arb | 2 + lib/l10n/app_fr.arb | 2 + lib/l10n/app_hi.arb | 2 + lib/l10n/app_it.arb | 2 + lib/l10n/app_nb.arb | 2 + lib/l10n/app_pl.arb | 2 + lib/l10n/app_pt.arb | 2 + lib/l10n/app_ru.arb | 2 + lib/l10n/app_uk.arb | 2 + lib/l10n/generated/app_localizations.dart | 12 + lib/l10n/generated/app_localizations_ar.dart | 6 + lib/l10n/generated/app_localizations_de.dart | 6 + lib/l10n/generated/app_localizations_en.dart | 6 + lib/l10n/generated/app_localizations_es.dart | 6 + lib/l10n/generated/app_localizations_et.dart | 6 + lib/l10n/generated/app_localizations_fa.dart | 6 + lib/l10n/generated/app_localizations_fr.dart | 6 + lib/l10n/generated/app_localizations_hi.dart | 6 + lib/l10n/generated/app_localizations_it.dart | 6 + lib/l10n/generated/app_localizations_nb.dart | 6 + lib/l10n/generated/app_localizations_pl.dart | 6 + lib/l10n/generated/app_localizations_pt.dart | 6 + lib/l10n/generated/app_localizations_ru.dart | 6 + lib/l10n/generated/app_localizations_uk.dart | 6 + .../editor/wysiwyg/wysiwyg_block_widgets.dart | 48 ++- .../wysiwyg/wysiwyg_document_controller.dart | 92 +++++ lib/src/editor/wysiwyg/wysiwyg_editor.dart | 102 +++-- lib/src/editor/wysiwyg/wysiwyg_toolbar.dart | 41 ++ lib/src/export/markdown_export_document.dart | 1 + lib/src/export/markdown_export_mapper.dart | 48 ++- lib/src/markdown/busymark_document.dart | 13 + .../busymark_markdown_serializer.dart | 45 ++- lib/src/markdown/markdown_ast_adapter.dart | 130 +++++- lib/src/markdown/markdown_parser.dart | 1 + lib/src/markdown/preview_model.dart | 83 ++-- .../presentation/workspace_screen.dart | 12 +- lib/src/workspace/workspace_service.dart | 137 +++++++ test/fixtures/markdown/writerside_markdown.md | 2 +- test/src/busymark_design_test.dart | 30 +- .../src/markdown_pdf_export_service_test.dart | 49 +++ test/src/source_audit_test.dart | 6 +- test/src/writerside_admonition_test.dart | 370 ++++++++++++++++++ test/src/wysiwyg_ai_test.dart | 2 +- 51 files changed, 1296 insertions(+), 109 deletions(-) create mode 100644 docs/admonitions.md create mode 100644 test/src/writerside_admonition_test.dart diff --git a/README.md b/README.md index c8846062..d68d1c55 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,9 @@ rules, an openable example, and the authoritative JetBrains references. Writerside `