From aae42e4f517d179520066d4e7dd3ebbf9276d09b Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 28 Aug 2026 15:04:55 -0700 Subject: [PATCH 1/7] bump version to v0.3.2 --- lib/src/app/app_metadata.dart | 2 +- linux/io.busystack.busymark.metainfo.xml | 1 + pubspec.yaml | 2 +- snap/snapcraft.yaml | 2 +- test/src/visualization_packaging_audit_test.dart | 10 +++++----- 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/lib/src/app/app_metadata.dart b/lib/src/app/app_metadata.dart index abc28a7..f47be82 100644 --- a/lib/src/app/app_metadata.dart +++ b/lib/src/app/app_metadata.dart @@ -1 +1 @@ -const busyMarkAppVersion = '0.3.1'; +const busyMarkAppVersion = '0.3.2'; diff --git a/linux/io.busystack.busymark.metainfo.xml b/linux/io.busystack.busymark.metainfo.xml index b319f97..bedc491 100644 --- a/linux/io.busystack.busymark.metainfo.xml +++ b/linux/io.busystack.busymark.metainfo.xml @@ -63,6 +63,7 @@ https://github.com/busystack/busymark/issues + diff --git a/pubspec.yaml b/pubspec.yaml index 1e6ebb4..b2b47ff 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.3.1 +version: 0.3.2 environment: sdk: ^3.12.1 diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 9c03c3d..392f2bd 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -1,6 +1,6 @@ name: busymark title: BusyMark -version: "0.3.1" +version: "0.3.2" 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/src/visualization_packaging_audit_test.dart b/test/src/visualization_packaging_audit_test.dart index 4aa3049..afb2c95 100644 --- a/test/src/visualization_packaging_audit_test.dart +++ b/test/src/visualization_packaging_audit_test.dart @@ -11,10 +11,10 @@ void main() { 'linux/io.busystack.busymark.metainfo.xml', ).readAsStringSync(); - expect(pubspec, contains(RegExp(r'^version: 0\.3\.1$', multiLine: true))); + expect(pubspec, contains(RegExp(r'^version: 0\.3\.2$', multiLine: true))); expect( snapcraft, - contains(RegExp(r'^version: "0\.3\.1"$', multiLine: true)), + contains(RegExp(r'^version: "0\.3\.2"$', multiLine: true)), ); expect(snapcraft, contains(RegExp(r'^grade: stable$', multiLine: true))); expect( @@ -39,9 +39,9 @@ void main() { ), ), ); - expect(metainfo, contains(' Date: Fri, 28 Aug 2026 17:25:34 -0700 Subject: [PATCH 2/7] remove conflicting librsvg to ensure proper SVG icon loading --- .github/workflows/flutter-linux.yml | 10 +++++++ pubspec.lock | 28 ++++++++++---------- snap/snapcraft.yaml | 4 +++ test/src/native_headerbar_audit_test.dart | 32 +++++++++++++++++++++++ tools/build_install_snap_local.sh | 7 +++++ 5 files changed, 67 insertions(+), 14 deletions(-) diff --git a/.github/workflows/flutter-linux.yml b/.github/workflows/flutter-linux.yml index 3f18bba..dfd1cbb 100644 --- a/.github/workflows/flutter-linux.yml +++ b/.github/workflows/flutter-linux.yml @@ -176,6 +176,16 @@ jobs: sudo snap install --dangerous "${{ steps.snapcraft.outputs.snap }}" + - name: Verify GTK SVG icon loader + run: | + snap run --shell busymark -c ' + test ! -e \ + "$SNAP/usr/lib/$SNAP_LAUNCHER_ARCH_TRIPLET/librsvg-2.so.2" + query="$SNAP_DESKTOP_RUNTIME/usr/lib/$SNAP_LAUNCHER_ARCH_TRIPLET/gdk-pixbuf-2.0/gdk-pixbuf-query-loaders" + loader="$SNAP_DESKTOP_RUNTIME/usr/lib/$SNAP_LAUNCHER_ARCH_TRIPLET/gdk-pixbuf-2.0/2.10.0/loaders/libpixbufloader_svg.so" + "$query" "$loader" | grep -q "\"svg\" 6 \"gdk-pixbuf\"" + ' + - name: Exercise strict Snap visualization and PDF paths under X11 run: | report="$HOME/snap/busymark/common/visualization-release-x11.json" diff --git a/pubspec.lock b/pubspec.lock index 95ba64d..16254ae 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -409,10 +409,10 @@ packages: dependency: "direct main" description: name: intl - sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" url: "https://pub.dev" source: hosted - version: "0.20.3" + version: "0.20.2" io: dependency: transitive description: @@ -497,10 +497,10 @@ packages: dependency: transitive description: name: matcher - sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.20" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -521,10 +521,10 @@ packages: dependency: transitive description: name: meta - sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.19.0" + version: "1.18.0" mime: dependency: transitive description: @@ -862,26 +862,26 @@ packages: dependency: transitive description: name: test - sha256: ca578dc12bb8b2f40b67b7d3bd2fac4f31c01a6ff7130a14e2597b919934507f + sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" url: "https://pub.dev" source: hosted - version: "1.31.1" + version: "1.31.0" test_api: dependency: transitive description: name: test_api - sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.12" + version: "0.7.11" test_core: dependency: transitive description: name: test_core - sha256: d2e98ec12998368dc59ddd47ab709f2cd55acd6b66dc7db764455a44082f4bc5 + sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" url: "https://pub.dev" source: hosted - version: "0.6.18" + version: "0.6.17" typed_data: dependency: transitive description: @@ -998,10 +998,10 @@ packages: dependency: transitive description: name: vector_math - sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.2.0" vm_service: dependency: transitive description: diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 392f2bd..969daf7 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -129,6 +129,10 @@ parts: "$CRAFT_PRIME/usr/lib/x86_64-linux-gnu/caca/libgl_plugin.so" \ "$CRAFT_PRIME/usr/lib/x86_64-linux-gnu/caca/libgl_plugin.so.0" \ "$CRAFT_PRIME/usr/lib/x86_64-linux-gnu/caca/libgl_plugin.so.0.0.0" + # GStreamer stages the core24 librsvg, but the GNOME runtime supplies + # the matching SVG pixbuf loader and a newer librsvg. Let that pair stay + # together; otherwise every SVG-backed GTK symbolic icon fails to load. + rm -f "$CRAFT_PRIME/usr/lib/x86_64-linux-gnu/librsvg-2.so.2"* mkdir -p "$CRAFT_PRIME/share/themes" "$CRAFT_PRIME/share/icons" cp -a "$CRAFT_PRIME/usr/share/themes"/Yaru* "$CRAFT_PRIME/share/themes/" cp -a "$CRAFT_PRIME/usr/share/icons"/Yaru* "$CRAFT_PRIME/share/icons/" diff --git a/test/src/native_headerbar_audit_test.dart b/test/src/native_headerbar_audit_test.dart index 933c7d5..3dfc89c 100644 --- a/test/src/native_headerbar_audit_test.dart +++ b/test/src/native_headerbar_audit_test.dart @@ -480,6 +480,38 @@ void main() { ); }); + test('strict Snap keeps the GTK SVG loader ABI-aligned with GNOME', () { + final snapcraft = File('snap/snapcraft.yaml').readAsStringSync(); + final localSnapBuilder = File( + 'tools/build_install_snap_local.sh', + ).readAsStringSync(); + final workflow = File( + '.github/workflows/flutter-linux.yml', + ).readAsStringSync(); + + expect( + snapcraft, + contains( + r'rm -f "$CRAFT_PRIME/usr/lib/x86_64-linux-gnu/librsvg-2.so.2"*', + ), + ); + expect( + localSnapBuilder, + contains(r'rm -f "$SNAP_ROOT/usr/lib/x86_64-linux-gnu/librsvg-2.so.2"*'), + ); + expect( + localSnapBuilder, + contains('squashfs-root/usr/lib/x86_64-linux-gnu/librsvg-2.so.2'), + ); + expect(workflow, contains('Verify GTK SVG icon loader')); + expect(workflow, contains('libpixbufloader_svg.so')); + expect( + workflow, + contains(r'$SNAP/usr/lib/$SNAP_LAUNCHER_ARCH_TRIPLET/librsvg-2.so.2'), + ); + expect(workflow, contains(r'\"svg\" 6 \"gdk-pixbuf\"')); + }); + test('local snap builder stages bundled Git tools', () { final script = File('tools/build_install_snap_local.sh').readAsStringSync(); diff --git a/tools/build_install_snap_local.sh b/tools/build_install_snap_local.sh index e78bf1b..5732359 100755 --- a/tools/build_install_snap_local.sh +++ b/tools/build_install_snap_local.sh @@ -303,6 +303,11 @@ cp -a "$SNAP_SCAFFOLD/." "$SNAP_ROOT/" test -f "$SNAP_ROOT/meta/snap.yaml" || fail "missing $SNAP_ROOT/meta/snap.yaml" +echo "== Align GTK SVG runtime ==" +# The GNOME content snap owns both its SVG pixbuf loader and matching librsvg. +# An older copy retained from the scaffold breaks every GTK symbolic SVG icon. +rm -f "$SNAP_ROOT/usr/lib/x86_64-linux-gnu/librsvg-2.so.2"* + echo "== Replace Flutter payload ==" rm -rf "$SNAP_ROOT/$BINARY_NAME" "$SNAP_ROOT/data" "$SNAP_ROOT/lib" cp -a "$BUNDLE_DIR/." "$SNAP_ROOT/" @@ -589,6 +594,8 @@ if [[ -d "$BUNDLE_DIR/lib" ]]; then fi ! unsquashfs -cat "$OUT" meta/snap.yaml | grep -q '^icon:' ! unsquashfs -cat "$OUT" meta/snap.yaml | grep -q '^[[:space:]]*desktop:' +! unsquashfs -ll "$OUT" | grep -q \ + 'squashfs-root/usr/lib/x86_64-linux-gnu/librsvg-2.so.2' echo "Built snap: $OUT" if [[ "$INSTALL_AFTER_BUILD" == "1" ]]; then From e0eedb7550eaa7381372a07fb39f449f51acbb3b Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 28 Aug 2026 17:54:45 -0700 Subject: [PATCH 3/7] Add recent workspace actions --- lib/l10n/app_ar.arb | 1 + lib/l10n/app_de.arb | 1 + lib/l10n/app_en.arb | 2 + lib/l10n/app_es.arb | 1 + lib/l10n/app_et.arb | 1 + lib/l10n/app_fa.arb | 1 + lib/l10n/app_fr.arb | 1 + lib/l10n/app_hi.arb | 1 + lib/l10n/app_it.arb | 1 + lib/l10n/app_nb.arb | 1 + lib/l10n/app_pl.arb | 1 + lib/l10n/app_pt.arb | 1 + lib/l10n/app_ru.arb | 1 + lib/l10n/app_uk.arb | 1 + lib/l10n/generated/app_localizations.dart | 6 + lib/l10n/generated/app_localizations_ar.dart | 3 + lib/l10n/generated/app_localizations_de.dart | 3 + lib/l10n/generated/app_localizations_en.dart | 3 + lib/l10n/generated/app_localizations_es.dart | 3 + lib/l10n/generated/app_localizations_et.dart | 3 + lib/l10n/generated/app_localizations_fa.dart | 3 + lib/l10n/generated/app_localizations_fr.dart | 3 + lib/l10n/generated/app_localizations_hi.dart | 3 + lib/l10n/generated/app_localizations_it.dart | 3 + lib/l10n/generated/app_localizations_nb.dart | 3 + lib/l10n/generated/app_localizations_pl.dart | 3 + lib/l10n/generated/app_localizations_pt.dart | 3 + lib/l10n/generated/app_localizations_ru.dart | 3 + lib/l10n/generated/app_localizations_uk.dart | 3 + lib/src/app/app_settings.dart | 11 + lib/src/app/busymark_design.dart | 18 +- .../presentation/welcome_screen.dart | 205 ++++++++++++++++-- .../presentation/workspace_identity_row.dart | 6 + test/src/app_settings_test.dart | 93 ++++++-- test/src/native_headerbar_audit_test.dart | 2 +- 35 files changed, 358 insertions(+), 40 deletions(-) diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index bac2e61..c6fb2bb 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -207,6 +207,7 @@ "@reading": { "description": "Reading view label." }, + "removeFromRecent": "إزالة من الأخيرة", "recent": "الأخيرة", "@recent": { "description": "Recent workspaces section title." diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index c1553c3..32783f2 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -207,6 +207,7 @@ "@reading": { "description": "Reading view label." }, + "removeFromRecent": "Aus „Zuletzt verwendet“ entfernen", "recent": "Zuletzt verwendet", "@recent": { "description": "Recent workspaces section title." diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 063b4b1..dde84fb 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -168,6 +168,8 @@ "@pasteWithoutFormatting": {"description": "Plain text paste command label."}, "reading": "Reading", "@reading": {"description": "Reading view label."}, + "removeFromRecent": "Remove from Recent", + "@removeFromRecent": {"description": "Action that removes a workspace from the Recent list without deleting its files."}, "recent": "Recent", "@recent": {"description": "Recent workspaces section title."}, "redo": "Redo", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index e8b35a6..1db5451 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -207,6 +207,7 @@ "@reading": { "description": "Reading view label." }, + "removeFromRecent": "Quitar de Recientes", "recent": "Recientes", "@recent": { "description": "Recent workspaces section title." diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index 71a1f69..1e74a2c 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -151,6 +151,7 @@ "@pasteWithoutFormatting": {"description": "Plain text paste command label."}, "reading": "Lugemisvaade", "@reading": {"description": "Reading view label."}, + "removeFromRecent": "Eemalda hiljutiste seast", "recent": "Hiljutised", "@recent": {"description": "Recent workspaces section title."}, "redo": "Tee uuesti", diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 0bb61df..0533666 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -207,6 +207,7 @@ "@reading": { "description": "Reading view label." }, + "removeFromRecent": "حذف از موارد اخیر", "recent": "موارد اخیر", "@recent": { "description": "Recent workspaces section title." diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 23cb721..12169f1 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -207,6 +207,7 @@ "@reading": { "description": "Reading view label." }, + "removeFromRecent": "Retirer des éléments récents", "recent": "Récents", "@recent": { "description": "Recent workspaces section title." diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 6c61325..321388a 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -207,6 +207,7 @@ "@reading": { "description": "Reading view label." }, + "removeFromRecent": "हालिया से हटाएँ", "recent": "हालिया", "@recent": { "description": "Recent workspaces section title." diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 6968218..15ae271 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -207,6 +207,7 @@ "@reading": { "description": "Reading view label." }, + "removeFromRecent": "Rimuovi dai recenti", "recent": "Recenti", "@recent": { "description": "Recent workspaces section title." diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb index a8d23e9..c7ffabd 100644 --- a/lib/l10n/app_nb.arb +++ b/lib/l10n/app_nb.arb @@ -207,6 +207,7 @@ "@reading": { "description": "Reading view label." }, + "removeFromRecent": "Fjern fra nylige", "recent": "Nylige", "@recent": { "description": "Recent workspaces section title." diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 6749ff1..66b1b99 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -207,6 +207,7 @@ "@reading": { "description": "Reading view label." }, + "removeFromRecent": "Usuń z ostatnich", "recent": "Ostatnie", "@recent": { "description": "Recent workspaces section title." diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index b11a5b4..143dff7 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -207,6 +207,7 @@ "@reading": { "description": "Reading view label." }, + "removeFromRecent": "Remover dos recentes", "recent": "Recentes", "@recent": { "description": "Recent workspaces section title." diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 0720d6c..85aec87 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -207,6 +207,7 @@ "@reading": { "description": "Reading view label." }, + "removeFromRecent": "Удалить из недавних", "recent": "Недавние", "@recent": { "description": "Recent workspaces section title." diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index 4c20212..dd4f9cb 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -207,6 +207,7 @@ "@reading": { "description": "Reading view label." }, + "removeFromRecent": "Вилучити з нещодавніх", "recent": "Останні", "@recent": { "description": "Recent workspaces section title." diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 9b40adf..ed78ce1 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -609,6 +609,12 @@ abstract class AppLocalizations { /// **'Reading'** String get reading; + /// Action that removes a workspace from the Recent list without deleting its files. + /// + /// In en, this message translates to: + /// **'Remove from Recent'** + String get removeFromRecent; + /// Recent workspaces section title. /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index ad67f47..4f5004f 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -266,6 +266,9 @@ class AppLocalizationsAr extends AppLocalizations { @override String get reading => 'وضع القراءة'; + @override + String get removeFromRecent => 'إزالة من الأخيرة'; + @override String get recent => 'الأخيرة'; diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 49567b1..2d7f426 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -270,6 +270,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get reading => 'Leseansicht'; + @override + String get removeFromRecent => 'Aus „Zuletzt verwendet“ entfernen'; + @override String get recent => 'Zuletzt verwendet'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 21cbb69..a1b49f8 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -267,6 +267,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get reading => 'Reading'; + @override + String get removeFromRecent => 'Remove from Recent'; + @override String get recent => 'Recent'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index bbb9b36..cd8aa4a 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -269,6 +269,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get reading => 'Lectura'; + @override + String get removeFromRecent => 'Quitar de Recientes'; + @override String get recent => 'Recientes'; diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index 22ec622..3d48e00 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -266,6 +266,9 @@ class AppLocalizationsEt extends AppLocalizations { @override String get reading => 'Lugemisvaade'; + @override + String get removeFromRecent => 'Eemalda hiljutiste seast'; + @override String get recent => 'Hiljutised'; diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index 5261f40..f40cff4 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -266,6 +266,9 @@ class AppLocalizationsFa extends AppLocalizations { @override String get reading => 'حالت مطالعه'; + @override + String get removeFromRecent => 'حذف از موارد اخیر'; + @override String get recent => 'موارد اخیر'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index ba2bf28..3a266bc 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -269,6 +269,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get reading => 'Lecture'; + @override + String get removeFromRecent => 'Retirer des éléments récents'; + @override String get recent => 'Récents'; diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index c66d96b..981636b 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -268,6 +268,9 @@ class AppLocalizationsHi extends AppLocalizations { @override String get reading => 'पठन दृश्य'; + @override + String get removeFromRecent => 'हालिया से हटाएँ'; + @override String get recent => 'हालिया'; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index 048442f..8e73524 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -268,6 +268,9 @@ class AppLocalizationsIt extends AppLocalizations { @override String get reading => 'Lettura'; + @override + String get removeFromRecent => 'Rimuovi dai recenti'; + @override String get recent => 'Recenti'; diff --git a/lib/l10n/generated/app_localizations_nb.dart b/lib/l10n/generated/app_localizations_nb.dart index 19239c3..85d3002 100644 --- a/lib/l10n/generated/app_localizations_nb.dart +++ b/lib/l10n/generated/app_localizations_nb.dart @@ -269,6 +269,9 @@ class AppLocalizationsNb extends AppLocalizations { @override String get reading => 'Lesevisning'; + @override + String get removeFromRecent => 'Fjern fra nylige'; + @override String get recent => 'Nylige'; diff --git a/lib/l10n/generated/app_localizations_pl.dart b/lib/l10n/generated/app_localizations_pl.dart index 0723dc2..3afc886 100644 --- a/lib/l10n/generated/app_localizations_pl.dart +++ b/lib/l10n/generated/app_localizations_pl.dart @@ -268,6 +268,9 @@ class AppLocalizationsPl extends AppLocalizations { @override String get reading => 'Widok do czytania'; + @override + String get removeFromRecent => 'Usuń z ostatnich'; + @override String get recent => 'Ostatnie'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index b538160..a663dc1 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -269,6 +269,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get reading => 'Leitura'; + @override + String get removeFromRecent => 'Remover dos recentes'; + @override String get recent => 'Recentes'; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 82dc7a9..be9598f 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -269,6 +269,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get reading => 'Режим чтения'; + @override + String get removeFromRecent => 'Удалить из недавних'; + @override String get recent => 'Недавние'; diff --git a/lib/l10n/generated/app_localizations_uk.dart b/lib/l10n/generated/app_localizations_uk.dart index 2616056..410a5c2 100644 --- a/lib/l10n/generated/app_localizations_uk.dart +++ b/lib/l10n/generated/app_localizations_uk.dart @@ -268,6 +268,9 @@ class AppLocalizationsUk extends AppLocalizations { @override String get reading => 'Режим читання'; + @override + String get removeFromRecent => 'Вилучити з нещодавніх'; + @override String get recent => 'Останні'; diff --git a/lib/src/app/app_settings.dart b/lib/src/app/app_settings.dart index 0a25eb5..78ad943 100644 --- a/lib/src/app/app_settings.dart +++ b/lib/src/app/app_settings.dart @@ -752,6 +752,17 @@ class AppSettingsController extends Notifier { return _mutate((settings) => settings.copyWith(recentWorkspaces: [])); } + Future removeRecentWorkspace(String path) { + return _mutate( + (settings) => settings.copyWith( + recentWorkspaces: [ + for (final item in settings.recentWorkspaces) + if (!p.equals(item.path, path)) item, + ], + ), + ); + } + Future _load() async { AppSettings loaded; try { diff --git a/lib/src/app/busymark_design.dart b/lib/src/app/busymark_design.dart index 19d6465..3fc3581 100644 --- a/lib/src/app/busymark_design.dart +++ b/lib/src/app/busymark_design.dart @@ -1088,6 +1088,7 @@ class BusyMarkCompactIconButton extends StatelessWidget { this.size = BusyMarkSizes.compactIconButton, this.glyphSize = BusyMarkSizes.compactIcon, this.foregroundColor, + this.focusNode, }); final String tooltip; @@ -1096,6 +1097,7 @@ class BusyMarkCompactIconButton extends StatelessWidget { final double size; final double glyphSize; final Color? foregroundColor; + final FocusNode? focusNode; @override Widget build(BuildContext context) { @@ -1120,6 +1122,7 @@ class BusyMarkCompactIconButton extends StatelessWidget { constraints: BoxConstraints.tightFor(width: size, height: size), padding: EdgeInsets.zero, style: semanticStyle.merge(yaruDefaults), + focusNode: focusNode, onPressed: onPressed, ); return YaruTheme.maybeOf(context)?.focusBorders == true @@ -1714,7 +1717,8 @@ class _BusyMarkMenuButtonState extends State> { if (!widget.enabled || event is! KeyDownEvent) { return KeyEventResult.ignored; } - if (event.logicalKey == LogicalKeyboardKey.arrowDown || + if (isBusyMarkContextMenuKeyEvent(event) || + event.logicalKey == LogicalKeyboardKey.arrowDown || event.logicalKey == LogicalKeyboardKey.enter || event.logicalKey == LogicalKeyboardKey.space) { if (!_open) { @@ -1789,6 +1793,18 @@ class _BusyMarkMenuButtonState extends State> { } } +/// Whether [event] is the standard desktop request for an item context menu. +/// +/// Linux applications conventionally support both the Menu key and Shift+F10. +bool isBusyMarkContextMenuKeyEvent(KeyEvent event) { + if (event is! KeyDownEvent) { + return false; + } + return event.logicalKey == LogicalKeyboardKey.contextMenu || + (event.logicalKey == LogicalKeyboardKey.f10 && + HardwareKeyboard.instance.isShiftPressed); +} + class BusyMarkPopupSelectorOption { const BusyMarkPopupSelectorOption({ required this.value, diff --git a/lib/src/workspace/presentation/welcome_screen.dart b/lib/src/workspace/presentation/welcome_screen.dart index a4a07fe..eeb1df9 100644 --- a/lib/src/workspace/presentation/welcome_screen.dart +++ b/lib/src/workspace/presentation/welcome_screen.dart @@ -5,8 +5,10 @@ import 'package:file_selector/file_selector.dart'; import 'package:busymark/src/app/startup_path.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; import 'package:path/path.dart' as p; +import 'package:url_launcher/url_launcher.dart'; import 'package:yaru/yaru.dart'; import '../../app/app_settings.dart'; @@ -16,6 +18,7 @@ import '../../app/busymark_design.dart'; import '../../app/busymark_glyphs.dart'; import '../../app/busymark_main_menu.dart'; import '../../app/busymark_shortcuts.dart'; +import '../../app/busymark_toast.dart'; import '../../app/localization.dart'; import '../../app/window_control_service.dart'; import '../../core/debug_log.dart'; @@ -93,6 +96,7 @@ class _WelcomeScreenState extends ConsumerState { child: _WelcomeSidebar( recentWorkspaces: settings.recentWorkspaces, onOpenRecent: _openPath, + onRecentAction: _performRecentAction, ), ); final welcomeContent = Expanded( @@ -444,16 +448,58 @@ class _WelcomeScreenState extends ConsumerState { context.go('/workspace'); } } + + Future _performRecentAction( + RecentWorkspace recent, + _RecentWorkspaceAction action, + ) async { + switch (action) { + case _RecentWorkspaceAction.openInFiles: + final rawPath = recent.path.trim(); + if (rawPath.isEmpty) { + return; + } + final normalized = p.normalize(rawPath); + final target = + FileSystemEntity.typeSync(normalized) == + FileSystemEntityType.directory + ? normalized + : p.dirname(normalized); + final launched = await launchUrl( + Uri.file(target), + mode: LaunchMode.externalApplication, + ); + if (!launched && mounted) { + BusyMarkToastOverlay.show( + context, + message: context.l10n.couldNotOpenTarget(target), + priority: BusyMarkToastPriority.high, + ); + } + case _RecentWorkspaceAction.copyPath: + await Clipboard.setData(ClipboardData(text: recent.path)); + case _RecentWorkspaceAction.removeFromRecent: + await ref + .read(appSettingsControllerProvider.notifier) + .removeRecentWorkspace(recent.path); + } + } } class _WelcomeSidebar extends StatelessWidget { const _WelcomeSidebar({ required this.recentWorkspaces, required this.onOpenRecent, + required this.onRecentAction, }); final List recentWorkspaces; final Future Function(String path) onOpenRecent; + final Future Function( + RecentWorkspace recent, + _RecentWorkspaceAction action, + ) + onRecentAction; @override Widget build(BuildContext context) { @@ -473,6 +519,8 @@ class _WelcomeSidebar extends StatelessWidget { _WelcomeRecentRow( recent: recent, onTap: () => unawaited(onOpenRecent(recent.path)), + onAction: (action) => + unawaited(onRecentAction(recent, action)), ), ], ), @@ -510,28 +558,130 @@ class _WelcomeSidebarSection extends StatelessWidget { } } -class _WelcomeRecentRow extends StatelessWidget { - const _WelcomeRecentRow({required this.recent, required this.onTap}); +enum _RecentWorkspaceAction { openInFiles, copyPath, removeFromRecent } + +class _WelcomeRecentRow extends StatefulWidget { + const _WelcomeRecentRow({ + required this.recent, + required this.onTap, + required this.onAction, + }); final RecentWorkspace recent; final VoidCallback onTap; + final ValueChanged<_RecentWorkspaceAction> onAction; + + @override + State<_WelcomeRecentRow> createState() => _WelcomeRecentRowState(); +} + +class _WelcomeRecentRowState extends State<_WelcomeRecentRow> { + final _rowKey = GlobalKey(); + late final FocusNode _rowFocusNode; + var _contextMenuOpen = false; + + @override + void initState() { + super.initState(); + _rowFocusNode = FocusNode(debugLabel: 'BusyMark recent workspace row'); + } + + @override + void dispose() { + _rowFocusNode.dispose(); + super.dispose(); + } + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + if (!isBusyMarkContextMenuKeyEvent(event)) { + return KeyEventResult.ignored; + } + unawaited(_showContextMenu()); + return KeyEventResult.handled; + } + + Future _showContextMenu([Offset? position]) async { + if (_contextMenuOpen) { + return; + } + final rowContext = _rowKey.currentContext; + if (rowContext == null) { + return; + } + setState(() => _contextMenuOpen = true); + _RecentWorkspaceAction? action; + try { + final items = _recentWorkspaceMenuItems(rowContext); + action = position == null + ? await showBusyMarkMenu<_RecentWorkspaceAction>( + context: rowContext, + anchorContext: rowContext, + items: items, + focusFirst: true, + ) + : await showBusyMarkContextMenu<_RecentWorkspaceAction>( + rowContext, + position, + items: items, + ); + } finally { + if (mounted) { + setState(() => _contextMenuOpen = false); + } + } + if (mounted && action != null) { + widget.onAction(action); + } + } @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: BusyMarkStroke.hairline), - child: Material( - color: BusyMarkLinuxPalette.transparent, - borderRadius: BorderRadius.circular(BusyMarkRadius.md), - clipBehavior: Clip.antiAlias, - child: InkWell( - hoverColor: busyMarkRowHoverColor(context), - onTap: onTap, - child: WorkspaceIdentityRow( - height: BusyMarkSizes.sidebarTreeRowHeight * 2, - icon: WorkspaceGlyphs.forRecent(recent), - name: busyMarkLtrIsolateFor(context, _displayPath(recent.path)), - path: busyMarkLtrIsolateFor(context, recent.path), + final items = _recentWorkspaceMenuItems(context); + return KeyedSubtree( + key: _rowKey, + child: Focus( + onKeyEvent: _handleKeyEvent, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: BusyMarkStroke.hairline, + ), + child: Material( + color: _contextMenuOpen + ? busyMarkRowHoverColor(context) + : BusyMarkLinuxPalette.transparent, + borderRadius: BorderRadius.circular(BusyMarkRadius.md), + clipBehavior: Clip.antiAlias, + child: InkWell( + hoverColor: busyMarkRowHoverColor(context), + focusNode: _rowFocusNode, + onTap: widget.onTap, + onSecondaryTapUp: (details) { + _rowFocusNode.requestFocus(); + unawaited(_showContextMenu(details.globalPosition)); + }, + child: WorkspaceIdentityRow( + height: BusyMarkSizes.sidebarTreeRowHeight * 2, + icon: WorkspaceGlyphs.forRecent(widget.recent), + name: busyMarkLtrIsolateFor( + context, + _displayPath(widget.recent.path), + ), + path: busyMarkLtrIsolateFor(context, widget.recent.path), + trailing: BusyMarkMenuButton<_RecentWorkspaceAction>( + tooltip: context.l10n.actions, + items: items, + onSelected: widget.onAction, + triggerBuilder: (context, trigger) => trigger.anchor( + child: BusyMarkCompactIconButton( + tooltip: context.l10n.actions, + icon: BusyMarkGlyphs.menuVertical, + focusNode: trigger.focusNode, + onPressed: trigger.onPressed, + ), + ), + ), + ), + ), ), ), ), @@ -539,6 +689,29 @@ class _WelcomeRecentRow extends StatelessWidget { } } +List> _recentWorkspaceMenuItems( + BuildContext context, +) { + return [ + BusyMarkPopupMenuItem( + value: _RecentWorkspaceAction.openInFiles, + label: context.l10n.openInFiles, + icon: BusyMarkGlyphs.folderOpen, + ), + BusyMarkPopupMenuItem( + value: _RecentWorkspaceAction.copyPath, + label: context.l10n.copyPath, + icon: BusyMarkGlyphs.copy, + ), + const PopupMenuDivider(height: BusyMarkSpacing.sm), + BusyMarkPopupMenuItem( + value: _RecentWorkspaceAction.removeFromRecent, + label: context.l10n.removeFromRecent, + icon: BusyMarkGlyphs.clear, + ), + ]; +} + String _displayPath(String path) { final name = p.basename(path); return name.isEmpty ? path : name; diff --git a/lib/src/workspace/presentation/workspace_identity_row.dart b/lib/src/workspace/presentation/workspace_identity_row.dart index 9d45754..4f8c50f 100644 --- a/lib/src/workspace/presentation/workspace_identity_row.dart +++ b/lib/src/workspace/presentation/workspace_identity_row.dart @@ -10,6 +10,7 @@ class WorkspaceIdentityRow extends StatelessWidget { required this.path, this.height, this.horizontalPadding = BusyMarkSpacing.sm, + this.trailing, }); final IconData icon; @@ -17,6 +18,7 @@ class WorkspaceIdentityRow extends StatelessWidget { final String path; final double? height; final double horizontalPadding; + final Widget? trailing; @override Widget build(BuildContext context) { @@ -56,6 +58,10 @@ class WorkspaceIdentityRow extends StatelessWidget { ], ), ), + if (trailing != null) ...[ + const SizedBox(width: BusyMarkSpacing.xs), + trailing!, + ], SizedBox(width: horizontalPadding), ], ); diff --git a/test/src/app_settings_test.dart b/test/src/app_settings_test.dart index 1154786..799ac14 100644 --- a/test/src/app_settings_test.dart +++ b/test/src/app_settings_test.dart @@ -464,6 +464,51 @@ void main() { }, ); + test( + 'removing a recent workspace persists without affecting files', + () async { + final store = _MemorySettingsStore() + ..value = AppSettings.defaults() + .copyWith( + recentWorkspaces: [ + RecentWorkspace( + path: '/tmp/docs', + kind: WorkspaceKindForTest.singleMarkdown, + lastOpenedAt: DateTime(2026, 1, 2), + ), + RecentWorkspace( + path: '/tmp/other', + kind: WorkspaceKindForTest.singleMarkdown, + lastOpenedAt: DateTime(2026, 1, 1), + ), + ], + ) + .toJson(); + final container = ProviderContainer( + overrides: [localSettingsStoreProvider.overrideWithValue(store)], + ); + addTearDown(container.dispose); + final controller = container.read(appSettingsControllerProvider.notifier); + await controller.waitUntilLoaded(); + + await controller.removeRecentWorkspace('/tmp/docs/.'); + + expect( + container + .read(appSettingsControllerProvider) + .recentWorkspaces + .map((item) => item.path), + ['/tmp/other'], + ); + expect( + AppSettings.fromJson( + store.value, + ).recentWorkspaces.map((item) => item.path), + ['/tmp/other'], + ); + }, + ); + test('remote image permissions persist globally and per workspace', () async { final store = _MemorySettingsStore(); final container = ProviderContainer( @@ -548,28 +593,32 @@ void main() { skip: Platform.isWindows, ); - test('stored Git trust does not follow a replaced canonical path', () async { - final root = await Directory.systemTemp.createTemp( - 'busymark-stored-git-trust-', - ); - addTearDown(() async { - if (await root.exists()) { - await root.delete(recursive: true); - } - }); - final trustedPath = await Directory('${root.path}/trusted').create(); - final replacement = await Directory('${root.path}/replacement').create(); - final stored = AppSettings.defaults() - .copyWith(trustedGitWorkspacePaths: [trustedPath.path]) - .toJson(); - - await trustedPath.delete(); - await Link(trustedPath.path).create(replacement.path); - final reloaded = AppSettings.fromJson(stored); - - expect(reloaded.trustsGitWorkspace(trustedPath.path), isFalse); - expect(reloaded.trustedGitWorkspacePaths, [trustedPath.path]); - }, skip: Platform.isWindows); + test( + 'stored Git trust does not follow a replaced canonical path', + () async { + final root = await Directory.systemTemp.createTemp( + 'busymark-stored-git-trust-', + ); + addTearDown(() async { + if (await root.exists()) { + await root.delete(recursive: true); + } + }); + final trustedPath = await Directory('${root.path}/trusted').create(); + final replacement = await Directory('${root.path}/replacement').create(); + final stored = AppSettings.defaults() + .copyWith(trustedGitWorkspacePaths: [trustedPath.path]) + .toJson(); + + await trustedPath.delete(); + await Link(trustedPath.path).create(replacement.path); + final reloaded = AppSettings.fromJson(stored); + + expect(reloaded.trustsGitWorkspace(trustedPath.path), isFalse); + expect(reloaded.trustedGitWorkspacePaths, [trustedPath.path]); + }, + skip: Platform.isWindows, + ); test('Git trust preserves leading and trailing path whitespace', () async { final root = await Directory.systemTemp.createTemp( diff --git a/test/src/native_headerbar_audit_test.dart b/test/src/native_headerbar_audit_test.dart index 3dfc89c..b3d3ec5 100644 --- a/test/src/native_headerbar_audit_test.dart +++ b/test/src/native_headerbar_audit_test.dart @@ -1671,7 +1671,7 @@ void main() { expect(welcome, contains('_WelcomeSidebar')); expect(welcome, contains('_WelcomeRecentRow')); expect(welcome, contains('WorkspaceIdentityRow')); - expect(welcome, contains('WorkspaceGlyphs.forRecent(recent)')); + expect(welcome, contains('WorkspaceGlyphs.forRecent(widget.recent)')); expect(welcome, contains('BusyMarkGlyphs.markdownFile')); expect(welcome, contains('BusyMarkGlyphs.folder')); expect(welcome, contains('BusyMarkGlyphs.writersideProject')); From 120b69702b1fbd9bd2e010c238e99a682340f1b0 Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 28 Aug 2026 17:54:56 -0700 Subject: [PATCH 4/7] Add accessible sidebar row menus --- .../presentation/workspace_screen.dart | 435 +++++++++++------- test/src/app_smoke_test.dart | 165 ++++++- test/src/source_audit_test.dart | 32 +- 3 files changed, 462 insertions(+), 170 deletions(-) diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index 2c6839e..85cc911 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -3567,6 +3567,7 @@ class _FilesTabState extends ConsumerState<_FilesTab> { late String _workspaceId; late Set _expandedPaths; late final FocusNode _treeFocusNode; + final _selectedRowMenuKey = GlobalKey<_SidebarTreeRowState>(); String? _selectedPath; _FileTreeClipboardEntry? _cutEntry; @@ -3627,6 +3628,10 @@ class _FilesTabState extends ConsumerState<_FilesTab> { .shortcut! .activator: const _DeleteSelectedFileTreeEntryIntent(), + const SingleActivator(LogicalKeyboardKey.contextMenu): + const _ShowSelectedSidebarTreeMenuIntent(), + const SingleActivator(LogicalKeyboardKey.f10, shift: true): + const _ShowSelectedSidebarTreeMenuIntent(), }, child: Actions( actions: { @@ -3637,6 +3642,13 @@ class _FilesTabState extends ConsumerState<_FilesTab> { return null; }, ), + _ShowSelectedSidebarTreeMenuIntent: + CallbackAction<_ShowSelectedSidebarTreeMenuIntent>( + onInvoke: (_) { + _selectedRowMenuKey.currentState?.showMenuFromKeyboard(); + return null; + }, + ), }, child: Focus( focusNode: _treeFocusNode, @@ -3654,6 +3666,7 @@ class _FilesTabState extends ConsumerState<_FilesTab> { final menuName = node.name; final menuIsFolder = node.isFolder; final selectedHistoryFile = historyFile; + final selected = _sameOptionalPath(_selectedPath, menuPath); void selectEntry() { _treeFocusNode.requestFocus(); if (!_sameOptionalPath(_selectedPath, menuPath)) { @@ -3661,7 +3674,7 @@ class _FilesTabState extends ConsumerState<_FilesTab> { } } - void onSecondaryTapUp(TapUpDetails details) { + void showMenu(BuildContext _, Offset position) { selectEntry(); unawaited( _showFileContextMenu( @@ -3670,19 +3683,20 @@ class _FilesTabState extends ConsumerState<_FilesTab> { menuPath, menuIsFolder, selectedHistoryFile, - details.globalPosition, + position, ), ); } return _SidebarTreeRow( + key: selected ? _selectedRowMenuKey : null, title: busyMarkLtrIsolateFor(context, node.name), depth: entry.depth, icon: _fileTreeIcon(node, expanded: expanded), vcsColor: vcsStatusColors.colorForNode(node), hasChildren: node.isFolder && node.children.isNotEmpty, expanded: expanded, - selected: _sameOptionalPath(_selectedPath, menuPath), + selected: selected, enabled: node.isFolder || openable, onTap: node.isFolder ? () { @@ -3706,7 +3720,7 @@ class _FilesTabState extends ConsumerState<_FilesTab> { } } : null, - onSecondaryTapUp: onSecondaryTapUp, + onMenuRequested: showMenu, ); }, ), @@ -3956,6 +3970,10 @@ class _DeleteSelectedFileTreeEntryIntent extends Intent { const _DeleteSelectedFileTreeEntryIntent(); } +class _ShowSelectedSidebarTreeMenuIntent extends Intent { + const _ShowSelectedSidebarTreeMenuIntent(); +} + bool _isWritersideTopicFile(Workspace workspace, DocumentFile? file) { if (file == null) { return false; @@ -4242,7 +4260,10 @@ Future _confirmDeleteFileTreeEntry( return confirmed ?? false; } -class _SidebarTreeRow extends StatelessWidget { +typedef _SidebarTreeMenuRequest = + void Function(BuildContext anchorContext, Offset globalPosition); + +class _SidebarTreeRow extends StatefulWidget { const _SidebarTreeRow({ super.key, required this.title, @@ -4257,7 +4278,7 @@ class _SidebarTreeRow extends StatelessWidget { this.vcsColor, this.onToggle, this.onTap, - this.onSecondaryTapUp, + this.onMenuRequested, }); final String title; @@ -4272,95 +4293,164 @@ class _SidebarTreeRow extends StatelessWidget { final BusyMarkVcsFileColor? vcsColor; final VoidCallback? onToggle; final VoidCallback? onTap; - final GestureTapUpCallback? onSecondaryTapUp; + final _SidebarTreeMenuRequest? onMenuRequested; + + @override + State<_SidebarTreeRow> createState() => _SidebarTreeRowState(); +} + +class _SidebarTreeRowState extends State<_SidebarTreeRow> { + var _hovered = false; + var _focused = false; + + void showMenuFromKeyboard() { + final onMenuRequested = widget.onMenuRequested; + if (!widget.enabled || onMenuRequested == null) { + return; + } + onMenuRequested(context, _sidebarTreeMenuAnchor(context)); + } + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + if (!widget.enabled || + widget.onMenuRequested == null || + !isBusyMarkContextMenuKeyEvent(event)) { + return KeyEventResult.ignored; + } + showMenuFromKeyboard(); + return KeyEventResult.handled; + } @override Widget build(BuildContext context) { final colors = BusyMarkSurfaceColors.of(context); final direction = Directionality.of(context); - final clickable = enabled && (onTap != null || onSecondaryTapUp != null); - final vcsForeground = vcsColor == null + final clickable = + widget.enabled && + (widget.onTap != null || widget.onMenuRequested != null); + final vcsForeground = widget.vcsColor == null ? null - : busyMarkVcsFileStatusColor(context, vcsColor!); - final foreground = !enabled || muted + : busyMarkVcsFileStatusColor(context, widget.vcsColor!); + final foreground = !widget.enabled || widget.muted ? colors.disabledForeground : vcsForeground ?? - (selected ? colors.foreground : colors.mutedForeground); - final titleColor = !enabled || muted + (widget.selected ? colors.foreground : colors.mutedForeground); + final titleColor = !widget.enabled || widget.muted ? colors.disabledForeground : vcsForeground ?? colors.foreground; final titleStyle = Theme.of( context, ).textTheme.bodyMedium?.copyWith(color: titleColor); - return Padding( - padding: const EdgeInsets.symmetric(vertical: BusyMarkStroke.hairline), - child: Material( - color: selected - ? busyMarkSelectedBackground(context) - : BusyMarkLinuxPalette.transparent, - borderRadius: BorderRadius.circular(BusyMarkRadius.md), - clipBehavior: Clip.antiAlias, - child: InkWell( - hoverColor: clickable - ? busyMarkRowHoverColor(context) + final menuVisible = + widget.enabled && + widget.onMenuRequested != null && + (widget.selected || _hovered || _focused); + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: BusyMarkStroke.hairline), + child: Material( + color: widget.selected + ? busyMarkSelectedBackground(context) : BusyMarkLinuxPalette.transparent, - onTap: enabled ? onTap : null, - onSecondaryTapUp: enabled ? onSecondaryTapUp : null, - child: SizedBox( - height: BusyMarkSizes.sidebarTreeRowHeight, - child: Row( - children: [ - SizedBox( - width: - BusyMarkSizes.sidebarTreeDepthBase + - depth * BusyMarkSizes.sidebarTreeDepthIndent, - ), - SizedBox.square( - dimension: BusyMarkSizes.sidebarTreeControl, - child: hasChildren - ? GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: enabled ? onToggle ?? onTap : null, - child: AnimatedRotation( - turns: expanded - ? direction == TextDirection.rtl - ? -0.25 - : 0.25 - : 0, - duration: BusyMarkMotion.sidebarExpand, - child: Icon( - BusyMarkGlyphs.collapsedTreeArrowFor(direction), - size: BusyMarkSizes.sidebarTreeArrow, + borderRadius: BorderRadius.circular(BusyMarkRadius.md), + clipBehavior: Clip.antiAlias, + child: Focus( + onKeyEvent: _handleKeyEvent, + child: InkWell( + hoverColor: clickable + ? busyMarkRowHoverColor(context) + : BusyMarkLinuxPalette.transparent, + onTap: widget.enabled ? widget.onTap : null, + onSecondaryTapUp: widget.enabled + ? widget.onMenuRequested == null + ? null + : (details) => widget.onMenuRequested!( + context, + details.globalPosition, + ) + : null, + onFocusChange: (focused) => setState(() => _focused = focused), + child: SizedBox( + height: BusyMarkSizes.sidebarTreeRowHeight, + child: Row( + children: [ + SizedBox( + width: + BusyMarkSizes.sidebarTreeDepthBase + + widget.depth * BusyMarkSizes.sidebarTreeDepthIndent, + ), + SizedBox.square( + dimension: BusyMarkSizes.sidebarTreeControl, + child: widget.hasChildren + ? GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.enabled + ? widget.onToggle ?? widget.onTap + : null, + child: AnimatedRotation( + turns: widget.expanded + ? direction == TextDirection.rtl + ? -0.25 + : 0.25 + : 0, + duration: BusyMarkMotion.sidebarExpand, + child: Icon( + BusyMarkGlyphs.collapsedTreeArrowFor( + direction, + ), + size: BusyMarkSizes.sidebarTreeArrow, + color: foreground, + ), + ), + ) + : const SizedBox.shrink(), + ), + const SizedBox(width: BusyMarkSpacing.xs), + SizedBox.square( + dimension: BusyMarkSizes.sidebarTreeControl, + child: Center( + child: + widget.leading ?? + Icon( + widget.icon, + size: BusyMarkSizes.iconSm, color: foreground, ), - ), - ) - : const SizedBox.shrink(), - ), - const SizedBox(width: BusyMarkSpacing.xs), - SizedBox.square( - dimension: BusyMarkSizes.sidebarTreeControl, - child: Center( - child: - leading ?? - Icon( - icon, - size: BusyMarkSizes.iconSm, - color: foreground, - ), - ), - ), - const SizedBox(width: BusyMarkSpacing.sm), - Expanded( - child: Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: titleStyle, - ), + ), + ), + const SizedBox(width: BusyMarkSpacing.sm), + Expanded( + child: Text( + widget.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: titleStyle, + ), + ), + if (widget.onMenuRequested != null) + SizedBox.square( + dimension: BusyMarkSizes.compactIconButton, + child: menuVisible + ? Builder( + builder: (buttonContext) => + BusyMarkCompactIconButton( + tooltip: context.l10n.actions, + icon: BusyMarkGlyphs.menuVertical, + onPressed: () => widget.onMenuRequested!( + buttonContext, + _sidebarTreeMenuAnchor(buttonContext), + ), + ), + ) + : const SizedBox.shrink(), + ) + else + const SizedBox(width: BusyMarkSpacing.xs), + ], ), - const SizedBox(width: BusyMarkSpacing.xs), - ], + ), ), ), ), @@ -4369,6 +4459,20 @@ class _SidebarTreeRow extends StatelessWidget { } } +Offset _sidebarTreeMenuAnchor(BuildContext context) { + final renderObject = context.findRenderObject(); + if (renderObject is! RenderBox || !renderObject.hasSize) { + return Offset.zero; + } + final direction = Directionality.of(context); + return renderObject.localToGlobal( + Offset( + direction == TextDirection.rtl ? 0 : renderObject.size.width, + renderObject.size.height, + ), + ); +} + IconData _fileTreeIcon(_FileTreeNode node, {required bool expanded}) { if (node.isFolder) { return expanded ? YaruIcons.folder_open : YaruIcons.folder; @@ -4689,6 +4793,7 @@ class _TocTabState extends ConsumerState<_TocTab> { String? _selectedInstanceTreePath; late Set _expandedNodeKeys; late final FocusNode _treeFocusNode; + final _selectedRowMenuKey = GlobalKey<_SidebarTreeRowState>(); String? _selectedNodePathKey; Set _selectedNodePathKeys = {}; String? _selectionAnchorPathKey; @@ -4852,6 +4957,10 @@ class _TocTabState extends ConsumerState<_TocTab> { .shortcut! .activator: const _RemoveSelectedTocEntryIntent(), + const SingleActivator(LogicalKeyboardKey.contextMenu): + const _ShowSelectedSidebarTreeMenuIntent(), + const SingleActivator(LogicalKeyboardKey.f10, shift: true): + const _ShowSelectedSidebarTreeMenuIntent(), }, child: Actions( actions: { @@ -4883,6 +4992,13 @@ class _TocTabState extends ConsumerState<_TocTab> { return null; }, ), + _ShowSelectedSidebarTreeMenuIntent: + CallbackAction<_ShowSelectedSidebarTreeMenuIntent>( + onInvoke: (_) { + _selectedRowMenuKey.currentState?.showMenuFromKeyboard(); + return null; + }, + ), }, child: Focus( focusNode: _treeFocusNode, @@ -4961,6 +5077,11 @@ class _TocTabState extends ConsumerState<_TocTab> { final topicPath = writersideTopic?.filePath; final rawLabel = _tocNodeLabel(context, node); final label = _tocNodeDisplayLabel(context, node); + final selected = _selectedNodePathKeys.isNotEmpty + ? _selectedNodePathKeys.contains(entry.pathKey) + : _selectedNodePathKey == null + ? topicPath == widget.workspace.activeFilePath + : entry.pathKey == _selectedNodePathKey; _TreeSelectionModifiers selectEntry() { _treeFocusNode.requestFocus(); final modifiers = _treeSelectionModifiers(); @@ -4989,83 +5110,84 @@ class _TocTabState extends ConsumerState<_TocTab> { }); } - return _SidebarTreeRow( + void showMenu(BuildContext _, Offset position) { + _treeFocusNode.requestFocus(); + if (!_selectedNodePathKeys.contains(entry.pathKey)) { + setState(() { + _selectedNodePathKey = entry.pathKey; + _selectedNodePathKeys = {entry.pathKey}; + _selectionAnchorPathKey = entry.pathKey; + }); + } + final selectedEntries = [ + for (final item in entries) + if (_selectedNodePathKeys.contains(item.pathKey)) item, + ]; + unawaited( + _showTopicContextMenu( + context, + instanceTreePath: instance.sourceTreePath, + entry: entry, + selectedEntries: selectedEntries.isEmpty + ? [entry] + : selectedEntries, + topic: writersideTopic, + rawLabel: rawLabel, + canEditStructure: + node.canEditStructure && + p.equals(node.sourceTreePath!, instance.sourceTreePath), + position: position, + ), + ); + } + + return KeyedSubtree( key: ValueKey('workspace-sidebar-toc-row-${entry.pathKey}'), - title: label, - enabled: true, - selected: _selectedNodePathKeys.isNotEmpty - ? _selectedNodePathKeys.contains(entry.pathKey) - : _selectedNodePathKey == null - ? topicPath == widget.workspace.activeFilePath - : entry.pathKey == _selectedNodePathKey, - depth: entry.depth, - icon: node.includeResolutionError != null - ? BusyMarkGlyphs.error - : node.workInProgress - ? BusyMarkGlyphs.warning - : node.href != null - ? BusyMarkGlyphs.externalLink - : BusyMarkGlyphs.document, - hasChildren: hasChildren, - expanded: expanded, - muted: node.hidden, - onToggle: hasChildren ? toggle : null, - onTap: topicPath != null - ? () async { - final modifiers = selectEntry(); - if (modifiers.control || modifiers.shift) { - return; - } - await ref - .read(workspaceControllerProvider.notifier) - .openActiveFile(topicPath); - if (mounted) { - _clearGitDetailSelection(ref); + child: _SidebarTreeRow( + key: entry.pathKey == _selectedNodePathKey + ? _selectedRowMenuKey + : null, + title: label, + enabled: true, + selected: selected, + depth: entry.depth, + icon: node.includeResolutionError != null + ? BusyMarkGlyphs.error + : node.workInProgress + ? BusyMarkGlyphs.warning + : node.href != null + ? BusyMarkGlyphs.externalLink + : BusyMarkGlyphs.document, + hasChildren: hasChildren, + expanded: expanded, + muted: node.hidden, + onToggle: hasChildren ? toggle : null, + onTap: topicPath != null + ? () async { + final modifiers = selectEntry(); + if (modifiers.control || modifiers.shift) { + return; + } + await ref + .read(workspaceControllerProvider.notifier) + .openActiveFile(topicPath); + if (mounted) { + _clearGitDetailSelection(ref); + } } - } - : hasChildren - ? () { - final modifiers = selectEntry(); - if (modifiers.control || modifiers.shift) { - return; + : hasChildren + ? () { + final modifiers = selectEntry(); + if (modifiers.control || modifiers.shift) { + return; + } + toggle(); } - toggle(); - } - : () { - selectEntry(); - }, - onSecondaryTapUp: (details) { - if (!_selectedNodePathKeys.contains(entry.pathKey)) { - setState(() { - _selectedNodePathKey = entry.pathKey; - _selectedNodePathKeys = {entry.pathKey}; - _selectionAnchorPathKey = entry.pathKey; - }); - } - final selectedEntries = [ - for (final item in entries) - if (_selectedNodePathKeys.contains(item.pathKey)) item, - ]; - unawaited( - _showTopicContextMenu( - context, - instanceTreePath: instance.sourceTreePath, - entry: entry, - selectedEntries: selectedEntries.isEmpty - ? [entry] - : selectedEntries, - topic: writersideTopic, - rawLabel: rawLabel, - canEditStructure: - node.canEditStructure && - p.equals( - node.sourceTreePath!, - instance.sourceTreePath, - ), - position: details.globalPosition, - ), - ); - }, + : () { + selectEntry(); + }, + onMenuRequested: showMenu, + ), ); }, ), @@ -7370,13 +7492,8 @@ class _OutlineTabState extends ConsumerState<_OutlineTab> { ), ); }, - onSecondaryTapUp: (details) => unawaited( - _showSectionMenu( - entries, - headingIndexes, - index, - details.globalPosition, - ), + onMenuRequested: (anchorContext, position) => unawaited( + _showSectionMenu(entries, headingIndexes, index, position), ), ), ); diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index 10d1baa..6c60307 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -2379,6 +2379,29 @@ void main() { expect(gitController.stagedPaths, ['README.md']); + final readmeRow = find + .ancestor( + of: find.text('README.md').first, + matching: find.byType(InkWell), + ) + .first; + final readmeActions = find.descendant( + of: readmeRow, + matching: find.byTooltip(l10n.actions), + ); + expect(readmeActions, findsOneWidget); + await tester.tap(readmeActions); + await tester.pumpAndSettle(); + expect(find.text(l10n.fileHistory), findsOneWidget); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + await tester.sendKeyEvent(LogicalKeyboardKey.contextMenu); + await tester.pumpAndSettle(); + expect(find.text(l10n.fileHistory), findsOneWidget); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + await tester.tap( find.text('README.md').first, buttons: kSecondaryMouseButton, @@ -2772,9 +2795,13 @@ void main() { ); expect(outlineFileMenu, findsOneWidget); expect(find.byTooltip(first.path), findsOneWidget); - expect(find.byTooltip(l10n.actions), findsOneWidget); + final outlineFileActions = find.descendant( + of: outlineFileMenu, + matching: find.byTooltip(l10n.actions), + ); + expect(outlineFileActions, findsOneWidget); - await tester.tap(find.byTooltip(l10n.actions)); + await tester.tap(outlineFileActions); await tester.pumpAndSettle(); expect(find.text(l10n.copyFileName), findsOneWidget); expect(find.text(l10n.copyPath), findsOneWidget); @@ -2785,7 +2812,7 @@ void main() { await tester.pumpAndSettle(); expect(clipboardText, 'Intro.md'); - await tester.tap(find.byTooltip(l10n.actions)); + await tester.tap(outlineFileActions); await tester.pumpAndSettle(); await tester.tap(find.text(l10n.copyPath)); await tester.pumpAndSettle(); @@ -2988,6 +3015,22 @@ void main() { ); } } + + await selectView(LogicalKeyboardKey.digit2); + final selectedTopicActions = find.byTooltip(l10n.actions); + expect(selectedTopicActions, findsOneWidget); + await tester.tap(selectedTopicActions); + await tester.pumpAndSettle(); + expect(find.text(l10n.newSiblingTopic), findsOneWidget); + expect(find.text(l10n.copyPath), findsOneWidget); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + await tester.sendKeyEvent(LogicalKeyboardKey.contextMenu); + await tester.pumpAndSettle(); + expect(find.text(l10n.newSiblingTopic), findsOneWidget); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); }); testWidgets('Files view colors entries by Git status', (tester) async { @@ -5143,11 +5186,17 @@ After break. expect(find.text(l10n.openMarkdownFile), findsNothing); expect(find.textContaining('Basic Markdown'), findsWidgets); expect(find.byTooltip(startupPath), findsOneWidget); + final outlineFileMenu = find.byKey( + const ValueKey('workspace-sidebar-outline-file-menu'), + ); + expect(outlineFileMenu, findsOneWidget); expect( - find.byKey(const ValueKey('workspace-sidebar-outline-file-menu')), + find.descendant( + of: outlineFileMenu, + matching: find.byTooltip(l10n.actions), + ), findsOneWidget, ); - expect(find.byTooltip(l10n.actions), findsOneWidget); final primarySidebarLabel = find.descendant( of: find.byKey(const ValueKey('workspace-sidebar-primary-label')), matching: find.byType(Text), @@ -5592,7 +5641,9 @@ Gamma body. Color outlineRowColor(int index) { final material = find.descendant( of: outlineRow(index), - matching: find.byType(Material), + matching: find.byWidgetPredicate( + (widget) => widget is Material && widget.type == MaterialType.canvas, + ), ); expect(material, findsOneWidget); return tester.widget(material).color ?? Colors.transparent; @@ -6158,6 +6209,108 @@ Draft paragraph. expect(find.text(l10n.openMarkdownFile), findsNothing); }); + testWidgets('recent workspace menus support pointer and keyboard actions', ( + tester, + ) async { + const recentPath = '/tmp/busymark-recent-actions'; + 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( + recentWorkspaces: [ + RecentWorkspace( + path: recentPath, + kind: 'markdownFolder', + lastOpenedAt: DateTime(2026, 1, 2), + ), + ], + ) + .toJson(); + final container = ProviderContainer( + overrides: [ + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue(settingsStore), + startupPathProvider.overrideWithValue(null), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + await container + .read(appSettingsControllerProvider.notifier) + .waitUntilLoaded(); + await tester.pumpAndSettle(); + + final recentLabel = find.text('busymark-recent-actions'); + expect(recentLabel, findsOneWidget); + final recentRow = find + .ancestor(of: recentLabel, matching: find.byType(InkWell)) + .first; + final menuButton = find.descendant( + of: recentRow, + matching: find.byTooltip(l10n.actions), + ); + expect(menuButton, findsOneWidget); + + await tester.tap(menuButton); + await tester.pumpAndSettle(); + expect(find.text(l10n.openInFiles), findsOneWidget); + expect(find.text(l10n.copyPath), findsOneWidget); + expect(find.text(l10n.removeFromRecent), findsOneWidget); + + await tester.tap(find.text(l10n.copyPath)); + await tester.pumpAndSettle(); + expect(clipboardText, recentPath); + + await tester.tap(recentRow, buttons: kSecondaryMouseButton); + await tester.pumpAndSettle(); + expect(find.text(l10n.removeFromRecent), findsOneWidget); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.f10); + await tester.sendKeyUpEvent(LogicalKeyboardKey.f10); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.pumpAndSettle(); + expect(find.text(l10n.removeFromRecent), findsOneWidget); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + await tester.tap(recentRow, buttons: kSecondaryMouseButton); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.removeFromRecent)); + await tester.pumpAndSettle(); + + expect(recentLabel, findsNothing); + expect( + container.read(appSettingsControllerProvider).recentWorkspaces, + isEmpty, + ); + expect(AppSettings.fromJson(settingsStore.value).recentWorkspaces, isEmpty); + }); + testWidgets('shared Markdown image renderer resolves local images', ( tester, ) async { diff --git a/test/src/source_audit_test.dart b/test/src/source_audit_test.dart index eff822d..d1482c4 100644 --- a/test/src/source_audit_test.dart +++ b/test/src/source_audit_test.dart @@ -1348,10 +1348,7 @@ void main() { expect(workspace, contains('_visibleOutlineTreeEntries')); expect(workspace, contains('onToggle: hasChildren ? toggle : null')); expect(workspace, contains('AnimatedRotation')); - expect( - workspace, - contains('BusyMarkGlyphs.collapsedTreeArrowFor(direction)'), - ); + expect(workspace, contains('BusyMarkGlyphs.collapsedTreeArrowFor(')); expect(workspace, contains('YaruIcons.folder_open')); expect(workspace, contains('YaruIcons.folder')); expect(workspace, contains('busyMarkRowHoverColor(context)')); @@ -1359,7 +1356,7 @@ void main() { expect(workspace, contains('vcsColor: vcsStatusColors.colorForNode(node)')); expect( workspace, - contains('busyMarkVcsFileStatusColor(context, vcsColor!)'), + contains('busyMarkVcsFileStatusColor(context, widget.vcsColor!)'), ); expect(workspace, contains('busyMarkVcsFileColorForGitStatus(status)')); expect(workspace, contains('_isOpenableTextDocument(file)')); @@ -1367,6 +1364,11 @@ void main() { expect(workspace, contains('openActiveFile(file.absolutePath)')); expect(workspace, contains('_showFileTreeMenu')); expect(workspace, contains('onSecondaryTapUp')); + expect(workspace, contains('onMenuRequested')); + expect(workspace, contains('_ShowSelectedSidebarTreeMenuIntent')); + expect(workspace, contains('LogicalKeyboardKey.contextMenu')); + expect(workspace, contains('LogicalKeyboardKey.f10, shift: true')); + expect(workspace, contains('BusyMarkGlyphs.menuVertical')); expect( RegExp( r'BusyMarkCommandIds\s*\.treeDeleteSelection', @@ -1440,6 +1442,26 @@ void main() { expect(workspace, isNot(contains('subtitle: _documentKindLabel'))); }); + test('recent workspaces expose one native menu action model', () { + final welcome = File( + 'lib/src/workspace/presentation/welcome_screen.dart', + ).readAsStringSync(); + final settings = File('lib/src/app/app_settings.dart').readAsStringSync(); + + expect(welcome, contains('enum _RecentWorkspaceAction')); + expect(welcome, contains('_recentWorkspaceMenuItems')); + expect(welcome, contains('BusyMarkMenuButton<_RecentWorkspaceAction>')); + expect( + welcome, + contains('showBusyMarkContextMenu<_RecentWorkspaceAction>'), + ); + expect(welcome, contains('isBusyMarkContextMenuKeyEvent(event)')); + expect(welcome, contains('label: context.l10n.openInFiles')); + expect(welcome, contains('label: context.l10n.copyPath')); + expect(welcome, contains('label: context.l10n.removeFromRecent')); + expect(settings, contains('removeRecentWorkspace(String path)')); + }); + test('outline tree drives source and preview heading navigation', () { final workspace = File( 'lib/src/workspace/presentation/workspace_screen.dart', From 98c824554c71da7a4d6d9bcf11d630040f59b59f Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 28 Aug 2026 18:03:07 -0700 Subject: [PATCH 5/7] Restore Flutter 3.47 dependency lock --- pubspec.lock | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 16254ae..95ba64d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -409,10 +409,10 @@ packages: dependency: "direct main" description: name: intl - sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" url: "https://pub.dev" source: hosted - version: "0.20.2" + version: "0.20.3" io: dependency: transitive description: @@ -497,10 +497,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.20" material_color_utilities: dependency: transitive description: @@ -521,10 +521,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.0" mime: dependency: transitive description: @@ -862,26 +862,26 @@ packages: dependency: transitive description: name: test - sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" + sha256: ca578dc12bb8b2f40b67b7d3bd2fac4f31c01a6ff7130a14e2597b919934507f url: "https://pub.dev" source: hosted - version: "1.31.0" + version: "1.31.1" test_api: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" test_core: dependency: transitive description: name: test_core - sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" + sha256: d2e98ec12998368dc59ddd47ab709f2cd55acd6b66dc7db764455a44082f4bc5 url: "https://pub.dev" source: hosted - version: "0.6.17" + version: "0.6.18" typed_data: dependency: transitive description: @@ -998,10 +998,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.2" vm_service: dependency: transitive description: From 1e29aff08fcb5666790dc6a4a59594037fd78fe4 Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 28 Aug 2026 18:03:15 -0700 Subject: [PATCH 6/7] Format CI test sources --- test/src/busymark_dialogs_test.dart | 8 +- test/src/document_persistence_test.dart | 26 +-- .../git/git_cli_gateway_integration_test.dart | 141 ++++++++-------- test/src/git/git_controller_test.dart | 76 +++++---- test/src/git/git_process_runner_test.dart | 60 +++---- test/src/workspace_controller_test.dart | 58 ++++--- test/src/writerside_project_creator_test.dart | 47 +++--- test/src/writerside_test.dart | 102 ++++++------ test/src/writerside_toc_editor_test.dart | 82 +++++----- test/src/writerside_topic_creator_test.dart | 41 +++-- .../writerside_topic_file_editor_test.dart | 48 +++--- ...wysiwyg_visualization_diagnostic_test.dart | 154 +++++++++--------- 12 files changed, 454 insertions(+), 389 deletions(-) diff --git a/test/src/busymark_dialogs_test.dart b/test/src/busymark_dialogs_test.dart index e8bfe36..b9bc9c3 100644 --- a/test/src/busymark_dialogs_test.dart +++ b/test/src/busymark_dialogs_test.dart @@ -233,9 +233,11 @@ void main() { final release = releaseBusyMarkModalBarrier(headerBar); await tester.pump(); - expect(transitions, [ - 1, - ], reason: 'the native hide must wait for the in-flight native show'); + expect( + transitions, + [1], + reason: 'the native hide must wait for the in-flight native show', + ); firstUpdate.complete(); await Future.wait([acquire, release]); diff --git a/test/src/document_persistence_test.dart b/test/src/document_persistence_test.dart index 5f2b089..b2522a3 100644 --- a/test/src/document_persistence_test.dart +++ b/test/src/document_persistence_test.dart @@ -175,17 +175,21 @@ void main() { }, ); - test('recovery state is written with private POSIX permissions', () async { - final directory = await Directory.systemTemp.createTemp( - 'busymark-recovery-permissions-', - ); - addTearDown(() => directory.delete(recursive: true)); - final path = p.join(directory.path, 'recovery.json'); - final store = JsonDocumentRecoveryStore(filePathOverride: path); + test( + 'recovery state is written with private POSIX permissions', + () async { + final directory = await Directory.systemTemp.createTemp( + 'busymark-recovery-permissions-', + ); + addTearDown(() => directory.delete(recursive: true)); + final path = p.join(directory.path, 'recovery.json'); + final store = JsonDocumentRecoveryStore(filePathOverride: path); - await store.writeEntries(const []); + await store.writeEntries(const []); - expect((await File(path).stat()).mode & 0x1ff, 0x180); - expect((await directory.stat()).mode & 0x1ff, 0x1c0); - }, skip: Platform.isWindows ? 'POSIX permissions only.' : false); + expect((await File(path).stat()).mode & 0x1ff, 0x180); + expect((await directory.stat()).mode & 0x1ff, 0x1c0); + }, + skip: Platform.isWindows ? 'POSIX permissions only.' : false, + ); } diff --git a/test/src/git/git_cli_gateway_integration_test.dart b/test/src/git/git_cli_gateway_integration_test.dart index 3f1c199..899f18c 100644 --- a/test/src/git/git_cli_gateway_integration_test.dart +++ b/test/src/git/git_cli_gateway_integration_test.dart @@ -937,77 +937,88 @@ void main() { skip: Platform.isWindows, ); - test('diff APIs do not run a repository-configured textconv', () async { - if (!await _gitAvailable()) { - markTestSkipped('Git executable is unavailable.'); - return; - } - final fixture = await _createTextconvFixture(); - final sentinel = File('${fixture.probe.path}.ran'); + test( + 'diff APIs do not run a repository-configured textconv', + () async { + if (!await _gitAvailable()) { + markTestSkipped('Git executable is unavailable.'); + return; + } + final fixture = await _createTextconvFixture(); + final sentinel = File('${fixture.probe.path}.ran'); - await _git(fixture.root.path, ['diff', '--textconv', '--', 'README.md']); - expect( - await sentinel.exists(), - isTrue, - reason: 'The textconv probe must execute without the mitigation.', - ); - await sentinel.delete(); + await _git(fixture.root.path, ['diff', '--textconv', '--', 'README.md']); + expect( + await sentinel.exists(), + isTrue, + reason: 'The textconv probe must execute without the mitigation.', + ); + await sentinel.delete(); - final fileDiff = await fixture.gateway.diffFile( - fixture.info, - 'README.md', - staged: false, - ); - expect(fileDiff.rawPatch, contains('Working tree change.')); - expect( - await sentinel.exists(), - isFalse, - reason: 'Git diffFile must disable repository textconv commands.', - ); + final fileDiff = await fixture.gateway.diffFile( + fixture.info, + 'README.md', + staged: false, + ); + expect(fileDiff.rawPatch, contains('Working tree change.')); + expect( + await sentinel.exists(), + isFalse, + reason: 'Git diffFile must disable repository textconv commands.', + ); - final allDiff = await fixture.gateway.diffAll(fixture.info, staged: false); - expect(allDiff.rawPatch, contains('Working tree change.')); - expect( - await sentinel.exists(), - isFalse, - reason: 'Git diffAll must disable repository textconv commands.', - ); - }, skip: Platform.isWindows); + final allDiff = await fixture.gateway.diffAll( + fixture.info, + staged: false, + ); + expect(allDiff.rawPatch, contains('Working tree change.')); + expect( + await sentinel.exists(), + isFalse, + reason: 'Git diffAll must disable repository textconv commands.', + ); + }, + skip: Platform.isWindows, + ); - test('commit details do not run a repository-configured textconv', () async { - if (!await _gitAvailable()) { - markTestSkipped('Git executable is unavailable.'); - return; - } - final fixture = await _createTextconvFixture(); - final sentinel = File('${fixture.probe.path}.ran'); - - await _git(fixture.root.path, [ - 'show', - '--textconv', - '--format=', - '--patch', - fixture.commitHash, - ]); - expect( - await sentinel.exists(), - isTrue, - reason: 'The textconv probe must execute for raw Git show.', - ); - await sentinel.delete(); + test( + 'commit details do not run a repository-configured textconv', + () async { + if (!await _gitAvailable()) { + markTestSkipped('Git executable is unavailable.'); + return; + } + final fixture = await _createTextconvFixture(); + final sentinel = File('${fixture.probe.path}.ran'); + + await _git(fixture.root.path, [ + 'show', + '--textconv', + '--format=', + '--patch', + fixture.commitHash, + ]); + expect( + await sentinel.exists(), + isTrue, + reason: 'The textconv probe must execute for raw Git show.', + ); + await sentinel.delete(); - final details = await fixture.gateway.commitDetails( - fixture.info, - fixture.commitHash, - ); + final details = await fixture.gateway.commitDetails( + fixture.info, + fixture.commitHash, + ); - expect(details.patch, contains('Committed change.')); - expect( - await sentinel.exists(), - isFalse, - reason: 'Git show must disable repository textconv commands.', - ); - }, skip: Platform.isWindows); + expect(details.patch, contains('Committed change.')); + expect( + await sentinel.exists(), + isFalse, + reason: 'Git show must disable repository textconv commands.', + ); + }, + skip: Platform.isWindows, + ); } Future _createRepository(String prefix) async { diff --git a/test/src/git/git_controller_test.dart b/test/src/git/git_controller_test.dart index d814064..b5e2986 100644 --- a/test/src/git/git_controller_test.dart +++ b/test/src/git/git_controller_test.dart @@ -206,46 +206,50 @@ void main() { ); }); - test('Git executes with the canonical path that was trusted', () async { - final root = await Directory.systemTemp.createTemp( - 'busymark-controller-git-trust-', - ); - addTearDown(() async { - if (await root.exists()) { - await root.delete(recursive: true); - } - }); - final trusted = await Directory('${root.path}/trusted').create(); - final replacement = await Directory('${root.path}/replacement').create(); - final workspaceLink = Link('${root.path}/workspace'); - await workspaceLink.create(trusted.path); - final gateway = _TrustRequiredFakeGitGateway(); - final container = _container(gateway); - await container - .read(appSettingsControllerProvider.notifier) - .trustGitWorkspace(workspaceLink.path); - final controller = container.read(gitControllerProvider.notifier); + test( + 'Git executes with the canonical path that was trusted', + () async { + final root = await Directory.systemTemp.createTemp( + 'busymark-controller-git-trust-', + ); + addTearDown(() async { + if (await root.exists()) { + await root.delete(recursive: true); + } + }); + final trusted = await Directory('${root.path}/trusted').create(); + final replacement = await Directory('${root.path}/replacement').create(); + final workspaceLink = Link('${root.path}/workspace'); + await workspaceLink.create(trusted.path); + final gateway = _TrustRequiredFakeGitGateway(); + final container = _container(gateway); + await container + .read(appSettingsControllerProvider.notifier) + .trustGitWorkspace(workspaceLink.path); + final controller = container.read(gitControllerProvider.notifier); - controller.attachWorkspace( - _workspace(id: workspaceLink.path, rootPath: workspaceLink.path), - ); - await controller.refresh(); + controller.attachWorkspace( + _workspace(id: workspaceLink.path, rootPath: workspaceLink.path), + ); + await controller.refresh(); - expect(gateway.lastDetectedWorkspacePath, trusted.path); - await controller.initializeRepository(); - expect(gateway.lastInitializeRootPath, trusted.path); - final trustedDetectCalls = gateway.detectCalls; - await workspaceLink.delete(); - await workspaceLink.create(replacement.path); + expect(gateway.lastDetectedWorkspacePath, trusted.path); + await controller.initializeRepository(); + expect(gateway.lastInitializeRootPath, trusted.path); + final trustedDetectCalls = gateway.detectCalls; + await workspaceLink.delete(); + await workspaceLink.create(replacement.path); - await controller.refresh(); + await controller.refresh(); - expect(gateway.detectCalls, trustedDetectCalls); - expect( - container.read(gitControllerProvider).requiresWorkspaceTrust, - isTrue, - ); - }, skip: Platform.isWindows); + expect(gateway.detectCalls, trustedDetectCalls); + expect( + container.read(gitControllerProvider).requiresWorkspaceTrust, + isTrue, + ); + }, + skip: Platform.isWindows, + ); test('stage and unstage update state', () async { final gateway = _FakeGitGateway(); diff --git a/test/src/git/git_process_runner_test.dart b/test/src/git/git_process_runner_test.dart index e7f80d4..e9b0d05 100644 --- a/test/src/git/git_process_runner_test.dart +++ b/test/src/git/git_process_runner_test.dart @@ -118,34 +118,38 @@ void main() { expect(gitEnvironmentForSnap(''), isEmpty); }); - test('snap launcher never falls back to the confined host setsid', () async { - final snapRoot = await Directory.systemTemp.createTemp( - 'busymark-snap-launcher-', - ); - addTearDown(() => snapRoot.delete(recursive: true)); - final launcher = GitProcessGroupLauncher(snapRootOverride: snapRoot.path); - - final direct = launcher.resolve('/snap/busymark/usr/bin/git', const [ - '--version', - ]); - expect(direct.executable, '/snap/busymark/usr/bin/git'); - expect(direct.arguments, const ['--version']); - expect(direct.processGroup, isFalse); - - final bundledSetsid = File('${snapRoot.path}/usr/bin/setsid'); - await bundledSetsid.create(recursive: true); - final wrapped = launcher.resolve('/snap/busymark/usr/bin/git', const [ - '--version', - ]); - expect(wrapped.executable, bundledSetsid.path); - expect(wrapped.arguments, const [ - '--wait', - '--', - '/snap/busymark/usr/bin/git', - '--version', - ]); - expect(wrapped.processGroup, isTrue); - }, skip: !Platform.isLinux); + test( + 'snap launcher never falls back to the confined host setsid', + () async { + final snapRoot = await Directory.systemTemp.createTemp( + 'busymark-snap-launcher-', + ); + addTearDown(() => snapRoot.delete(recursive: true)); + final launcher = GitProcessGroupLauncher(snapRootOverride: snapRoot.path); + + final direct = launcher.resolve('/snap/busymark/usr/bin/git', const [ + '--version', + ]); + expect(direct.executable, '/snap/busymark/usr/bin/git'); + expect(direct.arguments, const ['--version']); + expect(direct.processGroup, isFalse); + + final bundledSetsid = File('${snapRoot.path}/usr/bin/setsid'); + await bundledSetsid.create(recursive: true); + final wrapped = launcher.resolve('/snap/busymark/usr/bin/git', const [ + '--version', + ]); + expect(wrapped.executable, bundledSetsid.path); + expect(wrapped.arguments, const [ + '--wait', + '--', + '/snap/busymark/usr/bin/git', + '--version', + ]); + expect(wrapped.processGroup, isTrue); + }, + skip: !Platform.isLinux, + ); test('Git locator preserves process launcher failures', () async { final availability = await const GitExecutableLocator( diff --git a/test/src/workspace_controller_test.dart b/test/src/workspace_controller_test.dart index 713b9b4..cb6a2b3 100644 --- a/test/src/workspace_controller_test.dart +++ b/test/src/workspace_controller_test.dart @@ -492,36 +492,40 @@ void main() { }, ); - test('save as explicit overwrite replaces the final symlink only', () async { - final directory = await Directory.systemTemp.createTemp( - 'busymark-save-as-symlink-', - ); - final target = File('${directory.path}/target.md'); - final link = Link('${directory.path}/note.md'); - await target.writeAsString('# Target\n'); - await link.create(target.path); - final harness = await _createControllerHarness(); - final settingsController = harness.settingsController; - final controller = harness.controller; + test( + 'save as explicit overwrite replaces the final symlink only', + () async { + final directory = await Directory.systemTemp.createTemp( + 'busymark-save-as-symlink-', + ); + final target = File('${directory.path}/target.md'); + final link = Link('${directory.path}/note.md'); + await target.writeAsString('# Target\n'); + await link.create(target.path); + final harness = await _createControllerHarness(); + final settingsController = harness.settingsController; + final controller = harness.controller; - await controller.createMarkdownFile(); - controller.updateActiveText('# Draft\n'); + await controller.createMarkdownFile(); + controller.updateActiveText('# Draft\n'); - expect( - await controller.saveActiveAs(link.path, overwriteExisting: true), - isTrue, - ); - expect( - await FileSystemEntity.type(link.path, followLinks: false), - FileSystemEntityType.file, - ); - expect(await File(link.path).readAsString(), '# Draft\n'); - expect(await target.readAsString(), '# Target\n'); + expect( + await controller.saveActiveAs(link.path, overwriteExisting: true), + isTrue, + ); + expect( + await FileSystemEntity.type(link.path, followLinks: false), + FileSystemEntityType.file, + ); + expect(await File(link.path).readAsString(), '# Draft\n'); + expect(await target.readAsString(), '# Target\n'); - controller.dispose(); - settingsController.dispose(); - await directory.delete(recursive: true); - }, skip: Platform.isWindows ? 'POSIX symlink behavior only.' : false); + controller.dispose(); + settingsController.dispose(); + await directory.delete(recursive: true); + }, + skip: Platform.isWindows ? 'POSIX symlink behavior only.' : false, + ); test( 'save as preserves source edits for an untitled Markdown file', diff --git a/test/src/writerside_project_creator_test.dart b/test/src/writerside_project_creator_test.dart index f3c2e3d..ee0f4ea 100644 --- a/test/src/writerside_project_creator_test.dart +++ b/test/src/writerside_project_creator_test.dart @@ -253,29 +253,34 @@ void main() { skip: Platform.isWindows ? 'POSIX symlink behavior only.' : false, ); - test('canonicalizes a project parent selected through a symlink', () async { - final parent = await tempParent(); - final container = Directory(p.join(parent.path, 'container'))..createSync(); - final outside = Directory(p.join(parent.path, 'outside'))..createSync(); - final actualParent = Directory(p.join(outside.path, 'projects')) - ..createSync(); - final link = Link(p.join(container.path, 'bridge')) - ..createSync(outside.path); + test( + 'canonicalizes a project parent selected through a symlink', + () async { + final parent = await tempParent(); + final container = Directory(p.join(parent.path, 'container')) + ..createSync(); + final outside = Directory(p.join(parent.path, 'outside'))..createSync(); + final actualParent = Directory(p.join(outside.path, 'projects')) + ..createSync(); + final link = Link(p.join(container.path, 'bridge')) + ..createSync(outside.path); - final result = await creator.create( - WritersideProjectCreateRequest( - parentDirectoryPath: p.join(link.path, 'projects'), - projectName: 'Docs', - directoryName: 'docs', - instanceName: 'User Guide', - topicTitle: 'Getting started', - ), - ); + final result = await creator.create( + WritersideProjectCreateRequest( + parentDirectoryPath: p.join(link.path, 'projects'), + projectName: 'Docs', + directoryName: 'docs', + instanceName: 'User Guide', + topicTitle: 'Getting started', + ), + ); - final canonicalParent = await actualParent.resolveSymbolicLinks(); - expect(result.rootPath, p.join(canonicalParent, 'docs')); - expect(Directory(p.join(actualParent.path, 'docs')).existsSync(), isTrue); - }, skip: Platform.isWindows ? 'POSIX symlink behavior only.' : false); + final canonicalParent = await actualParent.resolveSymbolicLinks(); + expect(result.rootPath, p.join(canonicalParent, 'docs')); + expect(Directory(p.join(actualParent.path, 'docs')).existsSync(), isTrue); + }, + skip: Platform.isWindows ? 'POSIX symlink behavior only.' : false, + ); test('rejects unsafe create request names before writing files', () async { final parent = await tempParent(); diff --git a/test/src/writerside_test.dart b/test/src/writerside_test.dart index 9f49ddc..821ebfc 100644 --- a/test/src/writerside_test.dart +++ b/test/src/writerside_test.dart @@ -301,65 +301,75 @@ void main() { 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(''' + 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); + 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); + 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(''' + 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); + await Link( + p.join(root.path, 'writerside.cfg'), + ).create(outsideConfig.path); - final module = await moduleService.load(root.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); + 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-'); diff --git a/test/src/writerside_toc_editor_test.dart b/test/src/writerside_toc_editor_test.dart index 81ee7f6..92d5a4d 100644 --- a/test/src/writerside_toc_editor_test.dart +++ b/test/src/writerside_toc_editor_test.dart @@ -346,36 +346,40 @@ void main() { expect(outsideTree.readAsStringSync(), original); }); - test('rejects a symlinked tree without mutating its target', () async { - final root = await tempModule(); - final outside = await Directory.systemTemp.createTemp( - 'busymark-toc-editor-link-target-', - ); - addTearDown(() async { - if (await outside.exists()) { - await outside.delete(recursive: true); - } - }); - final outsideTree = File(p.join(outside.path, 'outside.tree')) - ..writeAsStringSync(_treeSource); - final original = outsideTree.readAsStringSync(); - final treePath = p.join(root.path, 'guide.tree'); - await File(treePath).delete(); - await Link(treePath).create(outsideTree.path); - - await expectLater( - editor.removeEntry(targetFor(root), const [0]), - throwsA( - isA().having( - (error) => error.code, - 'code', - 'writerside.topic.tree-file-missing', + test( + 'rejects a symlinked tree without mutating its target', + () async { + final root = await tempModule(); + final outside = await Directory.systemTemp.createTemp( + 'busymark-toc-editor-link-target-', + ); + addTearDown(() async { + if (await outside.exists()) { + await outside.delete(recursive: true); + } + }); + final outsideTree = File(p.join(outside.path, 'outside.tree')) + ..writeAsStringSync(_treeSource); + final original = outsideTree.readAsStringSync(); + final treePath = p.join(root.path, 'guide.tree'); + await File(treePath).delete(); + await Link(treePath).create(outsideTree.path); + + await expectLater( + editor.removeEntry(targetFor(root), const [0]), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'writerside.topic.tree-file-missing', + ), ), - ), - ); + ); - expect(outsideTree.readAsStringSync(), original); - }, skip: Platform.isWindows ? 'POSIX symlink behavior only.' : false); + expect(outsideTree.readAsStringSync(), original); + }, + skip: Platform.isWindows ? 'POSIX symlink behavior only.' : false, + ); test('does not overwrite a tree changed before atomic publication', () async { final root = await tempModule(); @@ -414,17 +418,21 @@ void main() { expect(temporaryFiles, isEmpty); }); - test('atomic replacement preserves the tree POSIX mode', () async { - final root = await tempModule(); - final treeFile = File(p.join(root.path, 'guide.tree')); - final chmod = await Process.run('chmod', ['640', treeFile.path]); - expect(chmod.exitCode, 0, reason: '${chmod.stderr}'); - final originalMode = (await treeFile.stat()).mode & 0xfff; + test( + 'atomic replacement preserves the tree POSIX mode', + () async { + final root = await tempModule(); + final treeFile = File(p.join(root.path, 'guide.tree')); + final chmod = await Process.run('chmod', ['640', treeFile.path]); + expect(chmod.exitCode, 0, reason: '${chmod.stderr}'); + final originalMode = (await treeFile.stat()).mode & 0xfff; - await editor.removeEntry(targetFor(root), const [0]); + await editor.removeEntry(targetFor(root), const [0]); - expect((await treeFile.stat()).mode & 0xfff, originalMode); - }, skip: Platform.isWindows ? 'POSIX permissions only.' : false); + expect((await treeFile.stat()).mode & 0xfff, originalMode); + }, + skip: Platform.isWindows ? 'POSIX permissions only.' : false, + ); } const _treeSource = ''' diff --git a/test/src/writerside_topic_creator_test.dart b/test/src/writerside_topic_creator_test.dart index 64cec20..aab34a6 100644 --- a/test/src/writerside_topic_creator_test.dart +++ b/test/src/writerside_topic_creator_test.dart @@ -480,23 +480,30 @@ void main() { }, ); - test('atomic tree publication preserves its POSIX mode', () async { - final root = await tempModule(); - final treeFile = File(p.join(root.path, 'ug.tree')); - final chmod = await Process.run('chmod', ['640', treeFile.path]); - expect(chmod.exitCode, 0, reason: '${chmod.stderr}'); - final originalMode = (await treeFile.stat()).mode & 0xfff; + test( + 'atomic tree publication preserves its POSIX mode', + () async { + final root = await tempModule(); + final treeFile = File(p.join(root.path, 'ug.tree')); + final chmod = await Process.run('chmod', ['640', treeFile.path]); + expect(chmod.exitCode, 0, reason: '${chmod.stderr}'); + final originalMode = (await treeFile.stat()).mode & 0xfff; - await creator.create( - WritersideTopicCreateTarget( - rootPath: root.path, - treePath: treeFile.path, - topicsRootDir: 'topics', - existingTopicIds: const {'intro'}, - ), - const WritersideTopicCreateRequest(title: 'Details', fileName: 'details'), - ); + await creator.create( + WritersideTopicCreateTarget( + rootPath: root.path, + treePath: treeFile.path, + topicsRootDir: 'topics', + existingTopicIds: const {'intro'}, + ), + const WritersideTopicCreateRequest( + title: 'Details', + fileName: 'details', + ), + ); - expect((await treeFile.stat()).mode & 0xfff, originalMode); - }, skip: Platform.isWindows ? 'POSIX permissions only.' : false); + expect((await treeFile.stat()).mode & 0xfff, originalMode); + }, + skip: Platform.isWindows ? 'POSIX permissions only.' : false, + ); } diff --git a/test/src/writerside_topic_file_editor_test.dart b/test/src/writerside_topic_file_editor_test.dart index a7696f9..0eb515e 100644 --- a/test/src/writerside_topic_file_editor_test.dart +++ b/test/src/writerside_topic_file_editor_test.dart @@ -499,34 +499,38 @@ void main() { }, ); - test('rename preserves topic and instance tree file modes', () async { - final fixture = await _fixture( - trees: { - 'guide.tree': ''' + test( + 'rename preserves topic and instance tree file modes', + () async { + final fixture = await _fixture( + trees: { + 'guide.tree': ''' ''', - }, - topics: {'guide.md': '# Guide\n'}, - ); - final topic = _topic(fixture.module, 'guide.md'); - final treeFile = File(p.join(fixture.root.path, 'guide.tree')); - final treeChmod = await Process.run('chmod', ['640', treeFile.path]); - expect(treeChmod.exitCode, 0, reason: '${treeChmod.stderr}'); - final topicChmod = await Process.run('chmod', ['600', topic.filePath]); - expect(topicChmod.exitCode, 0, reason: '${topicChmod.stderr}'); + }, + topics: {'guide.md': '# Guide\n'}, + ); + final topic = _topic(fixture.module, 'guide.md'); + final treeFile = File(p.join(fixture.root.path, 'guide.tree')); + final treeChmod = await Process.run('chmod', ['640', treeFile.path]); + expect(treeChmod.exitCode, 0, reason: '${treeChmod.stderr}'); + final topicChmod = await Process.run('chmod', ['600', topic.filePath]); + expect(topicChmod.exitCode, 0, reason: '${topicChmod.stderr}'); - await editor.rename( - module: fixture.module, - topic: topic, - newFileName: 'renamed.md', - ); + await editor.rename( + module: fixture.module, + topic: topic, + newFileName: 'renamed.md', + ); - expect((await treeFile.stat()).mode & 0xfff, 0x1a0); - final renamed = File(p.join(fixture.root.path, 'topics', 'renamed.md')); - expect((await renamed.stat()).mode & 0xfff, 0x180); - }, skip: Platform.isWindows ? 'POSIX file modes only.' : false); + expect((await treeFile.stat()).mode & 0xfff, 0x1a0); + final renamed = File(p.join(fixture.root.path, 'topics', 'renamed.md')); + expect((await renamed.stat()).mode & 0xfff, 0x180); + }, + skip: Platform.isWindows ? 'POSIX file modes only.' : false, + ); test( 'delete removes every TOC entry and promotes children in place', diff --git a/test/src/wysiwyg_visualization_diagnostic_test.dart b/test/src/wysiwyg_visualization_diagnostic_test.dart index 98b585d..7d8bd32 100644 --- a/test/src/wysiwyg_visualization_diagnostic_test.dart +++ b/test/src/wysiwyg_visualization_diagnostic_test.dart @@ -59,89 +59,91 @@ void main() { ); }); - testWidgets('WYSIWYG diagnostic selects its actual source line', ( - tester, - ) async { - final coordinator = VisualizationCoordinator( - renderers: const [_DiagnosticRenderer()], - cache: _MemoryVisualizationCache(cacheDirectory), - ); - addTearDown(coordinator.dispose); - final controller = BusyMarkWysiwygTextController( - text: 'first\nsecond\nthird', - ranges: const [], - ); - final undoController = UndoHistoryController(); - final focusNode = FocusNode(); - addTearDown(controller.dispose); - addTearDown(undoController.dispose); - addTearDown(focusNode.dispose); - var focusCalls = 0; - - await tester.pumpWidget( - ProviderScope( - overrides: [ - visualizationCoordinatorProvider.overrideWithValue(coordinator), - ], - child: MaterialApp( - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: Scaffold( - body: SingleChildScrollView( - child: BusyMarkWysiwygBlockField( - block: const BusyBlock( - id: 'diagram', - kind: BusyBlockKind.codeBlock, - attributes: {'language': 'mermaid'}, - inlines: [ - BusyInline( - kind: BusyInlineKind.text, - text: 'first\nsecond\nthird', + testWidgets( + 'WYSIWYG diagnostic selects its actual source line', + (tester) async { + final coordinator = VisualizationCoordinator( + renderers: const [_DiagnosticRenderer()], + cache: _MemoryVisualizationCache(cacheDirectory), + ); + addTearDown(coordinator.dispose); + final controller = BusyMarkWysiwygTextController( + text: 'first\nsecond\nthird', + ranges: const [], + ); + final undoController = UndoHistoryController(); + final focusNode = FocusNode(); + addTearDown(controller.dispose); + addTearDown(undoController.dispose); + addTearDown(focusNode.dispose); + var focusCalls = 0; + + await tester.pumpWidget( + ProviderScope( + overrides: [ + visualizationCoordinatorProvider.overrideWithValue(coordinator), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SingleChildScrollView( + child: BusyMarkWysiwygBlockField( + block: const BusyBlock( + id: 'diagram', + kind: BusyBlockKind.codeBlock, + attributes: {'language': 'mermaid'}, + inlines: [ + BusyInline( + kind: BusyInlineKind.text, + text: 'first\nsecond\nthird', + ), + ], + sourceSpan: SourceSpan( + filePath: '/workspace/demo.md', + startOffset: 20, + endOffset: 55, + startLine: 5, + startColumn: 1, + endLine: 9, + endColumn: 4, ), - ], - sourceSpan: SourceSpan( - filePath: '/workspace/demo.md', - startOffset: 20, - endOffset: 55, - startLine: 5, - startColumn: 1, - endLine: 9, - endColumn: 4, ), + documentFilePath: '/workspace/demo.md', + workspaceRoot: '/workspace', + allowRemoteImages: false, + controller: controller, + undoController: undoController, + focusNode: focusNode, + onChanged: (_) {}, + onTableCellChanged: (_, _) {}, + onTableRowInserted: (_, {required after}) {}, + onTableRowDeleted: (_) {}, + onTableColumnInserted: (_, {required after}) {}, + onTableColumnDeleted: (_) {}, + onTableColumnAlignmentChanged: (_, _) {}, + onTableDeleted: () {}, + onImageEditRequested: () {}, + onHtmlEditRequested: () {}, + onTaskChanged: (_) {}, + onFocused: () => focusCalls++, ), - documentFilePath: '/workspace/demo.md', - workspaceRoot: '/workspace', - allowRemoteImages: false, - controller: controller, - undoController: undoController, - focusNode: focusNode, - onChanged: (_) {}, - onTableCellChanged: (_, _) {}, - onTableRowInserted: (_, {required after}) {}, - onTableRowDeleted: (_) {}, - onTableColumnInserted: (_, {required after}) {}, - onTableColumnDeleted: (_) {}, - onTableColumnAlignmentChanged: (_, _) {}, - onTableDeleted: () {}, - onImageEditRequested: () {}, - onHtmlEditRequested: () {}, - onTaskChanged: (_) {}, - onFocused: () => focusCalls++, ), ), ), ), - ), - ); - - await _pumpUntilFound(tester, find.text('Broken third line')); - await tester.tap(find.text('Broken third line')); - await tester.pump(); - - expect(focusCalls, 1); - expect(focusNode.hasFocus, isTrue); - expect(controller.selection, const TextSelection.collapsed(offset: 13)); - }, timeout: const Timeout(Duration(seconds: 10))); + ); + + await _pumpUntilFound(tester, find.text('Broken third line')); + await tester.tap(find.text('Broken third line')); + await tester.pump(); + + expect(focusCalls, 1); + expect(focusNode.hasFocus, isTrue); + expect(controller.selection, const TextSelection.collapsed(offset: 13)); + }, + timeout: const Timeout(Duration(seconds: 10)), + ); } Future _pumpUntilFound(WidgetTester tester, Finder finder) async { From 84b686aa2d135b118fcc54b0712a58fbc47633be Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 28 Aug 2026 18:08:51 -0700 Subject: [PATCH 7/7] Format tests with CI Dart SDK --- test/src/app_settings_test.dart | 48 +++--- test/src/busymark_dialogs_test.dart | 8 +- test/src/document_persistence_test.dart | 26 ++- .../git/git_cli_gateway_integration_test.dart | 141 ++++++++-------- test/src/git/git_controller_test.dart | 76 ++++----- test/src/git/git_process_runner_test.dart | 60 ++++--- test/src/workspace_controller_test.dart | 58 +++---- test/src/writerside_project_creator_test.dart | 47 +++--- test/src/writerside_test.dart | 102 ++++++------ test/src/writerside_toc_editor_test.dart | 82 +++++----- test/src/writerside_topic_creator_test.dart | 41 ++--- .../writerside_topic_file_editor_test.dart | 48 +++--- ...wysiwyg_visualization_diagnostic_test.dart | 154 +++++++++--------- 13 files changed, 411 insertions(+), 480 deletions(-) diff --git a/test/src/app_settings_test.dart b/test/src/app_settings_test.dart index 799ac14..21e6e38 100644 --- a/test/src/app_settings_test.dart +++ b/test/src/app_settings_test.dart @@ -593,32 +593,28 @@ void main() { skip: Platform.isWindows, ); - test( - 'stored Git trust does not follow a replaced canonical path', - () async { - final root = await Directory.systemTemp.createTemp( - 'busymark-stored-git-trust-', - ); - addTearDown(() async { - if (await root.exists()) { - await root.delete(recursive: true); - } - }); - final trustedPath = await Directory('${root.path}/trusted').create(); - final replacement = await Directory('${root.path}/replacement').create(); - final stored = AppSettings.defaults() - .copyWith(trustedGitWorkspacePaths: [trustedPath.path]) - .toJson(); - - await trustedPath.delete(); - await Link(trustedPath.path).create(replacement.path); - final reloaded = AppSettings.fromJson(stored); - - expect(reloaded.trustsGitWorkspace(trustedPath.path), isFalse); - expect(reloaded.trustedGitWorkspacePaths, [trustedPath.path]); - }, - skip: Platform.isWindows, - ); + test('stored Git trust does not follow a replaced canonical path', () async { + final root = await Directory.systemTemp.createTemp( + 'busymark-stored-git-trust-', + ); + addTearDown(() async { + if (await root.exists()) { + await root.delete(recursive: true); + } + }); + final trustedPath = await Directory('${root.path}/trusted').create(); + final replacement = await Directory('${root.path}/replacement').create(); + final stored = AppSettings.defaults() + .copyWith(trustedGitWorkspacePaths: [trustedPath.path]) + .toJson(); + + await trustedPath.delete(); + await Link(trustedPath.path).create(replacement.path); + final reloaded = AppSettings.fromJson(stored); + + expect(reloaded.trustsGitWorkspace(trustedPath.path), isFalse); + expect(reloaded.trustedGitWorkspacePaths, [trustedPath.path]); + }, skip: Platform.isWindows); test('Git trust preserves leading and trailing path whitespace', () async { final root = await Directory.systemTemp.createTemp( diff --git a/test/src/busymark_dialogs_test.dart b/test/src/busymark_dialogs_test.dart index b9bc9c3..e8bfe36 100644 --- a/test/src/busymark_dialogs_test.dart +++ b/test/src/busymark_dialogs_test.dart @@ -233,11 +233,9 @@ void main() { final release = releaseBusyMarkModalBarrier(headerBar); await tester.pump(); - expect( - transitions, - [1], - reason: 'the native hide must wait for the in-flight native show', - ); + expect(transitions, [ + 1, + ], reason: 'the native hide must wait for the in-flight native show'); firstUpdate.complete(); await Future.wait([acquire, release]); diff --git a/test/src/document_persistence_test.dart b/test/src/document_persistence_test.dart index b2522a3..5f2b089 100644 --- a/test/src/document_persistence_test.dart +++ b/test/src/document_persistence_test.dart @@ -175,21 +175,17 @@ void main() { }, ); - test( - 'recovery state is written with private POSIX permissions', - () async { - final directory = await Directory.systemTemp.createTemp( - 'busymark-recovery-permissions-', - ); - addTearDown(() => directory.delete(recursive: true)); - final path = p.join(directory.path, 'recovery.json'); - final store = JsonDocumentRecoveryStore(filePathOverride: path); + test('recovery state is written with private POSIX permissions', () async { + final directory = await Directory.systemTemp.createTemp( + 'busymark-recovery-permissions-', + ); + addTearDown(() => directory.delete(recursive: true)); + final path = p.join(directory.path, 'recovery.json'); + final store = JsonDocumentRecoveryStore(filePathOverride: path); - await store.writeEntries(const []); + await store.writeEntries(const []); - expect((await File(path).stat()).mode & 0x1ff, 0x180); - expect((await directory.stat()).mode & 0x1ff, 0x1c0); - }, - skip: Platform.isWindows ? 'POSIX permissions only.' : false, - ); + expect((await File(path).stat()).mode & 0x1ff, 0x180); + expect((await directory.stat()).mode & 0x1ff, 0x1c0); + }, skip: Platform.isWindows ? 'POSIX permissions only.' : false); } diff --git a/test/src/git/git_cli_gateway_integration_test.dart b/test/src/git/git_cli_gateway_integration_test.dart index 899f18c..3f1c199 100644 --- a/test/src/git/git_cli_gateway_integration_test.dart +++ b/test/src/git/git_cli_gateway_integration_test.dart @@ -937,88 +937,77 @@ void main() { skip: Platform.isWindows, ); - test( - 'diff APIs do not run a repository-configured textconv', - () async { - if (!await _gitAvailable()) { - markTestSkipped('Git executable is unavailable.'); - return; - } - final fixture = await _createTextconvFixture(); - final sentinel = File('${fixture.probe.path}.ran'); + test('diff APIs do not run a repository-configured textconv', () async { + if (!await _gitAvailable()) { + markTestSkipped('Git executable is unavailable.'); + return; + } + final fixture = await _createTextconvFixture(); + final sentinel = File('${fixture.probe.path}.ran'); - await _git(fixture.root.path, ['diff', '--textconv', '--', 'README.md']); - expect( - await sentinel.exists(), - isTrue, - reason: 'The textconv probe must execute without the mitigation.', - ); - await sentinel.delete(); + await _git(fixture.root.path, ['diff', '--textconv', '--', 'README.md']); + expect( + await sentinel.exists(), + isTrue, + reason: 'The textconv probe must execute without the mitigation.', + ); + await sentinel.delete(); - final fileDiff = await fixture.gateway.diffFile( - fixture.info, - 'README.md', - staged: false, - ); - expect(fileDiff.rawPatch, contains('Working tree change.')); - expect( - await sentinel.exists(), - isFalse, - reason: 'Git diffFile must disable repository textconv commands.', - ); + final fileDiff = await fixture.gateway.diffFile( + fixture.info, + 'README.md', + staged: false, + ); + expect(fileDiff.rawPatch, contains('Working tree change.')); + expect( + await sentinel.exists(), + isFalse, + reason: 'Git diffFile must disable repository textconv commands.', + ); - final allDiff = await fixture.gateway.diffAll( - fixture.info, - staged: false, - ); - expect(allDiff.rawPatch, contains('Working tree change.')); - expect( - await sentinel.exists(), - isFalse, - reason: 'Git diffAll must disable repository textconv commands.', - ); - }, - skip: Platform.isWindows, - ); + final allDiff = await fixture.gateway.diffAll(fixture.info, staged: false); + expect(allDiff.rawPatch, contains('Working tree change.')); + expect( + await sentinel.exists(), + isFalse, + reason: 'Git diffAll must disable repository textconv commands.', + ); + }, skip: Platform.isWindows); - test( - 'commit details do not run a repository-configured textconv', - () async { - if (!await _gitAvailable()) { - markTestSkipped('Git executable is unavailable.'); - return; - } - final fixture = await _createTextconvFixture(); - final sentinel = File('${fixture.probe.path}.ran'); - - await _git(fixture.root.path, [ - 'show', - '--textconv', - '--format=', - '--patch', - fixture.commitHash, - ]); - expect( - await sentinel.exists(), - isTrue, - reason: 'The textconv probe must execute for raw Git show.', - ); - await sentinel.delete(); + test('commit details do not run a repository-configured textconv', () async { + if (!await _gitAvailable()) { + markTestSkipped('Git executable is unavailable.'); + return; + } + final fixture = await _createTextconvFixture(); + final sentinel = File('${fixture.probe.path}.ran'); + + await _git(fixture.root.path, [ + 'show', + '--textconv', + '--format=', + '--patch', + fixture.commitHash, + ]); + expect( + await sentinel.exists(), + isTrue, + reason: 'The textconv probe must execute for raw Git show.', + ); + await sentinel.delete(); - final details = await fixture.gateway.commitDetails( - fixture.info, - fixture.commitHash, - ); + final details = await fixture.gateway.commitDetails( + fixture.info, + fixture.commitHash, + ); - expect(details.patch, contains('Committed change.')); - expect( - await sentinel.exists(), - isFalse, - reason: 'Git show must disable repository textconv commands.', - ); - }, - skip: Platform.isWindows, - ); + expect(details.patch, contains('Committed change.')); + expect( + await sentinel.exists(), + isFalse, + reason: 'Git show must disable repository textconv commands.', + ); + }, skip: Platform.isWindows); } Future _createRepository(String prefix) async { diff --git a/test/src/git/git_controller_test.dart b/test/src/git/git_controller_test.dart index b5e2986..d814064 100644 --- a/test/src/git/git_controller_test.dart +++ b/test/src/git/git_controller_test.dart @@ -206,50 +206,46 @@ void main() { ); }); - test( - 'Git executes with the canonical path that was trusted', - () async { - final root = await Directory.systemTemp.createTemp( - 'busymark-controller-git-trust-', - ); - addTearDown(() async { - if (await root.exists()) { - await root.delete(recursive: true); - } - }); - final trusted = await Directory('${root.path}/trusted').create(); - final replacement = await Directory('${root.path}/replacement').create(); - final workspaceLink = Link('${root.path}/workspace'); - await workspaceLink.create(trusted.path); - final gateway = _TrustRequiredFakeGitGateway(); - final container = _container(gateway); - await container - .read(appSettingsControllerProvider.notifier) - .trustGitWorkspace(workspaceLink.path); - final controller = container.read(gitControllerProvider.notifier); + test('Git executes with the canonical path that was trusted', () async { + final root = await Directory.systemTemp.createTemp( + 'busymark-controller-git-trust-', + ); + addTearDown(() async { + if (await root.exists()) { + await root.delete(recursive: true); + } + }); + final trusted = await Directory('${root.path}/trusted').create(); + final replacement = await Directory('${root.path}/replacement').create(); + final workspaceLink = Link('${root.path}/workspace'); + await workspaceLink.create(trusted.path); + final gateway = _TrustRequiredFakeGitGateway(); + final container = _container(gateway); + await container + .read(appSettingsControllerProvider.notifier) + .trustGitWorkspace(workspaceLink.path); + final controller = container.read(gitControllerProvider.notifier); - controller.attachWorkspace( - _workspace(id: workspaceLink.path, rootPath: workspaceLink.path), - ); - await controller.refresh(); + controller.attachWorkspace( + _workspace(id: workspaceLink.path, rootPath: workspaceLink.path), + ); + await controller.refresh(); - expect(gateway.lastDetectedWorkspacePath, trusted.path); - await controller.initializeRepository(); - expect(gateway.lastInitializeRootPath, trusted.path); - final trustedDetectCalls = gateway.detectCalls; - await workspaceLink.delete(); - await workspaceLink.create(replacement.path); + expect(gateway.lastDetectedWorkspacePath, trusted.path); + await controller.initializeRepository(); + expect(gateway.lastInitializeRootPath, trusted.path); + final trustedDetectCalls = gateway.detectCalls; + await workspaceLink.delete(); + await workspaceLink.create(replacement.path); - await controller.refresh(); + await controller.refresh(); - expect(gateway.detectCalls, trustedDetectCalls); - expect( - container.read(gitControllerProvider).requiresWorkspaceTrust, - isTrue, - ); - }, - skip: Platform.isWindows, - ); + expect(gateway.detectCalls, trustedDetectCalls); + expect( + container.read(gitControllerProvider).requiresWorkspaceTrust, + isTrue, + ); + }, skip: Platform.isWindows); test('stage and unstage update state', () async { final gateway = _FakeGitGateway(); diff --git a/test/src/git/git_process_runner_test.dart b/test/src/git/git_process_runner_test.dart index e9b0d05..e7f80d4 100644 --- a/test/src/git/git_process_runner_test.dart +++ b/test/src/git/git_process_runner_test.dart @@ -118,38 +118,34 @@ void main() { expect(gitEnvironmentForSnap(''), isEmpty); }); - test( - 'snap launcher never falls back to the confined host setsid', - () async { - final snapRoot = await Directory.systemTemp.createTemp( - 'busymark-snap-launcher-', - ); - addTearDown(() => snapRoot.delete(recursive: true)); - final launcher = GitProcessGroupLauncher(snapRootOverride: snapRoot.path); - - final direct = launcher.resolve('/snap/busymark/usr/bin/git', const [ - '--version', - ]); - expect(direct.executable, '/snap/busymark/usr/bin/git'); - expect(direct.arguments, const ['--version']); - expect(direct.processGroup, isFalse); - - final bundledSetsid = File('${snapRoot.path}/usr/bin/setsid'); - await bundledSetsid.create(recursive: true); - final wrapped = launcher.resolve('/snap/busymark/usr/bin/git', const [ - '--version', - ]); - expect(wrapped.executable, bundledSetsid.path); - expect(wrapped.arguments, const [ - '--wait', - '--', - '/snap/busymark/usr/bin/git', - '--version', - ]); - expect(wrapped.processGroup, isTrue); - }, - skip: !Platform.isLinux, - ); + test('snap launcher never falls back to the confined host setsid', () async { + final snapRoot = await Directory.systemTemp.createTemp( + 'busymark-snap-launcher-', + ); + addTearDown(() => snapRoot.delete(recursive: true)); + final launcher = GitProcessGroupLauncher(snapRootOverride: snapRoot.path); + + final direct = launcher.resolve('/snap/busymark/usr/bin/git', const [ + '--version', + ]); + expect(direct.executable, '/snap/busymark/usr/bin/git'); + expect(direct.arguments, const ['--version']); + expect(direct.processGroup, isFalse); + + final bundledSetsid = File('${snapRoot.path}/usr/bin/setsid'); + await bundledSetsid.create(recursive: true); + final wrapped = launcher.resolve('/snap/busymark/usr/bin/git', const [ + '--version', + ]); + expect(wrapped.executable, bundledSetsid.path); + expect(wrapped.arguments, const [ + '--wait', + '--', + '/snap/busymark/usr/bin/git', + '--version', + ]); + expect(wrapped.processGroup, isTrue); + }, skip: !Platform.isLinux); test('Git locator preserves process launcher failures', () async { final availability = await const GitExecutableLocator( diff --git a/test/src/workspace_controller_test.dart b/test/src/workspace_controller_test.dart index cb6a2b3..713b9b4 100644 --- a/test/src/workspace_controller_test.dart +++ b/test/src/workspace_controller_test.dart @@ -492,40 +492,36 @@ void main() { }, ); - test( - 'save as explicit overwrite replaces the final symlink only', - () async { - final directory = await Directory.systemTemp.createTemp( - 'busymark-save-as-symlink-', - ); - final target = File('${directory.path}/target.md'); - final link = Link('${directory.path}/note.md'); - await target.writeAsString('# Target\n'); - await link.create(target.path); - final harness = await _createControllerHarness(); - final settingsController = harness.settingsController; - final controller = harness.controller; + test('save as explicit overwrite replaces the final symlink only', () async { + final directory = await Directory.systemTemp.createTemp( + 'busymark-save-as-symlink-', + ); + final target = File('${directory.path}/target.md'); + final link = Link('${directory.path}/note.md'); + await target.writeAsString('# Target\n'); + await link.create(target.path); + final harness = await _createControllerHarness(); + final settingsController = harness.settingsController; + final controller = harness.controller; - await controller.createMarkdownFile(); - controller.updateActiveText('# Draft\n'); + await controller.createMarkdownFile(); + controller.updateActiveText('# Draft\n'); - expect( - await controller.saveActiveAs(link.path, overwriteExisting: true), - isTrue, - ); - expect( - await FileSystemEntity.type(link.path, followLinks: false), - FileSystemEntityType.file, - ); - expect(await File(link.path).readAsString(), '# Draft\n'); - expect(await target.readAsString(), '# Target\n'); + expect( + await controller.saveActiveAs(link.path, overwriteExisting: true), + isTrue, + ); + expect( + await FileSystemEntity.type(link.path, followLinks: false), + FileSystemEntityType.file, + ); + expect(await File(link.path).readAsString(), '# Draft\n'); + expect(await target.readAsString(), '# Target\n'); - controller.dispose(); - settingsController.dispose(); - await directory.delete(recursive: true); - }, - skip: Platform.isWindows ? 'POSIX symlink behavior only.' : false, - ); + controller.dispose(); + settingsController.dispose(); + await directory.delete(recursive: true); + }, skip: Platform.isWindows ? 'POSIX symlink behavior only.' : false); test( 'save as preserves source edits for an untitled Markdown file', diff --git a/test/src/writerside_project_creator_test.dart b/test/src/writerside_project_creator_test.dart index ee0f4ea..f3c2e3d 100644 --- a/test/src/writerside_project_creator_test.dart +++ b/test/src/writerside_project_creator_test.dart @@ -253,34 +253,29 @@ void main() { skip: Platform.isWindows ? 'POSIX symlink behavior only.' : false, ); - test( - 'canonicalizes a project parent selected through a symlink', - () async { - final parent = await tempParent(); - final container = Directory(p.join(parent.path, 'container')) - ..createSync(); - final outside = Directory(p.join(parent.path, 'outside'))..createSync(); - final actualParent = Directory(p.join(outside.path, 'projects')) - ..createSync(); - final link = Link(p.join(container.path, 'bridge')) - ..createSync(outside.path); + test('canonicalizes a project parent selected through a symlink', () async { + final parent = await tempParent(); + final container = Directory(p.join(parent.path, 'container'))..createSync(); + final outside = Directory(p.join(parent.path, 'outside'))..createSync(); + final actualParent = Directory(p.join(outside.path, 'projects')) + ..createSync(); + final link = Link(p.join(container.path, 'bridge')) + ..createSync(outside.path); - final result = await creator.create( - WritersideProjectCreateRequest( - parentDirectoryPath: p.join(link.path, 'projects'), - projectName: 'Docs', - directoryName: 'docs', - instanceName: 'User Guide', - topicTitle: 'Getting started', - ), - ); + final result = await creator.create( + WritersideProjectCreateRequest( + parentDirectoryPath: p.join(link.path, 'projects'), + projectName: 'Docs', + directoryName: 'docs', + instanceName: 'User Guide', + topicTitle: 'Getting started', + ), + ); - final canonicalParent = await actualParent.resolveSymbolicLinks(); - expect(result.rootPath, p.join(canonicalParent, 'docs')); - expect(Directory(p.join(actualParent.path, 'docs')).existsSync(), isTrue); - }, - skip: Platform.isWindows ? 'POSIX symlink behavior only.' : false, - ); + final canonicalParent = await actualParent.resolveSymbolicLinks(); + expect(result.rootPath, p.join(canonicalParent, 'docs')); + expect(Directory(p.join(actualParent.path, 'docs')).existsSync(), isTrue); + }, skip: Platform.isWindows ? 'POSIX symlink behavior only.' : false); test('rejects unsafe create request names before writing files', () async { final parent = await tempParent(); diff --git a/test/src/writerside_test.dart b/test/src/writerside_test.dart index 821ebfc..9f49ddc 100644 --- a/test/src/writerside_test.dart +++ b/test/src/writerside_test.dart @@ -301,75 +301,65 @@ void main() { 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(''' + 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); + 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, - ); + 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(''' + 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); + await Link(p.join(root.path, 'writerside.cfg')).create(outsideConfig.path); - final module = await moduleService.load(root.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, - ); + 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-'); diff --git a/test/src/writerside_toc_editor_test.dart b/test/src/writerside_toc_editor_test.dart index 92d5a4d..81ee7f6 100644 --- a/test/src/writerside_toc_editor_test.dart +++ b/test/src/writerside_toc_editor_test.dart @@ -346,40 +346,36 @@ void main() { expect(outsideTree.readAsStringSync(), original); }); - test( - 'rejects a symlinked tree without mutating its target', - () async { - final root = await tempModule(); - final outside = await Directory.systemTemp.createTemp( - 'busymark-toc-editor-link-target-', - ); - addTearDown(() async { - if (await outside.exists()) { - await outside.delete(recursive: true); - } - }); - final outsideTree = File(p.join(outside.path, 'outside.tree')) - ..writeAsStringSync(_treeSource); - final original = outsideTree.readAsStringSync(); - final treePath = p.join(root.path, 'guide.tree'); - await File(treePath).delete(); - await Link(treePath).create(outsideTree.path); - - await expectLater( - editor.removeEntry(targetFor(root), const [0]), - throwsA( - isA().having( - (error) => error.code, - 'code', - 'writerside.topic.tree-file-missing', - ), + test('rejects a symlinked tree without mutating its target', () async { + final root = await tempModule(); + final outside = await Directory.systemTemp.createTemp( + 'busymark-toc-editor-link-target-', + ); + addTearDown(() async { + if (await outside.exists()) { + await outside.delete(recursive: true); + } + }); + final outsideTree = File(p.join(outside.path, 'outside.tree')) + ..writeAsStringSync(_treeSource); + final original = outsideTree.readAsStringSync(); + final treePath = p.join(root.path, 'guide.tree'); + await File(treePath).delete(); + await Link(treePath).create(outsideTree.path); + + await expectLater( + editor.removeEntry(targetFor(root), const [0]), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'writerside.topic.tree-file-missing', ), - ); + ), + ); - expect(outsideTree.readAsStringSync(), original); - }, - skip: Platform.isWindows ? 'POSIX symlink behavior only.' : false, - ); + expect(outsideTree.readAsStringSync(), original); + }, skip: Platform.isWindows ? 'POSIX symlink behavior only.' : false); test('does not overwrite a tree changed before atomic publication', () async { final root = await tempModule(); @@ -418,21 +414,17 @@ void main() { expect(temporaryFiles, isEmpty); }); - test( - 'atomic replacement preserves the tree POSIX mode', - () async { - final root = await tempModule(); - final treeFile = File(p.join(root.path, 'guide.tree')); - final chmod = await Process.run('chmod', ['640', treeFile.path]); - expect(chmod.exitCode, 0, reason: '${chmod.stderr}'); - final originalMode = (await treeFile.stat()).mode & 0xfff; + test('atomic replacement preserves the tree POSIX mode', () async { + final root = await tempModule(); + final treeFile = File(p.join(root.path, 'guide.tree')); + final chmod = await Process.run('chmod', ['640', treeFile.path]); + expect(chmod.exitCode, 0, reason: '${chmod.stderr}'); + final originalMode = (await treeFile.stat()).mode & 0xfff; - await editor.removeEntry(targetFor(root), const [0]); + await editor.removeEntry(targetFor(root), const [0]); - expect((await treeFile.stat()).mode & 0xfff, originalMode); - }, - skip: Platform.isWindows ? 'POSIX permissions only.' : false, - ); + expect((await treeFile.stat()).mode & 0xfff, originalMode); + }, skip: Platform.isWindows ? 'POSIX permissions only.' : false); } const _treeSource = ''' diff --git a/test/src/writerside_topic_creator_test.dart b/test/src/writerside_topic_creator_test.dart index aab34a6..64cec20 100644 --- a/test/src/writerside_topic_creator_test.dart +++ b/test/src/writerside_topic_creator_test.dart @@ -480,30 +480,23 @@ void main() { }, ); - test( - 'atomic tree publication preserves its POSIX mode', - () async { - final root = await tempModule(); - final treeFile = File(p.join(root.path, 'ug.tree')); - final chmod = await Process.run('chmod', ['640', treeFile.path]); - expect(chmod.exitCode, 0, reason: '${chmod.stderr}'); - final originalMode = (await treeFile.stat()).mode & 0xfff; + test('atomic tree publication preserves its POSIX mode', () async { + final root = await tempModule(); + final treeFile = File(p.join(root.path, 'ug.tree')); + final chmod = await Process.run('chmod', ['640', treeFile.path]); + expect(chmod.exitCode, 0, reason: '${chmod.stderr}'); + final originalMode = (await treeFile.stat()).mode & 0xfff; - await creator.create( - WritersideTopicCreateTarget( - rootPath: root.path, - treePath: treeFile.path, - topicsRootDir: 'topics', - existingTopicIds: const {'intro'}, - ), - const WritersideTopicCreateRequest( - title: 'Details', - fileName: 'details', - ), - ); + await creator.create( + WritersideTopicCreateTarget( + rootPath: root.path, + treePath: treeFile.path, + topicsRootDir: 'topics', + existingTopicIds: const {'intro'}, + ), + const WritersideTopicCreateRequest(title: 'Details', fileName: 'details'), + ); - expect((await treeFile.stat()).mode & 0xfff, originalMode); - }, - skip: Platform.isWindows ? 'POSIX permissions only.' : false, - ); + expect((await treeFile.stat()).mode & 0xfff, originalMode); + }, skip: Platform.isWindows ? 'POSIX permissions only.' : false); } diff --git a/test/src/writerside_topic_file_editor_test.dart b/test/src/writerside_topic_file_editor_test.dart index 0eb515e..a7696f9 100644 --- a/test/src/writerside_topic_file_editor_test.dart +++ b/test/src/writerside_topic_file_editor_test.dart @@ -499,38 +499,34 @@ void main() { }, ); - test( - 'rename preserves topic and instance tree file modes', - () async { - final fixture = await _fixture( - trees: { - 'guide.tree': ''' + test('rename preserves topic and instance tree file modes', () async { + final fixture = await _fixture( + trees: { + 'guide.tree': ''' ''', - }, - topics: {'guide.md': '# Guide\n'}, - ); - final topic = _topic(fixture.module, 'guide.md'); - final treeFile = File(p.join(fixture.root.path, 'guide.tree')); - final treeChmod = await Process.run('chmod', ['640', treeFile.path]); - expect(treeChmod.exitCode, 0, reason: '${treeChmod.stderr}'); - final topicChmod = await Process.run('chmod', ['600', topic.filePath]); - expect(topicChmod.exitCode, 0, reason: '${topicChmod.stderr}'); + }, + topics: {'guide.md': '# Guide\n'}, + ); + final topic = _topic(fixture.module, 'guide.md'); + final treeFile = File(p.join(fixture.root.path, 'guide.tree')); + final treeChmod = await Process.run('chmod', ['640', treeFile.path]); + expect(treeChmod.exitCode, 0, reason: '${treeChmod.stderr}'); + final topicChmod = await Process.run('chmod', ['600', topic.filePath]); + expect(topicChmod.exitCode, 0, reason: '${topicChmod.stderr}'); - await editor.rename( - module: fixture.module, - topic: topic, - newFileName: 'renamed.md', - ); + await editor.rename( + module: fixture.module, + topic: topic, + newFileName: 'renamed.md', + ); - expect((await treeFile.stat()).mode & 0xfff, 0x1a0); - final renamed = File(p.join(fixture.root.path, 'topics', 'renamed.md')); - expect((await renamed.stat()).mode & 0xfff, 0x180); - }, - skip: Platform.isWindows ? 'POSIX file modes only.' : false, - ); + expect((await treeFile.stat()).mode & 0xfff, 0x1a0); + final renamed = File(p.join(fixture.root.path, 'topics', 'renamed.md')); + expect((await renamed.stat()).mode & 0xfff, 0x180); + }, skip: Platform.isWindows ? 'POSIX file modes only.' : false); test( 'delete removes every TOC entry and promotes children in place', diff --git a/test/src/wysiwyg_visualization_diagnostic_test.dart b/test/src/wysiwyg_visualization_diagnostic_test.dart index 7d8bd32..98b585d 100644 --- a/test/src/wysiwyg_visualization_diagnostic_test.dart +++ b/test/src/wysiwyg_visualization_diagnostic_test.dart @@ -59,91 +59,89 @@ void main() { ); }); - testWidgets( - 'WYSIWYG diagnostic selects its actual source line', - (tester) async { - final coordinator = VisualizationCoordinator( - renderers: const [_DiagnosticRenderer()], - cache: _MemoryVisualizationCache(cacheDirectory), - ); - addTearDown(coordinator.dispose); - final controller = BusyMarkWysiwygTextController( - text: 'first\nsecond\nthird', - ranges: const [], - ); - final undoController = UndoHistoryController(); - final focusNode = FocusNode(); - addTearDown(controller.dispose); - addTearDown(undoController.dispose); - addTearDown(focusNode.dispose); - var focusCalls = 0; - - await tester.pumpWidget( - ProviderScope( - overrides: [ - visualizationCoordinatorProvider.overrideWithValue(coordinator), - ], - child: MaterialApp( - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: Scaffold( - body: SingleChildScrollView( - child: BusyMarkWysiwygBlockField( - block: const BusyBlock( - id: 'diagram', - kind: BusyBlockKind.codeBlock, - attributes: {'language': 'mermaid'}, - inlines: [ - BusyInline( - kind: BusyInlineKind.text, - text: 'first\nsecond\nthird', - ), - ], - sourceSpan: SourceSpan( - filePath: '/workspace/demo.md', - startOffset: 20, - endOffset: 55, - startLine: 5, - startColumn: 1, - endLine: 9, - endColumn: 4, + testWidgets('WYSIWYG diagnostic selects its actual source line', ( + tester, + ) async { + final coordinator = VisualizationCoordinator( + renderers: const [_DiagnosticRenderer()], + cache: _MemoryVisualizationCache(cacheDirectory), + ); + addTearDown(coordinator.dispose); + final controller = BusyMarkWysiwygTextController( + text: 'first\nsecond\nthird', + ranges: const [], + ); + final undoController = UndoHistoryController(); + final focusNode = FocusNode(); + addTearDown(controller.dispose); + addTearDown(undoController.dispose); + addTearDown(focusNode.dispose); + var focusCalls = 0; + + await tester.pumpWidget( + ProviderScope( + overrides: [ + visualizationCoordinatorProvider.overrideWithValue(coordinator), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SingleChildScrollView( + child: BusyMarkWysiwygBlockField( + block: const BusyBlock( + id: 'diagram', + kind: BusyBlockKind.codeBlock, + attributes: {'language': 'mermaid'}, + inlines: [ + BusyInline( + kind: BusyInlineKind.text, + text: 'first\nsecond\nthird', ), + ], + sourceSpan: SourceSpan( + filePath: '/workspace/demo.md', + startOffset: 20, + endOffset: 55, + startLine: 5, + startColumn: 1, + endLine: 9, + endColumn: 4, ), - documentFilePath: '/workspace/demo.md', - workspaceRoot: '/workspace', - allowRemoteImages: false, - controller: controller, - undoController: undoController, - focusNode: focusNode, - onChanged: (_) {}, - onTableCellChanged: (_, _) {}, - onTableRowInserted: (_, {required after}) {}, - onTableRowDeleted: (_) {}, - onTableColumnInserted: (_, {required after}) {}, - onTableColumnDeleted: (_) {}, - onTableColumnAlignmentChanged: (_, _) {}, - onTableDeleted: () {}, - onImageEditRequested: () {}, - onHtmlEditRequested: () {}, - onTaskChanged: (_) {}, - onFocused: () => focusCalls++, ), + documentFilePath: '/workspace/demo.md', + workspaceRoot: '/workspace', + allowRemoteImages: false, + controller: controller, + undoController: undoController, + focusNode: focusNode, + onChanged: (_) {}, + onTableCellChanged: (_, _) {}, + onTableRowInserted: (_, {required after}) {}, + onTableRowDeleted: (_) {}, + onTableColumnInserted: (_, {required after}) {}, + onTableColumnDeleted: (_) {}, + onTableColumnAlignmentChanged: (_, _) {}, + onTableDeleted: () {}, + onImageEditRequested: () {}, + onHtmlEditRequested: () {}, + onTaskChanged: (_) {}, + onFocused: () => focusCalls++, ), ), ), ), - ); - - await _pumpUntilFound(tester, find.text('Broken third line')); - await tester.tap(find.text('Broken third line')); - await tester.pump(); - - expect(focusCalls, 1); - expect(focusNode.hasFocus, isTrue); - expect(controller.selection, const TextSelection.collapsed(offset: 13)); - }, - timeout: const Timeout(Duration(seconds: 10)), - ); + ), + ); + + await _pumpUntilFound(tester, find.text('Broken third line')); + await tester.tap(find.text('Broken third line')); + await tester.pump(); + + expect(focusCalls, 1); + expect(focusNode.hasFocus, isTrue); + expect(controller.selection, const TextSelection.collapsed(offset: 13)); + }, timeout: const Timeout(Duration(seconds: 10))); } Future _pumpUntilFound(WidgetTester tester, Finder finder) async {