From 60d4db75833b245e4c97d830849638044ecfb205 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:13:21 +0900 Subject: [PATCH] =?UTF-8?q?WIP=2079=20=EB=B3=B4=EC=B6=A9=20(15=20=EC=A7=80?= =?UTF-8?q?=EC=A0=81=20=C2=B7=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=ED=8C=90?= =?UTF-8?q?=EB=8B=A8=20a~d):=20=ED=8F=B4=EB=8D=94=EC=99=80=20=EC=95=88?= =?UTF-8?q?=EC=9D=98=20=ED=8C=8C=EC=9D=BC=EC=9D=84=20=ED=95=A8=EA=BB=98=20?= =?UTF-8?q?=EA=B3=A8=EB=9D=BC=EB=8F=84=20=ED=95=9C=20=EB=B2=88=EB=A7=8C=20?= =?UTF-8?q?=C2=B7=20=ED=95=A9=EA=B3=84=EC=97=90=20=ED=8F=B4=EB=8D=94=20?= =?UTF-8?q?=EB=94=B0=EB=A1=9C=20=C2=B7=20[=EC=B0=BE=EA=B8=B0=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=EB=A1=9C=20=EB=8F=8C=EC=95=84=EA=B0=80=EA=B8=B0]=20?= =?UTF-8?q?=C2=B7=20"=EC=A7=80=EA=B8=88=EA=B9=8C=EC=A7=80=20=EC=B0=BE?= =?UTF-8?q?=EC=9D=80=20n=EA=B0=9C=20=EB=AA=A8=EB=91=90"=20(=EA=B2=80?= =?UTF-8?q?=ED=86=A0=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit c (데이터) 찾기 결과에서 폴더와 그 안의 파일을 함께 고르면 안의 것은 빼고 바깥 것만 (outermostPaths) - 안쪽 파일을 두 번 처리하거나 이동 뒤 "없음" 오류가 나고 확인 창 개수가 부풀 수 있었다. 탐색기의 복사 · 이동 · 지우기 시작에서도 같은 정리를 한다 (탐색기 표시는 이미 정리돼 있어 동작은 같음). b 아래 줄 합계: "파일 n개 · 크기 / 폴더 m개 (크기 미포함)". 지우기 확인 창에 "그중 폴더 m개 - 안의 파일 · 폴더도 모두 함께". a 복사 · 이동 · 지우기 뒤 결과 화면은 닫힌 채로, 탐색기 위에 [찾기 결과로 돌아가기] 줄 (✕ 로 닫기). 다시 찾지 않고 결과를 열며, 지운 것은 빼고 옮긴 것은 새 자리로 (고른 폴더와 함께 옮겨진 안쪽 것도). 다음 찾기 · ✕ · 결과 화면을 그냥 닫으면 없어진다. 처음 찾기가 끝나기 전 결과였으면 그렇다고 적는다. - 내 판단: [그 자리로] (reveal) 로 나가면 돌아가기 줄을 남기지 않는다 (탐색이라서). d 찾는 중 전체 고르기 단추 글: "지금까지 찾은 n개 모두", 다 찾으면 "모두". - 시험: explorer_test 79-c 복사 · 이동 · 지우기 (한 번만 · 오류 없음 · 확인 창 개수 · 돌아가기), explorer_search_test (outermostPaths · 단추 글 · 합계). Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 8 +++ assets/l10n/ja.json | 8 +++ assets/l10n/zh-Hans.json | 8 +++ lib/core/file_ops.dart | 14 +++++ lib/ui/explorer_page.dart | 100 ++++++++++++++++++++++++++++++++- lib/ui/explorer_search.dart | 59 ++++++++++++++++--- test/explorer_search_test.dart | 14 ++++- test/explorer_test.dart | 64 +++++++++++++++++++++ 8 files changed, 262 insertions(+), 13 deletions(-) diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 0a95170..0ed77c7 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -665,6 +665,7 @@ "그림을 페이지로 넣기 (이 페이지 뒤)": "Add images as pages (after this page)", "그림이 거의 같고 마지막 단계가 10배쯤 빠릅니다 (받아 두었을 때)": "Almost the same image, and the last step is about 10× faster (when downloaded)", "그중 {0}개는 받는 쪽이 더 새것입니다 - 더 새 파일을 예전 것으로 덮어씁니다": "{0} of them are newer on the receiving side - the newer files will be overwritten with older ones", + "그중 폴더 {0}개 - 안의 파일 · 폴더도 모두 함께": "Including {0} folders - everything inside them too", "글 → 그림 · 그림 → 그림 (기기 안에서, 모델은 환경 설정에서 받기)": "Text → image · image → image (on this device; download models in Settings)", "글을 격자에 반영": "Apply text to grid", "글자 · 버튼 크기입니다. 위쪽 막대의 − · + 로 그때그때 바꿀 수 있고, 가운데 숫자를 누르면 이 크기로 돌아갑니다 (지금 {0}%)": "Text · button size. Change it any time with − · + on the top bar; press the number in the middle to return to this size (now {0}%)", @@ -2043,6 +2044,7 @@ "지금 폴더에서 이름에 든 글자로 좁히기 (* · ? 패턴도 됨)": "Narrow this folder by text in the name (* and ? patterns work)", "지금 확인": "Check now", "지금: {0}": "Now: {0}", + "지금까지 찾은 {0}개 모두": "All {0} found so far", "지금은 듀얼 앱 (복제한 앱) 입니다. Android 가 듀얼 앱에는 이 권한을 주지 않을 수 있습니다. 허용 화면에서 켤 수 없으면 배지 없는 원래 JJ_MKVMaker 아이콘으로 여세요.": "This is the Dual App (cloned app). Android may not grant this permission to dual apps. If you can't turn it on, open the original JJ_MKVMaker icon without the badge.", "지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "The last run did not end normally (last check {0}:{1}). It has been noted in the job log.", "지난 작업 기록": "Old job logs", @@ -2093,7 +2095,10 @@ "창을 닫아도 (✕) 트레이에서 동기화 · MKV 만들기 · 다운로드를 계속합니다. 끄면 닫을 때 모두 끝냅니다. (실행 · 종료의 \"종료를 누르면\" 과 같은 설정)": "Closing the window (✕) keeps sync · MKV creation · downloads running from the tray. When off, closing ends everything. (Same setting as \"When closing\" under Run · Exit)", "창을 트레이에 둔 채 실시간 동기화가 원본을 못 읽어 멈추면 알림을 띄웁니다 (트레이 툴팁 경고는 늘)": "Shows a notification when live sync stops because it can't read the source while the window is in the tray (the tray tooltip warning is always on)", "찾기": "Search", + "찾기 결과 ({0}) · {1}": "Search results ({0}) · {1}", "찾기 결과 · {0}개": "Search results · {0}", + "찾기 결과 닫기": "Close search results", + "찾기 결과로 돌아가기": "Back to search results", "찾기: {0}": "Search: {0}", "찾는 즐겨찾기가 없습니다.": "No matching bookmarks.", "찾은 것이 없습니다.": "Nothing found.", @@ -2108,6 +2113,7 @@ "처음 시작 시": "At app start", "처음 쓸 때 내려받기 (기본)": "Download on first use (default)", "처음 아이콘 (파랑)": "Original icon (blue)", + "처음 찾을 때 끝까지 찾기 전의 결과입니다. 더 찾으려면 다시 찾으세요.": "These results are from before the search finished. Search again to find more.", "처음대로": "Reset to default", "처음부터 정하려면 AI 자막 창의 \"원어 (영상 속 언어)\" 에서 고르세요.": "To set it from the start, choose it in \"Source language (spoken in the video)\" in the AI subtitles window.", "처음으로: {0}": "Back to the first: {0}", @@ -2189,6 +2195,7 @@ "트레이의 JJ_MKVMaker 아이콘을 누르면 열립니다.": "Click the JJ_MKVMaker icon in the tray to open it.", "파일": "File", "파일 {0}/{1}": "Files {0}/{1}", + "파일 {0}개 · {1}": "{0} files · {1}", "파일 관리자에서 보기": "Show in file manager", "파일 복사 · 이동 방법": "File copy · move method", "파일 세는 중…": "Counting files…", @@ -2238,6 +2245,7 @@ "폰 세로 목록 크기": "List size (portrait phone)", "폴더": "Folder", "폴더 + 파일 목록": "Folders + file list", + "폴더 {0}개 (크기 미포함)": "{0} folders (size not included)", "폴더 {0}개 (크기 빼고)": "{0} folders (size not counted)", "폴더 {0}개는 빼고 파일만 보냈습니다.": "Sent files only ({0} folders left out).", "폴더 고르기": "Choose folder", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 0242a22..b36ee2a 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -665,6 +665,7 @@ "그림을 페이지로 넣기 (이 페이지 뒤)": "画像をページとして追加 (このページの後)", "그림이 거의 같고 마지막 단계가 10배쯤 빠릅니다 (받아 두었을 때)": "画像はほぼ同じで、最後の段階が約 10 倍速くなります (ダウンロード済みの場合)", "그중 {0}개는 받는 쪽이 더 새것입니다 - 더 새 파일을 예전 것으로 덮어씁니다": "そのうち {0} 件は受け取る側の方が新しいです - 新しいファイルを古いもので上書きします", + "그중 폴더 {0}개 - 안의 파일 · 폴더도 모두 함께": "うちフォルダー {0} 件 - 中のファイル・フォルダーもすべて一緒に", "글 → 그림 · 그림 → 그림 (기기 안에서, 모델은 환경 설정에서 받기)": "テキスト → 画像 · 画像 → 画像 (端末内で。モデルは設定でダウンロード)", "글을 격자에 반영": "テキストをグリッドに反映", "글자 · 버튼 크기입니다. 위쪽 막대의 − · + 로 그때그때 바꿀 수 있고, 가운데 숫자를 누르면 이 크기로 돌아갑니다 (지금 {0}%)": "文字・ボタンの大きさです。上部バーの − ・ + でその都度変更でき、中央の数字を押すとこの大きさに戻ります(現在 {0}%)", @@ -2043,6 +2044,7 @@ "지금 폴더에서 이름에 든 글자로 좁히기 (* · ? 패턴도 됨)": "このフォルダーを名前に含む文字で絞り込み (* · ? パターンも可)", "지금 확인": "今すぐ確認", "지금: {0}": "現在: {0}", + "지금까지 찾은 {0}개 모두": "これまでに見つかった {0} 件すべて", "지금은 듀얼 앱 (복제한 앱) 입니다. Android 가 듀얼 앱에는 이 권한을 주지 않을 수 있습니다. 허용 화면에서 켤 수 없으면 배지 없는 원래 JJ_MKVMaker 아이콘으로 여세요.": "これはデュアルアプリ (複製したアプリ) です。Android はデュアルアプリにこの権限を与えない場合があります。許可画面でオンにできない場合は、バッジのない元の JJ_MKVMaker アイコンから開いてください。", "지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "前回の実行が正常に終了しませんでした(最終確認 {0}:{1})。作業記録に残しました。", "지난 작업 기록": "過去の作業記録", @@ -2093,7 +2095,10 @@ "창을 닫아도 (✕) 트레이에서 동기화 · MKV 만들기 · 다운로드를 계속합니다. 끄면 닫을 때 모두 끝냅니다. (실행 · 종료의 \"종료를 누르면\" 과 같은 설정)": "ウィンドウを閉じても (✕) トレイで同期 · MKV 作成 · ダウンロードを続けます。オフにすると閉じたときにすべて終了します。(実行 · 終了の「終了を押したら」と同じ設定)", "창을 트레이에 둔 채 실시간 동기화가 원본을 못 읽어 멈추면 알림을 띄웁니다 (트레이 툴팁 경고는 늘)": "ウィンドウをトレイに置いたままリアルタイム同期が元を読めずに止まったら通知します (トレイのツールチップ警告は常に表示)", "찾기": "検索", + "찾기 결과 ({0}) · {1}": "検索結果 ({0}) · {1}", "찾기 결과 · {0}개": "検索結果 · {0} 件", + "찾기 결과 닫기": "検索結果を閉じる", + "찾기 결과로 돌아가기": "検索結果に戻る", "찾기: {0}": "検索: {0}", "찾는 즐겨찾기가 없습니다.": "該当するブックマークがありません。", "찾은 것이 없습니다.": "見つかりませんでした。", @@ -2108,6 +2113,7 @@ "처음 시작 시": "起動時", "처음 쓸 때 내려받기 (기본)": "初めて使うときにダウンロード (既定)", "처음 아이콘 (파랑)": "最初のアイコン(青)", + "처음 찾을 때 끝까지 찾기 전의 결과입니다. 더 찾으려면 다시 찾으세요.": "最初の検索が終わる前の結果です。さらに探すには検索し直してください。", "처음대로": "既定に戻す", "처음부터 정하려면 AI 자막 창의 \"원어 (영상 속 언어)\" 에서 고르세요.": "最初から決めるには、AI 字幕ウィンドウの「原語(動画内の言語)」で選んでください。", "처음으로: {0}": "最初へ: {0}", @@ -2189,6 +2195,7 @@ "트레이의 JJ_MKVMaker 아이콘을 누르면 열립니다.": "トレイの JJ_MKVMaker アイコンをクリックすると開きます。", "파일": "ファイル", "파일 {0}/{1}": "ファイル {0}/{1}", + "파일 {0}개 · {1}": "ファイル {0} 件 · {1}", "파일 관리자에서 보기": "ファイルマネージャーで表示", "파일 복사 · 이동 방법": "ファイルのコピー · 移動方法", "파일 세는 중…": "ファイルを数えています…", @@ -2238,6 +2245,7 @@ "폰 세로 목록 크기": "縦向きスマホのリストの大きさ", "폴더": "フォルダー", "폴더 + 파일 목록": "フォルダー + ファイル一覧", + "폴더 {0}개 (크기 미포함)": "フォルダー {0} 件 (サイズは含まない)", "폴더 {0}개 (크기 빼고)": "フォルダー {0} 件 (サイズ除く)", "폴더 {0}개는 빼고 파일만 보냈습니다.": "ファイルだけ送りました (フォルダー {0} 件は除外)。", "폴더 고르기": "フォルダーを選択", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 1d926f0..4a3f734 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -665,6 +665,7 @@ "그림을 페이지로 넣기 (이 페이지 뒤)": "将图片作为页面加入 (本页之后)", "그림이 거의 같고 마지막 단계가 10배쯤 빠릅니다 (받아 두었을 때)": "图片几乎相同,最后一步快约 10 倍 (已下载时)", "그중 {0}개는 받는 쪽이 더 새것입니다 - 더 새 파일을 예전 것으로 덮어씁니다": "其中 {0} 个在接收端较新 - 将用旧文件覆盖较新的文件", + "그중 폴더 {0}개 - 안의 파일 · 폴더도 모두 함께": "其中 {0} 个文件夹 - 其中的文件和文件夹也一并处理", "글 → 그림 · 그림 → 그림 (기기 안에서, 모델은 환경 설정에서 받기)": "文字 → 图片 · 图片 → 图片(在本设备上;模型在设置中下载)", "글을 격자에 반영": "将文本应用到网格", "글자 · 버튼 크기입니다. 위쪽 막대의 − · + 로 그때그때 바꿀 수 있고, 가운데 숫자를 누르면 이 크기로 돌아갑니다 (지금 {0}%)": "文字 · 按钮大小。可随时用顶部栏的 − · + 调整,点击中间的数字恢复为此大小(当前 {0}%)", @@ -2043,6 +2044,7 @@ "지금 폴더에서 이름에 든 글자로 좁히기 (* · ? 패턴도 됨)": "按名称中的文字筛选当前文件夹 (也可用 * · ? 模式)", "지금 확인": "立即检查", "지금: {0}": "当前: {0}", + "지금까지 찾은 {0}개 모두": "目前找到的全部 {0} 个", "지금은 듀얼 앱 (복제한 앱) 입니다. Android 가 듀얼 앱에는 이 권한을 주지 않을 수 있습니다. 허용 화면에서 켤 수 없으면 배지 없는 원래 JJ_MKVMaker 아이콘으로 여세요.": "当前是双开应用 (复制的应用)。Android 可能不会给双开应用此权限。如果无法开启,请从没有徽标的原 JJ_MKVMaker 图标打开。", "지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "上次运行未正常结束(最后检查 {0}:{1})。已记录在任务记录中。", "지난 작업 기록": "旧任务记录", @@ -2093,7 +2095,10 @@ "창을 닫아도 (✕) 트레이에서 동기화 · MKV 만들기 · 다운로드를 계속합니다. 끄면 닫을 때 모두 끝냅니다. (실행 · 종료의 \"종료를 누르면\" 과 같은 설정)": "关闭窗口 (✕) 后在托盘中继续同步 · 制作 MKV · 下载。关闭此项则关闭时全部结束。(与 运行 · 退出 中的“按退出时”为同一设置)", "창을 트레이에 둔 채 실시간 동기화가 원본을 못 읽어 멈추면 알림을 띄웁니다 (트레이 툴팁 경고는 늘)": "窗口在托盘中时,若实时同步因无法读取源而停止则显示通知 (托盘提示警告始终显示)", "찾기": "搜索", + "찾기 결과 ({0}) · {1}": "搜索结果 ({0}) · {1}", "찾기 결과 · {0}개": "搜索结果 · {0} 个", + "찾기 결과 닫기": "关闭搜索结果", + "찾기 결과로 돌아가기": "返回搜索结果", "찾기: {0}": "搜索: {0}", "찾는 즐겨찾기가 없습니다.": "没有匹配的书签。", "찾은 것이 없습니다.": "未找到。", @@ -2108,6 +2113,7 @@ "처음 시작 시": "启动时", "처음 쓸 때 내려받기 (기본)": "首次使用时下载 (默认)", "처음 아이콘 (파랑)": "最初的图标(蓝色)", + "처음 찾을 때 끝까지 찾기 전의 결과입니다. 더 찾으려면 다시 찾으세요.": "这些是首次搜索完成前的结果。如需查找更多,请重新搜索。", "처음대로": "恢复默认", "처음부터 정하려면 AI 자막 창의 \"원어 (영상 속 언어)\" 에서 고르세요.": "如需一开始就指定,请在 AI 字幕窗口的“原语言(视频中的语言)”中选择。", "처음으로: {0}": "回到第一个: {0}", @@ -2189,6 +2195,7 @@ "트레이의 JJ_MKVMaker 아이콘을 누르면 열립니다.": "点击托盘中的 JJ_MKVMaker 图标即可打开。", "파일": "文件", "파일 {0}/{1}": "文件 {0}/{1}", + "파일 {0}개 · {1}": "{0} 个文件 · {1}", "파일 관리자에서 보기": "在文件管理器中显示", "파일 복사 · 이동 방법": "文件复制 · 移动方式", "파일 세는 중…": "正在统计文件…", @@ -2238,6 +2245,7 @@ "폰 세로 목록 크기": "竖屏手机列表大小", "폴더": "文件夹", "폴더 + 파일 목록": "文件夹 + 文件列表", + "폴더 {0}개 (크기 미포함)": "{0} 个文件夹 (不含大小)", "폴더 {0}개 (크기 빼고)": "{0} 个文件夹(不计大小)", "폴더 {0}개는 빼고 파일만 보냈습니다.": "只发送了文件 (排除了 {0} 个文件夹)。", "폴더 고르기": "选择文件夹", diff --git a/lib/core/file_ops.dart b/lib/core/file_ops.dart index 42f11c8..407a5f0 100644 --- a/lib/core/file_ops.dart +++ b/lib/core/file_ops.dart @@ -147,6 +147,20 @@ bool isSameOrInside(String a, String b) { return samePath(a, b) || p.isWithin(y, x) || (Platform.isWindows && p.isWithin(y.toLowerCase(), x.toLowerCase())); } +/// 79-c: 고른 것 중 다른 고른 폴더 안에 있는 것 · 같은 것을 빼고 바깥 것만 (차례는 그대로). +/// 폴더와 그 안의 파일을 함께 고르면 안의 파일을 두 번 처리하거나 (이동 뒤 "없음" 오류) 확인 창 개수가 부풀었다 +List outermostPaths(Iterable paths) { + final all = paths.toList(); + final out = []; + for (var i = 0; i < all.length; i++) { + final x = all[i]; + if (out.any((o) => samePath(o, x))) continue; + if (all.any((o) => !samePath(o, x) && isSameOrInside(x, o))) continue; + out.add(x); + } + return out; +} + /// 복사 · 이동을 시작하기 전 확인 (모든 방법 공통 - rsync · robocopy 는 스스로 막지 않는다). /// 문제가 있으면 알릴 글, 없으면 null. /// - 원본이 없음 (다른 곳에서 지워졌거나 옮겨짐) · 대상 폴더가 없음 diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 9db25ee..b9c6aa9 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -1718,6 +1718,7 @@ class _ExplorerPageState extends State { /// 지우기. Windows 의 로컬 파일은 기본으로 휴지통으로 (창에서 "영구 삭제" 를 고르거나 Shift+Delete). /// WebDAV · Android 는 휴지통이 없어 바로 지운다. 여러 개면 실패해도 나머지를 계속하고, 못 지운 것을 알린다 (65). Future _delete(_Pane pane, List paths) async { + paths = outermostPaths(paths); // 79-c: 고른 폴더 안의 것은 한 번만 (확인 창 개수도) final shift = _shiftDelete; _shiftDelete = false; if (paths.isEmpty) { @@ -1747,6 +1748,13 @@ class _ExplorerPageState extends State { if (paths.length > 8) trf('… 외 {0}개', [paths.length - 8]), {for (final x in paths) vDirname(x)}.join('\n'), ].join('\n')), + // 79-b: 폴더가 있으면 몇 개인지 (안의 것도 함께 지워진다) + if (paths.any(vIsDirSync)) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text(trf('그중 폴더 {0}개 - 안의 파일 · 폴더도 모두 함께', [paths.where(vIsDirSync).length]), + style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)), + ), if (local && !canRecycle) Padding( padding: const EdgeInsets.only(top: 8), @@ -1905,6 +1913,7 @@ class _ExplorerPageState extends State { /// 다른 창의 지금 폴더로 복사 · 이동 (진행 창 · 취소) /// 복사 · 이동 버튼: 두 창이면 다른 창의 지금 폴더로, 그 밖의 배치는 담아 두기 (붙여넣기로) Future _transfer(_Pane pane, List sources, {required bool move}) async { + sources = outermostPaths(sources); // 79-c: 고른 폴더 안의 것은 한 번만 final other = _other; if (other == null) { _toClipboard(sources, move: move); @@ -2676,24 +2685,82 @@ class _ExplorerPageState extends State { /// 79: 마지막 찾기 조건 (이번 실행 동안 다음 찾기 창의 처음 값) SearchConditions _lastSearch = const SearchConditions(); + /// 79-a: 결과 화면에서 복사 · 이동 · 지우기를 한 뒤 [찾기 결과로 돌아가기] 로 다시 열 결과 (다음 찾기 · ✕ · 결과 화면을 그냥 닫으면 없어짐) + ({_Pane pane, String root, SearchConditions cond, List found, bool done, Map moved})? _searchBack; + /// 79: 찾기 - 이름 · 크기 · 바뀐 날 · 종류 조건 → 결과 목록 화면 (고르고 그대로 복사 · 이동 · 지우기, 누르면 열기 · 재생) Future _search(_Pane pane) async { setState(() => _active = _panes.indexOf(pane)); final cond = await askSearchConditions(context, pane.current, _lastSearch); if (cond == null || !mounted) return; _lastSearch = cond; + setState(() => _searchBack = null); + await _searchPage(pane, pane.current, cond); + } + + /// 79-a: [찾기 결과로 돌아가기] - 다시 찾지 않고, 그사이 지운 것은 빼고 옮긴 것은 새 자리로 + Future _backToSearch() async { + final back = _searchBack; + if (back == null) return; + final now = []; + for (final e in back.found) { + if (await vExists(e.path)) { + now.add(e); + continue; + } + final dest = back.moved[e.path]; + final there = dest == null ? null : vJoin(dest, vBasename(e.path)); + if (there != null && await vExists(there)) { + now.add(FileEntry(there, isDir: e.isDir, size: e.size, modified: e.modified)); + } + // 고른 폴더와 함께 옮겨진 안쪽 것: 옮긴 폴더 아래 같은 자리 + else { + for (final entry in back.moved.entries) { + if (!isSameOrInside(e.path, entry.key)) continue; + // 옮긴 폴더 이름 아래 남은 경로 (같은 꼴의 경로라 앞부분을 잘라 쓴다 - WebDAV 도) + final rest = e.path.length > entry.key.length ? e.path.substring(entry.key.length + 1) : ''; + final moved = rest.isEmpty ? vJoin(entry.value, vBasename(entry.key)) : vJoin(vJoin(entry.value, vBasename(entry.key)), rest); + if (await vExists(moved)) now.add(FileEntry(moved, isDir: e.isDir, size: e.size, modified: e.modified)); + break; + } + } + } + if (!mounted) return; + await _searchPage(back.pane, back.root, back.cond, initial: now, initialDone: back.done); + } + + Future _searchPage(_Pane pane, String root, SearchConditions cond, {List? initial, bool initialDone = true}) async { + final other = _other?.current; final act = await Navigator.of(context).push(MaterialPageRoute( builder: (_) => SearchResultsPage( - root: pane.current, + root: root, cond: cond, showHidden: c.settings.explorerShowHidden, - otherDir: _other?.current, + otherDir: other, onOpen: (e) => _open(e.path, pane: pane), + initial: initial, + initialDone: initialDone, ), )); - if (act == null || act.paths.isEmpty || !mounted) return; + if (!mounted) return; + // 결과 화면을 그냥 닫으면 돌아갈 결과도 없앤다 + if (act == null || act.paths.isEmpty) { + setState(() => _searchBack = null); + return; + } + if (act.kind != 'reveal') { + setState(() => _searchBack = ( + pane: pane, + root: root, + cond: cond, + found: act.found, + done: act.done, + moved: act.kind == 'move' && other != null ? {for (final x in act.paths) x: other} : const {}, + )); + } switch (act.kind) { case 'reveal': + setState(() => _searchBack = null); final path = act.paths.first; final dir = await vIsDir(path); await _goTo(pane, dir ? path : vDirname(path), fresh: true); @@ -2710,6 +2777,32 @@ class _ExplorerPageState extends State { } } + /// 79-a: 탐색기 위의 [찾기 결과로 돌아가기] 줄 + Widget _searchBackBar() { + final back = _searchBack!; + return Material( + color: JjColors.panelHigh, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row(children: [ + const Icon(Icons.manage_search, size: 18, color: JjColors.accent), + const SizedBox(width: 6), + Expanded( + child: Text(trf('찾기 결과 ({0}) · {1}', [back.cond.summary, back.root]), + maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: JjColors.textDim)), + ), + TextButton(onPressed: _backToSearch, child: Text(tr('찾기 결과로 돌아가기'))), + IconButton( + tooltip: tr('찾기 결과 닫기'), + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.close, size: 16), + onPressed: () => setState(() => _searchBack = null), + ), + ]), + ), + ); + } + Future _sortDialog() async { final s = c.settings; var by = s.explorerSort; @@ -3022,6 +3115,7 @@ class _ExplorerPageState extends State { _dropRemovedServers(); return Column(children: [ _topBar(), + if (_searchBack != null) _searchBackBar(), Expanded( child: Stack(children: [ Positioned.fill(child: _ready ? _body() : const Center(child: CircularProgressIndicator())), diff --git a/lib/ui/explorer_search.dart b/lib/ui/explorer_search.dart index 9f7c49f..70a7190 100644 --- a/lib/ui/explorer_search.dart +++ b/lib/ui/explorer_search.dart @@ -241,7 +241,11 @@ class SearchAction { /// 'reveal' (그 자리로) · 'copy' · 'move' · 'delete' final String kind; final List paths; - const SearchAction(this.kind, this.paths); + + /// 79-a: 그때까지 찾은 것 · 다 찾았는지 (탐색기의 [찾기 결과로 돌아가기] 에 쓴다) + final List found; + final bool done; + const SearchAction(this.kind, this.paths, {this.found = const [], this.done = true}); } /// 79: 찾기 결과를 목록으로 - 고르고 그대로 복사 · 이동 · 지우기, 누르면 열기 · 재생 (화면은 그대로). @@ -260,6 +264,10 @@ class SearchResultsPage extends StatefulWidget { /// 한 번에 보여 줄 개수 (멈추고 [더 보기]) final int chunk; + /// 79-a: [찾기 결과로 돌아가기] - 다시 찾지 않고 이 목록을 보인다 ([initialDone] 이 거짓이면 그때 다 찾지 못한 것) + final List? initial; + final bool initialDone; + const SearchResultsPage({ super.key, required this.root, @@ -268,6 +276,8 @@ class SearchResultsPage extends StatefulWidget { required this.onOpen, this.otherDir, this.chunk = 2000, + this.initial, + this.initialDone = true, }); @override @@ -289,6 +299,11 @@ class _SearchResultsPageState extends State { @override void initState() { super.initState(); + if (widget.initial != null) { + _found.addAll(widget.initial!); + _done = true; + return; + } final c = widget.cond; _sub = searchFiles(widget.root, c.name, showHidden: widget.showHidden, @@ -330,9 +345,24 @@ class _SearchResultsPageState extends State { _sub?.resume(); } - int get _markedBytes => _found.where((e) => _marked.contains(e.path) && !e.isDir).fold(0, (a, e) => a + e.size); + /// 79-c: 고른 것 중 바깥 것만 (고른 폴더 안의 파일은 그 폴더와 함께 한 번만 처리) + List get _targets { + final keep = outermostPaths([for (final e in _found) if (_marked.contains(e.path)) e.path]).toSet(); + return [for (final e in _found) if (keep.contains(e.path)) e]; + } - void _act(String kind) => Navigator.pop(context, SearchAction(kind, [for (final e in _found) if (_marked.contains(e.path)) e.path])); + /// 79-b: 아래 줄 - "파일 n개 · 크기", 폴더가 있으면 "폴더 m개 (크기 미포함)" 를 따로 (합계가 실제보다 작아 보이지 않게) + String _summary(List t) { + final files = t.where((e) => !e.isDir).toList(); + final dirs = t.length - files.length; + return [ + if (files.isNotEmpty) trf('파일 {0}개 · {1}', [files.length, formatSize(files.fold(0, (a, e) => a + e.size))]), + if (dirs > 0) trf('폴더 {0}개 (크기 미포함)', [dirs]), + ].join(' / '); + } + + void _act(String kind) => Navigator.pop( + context, SearchAction(kind, [for (final e in _targets) e.path], found: List.of(_found), done: _done && widget.initialDone && !_paused)); @override Widget build(BuildContext context) { @@ -348,10 +378,15 @@ class _SearchResultsPageState extends State { padding: EdgeInsets.symmetric(horizontal: 12), child: Center(child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))), ), + // 79-d: 찾는 중에는 "지금까지 찾은 n개 모두" (뒤에 나올 것은 고르지 않는다는 것이 보이게), 다 찾으면 "모두" if (_found.isNotEmpty) - IconButton( - tooltip: allMarked ? tr('모두 풀기') : tr('모두 고르기'), - icon: Icon(allMarked ? Icons.deselect : Icons.select_all), + TextButton.icon( + icon: Icon(allMarked ? Icons.deselect : Icons.select_all, size: 18), + label: Text(allMarked + ? tr('모두 풀기') + : _done + ? tr('모두') + : trf('지금까지 찾은 {0}개 모두', [_found.length])), onPressed: () => setState(() => allMarked ? _marked.clear() : _marked.addAll(_found.map((e) => e.path))), ), ], @@ -394,19 +429,25 @@ class _SearchResultsPageState extends State { trailing: IconButton( tooltip: tr('그 자리로'), icon: const Icon(Icons.drive_file_move_outline, size: 20), - onPressed: () => Navigator.pop(context, SearchAction('reveal', [e.path])), + onPressed: () => Navigator.pop(context, SearchAction('reveal', [e.path], found: List.of(_found), done: _done && widget.initialDone && !_paused)), ), // 고르는 중이면 누르기 = 고르기, 아니면 파일은 열기 · 재생 (화면은 그대로), 폴더는 그 폴더로 onTap: any ? () => setState(() => on ? _marked.remove(e.path) : _marked.add(e.path)) : e.isDir - ? () => Navigator.pop(context, SearchAction('reveal', [e.path])) + ? () => Navigator.pop(context, SearchAction('reveal', [e.path], found: List.of(_found), done: _done && widget.initialDone && !_paused)) : () => widget.onOpen(e), onLongPress: () => setState(() => on ? _marked.remove(e.path) : _marked.add(e.path)), ); }, ), ), + if (widget.initial != null && !widget.initialDone) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: Text(tr('처음 찾을 때 끝까지 찾기 전의 결과입니다. 더 찾으려면 다시 찾으세요.'), + style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)), + ), if (_paused) Padding( padding: const EdgeInsets.symmetric(vertical: 4), @@ -440,7 +481,7 @@ class _SearchResultsPageState extends State { decoration: const BoxDecoration(border: Border(top: BorderSide(color: JjColors.border))), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: Wrap(spacing: 4, runSpacing: 2, crossAxisAlignment: WrapCrossAlignment.center, children: [ - Text(trf('{0}개 · {1}', [_marked.length, formatSize(_markedBytes)]), style: const TextStyle(fontSize: 12)), + Text(_summary(_targets), style: const TextStyle(fontSize: 12)), TextButton.icon( onPressed: () => _act('copy'), icon: const Icon(Icons.copy, size: 18), diff --git a/test/explorer_search_test.dart b/test/explorer_search_test.dart index 1521132..1ca7a4f 100644 --- a/test/explorer_search_test.dart +++ b/test/explorer_search_test.dart @@ -34,6 +34,15 @@ void main() { expect(const SearchConditions().isEmpty, isTrue); }); + test('79-c outermostPaths: 고른 폴더 안의 것 · 같은 것을 빼고 차례는 그대로', () { + final a = p.join('C:', 'x', 'a'); + final inA = p.join(a, 'f.txt'); + final b2 = p.join('C:', 'x', 'b.txt'); + expect(outermostPaths([inA, b2, a, a]), [b2, a]); + expect(outermostPaths([b2]), [b2]); + expect(outermostPaths([p.join('C:', 'x', 'ab'), a]), [p.join('C:', 'x', 'ab'), a], reason: '이름 앞부분만 같은 것은 안이 아님'); + }); + group('searchFiles + accept', () { late Directory tmp; setUp(() { @@ -97,6 +106,8 @@ void main() { await show(tester, chunk: 2); expect(find.text('찾기 결과 · 2개'), findsOneWidget); expect(find.text('2개에서 멈췄습니다'), findsOneWidget); + // 79-d: 다 찾기 전에는 "지금까지 찾은 n개 모두" + expect(find.text('지금까지 찾은 2개 모두'), findsOneWidget); await tester.tap(find.text('2개 더 보기')); for (var i = 0; i < 20; i++) { await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 20))); @@ -110,6 +121,7 @@ void main() { } expect(find.text('찾기 결과 · 6개'), findsOneWidget, reason: '파일 5 + 폴더 1'); expect(find.textContaining('멈췄습니다'), findsNothing); + expect(find.widgetWithText(TextButton, '모두'), findsOneWidget, reason: '다 찾으면 그냥 "모두"'); }); testWidgets('누르면 열기 (화면 그대로) · 폴더는 그 자리로 · 고르면 복사 → 다른 창 · 지우기', (tester) async { @@ -126,7 +138,7 @@ void main() { await tester.tap(find.text('ep3.mkv')); await tester.pump(); expect(opened, hasLength(1), reason: '고르는 중에는 열지 않음'); - expect(find.text('2개 · 2B'), findsOneWidget); + expect(find.text('파일 2개 · 2B'), findsOneWidget); expect(find.text('복사 → dest'), findsOneWidget); await tester.tap(find.text('지우기')); await tester.pumpAndSettle(); diff --git a/test/explorer_test.dart b/test/explorer_test.dart index 4ec0397..4781718 100644 --- a/test/explorer_test.dart +++ b/test/explorer_test.dart @@ -900,6 +900,70 @@ void main() { expect(File(p.join(left, 'Ep01.txt')).existsSync(), isFalse); }); + // 79-c (관리자 판단 · 데이터): 찾기 결과에서 폴더와 그 안의 파일을 함께 고르면 안의 것은 한 번만 (폴더와 함께) + group('79-c 폴더 + 안의 파일을 함께 고름', () { + /// "sub" 로 찾아 sub 폴더 · sub/sub_a.txt 를 모두 고른다 + Future findSubAll(WidgetTester tester) async { + File(p.join(left, 'sub', 'sub_a.txt')).writeAsStringSync('aaaaa'); + await open(tester); + await act(tester, () => tester.tap(find.text('찾기').first)); + await calm(tester); + await tester.enterText(find.widgetWithText(TextField, '이름 (비워도 됨)'), 'sub'); + await tester.pump(); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '찾기'))); + await settle(tester, () => find.text('찾기 결과 · 2개').evaluate().isNotEmpty && find.widgetWithText(TextButton, '모두').evaluate().isNotEmpty); + await act(tester, () => tester.tap(find.widgetWithText(TextButton, '모두'))); + await calm(tester); + // 79-b: 고른 폴더 안의 파일은 세지 않고, 폴더 크기는 빠졌다고 적는다 + expect(find.text('폴더 1개 (크기 미포함)'), findsOneWidget); + } + + testWidgets('복사: 한 번만 - 다른 창에 sub/sub_a.txt 하나', (tester) async { + await findSubAll(tester); + await act(tester, () => tester.tap(find.text('복사 → right'))); + await calm(tester); + expect(find.textContaining('sub_a.txt'), findsNothing, reason: '확인 창에도 안쪽 파일은 따로 나오지 않는다'); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '복사'))); + await settle(tester, () => File(p.join(right, 'sub', 'sub_a.txt')).existsSync() && find.byType(LinearProgressIndicator).evaluate().isEmpty); + expect(File(p.join(right, 'sub_a.txt')).existsSync(), isFalse, reason: '안쪽 파일이 따로 한 번 더 복사되지 않음'); + expect(find.textContaining('오류'), findsNothing); + }); + + testWidgets('이동: "없음" 오류 없이 한 번만 · [찾기 결과로 돌아가기] 는 새 자리', (tester) async { + await findSubAll(tester); + await act(tester, () => tester.tap(find.text('이동 → right'))); + await calm(tester); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '이동'))); + await settle(tester, () => File(p.join(right, 'sub', 'sub_a.txt')).existsSync() && !Directory(p.join(left, 'sub')).existsSync() && + find.byType(LinearProgressIndicator).evaluate().isEmpty); + expect(File(p.join(right, 'sub_a.txt')).existsSync(), isFalse); + expect(find.textContaining('없'), findsNothing, reason: '안쪽 파일을 따로 옮기려다 "없음" 이 나지 않는다'); + // 79-a: 다시 찾지 않고 결과로 - 옮긴 것은 새 자리 + expect(find.text('찾기 결과로 돌아가기'), findsOneWidget); + await act(tester, () => tester.tap(find.text('찾기 결과로 돌아가기'))); + await settle(tester, () => find.text('찾기 결과 · 2개').evaluate().isNotEmpty); + final results = find.byType(SearchResultsPage); + expect(find.descendant(of: results, matching: find.textContaining(right)), findsNWidgets(2)); + }); + + testWidgets('지우기: 확인 창은 1개 항목 · 폴더 1개', (tester) async { + await findSubAll(tester); + await act(tester, () => tester.tap(find.text('지우기'))); + await calm(tester); + expect(find.textContaining('"sub" 을(를) 휴지통으로 보낼까요?'), findsOneWidget, reason: '안쪽 파일을 빼고 하나'); + expect(find.text('그중 폴더 1개 - 안의 파일 · 폴더도 모두 함께'), findsOneWidget); + await tester.tap(find.text('휴지통을 거치지 않고 영구 삭제 (Shift+Delete)')); + await calm(tester); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '영구 삭제'))); + await settle(tester, () => !Directory(p.join(left, 'sub')).existsSync() && find.byType(LinearProgressIndicator).evaluate().isEmpty); + expect(find.textContaining('지우지 못'), findsNothing); + // 79-a: 지운 것은 결과에서 빠진다 + await act(tester, () => tester.tap(find.text('찾기 결과로 돌아가기'))); + await settle(tester, () => find.text('찾기 결과 · 0개').evaluate().isNotEmpty); + expect(find.text('찾은 것이 없습니다.'), findsOneWidget); + }); + }); + testWidgets('79: 찾기 결과를 목록으로 - 이름 없이 종류 조건으로 찾고, 골라서 그대로 다른 창으로 복사', (tester) async { await open(tester); File(p.join(left, 'sub', 'big.mkv'))