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
RyuJongJin 2026-10-22 09:20:50 +09:00
parent dab9db5d4d
commit 0300d51092
7 changed files with 417 additions and 139 deletions

116
lib/app/delete_ops.dart Normal file
View File

@ -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);
}

View File

@ -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;
}

View File

@ -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 [];
}

View File

@ -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<ExplorerPage> {
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<ExplorerPage> {
]),
);
if (!ok) return;
// 하나씩: 실패해도 나머지는 계속. 실제로 어떻게 됐는지 세어 그대로 알린다 (94)
final failed = <(String, String)>[];
// 144: 경로가 너무 길어 휴지통이 받지 않은 것 (이유는 한 번, [영구 삭제] 로 바로 지울 수 있게)
final tooLong = <String>[];
var recycled = 0, nuked = 0, kept = 0;
// 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'));
}
}
// 하나씩: 실패해도 나머지는 계속. 실제로 어떻게 됐는지 세어 그대로 알린다 (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<ExplorerPage> {
]),
);
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<ExplorerPage> {
true;
// 144: 사용자가 [영구 삭제] 를 고른 긴 경로만 지운다
if (nukeLong == true) {
final notDeleted = <String>[];
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<ExplorerPage> {
Future<void> _undoTrash(List<TrashItem> items) async {
final trash = _appTrash;
if (trash == null) return;
final back = <String>[];
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<ExplorerPage> {
/// 148: 방금 휴지통으로 보낸 것을 원래 자리로 (원래 자리에 같은 이름이 생겼으면 그것은 두고 알림)
Future<void> _undoRecycle(List<String> paths, DateTime since) async {
final failed = <String>[];
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<ExplorerPage> {
await _runTransfer(pane, sources, other.current, move: move);
}
/// 복사 · 이동할 항목 (붙여넣기 전까지 담아 둠)
List<String> _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<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}) {
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<void> _paste(_Pane pane, String dest) async {
@ -3100,10 +3014,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
_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<ExplorerPage> {
_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 <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 로 닫기)
await showDialog<void>(
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<void>(
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<ExplorerPage> {
final builtin = method == CopyMethod.builtin || _viaDav([...sources, dest]);
// 확인 창은 바로 띄우고, 같은 이름은 창 안에서 뒤에서 찾는다 (느린 네트워크 · WebDAV 폴더라도 창이 늦게 뜨지 않게).
// 다 찾기 전에 누르면 이름 바꾸기 (원래 파일을 건드리지 않는 쪽)
final clashesFuture = builtin ? _findClashes(sources, dest) : Future.value(const <NameClash>[]);
final clashesFuture = builtin ? findClashes(sources, dest) : Future.value(const <NameClash>[]);
// 48: 환경 설정 "같은 이름이 있을 때" 가 늘 하는 것이면 묻지 않고 그대로 (확인 창에 알림만)
final always = switch (c.settings.copyConflict) {
'rename' => NameConflict.rename,
@ -3252,8 +3162,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
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<ExplorerPage> {
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<ExplorerPage> {
// 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) {

View File

@ -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);
}

View File

@ -460,7 +460,7 @@ void main() {
unawaited((tester.state(find.byType(ExplorerPage)) as dynamic).debugOpenTrash() as Future<void>);
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');

View File

@ -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);
});
}