diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 893ef97..2b5ed6d 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -2176,6 +2176,7 @@ "이동: 옮길 폴더를 골라 이동 버튼을 만듭니다 (환경 설정 > MKV 만들기 > 이동 버튼에서 여러 개 만들 수 있음)": "Move: choose a folder to create a move button (you can make several in Settings > Make MKV > Move buttons)", "이동: 이 동영상을 이동 폴더로 옮깁니다{0}": "Move: moves this video to the move folder{0}", "이동: 체크한 동영상 {0}개를 이동 폴더로 옮깁니다": "Move: moves the {0} checked videos to the move folder", + "이동할 것으로 담았습니다 - 탐색기에서 넣을 폴더를 길게 눌러 [붙여넣기]": "Ready to move - in the file explorer, long-press the destination folder and choose [Paste]", "이동할 폴더가 정해지지 않았습니다 (환경 설정 > MKV 만들기 > 이동 버튼)": "No move folder is set (Settings > Make MKV > Move buttons)", "이동할 폴더가 정해지지 않았습니다 (환경 설정 > 저장 위치)": "No move folder is set (Settings > Save locations)", "이동할 폴더를 만들 수 없습니다: {0} ({1})": "Cannot create the move folder: {0} ({1})", @@ -2522,6 +2523,7 @@ "지우고 맞추기": "Delete and sync", "지우고 초기화": "Delete and reset", "지우기": "Delete", + "지우기 (확인 · 휴지통)": "Delete (confirm · trash)", "지우기 포함": "with delete", "지우기가 들어 있는 작업입니다": "This task deletes files", "지우기가 들어간 실행이라, 비교하지 못하면 실행할 수 없습니다. 원본 · 대상을 확인한 뒤 [다시 비교] 를 누르세요.": "This run deletes files, so it can't run without a comparison. Check the source and target, then press [Compare again].", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 8282f48..92d8ccf 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -2176,6 +2176,7 @@ "이동: 옮길 폴더를 골라 이동 버튼을 만듭니다 (환경 설정 > MKV 만들기 > 이동 버튼에서 여러 개 만들 수 있음)": "移動: 移動先フォルダーを選んで移動ボタンを作ります(環境設定 > MKV 作成 > 移動ボタンで複数作れます)", "이동: 이 동영상을 이동 폴더로 옮깁니다{0}": "移動: この動画を移動フォルダーに移します{0}", "이동: 체크한 동영상 {0}개를 이동 폴더로 옮깁니다": "移動: チェックした動画 {0}個を移動フォルダーに移します", + "이동할 것으로 담았습니다 - 탐색기에서 넣을 폴더를 길게 눌러 [붙여넣기]": "移動するものとして保持しました - ファイルエクスプローラーで移動先のフォルダーを長押しして [貼り付け]", "이동할 폴더가 정해지지 않았습니다 (환경 설정 > MKV 만들기 > 이동 버튼)": "移動先フォルダーが決まっていません(環境設定 > MKV 作成 > 移動ボタン)", "이동할 폴더가 정해지지 않았습니다 (환경 설정 > 저장 위치)": "移動フォルダーが決まっていません(環境設定 > 保存先)", "이동할 폴더를 만들 수 없습니다: {0} ({1})": "移動フォルダーを作成できません: {0}({1})", @@ -2522,6 +2523,7 @@ "지우고 맞추기": "削除して同期", "지우고 초기화": "削除して初期化", "지우기": "削除", + "지우기 (확인 · 휴지통)": "削除 (確認・ごみ箱)", "지우기 포함": "削除あり", "지우기가 들어 있는 작업입니다": "削除を含む作業です", "지우기가 들어간 실행이라, 비교하지 못하면 실행할 수 없습니다. 원본 · 대상을 확인한 뒤 [다시 비교] 를 누르세요.": "削除を含む実行のため、比較できないと実行できません。元・対象を確認してから [もう一度比較] を押してください。", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 753bafe..8c730d6 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -2176,6 +2176,7 @@ "이동: 옮길 폴더를 골라 이동 버튼을 만듭니다 (환경 설정 > MKV 만들기 > 이동 버튼에서 여러 개 만들 수 있음)": "移动: 选择目标文件夹以创建移动按钮(可在 设置 > 制作 MKV > 移动按钮 中创建多个)", "이동: 이 동영상을 이동 폴더로 옮깁니다{0}": "移动: 将此视频移到移动目标文件夹{0}", "이동: 체크한 동영상 {0}개를 이동 폴더로 옮깁니다": "移动: 将勾选的 {0} 个视频移到移动目标文件夹", + "이동할 것으로 담았습니다 - 탐색기에서 넣을 폴더를 길게 눌러 [붙여넣기]": "已准备移动 - 在文件管理器中长按目标文件夹并选择 [粘贴]", "이동할 폴더가 정해지지 않았습니다 (환경 설정 > MKV 만들기 > 이동 버튼)": "尚未设置移动目标文件夹(设置 > 制作 MKV > 移动按钮)", "이동할 폴더가 정해지지 않았습니다 (환경 설정 > 저장 위치)": "尚未设置移动目标文件夹(设置 > 保存位置)", "이동할 폴더를 만들 수 없습니다: {0} ({1})": "无法创建移动目标文件夹: {0}({1})", @@ -2522,6 +2523,7 @@ "지우고 맞추기": "删除并同步", "지우고 초기화": "删除并重置", "지우기": "删除", + "지우기 (확인 · 휴지통)": "删除 (确认 · 回收站)", "지우기 포함": "含删除", "지우기가 들어 있는 작업입니다": "此任务包含删除", "지우기가 들어간 실행이라, 비교하지 못하면 실행할 수 없습니다. 원본 · 대상을 확인한 뒤 [다시 비교] 를 누르세요.": "此操作包含删除,无法比较时不能执行。请检查源和目标后按 [重新比较]。", diff --git a/lib/core/storage_scan.dart b/lib/core/storage_scan.dart index 70b03ee..9423e06 100644 --- a/lib/core/storage_scan.dart +++ b/lib/core/storage_scan.dart @@ -32,6 +32,41 @@ class StorageScan { /// [dir] 바로 안의 폴더를 큰 것부터 List biggestIn(String dir) => [...?children[key(dir)]]..sort((a, b) => sizeOf(b).compareTo(sizeOf(a))); + + /// X-plore 비교 5-1 ⑤ (관리자 지시 10/21): 지운 것 · 옮긴 것을 다시 훑지 않고 결과에서 뺀다 - 폴더면 그 아래 모두, 파일이면 그것. + /// 위 폴더들 (분석한 곳까지) 의 크기 · 파일 수에서 덜어 낸다. [fileSize] 는 큰 파일 목록에 없는 파일의 크기 (모르면 0) + void remove(String path, {int fileSize = 0}) { + final k = key(path); + var (size, files) = (fileSize, 1); + final folder = folders[k]; + if (folder != null) { + (size, files) = folder; + // 그 아래 폴더 모두 + final stack = [k]; + while (stack.isNotEmpty) { + final d = stack.removeLast(); + folders.remove(d); + for (final c in children.remove(d) ?? const []) { + stack.add(key(c)); + } + } + bigFiles.removeWhere((f) => isSameOrInside(f.path, path)); + } else { + final i = bigFiles.indexWhere((f) => key(f.path) == k); + if (i >= 0) size = bigFiles.removeAt(i).size; + } + final parent = vDirname(path); + children[key(parent)]?.removeWhere((c) => key(c) == k); + // 위 폴더들 (분석한 곳까지) 에서 덜어 냄 + var d = parent; + while (true) { + final dk = key(d); + final v = folders[dk]; + if (v != null) folders[dk] = ((v.$1 - size).clamp(0, 1 << 62), (v.$2 - files).clamp(0, 1 << 62)); + if (dk == key(root) || samePath(vDirname(d), d)) break; + d = vDirname(d); + } + } } /// 87: 고른 폴더 (또는 드라이브) 아래를 한 번 훑어 [StorageScan] 을 만든다 diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 8ab0943..b7aa73e 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -1944,7 +1944,11 @@ class _ExplorerPageState extends State { ); if (ok != true || !mounted) return; } - final path = await StorageAnalysisPage.open(context, dir); + // X-plore 비교 5-1 ⑤ (관리자 지시 10/21): 분석 화면에서도 이 탐색기의 지우기 (확인 · 휴지통) · 이동 (붙여넣기로) + final path = await StorageAnalysisPage.open(context, dir, onDelete: (paths) async { + await _delete(pane, paths); + return [for (final x in paths) if (!await vExists(x)) x]; + }, onCut: (paths) => _toClipboard(paths, move: true)); if (path == null || !mounted) return; final isDir = await vIsDir(path); await _goTo(pane, isDir ? path : vDirname(path), fresh: true); diff --git a/lib/ui/storage_page.dart b/lib/ui/storage_page.dart index fdbf208..08b39ee 100644 --- a/lib/ui/storage_page.dart +++ b/lib/ui/storage_page.dart @@ -9,17 +9,26 @@ import 'theme.dart'; /// 87: 저장 공간 분석 - 고른 폴더 · 드라이브 아래의 큰 폴더 · 큰 파일 차례. /// 폴더를 누르면 그 안으로 (한 번 훑은 결과로 바로), [그 자리로] 는 탐색기에서 그 자리로 (돌려주는 값). -/// 지우기는 여기서 하지 않는다 - 탐색기의 지우기 (확인 · 휴지통) 로만 (관리자 판단) +/// 지우기는 탐색기의 지우기 (확인 · 휴지통) 로만 (관리자 판단) - X-plore 비교 5-1 ⑤ (10/21): 이 화면의 ⋮ · 길게 누르기에서 탐색기의 +/// 그 지우기 ([onDelete]) 를 부르고, 지운 것은 다시 훑지 않고 결과에서 뺀다. 이동은 탐색기의 [이동 (붙여넣기로)] ([onCut]) class StorageAnalysisPage extends StatefulWidget { final String root; /// 시험에서 바꿔 끼움 final StorageScanner? scanner; - const StorageAnalysisPage({super.key, required this.root, this.scanner}); + + /// 탐색기의 지우기 (확인 창 · 휴지통). 실제로 지워진 것을 돌려준다 (취소면 빈 목록) + final Future> Function(List paths)? onDelete; + + /// 탐색기의 [이동 (붙여넣기로)] 에 담기 + final void Function(List paths)? onCut; + const StorageAnalysisPage({super.key, required this.root, this.scanner, this.onDelete, this.onCut}); /// 연다. 탐색기에서 갈 자리 (없으면 null) - static Future open(BuildContext context, String root) => - Navigator.of(context).push(MaterialPageRoute(builder: (_) => StorageAnalysisPage(root: root))); + static Future open(BuildContext context, String root, + {Future> Function(List paths)? onDelete, void Function(List paths)? onCut}) => + Navigator.of(context) + .push(MaterialPageRoute(builder: (_) => StorageAnalysisPage(root: root, onDelete: onDelete, onCut: onCut))); @override State createState() => _StorageAnalysisPageState(); @@ -63,6 +72,54 @@ class _StorageAnalysisPageState extends State { } } + /// X-plore 비교 5-1 ⑤: 그 자리에서 할 일 - [그 자리로] · [지우기] (탐색기의 확인 · 휴지통) · [이동 (붙여넣기로)] + Future _act(String path, String what, {int size = 0}) async { + switch (what) { + case 'go': + Navigator.pop(context, path); + case 'delete': + final done = await widget.onDelete?.call([path]) ?? const []; + if (!mounted || done.isEmpty) return; + setState(() { + for (final x in done) { + _scan?.remove(x, fileSize: size); + } + if (done.any((x) => isSameOrInside(_cur, x))) _cur = vDirname(done.first); + }); + case 'cut': + widget.onCut?.call([path]); + ScaffoldMessenger.maybeOf(context) + ?.showSnackBar(SnackBar(content: Text(tr('이동할 것으로 담았습니다 - 탐색기에서 넣을 폴더를 길게 눌러 [붙여넣기]')))); + } + } + + /// 줄의 ⋮ 메뉴 (길게 누르기도 같은 메뉴) + Widget _menuButton(String path, {int size = 0}) => PopupMenuButton( + tooltip: tr('더 보기'), + icon: const Icon(Icons.more_vert, size: 20), + onSelected: (w) => _act(path, w, size: size), + itemBuilder: (_) => [ + PopupMenuItem(value: 'go', child: ListTile(dense: true, leading: const Icon(Icons.folder_open), title: Text(tr('그 자리로')))), + if (widget.onDelete != null) + PopupMenuItem(value: 'delete', child: ListTile(dense: true, leading: const Icon(Icons.delete_outline), title: Text(tr('지우기 (확인 · 휴지통)')))), + if (widget.onCut != null) + PopupMenuItem(value: 'cut', child: ListTile(dense: true, leading: const Icon(Icons.content_cut), title: Text(tr('이동 (붙여넣기로)')))), + ], + ); + + Future _menuAt(Offset at, String path, {int size = 0}) async { + final w = await showMenu( + context: context, + position: RelativeRect.fromLTRB(at.dx, at.dy, at.dx, at.dy), + items: [ + PopupMenuItem(value: 'go', child: Text(tr('그 자리로'))), + if (widget.onDelete != null) PopupMenuItem(value: 'delete', child: Text(tr('지우기 (확인 · 휴지통)'))), + if (widget.onCut != null) PopupMenuItem(value: 'cut', child: Text(tr('이동 (붙여넣기로)'))), + ], + ); + if (w != null && mounted) await _act(path, w, size: size); + } + Widget _bar(double v) => ClipRRect( borderRadius: BorderRadius.circular(2), child: LinearProgressIndicator(value: v.clamp(0, 1), minHeight: 6, color: JjColors.accent, backgroundColor: JjColors.border), @@ -164,7 +221,10 @@ class _StorageAnalysisPageState extends State { itemBuilder: (_, i) { final d = list[i]; final size = s.sizeOf(d); - return ListTile( + return GestureDetector( + onLongPressStart: (x) => _menuAt(x.globalPosition, d), + onSecondaryTapUp: (x) => _menuAt(x.globalPosition, d), + child: ListTile( dense: true, leading: const Icon(Icons.folder, color: Colors.amber), title: Text(scanName(d), maxLines: 1, overflow: TextOverflow.ellipsis), @@ -174,13 +234,9 @@ class _StorageAnalysisPageState extends State { 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), - ), + trailing: _menuButton(d), onTap: () => setState(() => _cur = d), - ); + )); }, ); } @@ -212,7 +268,10 @@ class _StorageAnalysisPageState extends State { itemCount: list.length, itemBuilder: (_, i) { final f = list[i]; - return ListTile( + return GestureDetector( + onLongPressStart: (x) => _menuAt(x.globalPosition, f.path, size: f.size), + onSecondaryTapUp: (x) => _menuAt(x.globalPosition, f.path, size: f.size), + child: ListTile( dense: true, leading: const Icon(Icons.insert_drive_file_outlined), title: Text(f.name, maxLines: 1, overflow: TextOverflow.ellipsis), @@ -228,9 +287,10 @@ class _StorageAnalysisPageState extends State { maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11)), ), ]), - // 누르면 탐색기에서 그 파일 자리로 (지우기 등은 탐색기에서) + trailing: _menuButton(f.path, size: f.size), + // 누르면 탐색기에서 그 파일 자리로 onTap: () => Navigator.pop(context, f.path), - ); + )); }, ); } diff --git a/test/storage_scan_test.dart b/test/storage_scan_test.dart index 8676682..d278046 100644 --- a/test/storage_scan_test.dart +++ b/test/storage_scan_test.dart @@ -81,4 +81,71 @@ void main() { expect(done, isTrue); expect(picked, p.join(tmp.path, 'movies', 'a.mkv')); }); + test('5-1 ⑤ 지운 것을 다시 훑지 않고 빼기: 폴더 (그 아래 모두) · 파일 - 위 폴더들의 크기 · 파일 수에서 덜어 냄', () async { + final s = await StorageScanner().scan(tmp.path); + final movies = p.join(tmp.path, 'movies'); + s.remove(p.join(movies, 'old')); + expect(s.sizeOf(movies), 5000); + expect(s.filesOf(movies), 1); + expect(s.sizeOf(tmp.path), 5110); + expect(s.biggestIn(movies), isEmpty); + expect(s.bigFiles.map((f) => f.name), isNot(contains('b.mkv'))); + s.remove(p.join(movies, 'a.mkv')); + expect((s.sizeOf(movies), s.filesOf(movies)), (0, 0)); + expect((s.sizeOf(tmp.path), s.filesOf(tmp.path)), (110, 2)); + // 큰 파일 목록에 없는 파일은 알려 준 크기로 + s.remove(p.join(tmp.path, 'unknown.bin'), fileSize: 10); + expect(s.sizeOf(tmp.path), 100); + }); + + testWidgets('5-1 ⑤ 화면: ⋮ [지우기 (확인 · 휴지통)] 는 탐색기의 지우기를 부르고 결과에서 뺌 · 취소면 그대로 · [이동 (붙여넣기로)] 는 담기', (tester) async { + tester.view.physicalSize = const Size(1000, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final asked = >[]; + final cut = >[]; + var confirm = false; + await tester.pumpWidget(MaterialApp( + home: StorageAnalysisPage( + root: tmp.path, + onDelete: (paths) async { + asked.add(paths); + if (!confirm) return const []; + for (final x in paths) { + Directory(x).deleteSync(recursive: true); + } + return paths; + }, + onCut: cut.add, + ), + )); + for (var i = 0; i < 200 && find.text('movies').evaluate().isEmpty; i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 20))); + await tester.pump(); + } + Future menu(String row, String item) async { + await tester.tap(find.descendant(of: find.widgetWithText(ListTile, row), matching: find.byTooltip('더 보기'))); + await tester.pumpAndSettle(); + await tester.tap(find.text(item).last); + for (var i = 0; i < 10; i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 20))); + await tester.pump(); + } + } + + final movies = p.join(tmp.path, 'movies'); + // 취소 (탐색기 확인 창에서 [취소]) → 그대로 + await menu('movies', '지우기 (확인 · 휴지통)'); + expect(asked.single, [movies]); + expect(find.text('movies'), findsOneWidget); + // 확인 → 목록에서 빠지고 위 폴더 크기도 줄어듦 + confirm = true; + await menu('movies', '지우기 (확인 · 휴지통)'); + expect(find.text('movies'), findsNothing); + expect(find.textContaining('110B'), findsWidgets); + // 이동 (붙여넣기로) + await menu('docs', '이동 (붙여넣기로)'); + expect(cut.single, [p.join(tmp.path, 'docs')]); + await tester.pumpWidget(const SizedBox()); + }); }