import 'dart:async'; import 'dart:collection'; import 'dart:convert'; import 'dart:io'; import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart' show defaultTargetPlatform; import 'package:flutter/gestures.dart' show kBackMouseButton, kDoubleTapTimeout, kForwardMouseButton, PointerDeviceKind, PointerScrollEvent; 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 CopyTask, 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 SftpHostKeyException, SftpRegistry; import '../core/ftp.dart' show FtpCertException, 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 'key_hint.dart'; import 'monitor_page.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/rsync_filter.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 'smb_settings.dart'; import 'webdav_settings.dart'; import 'reader_page.dart'; import '../app/component_store.dart'; import '../app/components.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 '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 ArchiveEntry, ArchiveKind, ArchivePasswordException, archiveKindOf, extract7zPlan, extractZipPlan, list7z, listZip, planExtract, unsafeArchivePath; import 'batch_rename_dialog.dart'; import '../core/app_trash.dart'; import '../core/folder_size.dart'; import '../core/batch_rename.dart'; /// 파일 탐색기 (X-plore 참고): 두 창 (트리 목록) + 가운데 기능 버튼 줄. /// /// - 폴더를 누르면 그 자리에서 펼치고 · 접는다 (트리). 마지막으로 누른 폴더가 그 창의 "지금 폴더". /// - 동영상을 누르면 기본 동작 (내장 플레이어, 또는 환경 설정 > 재생 > 확장자별 재생 프로그램), /// 길게 누르거나 오른쪽 클릭하면 메뉴 (내장 플레이어 · 다른 앱으로 열기 · MKV 목록에 추가 · 이름 바꾸기 …). /// - 오른쪽 동그라미로 여러 개를 표시해 복사 · 이동 · 삭제 · 재생한다. 복사 · 이동은 다른 창의 지금 폴더로. /// - 창 배치 (두 창 / 한 창, 좌우 / 위아래, 버튼 줄 위치) 와 버튼 구성은 바꿀 수 있다 (위쪽 ⋮ 메뉴). /// /// [rsync] 면 Rsync 화면: 같은 모양이지만 늘 좌우 두 창 · 폴더만 · 창마다 폴더 하나만 고른다. /// 가운데 → (왼쪽 → 오른쪽) · ← (오른쪽 → 왼쪽) · ⇄ (둘 다 함께) 로 rsync, [모니터링] 에 실행한 것이 남는다. /// 202 · 221: 창 위 줄을 [← → ★] · 탭 · [WebDAV · 경로 입력] 으로 펴는 가장 작은 창 폭. 이보다 좁으면 끝 버튼은 [⋮] 하나 - /// 폰 세로 (약 400) 에서 탭 칸이 130px 남짓이라 두 번째 저장소 (SD 카드) 가 잘려 보였다 const double paneHeaderWideWidth = 480; /// 191: Rsync 화면이 좌우 두 창 + 가운데 버튼 줄로 보이는 가장 작은 폭 (이보다 좁으면 창 하나씩) const double rsyncTwoPaneMinWidth = 560; /// 189: 트리 화살표 칸의 폭 - 손가락으로 누르는 기기 (Android · iOS) 는 넓게 (줄 높이와 함께 누르는 영역) double treeChevronWidth(TargetPlatform platform) => platform == TargetPlatform.android || platform == TargetPlatform.iOS ? 44 : 20; class ExplorerPage extends StatefulWidget { final AppController c; final bool rsync; const ExplorerPage({super.key, required this.c, this.rsync = false}); /// 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; /// 시험: 경로 입력 창이 넣은 경로를 고쳐 쓰고 확인하는 것 (갈 경로, 칸 아래 이유) @visibleForTesting static Future<(String?, String?)> checkPathForTest(String input) => _ExplorerPageState.checkEnteredPath(input); 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(); } /// 기능 버튼 (가운데 줄). 이름은 설정 (explorerButtons) 에 저장된다. enum ExplorerButton { up(Icons.arrow_upward, '상위 폴더'), refresh(Icons.refresh, '새로 고침'), select(Icons.checklist, '선택'), // ── Rsync 화면에만 ── toRight(Icons.arrow_forward, '좌 → 우'), toLeft(Icons.arrow_back, '좌 ← 우'), both(Icons.compare_arrows, '좌 ⇄ 우'), monitor(Icons.monitor_heart_outlined, '모니터링'), // ── play(Icons.play_circle_outline, '재생'), addMkv(Icons.playlist_add, 'MKV 목록에 추가'), newFolder(Icons.create_new_folder_outlined, '새 폴더'), rename(Icons.drive_file_rename_outline, '이름 변경'), copy(Icons.copy_outlined, '복사'), move(Icons.drive_file_move_outline, '이동'), paste(Icons.content_paste, '붙여넣기'), delete(Icons.delete_outline, '삭제'), search(Icons.search, '찾기'), sort(Icons.sort, '정렬 기준'), hidden(Icons.visibility_outlined, '숨은 항목 표시'), history(Icons.history, '내역'), // 73: 즐겨찾기 (지금 폴더 넣기 · 한 번에 가기, Rsync 는 짝) bookmarks(Icons.star_outline, '즐겨찾기'), // 118: 두 창을 좌우 ⇆ / 위아래 ⇅ 로 한 번에 바꾸기 (한 창이면 두 창 (좌우) 으로) orient(Icons.swap_horiz, '좌우 ⇆ / 위아래 ⇅'), layout(Icons.view_quilt_outlined, '창 배치'), buttons(Icons.tune, '버튼 구성'), // 87: 지금 폴더 (드라이브) 의 큰 폴더 · 큰 파일 storage(Icons.pie_chart_outline, '저장 공간 분석'), // 89: 지금 폴더를 같은 Wi-Fi 의 다른 기기 브라우저에서 보기 · 받기 (읽기 전용) wifiShare(Icons.wifi_tethering, 'Wi-Fi 공유'), // X-plore 비교 e: 휴지통 (Android 앱 휴지통 · Windows 는 시스템 휴지통 열기) trash(Icons.restore_from_trash_outlined, '휴지통'); final IconData icon; final String label; const ExplorerButton(this.icon, this.label); /// Rsync 화면에만 있는 버튼 (파일 탐색기 버튼 구성에서는 빼기) bool get rsyncOnly => this == toRight || this == toLeft || this == both || this == monitor; /// 파일 탐색기에서 고를 수 있는 버튼 static List get explorerValues => [for (final b in values) if (!b.rsyncOnly) b]; /// Rsync 화면의 버튼 (늘 이대로, 두 창 사이) // 208: 방향 실행 (Rsync 화면의 핵심) 을 앞에 - 좁아서 [더 보기] 로 가는 것은 뒤쪽 static const rsyncButtons = [toRight, toLeft, both, monitor, up, refresh, newFolder, bookmarks]; /// 147 (관리자): 처음 값 (버튼 구성을 바꾸지 않았을 때) 은 자주 쓰는 것부터 - 좁은 화면에서 [더 보기] 로 가는 것은 뒤쪽 static List get defaultOrder { const first = [copy, move, delete, newFolder, rename, select, search, bookmarks, orient]; return [...first, for (final b in explorerValues) if (!first.contains(b)) b]; } static List fromSettings(List names) { final out = [ for (final n in names) for (final b in explorerValues) if (b.name == n) b, ]; // 사용자가 정한 순서는 그대로 (비어 있을 때만 처음 값) return out.isEmpty ? defaultOrder : out; } } /// 한 창의 상태: 저장 장치 (맨 위), 펼친 폴더, 읽어 둔 목록, 표시한 항목 class _Pane extends ChangeNotifier { String root; /// 지금 폴더. 75: 바뀔 때마다 뒤로 가기 내역에 (뒤로 · 앞으로로 움직일 때는 빼고) String get current => _current; set current(String v) { if (!traveling && _current.isNotEmpty && v.isNotEmpty && !samePath(_current, v)) { back.add(_current); if (back.length > 50) back.removeAt(0); forward.clear(); } _current = v; } String _current; /// 75: 창마다 뒤로 · 앞으로 (Alt+← / →, 마우스 4 · 5번 버튼) final back = []; final forward = []; bool traveling = false; /// 이 창의 목록이 화면에 붙어 있는지 (새로 붙을 때 지금 폴더를 보이게 - [_ExplorerPageState._revealNewViews]) bool onScreen = false; String? focused; final expanded = {}; final cache = >{}; final loading = {}; final errors = {}; /// 88: 처음 보는 (또는 바뀐) SFTP 서버 키 - 오류 안내에 지문 · [지문 확인하고 연결] final hostKeyErrors = {}; /// l: 처음 보는 (바뀐) FTPS 자체 서명 인증서 - 폴더마다 final certErrors = {}; final marked = {}; /// 74: 범위 고르기의 시작 (마지막으로 표시한 것) String? anchor; /// X-plore 비교 c: 트리의 "즐겨찾기" 묶음을 펼쳤는지 bool favoritesOpen = false; /// 76: 빠른 필터 - 열려 있으면 이름에 [filter] 가 든 것만 보인다 (창마다, 닫으면 지움) bool filterOpen = false; final filterCtl = TextEditingController(); final filterFocus = FocusNode(); String get filter => filterOpen ? filterCtl.text.trim() : ''; /// 목록 스크롤 (폴더로 이동하면 그 폴더가 보이게) final scroll = ScrollController(); /// "폴더 + 파일 목록" 배치의 오른쪽 파일 목록 스크롤 final listScroll = ScrollController(); /// 202: 창 위 저장 장치 탭 줄 (넘치면 옆으로 - 휠 · 끌기) final tabScroll = ScrollController(); _Pane(this.root) : _current = root; /// 화면을 닫은 뒤 끝난 파일 작업 · 읽기가 알리지 않게 bool _disposed = false; void changed() { if (!_disposed) notifyListeners(); } @override void dispose() { _disposed = true; scroll.dispose(); listScroll.dispose(); tabScroll.dispose(); filterCtl.dispose(); filterFocus.dispose(); super.dispose(); } } /// 트리 · 목록의 한 줄 class _Row { final FileEntry entry; final int depth; final bool isRoot; /// 파일 목록 맨 위의 ".." (상위 폴더로) final bool isUp; /// X-plore 비교 c: 트리 맨 위 항목 - [otherRoot] 다른 저장 장치 · 서버, [favorites] 즐겨찾기 묶음, [favorite] 즐겨찾기 하나. /// X-plore 숨기기: [hiddenToggle] 목록 맨 아래의 [숨긴 항목 보이기] (숨긴 수 [count]) final int kind; final int count; static const otherRoot = 1, favorites = 2, favorite = 3, hiddenToggle = 4; const _Row(this.entry, this.depth, {this.isRoot = false, this.isUp = false, this.kind = 0, this.count = 0}); } class _ExplorerPageState extends State { AppController get c => widget.c; /// 이 기기의 저장 장치: (경로, 이름) List<(String, String)> _local = []; /// 165: 드라이브마다 따로 뒤에서 확인 (Windows). null = 확인 중, true = 열 수 있음, false = 연결 안 됨 final Map _driveOk = {}; final Set _networkDrives = {}; /// 꺼내는 장치 · CD (카드 리더 · USB): 열 수 없으면 "비어 있음" (연결 안 됨이 아니라 매체가 없는 것) final Set _removableDrives = {}; Future _probeDrive(String path) async { if (mounted) setState(() => _driveOk[path] = null); bool ok; try { ok = await ExplorerPage.probeDrive(path); } catch (_) { ok = false; } if (mounted) setState(() => _driveOk[path] = ok); } /// 연결 안 된 드라이브를 누르면 다시 연결 (다시 읽기) - 되면 연다 Future _reconnect(_Pane pane, String path) async { await _probeDrive(path); if (!mounted) return; if (_driveOk[path] == true) { pane.cache.remove(path); await _goTo(pane, path); } else { _snack(_removableDrives.contains(path) ? trf('{0} 이 비어 있습니다 (카드 · USB · 디스크를 넣은 뒤 다시 누르세요).', [path]) : trf('{0} 에 연결할 수 없습니다. 네트워크 · NAS 가 켜져 있는지 확인하세요.', [path])); } } /// 저장 장치 + WebDAV 서버 (환경 설정 > 파일 탐색기 > WebDAV): (경로, 이름) List<(String, String)> get _volumes => [ ..._local, for (final s in c.settings.webdavServers) ('$davScheme${s.id}/', s.label), // 88: SFTP 서버 for (final s in c.settings.sftpServers) ('$sftpScheme${s.id}/', s.label), // 88: SMB 서버 (Android) for (final s in c.settings.smbServers) ('$smbScheme${s.id}/', s.label), // l: FTP · FTPS 서버 (평문 FTP 는 이름 앞에 ⚠) for (final s in c.settings.ftpServers) ('$ftpScheme${s.id}/', s.tls ? s.label : '⚠ ${s.label}'), ]; late final List<_Pane> _panes = [_Pane(''), _Pane('')]; int _active = 0; bool _ready = false; /// 화면 단축키 (F5 · Delete …) 를 받는 곳. 76: 빠른 필터 칸을 닫으면 여기로 돌려준다 final _keys = FocusNode(debugLabel: 'explorer'); /// 80: 폴더 크기 (필요할 때 계산하고 이번 실행 동안 기억) late final _sizes = FolderSizes()..addListener(_onSizes); /// 80: 폴더 크기가 나오면 - 크기순이면 다시 줄 세우고 화면 갱신 void _onSizes() { if (!mounted) return; for (final pane in _panes) { for (final k in pane.cache.keys.toList()) { if (_sortOf(k).$1 == 'size') pane.cache[k] = _sorted(pane.cache[k]!, k); } } setState(() {}); } /// 80: 폴더 크기 글 (모르면 null). 설정이 자동이면 계산을 맡긴다 - /// 지금 보고 있는 폴더 바로 안의 폴더만 (트리에 보이는 위 폴더들 (C:\ · 사용자 폴더 …) 은 드라이브 전체를 읽게 되어 자동으로 하지 않는다) String? _dirSize(_Pane pane, FileEntry e) { final s = _sizes.of(e.path); if (s != null) return s.label; if (_sizes.busy(e.path)) return '…'; final mode = c.settings.explorerFolderSize; // 80 보충 (관리자 판단): WebDAV 는 자동으로 하지 않는다 (서버에서 안의 것을 모두 읽음 - 길게 누르기로만) if ((mode == 'always' || (mode == 'sort' && _sortOf(pane.current).$1 == 'size')) && samePath(vDirname(e.path), pane.current) && !isDav(e.path)) { final forDir = pane.current; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _sizes.request(e.path, forDir: forDir); }); return '…'; } return null; } @override void initState() { super.initState(); if (widget.rsync) { ExplorerPage._openRsync++; } else { ExplorerPage._open++; } for (final pane in _panes) { pane.addListener(_onPane); } _resumeReload = AppLifecycleListener(onResume: () => unawaited(_reloadOpen(localOnly: true))); _hiddenSeen = c.settings.explorerHiddenItems; c.addListener(_onSettings); _init(); } /// 232: 앱으로 돌아오면 (다른 앱 · 탐색기에서 파일을 바꾸고 오면) 보이는 폴더를 다시 읽는다 late final AppLifecycleListener _resumeReload; bool _reloading = false; /// 열려 있는 (보이는) 폴더를 두 창 모두 다시 읽는다 - [새로 고침] (183) 과 같다. /// [localOnly]: 돌아올 때는 WebDAV 폴더는 빼고 (네트워크가 아직 안 붙었을 때 오류가 뜨지 않게 - [새로 고침] 으로는 읽음) Future _reloadOpen({bool localOnly = false}) async { if (_reloading) return; _reloading = true; try { // 180: 목록을 먼저 비우지 않고 새로 읽은 것으로 바꾼다 (비우면 목록이 사라졌다 생겨 스크롤 · 자리가 바뀐다) for (final x in _panes) { final open = {...x.expanded, x.current}; for (final d in open) { if (!mounted) return; if (localOnly && isDav(d)) continue; await _load(x, d, force: true); } x.cache.removeWhere((k, _) => !open.contains(k)); x.changed(); } } finally { _reloading = false; } } @override void dispose() { c.removeListener(_onSettings); _keys.dispose(); _sizes.dispose(); if (widget.rsync) { ExplorerPage._openRsync--; } else { ExplorerPage._open--; ExplorerPage.goToRequest.removeListener(_onGoToRequest); } for (final pane in _panes) { pane.dispose(); } if (_allFiles != null) _life.dispose(); _resumeReload.dispose(); super.dispose(); } void _onPane() { if (mounted) setState(() {}); } Future _init() async { _local = await _loadVolumes(); if (_local.isEmpty) _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)]); final wants = [ for (var i = 0; i < 2; i++) reach[i] ? saved[i] : _local[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++) { _panes[i] ..root = _volumeOf(wants[i]) ..current = wants[i] // 48 보충 (관리자): 처음 열면 아무것도 고르지 않은 상태 - 열자마자 폴더 통째가 복사 · 이동 대상이 되지 않게 ..focused = null; } if (!mounted) return; setState(() => _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) { for (final (path, _) in _local) { final type = driveType(path); if (type == 4) _networkDrives.add(path); if (type == 2 || type == 5) _removableDrives.add(path); unawaited(_probeDrive(path)); } } for (var i = 0; i < 2; i++) { final pane = _panes[i]; unawaited(_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 _panes) { pane.cache.clear(); for (final d in pane.expanded.toList()) { await _load(pane, d, force: true); } } } } /// 권한이 없을 때 목록 위 안내 (이유 · 할 일 · 허용 화면 열기 · 다시 확인) Widget? _accessNotice(_Pane 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('허용했으면 다시 확인'))), ], ); } /// 목록 위 안내들 (없으면 null). 좁은 화면 (접은 폴드 · 두 창) 에서 목록이 밀려 사라지지 않게 높이를 제한한다 Widget? _noticeArea(List notices, double paneHeight) { final list = notices.whereType().toList(); if (list.isEmpty) return null; return ConstrainedBox( constraints: BoxConstraints(maxHeight: paneHeight.isFinite ? paneHeight * 0.35 : 240), child: SingleChildScrollView(child: Column(mainAxisSize: MainAxisSize.min, children: list)), ); } /// 목록 위 안내 상자 Widget _notice( {required IconData icon, required String title, required String body, required List actions, Color color = Colors.redAccent}) => 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: const TextStyle(fontSize: 12, color: JjColors.textDim)), Wrap(spacing: 8, children: actions), ]), ); // ───────── 만들다 만 파일 (71) ───────── /// 복사 · 동기화가 중간에 끊겨 남은 임시 파일 (.jjpart · .jjsync). 지금 쓰는 중인 것 (2분 안에 바뀜) 은 빼고 List _partials(_Pane 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(_Pane pane, String dir) { final list = _partials(pane, dir); if (list.isEmpty) return null; return _notice( icon: Icons.broken_image_outlined, color: Colors.orangeAccent, 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 _panes) { if (q == pane || q.cache.containsKey(dir)) await _load(q, dir, force: true); } if (failed.isNotEmpty) _snack(trf('지우지 못한 파일: {0}', [failed.join(', ')])); }, child: Text(tr('지우기')), ), ], ); } // ───────── 읽을 수 없을 때 (12) ───────── /// 오류 → (무엇이 문제인지, 무엇을 하면 되는지) Widget? _errorNotice(_Pane 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: '${_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 _load(pane, where, force: true); }, child: Text(tr('지문 확인하고 연결')), ), TextButton(onPressed: () => _editFtp(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: '${_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 _load(pane, where, force: true); }, child: Text(tr('지문 확인하고 연결')), ), TextButton(onPressed: () => _editSftp(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: '${_displayPath(where)}: $title', body: body, actions: [ FilledButton.tonal( onPressed: () { pane.errors.remove(where); _goTo(pane, vDirname(where)); }, child: Text(tr('위 폴더로')), ), ], ); } return _notice( icon: dav ? Icons.cloud_off_outlined : Icons.error_outline, title: '${_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 _load(pane, where, force: true); for (final d in pane.expanded.toList()) { if (isSameOrInside(d, where) && d != where) await _load(pane, d, force: true); } }, child: Text(locked ? tr('마스터 비밀번호 넣기') : tr('다시 시도')), ), if (dav && !locked) TextButton( onPressed: () => sftp != null ? _editSftp(pane, pane.root) : smb != null ? _editSmb(pane, pane.root) : ftp != null ? _editFtp(pane, pane.root) : _editDav(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 _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 _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 _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 _displayPath(path); } } /// 화면에 보일 경로 (5 · 14): Android 는 "SD 카드 › JJ_sdtest › jj_mkv", WebDAV 는 "☁ 서버 › 폴더", Windows 는 그대로 String _displayPath(String path) { if (isDav(path)) { final d = DavPath.parse(path); return ['${d.mark} ${d.serverLabel}', ...d.rel.split('/').where((s) => s.isNotEmpty)].join(' › '); } if (!Platform.isAndroid) return path; String? best; var label = ''; for (final (v, l) in _local) { if ((samePath(v, path) || p.isWithin(v, path)) && (best == null || v.length > best.length)) { best = v; label = l; } } if (best == null) return path; final rest = p.relative(path, from: best); return [label, if (rest != '.') ...p.split(rest)].join(' › '); } Future> _loadVolumes() async { if (ExplorerPage.debugVolumes case final f?) return f(); if (Platform.isWindows) return windowsDrives(); if (Platform.isAndroid) { final v = await AndroidAccess.volumes(); return [for (final x in v) (x.$1, x.$2.isEmpty ? p.basename(x.$1) : x.$2)]; } return [('/', '/')]; } /// 설정에서 지운 WebDAV 서버를 보던 창은 이 기기의 첫 저장 장치로 void _dropRemovedServers() { if (!_ready) return; for (final pane in _panes) { if (isDav(pane.root) && !DavPath.parse(pane.root).serverKnown) { pane.marked.clear(); pane.cache.removeWhere((k, _) => isDav(k)); pane.expanded.removeWhere(isDav); pane.root = _local.first.$1; // 다시 그릴 때 또 하지 않게 WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _goTo(pane, _local.first.$1); }); } } } /// 다시 열 수 있는 폴더: 로컬은 있으면, WebDAV 는 그 서버가 아직 설정에 있으면 (열 때 확인) bool _reachable(String path) => isDav(path) ? DavPath.parse(path).serverKnown : Directory(path).existsSync(); /// [_reachable] 을 화면을 멈추지 않고 (2초 넘게 걸리면 있다고 보고 열어 본다) Future _reachableAsync(String path) async { if (isDav(path)) return _reachable(path); try { return await Directory(path).exists().timeout(const Duration(seconds: 2), onTimeout: () => true); } catch (_) { return false; } } String _volumeOf(String path) { if (isDav(path)) { final d = DavPath.parse(path); return '${d.scheme}${d.server}/'; } // 37: Windows 네트워크 공유 (\\NAS\공유) 는 그 공유가 맨 위 if (Platform.isWindows && path.startsWith(r'\\')) return uncRoot(path); String best = _local.first.$1; var len = -1; for (final (v, _) in _local) { final inside = samePath(v, path) || p.isWithin(v, path) || (Platform.isWindows && p.isWithin(v.toLowerCase(), path.toLowerCase())); if (inside && v.length > len) { best = v; len = v.length; } } return best; } // ───────── 목록 읽기 · 이동 ───────── Future _load(_Pane pane, String dir, {bool force = false}) async { if (!force && pane.cache.containsKey(dir)) return; // X-plore 비교 f: 압축 안 (읽어 둔 목록에서). 새로 고침이면 압축 파일이 바뀌었는지 보고 바뀌었으면 다시 읽는다 final arc = _archiveOf(dir); if (arc != null) { if (force && !await _readArchive(arc)) { pane.cache[dir] = const []; pane.changed(); return; } if (_archives.containsKey(arc)) { pane.cache[dir] = _sorted(_archiveList(dir), dir); pane.changed(); return; } } // 80: 다시 읽는 폴더는 안이 바뀌었을 수 있다 - 그 폴더와 위 폴더들의 크기를 잊는다 if (force) _sizes.forget(dir); pane.loading.add(dir); pane.changed(); try { final list = await listEntries(dir, showHidden: c.settings.explorerShowHidden); pane.cache[dir] = _sorted(list, dir); // 50: Android 가 막은 폴더는 비어 보인다 (오류 없이) - 비어 있는 것이 아니라 막힌 것이라고 알린다 if (list.isEmpty && isAndroidRestricted(dir)) { pane.errors[dir] = androidRestrictedError; } else { pane.errors.remove(dir); } pane.hostKeyErrors.remove(dir); pane.certErrors.remove(dir); } catch (e) { pane.cache[dir] = const []; pane.errors[dir] = isAndroidRestricted(dir) ? androidRestrictedError : '$e'; if (e is SftpHostKeyException) { pane.hostKeyErrors[dir] = e; } else { pane.hostKeyErrors.remove(dir); } if (e is FtpCertException) { pane.certErrors[dir] = e; } else { pane.certErrors.remove(dir); } // 37-2: 보던 폴더가 밖에서 지워졌으면 남아 있는 가장 가까운 상위 폴더로 (제목 · 경로 칸도 그 폴더로) if (!isDav(dir) && samePath(dir, pane.current) && !samePath(dir, pane.root) && !Directory(dir).existsSync()) { var up = vDirname(dir); while (!samePath(up, pane.root) && !Directory(up).existsSync() && vDirname(up) != up) { up = vDirname(up); } pane.errors.remove(dir); pane.cache.remove(dir); pane.expanded.remove(dir); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; _snack(tr('보던 폴더가 없어져 상위 폴더로 옮겼습니다')); unawaited(_goTo(pane, up, fresh: true)); }); } } finally { pane.loading.remove(dir); pane.changed(); } } /// [dir] 의 목록을 정렬. 84: "폴더마다 기억" 이 켜져 있으면 그 폴더의 정렬 (없으면 전체 정렬) List _sorted(List list, String dir) { final (sortBy, desc) = _sortOf(dir); final by = SortBy.values.firstWhere((s) => s.name == sortBy, orElse: () => SortBy.name); return sortEntries(list, by, descending: desc, dirSize: (d) => _sizes.of(d.path)?.bytes); } static String _sortKey(String dir) => isDav(dir) ? vNorm(dir) : (Platform.isWindows ? dir.toLowerCase() : dir); /// 84: [dir] 의 정렬 (기준, 거꾸로) (String, bool) _sortOf(String dir) { final s = c.settings; if (s.explorerSortPerFolder) { final v = s.explorerFolderSorts[_sortKey(dir)]; if (v != null && v.contains('|')) return (v.split('|').first, v.endsWith('|1')); } return (s.explorerSort, s.explorerSortDesc); } /// 84: 정렬을 바꾼다 - "폴더마다 기억" 이면 [dir] 에만, 아니면 전체. 모든 창의 목록을 다시 줄 세운다 Future _setSort(String dir, String by, bool desc) async { await c.updateSettings((x) { if (x.explorerSortPerFolder) { final m = Map.of(x.explorerFolderSorts)..remove(_sortKey(dir)); m[_sortKey(dir)] = '$by|${desc ? 1 : 0}'; while (m.length > 300) { m.remove(m.keys.first); } x.explorerFolderSorts = m; } else { x ..explorerSort = by ..explorerSortDesc = desc; } }); for (final pane in _panes) { for (final k in pane.cache.keys.toList()) { pane.cache[k] = _sorted(pane.cache[k]!, k); } pane.changed(); } } /// 84: 열 머리를 누름 - 같은 열이면 거꾸로, 다른 열이면 그 열로 (오름차순) Future _sortByColumn(_Pane pane, String by) async { setState(() => _active = _panes.indexOf(pane)); final (now, desc) = _sortOf(pane.current); await _setSort(pane.current, by, now == by ? !desc : false); } /// 그 폴더로: 저장 장치부터 그 폴더까지 펼치고 지금 폴더로 정한다 /// [fresh]: 경로 입력 · 찾기 · 내역으로 갈 때 - 그 폴더를 새로 읽는다 (앱 밖에서 바뀐 것까지 보이게, 122) /// 폴더로 간다. 그 폴더를 "고른 항목" 으로 잡지 않는다 (48 보충 · 관리자: 누르거나 표시하지 않은 폴더가 복사 · 이동 대상이 되지 않게). /// 위치는 지금 폴더 표시 · 스크롤로 보인다. [focus] 는 그 폴더를 일부러 고를 때만 Future _goTo(_Pane pane, String dir, {bool remember = true, bool fresh = false, bool focus = false}) async { // 124 · 128: 사용자가 WebDAV 로 간 것이면 마스터 창을 한 번 취소했어도 다시 묻는다 (켤 때 되살리는 것은 [remember] 없음) if (remember && isDav(dir)) await SecretGate.pass(force: true); if (!mounted) return; pane.root = _volumeOf(dir); final chain = []; dir = isDav(dir) ? vNorm(dir) : p.normalize(dir); var d = dir; while (true) { chain.insert(0, d); if (samePath(d, pane.root) || vDirname(d) == d) break; d = vDirname(d); } for (final (k, x) in chain.indexed) { pane.expanded.add(x); await _load(pane, x, force: fresh && k == chain.length - 1); // 122: 읽어 둔 목록에 다음 폴더가 없으면 (앱 밖에서 새로 만듦) 새로 읽어야 그 폴더까지 펼쳐진다 if (k < chain.length - 1 && !(pane.cache[x] ?? const []).any((e) => samePath(e.path, chain[k + 1]))) { await _load(pane, x, force: true); } } pane.current = dir; if (focus) pane.focused = dir; pane.changed(); if (pane.listScroll.hasClients) pane.listScroll.jumpTo(0); _reveal(pane, dir); if (remember) _remember(pane, dir); } /// 그 항목이 목록 위쪽 1/4 쯤에 보이게 스크롤 (깊은 폴더로 가면 위쪽 폴더들의 다른 항목에 밀려 안 보이므로) void _reveal(_Pane pane, String path) => WidgetsBinding.instance.addPostFrameCallback((_) => _revealNow(pane, path)); void _revealNow(_Pane pane, String path) { if (!mounted || !pane.scroll.hasClients) return; // 화면의 트리와 같은 줄로 센다 (폴더 + 파일 목록 배치 · Rsync 화면은 폴더만) final i = _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 * _look.rowHeightOf(context) - pos.viewportDimension / 4).clamp(0.0, double.infinity)); } /// 28 확인 (10/20): 창의 목록이 화면에 새로 붙으면 (좁은 Rsync 화면의 [오른쪽] 탭을 처음 볼 때 · 폭이 바뀌어 두 창이 될 때) /// 그 창의 지금 폴더를 보이게 한다. 켤 때 그려지지 않은 창은 [_goTo] 의 스크롤을 건너뛰어, 나중에 보면 맨 위 (저장 장치) 에 있었다 void _revealNewViews() => WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; for (final pane in _panes) { final on = pane.scroll.hasClients; if (on && !pane.onScreen && pane.current.isNotEmpty) _revealNow(pane, pane.current); pane.onScreen = on; } }); /// 75: 창마다 뒤로 · 앞으로 (가 본 폴더로) Future _travel(_Pane pane, {required bool forward}) async { // 앞의 이동이 끝나기 전에 또 불리면 (빨리 두 번 · 같은 누름이 두 번 들어옴) 무시 - 내역에서 두 칸이 빠지지 않게 if (pane.traveling) return; final from = forward ? pane.forward : pane.back; if (from.isEmpty) return; pane.traveling = true; try { final to = from.removeLast(); setState(() => _active = _panes.indexOf(pane)); // 지워졌거나 열 수 없는 폴더: 그 항목은 버리고 지금 폴더는 그대로 (이유를 알림) bool ok; try { ok = await vIsDir(to); } catch (_) { ok = false; } if (!mounted) return; if (!ok) { // 트리에도 남지 않게 (밖에서 지운 폴더) - 그 상위 폴더를 두 창 모두 다시 읽는다 for (final x in _panes) { x.expanded.removeWhere((d) => samePath(d, to) || d.toLowerCase().startsWith('${to.toLowerCase()}${isDav(to) ? '/' : p.separator}')); } await _refreshAll([vDirname(to)]); if (!mounted) return; setState(() {}); _snack(trf('{0} 폴더가 없거나 열 수 없어 내역에서 뺐습니다', [_displayPath(to)])); return; } (forward ? pane.back : pane.forward).add(pane.current); await _goTo(pane, to); } finally { pane.traveling = false; } if (mounted) setState(() {}); } /// 마지막 폴더 · 내역 저장 void _remember(_Pane pane, String dir) { final i = _panes.indexOf(pane); final paths = [..._panes.map((x) => x.current)]; if (widget.rsync) { unawaited(c.updateSettings((s) => s.rsyncPaths = paths)); } else { final hist = [dir, ...c.settings.explorerHistory.where((h) => !samePath(h, dir))].take(30).toList(); unawaited(c.updateSettings((s) => s ..explorerPaths = paths ..explorerHistory = hist)); } if (i >= 0) _active = i; } /// 지금 모양 (환경 설정 > 파일 탐색기 > 스타일) ExplorerStyle get _look => ExplorerStyle.of(c.settings.explorerStyle); /// 왼쪽 폴더 트리 + 오른쪽 파일 목록 배치 bool get _split => !widget.rsync && c.settings.explorerLayout == 'split'; /// 좌우 두 창 (Rsync 화면은 늘) bool get _dual => widget.rsync || c.settings.explorerLayout == 'dual' || // 자동: 넓으면 두 창, 좁은 화면 (폰 세로) 은 한 창 - 같은 목록이 반씩 잘려 두 번 보이지 않게 (c.settings.explorerLayout == 'auto' && !isCompact(context)); /// 한 번 누르면 선택 · 두 번 누르면 열기 (아니면 한 번에 바로 열기) bool get _selectMode => c.settings.explorerClick != 'open'; /// 트리 줄. [foldersOnly]: 폴더만 ("폴더 + 파일 목록" 배치의 왼쪽) /// X-plore 비교 c: 트리 맨 위에 저장 장치 · 서버 · 즐겨찾기 (X-plore 스타일 · 설정) /// X-plore 비교 ⚖: X-plore 스타일에서 표시 동그라미 늘 보이기 (설정) bool get _alwaysMarks => !widget.rsync && _look == ExplorerStyle.xplore && c.settings.explorerAlwaysMarks; /// X-plore 비교 ⚖: X-plore 스타일 탐색기 안에서 밀기 = 창 바꾸기 (설정) bool get _swipePanes => !widget.rsync && _look == ExplorerStyle.xplore && c.settings.explorerSwipePanes; /// 밀어서 창 바꾸기: 두 창이면 다른 창을 활성으로, 한 창이면 숨은 창과 바꿔 보인다 bool _switchPane(int dir) { if (!_swipePanes) return false; setState(() { if (_dual || _split) { _active = 1 - _active; } else { final t = _panes[0]; _panes[0] = _panes[1]; _panes[1] = t; _active = 0; } }); _remember(_pane, _pane.current); _snack(trf('창 바꿈: {0}', [_displayPath(_pane.current)])); return true; } bool get _rootsInTree => !widget.rsync && _look == ExplorerStyle.xplore && c.settings.explorerRootsInTree; List<_Row> _rows(_Pane pane, {bool foldersOnly = false}) { final out = <_Row>[]; final rootEntry = FileEntry(pane.root, isDir: true, modified: DateTime(0)); // X-plore 비교 c: 지금 저장 장치 앞뒤로 다른 저장 장치 · 서버 (접힌 줄), 맨 끝에 즐겨찾기 final roots = _rootsInTree ? _volumes : const <(String, String)>[]; final here = roots.indexWhere((v) => samePath(v.$1, pane.root)); for (final (path, _) in roots.take(here < 0 ? 0 : here)) { out.add(_Row(FileEntry(path, isDir: true, modified: DateTime(0)), 0, kind: _Row.otherRoot)); } out.add(_Row(rootEntry, 0, isRoot: true)); // 76: 빠른 필터 - 지금 폴더 안만 거른다 (트리의 다른 곳은 그대로). // 이름이 맞지 않아도 안에 맞는 것이 보이는 폴더는 남긴다 (어디에 있는지 보이게) var hidden = 0; bool walk(String dir, int depth) { if (!pane.expanded.contains(dir)) return false; var kept = false; for (final e in pane.cache[dir] ?? const []) { if (foldersOnly && !e.isDir) continue; // X-plore 숨기기: 목록에서 숨긴 것 (그 안도) 은 [숨긴 항목 보이기] 를 켰을 때만 if (_appHidden(e.path)) { hidden++; if (!_showAppHidden) continue; } final at = out.length; out.add(_Row(e, depth)); // X-plore 비교 f: 펼친 압축 (ZIP · 7z · RAR) 도 안을 폴더처럼 final inner = (e.isDir || _archives.containsKey(e.path)) && walk(e.path, depth + 1); if (!inner && !_passes(pane, e)) { out.length = at; } else { kept = true; } } return kept; } walk(pane.root, 1); if (roots.isNotEmpty) { for (final (path, _) in here < 0 ? roots : roots.skip(here + 1)) { out.add(_Row(FileEntry(path, isDir: true, modified: DateTime(0)), 0, kind: _Row.otherRoot)); } final favs = c.settings.folderBookmarks; if (favs.isNotEmpty) { out.add(_Row(FileEntry('\u0000favorites', isDir: true, modified: DateTime(0)), 0, kind: _Row.favorites)); if (pane.favoritesOpen) { for (final b in favs) { out.add(_Row(FileEntry(b.path, isDir: true, modified: DateTime(0)), 1, kind: _Row.favorite)); } } } } // X-plore 숨기기: 펼친 폴더 안에 숨긴 것이 있으면 목록 맨 아래에 [숨긴 항목 n개 보이기] (X-plore 자리) if (hidden > 0) out.add(_Row(FileEntry('\u0000hidden', isDir: false, modified: DateTime(0)), 0, kind: _Row.hiddenToggle, count: hidden)); return out; } /// X-plore 숨기기: [path] 가 목록에서 숨긴 것인지 (앱 설정의 목록 - 파일은 그대로) bool _appHidden(String path) { final list = c.settings.explorerHiddenItems; if (list.isEmpty) return false; if (!identical(list, _hiddenFrom)) { _hiddenFrom = list; _hiddenKeys = {for (final x in list) _hideKey(x)}; } return _hiddenKeys.contains(_hideKey(path)); } List? _hiddenFrom; Set _hiddenKeys = const {}; /// 숨긴 목록에서 같은 경로를 찾는 열쇠 (Windows 는 대소문자 무시) static String _hideKey(String x) { if (isDav(x) || x.contains('://')) return x; final n = p.normalize(x); return Platform.isWindows ? n.toLowerCase() : n; } /// X-plore 숨기기: 옮기거나 이름을 바꾼 것 ([moved] 원래 → 새 경로, 그 안의 것도) 은 숨긴 목록도 새 경로로, /// 지운 것 ([gone], 그 안의 것도 - 실제로 없어진 것만) 은 목록에서 뺀다 (관리자 결정 10/18) Future _followHidden({Iterable<(String, String)> moved = const [], Iterable gone = const []}) async { final list = c.settings.explorerHiddenItems; if (list.isEmpty || (moved.isEmpty && gone.isEmpty)) return; final out = []; var changed = false; for (final h in list) { if (gone.any((g) => isSameOrInside(h, g)) && !await vExists(h)) { changed = true; continue; } var now = h; for (final (from, to) in moved) { if (samePath(h, from)) { now = to; break; } if (isSameOrInside(h, from)) { now = vJoin(to, isDav(h) ? h.substring(from.length).replaceAll('\\', '/').replaceFirst(RegExp('^/+'), '') : p.relative(h, from: from)); break; } } if (now != h) changed = true; if (!out.any((x) => _hideKey(x) == _hideKey(now))) out.add(now); } if (changed) await c.updateSettings((s) => s.explorerHiddenItems = out); } /// X-plore 숨기기: 목록 맨 아래의 [숨긴 항목 n개 보이기] · [다시 숨기기] 를 누르면 (이번 실행 동안, 모든 창에) bool _showAppHidden = false; void _toggleAppHidden() { setState(() => _showAppHidden = !_showAppHidden); for (final q in _panes) { q.changed(); } } /// 설정 화면에서 숨긴 목록을 바꾸면 (풀기) 바로 목록에 보이게 List? _hiddenSeen; void _onSettings() { final now = c.settings.explorerHiddenItems; if (identical(now, _hiddenSeen) || !mounted) return; _hiddenSeen = now; setState(() {}); for (final q in _panes) { q.changed(); } } Widget _hiddenToggleRow(_Row row) { final look = _look; final label = _showAppHidden ? trf('숨긴 항목 {0}개 다시 숨기기', [row.count]) : trf('숨긴 항목 {0}개 보이기', [row.count]); return Semantics( button: true, label: label, excludeSemantics: true, onTap: _toggleAppHidden, child: InkWell( onTap: _toggleAppHidden, child: Row(children: [ SizedBox(width: treeChevronWidth(defaultTargetPlatform) + 4), Icon(_showAppHidden ? Icons.visibility_off_outlined : Icons.visibility_outlined, size: look.iconSize * 0.8, color: JjColors.textDim), const SizedBox(width: 8), Expanded( child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: look.fontSize - 1, color: JjColors.textDim)), ), ]), ), ); } /// X-plore 비교 c: 트리 맨 위 항목 줄 (다른 저장 장치 · 서버 · 즐겨찾기) - 누르면 그곳으로, 즐겨찾기 묶음은 펼치기 · 접기 Widget _extraRow(_Pane pane, _Row row) { if (row.kind == _Row.hiddenToggle) return _hiddenToggleRow(row); final look = _look; final path = row.entry.path; final volume = _volumes.firstWhere((v) => samePath(v.$1, path), orElse: () => (path, vBasename(path))).$2; final fav = row.kind == _Row.favorite ? c.settings.folderBookmarks.firstWhere((b) => b.path == path, orElse: () => FolderBookmark(name: vBasename(path), path: path)) : null; final (IconData icon, Color color, String label, bool? open) = switch (row.kind) { _Row.otherRoot => ( isDav(path) ? Icons.cloud_outlined : Platform.isWindows ? Icons.storage : Icons.sd_storage_outlined, JjColors.accent, volume, false, ), _Row.favorites => (Icons.star, Colors.amber, tr('즐겨찾기'), pane.favoritesOpen), _ => (Icons.star_outline, Colors.amber, fav!.name, null), }; void tap() { setState(() => _active = _panes.indexOf(pane)); if (row.kind == _Row.favorites) { pane.favoritesOpen = !pane.favoritesOpen; pane.changed(); return; } unawaited(_goTo(pane, path, fresh: true).then((_) => _reveal(pane, path))); } return Semantics( button: true, label: label, excludeSemantics: true, onTap: tap, child: InkWell( onTap: tap, child: Row(children: [ SizedBox(width: look.indent * row.depth + 4), SizedBox( width: treeChevronWidth(defaultTargetPlatform), child: open == null ? null : Icon(open ? Icons.expand_more : Icons.chevron_right, size: 18, color: JjColors.textDim), ), Icon(icon, size: look.iconSize * 0.8, color: color), const SizedBox(width: 8), Expanded( child: Column(mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: look.fontSize)), if (fav != null) Text(_displayPath(path), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: look.fontSize - 3, color: JjColors.textDim)), ]), ), ]), ), ); } // ───────── 누르기 ───────── /// 선택 모드 (X-plore 처럼): 각 줄의 ✔ 동그라미가 보이고, 누르면 고르기 · 풀기 bool _selecting = false; void _setSelecting(bool on) => setState(() { _selecting = on; if (!on) { for (final x in _panes) { x.marked.clear(); } } }); /// 한 번 누르기. /// - 선택 모드: 고르기 · 풀기 ([_toggleMark]) /// - 폴더: 바로 펼치기 · 접기 (트리), 들어가기 (오른쪽 파일 목록) /// - 파일: 누르기 설정이 "선택" 이면 고르기만 (두 번 누르면 실행), "바로 실행" 이면 실행 /// Ctrl 을 누른 채면 선택 모드로 고르기. 목록의 ".." 은 늘 상위 폴더로. /// X-plore 비교 d: 마지막으로 누른 폴더 · 시각 (두 번 누르기의 두 번째를 가리려고) (String, DateTime)? _lastFolderTap; Future _onTap(_Pane pane, _Row row, {bool list = false}) async { final e = row.entry; setState(() => _active = _panes.indexOf(pane)); if (row.isUp) { await _goTo(pane, e.path); return; } if (widget.rsync) { await _pick(pane, e); return; } // 74: Shift + 누르기 = 마지막으로 표시한 것부터 여기까지 (PC) if (!row.isRoot && HardwareKeyboard.instance.isShiftPressed && _canRange(pane, e)) { if (!_selecting) setState(() => _selecting = true); _markRange(pane, e); return; } if (!row.isRoot && (_selecting || HardwareKeyboard.instance.isControlPressed)) { if (!_selecting) setState(() => _selecting = true); await _toggleMark(pane, e); return; } // X-plore 비교 d: 펼쳐 둔 지금 폴더를 다시 누르면 접기 (설정). 두 번 누르기의 두 번째 (짧은 사이) 는 접지 않는다 if (e.isDir && !list && c.settings.explorerRetapCollapse) { final now = DateTime.now(); final last = _lastFolderTap; _lastFolderTap = (e.path, now); final again = last != null && samePath(last.$1, e.path) && now.difference(last.$2) < kDoubleTapTimeout * 2; if (!again && pane.expanded.contains(e.path) && samePath(pane.current, e.path)) { await _toggle(pane, e); return; } } if (e.isDir || !_selectMode) return _onOpen(pane, row, list: list); pane.focused = e.path; if (!list) pane.current = vDirname(e.path); pane.changed(); } /// 표시되었는지: 자기 자신, 또는 위 폴더가 표시되었으면 (폴더를 고르면 안의 것도 모두 고른 것). /// Rsync 화면은 고른 그 폴더만. bool _isMarked(_Pane pane, String path) => pane.marked.any((m) => widget.rsync ? samePath(path, m) : isSameOrInside(path, m)); /// Rsync 화면: 창마다 폴더 하나만 고른다 (다시 누르면 취소). 고르면 펼쳐서 안의 폴더가 보이게. Future _pick(_Pane pane, FileEntry e) async { if (!e.isDir) return; if (pane.marked.length == 1 && samePath(pane.marked.first, e.path)) { setState(pane.marked.clear); return; } setState(() => pane.marked ..clear() ..add(e.path)); if (!pane.expanded.contains(e.path)) { await _toggle(pane, e); } else { pane ..current = e.path ..focused = e.path ..changed(); } } /// 74: 범위 고르기를 할 수 있는지 - 마지막으로 표시한 것이 같은 폴더에 있고 다른 항목 bool _canRange(_Pane pane, FileEntry e) { final a = pane.anchor; return a != null && !samePath(a, e.path) && samePath(vDirname(a), vDirname(e.path)) && _isMarked(pane, a); } /// 76: 빠른 필터를 지나는지 - 지금 폴더 안의 것만 거른다 bool _passes(_Pane pane, FileEntry e) => pane.filter.isEmpty || !isSameOrInside(e.path, pane.current) || samePath(e.path, pane.current) || quickFilterMatch(vBasename(e.path), pane.filter); /// 76: [dir] 에서 지금 보이는 것 (빠른 필터에 맞는 것) /// X-plore 숨기기: 목록에서 숨긴 것은 [숨긴 항목 보이기] 를 켰을 때만 List _shown(_Pane pane, String dir) => [ for (final x in pane.cache[dir] ?? const []) if (_passes(pane, x) && (_showAppHidden || !_appHidden(x.path))) x ]; /// 74: 마지막으로 표시한 것부터 [to] 까지 (보이는 순서대로) 모두 표시 void _markRange(_Pane pane, FileEntry to) { final list = _shown(pane, vDirname(to.path)); final a = list.indexWhere((x) => samePath(x.path, pane.anchor!)); final b = list.indexWhere((x) => samePath(x.path, to.path)); if (a < 0 || b < 0) return; setState(() { for (var i = a < b ? a : b; i <= (a < b ? b : a); i++) { pane.marked.add(list[i].path); } _normalizeMarks(pane); pane.anchor = to.path; pane.focused = to.path; }); } /// 74: 고르기 도구 - 이 폴더 ([dir]) 의 항목에서 void _invertMarks(_Pane pane, String dir) => setState(() { for (final x in _shown(pane, dir)) { if (!pane.marked.remove(x.path)) pane.marked.add(x.path); } _normalizeMarks(pane); }); void _markWhere(_Pane pane, String dir, bool Function(FileEntry e) test) => setState(() { pane.marked.addAll([for (final x in _shown(pane, dir)) if (test(x)) x.path]); _normalizeMarks(pane); }); Future _markPattern(_Pane pane, String dir) async { final pattern = await showDialog(context: context, builder: (_) => const _PatternDialog()); if (pattern == null || pattern.trim().isEmpty) return; final re = globToRegExp(pattern); final before = pane.marked.length; _markWhere(pane, dir, (x) => re.hasMatch(vBasename(x.path))); if (pane.marked.length == before) _snack(trf('"{0}" 에 맞는 항목이 없습니다', [pattern.trim()])); } /// 다른 표시 안에 든 표시는 뺀다 (폴더 하나로 안의 것까지 고른 것이므로) void _normalizeMarks(_Pane pane) { final all = pane.marked.toList(); pane.marked.removeWhere((m) => all.any((o) => !identical(o, m) && !samePath(o, m) && isSameOrInside(m, o))); } /// 선택 모드에서 누르기. 폴더는 자기 자신과 안의 파일 · 폴더 전체가 한 묶음이다. /// - 고르지 않은 것을 누르면 고른다 (폴더는 펼쳐서 안의 것도 모두 고른 모양으로 보여 줌) /// - 고른 것을 다시 누르면 자기 자신과 안의 것을 모두 푼다. /// 위 폴더를 골라서 함께 골라진 것이면 그것만 빼고, 같은 폴더의 나머지는 고른 채로 둔다. Future _toggleMark(_Pane pane, FileEntry e) async { pane.focused = e.path; // X-plore 비교 ⚖ (설정 'xplore'): 폴더 1번 = 폴더 자신 · 2번 = 안의 파일 전체 · 3번 = 풀기 if (e.isDir && c.settings.explorerFolderMarkRule == 'xplore' && !widget.rsync) { final self = pane.marked.any((m) => samePath(m, e.path)); if (self) { await _load(pane, e.path); final kids = [for (final x in pane.cache[e.path] ?? const []) if (!x.isDir) x.path]; setState(() { pane.marked.removeWhere((m) => samePath(m, e.path)); pane.marked.addAll(kids); pane.expanded.add(e.path); }); return; } final kids = [for (final x in pane.cache[e.path] ?? const []) if (!x.isDir) x.path]; final inside = kids.isNotEmpty && kids.every((k) => pane.marked.any((m) => samePath(m, k))); if (inside) { setState(() => pane.marked.removeWhere((m) => kids.any((k) => samePath(k, m)))); return; } } if (!_isMarked(pane, e.path)) { setState(() { pane.marked.removeWhere((m) => isSameOrInside(m, e.path)); // 안에 따로 고른 것은 이 폴더 하나로 pane.marked.add(e.path); pane.anchor = e.path; }); if (e.isDir) { await _load(pane, e.path); setState(() => pane.expanded.add(e.path)); } return; } final owner = pane.marked.firstWhere((m) => isSameOrInside(e.path, m)); setState(() => pane.marked.removeWhere((m) => isSameOrInside(m, e.path))); if (samePath(owner, e.path)) return; // 위 폴더 [owner] 를 골라 둔 상태: owner 표시를 풀고, e 까지 내려가는 길의 다른 항목은 고른 채로 final keep = []; var dir = owner; while (!samePath(dir, e.path)) { await _load(pane, dir); String? next; for (final x in pane.cache[dir] ?? const []) { if (isSameOrInside(e.path, x.path)) { next = x.path; } else { keep.add(x.path); } } if (next == null) break; dir = next; } setState(() { pane.marked.remove(owner); pane.marked.addAll(keep); }); } /// 열기 (파일: 선택 설정이면 두 번 누르기, 바로 실행이면 한 번 누르기 / 폴더: 한 번 누르기): /// 트리의 폴더는 펼치기 · 접기, 목록의 폴더는 들어가기 (왼쪽 트리도 따라감), 파일은 실행. Future _onOpen(_Pane pane, _Row row, {bool list = false}) async { final e = row.entry; setState(() => _active = _panes.indexOf(pane)); pane.focused = e.path; if (row.isUp || (e.isDir && list)) { await _goTo(pane, e.path); return; } if (e.isDir) { // 189: 이름을 누르면 그 폴더로 (닫혀 있으면 펼친다). 이름으로는 접지 않는다 - 다시 누르거나 두 번 누르면 접혀 // 아래 줄이 움직여 다른 것을 누르게 된다. 접기 · 펼치기는 › 화살표로 (탐색기 · Total Commander 처럼) if (!pane.expanded.contains(e.path)) { await _toggle(pane, e); } else if (!samePath(pane.current, e.path)) { pane ..current = e.path ..changed(); _remember(pane, e.path); } return; } if (!list) pane.current = vDirname(e.path); pane.changed(); await _open(e.path, pane: pane); } /// 트리 폴더 펼치기 · 접기 (› 화살표 · 열기) Future _toggle(_Pane pane, FileEntry e) async { setState(() => _active = _panes.indexOf(pane)); // X-plore 비교 f: 압축 파일을 폴더처럼 펼치기 (읽기 전용) if (_canBrowseArchive(e) && !pane.expanded.contains(e.path)) { if (!await _readArchive(e.path)) return; pane.cache.remove(e.path); pane.expanded.add(e.path); await _load(pane, e.path); return; } if (pane.expanded.contains(e.path)) { pane.expanded.remove(e.path); // 접은 폴더 안이 지금 폴더였으면 접은 폴더로 if (isSameOrInside(pane.current, e.path)) pane.current = e.path; pane.changed(); return; } pane ..expanded.add(e.path) ..current = e.path ..focused = e.path; await _load(pane, e.path); if (pane.listScroll.hasClients) pane.listScroll.jumpTo(0); _remember(pane, e.path); } /// 다른 앱으로 열지 못했을 때: 음악 · 동영상이면 내장 플레이어를 알려 준다 (29) String _noAppMessage(String path) => isAudioFile(path) || isVideoFile(path) ? tr('이 형식을 여는 앱이 없습니다. 길게 누르거나 오른쪽 클릭 → [내장 플레이어로 재생] 으로 들어 보세요.') : tr('이 파일을 열 수 있는 앱이 없습니다.'); /// 파일 열기 (기본 동작): 동영상은 내장 플레이어 (또는 확장자별 프로그램), 그 밖은 기본 연결 프로그램 Future _open(String path, {_Pane? pane}) async { // X-plore 비교 f: 압축 안의 파일 - 그 파일만 임시 폴더로 꺼내 연다 if (_insideArchive(path)) { final local = await _extractOne(path); if (local == null || !mounted) return; path = local; } if (isVideoFile(path)) { await _play([path]); return; } // 86: 7z · RAR 은 열면 압축 풀기 (ZIP 은 아래의 보기 · ZIP 화면) final kind = archiveKindOf(path); if (kind == ArchiveKind.sevenZip || kind == ArchiveKind.rar) return _extractArchive(path, pane ?? _pane); // 이미지 · PDF · ZIP 보기 (컴포넌트를 켰을 때) if (c.settings.components.contains('viewer')) { final ext = extOf(path); if (c.settings.imageExts.contains(ext)) return _viewImages(path, pane); if (ext == 'pdf') return _viewPdf(path); if (zipExtensions.contains(ext)) return _openZip(path, pane); } // 관리자 지시 (10/20): Android 의 HWP · HWPX 는 앱 안에서 PDF 로 바꿔 본다 (rhwp - APK 에 들어 있음, 보기 컴포넌트를 켰을 때). // 열지 못하면 (암호 · 깨진 파일 · 라이브러리 없음) 이유를 알리고 예전처럼 다른 앱으로 if (Platform.isAndroid && c.settings.components.contains('viewer') && hwpExtensions.contains(extOf(path)) && RhwpEngine.available) { if (await _viewHwp(path)) return; } // 문서 미리보기 (Windows: 설치한 변환기로 PDF 로 바꿔 봄) if (c.settings.components.contains('docs') && Platform.isWindows && AppComponent.docExtensions.contains(extOf(path))) { return _viewDoc(path); } 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(_Pane pane, String dir) async { await _load(pane, dir); if (!mounted) return; final list = _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) => _BigThumbs.of(c, e), onOpen: (e) => _open(e.path, pane: pane), ), )); if (at == null || !mounted) return; await _goTo(pane, dir); pane.focused = at; pane.changed(); } /// WebDAV 파일은 열거나 재생하려면 임시 폴더로 받는다 (dav_external.dart) Future _fetch(String path) => fetchDav(context, c, path); /// 동영상 재생 (WebDAV 는 받지 않고 바로 스트리밍) 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, _Pane? pane) async { final dir = vDirname(path); // 폴더를 읽지 못하면 (E10: 이제 오류로 알림) 고른 그림 하나만 List list; try { list = pane?.cache[dir] ?? _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); } /// 문서 → 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 _refreshAll([vDirname(path)]); } /// ZIP: 환경 설정이 "만화 보기" 면 안의 그림을 바로 보고 (그림이 없으면 목록), 아니면 목록 /// 87: 저장 공간 분석 → 고른 것을 탐색기에서 그 자리로 (지우기는 탐색기의 지우기로만). /// WebDAV 는 직접 시킬 때만 (이 메뉴) 하고, 서버에서 모두 읽어 오래 걸린다고 먼저 알린다 Future _storage(_Pane pane, String dir) async { setState(() => _active = _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; } final path = await StorageAnalysisPage.open(context, dir); if (path == null || !mounted) return; final isDir = await vIsDir(path); await _goTo(pane, isDir ? path : vDirname(path), fresh: true); pane.focused = path; pane.changed(); _reveal(pane, path); } /// 89: Wi-Fi 공유 화면 (이미 켜져 있으면 그 공유를 보인다). 이 기기의 폴더만 - WebDAV 등 원격은 알림 Future _wifiShare(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: [ const 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, _Pane pane) async { final other = _dual ? _panes[1 - _panes.indexOf(pane)].current : null; final out = await extractArchiveFlow(context, c, path, otherDir: other); if (out != null && mounted) await _refreshAll([out, vDirname(out)]); } Future _openZip(String path, _Pane? 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 : _panes[1 - _panes.indexOf(pane)].current; final out = await openZip(context, c, path, otherDir: other); if (out != null) await _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 _refreshAll([dir]); } Future _menu(_Pane pane, FileEntry e, Offset at) async { setState(() => _active = _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.marked.contains(e.path) ? pane.marked.toList() : [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 = _isMarked(pane, e.path) && pane.marked.length > 1 ? pane.marked.toList() : [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, _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 (_selecting && _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, _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 비교 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('글 뷰어로 보기')), 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(_appHidden) ? ('appUnhide', Icons.visibility_outlined, many ? trf('{0}개 숨기기 풀기', [targets.length]) : tr('숨기기 풀기')) : ('appHide', Icons.visibility_off_outlined, many ? trf('{0}개 목록에서 숨기기', [targets.length]) : tr('목록에서 숨기기')), // 두 창이면 아이콘 줄의 복사 · 이동은 다른 창으로 - 담아 두었다가 원하는 폴더에 붙여넣기는 여기 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.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: const 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 _pick(pane, e); case 'open': await _goTo(pane, e.path); case 'openOther': final other = _panes[1 - _panes.indexOf(pane)]; await _goTo(other, e.path); setState(() => _active = _panes.indexOf(other)); case 'playFolder': await _playFolder(e.path); case 'playInternal': await _play([e.path], internal: true); case 'grid': await _grid(pane, e.path); case 'text': await _viewText(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': _markRange(pane, e); case 'select': setState(() => _selecting = true); if (!_isMarked(pane, e.path)) await _toggleMark(pane, e); case 'quickLook': await _quickLook(pane, e.path); case 'extract': await _extractArchive(e.path, pane); case 'storage': await _storage(pane, e.path); case 'wifiShare': await _wifiShare(e.path); case 'zip': final items = outermostPaths(_isMarked(pane, e.path) && pane.marked.length > 1 ? pane.marked.toList() : [e.path]); final other = _dual ? _panes[1 - _panes.indexOf(pane)].current : null; final made = await createZipFlow(context, c, items, dir: vDirname(items.first), otherDir: other); if (made != null && mounted) { await _refreshAll([vDirname(made)]); pane.focused = made; pane.changed(); } case 'appHide': await c.updateSettings((s) => s.explorerHiddenItems = [ ...s.explorerHiddenItems, for (final x in targets) if (!_appHidden(x)) x, ]); for (final q in _panes) { q.marked.removeAll(targets); q.changed(); } if (mounted) setState(() {}); _snack(_showAppHidden ? trf('목록에서 숨겼습니다 ({0}개). 파일은 그대로입니다.', [targets.length]) : trf('목록에서 숨겼습니다 ({0}개). 파일은 그대로이고, 목록 맨 아래의 [숨긴 항목 보이기] 로 다시 볼 수 있습니다.', [targets.length])); case 'appUnhide': final keys = {for (final x in targets) _hideKey(x)}; await c.updateSettings((s) => s.explorerHiddenItems = [ for (final x in s.explorerHiddenItems) if (!keys.contains(_hideKey(x))) x, ]); for (final q in _panes) { q.changed(); } if (mounted) setState(() {}); case 'props': await showFolderProperties(context, e.path); case 'hash': await showFileHash(context, e.path); case 'folderSize': if (_sizes.busy(e.path)) { _sizes.cancel(e.path); break; } // 사용자가 직접 시킨 것: 다른 폴더로 가도 끝까지 (멈추기는 아래 알림 · 같은 메뉴) _sizes ..forget(e.path) ..request(e.path); if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text(isDav(e.path) ? trf('"{0}" 크기를 계산합니다. 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다.', [e.name]) : trf('"{0}" 크기를 계산합니다.', [e.name])), action: SnackBarAction(label: tr('취소'), onPressed: () => _sizes.cancel(e.path)), )); } case 'rename': if (_isMarked(pane, e.path) && pane.marked.length > 1) { await _batchRename(pane, pane.marked.toList()); } else { await _rename(pane, e.path); } case 'copy': await _transfer(pane, _isMarked(pane, e.path) ? pane.marked.toList() : [e.path], move: false); case 'move': await _transfer(pane, _isMarked(pane, e.path) ? pane.marked.toList() : [e.path], move: true); case 'clipCopy': _toClipboard(_isMarked(pane, e.path) ? pane.marked.toList() : [e.path], move: false); case 'clipMove': _toClipboard(_isMarked(pane, e.path) ? pane.marked.toList() : [e.path], move: true); case 'paste': await _paste(pane, e.isDir ? e.path : vDirname(e.path)); case 'delete': // 77: 메뉴 머리의 "n개 항목" 과 같게 (예전에는 표시한 것이 여럿이어도 누른 것 하나만 지웠다) await _delete(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])); } } // ───────── 기능 ───────── _Pane get _pane => _panes[_active]; _Pane? get _other => _dual || _hiddenTarget ? _panes[1 - _active] : null; // ───────── X-plore 비교 f: 압축을 트리 안에서 폴더처럼 (ZIP · 7z · RAR, 읽기 전용) ───────── /// 펼쳐 본 압축의 목록 (압축 경로 → 항목) final _archives = >{}; /// 압축을 읽은 때의 (크기, 바뀐 때) - 바뀌었으면 다음에 펼치거나 새로 고칠 때 다시 읽는다 (관리자 지시 10/19) final _archiveStamps = {}; static Future<(int, DateTime)?> _stampOf(String path) async { try { final st = await File(path).stat(); if (st.type == FileSystemEntityType.notFound) return null; return (st.size, st.modified); } catch (_) { return null; } } /// [arc] 의 읽어 둔 목록과 창들의 그 안 목록을 잊는다 (다시 읽게) void _forgetArchive(String arc) { _archives.remove(arc); _archiveStamps.remove(arc); for (final q in _panes) { q.cache.removeWhere((k, _) => samePath(k, arc) || isSameOrInside(k, arc)); } } /// 트리에서 펼칠 수 있는 압축 (이 기기의 파일 · 탐색기 화면) bool _canBrowseArchive(FileEntry e) => !widget.rsync && !e.isDir && !isDav(e.path) && archiveKindOf(e.path) != null; /// [path] 가 들어 있는 (펼쳐 둔) 압축 - 압축 자신도 String? _archiveOf(String path) { String? best; for (final a in _archives.keys) { if ((samePath(path, a) || isSameOrInside(path, a)) && (best == null || a.length > best.length)) best = a; } return best; } /// 압축 안의 항목인지 (압축 파일 자신은 아님) bool _insideArchive(String path) { final a = _archiveOf(path); return a != null && !samePath(a, path); } /// 압축 안 경로 → 압축 안의 이름 ("폴더/파일") String _innerName(String archive, String path) => p.relative(path, from: archive).replaceAll('\\', '/'); /// 압축의 목록을 읽어 둔다 (비밀번호가 걸린 7z 등은 [압축 풀기] 로 안내). 읽었으면 true Future _readArchive(String path) async { final stamp = await _stampOf(path); if (_archives.containsKey(path)) { // 펼친 뒤 압축 파일이 바뀌었으면 (크기 · 바뀐 때) 다시 읽는다. 없어졌으면 잊는다 if (stamp != null && stamp == _archiveStamps[path]) return true; _forgetArchive(path); if (stamp == null) return false; } final kind = archiveKindOf(path)!; try { if (kind == ArchiveKind.zip) { _archives[path] = await listZip(path); } else { final exe = await bundledSevenZip(); if (exe == null) { _snack(tr('이 기기에서는 7z · RAR 안을 볼 수 없습니다 (다른 앱으로 열어 주세요)')); return false; } _archives[path] = await list7z(exe, path); } if (stamp != null) _archiveStamps[path] = stamp; return true; } on ArchivePasswordException { _snack(tr('비밀번호가 걸린 압축은 안을 미리 볼 수 없습니다 - [압축 풀기] 로 푸세요')); } catch (e) { _snack(trf('압축을 읽지 못했습니다: {0}', [e])); } return false; } /// 압축 안 폴더 [dir] 의 바로 아래 항목 (목록의 폴더 이름이 없어도 경로에서 폴더를 만든다) List _archiveList(String dir) { final arc = _archiveOf(dir)!; final rel = samePath(dir, arc) ? '' : _innerName(arc, dir); final out = {}; for (final a in _archives[arc]!) { if (a.isLink) continue; final n = a.name.replaceAll('\\', '/').replaceAll(RegExp(r'^(\./)+|/+$'), ''); if (n.isEmpty || unsafeArchivePath(n) != null) continue; if (rel.isNotEmpty && !n.startsWith('$rel/')) continue; final rest = rel.isEmpty ? n : n.substring(rel.length + 1); if (rest.isEmpty) continue; final first = rest.split('/').first; final folder = rest.contains('/') || a.isDir; final path = p.joinAll([arc, ...(rel.isEmpty ? first : '$rel/$first').split('/')]); final old = out[first]; if (old == null || (folder && !old.isDir)) { out[first] = FileEntry(path, isDir: folder, size: folder ? 0 : a.size, modified: a.modified ?? DateTime(0)); } } return out.values.toList(); } /// 압축 안의 파일 하나를 임시 폴더로 꺼낸다 (보기 · 재생). 꺼낸 경로 (못 하면 null) Future _extractOne(String path) async { final arc = _archiveOf(path)!; final inner = _innerName(arc, path); final dest = p.join(await c.services.storage.tempDirectory(), 'jj_arcview', '${arc.hashCode.toUnsigned(32)}'); final plan = planExtract(_archives[arc]!, names: [inner]); if (plan.encrypted) { _snack(tr('비밀번호가 걸린 파일입니다 - [압축 풀기] 로 푸세요')); return null; } try { if (archiveKindOf(arc) == ArchiveKind.zip) { await extractZipPlan(arc, dest, plan, conflict: NameConflict.overwrite); } else { final exe = await bundledSevenZip(); if (exe == null) return null; await extract7zPlan(exe, arc, dest, plan, conflict: NameConflict.overwrite); } } catch (e) { _snack(trf('압축에서 꺼내지 못했습니다: {0}', [e])); return null; } final out = p.joinAll([dest, ...inner.split('/')]); return File(out).existsSync() ? out : null; } /// 압축 안의 것을 꺼내기 - 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)) _innerName(arc, x)]; final done = await extractArchiveFlow(context, c, arc, otherDir: otherDir, names: names); if (done != null && mounted) await _refreshAll([done]); } /// 압축 안을 바꾸려 하면 막고 알린다 (읽기 전용). 막았으면 true /// [self] 면 압축 파일 자신도 (그 안으로 넣는 곳일 때) bool _blockArchive(List paths, {bool self = false}) { if (!paths.any((x) => _insideArchive(x) || (self && _archives.containsKey(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) => _volumes.firstWhere((v) => samePath(v.$1, dir), orElse: () => (dir, vBasename(dir).isEmpty ? _displayPath(dir) : vBasename(dir))).$2; /// X-plore 비교 a: 한 창일 때 맨 위의 "→ 갈 곳" 줄 - 숨은 창의 폴더 · [지금 폴더로] · [다른 폴더] Widget _targetBar() { final t = _panes[1 - _active]; return Material( color: JjColors.accent.withValues(alpha: 0.10), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), child: Row(children: [ const Icon(Icons.arrow_forward, size: 18, color: JjColors.accent), const SizedBox(width: 6), // 412 캡처 (10/18): "갈 곳:" 은 늘 보이고, 폴더 이름이 길면 가운데를 줄인다 (전체 경로는 길게 눌러 · 마우스를 올려) Expanded( child: Tooltip( message: _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: () => _goTo(t, _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 _goTo(t, d); }, child: Text(tr('다른 폴더')), ), ]), ), ); } /// 기능의 대상: 표시한 항목, 없으면 마지막으로 누른 항목 (저장 장치 맨 위는 빼고) List _targets(_Pane pane) { if (pane.marked.isNotEmpty) return pane.marked.toList(); 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))); } Future _refresh(_Pane pane, Iterable dirs) async { for (final d in {...dirs}) { if (pane.cache.containsKey(d)) await _load(pane, d, force: true); } } /// 265: 복사 · 이동 뒤 - 대상 · 원본의 위 폴더와, 펼쳐 둔 원본 · 대상 안쪽까지 두 창 모두 다시 읽는다. /// (예전에는 원본의 위 폴더만 읽어, 합치다 남은 원본 폴더 아래에 옮겨진 파일이 계속 보였다) 없어진 폴더는 접는다 Future _refreshAfterTransfer(List sources, String dest) async { for (final x in _panes) { final dirs = { dest, ...sources.map(vDirname), for (final k in x.cache.keys) if (isSameOrInside(k, dest) || sources.any((s) => isSameOrInside(k, s))) k, }; for (final d in dirs) { if (!mounted) return; if (!x.cache.containsKey(d)) continue; if (!await vExists(d)) { x.cache.remove(d); x.expanded.remove(d); continue; } // 180: 비우지 않고 새로 읽은 것으로 바꾼다 (자리 · 스크롤 그대로) await _load(x, d, force: true); } } } /// 바뀐 폴더를 두 창 모두 다시 읽는다 Future _refreshAll(Iterable dirs) async { for (final pane in _panes) { await _refresh(pane, dirs); } } /// X-plore b: 가운데 버튼 길게 누르기 - 복사 ↔ 이동, 지우기 → 영구 삭제로 묻기 (확인 창은 그대로) Future _buttonLong(ExplorerButton b) async { switch (b) { case ExplorerButton.copy: return _button(ExplorerButton.move); case ExplorerButton.move: return _button(ExplorerButton.copy); case ExplorerButton.delete: _shiftDelete = true; return _button(ExplorerButton.delete); default: return; } } Future _button(ExplorerButton b) async { final pane = _pane; switch (b) { case ExplorerButton.up: if (samePath(pane.current, pane.root)) return; final up = vDirname(pane.current); pane.expanded.remove(pane.current); await _goTo(pane, up); case ExplorerButton.select: _setSelecting(!_selecting); case ExplorerButton.monitor: await MonitorPage.open(context, c); case ExplorerButton.toRight: await _runRsync(right: true, left: false); case ExplorerButton.toLeft: await _runRsync(right: false, left: true); case ExplorerButton.both: await _runRsync(right: true, left: true); case ExplorerButton.refresh: // 183: 두 창 모두 (밖에서 지운 파일이 다른 창에 남지 않게) await _reloadOpen(); case ExplorerButton.play: final t = _targets(pane); // 181: 강조한 것이 mp3 등 소리 파일이어도 그것을 (예전에는 동영상이 아니라고 빼고 폴더의 동영상을 틀었다) final videos = t.where((x) => !vIsDirSync(x) && (isVideoFile(x) || isAudioFile(x))).toList(); if (videos.isNotEmpty) { await _play(videos, keepOrder: pane.marked.isNotEmpty); } else { await _playFolder(t.length == 1 && vIsDirSync(t.first) ? t.first : pane.current); } case ExplorerButton.addMkv: final t = _targets(pane); await _addMkv(t.isEmpty ? [pane.current] : t); case ExplorerButton.newFolder: // X-plore 비교 f: 펼친 압축 안 (지금 폴더가 압축 안의 폴더) 에는 만들지 않는다 if (_blockArchive([pane.current], self: true)) return; final name = await _askName(tr('새 폴더'), ''); if (name == null) return; try { final parent = pane.current; final made = await FileOps.makeFolder(parent, name); // 만든 폴더가 트리에 보이게: 부모를 펼치고 다시 읽은 뒤 그 줄까지 스크롤 pane.expanded.add(parent); await _load(pane, parent, force: true); await _refreshAll([parent]); // Rsync 화면: 만든 폴더를 이 창의 원본 · 대상으로 바로 고른다 if (widget.rsync) { pane.marked ..clear() ..add(made); } pane.focused = made; pane.changed(); _reveal(pane, made); } catch (e) { _snack(trf('폴더를 만들 수 없습니다: {0}', [e])); } case ExplorerButton.rename: final t = _targets(pane); if (t.isEmpty) { _snack(tr('이름을 바꿀 항목 하나를 고르세요.')); return; } // 78: 여럿을 골랐으면 일괄 이름 바꾸기 if (t.length > 1) return _batchRename(pane, t); await _rename(pane, t.first); case ExplorerButton.copy: await _transfer(pane, _targets(pane), move: false); case ExplorerButton.move: await _transfer(pane, _targets(pane), move: true); case ExplorerButton.paste: await _paste(pane, pane.current); case ExplorerButton.delete: await _delete(pane, _targets(pane)); case ExplorerButton.search: await _search(pane); case ExplorerButton.storage: await _storage(pane, pane.current); case ExplorerButton.wifiShare: await _wifiShare(pane.current); case ExplorerButton.trash: await _openTrash(); case ExplorerButton.sort: await _sortDialog(); case ExplorerButton.hidden: await c.updateSettings((s) => s.explorerShowHidden = !s.explorerShowHidden); for (final x in _panes) { x.cache.clear(); for (final d in x.expanded.toList()) { await _load(x, d, force: true); } } _snack(c.settings.explorerShowHidden ? tr('숨은 항목을 보입니다.') : tr('숨은 항목을 감춥니다.')); case ExplorerButton.history: await _historyDialog(pane); case ExplorerButton.bookmarks: await _bookmarks(pane); case ExplorerButton.orient: await _toggleOrientation(); case ExplorerButton.layout: await _layoutDialog(); case ExplorerButton.buttons: await _buttonsDialog(); } } 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); } 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])); } Future _rename(_Pane 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 _followHidden(moved: [(path, now)]); await _refreshAll([vDirname(path)]); for (final x in _panes) { if (x.marked.remove(path)) x.marked.add(now); } pane.focused = now; pane.changed(); } catch (e) { _snack(trf('이름을 바꿀 수 없습니다: {0}', [e])); } } /// 78: 일괄 이름 바꾸기 - 번호는 목록에 보이는 차례대로. 끝나면 [되돌리기] Future _batchRename(_Pane 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; ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text(trf('{0}개의 이름을 바꿨습니다.', [done.length])), duration: const Duration(seconds: 10), action: SnackBarAction( label: tr('되돌리기'), onPressed: () 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(_Pane pane, List<(String, String)> moved) async { await _followHidden(moved: moved); await _refreshAll({for (final (a, _) in moved) vDirname(a)}); for (final x in _panes) { for (final (from, to) in moved) { if (x.marked.remove(from)) x.marked.add(to); if (x.focused != null && samePath(x.focused!, from)) x.focused = to; } x.changed(); } } /// Shift+Delete 로 불렀는지 (휴지통을 거치지 않고 지우기를 미리 골라 둔다 - 65) bool _shiftDelete = false; /// 지우기. Windows 의 로컬 파일은 기본으로 휴지통으로 (창에서 "영구 삭제" 를 고르거나 Shift+Delete). /// WebDAV · Android 는 휴지통이 없어 바로 지운다. 여러 개면 실패해도 나머지를 계속하고, 못 지운 것을 알린다 (65). Future _delete(_Pane 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 ok = await showDialog( context: context, builder: (ctx) => StatefulBuilder( builder: (ctx, set) => AlertDialog( scrollable: true, title: Text(permanent ? tr('영구 삭제') : tr('휴지통으로 보내기')), // 무엇을 지우는지 늘 보여 준다 (여러 개면 앞의 8개 이름 · 폴더) content: 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: const TextStyle(fontSize: 12, color: Colors.orangeAccent)), ), if (hiddenInside > 0) Padding( padding: const EdgeInsets.only(top: 8), child: Text(trf('지우는 폴더 안에 목록에서 숨긴 항목 {0}개가 있습니다 - 함께 지워집니다', [hiddenInside]), style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)), ), if (local && !canRecycle) Padding( padding: const EdgeInsets.only(top: 8), child: Text(tr('이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.'), style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)), ), if (canTrash && !permanent) Padding( padding: const EdgeInsets.only(top: 8), child: Text(trf('앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).', [c.settings.androidTrashDays]), style: const 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)')), ), ]), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), FilledButton( style: FilledButton.styleFrom(backgroundColor: permanent ? Colors.red.shade700 : null), onPressed: () => Navigator.pop(ctx, true), child: Text(permanent ? tr('영구 삭제') : tr('휴지통으로')), ), ], ), ), ); if (ok != true) return; // 하나씩: 실패해도 나머지는 계속. 실제로 어떻게 됐는지 세어 그대로 알린다 (94) final failed = <(String, String)>[]; // 144: 경로가 너무 길어 휴지통이 받지 않은 것 (이유는 한 번, [영구 삭제] 로 바로 지울 수 있게) final tooLong = []; var recycled = 0, nuked = 0, kept = 0; // 148: 휴지통으로 보낸 것 (되돌리기용) - 정보 파일 시각은 초 단위라 조금 앞에서부터 final since = DateTime.now().subtract(const Duration(seconds: 2)); final sent = []; // X-plore 비교 e: 앱 휴지통으로 보낸 것 · 옮기지 못한 것 (영구 삭제할지 묻는다) final trashed = []; final notTrashed = <(String, String)>[]; // X-plore 숨기기: 휴지통에서 [되돌리기] 할 수 있는 것 (숨긴 목록에 남겨 되살리면 그대로 숨김) final undoable = []; for (final x in paths) { if (!permanent && canTrash) { try { trashed.add(await trash.moveToTrash(x)); undoable.add(x); recycled++; } on TrashMoveException catch (e) { notTrashed.add((x, '${e.cause}')); } continue; } try { if (!permanent) { switch (recycle(x)) { case RecycleResult.recycled: recycled++; sent.add(x); case RecycleResult.deletedPermanently: nuked++; case RecycleResult.cancelled: kept++; } } else { await FileOps().delete([x]); } } on LongPathException { tooLong.add(x); } catch (e) { failed.add((x, e is FileSystemException ? (e.osError?.message ?? e.message) : '$e')); } } // X-plore 비교 e: 휴지통으로 옮기지 못한 것 (다른 저장 장치 · 권한 등) - 몰래 지우지 않고 영구 삭제할지 묻는다 if (notTrashed.isNotEmpty && mounted) { final nuke = await showDialog( context: context, builder: (ctx) => AlertDialog( scrollable: true, icon: const Icon(Icons.warning_amber_rounded, color: Colors.orangeAccent), title: Text(trf('{0}개를 휴지통으로 옮기지 못했습니다', [notTrashed.length])), content: 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('영구 삭제하면 되살릴 수 없습니다.')), ]), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('그대로 두기'))), FilledButton( style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700), onPressed: () => Navigator.pop(ctx, true), child: Text(trf('영구 삭제 ({0}개, 되돌릴 수 없음)', [notTrashed.length])), ), ], ), ); if (nuke == true) { for (final (x, _) in notTrashed) { try { await FileOps().delete([x]); } catch (e) { failed.add((x, '$e')); } } } } if ((failed.isNotEmpty || tooLong.isNotEmpty) && mounted) { final nukeLong = await showDialog( context: context, builder: (ctx) => AlertDialog( scrollable: true, icon: const Icon(Icons.error_outline, color: Colors.redAccent), title: Text(trf('{0}개 중 {1}개를 지우지 못했습니다', [paths.length, failed.length + tooLong.length])), content: 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: const 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)), ]), actions: [ if (tooLong.isNotEmpty) ...[ TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('그대로 두기'))), FilledButton( style: FilledButton.styleFrom(backgroundColor: Colors.redAccent), onPressed: () => Navigator.pop(ctx, true), child: Text(trf('영구 삭제 ({0}개, 되돌릴 수 없음)', [tooLong.length])), ), ] else FilledButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('확인'))), ], ), ); // 144: 사용자가 [영구 삭제] 를 고른 긴 경로만 지운다 if (nukeLong == true) { final notDeleted = []; for (final x in tooLong) { try { await FileOps().delete([x]); } catch (e) { notDeleted.add('${vBasename(x)}: ${e is FileSystemException ? (e.osError?.message ?? e.message) : e}'); } } _snack([ if (tooLong.length > notDeleted.length) trf('{0}개를 영구 삭제했습니다.', [tooLong.length - notDeleted.length]), if (notDeleted.isNotEmpty) trf('지우지 못함: {0}', [notDeleted.take(3).join(' · ')]), ].join(' ')); } } else if (!permanent && mounted) { final text = [ if (recycled > 0) trf('{0}개 항목을 휴지통으로 보냈습니다.', [recycled]), // 휴지통보다 커서 Windows 가 물었고 사용자가 영구 삭제를 골랐다 if (nuked > 0) trf('{0}개는 휴지통에 들어가지 않아 영구 삭제했습니다 (Windows 가 묻고 고른 대로).', [nuked]), if (kept > 0) trf('{0}개는 지우지 않았습니다 (취소).', [kept]), ].join(' '); // 148: 휴지통으로 보낸 것은 바로 [되돌리기] ScaffoldMessenger.of(context).showSnackBar(SnackBar( duration: const Duration(seconds: 10), content: Text(text), action: trashed.isNotEmpty ? SnackBarAction(label: tr('되돌리기'), onPressed: () => _undoTrash(trashed)) : sent.isEmpty ? null : SnackBarAction(label: tr('되돌리기'), onPressed: () => _undoRecycle(sent, since)), )); } // X-plore 숨기기: 영구로 지운 것 (그 안 포함) 은 숨긴 목록에서 뺀다. 휴지통으로 보낸 것은 되돌리기 뒤에도 숨김이 되게 남긴다 await _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 _panes) { x.marked.removeAll(paths); if (_selecting && x.marked.isEmpty) _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 _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 _local) v]) : null; /// X-plore 비교 e: 방금 앱 휴지통으로 보낸 것을 원래 자리로 Future _undoTrash(List items) async { final trash = _appTrash; if (trash == null) return; final back = []; for (final i in items) { try { back.add(await trash.restore(i)); } catch (_) {} } await _refreshAll(back.map(p.dirname)); _snack(trf('{0}개를 원래 자리로 되살렸습니다', [back.length])); } /// 시험: [휴지통] 열기 @visibleForTesting Future debugOpenTrash() => _openTrash(); /// X-plore 비교 e: [휴지통] - Android 는 앱 휴지통 화면, Windows 는 시스템 휴지통 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 _refreshAll(back.map(p.dirname)); } /// 148: 방금 휴지통으로 보낸 것을 원래 자리로 (원래 자리에 같은 이름이 생겼으면 그것은 두고 알림) Future _undoRecycle(List paths, DateTime since) async { final failed = []; var back = 0; for (final x in paths) { try { restoreRecycled(x, since: since); back++; } catch (e) { failed.add('${vBasename(x)}: ${e is FileSystemException ? e.message : e}'); } } await _refreshAll(paths.map(p.dirname)); for (final x in _panes) { x.changed(); } _snack([ if (back > 0) trf('{0}개 항목을 되돌렸습니다.', [back]), if (failed.isNotEmpty) trf('되돌리지 못함: {0}', [failed.take(3).join(' · ')]), ].join(' ')); } /// 다른 창의 지금 폴더로 복사 · 이동 (진행 창 · 취소) /// 복사 · 이동 버튼: 두 창이면 다른 창의 지금 폴더로, 그 밖의 배치는 담아 두기 (붙여넣기로) Future _transfer(_Pane pane, List sources, {required bool move}) async { // 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); } /// 복사 · 이동할 항목 (붙여넣기 전까지 담아 둠) List _clip = []; bool _clipMove = false; /// 154: 고른 것이 없을 때 (복사 · 이동이 같은 말) String _pickFirst(bool move) => move ? tr('옮길 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).') : tr('복사할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).'); /// 154: 항목 이름 (앞의 3개 + "외 n개") /// 48: 받는 폴더에 같은 이름이 있는 원본 (같은 폴더로 복사는 늘 새 이름이라 빼고) /// 48 · 172: 대상 폴더에 같은 이름이 있는 원본 - 두 파일의 크기 · 바뀐 날을 함께 (어느 쪽이 새것인지 보고 고르게) static Future> _findClashes(List sources, String dest) async { final out = []; for (final s in sources) { if (samePath(vDirname(s), dest)) continue; try { final there = await vStat(vJoin(dest, vBasename(s))); if (there == null) continue; final here = await vStat(s); out.add((src: s, here: here, there: there)); } catch (_) {} } return out; } static String _namesOf(List paths) { final names = [for (final x in paths.take(3)) vBasename(x)].join(', '); return paths.length > 3 ? trf('{0} 외 {1}개', [names, paths.length - 3]) : names; } void _toClipboard(List sources, {required bool move}) { if (sources.isEmpty) { _snack(_pickFirst(move)); return; } setState(() { _clip = [...sources]; _clipMove = move; }); _snack(trf('{0} 을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).', [_namesOf(sources), move ? tr('이동') : tr('복사')])); } Future _paste(_Pane pane, String dest) async { if (_clip.isEmpty) { _snack(tr('붙여넣을 항목이 없습니다. 먼저 [복사] · [이동] 으로 담으세요.')); return; } final items = _clip, move = _clipMove; final done = await _runTransfer(pane, items, dest, move: move); // 옮긴 것은 다시 붙여넣을 수 없으므로 비운다 (복사는 여러 곳에 붙여넣을 수 있게 남김) if (done && move && mounted) setState(() => _clip = []); } /// 지금 도는 복사 · 이동 (아래에서 올라오는 진행 막대) /// (Rsync 화면의 ⇄ 는 둘이 함께) List _jobs = const []; void _onGoToRequest() { final dir = ExplorerPage.goToRequest.value; if (dir == null || !_ready || !mounted) return; ExplorerPage.goToRequest.value = null; unawaited(_goTo(_pane, dir, fresh: true).then((_) => _reveal(_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 _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 _refreshAll([job.dest, ...job.sources.map(vDirname)]); for (final x in _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(_Pane pane, List sources, String dest, {required bool move, void Function(TransferJob job)? onFinished}) async { if (sources.isEmpty) { _snack(_pickFirst(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: 같은 폴더로 - 짧은 알림은 놓치기 쉬워 창으로. 이동은 할 것이 없고, 복사는 사본을 만들지 한 번 묻는다 final here = [for (final x in sources) if (samePath(vDirname(x), dest)) x]; var sameFolderCopy = false; // 이동인데 일부만 이미 그 폴더에 있으면 그것만 건너뛰고 나머지를 옮긴다 (관리자: 섞어 고른 것을 다시 고르지 않게) var skippedHere = const []; if (move && here.isNotEmpty && here.length < sources.length) { skippedHere = here; sources = [for (final x in sources) if (!here.contains(x)) x]; } else if (here.isNotEmpty && (move || here.length == sources.length)) { final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( scrollable: true, title: Text(move ? tr('이미 이 폴더에 있습니다') : tr('같은 폴더입니다')), content: Text(move ? trf('{0} 은(는) 이미 이 폴더에 있어 옮길 것이 없습니다.\n{1}', [_namesOf(here), vDisplay(dest)]) : trf('같은 폴더에 사본을 만들까요? (이름 (2))\n{0}', [_namesOf(here)])), actions: [ if (move) FilledButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('확인'))) else ...[ TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('사본 만들기'))), ], ], ), ); if (ok != true || !mounted) return false; sameFolderCopy = true; // 이미 물었으니 아래 확인 창은 건너뛴다 } // 원본 · 대상이 없거나 (다른 곳에서 지움 - 목록을 새로 고침) 폴더를 자기 안으로 넣으려 하면 시작하지 않는다 final problem = transferProblem(sources, dest, move: move); if (problem != null) { _snack(problem); pane.marked.removeWhere((m) => vMissingSync(m)); await _refreshAll([dest, ...sources.map(p.dirname)]); for (final x in _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; final go = sameFolderCopy ? true : await showDialog( context: context, builder: (ctx) => StatefulBuilder( builder: (ctx, setInner) => AlertDialog( scrollable: true, title: Text(move ? tr('이동') : tr('복사')), content: 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: const 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: const 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: const TextStyle(color: Colors.orangeAccent)), // 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: const TextStyle(fontSize: 12, color: Colors.orangeAccent), ), ), ), ], 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)), ]), ), ]); }, ), ]), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(move ? tr('이동') : tr('복사'))), ], ), ), ); 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.marked.clear(); await _refreshAfterTransfer(sources, dest); // 넣은 폴더가 창의 지금 폴더면 펼쳐서 새 항목이 보이게 for (final x in _panes) { if (samePath(x.current, dest)) { x.expanded.add(dest); await _load(x, dest, force: true); } x.changed(); } final doneText = error is FileOpCancelled ? tr('취소했습니다.') : error != null ? trf('끝나지 못했습니다: {0}', [error]) : [ trf('{0}개 항목을 {1}', [job.made.length, move ? tr('옮겼습니다.') : tr('복사했습니다.')]), // 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)]), ].join(' · '); // 85: 끝나면 [폴더 열기] - 앱 탐색기에서 넣은 폴더로 (그 폴더를 보고 있는 창, 없으면 지금 창) if (error == null && mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text(doneText), action: SnackBarAction( label: tr('폴더 열기'), onPressed: () { final show = _panes.where((x) => samePath(x.current, dest)).firstOrNull ?? _pane; setState(() => _active = _panes.indexOf(show)); unawaited(_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; 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 = _panes[0].marked.firstOrNull, r = _panes[1].marked.firstOrNull; if (l == null || r == null) { _snack(tr('왼쪽 · 오른쪽 창에서 폴더를 하나씩 고르세요.')); return; } final runs = [if (right) (l, r), if (left) (r, l)]; // 34: 대상이 원본과 같거나 원본 안이면 막는다 (맞출 때마다 원본이 바뀜). 원본이 대상 안 (안쪽 → 바깥) 은 지우기가 없을 때만 if (runs.any((x) => isSameOrInside(x.$2, x.$1))) { _snack(tr('대상이 원본과 같거나 원본 안에 있습니다 (맞출 때마다 원본이 바뀝니다). 다른 폴더를 고르세요.')); return; } final nested = runs.any((x) => isSameOrInside(x.$1, x.$2)); if (nested && !c.settings.allowInnerToOuter) { _snack(tr('원본이 대상 안에 있습니다 (환경 설정에서 막아 둠). 다른 폴더를 고르세요.')); return; } // 23: 다른 쌍은 함께 돌릴 수 있다. 같은 폴더끼리 도는 중이면 (서로 덮어쓰지 않게) 막는다 if (_jobs.any((j) => !j.finished && runs.any((x) => (samePath(j.sources.first, x.$1) && samePath(j.dest, x.$2)) || (samePath(j.sources.first, x.$2) && samePath(j.dest, x.$1))))) { _snack(tr('이 두 폴더는 지금 rsync 하는 중입니다. 끝난 뒤에 하세요.')); return; } for (final (src, dst) in runs) { final problem = transferProblem([src], dst, move: false); if (problem != null) { _snack(problem); for (final x in _panes) { x.marked.removeWhere((m) => vMissingSync(m)); } await _refreshAll([vDirname(l), vDirname(r)]); return; } } final both = right && left; final center = CopyCenter.of(c); // 확인 창에는 기억한 옵션 (없으면 지금 설정) 을 보여 주고, 실행할 때 모니터링에 기억한다 // 96: 확인 창 · 미리 보기 · 실행이 같은 작업 (원본 파일 지우기 = 이동 작업) 과 같은 옵션을 쓴다 List shownTasks(bool move) => [for (final (src, dst) in runs) center.peek([src], dst, contents: true, method: 'rsync', move: move)]; // 178: 환경 설정 (또는 기억한) 옵션에 원본 지우기가 있으면 [원본 파일 지우기] 를 켠 채로 연다 (버튼은 [이동] · 경고). // 원본 지우기는 이 체크로만 정한다 - 옵션 글에서는 빼고 보이고, 끄면 이번 실행에서 정말 빠진다 final optionMove = runs.map((x) => center.peek([x.$1], x.$2, contents: true, method: 'rsync', move: false)).any((t) => optionsRemoveSource(t.options)); var tasks = shownTasks(optionMove && !both); String opts(CopyTask t) { final o = withoutSourceRemovingOptions(t.options); return both ? withUpdateOption(o) : o; } // 95: ⇄ 는 → 다음에 ← (차례로). ← 에서는 지우지 않는다 (→ 가 방금 복사한 것을 지우지 않게) String runOpts(int i) => both && i > 0 ? withoutDeleteOptions(opts(tasks[i])) : opts(tasks[i]); if (nested && tasks.any((t) => optionsDelete(opts(t)))) { _snack(tr('원본이 대상 안에 있으면 지우기 (--delete) 를 함께 쓸 수 없습니다 (대상의 다른 파일이 모두 지워집니다).')); return; } // 한 방향 (→ · ←) 만: 원본 파일 지우기 (--remove-source-files) 와 끝난 뒤 원본 정리 var removeSource = optionMove && !both; var prune = 'keep'; // 72: 실행 전 비교 결과 (지워질 수를 실행 버튼에) List? preview; Object? previewError; // 15 검토 F2: 미리 볼 수 없는 거르기 규칙을 알고 실행하는지 (지우기가 들어 있을 때) var filterAck = false; // 관리자 지시 (10/19): 원격 (앱이 맞춤) 도 같은 거르기를 따르므로 미리 볼 수 없는 규칙도 똑같이 알린다 List filterLimits() => {for (final t in tasks) ...RsyncFilter.parse(opts(t)).unsupported}.toList(); // 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 옵션이 남으면 [실행] 을 막는다 List badOptions() { final seen = {}; return [for (final t in tasks) ...optionProblems(opts(t)).where((x) => seen.add(x.text))]; } // 93: 지우는 실행 (--delete 등. 100: WebDAV 도 따른다) 은 비교가 끝나 지울 목록이 보인 뒤에만 bool deletes() => tasks.any((t) => optionsDelete(opts(t))); // 105: 원본이 대상 안인데 지우기가 들어가면 막는다 - 창 안에서 옵션이 바뀔 때 (원본 파일 지우기) 와 실행 직전에도 본다 bool nestedDelete() => nested && deletes(); if (!mounted) return; final go = await showDialog( context: context, builder: (ctx) => StatefulBuilder(builder: (ctx, set) => AlertDialog( scrollable: true, // 110: 버튼이 좁아 세로로 쌓여도 [취소] 가 먼저 (아래 · 위가 바뀌어 [취소] 자리에 [실행] 이 오지 않게) actionsOverflowDirection: VerticalDirection.down, title: Text(both ? tr('rsync 양쪽 (⇄)') : 'rsync'), content: SizedBox( width: 560, child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ for (final t in tasks) ...[ // 109: 끝 폴더가 보이게 짧게, 전체 경로는 작게 Text(shortPath(t.sources.first), style: const TextStyle(fontWeight: FontWeight.w600)), Text('→ ${shortPath(t.dest)}', style: const TextStyle(fontWeight: FontWeight.w600)), Text('${vDisplay(t.sources.first)}/ → ${vDisplay(t.dest)}/', style: const TextStyle(fontSize: 11, color: JjColors.textDim)), // 111: 이 쌍은 처음 실행 때 기억한 옵션을 쓴다 (환경 설정의 rsync 옵션을 바꿔도 그대로) if (center.tasks.any((x) => x.id == t.id)) Text(tr('이 폴더 쌍은 기억한 옵션을 씁니다 (환경 설정이 아니라 모니터링 > 복사 · rsync 에서 고칩니다)'), style: const TextStyle(fontSize: 11, color: JjColors.textDim)), Text('rsync ${runOpts(tasks.indexOf(t))}${removeSource ? ' --remove-source-files' : ''}' '${t.bandwidthKBps > 0 ? ' · ${trf('속도 제한 {0} KB/s', [t.bandwidthKBps])}' : ''}', style: const TextStyle(fontSize: 12, fontFamily: 'monospace', color: JjColors.textDim)), const SizedBox(height: 10), ], Text(tr('폴더 안의 것을 맞춥니다 (대상에 같은 이름의 폴더를 새로 만들지 않음).'), style: const TextStyle(fontSize: 12, color: JjColors.textDim)), // 222: 이동이면 동작대로 (예전에는 이동일 때도 "복사 · 원본 폴더는 그대로" 라고 썼다) if (nested) Text( removeSource && !both ? tr('원본이 대상 폴더 안에 있습니다: 원본의 내용이 대상 폴더 바로 아래로 옮겨집니다 (원본 파일은 지워지고, 원본 폴더는 아래에서 고른 대로).') : tr('원본이 대상 폴더 안에 있습니다: 원본의 내용이 대상 폴더 바로 아래에 복사됩니다 (원본 폴더는 그대로 남음).'), style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)), // 1280×720 등 낮은 화면에서도 보이게: 원본 지우기 체크 · 경고는 비교 목록보다 위에 (178) if (both) ...[ Text(keepOptionsTogether(tr('양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).')), style: const TextStyle(fontSize: 12, color: JjColors.textDim)), if (optionMove) Text(tr('양쪽 (⇄) 에서는 원본 파일을 지우지 않습니다 (옵션의 --remove-source-files 는 쓰지 않음).'), style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)), ] else ...[ CheckboxListTile( contentPadding: EdgeInsets.zero, value: removeSource, onChanged: (v) => set(() { removeSource = v ?? false; tasks = shownTasks(removeSource); preview = null; previewError = null; }), title: Text(keepOptionsTogether(tr('원본 파일 지우기 (--remove-source-files)'))), subtitle: Text(tr('대상으로 옮긴 파일을 원본에서 지웁니다 (이동)')), ), if (removeSource) ...[ // 178: 원본이 지워진다는 것을 분명히 (환경 설정 옵션 때문에 켜졌으면 그렇다고) Text( optionMove ? tr('원본 파일이 지워집니다 (이동). 환경 설정의 rsync 옵션에 --remove-source-files 가 있어 켜 두었습니다 - 끄면 이번에는 원본을 남깁니다.') : tr('원본 파일이 지워집니다 (이동).'), style: const TextStyle(fontSize: 12, color: Colors.orangeAccent, fontWeight: FontWeight.w600)), const SizedBox(height: 4), Text(tr('끝난 뒤 원본의 빈 폴더를 지웁니다 (find 원본/ -type d -empty -delete). 원본 폴더는:'), style: const TextStyle(fontSize: 12, color: JjColors.textDim)), const SizedBox(height: 6), PruneChoice(value: prune, onChanged: (v) => set(() => prune = v)), ], ], // 72: 무엇이 바뀌는지 파일별로 (→ · ← · 받는 쪽에만 있음 · 지워짐) const SizedBox(height: 8), if (badOptions().isNotEmpty) OptionProblemNote(badOptions()), // 관리자 지시 (10/20): [실행] 이 막힐 때는 막히는 이유 한 줄만 (미리 볼 수 없음 · [알고 실행합니다] 는 숨김) if (badOptions().isEmpty && filterLimits().isNotEmpty) FilterLimitNote(unsupported: filterLimits(), deletes: deletes(), ack: filterAck, onAck: (v) => set(() => filterAck = v)), SyncPreviewView( // 옵션이 바뀌면 (원본 파일 지우기) 다시 비교 key: ValueKey([for (var i = 0; i < tasks.length; i++) runOpts(i)].join('|')), // 177: rsync 로 맞추면 옵션의 거르기 (--exclude 등) 도 비교에 (WebDAV 는 앱이 직접 - 거르기를 따르지 않음) compute: () async => both ? previewBoth(l, r, delete: deletes(), filter: RsyncFilter.parse(opts(tasks.first))) : [ for (final t in tasks) ...await previewSync(t.sources.first, t.dest, toRight: t.sources.first == l, delete: deletes(), // 229: WebDAV 가 끼면 앱이 직접 - 환경 설정 "받는 쪽이 더 새것이면 건너뛰기" 도 따른다 update: optionsUpdate(opts(t)) || (_viaDav([l, r]) && c.settings.copySkipNewer), skipLinks: _viaDav([l, r]), filter: RsyncFilter.parse(opts(t))), ], // 97: 다시 비교하는 동안은 예전 결과로 실행하지 못하게 onStart: () => set(() { preview = null; previewError = null; }), onResult: (items) => set(() { preview = items; previewError = null; }), onError: (e) => set(() { preview = null; previewError = e; }), ), if (nestedDelete()) Text(tr('원본이 대상 안에 있으면 지우기 (--delete) 를 함께 쓸 수 없습니다 (대상의 다른 파일이 모두 지워집니다).'), style: const TextStyle(fontSize: 12, color: Colors.redAccent, fontWeight: FontWeight.w600)), // 110: 이 줄은 늘 같은 자리를 차지한다 (글이 사라져 창 높이가 바뀌면 버튼이 움직인다) SizedBox( height: 34, child: deletes() && preview == null ? Text( previewError != null ? tr('지우기가 들어간 실행이라, 비교하지 못하면 실행할 수 없습니다. 원본 · 대상을 확인한 뒤 [다시 비교] 를 누르세요.') : tr('지우기가 들어간 실행이라, 지울 목록이 나온 뒤에 실행할 수 있습니다.'), style: const TextStyle(fontSize: 12, color: Colors.orangeAccent), ) : null, ), const SizedBox(height: 4), if (_viaDav([l, r])) Text(tr('WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files · 거르기 (--exclude · --include · --filter · --delete-excluded) 를 따름).'), style: const TextStyle(fontSize: 12, color: JjColors.accent)), ]), ), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), Builder(builder: (_) { final dels = preview?.where((x) => x.action == PreviewAction.delete).length ?? 0; return FilledButton( style: dels > 0 ? FilledButton.styleFrom(backgroundColor: JjColors.danger) : null, onPressed: nestedDelete() || badOptions().isNotEmpty || (deletes() && preview == null) || (deletes() && filterLimits().isNotEmpty && !filterAck) ? null : () => Navigator.pop(ctx, true), // 110: 글자는 늘 같게 (비교가 끝나 글이 길어지면 버튼 배치가 바뀌어 [취소] 를 누르려다 [실행] 을 누르게 됨). // 지울 수는 위의 빨간 글 "받는 쪽에서 N개가 지워집니다" 로 보인다 child: Text(removeSource ? tr('이동') : tr('실행')), ); }), ], )), ); // 110: 무엇을 골랐는지 작업 기록에 (실행 · 취소 · 닫힘) c.note(trf('Rsync 확인 창 ({0}): {1}', [ both ? '⇄' : right ? '→' : '←', go == true ? tr('실행') : go == false ? tr('취소') : tr('닫힘 (실행 안 함)'), ])); if (go != true || !mounted) return; if (nestedDelete()) { _snack(tr('원본이 대상 안에 있으면 지우기 (--delete) 를 함께 쓸 수 없습니다 (대상의 다른 파일이 모두 지워집니다).')); return; } final dav = _viaDav([l, r]); final exe = dav ? '' : await _ensureRsync(); if (exe == null || !mounted) return; // 96: 확인 창에 보인 그 옵션으로 (기억한 작업의 옵션이 달라도) final shownOpts = [for (var i = 0; i < tasks.length; i++) runOpts(i)]; var run = [ for (final (src, dst) in runs) await center.remember([src], dst, contents: true, method: 'rsync', move: removeSource), ]; if (removeSource) { run = [for (final t in run) t.copyWith(prune: prune)]; for (final t in run) { await center.update(t); } } // 95: 차례로 (→ 가 끝난 뒤 ←). 앞의 것이 실패 · 취소되면 뒤의 것은 하지 않는다 final jobs = []; for (var i = 0; i < run.length; i++) { c.note(trf('rsync 시작 ({0}): {1} → {2}', [tr('Rsync 화면 확인 창의 [실행]'), run[i].sources.first, run[i].dest])); // 218: Rsync 화면은 rsync 로 (기억한 작업의 방법을 모니터링에서 바꿨어도 - 확인 창에 보인 그대로. WebDAV 가 끼면 앱이 직접) final j = await center.start(run[i].copyWith(options: shownOpts[i], method: 'rsync'), rsyncExe: dav ? null : exe); if (!mounted) break; if (j == null) { // 205: 시작하지 못하면 이유를 알린다 (예전에는 알림 없이 끝나 "이동이 안 됨" 으로만 보였다) final why = transferProblem(run[i].sources, run[i].dest, move: run[i].move, contents: run[i].contents); _snack(trf('시작하지 못했습니다: {0}', [why ?? tr('rsync 를 실행할 수 없습니다')])); c.note(trf('rsync 시작하지 못함: {0}', [why ?? ''])); break; } jobs.add(j); setState(() => _jobs = [..._jobs.where((x) => !x.finished), j]); await j.done; if (j.error != null) break; } if (jobs.isEmpty || !mounted) return; // 원본 폴더까지 지웠으면 고른 표시도 뺀다 for (final x in _panes) { x.marked.removeWhere((m) => vMissingSync(m)); } await _refreshAll([l, r, vDirname(l), vDirname(r)]); for (final x in _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]); } /// 아래에서 올라오는 진행 막대 (위: 전체 항목 중 · 아래: 지금 폴더의 파일 중) Widget _transferPanel() { final jobs = _jobs; // 85: 숨겼으면 오른쪽 아래 작은 표시 "복사 · 이동 n개 · 45%" (누르면 다시 펼침) if (jobs.isNotEmpty && c.settings.explorerProgressHidden) { return Align( alignment: Alignment.bottomRight, child: Padding( padding: const EdgeInsets.only(bottom: 8), child: ListenableBuilder( listenable: Listenable.merge(jobs), builder: (context, _) { final run = [for (final j in jobs) if (!j.finished) j]; // 관리자 판단: 숨겨도 멈춘 (실패한) 것은 분명히 - 빨간 표시 (끝난 알림 · 묻는 창은 숨김과 상관없이 늘 뜬다) final failed = jobs.any((j) => j.finished && j.error != null && j.error is! FileOpCancelled); final pct = run.isEmpty || run.any((j) => j.counting) ? null : (run.fold(0, (a, j) => a + j.overall) / run.length * 100).round(); return TransferProgressChip( running: run.length, percent: pct, failed: failed, onTap: () => c.updateSettings((x) => x.explorerProgressHidden = false)); }, ), ), ); } return AnimatedSlide( offset: jobs.isEmpty ? const Offset(0, 1.2) : Offset.zero, duration: const Duration(milliseconds: 250), curve: Curves.easeOut, child: jobs.isEmpty ? const SizedBox(height: 0) : Material( elevation: 12, color: JjColors.panel, borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), child: Padding( padding: const EdgeInsets.fromLTRB(16, 10, 12, 12), // 여러 개가 함께 돌면 (23) 높이를 넘지 않게 넘겨 보기 child: ConstrainedBox( constraints: BoxConstraints(maxHeight: MediaQuery.sizeOf(context).height * 0.45), child: SingleChildScrollView( child: Column(mainAxisSize: MainAxisSize.min, children: [ // 85: 숨기기 (아래 작은 표시로) - 다른 파일을 보면서 기다릴 수 있게. Android 는 알림줄에도 진행률 Align( alignment: Alignment.centerRight, child: SizedBox( height: 28, child: TextButton.icon( style: TextButton.styleFrom(visualDensity: VisualDensity.compact), onPressed: () => c.updateSettings((x) => x.explorerProgressHidden = true), icon: const Icon(Icons.expand_more, size: 18), label: Text(tr('숨기기'), style: const TextStyle(fontSize: 12)), ), ), ), for (final (k, job) in jobs.indexed) ...[ if (k > 0) const Divider(height: 18), ListenableBuilder(listenable: job, builder: (context, _) => _jobView(job)), ], ]), ), ), ), ), ); } /// 진행 막대 하나 (위: 전체 항목 중 · 아래: 지금 폴더의 파일 중) Widget _jobView(TransferJob job) { String pct(double v) => '${(v * 100).round()}%'; final cur = job.index < job.total ? job.index : job.total - 1; final curFiles = job.total == 0 ? 0 : job.filesTotal[cur.clamp(0, job.total - 1)]; final curDone = job.total == 0 ? 0 : job.filesDone[cur.clamp(0, job.total - 1)]; final title = job.finished ? (job.error == null ? tr('완료') : tr('멈춤')) : job.move ? tr('옮기는 중') : tr('복사하는 중'); Widget line(String label, double value, String right) => Padding( padding: const EdgeInsets.only(top: 6), child: Row(children: [ SizedBox( width: 300, child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12.5)), ), const SizedBox(width: 10), Expanded( child: ClipRRect( borderRadius: BorderRadius.circular(3), child: LinearProgressIndicator( minHeight: 8, value: job.counting ? null : value, // 빈 곳과 찬 곳이 분명히 다르게 color: JjColors.accent, backgroundColor: JjColors.border, ), ), ), const SizedBox(width: 10), SizedBox( width: 150, child: Text(right, textAlign: TextAlign.end, style: const TextStyle(fontSize: 12, color: JjColors.textDim)), ), ]), ); return Column(mainAxisSize: MainAxisSize.min, children: [ Row(children: [ Icon(job.move ? Icons.drive_file_move_outline : Icons.copy_outlined, size: 18, color: JjColors.accent), const SizedBox(width: 8), Text('$title · ${tr(job.method.label)}', style: const TextStyle(fontWeight: FontWeight.w600)), const SizedBox(width: 12), Expanded( // Rsync 화면: 원본 → 대상 (⇄ 는 두 줄) child: Text(widget.rsync ? '${job.sources.join(', ')}/ → ${job.dest}/' : job.dest, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: JjColors.textDim)), ), if (!job.finished) TextButton(onPressed: job.cancel, child: Text(tr('취소'))), ]), // Rsync 화면 (폴더 하나를 맞춤): 위 = 지금 복사하는 파일 (그 파일의 진행률 · 속도), 아래 = 폴더 전체 (파일 수) if (widget.rsync) ...[ line( job.counting ? tr('파일 세는 중…') : job.pruning ? (job.finished ? trf('빈 폴더 {0}개 지움', [job.pruned]) : tr('원본의 빈 폴더 정리 중…')) : job.finished ? tr('끝') : job.currentFile.isEmpty ? tr('시작하는 중…') : job.currentFile.split('/').last, job.finished ? 1.0 : (job.filePercent ?? 0), [ if (job.filePercent != null && !job.finished) pct(job.filePercent!), if (!job.finished && job.speed != null) '${formatSize(job.speed!.round())}/s', ].join(' · '), ), line( trf('{0} 전체', [job.currentName.isEmpty ? vBasename(job.sources.first) : job.currentName]), job.folderProgress, '${pct(job.folderProgress)} · ${job.checkTotal > 0 && !job.finished ? trf('항목 {0}/{1}', [job.checked, job.checkTotal]) : trf('파일 {0}/{1}', [job.allDone, job.allFiles])}', ), ] else ...[ // 파일 탐색기 (여러 항목): 위 = 전체 항목 중 · 아래 = 지금 폴더의 파일 중 line( trf('전체 {0}개 중 {1}번째', [job.total, (cur + 1).clamp(1, job.total)]), job.overall, '${pct(job.overall)} · ${trf('파일 {0}/{1}', [job.allDone, job.allFiles])}' '${!job.finished && job.speed != null ? ' · ${formatSize(job.speed!.round())}/s' : ''}', ), line( job.counting ? tr('파일 세는 중…') : job.pruning ? (job.finished ? trf('빈 폴더 {0}개 지움', [job.pruned]) : tr('원본의 빈 폴더 정리 중…')) : trf('{0} 안의 파일', [job.currentName]), job.current, '${pct(job.current)} · ${trf('파일 {0}/{1}', [curDone, curFiles])}', ), ], ]); } 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 _Meta.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: 결과 화면에서 복사 · 이동 · 지우기 · [그 자리로] 를 한 뒤 [찾기 결과로 돌아가기] 로 다시 열 결과 (다음 찾기 · ✕ · 결과 화면을 그냥 닫으면 없어짐) ({_Pane pane, String root, SearchConditions cond, List found, bool done, Map moved})? _searchBack; /// 79: 찾기 - 이름 · 크기 · 바뀐 날 · 종류 조건 → 결과 목록 화면 (고르고 그대로 복사 · 이동 · 지우기, 누르면 열기 · 재생) Future _search(_Pane pane) async { setState(() => _active = _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(_Pane 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 _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 _delete(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: [ const 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: const 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), ), ]), ), ); } Future _sortDialog() async { // 84: "폴더마다 기억" 이면 지금 창의 폴더 정렬 final dir = _pane.current; var (by, desc) = _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}', [_displayPath(dir)]), style: const 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 _setSort(dir, by, desc); } /// 73: 즐겨찾기 - 이 창으로 가기, Rsync 화면이면 짝 (왼쪽 · 오른쪽) 을 한 번에 Future _bookmarks(_Pane pane) async { setState(() => _active = _panes.indexOf(pane)); await showFolderBookmarks( context, c, current: pane.current, right: widget.rsync ? _panes[1].current : null, go: (dir) => _goTo(pane, dir, fresh: true), goPair: widget.rsync ? (a, b) { _goTo(_panes[0], a, fresh: true); _goTo(_panes[1], b, fresh: true); } : null, ); if (mounted) setState(() {}); } Future _historyDialog(_Pane 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 _goTo(pane, pick, fresh: true); } /// 118: 두 창이면 좌우 ⇆ 위아래 를 바꾸고, 한 창이면 두 창 (좌우) 으로. 한 번 눌러 바로 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 _panes) { _reveal(pane, pane.current); } } 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: const 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: const 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(() => _active = 0); // 배치가 바뀌면 목록을 새로 그리므로 맨 위로 간다: 보던 폴더가 다시 보이게 for (final pane in _panes) { _reveal(pane, pane.current); } } 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: const 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 => _selecting || _panes.any((x) => x.marked.isNotEmpty) || (_ready && !samePath(_pane.current, _pane.root)); /// 49: 뒤로 키 - 먼저 고른 것 풀기, 그다음 상위 폴더, 맨 위면 화면 닫기 (위쪽 ← 는 늘 닫기) Future _onBack() async { if (_selecting || _panes.any((x) => x.marked.isNotEmpty)) { _setSelecting(false); return; } if (!samePath(_pane.current, _pane.root)) { await _button(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: 스타일에 맞는 단축키 (Total Commander: F5 복사 · F6 이동 · F7 새 폴더 · F8 지우기 · Insert/Space 표시 · // Ctrl+R 새로 고침 / Windows 탐색기 · X-plore: Ctrl+C · X · V · A · F5 새로 고침) ..._styleKeys(), const SingleActivator(LogicalKeyboardKey.delete): () => _button(ExplorerButton.delete), // 65: Shift+Delete = 휴지통을 거치지 않고 (창에서 한 번 더 확인) const SingleActivator(LogicalKeyboardKey.delete, shift: true): () { _shiftDelete = true; _button(ExplorerButton.delete); }, const SingleActivator(LogicalKeyboardKey.f2): () => _button(ExplorerButton.rename), // 180: 선택 모드 · 표시 풀기 (복사 · 이동이 끝나도 선택 모드는 그대로이므로 바로 나올 수 있게) const SingleActivator(LogicalKeyboardKey.escape): () { // 76: 열린 빠른 필터부터 닫는다 if (_panes[_active].filterOpen) { _closeFilter(_panes[_active]); } else if (_selecting || _panes.any((x) => x.marked.isNotEmpty)) { _setSelecting(false); } }, const SingleActivator(LogicalKeyboardKey.keyF, control: true): () => _openFilter(_panes[_active]), const SingleActivator(LogicalKeyboardKey.backspace): () => _button(ExplorerButton.up), // 75: 지금 창의 뒤로 · 앞으로 const SingleActivator(LogicalKeyboardKey.arrowLeft, alt: true): () => _travel(_panes[_active], forward: false), const SingleActivator(LogicalKeyboardKey.arrowRight, alt: true): () => _travel(_panes[_active], forward: true), const SingleActivator(LogicalKeyboardKey.tab): () => setState(() => _active = _dual ? 1 - _active : 0), }; 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(_active, ev), child: Scaffold( body: SwipeNav( current: widget.rsync ? 'rsync' : 'explorer', child: ListenableBuilder( listenable: c, builder: (context, _) { _dropRemovedServers(); // 80 보충: 화면에서 사라진 폴더를 위해 걸어 둔 자동 크기 계산은 멈추고 뺀다 _sizes.retainAuto([for (final x in _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: !_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: _transferPanel()), ]), ), ]); }, ), ), ), ), ), ); } /// 지금 폴더 · 그 위 폴더들 (창의 맨 위 [pane.root] 까지, 가까운 것부터) List _ancestors(_Pane 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), middle: Row(children: [ Text(widget.rsync ? 'Rsync' : tr('파일 탐색기'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), const SizedBox(width: 12), // X-plore 경로 막대 (4.4x): 경로를 누르면 위 폴더들 (창의 맨 위까지) 이 나와 골라 간다 Expanded( child: !_ready ? const SizedBox.shrink() : PopupMenuButton( tooltip: tr('위 폴더로 가기'), position: PopupMenuPosition.under, onSelected: (d) => _goTo(_pane, d), itemBuilder: (_) => [ for (final d in _ancestors(_pane)) PopupMenuItem( value: d, enabled: !samePath(d, _pane.current), child: Text(_displayPath(d), maxLines: 1, overflow: TextOverflow.ellipsis), ), ], child: Text(_displayPath(_pane.current), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: JjColors.textDim)), ), ), PopupMenuButton( tooltip: tr('창 배치 · 버튼 구성'), icon: const Icon(Icons.more_vert), onSelected: (v) => v is ExplorerButton ? _button(v) : _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(_showAppHidden ? Icons.visibility_off_outlined : Icons.visibility_outlined, size: 18), const SizedBox(width: 12), Flexible( child: Text(_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 (_ready && shape != _shape) { if (_shape.isNotEmpty) { for (final pane in _panes) { _reveal(pane, pane.current); } } _shape = shape; } final dual = _dual; final split = _split; return LayoutBuilder(builder: (context, box) { // 폭이 바뀌면 (좁은 한 창 ↔ 두 창) 이 builder 만 다시 불린다 - 그때도 새로 붙은 창을 보이게 if (_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; final bar = s.explorerToolbar == 'hidden' && !widget.rsync ? null : _Toolbar( // X-plore b: 길게 누르기 = 짝 동작 (Rsync 화면은 없음) onLongPress: widget.rsync ? null : _buttonLong, // Rsync 화면: 늘 같은 버튼 (→ ← ⇄ 모니터링) 을 두 창 사이에 buttons: widget.rsync ? ExplorerButton.rsyncButtons : ExplorerButton.fromSettings(s.explorerButtons), vertical: side && !rsyncOne, panesSide: side, onPressed: _button, enabled: (b) => switch (b) { ExplorerButton.paste => _clip.isNotEmpty, _ => true, }, selected: (b) => (b == ExplorerButton.hidden && s.explorerShowHidden) || (b == ExplorerButton.select && _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 = _active.clamp(0, 1); return Column(children: [ _rsyncPaneSwitch(), ?bar, Expanded(child: keep(i, _paneView(i, foldersOnly: true))), ]); } final children = [ if (dual) ...[ Expanded(child: keep(0, _paneView(0, foldersOnly: widget.rsync))), if (middle) bar, Expanded(child: keep(1, _paneView(1, foldersOnly: widget.rsync))), if (edge) bar, ] else if (split) ...[ // 왼쪽 폴더 트리 (좁게) · 오른쪽 그 폴더의 파일 목록 Expanded(flex: 2, child: keep(0, _paneView(0, foldersOnly: true))), if (middle) bar, Expanded(flex: 3, child: keep(2, _listView(_panes[0]))), if (edge) bar, ] else ...[ if (middle) bar, Expanded( child: _hiddenTarget ? Column(children: [ListenableBuilder(listenable: _panes[1], builder: (_, _) => _targetBar()), Expanded(child: keep(0, _paneView(0)))]) : keep(0, _paneView(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: _active == k, onSelected: (_) => setState(() => _active = k), label: SizedBox( width: double.infinity, child: Text( '$side · ${vBasename(_panes[k].marked.isNotEmpty ? _panes[k].marked.first : _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 >= _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; _travel(_panes[i], forward: !back); } Widget _frame(int i, List children) { final active = i == _active; return Listener( // 75: 마우스가 올라간 창에 (지금 활성 창이 아니어도) onPointerDown: (ev) => _mouseTravel(i, ev), child: GestureDetector( onTapDown: (_) { if (_active != i) setState(() => _active = i); }, // 82: 창의 빈 곳에 놓으면 그 창의 지금 폴더로. 놓을 수 있으면 강조, 못 놓으면 (같은 창 등) 빨간 테두리 child: DragTarget( onWillAcceptWithDetails: (d) => canDropInto(d.data, i, _panes[i].current), onAcceptWithDetails: (d) => unawaited(_onDrop(d.data, i, _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(_Pane pane, FileEntry e) => _isMarked(pane, e.path) && pane.marked.isNotEmpty ? outermostPaths(pane.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) || _archives.containsKey(dest)) { _snack(tr('압축 안은 읽기 전용입니다 (꺼내기만 할 수 있습니다)')); return; } if (load.paths.any(_insideArchive)) { await _extractFromArchive(load.paths, dest); return; } setState(() => _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(_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); ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar(SnackBar( duration: const Duration(seconds: 10), content: Text([ trf('{0}개를 옮겼습니다.', [moved]), if (notUndoable > 0) trf('합친 폴더 {0}개는 무엇을 옮겼는지 몰라 되돌리지 않습니다.', [notUndoable]), if (overwritten > 0) trf('같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.', [overwritten]), ].join(' ')), action: SnackBarAction(label: tr('되돌리기'), onPressed: () => unawaited(_undoMove(_panes[load.from], back))), )); } /// 82: 되돌리기 - 원래 폴더마다 지금의 이동으로 (같은 이름이 생겼으면 지금의 같은 이름 창) Future _undoMove(_Pane 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); } } /// 선택 모드 막대: 고른 수 · 모두 고르기 · 선택 끝 Widget _marksBar(_Pane pane, {String? listDir}) { final dir = listDir ?? pane.current; // 74: 고른 개수와 합계 크기 (읽어 둔 목록 기준, 폴더 크기는 빼고) var size = 0, folders = 0; final byPath = {for (final l in pane.cache.values) for (final x in l) x.path: x}; for (final m in pane.marked) { final x = byPath[m]; if (x == null) continue; if (x.isDir) { folders++; } else { size += x.size; } } final focusedExt = pane.focused == null || byPath[pane.focused]?.isDir != false ? '' : extOf(pane.focused!); return Container( color: JjColors.panel, padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), child: Row(children: [ Icon(Icons.checklist, size: 18, color: JjColors.accent), const SizedBox(width: 6), Flexible( child: Text( [ trf('{0}개 표시함', [pane.marked.length]), if (size > 0) formatSize(size), if (folders > 0) trf('폴더 {0}개 (크기 빼고)', [folders]), ].join(' · '), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12)), ), const Spacer(), PopupMenuButton( tooltip: tr('고르기 도구'), onSelected: (v) { switch (v) { case 'invert': _invertMarks(pane, dir); case 'pattern': _markPattern(pane, dir); case 'sameExt': _markWhere(pane, dir, (x) => !x.isDir && extOf(x.path) == focusedExt); } }, itemBuilder: (_) => [ PopupMenuItem(value: 'invert', child: Text(tr('반전 (고른 것 ↔ 안 고른 것)'))), PopupMenuItem(value: 'pattern', child: Text(tr('패턴으로 고르기 (예: *.srt)'))), PopupMenuItem( value: 'sameExt', enabled: focusedExt.isNotEmpty, child: Text(focusedExt.isEmpty ? tr('같은 확장자 모두') : trf('같은 확장자 모두 (.{0})', [focusedExt])), ), ], child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), child: Row(mainAxisSize: MainAxisSize.min, children: [ Text(tr('고르기 도구'), style: const TextStyle(fontSize: 13, color: JjColors.accent)), const Icon(Icons.arrow_drop_down, size: 18, color: JjColors.accent), ]), ), ), TextButton( onPressed: () => setState(() { final dir = listDir ?? pane.current; pane.marked.addAll([for (final x in _shown(pane, dir)) x.path]); _normalizeMarks(pane); }), child: Text(tr('모두 선택')), ), TextButton(onPressed: () => setState(pane.marked.clear), child: Text(tr('표시 지우기'))), TextButton(onPressed: () => _setSelecting(false), child: Text(tr('선택 끝'))), ]), ); } /// 트리 창 (저장 장치 고르기 + 트리) Widget _paneView(int i, {bool foldersOnly = false}) { final pane = _panes[i]; final rows = _rows(pane, foldersOnly: foldersOnly); final look = _look; // 38: 창 높이를 알아야 안내 상자의 높이를 제한할 수 있다 return LayoutBuilder(builder: (context, box) => _frame(i, [ _paneHeader(pane), const Divider(height: 1), // 보이는 수는 맞는 것만 (맞는 것이 든 위 폴더는 빼고) if (pane.filterOpen) _filterBar(pane, rows.where((r) => !r.isRoot && isSameOrInside(r.entry.path, pane.current) && !samePath(r.entry.path, pane.current) && quickFilterMatch(vBasename(r.entry.path), pane.filter)).length), // 38: 안내가 길어도 목록을 덮지 않게 (창 높이의 35% 까지, 넘치면 넘겨 봄) ?_noticeArea([_accessNotice(pane), _errorNotice(pane, pane.current), _partialNotice(pane, pane.current)], box.maxHeight), if (look.columns && !foldersOnly) ExplorerColumnsHeader( style: look, markColumn: _selecting, sortBy: _sortOf(pane.current).$1, sortDesc: _sortOf(pane.current).$2, onSort: (by) => _sortByColumn(pane, by), ), Expanded( child: Stack(children: [ ListView.builder( controller: pane.scroll, itemCount: rows.length, itemExtent: look.rowHeightOf(context), // 76: 줄 위젯은 항목을 따라간다 (아래 [_rowKey]) itemBuilder: (_, k) => KeyedSubtree( key: _rowKey(rows[k]), child: rows[k].kind != 0 ? _extraRow(pane, rows[k]) : _rowView(pane, rows[k], compact: foldersOnly)), findChildIndexCallback: _rowIndexOf(rows), ), // 읽는 중 (WebDAV 서버를 기다릴 때 등). 180: 목록 위에 겹쳐 그린다 (끼어들면 목록이 밀려 다음 것을 누르려다 엉뚱한 것을 누른다) if (pane.loading.isNotEmpty) const Positioned(left: 0, right: 0, top: 0, child: LinearProgressIndicator(minHeight: 2)), ]), ), if ((_selecting || (_alwaysMarks && pane.marked.isNotEmpty)) && !foldersOnly) _marksBar(pane), ])); } /// 76: 목록 줄의 키 = 항목 경로. 키가 없으면 줄 목록이 바뀔 때 (빠른 필터 · 펼치기 · 새로 고침) 같은 자리의 줄 위젯을 /// 다른 항목에 다시 써서, 방금 누른 파일 줄이 폴더 줄로 바뀌면 두 번 누르기 기다림이 그리는 중에 버려지고 /// 그 누르기가 다른 항목에 들어갔다 (디버그에서는 "setState() called during build") static Key _rowKey(_Row r) => ValueKey('${r.kind != 0 ? 'k${r.kind}:' : r.isRoot ? 'root:' : r.isUp ? 'up:' : ''}${r.entry.path}'); /// 76: 줄이 옮겨 가도 같은 줄 위젯을 찾게 (키 → 지금 자리) static int? Function(Key) _rowIndexOf(List<_Row> rows) { Map? index; return (key) => (index ??= {for (var i = 0; i < rows.length; i++) _rowKey(rows[i]): i})[key]; } /// "폴더 + 파일 목록" 배치의 오른쪽: 왼쪽 트리에서 고른 폴더의 내용 (맨 위 ".." = 상위 폴더) Widget _listView(_Pane pane) { final dir = pane.current; final entries = pane.cache[dir] ?? const []; final up = !samePath(dir, pane.root); final shown = _shown(pane, dir); final hidden = entries.where((e) => _appHidden(e.path)).length; final rows = [ if (up) _Row(FileEntry(vDirname(dir), isDir: true, modified: DateTime(0)), 0, isUp: true), for (final e in shown) _Row(e, 0), // X-plore 숨기기: 맨 아래 [숨긴 항목 n개 보이기] if (hidden > 0) _Row(FileEntry('\u0000hidden', isDir: false, modified: DateTime(0)), 0, kind: _Row.hiddenToggle, count: hidden), ]; final look = _look; // 38: 창 높이를 알아야 안내 상자의 높이를 제한할 수 있다 return LayoutBuilder(builder: (context, box) => _frame(0, [ Container( height: 40, alignment: Alignment.centerLeft, padding: const EdgeInsets.symmetric(horizontal: 12), child: Text(_displayPath(dir), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: JjColors.textDim)), ), const Divider(height: 1), if (pane.filterOpen) _filterBar(pane, shown.length), ?_noticeArea([_accessNotice(pane), _errorNotice(pane, dir), _partialNotice(pane, dir)], box.maxHeight), if (look.columns) ExplorerColumnsHeader( style: look, markColumn: _selecting, sortBy: _sortOf(dir).$1, sortDesc: _sortOf(dir).$2, onSort: (by) => _sortByColumn(pane, by), ), Expanded( // 180: 다시 읽는 동안은 앞의 목록을 그대로 (빙글이로 바꾸면 목록이 새로 만들어져 스크롤이 맨 위로 간다) child: pane.loading.contains(dir) && !pane.cache.containsKey(dir) ? const Center(child: CircularProgressIndicator()) : entries.isEmpty && !up ? Center(child: Text(tr('비어 있음'), style: const TextStyle(color: JjColors.textDim))) : ListView.builder( controller: pane.listScroll, itemCount: rows.length, itemExtent: look.rowHeightOf(context), itemBuilder: (_, k) => KeyedSubtree( key: _rowKey(rows[k]), child: rows[k].kind != 0 ? _extraRow(pane, rows[k]) : _rowView(pane, rows[k], list: true)), findChildIndexCallback: _rowIndexOf(rows), ), ), if (_selecting) _marksBar(pane, listDir: dir), ])); } /// 76: 빠른 필터 열기 ([text] 가 있으면 그 글자로 시작) void _openFilter(_Pane pane, [String? text]) { setState(() { _active = _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(_Pane pane) { setState(() { pane ..filterOpen = false ..filterCtl.clear(); }); _reveal(pane, pane.focused ?? pane.current); _keys.requestFocus(); } /// 83: 지금 스타일이 Total Commander 인지 (Rsync 화면은 스타일 단축키를 쓰지 않는다 - 늘 같은 버튼) bool get _tcKeys => !widget.rsync && _look == ExplorerStyle.totalcmd; /// 83: 스타일에 맞는 단축키 Map _styleKeys() { if (widget.rsync) return {const SingleActivator(LogicalKeyboardKey.f5): () => _button(ExplorerButton.refresh)}; if (_tcKeys) { return { const SingleActivator(LogicalKeyboardKey.f5): () => _button(ExplorerButton.copy), const SingleActivator(LogicalKeyboardKey.f6): () => _button(ExplorerButton.move), const SingleActivator(LogicalKeyboardKey.f7): () => _button(ExplorerButton.newFolder), const SingleActivator(LogicalKeyboardKey.f8): () => _button(ExplorerButton.delete), const SingleActivator(LogicalKeyboardKey.keyR, control: true): () => _button(ExplorerButton.refresh), // Insert: 표시하고 다음 줄로 · Space: 표시 (TC 처럼) const SingleActivator(LogicalKeyboardKey.insert): () => _markFocused(next: true), const SingleActivator(LogicalKeyboardKey.space): () => _markFocused(next: false), // 81: 빠른 보기 (TC 의 보기 키) const SingleActivator(LogicalKeyboardKey.f3): () => _quickLook(_pane), }; } return { // 81: 빠른 보기 (Windows · macOS 처럼 Space) const SingleActivator(LogicalKeyboardKey.space): () => _quickLook(_pane), const SingleActivator(LogicalKeyboardKey.f5): () => _button(ExplorerButton.refresh), const SingleActivator(LogicalKeyboardKey.keyC, control: true): () => _toClipboard(_targets(_pane), move: false), const SingleActivator(LogicalKeyboardKey.keyX, control: true): () => _toClipboard(_targets(_pane), move: true), const SingleActivator(LogicalKeyboardKey.keyV, control: true): () => _paste(_pane, _pane.current), const SingleActivator(LogicalKeyboardKey.keyA, control: true): () { if (!_selecting) setState(() => _selecting = true); _markWhere(_pane, _pane.current, (_) => true); }, }; } /// 81: 빠른 보기 - 지금 줄 (없으면 아무것도 안 함). 같은 폴더에 보이는 항목을 ← → 로 Future _quickLook(_Pane pane, [String? path]) async { final f = path ?? pane.focused; if (f == null || samePath(f, pane.root)) return; final list = _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) => _BigThumbs.of(c, e), imageExts: c.settings.imageExts, onOpen: (e) => e.isDir ? unawaited(_goTo(pane, e.path)) : unawaited(_open(e.path, pane: pane)), ), ); } /// 83: (TC) 지금 줄을 표시 · 풀기, [next] 면 다음 줄로 Future _markFocused({required bool next}) async { final pane = _pane; final f = pane.focused; if (f == null || samePath(f, pane.root)) return; final list = _shown(pane, vDirname(f)); final i = list.indexWhere((x) => samePath(x.path, f)); if (i < 0) return; if (!_selecting) setState(() => _selecting = true); await _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 && !_panes[_active].filterOpen) return KeyEventResult.ignored; if (kb.isControlPressed || kb.isAltPressed || kb.isMetaPressed) return KeyEventResult.ignored; // 글자 칸 (필터 · 경로 입력 등) 에 치는 중이면 그 칸이 받는다 if (_typing) return KeyEventResult.ignored; if (!_ready || _panes.isEmpty) return KeyEventResult.ignored; final pane = _panes[_active]; _openFilter(pane, pane.filterOpen ? pane.filterCtl.text + ch : ch); return KeyEventResult.handled; } /// 76: 창 위 줄 아래의 빠른 필터 칸 - 보이는 수 · 맞는 것이 없을 때 안내 · [✕] Widget _filterBar(_Pane pane, int shown) { final none = pane.filter.isNotEmpty && shown == 0; return Container( height: 40, color: JjColors.panel, padding: const EdgeInsets.symmetric(horizontal: 8), child: Row(children: [ Icon(Icons.filter_alt, size: 16, color: JjColors.accent), const SizedBox(width: 6), Expanded( child: CallbackShortcuts( bindings: {const SingleActivator(LogicalKeyboardKey.escape): () => _closeFilter(pane)}, child: TextField( controller: pane.filterCtl, focusNode: pane.filterFocus, style: const TextStyle(fontSize: 13), decoration: InputDecoration( isDense: true, border: InputBorder.none, hintText: tr('지금 폴더에서 이름에 든 글자로 좁히기 (* · ? 패턴도 됨)'), ), onChanged: (_) => setState(() {}), ), ), ), if (pane.filter.isNotEmpty) Text(none ? tr('맞는 것이 없습니다') : trf('{0}개 보임', [shown]), style: TextStyle(fontSize: 12, color: none ? JjColors.danger : JjColors.textDim)), IconButton( tooltip: keyHint(tr('필터 닫기 (모두 보기)'), 'Esc'), visualDensity: VisualDensity.compact, icon: const Icon(Icons.close, size: 16), onPressed: () => _closeFilter(pane), ), ]), ); } /// 창 위쪽: 저장 장치 고르기 /// 창 위 줄: [뒤로 · 앞으로 · 즐겨찾기] · 저장 장치 탭 (넘치면 옆으로 밀기) · [WebDAV · 경로 입력]. /// 202: 드라이브가 많아 넘쳐도 끝의 버튼은 늘 보이게 (예전에는 줄 전체가 한 목록이라 화면 밖으로 밀려 누를 수 없었다) /// 좁은 창 (위아래 두 창 · 폰, [paneHeaderWideWidth] 미만) 은 앞 버튼도 탭과 함께 밀고, 끝 버튼은 [⋮] 하나로 (늘 보임) Widget _paneHeader(_Pane pane) => SizedBox( height: 40, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), child: LayoutBuilder( builder: (context, box) => box.maxWidth >= paneHeaderWideWidth ? Row(children: [ ..._headerStart(pane), Expanded(child: _driveTabs(pane)), ..._headerEnd(pane), ]) : Row(children: [ Expanded(child: _driveTabs(pane, lead: _headerStart(pane))), _headerMenu(pane), ]), ), ), ); /// 202: 좁은 창의 끝 버튼 - WebDAV 서버 추가 · 경로 입력 Widget _headerMenu(_Pane pane) => PopupMenuButton( tooltip: tr('경로 입력 · 네트워크 서버'), icon: const Icon(Icons.more_vert, size: 18), padding: EdgeInsets.zero, onSelected: (v) => switch (v) { 'path' => _enterPath(pane), 'sftp' => _editSftp(pane, null), 'smb' => _editSmb(pane, null), 'ftp' => _editFtp(pane, null), _ => _editDav(pane, null), }, itemBuilder: (_) => [ PopupMenuItem( value: 'path', child: ListTile(dense: true, leading: const Icon(Icons.edit_location_alt_outlined), title: Text(tr('경로 입력')))), PopupMenuItem( value: 'dav', child: ListTile(dense: true, leading: const Icon(Icons.cloud_outlined), title: Text(tr('WebDAV 서버 추가')))), PopupMenuItem( value: 'sftp', child: ListTile(dense: true, leading: const Icon(Icons.vpn_key_outlined), title: Text(tr('SFTP 서버 추가')))), PopupMenuItem( value: 'ftp', child: ListTile(dense: true, leading: const Icon(Icons.folder_shared_outlined), title: Text(tr('FTP 서버 추가')))), // 88: SMB 는 Android 만 (Windows 는 경로 입력에 \\서버\공유) if (ExplorerPage.debugAndroidPaths ?? Platform.isAndroid) PopupMenuItem( value: 'smb', child: ListTile(dense: true, leading: const Icon(Icons.dns_outlined), title: Text(tr('SMB 서버 추가')))), ], ); /// 창 위 줄 앞: 뒤로 · 앞으로 · 즐겨찾기 List _headerStart(_Pane pane) => [ // 75: 이 창의 뒤로 · 앞으로 (가 본 폴더) IconButton( tooltip: keyHint(tr('뒤로'), 'Alt+←'), visualDensity: VisualDensity.compact, icon: const Icon(Icons.arrow_back, size: 16), onPressed: pane.back.isEmpty ? null : () => _travel(pane, forward: false), ), IconButton( tooltip: keyHint(tr('앞으로'), 'Alt+→'), visualDensity: VisualDensity.compact, icon: const Icon(Icons.arrow_forward, size: 16), onPressed: pane.forward.isEmpty ? null : () => _travel(pane, forward: true), ), // 73: 즐겨찾기 (창 위에서 한 번에 - 드라이브가 많아도 보이게 맨 앞) IconButton( tooltip: tr('즐겨찾기'), visualDensity: VisualDensity.compact, icon: Icon( c.settings.folderBookmarks.any((b) => !b.isPair && samePath(b.path, pane.current)) ? Icons.star : Icons.star_outline, size: 16, color: Colors.amber), onPressed: () => _bookmarks(pane), ), // 76: 빠른 필터 (켜져 있으면 강조) IconButton( tooltip: keyHint(tr('빠른 필터'), 'Ctrl+F'), visualDensity: VisualDensity.compact, isSelected: pane.filterOpen, icon: Icon(pane.filterOpen ? Icons.filter_alt : Icons.filter_alt_outlined, size: 16, color: pane.filterOpen ? JjColors.accent : null), onPressed: () => pane.filterOpen ? _closeFilter(pane) : _openFilter(pane), ), ]; /// 202: 저장 장치 탭 - 넘치면 마우스 휠 · 끌기로도 옆으로. 비어 있거나 연결 안 된 드라이브는 숨기지 않고 뒤쪽으로 Widget _driveTabs(_Pane pane, {List lead = const []}) { final all = [ ..._volumes, // 37: 지금 열어 둔 네트워크 공유 (\\NAS\공유) 도 탭으로 for (final x in _panes) if (Platform.isWindows && x.root.startsWith(r'\\') && !_volumes.any((v) => samePath(v.$1, x.root))) (x.root, x.root), ]; final tabs = [ ...all.where((v) => _driveOk[v.$1] != false || samePath(pane.root, v.$1)), ...all.where((v) => _driveOk[v.$1] == false && !samePath(pane.root, v.$1)), ]; return Listener( // 마우스 휠 (위 · 아래) 을 옆으로 onPointerSignal: (e) { if (e is! PointerScrollEvent || !pane.tabScroll.hasClients) return; final pos = pane.tabScroll.position; final d = e.scrollDelta.dx != 0 ? e.scrollDelta.dx : e.scrollDelta.dy; pane.tabScroll.jumpTo((pos.pixels + d).clamp(0.0, pos.maxScrollExtent)); }, child: ScrollConfiguration( // 마우스로 끌어도 옆으로 (데스크톱은 기본으로 마우스 끌기가 꺼져 있음) behavior: ScrollConfiguration.of(context).copyWith( dragDevices: {PointerDeviceKind.touch, PointerDeviceKind.mouse, PointerDeviceKind.trackpad, PointerDeviceKind.stylus}, scrollbars: false, ), child: ListView( controller: pane.tabScroll, scrollDirection: Axis.horizontal, children: [ ...lead, for (final (path, label) in tabs) Padding( padding: const EdgeInsets.only(right: 4), child: TextButton.icon( style: TextButton.styleFrom( visualDensity: VisualDensity.compact, padding: const EdgeInsets.symmetric(horizontal: 10), backgroundColor: samePath(pane.root, path) ? JjColors.accent.withValues(alpha: 0.18) : null, foregroundColor: samePath(pane.root, path) ? JjColors.accent : null, ), icon: Icon( isSftp(path) ? Icons.vpn_key_outlined : isFtp(path) ? Icons.folder_shared_outlined : isSmb(path) ? Icons.dns_outlined : isDav(path) ? Icons.cloud_outlined : _networkDrives.contains(path) ? Icons.lan_outlined : Platform.isWindows ? Icons.storage : Icons.sd_storage_outlined, size: 16), // 165: 열 수 없는 드라이브는 숨기지 않고 "연결 안 됨" - 누르면 다시 연결 (다시 읽기) label: _driveOk[path] == false ? Text('$label · ${_removableDrives.contains(path) ? tr('비어 있음') : tr('연결 안 됨')} ⟳', style: TextStyle(color: _removableDrives.contains(path) ? JjColors.textDim : Colors.orangeAccent)) : Text(label), onPressed: () { setState(() => _active = _panes.indexOf(pane)); if (_driveOk[path] == false) { unawaited(_reconnect(pane, path)); } else { _goTo(pane, path); } }, onLongPress: isSftp(path) ? () => _editSftp(pane, path) : isFtp(path) ? () => _editFtp(pane, path) : isSmb(path) ? () => _editSmb(pane, path) : isDav(path) ? () => _editDav(pane, path) : null, ), ), ], ), ), ); } /// 202: 창 위 줄 끝 - 탭이 넘쳐도 늘 보이는 버튼 (WebDAV 서버 추가 · 경로 입력) List _headerEnd(_Pane pane) => [ if (c.settings.webdavServers.isEmpty) TextButton.icon( style: TextButton.styleFrom( visualDensity: VisualDensity.compact, padding: const EdgeInsets.symmetric(horizontal: 10), ), icon: const Icon(Icons.cloud_outlined, size: 16), label: const Text('WebDAV'), onPressed: () => _editDav(pane, null), ) else IconButton( tooltip: tr('WebDAV 서버 추가'), visualDensity: VisualDensity.compact, icon: const Icon(Icons.add, size: 16), onPressed: () => _editDav(pane, null), ), // 37: 경로를 직접 넣어 가기 (Windows 는 \\NAS\공유 도) // 88: SFTP 서버 추가 IconButton( tooltip: tr('SFTP 서버 추가'), visualDensity: VisualDensity.compact, icon: const Icon(Icons.vpn_key_outlined, size: 16), onPressed: () => _editSftp(pane, null), ), // l: FTP · FTPS 서버 추가 IconButton( tooltip: tr('FTP 서버 추가'), visualDensity: VisualDensity.compact, icon: const Icon(Icons.folder_shared_outlined, size: 16), onPressed: () => _editFtp(pane, null), ), IconButton( tooltip: tr('경로 입력'), visualDensity: VisualDensity.compact, icon: const Icon(Icons.edit_location_alt_outlined, size: 16), onPressed: () => _enterPath(pane), ), ]; /// 경로를 직접 넣어 가기 (37). 146 · 37-2: 없는 폴더면 창을 닫지 않고 칸 아래에 알린다 (고쳐서 다시 넣을 수 있게) Future _enterPath(_Pane pane) async { setState(() => _active = _panes.indexOf(pane)); final path = await showDialog(context: context, builder: (_) => _PathDialog(initial: pane.current)); if (path == null || !mounted) return; // 179: SMB 를 넣었다가 [WebDAV 서버 추가] 를 누름 if (path == _PathDialog.addDav) return _editDav(pane, null); // 88: Android 에서 smb:// · \\서버\공유 를 넣고 [SMB 서버 추가] if (path.startsWith(_PathDialog.addSmb)) return _editSmb(pane, null, from: path.substring(_PathDialog.addSmb.length)); await _goTo(pane, path, fresh: true); } /// 146: 넣은 경로를 고쳐 쓰기 · 확인. 갈 수 있으면 그 경로, 아니면 칸 아래에 보일 이유 static Future<(String?, String?)> checkEnteredPath(String input) async { var path = input.trim().replaceAll('"', ''); if (path.isEmpty) return (null, null); // 179: Android 는 네트워크 공유 (SMB) 를 아직 못 연다 - 폴더가 없다고 하지 않고 WebDAV 로 안내 if ((ExplorerPage.debugAndroidPaths ?? Platform.isAndroid) && looksLikeSmb(path)) return (null, smbNotSupported); // 190: Windows 는 smb:// · cifs:// 주소를 \\서버\공유 로 바꿔 연다 (주소가 틀린 줄 알지 않게) final unc = Platform.isWindows ? smbToUnc(path) : null; if (unc != null) path = unc; if (Platform.isWindows && RegExp(r'^\\\\[^\\/]+[\\/]?$').hasMatch(path)) { return (null, tr('네트워크 공유는 공유 폴더 이름까지 넣으세요 (예: \\\\NAS\\영상).')); } if (!isDav(path) && Platform.isWindows && RegExp(r'^[a-zA-Z]:$').hasMatch(path)) path = '$path\\'; bool ok; try { ok = isDav(path) || await Directory(path).exists(); } catch (_) { ok = false; } if (!ok && unc != null) { return (null, trf('폴더가 없거나 열 수 없습니다. Windows 의 네트워크 공유는 \\\\서버\\공유 형식입니다 (바꾼 경로: {0})', [unc])); } return ok ? (path, null) : (null, tr('폴더가 없거나 열 수 없습니다')); } /// WebDAV 서버 추가 (없으면 [root] 는 null) · 고치기 (탭 길게 누르기). 저장하면 그 서버로 간다. Future _editDav(_Pane pane, String? root) async { setState(() => _active = _panes.indexOf(pane)); final old = root == null ? null : DavRegistry.server(DavPath.parse(root).server); final saved = await editDavServer(context, c, old: old); if (saved == null || !mounted) return; for (final x in _panes) { x.cache.removeWhere((k, _) => isDav(k) && DavPath.parse(k).server == saved.id); } await _goTo(pane, '$davScheme${saved.id}/'); } /// 88: SFTP 서버 추가 (없으면 [root] 는 null) · 고치기 (탭 길게 누르기). 저장하면 그 서버로 간다. Future _editSftp(_Pane pane, String? root) async { setState(() => _active = _panes.indexOf(pane)); final old = root == null ? null : SftpRegistry.server(DavPath.parse(root).server); final saved = await editSftpServer(context, c, old: old); if (saved == null || !mounted) return; for (final x in _panes) { x.cache.removeWhere((k, _) => isSftp(k) && DavPath.parse(k).server == saved.id); x.hostKeyErrors.removeWhere((k, _) => isSftp(k) && DavPath.parse(k).server == saved.id); } await _goTo(pane, '$sftpScheme${saved.id}/'); } /// l: FTP 서버 추가 (없으면 [root] 는 null) · 고치기 (탭 길게 누르기). 저장하면 그 서버로 간다. Future _editFtp(_Pane pane, String? root) async { setState(() => _active = _panes.indexOf(pane)); final old = root == null ? null : FtpRegistry.server(DavPath.parse(root).server); final saved = await editFtpServer(context, c, old: old); if (saved == null || !mounted) return; for (final x in _panes) { x.cache.removeWhere((k, _) => isFtp(k) && DavPath.parse(k).server == saved.id); x.certErrors.removeWhere((k, _) => isFtp(k) && DavPath.parse(k).server == saved.id); } await _goTo(pane, '$ftpScheme${saved.id}/'); } /// 88: SMB 서버 추가 (없으면 [root] 는 null, [from] 은 경로 입력에 넣은 주소) · 고치기 (탭 길게 누르기). 저장하면 그 서버로 간다. Future _editSmb(_Pane pane, String? root, {String? from}) async { setState(() => _active = _panes.indexOf(pane)); final old = root == null ? null : SmbRegistry.server(DavPath.parse(root).server); final saved = await editSmbServer(context, c, old: old, from: from); if (saved == null || !mounted) return; for (final x in _panes) { x.cache.removeWhere((k, _) => isSmb(k) && DavPath.parse(k).server == saved.id); } await _goTo(pane, '$smbScheme${saved.id}/'); } /// 한 줄. [list]: "폴더 + 파일 목록" 의 오른쪽 목록 (트리 아님). [compact]: 폴더 트리 (열 · 표시 버튼 없이) Widget _rowView(_Pane pane, _Row row, {bool list = false, bool compact = false}) { final e = row.entry; final look = _look; final isCurrent = e.isDir && !list && !row.isUp && samePath(pane.current, e.path); final isFocused = !row.isUp && pane.focused != null && samePath(pane.focused!, e.path); final marked = _isMarked(pane, e.path); final open = (e.isDir || _archives.containsKey(e.path)) && !list && pane.expanded.contains(e.path); final loading = pane.loading.contains(e.path); final name = row.isUp ? '..' : row.isRoot ? (_volumes.firstWhere((v) => samePath(v.$1, e.path), orElse: () => (e.path, e.path)).$2) : look == ExplorerStyle.totalcmd ? totalCmdName(e) : e.name; final dim = TextStyle(fontSize: look.fontSize - 2, color: JjColors.textDim); final nameStyle = TextStyle( fontSize: look.fontSize, fontWeight: isCurrent || (look == ExplorerStyle.totalcmd && e.isDir) ? FontWeight.w600 : FontWeight.normal, color: isCurrent ? JjColors.accent : null, ); final chevronWidth = treeChevronWidth(defaultTargetPlatform); // 트리의 펼치기 화살표 (따로 누름). 189: 접기 · 펼치기는 이것으로만 하므로 줄 높이 전체를 누를 수 있게, // 손가락으로 누르는 기기 (Android) 는 폭도 넓게 Widget chevron() => SizedBox( width: chevronWidth, height: look.rowHeightOf(context), child: (e.isDir || _canBrowseArchive(e)) && !list && !row.isUp ? (loading ? const Center( child: SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 1.5))) // 241: TalkBack 이 읽는 이름 ("… 펼치기" · "… 접기") : Semantics( button: true, label: open ? trf('{0} 접기', [name]) : trf('{0} 펼치기', [name]), excludeSemantics: true, onTap: () => _toggle(pane, e), child: InkResponse( radius: chevronWidth / 2 + 4, containedInkWell: false, onTap: () => _toggle(pane, e), child: Center( child: Icon(open ? Icons.expand_more : Icons.chevron_right, size: 18, color: JjColors.textDim)), ), )) : null, ); // 좁은 창 (폰 세로의 좌우 두 창) 에서 깊은 폴더: 들여쓰기는 이름 자리를 남기는 만큼만 (넘치지 않게) Widget nameCell() => LayoutBuilder(builder: (context, box) { final fixed = (list ? 0 : chevronWidth) + (look.rich ? 64 : look.iconSize + 12) + 48.0; final indent = list ? 0.0 : (row.depth * look.indent).clamp(0.0, (box.maxWidth - fixed).clamp(0.0, double.infinity)); return Row(children: [ SizedBox(width: indent), if (!list) chevron(), SizedBox( width: look.rich ? 56 : look.iconSize + 6, height: look.rowHeightOf(context) - 8, child: Center(child: _icon(e, row.isRoot, open, up: row.isUp)), ), SizedBox(width: look.rich ? 8 : 6), Expanded( child: look.rich && !e.isDir && !row.isUp ? Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ Text(name, maxLines: 1, overflow: TextOverflow.ellipsis, style: nameStyle), // 좁은 화면 · 깊은 폴더에서 넘치지 않게. 960 캡처 (10/18): 동영상 정보와 한 줄 글로 (둘로 나누면 정보가 비어도 // 폭의 반을 차지해 넓은 창에서도 날짜가 잘렸다) isVideoFile(e.path) ? _MetaText(c: c, path: e.path, lead: '${formatSize(e.size)} · ${_date(e.modified)}', leadStyle: dim) : Text('${formatSize(e.size)} · ${_date(e.modified)}', style: dim, maxLines: 1, overflow: TextOverflow.ellipsis), ], ) : Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ Text(name, maxLines: 1, overflow: TextOverflow.ellipsis, style: nameStyle), if (look.rich && e.isDir && pane.errors[e.path] != null) Text(tr('읽을 수 없음'), style: dim.copyWith(color: Colors.redAccent)) // 80: 열 없는 모양 (X-plore · 좁은 화면) 은 폴더 이름 아래에 크기 else if (e.isDir && !row.isRoot && !row.isUp && (!look.columns || compact) && _dirSize(pane, e) != null) Text(_dirSize(pane, e)!, style: dim, maxLines: 1), ], ), ), ]); }); final real = !row.isRoot && !row.isUp; Widget cell(String t, int flex, {TextAlign align = TextAlign.start}) => Expanded( flex: flex, child: Text(t, style: dim, textAlign: align, maxLines: 1, overflow: TextOverflow.ellipsis), ); final cells = !look.columns || compact || !real ? [Expanded(child: nameCell())] : look == ExplorerStyle.windows ? [ Expanded(flex: 6, child: nameCell()), cell(_date(e.modified), 3), cell(typeLabel(e), 2), cell(e.isDir ? (_dirSize(pane, e) ?? '') : formatSize(e.size), 2, align: TextAlign.end), ] : [ Expanded(flex: 6, child: nameCell()), cell(e.isDir ? '' : e.ext, 1), cell(e.isDir ? (_dirSize(pane, e) ?? '') : formatSize(e.size), 2, align: TextAlign.end), const SizedBox(width: 8), cell(_date(e.modified), 3), ]; final paneIndex = _panes.indexOf(pane); final drag = real && !widget.rsync; Widget line = GestureDetector( // Rsync 화면은 고르기만 (파일 기능 메뉴 없음) // 25: Rsync 화면에서도 (폴더 메뉴) onSecondaryTapUp: real ? (d) => _menu(pane, e, d.globalPosition) : null, onLongPressStart: real ? (d) => _menu(pane, e, d.globalPosition) : null, child: Material( color: marked ? JjColors.accent.withValues(alpha: 0.18) : isFocused ? JjColors.panel : Colors.transparent, child: Row(children: [ Expanded( child: InkWell( onTap: () => _onTap(pane, row, list: list), // 선택 모드: 두 번 누르면 열기 (바로 열기 모드는 두 번 누르기를 받지 않음 - 한 번 누르기가 늦어지지 않게) // 폴더는 한 번 누르기로 바로 펼치므로 두 번 누르기를 받지 않는다 (기다리지 않게) onDoubleTap: _selectMode && !_selecting && !e.isDir && !row.isUp ? () => _onOpen(pane, row, list: list) : null, child: Padding( padding: const EdgeInsets.only(left: 6, right: 4), child: Row(children: cells), ), ), ), // 82: 터치로 끌기 - 선택 모드에서 표시한 줄의 ≡ 손잡이 (길게 누르기는 메뉴 그대로) if (drag && _selecting && marked) Draggable( data: DragLoad(paneIndex, _dragPaths(pane, e)), feedback: dragFeedback(_dragPaths(pane, e)), child: Tooltip( message: tr('끌어서 다른 창으로'), child: const SizedBox(width: 28, height: 36, child: Icon(Icons.drag_indicator, size: 18, color: JjColors.textDim)), ), ), // 표시 동그라미는 두 번 누르기 영역 밖 (기다리지 않고 바로) if ((real || (widget.rsync && row.isRoot)) && (_selecting || widget.rsync || _alwaysMarks)) SizedBox( width: 36, child: IconButton( tooltip: widget.rsync ? (marked ? tr('고르기 취소') : tr('이 폴더를 rsync 원본 · 대상으로 고르기')) : marked ? tr('표시 지우기') : tr('표시 (여러 개 고르기)'), visualDensity: VisualDensity.compact, padding: EdgeInsets.zero, // ✔ 모양: 고르지 않은 것은 흐리게, 고른 것은 진하게 icon: Icon(marked ? Icons.check_circle : Icons.check_circle_outline, size: look.rich ? 22 : 18, color: marked ? JjColors.accent : JjColors.textDim.withValues(alpha: 0.35)), onPressed: () { setState(() => _active = _panes.indexOf(pane)); widget.rsync ? _pick(pane, e) : _toggleMark(pane, e); }, ), ) else if (_selecting || widget.rsync || _alwaysMarks) const SizedBox(width: 36), ]), ), ); // X-plore 숨기기: 숨긴 것을 보이고 있을 때 숨긴 줄은 흐리게 if (real && _showAppHidden && _appHidden(e.path)) line = Opacity(opacity: 0.5, child: line); if (!drag) return line; // 82: 폴더 줄 위에 놓으면 그 폴더 안으로 (놓을 수 있으면 강조) if (e.isDir) { final inner = line; line = DragTarget( onWillAcceptWithDetails: (d) => canDropInto(d.data, paneIndex, e.path), onAcceptWithDetails: (d) => unawaited(_onDrop(d.data, paneIndex, e.path)), builder: (context, ok, _) => ok.isEmpty ? inner : DecoratedBox( decoration: BoxDecoration( color: JjColors.accent.withValues(alpha: 0.15), border: Border.all(color: JjColors.accent, width: 1.5), ), child: inner, ), ); } // 82: 마우스로 8px 넘게 끌면 시작 (고르기 · 두 번 누르기는 그대로) final paths = _dragPaths(pane, e); return MouseDraggable(data: DragLoad(paneIndex, paths), feedback: dragFeedback(paths), child: line); } Widget _icon(FileEntry e, bool isRoot, bool open, {bool up = false}) { final look = _look; if (up) return Icon(Icons.arrow_upward, size: look.iconSize, color: JjColors.textDim); if (isRoot) return Icon(isDav(e.path) ? Icons.cloud : Icons.sd_storage, color: JjColors.accent, size: look.iconSize - 2); // X-plore: 동영상 썸네일 · 그림 미리 보기 (WebDAV 는 받아야 하므로 아이콘만) if (look.rich && !e.isDir && !isDav(e.path)) { if (isVideoFile(e.path)) return _Thumb(c: c, entry: e); const images = {'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'}; if (images.contains(e.ext)) { return ClipRRect( borderRadius: BorderRadius.circular(3), child: Image.file(File(e.path), width: 56, height: 44, fit: BoxFit.cover, cacheWidth: 112, errorBuilder: (_, _, _) => const Icon(Icons.image_outlined, size: 28)), ); } } final (icon, color) = fileIcon(look, e, open: open); return Icon(icon, size: look.rich && !e.isDir ? 28 : look.iconSize, color: color); } } /// 172: 같은 이름 - 보낼 것 (복사 · 이동, [here]) 과 대상 폴더에 이미 있는 것 ([there]) typedef NameClash = ({String src, ({bool isDir, int size, DateTime modified})? here, ({bool isDir, int size, DateTime modified}) there}); /// 172: "이름: 보낼 것 1.2 MB · 2026-10-09 14:00 / 있는 것 900 KB · 2026-10-01 09:00 (보낼 것이 더 새것)" String clashCompareText(NameClash x) { String one(({bool isDir, int size, DateTime modified}) s) => [if (!s.isDir) formatSize(s.size) else tr('폴더'), if (s.modified.year > 1) _date(s.modified)].join(' · '); final h = x.here, t = x.there; final newer = h == null || h.modified.year <= 1 || t.modified.year <= 1 ? '' : h.modified.isAfter(t.modified.add(const Duration(seconds: 2))) ? tr(' (보낼 것이 더 새것)') : t.modified.isAfter(h.modified.add(const Duration(seconds: 2))) ? tr(' (있는 것이 더 새것)') : tr(' (바뀐 날 같음)'); return '${vBasename(x.src)}: ${trf('보낼 것 {0} / 있는 것 {1}', [h == null ? '?' : one(h), one(t)])}$newer'; } /// 203: 바뀐 날을 모름 (잠긴 시스템 파일은 1970-01-01 로 온다) 은 "—" String _date(DateTime d) => fileDateText(d); String fileDateText(DateTime d) => d.year <= 1970 ? '—' : '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')} ' '${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}'; /// 기능 버튼 줄 (세로: 두 창 사이 · 가로: 위아래 배치일 때) class _Toolbar extends StatelessWidget { final List buttons; final bool vertical; final void Function(ExplorerButton) onPressed; final bool Function(ExplorerButton) enabled; final bool Function(ExplorerButton) selected; /// 창이 둘인지 (두 창 · 폴더 + 파일 목록). 하나면 [orient] 는 "두 창으로" final bool twoPanes; /// 좁아도 [더 보기] 로 보내지 않는 버튼 (창 배치 · 담아 둔 것이 있을 때 붙여넣기) final Set pinned; /// X-plore b: 길게 누르기 = 짝 동작 (복사 ↔ 이동, 지우기 → 영구 삭제). 짝이 없는 버튼은 null final void Function(ExplorerButton)? onLongPress; const _Toolbar( {required this.buttons, required this.vertical, required this.onPressed, required this.enabled, required this.selected, this.onLongPress, this.twoPanes = true, this.pinned = const {ExplorerButton.orient}, bool? panesSide}) : panesSide = panesSide ?? vertical; /// 두 창이 좌우로 놓였는지 (버튼 이름 "좌 → 우" / "위 → 아래"). 보통 [vertical] 과 같고, /// 191 좁은 Rsync 화면 (창 하나씩, 버튼 줄은 가로) 은 좌우 final bool panesSide; /// X-plore b: 길게 누르면 하는 짝 동작의 이름 (없으면 null) static String? longPressLabel(ExplorerButton b) => switch (b) { ExplorerButton.copy => '이동', ExplorerButton.move => '복사', ExplorerButton.delete => '영구 삭제', _ => null, }; /// 118: 지금 배치를 보여 준다 (누르면 반대로) String _orientLabel() => !twoPanes ? '두 창으로' : panesSide ? '좌우 ⇆' : '위아래 ⇅'; String _orientTip() => !twoPanes ? '두 창으로 보기 (좌우)' : panesSide ? '창 배치: 좌우 (누르면 위아래)' : '창 배치: 위아래 (누르면 좌우)'; /// 창이 위아래로 놓이면 (버튼 줄이 가로) "좌 → 우" 대신 "위 → 아래" String _label(ExplorerButton b) => b == ExplorerButton.orient ? _orientLabel() : panesSide ? b.label : switch (b) { ExplorerButton.toRight => '위 → 아래', ExplorerButton.toLeft => '위 ← 아래', ExplorerButton.both => '위 ⇄ 아래', _ => b.label, }; IconData _icon(ExplorerButton b) => b == ExplorerButton.orient ? (!twoPanes ? Icons.vertical_split_outlined : panesSide ? Icons.swap_horiz : Icons.swap_vert) : panesSide ? b.icon : switch (b) { ExplorerButton.toRight => Icons.arrow_downward, ExplorerButton.toLeft => Icons.arrow_upward, ExplorerButton.both => Icons.swap_vert, _ => b.icon, }; @override Widget build(BuildContext context) { Widget btn(ExplorerButton b) { final on = enabled(b); final sel = selected(b); final pair = longPressLabel(b); final long = onLongPress; return Tooltip( message: tr(b == ExplorerButton.orient ? _orientTip() : _label(b)) + (pair != null && long != null ? ' · ${trf('길게 누르면 {0}', [tr(pair)])}' : ''), child: InkWell( onTap: on ? () => onPressed(b) : null, onLongPress: on && pair != null && long != null ? () => long(b) : null, borderRadius: BorderRadius.circular(6), child: SizedBox( width: 72, height: 58, child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(_icon(b), size: 22, color: !on ? JjColors.textDim.withValues(alpha: 0.4) : sel ? JjColors.accent : null), const SizedBox(height: 2), // 188: 두 줄로 꺾인 글 (일본어 "新しいフォルダー" 등) 이 버튼 아래로 잘렸다 - 남는 높이에 맞게 줄여 다 보이게 Flexible( child: FittedBox( fit: BoxFit.scaleDown, child: SizedBox( width: 68, child: Text(tr(_label(b)), maxLines: 2, textAlign: TextAlign.center, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 10.5, height: 1.1, color: on ? null : JjColors.textDim)), ), ), ), ]), ), ), ); } // 147: 다 들어가지 않으면 (좁은 · 낮은 화면) 들어가는 만큼 보이고 나머지는 [더 보기 ⋯] 메뉴로 - 밀어야 보이는 것을 몰라 못 찾던 것 Widget more(List rest) => Builder( builder: (context) => Tooltip( message: tr('더 보기'), child: InkWell( borderRadius: BorderRadius.circular(6), onTap: () async { final box = context.findRenderObject()! as RenderBox; final at = box.localToGlobal(Offset.zero); final pick = await showMenu( context: context, position: RelativeRect.fromLTRB(at.dx + box.size.width, at.dy, at.dx + box.size.width, at.dy), items: [ for (final b in rest) PopupMenuItem( value: b, enabled: enabled(b), height: 40, child: Row(children: [ Icon(_icon(b), size: 18, color: selected(b) ? JjColors.accent : null), const SizedBox(width: 10), Text(tr(_label(b))), ]), ), ], ); if (pick != null) onPressed(pick); }, child: SizedBox( width: 72, height: 58, child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon(Icons.more_horiz, size: 22), const SizedBox(height: 2), Text(tr('더 보기'), style: const TextStyle(fontSize: 10.5, height: 1.1)), ]), ), ), ), ); return Container( color: JjColors.panel, width: vertical ? 76 : null, height: vertical ? null : 62, padding: const EdgeInsets.all(2), child: LayoutBuilder(builder: (context, box) { final room = vertical ? box.maxHeight : box.maxWidth; final each = vertical ? 58.0 : 72.0; var shown = buttons; var rest = const []; if (room.isFinite && buttons.length * each > room) { final fit = (room / each).floor() - 1; // 하나는 [더 보기] 자리 if (fit >= 1) { shown = buttons.take(fit).toList(); rest = buttons.skip(fit).toList(); // 창 배치 버튼은 늘 줄에 (사용자가 가운데 아이콘 모음에 두라고 함, 10/9) - 넘치면 마지막 자리를 내준다. // 73 (관리자): 복사 · 이동으로 담아 둔 것이 있으면 [붙여넣기] 도 (담아 두고 붙여넣기를 찾지 못하는 일이 없게) for (final b in buttons.where((b) => pinned.contains(b) && rest.contains(b))) { final i = shown.lastIndexWhere((x) => !pinned.contains(x)); if (i < 0) break; final out = shown[i]; shown = [...shown.sublist(0, i), ...shown.sublist(i + 1), b]; rest = [out, ...rest.where((x) => x != b)]; } } } final children = [for (final b in shown) btn(b), if (rest.isNotEmpty) more(rest)]; return vertical ? Column(children: children) : Row(children: children); }), ); } } // ───────── 동영상 정보 · 썸네일 (보이는 줄만, 차례로) ───────── /// 한 번에 하나씩 (FFmpeg 를 동시에 많이 띄우지 않게) class _Queue { final int max; int _running = 0; final _waiting = Queue>(); _Queue(this.max); Future run(Future Function() job) async { if (_running >= max) { final w = Completer(); _waiting.add(w); await w.future; } _running++; try { return await job(); } finally { _running--; if (_waiting.isNotEmpty) _waiting.removeFirst().complete(); } } } /// "1920×1080 · 1:23:45" class _Meta { static final _cache = >{}; static final _q = _Queue(2); static Future of(AppController c, String path) => _cache[path] ??= _q.run(() async { try { final info = await c.services.mediaTool.probe(path); final v = info.ofType('video').where((s) => (s.width ?? 0) > 0).firstOrNull; final d = info.duration; return [ if (v != null) '${v.width}×${v.height}', if (d != null) _dur(d), ].join(' · '); } catch (_) { return ''; } }); static String _dur(Duration d) { final h = d.inHours, m = d.inMinutes % 60, s = d.inSeconds % 60; String two(int x) => x.toString().padLeft(2, '0'); return h > 0 ? '$h:${two(m)}:${two(s)}' : '$m:${two(s)}'; } } class _MetaText extends StatelessWidget { final AppController c; final String path; /// 앞에 붙일 글 (크기 · 날짜) - 동영상 정보와 한 줄로 이어 그린다 final String lead; final TextStyle? leadStyle; const _MetaText({required this.c, required this.path, this.lead = '', this.leadStyle}); @override Widget build(BuildContext context) => FutureBuilder( future: _Meta.of(c, path), builder: (_, s) => Text.rich( TextSpan(children: [ if (lead.isNotEmpty) TextSpan(text: lead, style: leadStyle), if ((s.data ?? '').isNotEmpty) TextSpan(text: '${lead.isEmpty ? '' : ' '}${s.data}', style: const TextStyle(fontSize: 11, color: JjColors.accent)), ]), maxLines: 1, overflow: TextOverflow.ellipsis), ); } /// 동영상 썸네일 (임시 폴더의 jj_thumbs 에 저장해 다시 쓰기). MKV 만들기 등 작업 중에는 만들지 않는다 (FFmpeg 를 같이 씀). class _Thumbs { static final _cache = >{}; static final _q = _Queue(1); static Future of(AppController c, FileEntry e) { final key = '${e.path}|${e.size}|${e.modified.millisecondsSinceEpoch}'; return _cache[key] ??= _q.run(() async { try { final dir = Directory(p.join(await c.services.storage.tempDirectory(), 'jj_thumbs')); await dir.create(recursive: true); final out = File(p.join(dir.path, '${md5.convert(utf8.encode(key))}.jpg')); if (await out.exists() && await out.length() > 0) return out; if (c.busy) { _cache.remove(key); // 작업이 끝나면 다시 시도 return null; } for (final at in ['5', '0']) { await c.services.mediaTool.runFfmpeg([ '-hide_banner', '-loglevel', 'error', '-ss', at, '-i', e.path, // '-frames:v', '1', '-vf', 'scale=160:-2', '-y', out.path, ]); if (await out.exists() && await out.length() > 0) return out; } } catch (_) {} return null; }); } } /// 85: 진행 패널을 숨겼을 때 오른쪽 아래 작은 표시 "복사 · 이동 n개 · 45%" (멈추면 빨갛게). 누르면 다시 펼침 class TransferProgressChip extends StatelessWidget { /// 도는 것 수 (0 이면 끝) · 진행률 (모르면 null) · 멈춘 (실패한) 것이 있는지 final int running; final int? percent; final bool failed; final VoidCallback onTap; const TransferProgressChip({super.key, required this.running, this.percent, this.failed = false, required this.onTap}); @override Widget build(BuildContext context) => Material( elevation: 6, color: failed ? JjColors.danger.withValues(alpha: 0.25) : JjColors.panelHigh, borderRadius: BorderRadius.circular(18), child: InkWell( borderRadius: BorderRadius.circular(18), onTap: onTap, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Row(mainAxisSize: MainAxisSize.min, children: [ // 멈춤은 진행 고리 대신 경고 표시 (꽉 찬 고리는 "끝났다" 로 보였다) if (failed) const Icon(Icons.error_outline, size: 16, color: JjColors.danger) else SizedBox( width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, value: running == 0 ? 1 : (percent == null ? null : percent! / 100)), ), const SizedBox(width: 8), // 좁은 화면 · 큰 글자면 두 줄로 (412 · 글자 1.6배에서 화면을 넘었다 - 253 시험) Flexible( child: Text( failed ? tr('복사 · 이동 멈춤 - 눌러서 보기') : running == 0 ? tr('복사 · 이동 끝') : percent == null ? trf('복사 · 이동 {0}개', [running]) : trf('복사 · 이동 {0}개 · {1}%', [running, percent]), maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12.5), ), ), const SizedBox(width: 4), Tooltip(message: tr('진행 패널 보기'), child: const Icon(Icons.expand_less, size: 18)), ]), ), ), ); } /// 81: 빠른 보기의 큰 썸네일 (가로 640, 같은 jj_thumbs 에 따로 저장). 사용자가 연 것이라 작업 중에도 만든다 class _BigThumbs { static final _cache = >{}; static final _q = _Queue(1); static Future of(AppController c, FileEntry e) { final key = '${e.path}|${e.size}|${e.modified.millisecondsSinceEpoch}|640'; return _cache[key] ??= _q.run(() async { try { final dir = Directory(p.join(await c.services.storage.tempDirectory(), 'jj_thumbs')); await dir.create(recursive: true); final out = File(p.join(dir.path, '${md5.convert(utf8.encode(key))}.jpg')); if (await out.exists() && await out.length() > 0) return out; for (final at in ['5', '0']) { await c.services.mediaTool.runFfmpeg([ '-hide_banner', '-loglevel', 'error', '-ss', at, '-i', e.path, // '-frames:v', '1', '-vf', 'scale=640:-2', '-y', out.path, ]); if (await out.exists() && await out.length() > 0) return out; } } catch (_) {} _cache.remove(key); return null; }); } } class _Thumb extends StatelessWidget { final AppController c; final FileEntry entry; const _Thumb({required this.c, required this.entry}); @override Widget build(BuildContext context) => FutureBuilder( future: _Thumbs.of(c, entry), builder: (_, s) { final f = s.data; if (f == null) return const Icon(Icons.movie_outlined, size: 28, color: JjColors.textDim); return ClipRRect( borderRadius: BorderRadius.circular(3), child: Stack(alignment: Alignment.center, children: [ Image.file(f, width: 56, height: 44, fit: BoxFit.cover, errorBuilder: (_, _, _) => const Icon(Icons.movie_outlined, size: 28)), const Icon(Icons.play_arrow, size: 18, color: Colors.white70), ]), ); }, ); } /// 76: 빠른 필터 - 비어 있으면 모두, * · ? 가 있으면 이름 전체 패턴, 아니면 이름에 든 글자 (대소문자 무시) bool quickFilterMatch(String name, String filter) { if (filter.isEmpty) return true; if (filter.contains('*') || filter.contains('?')) return globToRegExp(filter).hasMatch(name); return name.toLowerCase().contains(filter.toLowerCase()); } /// 146 · 37-2: 경로 입력 창 - 없는 폴더면 닫지 않고 칸 아래에 "폴더가 없거나 열 수 없습니다" /// 74: 패턴 (예: *.srt, 여러 개는 ; 로) → 이름 전체에 맞는 정규식 (대소문자 무시) RegExp globToRegExp(String pattern) { final parts = pattern.split(RegExp(r'[;,]')).map((x) => x.trim()).where((x) => x.isNotEmpty); String one(String g) => RegExp.escape(g).replaceAll(r'\*', '.*').replaceAll(r'\?', '.'); return RegExp('^(?:${parts.map(one).join('|')})\$', caseSensitive: false); } /// 74: 패턴 넣기 class _PatternDialog extends StatefulWidget { const _PatternDialog(); @override State<_PatternDialog> createState() => _PatternDialogState(); } class _PatternDialogState extends State<_PatternDialog> { final _ctl = TextEditingController(text: '*.srt'); @override void dispose() { _ctl.dispose(); super.dispose(); } @override Widget build(BuildContext context) => AlertDialog( title: Text(tr('패턴으로 고르기')), content: TextField( controller: _ctl, autofocus: true, decoration: InputDecoration(helperText: tr('* 는 아무 글자, ? 는 한 글자. 여러 개는 ; 로 (예: *.srt; *.ass)')), onSubmitted: (v) => Navigator.pop(context, v), ), actions: [ TextButton(onPressed: () => Navigator.pop(context), child: Text(tr('취소'))), FilledButton(onPressed: () => Navigator.pop(context, _ctl.text), child: Text(tr('고르기'))), ], ); } /// 179: SMB 경로 (smb:// · \\서버\공유) bool looksLikeSmb(String path) => RegExp(r'^(smb|cifs)://', caseSensitive: false).hasMatch(path) || path.startsWith(r'\\'); /// 190: smb://서버/공유/폴더 (cifs:// 도) → \\서버\공유\폴더. SMB 주소가 아니면 null String? smbToUnc(String path) { final m = RegExp(r'^(smb|cifs)://(.+)$', caseSensitive: false).firstMatch(path.trim()); if (m == null) return null; final parts = m.group(2)!.split(RegExp(r'[/\\]+')).where((s) => s.isNotEmpty).map(_decodeSmbPart).toList(); if (parts.isEmpty) return null; return '\\\\${parts.join('\\')}'; } /// %20 같은 주소 글자는 풀고, 한글 등 그대로 쓴 글자는 그대로 String _decodeSmbPart(String s) { try { return Uri.decodeComponent(s); } catch (_) { return s; } } String get smbNotSupported => tr('네트워크 공유 (SMB) 는 경로로 바로 열지 않습니다. [SMB 서버 추가] 로 서버를 넣으면 탭으로 열립니다'); class _PathDialog extends StatefulWidget { const _PathDialog({required this.initial}); final String initial; /// 창이 이것을 돌려주면 WebDAV 서버 추가 창을 연다 static const addDav = '\u0000webdav'; /// 88: 이것 + 넣은 주소를 돌려주면 SMB 서버 추가 창 (주소로 채움) static const addSmb = '\u0000smb:'; @override State<_PathDialog> createState() => _PathDialogState(); } class _PathDialogState extends State<_PathDialog> { late final _ctl = TextEditingController(text: widget.initial) ..selection = TextSelection(baseOffset: 0, extentOffset: widget.initial.length); String? _error; bool _checking = false; @override void dispose() { _ctl.dispose(); super.dispose(); } Future _submit() async { setState(() => _checking = true); final (path, error) = await _ExplorerPageState.checkEnteredPath(_ctl.text); if (!mounted) return; setState(() { _checking = false; _error = error; }); if (path != null) Navigator.pop(context, path); } @override Widget build(BuildContext context) => AlertDialog( scrollable: true, title: Text(Platform.isWindows ? tr('경로 입력 (예: D:\\영상 · \\\\NAS\\공유)') : tr('경로 입력')), content: TextField( controller: _ctl, autofocus: true, decoration: InputDecoration(hintText: tr('경로'), errorText: _error, errorMaxLines: 3), // 고치기 시작하면 앞의 오류는 지운다 onChanged: (_) { if (_error != null) setState(() => _error = null); }, onSubmitted: (_) => _submit(), ), actions: [ // 179: SMB 를 넣었으면 바로 WebDAV 서버 추가로 if (_error == smbNotSupported) ...[ TextButton(onPressed: () => Navigator.pop(context, _PathDialog.addDav), child: Text(tr('WebDAV 서버 추가'))), TextButton( onPressed: () => Navigator.pop(context, '${_PathDialog.addSmb}${_ctl.text}'), child: Text(tr('SMB 서버 추가'))), ], TextButton(onPressed: () => Navigator.pop(context), child: Text(tr('취소'))), FilledButton(onPressed: _checking ? null : _submit, child: Text(tr('확인'))), ], ); } /// 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)), ), ]), ), ), ), ), ), ]), ); }