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/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) {
|
||||
|
|
|
|||
|
|
@ -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>);
|
||||
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');
|
||||
|
|
|
|||
|
|
@ -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