E5a: 탐색기 창 상태 · 내역 · 줄 목록 · 폴더 읽기 · 저장 장치를 모듈로 (explorer_page 는 부르기만)

사용자 지시 (10/22): 나누기는 곧 모듈화 - 모듈 재사용 · 600줄. 관리자 승인 E5 계획 (E5a~E5e)
- lib/core/nav_history.dart NavHistory: 가 본 폴더 내역 (visit · take → arrive → finish, 상한 50, 움직이는 중 겹침 막기)
- lib/core/explorer/pane_state.dart PaneState: 창 하나의 상태 (화면 부품 없음). 탐색기의 _Pane 은 스크롤 · 필터 칸만 더함
- lib/core/explorer/tree_rows.dart buildTreeRows · TreeRow · passesFilter · quickFilterMatch · globToRegExp:
  펼친 트리 → 줄 목록 (빠른 필터 · 숨김 · 맨 위 장치 · 즐겨찾기 · 압축 안)
- lib/core/explorer/dir_loader.dart loadDir: 폴더 하나 읽기 - file_ops listEntries 를 부르기만, 오류는 이유를 남김
  (빈 목록으로 숨기지 않음), 보던 폴더가 지워지면 갈 상위 폴더를 알림
- lib/app/volumes.dart localVolumes · volumeOf · reachable · reachableAsync · DriveStatus: 저장 장치 · 드라이브 상태
- 모듈마다 첫머리에 약속 (입력 · 출력 · 오류). 다시 쓰는 곳: Rsync 화면 · 보관함 · 찾기 결과 창 (뒤 단계)
- 동작은 바꾸지 않음. explorer_page 6,590 → 6,357

확인: analyze 0 · 전체 단위 2회 통과 1,317 · 건너뜀 17 · 실패 0 · 점검 통합 시험 (1920 · 960 · 412) 2회 통과.
캡처 픽셀 비교 (E4 8335a3e 두 번 찍은 것을 흔들림 기준): 90장 중 84 · 85장 흔들림 안, 벗어난 5 · 6장은 모두 확인 창 -
창 안의 무작위 임시 경로 길이가 달라 창이 몇 px 밀린 것 (눈으로 같음 확인)
약속 시험 (실제 시험 폴더 · 실제 드라이브): nav_history 3 · explorer_rows (pane_state · dir_loader · tree_rows) 5 · volumes 3

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
split_pages
RyuJongJin 2026-10-22 08:27:49 +09:00
parent 8335a3eac7
commit dab9db5d4d
9 changed files with 749 additions and 320 deletions

105
lib/app/volumes.dart Normal file
View File

@ -0,0 +1,105 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart' as p;
import '../core/file_ops.dart' show driveType, samePath, uncRoot, windowsDrives;
import '../core/vfs.dart' show DavPath, isDav;
import '../platform/android/android_storage.dart' show AndroidAccess;
/// 약속 (E5a): 이 기기의 저장 장치 (경로, 이름) 와 드라이브 상태.
/// - [localVolumes]: Windows 드라이브 · Android 저장소 · 그 밖은 "/". 입력 없음, 출력 = 목록 (비지 않게 - 없으면 C:\ 또는 /).
/// - [volumeOf]: 경로 → 그 경로가 든 맨 위 (WebDAV 는 서버, \\NAS\공유 는 그 공유, 로컬은 가장 긴 장치). 오류 없음.
/// - [reachable] · [reachableAsync]: 다시 열 수 있는지 (비동기는 2초 넘게 걸리면 있다고 보고 열어 봄, WebDAV 는 서버가 설정에 있으면).
/// - [DriveStatus]: 드라이브마다 열 수 있는지 (null = 확인 중) · 네트워크 · 꺼내는 장치. 바뀌면 알림 (닫은 뒤는 알리지 않음).
/// 확인 실패는 false (던지지 않음).
Future<List<(String, String)>> localVolumes() async {
final List<(String, String)> v;
if (Platform.isWindows) {
v = windowsDrives();
} else if (Platform.isAndroid) {
v = [for (final x in await AndroidAccess.volumes()) (x.$1, x.$2.isEmpty ? p.basename(x.$1) : x.$2)];
} else {
v = [('/', '/')];
}
return v.isEmpty ? [(Platform.isWindows ? r'C:\' : '/', Platform.isWindows ? 'C:' : '/')] : v;
}
/// [path] 가 든 맨 위 ([local] 은 비지 않은 장치 목록)
String volumeOf(String path, List<(String, String)> local) {
if (isDav(path)) {
final d = DavPath.parse(path);
return '${d.scheme}${d.server}/';
}
// 37: Windows 네트워크 공유 (\\NAS\공유) 는 그 공유가 맨 위
if (Platform.isWindows && path.startsWith(r'\\')) return uncRoot(path);
var best = local.first.$1;
var len = -1;
for (final (v, _) in local) {
final inside =
samePath(v, path) || p.isWithin(v, path) || (Platform.isWindows && p.isWithin(v.toLowerCase(), path.toLowerCase()));
if (inside && v.length > len) {
best = v;
len = v.length;
}
}
return best;
}
/// 다시 열 수 있는 폴더: 로컬은 있으면, WebDAV 는 그 서버가 아직 설정에 있으면 (짧은 확인 - 내역 목록 등)
bool reachable(String path) => isDav(path) ? DavPath.parse(path).serverKnown : Directory(path).existsSync();
/// [reachable] 을 화면을 멈추지 않고
Future<bool> reachableAsync(String path) async {
if (isDav(path)) return reachable(path);
try {
return await Directory(path).exists().timeout(const Duration(seconds: 2), onTimeout: () => true);
} catch (_) {
return false;
}
}
/// 165: 드라이브마다 따로 뒤에서 확인한 상태 (느린 드라이브가 있어도 다른 드라이브 · 창은 바로)
class DriveStatus extends ChangeNotifier {
/// null = 확인 중, true = 열 수 있음, false = 연결 안 됨
final ok = <String, bool?>{};
final network = <String>{};
/// 꺼내는 장치 · CD (카드 리더 · USB): 열 수 없으면 "비어 있음" (연결 안 됨이 아니라 매체가 없는 것)
final removable = <String>{};
/// Windows 드라이브 종류를 적는다 (네트워크 4 · 꺼내는 장치 2 · CD 5)
void classify(Iterable<String> drives) {
for (final d in drives) {
final t = driveType(d);
if (t == 4) network.add(d);
if (t == 2 || t == 5) removable.add(d);
}
}
/// [probe] 로 [path] 를 열 수 있는지 확인 (실패 · 예외는 false). 닫은 뒤 끝나면 알리지 않는다
Future<bool> check(String path, Future<bool> Function(String path) probe) async {
ok[path] = null;
_changed();
bool r;
try {
r = await probe(path);
} catch (_) {
r = false;
}
ok[path] = r;
_changed();
return r;
}
bool _disposed = false;
void _changed() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}

View File

@ -0,0 +1,92 @@
import 'dart:io';
import '../file_ops.dart' show FileEntry, listEntries, samePath;
import '../ftp.dart' show FtpCertException;
import '../sftp.dart' show SftpHostKeyException;
import '../vfs.dart' show isDav, vDirname;
import 'pane_state.dart';
/// 약속 (E5a): 폴더 하나를 읽어 창 상태에 넣는다 ([loadDir]). 입력 = 창 · 폴더 · 새로 읽을지 · 숨김 파일 · 정렬,
/// 출력 = [LoadOutcome] (읽어 둔 것을 씀 · 읽음 · 오류 · 보던 폴더가 없어짐 (갈 상위 폴더)).
/// 오류: 읽지 못하면 [PaneState.errors] 에 이유를 남기고 목록은 비운다 (빈 목록으로 숨기지 않음 - 화면이 이유를 보임).
/// - 목록 읽기는 file_ops 의 [listEntries] (로컬 · 원격 모두), 정렬은 부르는 쪽 [sort] (정렬 설정 · 폴더 크기를 아는 곳).
/// - [inside] 가 목록을 주면 (압축 안처럼 읽어 둔 것) 그것을 쓴다. null 이면 보통 폴더로 읽는다.
/// - 읽는 동안 [PaneState.loading] 에 그 폴더가 있고, 앞뒤로 [PaneState.changed] 를 알린다.
/// - OS 가 막아 비어 보이는 폴더 ([osBlocked] 가 이유를 줌 - Android/data 등) 는 그 이유를 오류로 남긴다.
/// - 보던 폴더 (지금 폴더) 가 밖에서 지워졌으면 남아 있는 가장 가까운 상위 폴더를 [LoadOutcome.vanishedUpTo] 로 알린다
/// (그 폴더의 오류 · 목록 · 펼침은 지움). 그곳으로 옮기는 것은 부르는 쪽.
class LoadOutcome {
const LoadOutcome._(this.kind, [this.vanishedUpTo]);
static const cached = LoadOutcome._('cached');
static const loaded = LoadOutcome._('loaded');
static const failed = LoadOutcome._('failed');
const LoadOutcome.vanished(String up) : this._('vanished', up);
final String kind;
final String? vanishedUpTo;
}
Future<LoadOutcome> loadDir(
PaneState pane,
String dir, {
bool force = false,
bool showHidden = false,
required List<FileEntry> Function(List<FileEntry> list, String dir) sort,
Future<List<FileEntry>?> Function(String dir, bool force)? inside,
String? Function(String dir)? osBlocked,
void Function(String dir)? onFresh,
}) async {
if (!force && pane.cache.containsKey(dir)) return LoadOutcome.cached;
final kept = await inside?.call(dir, force);
if (kept != null) {
pane.cache[dir] = kept.isEmpty ? const [] : sort(kept, dir);
pane.changed();
return LoadOutcome.loaded;
}
// 80: 다시 읽는 폴더는 안이 바뀌었을 수 있다 (폴더 크기 등을 잊게)
if (force) onFresh?.call(dir);
pane.loading.add(dir);
pane.changed();
try {
final list = await listEntries(dir, showHidden: showHidden);
pane.cache[dir] = sort(list, dir);
// 50: OS 가 막은 폴더는 오류 없이 비어 보인다 - 비어 있는 것이 아니라 막힌 것이라고
final blocked = list.isEmpty ? osBlocked?.call(dir) : null;
if (blocked != null) {
pane.errors[dir] = blocked;
} else {
pane.errors.remove(dir);
}
pane.hostKeyErrors.remove(dir);
pane.certErrors.remove(dir);
return LoadOutcome.loaded;
} catch (e) {
pane.cache[dir] = const [];
pane.errors[dir] = osBlocked?.call(dir) ?? '$e';
if (e is SftpHostKeyException) {
pane.hostKeyErrors[dir] = e;
} else {
pane.hostKeyErrors.remove(dir);
}
if (e is FtpCertException) {
pane.certErrors[dir] = e;
} else {
pane.certErrors.remove(dir);
}
// 37-2: 보던 폴더가 밖에서 지워졌으면 남아 있는 가장 가까운 상위 폴더로
if (!isDav(dir) && samePath(dir, pane.current) && !samePath(dir, pane.root) && !Directory(dir).existsSync()) {
var up = vDirname(dir);
while (!samePath(up, pane.root) && !Directory(up).existsSync() && vDirname(up) != up) {
up = vDirname(up);
}
pane.errors.remove(dir);
pane.cache.remove(dir);
pane.expanded.remove(dir);
return LoadOutcome.vanished(up);
}
return LoadOutcome.failed;
} finally {
pane.loading.remove(dir);
pane.changed();
}
}

View File

@ -0,0 +1,82 @@
import 'package:flutter/foundation.dart';
import '../file_ops.dart' show FileEntry, samePath;
import '../ftp.dart' show FtpCertException;
import '../nav_history.dart';
import '../selection.dart';
import '../sftp.dart' show SftpHostKeyException;
/// 약속 (E5a): 탐색기 창 하나의 상태 (화면 부품 없음). 입력 = 폴더 이동 · 읽은 목록 · 표시, 출력 = 지금 상태 · 바뀌면 [changed] 알림.
/// 오류는 [errors] (폴더 → 이유) 에 남는다 - 읽지 못한 폴더를 빈 목록으로 숨기지 않는다.
/// - [current] 를 다른 폴더로 바꾸면 [nav] 에 떠난 곳이 쌓인다 (뒤로/앞으로로 움직이는 중은 빼고).
/// - 표시 규칙은 [sel] ([Selection]), 내역 규칙은 [nav] ([NavHistory]) 한 곳.
/// - 빠른 필터 글은 화면이 가진다 ([filter] 를 화면 쪽에서 덮어씀).
/// - [dispose] 뒤에는 [changed] 가 알리지 않는다 (화면을 닫은 뒤 끝난 읽기 · 파일 작업).
class PaneState extends ChangeNotifier {
PaneState(this.root, {bool rsync = false})
: _current = root,
sel = Selection(rsync: rsync);
/// 이 창의 저장 장치 (트리 맨 위)
String root;
/// 관리자 지시 10/20: 좁아서 숨긴 열 - 열을 그리는 창 (목록) 의 폭으로 정하고 열 머리 · 줄이 같이 쓴다
int colHide = 0;
/// 관리자 지시 10/21: 날짜 열이 좁아 짧은 꼴로 쓰는지
bool colDateShort = false;
/// 75: 창마다 뒤로 · 앞으로
final nav = NavHistory(same: samePath);
/// 지금 폴더. 바뀔 때마다 뒤로 가기 내역에 (뒤로 · 앞으로로 움직일 때는 빼고)
String get current => _current;
set current(String v) {
nav.visit(_current, v);
_current = v;
}
String _current;
/// 이 창의 목록이 화면에 붙어 있는지 (새로 붙을 때 지금 폴더를 보이게)
bool onScreen = false;
/// 지금 줄 (한 번 누른 것 - 표시가 없을 때 일의 대상)
String? focused;
final expanded = <String>{};
/// 읽어 둔 목록 (폴더 → 정렬된 항목)
final cache = <String, List<FileEntry>>{};
final loading = <String>{};
/// 읽지 못한 폴더 → 이유
final errors = <String, String>{};
/// 88: 처음 보는 (또는 바뀐) SFTP 서버 키 - 오류 안내에 지문 · [지문 확인하고 연결]
final hostKeyErrors = <String, SftpHostKeyException>{};
/// l: 처음 보는 (바뀐) FTPS 자체 서명 인증서 - 폴더마다
final certErrors = <String, FtpCertException>{};
/// 표시 (고른 것) · 범위 고르기의 시작
final Selection sel;
/// X-plore 비교 c: 트리의 "즐겨찾기" 묶음을 펼쳤는지
bool favoritesOpen = false;
/// 76: 빠른 필터가 열려 있는지 · 그 글 (화면이 칸의 글로 덮어씀)
bool filterOpen = false;
String get filter => '';
bool _disposed = false;
void changed() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}

View File

@ -0,0 +1,107 @@
import '../file_ops.dart' show FileEntry, isSameOrInside, samePath;
import '../vfs.dart' show vBasename;
import 'pane_state.dart';
/// 약속 (E5a): 창 상태 → 화면에 보일 줄 목록 ([buildTreeRows]). 입력 = [PaneState] (펼침 · 읽어 둔 목록 · 필터) 와 보기 옵션,
/// 출력 = 위에서 아래 차례의 [TreeRow] 들. 오류 없음 (읽지 않은 폴더는 줄이 없을 뿐).
/// - 빠른 필터는 지금 폴더 안만 거른다. 이름이 맞지 않아도 안에 맞는 것이 보이는 폴더는 남긴다.
/// - 목록에서 숨긴 것 ([isHidden]) 은 [showHidden] 일 때만, 숨긴 수는 맨 아래 [TreeRow.hiddenToggle] 줄에.
/// [foldersOnly] 면 파일은 먼저 건너뛰어 숨긴 수에도 들지 않는다.
/// - [roots] 가 있으면 (X-plore 트리 맨 위 장치) 지금 장치 앞뒤로 다른 장치 줄, 끝에 즐겨찾기 묶음.
/// - 펼친 압축 ([browsable]) 은 안을 폴더처럼 편다.
/// 트리 · 목록의 한 줄
class TreeRow {
final FileEntry entry;
final int depth;
final bool isRoot;
/// 파일 목록 맨 위의 ".." (상위 폴더로)
final bool isUp;
/// 트리 맨 위 항목 - [otherRoot] 다른 저장 장치 · 서버, [favorites] 즐겨찾기 묶음, [favorite] 즐겨찾기 하나,
/// [hiddenToggle] 목록 맨 아래의 [숨긴 항목 보이기] (숨긴 수 [count])
final int kind;
final int count;
static const otherRoot = 1, favorites = 2, favorite = 3, hiddenToggle = 4;
const TreeRow(this.entry, this.depth, {this.isRoot = false, this.isUp = false, this.kind = 0, this.count = 0});
}
/// 빠른 필터: 이름에 [filter] 가 들면 (대소문자 무시), * · ? 가 있으면 이름 전체가 패턴에 맞으면
bool quickFilterMatch(String name, String filter) {
if (filter.isEmpty) return true;
if (filter.contains('*') || filter.contains('?')) return globToRegExp(filter).hasMatch(name);
return name.toLowerCase().contains(filter.toLowerCase());
}
/// 74: 패턴 (예: *.srt, 여러 개는 ; 로) → 이름 전체에 맞는 정규식 (대소문자 무시)
RegExp globToRegExp(String pattern) {
final parts = pattern.split(RegExp(r'[;,]')).map((x) => x.trim()).where((x) => x.isNotEmpty);
String one(String g) => RegExp.escape(g).replaceAll(r'\*', '.*').replaceAll(r'\?', '.');
return RegExp('^(?:${parts.map(one).join('|')})\$', caseSensitive: false);
}
/// 76: [e] 가 빠른 필터를 지나는지 - 지금 폴더 안의 것만 거른다
bool passesFilter(PaneState pane, FileEntry e) =>
pane.filter.isEmpty ||
!isSameOrInside(e.path, pane.current) ||
samePath(e.path, pane.current) ||
quickFilterMatch(vBasename(e.path), pane.filter);
List<TreeRow> buildTreeRows(
PaneState pane, {
bool foldersOnly = false,
List<String> roots = const [],
List<String> favorites = const [],
required bool Function(String path) isHidden,
bool showHidden = false,
required bool Function(String path) browsable,
}) {
final out = <TreeRow>[];
FileEntry dirEntry(String path) => FileEntry(path, isDir: true, modified: DateTime(0));
final here = roots.indexWhere((r) => samePath(r, pane.root));
for (final r in roots.take(here < 0 ? 0 : here)) {
out.add(TreeRow(dirEntry(r), 0, kind: TreeRow.otherRoot));
}
out.add(TreeRow(dirEntry(pane.root), 0, isRoot: true));
var hidden = 0;
bool walk(String dir, int depth) {
if (!pane.expanded.contains(dir)) return false;
var kept = false;
for (final e in pane.cache[dir] ?? const <FileEntry>[]) {
if (foldersOnly && !e.isDir) continue;
if (isHidden(e.path)) {
hidden++;
if (!showHidden) continue;
}
final at = out.length;
out.add(TreeRow(e, depth));
final inner = (e.isDir || browsable(e.path)) && walk(e.path, depth + 1);
if (!inner && !passesFilter(pane, e)) {
out.length = at;
} else {
kept = true;
}
}
return kept;
}
walk(pane.root, 1);
if (roots.isNotEmpty) {
for (final r in here < 0 ? roots : roots.skip(here + 1)) {
out.add(TreeRow(dirEntry(r), 0, kind: TreeRow.otherRoot));
}
if (favorites.isNotEmpty) {
out.add(TreeRow(dirEntry('\u0000favorites'), 0, kind: TreeRow.favorites));
if (pane.favoritesOpen) {
for (final f in favorites) {
out.add(TreeRow(dirEntry(f), 1, kind: TreeRow.favorite));
}
}
}
}
if (hidden > 0) {
out.add(TreeRow(FileEntry('\u0000hidden', isDir: false, modified: DateTime(0)), 0, kind: TreeRow.hiddenToggle, count: hidden));
}
return out;
}

43
lib/core/nav_history.dart Normal file
View File

@ -0,0 +1,43 @@
/// 약속 (E5a): 가 본 폴더 내역 하나 (창마다). 입력 = 다른 폴더로 감 [visit] · 뒤로/앞으로 [take] → [arrive] → [finish],
/// 출력 = 돌아갈 곳 (없으면 null) · [canBack] · [canForward]. 오류 없음.
/// - [visit]: 다른 곳으로 가면 떠난 곳을 뒤로에 넣고 앞으로를 비운다. 같은 곳 · 빈 경로 · 뒤로/앞으로로 움직이는 중에는 넣지 않는다.
/// - 뒤로는 [limit] 개까지 (넘으면 가장 오래된 것부터 버림).
/// - 뒤로/앞으로가 끝나기 전에 또 부르면 null (빨리 두 번 눌러 두 칸이 빠지지 않게).
/// - 같은 경로인지는 [same] 으로 (탐색기는 Windows 대소문자 무시 · WebDAV 주소를 아는 samePath).
class NavHistory {
NavHistory({this.limit = 50, required this.same});
final int limit;
final bool Function(String a, String b) same;
final back = <String>[];
final forward = <String>[];
/// 뒤로/앞으로로 움직이는 중 ([take] 부터 [arrive] 까지)
bool traveling = false;
bool get canBack => back.isNotEmpty;
bool get canForward => forward.isNotEmpty;
/// [from] 에서 [to] 로 감 (보통의 이동)
void visit(String from, String to) {
if (traveling || from.isEmpty || to.isEmpty || same(from, to)) return;
back.add(from);
if (back.length > limit) back.removeAt(0);
forward.clear();
}
/// 뒤로 ([forward] false) · 앞으로 갈 곳을 꺼낸다. 없거나 움직이는 중이면 null.
/// 꺼낸 뒤 갈 수 있으면 [arrive] → (그곳으로 이동) → [finish], 갈 수 없으면 (지워짐 등) [finish] 만 - 그 항목은 버린다
String? take({required bool forward}) {
final from = forward ? this.forward : back;
if (traveling || from.isEmpty) return null;
traveling = true;
return from.removeLast();
}
/// [take] 로 꺼낸 곳으로 감: 떠난 [current] 는 반대쪽 내역에 (이동하는 동안은 [visit] 이 넣지 않음)
void arrive(String current, {required bool forward}) => (forward ? back : this.forward).add(current);
/// 뒤로/앞으로 끝 (닿았든 못 갔든)
void finish() => traveling = false;
}

View File

@ -22,8 +22,8 @@ import '../core/sync_tools.dart';
import '../core/file_ops.dart';
import '../core/playlist.dart' show isAudioFile, isVideoFile;
import '../core/vfs.dart';
import '../core/sftp.dart' show SftpHostKeyException, SftpRegistry;
import '../core/ftp.dart' show FtpCertException, FtpRegistry;
import '../core/sftp.dart' show SftpRegistry;
import '../core/ftp.dart' show FtpRegistry;
import '../core/smb.dart' show SmbRegistry;
import '../core/webdav.dart' show DavRegistry;
import '../platform/android/android_storage.dart';
@ -87,7 +87,12 @@ 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';
export '../core/explorer/tree_rows.dart' show globToRegExp, quickFilterMatch;
export '../core/name_clash.dart' show NameClash, clashCompareText, fileDateText;
/// 파일 탐색기 (X-plore 참고): 두 창 (트리 목록) + 가운데 기능 버튼 줄.
@ -262,57 +267,13 @@ enum ExplorerButton {
}
}
/// 한 창의 상태: 저장 장치 (맨 위), 펼친 폴더, 읽어 둔 목록, 표시한 항목
class _Pane extends ChangeNotifier {
String root;
/// 한 창: 상태는 [PaneState] (E5a, lib/core/explorer), 여기는 화면 부품 (스크롤 · 빠른 필터 칸) 만
class _Pane extends PaneState {
_Pane(super.root, {super.rsync});
/// 관리자 지시 10/20: 좁아서 숨긴 열 ([columnHideLevel]) - 열을 그리는 창 (목록) 의 폭으로 정하고 열 머리 · 줄이 같이 쓴다
int colHide = 0;
/// 관리자 지시 10/21: 날짜 열이 좁아 짧은 꼴 ([columnDateText] short) 로 쓰는지
bool colDateShort = false;
/// 지금 폴더. 75: 바뀔 때마다 뒤로 가기 내역에 (뒤로 · 앞으로로 움직일 때는 빼고)
String get current => _current;
set current(String v) {
if (!traveling && _current.isNotEmpty && v.isNotEmpty && !samePath(_current, v)) {
back.add(_current);
if (back.length > 50) back.removeAt(0);
forward.clear();
}
_current = v;
}
String _current;
/// 75: 창마다 뒤로 · 앞으로 (Alt+← / →, 마우스 4 · 5번 버튼)
final back = <String>[];
final forward = <String>[];
bool traveling = false;
/// 이 창의 목록이 화면에 붙어 있는지 (새로 붙을 때 지금 폴더를 보이게 - [_ExplorerPageState._revealNewViews])
bool onScreen = false;
String? focused;
final expanded = <String>{};
final cache = <String, List<FileEntry>>{};
final loading = <String>{};
final errors = <String, String>{};
/// 88: 처음 보는 (또는 바뀐) SFTP 서버 키 - 오류 안내에 지문 · [지문 확인하고 연결]
final hostKeyErrors = <String, SftpHostKeyException>{};
/// l: 처음 보는 (바뀐) FTPS 자체 서명 인증서 - 폴더마다
final certErrors = <String, FtpCertException>{};
/// 표시 (고른 것) · 범위 고르기의 시작 - 규칙은 [Selection] 한 곳 (E1)
final Selection sel;
/// X-plore 비교 c: 트리의 "즐겨찾기" 묶음을 펼쳤는지
bool favoritesOpen = false;
/// 76: 빠른 필터 - 열려 있으면 이름에 [filter] 가 든 것만 보인다 (창마다, 닫으면 지움)
bool filterOpen = false;
final filterCtl = TextEditingController();
final filterFocus = FocusNode();
@override
String get filter => filterOpen ? filterCtl.text.trim() : '';
/// 목록 스크롤 (폴더로 이동하면 그 폴더가 보이게)
@ -324,20 +285,8 @@ class _Pane extends ChangeNotifier {
/// 202: 창 위 저장 장치 탭 줄 (넘치면 옆으로 - 휠 · 끌기)
final tabScroll = ScrollController();
_Pane(this.root, {bool rsync = false})
: _current = root,
sel = Selection(rsync: rsync);
/// 화면을 닫은 뒤 끝난 파일 작업 · 읽기가 알리지 않게
bool _disposed = false;
void changed() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
scroll.dispose();
listScroll.dispose();
tabScroll.dispose();
@ -347,56 +296,29 @@ class _Pane extends ChangeNotifier {
}
}
/// 트리 · 목록의 한 줄
class _Row {
final FileEntry entry;
final int depth;
final bool isRoot;
/// 파일 목록 맨 위의 ".." (상위 폴더로)
final bool isUp;
/// X-plore 비교 c: 트리 맨 위 항목 - [otherRoot] 다른 저장 장치 · 서버, [favorites] 즐겨찾기 묶음, [favorite] 즐겨찾기 하나.
/// X-plore 숨기기: [hiddenToggle] 목록 맨 아래의 [숨긴 항목 보이기] (숨긴 수 [count])
final int kind;
final int count;
static const otherRoot = 1, favorites = 2, favorite = 3, hiddenToggle = 4;
const _Row(this.entry, this.depth, {this.isRoot = false, this.isUp = false, this.kind = 0, this.count = 0});
}
class _ExplorerPageState extends State<ExplorerPage> {
AppController get c => widget.c;
/// 이 기기의 저장 장치: (경로, 이름)
List<(String, String)> _local = [];
/// 165: 드라이브마다 따로 뒤에서 확인 (Windows). null = 확인 중, true = 열 수 있음, false = 연결 안 됨
final Map<String, bool?> _driveOk = {};
final Set<String> _networkDrives = {};
/// 꺼내는 장치 · CD (카드 리더 · USB): 열 수 없으면 "비어 있음" (연결 안 됨이 아니라 매체가 없는 것)
final Set<String> _removableDrives = {};
Future<void> _probeDrive(String path) async {
if (mounted) setState(() => _driveOk[path] = null);
bool ok;
try {
ok = await ExplorerPage.probeDrive(path);
} catch (_) {
ok = false;
}
if (mounted) setState(() => _driveOk[path] = ok);
/// 165: 드라이브마다 따로 뒤에서 확인한 상태 (E5a, lib/app/volumes.dart)
late final _drives = DriveStatus()..addListener(_onDrives);
void _onDrives() {
if (mounted) setState(() {});
}
Future<void> _probeDrive(String path) => _drives.check(path, ExplorerPage.probeDrive);
/// 연결 안 된 드라이브를 누르면 다시 연결 (다시 읽기) - 되면 연다
Future<void> _reconnect(_Pane pane, String path) async {
await _probeDrive(path);
if (!mounted) return;
if (_driveOk[path] == true) {
if (_drives.ok[path] == true) {
pane.cache.remove(path);
await _goTo(pane, path);
} else {
_snack(_removableDrives.contains(path)
_snack(_drives.removable.contains(path)
? trf('{0} 이 비어 있습니다 (카드 · USB · 디스크를 넣은 뒤 다시 누르세요).', [path])
: trf('{0} 에 연결할 수 없습니다. 네트워크 · NAS 가 켜져 있는지 확인하세요.', [path]));
}
@ -512,6 +434,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
c.removeListener(_onSettings);
_keys.dispose();
_sizes.dispose();
_drives.dispose();
if (widget.rsync) {
ExplorerPage._openRsync--;
} else {
@ -531,7 +454,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
}
Future<void> _init() async {
_local = await _loadVolumes();
_local = ExplorerPage.debugVolumes?.call() ?? await localVolumes();
if (_local.isEmpty) _local = [(Platform.isWindows ? r'C:\' : '/', Platform.isWindows ? 'C:' : '/')];
// X-plore 비교 e: 보관 일이 지난 휴지통 항목을 지운다 (뒤에서)
if (_appTrash case final t?) unawaited(t.purgeOlderThan(c.settings.androidTrashDays).catchError((_) => 0));
@ -540,7 +463,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
// 창마다 따로 읽는다: 한 창이 WebDAV 서버를 기다려도 다른 창 (이 기기 파일) 은 바로 보이게
// 165: 지난 폴더가 느린 네트워크 드라이브여도 화면이 멈추지 않게 (뒤에서 확인, 오래 걸리면 그냥 열어 본다 - 못 읽으면 그 창에 이유)
final reach = await Future.wait(
[for (var i = 0; i < 2; i++) i < saved.length ? _reachableAsync(saved[i]) : Future.value(false)]);
[for (var i = 0; i < 2; i++) i < saved.length ? reachableAsync(saved[i]) : Future.value(false)]);
// U1 (점검 10/22): 저장된 경로가 없는 창은 창마다 다른 저장 장치 (오른쪽 = 두 번째, 없으면 첫째) - 두 창이 같은 목록을
// 두 번 보이면 [복사] 의 "다른 창으로" 가 같은 곳이 되어 헷갈렸다. 저장된 경로가 있으면 그대로
final wants = [
@ -572,10 +495,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
}
// 165: 드라이브 종류 · 연결은 드라이브마다 따로 뒤에서 (느린 드라이브가 있어도 다른 드라이브 · 창은 바로)
if (Platform.isWindows) {
_drives.classify([for (final (path, _) in _local) path]);
for (final (path, _) in _local) {
final type = driveType(path);
if (type == 4) _networkDrives.add(path);
if (type == 2 || type == 5) _removableDrives.add(path);
unawaited(_probeDrive(path));
}
}
@ -1002,16 +923,6 @@ class _ExplorerPageState extends State<ExplorerPage> {
return [label, if (rest != '.') ...p.split(rest)].join(' › ');
}
Future<List<(String, String)>> _loadVolumes() async {
if (ExplorerPage.debugVolumes case final f?) return f();
if (Platform.isWindows) return windowsDrives();
if (Platform.isAndroid) {
final v = await AndroidAccess.volumes();
return [for (final x in v) (x.$1, x.$2.isEmpty ? p.basename(x.$1) : x.$2)];
}
return [('/', '/')];
}
/// 설정에서 지운 WebDAV 서버를 보던 창은 이 기기의 첫 저장 장치로
void _dropRemovedServers() {
if (!_ready) return;
@ -1028,106 +939,35 @@ class _ExplorerPageState extends State<ExplorerPage> {
}
}
/// 다시 열 수 있는 폴더: 로컬은 있으면, WebDAV 는 그 서버가 아직 설정에 있으면 (열 때 확인)
bool _reachable(String path) => isDav(path)
? DavPath.parse(path).serverKnown
: Directory(path).existsSync();
/// [_reachable] 을 화면을 멈추지 않고 (2초 넘게 걸리면 있다고 보고 열어 본다)
Future<bool> _reachableAsync(String path) async {
if (isDav(path)) return _reachable(path);
try {
return await Directory(path).exists().timeout(const Duration(seconds: 2), onTimeout: () => true);
} catch (_) {
return false;
}
}
String _volumeOf(String path) {
if (isDav(path)) {
final d = DavPath.parse(path);
return '${d.scheme}${d.server}/';
}
// 37: Windows 네트워크 공유 (\\NAS\공유) 는 그 공유가 맨 위
if (Platform.isWindows && path.startsWith(r'\\')) return uncRoot(path);
String best = _local.first.$1;
var len = -1;
for (final (v, _) in _local) {
final inside = samePath(v, path) || p.isWithin(v, path) ||
(Platform.isWindows && p.isWithin(v.toLowerCase(), path.toLowerCase()));
if (inside && v.length > len) {
best = v;
len = v.length;
}
}
return best;
}
String _volumeOf(String path) => volumeOf(path, _local);
// ───────── 목록 읽기 · 이동 ─────────
Future<void> _load(_Pane pane, String dir, {bool force = false}) async {
if (!force && pane.cache.containsKey(dir)) return;
// X-plore 비교 f: 압축 안 (읽어 둔 목록에서). 새로 고침이면 압축 파일이 바뀌었는지 보고 바뀌었으면 다시 읽는다
final arc = _archiveOf(dir);
if (arc != null) {
if (force && !await _readArchive(arc)) {
pane.cache[dir] = const [];
pane.changed();
return;
}
if (_archives.containsKey(arc)) {
pane.cache[dir] = _sorted(_archiveList(dir), dir);
pane.changed();
return;
}
}
// 80: 다시 읽는 폴더는 안이 바뀌었을 수 있다 - 그 폴더와 위 폴더들의 크기를 잊는다
if (force) _sizes.forget(dir);
pane.loading.add(dir);
pane.changed();
try {
final list = await listEntries(dir, showHidden: c.settings.explorerShowHidden);
pane.cache[dir] = _sorted(list, dir);
// 50: Android 가 막은 폴더는 비어 보인다 (오류 없이) - 비어 있는 것이 아니라 막힌 것이라고 알린다
if (list.isEmpty && isAndroidRestricted(dir)) {
pane.errors[dir] = androidRestrictedError;
} else {
pane.errors.remove(dir);
}
pane.hostKeyErrors.remove(dir);
pane.certErrors.remove(dir);
} catch (e) {
pane.cache[dir] = const [];
pane.errors[dir] = isAndroidRestricted(dir) ? androidRestrictedError : '$e';
if (e is SftpHostKeyException) {
pane.hostKeyErrors[dir] = e;
} else {
pane.hostKeyErrors.remove(dir);
}
if (e is FtpCertException) {
pane.certErrors[dir] = e;
} else {
pane.certErrors.remove(dir);
}
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 (!isDav(dir) && samePath(dir, pane.current) && !samePath(dir, pane.root) && !Directory(dir).existsSync()) {
var up = vDirname(dir);
while (!samePath(up, pane.root) && !Directory(up).existsSync() && vDirname(up) != up) {
up = vDirname(up);
}
pane.errors.remove(dir);
pane.cache.remove(dir);
pane.expanded.remove(dir);
if (r.vanishedUpTo case final up?) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_snack(tr('보던 폴더가 없어져 상위 폴더로 옮겼습니다'));
unawaited(_goTo(pane, up, fresh: true));
});
}
} finally {
pane.loading.remove(dir);
pane.changed();
}
/// X-plore 비교 f: 압축 안의 목록 (읽어 둔 것). 새로 고침이면 압축 파일이 바뀌었는지 보고 바뀌었으면 다시 읽는다.
/// 압축 안이 아니거나 아직 읽지 않았으면 null (보통 폴더로 읽음)
Future<List<FileEntry>?> _insideArchiveList(String dir, bool force) async {
final arc = _archiveOf(dir);
if (arc == null) return null;
if (force && !await _readArchive(arc)) return const [];
return _archives.containsKey(arc) ? _archiveList(dir) : null;
}
/// [dir] 의 목록을 정렬. 84: "폴더마다 기억" 이 켜져 있으면 그 폴더의 정렬 (없으면 전체 정렬)
@ -1262,15 +1102,11 @@ class _ExplorerPageState extends State<ExplorerPage> {
}
});
/// 75: 창마다 뒤로 · 앞으로 (가 본 폴더로)
/// 75: 창마다 뒤로 · 앞으로 (가 본 폴더로) - 내역 규칙은 [NavHistory]
Future<void> _travel(_Pane pane, {required bool forward}) async {
// 앞의 이동이 끝나기 전에 또 불리면 (빨리 두 번 · 같은 누름이 두 번 들어옴) 무시 - 내역에서 두 칸이 빠지지 않게
if (pane.traveling) return;
final from = forward ? pane.forward : pane.back;
if (from.isEmpty) return;
pane.traveling = true;
final to = pane.nav.take(forward: forward);
if (to == null) return;
try {
final to = from.removeLast();
setState(() => _active = _panes.indexOf(pane));
// 지워졌거나 열 수 없는 폴더: 그 항목은 버리고 지금 폴더는 그대로 (이유를 알림)
bool ok;
@ -1291,10 +1127,10 @@ class _ExplorerPageState extends State<ExplorerPage> {
_snack(trf('{0} 폴더가 없거나 열 수 없어 내역에서 뺐습니다', [_displayPath(to)]));
return;
}
(forward ? pane.back : pane.forward).add(pane.current);
pane.nav.arrive(pane.current, forward: forward);
await _goTo(pane, to);
} finally {
pane.traveling = false;
pane.nav.finish();
}
if (mounted) setState(() {});
}
@ -1371,61 +1207,13 @@ class _ExplorerPageState extends State<ExplorerPage> {
bool get _rootsInTree => !widget.rsync && _look == ExplorerStyle.xplore && c.settings.explorerRootsInTree;
List<_Row> _rows(_Pane pane, {bool foldersOnly = false}) {
final out = <_Row>[];
final rootEntry = FileEntry(pane.root, isDir: true, modified: DateTime(0));
// X-plore 비교 c: 지금 저장 장치 앞뒤로 다른 저장 장치 · 서버 (접힌 줄), 맨 끝에 즐겨찾기
final roots = _rootsInTree ? _volumes : const <(String, String)>[];
final here = roots.indexWhere((v) => samePath(v.$1, pane.root));
for (final (path, _) in roots.take(here < 0 ? 0 : here)) {
out.add(_Row(FileEntry(path, isDir: true, modified: DateTime(0)), 0, kind: _Row.otherRoot));
}
out.add(_Row(rootEntry, 0, isRoot: true));
// 76: 빠른 필터 - 지금 폴더 안만 거른다 (트리의 다른 곳은 그대로).
// 이름이 맞지 않아도 안에 맞는 것이 보이는 폴더는 남긴다 (어디에 있는지 보이게)
var hidden = 0;
bool walk(String dir, int depth) {
if (!pane.expanded.contains(dir)) return false;
var kept = false;
for (final e in pane.cache[dir] ?? const <FileEntry>[]) {
if (foldersOnly && !e.isDir) continue;
// X-plore 숨기기: 목록에서 숨긴 것 (그 안도) 은 [숨긴 항목 보이기] 를 켰을 때만
if (_appHidden(e.path)) {
hidden++;
if (!_showAppHidden) continue;
}
final at = out.length;
out.add(_Row(e, depth));
// X-plore 비교 f: 펼친 압축 (ZIP · 7z · RAR) 도 안을 폴더처럼
final inner = (e.isDir || _archives.containsKey(e.path)) && walk(e.path, depth + 1);
if (!inner && !_passes(pane, e)) {
out.length = at;
} else {
kept = true;
}
}
return kept;
}
walk(pane.root, 1);
if (roots.isNotEmpty) {
for (final (path, _) in here < 0 ? roots : roots.skip(here + 1)) {
out.add(_Row(FileEntry(path, isDir: true, modified: DateTime(0)), 0, kind: _Row.otherRoot));
}
final favs = c.settings.folderBookmarks;
if (favs.isNotEmpty) {
out.add(_Row(FileEntry('\u0000favorites', isDir: true, modified: DateTime(0)), 0, kind: _Row.favorites));
if (pane.favoritesOpen) {
for (final b in favs) {
out.add(_Row(FileEntry(b.path, isDir: true, modified: DateTime(0)), 1, kind: _Row.favorite));
}
}
}
}
// X-plore 숨기기: 펼친 폴더 안에 숨긴 것이 있으면 목록 맨 아래에 [숨긴 항목 n개 보이기] (X-plore 자리)
if (hidden > 0) out.add(_Row(FileEntry('\u0000hidden', isDir: false, modified: DateTime(0)), 0, kind: _Row.hiddenToggle, count: hidden));
return out;
}
List<TreeRow> _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: _archives.containsKey);
/// X-plore 숨기기: [path] 가 목록에서 숨긴 것인지 (앱 설정의 목록 - 파일은 그대로)
bool _appHidden(String path) {
@ -1512,7 +1300,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
}
}
Widget _hiddenToggleRow(_Row row) {
Widget _hiddenToggleRow(TreeRow row) {
final look = _look;
final label = _showAppHidden ? trf('숨긴 항목 {0}개 다시 숨기기', [row.count]) : trf('숨긴 항목 {0}개 보이기', [row.count]);
return Semantics(
@ -1536,16 +1324,16 @@ class _ExplorerPageState extends State<ExplorerPage> {
}
/// X-plore 비교 c: 트리 맨 위 항목 줄 (다른 저장 장치 · 서버 · 즐겨찾기) - 누르면 그곳으로, 즐겨찾기 묶음은 펼치기 · 접기
Widget _extraRow(_Pane pane, _Row row) {
if (row.kind == _Row.hiddenToggle) return _hiddenToggleRow(row);
Widget _extraRow(_Pane pane, TreeRow row) {
if (row.kind == TreeRow.hiddenToggle) return _hiddenToggleRow(row);
final look = _look;
final path = row.entry.path;
final volume = _volumes.firstWhere((v) => samePath(v.$1, path), orElse: () => (path, vBasename(path))).$2;
final fav = row.kind == _Row.favorite
final fav = row.kind == TreeRow.favorite
? c.settings.folderBookmarks.firstWhere((b) => b.path == path, orElse: () => FolderBookmark(name: vBasename(path), path: path))
: null;
final (IconData icon, Color color, String label, bool? open) = switch (row.kind) {
_Row.otherRoot => (
TreeRow.otherRoot => (
isDav(path)
? Icons.cloud_outlined
: Platform.isWindows
@ -1555,12 +1343,12 @@ class _ExplorerPageState extends State<ExplorerPage> {
volume,
false,
),
_Row.favorites => (Icons.star, JjColors.amber, tr('즐겨찾기'), pane.favoritesOpen),
TreeRow.favorites => (Icons.star, JjColors.amber, tr('즐겨찾기'), pane.favoritesOpen),
_ => (Icons.star_outline, JjColors.amber, fav!.name, null),
};
void tap() {
setState(() => _active = _panes.indexOf(pane));
if (row.kind == _Row.favorites) {
if (row.kind == TreeRow.favorites) {
pane.favoritesOpen = !pane.favoritesOpen;
pane.changed();
return;
@ -1618,7 +1406,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
/// X-plore 비교 d: 마지막으로 누른 폴더 · 시각 (두 번 누르기의 두 번째를 가리려고)
(String, DateTime)? _lastFolderTap;
Future<void> _onTap(_Pane pane, _Row row, {bool list = false}) async {
Future<void> _onTap(_Pane pane, TreeRow row, {bool list = false}) async {
final e = row.entry;
setState(() => _active = _panes.indexOf(pane));
if (widget.rsync && !row.isUp) {
@ -1654,7 +1442,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
}
/// 줄의 종류 ([ClickPolicy] 에 넘김)
ItemKind _kindOf(_Row row) => row.isUp
ItemKind _kindOf(TreeRow row) => row.isUp
? ItemKind.up
: row.isRoot
? ItemKind.root
@ -1663,7 +1451,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
: ItemKind.file;
/// [ClickPolicy] 가 정한 일을 한다 (화면 쪽 동작만 - 규칙은 [ClickPolicy] · [Selection])
Future<void> _act(_Pane pane, _Row row, ClickAction act, {bool list = false}) async {
Future<void> _act(_Pane pane, TreeRow row, ClickAction act, {bool list = false}) async {
final e = row.entry;
switch (act) {
case ClickAction.none:
@ -1727,18 +1515,11 @@ class _ExplorerPageState extends State<ExplorerPage> {
return a != null && !samePath(a, e.path) && samePath(vDirname(a), vDirname(e.path)) && _isMarked(pane, a);
}
/// 76: 빠른 필터를 지나는지 - 지금 폴더 안의 것만 거른다
bool _passes(_Pane pane, FileEntry e) =>
pane.filter.isEmpty ||
!isSameOrInside(e.path, pane.current) ||
samePath(e.path, pane.current) ||
quickFilterMatch(vBasename(e.path), pane.filter);
/// 76: [dir] 에서 지금 보이는 것 (빠른 필터에 맞는 것)
/// X-plore 숨기기: 목록에서 숨긴 것은 [숨긴 항목 보이기] 를 켰을 때만
List<FileEntry> _shown(_Pane pane, String dir) => [
for (final x in pane.cache[dir] ?? const <FileEntry>[])
if (_passes(pane, x) && (_showAppHidden || !_appHidden(x.path))) x
if (passesFilter(pane, x) && (_showAppHidden || !_appHidden(x.path))) x
];
/// 74: 마지막으로 표시한 것부터 [to] 까지 (보이는 순서대로) 모두 표시
@ -1781,7 +1562,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
/// 열기 (파일: 선택 설정이면 두 번 누르기, 바로 실행이면 한 번 누르기 / 폴더: 한 번 누르기):
/// 트리의 폴더는 펼치기 · 접기, 목록의 폴더는 들어가기 (왼쪽 트리도 따라감), 파일은 실행.
Future<void> _onOpen(_Pane pane, _Row row, {bool list = false}) async {
Future<void> _onOpen(_Pane pane, TreeRow row, {bool list = false}) async {
final e = row.entry;
setState(() => _active = _panes.indexOf(pane));
pane.focused = e.path;
@ -4392,7 +4173,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
}
Future<void> _historyDialog(_Pane pane) async {
final hist = c.settings.explorerHistory.where(_reachable).toList();
final hist = c.settings.explorerHistory.where(reachable).toList();
final pick = await showDialog<String>(
context: context,
builder: (ctx) => SimpleDialog(
@ -5169,11 +4950,11 @@ class _ExplorerPageState extends State<ExplorerPage> {
/// 76: 목록 줄의 키 = 항목 경로. 키가 없으면 줄 목록이 바뀔 때 (빠른 필터 · 펼치기 · 새로 고침) 같은 자리의 줄 위젯을
/// 다른 항목에 다시 써서, 방금 누른 파일 줄이 폴더 줄로 바뀌면 두 번 누르기 기다림이 그리는 중에 버려지고
/// 그 누르기가 다른 항목에 들어갔다 (디버그에서는 "setState() called during build")
static Key _rowKey(_Row r) =>
static Key _rowKey(TreeRow r) =>
ValueKey<String>('${r.kind != 0 ? 'k${r.kind}:' : r.isRoot ? 'root:' : r.isUp ? 'up:' : ''}${r.entry.path}');
/// 76: 줄이 옮겨 가도 같은 줄 위젯을 찾게 (키 → 지금 자리)
static int? Function(Key) _rowIndexOf(List<_Row> rows) {
static int? Function(Key) _rowIndexOf(List<TreeRow> rows) {
Map<Key, int>? index;
return (key) => (index ??= {for (var i = 0; i < rows.length; i++) _rowKey(rows[i]): i})[key];
}
@ -5186,10 +4967,10 @@ class _ExplorerPageState extends State<ExplorerPage> {
final shown = _shown(pane, dir);
final hidden = entries.where((e) => _appHidden(e.path)).length;
final rows = [
if (up) _Row(FileEntry(vDirname(dir), isDir: true, modified: DateTime(0)), 0, isUp: true),
for (final e in shown) _Row(e, 0),
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) _Row(FileEntry('\u0000hidden', isDir: false, modified: DateTime(0)), 0, kind: _Row.hiddenToggle, count: hidden),
if (hidden > 0) TreeRow(FileEntry('\u0000hidden', isDir: false, modified: DateTime(0)), 0, kind: TreeRow.hiddenToggle, count: hidden),
];
final look = _look;
// 38: 창 높이를 알아야 안내 상자의 높이를 제한할 수 있다
@ -5482,13 +5263,13 @@ class _ExplorerPageState extends State<ExplorerPage> {
tooltip: keyHint(tr('뒤로'), 'Alt+←'),
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.arrow_back, size: 16),
onPressed: pane.back.isEmpty ? null : () => _travel(pane, forward: false),
onPressed: !pane.nav.canBack ? null : () => _travel(pane, forward: false),
),
IconButton(
tooltip: keyHint(tr('앞으로'), 'Alt+→'),
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.arrow_forward, size: 16),
onPressed: pane.forward.isEmpty ? null : () => _travel(pane, forward: true),
onPressed: !pane.nav.canForward ? null : () => _travel(pane, forward: true),
),
// 73: 즐겨찾기 (창 위에서 한 번에 - 드라이브가 많아도 보이게 맨 앞)
IconButton(
@ -5520,8 +5301,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
if (Platform.isWindows && x.root.startsWith(r'\\') && !_volumes.any((v) => samePath(v.$1, x.root))) (x.root, x.root),
];
final tabs = [
...all.where((v) => _driveOk[v.$1] != false || samePath(pane.root, v.$1)),
...all.where((v) => _driveOk[v.$1] == false && !samePath(pane.root, v.$1)),
...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)),
];
return Listener(
// 마우스 휠 (위 · 아래) 을 옆으로
@ -5565,16 +5346,16 @@ class _ExplorerPageState extends State<ExplorerPage> {
? Icons.dns_outlined
: isDav(path)
? Icons.cloud_outlined
: _networkDrives.contains(path)
: _drives.network.contains(path)
? Icons.lan_outlined
: Platform.isWindows
? Icons.storage
: Icons.sd_storage_outlined,
size: 16),
// 165: 열 수 없는 드라이브는 숨기지 않고 "연결 안 됨" - 누르면 다시 연결 (다시 읽기)
label: _driveOk[path] == false
? Text('$label · ${_removableDrives.contains(path) ? tr('비어 있음') : tr('연결 안 됨')} ⟳',
style: TextStyle(color: _removableDrives.contains(path) ? JjColors.textDim : JjColors.warning))
label: _drives.ok[path] == false
? Text('$label · ${_drives.removable.contains(path) ? tr('비어 있음') : tr('연결 안 됨')} ⟳',
style: TextStyle(color: _drives.removable.contains(path) ? JjColors.textDim : JjColors.warning))
: Text(label),
onPressed: () {
setState(() => _active = _panes.indexOf(pane));
@ -5582,7 +5363,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
unawaited(_openRoot(pane));
} else if (isShizuku(path)) {
unawaited(_openShizuku(pane));
} else if (_driveOk[path] == false) {
} else if (_drives.ok[path] == false) {
unawaited(_reconnect(pane, path));
} else {
_goTo(pane, path);
@ -5743,7 +5524,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
}
/// 한 줄. [list]: "폴더 + 파일 목록" 의 오른쪽 목록 (트리 아님). [compact]: 폴더 트리 (열 · 표시 버튼 없이)
Widget _rowView(_Pane pane, _Row row, {bool list = false, bool compact = false}) {
Widget _rowView(_Pane pane, TreeRow row, {bool list = false, bool compact = false}) {
final e = row.entry;
final look = _look;
// 좁은 "폴더 + 파일 목록" 의 트리 (관리자 지시 10/20): ✔ 칸 · 크기 줄 없이 이름만, 줄에 두 줄이 들어가면 이름을 두 줄까지
@ -6401,20 +6182,6 @@ class _Thumb extends StatelessWidget {
}
/// 76: 빠른 필터 - 비어 있으면 모두, * · ? 가 있으면 이름 전체 패턴, 아니면 이름에 든 글자 (대소문자 무시)
bool quickFilterMatch(String name, String filter) {
if (filter.isEmpty) return true;
if (filter.contains('*') || filter.contains('?')) return globToRegExp(filter).hasMatch(name);
return name.toLowerCase().contains(filter.toLowerCase());
}
/// 146 · 37-2: 경로 입력 창 - 없는 폴더면 닫지 않고 칸 아래에 "폴더가 없거나 열 수 없습니다"
/// 74: 패턴 (예: *.srt, 여러 개는 ; 로) → 이름 전체에 맞는 정규식 (대소문자 무시)
RegExp globToRegExp(String pattern) {
final parts = pattern.split(RegExp(r'[;,]')).map((x) => x.trim()).where((x) => x.isNotEmpty);
String one(String g) => RegExp.escape(g).replaceAll(r'\*', '.*').replaceAll(r'\?', '.');
return RegExp('^(?:${parts.map(one).join('|')})\$', caseSensitive: false);
}
/// 74: 패턴 넣기
class _PatternDialog extends StatefulWidget {
const _PatternDialog();

View File

@ -0,0 +1,132 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/core/explorer/dir_loader.dart';
import 'package:jj_mkvmaker/core/explorer/pane_state.dart';
import 'package:jj_mkvmaker/core/explorer/tree_rows.dart';
import 'package:jj_mkvmaker/core/file_ops.dart';
import 'package:path/path.dart' as p;
/// E5a 약속 시험: lib/core/explorer/pane_state.dart · dir_loader.dart · tree_rows.dart 맨 위 약속.
/// 실제 시험 폴더를 읽는다 (가짜 파일 시스템 없음).
void main() {
late Directory tmp;
late String root, show, movies, ep1, notes;
setUp(() {
tmp = Directory.systemTemp.createTempSync('jj_rows_');
root = tmp.path;
show = p.join(root, 'Show');
movies = p.join(root, 'Movies');
Directory(p.join(show, 'Extras')).createSync(recursive: true);
Directory(movies).createSync();
ep1 = p.join(show, 'ep01.mkv');
File(ep1).writeAsStringSync('1');
File(p.join(show, 'ep02.mkv')).writeAsStringSync('22');
notes = p.join(root, 'notes.txt');
File(notes).writeAsStringSync('n');
});
tearDown(() => tmp.deleteSync(recursive: true));
List<FileEntry> byName(List<FileEntry> l, String _) => sortEntries(l, SortBy.name);
Future<void> open(PaneState pane, String dir) async {
pane.expanded.add(dir);
await loadDir(pane, dir, sort: byName);
}
List<String> names(List<TreeRow> rows) =>
[for (final r in rows) r.kind == TreeRow.hiddenToggle ? 'hidden' : '${' ' * r.depth}${p.basename(r.entry.path)}'];
test('창 상태: 지금 폴더를 바꾸면 내역에 · 닫은 뒤 알리지 않음', () {
final pane = PaneState(root);
var n = 0;
pane.addListener(() => n++);
pane.current = show;
pane.current = movies;
expect(pane.nav.back, [root, show]);
pane.changed();
expect(n, 1);
pane.dispose();
pane.changed();
expect(n, 1);
});
test('폴더 읽기: 읽음 → 정렬된 목록 · 두 번째는 읽어 둔 것 · 새로 읽기 · 읽는 동안 loading', () async {
final pane = PaneState(root);
final seen = <bool>[];
pane.addListener(() => seen.add(pane.loading.contains(root)));
expect((await loadDir(pane, root, sort: byName)).kind, 'loaded');
expect(seen.first, isTrue, reason: '읽는 동안 loading');
expect(pane.loading, isEmpty);
expect([for (final e in pane.cache[root]!) p.basename(e.path)], ['Movies', 'Show', 'notes.txt']);
expect((await loadDir(pane, root, sort: byName)).kind, 'cached');
File(p.join(root, 'new.txt')).writeAsStringSync('x');
var fresh = '';
expect((await loadDir(pane, root, sort: byName, force: true, onFresh: (d) => fresh = d)).kind, 'loaded');
expect(fresh, root);
expect(pane.cache[root]!.length, 4);
});
test('폴더 읽기 오류: 이유를 남기고 빈 목록 · 보던 폴더가 지워지면 남은 상위 폴더 · OS 가 막은 폴더 · 압축 안', () async {
final pane = PaneState(root);
final gone = p.join(show, 'Extras');
pane.current = gone;
Directory(gone).deleteSync();
final r = await loadDir(pane, gone, sort: byName);
expect([r.kind, r.vanishedUpTo], ['vanished', show]);
expect(pane.cache.containsKey(gone), isFalse);
final other = p.join(root, 'nope');
expect((await loadDir(pane, other, sort: byName)).kind, 'failed');
expect(pane.cache[other], isEmpty);
expect(pane.errors[other], isNotEmpty, reason: '빈 목록으로 숨기지 않음');
expect((await loadDir(pane, movies, sort: byName, osBlocked: (_) => 'BLOCKED')).kind, 'loaded');
expect(pane.errors[movies], 'BLOCKED', reason: '비어 보이는 막힌 폴더');
final zip = p.join(root, 'a.zip');
await loadDir(pane, zip, sort: byName, inside: (d, f) async => [FileEntry(p.join(zip, 'in.txt'), isDir: false, modified: DateTime(0))]);
expect(pane.cache[zip]!.single.path, endsWith('in.txt'));
});
test('줄 목록: 펼친 것만 · 깊이 · 빠른 필터 (안에 맞는 것이 있으면 폴더는 남김) · 숨김 · 폴더만', () async {
final pane = PaneState(root);
await open(pane, root);
await open(pane, show);
List<TreeRow> rows({bool showHidden = false, bool foldersOnly = false}) => buildTreeRows(pane,
foldersOnly: foldersOnly, isHidden: (x) => samePath(x, notes), showHidden: showHidden, browsable: (_) => false);
expect(names(rows()), [p.basename(root), ' Movies', ' Show', ' Extras', ' ep01.mkv', ' ep02.mkv', 'hidden']);
expect(rows().last.count, 1, reason: '숨긴 수');
expect(names(rows(showHidden: true)), contains(' notes.txt'));
// 폴더만 보기는 파일을 먼저 건너뛰어 숨긴 파일도 세지 않는다 (숨김 줄 없음)
expect(names(rows(foldersOnly: true)), [p.basename(root), ' Movies', ' Show', ' Extras']);
pane.current = show;
final filtered = _Filtered(pane, 'ep02');
expect(names(buildTreeRows(filtered, isHidden: (_) => false, browsable: (_) => false)),
[p.basename(root), ' Movies', ' Show', ' ep02.mkv', ' notes.txt'],
reason: '지금 폴더 (Show) 안만 거르고, 밖 (Movies) 은 그대로');
});
test('줄 목록: 맨 위 다른 장치 · 즐겨찾기 묶음 (펼치면 안의 것)', () async {
final pane = PaneState(root);
await open(pane, root);
final rows = buildTreeRows(pane,
roots: [r'A:\', root, r'Z:\'], favorites: [show], isHidden: (_) => false, browsable: (_) => false);
expect([for (final r in rows) r.kind].where((k) => k == TreeRow.otherRoot).length, 2);
expect(rows.first.entry.path, r'A:\');
expect(rows.last.kind, TreeRow.favorites);
pane.favoritesOpen = true;
final open2 = buildTreeRows(pane, roots: [root], favorites: [show], isHidden: (_) => false, browsable: (_) => false);
expect(open2.last.kind, TreeRow.favorite);
expect(quickFilterMatch('Show.S01E02.mkv', '*.mkv'), isTrue);
expect(globToRegExp('*.srt; ep??.mkv').hasMatch('EP01.MKV'), isTrue);
});
}
/// 빠른 필터 글을 가진 창 (화면이 칸의 글로 덮어쓰는 것과 같게)
class _Filtered extends PaneState {
_Filtered(PaneState from, this._f) : super(from.root) {
expanded.addAll(from.expanded);
cache.addAll(from.cache);
current = from.current;
}
final String _f;
@override
String get filter => _f;
}

View File

@ -0,0 +1,54 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/core/file_ops.dart' show samePath;
import 'package:jj_mkvmaker/core/nav_history.dart';
/// E5a 약속 시험 (모듈 하나에 한 장): lib/core/nav_history.dart 맨 위 약속.
void main() {
test('다른 곳으로 가면 떠난 곳이 뒤로, 앞으로는 비움 · 같은 곳 · 빈 경로는 넣지 않음 (대소문자 무시)', () {
final h = NavHistory(same: samePath);
h.visit('', r'C:\A');
h.visit(r'C:\A', r'c:\a');
expect(h.canBack, isFalse);
h.visit(r'C:\A', r'C:\B');
h.visit(r'C:\B', r'C:\C');
expect(h.back, [r'C:\A', r'C:\B']);
expect(h.canForward, isFalse);
});
test('뒤로 → 앞으로 · 새로 가면 앞으로를 비움 · 움직이는 중에는 넣지도 꺼내지도 않음', () {
final h = NavHistory(same: samePath)
..visit('a', 'b')
..visit('b', 'c');
final to = h.take(forward: false);
expect(to, 'b');
expect(h.take(forward: false), isNull, reason: '앞의 뒤로가 끝나기 전');
h.visit('c', 'b'); // 이동하는 동안 지금 폴더가 바뀜 - 넣지 않음
h.arrive('c', forward: false);
h.finish();
expect([h.back, h.forward], [
['a'],
['c']
]);
expect(h.take(forward: true), 'c');
h
..arrive('b', forward: true)
..finish();
expect(h.back, ['a', 'b']);
h.visit('c', 'd');
expect(h.forward, isEmpty);
});
test('갈 수 없으면 그 항목만 버리고 지금 자리 그대로 · 상한', () {
final h = NavHistory(same: samePath, limit: 3);
for (var i = 0; i < 5; i++) {
h.visit('$i', '${i + 1}');
}
expect(h.back, ['2', '3', '4']);
expect(h.take(forward: false), '4');
h.finish();
expect([h.back, h.forward], [
['2', '3'],
<String>[]
]);
});
}

View File

@ -0,0 +1,47 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/volumes.dart';
import 'package:path/path.dart' as p;
/// E5a 약속 시험: lib/app/volumes.dart 맨 위 약속 (이 PC 의 실제 드라이브 · 시험 폴더).
void main() {
test('이 기기의 저장 장치는 비지 않고, 경로가 든 맨 위를 고른다 (가장 긴 장치 · 대소문자 무시)', () async {
final v = await localVolumes();
expect(v, isNotEmpty);
final tmp = Directory.systemTemp.createTempSync('jj_vol_');
addTearDown(() => tmp.deleteSync(recursive: true));
final local = [...v, (tmp.path, 'tmp')];
expect(volumeOf(p.join(tmp.path, 'a', 'b'), local), tmp.path);
if (Platform.isWindows) {
expect(volumeOf(p.join(tmp.path, 'A').toUpperCase(), local).toLowerCase(), tmp.path.toLowerCase());
expect(volumeOf(r'\\nas\share\x', local), r'\\nas\share');
}
});
test('다시 열 수 있는지: 있는 폴더 true · 없는 폴더 false', () async {
final tmp = Directory.systemTemp.createTempSync('jj_vol_');
addTearDown(() => tmp.deleteSync(recursive: true));
expect(await reachableAsync(tmp.path), isTrue);
expect(await reachableAsync(p.join(tmp.path, 'none')), isFalse);
});
test('드라이브 상태: 확인 중 null → 결과 · 예외는 false · 닫은 뒤 알리지 않음', () async {
final s = DriveStatus();
final seen = <bool?>[];
s.addListener(() => seen.add(s.ok['X:']));
final gate = Completer<bool>();
final f = s.check('X:', (_) => gate.future);
expect(s.ok['X:'], isNull);
gate.complete(true);
expect(await f, isTrue);
expect(seen, [null, true]);
expect(await s.check('Y:', (_) async => throw const FileSystemException('x')), isFalse);
final late = Completer<bool>();
final g = s.check('Z:', (_) => late.future);
s.dispose();
late.complete(true);
expect(await g, isTrue, reason: '닫은 뒤 끝나도 던지지 않음');
});
}