WIP 87 저장 공간 분석: 고른 폴더 · 드라이브의 큰 폴더 · 큰 파일 차례, 누르면 탐색기에서 그 자리로 (검토 대기)
- 관리자 지시: 고른 폴더나 드라이브의 큰 폴더 · 큰 파일 차례. 80 의 폴더 크기 규칙 (링크 · junction 은 따라가지 않고 수만, 이미 센 폴더 (실제 경로) 는 다시 세지 않음, 취소) 을 그대로. - 여는 곳: 폴더 길게 누르기 [저장 공간 분석], ⋮ 메뉴 [저장 공간 분석] (지금 폴더 - 드라이브 맨 위면 드라이브 전체). - 한 번 훑으며 폴더마다 크기 (안쪽 합) · 파일 수와 큰 파일 200개를 모은다 → 폴더를 누르면 그 안으로 바로 (다시 읽지 않음), [위 폴더] · ← 로 나옴. "큰 폴더" (막대 = 지금 폴더 안 비율) · "큰 파일" (지금 폴더 아래). 읽지 못한 폴더 수 · 세지 않은 링크 수를 알린다. 읽는 동안 폴더 수 · 크기, [취소] (화면을 닫아도 멈춤). - 누르면 (파일) · [그 자리로] (폴더) 탐색기에서 그 자리로 - 지우기는 탐색기의 지우기 (확인 · 휴지통) 로만 (관리자 판단). - WebDAV 는 직접 시킬 때만 (이 메뉴) 하고, 먼저 "서버에서 모두 읽어 오래 걸릴 수 있습니다" 를 묻는다. - 시험: storage_scan_test (합 · 차례 · junction (위 폴더 고리 포함) 을 세지 않음 · 취소 · 화면 들어가기 · 큰 파일 · 돌려주는 경로), explorer_test 87 (길게 누르기 → 큰 파일 → 탐색기 그 자리). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>묶음6
parent
c440b688ea
commit
47144981d0
|
|
@ -0,0 +1,115 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'file_ops.dart';
|
||||
import 'folder_size.dart';
|
||||
import 'vfs.dart';
|
||||
|
||||
/// 87: 저장 공간 분석 결과 - 폴더마다 크기 (안쪽 모두 합) · 큰 파일 차례.
|
||||
/// 80 의 폴더 크기 규칙을 그대로: 링크 · junction 은 따라가지 않고 (수만), 이미 센 폴더 (실제 경로) 는 다시 세지 않고, 취소된다
|
||||
class StorageScan {
|
||||
final String root;
|
||||
|
||||
/// 폴더 → (크기, 파일 수). 열쇠는 [key] 로
|
||||
final Map<String, (int, int)> folders;
|
||||
|
||||
/// 큰 파일 (큰 것부터, [StorageScanner.topFiles] 개까지)
|
||||
final List<FileEntry> bigFiles;
|
||||
|
||||
/// 폴더 → 바로 안의 폴더들 (차례 없음)
|
||||
final Map<String, List<String>> children;
|
||||
|
||||
/// 읽지 못한 폴더 수 (권한 등 - 크기가 실제보다 작을 수 있음) · 세지 않은 링크 수
|
||||
final int unreadable, links;
|
||||
const StorageScan(this.root, this.folders, this.bigFiles, this.children, {this.unreadable = 0, this.links = 0});
|
||||
|
||||
static String key(String path) => isDav(path) ? vNorm(path) : path.toLowerCase();
|
||||
|
||||
int sizeOf(String dir) => folders[key(dir)]?.$1 ?? 0;
|
||||
int filesOf(String dir) => folders[key(dir)]?.$2 ?? 0;
|
||||
|
||||
/// [dir] 바로 안의 폴더를 큰 것부터
|
||||
List<String> biggestIn(String dir) => [...?children[key(dir)]]..sort((a, b) => sizeOf(b).compareTo(sizeOf(a)));
|
||||
}
|
||||
|
||||
/// 87: 고른 폴더 (또는 드라이브) 아래를 한 번 훑어 [StorageScan] 을 만든다
|
||||
class StorageScanner {
|
||||
/// 읽기 (시험에서 바꿔 끼움) - 80 과 같이 링크는 빼고 수만
|
||||
final Future<FolderListing> Function(String dir) list;
|
||||
final int topFiles;
|
||||
StorageScanner({Future<FolderListing> Function(String dir)? list, this.topFiles = 200}) : list = list ?? FolderSizes.listForSize;
|
||||
|
||||
/// [onProgress]: (읽은 폴더 수, 지금까지 크기). [cancelled] 가 참이면 [StorageScanCancelled]
|
||||
Future<StorageScan> scan(String root, {void Function(int dirs, int bytes)? onProgress, bool Function()? cancelled}) async {
|
||||
final key = StorageScan.key;
|
||||
final own = <String, (int, int)>{}; // 폴더 바로 안의 파일 크기 · 수
|
||||
final children = <String, List<String>>{};
|
||||
final parentOf = <String, String>{};
|
||||
final order = <String>[]; // 읽은 차례 (위 → 아래)
|
||||
final seen = <String>{};
|
||||
var big = <FileEntry>[];
|
||||
var bytes = 0, unreadable = 0, links = 0;
|
||||
final pending = [root];
|
||||
while (pending.isNotEmpty) {
|
||||
if (cancelled?.call() ?? false) throw const StorageScanCancelled();
|
||||
final dir = pending.removeLast();
|
||||
var real = dir;
|
||||
if (!isDav(dir)) {
|
||||
try {
|
||||
real = Directory(dir).resolveSymbolicLinksSync();
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!seen.add(key(real))) continue;
|
||||
order.add(dir);
|
||||
var size = 0, count = 0;
|
||||
try {
|
||||
final r = await list(dir);
|
||||
links += r.links;
|
||||
for (final e in r.entries) {
|
||||
if (e.isDir) {
|
||||
pending.add(e.path);
|
||||
children.putIfAbsent(key(dir), () => []).add(e.path);
|
||||
parentOf[key(e.path)] = key(dir);
|
||||
} else {
|
||||
final s = e.size > 0 ? e.size : 0;
|
||||
size += s;
|
||||
count++;
|
||||
if (big.length < topFiles || s > big.last.size) {
|
||||
big.add(e);
|
||||
big.sort((a, b) => b.size.compareTo(a.size));
|
||||
if (big.length > topFiles) big = big.sublist(0, topFiles);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
unreadable++;
|
||||
}
|
||||
own[key(dir)] = (size, count);
|
||||
bytes += size;
|
||||
if (order.length % 50 == 0) onProgress?.call(order.length, bytes);
|
||||
// 큰 폴더를 오래 읽을 때도 화면이 멈추지 않게
|
||||
if (order.length % 200 == 0) await Future<void>.delayed(Duration.zero);
|
||||
}
|
||||
// 아래부터 위로 더한다
|
||||
final total = {for (final e in own.entries) e.key: e.value};
|
||||
for (final d in order.reversed) {
|
||||
final k = key(d);
|
||||
final up = parentOf[k];
|
||||
if (up == null || !total.containsKey(up)) continue;
|
||||
final (s, n) = total[k]!;
|
||||
final (us, un) = total[up]!;
|
||||
total[up] = (us + s, un + n);
|
||||
}
|
||||
onProgress?.call(order.length, bytes);
|
||||
return StorageScan(root, total, big, children, unreadable: unreadable, links: links);
|
||||
}
|
||||
}
|
||||
|
||||
class StorageScanCancelled implements Exception {
|
||||
const StorageScanCancelled();
|
||||
}
|
||||
|
||||
/// 이름만 (화면 줄)
|
||||
String scanName(String path) => isDav(path) ? vBasename(path) : (p.basename(path).isEmpty ? path : p.basename(path));
|
||||
|
|
@ -47,6 +47,7 @@ import 'explorer_search.dart';
|
|||
import 'quick_look.dart';
|
||||
import 'explorer_drag.dart';
|
||||
import 'archive_ui.dart';
|
||||
import 'storage_page.dart';
|
||||
import '../core/archive_ops.dart' show ArchiveKind, archiveKindOf;
|
||||
import 'batch_rename_dialog.dart';
|
||||
import '../core/folder_size.dart';
|
||||
|
|
@ -161,7 +162,9 @@ enum ExplorerButton {
|
|||
// 118: 두 창을 좌우 ⇆ / 위아래 ⇅ 로 한 번에 바꾸기 (한 창이면 두 창 (좌우) 으로)
|
||||
orient(Icons.swap_horiz, '좌우 ⇆ / 위아래 ⇅'),
|
||||
layout(Icons.view_quilt_outlined, '창 배치'),
|
||||
buttons(Icons.tune, '버튼 구성');
|
||||
buttons(Icons.tune, '버튼 구성'),
|
||||
// 87: 지금 폴더 (드라이브) 의 큰 폴더 · 큰 파일
|
||||
storage(Icons.pie_chart_outline, '저장 공간 분석');
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
|
|
@ -1334,6 +1337,33 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
}
|
||||
|
||||
/// ZIP: 환경 설정이 "만화 보기" 면 안의 그림을 바로 보고 (그림이 없으면 목록), 아니면 목록
|
||||
/// 87: 저장 공간 분석 → 고른 것을 탐색기에서 그 자리로 (지우기는 탐색기의 지우기로만).
|
||||
/// WebDAV 는 직접 시킬 때만 (이 메뉴) 하고, 서버에서 모두 읽어 오래 걸린다고 먼저 알린다
|
||||
Future<void> _storage(_Pane pane, String dir) async {
|
||||
setState(() => _active = _panes.indexOf(pane));
|
||||
if (isDav(dir)) {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(tr('저장 공간 분석')),
|
||||
content: Text(tr('WebDAV 는 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다. 분석할까요?')),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('분석'))),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true || !mounted) return;
|
||||
}
|
||||
final path = await StorageAnalysisPage.open(context, dir);
|
||||
if (path == null || !mounted) return;
|
||||
final isDir = await vIsDir(path);
|
||||
await _goTo(pane, isDir ? path : vDirname(path), fresh: true);
|
||||
pane.focused = path;
|
||||
pane.changed();
|
||||
_reveal(pane, path);
|
||||
}
|
||||
|
||||
/// 86: 압축 풀기 (검사 · 확인 · 진행 · 취소) 뒤 목록 새로 고침
|
||||
Future<void> _extractArchive(String path, _Pane pane) async {
|
||||
final other = _dual ? _panes[1 - _panes.indexOf(pane)].current : null;
|
||||
|
|
@ -1412,6 +1442,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
// 86: 압축 풀기 (ZIP · 7z · RAR) · ZIP 으로 묶기 (표시한 것 모두)
|
||||
if (!e.isDir && archiveKindOf(e.path) != null) ('extract', Icons.unarchive_outlined, tr('압축 풀기')),
|
||||
if (!dav) ('zip', Icons.archive_outlined, many ? trf('{0}개 ZIP 으로 묶기', [targets.length]) : tr('ZIP 으로 묶기')),
|
||||
// 87: 저장 공간 분석 (이 폴더 아래의 큰 폴더 · 큰 파일)
|
||||
if (e.isDir) ('storage', Icons.pie_chart_outline, tr('저장 공간 분석')),
|
||||
// 80: 이 폴더 크기를 지금 계산 (설정이 "안 함" 이어도)
|
||||
if (e.isDir) ('folderSize', Icons.data_usage, _sizes.busy(e.path) ? tr('폴더 크기 계산 멈추기') : tr('폴더 크기 계산')),
|
||||
if (e.isDir && dual) ('openOther', Icons.vertical_split_outlined, tr('다른 창에서 열기')),
|
||||
|
|
@ -1503,6 +1535,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
await _quickLook(pane, e.path);
|
||||
case 'extract':
|
||||
await _extractArchive(e.path, pane);
|
||||
case 'storage':
|
||||
await _storage(pane, e.path);
|
||||
case 'zip':
|
||||
final items = outermostPaths(_isMarked(pane, e.path) && pane.marked.length > 1 ? pane.marked.toList() : [e.path]);
|
||||
final other = _dual ? _panes[1 - _panes.indexOf(pane)].current : null;
|
||||
|
|
@ -1719,6 +1753,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
await _delete(pane, _targets(pane));
|
||||
case ExplorerButton.search:
|
||||
await _search(pane);
|
||||
case ExplorerButton.storage:
|
||||
await _storage(pane, pane.current);
|
||||
case ExplorerButton.sort:
|
||||
await _sortDialog();
|
||||
case ExplorerButton.hidden:
|
||||
|
|
@ -3373,6 +3409,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
if (!widget.rsync) ...[ExplorerButton.layout, ExplorerButton.buttons],
|
||||
ExplorerButton.sort,
|
||||
ExplorerButton.hidden,
|
||||
if (!widget.rsync) ExplorerButton.storage,
|
||||
])
|
||||
PopupMenuItem(
|
||||
value: b,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,210 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../core/file_ops.dart';
|
||||
import '../core/storage_scan.dart';
|
||||
import '../core/vfs.dart';
|
||||
import '../l10n/tr.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
/// 87: 저장 공간 분석 - 고른 폴더 · 드라이브 아래의 큰 폴더 · 큰 파일 차례.
|
||||
/// 폴더를 누르면 그 안으로 (한 번 훑은 결과로 바로), [그 자리로] 는 탐색기에서 그 자리로 (돌려주는 값).
|
||||
/// 지우기는 여기서 하지 않는다 - 탐색기의 지우기 (확인 · 휴지통) 로만 (관리자 판단)
|
||||
class StorageAnalysisPage extends StatefulWidget {
|
||||
final String root;
|
||||
|
||||
/// 시험에서 바꿔 끼움
|
||||
final StorageScanner? scanner;
|
||||
const StorageAnalysisPage({super.key, required this.root, this.scanner});
|
||||
|
||||
/// 연다. 탐색기에서 갈 자리 (없으면 null)
|
||||
static Future<String?> open(BuildContext context, String root) =>
|
||||
Navigator.of(context).push<String>(MaterialPageRoute(builder: (_) => StorageAnalysisPage(root: root)));
|
||||
|
||||
@override
|
||||
State<StorageAnalysisPage> createState() => _StorageAnalysisPageState();
|
||||
}
|
||||
|
||||
class _StorageAnalysisPageState extends State<StorageAnalysisPage> {
|
||||
StorageScan? _scan;
|
||||
Object? _error;
|
||||
var _dirs = 0, _bytes = 0;
|
||||
var _stop = false;
|
||||
late String _cur = widget.root;
|
||||
var _tab = 0; // 0 큰 폴더 · 1 큰 파일
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_run();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stop = true; // 화면을 닫으면 멈춘다
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _run() async {
|
||||
try {
|
||||
final s = await (widget.scanner ?? StorageScanner()).scan(widget.root, cancelled: () => _stop, onProgress: (d, b) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_dirs = d;
|
||||
_bytes = b;
|
||||
});
|
||||
}
|
||||
});
|
||||
if (mounted) setState(() => _scan = s);
|
||||
} on StorageScanCancelled {
|
||||
// 닫음
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _error = e);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _bar(double v) => ClipRRect(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
child: LinearProgressIndicator(value: v.clamp(0, 1), minHeight: 6, color: JjColors.accent, backgroundColor: JjColors.border),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = _scan;
|
||||
final canUp = s != null && !samePath(_cur, widget.root);
|
||||
return PopScope(
|
||||
canPop: !canUp,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop && canUp) setState(() => _cur = vDirname(_cur));
|
||||
},
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(tr('저장 공간 분석')),
|
||||
actions: [
|
||||
if (s == null && _error == null)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
_stop = true;
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text(tr('취소')),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: s == null
|
||||
? Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
if (_error == null) const CircularProgressIndicator(),
|
||||
const SizedBox(height: 12),
|
||||
Text(_error != null ? trf('분석하지 못했습니다: {0}', [_error!]) : trf('읽는 중 · 폴더 {0}개 · {1}', [_dirs, formatSize(_bytes)]),
|
||||
textAlign: TextAlign.center),
|
||||
const SizedBox(height: 4),
|
||||
Text(vDisplay(widget.root), style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||
]),
|
||||
),
|
||||
)
|
||||
: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
|
||||
child: Row(children: [
|
||||
if (canUp)
|
||||
IconButton(
|
||||
tooltip: tr('위 폴더'),
|
||||
icon: const Icon(Icons.arrow_upward),
|
||||
onPressed: () => setState(() => _cur = vDirname(_cur)),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(vDisplay(_cur), maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||
Text(trf('{0} · 파일 {1}개', [formatSize(s.sizeOf(_cur)), s.filesOf(_cur)]),
|
||||
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||
]),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () => Navigator.pop(context, _cur),
|
||||
icon: const Icon(Icons.folder_open, size: 18),
|
||||
label: Text(tr('그 자리로')),
|
||||
),
|
||||
]),
|
||||
),
|
||||
if (s.unreadable > 0 || s.links > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Text(
|
||||
[
|
||||
if (s.unreadable > 0) trf('읽지 못한 폴더 {0}개 (크기가 실제보다 작을 수 있음)', [s.unreadable]),
|
||||
if (s.links > 0) trf('링크 {0}개는 세지 않음', [s.links]),
|
||||
].join(' · '),
|
||||
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
child: Wrap(spacing: 6, children: [
|
||||
ChoiceChip(label: Text(tr('큰 폴더')), selected: _tab == 0, onSelected: (_) => setState(() => _tab = 0)),
|
||||
ChoiceChip(label: Text(tr('큰 파일')), selected: _tab == 1, onSelected: (_) => setState(() => _tab = 1)),
|
||||
]),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(child: _tab == 0 ? _folders(s) : _files(s)),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _folders(StorageScan s) {
|
||||
final list = s.biggestIn(_cur);
|
||||
final total = s.sizeOf(_cur);
|
||||
if (list.isEmpty) return Center(child: Text(tr('안에 폴더가 없습니다')));
|
||||
return ListView.builder(
|
||||
itemCount: list.length,
|
||||
itemBuilder: (_, i) {
|
||||
final d = list[i];
|
||||
final size = s.sizeOf(d);
|
||||
return ListTile(
|
||||
dense: true,
|
||||
leading: const Icon(Icons.folder, color: Colors.amber),
|
||||
title: Text(scanName(d), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const SizedBox(height: 4),
|
||||
_bar(total == 0 ? 0 : size / total),
|
||||
const SizedBox(height: 2),
|
||||
Text('${formatSize(size)} · ${trf('파일 {0}개', [s.filesOf(d)])}', style: const TextStyle(fontSize: 11)),
|
||||
]),
|
||||
trailing: IconButton(
|
||||
tooltip: tr('그 자리로'),
|
||||
icon: const Icon(Icons.drive_file_move_outline, size: 20),
|
||||
onPressed: () => Navigator.pop(context, d),
|
||||
),
|
||||
onTap: () => setState(() => _cur = d),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _files(StorageScan s) {
|
||||
final list = [for (final f in s.bigFiles) if (isSameOrInside(f.path, _cur)) f];
|
||||
if (list.isEmpty) return Center(child: Text(tr('큰 파일이 없습니다')));
|
||||
final top = list.first.size;
|
||||
return ListView.builder(
|
||||
itemCount: list.length,
|
||||
itemBuilder: (_, i) {
|
||||
final f = list[i];
|
||||
return ListTile(
|
||||
dense: true,
|
||||
leading: const Icon(Icons.insert_drive_file_outlined),
|
||||
title: Text(f.name, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const SizedBox(height: 4),
|
||||
_bar(top == 0 ? 0 : f.size / top),
|
||||
const SizedBox(height: 2),
|
||||
Text('${formatSize(f.size)} · ${vDisplay(vDirname(f.path))}', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11)),
|
||||
]),
|
||||
// 누르면 탐색기에서 그 파일 자리로 (지우기 등은 탐색기에서)
|
||||
onTap: () => Navigator.pop(context, f.path),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ import 'package:jj_mkvmaker/ui/explorer_page.dart';
|
|||
import 'package:jj_mkvmaker/ui/explorer_search.dart';
|
||||
import 'package:jj_mkvmaker/ui/quick_look.dart';
|
||||
import 'package:jj_mkvmaker/ui/explorer_drag.dart';
|
||||
import 'package:jj_mkvmaker/ui/storage_page.dart';
|
||||
import 'package:jj_mkvmaker/ui/path_label.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'support/fake_recycle_bin.dart';
|
||||
|
|
@ -1233,6 +1234,22 @@ void main() {
|
|||
});
|
||||
}
|
||||
|
||||
testWidgets('87: 폴더 길게 누르기 [저장 공간 분석] → 큰 파일을 누르면 탐색기에서 그 자리로', (tester) async {
|
||||
File(p.join(left, 'sub', 'big.bin')).writeAsBytesSync(List.filled(4000, 1));
|
||||
await open(tester);
|
||||
await tester.longPress(find.text('sub').first);
|
||||
await calm(tester);
|
||||
await act(tester, () => tester.tap(find.text('저장 공간 분석')));
|
||||
await settle(tester, () => find.text('큰 파일').evaluate().isNotEmpty);
|
||||
await act(tester, () => tester.tap(find.text('큰 파일')));
|
||||
await calm(tester);
|
||||
await act(tester, () => tester.tap(find.text('big.bin')));
|
||||
await settle(tester, () => find.byType(StorageAnalysisPage).evaluate().isEmpty && find.text('big.bin').evaluate().isNotEmpty);
|
||||
await calm(tester);
|
||||
expect(find.byType(StorageAnalysisPage), findsNothing);
|
||||
expect(find.text('big.bin'), findsWidgets, reason: '탐색기에서 그 자리 (sub 안) 가 보인다');
|
||||
});
|
||||
|
||||
testWidgets('84: 열 머리를 누르면 그 기준으로 · 다시 누르면 거꾸로 (처음: 모든 폴더 같은 정렬)', (tester) async {
|
||||
File(p.join(left, 'a_big.txt')).writeAsStringSync('x' * 50);
|
||||
c.settings.explorerStyle = 'windows';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jj_mkvmaker/core/storage_scan.dart';
|
||||
import 'package:jj_mkvmaker/ui/storage_page.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// 87: 저장 공간 분석 - 폴더마다 크기 (안쪽 합) · 큰 파일 차례, 링크는 세지 않음 (80 과 같게) · 취소, 화면은 들어가기 · 그 자리로
|
||||
void main() {
|
||||
late Directory tmp;
|
||||
setUp(() {
|
||||
tmp = Directory.systemTemp.createTempSync('jj_scan87_');
|
||||
File(p.join(tmp.path, 'movies', 'a.mkv'))
|
||||
..createSync(recursive: true)
|
||||
..writeAsBytesSync(List.filled(5000, 0));
|
||||
File(p.join(tmp.path, 'movies', 'old', 'b.mkv'))
|
||||
..createSync(recursive: true)
|
||||
..writeAsBytesSync(List.filled(3000, 0));
|
||||
File(p.join(tmp.path, 'docs', 'c.txt'))
|
||||
..createSync(recursive: true)
|
||||
..writeAsBytesSync(List.filled(100, 0));
|
||||
File(p.join(tmp.path, 'top.bin')).writeAsBytesSync(List.filled(10, 0));
|
||||
});
|
||||
tearDown(() => tmp.deleteSync(recursive: true));
|
||||
|
||||
test('폴더마다 안쪽 합 · 큰 폴더 차례 · 큰 파일 차례', () async {
|
||||
final s = await StorageScanner().scan(tmp.path);
|
||||
expect(s.sizeOf(tmp.path), 8110);
|
||||
expect(s.filesOf(tmp.path), 4);
|
||||
expect(s.sizeOf(p.join(tmp.path, 'movies')), 8000);
|
||||
expect(s.biggestIn(tmp.path).map(p.basename), ['movies', 'docs']);
|
||||
expect(s.bigFiles.map((f) => f.name).take(3), ['a.mkv', 'b.mkv', 'c.txt']);
|
||||
});
|
||||
|
||||
test('링크 · junction 은 세지 않고 (위 폴더를 가리키는 고리도 끝남) · 취소', () async {
|
||||
var junctions = 0;
|
||||
if (Platform.isWindows) {
|
||||
for (final (link, target) in [(p.join(tmp.path, 'docs', 'loop'), tmp.path), (p.join(tmp.path, 'tomovies'), p.join(tmp.path, 'movies'))]) {
|
||||
if ((await Process.run('cmd', ['/c', 'mklink', '/J', link, target])).exitCode == 0) junctions++;
|
||||
}
|
||||
}
|
||||
final s = await StorageScanner().scan(tmp.path);
|
||||
expect(s.sizeOf(tmp.path), 8110, reason: 'junction 이 가리키는 것을 더하지 않는다');
|
||||
expect(s.links, junctions);
|
||||
await expectLater(StorageScanner().scan(tmp.path, cancelled: () => true), throwsA(isA<StorageScanCancelled>()));
|
||||
for (final l in [p.join(tmp.path, 'docs', 'loop'), p.join(tmp.path, 'tomovies')]) {
|
||||
if (Link(l).existsSync()) Link(l).deleteSync();
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('화면: 큰 폴더 → 들어가기 · 위로 · 큰 파일 · [그 자리로] 는 그 경로를 돌려준다', (tester) async {
|
||||
tester.view.physicalSize = const Size(1000, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
final key = GlobalKey<NavigatorState>();
|
||||
await tester.pumpWidget(MaterialApp(navigatorKey: key, home: const Scaffold()));
|
||||
String? picked;
|
||||
var done = false;
|
||||
Navigator.of(key.currentContext!)
|
||||
.push<String>(MaterialPageRoute(builder: (_) => StorageAnalysisPage(root: tmp.path)))
|
||||
.then((v) {
|
||||
picked = v;
|
||||
done = true;
|
||||
});
|
||||
for (var i = 0; i < 200 && find.text('movies').evaluate().isEmpty; i++) {
|
||||
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 20)));
|
||||
await tester.pump();
|
||||
}
|
||||
expect(find.text('movies'), findsOneWidget);
|
||||
await tester.tap(find.text('movies'));
|
||||
await tester.pump();
|
||||
expect(find.text('old'), findsOneWidget, reason: '들어가기');
|
||||
await tester.tap(find.byTooltip('위 폴더'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('큰 파일'));
|
||||
await tester.pump();
|
||||
expect(find.text('a.mkv'), findsOneWidget);
|
||||
await tester.tap(find.text('a.mkv'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(done, isTrue);
|
||||
expect(picked, p.join(tmp.path, 'movies', 'a.mkv'));
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue