Rsync 화면 (파일 탐색기 옆) · 파일 탐색기에서 모니터링 · rsync · lsync 빼기

- 위쪽 파일 탐색기 옆 [Rsync]: 파일 탐색기와 같은 모양, 늘 좌우 두 창 · 폴더만 · 창마다 폴더 하나만 고르기
- 가운데 좌 → 우 (왼쪽 → 오른쪽) · 좌 ← 우 (오른쪽 → 왼쪽) · 좌 ⇄ 우 (둘 다 함께, -u 로 받는 쪽의 새 파일은 덮어쓰지 않음)
  폴더 안의 것을 맞춤 (원본/ → 대상/), 확인 창에 경로 · 옵션, 진행 막대는 방향마다
- 가운데 [모니터링]: 실행한 rsync 가 남아 옵션 고치기 · 다시 실행 · lsync 로 옮기기 · 일정 (앞의 모니터링 그대로)
  확인 창에서 취소하면 남기지 않음, 고른 폴더는 다음에 열 때 그대로 (rsyncPaths)
- 파일 탐색기: 모니터링 버튼 · rsync 빼고 현재 방식 · robocopy 만 (예전 값 rsync 는 현재 방식으로), 복사는 모니터링에 남기지 않음
- 환경 설정: [Rsync] 묶음 (rsync 옵션 · 가져오기 · 실시간 동기화 · 백그라운드로 실행), 복사 모니터링 스위치 없앰
- Windows 실제 rsync 로 → · ⇄ 시험 (integration_test/rsync_page_test.dart)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
묶음3
RyuJongJin 2026-10-05 18:28:32 +09:00
parent 9ce55bbb74
commit c273297b11
16 changed files with 627 additions and 207 deletions

View File

@ -118,6 +118,7 @@
"OpenSubtitles 오류 {0}: {1}": "OpenSubtitles error {0}: {1}",
"PC 전체 CPU · 메모리 사용량": "Whole-PC CPU · memory usage",
"PC 전체 사용량\nCPU {0}%\n메모리 {1} / {2}GB ({3}%){4}": "Whole-PC usage\nCPU {0}%\nMemory {1} / {2}GB ({3}%){4}",
"Rsync (지금 여기)": "Rsync (you are here)",
"SD 카드": "SD card",
"SRT 형식이 아니거나 비어 있습니다.": "Not in SRT format or empty.",
"VLC 가 설치되어 있지 않습니다. 설치하면 목록에 나타납니다 (videolan.org).": "VLC is not installed. Once installed it appears in the list (videolan.org).",
@ -156,6 +157,7 @@
"rsync --delete · robocopy /PURGE. 끄면 대상에 더하기 · 바꾸기만": "rsync --delete · robocopy /PURGE. When off, only adds · updates in the target",
"rsync 가 없습니다 (환경 설정 > 파일 탐색기에서 내려받기 · 경로 지정)": "rsync is not available (Settings > File explorer: download · set path)",
"rsync 가져오기": "Get rsync",
"rsync 끝: {0}": "rsync done: {0}",
"rsync 내려받기": "Download rsync",
"rsync 는 처음 쓸 때 내려받습니다 (MSYS2 공식 저장소의 rsync 3.5.1 · 약 8MB, 파일마다 SHA256 확인).\n설정 폴더의 rsync 에 두어 업데이트해도 남습니다. 지금 받을까요?": "rsync is downloaded on first use (rsync 3.5.1 from the official MSYS2 repository · about 8MB, SHA256 checked for each file).\nIt is kept in the rsync folder of the settings folder and survives updates. Download now?",
"rsync 를 받지 못했습니다: {0}": "Could not download rsync: {0}",
@ -163,6 +165,7 @@
"rsync 받는 중": "Downloading rsync",
"rsync 실행 파일": "rsync executable",
"rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > 파일 탐색기에서 경로를 확인하세요.": "rsync executable not found. Check the path in Settings > File explorer.",
"rsync 양쪽 (⇄)": "rsync both ways (⇄)",
"rsync 옵션": "rsync options",
"v{0} 받는 중": "Downloading v{0}",
"v{0} 을 받았고 파일 검증(SHA256)을 마쳤습니다.\n{1}": "Downloaded v{0} and verified the file (SHA256).\n{1}",
@ -237,6 +240,7 @@
"같은 시리즈 (번호만 다른 파일)": "Same series (files that differ only by number)",
"같은 폴더의 모든 동영상": "All videos in the same folder",
"같은 폴더의 자막은 자동으로 추가됩니다": "Subtitles in the same folder are added automatically",
"같은 폴더이거나 한쪽이 다른 쪽 안에 있습니다. 다른 폴더를 고르세요.": "Same folder, or one is inside the other. Choose different folders.",
"거꾸로": "Reverse",
"건너뜀 (출력 폴더의 파일): {0}": "Skipped (file in the output folder): {0}",
"검색": "Search",
@ -250,6 +254,7 @@
"계속 (앱이 켜져 있는 동안)": "Always (while the app is running)",
"계속 편집": "Keep editing",
"고르기": "Choose",
"고르기 취소": "Unselect",
"고르지 않음": "Not chosen",
"고른 것 시작": "Start selected",
"고른 크기 이하에서 가장 좋은 화질 (영상에 없는 크기면 가까운 것)": "Best quality at or below the chosen size (nearest if the video lacks that size)",
@ -636,6 +641,7 @@
"실행": "Run",
"실행 · 종료": "Run · quit",
"실행 취소": "Undo",
"실행한 rsync 가 없습니다. Rsync 화면에서 → · ← · ⇄ 로 실행하면 여기에 자동으로 등록됩니다.": "No rsync runs yet. Runs started with → · ← · ⇄ on the Rsync screen are added here automatically.",
"싱크": "Sync",
"쓰레드 확인 (AI · 변환 작업이 멈출 때까지)": "Thread check (until AI · conversion jobs stop)",
"아랍어": "Arabic",
@ -653,6 +659,7 @@
"압축 파일 안에 없습니다": "Not in the archive",
"압축 파일 안에 프로그램이 없습니다.": "The program is not in the archive.",
"압축을 풀 수 없습니다: {0}": "Cannot extract the archive: {0}",
"앞의 rsync 가 끝난 뒤에 하세요.": "Wait until the previous rsync finishes.",
"앞의 복사 · 이동이 끝난 뒤에 하세요.": "Wait until the current copy · move finishes.",
"앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "Icon in the app list, home screen and the top-left button in the app. After changing it, the icon on your home screen may disappear and you may need to drag it from the app list again",
"앱 아이콘": "App icon",
@ -662,6 +669,7 @@
"앱에 들어 있는 것 (기본)": "Built into the app (default)",
"앱을 다시 켤 때 동기화": "Sync when the app restarts",
"앱이 켜져 있는 동안 늘 동기화합니다.": "Always syncs while the app is running.",
"양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).": "Both ways: newer files on the receiving side are not overwritten (-u).",
"어디서나 누르면 JJ_MKVMaker 창이 나타납니다": "Press anywhere to bring up the JJ_MKVMaker window",
"언어": "Language",
"언어 추가": "Add language",
@ -685,6 +693,7 @@
"영상을 다시 인코딩합니다 (시간이 오래 걸림, 음성은 그대로){0}": "Re-encodes the video (takes a long time, audio unchanged){0}",
"영상의 음성을 인식하고 선택한 언어로 번역합니다. 모든 처리는 이 {0}에서 이루어집니다.": "Recognizes the video's speech and translates it into the chosen languages. All processing happens on this {0}.",
"영어": "English",
"예: -avPog (보관 · 자세히 · 진행 · 소유자 · 그룹). 속도 제한은 파일 탐색기의 속도 제한을 함께 씀": "e.g. -avPog (archive · verbose · progress · owner · group). Uses the file explorer speed limit too",
"예: -avPog (보관 · 자세히 · 진행 · 소유자 · 그룹). 이동은 --remove-source-files 를 자동으로 붙임": "e.g. -avPog (archive · verbose · progress · owner · group). Move adds --remove-source-files automatically",
"예: /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",
@ -711,6 +720,7 @@
"외부 프로그램으로 재생: {0} ← {1}개": "Play with external program: {0} ← {1}",
"왼쪽": "Left",
"왼쪽 90°": "Left 90°",
"왼쪽 · 오른쪽 창에서 폴더를 하나씩 고르세요.": "Choose one folder in each of the left and right panes.",
"왼쪽 손잡이로 순서 바꾸기 · 아이콘을 끌어 왼쪽 폴더나 목록의 폴더 위에 놓으면 그 폴더로 이동 · 오른쪽 클릭 메뉴": "Reorder with the handle on the left · drag the icon onto a folder on the left or in the list to move it there · right-click menu",
"왼쪽에 폴더, 오른쪽에 왼쪽에서 고른 폴더의 파일들을 보여 줍니다 (Windows 탐색기처럼).": "Folders on the left, the files of the folder chosen on the left on the right (like Windows Explorer).",
"요청이 너무 많습니다. 잠시 후 다시 시도하세요.": "Too many requests. Try again in a moment.",
@ -773,6 +783,7 @@
"이 페이지를 {0}(으)로 번역": "Translate this page to {0}",
"이 폴더 선택": "Choose this folder",
"이 폴더 전체": "This whole folder",
"이 폴더를 rsync 원본 · 대상으로 고르기": "Choose this folder as rsync source · target",
"이 폴더에 동영상이 없습니다.": "There are no videos in this folder.",
"이 폴더에 새 폴더": "New folder in this folder",
"이 폴더에 현재 페이지 추가": "Add the current page to this folder",
@ -924,6 +935,9 @@
"종료 창 닫힘": "Quit window closed",
"종료할까요?": "Quit?",
"종류 (확장자)": "Type (extension)",
"좌 ← 우": "Left ← right",
"좌 → 우": "Left → right",
"좌 ⇄ 우": "Left ⇄ right",
"좌우": "Side by side",
"주말 종일": "Weekends, all day",
"주소": "Address",
@ -1061,6 +1075,7 @@
"폴더 삭제: {0} (안의 {1}개 포함)": "Folder deleted: {0} (including {1} inside)",
"폴더 선택": "Choose folder",
"폴더 수정": "Edit folder",
"폴더 안의 것을 맞춥니다 (대상에 같은 이름의 폴더를 새로 만들지 않음).": "Syncs the folder contents (no same-named folder is created in the target).",
"폴더 열기": "Open folder",
"폴더 이름": "Folder name",
"폴더 이름 바꾸기 · 이동": "Rename · move folder",

View File

@ -118,6 +118,7 @@
"OpenSubtitles 오류 {0}: {1}": "OpenSubtitles エラー {0}: {1}",
"PC 전체 CPU · 메모리 사용량": "PC 全体の CPU・メモリ使用量",
"PC 전체 사용량\nCPU {0}%\n메모리 {1} / {2}GB ({3}%){4}": "PC 全体の使用量\nCPU {0}%\nメモリ {1} / {2}GB({3}%){4}",
"Rsync (지금 여기)": "Rsync (現在地)",
"SD 카드": "SD カード",
"SRT 형식이 아니거나 비어 있습니다.": "SRT 形式ではないか、空です。",
"VLC 가 설치되어 있지 않습니다. 설치하면 목록에 나타납니다 (videolan.org).": "VLC がインストールされていません。インストールするとリストに表示されます(videolan.org)。",
@ -156,6 +157,7 @@
"rsync --delete · robocopy /PURGE. 끄면 대상에 더하기 · 바꾸기만": "rsync --delete · robocopy /PURGE。オフなら対象に追加 · 更新のみ",
"rsync 가 없습니다 (환경 설정 > 파일 탐색기에서 내려받기 · 경로 지정)": "rsync がありません (環境設定 > ファイルエクスプローラーでダウンロード · パス指定)",
"rsync 가져오기": "rsync の入手",
"rsync 끝: {0}": "rsync 完了: {0}",
"rsync 내려받기": "rsync をダウンロード",
"rsync 는 처음 쓸 때 내려받습니다 (MSYS2 공식 저장소의 rsync 3.5.1 · 약 8MB, 파일마다 SHA256 확인).\n설정 폴더의 rsync 에 두어 업데이트해도 남습니다. 지금 받을까요?": "rsync は初めて使うときにダウンロードします (MSYS2 公式リポジトリの rsync 3.5.1 · 約 8MB、ファイルごとに SHA256 を確認)。\n設定フォルダーの rsync に置くため、更新しても残ります。今ダウンロードしますか?",
"rsync 를 받지 못했습니다: {0}": "rsync をダウンロードできませんでした: {0}",
@ -163,6 +165,7 @@
"rsync 받는 중": "rsync をダウンロード中",
"rsync 실행 파일": "rsync 実行ファイル",
"rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > 파일 탐색기에서 경로를 확인하세요.": "rsync 実行ファイルが見つかりません。環境設定 > ファイルエクスプローラーでパスを確認してください。",
"rsync 양쪽 (⇄)": "rsync 双方向 (⇄)",
"rsync 옵션": "rsync オプション",
"v{0} 받는 중": "v{0} をダウンロード中",
"v{0} 을 받았고 파일 검증(SHA256)을 마쳤습니다.\n{1}": "v{0} をダウンロードし、ファイル検証(SHA256)を終えました。\n{1}",
@ -237,6 +240,7 @@
"같은 시리즈 (번호만 다른 파일)": "同じシリーズ(番号だけ違うファイル)",
"같은 폴더의 모든 동영상": "同じフォルダーのすべての動画",
"같은 폴더의 자막은 자동으로 추가됩니다": "同じフォルダーの字幕は自動で追加されます",
"같은 폴더이거나 한쪽이 다른 쪽 안에 있습니다. 다른 폴더를 고르세요.": "同じフォルダーか、一方がもう一方の中にあります。別のフォルダーを選んでください。",
"거꾸로": "逆順",
"건너뜀 (출력 폴더의 파일): {0}": "スキップ(出力フォルダーのファイル): {0}",
"검색": "検索",
@ -250,6 +254,7 @@
"계속 (앱이 켜져 있는 동안)": "常時 (アプリの起動中)",
"계속 편집": "編集を続ける",
"고르기": "選ぶ",
"고르기 취소": "選択を解除",
"고르지 않음": "未選択",
"고른 것 시작": "選んだものを開始",
"고른 크기 이하에서 가장 좋은 화질 (영상에 없는 크기면 가까운 것)": "選んだサイズ以下で最も良い画質(動画にないサイズなら近いもの)",
@ -636,6 +641,7 @@
"실행": "実行",
"실행 · 종료": "実行・終了",
"실행 취소": "元に戻す",
"실행한 rsync 가 없습니다. Rsync 화면에서 → · ← · ⇄ 로 실행하면 여기에 자동으로 등록됩니다.": "実行した rsync はありません。Rsync 画面で → · ← · ⇄ で実行すると自動でここに登録されます。",
"싱크": "同期",
"쓰레드 확인 (AI · 변환 작업이 멈출 때까지)": "スレッド確認(AI・変換作業が止まるまで)",
"아랍어": "アラビア語",
@ -653,6 +659,7 @@
"압축 파일 안에 없습니다": "圧縮ファイル内にありません",
"압축 파일 안에 프로그램이 없습니다.": "圧縮ファイル内にプログラムがありません。",
"압축을 풀 수 없습니다: {0}": "圧縮ファイルを展開できません: {0}",
"앞의 rsync 가 끝난 뒤에 하세요.": "前の rsync が終わってから行ってください。",
"앞의 복사 · 이동이 끝난 뒤에 하세요.": "前のコピー · 移動が終わってから行ってください。",
"앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "アプリ一覧・ホーム画面とアプリ内左上のボタンのアイコンです。変更するとホーム画面のアイコンが消えることがあり、アプリ一覧から再度ドラッグする必要がある場合があります",
"앱 아이콘": "アプリアイコン",
@ -662,6 +669,7 @@
"앱에 들어 있는 것 (기본)": "アプリに内蔵 (既定)",
"앱을 다시 켤 때 동기화": "アプリ再起動時の同期",
"앱이 켜져 있는 동안 늘 동기화합니다.": "アプリの起動中は常に同期します。",
"양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).": "双方向: 受け取る側の方が新しいファイルは上書きしません (-u)。",
"어디서나 누르면 JJ_MKVMaker 창이 나타납니다": "どこからでも押すと JJ_MKVMaker ウィンドウが表示されます",
"언어": "言語",
"언어 추가": "言語を追加",
@ -685,6 +693,7 @@
"영상을 다시 인코딩합니다 (시간이 오래 걸림, 음성은 그대로){0}": "映像を再エンコードします(時間がかかります、音声はそのまま){0}",
"영상의 음성을 인식하고 선택한 언어로 번역합니다. 모든 처리는 이 {0}에서 이루어집니다.": "動画の音声を認識し、選んだ言語に翻訳します。すべての処理はこの{0}で行われます。",
"영어": "英語",
"예: -avPog (보관 · 자세히 · 진행 · 소유자 · 그룹). 속도 제한은 파일 탐색기의 속도 제한을 함께 씀": "例: -avPog (保存 · 詳細 · 進行 · 所有者 · グループ)。速度制限はファイルエクスプローラーの設定を共用",
"예: -avPog (보관 · 자세히 · 진행 · 소유자 · 그룹). 이동은 --remove-source-files 를 자동으로 붙임": "例: -avPog (アーカイブ · 詳細 · 進捗 · 所有者 · グループ)。移動では --remove-source-files を自動で付加",
"예: /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 時間単位",
@ -711,6 +720,7 @@
"외부 프로그램으로 재생: {0} ← {1}개": "外部プログラムで再生: {0} ← {1}個",
"왼쪽": "左",
"왼쪽 90°": "左 90°",
"왼쪽 · 오른쪽 창에서 폴더를 하나씩 고르세요.": "左右のウィンドウでフォルダーを 1 つずつ選んでください。",
"왼쪽 손잡이로 순서 바꾸기 · 아이콘을 끌어 왼쪽 폴더나 목록의 폴더 위에 놓으면 그 폴더로 이동 · 오른쪽 클릭 메뉴": "左のハンドルで並べ替え・アイコンを左のフォルダーかリスト内のフォルダーにドラッグするとそのフォルダーへ移動・右クリックメニュー",
"왼쪽에 폴더, 오른쪽에 왼쪽에서 고른 폴더의 파일들을 보여 줍니다 (Windows 탐색기처럼).": "左にフォルダー、右に左で選んだフォルダーのファイルを表示します (Windows エクスプローラーのように)。",
"요청이 너무 많습니다. 잠시 후 다시 시도하세요.": "リクエストが多すぎます。しばらくしてからもう一度お試しください。",
@ -773,6 +783,7 @@
"이 페이지를 {0}(으)로 번역": "このページを{0}に翻訳",
"이 폴더 선택": "このフォルダーを選択",
"이 폴더 전체": "このフォルダー全体",
"이 폴더를 rsync 원본 · 대상으로 고르기": "このフォルダーを rsync の元 · 対象に選ぶ",
"이 폴더에 동영상이 없습니다.": "このフォルダーに動画がありません。",
"이 폴더에 새 폴더": "このフォルダーに新しいフォルダー",
"이 폴더에 현재 페이지 추가": "このフォルダーに現在のページを追加",
@ -924,6 +935,9 @@
"종료 창 닫힘": "終了ウィンドウを閉じました",
"종료할까요?": "終了しますか?",
"종류 (확장자)": "種類 (拡張子)",
"좌 ← 우": "左 ← 右",
"좌 → 우": "左 → 右",
"좌 ⇄ 우": "左 ⇄ 右",
"좌우": "左右",
"주말 종일": "週末 終日",
"주소": "アドレス",
@ -1061,6 +1075,7 @@
"폴더 삭제: {0} (안의 {1}개 포함)": "フォルダーを削除: {0}(中の {1}個を含む)",
"폴더 선택": "フォルダーを選択",
"폴더 수정": "フォルダーを編集",
"폴더 안의 것을 맞춥니다 (대상에 같은 이름의 폴더를 새로 만들지 않음).": "フォルダーの中身を合わせます (対象に同じ名前のフォルダーは作りません)。",
"폴더 열기": "フォルダーを開く",
"폴더 이름": "フォルダー名",
"폴더 이름 바꾸기 · 이동": "フォルダー名の変更・移動",

View File

@ -118,6 +118,7 @@
"OpenSubtitles 오류 {0}: {1}": "OpenSubtitles 错误 {0}: {1}",
"PC 전체 CPU · 메모리 사용량": "整机 CPU · 内存使用率",
"PC 전체 사용량\nCPU {0}%\n메모리 {1} / {2}GB ({3}%){4}": "整机使用率\nCPU {0}%\n内存 {1} / {2}GB({3}%){4}",
"Rsync (지금 여기)": "Rsync (当前位置)",
"SD 카드": "SD 卡",
"SRT 형식이 아니거나 비어 있습니다.": "不是 SRT 格式或为空。",
"VLC 가 설치되어 있지 않습니다. 설치하면 목록에 나타납니다 (videolan.org).": "未安装 VLC。安装后会出现在列表中(videolan.org)。",
@ -156,6 +157,7 @@
"rsync --delete · robocopy /PURGE. 끄면 대상에 더하기 · 바꾸기만": "rsync --delete · robocopy /PURGE。关闭时只在目标中添加 · 更新",
"rsync 가 없습니다 (환경 설정 > 파일 탐색기에서 내려받기 · 경로 지정)": "没有 rsync (设置 > 文件浏览器中下载 · 指定路径)",
"rsync 가져오기": "获取 rsync",
"rsync 끝: {0}": "rsync 完成: {0}",
"rsync 내려받기": "下载 rsync",
"rsync 는 처음 쓸 때 내려받습니다 (MSYS2 공식 저장소의 rsync 3.5.1 · 약 8MB, 파일마다 SHA256 확인).\n설정 폴더의 rsync 에 두어 업데이트해도 남습니다. 지금 받을까요?": "rsync 在首次使用时下载 (MSYS2 官方仓库的 rsync 3.5.1 · 约 8MB,逐个文件校验 SHA256)。\n保存在设置文件夹的 rsync 中,更新后仍保留。现在下载吗?",
"rsync 를 받지 못했습니다: {0}": "无法下载 rsync: {0}",
@ -163,6 +165,7 @@
"rsync 받는 중": "正在下载 rsync",
"rsync 실행 파일": "rsync 可执行文件",
"rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > 파일 탐색기에서 경로를 확인하세요.": "找不到 rsync 可执行文件。请在 设置 > 文件浏览器 中检查路径。",
"rsync 양쪽 (⇄)": "rsync 双向 (⇄)",
"rsync 옵션": "rsync 选项",
"v{0} 받는 중": "正在下载 v{0}",
"v{0} 을 받았고 파일 검증(SHA256)을 마쳤습니다.\n{1}": "已下载 v{0} 并完成文件校验(SHA256)。\n{1}",
@ -237,6 +240,7 @@
"같은 시리즈 (번호만 다른 파일)": "同一系列(仅编号不同的文件)",
"같은 폴더의 모든 동영상": "同一文件夹中的所有视频",
"같은 폴더의 자막은 자동으로 추가됩니다": "同一文件夹中的字幕会自动添加",
"같은 폴더이거나 한쪽이 다른 쪽 안에 있습니다. 다른 폴더를 고르세요.": "是同一文件夹,或一个在另一个里面。请选择其他文件夹。",
"거꾸로": "倒序",
"건너뜀 (출력 폴더의 파일): {0}": "已跳过(输出文件夹中的文件): {0}",
"검색": "搜索",
@ -250,6 +254,7 @@
"계속 (앱이 켜져 있는 동안)": "持续 (应用运行期间)",
"계속 편집": "继续编辑",
"고르기": "选择",
"고르기 취소": "取消选择",
"고르지 않음": "未选择",
"고른 것 시작": "开始所选",
"고른 크기 이하에서 가장 좋은 화질 (영상에 없는 크기면 가까운 것)": "不超过所选尺寸的最佳画质(视频没有该尺寸时取最接近的)",
@ -636,6 +641,7 @@
"실행": "运行",
"실행 · 종료": "运行 · 退出",
"실행 취소": "撤销",
"실행한 rsync 가 없습니다. Rsync 화면에서 → · ← · ⇄ 로 실행하면 여기에 자동으로 등록됩니다.": "还没有运行过 rsync。在 Rsync 画面用 → · ← · ⇄ 运行后会自动登记到这里。",
"싱크": "同步",
"쓰레드 확인 (AI · 변환 작업이 멈출 때까지)": "线程检查(直到 AI · 转换任务停止)",
"아랍어": "阿拉伯语",
@ -653,6 +659,7 @@
"압축 파일 안에 없습니다": "压缩文件中没有",
"압축 파일 안에 프로그램이 없습니다.": "压缩文件中没有程序。",
"압축을 풀 수 없습니다: {0}": "无法解压: {0}",
"앞의 rsync 가 끝난 뒤에 하세요.": "请等上一个 rsync 结束后再运行。",
"앞의 복사 · 이동이 끝난 뒤에 하세요.": "请等待前一个复制 · 移动完成。",
"앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "应用列表、主屏幕以及应用内左上角按钮的图标。更改后主屏幕上的图标可能会消失,可能需要从应用列表重新拖放",
"앱 아이콘": "应用图标",
@ -662,6 +669,7 @@
"앱에 들어 있는 것 (기본)": "应用内置 (默认)",
"앱을 다시 켤 때 동기화": "应用重新启动时同步",
"앱이 켜져 있는 동안 늘 동기화합니다.": "应用运行期间始终同步。",
"양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).": "双向: 接收端较新的文件不会被覆盖 (-u)。",
"어디서나 누르면 JJ_MKVMaker 창이 나타납니다": "在任何地方按下即可显示 JJ_MKVMaker 窗口",
"언어": "语言",
"언어 추가": "添加语言",
@ -685,6 +693,7 @@
"영상을 다시 인코딩합니다 (시간이 오래 걸림, 음성은 그대로){0}": "重新编码视频(耗时较长,音频不变){0}",
"영상의 음성을 인식하고 선택한 언어로 번역합니다. 모든 처리는 이 {0}에서 이루어집니다.": "识别视频中的语音并翻译成所选语言。所有处理均在此{0}上完成。",
"영어": "英语",
"예: -avPog (보관 · 자세히 · 진행 · 소유자 · 그룹). 속도 제한은 파일 탐색기의 속도 제한을 함께 씀": "例: -avPog (归档 · 详细 · 进度 · 所有者 · 组)。速度限制与文件浏览器共用",
"예: -avPog (보관 · 자세히 · 진행 · 소유자 · 그룹). 이동은 --remove-source-files 를 자동으로 붙임": "例: -avPog (归档 · 详细 · 进度 · 所有者 · 组)。移动时自动加上 --remove-source-files",
"예: /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 小时为单位",
@ -711,6 +720,7 @@
"외부 프로그램으로 재생: {0} ← {1}개": "用外部程序播放: {0} ← {1} 个",
"왼쪽": "左侧",
"왼쪽 90°": "向左 90°",
"왼쪽 · 오른쪽 창에서 폴더를 하나씩 고르세요.": "请在左右窗口中各选择一个文件夹。",
"왼쪽 손잡이로 순서 바꾸기 · 아이콘을 끌어 왼쪽 폴더나 목록의 폴더 위에 놓으면 그 폴더로 이동 · 오른쪽 클릭 메뉴": "用左侧手柄调整顺序 · 将图标拖到左侧文件夹或列表中的文件夹即可移入 · 右键菜单",
"왼쪽에 폴더, 오른쪽에 왼쪽에서 고른 폴더의 파일들을 보여 줍니다 (Windows 탐색기처럼).": "左侧显示文件夹,右侧显示左侧所选文件夹中的文件 (类似 Windows 资源管理器)。",
"요청이 너무 많습니다. 잠시 후 다시 시도하세요.": "请求过多。请稍后再试。",
@ -773,6 +783,7 @@
"이 페이지를 {0}(으)로 번역": "将此页面翻译为{0}",
"이 폴더 선택": "选择此文件夹",
"이 폴더 전체": "整个文件夹",
"이 폴더를 rsync 원본 · 대상으로 고르기": "选择此文件夹作为 rsync 源 · 目标",
"이 폴더에 동영상이 없습니다.": "此文件夹中没有视频。",
"이 폴더에 새 폴더": "在此文件夹中新建文件夹",
"이 폴더에 현재 페이지 추가": "将当前页面添加到此文件夹",
@ -924,6 +935,9 @@
"종료 창 닫힘": "退出窗口已关闭",
"종료할까요?": "要退出吗?",
"종류 (확장자)": "类型 (扩展名)",
"좌 ← 우": "左 ← 右",
"좌 → 우": "左 → 右",
"좌 ⇄ 우": "左 ⇄ 右",
"좌우": "左右",
"주말 종일": "周末 全天",
"주소": "地址",
@ -1061,6 +1075,7 @@
"폴더 삭제: {0} (안의 {1}개 포함)": "已删除文件夹: {0}(含其中 {1} 个)",
"폴더 선택": "选择文件夹",
"폴더 수정": "编辑文件夹",
"폴더 안의 것을 맞춥니다 (대상에 같은 이름의 폴더를 새로 만들지 않음).": "同步文件夹内的内容 (不会在目标中新建同名文件夹)。",
"폴더 열기": "打开文件夹",
"폴더 이름": "文件夹名称",
"폴더 이름 바꾸기 · 이동": "重命名 · 移动文件夹",

View File

@ -30,9 +30,7 @@ void main() {
}
Directory(dst).createSync();
final c = AppController(PlatformServices.create());
c.settings
..copyMonitor = true
..liveSyncPairs = [
c.settings.liveSyncPairs = [
LiveSyncPair(src, p.join(dst, 'mirror'), schedule: const ['0 9-17 * * 1-5', '0 22-23 * * 0,6'], delete: true),
];
final live = LiveSync(c)..start();

View File

@ -0,0 +1,106 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/app/live_sync.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/explorer_page.dart';
import 'package:jj_mkvmaker/ui/theme.dart';
import 'package:path/path.dart' as p;
/// Rsync 화면을 실제 rsync (앱에 들어 있는 것) 로: → 한 방향, ⇄ 양쪽 (-u), 모니터링에 남는지. 화면은 JJ_SHOT_DIR 에 PNG.
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('Rsync 화면: → · ⇄ 실제 실행', (tester) async {
final out = Platform.environment['JJ_SHOT_DIR'] ?? Directory.systemTemp.path;
final tmp = Directory.systemTemp.createTempSync('jj_rsyncpage_');
final a = p.join(tmp.path, '왼쪽 폴더'), b = p.join(tmp.path, 'right');
File(p.join(a, 'sub', '한글 파일.txt'))
..createSync(recursive: true)
..writeAsStringSync('left');
File(p.join(a, 'same.txt')).writeAsStringSync('old');
Directory(b).createSync();
await tester.runAsync(findBundledRsync);
expect(bundledRsync, isNotNull, reason: 'build 출력의 rsync\rsync.exe (tool\fetch_rsync.ps1)');
final c = AppController(PlatformServices.create());
c.settings
..rsyncPaths = [tmp.path, tmp.path]
..explorerStyle = 'windows';
final key = GlobalKey();
tester.view.physicalSize = const Size(1500, 900);
tester.view.devicePixelRatio = 1;
Future<void> wait(int ms) async {
for (var i = 0; i < ms ~/ 100; i++) {
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 100)));
await tester.pump();
}
}
Future<void> shot(String name) async {
final boundary = key.currentContext!.findRenderObject()! as RenderRepaintBoundary;
final image = await tester.runAsync(() => boundary.toImage());
final bytes = await tester.runAsync(() => image!.toByteData(format: ui.ImageByteFormat.png));
File('$out/$name.png').writeAsBytesSync(bytes!.buffer.asUint8List());
}
Future<void> tap(Finder f) async {
await tester.tap(f);
await wait(600);
}
await tester.pumpWidget(MaterialApp(
theme: buildTheme(),
builder: (_, child) => RepaintBoundary(key: key, child: child),
home: ExplorerPage(c: c, rsync: true),
));
await wait(2500);
// 왼쪽 창: 왼쪽 폴더 · 오른쪽 창: right
await tap(find.text('왼쪽 폴더').first);
await tap(find.text('right').last);
await wait(800);
expect(find.byTooltip('고르기 취소'), findsNWidgets(2));
// → : 왼쪽 폴더 안의 것 → right
await tap(find.text('좌 → 우'));
await tap(find.text('실행'));
for (var i = 0; i < 100 && !File(p.join(b, 'sub', '한글 파일.txt')).existsSync(); i++) {
await wait(200);
}
await wait(1500);
expect(File(p.join(b, 'sub', '한글 파일.txt')).readAsStringSync(), 'left');
expect(Directory(p.join(b, '왼쪽 폴더')).existsSync(), isFalse); // 안의 것만 (폴더째가 아님)
// ⇄ : 오른쪽에서 새 파일 · 더 새로 고친 same.txt 는 왼쪽으로, 오른쪽의 새 것은 덮어쓰지 않음 (-u)
File(p.join(b, 'only-right.txt')).writeAsStringSync('r');
await Future<void>.delayed(const Duration(seconds: 2));
File(p.join(b, 'same.txt')).writeAsStringSync('newer on right');
await tap(find.text('좌 ⇄ 우'));
await shot('rsync_confirm');
await tap(find.text('실행'));
await wait(1500);
await shot('rsync_running');
for (var i = 0; i < 100 && !File(p.join(a, 'only-right.txt')).existsSync(); i++) {
await wait(200);
}
await wait(2500);
expect(File(p.join(a, 'only-right.txt')).readAsStringSync(), 'r');
expect(File(p.join(b, 'same.txt')).readAsStringSync(), 'newer on right'); // 오른쪽의 새 것이 그대로
expect(File(p.join(a, 'same.txt')).readAsStringSync(), 'newer on right'); // 왼쪽으로 맞춰짐
await shot('rsync_page');
// 모니터링: 실행한 두 방향이 남는다
await tap(find.text('모니터링'));
await wait(1500);
expect(c.settings.copyTasks.length, 2);
expect(c.settings.copyTasks.every((t) => t.method == 'rsync' && t.contents && t.lastResult == 'done'), isTrue);
await shot('rsync_monitor');
tester.view.reset();
tmp.deleteSync(recursive: true);
});
}

View File

@ -51,9 +51,16 @@ class CopyCenter extends ChangeNotifier {
);
}
/// 같은 복사를 기억해 두었으면 그것, 없으면 새 작업 (기억하지는 않음 - 확인 창에 보여 줄 때)
CopyTask peek(List<String> sources, String dest, {bool move = false, bool contents = false, String? method}) {
final t = fresh(sources, dest, move: move, contents: contents, method: method);
return tasks.where((x) => x.key == t.key).firstOrNull ?? t;
}
/// 같은 복사를 기억해 두었으면 그것 (그 옵션을 쓴다), 없으면 새로 기억
Future<CopyTask> remember(List<String> sources, String dest, {bool move = false}) async {
final t = fresh(sources, dest, move: move);
Future<CopyTask> remember(List<String> sources, String dest,
{bool move = false, bool contents = false, String? method}) async {
final t = fresh(sources, dest, move: move, contents: contents, method: method);
final old = tasks.where((x) => x.key == t.key).firstOrNull;
if (old != null) return old;
await c.updateSettings((x) => x.copyTasks = [t, ...x.copyTasks]);

View File

@ -295,11 +295,11 @@ class AppSettings {
bool explorerSortDesc = false;
bool explorerShowHidden = false;
// ── 복사 · 이동 · 동기화 (파일 탐색기) ──
/// 방법: 'builtin' 현재 방식 (기본) · 'rsync' · 'robocopy' (Windows). 파일만 고를 때 / 폴더가 들어 있을 때
// ── 복사 · 이동 (파일 탐색기) · rsync · lsync (Rsync 화면) ──
/// 파일 탐색기의 복사 · 이동 방법: 'builtin' 현재 방식 (기본) · 'robocopy' (Windows). 파일만 고를 때 / 폴더가 들어 있을 때.
/// rsync 는 Rsync 화면에서 따로 (파일 탐색기에서는 쓰지 않음)
String copyMethodFile = 'builtin';
/// 폴더 복사 · 이동 기본: rsync (앱에 들어 있음. 이 기기에서 못 쓰면 현재 방식으로)
String copyMethodFolder = 'rsync';
String copyMethodFolder = 'builtin';
String rsyncOptions = '-avPog';
String robocopyOptions = '/E /COPY:DAT /DCOPY:T /R:2 /W:2';
@ -316,10 +316,12 @@ class AppSettings {
/// 실시간 동기화 (lsyncd 처럼)
List<LiveSyncPair> liveSyncPairs = [];
/// 복사 모니터링: 파일 탐색기 가운데 [모니터링] 버튼, 복사 · 이동을 기억해 다시 실행 · 옵션 고치기
bool copyMonitor = false;
/// 모니터링 (Rsync 화면 가운데 [모니터링]): 실행한 rsync 를 기억해 다시 실행 · 옵션 고치기 · lsync 로 옮기기
List<CopyTask> copyTasks = [];
/// Rsync 화면에서 마지막으로 연 폴더 (왼쪽 · 오른쪽)
List<String> rsyncPaths = [];
/// 실시간 동기화 확인 간격 (초). Windows 는 바뀌면 바로, 그 밖은 이 간격으로 살핀다.
int liveSyncIntervalSec = 30;
@ -424,7 +426,7 @@ class AppSettings {
'explorerPaths': explorerPaths,
'copyMethodFile': copyMethodFile,
'copyMethodFolder': copyMethodFolder,
'copyDefaults': 2,
'rsyncPaths': rsyncPaths,
'rsyncOptions': rsyncOptions,
'robocopyOptions': robocopyOptions,
'copyRunMode': copyRunMode,
@ -435,7 +437,6 @@ class AppSettings {
'liveSyncIntervalSec': liveSyncIntervalSec,
'runInBackground': runInBackground,
'liveSyncOnStart': liveSyncOnStart,
'copyMonitor': copyMonitor,
'copyTasks': [for (final x in copyTasks) x.toJson()],
'explorerHistory': explorerHistory,
'autoCheckUpdates': autoCheckUpdates,
@ -461,7 +462,8 @@ class AppSettings {
'aiWhisper': aiWhisper,
};
static String _method(Object? v) => const ['rsync', 'robocopy'].contains(v) ? v as String : 'builtin';
/// 파일 탐색기의 복사 방법 (rsync 는 Rsync 화면으로 옮겨 예전 값 rsync 도 현재 방식으로)
static String _method(Object? v) => v == 'robocopy' ? 'robocopy' : 'builtin';
/// 색 보정 값 (-100 ~ 100)
static int _adj(Object? v) => ((v as num?)?.round() ?? 0).clamp(-100, 100);
@ -527,10 +529,8 @@ class AppSettings {
..explorerShowHidden = j['explorerShowHidden'] as bool? ?? false
..explorerPaths = [for (final x in (j['explorerPaths'] as List?) ?? const []) '$x']
..copyMethodFile = _method(j['copyMethodFile'])
// rsync 를 앱에 넣기 전 (copyDefaults 없음) 의 기본 '현재 방식' 은 새 기본 rsync 로
..copyMethodFolder = j['copyDefaults'] == null && (j['copyMethodFolder'] ?? 'builtin') == 'builtin'
? 'rsync'
: _method(j['copyMethodFolder'])
..copyMethodFolder = _method(j['copyMethodFolder'])
..rsyncPaths = [for (final x in (j['rsyncPaths'] as List?) ?? const []) '$x']
..rsyncOptions = j['rsyncOptions'] as String? ?? '-avPog'
..robocopyOptions = j['robocopyOptions'] as String? ?? '/E /COPY:DAT /DCOPY:T /R:2 /W:2'
..copyRunMode = j['copyRunMode'] == 'once' ? 'once' : 'each'
@ -542,7 +542,6 @@ class AppSettings {
if (x is Map && x['source'] is String && x['target'] is String) LiveSyncPair.fromJson(x),
]
..liveSyncIntervalSec = ((j['liveSyncIntervalSec'] as num?)?.toInt() ?? 30).clamp(5, 3600)
..copyMonitor = j['copyMonitor'] == true
..runInBackground = j['runInBackground'] == true
..liveSyncOnStart = const ['auto', 'ask', 'off'].contains(j['liveSyncOnStart']) ? j['liveSyncOnStart'] as String : 'auto'
..copyTasks = [

View File

@ -57,6 +57,13 @@ String toCygwinPath(String path, {bool windows = true}) {
return s; // //server/share 는 그대로 읽는다
}
/// rsync 옵션에 -u (--update: 받는 쪽이 더 새 파일은 건너뜀) 를 더한다. 이미 있으면 그대로 (-avu 처럼 묶인 것도).
/// 양쪽 (⇄) 으로 함께 맞출 때 서로의 새 파일을 옛 파일로 덮어쓰지 않게.
String withUpdateOption(String options) {
final has = splitOptions(options).any((o) => o == '--update' || (o.startsWith('-') && !o.startsWith('--') && o.contains('u')));
return has ? options : '${options.trim()} -u'.trim();
}
/// rsync 인수: [options] + (대역폭 · 이동) + 원본들 + 대상 폴더/
/// 원본 폴더는 끝에 / 를 붙이지 않는다 (폴더째 대상 안으로 - 앱의 복사와 같은 결과).
List<String> rsyncArgs({

View File

@ -50,9 +50,17 @@ class AppNavButtons extends StatelessWidget {
/// 파일 탐색기 화면 자신
final bool onExplorerPage;
const AppNavButtons({super.key, this.onDownloadsPage = false, this.onBrowserPage = false, this.onExplorerPage = false});
static const double width = 6 * 40;
/// Rsync 화면 자신
final bool onRsyncPage;
const AppNavButtons(
{super.key,
this.onDownloadsPage = false,
this.onBrowserPage = false,
this.onExplorerPage = false,
this.onRsyncPage = false});
static const double width = 7 * 40;
/// MKV 화면 (맨 처음 화면) 까지 돌아가기
static void toMkv(BuildContext context) => Navigator.of(context).popUntil((r) => r.isFirst);
@ -126,6 +134,13 @@ class AppNavButtons extends StatelessWidget {
scope == null || onExplorerPage ? null : () => ExplorerPage.open(Navigator.of(context), c: scope.controller),
here: onExplorerPage,
),
// Rsync: 파일 탐색기 다음 (좌우 두 창 · 폴더 하나씩 골라 → ← ↔)
btn(
Icon(Icons.sync_alt, color: onRsyncPage ? JjColors.accent : null),
onRsyncPage ? tr('Rsync (지금 여기)') : 'Rsync',
scope == null || onRsyncPage ? null : () => ExplorerPage.openRsync(Navigator.of(context), c: scope.controller),
here: onRsyncPage,
),
btn(const Icon(Icons.arrow_back), tr('뒤로'),
// 브라우저 화면: 뒤로 키는 웹 페이지 뒤로 (PopScope) 이지만 이 버튼은 화면 이동이라 바로 닫는다
atRoot ? null : () => onBrowserPage ? Navigator.pop(context) : Navigator.maybePop(context)),

View File

@ -15,11 +15,13 @@ import 'rsync_setup.dart';
import 'schedule_editor.dart';
import 'theme.dart';
/// 환경 설정 > 파일 탐색기 의 복사 · 이동 · 동기화 부분:
/// 파일 / 폴더 복사 방법 (현재 방식 · rsync · robocopy), 옵션, 여러 개 실행 방식, 속도 제한, rsync 가져오기, 실시간 동기화.
/// 환경 설정의 복사 · 동기화 부분.
/// - 파일 탐색기 ([rsync] false): 파일 / 폴더 복사 방법 (현재 방식 · robocopy), robocopy 옵션, 속도 제한
/// - Rsync ([rsync] true): rsync 옵션, rsync 가져오기, 실시간 동기화 (lsync) · 백그라운드로 실행
class CopySyncSettings extends StatefulWidget {
final AppController c;
const CopySyncSettings({super.key, required this.c});
final bool rsync;
const CopySyncSettings({super.key, required this.c, this.rsync = false});
@override
State<CopySyncSettings> createState() => _CopySyncSettingsState();
@ -46,12 +48,14 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
}
}
/// 파일 탐색기의 방법 (rsync 는 Rsync 화면에서)
List<DropdownMenuItem<String>> _methods() => [
for (final m in CopyMethod.values)
if (copyMethodAvailable(m, c.settings)) DropdownMenuItem(value: m.name, child: Text(tr(m.label))),
if (m != CopyMethod.rsync && copyMethodAvailable(m, c.settings))
DropdownMenuItem(value: m.name, child: Text(tr(m.label))),
];
String _valid(String v) => copyMethodAvailable(CopyMethod.of(v), c.settings) ? v : 'builtin';
String _valid(String v) => v == 'robocopy' && Platform.isWindows ? v : 'builtin';
/// 옵션 입력칸 (바꾸면 바로 저장, ↺ 로 기본값)
Widget _options(String title, String hint, String value, String def, void Function(AppSettings, String) set) => ListTile(
@ -82,48 +86,36 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
);
@override
Widget build(BuildContext context) {
Widget build(BuildContext context) => widget.rsync ? _rsyncPart() : _explorerPart();
/// 파일 탐색기: 복사 · 이동 방법 (현재 방식 · robocopy) · 옵션 · 속도 제한
Widget _explorerPart() {
final s = c.settings;
final desk = Platform.isWindows;
final rsyncUsable = copyMethodAvailable(CopyMethod.rsync, s);
return Column(children: [
const Divider(height: 1),
ListTile(
title: Text(tr('파일 복사 · 이동 방법')),
subtitle: Text(tr('파일만 골랐을 때')),
trailing: DropdownButton<String>(
value: _valid(s.copyMethodFile),
items: _methods(),
onChanged: (v) => c.updateSettings((x) => x.copyMethodFile = v!),
if (desk) ...[
ListTile(
title: Text(tr('파일 복사 · 이동 방법')),
subtitle: Text(tr('파일만 골랐을 때')),
trailing: DropdownButton<String>(
value: _valid(s.copyMethodFile),
items: _methods(),
onChanged: (v) => c.updateSettings((x) => x.copyMethodFile = v!),
),
),
),
ListTile(
title: Text(tr('폴더 복사 · 이동 방법')),
subtitle: Text(tr('폴더가 들어 있을 때')),
trailing: DropdownButton<String>(
value: _valid(s.copyMethodFolder),
items: _methods(),
onChanged: (v) => c.updateSettings((x) => x.copyMethodFolder = v!),
ListTile(
title: Text(tr('폴더 복사 · 이동 방법')),
subtitle: Text(tr('폴더가 들어 있을 때')),
trailing: DropdownButton<String>(
value: _valid(s.copyMethodFolder),
items: _methods(),
onChanged: (v) => c.updateSettings((x) => x.copyMethodFolder = v!),
),
),
),
if (rsyncUsable)
_options(tr('rsync 옵션'), tr('예: -avPog (보관 · 자세히 · 진행 · 소유자 · 그룹). 이동은 --remove-source-files 를 자동으로 붙임'),
s.rsyncOptions, defaultRsyncOptions, (x, v) => x.rsyncOptions = v),
if (desk)
_options(tr('robocopy 옵션'), tr('예: /E /COPY:DAT /DCOPY:T /R:2 /W:2. 이동은 /MOVE 를 자동으로 붙임'), s.robocopyOptions,
defaultRobocopyOptions, (x, v) => x.robocopyOptions = v),
ListTile(
title: Text(tr('여러 개를 골랐을 때')),
subtitle: Text(tr('rsync 를 항목마다 따로 실행하거나, 한 번에 실행 (robocopy 는 늘 폴더마다)')),
trailing: DropdownButton<String>(
value: s.copyRunMode,
items: [
DropdownMenuItem(value: 'each', child: Text(tr('항목마다 따로 (기본)'))),
DropdownMenuItem(value: 'once', child: Text(tr('한 번에'))),
],
onChanged: (v) => c.updateSettings((x) => x.copyRunMode = v!),
),
),
],
ListTile(
title: Text(tr('속도 제한 (KB/s)')),
subtitle: Text(tr('0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. '
@ -140,6 +132,16 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
),
),
),
]);
}
/// Rsync 화면: rsync 옵션 · rsync 가져오기 · 실시간 동기화 (lsync)
Widget _rsyncPart() {
final s = c.settings;
final desk = Platform.isWindows;
return Column(children: [
_options(tr('rsync 옵션'), tr('예: -avPog (보관 · 자세히 · 진행 · 소유자 · 그룹). 속도 제한은 파일 탐색기의 속도 제한을 함께 씀'),
s.rsyncOptions, defaultRsyncOptions, (x, v) => x.rsyncOptions = v),
ListTile(
title: Text(tr('rsync 가져오기')),
subtitle: Text(_checking
@ -199,13 +201,6 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
child: Text(tr('고르기')),
),
),
SwitchListTile(
value: s.copyMonitor,
onChanged: (v) => c.updateSettings((x) => x.copyMonitor = v),
title: Text(tr('복사 모니터링')),
subtitle: Text(tr('파일 탐색기 가운데에 [모니터링] 버튼: 복사 · 이동을 기억해 진행 · 대상 디스크 용량 · 옵션을 보여 주고, '
'옵션을 고쳐 다시 실행 · 재개하거나 lsync 로 옮깁니다 (복사 · rsync 와 lsync 를 따로).')),
),
_LiveSyncTile(c: c),
]);
}

View File

@ -11,6 +11,7 @@ import 'package:path/path.dart' as p;
import '../app/app_controller.dart';
import '../app/copy_center.dart';
import '../app/live_sync.dart';
import '../app/settings.dart' show CopyTask;
import '../app/transfer_job.dart';
import '../core/sync_tools.dart';
import '../core/file_ops.dart';
@ -31,26 +32,37 @@ import '../l10n/tr.dart';
/// 길게 누르거나 오른쪽 클릭하면 메뉴 (내장 플레이어 · 다른 앱으로 열기 · MKV 목록에 추가 · 이름 바꾸기 …).
/// - 오른쪽 동그라미로 여러 개를 표시해 복사 · 이동 · 삭제 · 재생한다. 복사 · 이동은 다른 창의 지금 폴더로.
/// - 창 배치 (두 창 / 한 창, 좌우 / 위아래, 버튼 줄 위치) 와 버튼 구성은 바꿀 수 있다 (위쪽 ⋮ 메뉴).
///
/// [rsync] 면 Rsync 화면: 같은 모양이지만 늘 좌우 두 창 · 폴더만 · 창마다 폴더 하나만 고른다.
/// 가운데 → (왼쪽 → 오른쪽) · ← (오른쪽 → 왼쪽) · ⇄ (둘 다 함께) 로 rsync, [모니터링] 에 실행한 것이 남는다.
class ExplorerPage extends StatefulWidget {
final AppController c;
const ExplorerPage({super.key, required this.c});
final bool rsync;
const ExplorerPage({super.key, required this.c, this.rsync = false});
static const routeName = 'explorer';
static int _open = 0;
static const rsyncRouteName = 'rsync';
static int _open = 0, _openRsync = 0;
/// 파일 탐색기로: 이미 열려 있으면 그 화면으로 돌아간다 (보던 폴더 그대로)
static Future<void> open(NavigatorState nav, {required AppController c}) async {
if (_open > 0) {
static Future<void> open(NavigatorState nav, {required AppController c}) => _show(nav, c, rsync: false);
/// Rsync 화면으로 (이미 열려 있으면 그 화면으로)
static Future<void> openRsync(NavigatorState nav, {required AppController c}) => _show(nav, c, rsync: true);
static Future<void> _show(NavigatorState nav, AppController c, {required bool rsync}) async {
final name = rsync ? rsyncRouteName : routeName;
if ((rsync ? _openRsync : _open) > 0) {
var found = false;
nav.popUntil((r) {
if (r.settings.name == routeName) found = true;
if (r.settings.name == name) found = true;
return found || r.isFirst;
});
if (found) return;
}
await nav.push(MaterialPageRoute<void>(
settings: const RouteSettings(name: routeName),
builder: (_) => ExplorerPage(c: c),
settings: RouteSettings(name: name),
builder: (_) => ExplorerPage(c: c, rsync: rsync),
));
}
@ -63,7 +75,12 @@ enum ExplorerButton {
up(Icons.arrow_upward, '상위 폴더'),
refresh(Icons.refresh, '새로 고침'),
select(Icons.checklist, '선택'),
// ── Rsync 화면에만 ──
toRight(Icons.arrow_forward, '좌 → 우'),
toLeft(Icons.arrow_back, '좌 ← 우'),
both(Icons.compare_arrows, '좌 ⇄ 우'),
monitor(Icons.monitor_heart_outlined, '모니터링'),
// ──
play(Icons.play_circle_outline, '재생'),
addMkv(Icons.playlist_add, 'MKV 목록에 추가'),
newFolder(Icons.create_new_folder_outlined, '새 폴더'),
@ -83,13 +100,22 @@ enum ExplorerButton {
final String label;
const ExplorerButton(this.icon, this.label);
/// Rsync 화면에만 있는 버튼 (파일 탐색기 버튼 구성에서는 빼기)
bool get rsyncOnly => this == toRight || this == toLeft || this == both || this == monitor;
/// 파일 탐색기에서 고를 수 있는 버튼
static List<ExplorerButton> get explorerValues => [for (final b in values) if (!b.rsyncOnly) b];
/// Rsync 화면의 버튼 (늘 이대로, 두 창 사이)
static const rsyncButtons = [up, refresh, newFolder, toRight, toLeft, both, monitor];
static List<ExplorerButton> fromSettings(List<String> names) {
final out = [
for (final n in names)
for (final b in values)
for (final b in explorerValues)
if (b.name == n) b,
];
return out.isEmpty ? values.toList() : out;
return out.isEmpty ? explorerValues : out;
}
}
@ -151,7 +177,11 @@ class _ExplorerPageState extends State<ExplorerPage> {
@override
void initState() {
super.initState();
ExplorerPage._open++;
if (widget.rsync) {
ExplorerPage._openRsync++;
} else {
ExplorerPage._open++;
}
for (final pane in _panes) {
pane.addListener(_onPane);
}
@ -160,7 +190,11 @@ class _ExplorerPageState extends State<ExplorerPage> {
@override
void dispose() {
ExplorerPage._open--;
if (widget.rsync) {
ExplorerPage._openRsync--;
} else {
ExplorerPage._open--;
}
for (final pane in _panes) {
pane.dispose();
}
@ -174,7 +208,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
Future<void> _init() async {
_volumes = await _loadVolumes();
if (_volumes.isEmpty) _volumes = [(Platform.isWindows ? r'C:\' : '/', Platform.isWindows ? 'C:' : '/')];
final saved = c.settings.explorerPaths;
final saved = widget.rsync ? c.settings.rsyncPaths : c.settings.explorerPaths;
for (var i = 0; i < 2; i++) {
final want = i < saved.length && Directory(saved[i]).existsSync() ? saved[i] : _volumes[0].$1;
await _goTo(_panes[i], want, remember: false);
@ -272,10 +306,14 @@ class _ExplorerPageState extends State<ExplorerPage> {
void _remember(_Pane pane, String dir) {
final i = _panes.indexOf(pane);
final paths = [..._panes.map((x) => x.current)];
final hist = [dir, ...c.settings.explorerHistory.where((h) => !samePath(h, dir))].take(30).toList();
unawaited(c.updateSettings((s) => s
..explorerPaths = paths
..explorerHistory = hist));
if (widget.rsync) {
unawaited(c.updateSettings((s) => s.rsyncPaths = paths));
} else {
final hist = [dir, ...c.settings.explorerHistory.where((h) => !samePath(h, dir))].take(30).toList();
unawaited(c.updateSettings((s) => s
..explorerPaths = paths
..explorerHistory = hist));
}
if (i >= 0) _active = i;
}
@ -283,7 +321,10 @@ class _ExplorerPageState extends State<ExplorerPage> {
ExplorerStyle get _look => ExplorerStyle.of(c.settings.explorerStyle);
/// 왼쪽 폴더 트리 + 오른쪽 파일 목록 배치
bool get _split => c.settings.explorerLayout == 'split';
bool get _split => !widget.rsync && c.settings.explorerLayout == 'split';
/// 좌우 두 창 (Rsync 화면은 늘)
bool get _dual => widget.rsync || c.settings.explorerLayout == 'dual';
/// 한 번 누르면 선택 · 두 번 누르면 열기 (아니면 한 번에 바로 열기)
bool get _selectMode => c.settings.explorerClick != 'open';
@ -332,6 +373,10 @@ class _ExplorerPageState extends State<ExplorerPage> {
await _goTo(pane, e.path);
return;
}
if (widget.rsync) {
await _pick(pane, e);
return;
}
if (!row.isRoot && (_selecting || HardwareKeyboard.instance.isControlPressed)) {
if (!_selecting) setState(() => _selecting = true);
await _toggleMark(pane, e);
@ -343,8 +388,30 @@ class _ExplorerPageState extends State<ExplorerPage> {
pane.changed();
}
/// 표시되었는지: 자기 자신, 또는 위 폴더가 표시되었으면 (폴더를 고르면 안의 것도 모두 고른 것)
bool _isMarked(_Pane pane, String path) => pane.marked.any((m) => isSameOrInside(path, m));
/// 표시되었는지: 자기 자신, 또는 위 폴더가 표시되었으면 (폴더를 고르면 안의 것도 모두 고른 것).
/// Rsync 화면은 고른 그 폴더만.
bool _isMarked(_Pane pane, String path) =>
pane.marked.any((m) => widget.rsync ? samePath(path, m) : isSameOrInside(path, m));
/// Rsync 화면: 창마다 폴더 하나만 고른다 (다시 누르면 취소). 고르면 펼쳐서 안의 폴더가 보이게.
Future<void> _pick(_Pane pane, FileEntry e) async {
if (!e.isDir) return;
if (pane.marked.length == 1 && samePath(pane.marked.first, e.path)) {
setState(pane.marked.clear);
return;
}
setState(() => pane.marked
..clear()
..add(e.path));
if (!pane.expanded.contains(e.path)) {
await _toggle(pane, e);
} else {
pane
..current = e.path
..focused = e.path
..changed();
}
}
/// 다른 표시 안에 든 표시는 뺀다 (폴더 하나로 안의 것까지 고른 것이므로)
void _normalizeMarks(_Pane pane) {
@ -447,7 +514,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
pane.focused = e.path;
pane.changed();
final video = !e.isDir && isVideoFile(e.path);
final dual = c.settings.explorerLayout == 'dual';
final dual = _dual;
final items = <(String, IconData, String)>[
if (e.isDir) ('open', Icons.folder_open, tr('열기')),
if (e.isDir && dual) ('openOther', Icons.vertical_split_outlined, tr('다른 창에서 열기')),
@ -526,7 +593,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
// ───────── 기능 ─────────
_Pane get _pane => _panes[_active];
_Pane? get _other => c.settings.explorerLayout == 'dual' ? _panes[1 - _active] : null;
_Pane? get _other => _dual ? _panes[1 - _active] : null;
/// 기능의 대상: 표시한 항목, 없으면 마지막으로 누른 항목 (저장 장치 맨 위는 빼고)
List<String> _targets(_Pane pane) {
@ -566,6 +633,12 @@ class _ExplorerPageState extends State<ExplorerPage> {
_setSelecting(!_selecting);
case ExplorerButton.monitor:
await MonitorPage.open(context, c);
case ExplorerButton.toRight:
await _runRsync(right: true, left: false);
case ExplorerButton.toLeft:
await _runRsync(right: false, left: true);
case ExplorerButton.both:
await _runRsync(right: true, left: true);
case ExplorerButton.refresh:
pane.cache.clear();
for (final d in pane.expanded.toList()) {
@ -756,7 +829,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
}
/// 지금 도는 복사 · 이동 (아래에서 올라오는 진행 막대)
TransferJob? _job;
/// (Rsync 화면의 ⇄ 는 둘이 함께)
List<TransferJob> _jobs = const [];
/// rsync 가 없으면 (Windows · 내려받기 설정) 지금 내려받을지 묻는다. 쓸 수 있는 rsync 경로 (없으면 null)
Future<String?> _ensureRsync() async {
@ -777,7 +851,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
_snack(tr('복사 · 이동할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).'));
return false;
}
if (_job != null && !_job!.finished) {
if (_jobs.any((j) => !j.finished)) {
_snack(tr('앞의 복사 · 이동이 끝난 뒤에 하세요.'));
return false;
}
@ -792,11 +866,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
}
return false;
}
// 복사 모니터링을 켜면 기억한 같은 복사의 옵션을 쓰고, 처음이면 기억한다
// 파일 탐색기: 현재 방식 · robocopy (rsync 는 Rsync 화면 - 그쪽만 모니터링에 남는다)
final center = CopyCenter.of(c);
final task = c.settings.copyMonitor
? await center.remember(sources, dest, move: move)
: center.fresh(sources, dest, move: move);
final task = center.fresh(sources, dest, move: move);
final method = CopyMethod.of(task.method);
if (!mounted) return false;
final go = await showDialog<bool>(
@ -821,7 +893,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
}
final job = await center.start(task, rsyncExe: rsync);
if (job == null || !mounted) return false;
setState(() => _job = job);
setState(() => _jobs = [job]);
await job.done;
final error = job.error;
if (!mounted) return false;
@ -843,94 +915,189 @@ class _ExplorerPageState extends State<ExplorerPage> {
: trf('{0}개 항목을 {1}', [job.made.length, move ? tr('옮겼습니다.') : tr('복사했습니다.')]));
// 다 되면 진행 막대가 잠깐 100% 를 보인 뒤 내려간다
await Future<void>.delayed(const Duration(milliseconds: 1200));
if (mounted && _job == job) setState(() => _job = null);
if (mounted && _jobs.contains(job)) setState(() => _jobs = const []);
return error == null;
}
/// Rsync 화면: 고른 왼쪽 · 오른쪽 폴더로 rsync. [right] 왼쪽 → 오른쪽, [left] 오른쪽 → 왼쪽 (둘 다면 함께).
/// 폴더 "안의 것" 을 맞춘다 (원본/ → 대상/). 함께 할 때는 받는 쪽이 더 새 파일은 건너뛴다 (-u).
/// 실행한 것은 모니터링에 남는다 (같은 것을 다시 하면 기억한 옵션으로).
Future<void> _runRsync({required bool right, required bool left}) async {
final l = _panes[0].marked.firstOrNull, r = _panes[1].marked.firstOrNull;
if (l == null || r == null) {
_snack(tr('왼쪽 · 오른쪽 창에서 폴더를 하나씩 고르세요.'));
return;
}
if (isSameOrInside(l, r) || isSameOrInside(r, l)) {
_snack(tr('같은 폴더이거나 한쪽이 다른 쪽 안에 있습니다. 다른 폴더를 고르세요.'));
return;
}
if (_jobs.any((j) => !j.finished)) {
_snack(tr('앞의 rsync 가 끝난 뒤에 하세요.'));
return;
}
final runs = [if (right) (l, r), if (left) (r, l)];
for (final (src, dst) in runs) {
final problem = transferProblem([src], dst, move: false);
if (problem != null) {
_snack(problem);
for (final x in _panes) {
x.marked.removeWhere((m) => FileSystemEntity.typeSync(m) == FileSystemEntityType.notFound);
}
await _refreshAll([p.dirname(l), p.dirname(r)]);
return;
}
}
final both = right && left;
final center = CopyCenter.of(c);
// 확인 창에는 기억한 옵션 (없으면 지금 설정) 을 보여 주고, 실행할 때 모니터링에 기억한다
var tasks = [for (final (src, dst) in runs) center.peek([src], dst, contents: true, method: 'rsync')];
String opts(CopyTask t) => both ? withUpdateOption(t.options) : t.options;
if (!mounted) return;
final go = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(both ? tr('rsync 양쪽 (⇄)') : 'rsync'),
content: SizedBox(
width: 560,
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
for (final t in tasks) ...[
Text('${t.sources.first}/', style: const TextStyle(fontWeight: FontWeight.w600)),
Text('→ ${t.dest}/', style: const TextStyle(fontWeight: FontWeight.w600)),
Text('rsync ${opts(t)}${t.bandwidthKBps > 0 ? ' · ${trf('속도 제한 {0} KB/s', [t.bandwidthKBps])}' : ''}',
style: const TextStyle(fontSize: 12, fontFamily: 'monospace', color: JjColors.textDim)),
const SizedBox(height: 10),
],
Text(tr('폴더 안의 것을 맞춥니다 (대상에 같은 이름의 폴더를 새로 만들지 않음).'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
if (both)
Text(tr('양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
]),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('실행'))),
],
),
);
if (go != true || !mounted) return;
final exe = await _ensureRsync();
if (exe == null || !mounted) return;
tasks = [for (final (src, dst) in runs) await center.remember([src], dst, contents: true, method: 'rsync')];
final jobs = <TransferJob>[];
for (final t in tasks) {
final j = await center.start(t.copyWith(options: opts(t)), rsyncExe: exe);
if (j != null) jobs.add(j);
}
if (jobs.isEmpty || !mounted) return;
setState(() => _jobs = jobs);
await Future.wait(jobs.map((j) => j.done));
if (!mounted) return;
await _refreshAll([l, r]);
for (final x in _panes) {
x.changed();
}
final error = jobs.map((j) => j.error).whereType<Object>().firstOrNull;
_snack(error is FileOpCancelled
? tr('취소했습니다.')
: error != null
? trf('끝나지 못했습니다: {0}', [error])
: trf('rsync 끝: {0}', [jobs.map((j) => '${p.basename(j.sources.first)} → ${p.basename(j.dest)}').join(' · ')]));
await Future<void>.delayed(const Duration(milliseconds: 1200));
if (mounted && identical(_jobs, jobs)) setState(() => _jobs = const []);
}
/// 아래에서 올라오는 진행 막대 (위: 전체 항목 중 · 아래: 지금 폴더의 파일 중)
Widget _transferPanel() {
final job = _job;
final jobs = _jobs;
return AnimatedSlide(
offset: job == null ? const Offset(0, 1.2) : Offset.zero,
offset: jobs.isEmpty ? const Offset(0, 1.2) : Offset.zero,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
child: job == null
child: jobs.isEmpty
? const SizedBox(height: 0)
: ListenableBuilder(
listenable: job,
builder: (context, _) {
String pct(double v) => '${(v * 100).round()}%';
final cur = job.index < job.total ? job.index : job.total - 1;
final curFiles = job.total == 0 ? 0 : job.filesTotal[cur.clamp(0, job.total - 1)];
final curDone = job.total == 0 ? 0 : job.filesDone[cur.clamp(0, job.total - 1)];
final title = job.finished
? (job.error == null ? tr('완료') : tr('멈춤'))
: job.move
? tr('옮기는 중')
: tr('복사하는 중');
Widget line(String label, double value, String right) => Padding(
padding: const EdgeInsets.only(top: 6),
child: Row(children: [
SizedBox(
width: 300,
child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12.5)),
),
const SizedBox(width: 10),
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(3),
child: LinearProgressIndicator(
minHeight: 8,
value: job.counting ? null : value,
// 빈 곳과 찬 곳이 분명히 다르게
color: JjColors.accent,
backgroundColor: JjColors.border,
),
),
),
const SizedBox(width: 10),
SizedBox(
width: 150,
child: Text(right, textAlign: TextAlign.end, style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
),
]),
);
return Material(
elevation: 12,
color: JjColors.panel,
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 12, 12),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Row(children: [
Icon(job.move ? Icons.drive_file_move_outline : Icons.copy_outlined, size: 18, color: JjColors.accent),
const SizedBox(width: 8),
Text('$title · ${tr(job.method.label)}', style: const TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(width: 12),
Expanded(
child: Text(job.dest,
maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
),
if (!job.finished) TextButton(onPressed: job.cancel, child: Text(tr('취소'))),
]),
line(
trf('전체 {0}개 중 {1}번째', [job.total, (cur + 1).clamp(1, job.total)]),
job.overall,
'${pct(job.overall)} · ${trf('파일 {0}/{1}', [job.allDone, job.allFiles])}',
),
line(
job.counting ? tr('파일 세는 중…') : trf('{0} 안의 파일', [job.currentName]),
job.current,
'${pct(job.current)} · ${trf('파일 {0}/{1}', [curDone, curFiles])}',
),
]),
),
);
},
: Material(
elevation: 12,
color: JjColors.panel,
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 12, 12),
child: Column(mainAxisSize: MainAxisSize.min, children: [
for (final (k, job) in jobs.indexed) ...[
if (k > 0) const Divider(height: 18),
ListenableBuilder(listenable: job, builder: (context, _) => _jobView(job)),
],
]),
),
),
);
}
/// 진행 막대 하나 (위: 전체 항목 중 · 아래: 지금 폴더의 파일 중)
Widget _jobView(TransferJob job) {
String pct(double v) => '${(v * 100).round()}%';
final cur = job.index < job.total ? job.index : job.total - 1;
final curFiles = job.total == 0 ? 0 : job.filesTotal[cur.clamp(0, job.total - 1)];
final curDone = job.total == 0 ? 0 : job.filesDone[cur.clamp(0, job.total - 1)];
final title = job.finished
? (job.error == null ? tr('완료') : tr('멈춤'))
: job.move
? tr('옮기는 중')
: tr('복사하는 중');
Widget line(String label, double value, String right) => Padding(
padding: const EdgeInsets.only(top: 6),
child: Row(children: [
SizedBox(
width: 300,
child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12.5)),
),
const SizedBox(width: 10),
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(3),
child: LinearProgressIndicator(
minHeight: 8,
value: job.counting ? null : value,
// 빈 곳과 찬 곳이 분명히 다르게
color: JjColors.accent,
backgroundColor: JjColors.border,
),
),
),
const SizedBox(width: 10),
SizedBox(
width: 150,
child: Text(right, textAlign: TextAlign.end, style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
),
]),
);
return Column(mainAxisSize: MainAxisSize.min, children: [
Row(children: [
Icon(job.move ? Icons.drive_file_move_outline : Icons.copy_outlined, size: 18, color: JjColors.accent),
const SizedBox(width: 8),
Text('$title · ${tr(job.method.label)}', style: const TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(width: 12),
Expanded(
// Rsync 화면: 원본 → 대상 (⇄ 는 두 줄)
child: Text(widget.rsync ? '${job.sources.join(', ')}/ → ${job.dest}/' : job.dest,
maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
),
if (!job.finished) TextButton(onPressed: job.cancel, child: Text(tr('취소'))),
]),
line(
trf('전체 {0}개 중 {1}번째', [job.total, (cur + 1).clamp(1, job.total)]),
job.overall,
'${pct(job.overall)} · ${trf('파일 {0}/{1}', [job.allDone, job.allFiles])}',
),
line(
job.counting ? tr('파일 세는 중…') : trf('{0} 안의 파일', [job.currentName]),
job.current,
'${pct(job.current)} · ${trf('파일 {0}/{1}', [curDone, curFiles])}',
),
]);
}
Future<String?> _askName(String title, String initial) {
final ctl = TextEditingController(text: initial);
final dot = initial.lastIndexOf('.');
@ -1121,7 +1288,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
Future<void> _buttonsDialog() async {
final current = ExplorerButton.fromSettings(c.settings.explorerButtons);
final order = [...current, ...ExplorerButton.values.where((b) => !current.contains(b))];
final order = [...current, ...ExplorerButton.explorerValues.where((b) => !current.contains(b))];
final on = {...current};
final ok = await showDialog<bool>(
context: context,
@ -1160,10 +1327,10 @@ class _ExplorerPageState extends State<ExplorerPage> {
onPressed: () => set(() {
order
..clear()
..addAll(ExplorerButton.values);
..addAll(ExplorerButton.explorerValues);
on
..clear()
..addAll(ExplorerButton.values);
..addAll(ExplorerButton.explorerValues);
}),
child: Text(tr('기본값')),
),
@ -1189,7 +1356,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
const SingleActivator(LogicalKeyboardKey.f2): () => _button(ExplorerButton.rename),
const SingleActivator(LogicalKeyboardKey.backspace): () => _button(ExplorerButton.up),
const SingleActivator(LogicalKeyboardKey.tab): () =>
setState(() => _active = c.settings.explorerLayout == 'dual' ? 1 - _active : 0),
setState(() => _active = _dual ? 1 - _active : 0),
},
child: Focus(
autofocus: true,
@ -1217,9 +1384,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
color: JjColors.panel,
padding: const EdgeInsets.only(left: 8, right: appBarRightPadding),
child: Row(children: [
const AppNavButtons(onExplorerPage: true),
AppNavButtons(onExplorerPage: !widget.rsync, onRsyncPage: widget.rsync),
const SizedBox(width: 8),
Text(tr('파일 탐색기'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
Text(widget.rsync ? 'Rsync' : tr('파일 탐색기'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(width: 12),
Expanded(
child: Text(_ready ? _pane.current : '',
@ -1230,7 +1397,11 @@ class _ExplorerPageState extends State<ExplorerPage> {
icon: const Icon(Icons.more_vert),
onSelected: _button,
itemBuilder: (_) => [
for (final b in [ExplorerButton.layout, ExplorerButton.buttons, ExplorerButton.sort, ExplorerButton.hidden])
for (final b in [
if (!widget.rsync) ...[ExplorerButton.layout, ExplorerButton.buttons],
ExplorerButton.sort,
ExplorerButton.hidden,
])
PopupMenuItem(
value: b,
child: Row(children: [Icon(b.icon, size: 18), const SizedBox(width: 12), Text(tr(b.label))]),
@ -1255,7 +1426,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
}
_shape = shape;
}
final dual = s.explorerLayout == 'dual';
final dual = _dual;
final split = _split;
return LayoutBuilder(builder: (context, box) {
final side = !(dual || split) ||
@ -1264,14 +1435,11 @@ class _ExplorerPageState extends State<ExplorerPage> {
'stacked' => false,
_ => box.maxWidth >= box.maxHeight,
};
final bar = s.explorerToolbar == 'hidden'
final bar = s.explorerToolbar == 'hidden' && !widget.rsync
? null
: _Toolbar(
// [모니터링] 은 환경 설정 > 복사 모니터링 을 켰을 때만
buttons: [
for (final b in ExplorerButton.fromSettings(s.explorerButtons))
if (b != ExplorerButton.monitor || s.copyMonitor) b,
],
// Rsync 화면: 늘 같은 버튼 (→ ← ⇄ 모니터링) 을 두 창 사이에
buttons: widget.rsync ? ExplorerButton.rsyncButtons : ExplorerButton.fromSettings(s.explorerButtons),
vertical: side,
onPressed: _button,
enabled: (b) => switch (b) {
@ -1281,13 +1449,13 @@ class _ExplorerPageState extends State<ExplorerPage> {
selected: (b) =>
(b == ExplorerButton.hidden && s.explorerShowHidden) || (b == ExplorerButton.select && _selecting),
);
final middle = bar != null && s.explorerToolbar == 'middle';
final edge = bar != null && s.explorerToolbar == 'edge';
final middle = bar != null && (widget.rsync || s.explorerToolbar == 'middle');
final edge = bar != null && !widget.rsync && s.explorerToolbar == 'edge';
final children = <Widget>[
if (dual) ...[
Expanded(child: _paneView(0)),
Expanded(child: _paneView(0, foldersOnly: widget.rsync)),
if (middle) bar,
Expanded(child: _paneView(1)),
Expanded(child: _paneView(1, foldersOnly: widget.rsync)),
if (edge) bar,
] else if (split) ...[
// 왼쪽 폴더 트리 (좁게) · 오른쪽 그 폴더의 파일 목록
@ -1528,8 +1696,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
];
return GestureDetector(
onSecondaryTapUp: real ? (d) => _menu(pane, e, d.globalPosition) : null,
onLongPressStart: real ? (d) => _menu(pane, e, d.globalPosition) : null,
// Rsync 화면은 고르기만 (파일 기능 메뉴 없음)
onSecondaryTapUp: real && !widget.rsync ? (d) => _menu(pane, e, d.globalPosition) : null,
onLongPressStart: real && !widget.rsync ? (d) => _menu(pane, e, d.globalPosition) : null,
child: Material(
color: marked
? JjColors.accent.withValues(alpha: 0.18)
@ -1552,11 +1721,15 @@ class _ExplorerPageState extends State<ExplorerPage> {
),
),
// 표시 동그라미는 두 번 누르기 영역 밖 (기다리지 않고 바로)
if (real && _selecting)
if ((real || (widget.rsync && row.isRoot)) && (_selecting || widget.rsync))
SizedBox(
width: 36,
child: IconButton(
tooltip: marked ? tr('표시 지우기') : tr('표시 (여러 개 고르기)'),
tooltip: widget.rsync
? (marked ? tr('고르기 취소') : tr('이 폴더를 rsync 원본 · 대상으로 고르기'))
: marked
? tr('표시 지우기')
: tr('표시 (여러 개 고르기)'),
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
// ✔ 모양: 고르지 않은 것은 흐리게, 고른 것은 진하게
@ -1565,11 +1738,11 @@ class _ExplorerPageState extends State<ExplorerPage> {
color: marked ? JjColors.accent : JjColors.textDim.withValues(alpha: 0.35)),
onPressed: () {
setState(() => _active = _panes.indexOf(pane));
_toggleMark(pane, e);
widget.rsync ? _pick(pane, e) : _toggleMark(pane, e);
},
),
)
else if (_selecting)
else if (_selecting || widget.rsync)
const SizedBox(width: 36),
]),
),

View File

@ -166,7 +166,7 @@ class _CopyTab extends StatelessWidget {
final tasks = center.tasks;
if (tasks.isEmpty) {
return Center(
child: Text(tr('기억한 복사가 없습니다. 파일 탐색기에서 복사 · 이동하면 여기에 자동으로 등록됩니다.'),
child: Text(tr('실행한 rsync 가 없습니다. Rsync 화면에서 → · ← · ⇄ 로 실행하면 여기에 자동으로 등록됩니다.'),
style: const TextStyle(color: JjColors.textDim)),
);
}

View File

@ -596,9 +596,13 @@ class _SettingsPageState extends State<SettingsPage> {
onChanged: (v) => c.updateSettings((x) => x.explorerLayout = v!),
),
),
// 복사 · 이동 방법 (현재 방식 · rsync · robocopy) · 속도 제한 · 실시간 동기화
// 복사 · 이동 방법 (현재 방식 · robocopy) · 속도 제한
CopySyncSettings(c: c),
]),
// Rsync 화면: rsync 옵션 · 가져오기 · 실시간 동기화 (lsync) · 백그라운드로 실행
_group('rsync', Icons.sync_alt, 'Rsync', [
CopySyncSettings(c: c, rsync: true),
]),
_group('download', Icons.download_outlined, tr('다운로드'), [
if (desk)
SwitchListTile(
@ -836,7 +840,7 @@ class _SettingsPageState extends State<SettingsPage> {
final _groupKeys = <String, GlobalKey>{};
static const _groupIds = [
'general', 'display', 'mkv', 'subtitle', 'play', 'browser', 'files', 'download', 'run', 'cleanup', 'about', //
'general', 'display', 'mkv', 'subtitle', 'play', 'browser', 'files', 'rsync', 'download', 'run', 'cleanup', 'about', //
];
String _groupTitle(String id) => switch (id) {
@ -847,6 +851,7 @@ class _SettingsPageState extends State<SettingsPage> {
'play' => tr('재생'),
'browser' => tr('웹 브라우저'),
'files' => tr('파일 탐색기'),
'rsync' => 'Rsync',
'download' => tr('다운로드'),
'run' => tr('실행 · 종료'),
'cleanup' => tr('저장 공간 정리'),

View File

@ -59,6 +59,9 @@ void main() {
expect(find.text('상위 폴더'), findsOneWidget);
expect(find.text('doc.txt'), findsOneWidget);
expect(find.byTooltip('표시 (여러 개 고르기)'), findsNothing);
// 모니터링 · rsync 는 Rsync 화면에만
expect(find.text('모니터링'), findsNothing);
expect(find.text('좌 → 우'), findsNothing);
// 폴더를 한 번 누르면 바로 펼치고, 다시 누르면 접는다
await act(tester, () => tester.tap(find.text('sub')));
@ -278,6 +281,69 @@ void main() {
expect([back.explorerLayout, back.explorerToolbar, back.explorerButtons],
[c.settings.explorerLayout, c.settings.explorerToolbar, c.settings.explorerButtons]);
});
testWidgets('Rsync 화면: 늘 좌우 두 창 · 폴더만 · 창마다 하나만 고르기 · → ← ⇄ · 모니터링', (tester) async {
Directory(p.join(left, 'sub2')).createSync();
Directory(p.join(right, 'rsub')).createSync();
File(p.join(right, 'r.txt')).writeAsStringSync('r');
c.settings
..explorerLayout = 'single' // 파일 탐색기 배치와 상관없이 두 창
..rsyncPaths = [left, right];
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c, rsync: true))));
await settle(tester, () => find.text('sub').evaluate().isNotEmpty && find.text('rsub').evaluate().isNotEmpty);
expect(find.text('Rsync'), findsWidgets);
// 폴더만 (파일은 안 보임) · 버튼은 → ← ⇄ 모니터링 (파일 기능 · 선택 없음)
expect(find.text('doc.txt'), findsNothing);
expect(find.text('r.txt'), findsNothing);
for (final t in ['좌 → 우', '좌 ← 우', '좌 ⇄ 우', '모니터링']) {
expect(find.text(t), findsOneWidget);
}
expect(find.text('선택'), findsNothing);
expect(find.text('삭제'), findsNothing);
// 고르지 않고 → : 알림
await act(tester, () => tester.tap(find.text('좌 → 우')));
expect(find.text('왼쪽 · 오른쪽 창에서 폴더를 하나씩 고르세요.'), findsOneWidget);
// 창마다 하나만: sub → sub2 로 바꾸면 sub 는 풀림, 다시 누르면 취소
await act(tester, () => tester.tap(find.text('sub')));
await act(tester, () => tester.tap(find.text('sub2')));
await settle(tester, () => false, rounds: 5);
expect(find.byTooltip('고르기 취소'), findsOneWidget);
await act(tester, () => tester.tap(find.text('sub2')));
await settle(tester, () => false, rounds: 5);
expect(find.byTooltip('고르기 취소'), findsNothing);
await act(tester, () => tester.tap(find.text('sub')));
await act(tester, () => tester.tap(find.text('rsub')));
await settle(tester, () => false, rounds: 5);
expect(find.byTooltip('고르기 취소'), findsNWidgets(2)); // 왼쪽 하나 · 오른쪽 하나
// ⇄ : 두 방향을 보여 주고 -u, 취소하면 모니터링에 남지 않는다
await act(tester, () => tester.tap(find.text('좌 ⇄ 우')));
await tester.pumpAndSettle();
expect(find.text('rsync 양쪽 (⇄)'), findsOneWidget);
expect(find.text('${p.join(left, 'sub')}/'), findsOneWidget);
expect(find.text('→ ${p.join(right, 'rsub')}/'), findsOneWidget);
expect(find.text('${p.join(right, 'rsub')}/'), findsOneWidget);
expect(find.text('→ ${p.join(left, 'sub')}/'), findsOneWidget);
expect(find.textContaining('-avPog -u'), findsNWidgets(2));
await tester.tap(find.text('취소'));
await tester.pumpAndSettle();
expect(c.settings.copyTasks, isEmpty);
// → 는 한 방향 · -u 없이
await act(tester, () => tester.tap(find.text('좌 → 우')));
await tester.pumpAndSettle();
expect(find.text('→ ${p.join(right, 'rsub')}/'), findsOneWidget);
expect(find.text('→ ${p.join(left, 'sub')}/'), findsNothing);
expect(find.textContaining('-u'), findsNothing);
await tester.tap(find.text('취소'));
await tester.pumpAndSettle();
// 고른 폴더는 기억 (다음에 열 때 그 폴더부터)
expect(c.settings.rsyncPaths, [p.join(left, 'sub'), p.join(right, 'rsub')]);
});
}
/// [name] 줄의 "표시" 동그라미 버튼

View File

@ -190,7 +190,6 @@ void main() {
addTearDown(() => tmp.deleteSync(recursive: true));
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..copyMonitor = true
..copyTasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, '..'), method: 'builtin')]
..liveSyncPairs = [LiveSyncPair(tmp.path, p.join(tmp.path, 'x'), schedule: const ['0 9-17 * * 1-5'])];
tester.view.physicalSize = const Size(1500, 1000);

View File

@ -36,15 +36,20 @@ void main() {
['-a', '--bwlimit=10', '-8', '/x', '/y/']); // Android: 한글 이름을 그대로 출력 (-8)
});
test('기본값: 폴더 복사는 rsync (앱에 들어 있음), 예전 설정의 기본값 현재 방식도 rsync 로', () {
expect(AppSettings().copyMethodFolder, 'rsync');
expect(AppSettings().copyMethodFile, 'builtin');
expect(AppSettings.fromJson({}).copyMethodFolder, 'rsync');
expect(AppSettings.fromJson({'copyMethodFolder': 'builtin'}).copyMethodFolder, 'rsync'); // 예전 저장값
// 새 버전에서 직접 고른 현재 방식은 그대로
final s = AppSettings()..copyMethodFolder = 'builtin';
expect(AppSettings.fromJson(s.toJson()).copyMethodFolder, 'builtin');
test('파일 탐색기 복사 방법: 현재 방식 (기본) · robocopy, 예전 값 rsync 는 현재 방식으로 (rsync 는 Rsync 화면)', () {
expect([AppSettings().copyMethodFile, AppSettings().copyMethodFolder], ['builtin', 'builtin']);
expect(AppSettings.fromJson({'copyMethodFolder': 'rsync', 'copyMethodFile': 'rsync'}).copyMethodFolder, 'builtin');
expect(AppSettings.fromJson({'copyMethodFolder': 'robocopy'}).copyMethodFolder, 'robocopy');
final s = AppSettings()..rsyncPaths = ['/a', '/b'];
expect(AppSettings.fromJson(s.toJson()).rsyncPaths, ['/a', '/b']);
});
test('양쪽 (⇄) rsync 는 -u 를 더한다 (이미 있으면 그대로)', () {
expect(withUpdateOption('-avPog'), '-avPog -u');
expect(withUpdateOption('-avuP'), '-avuP');
expect(withUpdateOption('-a --update'), '-a --update');
expect(withUpdateOption('-a --bwlimit=10'), '-a --bwlimit=10 -u');
expect(withUpdateOption(''), '-u');
});
test('robocopy: 폴더마다 · 같은 폴더 파일은 한 번에 · /IPG · 이동', () {
@ -207,7 +212,7 @@ void main() {
final back = AppSettings.fromJson(c.settings.toJson());
expect(back.liveSyncPairs.single.target, p.join(src, 'in'));
expect([back.copyMethodFolder, back.rsyncOptions, back.copyRunMode, back.rsyncSource],
['rsync', '-avPog', 'each', 'download']);
['builtin', '-avPog', 'each', 'download']);
});
});
}