48 같은 이름 충돌: 덮어쓰기 · 건너뛰기 · 이름 바꾸기 고르기 · 154 · 155 복사 · 이동 대상 규칙과 확인 창의 항목 이름

- 48: 앱이 직접 복사 · 이동할 때 받는 폴더에 같은 이름이 있으면 확인 창에서 고른다 (기본 이름 바꾸기 = 원래 파일 그대로).
  덮어쓰기는 새 파일을 .jjpart 에 다 쓴 뒤에만 옛 파일을 지움, 폴더는 합침. 같은 폴더 · 종류가 다르면 새 이름.
  건너뛰기는 그대로 두고 끝난 알림에 알림. 확인 창은 바로 뜨고 같은 이름은 창 안에서 찾음 (다 찾기 전엔 이름 바꾸기)
- 154 · 155: 확인 창 · 담기 알림에 항목 이름 (앞 3개 + 외 n개), 고른 것이 없으면 "옮길 · 복사할 항목을 고르세요",
  길게 누르기의 다른 창으로 복사 · 이동도 표시한 것 모두
- 시험: explorer_conflict_test · file_ops_test, explorer_webdav_test 는 시험 폴더 드라이브로 고정

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
묶음3
RyuJongJin 2026-10-09 17:41:40 +09:00
parent f34cbe5a69
commit a878e3b1cc
10 changed files with 343 additions and 28 deletions

View File

@ -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}",

View File

@ -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}",

View File

@ -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}",

View File

@ -93,7 +93,7 @@ class CopyCenter extends ChangeNotifier {
/// 실행을 시작하고 그 작업을 바로 돌려준다 (rsync 가 없으면 null). 끝나면 결과를 기억한다 ([TransferJob.done]).
/// 실행 · 재개. 원본 · 대상이 없거나 폴더를 자기 안으로 넣는 것이면 시작하지 않고 실패로 남긴다 (null).
Future<TransferJob?> start(CopyTask t, {String? rsyncExe}) async {
Future<TransferJob?> 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);

View File

@ -35,6 +35,12 @@ class TransferJob extends ChangeNotifier {
/// 이동 뒤 원본 정리: '' 안 함 · 'keep' 빈 폴더 지움 (원본 폴더는 남김) · 'all' 원본 폴더까지
final String prune;
/// 48: 같은 이름이 있을 때 (앱이 직접 복사 · 이동할 때만 - robocopy · rsync 는 그 프로그램의 규칙)
final NameConflict conflict;
/// 같은 이름이라 건너뛴 항목 (원본 경로)
final skipped = <String>[];
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]; // 이름 바꾸기로 옮긴 경우
}

View File

@ -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 = <String>[];
FileOps({this.bandwidthKBps = 0, this.onFileDone, this.onBytes, this.conflict = NameConflict.rename});
/// [conflict] 에 따라 [destDir] 안의 대상 경로 (null = 건너뜀).
/// 같은 자리 (같은 폴더로 복사) 이거나 파일 ↔ 폴더처럼 종류가 다르면 덮어쓰지 않고 새 이름으로.
Future<String?> _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<String> 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;

View File

@ -1009,9 +1009,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
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<ExplorerPage> {
List<String> _clip = [];
bool _clipMove = false;
/// 154: 고른 것이 없을 때 (복사 · 이동이 같은 말)
String _pickFirst(bool move) => move
? tr('옮길 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).')
: tr('복사할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).');
/// 154: 항목 이름 (앞의 3개 + "외 n개")
/// 48: 받는 폴더에 같은 이름이 있는 원본 (같은 폴더로 복사는 늘 새 이름이라 빼고)
static Future<List<String>> _findClashes(List<String> sources, String dest) async {
final out = <String>[];
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<String> 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<String> 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<void> _paste(_Pane pane, String dest) async {
@ -1386,7 +1409,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
/// [dest] 로 복사 · 이동 (확인 · 아래 진행 막대 · 취소). 끝까지 했으면 true.
Future<bool> _runTransfer(_Pane pane, List<String> 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<ExplorerPage> {
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 <String>[]);
var conflict = NameConflict.rename;
final go = await showDialog<bool>(
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<List<String>>(
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 <String>[];
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<NameConflict>(
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<NameConflict>(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<ExplorerPage> {
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<ExplorerPage> {
? 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<void>.delayed(const Duration(milliseconds: 1200));
if (mounted && _jobs.contains(job)) setState(() => _jobs = [for (final j in _jobs) if (j != job) j]);

View File

@ -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<void> settle(WidgetTester tester, bool Function() done, {int rounds = 100}) async {
for (var i = 0; i < rounds && !done(); i++) {
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 30)));
await tester.pump();
}
await tester.pump(const Duration(milliseconds: 300));
}
/// 왼쪽 doc.txt 를 고르고 [복사] → 확인 창 (같은 이름 알림이 뜰 때까지)
Future<void> 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<void> 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);
});
}

View File

@ -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);

View File

@ -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');
});
});
}