E5d-5: 줄 메뉴 · 지우기 · 옮기기 · rsync · 파일 열기 흐름을 모듈로 (explorer_page 3,531 → 2,032)

- explorer/write_guard.dart: ExplorerGuard (압축 안 · 시스템 폴더 · 루트는 읽기 전용, 압축에서 꺼내기)
- explorer/explorer_jobs.dart: ExplorerJobs (도는 복사 · 이동 · rsync 목록, 끝나면 잠깐 뒤 내림, 이어받기)
- explorer/transfer_flow.dart: ExplorerTransfers (복사 · 이동 · 담기 · 붙여넣기 · 확인 창 · rsync 실행 파일)
- explorer/rsync_flow.dart: ExplorerRsyncFlow (Rsync 화면 → ← ⇄)
- explorer/delete_flow.dart: ExplorerDeleteFlow (휴지통 · 앱 휴지통 · 영구 삭제 · 되돌리기 · 휴지통 열기)
- explorer/open_flow.dart: ExplorerOpener (file_opener 의 길대로 실제로 열기 · 보기 · 재생 · 압축 · PDF · 공유)
- explorer/item_menu.dart · item_actions.dart: ExplorerItemMenu · 정보 · 경로 복사 · 숨기기 확인 · .nomedia · 아이콘 줄
- explorer/name_dialog.dart: askName
- ExplorerSelect.targets 더함, ExplorerCommands 24 → 16
- 약속 시험: test/explorer_flows_contract_test.dart (버튼 약속 시험은 실제 흐름으로)
- 동작은 그대로 (전체 단위 2회 · 점검 통합 2회 · 픽셀 비교)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
split_pages
RyuJongJin 2026-10-22 13:07:19 +09:00
parent 77d962bcdf
commit 4b3ccb0c4f
14 changed files with 2132 additions and 1632 deletions

View File

@ -0,0 +1,276 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:path/path.dart' as p;
import '../../app/delete_ops.dart';
import '../../core/app_trash.dart';
import '../../core/file_ops.dart';
import '../../core/vfs.dart';
import '../../l10n/tr.dart';
import '../../platform/windows/recycle_bin.dart';
import '../confirm_dialog.dart';
import '../notice.dart';
import '../theme.dart';
import '../trash_page.dart';
import 'explorer_controller.dart';
import 'explorer_pane.dart';
import 'explorer_select.dart';
import 'write_guard.dart';
/// 약속 (E5d-5): 지우기 흐름. Windows 의 로컬 파일은 기본으로 휴지통 (창에서 "영구 삭제" 를 고르거나 [shiftDelete]),
/// Android 는 앱 휴지통 ([appTrash], 보관 일이 0 이면 끔), WebDAV 등 휴지통이 없는 곳은 처음부터 영구 삭제로 묻는다.
/// 지우기 규칙은 delete_ops (E5b). 여러 개면 실패해도 나머지를 계속하고, 못 지운 것 · 휴지통에 못 넣은 것은 창으로 묻거나 알린다.
/// 휴지통으로 보낸 것은 알림의 [되돌리기]. 끝나면 표시 · 펼침 · 지금 폴더를 정리하고 다시 읽는다. 읽기 전용인 곳은 [ExplorerGuard] 가 막는다
class ExplorerDeleteFlow {
ExplorerDeleteFlow(this.ctl, this.sel, this.guard, {required this.androidTrash});
final ExplorerController ctl;
final ExplorerSelect sel;
final ExplorerGuard guard;
/// 앱 휴지통을 쓰는 기기인지 (Android)
final bool Function() androidTrash;
/// Shift+Delete 로 불렀는지 (휴지통을 거치지 않고 지우기를 미리 골라 둔다 - 65)
bool shiftDelete = false;
/// 지우기. Windows 의 로컬 파일은 기본으로 휴지통으로 (창에서 "영구 삭제" 를 고르거나 Shift+Delete).
/// WebDAV · Android 는 휴지통이 없어 바로 지운다. 여러 개면 실패해도 나머지를 계속하고, 못 지운 것을 알린다 (65).
Future<void> run(BuildContext context, ExplorerPane pane, List<String> paths) async {
if (guard.blockArchive(paths)) return;
paths = outermostPaths(paths); // 79-c: 고른 폴더 안의 것은 한 번만 (확인 창 개수도)
final shift = shiftDelete;
shiftDelete = false;
if (paths.isEmpty) {
ctl.host.snack(tr('지울 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).'));
return;
}
// 휴지통이 있는 곳: Windows 의 고정 디스크 (94: 네트워크 드라이브 · \\NAS · USB 메모리는 휴지통이 없어 처음부터 영구 삭제로 묻는다)
final local = Platform.isWindows && paths.every((x) => !isDav(x));
// X-plore 비교 e: Android 는 앱 휴지통 (저장 장치의 .jj_trash, 보관 일이 0 이면 끔)
final trash = appTrash;
final canTrash = trash != null && paths.every((x) => !isDav(x) && trash.trashDirFor(x) != null && !trash.isInTrash(x));
final canRecycle = (local && ctl.c.settings.recycleOnDelete && paths.every(hasRecycleBin)) || canTrash;
var permanent = !canRecycle || shift;
// X-plore 숨기기: 지우는 폴더 안에 목록에서 숨긴 것이 있으면 알린다 (숨긴 것도 함께 지워진다)
final hiddenInside = [
for (final h in ctl.c.settings.explorerHiddenItems)
if (paths.any((x) => !samePath(h, x) && isSameOrInside(h, x))) h
].length;
final otherMarks = [for (final x in ctl.panes) if (!identical(x, pane)) x.sel.marked.length].fold(0, (a, b) => a + b);
// U7: 확인 창은 공용 confirmDialog - 영구 삭제로 열면 Enter 의 첫 초점은 [취소]
final ok = await confirmDialog(
context,
title: () => permanent ? tr('영구 삭제') : tr('휴지통으로 보내기'),
danger: () => permanent,
confirmLabel: () => permanent ? tr('영구 삭제') : tr('휴지통으로'),
// 무엇을 지우는지 늘 보여 준다 (여러 개면 앞의 8개 이름 · 폴더)
body: (ctx, set) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(paths.length == 1
? '${permanent ? trf('"{0}" 을(를) 지울까요? 되돌릴 수 없습니다.', [vBasename(paths.first)]) : trf('"{0}" 을(를) 휴지통으로 보낼까요?', [vBasename(paths.first)])}'
'\n${vDirname(paths.first)}'
: [
permanent
? trf('{0}개 항목을 지울까요? 되돌릴 수 없습니다.', [paths.length])
: trf('{0}개 항목을 휴지통으로 보낼까요?', [paths.length]),
for (final x in paths.take(8)) '· ${vBasename(x)}',
if (paths.length > 8) trf('… 외 {0}개', [paths.length - 8]),
{for (final x in paths) vDirname(x)}.join('\n'),
].join('\n')),
// 79-b: 폴더가 있으면 몇 개인지 (안의 것도 함께 지워진다)
if (paths.any(vIsDirSync))
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(trf('그중 폴더 {0}개 - 안의 파일 · 폴더도 모두 함께', [paths.where(vIsDirSync).length]),
style: TextStyle(fontSize: 12, color: JjColors.warning)),
),
if (hiddenInside > 0)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(trf('지우는 폴더 안에 목록에서 숨긴 항목 {0}개가 있습니다 - 함께 지워집니다', [hiddenInside]),
style: TextStyle(fontSize: 12, color: JjColors.warning)),
),
if (local && !canRecycle)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(tr('이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.'),
style: TextStyle(fontSize: 12, color: JjColors.warning)),
),
if (canTrash && !permanent)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(trf('앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).', [ctl.c.settings.androidTrashDays]),
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
),
if (canRecycle)
CheckboxListTile(
contentPadding: EdgeInsets.zero,
value: permanent,
onChanged: (v) => set(() => permanent = v ?? false),
title: Text(canTrash ? tr('휴지통을 거치지 않고 영구 삭제') : tr('휴지통을 거치지 않고 영구 삭제 (Shift+Delete)')),
),
// U4: 다른 창의 표시는 이번 대상이 아님
if (otherMarks > 0)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(trf('다른 창에 {0}개 표시됨 (이번 대상 아님)', [otherMarks]),
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
),
]),
);
if (!ok) return;
// 하나씩: 실패해도 나머지는 계속. 실제로 어떻게 됐는지 세어 그대로 알린다 (94) - 지우기 규칙은 delete_ops (E5b)
final report = await deletePaths(paths, permanent: permanent, trash: !permanent && canTrash ? trash : null);
final failed = report.failed, tooLong = report.tooLong, sent = report.sent, trashed = report.trashed;
final notTrashed = report.notTrashed, undoable = report.undoable, since = report.since;
final recycled = report.recycled, nuked = report.nuked, kept = report.kept;
// X-plore 비교 e: 휴지통으로 옮기지 못한 것 (다른 저장 장치 · 권한 등) - 몰래 지우지 않고 영구 삭제할지 묻는다
if (notTrashed.isNotEmpty && context.mounted) {
// U7: 되돌릴 수 없는 일 - Enter 의 첫 초점은 [그대로 두기]
final nuke = await confirmDialog(
context,
icon: Icon(Icons.warning_amber_rounded, color: JjColors.warning),
title: () => trf('{0}개를 휴지통으로 옮기지 못했습니다', [notTrashed.length]),
danger: () => true,
cancelLabel: tr('그대로 두기'),
confirmLabel: () => trf('영구 삭제 ({0}개, 되돌릴 수 없음)', [notTrashed.length]),
body: (ctx, set) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
for (final (x, why) in notTrashed.take(10)) Text('· ${vBasename(x)} - $why', style: const TextStyle(fontSize: 12)),
const SizedBox(height: 8),
Text(tr('영구 삭제하면 되살릴 수 없습니다.')),
]),
);
if (nuke) {
for (final (x, e) in await deleteForever([for (final (x, _) in notTrashed) x])) {
failed.add((x, '$e'));
}
}
}
if ((failed.isNotEmpty || tooLong.isNotEmpty) && context.mounted) {
final failTitle = trf('{0}개 중 {1}개를 지우지 못했습니다', [paths.length, failed.length + tooLong.length]);
final failIcon = Icon(Icons.error_outline, color: JjColors.alert);
Widget failBody() => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
// 144: 긴 경로는 이유 한 줄 + 이름만 (영구 삭제는 아래 버튼으로)
if (tooLong.isNotEmpty) ...[
Text(tr('경로가 너무 길어 휴지통에 넣을 수 없습니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다).'),
style: const TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
for (final x in tooLong.take(30)) Text('· ${vBasename(x)}', style: const TextStyle(fontSize: 12)),
if (tooLong.length > 30) Text(trf('… 외 {0}개', [tooLong.length - 30])),
const SizedBox(height: 8),
],
for (final (x, why) in failed.take(30)) ...[
Text(vBasename(x), style: const TextStyle(fontWeight: FontWeight.w600)),
Text('${vDirname(x)} · $why', style: TextStyle(fontSize: 12, color: JjColors.textDim)),
const SizedBox(height: 4),
],
if (failed.length > 30) Text(trf('… 외 {0}개', [failed.length - 30])),
const SizedBox(height: 6),
// 이유를 이미 위에 적었으므로 짐작하는 말은 빼고, 실제로 지운 것이 있을 때만 "나머지는 지웠습니다"
if (failed.length + tooLong.length < paths.length) Text(tr('나머지는 지웠습니다.'), style: const TextStyle(fontSize: 12)),
]);
// U7: 긴 경로를 영구 삭제할지 묻는 것은 되돌릴 수 없는 일 - Enter 의 첫 초점은 [그대로 두기]. 아니면 알림만
final nukeLong = tooLong.isNotEmpty
? await confirmDialog(
context,
icon: failIcon,
title: () => failTitle,
danger: () => true,
cancelLabel: tr('그대로 두기'),
confirmLabel: () => trf('영구 삭제 ({0}개, 되돌릴 수 없음)', [tooLong.length]),
body: (ctx, set) => failBody(),
)
: await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
icon: failIcon,
title: Text(failTitle),
content: failBody(),
actions: [FilledButton(autofocus: true, onPressed: () => Navigator.pop(ctx), child: Text(tr('확인')))],
),
) ==
true;
// 144: 사용자가 [영구 삭제] 를 고른 긴 경로만 지운다
if (nukeLong == true) {
final notDeleted = [for (final (x, e) in await deleteForever(tooLong)) '${vBasename(x)}: ${deleteFailReason(e)}'];
ctl.host.snack(joinSentences([
if (tooLong.length > notDeleted.length) trf('{0}개를 영구 삭제했습니다.', [tooLong.length - notDeleted.length]),
if (notDeleted.isNotEmpty) trf('지우지 못함: {0}', [notDeleted.take(3).join(' · ')]),
]));
}
} else if (!permanent && context.mounted) {
final text = joinSentences([
if (recycled > 0) trf('{0}개 항목을 휴지통으로 보냈습니다.', [recycled]),
// 휴지통보다 커서 Windows 가 물었고 사용자가 영구 삭제를 골랐다
if (nuked > 0) trf('{0}개는 휴지통에 들어가지 않아 영구 삭제했습니다 (Windows 가 묻고 고른 대로).', [nuked]),
if (kept > 0) trf('{0}개는 지우지 않았습니다 (취소).', [kept]),
]);
// 148: 휴지통으로 보낸 것은 바로 [되돌리기]
showNotice(ScaffoldMessenger.of(context),
text: text,
action: trashed.isNotEmpty
? (tr('되돌리기'), () => _undoTrash(trashed))
: sent.isEmpty
? null
: (tr('되돌리기'), () => _undoRecycle(sent, since)));
}
// X-plore 숨기기: 영구로 지운 것 (그 안 포함) 은 숨긴 목록에서 뺀다. 휴지통으로 보낸 것은 되돌리기 뒤에도 숨김이 되게 남긴다
await ctl.followHidden(gone: [
for (final x in paths)
if (!sent.contains(x) && !undoable.contains(x) && !failed.any((f) => f.$1 == x)) x,
]);
for (final x in ctl.panes) {
paths.forEach(x.sel.forget);
if (sel.selecting && x.sel.isEmpty) sel.selecting = false;
x.expanded.removeWhere((d) => paths.any((s) => isSameOrInside(d, s)));
if (paths.any((s) => isSameOrInside(x.current, s))) x.current = vDirname(paths.first);
}
await ctl.refreshAll(paths.map(p.dirname));
}
/// X-plore 비교 e: Android 앱 휴지통 (보관 일이 0 이면 null - 바로 영구 삭제)
AppTrash? get appTrash => androidTrash() && ctl.c.settings.androidTrashDays > 0
? AppTrash([for (final (v, _) in ctl.local) v])
: null;
/// X-plore 비교 e: 방금 앱 휴지통으로 보낸 것을 원래 자리로
Future<void> _undoTrash(List<TrashItem> items) async {
final trash = appTrash;
if (trash == null) return;
final back = await undoTrash(trash, items);
await ctl.refreshAll(back.map(p.dirname));
ctl.host.snack(trf('{0}개를 원래 자리로 되살렸습니다', [back.length]));
}
/// X-plore 비교 e: [휴지통] - Android 는 앱 휴지통 화면, Windows 는 시스템 휴지통
Future<void> openTrash(BuildContext context) async {
final trash = appTrash;
if (trash == null) {
if (Platform.isWindows) {
await Process.start('explorer.exe', ['shell:RecycleBinFolder']);
} else {
ctl.host.snack(tr('휴지통이 꺼져 있습니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).'));
}
return;
}
final back = await Navigator.of(context).push<List<String>>(
MaterialPageRoute(builder: (_) => TrashPage(trash: trash, days: ctl.c.settings.androidTrashDays)));
if (back != null && back.isNotEmpty && context.mounted) await ctl.refreshAll(back.map(p.dirname));
}
/// 148: 방금 휴지통으로 보낸 것을 원래 자리로 (원래 자리에 같은 이름이 생겼으면 그것은 두고 알림)
Future<void> _undoRecycle(List<String> paths, DateTime since) async {
final (:back, failed: why) = undoRecycle(paths, since);
final failed = [for (final (x, e) in why) '${vBasename(x)}: $e'];
await ctl.refreshAll(paths.map(p.dirname));
for (final x in ctl.panes) {
x.changed();
}
ctl.host.snack(joinSentences([
if (back > 0) trf('{0}개 항목을 되돌렸습니다.', [back]),
if (failed.isNotEmpty) trf('되돌리지 못함: {0}', [failed.take(3).join(' · ')]),
]));
}
}

View File

@ -11,7 +11,13 @@ import '../monitor_page.dart';
import '../vault_page.dart' show openVault;
import 'explorer_controller.dart';
import 'explorer_pane.dart';
import 'delete_flow.dart';
import 'explorer_select.dart';
import 'name_dialog.dart';
import 'open_flow.dart';
import 'rsync_flow.dart';
import 'transfer_flow.dart';
import 'write_guard.dart';
/// 기능 버튼 (가운데 줄). 이름은 설정 (explorerButtons) 에 저장된다.
enum ExplorerButton {
@ -92,28 +98,21 @@ abstract interface class ExplorerCommands {
/// 창 · 메뉴를 띄울 곳
BuildContext get context;
/// 다음 지우기는 휴지통을 거치지 않음 (Shift+Delete · [삭제] 길게 누르기 - 창에서 한 번 더 확인)
abstract bool shiftDelete;
/// 복사 · 이동 갈 곳 (두 창 · 숨은 다른 창의 지금 폴더, 없으면 null - 담아 두기)
String? get otherDir;
/// 버튼이 다룰 것 (표시한 것, 없으면 지금 줄)
List<String> targetsOf(ExplorerPane pane);
Future<void> runRsync({required bool right, required bool left});
Future<void> play(List<String> paths, {bool internal = false, bool keepOrder = false});
Future<void> playFolder(String dir);
/// 두 창으로 보이는지
bool get dual;
/// 81: 빠른 보기 - [path] (없으면 지금 줄). 같은 폴더에 보이는 항목을 ← → 로
Future<void> quickLook(ExplorerPane pane, [String? path]);
Future<void> addMkv(List<String> paths);
/// 압축 안이면 막고 알림 (막았으면 true)
bool blockArchive(List<String> paths, {bool self = false});
Future<String?> askName(String title, String initial, {String? hint, bool selectAll = false});
Future<void> renameItem(ExplorerPane pane, String path);
Future<void> batchRename(ExplorerPane pane, List<String> paths);
Future<void> transfer(ExplorerPane pane, List<String> sources, {required bool move});
Future<void> paste(ExplorerPane pane, String dest);
Future<void> deleteItems(ExplorerPane pane, List<String> paths);
Future<void> openSearch(ExplorerPane pane);
Future<void> openStorage(ExplorerPane pane, String dir);
Future<void> openWifiShare(String dir);
Future<void> openTrash();
Future<void> sortDialog();
Future<void> historyDialog(ExplorerPane pane);
Future<void> openBookmarks(ExplorerPane pane);
@ -123,13 +122,19 @@ abstract interface class ExplorerCommands {
}
/// 약속 (E5d-4): 기능 버튼 하나 → 할 일 (가운데 줄 · 단축키 · 위 메뉴가 같이 부른다). 지금 창 ([ExplorerController.pane]) 에 한다.
/// 창 · 고르기 · 다시 읽기 · 새 폴더 · 숨은 항목은 여기서, 나머지는 [ExplorerCommands]. 못 하면 까닭을 알림 (Host)
/// 창 · 고르기 · 다시 읽기 · 새 폴더 · 숨은 항목은 여기서, 복사 · 이동 · 지우기 · rsync 는 흐름 모듈, 나머지는 [ExplorerCommands]. 못 하면 까닭을 알림 (Host)
class ExplorerButtons {
ExplorerButtons(this.ctl, this.sel, this.cmd);
ExplorerButtons(this.ctl, this.sel, this.cmd,
{required this.guard, required this.transfers, required this.deletes, required this.rsync, required this.opener});
final ExplorerController ctl;
final ExplorerSelect sel;
final ExplorerCommands cmd;
final ExplorerGuard guard;
final ExplorerTransfers transfers;
final ExplorerDeleteFlow deletes;
final ExplorerRsyncFlow rsync;
final ExplorerOpener opener;
/// X-plore b: 길게 누르면 하는 짝 동작의 이름 (없으면 null)
static String? longPressLabel(ExplorerButton b) => switch (b) {
@ -147,7 +152,7 @@ class ExplorerButtons {
case ExplorerButton.move:
return press(ExplorerButton.copy);
case ExplorerButton.delete:
cmd.shiftDelete = true;
deletes.shiftDelete = true;
return press(ExplorerButton.delete);
default:
return;
@ -167,30 +172,30 @@ class ExplorerButtons {
case ExplorerButton.monitor:
await MonitorPage.open(cmd.context, ctl.c);
case ExplorerButton.toRight:
await cmd.runRsync(right: true, left: false);
await rsync.run(cmd.context, right: true, left: false);
case ExplorerButton.toLeft:
await cmd.runRsync(right: false, left: true);
await rsync.run(cmd.context, right: false, left: true);
case ExplorerButton.both:
await cmd.runRsync(right: true, left: true);
await rsync.run(cmd.context, right: true, left: true);
case ExplorerButton.refresh:
// 183: 두 창 모두 (밖에서 지운 파일이 다른 창에 남지 않게)
await ctl.reloadOpen();
case ExplorerButton.play:
final t = cmd.targetsOf(pane);
final t = sel.targets(pane);
// 181: 강조한 것이 mp3 등 소리 파일이어도 그것을 (예전에는 동영상이 아니라고 빼고 폴더의 동영상을 틀었다)
final videos = t.where((x) => !vIsDirSync(x) && (isVideoFile(x) || isAudioFile(x))).toList();
if (videos.isNotEmpty) {
await cmd.play(videos, keepOrder: !pane.sel.isEmpty);
await opener.play(cmd.context, videos, keepOrder: !pane.sel.isEmpty);
} else {
await cmd.playFolder(t.length == 1 && vIsDirSync(t.first) ? t.first : pane.current);
await opener.playFolder(cmd.context, t.length == 1 && vIsDirSync(t.first) ? t.first : pane.current);
}
case ExplorerButton.addMkv:
final t = cmd.targetsOf(pane);
final t = sel.targets(pane);
await cmd.addMkv(t.isEmpty ? [pane.current] : t);
case ExplorerButton.newFolder:
// X-plore 비교 f: 펼친 압축 안 (지금 폴더가 압축 안의 폴더) 에는 만들지 않는다
if (cmd.blockArchive([pane.current], self: true)) return;
final name = await cmd.askName(tr('새 폴더'), '');
if (guard.blockArchive([pane.current], self: true)) return;
final name = await askName(cmd.context, tr('새 폴더'), '');
if (name == null) return;
try {
final parent = pane.current;
@ -211,7 +216,7 @@ class ExplorerButtons {
ctl.host.snack(trf('폴더를 만들 수 없습니다: {0}', [e]));
}
case ExplorerButton.rename:
final t = cmd.targetsOf(pane);
final t = sel.targets(pane);
if (t.isEmpty) {
ctl.host.snack(tr('이름을 바꿀 항목 하나를 고르세요.'));
return;
@ -220,13 +225,13 @@ class ExplorerButtons {
if (t.length > 1) return cmd.batchRename(pane, t);
await cmd.renameItem(pane, t.first);
case ExplorerButton.copy:
await cmd.transfer(pane, cmd.targetsOf(pane), move: false);
await transfers.transfer(cmd.context, pane, sel.targets(pane), move: false, otherDir: cmd.otherDir);
case ExplorerButton.move:
await cmd.transfer(pane, cmd.targetsOf(pane), move: true);
await transfers.transfer(cmd.context, pane, sel.targets(pane), move: true, otherDir: cmd.otherDir);
case ExplorerButton.paste:
await cmd.paste(pane, pane.current);
await transfers.paste(cmd.context, pane, pane.current);
case ExplorerButton.delete:
await cmd.deleteItems(pane, cmd.targetsOf(pane));
await deletes.run(cmd.context, pane, sel.targets(pane));
case ExplorerButton.search:
await cmd.openSearch(pane);
case ExplorerButton.storage:
@ -242,7 +247,7 @@ class ExplorerButtons {
await openVault(cmd.context, ctl.c);
}
case ExplorerButton.trash:
await cmd.openTrash();
await deletes.openTrash(cmd.context);
case ExplorerButton.sort:
await cmd.sortDialog();
case ExplorerButton.hidden:

View File

@ -0,0 +1,68 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../../app/transfer_job.dart';
import '../../core/file_ops.dart' show samePath;
import '../../core/vfs.dart' show vBasename, vDirname, vExists, vJoin;
import 'explorer_controller.dart';
/// 약속 (E5d-5): 지금 도는 복사 · 이동 · rsync 작업 (아래에서 올라오는 진행 패널의 목록). 23: 여럿이 함께 돈다.
/// 끝난 것은 진행 막대가 잠깐 100% 를 보인 뒤 ([dropDelay]) 목록에서 내려간다. 바뀌면 알림
class ExplorerJobs extends ChangeNotifier {
ExplorerJobs(this.ctl);
final ExplorerController ctl;
static const dropDelay = Duration(milliseconds: 1200);
List<TransferJob> list = const [];
bool get isEmpty => list.isEmpty;
/// 새 작업을 목록에 (끝난 것은 이때 뺀다)
void start(TransferJob job) {
list = [...list.where((j) => !j.finished), job];
notifyListeners();
}
/// 같은 것을 같은 곳으로 복사 · 이동하는 중인지 (그것만 막는다)
bool busyWith(List<String> sources, String dest) =>
list.any((j) => !j.finished && samePath(j.dest, dest) && j.sources.toSet().containsAll(sources));
/// [jobs] 를 잠깐 뒤 목록에서 뺀다
Future<void> dropLater(Iterable<TransferJob> jobs) async {
await Future<void>.delayed(dropDelay);
if (!ctl.host.alive || !list.any(jobs.contains)) return;
list = [for (final j in list) if (!jobs.contains(j)) j];
notifyListeners();
}
/// X-plore 숨기기: 옮긴 것은 숨긴 목록도 새 자리로 (원래 자리에 없고 새 자리에 있는 것만 - 같은 이름 바꾸기 · 실패는 그대로)
Future<void> followMoved(TransferJob job) async {
if (!job.move || job.contents || ctl.c.settings.explorerHiddenItems.isEmpty) return;
final moved = <(String, String)>[];
for (final s in job.sources) {
final to = vJoin(job.dest, vBasename(s));
if (!await vExists(s) && await vExists(to)) moved.add((s, to));
}
await ctl.followHidden(moved: moved);
}
/// 52: 이 화면을 떠난 사이에 시작된 (또는 앞의 화면이 시작한) 작업을 다시 보이고, 끝나면 새로 읽고 막대를 내린다
void adopt(TransferJob job) {
if (list.contains(job)) return;
list = [...list, job];
notifyListeners();
unawaited(job.done.then((_) async {
if (!ctl.host.alive) return;
await followMoved(job);
if (!ctl.host.alive) return;
await ctl.refreshAll([job.dest, ...job.sources.map(vDirname)]);
for (final x in ctl.panes) {
x.changed();
}
await dropLater([job]);
}));
}
}

View File

@ -120,6 +120,14 @@ class ExplorerSelect extends ChangeNotifier {
bool isMarked(ExplorerPane pane, String path) => pane.sel.isMarked(path);
/// 기능의 대상: 표시한 항목, 없으면 마지막으로 누른 항목 (저장 장치 맨 위는 빼고)
List<String> targets(ExplorerPane pane) {
if (!pane.sel.isEmpty) return pane.sel.marked;
final f = pane.focused;
if (f == null || samePath(f, pane.root)) return const [];
return [f];
}
Future<List<String>> _childPaths(ExplorerPane pane, String dir) async {
await ctl.load(pane, dir);
return [for (final x in pane.cache[dir] ?? const <FileEntry>[]) x.path];

View File

@ -0,0 +1,214 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:path/path.dart' as p;
import '../../core/file_attributes.dart' show readFileAttributes;
import '../../core/file_ops.dart' show FileEntry, FileOps, formatSize;
import '../../core/ftp.dart' show FtpRegistry;
import '../../core/name_clash.dart' show fileDateText;
import '../../core/playlist.dart' show isVideoFile;
import '../../core/sftp.dart' show SftpRegistry;
import '../../core/smb.dart' show SmbRegistry;
import '../../core/vfs.dart';
import '../../core/webdav.dart' show DavRegistry;
import '../../l10n/tr.dart';
import '../theme.dart';
import 'explorer_controller.dart';
import 'explorer_pane.dart';
import 'file_thumbs.dart' show VideoMeta;
// 약속 (E5d-5): 줄 메뉴의 항목 하나씩 하는 일 (정보 · 경로 복사 · 목록에서 숨기기 확인 · .nomedia) 과 메뉴 위 아이콘 줄.
// 묻는 창은 [취소] 면 아무것도 바꾸지 않는다. 못 하면 까닭을 알린다 (Host)
/// 77: [경로 복사] 에 넣을 것 - 실제 경로 (Android 도 "SD 카드 ›" 가 아닌 /storage/…), WebDAV 는 서버 주소 (아이디 · 비밀번호는 빼고)
String copyablePath(ExplorerController ctl, String path) {
if (!isDav(path)) return path;
final d = DavPath.parse(path);
// 88: SFTP 는 sftp://아이디@서버:포트/경로 (시작 폴더를 비웠으면 홈 기준 ~/)
if (d.isSftp) {
final s = SftpRegistry.server(d.server);
if (s == null) return ctl.displayPath(path);
final base = s.root.isEmpty ? '/~' : (s.root.startsWith('/') ? s.root : '/${s.root}');
return 'sftp://${s.user.isEmpty ? '' : '${s.user}@'}${s.host}${s.port == 22 ? '' : ':${s.port}'}$base${d.isRoot ? '' : d.rel}';
}
// l: FTP 는 ftp://아이디@서버:포트/경로 (암호화 990 은 ftps://, 비밀번호 없음)
if (d.isFtp) {
final s = FtpRegistry.server(d.server);
if (s == null) return ctl.displayPath(path);
final implicit = s.security == 'implicit';
final r = s.root.trim().replaceAll(RegExp(r'^/+|/+$'), '');
return '${implicit ? 'ftps' : 'ftp'}://${s.user.isEmpty ? '' : '${Uri.encodeComponent(s.user)}@'}${s.host}'
'${s.port == (implicit ? 990 : 21) ? '' : ':${s.port}'}${r.isEmpty ? '' : '/$r'}${d.isRoot ? '' : d.rel}';
}
// 88: SMB 는 smb://서버/공유/경로 (아이디 · 비밀번호 없음)
if (d.isSmb) {
final s = SmbRegistry.server(d.server);
if (s == null) return ctl.displayPath(path);
final r = s.root.trim().replaceAll(r'\', '/').replaceAll(RegExp(r'^/+|/+$'), '');
return 'smb://${s.host}${s.port == 445 ? '' : ':${s.port}'}/${s.share}${r.isEmpty ? '' : '/$r'}${d.isRoot ? '' : d.rel}';
}
try {
return DavRegistry.client(d.server).uriOf(d.rel).replace(userInfo: '').toString();
} catch (_) {
return ctl.displayPath(path);
}
}
/// 정보 창 - 경로 · 크기 · 만든 · 바뀐 날 · 속성 · 동영상 정보 (보기만)
Future<void> showFileInfo(BuildContext context, ExplorerController ctl, FileEntry e) async {
final size = e.isDir ? await FileOps.totalSize([e.path]) : e.size;
final meta = !e.isDir && !isDav(e.path) && isVideoFile(e.path) ? await VideoMeta.of(ctl.c, e.path) : null;
// 관리자 지시 10/18: 만든 때 · 숨김 · 읽기 전용 · 시스템 (보기만, 이 기기의 것만)
final attrs = isDav(e.path) ? null : readFileAttributes(e.path);
if (!context.mounted) return;
await showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(e.name),
content: SelectableText([
e.path,
'${tr('크기')}: ${formatSize(size)}',
if (attrs?.created != null) '${tr('만든 날')}: ${fileDateText(attrs!.created!)}',
if (e.modified.year > 1) '${tr('바뀐 날')}: ${fileDateText(e.modified)}',
if (attrs != null) '${tr('속성')}: ${attrs.labels.isEmpty ? tr('없음') : attrs.labels.join(' · ')}',
if (meta != null && meta.isNotEmpty) '${tr('동영상')}: $meta',
].join('\n')),
actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('닫기')))],
),
);
}
/// X-plore 숨기기: 목록에서 숨기기 전에 무엇인지 알리고 묻기 ([다음부터 묻지 않기] 는 설정에)
Future<bool> confirmAppHide(BuildContext context, ExplorerController ctl, List<String> targets) async {
var dontAsk = false;
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, set) => AlertDialog(
scrollable: true,
title: Text(targets.length > 1 ? trf('{0}개 목록에서 숨기기', [targets.length]) : tr('목록에서 숨기기')),
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
if (targets.length == 1) Text(vBasename(targets.single), style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 6),
Text(tr('이 앱의 파일 탐색기 목록에서만 빠집니다. 파일은 지우거나 옮기거나 이름을 바꾸지 않고, 다른 앱 · 갤러리에서는 그대로 보입니다.')),
const SizedBox(height: 6),
Text(tr('목록 맨 아래의 [숨긴 항목 보이기] 나 환경 설정 > 파일 탐색기에서 다시 볼 수 있습니다.'),
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
value: dontAsk,
onChanged: (v) => set(() => dontAsk = v ?? false),
title: Text(tr('다음부터 묻지 않기')),
),
]),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('숨기기'))),
],
),
),
);
if (ok != true) return false;
if (dontAsk) await ctl.c.updateSettings((s) => s.explorerHideConfirm = false);
return true;
}
/// X-plore 맞추기 (관리자 지시 10/20): 폴더에 빈 .nomedia 파일을 넣거나 지운다 (Android 갤러리 · 음악 앱에서 숨기기 · 다시 보이기).
/// 그 폴더의 파일은 그대로 - 무엇이 바뀌는지 먼저 묻는다. 갤러리에 반영되는 때는 그 앱이 다시 읽을 때다
Future<void> setNomedia(BuildContext context, ExplorerController ctl, ExplorerPane pane, String dir, {required bool on}) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(on ? tr('갤러리 · 음악 앱에서 숨기기 (.nomedia)') : tr('갤러리 · 음악 앱에 다시 보이기 (.nomedia 지우기)')),
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(vBasename(dir), style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 6),
Text(on
? tr('이 폴더에 빈 .nomedia 파일을 만듭니다. 갤러리 · 음악 앱이 이 폴더 (안의 폴더 포함) 의 그림 · 동영상 · 음악을 보이지 않게 됩니다.')
: tr('이 폴더의 .nomedia 파일을 지웁니다. 갤러리 · 음악 앱이 이 폴더의 그림 · 동영상 · 음악을 다시 보이게 됩니다.')),
const SizedBox(height: 6),
Text(tr('폴더 안의 파일은 바꾸지 않습니다. 갤러리에 반영되기까지 잠깐 걸릴 수 있습니다.'),
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
]),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(on ? tr('숨기기') : tr('다시 보이기'))),
],
),
);
if (ok != true || !context.mounted) return;
final f = File(p.join(dir, '.nomedia'));
try {
if (on) {
if (!await f.exists()) await f.writeAsBytes(const []);
} else if (await f.exists()) {
await f.delete();
}
} catch (err) {
ctl.host.snack(trf('바꾸지 못했습니다: {0}', [err]));
return;
}
// 관리자 지시 10/20: 갤러리가 바로 알도록 .nomedia 와 그 폴더를 다시 읽게 (넣을 때 · 지울 때 모두, 실패해도 조용히)
await ctl.c.services.shell.mediaScan([f.path, dir]);
await ctl.refreshAll([dir]);
pane.changed();
ctl.host.snack(on ? tr('갤러리 · 음악 앱에서 숨겼습니다 (.nomedia). 파일은 그대로입니다.') : tr('.nomedia 를 지웠습니다. 갤러리 · 음악 앱에 다시 보입니다.'));
}
/// 77: 길게 누르기 메뉴 위의 아이콘 줄 - (id, 아이콘, 짧은 이름, 풍선 도움말, 누를 수 있음).
/// 폰에서도 무엇인지 알 수 있게 아이콘 아래에 짧은 이름
class MenuIconRow extends PopupMenuEntry<String> {
final List<(String, IconData, String, String, bool)> items;
const MenuIconRow(this.items, {super.key});
@override
double get height => 60;
@override
bool represents(String? value) => false;
@override
State<MenuIconRow> createState() => MenuIconRowState();
}
class MenuIconRowState extends State<MenuIconRow> {
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: Row(mainAxisSize: MainAxisSize.min, children: [
for (final (id, icon, label, tip, on) in widget.items)
Tooltip(
message: tip,
child: InkWell(
borderRadius: BorderRadius.circular(6),
onTap: on ? () => Navigator.pop(context, id) : null,
child: Semantics(
button: true,
enabled: on,
label: tip,
excludeSemantics: true,
child: SizedBox(
width: 56,
height: 56,
child: Opacity(
opacity: on ? 1 : 0.38,
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Icon(icon, size: 20),
const SizedBox(height: 2),
FittedBox(
fit: BoxFit.scaleDown,
child: Text(label, maxLines: 1, style: const TextStyle(fontSize: 11)),
),
]),
),
),
),
),
),
]),
);
}

View File

@ -0,0 +1,328 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show Clipboard, ClipboardData;
import 'package:path/path.dart' as p;
import '../../app/settings.dart' show FolderBookmark;
import '../../core/app_hidden.dart';
import '../../core/archive_ops.dart' show archiveKindOf;
import '../../core/file_ops.dart';
import '../../core/playlist.dart' show isAudioFile, isVideoFile;
import '../../core/reader_sources.dart' show extOf, zipExtensions;
import '../../core/vfs.dart';
import '../../l10n/tr.dart';
import '../archive_ui.dart' show createZipFlow;
import '../file_tools.dart' show showFileHash, showFolderProperties;
import '../notice.dart';
import '../sqlite_viewer.dart' show openSqliteViewer, sqliteExtensions;
import '../theme.dart';
import '../vault_page.dart' show openVault;
import 'delete_flow.dart';
import 'explorer_buttons.dart';
import 'explorer_controller.dart';
import 'explorer_pane.dart';
import 'explorer_select.dart';
import 'item_actions.dart';
import 'open_flow.dart';
import 'transfer_flow.dart';
import 'write_guard.dart';
/// 약속 (E5d-5): 줄을 길게 누르거나 오른쪽 클릭하면 뜨는 메뉴. 누른 것이 표시한 것 중 하나면 표시한 것 모두에 한다 (77).
/// 위 아이콘 줄 (복사 · 이동 · 이름 변경 · 삭제 · 공유) + 상황별 항목 (Rsync 화면은 폴더에 맞는 것만, 압축 안은 새 파일을 만드는 것은 숨김).
/// 고른 것은 흐름 모듈 (열기 · 복사 · 이동 · 지우기) 이나 [ExplorerCommands] 로 한다. 고르지 않으면 아무것도 안 함
class ExplorerItemMenu {
ExplorerItemMenu(this.ctl, this.sel, this.cmd,
{required this.guard, required this.transfers, required this.deletes, required this.opener, required this.nomedia});
final ExplorerController ctl;
final ExplorerSelect sel;
final ExplorerCommands cmd;
final ExplorerGuard guard;
final ExplorerTransfers transfers;
final ExplorerDeleteFlow deletes;
final ExplorerOpener opener;
/// .nomedia 항목을 보일 기기인지 (Android)
final bool Function() nomedia;
Future<void> show(BuildContext context, ExplorerPane pane, FileEntry e, Offset at) async {
ctl
..active = ctl.panes.indexOf(pane)
..update();
pane.focused = e.path;
pane.changed();
final video = !e.isDir && isVideoFile(e.path);
final audio = !e.isDir && isAudioFile(e.path);
final dav = isDav(e.path);
// 그림 → PDF: 표시한 것 중 그림 (누른 것이 표시에 없으면 누른 것만)
final viewer = ctl.c.settings.components.contains('viewer');
final picked = pane.sel.has(e.path) ? pane.sel.marked : [e.path];
final images = [for (final x in picked) if (ctl.c.settings.imageExts.contains(extOf(x))) x];
final ext = extOf(e.path);
final viewable = viewer && !e.isDir &&
(ctl.c.settings.imageExts.contains(ext) || ext == 'pdf' || zipExtensions.contains(ext));
final dual = cmd.dual;
// 77: 누른 것이 표시한 것 중 하나면 표시한 것 모두에 (복사 · 이동 · 삭제 · 공유 · 경로 복사)
final targets = sel.isMarked(pane, e.path) && pane.sel.marked.length > 1 ? pane.sel.marked : [e.path];
final many = targets.length > 1;
// 데이터 검토 (10/18): 펼친 압축 안의 것은 실제 파일이 아니다 - 그것으로 새 파일을 만들거나 내놓는 항목 (ZIP 묶기 · Wi-Fi 공유 · PDF) 은 숨긴다
final inArchive = targets.any(guard.insideArchive) || guard.insideArchive(e.path);
// 25: Rsync 화면은 폴더만 보이므로 폴더에 맞는 것만 (고르기 · 열기 · 이름 변경 · 삭제 · 정보)
final items = ctl.rsync
? <(String, IconData, String)>[
('pickRsync', Icons.check_circle_outline,
sel.isMarked(pane, e.path) ? tr('고르기 취소') : tr('rsync 에 고르기')),
('open', Icons.folder_open, tr('열기')),
('openOther', Icons.vertical_split_outlined, tr('다른 창에서 열기')),
('rename', Icons.drive_file_rename_outline, tr('이름 변경')),
('delete', Icons.delete_outline, tr('삭제')),
if (!dav) ('reveal', Icons.folder_outlined, tr('파일 관리자에서 보기')),
('info', Icons.info_outline, tr('정보')),
]
: <(String, IconData, String)>[
// 77: 복사 · 이동 · 이름 변경 · 삭제 · 공유는 위의 아이콘 줄 ([_menuIcons]). 아래는 상황별 항목
// 74: 폰에서 범위 고르기 - 첫 항목을 표시한 뒤 마지막 항목을 길게 눌러
if (sel.selecting && sel.canRange(pane, e)) ('range', Icons.unfold_more, tr('여기까지 범위 고르기')),
if (e.isDir) ('open', Icons.folder_open, tr('열기')),
// 81: 빠른 보기 (PC 는 Space · TC 는 F3)
if (!e.isDir) ('quickLook', Icons.preview_outlined, tr('미리 보기')),
// 86: 압축 풀기 (ZIP · 7z · RAR) · ZIP 으로 묶기 (표시한 것 모두)
if (!e.isDir && archiveKindOf(e.path) != null) ('extract', Icons.unarchive_outlined, tr('압축 풀기')),
if (!dav && !inArchive) ('zip', Icons.archive_outlined, many ? trf('{0}개 ZIP 으로 묶기', [targets.length]) : tr('ZIP 으로 묶기')),
// 87: 저장 공간 분석 (이 폴더 아래의 큰 폴더 · 큰 파일)
if (e.isDir) ('storage', Icons.pie_chart_outline, tr('저장 공간 분석')),
// 89: 이 폴더를 Wi-Fi 로 공유 (이 기기의 폴더만)
if (e.isDir && !dav && !ctl.rsync && !inArchive) ('wifiShare', Icons.wifi_tethering, tr('Wi-Fi 로 공유')),
// 80: 이 폴더 크기를 지금 계산 (설정이 "안 함" 이어도)
if (e.isDir) ('folderSize', Icons.data_usage, ctl.sizes.busy(e.path) ? tr('폴더 크기 계산 멈추기') : tr('폴더 크기 계산')),
// X-plore P3: 폴더 속성 (안의 파일 · 폴더 수 · 크기 · 만든 · 바뀐 때) · 파일 해시 (MD5 · SHA-256) - 이 기기의 것만 (원격은 다 받아야 해서 뺌)
if (e.isDir && !dav) ('props', Icons.fact_check_outlined, tr('폴더 속성')),
if (!e.isDir && !dav) ('hash', Icons.tag, tr('해시 (MD5 · SHA-256)')),
if (e.isDir && dual) ('openOther', Icons.vertical_split_outlined, tr('다른 창에서 열기')),
if (e.isDir) ('playFolder', Icons.play_circle_outline, tr('이 폴더의 동영상 재생')),
// X-plore 맞추기 (관리자 지시 10/20): 이 폴더의 그림을 [계속 보기] 로
if (e.isDir && viewer) ('slideshow', Icons.slideshow_outlined, tr('사진 슬라이드쇼')),
// X-plore 비교 g: 그림 · 동영상을 썸네일 격자로
if (e.isDir && !dav) ('grid', Icons.grid_view, tr('격자로 보기 (그림 · 동영상)')),
if (video || audio) ('playInternal', Icons.play_circle_outline, tr('내장 플레이어로 재생')),
if (viewable) ('view', Icons.visibility_outlined, tr('보기')),
// X-plore 비교 h: 어떤 파일이든 글로 열어 보기 (읽기 전용)
if (!e.isDir && !video && !audio) ('text', Icons.article_outlined, tr('글 뷰어로 보기')),
// X-plore 비교 5-1 ④ (관리자 지시 10/21): SQLite (읽기 전용)
if (!e.isDir && !inArchive && sqliteExtensions.contains(ext)) ('sqlite', Icons.table_chart_outlined, tr('SQLite 보기 (읽기 전용)')),
// X-plore 비교 5-2 (사용자 결정 10/21): 암호 보관함에 넣기. 2단계 (관리자 지시 10/21): 폴더째 (안의 구조 그대로) · 원격 파일도 (받아서 넣음)
if (ctl.c.settings.vaultEnabled && !inArchive)
('vaultAdd', Icons.enhanced_encryption_outlined, many ? trf('{0}개 보관함에 넣기', [targets.length]) : tr('보관함에 넣기')),
if (!e.isDir) ('openWith', Icons.open_in_new, tr('다른 앱으로 열기')),
if (!e.isDir && !video) ('openDefault', Icons.launch, tr('기본 앱으로 열기')),
if (video && !dav) ('addMkv', Icons.playlist_add, tr('MKV 목록에 추가')),
if (viewer && images.isNotEmpty && !inArchive)
('toPdf', Icons.picture_as_pdf_outlined,
images.length == 1 ? tr('PDF 로 만들기') : trf('그림 {0}장을 PDF 로 만들기', [images.length])),
('select', Icons.check_circle_outline, tr('선택')),
// X-plore 숨기기: 목록에서만 숨긴다 (파일은 그대로) · 숨긴 것을 보이고 있으면 풀기
targets.every(ctl.appHidden)
? ('appUnhide', Icons.visibility_outlined, many ? trf('{0}개 숨기기 풀기', [targets.length]) : tr('숨기기 풀기'))
: ('appHide', Icons.visibility_off_outlined, many ? trf('{0}개 목록에서 숨기기', [targets.length]) : tr('목록에서 숨기기')),
// X-plore 맞추기 (관리자 지시 10/20): 폴더에 .nomedia 를 두면 갤러리 · 음악 앱이 그 안의 그림 · 동영상 · 음악을 보이지 않는다 (Android)
if (e.isDir && !dav && !inArchive && nomedia())
File(p.join(e.path, '.nomedia')).existsSync()
? ('nomediaOff', Icons.photo_library_outlined, tr('갤러리 · 음악 앱에 다시 보이기 (.nomedia 지우기)'))
: ('nomediaOn', Icons.hide_image_outlined, tr('갤러리 · 음악 앱에서 숨기기 (.nomedia)')),
// 두 창이면 아이콘 줄의 복사 · 이동은 다른 창으로 - 담아 두었다가 원하는 폴더에 붙여넣기는 여기
if (dual) ('clipCopy', Icons.content_copy, tr('복사 (붙여넣기로)')),
if (dual) ('clipMove', Icons.content_cut, tr('이동 (붙여넣기로)')),
if (transfers.clip.isNotEmpty)
('paste', Icons.content_paste, trf('{0} 에 붙여넣기 ({1}개)', [vBasename(e.isDir ? e.path : vDirname(e.path)), transfers.clip.items.length])),
('copyPath', Icons.link, many ? trf('경로 복사 ({0}개)', [targets.length]) : tr('경로 복사')),
if (e.isDir && !ctl.c.settings.folderBookmarks.any((b) => !b.isPair && samePath(b.path, e.path)))
('bookmark', Icons.star_outline, tr('즐겨찾기에 추가')),
if (!dav && (!Platform.isAndroid || e.isDir)) ('reveal', Icons.folder_outlined, tr('파일 관리자에서 보기')),
('info', Icons.info_outline, tr('정보')),
];
// 77: 위의 아이콘 줄 (X-plore · One Commander 처럼 자주 쓰는 것을 한 줄에)
final icons = ctl.rsync
? const <(String, IconData, String, String, bool)>[]
: <(String, IconData, String, String, bool)>[
(dual ? 'copy' : 'clipCopy', Icons.copy_outlined, tr('복사'), dual ? tr('다른 창으로 복사') : tr('복사 (붙여넣기로)'), true),
(dual ? 'move' : 'clipMove', Icons.drive_file_move_outline, tr('이동'), dual ? tr('다른 창으로 이동') : tr('이동 (붙여넣기로)'), true),
// 78: 여럿이면 일괄 이름 바꾸기
('rename', Icons.drive_file_rename_outline, tr('이름 변경'), many ? trf('{0}개 이름 일괄 바꾸기', [targets.length]) : tr('이름 변경'), true),
('delete', Icons.delete_outline, tr('삭제'), many ? trf('{0}개 삭제', [targets.length]) : tr('삭제'), true),
if (ctl.c.services.shell.canShare) ('share', Icons.share_outlined, tr('공유'), tr('공유 (다른 앱으로 보내기)'), targets.any((x) => !vIsDirSync(x))),
];
final size = MediaQuery.sizeOf(context);
final pick = await showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(at.dx, at.dy, size.width - at.dx, size.height - at.dy),
items: [
// 77: 여러 개를 골랐으면 무엇에 하는지 먼저
if (many)
PopupMenuItem<String>(
enabled: false,
height: 32,
child: Text(trf('{0}개 항목', [targets.length]),
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: JjColors.accent)),
),
if (icons.isNotEmpty) ...[MenuIconRow(icons), const PopupMenuDivider(height: 1)],
for (final (id, icon, label) in items)
PopupMenuItem(
value: id,
child: Row(children: [
Icon(icon, size: 18),
const SizedBox(width: 12),
Flexible(child: Text(label, overflow: TextOverflow.ellipsis)),
]),
),
],
);
if (pick == null || !context.mounted) return;
switch (pick) {
case 'pickRsync':
await sel.pick(pane, e);
case 'open':
await ctl.goTo(pane, e.path);
case 'openOther':
final other = ctl.panes[1 - ctl.panes.indexOf(pane)];
await ctl.goTo(other, e.path);
ctl
..active = ctl.panes.indexOf(other)
..update();
case 'playFolder':
await opener.playFolder(context, e.path);
case 'slideshow':
await opener.slideshow(context, pane, e.path);
case 'playInternal':
await opener.play(context, [e.path], internal: true);
case 'grid':
await opener.grid(context, pane, e.path);
case 'text':
await opener.viewText(context, e.path);
case 'sqlite':
await openSqliteViewer(context, ctl.c, e.path);
case 'vaultAdd':
await openVault(context, ctl.c, add: targets, deleteOriginals: (paths, {required permanent}) {
// 관리자 지시 (10/21): [영구 삭제] 를 고르면 휴지통을 거치지 않음 (Shift+Delete 와 같은 길 - 탐색기의 영구 삭제 확인 창을 한 번 더)
deletes.shiftDelete = permanent;
return deletes.run(context, pane, paths);
});
await ctl.refreshAll([vDirname(e.path)]);
case 'openWith':
final local = await opener.fetch(context, e.path);
if (local != null && !await ctl.c.services.shell.openWith(local, choose: true)) ctl.host.snack(ExplorerOpener.noAppMessage(e.path));
case 'openDefault':
final local = await opener.fetch(context, e.path);
if (local != null && !await ctl.c.services.shell.openWith(local)) ctl.host.snack(ExplorerOpener.noAppMessage(e.path));
case 'addMkv':
await cmd.addMkv([e.path]);
case 'range':
sel.markRange(pane, e);
case 'select':
sel.setSelecting(true);
if (!sel.isMarked(pane, e.path)) await sel.toggleMark(pane, e);
case 'quickLook':
await cmd.quickLook(pane, e.path);
case 'extract':
await opener.extractArchive(context, e.path, pane);
case 'storage':
await cmd.openStorage(pane, e.path);
case 'wifiShare':
await cmd.openWifiShare(e.path);
case 'zip':
final items = outermostPaths(sel.isMarked(pane, e.path) && pane.sel.marked.length > 1 ? pane.sel.marked : [e.path]);
final other = cmd.dual ? ctl.panes[1 - ctl.panes.indexOf(pane)].current : null;
final made = await createZipFlow(context, ctl.c, items, dir: vDirname(items.first), otherDir: other);
if (made != null && context.mounted) {
await ctl.refreshAll([vDirname(made)]);
pane.focused = made;
pane.changed();
}
case 'appHide':
if (ctl.c.settings.explorerHideConfirm && !await confirmAppHide(context, ctl, targets)) return;
await ctl.c.updateSettings((s) => s.explorerHiddenItems = [
...s.explorerHiddenItems,
for (final x in targets)
if (!ctl.appHidden(x)) x,
]);
for (final q in ctl.panes) {
targets.forEach(q.sel.forget);
q.changed();
}
ctl.update();
ctl.host.snack(ctl.showAppHidden
? trf('목록에서 숨겼습니다 ({0}개). 파일은 그대로입니다.', [targets.length])
: trf('목록에서 숨겼습니다 ({0}개). 파일은 그대로이고, 목록 맨 아래의 [숨긴 항목 보이기] 로 다시 볼 수 있습니다.', [targets.length]));
case 'appUnhide':
final keys = {for (final x in targets) HiddenList.keyOf(x)};
await ctl.c.updateSettings((s) => s.explorerHiddenItems = [
for (final x in s.explorerHiddenItems)
if (!keys.contains(HiddenList.keyOf(x))) x,
]);
for (final q in ctl.panes) {
q.changed();
}
ctl.update();
case 'nomediaOn' || 'nomediaOff':
await setNomedia(context, ctl, pane, e.path, on: pick == 'nomediaOn');
case 'props':
await showFolderProperties(context, e.path);
case 'hash':
await showFileHash(context, e.path);
case 'folderSize':
if (ctl.sizes.busy(e.path)) {
ctl.sizes.cancel(e.path);
break;
}
// 사용자가 직접 시킨 것: 다른 폴더로 가도 끝까지 (멈추기는 아래 알림 · 같은 메뉴)
ctl.sizes
..forget(e.path)
..request(e.path);
if (context.mounted) {
showNotice(ScaffoldMessenger.of(context),
text: isDav(e.path)
? trf('"{0}" 크기를 계산합니다. 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다.', [e.name])
: trf('"{0}" 크기를 계산합니다.', [e.name]),
action: (tr('취소'), () => ctl.sizes.cancel(e.path)));
}
case 'rename':
if (sel.isMarked(pane, e.path) && pane.sel.marked.length > 1) {
await cmd.batchRename(pane, pane.sel.marked);
} else {
await cmd.renameItem(pane, e.path);
}
case 'copy':
await transfers.transfer(context, pane, sel.isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: false, otherDir: cmd.otherDir);
case 'move':
await transfers.transfer(context, pane, sel.isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: true, otherDir: cmd.otherDir);
case 'clipCopy':
transfers.toClipboard(sel.isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: false);
case 'clipMove':
transfers.toClipboard(sel.isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: true);
case 'paste':
await transfers.paste(context, pane, e.isDir ? e.path : vDirname(e.path));
case 'delete':
// 77: 메뉴 머리의 "n개 항목" 과 같게 (예전에는 표시한 것이 여럿이어도 누른 것 하나만 지웠다)
await deletes.run(context, pane, targets);
case 'share':
await opener.share(context, targets);
case 'copyPath':
final paths = targets.map((x) => copyablePath(ctl, x)).toList();
await Clipboard.setData(ClipboardData(text: paths.join('\n')));
ctl.host.snack(many ? trf('경로 {0}개를 복사했습니다', [paths.length]) : trf('경로를 복사했습니다: {0}', [paths.single]));
case 'bookmark':
final name = vBasename(e.path).isEmpty ? e.path : vBasename(e.path);
await ctl.c.updateSettings((s) => s.folderBookmarks = [...s.folderBookmarks, FolderBookmark(name: name, path: e.path)]);
ctl.host.snack(trf('즐겨찾기에 넣었습니다: {0}', [name]));
ctl.update();
case 'view':
await opener.open(context, e.path, pane: pane);
case 'toPdf':
await opener.makePdf(context, images);
case 'reveal':
await ctl.c.services.shell.revealFile(e.path);
case 'info':
await showFileInfo(context, ctl, e);
}
}
}

View File

@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import '../../l10n/tr.dart';
/// 약속 (E5d-5): 이름 하나를 묻는 창 (새 폴더 · 이름 변경 · PDF 이름 등). 처음 값은 [initial] (확장자 앞까지 고른 채 -
/// [selectAll] 이면 모두). 빈 칸 · [취소] 는 null, 아니면 앞뒤 빈칸을 뺀 이름
Future<String?> askName(BuildContext context, String title, String initial, {String? hint, bool selectAll = false}) {
final ctl = TextEditingController(text: initial);
final dot = initial.lastIndexOf('.');
ctl.selection = TextSelection(baseOffset: 0, extentOffset: dot > 0 && !selectAll ? dot : initial.length);
return showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(title),
content: TextField(
controller: ctl,
autofocus: true,
decoration: InputDecoration(hintText: hint ?? tr('이름')),
onSubmitted: (v) => Navigator.pop(ctx, v.trim().isEmpty ? null : v.trim()),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('취소'))),
FilledButton(
onPressed: () => Navigator.pop(ctx, ctl.text.trim().isEmpty ? null : ctl.text.trim()),
child: Text(tr('확인'))),
],
),
);
}

View File

@ -0,0 +1,320 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:path/path.dart' as p;
import '../../app/component_store.dart';
import '../../app/file_opener.dart';
import '../../core/file_ops.dart';
import '../../core/hwp_source.dart';
import '../../core/playlist.dart' show isAudioFile, isVideoFile;
import '../../core/reader_sources.dart';
import '../../core/vfs.dart';
import '../file_error.dart' show explainFileError;
import '../player_page.dart' show playFiles;
import '../quick_look.dart' show textExtensions;
import '../../l10n/tr.dart';
import '../../platform/android/hwp_pdf_ffi.dart' show RhwpEngine;
import '../archive_ui.dart';
import '../dav_external.dart';
import '../explorer_grid.dart';
import '../progress_dialog.dart';
import '../reader_page.dart';
import '../text_viewer.dart';
import '../zip_page.dart';
import 'explorer_controller.dart';
import 'explorer_pane.dart';
import 'explorer_select.dart';
import 'file_thumbs.dart';
import 'name_dialog.dart';
import 'write_guard.dart';
/// 약속 (E5d-5): 파일을 여는 일. 여는 길은 file_opener ([openRouteFor]) 가 고르고, 여기는 그 길대로 실제로 연다 -
/// 내장 플레이어 · 그림 보기 (같은 폴더를 지금 순서로) · PDF · HWP (Android, 못 열면 다른 앱) · 문서 미리 보기 (PDF 로 바꿔) ·
/// ZIP (만화 보기 · 목록) · 압축 풀기 · 다른 앱. 압축 안의 파일은 꺼내서 ([ExplorerGuard.extractOne]), WebDAV 등 원격은 받아서 ([fetch]).
/// 열 앱이 없으면 글 파일은 글 뷰어로, 아니면 까닭을 알린다 (Host). [otherDirOf]: 압축을 풀 다른 창의 폴더 (없으면 null)
class ExplorerOpener {
ExplorerOpener(this.ctl, this.sel, this.guard, {required this.otherDirOf});
final ExplorerController ctl;
final ExplorerSelect sel;
final ExplorerGuard guard;
final String? Function(ExplorerPane pane) otherDirOf;
/// 다른 앱으로 열지 못했을 때: 음악 · 동영상이면 내장 플레이어를 알려 준다 (29)
static String noAppMessage(String path) => isAudioFile(path) || isVideoFile(path)
? tr('이 형식을 여는 앱이 없습니다. 길게 누르거나 오른쪽 클릭 → [내장 플레이어로 재생] 으로 들어 보세요.')
: tr('이 파일을 열 수 있는 앱이 없습니다.');
/// 파일 열기 (기본 동작): 동영상은 내장 플레이어 (또는 확장자별 프로그램), 그 밖은 기본 연결 프로그램
Future<void> open(BuildContext context, String path, {ExplorerPane? pane}) async {
// X-plore 비교 f: 압축 안의 파일 - 그 파일만 임시 폴더로 꺼내 연다
if (guard.insideArchive(path)) {
final local = await guard.extractOne(path);
if (local == null || !context.mounted) return;
path = local;
}
// 여는 길은 file_opener (E5c) 가 고른다
switch (openRouteFor(path,
components: ctl.c.settings.components,
imageExts: ctl.c.settings.imageExts,
android: Platform.isAndroid,
windows: Platform.isWindows,
hwpEngine: RhwpEngine.available)) {
case OpenRoute.player:
return play(context, [path]);
case OpenRoute.extractArchive:
return extractArchive(context, path, pane ?? ctl.pane);
case OpenRoute.images:
return viewImages(context, path, pane);
case OpenRoute.pdf:
return viewPdf(context, path);
case OpenRoute.zip:
return openZipFile(context, path, pane);
// 관리자 지시 (10/20): Android HWP · HWPX 는 앱 안에서 PDF 로 바꿔 본다. 못 열면 이유를 알리고 다른 앱으로
case OpenRoute.hwp:
if (await viewHwp(context, path)) return;
case OpenRoute.docPreview:
return viewDoc(context, path);
case OpenRoute.external:
}
if (!context.mounted) return;
final local = await fetch(context, path);
if (local == null) return;
final ok = await ctl.c.services.shell.openWith(local);
if (ok || !context.mounted) return;
// X-plore 비교 h: 글 파일을 열 앱이 없으면 앱 안의 글 뷰어로
if (textExtensions.contains(extOf(path))) return viewText(context, local);
ctl.host.snack(noAppMessage(path));
}
/// X-plore 비교 h: 글 뷰어 (WebDAV 등은 받은 뒤)
Future<void> viewText(BuildContext context, String path) async {
final local = await fetch(context, path);
if (local == null || !context.mounted) return;
await Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => TextViewerPage(path: local)));
}
/// X-plore 비교 g: 폴더의 그림 · 동영상을 격자로. 길게 누른 것은 탐색기에서 그 자리로
Future<void> grid(BuildContext context, ExplorerPane pane, String dir) async {
await ctl.load(pane, dir);
if (!context.mounted) return;
final list = sel.shown(pane, dir);
final at = await Navigator.of(context).push<String>(MaterialPageRoute(
builder: (_) => ExplorerGridPage(
title: vBasename(dir).isEmpty ? dir : vBasename(dir),
entries: list,
imageExts: ctl.c.settings.imageExts,
videoThumb: (e) => BigVideoThumbs.of(ctl.c, e),
onOpen: (e) => open(context, e.path, pane: pane),
),
));
if (at == null || !context.mounted) return;
await ctl.goTo(pane, dir);
pane.focused = at;
pane.changed();
}
/// WebDAV 파일은 열거나 재생하려면 임시 폴더로 받는다 (dav_external.dart)
Future<String?> fetch(BuildContext context, String path) => fetchDav(context, ctl.c, path);
/// 동영상 재생 (WebDAV 는 받지 않고 바로 스트리밍)
Future<void> play(BuildContext context, List<String> paths, {bool internal = false, bool keepOrder = false}) async {
if (!context.mounted || paths.isEmpty) return;
await playFiles(context, ctl.c, paths, internal: internal, keepOrder: keepOrder);
}
/// 그림 보기: 같은 폴더의 그림을 지금 정렬 순서대로 넘겨 본다
Future<void> viewImages(BuildContext context, String path, ExplorerPane? pane) async {
final dir = vDirname(path);
// 폴더를 읽지 못하면 (E10: 이제 오류로 알림) 고른 그림 하나만
List<FileEntry> list;
try {
list = pane?.cache[dir] ?? ctl.sorted(await listEntries(dir, showHidden: ctl.c.settings.explorerShowHidden), dir);
} catch (_) {
list = const [];
}
final images = [
for (final e in list)
if (!e.isDir && ctl.c.settings.imageExts.contains(extOf(e.path))) e.path,
];
if (!images.any((x) => samePath(x, path))) images.insert(0, path);
final start = images.indexWhere((x) => samePath(x, path));
final temp = await ctl.c.services.storage.tempDirectory();
if (!context.mounted) return;
await openReader(context, ctl.c, ImageFilesSource(images, tempDir: temp, title: vBasename(dir)), start: start);
}
/// X-plore 맞추기 (관리자 지시 10/20): 폴더의 그림 (지금 정렬 순서) 을 보기 화면으로 열고 [계속 보기] 를 켠 채 시작
Future<void> slideshow(BuildContext context, ExplorerPane pane, String dir) async {
List<FileEntry> list;
try {
list = pane.cache[dir] ?? ctl.sorted(await listEntries(dir, showHidden: ctl.c.settings.explorerShowHidden), dir);
} catch (e) {
ctl.host.snack(trf('폴더를 읽을 수 없습니다: {0}', [e]));
return;
}
final images = [
for (final e in list)
if (!e.isDir && ctl.c.settings.imageExts.contains(extOf(e.path)) && (ctl.showAppHidden || !ctl.appHidden(e.path))) e.path,
];
if (images.isEmpty) {
ctl.host.snack(tr('이 폴더에는 그림이 없습니다'));
return;
}
final temp = await ctl.c.services.storage.tempDirectory();
if (!context.mounted) return;
await openReader(context, ctl.c, ImageFilesSource(images, tempDir: temp, title: vBasename(dir)), autoPlay: true);
}
/// 문서 → PDF (진행 창) → 보기
Future<void> viewDoc(BuildContext context, String path) async {
final temp = await ctl.c.services.storage.tempDirectory();
if (!context.mounted) return;
final dialog = showProgressDialog(context, trf('PDF 로 바꾸는 중: {0}', [vBasename(path)]));
String? pdf;
Object? error;
try {
final local = await vLocalCopy(path, temp);
pdf = await ComponentStore.shared.convertToPdf(local, p.join(temp, 'jj_docs'));
} catch (e) {
error = e;
}
// 사용자가 [취소] · ← 로 그만두었으면 결과를 보이지 않는다 (바꾸기는 끝까지 돌았음)
if (dialog.cancelled()) return;
dialog.close();
await dialog.done;
if (pdf == null) {
ctl.host.snack(trf('미리 볼 수 없습니다: {0}', [error]));
return;
}
PdfSource src;
try {
src = await PdfSource.open(pdf, tempDir: temp);
} catch (e) {
ctl.host.snack(trf('PDF 를 열 수 없습니다: {0}', [e]));
return;
}
if (!context.mounted) return;
await openReader(context, ctl.c, src);
}
/// 관리자 지시 (10/20): HWP · HWPX → 보기 화면 (보는 쪽부터 몇 쪽씩 PDF 로 바꿈 - [HwpSource]). 열지 못하면 false
Future<bool> viewHwp(BuildContext context, String path) async {
final temp = await ctl.c.services.storage.tempDirectory();
if (!context.mounted) return true;
final dialog = showProgressDialog(context, trf('여는 중: {0}', [vBasename(path)]));
HwpSource? src;
Object? error;
try {
src = await HwpSource.open(path, engine: RhwpEngine(), tempDir: temp, fontDirs: RhwpEngine.fontDirs, log: ctl.c.note);
} catch (e) {
error = e;
}
// 사용자가 [취소] · ← 로 그만두었으면 연 문서를 닫고 끝 (다른 앱으로도 열지 않음)
if (dialog.cancelled()) {
await src?.dispose();
return true;
}
dialog.close();
await dialog.done;
if (src == null) {
ctl.host.snack(trf('앱 안에서 열지 못해 다른 앱으로 엽니다: {0}', [error]));
return false;
}
if (!context.mounted) {
await src.dispose();
return true;
}
await openReader(context, ctl.c, src);
return true;
}
Future<void> viewPdf(BuildContext context, String path) async {
final temp = await ctl.c.services.storage.tempDirectory();
PdfSource src;
try {
src = await PdfSource.open(path, tempDir: temp);
} catch (e) {
ctl.host.snack(trf('PDF 를 열 수 없습니다: {0}', [e]));
return;
}
if (!context.mounted) return;
await openReader(context, ctl.c, src);
await ctl.refreshAll([vDirname(path)]);
}
/// 86: 압축 풀기 (검사 · 확인 · 진행 · 취소) 뒤 목록 새로 고침
Future<void> extractArchive(BuildContext context, String path, ExplorerPane pane) async {
final other = otherDirOf(pane);
final out = await extractArchiveFlow(context, ctl.c, path, otherDir: other);
if (out != null && context.mounted) await ctl.refreshAll([out, vDirname(out)]);
}
Future<void> openZipFile(BuildContext context, String path, ExplorerPane? pane) async {
try {
if (ctl.c.settings.zipComic && await openZipComic(context, ctl.c, path)) return;
} catch (e) {
ctl.host.snack(trf('열 수 없습니다: {0}', [e]));
return;
}
if (!context.mounted) return;
final other = pane == null ? null : otherDirOf(pane);
final out = await openZip(context, ctl.c, path, otherDir: other);
if (out != null) await ctl.refreshAll([out, vDirname(out)]);
}
/// 고른 그림들을 한 장씩 PDF 로 (같은 폴더에, 이름을 묻는다)
Future<void> makePdf(BuildContext context, List<String> images) async {
if (images.isEmpty) return;
final dir = vDirname(images.first);
final first = vBasename(images.first);
final name = await askName(context, tr('PDF 로 만들기'), '${first.contains('.') ? first.substring(0, first.lastIndexOf('.')) : first}.pdf');
if (name == null || name.trim().isEmpty) return;
final out = vJoin(dir, name.toLowerCase().endsWith('.pdf') ? name : '$name.pdf');
final temp = await ctl.c.services.storage.tempDirectory();
try {
await imagesToPdf(images, out, tempDir: temp);
ctl.host.snack(trf('그림 {0}장으로 PDF 를 만들었습니다: {1}', [images.length, vBasename(out)]));
} catch (e) {
ctl.host.snack(trf('PDF 를 만들지 못했습니다: {0}', [e]));
}
await ctl.refreshAll([dir]);
}
Future<void> playFolder(BuildContext context, String dir) async {
final List<String> list;
try {
list = (await listEntries(dir)).where((e) => !e.isDir && isVideoFile(e.path)).map((e) => e.path).toList();
} catch (e) {
ctl.host.snack(trf('읽을 수 없습니다: {0}', [explainFileError('$e', dav: isDav(dir)).$1]));
return;
}
if (list.isEmpty) {
ctl.host.snack(tr('이 폴더에 동영상이 없습니다.'));
return;
}
if (!context.mounted) return;
await play(context, list);
}
/// 77: 공유 - 파일만 (폴더는 보낼 수 없어 빼고 알림). WebDAV 파일은 받아서
Future<void> share(BuildContext context, List<String> paths) async {
final files = [for (final x in paths) if (!vIsDirSync(x)) x];
if (files.isEmpty) {
ctl.host.snack(tr('폴더는 공유할 수 없습니다. 파일을 고르세요.'));
return;
}
final local = <String>[];
for (final f in files) {
final l = await fetch(context, f);
if (l == null || !context.mounted) return;
local.add(l);
}
if (!await ctl.c.services.shell.shareFiles(local)) {
ctl.host.snack(tr('공유 창을 열 수 없습니다.'));
} else if (files.length < paths.length) {
ctl.host.snack(trf('폴더 {0}개는 빼고 파일만 보냈습니다.', [paths.length - files.length]));
}
}
}

View File

@ -0,0 +1,101 @@
import 'package:flutter/widgets.dart';
import '../../app/copy_center.dart';
import '../../app/rsync_runner.dart';
import '../../core/file_ops.dart' show FileOpCancelled;
import '../../core/vfs.dart' show vDirname, vMissingSync;
import '../../l10n/tr.dart';
import 'explorer_controller.dart';
import 'explorer_jobs.dart';
import 'rsync_run_dialog.dart';
import 'transfer_flow.dart';
/// 약속 (E5d-5): Rsync 화면의 [→ ← ⇄] - 두 창에서 고른 폴더로 rsync. 막는 조건 · 옵션 · 미리 보기 · 실행은 [RsyncRun],
/// 확인 창은 rsync_run_dialog, 진행은 [ExplorerJobs], rsync 실행 파일은 [ExplorerTransfers.ensureRsync].
/// 끝나면 두 폴더를 다시 읽고 결과를 알린다 (Host). 확인 창이 떠 있는 동안 또 누르면 무시
class ExplorerRsyncFlow {
ExplorerRsyncFlow(this.ctl, this.jobs, this.transfers);
final ExplorerController ctl;
final ExplorerJobs jobs;
final ExplorerTransfers transfers;
/// Rsync 화면: 고른 왼쪽 · 오른쪽 폴더로 rsync. [right] 왼쪽 → 오른쪽, [left] 오른쪽 → 왼쪽 (둘 다면 함께).
/// 폴더 "안의 것" 을 맞춘다 (원본/ → 대상/). 함께 할 때는 받는 쪽이 더 새 파일은 건너뛴다 (-u).
/// 실행한 것은 모니터링에 남는다 (같은 것을 다시 하면 기억한 옵션으로).
/// 확인 창이 떠 있는 동안 또 누르면 무시 (창이 겹쳐 아래 창을 잘못 누르지 않게 - 110)
bool _asking = false;
Future<void> run(BuildContext context, {required bool right, required bool left}) async {
if (_asking) return;
_asking = true;
try {
await _run(context, right: right, left: left);
} finally {
_asking = false;
}
}
Future<void> _run(BuildContext context, {required bool right, required bool left}) async {
final l = ctl.panes[0].sel.marked.firstOrNull, r = ctl.panes[1].sel.marked.firstOrNull;
// 막는 조건 · 작업 · 옵션 · 미리 보기 · 실행은 RsyncRun (E5c), 확인 창은 rsync_run_dialog
final (:run, :problem, :missing) = RsyncRun.prepare(CopyCenter.of(ctl.c),
l: l,
r: r,
right: right,
left: left,
allowInnerToOuter: ctl.c.settings.allowInnerToOuter,
jobs: this.jobs.list,
viaDav: l != null && r != null && ExplorerTransfers.viaDav([l, r]),
copySkipNewer: ctl.c.settings.copySkipNewer);
if (run == null) {
ctl.host.snack(problem!);
if (missing) {
for (final x in ctl.panes) {
x.sel.removeWhere(vMissingSync);
}
await ctl.refreshAll([vDirname(l!), vDirname(r!)]);
}
return;
}
if (!context.mounted) return;
final go = await showRsyncRunDialog(context, run);
// 110: 무엇을 골랐는지 작업 기록에 (실행 · 취소 · 닫힘)
ctl.c.note(trf('Rsync 확인 창 ({0}): {1}', [
run.both ? '⇄' : right ? '→' : '←',
go == true ? tr('실행') : go == false ? tr('취소') : tr('닫힘 (실행 안 함)'),
]));
if (go != true || !context.mounted) return;
if (run.nestedDelete()) {
ctl.host.snack(RsyncRun.nestedDeleteMessage);
return;
}
final exe = run.viaDav ? '' : await transfers.ensureRsync(context);
if (exe == null || !context.mounted) return;
final res = await run.execute(
rsyncExe: exe,
alive: () => context.mounted,
note: ctl.c.note,
onStarted: this.jobs.start);
// 205: 시작하지 못하면 이유를 알린다
if (res.notStarted != null && context.mounted) ctl.host.snack(trf('시작하지 못했습니다: {0}', [res.notStarted]));
final jobs = res.jobs;
if (jobs.isEmpty || !context.mounted) return;
// 원본 폴더까지 지웠으면 고른 표시도 뺀다
for (final x in ctl.panes) {
x.sel.removeWhere(vMissingSync);
}
await ctl.refreshAll([run.l, run.r, vDirname(run.l), vDirname(run.r)]);
for (final x in ctl.panes) {
x.changed();
}
final error = jobs.map((j) => j.error).whereType<Object>().firstOrNull;
ctl.host.snack(error is FileOpCancelled
? tr('취소했습니다.')
: error != null
? trf('끝나지 못했습니다: {0}', [error])
// 212 · 216: 이동인지 · 몇 개인지 · 원본에 남은 것
: jobs.map((j) => j.doneSummary()).join('\n'));
await this.jobs.dropLater(jobs);
}
}

View File

@ -0,0 +1,350 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:path/path.dart' as p;
import '../../app/copy_center.dart';
import '../../app/live_sync.dart' show rsyncExecutable;
import '../../app/transfer_job.dart';
import '../../app/transfer_plan.dart';
import '../../core/file_clipboard.dart';
import '../../core/file_ops.dart';
import '../../core/name_clash.dart';
import '../../core/sync_preview.dart' show optionsRemoveSource, withoutSourceRemovingOptions;
import '../../core/sync_tools.dart';
import '../../core/vfs.dart';
import '../../l10n/tr.dart';
import '../confirm_dialog.dart';
import '../notice.dart';
import '../rsync_setup.dart';
import '../sync_preview_view.dart';
import '../theme.dart';
import 'explorer_controller.dart';
import 'explorer_jobs.dart';
import 'explorer_pane.dart';
import 'write_guard.dart';
/// 약속 (E5d-5): 복사 · 이동 흐름. 두 창이면 다른 창의 폴더 ([otherDir]) 로, 아니면 담아 두기 ([clip] → [paste]).
/// [run]: 확인 창 (같은 이름 · 방법 · 옵션) → 작업 (CopyCenter) → 진행 패널 ([ExplorerJobs]) → 다시 읽기 → 끝 알림 ([폴더 열기]).
/// 계획 (같은 폴더 · 없는 원본 · 자기 안으로) 은 transfer_plan, 읽기 전용인 곳은 [ExplorerGuard] 가 막는다. 끝까지 했으면 true.
/// 담은 것이 바뀌면 알림
class ExplorerTransfers extends ChangeNotifier {
ExplorerTransfers(this.ctl, this.guard, this.jobs);
final ExplorerController ctl;
final ExplorerGuard guard;
final ExplorerJobs jobs;
/// 다른 창의 지금 폴더로 복사 · 이동 (진행 창 · 취소)
/// 복사 · 이동 버튼: 두 창이면 다른 창의 지금 폴더로, 그 밖의 배치는 담아 두기 (붙여넣기로)
Future<void> transfer(BuildContext context, ExplorerPane pane, List<String> sources, {required bool move, String? otherDir}) async {
if (guard.blockShizukuMove(sources, null, move: move)) return;
// X-plore 비교 f: 압축 안의 것 - 복사는 꺼내기 (86 의 풀기 검사 그대로), 이동은 막음
if (sources.any(guard.insideArchive)) {
if (move) {
guard.blockArchive(sources);
return;
}
await guard.extractFrom(context, sources, otherDir);
return;
}
sources = outermostPaths(sources); // 79-c: 고른 폴더 안의 것은 한 번만
if (otherDir == null) {
toClipboard(sources, move: move);
return;
}
await run(context, pane, sources, otherDir, move: move);
}
/// 복사 · 이동할 항목 (붙여넣기 전까지 담아 둠) - FileClipboard (E5b)
final clip = FileClipboard();
/// 154: 고른 것이 없을 때 (복사 · 이동이 같은 말)
static String pickFirst(bool move) => move
? tr('옮길 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).')
: tr('복사할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).');
void toClipboard(List<String> sources, {required bool move}) {
if (guard.blockShizukuMove(sources, null, move: move)) return;
if (sources.isEmpty) {
ctl.host.snack(pickFirst(move));
return;
}
clip.put(sources, move: move);
notifyListeners();
ctl.host.snack(trf('{0} 을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).',
[namesOf(sources), move ? tr('이동') : tr('복사')]));
}
Future<void> paste(BuildContext context, ExplorerPane pane, String dest) async {
if (clip.isEmpty) {
ctl.host.snack(tr('붙여넣을 항목이 없습니다. 먼저 [복사] · [이동] 으로 담으세요.'));
return;
}
final done = await run(context, pane, clip.items, dest, move: clip.move);
// 옮긴 것은 다시 붙여넣을 수 없으므로 비운다 (복사는 여러 곳에 붙여넣을 수 있게 남김)
if (context.mounted) {
clip.pasted(done: done);
notifyListeners();
}
}
/// 한쪽이라도 WebDAV 면 rsync 대신 앱이 직접 맞춘다 (rsync 는 WebDAV 를 모름)
static bool viaDav(Iterable<String> paths) => paths.any(isDav);
/// rsync 가 없으면 (Windows · 내려받기 설정) 지금 내려받을지 묻는다. 쓸 수 있는 rsync 경로 (없으면 null)
Future<String?> ensureRsync(BuildContext context) async {
final s = ctl.c.settings;
final have = await rsyncExecutable(s);
if (have != null) return have;
if (!context.mounted) return null;
if (s.rsyncSource == 'custom' || !Platform.isWindows) {
ctl.host.snack(tr('rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > 파일 탐색기에서 경로를 확인하세요.'));
return null;
}
return installRsyncWithDialog(context);
}
/// [dest] 로 복사 · 이동 (확인 · 아래 진행 막대 · 취소). 끝까지 했으면 true.
Future<bool> run(BuildContext context, ExplorerPane pane, List<String> sources, String dest,
{required bool move, void Function(TransferJob job)? onFinished}) async {
if (sources.isEmpty) {
ctl.host.snack(pickFirst(move));
return false;
}
if (guard.blockShizukuMove(sources, dest, move: move)) return false;
// X-plore 비교 f: 압축 안으로는 넣지 않고 (읽기 전용), 압축 안의 것을 붙여넣으면 꺼내기로
if (guard.blockArchive([dest], self: true)) return false;
if (sources.any(guard.insideArchive)) {
if (move) return !guard.blockArchive(sources);
await guard.extractFrom(context, sources, dest);
return true;
}
// 23: 앞의 복사 · 이동이 끝나지 않아도 함께 시작한다 (아래 진행 막대에 하나씩). 같은 것을 같은 곳으로만 막는다
if (jobs.busyWith(sources, dest)) {
ctl.host.snack(tr('같은 항목을 같은 곳으로 복사 · 이동하는 중입니다.'));
return false;
}
// 135: 같은 폴더로 - 짧은 알림은 놓치기 쉬워 창으로. 이동은 할 것이 없고, 복사는 사본을 만들지 한 번 묻는다 (계획은 transfer_plan)
final plan = planTransfer(sources, dest, move: move);
sources = plan.sources;
final skippedHere = plan.skippedHere, here = plan.here;
var sameFolderCopy = false;
if (plan.nothingToMove) {
// 알림만 ([확인] 에 첫 초점 - Enter 로 닫기)
await showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(tr('이미 이 폴더에 있습니다')),
content: Text(trf('{0} 은(는) 이미 이 폴더에 있어 옮길 것이 없습니다.\n{1}', [namesOf(here), vDisplay(dest)])),
actions: [FilledButton(autofocus: true, onPressed: () => Navigator.pop(ctx), child: Text(tr('확인')))],
),
);
return false;
}
if (plan.askCopyHere) {
final ok = await confirmDialog(context,
title: () => tr('같은 폴더입니다'),
confirmLabel: () => tr('사본 만들기'),
body: (ctx, set) => Text(trf('같은 폴더에 사본을 만들까요? (이름 (2))\n{0}', [namesOf(here)])));
if (!ok || !context.mounted) return false;
sameFolderCopy = true; // 이미 물었으니 아래 확인 창은 건너뛴다
}
// 원본 · 대상이 없거나 (다른 곳에서 지움 - 목록을 새로 고침) 폴더를 자기 안으로 넣으려 하면 시작하지 않는다
final problem = plan.problem;
if (problem != null) {
ctl.host.snack(problem);
pane.sel.removeWhere(vMissingSync);
await ctl.refreshAll([dest, ...sources.map(p.dirname)]);
for (final x in ctl.panes) {
x.changed();
}
return false;
}
// 파일 탐색기: 현재 방식 · robocopy (rsync 는 Rsync 화면 - 그쪽만 모니터링에 남는다)
final center = CopyCenter.of(ctl.c);
final task = center.fresh(sources, dest, move: move);
final method = CopyMethod.of(task.method);
// 48: 받는 폴더에 같은 이름이 있으면 덮어쓰기 · 건너뛰기 · 이름 바꾸기를 고른다
// (앱이 직접 할 때만 - robocopy · rsync 는 그 프로그램의 규칙. 같은 폴더로 복사는 늘 새 이름)
final builtin = method == CopyMethod.builtin || viaDav([...sources, dest]);
// 확인 창은 바로 띄우고, 같은 이름은 창 안에서 뒤에서 찾는다 (느린 네트워크 · WebDAV 폴더라도 창이 늦게 뜨지 않게).
// 다 찾기 전에 누르면 이름 바꾸기 (원래 파일을 건드리지 않는 쪽)
final clashesFuture = builtin ? findClashes(sources, dest) : Future.value(const <NameClash>[]);
// 48: 환경 설정 "같은 이름이 있을 때" 가 늘 하는 것이면 묻지 않고 그대로 (확인 창에 알림만)
final always = switch (ctl.c.settings.copyConflict) {
'rename' => NameConflict.rename,
'overwrite' => NameConflict.overwrite,
'skip' => NameConflict.skip,
_ => null,
};
var conflict = always ?? NameConflict.rename;
// U4 (점검 10/22, 관리자 결정): 다른 창의 표시는 그대로 두고 이번 대상이 아님을 알린다
final otherMarks = [for (final x in ctl.panes) if (!identical(x, pane)) x.sel.marked.length].fold(0, (a, b) => a + b);
// U7: 공용 confirmDialog - 늘 덮어쓰기 설정이면 (되돌릴 수 없음) Enter 의 첫 초점은 [취소]
final go = sameFolderCopy
? true
: await confirmDialog(
context,
title: () => move ? tr('이동') : tr('복사'),
confirmLabel: () => move ? tr('이동') : tr('복사'),
danger: () => conflict == NameConflict.overwrite,
body: (ctx, setInner) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
// 61: 문장 조각을 이어 붙이지 않는다 (언어마다 어순이 달라 "…로 {복사할까요?}" 가 깨졌다)
Text('${move ? trf('{0}개 항목을 다음 폴더로 옮길까요?\n{1}', [sources.length, vDisplay(dest)]) : trf('{0}개 항목을 다음 폴더로 복사할까요?\n{1}', [sources.length, vDisplay(dest)])}'
'\n${namesOf(sources)}'
'${skippedHere.isEmpty ? '' : '\n${trf('이미 이 폴더에 있어 건너뜀: {0}', [namesOf(skippedHere)])}'}'
'\n\n${trf('방법: {0}', [tr(method.label)])}'
'${method == CopyMethod.builtin ? '' : ' · ${withoutSourceRemovingOptions(task.options)}${move ? (method == CopyMethod.rsync ? ' --remove-source-files' : ' /MOVE') : ''}'}'
'${task.bandwidthKBps > 0 ? ' · ${trf('속도 제한 {0} KB/s', [task.bandwidthKBps])}' : ''}'),
// 178 보충: 환경 설정의 원본 지우기 옵션은 복사에서 뺀다 - 왜 빠졌는지 알림
if (!move && method != CopyMethod.builtin && optionsRemoveSource(task.options))
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
trf('환경 설정의 {0} 는 복사에서는 쓰지 않습니다 (이동은 [이동] 으로)',
[splitOptions(task.options).where(isSourceRemovingOption).join(' ')]),
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
),
FutureBuilder<List<NameClash>>(
future: clashesFuture,
builder: (ctx, snap) {
if (snap.connectionState != ConnectionState.done) {
return Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(tr('같은 이름이 있는지 확인하는 중 …'), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
);
}
final clashes = snap.data ?? const <NameClash>[];
if (clashes.isEmpty) return const SizedBox.shrink();
return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
const SizedBox(height: 12),
Text(trf('같은 이름이 이미 있습니다: {0}', [namesOf([for (final x in clashes) x.src])]),
style: TextStyle(color: JjColors.warning)),
// 172: 두 파일의 크기 · 바뀐 날 (3개까지)
for (final x in clashes.take(3)) ...[
Padding(
padding: const EdgeInsets.only(top: 4, left: 8),
child: Text(clashCompareText(x), style: const TextStyle(fontSize: 12)),
),
// 229: 폴더를 합치면 안쪽에서 받는 쪽이 더 새 파일 - 건너뛰는지 (환경 설정) 미리
if (x.there.isDir && !isDav(x.src) && !isDav(dest))
FutureBuilder<int>(
future: FileOps.countNewerInside(x.src, vJoin(dest, vBasename(x.src))),
builder: (_, n) => Padding(
padding: const EdgeInsets.only(left: 20),
child: Text(
n.connectionState != ConnectionState.done
? tr('안쪽 파일을 확인하는 중 …')
: (n.data ?? 0) == 0
? ''
: ctl.c.settings.copySkipNewer
? trf('안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)', [n.data])
: trf('안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다', [n.data]),
style: TextStyle(fontSize: 12, color: JjColors.warning),
),
),
),
],
if (always != null)
Text(
trf('환경 설정대로 {0} (환경 설정 > 파일 탐색기 > 같은 이름이 있을 때)', [
switch (always) {
NameConflict.rename => tr('이름을 바꿉니다'),
NameConflict.overwrite => tr('덮어씁니다'),
NameConflict.skip => tr('건너뜁니다'),
}
]),
style: const TextStyle(fontSize: 12))
else
RadioGroup<NameConflict>(
groupValue: conflict,
onChanged: (x) => setInner(() => conflict = x ?? conflict),
child: Column(mainAxisSize: MainAxisSize.min, children: [
for (final (v, label) in [
(NameConflict.rename, tr('이름 바꾸기 (예: 이름 (2))')),
(NameConflict.overwrite, tr('덮어쓰기 (원래 파일은 없어집니다)')),
(NameConflict.skip, tr('건너뛰기')),
])
RadioListTile<NameConflict>(dense: true, contentPadding: EdgeInsets.zero, value: v, title: Text(label)),
]),
),
]);
},
),
if (otherMarks > 0)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(trf('다른 창에 {0}개 표시됨 (이번 대상 아님)', [otherMarks]),
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
),
]),
);
if (go != true || !context.mounted) return false;
// 104: 설정의 옵션에 지우기 (robocopy /MIR · /PURGE 등) 가 있으면 미리 보기를 거친다
if (!await confirmDeletingRun(context, task) || !context.mounted) return false;
String? rsync;
if (method == CopyMethod.rsync && !viaDav([...sources, dest])) {
rsync = await ensureRsync(context);
if (rsync == null || !context.mounted) return false;
}
final job = await center.start(task, rsyncExe: rsync, conflict: conflict);
if (job == null || !context.mounted) return false;
jobs.start(job);
await job.done;
final error = job.error;
await jobs.followMoved(job);
if (!context.mounted) return false;
// 180: 표시만 지우고 선택 모드는 그대로 (꺼지면 동그라미 칸 · 아래 막대가 사라져 자리가 바뀐다)
pane.sel.clear();
await ctl.refreshAfterTransfer(sources, dest);
// 넣은 폴더가 창의 지금 폴더면 펼쳐서 새 항목이 보이게
for (final x in ctl.panes) {
if (samePath(x.current, dest)) {
x.expanded.add(dest);
await ctl.load(x, dest, force: true);
}
x.changed();
}
final doneText = error is FileOpCancelled
? tr('취소했습니다.')
: error != null
? trf('끝나지 못했습니다: {0}', [error])
: joinSentences([
trf('{0}개 항목을 {1}', [job.made.length, move ? tr('옮겼습니다.') : tr('복사했습니다.')]),
// 관리자 지시 (10/21, 15 찾음 F3): 뒤 조각도 문장으로 쓰고 빈칸으로 잇는다 (예전에는 "…옮겼습니다. · 1개는 …" 처럼 마침표 뒤에 " · ")
// 264: 작업 기록에만 있던 것도 알림에 (폴더를 합칠 때 받는 쪽이 더 새 파일 등)
if (move && job.leftInSource > 0) trf('{0}개는 원본에 남았습니다 (건너뜀).', [job.leftInSource]),
if (!move && job.keptNewer > 0) trf('받는 쪽이 더 새것이라 {0}개를 건너뛰었습니다.', [job.keptNewer]),
if (job.skipped.isNotEmpty) trf('같은 이름이라 건너뛰었습니다: {0}.', [namesOf(job.skipped)]),
// M1 (10/19): 원격 맞추기에서 받는 쪽의 같은 이름 폴더는 --delete 없이 지우지 않는다
if (job.dirConflicts.isNotEmpty) trf('같은 이름의 폴더가 있어 {0}개를 건너뛰었습니다: {1}.', [job.dirConflicts.length, namesOf(job.dirConflicts)]),
if (skippedHere.isNotEmpty) trf('이미 이 폴더에 있어 건너뛰었습니다: {0}.', [namesOf(skippedHere)]),
]);
// 85: 끝나면 [폴더 열기] - 앱 탐색기에서 넣은 폴더로 (그 폴더를 보고 있는 창, 없으면 지금 창)
if (error == null && context.mounted) {
// U2 (점검 10/22): 끝 알림은 정해진 시간 뒤 닫힘 - 아래 표시 줄을 가리지 않게
showNotice(ScaffoldMessenger.of(context),
text: doneText,
action: (
tr('폴더 열기'),
() {
final show = ctl.panes.where((x) => samePath(x.current, dest)).firstOrNull ?? ctl.pane;
ctl
..active = ctl.panes.indexOf(show)
..update();
unawaited(ctl.goTo(show, dest, fresh: true));
}
));
} else {
ctl.host.snack(doneText);
}
// 82: 끌어 놓기의 이동 되돌리기 (끝난 알림 바로 뒤에 - 진행 막대가 내려가기를 기다리지 않게)
if (error == null && context.mounted) onFinished?.call(job);
// 다 되면 진행 막대가 잠깐 100% 를 보인 뒤 내려간다
await jobs.dropLater([job]);
return error == null;
}
}

View File

@ -0,0 +1,56 @@
import 'package:flutter/widgets.dart';
import '../../app/archive_browse.dart' show ArchiveRead;
import '../../core/file_ops.dart' show samePath;
import '../../core/shizuku.dart' show isRootPath, isShizuku;
import '../../l10n/tr.dart';
import '../archive_ui.dart' show extractArchiveFlow;
import 'explorer_controller.dart';
/// 약속 (E5d-5): 읽기 전용인 곳 (펼친 압축 안 · 시스템 폴더 Shizuku · 루트) 을 바꾸려는 일을 막고 까닭을 알린다 (Host).
/// 막았으면 true. 압축 안의 것은 꺼내기만 ([extractOne] · [extractFrom] - 86 의 풀기 창 그대로)
class ExplorerGuard {
ExplorerGuard(this.ctl);
final ExplorerController ctl;
bool insideArchive(String path) => ctl.arc.inside(path);
/// 압축 안을 바꾸려 하면 막고 알린다 (읽기 전용). [self] 면 압축 파일 자신도 (그 안으로 넣는 곳일 때)
bool blockArchive(List<String> paths, {bool self = false}) {
// X-plore 비교 5-2: 시스템 폴더 (Shizuku) 도 읽기 전용 - 쓰기 · 지우기 · 이름 바꾸기 · 옮기기 · 새 폴더 · 넣기를 막는다 (꺼내기만)
if (paths.any(isShizuku)) {
ctl.host.snack(paths.any(isRootPath)
? tr('루트 폴더는 읽기 전용입니다 (꺼내기 (복사) 만 할 수 있습니다)')
: tr('시스템 폴더 (Shizuku) 는 읽기 전용입니다 (꺼내기 (복사) 만 할 수 있습니다)'));
return true;
}
if (!paths.any((x) => insideArchive(x) || (self && ctl.arc.contains(x)))) return false;
ctl.host.snack(tr('압축 안은 읽기 전용입니다 (꺼내기만 할 수 있습니다)'));
return true;
}
/// 관리자 지시 (10/21, 28 지적): 시스템 폴더 · 루트 (shz://) 가 원본이나 받는 곳인 이동은 시작하지 않는다 -
/// 복사는 되고 원본 지우기에서 오류로 끝나던 것 (끌어 놓기 · 붙여넣기 · 버튼 · 메뉴 모두 여기를 지남)
bool blockShizukuMove(List<String> sources, String? dest, {required bool move}) {
if (!move || !(sources.any(isShizuku) || (dest != null && isShizuku(dest)))) return false;
ctl.host.snack(tr('읽기 전용이라 옮길 수 없습니다 - 복사를 쓰세요'));
return true;
}
/// 압축 안의 파일 하나를 임시 폴더로 꺼낸다 (보기 · 재생). 꺼낸 경로 (못 하면 null - 까닭을 알림)
Future<String?> extractOne(String path) async {
final (out, why) = await ctl.arc.extractOne(path, await ctl.c.services.storage.tempDirectory());
if (why == ArchiveRead.password) ctl.host.snack(tr('비밀번호가 걸린 파일입니다 - [압축 풀기] 로 푸세요'));
if (why == ArchiveRead.failed) ctl.host.snack(trf('압축에서 꺼내지 못했습니다: {0}', [ctl.arc.lastError]));
return out;
}
/// 압축 안의 것을 꺼내기 - 86 의 풀기 창 (검사 · 갈 곳 · 같은 이름) 그대로. 꺼낸 폴더를 다시 읽는다
Future<void> extractFrom(BuildContext context, List<String> paths, String? otherDir) async {
final arc = ctl.arc.archiveOf(paths.firstWhere(insideArchive))!;
final names = [for (final x in paths) if (insideArchive(x) && samePath(ctl.arc.archiveOf(x)!, arc)) ctl.arc.innerName(arc, x)];
final done = await extractArchiveFlow(context, ctl.c, arc, otherDir: otherDir, names: names);
if (done != null && ctl.host.alive) await ctl.refreshAll([done]);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
@ -8,14 +9,20 @@ import 'package:jj_mkvmaker/core/file_ops.dart' show samePath;
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/explorer/delete_flow.dart';
import 'package:jj_mkvmaker/ui/explorer/explorer_buttons.dart';
import 'package:jj_mkvmaker/ui/explorer/explorer_controller.dart';
import 'package:jj_mkvmaker/ui/explorer/explorer_host.dart';
import 'package:jj_mkvmaker/ui/explorer/explorer_jobs.dart';
import 'package:jj_mkvmaker/ui/explorer/explorer_pane.dart';
import 'package:jj_mkvmaker/ui/explorer/explorer_select.dart';
import 'package:jj_mkvmaker/ui/explorer/marks_bar.dart';
import 'package:jj_mkvmaker/ui/explorer/open_flow.dart';
import 'package:jj_mkvmaker/ui/explorer/rsync_flow.dart';
import 'package:jj_mkvmaker/ui/explorer/toolbar.dart';
import 'package:jj_mkvmaker/ui/explorer/transfer_flow.dart';
import 'package:jj_mkvmaker/ui/explorer/transfer_panel.dart';
import 'package:jj_mkvmaker/ui/explorer/write_guard.dart';
import 'package:path/path.dart' as p;
/// E5d-4 약속 시험: explorer_buttons · toolbar · marks_bar · transfer_panel · job_view 맨 위 약속
@ -46,24 +53,53 @@ void main() {
ctl.dispose();
});
await t.runAsync(() => ctl.goTo(ctl.panes[0], show));
return (ExplorerButtons(ctl, sel, host), ctl, sel, host);
final guard = ExplorerGuard(ctl);
final jobs = ExplorerJobs(ctl);
final transfers = ExplorerTransfers(ctl, guard, jobs);
addTearDown(() {
transfers.dispose();
jobs.dispose();
});
final b = ExplorerButtons(ctl, sel, host,
guard: guard,
transfers: transfers,
deletes: ExplorerDeleteFlow(ctl, sel, guard, androidTrash: () => false),
rsync: ExplorerRsyncFlow(ctl, jobs, transfers),
opener: ExplorerOpener(ctl, sel, guard, otherDirOf: (_) => null));
return (b, ctl, sel, host);
}
testWidgets('버튼: 상위 폴더 · 선택 · 새 폴더 (실제로 만들고 그 줄로) · 복사 → 화면의 일 · 길게 = 짝 동작', (t) async {
final (b, ctl, sel, host) = await open(t);
testWidgets('버튼: 상위 폴더 · 선택 · 새 폴더 (실제로 만들고 그 줄로) · 한 창 복사 = 담기 · 길게 = 짝 동작 (영구 삭제 창)', (t) async {
final (b, ctl, sel, _) = await open(t);
final q = ctl.pane;
await t.runAsync(() => b.press(ExplorerButton.select));
expect(sel.selecting, isTrue);
host.name = 'New';
await t.runAsync(() => b.press(ExplorerButton.newFolder));
var made0 = false;
b.press(ExplorerButton.newFolder).then((_) => made0 = true);
await t.pump();
await t.enterText(find.byType(TextField), 'New');
await t.tap(find.text('확인'));
for (var i = 0; i < 20 && !made0; i++) {
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 30)));
await t.pump(const Duration(milliseconds: 100));
}
final made = p.join(show, 'New');
expect([Directory(made).existsSync(), samePath(q.focused!, made)], [true, true]);
host.targets = [ep1];
await t.runAsync(() => sel.toggleMark(q, sel.shown(q, show).firstWhere((e) => samePath(e.path, ep1))));
await t.runAsync(() => b.press(ExplorerButton.copy));
expect([b.transfers.clip.items, b.transfers.clip.move], [
[ep1],
false
]);
await t.runAsync(() => b.longPress(ExplorerButton.copy));
await t.runAsync(() => b.longPress(ExplorerButton.delete));
expect(b.transfers.clip.move, isTrue, reason: '복사 길게 = 이동');
unawaited(b.longPress(ExplorerButton.delete));
await t.pump();
expect([find.text('영구 삭제').evaluate().isNotEmpty, b.deletes.shiftDelete], [true, false]);
await t.tap(find.text('취소'));
await t.pump();
expect(File(ep1).existsSync(), isTrue, reason: '취소하면 그대로');
await t.runAsync(() => b.longPress(ExplorerButton.refresh));
expect(host.calls, ['copy $ep1', 'move $ep1', 'delete(영구) $ep1']);
await t.runAsync(() => b.press(ExplorerButton.up));
expect(samePath(q.current, root), isTrue);
await t.runAsync(() => b.press(ExplorerButton.up));
@ -140,11 +176,8 @@ class _Host extends StatefulWidget {
State<_Host> createState() => _HostState();
}
/// 실제 화면: ExplorerHost · ExplorerCommands 를 이 화면이 맡는다 (화면의 일은 [calls] 에 적기만)
/// 실제 화면: ExplorerHost · ExplorerCommands 를 이 화면이 맡는다 (이 시험이 부르지 않는 화면의 일은 부르면 실패)
class _HostState extends State<_Host> implements ExplorerHost, ExplorerCommands {
final calls = <String>[];
List<String> targets = const [];
String? name;
Widget Function()? child;
/// [build] 로 그리고, [changes] 가 바뀔 때마다 다시 그린다
@ -165,46 +198,26 @@ class _HostState extends State<_Host> implements ExplorerHost, ExplorerCommands
void afterFrame(VoidCallback f) => WidgetsBinding.instance.addPostFrameCallback((_) => f());
@override
bool shiftDelete = false;
String? get otherDir => null;
@override
List<String> targetsOf(ExplorerPane pane) => targets;
bool get dual => false;
@override
Future<String?> askName(String title, String initial, {String? hint, bool selectAll = false}) async => name;
@override
bool blockArchive(List<String> paths, {bool self = false}) => false;
@override
Future<void> transfer(ExplorerPane pane, List<String> sources, {required bool move}) async =>
calls.add('${move ? 'move' : 'copy'} ${sources.join(',')}');
@override
Future<void> deleteItems(ExplorerPane pane, List<String> paths) async {
calls.add('delete${shiftDelete ? '(영구)' : ''} ${paths.join(',')}');
shiftDelete = false;
}
Future<void> quickLook(ExplorerPane pane, [String? path]) => _no();
Never _no() => throw UnimplementedError('이 시험에서 부르지 않음');
@override
Future<void> runRsync({required bool right, required bool left}) => _no();
@override
Future<void> play(List<String> paths, {bool internal = false, bool keepOrder = false}) => _no();
@override
Future<void> playFolder(String dir) => _no();
@override
Future<void> addMkv(List<String> paths) => _no();
@override
Future<void> renameItem(ExplorerPane pane, String path) => _no();
@override
Future<void> batchRename(ExplorerPane pane, List<String> paths) => _no();
@override
Future<void> paste(ExplorerPane pane, String dest) => _no();
@override
Future<void> openSearch(ExplorerPane pane) => _no();
@override
Future<void> openStorage(ExplorerPane pane, String dir) => _no();
@override
Future<void> openWifiShare(String dir) => _no();
@override
Future<void> openTrash() => _no();
@override
Future<void> sortDialog() => _no();
@override
Future<void> historyDialog(ExplorerPane pane) => _no();

View File

@ -0,0 +1,230 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/core/file_ops.dart' show FileEntry;
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/explorer/delete_flow.dart';
import 'package:jj_mkvmaker/ui/explorer/explorer_buttons.dart';
import 'package:jj_mkvmaker/ui/explorer/explorer_controller.dart';
import 'package:jj_mkvmaker/ui/explorer/explorer_host.dart';
import 'package:jj_mkvmaker/ui/explorer/explorer_jobs.dart';
import 'package:jj_mkvmaker/ui/explorer/explorer_pane.dart';
import 'package:jj_mkvmaker/ui/explorer/explorer_select.dart';
import 'package:jj_mkvmaker/ui/explorer/item_actions.dart';
import 'package:jj_mkvmaker/ui/explorer/item_menu.dart';
import 'package:jj_mkvmaker/ui/explorer/open_flow.dart';
import 'package:jj_mkvmaker/ui/explorer/transfer_flow.dart';
import 'package:jj_mkvmaker/ui/explorer/write_guard.dart';
import 'package:jj_mkvmaker/ui/text_viewer.dart';
import 'package:path/path.dart' as p;
/// E5d-5 약속 시험: write_guard · explorer_jobs · transfer_flow · delete_flow · open_flow · item_menu · item_actions 맨 위 약속
/// (실제 시험 폴더 · 실제 화면 · 실제 복사 · 실제 지우기. 다른 앱으로 여는 길은 이 PC 의 앱을 열므로 보지 않는다)
void main() {
late Directory tmp;
late String root, show, out, ep1, note;
setUp(() {
tmp = Directory.systemTemp.createTempSync('jj_flow_ui_');
root = tmp.path;
show = p.join(root, 'Show');
out = p.join(root, 'Out');
Directory(show).createSync();
Directory(out).createSync();
ep1 = p.join(show, 'ep1.mkv');
note = p.join(show, 'note.txt');
File(ep1).writeAsStringSync('1');
File(note).writeAsStringSync('글');
});
tearDown(() => tmp.deleteSync(recursive: true));
/// 실제 I/O · 창을 기다린다
Future<void> settle(WidgetTester t, [bool Function()? until]) async {
for (var i = 0; i < 30 && !(until?.call() ?? false); i++) {
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 30)));
await t.pump(const Duration(milliseconds: 100));
}
}
Future<_Flows> open(WidgetTester t) async {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
final key = GlobalKey<_HostState>();
await t.pumpWidget(MaterialApp(home: _Host(key: key)));
final host = key.currentState!;
final ctl = ExplorerController(c, rsync: false, host: host)..local = [(root, 'T:')];
final sel = ExplorerSelect(ctl, clock: DateTime.now, openFile: (_, _) async {});
final guard = ExplorerGuard(ctl);
final jobs = ExplorerJobs(ctl);
final transfers = ExplorerTransfers(ctl, guard, jobs);
final deletes = ExplorerDeleteFlow(ctl, sel, guard, androidTrash: () => false);
final opener = ExplorerOpener(ctl, sel, guard, otherDirOf: (_) => null);
addTearDown(() {
transfers.dispose();
jobs.dispose();
sel.dispose();
ctl.dispose();
});
await t.runAsync(() => ctl.goTo(ctl.panes[0], show));
final menu = ExplorerItemMenu(ctl, sel, host, guard: guard, transfers: transfers, deletes: deletes, opener: opener, nomedia: () => false);
return _Flows(host, ctl, sel, guard, jobs, transfers, deletes, opener, menu);
}
testWidgets('막기: 시스템 폴더 · 루트는 읽기 전용 (까닭 알림) · 시스템 폴더로 옮기기 막음 · 보통 폴더는 그대로', (t) async {
final f = await open(t);
expect([f.guard.blockArchive([ep1]), f.guard.blockShizukuMove([ep1], out, move: true)], [false, false]);
expect(f.guard.blockArchive(['shz://sys/a']), isTrue);
expect(f.guard.blockShizukuMove(['shz://sys/a'], out, move: true), isTrue);
expect(f.guard.blockShizukuMove(['shz://sys/a'], out, move: false), isFalse, reason: '복사 (꺼내기) 는 됨');
await t.pump();
expect(find.textContaining('읽기 전용'), findsWidgets);
});
testWidgets('옮기기: 한 창이면 담기 → 붙여넣기 = 실제 복사 (확인 창 → 진행 목록 → 끝나면 내려감)', (t) async {
final f = await open(t);
final q = f.ctl.pane;
await f.transfers.transfer(f.host.context, q, [], move: false);
await t.pump();
expect(find.text(ExplorerTransfers.pickFirst(false)), findsOneWidget, reason: '고른 것이 없으면 알림');
await f.transfers.transfer(f.host.context, q, [ep1], move: false);
expect([f.transfers.clip.items, f.transfers.clip.move], [
[ep1],
false
]);
var done = false;
unawaited(f.transfers.paste(f.host.context, q, out).then((_) => done = true));
await settle(t, () => find.text('복사').evaluate().length > 1);
await t.tap(find.widgetWithText(FilledButton, '복사'));
await settle(t, () => f.jobs.list.isNotEmpty);
expect(f.jobs.list.length, 1);
await settle(t, () => done);
expect([done, File(p.join(out, 'ep1.mkv')).existsSync(), File(ep1).existsSync(), f.transfers.clip.isEmpty], [true, true, true, false],
reason: '복사는 여러 곳에 붙여넣게 남김');
expect(f.jobs.list, isEmpty, reason: '끝나면 잠깐 뒤 내려감');
});
testWidgets('지우기: 영구 삭제 (Shift+Delete) 확인 창 → 실제로 지움 · 표시 정리 · [취소] 는 그대로', (t) async {
final f = await open(t);
final q = f.ctl.pane;
f.deletes.shiftDelete = true;
unawaited(f.deletes.run(f.host.context, q, [note]));
await t.pump();
await t.tap(find.text('취소'));
await t.pump();
expect([File(note).existsSync(), f.deletes.shiftDelete], [true, false]);
f.sel.setSelecting(true);
await t.runAsync(() => f.sel.toggleMark(q, FileEntry(note, isDir: false, modified: DateTime(0))));
f.deletes.shiftDelete = true;
var done = false;
unawaited(f.deletes.run(f.host.context, q, [note]).then((_) => done = true));
await t.pump();
await t.tap(find.widgetWithText(FilledButton, '영구 삭제'));
await settle(t, () => done);
expect([File(note).existsSync(), q.sel.isEmpty, f.sel.selecting], [false, true, false]);
});
testWidgets('열기: 글 뷰어 (앱 안) · 동영상 없는 폴더 재생은 알림 · 열 앱이 없을 때 알림 글', (t) async {
final f = await open(t);
unawaited(f.opener.viewText(f.host.context, note));
await settle(t, () => find.byType(TextViewerPage).evaluate().isNotEmpty);
expect(find.byType(TextViewerPage), findsOneWidget);
Navigator.of(f.host.context).pop();
await t.pump(const Duration(seconds: 1));
await settle(t);
await t.runAsync(() => f.opener.playFolder(f.host.context, out));
await t.pump();
expect(find.text('이 폴더에 동영상이 없습니다.'), findsWidgets);
expect(ExplorerOpener.noAppMessage(ep1), contains('내장 플레이어'));
expect(ExplorerOpener.noAppMessage(note), isNot(contains('내장 플레이어')));
});
testWidgets('줄 메뉴: 파일 항목 · [정보] 창 · 고르지 않으면 아무것도 안 함 · 경로 복사 글', (t) async {
final f = await open(t);
final e = FileEntry(note, isDir: false, modified: DateTime(2026, 10, 22, 9), size: 3);
unawaited(f.menu.show(f.host.context, f.ctl.pane, e, const Offset(10, 10)));
await t.pump();
await t.pump(const Duration(milliseconds: 300));
expect([find.text('정보').evaluate().length, find.text('글 뷰어로 보기').evaluate().length, find.text('삭제').evaluate().isNotEmpty], [1, 1, true]);
await t.tap(find.text('정보'));
await settle(t, () => find.text('닫기').evaluate().isNotEmpty);
expect(find.textContaining(note), findsOneWidget);
await t.tap(find.text('닫기'));
await t.pump(const Duration(milliseconds: 300));
unawaited(f.menu.show(f.host.context, f.ctl.pane, e, const Offset(10, 10)));
await t.pump(const Duration(milliseconds: 300));
await t.tapAt(const Offset(790, 590));
await t.pump(const Duration(milliseconds: 300));
expect([File(note).existsSync(), find.text('정보').evaluate().isEmpty], [true, true]);
expect(copyablePath(f.ctl, note), note, reason: '이 기기의 경로는 그대로');
});
}
class _Flows {
_Flows(this.host, this.ctl, this.sel, this.guard, this.jobs, this.transfers, this.deletes, this.opener, this.menu);
final _HostState host;
final ExplorerController ctl;
final ExplorerSelect sel;
final ExplorerGuard guard;
final ExplorerJobs jobs;
final ExplorerTransfers transfers;
final ExplorerDeleteFlow deletes;
final ExplorerOpener opener;
final ExplorerItemMenu menu;
}
class _Host extends StatefulWidget {
const _Host({super.key});
@override
State<_Host> createState() => _HostState();
}
/// 실제 화면: ExplorerHost · ExplorerCommands 를 이 화면이 맡는다 (이 시험이 부르지 않는 화면의 일은 부르면 실패)
class _HostState extends State<_Host> implements ExplorerHost, ExplorerCommands {
@override
bool get alive => mounted;
@override
void snack(String text) => ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text)));
@override
void reveal(ExplorerPane pane, String path) {}
@override
void afterFrame(VoidCallback f) => WidgetsBinding.instance.addPostFrameCallback((_) => f());
@override
String? get otherDir => null;
@override
bool get dual => false;
Never _no() => throw UnimplementedError('이 시험에서 부르지 않음');
@override
Future<void> quickLook(ExplorerPane pane, [String? path]) => _no();
@override
Future<void> addMkv(List<String> paths) => _no();
@override
Future<void> renameItem(ExplorerPane pane, String path) => _no();
@override
Future<void> batchRename(ExplorerPane pane, List<String> paths) => _no();
@override
Future<void> openSearch(ExplorerPane pane) => _no();
@override
Future<void> openStorage(ExplorerPane pane, String dir) => _no();
@override
Future<void> openWifiShare(String dir) => _no();
@override
Future<void> sortDialog() => _no();
@override
Future<void> historyDialog(ExplorerPane pane) => _no();
@override
Future<void> openBookmarks(ExplorerPane pane) => _no();
@override
Future<void> toggleOrientation() => _no();
@override
Future<void> layoutDialog() => _no();
@override
Future<void> buttonsDialog() => _no();
@override
Widget build(BuildContext context) => const Scaffold(body: SizedBox());
}