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, runOptionsText; 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.copy.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.copy.conflict) { '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)])}' // F5: 실제로 실행할 옵션 그대로 (robocopy 이동의 /IS /IT /IM · 링크 /XJ · 더 새것 건너뛰기 /XO 까지 - 예전에는 /MOVE 만) '${method == CopyMethod.builtin ? '' : ' · ${runOptionsText(method, task.options, move: move, skipNewer: ctl.c.settings.copy.skipNewer)}'}' '${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.copy.skipNewer ? 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; } }