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
parent
8335a3eac7
commit
dab9db5d4d
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>[]
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
|
@ -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: '닫은 뒤 끝나도 던지지 않음');
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue