JJ_MKVMaker/lib/ui/sync_preview_view.dart

373 lines
20 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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 bad = t.method == 'rsync' ? unreadableOptions(t.options) : const <String>[];
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 (bad.isNotEmpty) OptionProblemNote(bad),
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 || bad.isNotEmpty || (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)),
);
}
}
/// 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 옵션 - 빨갛게 알리고 [실행] 을 막는다 (rsync 가 원본 경로로 읽어 옵션이 몰래 빠짐)
class OptionProblemNote extends StatelessWidget {
final List<String> bad;
const OptionProblemNote(this.bad, {super.key});
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
trf('옵션을 알아볼 수 없습니다: {0} - 곧은 따옴표와 하이픈 (-) 으로 다시 적어 주세요', [bad.map(unbreakableOption).join(' · ')]),
style: const TextStyle(fontSize: 12, color: Colors.redAccent, fontWeight: FontWeight.w600)),
);
}
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)),
),
]);
}