diff --git a/.github/workflows/flutter-linux.yml b/.github/workflows/flutter-linux.yml index 1a1d2f5..9cb9683 100644 --- a/.github/workflows/flutter-linux.yml +++ b/.github/workflows/flutter-linux.yml @@ -29,6 +29,7 @@ jobs: uses: subosito/flutter-action@v2 with: channel: stable + flutter-version: '3.44.4' cache: true - name: Enable Linux desktop diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 13a0b95..7dee6ba 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -101,6 +101,10 @@ "@copyName": { "description": "Menu action that copies the selected file, folder, or workspace name." }, + "copyFileName": "نسخ اسم الملف", + "@copyFileName": { + "description": "Menu action that copies the active document file name." + }, "copyPath": "نسخ المسار", "@copyPath": { "description": "Menu action that copies the selected file, folder, or workspace path." @@ -117,6 +121,12 @@ "@cut": { "description": "Cut command label." }, + "promoteHeading": "ترقية العنوان", + "demoteHeading": "خفض رتبة العنوان", + "moveSectionUp": "نقل القسم إلى أعلى", + "moveSectionDown": "نقل القسم إلى أسفل", + "confirmDeleteSectionTitle": "حذف القسم؟", + "confirmDeleteSectionMessage": "هل تريد حذف \u2068{name}\u2069 وكل محتوى قسمه؟ لا يمكن التراجع عن هذا الإجراء.", "darkTheme": "داكن", "@darkTheme": { "description": "Dark theme option." @@ -157,6 +167,10 @@ "@mainMenu": { "description": "Native header bar main menu tooltip." }, + "fullScreen": "ملء الشاشة", + "@fullScreen": { + "description": "Command that toggles full-screen window mode." + }, "markdown": "Markdown", "@markdown": { "description": "Markdown format label." @@ -2201,7 +2215,7 @@ "gitProjectHistory": "المشروع", "gitFileHistory": "الملف الحالي", "gitAdditionsDeletions": "\u2068+{additions} -{deletions}\u2069", - "gitFileActions": "إجراءات الملف", + "fileActions": "إجراءات الملف", "gitStatusAdded": "مضاف", "gitStatusDeleted": "محذوف", "gitStatusRenamed": "أُعيدت تسميته", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index b1be88d..5ad9f28 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -101,6 +101,10 @@ "@copyName": { "description": "Menu action that copies the selected file, folder, or workspace name." }, + "copyFileName": "Dateinamen kopieren", + "@copyFileName": { + "description": "Menu action that copies the active document file name." + }, "copyPath": "Pfad kopieren", "@copyPath": { "description": "Menu action that copies the selected file, folder, or workspace path." @@ -117,6 +121,12 @@ "@cut": { "description": "Cut command label." }, + "promoteHeading": "Überschrift hochstufen", + "demoteHeading": "Überschrift herabstufen", + "moveSectionUp": "Abschnitt nach oben verschieben", + "moveSectionDown": "Abschnitt nach unten verschieben", + "confirmDeleteSectionTitle": "Abschnitt löschen?", + "confirmDeleteSectionMessage": "„{name}“ und den gesamten Inhalt des Abschnitts löschen? Dies kann nicht rückgängig gemacht werden.", "darkTheme": "Dunkel", "@darkTheme": { "description": "Dark theme option." @@ -157,6 +167,10 @@ "@mainMenu": { "description": "Native header bar main menu tooltip." }, + "fullScreen": "Vollbild", + "@fullScreen": { + "description": "Command that toggles full-screen window mode." + }, "markdown": "Markdown", "@markdown": { "description": "Markdown format label." @@ -2199,7 +2213,7 @@ "gitProjectHistory": "Projekt", "gitFileHistory": "Aktuelle Datei", "gitAdditionsDeletions": "+{additions} -{deletions}", - "gitFileActions": "Dateiaktionen", + "fileActions": "Dateiaktionen", "gitStatusAdded": "Hinzugefügt", "gitStatusDeleted": "Gelöscht", "gitStatusRenamed": "Umbenannt", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 1479b0e..89f768f 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -93,6 +93,8 @@ "@copy": {"description": "Copy command label."}, "copyName": "Copy name", "@copyName": {"description": "Menu action that copies the selected file, folder, or workspace name."}, + "copyFileName": "Copy file name", + "@copyFileName": {"description": "Menu action that copies the active document file name."}, "copyPath": "Copy path", "@copyPath": {"description": "Menu action that copies the selected file, folder, or workspace path."}, "create": "Create", @@ -101,6 +103,21 @@ "@creating": {"description": "Progress label while a project or topic is being created."}, "cut": "Cut", "@cut": {"description": "Cut command label."}, + "promoteHeading": "Promote heading", + "@promoteHeading": {"description": "Outline action that raises a heading and its descendants by one rank."}, + "demoteHeading": "Demote heading", + "@demoteHeading": {"description": "Outline action that lowers a heading and its descendants by one rank."}, + "moveSectionUp": "Move section up", + "@moveSectionUp": {"description": "Outline action that swaps a heading section with its previous sibling section."}, + "moveSectionDown": "Move section down", + "@moveSectionDown": {"description": "Outline action that swaps a heading section with its next sibling section."}, + "confirmDeleteSectionTitle": "Delete section?", + "@confirmDeleteSectionTitle": {"description": "Confirmation title before deleting a Markdown heading section."}, + "confirmDeleteSectionMessage": "Delete “{name}” and all content in its section? This cannot be undone.", + "@confirmDeleteSectionMessage": { + "description": "Confirmation message before deleting a Markdown heading section.", + "placeholders": {"name": {"type": "String"}} + }, "darkTheme": "Dark", "@darkTheme": {"description": "Dark theme option."}, "delete": "Delete", @@ -123,6 +140,8 @@ "@lightTheme": {"description": "Light theme option."}, "mainMenu": "Main menu", "@mainMenu": {"description": "Native header bar main menu tooltip."}, + "fullScreen": "Full Screen", + "@fullScreen": {"description": "Command that toggles full-screen window mode."}, "markdown": "Markdown", "@markdown": {"description": "Markdown format label."}, "open": "Open", @@ -1480,8 +1499,8 @@ "description": "Diff additions and deletions count.", "placeholders": {"additions": {"type": "int"}, "deletions": {"type": "int"}} }, - "gitFileActions": "File actions", - "@gitFileActions": {"description": "Tooltip for a changed file action menu."}, + "fileActions": "File actions", + "@fileActions": {"description": "Tooltip for a file 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 9bb7acd..b230dc1 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -101,6 +101,10 @@ "@copyName": { "description": "Menu action that copies the selected file, folder, or workspace name." }, + "copyFileName": "Copiar nombre de archivo", + "@copyFileName": { + "description": "Menu action that copies the active document file name." + }, "copyPath": "Copiar ruta", "@copyPath": { "description": "Menu action that copies the selected file, folder, or workspace path." @@ -117,6 +121,12 @@ "@cut": { "description": "Cut command label." }, + "promoteHeading": "Promover encabezado", + "demoteHeading": "Degradar encabezado", + "moveSectionUp": "Mover sección hacia arriba", + "moveSectionDown": "Mover sección hacia abajo", + "confirmDeleteSectionTitle": "¿Eliminar sección?", + "confirmDeleteSectionMessage": "¿Eliminar «{name}» y todo el contenido de su sección? Esto no se puede deshacer.", "darkTheme": "Oscuro", "@darkTheme": { "description": "Dark theme option." @@ -157,6 +167,10 @@ "@mainMenu": { "description": "Native header bar main menu tooltip." }, + "fullScreen": "Pantalla completa", + "@fullScreen": { + "description": "Command that toggles full-screen window mode." + }, "markdown": "Markdown", "@markdown": { "description": "Markdown format label." @@ -2199,7 +2213,7 @@ "gitProjectHistory": "Proyecto", "gitFileHistory": "Archivo actual", "gitAdditionsDeletions": "+{additions} -{deletions}", - "gitFileActions": "Acciones de archivo", + "fileActions": "Acciones de archivo", "gitStatusAdded": "Añadido", "gitStatusDeleted": "Eliminado", "gitStatusRenamed": "Renombrado", diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index 256c12a..9069cd3 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -93,6 +93,8 @@ "@copy": {"description": "Copy command label."}, "copyName": "Kopeeri nimi", "@copyName": {"description": "Menu action that copies the selected file, folder, or workspace name."}, + "copyFileName": "Kopeeri failinimi", + "@copyFileName": {"description": "Menu action that copies the active document file name."}, "copyPath": "Kopeeri tee", "@copyPath": {"description": "Menu action that copies the selected file, folder, or workspace path."}, "create": "Loo", @@ -101,6 +103,12 @@ "@creating": {"description": "Progress label while a project or topic is being created."}, "cut": "Lõika", "@cut": {"description": "Cut command label."}, + "promoteHeading": "Tõsta pealkirja taset", + "demoteHeading": "Langeta pealkirja taset", + "moveSectionUp": "Liiguta jaotis üles", + "moveSectionDown": "Liiguta jaotis alla", + "confirmDeleteSectionTitle": "Kas kustutada jaotis?", + "confirmDeleteSectionMessage": "Kas kustutada „{name}” ja kogu selle jaotise sisu? Seda toimingut ei saa tagasi võtta.", "darkTheme": "Tume", "@darkTheme": {"description": "Dark theme option."}, "delete": "Kustuta", @@ -123,6 +131,8 @@ "@lightTheme": {"description": "Light theme option."}, "mainMenu": "Peamenüü", "@mainMenu": {"description": "Native header bar main menu tooltip."}, + "fullScreen": "Täisekraan", + "@fullScreen": {"description": "Command that toggles full-screen window mode."}, "markdown": "Markdown", "@markdown": {"description": "Markdown format label."}, "open": "Ava", @@ -1480,8 +1490,8 @@ "description": "Diff additions and deletions count.", "placeholders": {"additions": {"type": "int"}, "deletions": {"type": "int"}} }, - "gitFileActions": "Failitoimingud", - "@gitFileActions": {"description": "Tooltip for a changed file action menu."}, + "fileActions": "Failitoimingud", + "@fileActions": {"description": "Tooltip for a file action menu."}, "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 f03790d..302bcc9 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -101,6 +101,10 @@ "@copyName": { "description": "Menu action that copies the selected file, folder, or workspace name." }, + "copyFileName": "کپی نام فایل", + "@copyFileName": { + "description": "Menu action that copies the active document file name." + }, "copyPath": "کپی مسیر", "@copyPath": { "description": "Menu action that copies the selected file, folder, or workspace path." @@ -117,6 +121,12 @@ "@cut": { "description": "Cut command label." }, + "promoteHeading": "ارتقای عنوان", + "demoteHeading": "تنزل عنوان", + "moveSectionUp": "انتقال بخش به بالا", + "moveSectionDown": "انتقال بخش به پایین", + "confirmDeleteSectionTitle": "بخش حذف شود؟", + "confirmDeleteSectionMessage": "«\u2068{name}\u2069» و همهٔ محتوای بخش آن حذف شود؟ این کار قابل بازگشت نیست.", "darkTheme": "تیره", "@darkTheme": { "description": "Dark theme option." @@ -157,6 +167,10 @@ "@mainMenu": { "description": "Native header bar main menu tooltip." }, + "fullScreen": "تمام‌صفحه", + "@fullScreen": { + "description": "Command that toggles full-screen window mode." + }, "markdown": "Markdown", "@markdown": { "description": "Markdown format label." @@ -2220,7 +2234,7 @@ "deletions": {"type": "int", "format": "decimalPattern"} } }, - "gitFileActions": "عملیات فایل", + "fileActions": "عملیات فایل", "gitStatusAdded": "افزوده‌شده", "gitStatusDeleted": "حذف‌شده", "gitStatusRenamed": "تغییرنام‌یافته", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index b204103..51a2654 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -101,6 +101,10 @@ "@copyName": { "description": "Menu action that copies the selected file, folder, or workspace name." }, + "copyFileName": "Copier le nom du fichier", + "@copyFileName": { + "description": "Menu action that copies the active document file name." + }, "copyPath": "Copier le chemin", "@copyPath": { "description": "Menu action that copies the selected file, folder, or workspace path." @@ -117,6 +121,12 @@ "@cut": { "description": "Cut command label." }, + "promoteHeading": "Promouvoir le titre", + "demoteHeading": "Rétrograder le titre", + "moveSectionUp": "Déplacer la section vers le haut", + "moveSectionDown": "Déplacer la section vers le bas", + "confirmDeleteSectionTitle": "Supprimer la section ?", + "confirmDeleteSectionMessage": "Supprimer « {name} » et tout le contenu de sa section ? Cette action est irréversible.", "darkTheme": "Sombre", "@darkTheme": { "description": "Dark theme option." @@ -157,6 +167,10 @@ "@mainMenu": { "description": "Native header bar main menu tooltip." }, + "fullScreen": "Plein écran", + "@fullScreen": { + "description": "Command that toggles full-screen window mode." + }, "markdown": "Markdown", "@markdown": { "description": "Markdown format label." @@ -2199,7 +2213,7 @@ "gitProjectHistory": "Projet", "gitFileHistory": "Fichier actuel", "gitAdditionsDeletions": "+{additions} -{deletions}", - "gitFileActions": "Actions sur le fichier", + "fileActions": "Actions sur le fichier", "gitStatusAdded": "Ajouté", "gitStatusDeleted": "Supprimé", "gitStatusRenamed": "Renommé", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 6685695..c3da836 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -101,6 +101,10 @@ "@copyName": { "description": "Menu action that copies the selected file, folder, or workspace name." }, + "copyFileName": "फ़ाइल नाम कॉपी करें", + "@copyFileName": { + "description": "Menu action that copies the active document file name." + }, "copyPath": "पथ कॉपी करें", "@copyPath": { "description": "Menu action that copies the selected file, folder, or workspace path." @@ -117,6 +121,12 @@ "@cut": { "description": "Cut command label." }, + "promoteHeading": "शीर्षक को ऊपर करें", + "demoteHeading": "शीर्षक को नीचे करें", + "moveSectionUp": "अनुभाग ऊपर ले जाएँ", + "moveSectionDown": "अनुभाग नीचे ले जाएँ", + "confirmDeleteSectionTitle": "अनुभाग हटाएँ?", + "confirmDeleteSectionMessage": "{name} और उसके अनुभाग की सारी सामग्री हटाएँ? इसे वापस नहीं किया जा सकता।", "darkTheme": "डार्क", "@darkTheme": { "description": "Dark theme option." @@ -157,6 +167,10 @@ "@mainMenu": { "description": "Native header bar main menu tooltip." }, + "fullScreen": "पूर्ण स्क्रीन", + "@fullScreen": { + "description": "Command that toggles full-screen window mode." + }, "markdown": "Markdown", "@markdown": { "description": "Markdown format label." @@ -2201,7 +2215,7 @@ "gitProjectHistory": "प्रोजेक्ट", "gitFileHistory": "मौजूदा फ़ाइल", "gitAdditionsDeletions": "+{additions} -{deletions}", - "gitFileActions": "फ़ाइल कार्रवाइयाँ", + "fileActions": "फ़ाइल कार्रवाइयाँ", "gitStatusAdded": "जोड़ा गया", "gitStatusDeleted": "हटाया गया", "gitStatusRenamed": "नाम बदला गया", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 046cc95..ab7911d 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -101,6 +101,10 @@ "@copyName": { "description": "Menu action that copies the selected file, folder, or workspace name." }, + "copyFileName": "Copia nome file", + "@copyFileName": { + "description": "Menu action that copies the active document file name." + }, "copyPath": "Copia percorso", "@copyPath": { "description": "Menu action that copies the selected file, folder, or workspace path." @@ -117,6 +121,12 @@ "@cut": { "description": "Cut command label." }, + "promoteHeading": "Promuovi intestazione", + "demoteHeading": "Retrocedi intestazione", + "moveSectionUp": "Sposta sezione in alto", + "moveSectionDown": "Sposta sezione in basso", + "confirmDeleteSectionTitle": "Eliminare la sezione?", + "confirmDeleteSectionMessage": "Eliminare «{name}» e tutto il contenuto della sua sezione? Questa operazione non può essere annullata.", "darkTheme": "Scuro", "@darkTheme": { "description": "Dark theme option." @@ -157,6 +167,10 @@ "@mainMenu": { "description": "Native header bar main menu tooltip." }, + "fullScreen": "Schermo intero", + "@fullScreen": { + "description": "Command that toggles full-screen window mode." + }, "markdown": "Markdown", "@markdown": { "description": "Markdown format label." @@ -2199,7 +2213,7 @@ "gitProjectHistory": "Progetto", "gitFileHistory": "File corrente", "gitAdditionsDeletions": "+{additions} -{deletions}", - "gitFileActions": "Azioni sul file", + "fileActions": "Azioni sul file", "gitStatusAdded": "Aggiunto", "gitStatusDeleted": "Eliminato", "gitStatusRenamed": "Rinominato", diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb index 4eb6096..e4bbeff 100644 --- a/lib/l10n/app_nb.arb +++ b/lib/l10n/app_nb.arb @@ -101,6 +101,10 @@ "@copyName": { "description": "Menu action that copies the selected file, folder, or workspace name." }, + "copyFileName": "Kopier filnavn", + "@copyFileName": { + "description": "Menu action that copies the active document file name." + }, "copyPath": "Kopier sti", "@copyPath": { "description": "Menu action that copies the selected file, folder, or workspace path." @@ -117,6 +121,12 @@ "@cut": { "description": "Cut command label." }, + "promoteHeading": "Hev overskrift", + "demoteHeading": "Senk overskrift", + "moveSectionUp": "Flytt seksjonen opp", + "moveSectionDown": "Flytt seksjonen ned", + "confirmDeleteSectionTitle": "Slette seksjonen?", + "confirmDeleteSectionMessage": "Slette «{name}» og alt innholdet i seksjonen? Dette kan ikke angres.", "darkTheme": "Mørk", "@darkTheme": { "description": "Dark theme option." @@ -157,6 +167,10 @@ "@mainMenu": { "description": "Native header bar main menu tooltip." }, + "fullScreen": "Fullskjerm", + "@fullScreen": { + "description": "Command that toggles full-screen window mode." + }, "markdown": "Markdown", "@markdown": { "description": "Markdown format label." @@ -2199,7 +2213,7 @@ "gitProjectHistory": "Prosjekt", "gitFileHistory": "Gjeldende fil", "gitAdditionsDeletions": "+{additions} -{deletions}", - "gitFileActions": "Filhandlinger", + "fileActions": "Filhandlinger", "gitStatusAdded": "Lagt til", "gitStatusDeleted": "Slettet", "gitStatusRenamed": "Gitt nytt navn", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index ebf2325..fe5624c 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -101,6 +101,10 @@ "@copyName": { "description": "Menu action that copies the selected file, folder, or workspace name." }, + "copyFileName": "Kopiuj nazwę pliku", + "@copyFileName": { + "description": "Menu action that copies the active document file name." + }, "copyPath": "Kopiuj ścieżkę", "@copyPath": { "description": "Menu action that copies the selected file, folder, or workspace path." @@ -117,6 +121,12 @@ "@cut": { "description": "Cut command label." }, + "promoteHeading": "Podnieś rangę nagłówka", + "demoteHeading": "Obniż rangę nagłówka", + "moveSectionUp": "Przenieś sekcję wyżej", + "moveSectionDown": "Przenieś sekcję niżej", + "confirmDeleteSectionTitle": "Usunąć sekcję?", + "confirmDeleteSectionMessage": "Usunąć „{name}” i całą zawartość tej sekcji? Tej operacji nie można cofnąć.", "darkTheme": "Ciemny", "@darkTheme": { "description": "Dark theme option." @@ -157,6 +167,10 @@ "@mainMenu": { "description": "Native header bar main menu tooltip." }, + "fullScreen": "Pełny ekran", + "@fullScreen": { + "description": "Command that toggles full-screen window mode." + }, "markdown": "Markdown", "@markdown": { "description": "Markdown format label." @@ -2217,7 +2231,7 @@ "gitProjectHistory": "Projekt", "gitFileHistory": "Bieżący plik", "gitAdditionsDeletions": "+{additions} -{deletions}", - "gitFileActions": "Działania na pliku", + "fileActions": "Działania na pliku", "gitStatusAdded": "Dodany", "gitStatusDeleted": "Usunięty", "gitStatusRenamed": "Nazwa zmieniona", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 65b12ff..d4a79f1 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -101,6 +101,10 @@ "@copyName": { "description": "Menu action that copies the selected file, folder, or workspace name." }, + "copyFileName": "Copiar nome do arquivo", + "@copyFileName": { + "description": "Menu action that copies the active document file name." + }, "copyPath": "Copiar caminho", "@copyPath": { "description": "Menu action that copies the selected file, folder, or workspace path." @@ -117,6 +121,12 @@ "@cut": { "description": "Cut command label." }, + "promoteHeading": "Promover título", + "demoteHeading": "Rebaixar título", + "moveSectionUp": "Mover seção para cima", + "moveSectionDown": "Mover seção para baixo", + "confirmDeleteSectionTitle": "Excluir seção?", + "confirmDeleteSectionMessage": "Excluir “{name}” e todo o conteúdo da seção? Isso não pode ser desfeito.", "darkTheme": "Escuro", "@darkTheme": { "description": "Dark theme option." @@ -157,6 +167,10 @@ "@mainMenu": { "description": "Native header bar main menu tooltip." }, + "fullScreen": "Tela cheia", + "@fullScreen": { + "description": "Command that toggles full-screen window mode." + }, "markdown": "Markdown", "@markdown": { "description": "Markdown format label." @@ -2199,7 +2213,7 @@ "gitProjectHistory": "Projeto", "gitFileHistory": "Arquivo atual", "gitAdditionsDeletions": "+{additions} -{deletions}", - "gitFileActions": "Ações do arquivo", + "fileActions": "Ações do arquivo", "gitStatusAdded": "Adicionado", "gitStatusDeleted": "Excluído", "gitStatusRenamed": "Renomeado", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 0ea5e3b..3ac14af 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -101,6 +101,10 @@ "@copyName": { "description": "Menu action that copies the selected file, folder, or workspace name." }, + "copyFileName": "Копировать имя файла", + "@copyFileName": { + "description": "Menu action that copies the active document file name." + }, "copyPath": "Копировать путь", "@copyPath": { "description": "Menu action that copies the selected file, folder, or workspace path." @@ -117,6 +121,12 @@ "@cut": { "description": "Cut command label." }, + "promoteHeading": "Повысить уровень заголовка", + "demoteHeading": "Понизить уровень заголовка", + "moveSectionUp": "Переместить раздел вверх", + "moveSectionDown": "Переместить раздел вниз", + "confirmDeleteSectionTitle": "Удалить раздел?", + "confirmDeleteSectionMessage": "Удалить раздел «{name}» со всем его содержимым? Это действие нельзя отменить.", "darkTheme": "Тёмная", "@darkTheme": { "description": "Dark theme option." @@ -157,6 +167,10 @@ "@mainMenu": { "description": "Native header bar main menu tooltip." }, + "fullScreen": "Полноэкранный режим", + "@fullScreen": { + "description": "Command that toggles full-screen window mode." + }, "markdown": "Markdown", "@markdown": { "description": "Markdown format label." @@ -2217,7 +2231,7 @@ "gitProjectHistory": "Проект", "gitFileHistory": "Текущий файл", "gitAdditionsDeletions": "+{additions} -{deletions}", - "gitFileActions": "Действия с файлом", + "fileActions": "Действия с файлом", "gitStatusAdded": "Добавлен", "gitStatusDeleted": "Удалён", "gitStatusRenamed": "Переименован", diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index ac0905a..77279ce 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -101,6 +101,10 @@ "@copyName": { "description": "Menu action that copies the selected file, folder, or workspace name." }, + "copyFileName": "Копіювати назву файлу", + "@copyFileName": { + "description": "Menu action that copies the active document file name." + }, "copyPath": "Копіювати шлях", "@copyPath": { "description": "Menu action that copies the selected file, folder, or workspace path." @@ -117,6 +121,12 @@ "@cut": { "description": "Cut command label." }, + "promoteHeading": "Підвищити рівень заголовка", + "demoteHeading": "Знизити рівень заголовка", + "moveSectionUp": "Перемістити розділ вище", + "moveSectionDown": "Перемістити розділ нижче", + "confirmDeleteSectionTitle": "Видалити розділ?", + "confirmDeleteSectionMessage": "Видалити розділ «{name}» з усім його вмістом? Цю дію не можна скасувати.", "darkTheme": "Темна", "@darkTheme": { "description": "Dark theme option." @@ -157,6 +167,10 @@ "@mainMenu": { "description": "Native header bar main menu tooltip." }, + "fullScreen": "Повноекранний режим", + "@fullScreen": { + "description": "Command that toggles full-screen window mode." + }, "markdown": "Markdown", "@markdown": { "description": "Markdown format label." @@ -2217,7 +2231,7 @@ "gitProjectHistory": "Проєкт", "gitFileHistory": "Поточний файл", "gitAdditionsDeletions": "+{additions} -{deletions}", - "gitFileActions": "Дії з файлом", + "fileActions": "Дії з файлом", "gitStatusAdded": "Додано", "gitStatusDeleted": "Видалено", "gitStatusRenamed": "Перейменовано", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index f54944a..1a09753 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -393,6 +393,12 @@ abstract class AppLocalizations { /// **'Copy name'** String get copyName; + /// Menu action that copies the active document file name. + /// + /// In en, this message translates to: + /// **'Copy file name'** + String get copyFileName; + /// Menu action that copies the selected file, folder, or workspace path. /// /// In en, this message translates to: @@ -417,6 +423,42 @@ abstract class AppLocalizations { /// **'Cut'** String get cut; + /// Outline action that raises a heading and its descendants by one rank. + /// + /// In en, this message translates to: + /// **'Promote heading'** + String get promoteHeading; + + /// Outline action that lowers a heading and its descendants by one rank. + /// + /// In en, this message translates to: + /// **'Demote heading'** + String get demoteHeading; + + /// Outline action that swaps a heading section with its previous sibling section. + /// + /// In en, this message translates to: + /// **'Move section up'** + String get moveSectionUp; + + /// Outline action that swaps a heading section with its next sibling section. + /// + /// In en, this message translates to: + /// **'Move section down'** + String get moveSectionDown; + + /// Confirmation title before deleting a Markdown heading section. + /// + /// In en, this message translates to: + /// **'Delete section?'** + String get confirmDeleteSectionTitle; + + /// Confirmation message before deleting a Markdown heading section. + /// + /// In en, this message translates to: + /// **'Delete “{name}” and all content in its section? This cannot be undone.'** + String confirmDeleteSectionMessage(String name); + /// Dark theme option. /// /// In en, this message translates to: @@ -483,6 +525,12 @@ abstract class AppLocalizations { /// **'Main menu'** String get mainMenu; + /// Command that toggles full-screen window mode. + /// + /// In en, this message translates to: + /// **'Full Screen'** + String get fullScreen; + /// Markdown format label. /// /// In en, this message translates to: @@ -3608,11 +3656,11 @@ abstract class AppLocalizations { /// **'+{additions} -{deletions}'** String gitAdditionsDeletions(int additions, int deletions); - /// Tooltip for a changed file action menu. + /// Tooltip for a file action menu. /// /// In en, this message translates to: /// **'File actions'** - String get gitFileActions; + String get fileActions; /// Git file status label. /// diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index 4c00b84..11bae62 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -155,6 +155,9 @@ class AppLocalizationsAr extends AppLocalizations { @override String get copyName => 'نسخ الاسم'; + @override + String get copyFileName => 'نسخ اسم الملف'; + @override String get copyPath => 'نسخ المسار'; @@ -167,6 +170,26 @@ class AppLocalizationsAr extends AppLocalizations { @override String get cut => 'قص'; + @override + String get promoteHeading => 'ترقية العنوان'; + + @override + String get demoteHeading => 'خفض رتبة العنوان'; + + @override + String get moveSectionUp => 'نقل القسم إلى أعلى'; + + @override + String get moveSectionDown => 'نقل القسم إلى أسفل'; + + @override + String get confirmDeleteSectionTitle => 'حذف القسم؟'; + + @override + String confirmDeleteSectionMessage(String name) { + return 'هل تريد حذف ⁨$name⁩ وكل محتوى قسمه؟ لا يمكن التراجع عن هذا الإجراء.'; + } + @override String get darkTheme => 'داكن'; @@ -200,6 +223,9 @@ class AppLocalizationsAr extends AppLocalizations { @override String get mainMenu => 'القائمة الرئيسية'; + @override + String get fullScreen => 'ملء الشاشة'; + @override String get markdown => 'Markdown'; @@ -2172,7 +2198,7 @@ class AppLocalizationsAr extends AppLocalizations { } @override - String get gitFileActions => 'إجراءات الملف'; + String get fileActions => 'إجراءات الملف'; @override String get gitStatusAdded => 'مضاف'; diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index a66c7cb..91345ce 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -159,6 +159,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get copyName => 'Namen kopieren'; + @override + String get copyFileName => 'Dateinamen kopieren'; + @override String get copyPath => 'Pfad kopieren'; @@ -171,6 +174,26 @@ class AppLocalizationsDe extends AppLocalizations { @override String get cut => 'Ausschneiden'; + @override + String get promoteHeading => 'Überschrift hochstufen'; + + @override + String get demoteHeading => 'Überschrift herabstufen'; + + @override + String get moveSectionUp => 'Abschnitt nach oben verschieben'; + + @override + String get moveSectionDown => 'Abschnitt nach unten verschieben'; + + @override + String get confirmDeleteSectionTitle => 'Abschnitt löschen?'; + + @override + String confirmDeleteSectionMessage(String name) { + return '„$name“ und den gesamten Inhalt des Abschnitts löschen? Dies kann nicht rückgängig gemacht werden.'; + } + @override String get darkTheme => 'Dunkel'; @@ -204,6 +227,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get mainMenu => 'Hauptmenü'; + @override + String get fullScreen => 'Vollbild'; + @override String get markdown => 'Markdown'; @@ -2172,7 +2198,7 @@ class AppLocalizationsDe extends AppLocalizations { } @override - String get gitFileActions => 'Dateiaktionen'; + String get fileActions => 'Dateiaktionen'; @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 dc57ed9..6ae46ba 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -156,6 +156,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get copyName => 'Copy name'; + @override + String get copyFileName => 'Copy file name'; + @override String get copyPath => 'Copy path'; @@ -168,6 +171,26 @@ class AppLocalizationsEn extends AppLocalizations { @override String get cut => 'Cut'; + @override + String get promoteHeading => 'Promote heading'; + + @override + String get demoteHeading => 'Demote heading'; + + @override + String get moveSectionUp => 'Move section up'; + + @override + String get moveSectionDown => 'Move section down'; + + @override + String get confirmDeleteSectionTitle => 'Delete section?'; + + @override + String confirmDeleteSectionMessage(String name) { + return 'Delete “$name” and all content in its section? This cannot be undone.'; + } + @override String get darkTheme => 'Dark'; @@ -201,6 +224,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get mainMenu => 'Main menu'; + @override + String get fullScreen => 'Full Screen'; + @override String get markdown => 'Markdown'; @@ -2144,7 +2170,7 @@ class AppLocalizationsEn extends AppLocalizations { } @override - String get gitFileActions => 'File actions'; + String get fileActions => 'File 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 fb9c015..4c32f29 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -158,6 +158,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get copyName => 'Copiar nombre'; + @override + String get copyFileName => 'Copiar nombre de archivo'; + @override String get copyPath => 'Copiar ruta'; @@ -170,6 +173,26 @@ class AppLocalizationsEs extends AppLocalizations { @override String get cut => 'Cortar'; + @override + String get promoteHeading => 'Promover encabezado'; + + @override + String get demoteHeading => 'Degradar encabezado'; + + @override + String get moveSectionUp => 'Mover sección hacia arriba'; + + @override + String get moveSectionDown => 'Mover sección hacia abajo'; + + @override + String get confirmDeleteSectionTitle => '¿Eliminar sección?'; + + @override + String confirmDeleteSectionMessage(String name) { + return '¿Eliminar «$name» y todo el contenido de su sección? Esto no se puede deshacer.'; + } + @override String get darkTheme => 'Oscuro'; @@ -203,6 +226,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get mainMenu => 'Menú principal'; + @override + String get fullScreen => 'Pantalla completa'; + @override String get markdown => 'Markdown'; @@ -2173,7 +2199,7 @@ class AppLocalizationsEs extends AppLocalizations { } @override - String get gitFileActions => 'Acciones de archivo'; + String get fileActions => 'Acciones de archivo'; @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 0ef6974..7c4212d 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -155,6 +155,9 @@ class AppLocalizationsEt extends AppLocalizations { @override String get copyName => 'Kopeeri nimi'; + @override + String get copyFileName => 'Kopeeri failinimi'; + @override String get copyPath => 'Kopeeri tee'; @@ -167,6 +170,26 @@ class AppLocalizationsEt extends AppLocalizations { @override String get cut => 'Lõika'; + @override + String get promoteHeading => 'Tõsta pealkirja taset'; + + @override + String get demoteHeading => 'Langeta pealkirja taset'; + + @override + String get moveSectionUp => 'Liiguta jaotis üles'; + + @override + String get moveSectionDown => 'Liiguta jaotis alla'; + + @override + String get confirmDeleteSectionTitle => 'Kas kustutada jaotis?'; + + @override + String confirmDeleteSectionMessage(String name) { + return 'Kas kustutada „$name” ja kogu selle jaotise sisu? Seda toimingut ei saa tagasi võtta.'; + } + @override String get darkTheme => 'Tume'; @@ -200,6 +223,9 @@ class AppLocalizationsEt extends AppLocalizations { @override String get mainMenu => 'Peamenüü'; + @override + String get fullScreen => 'Täisekraan'; + @override String get markdown => 'Markdown'; @@ -2147,7 +2173,7 @@ class AppLocalizationsEt extends AppLocalizations { } @override - String get gitFileActions => 'Failitoimingud'; + String get fileActions => 'Failitoimingud'; @override String get gitStatusAdded => 'Lisatud'; diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index b655bba..fd2287b 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -155,6 +155,9 @@ class AppLocalizationsFa extends AppLocalizations { @override String get copyName => 'کپی نام'; + @override + String get copyFileName => 'کپی نام فایل'; + @override String get copyPath => 'کپی مسیر'; @@ -167,6 +170,26 @@ class AppLocalizationsFa extends AppLocalizations { @override String get cut => 'برش'; + @override + String get promoteHeading => 'ارتقای عنوان'; + + @override + String get demoteHeading => 'تنزل عنوان'; + + @override + String get moveSectionUp => 'انتقال بخش به بالا'; + + @override + String get moveSectionDown => 'انتقال بخش به پایین'; + + @override + String get confirmDeleteSectionTitle => 'بخش حذف شود؟'; + + @override + String confirmDeleteSectionMessage(String name) { + return '«⁨$name⁩» و همهٔ محتوای بخش آن حذف شود؟ این کار قابل بازگشت نیست.'; + } + @override String get darkTheme => 'تیره'; @@ -200,6 +223,9 @@ class AppLocalizationsFa extends AppLocalizations { @override String get mainMenu => 'منوی اصلی'; + @override + String get fullScreen => 'تمام‌صفحه'; + @override String get markdown => 'Markdown'; @@ -2204,7 +2230,7 @@ class AppLocalizationsFa extends AppLocalizations { } @override - String get gitFileActions => 'عملیات فایل'; + String get fileActions => 'عملیات فایل'; @override String get gitStatusAdded => 'افزوده‌شده'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 61caff6..259154a 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -158,6 +158,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get copyName => 'Copier le nom'; + @override + String get copyFileName => 'Copier le nom du fichier'; + @override String get copyPath => 'Copier le chemin'; @@ -170,6 +173,26 @@ class AppLocalizationsFr extends AppLocalizations { @override String get cut => 'Couper'; + @override + String get promoteHeading => 'Promouvoir le titre'; + + @override + String get demoteHeading => 'Rétrograder le titre'; + + @override + String get moveSectionUp => 'Déplacer la section vers le haut'; + + @override + String get moveSectionDown => 'Déplacer la section vers le bas'; + + @override + String get confirmDeleteSectionTitle => 'Supprimer la section ?'; + + @override + String confirmDeleteSectionMessage(String name) { + return 'Supprimer « $name » et tout le contenu de sa section ? Cette action est irréversible.'; + } + @override String get darkTheme => 'Sombre'; @@ -203,6 +226,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get mainMenu => 'Menu principal'; + @override + String get fullScreen => 'Plein écran'; + @override String get markdown => 'Markdown'; @@ -2167,7 +2193,7 @@ class AppLocalizationsFr extends AppLocalizations { } @override - String get gitFileActions => 'Actions sur le fichier'; + String get fileActions => 'Actions sur le fichier'; @override String get gitStatusAdded => 'Ajouté'; diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index ed3a726..7e9ac28 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -157,6 +157,9 @@ class AppLocalizationsHi extends AppLocalizations { @override String get copyName => 'नाम कॉपी करें'; + @override + String get copyFileName => 'फ़ाइल नाम कॉपी करें'; + @override String get copyPath => 'पथ कॉपी करें'; @@ -169,6 +172,26 @@ class AppLocalizationsHi extends AppLocalizations { @override String get cut => 'कट करें'; + @override + String get promoteHeading => 'शीर्षक को ऊपर करें'; + + @override + String get demoteHeading => 'शीर्षक को नीचे करें'; + + @override + String get moveSectionUp => 'अनुभाग ऊपर ले जाएँ'; + + @override + String get moveSectionDown => 'अनुभाग नीचे ले जाएँ'; + + @override + String get confirmDeleteSectionTitle => 'अनुभाग हटाएँ?'; + + @override + String confirmDeleteSectionMessage(String name) { + return '$name और उसके अनुभाग की सारी सामग्री हटाएँ? इसे वापस नहीं किया जा सकता।'; + } + @override String get darkTheme => 'डार्क'; @@ -202,6 +225,9 @@ class AppLocalizationsHi extends AppLocalizations { @override String get mainMenu => 'मुख्य मेन्यू'; + @override + String get fullScreen => 'पूर्ण स्क्रीन'; + @override String get markdown => 'Markdown'; @@ -2141,7 +2167,7 @@ class AppLocalizationsHi extends AppLocalizations { } @override - String get gitFileActions => 'फ़ाइल कार्रवाइयाँ'; + String get fileActions => 'फ़ाइल कार्रवाइयाँ'; @override String get gitStatusAdded => 'जोड़ा गया'; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index e0c1cae..a0e01a8 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -157,6 +157,9 @@ class AppLocalizationsIt extends AppLocalizations { @override String get copyName => 'Copia nome'; + @override + String get copyFileName => 'Copia nome file'; + @override String get copyPath => 'Copia percorso'; @@ -169,6 +172,26 @@ class AppLocalizationsIt extends AppLocalizations { @override String get cut => 'Taglia'; + @override + String get promoteHeading => 'Promuovi intestazione'; + + @override + String get demoteHeading => 'Retrocedi intestazione'; + + @override + String get moveSectionUp => 'Sposta sezione in alto'; + + @override + String get moveSectionDown => 'Sposta sezione in basso'; + + @override + String get confirmDeleteSectionTitle => 'Eliminare la sezione?'; + + @override + String confirmDeleteSectionMessage(String name) { + return 'Eliminare «$name» e tutto il contenuto della sua sezione? Questa operazione non può essere annullata.'; + } + @override String get darkTheme => 'Scuro'; @@ -202,6 +225,9 @@ class AppLocalizationsIt extends AppLocalizations { @override String get mainMenu => 'Menu principale'; + @override + String get fullScreen => 'Schermo intero'; + @override String get markdown => 'Markdown'; @@ -2166,7 +2192,7 @@ class AppLocalizationsIt extends AppLocalizations { } @override - String get gitFileActions => 'Azioni sul file'; + String get fileActions => 'Azioni sul file'; @override String get gitStatusAdded => 'Aggiunto'; diff --git a/lib/l10n/generated/app_localizations_nb.dart b/lib/l10n/generated/app_localizations_nb.dart index d2aa559..7386931 100644 --- a/lib/l10n/generated/app_localizations_nb.dart +++ b/lib/l10n/generated/app_localizations_nb.dart @@ -158,6 +158,9 @@ class AppLocalizationsNb extends AppLocalizations { @override String get copyName => 'Kopier navn'; + @override + String get copyFileName => 'Kopier filnavn'; + @override String get copyPath => 'Kopier sti'; @@ -170,6 +173,26 @@ class AppLocalizationsNb extends AppLocalizations { @override String get cut => 'Klipp ut'; + @override + String get promoteHeading => 'Hev overskrift'; + + @override + String get demoteHeading => 'Senk overskrift'; + + @override + String get moveSectionUp => 'Flytt seksjonen opp'; + + @override + String get moveSectionDown => 'Flytt seksjonen ned'; + + @override + String get confirmDeleteSectionTitle => 'Slette seksjonen?'; + + @override + String confirmDeleteSectionMessage(String name) { + return 'Slette «$name» og alt innholdet i seksjonen? Dette kan ikke angres.'; + } + @override String get darkTheme => 'Mørk'; @@ -203,6 +226,9 @@ class AppLocalizationsNb extends AppLocalizations { @override String get mainMenu => 'Hovedmeny'; + @override + String get fullScreen => 'Fullskjerm'; + @override String get markdown => 'Markdown'; @@ -2147,7 +2173,7 @@ class AppLocalizationsNb extends AppLocalizations { } @override - String get gitFileActions => 'Filhandlinger'; + String get fileActions => 'Filhandlinger'; @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 cc51b55..970f5ac 100644 --- a/lib/l10n/generated/app_localizations_pl.dart +++ b/lib/l10n/generated/app_localizations_pl.dart @@ -157,6 +157,9 @@ class AppLocalizationsPl extends AppLocalizations { @override String get copyName => 'Kopiuj nazwę'; + @override + String get copyFileName => 'Kopiuj nazwę pliku'; + @override String get copyPath => 'Kopiuj ścieżkę'; @@ -169,6 +172,26 @@ class AppLocalizationsPl extends AppLocalizations { @override String get cut => 'Wytnij'; + @override + String get promoteHeading => 'Podnieś rangę nagłówka'; + + @override + String get demoteHeading => 'Obniż rangę nagłówka'; + + @override + String get moveSectionUp => 'Przenieś sekcję wyżej'; + + @override + String get moveSectionDown => 'Przenieś sekcję niżej'; + + @override + String get confirmDeleteSectionTitle => 'Usunąć sekcję?'; + + @override + String confirmDeleteSectionMessage(String name) { + return 'Usunąć „$name” i całą zawartość tej sekcji? Tej operacji nie można cofnąć.'; + } + @override String get darkTheme => 'Ciemny'; @@ -202,6 +225,9 @@ class AppLocalizationsPl extends AppLocalizations { @override String get mainMenu => 'Menu główne'; + @override + String get fullScreen => 'Pełny ekran'; + @override String get markdown => 'Markdown'; @@ -2185,7 +2211,7 @@ class AppLocalizationsPl extends AppLocalizations { } @override - String get gitFileActions => 'Działania na pliku'; + String get fileActions => 'Działania na pliku'; @override String get gitStatusAdded => 'Dodany'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index cf9c9d8..2906c48 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -158,6 +158,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get copyName => 'Copiar nome'; + @override + String get copyFileName => 'Copiar nome do arquivo'; + @override String get copyPath => 'Copiar caminho'; @@ -170,6 +173,26 @@ class AppLocalizationsPt extends AppLocalizations { @override String get cut => 'Recortar'; + @override + String get promoteHeading => 'Promover título'; + + @override + String get demoteHeading => 'Rebaixar título'; + + @override + String get moveSectionUp => 'Mover seção para cima'; + + @override + String get moveSectionDown => 'Mover seção para baixo'; + + @override + String get confirmDeleteSectionTitle => 'Excluir seção?'; + + @override + String confirmDeleteSectionMessage(String name) { + return 'Excluir “$name” e todo o conteúdo da seção? Isso não pode ser desfeito.'; + } + @override String get darkTheme => 'Escuro'; @@ -203,6 +226,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get mainMenu => 'Menu principal'; + @override + String get fullScreen => 'Tela cheia'; + @override String get markdown => 'Markdown'; @@ -2162,7 +2188,7 @@ class AppLocalizationsPt extends AppLocalizations { } @override - String get gitFileActions => 'Ações do arquivo'; + String get fileActions => 'Ações do arquivo'; @override String get gitStatusAdded => 'Adicionado'; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 4fed9bb..475ad16 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -158,6 +158,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get copyName => 'Копировать имя'; + @override + String get copyFileName => 'Копировать имя файла'; + @override String get copyPath => 'Копировать путь'; @@ -170,6 +173,26 @@ class AppLocalizationsRu extends AppLocalizations { @override String get cut => 'Вырезать'; + @override + String get promoteHeading => 'Повысить уровень заголовка'; + + @override + String get demoteHeading => 'Понизить уровень заголовка'; + + @override + String get moveSectionUp => 'Переместить раздел вверх'; + + @override + String get moveSectionDown => 'Переместить раздел вниз'; + + @override + String get confirmDeleteSectionTitle => 'Удалить раздел?'; + + @override + String confirmDeleteSectionMessage(String name) { + return 'Удалить раздел «$name» со всем его содержимым? Это действие нельзя отменить.'; + } + @override String get darkTheme => 'Тёмная'; @@ -203,6 +226,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get mainMenu => 'Главное меню'; + @override + String get fullScreen => 'Полноэкранный режим'; + @override String get markdown => 'Markdown'; @@ -2178,7 +2204,7 @@ class AppLocalizationsRu extends AppLocalizations { } @override - String get gitFileActions => 'Действия с файлом'; + String get fileActions => 'Действия с файлом'; @override String get gitStatusAdded => 'Добавлен'; diff --git a/lib/l10n/generated/app_localizations_uk.dart b/lib/l10n/generated/app_localizations_uk.dart index 7ab5c6c..8f01ceb 100644 --- a/lib/l10n/generated/app_localizations_uk.dart +++ b/lib/l10n/generated/app_localizations_uk.dart @@ -157,6 +157,9 @@ class AppLocalizationsUk extends AppLocalizations { @override String get copyName => 'Копіювати назву'; + @override + String get copyFileName => 'Копіювати назву файлу'; + @override String get copyPath => 'Копіювати шлях'; @@ -169,6 +172,26 @@ class AppLocalizationsUk extends AppLocalizations { @override String get cut => 'Вирізати'; + @override + String get promoteHeading => 'Підвищити рівень заголовка'; + + @override + String get demoteHeading => 'Знизити рівень заголовка'; + + @override + String get moveSectionUp => 'Перемістити розділ вище'; + + @override + String get moveSectionDown => 'Перемістити розділ нижче'; + + @override + String get confirmDeleteSectionTitle => 'Видалити розділ?'; + + @override + String confirmDeleteSectionMessage(String name) { + return 'Видалити розділ «$name» з усім його вмістом? Цю дію не можна скасувати.'; + } + @override String get darkTheme => 'Темна'; @@ -202,6 +225,9 @@ class AppLocalizationsUk extends AppLocalizations { @override String get mainMenu => 'Головне меню'; + @override + String get fullScreen => 'Повноекранний режим'; + @override String get markdown => 'Markdown'; @@ -2187,7 +2213,7 @@ class AppLocalizationsUk extends AppLocalizations { } @override - String get gitFileActions => 'Дії з файлом'; + String get fileActions => 'Дії з файлом'; @override String get gitStatusAdded => 'Додано'; diff --git a/lib/src/app/app_metadata.dart b/lib/src/app/app_metadata.dart index dcb8061..4295a5c 100644 --- a/lib/src/app/app_metadata.dart +++ b/lib/src/app/app_metadata.dart @@ -1 +1 @@ -const busyMarkAppVersion = '0.2.4'; +const busyMarkAppVersion = '0.2.5'; diff --git a/lib/src/app/app_settings.dart b/lib/src/app/app_settings.dart index c9f687b..20c29a6 100644 --- a/lib/src/app/app_settings.dart +++ b/lib/src/app/app_settings.dart @@ -279,7 +279,14 @@ abstract class LocalSettingsStore { } class JsonFileLocalSettingsStore implements LocalSettingsStore { - const JsonFileLocalSettingsStore(); + const JsonFileLocalSettingsStore({ + String? settingsFilePathOverride, + Future Function(File stagedFile, File targetFile)? beforePublish, + }) : _settingsFilePathOverride = settingsFilePathOverride, + _beforePublish = beforePublish; + + final String? _settingsFilePathOverride; + final Future Function(File stagedFile, File targetFile)? _beforePublish; @override Future> load() async { @@ -297,11 +304,41 @@ class JsonFileLocalSettingsStore implements LocalSettingsStore { @override Future save(Map json) async { final file = await _settingsFile(); + final content = const JsonEncoder.withIndent(' ').convert(json); await file.parent.create(recursive: true); - await file.writeAsString(const JsonEncoder.withIndent(' ').convert(json)); + // Keep staging on the target filesystem so publication is one atomic + // directory-entry replacement after the complete JSON has been flushed. + final stagingDirectory = await file.parent.createTemp( + '.busymark-settings-', + ); + final stagedFile = File(p.join(stagingDirectory.path, 'settings.json')); + try { + await stagedFile.writeAsString(content, flush: true); + await _beforePublish?.call(stagedFile, file); + await stagedFile.rename(file.path); + } finally { + try { + if (await stagedFile.exists()) { + await stagedFile.delete(); + } + } on Object { + // Cleanup must not hide the original save result or error. + } + try { + if (await stagingDirectory.exists()) { + await stagingDirectory.delete(recursive: true); + } + } on Object { + // Cleanup must not turn a successful atomic replacement into a failure. + } + } } Future _settingsFile() async { + final override = _settingsFilePathOverride; + if (override != null) { + return File(override); + } final directory = await getApplicationSupportDirectory(); return File(p.join(directory.path, 'settings.json')); } diff --git a/lib/src/app/busymark_app.dart b/lib/src/app/busymark_app.dart index ab7e6a0..47b8325 100644 --- a/lib/src/app/busymark_app.dart +++ b/lib/src/app/busymark_app.dart @@ -42,6 +42,14 @@ class BusyMarkApp extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final router = ref.watch(appRouterProvider); final settings = ref.watch(appSettingsControllerProvider); + final windowControls = ref.watch(windowControlServiceProvider); + ref.listen(headerBarActionsProvider, (previous, next) { + next.whenData((event) { + if (event.action == HeaderBarAction.fullScreen) { + unawaited(windowControls.toggleFullScreen()); + } + }); + }); final fallbackAccent = ref.watch(initialSystemAccentColorProvider); final accent = ref .watch(systemAccentColorProvider) @@ -73,7 +81,11 @@ class BusyMarkApp extends ConsumerWidget { ], supportedLocales: AppLocalizations.supportedLocales, builder: (context, child) { - final headerBarDefaults = _nativeHeaderBarDefaults(context, settings); + final headerBarDefaults = _nativeHeaderBarDefaults( + context, + settings, + fullScreen: windowControls.isFullScreen, + ); return HeaderBarConfigurationDefaults( configuration: headerBarDefaults, child: _BusyMarkWindowLifecycle( @@ -86,6 +98,9 @@ class BusyMarkApp extends ConsumerWidget { BusyMarkAppShortcutActivators.save: const _SaveActiveIntent(), BusyMarkAppShortcutActivators.exportPdf: const _ExportPdfIntent(), + BusyMarkAppShortcutActivators.fullScreen: + const _ToggleFullScreenIntent(), + BusyMarkAppShortcutActivators.back: const _BackIntent(), BusyMarkAppShortcutActivators.keyboardShortcuts: const _KeyboardShortcutsIntent(), BusyMarkAppShortcutActivators.settings: const _SettingsIntent(), @@ -183,6 +198,19 @@ class BusyMarkApp extends ConsumerWidget { return null; }, ), + _ToggleFullScreenIntent: + CallbackAction<_ToggleFullScreenIntent>( + onInvoke: (intent) { + unawaited(windowControls.toggleFullScreen()); + return null; + }, + ), + _BackIntent: CallbackAction<_BackIntent>( + onInvoke: (intent) { + unawaited(_navigateBack(ref, router)); + return null; + }, + ), _KeyboardShortcutsIntent: CallbackAction<_KeyboardShortcutsIntent>( onInvoke: (intent) { @@ -320,6 +348,24 @@ class BusyMarkApp extends ConsumerWidget { ); } + Future _navigateBack(WidgetRef ref, GoRouter router) async { + final uri = router.routeInformationProvider.value.uri; + if (uri.path == settingsRoutePath) { + router.go(SettingsReturnTarget.fromSettingsUri(uri).location); + return; + } + if (uri.path != '/workspace') { + return; + } + final navigatorContext = rootNavigatorKey.currentContext; + if (navigatorContext == null || + !await confirmSafeToContinue(navigatorContext, ref) || + !navigatorContext.mounted) { + return; + } + router.go('/'); + } + Future _showOpenChooser( BuildContext context, WidgetRef ref, @@ -594,8 +640,9 @@ class BusyMarkApp extends ConsumerWidget { HeaderBarConfiguration _nativeHeaderBarDefaults( BuildContext context, - AppSettings settings, - ) { + AppSettings settings, { + required bool fullScreen, + }) { final material = MaterialLocalizations.of(context); final l10n = context.l10n; final theme = HeaderBarTheme.fromContext(context); @@ -622,10 +669,14 @@ class BusyMarkApp extends ConsumerWidget { sidebar: settings.sidebarVisible ? l10n.hideSidebar : l10n.showSidebar, sidebarShortcut: BusyMarkSidebarShortcutLabels.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, @@ -652,6 +703,7 @@ class BusyMarkApp extends ConsumerWidget { sidebarVisible: false, sidebarToggleVisible: false, backVisible: false, + fullScreen: fullScreen, modalBarrierDepth: 0, sidebarWidth: BusyMarkSizes.sidebarWidth, labels: labels, @@ -851,6 +903,14 @@ class _ExportPdfIntent extends Intent { const _ExportPdfIntent(); } +class _ToggleFullScreenIntent extends Intent { + const _ToggleFullScreenIntent(); +} + +class _BackIntent extends Intent { + const _BackIntent(); +} + class _KeyboardShortcutsIntent extends Intent { const _KeyboardShortcutsIntent(); } diff --git a/lib/src/app/busymark_design.dart b/lib/src/app/busymark_design.dart index eaeb4ed..532d223 100644 --- a/lib/src/app/busymark_design.dart +++ b/lib/src/app/busymark_design.dart @@ -36,7 +36,6 @@ abstract final class BusyMarkRadius { static const double nativeHeaderButton = kYaruButtonRadius; static const double window = kYaruWindowRadius; static const double pill = 999; - static const double selection = 3; } abstract final class BusyMarkSizes { @@ -82,8 +81,8 @@ abstract final class BusyMarkSizes { static const double sourceFoldButtonRightInset = 1; static const double documentHeadingTop = 18; static const double documentHeadingBottom = 6; - static const double documentListMarkerWidth = 18; - static const double documentListMarkerTopInset = 2; + static const double documentListMarkerWidth = 28; + static const double documentListMarkerTopInset = 0; static const double documentListIndent = documentListMarkerWidth + BusyMarkSpacing.sm; static const double documentImageMinWidth = 80; @@ -129,7 +128,6 @@ abstract final class BusyMarkStroke { static const double focus = kYaruFocusBorderWidth; static const double sourceCursor = 1.4; static const double thematicBreak = 1.6; - static const double selectionInflate = 1.5; } abstract final class BusyMarkAlpha { @@ -211,9 +209,20 @@ abstract final class BusyMarkTypography { 1 => 1.55, 2 => 1.36, 3 => 1.22, - 4 => 1.12, - 5 => 1.04, - _ => 0.98, + 4 => 1.10, + 5 => 1.08, + _ => 1.06, + }; + } + + static double markdownHeadingLineHeight(int level) { + return switch (level) { + 1 => 1.20, + 2 => 1.23, + 3 => 1.28, + 4 => 1.37, + 5 => 1.39, + _ => 1.42, }; } } @@ -328,7 +337,6 @@ abstract final class BusyMarkInsets { vertical: BusyMarkSpacing.xs, ); static const wysiwygContainerContent = documentCalloutContent; - static const wysiwygTableContent = EdgeInsets.all(BusyMarkSpacing.smPlus); static const sourceEditor = EdgeInsets.fromLTRB( BusyMarkSourceEditorMetrics.paddingLeft, BusyMarkSourceEditorMetrics.paddingTop, @@ -1092,22 +1100,34 @@ class BusyMarkCompactIconButton extends StatelessWidget { @override Widget build(BuildContext context) { final colors = BusyMarkSurfaceColors.of(context); - return YaruIconButton( + final semanticStyle = ButtonStyle( + foregroundColor: foregroundColor == null + ? null + : WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return colors.disabledForeground; + } + return foregroundColor; + }), + ); + final yaruDefaults = YaruIconButton( + icon: const SizedBox.shrink(), iconSize: size, + ).defaultStyleOf(context); + final button = IconButton( tooltip: tooltip, - style: foregroundColor == null - ? null - : ButtonStyle( - foregroundColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) { - return colors.disabledForeground; - } - return foregroundColor; - }), - ), icon: Icon(icon, size: glyphSize), + constraints: BoxConstraints.tightFor(width: size, height: size), + padding: EdgeInsets.zero, + style: semanticStyle.merge(yaruDefaults), onPressed: onPressed, ); + return YaruTheme.maybeOf(context)?.focusBorders == true + ? YaruFocusBorder.primary( + borderRadius: BorderRadius.circular(BusyMarkRadius.pill), + child: button, + ) + : button; } } @@ -1455,6 +1475,7 @@ List> _busyMarkFallbackItems( enabled: item.enabled, checked: item.checked, trailingCheck: item.trailingCheck, + mutuallyExclusive: item.mutuallyExclusive, routeKey: routeKeyPending ? routeKey : null, ), ); @@ -1498,6 +1519,7 @@ class BusyMarkPopupMenuItem extends PopupMenuItem { super.enabled = true, bool checked = false, bool trailingCheck = false, + bool mutuallyExclusive = true, Key? routeKey, }) : label = label, menuValue = value, @@ -1505,6 +1527,7 @@ class BusyMarkPopupMenuItem extends PopupMenuItem { shortcut = shortcut, checked = checked, trailingCheck = trailingCheck, + mutuallyExclusive = mutuallyExclusive, super( value: value, child: KeyedSubtree( @@ -1515,6 +1538,7 @@ class BusyMarkPopupMenuItem extends PopupMenuItem { shortcut: shortcut, checked: checked, trailingCheck: trailingCheck, + mutuallyExclusive: mutuallyExclusive, ), ), ); @@ -1525,6 +1549,7 @@ class BusyMarkPopupMenuItem extends PopupMenuItem { final String? shortcut; final bool checked; final bool trailingCheck; + final bool mutuallyExclusive; } class _BusyMarkPopupMenuItemContent extends StatelessWidget { @@ -1534,6 +1559,7 @@ class _BusyMarkPopupMenuItemContent extends StatelessWidget { required this.shortcut, required this.checked, required this.trailingCheck, + required this.mutuallyExclusive, }); final String label; @@ -1541,6 +1567,7 @@ class _BusyMarkPopupMenuItemContent extends StatelessWidget { final String? shortcut; final bool checked; final bool trailingCheck; + final bool mutuallyExclusive; @override Widget build(BuildContext context) { @@ -1559,7 +1586,7 @@ class _BusyMarkPopupMenuItemContent extends StatelessWidget { ); return Semantics( checked: trailingCheck ? checked : null, - inMutuallyExclusiveGroup: trailingCheck, + inMutuallyExclusiveGroup: trailingCheck && mutuallyExclusive, child: IconTheme.merge( data: const IconThemeData(size: BusyMarkSizes.iconSm), child: Row( diff --git a/lib/src/app/busymark_dialogs.dart b/lib/src/app/busymark_dialogs.dart index aedccd3..3d10e1f 100644 --- a/lib/src/app/busymark_dialogs.dart +++ b/lib/src/app/busymark_dialogs.dart @@ -389,6 +389,22 @@ void showBusyMarkKeyboardShortcutsDialog(BuildContext context) { BusyMarkAppShortcutLabels.exportPdf, ), ), + BusyMarkActionRow( + title: context.l10n.fullScreen, + leading: const Icon(BusyMarkGlyphs.fullScreen), + trailing: const _KeyboardShortcutBadge( + BusyMarkAppShortcutLabels.fullScreen, + ), + ), + BusyMarkActionRow( + title: context.l10n.back, + leading: Icon( + BusyMarkGlyphs.backFor(Directionality.of(context)), + ), + trailing: const _KeyboardShortcutBadge( + BusyMarkAppShortcutLabels.back, + ), + ), BusyMarkActionRow( title: context.l10n.search, subtitle: context.l10n.shortcutSearchDescription, diff --git a/lib/src/app/busymark_glyphs.dart b/lib/src/app/busymark_glyphs.dart index b2fe106..9c50c8f 100644 --- a/lib/src/app/busymark_glyphs.dart +++ b/lib/src/app/busymark_glyphs.dart @@ -35,6 +35,7 @@ abstract final class BusyMarkGlyphs { static const IconData folder = YaruIcons.folder; static const IconData folderOpen = YaruIcons.folder_open; static const IconData font = YaruIcons.font; + static const IconData fullScreen = YaruIcons.fullscreen; static const IconData goTop = YaruIcons.go_top; static const IconData hardBreak = YaruIcons.go_down; static const IconData heading = YaruIcons.font; @@ -87,6 +88,7 @@ abstract final class BusyMarkGlyphs { static const IconData underline = YaruIcons.underline; static const IconData undo = YaruIcons.undo; static const IconData unorderedList = YaruIcons.unordered_list; + static const IconData upArrow = YaruIcons.pan_up; static const IconData warning = YaruIcons.warning; static const IconData writersideProject = YaruIcons.book; @@ -171,10 +173,13 @@ abstract final class BusyMarkGlyphs { if (icon == font || icon == heading) { return 'font-select-symbolic'; } + if (icon == fullScreen) { + return 'view-fullscreen-symbolic'; + } if (icon == goTop || icon == toolbarPlacement) { return 'go-top-symbolic'; } - if (icon == hardBreak) { + if (icon == downArrow || icon == hardBreak) { return 'go-down-symbolic'; } if (icon == hide) { @@ -282,6 +287,9 @@ abstract final class BusyMarkGlyphs { if (icon == unorderedList) { return 'format-unordered-list-symbolic'; } + if (icon == upArrow) { + return 'go-up-symbolic'; + } if (icon == warning) { return 'dialog-warning-symbolic'; } diff --git a/lib/src/app/busymark_main_menu.dart b/lib/src/app/busymark_main_menu.dart index 40a0b37..ce035cc 100644 --- a/lib/src/app/busymark_main_menu.dart +++ b/lib/src/app/busymark_main_menu.dart @@ -1,12 +1,15 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'busymark_design.dart'; import 'busymark_glyphs.dart'; import 'busymark_shortcuts.dart'; import 'localization.dart'; +import 'window_control_service.dart'; enum BusyMarkMainMenuAction { exportPdf, + fullScreen, settings, keyboardShortcuts, markdownAndHtml, @@ -14,7 +17,7 @@ enum BusyMarkMainMenuAction { aboutBusyMark, } -class BusyMarkMainMenuButton extends StatelessWidget { +class BusyMarkMainMenuButton extends ConsumerWidget { const BusyMarkMainMenuButton({ super.key, required this.onSelected, @@ -25,8 +28,11 @@ class BusyMarkMainMenuButton extends StatelessWidget { final bool canExportPdf; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final l10n = context.l10n; + final fullScreen = ref.watch( + windowControlServiceProvider.select((service) => service.isFullScreen), + ); return BusyMarkHeaderPopupMenuButton( tooltip: l10n.mainMenu, icon: BusyMarkGlyphs.menuVertical, @@ -38,6 +44,15 @@ class BusyMarkMainMenuButton extends StatelessWidget { shortcut: BusyMarkAppShortcutLabels.exportPdf, enabled: canExportPdf, ), + BusyMarkPopupMenuItem( + value: BusyMarkMainMenuAction.fullScreen, + label: l10n.fullScreen, + icon: BusyMarkGlyphs.fullScreen, + shortcut: BusyMarkAppShortcutLabels.fullScreen, + checked: fullScreen, + trailingCheck: true, + mutuallyExclusive: false, + ), BusyMarkPopupMenuItem( value: BusyMarkMainMenuAction.settings, label: l10n.settings, diff --git a/lib/src/app/busymark_shortcuts.dart b/lib/src/app/busymark_shortcuts.dart index 045f5e7..9e31cea 100644 --- a/lib/src/app/busymark_shortcuts.dart +++ b/lib/src/app/busymark_shortcuts.dart @@ -23,6 +23,8 @@ enum BusyMarkAppShortcutAction { open, save, exportPdf, + fullScreen, + back, search, keyboardShortcuts, markdownAndHtml, @@ -41,6 +43,8 @@ abstract final class BusyMarkAppShortcuts { static const openLabel = 'Ctrl+O'; static const saveLabel = 'Ctrl+S'; static const exportPdfLabel = 'Ctrl+Shift+E'; + static const fullScreenLabel = 'F11'; + static const backLabel = 'Alt+Left'; static const searchLabel = 'Ctrl+F'; static const keyboardShortcutsLabel = 'Ctrl+Alt+K'; static const markdownAndHtmlLabel = 'Ctrl+Alt+M'; @@ -55,6 +59,8 @@ abstract final class BusyMarkAppShortcuts { static const openGtkAccelerator = 'o'; static const saveGtkAccelerator = 's'; static const exportPdfGtkAccelerator = 'e'; + static const fullScreenGtkAccelerator = 'F11'; + static const backGtkAccelerator = 'Left'; static const searchGtkAccelerator = 'f'; static const keyboardShortcutsGtkAccelerator = 'k'; static const markdownAndHtmlGtkAccelerator = 'm'; @@ -89,6 +95,20 @@ abstract final class BusyMarkAppShortcuts { ), gtkAccelerator: exportPdfGtkAccelerator, ); + static const fullScreen = BusyMarkShortcutDefinition( + label: fullScreenLabel, + activator: SingleActivator(LogicalKeyboardKey.f11, includeRepeats: false), + gtkAccelerator: fullScreenGtkAccelerator, + ); + static const back = BusyMarkShortcutDefinition( + label: backLabel, + activator: SingleActivator( + LogicalKeyboardKey.arrowLeft, + alt: true, + includeRepeats: false, + ), + gtkAccelerator: backGtkAccelerator, + ); static const search = BusyMarkShortcutDefinition( label: searchLabel, activator: SingleActivator(LogicalKeyboardKey.keyF, control: true), @@ -161,6 +181,8 @@ abstract final class BusyMarkAppShortcuts { BusyMarkAppShortcutAction.open: open, BusyMarkAppShortcutAction.save: save, BusyMarkAppShortcutAction.exportPdf: exportPdf, + BusyMarkAppShortcutAction.fullScreen: fullScreen, + BusyMarkAppShortcutAction.back: back, BusyMarkAppShortcutAction.search: search, BusyMarkAppShortcutAction.keyboardShortcuts: keyboardShortcuts, BusyMarkAppShortcutAction.markdownAndHtml: markdownAndHtml, @@ -180,6 +202,8 @@ abstract final class BusyMarkAppShortcutLabels { static const open = BusyMarkAppShortcuts.openLabel; static const save = BusyMarkAppShortcuts.saveLabel; static const exportPdf = BusyMarkAppShortcuts.exportPdfLabel; + static const fullScreen = BusyMarkAppShortcuts.fullScreenLabel; + static const back = BusyMarkAppShortcuts.backLabel; static const search = BusyMarkAppShortcuts.searchLabel; static const keyboardShortcuts = BusyMarkAppShortcuts.keyboardShortcutsLabel; static const markdownAndHtml = BusyMarkAppShortcuts.markdownAndHtmlLabel; @@ -199,6 +223,9 @@ abstract final class BusyMarkAppShortcutActivators { static ShortcutActivator get save => BusyMarkAppShortcuts.save.activator; static ShortcutActivator get exportPdf => BusyMarkAppShortcuts.exportPdf.activator; + static ShortcutActivator get fullScreen => + BusyMarkAppShortcuts.fullScreen.activator; + static ShortcutActivator get back => BusyMarkAppShortcuts.back.activator; static ShortcutActivator get search => BusyMarkAppShortcuts.search.activator; static ShortcutActivator get keyboardShortcuts => BusyMarkAppShortcuts.keyboardShortcuts.activator; @@ -223,6 +250,8 @@ abstract final class BusyMarkAppShortcutGtkAccelerators { static const search = BusyMarkAppShortcuts.searchGtkAccelerator; static const exportPdf = BusyMarkAppShortcuts.exportPdfGtkAccelerator; + static const fullScreen = BusyMarkAppShortcuts.fullScreenGtkAccelerator; + static const back = BusyMarkAppShortcuts.backGtkAccelerator; static const keyboardShortcuts = BusyMarkAppShortcuts.keyboardShortcutsGtkAccelerator; static const markdownAndHtml = diff --git a/lib/src/app/window_control_service.dart b/lib/src/app/window_control_service.dart index bc3ea91..f1d5a93 100644 --- a/lib/src/app/window_control_service.dart +++ b/lib/src/app/window_control_service.dart @@ -1,7 +1,9 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/legacy.dart'; import 'package:window_manager/window_manager.dart'; enum WindowCloseAction { cancel, discard, save } @@ -9,6 +11,10 @@ enum WindowCloseAction { cancel, discard, save } abstract interface class NativeWindowController { Future setPreventClose(bool value); + Future isFullScreen(); + + Future setFullScreen(bool value); + Future close(); void addListener(WindowListener listener); @@ -24,6 +30,20 @@ class WindowManagerNativeWindowController implements NativeWindowController { return _ignoreMissingPlugin(() => windowManager.setPreventClose(value)); } + @override + Future isFullScreen() async { + try { + return await windowManager.isFullScreen(); + } on MissingPluginException { + return false; + } + } + + @override + Future setFullScreen(bool value) { + return _ignoreMissingPlugin(() => windowManager.setFullScreen(value)); + } + @override Future close() { return _ignoreMissingPlugin(windowManager.close); @@ -48,32 +68,61 @@ class WindowManagerNativeWindowController implements NativeWindowController { } } -class WindowControlService { +class WindowControlService extends ChangeNotifier { WindowControlService({required NativeWindowController nativeWindow}) : _nativeWindow = nativeWindow; final NativeWindowController _nativeWindow; WindowListener? _listener; + Future Function()? _onCloseRequest; var _closeInProgress = false; + var _initialized = false; + var _disposed = false; + var _fullScreen = false; + var _fullScreenChangeInProgress = false; + + bool get isFullScreen => _fullScreen; Future initialize() async { + if (_initialized || _disposed) { + return; + } + _initialized = true; + final listener = _BusyMarkWindowListener( + onCloseRequest: _handleNativeCloseRequest, + onFullScreenChanged: _setFullScreen, + ); + _listener = listener; + _nativeWindow.addListener(listener); await _nativeWindow.setPreventClose(true); + final fullScreen = await _nativeWindow.isFullScreen(); + if (!_disposed) { + _setFullScreen(fullScreen); + } } void registerCloseHandler(Future Function() onCloseRequest) { - unregisterCloseHandler(); - final listener = _BusyMarkWindowListener(onCloseRequest); - _listener = listener; - _nativeWindow.addListener(listener); + _onCloseRequest = onCloseRequest; } void unregisterCloseHandler() { - final listener = _listener; - if (listener == null) { + _onCloseRequest = null; + } + + Future toggleFullScreen() async { + if (_disposed || _fullScreenChangeInProgress) { return; } - _nativeWindow.removeListener(listener); - _listener = null; + _fullScreenChangeInProgress = true; + try { + final next = !await _nativeWindow.isFullScreen(); + await _nativeWindow.setFullScreen(next); + if (!_disposed) { + _setFullScreen(next); + } + } finally { + _fullScreenChangeInProgress = false; + } } Future handleCloseRequest({ @@ -114,16 +163,60 @@ class WindowControlService { await _nativeWindow.setPreventClose(false); await _nativeWindow.close(); } + + void _handleNativeCloseRequest() { + final handler = _onCloseRequest; + if (handler != null) { + unawaited(handler()); + } + } + + void _setFullScreen(bool value) { + if (_disposed || _fullScreen == value) { + return; + } + _fullScreen = value; + notifyListeners(); + } + + @override + void dispose() { + if (_disposed) { + return; + } + _disposed = true; + _onCloseRequest = null; + final listener = _listener; + if (listener != null) { + _nativeWindow.removeListener(listener); + _listener = null; + } + super.dispose(); + } } class _BusyMarkWindowListener with WindowListener { - const _BusyMarkWindowListener(this.onCloseRequest); + const _BusyMarkWindowListener({ + required this.onCloseRequest, + required this.onFullScreenChanged, + }); - final Future Function() onCloseRequest; + final VoidCallback onCloseRequest; + final ValueChanged onFullScreenChanged; @override void onWindowClose() { - unawaited(onCloseRequest()); + onCloseRequest(); + } + + @override + void onWindowEnterFullScreen() { + onFullScreenChanged(true); + } + + @override + void onWindowLeaveFullScreen() { + onFullScreenChanged(false); } } @@ -131,10 +224,10 @@ final nativeWindowControllerProvider = Provider( (ref) => const WindowManagerNativeWindowController(), ); -final windowControlServiceProvider = Provider((ref) { - final service = WindowControlService( - nativeWindow: ref.watch(nativeWindowControllerProvider), - ); - ref.onDispose(service.unregisterCloseHandler); - return service; -}); +final windowControlServiceProvider = + ChangeNotifierProvider((ref) { + final service = WindowControlService( + nativeWindow: ref.watch(nativeWindowControllerProvider), + ); + return service; + }); diff --git a/lib/src/core/diagnostic_localizations.dart b/lib/src/core/diagnostic_localizations.dart index 9acb179..1b0ffb3 100644 --- a/lib/src/core/diagnostic_localizations.dart +++ b/lib/src/core/diagnostic_localizations.dart @@ -61,6 +61,7 @@ String localizeDiagnostic(BuildContext context, Diagnostic diagnostic) { ), 'writerside.config.invalid-root' => l10n.diagnosticWritersideConfigInvalidRoot, + 'writerside.config.path-unsafe' => l10n.errorFileOperationOutsideRoot, 'writerside.config.missing-snippets-src' => l10n.diagnosticWritersideConfigMissingSnippetsSrc, 'writerside.config.missing-instance-groups-src' => diff --git a/lib/src/core/path_utils.dart b/lib/src/core/path_utils.dart index 161d67b..f37436e 100644 --- a/lib/src/core/path_utils.dart +++ b/lib/src/core/path_utils.dart @@ -56,10 +56,20 @@ class WorkspaceScanOptions { final int maxParsedFileBytes; final int maxParsedDocuments; + + /// Maximum number of filesystem entries inspected while walking the tree. + /// + /// Directories, links, and unsupported files all consume this budget. final int maxTreeEntries; final bool followLinks; } +typedef WorkspaceDirectoryLister = + Stream Function( + Directory directory, { + required bool followLinks, + }); + class WorkspaceScanResult { const WorkspaceScanResult({ required this.entities, @@ -122,23 +132,36 @@ Future> listWorkspaceEntities(String rootPath) async { Future scanWorkspaceEntities( String rootPath, { WorkspaceScanOptions options = const WorkspaceScanOptions(), + WorkspaceDirectoryLister? directoryLister, }) async { final directory = Directory(rootPath); if (!await directory.exists()) { return const WorkspaceScanResult(entities: [], diagnostics: []); } + final listDirectory = directoryLister ?? _listWorkspaceDirectory; final entities = []; final diagnostics = []; final pending = [directory]; var pendingIndex = 0; + var inspectedEntries = 0; + var reachedTreeEntryLimit = options.maxTreeEntries <= 0; while (pendingIndex < pending.length && - entities.length < options.maxTreeEntries) { + inspectedEntries < options.maxTreeEntries) { final current = pending[pendingIndex++]; - List listing; + final listing = []; try { - listing = await current.list(followLinks: options.followLinks).toList() - ..sort((a, b) => a.path.compareTo(b.path)); + await for (final entity in listDirectory( + current, + followLinks: options.followLinks, + )) { + listing.add(entity); + inspectedEntries++; + if (inspectedEntries >= options.maxTreeEntries) { + reachedTreeEntryLimit = true; + break; + } + } } on Object catch (error) { diagnostics.add( _scanWarning( @@ -149,6 +172,7 @@ Future scanWorkspaceEntities( ); continue; } + listing.sort((a, b) => a.path.compareTo(b.path)); for (final entity in listing) { FileSystemEntityType type; @@ -182,12 +206,11 @@ Future scanWorkspaceEntities( continue; } entities.add(entity); - if (entities.length >= options.maxTreeEntries) { - diagnostics.add(_scanWarning(rootPath, 'workspace.scan.skipped')); - break; - } } } + if (reachedTreeEntryLimit) { + diagnostics.add(_scanWarning(rootPath, 'workspace.scan.skipped')); + } entities.sort((a, b) => a.path.compareTo(b.path)); return WorkspaceScanResult( entities: entities, @@ -195,6 +218,13 @@ Future scanWorkspaceEntities( ); } +Stream _listWorkspaceDirectory( + Directory directory, { + required bool followLinks, +}) { + return directory.list(followLinks: followLinks); +} + Diagnostic _scanWarning( String path, String code, { diff --git a/lib/src/editor/document_list_marker.dart b/lib/src/editor/document_list_marker.dart index 861d21b..30dded8 100644 --- a/lib/src/editor/document_list_marker.dart +++ b/lib/src/editor/document_list_marker.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../app/busymark_design.dart'; import '../app/busymark_glyphs.dart'; +import 'document_surface.dart'; /// Shared list marker geometry for Editor and Preview. class BusyMarkDocumentListMarker extends StatelessWidget { @@ -10,30 +11,36 @@ class BusyMarkDocumentListMarker extends StatelessWidget { this.ordered = false, this.marker, this.task, - }); + this.onTaskChanged, + this.taskTooltip, + }) : assert(onTaskChanged == null || task != null); final bool ordered; final String? marker; final bool? task; + final ValueChanged? onTaskChanged; + final String? taskTooltip; @override Widget build(BuildContext context) { final colors = BusyMarkSurfaceColors.of(context); final taskState = task; final markerWidget = taskState != null - ? Icon( - taskState ? BusyMarkGlyphs.checkedBox : BusyMarkGlyphs.task, - size: BusyMarkSizes.iconSm, - color: colors.mutedForeground, - ) + ? _taskMarker(context, colors, taskState) : ordered ? Text( marker ?? '1.', textAlign: TextAlign.end, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colors.mutedForeground, - fontFeatures: const [FontFeature.tabularFigures()], - ), + maxLines: 1, + softWrap: false, + style: + busyMarkDocumentBodyTextStyle( + context, + color: colors.foreground, + ).copyWith( + fontWeight: FontWeight.w600, + fontFeatures: const [FontFeature.tabularFigures()], + ), ) : Padding( padding: const EdgeInsets.only( @@ -59,6 +66,48 @@ class BusyMarkDocumentListMarker extends StatelessWidget { ), ); } + + Widget _taskMarker( + BuildContext context, + BusyMarkSurfaceColors colors, + bool taskState, + ) { + final foreground = taskState + ? Theme.of(context).colorScheme.primary + : colors.foreground; + final onChanged = onTaskChanged; + if (onChanged == null) { + return SizedBox.square( + dimension: BusyMarkSizes.compactIconButton, + child: Center( + child: Icon( + taskState ? BusyMarkGlyphs.checkedBox : BusyMarkGlyphs.task, + size: BusyMarkSizes.iconSm, + color: foreground, + ), + ), + ); + } + void toggle() => onChanged(!taskState); + final tooltip = taskTooltip ?? ''; + return Semantics( + container: true, + checked: taskState, + enabled: true, + label: tooltip.isEmpty ? null : tooltip, + onTap: toggle, + child: ExcludeSemantics( + child: BusyMarkCompactIconButton( + tooltip: tooltip, + icon: taskState ? BusyMarkGlyphs.checkedBox : BusyMarkGlyphs.task, + size: BusyMarkSizes.compactIconButton, + glyphSize: BusyMarkSizes.iconSm, + foregroundColor: foreground, + onPressed: toggle, + ), + ), + ); + } } EdgeInsets busyMarkDocumentListItemPadding({ diff --git a/lib/src/editor/document_surface.dart b/lib/src/editor/document_surface.dart index 2e45cc2..93069a6 100644 --- a/lib/src/editor/document_surface.dart +++ b/lib/src/editor/document_surface.dart @@ -12,16 +12,15 @@ TextStyle busyMarkDocumentBodyTextStyle(BuildContext context, {Color? color}) { /// Shared heading typography for editable and rendered document views. TextStyle busyMarkDocumentHeadingTextStyle(BuildContext context, int? level) { - final theme = Theme.of(context).textTheme; - final style = switch (level ?? 6) { - 1 => theme.headlineSmall, - 2 => theme.titleLarge, - 3 => theme.titleMedium, - 4 => theme.titleSmall, - 5 => theme.bodyLarge, - _ => theme.bodyMedium, - }; - return (style ?? const TextStyle()).copyWith(fontWeight: FontWeight.w700); + final effectiveLevel = level != null && level >= 1 && level <= 6 ? level : 6; + final bodyStyle = busyMarkDocumentBodyTextStyle(context); + final bodySize = bodyStyle.fontSize ?? BusyMarkTypography.defaultFontSize; + return bodyStyle.copyWith( + fontSize: + bodySize * BusyMarkTypography.markdownHeadingScale(effectiveLevel), + fontWeight: FontWeight.w700, + height: BusyMarkTypography.markdownHeadingLineHeight(effectiveLevel), + ); } /// Resolves the actual child inset of [BusyMarkDocumentSurface]. diff --git a/lib/src/editor/document_text_geometry.dart b/lib/src/editor/document_text_geometry.dart new file mode 100644 index 0000000..d420ef7 --- /dev/null +++ b/lib/src/editor/document_text_geometry.dart @@ -0,0 +1,19 @@ +import 'dart:ui' show BoxHeightStyle, BoxWidthStyle; + +/// Text-layout geometry shared by editable and rendered document views. +/// +/// Flutter's editable renderer keeps a one-pixel gap beside the caret in +/// addition to [editableCursorWidth]. Rendered text reserves the same trailing +/// extent so Editor and Preview make identical wrapping decisions. +abstract final class BusyMarkDocumentTextGeometry { + static const double editableCursorWidth = 2.0; + static const double editableCaretGap = 1.0; + static const double editableLayoutInset = + editableCaretGap + editableCursorWidth; + + /// Matches Flutter's native-platform `EditableText` selection geometry. + static const BoxHeightStyle selectionHeightStyle = + BoxHeightStyle.includeLineSpacingMiddle; + static const BoxWidthStyle selectionWidthStyle = BoxWidthStyle.tight; + static const double fallbackSelectionAlpha = 0.40; +} diff --git a/lib/src/editor/markdown_image_view.dart b/lib/src/editor/markdown_image_view.dart index cf953ea..1bd7d0f 100644 --- a/lib/src/editor/markdown_image_view.dart +++ b/lib/src/editor/markdown_image_view.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'dart:isolate'; import 'dart:typed_data'; import 'package:flutter/material.dart'; @@ -147,6 +148,15 @@ class MarkdownImageView extends StatelessWidget { } Widget _fileImage(File file, String source, bool svg) { + if (svg) { + return _LocalSvgImage( + file: file, + source: source, + alt: alt, + width: width, + height: height, + ); + } if (!file.existsSync()) { return _MarkdownImagePlaceholder( source: source, @@ -154,21 +164,6 @@ class MarkdownImageView extends StatelessWidget { height: height ?? 120, ); } - if (svg) { - final svg = _readLocalSvgForFlutter(file); - if (svg == null) { - return _MarkdownImagePlaceholder( - source: source, - alt: alt, - height: height ?? 120, - ); - } - final badge = svg.badge; - if (badge != null) { - return _SvgBadgeView(data: badge, width: width, height: height); - } - return _svgPicture(svg.source, source); - } return Image.file( file, width: width, @@ -191,21 +186,6 @@ class MarkdownImageView extends StatelessWidget { height: height, ); } - - Widget _svgPicture(String svgSource, String source) { - return SvgPicture.string( - svgSource, - width: width, - height: height, - fit: BoxFit.contain, - alignment: Alignment.center, - errorBuilder: (context, error, stackTrace) => _MarkdownImagePlaceholder( - source: source, - alt: alt, - height: height ?? 120, - ), - ); - } } bool _isSvgPath(String value) { @@ -213,6 +193,57 @@ bool _isSvgPath(String value) { return p.extension(path).toLowerCase() == '.svg'; } +class _LocalSvgImage extends StatefulWidget { + const _LocalSvgImage({ + required this.file, + required this.source, + required this.alt, + required this.width, + required this.height, + }); + + final File file; + final String source; + final String alt; + final double? width; + final double? height; + + @override + State<_LocalSvgImage> createState() => _LocalSvgImageState(); +} + +class _LocalSvgImageState extends State<_LocalSvgImage> { + late Future<_SvgImageSource?> _svgSource; + + @override + void initState() { + super.initState(); + _svgSource = _loadLocalSvgForFlutter(widget.file); + } + + @override + void didUpdateWidget(covariant _LocalSvgImage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.file.path != widget.file.path) { + _svgSource = _loadLocalSvgForFlutter(widget.file); + } + } + + @override + Widget build(BuildContext context) { + return FutureBuilder<_SvgImageSource?>( + future: _svgSource, + builder: (context, snapshot) => _buildAsyncSvgImage( + snapshot: snapshot, + source: widget.source, + alt: widget.alt, + width: widget.width, + height: widget.height, + ), + ); + } +} + class _NetworkSvgImage extends StatefulWidget { const _NetworkSvgImage({ required this.source, @@ -251,56 +282,68 @@ class _NetworkSvgImageState extends State<_NetworkSvgImage> { Widget build(BuildContext context) { return FutureBuilder<_SvgImageSource?>( future: _svgSource, - builder: (context, snapshot) { - final svg = snapshot.data; - if (snapshot.connectionState == ConnectionState.done && svg != null) { - final badge = svg.badge; - if (badge != null) { - return _SvgBadgeView( - data: badge, - width: widget.width, - height: widget.height, - ); - } - return SvgPicture.string( - svg.source, - width: widget.width, - height: widget.height, - fit: BoxFit.contain, - alignment: Alignment.center, - errorBuilder: (context, error, stackTrace) => - _MarkdownImagePlaceholder( - source: widget.source, - alt: widget.alt, - height: widget.height ?? 120, - ), - ); - } - if (snapshot.connectionState != ConnectionState.done) { - return SizedBox(height: widget.height ?? _defaultRemoteSvgHeight); - } - return _MarkdownImagePlaceholder( - source: widget.source, - alt: widget.alt, - height: widget.height ?? 120, - ); - }, + builder: (context, snapshot) => _buildAsyncSvgImage( + snapshot: snapshot, + source: widget.source, + alt: widget.alt, + width: widget.width, + height: widget.height, + ), + ); + } +} + +Widget _buildAsyncSvgImage({ + required AsyncSnapshot<_SvgImageSource?> snapshot, + required String source, + required String alt, + required double? width, + required double? height, +}) { + final svg = snapshot.data; + if (snapshot.connectionState == ConnectionState.done && svg != null) { + final badge = svg.badge; + if (badge != null) { + return _SvgBadgeView(data: badge, width: width, height: height); + } + return SvgPicture.string( + svg.source, + width: width, + height: height, + fit: BoxFit.contain, + alignment: Alignment.center, + errorBuilder: (context, error, stackTrace) => _MarkdownImagePlaceholder( + source: source, + alt: alt, + height: height ?? 120, + ), ); } + if (snapshot.connectionState != ConnectionState.done) { + return SizedBox(height: height ?? _defaultPendingSvgHeight); + } + return _MarkdownImagePlaceholder( + source: source, + alt: alt, + height: height ?? 120, + ); } -_SvgImageSource? _readLocalSvgForFlutter(File file) { +Future<_SvgImageSource?> _loadLocalSvgForFlutter(File file) async { try { - final source = file.readAsStringSync(); - final sanitized = _svgForFlutter(source); - if (sanitized == null) { + final stat = await file.stat(); + if (stat.type != FileSystemEntityType.file || stat.size > _maxSvgBytes) { return null; } - return _SvgImageSource( - source: sanitized, - badge: - _SvgBadgeData.tryParse(source) ?? _SvgBadgeData.tryParse(sanitized), - ); + final bytes = BytesBuilder(copy: false); + await for (final chunk in file.openRead(0, _maxSvgBytes + 1)) { + bytes.add(chunk); + if (bytes.length > _maxSvgBytes) { + return null; + } + } + final rawSource = utf8.decode(bytes.takeBytes()); + return await Isolate.run(() => _parseSvgForFlutter(rawSource)); } on Object { return null; } @@ -330,16 +373,7 @@ Future<_SvgImageSource?> _loadNetworkSvgForFlutter(String source) async { } } final rawSource = utf8.decode(bytes.takeBytes()); - final sanitized = _svgForFlutter(rawSource); - if (sanitized == null) { - return null; - } - return _SvgImageSource( - source: sanitized, - badge: - _SvgBadgeData.tryParse(rawSource) ?? - _SvgBadgeData.tryParse(sanitized), - ); + return _parseSvgForFlutter(rawSource); } on Object { return null; } finally { @@ -347,6 +381,18 @@ Future<_SvgImageSource?> _loadNetworkSvgForFlutter(String source) async { } } +_SvgImageSource? _parseSvgForFlutter(String rawSource) { + final sanitized = _svgForFlutter(rawSource); + if (sanitized == null) { + return null; + } + return _SvgImageSource( + source: sanitized, + badge: + _SvgBadgeData.tryParse(rawSource) ?? _SvgBadgeData.tryParse(sanitized), + ); +} + class _SvgImageSource { const _SvgImageSource({required this.source, required this.badge}); @@ -876,7 +922,7 @@ const _supportedSvgImageMimeTypes = { const _networkSvgTimeout = Duration(seconds: 10); const _maxSvgBytes = 1024 * 1024; -const _defaultRemoteSvgHeight = 24.0; +const _defaultPendingSvgHeight = 24.0; const _badgeHorizontalPadding = 4.0; const _badgeIconTextGap = 4.0; diff --git a/lib/src/editor/source/source_commands.dart b/lib/src/editor/source/source_commands.dart index 210c4db..d8c64f8 100644 --- a/lib/src/editor/source/source_commands.dart +++ b/lib/src/editor/source/source_commands.dart @@ -183,8 +183,15 @@ abstract final class SourceCommands { final markerPattern = RegExp( r'^(\s*)(?:#{1,6}\s+)?(?:[-*+]\s+(?:\[[ xX]\]\s+)?|\d+[.)]\s+)?(.*)$', ); + if (command == SourceBlockCommand.orderedList) { + return _replaceLineRange( + value, + range, + _orderedListLines(range.lines, markerPattern).join('\n'), + ); + } final replacementLines = []; - for (final (index, line) in range.lines.indexed) { + for (final line in range.lines) { final match = markerPattern.firstMatch(line); final indent = match?.group(1) ?? ''; final content = match?.group(2) ?? line.trimLeft(); @@ -196,7 +203,9 @@ abstract final class SourceCommands { SourceBlockCommand.heading4 => '#### ', SourceBlockCommand.heading5 => '##### ', SourceBlockCommand.heading6 => '###### ', - SourceBlockCommand.orderedList => '${index + 1}. ', + SourceBlockCommand.orderedList => throw StateError( + 'Ordered lists are handled above.', + ), SourceBlockCommand.unorderedList => '- ', SourceBlockCommand.taskList => '- [ ] ', }; @@ -344,9 +353,15 @@ abstract final class SourceCommands { ) { final selection = _normalizedSelection(value); final text = value.text; + var effectiveEnd = selection.end; + if (!selection.isCollapsed && + effectiveEnd > selection.start && + text.codeUnitAt(effectiveEnd - 1) == 0x0a) { + effectiveEnd -= 1; + } final lineStart = text.lastIndexOf('\n', math.max(0, selection.start - 1)) + 1; - final nextBreak = text.indexOf('\n', selection.end); + final nextBreak = text.indexOf('\n', effectiveEnd); final lineEnd = nextBreak < 0 ? text.length : nextBreak; return ( start: lineStart, @@ -355,6 +370,54 @@ abstract final class SourceCommands { ); } +List _orderedListLines(List lines, RegExp markerPattern) { + final levels = <_OrderedListLevel>[]; + final result = []; + for (final line in lines) { + final match = markerPattern.firstMatch(line); + final inputIndent = match?.group(1) ?? ''; + final inputColumns = _indentColumns(inputIndent); + final content = match?.group(2) ?? line.trimLeft(); + while (levels.isNotEmpty && levels.last.inputColumns > inputColumns) { + levels.removeLast(); + } + if (levels.isEmpty || levels.last.inputColumns < inputColumns) { + final outputIndent = levels.isEmpty + ? inputIndent + : '${levels.last.outputIndent}${' ' * (levels.last.markerWidth + 1)}'; + levels.add( + _OrderedListLevel( + inputColumns: inputColumns, + outputIndent: outputIndent, + ), + ); + } + final level = levels.last; + level.number += 1; + final marker = '${level.number}.'; + level.markerWidth = marker.length; + result.add('${level.outputIndent}$marker $content'); + } + return result; +} + +int _indentColumns(String indentation) { + var columns = 0; + for (final codeUnit in indentation.codeUnits) { + columns += codeUnit == 0x09 ? 4 : 1; + } + return columns; +} + +class _OrderedListLevel { + _OrderedListLevel({required this.inputColumns, required this.outputIndent}); + + final int inputColumns; + final String outputIndent; + int number = 0; + int markerWidth = 2; +} + TextSelection _normalizedSelection(TextEditingValue value) { final selection = value.selection; if (!selection.isValid) { diff --git a/lib/src/editor/source/source_editor.dart b/lib/src/editor/source/source_editor.dart index 5e50f4b..3f42698 100644 --- a/lib/src/editor/source/source_editor.dart +++ b/lib/src/editor/source/source_editor.dart @@ -35,6 +35,7 @@ class BusyMarkSourceEditor extends StatefulWidget { required this.onChanged, required this.onOpenSearch, required this.onCloseSearch, + this.onVisibleLineChanged, }); final String text; @@ -49,6 +50,7 @@ class BusyMarkSourceEditor extends StatefulWidget { final BusyMarkSourceChanged onChanged; final VoidCallback onOpenSearch; final VoidCallback onCloseSearch; + final ValueChanged? onVisibleLineChanged; @override State createState() => BusyMarkSourceEditorState(); @@ -234,6 +236,7 @@ class BusyMarkSourceEditorState extends State { diagnosticMarkers: markers, layoutCache: _lineLayoutCache, onToggleFold: _toggleFold, + onVisibleLineChanged: widget.onVisibleLineChanged, child: SizedBox( key: _sourceEditorKey, child: KeyedSubtree( @@ -817,6 +820,7 @@ class _SourceEditorFrame extends StatelessWidget { required this.diagnosticMarkers, required this.layoutCache, required this.onToggleFold, + this.onVisibleLineChanged, required this.child, }); @@ -837,6 +841,7 @@ class _SourceEditorFrame extends StatelessWidget { final List diagnosticMarkers; final SourceLineLayoutCache layoutCache; final ValueChanged onToggleFold; + final ValueChanged? onVisibleLineChanged; final Widget child; @override @@ -853,6 +858,48 @@ class _SourceEditorFrame extends StatelessWidget { final textWidth = math .max(1, editorWidth - editorPaddingLeft - editorPaddingRight) .toDouble(); + int? visibleLineAt(double scrollOffset) { + final layouts = layoutCache.resolve( + context, + controller: controller, + foldRegions: foldRegions, + collapsedRegionKeys: collapsedRegionKeys, + textStyle: textStyle, + strutStyle: strutStyle, + lineHeight: lineHeight, + textWidth: textWidth, + diagnostics: diagnosticMarkers, + ); + if (layouts.isEmpty) { + return null; + } + final anchor = scrollOffset + lineHeight * 0.25; + var low = 0; + var high = layouts.length - 1; + var result = 0; + while (low <= high) { + final middle = (low + high) >> 1; + if (layouts[middle].top <= anchor) { + result = middle; + low = middle + 1; + } else { + high = middle - 1; + } + } + return layouts[result].gutterLine.fullLine; + } + + void reportVisibleLine(double scrollOffset) { + onVisibleLineChanged?.call(visibleLineAt(scrollOffset)); + } + + if (onVisibleLineChanged != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) { + reportVisibleLine(safeScrollOffset(scrollController)); + } + }); + } return DecoratedBox( decoration: BoxDecoration(color: colors.view), child: Row( @@ -906,7 +953,17 @@ class _SourceEditorFrame extends StatelessWidget { layoutCache: layoutCache, ), ), - Positioned.fill(child: child), + Positioned.fill( + child: NotificationListener( + onNotification: (notification) { + if (notification.metrics.axis == Axis.vertical) { + reportVisibleLine(notification.metrics.pixels); + } + return false; + }, + child: child, + ), + ), ], ), ), diff --git a/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart b/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart index 6561542..4601bde 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart @@ -7,16 +7,13 @@ import '../document_callout.dart'; import '../document_code_block.dart'; import '../document_list_marker.dart'; import '../document_surface.dart'; +import '../document_text_geometry.dart'; import '../document_text_direction.dart'; import '../document_thematic_break.dart'; import '../markdown_image_view.dart'; import '../../markdown/busymark_document.dart'; import 'wysiwyg_inline_controller.dart'; -const double _wysiwygTextFieldCursorWidth = 2.0; -const double busyMarkWysiwygTextFieldLayoutInset = - 1.0 + _wysiwygTextFieldCursorWidth; - TextDirection busyMarkWysiwygBlockTextDirection( BusyBlock block, { required TextDirection fallback, @@ -87,7 +84,6 @@ EdgeInsets busyMarkWysiwygContentPadding(BusyBlock block) { BusyBlockKind.writersideRawXml || BusyBlockKind.htmlBlock || BusyBlockKind.unknown => BusyMarkInsets.wysiwygContainerContent, - BusyBlockKind.table => BusyMarkInsets.wysiwygTableContent, _ => EdgeInsets.zero, }; } @@ -150,6 +146,7 @@ class BusyMarkWysiwygBlockField extends StatelessWidget { required this.onTableDeleted, required this.onImageEditRequested, required this.onHtmlEditRequested, + required this.onTaskChanged, required this.onFocused, this.selected = false, this.selectionRange, @@ -180,6 +177,7 @@ class BusyMarkWysiwygBlockField extends StatelessWidget { final VoidCallback onTableDeleted; final VoidCallback onImageEditRequested; final VoidCallback onHtmlEditRequested; + final ValueChanged onTaskChanged; final VoidCallback onFocused; final bool selected; final BusyMarkWysiwygSelectionRange? selectionRange; @@ -255,14 +253,21 @@ class BusyMarkWysiwygBlockField extends StatelessWidget { : null, behavior: HitTestBehavior.translucent, onTap: tapHandler, - child: DecoratedBox( - decoration: BoxDecoration( - color: _background(context), - borderRadius: BorderRadius.circular(BusyMarkRadius.md), - border: _border(context), - ), - child: Padding(padding: _contentPadding, child: content), - ), + child: block.kind == BusyBlockKind.table + ? content + : DecoratedBox( + decoration: BoxDecoration( + color: _background(context), + borderRadius: BorderRadius.circular( + BusyMarkRadius.md, + ), + border: _border(context), + ), + child: Padding( + padding: _contentPadding, + child: content, + ), + ), ), ), ); @@ -349,15 +354,21 @@ class BusyMarkWysiwygBlockField extends StatelessWidget { text: controller.text, style: style, selectionRange: selectionRange, - color: Theme.of(context).colorScheme.primary - .withValues( - alpha: BusyMarkAlpha.previewHighlight, + 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: - busyMarkWysiwygTextFieldLayoutInset, + layoutWidthInset: BusyMarkDocumentTextGeometry + .editableLayoutInset, ), ), ), @@ -380,7 +391,12 @@ class BusyMarkWysiwygBlockField extends StatelessWidget { minLines: 1, textDirection: textDirection, style: style, - cursorWidth: _wysiwygTextFieldCursorWidth, + cursorWidth: + BusyMarkDocumentTextGeometry.editableCursorWidth, + selectionHeightStyle: + BusyMarkDocumentTextGeometry.selectionHeightStyle, + selectionWidthStyle: + BusyMarkDocumentTextGeometry.selectionWidthStyle, decoration: const InputDecoration( isCollapsed: true, border: InputBorder.none, @@ -443,7 +459,6 @@ class BusyMarkWysiwygBlockField extends StatelessWidget { BusyBlockKind.writersideRawXml || BusyBlockKind.htmlBlock || BusyBlockKind.unknown => fontSize * 2.4, - BusyBlockKind.table => fontSize * 5.8, BusyBlockKind.image => BusyMarkSizes.documentImageMinHeight, _ => 0, }; @@ -481,6 +496,8 @@ class BusyMarkWysiwygBlockField extends StatelessWidget { ), BusyBlockKind.taskListItem => BusyMarkDocumentListMarker( task: block.attributes['task'] == 'true', + onTaskChanged: onTaskChanged, + taskTooltip: context.l10n.toggleTaskChecked, ), BusyBlockKind.htmlBlock => SizedBox( width: BusyMarkSizes.wysiwygPrefixWidth, @@ -501,7 +518,6 @@ class BusyMarkWysiwygBlockField extends StatelessWidget { BusyBlockKind.writersideTabs || BusyBlockKind.writersideProcedure || BusyBlockKind.writersideRawXml || - BusyBlockKind.table || BusyBlockKind.htmlBlock || BusyBlockKind.unknown => colors.panel, _ => BusyMarkLinuxPalette.transparent, @@ -515,7 +531,6 @@ class BusyMarkWysiwygBlockField extends StatelessWidget { BusyBlockKind.writersideTabs || BusyBlockKind.writersideProcedure || BusyBlockKind.writersideRawXml || - BusyBlockKind.table || BusyBlockKind.htmlBlock || BusyBlockKind.unknown => Border.all(color: colors.subtleBorder), _ => null, @@ -1183,81 +1198,67 @@ class _TableBlockEditor extends StatelessWidget { final dataWidth = (columnCount * BusyMarkSizes.tableColumnBaseWidth) .clamp(BusyMarkSizes.tableMinWidth, BusyMarkSizes.tableMaxWidth) .toDouble(); - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Align( - alignment: AlignmentDirectional.centerEnd, - child: BusyMarkHeaderIconButton( - tooltip: context.l10n.deleteTable, - icon: BusyMarkGlyphs.delete, - foregroundColor: colors.mutedForeground, - onPressed: onTableDeleted, + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: dataWidth + _controlSize), + child: Table( + border: TableBorder( + horizontalInside: BorderSide(color: colors.subtleBorder), + verticalInside: BorderSide(color: colors.subtleBorder), + top: BorderSide(color: colors.subtleBorder), + right: BorderSide(color: colors.subtleBorder), + bottom: BorderSide(color: colors.subtleBorder), + left: BorderSide(color: colors.subtleBorder), + borderRadius: BorderRadius.circular(BusyMarkRadius.sm), ), - ), - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: ConstrainedBox( - constraints: BoxConstraints(minWidth: dataWidth + _controlSize), - child: Table( - border: TableBorder( - horizontalInside: BorderSide(color: colors.subtleBorder), - verticalInside: BorderSide(color: colors.subtleBorder), - top: BorderSide(color: colors.subtleBorder), - right: BorderSide(color: colors.subtleBorder), - bottom: BorderSide(color: colors.subtleBorder), - left: BorderSide(color: colors.subtleBorder), - borderRadius: BorderRadius.circular(BusyMarkRadius.sm), - ), - defaultVerticalAlignment: TableCellVerticalAlignment.middle, - columnWidths: { - 0: const FixedColumnWidth(_controlSize), - for (var index = 0; index < columnCount; index++) - index + 1: const FlexColumnWidth(), - }, + defaultVerticalAlignment: TableCellVerticalAlignment.middle, + columnWidths: { + 0: const FixedColumnWidth(_controlSize), + for (var index = 0; index < columnCount; index++) + index + 1: const FlexColumnWidth(), + }, + children: [ + TableRow( + decoration: BoxDecoration(color: colors.controlHover), children: [ - TableRow( - decoration: BoxDecoration(color: colors.controlHover), - children: [ - const _TableCornerCell(), - for (var column = 0; column < columnCount; column++) - _TableColumnControlCell( - columnIndex: column, - onInserted: onColumnInserted, - onDeleted: onColumnDeleted, - ), - ], - ), - for (final (rowIndex, row) in rows.indexed) - TableRow( - decoration: BoxDecoration( - color: _isHeaderRow(row, rowIndex) - ? colors.control - : BusyMarkLinuxPalette.transparent, - ), - children: [ - _TableRowControlCell( - rowIndex: rowIndex, - onInserted: onRowInserted, - onDeleted: onRowDeleted, - ), - for (var column = 0; column < columnCount; column++) - _TableCellEditor( - cell: column < row.children.length - ? row.children[column] - : null, - header: _isHeaderRow(row, rowIndex), - style: busyMarkDocumentBodyTextStyle(context), - onFocused: onFocused, - onChanged: onCellChanged, - ), - ], + _TableCornerCell(onTableDeleted: onTableDeleted), + for (var column = 0; column < columnCount; column++) + _TableColumnControlCell( + columnIndex: column, + onInserted: onColumnInserted, + onDeleted: onColumnDeleted, ), ], ), - ), + for (final (rowIndex, row) in rows.indexed) + TableRow( + decoration: BoxDecoration( + color: _isHeaderRow(row, rowIndex) + ? colors.control + : BusyMarkLinuxPalette.transparent, + ), + children: [ + _TableRowControlCell( + rowIndex: rowIndex, + onInserted: onRowInserted, + onDeleted: onRowDeleted, + ), + for (var column = 0; column < columnCount; column++) + _TableCellEditor( + cell: column < row.children.length + ? row.children[column] + : null, + header: _isHeaderRow(row, rowIndex), + style: busyMarkDocumentBodyTextStyle(context), + onFocused: onFocused, + onChanged: onCellChanged, + ), + ], + ), + ], ), - ], + ), ); } @@ -1279,11 +1280,23 @@ class _TableBlockEditor extends StatelessWidget { enum _TableControlAction { insertBefore, insertAfter, delete } class _TableCornerCell extends StatelessWidget { - const _TableCornerCell(); + const _TableCornerCell({required this.onTableDeleted}); + + final VoidCallback onTableDeleted; @override Widget build(BuildContext context) { - return const SizedBox.square(dimension: _TableBlockEditor._controlSize); + final colors = BusyMarkSurfaceColors.of(context); + return SizedBox.square( + dimension: _TableBlockEditor._controlSize, + child: BusyMarkHeaderIconButton( + tooltip: context.l10n.deleteTable, + icon: BusyMarkGlyphs.delete, + foregroundColor: colors.mutedForeground, + borderRadius: BusyMarkRadius.sm, + onPressed: onTableDeleted, + ), + ); } } @@ -1532,22 +1545,12 @@ class _WysiwygSelectionPainter extends CustomPainter { )..layout(maxWidth: maxWidth); final boxes = textPainter.getBoxesForSelection( TextSelection(baseOffset: start, extentOffset: end), + boxHeightStyle: BusyMarkDocumentTextGeometry.selectionHeightStyle, + boxWidthStyle: BusyMarkDocumentTextGeometry.selectionWidthStyle, ); final paint = Paint()..color = color; for (final box in boxes) { - final rect = Rect.fromLTRB( - box.left, - box.top, - box.right, - box.bottom, - ).inflate(BusyMarkStroke.selectionInflate); - canvas.drawRRect( - RRect.fromRectAndRadius( - rect, - const Radius.circular(BusyMarkRadius.selection), - ), - paint, - ); + canvas.drawRect(box.toRect(), paint); } } diff --git a/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart b/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart index 4264882..9bde342 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart @@ -176,7 +176,17 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { insertThematicBreakAfter(blockId); return; } - _replaceBlock(blockId, (block) => _blockWithCommand(block, command)); + final blocks = _replaceInBlocks( + _document.blocks, + blockId, + (block) => _blockWithCommand(block, command), + ); + _document = _document.copyWith( + blocks: command == BusyWysiwygBlockCommand.orderedList + ? _normalizeOrderedListMarkers(blocks) + : blocks, + ); + notifyListeners(); } void applyBlockCommandToBlocks( @@ -192,14 +202,13 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { return; } final idSet = ids.toSet(); - var orderedIndex = 0; + final replaced = _replaceBlocksByIds(_document.blocks, idSet, (block) { + return _blockWithCommand(block, command); + }); _document = _document.copyWith( - blocks: _replaceBlocksByIds(_document.blocks, idSet, (block) { - final orderedNumber = command == BusyWysiwygBlockCommand.orderedList - ? ++orderedIndex - : null; - return _blockWithCommand(block, command, orderedNumber: orderedNumber); - }), + blocks: command == BusyWysiwygBlockCommand.orderedList + ? _normalizeOrderedListMarkers(replaced) + : replaced, ); notifyListeners(); } @@ -718,9 +727,13 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { _isListItemKind(result.last.kind) && !result.last.isSourceProtected) { final parent = result.removeLast(); + final indented = _numberIndentedListItem( + updated.copyWith(dirty: true), + parent.children, + ); result.add( parent.copyWith( - children: [...parent.children, updated.copyWith(dirty: true)], + children: [...parent.children, indented], dirty: true, ), ); @@ -736,7 +749,9 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { if (!changed) { return false; } - _document = _document.copyWith(blocks: blocks); + _document = _document.copyWith( + blocks: _normalizeOrderedListMarkers(blocks), + ); notifyListeners(); return true; } @@ -788,7 +803,9 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { if (!changed) { return false; } - _document = _document.copyWith(blocks: result); + _document = _document.copyWith( + blocks: _normalizeOrderedListMarkers(result), + ); notifyListeners(); return true; } @@ -1292,17 +1309,20 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { Set blockIds, BusyBlock Function(BusyBlock) replace, ) { - return [ - for (final block in blocks) - if (block.isSourceProtected) - block - else if (blockIds.contains(block.id)) - replace(block) - else - block.copyWith( - children: _replaceBlocksByIds(block.children, blockIds, replace), - ), - ]; + final result = []; + for (final block in blocks) { + if (block.isSourceProtected) { + result.add(block); + continue; + } + var updated = blockIds.contains(block.id) ? replace(block) : block; + final children = _replaceBlocksByIds(updated.children, blockIds, replace); + if (!identical(children, updated.children)) { + updated = updated.copyWith(children: children); + } + result.add(updated); + } + return result; } Iterable _flatten(List blocks) sync* { @@ -1609,11 +1629,7 @@ BusyBlock _withoutSourceSpan(BusyBlock block, {required bool dirty}) { ); } -BusyBlock _blockWithCommand( - BusyBlock block, - BusyWysiwygBlockCommand command, { - int? orderedNumber, -}) { +BusyBlock _blockWithCommand(BusyBlock block, BusyWysiwygBlockCommand command) { final kind = blockKindForCommand(command); final attributes = {...block.attributes} ..remove('ordered') @@ -1645,9 +1661,7 @@ BusyBlock _blockWithCommand( } if (kind == BusyBlockKind.orderedListItem) { attributes['ordered'] = 'true'; - attributes['marker'] = orderedNumber == null - ? block.attributes['marker'] ?? '1.' - : '$orderedNumber.'; + attributes['marker'] = _orderedMarker(block) ?? '1.'; } if (kind == BusyBlockKind.unorderedListItem) { attributes['ordered'] = 'false'; @@ -1661,6 +1675,99 @@ BusyBlock _blockWithCommand( return block.copyWith(kind: kind, attributes: attributes, dirty: true); } +BusyBlock _numberIndentedListItem( + BusyBlock block, + List existingSiblings, +) { + if (!_isOrderedListBlock(block)) { + return block; + } + final previous = existingSiblings.lastOrNull; + final previousMarker = previous == null || !_isOrderedListBlock(previous) + ? null + : _orderedMarkerParts(previous.attributes['marker']); + final marker = previousMarker == null + ? '1.' + : '${previousMarker.number + 1}${previousMarker.suffix}'; + return _withOrderedMarker(block, marker); +} + +List _normalizeOrderedListMarkers(List blocks) { + var changed = false; + var nextNumber = 0; + var suffix = '.'; + final result = []; + for (final block in blocks) { + var updated = block; + if (!block.preserveRaw && !block.isSourceProtected) { + final children = _normalizeOrderedListMarkers(block.children); + if (!identical(children, block.children)) { + updated = block.copyWith(children: children); + changed = true; + } + } + if (updated.preserveRaw || updated.isSourceProtected) { + nextNumber = 0; + suffix = '.'; + result.add(updated); + continue; + } + if (_isOrderedListBlock(updated)) { + if (nextNumber == 0) { + final firstMarker = _orderedMarkerParts(updated.attributes['marker']); + nextNumber = firstMarker?.number ?? 1; + suffix = firstMarker?.suffix ?? '.'; + } + final marker = '$nextNumber$suffix'; + if (updated.attributes['marker'] != marker || + updated.attributes['ordered'] != 'true') { + updated = _withOrderedMarker(updated, marker); + changed = true; + } + nextNumber += 1; + } else { + nextNumber = 0; + suffix = '.'; + } + result.add(updated); + } + return changed ? result : blocks; +} + +BusyBlock _withOrderedMarker(BusyBlock block, String marker) { + return block.copyWith( + attributes: {...block.attributes, 'ordered': 'true', 'marker': marker}, + preserveRaw: false, + dirty: true, + ); +} + +bool _isOrderedListBlock(BusyBlock block) { + return block.kind == BusyBlockKind.orderedListItem || + (block.kind == BusyBlockKind.taskListItem && + block.attributes['ordered'] == 'true'); +} + +String? _orderedMarker(BusyBlock block) { + if (!_isOrderedListBlock(block)) { + return null; + } + final marker = _orderedMarkerParts(block.attributes['marker']); + return marker == null ? null : '${marker.number}${marker.suffix}'; +} + +({int number, String suffix})? _orderedMarkerParts(String? marker) { + final match = RegExp(r'^(\d+)([.)])$').firstMatch(marker?.trim() ?? ''); + if (match == null) { + return null; + } + final number = int.tryParse(match.group(1) ?? ''); + if (number == null) { + return null; + } + return (number: number, suffix: match.group(2) ?? '.'); +} + BusyBlock _blockWithInlineCommand( BusyBlock block, BusyWysiwygInlineCommand command, diff --git a/lib/src/editor/wysiwyg/wysiwyg_editor.dart b/lib/src/editor/wysiwyg/wysiwyg_editor.dart index a360760..3ffcb9b 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_editor.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_editor.dart @@ -21,6 +21,7 @@ import '../document_callout.dart'; import '../document_code_block.dart'; import '../document_layout.dart'; import '../document_surface.dart'; +import '../document_text_geometry.dart'; import 'wysiwyg_block_widgets.dart'; import 'wysiwyg_commands.dart'; import 'wysiwyg_document_controller.dart'; @@ -49,6 +50,7 @@ class BusyMarkWysiwygEditor extends StatefulWidget { this.scrollToBlockId, this.scrollToSearchQuery, this.scrollRequest = 0, + this.onVisibleHeadingChanged, this.onOpenSearch, this.onCloseSearch, this.headerBarService, @@ -71,6 +73,7 @@ class BusyMarkWysiwygEditor extends StatefulWidget { final String? scrollToBlockId; final String? scrollToSearchQuery; final int scrollRequest; + final ValueChanged? onVisibleHeadingChanged; final VoidCallback? onOpenSearch; final VoidCallback? onCloseSearch; final LinuxHeaderBarService? headerBarService; @@ -91,6 +94,11 @@ class _BusyMarkWysiwygEditorState extends State { final _undoStack = []; final _redoStack = []; final _itemScrollController = ItemScrollController(); + final _itemPositionsListener = ItemPositionsListener.create(); + List<({int itemIndex, DocumentOutlineHeading heading})> + _viewportOutlineStops = const []; + String? _reportedVisibleHeadingKey; + bool _hasReportedVisibleHeading = false; late final FocusNode _selectionFocusNode; String? _activeBlockId; _DocumentTextSelection? _documentSelection; @@ -117,10 +125,14 @@ class _BusyMarkWysiwygEditorState extends State { _documentController = BusyMarkWysiwygDocumentController( document: widget.document, )..addListener(_handleDocumentControllerChanged); + _itemPositionsListener.itemPositions.addListener( + _handleVisibleItemsChanged, + ); _syncBlockControllers(); _scheduleInitialFocus(); _scheduleHeadingScroll(); _scheduleSearchScroll(); + _scheduleVisibleHeadingReport(); } @override @@ -129,6 +141,7 @@ class _BusyMarkWysiwygEditorState extends State { final fileChanged = oldWidget.document.filePath != widget.document.filePath; final sourceChanged = oldWidget.document.source != widget.document.source; if (fileChanged || (sourceChanged && !_internalChange)) { + _hasReportedVisibleHeading = false; _undoStack.clear(); _redoStack.clear(); if (fileChanged) { @@ -138,6 +151,11 @@ class _BusyMarkWysiwygEditorState extends State { _documentController.replaceDocument(widget.document); _initialFocusScheduled = false; _scheduleInitialFocus(); + _scheduleVisibleHeadingReport(); + } else if (oldWidget.onVisibleHeadingChanged != + widget.onVisibleHeadingChanged) { + _hasReportedVisibleHeading = false; + _scheduleVisibleHeadingReport(); } if (oldWidget.scrollRequest != widget.scrollRequest) { _scheduleHeadingScroll(); @@ -147,6 +165,9 @@ class _BusyMarkWysiwygEditorState extends State { @override void dispose() { + _itemPositionsListener.itemPositions.removeListener( + _handleVisibleItemsChanged, + ); _documentController.removeListener(_handleDocumentControllerChanged); _documentController.dispose(); for (final controller in _textControllers.values) { @@ -183,6 +204,55 @@ class _BusyMarkWysiwygEditorState extends State { _resetVerticalCaretMovement(); } + void _scheduleVisibleHeadingReport() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _handleVisibleItemsChanged(); + } + }); + } + + void _handleVisibleItemsChanged() { + if (!mounted || _viewportOutlineStops.isEmpty) { + return; + } + int? firstVisible; + for (final position in _itemPositionsListener.itemPositions.value) { + if (position.itemTrailingEdge <= 0 || position.itemLeadingEdge >= 1) { + continue; + } + if (firstVisible == null || position.index < firstVisible) { + firstVisible = position.index; + } + } + if (firstVisible == null) { + return; + } + var low = 0; + var high = _viewportOutlineStops.length - 1; + DocumentOutlineHeading? heading; + while (low <= high) { + final middle = (low + high) >> 1; + final candidate = _viewportOutlineStops[middle]; + if (candidate.itemIndex <= firstVisible) { + heading = candidate.heading; + low = middle + 1; + } else { + high = middle - 1; + } + } + final key = heading == null + ? null + : '${heading.id}\u0000${heading.editorBlockId ?? ''}' + '\u0000${heading.sourceStartOffset ?? ''}'; + if (_hasReportedVisibleHeading && _reportedVisibleHeadingKey == key) { + return; + } + _hasReportedVisibleHeading = true; + _reportedVisibleHeadingKey = key; + widget.onVisibleHeadingChanged?.call(heading); + } + @override Widget build(BuildContext context) { final colors = BusyMarkSurfaceColors.of(context); @@ -190,6 +260,21 @@ class _BusyMarkWysiwygEditorState extends State { final renderEntries = _editorRenderEntries( _documentController.document.blocks, ); + final outline = _documentController.document.outline; + final outlineByBlockId = { + for (final heading in outline) + if (heading.editorBlockId case final blockId?) blockId: heading, + }; + final outlineByHeadingId = { + for (final heading in outline) heading.id: heading, + }; + _viewportOutlineStops = [ + for (final (index, entry) in renderEntries.indexed) + if (outlineByBlockId[entry.block.id] ?? + outlineByHeadingId[entry.block.attributes['id']] + case final heading?) + (itemIndex: index, heading: heading), + ]; final blocks = entries.map((entry) => entry.block).toList(); final blockSelectionActive = _hasBlockSelection; final selectionRangesByBlockId = { @@ -312,6 +397,7 @@ class _BusyMarkWysiwygEditorState extends State { child: ScrollablePositionedList.builder( key: const ValueKey('wysiwyg-document-scroll'), itemScrollController: _itemScrollController, + itemPositionsListener: _itemPositionsListener, padding: documentLayout.scrollPadding, itemCount: renderEntries.length, itemBuilder: (context, index) => _buildRenderEntry( @@ -534,6 +620,7 @@ class _BusyMarkWysiwygEditorState extends State { unawaited(_handleImageBlockEditRequested(block.id)), onHtmlEditRequested: () => unawaited(_handleHtmlBlockEditRequested(block.id)), + onTaskChanged: (checked) => _handleTaskCheckedChanged(block.id, checked), ); } @@ -1056,7 +1143,7 @@ class _BusyMarkWysiwygEditorState extends State { if (heading == null) { return; } - _jumpToBlockAndAlign(heading.id); + _jumpToBlockAndAlign(heading.id, alignment: 0); }); } @@ -1085,7 +1172,7 @@ class _BusyMarkWysiwygEditorState extends State { extentOffset: matchStart + query.length, ); } - _jumpToBlockAndAlign(target.id); + _jumpToBlockAndAlign(target.id, alignment: 0.04); }); } @@ -1101,7 +1188,7 @@ class _BusyMarkWysiwygEditorState extends State { return null; } - bool _ensureBlockVisible(String blockId) { + bool _ensureBlockVisible(String blockId, {required double alignment}) { final targetContext = _blockKeys[blockId]?.currentContext; if (targetContext == null) { return false; @@ -1110,24 +1197,24 @@ class _BusyMarkWysiwygEditorState extends State { targetContext, duration: BusyMarkMotion.scroll, curve: Curves.easeOutCubic, - alignment: 0.04, + alignment: alignment, ); return true; } - void _jumpToBlockAndAlign(String blockId) { + void _jumpToBlockAndAlign(String blockId, {required double alignment}) { final request = widget.scrollRequest; - if (!_jumpToBlock(blockId)) { + if (!_jumpToBlock(blockId, alignment: alignment)) { return; } WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && widget.scrollRequest == request) { - _ensureBlockVisible(blockId); + _ensureBlockVisible(blockId, alignment: alignment); } }); } - bool _jumpToBlock(String blockId) { + bool _jumpToBlock(String blockId, {required double alignment}) { if (!_itemScrollController.isAttached) { return false; } @@ -1138,7 +1225,7 @@ class _BusyMarkWysiwygEditorState extends State { if (index < 0) { return false; } - _itemScrollController.jumpTo(index: index, alignment: 0.04); + _itemScrollController.jumpTo(index: index, alignment: alignment); return true; } @@ -2558,6 +2645,19 @@ class _BusyMarkWysiwygEditorState extends State { _emitMarkdown(); } + void _handleTaskCheckedChanged(String blockId, bool checked) { + final block = _documentController.blockById(blockId); + if (block == null || + block.kind != BusyBlockKind.taskListItem || + (block.attributes['task'] == 'true') == checked) { + return; + } + _handleBlockFocused(blockId); + _recordUndoSnapshot(); + _documentController.toggleTaskChecked([blockId]); + _emitMarkdown(); + } + void _applyHardBreakCommand() { final blockId = _activeBlockId; if (blockId == null) { @@ -3549,9 +3649,10 @@ class _BusyMarkWysiwygEditorState extends State { outerPadding.horizontal) .clamp(1.0, double.infinity) .toDouble(); - final textLayoutWidth = (maxWidth - busyMarkWysiwygTextFieldLayoutInset) - .clamp(1.0, double.infinity) - .toDouble(); + final textLayoutWidth = + (maxWidth - BusyMarkDocumentTextGeometry.editableLayoutInset) + .clamp(1.0, double.infinity) + .toDouble(); final physicalLeftInset = outerPadding.left + contentPadding.left + diff --git a/lib/src/export/markdown_export_mapper.dart b/lib/src/export/markdown_export_mapper.dart index 6c9880b..492f336 100644 --- a/lib/src/export/markdown_export_mapper.dart +++ b/lib/src/export/markdown_export_mapper.dart @@ -60,6 +60,10 @@ class MarkdownExportMapper { var index = 0; while (index < blocks.length) { final block = blocks[index]; + if (block.isSourceOnly) { + index++; + continue; + } if (_isListItem(block.kind)) { final ordered = _isOrderedListItem(block); final items = []; diff --git a/lib/src/git/application/git_controller.dart b/lib/src/git/application/git_controller.dart index 101fef9..46c8ad9 100644 --- a/lib/src/git/application/git_controller.dart +++ b/lib/src/git/application/git_controller.dart @@ -171,6 +171,11 @@ class GitController extends Notifier { Timer? _debounce; var _knownHashes = {}; var _refreshEpoch = 0; + var _workspaceEpoch = 0; + var _commitDetailsEpoch = 0; + var _branchesEpoch = 0; + var _historyEpoch = 0; + var _diffEpoch = 0; var _isUpdatingWorkspaceTrust = false; @override @@ -199,6 +204,9 @@ class GitController extends Notifier { } void attachWorkspace(Workspace workspace) { + if (state.attachedWorkspace?.id != workspace.id) { + _workspaceEpoch++; + } if (_gateway is UnavailableGitRepositoryGateway) { _debounce?.cancel(); _knownHashes = {}; @@ -403,8 +411,11 @@ class GitController extends Notifier { Future loadProjectHistory() => _loadHistory(); Future loadCommitDetails(String hash) async { - final repository = _trustedRepositoryInfo; - if (repository == null || !_knownHashes.contains(hash)) { + final operation = _captureRepositoryOperation(); + if (operation == null) { + return; + } + if (!_knownHashes.contains(hash)) { state = state.copyWith( lastError: GitFailure( code: GitFailureCode.invalidPath, @@ -415,13 +426,19 @@ class GitController extends Notifier { ); return; } + final requestEpoch = ++_commitDetailsEpoch; + final historyFilePath = state.historyFilePath; state = state.copyWith(isRunningOperation: true, lastError: null); try { final details = await _gateway.commitDetails( - repository, + operation.repository, hash, - repoRelativePath: state.historyFilePath, + repoRelativePath: historyFilePath, ); + if (!_isCurrentRepositoryOperation(operation) || + requestEpoch != _commitDetailsEpoch) { + return; + } final firstFilePath = _firstDiffFilePath(details.changedFiles); state = state.copyWith( isRunningOperation: false, @@ -438,6 +455,10 @@ class GitController extends Notifier { ), ); } on Object catch (error) { + if (!_isCurrentRepositoryOperation(operation) || + requestEpoch != _commitDetailsEpoch) { + return; + } _setFailure(error, commandName: 'show'); state = state.copyWith(isRunningOperation: false); } @@ -501,9 +522,9 @@ class GitController extends Notifier { } Future discardFiles(List repoRelativePaths) async { - final repository = _trustedRepositoryInfo; + final operation = _captureRepositoryOperation(); final snapshot = state.statusSnapshot; - if (repository == null || snapshot == null) { + if (operation == null || snapshot == null) { return; } final failure = _validation.validateRepoRelativePaths(repoRelativePaths); @@ -527,33 +548,45 @@ class GitController extends Notifier { try { GitOperationResult? result; if (tracked.isNotEmpty) { - result = await _gateway.discardTracked(repository, tracked); + result = await _gateway.discardTracked(operation.repository, tracked); + if (!_isCurrentRepositoryOperation(operation)) { + return; + } } if (untracked.isNotEmpty) { result = await _gateway.discardUntracked( - repository, + operation.repository, untracked, snapshot, ); + if (!_isCurrentRepositoryOperation(operation)) { + return; + } } state = state.copyWith( isRunningOperation: false, lastOperationMessage: result?.message, ); await refresh(); + if (!_isCurrentRepositoryOperation(operation)) { + return; + } final selected = state.selectedFilePath; if (selected != null && repoRelativePaths.contains(selected)) { state = state.copyWith(selectedDiff: null); } } on Object catch (error) { + if (!_isCurrentRepositoryOperation(operation)) { + return; + } _setFailure(error, commandName: 'restore'); state = state.copyWith(isRunningOperation: false); } } Future commit(String message) async { - final repository = _trustedRepositoryInfo; - if (repository == null) { + final operation = _captureRepositoryOperation(); + if (operation == null) { return; } final messageFailure = _validation.validateCommitMessage(message); @@ -565,23 +598,32 @@ class GitController extends Notifier { state = state.copyWith(lastError: failure); return; } - await _runOperation((repository) => _gateway.commit(repository, message)); - await loadProjectHistory(); + final completed = await _runOperation( + (repository) => _gateway.commit(repository, message), + context: operation, + ); + if (completed) { + await loadProjectHistory(); + } } - Future pullFastForwardOnly() { - return _runOperation( + Future pullFastForwardOnly() async { + await _runOperation( (repository) => _gateway.pullFastForwardOnly(repository), ); } Future push({bool allowSetUpstream = false}) async { - final repository = _trustedRepositoryInfo; - if (repository == null) { + final operation = _captureRepositoryOperation(); + if (operation == null) { return; } + final repository = operation.repository; if (repository.upstreamBranch != null) { - await _runOperation((repository) => _gateway.push(repository)); + await _runOperation( + (repository) => _gateway.push(repository), + context: operation, + ); return; } final branch = repository.currentBranch; @@ -597,6 +639,12 @@ class GitController extends Notifier { return; } final remotes = await _gateway.remotes(repository); + final currentRepository = _trustedRepositoryInfo; + if (!_isCurrentRepositoryOperation(operation) || + currentRepository?.currentBranch != branch || + currentRepository?.upstreamBranch != null) { + return; + } if (remotes.isEmpty) { state = state.copyWith( lastError: const GitFailure( @@ -626,6 +674,7 @@ class GitController extends Notifier { await _runOperation( (repository) => _gateway.pushSetUpstream(repository, remotes.single, branch), + context: operation, ); } @@ -635,10 +684,12 @@ class GitController extends Notifier { state = state.copyWith(lastError: failure); return; } - await _runOperation( + final completed = await _runOperation( (repository) => _gateway.createBranch(repository, branchName.trim()), ); - await loadBranches(); + if (completed) { + await loadBranches(); + } } Future switchBranch(String branchName) async { @@ -653,18 +704,22 @@ class GitController extends Notifier { ); return; } - await _runOperation( + final completed = await _runOperation( (repository) => _gateway.switchBranch(repository, branchName), ); - await loadBranches(); + if (completed) { + await loadBranches(); + } } Future initializeRepository() async { final workspace = state.attachedWorkspace; + final context = _captureWorkspaceOperation(); final trustedWorkspacePath = workspace == null ? null : _trustedWorkspaceGitPath(workspace); if (workspace == null || + context == null || trustedWorkspacePath == null || workspace.kind == WorkspaceKind.untitledMarkdown || workspace.kind == WorkspaceKind.singleMarkdown) { @@ -673,20 +728,30 @@ class GitController extends Notifier { await _runRootOperation( trustedWorkspacePath, _gateway.initializeRepository, + context: context, ); } Future> loadBranches() async { - final repository = _trustedRepositoryInfo; - if (repository == null) { + final operation = _captureRepositoryOperation(); + if (operation == null) { return state.branches; } + final requestEpoch = ++_branchesEpoch; state = state.copyWith(isRunningOperation: true, lastError: null); try { - final branches = await _gateway.branches(repository); + final branches = await _gateway.branches(operation.repository); + if (!_isCurrentRepositoryOperation(operation) || + requestEpoch != _branchesEpoch) { + return state.branches; + } state = state.copyWith(isRunningOperation: false, branches: branches); return branches; } on Object catch (error) { + if (!_isCurrentRepositoryOperation(operation) || + requestEpoch != _branchesEpoch) { + return state.branches; + } _setFailure(error, commandName: 'branch'); state = state.copyWith(isRunningOperation: false); return state.branches; @@ -694,16 +759,21 @@ class GitController extends Notifier { } Future _loadHistory({String? repoRelativePath}) async { - final repository = _trustedRepositoryInfo; - if (repository == null) { + final operation = _captureRepositoryOperation(); + if (operation == null) { return; } + final requestEpoch = ++_historyEpoch; state = state.copyWith(isRunningOperation: true, lastError: null); try { final history = await _gateway.history( - repository, + operation.repository, repoRelativePath: repoRelativePath, ); + if (!_isCurrentRepositoryOperation(operation) || + requestEpoch != _historyEpoch) { + return; + } _knownHashes = {for (final commit in history) commit.fullHash}; state = state.copyWith( isRunningOperation: false, @@ -718,28 +788,41 @@ class GitController extends Notifier { : state.selectedView, ); } on Object catch (error) { + if (!_isCurrentRepositoryOperation(operation) || + requestEpoch != _historyEpoch) { + return; + } _setFailure(error, commandName: 'log'); state = state.copyWith(isRunningOperation: false); } } Future _loadChangedFileDiff(String repoRelativePath) async { - final repository = _trustedRepositoryInfo; - if (repository == null) { + final operation = _captureRepositoryOperation(); + if (operation == null) { return; } + final requestEpoch = ++_diffEpoch; state = state.copyWith(isRunningOperation: true, lastError: null); try { final staged = await _gateway.diffFile( - repository, + operation.repository, repoRelativePath, staged: true, ); + if (!_isCurrentRepositoryOperation(operation) || + requestEpoch != _diffEpoch) { + return; + } final unstaged = await _gateway.diffFile( - repository, + operation.repository, repoRelativePath, staged: false, ); + if (!_isCurrentRepositoryOperation(operation) || + requestEpoch != _diffEpoch) { + return; + } state = state.copyWith( isRunningOperation: false, selectedCommitFilePath: repoRelativePath, @@ -751,6 +834,10 @@ class GitController extends Notifier { ), ); } on Object catch (error) { + if (!_isCurrentRepositoryOperation(operation) || + requestEpoch != _diffEpoch) { + return; + } _setFailure(error, commandName: 'diff'); state = state.copyWith(isRunningOperation: false); } @@ -764,8 +851,8 @@ class GitController extends Notifier { ) operation, ) async { - final repository = _trustedRepositoryInfo; - if (repository == null) { + final context = _captureRepositoryOperation(); + if (context == null) { return; } final failure = _validation.validateRepoRelativePaths(repoRelativePaths); @@ -775,49 +862,79 @@ class GitController extends Notifier { } await _runOperation( (repository) => operation(repository, repoRelativePaths), + context: context, ); } - Future _runOperation( - Future Function(GitRepositoryInfo repository) operation, - ) async { - final repository = _trustedRepositoryInfo; - if (repository == null) { - return; + Future _runOperation( + Future Function(GitRepositoryInfo repository) + operation, { + _GitRepositoryOperation? context, + }) async { + final currentContext = context ?? _captureRepositoryOperation(); + if (currentContext == null || + !_isCurrentRepositoryOperation(currentContext)) { + return false; } state = state.copyWith(isRunningOperation: true, lastError: null); try { - final result = await operation(repository); + final result = await operation(currentContext.repository); + if (!_isCurrentRepositoryOperation(currentContext)) { + return false; + } state = state.copyWith( isRunningOperation: false, lastOperationMessage: result.message, ); await refresh(); + if (!_isCurrentRepositoryOperation(currentContext)) { + return false; + } final selected = state.selectedFilePath; if (selected != null) { await _loadChangedFileDiff(selected); + if (!_isCurrentRepositoryOperation(currentContext)) { + return false; + } } + return true; } on Object catch (error) { + if (!_isCurrentRepositoryOperation(currentContext)) { + return false; + } _setFailure(error, commandName: 'git'); state = state.copyWith(isRunningOperation: false); + return false; } } - Future _runRootOperation( + Future _runRootOperation( String rootPath, - Future Function(String rootPath) operation, - ) async { + Future Function(String rootPath) operation, { + required _GitWorkspaceOperation context, + }) async { + if (!_isCurrentWorkspaceOperation(context)) { + return false; + } state = state.copyWith(isRunningOperation: true, lastError: null); try { final result = await operation(rootPath); + if (!_isCurrentWorkspaceOperation(context)) { + return false; + } state = state.copyWith( isRunningOperation: false, lastOperationMessage: result.message, ); await refresh(); + return _isCurrentWorkspaceOperation(context); } on Object catch (error) { + if (!_isCurrentWorkspaceOperation(context)) { + return false; + } _setFailure(error, commandName: 'git'); state = state.copyWith(isRunningOperation: false); + return false; } } @@ -832,6 +949,7 @@ class GitController extends Notifier { void _setWorkspaceTrustRequiredState() { _debounce?.cancel(); + _workspaceEpoch++; _knownHashes = {}; state = state.copyWith( isRefreshing: false, @@ -885,6 +1003,44 @@ class GitController extends Notifier { return _refreshEpoch == refreshEpoch && _isCurrentWorkspace(workspaceId); } + _GitWorkspaceOperation? _captureWorkspaceOperation() { + final workspace = state.attachedWorkspace; + if (workspace == null) { + return null; + } + return _GitWorkspaceOperation( + workspaceId: workspace.id, + workspaceEpoch: _workspaceEpoch, + ); + } + + _GitRepositoryOperation? _captureRepositoryOperation() { + final workspace = _captureWorkspaceOperation(); + final repository = _trustedRepositoryInfo; + if (workspace == null || repository == null) { + return null; + } + return _GitRepositoryOperation( + workspace: workspace, + repository: repository, + ); + } + + bool _isCurrentWorkspaceOperation(_GitWorkspaceOperation operation) { + return _workspaceEpoch == operation.workspaceEpoch && + _isCurrentWorkspace(operation.workspaceId); + } + + bool _isCurrentRepositoryOperation(_GitRepositoryOperation operation) { + if (!_isCurrentWorkspaceOperation(operation.workspace)) { + return false; + } + final repository = _trustedRepositoryInfo; + return repository != null && + repository.rootPath == operation.repository.rootPath && + repository.gitDirPath == operation.repository.gitDirPath; + } + bool _workspaceHasGitTrust(Workspace workspace) { return _trustedWorkspaceGitPath(workspace) != null; } @@ -958,6 +1114,26 @@ class GitController extends Notifier { } } +class _GitWorkspaceOperation { + const _GitWorkspaceOperation({ + required this.workspaceId, + required this.workspaceEpoch, + }); + + final String workspaceId; + final int workspaceEpoch; +} + +class _GitRepositoryOperation { + const _GitRepositoryOperation({ + required this.workspace, + required this.repository, + }); + + final _GitWorkspaceOperation workspace; + final GitRepositoryInfo repository; +} + class UnavailableGitRepositoryGateway implements GitRepositoryGateway { const UnavailableGitRepositoryGateway(); diff --git a/lib/src/git/data/git_process_runner.dart b/lib/src/git/data/git_process_runner.dart index 8f30def..3246f71 100644 --- a/lib/src/git/data/git_process_runner.dart +++ b/lib/src/git/data/git_process_runner.dart @@ -2,11 +2,13 @@ import 'dart:async'; import 'dart:convert'; import 'dart:ffi'; import 'dart:io'; +import 'dart:typed_data'; import '../domain/git_models.dart'; const gitCommandTimeout = Duration(minutes: 2); const gitExecutableProbeTimeout = Duration(seconds: 5); +const gitCommandOutputLimitBytes = 32 * 1024 * 1024; const _terminationGracePeriod = Duration(milliseconds: 250); const _terminationCleanupTimeout = Duration(seconds: 1); @@ -45,9 +47,13 @@ class GitProcessResult { class DartGitCommandRunner implements GitCommandRunner { const DartGitCommandRunner({ this.processGroupLauncher = const GitProcessGroupLauncher(), - }); + this.maxOutputBytes = gitCommandOutputLimitBytes, + }) : assert(maxOutputBytes > 0); final GitProcessGroupLauncher processGroupLauncher; + final int maxOutputBytes; + + static final Object _outputLimitReached = Object(); @override Future run( @@ -76,8 +82,9 @@ class DartGitCommandRunner implements GitCommandRunner { includeParentEnvironment: true, runInShell: false, ); - final stdoutFuture = process.stdout.expand((bytes) => bytes).toList(); - final stderrFuture = process.stderr.expand((bytes) => bytes).toList(); + final outputCapture = _BoundedOutputCapture(maxOutputBytes); + final stdoutFuture = outputCapture.collect(process.stdout); + final stderrFuture = outputCapture.collect(process.stderr); final completion = _collectResult( process, executable, @@ -93,7 +100,16 @@ class DartGitCommandRunner implements GitCommandRunner { throw _timeoutFailure(commandName); } try { - return await completion.timeout(Duration(microseconds: remainingMicros)); + final outcome = await Future.any([ + completion, + outputCapture.limitReached.then((_) => _outputLimitReached), + ]).timeout(Duration(microseconds: remainingMicros)); + if (identical(outcome, _outputLimitReached) || outputCapture.exceeded) { + await _terminate(process, processGroup: launch.processGroup); + await _awaitCleanup(completion); + throw _outputLimitFailure(commandName); + } + return outcome as GitProcessResult; } on TimeoutException { await _terminate(process, processGroup: launch.processGroup); await _awaitCleanup(completion); @@ -167,6 +183,48 @@ class DartGitCommandRunner implements GitCommandRunner { commandName: commandName, ); } + + GitFailure _outputLimitFailure(String commandName) { + return GitFailure( + code: GitFailureCode.commandFailed, + userMessageKey: 'gitErrorCommandFailed', + rawMessage: + 'Git command output exceeded the $maxOutputBytes-byte capture limit.', + commandName: commandName, + ); + } +} + +class _BoundedOutputCapture { + _BoundedOutputCapture(this.maxBytes); + + final int maxBytes; + final Completer _limitReached = Completer(); + var _capturedBytes = 0; + + Future get limitReached => _limitReached.future; + bool get exceeded => _limitReached.isCompleted; + + Future> collect(Stream> stream) async { + final output = BytesBuilder(copy: false); + await for (final chunk in stream) { + if (exceeded) { + continue; + } + final remaining = maxBytes - _capturedBytes; + if (chunk.length <= remaining) { + output.add(chunk); + _capturedBytes += chunk.length; + continue; + } + if (remaining > 0) { + output.add(chunk.sublist(0, remaining)); + _capturedBytes += remaining; + } + _limitReached.complete(); + } + return output.takeBytes(); + } } class GitProcessGroupLauncher { diff --git a/lib/src/git/domain/git_diff_parser.dart b/lib/src/git/domain/git_diff_parser.dart index 905ed1e..01c2f96 100644 --- a/lib/src/git/domain/git_diff_parser.dart +++ b/lib/src/git/domain/git_diff_parser.dart @@ -1,3 +1,6 @@ +import 'dart:convert'; +import 'dart:typed_data'; + import 'git_models.dart'; class GitDiffParser { @@ -55,22 +58,22 @@ class GitDiffParser { continue; } if (line.startsWith('rename from ')) { - file.oldPath = line.substring('rename from '.length); + file.oldPath = _decodeGitPath(line.substring('rename from '.length)); file.status = GitDiffFileStatus.renamed; continue; } if (line.startsWith('rename to ')) { - file.newPath = line.substring('rename to '.length); + file.newPath = _decodeGitPath(line.substring('rename to '.length)); file.status = GitDiffFileStatus.renamed; continue; } if (line.startsWith('copy from ')) { - file.oldPath = line.substring('copy from '.length); + file.oldPath = _decodeGitPath(line.substring('copy from '.length)); file.status = GitDiffFileStatus.copied; continue; } if (line.startsWith('copy to ')) { - file.newPath = line.substring('copy to '.length); + file.newPath = _decodeGitPath(line.substring('copy to '.length)); file.status = GitDiffFileStatus.copied; continue; } @@ -82,14 +85,14 @@ class GitDiffParser { continue; } if (line.startsWith('--- ')) { - final path = _stripDiffPathPrefix(line.substring(4).split('\t').first); + final path = _parsePatchHeaderPath(line.substring(4)); if (path != '/dev/null') { file.oldPath = path; } continue; } if (line.startsWith('+++ ')) { - final path = _stripDiffPathPrefix(line.substring(4).split('\t').first); + final path = _parsePatchHeaderPath(line.substring(4)); if (path != '/dev/null') { file.newPath = path; } @@ -172,6 +175,30 @@ class GitDiffParser { (String?, String?) _parseDiffGitPaths(String line) { final rest = line.substring('diff --git '.length); + if (rest.startsWith('"')) { + final first = _readQuotedPath(rest, 0); + if (first == null) { + return (null, null); + } + var secondStart = first.$2; + while (secondStart < rest.length && + rest.codeUnitAt(secondStart) == 0x20) { + secondStart += 1; + } + if (secondStart >= rest.length) { + return (null, null); + } + final second = rest.startsWith('"', secondStart) + ? _readQuotedPath(rest, secondStart)?.$1 + : rest.substring(secondStart); + if (second == null) { + return (null, null); + } + return ( + _stripDiffPathPrefix(_decodeGitPath(first.$1)), + _stripDiffPathPrefix(_decodeGitPath(second)), + ); + } final marker = rest.indexOf(' b/'); if (rest.startsWith('a/') && marker > 0) { return ( @@ -186,6 +213,100 @@ class GitDiffParser { return (null, null); } + String _parsePatchHeaderPath(String value) { + final token = value.split('\t').first; + return _stripDiffPathPrefix(_decodeGitPath(token)); + } + + (String, int)? _readQuotedPath(String value, int start) { + if (start >= value.length || value.codeUnitAt(start) != 0x22) { + return null; + } + var index = start + 1; + while (index < value.length) { + final codeUnit = value.codeUnitAt(index); + if (codeUnit == 0x5c) { + index += 2; + continue; + } + if (codeUnit == 0x22) { + return (value.substring(start, index + 1), index + 1); + } + index += 1; + } + return null; + } + + String _decodeGitPath(String value) { + if (value.length < 2 || + value.codeUnitAt(0) != 0x22 || + value.codeUnitAt(value.length - 1) != 0x22) { + return value; + } + final output = BytesBuilder(copy: false); + var index = 1; + final end = value.length - 1; + while (index < end) { + if (value.codeUnitAt(index) != 0x5c) { + index = _appendUtf8Rune(output, value, index); + continue; + } + index += 1; + if (index >= end) { + output.addByte(0x5c); + break; + } + final escaped = value.codeUnitAt(index); + if (_isOctalDigit(escaped)) { + var byte = 0; + var digits = 0; + while (index < end && + digits < 3 && + _isOctalDigit(value.codeUnitAt(index))) { + byte = (byte * 8) + value.codeUnitAt(index) - 0x30; + index += 1; + digits += 1; + } + output.addByte(byte); + continue; + } + final simpleEscape = const { + 0x61: 0x07, + 0x62: 0x08, + 0x74: 0x09, + 0x6e: 0x0a, + 0x76: 0x0b, + 0x66: 0x0c, + 0x72: 0x0d, + 0x22: 0x22, + 0x5c: 0x5c, + }[escaped]; + if (simpleEscape != null) { + output.addByte(simpleEscape); + index += 1; + continue; + } + output.addByte(0x5c); + index = _appendUtf8Rune(output, value, index); + } + return utf8.decode(output.takeBytes(), allowMalformed: true); + } + + int _appendUtf8Rune(BytesBuilder output, String value, int index) { + final firstCodeUnit = value.codeUnitAt(index); + final hasSurrogatePair = + firstCodeUnit >= 0xd800 && + firstCodeUnit <= 0xdbff && + index + 1 < value.length && + value.codeUnitAt(index + 1) >= 0xdc00 && + value.codeUnitAt(index + 1) <= 0xdfff; + final nextIndex = index + (hasSurrogatePair ? 2 : 1); + output.add(utf8.encode(value.substring(index, nextIndex))); + return nextIndex; + } + + bool _isOctalDigit(int codeUnit) => codeUnit >= 0x30 && codeUnit <= 0x37; + String _stripDiffPathPrefix(String value) { if (value.startsWith('a/') || value.startsWith('b/')) { return value.substring(2); diff --git a/lib/src/git/presentation/git_changes_view.dart b/lib/src/git/presentation/git_changes_view.dart index ba88e1f..b8c371b 100644 --- a/lib/src/git/presentation/git_changes_view.dart +++ b/lib/src/git/presentation/git_changes_view.dart @@ -382,7 +382,7 @@ class _ChangedFileRow extends StatelessWidget { ), ), BusyMarkHeaderPopupMenuButton<_FileAction>( - tooltip: context.l10n.gitFileActions, + tooltip: context.l10n.fileActions, icon: BusyMarkGlyphs.menuHorizontal, transparent: true, itemBuilder: (context) => [ diff --git a/lib/src/markdown/markdown_section_editor.dart b/lib/src/markdown/markdown_section_editor.dart new file mode 100644 index 0000000..8586328 --- /dev/null +++ b/lib/src/markdown/markdown_section_editor.dart @@ -0,0 +1,252 @@ +import 'markdown_model.dart'; + +/// A lossless editor for one heading section in a Markdown source document. +/// +/// A section starts at its heading and ends immediately before the next +/// heading at the same or a higher rank. Descendant headings therefore travel +/// with the section during copy, cut, delete, and sibling moves. +class MarkdownSectionEditor { + const MarkdownSectionEditor._({ + required this.source, + required this.headings, + required this.headingIndex, + required this.sectionEndIndex, + required this.startOffset, + required this.endOffset, + required this.previousSiblingIndex, + required this.nextSiblingIndex, + }); + + factory MarkdownSectionEditor.fromHeadings({ + required String source, + required List headings, + required int headingIndex, + }) { + if (headingIndex < 0 || headingIndex >= headings.length) { + throw RangeError.index(headingIndex, headings, 'headingIndex'); + } + _validateHeadingOffsets(source, headings); + + final level = headings[headingIndex].level; + final sectionEndIndex = _sectionEndIndex(headings, headingIndex); + final startOffset = headings[headingIndex].span.startOffset; + final endOffset = sectionEndIndex < headings.length + ? headings[sectionEndIndex].span.startOffset + : source.length; + int? previousSiblingIndex; + for (var index = headingIndex - 1; index >= 0; index -= 1) { + final candidateLevel = headings[index].level; + if (candidateLevel < level) { + break; + } + if (candidateLevel == level) { + previousSiblingIndex = index; + break; + } + } + final nextSiblingIndex = + sectionEndIndex < headings.length && + headings[sectionEndIndex].level == level + ? sectionEndIndex + : null; + + return MarkdownSectionEditor._( + source: source, + headings: headings, + headingIndex: headingIndex, + sectionEndIndex: sectionEndIndex, + startOffset: startOffset, + endOffset: endOffset, + previousSiblingIndex: previousSiblingIndex, + nextSiblingIndex: nextSiblingIndex, + ); + } + + final String source; + final List headings; + final int headingIndex; + final int sectionEndIndex; + final int startOffset; + final int endOffset; + final int? previousSiblingIndex; + final int? nextSiblingIndex; + + int get level => headings[headingIndex].level; + + String get sectionText => source.substring(startOffset, endOffset); + + bool get canPromote => level > 1; + + bool get canDemote { + for (var index = headingIndex; index < sectionEndIndex; index += 1) { + if (headings[index].level >= 6) { + return false; + } + } + return true; + } + + bool get canMoveUp => previousSiblingIndex != null; + + bool get canMoveDown => nextSiblingIndex != null; + + String withoutSection() => source.replaceRange(startOffset, endOffset, ''); + + String? promote() => canPromote ? _changeLevels(-1) : null; + + String? demote() => canDemote ? _changeLevels(1) : null; + + String? moveUp() { + final previousIndex = previousSiblingIndex; + if (previousIndex == null) { + return null; + } + final previousStart = headings[previousIndex].span.startOffset; + final previousSection = source.substring(previousStart, startOffset); + return source.replaceRange( + previousStart, + endOffset, + '$sectionText$previousSection', + ); + } + + String? moveDown() { + final nextIndex = nextSiblingIndex; + if (nextIndex == null) { + return null; + } + final nextEndIndex = _sectionEndIndex(headings, nextIndex); + final nextEndOffset = nextEndIndex < headings.length + ? headings[nextEndIndex].span.startOffset + : source.length; + final nextSection = source.substring(endOffset, nextEndOffset); + return source.replaceRange( + startOffset, + nextEndOffset, + '$nextSection$sectionText', + ); + } + + String? _changeLevels(int delta) { + final replacements = <_SourceReplacement>[]; + for (var index = headingIndex; index < sectionEndIndex; index += 1) { + final heading = headings[index]; + final nextLevel = heading.level + delta; + if (nextLevel < 1 || nextLevel > 6) { + return null; + } + final replacement = _headingLevelReplacement(source, heading, nextLevel); + if (replacement == null) { + return null; + } + replacements.add(replacement); + } + replacements.sort((left, right) => right.start.compareTo(left.start)); + var result = source; + for (final replacement in replacements) { + result = result.replaceRange( + replacement.start, + replacement.end, + replacement.text, + ); + } + return result; + } +} + +int _sectionEndIndex(List headings, int headingIndex) { + final level = headings[headingIndex].level; + var index = headingIndex + 1; + while (index < headings.length && headings[index].level > level) { + index += 1; + } + return index; +} + +void _validateHeadingOffsets(String source, List headings) { + var previousOffset = -1; + for (final heading in headings) { + final offset = heading.span.startOffset; + if (offset < 0 || offset > source.length || offset <= previousOffset) { + throw ArgumentError.value( + headings, + 'headings', + 'Heading offsets must be ordered and inside the source.', + ); + } + previousOffset = offset; + } +} + +_SourceReplacement? _headingLevelReplacement( + String source, + MarkdownHeading heading, + int nextLevel, +) { + final start = heading.span.startOffset; + final firstLineEnd = _lineContentEnd(source, start); + final firstLine = source.substring(start, firstLineEnd); + final atx = RegExp(r'^([ \t]{0,3})(#{1,6})(?=[ \t]|$)').firstMatch(firstLine); + if (atx != null) { + final markerStart = start + atx.group(1)!.length; + return _SourceReplacement( + start: markerStart, + end: markerStart + atx.group(2)!.length, + text: _repeatCharacter('#', nextLevel), + ); + } + + final firstBreak = source.indexOf('\n', start); + if (firstBreak < 0) { + return null; + } + final underlineStart = firstBreak + 1; + final underlineEnd = _lineContentEnd(source, underlineStart); + final underline = source.substring(underlineStart, underlineEnd); + final setext = RegExp(r'^([ \t]{0,3})(=+|-+)([ \t]*)$').firstMatch(underline); + if (setext == null) { + return null; + } + if (nextLevel <= 2) { + final marker = nextLevel == 1 ? '=' : '-'; + final markerLength = setext.group(2)!.length; + final markerStart = underlineStart + setext.group(1)!.length; + return _SourceReplacement( + start: markerStart, + end: markerStart + markerLength, + text: _repeatCharacter(marker, markerLength), + ); + } + + final indent = RegExp(r'^[ \t]{0,3}').firstMatch(firstLine)!.group(0)!; + final content = firstLine.substring(indent.length).trimRight(); + return _SourceReplacement( + start: start, + end: underlineEnd, + text: '$indent${_repeatCharacter('#', nextLevel)} $content', + ); +} + +String _repeatCharacter(String character, int count) => + List.filled(count, character).join(); + +int _lineContentEnd(String source, int start) { + final lineFeed = source.indexOf('\n', start); + var end = lineFeed < 0 ? source.length : lineFeed; + if (end > start && source.codeUnitAt(end - 1) == 13) { + end -= 1; + } + return end; +} + +class _SourceReplacement { + const _SourceReplacement({ + required this.start, + required this.end, + required this.text, + }); + + final int start; + final int end; + final String text; +} diff --git a/lib/src/platform/header_bar_configuration.dart b/lib/src/platform/header_bar_configuration.dart index cf3de2b..b54b461 100644 --- a/lib/src/platform/header_bar_configuration.dart +++ b/lib/src/platform/header_bar_configuration.dart @@ -30,10 +30,14 @@ class HeaderBarLabels { required this.sidebar, required this.sidebarShortcut, required this.back, + required this.backShortcut, required this.save, required this.exportPdf, required this.exportPdfShortcut, required this.exportPdfGtkAccelerator, + required this.fullScreen, + required this.fullScreenShortcut, + required this.fullScreenGtkAccelerator, required this.settings, required this.settingsShortcut, required this.settingsGtkAccelerator, @@ -67,10 +71,14 @@ class HeaderBarLabels { final String sidebar; final String sidebarShortcut; final String back; + final String backShortcut; final String save; final String exportPdf; final String exportPdfShortcut; final String exportPdfGtkAccelerator; + final String fullScreen; + final String fullScreenShortcut; + final String fullScreenGtkAccelerator; final String settings; final String settingsShortcut; final String settingsGtkAccelerator; @@ -104,10 +112,14 @@ class HeaderBarLabels { 'sidebar': sidebar, 'sidebarShortcut': sidebarShortcut, 'back': back, + 'backShortcut': backShortcut, 'save': save, 'exportPdf': exportPdf, 'exportPdfShortcut': exportPdfShortcut, 'exportPdfGtkAccelerator': exportPdfGtkAccelerator, + 'fullScreen': fullScreen, + 'fullScreenShortcut': fullScreenShortcut, + 'fullScreenGtkAccelerator': fullScreenGtkAccelerator, 'settings': settings, 'settingsShortcut': settingsShortcut, 'settingsGtkAccelerator': settingsGtkAccelerator, @@ -289,6 +301,7 @@ class HeaderBarConfiguration { required this.sidebarVisible, required this.sidebarToggleVisible, required this.backVisible, + required this.fullScreen, required this.modalBarrierDepth, required this.sidebarWidth, required this.labels, @@ -309,6 +322,7 @@ class HeaderBarConfiguration { final bool sidebarVisible; final bool sidebarToggleVisible; final bool backVisible; + final bool fullScreen; final int modalBarrierDepth; final double sidebarWidth; final HeaderBarLabels labels; @@ -330,6 +344,7 @@ class HeaderBarConfiguration { bool? sidebarVisible, bool? sidebarToggleVisible, bool? backVisible, + bool? fullScreen, int? modalBarrierDepth, double? sidebarWidth, HeaderBarLabels? labels, @@ -350,6 +365,7 @@ class HeaderBarConfiguration { sidebarVisible: sidebarVisible ?? this.sidebarVisible, sidebarToggleVisible: sidebarToggleVisible ?? this.sidebarToggleVisible, backVisible: backVisible ?? this.backVisible, + fullScreen: fullScreen ?? this.fullScreen, modalBarrierDepth: modalBarrierDepth ?? this.modalBarrierDepth, sidebarWidth: sidebarWidth ?? this.sidebarWidth, labels: labels ?? this.labels, @@ -371,6 +387,7 @@ class HeaderBarConfiguration { 'sidebarVisible': sidebarVisible, 'sidebarToggleVisible': sidebarToggleVisible, 'backVisible': backVisible, + 'fullScreen': fullScreen, 'modalBarrierVisible': modalBarrierVisible, 'modalBarrierDepth': modalBarrierDepth, 'sidebarWidth': sidebarWidth, @@ -391,6 +408,7 @@ class HeaderBarConfiguration { sidebarVisible == other.sidebarVisible && sidebarToggleVisible == other.sidebarToggleVisible && backVisible == other.backVisible && + fullScreen == other.fullScreen && modalBarrierDepth == other.modalBarrierDepth && sidebarWidth == other.sidebarWidth && labels == other.labels && @@ -420,6 +438,7 @@ class HeaderBarConfiguration { sidebarVisible, sidebarToggleVisible, backVisible, + fullScreen, modalBarrierDepth, sidebarWidth, labels, diff --git a/lib/src/platform/linux_header_bar_service.dart b/lib/src/platform/linux_header_bar_service.dart index 3b07386..2d99dd2 100644 --- a/lib/src/platform/linux_header_bar_service.dart +++ b/lib/src/platform/linux_header_bar_service.dart @@ -17,6 +17,7 @@ enum HeaderBarAction { refresh, save, exportPdf, + fullScreen, menu, settings, keyboardShortcuts, @@ -27,6 +28,11 @@ enum HeaderBarAction { viewModeSource, viewModePreview, viewModeSplit, + sidebarFiles, + sidebarToc, + sidebarOutline, + sidebarGit, + sidebarHistory, } class HeaderBarActionEvent { @@ -386,6 +392,7 @@ class LinuxHeaderBarService extends ChangeNotifier { 'refresh' => HeaderBarAction.refresh, 'save' => HeaderBarAction.save, 'exportPdf' => HeaderBarAction.exportPdf, + 'fullScreen' => HeaderBarAction.fullScreen, 'menu' => HeaderBarAction.menu, 'settings' => HeaderBarAction.settings, 'keyboardShortcuts' => HeaderBarAction.keyboardShortcuts, @@ -396,6 +403,11 @@ class LinuxHeaderBarService extends ChangeNotifier { 'viewModeSource' => HeaderBarAction.viewModeSource, 'viewModePreview' => HeaderBarAction.viewModePreview, 'viewModeSplit' => HeaderBarAction.viewModeSplit, + 'sidebarFiles' => HeaderBarAction.sidebarFiles, + 'sidebarToc' => HeaderBarAction.sidebarToc, + 'sidebarOutline' => HeaderBarAction.sidebarOutline, + 'sidebarGit' => HeaderBarAction.sidebarGit, + 'sidebarHistory' => HeaderBarAction.sidebarHistory, _ => null, }; } diff --git a/lib/src/workspace/presentation/settings_screen.dart b/lib/src/workspace/presentation/settings_screen.dart index 9566af4..0a505b2 100644 --- a/lib/src/workspace/presentation/settings_screen.dart +++ b/lib/src/workspace/presentation/settings_screen.dart @@ -12,7 +12,9 @@ import '../../app/busymark_dialogs.dart'; import '../../app/busymark_design.dart'; import '../../app/busymark_glyphs.dart'; import '../../app/busymark_main_menu.dart'; +import '../../app/busymark_shortcuts.dart'; import '../../app/localization.dart'; +import '../../app/window_control_service.dart'; import '../../feedback/presentation/feedback_dialog.dart'; import '../../platform/linux_header_bar_service.dart'; @@ -305,11 +307,17 @@ class _SettingsScreenState extends ConsumerState { case HeaderBarAction.refresh: case HeaderBarAction.save: case HeaderBarAction.exportPdf: + case HeaderBarAction.fullScreen: case HeaderBarAction.menu: case HeaderBarAction.viewModeEditor: case HeaderBarAction.viewModeSource: case HeaderBarAction.viewModePreview: case HeaderBarAction.viewModeSplit: + case HeaderBarAction.sidebarFiles: + case HeaderBarAction.sidebarToc: + case HeaderBarAction.sidebarOutline: + case HeaderBarAction.sidebarGit: + case HeaderBarAction.sidebarHistory: break; } } @@ -322,6 +330,8 @@ class _SettingsScreenState extends ConsumerState { switch (action) { case BusyMarkMainMenuAction.exportPdf: break; + case BusyMarkMainMenuAction.fullScreen: + unawaited(ref.read(windowControlServiceProvider).toggleFullScreen()); case BusyMarkMainMenuAction.settings: _selectPage(SettingsPage.appearance); case BusyMarkMainMenuAction.keyboardShortcuts: @@ -487,6 +497,7 @@ class _SettingsFallbackHeader extends StatelessWidget { BusyMarkHeaderIconButton( tooltip: context.l10n.back, icon: BusyMarkGlyphs.backFor(Directionality.of(context)), + shortcut: BusyMarkAppShortcutLabels.back, onPressed: onBack, ), const SizedBox(width: BusyMarkSpacing.sm), diff --git a/lib/src/workspace/presentation/welcome_screen.dart b/lib/src/workspace/presentation/welcome_screen.dart index 9def107..9edcdd6 100644 --- a/lib/src/workspace/presentation/welcome_screen.dart +++ b/lib/src/workspace/presentation/welcome_screen.dart @@ -17,6 +17,7 @@ import '../../app/busymark_glyphs.dart'; import '../../app/busymark_main_menu.dart'; import '../../app/busymark_shortcuts.dart'; import '../../app/localization.dart'; +import '../../app/window_control_service.dart'; import '../../core/debug_log.dart'; import '../../core/path_utils.dart'; import '../../feedback/presentation/feedback_dialog.dart'; @@ -233,11 +234,17 @@ class _WelcomeScreenState extends ConsumerState { case HeaderBarAction.refresh: case HeaderBarAction.save: case HeaderBarAction.exportPdf: + case HeaderBarAction.fullScreen: case HeaderBarAction.menu: case HeaderBarAction.viewModeEditor: case HeaderBarAction.viewModeSource: case HeaderBarAction.viewModePreview: case HeaderBarAction.viewModeSplit: + case HeaderBarAction.sidebarFiles: + case HeaderBarAction.sidebarToc: + case HeaderBarAction.sidebarOutline: + case HeaderBarAction.sidebarGit: + case HeaderBarAction.sidebarHistory: break; } } @@ -259,6 +266,8 @@ class _WelcomeScreenState extends ConsumerState { switch (action) { case BusyMarkMainMenuAction.exportPdf: break; + case BusyMarkMainMenuAction.fullScreen: + unawaited(ref.read(windowControlServiceProvider).toggleFullScreen()); case BusyMarkMainMenuAction.settings: context.go(settingsLocation(SettingsReturnTarget.welcome)); case BusyMarkMainMenuAction.keyboardShortcuts: diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index e1400ab..2d01178 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -21,6 +21,7 @@ import '../../app/busymark_main_menu.dart'; import '../../app/busymark_search_field.dart'; import '../../app/busymark_shortcuts.dart'; import '../../app/localization.dart'; +import '../../app/window_control_service.dart'; import '../../core/diagnostic.dart'; import '../../core/diagnostic_localizations.dart'; import '../../core/path_utils.dart' show slugForHeading; @@ -30,6 +31,7 @@ import '../../editor/document_code_block.dart'; import '../../editor/document_layout.dart'; import '../../editor/document_list_marker.dart'; import '../../editor/document_surface.dart'; +import '../../editor/document_text_geometry.dart'; import '../../editor/document_text_direction.dart'; import '../../editor/document_thematic_break.dart'; import '../../editor/markdown_image_view.dart'; @@ -50,6 +52,7 @@ import '../../markdown/busymark_document.dart'; import '../../markdown/document_outline.dart'; import '../../markdown/markdown_model.dart'; import '../../markdown/markdown_parser.dart'; +import '../../markdown/markdown_section_editor.dart'; import '../../markdown/preview_model.dart'; import '../../platform/linux_header_bar_service.dart'; import '../../writerside/writerside_model.dart'; @@ -68,6 +71,10 @@ final _outlineNavigationTargetProvider = _OutlineNavigationTargetController, _OutlineNavigationTarget? >(_OutlineNavigationTargetController.new); +final _outlineViewportTargetProvider = + NotifierProvider<_OutlineViewportTargetController, _OutlineViewportTarget?>( + _OutlineViewportTargetController.new, + ); final _sourceNavigationTargetProvider = NotifierProvider< _SourceNavigationTargetController, @@ -97,6 +104,19 @@ class _OutlineNavigationTargetController } } +class _OutlineViewportTargetController + extends Notifier<_OutlineViewportTarget?> { + @override + _OutlineViewportTarget? build() => null; + + void set(_OutlineViewportTarget target) { + if (state?.sameLocationAs(target) ?? false) { + return; + } + state = target; + } +} + class _SourceNavigationTargetController extends Notifier<_SourceNavigationTarget?> { @override @@ -108,11 +128,117 @@ class _SourceNavigationTargetController } class _WorkspaceSearchController extends Notifier<_WorkspaceSearchState> { + static const _debounceDelay = Duration(milliseconds: 120); + + Timer? _debounce; + late Future Function(String path) _loadText; + var _request = 0; + var _disposed = false; + @override - _WorkspaceSearchState build() => const _WorkspaceSearchState(); + _WorkspaceSearchState build() { + _loadText = ref.read(workspaceServiceProvider).loadText; + ref.listen(workspaceControllerProvider, (previous, next) { + refresh(next); + }); + ref.onDispose(() { + _disposed = true; + _request += 1; + _debounce?.cancel(); + }); + return const _WorkspaceSearchState(); + } void set(_WorkspaceSearchState searchState) { - state = searchState; + final inputChanged = + state.query != searchState.query || + state.caseSensitive != searchState.caseSensitive || + state.wholeWord != searchState.wholeWord || + state.regex != searchState.regex || + state.active != searchState.active; + state = searchState.copyWith( + matches: inputChanged ? const [] : state.matches, + searching: false, + ); + _schedule(ref.read(workspaceControllerProvider)); + } + + void refresh(WorkspaceState workspaceState) { + if (!state.active || state.options.query.isEmpty) { + return; + } + _schedule(workspaceState); + } + + Future submit() async { + _debounce?.cancel(); + final request = ++_request; + final workspaceState = ref.read(workspaceControllerProvider); + final options = state.options; + if (!state.active || + options.query.isEmpty || + workspaceState.workspace == null) { + return false; + } + state = state.copyWith(matches: const [], searching: true); + await _runSearch( + request: request, + workspaceState: workspaceState, + options: options, + ); + return !_disposed && request == _request; + } + + void _schedule(WorkspaceState workspaceState) { + _debounce?.cancel(); + final request = ++_request; + final workspace = workspaceState.workspace; + final options = state.options; + if (!state.active || options.query.isEmpty || workspace == null) { + final clearMatches = options.query.isEmpty || workspace == null; + if (state.searching || (clearMatches && state.matches.isNotEmpty)) { + state = state.copyWith( + matches: clearMatches ? const [] : state.matches, + searching: false, + ); + } + return; + } + state = state.copyWith(matches: const [], searching: true); + _debounce = Timer(_debounceDelay, () { + _debounce = null; + unawaited( + _runSearch( + request: request, + workspaceState: workspaceState, + options: options, + ), + ); + }); + } + + Future _runSearch({ + required int request, + required WorkspaceState workspaceState, + required SourceSearchOptions options, + }) async { + try { + final matches = await _loadWorkspaceSearchMatches( + workspaceState, + options, + loadText: _loadText, + isCancelled: () => _disposed || request != _request, + ); + if (_disposed || request != _request) { + return; + } + state = state.copyWith(matches: matches, searching: false); + } on Object { + if (_disposed || request != _request) { + return; + } + state = state.copyWith(matches: const [], searching: false); + } } } @@ -155,6 +281,30 @@ class _OutlineNavigationTarget { final String? editorBlockId; } +class _OutlineViewportTarget { + const _OutlineViewportTarget({ + required this.workspaceId, + required this.filePath, + required this.headingId, + required this.sourceStartOffset, + required this.editorBlockId, + }); + + final String workspaceId; + final String? filePath; + final String? headingId; + final int? sourceStartOffset; + final String? editorBlockId; + + bool sameLocationAs(_OutlineViewportTarget other) { + return workspaceId == other.workspaceId && + filePath == other.filePath && + headingId == other.headingId && + sourceStartOffset == other.sourceStartOffset && + editorBlockId == other.editorBlockId; + } +} + class _SourceNavigationTarget { const _SourceNavigationTarget({required this.filePath, required this.line}); @@ -169,6 +319,8 @@ class _WorkspaceSearchState { this.caseSensitive = false, this.wholeWord = false, this.regex = false, + this.matches = const [], + this.searching = false, }); final bool active; @@ -176,6 +328,8 @@ class _WorkspaceSearchState { final bool caseSensitive; final bool wholeWord; final bool regex; + final List<_WorkspaceSearchMatch> matches; + final bool searching; SourceSearchOptions get options => SourceSearchOptions( query: query.trim(), @@ -190,6 +344,8 @@ class _WorkspaceSearchState { bool? caseSensitive, bool? wholeWord, bool? regex, + List<_WorkspaceSearchMatch>? matches, + bool? searching, }) { return _WorkspaceSearchState( active: active ?? this.active, @@ -197,6 +353,8 @@ class _WorkspaceSearchState { caseSensitive: caseSensitive ?? this.caseSensitive, wholeWord: wholeWord ?? this.wholeWord, regex: regex ?? this.regex, + matches: matches ?? this.matches, + searching: searching ?? this.searching, ); } @@ -255,11 +413,7 @@ class WorkspaceScreen extends ConsumerWidget { } final searchState = ref.watch(_workspaceSearchProvider); final gitState = ref.watch(gitControllerProvider); - final searchResults = _workspaceSearchResults( - context, - state, - searchState.options, - ); + final searchResults = _workspaceSearchResults(context, searchState.matches); final colors = BusyMarkSurfaceColors.of(context); final headerBar = ref.watch(linuxHeaderBarServiceProvider); @@ -267,11 +421,12 @@ class WorkspaceScreen extends ConsumerWidget { final settingsController = ref.read(appSettingsControllerProvider.notifier); final sidebarVisible = settings.sidebarVisible && _hasWorkspaceSidebar(workspace); + final documentOutline = _activeDocumentOutline(state); final sidebar = SizedBox( width: BusyMarkSizes.sidebarWidth, child: _Sidebar( workspace: workspace, - outline: _activeDocumentOutline(state), + outline: documentOutline, searchState: searchState, searchResults: searchResults, onOpenSearchResult: (result) => _openSearchResult(context, ref, result), @@ -286,6 +441,7 @@ class WorkspaceScreen extends ConsumerWidget { child: gitState.selectedDiffForDisplay == null ? _EditorPreviewSplit( state: state, + outline: documentOutline, viewMode: settings.documentViewMode, editorFontSize: settings.editorFontSize, editorToolbarPlacement: settings.editorToolbarPlacement, @@ -330,9 +486,7 @@ class WorkspaceScreen extends ConsumerWidget { .set(current.copyWith(active: true, query: query)); unawaited(settingsController.setSidebarVisible(true)); case HeaderBarSearchSubmitted(): - if (searchResults.isNotEmpty) { - unawaited(_openSearchResult(context, ref, searchResults.first)); - } + unawaited(_submitSearch(context, ref)); case HeaderBarSearchCleared(): _clearSearchQuery(ref); case HeaderBarSearchEscapePressed(): @@ -435,7 +589,7 @@ class WorkspaceScreen extends ConsumerWidget { }, ), }, - child: Focus( + child: FocusScope( autofocus: true, child: Scaffold( backgroundColor: colors.window, @@ -446,6 +600,7 @@ class WorkspaceScreen extends ConsumerWidget { child: BusyMarkHeaderIconButton( tooltip: context.l10n.welcome, icon: BusyMarkGlyphs.home, + shortcut: BusyMarkAppShortcutLabels.back, onPressed: () async { final router = GoRouter.of(context); if (await confirmSafeToContinue(context, ref)) { @@ -459,17 +614,8 @@ class WorkspaceScreen extends ConsumerWidget { query: searchState.query, onChanged: (query) => _setSearchQuery(ref, query), onClear: () => _clearSearchQuery(ref), - onSubmitted: () { - if (searchResults.isNotEmpty) { - unawaited( - _openSearchResult( - context, - ref, - searchResults.first, - ), - ); - } - }, + onSubmitted: () => + unawaited(_submitSearch(context, ref)), onEscape: () => _closeSearch(ref), ) : _HeaderTitle( @@ -635,6 +781,19 @@ class WorkspaceScreen extends ConsumerWidget { .set(current.copyWith(query: '')); } + Future _submitSearch(BuildContext context, WidgetRef ref) async { + final current = await ref.read(_workspaceSearchProvider.notifier).submit(); + if (!current || !context.mounted) { + return; + } + final matches = ref.read(_workspaceSearchProvider).matches; + if (matches.isEmpty) { + return; + } + final result = _workspaceSearchResults(context, matches).first; + await _openSearchResult(context, ref, result); + } + void _handleHeaderBarAction( BuildContext context, WidgetRef ref, @@ -661,6 +820,8 @@ class WorkspaceScreen extends ConsumerWidget { break; case HeaderBarAction.exportPdf: unawaited(exportActiveMarkdownToPdf(context, ref)); + case HeaderBarAction.fullScreen: + break; case HeaderBarAction.settings: context.go(settingsLocation(SettingsReturnTarget.workspace)); case HeaderBarAction.keyboardShortcuts: @@ -699,6 +860,16 @@ class WorkspaceScreen extends ConsumerWidget { DocumentViewModePreference.split, ), ); + case HeaderBarAction.sidebarFiles: + _selectSidebarShortcut(ref, _SidebarTab.files); + case HeaderBarAction.sidebarToc: + _selectSidebarShortcut(ref, _SidebarTab.toc); + case HeaderBarAction.sidebarOutline: + _selectSidebarShortcut(ref, _SidebarTab.outline); + case HeaderBarAction.sidebarGit: + _selectSidebarShortcut(ref, _SidebarTab.git); + case HeaderBarAction.sidebarHistory: + _selectSidebarShortcut(ref, _SidebarTab.gitHistory); case HeaderBarAction.search: _toggleSearch(ref); case HeaderBarAction.menu: @@ -714,6 +885,8 @@ class WorkspaceScreen extends ConsumerWidget { switch (action) { case BusyMarkMainMenuAction.exportPdf: unawaited(exportActiveMarkdownToPdf(context, ref)); + case BusyMarkMainMenuAction.fullScreen: + unawaited(ref.read(windowControlServiceProvider).toggleFullScreen()); case BusyMarkMainMenuAction.settings: context.go(settingsLocation(SettingsReturnTarget.workspace)); case BusyMarkMainMenuAction.keyboardShortcuts: @@ -1131,8 +1304,15 @@ Future _showWorkspacePathMenu( required String name, required String path, required Offset position, + String? copyNameLabel, + bool pathActionsEnabled = true, }) async { - final action = await _showSidebarPathMenu(context, position); + final action = await _showSidebarPathMenu( + context, + position, + copyNameLabel: copyNameLabel, + pathActionsEnabled: pathActionsEnabled, + ); if (action == null || !context.mounted) { return; } @@ -1150,6 +1330,9 @@ Future _performWorkspacePathAction( required String path, required _PathMenuAction action, }) async { + if (path.isEmpty && action != _PathMenuAction.copyName) { + return; + } switch (action) { case _PathMenuAction.copyName: await _copyToClipboard(name); @@ -1163,36 +1346,46 @@ Future _performWorkspacePathAction( enum _PathMenuAction { copyName, copyPath, openInFiles } List> _sidebarPathMenuItems( - BuildContext context, -) { + BuildContext context, { + String? copyNameLabel, + bool pathActionsEnabled = true, +}) { return [ BusyMarkPopupMenuItem( value: _PathMenuAction.copyName, - label: context.l10n.copyName, + label: copyNameLabel ?? context.l10n.copyName, icon: BusyMarkGlyphs.copy, ), BusyMarkPopupMenuItem( value: _PathMenuAction.copyPath, label: context.l10n.copyPath, icon: BusyMarkGlyphs.copy, + enabled: pathActionsEnabled, ), const PopupMenuDivider(height: BusyMarkSpacing.sm), BusyMarkPopupMenuItem( value: _PathMenuAction.openInFiles, label: context.l10n.openInFiles, icon: BusyMarkGlyphs.folderOpen, + enabled: pathActionsEnabled, ), ]; } Future<_PathMenuAction?> _showSidebarPathMenu( BuildContext context, - Offset position, -) { + Offset position, { + String? copyNameLabel, + bool pathActionsEnabled = true, +}) { return showBusyMarkContextMenu<_PathMenuAction>( context, position, - items: _sidebarPathMenuItems(context), + items: _sidebarPathMenuItems( + context, + copyNameLabel: copyNameLabel, + pathActionsEnabled: pathActionsEnabled, + ), ); } @@ -1544,6 +1737,7 @@ class _SidebarState extends ConsumerState<_Sidebar> { ? _SearchSidebar( query: widget.searchState.query, results: widget.searchResults, + searching: widget.searchState.searching, onOpenResult: widget.onOpenSearchResult, ) : _topicUsageReview != null @@ -1939,6 +2133,15 @@ class _SidebarHeader extends StatelessWidget { Widget build(BuildContext context) { final colors = BusyMarkSurfaceColors.of(context); final path = _workspacePath(workspace); + final activeDocumentPath = + workspace.activeFilePath ?? workspace.markdown?.filePath ?? ''; + final hasActiveDocumentPath = activeDocumentPath.isNotEmpty; + final activeDocumentName = hasActiveDocumentPath + ? _fileNameFromPath(activeDocumentPath) + : context.l10n.untitledMarkdownFileName; + final activeDocumentFile = hasActiveDocumentPath + ? _documentFileForPath(workspace, activeDocumentPath) + : null; final repository = repositoryInfo; final branchLabel = _gitBranchLabel(context, repositoryInfo); final detailsStyle = Theme.of( @@ -2043,6 +2246,62 @@ class _SidebarHeader extends StatelessWidget { ), ), ], + if (selectedTab == _SidebarTab.outline) ...[ + const SizedBox(height: BusyMarkSpacing.sm), + _SidebarHeaderRow( + key: const ValueKey('workspace-sidebar-first-content'), + child: Row( + children: [ + Expanded( + child: _SidebarHeaderLine( + key: const ValueKey( + 'workspace-sidebar-outline-file-label', + ), + icon: _documentKindIcon( + activeDocumentFile?.kind ?? DocumentKind.markdown, + ), + text: busyMarkLtrIsolateFor(context, activeDocumentName), + style: detailsStyle, + tooltip: hasActiveDocumentPath + ? busyMarkLtrIsolateFor(context, activeDocumentPath) + : null, + onSecondaryTapUp: (lineContext, details) => + _showWorkspacePathMenu( + lineContext, + name: activeDocumentName, + path: activeDocumentPath, + position: details.globalPosition, + copyNameLabel: lineContext.l10n.copyFileName, + pathActionsEnabled: hasActiveDocumentPath, + ), + ), + ), + const SizedBox(width: BusyMarkSpacing.sm), + BusyMarkHeaderPopupMenuButton<_PathMenuAction>( + key: const ValueKey('workspace-sidebar-outline-file-menu'), + tooltip: context.l10n.fileActions, + icon: BusyMarkGlyphs.menuVertical, + transparent: true, + borderRadius: BusyMarkRadius.nativeHeaderButton, + highlightWhenOpen: false, + itemBuilder: (menuContext) => _sidebarPathMenuItems( + menuContext, + copyNameLabel: menuContext.l10n.copyFileName, + pathActionsEnabled: hasActiveDocumentPath, + ), + onSelected: (action) => unawaited( + _performWorkspacePathAction( + context, + name: activeDocumentName, + path: activeDocumentPath, + action: action, + ), + ), + ), + ], + ), + ), + ], if ((selectedTab == _SidebarTab.git || selectedTab == _SidebarTab.gitHistory) && repository != null && @@ -3635,7 +3894,6 @@ Future _confirmDeleteFileTreeEntry( class _SidebarTreeRow extends StatelessWidget { const _SidebarTreeRow({ - super.key, required this.title, required this.depth, required this.icon, @@ -5569,8 +5827,13 @@ class _OutlineTab extends ConsumerStatefulWidget { } class _OutlineTabState extends ConsumerState<_OutlineTab> { + static const _treeRowExtent = + BusyMarkSizes.sidebarTreeRowHeight + BusyMarkStroke.hairline * 2; + late String _outlineStateKey; late Set _expandedNodeKeys; + final _treeScrollController = ScrollController(); + String? _revealedActiveNodeKey; @override void initState() { @@ -5589,7 +5852,163 @@ class _OutlineTabState extends ConsumerState<_OutlineTab> { if (nextKey != _outlineStateKey) { _outlineStateKey = nextKey; _expandedNodeKeys = _initialExpandedOutlineNodeKeys(widget.headings); + _revealedActiveNodeKey = null; + } + } + + @override + void dispose() { + _treeScrollController.dispose(); + super.dispose(); + } + + void _scheduleActiveNodeReveal( + List<_OutlineTreeEntry> entries, + String? activeNodeKey, + ) { + if (activeNodeKey == null) { + _revealedActiveNodeKey = null; + return; + } + if (activeNodeKey == _revealedActiveNodeKey) { + return; + } + final index = entries.indexWhere( + (entry) => _outlineNodeKey(entry.node.heading) == activeNodeKey, + ); + if (index < 0) { + return; + } + _revealedActiveNodeKey = activeNodeKey; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_treeScrollController.hasClients) { + _revealedActiveNodeKey = null; + return; + } + final position = _treeScrollController.position; + final itemTop = BusyMarkInsets.sidebarList.top + index * _treeRowExtent; + final itemBottom = itemTop + _treeRowExtent; + final viewportTop = position.pixels; + final viewportBottom = viewportTop + position.viewportDimension; + final alreadyVisible = + itemBottom > viewportTop && itemTop < viewportBottom; + if (!alreadyVisible) { + final target = (itemTop - position.viewportDimension * 0.35) + .clamp(position.minScrollExtent, position.maxScrollExtent) + .toDouble(); + _treeScrollController.jumpTo(target); + } + }); + } + + Future _showSectionMenu( + DocumentOutlineHeading heading, + int headingIndex, + Offset position, + ) async { + final capabilities = _outlineSectionCapabilities( + widget.headings, + headingIndex, + ); + final action = await showBusyMarkContextMenu<_OutlineSectionAction>( + context, + position, + items: _outlineSectionMenuItems(context, capabilities), + ); + if (action == null || !mounted) { + return; + } + await _runSectionAction(heading, headingIndex, action); + } + + Future _runSectionAction( + DocumentOutlineHeading heading, + int preferredIndex, + _OutlineSectionAction action, + ) async { + final initialState = ref.read(workspaceControllerProvider); + final workspace = initialState.workspace; + if (workspace == null) { + return; + } + final workspaceId = workspace.id; + final activePath = workspace.activeFilePath ?? workspace.markdown?.filePath; + final source = initialState.activeText; + final mode = + workspace.markdown?.mode ?? + (workspace.kind == WorkspaceKind.writersideModule + ? MarkdownMode.writersideMarkdown + : MarkdownMode.commonMark); + final parsed = await const MarkdownParser().parseAsync( + filePath: activePath ?? 'untitled.md', + source: source, + mode: mode, + workspaceRoot: workspace.rootPath, + validateLocalReferences: false, + ); + if (!mounted || + !_isSameActiveDocument( + ref.read(workspaceControllerProvider), + workspaceId: workspaceId, + activePath: activePath, + source: source, + )) { + return; + } + final headingIndex = _resolveParsedOutlineHeadingIndex( + parsed.headings, + heading, + preferredIndex, + ); + if (headingIndex < 0) { + return; + } + final section = MarkdownSectionEditor.fromHeadings( + source: source, + headings: parsed.headings, + headingIndex: headingIndex, + ); + String? updatedSource; + switch (action) { + case _OutlineSectionAction.copy: + await _copyToClipboard(section.sectionText); + return; + case _OutlineSectionAction.cut: + await _copyToClipboard(section.sectionText); + updatedSource = section.withoutSection(); + case _OutlineSectionAction.delete: + final confirmed = await _confirmDeleteOutlineSection( + context, + ref, + heading.text, + ); + if (!confirmed || !mounted) { + return; + } + updatedSource = section.withoutSection(); + case _OutlineSectionAction.promote: + updatedSource = section.promote(); + case _OutlineSectionAction.demote: + updatedSource = section.demote(); + case _OutlineSectionAction.moveUp: + updatedSource = section.moveUp(); + case _OutlineSectionAction.moveDown: + updatedSource = section.moveDown(); + } + if (!mounted || + updatedSource == null || + updatedSource == source || + !_isSameActiveDocument( + ref.read(workspaceControllerProvider), + workspaceId: workspaceId, + activePath: activePath, + source: source, + )) { + return; } + ref + .read(workspaceControllerProvider.notifier) + .updateActiveText(updatedSource, sourceFilePath: activePath); } @override @@ -5603,19 +6022,35 @@ class _OutlineTabState extends ConsumerState<_OutlineTab> { } final tree = _buildOutlineTree(headings); final entries = _visibleOutlineTreeEntries(tree, _expandedNodeKeys); + final viewportTarget = ref.watch(_outlineViewportTargetProvider); + final activeNodeKey = _activeVisibleOutlineNodeKey( + workspace: widget.workspace, + headings: headings, + entries: entries, + target: viewportTarget, + ); + _scheduleActiveNodeReveal(entries, activeNodeKey); + final headingIndexes = { + for (var index = 0; index < headings.length; index += 1) + _outlineNodeKey(headings[index]): index, + }; return ListView.builder( key: const ValueKey('workspace-sidebar-outline-tree'), + controller: _treeScrollController, padding: BusyMarkInsets.sidebarList, + itemExtent: _treeRowExtent, itemCount: entries.length, itemBuilder: (context, index) { final entry = entries[index]; final node = entry.node; final heading = node.heading; final key = _outlineNodeKey(heading); + final headingIndex = headingIndexes[key]!; final expanded = _expandedNodeKeys.contains(key); final hasChildren = node.children.isNotEmpty; void toggle() { setState(() { + _revealedActiveNodeKey = null; if (expanded) { _expandedNodeKeys.remove(key); } else { @@ -5624,36 +6059,291 @@ class _OutlineTabState extends ConsumerState<_OutlineTab> { }); } - return _SidebarTreeRow( - key: index == 0 - ? const ValueKey('workspace-sidebar-first-content') - : null, - title: heading.text, - depth: entry.depth, - icon: BusyMarkGlyphs.tag, - leading: _HeadingBadge(level: heading.level), - hasChildren: hasChildren, - expanded: expanded, - onToggle: hasChildren ? toggle : null, - onTap: () { - ref - .read(_outlineNavigationTargetProvider.notifier) - .set( - _OutlineNavigationTarget( - workspaceId: widget.workspace.id, - filePath: widget.workspace.activeFilePath, - headingId: heading.id, - line: heading.sourceStartLine, - editorBlockId: heading.editorBlockId, - ), - ); - }, + return KeyedSubtree( + key: ValueKey('workspace-sidebar-outline-row-$headingIndex'), + child: _SidebarTreeRow( + title: heading.text, + depth: entry.depth, + icon: BusyMarkGlyphs.tag, + leading: _HeadingBadge(level: heading.level), + hasChildren: hasChildren, + expanded: expanded, + selected: key == activeNodeKey, + onToggle: hasChildren ? toggle : null, + onTap: () { + _setOutlineViewportTarget( + ref, + workspace: widget.workspace, + heading: heading, + ); + ref + .read(_outlineNavigationTargetProvider.notifier) + .set( + _OutlineNavigationTarget( + workspaceId: widget.workspace.id, + filePath: widget.workspace.activeFilePath, + headingId: heading.id, + line: heading.sourceStartLine, + editorBlockId: heading.editorBlockId, + ), + ); + }, + onSecondaryTapUp: (details) => unawaited( + _showSectionMenu(heading, headingIndex, details.globalPosition), + ), + ), ); }, ); } } +enum _OutlineSectionAction { + copy, + cut, + delete, + promote, + demote, + moveUp, + moveDown, +} + +typedef _OutlineSectionCapabilities = ({ + bool canPromote, + bool canDemote, + bool canMoveUp, + bool canMoveDown, +}); + +_OutlineSectionCapabilities _outlineSectionCapabilities( + List headings, + int headingIndex, +) { + final level = headings[headingIndex].level; + var sectionEndIndex = headingIndex + 1; + while (sectionEndIndex < headings.length && + headings[sectionEndIndex].level > level) { + sectionEndIndex += 1; + } + var canMoveUp = false; + for (var index = headingIndex - 1; index >= 0; index -= 1) { + if (headings[index].level < level) { + break; + } + if (headings[index].level == level) { + canMoveUp = true; + break; + } + } + return ( + canPromote: level > 1, + canDemote: headings + .getRange(headingIndex, sectionEndIndex) + .every((candidate) => candidate.level < 6), + canMoveUp: canMoveUp, + canMoveDown: + sectionEndIndex < headings.length && + headings[sectionEndIndex].level == level, + ); +} + +List> _outlineSectionMenuItems( + BuildContext context, + _OutlineSectionCapabilities capabilities, +) { + final direction = Directionality.of(context); + return [ + BusyMarkPopupMenuItem( + value: _OutlineSectionAction.copy, + label: context.l10n.copy, + icon: BusyMarkGlyphs.copy, + ), + BusyMarkPopupMenuItem( + value: _OutlineSectionAction.cut, + label: context.l10n.cut, + icon: BusyMarkGlyphs.cut, + ), + const PopupMenuDivider(height: BusyMarkSpacing.sm), + BusyMarkPopupMenuItem( + value: _OutlineSectionAction.promote, + label: context.l10n.promoteHeading, + icon: BusyMarkGlyphs.outdentFor(direction), + enabled: capabilities.canPromote, + ), + BusyMarkPopupMenuItem( + value: _OutlineSectionAction.demote, + label: context.l10n.demoteHeading, + 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, + label: context.l10n.delete, + icon: BusyMarkGlyphs.delete, + ), + ]; +} + +int _resolveParsedOutlineHeadingIndex( + List headings, + DocumentOutlineHeading target, + int preferredIndex, +) { + bool matches(MarkdownHeading candidate) => + candidate.level == target.level && + candidate.text == target.text && + candidate.id == target.id; + if (preferredIndex >= 0 && + preferredIndex < headings.length && + matches(headings[preferredIndex])) { + return preferredIndex; + } + final sourceOffset = target.sourceStartOffset; + if (sourceOffset != null) { + final index = headings.indexWhere( + (candidate) => + candidate.span.startOffset == sourceOffset && + candidate.level == target.level && + candidate.text == target.text, + ); + if (index >= 0) { + return index; + } + } + return headings.indexWhere(matches); +} + +bool _isSameActiveDocument( + WorkspaceState state, { + required String workspaceId, + required String? activePath, + required String source, +}) { + final workspace = state.workspace; + return workspace?.id == workspaceId && + (workspace?.activeFilePath ?? workspace?.markdown?.filePath) == + activePath && + state.activeText == source; +} + +Future _confirmDeleteOutlineSection( + BuildContext context, + WidgetRef ref, + String heading, +) async { + final headerBar = ref.read(linuxHeaderBarServiceProvider); + final confirmed = await showBusyMarkModalDialog( + context, + headerBarService: headerBar.isAvailable ? headerBar : null, + builder: (context) => BusyMarkDialogShell( + title: context.l10n.confirmDeleteSectionTitle, + maxWidth: BusyMarkSizes.dialog, + actions: [ + BusyMarkDialogButton( + label: context.l10n.cancel, + onPressed: () => Navigator.pop(context, false), + ), + BusyMarkDialogButton( + label: context.l10n.delete, + icon: BusyMarkGlyphs.delete, + destructive: true, + onPressed: () => Navigator.pop(context, true), + ), + ], + children: [Text(context.l10n.confirmDeleteSectionMessage(heading))], + ), + ); + return confirmed ?? false; +} + +void _setOutlineViewportTarget( + WidgetRef ref, { + required Workspace workspace, + required DocumentOutlineHeading? heading, +}) { + ref + .read(_outlineViewportTargetProvider.notifier) + .set( + _OutlineViewportTarget( + workspaceId: workspace.id, + filePath: workspace.activeFilePath ?? workspace.markdown?.filePath, + headingId: heading?.id, + sourceStartOffset: heading?.sourceStartOffset, + editorBlockId: heading?.editorBlockId, + ), + ); +} + +String? _activeVisibleOutlineNodeKey({ + required Workspace workspace, + required List headings, + required List<_OutlineTreeEntry> entries, + required _OutlineViewportTarget? target, +}) { + if (target == null || + target.workspaceId != workspace.id || + target.filePath != + (workspace.activeFilePath ?? workspace.markdown?.filePath)) { + return headings.isEmpty ? null : _outlineNodeKey(headings.first); + } + final targetIndex = _outlineViewportHeadingIndex(headings, target); + if (targetIndex < 0) { + return null; + } + final visibleKeys = { + for (final entry in entries) _outlineNodeKey(entry.node.heading), + }; + for (var index = targetIndex; index >= 0; index -= 1) { + final key = _outlineNodeKey(headings[index]); + if (visibleKeys.contains(key)) { + return key; + } + } + return null; +} + +int _outlineViewportHeadingIndex( + List headings, + _OutlineViewportTarget target, +) { + final editorBlockId = target.editorBlockId; + if (editorBlockId != null) { + final index = headings.indexWhere( + (heading) => heading.editorBlockId == editorBlockId, + ); + if (index >= 0) { + return index; + } + } + final sourceStartOffset = target.sourceStartOffset; + if (sourceStartOffset != null) { + final index = headings.indexWhere( + (heading) => heading.sourceStartOffset == sourceStartOffset, + ); + if (index >= 0) { + return index; + } + } + final headingId = target.headingId; + return headingId == null + ? -1 + : headings.indexWhere((heading) => heading.id == headingId); +} + List _activeDocumentOutline(WorkspaceState state) { final liveOutline = state.liveOutline; final workspace = state.workspace; @@ -7064,6 +7754,7 @@ DocumentFile? _documentFileForPath(Workspace workspace, String path) { class _EditorPreviewSplit extends ConsumerStatefulWidget { const _EditorPreviewSplit({ required this.state, + required this.outline, required this.viewMode, required this.editorFontSize, required this.editorToolbarPlacement, @@ -7072,6 +7763,7 @@ class _EditorPreviewSplit extends ConsumerStatefulWidget { }); final WorkspaceState state; + final List outline; final DocumentViewModePreference viewMode; final double editorFontSize; final EditorToolbarPlacement editorToolbarPlacement; @@ -7085,6 +7777,9 @@ class _EditorPreviewSplit extends ConsumerStatefulWidget { class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { final _previewScrollController = ItemScrollController(); + final _previewItemPositionsListener = ItemPositionsListener.create(); + PreviewDocument? _outlineStopsPreview; + List<_PositionedOutlineHeading> _previewOutlineStops = const []; final _sourceEditorKey = GlobalKey(); final _previewBlockContexts = {}; String _lastPath = ''; @@ -7101,6 +7796,17 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { void initState() { super.initState(); _lastPath = widget.state.workspace?.activeFilePath ?? ''; + _previewItemPositionsListener.itemPositions.addListener( + _handlePreviewVisibleItemsChanged, + ); + } + + @override + void dispose() { + _previewItemPositionsListener.itemPositions.removeListener( + _handlePreviewVisibleItemsChanged, + ); + super.dispose(); } @override @@ -7136,6 +7842,57 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { } } + void _handlePreviewVisibleItemsChanged() { + if (!mounted || + (widget.viewMode != DocumentViewModePreference.preview && + widget.viewMode != DocumentViewModePreference.split)) { + return; + } + final preview = widget.state.preview; + if (preview == null || preview.blocks.isEmpty) { + _publishVisibleOutlineHeading(null); + return; + } + final firstVisible = _firstVisiblePositionedItemIndex( + _previewItemPositionsListener.itemPositions.value, + ); + if (firstVisible == null) { + return; + } + if (!identical(_outlineStopsPreview, preview)) { + _outlineStopsPreview = preview; + _previewOutlineStops = [ + for (final (index, block) in preview.blocks.indexed) + if (_outlineHeadingForPreviewBlock(block) case final heading?) + (itemIndex: index, heading: heading), + ]; + } + _publishVisibleOutlineHeading( + _outlineHeadingAtItemIndex(_previewOutlineStops, firstVisible), + ); + } + + void _handleWysiwygVisibleHeadingChanged(DocumentOutlineHeading? heading) { + if (widget.viewMode == DocumentViewModePreference.editor) { + _publishVisibleOutlineHeading(heading); + } + } + + void _handleSourceVisibleLineChanged(int? line) { + final heading = line == null + ? null + : _outlineHeadingAtOrBeforeLine(widget.outline, line); + _publishVisibleOutlineHeading(heading); + } + + void _publishVisibleOutlineHeading(DocumentOutlineHeading? heading) { + final workspace = widget.state.workspace; + if (!mounted || workspace == null) { + return; + } + _setOutlineViewportTarget(ref, workspace: workspace, heading: heading); + } + @override Widget build(BuildContext context) { ref.listen(_outlineNavigationTargetProvider, (previous, next) { @@ -7217,8 +7974,7 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { .state .workspace ?.writersideModule - ?.config - .imagesDir ?? + ?.effectiveImagesDir ?? 'images', allowRemoteImages: allowRemoteImages, onRemoteImageBlocked: () => @@ -7237,6 +7993,7 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { scrollToBlockId: _wysiwygScrollBlockId, scrollToSearchQuery: _wysiwygSearchQuery, scrollRequest: _wysiwygScrollRequest, + onVisibleHeadingChanged: _handleWysiwygVisibleHeadingChanged, documentLayout: standaloneDocumentLayout, onOpenSearch: () => ref .read(workspaceSearchOpenRequestProvider.notifier) @@ -7271,6 +8028,7 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { onCloseSearch: () => ref .read(workspaceSearchCloseRequestProvider.notifier) .request(), + onVisibleLineChanged: _handleSourceVisibleLineChanged, onChanged: _handleSourceChanged, ), ), @@ -7285,6 +8043,7 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { preview: widget.state.preview, workspace: widget.state.workspace, controller: _previewScrollController, + itemPositionsListener: _previewItemPositionsListener, onBlockContextAvailable: _rememberPreviewBlockContext, onBlockContextUnavailable: _forgetPreviewBlockContext, documentLayout: sourceVisible @@ -7659,12 +8418,116 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { } } +int? _firstVisiblePositionedItemIndex(Iterable positions) { + int? first; + for (final position in positions) { + if (position.itemTrailingEdge <= 0 || position.itemLeadingEdge >= 1) { + continue; + } + if (first == null || position.index < first) { + first = position.index; + } + } + return first; +} + +typedef _PositionedOutlineHeading = ({ + int itemIndex, + DocumentOutlineHeading heading, +}); + +DocumentOutlineHeading? _outlineHeadingForPreviewBlock(PreviewBlock block) { + if (block.kind != PreviewBlockKind.heading) { + return null; + } + final headingId = block.attributes['id']; + final level = block.level; + final sourceStartLine = block.sourceStartLine; + final sourceStartOffset = block.sourceStartOffset; + if (headingId == null || + headingId.isEmpty || + level == null || + sourceStartLine == null || + sourceStartOffset == null) { + return null; + } + return DocumentOutlineHeading( + level: level, + text: block.text, + id: headingId, + sourceStartLine: sourceStartLine, + sourceStartOffset: sourceStartOffset, + editorBlockId: block.attributes['editorBlockId'], + ); +} + +DocumentOutlineHeading? _outlineHeadingAtItemIndex( + List<_PositionedOutlineHeading> headings, + int itemIndex, +) { + var low = 0; + var high = headings.length - 1; + DocumentOutlineHeading? result; + while (low <= high) { + final middle = (low + high) >> 1; + final candidate = headings[middle]; + if (candidate.itemIndex <= itemIndex) { + result = candidate.heading; + low = middle + 1; + } else { + high = middle - 1; + } + } + return result; +} + +DocumentOutlineHeading? _outlineHeadingAtOrBeforeLine( + List headings, + int line, +) { + var low = 0; + var high = headings.length - 1; + DocumentOutlineHeading? result; + var encounteredMissingLine = false; + while (low <= high) { + final middle = (low + high) >> 1; + final heading = headings[middle]; + final headingLine = heading.sourceStartLine; + if (headingLine == null) { + encounteredMissingLine = true; + break; + } + if (headingLine <= line) { + result = heading; + low = middle + 1; + } else { + high = middle - 1; + } + } + if (!encounteredMissingLine) { + return result; + } + result = null; + for (final heading in headings) { + final headingLine = heading.sourceStartLine; + if (headingLine == null) { + continue; + } + if (headingLine > line) { + break; + } + result = heading; + } + return result; +} + class _PreviewPane extends StatelessWidget { const _PreviewPane({ required this.preview, required this.workspace, required this.controller, required this.documentLayout, + this.itemPositionsListener, this.onBlockContextAvailable, this.onBlockContextUnavailable, }); @@ -7672,6 +8535,7 @@ class _PreviewPane extends StatelessWidget { final PreviewDocument? preview; final Workspace? workspace; final ItemScrollController controller; + final ItemPositionsListener? itemPositionsListener; final BusyMarkDocumentLayoutSpec documentLayout; final void Function(int index, BuildContext context)? onBlockContextAvailable; final void Function(int index, BuildContext context)? @@ -7696,6 +8560,7 @@ class _PreviewPane extends StatelessWidget { child: ScrollablePositionedList.builder( key: const ValueKey('preview-document-scroll'), itemScrollController: controller, + itemPositionsListener: itemPositionsListener, padding: documentLayout.scrollPadding, itemCount: document.blocks.length, itemBuilder: (context, index) => @@ -8178,23 +9043,28 @@ class _PreviewInlineText extends ConsumerWidget { final inlines = block.inlines.isEmpty ? [PreviewInline(kind: PreviewInlineKind.text, text: block.text)] : block.inlines; - return Text.rich( - TextSpan( - style: baseStyle, - children: [ - for (final inline in inlines) - _previewInlineSpan( - context, - inline, - workspace: workspace, - highlightQuery: highlightQuery, - allowRemoteImages: allowRemoteImages, - onRemoteImageBlocked: () => - unawaited(_showRemoteImagesPrompt(context, ref)), - onLinkTap: (destination) => - _openPreviewLink(context, ref, destination), - ), - ], + return Padding( + padding: const EdgeInsetsDirectional.only( + end: BusyMarkDocumentTextGeometry.editableLayoutInset, + ), + child: Text.rich( + TextSpan( + style: baseStyle, + children: [ + for (final inline in inlines) + _previewInlineSpan( + context, + inline, + workspace: workspace, + highlightQuery: highlightQuery, + allowRemoteImages: allowRemoteImages, + onRemoteImageBlocked: () => + unawaited(_showRemoteImagesPrompt(context, ref)), + onLinkTap: (destination) => + _openPreviewLink(context, ref, destination), + ), + ], + ), ), ); } @@ -8548,7 +9418,7 @@ class _PreviewImageBlock extends ConsumerWidget { workspaceRoot: _imageWorkspaceRoot(workspace), writersideRoot: workspace?.writersideModule?.rootPath, imagesDir: - workspace?.writersideModule?.config.imagesDir ?? 'images', + workspace?.writersideModule?.effectiveImagesDir ?? 'images', allowRemoteImages: allowRemoteImages, onRemoteImageBlocked: () => unawaited(_showRemoteImagesPrompt(context, ref)), @@ -8813,7 +9683,8 @@ InlineSpan _previewInlineImageSpan( activeFilePath: activeFilePath ?? '', workspaceRoot: _imageWorkspaceRoot(workspace), writersideRoot: workspace?.writersideModule?.rootPath, - imagesDir: workspace?.writersideModule?.config.imagesDir ?? 'images', + imagesDir: + workspace?.writersideModule?.effectiveImagesDir ?? 'images', allowRemoteImages: allowRemoteImages, onRemoteImageBlocked: onRemoteImageBlocked, maxWidth: BusyMarkSizes.previewMinWidth, @@ -9171,11 +10042,13 @@ class _SearchSidebar extends StatelessWidget { const _SearchSidebar({ required this.query, required this.results, + required this.searching, required this.onOpenResult, }); final String query; final List<_WorkspaceSearchResult> results; + final bool searching; final Future Function(_WorkspaceSearchResult result) onOpenResult; @override @@ -9187,6 +10060,13 @@ class _SearchSidebar extends StatelessWidget { title: context.l10n.search, ); } + if (searching && results.isEmpty) { + return const Center( + child: CircularProgressIndicator( + key: ValueKey('workspace-search-progress'), + ), + ); + } final colors = BusyMarkSurfaceColors.of(context); if (results.isEmpty) { return _SidebarEmptyState( @@ -9337,6 +10217,32 @@ class _WorkspaceSearchResult { final IconData icon; } +enum _WorkspaceSearchMatchKind { text, fileName } + +class _WorkspaceSearchMatch { + const _WorkspaceSearchMatch({ + required this.kind, + required this.filePath, + required this.relativePath, + required this.fileKind, + required this.line, + required this.startOffset, + required this.endOffset, + required this.query, + this.lineText, + }); + + final _WorkspaceSearchMatchKind kind; + final String filePath; + final String relativePath; + final DocumentKind fileKind; + final int line; + final int startOffset; + final int endOffset; + final String query; + final String? lineText; +} + class _WorkspaceSearchFileGroup { const _WorkspaceSearchFileGroup({ required this.relativePath, @@ -9365,23 +10271,69 @@ List<_WorkspaceSearchFileGroup> _workspaceSearchFileGroups( List<_WorkspaceSearchResult> _workspaceSearchResults( BuildContext context, - WorkspaceState state, - SourceSearchOptions options, + List<_WorkspaceSearchMatch> matches, ) { + return [ + for (final match in matches) + _WorkspaceSearchResult( + filePath: match.filePath, + relativePath: match.relativePath, + line: match.line, + startOffset: match.startOffset, + endOffset: match.endOffset, + query: match.query, + title: switch (match.kind) { + _WorkspaceSearchMatchKind.text => _searchResultTitle( + context, + match.lineText ?? '', + ), + _WorkspaceSearchMatchKind.fileName => busyMarkLtrIsolateFor( + context, + match.relativePath, + ), + }, + subtitle: switch (match.kind) { + _WorkspaceSearchMatchKind.text => context.l10n.searchResultLine( + match.relativePath, + match.line, + ), + _WorkspaceSearchMatchKind.fileName => _documentKindLabel( + context, + match.fileKind, + ), + }, + icon: switch (match.kind) { + _WorkspaceSearchMatchKind.text => BusyMarkGlyphs.paragraph, + _WorkspaceSearchMatchKind.fileName => _documentKindIcon( + match.fileKind, + ), + }, + ), + ]; +} + +const int _maxWorkspaceSearchResults = 80; +const int _maxWorkspaceSearchFileBytes = 1024 * 1024; + +Future> _loadWorkspaceSearchMatches( + WorkspaceState state, + SourceSearchOptions options, { + required Future Function(String path) loadText, + required bool Function() isCancelled, +}) async { final workspace = state.workspace; final trimmedQuery = options.query.trim(); - if (workspace == null || trimmedQuery.isEmpty) { + if (workspace == null || trimmedQuery.isEmpty || isCancelled()) { return const []; } - final results = <_WorkspaceSearchResult>[]; + final normalizedOptions = options.copyWith(query: trimmedQuery); + final results = <_WorkspaceSearchMatch>[]; final activePath = workspace.activeFilePath ?? workspace.markdown?.filePath; final sortedFiles = [...workspace.files] ..sort((a, b) => a.relativePath.compareTo(b.relativePath)); if (activePath != null && sortedFiles.every((file) => file.absolutePath != activePath)) { - _addWorkspaceSearchTextResults( - context: context, - workspace: workspace, + _addWorkspaceSearchTextMatches( file: DocumentFile( absolutePath: activePath, relativePath: p.basename(activePath), @@ -9390,97 +10342,92 @@ List<_WorkspaceSearchResult> _workspaceSearchResults( lastModified: DateTime.fromMillisecondsSinceEpoch(0), ), text: state.activeText, - options: options.copyWith(query: trimmedQuery), + options: normalizedOptions, results: results, ); if (results.length >= _maxWorkspaceSearchResults) { - return results; + return List.unmodifiable(results); } } for (final file in sortedFiles) { + if (isCancelled()) { + return const []; + } if (!_isOpenableTextDocument(file)) { continue; } - final text = file.absolutePath == activePath - ? state.activeText - : _readWorkspaceSearchText(file); + final String? text; + if (file.absolutePath == activePath) { + text = state.activeText; + } else if (file.size > _maxWorkspaceSearchFileBytes) { + text = null; + } else { + String? loadedText; + try { + loadedText = await loadText(file.absolutePath); + } on Object { + // Keep filename matching available when a document cannot be read. + } + if (isCancelled()) { + return const []; + } + text = loadedText; + } if (text != null) { - _addWorkspaceSearchTextResults( - context: context, - workspace: workspace, + _addWorkspaceSearchTextMatches( file: file, text: text, - options: options.copyWith(query: trimmedQuery), + options: normalizedOptions, results: results, ); if (results.length >= _maxWorkspaceSearchResults) { - return results; + return List.unmodifiable(results); } } - if (!_fileNameMatchesSearch(file.relativePath, options)) { + if (!_fileNameMatchesSearch(file.relativePath, normalizedOptions)) { continue; } - final displayPath = file.relativePath; - final kindLabel = _documentKindLabel(context, file.kind); results.add( - _WorkspaceSearchResult( + _WorkspaceSearchMatch( + kind: _WorkspaceSearchMatchKind.fileName, filePath: file.absolutePath, - relativePath: displayPath, + relativePath: file.relativePath, + fileKind: file.kind, line: 1, startOffset: 0, endOffset: 0, query: trimmedQuery, - title: busyMarkLtrIsolateFor(context, displayPath), - subtitle: kindLabel, - icon: _documentKindIcon(file.kind), ), ); if (results.length >= _maxWorkspaceSearchResults) { - return results; + return List.unmodifiable(results); } } - return results; -} - -const int _maxWorkspaceSearchResults = 80; -const int _maxWorkspaceSearchFileBytes = 1024 * 1024; - -String? _readWorkspaceSearchText(DocumentFile file) { - if (file.size > _maxWorkspaceSearchFileBytes) { - return null; - } - try { - return File(file.absolutePath).readAsStringSync(); - } on Object { - return null; - } + return List.unmodifiable(results); } -void _addWorkspaceSearchTextResults({ - required BuildContext context, - required Workspace workspace, +void _addWorkspaceSearchTextMatches({ required DocumentFile file, required String text, required SourceSearchOptions options, - required List<_WorkspaceSearchResult> results, + required List<_WorkspaceSearchMatch> results, }) { - final relativePath = _relativeDocumentPath(workspace, file.absolutePath); final document = SourceDocument(fullText: text); final searchResult = searchSourceDocument(document, options); for (final match in searchResult.matches) { final lineNumber = document.lineIndex.lineNumberAtOffset(match.fullStart); final line = document.lineIndex.lineAt(lineNumber).text; results.add( - _WorkspaceSearchResult( + _WorkspaceSearchMatch( + kind: _WorkspaceSearchMatchKind.text, filePath: file.absolutePath, - relativePath: relativePath, + relativePath: file.relativePath, + fileKind: file.kind, line: lineNumber, startOffset: match.fullStart, endOffset: match.fullEnd, query: options.query, - title: _searchResultTitle(context, line), - subtitle: context.l10n.searchResultLine(relativePath, lineNumber), - icon: BusyMarkGlyphs.paragraph, + lineText: line, ), ); if (results.length >= _maxWorkspaceSearchResults) { diff --git a/lib/src/workspace/workspace_controller.dart b/lib/src/workspace/workspace_controller.dart index 113ca72..9fb0da9 100644 --- a/lib/src/workspace/workspace_controller.dart +++ b/lib/src/workspace/workspace_controller.dart @@ -847,7 +847,11 @@ class WorkspaceController extends Notifier { return inFlight; } final completed = await inFlight; - if (!isActiveDocumentSaveTargetCurrent(operationTarget)) { + // The preceding save may have advanced the controller-owned disk + // snapshot. Re-pin only that snapshot; document and edit identity must + // still match the queued request. + final refreshedTarget = _refreshActiveDocumentSaveTarget(operationTarget); + if (refreshedTarget == null) { return false; } if (!state.isDirty && completed) { @@ -855,7 +859,7 @@ class WorkspaceController extends Notifier { } return saveActive( overwriteExternalChanges: overwriteExternalChanges, - target: operationTarget, + target: refreshedTarget, ); } late final Future operation; @@ -1056,26 +1060,6 @@ class WorkspaceController extends Notifier { return exists; } - Future activeFileChangedOnDisk({ - ActiveDocumentSaveTarget? target, - }) async { - final operationTarget = target ?? captureActiveDocumentSaveTarget(); - final active = operationTarget?.path; - if (operationTarget == null || - active == null || - !isActiveDocumentSaveTargetCurrent(operationTarget)) { - return false; - } - final changed = await _service.fileChangedSince( - active, - operationTarget.snapshot, - ); - if (!isActiveDocumentSaveTargetCurrent(operationTarget)) { - return false; - } - return changed; - } - Future discardActiveChanges({ActiveDocumentSaveTarget? target}) async { final operationTarget = target ?? captureActiveDocumentSaveTarget(); if (operationTarget == null) { @@ -1442,6 +1426,32 @@ class WorkspaceController extends Notifier { first.workspaceKind == second.workspaceKind && _sameFileSnapshot(first.snapshot, second.snapshot); } + + ActiveDocumentSaveTarget? _refreshActiveDocumentSaveTarget( + ActiveDocumentSaveTarget target, + ) { + final workspace = state.workspace; + if (!_isCurrentActiveDocument( + target.documentRevision, + workspaceId: target.workspaceId, + activeFilePath: target.path, + ) || + workspace == null || + _editRevision != target.editRevision || + state.activeText != target.text || + workspace.kind != target.workspaceKind) { + return null; + } + return ActiveDocumentSaveTarget._( + workspaceId: target.workspaceId, + path: target.path, + documentRevision: target.documentRevision, + editRevision: target.editRevision, + snapshot: workspace.activeFileSnapshot, + text: target.text, + workspaceKind: target.workspaceKind, + ); + } } bool _sameFileSnapshot( diff --git a/lib/src/workspace/workspace_safety.dart b/lib/src/workspace/workspace_safety.dart index 85d9ba1..d9b8b7b 100644 --- a/lib/src/workspace/workspace_safety.dart +++ b/lib/src/workspace/workspace_safety.dart @@ -10,6 +10,7 @@ import '../app/busymark_glyphs.dart'; import '../app/localization.dart'; import '../platform/linux_header_bar_service.dart'; import 'workspace_controller.dart'; +import 'workspace_message.dart'; enum _UnsavedChangesAction { cancel, discard, save } @@ -106,14 +107,17 @@ Future saveActiveWithOverwriteConfirmation( if (operationTarget.needsSaveLocation) { return _saveActiveAs(context, ref, operationTarget); } - final changedOnDisk = await controller.activeFileChangedOnDisk( - target: operationTarget, - ); - if (!controller.isActiveDocumentSaveTargetCurrent(operationTarget)) { - 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); + if (saved) { + return true; } - if (!changedOnDisk) { - return controller.saveActive(target: operationTarget); + final state = ref.read(workspaceControllerProvider); + if (!controller.isActiveDocumentSaveTargetCurrent(operationTarget) || + state.message?.code != + WorkspaceMessageCode.saveBlockedFileChangedOnDisk) { + return false; } if (!context.mounted) { return false; diff --git a/lib/src/workspace/workspace_service.dart b/lib/src/workspace/workspace_service.dart index 721fe83..8ad46d5 100644 --- a/lib/src/workspace/workspace_service.dart +++ b/lib/src/workspace/workspace_service.dart @@ -26,7 +26,7 @@ class WorkspaceService { const WorkspaceService({ this.markdownParser = const MarkdownParser(), this.previewBuilder = const MarkdownPreviewBuilder(), - this.writersideService = const WritersideModuleService(), + WritersideModuleService? writersideService, this.writersideProjectCreator = const WritersideProjectCreator(), this.writersideTopicCreator = const WritersideTopicCreator(), this.writersideTocEditor = const WritersideTocEditor(), @@ -34,7 +34,9 @@ class WorkspaceService { this.writersideTopicRemovalService = const WritersideTopicRemovalService(), this.scanOptions = const WorkspaceScanOptions(), Future Function(String targetPath)? beforeNewFilePublish, - }) : _beforeNewFilePublish = beforeNewFilePublish; + }) : writersideService = writersideService ?? const WritersideModuleService(), + _useWorkspaceScanOptionsForWriterside = writersideService == null, + _beforeNewFilePublish = beforeNewFilePublish; final MarkdownParser markdownParser; final MarkdownPreviewBuilder previewBuilder; @@ -45,6 +47,7 @@ class WorkspaceService { final WritersideTopicFileEditor writersideTopicFileEditor; final WritersideTopicRemovalService writersideTopicRemovalService; final WorkspaceScanOptions scanOptions; + final bool _useWorkspaceScanOptionsForWriterside; final Future Function(String targetPath)? _beforeNewFilePublish; Workspace createUntitledMarkdown({String source = ''}) { @@ -254,7 +257,14 @@ class WorkspaceService { Future _currentWritersideModule(Workspace workspace) async { final openedModule = _writersideModule(workspace); - return writersideService.load(openedModule.rootPath); + return _loadWritersideModule(openedModule.rootPath); + } + + Future _loadWritersideModule(String rootPath) { + return writersideService.load( + rootPath, + options: _useWorkspaceScanOptionsForWriterside ? scanOptions : null, + ); } WritersideInstance _writersideInstanceForTree( @@ -1092,7 +1102,7 @@ class WorkspaceService { String rootPath, { String? activeFilePath, }) async { - final module = await writersideService.load(rootPath); + final module = await _loadWritersideModule(rootPath); final scan = await scanWorkspaceEntities(rootPath, options: scanOptions); final entities = scan.entities; final files = []; diff --git a/lib/src/writerside/writerside_model.dart b/lib/src/writerside/writerside_model.dart index d11fa48..12005b4 100644 --- a/lib/src/writerside/writerside_model.dart +++ b/lib/src/writerside/writerside_model.dart @@ -290,6 +290,7 @@ class WritersideModule { required this.variables, required this.categories, required this.diagnostics, + required this.validatedImageDirs, }); final String rootPath; @@ -299,6 +300,9 @@ class WritersideModule { final List variables; final List categories; final List diagnostics; + final List validatedImageDirs; + + String get effectiveImagesDir => validatedImageDirs.firstOrNull ?? 'images'; Map get topicsByFileName { final grouped = >{}; diff --git a/lib/src/writerside/writerside_module_service.dart b/lib/src/writerside/writerside_module_service.dart index 29be8b6..e1a6de6 100644 --- a/lib/src/writerside/writerside_module_service.dart +++ b/lib/src/writerside/writerside_module_service.dart @@ -1,7 +1,10 @@ +import 'dart:convert'; import 'dart:io'; +import 'dart:typed_data'; import 'package:path/path.dart' as p; +import '../core/anchored_path_guard.dart'; import '../core/diagnostic.dart'; import '../core/local_image_resolver.dart'; import '../core/path_utils.dart'; @@ -27,105 +30,189 @@ class WritersideModuleService { final WritersideCategoriesParser categoriesParser; final WorkspaceScanOptions scanOptions; - Future load(String rootPath) async { - final root = normalizePath(rootPath); + Future load( + String rootPath, { + WorkspaceScanOptions? options, + }) async { + final effectiveScanOptions = options ?? scanOptions; + var root = normalizePath(rootPath); final diagnostics = []; - final configPath = await _resolveConfigPath(root); - if (configPath == null) { + final CanonicalPathAnchor anchor; + try { + anchor = await captureCanonicalDirectoryAnchor(root); + root = anchor.rootPath; + } on AnchoredPathViolation catch (error) { final expectedPath = p.join(root, 'writerside.cfg'); diagnostics.add( Diagnostic( code: 'writerside.config.missing', severity: DiagnosticSeverity.error, filePath: expectedPath, + args: {'error': '$error'}, ), ); - final emptyConfig = WritersideConfig( - filePath: expectedPath, - version: null, - moduleName: null, - topicRoots: const [WritersideTopicRoot(dir: 'topics', explicit: false)], - imageRoots: const [WritersideImageRoot(dir: 'images', explicit: false)], - apiSpecificationsDir: 'specifications', - apiSpecificationsExplicit: false, - buildConfigDir: 'cfg', - buildConfigExplicit: false, - snippetsDir: null, - resourcesFile: null, - resourcesDir: null, - varsFile: null, - categoriesFile: null, - instanceGroupsFile: null, - instances: const [], - settings: const WritersideSettingsConfig(), - diagnostics: const [], + return _emptyModule(root, expectedPath, diagnostics); + } + final configCandidate = await _resolveConfigPath(root); + if (configCandidate == null) { + diagnostics.add( + Diagnostic( + code: 'writerside.config.missing', + severity: DiagnosticSeverity.error, + filePath: p.join(root, 'writerside.cfg'), + ), + ); + return _emptyModule(root, p.join(root, 'writerside.cfg'), diagnostics); + } + final AnchoredPathResolution configResolution; + try { + configResolution = await resolveAnchoredPath( + anchor, + configCandidate, + allowRoot: false, + ); + } on AnchoredPathViolation catch (error) { + diagnostics.add( + _unsafeConfiguredPathDiagnostic( + configPath: configCandidate, + configuredPath: p.basename(configCandidate), + kind: 'config', + error: error, + ), ); - return WritersideModule( - rootPath: root, - config: emptyConfig, - instances: const [], - topics: const [], - variables: const [], - categories: const [], - diagnostics: diagnostics, + return _emptyModule(root, configCandidate, diagnostics); + } + if (configResolution.type != FileSystemEntityType.file) { + diagnostics.add( + Diagnostic( + code: 'writerside.config.missing', + severity: DiagnosticSeverity.error, + filePath: configResolution.path, + ), ); + return _emptyModule(root, configResolution.path, diagnostics); + } + final configPath = configResolution.path; + final configSource = await _readFileForParsing( + File(configPath), + diagnostics, + effectiveScanOptions, + readFailureCode: 'workspace.file.read-failed', + ); + if (configSource == null) { + return _emptyModule(root, configPath, diagnostics); } - final configSource = await File(configPath).readAsString(); final config = configParser.parse(configPath, configSource); diagnostics.addAll(config.diagnostics); final usableTopicRoots = await _existingTopicRoots( diagnostics, - root, + anchor, + config, + configPath, + configSource, + ); + final validatedImageDirs = await _validateImageRoots( + diagnostics, + anchor, config, + configPath, + configSource, ); - await _validateImageRoots(diagnostics, root, config); await _validateOptionalConfiguredDirectory( diagnostics, - root, + anchor, config.buildConfigDir, + configPath: configPath, + configSource: configSource, + kind: 'buildConfig', explicit: config.buildConfigExplicit, code: 'writerside.config.missing-build-config-directory', severity: DiagnosticSeverity.info, ); await _validateOptionalConfiguredDirectory( diagnostics, - root, + anchor, config.apiSpecificationsDir, + configPath: configPath, + configSource: configSource, + kind: 'apiSpecifications', explicit: config.apiSpecificationsExplicit, code: 'writerside.config.missing-api-specifications-directory', severity: DiagnosticSeverity.info, ); await _validateOptionalConfiguredDirectory( diagnostics, - root, + anchor, config.snippetsDir, + configPath: configPath, + configSource: configSource, + kind: 'snippets', explicit: config.snippetsDir != null, code: 'writerside.config.missing-snippets-directory', severity: DiagnosticSeverity.warning, ); - await _validateOptionalConfiguredFile( + final variablesResolution = await _validateOptionalConfiguredFile( diagnostics, - root, + anchor, config.varsFile, + configPath: configPath, + configSource: configSource, + kind: 'variables', code: 'writerside.config.missing-vars-file', ); - await _validateOptionalConfiguredFile( + final categoriesResolution = await _validateOptionalConfiguredFile( diagnostics, - root, + anchor, config.categoriesFile, + configPath: configPath, + configSource: configSource, + kind: 'categories', code: 'writerside.config.missing-categories-file', ); await _validateOptionalConfiguredFile( diagnostics, - root, + anchor, config.instanceGroupsFile, + configPath: configPath, + configSource: configSource, + kind: 'instanceGroups', code: 'writerside.config.missing-instance-groups-file', ); + await _validateConfiguredPathSafety( + diagnostics, + anchor, + config.resourcesFile, + configPath: configPath, + configSource: configSource, + kind: 'resourcesFile', + allowRoot: false, + ); + await _validateConfiguredPathSafety( + diagnostics, + anchor, + config.resourcesDir, + configPath: configPath, + configSource: configSource, + kind: 'resourcesDirectory', + allowRoot: true, + ); final instances = []; for (final source in config.instanceSources) { - final treePath = p.join(root, source); - if (!await File(treePath).exists()) { + final resolution = await _resolveConfiguredPath( + diagnostics, + anchor, + source, + configPath: configPath, + configSource: configSource, + kind: 'instanceTree', + allowRoot: false, + ); + if (resolution == null) { + continue; + } + final treePath = resolution.path; + if (resolution.type != FileSystemEntityType.file) { diagnostics.add( Diagnostic( code: 'writerside.config.missing-instance-tree', @@ -137,24 +224,61 @@ class WritersideModuleService { ); continue; } - final treeSource = await File(treePath).readAsString(); + final treeSource = await _readFileForParsing( + File(treePath), + diagnostics, + effectiveScanOptions, + readFailureCode: 'workspace.file.read-failed', + ); + if (treeSource == null) { + continue; + } final instance = treeParser.parse(treePath, treeSource); diagnostics.addAll(instance.diagnostics); instances.add(instance); } final topics = []; + final unparsedTopics = _UnparsedTopicIndex(); + var parsedDocuments = 0; for (final topicsRoot in usableTopicRoots) { final scan = await scanWorkspaceEntities( topicsRoot, - options: scanOptions, + options: effectiveScanOptions, ); diagnostics.addAll(scan.diagnostics); for (final entity in scan.entities.whereType()) { final extension = p.extension(entity.path).toLowerCase(); + if (extension != '.md' && + extension != '.markdown' && + extension != '.topic') { + continue; + } + final topicFileName = normalizedRelative(topicsRoot, entity.path); + if (parsedDocuments >= effectiveScanOptions.maxParsedDocuments) { + diagnostics.add( + Diagnostic( + code: 'workspace.scan.document-limit', + severity: DiagnosticSeverity.warning, + filePath: entity.path, + ), + ); + unparsedTopics.add(topicFileName); + continue; + } + final source = await _readFileForParsing( + entity, + diagnostics, + effectiveScanOptions, + readFailureCode: 'writerside.topic.read-failed', + ); + if (source == null) { + unparsedTopics.add(topicFileName); + continue; + } + parsedDocuments++; try { if (extension == '.md' || extension == '.markdown') { - final source = await entity.readAsString(); final topic = topicParser.parseMarkdown( filePath: entity.path, source: source, @@ -162,8 +286,7 @@ class WritersideModuleService { ); diagnostics.addAll(topic.diagnostics); topics.add(topic); - } else if (extension == '.topic') { - final source = await entity.readAsString(); + } else { final topic = topicParser.parseXml( filePath: entity.path, source: source, @@ -173,6 +296,7 @@ class WritersideModuleService { topics.add(topic); } } on Object catch (error) { + unparsedTopics.add(topicFileName); diagnostics.add( Diagnostic( code: 'writerside.topic.read-failed', @@ -186,27 +310,43 @@ class WritersideModuleService { } final variables = []; - if (config.varsFile != null) { - final file = File(p.join(root, config.varsFile)); + var validateVariables = true; + if (variablesResolution?.type == FileSystemEntityType.file) { + final file = File(variablesResolution!.path); if (await file.exists()) { - final parsed = variablesParser.parse( - file.path, - await file.readAsString(), + final source = await _readFileForParsing( + file, + diagnostics, + effectiveScanOptions, + readFailureCode: 'workspace.file.read-failed', ); - variables.addAll(parsed.$1); - diagnostics.addAll(parsed.$2); + if (source == null) { + validateVariables = false; + } else { + final parsed = variablesParser.parse(file.path, source); + variables.addAll(parsed.$1); + diagnostics.addAll(parsed.$2); + } } } final categories = []; - if (config.categoriesFile != null) { - final file = File(p.join(root, config.categoriesFile)); + var validateCategories = true; + if (categoriesResolution?.type == FileSystemEntityType.file) { + final file = File(categoriesResolution!.path); if (await file.exists()) { - final parsed = categoriesParser.parse( - file.path, - await file.readAsString(), + final source = await _readFileForParsing( + file, + diagnostics, + effectiveScanOptions, + readFailureCode: 'workspace.file.read-failed', ); - categories.addAll(parsed.$1); - diagnostics.addAll(parsed.$2); + if (source == null) { + validateCategories = false; + } else { + final parsed = categoriesParser.parse(file.path, source); + categories.addAll(parsed.$1); + diagnostics.addAll(parsed.$2); + } } } @@ -218,8 +358,17 @@ class WritersideModuleService { variables: variables, categories: categories, diagnostics: const [], + validatedImageDirs: validatedImageDirs, + ); + diagnostics.addAll( + _resolve( + module, + validatedImageDirs, + unparsedTopics: unparsedTopics, + validateVariables: validateVariables, + validateCategories: validateCategories, + ), ); - diagnostics.addAll(_resolve(module)); return WritersideModule( rootPath: module.rootPath, config: module.config, @@ -228,31 +377,134 @@ class WritersideModuleService { variables: module.variables, categories: module.categories, diagnostics: sortDiagnostics(diagnostics), + validatedImageDirs: module.validatedImageDirs, ); } Future _resolveConfigPath(String root) async { final configPath = p.join(root, 'writerside.cfg'); - if (await File(configPath).exists()) { + final configType = await FileSystemEntity.type( + configPath, + followLinks: false, + ); + if (configType == FileSystemEntityType.file || + configType == FileSystemEntityType.link) { return configPath; } final legacyPath = p.join(root, 'project.ihp'); - if (await File(legacyPath).exists()) { + final legacyType = await FileSystemEntity.type( + legacyPath, + followLinks: false, + ); + if (legacyType == FileSystemEntityType.file || + legacyType == FileSystemEntityType.link) { return legacyPath; } return null; } + WritersideModule _emptyModule( + String root, + String configPath, + List diagnostics, + ) { + final emptyConfig = WritersideConfig( + filePath: configPath, + version: null, + moduleName: null, + topicRoots: const [WritersideTopicRoot(dir: 'topics', explicit: false)], + imageRoots: const [WritersideImageRoot(dir: 'images', explicit: false)], + apiSpecificationsDir: 'specifications', + apiSpecificationsExplicit: false, + buildConfigDir: 'cfg', + buildConfigExplicit: false, + snippetsDir: null, + resourcesFile: null, + resourcesDir: null, + varsFile: null, + categoriesFile: null, + instanceGroupsFile: null, + instances: const [], + settings: const WritersideSettingsConfig(), + diagnostics: const [], + ); + return WritersideModule( + rootPath: root, + config: emptyConfig, + instances: const [], + topics: const [], + variables: const [], + categories: const [], + diagnostics: sortDiagnostics(diagnostics), + validatedImageDirs: const ['images'], + ); + } + + Future _readFileForParsing( + File file, + List diagnostics, + WorkspaceScanOptions options, { + required String readFailureCode, + }) async { + final maxBytes = options.maxParsedFileBytes; + if (maxBytes < 0) { + diagnostics.add(_fileTooLargeDiagnostic(file.path)); + return null; + } + try { + final bytes = BytesBuilder(copy: false); + await for (final chunk in file.openRead(0, maxBytes + 1)) { + bytes.add(chunk); + } + if (bytes.length > maxBytes) { + diagnostics.add(_fileTooLargeDiagnostic(file.path)); + return null; + } + return utf8.decode(bytes.takeBytes()); + } on Object catch (error) { + diagnostics.add( + Diagnostic( + code: readFailureCode, + severity: DiagnosticSeverity.warning, + filePath: file.path, + args: {'error': '$error'}, + ), + ); + return null; + } + } + + Diagnostic _fileTooLargeDiagnostic(String filePath) { + return Diagnostic( + code: 'workspace.file.too-large', + severity: DiagnosticSeverity.warning, + filePath: filePath, + ); + } + Future> _existingTopicRoots( List diagnostics, - String root, + CanonicalPathAnchor anchor, WritersideConfig config, + String configPath, + String configSource, ) async { final existing = []; for (final topicRoot in config.topicRoots) { - final path = p.join(root, topicRoot.dir); - if (await Directory(path).exists()) { - existing.add(path); + final resolution = await _resolveConfiguredPath( + diagnostics, + anchor, + topicRoot.dir, + configPath: configPath, + configSource: configSource, + kind: 'topics', + allowRoot: true, + ); + if (resolution == null) { + continue; + } + if (resolution.type == FileSystemEntityType.directory) { + existing.add(resolution.path); continue; } if (topicRoot.explicit) { @@ -260,7 +512,7 @@ class WritersideModuleService { Diagnostic( code: 'writerside.config.missing-directory', severity: DiagnosticSeverity.error, - filePath: path, + filePath: resolution.path, args: {'kind': 'topics', 'relativePath': topicRoot.dir}, ), ); @@ -270,7 +522,7 @@ class WritersideModuleService { config.topicRoots.any( (root) => !root.explicit && root.dir == 'topics', )) { - final path = p.join(root, 'topics'); + final path = p.join(anchor.rootPath, 'topics'); diagnostics.add( Diagnostic( code: 'writerside.config.missing-directory', @@ -283,14 +535,31 @@ class WritersideModuleService { return existing; } - Future _validateImageRoots( + Future> _validateImageRoots( List diagnostics, - String root, + CanonicalPathAnchor anchor, WritersideConfig config, + String configPath, + String configSource, ) async { + final validated = []; for (final imageRoot in config.imageRoots) { - final path = p.join(root, imageRoot.dir); - if (await Directory(path).exists()) { + final resolution = await _resolveConfiguredPath( + diagnostics, + anchor, + imageRoot.dir, + configPath: configPath, + configSource: configSource, + kind: 'images', + allowRoot: true, + ); + if (resolution == null) { + continue; + } + validated.add( + p.normalize(p.relative(resolution.path, from: anchor.rootPath)), + ); + if (resolution.type == FileSystemEntityType.directory) { continue; } diagnostics.add( @@ -299,62 +568,168 @@ class WritersideModuleService { severity: imageRoot.explicit ? DiagnosticSeverity.warning : DiagnosticSeverity.info, - filePath: path, + filePath: resolution.path, args: {'kind': 'images', 'relativePath': imageRoot.dir}, ), ); } + return validated.isEmpty ? const ['images'] : validated.toSet().toList(); } - Future _validateOptionalConfiguredDirectory( + Future _validateOptionalConfiguredDirectory( List diagnostics, - String root, + CanonicalPathAnchor anchor, String? relativePath, { + required String configPath, + required String configSource, + required String kind, required bool explicit, required String code, required DiagnosticSeverity severity, }) async { if (!explicit || relativePath == null || relativePath.isEmpty) { - return; + return null; } - final path = p.join(root, relativePath); - if (await Directory(path).exists()) { - return; + final resolution = await _resolveConfiguredPath( + diagnostics, + anchor, + relativePath, + configPath: configPath, + configSource: configSource, + kind: kind, + allowRoot: true, + ); + if (resolution == null || + resolution.type == FileSystemEntityType.directory) { + return resolution; } diagnostics.add( Diagnostic( code: code, severity: severity, - filePath: path, + filePath: resolution.path, args: {'relativePath': relativePath}, ), ); + return resolution; } - Future _validateOptionalConfiguredFile( + Future _validateOptionalConfiguredFile( List diagnostics, - String root, + CanonicalPathAnchor anchor, String? relativePath, { + required String configPath, + required String configSource, + required String kind, required String code, }) async { if (relativePath == null || relativePath.isEmpty) { - return; + return null; } - final path = p.join(root, relativePath); - if (await File(path).exists()) { - return; + final resolution = await _resolveConfiguredPath( + diagnostics, + anchor, + relativePath, + configPath: configPath, + configSource: configSource, + kind: kind, + allowRoot: false, + ); + if (resolution == null || resolution.type == FileSystemEntityType.file) { + return resolution; } diagnostics.add( Diagnostic( code: code, severity: DiagnosticSeverity.warning, - filePath: path, + filePath: resolution.path, args: {'relativePath': relativePath}, ), ); + return resolution; + } + + Future _validateConfiguredPathSafety( + List diagnostics, + CanonicalPathAnchor anchor, + String? configuredPath, { + required String configPath, + required String configSource, + required String kind, + required bool allowRoot, + }) async { + if (configuredPath == null || configuredPath.isEmpty) { + return; + } + await _resolveConfiguredPath( + diagnostics, + anchor, + configuredPath, + configPath: configPath, + configSource: configSource, + kind: kind, + allowRoot: allowRoot, + ); + } + + Future _resolveConfiguredPath( + List diagnostics, + CanonicalPathAnchor anchor, + String configuredPath, { + required String configPath, + required String configSource, + required String kind, + required bool allowRoot, + }) async { + final candidate = p.isAbsolute(configuredPath) + ? p.normalize(configuredPath) + : p.normalize(p.join(anchor.rootPath, configuredPath)); + try { + return await resolveAnchoredPath( + anchor, + candidate, + allowRoot: allowRoot, + allowMissingAncestors: true, + ); + } on AnchoredPathViolation catch (error) { + diagnostics.add( + _unsafeConfiguredPathDiagnostic( + configPath: configPath, + configSource: configSource, + configuredPath: configuredPath, + kind: kind, + error: error, + ), + ); + return null; + } + } + + Diagnostic _unsafeConfiguredPathDiagnostic({ + required String configPath, + String? configSource, + required String configuredPath, + required String kind, + required AnchoredPathViolation error, + }) { + return Diagnostic( + code: 'writerside.config.path-unsafe', + severity: DiagnosticSeverity.error, + filePath: configPath, + args: {'path': configuredPath, 'kind': kind, 'reason': error.reason.name}, + sourceSpan: configSource == null + ? null + : _stringSpan(configPath, configSource, configuredPath), + ); } - List _resolve(WritersideModule module) { + List _resolve( + WritersideModule module, + List validatedImageDirs, { + required _UnparsedTopicIndex unparsedTopics, + required bool validateVariables, + required bool validateCategories, + }) { final diagnostics = []; final variableNames = module.variableNames; final categoryIds = module.categories.map((item) => item.id).toSet(); @@ -377,7 +752,8 @@ class WritersideModuleService { for (final instance in module.instances) { if (instance.startPage != null) { final resolved = _resolveTopicReference(module, instance.startPage!); - if (resolved.isMissing) { + if (resolved.isMissing && + !unparsedTopics.matches(instance.startPage!)) { diagnostics.add( Diagnostic( code: 'writerside.tree.missing-start-page', @@ -399,7 +775,7 @@ class WritersideModuleService { final topic = node.topicFileName; if (topic != null) { final resolved = _resolveTopicReference(module, topic); - if (resolved.isMissing) { + if (resolved.isMissing && !unparsedTopics.matches(topic)) { diagnostics.add( Diagnostic( code: 'writerside.tree.missing-topic', @@ -460,17 +836,19 @@ class WritersideModuleService { } duplicateIds[id.id] = id.span; } - for (final variable in topic.variables) { - if (!variableNames.contains(variable.name)) { - diagnostics.add( - Diagnostic( - code: 'writerside.variable.unresolved', - severity: DiagnosticSeverity.warning, - filePath: topic.filePath, - args: {'name': variable.name}, - sourceSpan: variable.span, - ), - ); + if (validateVariables) { + for (final variable in topic.variables) { + if (!variableNames.contains(variable.name)) { + diagnostics.add( + Diagnostic( + code: 'writerside.variable.unresolved', + severity: DiagnosticSeverity.warning, + filePath: topic.filePath, + args: {'name': variable.name}, + sourceSpan: variable.span, + ), + ); + } } } for (final link in topic.links) { @@ -486,6 +864,10 @@ class WritersideModuleService { : _resolveTopicReference(module, targetReference, fromTopic: topic); final target = resolved.topic; if (target == null) { + if (resolved.isMissing && + unparsedTopics.matches(targetReference, fromTopic: topic)) { + continue; + } diagnostics.add( resolved.isAmbiguous ? _ambiguousTopicReferenceDiagnostic( @@ -532,7 +914,12 @@ class WritersideModuleService { if (hasUriScheme(image.destination)) { continue; } - if (!_localImageExistsInAnyRoot(module, topic, image.destination)) { + if (!_localImageExistsInAnyRoot( + module, + topic, + image.destination, + validatedImageDirs, + )) { diagnostics.add( Diagnostic( code: 'markdown.image.missing-file', @@ -563,6 +950,10 @@ class WritersideModuleService { ); final target = resolved.topic; if (target == null) { + if (resolved.isMissing && + unparsedTopics.matches(include.from!, fromTopic: topic)) { + continue; + } if (!include.nullable) { diagnostics.add( resolved.isAmbiguous @@ -597,25 +988,27 @@ class WritersideModuleService { } } } - for (final categoryRef in RegExp( - r']*ref="([^"]+)"', - ).allMatches(topic.markdown?.source ?? '')) { - final ref = categoryRef.group(1)!; - if (!categoryIds.contains(ref)) { - diagnostics.add( - Diagnostic( - code: 'writerside.category.unresolved', - severity: DiagnosticSeverity.warning, - filePath: topic.filePath, - args: {'ref': ref}, - sourceSpan: SourceSpan.fromOffsets( + if (validateCategories) { + for (final categoryRef in RegExp( + r']*ref="([^"]+)"', + ).allMatches(topic.markdown?.source ?? '')) { + final ref = categoryRef.group(1)!; + if (!categoryIds.contains(ref)) { + diagnostics.add( + Diagnostic( + code: 'writerside.category.unresolved', + severity: DiagnosticSeverity.warning, filePath: topic.filePath, - source: topic.markdown!.source, - startOffset: categoryRef.start, - endOffset: categoryRef.end, + args: {'ref': ref}, + sourceSpan: SourceSpan.fromOffsets( + filePath: topic.filePath, + source: topic.markdown!.source, + startOffset: categoryRef.start, + endOffset: categoryRef.end, + ), ), - ), - ); + ); + } } } } @@ -655,8 +1048,9 @@ class WritersideModuleService { WritersideModule module, WritersideTopic topic, String destination, + List validatedImageDirs, ) { - for (final imagesDir in module.config.imagesDirs) { + for (final imagesDir in validatedImageDirs) { if (localImageExists( activeFilePath: topic.filePath, destination: destination, @@ -698,3 +1092,32 @@ class _TopicResolution { bool get isAmbiguous => matches.length > 1; WritersideTopic? get topic => matches.length == 1 ? matches.single : null; } + +class _UnparsedTopicIndex { + final _fileNames = {}; + final _baseNames = {}; + + void add(String fileName) { + _fileNames.add(fileName); + _baseNames.add(p.basename(fileName)); + } + + bool matches(String reference, {WritersideTopic? fromTopic}) { + final normalized = p.normalize(reference.trim()).replaceAll(r'\', '/'); + if (normalized.isEmpty) { + return false; + } + if (_fileNames.contains(normalized)) { + return true; + } + if (fromTopic != null && !p.isAbsolute(normalized)) { + final relative = p + .normalize(p.join(p.dirname(fromTopic.fileName), normalized)) + .replaceAll(r'\', '/'); + if (_fileNames.contains(relative)) { + return true; + } + } + return _baseNames.contains(p.basename(normalized)); + } +} diff --git a/linux/io.busystack.busymark.metainfo.xml b/linux/io.busystack.busymark.metainfo.xml index 92fbe73..b64514a 100644 --- a/linux/io.busystack.busymark.metainfo.xml +++ b/linux/io.busystack.busymark.metainfo.xml @@ -63,7 +63,7 @@ https://github.com/busystack/busymark/issues - + diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index df684c8..dda7bc4 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -109,6 +109,7 @@ struct _MyApplication { GtkWidget* main_menu; GSimpleActionGroup* header_action_group; GSimpleAction* view_mode_action; + GSimpleAction* full_screen_action; gchar* view_mode; gchar* search_query; gchar* background_color; @@ -128,6 +129,7 @@ struct _MyApplication { gboolean sidebar_visible; gboolean text_direction_rtl; gboolean back_visible; + gboolean full_screen; gboolean search_active; gboolean modal_barrier_visible; gint modal_barrier_depth; @@ -149,6 +151,7 @@ struct HeaderBarConfiguration { gboolean sidebar_visible; gboolean sidebar_toggle_visible; gboolean back_visible; + gboolean full_screen; gboolean modal_barrier_visible; gint64 modal_barrier_depth; const gchar* search_query; @@ -1068,6 +1071,62 @@ static void invoke_header_bar_bool_action(MyApplication* self, nullptr, nullptr, nullptr); } +static const gchar* sidebar_shortcut_action_for_key(guint keyval) { + switch (keyval) { + case GDK_KEY_1: + case GDK_KEY_KP_1: + return "sidebarFiles"; + case GDK_KEY_2: + case GDK_KEY_KP_2: + return "sidebarToc"; + case GDK_KEY_3: + case GDK_KEY_KP_3: + return "sidebarOutline"; + case GDK_KEY_4: + case GDK_KEY_KP_4: + return "sidebarGit"; + case GDK_KEY_5: + case GDK_KEY_KP_5: + return "sidebarHistory"; + default: + return nullptr; + } +} + +// GtkSearchEntry owns the native keyboard focus while search is active, so +// these workspace shortcuts cannot reach Flutter's Shortcuts widget. Bridge +// them through the same header-bar channel used by the native controls. +static gboolean search_entry_key_press_cb(GtkWidget*, + GdkEventKey* event, + gpointer user_data) { + MyApplication* self = MY_APPLICATION(user_data); + const guint modifiers = + event->state & gtk_accelerator_get_default_mod_mask(); + if (self->suppress_header_actions || self->modal_barrier_visible) { + return FALSE; + } + if (modifiers == 0 && event->keyval == GDK_KEY_F11) { + invoke_header_bar_action(self, "fullScreen"); + return TRUE; + } + if (self->back_visible && modifiers == GDK_MOD1_MASK && + (event->keyval == GDK_KEY_Left || + event->keyval == GDK_KEY_KP_Left)) { + invoke_header_bar_action(self, "back"); + return TRUE; + } + if (modifiers != GDK_CONTROL_MASK) { + return FALSE; + } + const gchar* action = sidebar_shortcut_action_for_key(event->keyval); + if (action == nullptr) { + return FALSE; + } + focus_flutter_view(self); + invoke_header_bar_action(self, action); + return TRUE; +} + enum class SearchQueryUpdateDisposition { kAlreadyCurrent, kPreserveNativeText, @@ -1266,6 +1325,9 @@ static const gchar* main_menu_icon_name(const gchar* action) { if (g_strcmp0(action, "keyboardShortcuts") == 0) { return "input-keyboard-symbolic"; } + if (g_strcmp0(action, "fullScreen") == 0) { + return "view-fullscreen-symbolic"; + } if (g_strcmp0(action, "markdownAndHtml") == 0) { return "text-x-generic-symbolic"; } @@ -1313,6 +1375,11 @@ static void rebuild_main_menu_model(MyApplication* self, FlValue* labels) { localized_label_or(labels, "exportPdf", ""), "header.export-pdf", main_menu_icon_name("exportPdf"), fl_lookup_string_arg(labels, "exportPdfGtkAccelerator")); + append_action_menu_item( + self->main_menu_model, + localized_label_or(labels, "fullScreen", ""), "header.full-screen", + main_menu_icon_name("fullScreen"), + fl_lookup_string_arg(labels, "fullScreenGtkAccelerator")); append_action_menu_item( self->main_menu_model, localized_label_or(labels, "settings", ""), "header.settings", @@ -1394,6 +1461,14 @@ static void set_view_mode(MyApplication* self, const gchar* mode) { update_view_mode_icon(self); } +static void set_full_screen(MyApplication* self, gboolean full_screen) { + self->full_screen = full_screen; + if (self->full_screen_action != nullptr) { + g_simple_action_set_state(self->full_screen_action, + g_variant_new_boolean(full_screen)); + } +} + static void header_gaction_activated_cb(GSimpleAction* action, GVariant* parameter, gpointer user_data) { @@ -1431,6 +1506,18 @@ static void view_mode_gaction_activated_cb(GSimpleAction* action, invoke_header_bar_action(self, dart_action); } +static void full_screen_gaction_activated_cb(GSimpleAction* action, + GVariant* parameter, + gpointer user_data) { + MyApplication* self = MY_APPLICATION(user_data); + if (self->suppress_header_actions || self->modal_barrier_visible) { + return; + } + set_full_screen(self, !self->full_screen); + focus_flutter_view(self); + invoke_header_bar_action(self, "fullScreen"); +} + static void add_header_gaction(MyApplication* self, const gchar* action_name, const gchar* dart_action) { @@ -1467,6 +1554,13 @@ static void setup_header_actions(MyApplication* self) { add_header_gaction(self, "report-issue", "reportIssue"); add_header_gaction(self, "about", "aboutBusyMark"); + self->full_screen_action = g_simple_action_new_stateful( + "full-screen", nullptr, g_variant_new_boolean(FALSE)); + g_signal_connect(self->full_screen_action, "activate", + G_CALLBACK(full_screen_gaction_activated_cb), self); + g_action_map_add_action(G_ACTION_MAP(self->header_action_group), + G_ACTION(self->full_screen_action)); + self->view_mode_action = g_simple_action_new_stateful( "view-mode", G_VARIANT_TYPE_STRING, g_variant_new_string("split")); g_signal_connect(self->view_mode_action, "activate", @@ -1589,8 +1683,10 @@ static void set_localized_labels(MyApplication* self, FlValue* args) { const gchar* sidebar_shortcut = fl_lookup_string_arg(args, "sidebarShortcut"); const gchar* back = fl_lookup_string_arg(args, "back"); + const gchar* back_shortcut = + fl_lookup_string_arg(args, "backShortcut"); - set_widget_tooltip(self->back_button, back); + set_widget_tooltip_with_shortcut(self->back_button, back, back_shortcut); set_widget_tooltip_with_shortcut(self->sidebar_toggle_button, sidebar, sidebar_shortcut); set_widget_tooltip_with_shortcut(self->search_button, search, @@ -1783,6 +1879,8 @@ static gboolean decode_header_bar_configuration( &configuration->sidebar_toggle_visible) || !fl_lookup_optional_bool_arg(args, "backVisible", &configuration->back_visible) || + !fl_lookup_optional_bool_arg(args, "fullScreen", + &configuration->full_screen) || !fl_lookup_optional_bool_arg(args, "modalBarrierVisible", &configuration->modal_barrier_visible) || !fl_lookup_int64_arg(args, "modalBarrierDepth", @@ -1820,6 +1918,7 @@ static void apply_header_bar_configuration( set_sidebar_toggle_visible(self, configuration.sidebar_toggle_visible); set_search_visible(self, configuration.search_visible); set_back_visible(self, configuration.back_visible); + set_full_screen(self, configuration.full_screen); set_document_controls_visible( self, configuration.document_controls_visible); set_search_query(self, configuration.search_query); @@ -1915,6 +2014,8 @@ static GtkWidget* create_busymark_titlebar(MyApplication* self) { G_CALLBACK(search_entry_changed_cb), self); g_signal_connect(self->search_entry, "activate", G_CALLBACK(search_entry_activate_cb), self); + g_signal_connect(self->search_entry, "key-press-event", + G_CALLBACK(search_entry_key_press_cb), self); g_signal_connect(self->search_entry, "focus-in-event", G_CALLBACK(search_entry_focus_in_cb), self); g_signal_connect(self->search_entry, "focus-out-event", @@ -2813,6 +2914,7 @@ static void my_application_dispose(GObject* object) { g_clear_object(&self->main_menu_model); g_clear_object(&self->view_mode_menu_model); g_clear_object(&self->view_mode_action); + g_clear_object(&self->full_screen_action); g_clear_object(&self->header_action_group); g_clear_pointer(&self->background_color, g_free); g_clear_pointer(&self->sidebar_background_color, g_free); @@ -2872,6 +2974,7 @@ static void my_application_init(MyApplication* self) { self->main_menu = nullptr; self->header_action_group = nullptr; self->view_mode_action = nullptr; + self->full_screen_action = nullptr; self->view_mode = nullptr; self->search_query = g_strdup(""); self->background_color = g_strdup(kDefaultHeaderbarBackground); @@ -2891,6 +2994,7 @@ static void my_application_init(MyApplication* self) { self->sidebar_visible = TRUE; self->text_direction_rtl = FALSE; self->back_visible = FALSE; + self->full_screen = FALSE; self->search_active = FALSE; self->modal_barrier_visible = FALSE; self->modal_barrier_depth = 0; diff --git a/pubspec.yaml b/pubspec.yaml index 9388c79..f7f5dd1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: busymark description: Local-first Markdown and Writerside-compatible documentation editor. publish_to: 'none' -version: 0.2.4 +version: 0.2.5 environment: sdk: ^3.12.1 diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index e06c8de..fcfa160 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -1,6 +1,6 @@ name: busymark title: BusyMark -version: "0.2.4" +version: "0.2.5" summary: Markdown and Writerside documentation editor # Snap Store listing translations are managed outside this Flutter package. # Update store metadata when approved translated listing text is supplied. diff --git a/test/fixtures/writerside/basic_project/topics/intro.md b/test/fixtures/writerside/basic_project/topics/intro.md index a333c0e..83e5f37 100644 --- a/test/fixtures/writerside/basic_project/topics/intro.md +++ b/test/fixtures/writerside/basic_project/topics/intro.md @@ -7,3 +7,36 @@ Welcome to %product%.

This is a note.

See [Install](install.topic). + + + + +## Test header 21 + +### Test header 211 +### Test header 212 +### Test header 213 + +## Test header 23 + + +### Test header 232 +### Test header 231 +### Test header 22 + +#### Test header 221 +#### Test header 222 +#### Test header 223 + + +### Test header 233 + +### Test header 24 + + +#### Test header 241 +#### Test header 242 +#### Test header 243 + + + diff --git a/test/src/app_router_test.dart b/test/src/app_router_test.dart index c0552e9..e12b8dd 100644 --- a/test/src/app_router_test.dart +++ b/test/src/app_router_test.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:busymark/l10n/generated/app_localizations_en.dart'; import 'package:busymark/src/app/app_router.dart'; import 'package:busymark/src/app/busymark_app.dart'; +import 'package:busymark/src/app/busymark_shortcuts.dart'; import 'package:busymark/src/platform/linux_header_bar_service.dart'; import 'package:busymark/src/workspace/presentation/settings_screen.dart'; import 'package:busymark/src/workspace/workspace_controller.dart'; @@ -109,7 +110,9 @@ void main() { findsOneWidget, ); - await tester.tap(find.byTooltip(l10n.back)); + await tester.tap( + find.byTooltip('${l10n.back} (${BusyMarkAppShortcutLabels.back})'), + ); await tester.pumpAndSettle(); expect( diff --git a/test/src/app_settings_test.dart b/test/src/app_settings_test.dart index 8758dfa..3c5456c 100644 --- a/test/src/app_settings_test.dart +++ b/test/src/app_settings_test.dart @@ -1,10 +1,12 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:busymark/src/app/app_settings.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; void main() { test('window behavior defaults protect unsaved changes', () { @@ -138,6 +140,111 @@ void main() { ); }); + test( + 'file settings store keeps the live JSON intact until publication', + () async { + final directory = await Directory.systemTemp.createTemp( + 'busymark-settings-atomic-', + ); + addTearDown(() async { + if (await directory.exists()) { + await directory.delete(recursive: true); + } + }); + final settingsPath = p.join(directory.path, 'settings.json'); + final target = File(settingsPath); + final initialStore = JsonFileLocalSettingsStore( + settingsFilePathOverride: settingsPath, + ); + await initialStore.save({'generation': 1}); + final publishReached = Completer(); + final allowPublish = Completer(); + addTearDown(() { + if (!allowPublish.isCompleted) { + allowPublish.complete(); + } + }); + final store = JsonFileLocalSettingsStore( + settingsFilePathOverride: settingsPath, + beforePublish: (stagedFile, targetFile) async { + publishReached.complete(); + expect(targetFile.path, target.path); + expect( + (jsonDecode(await targetFile.readAsString()) as Map)['generation'], + 1, + ); + expect( + (jsonDecode(await stagedFile.readAsString()) as Map)['generation'], + 2, + ); + expect(p.dirname(stagedFile.parent.path), directory.path); + await allowPublish.future; + }, + ); + + final save = store.save({'generation': 2}); + await publishReached.future; + + expect((jsonDecode(await target.readAsString()) as Map)['generation'], 1); + allowPublish.complete(); + await save; + + expect((await store.load())['generation'], 2); + expect( + await directory + .list() + .where( + (entity) => + p.basename(entity.path).startsWith('.busymark-settings-'), + ) + .toList(), + isEmpty, + ); + }, + ); + + test( + 'failed settings publication preserves the previous valid JSON', + () async { + final directory = await Directory.systemTemp.createTemp( + 'busymark-settings-failure-', + ); + addTearDown(() async { + if (await directory.exists()) { + await directory.delete(recursive: true); + } + }); + final settingsPath = p.join(directory.path, 'settings.json'); + final initialStore = JsonFileLocalSettingsStore( + settingsFilePathOverride: settingsPath, + ); + await initialStore.save({'generation': 1}); + final interruptedStore = JsonFileLocalSettingsStore( + settingsFilePathOverride: settingsPath, + beforePublish: (stagedFile, targetFile) { + throw StateError('simulated interruption before atomic publication'); + }, + ); + + await expectLater( + interruptedStore.save({'generation': 2}), + throwsStateError, + ); + + expect((await initialStore.load())['generation'], 1); + expect( + await directory + .list() + .where( + (entity) => + p.basename(entity.path).startsWith('.busymark-settings-'), + ) + .toList(), + isEmpty, + ); + }, + ); + test('window behavior settings persist', () async { final store = _MemorySettingsStore(); final container = ProviderContainer( diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index 7c5d00b..44cd85a 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -24,6 +24,7 @@ import 'package:busymark/src/editor/document_callout.dart'; import 'package:busymark/src/editor/document_code_block.dart'; import 'package:busymark/src/editor/document_layout.dart'; import 'package:busymark/src/editor/document_list_marker.dart'; +import 'package:busymark/src/editor/document_text_geometry.dart'; import 'package:busymark/src/editor/document_thematic_break.dart'; import 'package:busymark/src/editor/markdown_image_view.dart'; import 'package:busymark/src/editor/source/source_editor.dart'; @@ -32,6 +33,7 @@ import 'package:busymark/src/feedback/presentation/feedback_dialog.dart'; import 'package:busymark/src/git/application/git_controller.dart'; import 'package:busymark/src/git/domain/git_models.dart'; import 'package:busymark/src/git/presentation/git_diff_viewer.dart'; +import 'package:busymark/src/markdown/preview_model.dart'; import 'package:busymark/src/platform/linux_header_bar_service.dart'; import 'package:busymark/src/writerside/writerside_model.dart'; import 'package:busymark/src/writerside/writerside_topic_creator.dart'; @@ -42,6 +44,7 @@ import 'package:busymark/src/workspace/workspace_model.dart'; import 'package:busymark/src/workspace/workspace_service.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -142,6 +145,85 @@ void main() { ); }); + testWidgets('main menu and F11 toggle full-screen mode', (tester) async { + final nativeWindow = _FakeNativeWindowController(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + nativeWindowControllerProvider.overrideWithValue(nativeWindow), + ], + child: const BusyMarkApp(), + ), + ); + await tester.pumpAndSettle(); + + Finder fullScreenMenuItem() => find.byWidgetPredicate( + (widget) => + widget is BusyMarkPopupMenuItem && + widget.label == l10n.fullScreen, + ); + + await tester.tap(find.byTooltip(l10n.mainMenu)); + await tester.pumpAndSettle(); + expect(find.text(l10n.fullScreen), findsOneWidget); + expect(find.text(BusyMarkAppShortcutLabels.fullScreen), findsOneWidget); + expect( + tester + .widget>(fullScreenMenuItem()) + .checked, + isFalse, + ); + + await tester.tap(find.text(l10n.fullScreen)); + await tester.pumpAndSettle(); + expect(nativeWindow.fullScreenValues, [true]); + + await tester.tap(find.byTooltip(l10n.mainMenu)); + await tester.pumpAndSettle(); + expect( + tester + .widget>(fullScreenMenuItem()) + .checked, + isTrue, + ); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + await tester.sendKeyEvent(LogicalKeyboardKey.f11); + await tester.pumpAndSettle(); + expect(nativeWindow.fullScreenValues, [true, false]); + }); + + testWidgets('Alt+Left activates the header Back command', (tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + ], + child: const BusyMarkApp(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byTooltip(l10n.mainMenu)); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.settings)); + await tester.pumpAndSettle(); + expect( + find.byTooltip('${l10n.back} (${BusyMarkAppShortcutLabels.back})'), + findsOneWidget, + ); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); + await tester.pumpAndSettle(); + + expect(find.text(l10n.createMarkdownFile), findsOneWidget); + expect(find.byKey(const ValueKey('settings-page-selector')), findsNothing); + }); + testWidgets('Escape closes header popup menus', (tester) async { await tester.pumpWidget( ProviderScope( @@ -2080,6 +2162,23 @@ void main() { testWidgets('sidebar view shortcuts select workspace sidebar tabs', ( tester, ) async { + String? clipboardText; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.setData') { + final arguments = call.arguments as Map; + clipboardText = arguments['text'] as String?; + } + return null; + }, + ); + addTearDown(() { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ); + }); final temp = Directory.systemTemp.createTempSync('busymark_sidebar_keys_'); addTearDown(() { temp.deleteSync(recursive: true); @@ -2093,10 +2192,14 @@ void main() { final gitController = _PresetGitController( _gitSidebarShortcutState(temp.path), ); + final settingsStore = _MemorySettingsStore() + ..value = AppSettings.defaults() + .copyWith(documentViewMode: DocumentViewModePreference.editor) + .toJson(); final container = ProviderContainer( overrides: [ linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), - localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), + localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceServiceProvider.overrideWithValue(service), startupPathProvider.overrideWithValue(temp.path), gitControllerProvider.overrideWith(() => gitController), @@ -2113,6 +2216,24 @@ void main() { await tester.pump(const Duration(milliseconds: 100)); } + Future pressDocumentViewShortcut( + LogicalKeyboardKey key, + DocumentViewModePreference expectedMode, + ) async { + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); + await tester.sendKeyDownEvent(key); + await tester.sendKeyUpEvent(key); + await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + expect( + container.read(appSettingsControllerProvider).documentViewMode, + expectedMode, + ); + } + await tester.pumpWidget( UncontrolledProviderScope( container: container, @@ -2150,12 +2271,34 @@ void main() { await tester.pump(const Duration(milliseconds: 100)); expect(find.byTooltip(l10n.sidebarViewMenu), findsOneWidget); + final activeEditorField = find.byWidgetPredicate( + (widget) => + widget is TextField && + widget.controller?.text.contains('Intro.md') == true, + ); + expect(activeEditorField, findsOneWidget); + await tester.tap(activeEditorField); + await tester.pump(); + expect( + tester.widget(activeEditorField).focusNode?.hasFocus, + isTrue, + reason: 'The shortcut must work while the document field owns focus.', + ); + + await pressDocumentViewShortcut( + LogicalKeyboardKey.digit2, + DocumentViewModePreference.source, + ); await pressControlShortcut(LogicalKeyboardKey.digit1); expect(find.text('Api.md'), findsOneWidget); expect(find.byTooltip(l10n.sidebarViewMenu), findsOneWidget); expect(find.byTooltip(temp.path), findsOneWidget); expect(find.byTooltip(l10n.gitBranchActions), findsNothing); + await pressDocumentViewShortcut( + LogicalKeyboardKey.digit3, + DocumentViewModePreference.preview, + ); await pressControlShortcut(LogicalKeyboardKey.digit4); expect(find.text(l10n.gitNoChanges), findsOneWidget); expect(find.byTooltip(temp.path), findsNothing); @@ -2205,18 +2348,48 @@ void main() { await tester.sendKeyEvent(LogicalKeyboardKey.escape); await tester.pumpAndSettle(); + await pressDocumentViewShortcut( + LogicalKeyboardKey.digit4, + DocumentViewModePreference.split, + ); await pressControlShortcut(LogicalKeyboardKey.digit5); expect(find.text('Sidebar history shortcut commit'), findsOneWidget); expect(find.byTooltip(temp.path), findsNothing); expect(find.byTooltip(l10n.gitBranchActions), findsOneWidget); expect(branchMenu, findsOneWidget); + await pressDocumentViewShortcut( + LogicalKeyboardKey.digit1, + DocumentViewModePreference.editor, + ); await pressControlShortcut(LogicalKeyboardKey.digit3); expect(find.text(l10n.gitNoChanges), findsNothing); expect(find.text('Sidebar history shortcut commit'), findsNothing); expect(find.text('Intro.md'), findsWidgets); expect(find.byTooltip(temp.path), findsNothing); expect(find.byTooltip(l10n.gitBranchActions), findsNothing); + final outlineFileMenu = find.byKey( + const ValueKey('workspace-sidebar-outline-file-menu'), + ); + expect(outlineFileMenu, findsOneWidget); + expect(find.byTooltip(first.path), findsOneWidget); + expect(find.byTooltip(l10n.fileActions), findsOneWidget); + + await tester.tap(find.byTooltip(l10n.fileActions)); + await tester.pumpAndSettle(); + expect(find.text(l10n.copyFileName), findsOneWidget); + expect(find.text(l10n.copyPath), findsOneWidget); + expect(find.text(l10n.openInFiles), findsOneWidget); + + await tester.tap(find.text(l10n.copyFileName)); + await tester.pumpAndSettle(); + expect(clipboardText, 'Intro.md'); + + await tester.tap(find.byTooltip(l10n.fileActions)); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.copyPath)); + await tester.pumpAndSettle(); + expect(clipboardText, first.path); await tester.tap(find.byTooltip(l10n.sidebarViewMenu)); await tester.pumpAndSettle(); @@ -2233,7 +2406,7 @@ void main() { } }); - testWidgets('Writerside sidebar keeps one gap below the project row', ( + testWidgets('Writerside sidebar shortcuts survive document view changes', ( tester, ) async { final binding = TestWidgetsFlutterBinding.ensureInitialized(); @@ -2281,6 +2454,32 @@ void main() { await tester.pumpAndSettle(); } + Future switchDocumentView( + LogicalKeyboardKey key, + DocumentViewModePreference expectedMode, + ) async { + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); + await tester.sendKeyDownEvent(key); + await tester.sendKeyUpEvent(key); + await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect( + container.read(appSettingsControllerProvider).documentViewMode, + expectedMode, + ); + } + + final activeDocumentField = find.byWidgetPredicate( + (widget) => + widget is TextField && + widget.controller?.text.contains('# Introduction') == true, + ); + expect(activeDocumentField, findsOneWidget); + await tester.tap(activeDocumentField); + await tester.pump(); + final primaryRow = find.byKey( const ValueKey('workspace-sidebar-primary-row'), ); @@ -2309,6 +2508,11 @@ void main() { if (key == LogicalKeyboardKey.digit2) { return find.byKey(const ValueKey('workspace-sidebar-toc-menu')); } + if (key == LogicalKeyboardKey.digit3) { + return find.byKey( + const ValueKey('workspace-sidebar-outline-file-menu'), + ); + } if (key == LogicalKeyboardKey.digit4 || key == LogicalKeyboardKey.digit5) { return find.byKey(const ValueKey('workspace-sidebar-branch-menu')); @@ -2318,13 +2522,53 @@ void main() { final actionMenuGuideRight = tester.getRect(primaryRow).right; double? actionMenuRight; - for (final (key, label, contextRow) in <(LogicalKeyboardKey, String, bool)>[ - (LogicalKeyboardKey.digit1, 'Files', true), - (LogicalKeyboardKey.digit2, 'Topics', true), - (LogicalKeyboardKey.digit3, 'Outline', false), - (LogicalKeyboardKey.digit4, 'Commit', true), - (LogicalKeyboardKey.digit5, 'History', true), - ]) { + for (final (key, label, contextRow, documentViewKey, expectedDocumentView) + in < + ( + LogicalKeyboardKey, + String, + bool, + LogicalKeyboardKey, + DocumentViewModePreference, + ) + >[ + ( + LogicalKeyboardKey.digit1, + 'Files', + true, + LogicalKeyboardKey.digit2, + DocumentViewModePreference.source, + ), + ( + LogicalKeyboardKey.digit2, + 'Topics', + true, + LogicalKeyboardKey.digit3, + DocumentViewModePreference.preview, + ), + ( + LogicalKeyboardKey.digit3, + 'Outline', + true, + LogicalKeyboardKey.digit4, + DocumentViewModePreference.split, + ), + ( + LogicalKeyboardKey.digit4, + 'Commit', + true, + LogicalKeyboardKey.digit1, + DocumentViewModePreference.editor, + ), + ( + LogicalKeyboardKey.digit5, + 'History', + true, + LogicalKeyboardKey.digit3, + DocumentViewModePreference.preview, + ), + ]) { + await switchDocumentView(documentViewKey, expectedDocumentView); await selectView(key); expect(viewMarker(key), findsOneWidget, reason: '$label selected view'); final firstContent = find.byKey(firstContentKey); @@ -3392,6 +3636,122 @@ void main() { ); }); + testWidgets('Editor and Preview share task-list presentation and wrapping', ( + tester, + ) async { + tester.view.physicalSize = const Size(1400, 800); + tester.view.devicePixelRatio = 1; + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + const paragraph = + 'P1 — Git push can target the wrong workspace. ' + 'lib/src/git/application/git_controller.dart captures repository A, ' + 'awaits its remotes, then reads the current repository again. ' + 'Switching repositories during that await can target the wrong remote.'; + final settingsStore = _MemorySettingsStore() + ..value = AppSettings.defaults() + .copyWith(documentViewMode: DocumentViewModePreference.editor) + .toJson(); + const service = _SearchWorkspaceService( + '- [x] $paragraph\n' + '- [ ] Pending task\n', + ); + final container = ProviderContainer( + overrides: [ + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue(settingsStore), + workspaceServiceProvider.overrideWithValue(service), + startupPathProvider.overrideWithValue('/tmp/wrap-parity.md'), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + for (var i = 0; i < 30; i += 1) { + await tester.pump(const Duration(milliseconds: 100)); + if (find + .byWidgetPredicate( + (widget) => + widget is TextField && widget.controller?.text == paragraph, + ) + .evaluate() + .isNotEmpty) { + break; + } + } + + final editorField = find.byWidgetPredicate( + (widget) => widget is TextField && widget.controller?.text == paragraph, + ); + final editorRender = _findRenderEditable( + tester.renderObject(editorField), + ); + expect(editorRender, isNotNull); + final editorLineEnds = _visualLineEndOffsets( + paragraph, + (selection) => editorRender!.getBoxesForSelection(selection), + ); + final editorFieldWidth = tester.getSize(editorField).width; + expect(editorLineEnds.length, greaterThan(1)); + final editorCheckedMarker = _taskMarkerVisual(tester, checked: true); + final editorUncheckedMarker = _taskMarkerVisual(tester, checked: false); + final editorMarkerColors = BusyMarkSurfaceColors.of( + tester.element(editorCheckedMarker.finder), + ); + final editorPrimary = Theme.of( + tester.element(editorCheckedMarker.finder), + ).colorScheme.primary; + expect(editorCheckedMarker.onTaskChanged, isNotNull); + expect(editorUncheckedMarker.onTaskChanged, isNotNull); + expect(editorCheckedMarker.color, editorPrimary); + expect(editorUncheckedMarker.color, editorMarkerColors.foreground); + + await container + .read(appSettingsControllerProvider.notifier) + .setDocumentViewMode(DocumentViewModePreference.preview); + await tester.pump(const Duration(milliseconds: 100)); + + final previewText = find.byWidgetPredicate( + (widget) => widget is Text && widget.textSpan?.toPlainText() == paragraph, + ); + final previewRichText = find.descendant( + of: previewText, + matching: find.byType(RichText), + ); + final previewRender = tester.renderObject(previewRichText); + final previewLineEnds = _visualLineEndOffsets( + paragraph, + (selection) => previewRender.getBoxesForSelection(selection), + ); + final previewCheckedMarker = _taskMarkerVisual(tester, checked: true); + final previewUncheckedMarker = _taskMarkerVisual(tester, checked: false); + + expect(previewLineEnds, editorLineEnds); + expect( + tester.getSize(previewRichText).width, + closeTo( + editorFieldWidth - BusyMarkDocumentTextGeometry.editableLayoutInset, + 0.01, + ), + ); + expect(previewCheckedMarker.onTaskChanged, isNull); + expect(previewUncheckedMarker.onTaskChanged, isNull); + expect(previewCheckedMarker.size, editorCheckedMarker.size); + expect(previewUncheckedMarker.size, editorUncheckedMarker.size); + expect(previewCheckedMarker.glyphSize, editorCheckedMarker.glyphSize); + expect(previewUncheckedMarker.glyphSize, editorUncheckedMarker.glyphSize); + expect(previewCheckedMarker.color, editorCheckedMarker.color); + expect(previewUncheckedMarker.color, editorUncheckedMarker.color); + }); + testWidgets('Editor and Preview reuse the same quote shell geometry', ( tester, ) async { @@ -3607,7 +3967,7 @@ void main() { expect(previewStyle?.height, editorStyle?.height); }); - testWidgets('Editor and Preview share heading and thematic-break geometry', ( + testWidgets('Editor and Preview share professional heading hierarchy', ( tester, ) async { tester.view.physicalSize = const Size(1400, 900); @@ -3621,8 +3981,12 @@ void main() { ..value = AppSettings.defaults() .copyWith(documentViewMode: DocumentViewModePreference.editor) .toJson(); - const headings = ['Third', 'Fourth', 'Fifth', 'Sixth']; + const headings = ['First', 'Second', 'Third', 'Fourth', 'Fifth', 'Sixth']; const service = _SearchWorkspaceService(''' +# First + +## Second + ### Third #### Fourth @@ -3631,6 +3995,8 @@ void main() { ###### Sixth +Body paragraph. + Before break. --- @@ -3671,6 +4037,61 @@ After break. editorHeadingRects[heading] = tester.getRect(finder); editorHeadingStyles[heading] = tester.widget(finder).style; } + final editorBody = find.byWidgetPredicate( + (widget) => + widget is TextField && widget.controller?.text == 'Body paragraph.', + ); + expect(editorBody, findsOneWidget); + final editorBodyStyle = tester.widget(editorBody).style; + final editorBodyRect = tester.getRect(editorBody); + final editorColors = BusyMarkSurfaceColors.of( + tester.element(editorHeading(headings.first)), + ); + final editorHeadingSizes = [ + for (final heading in headings) editorHeadingStyles[heading]!.fontSize!, + ]; + for (var index = 0; index < editorHeadingSizes.length - 1; index += 1) { + expect( + editorHeadingSizes[index], + greaterThan(editorHeadingSizes[index + 1]), + reason: + '${headings[index]} should be larger than ' + '${headings[index + 1]}', + ); + } + final editorBodySize = editorBodyStyle!.fontSize!; + for (var index = 0; index < editorHeadingSizes.length; index += 1) { + expect( + editorHeadingSizes[index], + greaterThan(editorBodySize), + reason: '${headings[index]} should be visibly larger than body text', + ); + } + expect( + editorHeadingSizes[3], + lessThanOrEqualTo(editorBodySize * 1.11), + reason: 'H4 should not jump disproportionately above body text', + ); + for (var index = 3; index < editorHeadingSizes.length - 1; index += 1) { + expect( + editorHeadingSizes[index] - editorHeadingSizes[index + 1], + lessThanOrEqualTo(editorBodySize * 0.03), + reason: 'H4-H6 should form a smooth visual progression', + ); + } + for (final heading in headings.skip(3)) { + expect( + editorHeadingRects[heading]!.height, + greaterThanOrEqualTo(editorBodyRect.height - 0.1), + reason: '$heading should not render shorter than body text', + ); + } + for (final style in editorHeadingStyles.values) { + expect(style?.color, editorColors.foreground); + expect(style?.fontWeight, FontWeight.w700); + expect(style?.height, isNotNull); + expect(style?.height, lessThan(BusyMarkTypography.bodyLineHeight)); + } final editorBreak = find.byType(BusyMarkDocumentThematicBreak); expect(editorBreak, findsOneWidget); expect( @@ -3702,11 +4123,13 @@ After break. (widget) => widget is Text && widget.textSpan?.toPlainText() == text, ), ); + final previewHeadingStyles = {}; for (final heading in headings) { final finder = previewHeading(heading); expect(finder, findsOneWidget); final previewRect = tester.getRect(finder); final previewStyle = tester.widget(finder).textSpan?.style; + previewHeadingStyles[heading] = previewStyle; final editorRect = editorHeadingRects[heading]!; final editorStyle = editorHeadingStyles[heading]; expect(previewRect.left, closeTo(editorRect.left, 0.1)); @@ -3716,6 +4139,23 @@ After break. expect(previewStyle?.fontWeight, editorStyle?.fontWeight); expect(previewStyle?.height, editorStyle?.height); } + final previewBody = find.descendant( + of: previewContent, + matching: find.byWidgetPredicate( + (widget) => + widget is Text && + widget.textSpan?.toPlainText() == 'Body paragraph.', + ), + ); + expect(previewBody, findsOneWidget); + final previewBodyStyle = tester.widget(previewBody).textSpan?.style; + expect(previewBodyStyle?.fontSize, editorBodyStyle.fontSize); + for (final heading in headings) { + expect( + previewHeadingStyles[heading]?.color, + editorHeadingStyles[heading]?.color, + ); + } final previewBreak = find.byType(BusyMarkDocumentThematicBreak); expect(previewBreak, findsOneWidget); @@ -4186,7 +4626,12 @@ After break. expect(service.openedPath, startupPath); expect(find.text(l10n.openMarkdownFile), findsNothing); expect(find.textContaining('Basic Markdown'), findsWidgets); - expect(find.byTooltip(startupPath), findsNothing); + expect(find.byTooltip(startupPath), findsOneWidget); + expect( + find.byKey(const ValueKey('workspace-sidebar-outline-file-menu')), + findsOneWidget, + ); + expect(find.byTooltip(l10n.fileActions), findsOneWidget); final primarySidebarLabel = find.descendant( of: find.byKey(const ValueKey('workspace-sidebar-primary-label')), matching: find.byType(Text), @@ -4296,35 +4741,465 @@ Body. }, ); - testWidgets('outline navigates to a renamed unsaved editor heading', ( + testWidgets('outline context menu edits complete heading sections', ( tester, ) async { - tester.view.physicalSize = const Size(1200, 720); + tester.view.physicalSize = const Size(1200, 800); tester.view.devicePixelRatio = 1; addTearDown(() { tester.view.resetPhysicalSize(); tester.view.resetDevicePixelRatio(); }); - const startupPath = '/tmp/unsaved-editor-outline.md'; - final source = [ - '# [Saved heading](https://example.com)', - '', - for (var index = 0; index < 60; index += 1) ...[ - 'Paragraph $index keeps the editor scrollable.', - '', - ], - ].join('\n'); - final service = _SearchWorkspaceService(source); - final settingsStore = _MemorySettingsStore() - ..value = AppSettings.defaults() - .copyWith( - autoSave: false, - validateOnEdit: false, - documentViewMode: DocumentViewModePreference.editor, - ) - .toJson(); - final container = ProviderContainer( + const startupPath = '/tmp/outline-section-actions.md'; + const source = ''' +# Root + +## Parent + +Parent intro. + +### Alpha + +Alpha body. + +#### Alpha child + +Child body. + +### Beta + +Beta body. + +### Gamma + +Gamma body. +'''; + const alphaSection = '''### Alpha + +Alpha body. + +#### Alpha child + +Child body. + +'''; + const betaSection = '''### Beta + +Beta body. + +'''; + String? clipboardText; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.setData') { + final arguments = call.arguments as Map; + clipboardText = arguments['text'] as String?; + } + return null; + }, + ); + addTearDown(() { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ); + }); + + final settingsStore = _MemorySettingsStore() + ..value = AppSettings.defaults() + .copyWith( + autoSave: false, + validateOnEdit: false, + documentViewMode: DocumentViewModePreference.source, + ) + .toJson(); + const service = _SearchWorkspaceService(source); + final container = ProviderContainer( + overrides: [ + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue(settingsStore), + workspaceServiceProvider.overrideWithValue(service), + startupPathProvider.overrideWithValue(startupPath), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + final outlineTree = find.byKey( + const ValueKey('workspace-sidebar-outline-tree'), + ); + for (var index = 0; index < 30; index += 1) { + await tester.pump(const Duration(milliseconds: 100)); + if (find + .descendant(of: outlineTree, matching: find.text('Alpha')) + .evaluate() + .isNotEmpty) { + break; + } + } + expect(outlineTree, findsOneWidget); + + Finder headingRow(String heading) => + find.descendant(of: outlineTree, matching: find.text(heading)); + Finder menuItem(String label) => find.byWidgetPredicate( + (widget) => + widget is BusyMarkPopupMenuItem && widget.label == label, + ); + BusyMarkPopupMenuItem popupItem(String label) => + tester.widget>(menuItem(label)); + + Future openMenu(String heading) async { + await tester.tap(headingRow(heading), buttons: kSecondaryButton); + await tester.pumpAndSettle(); + } + + Future expectSource(String expected) async { + for (var index = 0; index < 30; index += 1) { + await tester.pump(const Duration(milliseconds: 100)); + if (container.read(workspaceControllerProvider).activeText == + expected) { + break; + } + } + expect(container.read(workspaceControllerProvider).activeText, expected); + } + + Future resetSource() async { + container + .read(workspaceControllerProvider.notifier) + .updateActiveText(source, sourceFilePath: startupPath); + await expectSource(source); + for (var index = 0; index < 30; index += 1) { + await tester.pump(const Duration(milliseconds: 100)); + if (headingRow('Beta').evaluate().isNotEmpty) { + break; + } + } + } + + await openMenu('Alpha'); + for (final label in [ + l10n.copy, + l10n.cut, + l10n.promoteHeading, + l10n.demoteHeading, + l10n.moveSectionUp, + l10n.moveSectionDown, + l10n.delete, + ]) { + expect(menuItem(label), findsOneWidget); + } + expect(popupItem(l10n.promoteHeading).enabled, isTrue); + expect(popupItem(l10n.demoteHeading).enabled, isTrue); + expect(popupItem(l10n.moveSectionUp).enabled, isFalse); + expect(popupItem(l10n.moveSectionDown).enabled, isTrue); + + await tester.tap(find.text(l10n.copy)); + await tester.pumpAndSettle(); + expect(clipboardText, alphaSection); + expect(container.read(workspaceControllerProvider).activeText, source); + + await openMenu('Alpha'); + await tester.tap(find.text(l10n.promoteHeading)); + final promoted = source + .replaceFirst('### Alpha', '## Alpha') + .replaceFirst('#### Alpha child', '### Alpha child'); + await expectSource(promoted); + + await resetSource(); + await openMenu('Alpha'); + await tester.tap(find.text(l10n.demoteHeading)); + final demoted = source + .replaceFirst('### Alpha', '#### Alpha') + .replaceFirst('#### Alpha child', '##### Alpha child'); + await expectSource(demoted); + + await resetSource(); + await openMenu('Alpha'); + await tester.tap(find.text(l10n.moveSectionDown)); + final movedDown = source.replaceFirst( + '$alphaSection$betaSection', + '$betaSection$alphaSection', + ); + await expectSource(movedDown); + + await resetSource(); + await openMenu('Beta'); + expect(popupItem(l10n.moveSectionUp).enabled, isTrue); + expect(popupItem(l10n.moveSectionDown).enabled, isTrue); + await tester.tap(find.text(l10n.cut)); + await expectSource(source.replaceFirst(betaSection, '')); + expect(clipboardText, betaSection); + + await resetSource(); + await openMenu('Beta'); + await tester.tap(find.text(l10n.delete)); + await tester.pumpAndSettle(); + expect(find.text(l10n.confirmDeleteSectionTitle), findsOneWidget); + expect(find.text(l10n.confirmDeleteSectionMessage('Beta')), findsOneWidget); + await tester.tap(find.widgetWithText(BusyMarkDialogButton, l10n.delete)); + await expectSource(source.replaceFirst(betaSection, '')); + }); + + testWidgets('outline highlights the heading at the document viewport', ( + tester, + ) async { + tester.view.physicalSize = const Size(1200, 800); + tester.view.devicePixelRatio = 1; + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + final source = [ + '# First section', + '', + for (var index = 0; index < 40; index += 1) ...[ + 'First section paragraph $index.', + '', + ], + '## Second section', + '', + for (var index = 0; index < 40; index += 1) ...[ + 'Second section paragraph $index.', + '', + ], + '## Third section', + '', + for (var index = 0; index < 40; index += 1) ...[ + 'Third section paragraph $index.', + '', + ], + ].join('\n'); + const startupPath = '/tmp/outline-scroll-position.md'; + final settingsStore = _MemorySettingsStore() + ..value = AppSettings.defaults() + .copyWith( + autoSave: false, + documentViewMode: DocumentViewModePreference.preview, + ) + .toJson(); + final container = ProviderContainer( + overrides: [ + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue(settingsStore), + workspaceServiceProvider.overrideWithValue( + _SearchWorkspaceService(source), + ), + startupPathProvider.overrideWithValue(startupPath), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + final previewScroll = find.byKey(const ValueKey('preview-document-scroll')); + for (var index = 0; index < 30; index += 1) { + await tester.pump(const Duration(milliseconds: 100)); + if (previewScroll.evaluate().isNotEmpty && + find + .byKey(const ValueKey('workspace-sidebar-outline-row-2')) + .evaluate() + .isNotEmpty) { + break; + } + } + + Finder outlineRow(int index) => + find.byKey(ValueKey('workspace-sidebar-outline-row-$index')); + Color outlineRowColor(int index) { + final material = find.descendant( + of: outlineRow(index), + matching: find.byType(Material), + ); + expect(material, findsOneWidget); + return tester.widget(material).color ?? Colors.transparent; + } + + void expectSelectedOutlineRow(int selectedIndex) { + final selectedColor = busyMarkSelectedBackground( + tester.element(outlineRow(selectedIndex)), + ); + for (var index = 0; index < 3; index += 1) { + expect( + outlineRowColor(index), + index == selectedIndex + ? selectedColor + : BusyMarkLinuxPalette.transparent, + reason: 'Outline row $index', + ); + } + } + + final firstOutlineRowTop = tester.getTopLeft(outlineRow(0)).dy; + Finder firstOutlineToggle() => find.descendant( + of: outlineRow(0), + matching: find.byIcon( + BusyMarkGlyphs.collapsedTreeArrowFor(TextDirection.ltr), + ), + ); + expect(firstOutlineToggle(), findsOneWidget); + await tester.tap(firstOutlineToggle()); + await tester.pump(); + await tester.pump(BusyMarkMotion.sidebarExpand); + await tester.pump(); + expect( + tester.getTopLeft(outlineRow(0)).dy, + closeTo(firstOutlineRowTop, 1), + reason: 'Collapsing the Outline must preserve its top anchor.', + ); + await tester.tap(firstOutlineToggle()); + await tester.pump(); + await tester.pump(BusyMarkMotion.sidebarExpand); + await tester.pump(); + + final state = container.read(workspaceControllerProvider); + final outline = state.preview!.outline; + expect(outline.map((heading) => heading.text), [ + 'First section', + 'Second section', + 'Third section', + ]); + final secondPreviewIndex = state.preview!.blocks.indexWhere( + (block) => block.attributes['id'] == outline[1].id, + ); + expect(secondPreviewIndex, isNonNegative); + + expectSelectedOutlineRow(0); + tester + .widget(previewScroll) + .itemScrollController! + .jumpTo(index: secondPreviewIndex); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expectSelectedOutlineRow(1); + + final thirdPreviewIndex = state.preview!.blocks.indexWhere( + (block) => block.attributes['id'] == outline[2].id, + ); + expect(thirdPreviewIndex, isNonNegative); + await tester.tap(outlineRow(2)); + await tester.pump(); + await tester.pump(BusyMarkMotion.scroll); + await tester.pump(); + await tester.pump(BusyMarkMotion.scroll); + await tester.pump(); + final previewPositions = tester + .widget(previewScroll) + .itemPositionsNotifier! + .itemPositions + .value; + final thirdPreviewPosition = previewPositions.singleWhere( + (position) => position.index == thirdPreviewIndex, + ); + expect( + thirdPreviewPosition.itemLeadingEdge, + closeTo(0, 0.005), + reason: 'Preview Outline navigation must use the tracking anchor.', + ); + expectSelectedOutlineRow(2); + + await container + .read(appSettingsControllerProvider.notifier) + .setDocumentViewMode(DocumentViewModePreference.editor); + await tester.pump(const Duration(milliseconds: 100)); + final editorScroll = find.byKey(const ValueKey('wysiwyg-document-scroll')); + expect(editorScroll, findsOneWidget); + final editorDocument = container + .read(workspaceControllerProvider) + .workspace! + .markdown! + .busyDocument; + final thirdEditorIndex = editorDocument.blocks.indexWhere( + (block) => block.attributes['id'] == outline[2].id, + ); + expect(thirdEditorIndex, isNonNegative); + tester + .widget(editorScroll) + .itemScrollController! + .jumpTo(index: thirdEditorIndex); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expectSelectedOutlineRow(2); + + final secondEditorIndex = editorDocument.blocks.indexWhere( + (block) => block.attributes['id'] == outline[1].id, + ); + expect(secondEditorIndex, isNonNegative); + await tester.tap(outlineRow(1)); + await tester.pump(); + await tester.pump(BusyMarkMotion.scroll); + await tester.pump(); + final editorPositions = tester + .widget(editorScroll) + .itemPositionsNotifier! + .itemPositions + .value; + final secondEditorPosition = editorPositions.singleWhere( + (position) => position.index == secondEditorIndex, + ); + expect( + secondEditorPosition.itemLeadingEdge, + closeTo(0, 0.005), + reason: 'Outline navigation must use the viewport tracking anchor.', + ); + expectSelectedOutlineRow(1); + + await container + .read(appSettingsControllerProvider.notifier) + .setDocumentViewMode(DocumentViewModePreference.source); + await tester.pump(const Duration(milliseconds: 100)); + final sourceEditor = find.byType(BusyMarkSourceEditor); + expect(sourceEditor, findsOneWidget); + tester + .state(sourceEditor) + .scrollToLine(outline[1].sourceStartLine!); + await tester.pump(); + await tester.pump(BusyMarkMotion.scroll); + await tester.pump(const Duration(milliseconds: 100)); + expectSelectedOutlineRow(1); + }); + + testWidgets('outline navigates to a renamed unsaved editor heading', ( + tester, + ) async { + tester.view.physicalSize = const Size(1200, 720); + tester.view.devicePixelRatio = 1; + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + const startupPath = '/tmp/unsaved-editor-outline.md'; + final source = [ + '# [Saved heading](https://example.com)', + '', + for (var index = 0; index < 60; index += 1) ...[ + 'Paragraph $index keeps the editor scrollable.', + '', + ], + ].join('\n'); + final service = _SearchWorkspaceService(source); + final settingsStore = _MemorySettingsStore() + ..value = AppSettings.defaults() + .copyWith( + autoSave: false, + validateOnEdit: false, + documentViewMode: DocumentViewModePreference.editor, + ) + .toJson(); + final container = ProviderContainer( overrides: [ linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), @@ -4953,10 +5828,54 @@ Draft paragraph. ), ), ); - await tester.pump(); + expect(find.byType(SvgPicture), findsNothing); + await _pumpUntilFound(tester, find.byType(SvgPicture)); + + expect(find.textContaining('logo.svg'), findsNothing); expect(find.byType(SvgPicture), findsOneWidget); - expect(find.text('logo.svg'), findsNothing); + } finally { + temp.deleteSync(recursive: true); + } + }); + + testWidgets('shared Markdown image renderer rejects oversized local SVGs', ( + tester, + ) async { + final temp = Directory.systemTemp.createTempSync( + 'busymark_preview_svg_oversized_', + ); + try { + final padding = ''.padRight(1024 * 1024, 'x'); + File('${temp.path}/oversized.svg').writeAsStringSync( + '' + '' + '', + ); + final markdown = File('${temp.path}/image.md') + ..writeAsStringSync('# Image\n\n![Large](oversized.svg)\n'); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: MarkdownImageView( + source: 'oversized.svg', + alt: 'Large', + activeFilePath: markdown.path, + workspaceRoot: temp.path, + writersideRoot: null, + imagesDir: 'images', + allowRemoteImages: true, + ), + ), + ), + ); + await _pumpUntilFound(tester, find.textContaining('oversized.svg')); + + expect(find.byType(SvgPicture), findsNothing); + expect(find.textContaining('oversized.svg'), findsOneWidget); } finally { temp.deleteSync(recursive: true); } @@ -5003,8 +5922,7 @@ Draft paragraph. ), ), ); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 100)); + await _pumpUntilFound(tester, find.byType(SvgPicture)); expect(tester.takeException(), isNull); expect(find.byType(SvgPicture), findsOneWidget); @@ -5087,7 +6005,7 @@ Draft paragraph. ), ), ); - await tester.pump(); + await _pumpUntilFound(tester, find.byType(SvgPicture)); expect(tester.takeException(), isNull); expect(find.byType(SvgPicture), findsOneWidget); @@ -5155,6 +6073,74 @@ Draft paragraph. expect(find.byType(TextField).evaluate().length, initialTextFields); }); + testWidgets( + 'workspace search awaits non-active file reads without blocking builds', + (tester) async { + final service = _DeferredWorkspaceSearchService(); + final settingsStore = _MemorySettingsStore() + ..value = AppSettings.defaults() + .copyWith(documentViewMode: DocumentViewModePreference.preview) + .toJson(); + final container = ProviderContainer( + overrides: [ + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue(settingsStore), + workspaceServiceProvider.overrideWithValue(service), + startupPathProvider.overrideWithValue( + _DeferredWorkspaceSearchService.activePath, + ), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + for (var i = 0; i < 20; i += 1) { + await tester.pump(const Duration(milliseconds: 100)); + if (find.text(l10n.workspaceKindSingleMarkdown).evaluate().isNotEmpty) { + break; + } + } + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.keyF); + await tester.sendKeyUpEvent(LogicalKeyboardKey.keyF); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + await tester.enterText(find.byType(TextField), 'needle'); + await tester.pump(const Duration(milliseconds: 150)); + + expect(service.searchReadCount, 1); + expect( + find.byKey(const ValueKey('workspace-search-progress')), + findsOneWidget, + ); + + service.completeSearchRead('Delayed needle result'); + await tester.pump(); + await tester.pump(); + + expect( + find.byKey(const ValueKey('workspace-search-progress')), + findsNothing, + ); + expect(find.text('Delayed needle result'), findsOneWidget); + + final settings = container.read(appSettingsControllerProvider); + await container + .read(appSettingsControllerProvider.notifier) + .setWordWrap(!settings.wordWrap); + await tester.pump(); + + expect(service.searchReadCount, 1); + expect(find.text('Delayed needle result'), findsOneWidget); + }, + ); + testWidgets('preview search result clicks move preview scroll repeatedly', ( tester, ) async { @@ -5217,6 +6203,7 @@ Draft paragraph. await tester.pump(); await tester.enterText(find.byType(TextField), 'needle'); + await tester.pump(const Duration(milliseconds: 150)); await tester.pump(); expect( @@ -5374,6 +6361,7 @@ Draft paragraph. await tester.pump(); await tester.enterText(find.byType(TextField), 'code needle'); + await tester.pump(const Duration(milliseconds: 150)); await tester.pump(); await _tapLeftmostText(tester, 'second code needle target'); @@ -5437,6 +6425,7 @@ Draft paragraph. await tester.pump(); await tester.enterText(find.byType(TextField), 'ac'); + await tester.pump(const Duration(milliseconds: 150)); await tester.pump(); await _tapLeftmostText(tester, target); @@ -5450,7 +6439,7 @@ Draft paragraph. expect(rect.bottom, lessThan(800), reason: 'rect=$rect offset=$offset'); }); - testWidgets('Editor and Preview share list indentation and run spacing', ( + testWidgets('Editor and Preview share ordered list styling and geometry', ( tester, ) async { tester.view.physicalSize = const Size(1200, 800); @@ -5466,8 +6455,8 @@ Draft paragraph. .toJson(); final service = _SearchWorkspaceService( '# Title\n\n' - '- First item\n' - '- Second item\n' + '1. First item\n' + '2. Second item\n' '\n' 'After list paragraph.\n', ); @@ -5500,9 +6489,18 @@ Draft paragraph. final editorMarkers = find.byType(BusyMarkDocumentListMarker); expect(editorMarkers, findsNWidgets(2)); final editorMarkerRect = tester.getRect(editorMarkers.first); + final editorMarkerText = tester.widget( + find.descendant(of: editorMarkers.first, matching: find.text('1.')), + ); + final editorMarkerColors = BusyMarkSurfaceColors.of( + tester.element(editorMarkers.first), + ); final editorItemGap = editorSecond.top - editorFirst.bottom; final editorAfterListGap = editorAfter.top - editorSecond.bottom; + expect(editorMarkerRect.width, greaterThanOrEqualTo(24)); + expect(editorMarkerText.style?.color, editorMarkerColors.foreground); + expect(editorMarkerText.style?.fontWeight, FontWeight.w600); expect(editorAfterListGap, greaterThan(editorItemGap + BusyMarkSpacing.xs)); await container @@ -5516,6 +6514,9 @@ Draft paragraph. final previewMarkers = find.byType(BusyMarkDocumentListMarker); expect(previewMarkers, findsNWidgets(2)); final previewMarkerRect = tester.getRect(previewMarkers.first); + final previewMarkerText = tester.widget( + find.descendant(of: previewMarkers.first, matching: find.text('1.')), + ); final previewItemGap = previewSecond.top - previewFirst.bottom; final previewAfterListGap = previewAfter.top - previewSecond.bottom; @@ -5524,6 +6525,7 @@ Draft paragraph. expect(previewAfter.left, closeTo(editorAfter.left, 0.1)); expect(previewMarkerRect.left, closeTo(editorMarkerRect.left, 0.1)); expect(previewMarkerRect.width, editorMarkerRect.width); + expect(previewMarkerText.style, editorMarkerText.style); expect(previewItemGap, closeTo(editorItemGap, 0.1)); expect(previewAfterListGap, closeTo(editorAfterListGap, 0.1)); }); @@ -5658,6 +6660,7 @@ Draft paragraph. await tester.pump(); await tester.enterText(find.byType(TextField), 'ac'); + await tester.pump(const Duration(milliseconds: 150)); await tester.pump(); await _tapLeftmostText(tester, target); @@ -5785,6 +6788,81 @@ Draft paragraph. }); } +RenderEditable? _findRenderEditable(RenderObject root) { + if (root is RenderEditable) { + return root; + } + RenderEditable? result; + root.visitChildren((child) { + result ??= _findRenderEditable(child); + }); + return result; +} + +List _visualLineEndOffsets( + String text, + List Function(TextSelection selection) boxesForSelection, +) { + final ends = []; + double? previousTop; + for (var offset = 0; offset < text.length; offset += 1) { + final boxes = boxesForSelection( + TextSelection(baseOffset: offset, extentOffset: offset + 1), + ); + if (boxes.isEmpty) { + continue; + } + final top = boxes.first.top; + final priorTop = previousTop; + if (priorTop != null && (top - priorTop).abs() > 0.01) { + ends.add(offset); + } + previousTop = top; + } + ends.add(text.length); + return ends; +} + +({ + Finder finder, + Size size, + Color? color, + double? glyphSize, + ValueChanged? onTaskChanged, +}) +_taskMarkerVisual(WidgetTester tester, {required bool checked}) { + final markerFinder = find.byWidgetPredicate( + (widget) => widget is BusyMarkDocumentListMarker && widget.task == checked, + ); + expect(markerFinder, findsOneWidget); + final marker = tester.widget(markerFinder); + final iconFinder = find.descendant( + of: markerFinder, + matching: find.byIcon( + checked ? BusyMarkGlyphs.checkedBox : BusyMarkGlyphs.task, + ), + ); + expect(iconFinder, findsOneWidget); + final icon = tester.widget(iconFinder); + final iconTheme = IconTheme.of(tester.element(iconFinder)); + return ( + finder: markerFinder, + size: tester.getSize(markerFinder), + color: icon.color ?? iconTheme.color, + glyphSize: icon.size ?? iconTheme.size, + onTaskChanged: marker.onTaskChanged, + ); +} + +Future _pumpUntilFound(WidgetTester tester, Finder finder) async { + for (var i = 0; i < 20 && finder.evaluate().isEmpty; i += 1) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 25)), + ); + await tester.pump(); + } +} + double _largestScrollableOffset(WidgetTester tester) { var maxExtent = double.negativeInfinity; var pixels = 0.0; @@ -6038,6 +7116,7 @@ class _MutableWorkspaceController extends WorkspaceController { variables: module.variables, categories: module.categories, diagnostics: module.diagnostics, + validatedImageDirs: module.validatedImageDirs, ); state = state.copyWith( workspace: workspace.copyWith(writersideModule: updatedModule), @@ -6348,6 +7427,76 @@ class _SearchWorkspaceService extends WorkspaceService { } } +class _DeferredWorkspaceSearchService extends WorkspaceService { + _DeferredWorkspaceSearchService(); + + static const activePath = '/tmp/busymark-search-active.md'; + static const secondaryPath = '/tmp/busymark-search-secondary.md'; + static const _activeSource = '# Active document\n'; + + final Completer _searchRead = Completer(); + int searchReadCount = 0; + + @override + Future openPath(String path) async { + final markdown = markdownParser.parse( + filePath: activePath, + source: _activeSource, + ); + return Workspace( + id: '/tmp/busymark-search-workspace', + rootPath: '/tmp', + kind: WorkspaceKind.singleMarkdown, + openedAt: DateTime(2026), + activeFilePath: activePath, + activeFileModifiedAt: DateTime(2026), + files: [ + DocumentFile( + absolutePath: activePath, + relativePath: 'active.md', + kind: DocumentKind.markdown, + size: _activeSource.length, + lastModified: DateTime(2026), + ), + DocumentFile( + absolutePath: secondaryPath, + relativePath: 'secondary.md', + kind: DocumentKind.markdown, + size: 64, + lastModified: DateTime(2026), + ), + ], + diagnostics: markdown.diagnostics, + markdown: markdown, + ); + } + + @override + Future loadText(String path) { + if (path == secondaryPath) { + searchReadCount += 1; + return _searchRead.future; + } + return Future.value(_activeSource); + } + + @override + Future loadTextWithSnapshot(String path) async { + return WorkspaceFileLoad( + text: _activeSource, + snapshot: WorkspaceFileSnapshot( + modifiedAt: DateTime(2026), + size: _activeSource.length, + contentHash: 'deferred-search-active', + ), + ); + } + + void completeSearchRead(String source) { + _searchRead.complete(source); + } +} + class _PresetGitController extends GitController { _PresetGitController(this.initialState); @@ -6606,8 +7755,10 @@ class _MemorySettingsStore implements LocalSettingsStore { class _FakeNativeWindowController implements NativeWindowController { final preventCloseValues = []; + final fullScreenValues = []; final listeners = []; var closeCount = 0; + var fullScreen = false; @override Future setPreventClose(bool value) async { @@ -6619,6 +7770,15 @@ class _FakeNativeWindowController implements NativeWindowController { closeCount++; } + @override + Future isFullScreen() async => fullScreen; + + @override + Future setFullScreen(bool value) async { + fullScreen = value; + fullScreenValues.add(value); + } + @override void addListener(WindowListener listener) { listeners.add(listener); diff --git a/test/src/busymark_document_test.dart b/test/src/busymark_document_test.dart index b6b45cd..7bad73b 100644 --- a/test/src/busymark_document_test.dart +++ b/test/src/busymark_document_test.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'dart:ui' show CheckedState; import 'package:busymark/l10n/generated/app_localizations.dart'; import 'package:busymark/l10n/generated/app_localizations_de.dart'; @@ -13,6 +14,8 @@ import 'package:busymark/src/app/busymark_glyphs.dart'; import 'package:busymark/src/app/busymark_shortcuts.dart'; import 'package:busymark/src/editor/document_callout.dart'; import 'package:busymark/src/editor/document_layout.dart'; +import 'package:busymark/src/editor/document_list_marker.dart'; +import 'package:busymark/src/editor/document_text_geometry.dart'; import 'package:busymark/src/editor/markdown_image_view.dart'; import 'package:busymark/src/editor/wysiwyg/wysiwyg_block_widgets.dart'; import 'package:busymark/src/editor/wysiwyg/wysiwyg_commands.dart'; @@ -2837,6 +2840,17 @@ void main() {} ); await tester.pump(); + final paragraphField = tester.widget( + find.byType(TextField).at(1), + ); + expect( + paragraphField.selectionHeightStyle, + BusyMarkDocumentTextGeometry.selectionHeightStyle, + ); + expect( + paragraphField.selectionWidthStyle, + BusyMarkDocumentTextGeometry.selectionWidthStyle, + ); final textFieldRender = tester.renderObject( find.byType(TextField).at(1), ); @@ -2860,6 +2874,14 @@ void main() {} final painter = customPaint.painter! as dynamic; expect(painter.selectionRange.start, 0); expect(painter.selectionRange.end, firstParagraph.length); + final painterContext = tester.element(painterFinder.at(1)); + expect( + painter.color, + DefaultSelectionStyle.of(painterContext).selectionColor ?? + Theme.of(painterContext).colorScheme.primary.withValues( + alpha: BusyMarkDocumentTextGeometry.fallbackSelectionAlpha, + ), + ); final paintBox = tester.renderObject(painterFinder.at(1)); final painterText = @@ -2879,20 +2901,19 @@ void main() {} baseOffset: exampleStart, extentOffset: exampleStart + 'example.'.length, ), + boxHeightStyle: BusyMarkDocumentTextGeometry.selectionHeightStyle, + boxWidthStyle: BusyMarkDocumentTextGeometry.selectionWidthStyle, ); painterText.dispose(); expect(highlightBoxes, hasLength(exampleBoxes.length)); - final lineTolerance = - (exampleBoxes.last.bottom - exampleBoxes.last.top) / 3; - expect( - (highlightBoxes.last.top - exampleBoxes.last.top).abs(), - lessThan(lineTolerance), - ); - expect( - (highlightBoxes.last.bottom - exampleBoxes.last.bottom).abs(), - lessThan(lineTolerance), - ); + for (var index = 0; index < exampleBoxes.length; index += 1) { + expect(highlightBoxes[index].top, closeTo(exampleBoxes[index].top, 0.01)); + expect( + highlightBoxes[index].bottom, + closeTo(exampleBoxes[index].bottom, 0.01), + ); + } }); testWidgets('WYSIWYG Ctrl+X cuts block selection', (tester) async { @@ -5154,6 +5175,62 @@ void main() {} expect(find.byType(TextField).evaluate().length, lessThan(80)); }); + testWidgets('WYSIWYG task checkbox toggles directly when clicked', ( + tester, + ) async { + final semanticsHandle = tester.ensureSemantics(); + final parsed = parser.parse( + filePath: 'topic.md', + source: '- [ ] Clickable task\n', + ); + var markdown = parsed.source; + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SizedBox( + width: 900, + height: 640, + child: BusyMarkWysiwygEditor( + document: parsed.busyDocument, + onSourceChanged: (_, value) => markdown = value, + ), + ), + ), + ), + ); + await tester.pump(); + + final marker = find.byType(BusyMarkDocumentListMarker); + expect(marker, findsOneWidget); + expect(tester.widget(marker).task, isFalse); + final semanticCheckbox = find.bySemanticsLabel('Toggle task checked'); + expect(semanticCheckbox, findsOneWidget); + expect( + tester.getSemantics(semanticCheckbox).flagsCollection.isChecked, + CheckedState.isFalse, + ); + + await tester.tap(marker); + await tester.pump(); + + expect(markdown, '- [x] Clickable task\n'); + expect(tester.widget(marker).task, isTrue); + expect( + tester.getSemantics(semanticCheckbox).flagsCollection.isChecked, + CheckedState.isTrue, + ); + + await tester.tap(marker); + await tester.pump(); + + expect(markdown, '- [ ] Clickable task\n'); + expect(tester.widget(marker).task, isFalse); + semanticsHandle.dispose(); + }); + test('WYSIWYG block commands serialize headings and lists', () { final parsed = parser.parse(filePath: 'topic.md', source: 'Title\n'); final controller = BusyMarkWysiwygDocumentController( @@ -5541,10 +5618,28 @@ void main() {} ); await tester.pump(); - expect(find.byType(Table), findsOneWidget); + final tableFinder = find.byType(Table); + final deleteTableFinder = find.byTooltip('Delete table'); + expect(tableFinder, findsOneWidget); expect(find.byType(TextField), findsNWidgets(5)); expect(find.text('| Header 1 | Header 2 |'), findsNothing); - expect(find.byTooltip('Delete table'), findsOneWidget); + expect(deleteTableFinder, findsOneWidget); + expect( + find.descendant(of: tableFinder, matching: deleteTableFinder), + findsOneWidget, + ); + final framedTableAncestors = []; + tester.element(tableFinder).visitAncestorElements((element) { + if (element.widget is BusyMarkWysiwygBlockField) { + return false; + } + if (element.widget case final DecoratedBox decoratedBox) { + framedTableAncestors.add(decoratedBox); + } + return true; + }); + expect(framedTableAncestors, isEmpty); + expect(busyMarkWysiwygContentPadding(widgetTable), EdgeInsets.zero); expect(find.byTooltip('Column 1'), findsOneWidget); expect(find.byTooltip('Column 2'), findsOneWidget); expect(find.byTooltip('Row 1'), findsOneWidget); @@ -5571,6 +5666,12 @@ void main() {} '| --- | --- |\n' '| Alice | Cell |\n', ); + + await tester.tap(deleteTableFinder); + await tester.pump(); + + expect(find.byType(Table), findsNothing); + expect(markdown, 'Intro\n'); }); test('WYSIWYG list indent outdent and task toggle commands serialize', () { @@ -5606,6 +5707,120 @@ void main() {} taskController.toggleTaskChecked([taskId]); expect(taskController.markdown, '- [x] Todo\n'); }); + + test('WYSIWYG ordered indentation starts a nested list at one', () { + final parsed = parser.parse( + filePath: 'topic.md', + source: '1. One\n2. Two\n3. Three\n4. Child\n', + ); + final controller = BusyMarkWysiwygDocumentController( + document: parsed.busyDocument, + ); + final childId = controller.document.blocks.last.id; + + expect(controller.indentListItems([childId]), isTrue); + + final nested = controller.document.blocks.last.children.single; + expect(nested.attributes['marker'], '1.'); + expect(controller.markdown, '1. One\n\n2. Two\n\n3. Three\n 1. Child\n'); + }); + + test('WYSIWYG ordered indentation preserves a top-level start value', () { + final parsed = parser.parse( + filePath: 'topic.md', + source: '5. Parent\n6. Child\n', + ); + final controller = BusyMarkWysiwygDocumentController( + document: parsed.busyDocument, + ); + + expect( + controller.indentListItems([controller.document.blocks.last.id]), + isTrue, + ); + + final parent = controller.document.blocks.single; + expect(parent.attributes['marker'], '5.'); + expect(parent.children.single.attributes['marker'], '1.'); + }); + + test('WYSIWYG ordered indentation numbers multiple nested siblings', () { + final parsed = parser.parse( + filePath: 'topic.md', + source: '1. Parent\n2. First child\n3. Second child\n', + ); + final controller = BusyMarkWysiwygDocumentController( + document: parsed.busyDocument, + ); + final childIds = controller.document.blocks + .skip(1) + .map((block) => block.id) + .toList(); + + expect(controller.indentListItems(childIds), isTrue); + + expect( + controller.document.blocks.single.children.map( + (block) => block.attributes['marker'], + ), + ['1.', '2.'], + ); + }); + + test('WYSIWYG ordered outdent continues the parent list numbering', () { + final parsed = parser.parse( + filePath: 'topic.md', + source: '1. One\n2. Parent\n 1. Child\n', + ); + final controller = BusyMarkWysiwygDocumentController( + document: parsed.busyDocument, + ); + final childId = controller.document.blocks.last.children.single.id; + + expect(controller.outdentListItems([childId]), isTrue); + + expect( + controller.document.blocks.map((block) => block.attributes['marker']), + ['1.', '2.', '3.'], + ); + }); + + test('WYSIWYG converts an unordered item to a valid ordered marker', () { + final parsed = parser.parse(filePath: 'topic.md', source: '- Item\n'); + final controller = BusyMarkWysiwygDocumentController( + document: parsed.busyDocument, + ); + final blockId = controller.document.blocks.single.id; + + controller.applyBlockCommand(blockId, BusyWysiwygBlockCommand.orderedList); + + expect(controller.document.blocks.single.attributes['marker'], '1.'); + expect(controller.markdown, '1. Item\n'); + }); + + test('WYSIWYG converts selected nested items at each list depth', () { + final parsed = parser.parse( + filePath: 'topic.md', + source: '- Parent\n - Child\n', + ); + final controller = BusyMarkWysiwygDocumentController( + document: parsed.busyDocument, + ); + final parent = controller.document.blocks.single; + final child = parent.children.single; + + controller.applyBlockCommandToBlocks([ + parent.id, + child.id, + ], BusyWysiwygBlockCommand.orderedList); + + final convertedParent = controller.document.blocks.single; + expect(convertedParent.kind, BusyBlockKind.orderedListItem); + expect(convertedParent.attributes['marker'], '1.'); + expect(convertedParent.children.single.kind, BusyBlockKind.orderedListItem); + expect(convertedParent.children.single.attributes['marker'], '1.'); + expect(controller.markdown, '1. Parent\n 1. Child\n'); + }); } TextStyle? _spanStyleForText(InlineSpan span, String text) { diff --git a/test/src/editor_ui_primitives_audit_test.dart b/test/src/editor_ui_primitives_audit_test.dart index 1b59db0..437b239 100644 --- a/test/src/editor_ui_primitives_audit_test.dart +++ b/test/src/editor_ui_primitives_audit_test.dart @@ -44,7 +44,7 @@ void main() { r'class _RenderedHtmlBlock[\s\S]*?class _RenderedHtmlBlocks', ).firstMatch(widgets)!.group(0)!; final tableEditor = RegExp( - r'class _TableBlockEditor[\s\S]*?class _TableCornerCell', + r'class _TableBlockEditor[\s\S]*?class _TableColumnControlCell', ).firstMatch(widgets)!.group(0)!; final tableMenu = RegExp( r'class _TableControlMenuButton[\s\S]*?class _TableCellEditor', diff --git a/test/src/git/git_cli_gateway_integration_test.dart b/test/src/git/git_cli_gateway_integration_test.dart index 733d6fb..d02ab80 100644 --- a/test/src/git/git_cli_gateway_integration_test.dart +++ b/test/src/git/git_cli_gateway_integration_test.dart @@ -127,6 +127,39 @@ void main() { expect(switched?.detachedHeadCommit, isNull); }); + test('diff and commit details preserve C-quoted UTF-8 paths', () async { + if (!await _gitAvailable()) { + markTestSkipped('Git executable is unavailable.'); + return; + } + final root = await _createRepository('busymark-git-quoted-path-'); + await _git(root.path, ['config', 'core.quotePath', 'true']); + const repoRelativePath = 'docs/Über "guide".md'; + final document = File(p.join(root.path, repoRelativePath)); + await document.create(recursive: true); + await document.writeAsString('Initial.\n'); + await _git(root.path, ['add', '--', repoRelativePath]); + await _git(root.path, ['commit', '-m', 'Add quoted path']); + await document.writeAsString('Changed.\n'); + + const gateway = GitCliGateway(); + final info = await gateway.detectRepository(root.path); + expect(info, isNotNull); + + final workingTreeDiff = await gateway.diffAll(info!, staged: false); + expect(workingTreeDiff.files.single.oldPath, repoRelativePath); + expect(workingTreeDiff.files.single.newPath, repoRelativePath); + + await _git(root.path, ['add', '--', repoRelativePath]); + await _git(root.path, ['commit', '-m', 'Update quoted path']); + final commit = (await gateway.history(info, limit: 1)).single; + final details = await gateway.commitDetails(info, commit.fullHash); + + expect(details.changedFiles.single.oldPath, repoRelativePath); + expect(details.changedFiles.single.newPath, repoRelativePath); + expect(details.fileSnapshots[repoRelativePath], 'Changed.\n'); + }); + test( 'pushSetUpstream does not broaden an option-like branch to --all', () async { diff --git a/test/src/git/git_controller_test.dart b/test/src/git/git_controller_test.dart index 5bc3003..3214d11 100644 --- a/test/src/git/git_controller_test.dart +++ b/test/src/git/git_controller_test.dart @@ -370,6 +370,65 @@ void main() { expect(state.statusSnapshot?.repositoryInfo.rootPath, '/other'); }); + test( + 'cancels set-upstream push when the workspace changes during remote lookup', + () async { + final gateway = _DeferredSetUpstreamPushGitGateway(); + final container = _container(gateway); + final controller = container.read(gitControllerProvider.notifier); + + controller.attachWorkspace(_workspace()); + await controller.refresh(); + expect( + container.read(gitControllerProvider).repositoryInfo?.rootPath, + '/repo', + ); + + final push = controller.push(allowSetUpstream: true); + await gateway.remotesStarted.future; + expect(gateway.remotesRepository?.rootPath, '/repo'); + + controller.attachWorkspace(_workspace(id: '/other', rootPath: '/other')); + await controller.refresh(); + expect( + container.read(gitControllerProvider).repositoryInfo?.rootPath, + '/other', + ); + + gateway.remotesResult.complete(const ['origin']); + await push; + + expect(gateway.setUpstreamPushes, isEmpty); + expect( + container.read(gitControllerProvider).repositoryInfo?.rootPath, + '/other', + ); + }, + ); + + test('ignores stale history after a workspace switch', () async { + final gateway = _DeferredHistoryGitGateway(); + final container = _container(gateway); + final controller = container.read(gitControllerProvider.notifier); + + controller.attachWorkspace(_workspace()); + await controller.refresh(); + final history = controller.loadProjectHistory(); + await gateway.historyStarted.future; + + controller.attachWorkspace(_workspace(id: '/other', rootPath: '/other')); + await controller.refresh(); + gateway.historyResult.complete([ + _commitSummary('aaaaaaaaaaaaaaaa', 'History from the old repository'), + ]); + await history; + + final state = container.read(gitControllerProvider); + expect(state.repositoryInfo?.rootPath, '/other'); + expect(state.history, isEmpty); + expect(state.selectedView, GitView.changes); + }); + test('history tab reloads project history after workspace switch', () async { final gateway = _FakeGitGateway(); final container = _container(gateway); @@ -898,6 +957,95 @@ class _DeferredDetectGitGateway extends _FakeGitGateway { } } +class _DeferredHistoryGitGateway extends _FakeGitGateway { + final historyStarted = Completer(); + final historyResult = Completer>(); + + @override + Future detectRepository(String workspacePath) async { + return GitRepositoryInfo( + rootPath: workspacePath, + gitDirPath: '$workspacePath/.git', + ); + } + + @override + Future status(GitRepositoryInfo repository) async { + return GitStatusSnapshot(repositoryInfo: repository, files: const []); + } + + @override + Future> history( + GitRepositoryInfo repository, { + String? repoRelativePath, + int limit = 200, + int skip = 0, + }) { + if (!historyStarted.isCompleted) { + historyStarted.complete(); + } + return historyResult.future; + } +} + +class _DeferredSetUpstreamPushGitGateway extends _FakeGitGateway { + final remotesStarted = Completer(); + final remotesResult = Completer>(); + final setUpstreamPushes = + <({String rootPath, String remote, String branch})>[]; + GitRepositoryInfo? remotesRepository; + + @override + Future detectRepository(String workspacePath) async { + return GitRepositoryInfo( + rootPath: workspacePath, + gitDirPath: '$workspacePath/.git', + currentBranch: workspacePath == '/repo' ? 'branch-a' : 'branch-b', + hasRemote: true, + ); + } + + @override + Future status(GitRepositoryInfo repository) async { + return GitStatusSnapshot(repositoryInfo: repository, files: const []); + } + + @override + Future> remotes(GitRepositoryInfo repository) { + remotesRepository = repository; + if (!remotesStarted.isCompleted) { + remotesStarted.complete(); + } + return remotesResult.future; + } + + @override + Future pushSetUpstream( + GitRepositoryInfo repository, + String remote, + String branch, + ) async { + setUpstreamPushes.add(( + rootPath: repository.rootPath, + remote: remote, + branch: branch, + )); + return _result(); + } +} + +GitCommitSummary _commitSummary(String hash, String subject) { + return GitCommitSummary( + fullHash: hash, + shortHash: hash.substring(0, 7), + authorName: 'BusyMark Test', + authorEmail: 'busymark@example.com', + authorDate: DateTime(2026), + subject: subject, + parentHashes: const [], + ); +} + GitDiffFile _diffFile(String path) { return GitDiffFile( oldPath: path, diff --git a/test/src/git/git_diff_parser_test.dart b/test/src/git/git_diff_parser_test.dart index a91e961..0a77977 100644 --- a/test/src/git/git_diff_parser_test.dart +++ b/test/src/git/git_diff_parser_test.dart @@ -75,6 +75,35 @@ rename to new.md expect(file.newPath, 'new.md'); }); + test('decodes C-quoted UTF-8 paths emitted by Git', () { + final diff = parser.parse(r''' +diff --git "a/docs/\303\234ber \"guide\".md" "b/docs/\303\234ber \"guide\".md" +--- "a/docs/\303\234ber \"guide\".md" ++++ "b/docs/\303\234ber \"guide\".md" +@@ -1 +1 @@ +-Old ++New +'''); + + final file = diff.files.single; + expect(file.oldPath, 'docs/Über "guide".md'); + expect(file.newPath, 'docs/Über "guide".md'); + }); + + test('decodes C-quoted rename metadata emitted by Git', () { + final diff = parser.parse(r''' +diff --git "a/docs/\303\204lter Name.md" "b/docs/\303\204lterer \"Name\".md" +similarity index 100% +rename from "docs/\303\204lter Name.md" +rename to "docs/\303\204lterer \"Name\".md" +'''); + + final file = diff.files.single; + expect(file.status, GitDiffFileStatus.renamed); + expect(file.oldPath, 'docs/Älter Name.md'); + expect(file.newPath, 'docs/Älterer "Name".md'); + }); + test('parses binary file marker', () { final diff = parser.parse(''' diff --git a/image.png b/image.png diff --git a/test/src/git/git_process_runner_test.dart b/test/src/git/git_process_runner_test.dart index 02ef2df..7868ea6 100644 --- a/test/src/git/git_process_runner_test.dart +++ b/test/src/git/git_process_runner_test.dart @@ -23,6 +23,36 @@ void main() { skip: Platform.isWindows, ); + test( + 'runner terminates commands that exceed the combined output limit', + () async { + const runner = DartGitCommandRunner(maxOutputBytes: 128); + + await expectLater( + runner.run( + '/bin/sh', + const ['-c', 'printf %080d 0; printf %080d 0 >&2; sleep 10'], + timeout: const Duration(seconds: 5), + commandName: 'noisy-command', + ), + throwsA( + isA() + .having( + (failure) => failure.commandName, + 'commandName', + 'noisy-command', + ) + .having( + (failure) => failure.rawMessage, + 'rawMessage', + contains('128-byte'), + ), + ), + ); + }, + skip: Platform.isWindows, + ); + test('normal gateway commands always receive a finite timeout', () async { final runner = _RecordingGitRunner(); final gateway = GitCliGateway( diff --git a/test/src/git/git_widget_test.dart b/test/src/git/git_widget_test.dart index c674c9b..49953be 100644 --- a/test/src/git/git_widget_test.dart +++ b/test/src/git/git_widget_test.dart @@ -130,7 +130,7 @@ void main() { ), ); - await tester.tap(find.byTooltip(l10n.gitFileActions)); + await tester.tap(find.byTooltip(l10n.fileActions)); await tester.pumpAndSettle(); expect(find.text(l10n.gitOpenFile), findsOneWidget); diff --git a/test/src/header_bar_configuration_test.dart b/test/src/header_bar_configuration_test.dart index 346ace1..8133f8f 100644 --- a/test/src/header_bar_configuration_test.dart +++ b/test/src/header_bar_configuration_test.dart @@ -101,6 +101,7 @@ void main() { 'sidebarVisible': false, 'sidebarToggleVisible': false, 'backVisible': true, + 'fullScreen': false, 'modalBarrierVisible': false, 'modalBarrierDepth': 0, 'sidebarWidth': 300.0, @@ -435,6 +436,7 @@ HeaderBarConfiguration _configuration({ bool sidebarVisible = false, bool sidebarToggleVisible = false, bool backVisible = false, + bool fullScreen = false, }) { return HeaderBarConfiguration( title: title, @@ -448,6 +450,7 @@ HeaderBarConfiguration _configuration({ sidebarVisible: sidebarVisible, sidebarToggleVisible: sidebarToggleVisible, backVisible: backVisible, + fullScreen: fullScreen, modalBarrierDepth: 0, sidebarWidth: 300, labels: _labels, @@ -476,10 +479,14 @@ const _labels = HeaderBarLabels( sidebar: 'Sidebar', sidebarShortcut: 'F9', back: 'Back', + backShortcut: 'Alt+Left', save: 'Save', exportPdf: 'Export as PDF', exportPdfShortcut: 'Ctrl+Shift+E', exportPdfGtkAccelerator: 'e', + fullScreen: 'Full Screen', + fullScreenShortcut: 'F11', + fullScreenGtkAccelerator: 'F11', settings: 'Settings', settingsShortcut: 'Ctrl+,', settingsGtkAccelerator: 'comma', diff --git a/test/src/linux_header_bar_service_test.dart b/test/src/linux_header_bar_service_test.dart index 6816442..8feba2c 100644 --- a/test/src/linux_header_bar_service_test.dart +++ b/test/src/linux_header_bar_service_test.dart @@ -100,14 +100,26 @@ void main() { await sendNativeAction('save'); await sendNativeAction('save'); + await sendNativeAction('fullScreen'); await sendNativeAction('reportIssue'); + await sendNativeAction('sidebarFiles'); + await sendNativeAction('sidebarToc'); + await sendNativeAction('sidebarOutline'); + await sendNativeAction('sidebarGit'); + await sendNativeAction('sidebarHistory'); expect(events.map((event) => event.action), [ HeaderBarAction.save, HeaderBarAction.save, + HeaderBarAction.fullScreen, HeaderBarAction.reportIssue, + HeaderBarAction.sidebarFiles, + HeaderBarAction.sidebarToc, + HeaderBarAction.sidebarOutline, + HeaderBarAction.sidebarGit, + HeaderBarAction.sidebarHistory, ]); - expect(events.map((event) => event.sequence), [1, 2, 3]); + expect(events.map((event) => event.sequence), [1, 2, 3, 4, 5, 6, 7, 8, 9]); expect(events.first, isNot(events.last)); }); diff --git a/test/src/markdown_export_mapper_test.dart b/test/src/markdown_export_mapper_test.dart index 250a357..bd9d4f8 100644 --- a/test/src/markdown_export_mapper_test.dart +++ b/test/src/markdown_export_mapper_test.dart @@ -70,6 +70,45 @@ Text with **bold**, [safe](https://example.com), and [unsafe](javascript:alert(1 expect(links.last.destination, isNull); }); + test('omits reference definitions while preserving their resolved links', () { + final parsed = parser.parse( + filePath: '/workspace/guide.md', + source: ''' +[1]: https://example.com/exports "Exports" +[2]: https://example.com/security "Security" + +# Plan + +Review [the export guide][1]. +''', + validateLocalReferences: false, + ); + + expect( + parsed.busyDocument.blocks.where((block) => block.isSourceOnly), + hasLength(2), + ); + + final document = mapper.map(parsed.busyDocument); + expect(document.blocks.map((block) => block.kind), [ + MarkdownExportBlockKind.heading, + MarkdownExportBlockKind.paragraph, + ]); + + final link = document.blocks.last.inlines.singleWhere( + (inline) => inline.kind == MarkdownExportInlineKind.link, + ); + expect(link.destination, 'https://example.com/exports'); + + final payload = const TypstPayloadBuilder().build( + document: document, + options: const MarkdownPdfOptions(), + assets: const {}, + ); + expect(jsonEncode(payload), isNot(contains('[1]:'))); + expect(jsonEncode(payload), isNot(contains('[2]:'))); + }); + test('Typst payload contains data but never raw local image paths', () { final parsed = parser.parse( filePath: '/workspace/guide.md', diff --git a/test/src/markdown_section_editor_test.dart b/test/src/markdown_section_editor_test.dart new file mode 100644 index 0000000..1286a51 --- /dev/null +++ b/test/src/markdown_section_editor_test.dart @@ -0,0 +1,246 @@ +import 'package:busymark/src/markdown/markdown_parser.dart'; +import 'package:busymark/src/markdown/markdown_section_editor.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const parser = MarkdownParser(); + + MarkdownSectionEditor section(String source, String headingText) { + final parsed = parser.parse( + filePath: 'outline-actions.md', + source: source, + validateLocalReferences: false, + ); + final index = parsed.headings.indexWhere( + (heading) => heading.text == headingText, + ); + expect(index, isNonNegative, reason: headingText); + return MarkdownSectionEditor.fromHeadings( + source: source, + headings: parsed.headings, + headingIndex: index, + ); + } + + test('section includes descendants and stops at the next sibling', () { + const source = ''' +# Root + +Intro. + +## First + +First body. + +### Child + +Child body. + +#### Grandchild + +Grandchild body. + +## Second + +Second body. +'''; + final editor = section(source, 'First'); + + expect(editor.sectionText, '''## First + +First body. + +### Child + +Child body. + +#### Grandchild + +Grandchild body. + +'''); + expect(editor.withoutSection(), '''# Root + +Intro. + +## Second + +Second body. +'''); + }); + + test('move up and down swap complete sibling subtrees', () { + const source = ''' +# Root + +## Alpha + +Alpha body. + +### Alpha child + +Alpha child body. + +## Beta + +Beta body. + +### Beta child + +Beta child body. + +## Gamma + +Gamma body. + +# Tail +'''; + final beta = section(source, 'Beta'); + + expect(beta.canMoveUp, isTrue); + expect(beta.canMoveDown, isTrue); + expect(beta.moveUp(), '''# Root + +## Beta + +Beta body. + +### Beta child + +Beta child body. + +## Alpha + +Alpha body. + +### Alpha child + +Alpha child body. + +## Gamma + +Gamma body. + +# Tail +'''); + expect(beta.moveDown(), '''# Root + +## Alpha + +Alpha body. + +### Alpha child + +Alpha child body. + +## Gamma + +Gamma body. + +## Beta + +Beta body. + +### Beta child + +Beta child body. + +# Tail +'''); + + expect(section(source, 'Alpha').canMoveUp, isFalse); + expect(section(source, 'Gamma').canMoveDown, isFalse); + }); + + test('promote and demote preserve descendant hierarchy', () { + const source = ''' +# Root + +## Parent + +### Selected + +Text. + +#### Child + +##### Grandchild + +## Sibling +'''; + final editor = section(source, 'Selected'); + + expect(editor.canPromote, isTrue); + expect(editor.canDemote, isTrue); + expect(editor.promote(), '''# Root + +## Parent + +## Selected + +Text. + +### Child + +#### Grandchild + +## Sibling +'''); + expect(editor.demote(), '''# Root + +## Parent + +#### Selected + +Text. + +##### Child + +###### Grandchild + +## Sibling +'''); + }); + + test('demote is unavailable when a descendant is already H6', () { + const source = ''' +### Selected + +###### Deep child +'''; + final editor = section(source, 'Selected'); + + expect(editor.canDemote, isFalse); + expect(editor.demote(), isNull); + }); + + test('level changes preserve and convert Setext headings', () { + const source = ''' +Root +==== + +Child +----- + +### Grandchild +'''; + final root = section(source, 'Root'); + final child = section(source, 'Child'); + + expect(root.demote(), '''Root +---- + +### Child + +#### Grandchild +'''); + expect(child.promote(), '''Root +==== + +Child +===== + +## Grandchild +'''); + }); +} diff --git a/test/src/native_headerbar_audit_test.dart b/test/src/native_headerbar_audit_test.dart index 6984cc7..a1f68d2 100644 --- a/test/src/native_headerbar_audit_test.dart +++ b/test/src/native_headerbar_audit_test.dart @@ -100,6 +100,7 @@ void main() { 'sidebarWidth', 'textDirection', 'backVisible', + 'fullScreen', 'modalBarrierVisible', 'modalBarrierDepth', 'labels', @@ -212,11 +213,13 @@ void main() { expect(service, contains('keyboardShortcuts')); expect(service, contains('markdownAndHtml')); expect(service, contains('reportIssue')); + expect(service, contains('fullScreen')); expect(app, contains('menu: l10n.mainMenu')); expect(app, contains('settings: l10n.settings')); expect(app, contains('keyboardShortcuts: l10n.keyboardShortcuts')); expect(app, contains('markdownAndHtml: l10n.markdownAndHtml')); expect(app, contains('reportIssue: l10n.reportIssue')); + expect(app, contains('fullScreen: l10n.fullScreen')); expect(app, contains('aboutBusyMark: l10n.aboutBusyMark')); expect(dialogs, contains('showBusyMarkKeyboardShortcutsDialog')); expect(dialogs, contains('showBusyMarkMarkdownHtmlDialog')); @@ -226,6 +229,8 @@ void main() { expect(dialogs, contains('BusyMarkTextEditingShortcutLabels.redo')); expect(shortcuts, contains("newDocumentLabel = 'Ctrl+N'")); expect(shortcuts, contains("saveLabel = 'Ctrl+S'")); + expect(shortcuts, contains("fullScreenLabel = 'F11'")); + expect(shortcuts, contains("backLabel = 'Alt+Left'")); expect(shortcuts, contains("undoLabel = 'Ctrl+Z'")); expect(shortcuts, contains("redoLabel = 'Ctrl+Shift+Z'")); expect(workspace, contains('case HeaderBarAction.keyboardShortcuts:')); @@ -242,6 +247,7 @@ void main() { expect(mainMenu, contains('tooltip: l10n.mainMenu')); expect(mainMenu, contains('label: l10n.reportIssue')); expect(mainMenu, contains('label: l10n.exportAsPdf')); + expect(mainMenu, contains('label: l10n.fullScreen')); expect(mainMenu, contains('enabled: canExportPdf')); expect(native, contains('GtkWidget* main_menu_button;')); expect(native, contains('GMenu* main_menu_model;')); @@ -249,6 +255,9 @@ void main() { expect(native, contains('rebuild_main_menu_model')); expect(native, contains('"header.keyboard-shortcuts"')); expect(native, contains('"header.export-pdf"')); + expect(native, contains('"header.full-screen"')); + expect(native, contains('full_screen_gaction_activated_cb')); + expect(native, contains('configuration.full_screen')); expect(native, contains('configuration.can_export_pdf')); expect(native, contains('"header.markdown-and-html"')); expect(native, contains('"header.report-issue"')); @@ -1019,6 +1028,16 @@ void main() { expect(native, contains('"searchFocusChanged"')); expect(native, contains('"searchCleared"')); expect(native, contains('"searchEscapePressed"')); + expect(native, contains('sidebar_shortcut_action_for_key')); + expect(native, contains('GDK_KEY_KP_1')); + expect(native, contains('GDK_KEY_KP_5')); + expect(native, contains('"sidebarFiles"')); + expect(native, contains('"sidebarHistory"')); + expect(native, contains('modifiers != GDK_CONTROL_MASK')); + expect( + native, + contains('g_signal_connect(self->search_entry, "key-press-event",'), + ); expect(native, contains('strcmp(method, "focusSearch") == 0')); expect(native, contains('enum class SearchQueryUpdateDisposition')); expect(native, contains('resolve_search_query_update(false, true)')); @@ -1034,7 +1053,11 @@ void main() { ); expect(native, contains('native_entry_has_authority')); expect(native, isNot(contains('echoes_last_native_query'))); - expect(native, isNot(contains('"key-press-event"'))); + expect( + RegExp(r'"key-press-event"').allMatches(native), + hasLength(1), + reason: 'Only the native-focus shortcut bridge handles raw keys.', + ); }); test( diff --git a/test/src/path_utils_test.dart b/test/src/path_utils_test.dart new file mode 100644 index 0000000..de0de43 --- /dev/null +++ b/test/src/path_utils_test.dart @@ -0,0 +1,68 @@ +import 'dart:io'; + +import 'package:busymark/src/core/path_utils.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; + +void main() { + test( + 'maxTreeEntries cancels a wide directory listing at the limit', + () async { + final root = await Directory.systemTemp.createTemp('busymark-scan-wide-'); + addTearDown(() async { + if (await root.exists()) { + await root.delete(recursive: true); + } + }); + var yieldedEntries = 0; + + Stream listDirectory( + Directory directory, { + required bool followLinks, + }) async* { + expect(followLinks, isFalse); + for (var index = 0; index < 100; index++) { + yieldedEntries++; + yield File(p.join(directory.path, '$index.unsupported')); + } + } + + final result = await scanWorkspaceEntities( + root.path, + options: const WorkspaceScanOptions(maxTreeEntries: 3), + directoryLister: listDirectory, + ); + + expect(yieldedEntries, 3); + expect(result.entities, isEmpty); + expect( + result.diagnostics.map((diagnostic) => diagnostic.code), + contains('workspace.scan.skipped'), + ); + }, + ); + + test('maxTreeEntries counts directories against the scan budget', () async { + final root = await Directory.systemTemp.createTemp('busymark-scan-deep-'); + addTearDown(() async { + if (await root.exists()) { + await root.delete(recursive: true); + } + }); + final nested = await Directory( + p.join(root.path, 'level-one', 'level-two'), + ).create(recursive: true); + await File(p.join(nested.path, 'guide.md')).writeAsString('# Guide\n'); + + final result = await scanWorkspaceEntities( + root.path, + options: const WorkspaceScanOptions(maxTreeEntries: 2), + ); + + expect(result.entities, isEmpty); + expect( + result.diagnostics.map((diagnostic) => diagnostic.code), + contains('workspace.scan.skipped'), + ); + }); +} diff --git a/test/src/preview_builder_test.dart b/test/src/preview_builder_test.dart index e1f220a..07dedfc 100644 --- a/test/src/preview_builder_test.dart +++ b/test/src/preview_builder_test.dart @@ -213,6 +213,22 @@ void main() { ); }); + test('preview keeps ordered list numbering independent at each depth', () { + final parsed = parser.parse( + filePath: 'topic.md', + source: '1. One\n2. Two\n3. Parent\n 1. Child\n', + ); + final preview = previewBuilder.build(parsed); + final parent = preview.blocks.last; + + expect(preview.blocks.map((block) => block.attributes['marker']), [ + '1.', + '2.', + '3.', + ]); + expect(parent.children.single.attributes['marker'], '1.'); + }); + test('preview preserves inline Markdown semantics', () { final parsed = parser.parse( filePath: 'topic.md', diff --git a/test/src/source_audit_test.dart b/test/src/source_audit_test.dart index 84c2612..e89aaaa 100644 --- a/test/src/source_audit_test.dart +++ b/test/src/source_audit_test.dart @@ -536,6 +536,9 @@ void main() { expect(searchField, contains('onEscape: widget.onEscape')); expect(workspace, contains('class _SearchSidebar')); expect(workspace, contains('_workspaceSearchResults')); + expect(workspace, contains('_loadWorkspaceSearchMatches')); + expect(workspace, contains('await loadText(file.absolutePath)')); + expect(workspace, isNot(contains('readAsStringSync()'))); expect(workspace, contains('_searchNavigationTargetProvider')); }); @@ -1069,6 +1072,13 @@ void main() { workspace, contains('selectedTab == _SidebarTab.files && path.isNotEmpty'), ); + expect(workspace, contains('selectedTab == _SidebarTab.outline')); + expect( + workspace, + contains("ValueKey('workspace-sidebar-outline-file-menu')"), + ); + expect(workspace, contains('copyNameLabel: menuContext.l10n.copyFileName')); + expect(workspace, contains('tooltip: context.l10n.fileActions')); expect(workspace, isNot(contains('tooltip: context.l10n.openInFiles'))); expect(workspace, contains('icon: WorkspaceGlyphs.branch')); expect(workspace, isNot(contains('boldLeadingIcon'))); @@ -1416,7 +1426,11 @@ void main() { expect(workspace, contains('_outlineNavigationTargetProvider')); expect(workspace, contains('_OutlineNavigationTarget')); - expect(workspace, contains('outline: _activeDocumentOutline(state)')); + expect( + workspace, + contains('final documentOutline = _activeDocumentOutline(state);'), + ); + expect(workspace, contains('outline: documentOutline')); expect(workspace, contains('return preview.outline')); expect(workspace, contains('headingId: heading.id')); expect(workspace, contains('line: heading.sourceStartLine')); diff --git a/test/src/source_commands_test.dart b/test/src/source_commands_test.dart index 3b598ce..404c339 100644 --- a/test/src/source_commands_test.dart +++ b/test/src/source_commands_test.dart @@ -78,6 +78,37 @@ void main() { expect(value.selection.start, 1); }); + test('ordered list numbering restarts for nested source lines', () { + const source = 'One\nTwo\nThree\n Child\n'; + + final ordered = SourceCommands.applyBlockCommand( + const TextEditingValue( + text: source, + selection: TextSelection(baseOffset: 0, extentOffset: source.length), + ), + SourceBlockCommand.orderedList, + ); + + expect(ordered.text, '1. One\n2. Two\n3. Three\n 1. Child\n'); + }); + + test('ordered list numbering restarts beneath each source parent', () { + const source = 'Parent A\n Child A\nParent B\n Child B\n'; + + final ordered = SourceCommands.applyBlockCommand( + const TextEditingValue( + text: source, + selection: TextSelection(baseOffset: 0, extentOffset: source.length), + ), + SourceBlockCommand.orderedList, + ); + + expect( + ordered.text, + '1. Parent A\n 1. Child A\n2. Parent B\n 1. Child B\n', + ); + }); + test('inline commands toggle bold italic code and links', () { final bold = SourceCommands.applyInlineCommand( const TextEditingValue( diff --git a/test/src/window_control_service_test.dart b/test/src/window_control_service_test.dart index 2df57b1..49e33dd 100644 --- a/test/src/window_control_service_test.dart +++ b/test/src/window_control_service_test.dart @@ -10,6 +10,23 @@ void main() { await service.initialize(); expect(native.preventCloseValues, [true]); + expect(native.listeners, hasLength(1)); + }); + + test('toggles full screen and tracks native window-state changes', () async { + final native = _FakeNativeWindowController(); + final service = WindowControlService(nativeWindow: native); + + await service.initialize(); + await service.toggleFullScreen(); + + expect(native.fullScreenValues, [true]); + expect(service.isFullScreen, isTrue); + + native.listeners.single.onWindowLeaveFullScreen(); + expect(service.isFullScreen, isFalse); + native.listeners.single.onWindowEnterFullScreen(); + expect(service.isFullScreen, isTrue); }); test('no dirty documents closes without dialog', () async { @@ -130,8 +147,10 @@ void main() { class _FakeNativeWindowController implements NativeWindowController { final preventCloseValues = []; + final fullScreenValues = []; final listeners = []; var closeCount = 0; + var fullScreen = false; @override Future setPreventClose(bool value) async { @@ -143,6 +162,15 @@ class _FakeNativeWindowController implements NativeWindowController { closeCount++; } + @override + Future isFullScreen() async => fullScreen; + + @override + Future setFullScreen(bool value) async { + fullScreen = value; + fullScreenValues.add(value); + } + @override void addListener(WindowListener listener) { listeners.add(listener); diff --git a/test/src/workspace_controller_test.dart b/test/src/workspace_controller_test.dart index 511cba0..7b01a28 100644 --- a/test/src/workspace_controller_test.dart +++ b/test/src/workspace_controller_test.dart @@ -913,6 +913,31 @@ void main() { }, ); + test('overlapping manual save writes the newer requested revision', () async { + final service = _AutosaveWorkspaceService(pauseFirstSave: true); + final harness = await _createControllerHarness(service: service); + final settingsController = harness.settingsController; + final controller = harness.controller; + + await settingsController.setAutoSave(false); + await controller.openPath(service.path); + controller.updateActiveText('# First\n'); + final firstSave = controller.saveActive(); + await service.firstSaveStarted.future; + + controller.updateActiveText('# Second\n'); + final secondSave = controller.saveActive(); + service.releaseFirstSave(); + + expect(await firstSave, isTrue); + expect(await secondSave, isTrue); + expect(service.savedTexts, ['# First\n', '# Second\n']); + expect(controller.state.isDirty, isFalse); + + controller.dispose(); + settingsController.dispose(); + }); + test( 'save refuses to overwrite external file changes without force', () async { @@ -1130,6 +1155,8 @@ class _AppSettingsControllerDriver { Future setValidateOnEdit(bool enabled) => _notifier.setValidateOnEdit(enabled); + Future setAutoSave(bool enabled) => _notifier.setAutoSave(enabled); + void dispose() {} } diff --git a/test/src/workspace_safety_test.dart b/test/src/workspace_safety_test.dart index c6bc1b7..0b93622 100644 --- a/test/src/workspace_safety_test.dart +++ b/test/src/workspace_safety_test.dart @@ -328,6 +328,70 @@ void main() { }, ); + testWidgets('overlapping manual saves write the newer requested revision', ( + tester, + ) async { + final service = _IdentityWorkspaceService()..pauseFirstSave = true; + final saveRequests = >[]; + 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: () { + saveRequests.add( + saveActiveWithOverwriteConfirmation(context, ref), + ); + }, + child: const Text('Save document'), + ); + }, + ), + ), + ), + ), + ); + + final controller = widgetRef.read(workspaceControllerProvider.notifier); + await controller.openPath(service.rootPath); + controller.updateActiveText('# First revision\n'); + await tester.tap(find.text('Save document')); + await service.firstSaveStarted.future; + + controller.updateActiveText('# Second revision\n'); + await tester.tap(find.text('Save document')); + await tester.pump(); + service.releaseFirstSave(); + + expect(await Future.wait(saveRequests), [isTrue, isTrue]); + await tester.pumpAndSettle(); + expect(service.saves, [ + (path: service.firstPath, text: '# First revision\n'), + (path: service.firstPath, text: '# Second revision\n'), + ]); + expect(service.documents[service.firstPath], '# Second revision\n'); + expect(widgetRef.read(workspaceControllerProvider).isDirty, isFalse); + }); + testWidgets('save cancels when the active document changes during disk I/O', ( tester, ) async { @@ -845,11 +909,14 @@ class _IdentityWorkspaceService extends WorkspaceService { }; final saves = <({String path, String text})>[]; final fileChangeCheckStarted = Completer(); + final firstSaveStarted = Completer(); final pathExistsCheckStarted = Completer(); final pathExistsChecks = []; var firstChangedOnDisk = false; + var pauseFirstSave = false; Completer? pendingFileChangedResult; Completer? pendingPathExistsResult; + final _releaseFirstSave = Completer(); @override Future openPath(String path) async { @@ -918,10 +985,20 @@ class _IdentityWorkspaceService extends WorkspaceService { @override Future saveText(String path, String text) async { saves.add((path: path, text: text)); + if (pauseFirstSave && saves.length == 1) { + firstSaveStarted.complete(); + await _releaseFirstSave.future; + } documents[path] = text; return _snapshot(text); } + void releaseFirstSave() { + if (!_releaseFirstSave.isCompleted) { + _releaseFirstSave.complete(); + } + } + @override Future saveTextReplacingPath( String path, diff --git a/test/src/workspace_service_test.dart b/test/src/workspace_service_test.dart index 81b7a5e..b3cf330 100644 --- a/test/src/workspace_service_test.dart +++ b/test/src/workspace_service_test.dart @@ -6,6 +6,7 @@ import 'package:busymark/src/markdown/markdown_model.dart'; import 'package:busymark/src/markdown/markdown_parser.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'; import 'package:busymark/src/writerside/writerside_project_creator.dart'; import 'package:busymark/src/writerside/writerside_topic_creator.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -868,7 +869,9 @@ void main() { } final limitedService = WorkspaceService( - scanOptions: const WorkspaceScanOptions(maxTreeEntries: 2), + // Three root directories and the two shallow files consume the + // traversal budget before the deep subtree is visited. + scanOptions: const WorkspaceScanOptions(maxTreeEntries: 5), ); final workspace = await limitedService.openPath(directory.path); final relativePaths = workspace.files.map((file) => file.relativePath); @@ -936,6 +939,101 @@ void main() { await directory.delete(recursive: true); }, ); + + test( + 'large Writerside topics are listed but not parsed automatically', + () async { + final directory = await Directory.systemTemp.createTemp( + 'busymark-large-writerside-', + ); + addTearDown(() => directory.delete(recursive: true)); + Directory(p.join(directory.path, 'topics')).createSync(); + File(p.join(directory.path, 'writerside.cfg')).writeAsStringSync( + '', + ); + File(p.join(directory.path, 'ug.tree')).writeAsStringSync( + '' + '' + '', + ); + final largeTopic = File(p.join(directory.path, 'topics', 'large.md')) + ..writeAsStringSync('# Large\n\n${List.filled(512, 'x').join()}'); + final limitedService = WorkspaceService( + scanOptions: const WorkspaceScanOptions(maxParsedFileBytes: 256), + ); + + final workspace = await limitedService.openPath(directory.path); + + expect( + workspace.files.map((file) => file.relativePath), + contains('topics/large.md'), + ); + expect(workspace.writersideModule?.instances, hasLength(1)); + expect(workspace.writersideModule?.topics, isEmpty); + expect(workspace.markdown, isNull); + expect(workspace.activeFilePath, isNull); + expect( + workspace.diagnostics.where( + (diagnostic) => + diagnostic.code == 'workspace.file.too-large' && + diagnostic.filePath == largeTopic.path, + ), + hasLength(1), + ); + expect( + workspace.diagnostics.map((diagnostic) => diagnostic.code), + isNot(contains('writerside.tree.missing-topic')), + ); + }, + ); + + test('Writerside topic loading honors the document limit', () async { + final directory = await Directory.systemTemp.createTemp( + 'busymark-writerside-document-limit-', + ); + addTearDown(() => directory.delete(recursive: true)); + Directory(p.join(directory.path, 'topics')).createSync(); + File(p.join(directory.path, 'writerside.cfg')).writeAsStringSync( + '', + ); + File(p.join(directory.path, 'ug.tree')).writeAsStringSync( + '' + '' + '' + '', + ); + File(p.join(directory.path, 'topics', 'a.md')).writeAsStringSync('# A\n'); + final skippedTopic = File(p.join(directory.path, 'topics', 'b.md')) + ..writeAsStringSync('# B\n'); + final limitedService = WorkspaceService( + writersideService: const WritersideModuleService( + scanOptions: WorkspaceScanOptions(maxParsedDocuments: 1), + ), + ); + + final workspace = await limitedService.openPath(directory.path); + + expect( + workspace.files.map((file) => file.relativePath), + containsAll(['topics/a.md', 'topics/b.md']), + ); + expect(workspace.writersideModule?.topics.map((topic) => topic.fileName), [ + 'a.md', + ]); + expect(workspace.markdown?.title, 'A'); + expect( + workspace.diagnostics.where( + (diagnostic) => + diagnostic.code == 'workspace.scan.document-limit' && + diagnostic.filePath == skippedTopic.path, + ), + hasLength(1), + ); + expect( + workspace.diagnostics.map((diagnostic) => diagnostic.code), + isNot(contains('writerside.tree.missing-topic')), + ); + }); } Future<_WorkspaceSymlinkFixture> _createWorkspaceSymlinkFixture() async { diff --git a/test/src/writerside_test.dart b/test/src/writerside_test.dart index 0f7e975..46e6ade 100644 --- a/test/src/writerside_test.dart +++ b/test/src/writerside_test.dart @@ -2,6 +2,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/workspace/workspace_model.dart'; import 'package:busymark/src/workspace/workspace_service.dart'; import 'package:busymark/src/writerside/writerside_module_service.dart'; @@ -190,6 +191,159 @@ void main() { }, ); + test('does not parse a Writerside config above the byte limit', () async { + final root = await Directory.systemTemp.createTemp( + 'busymark-writerside-large-config-', + ); + addTearDown(() => root.deleteSync(recursive: true)); + final config = File(p.join(root.path, 'writerside.cfg')) + ..writeAsStringSync( + '', + ); + const limitedService = WritersideModuleService( + scanOptions: WorkspaceScanOptions(maxParsedFileBytes: 64), + ); + + final module = await limitedService.load(root.path); + + expect(module.config.moduleName, isNull); + expect( + module.diagnostics.where( + (diagnostic) => + diagnostic.code == 'workspace.file.too-large' && + diagnostic.filePath == config.path, + ), + hasLength(1), + ); + }); + + test('rejects configured paths outside the module root', () async { + final parent = await Directory.systemTemp.createTemp( + 'busymark-writerside-config-escape-', + ); + addTearDown(() => parent.deleteSync(recursive: true)); + final root = Directory(p.join(parent.path, 'module'))..createSync(); + final outside = Directory(p.join(parent.path, 'outside'))..createSync(); + Directory(p.join(outside.path, 'topics')).createSync(); + Directory(p.join(outside.path, 'images')).createSync(); + File( + p.join(outside.path, 'topics', 'secret.md'), + ).writeAsStringSync('# Outside\n'); + File(p.join(outside.path, 'ug.tree')).writeAsStringSync(''' + + + +'''); + File(p.join(outside.path, 'v.list')).writeAsStringSync(''' +secret +'''); + File(p.join(outside.path, 'c.list')).writeAsStringSync(''' + +'''); + File(p.join(root.path, 'writerside.cfg')).writeAsStringSync(''' + + + + + + + + + + + + +'''); + + final module = await moduleService.load(root.path); + final workspace = await workspaceService.openPath(root.path); + final unsafeDiagnostics = module.diagnostics.where( + (diagnostic) => diagnostic.code == 'writerside.config.path-unsafe', + ); + + expect(unsafeDiagnostics, hasLength(11)); + expect( + unsafeDiagnostics.map((diagnostic) => diagnostic.args['reason']).toSet(), + {'outsideRoot'}, + ); + expect(module.topics, isEmpty); + expect(module.instances, isEmpty); + expect(module.variables, isEmpty); + expect(module.categories, isEmpty); + expect(module.effectiveImagesDir, 'images'); + expect(workspace.activeFilePath, isNull); + }); + + test( + 'rejects a configured topic root reached through a symlink', + () async { + final parent = await Directory.systemTemp.createTemp( + 'busymark-writerside-config-symlink-', + ); + addTearDown(() => parent.deleteSync(recursive: true)); + final root = Directory(p.join(parent.path, 'module'))..createSync(); + final outside = Directory(p.join(parent.path, 'outside'))..createSync(); + final outsideTopic = File(p.join(outside.path, 'secret.md')) + ..writeAsStringSync('# Outside\n'); + await Link(p.join(root.path, 'topics')).create(outside.path); + File(p.join(root.path, 'writerside.cfg')).writeAsStringSync(''' + + + +'''); + + final module = await moduleService.load(root.path); + + expect( + module.topics.map((topic) => topic.filePath), + isNot(contains(outsideTopic.path)), + ); + expect( + module.diagnostics.where( + (diagnostic) => + diagnostic.code == 'writerside.config.path-unsafe' && + diagnostic.args['reason'] == 'symlinkComponent', + ), + isNotEmpty, + ); + }, + skip: Platform.isWindows ? 'POSIX symlink behavior only.' : false, + ); + + test( + 'rejects a Writerside config file reached through a symlink', + () async { + final parent = await Directory.systemTemp.createTemp( + 'busymark-writerside-config-file-symlink-', + ); + addTearDown(() => parent.deleteSync(recursive: true)); + final root = Directory(p.join(parent.path, 'module'))..createSync(); + final outsideConfig = File(p.join(parent.path, 'outside.cfg')) + ..writeAsStringSync(''' + + + +'''); + await Link( + p.join(root.path, 'writerside.cfg'), + ).create(outsideConfig.path); + + final module = await moduleService.load(root.path); + + expect(module.config.moduleName, isNull); + expect( + module.diagnostics.where( + (diagnostic) => + diagnostic.code == 'writerside.config.path-unsafe' && + diagnostic.args['kind'] == 'config' && + diagnostic.args['reason'] == 'symlinkComponent', + ), + isNotEmpty, + ); + }, + skip: Platform.isWindows ? 'POSIX symlink behavior only.' : false, + ); + test('loads project.ihp as an equivalent Writerside config file', () async { final root = await Directory.systemTemp.createTemp('busymark-project-ihp-'); addTearDown(() => root.deleteSync(recursive: true));