E5d-1: 탐색기 상태 · 화면 밖의 일을 ExplorerController 로, 화면 부탁은 ExplorerHost 로

관리자 승인 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 <noreply@anthropic.com>
split_pages
RyuJongJin 2026-10-22 10:26:22 +09:00
parent 067746223b
commit 4279747371
5 changed files with 971 additions and 792 deletions

View File

@ -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<ExplorerPane> 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<void> 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<List<FileEntry>?> _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<bool> 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<void> refresh(ExplorerPane q, Iterable<String> dirs) async {
for (final d in {...dirs}) {
if (q.cache.containsKey(d)) await load(q, d, force: true);
}
}
Future<void> refreshAll(Iterable<String> dirs) async {
for (final q in panes) {
await refresh(q, dirs);
}
}
/// 복사 · 이동 뒤: 대상 · 원본 폴더와 그 안의 읽어 둔 것을 새로 (없어진 것은 잊음, 자리 · 스크롤 그대로)
Future<void> refreshAfterTransfer(List<String> 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<void> 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<FileEntry> sorted(List<FileEntry> 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<FileEntry>? 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<FileEntry>? 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 <FileEntry>[]) 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<void> 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<String, String>.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<void> 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<void> 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 = <String>[];
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 <FileEntry>[]).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<void> 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<TreeRow> 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<void> followHidden({Iterable<(String, String)> moved = const [], Iterable<String> 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();
}
}
}

View File

@ -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);
}

View File

@ -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();
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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 = <String>[];
@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());
}