From 31a06c74edaafbf5f674ad1b2abaa84af68a90a1 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:48:42 +0900 Subject: [PATCH] =?UTF-8?q?WIP=2079=20=ED=8C=8C=EC=9D=BC=20=ED=83=90?= =?UTF-8?q?=EC=83=89=EA=B8=B0=20=EC=B0=BE=EA=B8=B0=20=EA=B2=B0=EA=B3=BC?= =?UTF-8?q?=EB=A5=BC=20=EB=AA=A9=EB=A1=9D=20=ED=99=94=EB=A9=B4=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C:=20=ED=81=AC=EA=B8=B0=20=C2=B7=20=EB=B0=94=EB=80=90?= =?UTF-8?q?=20=EB=82=A0=20=C2=B7=20=EC=A2=85=EB=A5=98=20=EC=A1=B0=EA=B1=B4?= =?UTF-8?q?,=20=EA=B3=A0=EB=A5=B4=EA=B3=A0=20=EA=B7=B8=EB=8C=80=EB=A1=9C?= =?UTF-8?q?=20=EB=B3=B5=EC=82=AC=20=C2=B7=20=EC=9D=B4=EB=8F=99=20=C2=B7=20?= =?UTF-8?q?=EC=A7=80=EC=9A=B0=EA=B8=B0,=202000=EA=B0=9C=EB=A7=88=EB=8B=A4?= =?UTF-8?q?=20[=EB=8D=94=20=EB=B3=B4=EA=B8=B0]=20(=EA=B2=80=ED=86=A0=20?= =?UTF-8?q?=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 관리자 지시 (F8): 찾기 결과를 목록으로 보여 그대로 복사 · 지우기 · 재생, 크기 · 날짜 · 확장자 조건 ("1GB 넘는 30일 지난 영상"), 2000개에서 멈추면 "더 보기". - 찾기 창: 이름 (비워도 됨, * ?) · 크기 이상 (MB/GB) · 바뀐 날 (아무 때 / N일 지남 / N일 안) · 종류 (모두 · 동영상 · 음악 · 자막 · 그림 · 확장자 직접). 크기 · 날짜 · 종류를 넣으면 파일만 찾는다. 마지막 조건은 이번 실행 동안 다음 찾기 창의 처음 값. - 결과 화면 (예전 작은 창 _SearchDialog 를 바꿈): 경로 · 크기 · 바뀐 날, 누르면 파일은 열기 · 재생 (결과 화면 그대로), 폴더는 그 자리로, 줄마다 [그 자리로]. 체크 · 길게 누르기로 고르면 아래 줄에 개수 · 합계 크기와 [복사 → 다른 창] [이동 → 다른 창] [지우기] - 고른 것만, 탐색기와 같은 확인 창 · 진행 패널 · 휴지통 (154 · 155 · 65). - 2000개마다 멈추고 [2000개 더 보기]. 딱 2000개면 멈췄다고 하지 않는다 (다음 것이 올 때만). - searchFiles 에 accept (조건) - 이름 없이 조건만으로도 찾는다. 이름도 조건도 없으면 예전처럼 찾지 않음. - 시험: explorer_search_test (조건 · accept · 더 보기 · 열기 · 고르기 · 폴더), explorer_test 79 (종류만으로 찾아 다른 창으로 복사). Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 22 ++ assets/l10n/ja.json | 22 ++ assets/l10n/zh-Hans.json | 22 ++ lib/core/file_ops.dart | 11 +- lib/ui/explorer_page.dart | 146 +++------- lib/ui/explorer_search.dart | 470 +++++++++++++++++++++++++++++++++ test/explorer_search_test.dart | 145 ++++++++++ test/explorer_test.dart | 29 ++ 8 files changed, 753 insertions(+), 114 deletions(-) create mode 100644 lib/ui/explorer_search.dart create mode 100644 test/explorer_search_test.dart diff --git a/assets/l10n/en.json b/assets/l10n/en.json index bcaf92c..641c84c 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -447,6 +447,7 @@ "{0} 을(를) 지울까요?": "Delete {0}?", "{0} 의 SHA256 이 다릅니다 (손상되었거나 다른 파일). 설치를 멈췄습니다.": "The SHA256 of {0} does not match (damaged or a different file). Installation stopped.", "{0} 이 비어 있습니다 (카드 · USB · 디스크를 넣은 뒤 다시 누르세요).": "{0} is empty (insert a card, USB drive or disc and tap again).", + "{0} 이상": "{0} or larger", "{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg does not have the {0} encoder.", "{0} 전체": "All of {0}", "{0} 접기": "Collapse {0}", @@ -466,6 +467,7 @@ "{0}개": "{0}", "{0}개 · {1}": "{0} · {1}", "{0}개 · 주소 {1}개": "{0} · {1} links", + "{0}개 더 보기": "Show {0} more", "{0}개 맞춤": "{0} synced", "{0}개 보임": "{0} shown", "{0}개 삭제": "Delete {0} items", @@ -495,11 +497,14 @@ "{0}개를 옮겼습니다 → {1} ({2})": "Moved {0} → {1} ({2})", "{0}개를 찾아 여기서 멈췄습니다. 이름을 더 자세히 넣거나 아래 폴더로 들어가 찾으세요.": "Stopped after finding {0}. Enter a more specific name or search inside a subfolder.", "{0}개를 풀었습니다: {1}": "Extracted {0}: {1}", + "{0}개에서 멈췄습니다": "Stopped at {0}", "{0}배": "{0}×", "{0}배로 올려 새 파일로 저장합니다. 원본 · ZIP 은 그대로입니다. 화면을 닫아도 계속하며 작업 알림에 남은 장과 예상 시간이 보입니다.": "Upscales {0}× and saves new files. Originals and ZIPs stay unchanged. It keeps going after you close the screen, and the job notification shows pages left and the estimated time.", "{0}분": "{0} min", "{0}분 {1}초": "{0} min {1} s", "{0}시간 {1}분": "{0} h {1} min", + "{0}일 안": "within {0} days", + "{0}일 지남": "older than {0} days", "{0}장 모두 해상도 올리기": "Upscale all {0} pages", "{0}장 저장: {1}": "{0} saved: {1}", "{0}장 중 {1}장에서 멈췄습니다": "Stopped after {1} of {0}", @@ -627,6 +632,7 @@ "권한 허용 화면 열기": "Open the permission screen", "권한이 없습니다": "No permission", "규칙상 허용되지 않음": "not allowed by ruleset", + "그 자리로": "Go to location", "그 파일 하나만": "Only that file", "그대로 두기": "Keep", "그래도 Chrome 엔진 쓰기…": "Use the Chrome engine anyway…", @@ -637,6 +643,7 @@ "그리스어": "Greek", "그릴 것 (프롬프트, 영어가 잘 됩니다)": "What to draw (prompt; English works best)", "그릴 내용을 적어 주세요": "Describe what to draw", + "그림": "Images", "그림 {0}장으로 PDF 를 만들었습니다: {1}": "Made a PDF from {0} images: {1}", "그림 {0}장을 PDF 로 만들기": "Make PDF from {0} images", "그림 {0}장을 {1} 페이지 뒤에 넣었습니다 (저장해야 파일에 반영됩니다).": "Added {0} images after page {1} (saved to the file when you save).", @@ -1003,6 +1010,8 @@ "모델 읽는 중 …": "Loading model …", "모델 파일이 잘렸습니다 (위치 {0})": "The model file is truncated (position {0})", "모델을 내려받을 수 없습니다 ({0}): {1}": "Cannot download the model ({0}): {1}", + "모두": "All", + "모두 고르기": "Select all", "모두 나중에": "All later", "모두 내장 플레이어": "Built-in player for all", "모두 멈춤": "Pause all", @@ -1049,7 +1058,10 @@ "바뀌는 것 없음 (양쪽이 같습니다)": "Nothing to change (both sides are the same)", "바뀐 것을 살펴보는 간격": "Interval for checking changes", "바뀐 날": "Modified", + "바뀐 날: 아무 때": "Modified: any time", "바뀐 내용": "Changes", + "바뀐 지 … 일 안": "Modified within … days", + "바뀐 지 … 일 지남": "Modified more than … days ago", "바로 끔": "Stop right away", "바로 시작 (기본)": "Start right away (default)", "바로 시작: 켜진 동기화를 모두 시작 · 골라서 시작: 켤 때 고르기 · 시작 안 함: 모니터링 > lsync 의 시작 버튼으로": "Start right away: start all enabled syncs · Choose: pick at startup · Don't start: use the start button in Monitor > lsync", @@ -1160,6 +1172,7 @@ "복사 · 이동은 두 창에서 씁니다 (창 배치 > 두 창).": "Copy · move works with two panes (Pane layout > Two panes).", "복사 · 이동할 폴더에 같은 이름이 있으면 (현재 방식 · WebDAV)": "If the destination folder already has the same name (built-in method · WebDAV)", "복사 · 이동할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "Choose items to copy · move (tap, or mark with the circle on the right).", + "복사 → {0}": "Copy → {0}", "복사 끝 ({0}): {1}개 → {2}": "Copy done ({0}): {1} → {2}", "복사 끝: {0}개 · {1} → {2}": "Copy done: {0} · {1} → {2}", "복사 모니터링": "Copy monitor", @@ -1496,6 +1509,7 @@ "예: /E /COPY:DAT /DCOPY:T /R:2 /W:2. 이동은 /MOVE 를 자동으로 붙임": "e.g. /E /COPY:DAT /DCOPY:T /R:2 /W:2. Move adds /MOVE automatically", "예: 0 9-17 * * 1-5 (평일 9시~17시 59분) · 0 0-5 * * * (매일 0~5시). 분은 무시하고 1시간 단위": "e.g. 0 9-17 * * 1-5 (weekdays 9:00-17:59) · 0 0-5 * * * (daily 0-5). Minutes are ignored, 1-hour units", "예: Ctrl+Shift+X (Ctrl·Shift·Alt·Win + 영문자·숫자·F1~F12)": "e.g. Ctrl+Shift+X (Ctrl·Shift·Alt·Win + letter·digit·F1~F12)", + "예: S01E · *.srt": "e.g. S01E · *.srt", "예: http://100.x.x.x:7860 (--api 로 켠 Automatic1111 · Forge)": "e.g. http://100.x.x.x:7860 (Automatic1111 · Forge started with --api)", "예: http://100.x.x.x:8188 (ComfyUI)": "e.g. http://100.x.x.x:8188 (ComfyUI)", "예: https://api.openai.com (경로 /v1/images/generations 를 붙여 부름)": "e.g. https://api.openai.com (called with the path /v1/images/generations)", @@ -1636,6 +1650,7 @@ "음성인식 중": "Recognizing speech", "음성인식 중 {0}%": "Recognizing speech {0}%", "음소거 (M)": "Mute (M)", + "음악": "Music", "이 PC 의 안전 저장소를 풀 수 없었습니다. WebDAV · OpenSubtitles 비밀번호를 한 번만 다시 넣어 주세요. 풀지 못한 파일은 지우지 않고 보관했습니다: {0}": "This PC's secure storage could not be decrypted. Please enter your WebDAV and OpenSubtitles passwords once more. The file that could not be decrypted was kept, not deleted: {0}", "이 WebDAV 서버는 로그인이 필요합니다": "This WebDAV server requires sign-in", "이 {0}의 설정 파일에 저장됩니다": "Saved in this {0}'s settings file", @@ -1705,6 +1720,7 @@ "이동": "Move", "이동 ({0})": "Move ({0})", "이동 (붙여넣기로)": "Move (to paste)", + "이동 → {0}": "Move → {0}", "이동 끝: {0}개 · {1} → {2}": "Move done: {0} · {1} → {2}", "이동 버튼 (세부 정보 오른쪽 아래, 위로 하나씩)": "Move buttons (bottom right of the details, stacked upward)", "이동 버튼 추가": "Add move button", @@ -1721,6 +1737,7 @@ "이동할 폴더가 정해지지 않았습니다 (환경 설정 > 저장 위치)": "No move folder is set (Settings > Save locations)", "이동할 폴더를 만들 수 없습니다: {0} ({1})": "Cannot create the move folder: {0} ({1})", "이름": "Name", + "이름 (비워도 됨)": "Name (optional)", "이름 (탭에 보임, 비우면 주소)": "Name (shown on the tab; address if empty)", "이름 바꾸기": "Rename", "이름 바꾸기 (예: 이름 (2))": "Keep both (e.g. name (2))", @@ -2052,6 +2069,7 @@ "창을 닫아도 (✕) 트레이에서 동기화 · 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}개": "Search results · {0}", "찾기: {0}": "Search: {0}", "찾는 즐겨찾기가 없습니다.": "No matching bookmarks.", "찾은 것이 없습니다.": "Nothing found.", @@ -2120,7 +2138,9 @@ "쿠키 파일 지우기": "Delete cookie file", "크게": "Large", "크기": "Size", + "크기 · 바뀐 날 · 종류를 넣으면 파일만 찾습니다.": "With size, date or type conditions, only files are found.", "크기 · 보정을 원래대로 돌려 코덱도 \"원본 유지\" 로 되돌렸습니다.": "Size and adjustments are back to original, so the codec was set back to \"Keep original\".", + "크기 이상": "Min size", "크기를 바꾸면 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "Changing the size requires re-encoding, so the codec was changed from \"Keep original\" to H.264.", "크메르어": "Khmer", "키 있음": "Key saved", @@ -2320,6 +2340,8 @@ "확장 설치: {0}": "Installing extension: {0}", "확장을 설치할 수 없습니다 ({0}): {1}": "Could not install the extension ({0}): {1}", "확장자": "Ext", + "확장자 (쉼표로)": "Extensions (comma-separated)", + "확장자 직접": "Custom extensions", "확장자별 재생 프로그램": "Player per file extension", "환경 설정": "Settings", "환경 설정 > 컴포넌트": "Settings > Components", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 5d47471..371ccdd 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -447,6 +447,7 @@ "{0} 을(를) 지울까요?": "{0} を削除しますか?", "{0} 의 SHA256 이 다릅니다 (손상되었거나 다른 파일). 설치를 멈췄습니다.": "{0} の SHA256 が一致しません (破損しているか別のファイル)。インストールを中止しました。", "{0} 이 비어 있습니다 (카드 · USB · 디스크를 넣은 뒤 다시 누르세요).": "{0} は空です (カード・USB・ディスクを入れてからもう一度押してください)。", + "{0} 이상": "{0} 以上", "{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg に {0} エンコーダーがありません。", "{0} 전체": "{0} 全体", "{0} 접기": "{0} を折りたたむ", @@ -466,6 +467,7 @@ "{0}개": "{0}個", "{0}개 · {1}": "{0}個・{1}", "{0}개 · 주소 {1}개": "{0}個・URL {1}個", + "{0}개 더 보기": "さらに {0} 件表示", "{0}개 맞춤": "{0} 件を同期", "{0}개 보임": "{0} 件表示", "{0}개 삭제": "{0} 件を削除", @@ -495,11 +497,14 @@ "{0}개를 옮겼습니다 → {1} ({2})": "{0}個を移動しました → {1}({2})", "{0}개를 찾아 여기서 멈췄습니다. 이름을 더 자세히 넣거나 아래 폴더로 들어가 찾으세요.": "{0} 件見つけたところで止めました。名前をより詳しく入力するか、下のフォルダーに入って探してください。", "{0}개를 풀었습니다: {1}": "{0} 個を展開しました: {1}", + "{0}개에서 멈췄습니다": "{0} 件で停止しました", "{0}배": "{0} 倍", "{0}배로 올려 새 파일로 저장합니다. 원본 · ZIP 은 그대로입니다. 화면을 닫아도 계속하며 작업 알림에 남은 장과 예상 시간이 보입니다.": "{0} 倍に高解像度化して新しいファイルで保存します。元のファイル・ZIP はそのままです。画面を閉じても続き、作業通知に残りの枚数と予想時間が表示されます。", "{0}분": "{0} 分", "{0}분 {1}초": "{0}分 {1}秒", "{0}시간 {1}분": "{0}時間 {1}分", + "{0}일 안": "{0} 日以内", + "{0}일 지남": "{0} 日以上前", "{0}장 모두 해상도 올리기": "{0} ページすべてを高解像度化", "{0}장 저장: {1}": "{0} 枚保存: {1}", "{0}장 중 {1}장에서 멈췄습니다": "{0} 枚中 {1} 枚で停止しました", @@ -627,6 +632,7 @@ "권한 허용 화면 열기": "権限の許可画面を開く", "권한이 없습니다": "権限がありません", "규칙상 허용되지 않음": "ルールで許可されていません", + "그 자리로": "その場所へ", "그 파일 하나만": "そのファイルだけ", "그대로 두기": "そのままにする", "그래도 Chrome 엔진 쓰기…": "それでも Chrome エンジンを使う…", @@ -637,6 +643,7 @@ "그리스어": "ギリシャ語", "그릴 것 (프롬프트, 영어가 잘 됩니다)": "描くもの (プロンプト、英語が最適です)", "그릴 내용을 적어 주세요": "描く内容を入力してください", + "그림": "画像", "그림 {0}장으로 PDF 를 만들었습니다: {1}": "画像 {0} 枚から PDF を作成しました: {1}", "그림 {0}장을 PDF 로 만들기": "画像 {0} 枚を PDF にする", "그림 {0}장을 {1} 페이지 뒤에 넣었습니다 (저장해야 파일에 반영됩니다).": "画像 {0} 枚を {1} ページの後に追加しました (保存するとファイルに反映されます)。", @@ -1003,6 +1010,8 @@ "모델 읽는 중 …": "モデルを読み込み中 …", "모델 파일이 잘렸습니다 (위치 {0})": "モデルファイルが途中で切れています(位置 {0})", "모델을 내려받을 수 없습니다 ({0}): {1}": "モデルをダウンロードできません({0}): {1}", + "모두": "すべて", + "모두 고르기": "すべて選択", "모두 나중에": "すべて後で", "모두 내장 플레이어": "すべて内蔵プレーヤー", "모두 멈춤": "すべて停止", @@ -1049,7 +1058,10 @@ "바뀌는 것 없음 (양쪽이 같습니다)": "変更なし (両方同じです)", "바뀐 것을 살펴보는 간격": "変更を確認する間隔", "바뀐 날": "更新日時", + "바뀐 날: 아무 때": "更新日: いつでも", "바뀐 내용": "変更内容", + "바뀐 지 … 일 안": "更新から … 日以内", + "바뀐 지 … 일 지남": "更新から … 日以上", "바로 끔": "すぐ終了", "바로 시작 (기본)": "すぐ開始 (既定)", "바로 시작: 켜진 동기화를 모두 시작 · 골라서 시작: 켤 때 고르기 · 시작 안 함: 모니터링 > lsync 의 시작 버튼으로": "すぐ開始: オンの同期をすべて開始 · 選んで開始: 起動時に選ぶ · 開始しない: モニタリング > lsync の開始ボタンで", @@ -1160,6 +1172,7 @@ "복사 · 이동은 두 창에서 씁니다 (창 배치 > 두 창).": "コピー · 移動は 2 ペインで使います (ペインの配置 > 2 ペイン)。", "복사 · 이동할 폴더에 같은 이름이 있으면 (현재 방식 · WebDAV)": "コピー・移動先のフォルダーに同じ名前がある場合 (現在の方式・WebDAV)", "복사 · 이동할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "コピー · 移動する項目を選んでください (タップ、または右の丸でマーク)。", + "복사 → {0}": "コピー → {0}", "복사 끝 ({0}): {1}개 → {2}": "コピー完了 ({0}): {1} 件 → {2}", "복사 끝: {0}개 · {1} → {2}": "コピー完了: {0} 件 · {1} → {2}", "복사 모니터링": "コピーのモニタリング", @@ -1496,6 +1509,7 @@ "예: /E /COPY:DAT /DCOPY:T /R:2 /W:2. 이동은 /MOVE 를 자동으로 붙임": "例: /E /COPY:DAT /DCOPY:T /R:2 /W:2。移動では /MOVE を自動で付加", "예: 0 9-17 * * 1-5 (평일 9시~17시 59분) · 0 0-5 * * * (매일 0~5시). 분은 무시하고 1시간 단위": "例: 0 9-17 * * 1-5 (平日 9 時~17 時 59 分) · 0 0-5 * * * (毎日 0~5 時)。分は無視し 1 時間単位", "예: Ctrl+Shift+X (Ctrl·Shift·Alt·Win + 영문자·숫자·F1~F12)": "例: Ctrl+Shift+X(Ctrl・Shift・Alt・Win + 英字・数字・F1〜F12)", + "예: S01E · *.srt": "例: S01E · *.srt", "예: http://100.x.x.x:7860 (--api 로 켠 Automatic1111 · Forge)": "例: http://100.x.x.x:7860 (--api で起動した Automatic1111・Forge)", "예: http://100.x.x.x:8188 (ComfyUI)": "例: http://100.x.x.x:8188 (ComfyUI)", "예: https://api.openai.com (경로 /v1/images/generations 를 붙여 부름)": "例: https://api.openai.com (パス /v1/images/generations を付けて呼び出し)", @@ -1636,6 +1650,7 @@ "음성인식 중": "音声認識中", "음성인식 중 {0}%": "音声認識中 {0}%", "음소거 (M)": "ミュート(M)", + "음악": "音楽", "이 PC 의 안전 저장소를 풀 수 없었습니다. WebDAV · OpenSubtitles 비밀번호를 한 번만 다시 넣어 주세요. 풀지 못한 파일은 지우지 않고 보관했습니다: {0}": "この PC の安全な保存領域を復号できませんでした。WebDAV・OpenSubtitles のパスワードをもう一度だけ入力してください。復号できなかったファイルは削除せず保管しました: {0}", "이 WebDAV 서버는 로그인이 필요합니다": "この WebDAV サーバーはログインが必要です", "이 {0}의 설정 파일에 저장됩니다": "この{0}の設定ファイルに保存されます", @@ -1705,6 +1720,7 @@ "이동": "移動", "이동 ({0})": "移動({0})", "이동 (붙여넣기로)": "移動 (貼り付け用)", + "이동 → {0}": "移動 → {0}", "이동 끝: {0}개 · {1} → {2}": "移動完了: {0} 件 · {1} → {2}", "이동 버튼 (세부 정보 오른쪽 아래, 위로 하나씩)": "移動ボタン(詳細の右下、上に 1つずつ)", "이동 버튼 추가": "移動ボタンを追加", @@ -1721,6 +1737,7 @@ "이동할 폴더가 정해지지 않았습니다 (환경 설정 > 저장 위치)": "移動フォルダーが決まっていません(環境設定 > 保存先)", "이동할 폴더를 만들 수 없습니다: {0} ({1})": "移動フォルダーを作成できません: {0}({1})", "이름": "名前", + "이름 (비워도 됨)": "名前 (空欄可)", "이름 (탭에 보임, 비우면 주소)": "名前 (タブに表示、空ならアドレス)", "이름 바꾸기": "名前を変更", "이름 바꾸기 (예: 이름 (2))": "名前を変える (例: 名前 (2))", @@ -2052,6 +2069,7 @@ "창을 닫아도 (✕) 트레이에서 동기화 · MKV 만들기 · 다운로드를 계속합니다. 끄면 닫을 때 모두 끝냅니다. (실행 · 종료의 \"종료를 누르면\" 과 같은 설정)": "ウィンドウを閉じても (✕) トレイで同期 · MKV 作成 · ダウンロードを続けます。オフにすると閉じたときにすべて終了します。(実行 · 終了の「終了を押したら」と同じ設定)", "창을 트레이에 둔 채 실시간 동기화가 원본을 못 읽어 멈추면 알림을 띄웁니다 (트레이 툴팁 경고는 늘)": "ウィンドウをトレイに置いたままリアルタイム同期が元を読めずに止まったら通知します (トレイのツールチップ警告は常に表示)", "찾기": "検索", + "찾기 결과 · {0}개": "検索結果 · {0} 件", "찾기: {0}": "検索: {0}", "찾는 즐겨찾기가 없습니다.": "該当するブックマークがありません。", "찾은 것이 없습니다.": "見つかりませんでした。", @@ -2120,7 +2138,9 @@ "쿠키 파일 지우기": "Cookie ファイルを削除", "크게": "大きく", "크기": "サイズ", + "크기 · 바뀐 날 · 종류를 넣으면 파일만 찾습니다.": "サイズ・更新日・種類を指定すると、ファイルだけを探します。", "크기 · 보정을 원래대로 돌려 코덱도 \"원본 유지\" 로 되돌렸습니다.": "サイズ・補正を元に戻したので、コーデックも「元のまま」に戻しました。", + "크기 이상": "最小サイズ", "크기를 바꾸면 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "サイズを変えると再エンコードが必要なため、コーデックを「元のまま」から H.264 に変更しました。", "크메르어": "クメール語", "키 있음": "キーあり", @@ -2320,6 +2340,8 @@ "확장 설치: {0}": "拡張機能のインストール: {0}", "확장을 설치할 수 없습니다 ({0}): {1}": "拡張機能をインストールできません ({0}): {1}", "확장자": "拡張子", + "확장자 (쉼표로)": "拡張子 (カンマ区切り)", + "확장자 직접": "拡張子を指定", "확장자별 재생 프로그램": "拡張子ごとの再生プログラム", "환경 설정": "環境設定", "환경 설정 > 컴포넌트": "環境設定 > コンポーネント", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index c2abf4f..344c8cc 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -447,6 +447,7 @@ "{0} 을(를) 지울까요?": "要删除 {0} 吗?", "{0} 의 SHA256 이 다릅니다 (손상되었거나 다른 파일). 설치를 멈췄습니다.": "{0} 的 SHA256 不一致(已损坏或是其他文件)。已停止安装。", "{0} 이 비어 있습니다 (카드 · USB · 디스크를 넣은 뒤 다시 누르세요).": "{0} 为空 (插入存储卡、U 盘或光盘后再点按)。", + "{0} 이상": "{0} 及以上", "{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg 中没有 {0} 编码器。", "{0} 전체": "{0} 全部", "{0} 접기": "折叠 {0}", @@ -466,6 +467,7 @@ "{0}개": "{0} 个", "{0}개 · {1}": "{0} 个 · {1}", "{0}개 · 주소 {1}개": "{0} 个 · 地址 {1} 个", + "{0}개 더 보기": "再显示 {0} 个", "{0}개 맞춤": "已同步 {0} 个", "{0}개 보임": "显示 {0} 项", "{0}개 삭제": "删除 {0} 项", @@ -495,11 +497,14 @@ "{0}개를 옮겼습니다 → {1} ({2})": "已移动 {0} 个 → {1}({2})", "{0}개를 찾아 여기서 멈췄습니다. 이름을 더 자세히 넣거나 아래 폴더로 들어가 찾으세요.": "找到 {0} 个后已停止。请输入更具体的名称或进入下级文件夹查找。", "{0}개를 풀었습니다: {1}": "已解压 {0} 个: {1}", + "{0}개에서 멈췄습니다": "已在 {0} 个处暂停", "{0}배": "{0} 倍", "{0}배로 올려 새 파일로 저장합니다. 원본 · ZIP 은 그대로입니다. 화면을 닫아도 계속하며 작업 알림에 남은 장과 예상 시간이 보입니다.": "提高到 {0} 倍并另存为新文件。原文件和 ZIP 保持不变。关闭画面后仍会继续,任务通知中会显示剩余页数和预计时间。", "{0}분": "{0} 分钟", "{0}분 {1}초": "{0} 分 {1} 秒", "{0}시간 {1}분": "{0} 小时 {1} 分", + "{0}일 안": "{0} 天内", + "{0}일 지남": "超过 {0} 天", "{0}장 모두 해상도 올리기": "提高全部 {0} 页的分辨率", "{0}장 저장: {1}": "已保存 {0} 张: {1}", "{0}장 중 {1}장에서 멈췄습니다": "共 {0} 张,在第 {1} 张后停止", @@ -627,6 +632,7 @@ "권한 허용 화면 열기": "打开权限授予界面", "권한이 없습니다": "没有权限", "규칙상 허용되지 않음": "规则不允许", + "그 자리로": "转到所在位置", "그 파일 하나만": "仅该文件", "그대로 두기": "保留", "그래도 Chrome 엔진 쓰기…": "仍然使用 Chrome 引擎…", @@ -637,6 +643,7 @@ "그리스어": "希腊语", "그릴 것 (프롬프트, 영어가 잘 됩니다)": "要画的内容 (提示词,英文效果最好)", "그릴 내용을 적어 주세요": "请输入要画的内容", + "그림": "图片", "그림 {0}장으로 PDF 를 만들었습니다: {1}": "已用 {0} 张图片制作 PDF: {1}", "그림 {0}장을 PDF 로 만들기": "将 {0} 张图片制作为 PDF", "그림 {0}장을 {1} 페이지 뒤에 넣었습니다 (저장해야 파일에 반영됩니다).": "已在第 {1} 页后加入 {0} 张图片 (保存后才会写入文件)。", @@ -1003,6 +1010,8 @@ "모델 읽는 중 …": "正在加载模型 …", "모델 파일이 잘렸습니다 (위치 {0})": "模型文件被截断(位置 {0})", "모델을 내려받을 수 없습니다 ({0}): {1}": "无法下载模型({0}): {1}", + "모두": "全部", + "모두 고르기": "全选", "모두 나중에": "全部稍后", "모두 내장 플레이어": "全部使用内置播放器", "모두 멈춤": "全部暂停", @@ -1049,7 +1058,10 @@ "바뀌는 것 없음 (양쪽이 같습니다)": "没有更改 (两边相同)", "바뀐 것을 살펴보는 간격": "检查更改的间隔", "바뀐 날": "修改时间", + "바뀐 날: 아무 때": "修改时间: 任意", "바뀐 내용": "更改内容", + "바뀐 지 … 일 안": "… 天内修改", + "바뀐 지 … 일 지남": "超过 … 天未修改", "바로 끔": "立即关闭", "바로 시작 (기본)": "立即开始 (默认)", "바로 시작: 켜진 동기화를 모두 시작 · 골라서 시작: 켤 때 고르기 · 시작 안 함: 모니터링 > lsync 의 시작 버튼으로": "立即开始: 开始所有已启用的同步 · 选择后开始: 启动时选择 · 不开始: 用 监控 > lsync 的开始按钮", @@ -1160,6 +1172,7 @@ "복사 · 이동은 두 창에서 씁니다 (창 배치 > 두 창).": "复制 · 移动需在双窗格中使用 (窗格布局 > 双窗格)。", "복사 · 이동할 폴더에 같은 이름이 있으면 (현재 방식 · WebDAV)": "目标文件夹中已有同名项目时 (当前方式 · WebDAV)", "복사 · 이동할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "请选择要复制 · 移动的项目 (点按,或用右侧圆圈标记)。", + "복사 → {0}": "复制 → {0}", "복사 끝 ({0}): {1}개 → {2}": "复制完成 ({0}): {1} 个 → {2}", "복사 끝: {0}개 · {1} → {2}": "复制完成:{0} 个 · {1} → {2}", "복사 모니터링": "复制监控", @@ -1496,6 +1509,7 @@ "예: /E /COPY:DAT /DCOPY:T /R:2 /W:2. 이동은 /MOVE 를 자동으로 붙임": "例: /E /COPY:DAT /DCOPY:T /R:2 /W:2。移动时自动加上 /MOVE", "예: 0 9-17 * * 1-5 (평일 9시~17시 59분) · 0 0-5 * * * (매일 0~5시). 분은 무시하고 1시간 단위": "例: 0 9-17 * * 1-5 (工作日 9:00~17:59) · 0 0-5 * * * (每天 0~5 点)。忽略分钟,以 1 小时为单位", "예: Ctrl+Shift+X (Ctrl·Shift·Alt·Win + 영문자·숫자·F1~F12)": "例: Ctrl+Shift+X(Ctrl·Shift·Alt·Win + 字母·数字·F1~F12)", + "예: S01E · *.srt": "例如: S01E · *.srt", "예: http://100.x.x.x:7860 (--api 로 켠 Automatic1111 · Forge)": "例: http://100.x.x.x:7860 (使用 --api 启动的 Automatic1111 · Forge)", "예: http://100.x.x.x:8188 (ComfyUI)": "例: http://100.x.x.x:8188 (ComfyUI)", "예: https://api.openai.com (경로 /v1/images/generations 를 붙여 부름)": "例: https://api.openai.com (调用时附加路径 /v1/images/generations)", @@ -1636,6 +1650,7 @@ "음성인식 중": "正在识别语音", "음성인식 중 {0}%": "正在识别语音 {0}%", "음소거 (M)": "静音(M)", + "음악": "音乐", "이 PC 의 안전 저장소를 풀 수 없었습니다. WebDAV · OpenSubtitles 비밀번호를 한 번만 다시 넣어 주세요. 풀지 못한 파일은 지우지 않고 보관했습니다: {0}": "无法解密此电脑的安全存储。请再输入一次 WebDAV · OpenSubtitles 密码。无法解密的文件未删除,已保留: {0}", "이 WebDAV 서버는 로그인이 필요합니다": "此 WebDAV 服务器需要登录", "이 {0}의 설정 파일에 저장됩니다": "保存在此{0}的设置文件中", @@ -1705,6 +1720,7 @@ "이동": "移动", "이동 ({0})": "移动({0})", "이동 (붙여넣기로)": "移动 (用于粘贴)", + "이동 → {0}": "移动 → {0}", "이동 끝: {0}개 · {1} → {2}": "移动完成:{0} 个 · {1} → {2}", "이동 버튼 (세부 정보 오른쪽 아래, 위로 하나씩)": "移动按钮(详细信息右下角,向上依次排列)", "이동 버튼 추가": "添加移动按钮", @@ -1721,6 +1737,7 @@ "이동할 폴더가 정해지지 않았습니다 (환경 설정 > 저장 위치)": "尚未设置移动目标文件夹(设置 > 保存位置)", "이동할 폴더를 만들 수 없습니다: {0} ({1})": "无法创建移动目标文件夹: {0}({1})", "이름": "名称", + "이름 (비워도 됨)": "名称 (可留空)", "이름 (탭에 보임, 비우면 주소)": "名称 (显示在标签上,留空则显示地址)", "이름 바꾸기": "重命名", "이름 바꾸기 (예: 이름 (2))": "重命名 (例: 名称 (2))", @@ -2052,6 +2069,7 @@ "창을 닫아도 (✕) 트레이에서 동기화 · MKV 만들기 · 다운로드를 계속합니다. 끄면 닫을 때 모두 끝냅니다. (실행 · 종료의 \"종료를 누르면\" 과 같은 설정)": "关闭窗口 (✕) 后在托盘中继续同步 · 制作 MKV · 下载。关闭此项则关闭时全部结束。(与 运行 · 退出 中的“按退出时”为同一设置)", "창을 트레이에 둔 채 실시간 동기화가 원본을 못 읽어 멈추면 알림을 띄웁니다 (트레이 툴팁 경고는 늘)": "窗口在托盘中时,若实时同步因无法读取源而停止则显示通知 (托盘提示警告始终显示)", "찾기": "搜索", + "찾기 결과 · {0}개": "搜索结果 · {0} 个", "찾기: {0}": "搜索: {0}", "찾는 즐겨찾기가 없습니다.": "没有匹配的书签。", "찾은 것이 없습니다.": "未找到。", @@ -2120,7 +2138,9 @@ "쿠키 파일 지우기": "删除 Cookie 文件", "크게": "大", "크기": "大小", + "크기 · 바뀐 날 · 종류를 넣으면 파일만 찾습니다.": "设置大小、修改时间或类型时,只查找文件。", "크기 · 보정을 원래대로 돌려 코덱도 \"원본 유지\" 로 되돌렸습니다.": "大小 · 调整已恢复原样,编解码器也恢复为“保持原样”。", + "크기 이상": "最小大小", "크기를 바꾸면 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "更改大小需要重新编码,因此编解码器已从“保持原样”改为 H.264。", "크메르어": "高棉语", "키 있음": "已有密钥", @@ -2320,6 +2340,8 @@ "확장 설치: {0}": "安装扩展: {0}", "확장을 설치할 수 없습니다 ({0}): {1}": "无法安装扩展({0}): {1}", "확장자": "扩展名", + "확장자 (쉼표로)": "扩展名 (逗号分隔)", + "확장자 직접": "自定义扩展名", "확장자별 재생 프로그램": "按扩展名设置播放程序", "환경 설정": "设置", "환경 설정 > 컴포넌트": "设置 > 组件", diff --git a/lib/core/file_ops.dart b/lib/core/file_ops.dart index 41b930a..42f11c8 100644 --- a/lib/core/file_ops.dart +++ b/lib/core/file_ops.dart @@ -896,10 +896,15 @@ class FileOps { /// [root] 아래에서 이름에 [query] 가 들어간 항목 찾기 (대소문자 무시, * ? 사용 가능). 찾는 대로 내보낸다. /// [limit] 개에서 멈춘다. 읽지 못한 폴더는 [onSkipped] 로 알린다 (24: 조용히 빠뜨리지 않게). +/// 79: [accept] 가 있으면 그 조건 (크기 · 바뀐 날 · 종류) 에도 맞는 것만. 이때는 [query] 를 비워도 된다. Stream searchFiles(String root, String query, - {bool showHidden = false, int limit = 2000, void Function(String dir, Object error)? onSkipped}) async* { + {bool showHidden = false, + int limit = 2000, + void Function(String dir, Object error)? onSkipped, + bool Function(FileEntry e)? accept}) async* { final q = query.trim().toLowerCase(); - if (q.isEmpty) return; + // 79: 이름 없이 조건만 ("1GB 넘는 30일 지난 영상") 으로도 찾는다 + if (q.isEmpty && accept == null) return; final wild = q.contains('*') || q.contains('?'); final re = wild ? RegExp('^${RegExp.escape(q).replaceAll(r'\*', '.*').replaceAll(r'\?', '.')}\$') @@ -922,7 +927,7 @@ Stream searchFiles(String root, String query, } for (final e in sortEntries(items, SortBy.name)) { final name = e.name.toLowerCase(); - if (re != null ? re.hasMatch(name) : name.contains(q)) { + if ((re != null ? re.hasMatch(name) : name.contains(q)) && (accept == null || accept(e))) { yield e; if (++n >= limit) return; } diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 674cbe3..9f5464f 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -43,6 +43,7 @@ import 'zip_page.dart'; import '../core/reader_sources.dart'; import '../l10n/tr.dart'; import 'explorer_bookmarks.dart'; +import 'explorer_search.dart'; /// 파일 탐색기 (X-plore 참고): 두 창 (트리 목록) + 가운데 기능 버튼 줄. /// @@ -2619,18 +2620,41 @@ class _ExplorerPageState extends State { ); } + /// 79: 마지막 찾기 조건 (이번 실행 동안 다음 찾기 창의 처음 값) + SearchConditions _lastSearch = const SearchConditions(); + + /// 79: 찾기 - 이름 · 크기 · 바뀐 날 · 종류 조건 → 결과 목록 화면 (고르고 그대로 복사 · 이동 · 지우기, 누르면 열기 · 재생) Future _search(_Pane pane) async { - final q = await _askName(trf('찾기: {0}', [pane.current]), ''); - if (q == null || !mounted) return; - final found = await showDialog( - context: context, - builder: (ctx) => _SearchDialog(root: pane.current, query: q, showHidden: c.settings.explorerShowHidden), - ); - if (found == null) return; - await _goTo(pane, found.isDir ? found.path : vDirname(found.path), fresh: true); - pane.focused = found.path; - pane.changed(); - _reveal(pane, found.path); + setState(() => _active = _panes.indexOf(pane)); + final cond = await askSearchConditions(context, pane.current, _lastSearch); + if (cond == null || !mounted) return; + _lastSearch = cond; + final act = await Navigator.of(context).push(MaterialPageRoute( + builder: (_) => SearchResultsPage( + root: pane.current, + cond: cond, + showHidden: c.settings.explorerShowHidden, + otherDir: _other?.current, + onOpen: (e) => _open(e.path, pane: pane), + ), + )); + if (act == null || act.paths.isEmpty || !mounted) return; + switch (act.kind) { + case 'reveal': + final path = act.paths.first; + final dir = await vIsDir(path); + await _goTo(pane, dir ? path : vDirname(path), fresh: true); + pane.focused = path; + pane.changed(); + _reveal(pane, path); + // 고른 것만 대상 (154 · 155) - 탐색기의 복사 · 이동 · 지우기와 같은 확인 · 진행 패널 · 휴지통 + case 'copy': + await _transfer(pane, act.paths, move: false); + case 'move': + await _transfer(pane, act.paths, move: true); + case 'delete': + await _delete(pane, act.paths); + } } Future _sortDialog() async { @@ -3986,106 +4010,6 @@ class _Toolbar extends StatelessWidget { } } -/// 찾기 결과 (찾는 대로 보여 주고, 누르면 그 자리로) -class _SearchDialog extends StatefulWidget { - final String root; - final String query; - final bool showHidden; - const _SearchDialog({required this.root, required this.query, required this.showHidden}); - - @override - State<_SearchDialog> createState() => _SearchDialogState(); -} - -class _SearchDialogState extends State<_SearchDialog> { - final _found = []; - StreamSubscription? _sub; - bool _done = false; - - /// 24: 찾기를 멈추는 개수 · 읽지 못한 폴더 - static const limit = 2000; - final _skipped = <(String, String)>[]; - - @override - void initState() { - super.initState(); - _sub = searchFiles(widget.root, widget.query, showHidden: widget.showHidden, limit: limit, onSkipped: (dir, e) { - if (mounted) { - setState(() => _skipped.add((dir, e is FileSystemException ? (e.osError?.message ?? e.message) : '$e'))); - } - }).listen( - (e) => setState(() => _found.add(e)), - onDone: () => setState(() => _done = true), - onError: (_) {}, - ); - } - - /// 아래 안내: 개수 한도에서 멈춤 · 읽지 못한 폴더 - Widget? _footer() { - final limited = _found.length >= limit; - if (!limited && _skipped.isEmpty) return null; - return Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - if (limited) - Text(trf('{0}개를 찾아 여기서 멈췄습니다. 이름을 더 자세히 넣거나 아래 폴더로 들어가 찾으세요.', [limit]), - style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)), - if (_skipped.isNotEmpty) - ExpansionTile( - tilePadding: EdgeInsets.zero, - dense: true, - title: Text(trf('읽지 못한 폴더 {0}개 (권한 · 연결 문제) - 그 안은 찾지 못했습니다', [_skipped.length]), - style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)), - children: [ - for (final (dir, why) in _skipped.take(50)) - Align( - alignment: Alignment.centerLeft, - child: Text('$dir · $why', style: const TextStyle(fontSize: 11, color: JjColors.textDim)), - ), - ], - ), - ]); - } - - @override - void dispose() { - _sub?.cancel(); - super.dispose(); - } - - @override - Widget build(BuildContext context) => AlertDialog( - title: Row(children: [ - Expanded(child: Text(trf('"{0}" 찾기 · {1}개', [widget.query, _found.length]))), - if (!_done) const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)), - ]), - content: SizedBox( - width: 560, - height: 420, - child: Column(children: [ - Expanded( - child: _found.isEmpty && _done - ? Center(child: Text(tr('찾은 것이 없습니다.'))) - : ListView.builder( - itemCount: _found.length, - itemBuilder: (_, i) { - final e = _found[i]; - return ListTile( - dense: true, - leading: Icon(e.isDir ? Icons.folder : Icons.insert_drive_file_outlined, - color: e.isDir ? Colors.amber : null), - title: Text(e.name), - subtitle: Text(vDirname(e.path), maxLines: 1, overflow: TextOverflow.ellipsis), - onTap: () => Navigator.pop(context, e), - ); - }, - ), - ), - ?_footer(), - ]), - ), - actions: [TextButton(onPressed: () => Navigator.pop(context), child: Text(tr('닫기')))], - ); -} - // ───────── 동영상 정보 · 썸네일 (보이는 줄만, 차례로) ───────── /// 한 번에 하나씩 (FFmpeg 를 동시에 많이 띄우지 않게) diff --git a/lib/ui/explorer_search.dart b/lib/ui/explorer_search.dart new file mode 100644 index 0000000..9f7c49f --- /dev/null +++ b/lib/ui/explorer_search.dart @@ -0,0 +1,470 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import '../core/file_ops.dart'; +import '../core/reader_sources.dart' show defaultImageExtensions; +import '../core/subtitle_detector.dart' show audioExtensions, subtitleExtensions, videoExtensions; +import '../core/vfs.dart'; +import '../l10n/tr.dart'; +import 'app_actions.dart' show isCompact; +import 'theme.dart'; + +/// 79: 찾기 조건 - 이름 (없어도 됨) · 크기 · 바뀐 날 · 종류 ("1GB 넘는 30일 지난 영상") +class SearchConditions { + /// 이름 (비우면 이름은 보지 않음). * · ? 를 쓸 수 있다 + final String name; + + /// 이 크기 이상 (바이트, null = 보지 않음) + final int? minBytes; + + /// 이 날 수보다 오래전에 바뀐 것만 (null = 보지 않음) + final int? olderDays; + + /// 이 날 수 안에 바뀐 것만 (null = 보지 않음) + final int? newerDays; + + /// 'all' · 'video' · 'audio' · 'subtitle' · 'image' · 'custom' ([exts]) + final String kind; + + /// 'custom' 일 때 확장자 ("mkv, mp4") + final String exts; + + const SearchConditions({this.name = '', this.minBytes, this.olderDays, this.newerDays, this.kind = 'all', this.exts = ''}); + + /// 이름 말고 다른 조건이 있는지 (있으면 파일만 찾는다 - 폴더에는 크기 · 종류가 없다) + bool get hasFileConditions => minBytes != null || olderDays != null || newerDays != null || kind != 'all'; + + bool get isEmpty => name.trim().isEmpty && !hasFileConditions; + + List get _kindExts => switch (kind) { + 'video' => videoExtensions, + 'audio' => audioExtensions, + 'subtitle' => subtitleExtensions, + 'image' => defaultImageExtensions, + 'custom' => [ + for (final x in exts.split(RegExp(r'[,;\s]+'))) + if (x.trim().isNotEmpty) x.trim().toLowerCase().replaceFirst(RegExp(r'^\*?\.'), ''), + ], + _ => const [], + }; + + /// 이름 말고 다른 조건에 맞는지 ([now] 는 시험에서 바꿔 끼움) + bool accepts(FileEntry e, {DateTime? now}) { + if (!hasFileConditions) return true; + if (e.isDir) return false; + if (minBytes != null && e.size < minBytes!) return false; + final t = now ?? DateTime.now(); + if (olderDays != null && e.modified.isAfter(t.subtract(Duration(days: olderDays!)))) return false; + if (newerDays != null && e.modified.isBefore(t.subtract(Duration(days: newerDays!)))) return false; + if (kind != 'all' && !_kindExts.contains(e.ext)) return false; + return true; + } + + /// 화면에 보일 요약: "이름 *.mkv · 1GB 이상 · 30일 지남 · 동영상" + String get summary => [ + if (name.trim().isNotEmpty) '"${name.trim()}"', + if (minBytes != null) trf('{0} 이상', [formatSize(minBytes!)]), + if (olderDays != null) trf('{0}일 지남', [olderDays!]), + if (newerDays != null) trf('{0}일 안', [newerDays!]), + if (kind != 'all') kindLabel(kind, exts), + ].join(' · '); + + static String kindLabel(String k, [String exts = '']) => switch (k) { + 'video' => tr('동영상'), + 'audio' => tr('음악'), + 'subtitle' => tr('자막'), + 'image' => tr('그림'), + 'custom' => exts.trim().isEmpty ? tr('확장자 직접') : exts.trim(), + _ => tr('모두'), + }; +} + +/// 79: 찾기 조건 창. 취소하면 null +Future askSearchConditions(BuildContext context, String where, SearchConditions last) => + showDialog(context: context, builder: (_) => _ConditionsDialog(where: where, last: last)); + +class _ConditionsDialog extends StatefulWidget { + final String where; + final SearchConditions last; + const _ConditionsDialog({required this.where, required this.last}); + + @override + State<_ConditionsDialog> createState() => _ConditionsDialogState(); +} + +class _ConditionsDialogState extends State<_ConditionsDialog> { + late final _name = TextEditingController(text: widget.last.name); + late final _size = TextEditingController(text: _sizeText(widget.last.minBytes)); + late final _days = TextEditingController(text: '${widget.last.olderDays ?? widget.last.newerDays ?? ''}'); + late final _exts = TextEditingController(text: widget.last.exts); + late String _unit = (widget.last.minBytes ?? 0) >= _gb && (widget.last.minBytes! % _gb == 0) ? 'GB' : 'MB'; + late String _when = widget.last.olderDays != null ? 'older' : (widget.last.newerDays != null ? 'newer' : 'any'); + late String _kind = widget.last.kind; + + static const _mb = 1024 * 1024; + static const _gb = 1024 * _mb; + + static String _sizeText(int? b) { + if (b == null) return ''; + if (b >= _gb && b % _gb == 0) return '${b ~/ _gb}'; + return '${(b / _mb).round()}'; + } + + @override + void dispose() { + _name.dispose(); + _size.dispose(); + _days.dispose(); + _exts.dispose(); + super.dispose(); + } + + SearchConditions get _value { + final size = double.tryParse(_size.text.trim()); + final days = int.tryParse(_days.text.trim()); + return SearchConditions( + name: _name.text.trim(), + minBytes: size == null || size <= 0 ? null : (size * (_unit == 'GB' ? _gb : _mb)).round(), + olderDays: _when == 'older' && days != null && days > 0 ? days : null, + newerDays: _when == 'newer' && days != null && days > 0 ? days : null, + kind: _kind, + exts: _exts.text.trim(), + ); + } + + void _submit() { + final v = _value; + if (v.isEmpty) return; + Navigator.pop(context, v); + } + + @override + Widget build(BuildContext context) { + final v = _value; + return AlertDialog( + scrollable: true, + title: Text(trf('찾기: {0}', [widget.where])), + content: SizedBox( + width: 460, + child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + TextField( + controller: _name, + autofocus: true, + decoration: InputDecoration( + labelText: tr('이름 (비워도 됨)'), + hintText: tr('예: S01E · *.srt'), + isDense: true, + ), + onChanged: (_) => setState(() {}), + onSubmitted: (_) => _submit(), + ), + const SizedBox(height: 12), + // 크기: 이 크기 이상 + Wrap(spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ + SizedBox( + width: 110, + child: TextField( + controller: _size, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: InputDecoration(labelText: tr('크기 이상'), isDense: true), + onChanged: (_) => setState(() {}), + ), + ), + DropdownButton( + value: _unit, + items: const [ + DropdownMenuItem(value: 'MB', child: Text('MB')), + DropdownMenuItem(value: 'GB', child: Text('GB')), + ], + onChanged: (x) => setState(() => _unit = x!), + ), + ]), + const SizedBox(height: 8), + // 바뀐 날: 아무 때 · N일 지남 · N일 안 + Wrap(spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ + DropdownButton( + value: _when, + items: [ + DropdownMenuItem(value: 'any', child: Text(tr('바뀐 날: 아무 때'))), + DropdownMenuItem(value: 'older', child: Text(tr('바뀐 지 … 일 지남'))), + DropdownMenuItem(value: 'newer', child: Text(tr('바뀐 지 … 일 안'))), + ], + onChanged: (x) => setState(() => _when = x!), + ), + if (_when != 'any') + SizedBox( + width: 80, + child: TextField( + controller: _days, + keyboardType: TextInputType.number, + decoration: InputDecoration(labelText: tr('일'), isDense: true), + onChanged: (_) => setState(() {}), + ), + ), + ]), + const SizedBox(height: 8), + // 종류 + Wrap(spacing: 6, runSpacing: 4, children: [ + for (final k in const ['all', 'video', 'audio', 'subtitle', 'image', 'custom']) + ChoiceChip( + label: Text(SearchConditions.kindLabel(k)), + selected: _kind == k, + onSelected: (_) => setState(() => _kind = k), + ), + ]), + if (_kind == 'custom') + TextField( + controller: _exts, + decoration: InputDecoration(labelText: tr('확장자 (쉼표로)'), hintText: 'mkv, mp4', isDense: true), + onChanged: (_) => setState(() {}), + ), + if (v.hasFileConditions) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text(tr('크기 · 바뀐 날 · 종류를 넣으면 파일만 찾습니다.'), + style: const TextStyle(fontSize: 12, color: JjColors.textDim)), + ), + ]), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: Text(tr('취소'))), + FilledButton(onPressed: v.isEmpty ? null : _submit, child: Text(tr('찾기'))), + ], + ); + } +} + +/// 79: 찾기 결과 화면에서 고른 일 (화면을 닫고 탐색기가 한다 - 진행 패널이 보이게) +class SearchAction { + /// 'reveal' (그 자리로) · 'copy' · 'move' · 'delete' + final String kind; + final List paths; + const SearchAction(this.kind, this.paths); +} + +/// 79: 찾기 결과를 목록으로 - 고르고 그대로 복사 · 이동 · 지우기, 누르면 열기 · 재생 (화면은 그대로). +/// 2000개마다 멈추고 [더 보기] +class SearchResultsPage extends StatefulWidget { + final String root; + final SearchConditions cond; + final bool showHidden; + + /// 열기 · 재생 (탐색기와 같게). 결과 화면은 닫지 않는다 + final Future Function(FileEntry e) onOpen; + + /// 복사 · 이동할 곳 (다른 창의 지금 폴더, 없으면 null - 복사 · 이동은 담아 두기) + final String? otherDir; + + /// 한 번에 보여 줄 개수 (멈추고 [더 보기]) + final int chunk; + + const SearchResultsPage({ + super.key, + required this.root, + required this.cond, + required this.showHidden, + required this.onOpen, + this.otherDir, + this.chunk = 2000, + }); + + @override + State createState() => _SearchResultsPageState(); +} + +class _SearchResultsPageState extends State { + final _found = []; + final _marked = {}; + final _skipped = <(String, String)>[]; + StreamSubscription? _sub; + bool _done = false; + bool _paused = false; + late int _until = widget.chunk; + + /// 한도를 넘은 첫 항목 - 이것이 왔을 때만 멈추고 [더 보기] (딱 한도만큼이면 멈췄다고 하지 않는다) + FileEntry? _held; + + @override + void initState() { + super.initState(); + final c = widget.cond; + _sub = searchFiles(widget.root, c.name, + showHidden: widget.showHidden, + limit: 1 << 40, + accept: c.hasFileConditions ? c.accepts : null, onSkipped: (dir, e) { + if (mounted) setState(() => _skipped.add((dir, e is FileSystemException ? (e.osError?.message ?? e.message) : '$e'))); + }).listen( + (e) { + if (!mounted) return; + // 한 번에 [chunk] 개 - 그다음 것이 오면 붙잡아 두고 멈춘 뒤 [더 보기] 를 기다린다 + if (_found.length >= _until) { + _held = e; + _sub?.pause(); + setState(() => _paused = true); + return; + } + setState(() => _found.add(e)); + }, + onDone: () { + if (mounted) setState(() => _done = true); + }, + onError: (_) {}, + ); + } + + @override + void dispose() { + _sub?.cancel(); + super.dispose(); + } + + void _more() { + setState(() { + _until += widget.chunk; + _paused = false; + if (_held != null) _found.add(_held!); + _held = null; + }); + _sub?.resume(); + } + + int get _markedBytes => _found.where((e) => _marked.contains(e.path) && !e.isDir).fold(0, (a, e) => a + e.size); + + void _act(String kind) => Navigator.pop(context, SearchAction(kind, [for (final e in _found) if (_marked.contains(e.path)) e.path])); + + @override + Widget build(BuildContext context) { + final compact = isCompact(context); + final any = _marked.isNotEmpty; + final allMarked = _found.isNotEmpty && _marked.length == _found.length; + return Scaffold( + appBar: AppBar( + title: Text(trf('찾기 결과 · {0}개', [_found.length]), maxLines: 1, overflow: TextOverflow.ellipsis), + actions: [ + if (!_done && !_paused) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 12), + child: Center(child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))), + ), + if (_found.isNotEmpty) + IconButton( + tooltip: allMarked ? tr('모두 풀기') : tr('모두 고르기'), + icon: Icon(allMarked ? Icons.deselect : Icons.select_all), + onPressed: () => setState(() => allMarked ? _marked.clear() : _marked.addAll(_found.map((e) => e.path))), + ), + ], + ), + body: Column(children: [ + // 어디서 무엇을 + Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 4), + child: Align( + alignment: Alignment.centerLeft, + child: Text('${widget.cond.summary} · ${widget.root}', + maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: JjColors.textDim)), + ), + ), + Expanded( + child: _found.isEmpty && _done + ? Center(child: Text(tr('찾은 것이 없습니다.'))) + : ListView.builder( + itemCount: _found.length, + itemBuilder: (_, i) { + final e = _found[i]; + final on = _marked.contains(e.path); + return ListTile( + dense: true, + selected: on, + leading: Checkbox( + value: on, + onChanged: (_) => setState(() => on ? _marked.remove(e.path) : _marked.add(e.path)), + ), + title: Text(e.name, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Text( + [ + vDirname(e.path), + if (!e.isDir) formatSize(e.size), + if (e.modified.year > 1) _date(e.modified), + ].join(' · '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: IconButton( + tooltip: tr('그 자리로'), + icon: const Icon(Icons.drive_file_move_outline, size: 20), + onPressed: () => Navigator.pop(context, SearchAction('reveal', [e.path])), + ), + // 고르는 중이면 누르기 = 고르기, 아니면 파일은 열기 · 재생 (화면은 그대로), 폴더는 그 폴더로 + onTap: any + ? () => setState(() => on ? _marked.remove(e.path) : _marked.add(e.path)) + : e.isDir + ? () => Navigator.pop(context, SearchAction('reveal', [e.path])) + : () => widget.onOpen(e), + onLongPress: () => setState(() => on ? _marked.remove(e.path) : _marked.add(e.path)), + ); + }, + ), + ), + if (_paused) + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Wrap(spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ + Text(trf('{0}개에서 멈췄습니다', [_found.length]), style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)), + OutlinedButton(onPressed: _more, child: Text(trf('{0}개 더 보기', [widget.chunk]))), + ]), + ), + if (_skipped.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: ExpansionTile( + tilePadding: EdgeInsets.zero, + dense: true, + title: Text(trf('읽지 못한 폴더 {0}개 (권한 · 연결 문제) - 그 안은 찾지 못했습니다', [_skipped.length]), + style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)), + children: [ + for (final (dir, why) in _skipped.take(50)) + Align( + alignment: Alignment.centerLeft, + child: Text('$dir · $why', style: const TextStyle(fontSize: 11, color: JjColors.textDim)), + ), + ], + ), + ), + // 고른 것으로 할 일 (고른 것만 대상 - 154 · 155) + if (any) + SafeArea( + top: false, + child: Container( + 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)), + TextButton.icon( + onPressed: () => _act('copy'), + icon: const Icon(Icons.copy, size: 18), + label: Text(widget.otherDir == null || compact ? tr('복사') : trf('복사 → {0}', [vBasename(widget.otherDir!)])), + ), + TextButton.icon( + onPressed: () => _act('move'), + icon: const Icon(Icons.drive_file_move, size: 18), + label: Text(widget.otherDir == null || compact ? tr('이동') : trf('이동 → {0}', [vBasename(widget.otherDir!)])), + ), + TextButton.icon( + onPressed: () => _act('delete'), + icon: const Icon(Icons.delete_outline, size: 18, color: JjColors.danger), + label: Text(tr('지우기'), style: const TextStyle(color: JjColors.danger)), + ), + ]), + ), + ), + ]), + ); + } + + static String _date(DateTime d) { + String two(int x) => x.toString().padLeft(2, '0'); + return '${d.year}-${two(d.month)}-${two(d.day)} ${two(d.hour)}:${two(d.minute)}'; + } +} diff --git a/test/explorer_search_test.dart b/test/explorer_search_test.dart new file mode 100644 index 0000000..1521132 --- /dev/null +++ b/test/explorer_search_test.dart @@ -0,0 +1,145 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/core/file_ops.dart'; +import 'package:jj_mkvmaker/ui/explorer_search.dart'; +import 'package:path/path.dart' as p; + +/// 79: 찾기 조건 (크기 · 바뀐 날 · 종류) · 결과 목록 화면 (2000개마다 멈춤 · 더 보기, 고르고 복사 · 이동 · 지우기, 누르면 열기) +void main() { + final now = DateTime(2026, 10, 17, 12); + FileEntry f(String name, {int size = 0, int daysAgo = 0}) => + FileEntry('/x/$name', isDir: false, size: size, modified: now.subtract(Duration(days: daysAgo))); + + test('조건: "1GB 넘는 30일 지난 영상"', () { + const gb = 1024 * 1024 * 1024; + const c = SearchConditions(minBytes: gb, olderDays: 30, kind: 'video'); + expect(c.accepts(f('a.mkv', size: 2 * gb, daysAgo: 40), now: now), isTrue); + expect(c.accepts(f('a.mkv', size: gb ~/ 2, daysAgo: 40), now: now), isFalse, reason: '작다'); + expect(c.accepts(f('a.mkv', size: 2 * gb, daysAgo: 3), now: now), isFalse, reason: '최근'); + expect(c.accepts(f('a.srt', size: 2 * gb, daysAgo: 40), now: now), isFalse, reason: '동영상 아님'); + expect(c.accepts(FileEntry('/x/d', isDir: true, modified: DateTime(2000)), now: now), isFalse, reason: '조건이 있으면 폴더는 아님'); + expect(c.summary, contains('1.0GB')); + }); + + test('조건: N일 안 · 확장자 직접 · 조건 없음', () { + expect(const SearchConditions(newerDays: 7).accepts(f('a', daysAgo: 3), now: now), isTrue); + expect(const SearchConditions(newerDays: 7).accepts(f('a', daysAgo: 9), now: now), isFalse); + const custom = SearchConditions(kind: 'custom', exts: '*.MKV, .mp4 webm'); + expect(custom.accepts(f('a.mkv'), now: now), isTrue); + expect(custom.accepts(f('a.webm'), now: now), isTrue); + expect(custom.accepts(f('a.avi'), now: now), isFalse); + expect(const SearchConditions(name: 'x').hasFileConditions, isFalse); + expect(const SearchConditions().isEmpty, isTrue); + }); + + group('searchFiles + accept', () { + late Directory tmp; + setUp(() { + tmp = Directory.systemTemp.createTempSync('jj_search79_'); + File(p.join(tmp.path, 'a', 'ep1.mkv')) + ..createSync(recursive: true) + ..writeAsBytesSync(List.filled(3000, 0)); + File(p.join(tmp.path, 'a', 'ep1.srt')).writeAsStringSync('x'); + File(p.join(tmp.path, 'small.mkv')).writeAsBytesSync(List.filled(10, 0)); + }); + tearDown(() => tmp.deleteSync(recursive: true)); + + test('이름 없이 조건만 · 이름 + 조건', () async { + const c = SearchConditions(minBytes: 1000, kind: 'video'); + expect(await searchFiles(tmp.path, '', accept: c.accepts).map((e) => e.name).toList(), ['ep1.mkv']); + expect(await searchFiles(tmp.path, 'ep1', accept: const SearchConditions(kind: 'subtitle').accepts).map((e) => e.name).toList(), + ['ep1.srt']); + expect(await searchFiles(tmp.path, '').toList(), isEmpty, reason: '이름도 조건도 없으면 찾지 않는다 (예전 그대로)'); + }); + }); + + group('결과 화면', () { + late Directory tmp; + setUp(() { + tmp = Directory.systemTemp.createTempSync('jj_search79_ui_'); + for (var i = 0; i < 5; i++) { + File(p.join(tmp.path, 'ep$i.mkv')).writeAsStringSync('$i'); + } + Directory(p.join(tmp.path, 'ep_dir')).createSync(); + }); + tearDown(() => tmp.deleteSync(recursive: true)); + + Future<(List, SearchAction? Function())> show(WidgetTester tester, {int chunk = 2000}) async { + tester.view.physicalSize = const Size(900, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final opened = []; + SearchAction? result; + final nav = GlobalKey(); + await tester.pumpWidget(MaterialApp(navigatorKey: nav, home: const Scaffold(body: Text('탐색기')))); + nav.currentState! + .push(MaterialPageRoute( + builder: (_) => SearchResultsPage( + root: tmp.path, + cond: const SearchConditions(name: 'ep'), + showHidden: false, + otherDir: p.join(tmp.path, 'dest'), + chunk: chunk, + onOpen: (e) async => opened.add(e), + ), + )) + .then((r) => result = r); + for (var i = 0; i < 40; i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 20))); + await tester.pump(); + } + return (opened, () => result); + } + + testWidgets('2개마다 멈추고 [2개 더 보기] - 끝까지', (tester) async { + await show(tester, chunk: 2); + expect(find.text('찾기 결과 · 2개'), findsOneWidget); + 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))); + await tester.pump(); + } + expect(find.text('찾기 결과 · 4개'), findsOneWidget); + await tester.tap(find.text('2개 더 보기')); + for (var i = 0; i < 20; i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 20))); + await tester.pump(); + } + expect(find.text('찾기 결과 · 6개'), findsOneWidget, reason: '파일 5 + 폴더 1'); + expect(find.textContaining('멈췄습니다'), findsNothing); + }); + + testWidgets('누르면 열기 (화면 그대로) · 폴더는 그 자리로 · 고르면 복사 → 다른 창 · 지우기', (tester) async { + final (opened, result) = await show(tester); + await tester.tap(find.text('ep1.mkv')); + await tester.pump(); + expect(opened.single.name, 'ep1.mkv'); + expect(find.text('찾기 결과 · 6개'), findsOneWidget, reason: '열어도 결과 화면은 그대로'); + + // 고르면 아래에 할 일 줄 (개수 · 크기), 고르는 동안 누르기 = 고르기 + expect(find.text('지우기'), findsNothing); + await tester.longPress(find.text('ep2.mkv')); + await tester.pump(); + await tester.tap(find.text('ep3.mkv')); + await tester.pump(); + expect(opened, hasLength(1), reason: '고르는 중에는 열지 않음'); + expect(find.text('2개 · 2B'), findsOneWidget); + expect(find.text('복사 → dest'), findsOneWidget); + await tester.tap(find.text('지우기')); + await tester.pumpAndSettle(); + expect(result()!.kind, 'delete'); + expect(result()!.paths.map(p.basename), unorderedEquals(['ep2.mkv', 'ep3.mkv']), reason: '고른 것만'); + }); + + testWidgets('폴더를 누르면 그 자리로', (tester) async { + final (_, result) = await show(tester); + await tester.tap(find.text('ep_dir')); + await tester.pumpAndSettle(); + expect(result()!.kind, 'reveal'); + expect(p.basename(result()!.paths.single), 'ep_dir'); + }); + }); +} diff --git a/test/explorer_test.dart b/test/explorer_test.dart index 7f53bc8..244aa08 100644 --- a/test/explorer_test.dart +++ b/test/explorer_test.dart @@ -13,6 +13,7 @@ import 'package:jj_mkvmaker/services/app_shell.dart'; import 'package:jj_mkvmaker/services/platform_services.dart'; import 'package:jj_mkvmaker/core/file_ops.dart' show formatSize; import 'package:jj_mkvmaker/ui/explorer_page.dart'; +import 'package:jj_mkvmaker/ui/explorer_search.dart'; import 'package:jj_mkvmaker/ui/path_label.dart'; import 'package:path/path.dart' as p; import 'support/fake_recycle_bin.dart'; @@ -870,6 +871,34 @@ void main() { expect(find.descendant(of: find.ancestor(of: find.text('rsub'), matching: find.byType(Material)).first, matching: find.byTooltip('고르기 취소')), findsNothing); // 창마다 하나만 }); + + testWidgets('79: 찾기 결과를 목록으로 - 이름 없이 종류 조건으로 찾고, 골라서 그대로 다른 창으로 복사', (tester) async { + await open(tester); + File(p.join(left, 'sub', 'big.mkv')) + ..createSync(recursive: true) + ..writeAsBytesSync(List.filled(2048, 1)); + await act(tester, () => tester.tap(find.text('찾기').first)); + await calm(tester); + // 이름은 비우고 종류만 (동영상) - 이름이 비면 [찾기] 가 꺼져 있다가 조건을 넣으면 켜진다 + expect(tester.widget(find.widgetWithText(FilledButton, '찾기')).onPressed, isNull); + await act(tester, () => tester.tap(find.widgetWithText(ChoiceChip, '동영상'))); + await calm(tester); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '찾기'))); + await settle(tester, () => find.text('big.mkv').evaluate().isNotEmpty); + expect(find.text('찾기 결과 · 1개'), findsOneWidget); + // (뒤의 탐색기 목록에는 doc.txt 가 있다 - 결과 화면 안만 본다) + expect(find.descendant(of: find.byType(SearchResultsPage), matching: find.text('doc.txt')), findsNothing, reason: '종류가 맞지 않음'); + // 고르면 아래에 할 일 줄 - 다른 창 (right) 으로 복사 + await act(tester, () => tester.tap(find.descendant(of: find.byType(SearchResultsPage), matching: find.byType(Checkbox)).first)); + await calm(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, 'big.mkv')).existsSync() && find.byType(LinearProgressIndicator).evaluate().isEmpty); + expect(File(p.join(right, 'big.mkv')).lengthSync(), 2048); + expect(File(p.join(left, 'sub', 'big.mkv')).existsSync(), isTrue); + }); } /// [name] 줄의 "표시" 동그라미 버튼