diff --git a/lib/core/storage/storage_manager.dart b/lib/core/storage/storage_manager.dart index 0e87df1..a2b9f78 100644 --- a/lib/core/storage/storage_manager.dart +++ b/lib/core/storage/storage_manager.dart @@ -16,6 +16,7 @@ class StorageKeys { static const String mediaLibraryItems = 'guangya.mediaLibraryItems'; static const String mediaCategoryRules = 'guangya.mediaCategoryRules'; static const String fileListCache = 'guangya.fileListCache'; + static const String fileMetadataCache = 'guangya.fileMetadataCache'; static const String httpProxyHost = 'guangya.httpProxyHost'; static const String httpProxyPort = 'guangya.httpProxyPort'; static const String mediaScanConcurrency = 'guangya.mediaScanConcurrency'; diff --git a/lib/models/cloud_file.dart b/lib/models/cloud_file.dart index d3804cd..5a59f3b 100644 --- a/lib/models/cloud_file.dart +++ b/lib/models/cloud_file.dart @@ -93,6 +93,33 @@ class CloudFile { return _formatBytes(size!); } + CloudFile copyWith({ + String? id, + String? name, + bool? isDirectory, + int? size, + bool clearSize = false, + String? gcid, + int? subDirectoryCount, + int? subFileCount, + String? modifiedAt, + String? cloudPath, + int? fileType, + }) { + return CloudFile( + id: id ?? this.id, + name: name ?? this.name, + isDirectory: isDirectory ?? this.isDirectory, + size: clearSize ? null : (size ?? this.size), + gcid: gcid ?? this.gcid, + subDirectoryCount: subDirectoryCount ?? this.subDirectoryCount, + subFileCount: subFileCount ?? this.subFileCount, + modifiedAt: modifiedAt ?? this.modifiedAt, + cloudPath: cloudPath ?? this.cloudPath, + fileType: fileType ?? this.fileType, + ); + } + static String _formatBytes(int bytes) { if (bytes < 1024) return '$bytes B'; if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; diff --git a/lib/pages/workspace_page.dart b/lib/pages/workspace_page.dart index 5fd3a91..881d052 100644 --- a/lib/pages/workspace_page.dart +++ b/lib/pages/workspace_page.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:desktop_drop/desktop_drop.dart'; @@ -8,6 +9,7 @@ import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:window_manager/window_manager.dart'; import '../app/app_theme.dart'; +import '../core/storage/storage_manager.dart'; import '../models/cloud_file.dart'; import '../models/media_library.dart'; import '../providers/auth_provider.dart'; @@ -15,6 +17,7 @@ import '../providers/file_provider.dart'; import '../providers/media_library_provider.dart'; import '../widgets/breadcrumb_bar.dart'; import '../widgets/file_list_tile.dart'; +import '../widgets/file_icon.dart'; import '../widgets/side_panel.dart'; import '../widgets/sort_menu.dart'; import 'media_library_page.dart'; @@ -26,6 +29,8 @@ enum WorkspaceMode { cloud, media } enum _PaneLayoutMode { single, dual } +enum _FileViewMode { list, columns } + enum _PaneIdentity { primary, secondary } class _DraggedCloudFiles { @@ -789,6 +794,7 @@ class _CloudWorkspace extends ConsumerStatefulWidget { class _CloudWorkspaceState extends ConsumerState<_CloudWorkspace> { _PaneLayoutMode _paneMode = _PaneLayoutMode.dual; + _FileViewMode _primaryViewMode = _FileViewMode.list; @override Widget build(BuildContext context) { @@ -819,16 +825,29 @@ class _CloudWorkspaceState extends ConsumerState<_CloudWorkspace> { child: _PrimaryFilePane( title: '左侧面板', state: state, + viewMode: _primaryViewMode, + onViewModeChanged: (mode) => setState( + () => _primaryViewMode = mode, + ), ), ), const SizedBox(width: 10), const Expanded(child: _SecondaryFilePane()), ], ) - : _PrimaryFilePane(title: '文件列表', state: state) + : _PrimaryFilePane( + title: '文件列表', + state: state, + viewMode: _primaryViewMode, + onViewModeChanged: (mode) => + setState(() => _primaryViewMode = mode), + ) : _PrimaryFilePane( title: state.section.label, state: state, + viewMode: _primaryViewMode, + onViewModeChanged: (mode) => + setState(() => _primaryViewMode = mode), ), ), ], @@ -1036,8 +1055,15 @@ class _ToolbarButton extends StatelessWidget { class _PrimaryFilePane extends ConsumerWidget { final String title; final FileState state; + final _FileViewMode viewMode; + final ValueChanged<_FileViewMode> onViewModeChanged; - const _PrimaryFilePane({required this.title, required this.state}); + const _PrimaryFilePane({ + required this.title, + required this.state, + required this.viewMode, + required this.onViewModeChanged, + }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -1068,11 +1094,11 @@ class _PrimaryFilePane extends ConsumerWidget { ? null : notifier.nextPage, onPageSizeChanged: notifier.setPageSize, + trailing: _PaneViewToggle(value: viewMode, onChanged: onViewModeChanged), child: RefreshIndicator( onRefresh: () => notifier.loadFiles(), - child: ListView.builder( - physics: const AlwaysScrollableScrollPhysics(), - padding: EdgeInsets.zero, + child: _FilePaneCollection( + viewMode: viewMode, itemCount: files.length, itemBuilder: (context, index) { final file = files[index]; @@ -1100,12 +1126,48 @@ class _PrimaryFilePane extends ConsumerWidget { ? notifier.restoreFiles([file]) : notifier.deleteFiles([file]), ); - return Draggable<_DraggedCloudFiles>( + final item = Draggable<_DraggedCloudFiles>( data: _DraggedCloudFiles([file], _PaneIdentity.primary), feedback: _DragFeedback(label: file.name), childWhenDragging: Opacity(opacity: 0.35, child: tile), child: tile, ); + if (viewMode == _FileViewMode.list) return item; + return Draggable<_DraggedCloudFiles>( + data: _DraggedCloudFiles([file], _PaneIdentity.primary), + feedback: _DragFeedback(label: file.name), + childWhenDragging: Opacity( + opacity: 0.35, + child: _FileGridCard( + file: file, + isSelected: selected, + onSelect: () { + if (file.isDirectory && !selected) { + notifier.navigateToFolder(file); + } else { + notifier.toggleSelection(file.id); + } + }, + onOpen: file.isDirectory + ? () => notifier.navigateToFolder(file) + : () => notifier.downloadFile(file), + ), + ), + child: _FileGridCard( + file: file, + isSelected: selected, + onSelect: () { + if (file.isDirectory && !selected) { + notifier.navigateToFolder(file); + } else { + notifier.toggleSelection(file.id); + } + }, + onOpen: file.isDirectory + ? () => notifier.navigateToFolder(file) + : () => notifier.downloadFile(file), + ), + ); }, ), ), @@ -1113,6 +1175,128 @@ class _PrimaryFilePane extends ConsumerWidget { } } +class _PaneViewToggle extends StatelessWidget { + final _FileViewMode value; + final ValueChanged<_FileViewMode> onChanged; + + const _PaneViewToggle({required this.value, required this.onChanged}); + + @override + Widget build(BuildContext context) { + final columns = value == _FileViewMode.columns; + return _PaneIconButton( + icon: columns ? Icons.view_list_rounded : Icons.grid_view_rounded, + tooltip: columns ? '切换列表模式' : '切换分栏模式', + onTap: () => + onChanged(columns ? _FileViewMode.list : _FileViewMode.columns), + ); + } +} + +class _FilePaneCollection extends StatelessWidget { + final _FileViewMode viewMode; + final int itemCount; + final IndexedWidgetBuilder itemBuilder; + + const _FilePaneCollection({ + required this.viewMode, + required this.itemCount, + required this.itemBuilder, + }); + + @override + Widget build(BuildContext context) { + if (viewMode == _FileViewMode.list) { + return ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + padding: EdgeInsets.zero, + itemCount: itemCount, + itemBuilder: itemBuilder, + ); + } + return GridView.builder( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(10), + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 168, + mainAxisExtent: 132, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + ), + itemCount: itemCount, + itemBuilder: itemBuilder, + ); + } +} + +class _FileGridCard extends StatelessWidget { + final CloudFile file; + final bool isSelected; + final VoidCallback onSelect; + final VoidCallback onOpen; + + const _FileGridCard({ + required this.file, + required this.onSelect, + required this.onOpen, + this.isSelected = false, + }); + + @override + Widget build(BuildContext context) { + final cs = ShadTheme.of(context).colorScheme; + return Semantics( + button: true, + label: + '${file.name},${file.isDirectory ? '文件夹' : file.typeName},${file.formattedSize}', + child: GestureDetector( + onTap: onSelect, + onDoubleTap: onOpen, + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: isSelected + ? cs.primary.withValues(alpha: 0.12) + : Colors.white.withValues(alpha: 0.34), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isSelected + ? cs.primary.withValues(alpha: 0.65) + : cs.border.withValues(alpha: 0.58), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(width: 38, height: 38, child: FileIcon(file: file)), + const Spacer(), + Text( + file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: cs.foreground, + ), + ), + const SizedBox(height: 3), + Text( + file.isDirectory && file.subFileCount != null + ? '${file.formattedSize} · ${file.subFileCount} 项' + : file.formattedSize, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 11, color: cs.mutedForeground), + ), + ], + ), + ), + ), + ); + } +} + class _SecondaryFilePane extends ConsumerStatefulWidget { const _SecondaryFilePane(); @@ -1128,6 +1312,8 @@ class _SecondaryFilePaneState extends ConsumerState<_SecondaryFilePane> { var _page = 0; var _pageSize = 50; var _totalPages = 1; + var _viewMode = _FileViewMode.list; + var _detailGeneration = 0; @override void initState() { @@ -1136,6 +1322,7 @@ class _SecondaryFilePaneState extends ConsumerState<_SecondaryFilePane> { } Future _load({String? parentID}) async { + final generation = ++_detailGeneration; setState(() { _loading = true; _error = null; @@ -1152,6 +1339,7 @@ class _SecondaryFilePaneState extends ConsumerState<_SecondaryFilePane> { _files = files; _totalPages = _extractTotalPages(result, files.length); }); + unawaited(_enrichFolderSizes(files, generation)); } catch (e) { setState(() => _error = e.toString()); } finally { @@ -1159,6 +1347,87 @@ class _SecondaryFilePaneState extends ConsumerState<_SecondaryFilePane> { } } + Future _enrichFolderSizes(List files, int generation) async { + final cache = _readFolderSizeCache(); + final now = DateTime.now().millisecondsSinceEpoch; + final ttl = Duration( + minutes: + (int.tryParse( + StorageManager.get( + StorageKeys.fileCacheTTLMinutes, + ) ?? + '3', + ) ?? + 3) + .clamp(1, 60), + ).inMilliseconds; + final queue = []; + final cached = {}; + for (final file in files.where((file) => file.isDirectory)) { + final entry = cache[file.id]; + final cachedAt = int.tryParse(entry?['cachedAt']?.toString() ?? ''); + final size = int.tryParse(entry?['size']?.toString() ?? ''); + if (cachedAt != null && size != null && now - cachedAt <= ttl) { + cached[file.id] = size; + } else { + queue.add(file); + } + } + + void apply(Map sizes) { + if (!mounted || generation != _detailGeneration || sizes.isEmpty) return; + setState(() { + _files = _files + .map( + (file) => sizes.containsKey(file.id) + ? file.copyWith(size: sizes[file.id]) + : file, + ) + .toList(); + }); + } + + apply(cached); + if (queue.isEmpty) return; + final api = ref.read(authProvider.notifier).api; + final resolved = {}; + await Future.wait( + List.generate(6, (_) async { + while (queue.isNotEmpty) { + final folder = queue.removeLast(); + try { + final detail = await api.fsDetail(folder.id); + final size = _extractFiles(detail) + .where((file) => file.id == folder.id) + .map((file) => file.size) + .whereType() + .firstOrNull; + if (size == null) continue; + cache[folder.id] = {'size': size, 'cachedAt': now}; + resolved[folder.id] = size; + apply({folder.id: size}); + } catch (_) { + // Continue enriching the visible folders after an individual failure. + } + } + }), + ); + if (resolved.isNotEmpty) { + await StorageManager.set(StorageKeys.fileMetadataCache, cache); + } + } + + Map> _readFolderSizeCache() { + final raw = StorageManager.get(StorageKeys.fileMetadataCache); + if (raw is! Map) return >{}; + return raw.map( + (key, value) => MapEntry( + key.toString(), + value is Map ? Map.from(value) : {}, + ), + ); + } + String? get _currentParentID => _path.isEmpty ? null : _path.last.id; Future _moveCloudFiles(List files, String? parentID) async { @@ -1257,11 +1526,14 @@ class _SecondaryFilePaneState extends ConsumerState<_SecondaryFilePane> { }); _load(parentID: _currentParentID); }, + trailing: _PaneViewToggle( + value: _viewMode, + onChanged: (mode) => setState(() => _viewMode = mode), + ), child: RefreshIndicator( onRefresh: () => _load(parentID: _currentParentID), - child: ListView.builder( - physics: const AlwaysScrollableScrollPhysics(), - padding: EdgeInsets.zero, + child: _FilePaneCollection( + viewMode: _viewMode, itemCount: _files.length, itemBuilder: (context, index) { final file = _files[index]; @@ -1300,11 +1572,36 @@ class _SecondaryFilePaneState extends ConsumerState<_SecondaryFilePane> { onDelete: () => ref.read(fileProvider.notifier).deleteFiles([file]), ); + if (_viewMode == _FileViewMode.list) { + return Draggable<_DraggedCloudFiles>( + data: _DraggedCloudFiles([file], _PaneIdentity.secondary), + feedback: _DragFeedback(label: file.name), + childWhenDragging: Opacity(opacity: 0.35, child: row), + child: row, + ); + } + void open() { + if (file.isDirectory) { + setState(() { + _path.add(file); + _page = 0; + }); + _load(parentID: file.id); + } else { + ref.read(fileProvider.notifier).downloadFile(file); + } + } + + final card = _FileGridCard( + file: file, + onSelect: open, + onOpen: open, + ); return Draggable<_DraggedCloudFiles>( data: _DraggedCloudFiles([file], _PaneIdentity.secondary), feedback: _DragFeedback(label: file.name), - childWhenDragging: Opacity(opacity: 0.35, child: row), - child: row, + childWhenDragging: Opacity(opacity: 0.35, child: card), + child: card, ); }, ), @@ -1526,6 +1823,7 @@ class _PaneIconButton extends StatelessWidget { class _FilePaneFrame extends StatelessWidget { final String title; + final Widget? trailing; final int itemCount; final bool isLoading; final String? errorMessage; @@ -1550,6 +1848,7 @@ class _FilePaneFrame extends StatelessWidget { const _FilePaneFrame({ required this.title, + this.trailing, required this.itemCount, required this.isLoading, required this.errorMessage, @@ -1604,6 +1903,8 @@ class _FilePaneFrame extends StatelessWidget { '$itemCount 项', style: TextStyle(fontSize: 11, color: cs.mutedForeground), ), + const Spacer(), + if (trailing case final Widget trailing) trailing, ], ), ), diff --git a/lib/providers/file_provider.dart b/lib/providers/file_provider.dart index b479dca..dce9391 100644 --- a/lib/providers/file_provider.dart +++ b/lib/providers/file_provider.dart @@ -137,6 +137,7 @@ class FileState { class FileNotifier extends StateNotifier { GuangyaAPI? _api; + var _detailGeneration = 0; FileNotifier() : super(const FileState()); @@ -159,12 +160,14 @@ class FileNotifier extends StateNotifier { Future loadFiles({String? parentID}) async { if (_api == null) return; + final generation = ++_detailGeneration; final resolvedParentID = parentID ?? _currentParentID; final cacheKey = '${state.section.name}:${resolvedParentID ?? 'root'}:${state.currentPage}:${state.pageSize}'; final cached = _readCachedFiles(cacheKey); if (cached != null) { state = state.copyWith(files: cached, clearError: true); + unawaited(_enrichFolderSizes(cached, generation)); return; } state = state.copyWith(isLoading: true, clearError: true); @@ -175,6 +178,7 @@ class FileNotifier extends StateNotifier { final totalPages = _extractTotalPages(result, extracted.length); state = state.copyWith(files: extracted, totalPages: totalPages); await _writeCachedFiles(cacheKey, extracted); + unawaited(_enrichFolderSizes(extracted, generation)); } catch (e) { state = state.copyWith(errorMessage: e.toString()); } finally { @@ -222,6 +226,124 @@ class FileNotifier extends StateNotifier { await StorageManager.set(StorageKeys.fileListCache, cache); } + Future _enrichFolderSizes(List files, int generation) async { + if (_api == null || files.isEmpty) return; + final cache = _readMetadataCache(); + final now = DateTime.now().millisecondsSinceEpoch; + final ttl = Duration( + minutes: + (int.tryParse( + StorageManager.get( + StorageKeys.fileCacheTTLMinutes, + ) ?? + '3', + ) ?? + 3) + .clamp(1, 60), + ).inMilliseconds; + final pending = []; + final known = {}; + for (final file in files.where((file) => file.isDirectory)) { + final entry = cache[file.id]; + final cachedAt = int.tryParse(entry?['cachedAt']?.toString() ?? ''); + final cachedSize = int.tryParse(entry?['size']?.toString() ?? ''); + if (cachedAt != null && cachedSize != null && now - cachedAt <= ttl) { + known[file.id] = cachedSize; + } else { + pending.add(file); + } + } + + void apply(Map sizes) { + if (generation != _detailGeneration || sizes.isEmpty) return; + final updated = state.files + .map( + (file) => sizes.containsKey(file.id) + ? file.copyWith(size: sizes[file.id]) + : file, + ) + .toList(); + state = state.copyWith(files: updated); + } + + apply(known); + if (pending.isEmpty) return; + + final queue = List.from(pending); + const concurrency = 6; + final resolved = {}; + await Future.wait( + List.generate(concurrency, (_) async { + while (queue.isNotEmpty) { + final file = queue.removeLast(); + try { + final detail = await _api!.fsDetail(file.id); + final detailFile = _extractFiles(detail) + .cast() + .firstWhere( + (candidate) => candidate?.id == file.id, + orElse: () => null, + ); + final size = + detailFile?.size ?? + _findIntDeep(detail, const [ + 'size', + 'fileSize', + 'resSize', + 'totalSize', + 'dirSize', + 'folderSize', + ]); + if (size == null) continue; + cache[file.id] = {'size': size, 'cachedAt': now}; + resolved[file.id] = size; + apply({file.id: size}); + } catch (_) { + // A missing detail must not block the rest of the visible page. + } + } + }), + ); + if (resolved.isNotEmpty) { + await StorageManager.set(StorageKeys.fileMetadataCache, cache); + } + } + + Map> _readMetadataCache() { + final raw = StorageManager.get(StorageKeys.fileMetadataCache); + if (raw is! Map) return >{}; + return raw.map( + (key, value) => MapEntry( + key.toString(), + value is Map ? Map.from(value) : {}, + ), + ); + } + + int? _findIntDeep(Map value, List keys) { + for (final entry in value.entries) { + if (keys.contains(entry.key)) { + final parsed = int.tryParse(entry.value?.toString() ?? ''); + if (parsed != null) return parsed; + } + if (entry.value is Map) { + final found = _findIntDeep( + Map.from(entry.value), + keys, + ); + if (found != null) return found; + } else if (entry.value is List) { + for (final child in entry.value as List) { + if (child is Map) { + final found = _findIntDeep(Map.from(child), keys); + if (found != null) return found; + } + } + } + } + return null; + } + Future> _fetchFiles(String? parentID) async { switch (state.section) { case WorkspaceSection.files: diff --git a/lib/widgets/file_list_tile.dart b/lib/widgets/file_list_tile.dart index 084259b..412e40d 100644 --- a/lib/widgets/file_list_tile.dart +++ b/lib/widgets/file_list_tile.dart @@ -168,19 +168,18 @@ class FileListTile extends StatelessWidget { ), ), - // File size - if (!file.isDirectory) - SizedBox( - width: 80, - child: Text( - file.formattedSize, - textAlign: TextAlign.right, - style: TextStyle( - fontSize: 12, - color: theme.colorScheme.mutedForeground, - ), + // Folder sizes are enriched in the background just like files. + SizedBox( + width: 80, + child: Text( + file.formattedSize, + textAlign: TextAlign.right, + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.mutedForeground, ), ), + ), const SizedBox(width: 12),