diff --git a/assets/l10n/en.json b/assets/l10n/en.json index b10118c..6d3c566 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -325,6 +325,7 @@ "{0}개 항목을 {1}": "{0} items {1}", "{0}개 항목을 다음 폴더로 {1}\n{2}": "{0} items to this folder — {1}\n{2}", "{0}개 항목을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).": "{0} items held. Press [Paste] in the destination folder ({1}).", + "{0}개 항목을 되돌렸습니다.": "Restored {0} item(s).", "{0}개 항목을 지울까요? 되돌릴 수 없습니다.": "Delete {0} items? This cannot be undone.", "{0}개 항목을 휴지통으로 보낼까요?": "Move {0} items to the Recycle Bin?", "{0}개 항목을 휴지통으로 보냈습니다.": "Moved {0} items to the Recycle Bin.", @@ -672,8 +673,10 @@ "동영상이 있는 폴더 아래 jj_mkv (기본)": "jj_mkv under the video's folder (default)", "동의하고 받기": "Agree and download", "동작 중 · {0} 까지": "Running · until {0}", + "되돌리기": "Undo", "되돌리기 계속": "Continue rollback", "되돌리기: AI 모델 {0} 을 복사해 두었습니다: {1}": "Rollback: copied AI models ({0}): {1}", + "되돌리지 못함: {0}": "Could not restore: {0}", "되돌릴 수 없습니다.": "This can't be undone.", "되살리고 다시 시작": "Restore and restart", "되살리기": "Restore", @@ -1206,6 +1209,8 @@ "요청이 너무 많습니다. 잠시 후 다시 시도하세요.": "Too many requests. Try again in a moment.", "우르두어": "Urdu", "우크라이나어": "Ukrainian", + "원래 자리에 같은 이름이 이미 있습니다": "An item with the same name already exists in the original place", + "원래 폴더가 없어 되돌리지 못했습니다 (휴지통에서 직접 복원하세요)": "The original folder no longer exists, so it was not restored (restore it from the Recycle Bin yourself)", "원문 보기 (번역 끄기)": "Show original (turn off translation)", "원본": "Source", "원본 비율": "Original aspect", @@ -1865,9 +1870,11 @@ "회전 없음": "No rotation", "회차": "Episode", "휴대폰 · 태블릿은 AV1 · VP9 을 하드웨어로 풀지 못해 끊기거나 배터리를 많이 쓰는 일이 있습니다. YouTube 의 H.264 는 1080p 까지라 더 큰 화질을 고르면 크기가 먼저입니다.": "Phones and tablets often can't decode AV1/VP9 in hardware, causing stutter or heavy battery use. YouTube's H.264 goes up to 1080p, so for higher qualities size comes first.", + "휴지통에서 찾지 못했습니다 (이미 비웠거나 되돌림)": "Not found in the Recycle Bin (already emptied or restored)", "휴지통으로": "To Recycle Bin", "휴지통으로 보내기": "Move to Recycle Bin", "휴지통을 거치지 않고 영구 삭제 (Shift+Delete)": "Delete permanently without the Recycle Bin (Shift+Delete)", + "휴지통이 없는 곳입니다": "This location has no Recycle Bin", "휴지통이 없습니다": "No Recycle Bin", "히브리어": "Hebrew", "힌디어": "Hindi" diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index d3c649f..f0818cf 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -325,6 +325,7 @@ "{0}개 항목을 {1}": "{0} 件の項目を{1}", "{0}개 항목을 다음 폴더로 {1}\n{2}": "{0} 件の項目を次のフォルダーへ{1}\n{2}", "{0}개 항목을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).": "{0} 件を保持しました。入れるフォルダーで [貼り付け] を押してください ({1})。", + "{0}개 항목을 되돌렸습니다.": "{0} 件を元に戻しました。", "{0}개 항목을 지울까요? 되돌릴 수 없습니다.": "{0} 件の項目を削除しますか? 元に戻せません。", "{0}개 항목을 휴지통으로 보낼까요?": "{0} 個の項目をごみ箱に移動しますか?", "{0}개 항목을 휴지통으로 보냈습니다.": "{0} 個の項目をごみ箱に移動しました。", @@ -672,8 +673,10 @@ "동영상이 있는 폴더 아래 jj_mkv (기본)": "動画があるフォルダーの下の jj_mkv(既定)", "동의하고 받기": "同意してダウンロード", "동작 중 · {0} 까지": "動作中 · {0} まで", + "되돌리기": "元に戻す", "되돌리기 계속": "戻す操作を続ける", "되돌리기: AI 모델 {0} 을 복사해 두었습니다: {1}": "戻す: AI モデル {0} をコピーしました: {1}", + "되돌리지 못함: {0}": "元に戻せません: {0}", "되돌릴 수 없습니다.": "元に戻せません。", "되살리고 다시 시작": "復元して再起動", "되살리기": "元に戻す", @@ -1206,6 +1209,8 @@ "요청이 너무 많습니다. 잠시 후 다시 시도하세요.": "リクエストが多すぎます。しばらくしてからもう一度お試しください。", "우르두어": "ウルドゥー語", "우크라이나어": "ウクライナ語", + "원래 자리에 같은 이름이 이미 있습니다": "元の場所に同じ名前が既にあります", + "원래 폴더가 없어 되돌리지 못했습니다 (휴지통에서 직접 복원하세요)": "元のフォルダーがないため元に戻せませんでした (ごみ箱から直接復元してください)", "원문 보기 (번역 끄기)": "原文を表示 (翻訳をオフ)", "원본": "元", "원본 비율": "元の比率", @@ -1865,9 +1870,11 @@ "회전 없음": "回転なし", "회차": "話数", "휴대폰 · 태블릿은 AV1 · VP9 을 하드웨어로 풀지 못해 끊기거나 배터리를 많이 쓰는 일이 있습니다. YouTube 의 H.264 는 1080p 까지라 더 큰 화질을 고르면 크기가 먼저입니다.": "スマホ・タブレットは AV1・VP9 をハードウェアで再生できず、途切れたりバッテリーを多く使うことがあります。YouTube の H.264 は 1080p までなので、それより高い画質を選ぶとサイズが優先です。", + "휴지통에서 찾지 못했습니다 (이미 비웠거나 되돌림)": "ごみ箱に見つかりません (空にしたか元に戻した)", "휴지통으로": "ごみ箱へ", "휴지통으로 보내기": "ごみ箱に移動", "휴지통을 거치지 않고 영구 삭제 (Shift+Delete)": "ごみ箱を使わずに完全に削除 (Shift+Delete)", + "휴지통이 없는 곳입니다": "ごみ箱がない場所です", "휴지통이 없습니다": "ごみ箱がありません", "히브리어": "ヘブライ語", "힌디어": "ヒンディー語" diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 6801b88..52484a4 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -325,6 +325,7 @@ "{0}개 항목을 {1}": "{0} 个项目{1}", "{0}개 항목을 다음 폴더로 {1}\n{2}": "将 {0} 个项目到以下文件夹 — {1}\n{2}", "{0}개 항목을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).": "已暂存 {0} 个项目。请在目标文件夹中按 [粘贴] ({1})。", + "{0}개 항목을 되돌렸습니다.": "已恢复 {0} 个项目。", "{0}개 항목을 지울까요? 되돌릴 수 없습니다.": "删除 {0} 个项目吗? 此操作无法撤销。", "{0}개 항목을 휴지통으로 보낼까요?": "将 {0} 个项目移到回收站吗?", "{0}개 항목을 휴지통으로 보냈습니다.": "已将 {0} 个项目移到回收站。", @@ -672,8 +673,10 @@ "동영상이 있는 폴더 아래 jj_mkv (기본)": "视频所在文件夹下的 jj_mkv(默认)", "동의하고 받기": "同意并下载", "동작 중 · {0} 까지": "运行中 · 直到 {0}", + "되돌리기": "撤销", "되돌리기 계속": "继续回退", "되돌리기: AI 모델 {0} 을 복사해 두었습니다: {1}": "回退: 已复制 AI 模型 {0}: {1}", + "되돌리지 못함: {0}": "无法恢复: {0}", "되돌릴 수 없습니다.": "无法撤销。", "되살리고 다시 시작": "恢复并重新启动", "되살리기": "恢复", @@ -1206,6 +1209,8 @@ "요청이 너무 많습니다. 잠시 후 다시 시도하세요.": "请求过多。请稍后再试。", "우르두어": "乌尔都语", "우크라이나어": "乌克兰语", + "원래 자리에 같은 이름이 이미 있습니다": "原位置已存在同名项目", + "원래 폴더가 없어 되돌리지 못했습니다 (휴지통에서 직접 복원하세요)": "原文件夹已不存在,未能恢复 (请在回收站中手动还原)", "원문 보기 (번역 끄기)": "显示原文 (关闭翻译)", "원본": "源", "원본 비율": "原始比例", @@ -1865,9 +1870,11 @@ "회전 없음": "不旋转", "회차": "集数", "휴대폰 · 태블릿은 AV1 · VP9 을 하드웨어로 풀지 못해 끊기거나 배터리를 많이 쓰는 일이 있습니다. YouTube 의 H.264 는 1080p 까지라 더 큰 화질을 고르면 크기가 먼저입니다.": "手机 · 平板常无法硬件解码 AV1 · VP9,会卡顿或耗电。YouTube 的 H.264 最高 1080p,选择更高画质时以尺寸优先。", + "휴지통에서 찾지 못했습니다 (이미 비웠거나 되돌림)": "在回收站中找不到 (已清空或已恢复)", "휴지통으로": "移到回收站", "휴지통으로 보내기": "移到回收站", "휴지통을 거치지 않고 영구 삭제 (Shift+Delete)": "不经过回收站永久删除 (Shift+Delete)", + "휴지통이 없는 곳입니다": "此位置没有回收站", "휴지통이 없습니다": "没有回收站", "히브리어": "希伯来语", "힌디어": "印地语" diff --git a/lib/platform/windows/recycle_bin.dart b/lib/platform/windows/recycle_bin.dart index f047aca..00135d1 100644 --- a/lib/platform/windows/recycle_bin.dart +++ b/lib/platform/windows/recycle_bin.dart @@ -2,6 +2,7 @@ import 'dart:ffi'; import 'dart:io'; import 'package:ffi/ffi.dart'; +import 'package:path/path.dart' as p; import '../../l10n/tr.dart'; @@ -112,6 +113,76 @@ bool? recycledInfoExists(String original, {required DateTime since}) { return readable ? false : null; } +/// 지금 사용자의 SID (예: S-1-5-21-…, 휴지통 폴더 이름). 모르면 null +String? currentUserSid() => _sid ??= () { + try { + // 전체 경로로 (PATH 에 Git 등의 다른 whoami 가 먼저 있을 수 있다) + final exe = '${Platform.environment['SystemRoot'] ?? r'C:\Windows'}\\System32\\whoami.exe'; + final r = Process.runSync(exe, ['/user', '/fo', 'csv', '/nh']); + return RegExp(r'"(S-1-[0-9-]+)"').firstMatch('${r.stdout}')?.group(1); + } catch (_) { + return null; + } + }(); +String? _sid; + +/// 148: 방금 휴지통으로 보낸 [original] 을 원래 자리로 되돌린다 ([since] 뒤에 생긴 것 중 가장 새것). +/// 휴지통의 $R… (내용) 을 원래 경로로 옮기고 $I… (정보) 를 지운다. 원래 자리에 이미 무엇이 있으면 덮지 않고 실패. +/// 실패하면 [FileSystemException] (이유는 읽을 수 있는 말). +void restoreFromRecycleBin(String original, {required DateTime since}) { + final m = RegExp(r'^([a-zA-Z]):').firstMatch(original); + if (m == null) throw FileSystemException(tr('휴지통이 없는 곳입니다'), original); + if (FileSystemEntity.typeSync(original, followLinks: false) != FileSystemEntityType.notFound) { + throw FileSystemException(tr('원래 자리에 같은 이름이 이미 있습니다'), original); + } + // 원래 폴더가 그사이 지워졌으면 다시 만들지 않는다 (사용자가 지운 폴더를 몰래 되살리지 않게) + if (!Directory(p.dirname(original)).existsSync()) { + throw FileSystemException(tr('원래 폴더가 없어 되돌리지 못했습니다 (휴지통에서 직접 복원하세요)'), original); + } + // 이 사용자 (SID) 의 휴지통 폴더에서만 찾는다 (관리자 권한으로 다른 사용자의 휴지통을 읽을 수 있어도) + final sid = currentUserSid(); + if (sid == null) throw FileSystemException(tr('휴지통에서 찾지 못했습니다 (이미 비웠거나 되돌림)'), original); + File? info; + DateTime? newest; + final bin = Directory('${m[1]}:\\\$Recycle.Bin'); + try { + for (final user in [Directory('${bin.path}\\$sid')]) { + List items; + try { + items = user.listSync(followLinks: false); + } catch (_) { + continue; + } + for (final f in items.whereType()) { + if (!f.uri.pathSegments.last.startsWith(r'$I')) continue; + try { + final t = f.lastModifiedSync(); + if (t.isBefore(since) || (newest != null && t.isBefore(newest))) continue; + final was = recycledInfoPath(f.readAsBytesSync()); + if (was != null && was.toLowerCase() == original.toLowerCase()) { + info = f; + newest = t; + } + } catch (_) {} + } + } + } catch (_) {} + final i = info; + if (i == null) throw FileSystemException(tr('휴지통에서 찾지 못했습니다 (이미 비웠거나 되돌림)'), original); + final dir = i.parent.path; + final content = '$dir\\\$R${i.uri.pathSegments.last.substring(2)}'; + final type = FileSystemEntity.typeSync(content, followLinks: false); + if (type == FileSystemEntityType.notFound) throw FileSystemException(tr('휴지통에서 찾지 못했습니다 (이미 비웠거나 되돌림)'), original); + if (type == FileSystemEntityType.directory) { + Directory(content).renameSync(original); + } else { + File(content).renameSync(original); + } + try { + i.deleteSync(); + } catch (_) {} +} + /// 휴지통이 받을 수 있는 경로 길이 (MAX_PATH - 끝의 NUL) const recycleMaxPath = 259; diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 33e7fba..c5f5f1e 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -301,7 +301,7 @@ class _ExplorerPageState extends State { } for (var i = 0; i < 2; i++) { final pane = _panes[i]; - unawaited(_goTo(pane, wants[i], remember: false, focus: false).then((_) => _reveal(pane, pane.current))); + unawaited(_goTo(pane, wants[i], remember: false).then((_) => _reveal(pane, pane.current))); } } @@ -594,7 +594,9 @@ class _ExplorerPageState extends State { /// 그 폴더로: 저장 장치부터 그 폴더까지 펼치고 지금 폴더로 정한다 /// [fresh]: 경로 입력 · 찾기 · 내역으로 갈 때 - 그 폴더를 새로 읽는다 (앱 밖에서 바뀐 것까지 보이게, 122) - Future _goTo(_Pane pane, String dir, {bool remember = true, bool fresh = false, bool focus = true}) async { + /// 폴더로 간다. 그 폴더를 "고른 항목" 으로 잡지 않는다 (48 보충 · 관리자: 누르거나 표시하지 않은 폴더가 복사 · 이동 대상이 되지 않게). + /// 위치는 지금 폴더 표시 · 스크롤로 보인다. [focus] 는 그 폴더를 일부러 고를 때만 + Future _goTo(_Pane pane, String dir, {bool remember = true, bool fresh = false, bool focus = false}) async { // 124 · 128: 사용자가 WebDAV 로 간 것이면 마스터 창을 한 번 취소했어도 다시 묻는다 (켤 때 되살리는 것은 [remember] 없음) if (remember && isDav(dir)) await SecretGate.pass(force: true); if (!mounted) return; @@ -1331,12 +1333,16 @@ class _ExplorerPageState extends State { // 하나씩: 실패해도 나머지는 계속. 실제로 어떻게 됐는지 세어 그대로 알린다 (94) final failed = <(String, String)>[]; var recycled = 0, nuked = 0, kept = 0; + // 148: 휴지통으로 보낸 것 (되돌리기용) - 정보 파일 시각은 초 단위라 조금 앞에서부터 + final since = DateTime.now().subtract(const Duration(seconds: 2)); + final sent = []; for (final x in paths) { try { if (!permanent) { switch (moveToRecycleBin(x)) { case RecycleResult.recycled: recycled++; + sent.add(x); case RecycleResult.deletedPermanently: nuked++; case RecycleResult.cancelled: @@ -1370,13 +1376,19 @@ class _ExplorerPageState extends State { actions: [FilledButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('확인')))], ), ); - } else if (!permanent) { - _snack([ + } else if (!permanent && mounted) { + final text = [ if (recycled > 0) trf('{0}개 항목을 휴지통으로 보냈습니다.', [recycled]), // 휴지통보다 커서 Windows 가 물었고 사용자가 영구 삭제를 골랐다 if (nuked > 0) trf('{0}개는 휴지통에 들어가지 않아 영구 삭제했습니다 (Windows 가 묻고 고른 대로).', [nuked]), if (kept > 0) trf('{0}개는 지우지 않았습니다 (취소).', [kept]), - ].join(' ')); + ].join(' '); + // 148: 휴지통으로 보낸 것은 바로 [되돌리기] + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + duration: const Duration(seconds: 10), + content: Text(text), + action: sent.isEmpty ? null : SnackBarAction(label: tr('되돌리기'), onPressed: () => _undoRecycle(sent, since)), + )); } for (final x in _panes) { x.marked.removeAll(paths); @@ -1387,6 +1399,28 @@ class _ExplorerPageState extends State { await _refreshAll(paths.map(p.dirname)); } + /// 148: 방금 휴지통으로 보낸 것을 원래 자리로 (원래 자리에 같은 이름이 생겼으면 그것은 두고 알림) + Future _undoRecycle(List paths, DateTime since) async { + final failed = []; + var back = 0; + for (final x in paths) { + try { + restoreFromRecycleBin(x, since: since); + back++; + } catch (e) { + failed.add('${vBasename(x)}: ${e is FileSystemException ? e.message : e}'); + } + } + await _refreshAll(paths.map(p.dirname)); + for (final x in _panes) { + x.changed(); + } + _snack([ + if (back > 0) trf('{0}개 항목을 되돌렸습니다.', [back]), + if (failed.isNotEmpty) trf('되돌리지 못함: {0}', [failed.take(3).join(' · ')]), + ].join(' ')); + } + /// 다른 창의 지금 폴더로 복사 · 이동 (진행 창 · 취소) /// 복사 · 이동 버튼: 두 창이면 다른 창의 지금 폴더로, 그 밖의 배치는 담아 두기 (붙여넣기로) Future _transfer(_Pane pane, List sources, {required bool move}) async { diff --git a/test/explorer_conflict_test.dart b/test/explorer_conflict_test.dart index 44cbbee..5a430e2 100644 --- a/test/explorer_conflict_test.dart +++ b/test/explorer_conflict_test.dart @@ -103,6 +103,15 @@ void main() { expect(find.widgetWithText(FilledButton, '복사'), findsNothing, reason: '확인 창이 뜨지 않음'); expect(find.textContaining('복사할 항목을 고르세요'), findsOneWidget); expect(Directory(p.join(right, 'left')).existsSync(), isFalse); + // 상위 폴더로 가도 (사용자 이동) 그 폴더를 대상으로 잡지 않는다 + await tester.runAsync(() => tester.tap(find.text('상위 폴더').first)); + await settle(tester, () => false, rounds: 15); + ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); + await tester.pump(); + await tester.runAsync(() => tester.tap(find.text('복사').first)); + await tester.pump(); + expect(find.widgetWithText(FilledButton, '복사'), findsNothing); + expect(find.textContaining('복사할 항목을 고르세요'), findsOneWidget); }); testWidgets('52: 복사 중에 화면을 떠났다 돌아와도 진행 막대가 다시 보이고, 끝나면 받는 폴더를 새로 읽는다 (.jjpart 가 남아 보이지 않음)', (tester) async { @@ -132,6 +141,31 @@ void main() { expect(find.textContaining('.jjpart'), findsNothing); }); + testWidgets('148: 휴지통으로 보낸 뒤 알림의 [되돌리기] 를 누르면 원래 자리로', (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.pumpAndSettle(); + await tester.runAsync(() => tester.tap(find.widgetWithText(FilledButton, '휴지통으로'))); + await settle(tester, () => find.text('되돌리기').evaluate().isNotEmpty); + expect(File(p.join(left, 'doc.txt')).existsSync(), isFalse); + expect(find.textContaining('1개 항목을 휴지통으로 보냈습니다.'), findsOneWidget); + // 알림의 버튼은 시험의 가짜 시간 안에서 누른다 (알림 닫기 애니메이션이 시험 밖에서 돌지 않게) + await tester.tap(find.text('되돌리기')); + await tester.pump(); + await settle(tester, () => find.textContaining('되돌렸습니다').evaluate().isNotEmpty); + expect(File(p.join(left, 'doc.txt')).readAsStringSync(), 'new'); + expect(find.textContaining('1개 항목을 되돌렸습니다.'), findsOneWidget); + // 알림 (10초) 이 시험이 끝난 뒤에 닫히지 않게 정리 + ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); + await tester.pumpAndSettle(); + }, skip: !Platform.isWindows); + testWidgets('48: 건너뛰기를 고르면 그대로 두고 건너뛴 것을 알린다', (tester) async { await startCopy(tester); await tester.tap(find.text('건너뛰기')); diff --git a/test/recycle_bin_test.dart b/test/recycle_bin_test.dart index 97933e4..d9cafaf 100644 --- a/test/recycle_bin_test.dart +++ b/test/recycle_bin_test.dart @@ -53,6 +53,54 @@ void main() { } }, skip: !Platform.isWindows); + test('148: 휴지통으로 보낸 파일 · 폴더를 원래 자리로 되돌린다 (휴지통에 남지 않음) · 원래 자리에 같은 이름이 있으면 덮지 않음', () async { + final tmp = Directory.systemTemp.createTempSync('jj_undo_'); + addTearDown(() async { + await _purgeFromRecycleBin(tmp.absolute.path); // 실패해 남은 것이 있으면 사용자 휴지통에서 지운다 + if (tmp.existsSync()) tmp.deleteSync(recursive: true); + }); + final since = DateTime.now().subtract(const Duration(seconds: 2)); + final f = File(p.join(tmp.absolute.path, 'undo.txt'))..writeAsStringSync('keep me'); + final d = Directory(p.join(tmp.absolute.path, 'undo_dir'))..createSync(); + File(p.join(d.path, 'a.txt')).writeAsStringSync('a'); + expect(moveToRecycleBin(f.path), RecycleResult.recycled); + expect(moveToRecycleBin(d.path), RecycleResult.recycled); + expect(f.existsSync() || d.existsSync(), isFalse); + restoreFromRecycleBin(f.path, since: since); + restoreFromRecycleBin(d.path, since: since); + expect(f.readAsStringSync(), 'keep me'); + expect(File(p.join(d.path, 'a.txt')).readAsStringSync(), 'a'); + expect(recycledInfoExists(f.path, since: since), isFalse, reason: '휴지통에 남지 않음'); + // 원래 자리에 같은 이름이 생겼으면 덮지 않고 실패 (휴지통의 것은 그대로) + expect(moveToRecycleBin(f.path), RecycleResult.recycled); + f.writeAsStringSync('new one'); + expect(() => restoreFromRecycleBin(f.path, since: since), throwsA(isA())); + expect(f.readAsStringSync(), 'new one'); + expect(recycledInfoExists(f.path, since: since), isTrue); + // 휴지통에 없으면 실패 + expect(() => restoreFromRecycleBin(p.join(tmp.absolute.path, 'never.txt'), since: since), throwsA(isA())); + }, skip: !Platform.isWindows); + + test('148: 원래 폴더가 그사이 지워졌으면 다시 만들지 않고 알린다 · 이 사용자 (SID) 의 휴지통에서만 찾는다', () async { + expect(currentUserSid(), matches(RegExp(r'^S-1-5-'))); + final tmp = Directory.systemTemp.createTempSync('jj_undo_'); + addTearDown(() async { + await _purgeFromRecycleBin(tmp.absolute.path); + 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(); // 사용자가 그 폴더를 지움 + expect( + () => restoreFromRecycleBin(f.path, since: since), + throwsA(isA() + .having((e) => e.message, 'message', contains('원래 폴더가 없어 되돌리지 못했습니다')))); + expect(folder.existsSync(), isFalse, reason: '지운 폴더를 몰래 다시 만들지 않음'); + expect(recycledInfoExists(f.path, since: since), isTrue, reason: '휴지통의 것은 그대로'); + }, skip: !Platform.isWindows); + test('긴 경로 (260자 넘음) 는 휴지통으로 보내지 않고 이유를 알린다 - 묻지 않고 영구 삭제되던 것 (폴더 안에 있어도)', () { if (!Platform.isWindows) return; // 긴 경로를 만들고 지우려면 앞에 \\?\ 를 붙인다