파일 탐색기: 어느 배치에서나 복사 · 이동 → 붙여넣기

"폴더 + 파일 목록" · 한 창 배치에서는 복사 · 이동이 막혀 있었다 (다른 창이 없음).
길게 누르기 메뉴 · 버튼의 [복사] · [이동] 으로 담아 두고, 넣을 폴더에서 [붙여넣기] (메뉴 · 버튼).
두 창은 지금처럼 "다른 창으로 복사 · 이동" 도 그대로. 붙여넣은 폴더는 펼쳐서 새 항목이 바로 보인다.
Android 태블릿 (Galaxy Tab S6 Lite) 에서 확인.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
묶음3
RyuJongJin 2026-10-05 08:48:56 +09:00
parent a485d0d997
commit 35ca463287
5 changed files with 98 additions and 9 deletions

View File

@ -163,6 +163,7 @@
"{0} 브라우저의 YouTube 로그인 쿠키 사용 (Chrome · Edge 는 브라우저를 닫아야 읽힐 수 있음)": "Use the YouTube sign-in cookies of the {0} browser (Chrome · Edge may need to be closed to be read)",
"{0} 순으로 정렬{1}": "Sort by {0}{1}",
"{0} 아래에 만듭니다": "Created under {0}",
"{0} 에 붙여넣기 ({1}개)": "Paste into {0} ({1})",
"{0} 에서 받을 파일을 찾지 못했습니다": "Could not find a file to download from {0}",
"{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg does not have the {0} encoder.",
"{0} 추가": "Add {0}",
@ -179,6 +180,7 @@
"{0}개 표시함": "{0} marked",
"{0}개 항목을 {1}": "{0} items {1}",
"{0}개 항목을 다음 폴더로 {1}\n{2}": "{0} items to this folder — {1}\n{2}",
"{0}개 항목을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).": "{0} items held. Press [Paste] in the destination folder ({1}).",
"{0}개 항목을 지울까요? 되돌릴 수 없습니다.": "Delete {0} items? This cannot be undone.",
"{0}개를 \"기타 즐겨찾기\" 에 가져왔습니다.": "Imported {0} into \"Other bookmarks\".",
"{0}개를 옮겼습니다 → {1}": "Moved {0} → {1}",
@ -440,6 +442,7 @@
"보기 {0}초 · Ctrl+휠 확대/축소": "View {0} s · Ctrl+wheel to zoom",
"보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "Choose the buttons to show and drag to reorder.",
"복사": "Copy",
"복사 (붙여넣기로)": "Copy (to paste)",
"복사 · 이동은 두 창에서 씁니다 (창 배치 > 두 창).": "Copy · move works with two panes (Pane layout > Two panes).",
"복사 · 이동할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "Choose items to copy · move (tap, or mark with the circle on the right).",
"복사(Ctrl+C)한 주소 자동 다운로드": "Auto-download copied (Ctrl+C) URLs",
@ -448,6 +451,8 @@
"복사했습니다.": "copied.",
"볼 수 없는 영상입니다 (삭제 · 지역 제한).": "This video is unavailable (deleted · region restricted).",
"분석 실패: {0} - {1}": "Analysis failed: {0} - {1}",
"붙여넣기": "Paste",
"붙여넣을 항목이 없습니다. 먼저 [복사] · [이동] 으로 담으세요.": "Nothing to paste. First hold items with [Copy] · [Move].",
"브라우저 엔진 (앱 안)": "Browser engine (in-app)",
"브라우저 쿠키를 읽지 못했습니다. 환경 설정 > 다운로드 > YouTube 쿠키 에서 \"앱 안 브라우저\" 를 고르거나, 고른 브라우저가 설치되어 있는지 확인하세요 (Chrome · Edge 는 브라우저를 닫아야 읽힙니다).": "Could not read the browser cookies. In Settings > Downloads > YouTube cookies, choose \"In-app browser\", or check that the chosen browser is installed (Chrome · Edge must be closed to be read).",
"비공개 · 회원 전용 영상입니다.": "This is a private · members-only video.",
@ -651,6 +656,7 @@
"이 폴더의 동영상 재생": "Play videos in this folder",
"이동": "Move",
"이동 ({0})": "Move ({0})",
"이동 (붙여넣기로)": "Move (to paste)",
"이동 버튼 (세부 정보 오른쪽 아래, 위로 하나씩)": "Move buttons (bottom right of the details, stacked upward)",
"이동 버튼 추가": "Add move button",
"이동 버튼의 폴더": "Folder of the move button",

View File

@ -163,6 +163,7 @@
"{0} 브라우저의 YouTube 로그인 쿠키 사용 (Chrome · Edge 는 브라우저를 닫아야 읽힐 수 있음)": "{0} ブラウザーの YouTube ログインクッキーを使う(Chrome・Edge はブラウザーを閉じないと読めないことがあります)",
"{0} 순으로 정렬{1}": "{0} 順に並べ替え{1}",
"{0} 아래에 만듭니다": "{0} の下に作成します",
"{0} 에 붙여넣기 ({1}개)": "{0} に貼り付け ({1} 件)",
"{0} 에서 받을 파일을 찾지 못했습니다": "{0} からダウンロードするファイルが見つかりませんでした",
"{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg に {0} エンコーダーがありません。",
"{0} 추가": "{0} を追加",
@ -179,6 +180,7 @@
"{0}개 표시함": "{0} 件をマーク",
"{0}개 항목을 {1}": "{0} 件の項目を{1}",
"{0}개 항목을 다음 폴더로 {1}\n{2}": "{0} 件の項目を次のフォルダーへ{1}\n{2}",
"{0}개 항목을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).": "{0} 件を保持しました。入れるフォルダーで [貼り付け] を押してください ({1})。",
"{0}개 항목을 지울까요? 되돌릴 수 없습니다.": "{0} 件の項目を削除しますか? 元に戻せません。",
"{0}개를 \"기타 즐겨찾기\" 에 가져왔습니다.": "{0}個を「その他のブックマーク」に取り込みました。",
"{0}개를 옮겼습니다 → {1}": "{0}個を移動しました → {1}",
@ -440,6 +442,7 @@
"보기 {0}초 · Ctrl+휠 확대/축소": "表示 {0}秒・Ctrl+ホイールで拡大/縮小",
"보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "表示するボタンを選び、ドラッグで順序を変えます。",
"복사": "コピー",
"복사 (붙여넣기로)": "コピー (貼り付け用)",
"복사 · 이동은 두 창에서 씁니다 (창 배치 > 두 창).": "コピー · 移動は 2 ペインで使います (ペインの配置 > 2 ペイン)。",
"복사 · 이동할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "コピー · 移動する項目を選んでください (タップ、または右の丸でマーク)。",
"복사(Ctrl+C)한 주소 자동 다운로드": "コピー(Ctrl+C)した URL を自動ダウンロード",
@ -448,6 +451,8 @@
"복사했습니다.": "コピーしました。",
"볼 수 없는 영상입니다 (삭제 · 지역 제한).": "視聴できない動画です(削除・地域制限)。",
"분석 실패: {0} - {1}": "分析失敗: {0} - {1}",
"붙여넣기": "貼り付け",
"붙여넣을 항목이 없습니다. 먼저 [복사] · [이동] 으로 담으세요.": "貼り付ける項目がありません。先に [コピー] · [移動] で保持してください。",
"브라우저 엔진 (앱 안)": "ブラウザーエンジン(アプリ内)",
"브라우저 쿠키를 읽지 못했습니다. 환경 설정 > 다운로드 > YouTube 쿠키 에서 \"앱 안 브라우저\" 를 고르거나, 고른 브라우저가 설치되어 있는지 확인하세요 (Chrome · Edge 는 브라우저를 닫아야 읽힙니다).": "ブラウザーのクッキーを読めませんでした。環境設定 > ダウンロード > YouTube クッキー で「アプリ内ブラウザー」を選ぶか、選んだブラウザーがインストールされているか確認してください(Chrome・Edge はブラウザーを閉じないと読めません)。",
"비공개 · 회원 전용 영상입니다.": "非公開・メンバー限定の動画です。",
@ -651,6 +656,7 @@
"이 폴더의 동영상 재생": "このフォルダーの動画を再生",
"이동": "移動",
"이동 ({0})": "移動({0})",
"이동 (붙여넣기로)": "移動 (貼り付け用)",
"이동 버튼 (세부 정보 오른쪽 아래, 위로 하나씩)": "移動ボタン(詳細の右下、上に 1つずつ)",
"이동 버튼 추가": "移動ボタンを追加",
"이동 버튼의 폴더": "移動ボタンのフォルダー",

View File

@ -163,6 +163,7 @@
"{0} 브라우저의 YouTube 로그인 쿠키 사용 (Chrome · Edge 는 브라우저를 닫아야 읽힐 수 있음)": "使用 {0} 浏览器的 YouTube 登录 Cookie(Chrome · Edge 可能需要关闭浏览器才能读取)",
"{0} 순으로 정렬{1}": "按{0}排序{1}",
"{0} 아래에 만듭니다": "在 {0} 下创建",
"{0} 에 붙여넣기 ({1}개)": "粘贴到 {0} ({1} 个)",
"{0} 에서 받을 파일을 찾지 못했습니다": "未在 {0} 找到要下载的文件",
"{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg 中没有 {0} 编码器。",
"{0} 추가": "添加 {0}",
@ -179,6 +180,7 @@
"{0}개 표시함": "已标记 {0} 个",
"{0}개 항목을 {1}": "{0} 个项目{1}",
"{0}개 항목을 다음 폴더로 {1}\n{2}": "将 {0} 个项目到以下文件夹 — {1}\n{2}",
"{0}개 항목을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).": "已暂存 {0} 个项目。请在目标文件夹中按 [粘贴] ({1})。",
"{0}개 항목을 지울까요? 되돌릴 수 없습니다.": "删除 {0} 个项目吗? 此操作无法撤销。",
"{0}개를 \"기타 즐겨찾기\" 에 가져왔습니다.": "已将 {0} 个导入“其他书签”。",
"{0}개를 옮겼습니다 → {1}": "已移动 {0} 个 → {1}",
@ -440,6 +442,7 @@
"보기 {0}초 · Ctrl+휠 확대/축소": "显示 {0} 秒 · Ctrl+滚轮缩放",
"보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "选择要显示的按钮,拖动以调整顺序。",
"복사": "复制",
"복사 (붙여넣기로)": "复制 (用于粘贴)",
"복사 · 이동은 두 창에서 씁니다 (창 배치 > 두 창).": "复制 · 移动需在双窗格中使用 (窗格布局 > 双窗格)。",
"복사 · 이동할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "请选择要复制 · 移动的项目 (点按,或用右侧圆圈标记)。",
"복사(Ctrl+C)한 주소 자동 다운로드": "自动下载复制(Ctrl+C)的地址",
@ -448,6 +451,8 @@
"복사했습니다.": "已复制。",
"볼 수 없는 영상입니다 (삭제 · 지역 제한).": "该视频无法观看(已删除 · 地区限制)。",
"분석 실패: {0} - {1}": "分析失败: {0} - {1}",
"붙여넣기": "粘贴",
"붙여넣을 항목이 없습니다. 먼저 [복사] · [이동] 으로 담으세요.": "没有可粘贴的项目。请先用 [复制] · [移动] 暂存。",
"브라우저 엔진 (앱 안)": "浏览器引擎(应用内)",
"브라우저 쿠키를 읽지 못했습니다. 환경 설정 > 다운로드 > YouTube 쿠키 에서 \"앱 안 브라우저\" 를 고르거나, 고른 브라우저가 설치되어 있는지 확인하세요 (Chrome · Edge 는 브라우저를 닫아야 읽힙니다).": "未能读取浏览器 Cookie。请在 设置 > 下载 > YouTube Cookie 中选择“应用内浏览器”,或确认所选浏览器已安装(Chrome · Edge 需关闭浏览器才能读取)。",
"비공개 · 회원 전용 영상입니다.": "这是私享 · 会员专享视频。",
@ -651,6 +656,7 @@
"이 폴더의 동영상 재생": "播放此文件夹中的视频",
"이동": "移动",
"이동 ({0})": "移动({0})",
"이동 (붙여넣기로)": "移动 (用于粘贴)",
"이동 버튼 (세부 정보 오른쪽 아래, 위로 하나씩)": "移动按钮(详细信息右下角,向上依次排列)",
"이동 버튼 추가": "添加移动按钮",
"이동 버튼의 폴더": "移动按钮的文件夹",

View File

@ -62,6 +62,7 @@ enum ExplorerButton {
rename(Icons.drive_file_rename_outline, '이름 변경'),
copy(Icons.copy_outlined, '복사'),
move(Icons.drive_file_move_outline, '이동'),
paste(Icons.content_paste, '붙여넣기'),
delete(Icons.delete_outline, '삭제'),
search(Icons.search, '찾기'),
sort(Icons.sort, '정렬 기준'),
@ -387,6 +388,11 @@ class _ExplorerPageState extends State<ExplorerPage> {
('rename', Icons.drive_file_rename_outline, tr('이름 변경')),
if (dual) ('copy', Icons.copy_outlined, tr('다른 창으로 복사')),
if (dual) ('move', Icons.drive_file_move_outline, tr('다른 창으로 이동')),
// 어느 배치에서나: 담아 두었다가 원하는 폴더에서 붙여넣기
('clipCopy', Icons.content_copy, dual ? tr('복사 (붙여넣기로)') : tr('복사')),
('clipMove', Icons.content_cut, dual ? tr('이동 (붙여넣기로)') : tr('이동')),
if (_clip.isNotEmpty)
('paste', Icons.content_paste, trf('{0} 에 붙여넣기 ({1}개)', [p.basename(e.isDir ? e.path : p.dirname(e.path)), _clip.length])),
('delete', Icons.delete_outline, tr('삭제')),
if (!Platform.isAndroid || e.isDir) ('reveal', Icons.folder_outlined, tr('파일 관리자에서 보기')),
('info', Icons.info_outline, tr('정보')),
@ -427,6 +433,12 @@ class _ExplorerPageState extends State<ExplorerPage> {
await _transfer(pane, [e.path], move: false);
case 'move':
await _transfer(pane, [e.path], move: true);
case 'clipCopy':
_toClipboard(pane.marked.contains(e.path) ? pane.marked.toList() : [e.path], move: false);
case 'clipMove':
_toClipboard(pane.marked.contains(e.path) ? pane.marked.toList() : [e.path], move: true);
case 'paste':
await _paste(pane, e.isDir ? e.path : p.dirname(e.path));
case 'delete':
await _delete(pane, [e.path]);
case 'reveal':
@ -513,6 +525,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
await _transfer(pane, _targets(pane), move: false);
case ExplorerButton.move:
await _transfer(pane, _targets(pane), move: true);
case ExplorerButton.paste:
await _paste(pane, pane.current);
case ExplorerButton.delete:
await _delete(pane, _targets(pane));
case ExplorerButton.search:
@ -623,17 +637,50 @@ class _ExplorerPageState extends State<ExplorerPage> {
}
/// 다른 창의 지금 폴더로 복사 · 이동 (진행 창 · 취소)
/// 복사 · 이동 버튼: 두 창이면 다른 창의 지금 폴더로, 그 밖의 배치는 담아 두기 (붙여넣기로)
Future<void> _transfer(_Pane pane, List<String> sources, {required bool move}) async {
final other = _other;
if (other == null) {
_snack(tr('복사 · 이동은 두 창에서 씁니다 (창 배치 > 두 창).'));
_toClipboard(sources, move: move);
return;
}
await _runTransfer(pane, sources, other.current, move: move);
}
/// 복사 · 이동할 항목 (붙여넣기 전까지 담아 둠)
List<String> _clip = [];
bool _clipMove = false;
void _toClipboard(List<String> sources, {required bool move}) {
if (sources.isEmpty) {
_snack(tr('복사 · 이동할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).'));
return;
}
final dest = other.current;
setState(() {
_clip = [...sources];
_clipMove = move;
});
_snack(trf('{0}개 항목을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).',
[sources.length, move ? tr('이동') : tr('복사')]));
}
Future<void> _paste(_Pane pane, String dest) async {
if (_clip.isEmpty) {
_snack(tr('붙여넣을 항목이 없습니다. 먼저 [복사] · [이동] 으로 담으세요.'));
return;
}
final items = _clip, move = _clipMove;
final done = await _runTransfer(pane, items, dest, move: move);
// 옮긴 것은 다시 붙여넣을 수 없으므로 비운다 (복사는 여러 곳에 붙여넣을 수 있게 남김)
if (done && move && mounted) setState(() => _clip = []);
}
/// [dest] 로 복사 · 이동 (확인 · 진행 창 · 취소). 끝까지 했으면 true.
Future<bool> _runTransfer(_Pane pane, List<String> sources, String dest, {required bool move}) async {
if (sources.isEmpty) {
_snack(tr('복사 · 이동할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).'));
return false;
}
final go = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
@ -645,7 +692,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
],
),
);
if (go != true || !mounted) return;
if (go != true || !mounted) return false;
final ops = FileOps();
final progress = ValueNotifier<(String, double)>(('', 0));
showDialog<void>(
@ -679,11 +726,18 @@ class _ExplorerPageState extends State<ExplorerPage> {
if (mounted) Navigator.of(context).pop();
pane.marked.clear();
await _refreshAll([dest, ...sources.map(p.dirname)]);
other.expanded.add(dest);
other.changed();
// 넣은 폴더가 창의 지금 폴더면 펼쳐서 새 항목이 보이게
for (final x in _panes) {
if (samePath(x.current, dest)) {
x.expanded.add(dest);
await _load(x, dest, force: true);
}
x.changed();
}
_snack(error != null
? trf('끝나지 못했습니다: {0}', [error])
: trf('{0}개 항목을 {1}', [made.length, move ? tr('옮겼습니다.') : tr('복사했습니다.')]));
return error == null;
}
Future<String?> _askName(String title, String initial) {
@ -1020,7 +1074,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
vertical: side,
onPressed: _button,
enabled: (b) => switch (b) {
ExplorerButton.copy || ExplorerButton.move => dual,
ExplorerButton.paste => _clip.isNotEmpty,
_ => true,
},
selected: (b) => b == ExplorerButton.hidden && s.explorerShowHidden,

View File

@ -134,15 +134,30 @@ void main() {
await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty);
expect(find.text('inner.txt'), findsOneWidget);
// Total Commander: [폴더] · <DIR> · 확장자 칸
// 이 배치에도 복사 · 이동: 길게 눌러 [복사] 로 담고 → 다른 폴더를 골라 [붙여넣기]
await tester.longPress(find.text('inner.txt'));
await tester.pumpAndSettle();
expect(find.text('다른 창으로 복사'), findsNothing);
await tester.tap(find.text('복사').last);
await tester.pumpAndSettle();
await act(tester, () => tester.tap(find.text('..')));
await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty);
await act(tester, () => tester.tap(find.text('붙여넣기')));
await tester.pumpAndSettle();
await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '복사')));
await settle(tester, () => File(p.join(left, 'inner.txt')).existsSync() && find.byType(LinearProgressIndicator).evaluate().isEmpty);
expect(File(p.join(left, 'inner.txt')).readAsStringSync(), 'inner');
expect(File(p.join(left, 'sub', 'inner.txt')).existsSync(), isTrue); // 복사이므로 원본은 그대로
await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty);
expect(find.text('inner.txt'), findsOneWidget); // 목록에 바로 보임
// Total Commander: [폴더] · <DIR> · 확장자 칸
await c.updateSettings((s) => s.explorerStyle = 'totalcmd');
await settle(tester, () => find.text('[sub]').evaluate().length == 2);
expect(find.text('[sub]'), findsNWidgets(2));
expect(find.text('<DIR>'), findsOneWidget);
expect(find.text('doc'), findsOneWidget); // 확장자를 뺀 이름
expect(find.text('txt'), findsOneWidget); // 확장자 칸
expect(find.text('txt'), findsNWidgets(2)); // 확장자 칸 (doc.txt · 붙여넣은 inner.txt)
expect(find.text('확장자'), findsOneWidget);
});
@ -180,7 +195,9 @@ void main() {
testWidgets('창 배치: 한 창 · 버튼 줄 숨김, 버튼 구성: 버튼 빼기 → 설정에 저장', (tester) async {
await open(tester);
await tester.tap(find.text('창 배치').first);
await tester.tap(find.byTooltip('창 배치 · 버튼 구성'));
await tester.pumpAndSettle();
await tester.tap(find.text('창 배치').last);
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(ChoiceChip, '한 창'));
await tester.pump();