355 lines
19 KiB
Dart
355 lines
19 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter/services.dart';
|
||
|
||
import '../app/settings.dart' show CopyTask;
|
||
import '../core/file_ops.dart' show formatSize, isPartialFile;
|
||
import '../core/rsync_filter.dart';
|
||
import '../core/sync_preview.dart';
|
||
import '../core/sync_tools.dart';
|
||
import '../core/vfs.dart';
|
||
import '../l10n/tr.dart';
|
||
import 'path_label.dart';
|
||
import 'theme.dart';
|
||
|
||
/// rsync 실행 전 비교 (72): 파일별로 → · ← · 받는 쪽에만 있음 · 지워짐 (빨강). 창이 열리면 바로 센다.
|
||
/// [onResult]: 다 세면 결과 (실행 버튼에 지워질 수를 보여 주려고)
|
||
class SyncPreviewView extends StatefulWidget {
|
||
final Future<List<PreviewItem>> Function() compute;
|
||
final void Function(List<PreviewItem> items)? onResult;
|
||
|
||
/// 비교하지 못함 (원본을 읽지 못함 등)
|
||
final void Function(Object error)? onError;
|
||
|
||
/// 비교를 (다시) 시작할 때 - 예전 결과로 실행하지 못하게 (97)
|
||
final void Function()? onStart;
|
||
final int limit;
|
||
const SyncPreviewView({super.key, required this.compute, this.onResult, this.onError, this.onStart, this.limit = 2000});
|
||
|
||
@override
|
||
State<SyncPreviewView> createState() => _SyncPreviewViewState();
|
||
}
|
||
|
||
class _SyncPreviewViewState extends State<SyncPreviewView> {
|
||
List<PreviewItem>? _items;
|
||
Object? _error;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_run(first: true);
|
||
}
|
||
|
||
/// 마지막으로 시작한 비교 (늦게 끝난 예전 비교가 새 결과를 덮지 않게)
|
||
int _gen = 0;
|
||
|
||
Future<void> _run({bool first = false}) async {
|
||
final gen = ++_gen;
|
||
setState(() {
|
||
_items = null;
|
||
_error = null;
|
||
});
|
||
// 첫 비교는 화면을 그리는 중이라 다음 틀에, [다시 비교] 는 바로 알린다
|
||
if (first) {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) => widget.onStart?.call());
|
||
} else {
|
||
widget.onStart?.call();
|
||
}
|
||
try {
|
||
final r = await widget.compute();
|
||
if (!mounted || gen != _gen) return;
|
||
setState(() => _items = r);
|
||
widget.onResult?.call(r);
|
||
} catch (e) {
|
||
if (!mounted || gen != _gen) return;
|
||
setState(() => _error = e);
|
||
widget.onError?.call(e);
|
||
}
|
||
}
|
||
|
||
static String _arrow(PreviewItem x) => x.toRight ? '→' : '←';
|
||
|
||
/// 110: 비교 중 · 끝 · 오류 어느 때나 같은 높이 (창 크기가 바뀌어 [취소] · [실행] 자리가 움직이지 않게)
|
||
static const height = 300.0;
|
||
|
||
/// 낮은 화면 (예: 1280×720) 에서는 화면 높이의 28% 까지만 - 아래의 [원본 파일 지우기] · 경고 · 버튼이 가려지지 않게.
|
||
/// 같은 화면 크기에서는 늘 같은 높이 (110)
|
||
double get _h => (MediaQuery.sizeOf(context).height * 0.28).clamp(140.0, height);
|
||
|
||
@override
|
||
Widget build(BuildContext context) =>
|
||
SizedBox(height: _h, child: Align(alignment: Alignment.topLeft, child: _body()));
|
||
|
||
Widget _body() {
|
||
final items = _items;
|
||
if (_error != null) {
|
||
return Row(children: [
|
||
Expanded(
|
||
child: Text(trf('비교하지 못했습니다: {0}', [_error!]), style: const TextStyle(fontSize: 12, color: Colors.redAccent)),
|
||
),
|
||
IconButton(tooltip: tr('다시 비교'), icon: const Icon(Icons.refresh, size: 18), onPressed: _run),
|
||
]);
|
||
}
|
||
if (items == null) {
|
||
return Row(children: [
|
||
const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)),
|
||
const SizedBox(width: 8),
|
||
Text(tr('무엇이 바뀌는지 비교하는 중…'), style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||
]);
|
||
}
|
||
int n(PreviewAction a, [bool? right]) =>
|
||
items.where((x) => x.action == a && (right == null || x.toRight == right)).length;
|
||
final dels = n(PreviewAction.delete);
|
||
final partials = items.where((x) => x.action == PreviewAction.delete && isPartialFile(x.rel)).length;
|
||
final overwrites = n(PreviewAction.update);
|
||
final overNewer = items.where((x) => x.action == PreviewAction.update && x.newer).length;
|
||
final skipped = n(PreviewAction.skipNewer);
|
||
final links = n(PreviewAction.skipLink);
|
||
final dirSkips = n(PreviewAction.skipDir);
|
||
final more = items.length >= widget.limit;
|
||
final parts = [
|
||
for (final right in [true, false])
|
||
if (n(PreviewAction.add, right) + n(PreviewAction.update, right) > 0)
|
||
trf('{0} 새로 {1} · 덮어씀 {2}', [right ? '→' : '←', n(PreviewAction.add, right), n(PreviewAction.update, right)]),
|
||
if (n(PreviewAction.onlyTarget) > 0) trf('받는 쪽에만 있음 {0} (그대로 둠)', [n(PreviewAction.onlyTarget)]),
|
||
if (skipped > 0) trf('받는 쪽이 더 새것 {0} (건너뜀)', [skipped]),
|
||
if (links > 0) trf('링크 {0} (건너뜀)', [links]),
|
||
if (dirSkips > 0) trf('같은 이름의 폴더가 있어 건너뜀 {0}', [dirSkips]),
|
||
];
|
||
return Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
|
||
Row(children: [
|
||
Expanded(
|
||
child: Text(
|
||
items.isEmpty ? tr('바뀌는 것 없음 (양쪽이 같습니다)') : parts.join(' · ') + (more ? tr(' · 더 있음') : ''),
|
||
style: TextStyle(fontSize: 12, color: items.isEmpty ? Colors.greenAccent : null),
|
||
),
|
||
),
|
||
IconButton(tooltip: tr('다시 비교'), icon: const Icon(Icons.refresh, size: 18), onPressed: _run),
|
||
]),
|
||
if (dels > 0)
|
||
Text(trf('받는 쪽에서 {0}개가 지워집니다 (--delete, 되돌릴 수 없음)', [dels]),
|
||
style: const TextStyle(fontSize: 12, color: Colors.redAccent, fontWeight: FontWeight.w600)),
|
||
// 15 검토 F4: 받는 쪽에만 있는 받다 만 파일 (*.jjpart 등) 도 지워진다는 것을 따로
|
||
if (partials > 0)
|
||
Text(trf('받다 만 파일 {0}개도 지워집니다', [partials]),
|
||
style: const TextStyle(fontSize: 12, color: Colors.redAccent)),
|
||
// 211: 내용이 다른 같은 이름은 받는 쪽 파일이 바뀐다 - 목록의 "~" 만으로는 알아채기 어려웠다
|
||
if (overwrites > 0)
|
||
Text(trf('받는 쪽에서 {0}개를 덮어씁니다 (내용이 다른 같은 이름 - 원래 것은 없어짐)', [overwrites]),
|
||
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent, fontWeight: FontWeight.w600)),
|
||
// 229: 받는 쪽이 더 새 파일을 예전 원본으로 덮어쓰게 되면 따로 크게 (건너뛰기를 끄거나 rsync 에 -u 가 없을 때)
|
||
if (overNewer > 0)
|
||
Text(trf('그중 {0}개는 받는 쪽이 더 새것입니다 - 더 새 파일을 예전 것으로 덮어씁니다', [overNewer]),
|
||
style: const TextStyle(fontSize: 12.5, color: Colors.orangeAccent, fontWeight: FontWeight.w800)),
|
||
if (items.isNotEmpty)
|
||
Container(
|
||
margin: const EdgeInsets.only(top: 6),
|
||
constraints: BoxConstraints(maxHeight: _h - 80),
|
||
decoration: BoxDecoration(color: JjColors.bg, borderRadius: BorderRadius.circular(6)),
|
||
padding: const EdgeInsets.all(8),
|
||
// 창 (AlertDialog) 이 크기를 미리 재므로 ListView 대신 (500줄까지)
|
||
child: SingleChildScrollView(
|
||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
// 지워질 것을 맨 위에
|
||
for (final x in [
|
||
...items.where((x) => x.action == PreviewAction.delete),
|
||
...items.where((x) => x.action != PreviewAction.delete),
|
||
].take(500))
|
||
Text(
|
||
switch (x.action) {
|
||
PreviewAction.add => '${_arrow(x)} + ${x.rel}${x.isDir ? '/' : ' (${formatSize(x.size)})'}',
|
||
PreviewAction.update =>
|
||
'${_arrow(x)} ~ ${x.rel} (${formatSize(x.size)}) ${x.newer ? tr('(더 새 파일을 덮어씀)') : tr('(덮어씀)')}',
|
||
PreviewAction.skipNewer => ' · ${x.rel} ${tr('(받는 쪽이 더 새것 - 건너뜀)')}',
|
||
PreviewAction.skipLink => ' · ${x.rel}${x.isDir ? '/' : ''} ${tr('(링크 · junction - 따라가지 않고 건너뜀)')}',
|
||
PreviewAction.onlyTarget => ' = ${x.rel}${x.isDir ? '/' : ''} ${tr('(받는 쪽에만 있음)')}',
|
||
PreviewAction.delete => x.replacedByFile
|
||
? '${_arrow(x)} − ${x.rel}/ ${trf('(폴더 · 안의 {0}개를 지우고 같은 이름의 파일로)', [x.inner])}'
|
||
: '${_arrow(x)} − ${x.rel}${x.isDir ? '/' : ''} ${tr('(지워짐)')}',
|
||
PreviewAction.skipDir => ' · ${x.rel} ${tr('(받는 쪽에 같은 이름의 폴더가 있어 건너뜀 - 폴더는 그대로)')}',
|
||
},
|
||
style: TextStyle(
|
||
fontSize: 11.5,
|
||
fontFamily: 'monospace',
|
||
color: switch (x.action) {
|
||
PreviewAction.delete => Colors.redAccent,
|
||
PreviewAction.update || PreviewAction.skipDir => Colors.orangeAccent,
|
||
PreviewAction.onlyTarget || PreviewAction.skipNewer || PreviewAction.skipLink => JjColors.textDim,
|
||
_ => null,
|
||
},
|
||
),
|
||
),
|
||
if (items.length > 500) Text(trf('… 외 {0}개', [items.length - 500]), style: const TextStyle(fontSize: 11)),
|
||
])),
|
||
),
|
||
]);
|
||
}
|
||
}
|
||
|
||
/// 지우기가 들어 있는 복사 작업인지 (104): rsync 의 --delete 계열 (WebDAV 도 따름 - 100), robocopy 의 /MIR · /PURGE (로컬만)
|
||
bool taskDeletes(CopyTask t) {
|
||
final dav = [...t.sources, t.dest].any(isDav);
|
||
return switch (CopyMethod.of(t.method)) {
|
||
CopyMethod.rsync => optionsDelete(t.options),
|
||
CopyMethod.robocopy => !dav && splitOptions(t.options).any((o) => o.toUpperCase() == '/MIR' || o.toUpperCase() == '/PURGE'),
|
||
CopyMethod.builtin => false,
|
||
};
|
||
}
|
||
|
||
/// 178 · 209: 실제로 실행할 방법 · 옵션 (원본 지우기 옵션은 빼고, 이동일 때만 그 방법의 원본 지우기를 붙인다).
|
||
/// 238: robocopy 는 실제로 붙는 것까지 (이동의 /MOVE /IS /IT /IM, 링크 /XJ, [skipNewer] (229) 의 /XO - robocopyRuns 와 같게)
|
||
String effectiveRunText(String method, String options, {required bool move, bool skipNewer = false}) {
|
||
final name = method == 'builtin' ? tr('현재 방식') : method;
|
||
if (method == 'builtin') return name;
|
||
final opts = withoutSourceRemovingOptions(options);
|
||
final extra = method == 'robocopy'
|
||
? robocopyExtraOptions(splitOptions(opts), move: move, skipNewer: skipNewer)
|
||
: [if (move && method == 'rsync') '--remove-source-files'];
|
||
return [name, opts, ...extra].where((x) => x.isNotEmpty).join(' ');
|
||
}
|
||
|
||
/// 104: 지우기가 들어 있는 작업은 어디서 실행하든 (모니터링 [실행] · 옵션 저장 뒤 실행 · 최종 정리) Rsync 화면과 같은 미리 보기를 거친다.
|
||
/// 지울 목록이 나온 뒤에만 [실행] 이 켜진다. 지우기가 없으면 묻지 않고 true
|
||
/// [includeMove] (210): 이동 (원본 파일 지우기) 도 미리 보기 · 확인을 거친다 - 모니터링의 [실행] 처럼 따로 묻는 창이 없을 때.
|
||
/// [skipNewer] (229): 앱이 직접 할 때 (현재 방식 · WebDAV) 받는 쪽이 더 새것이면 건너뛰는지 (환경 설정)
|
||
Future<bool> confirmDeletingRun(BuildContext context, CopyTask t, {bool includeMove = false, bool skipNewer = false}) async {
|
||
final deletes = taskDeletes(t);
|
||
final moves = includeMove && t.move;
|
||
if (!deletes && !moves) return true;
|
||
List<PreviewItem>? preview;
|
||
Object? error;
|
||
var filterAck = false;
|
||
final direct = t.method == 'builtin' || [...t.sources, t.dest].any(isDav);
|
||
// robocopy 는 /XO (옵션 칸 또는 환경 설정) 가 -u 와 같다
|
||
final robo = t.method == 'robocopy' && !direct;
|
||
final update = robo
|
||
? skipNewer || splitOptions(t.options).any((o) => o.toUpperCase() == '/XO')
|
||
: optionsUpdate(t.options) || (direct && skipNewer);
|
||
String dstOf(String src) => t.contents ? t.dest : vJoin(t.dest, vBasename(src));
|
||
// 177: rsync 로 맞출 때만 옵션의 거르기 (--exclude 등) 를 비교에도 (앱이 직접 · robocopy 는 이 규칙을 쓰지 않음)
|
||
// 관리자 지시 (10/19): 원격 (앱이 맞춤) 도 rsync 를 고른 작업이면 같은 거르기를 따른다 (현재 방식 · robocopy 는 이 규칙을 쓰지 않음)
|
||
final filter = t.method == 'rsync' ? RsyncFilter.parse(t.options) : RsyncFilter.none;
|
||
final ok = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => StatefulBuilder(
|
||
builder: (ctx, set) {
|
||
final dels = preview?.where((x) => x.action == PreviewAction.delete).length ?? 0;
|
||
return AlertDialog(
|
||
scrollable: true,
|
||
actionsOverflowDirection: VerticalDirection.down, // 110: [취소] 가 먼저
|
||
title: Text(deletes ? tr('지우기가 들어 있는 작업입니다') : tr('원본을 지우는 작업입니다 (이동)')),
|
||
content: SizedBox(
|
||
width: 560,
|
||
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
for (final s in t.sources) ...[
|
||
Text(shortPath(s), style: const TextStyle(fontWeight: FontWeight.w600)),
|
||
Text('→ ${shortPath(dstOf(s))}', style: const TextStyle(fontWeight: FontWeight.w600)),
|
||
Text('${vDisplay(s)} → ${vDisplay(dstOf(s))}', style: const TextStyle(fontSize: 11, color: JjColors.textDim)),
|
||
],
|
||
// 230: 칸에 적힌 옵션이 아니라 실제로 실행할 것 (원본 지우기 옵션은 이동일 때만 붙는다)
|
||
Text(effectiveRunText(t.method, t.options, move: t.move, skipNewer: skipNewer), style: const TextStyle(fontSize: 12, fontFamily: 'monospace', color: JjColors.textDim)),
|
||
// 210: 이동이면 실행 뒤 원본에서 지워진다는 것을 분명히
|
||
if (t.move)
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 4),
|
||
child: Text(tr('이동: 대상에 옮긴 파일은 원본에서 지워집니다'),
|
||
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)),
|
||
),
|
||
const SizedBox(height: 8),
|
||
// 15 검토 F2: 미리 볼 수 없는 거르기 규칙 (늘 같은 자리 - 비교 결과와 상관없이 옵션으로 정해짐)
|
||
if (filter.unsupported.isNotEmpty)
|
||
FilterLimitNote(unsupported: filter.unsupported, deletes: deletes, ack: filterAck, onAck: (v) => set(() => filterAck = v)),
|
||
SyncPreviewView(
|
||
compute: () async => [
|
||
for (final s in t.sources) ...await previewSync(s, dstOf(s), toRight: true, delete: deletes, update: update,
|
||
// robocopy 도 링크를 따라가지 않는다 (/XJ - 옵션 칸에 /SJ · /SL 이 없으면)
|
||
skipLinks: direct || (robo && robocopySkipsLinks(splitOptions(t.options))),
|
||
filter: filter),
|
||
],
|
||
onStart: () => set(() {
|
||
preview = null;
|
||
error = null;
|
||
}),
|
||
onResult: (items) => set(() => preview = items),
|
||
onError: (e) => set(() {
|
||
preview = null;
|
||
error = e;
|
||
}),
|
||
),
|
||
// 110: 늘 같은 자리 (버튼이 움직이지 않게)
|
||
SizedBox(
|
||
height: 34,
|
||
child: preview == null
|
||
? Text(
|
||
error != null
|
||
? tr('지우기가 들어간 실행이라, 비교하지 못하면 실행할 수 없습니다. 원본 · 대상을 확인한 뒤 [다시 비교] 를 누르세요.')
|
||
: deletes
|
||
? tr('지우기가 들어간 실행이라, 지울 목록이 나온 뒤에 실행할 수 있습니다.')
|
||
: tr('원본을 지우는 실행이라, 옮길 목록이 나온 뒤에 실행할 수 있습니다.'),
|
||
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent),
|
||
)
|
||
: null,
|
||
),
|
||
]),
|
||
),
|
||
actions: [
|
||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
|
||
FilledButton(
|
||
style: dels > 0 || moves ? FilledButton.styleFrom(backgroundColor: JjColors.danger) : null,
|
||
onPressed: preview == null || (deletes && filter.unsupported.isNotEmpty && !filterAck) ? null : () => Navigator.pop(ctx, true),
|
||
child: Text(tr('실행')), // 110: 글자를 바꾸지 않는다 (버튼 자리가 움직이지 않게)
|
||
),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
);
|
||
return ok == true;
|
||
}
|
||
|
||
/// 15 검토 F2 (177): rsync 옵션에 미리 볼 수 없는 거르기 규칙 (고치개 · H · P · merge · -F · -C · 읽지 못한 파일 등) 이 있으면
|
||
/// 지울 목록이 실제보다 적게 보일 수 있다 - 빨갛게 알리고, 지우기 (--delete) 와 함께면 체크해야 [실행] 이 켜진다 (72 · 104 와 같게)
|
||
/// 옵션 글 (예: --filter=H a.txt) 이 좁은 창에서 "--" 와 "filter=…" 로 갈리지 않게 줄바꿈 없이 묶는다 (관리자 지시 10/20, 28 지적):
|
||
/// 빈칸은 줄바꿈 없는 빈칸, - · / 뒤에는 줄바꿈을 막는 글자 (U+2060, 보이지 않음)
|
||
String unbreakableOption(String s) => s.replaceAll(' ', '\u00A0').replaceAllMapped(RegExp(r'[-/]'), (m) => '${m[0]}\u2060');
|
||
|
||
/// 옵션 입력 칸: 붙여 넣거나 친 글의 U+00A0 · U+2060 등을 고쳐 받는다 ([cleanOptionText], 관리자 지시 10/20).
|
||
/// 화면의 묶은 옵션 글 ([unbreakableOption]) 이나 다른 곳의 글을 붙여 넣어도 rsync 가 다르게 읽지 않게
|
||
class OptionTextFormatter extends TextInputFormatter {
|
||
const OptionTextFormatter();
|
||
|
||
@override
|
||
TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) {
|
||
final text = cleanOptionText(newValue.text);
|
||
if (text == newValue.text) return newValue;
|
||
// 커서 · 고른 곳은 앞부분을 고친 길이로 옮긴다
|
||
int at(int i) => i < 0 ? i : cleanOptionText(newValue.text.substring(0, i.clamp(0, newValue.text.length))).length;
|
||
return TextEditingValue(
|
||
text: text,
|
||
selection: TextSelection(baseOffset: at(newValue.selection.baseOffset), extentOffset: at(newValue.selection.extentOffset)),
|
||
);
|
||
}
|
||
}
|
||
|
||
class FilterLimitNote extends StatelessWidget {
|
||
final List<String> unsupported;
|
||
final bool deletes;
|
||
final bool ack;
|
||
final ValueChanged<bool> onAck;
|
||
const FilterLimitNote({super.key, required this.unsupported, required this.deletes, required this.ack, required this.onAck});
|
||
|
||
@override
|
||
Widget build(BuildContext context) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Text(trf('이 옵션의 일부 거르기 규칙은 미리 볼 수 없습니다 - 실제로 더 지워질 수 있습니다: {0}', [unsupported.map(unbreakableOption).join(' · ')]),
|
||
style: const TextStyle(fontSize: 12, color: Colors.redAccent, fontWeight: FontWeight.w600)),
|
||
if (deletes)
|
||
CheckboxListTile(
|
||
dense: true,
|
||
contentPadding: EdgeInsets.zero,
|
||
controlAffinity: ListTileControlAffinity.leading,
|
||
value: ack,
|
||
onChanged: (v) => onAck(v ?? false),
|
||
title: Text(tr('알고 실행합니다 (지울 목록이 실제와 다를 수 있음)'), style: const TextStyle(fontSize: 12)),
|
||
),
|
||
]);
|
||
}
|