135 · 146 · 37-2 같은 폴더로 이동 · 복사는 창으로 알림 · 경로 입력은 칸 아래 오류 · 지워진 폴더는 상위로

- 135: [이동] 을 모두 이미 그 폴더에 있는 것으로 하면 "이미 이 폴더에 있습니다" 창 (짧은 알림은 놓치기 쉬움).
  섞어 고르면 이미 있는 것만 건너뛰고 나머지를 옮김 (확인 창 · 끝난 알림에 "이미 이 폴더에 있어 건너뜀: …").
  [복사] 를 모두 같은 폴더로 하면 "같은 폴더에 사본을 만들까요? (이름 (2))" [취소] / [사본 만들기] (확인 창은 한 번만)
- 146: 경로 입력 창은 없는 경로면 닫지 않고 칸 아래 "폴더가 없거나 열 수 없습니다", 고치면 오류를 지움
- 37-2: 보던 폴더가 밖에서 지워지면 새로 읽을 때 남아 있는 가장 가까운 상위 폴더로, "보던 폴더가 없어져 상위 폴더로 옮겼습니다"
- 시험: explorer_conflict_test (135 · 135 보충 · 146 · 37-2), explorer_parallel_test 의 경로 입력 시험은 창이 열린 채로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
묶음3
RyuJongJin 2026-10-09 21:19:09 +09:00
parent 6a90ae5706
commit 6a5aea5ff2
6 changed files with 252 additions and 17 deletions

View File

@ -299,6 +299,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} 은(는) 이미 이 폴더에 있어 옮길 것이 없습니다.\n{1}": "{0} is already in this folder, so there is nothing to move.\n{1}",
"{0} 을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).": "Picked up {0}. Open the target folder and tap [Paste] ({1}).",
"{0} 을(를) 설치했습니다.": "Installed {0}.",
"{0} 을(를) 지울까요?": "Delete {0}?",
@ -388,9 +389,11 @@
"같은 이름이 있을 때": "When a file with the same name exists",
"같은 이름이라 건너뜀: {0}": "Skipped (same name): {0}",
"같은 조건으로 다시 (시드 그대로)": "Again with the same settings (same seed)",
"같은 폴더에 사본을 만들까요? (이름 (2))\n{0}": "Make a copy in the same folder? (name (2))\n{0}",
"같은 폴더의 모든 동영상": "All videos in the same folder",
"같은 폴더의 자막은 자동으로 추가됩니다": "Subtitles in the same folder are added automatically",
"같은 폴더이거나 한쪽이 다른 쪽 안에 있습니다. 다른 폴더를 고르세요.": "Same folder, or one is inside the other. Choose different folders.",
"같은 폴더입니다": "Same folder",
"같은 항목을 같은 곳으로 복사 · 이동하는 중입니다.": "The same items are already being copied/moved to the same place.",
"같은 화질이면 H.264 먼저 받기": "Prefer H.264 at the same quality",
"개수": "Count",
@ -877,6 +880,7 @@
"보기": "View",
"보기 {0}초 · Ctrl+휠 확대/축소": "View {0} s · Ctrl+wheel to zoom",
"보기 화면의 [계속 보기] 가 다음 장으로 넘기는 간격": "How often [Slideshow] in the viewer turns to the next page",
"보던 폴더가 없어져 상위 폴더로 옮겼습니다": "The folder you were viewing is gone, so moved to its parent folder",
"보안": "Security",
"보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "Choose the buttons to show and drag to reorder.",
"보정은 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "Adjustments require re-encoding, so the codec was changed from \"Keep original\" to H.264.",
@ -936,6 +940,7 @@
"빠른 디코더 (TAESD)": "Fast decoder (TAESD)",
"빼고 싶은 것 (선택)": "What to avoid (optional)",
"사라지는 것": "What is lost",
"사본 만들기": "Make a copy",
"사용 안 함": "Off",
"사용 안 함. \"Sign in to confirm you're not a bot\" 오류가 나오면 설정하세요": "Off. Set this if the \"Sign in to confirm you're not a bot\" error appears",
"사용: {0}": "Using: {0}",
@ -1373,7 +1378,9 @@
"이미 동영상 목록에 있습니다 ({0}개).": "Already in the video list ({0}).",
"이미 목록에 있는 동영상입니다 ({0}개).": "These videos are already in the list ({0}).",
"이미 받는 중이거나 받을 수 없는 주소입니다.": "Already downloading or a URL that cannot be downloaded.",
"이미 이 폴더에 있습니다": "Already in this folder",
"이미 이 폴더에 있습니다: {0}": "Already in this folder: {0}",
"이미 이 폴더에 있어 건너뜀: {0}": "Skipped, already in this folder: {0}",
"이미 있는 폴더입니다: {0}": "The folder already exists: {0}",
"이미 작업 중입니다": "Already working",
"이미지 · PDF · ZIP 보기": "Image · PDF · ZIP viewer",
@ -1785,6 +1792,7 @@
"폴더: {0}": "Folder: {0}",
"폴더가 들어 있을 때": "When folders are included",
"폴더가 아닙니다: {0}": "Not a folder: {0}",
"폴더가 없거나 열 수 없습니다": "The folder does not exist or cannot be opened",
"폴더가 없거나 열 수 없습니다: {0}": "The folder doesn't exist or can't be opened: {0}",
"폴더가 없습니다": "The folder does not exist",
"폴더는 보여도 그 안의 동영상 · 파일은 이 권한이 있어야 보입니다. 허용 화면에서 JJ_MKVMaker 를 켜고 돌아오세요.": "Folders are shown, but the videos and files inside need this permission. Turn on JJ_MKVMaker on the permission screen and come back.",

View File

@ -299,6 +299,7 @@
"{0} 에서 그리는 중 ({1}초)": "{0} で描画中 ({1} 秒)",
"{0} 에서 받을 파일을 찾지 못했습니다": "{0} からダウンロードするファイルが見つかりませんでした",
"{0} 외 {1}개": "{0} ほか {1} 件",
"{0} 은(는) 이미 이 폴더에 있어 옮길 것이 없습니다.\n{1}": "{0} は既にこのフォルダーにあるため移動するものがありません。\n{1}",
"{0} 을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).": "{0} を保持しました。入れるフォルダーで [貼り付け] を押してください ({1})。",
"{0} 을(를) 설치했습니다.": "{0} をインストールしました。",
"{0} 을(를) 지울까요?": "{0} を削除しますか?",
@ -388,9 +389,11 @@
"같은 이름이 있을 때": "同じ名前があるとき",
"같은 이름이라 건너뜀: {0}": "同じ名前のためスキップ: {0}",
"같은 조건으로 다시 (시드 그대로)": "同じ条件でもう一度 (シードはそのまま)",
"같은 폴더에 사본을 만들까요? (이름 (2))\n{0}": "同じフォルダーにコピーを作りますか? (名前 (2))\n{0}",
"같은 폴더의 모든 동영상": "同じフォルダーのすべての動画",
"같은 폴더의 자막은 자동으로 추가됩니다": "同じフォルダーの字幕は自動で追加されます",
"같은 폴더이거나 한쪽이 다른 쪽 안에 있습니다. 다른 폴더를 고르세요.": "同じフォルダーか、一方がもう一方の中にあります。別のフォルダーを選んでください。",
"같은 폴더입니다": "同じフォルダーです",
"같은 항목을 같은 곳으로 복사 · 이동하는 중입니다.": "同じ項目を同じ場所へコピー・移動中です。",
"같은 화질이면 H.264 먼저 받기": "同じ画質なら H.264 を優先",
"개수": "枚数",
@ -877,6 +880,7 @@
"보기": "表示",
"보기 {0}초 · Ctrl+휠 확대/축소": "表示 {0}秒・Ctrl+ホイールで拡大/縮小",
"보기 화면의 [계속 보기] 가 다음 장으로 넘기는 간격": "表示画面の [連続表示] が次のページへ進む間隔",
"보던 폴더가 없어져 상위 폴더로 옮겼습니다": "見ていたフォルダーがなくなったため上のフォルダーに移動しました",
"보안": "セキュリティ",
"보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "表示するボタンを選び、ドラッグで順序を変えます。",
"보정은 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "補正には再エンコードが必要なため、コーデックを「元のまま」から H.264 に変更しました。",
@ -936,6 +940,7 @@
"빠른 디코더 (TAESD)": "高速デコーダー (TAESD)",
"빼고 싶은 것 (선택)": "避けたいもの (任意)",
"사라지는 것": "消えるもの",
"사본 만들기": "コピーを作る",
"사용 안 함": "使用しない",
"사용 안 함. \"Sign in to confirm you're not a bot\" 오류가 나오면 설정하세요": "使用しない。「Sign in to confirm you're not a bot」エラーが出たら設定してください",
"사용: {0}": "使用: {0}",
@ -1373,7 +1378,9 @@
"이미 동영상 목록에 있습니다 ({0}개).": "既に動画リストにあります({0}個)。",
"이미 목록에 있는 동영상입니다 ({0}개).": "既にリストにある動画です({0}個)。",
"이미 받는 중이거나 받을 수 없는 주소입니다.": "既にダウンロード中か、ダウンロードできない URL です。",
"이미 이 폴더에 있습니다": "既にこのフォルダーにあります",
"이미 이 폴더에 있습니다: {0}": "すでにこのフォルダーにあります: {0}",
"이미 이 폴더에 있어 건너뜀: {0}": "既にこのフォルダーにあるためスキップ: {0}",
"이미 있는 폴더입니다: {0}": "既にあるフォルダーです: {0}",
"이미 작업 중입니다": "すでに作業中です",
"이미지 · PDF · ZIP 보기": "画像・PDF・ZIP ビューアー",
@ -1785,6 +1792,7 @@
"폴더: {0}": "フォルダー: {0}",
"폴더가 들어 있을 때": "フォルダーを含むとき",
"폴더가 아닙니다: {0}": "フォルダーではありません: {0}",
"폴더가 없거나 열 수 없습니다": "フォルダーがないか開けません",
"폴더가 없거나 열 수 없습니다: {0}": "フォルダーがないか、開けません: {0}",
"폴더가 없습니다": "フォルダーがありません",
"폴더는 보여도 그 안의 동영상 · 파일은 이 권한이 있어야 보입니다. 허용 화면에서 JJ_MKVMaker 를 켜고 돌아오세요.": "フォルダーは見えても、中の動画・ファイルはこの権限が必要です。許可画面で JJ_MKVMaker をオンにして戻ってください。",

View File

@ -299,6 +299,7 @@
"{0} 에서 그리는 중 ({1}초)": "正在 {0} 上绘制 ({1} 秒)",
"{0} 에서 받을 파일을 찾지 못했습니다": "未在 {0} 找到要下载的文件",
"{0} 외 {1}개": "{0} 等 {1} 个",
"{0} 은(는) 이미 이 폴더에 있어 옮길 것이 없습니다.\n{1}": "{0} 已在此文件夹中,无需移动。\n{1}",
"{0} 을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).": "已暂存 {0}。请在目标文件夹中点按 [粘贴] ({1})。",
"{0} 을(를) 설치했습니다.": "已安装 {0}。",
"{0} 을(를) 지울까요?": "要删除 {0} 吗?",
@ -388,9 +389,11 @@
"같은 이름이 있을 때": "存在同名项目时",
"같은 이름이라 건너뜀: {0}": "因同名已跳过: {0}",
"같은 조건으로 다시 (시드 그대로)": "以相同条件再来一次 (种子不变)",
"같은 폴더에 사본을 만들까요? (이름 (2))\n{0}": "要在同一文件夹中创建副本吗?(名称 (2))\n{0}",
"같은 폴더의 모든 동영상": "同一文件夹中的所有视频",
"같은 폴더의 자막은 자동으로 추가됩니다": "同一文件夹中的字幕会自动添加",
"같은 폴더이거나 한쪽이 다른 쪽 안에 있습니다. 다른 폴더를 고르세요.": "是同一文件夹,或一个在另一个里面。请选择其他文件夹。",
"같은 폴더입니다": "同一文件夹",
"같은 항목을 같은 곳으로 복사 · 이동하는 중입니다.": "正在将相同项目复制 · 移动到同一位置。",
"같은 화질이면 H.264 먼저 받기": "相同画质时优先 H.264",
"개수": "数量",
@ -877,6 +880,7 @@
"보기": "查看",
"보기 {0}초 · Ctrl+휠 확대/축소": "显示 {0} 秒 · Ctrl+滚轮缩放",
"보기 화면의 [계속 보기] 가 다음 장으로 넘기는 간격": "浏览画面中 [连续浏览] 翻到下一页的间隔",
"보던 폴더가 없어져 상위 폴더로 옮겼습니다": "正在查看的文件夹已不存在,已转到上级文件夹",
"보안": "安全",
"보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "选择要显示的按钮,拖动以调整顺序。",
"보정은 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "调整需要重新编码,因此编解码器已从“保持原样”改为 H.264。",
@ -936,6 +940,7 @@
"빠른 디코더 (TAESD)": "快速解码器 (TAESD)",
"빼고 싶은 것 (선택)": "要排除的内容 (可选)",
"사라지는 것": "丢失的内容",
"사본 만들기": "创建副本",
"사용 안 함": "不使用",
"사용 안 함. \"Sign in to confirm you're not a bot\" 오류가 나오면 설정하세요": "不使用。如出现“Sign in to confirm you're not a bot”错误请设置",
"사용: {0}": "使用: {0}",
@ -1373,7 +1378,9 @@
"이미 동영상 목록에 있습니다 ({0}개).": "已在视频列表中({0} 个)。",
"이미 목록에 있는 동영상입니다 ({0}개).": "这些视频已在列表中({0} 个)。",
"이미 받는 중이거나 받을 수 없는 주소입니다.": "已在下载中,或该地址无法下载。",
"이미 이 폴더에 있습니다": "已在此文件夹中",
"이미 이 폴더에 있습니다: {0}": "已在此文件夹中: {0}",
"이미 이 폴더에 있어 건너뜀: {0}": "已在此文件夹中,已跳过: {0}",
"이미 있는 폴더입니다: {0}": "文件夹已存在: {0}",
"이미 작업 중입니다": "已在处理中",
"이미지 · PDF · ZIP 보기": "图片 · PDF · ZIP 查看",
@ -1785,6 +1792,7 @@
"폴더: {0}": "文件夹: {0}",
"폴더가 들어 있을 때": "包含文件夹时",
"폴더가 아닙니다: {0}": "不是文件夹: {0}",
"폴더가 없거나 열 수 없습니다": "文件夹不存在或无法打开",
"폴더가 없거나 열 수 없습니다: {0}": "文件夹不存在或无法打开: {0}",
"폴더가 없습니다": "文件夹不存在",
"폴더는 보여도 그 안의 동영상 · 파일은 이 권한이 있어야 보입니다. 허용 화면에서 JJ_MKVMaker 를 켜고 돌아오세요.": "虽然能看到文件夹,但其中的视频和文件需要此权限。请在权限界面开启 JJ_MKVMaker 后返回。",

View File

@ -610,6 +610,21 @@ class _ExplorerPageState extends State<ExplorerPage> {
} catch (e) {
pane.cache[dir] = const [];
pane.errors[dir] = isAndroidRestricted(dir) ? androidRestrictedError : '$e';
// 37-2: 보던 폴더가 밖에서 지워졌으면 남아 있는 가장 가까운 상위 폴더로 (제목 · 경로 칸도 그 폴더로)
if (!isDav(dir) && samePath(dir, pane.current) && !samePath(dir, pane.root) && !Directory(dir).existsSync()) {
var up = vDirname(dir);
while (!samePath(up, pane.root) && !Directory(up).existsSync() && vDirname(up) != up) {
up = vDirname(up);
}
pane.errors.remove(dir);
pane.cache.remove(dir);
pane.expanded.remove(dir);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_snack(tr('보던 폴더가 없어져 상위 폴더로 옮겼습니다'));
unawaited(_goTo(pane, up, fresh: true));
});
}
} finally {
pane.loading.remove(dir);
pane.changed();
@ -1614,6 +1629,36 @@ class _ExplorerPageState extends State<ExplorerPage> {
_snack(tr('같은 항목을 같은 곳으로 복사 · 이동하는 중입니다.'));
return false;
}
// 135: 같은 폴더로 - 짧은 알림은 놓치기 쉬워 창으로. 이동은 할 것이 없고, 복사는 사본을 만들지 한 번 묻는다
final here = [for (final x in sources) if (samePath(vDirname(x), dest)) x];
var sameFolderCopy = false;
// 이동인데 일부만 이미 그 폴더에 있으면 그것만 건너뛰고 나머지를 옮긴다 (관리자: 섞어 고른 것을 다시 고르지 않게)
var skippedHere = const <String>[];
if (move && here.isNotEmpty && here.length < sources.length) {
skippedHere = here;
sources = [for (final x in sources) if (!here.contains(x)) x];
} else if (here.isNotEmpty && (move || here.length == sources.length)) {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(move ? tr('이미 이 폴더에 있습니다') : tr('같은 폴더입니다')),
content: Text(move
? trf('{0} 은(는) 이미 이 폴더에 있어 옮길 것이 없습니다.\n{1}', [_namesOf(here), vDisplay(dest)])
: trf('같은 폴더에 사본을 만들까요? (이름 (2))\n{0}', [_namesOf(here)])),
actions: [
if (move)
FilledButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('확인')))
else ...[
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('사본 만들기'))),
],
],
),
);
if (ok != true || !mounted) return false;
sameFolderCopy = true; // 이미 물었으니 아래 확인 창은 건너뛴다
}
// 원본 · 대상이 없거나 (다른 곳에서 지움 - 목록을 새로 고침) 폴더를 자기 안으로 넣으려 하면 시작하지 않는다
final problem = transferProblem(sources, dest, move: move);
if (problem != null) {
@ -1643,7 +1688,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
_ => null,
};
var conflict = always ?? NameConflict.rename;
final go = await showDialog<bool>(
final go = sameFolderCopy ? true : await showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setInner) => AlertDialog(
@ -1652,6 +1697,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
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)}'
'${skippedHere.isEmpty ? '' : '\n${trf('이미 이 폴더에 있어 건너뜀: {0}', [_namesOf(skippedHere)])}'}'
'\n\n${trf('방법: {0}', [tr(method.label)])}'
'${method == CopyMethod.builtin ? '' : ' · ${task.options}'}'
'${task.bandwidthKBps > 0 ? ' · ${trf('속도 제한 {0} KB/s', [task.bandwidthKBps])}' : ''}'),
@ -1736,6 +1782,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
: [
trf('{0}개 항목을 {1}', [job.made.length, move ? tr('옮겼습니다.') : tr('복사했습니다.')]),
if (job.skipped.isNotEmpty) trf('같은 이름이라 건너뜀: {0}', [_namesOf(job.skipped)]),
if (skippedHere.isNotEmpty) trf('이미 이 폴더에 있어 건너뜀: {0}', [_namesOf(skippedHere)]),
].join(' · '));
// 다 되면 진행 막대가 잠깐 100% 를 보인 뒤 내려간다
await Future<void>.delayed(const Duration(milliseconds: 1200));
@ -2736,27 +2783,29 @@ class _ExplorerPageState extends State<ExplorerPage> {
),
);
/// 경로를 직접 넣어 가기 (37). 없는 폴더면 알린다
/// 경로를 직접 넣어 가기 (37). 146 · 37-2: 없는 폴더면 창을 닫지 않고 칸 아래에 알린다 (고쳐서 다시 넣을 수 있게)
Future<void> _enterPath(_Pane pane) async {
setState(() => _active = _panes.indexOf(pane));
final input = await _askName(
Platform.isWindows ? tr('경로 입력 (예: D:\\영상 · \\\\NAS\\공유)') : tr('경로 입력'), pane.current,
hint: tr('경로'), selectAll: true);
if (input == null || !mounted) return;
final path = await showDialog<String>(context: context, builder: (_) => _PathDialog(initial: pane.current));
if (path == null || !mounted) return;
await _goTo(pane, path, fresh: true);
}
/// 146: 넣은 경로를 고쳐 쓰기 · 확인. 갈 수 있으면 그 경로, 아니면 칸 아래에 보일 이유
static Future<(String?, String?)> checkEnteredPath(String input) async {
var path = input.trim().replaceAll('"', '');
if (path.isEmpty) return;
if (path.isEmpty) return (null, null);
if (Platform.isWindows && RegExp(r'^\\\\[^\\/]+[\\/]?$').hasMatch(path)) {
_snack(tr('네트워크 공유는 공유 폴더 이름까지 넣으세요 (예: \\\\NAS\\영상).'));
return;
return (null, tr('네트워크 공유는 공유 폴더 이름까지 넣으세요 (예: \\\\NAS\\영상).'));
}
if (!isDav(path) && Platform.isWindows && RegExp(r'^[a-zA-Z]:$').hasMatch(path)) path = '$path\\';
final ok = isDav(path) || await Directory(path).exists();
if (!mounted) return;
if (!ok) {
_snack(trf('폴더가 없거나 열 수 없습니다: {0}', [path]));
return;
bool ok;
try {
ok = isDav(path) || await Directory(path).exists();
} catch (_) {
ok = false;
}
await _goTo(pane, path, fresh: true);
return ok ? (path, null) : (null, tr('폴더가 없거나 열 수 없습니다'));
}
/// WebDAV 서버 추가 (없으면 [root] 는 null) · 고치기 (탭 길게 누르기). 저장하면 그 서버로 간다.
@ -3265,3 +3314,56 @@ class _Thumb extends StatelessWidget {
},
);
}
/// 146 · 37-2: 경로 입력 창 - 없는 폴더면 닫지 않고 칸 아래에 "폴더가 없거나 열 수 없습니다"
class _PathDialog extends StatefulWidget {
const _PathDialog({required this.initial});
final String initial;
@override
State<_PathDialog> createState() => _PathDialogState();
}
class _PathDialogState extends State<_PathDialog> {
late final _ctl = TextEditingController(text: widget.initial)
..selection = TextSelection(baseOffset: 0, extentOffset: widget.initial.length);
String? _error;
bool _checking = false;
@override
void dispose() {
_ctl.dispose();
super.dispose();
}
Future<void> _submit() async {
setState(() => _checking = true);
final (path, error) = await _ExplorerPageState.checkEnteredPath(_ctl.text);
if (!mounted) return;
setState(() {
_checking = false;
_error = error;
});
if (path != null) Navigator.pop(context, path);
}
@override
Widget build(BuildContext context) => AlertDialog(
scrollable: true,
title: Text(Platform.isWindows ? tr('경로 입력 (예: D:\\영상 · \\\\NAS\\공유)') : tr('경로 입력')),
content: TextField(
controller: _ctl,
autofocus: true,
decoration: InputDecoration(hintText: tr('경로'), errorText: _error, errorMaxLines: 3),
// 고치기 시작하면 앞의 오류는 지운다
onChanged: (_) {
if (_error != null) setState(() => _error = null);
},
onSubmitted: (_) => _submit(),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: Text(tr('취소'))),
FilledButton(onPressed: _checking ? null : _submit, child: Text(tr('확인'))),
],
);
}

View File

@ -257,6 +257,115 @@ void main() {
expect(find.text('다시 시도'), findsOneWidget);
});
testWidgets('135: 두 창이 같은 폴더 - [이동] 은 "이미 이 폴더에 있습니다" 창 · [복사] 는 사본을 만들지 묻는다', (tester) async {
c.settings.explorerPaths = [left, left];
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('선택').first));
await settle(tester, () => false, rounds: 5);
await tester.runAsync(() => tester.tap(find.text('doc.txt').first));
await settle(tester, () => false, rounds: 5);
expect(find.textContaining('1개 표시함'), findsOneWidget);
await tester.runAsync(() => tester.tap(find.text('이동').first));
await settle(tester, () => find.byType(AlertDialog).evaluate().isNotEmpty, rounds: 30);
// 짧은 알림은 놓치기 쉬워 창으로 (135)
expect(find.descendant(of: find.byType(AlertDialog), matching: find.text('이미 이 폴더에 있습니다')), findsOneWidget);
expect(find.textContaining('doc.txt 은(는) 이미 이 폴더에 있어 옮길 것이 없습니다'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, '확인'));
await tester.pumpAndSettle();
expect(File(p.join(left, 'doc.txt')).existsSync(), isTrue);
// 같은 폴더로 [복사] 는 사본을 만들지 묻는다: [취소] → 그대로, [사본 만들기] → "doc (2).txt"
await tester.runAsync(() => tester.tap(find.text('복사').first));
await settle(tester, () => find.byType(AlertDialog).evaluate().isNotEmpty, rounds: 30);
expect(find.textContaining('같은 폴더에 사본을 만들까요? (이름 (2))'), findsOneWidget);
await tester.tap(find.text('취소'));
await tester.pumpAndSettle();
expect(File(p.join(left, 'doc (2).txt')).existsSync(), isFalse);
await tester.runAsync(() => tester.tap(find.text('복사').first));
await settle(tester, () => find.byType(AlertDialog).evaluate().isNotEmpty, rounds: 30);
await tester.runAsync(() => tester.tap(find.widgetWithText(FilledButton, '사본 만들기')));
await settle(tester, () => File(p.join(left, 'doc (2).txt')).existsSync() && find.byType(SnackBar).evaluate().isNotEmpty);
expect(File(p.join(left, 'doc (2).txt')).readAsStringSync(), 'new');
expect(find.widgetWithText(FilledButton, '복사'), findsNothing, reason: '확인 창을 두 번 띄우지 않음');
});
testWidgets('135 보충: 섞어 고른 [이동] - 이미 그 폴더에 있는 것만 건너뛰고 나머지는 옮긴다 (확인 창 · 끝난 알림에 적음)', (tester) async {
File(p.join(left, 'a.txt')).writeAsStringSync('a');
File(p.join(right, 'b.txt')).writeAsStringSync('b');
c.settings.explorerPaths = [tmp.path, right]; // 왼쪽 창은 left · right 를 함께 보는 위 폴더
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('left').evaluate().isNotEmpty);
// 왼쪽 창에서 left · right 를 펼침
await tester.runAsync(() => tester.tap(find.text('left').first));
await settle(tester, () => find.text('a.txt').evaluate().isNotEmpty);
await tester.runAsync(() => tester.tap(find.text('right').first));
await settle(tester, () => find.text('b.txt').evaluate().length >= 2);
await tester.runAsync(() => tester.tap(find.text('선택').first));
await settle(tester, () => false, rounds: 5);
await tester.runAsync(() => tester.tap(find.text('a.txt').first));
await tester.runAsync(() => tester.tap(find.text('b.txt').first));
await settle(tester, () => false, rounds: 5);
expect(find.textContaining('2개 표시함'), findsOneWidget);
await tester.runAsync(() => tester.tap(find.text('이동').first));
await settle(tester, () => find.byType(AlertDialog).evaluate().isNotEmpty, rounds: 30);
expect(find.textContaining('이미 이 폴더에 있어 건너뜀: b.txt'), findsOneWidget);
await tester.runAsync(() => tester.tap(find.widgetWithText(FilledButton, '이동')));
await settle(tester, () => File(p.join(right, 'a.txt')).existsSync() && find.byType(SnackBar).evaluate().isNotEmpty);
expect(File(p.join(right, 'a.txt')).readAsStringSync(), 'a');
expect(File(p.join(left, 'a.txt')).existsSync(), isFalse);
expect(File(p.join(right, 'b.txt')).readAsStringSync(), 'b');
expect(find.textContaining('이미 이 폴더에 있어 건너뜀: b.txt'), findsOneWidget, reason: '끝난 알림에도');
});
testWidgets('146 · 37-2: 없는 경로를 넣으면 창을 닫지 않고 칸 아래에 "폴더가 없거나 열 수 없습니다" · 고친 경로로 간다', (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().isNotEmpty);
await tester.tap(find.byTooltip('경로 입력').first);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField).last, p.join(tmp.path, 'nope'));
await tester.runAsync(() => tester.tap(find.widgetWithText(FilledButton, '확인')));
await settle(tester, () => find.text('폴더가 없거나 열 수 없습니다').evaluate().isNotEmpty);
expect(find.text('폴더가 없거나 열 수 없습니다'), findsOneWidget, reason: '칸 아래 (창은 그대로)');
expect(find.byType(AlertDialog), findsOneWidget);
// 고치기 시작하면 오류는 지워지고, 있는 폴더면 창을 닫고 간다
await tester.enterText(find.byType(TextField).last, right);
await tester.pump();
expect(find.text('폴더가 없거나 열 수 없습니다'), findsNothing);
await tester.runAsync(() => tester.tap(find.widgetWithText(FilledButton, '확인')));
await settle(tester, () => find.byType(AlertDialog).evaluate().isEmpty);
await settle(tester, () => false, rounds: 10);
expect(find.byType(AlertDialog), findsNothing);
expect(find.textContaining(right), findsWidgets);
});
testWidgets('37-2: 보던 폴더가 밖에서 지워지면 새로 읽을 때 남아 있는 상위 폴더로 옮기고 알린다', (tester) async {
final deep = Directory(p.join(left, 'a', 'b'))..createSync(recursive: true);
c.settings.explorerPaths = [deep.path, right];
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.textContaining(deep.path).evaluate().isNotEmpty);
// 밖에서 a 폴더째 지움 → [새로 고침]
Directory(p.join(left, 'a')).deleteSync(recursive: true);
await tester.runAsync(() => tester.tap(find.text('새로 고침').first));
await settle(tester, () => find.textContaining('보던 폴더가 없어져 상위 폴더로 옮겼습니다').evaluate().isNotEmpty);
expect(find.textContaining('보던 폴더가 없어져 상위 폴더로 옮겼습니다'), findsOneWidget);
await settle(tester, () => false, rounds: 10);
// 제목 (지금 경로) 도 남아 있는 가장 가까운 상위 (left) 로
expect(find.textContaining(p.join(left, 'a')), findsNothing);
expect(find.text('doc.txt'), findsWidgets);
});
testWidgets('48: 건너뛰기를 고르면 그대로 두고 건너뛴 것을 알린다', (tester) async {
await startCopy(tester);
await tester.tap(find.text('건너뛰기'));

View File

@ -111,8 +111,8 @@ void main() {
await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '확인')));
await settle(tester, () => find.textContaining('폴더가 없거나 열 수 없습니다').evaluate().isNotEmpty, rounds: 20);
expect(find.textContaining('폴더가 없거나 열 수 없습니다'), findsOneWidget);
await tester.tap(find.byTooltip('경로 입력').first);
await tester.pumpAndSettle();
// 146: 창은 열린 채 - 그 칸에서 고쳐 넣는다
expect(find.byType(AlertDialog), findsOneWidget);
await tester.enterText(find.byType(TextField).last, deep);
await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '확인')));
await settle(tester, () => find.text('deep.txt').evaluate().isNotEmpty);