From c273297b11f4bfc8f1fdb9af52575fb513a41aa4 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:28:32 +0900 Subject: [PATCH] =?UTF-8?q?Rsync=20=ED=99=94=EB=A9=B4=20(=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=ED=83=90=EC=83=89=EA=B8=B0=20=EC=98=86)=20=C2=B7?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=20=ED=83=90=EC=83=89=EA=B8=B0=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=AA=A8=EB=8B=88=ED=84=B0=EB=A7=81=20=C2=B7=20rsy?= =?UTF-8?q?nc=20=C2=B7=20lsync=20=EB=B9=BC=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 위쪽 파일 탐색기 옆 [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 --- assets/l10n/en.json | 15 + assets/l10n/ja.json | 15 + assets/l10n/zh-Hans.json | 15 + integration_test/monitor_shot_test.dart | 4 +- integration_test/rsync_page_test.dart | 106 ++++++ lib/app/copy_center.dart | 11 +- lib/app/settings.dart | 27 +- lib/core/sync_tools.dart | 7 + lib/ui/app_actions.dart | 19 +- lib/ui/copy_sync_settings.dart | 87 +++-- lib/ui/explorer_page.dart | 427 +++++++++++++++++------- lib/ui/monitor_page.dart | 2 +- lib/ui/settings_page.dart | 9 +- test/explorer_test.dart | 66 ++++ test/monitor_test.dart | 1 - test/sync_tools_test.dart | 23 +- 16 files changed, 627 insertions(+), 207 deletions(-) create mode 100644 integration_test/rsync_page_test.dart diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 6a6f9ed..abf91fb 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -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", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 45429f6..7500d65 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -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}個を含む)", "폴더 선택": "フォルダーを選択", "폴더 수정": "フォルダーを編集", + "폴더 안의 것을 맞춥니다 (대상에 같은 이름의 폴더를 새로 만들지 않음).": "フォルダーの中身を合わせます (対象に同じ名前のフォルダーは作りません)。", "폴더 열기": "フォルダーを開く", "폴더 이름": "フォルダー名", "폴더 이름 바꾸기 · 이동": "フォルダー名の変更・移動", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index d6bd234..a09fbf7 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -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} 个)", "폴더 선택": "选择文件夹", "폴더 수정": "编辑文件夹", + "폴더 안의 것을 맞춥니다 (대상에 같은 이름의 폴더를 새로 만들지 않음).": "同步文件夹内的内容 (不会在目标中新建同名文件夹)。", "폴더 열기": "打开文件夹", "폴더 이름": "文件夹名称", "폴더 이름 바꾸기 · 이동": "重命名 · 移动文件夹", diff --git a/integration_test/monitor_shot_test.dart b/integration_test/monitor_shot_test.dart index a6ea9b0..1b15e36 100644 --- a/integration_test/monitor_shot_test.dart +++ b/integration_test/monitor_shot_test.dart @@ -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(); diff --git a/integration_test/rsync_page_test.dart b/integration_test/rsync_page_test.dart new file mode 100644 index 0000000..56e0c64 --- /dev/null +++ b/integration_test/rsync_page_test.dart @@ -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 wait(int ms) async { + for (var i = 0; i < ms ~/ 100; i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 100))); + await tester.pump(); + } + } + + Future 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 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.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); + }); +} diff --git a/lib/app/copy_center.dart b/lib/app/copy_center.dart index de2df6f..df49093 100644 --- a/lib/app/copy_center.dart +++ b/lib/app/copy_center.dart @@ -51,9 +51,16 @@ class CopyCenter extends ChangeNotifier { ); } + /// 같은 복사를 기억해 두었으면 그것, 없으면 새 작업 (기억하지는 않음 - 확인 창에 보여 줄 때) + CopyTask peek(List 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 remember(List sources, String dest, {bool move = false}) async { - final t = fresh(sources, dest, move: move); + Future remember(List 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]); diff --git a/lib/app/settings.dart b/lib/app/settings.dart index 328c5cf..d658032 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -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 liveSyncPairs = []; - /// 복사 모니터링: 파일 탐색기 가운데 [모니터링] 버튼, 복사 · 이동을 기억해 다시 실행 · 옵션 고치기 - bool copyMonitor = false; + /// 모니터링 (Rsync 화면 가운데 [모니터링]): 실행한 rsync 를 기억해 다시 실행 · 옵션 고치기 · lsync 로 옮기기 List copyTasks = []; + /// Rsync 화면에서 마지막으로 연 폴더 (왼쪽 · 오른쪽) + List 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 = [ diff --git a/lib/core/sync_tools.dart b/lib/core/sync_tools.dart index 355027c..545d9eb 100644 --- a/lib/core/sync_tools.dart +++ b/lib/core/sync_tools.dart @@ -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 rsyncArgs({ diff --git a/lib/ui/app_actions.dart b/lib/ui/app_actions.dart index 830e35f..509a7da 100644 --- a/lib/ui/app_actions.dart +++ b/lib/ui/app_actions.dart @@ -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)), diff --git a/lib/ui/copy_sync_settings.dart b/lib/ui/copy_sync_settings.dart index 0ed995e..26a616e 100644 --- a/lib/ui/copy_sync_settings.dart +++ b/lib/ui/copy_sync_settings.dart @@ -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 createState() => _CopySyncSettingsState(); @@ -46,12 +48,14 @@ class _CopySyncSettingsState extends State { } } + /// 파일 탐색기의 방법 (rsync 는 Rsync 화면에서) List> _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 { ); @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( - 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( + value: _valid(s.copyMethodFile), + items: _methods(), + onChanged: (v) => c.updateSettings((x) => x.copyMethodFile = v!), + ), ), - ), - ListTile( - title: Text(tr('폴더 복사 · 이동 방법')), - subtitle: Text(tr('폴더가 들어 있을 때')), - trailing: DropdownButton( - value: _valid(s.copyMethodFolder), - items: _methods(), - onChanged: (v) => c.updateSettings((x) => x.copyMethodFolder = v!), + ListTile( + title: Text(tr('폴더 복사 · 이동 방법')), + subtitle: Text(tr('폴더가 들어 있을 때')), + trailing: DropdownButton( + 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( - 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 { ), ), ), + ]); + } + + /// 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 { 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), ]); } diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 79bf824..569c6ea 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -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 open(NavigatorState nav, {required AppController c}) async { - if (_open > 0) { + static Future open(NavigatorState nav, {required AppController c}) => _show(nav, c, rsync: false); + + /// Rsync 화면으로 (이미 열려 있으면 그 화면으로) + static Future openRsync(NavigatorState nav, {required AppController c}) => _show(nav, c, rsync: true); + + static Future _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( - 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 get explorerValues => [for (final b in values) if (!b.rsyncOnly) b]; + + /// Rsync 화면의 버튼 (늘 이대로, 두 창 사이) + static const rsyncButtons = [up, refresh, newFolder, toRight, toLeft, both, monitor]; + static List fromSettings(List 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 { @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 { @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 { Future _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 { 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 { 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 { 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 { 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 _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 { 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 { // ───────── 기능 ───────── _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 _targets(_Pane pane) { @@ -566,6 +633,12 @@ class _ExplorerPageState extends State { _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 { } /// 지금 도는 복사 · 이동 (아래에서 올라오는 진행 막대) - TransferJob? _job; + /// (Rsync 화면의 ⇄ 는 둘이 함께) + List _jobs = const []; /// rsync 가 없으면 (Windows · 내려받기 설정) 지금 내려받을지 묻는다. 쓸 수 있는 rsync 경로 (없으면 null) Future _ensureRsync() async { @@ -777,7 +851,7 @@ class _ExplorerPageState extends State { _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 { } 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( @@ -821,7 +893,7 @@ class _ExplorerPageState extends State { } 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 { : trf('{0}개 항목을 {1}', [job.made.length, move ? tr('옮겼습니다.') : tr('복사했습니다.')])); // 다 되면 진행 막대가 잠깐 100% 를 보인 뒤 내려간다 await Future.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 _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( + 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 = []; + 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().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.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 _askName(String title, String initial) { final ctl = TextEditingController(text: initial); final dot = initial.lastIndexOf('.'); @@ -1121,7 +1288,7 @@ class _ExplorerPageState extends State { Future _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( context: context, @@ -1160,10 +1327,10 @@ class _ExplorerPageState extends State { 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 { 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 { 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 { 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 { } _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 { '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 { 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 = [ 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 { ]; 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 { ), ), // 표시 동그라미는 두 번 누르기 영역 밖 (기다리지 않고 바로) - 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 { 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), ]), ), diff --git a/lib/ui/monitor_page.dart b/lib/ui/monitor_page.dart index c10efbb..d506c49 100644 --- a/lib/ui/monitor_page.dart +++ b/lib/ui/monitor_page.dart @@ -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)), ); } diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart index d34074c..d084bfe 100644 --- a/lib/ui/settings_page.dart +++ b/lib/ui/settings_page.dart @@ -596,9 +596,13 @@ class _SettingsPageState extends State { 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 { final _groupKeys = {}; 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 { 'play' => tr('재생'), 'browser' => tr('웹 브라우저'), 'files' => tr('파일 탐색기'), + 'rsync' => 'Rsync', 'download' => tr('다운로드'), 'run' => tr('실행 · 종료'), 'cleanup' => tr('저장 공간 정리'), diff --git a/test/explorer_test.dart b/test/explorer_test.dart index ac567d6..4d8ec34 100644 --- a/test/explorer_test.dart +++ b/test/explorer_test.dart @@ -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] 줄의 "표시" 동그라미 버튼 diff --git a/test/monitor_test.dart b/test/monitor_test.dart index 6a14dd9..4582852 100644 --- a/test/monitor_test.dart +++ b/test/monitor_test.dart @@ -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); diff --git a/test/sync_tools_test.dart b/test/sync_tools_test.dart index 7c85d01..9a8c0f8 100644 --- a/test/sync_tools_test.dart +++ b/test/sync_tools_test.dart @@ -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']); }); }); }