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
RyuJongJin 2026-10-05 21:47:20 +09:00
parent c273297b11
commit 1ac48354af
13 changed files with 383 additions and 13 deletions

View File

@ -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",

View File

@ -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})",
"추가 취소": "追加をキャンセル",

View File

@ -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})",
"추가 취소": "取消添加",

View File

@ -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);
});
}

View File

@ -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,
));
}

View File

@ -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 : '',
);
}

View File

@ -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);
}
}

View File

@ -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);

View File

@ -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])}',
),

View File

@ -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);

View File

@ -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();
// 고른 폴더는 기억 (다음에 열 때 그 폴더부터)

View File

@ -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();

View File

@ -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);