diff --git a/lib/ui/explorer/delete_flow.dart b/lib/ui/explorer/delete_flow.dart new file mode 100644 index 0000000..7ca9f41 --- /dev/null +++ b/lib/ui/explorer/delete_flow.dart @@ -0,0 +1,276 @@ +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 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.recycleOnDelete && paths.every(hasRecycleBin)) || canTrash; + var permanent = !canRecycle || shift; + // X-plore 숨기기: 지우는 폴더 안에 목록에서 숨긴 것이 있으면 알린다 (숨긴 것도 함께 지워진다) + final hiddenInside = [ + for (final h in ctl.c.settings.explorerHiddenItems) + 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.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( + 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.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.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); + 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(' · ')]), + ])); + } +} diff --git a/lib/ui/explorer/explorer_buttons.dart b/lib/ui/explorer/explorer_buttons.dart index c843cbc..b2f7c82 100644 --- a/lib/ui/explorer/explorer_buttons.dart +++ b/lib/ui/explorer/explorer_buttons.dart @@ -11,7 +11,13 @@ import '../monitor_page.dart'; import '../vault_page.dart' show openVault; import 'explorer_controller.dart'; import 'explorer_pane.dart'; +import 'delete_flow.dart'; import 'explorer_select.dart'; +import 'name_dialog.dart'; +import 'open_flow.dart'; +import 'rsync_flow.dart'; +import 'transfer_flow.dart'; +import 'write_guard.dart'; /// 기능 버튼 (가운데 줄). 이름은 설정 (explorerButtons) 에 저장된다. enum ExplorerButton { @@ -92,28 +98,21 @@ abstract interface class ExplorerCommands { /// 창 · 메뉴를 띄울 곳 BuildContext get context; - /// 다음 지우기는 휴지통을 거치지 않음 (Shift+Delete · [삭제] 길게 누르기 - 창에서 한 번 더 확인) - abstract bool shiftDelete; + /// 복사 · 이동 갈 곳 (두 창 · 숨은 다른 창의 지금 폴더, 없으면 null - 담아 두기) + String? get otherDir; - /// 버튼이 다룰 것 (표시한 것, 없으면 지금 줄) - List targetsOf(ExplorerPane pane); - Future runRsync({required bool right, required bool left}); - Future play(List paths, {bool internal = false, bool keepOrder = false}); - Future playFolder(String dir); + /// 두 창으로 보이는지 + bool get dual; + + /// 81: 빠른 보기 - [path] (없으면 지금 줄). 같은 폴더에 보이는 항목을 ← → 로 + Future quickLook(ExplorerPane pane, [String? path]); Future addMkv(List paths); - /// 압축 안이면 막고 알림 (막았으면 true) - bool blockArchive(List paths, {bool self = false}); - Future askName(String title, String initial, {String? hint, bool selectAll = false}); Future renameItem(ExplorerPane pane, String path); Future batchRename(ExplorerPane pane, List paths); - Future transfer(ExplorerPane pane, List sources, {required bool move}); - Future paste(ExplorerPane pane, String dest); - Future deleteItems(ExplorerPane pane, List paths); Future openSearch(ExplorerPane pane); Future openStorage(ExplorerPane pane, String dir); Future openWifiShare(String dir); - Future openTrash(); Future sortDialog(); Future historyDialog(ExplorerPane pane); Future openBookmarks(ExplorerPane pane); @@ -123,13 +122,19 @@ abstract interface class ExplorerCommands { } /// 약속 (E5d-4): 기능 버튼 하나 → 할 일 (가운데 줄 · 단축키 · 위 메뉴가 같이 부른다). 지금 창 ([ExplorerController.pane]) 에 한다. -/// 창 · 고르기 · 다시 읽기 · 새 폴더 · 숨은 항목은 여기서, 나머지는 [ExplorerCommands]. 못 하면 까닭을 알림 (Host) +/// 창 · 고르기 · 다시 읽기 · 새 폴더 · 숨은 항목은 여기서, 복사 · 이동 · 지우기 · rsync 는 흐름 모듈, 나머지는 [ExplorerCommands]. 못 하면 까닭을 알림 (Host) class ExplorerButtons { - ExplorerButtons(this.ctl, this.sel, this.cmd); + ExplorerButtons(this.ctl, this.sel, this.cmd, + {required this.guard, required this.transfers, required this.deletes, required this.rsync, required this.opener}); final ExplorerController ctl; final ExplorerSelect sel; final ExplorerCommands cmd; + final ExplorerGuard guard; + final ExplorerTransfers transfers; + final ExplorerDeleteFlow deletes; + final ExplorerRsyncFlow rsync; + final ExplorerOpener opener; /// X-plore b: 길게 누르면 하는 짝 동작의 이름 (없으면 null) static String? longPressLabel(ExplorerButton b) => switch (b) { @@ -147,7 +152,7 @@ class ExplorerButtons { case ExplorerButton.move: return press(ExplorerButton.copy); case ExplorerButton.delete: - cmd.shiftDelete = true; + deletes.shiftDelete = true; return press(ExplorerButton.delete); default: return; @@ -167,30 +172,30 @@ class ExplorerButtons { case ExplorerButton.monitor: await MonitorPage.open(cmd.context, ctl.c); case ExplorerButton.toRight: - await cmd.runRsync(right: true, left: false); + await rsync.run(cmd.context, right: true, left: false); case ExplorerButton.toLeft: - await cmd.runRsync(right: false, left: true); + await rsync.run(cmd.context, right: false, left: true); case ExplorerButton.both: - await cmd.runRsync(right: true, left: true); + await rsync.run(cmd.context, right: true, left: true); case ExplorerButton.refresh: // 183: 두 창 모두 (밖에서 지운 파일이 다른 창에 남지 않게) await ctl.reloadOpen(); case ExplorerButton.play: - final t = cmd.targetsOf(pane); + final t = sel.targets(pane); // 181: 강조한 것이 mp3 등 소리 파일이어도 그것을 (예전에는 동영상이 아니라고 빼고 폴더의 동영상을 틀었다) final videos = t.where((x) => !vIsDirSync(x) && (isVideoFile(x) || isAudioFile(x))).toList(); if (videos.isNotEmpty) { - await cmd.play(videos, keepOrder: !pane.sel.isEmpty); + await opener.play(cmd.context, videos, keepOrder: !pane.sel.isEmpty); } else { - await cmd.playFolder(t.length == 1 && vIsDirSync(t.first) ? t.first : pane.current); + await opener.playFolder(cmd.context, t.length == 1 && vIsDirSync(t.first) ? t.first : pane.current); } case ExplorerButton.addMkv: - final t = cmd.targetsOf(pane); + final t = sel.targets(pane); await cmd.addMkv(t.isEmpty ? [pane.current] : t); case ExplorerButton.newFolder: // X-plore 비교 f: 펼친 압축 안 (지금 폴더가 압축 안의 폴더) 에는 만들지 않는다 - if (cmd.blockArchive([pane.current], self: true)) return; - final name = await cmd.askName(tr('새 폴더'), ''); + if (guard.blockArchive([pane.current], self: true)) return; + final name = await askName(cmd.context, tr('새 폴더'), ''); if (name == null) return; try { final parent = pane.current; @@ -211,7 +216,7 @@ class ExplorerButtons { ctl.host.snack(trf('폴더를 만들 수 없습니다: {0}', [e])); } case ExplorerButton.rename: - final t = cmd.targetsOf(pane); + final t = sel.targets(pane); if (t.isEmpty) { ctl.host.snack(tr('이름을 바꿀 항목 하나를 고르세요.')); return; @@ -220,13 +225,13 @@ class ExplorerButtons { if (t.length > 1) return cmd.batchRename(pane, t); await cmd.renameItem(pane, t.first); case ExplorerButton.copy: - await cmd.transfer(pane, cmd.targetsOf(pane), move: false); + await transfers.transfer(cmd.context, pane, sel.targets(pane), move: false, otherDir: cmd.otherDir); case ExplorerButton.move: - await cmd.transfer(pane, cmd.targetsOf(pane), move: true); + await transfers.transfer(cmd.context, pane, sel.targets(pane), move: true, otherDir: cmd.otherDir); case ExplorerButton.paste: - await cmd.paste(pane, pane.current); + await transfers.paste(cmd.context, pane, pane.current); case ExplorerButton.delete: - await cmd.deleteItems(pane, cmd.targetsOf(pane)); + await deletes.run(cmd.context, pane, sel.targets(pane)); case ExplorerButton.search: await cmd.openSearch(pane); case ExplorerButton.storage: @@ -242,7 +247,7 @@ class ExplorerButtons { await openVault(cmd.context, ctl.c); } case ExplorerButton.trash: - await cmd.openTrash(); + await deletes.openTrash(cmd.context); case ExplorerButton.sort: await cmd.sortDialog(); case ExplorerButton.hidden: diff --git a/lib/ui/explorer/explorer_jobs.dart b/lib/ui/explorer/explorer_jobs.dart new file mode 100644 index 0000000..fa2c082 --- /dev/null +++ b/lib/ui/explorer/explorer_jobs.dart @@ -0,0 +1,68 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import '../../app/transfer_job.dart'; +import '../../core/file_ops.dart' show samePath; +import '../../core/vfs.dart' show vBasename, vDirname, vExists, vJoin; +import 'explorer_controller.dart'; + +/// 약속 (E5d-5): 지금 도는 복사 · 이동 · rsync 작업 (아래에서 올라오는 진행 패널의 목록). 23: 여럿이 함께 돈다. +/// 끝난 것은 진행 막대가 잠깐 100% 를 보인 뒤 ([dropDelay]) 목록에서 내려간다. 바뀌면 알림 +class ExplorerJobs extends ChangeNotifier { + ExplorerJobs(this.ctl); + + final ExplorerController ctl; + + static const dropDelay = Duration(milliseconds: 1200); + + List list = const []; + + bool get isEmpty => list.isEmpty; + + /// 새 작업을 목록에 (끝난 것은 이때 뺀다) + void start(TransferJob job) { + list = [...list.where((j) => !j.finished), job]; + notifyListeners(); + } + + /// 같은 것을 같은 곳으로 복사 · 이동하는 중인지 (그것만 막는다) + bool busyWith(List sources, String dest) => + list.any((j) => !j.finished && samePath(j.dest, dest) && j.sources.toSet().containsAll(sources)); + + /// [jobs] 를 잠깐 뒤 목록에서 뺀다 + Future dropLater(Iterable jobs) async { + await Future.delayed(dropDelay); + if (!ctl.host.alive || !list.any(jobs.contains)) return; + list = [for (final j in list) if (!jobs.contains(j)) j]; + notifyListeners(); + } + + /// X-plore 숨기기: 옮긴 것은 숨긴 목록도 새 자리로 (원래 자리에 없고 새 자리에 있는 것만 - 같은 이름 바꾸기 · 실패는 그대로) + Future followMoved(TransferJob job) async { + if (!job.move || job.contents || ctl.c.settings.explorerHiddenItems.isEmpty) return; + final moved = <(String, String)>[]; + for (final s in job.sources) { + final to = vJoin(job.dest, vBasename(s)); + if (!await vExists(s) && await vExists(to)) moved.add((s, to)); + } + await ctl.followHidden(moved: moved); + } + + /// 52: 이 화면을 떠난 사이에 시작된 (또는 앞의 화면이 시작한) 작업을 다시 보이고, 끝나면 새로 읽고 막대를 내린다 + void adopt(TransferJob job) { + if (list.contains(job)) return; + list = [...list, job]; + notifyListeners(); + unawaited(job.done.then((_) async { + if (!ctl.host.alive) return; + await followMoved(job); + if (!ctl.host.alive) return; + await ctl.refreshAll([job.dest, ...job.sources.map(vDirname)]); + for (final x in ctl.panes) { + x.changed(); + } + await dropLater([job]); + })); + } +} diff --git a/lib/ui/explorer/explorer_select.dart b/lib/ui/explorer/explorer_select.dart index 9879dfd..4139a63 100644 --- a/lib/ui/explorer/explorer_select.dart +++ b/lib/ui/explorer/explorer_select.dart @@ -120,6 +120,14 @@ class ExplorerSelect extends ChangeNotifier { bool isMarked(ExplorerPane pane, String path) => pane.sel.isMarked(path); + /// 기능의 대상: 표시한 항목, 없으면 마지막으로 누른 항목 (저장 장치 맨 위는 빼고) + List targets(ExplorerPane pane) { + if (!pane.sel.isEmpty) return pane.sel.marked; + final f = pane.focused; + if (f == null || samePath(f, pane.root)) return const []; + return [f]; + } + Future> _childPaths(ExplorerPane pane, String dir) async { await ctl.load(pane, dir); return [for (final x in pane.cache[dir] ?? const []) x.path]; diff --git a/lib/ui/explorer/item_actions.dart b/lib/ui/explorer/item_actions.dart new file mode 100644 index 0000000..00bf4c3 --- /dev/null +++ b/lib/ui/explorer/item_actions.dart @@ -0,0 +1,214 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:path/path.dart' as p; + +import '../../core/file_attributes.dart' show readFileAttributes; +import '../../core/file_ops.dart' show FileEntry, FileOps, formatSize; +import '../../core/ftp.dart' show FtpRegistry; +import '../../core/name_clash.dart' show fileDateText; +import '../../core/playlist.dart' show isVideoFile; +import '../../core/sftp.dart' show SftpRegistry; +import '../../core/smb.dart' show SmbRegistry; +import '../../core/vfs.dart'; +import '../../core/webdav.dart' show DavRegistry; +import '../../l10n/tr.dart'; +import '../theme.dart'; +import 'explorer_controller.dart'; +import 'explorer_pane.dart'; +import 'file_thumbs.dart' show VideoMeta; + +// 약속 (E5d-5): 줄 메뉴의 항목 하나씩 하는 일 (정보 · 경로 복사 · 목록에서 숨기기 확인 · .nomedia) 과 메뉴 위 아이콘 줄. +// 묻는 창은 [취소] 면 아무것도 바꾸지 않는다. 못 하면 까닭을 알린다 (Host) + +/// 77: [경로 복사] 에 넣을 것 - 실제 경로 (Android 도 "SD 카드 ›" 가 아닌 /storage/…), WebDAV 는 서버 주소 (아이디 · 비밀번호는 빼고) +String copyablePath(ExplorerController ctl, String path) { + if (!isDav(path)) return path; + final d = DavPath.parse(path); + // 88: SFTP 는 sftp://아이디@서버:포트/경로 (시작 폴더를 비웠으면 홈 기준 ~/) + if (d.isSftp) { + final s = SftpRegistry.server(d.server); + if (s == null) return ctl.displayPath(path); + final base = s.root.isEmpty ? '/~' : (s.root.startsWith('/') ? s.root : '/${s.root}'); + return 'sftp://${s.user.isEmpty ? '' : '${s.user}@'}${s.host}${s.port == 22 ? '' : ':${s.port}'}$base${d.isRoot ? '' : d.rel}'; + } + // l: FTP 는 ftp://아이디@서버:포트/경로 (암호화 990 은 ftps://, 비밀번호 없음) + if (d.isFtp) { + final s = FtpRegistry.server(d.server); + if (s == null) return ctl.displayPath(path); + final implicit = s.security == 'implicit'; + final r = s.root.trim().replaceAll(RegExp(r'^/+|/+$'), ''); + return '${implicit ? 'ftps' : 'ftp'}://${s.user.isEmpty ? '' : '${Uri.encodeComponent(s.user)}@'}${s.host}' + '${s.port == (implicit ? 990 : 21) ? '' : ':${s.port}'}${r.isEmpty ? '' : '/$r'}${d.isRoot ? '' : d.rel}'; + } + // 88: SMB 는 smb://서버/공유/경로 (아이디 · 비밀번호 없음) + if (d.isSmb) { + final s = SmbRegistry.server(d.server); + if (s == null) return ctl.displayPath(path); + final r = s.root.trim().replaceAll(r'\', '/').replaceAll(RegExp(r'^/+|/+$'), ''); + return 'smb://${s.host}${s.port == 445 ? '' : ':${s.port}'}/${s.share}${r.isEmpty ? '' : '/$r'}${d.isRoot ? '' : d.rel}'; + } + try { + return DavRegistry.client(d.server).uriOf(d.rel).replace(userInfo: '').toString(); + } catch (_) { + return ctl.displayPath(path); + } +} + +/// 정보 창 - 경로 · 크기 · 만든 · 바뀐 날 · 속성 · 동영상 정보 (보기만) +Future showFileInfo(BuildContext context, ExplorerController ctl, FileEntry e) async { + final size = e.isDir ? await FileOps.totalSize([e.path]) : e.size; + final meta = !e.isDir && !isDav(e.path) && isVideoFile(e.path) ? await VideoMeta.of(ctl.c, e.path) : null; + // 관리자 지시 10/18: 만든 때 · 숨김 · 읽기 전용 · 시스템 (보기만, 이 기기의 것만) + final attrs = isDav(e.path) ? null : readFileAttributes(e.path); + if (!context.mounted) return; + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + scrollable: true, + title: Text(e.name), + content: SelectableText([ + e.path, + '${tr('크기')}: ${formatSize(size)}', + if (attrs?.created != null) '${tr('만든 날')}: ${fileDateText(attrs!.created!)}', + if (e.modified.year > 1) '${tr('바뀐 날')}: ${fileDateText(e.modified)}', + if (attrs != null) '${tr('속성')}: ${attrs.labels.isEmpty ? tr('없음') : attrs.labels.join(' · ')}', + if (meta != null && meta.isNotEmpty) '${tr('동영상')}: $meta', + ].join('\n')), + actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('닫기')))], + ), + ); +} + +/// X-plore 숨기기: 목록에서 숨기기 전에 무엇인지 알리고 묻기 ([다음부터 묻지 않기] 는 설정에) +Future confirmAppHide(BuildContext context, ExplorerController ctl, List targets) async { + var dontAsk = false; + final ok = await showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, set) => AlertDialog( + scrollable: true, + title: Text(targets.length > 1 ? trf('{0}개 목록에서 숨기기', [targets.length]) : tr('목록에서 숨기기')), + content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (targets.length == 1) Text(vBasename(targets.single), style: const TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 6), + Text(tr('이 앱의 파일 탐색기 목록에서만 빠집니다. 파일은 지우거나 옮기거나 이름을 바꾸지 않고, 다른 앱 · 갤러리에서는 그대로 보입니다.')), + const SizedBox(height: 6), + Text(tr('목록 맨 아래의 [숨긴 항목 보이기] 나 환경 설정 > 파일 탐색기에서 다시 볼 수 있습니다.'), + style: TextStyle(fontSize: 12, color: JjColors.textDim)), + CheckboxListTile( + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + value: dontAsk, + onChanged: (v) => set(() => dontAsk = v ?? false), + title: Text(tr('다음부터 묻지 않기')), + ), + ]), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('숨기기'))), + ], + ), + ), + ); + if (ok != true) return false; + if (dontAsk) await ctl.c.updateSettings((s) => s.explorerHideConfirm = false); + return true; +} + +/// X-plore 맞추기 (관리자 지시 10/20): 폴더에 빈 .nomedia 파일을 넣거나 지운다 (Android 갤러리 · 음악 앱에서 숨기기 · 다시 보이기). +/// 그 폴더의 파일은 그대로 - 무엇이 바뀌는지 먼저 묻는다. 갤러리에 반영되는 때는 그 앱이 다시 읽을 때다 +Future setNomedia(BuildContext context, ExplorerController ctl, ExplorerPane pane, String dir, {required bool on}) async { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + scrollable: true, + title: Text(on ? tr('갤러리 · 음악 앱에서 숨기기 (.nomedia)') : tr('갤러리 · 음악 앱에 다시 보이기 (.nomedia 지우기)')), + content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(vBasename(dir), style: const TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 6), + Text(on + ? tr('이 폴더에 빈 .nomedia 파일을 만듭니다. 갤러리 · 음악 앱이 이 폴더 (안의 폴더 포함) 의 그림 · 동영상 · 음악을 보이지 않게 됩니다.') + : tr('이 폴더의 .nomedia 파일을 지웁니다. 갤러리 · 음악 앱이 이 폴더의 그림 · 동영상 · 음악을 다시 보이게 됩니다.')), + const SizedBox(height: 6), + Text(tr('폴더 안의 파일은 바꾸지 않습니다. 갤러리에 반영되기까지 잠깐 걸릴 수 있습니다.'), + style: TextStyle(fontSize: 12, color: JjColors.textDim)), + ]), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(on ? tr('숨기기') : tr('다시 보이기'))), + ], + ), + ); + if (ok != true || !context.mounted) return; + final f = File(p.join(dir, '.nomedia')); + try { + if (on) { + if (!await f.exists()) await f.writeAsBytes(const []); + } else if (await f.exists()) { + await f.delete(); + } + } catch (err) { + ctl.host.snack(trf('바꾸지 못했습니다: {0}', [err])); + return; + } + // 관리자 지시 10/20: 갤러리가 바로 알도록 .nomedia 와 그 폴더를 다시 읽게 (넣을 때 · 지울 때 모두, 실패해도 조용히) + await ctl.c.services.shell.mediaScan([f.path, dir]); + await ctl.refreshAll([dir]); + pane.changed(); + ctl.host.snack(on ? tr('갤러리 · 음악 앱에서 숨겼습니다 (.nomedia). 파일은 그대로입니다.') : tr('.nomedia 를 지웠습니다. 갤러리 · 음악 앱에 다시 보입니다.')); +} + +/// 77: 길게 누르기 메뉴 위의 아이콘 줄 - (id, 아이콘, 짧은 이름, 풍선 도움말, 누를 수 있음). +/// 폰에서도 무엇인지 알 수 있게 아이콘 아래에 짧은 이름 +class MenuIconRow extends PopupMenuEntry { + final List<(String, IconData, String, String, bool)> items; + const MenuIconRow(this.items, {super.key}); + + @override + double get height => 60; + + @override + bool represents(String? value) => false; + + @override + State createState() => MenuIconRowState(); +} + +class MenuIconRowState extends State { + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + for (final (id, icon, label, tip, on) in widget.items) + Tooltip( + message: tip, + child: InkWell( + borderRadius: BorderRadius.circular(6), + onTap: on ? () => Navigator.pop(context, id) : null, + child: Semantics( + button: true, + enabled: on, + label: tip, + excludeSemantics: true, + child: SizedBox( + width: 56, + height: 56, + child: Opacity( + opacity: on ? 1 : 0.38, + child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + Icon(icon, size: 20), + const SizedBox(height: 2), + FittedBox( + fit: BoxFit.scaleDown, + child: Text(label, maxLines: 1, style: const TextStyle(fontSize: 11)), + ), + ]), + ), + ), + ), + ), + ), + ]), + ); +} diff --git a/lib/ui/explorer/item_menu.dart b/lib/ui/explorer/item_menu.dart new file mode 100644 index 0000000..8f1e778 --- /dev/null +++ b/lib/ui/explorer/item_menu.dart @@ -0,0 +1,328 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show Clipboard, ClipboardData; +import 'package:path/path.dart' as p; + +import '../../app/settings.dart' show FolderBookmark; +import '../../core/app_hidden.dart'; +import '../../core/archive_ops.dart' show archiveKindOf; +import '../../core/file_ops.dart'; +import '../../core/playlist.dart' show isAudioFile, isVideoFile; +import '../../core/reader_sources.dart' show extOf, zipExtensions; +import '../../core/vfs.dart'; +import '../../l10n/tr.dart'; +import '../archive_ui.dart' show createZipFlow; +import '../file_tools.dart' show showFileHash, showFolderProperties; +import '../notice.dart'; +import '../sqlite_viewer.dart' show openSqliteViewer, sqliteExtensions; +import '../theme.dart'; +import '../vault_page.dart' show openVault; +import 'delete_flow.dart'; +import 'explorer_buttons.dart'; +import 'explorer_controller.dart'; +import 'explorer_pane.dart'; +import 'explorer_select.dart'; +import 'item_actions.dart'; +import 'open_flow.dart'; +import 'transfer_flow.dart'; +import 'write_guard.dart'; + +/// 약속 (E5d-5): 줄을 길게 누르거나 오른쪽 클릭하면 뜨는 메뉴. 누른 것이 표시한 것 중 하나면 표시한 것 모두에 한다 (77). +/// 위 아이콘 줄 (복사 · 이동 · 이름 변경 · 삭제 · 공유) + 상황별 항목 (Rsync 화면은 폴더에 맞는 것만, 압축 안은 새 파일을 만드는 것은 숨김). +/// 고른 것은 흐름 모듈 (열기 · 복사 · 이동 · 지우기) 이나 [ExplorerCommands] 로 한다. 고르지 않으면 아무것도 안 함 +class ExplorerItemMenu { + ExplorerItemMenu(this.ctl, this.sel, this.cmd, + {required this.guard, required this.transfers, required this.deletes, required this.opener, required this.nomedia}); + + final ExplorerController ctl; + final ExplorerSelect sel; + final ExplorerCommands cmd; + final ExplorerGuard guard; + final ExplorerTransfers transfers; + final ExplorerDeleteFlow deletes; + final ExplorerOpener opener; + + /// .nomedia 항목을 보일 기기인지 (Android) + final bool Function() nomedia; + + Future show(BuildContext context, ExplorerPane pane, FileEntry e, Offset at) async { + ctl + ..active = ctl.panes.indexOf(pane) + ..update(); + pane.focused = e.path; + pane.changed(); + final video = !e.isDir && isVideoFile(e.path); + final audio = !e.isDir && isAudioFile(e.path); + final dav = isDav(e.path); + // 그림 → PDF: 표시한 것 중 그림 (누른 것이 표시에 없으면 누른 것만) + final viewer = ctl.c.settings.components.contains('viewer'); + final picked = pane.sel.has(e.path) ? pane.sel.marked : [e.path]; + final images = [for (final x in picked) if (ctl.c.settings.imageExts.contains(extOf(x))) x]; + final ext = extOf(e.path); + final viewable = viewer && !e.isDir && + (ctl.c.settings.imageExts.contains(ext) || ext == 'pdf' || zipExtensions.contains(ext)); + final dual = cmd.dual; + // 77: 누른 것이 표시한 것 중 하나면 표시한 것 모두에 (복사 · 이동 · 삭제 · 공유 · 경로 복사) + final targets = sel.isMarked(pane, e.path) && pane.sel.marked.length > 1 ? pane.sel.marked : [e.path]; + final many = targets.length > 1; + // 데이터 검토 (10/18): 펼친 압축 안의 것은 실제 파일이 아니다 - 그것으로 새 파일을 만들거나 내놓는 항목 (ZIP 묶기 · Wi-Fi 공유 · PDF) 은 숨긴다 + final inArchive = targets.any(guard.insideArchive) || guard.insideArchive(e.path); + // 25: Rsync 화면은 폴더만 보이므로 폴더에 맞는 것만 (고르기 · 열기 · 이름 변경 · 삭제 · 정보) + final items = ctl.rsync + ? <(String, IconData, String)>[ + ('pickRsync', Icons.check_circle_outline, + sel.isMarked(pane, e.path) ? tr('고르기 취소') : tr('rsync 에 고르기')), + ('open', Icons.folder_open, tr('열기')), + ('openOther', Icons.vertical_split_outlined, tr('다른 창에서 열기')), + ('rename', Icons.drive_file_rename_outline, tr('이름 변경')), + ('delete', Icons.delete_outline, tr('삭제')), + if (!dav) ('reveal', Icons.folder_outlined, tr('파일 관리자에서 보기')), + ('info', Icons.info_outline, tr('정보')), + ] + : <(String, IconData, String)>[ + // 77: 복사 · 이동 · 이름 변경 · 삭제 · 공유는 위의 아이콘 줄 ([_menuIcons]). 아래는 상황별 항목 + // 74: 폰에서 범위 고르기 - 첫 항목을 표시한 뒤 마지막 항목을 길게 눌러 + if (sel.selecting && sel.canRange(pane, e)) ('range', Icons.unfold_more, tr('여기까지 범위 고르기')), + if (e.isDir) ('open', Icons.folder_open, tr('열기')), + // 81: 빠른 보기 (PC 는 Space · TC 는 F3) + if (!e.isDir) ('quickLook', Icons.preview_outlined, tr('미리 보기')), + // 86: 압축 풀기 (ZIP · 7z · RAR) · ZIP 으로 묶기 (표시한 것 모두) + if (!e.isDir && archiveKindOf(e.path) != null) ('extract', Icons.unarchive_outlined, tr('압축 풀기')), + if (!dav && !inArchive) ('zip', Icons.archive_outlined, many ? trf('{0}개 ZIP 으로 묶기', [targets.length]) : tr('ZIP 으로 묶기')), + // 87: 저장 공간 분석 (이 폴더 아래의 큰 폴더 · 큰 파일) + if (e.isDir) ('storage', Icons.pie_chart_outline, tr('저장 공간 분석')), + // 89: 이 폴더를 Wi-Fi 로 공유 (이 기기의 폴더만) + if (e.isDir && !dav && !ctl.rsync && !inArchive) ('wifiShare', Icons.wifi_tethering, tr('Wi-Fi 로 공유')), + // 80: 이 폴더 크기를 지금 계산 (설정이 "안 함" 이어도) + if (e.isDir) ('folderSize', Icons.data_usage, ctl.sizes.busy(e.path) ? tr('폴더 크기 계산 멈추기') : tr('폴더 크기 계산')), + // X-plore P3: 폴더 속성 (안의 파일 · 폴더 수 · 크기 · 만든 · 바뀐 때) · 파일 해시 (MD5 · SHA-256) - 이 기기의 것만 (원격은 다 받아야 해서 뺌) + if (e.isDir && !dav) ('props', Icons.fact_check_outlined, tr('폴더 속성')), + if (!e.isDir && !dav) ('hash', Icons.tag, tr('해시 (MD5 · SHA-256)')), + if (e.isDir && dual) ('openOther', Icons.vertical_split_outlined, tr('다른 창에서 열기')), + if (e.isDir) ('playFolder', Icons.play_circle_outline, tr('이 폴더의 동영상 재생')), + // X-plore 맞추기 (관리자 지시 10/20): 이 폴더의 그림을 [계속 보기] 로 + if (e.isDir && viewer) ('slideshow', Icons.slideshow_outlined, tr('사진 슬라이드쇼')), + // X-plore 비교 g: 그림 · 동영상을 썸네일 격자로 + if (e.isDir && !dav) ('grid', Icons.grid_view, tr('격자로 보기 (그림 · 동영상)')), + if (video || audio) ('playInternal', Icons.play_circle_outline, tr('내장 플레이어로 재생')), + if (viewable) ('view', Icons.visibility_outlined, tr('보기')), + // X-plore 비교 h: 어떤 파일이든 글로 열어 보기 (읽기 전용) + if (!e.isDir && !video && !audio) ('text', Icons.article_outlined, tr('글 뷰어로 보기')), + // X-plore 비교 5-1 ④ (관리자 지시 10/21): SQLite (읽기 전용) + if (!e.isDir && !inArchive && sqliteExtensions.contains(ext)) ('sqlite', Icons.table_chart_outlined, tr('SQLite 보기 (읽기 전용)')), + // X-plore 비교 5-2 (사용자 결정 10/21): 암호 보관함에 넣기. 2단계 (관리자 지시 10/21): 폴더째 (안의 구조 그대로) · 원격 파일도 (받아서 넣음) + if (ctl.c.settings.vaultEnabled && !inArchive) + ('vaultAdd', Icons.enhanced_encryption_outlined, many ? trf('{0}개 보관함에 넣기', [targets.length]) : tr('보관함에 넣기')), + if (!e.isDir) ('openWith', Icons.open_in_new, tr('다른 앱으로 열기')), + if (!e.isDir && !video) ('openDefault', Icons.launch, tr('기본 앱으로 열기')), + if (video && !dav) ('addMkv', Icons.playlist_add, tr('MKV 목록에 추가')), + if (viewer && images.isNotEmpty && !inArchive) + ('toPdf', Icons.picture_as_pdf_outlined, + images.length == 1 ? tr('PDF 로 만들기') : trf('그림 {0}장을 PDF 로 만들기', [images.length])), + ('select', Icons.check_circle_outline, tr('선택')), + // X-plore 숨기기: 목록에서만 숨긴다 (파일은 그대로) · 숨긴 것을 보이고 있으면 풀기 + targets.every(ctl.appHidden) + ? ('appUnhide', Icons.visibility_outlined, many ? trf('{0}개 숨기기 풀기', [targets.length]) : tr('숨기기 풀기')) + : ('appHide', Icons.visibility_off_outlined, many ? trf('{0}개 목록에서 숨기기', [targets.length]) : tr('목록에서 숨기기')), + // X-plore 맞추기 (관리자 지시 10/20): 폴더에 .nomedia 를 두면 갤러리 · 음악 앱이 그 안의 그림 · 동영상 · 음악을 보이지 않는다 (Android) + if (e.isDir && !dav && !inArchive && nomedia()) + File(p.join(e.path, '.nomedia')).existsSync() + ? ('nomediaOff', Icons.photo_library_outlined, tr('갤러리 · 음악 앱에 다시 보이기 (.nomedia 지우기)')) + : ('nomediaOn', Icons.hide_image_outlined, tr('갤러리 · 음악 앱에서 숨기기 (.nomedia)')), + // 두 창이면 아이콘 줄의 복사 · 이동은 다른 창으로 - 담아 두었다가 원하는 폴더에 붙여넣기는 여기 + if (dual) ('clipCopy', Icons.content_copy, tr('복사 (붙여넣기로)')), + if (dual) ('clipMove', Icons.content_cut, tr('이동 (붙여넣기로)')), + if (transfers.clip.isNotEmpty) + ('paste', Icons.content_paste, trf('{0} 에 붙여넣기 ({1}개)', [vBasename(e.isDir ? e.path : vDirname(e.path)), transfers.clip.items.length])), + ('copyPath', Icons.link, many ? trf('경로 복사 ({0}개)', [targets.length]) : tr('경로 복사')), + if (e.isDir && !ctl.c.settings.folderBookmarks.any((b) => !b.isPair && samePath(b.path, e.path))) + ('bookmark', Icons.star_outline, tr('즐겨찾기에 추가')), + if (!dav && (!Platform.isAndroid || e.isDir)) ('reveal', Icons.folder_outlined, tr('파일 관리자에서 보기')), + ('info', Icons.info_outline, tr('정보')), + ]; + // 77: 위의 아이콘 줄 (X-plore · One Commander 처럼 자주 쓰는 것을 한 줄에) + final icons = ctl.rsync + ? const <(String, IconData, String, String, bool)>[] + : <(String, IconData, String, String, bool)>[ + (dual ? 'copy' : 'clipCopy', Icons.copy_outlined, tr('복사'), dual ? tr('다른 창으로 복사') : tr('복사 (붙여넣기로)'), true), + (dual ? 'move' : 'clipMove', Icons.drive_file_move_outline, tr('이동'), dual ? tr('다른 창으로 이동') : tr('이동 (붙여넣기로)'), true), + // 78: 여럿이면 일괄 이름 바꾸기 + ('rename', Icons.drive_file_rename_outline, tr('이름 변경'), many ? trf('{0}개 이름 일괄 바꾸기', [targets.length]) : tr('이름 변경'), true), + ('delete', Icons.delete_outline, tr('삭제'), many ? trf('{0}개 삭제', [targets.length]) : tr('삭제'), true), + if (ctl.c.services.shell.canShare) ('share', Icons.share_outlined, tr('공유'), tr('공유 (다른 앱으로 보내기)'), targets.any((x) => !vIsDirSync(x))), + ]; + final size = MediaQuery.sizeOf(context); + final pick = await showMenu( + context: context, + position: RelativeRect.fromLTRB(at.dx, at.dy, size.width - at.dx, size.height - at.dy), + items: [ + // 77: 여러 개를 골랐으면 무엇에 하는지 먼저 + if (many) + PopupMenuItem( + enabled: false, + height: 32, + child: Text(trf('{0}개 항목', [targets.length]), + style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: JjColors.accent)), + ), + if (icons.isNotEmpty) ...[MenuIconRow(icons), const PopupMenuDivider(height: 1)], + for (final (id, icon, label) in items) + PopupMenuItem( + value: id, + child: Row(children: [ + Icon(icon, size: 18), + const SizedBox(width: 12), + Flexible(child: Text(label, overflow: TextOverflow.ellipsis)), + ]), + ), + ], + ); + if (pick == null || !context.mounted) return; + switch (pick) { + case 'pickRsync': + await sel.pick(pane, e); + case 'open': + await ctl.goTo(pane, e.path); + case 'openOther': + final other = ctl.panes[1 - ctl.panes.indexOf(pane)]; + await ctl.goTo(other, e.path); + ctl + ..active = ctl.panes.indexOf(other) + ..update(); + case 'playFolder': + await opener.playFolder(context, e.path); + case 'slideshow': + await opener.slideshow(context, pane, e.path); + case 'playInternal': + await opener.play(context, [e.path], internal: true); + case 'grid': + await opener.grid(context, pane, e.path); + case 'text': + await opener.viewText(context, e.path); + case 'sqlite': + await openSqliteViewer(context, ctl.c, e.path); + case 'vaultAdd': + await openVault(context, ctl.c, add: targets, deleteOriginals: (paths, {required permanent}) { + // 관리자 지시 (10/21): [영구 삭제] 를 고르면 휴지통을 거치지 않음 (Shift+Delete 와 같은 길 - 탐색기의 영구 삭제 확인 창을 한 번 더) + deletes.shiftDelete = permanent; + return deletes.run(context, pane, paths); + }); + await ctl.refreshAll([vDirname(e.path)]); + case 'openWith': + final local = await opener.fetch(context, e.path); + if (local != null && !await ctl.c.services.shell.openWith(local, choose: true)) ctl.host.snack(ExplorerOpener.noAppMessage(e.path)); + case 'openDefault': + final local = await opener.fetch(context, e.path); + if (local != null && !await ctl.c.services.shell.openWith(local)) ctl.host.snack(ExplorerOpener.noAppMessage(e.path)); + case 'addMkv': + await cmd.addMkv([e.path]); + case 'range': + sel.markRange(pane, e); + case 'select': + sel.setSelecting(true); + if (!sel.isMarked(pane, e.path)) await sel.toggleMark(pane, e); + case 'quickLook': + await cmd.quickLook(pane, e.path); + case 'extract': + await opener.extractArchive(context, e.path, pane); + case 'storage': + await cmd.openStorage(pane, e.path); + case 'wifiShare': + await cmd.openWifiShare(e.path); + case 'zip': + final items = outermostPaths(sel.isMarked(pane, e.path) && pane.sel.marked.length > 1 ? pane.sel.marked : [e.path]); + final other = cmd.dual ? ctl.panes[1 - ctl.panes.indexOf(pane)].current : null; + final made = await createZipFlow(context, ctl.c, items, dir: vDirname(items.first), otherDir: other); + if (made != null && context.mounted) { + await ctl.refreshAll([vDirname(made)]); + pane.focused = made; + pane.changed(); + } + case 'appHide': + if (ctl.c.settings.explorerHideConfirm && !await confirmAppHide(context, ctl, targets)) return; + await ctl.c.updateSettings((s) => s.explorerHiddenItems = [ + ...s.explorerHiddenItems, + for (final x in targets) + if (!ctl.appHidden(x)) x, + ]); + for (final q in ctl.panes) { + targets.forEach(q.sel.forget); + q.changed(); + } + ctl.update(); + ctl.host.snack(ctl.showAppHidden + ? trf('목록에서 숨겼습니다 ({0}개). 파일은 그대로입니다.', [targets.length]) + : trf('목록에서 숨겼습니다 ({0}개). 파일은 그대로이고, 목록 맨 아래의 [숨긴 항목 보이기] 로 다시 볼 수 있습니다.', [targets.length])); + case 'appUnhide': + final keys = {for (final x in targets) HiddenList.keyOf(x)}; + await ctl.c.updateSettings((s) => s.explorerHiddenItems = [ + for (final x in s.explorerHiddenItems) + if (!keys.contains(HiddenList.keyOf(x))) x, + ]); + for (final q in ctl.panes) { + q.changed(); + } + ctl.update(); + case 'nomediaOn' || 'nomediaOff': + await setNomedia(context, ctl, pane, e.path, on: pick == 'nomediaOn'); + case 'props': + await showFolderProperties(context, e.path); + case 'hash': + await showFileHash(context, e.path); + case 'folderSize': + if (ctl.sizes.busy(e.path)) { + ctl.sizes.cancel(e.path); + break; + } + // 사용자가 직접 시킨 것: 다른 폴더로 가도 끝까지 (멈추기는 아래 알림 · 같은 메뉴) + ctl.sizes + ..forget(e.path) + ..request(e.path); + if (context.mounted) { + showNotice(ScaffoldMessenger.of(context), + text: isDav(e.path) + ? trf('"{0}" 크기를 계산합니다. 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다.', [e.name]) + : trf('"{0}" 크기를 계산합니다.', [e.name]), + action: (tr('취소'), () => ctl.sizes.cancel(e.path))); + } + case 'rename': + if (sel.isMarked(pane, e.path) && pane.sel.marked.length > 1) { + await cmd.batchRename(pane, pane.sel.marked); + } else { + await cmd.renameItem(pane, e.path); + } + case 'copy': + await transfers.transfer(context, pane, sel.isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: false, otherDir: cmd.otherDir); + case 'move': + await transfers.transfer(context, pane, sel.isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: true, otherDir: cmd.otherDir); + case 'clipCopy': + transfers.toClipboard(sel.isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: false); + case 'clipMove': + transfers.toClipboard(sel.isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: true); + case 'paste': + await transfers.paste(context, pane, e.isDir ? e.path : vDirname(e.path)); + case 'delete': + // 77: 메뉴 머리의 "n개 항목" 과 같게 (예전에는 표시한 것이 여럿이어도 누른 것 하나만 지웠다) + await deletes.run(context, pane, targets); + case 'share': + await opener.share(context, targets); + case 'copyPath': + final paths = targets.map((x) => copyablePath(ctl, x)).toList(); + await Clipboard.setData(ClipboardData(text: paths.join('\n'))); + ctl.host.snack(many ? trf('경로 {0}개를 복사했습니다', [paths.length]) : trf('경로를 복사했습니다: {0}', [paths.single])); + case 'bookmark': + final name = vBasename(e.path).isEmpty ? e.path : vBasename(e.path); + await ctl.c.updateSettings((s) => s.folderBookmarks = [...s.folderBookmarks, FolderBookmark(name: name, path: e.path)]); + ctl.host.snack(trf('즐겨찾기에 넣었습니다: {0}', [name])); + ctl.update(); + case 'view': + await opener.open(context, e.path, pane: pane); + case 'toPdf': + await opener.makePdf(context, images); + case 'reveal': + await ctl.c.services.shell.revealFile(e.path); + case 'info': + await showFileInfo(context, ctl, e); + } + } +} diff --git a/lib/ui/explorer/name_dialog.dart b/lib/ui/explorer/name_dialog.dart new file mode 100644 index 0000000..149d672 --- /dev/null +++ b/lib/ui/explorer/name_dialog.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; + +import '../../l10n/tr.dart'; + +/// 약속 (E5d-5): 이름 하나를 묻는 창 (새 폴더 · 이름 변경 · PDF 이름 등). 처음 값은 [initial] (확장자 앞까지 고른 채 - +/// [selectAll] 이면 모두). 빈 칸 · [취소] 는 null, 아니면 앞뒤 빈칸을 뺀 이름 +Future askName(BuildContext context, String title, String initial, {String? hint, bool selectAll = false}) { + final ctl = TextEditingController(text: initial); + final dot = initial.lastIndexOf('.'); + ctl.selection = TextSelection(baseOffset: 0, extentOffset: dot > 0 && !selectAll ? dot : initial.length); + return showDialog( + context: context, + builder: (ctx) => AlertDialog( + scrollable: true, + title: Text(title), + content: TextField( + controller: ctl, + autofocus: true, + decoration: InputDecoration(hintText: hint ?? tr('이름')), + onSubmitted: (v) => Navigator.pop(ctx, v.trim().isEmpty ? null : v.trim()), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('취소'))), + FilledButton( + onPressed: () => Navigator.pop(ctx, ctl.text.trim().isEmpty ? null : ctl.text.trim()), + child: Text(tr('확인'))), + ], + ), + ); +} diff --git a/lib/ui/explorer/open_flow.dart b/lib/ui/explorer/open_flow.dart new file mode 100644 index 0000000..d96a86b --- /dev/null +++ b/lib/ui/explorer/open_flow.dart @@ -0,0 +1,320 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:path/path.dart' as p; + +import '../../app/component_store.dart'; +import '../../app/file_opener.dart'; +import '../../core/file_ops.dart'; +import '../../core/hwp_source.dart'; +import '../../core/playlist.dart' show isAudioFile, isVideoFile; +import '../../core/reader_sources.dart'; +import '../../core/vfs.dart'; +import '../file_error.dart' show explainFileError; +import '../player_page.dart' show playFiles; +import '../quick_look.dart' show textExtensions; +import '../../l10n/tr.dart'; +import '../../platform/android/hwp_pdf_ffi.dart' show RhwpEngine; +import '../archive_ui.dart'; +import '../dav_external.dart'; +import '../explorer_grid.dart'; +import '../progress_dialog.dart'; +import '../reader_page.dart'; +import '../text_viewer.dart'; +import '../zip_page.dart'; +import 'explorer_controller.dart'; +import 'explorer_pane.dart'; +import 'explorer_select.dart'; +import 'file_thumbs.dart'; +import 'name_dialog.dart'; +import 'write_guard.dart'; + +/// 약속 (E5d-5): 파일을 여는 일. 여는 길은 file_opener ([openRouteFor]) 가 고르고, 여기는 그 길대로 실제로 연다 - +/// 내장 플레이어 · 그림 보기 (같은 폴더를 지금 순서로) · PDF · HWP (Android, 못 열면 다른 앱) · 문서 미리 보기 (PDF 로 바꿔) · +/// ZIP (만화 보기 · 목록) · 압축 풀기 · 다른 앱. 압축 안의 파일은 꺼내서 ([ExplorerGuard.extractOne]), WebDAV 등 원격은 받아서 ([fetch]). +/// 열 앱이 없으면 글 파일은 글 뷰어로, 아니면 까닭을 알린다 (Host). [otherDirOf]: 압축을 풀 다른 창의 폴더 (없으면 null) +class ExplorerOpener { + ExplorerOpener(this.ctl, this.sel, this.guard, {required this.otherDirOf}); + + final ExplorerController ctl; + final ExplorerSelect sel; + final ExplorerGuard guard; + final String? Function(ExplorerPane pane) otherDirOf; + + /// 다른 앱으로 열지 못했을 때: 음악 · 동영상이면 내장 플레이어를 알려 준다 (29) + static String noAppMessage(String path) => isAudioFile(path) || isVideoFile(path) + ? tr('이 형식을 여는 앱이 없습니다. 길게 누르거나 오른쪽 클릭 → [내장 플레이어로 재생] 으로 들어 보세요.') + : tr('이 파일을 열 수 있는 앱이 없습니다.'); + + /// 파일 열기 (기본 동작): 동영상은 내장 플레이어 (또는 확장자별 프로그램), 그 밖은 기본 연결 프로그램 + Future open(BuildContext context, String path, {ExplorerPane? pane}) async { + // X-plore 비교 f: 압축 안의 파일 - 그 파일만 임시 폴더로 꺼내 연다 + if (guard.insideArchive(path)) { + final local = await guard.extractOne(path); + if (local == null || !context.mounted) return; + path = local; + } + // 여는 길은 file_opener (E5c) 가 고른다 + switch (openRouteFor(path, + components: ctl.c.settings.components, + imageExts: ctl.c.settings.imageExts, + android: Platform.isAndroid, + windows: Platform.isWindows, + hwpEngine: RhwpEngine.available)) { + case OpenRoute.player: + return play(context, [path]); + case OpenRoute.extractArchive: + return extractArchive(context, path, pane ?? ctl.pane); + case OpenRoute.images: + return viewImages(context, path, pane); + case OpenRoute.pdf: + return viewPdf(context, path); + case OpenRoute.zip: + return openZipFile(context, path, pane); + // 관리자 지시 (10/20): Android HWP · HWPX 는 앱 안에서 PDF 로 바꿔 본다. 못 열면 이유를 알리고 다른 앱으로 + case OpenRoute.hwp: + if (await viewHwp(context, path)) return; + case OpenRoute.docPreview: + return viewDoc(context, path); + case OpenRoute.external: + } + if (!context.mounted) return; + final local = await fetch(context, path); + if (local == null) return; + final ok = await ctl.c.services.shell.openWith(local); + if (ok || !context.mounted) return; + // X-plore 비교 h: 글 파일을 열 앱이 없으면 앱 안의 글 뷰어로 + if (textExtensions.contains(extOf(path))) return viewText(context, local); + ctl.host.snack(noAppMessage(path)); + } + + /// X-plore 비교 h: 글 뷰어 (WebDAV 등은 받은 뒤) + Future viewText(BuildContext context, String path) async { + final local = await fetch(context, path); + if (local == null || !context.mounted) return; + await Navigator.of(context).push(MaterialPageRoute(builder: (_) => TextViewerPage(path: local))); + } + + /// X-plore 비교 g: 폴더의 그림 · 동영상을 격자로. 길게 누른 것은 탐색기에서 그 자리로 + Future grid(BuildContext context, ExplorerPane pane, String dir) async { + await ctl.load(pane, dir); + if (!context.mounted) return; + final list = sel.shown(pane, dir); + final at = await Navigator.of(context).push(MaterialPageRoute( + builder: (_) => ExplorerGridPage( + title: vBasename(dir).isEmpty ? dir : vBasename(dir), + entries: list, + imageExts: ctl.c.settings.imageExts, + videoThumb: (e) => BigVideoThumbs.of(ctl.c, e), + onOpen: (e) => open(context, e.path, pane: pane), + ), + )); + if (at == null || !context.mounted) return; + await ctl.goTo(pane, dir); + pane.focused = at; + pane.changed(); + } + + /// WebDAV 파일은 열거나 재생하려면 임시 폴더로 받는다 (dav_external.dart) + Future fetch(BuildContext context, String path) => fetchDav(context, ctl.c, path); + + /// 동영상 재생 (WebDAV 는 받지 않고 바로 스트리밍) + Future play(BuildContext context, List paths, {bool internal = false, bool keepOrder = false}) async { + if (!context.mounted || paths.isEmpty) return; + await playFiles(context, ctl.c, paths, internal: internal, keepOrder: keepOrder); + } + + /// 그림 보기: 같은 폴더의 그림을 지금 정렬 순서대로 넘겨 본다 + Future viewImages(BuildContext context, String path, ExplorerPane? pane) async { + final dir = vDirname(path); + // 폴더를 읽지 못하면 (E10: 이제 오류로 알림) 고른 그림 하나만 + List list; + try { + list = pane?.cache[dir] ?? ctl.sorted(await listEntries(dir, showHidden: ctl.c.settings.explorerShowHidden), dir); + } catch (_) { + list = const []; + } + final images = [ + for (final e in list) + if (!e.isDir && ctl.c.settings.imageExts.contains(extOf(e.path))) e.path, + ]; + if (!images.any((x) => samePath(x, path))) images.insert(0, path); + final start = images.indexWhere((x) => samePath(x, path)); + final temp = await ctl.c.services.storage.tempDirectory(); + if (!context.mounted) return; + await openReader(context, ctl.c, ImageFilesSource(images, tempDir: temp, title: vBasename(dir)), start: start); + } + + /// X-plore 맞추기 (관리자 지시 10/20): 폴더의 그림 (지금 정렬 순서) 을 보기 화면으로 열고 [계속 보기] 를 켠 채 시작 + Future slideshow(BuildContext context, ExplorerPane pane, String dir) async { + List list; + try { + list = pane.cache[dir] ?? ctl.sorted(await listEntries(dir, showHidden: ctl.c.settings.explorerShowHidden), dir); + } catch (e) { + ctl.host.snack(trf('폴더를 읽을 수 없습니다: {0}', [e])); + return; + } + final images = [ + for (final e in list) + if (!e.isDir && ctl.c.settings.imageExts.contains(extOf(e.path)) && (ctl.showAppHidden || !ctl.appHidden(e.path))) e.path, + ]; + if (images.isEmpty) { + ctl.host.snack(tr('이 폴더에는 그림이 없습니다')); + return; + } + final temp = await ctl.c.services.storage.tempDirectory(); + if (!context.mounted) return; + await openReader(context, ctl.c, ImageFilesSource(images, tempDir: temp, title: vBasename(dir)), autoPlay: true); + } + + /// 문서 → PDF (진행 창) → 보기 + Future viewDoc(BuildContext context, String path) async { + final temp = await ctl.c.services.storage.tempDirectory(); + if (!context.mounted) return; + final dialog = showProgressDialog(context, trf('PDF 로 바꾸는 중: {0}', [vBasename(path)])); + String? pdf; + Object? error; + try { + final local = await vLocalCopy(path, temp); + pdf = await ComponentStore.shared.convertToPdf(local, p.join(temp, 'jj_docs')); + } catch (e) { + error = e; + } + // 사용자가 [취소] · ← 로 그만두었으면 결과를 보이지 않는다 (바꾸기는 끝까지 돌았음) + if (dialog.cancelled()) return; + dialog.close(); + await dialog.done; + if (pdf == null) { + ctl.host.snack(trf('미리 볼 수 없습니다: {0}', [error])); + return; + } + PdfSource src; + try { + src = await PdfSource.open(pdf, tempDir: temp); + } catch (e) { + ctl.host.snack(trf('PDF 를 열 수 없습니다: {0}', [e])); + return; + } + if (!context.mounted) return; + await openReader(context, ctl.c, src); + } + + /// 관리자 지시 (10/20): HWP · HWPX → 보기 화면 (보는 쪽부터 몇 쪽씩 PDF 로 바꿈 - [HwpSource]). 열지 못하면 false + Future viewHwp(BuildContext context, String path) async { + final temp = await ctl.c.services.storage.tempDirectory(); + if (!context.mounted) return true; + final dialog = showProgressDialog(context, trf('여는 중: {0}', [vBasename(path)])); + HwpSource? src; + Object? error; + try { + src = await HwpSource.open(path, engine: RhwpEngine(), tempDir: temp, fontDirs: RhwpEngine.fontDirs, log: ctl.c.note); + } catch (e) { + error = e; + } + // 사용자가 [취소] · ← 로 그만두었으면 연 문서를 닫고 끝 (다른 앱으로도 열지 않음) + if (dialog.cancelled()) { + await src?.dispose(); + return true; + } + dialog.close(); + await dialog.done; + if (src == null) { + ctl.host.snack(trf('앱 안에서 열지 못해 다른 앱으로 엽니다: {0}', [error])); + return false; + } + if (!context.mounted) { + await src.dispose(); + return true; + } + await openReader(context, ctl.c, src); + return true; + } + + Future viewPdf(BuildContext context, String path) async { + final temp = await ctl.c.services.storage.tempDirectory(); + PdfSource src; + try { + src = await PdfSource.open(path, tempDir: temp); + } catch (e) { + ctl.host.snack(trf('PDF 를 열 수 없습니다: {0}', [e])); + return; + } + if (!context.mounted) return; + await openReader(context, ctl.c, src); + await ctl.refreshAll([vDirname(path)]); + } + + /// 86: 압축 풀기 (검사 · 확인 · 진행 · 취소) 뒤 목록 새로 고침 + Future extractArchive(BuildContext context, String path, ExplorerPane pane) async { + final other = otherDirOf(pane); + final out = await extractArchiveFlow(context, ctl.c, path, otherDir: other); + if (out != null && context.mounted) await ctl.refreshAll([out, vDirname(out)]); + } + + Future openZipFile(BuildContext context, String path, ExplorerPane? pane) async { + try { + if (ctl.c.settings.zipComic && await openZipComic(context, ctl.c, path)) return; + } catch (e) { + ctl.host.snack(trf('열 수 없습니다: {0}', [e])); + return; + } + if (!context.mounted) return; + final other = pane == null ? null : otherDirOf(pane); + final out = await openZip(context, ctl.c, path, otherDir: other); + if (out != null) await ctl.refreshAll([out, vDirname(out)]); + } + + /// 고른 그림들을 한 장씩 PDF 로 (같은 폴더에, 이름을 묻는다) + Future makePdf(BuildContext context, List images) async { + if (images.isEmpty) return; + final dir = vDirname(images.first); + final first = vBasename(images.first); + final name = await askName(context, tr('PDF 로 만들기'), '${first.contains('.') ? first.substring(0, first.lastIndexOf('.')) : first}.pdf'); + if (name == null || name.trim().isEmpty) return; + final out = vJoin(dir, name.toLowerCase().endsWith('.pdf') ? name : '$name.pdf'); + final temp = await ctl.c.services.storage.tempDirectory(); + try { + await imagesToPdf(images, out, tempDir: temp); + ctl.host.snack(trf('그림 {0}장으로 PDF 를 만들었습니다: {1}', [images.length, vBasename(out)])); + } catch (e) { + ctl.host.snack(trf('PDF 를 만들지 못했습니다: {0}', [e])); + } + await ctl.refreshAll([dir]); + } + + Future playFolder(BuildContext context, String dir) async { + final List list; + try { + list = (await listEntries(dir)).where((e) => !e.isDir && isVideoFile(e.path)).map((e) => e.path).toList(); + } catch (e) { + ctl.host.snack(trf('읽을 수 없습니다: {0}', [explainFileError('$e', dav: isDav(dir)).$1])); + return; + } + if (list.isEmpty) { + ctl.host.snack(tr('이 폴더에 동영상이 없습니다.')); + return; + } + if (!context.mounted) return; + await play(context, list); + } + + /// 77: 공유 - 파일만 (폴더는 보낼 수 없어 빼고 알림). WebDAV 파일은 받아서 + Future share(BuildContext context, List paths) async { + final files = [for (final x in paths) if (!vIsDirSync(x)) x]; + if (files.isEmpty) { + ctl.host.snack(tr('폴더는 공유할 수 없습니다. 파일을 고르세요.')); + return; + } + final local = []; + for (final f in files) { + final l = await fetch(context, f); + if (l == null || !context.mounted) return; + local.add(l); + } + if (!await ctl.c.services.shell.shareFiles(local)) { + ctl.host.snack(tr('공유 창을 열 수 없습니다.')); + } else if (files.length < paths.length) { + ctl.host.snack(trf('폴더 {0}개는 빼고 파일만 보냈습니다.', [paths.length - files.length])); + } + } +} diff --git a/lib/ui/explorer/rsync_flow.dart b/lib/ui/explorer/rsync_flow.dart new file mode 100644 index 0000000..da32461 --- /dev/null +++ b/lib/ui/explorer/rsync_flow.dart @@ -0,0 +1,101 @@ +import 'package:flutter/widgets.dart'; + +import '../../app/copy_center.dart'; +import '../../app/rsync_runner.dart'; +import '../../core/file_ops.dart' show FileOpCancelled; +import '../../core/vfs.dart' show vDirname, vMissingSync; +import '../../l10n/tr.dart'; +import 'explorer_controller.dart'; +import 'explorer_jobs.dart'; +import 'rsync_run_dialog.dart'; +import 'transfer_flow.dart'; + +/// 약속 (E5d-5): Rsync 화면의 [→ ← ⇄] - 두 창에서 고른 폴더로 rsync. 막는 조건 · 옵션 · 미리 보기 · 실행은 [RsyncRun], +/// 확인 창은 rsync_run_dialog, 진행은 [ExplorerJobs], rsync 실행 파일은 [ExplorerTransfers.ensureRsync]. +/// 끝나면 두 폴더를 다시 읽고 결과를 알린다 (Host). 확인 창이 떠 있는 동안 또 누르면 무시 +class ExplorerRsyncFlow { + ExplorerRsyncFlow(this.ctl, this.jobs, this.transfers); + + final ExplorerController ctl; + final ExplorerJobs jobs; + final ExplorerTransfers transfers; + + /// Rsync 화면: 고른 왼쪽 · 오른쪽 폴더로 rsync. [right] 왼쪽 → 오른쪽, [left] 오른쪽 → 왼쪽 (둘 다면 함께). + /// 폴더 "안의 것" 을 맞춘다 (원본/ → 대상/). 함께 할 때는 받는 쪽이 더 새 파일은 건너뛴다 (-u). + /// 실행한 것은 모니터링에 남는다 (같은 것을 다시 하면 기억한 옵션으로). + /// 확인 창이 떠 있는 동안 또 누르면 무시 (창이 겹쳐 아래 창을 잘못 누르지 않게 - 110) + bool _asking = false; + + Future run(BuildContext context, {required bool right, required bool left}) async { + if (_asking) return; + _asking = true; + try { + await _run(context, right: right, left: left); + } finally { + _asking = false; + } + } + + Future _run(BuildContext context, {required bool right, required bool left}) async { + final l = ctl.panes[0].sel.marked.firstOrNull, r = ctl.panes[1].sel.marked.firstOrNull; + // 막는 조건 · 작업 · 옵션 · 미리 보기 · 실행은 RsyncRun (E5c), 확인 창은 rsync_run_dialog + final (:run, :problem, :missing) = RsyncRun.prepare(CopyCenter.of(ctl.c), + l: l, + r: r, + right: right, + left: left, + allowInnerToOuter: ctl.c.settings.allowInnerToOuter, + jobs: this.jobs.list, + viaDav: l != null && r != null && ExplorerTransfers.viaDav([l, r]), + copySkipNewer: ctl.c.settings.copySkipNewer); + if (run == null) { + ctl.host.snack(problem!); + if (missing) { + for (final x in ctl.panes) { + x.sel.removeWhere(vMissingSync); + } + await ctl.refreshAll([vDirname(l!), vDirname(r!)]); + } + return; + } + if (!context.mounted) return; + final go = await showRsyncRunDialog(context, run); + // 110: 무엇을 골랐는지 작업 기록에 (실행 · 취소 · 닫힘) + ctl.c.note(trf('Rsync 확인 창 ({0}): {1}', [ + run.both ? '⇄' : right ? '→' : '←', + go == true ? tr('실행') : go == false ? tr('취소') : tr('닫힘 (실행 안 함)'), + ])); + if (go != true || !context.mounted) return; + if (run.nestedDelete()) { + ctl.host.snack(RsyncRun.nestedDeleteMessage); + return; + } + final exe = run.viaDav ? '' : await transfers.ensureRsync(context); + if (exe == null || !context.mounted) return; + final res = await run.execute( + rsyncExe: exe, + alive: () => context.mounted, + note: ctl.c.note, + onStarted: this.jobs.start); + // 205: 시작하지 못하면 이유를 알린다 + if (res.notStarted != null && context.mounted) ctl.host.snack(trf('시작하지 못했습니다: {0}', [res.notStarted])); + final jobs = res.jobs; + if (jobs.isEmpty || !context.mounted) return; + // 원본 폴더까지 지웠으면 고른 표시도 뺀다 + for (final x in ctl.panes) { + x.sel.removeWhere(vMissingSync); + } + await ctl.refreshAll([run.l, run.r, vDirname(run.l), vDirname(run.r)]); + for (final x in ctl.panes) { + x.changed(); + } + final error = jobs.map((j) => j.error).whereType().firstOrNull; + ctl.host.snack(error is FileOpCancelled + ? tr('취소했습니다.') + : error != null + ? trf('끝나지 못했습니다: {0}', [error]) + // 212 · 216: 이동인지 · 몇 개인지 · 원본에 남은 것 + : jobs.map((j) => j.doneSummary()).join('\n')); + await this.jobs.dropLater(jobs); + } +} diff --git a/lib/ui/explorer/transfer_flow.dart b/lib/ui/explorer/transfer_flow.dart new file mode 100644 index 0000000..ffda595 --- /dev/null +++ b/lib/ui/explorer/transfer_flow.dart @@ -0,0 +1,350 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:path/path.dart' as p; + +import '../../app/copy_center.dart'; +import '../../app/live_sync.dart' show rsyncExecutable; +import '../../app/transfer_job.dart'; +import '../../app/transfer_plan.dart'; +import '../../core/file_clipboard.dart'; +import '../../core/file_ops.dart'; +import '../../core/name_clash.dart'; +import '../../core/sync_preview.dart' show optionsRemoveSource, withoutSourceRemovingOptions; +import '../../core/sync_tools.dart'; +import '../../core/vfs.dart'; +import '../../l10n/tr.dart'; +import '../confirm_dialog.dart'; +import '../notice.dart'; +import '../rsync_setup.dart'; +import '../sync_preview_view.dart'; +import '../theme.dart'; +import 'explorer_controller.dart'; +import 'explorer_jobs.dart'; +import 'explorer_pane.dart'; +import 'write_guard.dart'; + +/// 약속 (E5d-5): 복사 · 이동 흐름. 두 창이면 다른 창의 폴더 ([otherDir]) 로, 아니면 담아 두기 ([clip] → [paste]). +/// [run]: 확인 창 (같은 이름 · 방법 · 옵션) → 작업 (CopyCenter) → 진행 패널 ([ExplorerJobs]) → 다시 읽기 → 끝 알림 ([폴더 열기]). +/// 계획 (같은 폴더 · 없는 원본 · 자기 안으로) 은 transfer_plan, 읽기 전용인 곳은 [ExplorerGuard] 가 막는다. 끝까지 했으면 true. +/// 담은 것이 바뀌면 알림 +class ExplorerTransfers extends ChangeNotifier { + ExplorerTransfers(this.ctl, this.guard, this.jobs); + + final ExplorerController ctl; + final ExplorerGuard guard; + final ExplorerJobs jobs; + + /// 다른 창의 지금 폴더로 복사 · 이동 (진행 창 · 취소) + /// 복사 · 이동 버튼: 두 창이면 다른 창의 지금 폴더로, 그 밖의 배치는 담아 두기 (붙여넣기로) + Future transfer(BuildContext context, ExplorerPane pane, List sources, {required bool move, String? otherDir}) async { + if (guard.blockShizukuMove(sources, null, move: move)) return; + // X-plore 비교 f: 압축 안의 것 - 복사는 꺼내기 (86 의 풀기 검사 그대로), 이동은 막음 + if (sources.any(guard.insideArchive)) { + if (move) { + guard.blockArchive(sources); + return; + } + await guard.extractFrom(context, sources, otherDir); + return; + } + sources = outermostPaths(sources); // 79-c: 고른 폴더 안의 것은 한 번만 + if (otherDir == null) { + toClipboard(sources, move: move); + return; + } + await run(context, pane, sources, otherDir, move: move); + } + + /// 복사 · 이동할 항목 (붙여넣기 전까지 담아 둠) - FileClipboard (E5b) + final clip = FileClipboard(); + + /// 154: 고른 것이 없을 때 (복사 · 이동이 같은 말) + static String pickFirst(bool move) => move + ? tr('옮길 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).') + : tr('복사할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).'); + + void toClipboard(List sources, {required bool move}) { + if (guard.blockShizukuMove(sources, null, move: move)) return; + if (sources.isEmpty) { + ctl.host.snack(pickFirst(move)); + return; + } + clip.put(sources, move: move); + notifyListeners(); + ctl.host.snack(trf('{0} 을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).', + [namesOf(sources), move ? tr('이동') : tr('복사')])); + } + + Future paste(BuildContext context, ExplorerPane pane, String dest) async { + if (clip.isEmpty) { + ctl.host.snack(tr('붙여넣을 항목이 없습니다. 먼저 [복사] · [이동] 으로 담으세요.')); + return; + } + final done = await run(context, pane, clip.items, dest, move: clip.move); + // 옮긴 것은 다시 붙여넣을 수 없으므로 비운다 (복사는 여러 곳에 붙여넣을 수 있게 남김) + if (context.mounted) { + clip.pasted(done: done); + notifyListeners(); + } + } + + /// 한쪽이라도 WebDAV 면 rsync 대신 앱이 직접 맞춘다 (rsync 는 WebDAV 를 모름) + static bool viaDav(Iterable paths) => paths.any(isDav); + + /// rsync 가 없으면 (Windows · 내려받기 설정) 지금 내려받을지 묻는다. 쓸 수 있는 rsync 경로 (없으면 null) + Future ensureRsync(BuildContext context) async { + final s = ctl.c.settings; + final have = await rsyncExecutable(s); + if (have != null) return have; + if (!context.mounted) return null; + if (s.rsyncSource == 'custom' || !Platform.isWindows) { + ctl.host.snack(tr('rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > 파일 탐색기에서 경로를 확인하세요.')); + return null; + } + return installRsyncWithDialog(context); + } + + /// [dest] 로 복사 · 이동 (확인 · 아래 진행 막대 · 취소). 끝까지 했으면 true. + Future run(BuildContext context, ExplorerPane pane, List sources, String dest, + {required bool move, void Function(TransferJob job)? onFinished}) async { + if (sources.isEmpty) { + ctl.host.snack(pickFirst(move)); + return false; + } + if (guard.blockShizukuMove(sources, dest, move: move)) return false; + // X-plore 비교 f: 압축 안으로는 넣지 않고 (읽기 전용), 압축 안의 것을 붙여넣으면 꺼내기로 + if (guard.blockArchive([dest], self: true)) return false; + if (sources.any(guard.insideArchive)) { + if (move) return !guard.blockArchive(sources); + await guard.extractFrom(context, sources, dest); + return true; + } + // 23: 앞의 복사 · 이동이 끝나지 않아도 함께 시작한다 (아래 진행 막대에 하나씩). 같은 것을 같은 곳으로만 막는다 + if (jobs.busyWith(sources, dest)) { + ctl.host.snack(tr('같은 항목을 같은 곳으로 복사 · 이동하는 중입니다.')); + return false; + } + // 135: 같은 폴더로 - 짧은 알림은 놓치기 쉬워 창으로. 이동은 할 것이 없고, 복사는 사본을 만들지 한 번 묻는다 (계획은 transfer_plan) + final plan = planTransfer(sources, dest, move: move); + sources = plan.sources; + final skippedHere = plan.skippedHere, here = plan.here; + var sameFolderCopy = false; + if (plan.nothingToMove) { + // 알림만 ([확인] 에 첫 초점 - Enter 로 닫기) + await showDialog( + 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)]))); + if (!ok || !context.mounted) return false; + sameFolderCopy = true; // 이미 물었으니 아래 확인 창은 건너뛴다 + } + // 원본 · 대상이 없거나 (다른 곳에서 지움 - 목록을 새로 고침) 폴더를 자기 안으로 넣으려 하면 시작하지 않는다 + final problem = plan.problem; + if (problem != null) { + ctl.host.snack(problem); + pane.sel.removeWhere(vMissingSync); + await ctl.refreshAll([dest, ...sources.map(p.dirname)]); + for (final x in ctl.panes) { + x.changed(); + } + return false; + } + // 파일 탐색기: 현재 방식 · robocopy (rsync 는 Rsync 화면 - 그쪽만 모니터링에 남는다) + final center = CopyCenter.of(ctl.c); + final task = center.fresh(sources, dest, move: move); + final method = CopyMethod.of(task.method); + // 48: 받는 폴더에 같은 이름이 있으면 덮어쓰기 · 건너뛰기 · 이름 바꾸기를 고른다 + // (앱이 직접 할 때만 - robocopy · rsync 는 그 프로그램의 규칙. 같은 폴더로 복사는 늘 새 이름) + final builtin = method == CopyMethod.builtin || viaDav([...sources, dest]); + // 확인 창은 바로 띄우고, 같은 이름은 창 안에서 뒤에서 찾는다 (느린 네트워크 · WebDAV 폴더라도 창이 늦게 뜨지 않게). + // 다 찾기 전에 누르면 이름 바꾸기 (원래 파일을 건드리지 않는 쪽) + final clashesFuture = builtin ? findClashes(sources, dest) : Future.value(const []); + // 48: 환경 설정 "같은 이름이 있을 때" 가 늘 하는 것이면 묻지 않고 그대로 (확인 창에 알림만) + final always = switch (ctl.c.settings.copyConflict) { + 'rename' => NameConflict.rename, + 'overwrite' => NameConflict.overwrite, + 'skip' => NameConflict.skip, + _ => null, + }; + var conflict = always ?? NameConflict.rename; + // U4 (점검 10/22, 관리자 결정): 다른 창의 표시는 그대로 두고 이번 대상이 아님을 알린다 + 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 go = sameFolderCopy + ? true + : await confirmDialog( + context, + title: () => move ? tr('이동') : tr('복사'), + confirmLabel: () => move ? tr('이동') : tr('복사'), + danger: () => conflict == NameConflict.overwrite, + 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\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])}' : ''}'), + // 178 보충: 환경 설정의 원본 지우기 옵션은 복사에서 뺀다 - 왜 빠졌는지 알림 + if (!move && method != CopyMethod.builtin && optionsRemoveSource(task.options)) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + trf('환경 설정의 {0} 는 복사에서는 쓰지 않습니다 (이동은 [이동] 으로)', + [splitOptions(task.options).where(isSourceRemovingOption).join(' ')]), + style: TextStyle(fontSize: 12, color: JjColors.textDim)), + ), + FutureBuilder>( + future: clashesFuture, + builder: (ctx, snap) { + if (snap.connectionState != ConnectionState.done) { + return Padding( + padding: const EdgeInsets.only(top: 12), + child: Text(tr('같은 이름이 있는지 확인하는 중 …'), style: TextStyle(fontSize: 12, color: JjColors.textDim)), + ); + } + final clashes = snap.data ?? const []; + 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])]), + style: TextStyle(color: JjColors.warning)), + // 172: 두 파일의 크기 · 바뀐 날 (3개까지) + for (final x in clashes.take(3)) ...[ + Padding( + padding: const EdgeInsets.only(top: 4, left: 8), + child: Text(clashCompareText(x), style: const TextStyle(fontSize: 12)), + ), + // 229: 폴더를 합치면 안쪽에서 받는 쪽이 더 새 파일 - 건너뛰는지 (환경 설정) 미리 + if (x.there.isDir && !isDav(x.src) && !isDav(dest)) + FutureBuilder( + future: FileOps.countNewerInside(x.src, vJoin(dest, vBasename(x.src))), + builder: (_, n) => Padding( + padding: const EdgeInsets.only(left: 20), + child: Text( + n.connectionState != ConnectionState.done + ? tr('안쪽 파일을 확인하는 중 …') + : (n.data ?? 0) == 0 + ? '' + : ctl.c.settings.copySkipNewer + ? trf('안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)', [n.data]) + : trf('안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다', [n.data]), + style: TextStyle(fontSize: 12, color: JjColors.warning), + ), + ), + ), + ], + if (always != null) + Text( + trf('환경 설정대로 {0} (환경 설정 > 파일 탐색기 > 같은 이름이 있을 때)', [ + switch (always) { + NameConflict.rename => tr('이름을 바꿉니다'), + NameConflict.overwrite => tr('덮어씁니다'), + NameConflict.skip => tr('건너뜁니다'), + } + ]), + style: const TextStyle(fontSize: 12)) + else + RadioGroup( + groupValue: conflict, + onChanged: (x) => setInner(() => conflict = x ?? conflict), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + for (final (v, label) in [ + (NameConflict.rename, tr('이름 바꾸기 (예: 이름 (2))')), + (NameConflict.overwrite, tr('덮어쓰기 (원래 파일은 없어집니다)')), + (NameConflict.skip, tr('건너뛰기')), + ]) + RadioListTile(dense: true, contentPadding: EdgeInsets.zero, value: v, title: Text(label)), + ]), + ), + ]); + }, + ), + if (otherMarks > 0) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text(trf('다른 창에 {0}개 표시됨 (이번 대상 아님)', [otherMarks]), + style: TextStyle(fontSize: 12, color: JjColors.textDim)), + ), + ]), + ); + if (go != true || !context.mounted) return false; + // 104: 설정의 옵션에 지우기 (robocopy /MIR · /PURGE 등) 가 있으면 미리 보기를 거친다 + if (!await confirmDeletingRun(context, task) || !context.mounted) return false; + String? rsync; + if (method == CopyMethod.rsync && !viaDav([...sources, dest])) { + rsync = await ensureRsync(context); + if (rsync == null || !context.mounted) return false; + } + final job = await center.start(task, rsyncExe: rsync, conflict: conflict); + if (job == null || !context.mounted) return false; + jobs.start(job); + await job.done; + final error = job.error; + await jobs.followMoved(job); + if (!context.mounted) return false; + // 180: 표시만 지우고 선택 모드는 그대로 (꺼지면 동그라미 칸 · 아래 막대가 사라져 자리가 바뀐다) + pane.sel.clear(); + await ctl.refreshAfterTransfer(sources, dest); + // 넣은 폴더가 창의 지금 폴더면 펼쳐서 새 항목이 보이게 + for (final x in ctl.panes) { + if (samePath(x.current, dest)) { + x.expanded.add(dest); + await ctl.load(x, dest, force: true); + } + x.changed(); + } + final doneText = error is FileOpCancelled + ? tr('취소했습니다.') + : error != null + ? trf('끝나지 못했습니다: {0}', [error]) + : joinSentences([ + trf('{0}개 항목을 {1}', [job.made.length, move ? tr('옮겼습니다.') : tr('복사했습니다.')]), + // 관리자 지시 (10/21, 15 찾음 F3): 뒤 조각도 문장으로 쓰고 빈칸으로 잇는다 (예전에는 "…옮겼습니다. · 1개는 …" 처럼 마침표 뒤에 " · ") + // 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)]), + // M1 (10/19): 원격 맞추기에서 받는 쪽의 같은 이름 폴더는 --delete 없이 지우지 않는다 + if (job.dirConflicts.isNotEmpty) trf('같은 이름의 폴더가 있어 {0}개를 건너뛰었습니다: {1}.', [job.dirConflicts.length, namesOf(job.dirConflicts)]), + if (skippedHere.isNotEmpty) trf('이미 이 폴더에 있어 건너뛰었습니다: {0}.', [namesOf(skippedHere)]), + ]); + // 85: 끝나면 [폴더 열기] - 앱 탐색기에서 넣은 폴더로 (그 폴더를 보고 있는 창, 없으면 지금 창) + if (error == null && context.mounted) { + // U2 (점검 10/22): 끝 알림은 정해진 시간 뒤 닫힘 - 아래 표시 줄을 가리지 않게 + showNotice(ScaffoldMessenger.of(context), + text: doneText, + action: ( + tr('폴더 열기'), + () { + final show = ctl.panes.where((x) => samePath(x.current, dest)).firstOrNull ?? ctl.pane; + ctl + ..active = ctl.panes.indexOf(show) + ..update(); + unawaited(ctl.goTo(show, dest, fresh: true)); + } + )); + } else { + ctl.host.snack(doneText); + } + // 82: 끌어 놓기의 이동 되돌리기 (끝난 알림 바로 뒤에 - 진행 막대가 내려가기를 기다리지 않게) + if (error == null && context.mounted) onFinished?.call(job); + // 다 되면 진행 막대가 잠깐 100% 를 보인 뒤 내려간다 + await jobs.dropLater([job]); + return error == null; + } +} diff --git a/lib/ui/explorer/write_guard.dart b/lib/ui/explorer/write_guard.dart new file mode 100644 index 0000000..28257f9 --- /dev/null +++ b/lib/ui/explorer/write_guard.dart @@ -0,0 +1,56 @@ +import 'package:flutter/widgets.dart'; + +import '../../app/archive_browse.dart' show ArchiveRead; +import '../../core/file_ops.dart' show samePath; +import '../../core/shizuku.dart' show isRootPath, isShizuku; +import '../../l10n/tr.dart'; +import '../archive_ui.dart' show extractArchiveFlow; +import 'explorer_controller.dart'; + +/// 약속 (E5d-5): 읽기 전용인 곳 (펼친 압축 안 · 시스템 폴더 Shizuku · 루트) 을 바꾸려는 일을 막고 까닭을 알린다 (Host). +/// 막았으면 true. 압축 안의 것은 꺼내기만 ([extractOne] · [extractFrom] - 86 의 풀기 창 그대로) +class ExplorerGuard { + ExplorerGuard(this.ctl); + + final ExplorerController ctl; + + bool insideArchive(String path) => ctl.arc.inside(path); + + /// 압축 안을 바꾸려 하면 막고 알린다 (읽기 전용). [self] 면 압축 파일 자신도 (그 안으로 넣는 곳일 때) + bool blockArchive(List paths, {bool self = false}) { + // X-plore 비교 5-2: 시스템 폴더 (Shizuku) 도 읽기 전용 - 쓰기 · 지우기 · 이름 바꾸기 · 옮기기 · 새 폴더 · 넣기를 막는다 (꺼내기만) + if (paths.any(isShizuku)) { + ctl.host.snack(paths.any(isRootPath) + ? tr('루트 폴더는 읽기 전용입니다 (꺼내기 (복사) 만 할 수 있습니다)') + : tr('시스템 폴더 (Shizuku) 는 읽기 전용입니다 (꺼내기 (복사) 만 할 수 있습니다)')); + return true; + } + if (!paths.any((x) => insideArchive(x) || (self && ctl.arc.contains(x)))) return false; + ctl.host.snack(tr('압축 안은 읽기 전용입니다 (꺼내기만 할 수 있습니다)')); + return true; + } + + /// 관리자 지시 (10/21, 28 지적): 시스템 폴더 · 루트 (shz://) 가 원본이나 받는 곳인 이동은 시작하지 않는다 - + /// 복사는 되고 원본 지우기에서 오류로 끝나던 것 (끌어 놓기 · 붙여넣기 · 버튼 · 메뉴 모두 여기를 지남) + bool blockShizukuMove(List sources, String? dest, {required bool move}) { + if (!move || !(sources.any(isShizuku) || (dest != null && isShizuku(dest)))) return false; + ctl.host.snack(tr('읽기 전용이라 옮길 수 없습니다 - 복사를 쓰세요')); + return true; + } + + /// 압축 안의 파일 하나를 임시 폴더로 꺼낸다 (보기 · 재생). 꺼낸 경로 (못 하면 null - 까닭을 알림) + Future extractOne(String path) async { + final (out, why) = await ctl.arc.extractOne(path, await ctl.c.services.storage.tempDirectory()); + if (why == ArchiveRead.password) ctl.host.snack(tr('비밀번호가 걸린 파일입니다 - [압축 풀기] 로 푸세요')); + if (why == ArchiveRead.failed) ctl.host.snack(trf('압축에서 꺼내지 못했습니다: {0}', [ctl.arc.lastError])); + return out; + } + + /// 압축 안의 것을 꺼내기 - 86 의 풀기 창 (검사 · 갈 곳 · 같은 이름) 그대로. 꺼낸 폴더를 다시 읽는다 + Future extractFrom(BuildContext context, List paths, String? otherDir) async { + final arc = ctl.arc.archiveOf(paths.firstWhere(insideArchive))!; + final names = [for (final x in paths) if (insideArchive(x) && samePath(ctl.arc.archiveOf(x)!, arc)) ctl.arc.innerName(arc, x)]; + final done = await extractArchiveFlow(context, ctl.c, arc, otherDir: otherDir, names: names); + if (done != null && ctl.host.alive) await ctl.refreshAll([done]); + } +} diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index f2b97b5..8dde205 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -3,87 +3,49 @@ export 'explorer/path_dialog.dart' show looksLikeSmb, smbToUnc; export 'explorer/explorer_buttons.dart' show ExplorerButton; export 'explorer/transfer_panel.dart' show TransferProgressChip; import 'dart:async'; -import 'dart:collection'; import 'dart:io'; import 'dart:math' as math; import 'package:flutter/gestures.dart' show kBackMouseButton, kForwardMouseButton; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:path/path.dart' as p; import '../app/app_controller.dart'; import '../app/copy_center.dart'; -import '../app/live_sync.dart'; -import '../app/settings.dart' show FolderBookmark; import '../app/transfer_job.dart'; import '../core/secret_gate.dart'; -import '../core/sync_tools.dart'; import '../core/file_ops.dart'; -import '../core/playlist.dart' show isAudioFile, isVideoFile; +import '../core/playlist.dart' show isVideoFile; import '../core/vfs.dart'; import '../core/sftp.dart' show SftpRegistry; import '../core/ftp.dart' show FtpRegistry; import '../core/smb.dart' show SmbRegistry; import '../core/webdav.dart' show DavRegistry; import '../platform/android/android_storage.dart'; -import '../platform/android/hwp_pdf_ffi.dart' show RhwpEngine; import 'app_actions.dart'; import 'explorer_look.dart'; -import 'player_page.dart'; -import 'rsync_setup.dart'; import 'theme.dart'; import 'file_error.dart'; import 'folder_picker.dart' show pickFolderOrDav; -import 'trash_page.dart'; -import 'dav_external.dart'; -import '../platform/windows/recycle_bin.dart'; -import '../core/sync_preview.dart'; import 'path_label.dart'; -import 'progress_dialog.dart'; -import 'sync_preview_view.dart'; import 'sftp_settings.dart'; import 'ftp_settings.dart'; -import 'reader_page.dart'; -import '../app/component_store.dart'; -import 'zip_page.dart'; -import '../core/reader_sources.dart'; -import '../core/hwp_source.dart'; import '../l10n/tr.dart'; import 'explorer_bookmarks.dart'; import 'explorer_search.dart'; import 'tail_text.dart'; import '../core/thumb_cache.dart' show thumbMemoryForgetters; import '../core/shizuku.dart'; -import 'vault_page.dart' show openVault; import 'explorer_keys.dart' show defaultExplorerKeys, effectiveExplorerKeys; -import 'sqlite_viewer.dart' show openSqliteViewer, sqliteExtensions; import 'quick_look.dart'; -import 'file_tools.dart' show showFileHash, showFolderProperties; -import '../core/file_attributes.dart' show readFileAttributes; import 'explorer_drag.dart'; -import 'archive_ui.dart'; -import 'explorer_grid.dart'; -import 'text_viewer.dart'; import 'storage_page.dart'; import 'wifi_share_page.dart'; import '../core/wifi_share.dart' show WifiShare; -import '../core/archive_ops.dart' show archiveKindOf; import 'batch_rename_dialog.dart'; -import '../core/app_trash.dart'; import '../core/batch_rename.dart'; import 'notice.dart'; -import 'confirm_dialog.dart'; -import '../core/name_clash.dart'; import '../app/volumes.dart'; -import '../app/delete_ops.dart'; -import '../app/transfer_plan.dart'; -import '../core/file_clipboard.dart'; -import '../app/archive_browse.dart'; -import '../app/file_opener.dart'; -import '../core/app_hidden.dart'; -import '../app/rsync_runner.dart'; -import 'explorer/rsync_run_dialog.dart'; import 'explorer/explorer_controller.dart'; import 'explorer/explorer_host.dart'; import 'explorer/explorer_pane.dart'; @@ -93,6 +55,14 @@ import 'explorer/path_dialog.dart'; import 'explorer/pane_header.dart'; import 'explorer/pane_view.dart'; import 'explorer/explorer_buttons.dart'; +import 'explorer/item_menu.dart'; +import 'explorer/name_dialog.dart'; +import 'explorer/open_flow.dart'; +import 'explorer/delete_flow.dart'; +import 'explorer/explorer_jobs.dart'; +import 'explorer/rsync_flow.dart'; +import 'explorer/transfer_flow.dart'; +import 'explorer/write_guard.dart'; import 'explorer/transfer_panel.dart'; import 'explorer/marks_bar.dart'; import 'explorer/toolbar.dart'; @@ -263,6 +233,8 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex ExplorerPage._open--; ExplorerPage.goToRequest.removeListener(_onGoToRequest); } + _transfers.dispose(); + _jobs.dispose(); _sel.dispose(); _ctl.dispose(); if (_allFiles != null) _life.dispose(); @@ -278,7 +250,7 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex _ctl.local = ExplorerPage.debugVolumes?.call() ?? await localVolumes(); if (_ctl.local.isEmpty) _ctl.local = [(Platform.isWindows ? r'C:\' : '/', Platform.isWindows ? 'C:' : '/')]; // X-plore 비교 e: 보관 일이 지난 휴지통 항목을 지운다 (뒤에서) - if (_appTrash case final t?) unawaited(t.purgeOlderThan(c.settings.androidTrashDays).catchError((_) => 0)); + if (_deletes.appTrash case final t?) unawaited(t.purgeOlderThan(c.settings.androidTrashDays).catchError((_) => 0)); if (Platform.isAndroid) unawaited(_checkAccess()); final saved = widget.rsync ? c.settings.rsyncPaths : c.settings.explorerPaths; // 창마다 따로 읽는다: 한 창이 WebDAV 서버를 기다려도 다른 창 (이 기기 파일) 은 바로 보이게 @@ -311,7 +283,7 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex final center = CopyCenter.peekOf(c); if (center != null) { for (final job in center.jobs.values.where((j) => !j.finished)) { - _adoptJob(job); + _jobs.adopt(job); } } // 165: 드라이브 종류 · 연결은 드라이브마다 따로 뒤에서 (느린 드라이브가 있어도 다른 드라이브 · 창은 바로) @@ -680,40 +652,6 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex ); } - /// 77: [경로 복사] 에 넣을 것 - 실제 경로 (Android 도 "SD 카드 ›" 가 아닌 /storage/…), WebDAV 는 서버 주소 (아이디 · 비밀번호는 빼고) - String _copyablePath(String path) { - if (!isDav(path)) return path; - final d = DavPath.parse(path); - // 88: SFTP 는 sftp://아이디@서버:포트/경로 (시작 폴더를 비웠으면 홈 기준 ~/) - if (d.isSftp) { - final s = SftpRegistry.server(d.server); - if (s == null) return _ctl.displayPath(path); - final base = s.root.isEmpty ? '/~' : (s.root.startsWith('/') ? s.root : '/${s.root}'); - return 'sftp://${s.user.isEmpty ? '' : '${s.user}@'}${s.host}${s.port == 22 ? '' : ':${s.port}'}$base${d.isRoot ? '' : d.rel}'; - } - // l: FTP 는 ftp://아이디@서버:포트/경로 (암호화 990 은 ftps://, 비밀번호 없음) - if (d.isFtp) { - final s = FtpRegistry.server(d.server); - if (s == null) return _ctl.displayPath(path); - final implicit = s.security == 'implicit'; - final r = s.root.trim().replaceAll(RegExp(r'^/+|/+$'), ''); - return '${implicit ? 'ftps' : 'ftp'}://${s.user.isEmpty ? '' : '${Uri.encodeComponent(s.user)}@'}${s.host}' - '${s.port == (implicit ? 990 : 21) ? '' : ':${s.port}'}${r.isEmpty ? '' : '/$r'}${d.isRoot ? '' : d.rel}'; - } - // 88: SMB 는 smb://서버/공유/경로 (아이디 · 비밀번호 없음) - if (d.isSmb) { - final s = SmbRegistry.server(d.server); - if (s == null) return _ctl.displayPath(path); - final r = s.root.trim().replaceAll(r'\', '/').replaceAll(RegExp(r'^/+|/+$'), ''); - return 'smb://${s.host}${s.port == 445 ? '' : ':${s.port}'}/${s.share}${r.isEmpty ? '' : '/$r'}${d.isRoot ? '' : d.rel}'; - } - try { - return DavRegistry.client(d.server).uriOf(d.rel).replace(userInfo: '').toString(); - } catch (_) { - return _ctl.displayPath(path); - } - } - /// 화면에 보일 경로 (5 · 14): Android 는 "SD 카드 › JJ_sdtest › jj_mkv", WebDAV 는 "☁ 서버 › 폴더", Windows 는 그대로 /// 그 항목이 목록 위쪽 1/4 쯤에 보이게 스크롤 (깊은 폴더로 가면 위쪽 폴더들의 다른 항목에 밀려 안 보이므로) void _reveal(ExplorerPane pane, String path) => WidgetsBinding.instance.addPostFrameCallback((_) => _revealNow(pane, path)); @@ -846,7 +784,7 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex @override Widget marksBar(ExplorerPane pane, {String? listDir}) => ExplorerMarksBar(sel: _sel, pane: pane, listDir: listDir); @override - void onMenu(ExplorerPane pane, FileEntry e, Offset at) => _menu(pane, e, at); + void onMenu(ExplorerPane pane, FileEntry e, Offset at) => _itemMenu.show(context, pane, e, at); @override List dragPaths(ExplorerPane pane, FileEntry e) => _dragPaths(pane, e); @override @@ -857,219 +795,41 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex void filterChanged() => setState(() {}); /// 기능 버튼 → 할 일 (E5d-4) - late final _buttons = ExplorerButtons(_ctl, _sel, this); + late final _buttons = ExplorerButtons(_ctl, _sel, this, guard: _guard, transfers: _transfers, deletes: _deletes, rsync: _rsyncFlow, opener: _opener); + + /// 읽기 전용인 곳 막기 · 진행 작업 · 복사 · 이동 · rsync · 지우기 (E5d-5) + late final _guard = ExplorerGuard(_ctl); + late final _jobs = ExplorerJobs(_ctl)..addListener(_onPane); + late final _transfers = ExplorerTransfers(_ctl, _guard, _jobs)..addListener(_onPane); + late final _rsyncFlow = ExplorerRsyncFlow(_ctl, _jobs, _transfers); + late final _deletes = ExplorerDeleteFlow(_ctl, _sel, _guard, androidTrash: () => ExplorerPage.debugAndroidTrash ?? Platform.isAndroid); + + @override + String? get otherDir => _other?.current; + + /// 시험: [휴지통] 열기 (E5e 에서 지움) + @visibleForTesting + Future debugOpenTrash() => _deletes.openTrash(context); + + /// 줄 메뉴 (E5d-5) + late final _itemMenu = ExplorerItemMenu(_ctl, _sel, this, + guard: _guard, transfers: _transfers, deletes: _deletes, opener: _opener, nomedia: () => ExplorerPage.debugNomedia ?? Platform.isAndroid); + + @override + bool get dual => _dual; + + /// 파일 열기 · 보기 · 재생 (E5d-5) + late final ExplorerOpener _opener = ExplorerOpener(_ctl, _sel, _guard, + otherDirOf: (pane) => _dual ? _ctl.panes[1 - _ctl.panes.indexOf(pane)].current : null); /// 서버 추가 · 고치기 · 경로 입력 (E5d-3) late final _servers = ExplorerServers(_ctl); // ───────── 누르기 · 고르기 (E5d-2: ExplorerSelect) ───────── - late final _sel = ExplorerSelect(_ctl, clock: widget.clock, openFile: (path, pane) => _open(path, pane: pane)) + late final _sel = ExplorerSelect(_ctl, clock: widget.clock, openFile: (path, pane) => _opener.open(context, path, pane: pane)) ..addListener(_onPane); - /// 다른 앱으로 열지 못했을 때: 음악 · 동영상이면 내장 플레이어를 알려 준다 (29) - String _noAppMessage(String path) => isAudioFile(path) || isVideoFile(path) - ? tr('이 형식을 여는 앱이 없습니다. 길게 누르거나 오른쪽 클릭 → [내장 플레이어로 재생] 으로 들어 보세요.') - : tr('이 파일을 열 수 있는 앱이 없습니다.'); - - /// 파일 열기 (기본 동작): 동영상은 내장 플레이어 (또는 확장자별 프로그램), 그 밖은 기본 연결 프로그램 - Future _open(String path, {ExplorerPane? pane}) async { - // X-plore 비교 f: 압축 안의 파일 - 그 파일만 임시 폴더로 꺼내 연다 - if (_insideArchive(path)) { - final local = await _extractOne(path); - if (local == null || !mounted) return; - path = local; - } - // 여는 길은 file_opener (E5c) 가 고른다 - switch (openRouteFor(path, - components: c.settings.components, - imageExts: c.settings.imageExts, - android: Platform.isAndroid, - windows: Platform.isWindows, - hwpEngine: RhwpEngine.available)) { - case OpenRoute.player: - return play([path]); - case OpenRoute.extractArchive: - return _extractArchive(path, pane ?? _ctl.pane); - case OpenRoute.images: - return _viewImages(path, pane); - case OpenRoute.pdf: - return _viewPdf(path); - case OpenRoute.zip: - return _openZip(path, pane); - // 관리자 지시 (10/20): Android HWP · HWPX 는 앱 안에서 PDF 로 바꿔 본다. 못 열면 이유를 알리고 다른 앱으로 - case OpenRoute.hwp: - if (await _viewHwp(path)) return; - case OpenRoute.docPreview: - return _viewDoc(path); - case OpenRoute.external: - } - final local = await _fetch(path); - if (local == null) return; - final ok = await c.services.shell.openWith(local); - if (ok || !mounted) return; - // X-plore 비교 h: 글 파일을 열 앱이 없으면 앱 안의 글 뷰어로 - if (textExtensions.contains(extOf(path))) return _viewText(local); - _snack(_noAppMessage(path)); - } - - /// X-plore 비교 h: 글 뷰어 (WebDAV 등은 받은 뒤) - Future _viewText(String path) async { - final local = await _fetch(path); - if (local == null || !mounted) return; - await Navigator.of(context).push(MaterialPageRoute(builder: (_) => TextViewerPage(path: local))); - } - - /// X-plore 비교 g: 폴더의 그림 · 동영상을 격자로. 길게 누른 것은 탐색기에서 그 자리로 - Future _grid(ExplorerPane pane, String dir) async { - await _ctl.load(pane, dir); - if (!mounted) return; - final list = _sel.shown(pane, dir); - final at = await Navigator.of(context).push(MaterialPageRoute( - builder: (_) => ExplorerGridPage( - title: vBasename(dir).isEmpty ? dir : vBasename(dir), - entries: list, - imageExts: c.settings.imageExts, - videoThumb: (e) => BigVideoThumbs.of(c, e), - onOpen: (e) => _open(e.path, pane: pane), - ), - )); - if (at == null || !mounted) return; - await _ctl.goTo(pane, dir); - pane.focused = at; - pane.changed(); - } - - /// WebDAV 파일은 열거나 재생하려면 임시 폴더로 받는다 (dav_external.dart) - Future _fetch(String path) => fetchDav(context, c, path); - - /// 동영상 재생 (WebDAV 는 받지 않고 바로 스트리밍) - @override - Future play(List paths, {bool internal = false, bool keepOrder = false}) async { - if (!mounted || paths.isEmpty) return; - await playFiles(context, c, paths, internal: internal, keepOrder: keepOrder); - } - - /// 그림 보기: 같은 폴더의 그림을 지금 정렬 순서대로 넘겨 본다 - Future _viewImages(String path, ExplorerPane? pane) async { - final dir = vDirname(path); - // 폴더를 읽지 못하면 (E10: 이제 오류로 알림) 고른 그림 하나만 - List list; - try { - list = pane?.cache[dir] ?? _ctl.sorted(await listEntries(dir, showHidden: c.settings.explorerShowHidden), dir); - } catch (_) { - list = const []; - } - final images = [ - for (final e in list) - if (!e.isDir && c.settings.imageExts.contains(extOf(e.path))) e.path, - ]; - if (!images.any((x) => samePath(x, path))) images.insert(0, path); - final start = images.indexWhere((x) => samePath(x, path)); - final temp = await c.services.storage.tempDirectory(); - if (!mounted) return; - await openReader(context, c, ImageFilesSource(images, tempDir: temp, title: vBasename(dir)), start: start); - } - - /// X-plore 맞추기 (관리자 지시 10/20): 폴더의 그림 (지금 정렬 순서) 을 보기 화면으로 열고 [계속 보기] 를 켠 채 시작 - Future _slideshow(ExplorerPane pane, String dir) async { - List list; - try { - list = pane.cache[dir] ?? _ctl.sorted(await listEntries(dir, showHidden: c.settings.explorerShowHidden), dir); - } catch (e) { - _snack(trf('폴더를 읽을 수 없습니다: {0}', [e])); - return; - } - final images = [ - for (final e in list) - if (!e.isDir && c.settings.imageExts.contains(extOf(e.path)) && (_ctl.showAppHidden || !_ctl.appHidden(e.path))) e.path, - ]; - if (images.isEmpty) { - _snack(tr('이 폴더에는 그림이 없습니다')); - return; - } - final temp = await c.services.storage.tempDirectory(); - if (!mounted) return; - await openReader(context, c, ImageFilesSource(images, tempDir: temp, title: vBasename(dir)), autoPlay: true); - } - - /// 문서 → PDF (진행 창) → 보기 - Future _viewDoc(String path) async { - final temp = await c.services.storage.tempDirectory(); - if (!mounted) return; - final dialog = showProgressDialog(context, trf('PDF 로 바꾸는 중: {0}', [vBasename(path)])); - String? pdf; - Object? error; - try { - final local = await vLocalCopy(path, temp); - pdf = await ComponentStore.shared.convertToPdf(local, p.join(temp, 'jj_docs')); - } catch (e) { - error = e; - } - // 사용자가 [취소] · ← 로 그만두었으면 결과를 보이지 않는다 (바꾸기는 끝까지 돌았음) - if (dialog.cancelled()) return; - dialog.close(); - await dialog.done; - if (pdf == null) { - _snack(trf('미리 볼 수 없습니다: {0}', [error])); - return; - } - PdfSource src; - try { - src = await PdfSource.open(pdf, tempDir: temp); - } catch (e) { - _snack(trf('PDF 를 열 수 없습니다: {0}', [e])); - return; - } - if (!mounted) return; - await openReader(context, c, src); - } - - /// 관리자 지시 (10/20): HWP · HWPX → 보기 화면 (보는 쪽부터 몇 쪽씩 PDF 로 바꿈 - [HwpSource]). 열지 못하면 false - Future _viewHwp(String path) async { - final temp = await c.services.storage.tempDirectory(); - if (!mounted) return true; - final dialog = showProgressDialog(context, trf('여는 중: {0}', [vBasename(path)])); - HwpSource? src; - Object? error; - try { - src = await HwpSource.open(path, engine: RhwpEngine(), tempDir: temp, fontDirs: RhwpEngine.fontDirs, log: c.note); - } catch (e) { - error = e; - } - // 사용자가 [취소] · ← 로 그만두었으면 연 문서를 닫고 끝 (다른 앱으로도 열지 않음) - if (dialog.cancelled()) { - await src?.dispose(); - return true; - } - dialog.close(); - await dialog.done; - if (src == null) { - _snack(trf('앱 안에서 열지 못해 다른 앱으로 엽니다: {0}', [error])); - return false; - } - if (!mounted) { - await src.dispose(); - return true; - } - await openReader(context, c, src); - return true; - } - - Future _viewPdf(String path) async { - final temp = await c.services.storage.tempDirectory(); - PdfSource src; - try { - src = await PdfSource.open(path, tempDir: temp); - } catch (e) { - _snack(trf('PDF 를 열 수 없습니다: {0}', [e])); - return; - } - if (!mounted) return; - await openReader(context, c, src); - await _ctl.refreshAll([vDirname(path)]); - } - /// ZIP: 환경 설정이 "만화 보기" 면 안의 그림을 바로 보고 (그림이 없으면 목록), 아니면 목록 /// 87: 저장 공간 분석 → 고른 것을 탐색기에서 그 자리로 (지우기는 탐색기의 지우기로만). /// WebDAV 는 직접 시킬 때만 (이 메뉴) 하고, 서버에서 모두 읽어 오래 걸린다고 먼저 알린다 @@ -1092,9 +852,9 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex } // X-plore 비교 5-1 ⑤ (관리자 지시 10/21): 분석 화면에서도 이 탐색기의 지우기 (확인 · 휴지통) · 이동 (붙여넣기로) final path = await StorageAnalysisPage.open(context, dir, onDelete: (paths) async { - await deleteItems(pane, paths); + await _deletes.run(context, pane, paths); return [for (final x in paths) if (!await vExists(x)) x]; - }, onCut: (paths) => _toClipboard(paths, move: true)); + }, onCut: (paths) => _transfers.toClipboard(paths, move: true)); if (path == null || !mounted) return; final isDir = await vIsDir(path); await _ctl.goTo(pane, isDir ? path : vDirname(path), fresh: true); @@ -1142,389 +902,12 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex ); } - /// 86: 압축 풀기 (검사 · 확인 · 진행 · 취소) 뒤 목록 새로 고침 - Future _extractArchive(String path, ExplorerPane pane) async { - final other = _dual ? _ctl.panes[1 - _ctl.panes.indexOf(pane)].current : null; - final out = await extractArchiveFlow(context, c, path, otherDir: other); - if (out != null && mounted) await _ctl.refreshAll([out, vDirname(out)]); - } - - Future _openZip(String path, ExplorerPane? pane) async { - try { - if (c.settings.zipComic && await openZipComic(context, c, path)) return; - } catch (e) { - _snack(trf('열 수 없습니다: {0}', [e])); - return; - } - if (!mounted) return; - final other = pane == null || !_dual ? null : _ctl.panes[1 - _ctl.panes.indexOf(pane)].current; - final out = await openZip(context, c, path, otherDir: other); - if (out != null) await _ctl.refreshAll([out, vDirname(out)]); - } - - /// 고른 그림들을 한 장씩 PDF 로 (같은 폴더에, 이름을 묻는다) - Future _imagesToPdf(List images) async { - if (images.isEmpty) return; - final dir = vDirname(images.first); - final first = vBasename(images.first); - final name = await askName(tr('PDF 로 만들기'), '${first.contains('.') ? first.substring(0, first.lastIndexOf('.')) : first}.pdf'); - if (name == null || name.trim().isEmpty) return; - final out = vJoin(dir, name.toLowerCase().endsWith('.pdf') ? name : '$name.pdf'); - final temp = await c.services.storage.tempDirectory(); - try { - await imagesToPdf(images, out, tempDir: temp); - _snack(trf('그림 {0}장으로 PDF 를 만들었습니다: {1}', [images.length, vBasename(out)])); - } catch (e) { - _snack(trf('PDF 를 만들지 못했습니다: {0}', [e])); - } - await _ctl.refreshAll([dir]); - } - - Future _menu(ExplorerPane pane, FileEntry e, Offset at) async { - setState(() => _ctl.active = _ctl.panes.indexOf(pane)); - pane.focused = e.path; - pane.changed(); - final video = !e.isDir && isVideoFile(e.path); - final audio = !e.isDir && isAudioFile(e.path); - final dav = isDav(e.path); - // 그림 → PDF: 표시한 것 중 그림 (누른 것이 표시에 없으면 누른 것만) - final viewer = c.settings.components.contains('viewer'); - final picked = pane.sel.has(e.path) ? pane.sel.marked : [e.path]; - final images = [for (final x in picked) if (c.settings.imageExts.contains(extOf(x))) x]; - final ext = extOf(e.path); - final viewable = viewer && !e.isDir && - (c.settings.imageExts.contains(ext) || ext == 'pdf' || zipExtensions.contains(ext)); - final dual = _dual; - // 77: 누른 것이 표시한 것 중 하나면 표시한 것 모두에 (복사 · 이동 · 삭제 · 공유 · 경로 복사) - final targets = _sel.isMarked(pane, e.path) && pane.sel.marked.length > 1 ? pane.sel.marked : [e.path]; - final many = targets.length > 1; - // 데이터 검토 (10/18): 펼친 압축 안의 것은 실제 파일이 아니다 - 그것으로 새 파일을 만들거나 내놓는 항목 (ZIP 묶기 · Wi-Fi 공유 · PDF) 은 숨긴다 - final inArchive = targets.any(_insideArchive) || _insideArchive(e.path); - // 25: Rsync 화면은 폴더만 보이므로 폴더에 맞는 것만 (고르기 · 열기 · 이름 변경 · 삭제 · 정보) - final items = widget.rsync - ? <(String, IconData, String)>[ - ('pickRsync', Icons.check_circle_outline, - _sel.isMarked(pane, e.path) ? tr('고르기 취소') : tr('rsync 에 고르기')), - ('open', Icons.folder_open, tr('열기')), - ('openOther', Icons.vertical_split_outlined, tr('다른 창에서 열기')), - ('rename', Icons.drive_file_rename_outline, tr('이름 변경')), - ('delete', Icons.delete_outline, tr('삭제')), - if (!dav) ('reveal', Icons.folder_outlined, tr('파일 관리자에서 보기')), - ('info', Icons.info_outline, tr('정보')), - ] - : <(String, IconData, String)>[ - // 77: 복사 · 이동 · 이름 변경 · 삭제 · 공유는 위의 아이콘 줄 ([_menuIcons]). 아래는 상황별 항목 - // 74: 폰에서 범위 고르기 - 첫 항목을 표시한 뒤 마지막 항목을 길게 눌러 - if (_sel.selecting && _sel.canRange(pane, e)) ('range', Icons.unfold_more, tr('여기까지 범위 고르기')), - if (e.isDir) ('open', Icons.folder_open, tr('열기')), - // 81: 빠른 보기 (PC 는 Space · TC 는 F3) - if (!e.isDir) ('quickLook', Icons.preview_outlined, tr('미리 보기')), - // 86: 압축 풀기 (ZIP · 7z · RAR) · ZIP 으로 묶기 (표시한 것 모두) - if (!e.isDir && archiveKindOf(e.path) != null) ('extract', Icons.unarchive_outlined, tr('압축 풀기')), - if (!dav && !inArchive) ('zip', Icons.archive_outlined, many ? trf('{0}개 ZIP 으로 묶기', [targets.length]) : tr('ZIP 으로 묶기')), - // 87: 저장 공간 분석 (이 폴더 아래의 큰 폴더 · 큰 파일) - if (e.isDir) ('storage', Icons.pie_chart_outline, tr('저장 공간 분석')), - // 89: 이 폴더를 Wi-Fi 로 공유 (이 기기의 폴더만) - if (e.isDir && !dav && !widget.rsync && !inArchive) ('wifiShare', Icons.wifi_tethering, tr('Wi-Fi 로 공유')), - // 80: 이 폴더 크기를 지금 계산 (설정이 "안 함" 이어도) - if (e.isDir) ('folderSize', Icons.data_usage, _ctl.sizes.busy(e.path) ? tr('폴더 크기 계산 멈추기') : tr('폴더 크기 계산')), - // X-plore P3: 폴더 속성 (안의 파일 · 폴더 수 · 크기 · 만든 · 바뀐 때) · 파일 해시 (MD5 · SHA-256) - 이 기기의 것만 (원격은 다 받아야 해서 뺌) - if (e.isDir && !dav) ('props', Icons.fact_check_outlined, tr('폴더 속성')), - if (!e.isDir && !dav) ('hash', Icons.tag, tr('해시 (MD5 · SHA-256)')), - if (e.isDir && dual) ('openOther', Icons.vertical_split_outlined, tr('다른 창에서 열기')), - if (e.isDir) ('playFolder', Icons.play_circle_outline, tr('이 폴더의 동영상 재생')), - // X-plore 맞추기 (관리자 지시 10/20): 이 폴더의 그림을 [계속 보기] 로 - if (e.isDir && viewer) ('slideshow', Icons.slideshow_outlined, tr('사진 슬라이드쇼')), - // X-plore 비교 g: 그림 · 동영상을 썸네일 격자로 - if (e.isDir && !dav) ('grid', Icons.grid_view, tr('격자로 보기 (그림 · 동영상)')), - if (video || audio) ('playInternal', Icons.play_circle_outline, tr('내장 플레이어로 재생')), - if (viewable) ('view', Icons.visibility_outlined, tr('보기')), - // X-plore 비교 h: 어떤 파일이든 글로 열어 보기 (읽기 전용) - if (!e.isDir && !video && !audio) ('text', Icons.article_outlined, tr('글 뷰어로 보기')), - // X-plore 비교 5-1 ④ (관리자 지시 10/21): SQLite (읽기 전용) - if (!e.isDir && !inArchive && sqliteExtensions.contains(ext)) ('sqlite', Icons.table_chart_outlined, tr('SQLite 보기 (읽기 전용)')), - // X-plore 비교 5-2 (사용자 결정 10/21): 암호 보관함에 넣기. 2단계 (관리자 지시 10/21): 폴더째 (안의 구조 그대로) · 원격 파일도 (받아서 넣음) - if (c.settings.vaultEnabled && !inArchive) - ('vaultAdd', Icons.enhanced_encryption_outlined, many ? trf('{0}개 보관함에 넣기', [targets.length]) : tr('보관함에 넣기')), - if (!e.isDir) ('openWith', Icons.open_in_new, tr('다른 앱으로 열기')), - if (!e.isDir && !video) ('openDefault', Icons.launch, tr('기본 앱으로 열기')), - if (video && !dav) ('addMkv', Icons.playlist_add, tr('MKV 목록에 추가')), - if (viewer && images.isNotEmpty && !inArchive) - ('toPdf', Icons.picture_as_pdf_outlined, - images.length == 1 ? tr('PDF 로 만들기') : trf('그림 {0}장을 PDF 로 만들기', [images.length])), - ('select', Icons.check_circle_outline, tr('선택')), - // X-plore 숨기기: 목록에서만 숨긴다 (파일은 그대로) · 숨긴 것을 보이고 있으면 풀기 - targets.every(_ctl.appHidden) - ? ('appUnhide', Icons.visibility_outlined, many ? trf('{0}개 숨기기 풀기', [targets.length]) : tr('숨기기 풀기')) - : ('appHide', Icons.visibility_off_outlined, many ? trf('{0}개 목록에서 숨기기', [targets.length]) : tr('목록에서 숨기기')), - // X-plore 맞추기 (관리자 지시 10/20): 폴더에 .nomedia 를 두면 갤러리 · 음악 앱이 그 안의 그림 · 동영상 · 음악을 보이지 않는다 (Android) - if (e.isDir && !dav && !inArchive && (ExplorerPage.debugNomedia ?? Platform.isAndroid)) - File(p.join(e.path, '.nomedia')).existsSync() - ? ('nomediaOff', Icons.photo_library_outlined, tr('갤러리 · 음악 앱에 다시 보이기 (.nomedia 지우기)')) - : ('nomediaOn', Icons.hide_image_outlined, tr('갤러리 · 음악 앱에서 숨기기 (.nomedia)')), - // 두 창이면 아이콘 줄의 복사 · 이동은 다른 창으로 - 담아 두었다가 원하는 폴더에 붙여넣기는 여기 - 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.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('즐겨찾기에 추가')), - if (!dav && (!Platform.isAndroid || e.isDir)) ('reveal', Icons.folder_outlined, tr('파일 관리자에서 보기')), - ('info', Icons.info_outline, tr('정보')), - ]; - // 77: 위의 아이콘 줄 (X-plore · One Commander 처럼 자주 쓰는 것을 한 줄에) - final icons = widget.rsync - ? const <(String, IconData, String, String, bool)>[] - : <(String, IconData, String, String, bool)>[ - (dual ? 'copy' : 'clipCopy', Icons.copy_outlined, tr('복사'), dual ? tr('다른 창으로 복사') : tr('복사 (붙여넣기로)'), true), - (dual ? 'move' : 'clipMove', Icons.drive_file_move_outline, tr('이동'), dual ? tr('다른 창으로 이동') : tr('이동 (붙여넣기로)'), true), - // 78: 여럿이면 일괄 이름 바꾸기 - ('rename', Icons.drive_file_rename_outline, tr('이름 변경'), many ? trf('{0}개 이름 일괄 바꾸기', [targets.length]) : tr('이름 변경'), true), - ('delete', Icons.delete_outline, tr('삭제'), many ? trf('{0}개 삭제', [targets.length]) : tr('삭제'), true), - if (c.services.shell.canShare) ('share', Icons.share_outlined, tr('공유'), tr('공유 (다른 앱으로 보내기)'), targets.any((x) => !vIsDirSync(x))), - ]; - final size = MediaQuery.sizeOf(context); - final pick = await showMenu( - context: context, - position: RelativeRect.fromLTRB(at.dx, at.dy, size.width - at.dx, size.height - at.dy), - items: [ - // 77: 여러 개를 골랐으면 무엇에 하는지 먼저 - if (many) - PopupMenuItem( - enabled: false, - height: 32, - child: Text(trf('{0}개 항목', [targets.length]), - style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: JjColors.accent)), - ), - if (icons.isNotEmpty) ...[_MenuIconRow(icons), const PopupMenuDivider(height: 1)], - for (final (id, icon, label) in items) - PopupMenuItem( - value: id, - child: Row(children: [ - Icon(icon, size: 18), - const SizedBox(width: 12), - Flexible(child: Text(label, overflow: TextOverflow.ellipsis)), - ]), - ), - ], - ); - if (pick == null || !mounted) return; - switch (pick) { - case 'pickRsync': - await _sel.pick(pane, e); - case 'open': - await _ctl.goTo(pane, e.path); - case 'openOther': - final other = _ctl.panes[1 - _ctl.panes.indexOf(pane)]; - await _ctl.goTo(other, e.path); - setState(() => _ctl.active = _ctl.panes.indexOf(other)); - case 'playFolder': - await playFolder(e.path); - case 'slideshow': - await _slideshow(pane, e.path); - case 'playInternal': - await play([e.path], internal: true); - case 'grid': - await _grid(pane, e.path); - case 'text': - await _viewText(e.path); - case 'sqlite': - await openSqliteViewer(context, c, e.path); - case 'vaultAdd': - await openVault(context, c, add: targets, deleteOriginals: (paths, {required permanent}) { - // 관리자 지시 (10/21): [영구 삭제] 를 고르면 휴지통을 거치지 않음 (Shift+Delete 와 같은 길 - 탐색기의 영구 삭제 확인 창을 한 번 더) - shiftDelete = permanent; - return deleteItems(pane, paths); - }); - await _ctl.refreshAll([vDirname(e.path)]); - case 'openWith': - final local = await _fetch(e.path); - if (local != null && !await c.services.shell.openWith(local, choose: true)) _snack(_noAppMessage(e.path)); - case 'openDefault': - final local = await _fetch(e.path); - if (local != null && !await c.services.shell.openWith(local)) _snack(_noAppMessage(e.path)); - case 'addMkv': - await addMkv([e.path]); - case 'range': - _sel.markRange(pane, e); - case 'select': - setState(() => _sel.selecting = true); - if (!_sel.isMarked(pane, e.path)) await _sel.toggleMark(pane, e); - case 'quickLook': - await _quickLook(pane, e.path); - case 'extract': - await _extractArchive(e.path, pane); - case 'storage': - await openStorage(pane, e.path); - case 'wifiShare': - await openWifiShare(e.path); - case 'zip': - final items = outermostPaths(_sel.isMarked(pane, e.path) && pane.sel.marked.length > 1 ? pane.sel.marked : [e.path]); - final other = _dual ? _ctl.panes[1 - _ctl.panes.indexOf(pane)].current : null; - final made = await createZipFlow(context, c, items, dir: vDirname(items.first), otherDir: other); - if (made != null && mounted) { - await _ctl.refreshAll([vDirname(made)]); - pane.focused = made; - pane.changed(); - } - case 'appHide': - if (c.settings.explorerHideConfirm && !await _confirmAppHide(targets)) return; - await c.updateSettings((s) => s.explorerHiddenItems = [ - ...s.explorerHiddenItems, - for (final x in targets) - if (!_ctl.appHidden(x)) x, - ]); - for (final q in _ctl.panes) { - targets.forEach(q.sel.forget); - q.changed(); - } - if (mounted) setState(() {}); - _snack(_ctl.showAppHidden - ? trf('목록에서 숨겼습니다 ({0}개). 파일은 그대로입니다.', [targets.length]) - : trf('목록에서 숨겼습니다 ({0}개). 파일은 그대로이고, 목록 맨 아래의 [숨긴 항목 보이기] 로 다시 볼 수 있습니다.', [targets.length])); - case 'appUnhide': - final keys = {for (final x in targets) HiddenList.keyOf(x)}; - await c.updateSettings((s) => s.explorerHiddenItems = [ - for (final x in s.explorerHiddenItems) - if (!keys.contains(HiddenList.keyOf(x))) x, - ]); - for (final q in _ctl.panes) { - q.changed(); - } - if (mounted) setState(() {}); - case 'nomediaOn' || 'nomediaOff': - await _nomedia(pane, e.path, on: pick == 'nomediaOn'); - case 'props': - await showFolderProperties(context, e.path); - case 'hash': - await showFileHash(context, e.path); - case 'folderSize': - if (_ctl.sizes.busy(e.path)) { - _ctl.sizes.cancel(e.path); - break; - } - // 사용자가 직접 시킨 것: 다른 폴더로 가도 끝까지 (멈추기는 아래 알림 · 같은 메뉴) - _ctl.sizes - ..forget(e.path) - ..request(e.path); - if (mounted) { - showNotice(ScaffoldMessenger.of(context), - text: isDav(e.path) - ? trf('"{0}" 크기를 계산합니다. 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다.', [e.name]) - : trf('"{0}" 크기를 계산합니다.', [e.name]), - action: (tr('취소'), () => _ctl.sizes.cancel(e.path))); - } - case 'rename': - if (_sel.isMarked(pane, e.path) && pane.sel.marked.length > 1) { - await batchRename(pane, pane.sel.marked); - } else { - await renameItem(pane, e.path); - } - case 'copy': - await transfer(pane, _sel.isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: false); - case 'move': - await transfer(pane, _sel.isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: true); - case 'clipCopy': - _toClipboard(_sel.isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: false); - case 'clipMove': - _toClipboard(_sel.isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: true); - case 'paste': - await paste(pane, e.isDir ? e.path : vDirname(e.path)); - case 'delete': - // 77: 메뉴 머리의 "n개 항목" 과 같게 (예전에는 표시한 것이 여럿이어도 누른 것 하나만 지웠다) - await deleteItems(pane, targets); - case 'share': - await _share(targets); - case 'copyPath': - final paths = targets.map(_copyablePath).toList(); - await Clipboard.setData(ClipboardData(text: paths.join('\n'))); - _snack(many ? trf('경로 {0}개를 복사했습니다', [paths.length]) : trf('경로를 복사했습니다: {0}', [paths.single])); - case 'bookmark': - final name = vBasename(e.path).isEmpty ? e.path : vBasename(e.path); - await c.updateSettings((s) => s.folderBookmarks = [...s.folderBookmarks, FolderBookmark(name: name, path: e.path)]); - _snack(trf('즐겨찾기에 넣었습니다: {0}', [name])); - setState(() {}); - case 'view': - await _open(e.path, pane: pane); - case 'toPdf': - await _imagesToPdf(images); - case 'reveal': - await c.services.shell.revealFile(e.path); - case 'info': - await _info(e); - } - } - - /// 77: 공유 - 파일만 (폴더는 보낼 수 없어 빼고 알림). WebDAV 파일은 받아서 - Future _share(List paths) async { - final files = [for (final x in paths) if (!vIsDirSync(x)) x]; - if (files.isEmpty) { - _snack(tr('폴더는 공유할 수 없습니다. 파일을 고르세요.')); - return; - } - final local = []; - for (final f in files) { - final l = await _fetch(f); - if (l == null || !mounted) return; - local.add(l); - } - if (!await c.services.shell.shareFiles(local)) { - _snack(tr('공유 창을 열 수 없습니다.')); - } else if (files.length < paths.length) { - _snack(trf('폴더 {0}개는 빼고 파일만 보냈습니다.', [paths.length - files.length])); - } - } - // ───────── 기능 ───────── ExplorerPane? get _other => _dual || _hiddenTarget ? _ctl.panes[1 - _ctl.active] : null; // ───────── X-plore 비교 f: 압축을 트리 안에서 폴더처럼 (ZIP · 7z · RAR, 읽기 전용) ───────── - String? _archiveOf(String path) => _ctl.arc.archiveOf(path); - bool _insideArchive(String path) => _ctl.arc.inside(path); - - /// 압축 안의 파일 하나를 임시 폴더로 꺼낸다 (보기 · 재생). 꺼낸 경로 (못 하면 null) - Future _extractOne(String path) async { - final (out, why) = await _ctl.arc.extractOne(path, await c.services.storage.tempDirectory()); - if (why == ArchiveRead.password) _snack(tr('비밀번호가 걸린 파일입니다 - [압축 풀기] 로 푸세요')); - if (why == ArchiveRead.failed) _snack(trf('압축에서 꺼내지 못했습니다: {0}', [_ctl.arc.lastError])); - return out; - } - - /// 압축 안의 것을 꺼내기 - 86 의 풀기 창 (검사 · 갈 곳 · 같은 이름) 그대로 - Future _extractFromArchive(List paths, String? otherDir) async { - final arc = _archiveOf(paths.firstWhere(_insideArchive))!; - final names = [for (final x in paths) if (_insideArchive(x) && samePath(_archiveOf(x)!, arc)) _ctl.arc.innerName(arc, x)]; - final done = await extractArchiveFlow(context, c, arc, otherDir: otherDir, names: names); - if (done != null && mounted) await _ctl.refreshAll([done]); - } - - /// 관리자 지시 (10/21, 28 지적): 시스템 폴더 · 루트 (shz://) 가 원본이나 받는 곳인 이동은 시작하지 않는다 - - /// 복사는 되고 원본 지우기에서 오류로 끝나던 것 (끌어 놓기 · 붙여넣기 · 버튼 · 메뉴 모두 여기를 지남). 막았으면 true - bool _blockShizukuMove(List sources, String? dest, {required bool move}) { - if (!move || !(sources.any(isShizuku) || (dest != null && isShizuku(dest)))) return false; - _snack(tr('읽기 전용이라 옮길 수 없습니다 - 복사를 쓰세요')); - return true; - } - - /// 압축 안을 바꾸려 하면 막고 알린다 (읽기 전용). 막았으면 true - /// [self] 면 압축 파일 자신도 (그 안으로 넣는 곳일 때) - @override - bool blockArchive(List paths, {bool self = false}) { - // X-plore 비교 5-2: 시스템 폴더 (Shizuku) 도 읽기 전용 - 쓰기 · 지우기 · 이름 바꾸기 · 옮기기 · 새 폴더 · 넣기를 막는다 (꺼내기만) - if (paths.any(isShizuku)) { - _snack(paths.any(isRootPath) - ? tr('루트 폴더는 읽기 전용입니다 (꺼내기 (복사) 만 할 수 있습니다)') - : tr('시스템 폴더 (Shizuku) 는 읽기 전용입니다 (꺼내기 (복사) 만 할 수 있습니다)')); - return true; - } - if (!paths.any((x) => _insideArchive(x) || (self && _ctl.arc.contains(x)))) return false; - _snack(tr('압축 안은 읽기 전용입니다 (꺼내기만 할 수 있습니다)')); - return true; - } - /// X-plore 비교 a: 한 창인데 "숨은 다른 창으로" 복사 · 이동 (위에 갈 곳 줄) bool get _hiddenTarget => !widget.rsync && !_dual && !_split && c.settings.explorerOnePaneCopy == 'other'; @@ -1573,37 +956,11 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex ); } - /// 기능의 대상: 표시한 항목, 없으면 마지막으로 누른 항목 (저장 장치 맨 위는 빼고) - @override - List targetsOf(ExplorerPane pane) { - if (!pane.sel.isEmpty) return pane.sel.marked; - final f = pane.focused; - if (f == null || samePath(f, pane.root)) return const []; - return [f]; - } - void _snack(String text) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text))); } - @override - Future playFolder(String dir) async { - final List list; - try { - list = (await listEntries(dir)).where((e) => !e.isDir && isVideoFile(e.path)).map((e) => e.path).toList(); - } catch (e) { - _snack(trf('읽을 수 없습니다: {0}', [explainFileError('$e', dav: isDav(dir)).$1])); - return; - } - if (list.isEmpty) { - _snack(tr('이 폴더에 동영상이 없습니다.')); - return; - } - if (!mounted) return; - await play(list); - } - @override Future addMkv(List paths) async { if (paths.any(isDav)) { @@ -1634,8 +991,8 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex @override Future renameItem(ExplorerPane pane, String path) async { - if (blockArchive([path])) return; - final name = await askName(tr('이름 변경'), vBasename(path)); + if (_guard.blockArchive([path])) return; + final name = await askName(context, tr('이름 변경'), vBasename(path)); if (name == null || name == vBasename(path)) return; try { final now = await FileOps.rename(path, name); @@ -1654,7 +1011,7 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex /// 78: 일괄 이름 바꾸기 - 번호는 목록에 보이는 차례대로. 끝나면 [되돌리기] @override Future batchRename(ExplorerPane pane, List paths) async { - if (blockArchive(paths)) return; + if (_guard.blockArchive(paths)) return; int at(String x) { final i = (pane.cache[vDirname(x)] ?? const []).indexWhere((e) => samePath(e.path, x)); return i < 0 ? 1 << 30 : i; @@ -1699,312 +1056,6 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex } } - /// Shift+Delete 로 불렀는지 (휴지통을 거치지 않고 지우기를 미리 골라 둔다 - 65) - @override - bool shiftDelete = false; - - /// 지우기. Windows 의 로컬 파일은 기본으로 휴지통으로 (창에서 "영구 삭제" 를 고르거나 Shift+Delete). - /// WebDAV · Android 는 휴지통이 없어 바로 지운다. 여러 개면 실패해도 나머지를 계속하고, 못 지운 것을 알린다 (65). - @override - Future deleteItems(ExplorerPane pane, List paths) async { - if (blockArchive(paths)) return; - paths = outermostPaths(paths); // 79-c: 고른 폴더 안의 것은 한 번만 (확인 창 개수도) - final shift = shiftDelete; - shiftDelete = false; - if (paths.isEmpty) { - _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 && c.settings.recycleOnDelete && paths.every(hasRecycleBin)) || canTrash; - var permanent = !canRecycle || shift; - // X-plore 숨기기: 지우는 폴더 안에 목록에서 숨긴 것이 있으면 알린다 (숨긴 것도 함께 지워진다) - final hiddenInside = [ - for (final h in c.settings.explorerHiddenItems) - 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}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).', [c.settings.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 && 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) && 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)}']; - _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 && 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 => (ExplorerPage.debugAndroidTrash ?? Platform.isAndroid) && c.settings.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)); - _snack(trf('{0}개를 원래 자리로 되살렸습니다', [back.length])); - } - - /// 시험: [휴지통] 열기 - @visibleForTesting - Future debugOpenTrash() => openTrash(); - - /// X-plore 비교 e: [휴지통] - Android 는 앱 휴지통 화면, Windows 는 시스템 휴지통 - @override - Future openTrash() async { - final trash = _appTrash; - if (trash == null) { - if (Platform.isWindows) { - await Process.start('explorer.exe', ['shell:RecycleBinFolder']); - } else { - _snack(tr('휴지통이 꺼져 있습니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).')); - } - return; - } - final back = await Navigator.of(context).push>( - MaterialPageRoute(builder: (_) => TrashPage(trash: trash, days: c.settings.androidTrashDays))); - if (back != null && back.isNotEmpty && mounted) await _ctl.refreshAll(back.map(p.dirname)); - } - - /// 148: 방금 휴지통으로 보낸 것을 원래 자리로 (원래 자리에 같은 이름이 생겼으면 그것은 두고 알림) - Future _undoRecycle(List 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(); - } - _snack(joinSentences([ - if (back > 0) trf('{0}개 항목을 되돌렸습니다.', [back]), - if (failed.isNotEmpty) trf('되돌리지 못함: {0}', [failed.take(3).join(' · ')]), - ])); - } - - /// 다른 창의 지금 폴더로 복사 · 이동 (진행 창 · 취소) - /// 복사 · 이동 버튼: 두 창이면 다른 창의 지금 폴더로, 그 밖의 배치는 담아 두기 (붙여넣기로) - @override - Future transfer(ExplorerPane pane, List sources, {required bool move}) async { - if (_blockShizukuMove(sources, null, move: move)) return; - // X-plore 비교 f: 압축 안의 것 - 복사는 꺼내기 (86 의 풀기 검사 그대로), 이동은 막음 - if (sources.any(_insideArchive)) { - if (move) { - blockArchive(sources); - return; - } - await _extractFromArchive(sources, _other?.current); - return; - } - sources = outermostPaths(sources); // 79-c: 고른 폴더 안의 것은 한 번만 - final other = _other; - if (other == null) { - _toClipboard(sources, move: move); - return; - } - await _runTransfer(pane, sources, other.current, move: move); - } - - /// 복사 · 이동할 항목 (붙여넣기 전까지 담아 둠) - FileClipboard (E5b) - final _clip = FileClipboard(); - - /// 154: 고른 것이 없을 때 (복사 · 이동이 같은 말) - String _pickFirst(bool move) => move - ? tr('옮길 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).') - : tr('복사할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).'); - - void _toClipboard(List sources, {required bool move}) { - if (_blockShizukuMove(sources, null, move: move)) return; - if (sources.isEmpty) { - _snack(_pickFirst(move)); - return; - } - setState(() => _clip.put(sources, move: move)); - _snack(trf('{0} 을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).', - [namesOf(sources), move ? tr('이동') : tr('복사')])); - } - - @override - Future paste(ExplorerPane pane, String dest) async { - if (_clip.isEmpty) { - _snack(tr('붙여넣을 항목이 없습니다. 먼저 [복사] · [이동] 으로 담으세요.')); - return; - } - final done = await _runTransfer(pane, _clip.items, dest, move: _clip.move); - // 옮긴 것은 다시 붙여넣을 수 없으므로 비운다 (복사는 여러 곳에 붙여넣을 수 있게 남김) - if (mounted) setState(() => _clip.pasted(done: done)); - } - - /// 지금 도는 복사 · 이동 (아래에서 올라오는 진행 막대) - /// (Rsync 화면의 ⇄ 는 둘이 함께) - List _jobs = const []; - void _onGoToRequest() { final dir = ExplorerPage.goToRequest.value; if (dir == null || !_ctl.ready || !mounted) return; @@ -2012,422 +1063,6 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex unawaited(_ctl.goTo(_ctl.pane, dir, fresh: true).then((_) => _reveal(_ctl.pane, dir))); } - /// 52: 이 화면을 떠난 사이에 시작된 (또는 앞의 화면이 시작한) 작업을 다시 보이고, 끝나면 새로 읽고 막대를 내린다 - /// X-plore 숨기기: 옮긴 것은 숨긴 목록도 새 자리로 (원래 자리에 없고 새 자리에 있는 것만 - 같은 이름 바꾸기 · 실패는 그대로) - Future _followMovedJob(TransferJob job) async { - if (!job.move || job.contents || c.settings.explorerHiddenItems.isEmpty) return; - final moved = <(String, String)>[]; - for (final s in job.sources) { - final to = vJoin(job.dest, vBasename(s)); - if (!await vExists(s) && await vExists(to)) moved.add((s, to)); - } - await _ctl.followHidden(moved: moved); - } - - void _adoptJob(TransferJob job) { - if (_jobs.contains(job)) return; - setState(() => _jobs = [..._jobs, job]); - unawaited(job.done.then((_) async { - if (!mounted) return; - await _followMovedJob(job); - if (!mounted) return; - await _ctl.refreshAll([job.dest, ...job.sources.map(vDirname)]); - for (final x in _ctl.panes) { - x.changed(); - } - await Future.delayed(const Duration(milliseconds: 1200)); - if (mounted && _jobs.contains(job)) setState(() => _jobs = [for (final j in _jobs) if (j != job) j]); - })); - } - - /// 한쪽이라도 WebDAV 면 rsync 대신 앱이 직접 맞춘다 (rsync 는 WebDAV 를 모름) - bool _viaDav(Iterable paths) => paths.any(isDav); - - /// rsync 가 없으면 (Windows · 내려받기 설정) 지금 내려받을지 묻는다. 쓸 수 있는 rsync 경로 (없으면 null) - Future _ensureRsync() async { - final s = c.settings; - final have = await rsyncExecutable(s); - if (have != null) return have; - if (!mounted) return null; - if (s.rsyncSource == 'custom' || !Platform.isWindows) { - _snack(tr('rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > 파일 탐색기에서 경로를 확인하세요.')); - return null; - } - return installRsyncWithDialog(context); - } - - /// [dest] 로 복사 · 이동 (확인 · 아래 진행 막대 · 취소). 끝까지 했으면 true. - Future _runTransfer(ExplorerPane pane, List sources, String dest, - {required bool move, void Function(TransferJob job)? onFinished}) async { - if (sources.isEmpty) { - _snack(_pickFirst(move)); - return false; - } - if (_blockShizukuMove(sources, dest, move: move)) return false; - // X-plore 비교 f: 압축 안으로는 넣지 않고 (읽기 전용), 압축 안의 것을 붙여넣으면 꺼내기로 - if (blockArchive([dest], self: true)) return false; - if (sources.any(_insideArchive)) { - if (move) return !blockArchive(sources); - await _extractFromArchive(sources, dest); - return true; - } - // 23: 앞의 복사 · 이동이 끝나지 않아도 함께 시작한다 (아래 진행 막대에 하나씩). 같은 것을 같은 곳으로만 막는다 - if (_jobs.any((j) => !j.finished && samePath(j.dest, dest) && j.sources.toSet().containsAll(sources))) { - _snack(tr('같은 항목을 같은 곳으로 복사 · 이동하는 중입니다.')); - return false; - } - // 135: 같은 폴더로 - 짧은 알림은 놓치기 쉬워 창으로. 이동은 할 것이 없고, 복사는 사본을 만들지 한 번 묻는다 (계획은 transfer_plan) - final plan = planTransfer(sources, dest, move: move); - sources = plan.sources; - final skippedHere = plan.skippedHere, here = plan.here; - var sameFolderCopy = false; - if (plan.nothingToMove) { - // 알림만 ([확인] 에 첫 초점 - Enter 로 닫기) - await showDialog( - 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)]))); - if (!ok || !mounted) return false; - sameFolderCopy = true; // 이미 물었으니 아래 확인 창은 건너뛴다 - } - // 원본 · 대상이 없거나 (다른 곳에서 지움 - 목록을 새로 고침) 폴더를 자기 안으로 넣으려 하면 시작하지 않는다 - final problem = plan.problem; - if (problem != null) { - _snack(problem); - pane.sel.removeWhere(vMissingSync); - await _ctl.refreshAll([dest, ...sources.map(p.dirname)]); - for (final x in _ctl.panes) { - x.changed(); - } - return false; - } - // 파일 탐색기: 현재 방식 · robocopy (rsync 는 Rsync 화면 - 그쪽만 모니터링에 남는다) - final center = CopyCenter.of(c); - final task = center.fresh(sources, dest, move: move); - final method = CopyMethod.of(task.method); - // 48: 받는 폴더에 같은 이름이 있으면 덮어쓰기 · 건너뛰기 · 이름 바꾸기를 고른다 - // (앱이 직접 할 때만 - robocopy · rsync 는 그 프로그램의 규칙. 같은 폴더로 복사는 늘 새 이름) - final builtin = method == CopyMethod.builtin || _viaDav([...sources, dest]); - // 확인 창은 바로 띄우고, 같은 이름은 창 안에서 뒤에서 찾는다 (느린 네트워크 · WebDAV 폴더라도 창이 늦게 뜨지 않게). - // 다 찾기 전에 누르면 이름 바꾸기 (원래 파일을 건드리지 않는 쪽) - final clashesFuture = builtin ? findClashes(sources, dest) : Future.value(const []); - // 48: 환경 설정 "같은 이름이 있을 때" 가 늘 하는 것이면 묻지 않고 그대로 (확인 창에 알림만) - final always = switch (c.settings.copyConflict) { - 'rename' => NameConflict.rename, - 'overwrite' => NameConflict.overwrite, - 'skip' => NameConflict.skip, - _ => null, - }; - var conflict = always ?? NameConflict.rename; - // U4 (점검 10/22, 관리자 결정): 다른 창의 표시는 그대로 두고 이번 대상이 아님을 알린다 - 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 go = sameFolderCopy - ? true - : await confirmDialog( - context, - title: () => move ? tr('이동') : tr('복사'), - confirmLabel: () => move ? tr('이동') : tr('복사'), - danger: () => conflict == NameConflict.overwrite, - 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\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])}' : ''}'), - // 178 보충: 환경 설정의 원본 지우기 옵션은 복사에서 뺀다 - 왜 빠졌는지 알림 - if (!move && method != CopyMethod.builtin && optionsRemoveSource(task.options)) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - trf('환경 설정의 {0} 는 복사에서는 쓰지 않습니다 (이동은 [이동] 으로)', - [splitOptions(task.options).where(isSourceRemovingOption).join(' ')]), - style: TextStyle(fontSize: 12, color: JjColors.textDim)), - ), - FutureBuilder>( - future: clashesFuture, - builder: (ctx, snap) { - if (snap.connectionState != ConnectionState.done) { - return Padding( - padding: const EdgeInsets.only(top: 12), - child: Text(tr('같은 이름이 있는지 확인하는 중 …'), style: TextStyle(fontSize: 12, color: JjColors.textDim)), - ); - } - final clashes = snap.data ?? const []; - 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])]), - style: TextStyle(color: JjColors.warning)), - // 172: 두 파일의 크기 · 바뀐 날 (3개까지) - for (final x in clashes.take(3)) ...[ - Padding( - padding: const EdgeInsets.only(top: 4, left: 8), - child: Text(clashCompareText(x), style: const TextStyle(fontSize: 12)), - ), - // 229: 폴더를 합치면 안쪽에서 받는 쪽이 더 새 파일 - 건너뛰는지 (환경 설정) 미리 - if (x.there.isDir && !isDav(x.src) && !isDav(dest)) - FutureBuilder( - future: FileOps.countNewerInside(x.src, vJoin(dest, vBasename(x.src))), - builder: (_, n) => Padding( - padding: const EdgeInsets.only(left: 20), - child: Text( - n.connectionState != ConnectionState.done - ? tr('안쪽 파일을 확인하는 중 …') - : (n.data ?? 0) == 0 - ? '' - : c.settings.copySkipNewer - ? trf('안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)', [n.data]) - : trf('안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다', [n.data]), - style: TextStyle(fontSize: 12, color: JjColors.warning), - ), - ), - ), - ], - if (always != null) - Text( - trf('환경 설정대로 {0} (환경 설정 > 파일 탐색기 > 같은 이름이 있을 때)', [ - switch (always) { - NameConflict.rename => tr('이름을 바꿉니다'), - NameConflict.overwrite => tr('덮어씁니다'), - NameConflict.skip => tr('건너뜁니다'), - } - ]), - style: const TextStyle(fontSize: 12)) - else - RadioGroup( - groupValue: conflict, - onChanged: (x) => setInner(() => conflict = x ?? conflict), - child: Column(mainAxisSize: MainAxisSize.min, children: [ - for (final (v, label) in [ - (NameConflict.rename, tr('이름 바꾸기 (예: 이름 (2))')), - (NameConflict.overwrite, tr('덮어쓰기 (원래 파일은 없어집니다)')), - (NameConflict.skip, tr('건너뛰기')), - ]) - RadioListTile(dense: true, contentPadding: EdgeInsets.zero, value: v, title: Text(label)), - ]), - ), - ]); - }, - ), - if (otherMarks > 0) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text(trf('다른 창에 {0}개 표시됨 (이번 대상 아님)', [otherMarks]), - style: TextStyle(fontSize: 12, color: JjColors.textDim)), - ), - ]), - ); - if (go != true || !mounted) return false; - // 104: 설정의 옵션에 지우기 (robocopy /MIR · /PURGE 등) 가 있으면 미리 보기를 거친다 - if (!await confirmDeletingRun(context, task) || !mounted) return false; - String? rsync; - if (method == CopyMethod.rsync && !_viaDav([...sources, dest])) { - rsync = await _ensureRsync(); - if (rsync == null || !mounted) return false; - } - final job = await center.start(task, rsyncExe: rsync, conflict: conflict); - if (job == null || !mounted) return false; - setState(() => _jobs = [..._jobs.where((j) => !j.finished), job]); - await job.done; - final error = job.error; - await _followMovedJob(job); - if (!mounted) return false; - // 180: 표시만 지우고 선택 모드는 그대로 (꺼지면 동그라미 칸 · 아래 막대가 사라져 자리가 바뀐다) - pane.sel.clear(); - await _ctl.refreshAfterTransfer(sources, dest); - // 넣은 폴더가 창의 지금 폴더면 펼쳐서 새 항목이 보이게 - for (final x in _ctl.panes) { - if (samePath(x.current, dest)) { - x.expanded.add(dest); - await _ctl.load(x, dest, force: true); - } - x.changed(); - } - final doneText = error is FileOpCancelled - ? tr('취소했습니다.') - : error != null - ? trf('끝나지 못했습니다: {0}', [error]) - : joinSentences([ - trf('{0}개 항목을 {1}', [job.made.length, move ? tr('옮겼습니다.') : tr('복사했습니다.')]), - // 관리자 지시 (10/21, 15 찾음 F3): 뒤 조각도 문장으로 쓰고 빈칸으로 잇는다 (예전에는 "…옮겼습니다. · 1개는 …" 처럼 마침표 뒤에 " · ") - // 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)]), - // M1 (10/19): 원격 맞추기에서 받는 쪽의 같은 이름 폴더는 --delete 없이 지우지 않는다 - 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) { - // U2 (점검 10/22): 끝 알림은 정해진 시간 뒤 닫힘 - 아래 표시 줄을 가리지 않게 - showNotice(ScaffoldMessenger.of(context), - text: doneText, - action: ( - tr('폴더 열기'), - () { - final show = _ctl.panes.where((x) => samePath(x.current, dest)).firstOrNull ?? _ctl.pane; - setState(() => _ctl.active = _ctl.panes.indexOf(show)); - unawaited(_ctl.goTo(show, dest, fresh: true)); - } - )); - } else { - _snack(doneText); - } - // 82: 끌어 놓기의 이동 되돌리기 (끝난 알림 바로 뒤에 - 진행 막대가 내려가기를 기다리지 않게) - if (error == null && mounted) onFinished?.call(job); - // 다 되면 진행 막대가 잠깐 100% 를 보인 뒤 내려간다 - await Future.delayed(const Duration(milliseconds: 1200)); - if (mounted && _jobs.contains(job)) setState(() => _jobs = [for (final j in _jobs) if (j != job) j]); - return error == null; - } - - /// Rsync 화면: 고른 왼쪽 · 오른쪽 폴더로 rsync. [right] 왼쪽 → 오른쪽, [left] 오른쪽 → 왼쪽 (둘 다면 함께). - /// 폴더 "안의 것" 을 맞춘다 (원본/ → 대상/). 함께 할 때는 받는 쪽이 더 새 파일은 건너뛴다 (-u). - /// 실행한 것은 모니터링에 남는다 (같은 것을 다시 하면 기억한 옵션으로). - /// 확인 창이 떠 있는 동안 또 누르면 무시 (창이 겹쳐 아래 창을 잘못 누르지 않게 - 110) - bool _rsyncAsking = false; - - @override - Future runRsync({required bool right, required bool left}) async { - if (_rsyncAsking) return; - _rsyncAsking = true; - try { - await _runRsyncInner(right: right, left: left); - } finally { - _rsyncAsking = false; - } - } - - Future _runRsyncInner({required bool right, required bool left}) async { - final l = _ctl.panes[0].sel.marked.firstOrNull, r = _ctl.panes[1].sel.marked.firstOrNull; - // 막는 조건 · 작업 · 옵션 · 미리 보기 · 실행은 RsyncRun (E5c), 확인 창은 rsync_run_dialog - final (:run, :problem, :missing) = RsyncRun.prepare(CopyCenter.of(c), - l: l, - r: r, - right: right, - left: left, - allowInnerToOuter: c.settings.allowInnerToOuter, - jobs: _jobs, - viaDav: l != null && r != null && _viaDav([l, r]), - copySkipNewer: c.settings.copySkipNewer); - if (run == null) { - _snack(problem!); - if (missing) { - for (final x in _ctl.panes) { - x.sel.removeWhere(vMissingSync); - } - await _ctl.refreshAll([vDirname(l!), vDirname(r!)]); - } - return; - } - if (!mounted) return; - final go = await showRsyncRunDialog(context, run); - // 110: 무엇을 골랐는지 작업 기록에 (실행 · 취소 · 닫힘) - c.note(trf('Rsync 확인 창 ({0}): {1}', [ - run.both ? '⇄' : right ? '→' : '←', - go == true ? tr('실행') : go == false ? tr('취소') : tr('닫힘 (실행 안 함)'), - ])); - if (go != true || !mounted) return; - if (run.nestedDelete()) { - _snack(RsyncRun.nestedDeleteMessage); - return; - } - final exe = run.viaDav ? '' : await _ensureRsync(); - if (exe == null || !mounted) return; - final res = await run.execute( - rsyncExe: exe, - alive: () => mounted, - note: c.note, - onStarted: (j) => setState(() => _jobs = [..._jobs.where((x) => !x.finished), j])); - // 205: 시작하지 못하면 이유를 알린다 - if (res.notStarted != null && mounted) _snack(trf('시작하지 못했습니다: {0}', [res.notStarted])); - final jobs = res.jobs; - if (jobs.isEmpty || !mounted) return; - // 원본 폴더까지 지웠으면 고른 표시도 뺀다 - for (final x in _ctl.panes) { - x.sel.removeWhere(vMissingSync); - } - await _ctl.refreshAll([run.l, run.r, vDirname(run.l), vDirname(run.r)]); - for (final x in _ctl.panes) { - x.changed(); - } - final error = jobs.map((j) => j.error).whereType().firstOrNull; - _snack(error is FileOpCancelled - ? tr('취소했습니다.') - : error != null - ? trf('끝나지 못했습니다: {0}', [error]) - // 212 · 216: 이동인지 · 몇 개인지 · 원본에 남은 것 - : jobs.map((j) => j.doneSummary()).join('\n')); - await Future.delayed(const Duration(milliseconds: 1200)); - if (mounted) setState(() => _jobs = [for (final j in _jobs) if (!jobs.contains(j)) j]); - } - - @override - Future askName(String title, String initial, {String? hint, bool selectAll = false}) { - final ctl = TextEditingController(text: initial); - final dot = initial.lastIndexOf('.'); - ctl.selection = TextSelection(baseOffset: 0, extentOffset: dot > 0 && !selectAll ? dot : initial.length); - return showDialog( - context: context, - builder: (ctx) => AlertDialog( - scrollable: true, - title: Text(title), - content: TextField( - controller: ctl, - autofocus: true, - decoration: InputDecoration(hintText: hint ?? tr('이름')), - onSubmitted: (v) => Navigator.pop(ctx, v.trim().isEmpty ? null : v.trim()), - ), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('취소'))), - FilledButton( - onPressed: () => Navigator.pop(ctx, ctl.text.trim().isEmpty ? null : ctl.text.trim()), - child: Text(tr('확인'))), - ], - ), - ); - } - - Future _info(FileEntry e) async { - final size = e.isDir ? await FileOps.totalSize([e.path]) : e.size; - final meta = !e.isDir && !isDav(e.path) && isVideoFile(e.path) ? await VideoMeta.of(c, e.path) : null; - // 관리자 지시 10/18: 만든 때 · 숨김 · 읽기 전용 · 시스템 (보기만, 이 기기의 것만) - final attrs = isDav(e.path) ? null : readFileAttributes(e.path); - if (!mounted) return; - await showDialog( - context: context, - builder: (ctx) => AlertDialog( - scrollable: true, - title: Text(e.name), - content: SelectableText([ - e.path, - '${tr('크기')}: ${formatSize(size)}', - if (attrs?.created != null) '${tr('만든 날')}: ${_date(attrs!.created!)}', - if (e.modified.year > 1) '${tr('바뀐 날')}: ${_date(e.modified)}', - if (attrs != null) '${tr('속성')}: ${attrs.labels.isEmpty ? tr('없음') : attrs.labels.join(' · ')}', - if (meta != null && meta.isNotEmpty) '${tr('동영상')}: $meta', - ].join('\n')), - actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('닫기')))], - ), - ); - } - /// 79: 마지막 찾기 조건 (이번 실행 동안 다음 찾기 창의 처음 값) SearchConditions _lastSearch = const SearchConditions(); @@ -2484,7 +1119,7 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex cond: cond, showHidden: c.settings.explorerShowHidden, otherDir: other, - onOpen: (e) => _open(e.path, pane: pane), + onOpen: (e) => _opener.open(context, e.path, pane: pane), initial: initial, initialDone: initialDone, ), @@ -2516,11 +1151,11 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex _reveal(pane, path); // 고른 것만 대상 (154 · 155) - 탐색기의 복사 · 이동 · 지우기와 같은 확인 · 진행 패널 · 휴지통 case 'copy': - await transfer(pane, act.paths, move: false); + await _transfers.transfer(context, pane, act.paths, move: false, otherDir: otherDir); case 'move': - await transfer(pane, act.paths, move: true); + await _transfers.transfer(context, pane, act.paths, move: true, otherDir: otherDir); case 'delete': - await deleteItems(pane, act.paths); + await _deletes.run(context, pane, act.paths); } } @@ -2551,84 +1186,6 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex } /// X-plore 맞추기 (관리자 지시 10/20): [목록에서 숨기기] 전에 무엇이 바뀌는지 알림. [다음부터 묻지 않기] 는 환경 설정에서 다시 켬 - Future _confirmAppHide(List targets) async { - var dontAsk = false; - final ok = await showDialog( - context: context, - builder: (ctx) => StatefulBuilder( - builder: (ctx, set) => AlertDialog( - scrollable: true, - title: Text(targets.length > 1 ? trf('{0}개 목록에서 숨기기', [targets.length]) : tr('목록에서 숨기기')), - content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (targets.length == 1) Text(vBasename(targets.single), style: const TextStyle(fontWeight: FontWeight.bold)), - const SizedBox(height: 6), - Text(tr('이 앱의 파일 탐색기 목록에서만 빠집니다. 파일은 지우거나 옮기거나 이름을 바꾸지 않고, 다른 앱 · 갤러리에서는 그대로 보입니다.')), - const SizedBox(height: 6), - Text(tr('목록 맨 아래의 [숨긴 항목 보이기] 나 환경 설정 > 파일 탐색기에서 다시 볼 수 있습니다.'), - style: TextStyle(fontSize: 12, color: JjColors.textDim)), - CheckboxListTile( - contentPadding: EdgeInsets.zero, - controlAffinity: ListTileControlAffinity.leading, - value: dontAsk, - onChanged: (v) => set(() => dontAsk = v ?? false), - title: Text(tr('다음부터 묻지 않기')), - ), - ]), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), - FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('숨기기'))), - ], - ), - ), - ); - if (ok != true) return false; - if (dontAsk) await c.updateSettings((s) => s.explorerHideConfirm = false); - return true; - } - - /// X-plore 맞추기 (관리자 지시 10/20): 폴더에 빈 .nomedia 파일을 넣거나 지운다 (Android 갤러리 · 음악 앱에서 숨기기 · 다시 보이기). - /// 그 폴더의 파일은 그대로 - 무엇이 바뀌는지 먼저 묻는다. 갤러리에 반영되는 때는 그 앱이 다시 읽을 때다 - Future _nomedia(ExplorerPane pane, String dir, {required bool on}) async { - final ok = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - scrollable: true, - title: Text(on ? tr('갤러리 · 음악 앱에서 숨기기 (.nomedia)') : tr('갤러리 · 음악 앱에 다시 보이기 (.nomedia 지우기)')), - content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(vBasename(dir), style: const TextStyle(fontWeight: FontWeight.bold)), - const SizedBox(height: 6), - Text(on - ? tr('이 폴더에 빈 .nomedia 파일을 만듭니다. 갤러리 · 음악 앱이 이 폴더 (안의 폴더 포함) 의 그림 · 동영상 · 음악을 보이지 않게 됩니다.') - : tr('이 폴더의 .nomedia 파일을 지웁니다. 갤러리 · 음악 앱이 이 폴더의 그림 · 동영상 · 음악을 다시 보이게 됩니다.')), - const SizedBox(height: 6), - Text(tr('폴더 안의 파일은 바꾸지 않습니다. 갤러리에 반영되기까지 잠깐 걸릴 수 있습니다.'), - style: TextStyle(fontSize: 12, color: JjColors.textDim)), - ]), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), - FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(on ? tr('숨기기') : tr('다시 보이기'))), - ], - ), - ); - if (ok != true || !mounted) return; - final f = File(p.join(dir, '.nomedia')); - try { - if (on) { - if (!await f.exists()) await f.writeAsBytes(const []); - } else if (await f.exists()) { - await f.delete(); - } - } catch (err) { - _snack(trf('바꾸지 못했습니다: {0}', [err])); - return; - } - // 관리자 지시 10/20: 갤러리가 바로 알도록 .nomedia 와 그 폴더를 다시 읽게 (넣을 때 · 지울 때 모두, 실패해도 조용히) - await c.services.shell.mediaScan([f.path, dir]); - await _ctl.refreshAll([dir]); - pane.changed(); - _snack(on ? tr('갤러리 · 음악 앱에서 숨겼습니다 (.nomedia). 파일은 그대로입니다.') : tr('.nomedia 를 지웠습니다. 갤러리 · 음악 앱에 다시 보입니다.')); - } - @override Future sortDialog() async { // 84: "폴더마다 기억" 이면 지금 창의 폴더 정렬 @@ -2958,7 +1515,7 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex child: _body()) : _body()), // 복사 · 이동 진행: 아래에서 올라오고 끝나면 내려간다 - Positioned(left: 8, right: 8, bottom: 0, child: ExplorerTransferPanel(c: c, jobs: _jobs, rsync: widget.rsync)), + Positioned(left: 8, right: 8, bottom: 0, child: ExplorerTransferPanel(c: c, jobs: _jobs.list, rsync: widget.rsync)), ]), ), ]); @@ -3102,13 +1659,13 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex panesSide: side, onPressed: _buttons.press, enabled: (b) => switch (b) { - ExplorerButton.paste => _clip.isNotEmpty, + ExplorerButton.paste => _transfers.clip.isNotEmpty, _ => true, }, selected: (b) => (b == ExplorerButton.hidden && s.explorerShowHidden) || (b == ExplorerButton.select && _sel.selecting), twoPanes: dual || split, - pinned: {ExplorerButton.orient, if (_clip.isNotEmpty) ExplorerButton.paste}, + pinned: {ExplorerButton.orient, if (_transfers.clip.isNotEmpty) ExplorerButton.paste}, ); final middle = bar != null && (widget.rsync || s.explorerToolbar == 'middle'); final edge = bar != null && !widget.rsync && s.explorerToolbar == 'edge'; @@ -3240,12 +1797,12 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex Future _onDrop(DragLoad load, int to, String dest) async { if (!canDropInto(load, to, dest) || !mounted) return; // X-plore 비교 f: 압축 안으로는 놓지 않고, 압축 안의 것은 꺼내기로 - if (_insideArchive(dest) || _ctl.arc.contains(dest)) { + if (_guard.insideArchive(dest) || _ctl.arc.contains(dest)) { _snack(tr('압축 안은 읽기 전용입니다 (꺼내기만 할 수 있습니다)')); return; } - if (load.paths.any(_insideArchive)) { - await _extractFromArchive(load.paths, dest); + if (load.paths.any(_guard.insideArchive)) { + await _guard.extractFrom(context, load.paths, dest); return; } setState(() => _ctl.active = to); @@ -3261,7 +1818,7 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex } } if (!mounted) return; - await _runTransfer(_ctl.panes[load.from], load.paths, dest, + await _transfers.run(context, _ctl.panes[load.from], load.paths, dest, move: move, onFinished: move ? (job) => _offerUndoMove(load, job, existed) : null); } @@ -3320,7 +1877,7 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex if (!await vExists(dir)) await vMkdirs(dir); } catch (_) {} if (!mounted) return; - await _runTransfer(pane, there, dir, move: true); + await _transfers.run(context, pane, there, dir, move: true); } } @@ -3364,16 +1921,16 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex 'delete': () => _buttons.press(ExplorerButton.delete), // 65: Shift+Delete = 휴지통을 거치지 않고 (창에서 한 번 더 확인) 'deletePermanent': () { - shiftDelete = true; + _deletes.shiftDelete = true; _buttons.press(ExplorerButton.delete); }, 'rename': () => _buttons.press(ExplorerButton.rename), 'refresh': () => _buttons.press(ExplorerButton.refresh), // 81: 빠른 보기 - 'quickLook': () => _quickLook(_ctl.pane), - 'clipCopy': () => _toClipboard(targetsOf(_ctl.pane), move: false), - 'clipCut': () => _toClipboard(targetsOf(_ctl.pane), move: true), - 'paste': () => paste(_ctl.pane, _ctl.pane.current), + 'quickLook': () => quickLook(_ctl.pane), + 'clipCopy': () => _transfers.toClipboard(_sel.targets(_ctl.pane), move: false), + 'clipCut': () => _transfers.toClipboard(_sel.targets(_ctl.pane), move: true), + 'paste': () => _transfers.paste(context, _ctl.pane, _ctl.pane.current), 'selectAll': () { if (!_sel.selecting) setState(() => _sel.selecting = true); _sel.markWhere(_ctl.pane, _ctl.pane.current, (_) => true); @@ -3403,7 +1960,8 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex } /// 81: 빠른 보기 - 지금 줄 (없으면 아무것도 안 함). 같은 폴더에 보이는 항목을 ← → 로 - Future _quickLook(ExplorerPane pane, [String? path]) async { + @override + Future quickLook(ExplorerPane pane, [String? path]) async { final f = path ?? pane.focused; if (f == null || samePath(f, pane.root)) return; final list = _sel.shown(pane, vDirname(f)); @@ -3417,7 +1975,7 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex probe: c.services.mediaTool.probe, thumbnail: (e) => BigVideoThumbs.of(c, e), imageExts: c.settings.imageExts, - onOpen: (e) => e.isDir ? unawaited(_ctl.goTo(pane, e.path)) : unawaited(_open(e.path, pane: pane)), + onOpen: (e) => e.isDir ? unawaited(_ctl.goTo(pane, e.path)) : unawaited(_opener.open(context, e.path, pane: pane)), ), ); } @@ -3472,60 +2030,3 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex } -/// 203: 바뀐 날을 모름 (잠긴 시스템 파일은 1970-01-01 로 온다) 은 "—" -String _date(DateTime d) => fileDateText(d); - - -/// 77: 길게 누르기 메뉴 위의 아이콘 줄 - (id, 아이콘, 짧은 이름, 풍선 도움말, 누를 수 있음). -/// 폰에서도 무엇인지 알 수 있게 아이콘 아래에 짧은 이름 -class _MenuIconRow extends PopupMenuEntry { - final List<(String, IconData, String, String, bool)> items; - const _MenuIconRow(this.items); - - @override - double get height => 60; - - @override - bool represents(String? value) => false; - - @override - State<_MenuIconRow> createState() => _MenuIconRowState(); -} - -class _MenuIconRowState extends State<_MenuIconRow> { - @override - Widget build(BuildContext context) => Padding( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), - child: Row(mainAxisSize: MainAxisSize.min, children: [ - for (final (id, icon, label, tip, on) in widget.items) - Tooltip( - message: tip, - child: InkWell( - borderRadius: BorderRadius.circular(6), - onTap: on ? () => Navigator.pop(context, id) : null, - child: Semantics( - button: true, - enabled: on, - label: tip, - excludeSemantics: true, - child: SizedBox( - width: 56, - height: 56, - child: Opacity( - opacity: on ? 1 : 0.38, - child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(icon, size: 20), - const SizedBox(height: 2), - FittedBox( - fit: BoxFit.scaleDown, - child: Text(label, maxLines: 1, style: const TextStyle(fontSize: 11)), - ), - ]), - ), - ), - ), - ), - ), - ]), - ); -} diff --git a/test/explorer_buttons_contract_test.dart b/test/explorer_buttons_contract_test.dart index d30b5cd..75db542 100644 --- a/test/explorer_buttons_contract_test.dart +++ b/test/explorer_buttons_contract_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; @@ -8,14 +9,20 @@ import 'package:jj_mkvmaker/core/file_ops.dart' show samePath; import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/explorer/delete_flow.dart'; import 'package:jj_mkvmaker/ui/explorer/explorer_buttons.dart'; import 'package:jj_mkvmaker/ui/explorer/explorer_controller.dart'; import 'package:jj_mkvmaker/ui/explorer/explorer_host.dart'; +import 'package:jj_mkvmaker/ui/explorer/explorer_jobs.dart'; import 'package:jj_mkvmaker/ui/explorer/explorer_pane.dart'; import 'package:jj_mkvmaker/ui/explorer/explorer_select.dart'; import 'package:jj_mkvmaker/ui/explorer/marks_bar.dart'; +import 'package:jj_mkvmaker/ui/explorer/open_flow.dart'; +import 'package:jj_mkvmaker/ui/explorer/rsync_flow.dart'; import 'package:jj_mkvmaker/ui/explorer/toolbar.dart'; +import 'package:jj_mkvmaker/ui/explorer/transfer_flow.dart'; import 'package:jj_mkvmaker/ui/explorer/transfer_panel.dart'; +import 'package:jj_mkvmaker/ui/explorer/write_guard.dart'; import 'package:path/path.dart' as p; /// E5d-4 약속 시험: explorer_buttons · toolbar · marks_bar · transfer_panel · job_view 맨 위 약속 @@ -46,24 +53,53 @@ void main() { ctl.dispose(); }); await t.runAsync(() => ctl.goTo(ctl.panes[0], show)); - return (ExplorerButtons(ctl, sel, host), ctl, sel, host); + final guard = ExplorerGuard(ctl); + final jobs = ExplorerJobs(ctl); + final transfers = ExplorerTransfers(ctl, guard, jobs); + addTearDown(() { + transfers.dispose(); + jobs.dispose(); + }); + final b = ExplorerButtons(ctl, sel, host, + guard: guard, + transfers: transfers, + deletes: ExplorerDeleteFlow(ctl, sel, guard, androidTrash: () => false), + rsync: ExplorerRsyncFlow(ctl, jobs, transfers), + opener: ExplorerOpener(ctl, sel, guard, otherDirOf: (_) => null)); + return (b, ctl, sel, host); } - testWidgets('버튼: 상위 폴더 · 선택 · 새 폴더 (실제로 만들고 그 줄로) · 복사 → 화면의 일 · 길게 = 짝 동작', (t) async { - final (b, ctl, sel, host) = await open(t); + testWidgets('버튼: 상위 폴더 · 선택 · 새 폴더 (실제로 만들고 그 줄로) · 한 창 복사 = 담기 · 길게 = 짝 동작 (영구 삭제 창)', (t) async { + final (b, ctl, sel, _) = await open(t); final q = ctl.pane; await t.runAsync(() => b.press(ExplorerButton.select)); expect(sel.selecting, isTrue); - host.name = 'New'; - await t.runAsync(() => b.press(ExplorerButton.newFolder)); + var made0 = false; + b.press(ExplorerButton.newFolder).then((_) => made0 = true); + await t.pump(); + await t.enterText(find.byType(TextField), 'New'); + await t.tap(find.text('확인')); + for (var i = 0; i < 20 && !made0; i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 30))); + await t.pump(const Duration(milliseconds: 100)); + } final made = p.join(show, 'New'); expect([Directory(made).existsSync(), samePath(q.focused!, made)], [true, true]); - host.targets = [ep1]; + await t.runAsync(() => sel.toggleMark(q, sel.shown(q, show).firstWhere((e) => samePath(e.path, ep1)))); await t.runAsync(() => b.press(ExplorerButton.copy)); + expect([b.transfers.clip.items, b.transfers.clip.move], [ + [ep1], + false + ]); await t.runAsync(() => b.longPress(ExplorerButton.copy)); - await t.runAsync(() => b.longPress(ExplorerButton.delete)); + expect(b.transfers.clip.move, isTrue, reason: '복사 길게 = 이동'); + unawaited(b.longPress(ExplorerButton.delete)); + await t.pump(); + expect([find.text('영구 삭제').evaluate().isNotEmpty, b.deletes.shiftDelete], [true, false]); + await t.tap(find.text('취소')); + await t.pump(); + expect(File(ep1).existsSync(), isTrue, reason: '취소하면 그대로'); await t.runAsync(() => b.longPress(ExplorerButton.refresh)); - expect(host.calls, ['copy $ep1', 'move $ep1', 'delete(영구) $ep1']); await t.runAsync(() => b.press(ExplorerButton.up)); expect(samePath(q.current, root), isTrue); await t.runAsync(() => b.press(ExplorerButton.up)); @@ -140,11 +176,8 @@ class _Host extends StatefulWidget { State<_Host> createState() => _HostState(); } -/// 실제 화면: ExplorerHost · ExplorerCommands 를 이 화면이 맡는다 (화면의 일은 [calls] 에 적기만) +/// 실제 화면: ExplorerHost · ExplorerCommands 를 이 화면이 맡는다 (이 시험이 부르지 않는 화면의 일은 부르면 실패) class _HostState extends State<_Host> implements ExplorerHost, ExplorerCommands { - final calls = []; - List targets = const []; - String? name; Widget Function()? child; /// [build] 로 그리고, [changes] 가 바뀔 때마다 다시 그린다 @@ -165,46 +198,26 @@ class _HostState extends State<_Host> implements ExplorerHost, ExplorerCommands void afterFrame(VoidCallback f) => WidgetsBinding.instance.addPostFrameCallback((_) => f()); @override - bool shiftDelete = false; + String? get otherDir => null; @override - List targetsOf(ExplorerPane pane) => targets; + bool get dual => false; @override - Future askName(String title, String initial, {String? hint, bool selectAll = false}) async => name; - @override - bool blockArchive(List paths, {bool self = false}) => false; - @override - Future transfer(ExplorerPane pane, List sources, {required bool move}) async => - calls.add('${move ? 'move' : 'copy'} ${sources.join(',')}'); - @override - Future deleteItems(ExplorerPane pane, List paths) async { - calls.add('delete${shiftDelete ? '(영구)' : ''} ${paths.join(',')}'); - shiftDelete = false; - } + Future quickLook(ExplorerPane pane, [String? path]) => _no(); Never _no() => throw UnimplementedError('이 시험에서 부르지 않음'); @override - Future runRsync({required bool right, required bool left}) => _no(); - @override - Future play(List paths, {bool internal = false, bool keepOrder = false}) => _no(); - @override - Future playFolder(String dir) => _no(); - @override Future addMkv(List paths) => _no(); @override Future renameItem(ExplorerPane pane, String path) => _no(); @override Future batchRename(ExplorerPane pane, List paths) => _no(); @override - Future paste(ExplorerPane pane, String dest) => _no(); - @override Future openSearch(ExplorerPane pane) => _no(); @override Future openStorage(ExplorerPane pane, String dir) => _no(); @override Future openWifiShare(String dir) => _no(); @override - Future openTrash() => _no(); - @override Future sortDialog() => _no(); @override Future historyDialog(ExplorerPane pane) => _no(); diff --git a/test/explorer_flows_contract_test.dart b/test/explorer_flows_contract_test.dart new file mode 100644 index 0000000..fc76c45 --- /dev/null +++ b/test/explorer_flows_contract_test.dart @@ -0,0 +1,230 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/core/file_ops.dart' show FileEntry; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/explorer/delete_flow.dart'; +import 'package:jj_mkvmaker/ui/explorer/explorer_buttons.dart'; +import 'package:jj_mkvmaker/ui/explorer/explorer_controller.dart'; +import 'package:jj_mkvmaker/ui/explorer/explorer_host.dart'; +import 'package:jj_mkvmaker/ui/explorer/explorer_jobs.dart'; +import 'package:jj_mkvmaker/ui/explorer/explorer_pane.dart'; +import 'package:jj_mkvmaker/ui/explorer/explorer_select.dart'; +import 'package:jj_mkvmaker/ui/explorer/item_actions.dart'; +import 'package:jj_mkvmaker/ui/explorer/item_menu.dart'; +import 'package:jj_mkvmaker/ui/explorer/open_flow.dart'; +import 'package:jj_mkvmaker/ui/explorer/transfer_flow.dart'; +import 'package:jj_mkvmaker/ui/explorer/write_guard.dart'; +import 'package:jj_mkvmaker/ui/text_viewer.dart'; +import 'package:path/path.dart' as p; + +/// E5d-5 약속 시험: write_guard · explorer_jobs · transfer_flow · delete_flow · open_flow · item_menu · item_actions 맨 위 약속 +/// (실제 시험 폴더 · 실제 화면 · 실제 복사 · 실제 지우기. 다른 앱으로 여는 길은 이 PC 의 앱을 열므로 보지 않는다) +void main() { + late Directory tmp; + late String root, show, out, ep1, note; + setUp(() { + tmp = Directory.systemTemp.createTempSync('jj_flow_ui_'); + root = tmp.path; + show = p.join(root, 'Show'); + out = p.join(root, 'Out'); + Directory(show).createSync(); + Directory(out).createSync(); + ep1 = p.join(show, 'ep1.mkv'); + note = p.join(show, 'note.txt'); + File(ep1).writeAsStringSync('1'); + File(note).writeAsStringSync('글'); + }); + tearDown(() => tmp.deleteSync(recursive: true)); + + /// 실제 I/O · 창을 기다린다 + Future settle(WidgetTester t, [bool Function()? until]) async { + for (var i = 0; i < 30 && !(until?.call() ?? false); i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 30))); + await t.pump(const Duration(milliseconds: 100)); + } + } + + Future<_Flows> open(WidgetTester t) async { + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + final key = GlobalKey<_HostState>(); + await t.pumpWidget(MaterialApp(home: _Host(key: key))); + final host = key.currentState!; + final ctl = ExplorerController(c, rsync: false, host: host)..local = [(root, 'T:')]; + final sel = ExplorerSelect(ctl, clock: DateTime.now, openFile: (_, _) async {}); + final guard = ExplorerGuard(ctl); + final jobs = ExplorerJobs(ctl); + final transfers = ExplorerTransfers(ctl, guard, jobs); + final deletes = ExplorerDeleteFlow(ctl, sel, guard, androidTrash: () => false); + final opener = ExplorerOpener(ctl, sel, guard, otherDirOf: (_) => null); + addTearDown(() { + transfers.dispose(); + jobs.dispose(); + sel.dispose(); + ctl.dispose(); + }); + await t.runAsync(() => ctl.goTo(ctl.panes[0], show)); + final menu = ExplorerItemMenu(ctl, sel, host, guard: guard, transfers: transfers, deletes: deletes, opener: opener, nomedia: () => false); + return _Flows(host, ctl, sel, guard, jobs, transfers, deletes, opener, menu); + } + + testWidgets('막기: 시스템 폴더 · 루트는 읽기 전용 (까닭 알림) · 시스템 폴더로 옮기기 막음 · 보통 폴더는 그대로', (t) async { + final f = await open(t); + expect([f.guard.blockArchive([ep1]), f.guard.blockShizukuMove([ep1], out, move: true)], [false, false]); + expect(f.guard.blockArchive(['shz://sys/a']), isTrue); + expect(f.guard.blockShizukuMove(['shz://sys/a'], out, move: true), isTrue); + expect(f.guard.blockShizukuMove(['shz://sys/a'], out, move: false), isFalse, reason: '복사 (꺼내기) 는 됨'); + await t.pump(); + expect(find.textContaining('읽기 전용'), findsWidgets); + }); + + testWidgets('옮기기: 한 창이면 담기 → 붙여넣기 = 실제 복사 (확인 창 → 진행 목록 → 끝나면 내려감)', (t) async { + final f = await open(t); + final q = f.ctl.pane; + await f.transfers.transfer(f.host.context, q, [], move: false); + await t.pump(); + expect(find.text(ExplorerTransfers.pickFirst(false)), findsOneWidget, reason: '고른 것이 없으면 알림'); + await f.transfers.transfer(f.host.context, q, [ep1], move: false); + expect([f.transfers.clip.items, f.transfers.clip.move], [ + [ep1], + false + ]); + var done = false; + unawaited(f.transfers.paste(f.host.context, q, out).then((_) => done = true)); + await settle(t, () => find.text('복사').evaluate().length > 1); + await t.tap(find.widgetWithText(FilledButton, '복사')); + await settle(t, () => f.jobs.list.isNotEmpty); + expect(f.jobs.list.length, 1); + await settle(t, () => done); + expect([done, File(p.join(out, 'ep1.mkv')).existsSync(), File(ep1).existsSync(), f.transfers.clip.isEmpty], [true, true, true, false], + reason: '복사는 여러 곳에 붙여넣게 남김'); + expect(f.jobs.list, isEmpty, reason: '끝나면 잠깐 뒤 내려감'); + }); + + testWidgets('지우기: 영구 삭제 (Shift+Delete) 확인 창 → 실제로 지움 · 표시 정리 · [취소] 는 그대로', (t) async { + final f = await open(t); + final q = f.ctl.pane; + f.deletes.shiftDelete = true; + unawaited(f.deletes.run(f.host.context, q, [note])); + await t.pump(); + await t.tap(find.text('취소')); + await t.pump(); + expect([File(note).existsSync(), f.deletes.shiftDelete], [true, false]); + f.sel.setSelecting(true); + await t.runAsync(() => f.sel.toggleMark(q, FileEntry(note, isDir: false, modified: DateTime(0)))); + f.deletes.shiftDelete = true; + var done = false; + unawaited(f.deletes.run(f.host.context, q, [note]).then((_) => done = true)); + await t.pump(); + await t.tap(find.widgetWithText(FilledButton, '영구 삭제')); + await settle(t, () => done); + expect([File(note).existsSync(), q.sel.isEmpty, f.sel.selecting], [false, true, false]); + }); + + testWidgets('열기: 글 뷰어 (앱 안) · 동영상 없는 폴더 재생은 알림 · 열 앱이 없을 때 알림 글', (t) async { + final f = await open(t); + unawaited(f.opener.viewText(f.host.context, note)); + await settle(t, () => find.byType(TextViewerPage).evaluate().isNotEmpty); + expect(find.byType(TextViewerPage), findsOneWidget); + Navigator.of(f.host.context).pop(); + await t.pump(const Duration(seconds: 1)); + await settle(t); + await t.runAsync(() => f.opener.playFolder(f.host.context, out)); + await t.pump(); + expect(find.text('이 폴더에 동영상이 없습니다.'), findsWidgets); + expect(ExplorerOpener.noAppMessage(ep1), contains('내장 플레이어')); + expect(ExplorerOpener.noAppMessage(note), isNot(contains('내장 플레이어'))); + }); + + testWidgets('줄 메뉴: 파일 항목 · [정보] 창 · 고르지 않으면 아무것도 안 함 · 경로 복사 글', (t) async { + final f = await open(t); + final e = FileEntry(note, isDir: false, modified: DateTime(2026, 10, 22, 9), size: 3); + unawaited(f.menu.show(f.host.context, f.ctl.pane, e, const Offset(10, 10))); + await t.pump(); + await t.pump(const Duration(milliseconds: 300)); + expect([find.text('정보').evaluate().length, find.text('글 뷰어로 보기').evaluate().length, find.text('삭제').evaluate().isNotEmpty], [1, 1, true]); + await t.tap(find.text('정보')); + await settle(t, () => find.text('닫기').evaluate().isNotEmpty); + expect(find.textContaining(note), findsOneWidget); + await t.tap(find.text('닫기')); + await t.pump(const Duration(milliseconds: 300)); + unawaited(f.menu.show(f.host.context, f.ctl.pane, e, const Offset(10, 10))); + await t.pump(const Duration(milliseconds: 300)); + await t.tapAt(const Offset(790, 590)); + await t.pump(const Duration(milliseconds: 300)); + expect([File(note).existsSync(), find.text('정보').evaluate().isEmpty], [true, true]); + expect(copyablePath(f.ctl, note), note, reason: '이 기기의 경로는 그대로'); + }); +} + +class _Flows { + _Flows(this.host, this.ctl, this.sel, this.guard, this.jobs, this.transfers, this.deletes, this.opener, this.menu); + final _HostState host; + final ExplorerController ctl; + final ExplorerSelect sel; + final ExplorerGuard guard; + final ExplorerJobs jobs; + final ExplorerTransfers transfers; + final ExplorerDeleteFlow deletes; + final ExplorerOpener opener; + final ExplorerItemMenu menu; +} + +class _Host extends StatefulWidget { + const _Host({super.key}); + @override + State<_Host> createState() => _HostState(); +} + +/// 실제 화면: ExplorerHost · ExplorerCommands 를 이 화면이 맡는다 (이 시험이 부르지 않는 화면의 일은 부르면 실패) +class _HostState extends State<_Host> implements ExplorerHost, ExplorerCommands { + @override + bool get alive => mounted; + @override + void snack(String text) => ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text))); + @override + void reveal(ExplorerPane pane, String path) {} + @override + void afterFrame(VoidCallback f) => WidgetsBinding.instance.addPostFrameCallback((_) => f()); + + @override + String? get otherDir => null; + @override + bool get dual => false; + + Never _no() => throw UnimplementedError('이 시험에서 부르지 않음'); + @override + Future quickLook(ExplorerPane pane, [String? path]) => _no(); + @override + Future addMkv(List paths) => _no(); + @override + Future renameItem(ExplorerPane pane, String path) => _no(); + @override + Future batchRename(ExplorerPane pane, List paths) => _no(); + @override + Future openSearch(ExplorerPane pane) => _no(); + @override + Future openStorage(ExplorerPane pane, String dir) => _no(); + @override + Future openWifiShare(String dir) => _no(); + @override + Future sortDialog() => _no(); + @override + Future historyDialog(ExplorerPane pane) => _no(); + @override + Future openBookmarks(ExplorerPane pane) => _no(); + @override + Future toggleOrientation() => _no(); + @override + Future layoutDialog() => _no(); + @override + Future buttonsDialog() => _no(); + + @override + Widget build(BuildContext context) => const Scaffold(body: SizedBox()); +}