144 긴 경로 지우기: 휴지통에 넣지 못한 이유는 한 줄 · 이름만 · [그대로 두기] / [영구 삭제 (n개, 되돌릴 수 없음)]

- 휴지통이 받지 않는 긴 경로 (260자 넘음) 는 LongPathException 으로 따로 모아, 실패 창 위에 이유 한 줄과 이름만
- [영구 삭제] 는 그 항목만 지우고 결과를 알림. 긴 경로가 없으면 예전처럼 [확인]
- 시험: 긴 경로 폴더 → 이유 한 줄 · [그대로 두기] 면 남음 · [영구 삭제] 면 지움
- recycle_bin_test: 148 의 "원래 폴더 없음" 시험이 휴지통에 남긴 것을 정확히 그 폴더로 찾아 지우게 (사용자 휴지통에 남던 것)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
묶음3
RyuJongJin 2026-10-09 19:31:07 +09:00
parent e1e3316d8d
commit f97f63df79
7 changed files with 105 additions and 8 deletions

View File

@ -333,6 +333,7 @@
"{0}개는 휴지통보다 커서 영구 삭제했습니다 (Windows 가 물은 대로).": "{0} were too large for the Recycle Bin and were deleted permanently (as Windows asked).",
"{0}개는 휴지통에 들어가지 않아 영구 삭제했습니다 (Windows 가 묻고 고른 대로).": "{0} didn't fit in the Recycle Bin and were deleted permanently (as chosen when Windows asked).",
"{0}개를 \"기타 즐겨찾기\" 에 가져왔습니다.": "Imported {0} into \"Other bookmarks\".",
"{0}개를 영구 삭제했습니다.": "Permanently deleted {0} item(s).",
"{0}개를 옮겼습니다 → {1}": "Moved {0} → {1}",
"{0}개를 옮겼습니다 → {1} ({2})": "Moved {0} → {1} ({2})",
"{0}개를 찾아 여기서 멈췄습니다. 이름을 더 자세히 넣거나 아래 폴더로 들어가 찾으세요.": "Stopped after finding {0}. Enter a more specific name or search inside a subfolder.",
@ -406,6 +407,7 @@
"경로": "Path",
"경로 입력": "Enter path",
"경로 입력 (예: D:\\영상 · \\\\NAS\\공유)": "Enter path (e.g. D:\\Videos · \\\\NAS\\share)",
"경로가 너무 길어 휴지통에 넣을 수 없습니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다).": "The path is too long for the Recycle Bin (the Windows Recycle Bin does not accept paths over 260 characters).",
"경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다": "The path is too long (the Windows Recycle Bin doesn't accept paths over 260 characters) - you can delete it permanently",
"계속": "Always",
"계속 (앱이 켜져 있는 동안)": "Always (while the app is running)",
@ -434,6 +436,7 @@
"권한 허용 화면 열기": "Open the permission screen",
"권한이 없습니다": "No permission",
"그 파일 하나만": "Only that file",
"그대로 두기": "Keep",
"그래도 맞추기 ({0}개 지움)": "Sync anyway (delete {0})",
"그리는 중 {0}/{1}": "Drawing {0}/{1}",
"그리려면 {0} 를 받아야 합니다 (모델은 받은 뒤 이 기기에만 있고, 그림 · 프롬프트는 밖으로 나가지 않습니다).": "To draw, {0} must be downloaded (the model stays on this device, and images and prompts never leave it).",
@ -1153,6 +1156,7 @@
"열 수 없습니다: {0}": "Cannot open: {0}",
"열기": "Open",
"영구 삭제": "Delete permanently",
"영구 삭제 ({0}개, 되돌릴 수 없음)": "Delete permanently ({0}, cannot be undone)",
"영상 {0}": "Video {0}",
"영상 · 음성 합치는 중": "Merging video · audio",
"영상 재생 오류: {0}": "Video playback error: {0}",
@ -1601,6 +1605,7 @@
"지우기가 들어간 실행이라, 지울 목록이 나온 뒤에 실행할 수 있습니다.": "This run deletes files, so you can run it after the list of deletions appears.",
"지우면 휴지통으로": "Delete to the Recycle Bin",
"지우지 못한 파일: {0}": "Couldn't delete: {0}",
"지우지 못함: {0}": "Could not delete: {0}",
"지우지 못했습니다: {0}": "Could not delete: {0}",
"지우지 않고 맞춤 · 대상에만 있는 {0}개를 지울지 확인 필요": "Synced without deleting · confirm whether to delete {0} items only in the target",
"지우지 않기 (지우기 끄기)": "Don't delete (turn off delete)",

View File

@ -333,6 +333,7 @@
"{0}개는 휴지통보다 커서 영구 삭제했습니다 (Windows 가 물은 대로).": "{0} 個はごみ箱より大きいため完全に削除しました (Windows の確認どおり)。",
"{0}개는 휴지통에 들어가지 않아 영구 삭제했습니다 (Windows 가 묻고 고른 대로).": "{0} 件はごみ箱に入らなかったため完全に削除しました (Windows の確認で選んだとおり)。",
"{0}개를 \"기타 즐겨찾기\" 에 가져왔습니다.": "{0}個を「その他のブックマーク」に取り込みました。",
"{0}개를 영구 삭제했습니다.": "{0} 件を完全に削除しました。",
"{0}개를 옮겼습니다 → {1}": "{0}個を移動しました → {1}",
"{0}개를 옮겼습니다 → {1} ({2})": "{0}個を移動しました → {1}({2})",
"{0}개를 찾아 여기서 멈췄습니다. 이름을 더 자세히 넣거나 아래 폴더로 들어가 찾으세요.": "{0} 件見つけたところで止めました。名前をより詳しく入力するか、下のフォルダーに入って探してください。",
@ -406,6 +407,7 @@
"경로": "パス",
"경로 입력": "パスを入力",
"경로 입력 (예: D:\\영상 · \\\\NAS\\공유)": "パスを入力 (例: D:\\動画・\\\\NAS\\共有)",
"경로가 너무 길어 휴지통에 넣을 수 없습니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다).": "パスが長すぎてごみ箱に入れられません (Windows のごみ箱は 260 文字を超えるパスを受け付けません)。",
"경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다": "パスが長すぎます (Windows のごみ箱は 260 文字を超えるパスを受け付けません) - 完全に削除はできます",
"계속": "常時",
"계속 (앱이 켜져 있는 동안)": "常時 (アプリの起動中)",
@ -434,6 +436,7 @@
"권한 허용 화면 열기": "権限の許可画面を開く",
"권한이 없습니다": "権限がありません",
"그 파일 하나만": "そのファイルだけ",
"그대로 두기": "そのままにする",
"그래도 맞추기 ({0}개 지움)": "それでも同期 ({0} 個を削除)",
"그리는 중 {0}/{1}": "描画中 {0}/{1}",
"그리려면 {0} 를 받아야 합니다 (모델은 받은 뒤 이 기기에만 있고, 그림 · 프롬프트는 밖으로 나가지 않습니다).": "描くには {0} のダウンロードが必要です (モデルはこの端末にだけ置かれ、画像・プロンプトは外に出ません)。",
@ -1153,6 +1156,7 @@
"열 수 없습니다: {0}": "開けません: {0}",
"열기": "開く",
"영구 삭제": "完全に削除",
"영구 삭제 ({0}개, 되돌릴 수 없음)": "完全に削除 ({0} 件、元に戻せません)",
"영상 {0}": "映像 {0}",
"영상 · 음성 합치는 중": "映像・音声を結合中",
"영상 재생 오류: {0}": "動画再生エラー: {0}",
@ -1601,6 +1605,7 @@
"지우기가 들어간 실행이라, 지울 목록이 나온 뒤에 실행할 수 있습니다.": "削除を含む実行のため、削除一覧が表示されてから実行できます。",
"지우면 휴지통으로": "削除するとごみ箱へ",
"지우지 못한 파일: {0}": "削除できなかったファイル: {0}",
"지우지 못함: {0}": "削除できません: {0}",
"지우지 못했습니다: {0}": "削除できませんでした: {0}",
"지우지 않고 맞춤 · 대상에만 있는 {0}개를 지울지 확인 필요": "削除せずに同期・対象にだけある {0} 個を削除するか確認が必要",
"지우지 않기 (지우기 끄기)": "削除しない (削除をオフ)",

View File

@ -333,6 +333,7 @@
"{0}개는 휴지통보다 커서 영구 삭제했습니다 (Windows 가 물은 대로).": "{0} 个超出回收站容量,已永久删除 (按 Windows 的提示)。",
"{0}개는 휴지통에 들어가지 않아 영구 삭제했습니다 (Windows 가 묻고 고른 대로).": "{0} 个项目无法放入回收站,已永久删除 (按 Windows 询问时的选择)。",
"{0}개를 \"기타 즐겨찾기\" 에 가져왔습니다.": "已将 {0} 个导入“其他书签”。",
"{0}개를 영구 삭제했습니다.": "已永久删除 {0} 个项目。",
"{0}개를 옮겼습니다 → {1}": "已移动 {0} 个 → {1}",
"{0}개를 옮겼습니다 → {1} ({2})": "已移动 {0} 个 → {1}({2})",
"{0}개를 찾아 여기서 멈췄습니다. 이름을 더 자세히 넣거나 아래 폴더로 들어가 찾으세요.": "找到 {0} 个后已停止。请输入更具体的名称或进入下级文件夹查找。",
@ -406,6 +407,7 @@
"경로": "路径",
"경로 입력": "输入路径",
"경로 입력 (예: D:\\영상 · \\\\NAS\\공유)": "输入路径 (例: D:\\视频 · \\\\NAS\\共享)",
"경로가 너무 길어 휴지통에 넣을 수 없습니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다).": "路径过长,无法放入回收站 (Windows 回收站不接受超过 260 个字符的路径)。",
"경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다": "路径过长 (Windows 回收站不接受超过 260 个字符的路径) - 可以永久删除",
"계속": "持续",
"계속 (앱이 켜져 있는 동안)": "持续 (应用运行期间)",
@ -434,6 +436,7 @@
"권한 허용 화면 열기": "打开权限授予界面",
"권한이 없습니다": "没有权限",
"그 파일 하나만": "仅该文件",
"그대로 두기": "保留",
"그래도 맞추기 ({0}개 지움)": "仍然同步 (删除 {0} 个)",
"그리는 중 {0}/{1}": "正在绘制 {0}/{1}",
"그리려면 {0} 를 받아야 합니다 (모델은 받은 뒤 이 기기에만 있고, 그림 · 프롬프트는 밖으로 나가지 않습니다).": "要绘制,需要下载 {0} (模型只保存在本设备上,图片和提示词不会离开设备)。",
@ -1153,6 +1156,7 @@
"열 수 없습니다: {0}": "无法打开: {0}",
"열기": "打开",
"영구 삭제": "永久删除",
"영구 삭제 ({0}개, 되돌릴 수 없음)": "永久删除 ({0} 个,无法撤销)",
"영상 {0}": "视频 {0}",
"영상 · 음성 합치는 중": "正在合并视频 · 音频",
"영상 재생 오류: {0}": "视频播放错误: {0}",
@ -1601,6 +1605,7 @@
"지우기가 들어간 실행이라, 지울 목록이 나온 뒤에 실행할 수 있습니다.": "此操作包含删除,需在显示删除列表后才能执行。",
"지우면 휴지통으로": "删除时移到回收站",
"지우지 못한 파일: {0}": "无法删除的文件: {0}",
"지우지 못함: {0}": "无法删除: {0}",
"지우지 못했습니다: {0}": "无法删除: {0}",
"지우지 않고 맞춤 · 대상에만 있는 {0}개를 지울지 확인 필요": "已同步但未删除 · 需确认是否删除仅在目标中的 {0} 个项目",
"지우지 않기 (지우기 끄기)": "不删除 (关闭删除)",

View File

@ -214,6 +214,11 @@ String recycleErrorText(int code) => switch (code) {
_ => trf('Windows 오류 0x{0}', [code.toRadixString(16)]),
};
/// 144: 경로가 너무 길어 휴지통이 받지 않는 것 (영구 삭제는 사용자가 따로 고른다). [path] 는 그 긴 경로
class LongPathException extends FileSystemException {
const LongPathException(super.message, super.path);
}
/// Windows 휴지통으로 보내기 (SHFileOperationW, FO_DELETE + FOF_ALLOWUNDO). 65: 탐색기의 기본 지우기.
/// 하나씩 보내 어느 것이 실패했는지 알 수 있게 한다. 실패하면 [FileSystemException] (이유는 읽을 수 있는 말).
/// 휴지통보다 커서 들어가지 않으면 Windows 가 묻게 하고 (FOF_WANTNUKEWARNING), 실제로 어떻게 됐는지 돌려준다 (94).
@ -223,7 +228,7 @@ RecycleResult moveToRecycleBin(String path) {
// 휴지통은 260자 넘는 경로를 받지 않고, 그때 Windows 는 묻지 않고 영구 삭제해 버린다 (시험에서 확인).
// 그래서 보내기 전에 (폴더면 안의 것까지) 긴 경로가 있으면 지우지 않고 이유를 알린다 - 영구 삭제는 사용자가 따로 고른다.
final long = longPathInside(path);
if (long != null) throw FileSystemException(recycleErrorText(0x7C), long);
if (long != null) throw LongPathException(recycleErrorText(0x7C), long);
final shell32 = DynamicLibrary.open('shell32.dll');
final op = shell32.lookupFunction<Int32 Function(Pointer<_ShFileOp>), int Function(Pointer<_ShFileOp>)>('SHFileOperationW');
final before = _binCount(path);

View File

@ -1332,6 +1332,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
if (ok != true) return;
// 하나씩: 실패해도 나머지는 계속. 실제로 어떻게 됐는지 세어 그대로 알린다 (94)
final failed = <(String, String)>[];
// 144: 경로가 너무 길어 휴지통이 받지 않은 것 (이유는 한 번, [영구 삭제] 로 바로 지울 수 있게)
final tooLong = <String>[];
var recycled = 0, nuked = 0, kept = 0;
// 148: 휴지통으로 보낸 것 (되돌리기용) - 정보 파일 시각은 초 단위라 조금 앞에서부터
final since = DateTime.now().subtract(const Duration(seconds: 2));
@ -1351,18 +1353,29 @@ class _ExplorerPageState extends State<ExplorerPage> {
} else {
await FileOps().delete([x]);
}
} on LongPathException {
tooLong.add(x);
} catch (e) {
failed.add((x, e is FileSystemException ? (e.osError?.message ?? e.message) : '$e'));
}
}
if (failed.isNotEmpty && mounted) {
await showDialog<void>(
if ((failed.isNotEmpty || tooLong.isNotEmpty) && mounted) {
final nukeLong = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
icon: const Icon(Icons.error_outline, color: Colors.redAccent),
title: Text(trf('{0}개 중 {1}개를 지우지 못했습니다', [paths.length, failed.length])),
title: Text(trf('{0}개 중 {1}개를 지우지 못했습니다', [paths.length, failed.length + tooLong.length])),
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
// 144: 긴 경로는 이유 한 줄 + 이름만 (영구 삭제는 아래 버튼으로)
if (tooLong.isNotEmpty) ...[
Text(tr('경로가 너무 길어 휴지통에 넣을 수 없습니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다).'),
style: const TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
for (final x in tooLong.take(30)) Text('· ${vBasename(x)}', style: const TextStyle(fontSize: 12)),
if (tooLong.length > 30) Text(trf('… 외 {0}개', [tooLong.length - 30])),
const SizedBox(height: 8),
],
for (final (x, why) in failed.take(30)) ...[
Text(vBasename(x), style: const TextStyle(fontWeight: FontWeight.w600)),
Text('${vDirname(x)} · $why', style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
@ -1371,11 +1384,36 @@ class _ExplorerPageState extends State<ExplorerPage> {
if (failed.length > 30) Text(trf('… 외 {0}개', [failed.length - 30])),
const SizedBox(height: 6),
// 이유를 이미 위에 적었으므로 짐작하는 말은 빼고, 실제로 지운 것이 있을 때만 "나머지는 지웠습니다"
if (failed.length < paths.length) Text(tr('나머지는 지웠습니다.'), style: const TextStyle(fontSize: 12)),
if (failed.length + tooLong.length < paths.length) Text(tr('나머지는 지웠습니다.'), style: const TextStyle(fontSize: 12)),
]),
actions: [FilledButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('확인')))],
actions: [
if (tooLong.isNotEmpty) ...[
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('그대로 두기'))),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: Colors.redAccent),
onPressed: () => Navigator.pop(ctx, true),
child: Text(trf('영구 삭제 ({0}개, 되돌릴 수 없음)', [tooLong.length])),
),
] else
FilledButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('확인'))),
],
),
);
// 144: 사용자가 [영구 삭제] 를 고른 긴 경로만 지운다
if (nukeLong == true) {
final notDeleted = <String>[];
for (final x in tooLong) {
try {
await FileOps().delete([x]);
} catch (e) {
notDeleted.add('${vBasename(x)}: ${e is FileSystemException ? (e.osError?.message ?? e.message) : e}');
}
}
_snack([
if (tooLong.length > notDeleted.length) trf('{0}개를 영구 삭제했습니다.', [tooLong.length - notDeleted.length]),
if (notDeleted.isNotEmpty) trf('지우지 못함: {0}', [notDeleted.take(3).join(' · ')]),
].join(' '));
}
} else if (!permanent && mounted) {
final text = [
if (recycled > 0) trf('{0}개 항목을 휴지통으로 보냈습니다.', [recycled]),

View File

@ -166,6 +166,44 @@ void main() {
await tester.pumpAndSettle();
}, skip: !Platform.isWindows);
testWidgets('144: 긴 경로는 휴지통에 넣지 못한 이유 한 줄과 [영구 삭제] - 누르면 지운다 · [그대로 두기] 면 남는다', (tester) async {
String lp(String s) => r'\\?\' + s;
var deep = p.join(left, 'deep');
while (deep.length < 250) {
deep = p.join(deep, 'd' * 30);
}
Directory(lp(deep)).createSync(recursive: true);
File(lp(p.join(deep, 'a_file_name_that_makes_it_longer.txt'))).writeAsStringSync('x');
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('deep').evaluate().isNotEmpty);
Future<void> deleteDeep() async {
await tester.runAsync(() => tester.tap(find.text('deep').first));
await settle(tester, () => false, rounds: 10);
await tester.runAsync(() => tester.tap(find.text('삭제').first));
await tester.pumpAndSettle();
await tester.runAsync(() => tester.tap(find.widgetWithText(FilledButton, '휴지통으로')));
await settle(tester, () => find.textContaining('경로가 너무 길어').evaluate().isNotEmpty);
}
await deleteDeep();
// 이유는 한 줄, 항목은 이름만, 버튼은 [그대로 두기] · [영구 삭제]
expect(find.textContaining('경로가 너무 길어 휴지통에 넣을 수 없습니다'), findsOneWidget);
expect(find.text('· deep'), findsOneWidget);
expect(find.textContaining('영구 삭제로 지울 수 있습니다'), findsNothing, reason: '긴 설명을 항목마다 되풀이하지 않음');
await tester.tap(find.text('그대로 두기'));
await tester.pumpAndSettle();
expect(Directory(lp(deep)).existsSync(), isTrue);
// 다시 → [영구 삭제]
await deleteDeep();
await tester.runAsync(() => tester.tap(find.textContaining('영구 삭제 (1개, 되돌릴 수 없음)')));
await settle(tester, () => find.textContaining('영구 삭제했습니다').evaluate().isNotEmpty);
expect(Directory(p.join(left, 'deep')).existsSync(), isFalse);
expect(find.textContaining('1개를 영구 삭제했습니다.'), findsOneWidget);
}, skip: !Platform.isWindows);
testWidgets('48: 건너뛰기를 고르면 그대로 두고 건너뛴 것을 알린다', (tester) async {
await startCopy(tester);
await tester.tap(find.text('건너뛰기'));

View File

@ -84,12 +84,13 @@ void main() {
test('148: 원래 폴더가 그사이 지워졌으면 다시 만들지 않고 알린다 · 이 사용자 (SID) 의 휴지통에서만 찾는다', () async {
expect(currentUserSid(), matches(RegExp(r'^S-1-5-')));
final tmp = Directory.systemTemp.createTempSync('jj_undo_');
final folder = Directory(p.join(tmp.absolute.path, 'gone'))..createSync();
addTearDown(() async {
await _purgeFromRecycleBin(tmp.absolute.path);
// 휴지통에 남긴 x.txt 의 원래 위치는 gone 폴더 (정확히 그 폴더로 찾아 지운다)
expect(await _purgeFromRecycleBin(folder.path), 1);
if (tmp.existsSync()) tmp.deleteSync(recursive: true);
});
final since = DateTime.now().subtract(const Duration(seconds: 2));
final folder = Directory(p.join(tmp.absolute.path, 'gone'))..createSync();
final f = File(p.join(folder.path, 'x.txt'))..writeAsStringSync('x');
expect(moveToRecycleBin(f.path), RecycleResult.recycled);
folder.deleteSync(); // 사용자가 그 폴더를 지움