E5b: 지우기 · 되돌리기 · 복사 계획 · 담기를 모듈로 (explorer_page 는 확인 창 · 알림만)
관리자 승인 E5 계획 (E5a~E5e). 동작은 바꾸지 않음
- lib/app/delete_ops.dart deletePaths · deleteForever · undoTrash · undoRecycle · DeleteReport: 휴지통은 recycle_bin
(Windows) · app_trash (Android) 를 부르기만. 하나가 실패해도 나머지는 계속, 휴지통으로 못 보낸 것은 몰래 영구 삭제하지
않고 보고에 (앱 휴지통 밖 · 긴 경로) - 영구 삭제는 부르는 쪽이 물은 뒤
- lib/app/transfer_plan.dart planTransfer · findClashes · namesOf: 복사 · 이동 전 계획 (바깥 것만 · 같은 폴더 135 ·
막는 문제), 같은 이름 (크기 · 바뀐 날)
- lib/core/file_clipboard.dart FileClipboard: 담기 (빈 것은 담지 않음, 이동은 붙여넣은 뒤 비움, 복사는 남김)
- 다시 쓰는 곳: 탐색기 · 다운로드 화면 지우기 · 보관함 · 정리 · 끌어 놓기 · 붙여넣기 · 찾기 결과 복사
- explorer_page 6,357 → 6,267
- test/explorer_test.dart "X-plore e: Android 앱 휴지통" 시험: [되살리기] 를 화면 넘김이 끝나 눌릴 수 있을 때 누르게
(넘기는 중에 누르면 다른 화면을 눌러 한 번 실패 - TrashPage 는 바꾸지 않음, 기대값 그대로, 줄 수 그대로 2,009)
확인: analyze 0 · 전체 단위 2회 통과 1,324 · 건너뜀 18 (새 실제 휴지통 시험 1) · 실패 0 ·
실제 Windows 휴지통 시험 (JJ_TEST_REAL_RECYCLE=1: delete_ops · recycle_bin · explorer_conflict 28개) 2회 통과, 흔적 없음 ·
점검 통합 시험 (1920 · 960 · 412) 2회 통과.
캡처 픽셀 비교 (시험 폴더 이름 · 파일 시각 고정, E5a dab9db5 를 두 번 찍은 것이 기준 - 기준끼리 87장 같음): 90장 중
87 · 88장 픽셀까지 같음, 다른 2 · 3장은 기준끼리도 다른 움직이는 장면 (이동 확인 · 선택 끝)
약속 시험 (실제 시험 폴더 · 실제 앱 휴지통 · 열려 있어 못 지우는 실제 파일): delete_ops 3 · transfer_plan + file_clipboard 5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
split_pages
parent
dab9db5d4d
commit
0300d51092
|
|
@ -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 = <String>[];
|
||||||
|
|
||||||
|
/// 앱 휴지통으로 보낸 것 · 그 경로 (되돌릴 수 있음)
|
||||||
|
final trashed = <TrashItem>[];
|
||||||
|
final undoable = <String>[];
|
||||||
|
|
||||||
|
/// 앱 휴지통으로 옮기지 못한 것 (경로, 이유) - 다른 저장 장치 · 권한 등
|
||||||
|
final notTrashed = <(String, String)>[];
|
||||||
|
|
||||||
|
/// 경로가 너무 길어 휴지통이 받지 않은 것
|
||||||
|
final tooLong = <String>[];
|
||||||
|
|
||||||
|
/// 지우지 못한 것 (경로, 이유)
|
||||||
|
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<DeleteReport> deletePaths(List<String> 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<List<(String, Object)>> deleteForever(Iterable<String> 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<List<String>> undoTrash(AppTrash trash, List<TrashItem> items) async {
|
||||||
|
final back = <String>[];
|
||||||
|
for (final i in items) {
|
||||||
|
try {
|
||||||
|
back.add(await trash.restore(i));
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
return back;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Windows 휴지통에서 되살림 - 되살린 수 · 못 되살린 것 (경로, 이유)
|
||||||
|
({int back, List<(String, String)> failed}) undoRecycle(List<String> 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);
|
||||||
|
}
|
||||||
|
|
@ -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<String> sources;
|
||||||
|
|
||||||
|
/// 이미 대상 폴더에 있는 원본
|
||||||
|
final List<String> here;
|
||||||
|
|
||||||
|
/// 이동에서 이미 그 폴더에 있어 건너뛰는 것
|
||||||
|
final List<String> skippedHere;
|
||||||
|
final bool nothingToMove;
|
||||||
|
final bool askCopyHere;
|
||||||
|
|
||||||
|
/// 시작하지 않는 이유 (원본 · 대상이 없음, 폴더를 자기 안으로 등) - 없으면 null
|
||||||
|
final String? problem;
|
||||||
|
}
|
||||||
|
|
||||||
|
TransferPlan planTransfer(List<String> 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 <String>[];
|
||||||
|
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<List<NameClash>> findClashes(List<String> sources, String dest) async {
|
||||||
|
final out = <NameClash>[];
|
||||||
|
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<String> 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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
/// 약속 (E5b): 복사 · 이동할 항목을 붙여넣기 전까지 담아 둔다 (창 · 화면 사이).
|
||||||
|
/// 입력 = 담기 [put] (항목 · 이동인지) · 붙여넣은 뒤 [pasted], 출력 = [items] · [move] · [isEmpty].
|
||||||
|
/// - 빈 목록은 담지 않는다 ([put] 이 false).
|
||||||
|
/// - 옮긴 것은 다시 붙여넣을 수 없으므로 붙여넣기가 끝나면 비우고, 복사는 여러 곳에 붙여넣을 수 있게 남긴다.
|
||||||
|
/// - 다시 쓰는 곳: 탐색기 두 창 · 한 창 · 보관함 꺼내기.
|
||||||
|
class FileClipboard {
|
||||||
|
List<String> _items = const [];
|
||||||
|
bool _move = false;
|
||||||
|
|
||||||
|
List<String> get items => _items;
|
||||||
|
bool get move => _move;
|
||||||
|
bool get isEmpty => _items.isEmpty;
|
||||||
|
bool get isNotEmpty => _items.isNotEmpty;
|
||||||
|
|
||||||
|
/// 담는다 (앞에 담은 것은 바뀜). 빈 목록이면 담지 않고 false
|
||||||
|
bool put(List<String> 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 [];
|
||||||
|
}
|
||||||
|
|
@ -91,6 +91,9 @@ import '../app/volumes.dart';
|
||||||
import '../core/explorer/dir_loader.dart';
|
import '../core/explorer/dir_loader.dart';
|
||||||
import '../core/explorer/pane_state.dart';
|
import '../core/explorer/pane_state.dart';
|
||||||
import '../core/explorer/tree_rows.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/explorer/tree_rows.dart' show globToRegExp, quickFilterMatch;
|
||||||
export '../core/name_clash.dart' show NameClash, clashCompareText, fileDateText;
|
export '../core/name_clash.dart' show NameClash, clashCompareText, fileDateText;
|
||||||
|
|
@ -2011,7 +2014,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
||||||
if (dual) ('clipCopy', Icons.content_copy, tr('복사 (붙여넣기로)')),
|
if (dual) ('clipCopy', Icons.content_copy, tr('복사 (붙여넣기로)')),
|
||||||
if (dual) ('clipMove', Icons.content_cut, tr('이동 (붙여넣기로)')),
|
if (dual) ('clipMove', Icons.content_cut, tr('이동 (붙여넣기로)')),
|
||||||
if (_clip.isNotEmpty)
|
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('경로 복사')),
|
('copyPath', Icons.link, many ? trf('경로 복사 ({0}개)', [targets.length]) : tr('경로 복사')),
|
||||||
if (e.isDir && !c.settings.folderBookmarks.any((b) => !b.isPair && samePath(b.path, e.path)))
|
if (e.isDir && !c.settings.folderBookmarks.any((b) => !b.isPair && samePath(b.path, e.path)))
|
||||||
('bookmark', Icons.star_outline, tr('즐겨찾기에 추가')),
|
('bookmark', Icons.star_outline, tr('즐겨찾기에 추가')),
|
||||||
|
|
@ -2807,50 +2810,11 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
// 하나씩: 실패해도 나머지는 계속. 실제로 어떻게 됐는지 세어 그대로 알린다 (94)
|
// 하나씩: 실패해도 나머지는 계속. 실제로 어떻게 됐는지 세어 그대로 알린다 (94) - 지우기 규칙은 delete_ops (E5b)
|
||||||
final failed = <(String, String)>[];
|
final report = await deletePaths(paths, permanent: permanent, trash: !permanent && canTrash ? trash : null);
|
||||||
// 144: 경로가 너무 길어 휴지통이 받지 않은 것 (이유는 한 번, [영구 삭제] 로 바로 지울 수 있게)
|
final failed = report.failed, tooLong = report.tooLong, sent = report.sent, trashed = report.trashed;
|
||||||
final tooLong = <String>[];
|
final notTrashed = report.notTrashed, undoable = report.undoable, since = report.since;
|
||||||
var recycled = 0, nuked = 0, kept = 0;
|
final recycled = report.recycled, nuked = report.nuked, kept = report.kept;
|
||||||
// 148: 휴지통으로 보낸 것 (되돌리기용) - 정보 파일 시각은 초 단위라 조금 앞에서부터
|
|
||||||
final since = DateTime.now().subtract(const Duration(seconds: 2));
|
|
||||||
final sent = <String>[];
|
|
||||||
// X-plore 비교 e: 앱 휴지통으로 보낸 것 · 옮기지 못한 것 (영구 삭제할지 묻는다)
|
|
||||||
final trashed = <TrashItem>[];
|
|
||||||
final notTrashed = <(String, String)>[];
|
|
||||||
// X-plore 숨기기: 휴지통에서 [되돌리기] 할 수 있는 것 (숨긴 목록에 남겨 되살리면 그대로 숨김)
|
|
||||||
final undoable = <String>[];
|
|
||||||
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'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// X-plore 비교 e: 휴지통으로 옮기지 못한 것 (다른 저장 장치 · 권한 등) - 몰래 지우지 않고 영구 삭제할지 묻는다
|
// X-plore 비교 e: 휴지통으로 옮기지 못한 것 (다른 저장 장치 · 권한 등) - 몰래 지우지 않고 영구 삭제할지 묻는다
|
||||||
if (notTrashed.isNotEmpty && mounted) {
|
if (notTrashed.isNotEmpty && mounted) {
|
||||||
// U7: 되돌릴 수 없는 일 - Enter 의 첫 초점은 [그대로 두기]
|
// U7: 되돌릴 수 없는 일 - Enter 의 첫 초점은 [그대로 두기]
|
||||||
|
|
@ -2868,15 +2832,11 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
if (nuke) {
|
if (nuke) {
|
||||||
for (final (x, _) in notTrashed) {
|
for (final (x, e) in await deleteForever([for (final (x, _) in notTrashed) x])) {
|
||||||
try {
|
|
||||||
await FileOps().delete([x]);
|
|
||||||
} catch (e) {
|
|
||||||
failed.add((x, '$e'));
|
failed.add((x, '$e'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if ((failed.isNotEmpty || tooLong.isNotEmpty) && mounted) {
|
if ((failed.isNotEmpty || tooLong.isNotEmpty) && mounted) {
|
||||||
final failTitle = trf('{0}개 중 {1}개를 지우지 못했습니다', [paths.length, failed.length + tooLong.length]);
|
final failTitle = trf('{0}개 중 {1}개를 지우지 못했습니다', [paths.length, failed.length + tooLong.length]);
|
||||||
final failIcon = Icon(Icons.error_outline, color: JjColors.alert);
|
final failIcon = Icon(Icons.error_outline, color: JjColors.alert);
|
||||||
|
|
@ -2924,14 +2884,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
||||||
true;
|
true;
|
||||||
// 144: 사용자가 [영구 삭제] 를 고른 긴 경로만 지운다
|
// 144: 사용자가 [영구 삭제] 를 고른 긴 경로만 지운다
|
||||||
if (nukeLong == true) {
|
if (nukeLong == true) {
|
||||||
final notDeleted = <String>[];
|
final notDeleted = [for (final (x, e) in await deleteForever(tooLong)) '${vBasename(x)}: ${deleteFailReason(e)}'];
|
||||||
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}');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_snack(joinSentences([
|
_snack(joinSentences([
|
||||||
if (tooLong.length > notDeleted.length) trf('{0}개를 영구 삭제했습니다.', [tooLong.length - notDeleted.length]),
|
if (tooLong.length > notDeleted.length) trf('{0}개를 영구 삭제했습니다.', [tooLong.length - notDeleted.length]),
|
||||||
if (notDeleted.isNotEmpty) trf('지우지 못함: {0}', [notDeleted.take(3).join(' · ')]),
|
if (notDeleted.isNotEmpty) trf('지우지 못함: {0}', [notDeleted.take(3).join(' · ')]),
|
||||||
|
|
@ -2976,12 +2929,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
||||||
Future<void> _undoTrash(List<TrashItem> items) async {
|
Future<void> _undoTrash(List<TrashItem> items) async {
|
||||||
final trash = _appTrash;
|
final trash = _appTrash;
|
||||||
if (trash == null) return;
|
if (trash == null) return;
|
||||||
final back = <String>[];
|
final back = await undoTrash(trash, items);
|
||||||
for (final i in items) {
|
|
||||||
try {
|
|
||||||
back.add(await trash.restore(i));
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
await _refreshAll(back.map(p.dirname));
|
await _refreshAll(back.map(p.dirname));
|
||||||
_snack(trf('{0}개를 원래 자리로 되살렸습니다', [back.length]));
|
_snack(trf('{0}개를 원래 자리로 되살렸습니다', [back.length]));
|
||||||
}
|
}
|
||||||
|
|
@ -3008,16 +2956,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
||||||
|
|
||||||
/// 148: 방금 휴지통으로 보낸 것을 원래 자리로 (원래 자리에 같은 이름이 생겼으면 그것은 두고 알림)
|
/// 148: 방금 휴지통으로 보낸 것을 원래 자리로 (원래 자리에 같은 이름이 생겼으면 그것은 두고 알림)
|
||||||
Future<void> _undoRecycle(List<String> paths, DateTime since) async {
|
Future<void> _undoRecycle(List<String> paths, DateTime since) async {
|
||||||
final failed = <String>[];
|
final (:back, failed: why) = undoRecycle(paths, since);
|
||||||
var back = 0;
|
final failed = [for (final (x, e) in why) '${vBasename(x)}: $e'];
|
||||||
for (final x in paths) {
|
|
||||||
try {
|
|
||||||
restoreRecycled(x, since: since);
|
|
||||||
back++;
|
|
||||||
} catch (e) {
|
|
||||||
failed.add('${vBasename(x)}: ${e is FileSystemException ? e.message : e}');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await _refreshAll(paths.map(p.dirname));
|
await _refreshAll(paths.map(p.dirname));
|
||||||
for (final x in _panes) {
|
for (final x in _panes) {
|
||||||
x.changed();
|
x.changed();
|
||||||
|
|
@ -3050,49 +2990,23 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
||||||
await _runTransfer(pane, sources, other.current, move: move);
|
await _runTransfer(pane, sources, other.current, move: move);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 복사 · 이동할 항목 (붙여넣기 전까지 담아 둠)
|
/// 복사 · 이동할 항목 (붙여넣기 전까지 담아 둠) - FileClipboard (E5b)
|
||||||
List<String> _clip = [];
|
final _clip = FileClipboard();
|
||||||
bool _clipMove = false;
|
|
||||||
|
|
||||||
/// 154: 고른 것이 없을 때 (복사 · 이동이 같은 말)
|
/// 154: 고른 것이 없을 때 (복사 · 이동이 같은 말)
|
||||||
String _pickFirst(bool move) => move
|
String _pickFirst(bool move) => move
|
||||||
? tr('옮길 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).')
|
? tr('옮길 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).')
|
||||||
: tr('복사할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).');
|
: tr('복사할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).');
|
||||||
|
|
||||||
/// 154: 항목 이름 (앞의 3개 + "외 n개")
|
|
||||||
/// 48: 받는 폴더에 같은 이름이 있는 원본 (같은 폴더로 복사는 늘 새 이름이라 빼고)
|
|
||||||
/// 48 · 172: 대상 폴더에 같은 이름이 있는 원본 - 두 파일의 크기 · 바뀐 날을 함께 (어느 쪽이 새것인지 보고 고르게)
|
|
||||||
static Future<List<NameClash>> _findClashes(List<String> sources, String dest) async {
|
|
||||||
final out = <NameClash>[];
|
|
||||||
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<String> 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<String> sources, {required bool move}) {
|
void _toClipboard(List<String> sources, {required bool move}) {
|
||||||
if (_blockShizukuMove(sources, null, move: move)) return;
|
if (_blockShizukuMove(sources, null, move: move)) return;
|
||||||
if (sources.isEmpty) {
|
if (sources.isEmpty) {
|
||||||
_snack(_pickFirst(move));
|
_snack(_pickFirst(move));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState(() {
|
setState(() => _clip.put(sources, move: move));
|
||||||
_clip = [...sources];
|
|
||||||
_clipMove = move;
|
|
||||||
});
|
|
||||||
_snack(trf('{0} 을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).',
|
_snack(trf('{0} 을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).',
|
||||||
[_namesOf(sources), move ? tr('이동') : tr('복사')]));
|
[namesOf(sources), move ? tr('이동') : tr('복사')]));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _paste(_Pane pane, String dest) async {
|
Future<void> _paste(_Pane pane, String dest) async {
|
||||||
|
|
@ -3100,10 +3014,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
||||||
_snack(tr('붙여넣을 항목이 없습니다. 먼저 [복사] · [이동] 으로 담으세요.'));
|
_snack(tr('붙여넣을 항목이 없습니다. 먼저 [복사] · [이동] 으로 담으세요.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final items = _clip, move = _clipMove;
|
final done = await _runTransfer(pane, _clip.items, dest, move: _clip.move);
|
||||||
final done = await _runTransfer(pane, items, dest, move: move);
|
|
||||||
// 옮긴 것은 다시 붙여넣을 수 없으므로 비운다 (복사는 여러 곳에 붙여넣을 수 있게 남김)
|
// 옮긴 것은 다시 붙여넣을 수 없으므로 비운다 (복사는 여러 곳에 붙여넣을 수 있게 남김)
|
||||||
if (done && move && mounted) setState(() => _clip = []);
|
if (mounted) setState(() => _clip.pasted(done: done));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 지금 도는 복사 · 이동 (아래에서 올라오는 진행 막대)
|
/// 지금 도는 복사 · 이동 (아래에서 올라오는 진행 막대)
|
||||||
|
|
@ -3181,37 +3094,34 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
||||||
_snack(tr('같은 항목을 같은 곳으로 복사 · 이동하는 중입니다.'));
|
_snack(tr('같은 항목을 같은 곳으로 복사 · 이동하는 중입니다.'));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// 135: 같은 폴더로 - 짧은 알림은 놓치기 쉬워 창으로. 이동은 할 것이 없고, 복사는 사본을 만들지 한 번 묻는다
|
// 135: 같은 폴더로 - 짧은 알림은 놓치기 쉬워 창으로. 이동은 할 것이 없고, 복사는 사본을 만들지 한 번 묻는다 (계획은 transfer_plan)
|
||||||
final here = [for (final x in sources) if (samePath(vDirname(x), dest)) x];
|
final plan = planTransfer(sources, dest, move: move);
|
||||||
|
sources = plan.sources;
|
||||||
|
final skippedHere = plan.skippedHere, here = plan.here;
|
||||||
var sameFolderCopy = false;
|
var sameFolderCopy = false;
|
||||||
// 이동인데 일부만 이미 그 폴더에 있으면 그것만 건너뛰고 나머지를 옮긴다 (관리자: 섞어 고른 것을 다시 고르지 않게)
|
if (plan.nothingToMove) {
|
||||||
var skippedHere = const <String>[];
|
|
||||||
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 로 닫기)
|
// 알림만 ([확인] 에 첫 초점 - Enter 로 닫기)
|
||||||
await showDialog<void>(
|
await showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
scrollable: true,
|
scrollable: true,
|
||||||
title: Text(tr('이미 이 폴더에 있습니다')),
|
title: Text(tr('이미 이 폴더에 있습니다')),
|
||||||
content: Text(trf('{0} 은(는) 이미 이 폴더에 있어 옮길 것이 없습니다.\n{1}', [_namesOf(here), vDisplay(dest)])),
|
content: Text(trf('{0} 은(는) 이미 이 폴더에 있어 옮길 것이 없습니다.\n{1}', [namesOf(here), vDisplay(dest)])),
|
||||||
actions: [FilledButton(autofocus: true, onPressed: () => Navigator.pop(ctx), child: Text(tr('확인')))],
|
actions: [FilledButton(autofocus: true, onPressed: () => Navigator.pop(ctx), child: Text(tr('확인')))],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (plan.askCopyHere) {
|
||||||
final ok = await confirmDialog(context,
|
final ok = await confirmDialog(context,
|
||||||
title: () => tr('같은 폴더입니다'),
|
title: () => tr('같은 폴더입니다'),
|
||||||
confirmLabel: () => 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;
|
if (!ok || !mounted) return false;
|
||||||
sameFolderCopy = true; // 이미 물었으니 아래 확인 창은 건너뛴다
|
sameFolderCopy = true; // 이미 물었으니 아래 확인 창은 건너뛴다
|
||||||
}
|
}
|
||||||
// 원본 · 대상이 없거나 (다른 곳에서 지움 - 목록을 새로 고침) 폴더를 자기 안으로 넣으려 하면 시작하지 않는다
|
// 원본 · 대상이 없거나 (다른 곳에서 지움 - 목록을 새로 고침) 폴더를 자기 안으로 넣으려 하면 시작하지 않는다
|
||||||
final problem = transferProblem(sources, dest, move: move);
|
final problem = plan.problem;
|
||||||
if (problem != null) {
|
if (problem != null) {
|
||||||
_snack(problem);
|
_snack(problem);
|
||||||
pane.sel.removeWhere(vMissingSync);
|
pane.sel.removeWhere(vMissingSync);
|
||||||
|
|
@ -3230,7 +3140,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
||||||
final builtin = method == CopyMethod.builtin || _viaDav([...sources, dest]);
|
final builtin = method == CopyMethod.builtin || _viaDav([...sources, dest]);
|
||||||
// 확인 창은 바로 띄우고, 같은 이름은 창 안에서 뒤에서 찾는다 (느린 네트워크 · WebDAV 폴더라도 창이 늦게 뜨지 않게).
|
// 확인 창은 바로 띄우고, 같은 이름은 창 안에서 뒤에서 찾는다 (느린 네트워크 · WebDAV 폴더라도 창이 늦게 뜨지 않게).
|
||||||
// 다 찾기 전에 누르면 이름 바꾸기 (원래 파일을 건드리지 않는 쪽)
|
// 다 찾기 전에 누르면 이름 바꾸기 (원래 파일을 건드리지 않는 쪽)
|
||||||
final clashesFuture = builtin ? _findClashes(sources, dest) : Future.value(const <NameClash>[]);
|
final clashesFuture = builtin ? findClashes(sources, dest) : Future.value(const <NameClash>[]);
|
||||||
// 48: 환경 설정 "같은 이름이 있을 때" 가 늘 하는 것이면 묻지 않고 그대로 (확인 창에 알림만)
|
// 48: 환경 설정 "같은 이름이 있을 때" 가 늘 하는 것이면 묻지 않고 그대로 (확인 창에 알림만)
|
||||||
final always = switch (c.settings.copyConflict) {
|
final always = switch (c.settings.copyConflict) {
|
||||||
'rename' => NameConflict.rename,
|
'rename' => NameConflict.rename,
|
||||||
|
|
@ -3252,8 +3162,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
||||||
body: (ctx, setInner) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
body: (ctx, setInner) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
// 61: 문장 조각을 이어 붙이지 않는다 (언어마다 어순이 달라 "…로 {복사할까요?}" 가 깨졌다)
|
// 61: 문장 조각을 이어 붙이지 않는다 (언어마다 어순이 달라 "…로 {복사할까요?}" 가 깨졌다)
|
||||||
Text('${move ? trf('{0}개 항목을 다음 폴더로 옮길까요?\n{1}', [sources.length, vDisplay(dest)]) : trf('{0}개 항목을 다음 폴더로 복사할까요?\n{1}', [sources.length, vDisplay(dest)])}'
|
Text('${move ? trf('{0}개 항목을 다음 폴더로 옮길까요?\n{1}', [sources.length, vDisplay(dest)]) : trf('{0}개 항목을 다음 폴더로 복사할까요?\n{1}', [sources.length, vDisplay(dest)])}'
|
||||||
'\n${_namesOf(sources)}'
|
'\n${namesOf(sources)}'
|
||||||
'${skippedHere.isEmpty ? '' : '\n${trf('이미 이 폴더에 있어 건너뜀: {0}', [_namesOf(skippedHere)])}'}'
|
'${skippedHere.isEmpty ? '' : '\n${trf('이미 이 폴더에 있어 건너뜀: {0}', [namesOf(skippedHere)])}'}'
|
||||||
'\n\n${trf('방법: {0}', [tr(method.label)])}'
|
'\n\n${trf('방법: {0}', [tr(method.label)])}'
|
||||||
'${method == CopyMethod.builtin ? '' : ' · ${withoutSourceRemovingOptions(task.options)}${move ? (method == CopyMethod.rsync ? ' --remove-source-files' : ' /MOVE') : ''}'}'
|
'${method == CopyMethod.builtin ? '' : ' · ${withoutSourceRemovingOptions(task.options)}${move ? (method == CopyMethod.rsync ? ' --remove-source-files' : ' /MOVE') : ''}'}'
|
||||||
'${task.bandwidthKBps > 0 ? ' · ${trf('속도 제한 {0} KB/s', [task.bandwidthKBps])}' : ''}'),
|
'${task.bandwidthKBps > 0 ? ' · ${trf('속도 제한 {0} KB/s', [task.bandwidthKBps])}' : ''}'),
|
||||||
|
|
@ -3279,7 +3189,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
||||||
if (clashes.isEmpty) return const SizedBox.shrink();
|
if (clashes.isEmpty) return const SizedBox.shrink();
|
||||||
return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
const SizedBox(height: 12),
|
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)),
|
style: TextStyle(color: JjColors.warning)),
|
||||||
// 172: 두 파일의 크기 · 바뀐 날 (3개까지)
|
// 172: 두 파일의 크기 · 바뀐 날 (3개까지)
|
||||||
for (final x in clashes.take(3)) ...[
|
for (final x in clashes.take(3)) ...[
|
||||||
|
|
@ -3376,10 +3286,10 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
||||||
// 264: 작업 기록에만 있던 것도 알림에 (폴더를 합칠 때 받는 쪽이 더 새 파일 등)
|
// 264: 작업 기록에만 있던 것도 알림에 (폴더를 합칠 때 받는 쪽이 더 새 파일 등)
|
||||||
if (move && job.leftInSource > 0) trf('{0}개는 원본에 남았습니다 (건너뜀).', [job.leftInSource]),
|
if (move && job.leftInSource > 0) trf('{0}개는 원본에 남았습니다 (건너뜀).', [job.leftInSource]),
|
||||||
if (!move && job.keptNewer > 0) trf('받는 쪽이 더 새것이라 {0}개를 건너뛰었습니다.', [job.keptNewer]),
|
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 없이 지우지 않는다
|
// M1 (10/19): 원격 맞추기에서 받는 쪽의 같은 이름 폴더는 --delete 없이 지우지 않는다
|
||||||
if (job.dirConflicts.isNotEmpty) trf('같은 이름의 폴더가 있어 {0}개를 건너뛰었습니다: {1}.', [job.dirConflicts.length, _namesOf(job.dirConflicts)]),
|
if (job.dirConflicts.isNotEmpty) trf('같은 이름의 폴더가 있어 {0}개를 건너뛰었습니다: {1}.', [job.dirConflicts.length, namesOf(job.dirConflicts)]),
|
||||||
if (skippedHere.isNotEmpty) trf('이미 이 폴더에 있어 건너뛰었습니다: {0}.', [_namesOf(skippedHere)]),
|
if (skippedHere.isNotEmpty) trf('이미 이 폴더에 있어 건너뛰었습니다: {0}.', [namesOf(skippedHere)]),
|
||||||
]);
|
]);
|
||||||
// 85: 끝나면 [폴더 열기] - 앱 탐색기에서 넣은 폴더로 (그 폴더를 보고 있는 창, 없으면 지금 창)
|
// 85: 끝나면 [폴더 열기] - 앱 탐색기에서 넣은 폴더로 (그 폴더를 보고 있는 창, 없으면 지금 창)
|
||||||
if (error == null && mounted) {
|
if (error == null && mounted) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -460,7 +460,7 @@ void main() {
|
||||||
unawaited((tester.state(find.byType(ExplorerPage)) as dynamic).debugOpenTrash() as Future<void>);
|
unawaited((tester.state(find.byType(ExplorerPage)) as dynamic).debugOpenTrash() as Future<void>);
|
||||||
await settle(tester, () => find.byType(CheckboxListTile).evaluate().isNotEmpty);
|
await settle(tester, () => find.byType(CheckboxListTile).evaluate().isNotEmpty);
|
||||||
await act(tester, () => tester.tap(find.byType(CheckboxListTile).first));
|
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 act(tester, () => tester.tap(find.text('되살리기')));
|
||||||
await settle(tester, () => File(doc).existsSync());
|
await settle(tester, () => File(doc).existsSync());
|
||||||
expect(File(doc).readAsStringSync(), 'hello');
|
expect(File(doc).readAsStringSync(), 'hello');
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue