import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; import '../../app/app_controller.dart'; import '../../app/archive_browse.dart'; import '../../app/volumes.dart' as vol; import '../../app/volumes.dart' show DriveStatus; import '../../core/app_hidden.dart'; import '../../core/explorer/dir_loader.dart'; import '../../core/explorer/tree_rows.dart'; import '../../core/file_ops.dart'; import '../../core/folder_size.dart'; import '../../core/playlist.dart' show isAudioFile, isVideoFile; import '../../core/secret_gate.dart'; import '../../core/shizuku.dart'; import '../../core/vfs.dart'; import '../../l10n/tr.dart'; import '../archive_ui.dart' show bundledSevenZip; import '../explorer_look.dart'; import '../file_error.dart'; import 'explorer_host.dart'; import 'explorer_pane.dart'; /// 약속 (E5d-1): 탐색기 화면 하나의 상태와 화면 밖의 일 - 창 두 개 · 지금 창 · 저장 장치 · 드라이브 · 폴더 크기 · 압축 · /// 목록에서 숨기기 · 읽기 · 정렬 · 이동 · 뒤로/앞으로 · 기억 · 새로 고침. /// 일은 이미 있는 모듈에 맡기고 묶어 부르기만 한다 (PaneState · NavHistory · dir_loader · tree_rows · volumes · /// archive_browse · app_hidden · folder_size). 화면에 부탁할 것 (알림 · 스크롤 · 다음 그리기) 은 [ExplorerHost] 로. /// 바뀌면 [notifyListeners] (화면이 다시 그림). 오류는 던지지 않고 창의 오류 · 알림으로. class ExplorerController extends ChangeNotifier { ExplorerController(this.c, {required this.rsync, required this.host}) { sizes.addListener(_onSizes); drives.addListener(notifyListeners); } final AppController c; final bool rsync; final ExplorerHost host; late final List panes = [ExplorerPane('', rsync: rsync), ExplorerPane('', rsync: rsync)]; int active = 0; bool ready = false; ExplorerPane get pane => panes[active]; /// 이 기기의 저장 장치: (경로, 이름) List<(String, String)> local = []; /// 165: 드라이브마다 따로 뒤에서 확인한 상태 final drives = DriveStatus(); /// 80: 폴더 크기 (필요할 때 계산하고 이번 실행 동안 기억) final sizes = FolderSizes(); /// X-plore 비교 f: 압축을 폴더처럼 - 잊으면 창들의 그 안 목록도 지운다 late final arc = ArchiveBrowser(bundledSevenZip, onForget: (a) { for (final q in panes) { q.cache.removeWhere((k, _) => samePath(k, a) || isSameOrInside(k, a)); } }); /// X-plore 숨기기: 목록에서 숨긴 것 (앱 설정의 목록 - 파일은 그대로) final hidden = HiddenList(); /// X-plore 숨기기: [숨긴 항목 n개 보이기] (이번 실행 동안, 모든 창에) bool showAppHidden = false; void update() => notifyListeners(); @override void dispose() { sizes.dispose(); drives.dispose(); for (final q in panes) { q.dispose(); } super.dispose(); } // ───────── 저장 장치 ───────── /// 저장 장치 + 서버들 (WebDAV · SFTP · SMB · FTP · 시스템 폴더 · 루트): (경로, 이름) List<(String, String)> get volumes => [ ...local, for (final s in c.settings.webdavServers) ('$davScheme${s.id}/', s.label), for (final s in c.settings.sftpServers) ('$sftpScheme${s.id}/', s.label), for (final s in c.settings.smbServers) ('$smbScheme${s.id}/', s.label), // l: 평문 FTP 는 이름 앞에 ⚠ for (final s in c.settings.ftpServers) ('$ftpScheme${s.id}/', s.tls ? s.label : '⚠ ${s.label}'), if (c.settings.shizukuEnabled && shizukuSupported()) ('$shizukuScheme$shizukuServer/', tr('시스템 (읽기 전용)')), if (c.settings.rootEnabled && rootSupported()) (rootHome, tr('루트 (읽기 전용)')), ]; String volumeOf(String path) => vol.volumeOf(path, local); /// 화면에 보일 경로 (WebDAV 는 "☁ 서버 › 폴더", Android 는 저장 장치 이름부터) 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(' › '); } /// 설정에서 지운 WebDAV 서버를 보던 창은 이 기기의 첫 저장 장치로 void dropRemovedServers() { if (!ready) return; for (final q in panes) { if (isDav(q.root) && !DavPath.parse(q.root).serverKnown) { q.sel.clear(); q.cache.removeWhere((k, _) => isDav(k)); q.expanded.removeWhere(isDav); q.root = local.first.$1; // 다시 그릴 때 또 하지 않게 host.afterFrame(() { if (host.alive) unawaited(goTo(q, local.first.$1)); }); } } } // ───────── 폴더 크기 (80) ───────── void _onSizes() { if (!host.alive) return; for (final q in panes) { for (final k in q.cache.keys.toList()) { if (sortOf(k).$1 == 'size') q.cache[k] = sorted(q.cache[k]!, k); } } notifyListeners(); } /// 폴더 크기 글 (모르면 null). 설정이 자동이면 계산을 맡긴다 - 지금 보는 폴더 바로 안의 폴더만, WebDAV 는 하지 않음 String? dirSize(ExplorerPane q, FileEntry e) { final s = sizes.of(e.path); if (s != null) return s.label; if (sizes.busy(e.path)) return '…'; final mode = c.settings.explorer.folderSize; if ((mode == 'always' || (mode == 'sort' && sortOf(q.current).$1 == 'size')) && samePath(vDirname(e.path), q.current) && !isDav(e.path)) { final forDir = q.current; host.afterFrame(() { if (host.alive) sizes.request(e.path, forDir: forDir); }); return '…'; } return null; } // ───────── 목록 읽기 ───────── Future load(ExplorerPane q, String dir, {bool force = false}) async { final r = await loadDir(q, dir, force: force, showHidden: c.settings.explorer.showHidden, sort: sorted, inside: _insideArchiveList, osBlocked: (d) => isAndroidRestricted(d) ? androidRestrictedError : null, onFresh: sizes.forget); // 37-2: 보던 폴더가 밖에서 지워졌으면 남아 있는 가장 가까운 상위 폴더로 if (r.vanishedUpTo case final up?) { host.afterFrame(() { if (!host.alive) return; host.snack(tr('보던 폴더가 없어져 상위 폴더로 옮겼습니다')); unawaited(goTo(q, up, fresh: true)); }); } } Future?> _insideArchiveList(String dir, bool force) async { final a = arc.archiveOf(dir); if (a == null) return null; if (force && !await readArchive(a)) return const []; return arc.contains(a) ? arc.list(dir) : null; } /// 압축의 목록을 읽어 둔다 (못 읽으면 까닭을 알림). 읽었으면 true Future readArchive(String path) async { switch (await arc.read(path)) { case ArchiveRead.ok: return true; case ArchiveRead.gone: return false; case ArchiveRead.noTool: host.snack(tr('이 기기에서는 7z · RAR 안을 볼 수 없습니다 (다른 앱으로 열어 주세요)')); case ArchiveRead.password: host.snack(tr('비밀번호가 걸린 압축은 안을 미리 볼 수 없습니다 - [압축 풀기] 로 푸세요')); case ArchiveRead.failed: host.snack(trf('압축을 읽지 못했습니다: {0}', [arc.lastError])); } return false; } Future refresh(ExplorerPane q, Iterable dirs) async { for (final d in {...dirs}) { if (q.cache.containsKey(d)) await load(q, d, force: true); } } Future refreshAll(Iterable dirs) async { for (final q in panes) { await refresh(q, dirs); } } /// 복사 · 이동 뒤: 대상 · 원본 폴더와 그 안의 읽어 둔 것을 새로 (없어진 것은 잊음, 자리 · 스크롤 그대로) 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 (!host.alive) return; if (!x.cache.containsKey(d)) continue; if (!await vExists(d)) { x.cache.remove(d); x.expanded.remove(d); continue; } await load(x, d, force: true); } } } /// 열려 있는 (보이는) 폴더를 두 창 모두 다시 읽는다 ([localOnly]: WebDAV 등은 빼고) bool _reloading = false; Future reloadOpen({bool localOnly = false}) async { if (_reloading) return; _reloading = true; try { for (final x in panes) { final open = {...x.expanded, x.current}; for (final d in open) { if (!host.alive) return; if (localOnly && isDav(d)) continue; await load(x, d, force: true); } x.cache.removeWhere((k, _) => !open.contains(k)); x.changed(); } } finally { _reloading = false; } } // ───────── 정렬 (84) ───────── List sorted(List list, String dir) { final (sortBy, desc) = sortOf(dir, list: list); 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); /// [dir] 의 정렬 (기준, 거꾸로) - 폴더마다 기억한 것 → 종류별 기본 정렬 → 전체 정렬 (String, bool) sortOf(String dir, {List? list}) { final s = c.settings; if (s.explorer.sortPerFolder || s.explorer.sortByKind) { final v = s.explorer.folderSorts[_sortKey(dir)]; if (v != null && v.contains('|')) return (v.split('|').first, v.endsWith('|1')); } if (s.explorer.sortByKind) { final k = _kindSort(dir, list: list); if (k != null) return k; } return (s.explorer.sort, s.explorer.sortDesc); } /// X-plore 비교 5-1 ①: 파일의 반 넘게가 사진 · 동영상이면 날짜 (새것 먼저), 음악이면 이름, 그 밖은 null (String, bool)? _kindSort(String dir, {List? list}) { if (list == null) { for (final q in panes) { list = q.cache[dir]; if (list != null) break; } } final files = [for (final e in list ?? const []) if (!e.isDir) e]; if (files.isEmpty) return null; final media = files.where((e) => c.settings.imageExts.contains(e.ext) || isVideoFile(e.path)).length; if (media * 2 > files.length) return ('date', true); if (files.where((e) => isAudioFile(e.path)).length * 2 > files.length) return ('name', false); return null; } /// 정렬을 바꾼다 - "폴더마다 기억" (또는 종류별 정렬이 쓰이는 폴더) 이면 [dir] 에만, 아니면 전체. 모든 창을 다시 줄 세운다 Future setSort(String dir, String by, bool desc) async { final kindHere = c.settings.explorer.sortByKind && _kindSort(dir) != null; await c.updateSettings((x) { if (x.explorer.sortPerFolder || kindHere) { final m = Map.of(x.explorer.folderSorts)..remove(_sortKey(dir)); m[_sortKey(dir)] = '$by|${desc ? 1 : 0}'; while (m.length > 300) { m.remove(m.keys.first); } x.explorer.folderSorts = m; } else { x ..explorer.sort = by ..explorer.sortDesc = desc; } }); resortAll(); } /// 읽어 둔 목록을 모두 다시 줄 세운다 void resortAll() { for (final q in panes) { for (final k in q.cache.keys.toList()) { q.cache[k] = sorted(q.cache[k]!, k); } q.changed(); } } /// 열 머리를 누름 - 같은 열이면 거꾸로, 다른 열이면 그 열로 (오름차순) Future sortByColumn(ExplorerPane q, String by) async { active = panes.indexOf(q); notifyListeners(); final (now, desc) = sortOf(q.current); await setSort(q.current, by, now == by ? !desc : false); } // ───────── 이동 ───────── /// 그 폴더로: 저장 장치부터 그 폴더까지 펼치고 지금 폴더로 정한다. [fresh] 면 그 폴더를 새로 읽고, /// [focus] 일 때만 그 폴더를 고른 항목으로 (48 보충). [remember] 면 마지막 폴더 · 내역에 남긴다 Future goTo(ExplorerPane q, String dir, {bool remember = true, bool fresh = false, bool focus = false}) async { // 124 · 128: 사용자가 WebDAV 로 간 것이면 마스터 창을 한 번 취소했어도 다시 묻는다 if (remember && isDav(dir)) await SecretGate.pass(force: true); if (!host.alive) return; q.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, q.root) || vDirname(d) == d) break; d = vDirname(d); } for (final (k, x) in chain.indexed) { q.expanded.add(x); await load(q, x, force: fresh && k == chain.length - 1); // 122: 읽어 둔 목록에 다음 폴더가 없으면 (앱 밖에서 새로 만듦) 새로 읽어야 그 폴더까지 펼쳐진다 if (k < chain.length - 1 && !(q.cache[x] ?? const []).any((e) => samePath(e.path, chain[k + 1]))) { await load(q, x, force: true); } } q.current = dir; if (focus) q.focused = dir; q.changed(); if (q.listScroll.hasClients) q.listScroll.jumpTo(0); host.reveal(q, dir); if (remember) this.remember(q, dir); } /// 75: 창마다 뒤로 · 앞으로 (가 본 폴더로) - 내역 규칙은 NavHistory. 갈 수 없는 폴더는 내역에서 빼고 알림 Future travel(ExplorerPane q, {required bool forward}) async { final to = q.nav.take(forward: forward); if (to == null) return; try { active = panes.indexOf(q); notifyListeners(); bool ok; try { ok = await vIsDir(to); } catch (_) { ok = false; } if (!host.alive) 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 (!host.alive) return; notifyListeners(); host.snack(trf('{0} 폴더가 없거나 열 수 없어 내역에서 뺐습니다', [displayPath(to)])); return; } q.nav.arrive(q.current, forward: forward); await goTo(q, to); } finally { q.nav.finish(); } if (host.alive) notifyListeners(); } /// 마지막 폴더 · 내역 저장 void remember(ExplorerPane q, String dir) { final i = panes.indexOf(q); final paths = [...panes.map((x) => x.current)]; if (rsync) { unawaited(c.updateSettings((s) => s.copy.rsyncPaths = paths)); } else { final hist = [dir, ...c.settings.explorer.history.where((h) => !samePath(h, dir))].take(30).toList(); unawaited(c.updateSettings((s) => s ..explorer.paths = paths ..explorer.history = hist)); } if (i >= 0) active = i; } // ───────── 줄 목록 · 목록에서 숨기기 ───────── ExplorerStyle get look => ExplorerStyle.of(c.settings.explorer.style); bool get rootsInTree => !rsync && look == ExplorerStyle.xplore && c.settings.explorer.rootsInTree; List rows(ExplorerPane q, {bool foldersOnly = false}) => buildTreeRows(q, foldersOnly: foldersOnly, roots: rootsInTree ? [for (final (path, _) in volumes) path] : const [], favorites: rootsInTree ? [for (final b in c.settings.folderBookmarks) b.path] : const [], isHidden: appHidden, showHidden: showAppHidden, browsable: arc.contains); bool appHidden(String path) => hidden.isHidden(c.settings.explorer.hiddenItems, path); /// 옮기거나 이름을 바꾼 것은 숨긴 목록도 새 경로로, 지운 것은 목록에서 뺀다 (관리자 결정 10/18) Future followHidden({Iterable<(String, String)> moved = const [], Iterable gone = const []}) async { final out = await HiddenList.follow(c.settings.explorer.hiddenItems, moved: moved, gone: gone); if (out != null) await c.updateSettings((s) => s.explorer.hiddenItems = out); } void toggleAppHidden() { showAppHidden = !showAppHidden; notifyListeners(); for (final q in panes) { q.changed(); } } }