diff --git a/lib/pages/workspace_page.dart b/lib/pages/workspace_page.dart index 8ccb554..2163847 100644 --- a/lib/pages/workspace_page.dart +++ b/lib/pages/workspace_page.dart @@ -46,6 +46,26 @@ class _DraggedCloudFiles { bool _hasPressedKey(LogicalKeyboardKey key) => HardwareKeyboard.instance.logicalKeysPressed.contains(key); +const _desktopSelectAllShortcuts = { + SingleActivator(LogicalKeyboardKey.keyA, meta: true): _SelectAllFilesIntent(), + SingleActivator(LogicalKeyboardKey.keyA, control: true): + _SelectAllFilesIntent(), +}; + +class _SelectAllFilesIntent extends Intent { + const _SelectAllFilesIntent(); +} + +class _DeleteSelectedIntent extends Intent { + const _DeleteSelectedIntent(); +} + +const _desktopDeleteShortcuts = { + SingleActivator(LogicalKeyboardKey.delete): _DeleteSelectedIntent(), + SingleActivator(LogicalKeyboardKey.backspace, meta: true): + _DeleteSelectedIntent(), +}; + void _selectDesktopFile(FileNotifier notifier, CloudFile file) { notifier.selectWithModifiers( file.id, @@ -102,6 +122,40 @@ void _openCloudFile(BuildContext context, WidgetRef ref, CloudFile file) { ref.read(fileProvider.notifier).downloadFile(file); } +Future _confirmDeleteCloudFiles( + BuildContext context, + List files, + Future Function() onConfirm, +) async { + if (files.isEmpty) return; + final confirmed = await showShadDialog( + context: context, + builder: (dialogContext) => ShadDialog( + title: Text('删除 ${files.length} 项?'), + description: Text( + files.length == 1 + ? files.first.name + : '将删除所选的 ${files.length} 个文件或文件夹。', + ), + actions: [ + ShadButton.outline( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('取消'), + ), + ShadButton.destructive( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('删除'), + ), + ], + child: const Padding( + padding: EdgeInsets.only(top: 8), + child: Text('此操作会将项目移入回收站。'), + ), + ), + ); + if (confirmed == true) await onConfirm(); +} + class WorkspacePage extends ConsumerStatefulWidget { const WorkspacePage({super.key}); @@ -1320,6 +1374,7 @@ class _PrimaryFilePane extends ConsumerWidget { initialPath: state.folderPath, initialFiles: state.files, onViewModeChanged: onViewModeChanged, + allowDelete: state.section != WorkspaceSection.recycle, ); } final notifier = ref.read(fileProvider.notifier); @@ -1358,6 +1413,21 @@ class _PrimaryFilePane extends ConsumerWidget { itemIDs: files.map((file) => file.id).toList(growable: false), selectedIDs: state.selectedIDs, onMarqueeSelectionChanged: notifier.setSelection, + onSelectAll: notifier.selectAll, + onDeleteSelected: state.section == WorkspaceSection.recycle + ? null + : () { + final selected = files + .where((file) => state.selectedIDs.contains(file.id)) + .toList(); + unawaited( + _confirmDeleteCloudFiles( + context, + selected, + () => notifier.deleteFiles(selected), + ), + ); + }, itemBuilder: (context, index) { final file = files[index]; final selected = state.selectedIDs.contains(file.id); @@ -1476,6 +1546,8 @@ class _FilePaneCollection extends StatefulWidget { final List? itemIDs; final Set? selectedIDs; final ValueChanged>? onMarqueeSelectionChanged; + final VoidCallback? onSelectAll; + final VoidCallback? onDeleteSelected; const _FilePaneCollection({ required this.viewMode, @@ -1484,6 +1556,8 @@ class _FilePaneCollection extends StatefulWidget { this.itemIDs, this.selectedIDs, this.onMarqueeSelectionChanged, + this.onSelectAll, + this.onDeleteSelected, }); @override @@ -1494,6 +1568,7 @@ class _FilePaneCollection extends StatefulWidget { /// visible item intersected by the rectangle. Command/Ctrl preserves selection. class _FilePaneCollectionState extends State<_FilePaneCollection> { final _itemKeys = {}; + final _focusNode = FocusNode(debugLabel: 'file-pane'); Offset? _start; Offset? _current; Set _selectionBefore = const {}; @@ -1555,6 +1630,12 @@ class _FilePaneCollectionState extends State<_FilePaneCollection> { }); } + @override + void dispose() { + _focusNode.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final content = widget.viewMode == _FileViewMode.list @@ -1586,26 +1667,47 @@ class _FilePaneCollectionState extends State<_FilePaneCollection> { final current = _current; final showMarquee = start != null && current != null && (current - start).distance >= 4; - return Listener( - behavior: HitTestBehavior.translucent, - onPointerDown: _pointerDown, - onPointerMove: _pointerMove, - onPointerUp: _pointerEnd, - onPointerCancel: _pointerEnd, - child: Stack( - fit: StackFit.expand, - children: [ - content, - if (showMarquee) - IgnorePointer( - child: CustomPaint( - painter: _MarqueeSelectionPainter( - Rect.fromPoints(start, current), - ShadTheme.of(context).colorScheme.primary, + return FocusableActionDetector( + focusNode: _focusNode, + actions: { + _SelectAllFilesIntent: CallbackAction<_SelectAllFilesIntent>( + onInvoke: (_) { + widget.onSelectAll?.call(); + return null; + }, + ), + _DeleteSelectedIntent: CallbackAction<_DeleteSelectedIntent>( + onInvoke: (_) { + widget.onDeleteSelected?.call(); + return null; + }, + ), + }, + shortcuts: {..._desktopSelectAllShortcuts, ..._desktopDeleteShortcuts}, + child: Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: (event) { + _focusNode.requestFocus(); + _pointerDown(event); + }, + onPointerMove: _pointerMove, + onPointerUp: _pointerEnd, + onPointerCancel: _pointerEnd, + child: Stack( + fit: StackFit.expand, + children: [ + content, + if (showMarquee) + IgnorePointer( + child: CustomPaint( + painter: _MarqueeSelectionPainter( + Rect.fromPoints(start, current), + ShadTheme.of(context).colorScheme.primary, + ), ), ), - ), - ], + ], + ), ), ); } @@ -1688,6 +1790,7 @@ class _ColumnFileBrowser extends ConsumerStatefulWidget { onMoveCloudFiles; final Future Function(List files, String? parentID)? onUploadLocalFiles; + final bool allowDelete; final ValueChanged>? onPathChanged; const _ColumnFileBrowser({ @@ -1698,6 +1801,7 @@ class _ColumnFileBrowser extends ConsumerStatefulWidget { this.source = _PaneIdentity.primary, this.onMoveCloudFiles, this.onUploadLocalFiles, + this.allowDelete = true, this.onPathChanged, }); @@ -1973,6 +2077,28 @@ class _ColumnFileBrowserState extends ConsumerState<_ColumnFileBrowser> { if (mounted) await _loadColumn(index); } + void _selectAllColumn(int index) { + if (index >= _columns.length) return; + setState(() { + final columns = _columns.toList(); + final column = columns[index]; + columns[index] = column.copyWith( + selectedIDs: column.files.map((file) => file.id).toSet(), + ); + _columns = columns; + }); + } + + void _deleteColumnFiles(int index, List files) { + if (!widget.allowDelete || files.isEmpty) return; + unawaited( + _confirmDeleteCloudFiles(context, files, () async { + await ref.read(fileProvider.notifier).deleteFiles(files); + if (mounted) await _loadColumn(index); + }), + ); + } + @override Widget build(BuildContext context) { final itemCount = _columns.isEmpty ? 0 : _columns.last.files.length; @@ -2018,6 +2144,9 @@ class _ColumnFileBrowserState extends ConsumerState<_ColumnFileBrowser> { column: _columns[index], source: widget.source, onSelect: (file) => _selectColumnFile(index, file), + onSelectAll: () => _selectAllColumn(index), + onDeleteSelected: (files) => + _deleteColumnFiles(index, files), onOpenFolder: (folder) => _openFolder(index, folder), onOpenFile: (file) => _openCloudFile(context, ref, file), onMoveCloudFiles: (files, parentID) => @@ -2062,6 +2191,8 @@ class _FinderColumn extends StatefulWidget { final _ColumnListing column; final _PaneIdentity source; final ValueChanged onSelect; + final VoidCallback onSelectAll; + final ValueChanged> onDeleteSelected; final ValueChanged onOpenFolder; final ValueChanged onOpenFile; final ValueChanged onCopyFastTransfer; @@ -2075,6 +2206,8 @@ class _FinderColumn extends StatefulWidget { required this.column, required this.source, required this.onSelect, + required this.onSelectAll, + required this.onDeleteSelected, required this.onOpenFolder, required this.onOpenFile, required this.onCopyFastTransfer, @@ -2088,138 +2221,175 @@ class _FinderColumn extends StatefulWidget { class _FinderColumnState extends State<_FinderColumn> { var _dragActive = false; + final _focusNode = FocusNode(debugLabel: 'finder-column'); + + @override + void dispose() { + _focusNode.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { final cs = ShadTheme.of(context).colorScheme; final column = widget.column; - return SizedBox( - width: 244, - child: DropTarget( - onDragEntered: (_) => setState(() => _dragActive = true), - onDragExited: (_) => setState(() => _dragActive = false), - onDragDone: (details) async { - setState(() => _dragActive = false); - final files = details.files - .map((file) => file.path) - .where((path) => path.isNotEmpty) - .map(File.new) - .where((file) => file.existsSync()) - .toList(); - if (files.isNotEmpty) { - await widget.onUploadLocalFiles(files, column.parentID); - } - }, - child: DragTarget<_DraggedCloudFiles>( - onWillAcceptWithDetails: (_) => true, - onAcceptWithDetails: (details) async { - setState(() => _dragActive = false); - await widget.onMoveCloudFiles(details.data.files, column.parentID); + return FocusableActionDetector( + focusNode: _focusNode, + shortcuts: {..._desktopSelectAllShortcuts, ..._desktopDeleteShortcuts}, + actions: { + _SelectAllFilesIntent: CallbackAction<_SelectAllFilesIntent>( + onInvoke: (_) { + widget.onSelectAll(); + return null; }, - onLeave: (_) => setState(() => _dragActive = false), - builder: (context, candidates, rejected) => AnimatedContainer( - duration: const Duration(milliseconds: 120), - decoration: BoxDecoration( - color: _dragActive || candidates.isNotEmpty - ? cs.primary.withValues(alpha: 0.10) - : Colors.white.withValues(alpha: 0.22), - border: Border( - right: BorderSide(color: cs.border.withValues(alpha: 0.70)), - left: BorderSide( + ), + _DeleteSelectedIntent: CallbackAction<_DeleteSelectedIntent>( + onInvoke: (_) { + final selected = widget.column.files + .where((file) => widget.column.selectedIDs.contains(file.id)) + .toList(); + widget.onDeleteSelected(selected); + return null; + }, + ), + }, + child: Listener( + onPointerDown: (_) => _focusNode.requestFocus(), + child: SizedBox( + width: 244, + child: DropTarget( + onDragEntered: (_) => setState(() => _dragActive = true), + onDragExited: (_) => setState(() => _dragActive = false), + onDragDone: (details) async { + setState(() => _dragActive = false); + final files = details.files + .map((file) => file.path) + .where((path) => path.isNotEmpty) + .map(File.new) + .where((file) => file.existsSync()) + .toList(); + if (files.isNotEmpty) { + await widget.onUploadLocalFiles(files, column.parentID); + } + }, + child: DragTarget<_DraggedCloudFiles>( + onWillAcceptWithDetails: (_) => true, + onAcceptWithDetails: (details) async { + setState(() => _dragActive = false); + await widget.onMoveCloudFiles( + details.data.files, + column.parentID, + ); + }, + onLeave: (_) => setState(() => _dragActive = false), + builder: (context, candidates, rejected) => AnimatedContainer( + duration: const Duration(milliseconds: 120), + decoration: BoxDecoration( color: _dragActive || candidates.isNotEmpty - ? cs.primary - : Colors.transparent, - width: 2, - ), - ), - ), - child: Column( - children: [ - Container( - height: 34, - padding: const EdgeInsets.symmetric(horizontal: 10), - alignment: Alignment.centerLeft, - child: Text( - column.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w800, - color: cs.foreground, + ? cs.primary.withValues(alpha: 0.10) + : Colors.white.withValues(alpha: 0.22), + border: Border( + right: BorderSide(color: cs.border.withValues(alpha: 0.70)), + left: BorderSide( + color: _dragActive || candidates.isNotEmpty + ? cs.primary + : Colors.transparent, + width: 2, ), ), ), - Divider(height: 1, color: cs.border.withValues(alpha: 0.60)), - Expanded( - child: column.isLoading - ? const _ShadLoading() - : column.errorMessage != null - ? Center( - child: Padding( - padding: const EdgeInsets.all(12), - child: Text( - column.errorMessage!, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11, - color: cs.destructive, - ), - ), - ), - ) - : ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 4), - itemCount: column.files.length, - itemBuilder: (context, index) { - final file = column.files[index]; - final selected = column.selectedIDs.contains( - file.id, - ); - final row = _FastTransferContextMenu( - file: file, - onCopyFastTransfer: () => - widget.onCopyFastTransfer(file), - child: _FinderColumnItem( - file: file, - selected: selected, - onTap: () => widget.onSelect(file), - onOpen: () { - if (file.isDirectory) { - widget.onOpenFolder(file); - } else { - widget.onOpenFile(file); - } - }, - ), - ); - return Draggable<_DraggedCloudFiles>( - data: _DraggedCloudFiles( - selected - ? column.files - .where( - (item) => column.selectedIDs - .contains(item.id), - ) - .toList() - : [file], - widget.source, - ), - feedback: _DragFeedback(label: file.name), - childWhenDragging: Opacity( - opacity: 0.35, - child: row, - ), - child: _FolderMoveTarget( - file: file, - onMove: widget.onMoveCloudFiles, - child: row, - ), - ); - }, + child: Column( + children: [ + Container( + height: 34, + padding: const EdgeInsets.symmetric(horizontal: 10), + alignment: Alignment.centerLeft, + child: Text( + column.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w800, + color: cs.foreground, ), + ), + ), + Divider( + height: 1, + color: cs.border.withValues(alpha: 0.60), + ), + Expanded( + child: column.isLoading + ? const _ShadLoading() + : column.errorMessage != null + ? Center( + child: Padding( + padding: const EdgeInsets.all(12), + child: Text( + column.errorMessage!, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + color: cs.destructive, + ), + ), + ), + ) + : ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 4), + itemCount: column.files.length, + itemBuilder: (context, index) { + final file = column.files[index]; + final selected = column.selectedIDs.contains( + file.id, + ); + final row = _FastTransferContextMenu( + file: file, + onCopyFastTransfer: () => + widget.onCopyFastTransfer(file), + child: _FinderColumnItem( + file: file, + selected: selected, + onTap: () => widget.onSelect(file), + onOpen: () { + if (file.isDirectory) { + widget.onOpenFolder(file); + } else { + widget.onOpenFile(file); + } + }, + ), + ); + return Draggable<_DraggedCloudFiles>( + data: _DraggedCloudFiles( + selected + ? column.files + .where( + (item) => column.selectedIDs + .contains(item.id), + ) + .toList() + : [file], + widget.source, + ), + feedback: _DragFeedback(label: file.name), + childWhenDragging: Opacity( + opacity: 0.35, + child: row, + ), + child: _FolderMoveTarget( + file: file, + onMove: widget.onMoveCloudFiles, + child: row, + ), + ); + }, + ), + ), + ], ), - ], + ), ), ), ), @@ -2756,6 +2926,22 @@ class _SecondaryFilePaneState extends ConsumerState<_SecondaryFilePane> { ..clear() ..addAll(ids); }), + onSelectAll: () => setState(() { + _selectedIDs + ..clear() + ..addAll(_files.map((file) => file.id)); + }), + onDeleteSelected: () { + final selected = _files + .where((file) => _selectedIDs.contains(file.id)) + .toList(); + unawaited( + _confirmDeleteCloudFiles(context, selected, () async { + await ref.read(fileProvider.notifier).deleteFiles(selected); + if (mounted) await _load(parentID: _currentParentID); + }), + ); + }, itemBuilder: (context, index) { final file = _files[index]; final selected = _selectedIDs.contains(file.id); diff --git a/lib/providers/file_provider.dart b/lib/providers/file_provider.dart index 3894e2e..eedd718 100644 --- a/lib/providers/file_provider.dart +++ b/lib/providers/file_provider.dart @@ -531,11 +531,7 @@ class FileNotifier extends StateNotifier { } void selectAll() { - if (state.selectedIDs.length == state.files.length) { - state = state.copyWith(selectedIDs: {}); - } else { - state = state.copyWith(selectedIDs: state.files.map((f) => f.id).toSet()); - } + state = state.copyWith(selectedIDs: state.files.map((f) => f.id).toSet()); } void clearSelection() {