/// 실시간 동기화 (lsync) 흐름과 확인 창. 화면 상태 없이 (context · AppController · LiveSync · 쌍) 만 받는다. /// 약속: /// - confirmLiveDeletes · confirmLiveDeleteNow · confirmEmptySourceSync: 지울 목록을 보이고, 고른 대로만 /// live.decideDelete / deleteNow / syncEmptyAnyway 를 부른다. 취소면 아무것도 하지 않음. /// - askLiveSyncStart: 켜 둔 쌍 가운데 이번 실행에 시작할 것을 고르게 해 live.runOnly 로 시작. /// - replaceLiveSyncPair: 같은 원본 · 대상 쌍만 바꾸고 순서는 그대로 저장. /// - showLivePairMenu: 카드의 고치기 · 지우기 메뉴. /// - finalizeLiveSync: 최종 정리 (rsync --remove-source-files + 빈 폴더 지우기). 작업이 시작되면 true. /// rsync 를 못 찾거나 시작하지 못하면 SnackBar 로 알리고 false. library; import 'dart:io'; import 'package:flutter/material.dart'; import '../../app/app_controller.dart'; import '../../app/copy_center.dart'; import '../../app/live_sync.dart'; import '../../app/settings.dart'; import '../../core/vfs.dart'; import '../../l10n/tr.dart'; import '../copy_sync_settings.dart' show editLiveSyncPairDialog, removeLiveSyncPair; import '../rsync_setup.dart'; import '../schedule_editor.dart'; import '../sync_preview_view.dart' show FilterLimitNote, confirmDeletingRun, keepOptionsTogether; import '../theme.dart'; import 'transfer_widgets.dart'; /// 실시간 동기화 "지우기" 확인 창 (42): 대상에만 있어 지워질 항목을 보여 주고 [지우기 포함으로 맞추기] · [지우지 않기] · [나중에] Future confirmLiveDeletes(BuildContext context, LiveSync live, LiveSyncPair x) async { final list = live.toDelete[LiveSync.keyOf(x)] ?? const []; if (list.isEmpty) return; final pick = await _liveDeleteDialog(context, live, x, list, live.toDeleteExcluded[LiveSync.keyOf(x)] ?? const [], now: false); if (pick == null) return; await live.decideDelete(x, delete: pick); } /// P0 (관리자 3a 10/20): 미리 볼 수 없는 거르기 규칙이 있어 자동으로 지우지 않는 쌍의 [지금 지우기 (확인)]. /// 빨간 FilterLimitNote 와 [알고 실행합니다] 체크를 거쳐 이번 한 번만 지운다 (다음 자동 맞추기는 다시 보내기만) Future confirmLiveDeleteNow(BuildContext context, LiveSync live, LiveSyncPair x) async { final (list, excl) = await live.previewDeletes(x); if (!context.mounted) return; final pick = await _liveDeleteDialog(context, live, x, list, excl, now: true); if (pick == true) await live.deleteNow(x); } Future _liveDeleteDialog(BuildContext context, LiveSync live, LiveSyncPair x, List list, List exclIn, {required bool now}) { // 관리자 3a 조건 (10/20): --delete-excluded 때문에 지워지는 것 (거른 것 · 거른 것이 든 폴더) 은 따로 묶어 알고 누르게 final excl = exclIn.toSet(); final plain = [for (final n in list) if (!excl.contains(n)) n]; final filtered = [for (final n in list) if (excl.contains(n)) n]; // P0: 미리 볼 수 없는 규칙 - 목록이 실제와 다를 수 있음을 빨갛게, 체크해야 [지우고 맞추기] final limits = live.unsupportedOf(x); var ack = false; return showDialog( context: context, builder: (ctx) => StatefulBuilder( builder: (ctx, set) => AlertDialog( title: Text(list.isEmpty ? tr('대상에서 지울까요?') : trf('대상에서 {0}개를 지울까요?', [list.length])), content: SizedBox( width: 560, // 세로 폰에서도 목록 (특히 거른 것 묶음) 이 더 보이게 화면 높이를 따라 (360 ~ 520) height: (MediaQuery.sizeOf(ctx).height * 0.55).clamp(360.0, 520.0), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ if (limits.isNotEmpty) ...[ FilterLimitNote(unsupported: limits, deletes: true, ack: ack, onAck: (v) => set(() => ack = v)), const SizedBox(height: 4), ], // 관리자 3a 판단 (10/20): 확인한 쌍인데 옵션이 바뀌어 지울 것이 늘어 다시 묻는 것 if (!now && live.reasked.contains(LiveSync.keyOf(x))) ...[ Text(tr('지우기를 확인한 뒤 rsync 옵션이 바뀌어 지울 것이 늘었습니다. 다시 확인해 주세요.'), style: TextStyle(fontWeight: FontWeight.w600, color: JjColors.warning)), const SizedBox(height: 6), ], Text(trf('"{0}" 에는 없고 대상 "{1}" 에만 있는 항목입니다. 지우기 포함으로 맞추면 대상에서 지워집니다 (되돌릴 수 없음).', [vDisplay(x.source), vDisplay(x.target)])), const SizedBox(height: 8), Expanded( child: ListView(children: [ if (list.isEmpty) Text(tr('미리 본 지울 것은 없습니다 - 위 규칙 때문에 실제로는 지워질 수 있습니다'), style: TextStyle(fontSize: 12, color: JjColors.textDim)), for (final n in plain.take(500)) Text(n, style: const TextStyle(fontSize: 12, fontFamily: 'Consolas')), if (plain.length > 500) Text(trf('… 외 {0}개', [plain.length - 500])), if (filtered.isNotEmpty) ...[ if (plain.isNotEmpty) const SizedBox(height: 10), Text(keepOptionsTogether(trf('거른 것 {0}개도 지워집니다 (--delete-excluded)', [filtered.length])), style: TextStyle(fontWeight: FontWeight.w600, color: JjColors.danger)), Text(keepOptionsTogether(tr('rsync 옵션에서 뺀 것 (--exclude 등) 이지만 --delete-excluded 가 있어 대상에서 지웁니다. 지키려면 옵션에서 --delete-excluded 를 빼세요.')), style: TextStyle(fontSize: 12, color: JjColors.textDim)), const SizedBox(height: 4), for (final n in filtered.take(500)) Text(n, style: TextStyle(fontSize: 12, fontFamily: 'Consolas', color: JjColors.danger)), if (filtered.length > 500) Text(trf('… 외 {0}개', [filtered.length - 500])), ], ]), ), ]), ), actions: [ TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('나중에'))), if (!now) TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('지우지 않기 (지우기 끄기)'))), FilledButton( style: FilledButton.styleFrom(backgroundColor: JjColors.danger), onPressed: limits.isNotEmpty && !ack ? null : () => Navigator.pop(ctx, true), child: Text(list.isEmpty ? tr('지우고 맞추기') : trf('{0}개 지우고 맞추기', [list.length])), ), ], ), ), ); } /// 70: 원본이 비어 멈춘 쌍 - 지워질 목록을 보여 주고 [그래도 맞추기] 로 확인받는다 (42 와 같은 창) Future confirmEmptySourceSync(BuildContext context, LiveSync live, LiveSyncPair x) async { final list = live.emptyDeletes[LiveSync.keyOf(x)] ?? const []; if (list.isEmpty) return; final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(trf('원본이 비어 있습니다. 대상에서 {0}개를 지울까요?', [list.length])), content: SizedBox( width: 560, height: 360, child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(trf('"{0}" 이 비어 있어, 맞추면 대상 "{1}" 의 아래 항목이 모두 지워집니다 (되돌릴 수 없음). ' '원본을 일부러 비운 경우에만 누르세요.', [vDisplay(x.source), vDisplay(x.target)])), const SizedBox(height: 8), Expanded( child: ListView(children: [ for (final n in list.take(500)) Text(n, style: const TextStyle(fontSize: 12, fontFamily: 'Consolas')), if (list.length > 500) Text(trf('… 외 {0}개', [list.length - 500])), ]), ), ]), ), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), FilledButton( style: FilledButton.styleFrom(backgroundColor: JjColors.danger), onPressed: () => Navigator.pop(ctx, true), child: Text(trf('그래도 맞추기 ({0}개 지움)', [list.length])), ), ], ), ); if (ok == true) await live.syncEmptyAnyway(x); } /// 앱을 다시 켤 때 ([AppSettings.liveSyncOnStart] 'ask'): 어느 실시간 동기화를 시작할지 고르기. /// 고르지 않은 것은 이번 실행 동안 멈춤 (모니터링 > lsync 에서 ▶ 로 시작). Future askLiveSyncStart(BuildContext context, LiveSync live) async { final pairs = live.c.settings.liveSyncPairs.where((x) => x.enabled).toList(); if (pairs.isEmpty) return; final pick = {for (final x in pairs) LiveSync.keyOf(x)}; final ok = await showDialog( context: context, builder: (ctx) => StatefulBuilder( builder: (ctx, set) => AlertDialog( title: Text(tr('실시간 동기화 시작')), content: SizedBox( width: 520, child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(tr('시작할 동기화를 고르세요. 고르지 않은 것은 이번 실행 동안 멈춥니다 (모니터링 > lsync 에서 시작).'), style: TextStyle(fontSize: 12, color: JjColors.textDim)), const SizedBox(height: 8), Flexible( child: ListView(shrinkWrap: true, children: [ for (final x in pairs) CheckboxListTile( dense: true, contentPadding: EdgeInsets.zero, value: pick.contains(LiveSync.keyOf(x)), onChanged: (v) => set(() => v == true ? pick.add(LiveSync.keyOf(x)) : pick.remove(LiveSync.keyOf(x))), title: Text('${vDisplay(x.source)} → ${vDisplay(x.target)}', maxLines: 2, overflow: TextOverflow.ellipsis), subtitle: Text(scheduleSummary(x.schedule)), ), ]), ), ]), ), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('모두 나중에'))), FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('고른 것 시작'))), ], ), ), ); if (ok == true) live.runOnly(pairs.where((x) => pick.contains(LiveSync.keyOf(x)))); } /// 같은 원본 · 대상 쌍만 [now] 로 바꿔 저장 (나머지 순서 그대로) Future replaceLiveSyncPair(AppController c, LiveSyncPair old, LiveSyncPair now) => c.updateSettings((x) => x.liveSyncPairs = [ for (final y in x.liveSyncPairs) y.source == old.source && y.target == old.target ? now : y, ]); /// 69: 카드의 길게 누르기 · 오른쪽 클릭 메뉴 Future showLivePairMenu(BuildContext context, AppController c, LiveSyncPair x, Offset at) async { final pick = await showMenu( context: context, position: RelativeRect.fromLTRB(at.dx, at.dy, at.dx, at.dy), items: [ PopupMenuItem(value: 'edit', child: Text(tr('고치기'))), PopupMenuItem(value: 'remove', child: Text(tr('지우기'))), ], ); if (!context.mounted) return; if (pick == 'edit') { await editLiveSyncPairDialog(context, c, x); } else if (pick == 'remove') { await removeLiveSyncPair(context, c, x); } } /// 최종 정리: 원본의 것을 모두 대상으로 옮기고 (rsync -avHPOg --remove-source-files 원본/ 대상/) /// 빈 폴더를 지운다 (find 원본/ -type d -empty -delete). 원본 폴더를 남길지 · 지울지만 고른다. /// 원본이 비므로 이 실시간 동기화는 끈다 (켜 둔 채 "지우기 포함" 이면 대상까지 비워 버린다). /// 작업이 시작되면 true (진행은 복사 · rsync 목록에서 봄 - 그쪽으로 넘기는 일은 부르는 쪽이 한다). Future finalizeLiveSync(BuildContext context, AppController c, LiveSyncPair x) async { final live = LiveSync.instance; final messenger = ScaffoldMessenger.of(context); if (live?.isRunning(x) ?? false) { messenger.showSnackBar(SnackBar(content: Text(tr('지금 맞추는 중입니다. 끝난 뒤에 하세요.')))); return false; } var prune = 'keep'; final go = await showDialog( context: context, builder: (ctx) => StatefulBuilder( builder: (ctx, set) => AlertDialog( scrollable: true, title: Text(tr('최종 정리')), content: SizedBox( width: 600, child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(tr('원본의 파일을 모두 대상으로 옮기고 (원본에서 지움), 원본에 남은 빈 폴더를 지웁니다.')), const SizedBox(height: 10), SelectableText( 'rsync -avHPOg --remove-source-files ${x.source}/ ${x.target}/\n' 'find ${x.source}/ -type d -empty -delete', style: TextStyle(fontFamily: 'monospace', fontSize: 12, color: JjColors.textDim)), const SizedBox(height: 14), Text(tr('원본 폴더'), style: const TextStyle(fontWeight: FontWeight.w600)), const SizedBox(height: 6), PruneChoice(value: prune, onChanged: (v) => set(() => prune = v)), const SizedBox(height: 12), Text(tr('원본이 비므로 이 실시간 동기화는 끕니다. 진행은 [복사 · rsync] 탭에서 봅니다.'), style: TextStyle(fontSize: 12, color: JjColors.warning)), ]), ), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('최종 정리'))), ], ), ), ); if (go != true || !context.mounted) return false; var exe = await rsyncExecutable(c.settings); if (exe == null && Platform.isWindows && c.settings.rsyncSource == 'download' && context.mounted) { exe = await installRsyncWithDialog(context); } if (exe == null) { messenger.showSnackBar(SnackBar(content: Text(tr('rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > Rsync 에서 확인하세요.')))); return false; } final center = CopyCenter.of(c); var t = await center.remember([x.source], x.target, move: true, contents: true, method: 'rsync'); // 처음이면 정해진 옵션 (모니터링에서 고친 것이 있으면 그것) t = t.copyWith(prune: prune, options: t.lastRun.isEmpty ? '-avHPOg' : null); await center.update(t); // 104: 고친 옵션에 지우기가 있으면 미리 보기를 거친다 if (!context.mounted || !await confirmDeletingRun(context, t) || !context.mounted) return false; await replaceLiveSyncPair(c, x, x.copyWith(enabled: false)); c.note(trf('rsync 시작 ({0}): {1} → {2}', [tr('실시간 동기화의 [최종 정리]'), x.source, x.target])); final job = await center.start(t, rsyncExe: exe); if (!context.mounted) return false; if (job == null) { messenger.showSnackBar(SnackBar(content: Text(center.tasks.firstWhere((y) => y.id == t.id).lastMessage))); return false; } return true; }