JJ_MKVMaker/lib/ui/explorer_page.dart

2033 lines
93 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

export 'explorer/path_dialog.dart' show looksLikeSmb, smbToUnc;
export 'explorer/explorer_buttons.dart' show ExplorerButton;
export 'explorer/transfer_panel.dart' show TransferProgressChip;
import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'package:flutter/gestures.dart' show kBackMouseButton, kForwardMouseButton;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../app/app_controller.dart';
import '../app/copy_center.dart';
import '../app/transfer_job.dart';
import '../core/secret_gate.dart';
import '../core/file_ops.dart';
import '../core/playlist.dart' show isVideoFile;
import '../core/vfs.dart';
import '../core/sftp.dart' show SftpRegistry;
import '../core/ftp.dart' show FtpRegistry;
import '../core/smb.dart' show SmbRegistry;
import '../core/webdav.dart' show DavRegistry;
import '../platform/android/android_storage.dart';
import 'app_actions.dart';
import 'explorer_look.dart';
import 'theme.dart';
import 'file_error.dart';
import 'folder_picker.dart' show pickFolderOrDav;
import 'path_label.dart';
import 'sftp_settings.dart';
import 'ftp_settings.dart';
import '../l10n/tr.dart';
import 'explorer_bookmarks.dart';
import 'explorer_search.dart';
import 'tail_text.dart';
import '../core/thumb_cache.dart' show thumbMemoryForgetters;
import '../core/shizuku.dart';
import 'explorer_keys.dart' show defaultExplorerKeys, effectiveExplorerKeys;
import 'quick_look.dart';
import 'explorer_drag.dart';
import 'storage_page.dart';
import 'wifi_share_page.dart';
import '../core/wifi_share.dart' show WifiShare;
import 'batch_rename_dialog.dart';
import '../core/batch_rename.dart';
import 'notice.dart';
import '../app/volumes.dart';
import 'explorer/explorer_controller.dart';
import 'explorer/explorer_host.dart';
import 'explorer/explorer_pane.dart';
import 'explorer/file_thumbs.dart';
import 'explorer/explorer_select.dart';
import 'explorer/path_dialog.dart';
import 'explorer/pane_header.dart';
import 'explorer/pane_view.dart';
import 'explorer/explorer_buttons.dart';
import 'explorer/item_menu.dart';
import 'explorer/name_dialog.dart';
import 'explorer/open_flow.dart';
import 'explorer/delete_flow.dart';
import 'explorer/explorer_jobs.dart';
import 'explorer/rsync_flow.dart';
import 'explorer/transfer_flow.dart';
import 'explorer/write_guard.dart';
import 'explorer/transfer_panel.dart';
import 'explorer/marks_bar.dart';
import 'explorer/toolbar.dart';
import 'explorer/server_flows.dart';
export '../core/explorer/tree_rows.dart' show globToRegExp, quickFilterMatch;
export '../core/name_clash.dart' show NameClash, clashCompareText, fileDateText;
/// 파일 탐색기 (X-plore 참고): 두 창 (트리 목록) + 가운데 기능 버튼 줄.
///
/// - 폴더를 누르면 그 자리에서 펼치고 · 접는다 (트리). 마지막으로 누른 폴더가 그 창의 "지금 폴더".
/// - 동영상을 누르면 기본 동작 (내장 플레이어, 또는 환경 설정 > 재생 > 확장자별 재생 프로그램),
/// 길게 누르거나 오른쪽 클릭하면 메뉴 (내장 플레이어 · 다른 앱으로 열기 · MKV 목록에 추가 · 이름 바꾸기 …).
/// - 오른쪽 동그라미로 여러 개를 표시해 복사 · 이동 · 삭제 · 재생한다. 복사 · 이동은 다른 창의 지금 폴더로.
/// - 창 배치 (두 창 / 한 창, 좌우 / 위아래, 버튼 줄 위치) 와 버튼 구성은 바꿀 수 있다 (위쪽 ⋮ 메뉴).
///
/// [rsync] 면 Rsync 화면: 같은 모양이지만 늘 좌우 두 창 · 폴더만 · 창마다 폴더 하나만 고른다.
/// 가운데 → (왼쪽 → 오른쪽) · ← (오른쪽 → 왼쪽) · ⇄ (둘 다 함께) 로 rsync, [모니터링] 에 실행한 것이 남는다.
/// 202 · 221: 창 위 줄을 [← → ★] · 탭 · [WebDAV · 경로 입력] 으로 펴는 가장 작은 창 폭. 이보다 좁으면 끝 버튼은 [⋮] 하나 -
/// 폰 세로 (약 400) 에서 탭 칸이 130px 남짓이라 두 번째 저장소 (SD 카드) 가 잘려 보였다
/// 191: Rsync 화면이 좌우 두 창 + 가운데 버튼 줄로 보이는 가장 작은 폭 (이보다 좁으면 창 하나씩)
const double rsyncTwoPaneMinWidth = 560;
class ExplorerPage extends StatefulWidget {
final AppController c;
final bool rsync;
/// 지금 시각 (폴더 두 번 누르기의 두 번째인지 가르는 데). 시험은 정한 시각을 넘긴다 - 실제 시간에 기대 우연히 통과하지 않게
final DateTime Function() clock;
const ExplorerPage({super.key, required this.c, this.rsync = false, this.clock = DateTime.now});
/// 165: 드라이브를 열 수 있는지 (시험에서 응답 없는 드라이브로 바꾼다). 화면을 멈추지 않는 비동기
@visibleForTesting
static Future<bool> Function(String path) probeDrive = (path) => Directory(path).exists();
/// 시험: 이 기기의 저장 장치 목록 (null 이면 실제 목록)
@visibleForTesting
static List<(String, String)> Function()? debugVolumes;
/// 시험: 경로 입력을 Android 처럼 (SMB 안내, 179). null 이면 실제 기기
@visibleForTesting
static bool? debugAndroidPaths;
/// 시험: Android 앱 휴지통 (X-plore e) 을 Windows 에서. null 이면 실제 기기
@visibleForTesting
static bool? debugAndroidTrash;
/// 시험: 폴더 .nomedia 넣기 · 지우기 (Android 갤러리 · 음악 앱에서 숨기기) 를 Windows 에서. null 이면 Android 만
@visibleForTesting
static bool? debugNomedia;
/// 이번 실행에서 시스템 폴더 경고를 보였는지 (관리자 결정 10/21: 앱을 켠 뒤 처음 열 때 한 번)
static bool shizukuWarnedThisRun = false;
/// 루트도 같게 (따로 센다)
static bool rootWarnedThisRun = false;
/// 시험: 경로 입력 창이 넣은 경로를 고쳐 쓰고 확인하는 것 (갈 경로, 칸 아래 이유)
@visibleForTesting
static Future<(String?, String?)> checkPathForTest(String input) =>
checkEnteredPath(input, android: debugAndroidPaths ?? Platform.isAndroid);
static const routeName = 'explorer';
static const rsyncRouteName = 'rsync';
static int _open = 0, _openRsync = 0;
/// 파일 탐색기로: 이미 열려 있으면 그 화면으로 돌아간다 (보던 폴더 그대로)
static Future<void> open(NavigatorState nav, {required AppController c}) => _show(nav, c, rsync: false);
/// Rsync 화면으로 (이미 열려 있으면 그 화면으로)
static Future<void> openRsync(NavigatorState nav, {required AppController c}) => _show(nav, c, rsync: true);
/// 162: 이 앱의 파일 탐색기로 그 폴더를 연다 (이미 열려 있으면 그 화면에서 그 폴더로). 끝난 작업의 [열기] 등
static Future<void> openAt(NavigatorState nav, {required AppController c, required String dir}) {
goToRequest.value = dir;
return _show(nav, c, rsync: false);
}
/// 열려 있는 (또는 곧 열릴) 탐색기가 갈 폴더 (가면 비운다)
static final goToRequest = ValueNotifier<String?>(null);
static Future<void> _show(NavigatorState nav, AppController c, {required bool rsync}) async {
final name = rsync ? rsyncRouteName : routeName;
if ((rsync ? _openRsync : _open) > 0) {
var found = false;
nav.popUntil((r) {
if (r.settings.name == name) found = true;
return found || r.isFirst;
});
if (found) return;
}
await nav.push(MaterialPageRoute<void>(
settings: RouteSettings(name: name),
builder: (_) => ExplorerPage(c: c, rsync: rsync),
));
}
@override
State<ExplorerPage> createState() => _ExplorerPageState();
}
class _ExplorerPageState extends State<ExplorerPage> implements ExplorerHost, ExplorerPaneParts, ExplorerCommands {
AppController get c => widget.c;
/// 탐색기의 상태 · 화면 밖의 일 (E5d-1) - 화면은 부르기만. 화면에 부탁할 것은 [ExplorerHost] 로 이 상태가 해 준다
late final _ctl = ExplorerController(c, rsync: widget.rsync, host: this)..addListener(_onPane);
@override
bool get alive => mounted;
@override
void snack(String text) => _snack(text);
@override
void reveal(ExplorerPane pane, String path) => _reveal(pane, path);
@override
void afterFrame(VoidCallback f) => WidgetsBinding.instance.addPostFrameCallback((_) => f());
Future<void> _probeDrive(String path) => _ctl.drives.check(path, ExplorerPage.probeDrive);
/// 연결 안 된 드라이브를 누르면 다시 연결 (다시 읽기) - 되면 연다
Future<void> _reconnect(ExplorerPane pane, String path) async {
await _probeDrive(path);
if (!mounted) return;
if (_ctl.drives.ok[path] == true) {
pane.cache.remove(path);
await _ctl.goTo(pane, path);
} else {
_snack(_ctl.drives.removable.contains(path)
? trf('{0} 이 비어 있습니다 (카드 · USB · 디스크를 넣은 뒤 다시 누르세요).', [path])
: trf('{0} 에 연결할 수 없습니다. 네트워크 · NAS 가 켜져 있는지 확인하세요.', [path]));
}
}
/// 화면 단축키 (F5 · Delete …) 를 받는 곳. 76: 빠른 필터 칸을 닫으면 여기로 돌려준다
final _keys = FocusNode(debugLabel: 'explorer');
@override
void initState() {
// 관리자 지시 (10/21): 보관함에 넣은 파일의 썸네일 기억을 잊게 (static 함수라 여러 번 넣어도 하나)
thumbMemoryForgetters
..add(VideoThumbs.forget)
..add(BigVideoThumbs.forget);
super.initState();
if (widget.rsync) {
ExplorerPage._openRsync++;
} else {
ExplorerPage._open++;
}
for (final pane in _ctl.panes) {
pane.addListener(_onPane);
}
_resumeReload = AppLifecycleListener(onResume: () => unawaited(_ctl.reloadOpen(localOnly: true)));
_hiddenSeen = c.settings.explorerHiddenItems;
_kindSeen = c.settings.explorerSortByKind;
c.addListener(_onSettings);
_init();
}
/// 232: 앱으로 돌아오면 (다른 앱 · 탐색기에서 파일을 바꾸고 오면) 보이는 폴더를 다시 읽는다
late final AppLifecycleListener _resumeReload;
@override
void dispose() {
c.removeListener(_onSettings);
_keys.dispose();
if (widget.rsync) {
ExplorerPage._openRsync--;
} else {
ExplorerPage._open--;
ExplorerPage.goToRequest.removeListener(_onGoToRequest);
}
_transfers.dispose();
_jobs.dispose();
_sel.dispose();
_ctl.dispose();
if (_allFiles != null) _life.dispose();
_resumeReload.dispose();
super.dispose();
}
void _onPane() {
if (mounted) setState(() {});
}
Future<void> _init() async {
_ctl.local = ExplorerPage.debugVolumes?.call() ?? await localVolumes();
if (_ctl.local.isEmpty) _ctl.local = [(Platform.isWindows ? r'C:\' : '/', Platform.isWindows ? 'C:' : '/')];
// X-plore 비교 e: 보관 일이 지난 휴지통 항목을 지운다 (뒤에서)
if (_deletes.appTrash case final t?) unawaited(t.purgeOlderThan(c.settings.androidTrashDays).catchError((_) => 0));
if (Platform.isAndroid) unawaited(_checkAccess());
final saved = widget.rsync ? c.settings.rsyncPaths : c.settings.explorerPaths;
// 창마다 따로 읽는다: 한 창이 WebDAV 서버를 기다려도 다른 창 (이 기기 파일) 은 바로 보이게
// 165: 지난 폴더가 느린 네트워크 드라이브여도 화면이 멈추지 않게 (뒤에서 확인, 오래 걸리면 그냥 열어 본다 - 못 읽으면 그 창에 이유)
final reach = await Future.wait(
[for (var i = 0; i < 2; i++) i < saved.length ? reachableAsync(saved[i]) : Future.value(false)]);
// U1 (점검 10/22): 저장된 경로가 없는 창은 창마다 다른 저장 장치 (오른쪽 = 두 번째, 없으면 첫째) - 두 창이 같은 목록을
// 두 번 보이면 [복사] 의 "다른 창으로" 가 같은 곳이 되어 헷갈렸다. 저장된 경로가 있으면 그대로
final wants = [
for (var i = 0; i < 2; i++) reach[i] ? saved[i] : _ctl.local[i < _ctl.local.length ? i : 0].$1,
];
// 162: "이 폴더를 탐색기로" 로 열렸으면 첫 창은 그 폴더 (지난 폴더 대신)
final asked = widget.rsync ? null : ExplorerPage.goToRequest.value;
if (asked != null) {
ExplorerPage.goToRequest.value = null;
wants[0] = asked;
}
for (var i = 0; i < 2; i++) {
_ctl.panes[i]
..root = _ctl.volumeOf(wants[i])
..current = wants[i]
// 48 보충 (관리자): 처음 열면 아무것도 고르지 않은 상태 - 열자마자 폴더 통째가 복사 · 이동 대상이 되지 않게
..focused = null;
}
if (!mounted) return;
setState(() => _ctl.ready = true);
// (이미 열린 화면에 들어온 요청은 그 화면에서)
if (!widget.rsync) ExplorerPage.goToRequest.addListener(_onGoToRequest);
// 52: 다른 화면에 다녀와도 아직 도는 복사 · 이동의 진행 막대를 다시 보이고, 끝나면 그 폴더들을 새로 읽는다
final center = CopyCenter.peekOf(c);
if (center != null) {
for (final job in center.jobs.values.where((j) => !j.finished)) {
_jobs.adopt(job);
}
}
// 165: 드라이브 종류 · 연결은 드라이브마다 따로 뒤에서 (느린 드라이브가 있어도 다른 드라이브 · 창은 바로)
if (Platform.isWindows) {
_ctl.drives.classify([for (final (path, _) in _ctl.local) path]);
for (final (path, _) in _ctl.local) {
unawaited(_probeDrive(path));
}
}
for (var i = 0; i < 2; i++) {
final pane = _ctl.panes[i];
unawaited(_ctl.goTo(pane, wants[i], remember: false).then((_) => _reveal(pane, pane.current)));
}
}
// ───────── 권한 (Android) ─────────
/// "모든 파일에 대한 접근" 권한 (없으면 폴더만 보이고 파일은 안 보인다). null = 아직 모름
bool? _allFiles;
/// 듀얼 앱 (복제한 앱) 인지: 저장소가 /storage/emulated/0 이 아님
bool _dualApp = false;
late final AppLifecycleListener _life = AppLifecycleListener(onResume: () {
if (Platform.isAndroid && _allFiles == false) unawaited(_checkAccess(reload: true));
});
Future<void> _checkAccess({bool reload = false}) async {
_life; // 다시 돌아오면 (권한 화면에서 허용하고 오면) 다시 확인
final ok = await AndroidAccess.hasAllFiles();
var dual = false;
try {
final root = await AndroidAccess.storageRoot();
dual = root.contains('/emulated/') && !root.endsWith('/emulated/0');
} catch (_) {}
if (!mounted) return;
final changed = ok != _allFiles;
setState(() {
_allFiles = ok;
_dualApp = dual;
});
if (reload && changed && ok) {
for (final pane in _ctl.panes) {
pane.cache.clear();
for (final d in pane.expanded.toList()) {
await _ctl.load(pane, d, force: true);
}
}
}
}
/// 권한이 없을 때 목록 위 안내 (이유 · 할 일 · 허용 화면 열기 · 다시 확인)
/// X-plore 비교 5-2: 시스템 폴더를 보는 동안 늘 주황 띠
Widget? _shizukuNotice(ExplorerPane pane) {
if (!isShizuku(pane.current)) return null;
final root = isRootPath(pane.current);
return _notice(
icon: root ? Icons.security : Icons.admin_panel_settings_outlined,
title: root ? tr('루트 (su) - 읽기 전용') : tr('시스템 폴더 (Shizuku) - 읽기 전용'),
body: root
? tr('목록 · 보기 · 꺼내기 (복사) 만 됩니다. 시스템 · 다른 앱의 개인 자료가 있으니 꺼낸 파일을 조심해서 다루세요')
: tr('목록 · 보기 · 꺼내기 (복사) 만 됩니다. 다른 앱의 데이터가 있을 수 있으니 꺼낸 파일을 조심해서 다루세요'),
actions: const [],
color: JjColors.warning,
);
}
/// X-plore 비교 5-2: 루트 탭 - 이번 실행에서 처음이면 경고, su 가 있고 허락을 받았을 때만 "/" 로 (관리자 3a 결정)
Future<void> _openRoot(ExplorerPane pane) async {
if (!ExplorerPage.rootWarnedThisRun) {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
icon: Icon(Icons.security, color: JjColors.warning),
title: Text(tr('루트 폴더 보기 (읽기 전용)')),
content: Text(tr('루트 (su) 권한으로 시스템 · 다른 앱의 폴더 전체를 봅니다. 다른 앱의 개인 자료 (대화 · 로그인 정보 · 비밀번호 등) 도 '
'보일 수 있습니다. 이 앱은 읽기만 하고 (쓰기 · 지우기 · 이름 바꾸기 없음), 꺼낸 파일은 고른 곳에만 둡니다.')),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('알고 봅니다'))),
],
),
);
if (ok != true || !mounted) return;
ExplorerPage.rootWarnedThisRun = true;
}
try {
final st = await ShizukuRemote.rootBackend.status();
if (!st.running) {
_snack(tr('이 기기에는 su 가 없습니다 - 루팅한 기기에서만 쓸 수 있습니다'));
return;
}
if (!st.granted && !await ShizukuRemote.rootBackend.request()) {
_snack(tr('루트 허락이 없어 볼 수 없습니다 (su 앱에서 이 앱을 허락하세요)'));
return;
}
} catch (e) {
_snack(trf('루트에 연결하지 못했습니다: {0}', [e]));
return;
}
if (!mounted) return;
setState(() => _ctl.active = _ctl.panes.indexOf(pane));
await _ctl.goTo(pane, rootHome);
}
/// X-plore 비교 5-2: 시스템 폴더 탭 - 이번 실행에서 처음이면 경고, Shizuku 가 켜져 있고 허락을 받았을 때만 Android 폴더로
Future<void> _openShizuku(ExplorerPane pane) async {
if (!ExplorerPage.shizukuWarnedThisRun) {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
icon: Icon(Icons.admin_panel_settings_outlined, color: JjColors.warning),
title: Text(tr('시스템 폴더 보기 (읽기 전용)')),
content: Text(tr('Shizuku 의 권한으로 Android/data · obb 처럼 막힌 폴더를 봅니다. 다른 앱의 데이터 (대화 · 로그인 정보 등) 가 '
'있을 수 있습니다. 이 앱은 읽기만 하고 (쓰기 · 지우기 · 이름 바꾸기 없음), 꺼낸 파일은 고른 곳에만 둡니다.')),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('알고 봅니다'))),
],
),
);
if (ok != true || !mounted) return;
ExplorerPage.shizukuWarnedThisRun = true;
}
try {
final st = await ShizukuRemote.backend.status();
if (!st.running) {
_snack(tr('Shizuku 가 켜져 있지 않습니다 - Shizuku 앱을 설치하고 (무선 디버깅 · adb 로) 시작한 뒤 다시 누르세요'));
return;
}
if (!st.granted && !await ShizukuRemote.backend.request()) {
_snack(tr('Shizuku 허락이 없어 볼 수 없습니다'));
return;
}
} catch (e) {
_snack(trf('Shizuku 에 연결하지 못했습니다: {0}', [e]));
return;
}
if (!mounted) return;
setState(() => _ctl.active = _ctl.panes.indexOf(pane));
await _ctl.goTo(pane, shizukuHome(_ctl.local.first.$1));
}
Widget? _accessNotice(ExplorerPane pane) {
if (!Platform.isAndroid || _allFiles != false || isDav(pane.root)) return null;
return _notice(
icon: Icons.lock_outline,
title: tr('파일이 보이지 않습니다: "모든 파일에 대한 접근" 권한이 없습니다'),
body: _dualApp
? tr('지금은 듀얼 앱 (복제한 앱) 입니다. Android 가 듀얼 앱에는 이 권한을 주지 않을 수 있습니다. '
'허용 화면에서 켤 수 없으면 배지 없는 원래 JJ_MKVMaker 아이콘으로 여세요.')
: tr('폴더는 보여도 그 안의 동영상 · 파일은 이 권한이 있어야 보입니다. 허용 화면에서 JJ_MKVMaker 를 켜고 돌아오세요.'),
actions: [
FilledButton.tonal(onPressed: AndroidAccess.request, child: Text(tr('권한 허용 화면 열기'))),
TextButton(onPressed: () => _checkAccess(reload: true), child: Text(tr('허용했으면 다시 확인'))),
],
);
}
/// 목록 위 안내 상자
Widget _notice(
{required IconData icon,
required String title,
required String body,
required List<Widget> actions,
Color? color}) =>
_noticeBox(icon, title, body, actions, color ?? JjColors.alert);
Widget _noticeBox(IconData icon, String title, String body, List<Widget> actions, Color color) =>
Container(
width: double.infinity,
margin: const EdgeInsets.fromLTRB(6, 6, 6, 0),
padding: const EdgeInsets.fromLTRB(12, 10, 12, 6),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: color.withValues(alpha: 0.5)),
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Icon(icon, size: 18, color: color),
const SizedBox(width: 8),
Expanded(child: Text(title, style: const TextStyle(fontWeight: FontWeight.w600))),
]),
const SizedBox(height: 4),
Text(body, style: TextStyle(fontSize: 12, color: JjColors.textDim)),
Wrap(spacing: 8, children: actions),
]),
);
// ───────── 만들다 만 파일 (71) ─────────
/// 복사 · 동기화가 중간에 끊겨 남은 임시 파일 (.jjpart · .jjsync). 지금 쓰는 중인 것 (2분 안에 바뀜) 은 빼고
List<FileEntry> _partials(ExplorerPane pane, String dir) {
final old = DateTime.now().subtract(const Duration(minutes: 2));
return [
for (final e in pane.cache[dir] ?? const <FileEntry>[])
if (!e.isDir && isPartialFile(e.name) && e.modified.isBefore(old)) e,
];
}
Widget? _partialNotice(ExplorerPane pane, String dir) {
final list = _partials(pane, dir);
if (list.isEmpty) return null;
return _notice(
icon: Icons.broken_image_outlined,
color: JjColors.warning,
title: trf('만들다 만 파일 {0}개', [list.length]),
body: tr('복사 · 동기화가 중간에 끊겨 남은 임시 파일입니다 (.jjpart). 완성된 파일이 아니므로 지워도 됩니다. '
'같은 파일을 다시 복사하면 새로 만듭니다.'),
actions: [
TextButton(
onPressed: () async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(trf('만들다 만 파일 {0}개를 지울까요?', [list.length])),
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
for (final e in list.take(50)) Text(e.name, style: const TextStyle(fontSize: 12)),
if (list.length > 50) Text(trf('… 외 {0}개', [list.length - 50])),
]),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('지우기'))),
],
),
);
if (ok != true) return;
final failed = <String>[];
for (final e in list) {
try {
await vDelete(e.path);
} catch (_) {
failed.add(e.name);
}
}
// 같은 폴더를 보는 다른 창도 새로
for (final q in _ctl.panes) {
if (q == pane || q.cache.containsKey(dir)) await _ctl.load(q, dir, force: true);
}
if (failed.isNotEmpty) _snack(trf('지우지 못한 파일: {0}', [failed.join(', ')]));
},
child: Text(tr('지우기')),
),
],
);
}
// ───────── 읽을 수 없을 때 (12) ─────────
/// 오류 → (무엇이 문제인지, 무엇을 하면 되는지)
Widget? _errorNotice(ExplorerPane pane, String dir) {
final err = pane.errors[dir] ?? pane.errors[pane.root];
if (err == null) return null;
final where = pane.errors[dir] != null ? dir : pane.root;
final dav = isDav(where);
final server = isWebDav(where) ? DavRegistry.server(DavPath.parse(where).server) : null;
final sftp = isSftp(where) ? SftpRegistry.server(DavPath.parse(where).server) : null;
final smb = isSmb(where) ? SmbRegistry.server(DavPath.parse(where).server) : null;
final ftp = isFtp(where) ? FtpRegistry.server(DavPath.parse(where).server) : null;
// l: 처음 보는 FTPS 자체 서명 인증서 - 지문을 보이고 [지문 확인하고 연결] (바뀐 인증서는 막고 서버 설정으로)
final cert = pane.certErrors[where] ?? pane.certErrors[pane.root];
if (ftp != null && cert != null) {
return _notice(
icon: cert.changed ? Icons.gpp_bad_outlined : Icons.fingerprint,
title: '${_ctl.displayPath(where)}: ${cert.changed ? tr('서버 인증서가 바뀌었습니다') : tr('처음 보는 서버 인증서입니다')}',
body: cert.message,
actions: [
if (!cert.changed)
FilledButton.tonal(
onPressed: () async {
if (!await askTrustFtpCert(context, ftp.label, cert)) return;
await trustFtpCert(c, ftp.id, cert);
pane.certErrors.remove(where);
pane.errors.remove(where);
pane.changed();
await _ctl.load(pane, where, force: true);
},
child: Text(tr('지문 확인하고 연결')),
),
TextButton(onPressed: () => _servers.editFtp(context, pane, pane.root), child: Text(tr('서버 설정 고치기'))),
],
);
}
// 88: 처음 보는 SFTP 서버 키 - 지문을 보이고 [지문 확인하고 연결] (바뀐 키는 막고 서버 설정으로)
final hostKey = pane.hostKeyErrors[where] ?? pane.hostKeyErrors[pane.root];
if (sftp != null && hostKey != null) {
return _notice(
icon: hostKey.changed ? Icons.gpp_bad_outlined : Icons.fingerprint,
title: '${_ctl.displayPath(where)}: ${hostKey.changed ? tr('서버의 키가 바뀌었습니다') : tr('처음 연결하는 서버입니다')}',
body: hostKey.message,
actions: [
if (!hostKey.changed)
FilledButton.tonal(
onPressed: () async {
if (!await askTrustHostKey(context, sftp.label, hostKey)) return;
await trustSftpHostKey(c, sftp.id, hostKey);
pane.hostKeyErrors.remove(where);
pane.errors.remove(where);
pane.changed();
await _ctl.load(pane, where, force: true);
},
child: Text(tr('지문 확인하고 연결')),
),
TextButton(onPressed: () => _servers.editSftp(context, pane, pane.root), child: Text(tr('서버 설정 고치기'))),
],
);
}
final (title, body) = explainFileError(err,
dav: dav,
noPassword: (server != null && server.password.isEmpty) ||
(sftp != null && sftp.password.isEmpty && sftp.privateKey.isEmpty) ||
(smb != null && smb.user.isNotEmpty && smb.password.isEmpty) ||
(ftp != null && ftp.user.isNotEmpty && ftp.password.isEmpty));
final locked = isLockedError(err);
// 50: 막힌 폴더는 다시 해도 같으니 [다시 시도] 없이 [위 폴더로]
if (err == androidRestrictedError) {
return _notice(
icon: Icons.lock_outline,
title: '${_ctl.displayPath(where)}: $title',
body: body,
actions: [
FilledButton.tonal(
onPressed: () {
pane.errors.remove(where);
_ctl.goTo(pane, vDirname(where));
},
child: Text(tr('위 폴더로')),
),
],
);
}
return _notice(
icon: dav ? Icons.cloud_off_outlined : Icons.error_outline,
title: '${_ctl.displayPath(where)}: $title',
body: body,
actions: [
FilledButton.tonal(
onPressed: () async {
// 133: 마스터를 취소해 막혔으면 여기서 다시 묻는다
if (locked && !await SecretGate.pass(force: true)) return;
pane.errors.remove(where);
pane.changed();
await _ctl.load(pane, where, force: true);
for (final d in pane.expanded.toList()) {
if (isSameOrInside(d, where) && d != where) await _ctl.load(pane, d, force: true);
}
},
child: Text(locked ? tr('마스터 비밀번호 넣기') : tr('다시 시도')),
),
// 시스템 폴더 (Shizuku) 는 고칠 서버 설정이 없다
if (dav && !locked && !isShizuku(where))
TextButton(
onPressed: () => sftp != null
? _servers.editSftp(context, pane, pane.root)
: smb != null
? _servers.editSmb(context, pane, pane.root)
: ftp != null
? _servers.editFtp(context, pane, pane.root)
: _servers.editDav(context, pane, pane.root),
child: Text(tr('서버 설정 고치기'))),
],
);
}
/// 화면에 보일 경로 (5 · 14): Android 는 "SD 카드 › JJ_sdtest › jj_mkv", WebDAV 는 "☁ 서버 › 폴더", Windows 는 그대로
/// 그 항목이 목록 위쪽 1/4 쯤에 보이게 스크롤 (깊은 폴더로 가면 위쪽 폴더들의 다른 항목에 밀려 안 보이므로)
void _reveal(ExplorerPane pane, String path) => WidgetsBinding.instance.addPostFrameCallback((_) => _revealNow(pane, path));
void _revealNow(ExplorerPane pane, String path) {
if (!mounted || !pane.scroll.hasClients) return;
// 화면의 트리와 같은 줄로 센다 (폴더 + 파일 목록 배치 · Rsync 화면은 폴더만)
final i = _ctl.rows(pane, foldersOnly: _split || widget.rsync).indexWhere((r) => r.kind == 0 && samePath(r.entry.path, path));
if (i < 0) return;
final pos = pane.scroll.position;
// 180: 끝 (maxScrollExtent) 으로 자르지 않는다 - 줄이 막 늘어난 때는 끝이 아직 앞의 것이라 보던 폴더로 못 간다.
// 넘치면 다음 배치 때 실제 끝으로 맞춰진다
pane.scroll.jumpTo((i * _rowH() - pos.viewportDimension / 4).clamp(0.0, double.infinity));
}
/// 28 확인 (10/20): 창의 목록이 화면에 새로 붙으면 (좁은 Rsync 화면의 [오른쪽] 탭을 처음 볼 때 · 폭이 바뀌어 두 창이 될 때)
/// 그 창의 지금 폴더를 보이게 한다. 켤 때 그려지지 않은 창은 [_ctl.goTo] 의 스크롤을 건너뛰어, 나중에 보면 맨 위 (저장 장치) 에 있었다
void _revealNewViews() => WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
for (final pane in _ctl.panes) {
final on = pane.scroll.hasClients;
if (on && !pane.onScreen && pane.current.isNotEmpty) _revealNow(pane, pane.current);
pane.onScreen = on;
}
});
/// X-plore 비교 40: 목록 글자 크기 (설정 %) · 줄 간격을 넣은 줄 높이
double get _listTextScale => c.settings.explorerTextScale / 100;
double _rowH() => _ctl.look.rowHeightOf(context, textScale: _listTextScale, spacing: rowSpacingFactor(c.settings.explorerRowSpacing));
/// 왼쪽 폴더 트리 + 오른쪽 파일 목록 배치
bool get _split => !widget.rsync && c.settings.explorerLayout == 'split' && !_splitTight;
/// 관리자 지시 10/20 (28 검토): 글자가 커서 (기기 글자 × 목록 글자 크기) 좁은 화면에 트리 · 목록 두 창이 다 들어가지 않으면
/// 그동안만 한 창 (트리 + 위 경로 막대) 으로 보인다 - 고른 배치 (설정) 는 그대로, 화면이 넓어지거나 글자가 작아지면 다시 두 창. [_body] 가 정한다
bool _splitTight = false;
/// 관리자 지시 10/20: 좁은 화면 (폭 < [narrowSplitWidth]) 의 "폴더 + 파일 목록" 좌우 배치 - 트리 창에 최소 폭을 두고
/// ✔ 칸 · 크기 줄을 숨긴다 (412 폭에서 트리가 77px 이라 폴더 이름이 한 글자도 안 보였다). [_body] 가 정한다
bool _narrowSplit = false;
static const narrowSplitWidth = 600.0;
/// 좌우 두 창 (Rsync 화면은 늘)
bool get _dual =>
widget.rsync ||
c.settings.explorerLayout == 'dual' ||
// 자동: 넓으면 두 창, 좁은 화면 (폰 세로) 은 한 창 - 같은 목록이 반씩 잘려 두 번 보이지 않게
(c.settings.explorerLayout == 'auto' && !isCompact(context));
/// 트리 줄. [foldersOnly]: 폴더만 ("폴더 + 파일 목록" 배치의 왼쪽)
/// X-plore 비교 c: 트리 맨 위에 저장 장치 · 서버 · 즐겨찾기 (X-plore 스타일 · 설정)
/// X-plore 비교 ⚖: X-plore 스타일에서 표시 동그라미 늘 보이기 (설정)
bool get _alwaysMarks => !widget.rsync && _ctl.look == ExplorerStyle.xplore && c.settings.explorerAlwaysMarks;
/// X-plore 비교 ⚖: X-plore 스타일 탐색기 안에서 밀기 = 창 바꾸기 (설정)
bool get _swipePanes => !widget.rsync && _ctl.look == ExplorerStyle.xplore && c.settings.explorerSwipePanes;
/// 밀어서 창 바꾸기: 두 창이면 다른 창을 활성으로, 한 창이면 숨은 창과 바꿔 보인다
bool _switchPane(int dir) {
if (!_swipePanes) return false;
setState(() {
if (_dual || _split) {
_ctl.active = 1 - _ctl.active;
} else {
final t = _ctl.panes[0];
_ctl.panes[0] = _ctl.panes[1];
_ctl.panes[1] = t;
_ctl.active = 0;
}
});
_ctl.remember(_ctl.pane, _ctl.pane.current);
_snack(trf('창 바꿈: {0}', [_ctl.displayPath(_ctl.pane.current)]));
return true;
}
/// 설정 화면에서 숨긴 목록을 바꾸면 (풀기) 바로 목록에 보이게
List<String>? _hiddenSeen;
/// 종류별 기본 정렬 (5-1 ①) 을 켜고 끈 것 - 바뀌면 읽어 둔 목록을 다시 줄 세운다
bool? _kindSeen;
void _onSettings() {
if (!mounted) return;
if (_kindSeen != null && _kindSeen != c.settings.explorerSortByKind) {
for (final pane in _ctl.panes) {
for (final k in pane.cache.keys.toList()) {
pane.cache[k] = _ctl.sorted(pane.cache[k]!, k);
}
pane.changed();
}
}
_kindSeen = c.settings.explorerSortByKind;
final now = c.settings.explorerHiddenItems;
if (identical(now, _hiddenSeen)) return;
_hiddenSeen = now;
setState(() {});
for (final q in _ctl.panes) {
q.changed();
}
}
// ───────── 창 보기 (E5d-3: ExplorerPaneView · ExplorerListView 가 부르는 것) ─────────
@override
double get rowHeight => _rowH();
@override
double get listTextScale => _listTextScale;
@override
bool get narrowSplit => _narrowSplit;
@override
bool get alwaysMarks => _alwaysMarks;
@override
Widget frame(int i, List<Widget> children) => _frame(i, children);
@override
Widget header(ExplorerPane pane) => ExplorerPaneHeader(
ctl: _ctl,
servers: _servers,
pane: pane,
android: ExplorerPage.debugAndroidPaths ?? Platform.isAndroid,
onBookmarks: () => openBookmarks(pane),
onOpenFilter: () => _openFilter(pane),
onCloseFilter: () => _closeFilter(pane),
onOpenRoot: () => _openRoot(pane),
onOpenShizuku: () => _openShizuku(pane),
onReconnect: (path) => _reconnect(pane, path),
);
@override
List<Widget?> notices(ExplorerPane pane, String dir) =>
[_shizukuNotice(pane), _accessNotice(pane), _errorNotice(pane, dir), _partialNotice(pane, dir)];
@override
Widget marksBar(ExplorerPane pane, {String? listDir}) => ExplorerMarksBar(sel: _sel, pane: pane, listDir: listDir);
@override
void onMenu(ExplorerPane pane, FileEntry e, Offset at) => _itemMenu.show(context, pane, e, at);
@override
List<String> dragPaths(ExplorerPane pane, FileEntry e) => _dragPaths(pane, e);
@override
Future<void> onDrop(DragLoad load, int to, String dest) => _onDrop(load, to, dest);
@override
void closeFilter(ExplorerPane pane) => _closeFilter(pane);
@override
void filterChanged() => setState(() {});
/// 기능 버튼 → 할 일 (E5d-4)
late final _buttons = ExplorerButtons(_ctl, _sel, this, guard: _guard, transfers: _transfers, deletes: _deletes, rsync: _rsyncFlow, opener: _opener);
/// 읽기 전용인 곳 막기 · 진행 작업 · 복사 · 이동 · rsync · 지우기 (E5d-5)
late final _guard = ExplorerGuard(_ctl);
late final _jobs = ExplorerJobs(_ctl)..addListener(_onPane);
late final _transfers = ExplorerTransfers(_ctl, _guard, _jobs)..addListener(_onPane);
late final _rsyncFlow = ExplorerRsyncFlow(_ctl, _jobs, _transfers);
late final _deletes = ExplorerDeleteFlow(_ctl, _sel, _guard, androidTrash: () => ExplorerPage.debugAndroidTrash ?? Platform.isAndroid);
@override
String? get otherDir => _other?.current;
/// 시험: [휴지통] 열기 (E5e 에서 지움)
@visibleForTesting
Future<void> debugOpenTrash() => _deletes.openTrash(context);
/// 줄 메뉴 (E5d-5)
late final _itemMenu = ExplorerItemMenu(_ctl, _sel, this,
guard: _guard, transfers: _transfers, deletes: _deletes, opener: _opener, nomedia: () => ExplorerPage.debugNomedia ?? Platform.isAndroid);
@override
bool get dual => _dual;
/// 파일 열기 · 보기 · 재생 (E5d-5)
late final ExplorerOpener _opener = ExplorerOpener(_ctl, _sel, _guard,
otherDirOf: (pane) => _dual ? _ctl.panes[1 - _ctl.panes.indexOf(pane)].current : null);
/// 서버 추가 · 고치기 · 경로 입력 (E5d-3)
late final _servers = ExplorerServers(_ctl);
// ───────── 누르기 · 고르기 (E5d-2: ExplorerSelect) ─────────
late final _sel = ExplorerSelect(_ctl, clock: widget.clock, openFile: (path, pane) => _opener.open(context, path, pane: pane))
..addListener(_onPane);
/// ZIP: 환경 설정이 "만화 보기" 면 안의 그림을 바로 보고 (그림이 없으면 목록), 아니면 목록
/// 87: 저장 공간 분석 → 고른 것을 탐색기에서 그 자리로 (지우기는 탐색기의 지우기로만).
/// WebDAV 는 직접 시킬 때만 (이 메뉴) 하고, 서버에서 모두 읽어 오래 걸린다고 먼저 알린다
@override
Future<void> openStorage(ExplorerPane pane, String dir) async {
setState(() => _ctl.active = _ctl.panes.indexOf(pane));
if (isDav(dir)) {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(tr('저장 공간 분석')),
content: Text(tr('WebDAV · SFTP 는 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다. 분석할까요?')),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('분석'))),
],
),
);
if (ok != true || !mounted) return;
}
// X-plore 비교 5-1 ⑤ (관리자 지시 10/21): 분석 화면에서도 이 탐색기의 지우기 (확인 · 휴지통) · 이동 (붙여넣기로)
final path = await StorageAnalysisPage.open(context, dir, onDelete: (paths) async {
await _deletes.run(context, pane, paths);
return [for (final x in paths) if (!await vExists(x)) x];
}, onCut: (paths) => _transfers.toClipboard(paths, move: true));
if (path == null || !mounted) return;
final isDir = await vIsDir(path);
await _ctl.goTo(pane, isDir ? path : vDirname(path), fresh: true);
pane.focused = path;
pane.changed();
_reveal(pane, path);
}
/// 89: Wi-Fi 공유 화면 (이미 켜져 있으면 그 공유를 보인다). 이 기기의 폴더만 - WebDAV 등 원격은 알림
@override
Future<void> openWifiShare(String dir) async {
final on = WifiShare.instance;
if (on == null && isDav(dir)) {
_snack(tr('Wi-Fi 공유는 이 기기의 폴더만 할 수 있습니다'));
return;
}
await WifiSharePage.open(context, c, on?.root ?? dir);
}
/// 89: 탐색기 위의 [Wi-Fi 공유 중] 줄 - 화면을 닫아도 켜져 있음을 늘 보이게 (사용자가 모르게 열려 있지 않게)
Widget _wifiBar(WifiShare s) {
final left = s.endsAt?.difference(DateTime.now());
return Material(
color: JjColors.panelHigh,
child: InkWell(
onTap: () => WifiSharePage.open(context, c, s.root),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(children: [
Icon(Icons.wifi_tethering, size: 18, color: JjColors.success),
const SizedBox(width: 6),
Expanded(
child: Text(
left == null
? trf('Wi-Fi 공유 중 · 코드 {0} · {1}', [s.code, s.root])
: trf('Wi-Fi 공유 중 · 코드 {0} · {1}분 남음 · {2}', [s.code, (left.inSeconds / 60).ceil().clamp(0, 9999), s.root]),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12)),
),
TextButton(onPressed: () => s.stop(), child: Text(tr('끄기'))),
]),
),
),
);
}
// ───────── 기능 ─────────
ExplorerPane? get _other => _dual || _hiddenTarget ? _ctl.panes[1 - _ctl.active] : null;
// ───────── X-plore 비교 f: 압축을 트리 안에서 폴더처럼 (ZIP · 7z · RAR, 읽기 전용) ─────────
/// X-plore 비교 a: 한 창인데 "숨은 다른 창으로" 복사 · 이동 (위에 갈 곳 줄)
bool get _hiddenTarget => !widget.rsync && !_dual && !_split && c.settings.explorerOnePaneCopy == 'other';
/// 폴더 이름 (저장 장치 맨 위면 그 이름) - 좁은 줄에 경로 대신 (전체 경로는 풍선 도움말)
String _folderName(String dir) =>
_ctl.volumes.firstWhere((v) => samePath(v.$1, dir), orElse: () => (dir, vBasename(dir).isEmpty ? _ctl.displayPath(dir) : vBasename(dir))).$2;
/// X-plore 비교 a: 한 창일 때 맨 위의 "→ 갈 곳" 줄 - 숨은 창의 폴더 · [지금 폴더로] · [다른 폴더]
Widget _targetBar() {
final t = _ctl.panes[1 - _ctl.active];
return Material(
color: JjColors.accent.withValues(alpha: 0.10),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: Row(children: [
Icon(Icons.arrow_forward, size: 18, color: JjColors.accent),
const SizedBox(width: 6),
// 412 캡처 (10/18): "갈 곳:" 은 늘 보이고, 폴더 이름이 길면 가운데를 줄인다 (전체 경로는 길게 눌러 · 마우스를 올려)
Expanded(
child: Tooltip(
message: _ctl.displayPath(t.current),
triggerMode: TooltipTriggerMode.longPress,
child: LayoutBuilder(builder: (context, box) {
const style = TextStyle(fontSize: 12.5);
final label = trf('갈 곳: {0}', ['']);
final room = box.maxWidth - textWidth(context, label, style);
return Text('$label${middleEllipsis(context, _folderName(t.current), style, room)}', maxLines: 1, overflow: TextOverflow.clip, style: style);
}),
),
),
TextButton(
style: TextButton.styleFrom(visualDensity: VisualDensity.compact),
onPressed: () => _ctl.goTo(t, _ctl.pane.current),
child: Text(tr('지금 폴더로')),
),
TextButton(
style: TextButton.styleFrom(visualDensity: VisualDensity.compact),
onPressed: () async {
final d = await pickFolderOrDav(context, tr('복사 · 이동 갈 곳'), t.current);
if (d != null && mounted) await _ctl.goTo(t, d);
},
child: Text(tr('다른 폴더')),
),
]),
),
);
}
void _snack(String text) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text)));
}
@override
Future<void> addMkv(List<String> paths) async {
if (paths.any(isDav)) {
_snack(tr('WebDAV · SFTP 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.'));
return;
}
final videos = <String>[];
final unreadable = <String>[];
for (final x in paths) {
if (vIsDirSync(x)) {
try {
videos.addAll((await listEntries(x)).where((e) => !e.isDir && isVideoFile(e.path)).map((e) => e.path));
} catch (_) {
unreadable.add(vBasename(x)); // 읽지 못한 폴더는 건너뛰고 알린다
}
} else if (isVideoFile(x)) {
videos.add(x);
}
}
if (unreadable.isNotEmpty) _snack(trf('읽을 수 없어 건너뛴 폴더: {0}', [unreadable.take(3).join(', ')]));
if (videos.isEmpty) {
if (unreadable.isEmpty) _snack(tr('추가할 동영상이 없습니다.'));
return;
}
await c.addVideos(videos, allowOutputFolder: true);
_snack(trf('MKV 목록에 {0}개를 추가했습니다.', [videos.length]));
}
@override
Future<void> renameItem(ExplorerPane pane, String path) async {
if (_guard.blockArchive([path])) return;
final name = await askName(context, tr('이름 변경'), vBasename(path));
if (name == null || name == vBasename(path)) return;
try {
final now = await FileOps.rename(path, name);
await _ctl.followHidden(moved: [(path, now)]);
await _ctl.refreshAll([vDirname(path)]);
for (final x in _ctl.panes) {
x.sel.rename(path, now);
}
pane.focused = now;
pane.changed();
} catch (e) {
_snack(trf('이름을 바꿀 수 없습니다: {0}', [e]));
}
}
/// 78: 일괄 이름 바꾸기 - 번호는 목록에 보이는 차례대로. 끝나면 [되돌리기]
@override
Future<void> batchRename(ExplorerPane pane, List<String> paths) async {
if (_guard.blockArchive(paths)) return;
int at(String x) {
final i = (pane.cache[vDirname(x)] ?? const <FileEntry>[]).indexWhere((e) => samePath(e.path, x));
return i < 0 ? 1 << 30 : i;
}
final ordered = [...paths]..sort((a, b) {
final d = vDirname(a).compareTo(vDirname(b));
return d != 0 ? d : at(a).compareTo(at(b));
});
final done = await showBatchRename(context, ordered,
isDir: vIsDirSync,
initial: RenameRule.fromJson(c.settings.batchRenameLast),
onRun: (r) => c.updateSettings((s) => s.batchRenameLast = r.toJson()));
if (done == null || done.isEmpty || !mounted) return;
await _afterBatchRename(pane, done);
if (!mounted) return;
showNotice(ScaffoldMessenger.of(context),
text: trf('{0}개의 이름을 바꿨습니다.', [done.length]),
action: (
tr('되돌리기'),
() async {
try {
final back = await runBatchRename([for (final (orig, now) in done) (now, vBasename(orig))]);
if (mounted) await _afterBatchRename(pane, back);
} on BatchRenameException catch (e) {
_snack(trf('되돌리지 못했습니다: {0}', [e]));
}
}
));
}
/// 바꾼 뒤: 목록을 다시 읽고, 표시 · 초점을 새 이름으로
Future<void> _afterBatchRename(ExplorerPane pane, List<(String, String)> moved) async {
await _ctl.followHidden(moved: moved);
await _ctl.refreshAll({for (final (a, _) in moved) vDirname(a)});
for (final x in _ctl.panes) {
for (final (from, to) in moved) {
x.sel.rename(from, to);
if (x.focused != null && samePath(x.focused!, from)) x.focused = to;
}
x.changed();
}
}
void _onGoToRequest() {
final dir = ExplorerPage.goToRequest.value;
if (dir == null || !_ctl.ready || !mounted) return;
ExplorerPage.goToRequest.value = null;
unawaited(_ctl.goTo(_ctl.pane, dir, fresh: true).then((_) => _reveal(_ctl.pane, dir)));
}
/// 79: 마지막 찾기 조건 (이번 실행 동안 다음 찾기 창의 처음 값)
SearchConditions _lastSearch = const SearchConditions();
/// 79-a: 결과 화면에서 복사 · 이동 · 지우기 · [그 자리로] 를 한 뒤 [찾기 결과로 돌아가기] 로 다시 열 결과 (다음 찾기 · ✕ · 결과 화면을 그냥 닫으면 없어짐)
({ExplorerPane pane, String root, SearchConditions cond, List<FileEntry> found, bool done, Map<String, String> moved})? _searchBack;
/// 79: 찾기 - 이름 · 크기 · 바뀐 날 · 종류 조건 → 결과 목록 화면 (고르고 그대로 복사 · 이동 · 지우기, 누르면 열기 · 재생)
@override
Future<void> openSearch(ExplorerPane pane) async {
setState(() => _ctl.active = _ctl.panes.indexOf(pane));
final cond = await askSearchConditions(context, pane.current, _lastSearch);
if (cond == null || !mounted) return;
_lastSearch = cond;
setState(() => _searchBack = null);
await _searchPage(pane, pane.current, cond);
}
/// 79-a: [찾기 결과로 돌아가기] - 다시 찾지 않고, 그사이 지운 것은 빼고 옮긴 것은 새 자리로
Future<void> _backToSearch() async {
final back = _searchBack;
if (back == null) return;
final now = <FileEntry>[];
for (final e in back.found) {
if (await vExists(e.path)) {
now.add(e);
continue;
}
final dest = back.moved[e.path];
final there = dest == null ? null : vJoin(dest, vBasename(e.path));
if (there != null && await vExists(there)) {
now.add(FileEntry(there, isDir: e.isDir, size: e.size, modified: e.modified));
}
// 고른 폴더와 함께 옮겨진 안쪽 것: 옮긴 폴더 아래 같은 자리
else {
for (final entry in back.moved.entries) {
if (!isSameOrInside(e.path, entry.key)) continue;
// 옮긴 폴더 이름 아래 남은 경로 (같은 꼴의 경로라 앞부분을 잘라 쓴다 - WebDAV 도)
final rest = e.path.length > entry.key.length ? e.path.substring(entry.key.length + 1) : '';
final moved = rest.isEmpty ? vJoin(entry.value, vBasename(entry.key)) : vJoin(vJoin(entry.value, vBasename(entry.key)), rest);
if (await vExists(moved)) now.add(FileEntry(moved, isDir: e.isDir, size: e.size, modified: e.modified));
break;
}
}
}
if (!mounted) return;
await _searchPage(back.pane, back.root, back.cond, initial: now, initialDone: back.done);
}
Future<void> _searchPage(ExplorerPane pane, String root, SearchConditions cond, {List<FileEntry>? initial, bool initialDone = true}) async {
final other = _other?.current;
final act = await Navigator.of(context).push<SearchAction>(MaterialPageRoute(
builder: (_) => SearchResultsPage(
root: root,
cond: cond,
showHidden: c.settings.explorerShowHidden,
otherDir: other,
onOpen: (e) => _opener.open(context, e.path, pane: pane),
initial: initial,
initialDone: initialDone,
),
));
if (!mounted) return;
// 결과 화면을 그냥 닫으면 돌아갈 결과도 없앤다
if (act == null || act.paths.isEmpty) {
setState(() => _searchBack = null);
return;
}
// 관리자 판단 (10/17): [그 자리로] 로 나가도 남긴다 - 한 곳을 확인하고 다음 결과로 가는 것이 가장 흔한 쓰임
{
setState(() => _searchBack = (
pane: pane,
root: root,
cond: cond,
found: act.found,
done: act.done,
moved: act.kind == 'move' && other != null ? {for (final x in act.paths) x: other} : const <String, String>{},
));
}
switch (act.kind) {
case 'reveal':
final path = act.paths.first;
final dir = await vIsDir(path);
await _ctl.goTo(pane, dir ? path : vDirname(path), fresh: true);
pane.focused = path;
pane.changed();
_reveal(pane, path);
// 고른 것만 대상 (154 · 155) - 탐색기의 복사 · 이동 · 지우기와 같은 확인 · 진행 패널 · 휴지통
case 'copy':
await _transfers.transfer(context, pane, act.paths, move: false, otherDir: otherDir);
case 'move':
await _transfers.transfer(context, pane, act.paths, move: true, otherDir: otherDir);
case 'delete':
await _deletes.run(context, pane, act.paths);
}
}
/// 79-a: 탐색기 위의 [찾기 결과로 돌아가기] 줄
Widget _searchBackBar() {
final back = _searchBack!;
return Material(
color: JjColors.panelHigh,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(children: [
Icon(Icons.manage_search, size: 18, color: JjColors.accent),
const SizedBox(width: 6),
Expanded(
child: Text(trf('찾기 결과 ({0}) · {1}', [back.cond.summary, back.root]),
maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 12, color: JjColors.textDim)),
),
TextButton(onPressed: _backToSearch, child: Text(tr('찾기 결과로 돌아가기'))),
IconButton(
tooltip: tr('찾기 결과 닫기'),
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.close, size: 16),
onPressed: () => setState(() => _searchBack = null),
),
]),
),
);
}
/// X-plore 맞추기 (관리자 지시 10/20): [목록에서 숨기기] 전에 무엇이 바뀌는지 알림. [다음부터 묻지 않기] 는 환경 설정에서 다시 켬
@override
Future<void> sortDialog() async {
// 84: "폴더마다 기억" 이면 지금 창의 폴더 정렬
final dir = _ctl.pane.current;
var (by, desc) = _ctl.sortOf(dir);
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, set) => AlertDialog(
scrollable: true,
title: Text(tr('정렬 기준')),
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Wrap(spacing: 6, children: [
for (final (v, label) in [
('name', tr('이름')),
('date', tr('바뀐 날')),
('size', tr('크기')),
('type', tr('종류 (확장자)')),
])
ChoiceChip(label: Text(label), selected: by == v, onSelected: (_) => set(() => by = v)),
]),
SwitchListTile(value: desc, title: Text(tr('거꾸로')), onChanged: (x) => set(() => desc = x)),
if (c.settings.explorerSortPerFolder)
Text(trf('이 폴더에만: {0}', [_ctl.displayPath(dir)]), 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(tr('확인'))),
],
),
),
);
if (ok != true) return;
await _ctl.setSort(dir, by, desc);
}
/// 73: 즐겨찾기 - 이 창으로 가기, Rsync 화면이면 짝 (왼쪽 · 오른쪽) 을 한 번에
@override
Future<void> openBookmarks(ExplorerPane pane) async {
setState(() => _ctl.active = _ctl.panes.indexOf(pane));
await showFolderBookmarks(
context,
c,
current: pane.current,
right: widget.rsync ? _ctl.panes[1].current : null,
go: (dir) => _ctl.goTo(pane, dir, fresh: true),
goPair: widget.rsync
? (a, b) {
_ctl.goTo(_ctl.panes[0], a, fresh: true);
_ctl.goTo(_ctl.panes[1], b, fresh: true);
}
: null,
);
if (mounted) setState(() {});
}
@override
Future<void> historyDialog(ExplorerPane pane) async {
final hist = c.settings.explorerHistory.where(reachable).toList();
final pick = await showDialog<String>(
context: context,
builder: (ctx) => SimpleDialog(
title: Text(tr('내역 (최근 폴더)')),
children: [
if (hist.isEmpty) Padding(padding: const EdgeInsets.all(16), child: Text(tr('없음'))),
for (final h in hist)
SimpleDialogOption(
onPressed: () => Navigator.pop(ctx, h),
child: Text(h, maxLines: 2, overflow: TextOverflow.ellipsis),
),
],
),
);
if (pick != null) await _ctl.goTo(pane, pick, fresh: true);
}
/// 118: 두 창이면 좌우 ⇆ 위아래 를 바꾸고, 한 창이면 두 창 (좌우) 으로. 한 번 눌러 바로
@override
Future<void> toggleOrientation() async {
final s = c.settings;
if (!(_dual || _split)) {
await c.updateSettings((x) => x
..explorerLayout = 'dual'
..explorerOrientation = 'side');
} else {
// 지금 보이는 배치의 반대 ('화면 모양 따라' 였으면 지금 모양에서 정함)
final size = MediaQuery.sizeOf(context);
final nowSide = switch (s.explorerOrientation) {
'side' => true,
'stacked' => false,
_ => size.width >= size.height,
};
await c.updateSettings((x) => x.explorerOrientation = nowSide ? 'stacked' : 'side');
}
if (!mounted) return;
setState(() {});
for (final pane in _ctl.panes) {
_reveal(pane, pane.current);
}
}
@override
Future<void> layoutDialog() async {
final s = c.settings;
var layout = s.explorerLayout, orient = s.explorerOrientation, bar = s.explorerToolbar;
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, set) {
Widget group(String title, String value, List<(String, String)> items, void Function(String) on) =>
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Padding(
padding: const EdgeInsets.only(top: 8, left: 4),
child: Text(title, style: const TextStyle(fontWeight: FontWeight.w600)),
),
Wrap(spacing: 6, children: [
for (final (v, label) in items)
ChoiceChip(label: Text(label), selected: value == v, onSelected: (_) => set(() => on(v))),
]),
]);
return AlertDialog(
scrollable: true,
title: Text(tr('창 배치')),
content: SizedBox(
width: 420,
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
group(tr('창'), layout, [
('auto', tr('화면 크기 따라')),
('dual', tr('두 창 (기본)')),
('split', tr('폴더 + 파일 목록')),
('single', tr('한 창')),
], (v) => layout = v),
if (layout == 'split')
Padding(
padding: const EdgeInsets.only(top: 6, left: 4),
child: Text(tr('왼쪽에 폴더, 오른쪽에 왼쪽에서 고른 폴더의 파일들을 보여 줍니다 (Windows 탐색기처럼).'),
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
),
if (layout != 'single')
group(tr('두 창 배치'), orient, [
('side', tr('좌우 (기본)')),
('stacked', tr('위아래')),
('auto', tr('화면 모양 따라')),
], (v) => orient = v),
group(tr('기능 버튼 줄'), bar, [
('middle', layout != 'single' ? tr('두 창 사이') : tr('왼쪽')),
('edge', tr('끝 (오른쪽 · 아래)')),
('hidden', tr('숨김')),
], (v) => bar = v),
if (bar == 'hidden')
Padding(
padding: const EdgeInsets.only(top: 8, left: 4),
child: 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(tr('확인'))),
],
);
},
),
);
if (ok != true) return;
await c.updateSettings((x) => x
..explorerLayout = layout
..explorerOrientation = orient
..explorerToolbar = bar);
if (layout == 'single') setState(() => _ctl.active = 0);
// 배치가 바뀌면 목록을 새로 그리므로 맨 위로 간다: 보던 폴더가 다시 보이게
for (final pane in _ctl.panes) {
_reveal(pane, pane.current);
}
}
@override
Future<void> buttonsDialog() async {
final current = ExplorerButton.fromSettings(c.settings.explorerButtons);
final order = [...current, ...ExplorerButton.explorerValues.where((b) => !current.contains(b))];
final on = {...current};
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, set) => AlertDialog(
title: Text(tr('버튼 구성')),
content: SizedBox(
width: 380,
height: 460,
child: Column(children: [
Text(tr('보일 버튼을 고르고, 끌어서 순서를 바꿉니다.'),
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
const SizedBox(height: 8),
Expanded(
child: ReorderableListView(
buildDefaultDragHandles: true,
onReorderItem: (a, b) => set(() => order.insert(b, order.removeAt(a))),
children: [
for (final b in order)
CheckboxListTile(
key: ValueKey(b),
dense: true,
value: on.contains(b),
secondary: Icon(b.icon),
title: Text(tr(b.label)),
controlAffinity: ListTileControlAffinity.leading,
onChanged: (v) => set(() => v == true ? on.add(b) : on.remove(b)),
),
],
),
),
]),
),
actions: [
TextButton(
onPressed: () => set(() {
order
..clear()
..addAll(ExplorerButton.defaultOrder);
on
..clear()
..addAll(ExplorerButton.explorerValues);
}),
child: Text(tr('기본값')),
),
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('확인'))),
],
),
),
);
if (ok != true) return;
final names = [for (final b in order) if (on.contains(b)) b.name];
await c.updateSettings((x) => x.explorerButtons = names.isEmpty ? [ExplorerButton.layout.name] : names);
}
// ───────── 화면 ─────────
/// 49: 뒤로 키가 화면을 닫기 전에 할 일이 있는지 (고른 것이 있거나 맨 위 폴더가 아님)
bool get _backHasWork =>
_sel.selecting || _ctl.panes.any((x) => !x.sel.isEmpty) || (_ctl.ready && !samePath(_ctl.pane.current, _ctl.pane.root));
/// 49: 뒤로 키 - 먼저 고른 것 풀기, 그다음 상위 폴더, 맨 위면 화면 닫기 (위쪽 ← 는 늘 닫기)
Future<void> _onBack() async {
if (_sel.selecting || _ctl.panes.any((x) => !x.sel.isEmpty)) {
_sel.setSelecting(false);
return;
}
if (!samePath(_ctl.pane.current, _ctl.pane.root)) {
await _buttons.press(ExplorerButton.up);
return;
}
if (mounted) Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: !_backHasWork,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) unawaited(_onBack());
},
child: _buildPage(context),
);
}
Widget _buildPage(BuildContext context) {
// P0 (15 지적 10/17): 글자 칸 (빠른 필터 · 경로 등) 에 치는 중이면 파일 단축키를 쓰지 않는다 - 예전에는 CallbackShortcuts 가
// 글자 칸보다 먼저 키를 받아, 칸에서 Delete 를 누르면 파일 지우기 창이 뜨고 Ctrl+A · C · V · Space 도 파일에 쓰였다
final bindings = <ShortcutActivator, VoidCallback>{
// 83 · X-plore 비교 5-1 ⑥: 스타일에 맞는 처음 키 + 사용자가 바꾼 키 ([explorer_keys.dart], 환경 설정 > 파일 탐색기 > 단축키)
..._keyBindings(),
// 180: 선택 모드 · 표시 풀기 (복사 · 이동이 끝나도 선택 모드는 그대로이므로 바로 나올 수 있게) - Esc 는 바꾸지 않는다
const SingleActivator(LogicalKeyboardKey.escape): () {
// 76: 열린 빠른 필터부터 닫는다
if (_ctl.panes[_ctl.active].filterOpen) {
_closeFilter(_ctl.panes[_ctl.active]);
} else if (_sel.selecting || _ctl.panes.any((x) => !x.sel.isEmpty)) {
_sel.setSelecting(false);
}
},
};
return Focus(
canRequestFocus: false,
skipTraversal: true,
onKeyEvent: (_, ev) => _shortcut(bindings, ev),
child: Focus(
focusNode: _keys,
autofocus: true,
// 76: 목록에서 글자를 바로 치면 지금 창의 빠른 필터가 그 글자로 열린다 (One Commander · TC 처럼)
onKeyEvent: (_, ev) => _typeToFilter(ev),
child: Listener(
// 75: 마우스 4 · 5번 (뒤로 · 앞으로) 버튼 - 창 밖 (위 막대 등) 이면 지금 창 (창 위면 그 창이 먼저 받음, [_frame])
onPointerDown: (ev) => _mouseTravel(_ctl.active, ev),
child: Scaffold(
body: SwipeNav(
current: widget.rsync ? 'rsync' : 'explorer',
child: ListenableBuilder(
listenable: c,
builder: (context, _) {
_ctl.dropRemovedServers();
// 80 보충: 화면에서 사라진 폴더를 위해 걸어 둔 자동 크기 계산은 멈추고 뺀다
_ctl.sizes.retainAuto([for (final x in _ctl.panes) x.current]);
return Column(children: [
_topBar(),
if (_searchBack != null) _searchBackBar(),
// 89: Wi-Fi 공유가 켜져 있는 동안
if (!widget.rsync)
ValueListenableBuilder<WifiShare?>(
valueListenable: WifiShare.current,
builder: (_, s, _) => s == null ? const SizedBox.shrink() : _wifiBar(s),
),
Expanded(
child: Stack(children: [
// X-plore 비교 ⚖: 탐색기 안에서 밀기 = 창 바꾸기 (안쪽이 먼저 받아 화면 이동은 위 막대에서만)
Positioned.fill(
child: !_ctl.ready
? const Center(child: CircularProgressIndicator())
: _swipePanes
? GestureDetector(
behavior: HitTestBehavior.translucent,
onHorizontalDragEnd: (d) {
final v = d.primaryVelocity ?? 0;
if (v.abs() >= 500) _switchPane(v < 0 ? 1 : -1);
},
child: _body())
: _body()),
// 복사 · 이동 진행: 아래에서 올라오고 끝나면 내려간다
Positioned(left: 8, right: 8, bottom: 0, child: ExplorerTransferPanel(c: c, jobs: _jobs.list, rsync: widget.rsync)),
]),
),
]);
},
),
),
),
),
),
);
}
/// 지금 폴더 · 그 위 폴더들 (창의 맨 위 [pane.root] 까지, 가까운 것부터)
List<String> _ancestors(ExplorerPane pane) {
final out = <String>[];
var d = pane.current;
for (var i = 0; i < 64; i++) {
out.add(d);
if (samePath(d, pane.root)) break;
final up = vDirname(d);
if (up == d || up.isEmpty) break;
d = up;
}
return out;
}
Widget _topBar() => AppTopBar(
nav: AppNavButtons(onExplorerPage: !widget.rsync, onRsyncPage: widget.rsync),
actions: AppActions(c: c),
// 아주 좁고 글자가 크면 (300 폭 · 1.3 × 150%) 제목도 줄임 - 넘쳐 그리기 오류가 나던 것 (날짜 열 312 조합 시험에서 찾음).
// 보통은 제목 폭 그대로 (경로 막대에 80 남짓 남을 때까지)
middle: LayoutBuilder(builder: (context, box) => Row(children: [
ConstrainedBox(
constraints: BoxConstraints(maxWidth: math.max(40, box.maxWidth - 92)),
child: Text(widget.rsync ? 'Rsync' : tr('파일 탐색기'),
maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
),
const SizedBox(width: 12),
// X-plore 경로 막대 (4.4x): 경로를 누르면 위 폴더들 (창의 맨 위까지) 이 나와 골라 간다
Expanded(
child: !_ctl.ready
? const SizedBox.shrink()
: PopupMenuButton<String>(
tooltip: tr('위 폴더로 가기'),
position: PopupMenuPosition.under,
onSelected: (d) => _ctl.goTo(_ctl.pane, d),
itemBuilder: (_) => [
for (final d in _ancestors(_ctl.pane))
PopupMenuItem(
value: d,
enabled: !samePath(d, _ctl.pane.current),
child: Text(_ctl.displayPath(d), maxLines: 1, overflow: TextOverflow.ellipsis),
),
],
// 관리자 지시 (10/21, 28 지적): 좁으면 앞을 "…" 로 - 지금 폴더 (끝) 가 늘 보이게
child: TailText(_ctl.displayPath(_ctl.pane.current), style: TextStyle(color: JjColors.textDim)),
),
),
PopupMenuButton<Object>(
tooltip: tr('창 배치 · 버튼 구성'),
icon: const Icon(Icons.more_vert),
onSelected: (v) => v is ExplorerButton ? _buttons.press(v) : _ctl.toggleAppHidden(),
itemBuilder: (_) => [
for (final b in [
if (!widget.rsync) ...[ExplorerButton.layout, ExplorerButton.buttons],
ExplorerButton.sort,
ExplorerButton.hidden,
if (!widget.rsync) ExplorerButton.storage,
if (!widget.rsync) ExplorerButton.wifiShare,
])
PopupMenuItem(
value: b,
child: Row(children: [Icon(b.icon, size: 18), const SizedBox(width: 12), Text(tr(b.label))]),
),
// X-plore 숨기기: 큰 폴더를 펼쳐 목록 맨 아래가 멀 때도 (관리자 결정 10/18)
if (!widget.rsync && c.settings.explorerHiddenItems.isNotEmpty)
PopupMenuItem(
value: 'appHidden',
child: Row(children: [
Icon(_ctl.showAppHidden ? Icons.visibility_off_outlined : Icons.visibility_outlined, size: 18),
const SizedBox(width: 12),
Flexible(
child: Text(_ctl.showAppHidden
? tr('목록에서 숨긴 항목 다시 숨기기')
: trf('목록에서 숨긴 항목 보이기 ({0}개)', [c.settings.explorerHiddenItems.length])),
),
]),
),
],
),
])),
);
/// 모양 · 배치가 바뀌면 (줄 높이 · 창 너비가 달라져) 보던 폴더가 밀려나므로 다시 그 폴더로
String _shape = '';
Widget _body() {
final s = c.settings;
final shape = '${s.explorerStyle}|${s.explorerLayout}|${s.explorerOrientation}|${s.explorerToolbar}';
if (_ctl.ready && shape != _shape) {
if (_shape.isNotEmpty) {
for (final pane in _ctl.panes) {
_reveal(pane, pane.current);
}
}
_shape = shape;
}
final dual = _dual;
return LayoutBuilder(builder: (context, box) {
// 두 창이 다 들어가는지: 트리 (화살표 · 아이콘 · 여백 78 + 이름 40) + 목록 (아이콘 · ✔ · 여백 104 + 이름 50), 이름은 글자 배율만큼.
// 1배면 360 폭에도 들어가고, 기기 글자 1.3 × 150% (약 2배) 면 412 폭에도 모자란다
final wantSplit = !widget.rsync && s.explorerLayout == 'split';
final textScale = MediaQuery.textScalerOf(context).scale(14) / 14 * _listTextScale;
final barW = s.explorerToolbar == 'hidden' ? 0.0 : 72.0;
final sideNow = switch (s.explorerOrientation) { 'side' => true, 'stacked' => false, _ => box.maxWidth >= box.maxHeight };
final tightWas = _splitTight;
_splitTight = wantSplit && sideNow && box.maxWidth - barW < 182 + 90 * textScale;
// 바뀌면 이 builder 밖 (버튼 이름 "두 창" 등) 도 한 번 다시 그린다
if (tightWas != _splitTight) WidgetsBinding.instance.addPostFrameCallback((_) => mounted ? setState(() {}) : null);
final split = _split;
// 폭이 바뀌면 (좁은 한 창 ↔ 두 창) 이 builder 만 다시 불린다 - 그때도 새로 붙은 창을 보이게
if (_ctl.ready) _revealNewViews();
final side = !(dual || split) ||
switch (s.explorerOrientation) {
'side' => true,
'stacked' => false,
_ => box.maxWidth >= box.maxHeight,
};
// 191: Rsync 화면이 좁으면 (폴드 접은 화면 · 폰 세로의 좌우 두 창) 창 하나씩 - [왼쪽 | 오른쪽] 으로 바꿔 본다
// (두 창 + 가운데 버튼 줄이면 창이 140px 남짓이라 폴더 이름이 잘렸다)
final rsyncOne = widget.rsync && side && box.maxWidth < rsyncTwoPaneMinWidth;
_narrowSplit = split && side && box.maxWidth < narrowSplitWidth;
final bar = s.explorerToolbar == 'hidden' && !widget.rsync
? null
: ExplorerToolbar(
// X-plore b: 길게 누르기 = 짝 동작 (Rsync 화면은 없음)
onLongPress: widget.rsync ? null : _buttons.longPress,
// Rsync 화면: 늘 같은 버튼 (→ ← ⇄ 모니터링) 을 두 창 사이에
buttons: widget.rsync ? ExplorerButton.rsyncButtons : ExplorerButton.fromSettings(s.explorerButtons),
vertical: side && !rsyncOne,
panesSide: side,
onPressed: _buttons.press,
enabled: (b) => switch (b) {
ExplorerButton.paste => _transfers.clip.isNotEmpty,
_ => true,
},
selected: (b) =>
(b == ExplorerButton.hidden && s.explorerShowHidden) || (b == ExplorerButton.select && _sel.selecting),
twoPanes: dual || split,
pinned: {ExplorerButton.orient, if (_transfers.clip.isNotEmpty) ExplorerButton.paste},
);
final middle = bar != null && (widget.rsync || s.explorerToolbar == 'middle');
final edge = bar != null && !widget.rsync && s.explorerToolbar == 'edge';
// 180: 창마다 같은 열쇠 - 가로 ↔ 세로 배치가 바뀌어도 (창 크기 · 작업 현황이 열림) 창을 새로 만들지 않는다 (스크롤이 맨 위로 가지 않게)
Widget keep(int k, Widget w) => KeyedSubtree(key: _viewKeys[k], child: w);
if (rsyncOne) {
final i = _ctl.active.clamp(0, 1);
return Column(children: [
_rsyncPaneSwitch(),
?bar,
Expanded(child: keep(i, ExplorerPaneView(ctl: _ctl, sel: _sel, parts: this, index: i, foldersOnly: true))),
]);
}
final children = <Widget>[
if (dual) ...[
Expanded(child: keep(0, ExplorerPaneView(ctl: _ctl, sel: _sel, parts: this, index: 0, foldersOnly: widget.rsync))),
if (middle) bar,
Expanded(child: keep(1, ExplorerPaneView(ctl: _ctl, sel: _sel, parts: this, index: 1, foldersOnly: widget.rsync))),
if (edge) bar,
] else if (split) ...[
// 왼쪽 폴더 트리 (좁게) · 오른쪽 그 폴더의 파일 목록. 좁은 화면은 트리에 최소 폭 (130px 또는 버튼 줄 (72) 을 뺀 폭의 42%) -
// 버튼 줄까지 넣어 38% 로 하면 360 폭에서 오른쪽 목록의 파일 이름이 두세 글자만 남았다
if (_narrowSplit)
SizedBox(
width: math.max(130.0, (box.maxWidth - (middle || edge ? 72 : 0)) * 0.42),
child: keep(0, ExplorerPaneView(ctl: _ctl, sel: _sel, parts: this, index: 0, foldersOnly: true)))
else
Expanded(flex: 2, child: keep(0, ExplorerPaneView(ctl: _ctl, sel: _sel, parts: this, index: 0, foldersOnly: true))),
if (middle) bar,
Expanded(flex: 3, child: keep(2, ExplorerListView(ctl: _ctl, sel: _sel, parts: this, pane: _ctl.panes[0]))),
if (edge) bar,
] else ...[
if (middle) bar,
Expanded(
child: _hiddenTarget
? Column(children: [ListenableBuilder(listenable: _ctl.panes[1], builder: (_, _) => _targetBar()), Expanded(child: keep(0, ExplorerPaneView(ctl: _ctl, sel: _sel, parts: this, index: 0)))])
: keep(0, ExplorerPaneView(ctl: _ctl, sel: _sel, parts: this, index: 0))),
if (edge) bar,
],
];
return side ? Row(children: children) : Column(children: children);
});
}
/// 191: 좁은 Rsync 화면의 [왼쪽 | 오른쪽] - 그 창에서 고른 폴더 (없으면 지금 폴더) 이름과 함께
Widget _rsyncPaneSwitch() => Padding(
padding: const EdgeInsets.fromLTRB(4, 4, 4, 0),
child: Row(children: [
for (final (k, side) in [(0, tr('왼쪽')), (1, tr('오른쪽'))])
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: ChoiceChip(
showCheckmark: false,
selected: _ctl.active == k,
onSelected: (_) => setState(() => _ctl.active = k),
label: SizedBox(
width: double.infinity,
child: Text(
'$side · ${vBasename(!_ctl.panes[k].sel.isEmpty ? _ctl.panes[k].sel.marked.first : _ctl.panes[k].current)}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
),
),
]),
);
/// 180: 왼쪽 창 · 오른쪽 창 · 파일 목록 (배치가 바뀌어도 같은 위젯 - [_body])
final _viewKeys = [GlobalKey(), GlobalKey(), GlobalKey()];
/// 75: 마우스 4 · 5번 - 그 창의 뒤로 · 앞으로 (그 창을 활성으로).
/// 창 위의 누름은 창 ([_frame]) 과 화면 Listener 가 둘 다 받는다. 받는 곳마다 좌표를 바꾼 새 이벤트라 객체로는 같은지 알 수 없어
/// 누른 손가락 (pointer) · 시각으로 한 번만 쓴다 (창이 먼저 받는다)
(int, Duration)? _navPress;
void _mouseTravel(int i, PointerDownEvent ev) {
if (i >= _ctl.panes.length) return;
final back = ev.buttons & kBackMouseButton != 0;
if (!back && ev.buttons & kForwardMouseButton == 0) return;
final press = (ev.pointer, ev.timeStamp);
if (press == _navPress) return;
_navPress = press;
_ctl.travel(_ctl.panes[i], forward: !back);
}
Widget _frame(int i, List<Widget> children) {
final active = i == _ctl.active;
return Listener(
// 75: 마우스가 올라간 창에 (지금 활성 창이 아니어도)
onPointerDown: (ev) => _mouseTravel(i, ev),
child: GestureDetector(
onTapDown: (_) {
if (_ctl.active != i) setState(() => _ctl.active = i);
},
// 82: 창의 빈 곳에 놓으면 그 창의 지금 폴더로. 놓을 수 있으면 강조, 못 놓으면 (같은 창 등) 빨간 테두리
child: DragTarget<DragLoad>(
onWillAcceptWithDetails: (d) => canDropInto(d.data, i, _ctl.panes[i].current),
onAcceptWithDetails: (d) => unawaited(_onDrop(d.data, i, _ctl.panes[i].current)),
builder: (context, ok, no) => Container(
margin: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: ok.isNotEmpty ? JjColors.accent.withValues(alpha: 0.06) : JjColors.bg,
border: Border.all(
color: ok.isNotEmpty
? JjColors.accent
: no.isNotEmpty
? JjColors.danger
: active
? JjColors.accent
: JjColors.border,
width: ok.isNotEmpty || no.isNotEmpty ? 2 : (active ? 1.5 : 1)),
borderRadius: BorderRadius.circular(6),
),
child: Column(children: children),
),
),
),
);
}
/// 82: 끌 항목 - 누른 것이 표시한 것 중 하나면 표시한 것 모두 (바깥 것만), 아니면 그것 하나
List<String> _dragPaths(ExplorerPane pane, FileEntry e) =>
_sel.isMarked(pane, e.path) && !pane.sel.isEmpty ? outermostPaths(pane.sel.marked) : [e.path];
/// 82: 놓음 - 설정 "끌어 놓기 기본" (묻기 · 복사 · 이동) 뒤 지금의 복사 · 이동 (확인 창 · 같은 이름 · 진행 패널).
/// 이동이 끝나면 아래 알림에 [되돌리기] (10초)
Future<void> _onDrop(DragLoad load, int to, String dest) async {
if (!canDropInto(load, to, dest) || !mounted) return;
// X-plore 비교 f: 압축 안으로는 놓지 않고, 압축 안의 것은 꺼내기로
if (_guard.insideArchive(dest) || _ctl.arc.contains(dest)) {
_snack(tr('압축 안은 읽기 전용입니다 (꺼내기만 할 수 있습니다)'));
return;
}
if (load.paths.any(_guard.insideArchive)) {
await _guard.extractFrom(context, load.paths, dest);
return;
}
setState(() => _ctl.active = to);
final mode = c.settings.explorerDropAction;
final move = mode == 'ask' ? await askDropAction(context, load.paths, dest) : mode == 'move';
if (move == null || !mounted) return;
// 되돌리기에 쓸 것: 대상에 이미 있던 이름 (합친 폴더는 통째로 되돌리지 않게)
final existed = <String>{};
if (move) {
for (final s in load.paths) {
final t = vJoin(dest, vBasename(s));
if (await vExists(t)) existed.add(t);
}
}
if (!mounted) return;
await _transfers.run(context, _ctl.panes[load.from], load.paths, dest,
move: move, onFinished: move ? (job) => _offerUndoMove(load, job, existed) : null);
}
/// 82: 이동 되돌리기 알림. 실제로 옮긴 것만 원래 자리로: 새로 생긴 항목은 통째로, 이미 있던 폴더에 합친 것은 앱이 옮긴 파일만
/// (robocopy 로 합친 폴더는 무엇을 옮겼는지 몰라 되돌리지 않고 알린다)
void _offerUndoMove(DragLoad load, TransferJob job, Set<String> existed) {
// (원래 폴더 → 지금 경로들)
final back = <String, List<String>>{};
// 되돌리지 않는 것: 합친 폴더 (robocopy - 무엇을 옮겼는지 모름) · 같은 이름을 덮어쓴 파일 (원래 파일은 이미 없음, 15 지적)
var notUndoable = 0, overwritten = 0;
final pairs = job.made.length == load.paths.length
? [for (var i = 0; i < load.paths.length; i++) (load.paths[i], job.made[i])]
: [
for (final m in job.made)
for (final s in load.paths)
if (vBasename(s) == vBasename(m)) (s, m),
];
for (final (src, now) in pairs) {
if (!existed.contains(now)) {
back.putIfAbsent(vDirname(src), () => []).add(now);
continue;
}
// 같은 이름을 덮어쓴 파일: 되돌리면 대상에 아무것도 남지 않으므로 그대로 둔다
if (!vIsDirSync(now)) {
overwritten++;
continue;
}
// 합친 폴더: 그 안에서 실제로 옮긴 파일만
final files = [for (final f in job.movedFiles) if (isSameOrInside(f, src) && !samePath(f, src)) f];
if (files.isEmpty) {
notUndoable++;
continue;
}
for (final f in files) {
final rest = f.substring(src.length + 1);
back.putIfAbsent(vDirname(f), () => []).add(vJoin(now, rest));
}
}
if (back.isEmpty || !mounted) return;
final moved = back.values.fold(0, (a, l) => a + l.length);
showNotice(ScaffoldMessenger.of(context)..hideCurrentSnackBar(),
text: joinSentences([
trf('{0}개를 옮겼습니다.', [moved]),
if (notUndoable > 0) trf('합친 폴더 {0}개는 무엇을 옮겼는지 몰라 되돌리지 않습니다.', [notUndoable]),
if (overwritten > 0) trf('같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.', [overwritten]),
]),
action: (tr('되돌리기'), () => unawaited(_undoMove(_ctl.panes[load.from], back))));
}
/// 82: 되돌리기 - 원래 폴더마다 지금의 이동으로 (같은 이름이 생겼으면 지금의 같은 이름 창)
Future<void> _undoMove(ExplorerPane pane, Map<String, List<String>> back) async {
for (final MapEntry(key: dir, value: items) in back.entries) {
final there = [for (final x in items) if (await vExists(x)) x];
if (there.isEmpty || !mounted) continue;
try {
if (!await vExists(dir)) await vMkdirs(dir);
} catch (_) {}
if (!mounted) return;
await _transfers.run(context, pane, there, dir, move: true);
}
}
/// 선택 모드 막대: 고른 수 · 모두 고르기 · 선택 끝
/// 76: 빠른 필터 열기 ([text] 가 있으면 그 글자로 시작)
void _openFilter(ExplorerPane pane, [String? text]) {
setState(() {
_ctl.active = _ctl.panes.indexOf(pane);
pane.filterOpen = true;
if (text != null) {
pane.filterCtl.value = TextEditingValue(text: text, selection: TextSelection.collapsed(offset: text.length));
}
});
WidgetsBinding.instance.addPostFrameCallback((_) => pane.filterFocus.requestFocus());
// 거른 결과 (지금 폴더 아래) 가 보이게
_reveal(pane, pane.current);
}
/// 76: 닫으면 글자도 지워 모두 보인다. 줄이 다시 늘어나 맨 위로 가지 않게 고른 것 (없으면 지금 폴더) 으로
void _closeFilter(ExplorerPane pane) {
setState(() {
pane
..filterOpen = false
..filterCtl.clear();
});
_reveal(pane, pane.focused ?? pane.current);
_keys.requestFocus();
}
/// 83: 지금 스타일이 Total Commander 인지 (Rsync 화면은 스타일 단축키를 쓰지 않는다 - 늘 같은 버튼)
bool get _tcKeys => !widget.rsync && Platform.isWindows;
/// 83 · X-plore 비교 5-1 ⑥ (관리자 지시 10/21): 동작마다의 키 → 할 일. 처음 키는 스타일대로 (Total Commander: F5 복사 · F6 이동 ·
/// F7 새 폴더 · F8 지우기 · Insert/Space 표시 · Ctrl+R 새로 고침 / Windows 탐색기 · X-plore: Ctrl+C · X · V · A · F5 새로 고침),
/// 사용자가 바꾼 것은 설정 explorerKeyOverrides. Rsync 화면은 늘 같은 키 (바꾼 것을 쓰지 않음)
Map<ShortcutActivator, VoidCallback> _keyBindings() {
final cb = <String, VoidCallback>{
'copy': () => _buttons.press(ExplorerButton.copy),
'move': () => _buttons.press(ExplorerButton.move),
'newFolder': () => _buttons.press(ExplorerButton.newFolder),
'delete': () => _buttons.press(ExplorerButton.delete),
// 65: Shift+Delete = 휴지통을 거치지 않고 (창에서 한 번 더 확인)
'deletePermanent': () {
_deletes.shiftDelete = true;
_buttons.press(ExplorerButton.delete);
},
'rename': () => _buttons.press(ExplorerButton.rename),
'refresh': () => _buttons.press(ExplorerButton.refresh),
// 81: 빠른 보기
'quickLook': () => quickLook(_ctl.pane),
'clipCopy': () => _transfers.toClipboard(_sel.targets(_ctl.pane), move: false),
'clipCut': () => _transfers.toClipboard(_sel.targets(_ctl.pane), move: true),
'paste': () => _transfers.paste(context, _ctl.pane, _ctl.pane.current),
'selectAll': () {
if (!_sel.selecting) setState(() => _sel.selecting = true);
_sel.markWhere(_ctl.pane, _ctl.pane.current, (_) => true);
},
// TC: Space 표시 · Insert 표시하고 다음 줄로
'mark': () => _markFocused(next: false),
'markNext': () => _markFocused(next: true),
'filter': () => _openFilter(_ctl.panes[_ctl.active]),
'up': () => _buttons.press(ExplorerButton.up),
// 75: 지금 창의 뒤로 · 앞으로
'back': () => _ctl.travel(_ctl.panes[_ctl.active], forward: false),
'forward': () => _ctl.travel(_ctl.panes[_ctl.active], forward: true),
'switchPane': () => setState(() => _ctl.active = _dual ? 1 - _ctl.active : 0),
};
final keys = widget.rsync
? {
for (final e in defaultExplorerKeys(tc: false).entries)
if (const {'delete', 'deletePermanent', 'rename', 'refresh', 'filter', 'up', 'back', 'forward', 'switchPane'}.contains(e.key))
e.key: e.value,
}
: effectiveExplorerKeys(tc: _tcKeys, overrides: c.settings.explorerKeyOverrides);
return {
for (final e in keys.entries)
if (cb[e.key] != null)
for (final a in e.value) a: cb[e.key]!,
};
}
/// 81: 빠른 보기 - 지금 줄 (없으면 아무것도 안 함). 같은 폴더에 보이는 항목을 ← → 로
@override
Future<void> quickLook(ExplorerPane pane, [String? path]) async {
final f = path ?? pane.focused;
if (f == null || samePath(f, pane.root)) return;
final list = _sel.shown(pane, vDirname(f));
final i = list.indexWhere((x) => samePath(x.path, f));
if (i < 0) return;
await showDialog<void>(
context: context,
builder: (_) => QuickLook(
entries: list,
index: i,
probe: c.services.mediaTool.probe,
thumbnail: (e) => BigVideoThumbs.of(c, e),
imageExts: c.settings.imageExts,
onOpen: (e) => e.isDir ? unawaited(_ctl.goTo(pane, e.path)) : unawaited(_opener.open(context, e.path, pane: pane)),
),
);
}
/// 83: (TC) 지금 줄을 표시 · 풀기, [next] 면 다음 줄로
Future<void> _markFocused({required bool next}) async {
final pane = _ctl.pane;
final f = pane.focused;
if (f == null || samePath(f, pane.root)) return;
final list = _sel.shown(pane, vDirname(f));
final i = list.indexWhere((x) => samePath(x.path, f));
if (i < 0) return;
if (!_sel.selecting) setState(() => _sel.selecting = true);
await _sel.toggleMark(pane, list[i]);
if (next && i + 1 < list.length) {
pane.focused = list[i + 1].path;
pane.changed();
_reveal(pane, list[i + 1].path);
}
}
/// 글자 칸에 치는 중인지 (그 칸이 키를 받아야 한다)
static bool get _typing => FocusManager.instance.primaryFocus?.context?.findAncestorStateOfType<EditableTextState>() != null;
/// 탐색기 단축키: 글자 칸에 치는 중이면 넘기고 (칸의 기본 동작 - 선택 · 복사 · 붙여넣기 · 지우기 · 빈칸), 아니면 맞는 것을 한다
KeyEventResult _shortcut(Map<ShortcutActivator, VoidCallback> bindings, KeyEvent ev) {
if (ev is KeyUpEvent || _typing) return KeyEventResult.ignored;
for (final b in bindings.entries) {
if (b.key.accepts(ev, HardwareKeyboard.instance)) {
b.value();
return KeyEventResult.handled;
}
}
return KeyEventResult.ignored;
}
KeyEventResult _typeToFilter(KeyEvent ev) {
if (ev is! KeyDownEvent) return KeyEventResult.ignored;
final ch = ev.character;
final kb = HardwareKeyboard.instance;
if (ch == null || ch.isEmpty || ch.runes.first < 0x20 || ch.runes.first == 0x7f) return KeyEventResult.ignored;
// 83 · 81: Space 는 표시 (TC) · 빠른 보기 (그 밖) - 빠른 필터를 열지 않는다 (필터가 열려 있으면 그대로 글자)
if (ch == ' ' && !widget.rsync && !_ctl.panes[_ctl.active].filterOpen) return KeyEventResult.ignored;
if (kb.isControlPressed || kb.isAltPressed || kb.isMetaPressed) return KeyEventResult.ignored;
// 글자 칸 (필터 · 경로 입력 등) 에 치는 중이면 그 칸이 받는다
if (_typing) return KeyEventResult.ignored;
if (!_ctl.ready || _ctl.panes.isEmpty) return KeyEventResult.ignored;
final pane = _ctl.panes[_ctl.active];
_openFilter(pane, pane.filterOpen ? pane.filterCtl.text + ch : ch);
return KeyEventResult.handled;
}
}