diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 07599d9..f9c0f57 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -474,6 +474,7 @@ "{0}: 체크한 동영상 {1}개를 {2} 로 옮깁니다": "{0}: moves the {1} checked videos to {2}", "{0}개": "{0}", "{0}개 · {1}": "{0} · {1}", + "{0}개 · {1} …": "{0} items · {1} …", "{0}개 · 주소 {1}개": "{0} · {1} links", "{0}개 더 보기": "Show {0} more", "{0}개 맞춤": "{0} synced", @@ -488,6 +489,7 @@ "{0}개 풀기": "Extract {0}", "{0}개 항목": "{0} items", "{0}개 항목을 {1}": "{0} items {1}", + "{0}개 항목을 놓았습니다": "Dropped {0} items", "{0}개 항목을 다음 폴더로 {1}\n{2}": "{0} items to this folder — {1}\n{2}", "{0}개 항목을 다음 폴더로 복사할까요?\n{1}": "Copy {0} items to this folder?\n{1}", "{0}개 항목을 다음 폴더로 옮길까요?\n{1}": "Move {0} items to this folder?\n{1}", @@ -504,6 +506,7 @@ "{0}개를 영구 삭제했습니다.": "Permanently deleted {0} item(s).", "{0}개를 옮겼습니다 → {1}": "Moved {0} → {1}", "{0}개를 옮겼습니다 → {1} ({2})": "Moved {0} → {1} ({2})", + "{0}개를 옮겼습니다.": "Moved {0} items.", "{0}개를 찾아 여기서 멈췄습니다. 이름을 더 자세히 넣거나 아래 폴더로 들어가 찾으세요.": "Stopped after finding {0}. Enter a more specific name or search inside a subfolder.", "{0}개를 풀었습니다: {1}": "Extracted {0}: {1}", "{0}개에서 멈췄습니다": "Stopped at {0}", @@ -712,7 +715,9 @@ "끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). Chrome 엔진은 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "When off, page scripts do not run (fewer ads · trackers, but script-built sites such as YouTube will not show). With the Chrome engine, page translation · video detection also stop.", "끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "When off, page scripts do not run (fewer ads · trackers, but script-built sites such as YouTube will not show). Page translation · video detection also stop.", "끄면 환경 설정 · 다운로드 목록 · 다른 화면을 보는 동안에도 웹 페이지의 소리가 계속 들립니다": "When off, the web page keeps playing sound while you look at settings, the download list or other screens", + "끌어 놓기 기본": "Drag and drop default", "끌어다 놓은 항목에 동영상이 없습니다.": "There are no videos in the dropped items.", + "끌어서 다른 창으로": "Drag to the other pane", "끝": "End", "끝 (오른쪽 · 아래)": "Edge (right · bottom)", "끝 = 현재": "End = now", @@ -810,6 +815,7 @@ "다른 저장소로는 이름만 바꿀 수 없습니다": "Cannot just rename onto another storage", "다른 창 폴더로": "To the other pane's folder", "다른 창에서 열기": "Open in other pane", + "다른 창으로 끌어 놓았을 때. 어느 쪽이든 복사 · 이동 확인 창 (같은 이름 등) 은 그대로 뜹니다": "When you drop onto the other pane. Either way, the copy/move confirmation (same names etc.) still appears", "다른 창으로 복사": "Copy to other pane", "다른 창으로 이동": "Move to other pane", "다른 폴더로 이동": "Move to another folder", @@ -1074,6 +1080,7 @@ "문자셋 경고": "Charset warning", "문제 {0}개 - 고치기 전에는 바꾸지 않습니다": "{0} problems - nothing is renamed until they're fixed", "묻기": "Ask", + "묻기 (기본)": "Ask (default)", "물어보지 않기": "Never ask", "미리 보기": "Quick look", "미리 볼 수 없습니다: {0}": "Cannot preview: {0}", @@ -2353,6 +2360,7 @@ "한국어": "Korean", "한글": "Hangul", "할 일": "Task", + "합친 폴더 {0}개는 무엇을 옮겼는지 몰라 되돌리지 않습니다.": "{0} merged folders can't be undone (what was moved isn't known).", "항목 {0}/{1}": "Items {0}/{1}", "항목마다 따로 (기본)": "Each item separately (default)", "해상도 올리기 메모리": "Upscaling memory", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 97f21c4..826c568 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -474,6 +474,7 @@ "{0}: 체크한 동영상 {1}개를 {2} 로 옮깁니다": "{0}: チェックした動画 {1}個を {2} に移動します", "{0}개": "{0}個", "{0}개 · {1}": "{0}個・{1}", + "{0}개 · {1} …": "{0} 件 · {1} …", "{0}개 · 주소 {1}개": "{0}個・URL {1}個", "{0}개 더 보기": "さらに {0} 件表示", "{0}개 맞춤": "{0} 件を同期", @@ -488,6 +489,7 @@ "{0}개 풀기": "{0} 個を展開", "{0}개 항목": "{0} 項目", "{0}개 항목을 {1}": "{0} 件の項目を{1}", + "{0}개 항목을 놓았습니다": "{0} 件の項目をドロップしました", "{0}개 항목을 다음 폴더로 {1}\n{2}": "{0} 件の項目を次のフォルダーへ{1}\n{2}", "{0}개 항목을 다음 폴더로 복사할까요?\n{1}": "{0} 件の項目を次のフォルダーへコピーしますか?\n{1}", "{0}개 항목을 다음 폴더로 옮길까요?\n{1}": "{0} 件の項目を次のフォルダーへ移動しますか?\n{1}", @@ -504,6 +506,7 @@ "{0}개를 영구 삭제했습니다.": "{0} 件を完全に削除しました。", "{0}개를 옮겼습니다 → {1}": "{0}個を移動しました → {1}", "{0}개를 옮겼습니다 → {1} ({2})": "{0}個を移動しました → {1}({2})", + "{0}개를 옮겼습니다.": "{0} 件を移動しました。", "{0}개를 찾아 여기서 멈췄습니다. 이름을 더 자세히 넣거나 아래 폴더로 들어가 찾으세요.": "{0} 件見つけたところで止めました。名前をより詳しく入力するか、下のフォルダーに入って探してください。", "{0}개를 풀었습니다: {1}": "{0} 個を展開しました: {1}", "{0}개에서 멈췄습니다": "{0} 件で停止しました", @@ -712,7 +715,9 @@ "끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). Chrome 엔진은 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "オフにするとページのスクリプトを実行しません (広告 · 追跡は減りますが、YouTube のようにスクリプトで作られたサイトは表示されません)。Chrome エンジンではページ翻訳 · 動画の検出も止まります。", "끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "オフにするとページのスクリプトを実行しません (広告 · 追跡は減りますが、YouTube のようにスクリプトで作られたサイトは表示されません)。ページ翻訳 · 動画の検出も止まります。", "끄면 환경 설정 · 다운로드 목록 · 다른 화면을 보는 동안에도 웹 페이지의 소리가 계속 들립니다": "オフにすると、環境設定・ダウンロード一覧・他の画面を見ている間も Web ページの音が流れ続けます", + "끌어 놓기 기본": "ドラッグ&ドロップの既定", "끌어다 놓은 항목에 동영상이 없습니다.": "ドロップした項目に動画がありません。", + "끌어서 다른 창으로": "ドラッグしてもう一方のウィンドウへ", "끝": "終了", "끝 (오른쪽 · 아래)": "端 (右 · 下)", "끝 = 현재": "終了 = 現在", @@ -810,6 +815,7 @@ "다른 저장소로는 이름만 바꿀 수 없습니다": "別のストレージへは名前の変更だけではできません", "다른 창 폴더로": "もう一方の画面のフォルダーへ", "다른 창에서 열기": "もう一方のペインで開く", + "다른 창으로 끌어 놓았을 때. 어느 쪽이든 복사 · 이동 확인 창 (같은 이름 등) 은 그대로 뜹니다": "もう一方のウィンドウにドロップしたとき。どちらでもコピー・移動の確認 (同じ名前など) は表示されます", "다른 창으로 복사": "もう一方のペインへコピー", "다른 창으로 이동": "もう一方のペインへ移動", "다른 폴더로 이동": "他のフォルダーに移動", @@ -1074,6 +1080,7 @@ "문자셋 경고": "文字コードの警告", "문제 {0}개 - 고치기 전에는 바꾸지 않습니다": "問題 {0} 件 - 直すまで変更しません", "묻기": "確認する", + "묻기 (기본)": "確認する (既定)", "물어보지 않기": "尋ねない", "미리 보기": "クイックプレビュー", "미리 볼 수 없습니다: {0}": "プレビューできません: {0}", @@ -2353,6 +2360,7 @@ "한국어": "韓国語", "한글": "ハングル", "할 일": "内容", + "합친 폴더 {0}개는 무엇을 옮겼는지 몰라 되돌리지 않습니다.": "結合したフォルダー {0} 件は、何を移動したか分からないため元に戻しません。", "항목 {0}/{1}": "項目 {0}/{1}", "항목마다 따로 (기본)": "項目ごとに (既定)", "해상도 올리기 메모리": "高解像度化のメモリ", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 9ea6298..d334d94 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -474,6 +474,7 @@ "{0}: 체크한 동영상 {1}개를 {2} 로 옮깁니다": "{0}: 将勾选的 {1} 个视频移到 {2}", "{0}개": "{0} 个", "{0}개 · {1}": "{0} 个 · {1}", + "{0}개 · {1} …": "{0} 项 · {1} …", "{0}개 · 주소 {1}개": "{0} 个 · 地址 {1} 个", "{0}개 더 보기": "再显示 {0} 个", "{0}개 맞춤": "已同步 {0} 个", @@ -488,6 +489,7 @@ "{0}개 풀기": "解压 {0} 个", "{0}개 항목": "{0} 个项目", "{0}개 항목을 {1}": "{0} 个项目{1}", + "{0}개 항목을 놓았습니다": "已放下 {0} 项", "{0}개 항목을 다음 폴더로 {1}\n{2}": "将 {0} 个项目到以下文件夹 — {1}\n{2}", "{0}개 항목을 다음 폴더로 복사할까요?\n{1}": "要将 {0} 个项目复制到以下文件夹吗?\n{1}", "{0}개 항목을 다음 폴더로 옮길까요?\n{1}": "要将 {0} 个项目移动到以下文件夹吗?\n{1}", @@ -504,6 +506,7 @@ "{0}개를 영구 삭제했습니다.": "已永久删除 {0} 个项目。", "{0}개를 옮겼습니다 → {1}": "已移动 {0} 个 → {1}", "{0}개를 옮겼습니다 → {1} ({2})": "已移动 {0} 个 → {1}({2})", + "{0}개를 옮겼습니다.": "已移动 {0} 项。", "{0}개를 찾아 여기서 멈췄습니다. 이름을 더 자세히 넣거나 아래 폴더로 들어가 찾으세요.": "找到 {0} 个后已停止。请输入更具体的名称或进入下级文件夹查找。", "{0}개를 풀었습니다: {1}": "已解压 {0} 个: {1}", "{0}개에서 멈췄습니다": "已在 {0} 个处暂停", @@ -712,7 +715,9 @@ "끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). Chrome 엔진은 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "关闭后不运行页面脚本 (广告 · 跟踪会减少,但 YouTube 等由脚本构建的网站将无法显示)。使用 Chrome 引擎时,页面翻译 · 视频检测也会停止。", "끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "关闭后不运行页面脚本 (广告 · 跟踪会减少,但 YouTube 等由脚本构建的网站将无法显示)。页面翻译 · 视频检测也会停止。", "끄면 환경 설정 · 다운로드 목록 · 다른 화면을 보는 동안에도 웹 페이지의 소리가 계속 들립니다": "关闭后,在查看设置、下载列表或其他界面时网页声音会继续播放", + "끌어 놓기 기본": "拖放默认操作", "끌어다 놓은 항목에 동영상이 없습니다.": "拖入的项目中没有视频。", + "끌어서 다른 창으로": "拖到另一个窗格", "끝": "结束", "끝 (오른쪽 · 아래)": "边缘 (右 · 下)", "끝 = 현재": "结束 = 当前", @@ -810,6 +815,7 @@ "다른 저장소로는 이름만 바꿀 수 없습니다": "不能仅通过重命名移到其他存储", "다른 창 폴더로": "到另一窗格的文件夹", "다른 창에서 열기": "在另一窗格中打开", + "다른 창으로 끌어 놓았을 때. 어느 쪽이든 복사 · 이동 확인 창 (같은 이름 등) 은 그대로 뜹니다": "拖放到另一个窗格时。无论哪种,都会显示复制/移动确认 (同名等)", "다른 창으로 복사": "复制到另一窗格", "다른 창으로 이동": "移动到另一窗格", "다른 폴더로 이동": "移动到其他文件夹", @@ -1074,6 +1080,7 @@ "문자셋 경고": "字符集警告", "문제 {0}개 - 고치기 전에는 바꾸지 않습니다": "{0} 个问题 - 修正前不会重命名", "묻기": "询问", + "묻기 (기본)": "询问 (默认)", "물어보지 않기": "不询问", "미리 보기": "快速预览", "미리 볼 수 없습니다: {0}": "无法预览: {0}", @@ -2353,6 +2360,7 @@ "한국어": "韩语", "한글": "韩文", "할 일": "任务", + "합친 폴더 {0}개는 무엇을 옮겼는지 몰라 되돌리지 않습니다.": "{0} 个合并的文件夹无法撤销 (不清楚移动了哪些内容)。", "항목 {0}/{1}": "项目 {0}/{1}", "항목마다 따로 (기본)": "逐个项目 (默认)", "해상도 올리기 메모리": "提升分辨率内存", diff --git a/lib/app/settings.dart b/lib/app/settings.dart index 0a96da9..904c289 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -422,6 +422,9 @@ class AppSettings { /// 80: 폴더 크기 계산 - 'sort' 크기순 정렬일 때만 (처음 값) · 'always' 늘 · 'off' 안 함 (길게 누르기 [폴더 크기 계산] 은 늘 됨) String explorerFolderSize = 'sort'; + + /// 82: 창 사이 끌어 놓기 - 'ask' 묻기 (처음 값, 관리자 판단) · 'copy' 복사 · 'move' 이동 + String explorerDropAction = 'ask'; bool explorerShowHidden = false; // ── 복사 · 이동 (파일 탐색기) · rsync · lsync (Rsync 화면) ── @@ -741,6 +744,7 @@ class AppSettings { 'explorerSort': explorerSort, 'explorerSortDesc': explorerSortDesc, 'explorerFolderSize': explorerFolderSize, + 'explorerDropAction': explorerDropAction, 'explorerSortPerFolder': explorerSortPerFolder, 'explorerFolderSorts': explorerFolderSorts, 'explorerShowHidden': explorerShowHidden, @@ -958,6 +962,7 @@ class AppSettings { ..explorerSortDesc = j['explorerSortDesc'] as bool? ?? false ..explorerFolderSize = const ['sort', 'always', 'off'].contains(j['explorerFolderSize']) ? j['explorerFolderSize'] as String : 'sort' ..explorerSortPerFolder = j['explorerSortPerFolder'] == true + ..explorerDropAction = const ['ask', 'copy', 'move'].contains(j['explorerDropAction']) ? j['explorerDropAction'] as String : 'ask' ..explorerFolderSorts = { if (j['explorerFolderSorts'] is Map) for (final e in (j['explorerFolderSorts'] as Map).entries) diff --git a/lib/app/transfer_job.dart b/lib/app/transfer_job.dart index 4c284d6..6ac6a8b 100644 --- a/lib/app/transfer_job.dart +++ b/lib/app/transfer_job.dart @@ -169,6 +169,9 @@ class TransferJob extends ChangeNotifier { bool cancelled = false; final made = []; + /// 82: 앱이 하나씩 복사 · 이동한 원본 파일 (폴더를 합칠 때 실제로 옮긴 것만 되돌리려고 - 이름 바꾸기로 통째 옮긴 것은 [made] 만) + final movedFiles = []; + /// 마지막 출력 몇 줄 (실패했을 때 보여 줌) final log = []; @@ -321,6 +324,7 @@ class TransferJob extends ChangeNotifier { bandwidthKBps: bandwidthKBps, onFileDone: (src) { filesDone[i]++; + movedFiles.add(src); currentFile = vBasename(src); _tick(); }, diff --git a/lib/ui/explorer_drag.dart b/lib/ui/explorer_drag.dart new file mode 100644 index 0000000..8a278cf --- /dev/null +++ b/lib/ui/explorer_drag.dart @@ -0,0 +1,99 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +import '../core/file_ops.dart'; +import '../core/vfs.dart'; +import '../l10n/tr.dart'; +import 'theme.dart'; + +/// 82: 창 사이 끌어 놓기로 옮기는 것 - 어느 창 ([from], 창 번호) 의 어떤 항목들 +class DragLoad { + final int from; + final List paths; + const DragLoad(this.from, this.paths); +} + +/// 82: [load] 를 [to] 창의 [dest] 폴더에 놓을 수 있는지 - 같은 창 · 자기 안 · 원본 폴더 자신은 안 됨 +bool canDropInto(DragLoad load, int to, String dest) { + if (load.from == to || load.paths.isEmpty) return false; + if (load.paths.any((x) => isSameOrInside(dest, x))) return false; + if (load.paths.every((x) => samePath(vDirname(x), dest))) return false; + return true; +} + +/// 82: 마우스로 [slop] (8px) 넘게 움직였을 때만 끌기 - 한 번 누르기 (고르기) · 두 번 누르기 (열기) 가 흔들리지 않게 +class MouseDraggable extends Draggable { + final double slop; + const MouseDraggable({ + super.key, + required super.child, + required super.feedback, + super.data, + super.childWhenDragging, + super.onDragStarted, + super.onDragEnd, + this.slop = 8, + }); + + @override + MultiDragGestureRecognizer createRecognizer(GestureMultiDragStartCallback onStart) => + _SlopMultiDrag(slop, supportedDevices: const {PointerDeviceKind.mouse})..onStart = onStart; +} + +class _SlopMultiDrag extends MultiDragGestureRecognizer { + final double slop; + _SlopMultiDrag(this.slop, {super.supportedDevices}) : super(debugOwner: null); + + @override + MultiDragPointerState createNewPointerState(PointerDownEvent event) => _SlopState(slop, event.position, event.kind, gestureSettings); + + @override + String get debugDescription => 'explorer drag'; +} + +class _SlopState extends MultiDragPointerState { + final double slop; + _SlopState(this.slop, super.initialPosition, super.kind, super.gestureSettings); + + @override + void checkForResolutionAfterMove() { + if (pendingDelta!.distance > slop) resolve(GestureDisposition.accepted); + } + + @override + void accepted(GestureMultiDragStartCallback starter) => starter(initialPosition); +} + +/// 82: 끄는 동안 손가락 · 마우스 옆에 보이는 것: "n개 · 첫 이름" +Widget dragFeedback(List paths) => Material( + color: Colors.transparent, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: JjColors.panelHigh, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: JjColors.accent), + ), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + const Icon(Icons.drive_file_move_outline, size: 16, color: JjColors.accent), + const SizedBox(width: 6), + Text(paths.length == 1 ? vBasename(paths.first) : trf('{0}개 · {1} …', [paths.length, vBasename(paths.first)]), + style: const TextStyle(fontSize: 12, color: JjColors.text)), + ]), + ), + ); + +/// 82: 놓았을 때 묻기 - 복사 (Enter, 처음 고름) · 이동 · 취소. 취소하면 null +Future askDropAction(BuildContext context, List paths, String dest) => showDialog( + context: context, + builder: (ctx) => AlertDialog( + scrollable: true, + title: Text(trf('{0}개 항목을 놓았습니다', [paths.length])), + content: Text('${vDisplay(dest)}\n${paths.take(5).map(vBasename).join(', ')}${paths.length > 5 ? ' …' : ''}'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('취소'))), + OutlinedButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('이동'))), + FilledButton(autofocus: true, onPressed: () => Navigator.pop(ctx, false), child: Text(tr('복사'))), + ], + ), + ); diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index ba9cb79..af287bc 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -45,6 +45,7 @@ import '../l10n/tr.dart'; import 'explorer_bookmarks.dart'; import 'explorer_search.dart'; import 'quick_look.dart'; +import 'explorer_drag.dart'; import 'batch_rename_dialog.dart'; import '../core/folder_size.dart'; import '../core/batch_rename.dart'; @@ -2127,7 +2128,8 @@ class _ExplorerPageState extends State { } /// [dest] 로 복사 · 이동 (확인 · 아래 진행 막대 · 취소). 끝까지 했으면 true. - Future _runTransfer(_Pane pane, List sources, String dest, {required bool move}) async { + Future _runTransfer(_Pane pane, List sources, String dest, + {required bool move, void Function(TransferJob job)? onFinished}) async { if (sources.isEmpty) { _snack(_pickFirst(move)); return false; @@ -2330,6 +2332,8 @@ class _ExplorerPageState extends State { if (job.skipped.isNotEmpty) trf('같은 이름이라 건너뜀: {0}', [_namesOf(job.skipped)]), if (skippedHere.isNotEmpty) trf('이미 이 폴더에 있어 건너뜀: {0}', [_namesOf(skippedHere)]), ].join(' · ')); + // 82: 끌어 놓기의 이동 되돌리기 (끝난 알림 바로 뒤에 - 진행 막대가 내려가기를 기다리지 않게) + if (error == null && mounted) onFinished?.call(job); // 다 되면 진행 막대가 잠깐 100% 를 보인 뒤 내려간다 await Future.delayed(const Duration(milliseconds: 1200)); if (mounted && _jobs.contains(job)) setState(() => _jobs = [for (final j in _jobs) if (j != job) j]); @@ -3401,19 +3405,113 @@ class _ExplorerPageState extends State { onTapDown: (_) { if (_active != i) setState(() => _active = i); }, - child: Container( - margin: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: JjColors.bg, - border: Border.all(color: active ? JjColors.accent : JjColors.border, width: active ? 1.5 : 1), - borderRadius: BorderRadius.circular(6), + // 82: 창의 빈 곳에 놓으면 그 창의 지금 폴더로. 놓을 수 있으면 강조, 못 놓으면 (같은 창 등) 빨간 테두리 + child: DragTarget( + onWillAcceptWithDetails: (d) => canDropInto(d.data, i, _panes[i].current), + onAcceptWithDetails: (d) => unawaited(_onDrop(d.data, i, _panes[i].current)), + builder: (context, ok, no) => Container( + margin: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: ok.isNotEmpty ? JjColors.accent.withValues(alpha: 0.06) : JjColors.bg, + border: Border.all( + color: ok.isNotEmpty + ? JjColors.accent + : no.isNotEmpty + ? JjColors.danger + : active + ? JjColors.accent + : JjColors.border, + width: ok.isNotEmpty || no.isNotEmpty ? 2 : (active ? 1.5 : 1)), + borderRadius: BorderRadius.circular(6), + ), + child: Column(children: children), + ), ), - child: Column(children: children), - ), ), ); } + /// 82: 끌 항목 - 누른 것이 표시한 것 중 하나면 표시한 것 모두 (바깥 것만), 아니면 그것 하나 + List _dragPaths(_Pane pane, FileEntry e) => + _isMarked(pane, e.path) && pane.marked.isNotEmpty ? outermostPaths(pane.marked) : [e.path]; + + /// 82: 놓음 - 설정 "끌어 놓기 기본" (묻기 · 복사 · 이동) 뒤 지금의 복사 · 이동 (확인 창 · 같은 이름 · 진행 패널). + /// 이동이 끝나면 아래 알림에 [되돌리기] (10초) + Future _onDrop(DragLoad load, int to, String dest) async { + if (!canDropInto(load, to, dest) || !mounted) return; + setState(() => _active = to); + final mode = c.settings.explorerDropAction; + final move = mode == 'ask' ? await askDropAction(context, load.paths, dest) : mode == 'move'; + if (move == null || !mounted) return; + // 되돌리기에 쓸 것: 대상에 이미 있던 이름 (합친 폴더는 통째로 되돌리지 않게) + final existed = {}; + if (move) { + for (final s in load.paths) { + final t = vJoin(dest, vBasename(s)); + if (await vExists(t)) existed.add(t); + } + } + if (!mounted) return; + await _runTransfer(_panes[load.from], load.paths, dest, + move: move, onFinished: move ? (job) => _offerUndoMove(load, job, existed) : null); + } + + /// 82: 이동 되돌리기 알림. 실제로 옮긴 것만 원래 자리로: 새로 생긴 항목은 통째로, 이미 있던 폴더에 합친 것은 앱이 옮긴 파일만 + /// (robocopy 로 합친 폴더는 무엇을 옮겼는지 몰라 되돌리지 않고 알린다) + void _offerUndoMove(DragLoad load, TransferJob job, Set existed) { + // (원래 폴더 → 지금 경로들) + final back = >{}; + var notUndoable = 0; + final pairs = job.made.length == load.paths.length + ? [for (var i = 0; i < load.paths.length; i++) (load.paths[i], job.made[i])] + : [ + for (final m in job.made) + for (final s in load.paths) + if (vBasename(s) == vBasename(m)) (s, m), + ]; + for (final (src, now) in pairs) { + if (!existed.contains(now)) { + back.putIfAbsent(vDirname(src), () => []).add(now); + continue; + } + // 합친 폴더: 그 안에서 실제로 옮긴 파일만 + final files = [for (final f in job.movedFiles) if (isSameOrInside(f, src) && !samePath(f, src)) f]; + if (files.isEmpty) { + notUndoable++; + continue; + } + for (final f in files) { + final rest = f.substring(src.length + 1); + back.putIfAbsent(vDirname(f), () => []).add(vJoin(now, rest)); + } + } + if (back.isEmpty || !mounted) return; + final moved = back.values.fold(0, (a, l) => a + l.length); + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar( + duration: const Duration(seconds: 10), + content: Text([ + trf('{0}개를 옮겼습니다.', [moved]), + if (notUndoable > 0) trf('합친 폴더 {0}개는 무엇을 옮겼는지 몰라 되돌리지 않습니다.', [notUndoable]), + ].join(' ')), + action: SnackBarAction(label: tr('되돌리기'), onPressed: () => unawaited(_undoMove(_panes[load.from], back))), + )); + } + + /// 82: 되돌리기 - 원래 폴더마다 지금의 이동으로 (같은 이름이 생겼으면 지금의 같은 이름 창) + Future _undoMove(_Pane pane, Map> back) async { + for (final MapEntry(key: dir, value: items) in back.entries) { + final there = [for (final x in items) if (await vExists(x)) x]; + if (there.isEmpty || !mounted) continue; + try { + if (!await vExists(dir)) await vMkdirs(dir); + } catch (_) {} + if (!mounted) return; + await _runTransfer(pane, there, dir, move: true); + } + } + /// 선택 모드 막대: 고른 수 · 모두 고르기 · 선택 끝 Widget _marksBar(_Pane pane, {String? listDir}) { final dir = listDir ?? pane.current; @@ -4098,7 +4196,9 @@ class _ExplorerPageState extends State { cell(_date(e.modified), 3), ]; - return GestureDetector( + final paneIndex = _panes.indexOf(pane); + final drag = real && !widget.rsync; + Widget line = GestureDetector( // Rsync 화면은 고르기만 (파일 기능 메뉴 없음) // 25: Rsync 화면에서도 (폴더 메뉴) onSecondaryTapUp: real ? (d) => _menu(pane, e, d.globalPosition) : null, @@ -4124,6 +4224,16 @@ class _ExplorerPageState extends State { ), ), ), + // 82: 터치로 끌기 - 선택 모드에서 표시한 줄의 ≡ 손잡이 (길게 누르기는 메뉴 그대로) + if (drag && _selecting && marked) + Draggable( + data: DragLoad(paneIndex, _dragPaths(pane, e)), + feedback: dragFeedback(_dragPaths(pane, e)), + child: Tooltip( + message: tr('끌어서 다른 창으로'), + child: const SizedBox(width: 28, height: 36, child: Icon(Icons.drag_indicator, size: 18, color: JjColors.textDim)), + ), + ), // 표시 동그라미는 두 번 누르기 영역 밖 (기다리지 않고 바로) if ((real || (widget.rsync && row.isRoot)) && (_selecting || widget.rsync)) SizedBox( @@ -4151,6 +4261,27 @@ class _ExplorerPageState extends State { ]), ), ); + if (!drag) return line; + // 82: 폴더 줄 위에 놓으면 그 폴더 안으로 (놓을 수 있으면 강조) + if (e.isDir) { + final inner = line; + line = DragTarget( + onWillAcceptWithDetails: (d) => canDropInto(d.data, paneIndex, e.path), + onAcceptWithDetails: (d) => unawaited(_onDrop(d.data, paneIndex, e.path)), + builder: (context, ok, _) => ok.isEmpty + ? inner + : DecoratedBox( + decoration: BoxDecoration( + color: JjColors.accent.withValues(alpha: 0.15), + border: Border.all(color: JjColors.accent, width: 1.5), + ), + child: inner, + ), + ); + } + // 82: 마우스로 8px 넘게 끌면 시작 (고르기 · 두 번 누르기는 그대로) + final paths = _dragPaths(pane, e); + return MouseDraggable(data: DragLoad(paneIndex, paths), feedback: dragFeedback(paths), child: line); } Widget _icon(FileEntry e, bool isRoot, bool open, {bool up = false}) { diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart index d2cce03..461a448 100644 --- a/lib/ui/settings_page.dart +++ b/lib/ui/settings_page.dart @@ -767,6 +767,20 @@ class _SettingsPageState extends State { onChanged: (v) => c.updateSettings((x) => x.explorerClick = v!), ), ), + // 82: 창 사이 끌어 놓기 (처음: 묻기) + SettingTile( + title: Text(tr('끌어 놓기 기본')), + subtitle: Text(tr('다른 창으로 끌어 놓았을 때. 어느 쪽이든 복사 · 이동 확인 창 (같은 이름 등) 은 그대로 뜹니다')), + trailing: DropdownButton( + value: s.explorerDropAction, + items: [ + DropdownMenuItem(value: 'ask', child: Text(tr('묻기 (기본)'))), + DropdownMenuItem(value: 'copy', child: Text(tr('복사'))), + DropdownMenuItem(value: 'move', child: Text(tr('이동'))), + ], + onChanged: (v) => c.updateSettings((x) => x.explorerDropAction = v ?? 'ask'), + ), + ), // 84: 정렬을 폴더마다 기억 (처음: 끔) SettingTile( title: Text(tr('정렬을 폴더마다 기억')), diff --git a/test/explorer_test.dart b/test/explorer_test.dart index 3273781..aedb224 100644 --- a/test/explorer_test.dart +++ b/test/explorer_test.dart @@ -15,6 +15,7 @@ import 'package:jj_mkvmaker/core/file_ops.dart' show formatSize; import 'package:jj_mkvmaker/ui/explorer_page.dart'; import 'package:jj_mkvmaker/ui/explorer_search.dart'; import 'package:jj_mkvmaker/ui/quick_look.dart'; +import 'package:jj_mkvmaker/ui/explorer_drag.dart'; import 'package:jj_mkvmaker/ui/path_label.dart'; import 'package:path/path.dart' as p; import 'support/fake_recycle_bin.dart'; @@ -1014,6 +1015,93 @@ void main() { expect(find.textContaining('담았습니다'), findsOneWidget); }); + group('82 창 사이 끌어 놓기', () { + test('놓을 수 있는 곳: 같은 창 · 자기 안 · 원본 폴더 자신은 안 됨', () { + final a = p.join('C:', 'x', 'a'), f = p.join('C:', 'x', 'f.txt'); + expect(canDropInto(DragLoad(0, [f]), 1, p.join('C:', 'y')), isTrue); + expect(canDropInto(DragLoad(0, [f]), 0, p.join('C:', 'y')), isFalse, reason: '같은 창'); + expect(canDropInto(DragLoad(0, [a]), 1, p.join(a, 'in')), isFalse, reason: '자기 안'); + expect(canDropInto(DragLoad(0, [f]), 1, p.join('C:', 'x')), isFalse, reason: '원본 폴더 자신'); + }); + + /// 오른쪽 창에 보이는 [name] 줄 (두 창 모두 같은 이름이 보이면 오른쪽 것) + Offset rightOf(WidgetTester tester, String name) { + final all = find.text(name).evaluate().map((e) => tester.getCenter(find.byWidget(e.widget).first)).toList() + ..sort((x, y) => x.dx.compareTo(y.dx)); + return all.last; + } + + Future mouseDrag(WidgetTester tester, Offset from, Offset to) async { + final g = await tester.startGesture(from, kind: PointerDeviceKind.mouse); + await g.moveBy(const Offset(20, 0)); + await tester.pump(); + await g.moveTo(to); + await tester.pump(); + await g.up(); + await calm(tester); + } + + testWidgets('짧게 움직이면 고르기 · 같은 창에는 놓아도 아무 일 없음 · 묻기 [취소] 도 아무 일 없음', (tester) async { + await open(tester); + final doc = tester.getCenter(find.text('doc.txt').first); + // 4px 만 움직임 = 누르기 (끌기 아님) + final g = await tester.startGesture(doc, kind: PointerDeviceKind.mouse); + await g.moveBy(const Offset(4, 0)); + await g.up(); + await calm(tester); + expect(find.byType(AlertDialog), findsNothing); + // 같은 창의 sub 위에 놓기 - 못 놓음 + await mouseDrag(tester, doc, tester.getCenter(find.text('sub').first)); + expect(find.byType(AlertDialog), findsNothing, reason: '같은 창에는 놓지 않는다'); + // 오른쪽 창 (right 폴더) 에 놓기 → 묻기 → 취소 + await mouseDrag(tester, doc, rightOf(tester, 'right')); + expect(find.text('1개 항목을 놓았습니다'), findsOneWidget); + await tester.tap(find.text('취소').last); + await calm(tester); + expect(File(p.join(left, 'doc.txt')).existsSync(), isTrue); + expect(File(p.join(right, 'doc.txt')).existsSync(), isFalse); + }); + + testWidgets('이동 → [되돌리기]: 새로 생긴 것은 통째로, 이미 있던 폴더에 합친 것은 옮긴 파일만', (tester) async { + // 오른쪽에 이미 sub 폴더 (원래 파일 하나) - 합치기 + File(p.join(right, 'sub', 'mine.txt')) + ..createSync(recursive: true) + ..writeAsStringSync('mine'); + c.settings + ..explorerDropAction = 'move' + ..copyConflict = 'overwrite'; + await open(tester); + await act(tester, () => tester.tap(find.text('선택').first)); + await act(tester, () => tester.tap(markOf('doc.txt'))); + await act(tester, () => tester.tap(markOf('sub'))); + await calm(tester); + await mouseDrag(tester, tester.getCenter(find.text('doc.txt').first), rightOf(tester, 'right')); + // (놓은 뒤 대상에 이미 있는 이름을 실제 파일로 확인하므로 실제 시간을 흘려 기다린다) + await settle(tester, () => find.widgetWithText(FilledButton, '이동').evaluate().isNotEmpty); + // 지금의 이동 확인 창 그대로 + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '이동'))); + await settle(tester, () => find.text('되돌리기').evaluate().isNotEmpty); + expect(File(p.join(right, 'doc.txt')).existsSync(), isTrue); + expect(File(p.join(right, 'sub', 'inner.txt')).existsSync(), isTrue); + expect(Directory(p.join(left, 'sub')).existsSync(), isFalse); + // 되돌리기: 원래 폴더마다 지금의 이동 (확인 창) + await act(tester, () => tester.tap(find.text('되돌리기'))); + // 원래 폴더가 둘 (left · left/sub) 이라 이동 확인 창도 둘 + for (var i = 0; i < 2; i++) { + await settle(tester, () => find.widgetWithText(FilledButton, '이동').evaluate().isNotEmpty); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '이동'))); + await settle(tester, () => find.byType(AlertDialog).evaluate().isEmpty, rounds: 50); + } + await settle(tester, () => File(p.join(left, 'doc.txt')).existsSync() && File(p.join(left, 'sub', 'inner.txt')).existsSync()); + expect(File(p.join(left, 'doc.txt')).readAsStringSync(), 'hello'); + expect(File(p.join(left, 'sub', 'inner.txt')).readAsStringSync(), 'inner'); + expect(File(p.join(right, 'sub', 'mine.txt')).readAsStringSync(), 'mine', reason: '원래 있던 폴더 · 파일은 그대로 (폴더째 옮기지 않음)'); + expect(File(p.join(right, 'sub', 'inner.txt')).existsSync(), isFalse); + ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); + await calm(tester); + }); + }); + testWidgets('81: Space 로 빠른 보기 (글 파일 앞부분) · 필터에 치는 중에는 그냥 빈칸 · TC 는 F3', (tester) async { c.settings.explorerStyle = 'windows'; await open(tester);