JJ_MKVMaker/lib/ui/explorer/delete_flow.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.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(' · ')]),
]));
}
}