rsync 원본 파일 지우기 (--remove-source-files) · 빈 폴더 정리 · lsync 최종 정리
- Rsync 화면의 한 방향 (좌 → 우 · 좌 ← 우): 확인 창에서 [원본 파일 지우기 (--remove-source-files)] 를 고를 수 있고, 고르면 끝난 뒤 원본의 빈 폴더를 지움 (find 원본/ -type d -empty -delete) - 원본 폴더는 남김 / 지움 중 고르기. 양쪽 (⇄) 에는 없음 - 모니터링 lsync 의 쌍마다 [최종 정리]: rsync -avHPOg --remove-source-files 원본/ 대상/ 뒤 빈 폴더 지우기, 원본 폴더 남김 / 지움 고르기. 원본이 비므로 그 실시간 동기화는 끔 (지우기 포함이면 대상까지 비우지 않게), 진행은 복사 · rsync 탭에 남음 - 기억한 작업에 원본 정리 (prune) 저장, 모니터링 카드에서 바꾸기 · 진행 막대에 "빈 폴더 N개 지움" - 원본 정리를 고른 이동은 예전의 "비면 원본 폴더째 지우기" 대신 고른 대로 - Windows 실제 rsync 로 시험 (integration_test/rsync_page_test.dart) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>묶음3
parent
c273297b11
commit
1ac48354af
|
|
@ -164,6 +164,7 @@
|
|||
"rsync 를 항목마다 따로 실행하거나, 한 번에 실행 (robocopy 는 늘 폴더마다)": "Run rsync separately for each item, or once for all (robocopy always runs per folder)",
|
||||
"rsync 받는 중": "Downloading rsync",
|
||||
"rsync 실행 파일": "rsync executable",
|
||||
"rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > Rsync 에서 확인하세요.": "Cannot find the rsync executable. Check Settings > Rsync.",
|
||||
"rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > 파일 탐색기에서 경로를 확인하세요.": "rsync executable not found. Check the path in Settings > File explorer.",
|
||||
"rsync 양쪽 (⇄)": "rsync both ways (⇄)",
|
||||
"rsync 옵션": "rsync options",
|
||||
|
|
@ -298,6 +299,7 @@
|
|||
"끝 시간이 시작보다 빠른 줄: {0}{1}": "Lines whose end is before their start: {0}{1}",
|
||||
"끝: {0}": "End: {0}",
|
||||
"끝나지 못했습니다: {0}": "Did not finish: {0}",
|
||||
"끝난 뒤 원본의 빈 폴더를 지웁니다 (find 원본/ -type d -empty -delete). 원본 폴더는:": "Afterwards, empty folders in the source are deleted (find source/ -type d -empty -delete). The source folder:",
|
||||
"나가기": "Exit",
|
||||
"나머지는 대기했다가 차례로 받습니다": "The rest wait and download in turn",
|
||||
"나중에": "Later",
|
||||
|
|
@ -390,6 +392,7 @@
|
|||
"대상 디스크: 알 수 없음": "Target disk: unknown",
|
||||
"대상 폴더 열기": "Open containing folder",
|
||||
"대상 폴더가 없습니다: {0}": "Target folder not found: {0}",
|
||||
"대상으로 옮긴 파일을 원본에서 지웁니다 (이동)": "Files transferred to the target are deleted from the source (move)",
|
||||
"더 보기": "More",
|
||||
"더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)": "When JJ_MKVMaker is chosen by double-click · default app (the right-click menu stays)",
|
||||
"더한 언어 (AI 자동 번역)": "Added language (AI auto-translated)",
|
||||
|
|
@ -546,6 +549,10 @@
|
|||
"비어 있는 폴더": "Empty folder",
|
||||
"비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "This folder is empty.\nPress [Add bookmark] · [New folder] above, or drag bookmarks onto a folder on the left.",
|
||||
"비어 있음": "Empty",
|
||||
"빈 폴더 {0}개 지움": "Deleted {0} empty folders",
|
||||
"빈 폴더 그대로": "Leave empty folders",
|
||||
"빈 폴더 지움 · 원본 폴더는 남김": "Delete empty folders · keep the source folder",
|
||||
"빈 폴더 지움 · 원본 폴더도 지움": "Delete empty folders · delete the source folder too",
|
||||
"사용 안 함": "Off",
|
||||
"사용 안 함. \"Sign in to confirm you're not a bot\" 오류가 나오면 설정하세요": "Off. Set this if the \"Sign in to confirm you're not a bot\" error appears",
|
||||
"사용: {0}": "Using: {0}",
|
||||
|
|
@ -730,12 +737,17 @@
|
|||
"원본 비율": "Original aspect",
|
||||
"원본 유지 (재인코딩 없음)": "Keep original (no re-encoding)",
|
||||
"원본 크기": "Original size",
|
||||
"원본 파일 지우기 (--remove-source-files)": "Delete source files (--remove-source-files)",
|
||||
"원본 폴더": "Source folder",
|
||||
"원본 폴더가 바뀌면 곧바로 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안). 시작할 때 한 번 맞춥니다.": "When the source folder changes, the target is synced right away (while the app is running). Also syncs once at start.",
|
||||
"원본 폴더가 없습니다": "Source folder not found",
|
||||
"원본과 대상이 서로 안에 있습니다": "Source and target are inside each other",
|
||||
"원본에 없는 것을 대상에서 지우기": "Delete from target what is not in the source",
|
||||
"원본의 {0}배": "{0}× original",
|
||||
"원본의 빈 폴더 정리 중…": "Cleaning up empty folders in the source…",
|
||||
"원본의 파일을 모두 대상으로 옮기고 (원본에서 지움), 원본에 남은 빈 폴더를 지웁니다.": "Moves all files from the source to the target (deleting them from the source), then deletes the empty folders left in the source.",
|
||||
"원본이 바뀌면 대상에 맞춥니다. 일정을 정하면 그 시간에만 맞추고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다.": "When the source changes, the target is synced. With a schedule, sync happens only in that time; changes outside it are collected and synced at the next start time.",
|
||||
"원본이 비므로 이 실시간 동기화는 끕니다. 진행은 [복사 · rsync] 탭에서 봅니다.": "The source will be empty, so this live sync is turned off. See progress in the [Copy · rsync] tab.",
|
||||
"원본이 없습니다 (다른 곳에서 지워졌거나 옮겨졌습니다): {0}": "Source not found (deleted or moved elsewhere): {0}",
|
||||
"원어 (영상 속 언어)": "Source language (spoken in the video)",
|
||||
"원어({0})와 같은 언어로는 번역하지 않습니다.": "Will not translate into the same language as the source ({0}).",
|
||||
|
|
@ -966,6 +978,7 @@
|
|||
"즐겨찾기 표시줄": "Bookmarks bar",
|
||||
"지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "The running copy uses the previous options. Stop it and run again with the new options?",
|
||||
"지금 맞추기": "Sync now",
|
||||
"지금 맞추는 중입니다. 끝난 뒤에 하세요.": "Syncing right now. Try again when it finishes.",
|
||||
"지금 보고 있는 동영상의 {0} (여러 개는 목록에서 체크)": "{0} for the video you are viewing (check several in the list)",
|
||||
"지금 설치": "Install now",
|
||||
"지금 쓰는 버전: v{0} → 새 버전: v{1}": "Current version: v{0} → new version: v{1}",
|
||||
|
|
@ -1011,6 +1024,7 @@
|
|||
"최신 버전 확인": "Check for updates",
|
||||
"최신 버전을 확인할 수 없습니다 ({0})": "Cannot check the latest version ({0})",
|
||||
"최신 버전입니다 (v{0})": "You have the latest version (v{0})",
|
||||
"최종 정리": "Final cleanup",
|
||||
"추가": "Add",
|
||||
"추가 ({0})": "Add ({0})",
|
||||
"추가 취소": "Cancel adding",
|
||||
|
|
|
|||
|
|
@ -164,6 +164,7 @@
|
|||
"rsync 를 항목마다 따로 실행하거나, 한 번에 실행 (robocopy 는 늘 폴더마다)": "rsync を項目ごとに実行するか、まとめて 1 回で実行 (robocopy は常にフォルダーごと)",
|
||||
"rsync 받는 중": "rsync をダウンロード中",
|
||||
"rsync 실행 파일": "rsync 実行ファイル",
|
||||
"rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > Rsync 에서 확인하세요.": "rsync 実行ファイルが見つかりません。環境設定 > Rsync を確認してください。",
|
||||
"rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > 파일 탐색기에서 경로를 확인하세요.": "rsync 実行ファイルが見つかりません。環境設定 > ファイルエクスプローラーでパスを確認してください。",
|
||||
"rsync 양쪽 (⇄)": "rsync 双方向 (⇄)",
|
||||
"rsync 옵션": "rsync オプション",
|
||||
|
|
@ -298,6 +299,7 @@
|
|||
"끝 시간이 시작보다 빠른 줄: {0}{1}": "終了時間が開始より早い行: {0}{1}",
|
||||
"끝: {0}": "終了: {0}",
|
||||
"끝나지 못했습니다: {0}": "完了できませんでした: {0}",
|
||||
"끝난 뒤 원본의 빈 폴더를 지웁니다 (find 원본/ -type d -empty -delete). 원본 폴더는:": "終了後、元の空フォルダーを削除します (find 元/ -type d -empty -delete)。元フォルダーは:",
|
||||
"나가기": "終了する",
|
||||
"나머지는 대기했다가 차례로 받습니다": "残りは待機して順番にダウンロードします",
|
||||
"나중에": "後で",
|
||||
|
|
@ -390,6 +392,7 @@
|
|||
"대상 디스크: 알 수 없음": "対象ディスク: 不明",
|
||||
"대상 폴더 열기": "対象フォルダーを開く",
|
||||
"대상 폴더가 없습니다: {0}": "対象フォルダーがありません: {0}",
|
||||
"대상으로 옮긴 파일을 원본에서 지웁니다 (이동)": "対象へ移したファイルを元から削除します (移動)",
|
||||
"더 보기": "その他",
|
||||
"더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)": "ダブルクリック・既定のアプリで JJ_MKVMaker を選んだとき(右クリックメニューはそのまま)",
|
||||
"더한 언어 (AI 자동 번역)": "追加した言語(AI 自動翻訳)",
|
||||
|
|
@ -546,6 +549,10 @@
|
|||
"비어 있는 폴더": "空のフォルダー",
|
||||
"비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "空のフォルダーです。\n上の[ブックマーク追加]・[新しいフォルダー]を押すか、ブックマークを左のフォルダーにドラッグしてください。",
|
||||
"비어 있음": "空",
|
||||
"빈 폴더 {0}개 지움": "空フォルダー {0} 個を削除",
|
||||
"빈 폴더 그대로": "空フォルダーはそのまま",
|
||||
"빈 폴더 지움 · 원본 폴더는 남김": "空フォルダーを削除 · 元フォルダーは残す",
|
||||
"빈 폴더 지움 · 원본 폴더도 지움": "空フォルダーを削除 · 元フォルダーも削除",
|
||||
"사용 안 함": "使用しない",
|
||||
"사용 안 함. \"Sign in to confirm you're not a bot\" 오류가 나오면 설정하세요": "使用しない。「Sign in to confirm you're not a bot」エラーが出たら設定してください",
|
||||
"사용: {0}": "使用: {0}",
|
||||
|
|
@ -730,12 +737,17 @@
|
|||
"원본 비율": "元の比率",
|
||||
"원본 유지 (재인코딩 없음)": "元のまま(再エンコードなし)",
|
||||
"원본 크기": "元のサイズ",
|
||||
"원본 파일 지우기 (--remove-source-files)": "元ファイルを削除 (--remove-source-files)",
|
||||
"원본 폴더": "元フォルダー",
|
||||
"원본 폴더가 바뀌면 곧바로 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안). 시작할 때 한 번 맞춥니다.": "元フォルダーが変わるとすぐ対象フォルダーに合わせます (アプリの起動中)。起動時にも 1 回合わせます。",
|
||||
"원본 폴더가 없습니다": "元フォルダーがありません",
|
||||
"원본과 대상이 서로 안에 있습니다": "元と対象が互いの中にあります",
|
||||
"원본에 없는 것을 대상에서 지우기": "元にないものを対象から削除",
|
||||
"원본의 {0}배": "元の {0}倍",
|
||||
"원본의 빈 폴더 정리 중…": "元の空フォルダーを整理中…",
|
||||
"원본의 파일을 모두 대상으로 옮기고 (원본에서 지움), 원본에 남은 빈 폴더를 지웁니다.": "元のファイルをすべて対象へ移し (元から削除)、元に残った空フォルダーを削除します。",
|
||||
"원본이 바뀌면 대상에 맞춥니다. 일정을 정하면 그 시간에만 맞추고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다.": "元が変わると対象に合わせます。予定を決めるとその時間だけ同期し、それ以外は変更を貯めて次の開始時刻に同期します。",
|
||||
"원본이 비므로 이 실시간 동기화는 끕니다. 진행은 [복사 · rsync] 탭에서 봅니다.": "元が空になるため、このリアルタイム同期はオフにします。進捗は [コピー · rsync] タブで確認します。",
|
||||
"원본이 없습니다 (다른 곳에서 지워졌거나 옮겨졌습니다): {0}": "元がありません (他の場所で削除または移動されました): {0}",
|
||||
"원어 (영상 속 언어)": "原語(動画内の言語)",
|
||||
"원어({0})와 같은 언어로는 번역하지 않습니다.": "原語({0})と同じ言語には翻訳しません。",
|
||||
|
|
@ -966,6 +978,7 @@
|
|||
"즐겨찾기 표시줄": "ブックマークバー",
|
||||
"지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "実行中のコピーは前のオプションで動いています。止めて新しいオプションで再実行しますか?",
|
||||
"지금 맞추기": "今すぐ同期",
|
||||
"지금 맞추는 중입니다. 끝난 뒤에 하세요.": "同期中です。終わってから行ってください。",
|
||||
"지금 보고 있는 동영상의 {0} (여러 개는 목록에서 체크)": "今見ている動画の{0}(複数はリストでチェック)",
|
||||
"지금 설치": "今すぐインストール",
|
||||
"지금 쓰는 버전: v{0} → 새 버전: v{1}": "現在のバージョン: v{0} → 新しいバージョン: v{1}",
|
||||
|
|
@ -1011,6 +1024,7 @@
|
|||
"최신 버전 확인": "最新バージョンを確認",
|
||||
"최신 버전을 확인할 수 없습니다 ({0})": "最新バージョンを確認できません({0})",
|
||||
"최신 버전입니다 (v{0})": "最新バージョンです(v{0})",
|
||||
"최종 정리": "最終整理",
|
||||
"추가": "追加",
|
||||
"추가 ({0})": "追加({0})",
|
||||
"추가 취소": "追加をキャンセル",
|
||||
|
|
|
|||
|
|
@ -164,6 +164,7 @@
|
|||
"rsync 를 항목마다 따로 실행하거나, 한 번에 실행 (robocopy 는 늘 폴더마다)": "rsync 按项目分别执行,或一次执行 (robocopy 总是按文件夹)",
|
||||
"rsync 받는 중": "正在下载 rsync",
|
||||
"rsync 실행 파일": "rsync 可执行文件",
|
||||
"rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > Rsync 에서 확인하세요.": "找不到 rsync 可执行文件。请检查 设置 > Rsync。",
|
||||
"rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > 파일 탐색기에서 경로를 확인하세요.": "找不到 rsync 可执行文件。请在 设置 > 文件浏览器 中检查路径。",
|
||||
"rsync 양쪽 (⇄)": "rsync 双向 (⇄)",
|
||||
"rsync 옵션": "rsync 选项",
|
||||
|
|
@ -298,6 +299,7 @@
|
|||
"끝 시간이 시작보다 빠른 줄: {0}{1}": "结束时间早于开始时间的行: {0}{1}",
|
||||
"끝: {0}": "结束: {0}",
|
||||
"끝나지 못했습니다: {0}": "未能完成: {0}",
|
||||
"끝난 뒤 원본의 빈 폴더를 지웁니다 (find 원본/ -type d -empty -delete). 원본 폴더는:": "完成后删除源中的空文件夹 (find 源/ -type d -empty -delete)。源文件夹:",
|
||||
"나가기": "退出",
|
||||
"나머지는 대기했다가 차례로 받습니다": "其余的等待并依次下载",
|
||||
"나중에": "以后再说",
|
||||
|
|
@ -390,6 +392,7 @@
|
|||
"대상 디스크: 알 수 없음": "目标磁盘: 未知",
|
||||
"대상 폴더 열기": "打开所在文件夹",
|
||||
"대상 폴더가 없습니다: {0}": "目标文件夹不存在: {0}",
|
||||
"대상으로 옮긴 파일을 원본에서 지웁니다 (이동)": "已传到目标的文件从源中删除 (移动)",
|
||||
"더 보기": "更多",
|
||||
"더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)": "双击 · 默认打开方式选择 JJ_MKVMaker 时(右键菜单不变)",
|
||||
"더한 언어 (AI 자동 번역)": "已添加的语言(AI 自动翻译)",
|
||||
|
|
@ -546,6 +549,10 @@
|
|||
"비어 있는 폴더": "空文件夹",
|
||||
"비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "这是空文件夹。\n请点击上方的[添加书签] · [新建文件夹],或将书签拖到左侧文件夹。",
|
||||
"비어 있음": "空",
|
||||
"빈 폴더 {0}개 지움": "已删除 {0} 个空文件夹",
|
||||
"빈 폴더 그대로": "保留空文件夹",
|
||||
"빈 폴더 지움 · 원본 폴더는 남김": "删除空文件夹 · 保留源文件夹",
|
||||
"빈 폴더 지움 · 원본 폴더도 지움": "删除空文件夹 · 源文件夹也删除",
|
||||
"사용 안 함": "不使用",
|
||||
"사용 안 함. \"Sign in to confirm you're not a bot\" 오류가 나오면 설정하세요": "不使用。如出现“Sign in to confirm you're not a bot”错误请设置",
|
||||
"사용: {0}": "使用: {0}",
|
||||
|
|
@ -730,12 +737,17 @@
|
|||
"원본 비율": "原始比例",
|
||||
"원본 유지 (재인코딩 없음)": "保持原样(不重新编码)",
|
||||
"원본 크기": "原始尺寸",
|
||||
"원본 파일 지우기 (--remove-source-files)": "删除源文件 (--remove-source-files)",
|
||||
"원본 폴더": "源文件夹",
|
||||
"원본 폴더가 바뀌면 곧바로 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안). 시작할 때 한 번 맞춥니다.": "源文件夹变化时立即同步到目标 (应用运行期间)。启动时也同步一次。",
|
||||
"원본 폴더가 없습니다": "源文件夹不存在",
|
||||
"원본과 대상이 서로 안에 있습니다": "源与目标互相包含",
|
||||
"원본에 없는 것을 대상에서 지우기": "从目标中删除源中没有的内容",
|
||||
"원본의 {0}배": "原始的 {0} 倍",
|
||||
"원본의 빈 폴더 정리 중…": "正在清理源中的空文件夹…",
|
||||
"원본의 파일을 모두 대상으로 옮기고 (원본에서 지움), 원본에 남은 빈 폴더를 지웁니다.": "将源中的所有文件移到目标 (从源中删除),然后删除源中剩下的空文件夹。",
|
||||
"원본이 바뀌면 대상에 맞춥니다. 일정을 정하면 그 시간에만 맞추고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다.": "源变化时同步到目标。设定计划后只在该时间同步,其他时间的变化会累积到下次开始时间再同步。",
|
||||
"원본이 비므로 이 실시간 동기화는 끕니다. 진행은 [복사 · rsync] 탭에서 봅니다.": "源将变空,因此关闭此实时同步。进度请在 [复制 · rsync] 标签中查看。",
|
||||
"원본이 없습니다 (다른 곳에서 지워졌거나 옮겨졌습니다): {0}": "源不存在 (已在别处删除或移动): {0}",
|
||||
"원어 (영상 속 언어)": "原语言(视频中的语言)",
|
||||
"원어({0})와 같은 언어로는 번역하지 않습니다.": "不会翻译成与原语言({0})相同的语言。",
|
||||
|
|
@ -966,6 +978,7 @@
|
|||
"즐겨찾기 표시줄": "书签栏",
|
||||
"지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "正在运行的复制使用的是之前的选项。停止并用新选项重新运行吗?",
|
||||
"지금 맞추기": "立即同步",
|
||||
"지금 맞추는 중입니다. 끝난 뒤에 하세요.": "正在同步。请等结束后再进行。",
|
||||
"지금 보고 있는 동영상의 {0} (여러 개는 목록에서 체크)": "当前查看视频的{0}(多个请在列表中勾选)",
|
||||
"지금 설치": "立即安装",
|
||||
"지금 쓰는 버전: v{0} → 새 버전: v{1}": "当前版本: v{0} → 新版本: v{1}",
|
||||
|
|
@ -1011,6 +1024,7 @@
|
|||
"최신 버전 확인": "检查最新版本",
|
||||
"최신 버전을 확인할 수 없습니다 ({0})": "无法检查最新版本({0})",
|
||||
"최신 버전입니다 (v{0})": "已是最新版本(v{0})",
|
||||
"최종 정리": "最终整理",
|
||||
"추가": "添加",
|
||||
"추가 ({0})": "添加({0})",
|
||||
"추가 취소": "取消添加",
|
||||
|
|
|
|||
|
|
@ -7,8 +7,10 @@ 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/app/settings.dart';
|
||||
import 'package:jj_mkvmaker/services/platform_services.dart';
|
||||
import 'package:jj_mkvmaker/ui/explorer_page.dart';
|
||||
import 'package:jj_mkvmaker/ui/monitor_page.dart';
|
||||
import 'package:jj_mkvmaker/ui/theme.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
|
|
@ -103,4 +105,96 @@ void main() {
|
|||
tester.view.reset();
|
||||
tmp.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
testWidgets('원본 파일 지우기 (--remove-source-files) · 빈 폴더 정리 · lsync 최종 정리', (tester) async {
|
||||
final out = Platform.environment['JJ_SHOT_DIR'] ?? Directory.systemTemp.path;
|
||||
final tmp = Directory.systemTemp.createTempSync('jj_rsyncmove_');
|
||||
final a = p.join(tmp.path, 'srcA'), b = p.join(tmp.path, 'dstB');
|
||||
final cDir = p.join(tmp.path, 'liveC'), d = p.join(tmp.path, 'liveD');
|
||||
for (final (dir, name) in [(a, 'x'), (cDir, 'y')]) {
|
||||
File(p.join(dir, 'sub', 'deep', '$name.txt'))
|
||||
..createSync(recursive: true)
|
||||
..writeAsStringSync(name);
|
||||
Directory(p.join(dir, 'empty')).createSync();
|
||||
}
|
||||
Directory(b).createSync();
|
||||
Directory(d).createSync();
|
||||
await tester.runAsync(findBundledRsync);
|
||||
final c = AppController(PlatformServices.create());
|
||||
c.settings
|
||||
..rsyncPaths = [tmp.path, tmp.path]
|
||||
..liveSyncPairs = [LiveSyncPair(cDir, d, method: 'rsync', delete: true)];
|
||||
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);
|
||||
}
|
||||
|
||||
// ── Rsync 화면: 좌 → 우 + 원본 파일 지우기 + 원본 폴더도 지움
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
theme: buildTheme(),
|
||||
builder: (_, child) => RepaintBoundary(key: key, child: child),
|
||||
home: ExplorerPage(c: c, rsync: true),
|
||||
));
|
||||
await wait(2500);
|
||||
await tap(find.text('srcA').first);
|
||||
await tap(find.text('dstB').last);
|
||||
await tap(find.text('좌 → 우'));
|
||||
await tap(find.text('원본 파일 지우기 (--remove-source-files)'));
|
||||
await tap(find.text('빈 폴더 지움 · 원본 폴더도 지움'));
|
||||
await shot('rsync_move_confirm');
|
||||
await tap(find.widgetWithText(FilledButton, '이동'));
|
||||
for (var i = 0; i < 100 && Directory(a).existsSync(); i++) {
|
||||
await wait(200);
|
||||
}
|
||||
await wait(1500);
|
||||
expect(File(p.join(b, 'sub', 'deep', 'x.txt')).readAsStringSync(), 'x');
|
||||
expect(Directory(p.join(b, 'empty')).existsSync(), isTrue); // 빈 폴더도 대상에는 만들어짐 (rsync -a)
|
||||
expect(Directory(a).existsSync(), isFalse, reason: '원본 폴더까지 지움');
|
||||
final moved = c.settings.copyTasks.single;
|
||||
expect([moved.move, moved.prune, moved.lastResult], [true, 'all', 'done']);
|
||||
expect(moved.lastMessage, '빈 폴더 4개 지움'); // sub/deep · sub · empty · srcA
|
||||
|
||||
// ── lsync 최종 정리: 원본 폴더는 남김 (기본)
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
theme: buildTheme(),
|
||||
builder: (_, child) => RepaintBoundary(key: key, child: child),
|
||||
home: MonitorPage(c: c, initialTab: 1),
|
||||
));
|
||||
await wait(1500);
|
||||
await tap(find.text('최종 정리').first);
|
||||
await shot('lsync_finalize_confirm');
|
||||
expect(find.textContaining('rsync -avHPOg --remove-source-files'), findsOneWidget);
|
||||
await tap(find.widgetWithText(FilledButton, '최종 정리'));
|
||||
for (var i = 0; i < 100 && File(p.join(cDir, 'sub', 'deep', 'y.txt')).existsSync(); i++) {
|
||||
await wait(200);
|
||||
}
|
||||
await wait(2500);
|
||||
await shot('lsync_finalize_done');
|
||||
expect(File(p.join(d, 'sub', 'deep', 'y.txt')).readAsStringSync(), 'y');
|
||||
expect(Directory(cDir).existsSync(), isTrue, reason: '원본 폴더는 남김');
|
||||
expect(Directory(cDir).listSync(), isEmpty, reason: '빈 폴더는 모두 지움');
|
||||
expect(c.settings.liveSyncPairs.single.enabled, isFalse, reason: '원본이 비었으므로 실시간 동기화는 끔');
|
||||
final fin = c.settings.copyTasks.firstWhere((t) => t.sources.single == cDir);
|
||||
expect([fin.options, fin.move, fin.prune, fin.lastResult], ['-avHPOg', true, 'keep', 'done']);
|
||||
expect(fin.lastMessage, '빈 폴더 3개 지움'); // sub/deep · sub · empty (liveC 는 남김)
|
||||
tester.view.reset();
|
||||
tmp.deleteSync(recursive: true);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@ class CopyCenter extends ChangeNotifier {
|
|||
bandwidthKBps: t.bandwidthKBps,
|
||||
rsyncExe: exe,
|
||||
contents: t.contents,
|
||||
prune: t.move ? t.prune : '',
|
||||
);
|
||||
jobs[t.id] = job;
|
||||
job.addListener(notifyListeners);
|
||||
|
|
@ -122,7 +123,7 @@ class CopyCenter extends ChangeNotifier {
|
|||
await update(cur.copyWith(
|
||||
lastRun: DateTime.now().toIso8601String(),
|
||||
lastResult: e == null ? 'done' : e is FileOpCancelled ? 'cancelled' : 'failed',
|
||||
lastMessage: e == null ? '' : '$e',
|
||||
lastMessage: e == null ? (job.pruned > 0 ? trf('빈 폴더 {0}개 지움', [job.pruned]) : '') : '$e',
|
||||
lastFiles: job.allDone,
|
||||
));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,6 +87,10 @@ class CopyTask {
|
|||
final String lastMessage;
|
||||
final int lastFiles;
|
||||
|
||||
/// 이동 ([move], rsync --remove-source-files) 뒤 원본 정리: '' 그대로 · 'keep' 빈 폴더를 지우고 원본 폴더는 남김 ·
|
||||
/// 'all' 원본 폴더까지 (비었으면) 지움 (find 원본/ -type d -empty -delete)
|
||||
final String prune;
|
||||
|
||||
const CopyTask({
|
||||
required this.id,
|
||||
required this.sources,
|
||||
|
|
@ -101,6 +105,7 @@ class CopyTask {
|
|||
this.lastResult = '',
|
||||
this.lastMessage = '',
|
||||
this.lastFiles = 0,
|
||||
this.prune = '',
|
||||
});
|
||||
|
||||
/// 같은 복사인지 (원본들 · 대상 · 이동)
|
||||
|
|
@ -116,6 +121,7 @@ class CopyTask {
|
|||
String? lastResult,
|
||||
String? lastMessage,
|
||||
int? lastFiles,
|
||||
String? prune,
|
||||
}) =>
|
||||
CopyTask(
|
||||
id: id,
|
||||
|
|
@ -131,6 +137,7 @@ class CopyTask {
|
|||
lastResult: lastResult ?? this.lastResult,
|
||||
lastMessage: lastMessage ?? this.lastMessage,
|
||||
lastFiles: lastFiles ?? this.lastFiles,
|
||||
prune: prune ?? this.prune,
|
||||
);
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
|
|
@ -147,6 +154,7 @@ class CopyTask {
|
|||
'lastResult': lastResult,
|
||||
'lastMessage': lastMessage,
|
||||
'lastFiles': lastFiles,
|
||||
'prune': prune,
|
||||
};
|
||||
|
||||
factory CopyTask.fromJson(Map<Object?, Object?> j) => CopyTask(
|
||||
|
|
@ -163,6 +171,7 @@ class CopyTask {
|
|||
lastResult: j['lastResult'] as String? ?? '',
|
||||
lastMessage: j['lastMessage'] as String? ?? '',
|
||||
lastFiles: (j['lastFiles'] as num?)?.toInt() ?? 0,
|
||||
prune: const ['keep', 'all'].contains(j['prune']) ? j['prune'] as String : '',
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ class TransferJob extends ChangeNotifier {
|
|||
/// 폴더 "안의 것" 을 [dest] 에 맞추기 (rsync 원본/ → 대상/, 현재 방식은 바뀐 것만). lsync 에서 옮겨 온 복사.
|
||||
final bool contents;
|
||||
|
||||
/// 이동 뒤 원본 정리: '' 안 함 · 'keep' 빈 폴더 지움 (원본 폴더는 남김) · 'all' 원본 폴더까지
|
||||
final String prune;
|
||||
|
||||
TransferJob({
|
||||
required this.sources,
|
||||
required this.dest,
|
||||
|
|
@ -40,8 +43,13 @@ class TransferJob extends ChangeNotifier {
|
|||
this.bandwidthKBps = 0,
|
||||
this.rsyncExe,
|
||||
this.contents = false,
|
||||
this.prune = '',
|
||||
});
|
||||
|
||||
/// 원본 정리 중 · 지운 빈 폴더 수
|
||||
bool pruning = false;
|
||||
int pruned = 0;
|
||||
|
||||
// ── 진행 상태 ──
|
||||
int index = 0; // 지금 항목
|
||||
int get total => sources.length;
|
||||
|
|
@ -124,6 +132,14 @@ class TransferJob extends ChangeNotifier {
|
|||
await _runRobocopy();
|
||||
}
|
||||
if (cancelled) throw const FileOpCancelled();
|
||||
// 옮긴 뒤 원본에 남은 빈 폴더 정리 (find 원본/ -type d -empty -delete)
|
||||
if (move && prune.isNotEmpty) {
|
||||
pruning = true;
|
||||
_tick();
|
||||
for (final s in sources) {
|
||||
if (FileSystemEntity.isDirectorySync(s)) pruned += await removeEmptyDirs(s, keepRoot: prune == 'keep');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
error = cancelled ? const FileOpCancelled() : e;
|
||||
} finally {
|
||||
|
|
@ -213,8 +229,9 @@ class TransferJob extends ChangeNotifier {
|
|||
for (final i in idx) {
|
||||
filesDone[i] = filesTotal[i];
|
||||
made.add(p.join(dest, p.basename(sources[i])));
|
||||
// 이동: rsync 는 파일만 지우므로 빈 폴더를 정리
|
||||
if (move && FileSystemEntity.isDirectorySync(sources[i]) && await countFiles(sources[i]) == 0) {
|
||||
// 폴더째 이동 (안의 것 [contents] 이 아니고 원본 정리 [prune] 를 고르지 않음): rsync 는 파일만 지우므로
|
||||
// 비어 남은 원본 폴더를 지운다. 원본 정리를 고른 이동은 끝에서 그 고른 대로 ([prune]).
|
||||
if (move && !contents && prune.isEmpty && FileSystemEntity.isDirectorySync(sources[i]) && await countFiles(sources[i]) == 0) {
|
||||
await Directory(sources[i]).delete(recursive: true);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,6 +106,39 @@ String? transferProblem(List<String> sources, String dest, {required bool move})
|
|||
return null;
|
||||
}
|
||||
|
||||
/// [root] 안의 빈 폴더를 안쪽부터 지운다 (find root/ -type d -empty -delete 와 같음). 지운 폴더 수.
|
||||
/// [keepRoot] 면 [root] 자신은 비어도 남긴다. 파일 · 링크가 하나라도 있는 폴더는 그대로.
|
||||
Future<int> removeEmptyDirs(String root, {bool keepRoot = true}) async {
|
||||
var n = 0;
|
||||
Future<bool> walk(Directory d) async {
|
||||
var empty = true;
|
||||
final items = await d.list(followLinks: false).handleError((_) => empty = false).toList();
|
||||
for (final e in items) {
|
||||
if (e is Directory && await walk(e)) {
|
||||
try {
|
||||
await e.delete();
|
||||
n++;
|
||||
} catch (_) {
|
||||
empty = false;
|
||||
}
|
||||
} else {
|
||||
empty = false;
|
||||
}
|
||||
}
|
||||
return empty;
|
||||
}
|
||||
|
||||
final d = Directory(root);
|
||||
if (!await d.exists()) return 0;
|
||||
if (await walk(d) && !keepRoot) {
|
||||
try {
|
||||
await d.delete();
|
||||
n++;
|
||||
} catch (_) {}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/// 복사 · 이동 중 알림 (지금 파일, 지금까지 바이트, 전체 바이트)
|
||||
typedef CopyProgress = void Function(String current, int done, int total);
|
||||
|
||||
|
|
|
|||
|
|
@ -953,10 +953,13 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
// 확인 창에는 기억한 옵션 (없으면 지금 설정) 을 보여 주고, 실행할 때 모니터링에 기억한다
|
||||
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;
|
||||
// 한 방향 (→ · ←) 만: 원본 파일 지우기 (--remove-source-files) 와 끝난 뒤 원본 정리
|
||||
var removeSource = false;
|
||||
var prune = 'keep';
|
||||
if (!mounted) return;
|
||||
final go = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
builder: (ctx) => StatefulBuilder(builder: (ctx, set) => AlertDialog(
|
||||
title: Text(both ? tr('rsync 양쪽 (⇄)') : 'rsync'),
|
||||
content: SizedBox(
|
||||
width: 560,
|
||||
|
|
@ -964,7 +967,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
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])}' : ''}',
|
||||
Text('rsync ${opts(t)}${removeSource ? ' --remove-source-files' : ''}'
|
||||
'${t.bandwidthKBps > 0 ? ' · ${trf('속도 제한 {0} KB/s', [t.bandwidthKBps])}' : ''}',
|
||||
style: const TextStyle(fontSize: 12, fontFamily: 'monospace', color: JjColors.textDim)),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
|
|
@ -972,19 +976,42 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||
if (both)
|
||||
Text(tr('양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).'),
|
||||
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||
style: const TextStyle(fontSize: 12, color: JjColors.textDim))
|
||||
else ...[
|
||||
CheckboxListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: removeSource,
|
||||
onChanged: (v) => set(() => removeSource = v ?? false),
|
||||
title: Text(tr('원본 파일 지우기 (--remove-source-files)')),
|
||||
subtitle: Text(tr('대상으로 옮긴 파일을 원본에서 지웁니다 (이동)')),
|
||||
),
|
||||
if (removeSource) ...[
|
||||
Text(tr('끝난 뒤 원본의 빈 폴더를 지웁니다 (find 원본/ -type d -empty -delete). 원본 폴더는:'),
|
||||
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||
const SizedBox(height: 6),
|
||||
PruneChoice(value: prune, onChanged: (v) => set(() => prune = v)),
|
||||
],
|
||||
],
|
||||
]),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('실행'))),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(removeSource ? tr('이동') : 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')];
|
||||
tasks = [
|
||||
for (final (src, dst) in runs) await center.remember([src], dst, contents: true, method: 'rsync', move: removeSource),
|
||||
];
|
||||
if (removeSource) {
|
||||
tasks = [for (final t in tasks) t.copyWith(prune: prune)];
|
||||
for (final t in tasks) {
|
||||
await center.update(t);
|
||||
}
|
||||
}
|
||||
final jobs = <TransferJob>[];
|
||||
for (final t in tasks) {
|
||||
final j = await center.start(t.copyWith(options: opts(t)), rsyncExe: exe);
|
||||
|
|
@ -994,7 +1021,11 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
setState(() => _jobs = jobs);
|
||||
await Future.wait(jobs.map((j) => j.done));
|
||||
if (!mounted) return;
|
||||
await _refreshAll([l, r]);
|
||||
// 원본 폴더까지 지웠으면 고른 표시도 뺀다
|
||||
for (final x in _panes) {
|
||||
x.marked.removeWhere((m) => FileSystemEntity.typeSync(m) == FileSystemEntityType.notFound);
|
||||
}
|
||||
await _refreshAll([l, r, p.dirname(l), p.dirname(r)]);
|
||||
for (final x in _panes) {
|
||||
x.changed();
|
||||
}
|
||||
|
|
@ -1091,7 +1122,11 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
'${pct(job.overall)} · ${trf('파일 {0}/{1}', [job.allDone, job.allFiles])}',
|
||||
),
|
||||
line(
|
||||
job.counting ? tr('파일 세는 중…') : trf('{0} 안의 파일', [job.currentName]),
|
||||
job.counting
|
||||
? tr('파일 세는 중…')
|
||||
: job.pruning
|
||||
? (job.finished ? trf('빈 폴더 {0}개 지움', [job.pruned]) : tr('원본의 빈 폴더 정리 중…'))
|
||||
: trf('{0} 안의 파일', [job.currentName]),
|
||||
job.current,
|
||||
'${pct(job.current)} · ${trf('파일 {0}/{1}', [curDone, curFiles])}',
|
||||
),
|
||||
|
|
|
|||
|
|
@ -144,7 +144,13 @@ class TransferProgressLines extends StatelessWidget {
|
|||
return Column(children: [
|
||||
line(trf('전체 {0}개 중 {1}번째', [job.total, cur + 1]), job.overall,
|
||||
'${pct(job.overall)} · ${trf('파일 {0}/{1}', [job.allDone, job.allFiles])}'),
|
||||
line(job.counting ? tr('파일 세는 중…') : trf('{0} 안의 파일', [p.basename(job.sources[cur])]), job.current,
|
||||
line(
|
||||
job.counting
|
||||
? tr('파일 세는 중…')
|
||||
: job.pruning
|
||||
? (job.finished ? trf('빈 폴더 {0}개 지움', [job.pruned]) : tr('원본의 빈 폴더 정리 중…'))
|
||||
: trf('{0} 안의 파일', [p.basename(job.sources[cur])]),
|
||||
job.current,
|
||||
'${pct(job.current)} · ${trf('파일 {0}/{1}', [job.filesDone[cur], job.filesTotal[cur]])}'),
|
||||
]);
|
||||
},
|
||||
|
|
@ -179,6 +185,27 @@ class _CopyTab extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
/// 이동 (rsync --remove-source-files) 뒤 원본 정리 이름
|
||||
String pruneLabel(String v) => switch (v) {
|
||||
'keep' => tr('빈 폴더 지움 · 원본 폴더는 남김'),
|
||||
'all' => tr('빈 폴더 지움 · 원본 폴더도 지움'),
|
||||
_ => tr('빈 폴더 그대로'),
|
||||
};
|
||||
|
||||
/// 원본 정리 고르기: 원본 폴더를 남길지 · 지울지 (빈 폴더는 지움 = find 원본/ -type d -empty -delete)
|
||||
class PruneChoice extends StatelessWidget {
|
||||
final String value;
|
||||
final ValueChanged<String> onChanged;
|
||||
final bool allowNone;
|
||||
const PruneChoice({super.key, required this.value, required this.onChanged, this.allowNone = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Wrap(spacing: 6, runSpacing: 6, children: [
|
||||
for (final v in [if (allowNone) '', 'keep', 'all'])
|
||||
ChoiceChip(label: Text(pruneLabel(v)), selected: value == v, onSelected: (_) => onChanged(v)),
|
||||
]);
|
||||
}
|
||||
|
||||
class _CopyCard extends StatefulWidget {
|
||||
final AppController c;
|
||||
final CopyTask task;
|
||||
|
|
@ -194,8 +221,10 @@ class _CopyCardState extends State<_CopyCard> {
|
|||
late final _options = TextEditingController(text: widget.task.options);
|
||||
late final _bw = TextEditingController(text: '${widget.task.bandwidthKBps}');
|
||||
late bool _once = widget.task.once;
|
||||
late String _prune = widget.task.prune;
|
||||
|
||||
bool get _dirty =>
|
||||
_prune != widget.task.prune ||
|
||||
_method != widget.task.method ||
|
||||
_options.text.trim() != widget.task.options ||
|
||||
(int.tryParse(_bw.text) ?? 0) != widget.task.bandwidthKBps ||
|
||||
|
|
@ -234,6 +263,7 @@ class _CopyCardState extends State<_CopyCard> {
|
|||
options: _options.text.trim(),
|
||||
bandwidthKBps: int.tryParse(_bw.text) ?? 0,
|
||||
once: _once,
|
||||
prune: _prune,
|
||||
);
|
||||
await center.update(t);
|
||||
if (!mounted) return;
|
||||
|
|
@ -366,6 +396,13 @@ class _CopyCardState extends State<_CopyCard> {
|
|||
),
|
||||
if (t.sources.length > 1 && _method == 'rsync')
|
||||
FilterChip(label: Text(tr('한 번에')), selected: _once, onSelected: (v) => setState(() => _once = v)),
|
||||
// 이동 (원본 파일 지우기) 이면: 끝난 뒤 원본의 빈 폴더 · 원본 폴더 정리
|
||||
if (t.move && _method == 'rsync')
|
||||
DropdownButton<String>(
|
||||
value: _prune,
|
||||
items: [for (final v in ['', 'keep', 'all']) DropdownMenuItem(value: v, child: Text(pruneLabel(v)))],
|
||||
onChanged: (v) => setState(() => _prune = v!),
|
||||
),
|
||||
if (_dirty) FilledButton.tonal(onPressed: _save, child: Text(tr('저장'))),
|
||||
]),
|
||||
),
|
||||
|
|
@ -427,6 +464,71 @@ class _LiveTabState extends State<_LiveTab> {
|
|||
for (final y in x.liveSyncPairs) y.source == old.source && y.target == old.target ? now : y,
|
||||
]);
|
||||
|
||||
/// 최종 정리: 원본의 것을 모두 대상으로 옮기고 (rsync -avHPOg --remove-source-files 원본/ 대상/)
|
||||
/// 빈 폴더를 지운다 (find 원본/ -type d -empty -delete). 원본 폴더를 남길지 · 지울지만 고른다.
|
||||
/// 원본이 비므로 이 실시간 동기화는 끈다 (켜 둔 채 "지우기 포함" 이면 대상까지 비워 버린다).
|
||||
Future<void> _finalize(LiveSyncPair x) async {
|
||||
final live = LiveSync.instance;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
if (live?.isRunning(x) ?? false) {
|
||||
messenger.showSnackBar(SnackBar(content: Text(tr('지금 맞추는 중입니다. 끝난 뒤에 하세요.'))));
|
||||
return;
|
||||
}
|
||||
var prune = 'keep';
|
||||
final go = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, set) => AlertDialog(
|
||||
title: Text(tr('최종 정리')),
|
||||
content: SizedBox(
|
||||
width: 600,
|
||||
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(tr('원본의 파일을 모두 대상으로 옮기고 (원본에서 지움), 원본에 남은 빈 폴더를 지웁니다.')),
|
||||
const SizedBox(height: 10),
|
||||
SelectableText(
|
||||
'rsync -avHPOg --remove-source-files ${x.source}/ ${x.target}/\n'
|
||||
'find ${x.source}/ -type d -empty -delete',
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 12, color: JjColors.textDim)),
|
||||
const SizedBox(height: 14),
|
||||
Text(tr('원본 폴더'), style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 6),
|
||||
PruneChoice(value: prune, onChanged: (v) => set(() => prune = v)),
|
||||
const SizedBox(height: 12),
|
||||
Text(tr('원본이 비므로 이 실시간 동기화는 끕니다. 진행은 [복사 · rsync] 탭에서 봅니다.'),
|
||||
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)),
|
||||
]),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('최종 정리'))),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (go != true || !mounted) return;
|
||||
var exe = await rsyncExecutable(c.settings);
|
||||
if (exe == null && Platform.isWindows && c.settings.rsyncSource == 'download' && mounted) {
|
||||
exe = await installRsyncWithDialog(context);
|
||||
}
|
||||
if (exe == null) {
|
||||
messenger.showSnackBar(SnackBar(content: Text(tr('rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > Rsync 에서 확인하세요.'))));
|
||||
return;
|
||||
}
|
||||
await _replace(x, x.copyWith(enabled: false));
|
||||
final center = CopyCenter.of(c);
|
||||
var t = await center.remember([x.source], x.target, move: true, contents: true, method: 'rsync');
|
||||
// 처음이면 정해진 옵션 (모니터링에서 고친 것이 있으면 그것)
|
||||
t = t.copyWith(prune: prune, options: t.lastRun.isEmpty ? '-avHPOg' : null);
|
||||
await center.update(t);
|
||||
final job = await center.start(t, rsyncExe: exe);
|
||||
if (!mounted) return;
|
||||
if (job == null) {
|
||||
messenger.showSnackBar(SnackBar(content: Text(center.tasks.firstWhere((y) => y.id == t.id).lastMessage)));
|
||||
return;
|
||||
}
|
||||
DefaultTabController.of(context).animateTo(0);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final live = LiveSync.instance;
|
||||
|
|
@ -591,6 +693,11 @@ class _LiveTabState extends State<_LiveTab> {
|
|||
label: Text(tr('지금 맞추기')),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
TextButton.icon(
|
||||
onPressed: running ? null : () => _finalize(x),
|
||||
icon: const Icon(Icons.cleaning_services_outlined, size: 18),
|
||||
label: Text(tr('최종 정리')),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () async {
|
||||
await CopyCenter.of(c).fromLiveSync(x);
|
||||
|
|
|
|||
|
|
@ -333,12 +333,20 @@ void main() {
|
|||
await tester.tap(find.text('취소'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(c.settings.copyTasks, isEmpty);
|
||||
// → 는 한 방향 · -u 없이
|
||||
expect(find.text('원본 파일 지우기 (--remove-source-files)'), findsNothing); // 양쪽에는 없음
|
||||
// → 는 한 방향 · -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);
|
||||
expect(find.text('빈 폴더 지움 · 원본 폴더는 남김'), findsNothing);
|
||||
await tester.tap(find.text('원본 파일 지우기 (--remove-source-files)'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining('-avPog --remove-source-files'), findsOneWidget);
|
||||
expect(find.text('빈 폴더 지움 · 원본 폴더는 남김'), findsOneWidget);
|
||||
expect(find.text('빈 폴더 지움 · 원본 폴더도 지움'), findsOneWidget);
|
||||
expect(find.widgetWithText(FilledButton, '이동'), findsOneWidget);
|
||||
await tester.tap(find.text('취소'));
|
||||
await tester.pumpAndSettle();
|
||||
// 고른 폴더는 기억 (다음에 열 때 그 폴더부터)
|
||||
|
|
|
|||
|
|
@ -76,6 +76,22 @@ void main() {
|
|||
expect(b.existsSync(), isFalse);
|
||||
});
|
||||
|
||||
test('빈 폴더 지우기 (find -type d -empty -delete): 안쪽부터 · 파일 있는 폴더는 그대로 · 원본 폴더 남김 / 지움', () async {
|
||||
final root = p.join(tmp.path, 'src');
|
||||
Directory(p.join(root, 'a', 'b', 'c')).createSync(recursive: true);
|
||||
Directory(p.join(root, 'empty')).createSync();
|
||||
file('src/keep/x.txt');
|
||||
expect(await removeEmptyDirs(root), 4); // a/b/c, a/b, a, empty
|
||||
expect(Directory(p.join(root, 'keep')).existsSync(), isTrue);
|
||||
expect(Directory(p.join(root, 'a')).existsSync(), isFalse);
|
||||
File(p.join(root, 'keep', 'x.txt')).deleteSync();
|
||||
expect(await removeEmptyDirs(root), 1); // keep (원본 폴더는 남김)
|
||||
expect(Directory(root).existsSync(), isTrue);
|
||||
expect(await removeEmptyDirs(root, keepRoot: false), 1); // 원본 폴더도
|
||||
expect(Directory(root).existsSync(), isFalse);
|
||||
expect(await removeEmptyDirs(root), 0); // 없으면 아무것도
|
||||
});
|
||||
|
||||
test('시작 전 확인: 없는 원본 · 없는 대상 · 자기 안으로 · 같은 폴더로 이동', () {
|
||||
final a = Directory(p.join(tmp.path, 'a'))..createSync();
|
||||
final inner = Directory(p.join(a.path, 'inner'))..createSync();
|
||||
|
|
|
|||
|
|
@ -102,6 +102,14 @@ void main() {
|
|||
expect(Directory(p.join(dst, 'src', 'src')).existsSync(), isFalse);
|
||||
});
|
||||
|
||||
test('이동 뒤 원본 정리 (prune) 저장 · 읽기', () {
|
||||
final t = CopyTask(id: '1', sources: const ['/a'], dest: '/b', move: true, contents: true, method: 'rsync', prune: 'all');
|
||||
final back = CopyTask.fromJson(t.toJson());
|
||||
expect([back.move, back.prune], [true, 'all']);
|
||||
expect(CopyTask.fromJson({'id': '2', 'dest': '/b', 'prune': 'x'}).prune, '');
|
||||
expect(t.copyWith(prune: 'keep').prune, 'keep');
|
||||
});
|
||||
|
||||
test('기억한 복사의 원본이 없어졌거나 자기 안으로면 시작하지 않고 실패로 남긴다', () async {
|
||||
c.settings.copyMethodFolder = 'rsync';
|
||||
final center = CopyCenter(c);
|
||||
|
|
|
|||
Loading…
Reference in New Issue