323 lines
19 KiB
Dart
323 lines
19 KiB
Dart
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 'explorer_buttons.dart';
|
|
import 'explorer_controller.dart';
|
|
import 'explorer_flows.dart';
|
|
import 'explorer_pane.dart';
|
|
import 'explorer_select.dart';
|
|
import 'open_flow.dart' show ExplorerOpener;
|
|
import 'item_actions.dart';
|
|
|
|
/// 약속 (E5d-5): 줄을 길게 누르거나 오른쪽 클릭하면 뜨는 메뉴. 누른 것이 표시한 것 중 하나면 표시한 것 모두에 한다 (77).
|
|
/// 위 아이콘 줄 (복사 · 이동 · 이름 변경 · 삭제 · 공유) + 상황별 항목 (Rsync 화면은 폴더에 맞는 것만, 압축 안은 새 파일을 만드는 것은 숨김).
|
|
/// 고른 것은 흐름 모듈 (열기 · 복사 · 이동 · 지우기) 이나 [ExplorerCommands] 로 한다. 고르지 않으면 아무것도 안 함
|
|
class ExplorerItemMenu {
|
|
ExplorerItemMenu(this.ctl, this.sel, this.cmd, this.flows, {required this.nomedia});
|
|
|
|
final ExplorerController ctl;
|
|
final ExplorerSelect sel;
|
|
final ExplorerCommands cmd;
|
|
final ExplorerFlows flows;
|
|
|
|
/// .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.display.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(flows.guard.insideArchive) || flows.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.vault.enabled && !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 (flows.transfers.clip.isNotEmpty)
|
|
('paste', Icons.content_paste, trf('{0} 에 붙여넣기 ({1}개)', [vBasename(e.isDir ? e.path : vDirname(e.path)), flows.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 flows.opener.playFolder(context, e.path);
|
|
case 'slideshow':
|
|
await flows.opener.slideshow(context, pane, e.path);
|
|
case 'playInternal':
|
|
await flows.opener.play(context, [e.path], internal: true);
|
|
case 'grid':
|
|
await flows.opener.grid(context, pane, e.path);
|
|
case 'text':
|
|
await flows.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 와 같은 길 - 탐색기의 영구 삭제 확인 창을 한 번 더)
|
|
flows.deletes.shiftDelete = permanent;
|
|
return flows.deletes.run(context, pane, paths);
|
|
});
|
|
await ctl.refreshAll([vDirname(e.path)]);
|
|
case 'openWith':
|
|
final local = await flows.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 flows.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 flows.opener.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 flows.opener.quickLook(context, pane, e.path);
|
|
case 'extract':
|
|
await flows.opener.extractArchive(context, e.path, pane);
|
|
case 'storage':
|
|
await flows.tools.openStorage(context, pane, e.path);
|
|
case 'wifiShare':
|
|
await flows.tools.openWifiShare(context, 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.explorer.hideConfirm && !await confirmAppHide(context, ctl, targets)) return;
|
|
await ctl.c.updateSettings((s) => s.explorer.hiddenItems = [
|
|
...s.explorer.hiddenItems,
|
|
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.explorer.hiddenItems = [
|
|
for (final x in s.explorer.hiddenItems)
|
|
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 flows.renames.batch(context, pane, pane.sel.marked);
|
|
} else {
|
|
await flows.renames.rename(context, pane, e.path);
|
|
}
|
|
case 'copy':
|
|
await flows.transfers.transfer(context, pane, sel.isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: false, otherDir: cmd.otherDir);
|
|
case 'move':
|
|
await flows.transfers.transfer(context, pane, sel.isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: true, otherDir: cmd.otherDir);
|
|
case 'clipCopy':
|
|
flows.transfers.toClipboard(sel.isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: false);
|
|
case 'clipMove':
|
|
flows.transfers.toClipboard(sel.isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: true);
|
|
case 'paste':
|
|
await flows.transfers.paste(context, pane, e.isDir ? e.path : vDirname(e.path));
|
|
case 'delete':
|
|
// 77: 메뉴 머리의 "n개 항목" 과 같게 (예전에는 표시한 것이 여럿이어도 누른 것 하나만 지웠다)
|
|
await flows.deletes.run(context, pane, targets);
|
|
case 'share':
|
|
await flows.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 flows.opener.open(context, e.path, pane: pane);
|
|
case 'toPdf':
|
|
await flows.opener.makePdf(context, images);
|
|
case 'reveal':
|
|
await ctl.c.services.shell.revealFile(e.path);
|
|
case 'info':
|
|
await showFileInfo(context, ctl, e);
|
|
}
|
|
}
|
|
}
|