WIP 82 파일 탐색기 창 사이 끌어 놓기: 마우스 8px 끌기 · 터치 ≡ 손잡이, 놓을 곳 강조, 묻기 (복사 · 이동), 이동 [되돌리기] (검토 대기)

- 관리자 지시 (F11) · 판단: 창 사이 끌어 놓기로 복사 · 이동 (태블릿 · PC). 미리 보기 · 확인 · 되돌리기.
- 끌기: PC 마우스는 줄을 8px 넘게 움직였을 때만 (한 번 누르기 = 고르기 · 두 번 = 열기는 그대로).
  터치는 선택 모드에서 표시한 줄의 ≡ 손잡이 (길게 누르기 메뉴는 그대로). 누른 줄이 표시한 것이면 표시한 것 모두 (바깥 것만).
- 놓기: 다른 창의 폴더 줄 = 그 폴더 안, 창의 빈 곳 = 그 창의 지금 폴더. 놓을 수 있으면 강조, 못 놓는 곳 (같은 창 · 자기 안 ·
  원본 폴더 자신) 은 빨간 테두리이고 놓아도 아무 일 없음. WebDAV 창도 지금의 복사 · 이동으로.
- 설정 "끌어 놓기 기본": 묻기 (처음 값) / 복사 / 이동. 묻기 창은 [복사] 가 처음 고름 (Enter) · [이동] · [취소].
  어느 쪽이든 지금의 복사 · 이동 확인 창 (같은 이름 등) · 진행 패널을 그대로 탄다.
- 이동만 끝난 알림에 [되돌리기] (10초): 실제로 옮긴 것만 원래 자리로 (원래 폴더마다 지금의 이동 - 같은 이름이면 지금의 같은 이름 창).
  대상에 새로 생긴 항목은 통째로, 이미 있던 폴더에 합친 것은 앱이 옮긴 파일만 (TransferJob.movedFiles) -
  그 폴더 전체를 옮기지 않는다. robocopy 로 합친 폴더는 무엇을 옮겼는지 몰라 되돌리지 않고 알린다. 복사 되돌리기는 없음.
- 내 판단: 끝난 알림 (n개 옮김) 은 [되돌리기] 알림으로 바로 바꾼다 (진행 막대가 내려가기를 기다리지 않게).
- 시험: explorer_test 82 (놓는 곳 규칙, 4px 는 누르기, 같은 창 · 묻기 [취소] 는 아무 일 없음,
  이동 → 되돌리기 - 합친 폴더의 원래 파일은 그대로 · 옮긴 파일만 돌아옴).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
묶음6
RyuJongJin 2026-10-17 13:12:04 +09:00
parent b1f20914a0
commit 39dab23138
9 changed files with 375 additions and 10 deletions

View File

@ -474,6 +474,7 @@
"{0}: 체크한 동영상 {1}개를 {2} 로 옮깁니다": "{0}: moves the {1} checked videos to {2}", "{0}: 체크한 동영상 {1}개를 {2} 로 옮깁니다": "{0}: moves the {1} checked videos to {2}",
"{0}개": "{0}", "{0}개": "{0}",
"{0}개 · {1}": "{0} · {1}", "{0}개 · {1}": "{0} · {1}",
"{0}개 · {1} …": "{0} items · {1} …",
"{0}개 · 주소 {1}개": "{0} · {1} links", "{0}개 · 주소 {1}개": "{0} · {1} links",
"{0}개 더 보기": "Show {0} more", "{0}개 더 보기": "Show {0} more",
"{0}개 맞춤": "{0} synced", "{0}개 맞춤": "{0} synced",
@ -488,6 +489,7 @@
"{0}개 풀기": "Extract {0}", "{0}개 풀기": "Extract {0}",
"{0}개 항목": "{0} items", "{0}개 항목": "{0} items",
"{0}개 항목을 {1}": "{0} items {1}", "{0}개 항목을 {1}": "{0} items {1}",
"{0}개 항목을 놓았습니다": "Dropped {0} items",
"{0}개 항목을 다음 폴더로 {1}\n{2}": "{0} items to this folder — {1}\n{2}", "{0}개 항목을 다음 폴더로 {1}\n{2}": "{0} items to this folder — {1}\n{2}",
"{0}개 항목을 다음 폴더로 복사할까요?\n{1}": "Copy {0} items to this folder?\n{1}", "{0}개 항목을 다음 폴더로 복사할까요?\n{1}": "Copy {0} items to this folder?\n{1}",
"{0}개 항목을 다음 폴더로 옮길까요?\n{1}": "Move {0} items to this folder?\n{1}", "{0}개 항목을 다음 폴더로 옮길까요?\n{1}": "Move {0} items to this folder?\n{1}",
@ -504,6 +506,7 @@
"{0}개를 영구 삭제했습니다.": "Permanently deleted {0} item(s).", "{0}개를 영구 삭제했습니다.": "Permanently deleted {0} item(s).",
"{0}개를 옮겼습니다 → {1}": "Moved {0} → {1}", "{0}개를 옮겼습니다 → {1}": "Moved {0} → {1}",
"{0}개를 옮겼습니다 → {1} ({2})": "Moved {0} → {1} ({2})", "{0}개를 옮겼습니다 → {1} ({2})": "Moved {0} → {1} ({2})",
"{0}개를 옮겼습니다.": "Moved {0} items.",
"{0}개를 찾아 여기서 멈췄습니다. 이름을 더 자세히 넣거나 아래 폴더로 들어가 찾으세요.": "Stopped after finding {0}. Enter a more specific name or search inside a subfolder.", "{0}개를 찾아 여기서 멈췄습니다. 이름을 더 자세히 넣거나 아래 폴더로 들어가 찾으세요.": "Stopped after finding {0}. Enter a more specific name or search inside a subfolder.",
"{0}개를 풀었습니다: {1}": "Extracted {0}: {1}", "{0}개를 풀었습니다: {1}": "Extracted {0}: {1}",
"{0}개에서 멈췄습니다": "Stopped at {0}", "{0}개에서 멈췄습니다": "Stopped at {0}",
@ -712,7 +715,9 @@
"끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). Chrome 엔진은 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "When off, page scripts do not run (fewer ads · trackers, but script-built sites such as YouTube will not show). With the Chrome engine, page translation · video detection also stop.", "끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). Chrome 엔진은 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "When off, page scripts do not run (fewer ads · trackers, but script-built sites such as YouTube will not show). With the Chrome engine, page translation · video detection also stop.",
"끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "When off, page scripts do not run (fewer ads · trackers, but script-built sites such as YouTube will not show). Page translation · video detection also stop.", "끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "When off, page scripts do not run (fewer ads · trackers, but script-built sites such as YouTube will not show). Page translation · video detection also stop.",
"끄면 환경 설정 · 다운로드 목록 · 다른 화면을 보는 동안에도 웹 페이지의 소리가 계속 들립니다": "When off, the web page keeps playing sound while you look at settings, the download list or other screens", "끄면 환경 설정 · 다운로드 목록 · 다른 화면을 보는 동안에도 웹 페이지의 소리가 계속 들립니다": "When off, the web page keeps playing sound while you look at settings, the download list or other screens",
"끌어 놓기 기본": "Drag and drop default",
"끌어다 놓은 항목에 동영상이 없습니다.": "There are no videos in the dropped items.", "끌어다 놓은 항목에 동영상이 없습니다.": "There are no videos in the dropped items.",
"끌어서 다른 창으로": "Drag to the other pane",
"끝": "End", "끝": "End",
"끝 (오른쪽 · 아래)": "Edge (right · bottom)", "끝 (오른쪽 · 아래)": "Edge (right · bottom)",
"끝 = 현재": "End = now", "끝 = 현재": "End = now",
@ -810,6 +815,7 @@
"다른 저장소로는 이름만 바꿀 수 없습니다": "Cannot just rename onto another storage", "다른 저장소로는 이름만 바꿀 수 없습니다": "Cannot just rename onto another storage",
"다른 창 폴더로": "To the other pane's folder", "다른 창 폴더로": "To the other pane's folder",
"다른 창에서 열기": "Open in other pane", "다른 창에서 열기": "Open in other pane",
"다른 창으로 끌어 놓았을 때. 어느 쪽이든 복사 · 이동 확인 창 (같은 이름 등) 은 그대로 뜹니다": "When you drop onto the other pane. Either way, the copy/move confirmation (same names etc.) still appears",
"다른 창으로 복사": "Copy to other pane", "다른 창으로 복사": "Copy to other pane",
"다른 창으로 이동": "Move to other pane", "다른 창으로 이동": "Move to other pane",
"다른 폴더로 이동": "Move to another folder", "다른 폴더로 이동": "Move to another folder",
@ -1074,6 +1080,7 @@
"문자셋 경고": "Charset warning", "문자셋 경고": "Charset warning",
"문제 {0}개 - 고치기 전에는 바꾸지 않습니다": "{0} problems - nothing is renamed until they're fixed", "문제 {0}개 - 고치기 전에는 바꾸지 않습니다": "{0} problems - nothing is renamed until they're fixed",
"묻기": "Ask", "묻기": "Ask",
"묻기 (기본)": "Ask (default)",
"물어보지 않기": "Never ask", "물어보지 않기": "Never ask",
"미리 보기": "Quick look", "미리 보기": "Quick look",
"미리 볼 수 없습니다: {0}": "Cannot preview: {0}", "미리 볼 수 없습니다: {0}": "Cannot preview: {0}",
@ -2353,6 +2360,7 @@
"한국어": "Korean", "한국어": "Korean",
"한글": "Hangul", "한글": "Hangul",
"할 일": "Task", "할 일": "Task",
"합친 폴더 {0}개는 무엇을 옮겼는지 몰라 되돌리지 않습니다.": "{0} merged folders can't be undone (what was moved isn't known).",
"항목 {0}/{1}": "Items {0}/{1}", "항목 {0}/{1}": "Items {0}/{1}",
"항목마다 따로 (기본)": "Each item separately (default)", "항목마다 따로 (기본)": "Each item separately (default)",
"해상도 올리기 메모리": "Upscaling memory", "해상도 올리기 메모리": "Upscaling memory",

View File

@ -474,6 +474,7 @@
"{0}: 체크한 동영상 {1}개를 {2} 로 옮깁니다": "{0}: チェックした動画 {1}個を {2} に移動します", "{0}: 체크한 동영상 {1}개를 {2} 로 옮깁니다": "{0}: チェックした動画 {1}個を {2} に移動します",
"{0}개": "{0}個", "{0}개": "{0}個",
"{0}개 · {1}": "{0}個・{1}", "{0}개 · {1}": "{0}個・{1}",
"{0}개 · {1} …": "{0} 件 · {1} …",
"{0}개 · 주소 {1}개": "{0}個・URL {1}個", "{0}개 · 주소 {1}개": "{0}個・URL {1}個",
"{0}개 더 보기": "さらに {0} 件表示", "{0}개 더 보기": "さらに {0} 件表示",
"{0}개 맞춤": "{0} 件を同期", "{0}개 맞춤": "{0} 件を同期",
@ -488,6 +489,7 @@
"{0}개 풀기": "{0} 個を展開", "{0}개 풀기": "{0} 個を展開",
"{0}개 항목": "{0} 項目", "{0}개 항목": "{0} 項目",
"{0}개 항목을 {1}": "{0} 件の項目を{1}", "{0}개 항목을 {1}": "{0} 件の項目を{1}",
"{0}개 항목을 놓았습니다": "{0} 件の項目をドロップしました",
"{0}개 항목을 다음 폴더로 {1}\n{2}": "{0} 件の項目を次のフォルダーへ{1}\n{2}", "{0}개 항목을 다음 폴더로 {1}\n{2}": "{0} 件の項目を次のフォルダーへ{1}\n{2}",
"{0}개 항목을 다음 폴더로 복사할까요?\n{1}": "{0} 件の項目を次のフォルダーへコピーしますか?\n{1}", "{0}개 항목을 다음 폴더로 복사할까요?\n{1}": "{0} 件の項目を次のフォルダーへコピーしますか?\n{1}",
"{0}개 항목을 다음 폴더로 옮길까요?\n{1}": "{0} 件の項目を次のフォルダーへ移動しますか?\n{1}", "{0}개 항목을 다음 폴더로 옮길까요?\n{1}": "{0} 件の項目を次のフォルダーへ移動しますか?\n{1}",
@ -504,6 +506,7 @@
"{0}개를 영구 삭제했습니다.": "{0} 件を完全に削除しました。", "{0}개를 영구 삭제했습니다.": "{0} 件を完全に削除しました。",
"{0}개를 옮겼습니다 → {1}": "{0}個を移動しました → {1}", "{0}개를 옮겼습니다 → {1}": "{0}個を移動しました → {1}",
"{0}개를 옮겼습니다 → {1} ({2})": "{0}個を移動しました → {1}({2})", "{0}개를 옮겼습니다 → {1} ({2})": "{0}個を移動しました → {1}({2})",
"{0}개를 옮겼습니다.": "{0} 件を移動しました。",
"{0}개를 찾아 여기서 멈췄습니다. 이름을 더 자세히 넣거나 아래 폴더로 들어가 찾으세요.": "{0} 件見つけたところで止めました。名前をより詳しく入力するか、下のフォルダーに入って探してください。", "{0}개를 찾아 여기서 멈췄습니다. 이름을 더 자세히 넣거나 아래 폴더로 들어가 찾으세요.": "{0} 件見つけたところで止めました。名前をより詳しく入力するか、下のフォルダーに入って探してください。",
"{0}개를 풀었습니다: {1}": "{0} 個を展開しました: {1}", "{0}개를 풀었습니다: {1}": "{0} 個を展開しました: {1}",
"{0}개에서 멈췄습니다": "{0} 件で停止しました", "{0}개에서 멈췄습니다": "{0} 件で停止しました",
@ -712,7 +715,9 @@
"끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). Chrome 엔진은 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "オフにするとページのスクリプトを実行しません (広告 · 追跡は減りますが、YouTube のようにスクリプトで作られたサイトは表示されません)。Chrome エンジンではページ翻訳 · 動画の検出も止まります。", "끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). Chrome 엔진은 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "オフにするとページのスクリプトを実行しません (広告 · 追跡は減りますが、YouTube のようにスクリプトで作られたサイトは表示されません)。Chrome エンジンではページ翻訳 · 動画の検出も止まります。",
"끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "オフにするとページのスクリプトを実行しません (広告 · 追跡は減りますが、YouTube のようにスクリプトで作られたサイトは表示されません)。ページ翻訳 · 動画の検出も止まります。", "끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "オフにするとページのスクリプトを実行しません (広告 · 追跡は減りますが、YouTube のようにスクリプトで作られたサイトは表示されません)。ページ翻訳 · 動画の検出も止まります。",
"끄면 환경 설정 · 다운로드 목록 · 다른 화면을 보는 동안에도 웹 페이지의 소리가 계속 들립니다": "オフにすると、環境設定・ダウンロード一覧・他の画面を見ている間も Web ページの音が流れ続けます", "끄면 환경 설정 · 다운로드 목록 · 다른 화면을 보는 동안에도 웹 페이지의 소리가 계속 들립니다": "オフにすると、環境設定・ダウンロード一覧・他の画面を見ている間も Web ページの音が流れ続けます",
"끌어 놓기 기본": "ドラッグ&ドロップの既定",
"끌어다 놓은 항목에 동영상이 없습니다.": "ドロップした項目に動画がありません。", "끌어다 놓은 항목에 동영상이 없습니다.": "ドロップした項目に動画がありません。",
"끌어서 다른 창으로": "ドラッグしてもう一方のウィンドウへ",
"끝": "終了", "끝": "終了",
"끝 (오른쪽 · 아래)": "端 (右 · 下)", "끝 (오른쪽 · 아래)": "端 (右 · 下)",
"끝 = 현재": "終了 = 現在", "끝 = 현재": "終了 = 現在",
@ -810,6 +815,7 @@
"다른 저장소로는 이름만 바꿀 수 없습니다": "別のストレージへは名前の変更だけではできません", "다른 저장소로는 이름만 바꿀 수 없습니다": "別のストレージへは名前の変更だけではできません",
"다른 창 폴더로": "もう一方の画面のフォルダーへ", "다른 창 폴더로": "もう一方の画面のフォルダーへ",
"다른 창에서 열기": "もう一方のペインで開く", "다른 창에서 열기": "もう一方のペインで開く",
"다른 창으로 끌어 놓았을 때. 어느 쪽이든 복사 · 이동 확인 창 (같은 이름 등) 은 그대로 뜹니다": "もう一方のウィンドウにドロップしたとき。どちらでもコピー・移動の確認 (同じ名前など) は表示されます",
"다른 창으로 복사": "もう一方のペインへコピー", "다른 창으로 복사": "もう一方のペインへコピー",
"다른 창으로 이동": "もう一方のペインへ移動", "다른 창으로 이동": "もう一方のペインへ移動",
"다른 폴더로 이동": "他のフォルダーに移動", "다른 폴더로 이동": "他のフォルダーに移動",
@ -1074,6 +1080,7 @@
"문자셋 경고": "文字コードの警告", "문자셋 경고": "文字コードの警告",
"문제 {0}개 - 고치기 전에는 바꾸지 않습니다": "問題 {0} 件 - 直すまで変更しません", "문제 {0}개 - 고치기 전에는 바꾸지 않습니다": "問題 {0} 件 - 直すまで変更しません",
"묻기": "確認する", "묻기": "確認する",
"묻기 (기본)": "確認する (既定)",
"물어보지 않기": "尋ねない", "물어보지 않기": "尋ねない",
"미리 보기": "クイックプレビュー", "미리 보기": "クイックプレビュー",
"미리 볼 수 없습니다: {0}": "プレビューできません: {0}", "미리 볼 수 없습니다: {0}": "プレビューできません: {0}",
@ -2353,6 +2360,7 @@
"한국어": "韓国語", "한국어": "韓国語",
"한글": "ハングル", "한글": "ハングル",
"할 일": "内容", "할 일": "内容",
"합친 폴더 {0}개는 무엇을 옮겼는지 몰라 되돌리지 않습니다.": "結合したフォルダー {0} 件は、何を移動したか分からないため元に戻しません。",
"항목 {0}/{1}": "項目 {0}/{1}", "항목 {0}/{1}": "項目 {0}/{1}",
"항목마다 따로 (기본)": "項目ごとに (既定)", "항목마다 따로 (기본)": "項目ごとに (既定)",
"해상도 올리기 메모리": "高解像度化のメモリ", "해상도 올리기 메모리": "高解像度化のメモリ",

View File

@ -474,6 +474,7 @@
"{0}: 체크한 동영상 {1}개를 {2} 로 옮깁니다": "{0}: 将勾选的 {1} 个视频移到 {2}", "{0}: 체크한 동영상 {1}개를 {2} 로 옮깁니다": "{0}: 将勾选的 {1} 个视频移到 {2}",
"{0}개": "{0} 个", "{0}개": "{0} 个",
"{0}개 · {1}": "{0} 个 · {1}", "{0}개 · {1}": "{0} 个 · {1}",
"{0}개 · {1} …": "{0} 项 · {1} …",
"{0}개 · 주소 {1}개": "{0} 个 · 地址 {1} 个", "{0}개 · 주소 {1}개": "{0} 个 · 地址 {1} 个",
"{0}개 더 보기": "再显示 {0} 个", "{0}개 더 보기": "再显示 {0} 个",
"{0}개 맞춤": "已同步 {0} 个", "{0}개 맞춤": "已同步 {0} 个",
@ -488,6 +489,7 @@
"{0}개 풀기": "解压 {0} 个", "{0}개 풀기": "解压 {0} 个",
"{0}개 항목": "{0} 个项目", "{0}개 항목": "{0} 个项目",
"{0}개 항목을 {1}": "{0} 个项目{1}", "{0}개 항목을 {1}": "{0} 个项目{1}",
"{0}개 항목을 놓았습니다": "已放下 {0} 项",
"{0}개 항목을 다음 폴더로 {1}\n{2}": "将 {0} 个项目到以下文件夹 — {1}\n{2}", "{0}개 항목을 다음 폴더로 {1}\n{2}": "将 {0} 个项目到以下文件夹 — {1}\n{2}",
"{0}개 항목을 다음 폴더로 복사할까요?\n{1}": "要将 {0} 个项目复制到以下文件夹吗?\n{1}", "{0}개 항목을 다음 폴더로 복사할까요?\n{1}": "要将 {0} 个项目复制到以下文件夹吗?\n{1}",
"{0}개 항목을 다음 폴더로 옮길까요?\n{1}": "要将 {0} 个项目移动到以下文件夹吗?\n{1}", "{0}개 항목을 다음 폴더로 옮길까요?\n{1}": "要将 {0} 个项目移动到以下文件夹吗?\n{1}",
@ -504,6 +506,7 @@
"{0}개를 영구 삭제했습니다.": "已永久删除 {0} 个项目。", "{0}개를 영구 삭제했습니다.": "已永久删除 {0} 个项目。",
"{0}개를 옮겼습니다 → {1}": "已移动 {0} 个 → {1}", "{0}개를 옮겼습니다 → {1}": "已移动 {0} 个 → {1}",
"{0}개를 옮겼습니다 → {1} ({2})": "已移动 {0} 个 → {1}({2})", "{0}개를 옮겼습니다 → {1} ({2})": "已移动 {0} 个 → {1}({2})",
"{0}개를 옮겼습니다.": "已移动 {0} 项。",
"{0}개를 찾아 여기서 멈췄습니다. 이름을 더 자세히 넣거나 아래 폴더로 들어가 찾으세요.": "找到 {0} 个后已停止。请输入更具体的名称或进入下级文件夹查找。", "{0}개를 찾아 여기서 멈췄습니다. 이름을 더 자세히 넣거나 아래 폴더로 들어가 찾으세요.": "找到 {0} 个后已停止。请输入更具体的名称或进入下级文件夹查找。",
"{0}개를 풀었습니다: {1}": "已解压 {0} 个: {1}", "{0}개를 풀었습니다: {1}": "已解压 {0} 个: {1}",
"{0}개에서 멈췄습니다": "已在 {0} 个处暂停", "{0}개에서 멈췄습니다": "已在 {0} 个处暂停",
@ -712,7 +715,9 @@
"끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). Chrome 엔진은 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "关闭后不运行页面脚本 (广告 · 跟踪会减少,但 YouTube 等由脚本构建的网站将无法显示)。使用 Chrome 引擎时,页面翻译 · 视频检测也会停止。", "끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). Chrome 엔진은 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "关闭后不运行页面脚本 (广告 · 跟踪会减少,但 YouTube 等由脚本构建的网站将无法显示)。使用 Chrome 引擎时,页面翻译 · 视频检测也会停止。",
"끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "关闭后不运行页面脚本 (广告 · 跟踪会减少,但 YouTube 等由脚本构建的网站将无法显示)。页面翻译 · 视频检测也会停止。", "끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "关闭后不运行页面脚本 (广告 · 跟踪会减少,但 YouTube 等由脚本构建的网站将无法显示)。页面翻译 · 视频检测也会停止。",
"끄면 환경 설정 · 다운로드 목록 · 다른 화면을 보는 동안에도 웹 페이지의 소리가 계속 들립니다": "关闭后,在查看设置、下载列表或其他界面时网页声音会继续播放", "끄면 환경 설정 · 다운로드 목록 · 다른 화면을 보는 동안에도 웹 페이지의 소리가 계속 들립니다": "关闭后,在查看设置、下载列表或其他界面时网页声音会继续播放",
"끌어 놓기 기본": "拖放默认操作",
"끌어다 놓은 항목에 동영상이 없습니다.": "拖入的项目中没有视频。", "끌어다 놓은 항목에 동영상이 없습니다.": "拖入的项目中没有视频。",
"끌어서 다른 창으로": "拖到另一个窗格",
"끝": "结束", "끝": "结束",
"끝 (오른쪽 · 아래)": "边缘 (右 · 下)", "끝 (오른쪽 · 아래)": "边缘 (右 · 下)",
"끝 = 현재": "结束 = 当前", "끝 = 현재": "结束 = 当前",
@ -810,6 +815,7 @@
"다른 저장소로는 이름만 바꿀 수 없습니다": "不能仅通过重命名移到其他存储", "다른 저장소로는 이름만 바꿀 수 없습니다": "不能仅通过重命名移到其他存储",
"다른 창 폴더로": "到另一窗格的文件夹", "다른 창 폴더로": "到另一窗格的文件夹",
"다른 창에서 열기": "在另一窗格中打开", "다른 창에서 열기": "在另一窗格中打开",
"다른 창으로 끌어 놓았을 때. 어느 쪽이든 복사 · 이동 확인 창 (같은 이름 등) 은 그대로 뜹니다": "拖放到另一个窗格时。无论哪种,都会显示复制/移动确认 (同名等)",
"다른 창으로 복사": "复制到另一窗格", "다른 창으로 복사": "复制到另一窗格",
"다른 창으로 이동": "移动到另一窗格", "다른 창으로 이동": "移动到另一窗格",
"다른 폴더로 이동": "移动到其他文件夹", "다른 폴더로 이동": "移动到其他文件夹",
@ -1074,6 +1080,7 @@
"문자셋 경고": "字符集警告", "문자셋 경고": "字符集警告",
"문제 {0}개 - 고치기 전에는 바꾸지 않습니다": "{0} 个问题 - 修正前不会重命名", "문제 {0}개 - 고치기 전에는 바꾸지 않습니다": "{0} 个问题 - 修正前不会重命名",
"묻기": "询问", "묻기": "询问",
"묻기 (기본)": "询问 (默认)",
"물어보지 않기": "不询问", "물어보지 않기": "不询问",
"미리 보기": "快速预览", "미리 보기": "快速预览",
"미리 볼 수 없습니다: {0}": "无法预览: {0}", "미리 볼 수 없습니다: {0}": "无法预览: {0}",
@ -2353,6 +2360,7 @@
"한국어": "韩语", "한국어": "韩语",
"한글": "韩文", "한글": "韩文",
"할 일": "任务", "할 일": "任务",
"합친 폴더 {0}개는 무엇을 옮겼는지 몰라 되돌리지 않습니다.": "{0} 个合并的文件夹无法撤销 (不清楚移动了哪些内容)。",
"항목 {0}/{1}": "项目 {0}/{1}", "항목 {0}/{1}": "项目 {0}/{1}",
"항목마다 따로 (기본)": "逐个项目 (默认)", "항목마다 따로 (기본)": "逐个项目 (默认)",
"해상도 올리기 메모리": "提升分辨率内存", "해상도 올리기 메모리": "提升分辨率内存",

View File

@ -422,6 +422,9 @@ class AppSettings {
/// 80: 폴더 크기 계산 - 'sort' 크기순 정렬일 때만 (처음 값) · 'always' 늘 · 'off' 안 함 (길게 누르기 [폴더 크기 계산] 은 늘 됨) /// 80: 폴더 크기 계산 - 'sort' 크기순 정렬일 때만 (처음 값) · 'always' 늘 · 'off' 안 함 (길게 누르기 [폴더 크기 계산] 은 늘 됨)
String explorerFolderSize = 'sort'; String explorerFolderSize = 'sort';
/// 82: 창 사이 끌어 놓기 - 'ask' 묻기 (처음 값, 관리자 판단) · 'copy' 복사 · 'move' 이동
String explorerDropAction = 'ask';
bool explorerShowHidden = false; bool explorerShowHidden = false;
// ── 복사 · 이동 (파일 탐색기) · rsync · lsync (Rsync 화면) ── // ── 복사 · 이동 (파일 탐색기) · rsync · lsync (Rsync 화면) ──
@ -741,6 +744,7 @@ class AppSettings {
'explorerSort': explorerSort, 'explorerSort': explorerSort,
'explorerSortDesc': explorerSortDesc, 'explorerSortDesc': explorerSortDesc,
'explorerFolderSize': explorerFolderSize, 'explorerFolderSize': explorerFolderSize,
'explorerDropAction': explorerDropAction,
'explorerSortPerFolder': explorerSortPerFolder, 'explorerSortPerFolder': explorerSortPerFolder,
'explorerFolderSorts': explorerFolderSorts, 'explorerFolderSorts': explorerFolderSorts,
'explorerShowHidden': explorerShowHidden, 'explorerShowHidden': explorerShowHidden,
@ -958,6 +962,7 @@ class AppSettings {
..explorerSortDesc = j['explorerSortDesc'] as bool? ?? false ..explorerSortDesc = j['explorerSortDesc'] as bool? ?? false
..explorerFolderSize = const ['sort', 'always', 'off'].contains(j['explorerFolderSize']) ? j['explorerFolderSize'] as String : 'sort' ..explorerFolderSize = const ['sort', 'always', 'off'].contains(j['explorerFolderSize']) ? j['explorerFolderSize'] as String : 'sort'
..explorerSortPerFolder = j['explorerSortPerFolder'] == true ..explorerSortPerFolder = j['explorerSortPerFolder'] == true
..explorerDropAction = const ['ask', 'copy', 'move'].contains(j['explorerDropAction']) ? j['explorerDropAction'] as String : 'ask'
..explorerFolderSorts = { ..explorerFolderSorts = {
if (j['explorerFolderSorts'] is Map) if (j['explorerFolderSorts'] is Map)
for (final e in (j['explorerFolderSorts'] as Map).entries) for (final e in (j['explorerFolderSorts'] as Map).entries)

View File

@ -169,6 +169,9 @@ class TransferJob extends ChangeNotifier {
bool cancelled = false; bool cancelled = false;
final made = <String>[]; final made = <String>[];
/// 82: 앱이 하나씩 복사 · 이동한 원본 파일 (폴더를 합칠 때 실제로 옮긴 것만 되돌리려고 - 이름 바꾸기로 통째 옮긴 것은 [made] 만)
final movedFiles = <String>[];
/// 마지막 출력 몇 줄 (실패했을 때 보여 줌) /// 마지막 출력 몇 줄 (실패했을 때 보여 줌)
final log = <String>[]; final log = <String>[];
@ -321,6 +324,7 @@ class TransferJob extends ChangeNotifier {
bandwidthKBps: bandwidthKBps, bandwidthKBps: bandwidthKBps,
onFileDone: (src) { onFileDone: (src) {
filesDone[i]++; filesDone[i]++;
movedFiles.add(src);
currentFile = vBasename(src); currentFile = vBasename(src);
_tick(); _tick();
}, },

99
lib/ui/explorer_drag.dart Normal file
View File

@ -0,0 +1,99 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import '../core/file_ops.dart';
import '../core/vfs.dart';
import '../l10n/tr.dart';
import 'theme.dart';
/// 82: 창 사이 끌어 놓기로 옮기는 것 - 어느 창 ([from], 창 번호) 의 어떤 항목들
class DragLoad {
final int from;
final List<String> paths;
const DragLoad(this.from, this.paths);
}
/// 82: [load] 를 [to] 창의 [dest] 폴더에 놓을 수 있는지 - 같은 창 · 자기 안 · 원본 폴더 자신은 안 됨
bool canDropInto(DragLoad load, int to, String dest) {
if (load.from == to || load.paths.isEmpty) return false;
if (load.paths.any((x) => isSameOrInside(dest, x))) return false;
if (load.paths.every((x) => samePath(vDirname(x), dest))) return false;
return true;
}
/// 82: 마우스로 [slop] (8px) 넘게 움직였을 때만 끌기 - 한 번 누르기 (고르기) · 두 번 누르기 (열기) 가 흔들리지 않게
class MouseDraggable<T extends Object> extends Draggable<T> {
final double slop;
const MouseDraggable({
super.key,
required super.child,
required super.feedback,
super.data,
super.childWhenDragging,
super.onDragStarted,
super.onDragEnd,
this.slop = 8,
});
@override
MultiDragGestureRecognizer createRecognizer(GestureMultiDragStartCallback onStart) =>
_SlopMultiDrag(slop, supportedDevices: const {PointerDeviceKind.mouse})..onStart = onStart;
}
class _SlopMultiDrag extends MultiDragGestureRecognizer {
final double slop;
_SlopMultiDrag(this.slop, {super.supportedDevices}) : super(debugOwner: null);
@override
MultiDragPointerState createNewPointerState(PointerDownEvent event) => _SlopState(slop, event.position, event.kind, gestureSettings);
@override
String get debugDescription => 'explorer drag';
}
class _SlopState extends MultiDragPointerState {
final double slop;
_SlopState(this.slop, super.initialPosition, super.kind, super.gestureSettings);
@override
void checkForResolutionAfterMove() {
if (pendingDelta!.distance > slop) resolve(GestureDisposition.accepted);
}
@override
void accepted(GestureMultiDragStartCallback starter) => starter(initialPosition);
}
/// 82: 끄는 동안 손가락 · 마우스 옆에 보이는 것: "n개 · 첫 이름"
Widget dragFeedback(List<String> paths) => Material(
color: Colors.transparent,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: JjColors.panelHigh,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: JjColors.accent),
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
const Icon(Icons.drive_file_move_outline, size: 16, color: JjColors.accent),
const SizedBox(width: 6),
Text(paths.length == 1 ? vBasename(paths.first) : trf('{0}개 · {1} …', [paths.length, vBasename(paths.first)]),
style: const TextStyle(fontSize: 12, color: JjColors.text)),
]),
),
);
/// 82: 놓았을 때 묻기 - 복사 (Enter, 처음 고름) · 이동 · 취소. 취소하면 null
Future<bool?> askDropAction(BuildContext context, List<String> paths, String dest) => showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(trf('{0}개 항목을 놓았습니다', [paths.length])),
content: Text('${vDisplay(dest)}\n${paths.take(5).map(vBasename).join(', ')}${paths.length > 5 ? ' …' : ''}'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('취소'))),
OutlinedButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('이동'))),
FilledButton(autofocus: true, onPressed: () => Navigator.pop(ctx, false), child: Text(tr('복사'))),
],
),
);

View File

@ -45,6 +45,7 @@ import '../l10n/tr.dart';
import 'explorer_bookmarks.dart'; import 'explorer_bookmarks.dart';
import 'explorer_search.dart'; import 'explorer_search.dart';
import 'quick_look.dart'; import 'quick_look.dart';
import 'explorer_drag.dart';
import 'batch_rename_dialog.dart'; import 'batch_rename_dialog.dart';
import '../core/folder_size.dart'; import '../core/folder_size.dart';
import '../core/batch_rename.dart'; import '../core/batch_rename.dart';
@ -2127,7 +2128,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
} }
/// [dest] 로 복사 · 이동 (확인 · 아래 진행 막대 · 취소). 끝까지 했으면 true. /// [dest] 로 복사 · 이동 (확인 · 아래 진행 막대 · 취소). 끝까지 했으면 true.
Future<bool> _runTransfer(_Pane pane, List<String> sources, String dest, {required bool move}) async { Future<bool> _runTransfer(_Pane pane, List<String> sources, String dest,
{required bool move, void Function(TransferJob job)? onFinished}) async {
if (sources.isEmpty) { if (sources.isEmpty) {
_snack(_pickFirst(move)); _snack(_pickFirst(move));
return false; return false;
@ -2330,6 +2332,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
if (job.skipped.isNotEmpty) trf('같은 이름이라 건너뜀: {0}', [_namesOf(job.skipped)]), if (job.skipped.isNotEmpty) trf('같은 이름이라 건너뜀: {0}', [_namesOf(job.skipped)]),
if (skippedHere.isNotEmpty) trf('이미 이 폴더에 있어 건너뜀: {0}', [_namesOf(skippedHere)]), if (skippedHere.isNotEmpty) trf('이미 이 폴더에 있어 건너뜀: {0}', [_namesOf(skippedHere)]),
].join(' · ')); ].join(' · '));
// 82: 끌어 놓기의 이동 되돌리기 (끝난 알림 바로 뒤에 - 진행 막대가 내려가기를 기다리지 않게)
if (error == null && mounted) onFinished?.call(job);
// 다 되면 진행 막대가 잠깐 100% 를 보인 뒤 내려간다 // 다 되면 진행 막대가 잠깐 100% 를 보인 뒤 내려간다
await Future<void>.delayed(const Duration(milliseconds: 1200)); await Future<void>.delayed(const Duration(milliseconds: 1200));
if (mounted && _jobs.contains(job)) setState(() => _jobs = [for (final j in _jobs) if (j != job) j]); if (mounted && _jobs.contains(job)) setState(() => _jobs = [for (final j in _jobs) if (j != job) j]);
@ -3401,19 +3405,113 @@ class _ExplorerPageState extends State<ExplorerPage> {
onTapDown: (_) { onTapDown: (_) {
if (_active != i) setState(() => _active = i); if (_active != i) setState(() => _active = i);
}, },
child: Container( // 82: 창의 빈 곳에 놓으면 그 창의 지금 폴더로. 놓을 수 있으면 강조, 못 놓으면 (같은 창 등) 빨간 테두리
child: DragTarget<DragLoad>(
onWillAcceptWithDetails: (d) => canDropInto(d.data, i, _panes[i].current),
onAcceptWithDetails: (d) => unawaited(_onDrop(d.data, i, _panes[i].current)),
builder: (context, ok, no) => Container(
margin: const EdgeInsets.all(4), margin: const EdgeInsets.all(4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: JjColors.bg, color: ok.isNotEmpty ? JjColors.accent.withValues(alpha: 0.06) : JjColors.bg,
border: Border.all(color: active ? JjColors.accent : JjColors.border, width: active ? 1.5 : 1), border: Border.all(
color: ok.isNotEmpty
? JjColors.accent
: no.isNotEmpty
? JjColors.danger
: active
? JjColors.accent
: JjColors.border,
width: ok.isNotEmpty || no.isNotEmpty ? 2 : (active ? 1.5 : 1)),
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
), ),
child: Column(children: children), child: Column(children: children),
), ),
), ),
),
); );
} }
/// 82: 끌 항목 - 누른 것이 표시한 것 중 하나면 표시한 것 모두 (바깥 것만), 아니면 그것 하나
List<String> _dragPaths(_Pane pane, FileEntry e) =>
_isMarked(pane, e.path) && pane.marked.isNotEmpty ? outermostPaths(pane.marked) : [e.path];
/// 82: 놓음 - 설정 "끌어 놓기 기본" (묻기 · 복사 · 이동) 뒤 지금의 복사 · 이동 (확인 창 · 같은 이름 · 진행 패널).
/// 이동이 끝나면 아래 알림에 [되돌리기] (10초)
Future<void> _onDrop(DragLoad load, int to, String dest) async {
if (!canDropInto(load, to, dest) || !mounted) return;
setState(() => _active = to);
final mode = c.settings.explorerDropAction;
final move = mode == 'ask' ? await askDropAction(context, load.paths, dest) : mode == 'move';
if (move == null || !mounted) return;
// 되돌리기에 쓸 것: 대상에 이미 있던 이름 (합친 폴더는 통째로 되돌리지 않게)
final existed = <String>{};
if (move) {
for (final s in load.paths) {
final t = vJoin(dest, vBasename(s));
if (await vExists(t)) existed.add(t);
}
}
if (!mounted) return;
await _runTransfer(_panes[load.from], load.paths, dest,
move: move, onFinished: move ? (job) => _offerUndoMove(load, job, existed) : null);
}
/// 82: 이동 되돌리기 알림. 실제로 옮긴 것만 원래 자리로: 새로 생긴 항목은 통째로, 이미 있던 폴더에 합친 것은 앱이 옮긴 파일만
/// (robocopy 로 합친 폴더는 무엇을 옮겼는지 몰라 되돌리지 않고 알린다)
void _offerUndoMove(DragLoad load, TransferJob job, Set<String> existed) {
// (원래 폴더 → 지금 경로들)
final back = <String, List<String>>{};
var notUndoable = 0;
final pairs = job.made.length == load.paths.length
? [for (var i = 0; i < load.paths.length; i++) (load.paths[i], job.made[i])]
: [
for (final m in job.made)
for (final s in load.paths)
if (vBasename(s) == vBasename(m)) (s, m),
];
for (final (src, now) in pairs) {
if (!existed.contains(now)) {
back.putIfAbsent(vDirname(src), () => []).add(now);
continue;
}
// 합친 폴더: 그 안에서 실제로 옮긴 파일만
final files = [for (final f in job.movedFiles) if (isSameOrInside(f, src) && !samePath(f, src)) f];
if (files.isEmpty) {
notUndoable++;
continue;
}
for (final f in files) {
final rest = f.substring(src.length + 1);
back.putIfAbsent(vDirname(f), () => []).add(vJoin(now, rest));
}
}
if (back.isEmpty || !mounted) return;
final moved = back.values.fold(0, (a, l) => a + l.length);
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(
duration: const Duration(seconds: 10),
content: Text([
trf('{0}개를 옮겼습니다.', [moved]),
if (notUndoable > 0) trf('합친 폴더 {0}개는 무엇을 옮겼는지 몰라 되돌리지 않습니다.', [notUndoable]),
].join(' ')),
action: SnackBarAction(label: tr('되돌리기'), onPressed: () => unawaited(_undoMove(_panes[load.from], back))),
));
}
/// 82: 되돌리기 - 원래 폴더마다 지금의 이동으로 (같은 이름이 생겼으면 지금의 같은 이름 창)
Future<void> _undoMove(_Pane pane, Map<String, List<String>> back) async {
for (final MapEntry(key: dir, value: items) in back.entries) {
final there = [for (final x in items) if (await vExists(x)) x];
if (there.isEmpty || !mounted) continue;
try {
if (!await vExists(dir)) await vMkdirs(dir);
} catch (_) {}
if (!mounted) return;
await _runTransfer(pane, there, dir, move: true);
}
}
/// 선택 모드 막대: 고른 수 · 모두 고르기 · 선택 끝 /// 선택 모드 막대: 고른 수 · 모두 고르기 · 선택 끝
Widget _marksBar(_Pane pane, {String? listDir}) { Widget _marksBar(_Pane pane, {String? listDir}) {
final dir = listDir ?? pane.current; final dir = listDir ?? pane.current;
@ -4098,7 +4196,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
cell(_date(e.modified), 3), cell(_date(e.modified), 3),
]; ];
return GestureDetector( final paneIndex = _panes.indexOf(pane);
final drag = real && !widget.rsync;
Widget line = GestureDetector(
// Rsync 화면은 고르기만 (파일 기능 메뉴 없음) // Rsync 화면은 고르기만 (파일 기능 메뉴 없음)
// 25: Rsync 화면에서도 (폴더 메뉴) // 25: Rsync 화면에서도 (폴더 메뉴)
onSecondaryTapUp: real ? (d) => _menu(pane, e, d.globalPosition) : null, onSecondaryTapUp: real ? (d) => _menu(pane, e, d.globalPosition) : null,
@ -4124,6 +4224,16 @@ class _ExplorerPageState extends State<ExplorerPage> {
), ),
), ),
), ),
// 82: 터치로 끌기 - 선택 모드에서 표시한 줄의 ≡ 손잡이 (길게 누르기는 메뉴 그대로)
if (drag && _selecting && marked)
Draggable<DragLoad>(
data: DragLoad(paneIndex, _dragPaths(pane, e)),
feedback: dragFeedback(_dragPaths(pane, e)),
child: Tooltip(
message: tr('끌어서 다른 창으로'),
child: const SizedBox(width: 28, height: 36, child: Icon(Icons.drag_indicator, size: 18, color: JjColors.textDim)),
),
),
// 표시 동그라미는 두 번 누르기 영역 밖 (기다리지 않고 바로) // 표시 동그라미는 두 번 누르기 영역 밖 (기다리지 않고 바로)
if ((real || (widget.rsync && row.isRoot)) && (_selecting || widget.rsync)) if ((real || (widget.rsync && row.isRoot)) && (_selecting || widget.rsync))
SizedBox( SizedBox(
@ -4151,6 +4261,27 @@ class _ExplorerPageState extends State<ExplorerPage> {
]), ]),
), ),
); );
if (!drag) return line;
// 82: 폴더 줄 위에 놓으면 그 폴더 안으로 (놓을 수 있으면 강조)
if (e.isDir) {
final inner = line;
line = DragTarget<DragLoad>(
onWillAcceptWithDetails: (d) => canDropInto(d.data, paneIndex, e.path),
onAcceptWithDetails: (d) => unawaited(_onDrop(d.data, paneIndex, e.path)),
builder: (context, ok, _) => ok.isEmpty
? inner
: DecoratedBox(
decoration: BoxDecoration(
color: JjColors.accent.withValues(alpha: 0.15),
border: Border.all(color: JjColors.accent, width: 1.5),
),
child: inner,
),
);
}
// 82: 마우스로 8px 넘게 끌면 시작 (고르기 · 두 번 누르기는 그대로)
final paths = _dragPaths(pane, e);
return MouseDraggable<DragLoad>(data: DragLoad(paneIndex, paths), feedback: dragFeedback(paths), child: line);
} }
Widget _icon(FileEntry e, bool isRoot, bool open, {bool up = false}) { Widget _icon(FileEntry e, bool isRoot, bool open, {bool up = false}) {

View File

@ -767,6 +767,20 @@ class _SettingsPageState extends State<SettingsPage> {
onChanged: (v) => c.updateSettings((x) => x.explorerClick = v!), onChanged: (v) => c.updateSettings((x) => x.explorerClick = v!),
), ),
), ),
// 82: 창 사이 끌어 놓기 (처음: 묻기)
SettingTile(
title: Text(tr('끌어 놓기 기본')),
subtitle: Text(tr('다른 창으로 끌어 놓았을 때. 어느 쪽이든 복사 · 이동 확인 창 (같은 이름 등) 은 그대로 뜹니다')),
trailing: DropdownButton<String>(
value: s.explorerDropAction,
items: [
DropdownMenuItem(value: 'ask', child: Text(tr('묻기 (기본)'))),
DropdownMenuItem(value: 'copy', child: Text(tr('복사'))),
DropdownMenuItem(value: 'move', child: Text(tr('이동'))),
],
onChanged: (v) => c.updateSettings((x) => x.explorerDropAction = v ?? 'ask'),
),
),
// 84: 정렬을 폴더마다 기억 (처음: 끔) // 84: 정렬을 폴더마다 기억 (처음: 끔)
SettingTile( SettingTile(
title: Text(tr('정렬을 폴더마다 기억')), title: Text(tr('정렬을 폴더마다 기억')),

View File

@ -15,6 +15,7 @@ import 'package:jj_mkvmaker/core/file_ops.dart' show formatSize;
import 'package:jj_mkvmaker/ui/explorer_page.dart'; import 'package:jj_mkvmaker/ui/explorer_page.dart';
import 'package:jj_mkvmaker/ui/explorer_search.dart'; import 'package:jj_mkvmaker/ui/explorer_search.dart';
import 'package:jj_mkvmaker/ui/quick_look.dart'; import 'package:jj_mkvmaker/ui/quick_look.dart';
import 'package:jj_mkvmaker/ui/explorer_drag.dart';
import 'package:jj_mkvmaker/ui/path_label.dart'; import 'package:jj_mkvmaker/ui/path_label.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'support/fake_recycle_bin.dart'; import 'support/fake_recycle_bin.dart';
@ -1014,6 +1015,93 @@ void main() {
expect(find.textContaining('담았습니다'), findsOneWidget); expect(find.textContaining('담았습니다'), findsOneWidget);
}); });
group('82 창 사이 끌어 놓기', () {
test('놓을 수 있는 곳: 같은 창 · 자기 안 · 원본 폴더 자신은 안 됨', () {
final a = p.join('C:', 'x', 'a'), f = p.join('C:', 'x', 'f.txt');
expect(canDropInto(DragLoad(0, [f]), 1, p.join('C:', 'y')), isTrue);
expect(canDropInto(DragLoad(0, [f]), 0, p.join('C:', 'y')), isFalse, reason: '같은 창');
expect(canDropInto(DragLoad(0, [a]), 1, p.join(a, 'in')), isFalse, reason: '자기 안');
expect(canDropInto(DragLoad(0, [f]), 1, p.join('C:', 'x')), isFalse, reason: '원본 폴더 자신');
});
/// 오른쪽 창에 보이는 [name] 줄 (두 창 모두 같은 이름이 보이면 오른쪽 것)
Offset rightOf(WidgetTester tester, String name) {
final all = find.text(name).evaluate().map((e) => tester.getCenter(find.byWidget(e.widget).first)).toList()
..sort((x, y) => x.dx.compareTo(y.dx));
return all.last;
}
Future<void> mouseDrag(WidgetTester tester, Offset from, Offset to) async {
final g = await tester.startGesture(from, kind: PointerDeviceKind.mouse);
await g.moveBy(const Offset(20, 0));
await tester.pump();
await g.moveTo(to);
await tester.pump();
await g.up();
await calm(tester);
}
testWidgets('짧게 움직이면 고르기 · 같은 창에는 놓아도 아무 일 없음 · 묻기 [취소] 도 아무 일 없음', (tester) async {
await open(tester);
final doc = tester.getCenter(find.text('doc.txt').first);
// 4px 만 움직임 = 누르기 (끌기 아님)
final g = await tester.startGesture(doc, kind: PointerDeviceKind.mouse);
await g.moveBy(const Offset(4, 0));
await g.up();
await calm(tester);
expect(find.byType(AlertDialog), findsNothing);
// 같은 창의 sub 위에 놓기 - 못 놓음
await mouseDrag(tester, doc, tester.getCenter(find.text('sub').first));
expect(find.byType(AlertDialog), findsNothing, reason: '같은 창에는 놓지 않는다');
// 오른쪽 창 (right 폴더) 에 놓기 → 묻기 → 취소
await mouseDrag(tester, doc, rightOf(tester, 'right'));
expect(find.text('1개 항목을 놓았습니다'), findsOneWidget);
await tester.tap(find.text('취소').last);
await calm(tester);
expect(File(p.join(left, 'doc.txt')).existsSync(), isTrue);
expect(File(p.join(right, 'doc.txt')).existsSync(), isFalse);
});
testWidgets('이동 → [되돌리기]: 새로 생긴 것은 통째로, 이미 있던 폴더에 합친 것은 옮긴 파일만', (tester) async {
// 오른쪽에 이미 sub 폴더 (원래 파일 하나) - 합치기
File(p.join(right, 'sub', 'mine.txt'))
..createSync(recursive: true)
..writeAsStringSync('mine');
c.settings
..explorerDropAction = 'move'
..copyConflict = 'overwrite';
await open(tester);
await act(tester, () => tester.tap(find.text('선택').first));
await act(tester, () => tester.tap(markOf('doc.txt')));
await act(tester, () => tester.tap(markOf('sub')));
await calm(tester);
await mouseDrag(tester, tester.getCenter(find.text('doc.txt').first), rightOf(tester, 'right'));
// (놓은 뒤 대상에 이미 있는 이름을 실제 파일로 확인하므로 실제 시간을 흘려 기다린다)
await settle(tester, () => find.widgetWithText(FilledButton, '이동').evaluate().isNotEmpty);
// 지금의 이동 확인 창 그대로
await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '이동')));
await settle(tester, () => find.text('되돌리기').evaluate().isNotEmpty);
expect(File(p.join(right, 'doc.txt')).existsSync(), isTrue);
expect(File(p.join(right, 'sub', 'inner.txt')).existsSync(), isTrue);
expect(Directory(p.join(left, 'sub')).existsSync(), isFalse);
// 되돌리기: 원래 폴더마다 지금의 이동 (확인 창)
await act(tester, () => tester.tap(find.text('되돌리기')));
// 원래 폴더가 둘 (left · left/sub) 이라 이동 확인 창도 둘
for (var i = 0; i < 2; i++) {
await settle(tester, () => find.widgetWithText(FilledButton, '이동').evaluate().isNotEmpty);
await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '이동')));
await settle(tester, () => find.byType(AlertDialog).evaluate().isEmpty, rounds: 50);
}
await settle(tester, () => File(p.join(left, 'doc.txt')).existsSync() && File(p.join(left, 'sub', 'inner.txt')).existsSync());
expect(File(p.join(left, 'doc.txt')).readAsStringSync(), 'hello');
expect(File(p.join(left, 'sub', 'inner.txt')).readAsStringSync(), 'inner');
expect(File(p.join(right, 'sub', 'mine.txt')).readAsStringSync(), 'mine', reason: '원래 있던 폴더 · 파일은 그대로 (폴더째 옮기지 않음)');
expect(File(p.join(right, 'sub', 'inner.txt')).existsSync(), isFalse);
ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars();
await calm(tester);
});
});
testWidgets('81: Space 로 빠른 보기 (글 파일 앞부분) · 필터에 치는 중에는 그냥 빈칸 · TC 는 F3', (tester) async { testWidgets('81: Space 로 빠른 보기 (글 파일 앞부분) · 필터에 치는 중에는 그냥 빈칸 · TC 는 F3', (tester) async {
c.settings.explorerStyle = 'windows'; c.settings.explorerStyle = 'windows';
await open(tester); await open(tester);