351 lines
19 KiB
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.copy.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.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)])}'
|
|
'${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.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<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;
|
|
}
|
|
}
|