JJ_MKVMaker/lib/ui/explorer/transfer_flow.dart

351 lines
19 KiB
Dart

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<void> transfer(BuildContext context, ExplorerPane pane, List<String> 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<String> 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<void> 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<String> paths) => paths.any(isDav);
/// rsync 가 없으면 (Windows · 내려받기 설정) 지금 내려받을지 묻는다. 쓸 수 있는 rsync 경로 (없으면 null)
Future<String?> 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<bool> run(BuildContext context, ExplorerPane pane, List<String> 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<void>(
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 <NameClash>[]);
// 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<List<NameClash>>(
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 <NameClash>[];
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<int>(
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<NameConflict>(
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<NameConflict>(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;
}
}