277 lines
15 KiB
Dart
277 lines
15 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:path/path.dart' as p;
|
|
|
|
import '../../app/delete_ops.dart';
|
|
import '../../core/app_trash.dart';
|
|
import '../../core/file_ops.dart';
|
|
import '../../core/vfs.dart';
|
|
import '../../l10n/tr.dart';
|
|
import '../../platform/windows/recycle_bin.dart';
|
|
import '../confirm_dialog.dart';
|
|
import '../notice.dart';
|
|
import '../theme.dart';
|
|
import '../trash_page.dart';
|
|
import 'explorer_controller.dart';
|
|
import 'explorer_pane.dart';
|
|
import 'explorer_select.dart';
|
|
import 'write_guard.dart';
|
|
|
|
/// 약속 (E5d-5): 지우기 흐름. Windows 의 로컬 파일은 기본으로 휴지통 (창에서 "영구 삭제" 를 고르거나 [shiftDelete]),
|
|
/// Android 는 앱 휴지통 ([appTrash], 보관 일이 0 이면 끔), WebDAV 등 휴지통이 없는 곳은 처음부터 영구 삭제로 묻는다.
|
|
/// 지우기 규칙은 delete_ops (E5b). 여러 개면 실패해도 나머지를 계속하고, 못 지운 것 · 휴지통에 못 넣은 것은 창으로 묻거나 알린다.
|
|
/// 휴지통으로 보낸 것은 알림의 [되돌리기]. 끝나면 표시 · 펼침 · 지금 폴더를 정리하고 다시 읽는다. 읽기 전용인 곳은 [ExplorerGuard] 가 막는다
|
|
class ExplorerDeleteFlow {
|
|
ExplorerDeleteFlow(this.ctl, this.sel, this.guard, {required this.androidTrash});
|
|
|
|
final ExplorerController ctl;
|
|
final ExplorerSelect sel;
|
|
final ExplorerGuard guard;
|
|
|
|
/// 앱 휴지통을 쓰는 기기인지 (Android)
|
|
final bool Function() androidTrash;
|
|
|
|
/// Shift+Delete 로 불렀는지 (휴지통을 거치지 않고 지우기를 미리 골라 둔다 - 65)
|
|
bool shiftDelete = false;
|
|
|
|
/// 지우기. Windows 의 로컬 파일은 기본으로 휴지통으로 (창에서 "영구 삭제" 를 고르거나 Shift+Delete).
|
|
/// WebDAV · Android 는 휴지통이 없어 바로 지운다. 여러 개면 실패해도 나머지를 계속하고, 못 지운 것을 알린다 (65).
|
|
Future<void> run(BuildContext context, ExplorerPane pane, List<String> paths) async {
|
|
if (guard.blockArchive(paths)) return;
|
|
paths = outermostPaths(paths); // 79-c: 고른 폴더 안의 것은 한 번만 (확인 창 개수도)
|
|
final shift = shiftDelete;
|
|
shiftDelete = false;
|
|
if (paths.isEmpty) {
|
|
ctl.host.snack(tr('지울 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).'));
|
|
return;
|
|
}
|
|
// 휴지통이 있는 곳: Windows 의 고정 디스크 (94: 네트워크 드라이브 · \\NAS · USB 메모리는 휴지통이 없어 처음부터 영구 삭제로 묻는다)
|
|
final local = Platform.isWindows && paths.every((x) => !isDav(x));
|
|
// X-plore 비교 e: Android 는 앱 휴지통 (저장 장치의 .jj_trash, 보관 일이 0 이면 끔)
|
|
final trash = appTrash;
|
|
final canTrash = trash != null && paths.every((x) => !isDav(x) && trash.trashDirFor(x) != null && !trash.isInTrash(x));
|
|
final canRecycle = (local && ctl.c.settings.general.recycleOnDelete && paths.every(hasRecycleBin)) || canTrash;
|
|
var permanent = !canRecycle || shift;
|
|
// X-plore 숨기기: 지우는 폴더 안에 목록에서 숨긴 것이 있으면 알린다 (숨긴 것도 함께 지워진다)
|
|
final hiddenInside = [
|
|
for (final h in ctl.c.settings.explorer.hiddenItems)
|
|
if (paths.any((x) => !samePath(h, x) && isSameOrInside(h, x))) h
|
|
].length;
|
|
final otherMarks = [for (final x in ctl.panes) if (!identical(x, pane)) x.sel.marked.length].fold(0, (a, b) => a + b);
|
|
// U7: 확인 창은 공용 confirmDialog - 영구 삭제로 열면 Enter 의 첫 초점은 [취소]
|
|
final ok = await confirmDialog(
|
|
context,
|
|
title: () => permanent ? tr('영구 삭제') : tr('휴지통으로 보내기'),
|
|
danger: () => permanent,
|
|
confirmLabel: () => permanent ? tr('영구 삭제') : tr('휴지통으로'),
|
|
// 무엇을 지우는지 늘 보여 준다 (여러 개면 앞의 8개 이름 · 폴더)
|
|
body: (ctx, set) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
Text(paths.length == 1
|
|
? '${permanent ? trf('"{0}" 을(를) 지울까요? 되돌릴 수 없습니다.', [vBasename(paths.first)]) : trf('"{0}" 을(를) 휴지통으로 보낼까요?', [vBasename(paths.first)])}'
|
|
'\n${vDirname(paths.first)}'
|
|
: [
|
|
permanent
|
|
? trf('{0}개 항목을 지울까요? 되돌릴 수 없습니다.', [paths.length])
|
|
: trf('{0}개 항목을 휴지통으로 보낼까요?', [paths.length]),
|
|
for (final x in paths.take(8)) '· ${vBasename(x)}',
|
|
if (paths.length > 8) trf('… 외 {0}개', [paths.length - 8]),
|
|
{for (final x in paths) vDirname(x)}.join('\n'),
|
|
].join('\n')),
|
|
// 79-b: 폴더가 있으면 몇 개인지 (안의 것도 함께 지워진다)
|
|
if (paths.any(vIsDirSync))
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 8),
|
|
child: Text(trf('그중 폴더 {0}개 - 안의 파일 · 폴더도 모두 함께', [paths.where(vIsDirSync).length]),
|
|
style: TextStyle(fontSize: 12, color: JjColors.warning)),
|
|
),
|
|
if (hiddenInside > 0)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 8),
|
|
child: Text(trf('지우는 폴더 안에 목록에서 숨긴 항목 {0}개가 있습니다 - 함께 지워집니다', [hiddenInside]),
|
|
style: TextStyle(fontSize: 12, color: JjColors.warning)),
|
|
),
|
|
if (local && !canRecycle)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 8),
|
|
child: Text(tr('이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.'),
|
|
style: TextStyle(fontSize: 12, color: JjColors.warning)),
|
|
),
|
|
if (canTrash && !permanent)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 8),
|
|
child: Text(trf('앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).', [ctl.c.settings.explorer.androidTrashDays]),
|
|
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
|
|
),
|
|
if (canRecycle)
|
|
CheckboxListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
value: permanent,
|
|
onChanged: (v) => set(() => permanent = v ?? false),
|
|
title: Text(canTrash ? tr('휴지통을 거치지 않고 영구 삭제') : tr('휴지통을 거치지 않고 영구 삭제 (Shift+Delete)')),
|
|
),
|
|
// U4: 다른 창의 표시는 이번 대상이 아님
|
|
if (otherMarks > 0)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 8),
|
|
child: Text(trf('다른 창에 {0}개 표시됨 (이번 대상 아님)', [otherMarks]),
|
|
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
|
|
),
|
|
]),
|
|
);
|
|
if (!ok) return;
|
|
// 하나씩: 실패해도 나머지는 계속. 실제로 어떻게 됐는지 세어 그대로 알린다 (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 && context.mounted) {
|
|
// U7: 되돌릴 수 없는 일 - Enter 의 첫 초점은 [그대로 두기]
|
|
final nuke = await confirmDialog(
|
|
context,
|
|
icon: Icon(Icons.warning_amber_rounded, color: JjColors.warning),
|
|
title: () => trf('{0}개를 휴지통으로 옮기지 못했습니다', [notTrashed.length]),
|
|
danger: () => true,
|
|
cancelLabel: tr('그대로 두기'),
|
|
confirmLabel: () => trf('영구 삭제 ({0}개, 되돌릴 수 없음)', [notTrashed.length]),
|
|
body: (ctx, set) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
for (final (x, why) in notTrashed.take(10)) Text('· ${vBasename(x)} - $why', style: const TextStyle(fontSize: 12)),
|
|
const SizedBox(height: 8),
|
|
Text(tr('영구 삭제하면 되살릴 수 없습니다.')),
|
|
]),
|
|
);
|
|
if (nuke) {
|
|
for (final (x, e) in await deleteForever([for (final (x, _) in notTrashed) x])) {
|
|
failed.add((x, '$e'));
|
|
}
|
|
}
|
|
}
|
|
if ((failed.isNotEmpty || tooLong.isNotEmpty) && context.mounted) {
|
|
final failTitle = trf('{0}개 중 {1}개를 지우지 못했습니다', [paths.length, failed.length + tooLong.length]);
|
|
final failIcon = Icon(Icons.error_outline, color: JjColors.alert);
|
|
Widget failBody() => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
// 144: 긴 경로는 이유 한 줄 + 이름만 (영구 삭제는 아래 버튼으로)
|
|
if (tooLong.isNotEmpty) ...[
|
|
Text(tr('경로가 너무 길어 휴지통에 넣을 수 없습니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다).'),
|
|
style: const TextStyle(fontWeight: FontWeight.w600)),
|
|
const SizedBox(height: 4),
|
|
for (final x in tooLong.take(30)) Text('· ${vBasename(x)}', style: const TextStyle(fontSize: 12)),
|
|
if (tooLong.length > 30) Text(trf('… 외 {0}개', [tooLong.length - 30])),
|
|
const SizedBox(height: 8),
|
|
],
|
|
for (final (x, why) in failed.take(30)) ...[
|
|
Text(vBasename(x), style: const TextStyle(fontWeight: FontWeight.w600)),
|
|
Text('${vDirname(x)} · $why', style: TextStyle(fontSize: 12, color: JjColors.textDim)),
|
|
const SizedBox(height: 4),
|
|
],
|
|
if (failed.length > 30) Text(trf('… 외 {0}개', [failed.length - 30])),
|
|
const SizedBox(height: 6),
|
|
// 이유를 이미 위에 적었으므로 짐작하는 말은 빼고, 실제로 지운 것이 있을 때만 "나머지는 지웠습니다"
|
|
if (failed.length + tooLong.length < paths.length) Text(tr('나머지는 지웠습니다.'), style: const TextStyle(fontSize: 12)),
|
|
]);
|
|
// U7: 긴 경로를 영구 삭제할지 묻는 것은 되돌릴 수 없는 일 - Enter 의 첫 초점은 [그대로 두기]. 아니면 알림만
|
|
final nukeLong = tooLong.isNotEmpty
|
|
? await confirmDialog(
|
|
context,
|
|
icon: failIcon,
|
|
title: () => failTitle,
|
|
danger: () => true,
|
|
cancelLabel: tr('그대로 두기'),
|
|
confirmLabel: () => trf('영구 삭제 ({0}개, 되돌릴 수 없음)', [tooLong.length]),
|
|
body: (ctx, set) => failBody(),
|
|
)
|
|
: await showDialog<bool>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
scrollable: true,
|
|
icon: failIcon,
|
|
title: Text(failTitle),
|
|
content: failBody(),
|
|
actions: [FilledButton(autofocus: true, onPressed: () => Navigator.pop(ctx), child: Text(tr('확인')))],
|
|
),
|
|
) ==
|
|
true;
|
|
// 144: 사용자가 [영구 삭제] 를 고른 긴 경로만 지운다
|
|
if (nukeLong == true) {
|
|
final notDeleted = [for (final (x, e) in await deleteForever(tooLong)) '${vBasename(x)}: ${deleteFailReason(e)}'];
|
|
ctl.host.snack(joinSentences([
|
|
if (tooLong.length > notDeleted.length) trf('{0}개를 영구 삭제했습니다.', [tooLong.length - notDeleted.length]),
|
|
if (notDeleted.isNotEmpty) trf('지우지 못함: {0}', [notDeleted.take(3).join(' · ')]),
|
|
]));
|
|
}
|
|
} else if (!permanent && context.mounted) {
|
|
final text = joinSentences([
|
|
if (recycled > 0) trf('{0}개 항목을 휴지통으로 보냈습니다.', [recycled]),
|
|
// 휴지통보다 커서 Windows 가 물었고 사용자가 영구 삭제를 골랐다
|
|
if (nuked > 0) trf('{0}개는 휴지통에 들어가지 않아 영구 삭제했습니다 (Windows 가 묻고 고른 대로).', [nuked]),
|
|
if (kept > 0) trf('{0}개는 지우지 않았습니다 (취소).', [kept]),
|
|
]);
|
|
// 148: 휴지통으로 보낸 것은 바로 [되돌리기]
|
|
showNotice(ScaffoldMessenger.of(context),
|
|
text: text,
|
|
action: trashed.isNotEmpty
|
|
? (tr('되돌리기'), () => _undoTrash(trashed))
|
|
: sent.isEmpty
|
|
? null
|
|
: (tr('되돌리기'), () => _undoRecycle(sent, since)));
|
|
}
|
|
// X-plore 숨기기: 영구로 지운 것 (그 안 포함) 은 숨긴 목록에서 뺀다. 휴지통으로 보낸 것은 되돌리기 뒤에도 숨김이 되게 남긴다
|
|
await ctl.followHidden(gone: [
|
|
for (final x in paths)
|
|
if (!sent.contains(x) && !undoable.contains(x) && !failed.any((f) => f.$1 == x)) x,
|
|
]);
|
|
for (final x in ctl.panes) {
|
|
paths.forEach(x.sel.forget);
|
|
if (sel.selecting && x.sel.isEmpty) sel.selecting = false;
|
|
x.expanded.removeWhere((d) => paths.any((s) => isSameOrInside(d, s)));
|
|
if (paths.any((s) => isSameOrInside(x.current, s))) x.current = vDirname(paths.first);
|
|
}
|
|
await ctl.refreshAll(paths.map(p.dirname));
|
|
}
|
|
|
|
/// X-plore 비교 e: Android 앱 휴지통 (보관 일이 0 이면 null - 바로 영구 삭제)
|
|
AppTrash? get appTrash => androidTrash() && ctl.c.settings.explorer.androidTrashDays > 0
|
|
? AppTrash([for (final (v, _) in ctl.local) v])
|
|
: null;
|
|
|
|
/// X-plore 비교 e: 방금 앱 휴지통으로 보낸 것을 원래 자리로
|
|
Future<void> _undoTrash(List<TrashItem> items) async {
|
|
final trash = appTrash;
|
|
if (trash == null) return;
|
|
final back = await undoTrash(trash, items);
|
|
await ctl.refreshAll(back.map(p.dirname));
|
|
ctl.host.snack(trf('{0}개를 원래 자리로 되살렸습니다', [back.length]));
|
|
}
|
|
|
|
/// X-plore 비교 e: [휴지통] - Android 는 앱 휴지통 화면, Windows 는 시스템 휴지통
|
|
Future<void> openTrash(BuildContext context) async {
|
|
final trash = appTrash;
|
|
if (trash == null) {
|
|
if (Platform.isWindows) {
|
|
await Process.start('explorer.exe', ['shell:RecycleBinFolder']);
|
|
} else {
|
|
ctl.host.snack(tr('휴지통이 꺼져 있습니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).'));
|
|
}
|
|
return;
|
|
}
|
|
final back = await Navigator.of(context).push<List<String>>(
|
|
MaterialPageRoute(builder: (_) => TrashPage(trash: trash, days: ctl.c.settings.explorer.androidTrashDays)));
|
|
if (back != null && back.isNotEmpty && context.mounted) await ctl.refreshAll(back.map(p.dirname));
|
|
}
|
|
|
|
/// 148: 방금 휴지통으로 보낸 것을 원래 자리로 (원래 자리에 같은 이름이 생겼으면 그것은 두고 알림)
|
|
Future<void> _undoRecycle(List<String> paths, DateTime since) async {
|
|
final (:back, failed: why) = undoRecycle(paths, since);
|
|
final failed = [for (final (x, e) in why) '${vBasename(x)}: $e'];
|
|
await ctl.refreshAll(paths.map(p.dirname));
|
|
for (final x in ctl.panes) {
|
|
x.changed();
|
|
}
|
|
ctl.host.snack(joinSentences([
|
|
if (back > 0) trf('{0}개 항목을 되돌렸습니다.', [back]),
|
|
if (failed.isNotEmpty) trf('되돌리지 못함: {0}', [failed.take(3).join(' · ')]),
|
|
]));
|
|
}
|
|
}
|