From a878e3b1cc346b9ec87b04281b4ceffd3df3ebe3 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:41:40 +0900 Subject: [PATCH] =?UTF-8?q?48=20=EA=B0=99=EC=9D=80=20=EC=9D=B4=EB=A6=84=20?= =?UTF-8?q?=EC=B6=A9=EB=8F=8C:=20=EB=8D=AE=EC=96=B4=EC=93=B0=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EA=B1=B4=EB=84=88=EB=9B=B0=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EB=B0=94=EA=BE=B8=EA=B8=B0=20=EA=B3=A0?= =?UTF-8?q?=EB=A5=B4=EA=B8=B0=20=C2=B7=20154=20=C2=B7=20155=20=EB=B3=B5?= =?UTF-8?q?=EC=82=AC=20=C2=B7=20=EC=9D=B4=EB=8F=99=20=EB=8C=80=EC=83=81=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=EA=B3=BC=20=ED=99=95=EC=9D=B8=20=EC=B0=BD?= =?UTF-8?q?=EC=9D=98=20=ED=95=AD=EB=AA=A9=20=EC=9D=B4=EB=A6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 48: 앱이 직접 복사 · 이동할 때 받는 폴더에 같은 이름이 있으면 확인 창에서 고른다 (기본 이름 바꾸기 = 원래 파일 그대로). 덮어쓰기는 새 파일을 .jjpart 에 다 쓴 뒤에만 옛 파일을 지움, 폴더는 합침. 같은 폴더 · 종류가 다르면 새 이름. 건너뛰기는 그대로 두고 끝난 알림에 알림. 확인 창은 바로 뜨고 같은 이름은 창 안에서 찾음 (다 찾기 전엔 이름 바꾸기) - 154 · 155: 확인 창 · 담기 알림에 항목 이름 (앞 3개 + 외 n개), 고른 것이 없으면 "옮길 · 복사할 항목을 고르세요", 길게 누르기의 다른 창으로 복사 · 이동도 표시한 것 모두 - 시험: explorer_conflict_test · file_ops_test, explorer_webdav_test 는 시험 폴더 드라이브로 고정 Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 9 +++ assets/l10n/ja.json | 9 +++ assets/l10n/zh-Hans.json | 9 +++ lib/app/copy_center.dart | 3 +- lib/app/transfer_job.dart | 9 +++ lib/core/file_ops.dart | 64 ++++++++++++++++-- lib/ui/explorer_page.dart | 107 +++++++++++++++++++++++++------ test/explorer_conflict_test.dart | 95 +++++++++++++++++++++++++++ test/explorer_webdav_test.dart | 4 ++ test/file_ops_test.dart | 62 ++++++++++++++++++ 10 files changed, 343 insertions(+), 28 deletions(-) create mode 100644 test/explorer_conflict_test.dart diff --git a/assets/l10n/en.json b/assets/l10n/en.json index cd578bd..f64de1e 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -297,6 +297,7 @@ "{0} 에서 그리는 중 ({1}초)": "Drawing on {0} ({1} s)", "{0} 에서 받을 파일을 찾지 못했습니다": "Could not find a file to download from {0}", "{0} 외 {1}개": "{0} and {1} more", + "{0} 을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).": "Picked up {0}. Open the target folder and tap [Paste] ({1}).", "{0} 을(를) 설치했습니다.": "Installed {0}.", "{0} 을(를) 지울까요?": "Delete {0}?", "{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg does not have the {0} encoder.", @@ -377,6 +378,9 @@ "가져온 즐겨찾기 ({0})": "Imported bookmarks ({0})", "가져올 즐겨찾기가 없습니다.": "No bookmarks to import.", "같은 시리즈 (번호만 다른 파일)": "Same series (files that differ only by number)", + "같은 이름이 이미 있습니다: {0}": "Already exists with the same name: {0}", + "같은 이름이 있는지 확인하는 중 …": "Checking for items with the same name …", + "같은 이름이라 건너뜀: {0}": "Skipped (same name): {0}", "같은 조건으로 다시 (시드 그대로)": "Again with the same settings (same seed)", "같은 폴더의 모든 동영상": "All videos in the same folder", "같은 폴더의 자막은 자동으로 추가됩니다": "Subtitles in the same folder are added automatically", @@ -385,6 +389,7 @@ "같은 화질이면 H.264 먼저 받기": "Prefer H.264 at the same quality", "개수": "Count", "거꾸로": "Reverse", + "건너뛰기": "Skip", "건너뜀 (출력 폴더의 파일): {0}": "Skipped (file in the output folder): {0}", "검색": "Search", "검색 지우기": "Clear search", @@ -624,6 +629,7 @@ "더 보기": "More", "더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)": "When JJ_MKVMaker is chosen by double-click · default app (the right-click menu stays)", "더한 언어 (AI 자동 번역)": "Added language (AI auto-translated)", + "덮어쓰기 (원래 파일은 없어집니다)": "Overwrite (the existing file is replaced)", "덴마크어": "Danish", "독일어": "German", "돌아가기": "Go back", @@ -872,6 +878,7 @@ "복사(Ctrl+C)한 주소 자동 다운로드": "Auto-download copied (Ctrl+C) URLs", "복사하는 중": "Copying", "복사할": "copy", + "복사할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "Choose items to copy (tap, or mark with the circle on the right).", "복사할까요?": "copy?", "복사해 둔 AI 모델 ({0}) 을 되살렸습니다": "Restored the copied AI models ({0})", "복사해 둔 AI 모델 ({0}) 을 되살리는 중": "Restoring the copied AI models ({0})", @@ -1169,6 +1176,7 @@ "옮기기": "Move", "옮기는 중": "Moving", "옮길": "move", + "옮길 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "Choose items to move (tap, or mark with the circle on the right).", "옮길까요?": "move?", "옵션": "Options", "옵션을 저장했습니다": "Options saved", @@ -1327,6 +1335,7 @@ "이동할 폴더를 만들 수 없습니다: {0} ({1})": "Cannot create the move folder: {0} ({1})", "이름": "Name", "이름 (탭에 보임, 비우면 주소)": "Name (shown on the tab; address if empty)", + "이름 바꾸기 (예: 이름 (2))": "Keep both (e.g. name (2))", "이름 바꾸기 · 이동": "Rename · move", "이름 변경": "Rename", "이름을 바꿀 수 없습니다: {0}": "Cannot rename: {0}", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index c580730..a7adfc9 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -297,6 +297,7 @@ "{0} 에서 그리는 중 ({1}초)": "{0} で描画中 ({1} 秒)", "{0} 에서 받을 파일을 찾지 못했습니다": "{0} からダウンロードするファイルが見つかりませんでした", "{0} 외 {1}개": "{0} ほか {1} 件", + "{0} 을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).": "{0} を保持しました。入れるフォルダーで [貼り付け] を押してください ({1})。", "{0} 을(를) 설치했습니다.": "{0} をインストールしました。", "{0} 을(를) 지울까요?": "{0} を削除しますか?", "{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg に {0} エンコーダーがありません。", @@ -377,6 +378,9 @@ "가져온 즐겨찾기 ({0})": "取り込んだブックマーク({0})", "가져올 즐겨찾기가 없습니다.": "取り込むブックマークがありません。", "같은 시리즈 (번호만 다른 파일)": "同じシリーズ(番号だけ違うファイル)", + "같은 이름이 이미 있습니다: {0}": "同じ名前が既にあります: {0}", + "같은 이름이 있는지 확인하는 중 …": "同じ名前があるか確認中 …", + "같은 이름이라 건너뜀: {0}": "同じ名前のためスキップ: {0}", "같은 조건으로 다시 (시드 그대로)": "同じ条件でもう一度 (シードはそのまま)", "같은 폴더의 모든 동영상": "同じフォルダーのすべての動画", "같은 폴더의 자막은 자동으로 추가됩니다": "同じフォルダーの字幕は自動で追加されます", @@ -385,6 +389,7 @@ "같은 화질이면 H.264 먼저 받기": "同じ画質なら H.264 を優先", "개수": "枚数", "거꾸로": "逆順", + "건너뛰기": "スキップ", "건너뜀 (출력 폴더의 파일): {0}": "スキップ(出力フォルダーのファイル): {0}", "검색": "検索", "검색 지우기": "検索をクリア", @@ -624,6 +629,7 @@ "더 보기": "その他", "더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)": "ダブルクリック・既定のアプリで JJ_MKVMaker を選んだとき(右クリックメニューはそのまま)", "더한 언어 (AI 자동 번역)": "追加した言語(AI 自動翻訳)", + "덮어쓰기 (원래 파일은 없어집니다)": "上書き (元のファイルは消えます)", "덴마크어": "デンマーク語", "독일어": "ドイツ語", "돌아가기": "戻る", @@ -872,6 +878,7 @@ "복사(Ctrl+C)한 주소 자동 다운로드": "コピー(Ctrl+C)した URL を自動ダウンロード", "복사하는 중": "コピー中", "복사할": "コピーする", + "복사할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "コピーする項目を選んでください (タップするか右の丸で印を付ける)。", "복사할까요?": "コピーしますか?", "복사해 둔 AI 모델 ({0}) 을 되살렸습니다": "コピーした AI モデル ({0}) を復元しました", "복사해 둔 AI 모델 ({0}) 을 되살리는 중": "コピーした AI モデル ({0}) を復元中", @@ -1169,6 +1176,7 @@ "옮기기": "移動", "옮기는 중": "移動中", "옮길": "移す", + "옮길 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "移動する項目を選んでください (タップするか右の丸で印を付ける)。", "옮길까요?": "移動しますか?", "옵션": "オプション", "옵션을 저장했습니다": "オプションを保存しました", @@ -1327,6 +1335,7 @@ "이동할 폴더를 만들 수 없습니다: {0} ({1})": "移動フォルダーを作成できません: {0}({1})", "이름": "名前", "이름 (탭에 보임, 비우면 주소)": "名前 (タブに表示、空ならアドレス)", + "이름 바꾸기 (예: 이름 (2))": "名前を変える (例: 名前 (2))", "이름 바꾸기 · 이동": "名前の変更・移動", "이름 변경": "名前の変更", "이름을 바꿀 수 없습니다: {0}": "名前を変更できません: {0}", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 396ec6b..de04c82 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -297,6 +297,7 @@ "{0} 에서 그리는 중 ({1}초)": "正在 {0} 上绘制 ({1} 秒)", "{0} 에서 받을 파일을 찾지 못했습니다": "未在 {0} 找到要下载的文件", "{0} 외 {1}개": "{0} 等 {1} 个", + "{0} 을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).": "已暂存 {0}。请在目标文件夹中点按 [粘贴] ({1})。", "{0} 을(를) 설치했습니다.": "已安装 {0}。", "{0} 을(를) 지울까요?": "要删除 {0} 吗?", "{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg 中没有 {0} 编码器。", @@ -377,6 +378,9 @@ "가져온 즐겨찾기 ({0})": "导入的书签({0})", "가져올 즐겨찾기가 없습니다.": "没有可导入的书签。", "같은 시리즈 (번호만 다른 파일)": "同一系列(仅编号不同的文件)", + "같은 이름이 이미 있습니다: {0}": "已存在同名项目: {0}", + "같은 이름이 있는지 확인하는 중 …": "正在检查是否有同名项目 …", + "같은 이름이라 건너뜀: {0}": "因同名已跳过: {0}", "같은 조건으로 다시 (시드 그대로)": "以相同条件再来一次 (种子不变)", "같은 폴더의 모든 동영상": "同一文件夹中的所有视频", "같은 폴더의 자막은 자동으로 추가됩니다": "同一文件夹中的字幕会自动添加", @@ -385,6 +389,7 @@ "같은 화질이면 H.264 먼저 받기": "相同画质时优先 H.264", "개수": "数量", "거꾸로": "倒序", + "건너뛰기": "跳过", "건너뜀 (출력 폴더의 파일): {0}": "已跳过(输出文件夹中的文件): {0}", "검색": "搜索", "검색 지우기": "清除搜索", @@ -624,6 +629,7 @@ "더 보기": "更多", "더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)": "双击 · 默认打开方式选择 JJ_MKVMaker 时(右键菜单不变)", "더한 언어 (AI 자동 번역)": "已添加的语言(AI 自动翻译)", + "덮어쓰기 (원래 파일은 없어집니다)": "覆盖 (原文件将被替换)", "덴마크어": "丹麦语", "독일어": "德语", "돌아가기": "返回", @@ -872,6 +878,7 @@ "복사(Ctrl+C)한 주소 자동 다운로드": "自动下载复制(Ctrl+C)的地址", "복사하는 중": "正在复制", "복사할": "复制", + "복사할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "请选择要复制的项目 (点按或用右侧圆圈标记)。", "복사할까요?": "复制吗?", "복사해 둔 AI 모델 ({0}) 을 되살렸습니다": "已恢复复制的 AI 模型 ({0})", "복사해 둔 AI 모델 ({0}) 을 되살리는 중": "正在恢复复制的 AI 模型 ({0})", @@ -1169,6 +1176,7 @@ "옮기기": "移动", "옮기는 중": "正在移动", "옮길": "移动", + "옮길 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "请选择要移动的项目 (点按或用右侧圆圈标记)。", "옮길까요?": "移动吗?", "옵션": "选项", "옵션을 저장했습니다": "已保存选项", @@ -1327,6 +1335,7 @@ "이동할 폴더를 만들 수 없습니다: {0} ({1})": "无法创建移动目标文件夹: {0}({1})", "이름": "名称", "이름 (탭에 보임, 비우면 주소)": "名称 (显示在标签上,留空则显示地址)", + "이름 바꾸기 (예: 이름 (2))": "重命名 (例: 名称 (2))", "이름 바꾸기 · 이동": "重命名 · 移动", "이름 변경": "重命名", "이름을 바꿀 수 없습니다: {0}": "无法重命名: {0}", diff --git a/lib/app/copy_center.dart b/lib/app/copy_center.dart index a872ae2..4934a43 100644 --- a/lib/app/copy_center.dart +++ b/lib/app/copy_center.dart @@ -93,7 +93,7 @@ class CopyCenter extends ChangeNotifier { /// 실행을 시작하고 그 작업을 바로 돌려준다 (rsync 가 없으면 null). 끝나면 결과를 기억한다 ([TransferJob.done]). /// 실행 · 재개. 원본 · 대상이 없거나 폴더를 자기 안으로 넣는 것이면 시작하지 않고 실패로 남긴다 (null). - Future start(CopyTask t, {String? rsyncExe}) async { + Future start(CopyTask t, {String? rsyncExe, NameConflict conflict = NameConflict.rename}) async { if (isRunning(t.id)) return jobs[t.id]; final problem = transferProblem(t.sources, t.dest, move: t.move); if (problem != null) { @@ -118,6 +118,7 @@ class CopyCenter extends ChangeNotifier { rsyncExe: exe, contents: t.contents, prune: t.move ? t.prune : '', + conflict: conflict, ); jobs[t.id] = job; job.addListener(notifyListeners); diff --git a/lib/app/transfer_job.dart b/lib/app/transfer_job.dart index 0e9f2f5..b83b5d8 100644 --- a/lib/app/transfer_job.dart +++ b/lib/app/transfer_job.dart @@ -35,6 +35,12 @@ class TransferJob extends ChangeNotifier { /// 이동 뒤 원본 정리: '' 안 함 · 'keep' 빈 폴더 지움 (원본 폴더는 남김) · 'all' 원본 폴더까지 final String prune; + /// 48: 같은 이름이 있을 때 (앱이 직접 복사 · 이동할 때만 - robocopy · rsync 는 그 프로그램의 규칙) + final NameConflict conflict; + + /// 같은 이름이라 건너뛴 항목 (원본 경로) + final skipped = []; + TransferJob({ required this.sources, required this.dest, @@ -46,6 +52,7 @@ class TransferJob extends ChangeNotifier { this.rsyncExe, this.contents = false, this.prune = '', + this.conflict = NameConflict.rename, }); /// 지금 복사하는 파일 (원본 기준 상대 경로, rsync 는 이름을 먼저 알리고 그 파일의 진행률을 알린다) @@ -205,6 +212,7 @@ class TransferJob extends ChangeNotifier { }, // 보낸 양으로 전송 속도 (WebDAV 포함) onBytes: _addBytes, + conflict: conflict, ); _tick(); if (contents && (isDav(sources[i]) || FileSystemEntity.isDirectorySync(sources[i]))) { @@ -215,6 +223,7 @@ class TransferJob extends ChangeNotifier { } else { final r = move ? await _ops!.move([sources[i]], dest) : await _ops!.copy([sources[i]], dest); made.addAll(r); + skipped.addAll(_ops!.skipped); } filesDone[i] = filesTotal[i]; // 이름 바꾸기로 옮긴 경우 } diff --git a/lib/core/file_ops.dart b/lib/core/file_ops.dart index cd78c0b..7da674a 100644 --- a/lib/core/file_ops.dart +++ b/lib/core/file_ops.dart @@ -73,6 +73,18 @@ List<(String path, String label)> windowsDrives() => [ if (FileSystemEntity.isDirectorySync('$c:\\')) ('$c:\\', '$c:'), ]; +/// 48: 받는 폴더에 같은 이름이 있을 때 +enum NameConflict { + /// "이름 (2).확장자" 로 (예전 동작) + rename, + + /// 덮어쓰기 (폴더는 합치고 안의 같은 이름 파일을 덮어씀) + overwrite, + + /// 건너뛰기 (그 항목은 복사 · 이동하지 않음) + skip, +} + /// 같은 이름이 있으면 "이름 (2).확장자" 로 String uniqueTarget(String dir, String name) { var target = p.join(dir, name); @@ -265,7 +277,28 @@ class FileOps { /// 조각을 보낼 때마다 (바이트) - 전송 속도 계산용 final void Function(int bytes)? onBytes; - FileOps({this.bandwidthKBps = 0, this.onFileDone, this.onBytes}); + /// 48: 같은 이름이 있을 때 (맨 위 항목 기준 - 덮어쓰기면 폴더 안의 같은 이름 파일도 덮어씀) + final NameConflict conflict; + + /// 건너뛴 항목 (원본 경로) + final skipped = []; + + FileOps({this.bandwidthKBps = 0, this.onFileDone, this.onBytes, this.conflict = NameConflict.rename}); + + /// [conflict] 에 따라 [destDir] 안의 대상 경로 (null = 건너뜀). + /// 같은 자리 (같은 폴더로 복사) 이거나 파일 ↔ 폴더처럼 종류가 다르면 덮어쓰지 않고 새 이름으로. + Future _targetFor(String src, String destDir) async { + final name = vBasename(src); + final plain = vJoin(destDir, name); + if (conflict == NameConflict.rename || !await vExists(plain)) return vUniqueTarget(destDir, name); + if (samePath(src, plain)) return vUniqueTarget(destDir, name); + if (conflict == NameConflict.skip) { + skipped.add(src); + return null; + } + if (await vIsDir(src) != await vIsDir(plain)) return vUniqueTarget(destDir, name); + return plain; + } /// 한쪽이라도 WebDAV 면 아래의 "V" (어느 저장소든) 판으로 static bool _anyDav(Iterable paths) => paths.any(isDav); @@ -340,6 +373,8 @@ class FileOps { } catch (_) {} rethrow; } + // 덮어쓰기: 새 파일을 다 쓴 뒤에야 옛 파일을 지운다 (도중에 실패하면 옛 파일이 남게) + if (File(finalTarget).existsSync()) await File(finalTarget).delete(); await File(target).rename(finalTarget); try { await File(finalTarget).setLastModified(await f.lastModified()); @@ -362,7 +397,8 @@ class FileOps { if (FileSystemEntity.isDirectorySync(s) && isSameOrInside(destDir, s)) { throw FileSystemException('폴더를 자기 안으로 복사할 수 없습니다', s); } - final target = uniqueTarget(destDir, p.basename(s)); + final target = await _targetFor(s, destDir); + if (target == null) continue; await copyAny(s, target); made.add(target); } @@ -380,9 +416,19 @@ class FileOps { made.add(s); // 이미 그 폴더에 있음 continue; } - final target = uniqueTarget(destDir, p.basename(s)); + final target = await _targetFor(s, destDir); + if (target == null) continue; + final exists = FileSystemEntity.typeSync(target) != FileSystemEntityType.notFound; + if (exists && FileSystemEntity.isDirectorySync(s)) { + // 덮어쓰기로 폴더를 합친다: 복사 (안의 같은 이름은 덮어씀) 뒤 원본 삭제 + made.addAll(await copy([s], destDir, onProgress: onProgress)); + _check(); + await delete([s]); + continue; + } try { final t = FileSystemEntity.typeSync(s); + // 덮어쓰기 (파일): 이름 바꾸기가 옛 파일을 바로 바꿔 놓는다 (다른 드라이브면 아래 복사가 덮어씀) if (t == FileSystemEntityType.directory) { await Directory(s).rename(target); } else { @@ -611,7 +657,8 @@ class FileOps { for (final s in sources) { if (isSameOrInside(destDir, s) && await vIsDir(s)) throw FileSystemException('폴더를 자기 안으로 복사할 수 없습니다', s); - final target = await vUniqueTarget(destDir, vBasename(s)); + final target = await _targetFor(s, destDir); + if (target == null) continue; await copyAny(s, target); made.add(target); } @@ -627,9 +674,12 @@ class FileOps { made.add(s); continue; } - final target = await vUniqueTarget(destDir, vBasename(s)); - // 같은 WebDAV 서버 안이면 서버가 옮긴다 (빠름) - if (isDav(s) && isDav(destDir) && DavPath.parse(s).server == DavPath.parse(destDir).server) { + final target = await _targetFor(s, destDir); + if (target == null) continue; + final exists = await vExists(target); + // 같은 WebDAV 서버 안이면 서버가 옮긴다 (빠름) - 덮어쓸 폴더는 합쳐야 하니 복사로 + if (isDav(s) && isDav(destDir) && DavPath.parse(s).server == DavPath.parse(destDir).server && !(exists && await vIsDir(s))) { + if (exists) await vDelete(target); await vRename(s, target); made.add(target); continue; diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index dfda755..cd34449 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -1009,9 +1009,9 @@ class _ExplorerPageState extends State { case 'rename': await _rename(pane, e.path); case 'copy': - await _transfer(pane, [e.path], move: false); + await _transfer(pane, _isMarked(pane, e.path) ? pane.marked.toList() : [e.path], move: false); case 'move': - await _transfer(pane, [e.path], move: true); + await _transfer(pane, _isMarked(pane, e.path) ? pane.marked.toList() : [e.path], move: true); case 'clipCopy': _toClipboard(_isMarked(pane, e.path) ? pane.marked.toList() : [e.path], move: false); case 'clipMove': @@ -1339,17 +1339,40 @@ class _ExplorerPageState extends State { List _clip = []; bool _clipMove = false; + /// 154: 고른 것이 없을 때 (복사 · 이동이 같은 말) + String _pickFirst(bool move) => move + ? tr('옮길 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).') + : tr('복사할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).'); + + /// 154: 항목 이름 (앞의 3개 + "외 n개") + /// 48: 받는 폴더에 같은 이름이 있는 원본 (같은 폴더로 복사는 늘 새 이름이라 빼고) + static Future> _findClashes(List sources, String dest) async { + final out = []; + for (final s in sources) { + if (samePath(vDirname(s), dest)) continue; + try { + if (await vExists(vJoin(dest, vBasename(s)))) out.add(s); + } catch (_) {} + } + return out; + } + + static String _namesOf(List paths) { + final names = [for (final x in paths.take(3)) vBasename(x)].join(', '); + return paths.length > 3 ? trf('{0} 외 {1}개', [names, paths.length - 3]) : names; + } + void _toClipboard(List sources, {required bool move}) { if (sources.isEmpty) { - _snack(tr('복사 · 이동할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).')); + _snack(_pickFirst(move)); return; } setState(() { _clip = [...sources]; _clipMove = move; }); - _snack(trf('{0}개 항목을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).', - [sources.length, move ? tr('이동') : tr('복사')])); + _snack(trf('{0} 을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).', + [_namesOf(sources), move ? tr('이동') : tr('복사')])); } Future _paste(_Pane pane, String dest) async { @@ -1386,7 +1409,7 @@ class _ExplorerPageState extends State { /// [dest] 로 복사 · 이동 (확인 · 아래 진행 막대 · 취소). 끝까지 했으면 true. Future _runTransfer(_Pane pane, List sources, String dest, {required bool move}) async { if (sources.isEmpty) { - _snack(tr('복사 · 이동할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).')); + _snack(_pickFirst(move)); return false; } // 23: 앞의 복사 · 이동이 끝나지 않아도 함께 시작한다 (아래 진행 막대에 하나씩). 같은 것을 같은 곳으로만 막는다 @@ -1409,20 +1432,61 @@ class _ExplorerPageState extends State { final center = CopyCenter.of(c); final task = center.fresh(sources, dest, move: move); final method = CopyMethod.of(task.method); - if (!mounted) return false; + // 48: 받는 폴더에 같은 이름이 있으면 덮어쓰기 · 건너뛰기 · 이름 바꾸기를 고른다 + // (앱이 직접 할 때만 - robocopy · rsync 는 그 프로그램의 규칙. 같은 폴더로 복사는 늘 새 이름) + final builtin = method == CopyMethod.builtin || _viaDav([...sources, dest]); + // 확인 창은 바로 띄우고, 같은 이름은 창 안에서 뒤에서 찾는다 (느린 네트워크 · WebDAV 폴더라도 창이 늦게 뜨지 않게). + // 다 찾기 전에 누르면 이름 바꾸기 (원래 파일을 건드리지 않는 쪽) + final clashesFuture = builtin ? _findClashes(sources, dest) : Future.value(const []); + var conflict = NameConflict.rename; final go = await showDialog( context: context, - builder: (ctx) => AlertDialog( - scrollable: true, - title: Text(move ? tr('이동') : tr('복사')), - content: Text('${trf('{0}개 항목을 다음 폴더로 {1}\n{2}', [sources.length, move ? tr('옮길까요?') : tr('복사할까요?'), vDisplay(dest)])}' - '\n\n${trf('방법: {0}', [tr(method.label)])}' - '${method == CopyMethod.builtin ? '' : ' · ${task.options}'}' - '${task.bandwidthKBps > 0 ? ' · ${trf('속도 제한 {0} KB/s', [task.bandwidthKBps])}' : ''}'), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), - FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(move ? tr('이동') : tr('복사'))), - ], + builder: (ctx) => StatefulBuilder( + builder: (ctx, setInner) => AlertDialog( + scrollable: true, + title: Text(move ? tr('이동') : tr('복사')), + content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('${trf('{0}개 항목을 다음 폴더로 {1}\n{2}', [sources.length, move ? tr('옮길까요?') : tr('복사할까요?'), vDisplay(dest)])}' + '\n${_namesOf(sources)}' + '\n\n${trf('방법: {0}', [tr(method.label)])}' + '${method == CopyMethod.builtin ? '' : ' · ${task.options}'}' + '${task.bandwidthKBps > 0 ? ' · ${trf('속도 제한 {0} KB/s', [task.bandwidthKBps])}' : ''}'), + FutureBuilder>( + future: clashesFuture, + builder: (ctx, snap) { + if (snap.connectionState != ConnectionState.done) { + return Padding( + padding: const EdgeInsets.only(top: 12), + child: Text(tr('같은 이름이 있는지 확인하는 중 …'), style: const TextStyle(fontSize: 12, color: JjColors.textDim)), + ); + } + final clashes = snap.data ?? const []; + if (clashes.isEmpty) return const SizedBox.shrink(); + return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + const SizedBox(height: 12), + Text(trf('같은 이름이 이미 있습니다: {0}', [_namesOf(clashes)]), + style: const TextStyle(color: Colors.orangeAccent)), + RadioGroup( + groupValue: conflict, + onChanged: (x) => setInner(() => conflict = x ?? conflict), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + for (final (v, label) in [ + (NameConflict.rename, tr('이름 바꾸기 (예: 이름 (2))')), + (NameConflict.overwrite, tr('덮어쓰기 (원래 파일은 없어집니다)')), + (NameConflict.skip, tr('건너뛰기')), + ]) + RadioListTile(dense: true, contentPadding: EdgeInsets.zero, value: v, title: Text(label)), + ]), + ), + ]); + }, + ), + ]), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(move ? tr('이동') : tr('복사'))), + ], + ), ), ); if (go != true || !mounted) return false; @@ -1433,7 +1497,7 @@ class _ExplorerPageState extends State { rsync = await _ensureRsync(); if (rsync == null || !mounted) return false; } - final job = await center.start(task, rsyncExe: rsync); + final job = await center.start(task, rsyncExe: rsync, conflict: conflict); if (job == null || !mounted) return false; setState(() => _jobs = [..._jobs.where((j) => !j.finished), job]); await job.done; @@ -1454,7 +1518,10 @@ class _ExplorerPageState extends State { ? tr('취소했습니다.') : error != null ? trf('끝나지 못했습니다: {0}', [error]) - : trf('{0}개 항목을 {1}', [job.made.length, move ? tr('옮겼습니다.') : tr('복사했습니다.')])); + : [ + trf('{0}개 항목을 {1}', [job.made.length, move ? tr('옮겼습니다.') : tr('복사했습니다.')]), + if (job.skipped.isNotEmpty) trf('같은 이름이라 건너뜀: {0}', [_namesOf(job.skipped)]), + ].join(' · ')); // 다 되면 진행 막대가 잠깐 100% 를 보인 뒤 내려간다 await Future.delayed(const Duration(milliseconds: 1200)); if (mounted && _jobs.contains(job)) setState(() => _jobs = [for (final j in _jobs) if (j != job) j]); diff --git a/test/explorer_conflict_test.dart b/test/explorer_conflict_test.dart new file mode 100644 index 0000000..10178d8 --- /dev/null +++ b/test/explorer_conflict_test.dart @@ -0,0 +1,95 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/explorer_page.dart'; +import 'package:path/path.dart' as p; + +/// 48 · 154 · 155: 같은 이름이 있으면 덮어쓰기 · 건너뛰기 · 이름 바꾸기를 고르고, 확인 창에 항목 이름이 보인다 +void main() { + late Directory tmp; + late String left, right; + late AppController c; + + setUp(() { + tmp = Directory.systemTemp.createTempSync('jj_conflict_'); + left = p.join(tmp.path, 'left'); + right = p.join(tmp.path, 'right'); + File(p.join(left, 'doc.txt')) + ..createSync(recursive: true) + ..writeAsStringSync('new'); + File(p.join(right, 'doc.txt')) + ..createSync(recursive: true) + ..writeAsStringSync('old'); + c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + c.settings.explorerPaths = [left, right]; + final root = p.rootPrefix(tmp.path); + ExplorerPage.debugVolumes = () => [(root, root.replaceAll(RegExp(r'[\\/]+$'), ''))]; + }); + tearDown(() { + ExplorerPage.debugVolumes = null; + tmp.deleteSync(recursive: true); + }); + + Future settle(WidgetTester tester, bool Function() done, {int rounds = 100}) async { + for (var i = 0; i < rounds && !done(); i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 30))); + await tester.pump(); + } + await tester.pump(const Duration(milliseconds: 300)); + } + + /// 왼쪽 doc.txt 를 고르고 [복사] → 확인 창 (같은 이름 알림이 뜰 때까지) + Future startCopy(WidgetTester tester) async { + tester.view.physicalSize = const Size(1600, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c)))); + await settle(tester, () => find.text('doc.txt').evaluate().length >= 2); + await tester.runAsync(() => tester.tap(find.text('doc.txt').first)); + await settle(tester, () => false, rounds: 10); + await tester.runAsync(() => tester.tap(find.text('복사').first)); + await tester.pump(); + // 확인 창은 바로 뜨고, 같은 이름은 창 안에서 찾아 알린다 + expect(find.widgetWithText(FilledButton, '복사'), findsOneWidget); + await settle(tester, () => find.textContaining('같은 이름이 이미 있습니다').evaluate().isNotEmpty); + expect(find.textContaining('같은 이름이 이미 있습니다: doc.txt'), findsOneWidget); + // 154 · 155: 확인 창에 항목 이름 + expect(find.textContaining('\ndoc.txt'), findsOneWidget); + } + + Future confirm(WidgetTester tester) async { + await tester.runAsync(() => tester.tap(find.widgetWithText(FilledButton, '복사'))); + await settle(tester, () => find.byType(LinearProgressIndicator).evaluate().isEmpty && find.byType(SnackBar).evaluate().isNotEmpty); + } + + testWidgets('48: 기본은 이름 바꾸기 (원래 파일 그대로, 새 파일은 "doc (2).txt")', (tester) async { + await startCopy(tester); + await confirm(tester); + expect(File(p.join(right, 'doc.txt')).readAsStringSync(), 'old'); + expect(File(p.join(right, 'doc (2).txt')).readAsStringSync(), 'new'); + }); + + testWidgets('48: 덮어쓰기를 고르면 원래 파일을 새것으로', (tester) async { + await startCopy(tester); + await tester.tap(find.text('덮어쓰기 (원래 파일은 없어집니다)')); + await tester.pump(); + await confirm(tester); + expect(File(p.join(right, 'doc.txt')).readAsStringSync(), 'new'); + expect(File(p.join(right, 'doc (2).txt')).existsSync(), isFalse); + }); + + testWidgets('48: 건너뛰기를 고르면 그대로 두고 건너뛴 것을 알린다', (tester) async { + await startCopy(tester); + await tester.tap(find.text('건너뛰기')); + await tester.pump(); + await confirm(tester); + expect(File(p.join(right, 'doc.txt')).readAsStringSync(), 'old'); + expect(File(p.join(right, 'doc (2).txt')).existsSync(), isFalse); + expect(find.textContaining('같은 이름이라 건너뜀: doc.txt'), findsOneWidget); + }); +} diff --git a/test/explorer_webdav_test.dart b/test/explorer_webdav_test.dart index df256ca..186d468 100644 --- a/test/explorer_webdav_test.dart +++ b/test/explorer_webdav_test.dart @@ -46,10 +46,14 @@ void main() { server = TestDavServer(remote); await server.start(); shell = _Shell(); + // 이 PC 의 드라이브 (네트워크 드라이브 등) 가 많으면 탭이 밀려 "WebDAV" 탭이 화면 밖으로 간다 - 시험 폴더의 드라이브만 + final root = p.rootPrefix(tmp.path); + ExplorerPage.debugVolumes = () => [(root, root.replaceAll(RegExp(r'[\\/]+$'), ''))]; c = AppController(PlatformServices( mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), shell: shell)); }); tearDown(() async { + ExplorerPage.debugVolumes = null; DavRegistry.configure([]); await server.stop(); tmp.deleteSync(recursive: true); diff --git a/test/file_ops_test.dart b/test/file_ops_test.dart index f6ce0e1..ff90fe0 100644 --- a/test/file_ops_test.dart +++ b/test/file_ops_test.dart @@ -138,4 +138,66 @@ void main() { expect(formatSize(5 * 1024 * 1024), '5.0MB'); expect(formatSize(3 * 1024 * 1024 * 1024), '3.0GB'); }); + + group('48 같은 이름', () { + late Directory a, b; + setUp(() { + a = Directory(p.join(tmp.path, 'a'))..createSync(); + b = Directory(p.join(tmp.path, 'b'))..createSync(); + File(p.join(a.path, 'same.txt')).writeAsStringSync('new'); + File(p.join(b.path, 'same.txt')).writeAsStringSync('old'); + }); + + test('이름 바꾸기 (기본): same (2).txt', () async { + final made = await FileOps().copy([p.join(a.path, 'same.txt')], b.path); + expect(p.basename(made.single), 'same (2).txt'); + expect(File(p.join(b.path, 'same.txt')).readAsStringSync(), 'old'); + }); + + test('덮어쓰기: 복사 · 이동', () async { + await FileOps(conflict: NameConflict.overwrite).copy([p.join(a.path, 'same.txt')], b.path); + expect(File(p.join(b.path, 'same.txt')).readAsStringSync(), 'new'); + expect(Directory(b.path).listSync().length, 1); + File(p.join(a.path, 'same.txt')).writeAsStringSync('newer'); + await FileOps(conflict: NameConflict.overwrite).move([p.join(a.path, 'same.txt')], b.path); + expect(File(p.join(b.path, 'same.txt')).readAsStringSync(), 'newer'); + expect(File(p.join(a.path, 'same.txt')).existsSync(), false); + }); + + test('건너뛰기: 그대로 두고 건너뛴 것을 알림', () async { + final ops = FileOps(conflict: NameConflict.skip); + File(p.join(a.path, 'other.txt')).writeAsStringSync('x'); + final made = await ops.move([p.join(a.path, 'same.txt'), p.join(a.path, 'other.txt')], b.path); + expect(made.map(p.basename), ['other.txt']); + expect(ops.skipped.map(p.basename), ['same.txt']); + expect(File(p.join(b.path, 'same.txt')).readAsStringSync(), 'old'); + expect(File(p.join(a.path, 'same.txt')).existsSync(), true); + }); + + test('덮어쓰기: 폴더는 합친다 (이동)', () async { + Directory(p.join(a.path, 'd')).createSync(); + Directory(p.join(b.path, 'd')).createSync(); + File(p.join(a.path, 'd', 'x.txt')).writeAsStringSync('new'); + File(p.join(b.path, 'd', 'x.txt')).writeAsStringSync('old'); + File(p.join(b.path, 'd', 'keep.txt')).writeAsStringSync('k'); + await FileOps(conflict: NameConflict.overwrite).move([p.join(a.path, 'd')], b.path); + expect(File(p.join(b.path, 'd', 'x.txt')).readAsStringSync(), 'new'); + expect(File(p.join(b.path, 'd', 'keep.txt')).existsSync(), true); + expect(Directory(p.join(a.path, 'd')).existsSync(), false); + }); + + test('같은 폴더로 복사는 덮어쓰기여도 새 이름', () async { + final made = await FileOps(conflict: NameConflict.overwrite).copy([p.join(b.path, 'same.txt')], b.path); + expect(p.basename(made.single), 'same (2).txt'); + expect(File(p.join(b.path, 'same.txt')).readAsStringSync(), 'old'); + }); + + test('파일 ↔ 폴더처럼 종류가 다르면 덮어쓰지 않고 새 이름', () async { + Directory(p.join(a.path, 'k')).createSync(); + File(p.join(b.path, 'k')).writeAsStringSync('file'); + final made = await FileOps(conflict: NameConflict.overwrite).copy([p.join(a.path, 'k')], b.path); + expect(p.basename(made.single), 'k (2)'); + expect(File(p.join(b.path, 'k')).readAsStringSync(), 'file'); + }); + }); }