feat: add file panel column view and folder sizes
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
+312
-11
@@ -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<void> _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<void> _enrichFolderSizes(List<CloudFile> files, int generation) async {
|
||||
final cache = _readFolderSizeCache();
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
final ttl = Duration(
|
||||
minutes:
|
||||
(int.tryParse(
|
||||
StorageManager.get<String>(
|
||||
StorageKeys.fileCacheTTLMinutes,
|
||||
) ??
|
||||
'3',
|
||||
) ??
|
||||
3)
|
||||
.clamp(1, 60),
|
||||
).inMilliseconds;
|
||||
final queue = <CloudFile>[];
|
||||
final cached = <String, int>{};
|
||||
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<String, int> 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 = <String, int>{};
|
||||
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<int>()
|
||||
.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<String, Map<String, dynamic>> _readFolderSizeCache() {
|
||||
final raw = StorageManager.get<dynamic>(StorageKeys.fileMetadataCache);
|
||||
if (raw is! Map) return <String, Map<String, dynamic>>{};
|
||||
return raw.map(
|
||||
(key, value) => MapEntry(
|
||||
key.toString(),
|
||||
value is Map ? Map<String, dynamic>.from(value) : <String, dynamic>{},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String? get _currentParentID => _path.isEmpty ? null : _path.last.id;
|
||||
|
||||
Future<void> _moveCloudFiles(List<CloudFile> 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,
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -137,6 +137,7 @@ class FileState {
|
||||
|
||||
class FileNotifier extends StateNotifier<FileState> {
|
||||
GuangyaAPI? _api;
|
||||
var _detailGeneration = 0;
|
||||
|
||||
FileNotifier() : super(const FileState());
|
||||
|
||||
@@ -159,12 +160,14 @@ class FileNotifier extends StateNotifier<FileState> {
|
||||
|
||||
Future<void> 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<FileState> {
|
||||
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<FileState> {
|
||||
await StorageManager.set(StorageKeys.fileListCache, cache);
|
||||
}
|
||||
|
||||
Future<void> _enrichFolderSizes(List<CloudFile> 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<String>(
|
||||
StorageKeys.fileCacheTTLMinutes,
|
||||
) ??
|
||||
'3',
|
||||
) ??
|
||||
3)
|
||||
.clamp(1, 60),
|
||||
).inMilliseconds;
|
||||
final pending = <CloudFile>[];
|
||||
final known = <String, int>{};
|
||||
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<String, int> 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<CloudFile>.from(pending);
|
||||
const concurrency = 6;
|
||||
final resolved = <String, int>{};
|
||||
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<CloudFile?>()
|
||||
.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<String, Map<String, dynamic>> _readMetadataCache() {
|
||||
final raw = StorageManager.get<dynamic>(StorageKeys.fileMetadataCache);
|
||||
if (raw is! Map) return <String, Map<String, dynamic>>{};
|
||||
return raw.map(
|
||||
(key, value) => MapEntry(
|
||||
key.toString(),
|
||||
value is Map ? Map<String, dynamic>.from(value) : <String, dynamic>{},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
int? _findIntDeep(Map<String, dynamic> value, List<String> 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<String, dynamic>.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<String, dynamic>.from(child), keys);
|
||||
if (found != null) return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _fetchFiles(String? parentID) async {
|
||||
switch (state.section) {
|
||||
case WorkspaceSection.files:
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user