From 58bc8fa35b4f57e372cee9c3871207df146fd7af Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:41:58 +0900 Subject: [PATCH] =?UTF-8?q?lsync=20=EB=A1=9C=20=EC=98=AE=EA=B8=B8=20?= =?UTF-8?q?=EB=95=8C=20=EC=9B=90=EB=B3=B8=EC=9D=84=20=EC=A7=80=EC=9A=B0?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EC=9D=8C=20=EC=95=88=EB=82=B4=20=C2=B7=20?= =?UTF-8?q?=EB=8C=80=EC=83=81=EC=97=90=EB=A7=8C=20=EC=9E=88=EB=8A=94=20?= =?UTF-8?q?=EA=B2=83=EC=9D=84=20=EC=82=AC=EC=8B=A4=EB=8C=80=EB=A1=9C=20(24?= =?UTF-8?q?3=20=C2=B7=20244)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 243 (사용자 편의, 기기 확인): 이동 카드를 lsync 로 옮기면 원본이 남아 헷갈렸다 (107: 실시간 동기화는 복사로 맞춤) - 확인 창에 알리고 쌍에 '복사로 맞춤' - 244 (버그, 기기 확인): 대상에만 있는 파일이 있는데 '같습니다' - 대상에만 있는 수를 세어 사실대로 Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 3 +++ assets/l10n/ja.json | 3 +++ assets/l10n/zh-Hans.json | 3 +++ lib/app/live_sync.dart | 19 +++++++++++++++++++ lib/ui/monitor_page.dart | 30 +++++++++++++++++++++++------- test/dismiss_test.dart | 23 +++++++++++++++++++++++ test/monitor_test.dart | 25 +++++++++++++++++++++++++ 7 files changed, 99 insertions(+), 7 deletions(-) diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 568fdd3..f67d78e 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -1000,6 +1000,7 @@ "복사 실패 ({0}): {1}": "Copy failed ({0}): {1}", "복사 작업이라 칸의 원본 지우기 옵션 (--remove-source-files · /MOV) 은 쓰지 않습니다. 옮기려면 Rsync 화면에서 [원본 파일 지우기] 를 켜세요": "This is a copy task, so source-removing options in the field (--remove-source-files · /MOV) are not used. To move, turn on [Remove source files] on the Rsync screen", "복사(Ctrl+C)한 주소 자동 다운로드": "Auto-download copied (Ctrl+C) URLs", + "복사로 맞춤": "Syncs by copying", "복사하는 중": "Copying", "복사할": "copy", "복사할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "Choose items to copy (tap, or mark with the circle on the right).", @@ -1191,6 +1192,7 @@ "실시간 동기화: 원본 폴더": "Live sync: source folder", "실시간 동기화가 멈췄습니다": "Live sync stopped", "실시간 동기화가 없습니다. [추가] 하거나 복사 · rsync 탭에서 [lsync 로 이동] 하세요.": "No live sync. [Add] one, or use [Move to lsync] in the copy · rsync tab.", + "실시간 동기화는 원본을 지우지 않습니다 (복사로 맞춤). 원본을 비우려면 lsync 의 [최종 정리] 를 쓰세요.": "Real-time sync never deletes the source (it syncs by copying). To empty the source, use [Final cleanup] in lsync.", "실시간 동기화를 지울까요?": "Delete this live sync?", "실시간 동기화의 [최종 정리]": "[Final cleanup] in live sync", "실제 실행: {0}": "Actually runs: {0}", @@ -1385,6 +1387,7 @@ "원본을 지우는 실행이라, 옮길 목록이 나온 뒤에 실행할 수 있습니다.": "This run removes source files, so it can run once the list of files to move is shown.", "원본을 지우는 작업입니다 (이동)": "This task removes source files (move)", "원본의 {0}배": "{0}× original", + "원본의 것은 모두 대상에 있습니다 (대상에만 있는 것 {0}개는 그대로 둠)": "Everything in the source is in the target ({0} items only in the target are left as they are)", "원본의 빈 폴더 정리 중…": "Cleaning up empty folders in the source…", "원본의 파일을 모두 대상으로 옮기고 (원본에서 지움), 원본에 남은 빈 폴더를 지웁니다.": "Moves all files from the source to the target (deleting them from the source), then deletes the empty folders left in the source.", "원본이 대상 안에 있습니다 (환경 설정에서 막아 둠)": "The source is inside the target (blocked in settings)", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 74e16cb..3f5242d 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -1000,6 +1000,7 @@ "복사 실패 ({0}): {1}": "コピー失敗 ({0}): {1}", "복사 작업이라 칸의 원본 지우기 옵션 (--remove-source-files · /MOV) 은 쓰지 않습니다. 옮기려면 Rsync 화면에서 [원본 파일 지우기] 를 켜세요": "コピー作業のため、欄の元ファイル削除オプション (--remove-source-files · /MOV) は使いません。移動するには Rsync 画面で [元ファイルを削除] をオンにしてください", "복사(Ctrl+C)한 주소 자동 다운로드": "コピー(Ctrl+C)した URL を自動ダウンロード", + "복사로 맞춤": "コピーで合わせる", "복사하는 중": "コピー中", "복사할": "コピーする", "복사할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "コピーする項目を選んでください (タップするか右の丸で印を付ける)。", @@ -1191,6 +1192,7 @@ "실시간 동기화: 원본 폴더": "リアルタイム同期: 元フォルダー", "실시간 동기화가 멈췄습니다": "リアルタイム同期が停止しました", "실시간 동기화가 없습니다. [추가] 하거나 복사 · rsync 탭에서 [lsync 로 이동] 하세요.": "リアルタイム同期はありません。[追加] するか、コピー · rsync タブで [lsync へ移動] してください。", + "실시간 동기화는 원본을 지우지 않습니다 (복사로 맞춤). 원본을 비우려면 lsync 의 [최종 정리] 를 쓰세요.": "リアルタイム同期は元を削除しません (コピーで合わせます)。元を空にするには lsync の [最終整理] を使ってください。", "실시간 동기화를 지울까요?": "このリアルタイム同期を削除しますか?", "실시간 동기화의 [최종 정리]": "リアルタイム同期の [最終整理]", "실제 실행: {0}": "実際の実行: {0}", @@ -1385,6 +1387,7 @@ "원본을 지우는 실행이라, 옮길 목록이 나온 뒤에 실행할 수 있습니다.": "元のファイルを削除する実行のため、移動する一覧が表示されてから実行できます。", "원본을 지우는 작업입니다 (이동)": "元のファイルを削除する作業です (移動)", "원본의 {0}배": "元の {0}倍", + "원본의 것은 모두 대상에 있습니다 (대상에만 있는 것 {0}개는 그대로 둠)": "元のものはすべて対象にあります (対象にだけある {0} 件はそのまま)", "원본의 빈 폴더 정리 중…": "元の空フォルダーを整理中…", "원본의 파일을 모두 대상으로 옮기고 (원본에서 지움), 원본에 남은 빈 폴더를 지웁니다.": "元のファイルをすべて対象へ移し (元から削除)、元に残った空フォルダーを削除します。", "원본이 대상 안에 있습니다 (환경 설정에서 막아 둠)": "元が対象の中にあります (環境設定で禁止)", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 1037c7c..62c399f 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -1000,6 +1000,7 @@ "복사 실패 ({0}): {1}": "复制失败 ({0}): {1}", "복사 작업이라 칸의 원본 지우기 옵션 (--remove-source-files · /MOV) 은 쓰지 않습니다. 옮기려면 Rsync 화면에서 [원본 파일 지우기] 를 켜세요": "这是复制任务,栏中的删除源文件选项(--remove-source-files · /MOV)不会使用。要移动,请在 Rsync 界面打开 [删除源文件]", "복사(Ctrl+C)한 주소 자동 다운로드": "自动下载复制(Ctrl+C)的地址", + "복사로 맞춤": "以复制同步", "복사하는 중": "正在复制", "복사할": "复制", "복사할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "请选择要复制的项目 (点按或用右侧圆圈标记)。", @@ -1191,6 +1192,7 @@ "실시간 동기화: 원본 폴더": "实时同步: 源文件夹", "실시간 동기화가 멈췄습니다": "实时同步已停止", "실시간 동기화가 없습니다. [추가] 하거나 복사 · rsync 탭에서 [lsync 로 이동] 하세요.": "没有实时同步。请 [添加],或在 复制 · rsync 标签中 [移到 lsync]。", + "실시간 동기화는 원본을 지우지 않습니다 (복사로 맞춤). 원본을 비우려면 lsync 의 [최종 정리] 를 쓰세요.": "实时同步不会删除源(以复制方式同步)。要清空源,请使用 lsync 的 [最终整理]。", "실시간 동기화를 지울까요?": "删除此实时同步吗?", "실시간 동기화의 [최종 정리]": "实时同步的 [最终整理]", "실제 실행: {0}": "实际执行:{0}", @@ -1385,6 +1387,7 @@ "원본을 지우는 실행이라, 옮길 목록이 나온 뒤에 실행할 수 있습니다.": "此操作会删除源文件,需在显示要移动的列表后才能执行。", "원본을 지우는 작업입니다 (이동)": "此任务会删除源文件(移动)", "원본의 {0}배": "原始的 {0} 倍", + "원본의 것은 모두 대상에 있습니다 (대상에만 있는 것 {0}개는 그대로 둠)": "源中的内容都已在目标中(仅在目标中的 {0} 项保持不变)", "원본의 빈 폴더 정리 중…": "正在清理源中的空文件夹…", "원본의 파일을 모두 대상으로 옮기고 (원본에서 지움), 원본에 남은 빈 폴더를 지웁니다.": "将源中的所有文件移到目标 (从源中删除),然后删除源中剩下的空文件夹。", "원본이 대상 안에 있습니다 (환경 설정에서 막아 둠)": "源位于目标内 (已在设置中阻止)", diff --git a/lib/app/live_sync.dart b/lib/app/live_sync.dart index bbd6b65..daef84c 100644 --- a/lib/app/live_sync.dart +++ b/lib/app/live_sync.dart @@ -247,10 +247,28 @@ class LiveSync extends ChangeNotifier { } try { pending[k] = await diff(x); + await _countOnlyTarget(x); } catch (_) {} notifyListeners(); } + /// 244: 지우기 포함이 아닌 쌍에서 대상에만 있는 항목 수 (맞출 것이 없을 때 "같습니다" 대신 사실대로 보이려고) + final onlyTarget = {}; + + Future _countOnlyTarget(LiveSyncPair x) async { + final k = keyOf(x); + if (x.delete || (pending[k]?.isNotEmpty ?? true)) { + onlyTarget.remove(k); + return; + } + // 지우기 포함으로 셌을 때 "지울 것" (− ) 이 곧 대상에만 있는 것 + try { + onlyTarget[k] = [for (final d in await diff(x.copyWith(delete: true), limit: 10000)) if (d.startsWith('− ')) d].length; + } catch (_) { + onlyTarget.remove(k); // 세지 못하면 예전 글 + } + } + static String keyOf(LiveSyncPair x) => '${x.source}=>${x.target}'; /// 지우기를 확인하지 않은 쌍: 대상에만 있어 지워질 항목 (원본 기준 상대 경로). 확인 창에 보여 준다 (42) @@ -401,6 +419,7 @@ class LiveSync extends ChangeNotifier { problems.remove(k); emptyDeletes.remove(k); pending[k] = await diff(asked); + await _countOnlyTarget(asked); if (n != 0) c.note(trf('실시간 동기화 ({0}): {1} → {2}', [method.label, x.source, x.target])); } on SourceUnreadableException catch (e) { // 68 · 70: 대상은 건드리지 않고 멈춤. 카드에 빨갛게 · 작업 알림에 · 로그에 diff --git a/lib/ui/monitor_page.dart b/lib/ui/monitor_page.dart index fbe3cb8..7fcb0de 100644 --- a/lib/ui/monitor_page.dart +++ b/lib/ui/monitor_page.dart @@ -455,20 +455,30 @@ class _CopyCardState extends State<_CopyCard> { /// lsync 로: 끝나지 않은 것 (실행 중 · 실패 · 취소 · 아직 안 함) 도 옮길 수 있다 - 남은 것은 lsync 가 이어서 맞춘다. /// 실행 중이면 물어본 뒤 멈추고 옮긴다. Future _toLive(CopyTask t) async { - if (center.isRunning(t.id)) { + final running = center.isRunning(t.id); + // 243: 이동 카드 - 실시간 동기화는 원본을 지우지 않는다 (107). 옮기기 전에 알린다 + if (running || t.move) { final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( - scrollable: true, + scrollable: true, title: Text(tr('lsync 로 이동')), - content: Text(tr('지금 실행 중입니다. 멈추고 lsync (실시간 동기화) 로 옮길까요? 남은 것은 lsync 가 이어서 맞춥니다.')), + content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (running) Text(tr('지금 실행 중입니다. 멈추고 lsync (실시간 동기화) 로 옮길까요? 남은 것은 lsync 가 이어서 맞춥니다.')), + if (running && t.move) const SizedBox(height: 10), + if (t.move) + Text(tr('실시간 동기화는 원본을 지우지 않습니다 (복사로 맞춤). 원본을 비우려면 lsync 의 [최종 정리] 를 쓰세요.'), + style: const TextStyle(color: Colors.orangeAccent)), + ]), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), - FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('멈추고 옮기기'))), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(running ? tr('멈추고 옮기기') : tr('옮기기'))), ], ), ); - if (ok != true) return; + if (ok != true || !mounted) return; + } + if (running) { final job = center.jobs[t.id]!; job.cancel(); await job.done; @@ -942,7 +952,8 @@ class _LiveTabState extends State<_LiveTab> { ), Chip( visualDensity: VisualDensity.compact, - label: Text([tr(CopyMethod.of(x.method).label), if (x.delete) tr('지우기 포함')].join(' · ')), + // 243: 실시간 동기화는 늘 복사로 맞춘다 (원본은 지우지 않음 - 원본을 비우려면 [최종 정리]) + label: Text([tr(CopyMethod.of(x.method).label), tr('복사로 맞춤'), if (x.delete) tr('지우기 포함')].join(' · ')), ), // 이번 실행 동안 멈춤 / 시작 (설정의 켜기 · 끄기는 오른쪽 스위치) if (live != null && x.enabled) @@ -1013,7 +1024,12 @@ class _LiveTabState extends State<_LiveTab> { child: pend == null ? Text(tr('맞출 것을 세는 중…'), style: const TextStyle(fontSize: 12, color: JjColors.textDim)) : pend.isEmpty - ? Text(tr('맞출 것 없음 (원본과 대상이 같습니다)'), style: const TextStyle(fontSize: 12, color: Colors.greenAccent)) + // 244: 대상에만 있는 것이 있으면 "같습니다" 가 아니라 사실대로 + ? Text( + (live?.onlyTarget[k] ?? 0) > 0 + ? trf('원본의 것은 모두 대상에 있습니다 (대상에만 있는 것 {0}개는 그대로 둠)', [live!.onlyTarget[k]]) + : tr('맞출 것 없음 (원본과 대상이 같습니다)'), + style: const TextStyle(fontSize: 12, color: Colors.greenAccent)) : Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( trf('맞출 것 {0}개{1}', [pend.length >= 500 ? '500+' : pend.length, active ? '' : tr(' · 다음 시작 시간에 맞춥니다')]), diff --git a/test/dismiss_test.dart b/test/dismiss_test.dart index dc21781..5ca3a2e 100644 --- a/test/dismiss_test.dart +++ b/test/dismiss_test.dart @@ -277,6 +277,29 @@ void main() { expect(userErrorText('그냥 글'), '그냥 글'); }); + testWidgets('243: 이동 카드를 lsync 로 옮길 때 "원본을 지우지 않음 (복사로 맞춤)" 을 알리고, 취소하면 옮기지 않는다', (tester) async { + 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')]; + tester.view.physicalSize = const Size(1500, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget(MaterialApp(home: MonitorPage(c: c))); + await tester.pump(); + await tester.tap(find.text('lsync 로 이동').first); + await tester.pumpAndSettle(); + expect(find.textContaining('실시간 동기화는 원본을 지우지 않습니다 (복사로 맞춤)'), findsOneWidget); + await tester.tap(find.text('취소').last); + await tester.pumpAndSettle(); + expect(c.settings.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]); + }); + testWidgets('105: 원본이 대상 안 + 창에서 [원본 파일 지우기] 로 지우기 옵션이 생기면 막는다', (tester) async { final outer = p.join(tmp.path, 'O'), inner = p.join(outer, 'in'); File(p.join(inner, 'n.txt')) diff --git a/test/monitor_test.dart b/test/monitor_test.dart index 83500b9..0f8869b 100644 --- a/test/monitor_test.dart +++ b/test/monitor_test.dart @@ -237,6 +237,31 @@ void main() { expect(live.watching.map((x) => x.source), [b.path]); }); + test('244: 맞출 것이 없어도 대상에만 있는 것이 있으면 "같습니다" 가 아니라 그 수를 센다', () async { + final tmp = Directory.systemTemp.createTempSync('jj_only_'); + addTearDown(() => tmp.deleteSync(recursive: true)); + final a = Directory(p.join(tmp.path, 'a'))..createSync(); + final t = Directory(p.join(tmp.path, 't'))..createSync(); + final now = DateTime.now(); + for (final d in [a, t]) { + (File(p.join(d.path, 'same.txt'))..writeAsStringSync('s')).setLastModifiedSync(now); + } + File(p.join(t.path, 'only1.txt')).writeAsStringSync('1'); + 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]; + final live = LiveSync(c); + addTearDown(live.dispose); + await live.refreshPending(pair); + expect(live.pending[LiveSync.keyOf(pair)], isEmpty); + expect(live.onlyTarget[LiveSync.keyOf(pair)], 2); + File(p.join(t.path, 'only1.txt')).deleteSync(); + Directory(p.join(t.path, 'onlydir')).deleteSync(); + await live.refreshPending(pair); + expect(live.onlyTarget[LiveSync.keyOf(pair)], 0, reason: '정말 같으면 0 - "같습니다"'); + }); + test('dispose 뒤에 맞추기가 끝나도 알리지 않는다 (앱을 끄는 중 - used after disposed 오류 없음)', () async { final tmp = Directory.systemTemp.createTempSync('jj_disp_'); addTearDown(() => tmp.deleteSync(recursive: true));