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/explorer/focus_after_delete.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, required this.recycleBin}); final ExplorerController ctl; final ExplorerSelect sel; final ExplorerGuard guard; /// 앱 휴지통을 쓰는 기기인지 (Android) final bool Function() androidTrash; /// Windows 휴지통 (기기에서 - ExplorerDevice.recycleBin) final RecycleBinBackend Function() recycleBin; /// Shift+Delete 로 불렀는지 (휴지통을 거치지 않고 지우기를 미리 골라 둔다 - 65) bool shiftDelete = false; /// 지우기. Windows 의 로컬 파일은 기본으로 휴지통으로 (창에서 "영구 삭제" 를 고르거나 Shift+Delete). /// WebDAV · Android 는 휴지통이 없어 바로 지운다. 여러 개면 실패해도 나머지를 계속하고, 못 지운 것을 알린다 (65). Future run(BuildContext context, ExplorerPane pane, List 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; // 관리자 결정 10/23: 지운 뒤 초점은 지운 줄의 다음 줄로 (마지막이었으면 앞 줄로) - 지우기 전의 차례를 적어 둔다 final focusedBefore = pane.focused; final shownBefore = focusedBefore == null ? const [] : [for (final e in sel.shown(pane, vDirname(focusedBefore))) e.path]; // 하나씩: 실패해도 나머지는 계속. 실제로 어떻게 됐는지 세어 그대로 알린다 (94) - 지우기 규칙은 delete_ops (E5b) final report = await deletePaths(paths, permanent: permanent, trash: !permanent && canTrash ? trash : null, bin: recycleBin()); 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( 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)); final gone = [for (final x in paths) if (!await vExists(x)) x]; final next = focusAfterDelete(shownBefore, gone, focusedBefore, same: samePath, inside: isSameOrInside); // 그사이 사용자가 다른 줄로 옮겼으면 그대로 둔다 (새로 읽으며 지운 줄의 초점이 비워졌을 수도 있다) final cur = pane.focused; if (next != null && (cur == null || samePath(cur, focusedBefore!))) { pane.focused = next.isEmpty ? null : next; pane.changed(); } } /// 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 _undoTrash(List 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 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>( 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 _undoRecycle(List paths, DateTime since) async { final (:back, failed: why) = undoRecycle(paths, since, bin: recycleBin()); 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(' · ')]), ])); } }