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:collection'; 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 'package:path/path.dart' as p; import '../app/app_controller.dart'; import '../app/copy_center.dart'; import '../app/live_sync.dart'; import '../app/settings.dart' show FolderBookmark; import '../app/transfer_job.dart'; import '../core/secret_gate.dart'; import '../core/sync_tools.dart'; import '../core/file_ops.dart'; import '../core/playlist.dart' show isAudioFile, 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 '../platform/android/hwp_pdf_ffi.dart' show RhwpEngine; import 'app_actions.dart'; import 'explorer_look.dart'; import 'player_page.dart'; import 'rsync_setup.dart'; import 'theme.dart'; import 'file_error.dart'; import 'folder_picker.dart' show pickFolderOrDav; import 'trash_page.dart'; import 'dav_external.dart'; import '../platform/windows/recycle_bin.dart'; import '../core/sync_preview.dart'; import 'path_label.dart'; import 'progress_dialog.dart'; import 'sync_preview_view.dart'; import 'sftp_settings.dart'; import 'ftp_settings.dart'; import 'reader_page.dart'; import '../app/component_store.dart'; import 'zip_page.dart'; import '../core/reader_sources.dart'; import '../core/hwp_source.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 'vault_page.dart' show openVault; import 'explorer_keys.dart' show defaultExplorerKeys, effectiveExplorerKeys; import 'sqlite_viewer.dart' show openSqliteViewer, sqliteExtensions; import 'quick_look.dart'; import 'file_tools.dart' show showFileHash, showFolderProperties; import '../core/file_attributes.dart' show readFileAttributes; import 'explorer_drag.dart'; import 'archive_ui.dart'; import 'explorer_grid.dart'; import 'text_viewer.dart'; import 'storage_page.dart'; import 'wifi_share_page.dart'; import '../core/wifi_share.dart' show WifiShare; import '../core/archive_ops.dart' show archiveKindOf; import 'batch_rename_dialog.dart'; import '../core/app_trash.dart'; import '../core/batch_rename.dart'; import 'notice.dart'; import 'confirm_dialog.dart'; import '../core/name_clash.dart'; import '../app/volumes.dart'; import '../app/delete_ops.dart'; import '../app/transfer_plan.dart'; import '../core/file_clipboard.dart'; import '../app/archive_browse.dart'; import '../app/file_opener.dart'; import '../core/app_hidden.dart'; import '../app/rsync_runner.dart'; import 'explorer/rsync_run_dialog.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/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); } _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 (_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)) { _adoptJob(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('서버 설정 고치기'))), ], ); } /// 77: [경로 복사] 에 넣을 것 - 실제 경로 (Android 도 "SD 카드 ›" 가 아닌 /storage/…), WebDAV 는 서버 주소 (아이디 · 비밀번호는 빼고) String _copyablePath(String path) { if (!isDav(path)) return path; final d = DavPath.parse(path); // 88: SFTP 는 sftp://아이디@서버:포트/경로 (시작 폴더를 비웠으면 홈 기준 ~/) if (d.isSftp) { final s = SftpRegistry.server(d.server); if (s == null) return _ctl.displayPath(path); final base = s.root.isEmpty ? '/~' : (s.root.startsWith('/') ? s.root : '/${s.root}'); return 'sftp://${s.user.isEmpty ? '' : '${s.user}@'}${s.host}${s.port == 22 ? '' : ':${s.port}'}$base${d.isRoot ? '' : d.rel}'; } // l: FTP 는 ftp://아이디@서버:포트/경로 (암호화 990 은 ftps://, 비밀번호 없음) if (d.isFtp) { final s = FtpRegistry.server(d.server); if (s == null) return _ctl.displayPath(path); final implicit = s.security == 'implicit'; final r = s.root.trim().replaceAll(RegExp(r'^/+|/+$'), ''); return '${implicit ? 'ftps' : 'ftp'}://${s.user.isEmpty ? '' : '${Uri.encodeComponent(s.user)}@'}${s.host}' '${s.port == (implicit ? 990 : 21) ? '' : ':${s.port}'}${r.isEmpty ? '' : '/$r'}${d.isRoot ? '' : d.rel}'; } // 88: SMB 는 smb://서버/공유/경로 (아이디 · 비밀번호 없음) if (d.isSmb) { final s = SmbRegistry.server(d.server); if (s == null) return _ctl.displayPath(path); final r = s.root.trim().replaceAll(r'\', '/').replaceAll(RegExp(r'^/+|/+$'), ''); return 'smb://${s.host}${s.port == 445 ? '' : ':${s.port}'}/${s.share}${r.isEmpty ? '' : '/$r'}${d.isRoot ? '' : d.rel}'; } try { return DavRegistry.client(d.server).uriOf(d.rel).replace(userInfo: '').toString(); } catch (_) { return _ctl.displayPath(path); } } /// 화면에 보일 경로 (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) => _menu(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); /// 서버 추가 · 고치기 · 경로 입력 (E5d-3) late final _servers = ExplorerServers(_ctl); // ───────── 누르기 · 고르기 (E5d-2: ExplorerSelect) ───────── late final _sel = ExplorerSelect(_ctl, clock: widget.clock, openFile: (path, pane) => _open(path, pane: pane)) ..addListener(_onPane); /// 다른 앱으로 열지 못했을 때: 음악 · 동영상이면 내장 플레이어를 알려 준다 (29) String _noAppMessage(String path) => isAudioFile(path) || isVideoFile(path) ? tr('이 형식을 여는 앱이 없습니다. 길게 누르거나 오른쪽 클릭 → [내장 플레이어로 재생] 으로 들어 보세요.') : tr('이 파일을 열 수 있는 앱이 없습니다.'); /// 파일 열기 (기본 동작): 동영상은 내장 플레이어 (또는 확장자별 프로그램), 그 밖은 기본 연결 프로그램 Future _open(String path, {ExplorerPane? pane}) async { // X-plore 비교 f: 압축 안의 파일 - 그 파일만 임시 폴더로 꺼내 연다 if (_insideArchive(path)) { final local = await _extractOne(path); if (local == null || !mounted) return; path = local; } // 여는 길은 file_opener (E5c) 가 고른다 switch (openRouteFor(path, components: c.settings.components, imageExts: c.settings.imageExts, android: Platform.isAndroid, windows: Platform.isWindows, hwpEngine: RhwpEngine.available)) { case OpenRoute.player: return play([path]); case OpenRoute.extractArchive: return _extractArchive(path, pane ?? _ctl.pane); case OpenRoute.images: return _viewImages(path, pane); case OpenRoute.pdf: return _viewPdf(path); case OpenRoute.zip: return _openZip(path, pane); // 관리자 지시 (10/20): Android HWP · HWPX 는 앱 안에서 PDF 로 바꿔 본다. 못 열면 이유를 알리고 다른 앱으로 case OpenRoute.hwp: if (await _viewHwp(path)) return; case OpenRoute.docPreview: return _viewDoc(path); case OpenRoute.external: } final local = await _fetch(path); if (local == null) return; final ok = await c.services.shell.openWith(local); if (ok || !mounted) return; // X-plore 비교 h: 글 파일을 열 앱이 없으면 앱 안의 글 뷰어로 if (textExtensions.contains(extOf(path))) return _viewText(local); _snack(_noAppMessage(path)); } /// X-plore 비교 h: 글 뷰어 (WebDAV 등은 받은 뒤) Future _viewText(String path) async { final local = await _fetch(path); if (local == null || !mounted) return; await Navigator.of(context).push(MaterialPageRoute(builder: (_) => TextViewerPage(path: local))); } /// X-plore 비교 g: 폴더의 그림 · 동영상을 격자로. 길게 누른 것은 탐색기에서 그 자리로 Future _grid(ExplorerPane pane, String dir) async { await _ctl.load(pane, dir); if (!mounted) return; final list = _sel.shown(pane, dir); final at = await Navigator.of(context).push(MaterialPageRoute( builder: (_) => ExplorerGridPage( title: vBasename(dir).isEmpty ? dir : vBasename(dir), entries: list, imageExts: c.settings.imageExts, videoThumb: (e) => BigVideoThumbs.of(c, e), onOpen: (e) => _open(e.path, pane: pane), ), )); if (at == null || !mounted) return; await _ctl.goTo(pane, dir); pane.focused = at; pane.changed(); } /// WebDAV 파일은 열거나 재생하려면 임시 폴더로 받는다 (dav_external.dart) Future _fetch(String path) => fetchDav(context, c, path); /// 동영상 재생 (WebDAV 는 받지 않고 바로 스트리밍) @override Future play(List paths, {bool internal = false, bool keepOrder = false}) async { if (!mounted || paths.isEmpty) return; await playFiles(context, c, paths, internal: internal, keepOrder: keepOrder); } /// 그림 보기: 같은 폴더의 그림을 지금 정렬 순서대로 넘겨 본다 Future _viewImages(String path, ExplorerPane? pane) async { final dir = vDirname(path); // 폴더를 읽지 못하면 (E10: 이제 오류로 알림) 고른 그림 하나만 List list; try { list = pane?.cache[dir] ?? _ctl.sorted(await listEntries(dir, showHidden: c.settings.explorerShowHidden), dir); } catch (_) { list = const []; } final images = [ for (final e in list) if (!e.isDir && c.settings.imageExts.contains(extOf(e.path))) e.path, ]; if (!images.any((x) => samePath(x, path))) images.insert(0, path); final start = images.indexWhere((x) => samePath(x, path)); final temp = await c.services.storage.tempDirectory(); if (!mounted) return; await openReader(context, c, ImageFilesSource(images, tempDir: temp, title: vBasename(dir)), start: start); } /// X-plore 맞추기 (관리자 지시 10/20): 폴더의 그림 (지금 정렬 순서) 을 보기 화면으로 열고 [계속 보기] 를 켠 채 시작 Future _slideshow(ExplorerPane pane, String dir) async { List list; try { list = pane.cache[dir] ?? _ctl.sorted(await listEntries(dir, showHidden: c.settings.explorerShowHidden), dir); } catch (e) { _snack(trf('폴더를 읽을 수 없습니다: {0}', [e])); return; } final images = [ for (final e in list) if (!e.isDir && c.settings.imageExts.contains(extOf(e.path)) && (_ctl.showAppHidden || !_ctl.appHidden(e.path))) e.path, ]; if (images.isEmpty) { _snack(tr('이 폴더에는 그림이 없습니다')); return; } final temp = await c.services.storage.tempDirectory(); if (!mounted) return; await openReader(context, c, ImageFilesSource(images, tempDir: temp, title: vBasename(dir)), autoPlay: true); } /// 문서 → PDF (진행 창) → 보기 Future _viewDoc(String path) async { final temp = await c.services.storage.tempDirectory(); if (!mounted) return; final dialog = showProgressDialog(context, trf('PDF 로 바꾸는 중: {0}', [vBasename(path)])); String? pdf; Object? error; try { final local = await vLocalCopy(path, temp); pdf = await ComponentStore.shared.convertToPdf(local, p.join(temp, 'jj_docs')); } catch (e) { error = e; } // 사용자가 [취소] · ← 로 그만두었으면 결과를 보이지 않는다 (바꾸기는 끝까지 돌았음) if (dialog.cancelled()) return; dialog.close(); await dialog.done; if (pdf == null) { _snack(trf('미리 볼 수 없습니다: {0}', [error])); return; } PdfSource src; try { src = await PdfSource.open(pdf, tempDir: temp); } catch (e) { _snack(trf('PDF 를 열 수 없습니다: {0}', [e])); return; } if (!mounted) return; await openReader(context, c, src); } /// 관리자 지시 (10/20): HWP · HWPX → 보기 화면 (보는 쪽부터 몇 쪽씩 PDF 로 바꿈 - [HwpSource]). 열지 못하면 false Future _viewHwp(String path) async { final temp = await c.services.storage.tempDirectory(); if (!mounted) return true; final dialog = showProgressDialog(context, trf('여는 중: {0}', [vBasename(path)])); HwpSource? src; Object? error; try { src = await HwpSource.open(path, engine: RhwpEngine(), tempDir: temp, fontDirs: RhwpEngine.fontDirs, log: c.note); } catch (e) { error = e; } // 사용자가 [취소] · ← 로 그만두었으면 연 문서를 닫고 끝 (다른 앱으로도 열지 않음) if (dialog.cancelled()) { await src?.dispose(); return true; } dialog.close(); await dialog.done; if (src == null) { _snack(trf('앱 안에서 열지 못해 다른 앱으로 엽니다: {0}', [error])); return false; } if (!mounted) { await src.dispose(); return true; } await openReader(context, c, src); return true; } Future _viewPdf(String path) async { final temp = await c.services.storage.tempDirectory(); PdfSource src; try { src = await PdfSource.open(path, tempDir: temp); } catch (e) { _snack(trf('PDF 를 열 수 없습니다: {0}', [e])); return; } if (!mounted) return; await openReader(context, c, src); await _ctl.refreshAll([vDirname(path)]); } /// 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 deleteItems(pane, paths); return [for (final x in paths) if (!await vExists(x)) x]; }, onCut: (paths) => _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('끄기'))), ]), ), ), ); } /// 86: 압축 풀기 (검사 · 확인 · 진행 · 취소) 뒤 목록 새로 고침 Future _extractArchive(String path, ExplorerPane pane) async { final other = _dual ? _ctl.panes[1 - _ctl.panes.indexOf(pane)].current : null; final out = await extractArchiveFlow(context, c, path, otherDir: other); if (out != null && mounted) await _ctl.refreshAll([out, vDirname(out)]); } Future _openZip(String path, ExplorerPane? pane) async { try { if (c.settings.zipComic && await openZipComic(context, c, path)) return; } catch (e) { _snack(trf('열 수 없습니다: {0}', [e])); return; } if (!mounted) return; final other = pane == null || !_dual ? null : _ctl.panes[1 - _ctl.panes.indexOf(pane)].current; final out = await openZip(context, c, path, otherDir: other); if (out != null) await _ctl.refreshAll([out, vDirname(out)]); } /// 고른 그림들을 한 장씩 PDF 로 (같은 폴더에, 이름을 묻는다) Future _imagesToPdf(List images) async { if (images.isEmpty) return; final dir = vDirname(images.first); final first = vBasename(images.first); final name = await askName(tr('PDF 로 만들기'), '${first.contains('.') ? first.substring(0, first.lastIndexOf('.')) : first}.pdf'); if (name == null || name.trim().isEmpty) return; final out = vJoin(dir, name.toLowerCase().endsWith('.pdf') ? name : '$name.pdf'); final temp = await c.services.storage.tempDirectory(); try { await imagesToPdf(images, out, tempDir: temp); _snack(trf('그림 {0}장으로 PDF 를 만들었습니다: {1}', [images.length, vBasename(out)])); } catch (e) { _snack(trf('PDF 를 만들지 못했습니다: {0}', [e])); } await _ctl.refreshAll([dir]); } Future _menu(ExplorerPane pane, FileEntry e, Offset at) async { setState(() => _ctl.active = _ctl.panes.indexOf(pane)); pane.focused = e.path; pane.changed(); final video = !e.isDir && isVideoFile(e.path); final audio = !e.isDir && isAudioFile(e.path); final dav = isDav(e.path); // 그림 → PDF: 표시한 것 중 그림 (누른 것이 표시에 없으면 누른 것만) final viewer = c.settings.components.contains('viewer'); final picked = pane.sel.has(e.path) ? pane.sel.marked : [e.path]; final images = [for (final x in picked) if (c.settings.imageExts.contains(extOf(x))) x]; final ext = extOf(e.path); final viewable = viewer && !e.isDir && (c.settings.imageExts.contains(ext) || ext == 'pdf' || zipExtensions.contains(ext)); final dual = _dual; // 77: 누른 것이 표시한 것 중 하나면 표시한 것 모두에 (복사 · 이동 · 삭제 · 공유 · 경로 복사) final targets = _sel.isMarked(pane, e.path) && pane.sel.marked.length > 1 ? pane.sel.marked : [e.path]; final many = targets.length > 1; // 데이터 검토 (10/18): 펼친 압축 안의 것은 실제 파일이 아니다 - 그것으로 새 파일을 만들거나 내놓는 항목 (ZIP 묶기 · Wi-Fi 공유 · PDF) 은 숨긴다 final inArchive = targets.any(_insideArchive) || _insideArchive(e.path); // 25: Rsync 화면은 폴더만 보이므로 폴더에 맞는 것만 (고르기 · 열기 · 이름 변경 · 삭제 · 정보) final items = widget.rsync ? <(String, IconData, String)>[ ('pickRsync', Icons.check_circle_outline, _sel.isMarked(pane, e.path) ? tr('고르기 취소') : tr('rsync 에 고르기')), ('open', Icons.folder_open, tr('열기')), ('openOther', Icons.vertical_split_outlined, tr('다른 창에서 열기')), ('rename', Icons.drive_file_rename_outline, tr('이름 변경')), ('delete', Icons.delete_outline, tr('삭제')), if (!dav) ('reveal', Icons.folder_outlined, tr('파일 관리자에서 보기')), ('info', Icons.info_outline, tr('정보')), ] : <(String, IconData, String)>[ // 77: 복사 · 이동 · 이름 변경 · 삭제 · 공유는 위의 아이콘 줄 ([_menuIcons]). 아래는 상황별 항목 // 74: 폰에서 범위 고르기 - 첫 항목을 표시한 뒤 마지막 항목을 길게 눌러 if (_sel.selecting && _sel.canRange(pane, e)) ('range', Icons.unfold_more, tr('여기까지 범위 고르기')), if (e.isDir) ('open', Icons.folder_open, tr('열기')), // 81: 빠른 보기 (PC 는 Space · TC 는 F3) if (!e.isDir) ('quickLook', Icons.preview_outlined, tr('미리 보기')), // 86: 압축 풀기 (ZIP · 7z · RAR) · ZIP 으로 묶기 (표시한 것 모두) if (!e.isDir && archiveKindOf(e.path) != null) ('extract', Icons.unarchive_outlined, tr('압축 풀기')), if (!dav && !inArchive) ('zip', Icons.archive_outlined, many ? trf('{0}개 ZIP 으로 묶기', [targets.length]) : tr('ZIP 으로 묶기')), // 87: 저장 공간 분석 (이 폴더 아래의 큰 폴더 · 큰 파일) if (e.isDir) ('storage', Icons.pie_chart_outline, tr('저장 공간 분석')), // 89: 이 폴더를 Wi-Fi 로 공유 (이 기기의 폴더만) if (e.isDir && !dav && !widget.rsync && !inArchive) ('wifiShare', Icons.wifi_tethering, tr('Wi-Fi 로 공유')), // 80: 이 폴더 크기를 지금 계산 (설정이 "안 함" 이어도) if (e.isDir) ('folderSize', Icons.data_usage, _ctl.sizes.busy(e.path) ? tr('폴더 크기 계산 멈추기') : tr('폴더 크기 계산')), // X-plore P3: 폴더 속성 (안의 파일 · 폴더 수 · 크기 · 만든 · 바뀐 때) · 파일 해시 (MD5 · SHA-256) - 이 기기의 것만 (원격은 다 받아야 해서 뺌) if (e.isDir && !dav) ('props', Icons.fact_check_outlined, tr('폴더 속성')), if (!e.isDir && !dav) ('hash', Icons.tag, tr('해시 (MD5 · SHA-256)')), if (e.isDir && dual) ('openOther', Icons.vertical_split_outlined, tr('다른 창에서 열기')), if (e.isDir) ('playFolder', Icons.play_circle_outline, tr('이 폴더의 동영상 재생')), // X-plore 맞추기 (관리자 지시 10/20): 이 폴더의 그림을 [계속 보기] 로 if (e.isDir && viewer) ('slideshow', Icons.slideshow_outlined, tr('사진 슬라이드쇼')), // X-plore 비교 g: 그림 · 동영상을 썸네일 격자로 if (e.isDir && !dav) ('grid', Icons.grid_view, tr('격자로 보기 (그림 · 동영상)')), if (video || audio) ('playInternal', Icons.play_circle_outline, tr('내장 플레이어로 재생')), if (viewable) ('view', Icons.visibility_outlined, tr('보기')), // X-plore 비교 h: 어떤 파일이든 글로 열어 보기 (읽기 전용) if (!e.isDir && !video && !audio) ('text', Icons.article_outlined, tr('글 뷰어로 보기')), // X-plore 비교 5-1 ④ (관리자 지시 10/21): SQLite (읽기 전용) if (!e.isDir && !inArchive && sqliteExtensions.contains(ext)) ('sqlite', Icons.table_chart_outlined, tr('SQLite 보기 (읽기 전용)')), // X-plore 비교 5-2 (사용자 결정 10/21): 암호 보관함에 넣기. 2단계 (관리자 지시 10/21): 폴더째 (안의 구조 그대로) · 원격 파일도 (받아서 넣음) if (c.settings.vaultEnabled && !inArchive) ('vaultAdd', Icons.enhanced_encryption_outlined, many ? trf('{0}개 보관함에 넣기', [targets.length]) : tr('보관함에 넣기')), if (!e.isDir) ('openWith', Icons.open_in_new, tr('다른 앱으로 열기')), if (!e.isDir && !video) ('openDefault', Icons.launch, tr('기본 앱으로 열기')), if (video && !dav) ('addMkv', Icons.playlist_add, tr('MKV 목록에 추가')), if (viewer && images.isNotEmpty && !inArchive) ('toPdf', Icons.picture_as_pdf_outlined, images.length == 1 ? tr('PDF 로 만들기') : trf('그림 {0}장을 PDF 로 만들기', [images.length])), ('select', Icons.check_circle_outline, tr('선택')), // X-plore 숨기기: 목록에서만 숨긴다 (파일은 그대로) · 숨긴 것을 보이고 있으면 풀기 targets.every(_ctl.appHidden) ? ('appUnhide', Icons.visibility_outlined, many ? trf('{0}개 숨기기 풀기', [targets.length]) : tr('숨기기 풀기')) : ('appHide', Icons.visibility_off_outlined, many ? trf('{0}개 목록에서 숨기기', [targets.length]) : tr('목록에서 숨기기')), // X-plore 맞추기 (관리자 지시 10/20): 폴더에 .nomedia 를 두면 갤러리 · 음악 앱이 그 안의 그림 · 동영상 · 음악을 보이지 않는다 (Android) if (e.isDir && !dav && !inArchive && (ExplorerPage.debugNomedia ?? Platform.isAndroid)) File(p.join(e.path, '.nomedia')).existsSync() ? ('nomediaOff', Icons.photo_library_outlined, tr('갤러리 · 음악 앱에 다시 보이기 (.nomedia 지우기)')) : ('nomediaOn', Icons.hide_image_outlined, tr('갤러리 · 음악 앱에서 숨기기 (.nomedia)')), // 두 창이면 아이콘 줄의 복사 · 이동은 다른 창으로 - 담아 두었다가 원하는 폴더에 붙여넣기는 여기 if (dual) ('clipCopy', Icons.content_copy, tr('복사 (붙여넣기로)')), if (dual) ('clipMove', Icons.content_cut, tr('이동 (붙여넣기로)')), if (_clip.isNotEmpty) ('paste', Icons.content_paste, trf('{0} 에 붙여넣기 ({1}개)', [vBasename(e.isDir ? e.path : vDirname(e.path)), _clip.items.length])), ('copyPath', Icons.link, many ? trf('경로 복사 ({0}개)', [targets.length]) : tr('경로 복사')), if (e.isDir && !c.settings.folderBookmarks.any((b) => !b.isPair && samePath(b.path, e.path))) ('bookmark', Icons.star_outline, tr('즐겨찾기에 추가')), if (!dav && (!Platform.isAndroid || e.isDir)) ('reveal', Icons.folder_outlined, tr('파일 관리자에서 보기')), ('info', Icons.info_outline, tr('정보')), ]; // 77: 위의 아이콘 줄 (X-plore · One Commander 처럼 자주 쓰는 것을 한 줄에) final icons = widget.rsync ? const <(String, IconData, String, String, bool)>[] : <(String, IconData, String, String, bool)>[ (dual ? 'copy' : 'clipCopy', Icons.copy_outlined, tr('복사'), dual ? tr('다른 창으로 복사') : tr('복사 (붙여넣기로)'), true), (dual ? 'move' : 'clipMove', Icons.drive_file_move_outline, tr('이동'), dual ? tr('다른 창으로 이동') : tr('이동 (붙여넣기로)'), true), // 78: 여럿이면 일괄 이름 바꾸기 ('rename', Icons.drive_file_rename_outline, tr('이름 변경'), many ? trf('{0}개 이름 일괄 바꾸기', [targets.length]) : tr('이름 변경'), true), ('delete', Icons.delete_outline, tr('삭제'), many ? trf('{0}개 삭제', [targets.length]) : tr('삭제'), true), if (c.services.shell.canShare) ('share', Icons.share_outlined, tr('공유'), tr('공유 (다른 앱으로 보내기)'), targets.any((x) => !vIsDirSync(x))), ]; final size = MediaQuery.sizeOf(context); final pick = await showMenu( context: context, position: RelativeRect.fromLTRB(at.dx, at.dy, size.width - at.dx, size.height - at.dy), items: [ // 77: 여러 개를 골랐으면 무엇에 하는지 먼저 if (many) PopupMenuItem( enabled: false, height: 32, child: Text(trf('{0}개 항목', [targets.length]), style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: JjColors.accent)), ), if (icons.isNotEmpty) ...[_MenuIconRow(icons), const PopupMenuDivider(height: 1)], for (final (id, icon, label) in items) PopupMenuItem( value: id, child: Row(children: [ Icon(icon, size: 18), const SizedBox(width: 12), Flexible(child: Text(label, overflow: TextOverflow.ellipsis)), ]), ), ], ); if (pick == null || !mounted) return; switch (pick) { case 'pickRsync': await _sel.pick(pane, e); case 'open': await _ctl.goTo(pane, e.path); case 'openOther': final other = _ctl.panes[1 - _ctl.panes.indexOf(pane)]; await _ctl.goTo(other, e.path); setState(() => _ctl.active = _ctl.panes.indexOf(other)); case 'playFolder': await playFolder(e.path); case 'slideshow': await _slideshow(pane, e.path); case 'playInternal': await play([e.path], internal: true); case 'grid': await _grid(pane, e.path); case 'text': await _viewText(e.path); case 'sqlite': await openSqliteViewer(context, c, e.path); case 'vaultAdd': await openVault(context, c, add: targets, deleteOriginals: (paths, {required permanent}) { // 관리자 지시 (10/21): [영구 삭제] 를 고르면 휴지통을 거치지 않음 (Shift+Delete 와 같은 길 - 탐색기의 영구 삭제 확인 창을 한 번 더) shiftDelete = permanent; return deleteItems(pane, paths); }); await _ctl.refreshAll([vDirname(e.path)]); case 'openWith': final local = await _fetch(e.path); if (local != null && !await c.services.shell.openWith(local, choose: true)) _snack(_noAppMessage(e.path)); case 'openDefault': final local = await _fetch(e.path); if (local != null && !await c.services.shell.openWith(local)) _snack(_noAppMessage(e.path)); case 'addMkv': await addMkv([e.path]); case 'range': _sel.markRange(pane, e); case 'select': setState(() => _sel.selecting = true); if (!_sel.isMarked(pane, e.path)) await _sel.toggleMark(pane, e); case 'quickLook': await _quickLook(pane, e.path); case 'extract': await _extractArchive(e.path, pane); case 'storage': await openStorage(pane, e.path); case 'wifiShare': await openWifiShare(e.path); case 'zip': final items = outermostPaths(_sel.isMarked(pane, e.path) && pane.sel.marked.length > 1 ? pane.sel.marked : [e.path]); final other = _dual ? _ctl.panes[1 - _ctl.panes.indexOf(pane)].current : null; final made = await createZipFlow(context, c, items, dir: vDirname(items.first), otherDir: other); if (made != null && mounted) { await _ctl.refreshAll([vDirname(made)]); pane.focused = made; pane.changed(); } case 'appHide': if (c.settings.explorerHideConfirm && !await _confirmAppHide(targets)) return; await c.updateSettings((s) => s.explorerHiddenItems = [ ...s.explorerHiddenItems, for (final x in targets) if (!_ctl.appHidden(x)) x, ]); for (final q in _ctl.panes) { targets.forEach(q.sel.forget); q.changed(); } if (mounted) setState(() {}); _snack(_ctl.showAppHidden ? trf('목록에서 숨겼습니다 ({0}개). 파일은 그대로입니다.', [targets.length]) : trf('목록에서 숨겼습니다 ({0}개). 파일은 그대로이고, 목록 맨 아래의 [숨긴 항목 보이기] 로 다시 볼 수 있습니다.', [targets.length])); case 'appUnhide': final keys = {for (final x in targets) HiddenList.keyOf(x)}; await c.updateSettings((s) => s.explorerHiddenItems = [ for (final x in s.explorerHiddenItems) if (!keys.contains(HiddenList.keyOf(x))) x, ]); for (final q in _ctl.panes) { q.changed(); } if (mounted) setState(() {}); case 'nomediaOn' || 'nomediaOff': await _nomedia(pane, e.path, on: pick == 'nomediaOn'); case 'props': await showFolderProperties(context, e.path); case 'hash': await showFileHash(context, e.path); case 'folderSize': if (_ctl.sizes.busy(e.path)) { _ctl.sizes.cancel(e.path); break; } // 사용자가 직접 시킨 것: 다른 폴더로 가도 끝까지 (멈추기는 아래 알림 · 같은 메뉴) _ctl.sizes ..forget(e.path) ..request(e.path); if (mounted) { showNotice(ScaffoldMessenger.of(context), text: isDav(e.path) ? trf('"{0}" 크기를 계산합니다. 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다.', [e.name]) : trf('"{0}" 크기를 계산합니다.', [e.name]), action: (tr('취소'), () => _ctl.sizes.cancel(e.path))); } case 'rename': if (_sel.isMarked(pane, e.path) && pane.sel.marked.length > 1) { await batchRename(pane, pane.sel.marked); } else { await renameItem(pane, e.path); } case 'copy': await transfer(pane, _sel.isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: false); case 'move': await transfer(pane, _sel.isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: true); case 'clipCopy': _toClipboard(_sel.isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: false); case 'clipMove': _toClipboard(_sel.isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: true); case 'paste': await paste(pane, e.isDir ? e.path : vDirname(e.path)); case 'delete': // 77: 메뉴 머리의 "n개 항목" 과 같게 (예전에는 표시한 것이 여럿이어도 누른 것 하나만 지웠다) await deleteItems(pane, targets); case 'share': await _share(targets); case 'copyPath': final paths = targets.map(_copyablePath).toList(); await Clipboard.setData(ClipboardData(text: paths.join('\n'))); _snack(many ? trf('경로 {0}개를 복사했습니다', [paths.length]) : trf('경로를 복사했습니다: {0}', [paths.single])); case 'bookmark': final name = vBasename(e.path).isEmpty ? e.path : vBasename(e.path); await c.updateSettings((s) => s.folderBookmarks = [...s.folderBookmarks, FolderBookmark(name: name, path: e.path)]); _snack(trf('즐겨찾기에 넣었습니다: {0}', [name])); setState(() {}); case 'view': await _open(e.path, pane: pane); case 'toPdf': await _imagesToPdf(images); case 'reveal': await c.services.shell.revealFile(e.path); case 'info': await _info(e); } } /// 77: 공유 - 파일만 (폴더는 보낼 수 없어 빼고 알림). WebDAV 파일은 받아서 Future _share(List paths) async { final files = [for (final x in paths) if (!vIsDirSync(x)) x]; if (files.isEmpty) { _snack(tr('폴더는 공유할 수 없습니다. 파일을 고르세요.')); return; } final local = []; for (final f in files) { final l = await _fetch(f); if (l == null || !mounted) return; local.add(l); } if (!await c.services.shell.shareFiles(local)) { _snack(tr('공유 창을 열 수 없습니다.')); } else if (files.length < paths.length) { _snack(trf('폴더 {0}개는 빼고 파일만 보냈습니다.', [paths.length - files.length])); } } // ───────── 기능 ───────── ExplorerPane? get _other => _dual || _hiddenTarget ? _ctl.panes[1 - _ctl.active] : null; // ───────── X-plore 비교 f: 압축을 트리 안에서 폴더처럼 (ZIP · 7z · RAR, 읽기 전용) ───────── String? _archiveOf(String path) => _ctl.arc.archiveOf(path); bool _insideArchive(String path) => _ctl.arc.inside(path); /// 압축 안의 파일 하나를 임시 폴더로 꺼낸다 (보기 · 재생). 꺼낸 경로 (못 하면 null) Future _extractOne(String path) async { final (out, why) = await _ctl.arc.extractOne(path, await c.services.storage.tempDirectory()); if (why == ArchiveRead.password) _snack(tr('비밀번호가 걸린 파일입니다 - [압축 풀기] 로 푸세요')); if (why == ArchiveRead.failed) _snack(trf('압축에서 꺼내지 못했습니다: {0}', [_ctl.arc.lastError])); return out; } /// 압축 안의 것을 꺼내기 - 86 의 풀기 창 (검사 · 갈 곳 · 같은 이름) 그대로 Future _extractFromArchive(List paths, String? otherDir) async { final arc = _archiveOf(paths.firstWhere(_insideArchive))!; final names = [for (final x in paths) if (_insideArchive(x) && samePath(_archiveOf(x)!, arc)) _ctl.arc.innerName(arc, x)]; final done = await extractArchiveFlow(context, c, arc, otherDir: otherDir, names: names); if (done != null && mounted) await _ctl.refreshAll([done]); } /// 관리자 지시 (10/21, 28 지적): 시스템 폴더 · 루트 (shz://) 가 원본이나 받는 곳인 이동은 시작하지 않는다 - /// 복사는 되고 원본 지우기에서 오류로 끝나던 것 (끌어 놓기 · 붙여넣기 · 버튼 · 메뉴 모두 여기를 지남). 막았으면 true bool _blockShizukuMove(List sources, String? dest, {required bool move}) { if (!move || !(sources.any(isShizuku) || (dest != null && isShizuku(dest)))) return false; _snack(tr('읽기 전용이라 옮길 수 없습니다 - 복사를 쓰세요')); return true; } /// 압축 안을 바꾸려 하면 막고 알린다 (읽기 전용). 막았으면 true /// [self] 면 압축 파일 자신도 (그 안으로 넣는 곳일 때) @override bool blockArchive(List paths, {bool self = false}) { // X-plore 비교 5-2: 시스템 폴더 (Shizuku) 도 읽기 전용 - 쓰기 · 지우기 · 이름 바꾸기 · 옮기기 · 새 폴더 · 넣기를 막는다 (꺼내기만) if (paths.any(isShizuku)) { _snack(paths.any(isRootPath) ? tr('루트 폴더는 읽기 전용입니다 (꺼내기 (복사) 만 할 수 있습니다)') : tr('시스템 폴더 (Shizuku) 는 읽기 전용입니다 (꺼내기 (복사) 만 할 수 있습니다)')); return true; } if (!paths.any((x) => _insideArchive(x) || (self && _ctl.arc.contains(x)))) return false; _snack(tr('압축 안은 읽기 전용입니다 (꺼내기만 할 수 있습니다)')); return true; } /// 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('다른 폴더')), ), ]), ), ); } /// 기능의 대상: 표시한 항목, 없으면 마지막으로 누른 항목 (저장 장치 맨 위는 빼고) @override List targetsOf(ExplorerPane pane) { if (!pane.sel.isEmpty) return pane.sel.marked; final f = pane.focused; if (f == null || samePath(f, pane.root)) return const []; return [f]; } void _snack(String text) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text))); } @override Future playFolder(String dir) async { final List list; try { list = (await listEntries(dir)).where((e) => !e.isDir && isVideoFile(e.path)).map((e) => e.path).toList(); } catch (e) { _snack(trf('읽을 수 없습니다: {0}', [explainFileError('$e', dav: isDav(dir)).$1])); return; } if (list.isEmpty) { _snack(tr('이 폴더에 동영상이 없습니다.')); return; } if (!mounted) return; await play(list); } @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 (blockArchive([path])) return; final name = await askName(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 (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(); } } /// Shift+Delete 로 불렀는지 (휴지통을 거치지 않고 지우기를 미리 골라 둔다 - 65) @override bool shiftDelete = false; /// 지우기. Windows 의 로컬 파일은 기본으로 휴지통으로 (창에서 "영구 삭제" 를 고르거나 Shift+Delete). /// WebDAV · Android 는 휴지통이 없어 바로 지운다. 여러 개면 실패해도 나머지를 계속하고, 못 지운 것을 알린다 (65). @override Future deleteItems(ExplorerPane pane, List paths) async { if (blockArchive(paths)) return; paths = outermostPaths(paths); // 79-c: 고른 폴더 안의 것은 한 번만 (확인 창 개수도) final shift = shiftDelete; shiftDelete = false; if (paths.isEmpty) { _snack(tr('지울 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).')); return; } // 휴지통이 있는 곳: Windows 의 고정 디스크 (94: 네트워크 드라이브 · \\NAS · USB 메모리는 휴지통이 없어 처음부터 영구 삭제로 묻는다) final local = Platform.isWindows && paths.every((x) => !isDav(x)); // X-plore 비교 e: Android 는 앱 휴지통 (저장 장치의 .jj_trash, 보관 일이 0 이면 끔) final trash = _appTrash; final canTrash = trash != null && paths.every((x) => !isDav(x) && trash.trashDirFor(x) != null && !trash.isInTrash(x)); final canRecycle = (local && c.settings.recycleOnDelete && paths.every(hasRecycleBin)) || canTrash; var permanent = !canRecycle || shift; // X-plore 숨기기: 지우는 폴더 안에 목록에서 숨긴 것이 있으면 알린다 (숨긴 것도 함께 지워진다) final hiddenInside = [ for (final h in c.settings.explorerHiddenItems) if (paths.any((x) => !samePath(h, x) && isSameOrInside(h, x))) h ].length; final otherMarks = [for (final x in _ctl.panes) if (!identical(x, pane)) x.sel.marked.length].fold(0, (a, b) => a + b); // U7: 확인 창은 공용 confirmDialog - 영구 삭제로 열면 Enter 의 첫 초점은 [취소] final ok = await confirmDialog( context, title: () => permanent ? tr('영구 삭제') : tr('휴지통으로 보내기'), danger: () => permanent, confirmLabel: () => permanent ? tr('영구 삭제') : tr('휴지통으로'), // 무엇을 지우는지 늘 보여 준다 (여러 개면 앞의 8개 이름 · 폴더) body: (ctx, set) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(paths.length == 1 ? '${permanent ? trf('"{0}" 을(를) 지울까요? 되돌릴 수 없습니다.', [vBasename(paths.first)]) : trf('"{0}" 을(를) 휴지통으로 보낼까요?', [vBasename(paths.first)])}' '\n${vDirname(paths.first)}' : [ permanent ? trf('{0}개 항목을 지울까요? 되돌릴 수 없습니다.', [paths.length]) : trf('{0}개 항목을 휴지통으로 보낼까요?', [paths.length]), for (final x in paths.take(8)) '· ${vBasename(x)}', if (paths.length > 8) trf('… 외 {0}개', [paths.length - 8]), {for (final x in paths) vDirname(x)}.join('\n'), ].join('\n')), // 79-b: 폴더가 있으면 몇 개인지 (안의 것도 함께 지워진다) if (paths.any(vIsDirSync)) Padding( padding: const EdgeInsets.only(top: 8), child: Text(trf('그중 폴더 {0}개 - 안의 파일 · 폴더도 모두 함께', [paths.where(vIsDirSync).length]), style: TextStyle(fontSize: 12, color: JjColors.warning)), ), if (hiddenInside > 0) Padding( padding: const EdgeInsets.only(top: 8), child: Text(trf('지우는 폴더 안에 목록에서 숨긴 항목 {0}개가 있습니다 - 함께 지워집니다', [hiddenInside]), style: TextStyle(fontSize: 12, color: JjColors.warning)), ), if (local && !canRecycle) Padding( padding: const EdgeInsets.only(top: 8), child: Text(tr('이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.'), style: TextStyle(fontSize: 12, color: JjColors.warning)), ), if (canTrash && !permanent) Padding( padding: const EdgeInsets.only(top: 8), child: Text(trf('앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).', [c.settings.androidTrashDays]), style: TextStyle(fontSize: 12, color: JjColors.textDim)), ), if (canRecycle) CheckboxListTile( contentPadding: EdgeInsets.zero, value: permanent, onChanged: (v) => set(() => permanent = v ?? false), title: Text(canTrash ? tr('휴지통을 거치지 않고 영구 삭제') : tr('휴지통을 거치지 않고 영구 삭제 (Shift+Delete)')), ), // U4: 다른 창의 표시는 이번 대상이 아님 if (otherMarks > 0) Padding( padding: const EdgeInsets.only(top: 8), child: Text(trf('다른 창에 {0}개 표시됨 (이번 대상 아님)', [otherMarks]), style: TextStyle(fontSize: 12, color: JjColors.textDim)), ), ]), ); if (!ok) return; // 하나씩: 실패해도 나머지는 계속. 실제로 어떻게 됐는지 세어 그대로 알린다 (94) - 지우기 규칙은 delete_ops (E5b) final report = await deletePaths(paths, permanent: permanent, trash: !permanent && canTrash ? trash : null); final failed = report.failed, tooLong = report.tooLong, sent = report.sent, trashed = report.trashed; final notTrashed = report.notTrashed, undoable = report.undoable, since = report.since; final recycled = report.recycled, nuked = report.nuked, kept = report.kept; // X-plore 비교 e: 휴지통으로 옮기지 못한 것 (다른 저장 장치 · 권한 등) - 몰래 지우지 않고 영구 삭제할지 묻는다 if (notTrashed.isNotEmpty && mounted) { // U7: 되돌릴 수 없는 일 - Enter 의 첫 초점은 [그대로 두기] final nuke = await confirmDialog( context, icon: Icon(Icons.warning_amber_rounded, color: JjColors.warning), title: () => trf('{0}개를 휴지통으로 옮기지 못했습니다', [notTrashed.length]), danger: () => true, cancelLabel: tr('그대로 두기'), confirmLabel: () => trf('영구 삭제 ({0}개, 되돌릴 수 없음)', [notTrashed.length]), body: (ctx, set) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ for (final (x, why) in notTrashed.take(10)) Text('· ${vBasename(x)} - $why', style: const TextStyle(fontSize: 12)), const SizedBox(height: 8), Text(tr('영구 삭제하면 되살릴 수 없습니다.')), ]), ); if (nuke) { for (final (x, e) in await deleteForever([for (final (x, _) in notTrashed) x])) { failed.add((x, '$e')); } } } if ((failed.isNotEmpty || tooLong.isNotEmpty) && mounted) { final failTitle = trf('{0}개 중 {1}개를 지우지 못했습니다', [paths.length, failed.length + tooLong.length]); final failIcon = Icon(Icons.error_outline, color: JjColors.alert); Widget failBody() => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ // 144: 긴 경로는 이유 한 줄 + 이름만 (영구 삭제는 아래 버튼으로) if (tooLong.isNotEmpty) ...[ Text(tr('경로가 너무 길어 휴지통에 넣을 수 없습니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다).'), style: const TextStyle(fontWeight: FontWeight.w600)), const SizedBox(height: 4), for (final x in tooLong.take(30)) Text('· ${vBasename(x)}', style: const TextStyle(fontSize: 12)), if (tooLong.length > 30) Text(trf('… 외 {0}개', [tooLong.length - 30])), const SizedBox(height: 8), ], for (final (x, why) in failed.take(30)) ...[ Text(vBasename(x), style: const TextStyle(fontWeight: FontWeight.w600)), Text('${vDirname(x)} · $why', style: TextStyle(fontSize: 12, color: JjColors.textDim)), const SizedBox(height: 4), ], if (failed.length > 30) Text(trf('… 외 {0}개', [failed.length - 30])), const SizedBox(height: 6), // 이유를 이미 위에 적었으므로 짐작하는 말은 빼고, 실제로 지운 것이 있을 때만 "나머지는 지웠습니다" if (failed.length + tooLong.length < paths.length) Text(tr('나머지는 지웠습니다.'), style: const TextStyle(fontSize: 12)), ]); // U7: 긴 경로를 영구 삭제할지 묻는 것은 되돌릴 수 없는 일 - Enter 의 첫 초점은 [그대로 두기]. 아니면 알림만 final nukeLong = tooLong.isNotEmpty ? await confirmDialog( context, icon: failIcon, title: () => failTitle, danger: () => true, cancelLabel: tr('그대로 두기'), confirmLabel: () => trf('영구 삭제 ({0}개, 되돌릴 수 없음)', [tooLong.length]), body: (ctx, set) => failBody(), ) : await showDialog( context: context, builder: (ctx) => AlertDialog( scrollable: true, icon: failIcon, title: Text(failTitle), content: failBody(), actions: [FilledButton(autofocus: true, onPressed: () => Navigator.pop(ctx), child: Text(tr('확인')))], ), ) == true; // 144: 사용자가 [영구 삭제] 를 고른 긴 경로만 지운다 if (nukeLong == true) { final notDeleted = [for (final (x, e) in await deleteForever(tooLong)) '${vBasename(x)}: ${deleteFailReason(e)}']; _snack(joinSentences([ if (tooLong.length > notDeleted.length) trf('{0}개를 영구 삭제했습니다.', [tooLong.length - notDeleted.length]), if (notDeleted.isNotEmpty) trf('지우지 못함: {0}', [notDeleted.take(3).join(' · ')]), ])); } } else if (!permanent && mounted) { final text = joinSentences([ if (recycled > 0) trf('{0}개 항목을 휴지통으로 보냈습니다.', [recycled]), // 휴지통보다 커서 Windows 가 물었고 사용자가 영구 삭제를 골랐다 if (nuked > 0) trf('{0}개는 휴지통에 들어가지 않아 영구 삭제했습니다 (Windows 가 묻고 고른 대로).', [nuked]), if (kept > 0) trf('{0}개는 지우지 않았습니다 (취소).', [kept]), ]); // 148: 휴지통으로 보낸 것은 바로 [되돌리기] showNotice(ScaffoldMessenger.of(context), text: text, action: trashed.isNotEmpty ? (tr('되돌리기'), () => _undoTrash(trashed)) : sent.isEmpty ? null : (tr('되돌리기'), () => _undoRecycle(sent, since))); } // X-plore 숨기기: 영구로 지운 것 (그 안 포함) 은 숨긴 목록에서 뺀다. 휴지통으로 보낸 것은 되돌리기 뒤에도 숨김이 되게 남긴다 await _ctl.followHidden(gone: [ for (final x in paths) if (!sent.contains(x) && !undoable.contains(x) && !failed.any((f) => f.$1 == x)) x, ]); for (final x in _ctl.panes) { paths.forEach(x.sel.forget); if (_sel.selecting && x.sel.isEmpty) _sel.selecting = false; x.expanded.removeWhere((d) => paths.any((s) => isSameOrInside(d, s))); if (paths.any((s) => isSameOrInside(x.current, s))) x.current = vDirname(paths.first); } await _ctl.refreshAll(paths.map(p.dirname)); } /// X-plore 비교 e: Android 앱 휴지통 (보관 일이 0 이면 null - 바로 영구 삭제) AppTrash? get _appTrash => (ExplorerPage.debugAndroidTrash ?? Platform.isAndroid) && c.settings.androidTrashDays > 0 ? AppTrash([for (final (v, _) in _ctl.local) v]) : null; /// X-plore 비교 e: 방금 앱 휴지통으로 보낸 것을 원래 자리로 Future _undoTrash(List items) async { final trash = _appTrash; if (trash == null) return; final back = await undoTrash(trash, items); await _ctl.refreshAll(back.map(p.dirname)); _snack(trf('{0}개를 원래 자리로 되살렸습니다', [back.length])); } /// 시험: [휴지통] 열기 @visibleForTesting Future debugOpenTrash() => openTrash(); /// X-plore 비교 e: [휴지통] - Android 는 앱 휴지통 화면, Windows 는 시스템 휴지통 @override Future openTrash() async { final trash = _appTrash; if (trash == null) { if (Platform.isWindows) { await Process.start('explorer.exe', ['shell:RecycleBinFolder']); } else { _snack(tr('휴지통이 꺼져 있습니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).')); } return; } final back = await Navigator.of(context).push>( MaterialPageRoute(builder: (_) => TrashPage(trash: trash, days: c.settings.androidTrashDays))); if (back != null && back.isNotEmpty && mounted) await _ctl.refreshAll(back.map(p.dirname)); } /// 148: 방금 휴지통으로 보낸 것을 원래 자리로 (원래 자리에 같은 이름이 생겼으면 그것은 두고 알림) Future _undoRecycle(List paths, DateTime since) async { final (:back, failed: why) = undoRecycle(paths, since); final failed = [for (final (x, e) in why) '${vBasename(x)}: $e']; await _ctl.refreshAll(paths.map(p.dirname)); for (final x in _ctl.panes) { x.changed(); } _snack(joinSentences([ if (back > 0) trf('{0}개 항목을 되돌렸습니다.', [back]), if (failed.isNotEmpty) trf('되돌리지 못함: {0}', [failed.take(3).join(' · ')]), ])); } /// 다른 창의 지금 폴더로 복사 · 이동 (진행 창 · 취소) /// 복사 · 이동 버튼: 두 창이면 다른 창의 지금 폴더로, 그 밖의 배치는 담아 두기 (붙여넣기로) @override Future transfer(ExplorerPane pane, List sources, {required bool move}) async { if (_blockShizukuMove(sources, null, move: move)) return; // X-plore 비교 f: 압축 안의 것 - 복사는 꺼내기 (86 의 풀기 검사 그대로), 이동은 막음 if (sources.any(_insideArchive)) { if (move) { blockArchive(sources); return; } await _extractFromArchive(sources, _other?.current); return; } sources = outermostPaths(sources); // 79-c: 고른 폴더 안의 것은 한 번만 final other = _other; if (other == null) { _toClipboard(sources, move: move); return; } await _runTransfer(pane, sources, other.current, move: move); } /// 복사 · 이동할 항목 (붙여넣기 전까지 담아 둠) - FileClipboard (E5b) final _clip = FileClipboard(); /// 154: 고른 것이 없을 때 (복사 · 이동이 같은 말) String _pickFirst(bool move) => move ? tr('옮길 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).') : tr('복사할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).'); void _toClipboard(List sources, {required bool move}) { if (_blockShizukuMove(sources, null, move: move)) return; if (sources.isEmpty) { _snack(_pickFirst(move)); return; } setState(() => _clip.put(sources, move: move)); _snack(trf('{0} 을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).', [namesOf(sources), move ? tr('이동') : tr('복사')])); } @override Future paste(ExplorerPane pane, String dest) async { if (_clip.isEmpty) { _snack(tr('붙여넣을 항목이 없습니다. 먼저 [복사] · [이동] 으로 담으세요.')); return; } final done = await _runTransfer(pane, _clip.items, dest, move: _clip.move); // 옮긴 것은 다시 붙여넣을 수 없으므로 비운다 (복사는 여러 곳에 붙여넣을 수 있게 남김) if (mounted) setState(() => _clip.pasted(done: done)); } /// 지금 도는 복사 · 이동 (아래에서 올라오는 진행 막대) /// (Rsync 화면의 ⇄ 는 둘이 함께) List _jobs = const []; 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))); } /// 52: 이 화면을 떠난 사이에 시작된 (또는 앞의 화면이 시작한) 작업을 다시 보이고, 끝나면 새로 읽고 막대를 내린다 /// X-plore 숨기기: 옮긴 것은 숨긴 목록도 새 자리로 (원래 자리에 없고 새 자리에 있는 것만 - 같은 이름 바꾸기 · 실패는 그대로) Future _followMovedJob(TransferJob job) async { if (!job.move || job.contents || c.settings.explorerHiddenItems.isEmpty) return; final moved = <(String, String)>[]; for (final s in job.sources) { final to = vJoin(job.dest, vBasename(s)); if (!await vExists(s) && await vExists(to)) moved.add((s, to)); } await _ctl.followHidden(moved: moved); } void _adoptJob(TransferJob job) { if (_jobs.contains(job)) return; setState(() => _jobs = [..._jobs, job]); unawaited(job.done.then((_) async { if (!mounted) return; await _followMovedJob(job); if (!mounted) return; await _ctl.refreshAll([job.dest, ...job.sources.map(vDirname)]); for (final x in _ctl.panes) { x.changed(); } await Future.delayed(const Duration(milliseconds: 1200)); if (mounted && _jobs.contains(job)) setState(() => _jobs = [for (final j in _jobs) if (j != job) j]); })); } /// 한쪽이라도 WebDAV 면 rsync 대신 앱이 직접 맞춘다 (rsync 는 WebDAV 를 모름) bool _viaDav(Iterable paths) => paths.any(isDav); /// rsync 가 없으면 (Windows · 내려받기 설정) 지금 내려받을지 묻는다. 쓸 수 있는 rsync 경로 (없으면 null) Future _ensureRsync() async { final s = c.settings; final have = await rsyncExecutable(s); if (have != null) return have; if (!mounted) return null; if (s.rsyncSource == 'custom' || !Platform.isWindows) { _snack(tr('rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > 파일 탐색기에서 경로를 확인하세요.')); return null; } return installRsyncWithDialog(context); } /// [dest] 로 복사 · 이동 (확인 · 아래 진행 막대 · 취소). 끝까지 했으면 true. Future _runTransfer(ExplorerPane pane, List sources, String dest, {required bool move, void Function(TransferJob job)? onFinished}) async { if (sources.isEmpty) { _snack(_pickFirst(move)); return false; } if (_blockShizukuMove(sources, dest, move: move)) return false; // X-plore 비교 f: 압축 안으로는 넣지 않고 (읽기 전용), 압축 안의 것을 붙여넣으면 꺼내기로 if (blockArchive([dest], self: true)) return false; if (sources.any(_insideArchive)) { if (move) return !blockArchive(sources); await _extractFromArchive(sources, dest); return true; } // 23: 앞의 복사 · 이동이 끝나지 않아도 함께 시작한다 (아래 진행 막대에 하나씩). 같은 것을 같은 곳으로만 막는다 if (_jobs.any((j) => !j.finished && samePath(j.dest, dest) && j.sources.toSet().containsAll(sources))) { _snack(tr('같은 항목을 같은 곳으로 복사 · 이동하는 중입니다.')); return false; } // 135: 같은 폴더로 - 짧은 알림은 놓치기 쉬워 창으로. 이동은 할 것이 없고, 복사는 사본을 만들지 한 번 묻는다 (계획은 transfer_plan) final plan = planTransfer(sources, dest, move: move); sources = plan.sources; final skippedHere = plan.skippedHere, here = plan.here; var sameFolderCopy = false; if (plan.nothingToMove) { // 알림만 ([확인] 에 첫 초점 - Enter 로 닫기) await showDialog( context: context, builder: (ctx) => AlertDialog( scrollable: true, title: Text(tr('이미 이 폴더에 있습니다')), content: Text(trf('{0} 은(는) 이미 이 폴더에 있어 옮길 것이 없습니다.\n{1}', [namesOf(here), vDisplay(dest)])), actions: [FilledButton(autofocus: true, onPressed: () => Navigator.pop(ctx), child: Text(tr('확인')))], ), ); return false; } if (plan.askCopyHere) { final ok = await confirmDialog(context, title: () => tr('같은 폴더입니다'), confirmLabel: () => tr('사본 만들기'), body: (ctx, set) => Text(trf('같은 폴더에 사본을 만들까요? (이름 (2))\n{0}', [namesOf(here)]))); if (!ok || !mounted) return false; sameFolderCopy = true; // 이미 물었으니 아래 확인 창은 건너뛴다 } // 원본 · 대상이 없거나 (다른 곳에서 지움 - 목록을 새로 고침) 폴더를 자기 안으로 넣으려 하면 시작하지 않는다 final problem = plan.problem; if (problem != null) { _snack(problem); pane.sel.removeWhere(vMissingSync); await _ctl.refreshAll([dest, ...sources.map(p.dirname)]); for (final x in _ctl.panes) { x.changed(); } return false; } // 파일 탐색기: 현재 방식 · robocopy (rsync 는 Rsync 화면 - 그쪽만 모니터링에 남는다) final center = CopyCenter.of(c); final task = center.fresh(sources, dest, move: move); final method = CopyMethod.of(task.method); // 48: 받는 폴더에 같은 이름이 있으면 덮어쓰기 · 건너뛰기 · 이름 바꾸기를 고른다 // (앱이 직접 할 때만 - robocopy · rsync 는 그 프로그램의 규칙. 같은 폴더로 복사는 늘 새 이름) final builtin = method == CopyMethod.builtin || _viaDav([...sources, dest]); // 확인 창은 바로 띄우고, 같은 이름은 창 안에서 뒤에서 찾는다 (느린 네트워크 · WebDAV 폴더라도 창이 늦게 뜨지 않게). // 다 찾기 전에 누르면 이름 바꾸기 (원래 파일을 건드리지 않는 쪽) final clashesFuture = builtin ? findClashes(sources, dest) : Future.value(const []); // 48: 환경 설정 "같은 이름이 있을 때" 가 늘 하는 것이면 묻지 않고 그대로 (확인 창에 알림만) final always = switch (c.settings.copyConflict) { 'rename' => NameConflict.rename, 'overwrite' => NameConflict.overwrite, 'skip' => NameConflict.skip, _ => null, }; var conflict = always ?? NameConflict.rename; // U4 (점검 10/22, 관리자 결정): 다른 창의 표시는 그대로 두고 이번 대상이 아님을 알린다 final otherMarks = [for (final x in _ctl.panes) if (!identical(x, pane)) x.sel.marked.length].fold(0, (a, b) => a + b); // U7: 공용 confirmDialog - 늘 덮어쓰기 설정이면 (되돌릴 수 없음) Enter 의 첫 초점은 [취소] final go = sameFolderCopy ? true : await confirmDialog( context, title: () => move ? tr('이동') : tr('복사'), confirmLabel: () => move ? tr('이동') : tr('복사'), danger: () => conflict == NameConflict.overwrite, body: (ctx, setInner) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ // 61: 문장 조각을 이어 붙이지 않는다 (언어마다 어순이 달라 "…로 {복사할까요?}" 가 깨졌다) Text('${move ? trf('{0}개 항목을 다음 폴더로 옮길까요?\n{1}', [sources.length, vDisplay(dest)]) : trf('{0}개 항목을 다음 폴더로 복사할까요?\n{1}', [sources.length, vDisplay(dest)])}' '\n${namesOf(sources)}' '${skippedHere.isEmpty ? '' : '\n${trf('이미 이 폴더에 있어 건너뜀: {0}', [namesOf(skippedHere)])}'}' '\n\n${trf('방법: {0}', [tr(method.label)])}' '${method == CopyMethod.builtin ? '' : ' · ${withoutSourceRemovingOptions(task.options)}${move ? (method == CopyMethod.rsync ? ' --remove-source-files' : ' /MOVE') : ''}'}' '${task.bandwidthKBps > 0 ? ' · ${trf('속도 제한 {0} KB/s', [task.bandwidthKBps])}' : ''}'), // 178 보충: 환경 설정의 원본 지우기 옵션은 복사에서 뺀다 - 왜 빠졌는지 알림 if (!move && method != CopyMethod.builtin && optionsRemoveSource(task.options)) Padding( padding: const EdgeInsets.only(top: 6), child: Text( trf('환경 설정의 {0} 는 복사에서는 쓰지 않습니다 (이동은 [이동] 으로)', [splitOptions(task.options).where(isSourceRemovingOption).join(' ')]), style: TextStyle(fontSize: 12, color: JjColors.textDim)), ), FutureBuilder>( future: clashesFuture, builder: (ctx, snap) { if (snap.connectionState != ConnectionState.done) { return Padding( padding: const EdgeInsets.only(top: 12), child: Text(tr('같은 이름이 있는지 확인하는 중 …'), style: TextStyle(fontSize: 12, color: JjColors.textDim)), ); } final clashes = snap.data ?? const []; if (clashes.isEmpty) return const SizedBox.shrink(); return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 12), Text(trf('같은 이름이 이미 있습니다: {0}', [namesOf([for (final x in clashes) x.src])]), style: TextStyle(color: JjColors.warning)), // 172: 두 파일의 크기 · 바뀐 날 (3개까지) for (final x in clashes.take(3)) ...[ Padding( padding: const EdgeInsets.only(top: 4, left: 8), child: Text(clashCompareText(x), style: const TextStyle(fontSize: 12)), ), // 229: 폴더를 합치면 안쪽에서 받는 쪽이 더 새 파일 - 건너뛰는지 (환경 설정) 미리 if (x.there.isDir && !isDav(x.src) && !isDav(dest)) FutureBuilder( future: FileOps.countNewerInside(x.src, vJoin(dest, vBasename(x.src))), builder: (_, n) => Padding( padding: const EdgeInsets.only(left: 20), child: Text( n.connectionState != ConnectionState.done ? tr('안쪽 파일을 확인하는 중 …') : (n.data ?? 0) == 0 ? '' : c.settings.copySkipNewer ? trf('안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)', [n.data]) : trf('안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다', [n.data]), style: TextStyle(fontSize: 12, color: JjColors.warning), ), ), ), ], if (always != null) Text( trf('환경 설정대로 {0} (환경 설정 > 파일 탐색기 > 같은 이름이 있을 때)', [ switch (always) { NameConflict.rename => tr('이름을 바꿉니다'), NameConflict.overwrite => tr('덮어씁니다'), NameConflict.skip => tr('건너뜁니다'), } ]), style: const TextStyle(fontSize: 12)) else RadioGroup( groupValue: conflict, onChanged: (x) => setInner(() => conflict = x ?? conflict), child: Column(mainAxisSize: MainAxisSize.min, children: [ for (final (v, label) in [ (NameConflict.rename, tr('이름 바꾸기 (예: 이름 (2))')), (NameConflict.overwrite, tr('덮어쓰기 (원래 파일은 없어집니다)')), (NameConflict.skip, tr('건너뛰기')), ]) RadioListTile(dense: true, contentPadding: EdgeInsets.zero, value: v, title: Text(label)), ]), ), ]); }, ), if (otherMarks > 0) Padding( padding: const EdgeInsets.only(top: 8), child: Text(trf('다른 창에 {0}개 표시됨 (이번 대상 아님)', [otherMarks]), style: TextStyle(fontSize: 12, color: JjColors.textDim)), ), ]), ); if (go != true || !mounted) return false; // 104: 설정의 옵션에 지우기 (robocopy /MIR · /PURGE 등) 가 있으면 미리 보기를 거친다 if (!await confirmDeletingRun(context, task) || !mounted) return false; String? rsync; if (method == CopyMethod.rsync && !_viaDav([...sources, dest])) { rsync = await _ensureRsync(); if (rsync == null || !mounted) return false; } final job = await center.start(task, rsyncExe: rsync, conflict: conflict); if (job == null || !mounted) return false; setState(() => _jobs = [..._jobs.where((j) => !j.finished), job]); await job.done; final error = job.error; await _followMovedJob(job); if (!mounted) return false; // 180: 표시만 지우고 선택 모드는 그대로 (꺼지면 동그라미 칸 · 아래 막대가 사라져 자리가 바뀐다) pane.sel.clear(); await _ctl.refreshAfterTransfer(sources, dest); // 넣은 폴더가 창의 지금 폴더면 펼쳐서 새 항목이 보이게 for (final x in _ctl.panes) { if (samePath(x.current, dest)) { x.expanded.add(dest); await _ctl.load(x, dest, force: true); } x.changed(); } final doneText = error is FileOpCancelled ? tr('취소했습니다.') : error != null ? trf('끝나지 못했습니다: {0}', [error]) : joinSentences([ trf('{0}개 항목을 {1}', [job.made.length, move ? tr('옮겼습니다.') : tr('복사했습니다.')]), // 관리자 지시 (10/21, 15 찾음 F3): 뒤 조각도 문장으로 쓰고 빈칸으로 잇는다 (예전에는 "…옮겼습니다. · 1개는 …" 처럼 마침표 뒤에 " · ") // 264: 작업 기록에만 있던 것도 알림에 (폴더를 합칠 때 받는 쪽이 더 새 파일 등) if (move && job.leftInSource > 0) trf('{0}개는 원본에 남았습니다 (건너뜀).', [job.leftInSource]), if (!move && job.keptNewer > 0) trf('받는 쪽이 더 새것이라 {0}개를 건너뛰었습니다.', [job.keptNewer]), if (job.skipped.isNotEmpty) trf('같은 이름이라 건너뛰었습니다: {0}.', [namesOf(job.skipped)]), // M1 (10/19): 원격 맞추기에서 받는 쪽의 같은 이름 폴더는 --delete 없이 지우지 않는다 if (job.dirConflicts.isNotEmpty) trf('같은 이름의 폴더가 있어 {0}개를 건너뛰었습니다: {1}.', [job.dirConflicts.length, namesOf(job.dirConflicts)]), if (skippedHere.isNotEmpty) trf('이미 이 폴더에 있어 건너뛰었습니다: {0}.', [namesOf(skippedHere)]), ]); // 85: 끝나면 [폴더 열기] - 앱 탐색기에서 넣은 폴더로 (그 폴더를 보고 있는 창, 없으면 지금 창) if (error == null && mounted) { // U2 (점검 10/22): 끝 알림은 정해진 시간 뒤 닫힘 - 아래 표시 줄을 가리지 않게 showNotice(ScaffoldMessenger.of(context), text: doneText, action: ( tr('폴더 열기'), () { final show = _ctl.panes.where((x) => samePath(x.current, dest)).firstOrNull ?? _ctl.pane; setState(() => _ctl.active = _ctl.panes.indexOf(show)); unawaited(_ctl.goTo(show, dest, fresh: true)); } )); } else { _snack(doneText); } // 82: 끌어 놓기의 이동 되돌리기 (끝난 알림 바로 뒤에 - 진행 막대가 내려가기를 기다리지 않게) if (error == null && mounted) onFinished?.call(job); // 다 되면 진행 막대가 잠깐 100% 를 보인 뒤 내려간다 await Future.delayed(const Duration(milliseconds: 1200)); if (mounted && _jobs.contains(job)) setState(() => _jobs = [for (final j in _jobs) if (j != job) j]); return error == null; } /// Rsync 화면: 고른 왼쪽 · 오른쪽 폴더로 rsync. [right] 왼쪽 → 오른쪽, [left] 오른쪽 → 왼쪽 (둘 다면 함께). /// 폴더 "안의 것" 을 맞춘다 (원본/ → 대상/). 함께 할 때는 받는 쪽이 더 새 파일은 건너뛴다 (-u). /// 실행한 것은 모니터링에 남는다 (같은 것을 다시 하면 기억한 옵션으로). /// 확인 창이 떠 있는 동안 또 누르면 무시 (창이 겹쳐 아래 창을 잘못 누르지 않게 - 110) bool _rsyncAsking = false; @override Future runRsync({required bool right, required bool left}) async { if (_rsyncAsking) return; _rsyncAsking = true; try { await _runRsyncInner(right: right, left: left); } finally { _rsyncAsking = false; } } Future _runRsyncInner({required bool right, required bool left}) async { final l = _ctl.panes[0].sel.marked.firstOrNull, r = _ctl.panes[1].sel.marked.firstOrNull; // 막는 조건 · 작업 · 옵션 · 미리 보기 · 실행은 RsyncRun (E5c), 확인 창은 rsync_run_dialog final (:run, :problem, :missing) = RsyncRun.prepare(CopyCenter.of(c), l: l, r: r, right: right, left: left, allowInnerToOuter: c.settings.allowInnerToOuter, jobs: _jobs, viaDav: l != null && r != null && _viaDav([l, r]), copySkipNewer: c.settings.copySkipNewer); if (run == null) { _snack(problem!); if (missing) { for (final x in _ctl.panes) { x.sel.removeWhere(vMissingSync); } await _ctl.refreshAll([vDirname(l!), vDirname(r!)]); } return; } if (!mounted) return; final go = await showRsyncRunDialog(context, run); // 110: 무엇을 골랐는지 작업 기록에 (실행 · 취소 · 닫힘) c.note(trf('Rsync 확인 창 ({0}): {1}', [ run.both ? '⇄' : right ? '→' : '←', go == true ? tr('실행') : go == false ? tr('취소') : tr('닫힘 (실행 안 함)'), ])); if (go != true || !mounted) return; if (run.nestedDelete()) { _snack(RsyncRun.nestedDeleteMessage); return; } final exe = run.viaDav ? '' : await _ensureRsync(); if (exe == null || !mounted) return; final res = await run.execute( rsyncExe: exe, alive: () => mounted, note: c.note, onStarted: (j) => setState(() => _jobs = [..._jobs.where((x) => !x.finished), j])); // 205: 시작하지 못하면 이유를 알린다 if (res.notStarted != null && mounted) _snack(trf('시작하지 못했습니다: {0}', [res.notStarted])); final jobs = res.jobs; if (jobs.isEmpty || !mounted) return; // 원본 폴더까지 지웠으면 고른 표시도 뺀다 for (final x in _ctl.panes) { x.sel.removeWhere(vMissingSync); } await _ctl.refreshAll([run.l, run.r, vDirname(run.l), vDirname(run.r)]); for (final x in _ctl.panes) { x.changed(); } final error = jobs.map((j) => j.error).whereType().firstOrNull; _snack(error is FileOpCancelled ? tr('취소했습니다.') : error != null ? trf('끝나지 못했습니다: {0}', [error]) // 212 · 216: 이동인지 · 몇 개인지 · 원본에 남은 것 : jobs.map((j) => j.doneSummary()).join('\n')); await Future.delayed(const Duration(milliseconds: 1200)); if (mounted) setState(() => _jobs = [for (final j in _jobs) if (!jobs.contains(j)) j]); } @override Future askName(String title, String initial, {String? hint, bool selectAll = false}) { final ctl = TextEditingController(text: initial); final dot = initial.lastIndexOf('.'); ctl.selection = TextSelection(baseOffset: 0, extentOffset: dot > 0 && !selectAll ? dot : initial.length); return showDialog( context: context, builder: (ctx) => AlertDialog( scrollable: true, title: Text(title), content: TextField( controller: ctl, autofocus: true, decoration: InputDecoration(hintText: hint ?? tr('이름')), onSubmitted: (v) => Navigator.pop(ctx, v.trim().isEmpty ? null : v.trim()), ), actions: [ TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('취소'))), FilledButton( onPressed: () => Navigator.pop(ctx, ctl.text.trim().isEmpty ? null : ctl.text.trim()), child: Text(tr('확인'))), ], ), ); } Future _info(FileEntry e) async { final size = e.isDir ? await FileOps.totalSize([e.path]) : e.size; final meta = !e.isDir && !isDav(e.path) && isVideoFile(e.path) ? await VideoMeta.of(c, e.path) : null; // 관리자 지시 10/18: 만든 때 · 숨김 · 읽기 전용 · 시스템 (보기만, 이 기기의 것만) final attrs = isDav(e.path) ? null : readFileAttributes(e.path); if (!mounted) return; await showDialog( context: context, builder: (ctx) => AlertDialog( scrollable: true, title: Text(e.name), content: SelectableText([ e.path, '${tr('크기')}: ${formatSize(size)}', if (attrs?.created != null) '${tr('만든 날')}: ${_date(attrs!.created!)}', if (e.modified.year > 1) '${tr('바뀐 날')}: ${_date(e.modified)}', if (attrs != null) '${tr('속성')}: ${attrs.labels.isEmpty ? tr('없음') : attrs.labels.join(' · ')}', if (meta != null && meta.isNotEmpty) '${tr('동영상')}: $meta', ].join('\n')), actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('닫기')))], ), ); } /// 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) => _open(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 transfer(pane, act.paths, move: false); case 'move': await transfer(pane, act.paths, move: true); case 'delete': await deleteItems(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): [목록에서 숨기기] 전에 무엇이 바뀌는지 알림. [다음부터 묻지 않기] 는 환경 설정에서 다시 켬 Future _confirmAppHide(List targets) async { var dontAsk = false; final ok = await showDialog( context: context, builder: (ctx) => StatefulBuilder( builder: (ctx, set) => AlertDialog( scrollable: true, title: Text(targets.length > 1 ? trf('{0}개 목록에서 숨기기', [targets.length]) : tr('목록에서 숨기기')), content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ if (targets.length == 1) Text(vBasename(targets.single), style: const TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 6), Text(tr('이 앱의 파일 탐색기 목록에서만 빠집니다. 파일은 지우거나 옮기거나 이름을 바꾸지 않고, 다른 앱 · 갤러리에서는 그대로 보입니다.')), const SizedBox(height: 6), Text(tr('목록 맨 아래의 [숨긴 항목 보이기] 나 환경 설정 > 파일 탐색기에서 다시 볼 수 있습니다.'), style: TextStyle(fontSize: 12, color: JjColors.textDim)), CheckboxListTile( contentPadding: EdgeInsets.zero, controlAffinity: ListTileControlAffinity.leading, value: dontAsk, onChanged: (v) => set(() => dontAsk = v ?? false), title: Text(tr('다음부터 묻지 않기')), ), ]), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('숨기기'))), ], ), ), ); if (ok != true) return false; if (dontAsk) await c.updateSettings((s) => s.explorerHideConfirm = false); return true; } /// X-plore 맞추기 (관리자 지시 10/20): 폴더에 빈 .nomedia 파일을 넣거나 지운다 (Android 갤러리 · 음악 앱에서 숨기기 · 다시 보이기). /// 그 폴더의 파일은 그대로 - 무엇이 바뀌는지 먼저 묻는다. 갤러리에 반영되는 때는 그 앱이 다시 읽을 때다 Future _nomedia(ExplorerPane pane, String dir, {required bool on}) async { final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( scrollable: true, title: Text(on ? tr('갤러리 · 음악 앱에서 숨기기 (.nomedia)') : tr('갤러리 · 음악 앱에 다시 보이기 (.nomedia 지우기)')), content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(vBasename(dir), style: const TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 6), Text(on ? tr('이 폴더에 빈 .nomedia 파일을 만듭니다. 갤러리 · 음악 앱이 이 폴더 (안의 폴더 포함) 의 그림 · 동영상 · 음악을 보이지 않게 됩니다.') : tr('이 폴더의 .nomedia 파일을 지웁니다. 갤러리 · 음악 앱이 이 폴더의 그림 · 동영상 · 음악을 다시 보이게 됩니다.')), const SizedBox(height: 6), Text(tr('폴더 안의 파일은 바꾸지 않습니다. 갤러리에 반영되기까지 잠깐 걸릴 수 있습니다.'), style: TextStyle(fontSize: 12, color: JjColors.textDim)), ]), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(on ? tr('숨기기') : tr('다시 보이기'))), ], ), ); if (ok != true || !mounted) return; final f = File(p.join(dir, '.nomedia')); try { if (on) { if (!await f.exists()) await f.writeAsBytes(const []); } else if (await f.exists()) { await f.delete(); } } catch (err) { _snack(trf('바꾸지 못했습니다: {0}', [err])); return; } // 관리자 지시 10/20: 갤러리가 바로 알도록 .nomedia 와 그 폴더를 다시 읽게 (넣을 때 · 지울 때 모두, 실패해도 조용히) await c.services.shell.mediaScan([f.path, dir]); await _ctl.refreshAll([dir]); pane.changed(); _snack(on ? tr('갤러리 · 음악 앱에서 숨겼습니다 (.nomedia). 파일은 그대로입니다.') : tr('.nomedia 를 지웠습니다. 갤러리 · 음악 앱에 다시 보입니다.')); } @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, 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 => _clip.isNotEmpty, _ => true, }, selected: (b) => (b == ExplorerButton.hidden && s.explorerShowHidden) || (b == ExplorerButton.select && _sel.selecting), twoPanes: dual || split, pinned: {ExplorerButton.orient, if (_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 (_insideArchive(dest) || _ctl.arc.contains(dest)) { _snack(tr('압축 안은 읽기 전용입니다 (꺼내기만 할 수 있습니다)')); return; } if (load.paths.any(_insideArchive)) { await _extractFromArchive(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 _runTransfer(_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 _runTransfer(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': () { shiftDelete = true; _buttons.press(ExplorerButton.delete); }, 'rename': () => _buttons.press(ExplorerButton.rename), 'refresh': () => _buttons.press(ExplorerButton.refresh), // 81: 빠른 보기 'quickLook': () => _quickLook(_ctl.pane), 'clipCopy': () => _toClipboard(targetsOf(_ctl.pane), move: false), 'clipCut': () => _toClipboard(targetsOf(_ctl.pane), move: true), 'paste': () => paste(_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: 빠른 보기 - 지금 줄 (없으면 아무것도 안 함). 같은 폴더에 보이는 항목을 ← → 로 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(_open(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; } } /// 203: 바뀐 날을 모름 (잠긴 시스템 파일은 1970-01-01 로 온다) 은 "—" String _date(DateTime d) => fileDateText(d); /// 77: 길게 누르기 메뉴 위의 아이콘 줄 - (id, 아이콘, 짧은 이름, 풍선 도움말, 누를 수 있음). /// 폰에서도 무엇인지 알 수 있게 아이콘 아래에 짧은 이름 class _MenuIconRow extends PopupMenuEntry { final List<(String, IconData, String, String, bool)> items; const _MenuIconRow(this.items); @override double get height => 60; @override bool represents(String? value) => false; @override State<_MenuIconRow> createState() => _MenuIconRowState(); } class _MenuIconRowState extends State<_MenuIconRow> { @override Widget build(BuildContext context) => Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), child: Row(mainAxisSize: MainAxisSize.min, children: [ for (final (id, icon, label, tip, on) in widget.items) Tooltip( message: tip, child: InkWell( borderRadius: BorderRadius.circular(6), onTap: on ? () => Navigator.pop(context, id) : null, child: Semantics( button: true, enabled: on, label: tip, excludeSemantics: true, child: SizedBox( width: 56, height: 56, child: Opacity( opacity: on ? 1 : 0.38, child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(icon, size: 20), const SizedBox(height: 2), FittedBox( fit: BoxFit.scaleDown, child: Text(label, maxLines: 1, style: const TextStyle(fontSize: 11)), ), ]), ), ), ), ), ), ]), ); }