import 'package:flutter/material.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> Function() compute; final void Function(List 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 createState() => _SyncPreviewViewState(); } class _SyncPreviewViewState extends State { List? _items; Object? _error; @override void initState() { super.initState(); _run(first: true); } /// 마지막으로 시작한 비교 (늦게 끝난 예전 비교가 새 결과를 덮지 않게) int _gen = 0; Future _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 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? 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( 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(' ', ' ').replaceAllMapped(RegExp(r'[-/]'), (m) => '${m[0]}⁠'); class FilterLimitNote extends StatelessWidget { final List unsupported; final bool deletes; final bool ack; final ValueChanged 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)), ), ]); }