diff --git a/lib/app/delete_ops.dart b/lib/app/delete_ops.dart new file mode 100644 index 0000000..9dd0965 --- /dev/null +++ b/lib/app/delete_ops.dart @@ -0,0 +1,116 @@ +import 'dart:io'; + +import '../core/app_trash.dart'; +import '../core/file_ops.dart' show FileOps; +import '../platform/windows/recycle_bin.dart'; + +/// 약속 (E5b): 지우기 · 되돌리기. 휴지통은 recycle_bin (Windows) · app_trash (Android) 를 부르기만 한다. +/// - [deletePaths]: 입력 = 경로들 · 영구인지 · 앱 휴지통 (있으면 그리로), 출력 = [DeleteReport] (실제로 어떻게 됐는지 하나씩). +/// 하나가 실패해도 나머지는 계속한다. 휴지통으로 못 보낸 것은 몰래 영구 삭제하지 않고 보고에 남긴다 +/// (앱 휴지통 [DeleteReport.notTrashed] · 긴 경로 [DeleteReport.tooLong] - 영구 삭제는 부르는 쪽이 물은 뒤 [deleteForever]). +/// - [deleteForever]: 영구 삭제, 출력 = 못 지운 것 (경로, 예외). +/// - [undoTrash] · [undoRecycle]: 방금 휴지통으로 보낸 것을 원래 자리로, 출력 = 되살린 것 · 못 되살린 것 (이유). +/// - 다시 쓰는 곳: 탐색기 · 다운로드 화면 지우기 · 보관함 · 정리. +class DeleteReport { + DeleteReport(this.since); + + /// 이 지우기를 시작한 때 (Windows 휴지통 되돌리기용 - 정보 파일 시각이 초 단위라 조금 앞) + final DateTime since; + + /// Windows 휴지통으로 보낸 것 (되돌릴 수 있음) + final sent = []; + + /// 앱 휴지통으로 보낸 것 · 그 경로 (되돌릴 수 있음) + final trashed = []; + final undoable = []; + + /// 앱 휴지통으로 옮기지 못한 것 (경로, 이유) - 다른 저장 장치 · 권한 등 + final notTrashed = <(String, String)>[]; + + /// 경로가 너무 길어 휴지통이 받지 않은 것 + final tooLong = []; + + /// 지우지 못한 것 (경로, 이유) + final failed = <(String, String)>[]; + + /// 휴지통으로 보낸 수 · Windows 가 묻고 영구 삭제한 수 · 취소한 수 + var recycled = 0, nuked = 0, kept = 0; +} + +/// 사람이 읽을 실패 이유 (OS 메시지가 있으면 그것) +String deleteFailReason(Object e) => e is FileSystemException ? (e.osError?.message ?? e.message) : '$e'; + +Future deletePaths(List paths, {required bool permanent, AppTrash? trash, DateTime? now}) async { + final r = DeleteReport((now ?? DateTime.now()).subtract(const Duration(seconds: 2))); + for (final x in paths) { + if (!permanent && trash != null) { + try { + r.trashed.add(await trash.moveToTrash(x)); + r.undoable.add(x); + r.recycled++; + } on TrashMoveException catch (e) { + r.notTrashed.add((x, '${e.cause}')); + } + continue; + } + try { + if (!permanent) { + switch (recycle(x)) { + case RecycleResult.recycled: + r.recycled++; + r.sent.add(x); + case RecycleResult.deletedPermanently: + r.nuked++; + case RecycleResult.cancelled: + r.kept++; + } + } else { + await FileOps().delete([x]); + } + } on LongPathException { + r.tooLong.add(x); + } catch (e) { + r.failed.add((x, deleteFailReason(e))); + } + } + return r; +} + +/// 영구 삭제 - 못 지운 것 (경로, 예외) +Future> deleteForever(Iterable paths) async { + final failed = <(String, Object)>[]; + for (final x in paths) { + try { + await FileOps().delete([x]); + } catch (e) { + failed.add((x, e)); + } + } + return failed; +} + +/// 앱 휴지통에서 되살림 - 되살린 경로 (못 되살린 것은 빠짐) +Future> undoTrash(AppTrash trash, List items) async { + final back = []; + for (final i in items) { + try { + back.add(await trash.restore(i)); + } catch (_) {} + } + return back; +} + +/// Windows 휴지통에서 되살림 - 되살린 수 · 못 되살린 것 (경로, 이유) +({int back, List<(String, String)> failed}) undoRecycle(List paths, DateTime since) { + final failed = <(String, String)>[]; + var back = 0; + for (final x in paths) { + try { + restoreRecycled(x, since: since); + back++; + } catch (e) { + failed.add((x, e is FileSystemException ? e.message : '$e')); + } + } + return (back: back, failed: failed); +} diff --git a/lib/app/transfer_plan.dart b/lib/app/transfer_plan.dart new file mode 100644 index 0000000..1dbd857 --- /dev/null +++ b/lib/app/transfer_plan.dart @@ -0,0 +1,78 @@ +import '../core/file_ops.dart' show outermostPaths, samePath, transferProblem; +import '../core/name_clash.dart'; +import '../core/vfs.dart' show vBasename, vDirname, vJoin, vStat; +import '../l10n/tr.dart'; + +/// 약속 (E5b): 복사 · 이동을 시작하기 전 계획 ([planTransfer]) - CopyCenter.start 앞 단계. +/// 입력 = 원본들 · 대상 폴더 · 이동인지, 출력 = [TransferPlan] (실제로 보낼 것 · 건너뛸 것 · 무엇을 물을지 · 막는 문제). +/// 오류 없음 (막는 문제는 [TransferPlan.problem] 글로). +/// - 고른 폴더 안의 것은 한 번만 (바깥 것만). +/// - 135: 같은 폴더로 - 이동인데 일부만 이미 그 폴더에 있으면 그것만 건너뛰고 나머지를 옮긴다. +/// 모두 이미 그 폴더면 이동은 할 것이 없고 ([TransferPlan.nothingToMove]), 복사는 사본을 만들지 묻는다 ([TransferPlan.askCopyHere]). +/// - [findClashes]: 대상에 같은 이름이 있는 원본 (크기 · 바뀐 날 함께, 같은 폴더로 복사는 늘 새 이름이라 뺌). +/// - 다시 쓰는 곳: 탐색기 [복사] · [이동] · 끌어 놓기 · 붙여넣기 · 찾기 결과 복사. +class TransferPlan { + const TransferPlan({ + required this.sources, + required this.here, + required this.skippedHere, + required this.nothingToMove, + required this.askCopyHere, + required this.problem, + }); + + /// 실제로 보낼 것 + final List sources; + + /// 이미 대상 폴더에 있는 원본 + final List here; + + /// 이동에서 이미 그 폴더에 있어 건너뛰는 것 + final List skippedHere; + final bool nothingToMove; + final bool askCopyHere; + + /// 시작하지 않는 이유 (원본 · 대상이 없음, 폴더를 자기 안으로 등) - 없으면 null + final String? problem; +} + +TransferPlan planTransfer(List sources, String dest, {required bool move}) { + sources = outermostPaths(sources); + final here = [for (final x in sources) if (samePath(vDirname(x), dest)) x]; + var skipped = const []; + var rest = sources; + if (move && here.isNotEmpty && here.length < sources.length) { + skipped = here; + rest = [for (final x in sources) if (!here.contains(x)) x]; + } + final all = here.isNotEmpty && skipped.isEmpty && (move || here.length == sources.length); + return TransferPlan( + sources: rest, + here: here, + skippedHere: skipped, + nothingToMove: all && move, + askCopyHere: all && !move, + problem: all && move ? null : transferProblem(rest, dest, move: move), + ); +} + +/// 48 · 172: 대상 폴더에 같은 이름이 있는 원본 - 두 파일의 크기 · 바뀐 날을 함께 (어느 쪽이 새것인지 보고 고르게) +Future> findClashes(List sources, String dest) async { + final out = []; + for (final s in sources) { + if (samePath(vDirname(s), dest)) continue; + try { + final there = await vStat(vJoin(dest, vBasename(s))); + if (there == null) continue; + final here = await vStat(s); + out.add((src: s, here: here, there: there)); + } catch (_) {} + } + return out; +} + +/// 154: 항목 이름 (앞의 3개 + "외 n개") +String namesOf(List paths) { + final names = [for (final x in paths.take(3)) vBasename(x)].join(', '); + return paths.length > 3 ? trf('{0} 외 {1}개', [names, paths.length - 3]) : names; +} diff --git a/lib/core/file_clipboard.dart b/lib/core/file_clipboard.dart new file mode 100644 index 0000000..95610fb --- /dev/null +++ b/lib/core/file_clipboard.dart @@ -0,0 +1,29 @@ +/// 약속 (E5b): 복사 · 이동할 항목을 붙여넣기 전까지 담아 둔다 (창 · 화면 사이). +/// 입력 = 담기 [put] (항목 · 이동인지) · 붙여넣은 뒤 [pasted], 출력 = [items] · [move] · [isEmpty]. +/// - 빈 목록은 담지 않는다 ([put] 이 false). +/// - 옮긴 것은 다시 붙여넣을 수 없으므로 붙여넣기가 끝나면 비우고, 복사는 여러 곳에 붙여넣을 수 있게 남긴다. +/// - 다시 쓰는 곳: 탐색기 두 창 · 한 창 · 보관함 꺼내기. +class FileClipboard { + List _items = const []; + bool _move = false; + + List get items => _items; + bool get move => _move; + bool get isEmpty => _items.isEmpty; + bool get isNotEmpty => _items.isNotEmpty; + + /// 담는다 (앞에 담은 것은 바뀜). 빈 목록이면 담지 않고 false + bool put(List items, {required bool move}) { + if (items.isEmpty) return false; + _items = List.unmodifiable(items); + _move = move; + return true; + } + + /// 붙여넣기가 끝남: [done] 이고 이동이었으면 비운다 + void pasted({required bool done}) { + if (done && _move) clear(); + } + + void clear() => _items = const []; +} diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 3275155..407c7ea 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -91,6 +91,9 @@ import '../app/volumes.dart'; import '../core/explorer/dir_loader.dart'; import '../core/explorer/pane_state.dart'; import '../core/explorer/tree_rows.dart'; +import '../app/delete_ops.dart'; +import '../app/transfer_plan.dart'; +import '../core/file_clipboard.dart'; export '../core/explorer/tree_rows.dart' show globToRegExp, quickFilterMatch; export '../core/name_clash.dart' show NameClash, clashCompareText, fileDateText; @@ -2011,7 +2014,7 @@ class _ExplorerPageState extends State { if (dual) ('clipCopy', Icons.content_copy, tr('복사 (붙여넣기로)')), if (dual) ('clipMove', Icons.content_cut, tr('이동 (붙여넣기로)')), if (_clip.isNotEmpty) - ('paste', Icons.content_paste, trf('{0} 에 붙여넣기 ({1}개)', [vBasename(e.isDir ? e.path : vDirname(e.path)), _clip.length])), + ('paste', Icons.content_paste, trf('{0} 에 붙여넣기 ({1}개)', [vBasename(e.isDir ? e.path : vDirname(e.path)), _clip.items.length])), ('copyPath', Icons.link, many ? trf('경로 복사 ({0}개)', [targets.length]) : tr('경로 복사')), if (e.isDir && !c.settings.folderBookmarks.any((b) => !b.isPair && samePath(b.path, e.path))) ('bookmark', Icons.star_outline, tr('즐겨찾기에 추가')), @@ -2807,50 +2810,11 @@ class _ExplorerPageState extends State { ]), ); if (!ok) return; - // 하나씩: 실패해도 나머지는 계속. 실제로 어떻게 됐는지 세어 그대로 알린다 (94) - final failed = <(String, String)>[]; - // 144: 경로가 너무 길어 휴지통이 받지 않은 것 (이유는 한 번, [영구 삭제] 로 바로 지울 수 있게) - final tooLong = []; - var recycled = 0, nuked = 0, kept = 0; - // 148: 휴지통으로 보낸 것 (되돌리기용) - 정보 파일 시각은 초 단위라 조금 앞에서부터 - final since = DateTime.now().subtract(const Duration(seconds: 2)); - final sent = []; - // X-plore 비교 e: 앱 휴지통으로 보낸 것 · 옮기지 못한 것 (영구 삭제할지 묻는다) - final trashed = []; - final notTrashed = <(String, String)>[]; - // X-plore 숨기기: 휴지통에서 [되돌리기] 할 수 있는 것 (숨긴 목록에 남겨 되살리면 그대로 숨김) - final undoable = []; - for (final x in paths) { - if (!permanent && canTrash) { - try { - trashed.add(await trash.moveToTrash(x)); - undoable.add(x); - recycled++; - } on TrashMoveException catch (e) { - notTrashed.add((x, '${e.cause}')); - } - continue; - } - try { - if (!permanent) { - switch (recycle(x)) { - case RecycleResult.recycled: - recycled++; - sent.add(x); - case RecycleResult.deletedPermanently: - nuked++; - case RecycleResult.cancelled: - kept++; - } - } else { - await FileOps().delete([x]); - } - } on LongPathException { - tooLong.add(x); - } catch (e) { - failed.add((x, e is FileSystemException ? (e.osError?.message ?? e.message) : '$e')); - } - } + // 하나씩: 실패해도 나머지는 계속. 실제로 어떻게 됐는지 세어 그대로 알린다 (94) - 지우기 규칙은 delete_ops (E5b) + final report = await deletePaths(paths, permanent: permanent, trash: !permanent && canTrash ? trash : null); + final failed = report.failed, tooLong = report.tooLong, sent = report.sent, trashed = report.trashed; + final notTrashed = report.notTrashed, undoable = report.undoable, since = report.since; + final recycled = report.recycled, nuked = report.nuked, kept = report.kept; // X-plore 비교 e: 휴지통으로 옮기지 못한 것 (다른 저장 장치 · 권한 등) - 몰래 지우지 않고 영구 삭제할지 묻는다 if (notTrashed.isNotEmpty && mounted) { // U7: 되돌릴 수 없는 일 - Enter 의 첫 초점은 [그대로 두기] @@ -2868,12 +2832,8 @@ class _ExplorerPageState extends State { ]), ); if (nuke) { - for (final (x, _) in notTrashed) { - try { - await FileOps().delete([x]); - } catch (e) { - failed.add((x, '$e')); - } + for (final (x, e) in await deleteForever([for (final (x, _) in notTrashed) x])) { + failed.add((x, '$e')); } } } @@ -2924,14 +2884,7 @@ class _ExplorerPageState extends State { true; // 144: 사용자가 [영구 삭제] 를 고른 긴 경로만 지운다 if (nukeLong == true) { - final notDeleted = []; - for (final x in tooLong) { - try { - await FileOps().delete([x]); - } catch (e) { - notDeleted.add('${vBasename(x)}: ${e is FileSystemException ? (e.osError?.message ?? e.message) : e}'); - } - } + final notDeleted = [for (final (x, e) in await deleteForever(tooLong)) '${vBasename(x)}: ${deleteFailReason(e)}']; _snack(joinSentences([ if (tooLong.length > notDeleted.length) trf('{0}개를 영구 삭제했습니다.', [tooLong.length - notDeleted.length]), if (notDeleted.isNotEmpty) trf('지우지 못함: {0}', [notDeleted.take(3).join(' · ')]), @@ -2976,12 +2929,7 @@ class _ExplorerPageState extends State { Future _undoTrash(List items) async { final trash = _appTrash; if (trash == null) return; - final back = []; - for (final i in items) { - try { - back.add(await trash.restore(i)); - } catch (_) {} - } + final back = await undoTrash(trash, items); await _refreshAll(back.map(p.dirname)); _snack(trf('{0}개를 원래 자리로 되살렸습니다', [back.length])); } @@ -3008,16 +2956,8 @@ class _ExplorerPageState extends State { /// 148: 방금 휴지통으로 보낸 것을 원래 자리로 (원래 자리에 같은 이름이 생겼으면 그것은 두고 알림) Future _undoRecycle(List paths, DateTime since) async { - final failed = []; - var back = 0; - for (final x in paths) { - try { - restoreRecycled(x, since: since); - back++; - } catch (e) { - failed.add('${vBasename(x)}: ${e is FileSystemException ? e.message : e}'); - } - } + final (:back, failed: why) = undoRecycle(paths, since); + final failed = [for (final (x, e) in why) '${vBasename(x)}: $e']; await _refreshAll(paths.map(p.dirname)); for (final x in _panes) { x.changed(); @@ -3050,49 +2990,23 @@ class _ExplorerPageState extends State { await _runTransfer(pane, sources, other.current, move: move); } - /// 복사 · 이동할 항목 (붙여넣기 전까지 담아 둠) - List _clip = []; - bool _clipMove = false; + /// 복사 · 이동할 항목 (붙여넣기 전까지 담아 둠) - FileClipboard (E5b) + final _clip = FileClipboard(); /// 154: 고른 것이 없을 때 (복사 · 이동이 같은 말) String _pickFirst(bool move) => move ? tr('옮길 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).') : tr('복사할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).'); - /// 154: 항목 이름 (앞의 3개 + "외 n개") - /// 48: 받는 폴더에 같은 이름이 있는 원본 (같은 폴더로 복사는 늘 새 이름이라 빼고) - /// 48 · 172: 대상 폴더에 같은 이름이 있는 원본 - 두 파일의 크기 · 바뀐 날을 함께 (어느 쪽이 새것인지 보고 고르게) - static Future> _findClashes(List sources, String dest) async { - final out = []; - for (final s in sources) { - if (samePath(vDirname(s), dest)) continue; - try { - final there = await vStat(vJoin(dest, vBasename(s))); - if (there == null) continue; - final here = await vStat(s); - out.add((src: s, here: here, there: there)); - } catch (_) {} - } - return out; - } - - static String _namesOf(List paths) { - final names = [for (final x in paths.take(3)) vBasename(x)].join(', '); - return paths.length > 3 ? trf('{0} 외 {1}개', [names, paths.length - 3]) : names; - } - void _toClipboard(List sources, {required bool move}) { if (_blockShizukuMove(sources, null, move: move)) return; if (sources.isEmpty) { _snack(_pickFirst(move)); return; } - setState(() { - _clip = [...sources]; - _clipMove = move; - }); + setState(() => _clip.put(sources, move: move)); _snack(trf('{0} 을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).', - [_namesOf(sources), move ? tr('이동') : tr('복사')])); + [namesOf(sources), move ? tr('이동') : tr('복사')])); } Future _paste(_Pane pane, String dest) async { @@ -3100,10 +3014,9 @@ class _ExplorerPageState extends State { _snack(tr('붙여넣을 항목이 없습니다. 먼저 [복사] · [이동] 으로 담으세요.')); return; } - final items = _clip, move = _clipMove; - final done = await _runTransfer(pane, items, dest, move: move); + final done = await _runTransfer(pane, _clip.items, dest, move: _clip.move); // 옮긴 것은 다시 붙여넣을 수 없으므로 비운다 (복사는 여러 곳에 붙여넣을 수 있게 남김) - if (done && move && mounted) setState(() => _clip = []); + if (mounted) setState(() => _clip.pasted(done: done)); } /// 지금 도는 복사 · 이동 (아래에서 올라오는 진행 막대) @@ -3181,37 +3094,34 @@ class _ExplorerPageState extends State { _snack(tr('같은 항목을 같은 곳으로 복사 · 이동하는 중입니다.')); return false; } - // 135: 같은 폴더로 - 짧은 알림은 놓치기 쉬워 창으로. 이동은 할 것이 없고, 복사는 사본을 만들지 한 번 묻는다 - final here = [for (final x in sources) if (samePath(vDirname(x), dest)) x]; + // 135: 같은 폴더로 - 짧은 알림은 놓치기 쉬워 창으로. 이동은 할 것이 없고, 복사는 사본을 만들지 한 번 묻는다 (계획은 transfer_plan) + final plan = planTransfer(sources, dest, move: move); + sources = plan.sources; + final skippedHere = plan.skippedHere, here = plan.here; var sameFolderCopy = false; - // 이동인데 일부만 이미 그 폴더에 있으면 그것만 건너뛰고 나머지를 옮긴다 (관리자: 섞어 고른 것을 다시 고르지 않게) - var skippedHere = const []; - if (move && here.isNotEmpty && here.length < sources.length) { - skippedHere = here; - sources = [for (final x in sources) if (!here.contains(x)) x]; - } else if (here.isNotEmpty && (move || here.length == sources.length)) { - if (move) { - // 알림만 ([확인] 에 첫 초점 - Enter 로 닫기) - await showDialog( - context: context, - builder: (ctx) => AlertDialog( - scrollable: true, - title: Text(tr('이미 이 폴더에 있습니다')), - content: Text(trf('{0} 은(는) 이미 이 폴더에 있어 옮길 것이 없습니다.\n{1}', [_namesOf(here), vDisplay(dest)])), - actions: [FilledButton(autofocus: true, onPressed: () => Navigator.pop(ctx), child: Text(tr('확인')))], - ), - ); - return false; - } + if (plan.nothingToMove) { + // 알림만 ([확인] 에 첫 초점 - Enter 로 닫기) + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + scrollable: true, + title: Text(tr('이미 이 폴더에 있습니다')), + content: Text(trf('{0} 은(는) 이미 이 폴더에 있어 옮길 것이 없습니다.\n{1}', [namesOf(here), vDisplay(dest)])), + actions: [FilledButton(autofocus: true, onPressed: () => Navigator.pop(ctx), child: Text(tr('확인')))], + ), + ); + return false; + } + if (plan.askCopyHere) { final ok = await confirmDialog(context, title: () => tr('같은 폴더입니다'), confirmLabel: () => tr('사본 만들기'), - body: (ctx, set) => Text(trf('같은 폴더에 사본을 만들까요? (이름 (2))\n{0}', [_namesOf(here)]))); + body: (ctx, set) => Text(trf('같은 폴더에 사본을 만들까요? (이름 (2))\n{0}', [namesOf(here)]))); if (!ok || !mounted) return false; sameFolderCopy = true; // 이미 물었으니 아래 확인 창은 건너뛴다 } // 원본 · 대상이 없거나 (다른 곳에서 지움 - 목록을 새로 고침) 폴더를 자기 안으로 넣으려 하면 시작하지 않는다 - final problem = transferProblem(sources, dest, move: move); + final problem = plan.problem; if (problem != null) { _snack(problem); pane.sel.removeWhere(vMissingSync); @@ -3230,7 +3140,7 @@ class _ExplorerPageState extends State { final builtin = method == CopyMethod.builtin || _viaDav([...sources, dest]); // 확인 창은 바로 띄우고, 같은 이름은 창 안에서 뒤에서 찾는다 (느린 네트워크 · WebDAV 폴더라도 창이 늦게 뜨지 않게). // 다 찾기 전에 누르면 이름 바꾸기 (원래 파일을 건드리지 않는 쪽) - final clashesFuture = builtin ? _findClashes(sources, dest) : Future.value(const []); + final clashesFuture = builtin ? findClashes(sources, dest) : Future.value(const []); // 48: 환경 설정 "같은 이름이 있을 때" 가 늘 하는 것이면 묻지 않고 그대로 (확인 창에 알림만) final always = switch (c.settings.copyConflict) { 'rename' => NameConflict.rename, @@ -3252,8 +3162,8 @@ class _ExplorerPageState extends State { body: (ctx, setInner) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ // 61: 문장 조각을 이어 붙이지 않는다 (언어마다 어순이 달라 "…로 {복사할까요?}" 가 깨졌다) Text('${move ? trf('{0}개 항목을 다음 폴더로 옮길까요?\n{1}', [sources.length, vDisplay(dest)]) : trf('{0}개 항목을 다음 폴더로 복사할까요?\n{1}', [sources.length, vDisplay(dest)])}' - '\n${_namesOf(sources)}' - '${skippedHere.isEmpty ? '' : '\n${trf('이미 이 폴더에 있어 건너뜀: {0}', [_namesOf(skippedHere)])}'}' + '\n${namesOf(sources)}' + '${skippedHere.isEmpty ? '' : '\n${trf('이미 이 폴더에 있어 건너뜀: {0}', [namesOf(skippedHere)])}'}' '\n\n${trf('방법: {0}', [tr(method.label)])}' '${method == CopyMethod.builtin ? '' : ' · ${withoutSourceRemovingOptions(task.options)}${move ? (method == CopyMethod.rsync ? ' --remove-source-files' : ' /MOVE') : ''}'}' '${task.bandwidthKBps > 0 ? ' · ${trf('속도 제한 {0} KB/s', [task.bandwidthKBps])}' : ''}'), @@ -3279,7 +3189,7 @@ class _ExplorerPageState extends State { if (clashes.isEmpty) return const SizedBox.shrink(); return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 12), - Text(trf('같은 이름이 이미 있습니다: {0}', [_namesOf([for (final x in clashes) x.src])]), + Text(trf('같은 이름이 이미 있습니다: {0}', [namesOf([for (final x in clashes) x.src])]), style: TextStyle(color: JjColors.warning)), // 172: 두 파일의 크기 · 바뀐 날 (3개까지) for (final x in clashes.take(3)) ...[ @@ -3376,10 +3286,10 @@ class _ExplorerPageState extends State { // 264: 작업 기록에만 있던 것도 알림에 (폴더를 합칠 때 받는 쪽이 더 새 파일 등) if (move && job.leftInSource > 0) trf('{0}개는 원본에 남았습니다 (건너뜀).', [job.leftInSource]), if (!move && job.keptNewer > 0) trf('받는 쪽이 더 새것이라 {0}개를 건너뛰었습니다.', [job.keptNewer]), - if (job.skipped.isNotEmpty) trf('같은 이름이라 건너뛰었습니다: {0}.', [_namesOf(job.skipped)]), + if (job.skipped.isNotEmpty) trf('같은 이름이라 건너뛰었습니다: {0}.', [namesOf(job.skipped)]), // M1 (10/19): 원격 맞추기에서 받는 쪽의 같은 이름 폴더는 --delete 없이 지우지 않는다 - if (job.dirConflicts.isNotEmpty) trf('같은 이름의 폴더가 있어 {0}개를 건너뛰었습니다: {1}.', [job.dirConflicts.length, _namesOf(job.dirConflicts)]), - if (skippedHere.isNotEmpty) trf('이미 이 폴더에 있어 건너뛰었습니다: {0}.', [_namesOf(skippedHere)]), + if (job.dirConflicts.isNotEmpty) trf('같은 이름의 폴더가 있어 {0}개를 건너뛰었습니다: {1}.', [job.dirConflicts.length, namesOf(job.dirConflicts)]), + if (skippedHere.isNotEmpty) trf('이미 이 폴더에 있어 건너뛰었습니다: {0}.', [namesOf(skippedHere)]), ]); // 85: 끝나면 [폴더 열기] - 앱 탐색기에서 넣은 폴더로 (그 폴더를 보고 있는 창, 없으면 지금 창) if (error == null && mounted) { diff --git a/test/delete_ops_contract_test.dart b/test/delete_ops_contract_test.dart new file mode 100644 index 0000000..3cd7f58 --- /dev/null +++ b/test/delete_ops_contract_test.dart @@ -0,0 +1,75 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/delete_ops.dart'; +import 'package:jj_mkvmaker/core/app_trash.dart'; +import 'package:path/path.dart' as p; + +import 'support/fake_recycle_bin.dart' show realRecycleSkip; + +/// E5b 약속 시험: lib/app/delete_ops.dart 맨 위 약속. 시험 폴더의 실제 파일 · 실제 앱 휴지통 (가짜 없음). +/// Windows 휴지통은 실제 사용자 휴지통이라 JJ_TEST_REAL_RECYCLE=1 일 때만 (끝나면 되살려 흔적 없음). +void main() { + if (!Platform.isWindows) return; // 열려 있는 파일을 못 지우는 것은 Windows + late Directory tmp; + late String a, b, dir, locked; + late RandomAccessFile hold; + setUp(() { + tmp = Directory.systemTemp.createTempSync('jj_del_'); + a = p.join(tmp.path, 'a.txt'); + b = p.join(tmp.path, 'b.txt'); + dir = p.join(tmp.path, 'Show'); + locked = p.join(tmp.path, 'locked.txt'); + File(a).writeAsStringSync('a'); + File(b).writeAsStringSync('b'); + Directory(dir).createSync(); + File(p.join(dir, 'ep.mkv')).writeAsStringSync('x'); + File(locked).writeAsStringSync('l'); + hold = File(locked).openSync(mode: FileMode.append); // Windows: 열려 있는 파일은 지울 수 없다 + }); + tearDown(() { + hold.closeSync(); + tmp.deleteSync(recursive: true); + }); + + test('영구: 하나가 실패해도 나머지는 지우고 실패를 이유와 함께 (열려 있는 파일)', () async { + final r = await deletePaths([a, locked, dir], permanent: true); + expect(File(a).existsSync(), isFalse); + expect(Directory(dir).existsSync(), isFalse); + expect([for (final (x, _) in r.failed) x], [locked]); + expect(File(locked).existsSync(), isTrue); + expect(r.failed.single.$2, isNotEmpty); + expect([r.sent, r.trashed, r.tooLong], everyElement(isEmpty)); + }); + + test('앱 휴지통: 보내고 되살림 · 못 보낸 것은 몰래 지우지 않고 보고 · deleteForever', () async { + final trash = AppTrash([tmp.path]); + final outside = Directory.systemTemp.createTempSync('jj_del_out_'); + addTearDown(() => outside.deleteSync(recursive: true)); + final far = p.join(outside.path, 'far.txt'); + File(far).writeAsStringSync('f'); + final r = await deletePaths([a, dir, far], permanent: false, trash: trash); + expect([File(a).existsSync(), Directory(dir).existsSync()], [false, false]); + expect(r.undoable, [a, dir]); + expect(r.recycled, 2); + expect([for (final (x, _) in r.notTrashed) x], [far], reason: '저장 장치 밖'); + expect(File(far).existsSync(), isTrue, reason: '몰래 영구 삭제하지 않음'); + final back = await undoTrash(trash, r.trashed); + expect(back.length, 2); + expect([File(a).existsSync(), File(p.join(dir, 'ep.mkv')).existsSync()], [true, true]); + expect([for (final (x, _) in await deleteForever([far, locked])) x], [locked]); + expect(File(far).existsSync(), isFalse); + expect(deleteFailReason(const FileSystemException('x', 'y', OSError('잠김', 32))), '잠김'); + }); + + test('Windows 휴지통: 보내고 되돌림 (실제 휴지통)', () async { + final r = await deletePaths([a, b], permanent: false); + expect(r.sent, [a, b]); + expect(r.recycled, 2); + expect(File(a).existsSync(), isFalse); + final u = undoRecycle(r.sent, r.since); + expect(u.back, 2); + expect(u.failed, isEmpty); + expect([File(a).existsSync(), File(b).existsSync()], [true, true]); + }, skip: realRecycleSkip); +} diff --git a/test/explorer_test.dart b/test/explorer_test.dart index 63550e0..7aa2a74 100644 --- a/test/explorer_test.dart +++ b/test/explorer_test.dart @@ -460,7 +460,7 @@ void main() { unawaited((tester.state(find.byType(ExplorerPage)) as dynamic).debugOpenTrash() as Future); await settle(tester, () => find.byType(CheckboxListTile).evaluate().isNotEmpty); await act(tester, () => tester.tap(find.byType(CheckboxListTile).first)); - await calm(tester); + await settle(tester, () => find.text('되살리기').hitTestable().evaluate().isNotEmpty); // 화면 넘김이 끝나 눌릴 때까지 await act(tester, () => tester.tap(find.text('되살리기'))); await settle(tester, () => File(doc).existsSync()); expect(File(doc).readAsStringSync(), 'hello'); diff --git a/test/transfer_plan_contract_test.dart b/test/transfer_plan_contract_test.dart new file mode 100644 index 0000000..e0df6d4 --- /dev/null +++ b/test/transfer_plan_contract_test.dart @@ -0,0 +1,70 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/transfer_plan.dart'; +import 'package:jj_mkvmaker/core/file_clipboard.dart'; +import 'package:path/path.dart' as p; + +/// E5b 약속 시험: lib/app/transfer_plan.dart · lib/core/file_clipboard.dart 맨 위 약속 (시험 폴더의 실제 파일). +void main() { + late Directory tmp; + late String src, dst, a, b, sub, inner; + setUp(() { + tmp = Directory.systemTemp.createTempSync('jj_plan_'); + src = p.join(tmp.path, 'src'); + dst = p.join(tmp.path, 'dst'); + sub = p.join(src, 'sub'); + Directory(sub).createSync(recursive: true); + Directory(dst).createSync(); + a = p.join(src, 'a.txt'); + b = p.join(src, 'b.txt'); + inner = p.join(sub, 'in.txt'); + for (final f in [a, b, inner]) { + File(f).writeAsStringSync('x'); + } + File(p.join(dst, 'a.txt')).writeAsStringSync('older and longer'); + }); + tearDown(() => tmp.deleteSync(recursive: true)); + + test('보통: 바깥 것만 보냄 · 문제 없음', () { + final plan = planTransfer([sub, inner, a], dst, move: false); + expect(plan.sources, [sub, a], reason: '고른 폴더 안의 것은 한 번만'); + expect([plan.askCopyHere, plan.nothingToMove, plan.problem], [false, false, null]); + }); + + test('같은 폴더로: 이동 일부 = 그것만 건너뜀 · 이동 전부 = 할 것 없음 · 복사 전부 = 사본을 물음', () { + final some = planTransfer([a, p.join(dst, 'a.txt')], dst, move: true); + expect(some.skippedHere, [p.join(dst, 'a.txt')]); + expect(some.sources, [a]); + final allMove = planTransfer([a, b], src, move: true); + expect([allMove.nothingToMove, allMove.problem], [true, null]); + final allCopy = planTransfer([a, b], src, move: false); + expect(allCopy.askCopyHere, isTrue); + expect(allCopy.sources, [a, b]); + }); + + test('막는 문제: 없는 원본 · 폴더를 자기 안으로', () { + expect(planTransfer([p.join(src, 'none.txt')], dst, move: false).problem, isNotNull); + expect(planTransfer([src], sub, move: true).problem, isNotNull); + }); + + test('같은 이름: 대상에 있는 것만, 크기 · 바뀐 날 함께 · 이름 줄임', () async { + final clashes = await findClashes([a, b], dst); + expect([for (final c in clashes) c.src], [a]); + expect(clashes.single.there.size, greaterThan(clashes.single.here!.size)); + expect(namesOf(['x/1', 'x/2', 'x/3', 'x/4', 'x/5']), contains('1, 2, 3')); + }); + + test('담기: 빈 것은 담지 않음 · 이동은 붙여넣은 뒤 비움 · 복사는 남김', () { + final clip = FileClipboard(); + expect(clip.put([], move: false), isFalse); + expect(clip.put([a], move: false), isTrue); + clip.pasted(done: true); + expect(clip.items, [a], reason: '복사는 여러 곳에'); + clip.put([b], move: true); + clip.pasted(done: false); + expect(clip.isNotEmpty, isTrue, reason: '못 옮겼으면 남음'); + clip.pasted(done: true); + expect(clip.isEmpty, isTrue); + }); +}