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/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_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/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/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/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..969daf7 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. @@ -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/app_settings_test.dart b/test/src/app_settings_test.dart index 1154786..21e6e38 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( 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/native_headerbar_audit_test.dart b/test/src/native_headerbar_audit_test.dart index 933c7d5..b3d3ec5 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(); @@ -1639,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')); 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', 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('