From 059c8cba5956cb13f1cbf234b1d9deb4c5733e09 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:10:42 +0900 Subject: [PATCH] =?UTF-8?q?=EC=84=A4=EC=A0=95=20A=20=EB=B6=84=EC=95=BC=204?= =?UTF-8?q?:=20=EB=B3=B5=EC=82=AC=20=C2=B7=20=EC=9D=B4=EB=8F=99=20=C2=B7?= =?UTF-8?q?=20rsync=20=C2=B7=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20(CopySettings)=20-=20=ED=82=A4=2016=20?= =?UTF-8?q?=C2=B7=20=EB=8F=84=EC=9A=B0=EB=AF=B8=20=5Fmethod=20=ED=95=A8?= =?UTF-8?q?=EA=BB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 부르는 곳: s.copyTasks → s.copy.tasks 꼴 (RsyncRun 의 자기 필드 copySkipNewer 는 그대로 - 같은 이름이라 잘못 바뀐 것을 되돌림) - 문서 주석 [AppSettings.x] → [CopySettings.x] 등 Co-Authored-By: Claude Opus 5.5 --- integration_test/copy_sync_shot_test.dart | 6 +- integration_test/monitor_shot_test.dart | 2 +- integration_test/rsync_page_test.dart | 20 ++-- integration_test/windows_b3_shot_test.dart | 2 +- .../windows_bigtext_shot_test.dart | 6 +- .../windows_live_excl_shot_test.dart | 8 +- .../windows_lsync_p0_shot_test.dart | 10 +- .../windows_move_left_shot_test.dart | 2 +- .../windows_rsync_narrow_shot_test.dart | 2 +- lib/app/copy_center.dart | 32 +++--- lib/app/live_sync.dart | 44 ++++---- lib/app/settings.dart | 77 ++----------- lib/app/settings/copy_settings.dart | 102 ++++++++++++++++++ lib/main.dart | 10 +- lib/ui/archive_ui.dart | 8 +- lib/ui/copy_sync_settings.dart | 68 ++++++------ lib/ui/explorer/explorer_controller.dart | 2 +- lib/ui/explorer/rsync_flow.dart | 2 +- lib/ui/explorer/transfer_flow.dart | 6 +- lib/ui/explorer_page.dart | 2 +- lib/ui/monitor/copy_task_card.dart | 10 +- lib/ui/monitor/live_sync_flow.dart | 10 +- lib/ui/monitor_page.dart | 4 +- lib/ui/settings/transfer_sections.dart | 6 +- lib/ui/webdav_settings.dart | 4 +- test/a11y_names_test.dart | 6 +- test/confirm_actions_test.dart | 6 +- test/decisions_test.dart | 8 +- test/dismiss_test.dart | 36 +++---- test/explorer_archive_trash_test.dart | 4 +- test/explorer_conflict_test.dart | 6 +- test/explorer_filter_rsync_test.dart | 6 +- test/explorer_keys_drag_test.dart | 4 +- test/explorer_modules_e5c_contract_test.dart | 2 +- test/explorer_parallel_test.dart | 12 +-- test/explorer_select_ops_test.dart | 6 +- test/explorer_steady_test.dart | 4 +- test/explorer_webdav_test.dart | 8 +- test/large_text_test.dart | 6 +- test/live_sync_args_test.dart | 10 +- test/live_sync_scene_test.dart | 74 ++++++------- test/mirror_safety_test.dart | 8 +- test/monitor_modules_test.dart | 14 +-- test/monitor_test.dart | 40 +++---- test/move_settled_test.dart | 4 +- test/rsync_msys_args_test.dart | 22 ++-- test/rsync_phsr_test.dart | 10 +- test/rsync_safety_test.dart | 12 +-- test/security_bits_test.dart | 4 +- test/settings_text_focus_test.dart | 6 +- test/small_items_test.dart | 2 +- test/sync_alert_test.dart | 2 +- test/sync_problem_test.dart | 18 ++-- test/sync_tools_test.dart | 22 ++-- test/webdav_test.dart | 16 +-- 55 files changed, 430 insertions(+), 393 deletions(-) create mode 100644 lib/app/settings/copy_settings.dart diff --git a/integration_test/copy_sync_shot_test.dart b/integration_test/copy_sync_shot_test.dart index f34b43f..5c3d517 100644 --- a/integration_test/copy_sync_shot_test.dart +++ b/integration_test/copy_sync_shot_test.dart @@ -24,9 +24,9 @@ void main() { final out = Platform.environment['JJ_SHOT_DIR'] ?? Directory.systemTemp.path; final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings - ..copyMethodFolder = 'rsync' - ..copyBandwidthKBps = 5000 - ..liveSyncPairs = [ + ..copy.methodFolder = 'rsync' + ..copy.bandwidthKBps = 5000 + ..copy.liveSyncPairs = [ LiveSyncPair(r'D:\영상\원본', r'\\nas\backup\영상', method: 'rsync', delete: true), LiveSyncPair(r'C:\Users\me\Documents', r'E:\Documents', enabled: false), ]; diff --git a/integration_test/monitor_shot_test.dart b/integration_test/monitor_shot_test.dart index 68e9b13..4c1689a 100644 --- a/integration_test/monitor_shot_test.dart +++ b/integration_test/monitor_shot_test.dart @@ -33,7 +33,7 @@ void main() { } Directory(dst).createSync(); final c = AppController(PlatformServices.create()); - c.settings.liveSyncPairs = [ + c.settings.copy.liveSyncPairs = [ LiveSyncPair(src, p.join(dst, 'mirror'), schedule: const ['0 9-17 * * 1-5', '0 22-23 * * 0,6'], delete: true), ]; final live = LiveSync(c)..start(); diff --git a/integration_test/rsync_page_test.dart b/integration_test/rsync_page_test.dart index aa283c9..deb8ae5 100644 --- a/integration_test/rsync_page_test.dart +++ b/integration_test/rsync_page_test.dart @@ -34,7 +34,7 @@ void main() { final c = AppController(PlatformServices.create()); c.settings - ..rsyncPaths = [tmp.path, tmp.path] + ..copy.rsyncPaths = [tmp.path, tmp.path] ..explorer.style = 'windows'; final key = GlobalKey(); tester.view.physicalSize = const Size(1500, 900); @@ -101,8 +101,8 @@ void main() { // 모니터링: 실행한 두 방향이 남는다 await tap(find.text('모니터링')); await wait(1500); - expect(c.settings.copyTasks.length, 2); - expect(c.settings.copyTasks.every((t) => t.method == 'rsync' && t.contents && t.lastResult == 'done'), isTrue); + expect(c.settings.copy.tasks.length, 2); + expect(c.settings.copy.tasks.every((t) => t.method == 'rsync' && t.contents && t.lastResult == 'done'), isTrue); await shot('rsync_monitor'); tester.view.reset(); tmp.deleteSync(recursive: true); @@ -124,8 +124,8 @@ void main() { await tester.runAsync(findBundledRsync); final c = AppController(PlatformServices.create()); c.settings - ..rsyncPaths = [tmp.path, tmp.path] - ..rsyncOptions = '-avPog --delete --exclude=*.log --exclude=build/ --exclude=/sub/deep/'; + ..copy.rsyncPaths = [tmp.path, tmp.path] + ..copy.rsyncOptions = '-avPog --delete --exclude=*.log --exclude=build/ --exclude=/sub/deep/'; final key = GlobalKey(); tester.view.physicalSize = const Size(1500, 900); tester.view.devicePixelRatio = 1; @@ -194,8 +194,8 @@ void main() { await tester.runAsync(findBundledRsync); final c = AppController(PlatformServices.create()); c.settings - ..rsyncPaths = [tmp.path, tmp.path] - ..liveSyncPairs = [LiveSyncPair(cDir, d, method: 'rsync', delete: true)]; + ..copy.rsyncPaths = [tmp.path, tmp.path] + ..copy.liveSyncPairs = [LiveSyncPair(cDir, d, method: 'rsync', delete: true)]; final key = GlobalKey(); tester.view.physicalSize = const Size(1500, 900); tester.view.devicePixelRatio = 1; @@ -240,7 +240,7 @@ void main() { expect(File(p.join(b, 'sub', 'deep', 'x.txt')).readAsStringSync(), 'x'); expect(Directory(p.join(b, 'empty')).existsSync(), isTrue); // 빈 폴더도 대상에는 만들어짐 (rsync -a) expect(Directory(a).existsSync(), isFalse, reason: '원본 폴더까지 지움'); - final moved = c.settings.copyTasks.single; + final moved = c.settings.copy.tasks.single; expect([moved.move, moved.prune, moved.lastResult], [true, 'all', 'done']); expect(moved.lastMessage, '빈 폴더 4개 지움'); // sub/deep · sub · empty · srcA @@ -263,8 +263,8 @@ void main() { expect(File(p.join(d, 'sub', 'deep', 'y.txt')).readAsStringSync(), 'y'); expect(Directory(cDir).existsSync(), isTrue, reason: '원본 폴더는 남김'); expect(Directory(cDir).listSync(), isEmpty, reason: '빈 폴더는 모두 지움'); - expect(c.settings.liveSyncPairs.single.enabled, isFalse, reason: '원본이 비었으므로 실시간 동기화는 끔'); - final fin = c.settings.copyTasks.firstWhere((t) => t.sources.single == cDir); + expect(c.settings.copy.liveSyncPairs.single.enabled, isFalse, reason: '원본이 비었으므로 실시간 동기화는 끔'); + final fin = c.settings.copy.tasks.firstWhere((t) => t.sources.single == cDir); expect([fin.options, fin.move, fin.prune, fin.lastResult], ['-avHPOg', true, 'keep', 'done']); expect(fin.lastMessage, '빈 폴더 3개 지움'); // sub/deep · sub · empty (liveC 는 남김) tester.view.reset(); diff --git a/integration_test/windows_b3_shot_test.dart b/integration_test/windows_b3_shot_test.dart index a178540..7584c47 100644 --- a/integration_test/windows_b3_shot_test.dart +++ b/integration_test/windows_b3_shot_test.dart @@ -149,7 +149,7 @@ void main() { testWidgets('52: 복사 중에 다른 화면에 갔다 와도 진행 막대', (t) async { // 다른 화면에 갔다 와도 아직 복사 중이게 (약 25초) File(p.join(left, 'big.bin')).writeAsBytesSync(List.filled(2 * 1024 * 1024, 7)); - c.settings.copyBandwidthKBps = 80; + c.settings.copy.bandwidthKBps = 80; await show(t, ExplorerPage(device: device, c: c)); await selectAndPress(t, 'big.bin', '복사'); await t.tap(find.widgetWithText(FilledButton, '복사')); diff --git a/integration_test/windows_bigtext_shot_test.dart b/integration_test/windows_bigtext_shot_test.dart index 2cad2b7..bcd3318 100644 --- a/integration_test/windows_bigtext_shot_test.dart +++ b/integration_test/windows_bigtext_shot_test.dart @@ -60,9 +60,9 @@ void main() { final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings ..explorer.paths = [tmp.path, tmp.path] - ..rsyncPaths = [tmp.path, tmp.path] - ..copyTasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, 'sub'), method: 'rsync', options: '-av')] - ..liveSyncPairs = [LiveSyncPair(tmp.path, p.join(tmp.path, 'x'))]; + ..copy.rsyncPaths = [tmp.path, tmp.path] + ..copy.tasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, 'sub'), method: 'rsync', options: '-av')] + ..copy.liveSyncPairs = [LiveSyncPair(tmp.path, p.join(tmp.path, 'x'))]; c.videos.add(VideoItem(video)); // 256: 폰 가로에서 작업 기록을 켜도 목록이 여러 줄 보이는지 - 동영상 5개 · 기록 몇 줄 for (var i = 2; i <= 5; i++) { diff --git a/integration_test/windows_live_excl_shot_test.dart b/integration_test/windows_live_excl_shot_test.dart index 36f93d9..a5a21bd 100644 --- a/integration_test/windows_live_excl_shot_test.dart +++ b/integration_test/windows_live_excl_shot_test.dart @@ -51,12 +51,12 @@ void main() { } final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings - ..rsyncSource = 'custom' - ..rsyncPath = rsync - ..rsyncOptions = '-av --exclude=*.tmp --delete-excluded'; + ..copy.rsyncSource = 'custom' + ..copy.rsyncPath = rsync + ..copy.rsyncOptions = '-av --exclude=*.tmp --delete-excluded'; // 이미 --exclude 만으로 확인한 쌍에 --delete-excluded 를 더함 → 다시 묻는 창 (주황 이유 줄 · 거른 것 묶음) final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true, deleteConfirmed: true, confirmedOptions: '-av --exclude=*.tmp'); - c.settings.liveSyncPairs = [pair]; + c.settings.copy.liveSyncPairs = [pair]; final live = LiveSync(c); addTearDown(live.dispose); await t.runAsync(() => live.syncNow(pair)); diff --git a/integration_test/windows_lsync_p0_shot_test.dart b/integration_test/windows_lsync_p0_shot_test.dart index 1ed8d93..a0fea9b 100644 --- a/integration_test/windows_lsync_p0_shot_test.dart +++ b/integration_test/windows_lsync_p0_shot_test.dart @@ -47,13 +47,13 @@ void main() { File(p.join(tmp.path, 'rules.txt')).writeAsStringSync('H a.txt\n'); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings - ..rsyncSource = 'custom' - ..rsyncPath = rsync - ..rsyncOptions = '-a --filter="merge ${toCygwinPath(p.join(tmp.path, 'rules.txt'))}"' - ..liveSyncPairs = [LiveSyncPair(src, dst, method: 'rsync', delete: true, deleteConfirmed: true)]; + ..copy.rsyncSource = 'custom' + ..copy.rsyncPath = rsync + ..copy.rsyncOptions = '-a --filter="merge ${toCygwinPath(p.join(tmp.path, 'rules.txt'))}"' + ..copy.liveSyncPairs = [LiveSyncPair(src, dst, method: 'rsync', delete: true, deleteConfirmed: true)]; final live = LiveSync(c)..start(); addTearDown(live.dispose); - final pair = c.settings.liveSyncPairs.single; + final pair = c.settings.copy.liveSyncPairs.single; await t.runAsync(() => live.syncNow(pair)); expect(live.noAutoDelete[LiveSync.keyOf(pair)], isNotEmpty); final shotKey = GlobalKey(); diff --git a/integration_test/windows_move_left_shot_test.dart b/integration_test/windows_move_left_shot_test.dart index ba39dc6..fef9f3a 100644 --- a/integration_test/windows_move_left_shot_test.dart +++ b/integration_test/windows_move_left_shot_test.dart @@ -48,7 +48,7 @@ void main() { final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings ..explorer.paths = [left, right] - ..copyConflict = 'overwrite'; + ..copy.conflict = 'overwrite'; final root = p.rootPrefix(tmp.path); device.volumes = () => [(root, root.replaceAll(RegExp(r'[\\/]+$'), ''))]; final shotKey = GlobalKey(); diff --git a/integration_test/windows_rsync_narrow_shot_test.dart b/integration_test/windows_rsync_narrow_shot_test.dart index eb737fb..317b72d 100644 --- a/integration_test/windows_rsync_narrow_shot_test.dart +++ b/integration_test/windows_rsync_narrow_shot_test.dart @@ -51,7 +51,7 @@ void main() { final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings ..explorer.orientation = 'side' - ..rsyncPaths = [l, r]; + ..copy.rsyncPaths = [l, r]; final shotKey = GlobalKey(); await t.pumpWidget(MaterialApp( theme: buildTheme(), diff --git a/lib/app/copy_center.dart b/lib/app/copy_center.dart index 34a7307..9816674 100644 --- a/lib/app/copy_center.dart +++ b/lib/app/copy_center.dart @@ -28,7 +28,7 @@ class CopyCenter extends ChangeNotifier { /// 작업 id → 지금 (또는 마지막) 실행 final jobs = {}; - List get tasks => c.settings.copyTasks; + List get tasks => c.settings.copy.tasks; bool isRunning(String id) => jobs[id] != null && !jobs[id]!.finished; @@ -50,7 +50,7 @@ class CopyCenter extends ChangeNotifier { CopyTask fresh(List sources, String dest, {bool move = false, bool contents = false, String? method}) { final s = c.settings; final hasDir = sources.any((s) => isDav(s) || FileSystemEntity.isDirectorySync(s)); - var m = CopyMethod.of(method ?? (hasDir ? s.copyMethodFolder : s.copyMethodFile)); + var m = CopyMethod.of(method ?? (hasDir ? s.copy.methodFolder : s.copy.methodFile)); if (!copyMethodAvailable(m, s)) m = CopyMethod.builtin; // WebDAV 가 끼면 실제로는 앱이 직접 복사한다 (확인 창에 robocopy 로 잘못 보이지 않게). Rsync 화면 ([method] 지정) 은 그대로 if (method == null && [...sources, dest].any(isDav)) m = CopyMethod.builtin; @@ -61,9 +61,9 @@ class CopyCenter extends ChangeNotifier { move: move, contents: contents, method: m.name, - options: m == CopyMethod.robocopy ? s.robocopyOptions : s.rsyncOptions, - once: s.copyRunMode == 'once', - bandwidthKBps: s.copyBandwidthKBps, + options: m == CopyMethod.robocopy ? s.copy.robocopyOptions : s.copy.rsyncOptions, + once: s.copy.runMode == 'once', + bandwidthKBps: s.copy.bandwidthKBps, ); } @@ -79,20 +79,20 @@ class CopyCenter extends ChangeNotifier { final t = fresh(sources, dest, move: move, contents: contents, method: method); final old = tasks.where((x) => x.key == t.key).firstOrNull; if (old != null) return old; - await c.updateSettings((x) => x.copyTasks = [t, ...x.copyTasks]); + await c.updateSettings((x) => x.copy.tasks = [t, ...x.copy.tasks]); notifyListeners(); return t; } Future update(CopyTask t) async { - await c.updateSettings((x) => x.copyTasks = [for (final y in x.copyTasks) y.id == t.id ? t : y]); + await c.updateSettings((x) => x.copy.tasks = [for (final y in x.copy.tasks) y.id == t.id ? t : y]); notifyListeners(); } Future remove(String id) async { cancel(id); jobs.remove(id); - await c.updateSettings((x) => x.copyTasks = [for (final y in x.copyTasks) if (y.id != id) y]); + await c.updateSettings((x) => x.copy.tasks = [for (final y in x.copy.tasks) if (y.id != id) y]); notifyListeners(); } @@ -126,7 +126,7 @@ class CopyCenter extends ChangeNotifier { contents: t.contents, prune: t.move ? t.prune : '', conflict: conflict, - skipNewer: c.settings.copySkipNewer, + skipNewer: c.settings.copy.skipNewer, ); jobs[t.id] = job; job.addListener(notifyListeners); @@ -170,13 +170,13 @@ class CopyCenter extends ChangeNotifier { ]; final rest = t.sources.where((s) => !dirs.contains(s)).toList(); await c.updateSettings((x) => x - ..liveSyncPairs = [ - ...x.liveSyncPairs, + ..copy.liveSyncPairs = [ + ...x.copy.liveSyncPairs, for (final n in pairs) - if (!x.liveSyncPairs.any((o) => o.source == n.source && o.target == n.target)) n, + if (!x.copy.liveSyncPairs.any((o) => o.source == n.source && o.target == n.target)) n, ] - ..copyTasks = [ - for (final y in x.copyTasks) + ..copy.tasks = [ + for (final y in x.copy.tasks) if (y.id != t.id) y else if (rest.isNotEmpty) @@ -203,8 +203,8 @@ class CopyCenter extends ChangeNotifier { final m = copyMethodAvailable(CopyMethod.rsync, s) ? 'rsync' : x.method; final t = fresh([x.source], x.target, contents: true, method: m); await c.updateSettings((y) => y - ..liveSyncPairs = [for (final o in y.liveSyncPairs) if (!(o.source == x.source && o.target == x.target)) o] - ..copyTasks = [t, ...y.copyTasks]); + ..copy.liveSyncPairs = [for (final o in y.copy.liveSyncPairs) if (!(o.source == x.source && o.target == x.target)) o] + ..copy.tasks = [t, ...y.copy.tasks]); notifyListeners(); return t; } diff --git a/lib/app/live_sync.dart b/lib/app/live_sync.dart index c34dec4..e90ea95 100644 --- a/lib/app/live_sync.dart +++ b/lib/app/live_sync.dart @@ -41,8 +41,8 @@ Future findBundledRsync() async { /// - 직접 지정: 그 파일 /// - 기본: 앱에 들어 있는 것, 없으면 (Windows) 내려받아 둔 것 Future rsyncExecutable(AppSettings s) async { - if (s.rsyncSource == 'custom') { - return s.rsyncPath.isNotEmpty && File(s.rsyncPath).existsSync() ? s.rsyncPath : null; + if (s.copy.rsyncSource == 'custom') { + return s.copy.rsyncPath.isNotEmpty && File(s.copy.rsyncPath).existsSync() ? s.copy.rsyncPath : null; } final b = bundledRsync; if (b != null && File(b).existsSync()) return b; @@ -54,11 +54,11 @@ Future rsyncExecutable(AppSettings s) async { bool copyMethodAvailable(CopyMethod m, AppSettings s) => switch (m) { CopyMethod.builtin => true, CopyMethod.robocopy => Platform.isWindows, - CopyMethod.rsync => Platform.isWindows || s.rsyncSource == 'custom' || bundledRsync != null, + CopyMethod.rsync => Platform.isWindows || s.copy.rsyncSource == 'custom' || bundledRsync != null, }; /// 실시간 동기화 (lsyncd 처럼): 설정의 폴더 쌍마다 원본을 지켜보다가 바뀌면 대상에 맞춘다. -/// Windows 는 폴더 감시로 바로 (모아서 3초 뒤), 그 밖은 [AppSettings.liveSyncIntervalSec] 마다 살핀다. +/// Windows 는 폴더 감시로 바로 (모아서 3초 뒤), 그 밖은 [CopySettings.liveSyncIntervalSec] 마다 살핀다. /// 앱이 켜져 있는 동안만 동작하고, 시작할 때 한 번 맞춘다. class LiveSync extends ChangeNotifier { final AppController c; @@ -82,13 +82,13 @@ class LiveSync extends ChangeNotifier { /// 1분마다: 동작 시간 (cron) 이 시작되면 밀린 것을 맞춘다 Timer? _clock; - /// 이번 실행 동안 멈춘 쌍 (설정의 켜기 · 끄기와 따로, 앱을 다시 켜면 [AppSettings.liveSyncOnStart] 를 따른다) + /// 이번 실행 동안 멈춘 쌍 (설정의 켜기 · 끄기와 따로, 앱을 다시 켜면 [CopySettings.liveSyncOnStart] 를 따른다) final paused = {}; /// [hold] 면 켜진 쌍을 모두 멈춘 채로 시작 (모니터링 · [resume] 으로 시작) void start({bool hold = false}) { instance = this; - if (hold) paused.addAll(c.settings.liveSyncPairs.where((x) => x.enabled).map(keyOf)); + if (hold) paused.addAll(c.settings.copy.liveSyncPairs.where((x) => x.enabled).map(keyOf)); c.addListener(_apply); _apply(); _clock ??= Timer.periodic(const Duration(minutes: 1), (_) => _onClock()); @@ -120,19 +120,19 @@ class LiveSync extends ChangeNotifier { /// 지금 지켜보는 쌍 (켜져 있고 멈추지 않은 것) List get watching => [ - for (final x in c.settings.liveSyncPairs) + for (final x in c.settings.copy.liveSyncPairs) if (x.enabled && !paused.contains(keyOf(x))) x, ]; void pause(LiveSyncPair x) => _setPaused({...paused, keyOf(x)}); void resume(LiveSyncPair x) => _setPaused({...paused}..remove(keyOf(x))); - void pauseAll() => _setPaused({...paused, ...c.settings.liveSyncPairs.map(keyOf)}); + void pauseAll() => _setPaused({...paused, ...c.settings.copy.liveSyncPairs.map(keyOf)}); void resumeAll() => _setPaused({}); /// 시작할 때 고른 쌍만 (나머지는 멈춤) void runOnly(Iterable xs) { final keep = xs.map(keyOf).toSet(); - _setPaused({for (final x in c.settings.liveSyncPairs) if (!keep.contains(keyOf(x))) keyOf(x)}); + _setPaused({for (final x in c.settings.copy.liveSyncPairs) if (!keep.contains(keyOf(x))) keyOf(x)}); } void _setPaused(Set v) { @@ -153,10 +153,10 @@ class LiveSync extends ChangeNotifier { /// 원본과 대상의 다른 점 (크기 · 바뀐 시각). [x.delete] 면 원본에 없는 대상 항목도 "− 이름" 으로. /// 관리자 지시 (10/19): rsync 쌍이 원격이라 앱이 맞출 때 (그리고 그 대기 목록) 도 환경 설정 rsync 옵션의 거르기를 따른다. /// 로컬 rsync 쌍은 rsync 가 직접 따르므로 대기 목록 (diff) 에도 같이 - RsyncFilter _filterOf(LiveSyncPair x) => x.method == 'rsync' ? RsyncFilter.parse(c.settings.rsyncOptions) : RsyncFilter.none; + RsyncFilter _filterOf(LiveSyncPair x) => x.method == 'rsync' ? RsyncFilter.parse(c.settings.copy.rsyncOptions) : RsyncFilter.none; /// 지울 것에 영향을 주는 옵션 (rsync 쌍만 - 거르기 · --delete-excluded). 확인할 때 쌍에 기억한다 ([LiveSyncPair.confirmedOptions]) - String _optionsOf(LiveSyncPair x) => x.method == 'rsync' ? c.settings.rsyncOptions : ''; + String _optionsOf(LiveSyncPair x) => x.method == 'rsync' ? c.settings.copy.rsyncOptions : ''; /// 관리자 3a 판단 (10/20): 확인한 쌍이라도 옵션이 바뀌어 지울 것이 늘었으면 다시 묻는다 (로컬 · 원격 쌍 모두). /// 늘지 않았으면 (줄거나 같음) 묻지 않고 지금 옵션을 기억한다. 예전에 확인한 쌍 (기억한 옵션 없음) 은 예전 동작 @@ -419,8 +419,8 @@ class LiveSync extends ChangeNotifier { } /// 설정의 쌍을 바꾼다 (같은 원본 → 대상) - Future _setPair(LiveSyncPair old, LiveSyncPair now) => c.updateSettings((s) => s.liveSyncPairs = [ - for (final p in s.liveSyncPairs) keyOf(p) == keyOf(old) ? now : p, + Future _setPair(LiveSyncPair old, LiveSyncPair now) => c.updateSettings((s) => s.copy.liveSyncPairs = [ + for (final p in s.copy.liveSyncPairs) keyOf(p) == keyOf(old) ? now : p, ]); /// 지울 목록을 보고 결정: [delete] true = 지우기 포함으로 맞추기 (확인함), false = 지우기 끄기. 그 뒤 바로 맞춘다 @@ -449,7 +449,7 @@ class LiveSync extends ChangeNotifier { void _apply() { final s = c.settings; final pairs = watching; - final sig = jsonEncode([for (final x in pairs) x.toJson(), s.liveSyncIntervalSec]); + final sig = jsonEncode([for (final x in pairs) x.toJson(), s.copy.liveSyncIntervalSec]); if (sig == _sig) return; _sig = sig; _stopAll(); @@ -475,11 +475,11 @@ class LiveSync extends ChangeNotifier { _schedule(x); }); } catch (_) { - _timers[k] = Timer.periodic(Duration(seconds: s.liveSyncIntervalSec), (_) => _schedule(x)); + _timers[k] = Timer.periodic(Duration(seconds: s.copy.liveSyncIntervalSec), (_) => _schedule(x)); } } else { // Android 등 · 원본이 WebDAV: 폴더 안쪽까지 감시가 안 되므로 정해진 간격으로 살핀다 - _timers[k] = Timer.periodic(Duration(seconds: s.liveSyncIntervalSec), (_) => _schedule(x)); + _timers[k] = Timer.periodic(Duration(seconds: s.copy.liveSyncIntervalSec), (_) => _schedule(x)); } _schedule(x); // 시작할 때 한 번 } @@ -581,7 +581,7 @@ class LiveSync extends ChangeNotifier { // 한쪽이라도 WebDAV 면 rsync · robocopy 대신 앱이 맞춘다 (크기 · 시각 비교) final method = isDav(x.source) || isDav(x.target) ? CopyMethod.builtin : CopyMethod.of(x.method); // M1 (10/19): 원격 쌍에서 받는 쪽의 같은 이름 폴더는 지우기 포함 (--delete) 이 아니면 지우지 않고 건너뛴다 - 카드에 알린다 - final ops = FileOps(bandwidthKBps: s.copyBandwidthKBps); + final ops = FileOps(bandwidthKBps: s.copy.bandwidthKBps); final n = switch (method) { CopyMethod.builtin => await ops.mirror(x.source, x.target, delete: x.delete, allowEmptySource: allowEmpty, filter: _filterOf(x)), CopyMethod.rsync => await _rsync(x, s), @@ -650,15 +650,15 @@ class LiveSync extends ChangeNotifier { // 107: 원본을 지우는 옵션 (--remove-source-files) 도 실시간 동기화에서는 쓰지 않는다 // 관리자 판단 (10/20, 28 확인): 목록 · 기록 · 배치 파일의 C:\… 는 rsync 가 읽는 꼴로 (rsyncFileOptionArgs) ...rsyncFileOptionArgs([ - for (final o in splitOptions(s.rsyncOptions)) + for (final o in splitOptions(s.copy.rsyncOptions)) if (o != '-P' && o != '--progress' && !isRsyncDeleteOption(o) && !isSourceRemovingOption(o)) o, ], windows: windows), if (!windows) '-8', // 한글 등 이름을 \#355… 로 바꾸지 않고 그대로 (Android 빌드는 iconv 없음) - if (s.copyBandwidthKBps > 0) '--bwlimit=${s.copyBandwidthKBps}', + if (s.copy.bandwidthKBps > 0) '--bwlimit=${s.copy.bandwidthKBps}', if (x.delete) '--delete', // 10/20 (15, 28 의 장면으로 찾음): --delete-excluded 는 쌍이 지우기 포함일 때만 남긴다 - 예전에는 늘 빼서, 지울지 묻는 창 // (diff 는 옵션의 거르기를 따름) 에 보인 거른 것을 실제로는 지우지 않고 맞출 것에 계속 남았다 (원격 쌍 · Rsync 화면은 따름) - if (x.delete && splitOptions(s.rsyncOptions).contains('--delete-excluded')) '--delete-excluded', + if (x.delete && splitOptions(s.copy.rsyncOptions).contains('--delete-excluded')) '--delete-excluded', ...appTempRsyncExcludes, // 78 보충: 이름 바꾸는 중의 임시 이름은 보내지도 지우지도 않는다 '${toCygwinPath(x.source, windows: windows)}/', '${rsyncDestPath(x.target, windows: windows)}/', // 받는 쪽 UNC 는 \\서버\… (28 · 15 확인 10/20) @@ -667,7 +667,7 @@ class LiveSync extends ChangeNotifier { /// robocopy 인수 (실시간 동기화). 지우기 (/MIR · /PURGE) 는 쌍의 "지우기 포함" 으로만 (/MIR 는 /E 로) - 97 static List robocopyArgsFor(LiveSyncPair x, AppSettings s) { final opts = [ - for (final o in splitOptions(s.robocopyOptions)) + for (final o in splitOptions(s.copy.robocopyOptions)) if (o.toUpperCase() == '/MIR') '/E' else if (o.toUpperCase() != '/PURGE' && !isSourceRemovingOption(o)) o, ]; return [ @@ -679,7 +679,7 @@ class LiveSync extends ChangeNotifier { if (!robocopyLinkOption(opts)) '/XJ', if (x.delete) '/PURGE', ...appTempRobocopyExcludes, // 78 보충 - if (s.copyBandwidthKBps > 0) '/IPG:${robocopyIpg(s.copyBandwidthKBps)}', + if (s.copy.bandwidthKBps > 0) '/IPG:${robocopyIpg(s.copy.bandwidthKBps)}', '/BYTES', '/NJH', '/NJS', '/NDL', '/NP', ]; } diff --git a/lib/app/settings.dart b/lib/app/settings.dart index 678f2c7..9ea232a 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -16,6 +16,7 @@ import '../services/app_shell.dart' show appIconOf; import '../core/remote_idle.dart'; import 'settings/records.dart'; import 'settings/settings_group.dart'; +import 'settings/copy_settings.dart'; import 'settings/tor_settings.dart'; import 'settings/network_settings.dart'; import 'settings/explorer_settings.dart'; @@ -23,6 +24,7 @@ import 'settings/explorer_settings.dart'; export 'settings/records.dart'; export 'settings/settings_group.dart'; export 'settings/settings_store.dart'; +export 'settings/copy_settings.dart'; export 'settings/tor_settings.dart'; export 'settings/network_settings.dart'; export 'settings/explorer_settings.dart'; @@ -226,42 +228,18 @@ class AppSettings { // ── 복사 · 이동 (파일 탐색기) · rsync · lsync (Rsync 화면) ── - /// 파일 탐색기의 복사 · 이동 방법: 'builtin' 현재 방식 (기본) · 'robocopy' (Windows). 파일만 고를 때 / 폴더가 들어 있을 때. - /// rsync 는 Rsync 화면에서 따로 (파일 탐색기에서는 쓰지 않음) - String copyMethodFile = 'builtin'; - /// 48: 복사 · 이동할 때 받는 폴더에 같은 이름이 있으면 'ask' (확인 창에서 고름, 처음 값) · 'rename' · 'overwrite' · 'skip' - String copyConflict = 'ask'; /// P0: MKV 를 만들 때 결과 MKV 가 이미 있으면 - 'ask' 묻기 (처음 값) · 'overwrite' 덮어쓰기 · 'skip' 건너뛰기 · 'rename' 다른 이름. /// 덮어쓰기여도 다 만든 뒤에만 바꿔 넣는다 (도중에 취소 · 실패하면 예전 것 그대로) String mkvExisting = 'ask'; - /// 229: 앱이 직접 하는 복사 · 이동 (현재 방식 · WebDAV 의 안의 것 맞추기) 에서 받는 쪽이 더 새것이면 건너뛴다 (rsync -u 와 같음). - /// 처음 값 켜짐 - 예전 원본으로 새 파일을 덮어쓰고 (이동이면 원본까지 지워) 새 내용을 잃었다 (관리자 판단) - bool copySkipNewer = true; - String copyMethodFolder = 'builtin'; - String rsyncOptions = '-avPog'; - String robocopyOptions = '/E /COPY:DAT /DCOPY:T /R:2 /W:2'; - /// 여러 개를 고르면: 'each' 항목마다 따로 실행 (기본) · 'once' 한 번에 (rsync) - String copyRunMode = 'each'; - /// 속도 제한 KB/s (0 = 제한 없음). 모든 방법에 (rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게) - int copyBandwidthKBps = 0; - /// rsync 가져오기: 'download' 앱에 들어 있는 것 (기본, Windows 는 없으면 내려받기) · 'custom' 직접 지정한 실행 파일 ([rsyncPath]) - String rsyncSource = 'download'; - String rsyncPath = ''; - /// 실시간 동기화 (lsyncd 처럼) - List liveSyncPairs = []; - /// 모니터링 (Rsync 화면 가운데 [모니터링]): 실행한 rsync 를 기억해 다시 실행 · 옵션 고치기 · lsync 로 옮기기 - List copyTasks = []; - /// Rsync 화면에서 마지막으로 연 폴더 (왼쪽 · 오른쪽) - List rsyncPaths = []; /// 설치한 (켠) 컴포넌트 id (app/components.dart). 처음엔 기본으로 들어 있는 것 모두 List components = AppComponent.defaultInstalled; @@ -303,8 +281,6 @@ class AppSettings { /// 99 (Windows): 시작 메뉴에 등록 - 알림이 JJ_MKVMaker 로 보이고 눌러서 열린다 bool startMenuShortcut = true; - /// 92 (Windows): 실시간 동기화가 멈췄을 때 Windows 알림 - bool syncStopToast = true; // ── 102: 사용자에게 물어 정한 것 · 관리자 1차 판단 (기본값 = 그 답) ── @@ -432,15 +408,11 @@ class AppSettings { /// 저장한 뒤 'upscaled' 올린 것으로 계속 보기 · 'original' 원본으로 String aiUpAfter = 'upscaled'; - /// 실시간 동기화 확인 간격 (초). Windows 는 바뀌면 바로, 그 밖은 이 간격으로 살핀다. - int liveSyncIntervalSec = 30; /// 백그라운드로 실행 (Android): 켜면 ← 로 닫거나 최근 앱에서 밀어도 동기화 · MKV 만들기 · 다운로드를 계속한다 (알림에 표시). /// 끄면 지금처럼 닫을 때 끝난다. Windows 는 [closeAction] 'background' 가 같은 뜻 ([backgroundRun]). bool runInBackground = true; - /// 앱을 다시 켤 때 실시간 동기화: 'auto' 바로 시작 / 'ask' 골라서 시작 / 'off' 시작 안 함 (모니터링에서 시작) - String liveSyncOnStart = 'auto'; /// 백그라운드로 실행 (설정 화면의 체크 하나). Windows: 창 ✕ 를 누르면 트레이로 (끄면 종료), Android: [runInBackground] bool get backgroundRun => Platform.isWindows ? closeAction == 'background' : runInBackground; @@ -499,8 +471,11 @@ class AppSettings { /// Tor 釉뚮씪?곗? (?ъ슜???붿껌 10/10) final tor = TorBrowserSettings(); + /// 복사 · 이동 (파일 탐색기) · rsync · 실시간 동기화 (Rsync 화면) + final copy = CopySettings(); + /// 설정 분야들 (설정 A) - 저마다 자기 키를 읽고 쓴다. toJson · fromJson 은 이 목록을 돌며 합치기만 한다 - List get groups => [explorer, network, tor]; + List get groups => [explorer, network, tor, copy]; /// [plainSecrets]: 안전 저장소를 쓸 수 없을 때만 (예전 파일에 있던 비밀 값을 잃지 않게) 예전처럼 파일에 둔다 Map toJson({bool plainSecrets = false}) { @@ -571,12 +546,7 @@ class AppSettings { 'rootEnabled': rootEnabled, 'vaultMasterCopy': vaultMasterCopy, 'vaultAutoLockSeconds': vaultAutoLockSeconds, - 'copyMethodFile': copyMethodFile, - 'copyConflict': copyConflict, 'mkvExisting': mkvExisting, - 'copySkipNewer': copySkipNewer, - 'copyMethodFolder': copyMethodFolder, - 'rsyncPaths': rsyncPaths, 'components': components, 'aiImageV1': true, 'robocopyLinksV1': true, @@ -592,7 +562,6 @@ class AppSettings { 'readerVolumeKeys': readerVolumeKeys, 'readerSplit': readerSplit, 'startMenuShortcut': startMenuShortcut, - 'syncStopToast': syncStopToast, 'swipeWrap': swipeWrap, 'browserSwipeHinted': browserSwipeHinted, 'cleanupPrechecked': cleanupPrechecked, @@ -647,18 +616,8 @@ class AppSettings { 'smbRequireSecure': smbRequireSecure, 'remoteIdleSeconds': remoteIdleSeconds, 'ftpServers': [for (final x in ftpServers) x.toJson()], - 'rsyncOptions': rsyncOptions, - 'robocopyOptions': robocopyOptions, - 'copyRunMode': copyRunMode, - 'copyBandwidthKBps': copyBandwidthKBps, - 'rsyncSource': rsyncSource, - 'rsyncPath': rsyncPath, - 'liveSyncPairs': [for (final x in liveSyncPairs) x.toJson()], - 'liveSyncIntervalSec': liveSyncIntervalSec, 'runInBackground': runInBackground, 'backgroundV2': true, - 'liveSyncOnStart': liveSyncOnStart, - 'copyTasks': [for (final x in copyTasks) x.toJson()], 'autoCheckUpdates': autoCheckUpdates, 'lastUpdateCheck': lastUpdateCheck, 'lastUpdateCheckVersion': lastUpdateCheckVersion, @@ -689,8 +648,6 @@ class AppSettings { return j; } - /// 파일 탐색기의 복사 방법 (rsync 는 Rsync 화면으로 옮겨 예전 값 rsync 도 현재 방식으로) - static String _method(Object? v) => v == 'robocopy' ? 'robocopy' : 'builtin'; /// 색 보정 값 (-100 ~ 100) static int _adj(Object? v) => ((v as num?)?.round() ?? 0).clamp(-100, 100); @@ -762,12 +719,7 @@ class AppSettings { ..wifiShareMinutes = const [0, 10, 30, 60, 120].contains(j['wifiShareMinutes']) ? j['wifiShareMinutes'] as int : 30 ..wifiShareTailscale = j['wifiShareTailscale'] is bool ? j['wifiShareTailscale'] as bool : true ..wifiShareFolder = j['wifiShareFolder'] is String ? j['wifiShareFolder'] as String : '' - ..copyMethodFile = _method(j['copyMethodFile']) - ..copyConflict = const ['rename', 'overwrite', 'skip'].contains(j['copyConflict']) ? j['copyConflict'] as String : 'ask' ..mkvExisting = const ['rename', 'overwrite', 'skip'].contains(j['mkvExisting']) ? j['mkvExisting'] as String : 'ask' - ..copySkipNewer = j['copySkipNewer'] != false - ..copyMethodFolder = _method(j['copyMethodFolder']) - ..rsyncPaths = [for (final x in (j['rsyncPaths'] as List?) ?? const []) '$x'] ..components = j['components'] is List ? [ // 225: 이 판이 모르는 구성 요소 (새 판의 Tor 등) 도 지우지 않고 그대로 둔다 - 예전 판을 거쳐 다시 올려도 켠 것이 남게 (40 과 같은 원칙). @@ -794,7 +746,6 @@ class AppSettings { ..readerVolumeKeys = j['readerVolumeKeys'] as bool? ?? false ..readerSplit = const ['auto', 'on', 'off'].contains(j['readerSplit']) ? j['readerSplit'] as String : 'auto' ..startMenuShortcut = j['startMenuShortcut'] != false - ..syncStopToast = j['syncStopToast'] != false ..swipeWrap = j['swipeWrap'] != false ..browserSwipeHinted = j['browserSwipeHinted'] == true ..cleanupPrechecked = j['cleanupPrechecked'] is List @@ -863,24 +814,8 @@ class AppSettings { for (final x in (j['ftpServers'] as List?) ?? const []) if (x is Map) FtpServer.fromJson(x), ] - ..rsyncOptions = j['rsyncOptions'] as String? ?? '-avPog' - ..robocopyOptions = j['robocopyOptions'] as String? ?? '/E /COPY:DAT /DCOPY:T /R:2 /W:2' - ..copyRunMode = j['copyRunMode'] == 'once' ? 'once' : 'each' - ..copyBandwidthKBps = ((j['copyBandwidthKBps'] as num?)?.toInt() ?? 0).clamp(0, 10000000) - ..rsyncSource = j['rsyncSource'] == 'custom' ? 'custom' : 'download' - ..rsyncPath = j['rsyncPath'] as String? ?? '' - ..liveSyncPairs = [ - for (final x in (j['liveSyncPairs'] as List?) ?? const []) - if (x is Map && x['source'] is String && x['target'] is String) LiveSyncPair.fromJson(x), - ] - ..liveSyncIntervalSec = ((j['liveSyncIntervalSec'] as num?)?.toInt() ?? 30).clamp(5, 3600) // 사용자 결정 (10/8): 기본 켜짐. 예전 기본 (꺼짐) 은 한 번 켜짐으로 ..runInBackground = j['backgroundV2'] == true ? j['runInBackground'] != false : true - ..liveSyncOnStart = const ['auto', 'ask', 'off'].contains(j['liveSyncOnStart']) ? j['liveSyncOnStart'] as String : 'auto' - ..copyTasks = [ - for (final x in (j['copyTasks'] as List?) ?? const []) - if (x is Map && x['dest'] is String) CopyTask.fromJson(x), - ] ..autoCheckUpdates = j['autoCheckUpdates'] as bool? ?? true ..lastUpdateCheck = j['lastUpdateCheck'] as String? ?? '' ..lastUpdateCheckVersion = j['lastUpdateCheckVersion'] as String? ?? '' diff --git a/lib/app/settings/copy_settings.dart b/lib/app/settings/copy_settings.dart new file mode 100644 index 0000000..c359328 --- /dev/null +++ b/lib/app/settings/copy_settings.dart @@ -0,0 +1,102 @@ + +import 'records.dart'; +import 'settings_group.dart'; + +/// 약속 (설정 A): 환경 설정 > 복사 · 이동 (파일 탐색기) · rsync · 실시간 동기화 (Rsync 화면) - 자기 키 (이름 · 처음 값 · 읽기 · 쓰기) 를 스스로 갖는다. +/// 설정 하나를 더할 때는 이 파일만 고친다. 저장 파일의 키 이름은 예전 그대로 (평평한 키) +class CopySettings implements SettingsGroup { + /// 파일 탐색기의 복사 · 이동 방법: 'builtin' 현재 방식 (기본) · 'robocopy' (Windows). 파일만 고를 때 / 폴더가 들어 있을 때. + /// rsync 는 Rsync 화면에서 따로 (파일 탐색기에서는 쓰지 않음) + String methodFile = 'builtin'; + + /// 48: 복사 · 이동할 때 받는 폴더에 같은 이름이 있으면 'ask' (확인 창에서 고름, 처음 값) · 'rename' · 'overwrite' · 'skip' + String conflict = 'ask'; + + /// 229: 앱이 직접 하는 복사 · 이동 (현재 방식 · WebDAV 의 안의 것 맞추기) 에서 받는 쪽이 더 새것이면 건너뛴다 (rsync -u 와 같음). + /// 처음 값 켜짐 - 예전 원본으로 새 파일을 덮어쓰고 (이동이면 원본까지 지워) 새 내용을 잃었다 (관리자 판단) + bool skipNewer = true; + + String methodFolder = 'builtin'; + + String rsyncOptions = '-avPog'; + + String robocopyOptions = '/E /COPY:DAT /DCOPY:T /R:2 /W:2'; + + /// 여러 개를 고르면: 'each' 항목마다 따로 실행 (기본) · 'once' 한 번에 (rsync) + String runMode = 'each'; + + /// 속도 제한 KB/s (0 = 제한 없음). 모든 방법에 (rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게) + int bandwidthKBps = 0; + + /// rsync 가져오기: 'download' 앱에 들어 있는 것 (기본, Windows 는 없으면 내려받기) · 'custom' 직접 지정한 실행 파일 ([rsyncPath]) + String rsyncSource = 'download'; + + String rsyncPath = ''; + + /// 실시간 동기화 (lsyncd 처럼) + List liveSyncPairs = []; + + /// 모니터링 (Rsync 화면 가운데 [모니터링]): 실행한 rsync 를 기억해 다시 실행 · 옵션 고치기 · lsync 로 옮기기 + List tasks = []; + + /// Rsync 화면에서 마지막으로 연 폴더 (왼쪽 · 오른쪽) + List rsyncPaths = []; + + /// 92 (Windows): 실시간 동기화가 멈췄을 때 Windows 알림 + bool syncStopToast = true; + + /// 실시간 동기화 확인 간격 (초). Windows 는 바뀌면 바로, 그 밖은 이 간격으로 살핀다. + int liveSyncIntervalSec = 30; + + /// 앱을 다시 켤 때 실시간 동기화: 'auto' 바로 시작 / 'ask' 골라서 시작 / 'off' 시작 안 함 (모니터링에서 시작) + String liveSyncOnStart = 'auto'; + + /// 파일 탐색기의 복사 방법 (rsync 는 Rsync 화면으로 옮겨 예전 값 rsync 도 현재 방식으로) + static String _method(Object? v) => v == 'robocopy' ? 'robocopy' : 'builtin'; + + @override + void write(Map j, {required bool plainSecrets}) { + j['copyMethodFile'] = methodFile; + j['copyConflict'] = conflict; + j['copySkipNewer'] = skipNewer; + j['copyMethodFolder'] = methodFolder; + j['rsyncOptions'] = rsyncOptions; + j['robocopyOptions'] = robocopyOptions; + j['copyRunMode'] = runMode; + j['copyBandwidthKBps'] = bandwidthKBps; + j['rsyncSource'] = rsyncSource; + j['rsyncPath'] = rsyncPath; + j['liveSyncPairs'] = [for (final x in liveSyncPairs) x.toJson()]; + j['liveSyncIntervalSec'] = liveSyncIntervalSec; + j['liveSyncOnStart'] = liveSyncOnStart; + j['copyTasks'] = [for (final x in tasks) x.toJson()]; + j['rsyncPaths'] = rsyncPaths; + j['syncStopToast'] = syncStopToast; + } + + @override + void read(Map j) { + methodFile = _method(j['copyMethodFile']); + conflict = const ['rename', 'overwrite', 'skip'].contains(j['copyConflict']) ? j['copyConflict'] as String : 'ask'; + skipNewer = j['copySkipNewer'] != false; + methodFolder = _method(j['copyMethodFolder']); + rsyncPaths = [for (final x in (j['rsyncPaths'] as List?) ?? const []) '$x']; + syncStopToast = j['syncStopToast'] != false; + rsyncOptions = j['rsyncOptions'] as String? ?? '-avPog'; + robocopyOptions = j['robocopyOptions'] as String? ?? '/E /COPY:DAT /DCOPY:T /R:2 /W:2'; + runMode = j['copyRunMode'] == 'once' ? 'once' : 'each'; + bandwidthKBps = ((j['copyBandwidthKBps'] as num?)?.toInt() ?? 0).clamp(0, 10000000); + rsyncSource = j['rsyncSource'] == 'custom' ? 'custom' : 'download'; + rsyncPath = j['rsyncPath'] as String? ?? ''; + liveSyncPairs = [ + for (final x in (j['liveSyncPairs'] as List?) ?? const []) + if (x is Map && x['source'] is String && x['target'] is String) LiveSyncPair.fromJson(x), + ]; + liveSyncIntervalSec = ((j['liveSyncIntervalSec'] as num?)?.toInt() ?? 30).clamp(5, 3600); + liveSyncOnStart = const ['auto', 'ask', 'off'].contains(j['liveSyncOnStart']) ? j['liveSyncOnStart'] as String : 'auto'; + tasks = [ + for (final x in (j['copyTasks'] as List?) ?? const []) + if (x is Map && x['dest'] is String) CopyTask.fromJson(x), + ]; + } +} diff --git a/lib/main.dart b/lib/main.dart index b5f3c44..9470c74 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -148,7 +148,7 @@ Future main(List args) async { await setupMasterLock(controller); // 124: 저장된 비밀번호를 쓰기 전에 (실시간 동기화보다 먼저) // 실시간 동기화 (환경 설정 > 파일 탐색기): 앱이 켜져 있는 동안. 다시 켤 때 바로 / 골라서 / 시작 안 함 await findBundledRsync(); // 앱에 들어 있는 rsync - final live = LiveSync(controller)..start(hold: controller.settings.liveSyncOnStart != 'auto'); + final live = LiveSync(controller)..start(hold: controller.settings.copy.liveSyncOnStart != 'auto'); // 화면 언어 (환경 설정 > 화면 언어) i18n.init(controller, dataDir); await i18n.apply(controller.settings.uiLanguage, save: false); @@ -286,7 +286,7 @@ Future main(List args) async { ?.showSnackBar(SnackBar(content: Text(trf('다운로드 추가: {0}', [t.source]))))); // 92: 트레이에 둔 채 실시간 동기화가 멈추면 Windows 알림 · 트레이 툴팁, 누르면 모니터링 > lsync 카드로 final syncAlert = SyncAlert(live, services.shell, - toastEnabled: () => controller.settings.syncStopToast, + toastEnabled: () => controller.settings.copy.syncStopToast, openLsync: () { final nav = navigatorKey.currentState; if (nav != null) unawaited(nav.push(MaterialPageRoute(builder: (_) => MonitorPage(c: controller, initialTab: 1)))); @@ -399,7 +399,7 @@ Future main(List args) async { if (home != 'mkv' && nav0 != null) { unawaited(AppNavButtons.openPage(nav0, home, c: controller, downloads: downloads, bookmarks: bookmarks)); } - if (controller.settings.liveSyncOnStart == 'ask' && ctx.mounted) await askLiveSyncStart(ctx, live); + if (controller.settings.copy.liveSyncOnStart == 'ask' && ctx.mounted) await askLiveSyncStart(ctx, live); // 설정 파일을 읽지 못했으면 (백업으로 되살렸거나 처음 설정으로 켰으면) 알린다 if (ctx.mounted) await showSettingsProblem(ctx, controller); watchSecretIssue(messengerKey, controller); @@ -595,7 +595,7 @@ Future runAndroid(String dataDir) async { await setupMasterLock(controller); // 124: 저장된 비밀번호를 쓰기 전에 (실시간 동기화보다 먼저) // 실시간 동기화 (환경 설정 > 파일 탐색기): 앱이 켜져 있는 동안. 다시 켤 때 바로 / 골라서 / 시작 안 함 await findBundledRsync(); // 앱에 들어 있는 rsync - final live = LiveSync(controller)..start(hold: controller.settings.liveSyncOnStart != 'auto'); + final live = LiveSync(controller)..start(hold: controller.settings.copy.liveSyncOnStart != 'auto'); // 화면 언어 (환경 설정 > 화면 언어) i18n.init(controller, dataDir); await i18n.apply(controller.settings.uiLanguage, save: false); @@ -703,7 +703,7 @@ Future runAndroid(String dataDir) async { } // 다시 켤 때 실시간 동기화를 골라서 시작 final ctx0 = navigatorKey.currentContext; - if (controller.settings.liveSyncOnStart == 'ask' && ctx0 != null && ctx0.mounted) await askLiveSyncStart(ctx0, live); + if (controller.settings.copy.liveSyncOnStart == 'ask' && ctx0 != null && ctx0.mounted) await askLiveSyncStart(ctx0, live); // 다른 버전을 쓰다가 돌아왔거나 앱을 다시 설치했으면 보관해 둔 설정을 되살릴지 (되살리면 앱을 끝내고 다시 켜 달라고) final current = await _currentVersion(services); final ctxV = navigatorKey.currentContext; diff --git a/lib/ui/archive_ui.dart b/lib/ui/archive_ui.dart index a36e373..c7e2e07 100644 --- a/lib/ui/archive_ui.dart +++ b/lib/ui/archive_ui.dart @@ -157,7 +157,7 @@ Future extractArchiveFlow(BuildContext context, AppController c, String return null; } var dest = choices.first.$1; - var conflict = switch (c.settings.copyConflict) { 'overwrite' => NameConflict.overwrite, 'skip' => NameConflict.skip, _ => NameConflict.rename }; + var conflict = switch (c.settings.copy.conflict) { 'overwrite' => NameConflict.overwrite, 'skip' => NameConflict.skip, _ => NameConflict.rename }; Future free(String d) async { var x = d; while (!Directory(x).existsSync() && p.dirname(x) != x) { @@ -220,7 +220,7 @@ Future extractArchiveFlow(BuildContext context, AppController c, String // 관리자 판단: 덮어쓴 파일은 휴지통에 남지 않는다 - 되돌릴 수 없다고 알린다 (받는 쪽이 더 새것이면 설정대로 그대로 둠) if (conflict == NameConflict.overwrite) Text( - c.settings.copySkipNewer + c.settings.copy.skipNewer ? tr('덮어쓴 파일은 되돌릴 수 없습니다 (받는 쪽이 더 새 파일은 그대로 둡니다).') : tr('덮어쓴 파일은 되돌릴 수 없습니다.'), style: TextStyle(color: JjColors.warning, fontSize: 12)), @@ -253,13 +253,13 @@ Future extractArchiveFlow(BuildContext context, AppController c, String if (kind == ArchiveKind.zip) { return extractZipPlan(local, dest, plan, conflict: conflict, - skipNewer: c.settings.copySkipNewer, + skipNewer: c.settings.copy.skipNewer, password: pw, cancelled: cancelled, onProgress: (d, t) => set(t == 0 ? null : d / t)); } return extract7zPlan(exe!, local, dest, plan, - conflict: conflict, skipNewer: c.settings.copySkipNewer, password: pw, cancelled: cancelled, onProgress: set); + conflict: conflict, skipNewer: c.settings.copy.skipNewer, password: pw, cancelled: cancelled, onProgress: set); }); say([ trf('{0}개를 풀었습니다: {1}', [r.done, vDisplay(dest)]), diff --git a/lib/ui/copy_sync_settings.dart b/lib/ui/copy_sync_settings.dart index 7401b2b..d0aab86 100644 --- a/lib/ui/copy_sync_settings.dart +++ b/lib/ui/copy_sync_settings.dart @@ -125,36 +125,36 @@ class _CopySyncSettingsState extends State { title: Text(tr('파일 복사 · 이동 방법')), subtitle: Text(tr('파일만 골랐을 때')), trailing: DropdownButton( - value: _valid(s.copyMethodFile), + value: _valid(s.copy.methodFile), items: _methods(), - onChanged: (v) => c.updateSettings((x) => x.copyMethodFile = v!), + onChanged: (v) => c.updateSettings((x) => x.copy.methodFile = v!), ), ), SettingTile( title: Text(tr('폴더 복사 · 이동 방법')), subtitle: Text(tr('폴더가 들어 있을 때')), trailing: DropdownButton( - value: _valid(s.copyMethodFolder), + value: _valid(s.copy.methodFolder), items: _methods(), - onChanged: (v) => c.updateSettings((x) => x.copyMethodFolder = v!), + onChanged: (v) => c.updateSettings((x) => x.copy.methodFolder = v!), ), ), - _options(tr('robocopy 옵션'), tr('예: /E /COPY:DAT /DCOPY:T /R:2 /W:2. 이동은 /MOVE 를 자동으로 붙임'), s.robocopyOptions, - defaultRobocopyOptions, (x, v) => x.robocopyOptions = v), + _options(tr('robocopy 옵션'), tr('예: /E /COPY:DAT /DCOPY:T /R:2 /W:2. 이동은 /MOVE 를 자동으로 붙임'), s.copy.robocopyOptions, + defaultRobocopyOptions, (x, v) => x.copy.robocopyOptions = v), ], // 48: 같은 이름이 있을 때 (앱이 직접 복사 · 이동할 때, robocopy · rsync 는 그 프로그램의 규칙) SettingTile( title: Text(tr('같은 이름이 있을 때')), subtitle: Text(tr('복사 · 이동할 폴더에 같은 이름이 있으면 (현재 방식 · WebDAV)')), trailing: DropdownButton( - value: const ['ask', 'rename', 'overwrite', 'skip'].contains(s.copyConflict) ? s.copyConflict : 'ask', + value: const ['ask', 'rename', 'overwrite', 'skip'].contains(s.copy.conflict) ? s.copy.conflict : 'ask', items: [ DropdownMenuItem(value: 'ask', child: Text(tr('묻기'))), DropdownMenuItem(value: 'rename', child: Text(tr('늘 이름 바꾸기'))), DropdownMenuItem(value: 'overwrite', child: Text(tr('늘 덮어쓰기'))), DropdownMenuItem(value: 'skip', child: Text(tr('늘 건너뛰기'))), ], - onChanged: (v) => c.updateSettings((x) => x.copyConflict = v ?? 'ask'), + onChanged: (v) => c.updateSettings((x) => x.copy.conflict = v ?? 'ask'), ), ), _skipNewerTile(), @@ -165,12 +165,12 @@ class _CopySyncSettingsState extends State { trailing: SizedBox( width: 140, child: TextFormField( - initialValue: '${s.copyBandwidthKBps}', + initialValue: '${s.copy.bandwidthKBps}', keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], decoration: const InputDecoration(isDense: true, border: OutlineInputBorder(), suffixText: 'KB/s'), onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), - onChanged: (v) => c.updateSettings((x) => x.copyBandwidthKBps = int.tryParse(v) ?? 0), + onChanged: (v) => c.updateSettings((x) => x.copy.bandwidthKBps = int.tryParse(v) ?? 0), ), ), ), @@ -180,8 +180,8 @@ class _CopySyncSettingsState extends State { /// Rsync 화면: rsync 옵션 · rsync 가져오기 · 실시간 동기화 (lsync) /// 229: 받는 쪽이 더 새것이면 건너뛰기 - 파일 탐색기 · Rsync 탭 두 곳에 같은 설정 (Rsync 화면 · 모니터링에서도 쓰여 찾기 쉽게) Widget _skipNewerTile() => SwitchListTile( - value: c.settings.copySkipNewer, - onChanged: (v) => c.updateSettings((x) => x.copySkipNewer = v), + value: c.settings.copy.skipNewer, + onChanged: (v) => c.updateSettings((x) => x.copy.skipNewer = v), title: Text(tr('받는 쪽이 더 새것이면 건너뛰기')), subtitle: Text(keepOptionsTogether(tr('현재 방식 · robocopy · WebDAV 로 복사 · 이동할 때 (rsync 의 -u, robocopy 의 /XO 와 같음). 건너뛴 파일은 이동이라도 원본에 남깁니다'))), ); @@ -193,7 +193,7 @@ class _CopySyncSettingsState extends State { // 111: 이미 실행한 쌍은 기억한 옵션을 쓴다는 것을 알린다 _options(tr('rsync 옵션'), tr('예: -avPog (보관 · 자세히 · 진행 · 소유자 · 그룹). 속도 제한은 파일 탐색기의 속도 제한을 함께 씀. ' '새로 맞추는 폴더 쌍에 씁니다 - 이미 실행한 쌍은 기억한 옵션을 쓰며 모니터링 > 복사 · rsync 에서 따로 고칩니다'), - s.rsyncOptions, defaultRsyncOptions, (x, v) => x.rsyncOptions = v, rsync: true), + s.copy.rsyncOptions, defaultRsyncOptions, (x, v) => x.copy.rsyncOptions = v, rsync: true), _skipNewerTile(), SettingTile( title: Text(tr('rsync 가져오기')), @@ -201,14 +201,14 @@ class _CopySyncSettingsState extends State { ? tr('확인 중…') : _rsync != null ? trf('사용: {0}', [_rsync!]) - : s.rsyncSource == 'download' + : s.copy.rsyncSource == 'download' ? (desk ? tr('이 설치본에는 rsync 가 들어 있지 않습니다 (rsync 로 복사할 때 · 오른쪽 [내려받기] 로 받습니다)') : tr('이 설치본에는 rsync 가 들어 있지 않습니다. 직접 지정하세요.')) : tr('지정한 파일이 없습니다')), trailing: Row(mainAxisSize: MainAxisSize.min, children: [ // 내려받기 · 내려받은 것 지우기 (앱에 들어 있는 것을 쓰면 보이지 않음) - if (desk && s.rsyncSource == 'download' && !_checking && (_rsync == null || _rsync != bundledRsync)) + if (desk && s.copy.rsyncSource == 'download' && !_checking && (_rsync == null || _rsync != bundledRsync)) _rsync == null ? TextButton( onPressed: () async { @@ -227,28 +227,28 @@ class _CopySyncSettingsState extends State { ), const SizedBox(width: 8), DropdownButton( - value: s.rsyncSource, + value: s.copy.rsyncSource, items: [ DropdownMenuItem(value: 'download', child: Text(tr('앱에 들어 있는 것 (기본)'))), DropdownMenuItem(value: 'custom', child: Text(tr('직접 지정'))), ], onChanged: (v) async { - await c.updateSettings((x) => x.rsyncSource = v!); + await c.updateSettings((x) => x.copy.rsyncSource = v!); await _check(); }, ), ]), ), - if (s.rsyncSource == 'custom') + if (s.copy.rsyncSource == 'custom') SettingTile( title: Text(tr('rsync 실행 파일')), - subtitle: Text(s.rsyncPath.isEmpty ? tr('고르지 않음') : s.rsyncPath), + subtitle: Text(s.copy.rsyncPath.isEmpty ? tr('고르지 않음') : s.copy.rsyncPath), trailing: TextButton( onPressed: () async { final r = await FilePicker.pickFiles(dialogTitle: tr('rsync 실행 파일')); final path = r.isEmpty ? null : r.single.path; if (path == null) return; - await c.updateSettings((x) => x.rsyncPath = path); + await c.updateSettings((x) => x.copy.rsyncPath = path); await _check(); }, child: Text(tr('고르기')), @@ -275,8 +275,8 @@ Future removeLiveSyncPair(BuildContext context, AppController c, LiveSyncP ok: tr('지우기'), ); if (!ok) return; - await c.updateSettings((y) => y.liveSyncPairs = [ - for (final o in y.liveSyncPairs) if (!(o.source == x.source && o.target == x.target)) o, + await c.updateSettings((y) => y.copy.liveSyncPairs = [ + for (final o in y.copy.liveSyncPairs) if (!(o.source == x.source && o.target == x.target)) o, ]); } @@ -374,7 +374,7 @@ class _LiveSyncTile extends StatelessWidget { if (ok != true) return; if (old == null) { await c.updateSettings((x) => - x.liveSyncPairs = [...x.liveSyncPairs, LiveSyncPair(src!, dst!, method: method, delete: delete, schedule: schedule)]); + x.copy.liveSyncPairs = [...x.copy.liveSyncPairs, LiveSyncPair(src!, dst!, method: method, delete: delete, schedule: schedule)]); return; } // 원본 · 대상이 바뀌면 새 쌍 (지우기 확인도 새로 - 42), 아니면 방법 · 지우기 · 일정만 @@ -382,21 +382,21 @@ class _LiveSyncTile extends StatelessWidget { final now = same ? old.copyWith(method: method, delete: delete, schedule: schedule) : LiveSyncPair(src!, dst!, method: method, delete: delete, schedule: schedule, enabled: old.enabled); - await c.updateSettings((x) => x.liveSyncPairs = [ - for (final o in x.liveSyncPairs) o.source == old.source && o.target == old.target ? now : o, + await c.updateSettings((x) => x.copy.liveSyncPairs = [ + for (final o in x.copy.liveSyncPairs) o.source == old.source && o.target == old.target ? now : o, ]); } @override Widget build(BuildContext context) { final live = LiveSync.instance; - final pairs = c.settings.liveSyncPairs; + final pairs = c.settings.copy.liveSyncPairs; Widget body() => Column(children: [ SettingTile( title: Text(tr('실시간 동기화 (lsyncd 처럼)')), subtitle: Text(Platform.isWindows ? tr('원본 폴더가 바뀌면 곧바로 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안). 시작할 때 한 번 맞춥니다.') - : trf('{0}초마다 원본 폴더를 살펴 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안).', [c.settings.liveSyncIntervalSec])), + : trf('{0}초마다 원본 폴더를 살펴 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안).', [c.settings.copy.liveSyncIntervalSec])), trailing: TextButton.icon( onPressed: () => _add(context, c), icon: const Icon(Icons.add, size: 18), @@ -414,13 +414,13 @@ class _LiveSyncTile extends StatelessWidget { ? tr('WebDAV 원본에 씁니다 (PC 의 폴더는 바뀌면 바로 맞춤). 짧을수록 빨리 맞추지만 서버 · 배터리를 더 씁니다.') : tr('짧을수록 빨리 맞추지만 배터리를 더 씁니다.')), trailing: DropdownButton( - value: c.settings.liveSyncIntervalSec, + value: c.settings.copy.liveSyncIntervalSec, items: [ - for (final v in {5, 10, 15, 30, 60, 120, 300, 600, 1800, 3600, c.settings.liveSyncIntervalSec}.toList()..sort()) + for (final v in {5, 10, 15, 30, 60, 120, 300, 600, 1800, 3600, c.settings.copy.liveSyncIntervalSec}.toList()..sort()) DropdownMenuItem(value: v, child: Text(v < 60 ? trf('{0}초', [v]) : trf('{0}분', [v ~/ 60]))), ], onChanged: (v) { - if (v != null) c.updateSettings((x) => x.liveSyncIntervalSec = v); + if (v != null) c.updateSettings((x) => x.copy.liveSyncIntervalSec = v); }, ), ), @@ -452,7 +452,7 @@ class _LiveSyncTile extends StatelessWidget { onPressed: () async { final r = await editSchedule(context, pairs[i].schedule); if (r == null) return; - await c.updateSettings((x) => x.liveSyncPairs = [ + await c.updateSettings((x) => x.copy.liveSyncPairs = [ for (var k = 0; k < pairs.length; k++) k == i ? pairs[k].copyWith(schedule: r) : pairs[k], ]); }, @@ -465,7 +465,7 @@ class _LiveSyncTile extends StatelessWidget { Switch( value: pairs[i].enabled, onChanged: (v) => c.updateSettings( - (x) => x.liveSyncPairs = [for (var k = 0; k < pairs.length; k++) k == i ? pairs[k].copyWith(enabled: v) : pairs[k]]), + (x) => x.copy.liveSyncPairs = [for (var k = 0; k < pairs.length; k++) k == i ? pairs[k].copyWith(enabled: v) : pairs[k]]), ), IconButton( tooltip: tr('고치기'), @@ -501,14 +501,14 @@ class LiveSyncRunOptions extends StatelessWidget { Widget build(BuildContext context) { final s = c.settings; final onStart = DropdownButton( - value: s.liveSyncOnStart, + value: s.copy.liveSyncOnStart, isDense: dense, items: [ DropdownMenuItem(value: 'auto', child: Text(tr('바로 시작 (기본)'))), DropdownMenuItem(value: 'ask', child: Text(tr('골라서 시작'))), DropdownMenuItem(value: 'off', child: Text(tr('시작 안 함 (모니터링에서 시작)'))), ], - onChanged: (v) => c.updateSettings((x) => x.liveSyncOnStart = v!), + onChanged: (v) => c.updateSettings((x) => x.copy.liveSyncOnStart = v!), ); void setBackground(bool? v) => c.updateSettings((x) => x.backgroundRun = v ?? false); if (dense) { diff --git a/lib/ui/explorer/explorer_controller.dart b/lib/ui/explorer/explorer_controller.dart index 952abb8..ed419dd 100644 --- a/lib/ui/explorer/explorer_controller.dart +++ b/lib/ui/explorer/explorer_controller.dart @@ -407,7 +407,7 @@ class ExplorerController extends ChangeNotifier { final i = panes.indexOf(q); final paths = [...panes.map((x) => x.current)]; if (rsync) { - unawaited(c.updateSettings((s) => s.rsyncPaths = paths)); + unawaited(c.updateSettings((s) => s.copy.rsyncPaths = paths)); } else { final hist = [dir, ...c.settings.explorer.history.where((h) => !samePath(h, dir))].take(30).toList(); unawaited(c.updateSettings((s) => s diff --git a/lib/ui/explorer/rsync_flow.dart b/lib/ui/explorer/rsync_flow.dart index da32461..0ca2ced 100644 --- a/lib/ui/explorer/rsync_flow.dart +++ b/lib/ui/explorer/rsync_flow.dart @@ -47,7 +47,7 @@ class ExplorerRsyncFlow { allowInnerToOuter: ctl.c.settings.allowInnerToOuter, jobs: this.jobs.list, viaDav: l != null && r != null && ExplorerTransfers.viaDav([l, r]), - copySkipNewer: ctl.c.settings.copySkipNewer); + copySkipNewer: ctl.c.settings.copy.skipNewer); if (run == null) { ctl.host.snack(problem!); if (missing) { diff --git a/lib/ui/explorer/transfer_flow.dart b/lib/ui/explorer/transfer_flow.dart index ffda595..e0c94a9 100644 --- a/lib/ui/explorer/transfer_flow.dart +++ b/lib/ui/explorer/transfer_flow.dart @@ -99,7 +99,7 @@ class ExplorerTransfers extends ChangeNotifier { final have = await rsyncExecutable(s); if (have != null) return have; if (!context.mounted) return null; - if (s.rsyncSource == 'custom' || !Platform.isWindows) { + if (s.copy.rsyncSource == 'custom' || !Platform.isWindows) { ctl.host.snack(tr('rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > 파일 탐색기에서 경로를 확인하세요.')); return null; } @@ -174,7 +174,7 @@ class ExplorerTransfers extends ChangeNotifier { // 다 찾기 전에 누르면 이름 바꾸기 (원래 파일을 건드리지 않는 쪽) final clashesFuture = builtin ? findClashes(sources, dest) : Future.value(const []); // 48: 환경 설정 "같은 이름이 있을 때" 가 늘 하는 것이면 묻지 않고 그대로 (확인 창에 알림만) - final always = switch (ctl.c.settings.copyConflict) { + final always = switch (ctl.c.settings.copy.conflict) { 'rename' => NameConflict.rename, 'overwrite' => NameConflict.overwrite, 'skip' => NameConflict.skip, @@ -240,7 +240,7 @@ class ExplorerTransfers extends ChangeNotifier { ? tr('안쪽 파일을 확인하는 중 …') : (n.data ?? 0) == 0 ? '' - : ctl.c.settings.copySkipNewer + : ctl.c.settings.copy.skipNewer ? trf('안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)', [n.data]) : trf('안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다', [n.data]), style: TextStyle(fontSize: 12, color: JjColors.warning), diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index b97abf6..672927e 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -184,7 +184,7 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex // X-plore 비교 e: 보관 일이 지난 휴지통 항목을 지운다 (뒤에서) if (_flows.deletes.appTrash case final t?) unawaited(t.purgeOlderThan(c.settings.explorer.androidTrashDays).catchError((_) => 0)); if (Platform.isAndroid) unawaited(_access.checkAccess()); - final saved = widget.rsync ? c.settings.rsyncPaths : c.settings.explorer.paths; + final saved = widget.rsync ? c.settings.copy.rsyncPaths : c.settings.explorer.paths; // 창마다 따로 읽는다: 한 창이 WebDAV 서버를 기다려도 다른 창 (이 기기 파일) 은 바로 보이게 // 165: 지난 폴더가 느린 네트워크 드라이브여도 화면이 멈추지 않게 (뒤에서 확인, 오래 걸리면 그냥 열어 본다 - 못 읽으면 그 창에 이유) final reach = await Future.wait( diff --git a/lib/ui/monitor/copy_task_card.dart b/lib/ui/monitor/copy_task_card.dart index 9a7fd16..4ecd6b2 100644 --- a/lib/ui/monitor/copy_task_card.dart +++ b/lib/ui/monitor/copy_task_card.dart @@ -120,12 +120,12 @@ class _CopyTaskCardState extends State { FocusManager.instance.primaryFocus?.unfocus(); // 104: 지우기가 들어 있으면 Rsync 화면과 같은 미리 보기를 거친다 (그사이 대상에 생긴 파일을 묻지 않고 지우지 않게). // 210: 이동 (원본 지우기) 도 - 카드의 [실행] 은 따로 묻는 창이 없어 곧바로 원본을 지웠다. 복사만은 지금처럼 바로 - if (!await confirmDeletingRun(context, t, includeMove: true, skipNewer: widget.c.settings.copySkipNewer) || !mounted) return; + if (!await confirmDeletingRun(context, t, includeMove: true, skipNewer: widget.c.settings.copy.skipNewer) || !mounted) return; widget.c.note(trf('rsync 시작 ({0}): {1} → {2}', [tr('모니터링의 [실행]'), t.sources.join(', '), t.dest])); String? exe; if (t.method == 'rsync') { exe = await rsyncExecutable(widget.c.settings); - if (exe == null && Platform.isWindows && widget.c.settings.rsyncSource == 'download' && mounted) { + if (exe == null && Platform.isWindows && widget.c.settings.copy.rsyncSource == 'download' && mounted) { exe = await installRsyncWithDialog(context); } if (exe == null) { @@ -162,7 +162,7 @@ class _CopyTaskCardState extends State { content: Text(trf('칸에 보이는 옵션: {0}\n저장된 옵션: {1}\n\n고친 옵션을 저장하고 실행할까요?', [ '${_method == 'builtin' ? tr('현재 방식') : _method} ${_options.text.trim()}', // 178: 실제로 쓰는 옵션 (원본 지우기는 이동일 때만) - effectiveRunText(t.method, t.options, move: t.move, skipNewer: widget.c.settings.copySkipNewer), + effectiveRunText(t.method, t.options, move: t.move, skipNewer: widget.c.settings.copy.skipNewer), ])), actions: [ TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('취소'))), @@ -342,7 +342,7 @@ class _CopyTaskCardState extends State { ], onChanged: (v) => setState(() { if (v != _method && _options.text.trim().isEmpty || _options.text.trim() == defaultRsyncOptions || _options.text.trim() == defaultRobocopyOptions) { - _options.text = v == 'robocopy' ? s.robocopyOptions : v == 'rsync' ? s.rsyncOptions : ''; + _options.text = v == 'robocopy' ? s.copy.robocopyOptions : v == 'rsync' ? s.copy.rsyncOptions : ''; } _method = v!; }), @@ -363,7 +363,7 @@ class _CopyTaskCardState extends State { width: 460, child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( - '${t.move ? tr('이동') : tr('복사')} · ${trf('실제 실행: {0}', [effectiveRunText(_method, _options.text.trim(), move: t.move, skipNewer: s.copySkipNewer)])}', + '${t.move ? tr('이동') : tr('복사')} · ${trf('실제 실행: {0}', [effectiveRunText(_method, _options.text.trim(), move: t.move, skipNewer: s.copy.skipNewer)])}', style: TextStyle(fontSize: 12, color: t.move ? JjColors.warning : JjColors.textDim), ), // 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 옵션 (실행은 작업이 막는다) diff --git a/lib/ui/monitor/live_sync_flow.dart b/lib/ui/monitor/live_sync_flow.dart index 3f2688f..fba35a3 100644 --- a/lib/ui/monitor/live_sync_flow.dart +++ b/lib/ui/monitor/live_sync_flow.dart @@ -147,10 +147,10 @@ Future confirmEmptySourceSync(BuildContext context, LiveSync live, LiveSyn if (ok == true) await live.syncEmptyAnyway(x); } -/// 앱을 다시 켤 때 ([AppSettings.liveSyncOnStart] 'ask'): 어느 실시간 동기화를 시작할지 고르기. +/// 앱을 다시 켤 때 ([CopySettings.liveSyncOnStart] 'ask'): 어느 실시간 동기화를 시작할지 고르기. /// 고르지 않은 것은 이번 실행 동안 멈춤 (모니터링 > lsync 에서 ▶ 로 시작). Future askLiveSyncStart(BuildContext context, LiveSync live) async { - final pairs = live.c.settings.liveSyncPairs.where((x) => x.enabled).toList(); + final pairs = live.c.settings.copy.liveSyncPairs.where((x) => x.enabled).toList(); if (pairs.isEmpty) return; final pick = {for (final x in pairs) LiveSync.keyOf(x)}; final ok = await showDialog( @@ -190,8 +190,8 @@ Future askLiveSyncStart(BuildContext context, LiveSync live) async { } /// 같은 원본 · 대상 쌍만 [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, +Future replaceLiveSyncPair(AppController c, LiveSyncPair old, LiveSyncPair now) => c.updateSettings((x) => x.copy.liveSyncPairs = [ + for (final y in x.copy.liveSyncPairs) y.source == old.source && y.target == old.target ? now : y, ]); /// 69: 카드의 길게 누르기 · 오른쪽 클릭 메뉴 @@ -257,7 +257,7 @@ Future finalizeLiveSync(BuildContext context, AppController c, LiveSyncPai ); if (go != true || !context.mounted) return false; var exe = await rsyncExecutable(c.settings); - if (exe == null && Platform.isWindows && c.settings.rsyncSource == 'download' && context.mounted) { + if (exe == null && Platform.isWindows && c.settings.copy.rsyncSource == 'download' && context.mounted) { exe = await installRsyncWithDialog(context); } if (exe == null) { diff --git a/lib/ui/monitor_page.dart b/lib/ui/monitor_page.dart index 59ef0f3..e9e6e93 100644 --- a/lib/ui/monitor_page.dart +++ b/lib/ui/monitor_page.dart @@ -86,7 +86,7 @@ class _LiveTabState extends State<_LiveTab> { // 열 때 맞출 것을 새로 센다 final live = LiveSync.instance; if (live != null) { - for (final x in c.settings.liveSyncPairs) { + for (final x in c.settings.copy.liveSyncPairs) { live.refreshPending(x); } } @@ -103,7 +103,7 @@ class _LiveTabState extends State<_LiveTab> { return ListenableBuilder( listenable: Listenable.merge([c, ?live]), builder: (context, _) { - final pairs = c.settings.liveSyncPairs; + final pairs = c.settings.copy.liveSyncPairs; return ListView(padding: const EdgeInsets.all(12), children: [ Row(children: [ Expanded( diff --git a/lib/ui/settings/transfer_sections.dart b/lib/ui/settings/transfer_sections.dart index d365ada..9cf9de3 100644 --- a/lib/ui/settings/transfer_sections.dart +++ b/lib/ui/settings/transfer_sections.dart @@ -42,13 +42,13 @@ class _RsyncSettingsSectionState extends State { ), if (desk) SwitchListTile( - value: s.syncStopToast, - onChanged: (v) => c.updateSettings((x) => x.syncStopToast = v), + value: s.copy.syncStopToast, + onChanged: (v) => c.updateSettings((x) => x.copy.syncStopToast = v), title: Text(tr('동기화가 멈추면 Windows 알림')), subtitle: Text(tr('창을 트레이에 둔 채 실시간 동기화가 원본을 못 읽어 멈추면 알림을 띄웁니다 (트레이 툴팁 경고는 늘)')), ), // Windows 에서 알림이 꺼져 있으면 알리고 설정을 열어 준다 (바꾸지는 않음) - if (desk && s.syncStopToast) const ToastStatusHint(), + if (desk && s.copy.syncStopToast) const ToastStatusHint(), ]); } } diff --git a/lib/ui/webdav_settings.dart b/lib/ui/webdav_settings.dart index eb2b22f..19f405f 100644 --- a/lib/ui/webdav_settings.dart +++ b/lib/ui/webdav_settings.dart @@ -89,9 +89,9 @@ bool isPlainHttp(String url) => url.trim().toLowerCase().startsWith('http://'); List davServerUsers(AppSettings s, String id) { bool on(String path) => isDav(path) && DavPath.parse(path).server == id; return [ - for (final x in s.liveSyncPairs) + for (final x in s.copy.liveSyncPairs) if (on(x.source) || on(x.target)) trf('실시간 동기화: {0} → {1}', [vDisplay(x.source), vDisplay(x.target)]), - for (final t in s.copyTasks) + for (final t in s.copy.tasks) if (on(t.dest) || t.sources.any(on)) trf('기억된 작업: {0} → {1}', [t.sources.map(vDisplay).join(', '), vDisplay(t.dest)]), ]; } diff --git a/test/a11y_names_test.dart b/test/a11y_names_test.dart index e4e37d6..f3e920a 100644 --- a/test/a11y_names_test.dart +++ b/test/a11y_names_test.dart @@ -36,9 +36,9 @@ void main() { Directory(p.join(tmp.path, 'sub')).createSync(); c.settings ..explorer.paths = [tmp.path, tmp.path] - ..rsyncPaths = [tmp.path, tmp.path] - ..copyTasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, 'sub'), method: 'rsync', options: '-av')] - ..liveSyncPairs = [LiveSyncPair(tmp.path, p.join(tmp.path, 'x'))]; + ..copy.rsyncPaths = [tmp.path, tmp.path] + ..copy.tasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, 'sub'), method: 'rsync', options: '-av')] + ..copy.liveSyncPairs = [LiveSyncPair(tmp.path, p.join(tmp.path, 'x'))]; await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: page(c)))); for (var i = 0; i < 20; i++) { await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 30))); diff --git a/test/confirm_actions_test.dart b/test/confirm_actions_test.dart index a060813..b1714a2 100644 --- a/test/confirm_actions_test.dart +++ b/test/confirm_actions_test.dart @@ -36,7 +36,7 @@ void main() { final tmp = Directory.systemTemp.createTempSync('jj_card_'); addTearDown(() => tmp.deleteSync(recursive: true)); final src = p.join(tmp.path, 'movies', 'src'), dst = p.join(tmp.path, 'backup', 'dst'); - c.settings.liveSyncPairs = [LiveSyncPair(src, dst)]; + c.settings.copy.liveSyncPairs = [LiveSyncPair(src, dst)]; final live = LiveSync(c); LiveSync.instance = live; addTearDown(() { @@ -61,13 +61,13 @@ void main() { expect(find.text('실시간 동기화를 지울까요?'), findsOneWidget); await tester.tap(find.text('취소')); await tester.pumpAndSettle(); - expect(c.settings.liveSyncPairs, hasLength(1)); + expect(c.settings.copy.liveSyncPairs, hasLength(1)); // 카드의 지우기 버튼 → 확인 → 지움 await tester.tap(find.byTooltip('지우기')); await tester.pumpAndSettle(); await tester.tap(find.widgetWithText(FilledButton, '지우기')); await tester.pumpAndSettle(); - expect(c.settings.liveSyncPairs, isEmpty); + expect(c.settings.copy.liveSyncPairs, isEmpty); }); testWidgets('46: MKV 목록 "모두 지우기" 는 확인 뒤 · 45: 대기 작업이 있으면 지금 작업만 / 모두 고르기', (tester) async { diff --git a/test/decisions_test.dart b/test/decisions_test.dart index 3fbdcc7..7eefc7e 100644 --- a/test/decisions_test.dart +++ b/test/decisions_test.dart @@ -27,7 +27,7 @@ void main() { test('기본값 = 정한 답 · 저장 · 읽기', () { final s = AppSettings(); expect([ - s.startMenuShortcut, s.syncStopToast, s.swipeWrap, s.migrationNotice, s.allowInnerToOuter, + s.startMenuShortcut, s.copy.syncStopToast, s.swipeWrap, s.migrationNotice, s.allowInnerToOuter, s.rememberPasswords, s.recycleOnDelete, s.readerSystemBars, ], everyElement(isTrue)); expect(s.cleanupPrechecked, ['work', 'download']); @@ -37,12 +37,12 @@ void main() { expect([s.webPauseOnLeave, s.autoCheckUpdates, s.youtubeAdSkip, s.youtubeAdHide, s.runInBackground, s.swipeNav], everyElement(isTrue)); // 탐색기 처음 배치 = 두 창 (규칙 7 · 관리자 결정 3, 10/22) - expect([s.liveSyncOnStart, s.liveSyncIntervalSec, s.explorer.layout, s.zipComic], ['auto', 30, 'dual', false]); + expect([s.copy.liveSyncOnStart, s.copy.liveSyncIntervalSec, s.explorer.layout, s.zipComic], ['auto', 30, 'dual', false]); expect([AppSettings.uiScaleMin, AppSettings.uiScaleMax], [0.5, 2.5]); // 바꾼 값은 남는다 final b = AppSettings.fromJson((AppSettings() ..startMenuShortcut = false - ..syncStopToast = false + ..copy.syncStopToast = false ..swipeWrap = false ..migrationNotice = false ..allowInnerToOuter = false @@ -52,7 +52,7 @@ void main() { ..tripleTapAction = 'none' ..davExternalOpen = 'url') .toJson()); - expect([b.startMenuShortcut, b.syncStopToast, b.swipeWrap, b.migrationNotice, b.allowInnerToOuter, b.rememberPasswords, + expect([b.startMenuShortcut, b.copy.syncStopToast, b.swipeWrap, b.migrationNotice, b.allowInnerToOuter, b.rememberPasswords, b.recycleOnDelete], everyElement(isFalse)); expect([b.cleanupPrechecked, b.tripleTapAction, b.davExternalOpen], [['logs'], 'none', 'url']); }); diff --git a/test/dismiss_test.dart b/test/dismiss_test.dart index 8e34e81..c729e77 100644 --- a/test/dismiss_test.dart +++ b/test/dismiss_test.dart @@ -90,17 +90,17 @@ void main() { ..createSync(recursive: true) ..writeAsStringSync('o'); final pair = LiveSyncPair(src, dst, delete: true); - c.settings.liveSyncPairs = [pair]; + c.settings.copy.liveSyncPairs = [pair]; final live = LiveSync(c); addTearDown(live.dispose); await tester.runAsync(() => live.syncNow(pair)); expect(live.toDelete[LiveSync.keyOf(pair)], ['only.txt']); - await host(tester, (ctx) => confirmLiveDeletes(ctx, live, c.settings.liveSyncPairs.single)); + await host(tester, (ctx) => confirmLiveDeletes(ctx, live, c.settings.copy.liveSyncPairs.single)); await dismiss(tester, how == 'cancel' ? 'back' : how); // 이 창의 "닫기" 는 [나중에] await settle(tester, () => false, rounds: 10); expect(File(p.join(dst, 'only.txt')).existsSync(), isTrue); - expect(c.settings.liveSyncPairs.single.delete, isTrue); - expect(c.settings.liveSyncPairs.single.deleteConfirmed, isFalse); + expect(c.settings.copy.liveSyncPairs.single.delete, isTrue); + expect(c.settings.copy.liveSyncPairs.single.deleteConfirmed, isFalse); }); testWidgets('70 원본이 빈 쌍의 [그래도 맞추기] 창 ($how) → 지우지 않음', (tester) async { @@ -110,7 +110,7 @@ void main() { ..createSync(recursive: true) ..writeAsStringSync('k'); final pair = LiveSyncPair(src, dst, delete: true).copyWith(deleteConfirmed: true); - c.settings.liveSyncPairs = [pair]; + c.settings.copy.liveSyncPairs = [pair]; final live = LiveSync(c); addTearDown(live.dispose); await tester.runAsync(() => live.syncNow(pair)); @@ -151,7 +151,7 @@ void main() { final keep = File(p.join(dst, 'keep.txt')) ..createSync(recursive: true) ..writeAsStringSync('k'); - c.settings.copyTasks = [CopyTask(id: '1', sources: [src], dest: dst, contents: true, method: 'rsync', options: '-av --delete')]; + c.settings.copy.tasks = [CopyTask(id: '1', sources: [src], dest: dst, contents: true, method: 'rsync', options: '-av --delete')]; tester.view.physicalSize = const Size(1500, 1000); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); @@ -175,7 +175,7 @@ void main() { ..createSync(recursive: true) ..writeAsStringSync('a'); Directory(dst).createSync(); - c.settings.copyTasks = [CopyTask(id: '1', sources: [src], dest: dst, contents: true, move: true, method: 'builtin')]; + c.settings.copy.tasks = [CopyTask(id: '1', sources: [src], dest: dst, contents: true, move: true, method: 'builtin')]; tester.view.physicalSize = const Size(1500, 1000); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); @@ -278,7 +278,7 @@ void main() { ..createSync(recursive: true) ..writeAsStringSync('a'); Directory(dst).createSync(); - c.settings.copyTasks = [CopyTask(id: '1', sources: [src], dest: dst, contents: true, method: 'robocopy', options: '/E')]; + c.settings.copy.tasks = [CopyTask(id: '1', sources: [src], dest: dst, contents: true, method: 'robocopy', options: '/E')]; tester.view.physicalSize = const Size(1500, 1000); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); @@ -297,11 +297,11 @@ void main() { testWidgets('237: 모니터링 카드 실패 글은 예외 이름 없이, 원문 (명령 줄 포함) 은 [자세히] 에서 보고 복사', (tester) async { const raw = 'ProcessException: 원본 파일을 지우지 못했습니다\n Command: rsync -avPog --remove-source-files a/ b/'; - c.settings.copyTasks = [ + c.settings.copy.tasks = [ const CopyTask(id: '1', sources: ['a'], dest: 'b', method: 'rsync', options: '-avPog', move: true) .copyWith(lastResult: 'failed', lastRun: DateTime(2026, 10, 12, 9).toIso8601String(), lastMessage: '원본 파일을 지우지 못했습니다', lastDetail: raw), ]; - expect(CopyTask.fromJson(c.settings.copyTasks.single.toJson()).lastDetail, raw); + expect(CopyTask.fromJson(c.settings.copy.tasks.single.toJson()).lastDetail, raw); tester.view.physicalSize = const Size(1500, 1000); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); @@ -339,7 +339,7 @@ void main() { final src = p.join(tmp.path, 'mv243'), dst = p.join(tmp.path, 'mv243d'); Directory(src).createSync(); Directory(dst).createSync(); - c.settings.copyTasks = [CopyTask(id: '1', sources: [src], dest: dst, contents: true, move: true, method: 'builtin')]; + c.settings.copy.tasks = [CopyTask(id: '1', sources: [src], dest: dst, contents: true, move: true, method: 'builtin')]; tester.view.physicalSize = const Size(1500, 1000); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); @@ -350,12 +350,12 @@ void main() { expect(find.textContaining('실시간 동기화는 원본을 지우지 않습니다 (복사로 맞춤)'), findsOneWidget); await tester.tap(find.text('취소').last); await tester.pumpAndSettle(); - expect(c.settings.liveSyncPairs, isEmpty, reason: '취소하면 옮기지 않음'); + expect(c.settings.copy.liveSyncPairs, isEmpty, reason: '취소하면 옮기지 않음'); await tester.tap(find.text('lsync 로 이동').first); await tester.pumpAndSettle(); await tester.tap(find.widgetWithText(FilledButton, '옮기기')); await tester.pumpAndSettle(); - expect(c.settings.liveSyncPairs.map((x) => x.source), [src]); + expect(c.settings.copy.liveSyncPairs.map((x) => x.source), [src]); }); testWidgets('105: 원본이 대상 안 + 창에서 [원본 파일 지우기] 로 지우기 옵션이 생기면 막는다', (tester) async { @@ -365,9 +365,9 @@ void main() { ..writeAsStringSync('n'); final center = CopyCenter.of(c); c.settings - ..rsyncOptions = '-avPog' + ..copy.rsyncOptions = '-avPog' ..explorer.layout = 'single' - ..rsyncPaths = [outer, tmp.path]; + ..copy.rsyncPaths = [outer, tmp.path]; // 이동 (원본 파일 지우기) 작업만 --delete 를 기억 await center.remember([inner], outer, contents: true, method: 'rsync', move: true).then((t) => center.update(t.copyWith(options: '-avPog --delete'))); tester.view.physicalSize = const Size(1600, 900); @@ -404,7 +404,7 @@ void main() { final a = p.join(tmp.path, 'ca'), b = p.join(tmp.path, 'cb'); Directory(a).createSync(recursive: true); Directory(b).createSync(recursive: true); - c.settings.copyTasks = [ + c.settings.copy.tasks = [ CopyTask(id: '1', sources: [a], dest: b, contents: true, method: 'rsync', options: '-avPog --remove-source-files'), CopyTask(id: '2', sources: [b], dest: a, contents: true, move: true, method: 'rsync', options: '-avPog'), ]; @@ -427,7 +427,7 @@ void main() { File(p.join(dst, 'keep.txt')) ..createSync(recursive: true) ..writeAsStringSync('k'); - c.settings.copyTasks = [CopyTask(id: '1', sources: [src], dest: dst, contents: true, method: 'rsync', options: '-av')]; + c.settings.copy.tasks = [CopyTask(id: '1', sources: [src], dest: dst, contents: true, method: 'rsync', options: '-av')]; tester.view.physicalSize = const Size(1500, 1000); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); @@ -441,7 +441,7 @@ void main() { expect(find.text('고친 옵션을 저장하지 않았습니다'), findsOneWidget); await tester.tap(find.text('저장하고 실행')); await tester.pump(const Duration(milliseconds: 300)); - expect(c.settings.copyTasks.single.options, '-av --delete'); + expect(c.settings.copy.tasks.single.options, '-av --delete'); // 저장한 옵션에 지우기가 있으므로 미리 보기 창 expect(find.text('지우기가 들어 있는 작업입니다'), findsOneWidget); await settle(tester, () => find.textContaining('비교하는 중').evaluate().isEmpty, rounds: 200); diff --git a/test/explorer_archive_trash_test.dart b/test/explorer_archive_trash_test.dart index 2dbd129..19764cf 100644 --- a/test/explorer_archive_trash_test.dart +++ b/test/explorer_archive_trash_test.dart @@ -158,8 +158,8 @@ void main() { testWidgets('178 보충: 설정의 원본 지우기 옵션은 복사 창에서 빠진 까닭을 알린다 (이동 창에는 없음)', (tester) async { c.settings - ..copyMethodFile = 'robocopy' - ..robocopyOptions = '/E /MOV'; + ..copy.methodFile = 'robocopy' + ..copy.robocopyOptions = '/E /MOV'; await open(tester); await act(tester, () => tester.tap(find.text('doc.txt'))); await settle(tester, () => false, rounds: 15); diff --git a/test/explorer_conflict_test.dart b/test/explorer_conflict_test.dart index e541bd6..552adcd 100644 --- a/test/explorer_conflict_test.dart +++ b/test/explorer_conflict_test.dart @@ -103,7 +103,7 @@ void main() { }); testWidgets('48 보충: 환경 설정이 "늘 덮어쓰기" 면 묻지 않고 (확인 창에 알림만) 덮어쓴다', (tester) async { - c.settings.copyConflict = 'overwrite'; + c.settings.copy.conflict = 'overwrite'; await startCopy(tester); expect(find.text('덮어쓰기 (원래 파일은 없어집니다)'), findsNothing, reason: '고르는 칸 없음'); expect(find.textContaining('환경 설정대로 덮어씁니다'), findsOneWidget); @@ -121,7 +121,7 @@ void main() { File(p.join(right, 'D', 'same.txt')) ..createSync(recursive: true) ..writeAsStringSync('NEWER'); - c.settings.copyConflict = 'overwrite'; + c.settings.copy.conflict = 'overwrite'; tester.view.physicalSize = const Size(1600, 900); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); @@ -167,7 +167,7 @@ void main() { testWidgets('52: 복사 중에 화면을 떠났다 돌아와도 진행 막대가 다시 보이고, 끝나면 받는 폴더를 새로 읽는다 (.jjpart 가 남아 보이지 않음)', (tester) async { File(p.join(left, 'big.bin')).writeAsBytesSync(List.filled(150 * 1024, 3)); - c.settings.copyBandwidthKBps = 60; // 약 2.5초 걸리게 + c.settings.copy.bandwidthKBps = 60; // 약 2.5초 걸리게 tester.view.physicalSize = const Size(1600, 900); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); diff --git a/test/explorer_filter_rsync_test.dart b/test/explorer_filter_rsync_test.dart index 20f0851..f192ad1 100644 --- a/test/explorer_filter_rsync_test.dart +++ b/test/explorer_filter_rsync_test.dart @@ -208,7 +208,7 @@ void main() { File(p.join(right, 'rsub', 'back.txt')).writeAsStringSync('b'); // ⇄ 비교에서 ← 로 건너갈 것 c.settings ..explorer.layout = 'single' // 파일 탐색기 배치와 상관없이 두 창 - ..rsyncPaths = [left, right]; + ..copy.rsyncPaths = [left, right]; tester.view.physicalSize = const Size(1600, 900); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); @@ -257,7 +257,7 @@ void main() { expect(find.textContaining('-avPog -u'), findsNWidgets(2)); await tester.tap(find.text('취소')); await calm(tester); - expect(c.settings.copyTasks, isEmpty); + expect(c.settings.copy.tasks, isEmpty); expect(find.text(keepOptionsTogether('원본 파일 지우기 (--remove-source-files)')), findsNothing); // 양쪽에는 없음 // → 는 한 방향 · -u 없이 · 원본 파일 지우기를 고를 수 있다 (고르면 원본 폴더 남김 / 지움) await act(tester, () => tester.tap(find.text('좌 → 우'))); @@ -276,7 +276,7 @@ void main() { await tester.tap(find.text('취소')); await calm(tester); // 고른 폴더는 기억 (다음에 열 때 그 폴더부터) - expect(c.settings.rsyncPaths, [p.join(left, 'sub'), p.join(right, 'rsub')]); + expect(c.settings.copy.rsyncPaths, [p.join(left, 'sub'), p.join(right, 'rsub')]); // 새 폴더: 만든 폴더가 바로 그 창의 원본 · 대상으로 골라진다 (오른쪽 창 rsub 안에) await act(tester, () => tester.tap(find.text('rsub'))); diff --git a/test/explorer_keys_drag_test.dart b/test/explorer_keys_drag_test.dart index ef16ad7..17e3811 100644 --- a/test/explorer_keys_drag_test.dart +++ b/test/explorer_keys_drag_test.dart @@ -152,7 +152,7 @@ void main() { File(p.join(left, 'e.txt')).writeAsStringSync('e'); c.settings ..explorer.dropAction = 'move' - ..copyConflict = 'overwrite'; + ..copy.conflict = 'overwrite'; await open(tester); // 전체 시험 중 (부하) 에는 목록을 늦게 읽어 e.txt 를 못 찾았다 - 두 파일이 보일 때까지 await settle(tester, () => find.text('e.txt').evaluate().isNotEmpty && find.text('doc.txt').evaluate().isNotEmpty); @@ -178,7 +178,7 @@ void main() { ..writeAsStringSync('mine'); c.settings ..explorer.dropAction = 'move' - ..copyConflict = 'overwrite'; + ..copy.conflict = 'overwrite'; await open(tester); await act(tester, () => tester.tap(find.text('선택').first)); await act(tester, () => tester.tap(markOf('doc.txt'))); diff --git a/test/explorer_modules_e5c_contract_test.dart b/test/explorer_modules_e5c_contract_test.dart index c3cf141..e6af497 100644 --- a/test/explorer_modules_e5c_contract_test.dart +++ b/test/explorer_modules_e5c_contract_test.dart @@ -92,7 +92,7 @@ void main() { test('Rsync 실행: 막는 이유 · 쌍 · ⇄ 의 옵션 (← 는 지우지 않음 · -u) · 원본 지우기 · 미리 보기 (실제 폴더)', () async { final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); - c.settings.rsyncOptions = '-a --delete'; + c.settings.copy.rsyncOptions = '-a --delete'; final center = CopyCenter.of(c); final l = p.join(tmp.path, 'L'), r = p.join(tmp.path, 'R'); Directory(l).createSync(); diff --git a/test/explorer_parallel_test.dart b/test/explorer_parallel_test.dart index 562e0d3..e5c6f12 100644 --- a/test/explorer_parallel_test.dart +++ b/test/explorer_parallel_test.dart @@ -30,7 +30,7 @@ void main() { final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings ..explorer.paths = [left, right] - ..copyBandwidthKBps = 512; // 큰 파일이 6초쯤 걸리게 + ..copy.bandwidthKBps = 512; // 큰 파일이 6초쯤 걸리게 tester.view.physicalSize = const Size(1600, 900); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); @@ -70,7 +70,7 @@ void main() { final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings ..explorer.orientation = 'side' - ..rsyncPaths = [l, r]; + ..copy.rsyncPaths = [l, r]; tester.view.physicalSize = const Size(400, 860); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); @@ -110,7 +110,7 @@ void main() { final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings ..explorer.orientation = 'side' - ..rsyncPaths = [l, r]; + ..copy.rsyncPaths = [l, r]; tester.view.physicalSize = const Size(412, 900); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); @@ -146,7 +146,7 @@ void main() { final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings ..explorer.orientation = 'side' - ..rsyncPaths = [l, r]; + ..copy.rsyncPaths = [l, r]; tester.view.physicalSize = const Size(860, 400); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); @@ -168,7 +168,7 @@ void main() { final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings ..explorer.orientation = 'side' - ..rsyncPaths = [p.join(inner, 'L'), p.join(sd, 'R')]; + ..copy.rsyncPaths = [p.join(inner, 'L'), p.join(sd, 'R')]; tester.view.physicalSize = const Size(412, 880); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); @@ -194,7 +194,7 @@ void main() { final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings ..explorer.layout = 'single' - ..rsyncPaths = [l, r]; + ..copy.rsyncPaths = [l, r]; tester.view.physicalSize = const Size(1600, 900); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); diff --git a/test/explorer_select_ops_test.dart b/test/explorer_select_ops_test.dart index fd3291a..00f6544 100644 --- a/test/explorer_select_ops_test.dart +++ b/test/explorer_select_ops_test.dart @@ -185,7 +185,7 @@ void main() { tester.view.physicalSize = const Size(1600, 900); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); - c.settings.rsyncPaths = [left, right]; + c.settings.copy.rsyncPaths = [left, right]; await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c, rsync: true)))); await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty); await act(tester, () => tester.tap(find.byTooltip('즐겨찾기').first)); @@ -203,9 +203,9 @@ void main() { await act(tester, () => tester.tap(find.byTooltip('즐겨찾기').first)); await calm(tester); await act(tester, () => tester.tap(find.text('거꾸로'))); - await settle(tester, () => c.settings.rsyncPaths.length == 2 && c.settings.rsyncPaths[0] == right && c.settings.rsyncPaths[1] == left, + await settle(tester, () => c.settings.copy.rsyncPaths.length == 2 && c.settings.copy.rsyncPaths[0] == right && c.settings.copy.rsyncPaths[1] == left, rounds: 100); - expect(c.settings.rsyncPaths, [right, left]); + expect(c.settings.copy.rsyncPaths, [right, left]); }); testWidgets('74: 고르기 도구 - 개수 · 합계 크기, 반전, 패턴, 같은 확장자, 범위 (Shift · 길게 누르기)', (tester) async { diff --git a/test/explorer_steady_test.dart b/test/explorer_steady_test.dart index 3eb0a5e..cecbd0e 100644 --- a/test/explorer_steady_test.dart +++ b/test/explorer_steady_test.dart @@ -38,8 +38,8 @@ void main() { c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), shell: NoopShell())); c.settings ..explorer.paths = [left, right] - ..copyMethodFile = 'builtin' - ..copyMethodFolder = 'builtin'; + ..copy.methodFile = 'builtin' + ..copy.methodFolder = 'builtin'; }); tearDown(() async { for (var i = 0;; i++) { diff --git a/test/explorer_webdav_test.dart b/test/explorer_webdav_test.dart index b18bd69..c5d8f3a 100644 --- a/test/explorer_webdav_test.dart +++ b/test/explorer_webdav_test.dart @@ -122,8 +122,8 @@ void main() { final s = DavServer(id: 'nas', name: '집 NAS', url: server.url, user: 'user', password: 'pass'); c.settings ..webdavServers = [s] - ..rsyncOptions = '-av --remove-source-files' - ..rsyncPaths = [left, 'dav://nas/']; + ..copy.rsyncOptions = '-av --remove-source-files' + ..copy.rsyncPaths = [left, 'dav://nas/']; DavRegistry.configure([s]); // 1280×720 (낮은 창) 에서도 체크 · 경고가 버튼에 가려지지 않게 await pump(tester, ExplorerPage(device: device, c: c, rsync: true), size: const Size(1280, 720)); @@ -193,7 +193,7 @@ void main() { final s = DavServer(id: 'nas', name: '집 NAS', url: server.url, user: 'user', password: 'pass'); c.settings ..webdavServers = [s] - ..rsyncPaths = [left, 'dav://nas/']; + ..copy.rsyncPaths = [left, 'dav://nas/']; DavRegistry.configure([s]); await pump(tester, ExplorerPage(device: device, c: c, rsync: true)); await settle(tester, () => find.text('sub').evaluate().isNotEmpty && find.text('원격').evaluate().isNotEmpty); @@ -216,6 +216,6 @@ void main() { await settle(tester, () => got.existsSync() && find.byType(LinearProgressIndicator).evaluate().isEmpty, rounds: 200); expect(got.readAsStringSync(), 'inner'); expect(File(p.join(remote.path, '원격', 'note.txt')).existsSync(), isTrue); // 지우기 없이 - expect(c.settings.copyTasks.single.dest, 'dav://nas/원격'); // 모니터링에 남음 + expect(c.settings.copy.tasks.single.dest, 'dav://nas/원격'); // 모니터링에 남음 }); } diff --git a/test/large_text_test.dart b/test/large_text_test.dart index b1f1301..70aac52 100644 --- a/test/large_text_test.dart +++ b/test/large_text_test.dart @@ -53,9 +53,9 @@ void main() { final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings ..explorer.paths = [tmp.path, tmp.path] - ..rsyncPaths = [tmp.path, tmp.path] - ..copyTasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, 'sub'), method: 'rsync', options: '-av')] - ..liveSyncPairs = [LiveSyncPair(tmp.path, p.join(tmp.path, 'x'))]; + ..copy.rsyncPaths = [tmp.path, tmp.path] + ..copy.tasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, 'sub'), method: 'rsync', options: '-av')] + ..copy.liveSyncPairs = [LiveSyncPair(tmp.path, p.join(tmp.path, 'x'))]; c.videos.add(VideoItem(p.join(tmp.path, '아주 긴 이름의 동영상 파일 - 시험용 2026.mp4'))); c.selected = c.videos.first; final d = DownloadManager(backends: const [], settings: () => c.settings, readClipboard: () async => null); diff --git a/test/live_sync_args_test.dart b/test/live_sync_args_test.dart index c3cffba..160b130 100644 --- a/test/live_sync_args_test.dart +++ b/test/live_sync_args_test.dart @@ -5,7 +5,7 @@ import 'package:jj_mkvmaker/app/settings.dart'; /// 97: 실시간 동기화의 지우기는 쌍의 "지우기 포함" 으로만 (설정 옵션의 --delete · --del · /MIR · /PURGE 는 빼고) void main() { test('rsync: 설정 옵션의 지우기는 빼고, 쌍이 지우기 포함일 때만 --delete 하나', () { - final s = AppSettings()..rsyncOptions = '-avPog --delete --del --delete-after'; + final s = AppSettings()..copy.rsyncOptions = '-avPog --delete --del --delete-after'; final off = LiveSync.rsyncArgsFor(LiveSyncPair('/a', '/b'), s, windows: false); expect(off.where((o) => o.startsWith('--del')), isEmpty); final on = LiveSync.rsyncArgsFor(LiveSyncPair('/a', '/b', delete: true), s, windows: false); @@ -13,7 +13,7 @@ void main() { }); test('10/20: --delete-excluded 는 쌍이 지우기 포함일 때만 (지울지 묻는 목록과 같게)', () { - final s = AppSettings()..rsyncOptions = '-av --exclude=*.tmp --delete-excluded'; + final s = AppSettings()..copy.rsyncOptions = '-av --exclude=*.tmp --delete-excluded'; final off = LiveSync.rsyncArgsFor(LiveSyncPair('/a', '/b'), s, windows: false); expect(off.where((o) => o.startsWith('--del')), isEmpty); final on = LiveSync.rsyncArgsFor(LiveSyncPair('/a', '/b', delete: true), s, windows: false); @@ -22,7 +22,7 @@ void main() { }); test('robocopy: /MIR 는 /E 로, /PURGE 는 쌍이 지우기 포함일 때만', () { - final s = AppSettings()..robocopyOptions = '/MIR /PURGE /R:2'; + final s = AppSettings()..copy.robocopyOptions = '/MIR /PURGE /R:2'; final off = LiveSync.robocopyArgsFor(LiveSyncPair(r'C:\a', r'D:\b'), s).map((o) => o.toUpperCase()).toList(); expect(off, isNot(contains('/MIR'))); expect(off, isNot(contains('/PURGE'))); @@ -33,8 +33,8 @@ void main() { test('107: 원본을 지우는 옵션 (--remove-source-files · /MOV · /MOVE) 도 실시간 동기화에서는 쓰지 않는다', () { final s = AppSettings() - ..rsyncOptions = '-avPog --remove-source-files --remove-sent-files' - ..robocopyOptions = '/E /MOV /MOVE'; + ..copy.rsyncOptions = '-avPog --remove-source-files --remove-sent-files' + ..copy.robocopyOptions = '/E /MOV /MOVE'; final r = LiveSync.rsyncArgsFor(LiveSyncPair('/a', '/b', delete: true), s, windows: false); expect(r.where((o) => o.startsWith('--remove')), isEmpty); final w = LiveSync.robocopyArgsFor(LiveSyncPair(r'C:', r'D:', delete: true), s).map((o) => o.toUpperCase()).toList(); diff --git a/test/live_sync_scene_test.dart b/test/live_sync_scene_test.dart index 182d04b..2b33fc5 100644 --- a/test/live_sync_scene_test.dart +++ b/test/live_sync_scene_test.dart @@ -31,8 +31,8 @@ void main() { final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); // 앱에 든 rsync 를 그대로 (시험 환경에는 앱 폴더 (path_provider) 가 없어 '직접 지정' 으로) c.settings - ..rsyncSource = 'custom' - ..rsyncPath = rsync; + ..copy.rsyncSource = 'custom' + ..copy.rsyncPath = rsync; final bad = []; for (final opts in sceneOptions) { writeScene(src, want); @@ -40,9 +40,9 @@ void main() { writeScene(src, got); final before = treeOf(got); final gone = [for (final k in before.keys) if (!w.containsKey(k)) k]; - c.settings.rsyncOptions = opts; + c.settings.copy.rsyncOptions = opts; final pair = LiveSyncPair(src, got, method: 'rsync', delete: optionsDelete(opts)); - c.settings.liveSyncPairs = [pair]; + c.settings.copy.liveSyncPairs = [pair]; final live = LiveSync(c); final k = LiveSync.keyOf(pair); final why = []; @@ -64,7 +64,7 @@ void main() { if (!RsyncFilter.parse(opts).deleteExcluded && excl.isNotEmpty) why.add('--delete-excluded 가 없는데 따로 묶음: $excl'); if (excl.toSet().difference(asked.toSet()).isNotEmpty) why.add('따로 묶은 것이 지울 목록에 없음: $excl'); if (excl.toSet().length != wantExcl.length || !excl.toSet().containsAll(wantExcl)) why.add('따로 묶은 것 $excl ≠ $wantExcl'); - if (asked.isNotEmpty) await live.decideDelete(c.settings.liveSyncPairs.single, delete: true); + if (asked.isNotEmpty) await live.decideDelete(c.settings.copy.liveSyncPairs.single, delete: true); why.addAll(treeDiff(w, treeOf(got))); if ((live.pending[k] ?? const []).isNotEmpty) why.add('맞춘 뒤에도 맞출 것: ${live.pending[k]}'); live.dispose(); @@ -86,11 +86,11 @@ void main() { } final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings - ..rsyncSource = 'custom' - ..rsyncPath = rsync - ..rsyncOptions = '-av --exclude=*.tmp --delete-excluded'; + ..copy.rsyncSource = 'custom' + ..copy.rsyncPath = rsync + ..copy.rsyncOptions = '-av --exclude=*.tmp --delete-excluded'; final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true); - c.settings.liveSyncPairs = [pair]; + c.settings.copy.liveSyncPairs = [pair]; final live = LiveSync(c); addTearDown(live.dispose); await tester.runAsync(() => live.syncNow(pair)); @@ -130,29 +130,29 @@ void main() { } final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings - ..rsyncSource = 'custom' - ..rsyncPath = rsync - ..rsyncOptions = '-av --exclude=*.tmp'; + ..copy.rsyncSource = 'custom' + ..copy.rsyncPath = rsync + ..copy.rsyncOptions = '-av --exclude=*.tmp'; // 이미 이 옵션으로 확인한 쌍 - c.settings.liveSyncPairs = [ + c.settings.copy.liveSyncPairs = [ LiveSyncPair(src, dst, method: 'rsync', delete: true, deleteConfirmed: true, confirmedOptions: '-av --exclude=*.tmp'), ]; final live = LiveSync(c); addTearDown(live.dispose); - LiveSyncPair pair() => c.settings.liveSyncPairs.single; + LiveSyncPair pair() => c.settings.copy.liveSyncPairs.single; final k = LiveSync.keyOf(pair()); await live.syncNow(pair()); expect(live.toDelete[k], isNull); expect(File(p.join(dst, 'a.txt')).existsSync(), isTrue); // ① 지울 것이 늘지 않는 바꿈 (-h 더하기) → 묻지 않고 지금 옵션을 기억 - c.settings.rsyncOptions = '-avh --exclude=*.tmp'; + c.settings.copy.rsyncOptions = '-avh --exclude=*.tmp'; await live.syncNow(pair()); expect(live.toDelete[k], isNull); expect(pair().confirmedOptions, '-avh --exclude=*.tmp'); // ② --delete-excluded 를 더함 → 거른 것이 새로 지워지므로 다시 묻고, 확인 전에는 지우지 않음 - c.settings.rsyncOptions = '-avh --exclude=*.tmp --delete-excluded'; + c.settings.copy.rsyncOptions = '-avh --exclude=*.tmp --delete-excluded'; await live.syncNow(pair()); expect(live.toDelete[k], unorderedEquals(['only.tmp', 'keep'])); expect(live.reasked, contains(k)); @@ -167,7 +167,7 @@ void main() { expect(live.reasked, isNot(contains(k))); // ③ 거르기를 빼면 (--exclude 를 지움) 지울 것은 줄거나 같다 → 묻지 않음 - c.settings.rsyncOptions = '-avh'; + c.settings.copy.rsyncOptions = '-avh'; await live.syncNow(pair()); expect(live.toDelete[k], isNull); expect(pair().confirmedOptions, '-avh'); @@ -175,15 +175,15 @@ void main() { // ④ 예전에 확인한 쌍 (기억한 옵션 없음): --delete-excluded 로 새로 지워질 것이 있으면 묻는다 · 없으면 기억만 File(p.join(dst, 'new.tmp')).writeAsStringSync('n'); c.settings - ..rsyncOptions = '-av --exclude=*.tmp --delete-excluded' - ..liveSyncPairs = [LiveSyncPair.fromJson({'source': src, 'target': dst, 'method': 'rsync', 'delete': true})]; + ..copy.rsyncOptions = '-av --exclude=*.tmp --delete-excluded' + ..copy.liveSyncPairs = [LiveSyncPair.fromJson({'source': src, 'target': dst, 'method': 'rsync', 'delete': true})]; expect(pair().confirmedOptions, isNull); await live.syncNow(pair()); expect(live.toDelete[k], ['new.tmp']); expect(File(p.join(dst, 'new.tmp')).existsSync(), isTrue); c.settings - ..rsyncOptions = '-av --exclude=*.tmp' - ..liveSyncPairs = [LiveSyncPair.fromJson({'source': src, 'target': dst, 'method': 'rsync', 'delete': true})]; + ..copy.rsyncOptions = '-av --exclude=*.tmp' + ..copy.liveSyncPairs = [LiveSyncPair.fromJson({'source': src, 'target': dst, 'method': 'rsync', 'delete': true})]; await live.syncNow(pair()); expect(live.toDelete[k], isNull); expect(pair().confirmedOptions, '-av --exclude=*.tmp'); @@ -219,8 +219,8 @@ void main() { } c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings - ..rsyncSource = 'custom' - ..rsyncPath = sceneRsync ?? ''; + ..copy.rsyncSource = 'custom' + ..copy.rsyncPath = sceneRsync ?? ''; }); tearDown(() => tmp.deleteSync(recursive: true)); bool there(String rel) => File(p.join(dst, rel)).existsSync(); @@ -248,14 +248,14 @@ void main() { expect(r.exitCode, 0, reason: '${r.stderr}'); expect(File(p.join(probe, 'a.txt')).existsSync(), isFalse, reason: '실제 rsync 는 지움'); - c.settings.rsyncOptions = opts; + c.settings.copy.rsyncOptions = opts; final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true, deleteConfirmed: confirmed, confirmedOptions: confirmed ? opts : null); - c.settings.liveSyncPairs = [pair]; + c.settings.copy.liveSyncPairs = [pair]; final live = LiveSync(c); addTearDown(live.dispose); final k = LiveSync.keyOf(pair); for (var i = 0; i < 2; i++) { - await live.syncNow(c.settings.liveSyncPairs.single); + await live.syncNow(c.settings.copy.liveSyncPairs.single); expect(live.status[k]!.$2, isNot(contains('실패'))); expect([there('a.txt'), there('old.log'), there('extra.txt')], [true, true, true], reason: '자동 맞추기는 지우지 않음 ($i)'); expect(there('b.txt') && there('c.log'), isTrue, reason: '보내기는 함'); @@ -264,13 +264,13 @@ void main() { expect(live.pending[k]!.where((d) => d.startsWith('− ')), isEmpty, reason: '− 줄이 늘 남지 않음'); } // [지금 지우기 (확인)] - 창에 보일 목록은 미리 본 것 (a.txt 는 없음 - 그래서 빨간 경고와 체크) - final (list, _) = await live.previewDeletes(c.settings.liveSyncPairs.single); + final (list, _) = await live.previewDeletes(c.settings.copy.liveSyncPairs.single); expect(list, unorderedEquals(['old.log', 'extra.txt'])); - await live.deleteNow(c.settings.liveSyncPairs.single); + await live.deleteNow(c.settings.copy.liveSyncPairs.single); expect([there('old.log'), there('extra.txt')], [false, false]); // 다음 자동 맞추기는 다시 보내기만 File(p.join(dst, 'extra2.txt')).writeAsStringSync('x'); - await live.syncNow(c.settings.liveSyncPairs.single); + await live.syncNow(c.settings.copy.liveSyncPairs.single); expect(there('extra2.txt'), isTrue); expect(live.noAutoDelete[k], isNotEmpty); }); @@ -279,19 +279,19 @@ void main() { test('관리자 지시 (10/20): P (지키기) 는 이제 따른다 - 묻는 목록에 지킨 것 없음 · 확인 뒤 남음 · 다시 묻지 않음', () async { if (sceneRsync == null) return markTestSkipped('앱에 든 rsync 가 없음'); - c.settings.rsyncOptions = '-a --filter="P old.log"'; + c.settings.copy.rsyncOptions = '-a --filter="P old.log"'; final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true); - c.settings.liveSyncPairs = [pair]; + c.settings.copy.liveSyncPairs = [pair]; final live = LiveSync(c); addTearDown(live.dispose); final k = LiveSync.keyOf(pair); await live.syncNow(pair); expect(live.noAutoDelete[k], isNull); expect(live.toDelete[k], ['extra.txt'], reason: 'old.log 는 지킴'); - await live.decideDelete(c.settings.liveSyncPairs.single, delete: true); + await live.decideDelete(c.settings.copy.liveSyncPairs.single, delete: true); expect((there('old.log'), there('extra.txt')), (true, false)); for (var i = 0; i < 2; i++) { - await live.syncNow(c.settings.liveSyncPairs.single); + await live.syncNow(c.settings.copy.liveSyncPairs.single); expect(live.toDelete[k], isNull, reason: '다시 묻지 않음 ($i)'); expect(live.pending[k], isEmpty); } @@ -299,9 +299,9 @@ void main() { test('미리 볼 수 있는 규칙만이면 예전처럼 (자동 지우기 · 묻기)', () async { if (sceneRsync == null) return markTestSkipped('앱에 든 rsync 가 없음'); - c.settings.rsyncOptions = '-a --exclude=*.log'; + c.settings.copy.rsyncOptions = '-a --exclude=*.log'; final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true); - c.settings.liveSyncPairs = [pair]; + c.settings.copy.liveSyncPairs = [pair]; final live = LiveSync(c); addTearDown(live.dispose); await live.syncNow(pair); @@ -312,9 +312,9 @@ void main() { testWidgets('[지금 지우기 (확인)] 창: 빨간 "미리 볼 수 없음" · 체크해야 [지우고 맞추기] · [지우지 않기] 는 없음', (tester) async { if (sceneRsync == null) return markTestSkipped('앱에 든 rsync 가 없음'); - c.settings.rsyncOptions = '-a --filter="merge C:/jj_rules/rules.txt"'; // 아직 따르지 못하는 규칙 (H 는 이제 따름) · 목록 파일은 전체 경로 + c.settings.copy.rsyncOptions = '-a --filter="merge C:/jj_rules/rules.txt"'; // 아직 따르지 못하는 규칙 (H 는 이제 따름) · 목록 파일은 전체 경로 final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true, deleteConfirmed: true); - c.settings.liveSyncPairs = [pair]; + c.settings.copy.liveSyncPairs = [pair]; final live = LiveSync(c); addTearDown(live.dispose); late BuildContext ctx; diff --git a/test/mirror_safety_test.dart b/test/mirror_safety_test.dart index bd72cb2..8a42daa 100644 --- a/test/mirror_safety_test.dart +++ b/test/mirror_safety_test.dart @@ -50,7 +50,7 @@ void main() { () async { final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final pair = LiveSyncPair(src, dst, method: 'rsync', delete: true); - c.settings.liveSyncPairs = [pair]; + c.settings.copy.liveSyncPairs = [pair]; final live = LiveSync(c); await live.syncNow(pair); expect(live.status[LiveSync.keyOf(pair)]!.$2, '원본을 읽을 수 없어 멈춤'); @@ -65,7 +65,7 @@ void main() { ..writeAsStringSync('a'); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final pair = LiveSyncPair(src, dst, delete: true); // 새 쌍: deleteConfirmed false - c.settings.liveSyncPairs = [pair]; + c.settings.copy.liveSyncPairs = [pair]; final live = LiveSync(c); await live.syncNow(pair); final k = LiveSync.keyOf(pair); @@ -74,8 +74,8 @@ void main() { expect(live.toDelete[k], unorderedEquals(['keep1.txt', 'sub'])); expect(live.status[k]!.$2, contains('확인 필요')); // 지우기 포함으로 맞추기 (확인) - await live.decideDelete(c.settings.liveSyncPairs.single, delete: true); - expect(c.settings.liveSyncPairs.single.deleteConfirmed, isTrue); + await live.decideDelete(c.settings.copy.liveSyncPairs.single, delete: true); + expect(c.settings.copy.liveSyncPairs.single.deleteConfirmed, isTrue); expect(File(p.join(dst, 'keep1.txt')).existsSync(), isFalse); expect(live.toDelete[k], isNull); live.dispose(); diff --git a/test/monitor_modules_test.dart b/test/monitor_modules_test.dart index 3b8e487..6b7f7ed 100644 --- a/test/monitor_modules_test.dart +++ b/test/monitor_modules_test.dart @@ -54,10 +54,10 @@ void main() { final a = LiveSyncPair(p.join(tmp.path, 'a'), p.join(tmp.path, 'A')); final b = LiveSyncPair(p.join(tmp.path, 'b'), p.join(tmp.path, 'B')); final d = LiveSyncPair(p.join(tmp.path, 'd'), p.join(tmp.path, 'D')); - c.settings.liveSyncPairs = [a, b, d]; + c.settings.copy.liveSyncPairs = [a, b, d]; await replaceLiveSyncPair(c, b, b.copyWith(enabled: false)); - expect(c.settings.liveSyncPairs.map((x) => x.source), [a.source, b.source, d.source]); - expect(c.settings.liveSyncPairs.map((x) => x.enabled), [true, false, true]); + expect(c.settings.copy.liveSyncPairs.map((x) => x.source), [a.source, b.source, d.source]); + expect(c.settings.copy.liveSyncPairs.map((x) => x.enabled), [true, false, true]); }); testWidgets('copy_task_card: 작업이 없으면 안내, 있으면 작업마다 카드', (tester) async { @@ -76,7 +76,7 @@ void main() { testWidgets('live_pair_card: 화면 일은 부르는 쪽 콜백으로 (최종 정리 · 복사 목록으로)', (tester) async { final c = _controller(); final x = LiveSyncPair(tmp.path, p.join(tmp.path, 'x'), method: 'builtin'); - c.settings.liveSyncPairs = [x]; + c.settings.copy.liveSyncPairs = [x]; tester.view.physicalSize = const Size(1500, 1000); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); @@ -95,12 +95,12 @@ void main() { // 스위치는 replaceLiveSyncPair 로 저장 await tester.tap(find.byType(Switch)); await tester.pump(); - expect(c.settings.liveSyncPairs.single.enabled, isFalse); + expect(c.settings.copy.liveSyncPairs.single.enabled, isFalse); // 복사 목록으로 옮기면 lsync 쌍은 빠지고 복사 작업이 생긴 뒤 부르는 쪽 콜백 await tester.tap(find.text('복사 · rsync 로 이동')); await tester.pumpAndSettle(); expect(shown, 1); - expect(c.settings.liveSyncPairs, isEmpty); - expect(c.settings.copyTasks.single.dest, x.target); + expect(c.settings.copy.liveSyncPairs, isEmpty); + expect(c.settings.copy.tasks.single.dest, x.target); }); } diff --git a/test/monitor_test.dart b/test/monitor_test.dart index 881372d..13d14bb 100644 --- a/test/monitor_test.dart +++ b/test/monitor_test.dart @@ -88,7 +88,7 @@ void main() { final single = File(p.join(tmp.path, 'single.txt'))..writeAsStringSync('s'); final center = CopyCenter(c); final t = CopyTask(id: '9', sources: [src, single.path], dest: dst, move: true, method: 'rsync', prune: 'all', once: true); - await c.updateSettings((x) => x.copyTasks = [t]); + await c.updateSettings((x) => x.copy.tasks = [t]); expect(await center.toLiveSync(center.tasks.single), 1); final rest = center.tasks.single; expect(rest.sources, [single.path]); @@ -96,7 +96,7 @@ void main() { }); test('같은 복사는 기억한 옵션 · 실행 결과 기록 · lsync 로 이동 · 다시 복사 목록으로', () async { - c.settings.copyMethodFolder = 'builtin'; // rsync 실행은 sync_tools_test (시험 환경에서는 path_provider 없음) + c.settings.copy.methodFolder = 'builtin'; // rsync 실행은 sync_tools_test (시험 환경에서는 path_provider 없음) final center = CopyCenter(c); final t = await center.remember([src], dst); await center.update(t.copyWith(options: '-a --custom', bandwidthKBps: 77)); @@ -114,12 +114,12 @@ void main() { // lsync 로: 원본 → 대상\이름 expect(await center.toLiveSync(center.tasks.single), 1); expect(center.tasks, isEmpty); - final pair = c.settings.liveSyncPairs.single; + final pair = c.settings.copy.liveSyncPairs.single; expect([pair.source, pair.target], [src, p.join(dst, 'src')]); // 다시 복사 목록으로 (안의 것 → 대상) final back = await center.fromLiveSync(pair); - expect(c.settings.liveSyncPairs, isEmpty); + expect(c.settings.copy.liveSyncPairs, isEmpty); expect([back.contents, back.dest], [true, p.join(dst, 'src')]); expect(back.method, Platform.isWindows ? 'rsync' : 'builtin'); // rsync 로 옮겨 온다 (쓸 수 있으면) File(p.join(src, 'new.txt')).writeAsStringSync('n'); @@ -139,7 +139,7 @@ void main() { }); test('기억한 복사의 원본이 없어졌거나 자기 안으로면 시작하지 않고 실패로 남긴다', () async { - c.settings.copyMethodFolder = 'rsync'; + c.settings.copy.methodFolder = 'rsync'; final center = CopyCenter(c); final t = await center.remember([src], src); // 자기 안으로 expect(await center.start(t), isNull); @@ -157,7 +157,7 @@ void main() { final pair = LiveSyncPair(src, target, schedule: ['0 $off * * *'], delete: true); expect(await LiveSync.diff(pair), unorderedEquals(['a.txt', 'sub/b.txt'])); expect(LiveSync.activeNow(pair), isFalse); - c.settings.liveSyncPairs = [pair]; + c.settings.copy.liveSyncPairs = [pair]; final live = LiveSync(c)..start(); // 모아서 3초 뒤 (컴퓨터가 바쁘면 늦을 수 있어 정해진 4초가 아니라 될 때까지 - 최대 20초) for (var i = 0; i < 80 && (live.pending[LiveSync.keyOf(pair)]?.length ?? 0) < 2; i++) { @@ -191,10 +191,10 @@ void main() { test('설정 저장 · 읽기, Windows 는 종료 동작과 같은 값', () { final s = AppSettings() ..runInBackground = true - ..liveSyncOnStart = 'ask'; + ..copy.liveSyncOnStart = 'ask'; final r = AppSettings.fromJson(s.toJson()); - expect([r.runInBackground, r.liveSyncOnStart], [true, 'ask']); - expect(AppSettings.fromJson({'liveSyncOnStart': 'x'}).liveSyncOnStart, 'auto'); + expect([r.runInBackground, r.copy.liveSyncOnStart], [true, 'ask']); + expect(AppSettings.fromJson({'liveSyncOnStart': 'x'}).copy.liveSyncOnStart, 'auto'); // 사용자 결정 (10/8): Android 백그라운드로 실행은 기본 켜짐. 사용자가 끈 값 (새 형식) 은 그대로 expect(AppSettings.fromJson({}).runInBackground, isTrue); expect(AppSettings.fromJson({'runInBackground': false, 'backgroundV2': true}).runInBackground, isFalse); @@ -218,9 +218,9 @@ void main() { final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final pa = LiveSyncPair(a.path, p.join(tmp.path, 'ta')), pb = LiveSyncPair(b.path, p.join(tmp.path, 'tb')); c.settings - ..liveSyncPairs = [pa, pb] - ..liveSyncOnStart = 'off'; - final live = LiveSync(c)..start(hold: c.settings.liveSyncOnStart != 'auto'); + ..copy.liveSyncPairs = [pa, pb] + ..copy.liveSyncOnStart = 'off'; + final live = LiveSync(c)..start(hold: c.settings.copy.liveSyncOnStart != 'auto'); // 맞추기 (rsync 프로세스) 가 끝난 뒤 닫고 지운다 - 도는 중에 지우면 "다른 프로세스가 사용 중" (전체 시험이 무거울 때) addTearDown(() async { for (var i = 0; i < 200 && live.anyRunning; i++) { @@ -264,7 +264,7 @@ void main() { Directory(p.join(t.path, 'onlydir')).createSync(); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final pair = LiveSyncPair(a.path, t.path, method: 'builtin'); - c.settings.liveSyncPairs = [pair]; + c.settings.copy.liveSyncPairs = [pair]; final live = LiveSync(c); addTearDown(live.dispose); await live.refreshPending(pair); @@ -283,7 +283,7 @@ void main() { File(p.join(a.path, '1.txt')).writeAsStringSync('1'); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final pair = LiveSyncPair(a.path, p.join(tmp.path, 't'), method: 'builtin'); - c.settings.liveSyncPairs = [pair]; + c.settings.copy.liveSyncPairs = [pair]; final live = LiveSync(c); final running = live.syncNow(pair); live.dispose(); @@ -297,8 +297,8 @@ void main() { addTearDown(() => tmp.deleteSync(recursive: true)); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings - ..copyTasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, '..'), method: 'builtin')] - ..liveSyncPairs = [LiveSyncPair(tmp.path, p.join(tmp.path, 'x'), schedule: const ['0 9-17 * * 1-5'])]; + ..copy.tasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, '..'), method: 'builtin')] + ..copy.liveSyncPairs = [LiveSyncPair(tmp.path, p.join(tmp.path, 'x'), schedule: const ['0 9-17 * * 1-5'])]; tester.view.physicalSize = const Size(1500, 1000); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); @@ -318,7 +318,7 @@ void main() { expect(find.text('설정 반영 후 실행'), findsOneWidget); await tester.tap(find.text('현재 유지')); await tester.pumpAndSettle(); - expect(c.settings.copyTasks.single.bandwidthKBps, 500); + expect(c.settings.copy.tasks.single.bandwidthKBps, 500); expect(CopyCenter.of(c).jobs, isEmpty); // lsync 탭: 일정 요약 · 맞추기 버튼 @@ -333,7 +333,7 @@ void main() { final tmp = Directory.systemTemp.createTempSync('jj_monbad_'); addTearDown(() => tmp.deleteSync(recursive: true)); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); - c.settings.copyTasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, '..'), method: 'rsync', options: '-av delete')]; + c.settings.copy.tasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, '..'), method: 'rsync', options: '-av delete')]; tester.view.physicalSize = const Size(1500, 1000); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); @@ -353,7 +353,7 @@ void main() { final tmp = Directory.systemTemp.createTempSync('jj_mon233_'); addTearDown(() => tmp.deleteSync(recursive: true)); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); - c.settings.copyTasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, '..'), move: true, method: 'builtin')]; + c.settings.copy.tasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, '..'), move: true, method: 'builtin')]; tester.view.physicalSize = const Size(1500, 1000); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); @@ -372,6 +372,6 @@ void main() { expect(find.text('받는 쪽이 더 새것이면 건너뛰기'), findsOneWidget); await tester.tap(find.text('받는 쪽이 더 새것이면 건너뛰기')); await tester.pump(); - expect(c.settings.copySkipNewer, isFalse); + expect(c.settings.copy.skipNewer, isFalse); }); } diff --git a/test/move_settled_test.dart b/test/move_settled_test.dart index 334231a..f5ad459 100644 --- a/test/move_settled_test.dart +++ b/test/move_settled_test.dart @@ -182,8 +182,8 @@ void main() { }); test('229: 앱 직접 이동 - 받는 쪽이 더 새것이면 덮어쓰지 않고 원본도 남긴다 (환경 설정 처음 값 켜짐)', () async { - expect(AppSettings.fromJson({}).copySkipNewer, isTrue); - expect(AppSettings.fromJson((AppSettings()..copySkipNewer = false).toJson()).copySkipNewer, isFalse); + expect(AppSettings.fromJson({}).copy.skipNewer, isTrue); + expect(AppSettings.fromJson((AppSettings()..copy.skipNewer = false).toJson()).copy.skipNewer, isFalse); final old = File(p.join(src, 'older.txt'))..writeAsStringSync('old in src'); old.setLastModifiedSync(DateTime.now().subtract(const Duration(days: 2))); File(p.join(dst, 'older.txt')).writeAsStringSync('NEW version in dst'); diff --git a/test/rsync_msys_args_test.dart b/test/rsync_msys_args_test.dart index 0da64dd..93df706 100644 --- a/test/rsync_msys_args_test.dart +++ b/test/rsync_msys_args_test.dart @@ -225,14 +225,14 @@ void main() { final (s, d) = scene('ls', 'ld', ['a.txt'], ['old.txt']); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings - ..rsyncSource = 'custom' - ..rsyncPath = rsync - ..rsyncOptions = '-a' - ..liveSyncPairs = [LiveSyncPair(unc(s), unc(d), method: 'rsync', delete: true, deleteConfirmed: true, confirmedOptions: '-a')]; + ..copy.rsyncSource = 'custom' + ..copy.rsyncPath = rsync + ..copy.rsyncOptions = '-a' + ..copy.liveSyncPairs = [LiveSyncPair(unc(s), unc(d), method: 'rsync', delete: true, deleteConfirmed: true, confirmedOptions: '-a')]; final live = LiveSync(c); addTearDown(live.dispose); - await live.syncNow(c.settings.liveSyncPairs.single); - expect(live.status[LiveSync.keyOf(c.settings.liveSyncPairs.single)]!.$2, isNot(contains('실패'))); + await live.syncNow(c.settings.copy.liveSyncPairs.single); + expect(live.status[LiveSync.keyOf(c.settings.copy.liveSyncPairs.single)]!.$2, isNot(contains('실패'))); expect(treeOf(d).keys.toSet(), {'a.txt'}); }, skip: Platform.isWindows ? false : 'Windows 만 (MSYS2 rsync)'); @@ -241,13 +241,13 @@ void main() { final (s, d) = scene("s'1", "d'2", ['a.txt'], ["it's.txt", "Bob's.txt", 'old.txt']); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings - ..rsyncSource = 'custom' - ..rsyncPath = rsync - ..rsyncOptions = '-a --exclude="it\'s.txt" --exclude="Bob\'s.txt"' - ..liveSyncPairs = [LiveSyncPair(s, d, method: 'rsync', delete: true, deleteConfirmed: true, confirmedOptions: '-a --exclude="it\'s.txt" --exclude="Bob\'s.txt"')]; + ..copy.rsyncSource = 'custom' + ..copy.rsyncPath = rsync + ..copy.rsyncOptions = '-a --exclude="it\'s.txt" --exclude="Bob\'s.txt"' + ..copy.liveSyncPairs = [LiveSyncPair(s, d, method: 'rsync', delete: true, deleteConfirmed: true, confirmedOptions: '-a --exclude="it\'s.txt" --exclude="Bob\'s.txt"')]; final live = LiveSync(c); addTearDown(live.dispose); - final pair = c.settings.liveSyncPairs.single; + final pair = c.settings.copy.liveSyncPairs.single; await live.syncNow(pair); expect(live.status[LiveSync.keyOf(pair)]!.$2, isNot(contains('실패'))); final t = treeOf(d); diff --git a/test/rsync_phsr_test.dart b/test/rsync_phsr_test.dart index 86587bd..aa84ddc 100644 --- a/test/rsync_phsr_test.dart +++ b/test/rsync_phsr_test.dart @@ -159,15 +159,15 @@ void main() { // ③ · ④ 실시간 동기화 (새 쌍 - 지울 것이 있으면 묻고, 확인하면 맞춤) final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings - ..rsyncSource = 'custom' - ..rsyncPath = rsync - ..rsyncOptions = opts + ..copy.rsyncSource = 'custom' + ..copy.rsyncPath = rsync + ..copy.rsyncOptions = opts ..webdavServers = [dav()]; for (final (label, target, local) in [('실시간 (로컬)', p.join(tmp.path, 'live'), true), ('실시간 (원격)', 'dav://$id/l', false)]) { final dir = local ? target : p.join(remote.path, 'l'); writeScene(src, dir, srcFiles: _src, dstFiles: _dst); final pair = LiveSyncPair(src, target, method: 'rsync', delete: delete); - c.settings.liveSyncPairs = [pair]; + c.settings.copy.liveSyncPairs = [pair]; final live = LiveSync(c); final k = LiveSync.keyOf(pair); await live.syncNow(pair); @@ -180,7 +180,7 @@ void main() { for (final a in asked) { if (!gone.contains(a)) why.add('$label: rsync 는 지우지 않는데 물음: $a'); } - if (asked.isNotEmpty) await live.decideDelete(c.settings.liveSyncPairs.single, delete: true); + if (asked.isNotEmpty) await live.decideDelete(c.settings.copy.liveSyncPairs.single, delete: true); for (final d in treeDiff(w, treeOf(dir))) { why.add('$label: $d'); } diff --git a/test/rsync_safety_test.dart b/test/rsync_safety_test.dart index a5b2e21..98c773d 100644 --- a/test/rsync_safety_test.dart +++ b/test/rsync_safety_test.dart @@ -39,7 +39,7 @@ void main() { c.settings ..webdavServers = [s] ..explorer.layout = 'single' - ..rsyncPaths = [left, 'dav://nas/']; + ..copy.rsyncPaths = [left, 'dav://nas/']; DavRegistry.configure([s]); }); tearDown(() async { @@ -68,7 +68,7 @@ void main() { testWidgets('100 · 95: WebDAV ⇄ + --delete - 미리 보기대로 → (지움) 다음 ← (지우지 않음), 한쪽에만 있던 새 파일이 양쪽에서 사라지지 않는다', (tester) async { - c.settings.rsyncOptions = '-avPog --delete'; + c.settings.copy.rsyncOptions = '-avPog --delete'; await open(tester); await act(tester, () => tester.tap(find.text('좌 ⇄ 우'))); await waitCompare(tester); @@ -92,7 +92,7 @@ void main() { testWidgets('96: 원본 파일 지우기를 켜면 확인 창 · 미리 보기가 실제 실행할 (이동) 작업의 옵션으로 바뀐다', (tester) async { // 기억해 둔 (복사) 작업은 --delete 없음, 새 작업의 기본 옵션은 --delete 있음 - c.settings.rsyncOptions = '-avPog --delete'; + c.settings.copy.rsyncOptions = '-avPog --delete'; await open(tester); await CopyCenter.of(c) .remember([p.join(left, 'sub')], 'dav://nas/rsub', contents: true, method: 'rsync') @@ -115,7 +115,7 @@ void main() { }); testWidgets('97: [다시 비교] 하는 동안은 지우는 실행을 누를 수 없다', (tester) async { - c.settings.rsyncOptions = '-avPog --del'; + c.settings.copy.rsyncOptions = '-avPog --del'; await open(tester); await act(tester, () => tester.tap(find.text('좌 → 우'))); await waitCompare(tester); @@ -150,13 +150,13 @@ void main() { await settle(tester, () => false, rounds: 60); expect(File(p.join(remote.path, 'rsub', 'a.txt')).existsSync(), isFalse); expect(File(p.join(left, 'sub', 'b.txt')).existsSync(), isFalse); - expect(c.settings.copyTasks, isEmpty); + expect(c.settings.copy.tasks, isEmpty); await tester.pump(const Duration(seconds: 20)); }); } testWidgets('110: 좁은 화면 - 비교가 끝나도 [취소] · [실행] 자리가 그대로, 창을 여러 번 열고 닫아도 실행되지 않음', (tester) async { - c.settings.rsyncOptions = '-avPog --delete'; + c.settings.copy.rsyncOptions = '-avPog --delete'; HttpOverrides.global = null; tester.view.physicalSize = const Size(900, 1400); // 세로로 든 태블릿 tester.view.devicePixelRatio = 1; diff --git a/test/security_bits_test.dart b/test/security_bits_test.dart index c66cdcd..7c9d714 100644 --- a/test/security_bits_test.dart +++ b/test/security_bits_test.dart @@ -67,12 +67,12 @@ void main() { test('124-1: 지우려는 서버를 쓰는 실시간 동기화 · 기억된 작업을 찾는다', () { final s = AppSettings() - ..liveSyncPairs = [ + ..copy.liveSyncPairs = [ LiveSyncPair(r'C:\영상', 'dav://nas/백업'), LiveSyncPair(r'C:\a', r'D:\b'), LiveSyncPair('dav://other/x', r'C:\y'), ] - ..copyTasks = [ + ..copy.tasks = [ CopyTask(id: 't', sources: const ['dav://nas/사진'], dest: r'C:\사진', method: 'builtin', options: '', once: false), ]; final users = davServerUsers(s, 'nas'); diff --git a/test/settings_text_focus_test.dart b/test/settings_text_focus_test.dart index 1634ee2..55c1cc8 100644 --- a/test/settings_text_focus_test.dart +++ b/test/settings_text_focus_test.dart @@ -12,7 +12,7 @@ import 'package:jj_mkvmaker/ui/settings_page.dart'; void main() { testWidgets('213: 기본값 ("-avPog") 을 지나며 쳐도 칸이 그대로 (포커스 · 입력 이어짐) · ↺ 는 칸 안의 글도 되돌린다', (tester) async { final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); - c.settings.rsyncOptions = '-avPo'; + c.settings.copy.rsyncOptions = '-avPo'; tester.view.physicalSize = const Size(1400, 1600); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); @@ -36,11 +36,11 @@ void main() { expect(tester.state(find.byType(TextFormField).first), same(first), reason: '칸을 새로 만들지 않음 ($t)'); expect(tester.testTextInput.hasAnyClients, isTrue, reason: '포커스 그대로 ($t)'); } - expect(c.settings.rsyncOptions, '-avPog -'); + expect(c.settings.copy.rsyncOptions, '-avPog -'); // ↺: 칸 안의 글도 기본값으로 await tester.tap(find.byTooltip('기본값으로 ($defaultRsyncOptions)').first); await tester.pump(); - expect(c.settings.rsyncOptions, defaultRsyncOptions); + expect(c.settings.copy.rsyncOptions, defaultRsyncOptions); expect(find.text(defaultRsyncOptions), findsWidgets); }); diff --git a/test/small_items_test.dart b/test/small_items_test.dart index c69377f..889f359 100644 --- a/test/small_items_test.dart +++ b/test/small_items_test.dart @@ -25,7 +25,7 @@ void main() { }); test('WebDAV 가 끼면 탐색기 복사 확인 창에 robocopy 가 아니라 실제 방법 (현재 방식)', () { - c.settings.copyMethodFolder = 'robocopy'; + c.settings.copy.methodFolder = 'robocopy'; final t = CopyCenter.of(c).fresh(['dav://nas/a'], r'C:\dst'); expect(CopyMethod.of(t.method), CopyMethod.builtin); // Rsync 화면 (방법을 정해서) 은 그대로 diff --git a/test/sync_alert_test.dart b/test/sync_alert_test.dart index bf6ce05..b574f22 100644 --- a/test/sync_alert_test.dart +++ b/test/sync_alert_test.dart @@ -39,7 +39,7 @@ void main() { addTearDown(() => tmp.deleteSync(recursive: true)); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final pair = LiveSyncPair(p.join(tmp.path, 'gone'), p.join(tmp.path, 'dst')); - c.settings.liveSyncPairs = [pair]; + c.settings.copy.liveSyncPairs = [pair]; final live = LiveSync(c); final shell = _Shell(); var opened = 0; diff --git a/test/sync_problem_test.dart b/test/sync_problem_test.dart index ab93940..fbcda4c 100644 --- a/test/sync_problem_test.dart +++ b/test/sync_problem_test.dart @@ -40,7 +40,7 @@ void main() { test('68: 원본을 읽을 수 없으면 문제로 기억 (짧은 원인 · 원문 예외 없음) · "맞출 것 없음" 이 아님 · 작업 알림 맨 앞', () async { final pair = LiveSyncPair(src, dst, delete: true).copyWith(deleteConfirmed: true); - c.settings.liveSyncPairs = [pair]; + c.settings.copy.liveSyncPairs = [pair]; final live = LiveSync(c); final k = LiveSync.keyOf(pair); await live.refreshPending(pair); @@ -64,12 +64,12 @@ void main() { test('258: 멈춘 쌍을 지우면 "동기화 멈춤" 도 바로 사라진다 (앱을 다시 켜지 않아도)', () async { final pair = LiveSyncPair(src, dst, delete: true).copyWith(deleteConfirmed: true); - c.settings.liveSyncPairs = [pair]; + c.settings.copy.liveSyncPairs = [pair]; final live = LiveSync(c)..start(); final k = LiveSync.keyOf(pair); await live.syncNow(pair); expect(live.problems[k], isNotNull); - await c.updateSettings((s) => s.liveSyncPairs = []); + await c.updateSettings((s) => s.copy.liveSyncPairs = []); expect(live.problems, isEmpty); live.dispose(); }); @@ -77,7 +77,7 @@ void main() { test('70: 원본이 정말 비었으면 멈추고 지울 목록 → [그래도 맞추기] 로 그 한 번만 지운다', () async { Directory(src).createSync(); final pair = LiveSyncPair(src, dst, delete: true).copyWith(deleteConfirmed: true); - c.settings.liveSyncPairs = [pair]; + c.settings.copy.liveSyncPairs = [pair]; final live = LiveSync(c); final k = LiveSync.keyOf(pair); await live.syncNow(pair); @@ -104,7 +104,7 @@ void main() { final live = LiveSync(c); // 안쪽 → 바깥, 지우기 없음: 맞춘다 (다른 파일은 그대로) final ok = LiveSyncPair(inner, outer); - c.settings.liveSyncPairs = [ok]; + c.settings.copy.liveSyncPairs = [ok]; await live.syncNow(ok); expect(File(p.join(outer, 'new.txt')).existsSync(), isTrue); expect(File(p.join(outer, 'other.txt')).existsSync(), isTrue); @@ -123,7 +123,7 @@ void main() { (tester) async { Directory(src).createSync(); final pair = LiveSyncPair(src, dst, delete: true).copyWith(deleteConfirmed: true); - c.settings.liveSyncPairs = [pair]; + c.settings.copy.liveSyncPairs = [pair]; final live = LiveSync(c); LiveSync.instance = live; addTearDown(() { @@ -155,7 +155,7 @@ void main() { Directory(src).createSync(); File(p.join(src, 'x.txt')).writeAsStringSync('x'); final pair = LiveSyncPair(src, dst); - c.settings.liveSyncPairs = [pair]; + c.settings.copy.liveSyncPairs = [pair]; final live = LiveSync(c); LiveSync.instance = live; addTearDown(() { @@ -188,8 +188,8 @@ void main() { ..writeAsStringSync('r'); c.settings ..explorer.layout = 'single' - ..rsyncOptions = '-avPog --delete' - ..rsyncPaths = [l, r]; + ..copy.rsyncOptions = '-avPog --delete' + ..copy.rsyncPaths = [l, r]; tester.view.physicalSize = const Size(1600, 900); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); diff --git a/test/sync_tools_test.dart b/test/sync_tools_test.dart index 28315f3..ea2f747 100644 --- a/test/sync_tools_test.dart +++ b/test/sync_tools_test.dart @@ -37,11 +37,11 @@ void main() { }); test('파일 탐색기 복사 방법: 현재 방식 (기본) · robocopy, 예전 값 rsync 는 현재 방식으로 (rsync 는 Rsync 화면)', () { - expect([AppSettings().copyMethodFile, AppSettings().copyMethodFolder], ['builtin', 'builtin']); - expect(AppSettings.fromJson({'copyMethodFolder': 'rsync', 'copyMethodFile': 'rsync'}).copyMethodFolder, 'builtin'); - expect(AppSettings.fromJson({'copyMethodFolder': 'robocopy'}).copyMethodFolder, 'robocopy'); - final s = AppSettings()..rsyncPaths = ['/a', '/b']; - expect(AppSettings.fromJson(s.toJson()).rsyncPaths, ['/a', '/b']); + expect([AppSettings().copy.methodFile, AppSettings().copy.methodFolder], ['builtin', 'builtin']); + expect(AppSettings.fromJson({'copyMethodFolder': 'rsync', 'copyMethodFile': 'rsync'}).copy.methodFolder, 'builtin'); + expect(AppSettings.fromJson({'copyMethodFolder': 'robocopy'}).copy.methodFolder, 'robocopy'); + final s = AppSettings()..copy.rsyncPaths = ['/a', '/b']; + expect(AppSettings.fromJson(s.toJson()).copy.rsyncPaths, ['/a', '/b']); }); test('양쪽 (⇄) rsync 는 -u 를 더한다 (이미 있으면 그대로)', () { @@ -117,7 +117,7 @@ void main() { expect(rc('/E /SJ'), isNot(contains('/XJ'))); expect(rc('/E /xjd').where((o) => o.toUpperCase().startsWith('/XJ')), ['/xjd']); expect([robocopySkipsLinks(['/E']), robocopySkipsLinks(['/E', '/SJ'])], [true, false]); - final live = LiveSync.robocopyArgsFor(LiveSyncPair(r'C:\a', r'D:\b'), AppSettings()..robocopyOptions = '/E'); + final live = LiveSync.robocopyArgsFor(LiveSyncPair(r'C:\a', r'D:\b'), AppSettings()..copy.robocopyOptions = '/E'); expect(live, contains('/XJ')); }); @@ -277,20 +277,20 @@ void main() { test('실시간 동기화: 지금 맞추기 (현재 방식) · 상태', () async { final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final pair = LiveSyncPair(src, p.join(tmp.path, 'mirror')); - c.settings.liveSyncPairs = [pair]; + c.settings.copy.liveSyncPairs = [pair]; final live = LiveSync(c); await live.syncNow(pair); expect(File(p.join(tmp.path, 'mirror', 'sub', 'b.txt')).readAsStringSync(), 'bbb'); expect(live.status[LiveSync.keyOf(pair)]!.$2, contains('3')); // 대상이 원본 안이면 거부 - c.settings.liveSyncPairs = [LiveSyncPair(src, p.join(src, 'in'))]; + c.settings.copy.liveSyncPairs = [LiveSyncPair(src, p.join(src, 'in'))]; live.start(); - expect(live.status[LiveSync.keyOf(c.settings.liveSyncPairs.first)]!.$2, contains('안에')); + expect(live.status[LiveSync.keyOf(c.settings.copy.liveSyncPairs.first)]!.$2, contains('안에')); live.dispose(); // 설정 저장 · 읽기 final back = AppSettings.fromJson(c.settings.toJson()); - expect(back.liveSyncPairs.single.target, p.join(src, 'in')); - expect([back.copyMethodFolder, back.rsyncOptions, back.copyRunMode, back.rsyncSource], + expect(back.copy.liveSyncPairs.single.target, p.join(src, 'in')); + expect([back.copy.methodFolder, back.copy.rsyncOptions, back.copy.runMode, back.copy.rsyncSource], ['builtin', '-avPog', 'each', 'download']); }); }); diff --git a/test/webdav_test.dart b/test/webdav_test.dart index 6db552e..95785d0 100644 --- a/test/webdav_test.dart +++ b/test/webdav_test.dart @@ -155,7 +155,7 @@ void main() { // 예전에 확인한 쌍은 처음 맞출 때 지금 옵션을 기억하며 설정을 저장한다 (서버 목록도 설정에서 다시 읽음) - 시험 서버를 설정에도 c.settings.webdavServers = [DavServer(id: id, name: '집 NAS', url: server.url, user: 'user', password: 'pass')]; final pair = LiveSyncPair(p.join(local.path, 'live'), 'dav://$id/mirror', method: 'rsync', delete: true, deleteConfirmed: true); - c.settings.liveSyncPairs = [pair]; + c.settings.copy.liveSyncPairs = [pair]; final live = LiveSync(c); expect(await LiveSync.diff(pair), unorderedEquals(['a.txt', 'sub/b.txt', '− old.txt'])); await live.syncNow(pair); @@ -336,10 +336,10 @@ void main() { final before = treeOf(got); // rsync 가 지운 것 (받는 쪽에 있다가 없어진 것) final gone = [for (final k in before.keys) if (!w.containsKey(k)) k]; - c.settings.rsyncOptions = opts; + c.settings.copy.rsyncOptions = opts; // 새 쌍 (지우기 확인 전) - 지울 것이 있으면 지우지 않고 맞춘 뒤 [toDelete] 로 묻는다 final pair = LiveSyncPair(src, 'dav://$id/live', method: 'rsync', delete: optionsDelete(opts)); - c.settings.liveSyncPairs = [pair]; + c.settings.copy.liveSyncPairs = [pair]; final live = LiveSync(c); final k = LiveSync.keyOf(pair); final why = []; @@ -353,7 +353,7 @@ void main() { for (final a in asked) { if (!gone.contains(a)) why.add('rsync 는 지우지 않는데 물음: $a'); } - if (asked.isNotEmpty) await live.decideDelete(c.settings.liveSyncPairs.single, delete: true); + if (asked.isNotEmpty) await live.decideDelete(c.settings.copy.liveSyncPairs.single, delete: true); why.addAll(treeDiff(w, treeOf(got))); if ((live.pending[k] ?? const []).isNotEmpty) why.add('맞춘 뒤에도 맞출 것: ${live.pending[k]}'); live.dispose(); @@ -368,18 +368,18 @@ void main() { final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings ..webdavServers = [DavServer(id: id, name: '집 NAS', url: server.url, user: 'user', password: 'pass')] - ..rsyncOptions = '-av --exclude=*.tmp' - ..liveSyncPairs = [ + ..copy.rsyncOptions = '-av --exclude=*.tmp' + ..copy.liveSyncPairs = [ LiveSyncPair(p.join(local.path, 'rq'), 'dav://$id/rqd', method: 'rsync', delete: true, deleteConfirmed: true, confirmedOptions: '-av --exclude=*.tmp'), ]; final live = LiveSync(c); addTearDown(live.dispose); - LiveSyncPair pair() => c.settings.liveSyncPairs.single; + LiveSyncPair pair() => c.settings.copy.liveSyncPairs.single; final k = LiveSync.keyOf(pair()); await live.syncNow(pair()); expect(live.toDelete[k], isNull); - c.settings.rsyncOptions = '-av --exclude=*.tmp --delete-excluded'; + c.settings.copy.rsyncOptions = '-av --exclude=*.tmp --delete-excluded'; await live.syncNow(pair()); expect(live.toDelete[k], ['only.tmp']); expect(live.reasked, contains(k));