From 4279747371cb3440f1685c5df7030bc58373b44d Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:26:22 +0900 Subject: [PATCH] =?UTF-8?q?E5d-1:=20=ED=83=90=EC=83=89=EA=B8=B0=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=C2=B7=20=ED=99=94=EB=A9=B4=20=EB=B0=96?= =?UTF-8?q?=EC=9D=98=20=EC=9D=BC=EC=9D=84=20ExplorerController=20=EB=A1=9C?= =?UTF-8?q?,=20=ED=99=94=EB=A9=B4=20=EB=B6=80=ED=83=81=EC=9D=80=20Explorer?= =?UTF-8?q?Host=20=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자 승인 E5d 6단계 중 1. 동작은 바꾸지 않음 - lib/ui/explorer/explorer_controller.dart ExplorerController (448줄): 창 두 개 · 지금 창 · 저장 장치 · 드라이브 · 폴더 크기 · 압축 · 목록에서 숨기기 · 읽기 · 정렬 · 이동 · 뒤로/앞으로 · 기억 · 새로 고침. 일은 이미 있는 모듈 (PaneState · NavHistory · dir_loader · tree_rows · volumes · archive_browse · app_hidden · folder_size) 에 맡기고 묶어 부르기만 - lib/ui/explorer/explorer_host.dart ExplorerHost: 일이 화면에 부탁하는 것 (떠 있는지 · 알림 · 스크롤 · 다음 그리기). 탐색기 화면 상태가 구현 - lib/ui/explorer/explorer_pane.dart ExplorerPane: PaneState + 화면 부품 (스크롤 · 빠른 필터 칸) - explorer_page 의 _Pane - explorer_page 는 컨트롤러를 부르기만: 5,922 → 5,497. 줄 누르기 · 고르기는 E5d-2 (줄 위젯과 함께) 확인: analyze 0 · 전체 단위 2회 통과 1,332 · 건너뜀 18 · 실패 0 · 점검 통합 시험 (1920 · 960 · 412) 2회 통과. 캡처 픽셀 비교 (고정 기준 E5a): 두 회 모두 90장 중 88장 픽셀까지 같음, 다른 2장은 기준끼리도 다른 움직이는 장면 (이동 확인) 약속 시험 test/explorer_controller_contract_test.dart 3 (실제 시험 폴더 · Host 는 실제 화면 - 실제 ScaffoldMessenger 알림 · 실제 다음 그리기). 탐색기 화면 자신이 Host 로 하는 일은 explorer_test 의 화면 시험이 실제로 확인 Co-Authored-By: Claude Opus 5.5 --- lib/ui/explorer/explorer_controller.dart | 448 +++++++ lib/ui/explorer/explorer_host.dart | 16 + lib/ui/explorer/explorer_pane.dart | 32 + lib/ui/explorer_page.dart | 1159 ++++++------------- test/explorer_controller_contract_test.dart | 108 ++ 5 files changed, 971 insertions(+), 792 deletions(-) create mode 100644 lib/ui/explorer/explorer_controller.dart create mode 100644 lib/ui/explorer/explorer_host.dart create mode 100644 lib/ui/explorer/explorer_pane.dart create mode 100644 test/explorer_controller_contract_test.dart diff --git a/lib/ui/explorer/explorer_controller.dart b/lib/ui/explorer/explorer_controller.dart new file mode 100644 index 0000000..ac76b66 --- /dev/null +++ b/lib/ui/explorer/explorer_controller.dart @@ -0,0 +1,448 @@ +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.explorerFolderSize; + 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.explorerShowHidden, + 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.explorerSortPerFolder || s.explorerSortByKind) { + final v = s.explorerFolderSorts[_sortKey(dir)]; + if (v != null && v.contains('|')) return (v.split('|').first, v.endsWith('|1')); + } + if (s.explorerSortByKind) { + final k = _kindSort(dir, list: list); + if (k != null) return k; + } + return (s.explorerSort, s.explorerSortDesc); + } + + /// 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.explorerSortByKind && _kindSort(dir) != null; + await c.updateSettings((x) { + if (x.explorerSortPerFolder || kindHere) { + 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; + } + }); + 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.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 rootsInTree => !rsync && look == ExplorerStyle.xplore && c.settings.explorerRootsInTree; + + 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.explorerHiddenItems, path); + + /// 옮기거나 이름을 바꾼 것은 숨긴 목록도 새 경로로, 지운 것은 목록에서 뺀다 (관리자 결정 10/18) + Future followHidden({Iterable<(String, String)> moved = const [], Iterable gone = const []}) async { + final out = await HiddenList.follow(c.settings.explorerHiddenItems, moved: moved, gone: gone); + if (out != null) await c.updateSettings((s) => s.explorerHiddenItems = out); + } + + void toggleAppHidden() { + showAppHidden = !showAppHidden; + notifyListeners(); + for (final q in panes) { + q.changed(); + } + } +} diff --git a/lib/ui/explorer/explorer_host.dart b/lib/ui/explorer/explorer_host.dart new file mode 100644 index 0000000..bcc3f5d --- /dev/null +++ b/lib/ui/explorer/explorer_host.dart @@ -0,0 +1,16 @@ +import 'package:flutter/widgets.dart'; + +import 'explorer_pane.dart'; + +/// 약속 (E5d-1): 탐색기의 일 (ExplorerController 와 작은 컨트롤러들) 이 화면에 부탁하는 것. +/// 일은 화면 위젯 · BuildContext 를 모르고 이것만 부른다. 탐색기 화면 ([ExplorerPage] 의 상태) 이 실제로 해 준다. +/// - [alive]: 화면이 아직 떠 있는지 (닫힌 뒤 끝난 일은 아무것도 하지 않음) +/// - [snack]: 짧은 알림 (아래 SnackBar) +/// - [reveal]: 그 창 목록에서 [path] 가 보이게 스크롤 (다음 그리기 뒤) +/// - [afterFrame]: 다음 그리기 뒤에 할 일 +abstract interface class ExplorerHost { + bool get alive; + void snack(String text); + void reveal(ExplorerPane pane, String path); + void afterFrame(VoidCallback f); +} diff --git a/lib/ui/explorer/explorer_pane.dart b/lib/ui/explorer/explorer_pane.dart new file mode 100644 index 0000000..096a17b --- /dev/null +++ b/lib/ui/explorer/explorer_pane.dart @@ -0,0 +1,32 @@ +import 'package:flutter/widgets.dart'; + +import '../../core/explorer/pane_state.dart'; + +/// 탐색기 창 하나 (E5d-1): 상태는 [PaneState] (lib/core/explorer), 여기는 화면 부품 (스크롤 · 빠른 필터 칸) 만. +class ExplorerPane extends PaneState { + ExplorerPane(super.root, {super.rsync}); + + final filterCtl = TextEditingController(); + final filterFocus = FocusNode(); + @override + String get filter => filterOpen ? filterCtl.text.trim() : ''; + + /// 목록 스크롤 (폴더로 이동하면 그 폴더가 보이게) + final scroll = ScrollController(); + + /// "폴더 + 파일 목록" 배치의 오른쪽 파일 목록 스크롤 + final listScroll = ScrollController(); + + /// 202: 창 위 저장 장치 탭 줄 (넘치면 옆으로 - 휠 · 끌기) + final tabScroll = ScrollController(); + + @override + void dispose() { + scroll.dispose(); + listScroll.dispose(); + tabScroll.dispose(); + filterCtl.dispose(); + filterFocus.dispose(); + super.dispose(); + } +} diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index b79c86c..293048a 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -78,15 +78,12 @@ import '../core/wifi_share.dart' show WifiShare; import '../core/archive_ops.dart' show archiveKindOf; import 'batch_rename_dialog.dart'; import '../core/app_trash.dart'; -import '../core/folder_size.dart'; import '../core/batch_rename.dart'; import 'notice.dart'; import 'confirm_dialog.dart'; import '../core/name_clash.dart'; import 'adaptive_row.dart'; import '../app/volumes.dart'; -import '../core/explorer/dir_loader.dart'; -import '../core/explorer/pane_state.dart'; import '../core/explorer/tree_rows.dart'; import '../app/delete_ops.dart'; import '../app/transfer_plan.dart'; @@ -96,6 +93,9 @@ import '../app/file_opener.dart'; import '../core/app_hidden.dart'; import '../app/rsync_runner.dart'; import 'explorer/rsync_run_dialog.dart'; +import 'explorer/explorer_controller.dart'; +import 'explorer/explorer_host.dart'; +import 'explorer/explorer_pane.dart'; export '../core/explorer/tree_rows.dart' show globToRegExp, quickFilterMatch; export '../core/name_clash.dart' show NameClash, clashCompareText, fileDateText; @@ -272,120 +272,40 @@ enum ExplorerButton { } } -/// 한 창: 상태는 [PaneState] (E5a, lib/core/explorer), 여기는 화면 부품 (스크롤 · 빠른 필터 칸) 만 -class _Pane extends PaneState { - _Pane(super.root, {super.rsync}); - - final filterCtl = TextEditingController(); - final filterFocus = FocusNode(); - @override - String get filter => filterOpen ? filterCtl.text.trim() : ''; - - /// 목록 스크롤 (폴더로 이동하면 그 폴더가 보이게) - final scroll = ScrollController(); - - /// "폴더 + 파일 목록" 배치의 오른쪽 파일 목록 스크롤 - final listScroll = ScrollController(); - - /// 202: 창 위 저장 장치 탭 줄 (넘치면 옆으로 - 휠 · 끌기) - final tabScroll = ScrollController(); - - @override - void dispose() { - scroll.dispose(); - listScroll.dispose(); - tabScroll.dispose(); - filterCtl.dispose(); - filterFocus.dispose(); - super.dispose(); - } -} - -class _ExplorerPageState extends State { +class _ExplorerPageState extends State implements ExplorerHost { AppController get c => widget.c; - /// 이 기기의 저장 장치: (경로, 이름) - List<(String, String)> _local = []; + /// 탐색기의 상태 · 화면 밖의 일 (E5d-1) - 화면은 부르기만. 화면에 부탁할 것은 [ExplorerHost] 로 이 상태가 해 준다 + late final _ctl = ExplorerController(c, rsync: widget.rsync, host: this)..addListener(_onPane); - /// 165: 드라이브마다 따로 뒤에서 확인한 상태 (E5a, lib/app/volumes.dart) - late final _drives = DriveStatus()..addListener(_onDrives); - void _onDrives() { - if (mounted) setState(() {}); - } + @override + bool get alive => mounted; + @override + void snack(String text) => _snack(text); + @override + void reveal(ExplorerPane pane, String path) => _reveal(pane, path); + @override + void afterFrame(VoidCallback f) => WidgetsBinding.instance.addPostFrameCallback((_) => f()); - Future _probeDrive(String path) => _drives.check(path, ExplorerPage.probeDrive); + Future _probeDrive(String path) => _ctl.drives.check(path, ExplorerPage.probeDrive); /// 연결 안 된 드라이브를 누르면 다시 연결 (다시 읽기) - 되면 연다 - Future _reconnect(_Pane pane, String path) async { + Future _reconnect(ExplorerPane pane, String path) async { await _probeDrive(path); if (!mounted) return; - if (_drives.ok[path] == true) { + if (_ctl.drives.ok[path] == true) { pane.cache.remove(path); - await _goTo(pane, path); + await _ctl.goTo(pane, path); } else { - _snack(_drives.removable.contains(path) + _snack(_ctl.drives.removable.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}'), - // X-plore 비교 5-2: 시스템 폴더 (Shizuku, 읽기 전용) - 환경 설정에서 켰을 때 (Android) - if (c.settings.shizukuEnabled && shizukuSupported()) - ('$shizukuScheme$shizukuServer/', tr('시스템 (읽기 전용)')), - // X-plore 비교 5-2: 루트 (su, 읽기 전용) - 환경 설정에서 켰을 때 (Android) - if (c.settings.rootEnabled && rootSupported()) (rootHome, tr('루트 (읽기 전용)')), - ]; - late final List<_Pane> _panes = [_Pane('', rsync: widget.rsync), _Pane('', rsync: widget.rsync)]; - 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() { // 관리자 지시 (10/21): 보관함에 넣은 파일의 썸네일 기억을 잊게 (static 함수라 여러 번 넣어도 하나) @@ -398,10 +318,10 @@ class _ExplorerPageState extends State { } else { ExplorerPage._open++; } - for (final pane in _panes) { + for (final pane in _ctl.panes) { pane.addListener(_onPane); } - _resumeReload = AppLifecycleListener(onResume: () => unawaited(_reloadOpen(localOnly: true))); + _resumeReload = AppLifecycleListener(onResume: () => unawaited(_ctl.reloadOpen(localOnly: true))); _hiddenSeen = c.settings.explorerHiddenItems; _kindSeen = c.settings.explorerSortByKind; c.addListener(_onSettings); @@ -410,45 +330,17 @@ class _ExplorerPageState extends State { /// 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(); - _drives.dispose(); if (widget.rsync) { ExplorerPage._openRsync--; } else { ExplorerPage._open--; ExplorerPage.goToRequest.removeListener(_onGoToRequest); } - for (final pane in _panes) { - pane.dispose(); - } + _ctl.dispose(); if (_allFiles != null) _life.dispose(); _resumeReload.dispose(); super.dispose(); @@ -459,8 +351,8 @@ class _ExplorerPageState extends State { } Future _init() async { - _local = ExplorerPage.debugVolumes?.call() ?? await localVolumes(); - if (_local.isEmpty) _local = [(Platform.isWindows ? r'C:\' : '/', Platform.isWindows ? 'C:' : '/')]; + _ctl.local = ExplorerPage.debugVolumes?.call() ?? await localVolumes(); + if (_ctl.local.isEmpty) _ctl.local = [(Platform.isWindows ? r'C:\' : '/', Platform.isWindows ? 'C:' : '/')]; // X-plore 비교 e: 보관 일이 지난 휴지통 항목을 지운다 (뒤에서) if (_appTrash case final t?) unawaited(t.purgeOlderThan(c.settings.androidTrashDays).catchError((_) => 0)); if (Platform.isAndroid) unawaited(_checkAccess()); @@ -472,7 +364,7 @@ class _ExplorerPageState extends State { // U1 (점검 10/22): 저장된 경로가 없는 창은 창마다 다른 저장 장치 (오른쪽 = 두 번째, 없으면 첫째) - 두 창이 같은 목록을 // 두 번 보이면 [복사] 의 "다른 창으로" 가 같은 곳이 되어 헷갈렸다. 저장된 경로가 있으면 그대로 final wants = [ - for (var i = 0; i < 2; i++) reach[i] ? saved[i] : _local[i < _local.length ? i : 0].$1, + for (var i = 0; i < 2; i++) reach[i] ? saved[i] : _ctl.local[i < _ctl.local.length ? i : 0].$1, ]; // 162: "이 폴더를 탐색기로" 로 열렸으면 첫 창은 그 폴더 (지난 폴더 대신) final asked = widget.rsync ? null : ExplorerPage.goToRequest.value; @@ -481,14 +373,14 @@ class _ExplorerPageState extends State { wants[0] = asked; } for (var i = 0; i < 2; i++) { - _panes[i] - ..root = _volumeOf(wants[i]) + _ctl.panes[i] + ..root = _ctl.volumeOf(wants[i]) ..current = wants[i] // 48 보충 (관리자): 처음 열면 아무것도 고르지 않은 상태 - 열자마자 폴더 통째가 복사 · 이동 대상이 되지 않게 ..focused = null; } if (!mounted) return; - setState(() => _ready = true); + setState(() => _ctl.ready = true); // (이미 열린 화면에 들어온 요청은 그 화면에서) if (!widget.rsync) ExplorerPage.goToRequest.addListener(_onGoToRequest); // 52: 다른 화면에 다녀와도 아직 도는 복사 · 이동의 진행 막대를 다시 보이고, 끝나면 그 폴더들을 새로 읽는다 @@ -500,14 +392,14 @@ class _ExplorerPageState extends State { } // 165: 드라이브 종류 · 연결은 드라이브마다 따로 뒤에서 (느린 드라이브가 있어도 다른 드라이브 · 창은 바로) if (Platform.isWindows) { - _drives.classify([for (final (path, _) in _local) path]); - for (final (path, _) in _local) { + _ctl.drives.classify([for (final (path, _) in _ctl.local) path]); + for (final (path, _) in _ctl.local) { unawaited(_probeDrive(path)); } } for (var i = 0; i < 2; i++) { - final pane = _panes[i]; - unawaited(_goTo(pane, wants[i], remember: false).then((_) => _reveal(pane, pane.current))); + final pane = _ctl.panes[i]; + unawaited(_ctl.goTo(pane, wants[i], remember: false).then((_) => _reveal(pane, pane.current))); } } @@ -538,10 +430,10 @@ class _ExplorerPageState extends State { _dualApp = dual; }); if (reload && changed && ok) { - for (final pane in _panes) { + for (final pane in _ctl.panes) { pane.cache.clear(); for (final d in pane.expanded.toList()) { - await _load(pane, d, force: true); + await _ctl.load(pane, d, force: true); } } } @@ -549,7 +441,7 @@ class _ExplorerPageState extends State { /// 권한이 없을 때 목록 위 안내 (이유 · 할 일 · 허용 화면 열기 · 다시 확인) /// X-plore 비교 5-2: 시스템 폴더를 보는 동안 늘 주황 띠 - Widget? _shizukuNotice(_Pane pane) { + Widget? _shizukuNotice(ExplorerPane pane) { if (!isShizuku(pane.current)) return null; final root = isRootPath(pane.current); return _notice( @@ -564,7 +456,7 @@ class _ExplorerPageState extends State { } /// X-plore 비교 5-2: 루트 탭 - 이번 실행에서 처음이면 경고, su 가 있고 허락을 받았을 때만 "/" 로 (관리자 3a 결정) - Future _openRoot(_Pane pane) async { + Future _openRoot(ExplorerPane pane) async { if (!ExplorerPage.rootWarnedThisRun) { final ok = await showDialog( context: context, @@ -598,12 +490,12 @@ class _ExplorerPageState extends State { return; } if (!mounted) return; - setState(() => _active = _panes.indexOf(pane)); - await _goTo(pane, rootHome); + setState(() => _ctl.active = _ctl.panes.indexOf(pane)); + await _ctl.goTo(pane, rootHome); } /// X-plore 비교 5-2: 시스템 폴더 탭 - 이번 실행에서 처음이면 경고, Shizuku 가 켜져 있고 허락을 받았을 때만 Android 폴더로 - Future _openShizuku(_Pane pane) async { + Future _openShizuku(ExplorerPane pane) async { if (!ExplorerPage.shizukuWarnedThisRun) { final ok = await showDialog( context: context, @@ -637,11 +529,11 @@ class _ExplorerPageState extends State { return; } if (!mounted) return; - setState(() => _active = _panes.indexOf(pane)); - await _goTo(pane, shizukuHome(_local.first.$1)); + setState(() => _ctl.active = _ctl.panes.indexOf(pane)); + await _ctl.goTo(pane, shizukuHome(_ctl.local.first.$1)); } - Widget? _accessNotice(_Pane pane) { + Widget? _accessNotice(ExplorerPane pane) { if (!Platform.isAndroid || _allFiles != false || isDav(pane.root)) return null; return _notice( icon: Icons.lock_outline, @@ -701,7 +593,7 @@ class _ExplorerPageState extends State { // ───────── 만들다 만 파일 (71) ───────── /// 복사 · 동기화가 중간에 끊겨 남은 임시 파일 (.jjpart · .jjsync). 지금 쓰는 중인 것 (2분 안에 바뀜) 은 빼고 - List _partials(_Pane pane, String dir) { + List _partials(ExplorerPane pane, String dir) { final old = DateTime.now().subtract(const Duration(minutes: 2)); return [ for (final e in pane.cache[dir] ?? const []) @@ -709,7 +601,7 @@ class _ExplorerPageState extends State { ]; } - Widget? _partialNotice(_Pane pane, String dir) { + Widget? _partialNotice(ExplorerPane pane, String dir) { final list = _partials(pane, dir); if (list.isEmpty) return null; return _notice( @@ -746,8 +638,8 @@ class _ExplorerPageState extends State { } } // 같은 폴더를 보는 다른 창도 새로 - for (final q in _panes) { - if (q == pane || q.cache.containsKey(dir)) await _load(q, dir, force: true); + for (final q in _ctl.panes) { + if (q == pane || q.cache.containsKey(dir)) await _ctl.load(q, dir, force: true); } if (failed.isNotEmpty) _snack(trf('지우지 못한 파일: {0}', [failed.join(', ')])); }, @@ -760,7 +652,7 @@ class _ExplorerPageState extends State { // ───────── 읽을 수 없을 때 (12) ───────── /// 오류 → (무엇이 문제인지, 무엇을 하면 되는지) - Widget? _errorNotice(_Pane pane, String dir) { + Widget? _errorNotice(ExplorerPane pane, String dir) { final err = pane.errors[dir] ?? pane.errors[pane.root]; if (err == null) return null; final where = pane.errors[dir] != null ? dir : pane.root; @@ -774,7 +666,7 @@ class _ExplorerPageState extends State { if (ftp != null && cert != null) { return _notice( icon: cert.changed ? Icons.gpp_bad_outlined : Icons.fingerprint, - title: '${_displayPath(where)}: ${cert.changed ? tr('서버 인증서가 바뀌었습니다') : tr('처음 보는 서버 인증서입니다')}', + title: '${_ctl.displayPath(where)}: ${cert.changed ? tr('서버 인증서가 바뀌었습니다') : tr('처음 보는 서버 인증서입니다')}', body: cert.message, actions: [ if (!cert.changed) @@ -785,7 +677,7 @@ class _ExplorerPageState extends State { pane.certErrors.remove(where); pane.errors.remove(where); pane.changed(); - await _load(pane, where, force: true); + await _ctl.load(pane, where, force: true); }, child: Text(tr('지문 확인하고 연결')), ), @@ -798,7 +690,7 @@ class _ExplorerPageState extends State { if (sftp != null && hostKey != null) { return _notice( icon: hostKey.changed ? Icons.gpp_bad_outlined : Icons.fingerprint, - title: '${_displayPath(where)}: ${hostKey.changed ? tr('서버의 키가 바뀌었습니다') : tr('처음 연결하는 서버입니다')}', + title: '${_ctl.displayPath(where)}: ${hostKey.changed ? tr('서버의 키가 바뀌었습니다') : tr('처음 연결하는 서버입니다')}', body: hostKey.message, actions: [ if (!hostKey.changed) @@ -809,7 +701,7 @@ class _ExplorerPageState extends State { pane.hostKeyErrors.remove(where); pane.errors.remove(where); pane.changed(); - await _load(pane, where, force: true); + await _ctl.load(pane, where, force: true); }, child: Text(tr('지문 확인하고 연결')), ), @@ -828,13 +720,13 @@ class _ExplorerPageState extends State { if (err == androidRestrictedError) { return _notice( icon: Icons.lock_outline, - title: '${_displayPath(where)}: $title', + title: '${_ctl.displayPath(where)}: $title', body: body, actions: [ FilledButton.tonal( onPressed: () { pane.errors.remove(where); - _goTo(pane, vDirname(where)); + _ctl.goTo(pane, vDirname(where)); }, child: Text(tr('위 폴더로')), ), @@ -843,7 +735,7 @@ class _ExplorerPageState extends State { } return _notice( icon: dav ? Icons.cloud_off_outlined : Icons.error_outline, - title: '${_displayPath(where)}: $title', + title: '${_ctl.displayPath(where)}: $title', body: body, actions: [ FilledButton.tonal( @@ -852,9 +744,9 @@ class _ExplorerPageState extends State { if (locked && !await SecretGate.pass(force: true)) return; pane.errors.remove(where); pane.changed(); - await _load(pane, where, force: true); + await _ctl.load(pane, where, force: true); for (final d in pane.expanded.toList()) { - if (isSameOrInside(d, where) && d != where) await _load(pane, d, force: true); + if (isSameOrInside(d, where) && d != where) await _ctl.load(pane, d, force: true); } }, child: Text(locked ? tr('마스터 비밀번호 넣기') : tr('다시 시도')), @@ -881,14 +773,14 @@ class _ExplorerPageState extends State { // 88: SFTP 는 sftp://아이디@서버:포트/경로 (시작 폴더를 비웠으면 홈 기준 ~/) if (d.isSftp) { final s = SftpRegistry.server(d.server); - if (s == null) return _displayPath(path); + if (s == null) return _ctl.displayPath(path); final base = s.root.isEmpty ? '/~' : (s.root.startsWith('/') ? s.root : '/${s.root}'); return 'sftp://${s.user.isEmpty ? '' : '${s.user}@'}${s.host}${s.port == 22 ? '' : ':${s.port}'}$base${d.isRoot ? '' : d.rel}'; } // l: FTP 는 ftp://아이디@서버:포트/경로 (암호화 990 은 ftps://, 비밀번호 없음) if (d.isFtp) { final s = FtpRegistry.server(d.server); - if (s == null) return _displayPath(path); + if (s == null) return _ctl.displayPath(path); final implicit = s.security == 'implicit'; final r = s.root.trim().replaceAll(RegExp(r'^/+|/+$'), ''); return '${implicit ? 'ftps' : 'ftp'}://${s.user.isEmpty ? '' : '${Uri.encodeComponent(s.user)}@'}${s.host}' @@ -897,198 +789,25 @@ class _ExplorerPageState extends State { // 88: SMB 는 smb://서버/공유/경로 (아이디 · 비밀번호 없음) if (d.isSmb) { final s = SmbRegistry.server(d.server); - if (s == null) return _displayPath(path); + if (s == null) return _ctl.displayPath(path); final r = s.root.trim().replaceAll(r'\', '/').replaceAll(RegExp(r'^/+|/+$'), ''); return 'smb://${s.host}${s.port == 445 ? '' : ':${s.port}'}/${s.share}${r.isEmpty ? '' : '/$r'}${d.isRoot ? '' : d.rel}'; } try { return DavRegistry.client(d.server).uriOf(d.rel).replace(userInfo: '').toString(); } catch (_) { - return _displayPath(path); + return _ctl.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(' › '); - } - - /// 설정에서 지운 WebDAV 서버를 보던 창은 이 기기의 첫 저장 장치로 - void _dropRemovedServers() { - if (!_ready) return; - for (final pane in _panes) { - if (isDav(pane.root) && !DavPath.parse(pane.root).serverKnown) { - pane.sel.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); - }); - } - } - } - - String _volumeOf(String path) => volumeOf(path, _local); - - // ───────── 목록 읽기 · 이동 ───────── - - Future _load(_Pane pane, String dir, {bool force = false}) async { - final r = await loadDir(pane, dir, - force: force, - showHidden: c.settings.explorerShowHidden, - sort: _sorted, - inside: _insideArchiveList, - osBlocked: (d) => isAndroidRestricted(d) ? androidRestrictedError : null, - onFresh: _sizes.forget); - // 37-2: 보던 폴더가 밖에서 지워졌으면 남아 있는 가장 가까운 상위 폴더로 (제목 · 경로 칸도 그 폴더로) - if (r.vanishedUpTo case final up?) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - _snack(tr('보던 폴더가 없어져 상위 폴더로 옮겼습니다')); - unawaited(_goTo(pane, up, fresh: true)); - }); - } - } - - /// X-plore 비교 f: 압축 안의 목록 (읽어 둔 것). 새로 고침이면 압축 파일이 바뀌었는지 보고 바뀌었으면 다시 읽는다. - /// 압축 안이 아니거나 아직 읽지 않았으면 null (보통 폴더로 읽음) - Future?> _insideArchiveList(String dir, bool force) async { - final arc = _archiveOf(dir); - if (arc == null) return null; - if (force && !await _readArchive(arc)) return const []; - return _arc.contains(arc) ? _arc.list(dir) : null; - } - - /// [dir] 의 목록을 정렬. 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); - - /// 84: [dir] 의 정렬 (기준, 거꾸로) - (String, bool) _sortOf(String dir, {List? list}) { - final s = c.settings; - // 폴더마다 기억한 것 (종류별 기본 정렬을 켰을 때 그 폴더에서 바꾼 것도) 이 먼저 - if (s.explorerSortPerFolder || s.explorerSortByKind) { - final v = s.explorerFolderSorts[_sortKey(dir)]; - if (v != null && v.contains('|')) return (v.split('|').first, v.endsWith('|1')); - } - if (s.explorerSortByKind) { - final k = _kindSort(dir, list: list); - if (k != null) return k; - } - return (s.explorerSort, s.explorerSortDesc); - } - - /// X-plore 비교 5-1 ① (관리자 지시 10/21): 종류별 기본 정렬 - 읽어 둔 목록의 파일 중 반 넘게가 사진 · 동영상이면 날짜 (새것 먼저 - - /// 내 판단: 갤러리처럼), 음악이면 이름. 그 밖 · 아직 읽지 않은 폴더는 null (전체 정렬). [list] 지금 줄 세우는 목록 (없으면 읽어 둔 것) - (String, bool)? _kindSort(String dir, {List? list}) { - if (list == null) { - for (final p in _panes) { - list = p.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; - } - - /// 84: 정렬을 바꾼다 - "폴더마다 기억" 이면 [dir] 에만, 아니면 전체. 모든 창의 목록을 다시 줄 세운다 - Future _setSort(String dir, String by, bool desc) async { - // 종류별 기본 정렬이 그 폴더에 쓰이고 있으면 바꾼 정렬은 그 폴더에 (전체 정렬을 바꾸면 이 폴더는 그대로라 눌러도 안 바뀐 것처럼 보인다) - final kindHere = c.settings.explorerSortByKind && _kindSort(dir) != null; - await c.updateSettings((x) { - if (x.explorerSortPerFolder || kindHere) { - 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 _reveal(ExplorerPane pane, String path) => WidgetsBinding.instance.addPostFrameCallback((_) => _revealNow(pane, path)); - void _revealNow(_Pane pane, String path) { + void _revealNow(ExplorerPane 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)); + final i = _ctl.rows(pane, foldersOnly: _split || widget.rsync).indexWhere((r) => r.kind == 0 && samePath(r.entry.path, path)); if (i < 0) return; final pos = pane.scroll.position; // 180: 끝 (maxScrollExtent) 으로 자르지 않는다 - 줄이 막 늘어난 때는 끝이 아직 앞의 것이라 보던 폴더로 못 간다. @@ -1097,70 +816,19 @@ class _ExplorerPageState extends State { } /// 28 확인 (10/20): 창의 목록이 화면에 새로 붙으면 (좁은 Rsync 화면의 [오른쪽] 탭을 처음 볼 때 · 폭이 바뀌어 두 창이 될 때) - /// 그 창의 지금 폴더를 보이게 한다. 켤 때 그려지지 않은 창은 [_goTo] 의 스크롤을 건너뛰어, 나중에 보면 맨 위 (저장 장치) 에 있었다 + /// 그 창의 지금 폴더를 보이게 한다. 켤 때 그려지지 않은 창은 [_ctl.goTo] 의 스크롤을 건너뛰어, 나중에 보면 맨 위 (저장 장치) 에 있었다 void _revealNewViews() => WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - for (final pane in _panes) { + for (final pane in _ctl.panes) { final on = pane.scroll.hasClients; if (on && !pane.onScreen && pane.current.isNotEmpty) _revealNow(pane, pane.current); pane.onScreen = on; } }); - /// 75: 창마다 뒤로 · 앞으로 (가 본 폴더로) - 내역 규칙은 [NavHistory] - Future _travel(_Pane pane, {required bool forward}) async { - final to = pane.nav.take(forward: forward); - if (to == null) return; - try { - 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; - } - pane.nav.arrive(pane.current, forward: forward); - await _goTo(pane, to); - } finally { - pane.nav.finish(); - } - 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); - /// X-plore 비교 40: 목록 글자 크기 (설정 %) · 줄 간격을 넣은 줄 높이 double get _listTextScale => c.settings.explorerTextScale / 100; - double _rowH() => _look.rowHeightOf(context, textScale: _listTextScale, spacing: rowSpacingFactor(c.settings.explorerRowSpacing)); + double _rowH() => _ctl.look.rowHeightOf(context, textScale: _listTextScale, spacing: rowSpacingFactor(c.settings.explorerRowSpacing)); /// 왼쪽 폴더 트리 + 오른쪽 파일 목록 배치 bool get _split => !widget.rsync && c.settings.explorerLayout == 'split' && !_splitTight; @@ -1187,59 +855,29 @@ class _ExplorerPageState extends State { /// 트리 줄. [foldersOnly]: 폴더만 ("폴더 + 파일 목록" 배치의 왼쪽) /// X-plore 비교 c: 트리 맨 위에 저장 장치 · 서버 · 즐겨찾기 (X-plore 스타일 · 설정) /// X-plore 비교 ⚖: X-plore 스타일에서 표시 동그라미 늘 보이기 (설정) - bool get _alwaysMarks => !widget.rsync && _look == ExplorerStyle.xplore && c.settings.explorerAlwaysMarks; + bool get _alwaysMarks => !widget.rsync && _ctl.look == ExplorerStyle.xplore && c.settings.explorerAlwaysMarks; /// X-plore 비교 ⚖: X-plore 스타일 탐색기 안에서 밀기 = 창 바꾸기 (설정) - bool get _swipePanes => !widget.rsync && _look == ExplorerStyle.xplore && c.settings.explorerSwipePanes; + bool get _swipePanes => !widget.rsync && _ctl.look == ExplorerStyle.xplore && c.settings.explorerSwipePanes; /// 밀어서 창 바꾸기: 두 창이면 다른 창을 활성으로, 한 창이면 숨은 창과 바꿔 보인다 bool _switchPane(int dir) { if (!_swipePanes) return false; setState(() { if (_dual || _split) { - _active = 1 - _active; + _ctl.active = 1 - _ctl.active; } else { - final t = _panes[0]; - _panes[0] = _panes[1]; - _panes[1] = t; - _active = 0; + final t = _ctl.panes[0]; + _ctl.panes[0] = _ctl.panes[1]; + _ctl.panes[1] = t; + _ctl.active = 0; } }); - _remember(_pane, _pane.current); - _snack(trf('창 바꿈: {0}', [_displayPath(_pane.current)])); + _ctl.remember(_ctl.pane, _ctl.pane.current); + _snack(trf('창 바꿈: {0}', [_ctl.displayPath(_ctl.pane.current)])); return true; } - bool get _rootsInTree => !widget.rsync && _look == ExplorerStyle.xplore && c.settings.explorerRootsInTree; - - List _rows(_Pane pane, {bool foldersOnly = false}) => buildTreeRows(pane, - 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); - - /// X-plore 숨기기: [path] 가 목록에서 숨긴 것인지 (앱 설정의 목록 - 파일은 그대로). 규칙은 HiddenList (E5c) - final _hidden = HiddenList(); - bool _appHidden(String path) => _hidden.isHidden(c.settings.explorerHiddenItems, path); - - /// X-plore 숨기기: 옮기거나 이름을 바꾼 것은 숨긴 목록도 새 경로로, 지운 것은 목록에서 뺀다 (관리자 결정 10/18) - Future _followHidden({Iterable<(String, String)> moved = const [], Iterable gone = const []}) async { - final out = await HiddenList.follow(c.settings.explorerHiddenItems, moved: moved, gone: gone); - if (out != null) 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; /// 종류별 기본 정렬 (5-1 ①) 을 켜고 끈 것 - 바뀌면 읽어 둔 목록을 다시 줄 세운다 @@ -1248,9 +886,9 @@ class _ExplorerPageState extends State { void _onSettings() { if (!mounted) return; if (_kindSeen != null && _kindSeen != c.settings.explorerSortByKind) { - for (final pane in _panes) { + for (final pane in _ctl.panes) { for (final k in pane.cache.keys.toList()) { - pane.cache[k] = _sorted(pane.cache[k]!, k); + pane.cache[k] = _ctl.sorted(pane.cache[k]!, k); } pane.changed(); } @@ -1260,24 +898,24 @@ class _ExplorerPageState extends State { if (identical(now, _hiddenSeen)) return; _hiddenSeen = now; setState(() {}); - for (final q in _panes) { + for (final q in _ctl.panes) { q.changed(); } } Widget _hiddenToggleRow(TreeRow row) { - final look = _look; - final label = _showAppHidden ? trf('숨긴 항목 {0}개 다시 숨기기', [row.count]) : trf('숨긴 항목 {0}개 보이기', [row.count]); + final look = _ctl.look; + final label = _ctl.showAppHidden ? trf('숨긴 항목 {0}개 다시 숨기기', [row.count]) : trf('숨긴 항목 {0}개 보이기', [row.count]); return Semantics( button: true, label: label, excludeSemantics: true, - onTap: _toggleAppHidden, + onTap: _ctl.toggleAppHidden, child: InkWell( - onTap: _toggleAppHidden, + onTap: _ctl.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), + Icon(_ctl.showAppHidden ? Icons.visibility_off_outlined : Icons.visibility_outlined, size: look.iconSize * 0.8, color: JjColors.textDim), const SizedBox(width: 8), Expanded( child: Text(label, @@ -1289,11 +927,11 @@ class _ExplorerPageState extends State { } /// X-plore 비교 c: 트리 맨 위 항목 줄 (다른 저장 장치 · 서버 · 즐겨찾기) - 누르면 그곳으로, 즐겨찾기 묶음은 펼치기 · 접기 - Widget _extraRow(_Pane pane, TreeRow row) { + Widget _extraRow(ExplorerPane pane, TreeRow row) { if (row.kind == TreeRow.hiddenToggle) return _hiddenToggleRow(row); - final look = _look; + final look = _ctl.look; final path = row.entry.path; - final volume = _volumes.firstWhere((v) => samePath(v.$1, path), orElse: () => (path, vBasename(path))).$2; + final volume = _ctl.volumes.firstWhere((v) => samePath(v.$1, path), orElse: () => (path, vBasename(path))).$2; final fav = row.kind == TreeRow.favorite ? c.settings.folderBookmarks.firstWhere((b) => b.path == path, orElse: () => FolderBookmark(name: vBasename(path), path: path)) : null; @@ -1312,13 +950,13 @@ class _ExplorerPageState extends State { _ => (Icons.star_outline, JjColors.amber, fav!.name, null), }; void tap() { - setState(() => _active = _panes.indexOf(pane)); + setState(() => _ctl.active = _ctl.panes.indexOf(pane)); if (row.kind == TreeRow.favorites) { pane.favoritesOpen = !pane.favoritesOpen; pane.changed(); return; } - unawaited(_goTo(pane, path, fresh: true).then((_) => _reveal(pane, path))); + unawaited(_ctl.goTo(pane, path, fresh: true).then((_) => _reveal(pane, path))); } return Semantics( @@ -1340,7 +978,7 @@ class _ExplorerPageState extends State { 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), + Text(_ctl.displayPath(path), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: look.fontSize - 3, color: JjColors.textDim)), ]), ), @@ -1357,7 +995,7 @@ class _ExplorerPageState extends State { void _setSelecting(bool on) => setState(() { _selecting = on; if (!on) { - for (final x in _panes) { + for (final x in _ctl.panes) { x.sel.clear(); } } @@ -1371,9 +1009,9 @@ class _ExplorerPageState extends State { /// X-plore 비교 d: 마지막으로 누른 폴더 · 시각 (두 번 누르기의 두 번째를 가리려고) (String, DateTime)? _lastFolderTap; - Future _onTap(_Pane pane, TreeRow row, {bool list = false}) async { + Future _onTap(ExplorerPane pane, TreeRow row, {bool list = false}) async { final e = row.entry; - setState(() => _active = _panes.indexOf(pane)); + setState(() => _ctl.active = _ctl.panes.indexOf(pane)); if (widget.rsync && !row.isUp) { await _pick(pane, e); return; @@ -1416,7 +1054,7 @@ class _ExplorerPageState extends State { : ItemKind.file; /// [ClickPolicy] 가 정한 일을 한다 (화면 쪽 동작만 - 규칙은 [ClickPolicy] · [Selection]) - Future _act(_Pane pane, TreeRow row, ClickAction act, {bool list = false}) async { + Future _act(ExplorerPane pane, TreeRow row, ClickAction act, {bool list = false}) async { final e = row.entry; switch (act) { case ClickAction.none: @@ -1439,7 +1077,7 @@ class _ExplorerPageState extends State { ..current = e.path ..focused = e.path ..changed(); - _remember(pane, e.path); + _ctl.remember(pane, e.path); } case ClickAction.toggleMark: if (!_selecting) setState(() => _selecting = true); @@ -1449,16 +1087,16 @@ class _ExplorerPageState extends State { /// 표시되었는지: 자기 자신, 또는 위 폴더가 표시되었으면 (폴더를 고르면 안의 것도 모두 고른 것). /// Rsync 화면은 고른 그 폴더만. - bool _isMarked(_Pane pane, String path) => pane.sel.isMarked(path); + bool _isMarked(ExplorerPane pane, String path) => pane.sel.isMarked(path); /// [Selection] 이 폴더를 나눌 때 안의 경로 (읽어 둔 목록, 없으면 읽음) - Future> _childPaths(_Pane pane, String dir) async { - await _load(pane, dir); + Future> _childPaths(ExplorerPane pane, String dir) async { + await _ctl.load(pane, dir); return [for (final x in pane.cache[dir] ?? const []) x.path]; } /// Rsync 화면: 창마다 폴더 하나만 고른다 (다시 누르면 취소, 규칙 8). 고르면 펼쳐서 안의 폴더가 보이게. - Future _pick(_Pane pane, FileEntry e) async { + Future _pick(ExplorerPane pane, FileEntry e) async { if (!e.isDir) return; await pane.sel.toggle(e.path, isDir: true, list: (d) => _childPaths(pane, d)); if (!mounted) return; @@ -1475,32 +1113,32 @@ class _ExplorerPageState extends State { } /// 74: 범위 고르기를 할 수 있는지 - 마지막으로 표시한 것이 같은 폴더에 있고 다른 항목 - bool _canRange(_Pane pane, FileEntry e) { + bool _canRange(ExplorerPane pane, FileEntry e) { final a = pane.sel.anchor; return a != null && !samePath(a, e.path) && samePath(vDirname(a), vDirname(e.path)) && _isMarked(pane, a); } /// 76: [dir] 에서 지금 보이는 것 (빠른 필터에 맞는 것) /// X-plore 숨기기: 목록에서 숨긴 것은 [숨긴 항목 보이기] 를 켰을 때만 - List _shown(_Pane pane, String dir) => [ + List _shown(ExplorerPane pane, String dir) => [ for (final x in pane.cache[dir] ?? const []) - if (passesFilter(pane, x) && (_showAppHidden || !_appHidden(x.path))) x + if (passesFilter(pane, x) && (_ctl.showAppHidden || !_ctl.appHidden(x.path))) x ]; /// 74: 마지막으로 표시한 것부터 [to] 까지 (보이는 순서대로) 모두 표시 - void _markRange(_Pane pane, FileEntry to) => setState(() { + void _markRange(ExplorerPane pane, FileEntry to) => setState(() { pane.sel.range([for (final x in _shown(pane, vDirname(to.path))) x.path], to.path); pane.focused = to.path; }); /// 74: 고르기 도구 - 이 폴더 ([dir]) 의 항목에서 - void _invertMarks(_Pane pane, String dir) => + void _invertMarks(ExplorerPane pane, String dir) => setState(() => pane.sel.invert([for (final x in _shown(pane, dir)) x.path])); - void _markWhere(_Pane pane, String dir, bool Function(FileEntry e) test) => + void _markWhere(ExplorerPane pane, String dir, bool Function(FileEntry e) test) => setState(() => pane.sel.addAll([for (final x in _shown(pane, dir)) if (test(x)) x.path])); - Future _markPattern(_Pane pane, String dir) async { + Future _markPattern(ExplorerPane pane, String dir) async { final pattern = await showDialog(context: context, builder: (_) => const _PatternDialog()); if (pattern == null || pattern.trim().isEmpty) return; final re = globToRegExp(pattern); @@ -1510,7 +1148,7 @@ class _ExplorerPageState extends State { } /// 선택 모드에서 누르기 (규칙 5 · 6 은 [Selection.toggle]). 고른 폴더는 펼쳐서 안의 것도 고른 모양으로 보여 준다. - Future _toggleMark(_Pane pane, FileEntry e) async { + Future _toggleMark(ExplorerPane pane, FileEntry e) async { pane.focused = e.path; try { await pane.sel.toggle(e.path, isDir: e.isDir, list: (d) => _childPaths(pane, d)); @@ -1520,19 +1158,19 @@ class _ExplorerPageState extends State { if (!mounted) return; setState(() {}); if (e.isDir && pane.sel.has(e.path) && !pane.expanded.contains(e.path)) { - await _load(pane, e.path); + await _ctl.load(pane, e.path); if (mounted) setState(() => pane.expanded.add(e.path)); } } /// 열기 (파일: 선택 설정이면 두 번 누르기, 바로 실행이면 한 번 누르기 / 폴더: 한 번 누르기): /// 트리의 폴더는 펼치기 · 접기, 목록의 폴더는 들어가기 (왼쪽 트리도 따라감), 파일은 실행. - Future _onOpen(_Pane pane, TreeRow row, {bool list = false}) async { + Future _onOpen(ExplorerPane pane, TreeRow row, {bool list = false}) async { final e = row.entry; - setState(() => _active = _panes.indexOf(pane)); + setState(() => _ctl.active = _ctl.panes.indexOf(pane)); pane.focused = e.path; if (row.isUp || (e.isDir && list)) { - await _goTo(pane, e.path); + await _ctl.goTo(pane, e.path); return; } if (e.isDir) { @@ -1544,7 +1182,7 @@ class _ExplorerPageState extends State { pane ..current = e.path ..changed(); - _remember(pane, e.path); + _ctl.remember(pane, e.path); } return; } @@ -1554,14 +1192,14 @@ class _ExplorerPageState extends State { } /// 트리 폴더 펼치기 · 접기 (› 화살표 · 열기) - Future _toggle(_Pane pane, FileEntry e) async { - setState(() => _active = _panes.indexOf(pane)); + Future _toggle(ExplorerPane pane, FileEntry e) async { + setState(() => _ctl.active = _ctl.panes.indexOf(pane)); // X-plore 비교 f: 압축 파일을 폴더처럼 펼치기 (읽기 전용) if (_canBrowseArchive(e) && !pane.expanded.contains(e.path)) { - if (!await _readArchive(e.path)) return; + if (!await _ctl.readArchive(e.path)) return; pane.cache.remove(e.path); pane.expanded.add(e.path); - await _load(pane, e.path); + await _ctl.load(pane, e.path); return; } if (pane.expanded.contains(e.path)) { @@ -1575,9 +1213,9 @@ class _ExplorerPageState extends State { ..expanded.add(e.path) ..current = e.path ..focused = e.path; - await _load(pane, e.path); + await _ctl.load(pane, e.path); if (pane.listScroll.hasClients) pane.listScroll.jumpTo(0); - _remember(pane, e.path); + _ctl.remember(pane, e.path); } /// 다른 앱으로 열지 못했을 때: 음악 · 동영상이면 내장 플레이어를 알려 준다 (29) @@ -1586,7 +1224,7 @@ class _ExplorerPageState extends State { : tr('이 파일을 열 수 있는 앱이 없습니다.'); /// 파일 열기 (기본 동작): 동영상은 내장 플레이어 (또는 확장자별 프로그램), 그 밖은 기본 연결 프로그램 - Future _open(String path, {_Pane? pane}) async { + Future _open(String path, {ExplorerPane? pane}) async { // X-plore 비교 f: 압축 안의 파일 - 그 파일만 임시 폴더로 꺼내 연다 if (_insideArchive(path)) { final local = await _extractOne(path); @@ -1603,7 +1241,7 @@ class _ExplorerPageState extends State { case OpenRoute.player: return _play([path]); case OpenRoute.extractArchive: - return _extractArchive(path, pane ?? _pane); + return _extractArchive(path, pane ?? _ctl.pane); case OpenRoute.images: return _viewImages(path, pane); case OpenRoute.pdf: @@ -1634,8 +1272,8 @@ class _ExplorerPageState extends State { } /// X-plore 비교 g: 폴더의 그림 · 동영상을 격자로. 길게 누른 것은 탐색기에서 그 자리로 - Future _grid(_Pane pane, String dir) async { - await _load(pane, dir); + Future _grid(ExplorerPane pane, String dir) async { + await _ctl.load(pane, dir); if (!mounted) return; final list = _shown(pane, dir); final at = await Navigator.of(context).push(MaterialPageRoute( @@ -1648,7 +1286,7 @@ class _ExplorerPageState extends State { ), )); if (at == null || !mounted) return; - await _goTo(pane, dir); + await _ctl.goTo(pane, dir); pane.focused = at; pane.changed(); } @@ -1663,12 +1301,12 @@ class _ExplorerPageState extends State { } /// 그림 보기: 같은 폴더의 그림을 지금 정렬 순서대로 넘겨 본다 - Future _viewImages(String path, _Pane? pane) async { + Future _viewImages(String path, ExplorerPane? pane) async { final dir = vDirname(path); // 폴더를 읽지 못하면 (E10: 이제 오류로 알림) 고른 그림 하나만 List list; try { - list = pane?.cache[dir] ?? _sorted(await listEntries(dir, showHidden: c.settings.explorerShowHidden), dir); + list = pane?.cache[dir] ?? _ctl.sorted(await listEntries(dir, showHidden: c.settings.explorerShowHidden), dir); } catch (_) { list = const []; } @@ -1684,17 +1322,17 @@ class _ExplorerPageState extends State { } /// X-plore 맞추기 (관리자 지시 10/20): 폴더의 그림 (지금 정렬 순서) 을 보기 화면으로 열고 [계속 보기] 를 켠 채 시작 - Future _slideshow(_Pane pane, String dir) async { + Future _slideshow(ExplorerPane pane, String dir) async { List list; try { - list = pane.cache[dir] ?? _sorted(await listEntries(dir, showHidden: c.settings.explorerShowHidden), dir); + list = pane.cache[dir] ?? _ctl.sorted(await listEntries(dir, showHidden: c.settings.explorerShowHidden), dir); } catch (e) { _snack(trf('폴더를 읽을 수 없습니다: {0}', [e])); return; } final images = [ for (final e in list) - if (!e.isDir && c.settings.imageExts.contains(extOf(e.path)) && (_showAppHidden || !_appHidden(e.path))) e.path, + if (!e.isDir && c.settings.imageExts.contains(extOf(e.path)) && (_ctl.showAppHidden || !_ctl.appHidden(e.path))) e.path, ]; if (images.isEmpty) { _snack(tr('이 폴더에는 그림이 없습니다')); @@ -1779,14 +1417,14 @@ class _ExplorerPageState extends State { } if (!mounted) return; await openReader(context, c, src); - await _refreshAll([vDirname(path)]); + await _ctl.refreshAll([vDirname(path)]); } /// ZIP: 환경 설정이 "만화 보기" 면 안의 그림을 바로 보고 (그림이 없으면 목록), 아니면 목록 /// 87: 저장 공간 분석 → 고른 것을 탐색기에서 그 자리로 (지우기는 탐색기의 지우기로만). /// WebDAV 는 직접 시킬 때만 (이 메뉴) 하고, 서버에서 모두 읽어 오래 걸린다고 먼저 알린다 - Future _storage(_Pane pane, String dir) async { - setState(() => _active = _panes.indexOf(pane)); + Future _storage(ExplorerPane pane, String dir) async { + setState(() => _ctl.active = _ctl.panes.indexOf(pane)); if (isDav(dir)) { final ok = await showDialog( context: context, @@ -1808,7 +1446,7 @@ class _ExplorerPageState extends State { }, onCut: (paths) => _toClipboard(paths, move: true)); if (path == null || !mounted) return; final isDir = await vIsDir(path); - await _goTo(pane, isDir ? path : vDirname(path), fresh: true); + await _ctl.goTo(pane, isDir ? path : vDirname(path), fresh: true); pane.focused = path; pane.changed(); _reveal(pane, path); @@ -1853,13 +1491,13 @@ class _ExplorerPageState extends State { } /// 86: 압축 풀기 (검사 · 확인 · 진행 · 취소) 뒤 목록 새로 고침 - Future _extractArchive(String path, _Pane pane) async { - final other = _dual ? _panes[1 - _panes.indexOf(pane)].current : null; + Future _extractArchive(String path, ExplorerPane pane) async { + final other = _dual ? _ctl.panes[1 - _ctl.panes.indexOf(pane)].current : null; final out = await extractArchiveFlow(context, c, path, otherDir: other); - if (out != null && mounted) await _refreshAll([out, vDirname(out)]); + if (out != null && mounted) await _ctl.refreshAll([out, vDirname(out)]); } - Future _openZip(String path, _Pane? pane) async { + Future _openZip(String path, ExplorerPane? pane) async { try { if (c.settings.zipComic && await openZipComic(context, c, path)) return; } catch (e) { @@ -1867,9 +1505,9 @@ class _ExplorerPageState extends State { return; } if (!mounted) return; - final other = pane == null || !_dual ? null : _panes[1 - _panes.indexOf(pane)].current; + final other = pane == null || !_dual ? null : _ctl.panes[1 - _ctl.panes.indexOf(pane)].current; final out = await openZip(context, c, path, otherDir: other); - if (out != null) await _refreshAll([out, vDirname(out)]); + if (out != null) await _ctl.refreshAll([out, vDirname(out)]); } /// 고른 그림들을 한 장씩 PDF 로 (같은 폴더에, 이름을 묻는다) @@ -1887,11 +1525,11 @@ class _ExplorerPageState extends State { } catch (e) { _snack(trf('PDF 를 만들지 못했습니다: {0}', [e])); } - await _refreshAll([dir]); + await _ctl.refreshAll([dir]); } - Future _menu(_Pane pane, FileEntry e, Offset at) async { - setState(() => _active = _panes.indexOf(pane)); + Future _menu(ExplorerPane pane, FileEntry e, Offset at) async { + setState(() => _ctl.active = _ctl.panes.indexOf(pane)); pane.focused = e.path; pane.changed(); final video = !e.isDir && isVideoFile(e.path); @@ -1937,7 +1575,7 @@ class _ExplorerPageState extends State { // 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('폴더 크기 계산')), + if (e.isDir) ('folderSize', Icons.data_usage, _ctl.sizes.busy(e.path) ? tr('폴더 크기 계산 멈추기') : tr('폴더 크기 계산')), // X-plore P3: 폴더 속성 (안의 파일 · 폴더 수 · 크기 · 만든 · 바뀐 때) · 파일 해시 (MD5 · SHA-256) - 이 기기의 것만 (원격은 다 받아야 해서 뺌) if (e.isDir && !dav) ('props', Icons.fact_check_outlined, tr('폴더 속성')), if (!e.isDir && !dav) ('hash', Icons.tag, tr('해시 (MD5 · SHA-256)')), @@ -1964,7 +1602,7 @@ class _ExplorerPageState extends State { images.length == 1 ? tr('PDF 로 만들기') : trf('그림 {0}장을 PDF 로 만들기', [images.length])), ('select', Icons.check_circle_outline, tr('선택')), // X-plore 숨기기: 목록에서만 숨긴다 (파일은 그대로) · 숨긴 것을 보이고 있으면 풀기 - targets.every(_appHidden) + targets.every(_ctl.appHidden) ? ('appUnhide', Icons.visibility_outlined, many ? trf('{0}개 숨기기 풀기', [targets.length]) : tr('숨기기 풀기')) : ('appHide', Icons.visibility_off_outlined, many ? trf('{0}개 목록에서 숨기기', [targets.length]) : tr('목록에서 숨기기')), // X-plore 맞추기 (관리자 지시 10/20): 폴더에 .nomedia 를 두면 갤러리 · 음악 앱이 그 안의 그림 · 동영상 · 음악을 보이지 않는다 (Android) @@ -2024,11 +1662,11 @@ class _ExplorerPageState extends State { case 'pickRsync': await _pick(pane, e); case 'open': - await _goTo(pane, e.path); + await _ctl.goTo(pane, e.path); case 'openOther': - final other = _panes[1 - _panes.indexOf(pane)]; - await _goTo(other, e.path); - setState(() => _active = _panes.indexOf(other)); + final other = _ctl.panes[1 - _ctl.panes.indexOf(pane)]; + await _ctl.goTo(other, e.path); + setState(() => _ctl.active = _ctl.panes.indexOf(other)); case 'playFolder': await _playFolder(e.path); case 'slideshow': @@ -2047,7 +1685,7 @@ class _ExplorerPageState extends State { _shiftDelete = permanent; return _delete(pane, paths); }); - await _refreshAll([vDirname(e.path)]); + await _ctl.refreshAll([vDirname(e.path)]); case 'openWith': final local = await _fetch(e.path); if (local != null && !await c.services.shell.openWith(local, choose: true)) _snack(_noAppMessage(e.path)); @@ -2071,10 +1709,10 @@ class _ExplorerPageState extends State { await _wifiShare(e.path); case 'zip': final items = outermostPaths(_isMarked(pane, e.path) && pane.sel.marked.length > 1 ? pane.sel.marked : [e.path]); - final other = _dual ? _panes[1 - _panes.indexOf(pane)].current : null; + final other = _dual ? _ctl.panes[1 - _ctl.panes.indexOf(pane)].current : null; final made = await createZipFlow(context, c, items, dir: vDirname(items.first), otherDir: other); if (made != null && mounted) { - await _refreshAll([vDirname(made)]); + await _ctl.refreshAll([vDirname(made)]); pane.focused = made; pane.changed(); } @@ -2083,14 +1721,14 @@ class _ExplorerPageState extends State { await c.updateSettings((s) => s.explorerHiddenItems = [ ...s.explorerHiddenItems, for (final x in targets) - if (!_appHidden(x)) x, + if (!_ctl.appHidden(x)) x, ]); - for (final q in _panes) { + for (final q in _ctl.panes) { targets.forEach(q.sel.forget); q.changed(); } if (mounted) setState(() {}); - _snack(_showAppHidden + _snack(_ctl.showAppHidden ? trf('목록에서 숨겼습니다 ({0}개). 파일은 그대로입니다.', [targets.length]) : trf('목록에서 숨겼습니다 ({0}개). 파일은 그대로이고, 목록 맨 아래의 [숨긴 항목 보이기] 로 다시 볼 수 있습니다.', [targets.length])); case 'appUnhide': @@ -2099,7 +1737,7 @@ class _ExplorerPageState extends State { for (final x in s.explorerHiddenItems) if (!keys.contains(HiddenList.keyOf(x))) x, ]); - for (final q in _panes) { + for (final q in _ctl.panes) { q.changed(); } if (mounted) setState(() {}); @@ -2110,12 +1748,12 @@ class _ExplorerPageState extends State { case 'hash': await showFileHash(context, e.path); case 'folderSize': - if (_sizes.busy(e.path)) { - _sizes.cancel(e.path); + if (_ctl.sizes.busy(e.path)) { + _ctl.sizes.cancel(e.path); break; } // 사용자가 직접 시킨 것: 다른 폴더로 가도 끝까지 (멈추기는 아래 알림 · 같은 메뉴) - _sizes + _ctl.sizes ..forget(e.path) ..request(e.path); if (mounted) { @@ -2123,7 +1761,7 @@ class _ExplorerPageState extends State { text: isDav(e.path) ? trf('"{0}" 크기를 계산합니다. 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다.', [e.name]) : trf('"{0}" 크기를 계산합니다.', [e.name]), - action: (tr('취소'), () => _sizes.cancel(e.path))); + action: (tr('취소'), () => _ctl.sizes.cancel(e.path))); } case 'rename': if (_isMarked(pane, e.path) && pane.sel.marked.length > 1) { @@ -2188,54 +1826,29 @@ class _ExplorerPageState extends State { // ───────── 기능 ───────── - _Pane get _pane => _panes[_active]; - _Pane? get _other => _dual || _hiddenTarget ? _panes[1 - _active] : null; + ExplorerPane? get _other => _dual || _hiddenTarget ? _ctl.panes[1 - _ctl.active] : null; // ───────── X-plore 비교 f: 압축을 트리 안에서 폴더처럼 (ZIP · 7z · RAR, 읽기 전용) ───────── - /// 압축 보기 (읽기 · 바뀌면 다시 읽기 · 안의 목록 · 하나 꺼내기) 는 ArchiveBrowser (E5c). 잊으면 창들의 그 안 목록도 지운다 - late final _arc = ArchiveBrowser(bundledSevenZip, onForget: (arc) { - for (final q in _panes) { - q.cache.removeWhere((k, _) => samePath(k, arc) || isSameOrInside(k, arc)); - } - }); - /// 트리에서 펼칠 수 있는 압축 (이 기기의 파일 · 탐색기 화면) bool _canBrowseArchive(FileEntry e) => !widget.rsync && ArchiveBrowser.canBrowse(e); - String? _archiveOf(String path) => _arc.archiveOf(path); - bool _insideArchive(String path) => _arc.inside(path); - - /// 압축의 목록을 읽어 둔다 (비밀번호가 걸린 7z 등은 [압축 풀기] 로 안내). 읽었으면 true - Future _readArchive(String path) async { - switch (await _arc.read(path)) { - case ArchiveRead.ok: - return true; - case ArchiveRead.gone: - return false; - case ArchiveRead.noTool: - _snack(tr('이 기기에서는 7z · RAR 안을 볼 수 없습니다 (다른 앱으로 열어 주세요)')); - case ArchiveRead.password: - _snack(tr('비밀번호가 걸린 압축은 안을 미리 볼 수 없습니다 - [압축 풀기] 로 푸세요')); - case ArchiveRead.failed: - _snack(trf('압축을 읽지 못했습니다: {0}', [_arc.lastError])); - } - return false; - } + String? _archiveOf(String path) => _ctl.arc.archiveOf(path); + bool _insideArchive(String path) => _ctl.arc.inside(path); /// 압축 안의 파일 하나를 임시 폴더로 꺼낸다 (보기 · 재생). 꺼낸 경로 (못 하면 null) Future _extractOne(String path) async { - final (out, why) = await _arc.extractOne(path, await c.services.storage.tempDirectory()); + final (out, why) = await _ctl.arc.extractOne(path, await c.services.storage.tempDirectory()); if (why == ArchiveRead.password) _snack(tr('비밀번호가 걸린 파일입니다 - [압축 풀기] 로 푸세요')); - if (why == ArchiveRead.failed) _snack(trf('압축에서 꺼내지 못했습니다: {0}', [_arc.lastError])); + if (why == ArchiveRead.failed) _snack(trf('압축에서 꺼내지 못했습니다: {0}', [_ctl.arc.lastError])); return out; } /// 압축 안의 것을 꺼내기 - 86 의 풀기 창 (검사 · 갈 곳 · 같은 이름) 그대로 Future _extractFromArchive(List paths, String? otherDir) async { final arc = _archiveOf(paths.firstWhere(_insideArchive))!; - final names = [for (final x in paths) if (_insideArchive(x) && samePath(_archiveOf(x)!, arc)) _arc.innerName(arc, x)]; + final names = [for (final x in paths) if (_insideArchive(x) && samePath(_archiveOf(x)!, arc)) _ctl.arc.innerName(arc, x)]; final done = await extractArchiveFlow(context, c, arc, otherDir: otherDir, names: names); - if (done != null && mounted) await _refreshAll([done]); + if (done != null && mounted) await _ctl.refreshAll([done]); } /// 관리자 지시 (10/21, 28 지적): 시스템 폴더 · 루트 (shz://) 가 원본이나 받는 곳인 이동은 시작하지 않는다 - @@ -2256,7 +1869,7 @@ class _ExplorerPageState extends State { : tr('시스템 폴더 (Shizuku) 는 읽기 전용입니다 (꺼내기 (복사) 만 할 수 있습니다)')); return true; } - if (!paths.any((x) => _insideArchive(x) || (self && _arc.contains(x)))) return false; + if (!paths.any((x) => _insideArchive(x) || (self && _ctl.arc.contains(x)))) return false; _snack(tr('압축 안은 읽기 전용입니다 (꺼내기만 할 수 있습니다)')); return true; } @@ -2266,11 +1879,11 @@ class _ExplorerPageState extends State { /// 폴더 이름 (저장 장치 맨 위면 그 이름) - 좁은 줄에 경로 대신 (전체 경로는 풍선 도움말) String _folderName(String dir) => - _volumes.firstWhere((v) => samePath(v.$1, dir), orElse: () => (dir, vBasename(dir).isEmpty ? _displayPath(dir) : vBasename(dir))).$2; + _ctl.volumes.firstWhere((v) => samePath(v.$1, dir), orElse: () => (dir, vBasename(dir).isEmpty ? _ctl.displayPath(dir) : vBasename(dir))).$2; /// X-plore 비교 a: 한 창일 때 맨 위의 "→ 갈 곳" 줄 - 숨은 창의 폴더 · [지금 폴더로] · [다른 폴더] Widget _targetBar() { - final t = _panes[1 - _active]; + final t = _ctl.panes[1 - _ctl.active]; return Material( color: JjColors.accent.withValues(alpha: 0.10), child: Padding( @@ -2281,7 +1894,7 @@ class _ExplorerPageState extends State { // 412 캡처 (10/18): "갈 곳:" 은 늘 보이고, 폴더 이름이 길면 가운데를 줄인다 (전체 경로는 길게 눌러 · 마우스를 올려) Expanded( child: Tooltip( - message: _displayPath(t.current), + message: _ctl.displayPath(t.current), triggerMode: TooltipTriggerMode.longPress, child: LayoutBuilder(builder: (context, box) { const style = TextStyle(fontSize: 12.5); @@ -2293,14 +1906,14 @@ class _ExplorerPageState extends State { ), TextButton( style: TextButton.styleFrom(visualDensity: VisualDensity.compact), - onPressed: () => _goTo(t, _pane.current), + onPressed: () => _ctl.goTo(t, _ctl.pane.current), child: Text(tr('지금 폴더로')), ), TextButton( style: TextButton.styleFrom(visualDensity: VisualDensity.compact), onPressed: () async { final d = await pickFolderOrDav(context, tr('복사 · 이동 갈 곳'), t.current); - if (d != null && mounted) await _goTo(t, d); + if (d != null && mounted) await _ctl.goTo(t, d); }, child: Text(tr('다른 폴더')), ), @@ -2310,7 +1923,7 @@ class _ExplorerPageState extends State { } /// 기능의 대상: 표시한 항목, 없으면 마지막으로 누른 항목 (저장 장치 맨 위는 빼고) - List _targets(_Pane pane) { + List _targets(ExplorerPane pane) { if (!pane.sel.isEmpty) return pane.sel.marked; final f = pane.focused; if (f == null || samePath(f, pane.root)) return const []; @@ -2322,44 +1935,6 @@ class _ExplorerPageState extends State { 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: @@ -2375,13 +1950,13 @@ class _ExplorerPageState extends State { } Future _button(ExplorerButton b) async { - final pane = _pane; + final pane = _ctl.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); + await _ctl.goTo(pane, up); case ExplorerButton.select: _setSelecting(!_selecting); case ExplorerButton.monitor: @@ -2394,7 +1969,7 @@ class _ExplorerPageState extends State { await _runRsync(right: true, left: true); case ExplorerButton.refresh: // 183: 두 창 모두 (밖에서 지운 파일이 다른 창에 남지 않게) - await _reloadOpen(); + await _ctl.reloadOpen(); case ExplorerButton.play: final t = _targets(pane); // 181: 강조한 것이 mp3 등 소리 파일이어도 그것을 (예전에는 동영상이 아니라고 빼고 폴더의 동영상을 틀었다) @@ -2417,8 +1992,8 @@ class _ExplorerPageState extends State { final made = await FileOps.makeFolder(parent, name); // 만든 폴더가 트리에 보이게: 부모를 펼치고 다시 읽은 뒤 그 줄까지 스크롤 pane.expanded.add(parent); - await _load(pane, parent, force: true); - await _refreshAll([parent]); + await _ctl.load(pane, parent, force: true); + await _ctl.refreshAll([parent]); // Rsync 화면: 만든 폴더를 이 창의 원본 · 대상으로 바로 고른다 if (widget.rsync) { pane.sel.clear(); @@ -2467,10 +2042,10 @@ class _ExplorerPageState extends State { await _sortDialog(); case ExplorerButton.hidden: await c.updateSettings((s) => s.explorerShowHidden = !s.explorerShowHidden); - for (final x in _panes) { + for (final x in _ctl.panes) { x.cache.clear(); for (final d in x.expanded.toList()) { - await _load(x, d, force: true); + await _ctl.load(x, d, force: true); } } _snack(c.settings.explorerShowHidden ? tr('숨은 항목을 보입니다.') : tr('숨은 항목을 감춥니다.')); @@ -2530,15 +2105,15 @@ class _ExplorerPageState extends State { _snack(trf('MKV 목록에 {0}개를 추가했습니다.', [videos.length])); } - Future _rename(_Pane pane, String path) async { + Future _rename(ExplorerPane pane, String path) async { if (_blockArchive([path])) return; final name = await _askName(tr('이름 변경'), vBasename(path)); if (name == null || name == vBasename(path)) return; try { final now = await FileOps.rename(path, name); - await _followHidden(moved: [(path, now)]); - await _refreshAll([vDirname(path)]); - for (final x in _panes) { + await _ctl.followHidden(moved: [(path, now)]); + await _ctl.refreshAll([vDirname(path)]); + for (final x in _ctl.panes) { x.sel.rename(path, now); } pane.focused = now; @@ -2549,7 +2124,7 @@ class _ExplorerPageState extends State { } /// 78: 일괄 이름 바꾸기 - 번호는 목록에 보이는 차례대로. 끝나면 [되돌리기] - Future _batchRename(_Pane pane, List paths) async { + Future _batchRename(ExplorerPane pane, List paths) async { if (_blockArchive(paths)) return; int at(String x) { final i = (pane.cache[vDirname(x)] ?? const []).indexWhere((e) => samePath(e.path, x)); @@ -2583,10 +2158,10 @@ class _ExplorerPageState extends State { } /// 바꾼 뒤: 목록을 다시 읽고, 표시 · 초점을 새 이름으로 - 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) { + Future _afterBatchRename(ExplorerPane pane, List<(String, String)> moved) async { + await _ctl.followHidden(moved: moved); + await _ctl.refreshAll({for (final (a, _) in moved) vDirname(a)}); + for (final x in _ctl.panes) { for (final (from, to) in moved) { x.sel.rename(from, to); if (x.focused != null && samePath(x.focused!, from)) x.focused = to; @@ -2600,7 +2175,7 @@ class _ExplorerPageState extends State { /// 지우기. Windows 의 로컬 파일은 기본으로 휴지통으로 (창에서 "영구 삭제" 를 고르거나 Shift+Delete). /// WebDAV · Android 는 휴지통이 없어 바로 지운다. 여러 개면 실패해도 나머지를 계속하고, 못 지운 것을 알린다 (65). - Future _delete(_Pane pane, List paths) async { + Future _delete(ExplorerPane pane, List paths) async { if (_blockArchive(paths)) return; paths = outermostPaths(paths); // 79-c: 고른 폴더 안의 것은 한 번만 (확인 창 개수도) final shift = _shiftDelete; @@ -2621,7 +2196,7 @@ class _ExplorerPageState extends State { for (final h in c.settings.explorerHiddenItems) if (paths.any((x) => !samePath(h, x) && isSameOrInside(h, x))) h ].length; - final otherMarks = [for (final x in _panes) if (!identical(x, pane)) x.sel.marked.length].fold(0, (a, b) => a + b); + final otherMarks = [for (final x in _ctl.panes) if (!identical(x, pane)) x.sel.marked.length].fold(0, (a, b) => a + b); // U7: 확인 창은 공용 confirmDialog - 영구 삭제로 열면 Enter 의 첫 초점은 [취소] final ok = await confirmDialog( context, @@ -2780,22 +2355,22 @@ class _ExplorerPageState extends State { : (tr('되돌리기'), () => _undoRecycle(sent, since))); } // X-plore 숨기기: 영구로 지운 것 (그 안 포함) 은 숨긴 목록에서 뺀다. 휴지통으로 보낸 것은 되돌리기 뒤에도 숨김이 되게 남긴다 - await _followHidden(gone: [ + await _ctl.followHidden(gone: [ for (final x in paths) if (!sent.contains(x) && !undoable.contains(x) && !failed.any((f) => f.$1 == x)) x, ]); - for (final x in _panes) { + for (final x in _ctl.panes) { paths.forEach(x.sel.forget); if (_selecting && x.sel.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)); + await _ctl.refreshAll(paths.map(p.dirname)); } /// X-plore 비교 e: Android 앱 휴지통 (보관 일이 0 이면 null - 바로 영구 삭제) AppTrash? get _appTrash => (ExplorerPage.debugAndroidTrash ?? Platform.isAndroid) && c.settings.androidTrashDays > 0 - ? AppTrash([for (final (v, _) in _local) v]) + ? AppTrash([for (final (v, _) in _ctl.local) v]) : null; /// X-plore 비교 e: 방금 앱 휴지통으로 보낸 것을 원래 자리로 @@ -2803,7 +2378,7 @@ class _ExplorerPageState extends State { final trash = _appTrash; if (trash == null) return; final back = await undoTrash(trash, items); - await _refreshAll(back.map(p.dirname)); + await _ctl.refreshAll(back.map(p.dirname)); _snack(trf('{0}개를 원래 자리로 되살렸습니다', [back.length])); } @@ -2824,15 +2399,15 @@ class _ExplorerPageState extends State { } 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)); + if (back != null && back.isNotEmpty && mounted) await _ctl.refreshAll(back.map(p.dirname)); } /// 148: 방금 휴지통으로 보낸 것을 원래 자리로 (원래 자리에 같은 이름이 생겼으면 그것은 두고 알림) Future _undoRecycle(List paths, DateTime since) async { final (:back, failed: why) = undoRecycle(paths, since); final failed = [for (final (x, e) in why) '${vBasename(x)}: $e']; - await _refreshAll(paths.map(p.dirname)); - for (final x in _panes) { + await _ctl.refreshAll(paths.map(p.dirname)); + for (final x in _ctl.panes) { x.changed(); } _snack(joinSentences([ @@ -2843,7 +2418,7 @@ class _ExplorerPageState extends State { /// 다른 창의 지금 폴더로 복사 · 이동 (진행 창 · 취소) /// 복사 · 이동 버튼: 두 창이면 다른 창의 지금 폴더로, 그 밖의 배치는 담아 두기 (붙여넣기로) - Future _transfer(_Pane pane, List sources, {required bool move}) async { + Future _transfer(ExplorerPane pane, List sources, {required bool move}) async { if (_blockShizukuMove(sources, null, move: move)) return; // X-plore 비교 f: 압축 안의 것 - 복사는 꺼내기 (86 의 풀기 검사 그대로), 이동은 막음 if (sources.any(_insideArchive)) { @@ -2882,7 +2457,7 @@ class _ExplorerPageState extends State { [namesOf(sources), move ? tr('이동') : tr('복사')])); } - Future _paste(_Pane pane, String dest) async { + Future _paste(ExplorerPane pane, String dest) async { if (_clip.isEmpty) { _snack(tr('붙여넣을 항목이 없습니다. 먼저 [복사] · [이동] 으로 담으세요.')); return; @@ -2898,9 +2473,9 @@ class _ExplorerPageState extends State { void _onGoToRequest() { final dir = ExplorerPage.goToRequest.value; - if (dir == null || !_ready || !mounted) return; + if (dir == null || !_ctl.ready || !mounted) return; ExplorerPage.goToRequest.value = null; - unawaited(_goTo(_pane, dir, fresh: true).then((_) => _reveal(_pane, dir))); + unawaited(_ctl.goTo(_ctl.pane, dir, fresh: true).then((_) => _reveal(_ctl.pane, dir))); } /// 52: 이 화면을 떠난 사이에 시작된 (또는 앞의 화면이 시작한) 작업을 다시 보이고, 끝나면 새로 읽고 막대를 내린다 @@ -2912,7 +2487,7 @@ class _ExplorerPageState extends State { final to = vJoin(job.dest, vBasename(s)); if (!await vExists(s) && await vExists(to)) moved.add((s, to)); } - await _followHidden(moved: moved); + await _ctl.followHidden(moved: moved); } void _adoptJob(TransferJob job) { @@ -2922,8 +2497,8 @@ class _ExplorerPageState extends State { if (!mounted) return; await _followMovedJob(job); if (!mounted) return; - await _refreshAll([job.dest, ...job.sources.map(vDirname)]); - for (final x in _panes) { + await _ctl.refreshAll([job.dest, ...job.sources.map(vDirname)]); + for (final x in _ctl.panes) { x.changed(); } await Future.delayed(const Duration(milliseconds: 1200)); @@ -2948,7 +2523,7 @@ class _ExplorerPageState extends State { } /// [dest] 로 복사 · 이동 (확인 · 아래 진행 막대 · 취소). 끝까지 했으면 true. - Future _runTransfer(_Pane pane, List sources, String dest, + Future _runTransfer(ExplorerPane pane, List sources, String dest, {required bool move, void Function(TransferJob job)? onFinished}) async { if (sources.isEmpty) { _snack(_pickFirst(move)); @@ -2998,8 +2573,8 @@ class _ExplorerPageState extends State { if (problem != null) { _snack(problem); pane.sel.removeWhere(vMissingSync); - await _refreshAll([dest, ...sources.map(p.dirname)]); - for (final x in _panes) { + await _ctl.refreshAll([dest, ...sources.map(p.dirname)]); + for (final x in _ctl.panes) { x.changed(); } return false; @@ -3023,7 +2598,7 @@ class _ExplorerPageState extends State { }; var conflict = always ?? NameConflict.rename; // U4 (점검 10/22, 관리자 결정): 다른 창의 표시는 그대로 두고 이번 대상이 아님을 알린다 - final otherMarks = [for (final x in _panes) if (!identical(x, pane)) x.sel.marked.length].fold(0, (a, b) => a + b); + final otherMarks = [for (final x in _ctl.panes) if (!identical(x, pane)) x.sel.marked.length].fold(0, (a, b) => a + b); // U7: 공용 confirmDialog - 늘 덮어쓰기 설정이면 (되돌릴 수 없음) Enter 의 첫 초점은 [취소] final go = sameFolderCopy ? true @@ -3140,12 +2715,12 @@ class _ExplorerPageState extends State { if (!mounted) return false; // 180: 표시만 지우고 선택 모드는 그대로 (꺼지면 동그라미 칸 · 아래 막대가 사라져 자리가 바뀐다) pane.sel.clear(); - await _refreshAfterTransfer(sources, dest); + await _ctl.refreshAfterTransfer(sources, dest); // 넣은 폴더가 창의 지금 폴더면 펼쳐서 새 항목이 보이게 - for (final x in _panes) { + for (final x in _ctl.panes) { if (samePath(x.current, dest)) { x.expanded.add(dest); - await _load(x, dest, force: true); + await _ctl.load(x, dest, force: true); } x.changed(); } @@ -3172,9 +2747,9 @@ class _ExplorerPageState extends State { action: ( tr('폴더 열기'), () { - final show = _panes.where((x) => samePath(x.current, dest)).firstOrNull ?? _pane; - setState(() => _active = _panes.indexOf(show)); - unawaited(_goTo(show, dest, fresh: true)); + final show = _ctl.panes.where((x) => samePath(x.current, dest)).firstOrNull ?? _ctl.pane; + setState(() => _ctl.active = _ctl.panes.indexOf(show)); + unawaited(_ctl.goTo(show, dest, fresh: true)); } )); } else { @@ -3205,7 +2780,7 @@ class _ExplorerPageState extends State { } Future _runRsyncInner({required bool right, required bool left}) async { - final l = _panes[0].sel.marked.firstOrNull, r = _panes[1].sel.marked.firstOrNull; + final l = _ctl.panes[0].sel.marked.firstOrNull, r = _ctl.panes[1].sel.marked.firstOrNull; // 막는 조건 · 작업 · 옵션 · 미리 보기 · 실행은 RsyncRun (E5c), 확인 창은 rsync_run_dialog final (:run, :problem, :missing) = RsyncRun.prepare(CopyCenter.of(c), l: l, @@ -3219,10 +2794,10 @@ class _ExplorerPageState extends State { if (run == null) { _snack(problem!); if (missing) { - for (final x in _panes) { + for (final x in _ctl.panes) { x.sel.removeWhere(vMissingSync); } - await _refreshAll([vDirname(l!), vDirname(r!)]); + await _ctl.refreshAll([vDirname(l!), vDirname(r!)]); } return; } @@ -3250,11 +2825,11 @@ class _ExplorerPageState extends State { final jobs = res.jobs; if (jobs.isEmpty || !mounted) return; // 원본 폴더까지 지웠으면 고른 표시도 뺀다 - for (final x in _panes) { + for (final x in _ctl.panes) { x.sel.removeWhere(vMissingSync); } - await _refreshAll([run.l, run.r, vDirname(run.l), vDirname(run.r)]); - for (final x in _panes) { + await _ctl.refreshAll([run.l, run.r, vDirname(run.l), vDirname(run.r)]); + for (final x in _ctl.panes) { x.changed(); } final error = jobs.map((j) => j.error).whereType().firstOrNull; @@ -3487,11 +3062,11 @@ class _ExplorerPageState extends State { SearchConditions _lastSearch = const SearchConditions(); /// 79-a: 결과 화면에서 복사 · 이동 · 지우기 · [그 자리로] 를 한 뒤 [찾기 결과로 돌아가기] 로 다시 열 결과 (다음 찾기 · ✕ · 결과 화면을 그냥 닫으면 없어짐) - ({_Pane pane, String root, SearchConditions cond, List found, bool done, Map moved})? _searchBack; + ({ExplorerPane pane, String root, SearchConditions cond, List found, bool done, Map moved})? _searchBack; /// 79: 찾기 - 이름 · 크기 · 바뀐 날 · 종류 조건 → 결과 목록 화면 (고르고 그대로 복사 · 이동 · 지우기, 누르면 열기 · 재생) - Future _search(_Pane pane) async { - setState(() => _active = _panes.indexOf(pane)); + Future _search(ExplorerPane pane) async { + setState(() => _ctl.active = _ctl.panes.indexOf(pane)); final cond = await askSearchConditions(context, pane.current, _lastSearch); if (cond == null || !mounted) return; _lastSearch = cond; @@ -3530,7 +3105,7 @@ class _ExplorerPageState extends State { 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 { + Future _searchPage(ExplorerPane pane, String root, SearchConditions cond, {List? initial, bool initialDone = true}) async { final other = _other?.current; final act = await Navigator.of(context).push(MaterialPageRoute( builder: (_) => SearchResultsPage( @@ -3564,7 +3139,7 @@ class _ExplorerPageState extends State { case 'reveal': final path = act.paths.first; final dir = await vIsDir(path); - await _goTo(pane, dir ? path : vDirname(path), fresh: true); + await _ctl.goTo(pane, dir ? path : vDirname(path), fresh: true); pane.focused = path; pane.changed(); _reveal(pane, path); @@ -3642,7 +3217,7 @@ class _ExplorerPageState extends State { /// X-plore 맞추기 (관리자 지시 10/20): 폴더에 빈 .nomedia 파일을 넣거나 지운다 (Android 갤러리 · 음악 앱에서 숨기기 · 다시 보이기). /// 그 폴더의 파일은 그대로 - 무엇이 바뀌는지 먼저 묻는다. 갤러리에 반영되는 때는 그 앱이 다시 읽을 때다 - Future _nomedia(_Pane pane, String dir, {required bool on}) async { + Future _nomedia(ExplorerPane pane, String dir, {required bool on}) async { final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( @@ -3678,15 +3253,15 @@ class _ExplorerPageState extends State { } // 관리자 지시 10/20: 갤러리가 바로 알도록 .nomedia 와 그 폴더를 다시 읽게 (넣을 때 · 지울 때 모두, 실패해도 조용히) await c.services.shell.mediaScan([f.path, dir]); - await _refreshAll([dir]); + await _ctl.refreshAll([dir]); pane.changed(); _snack(on ? tr('갤러리 · 음악 앱에서 숨겼습니다 (.nomedia). 파일은 그대로입니다.') : tr('.nomedia 를 지웠습니다. 갤러리 · 음악 앱에 다시 보입니다.')); } Future _sortDialog() async { // 84: "폴더마다 기억" 이면 지금 창의 폴더 정렬 - final dir = _pane.current; - var (by, desc) = _sortOf(dir); + final dir = _ctl.pane.current; + var (by, desc) = _ctl.sortOf(dir); final ok = await showDialog( context: context, builder: (ctx) => StatefulBuilder( @@ -3705,7 +3280,7 @@ class _ExplorerPageState extends State { ]), SwitchListTile(value: desc, title: Text(tr('거꾸로')), onChanged: (x) => set(() => desc = x)), if (c.settings.explorerSortPerFolder) - Text(trf('이 폴더에만: {0}', [_displayPath(dir)]), style: TextStyle(fontSize: 12, color: JjColors.textDim)), + Text(trf('이 폴더에만: {0}', [_ctl.displayPath(dir)]), style: TextStyle(fontSize: 12, color: JjColors.textDim)), ]), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), @@ -3715,29 +3290,29 @@ class _ExplorerPageState extends State { ), ); if (ok != true) return; - await _setSort(dir, by, desc); + await _ctl.setSort(dir, by, desc); } /// 73: 즐겨찾기 - 이 창으로 가기, Rsync 화면이면 짝 (왼쪽 · 오른쪽) 을 한 번에 - Future _bookmarks(_Pane pane) async { - setState(() => _active = _panes.indexOf(pane)); + Future _bookmarks(ExplorerPane pane) async { + setState(() => _ctl.active = _ctl.panes.indexOf(pane)); await showFolderBookmarks( context, c, current: pane.current, - right: widget.rsync ? _panes[1].current : null, - go: (dir) => _goTo(pane, dir, fresh: true), + right: widget.rsync ? _ctl.panes[1].current : null, + go: (dir) => _ctl.goTo(pane, dir, fresh: true), goPair: widget.rsync ? (a, b) { - _goTo(_panes[0], a, fresh: true); - _goTo(_panes[1], b, fresh: true); + _ctl.goTo(_ctl.panes[0], a, fresh: true); + _ctl.goTo(_ctl.panes[1], b, fresh: true); } : null, ); if (mounted) setState(() {}); } - Future _historyDialog(_Pane pane) async { + Future _historyDialog(ExplorerPane pane) async { final hist = c.settings.explorerHistory.where(reachable).toList(); final pick = await showDialog( context: context, @@ -3753,7 +3328,7 @@ class _ExplorerPageState extends State { ], ), ); - if (pick != null) await _goTo(pane, pick, fresh: true); + if (pick != null) await _ctl.goTo(pane, pick, fresh: true); } /// 118: 두 창이면 좌우 ⇆ 위아래 를 바꾸고, 한 창이면 두 창 (좌우) 으로. 한 번 눌러 바로 @@ -3775,7 +3350,7 @@ class _ExplorerPageState extends State { } if (!mounted) return; setState(() {}); - for (final pane in _panes) { + for (final pane in _ctl.panes) { _reveal(pane, pane.current); } } @@ -3848,9 +3423,9 @@ class _ExplorerPageState extends State { ..explorerLayout = layout ..explorerOrientation = orient ..explorerToolbar = bar); - if (layout == 'single') setState(() => _active = 0); + if (layout == 'single') setState(() => _ctl.active = 0); // 배치가 바뀌면 목록을 새로 그리므로 맨 위로 간다: 보던 폴더가 다시 보이게 - for (final pane in _panes) { + for (final pane in _ctl.panes) { _reveal(pane, pane.current); } } @@ -3918,15 +3493,15 @@ class _ExplorerPageState extends State { /// 49: 뒤로 키가 화면을 닫기 전에 할 일이 있는지 (고른 것이 있거나 맨 위 폴더가 아님) bool get _backHasWork => - _selecting || _panes.any((x) => !x.sel.isEmpty) || (_ready && !samePath(_pane.current, _pane.root)); + _selecting || _ctl.panes.any((x) => !x.sel.isEmpty) || (_ctl.ready && !samePath(_ctl.pane.current, _ctl.pane.root)); /// 49: 뒤로 키 - 먼저 고른 것 풀기, 그다음 상위 폴더, 맨 위면 화면 닫기 (위쪽 ← 는 늘 닫기) Future _onBack() async { - if (_selecting || _panes.any((x) => !x.sel.isEmpty)) { + if (_selecting || _ctl.panes.any((x) => !x.sel.isEmpty)) { _setSelecting(false); return; } - if (!samePath(_pane.current, _pane.root)) { + if (!samePath(_ctl.pane.current, _ctl.pane.root)) { await _button(ExplorerButton.up); return; } @@ -3953,9 +3528,9 @@ class _ExplorerPageState extends State { // 180: 선택 모드 · 표시 풀기 (복사 · 이동이 끝나도 선택 모드는 그대로이므로 바로 나올 수 있게) - Esc 는 바꾸지 않는다 const SingleActivator(LogicalKeyboardKey.escape): () { // 76: 열린 빠른 필터부터 닫는다 - if (_panes[_active].filterOpen) { - _closeFilter(_panes[_active]); - } else if (_selecting || _panes.any((x) => !x.sel.isEmpty)) { + if (_ctl.panes[_ctl.active].filterOpen) { + _closeFilter(_ctl.panes[_ctl.active]); + } else if (_selecting || _ctl.panes.any((x) => !x.sel.isEmpty)) { _setSelecting(false); } }, @@ -3971,16 +3546,16 @@ class _ExplorerPageState extends State { onKeyEvent: (_, ev) => _typeToFilter(ev), child: Listener( // 75: 마우스 4 · 5번 (뒤로 · 앞으로) 버튼 - 창 밖 (위 막대 등) 이면 지금 창 (창 위면 그 창이 먼저 받음, [_frame]) - onPointerDown: (ev) => _mouseTravel(_active, ev), + onPointerDown: (ev) => _mouseTravel(_ctl.active, ev), child: Scaffold( body: SwipeNav( current: widget.rsync ? 'rsync' : 'explorer', child: ListenableBuilder( listenable: c, builder: (context, _) { - _dropRemovedServers(); + _ctl.dropRemovedServers(); // 80 보충: 화면에서 사라진 폴더를 위해 걸어 둔 자동 크기 계산은 멈추고 뺀다 - _sizes.retainAuto([for (final x in _panes) x.current]); + _ctl.sizes.retainAuto([for (final x in _ctl.panes) x.current]); return Column(children: [ _topBar(), if (_searchBack != null) _searchBackBar(), @@ -3994,7 +3569,7 @@ class _ExplorerPageState extends State { child: Stack(children: [ // X-plore 비교 ⚖: 탐색기 안에서 밀기 = 창 바꾸기 (안쪽이 먼저 받아 화면 이동은 위 막대에서만) Positioned.fill( - child: !_ready + child: !_ctl.ready ? const Center(child: CircularProgressIndicator()) : _swipePanes ? GestureDetector( @@ -4020,7 +3595,7 @@ class _ExplorerPageState extends State { } /// 지금 폴더 · 그 위 폴더들 (창의 맨 위 [pane.root] 까지, 가까운 것부터) - List _ancestors(_Pane pane) { + List _ancestors(ExplorerPane pane) { final out = []; var d = pane.current; for (var i = 0; i < 64; i++) { @@ -4047,28 +3622,28 @@ class _ExplorerPageState extends State { const SizedBox(width: 12), // X-plore 경로 막대 (4.4x): 경로를 누르면 위 폴더들 (창의 맨 위까지) 이 나와 골라 간다 Expanded( - child: !_ready + child: !_ctl.ready ? const SizedBox.shrink() : PopupMenuButton( tooltip: tr('위 폴더로 가기'), position: PopupMenuPosition.under, - onSelected: (d) => _goTo(_pane, d), + onSelected: (d) => _ctl.goTo(_ctl.pane, d), itemBuilder: (_) => [ - for (final d in _ancestors(_pane)) + for (final d in _ancestors(_ctl.pane)) PopupMenuItem( value: d, - enabled: !samePath(d, _pane.current), - child: Text(_displayPath(d), maxLines: 1, overflow: TextOverflow.ellipsis), + enabled: !samePath(d, _ctl.pane.current), + child: Text(_ctl.displayPath(d), maxLines: 1, overflow: TextOverflow.ellipsis), ), ], // 관리자 지시 (10/21, 28 지적): 좁으면 앞을 "…" 로 - 지금 폴더 (끝) 가 늘 보이게 - child: TailText(_displayPath(_pane.current), style: TextStyle(color: JjColors.textDim)), + child: TailText(_ctl.displayPath(_ctl.pane.current), style: TextStyle(color: JjColors.textDim)), ), ), PopupMenuButton( tooltip: tr('창 배치 · 버튼 구성'), icon: const Icon(Icons.more_vert), - onSelected: (v) => v is ExplorerButton ? _button(v) : _toggleAppHidden(), + onSelected: (v) => v is ExplorerButton ? _button(v) : _ctl.toggleAppHidden(), itemBuilder: (_) => [ for (final b in [ if (!widget.rsync) ...[ExplorerButton.layout, ExplorerButton.buttons], @@ -4086,10 +3661,10 @@ class _ExplorerPageState extends State { PopupMenuItem( value: 'appHidden', child: Row(children: [ - Icon(_showAppHidden ? Icons.visibility_off_outlined : Icons.visibility_outlined, size: 18), + Icon(_ctl.showAppHidden ? Icons.visibility_off_outlined : Icons.visibility_outlined, size: 18), const SizedBox(width: 12), Flexible( - child: Text(_showAppHidden + child: Text(_ctl.showAppHidden ? tr('목록에서 숨긴 항목 다시 숨기기') : trf('목록에서 숨긴 항목 보이기 ({0}개)', [c.settings.explorerHiddenItems.length])), ), @@ -4106,9 +3681,9 @@ class _ExplorerPageState extends State { Widget _body() { final s = c.settings; final shape = '${s.explorerStyle}|${s.explorerLayout}|${s.explorerOrientation}|${s.explorerToolbar}'; - if (_ready && shape != _shape) { + if (_ctl.ready && shape != _shape) { if (_shape.isNotEmpty) { - for (final pane in _panes) { + for (final pane in _ctl.panes) { _reveal(pane, pane.current); } } @@ -4128,7 +3703,7 @@ class _ExplorerPageState extends State { if (tightWas != _splitTight) WidgetsBinding.instance.addPostFrameCallback((_) => mounted ? setState(() {}) : null); final split = _split; // 폭이 바뀌면 (좁은 한 창 ↔ 두 창) 이 builder 만 다시 불린다 - 그때도 새로 붙은 창을 보이게 - if (_ready) _revealNewViews(); + if (_ctl.ready) _revealNewViews(); final side = !(dual || split) || switch (s.explorerOrientation) { 'side' => true, @@ -4163,7 +3738,7 @@ class _ExplorerPageState extends State { // 180: 창마다 같은 열쇠 - 가로 ↔ 세로 배치가 바뀌어도 (창 크기 · 작업 현황이 열림) 창을 새로 만들지 않는다 (스크롤이 맨 위로 가지 않게) Widget keep(int k, Widget w) => KeyedSubtree(key: _viewKeys[k], child: w); if (rsyncOne) { - final i = _active.clamp(0, 1); + final i = _ctl.active.clamp(0, 1); return Column(children: [ _rsyncPaneSwitch(), ?bar, @@ -4186,13 +3761,13 @@ class _ExplorerPageState extends State { else Expanded(flex: 2, child: keep(0, _paneView(0, foldersOnly: true))), if (middle) bar, - Expanded(flex: 3, child: keep(2, _listView(_panes[0]))), + Expanded(flex: 3, child: keep(2, _listView(_ctl.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)))]) + ? Column(children: [ListenableBuilder(listenable: _ctl.panes[1], builder: (_, _) => _targetBar()), Expanded(child: keep(0, _paneView(0)))]) : keep(0, _paneView(0))), if (edge) bar, ], @@ -4211,12 +3786,12 @@ class _ExplorerPageState extends State { padding: const EdgeInsets.symmetric(horizontal: 2), child: ChoiceChip( showCheckmark: false, - selected: _active == k, - onSelected: (_) => setState(() => _active = k), + selected: _ctl.active == k, + onSelected: (_) => setState(() => _ctl.active = k), label: SizedBox( width: double.infinity, child: Text( - '$side · ${vBasename(!_panes[k].sel.isEmpty ? _panes[k].sel.marked.first : _panes[k].current)}', + '$side · ${vBasename(!_ctl.panes[k].sel.isEmpty ? _ctl.panes[k].sel.marked.first : _ctl.panes[k].current)}', maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -4235,28 +3810,28 @@ class _ExplorerPageState extends State { /// 누른 손가락 (pointer) · 시각으로 한 번만 쓴다 (창이 먼저 받는다) (int, Duration)? _navPress; void _mouseTravel(int i, PointerDownEvent ev) { - if (i >= _panes.length) return; + if (i >= _ctl.panes.length) return; final back = ev.buttons & kBackMouseButton != 0; if (!back && ev.buttons & kForwardMouseButton == 0) return; final press = (ev.pointer, ev.timeStamp); if (press == _navPress) return; _navPress = press; - _travel(_panes[i], forward: !back); + _ctl.travel(_ctl.panes[i], forward: !back); } Widget _frame(int i, List children) { - final active = i == _active; + final active = i == _ctl.active; return Listener( // 75: 마우스가 올라간 창에 (지금 활성 창이 아니어도) onPointerDown: (ev) => _mouseTravel(i, ev), child: GestureDetector( onTapDown: (_) { - if (_active != i) setState(() => _active = i); + if (_ctl.active != i) setState(() => _ctl.active = i); }, // 82: 창의 빈 곳에 놓으면 그 창의 지금 폴더로. 놓을 수 있으면 강조, 못 놓으면 (같은 창 등) 빨간 테두리 child: DragTarget( - onWillAcceptWithDetails: (d) => canDropInto(d.data, i, _panes[i].current), - onAcceptWithDetails: (d) => unawaited(_onDrop(d.data, i, _panes[i].current)), + onWillAcceptWithDetails: (d) => canDropInto(d.data, i, _ctl.panes[i].current), + onAcceptWithDetails: (d) => unawaited(_onDrop(d.data, i, _ctl.panes[i].current)), builder: (context, ok, no) => Container( margin: const EdgeInsets.all(4), decoration: BoxDecoration( @@ -4280,7 +3855,7 @@ class _ExplorerPageState extends State { } /// 82: 끌 항목 - 누른 것이 표시한 것 중 하나면 표시한 것 모두 (바깥 것만), 아니면 그것 하나 - List _dragPaths(_Pane pane, FileEntry e) => + List _dragPaths(ExplorerPane pane, FileEntry e) => _isMarked(pane, e.path) && !pane.sel.isEmpty ? outermostPaths(pane.sel.marked) : [e.path]; /// 82: 놓음 - 설정 "끌어 놓기 기본" (묻기 · 복사 · 이동) 뒤 지금의 복사 · 이동 (확인 창 · 같은 이름 · 진행 패널). @@ -4288,7 +3863,7 @@ class _ExplorerPageState extends State { Future _onDrop(DragLoad load, int to, String dest) async { if (!canDropInto(load, to, dest) || !mounted) return; // X-plore 비교 f: 압축 안으로는 놓지 않고, 압축 안의 것은 꺼내기로 - if (_insideArchive(dest) || _arc.contains(dest)) { + if (_insideArchive(dest) || _ctl.arc.contains(dest)) { _snack(tr('압축 안은 읽기 전용입니다 (꺼내기만 할 수 있습니다)')); return; } @@ -4296,7 +3871,7 @@ class _ExplorerPageState extends State { await _extractFromArchive(load.paths, dest); return; } - setState(() => _active = to); + setState(() => _ctl.active = to); final mode = c.settings.explorerDropAction; final move = mode == 'ask' ? await askDropAction(context, load.paths, dest) : mode == 'move'; if (move == null || !mounted) return; @@ -4309,7 +3884,7 @@ class _ExplorerPageState extends State { } } if (!mounted) return; - await _runTransfer(_panes[load.from], load.paths, dest, + await _runTransfer(_ctl.panes[load.from], load.paths, dest, move: move, onFinished: move ? (job) => _offerUndoMove(load, job, existed) : null); } @@ -4356,11 +3931,11 @@ class _ExplorerPageState extends State { if (notUndoable > 0) trf('합친 폴더 {0}개는 무엇을 옮겼는지 몰라 되돌리지 않습니다.', [notUndoable]), if (overwritten > 0) trf('같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.', [overwritten]), ]), - action: (tr('되돌리기'), () => unawaited(_undoMove(_panes[load.from], back)))); + action: (tr('되돌리기'), () => unawaited(_undoMove(_ctl.panes[load.from], back)))); } /// 82: 되돌리기 - 원래 폴더마다 지금의 이동으로 (같은 이름이 생겼으면 지금의 같은 이름 창) - Future _undoMove(_Pane pane, Map> back) async { + Future _undoMove(ExplorerPane pane, Map> back) async { for (final MapEntry(key: dir, value: items) in back.entries) { final there = [for (final x in items) if (await vExists(x)) x]; if (there.isEmpty || !mounted) continue; @@ -4373,7 +3948,7 @@ class _ExplorerPageState extends State { } /// 선택 모드 막대: 고른 수 · 모두 고르기 · 선택 끝 - Widget _marksBar(_Pane pane, {String? listDir}) { + Widget _marksBar(ExplorerPane pane, {String? listDir}) { final dir = listDir ?? pane.current; // 74: 고른 개수와 합계 크기 (읽어 둔 목록 기준, 폴더 크기는 빼고) var size = 0, folders = 0; @@ -4454,7 +4029,7 @@ class _ExplorerPageState extends State { /// 트리 창 (저장 장치 고르기 + 트리) /// 관리자 지시 10/20 · 10/21: 좁은 창의 열 - 숨길 열 · 날짜를 짧게 쓸지. 줄의 흐린 글과 같은 모양으로 날짜 글 폭을 잰다 /// (날짜가 "2026-10-2…" 로 잘리지 않게). 열 머리 · 줄이 같이 쓴다 - void _layoutColumns(BuildContext context, _Pane pane, ExplorerStyle look, double width) { + void _layoutColumns(BuildContext context, ExplorerPane pane, ExplorerStyle look, double width) { final scale = MediaQuery.textScalerOf(context).scale(14) / 14 * _listTextScale; final style = DefaultTextStyle.of(context).style.merge(TextStyle(fontSize: look.fontSize - 2)); double measure(String t) => @@ -4467,9 +4042,9 @@ class _ExplorerPageState extends State { } Widget _paneView(int i, {bool foldersOnly = false}) { - final pane = _panes[i]; - final rows = _rows(pane, foldersOnly: foldersOnly); - final look = _look; + final pane = _ctl.panes[i]; + final rows = _ctl.rows(pane, foldersOnly: foldersOnly); + final look = _ctl.look; // 38: 창 높이를 알아야 안내 상자의 높이를 제한할 수 있다 return LayoutBuilder(builder: (context, box) { // 열을 그리는 창만 숨길 열을 정한다 ("폴더 + 파일 목록" 의 트리는 열이 없고 목록과 같은 창을 쓴다) @@ -4487,9 +4062,9 @@ class _ExplorerPageState extends State { style: look, markColumn: _selecting, hide: pane.colHide, - sortBy: _sortOf(pane.current).$1, - sortDesc: _sortOf(pane.current).$2, - onSort: (by) => _sortByColumn(pane, by), + sortBy: _ctl.sortOf(pane.current).$1, + sortDesc: _ctl.sortOf(pane.current).$2, + onSort: (by) => _ctl.sortByColumn(pane, by), ), Expanded( child: Stack(children: [ @@ -4525,19 +4100,19 @@ class _ExplorerPageState extends State { } /// "폴더 + 파일 목록" 배치의 오른쪽: 왼쪽 트리에서 고른 폴더의 내용 (맨 위 ".." = 상위 폴더) - Widget _listView(_Pane pane) { + Widget _listView(ExplorerPane 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 hidden = entries.where((e) => _ctl.appHidden(e.path)).length; final rows = [ if (up) TreeRow(FileEntry(vDirname(dir), isDir: true, modified: DateTime(0)), 0, isUp: true), for (final e in shown) TreeRow(e, 0), // X-plore 숨기기: 맨 아래 [숨긴 항목 n개 보이기] if (hidden > 0) TreeRow(FileEntry('\u0000hidden', isDir: false, modified: DateTime(0)), 0, kind: TreeRow.hiddenToggle, count: hidden), ]; - final look = _look; + final look = _ctl.look; // 38: 창 높이를 알아야 안내 상자의 높이를 제한할 수 있다 return LayoutBuilder(builder: (context, box) { if (look.columns) _layoutColumns(context, pane, look, box.maxWidth); @@ -4546,7 +4121,7 @@ class _ExplorerPageState extends State { height: 40, alignment: Alignment.centerLeft, padding: const EdgeInsets.symmetric(horizontal: 12), - child: Text(_displayPath(dir), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: JjColors.textDim)), + child: Text(_ctl.displayPath(dir), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: JjColors.textDim)), ), const Divider(height: 1), if (pane.filterOpen) _filterBar(pane, shown.length), @@ -4556,9 +4131,9 @@ class _ExplorerPageState extends State { style: look, markColumn: _selecting, hide: pane.colHide, - sortBy: _sortOf(dir).$1, - sortDesc: _sortOf(dir).$2, - onSort: (by) => _sortByColumn(pane, by), + sortBy: _ctl.sortOf(dir).$1, + sortDesc: _ctl.sortOf(dir).$2, + onSort: (by) => _ctl.sortByColumn(pane, by), ), Expanded( // 180: 다시 읽는 동안은 앞의 목록을 그대로 (빙글이로 바꾸면 목록이 새로 만들어져 스크롤이 맨 위로 간다) @@ -4581,9 +4156,9 @@ class _ExplorerPageState extends State { } /// 76: 빠른 필터 열기 ([text] 가 있으면 그 글자로 시작) - void _openFilter(_Pane pane, [String? text]) { + void _openFilter(ExplorerPane pane, [String? text]) { setState(() { - _active = _panes.indexOf(pane); + _ctl.active = _ctl.panes.indexOf(pane); pane.filterOpen = true; if (text != null) { pane.filterCtl.value = TextEditingValue(text: text, selection: TextSelection.collapsed(offset: text.length)); @@ -4595,7 +4170,7 @@ class _ExplorerPageState extends State { } /// 76: 닫으면 글자도 지워 모두 보인다. 줄이 다시 늘어나 맨 위로 가지 않게 고른 것 (없으면 지금 폴더) 으로 - void _closeFilter(_Pane pane) { + void _closeFilter(ExplorerPane pane) { setState(() { pane ..filterOpen = false @@ -4625,23 +4200,23 @@ class _ExplorerPageState extends State { 'rename': () => _button(ExplorerButton.rename), 'refresh': () => _button(ExplorerButton.refresh), // 81: 빠른 보기 - 'quickLook': () => _quickLook(_pane), - 'clipCopy': () => _toClipboard(_targets(_pane), move: false), - 'clipCut': () => _toClipboard(_targets(_pane), move: true), - 'paste': () => _paste(_pane, _pane.current), + 'quickLook': () => _quickLook(_ctl.pane), + 'clipCopy': () => _toClipboard(_targets(_ctl.pane), move: false), + 'clipCut': () => _toClipboard(_targets(_ctl.pane), move: true), + 'paste': () => _paste(_ctl.pane, _ctl.pane.current), 'selectAll': () { if (!_selecting) setState(() => _selecting = true); - _markWhere(_pane, _pane.current, (_) => true); + _markWhere(_ctl.pane, _ctl.pane.current, (_) => true); }, // TC: Space 표시 · Insert 표시하고 다음 줄로 'mark': () => _markFocused(next: false), 'markNext': () => _markFocused(next: true), - 'filter': () => _openFilter(_panes[_active]), + 'filter': () => _openFilter(_ctl.panes[_ctl.active]), 'up': () => _button(ExplorerButton.up), // 75: 지금 창의 뒤로 · 앞으로 - 'back': () => _travel(_panes[_active], forward: false), - 'forward': () => _travel(_panes[_active], forward: true), - 'switchPane': () => setState(() => _active = _dual ? 1 - _active : 0), + 'back': () => _ctl.travel(_ctl.panes[_ctl.active], forward: false), + 'forward': () => _ctl.travel(_ctl.panes[_ctl.active], forward: true), + 'switchPane': () => setState(() => _ctl.active = _dual ? 1 - _ctl.active : 0), }; final keys = widget.rsync ? { @@ -4658,7 +4233,7 @@ class _ExplorerPageState extends State { } /// 81: 빠른 보기 - 지금 줄 (없으면 아무것도 안 함). 같은 폴더에 보이는 항목을 ← → 로 - Future _quickLook(_Pane pane, [String? path]) async { + Future _quickLook(ExplorerPane pane, [String? path]) async { final f = path ?? pane.focused; if (f == null || samePath(f, pane.root)) return; final list = _shown(pane, vDirname(f)); @@ -4672,14 +4247,14 @@ class _ExplorerPageState extends State { 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)), + onOpen: (e) => e.isDir ? unawaited(_ctl.goTo(pane, e.path)) : unawaited(_open(e.path, pane: pane)), ), ); } /// 83: (TC) 지금 줄을 표시 · 풀기, [next] 면 다음 줄로 Future _markFocused({required bool next}) async { - final pane = _pane; + final pane = _ctl.pane; final f = pane.focused; if (f == null || samePath(f, pane.root)) return; final list = _shown(pane, vDirname(f)); @@ -4715,18 +4290,18 @@ class _ExplorerPageState extends State { 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 (ch == ' ' && !widget.rsync && !_ctl.panes[_ctl.active].filterOpen) return KeyEventResult.ignored; if (kb.isControlPressed || kb.isAltPressed || kb.isMetaPressed) return KeyEventResult.ignored; // 글자 칸 (필터 · 경로 입력 등) 에 치는 중이면 그 칸이 받는다 if (_typing) return KeyEventResult.ignored; - if (!_ready || _panes.isEmpty) return KeyEventResult.ignored; - final pane = _panes[_active]; + if (!_ctl.ready || _ctl.panes.isEmpty) return KeyEventResult.ignored; + final pane = _ctl.panes[_ctl.active]; _openFilter(pane, pane.filterOpen ? pane.filterCtl.text + ch : ch); return KeyEventResult.handled; } /// 76: 창 위 줄 아래의 빠른 필터 칸 - 보이는 수 · 맞는 것이 없을 때 안내 · [✕] - Widget _filterBar(_Pane pane, int shown) { + Widget _filterBar(ExplorerPane pane, int shown) { final none = pane.filter.isNotEmpty && shown == 0; return Container( height: 40, @@ -4768,7 +4343,7 @@ class _ExplorerPageState extends State { /// 창 위 줄: [뒤로 · 앞으로 · 즐겨찾기] · 저장 장치 탭 (넘치면 옆으로 밀기) · [WebDAV · 경로 입력]. /// 202: 드라이브가 많아 넘쳐도 끝의 버튼은 늘 보이게 (예전에는 줄 전체가 한 목록이라 화면 밖으로 밀려 누를 수 없었다) /// 좁은 창 (위아래 두 창 · 폰, [paneHeaderWideWidth] 미만) 은 앞 버튼도 탭과 함께 밀고, 끝 버튼은 [⋮] 하나로 (늘 보임) - Widget _paneHeader(_Pane pane) => SizedBox( + Widget _paneHeader(ExplorerPane pane) => SizedBox( height: 40, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), @@ -4788,7 +4363,7 @@ class _ExplorerPageState extends State { ); /// 202: 좁은 창의 끝 버튼 - WebDAV 서버 추가 · 경로 입력 - Widget _headerMenu(_Pane pane) => PopupMenuButton( + Widget _headerMenu(ExplorerPane pane) => PopupMenuButton( tooltip: tr('경로 입력 · 네트워크 서버'), icon: const Icon(Icons.more_vert, size: 18), padding: EdgeInsets.zero, @@ -4822,19 +4397,19 @@ class _ExplorerPageState extends State { ); /// 창 위 줄 앞: 뒤로 · 앞으로 · 즐겨찾기 - List _headerStart(_Pane pane) => [ + List _headerStart(ExplorerPane pane) => [ // 75: 이 창의 뒤로 · 앞으로 (가 본 폴더) IconButton( tooltip: keyHint(tr('뒤로'), 'Alt+←'), visualDensity: VisualDensity.compact, icon: const Icon(Icons.arrow_back, size: 16), - onPressed: !pane.nav.canBack ? null : () => _travel(pane, forward: false), + onPressed: !pane.nav.canBack ? null : () => _ctl.travel(pane, forward: false), ), IconButton( tooltip: keyHint(tr('앞으로'), 'Alt+→'), visualDensity: VisualDensity.compact, icon: const Icon(Icons.arrow_forward, size: 16), - onPressed: !pane.nav.canForward ? null : () => _travel(pane, forward: true), + onPressed: !pane.nav.canForward ? null : () => _ctl.travel(pane, forward: true), ), // 73: 즐겨찾기 (창 위에서 한 번에 - 드라이브가 많아도 보이게 맨 앞) IconButton( @@ -4858,16 +4433,16 @@ class _ExplorerPageState extends State { ]; /// 202: 저장 장치 탭 - 넘치면 마우스 휠 · 끌기로도 옆으로. 비어 있거나 연결 안 된 드라이브는 숨기지 않고 뒤쪽으로 - Widget _driveTabs(_Pane pane, {List lead = const []}) { + Widget _driveTabs(ExplorerPane pane, {List lead = const []}) { final all = [ - ..._volumes, + ..._ctl.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), + for (final x in _ctl.panes) + if (Platform.isWindows && x.root.startsWith(r'\\') && !_ctl.volumes.any((v) => samePath(v.$1, x.root))) (x.root, x.root), ]; final tabs = [ - ...all.where((v) => _drives.ok[v.$1] != false || samePath(pane.root, v.$1)), - ...all.where((v) => _drives.ok[v.$1] == false && !samePath(pane.root, v.$1)), + ...all.where((v) => _ctl.drives.ok[v.$1] != false || samePath(pane.root, v.$1)), + ...all.where((v) => _ctl.drives.ok[v.$1] == false && !samePath(pane.root, v.$1)), ]; return Listener( // 마우스 휠 (위 · 아래) 을 옆으로 @@ -4911,27 +4486,27 @@ class _ExplorerPageState extends State { ? Icons.dns_outlined : isDav(path) ? Icons.cloud_outlined - : _drives.network.contains(path) + : _ctl.drives.network.contains(path) ? Icons.lan_outlined : Platform.isWindows ? Icons.storage : Icons.sd_storage_outlined, size: 16), // 165: 열 수 없는 드라이브는 숨기지 않고 "연결 안 됨" - 누르면 다시 연결 (다시 읽기) - label: _drives.ok[path] == false - ? Text('$label · ${_drives.removable.contains(path) ? tr('비어 있음') : tr('연결 안 됨')} ⟳', - style: TextStyle(color: _drives.removable.contains(path) ? JjColors.textDim : JjColors.warning)) + label: _ctl.drives.ok[path] == false + ? Text('$label · ${_ctl.drives.removable.contains(path) ? tr('비어 있음') : tr('연결 안 됨')} ⟳', + style: TextStyle(color: _ctl.drives.removable.contains(path) ? JjColors.textDim : JjColors.warning)) : Text(label), onPressed: () { - setState(() => _active = _panes.indexOf(pane)); + setState(() => _ctl.active = _ctl.panes.indexOf(pane)); if (isRootPath(path)) { unawaited(_openRoot(pane)); } else if (isShizuku(path)) { unawaited(_openShizuku(pane)); - } else if (_drives.ok[path] == false) { + } else if (_ctl.drives.ok[path] == false) { unawaited(_reconnect(pane, path)); } else { - _goTo(pane, path); + _ctl.goTo(pane, path); } }, onLongPress: isSftp(path) @@ -4952,7 +4527,7 @@ class _ExplorerPageState extends State { } /// 202: 창 위 줄 끝 - 탭이 넘쳐도 늘 보이는 버튼 (WebDAV 서버 추가 · 경로 입력) - List _headerEnd(_Pane pane) => [ + List _headerEnd(ExplorerPane pane) => [ if (c.settings.webdavServers.isEmpty) TextButton.icon( style: TextButton.styleFrom( @@ -5002,15 +4577,15 @@ class _ExplorerPageState extends State { ]; /// 경로를 직접 넣어 가기 (37). 146 · 37-2: 없는 폴더면 창을 닫지 않고 칸 아래에 알린다 (고쳐서 다시 넣을 수 있게) - Future _enterPath(_Pane pane) async { - setState(() => _active = _panes.indexOf(pane)); + Future _enterPath(ExplorerPane pane) async { + setState(() => _ctl.active = _ctl.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); + await _ctl.goTo(pane, path, fresh: true); } /// 146: 넣은 경로를 고쳐 쓰기 · 확인. 갈 수 있으면 그 경로, 아니면 칸 아래에 보일 이유 @@ -5039,71 +4614,71 @@ class _ExplorerPageState extends State { } /// WebDAV 서버 추가 (없으면 [root] 는 null) · 고치기 (탭 길게 누르기). 저장하면 그 서버로 간다. - Future _editDav(_Pane pane, String? root) async { - setState(() => _active = _panes.indexOf(pane)); + Future _editDav(ExplorerPane pane, String? root) async { + setState(() => _ctl.active = _ctl.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) { + for (final x in _ctl.panes) { x.cache.removeWhere((k, _) => isDav(k) && DavPath.parse(k).server == saved.id); } - await _goTo(pane, '$davScheme${saved.id}/'); + await _ctl.goTo(pane, '$davScheme${saved.id}/'); } /// 88: SFTP 서버 추가 (없으면 [root] 는 null) · 고치기 (탭 길게 누르기). 저장하면 그 서버로 간다. - Future _editSftp(_Pane pane, String? root) async { - setState(() => _active = _panes.indexOf(pane)); + Future _editSftp(ExplorerPane pane, String? root) async { + setState(() => _ctl.active = _ctl.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) { + for (final x in _ctl.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}/'); + await _ctl.goTo(pane, '$sftpScheme${saved.id}/'); } /// l: FTP 서버 추가 (없으면 [root] 는 null) · 고치기 (탭 길게 누르기). 저장하면 그 서버로 간다. - Future _editFtp(_Pane pane, String? root) async { - setState(() => _active = _panes.indexOf(pane)); + Future _editFtp(ExplorerPane pane, String? root) async { + setState(() => _ctl.active = _ctl.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) { + for (final x in _ctl.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}/'); + await _ctl.goTo(pane, '$ftpScheme${saved.id}/'); } /// 88: SMB 서버 추가 (없으면 [root] 는 null, [from] 은 경로 입력에 넣은 주소) · 고치기 (탭 길게 누르기). 저장하면 그 서버로 간다. - Future _editSmb(_Pane pane, String? root, {String? from}) async { - setState(() => _active = _panes.indexOf(pane)); + Future _editSmb(ExplorerPane pane, String? root, {String? from}) async { + setState(() => _ctl.active = _ctl.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) { + for (final x in _ctl.panes) { x.cache.removeWhere((k, _) => isSmb(k) && DavPath.parse(k).server == saved.id); } - await _goTo(pane, '$smbScheme${saved.id}/'); + await _ctl.goTo(pane, '$smbScheme${saved.id}/'); } /// 한 줄. [list]: "폴더 + 파일 목록" 의 오른쪽 목록 (트리 아님). [compact]: 폴더 트리 (열 · 표시 버튼 없이) - Widget _rowView(_Pane pane, TreeRow row, {bool list = false, bool compact = false}) { + Widget _rowView(ExplorerPane pane, TreeRow row, {bool list = false, bool compact = false}) { final e = row.entry; - final look = _look; + final look = _ctl.look; // 좁은 "폴더 + 파일 목록" 의 트리 (관리자 지시 10/20): ✔ 칸 · 크기 줄 없이 이름만, 줄에 두 줄이 들어가면 이름을 두 줄까지 final slim = compact && _narrowSplit && !list; final nameLines = slim && _rowH() >= MediaQuery.textScalerOf(context).scale(look.fontSize) * _listTextScale * 2.5 + 6 ? 2 : 1; 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 || _arc.contains(e.path)) && !list && pane.expanded.contains(e.path); + final open = (e.isDir || _ctl.arc.contains(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) + ? (_ctl.volumes.firstWhere((v) => samePath(v.$1, e.path), orElse: () => (e.path, e.path)).$2) : look == ExplorerStyle.totalcmd ? totalCmdName(e) : e.name; @@ -5190,8 +4765,8 @@ class _ExplorerPageState extends State { else if (look.rich && e.isDir && pane.errors[e.path] != null) Text(tr('읽을 수 없음'), style: dim.copyWith(color: JjColors.alert)) // 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), + else if (e.isDir && !row.isRoot && !row.isUp && (!look.columns || compact) && _ctl.dirSize(pane, e) != null) + Text(_ctl.dirSize(pane, e)!, style: dim, maxLines: 1), ], ), ), @@ -5214,16 +4789,16 @@ class _ExplorerPageState extends State { Expanded(flex: 6, child: nameCell()), if (hide < 2) cell(columnDateText(e.modified, short: pane.colDateShort), 3), if (hide < 1) cell(typeLabel(e), 2), - cell(e.isDir ? (_dirSize(pane, e) ?? '') : formatSize(e.size), 2, align: TextAlign.end), + cell(e.isDir ? (_ctl.dirSize(pane, e) ?? '') : formatSize(e.size), 2, align: TextAlign.end), ] : [ Expanded(flex: 6, child: nameCell()), if (hide < 1) cell(e.isDir ? '' : e.ext, 1), - cell(e.isDir ? (_dirSize(pane, e) ?? '') : formatSize(e.size), 2, align: TextAlign.end), + cell(e.isDir ? (_ctl.dirSize(pane, e) ?? '') : formatSize(e.size), 2, align: TextAlign.end), if (hide < 2) ...[const SizedBox(width: 8), cell(columnDateText(e.modified, short: pane.colDateShort), 3)], ]; - final paneIndex = _panes.indexOf(pane); + final paneIndex = _ctl.panes.indexOf(pane); final drag = real && !widget.rsync; Widget line = GestureDetector( // Rsync 화면은 고르기만 (파일 기능 메뉴 없음) @@ -5283,7 +4858,7 @@ class _ExplorerPageState extends State { size: look.rich ? 24 : 20, color: marked ? JjColors.accent : JjColors.textDim.withValues(alpha: 0.35)), onPressed: () { - setState(() => _active = _panes.indexOf(pane)); + setState(() => _ctl.active = _ctl.panes.indexOf(pane)); widget.rsync ? _pick(pane, e) : _toggleMark(pane, e); }, ), @@ -5294,7 +4869,7 @@ class _ExplorerPageState extends State { ), ); // X-plore 숨기기: 숨긴 것을 보이고 있을 때 숨긴 줄은 흐리게 - if (real && _showAppHidden && _appHidden(e.path)) line = Opacity(opacity: 0.5, child: line); + if (real && _ctl.showAppHidden && _ctl.appHidden(e.path)) line = Opacity(opacity: 0.5, child: line); if (!drag) return line; // 82: 폴더 줄 위에 놓으면 그 폴더 안으로 (놓을 수 있으면 강조) if (e.isDir) { @@ -5319,7 +4894,7 @@ class _ExplorerPageState extends State { } Widget _icon(FileEntry e, bool isRoot, bool open, {bool up = false}) { - final look = _look; + final look = _ctl.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 비교 5-1 ② (관리자 지시 10/21): 원격은 설정 (처음 값 Wi-Fi 에서만) 에 따라 받아서 - 받기 전 · 못 받으면 아이콘 diff --git a/test/explorer_controller_contract_test.dart b/test/explorer_controller_contract_test.dart new file mode 100644 index 0000000..e578fb7 --- /dev/null +++ b/test/explorer_controller_contract_test.dart @@ -0,0 +1,108 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/explorer/explorer_controller.dart'; +import 'package:jj_mkvmaker/ui/explorer/explorer_host.dart'; +import 'package:jj_mkvmaker/ui/explorer/explorer_pane.dart'; +import 'package:path/path.dart' as p; + +/// E5d-1 약속 시험: lib/ui/explorer/explorer_controller.dart · explorer_host.dart 맨 위 약속. +/// Host 는 실제 화면이 해 준다 (실제 ScaffoldMessenger 의 알림 · 실제 다음 그리기). 폴더는 실제 시험 폴더, 설정은 메모리에만. +void main() { + late Directory tmp; + late String a, b, show; + setUp(() { + tmp = Directory.systemTemp.createTempSync('jj_ctl_'); + a = p.join(tmp.path, 'A'); + b = p.join(tmp.path, 'B'); + show = p.join(a, 'Show'); + Directory(show).createSync(recursive: true); + Directory(b).createSync(); + File(p.join(show, 'ep1.mkv')).writeAsStringSync('1'); + File(p.join(a, 'big.bin')).writeAsBytesSync(List.filled(3000, 0)); + File(p.join(a, 'small.txt')).writeAsStringSync('s'); + }); + tearDown(() => tmp.deleteSync(recursive: true)); + + Future<(ExplorerController, _HostState)> open(WidgetTester t) async { + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + final key = GlobalKey<_HostState>(); + await t.pumpWidget(MaterialApp(home: _Host(key: key))); + final ctl = ExplorerController(c, rsync: false, host: key.currentState!); + ctl.local = [(a, 'A:'), (b, 'B:')]; + addTearDown(ctl.dispose); + return (ctl, key.currentState!); + } + + testWidgets('이동: 장치부터 펼치고 읽음 · 지금 폴더 · 기억 · 스크롤 부탁 · 뒤로/앞으로', (t) async { + final (ctl, host) = await open(t); + final q = ctl.panes[0]; + await t.runAsync(() => ctl.goTo(q, show)); + expect(q.root, a); + expect(q.current, show); + expect(q.expanded, containsAll([a, show])); + expect([for (final e in q.cache[show]!) p.basename(e.path)], ['ep1.mkv']); + expect(host.revealed, [show], reason: 'Host 에 스크롤 부탁'); + expect(ctl.c.settings.explorerPaths.first, show, reason: '마지막 폴더 기억'); + await t.runAsync(() => ctl.goTo(q, b)); + expect(q.root, b, reason: '다른 장치'); + await t.runAsync(() => ctl.travel(q, forward: false)); + expect(q.current, show); + await t.runAsync(() => ctl.travel(q, forward: true)); + expect(q.current, b); + }); + + testWidgets('뒤로 갈 곳이 지워졌으면 내역에서 빼고 실제 알림으로 까닭 · 정렬 바꾸기 · 숨기기 토글', (t) async { + final (ctl, host) = await open(t); + final q = ctl.panes[0]; + await t.runAsync(() => ctl.goTo(q, show)); + await t.runAsync(() => ctl.goTo(q, b)); + Directory(show).deleteSync(recursive: true); + await t.runAsync(() => ctl.travel(q, forward: false)); + await t.pump(); + expect(q.current, b); + expect(find.textContaining('내역에서 뺐습니다'), findsOneWidget, reason: '실제 ScaffoldMessenger 알림'); + await t.runAsync(() => ctl.goTo(q, a)); + await t.runAsync(() => ctl.setSort(a, 'size', true)); + expect(p.basename(q.cache[a]!.where((e) => !e.isDir).first.path), 'big.bin'); + expect(ctl.sortOf(a), ('size', true)); + var n = 0; + ctl.addListener(() => n++); + ctl.toggleAppHidden(); + expect([ctl.showAppHidden, n], [true, 1]); + expect(host.alive, isTrue); + }); + + testWidgets('화면 밖 글: 경로 보이기 · 서버 목록 · 장치 맞추기', (t) async { + final (ctl, _) = await open(t); + expect(ctl.volumeOf(p.join(b, 'x')), b); + expect(ctl.volumes.take(2).map((v) => v.$2), ['A:', 'B:']); + expect(ctl.displayPath(show), Platform.isAndroid ? 'A: › Show' : show); + }); +} + +/// 실제 화면이 해 주는 Host (실제 ScaffoldMessenger 알림 · 실제 다음 그리기) +class _Host extends StatefulWidget { + const _Host({super.key}); + @override + State<_Host> createState() => _HostState(); +} + +class _HostState extends State<_Host> implements ExplorerHost { + final revealed = []; + @override + bool get alive => mounted; + @override + void snack(String text) => ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text))); + @override + void reveal(ExplorerPane pane, String path) => revealed.add(path); + @override + void afterFrame(VoidCallback f) => WidgetsBinding.instance.addPostFrameCallback((_) => f()); + @override + Widget build(BuildContext context) => const Scaffold(body: SizedBox()); +}