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 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 open(NavigatorState nav, {required AppController c}) => _show(nav, c, rsync: false); /// Rsync 화면으로 (이미 열려 있으면 그 화면으로) static Future openRsync(NavigatorState nav, {required AppController c}) => _show(nav, c, rsync: true); /// 162: 이 앱의 파일 탐색기로 그 폴더를 연다 (이미 열려 있으면 그 화면에서 그 폴더로). 끝난 작업의 [열기] 등 static Future openAt(NavigatorState nav, {required AppController c, required String dir}) { goToRequest.value = dir; return _show(nav, c, rsync: false); } /// 열려 있는 (또는 곧 열릴) 탐색기가 갈 폴더 (가면 비운다) static final goToRequest = ValueNotifier(null); static Future _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( settings: RouteSettings(name: name), builder: (_) => ExplorerPage(c: c, rsync: rsync), )); } @override State createState() => _ExplorerPageState(); } class _ExplorerPageState extends State 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 _probeDrive(String path) => _ctl.drives.check(path, ExplorerPage.probeDrive); /// 연결 안 된 드라이브를 누르면 다시 연결 (다시 읽기) - 되면 연다 Future _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 _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 _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 _openRoot(ExplorerPane pane) async { if (!ExplorerPage.rootWarnedThisRun) { final ok = await showDialog( 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 _openShizuku(ExplorerPane pane) async { if (!ExplorerPage.shizukuWarnedThisRun) { final ok = await showDialog( 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 actions, Color? color}) => _noticeBox(icon, title, body, actions, color ?? JjColors.alert); Widget _noticeBox(IconData icon, String title, String body, List 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 _partials(ExplorerPane pane, String dir) { final old = DateTime.now().subtract(const Duration(minutes: 2)); return [ for (final e in pane.cache[dir] ?? const []) 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( 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 = []; 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? _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 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 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 dragPaths(ExplorerPane pane, FileEntry e) => _dragPaths(pane, e); @override Future 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 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 openStorage(ExplorerPane pane, String dir) async { setState(() => _ctl.active = _ctl.panes.indexOf(pane)); if (isDav(dir)) { final ok = await showDialog( 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 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 addMkv(List paths) async { if (paths.any(isDav)) { _snack(tr('WebDAV · SFTP 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.')); return; } final videos = []; final unreadable = []; 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 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 batchRename(ExplorerPane pane, List paths) async { if (_guard.blockArchive(paths)) return; int at(String x) { final i = (pane.cache[vDirname(x)] ?? const []).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 _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 found, bool done, Map moved})? _searchBack; /// 79: 찾기 - 이름 · 크기 · 바뀐 날 · 종류 조건 → 결과 목록 화면 (고르고 그대로 복사 · 이동 · 지우기, 누르면 열기 · 재생) @override Future 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 _backToSearch() async { final back = _searchBack; if (back == null) return; final now = []; 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 _searchPage(ExplorerPane pane, String root, SearchConditions cond, {List? initial, bool initialDone = true}) async { final other = _other?.current; final act = await Navigator.of(context).push(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 {}, )); } 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 sortDialog() async { // 84: "폴더마다 기억" 이면 지금 창의 폴더 정렬 final dir = _ctl.pane.current; var (by, desc) = _ctl.sortOf(dir); final ok = await showDialog( 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 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 historyDialog(ExplorerPane pane) async { final hist = c.settings.explorerHistory.where(reachable).toList(); final pick = await showDialog( 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 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 layoutDialog() async { final s = c.settings; var layout = s.explorerLayout, orient = s.explorerOrientation, bar = s.explorerToolbar; final ok = await showDialog( 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 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( 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 _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 = { // 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( 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 _ancestors(ExplorerPane pane) { final out = []; 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( 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( 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 = [ 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 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( 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 _dragPaths(ExplorerPane pane, FileEntry e) => _sel.isMarked(pane, e.path) && !pane.sel.isEmpty ? outermostPaths(pane.sel.marked) : [e.path]; /// 82: 놓음 - 설정 "끌어 놓기 기본" (묻기 · 복사 · 이동) 뒤 지금의 복사 · 이동 (확인 창 · 같은 이름 · 진행 패널). /// 이동이 끝나면 아래 알림에 [되돌리기] (10초) Future _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 = {}; 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 existed) { // (원래 폴더 → 지금 경로들) final back = >{}; // 되돌리지 않는 것: 합친 폴더 (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 _undoMove(ExplorerPane pane, Map> back) async { for (final MapEntry(key: dir, value: items) in back.entries) { final there = [for (final x in items) if (await vExists(x)) x]; if (there.isEmpty || !mounted) continue; try { if (!await vExists(dir)) await vMkdirs(dir); } catch (_) {} if (!mounted) return; await _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 _keyBindings() { final cb = { '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 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( 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 _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() != null; /// 탐색기 단축키: 글자 칸에 치는 중이면 넘기고 (칸의 기본 동작 - 선택 · 복사 · 붙여넣기 · 지우기 · 빈칸), 아니면 맞는 것을 한다 KeyEventResult _shortcut(Map 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; } }