diff --git a/lib/app/volumes.dart b/lib/app/volumes.dart new file mode 100644 index 0000000..a28883d --- /dev/null +++ b/lib/app/volumes.dart @@ -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> 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 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 = {}; + final network = {}; + + /// 꺼내는 장치 · CD (카드 리더 · USB): 열 수 없으면 "비어 있음" (연결 안 됨이 아니라 매체가 없는 것) + final removable = {}; + + /// Windows 드라이브 종류를 적는다 (네트워크 4 · 꺼내는 장치 2 · CD 5) + void classify(Iterable 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 check(String path, Future 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(); + } +} diff --git a/lib/core/explorer/dir_loader.dart b/lib/core/explorer/dir_loader.dart new file mode 100644 index 0000000..9342680 --- /dev/null +++ b/lib/core/explorer/dir_loader.dart @@ -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 loadDir( + PaneState pane, + String dir, { + bool force = false, + bool showHidden = false, + required List Function(List list, String dir) sort, + Future?> 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(); + } +} diff --git a/lib/core/explorer/pane_state.dart b/lib/core/explorer/pane_state.dart new file mode 100644 index 0000000..e03eb33 --- /dev/null +++ b/lib/core/explorer/pane_state.dart @@ -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 = {}; + + /// 읽어 둔 목록 (폴더 → 정렬된 항목) + final cache = >{}; + final loading = {}; + + /// 읽지 못한 폴더 → 이유 + final errors = {}; + + /// 88: 처음 보는 (또는 바뀐) SFTP 서버 키 - 오류 안내에 지문 · [지문 확인하고 연결] + final hostKeyErrors = {}; + + /// l: 처음 보는 (바뀐) FTPS 자체 서명 인증서 - 폴더마다 + final certErrors = {}; + + /// 표시 (고른 것) · 범위 고르기의 시작 + 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(); + } +} diff --git a/lib/core/explorer/tree_rows.dart b/lib/core/explorer/tree_rows.dart new file mode 100644 index 0000000..94f380b --- /dev/null +++ b/lib/core/explorer/tree_rows.dart @@ -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 buildTreeRows( + PaneState pane, { + bool foldersOnly = false, + List roots = const [], + List favorites = const [], + required bool Function(String path) isHidden, + bool showHidden = false, + required bool Function(String path) browsable, +}) { + final out = []; + 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 []) { + 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; +} diff --git a/lib/core/nav_history.dart b/lib/core/nav_history.dart new file mode 100644 index 0000000..5048201 --- /dev/null +++ b/lib/core/nav_history.dart @@ -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 = []; + final forward = []; + + /// 뒤로/앞으로로 움직이는 중 ([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; +} diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 455d029..3275155 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -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 = []; - final forward = []; - bool traveling = false; - - /// 이 창의 목록이 화면에 붙어 있는지 (새로 붙을 때 지금 폴더를 보이게 - [_ExplorerPageState._revealNewViews]) - bool onScreen = false; - String? focused; - final expanded = {}; - final cache = >{}; - final loading = {}; - final errors = {}; - - /// 88: 처음 보는 (또는 바뀐) SFTP 서버 키 - 오류 안내에 지문 · [지문 확인하고 연결] - final hostKeyErrors = {}; - - /// l: 처음 보는 (바뀐) FTPS 자체 서명 인증서 - 폴더마다 - final certErrors = {}; - /// 표시 (고른 것) · 범위 고르기의 시작 - 규칙은 [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 { AppController get c => widget.c; /// 이 기기의 저장 장치: (경로, 이름) List<(String, String)> _local = []; - /// 165: 드라이브마다 따로 뒤에서 확인 (Windows). null = 확인 중, true = 열 수 있음, false = 연결 안 됨 - final Map _driveOk = {}; - final Set _networkDrives = {}; - - /// 꺼내는 장치 · CD (카드 리더 · USB): 열 수 없으면 "비어 있음" (연결 안 됨이 아니라 매체가 없는 것) - final Set _removableDrives = {}; - - Future _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 _probeDrive(String path) => _drives.check(path, ExplorerPage.probeDrive); + /// 연결 안 된 드라이브를 누르면 다시 연결 (다시 읽기) - 되면 연다 Future _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 { c.removeListener(_onSettings); _keys.dispose(); _sizes.dispose(); + _drives.dispose(); if (widget.rsync) { ExplorerPage._openRsync--; } else { @@ -531,7 +454,7 @@ class _ExplorerPageState extends State { } Future _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 { // 창마다 따로 읽는다: 한 창이 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 { } // 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 { return [label, if (rest != '.') ...p.split(rest)].join(' › '); } - Future> _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 { } } - /// 다시 열 수 있는 폴더: 로컬은 있으면, WebDAV 는 그 서버가 아직 설정에 있으면 (열 때 확인) - bool _reachable(String path) => isDav(path) - ? DavPath.parse(path).serverKnown - : Directory(path).existsSync(); - - /// [_reachable] 을 화면을 멈추지 않고 (2초 넘게 걸리면 있다고 보고 열어 본다) - Future _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 _load(_Pane pane, String dir, {bool force = false}) async { - if (!force && pane.cache.containsKey(dir)) return; - // X-plore 비교 f: 압축 안 (읽어 둔 목록에서). 새로 고침이면 압축 파일이 바뀌었는지 보고 바뀌었으면 다시 읽는다 + final r = await loadDir(pane, dir, + force: force, + showHidden: c.settings.explorerShowHidden, + sort: _sorted, + inside: _insideArchiveList, + osBlocked: (d) => isAndroidRestricted(d) ? androidRestrictedError : null, + onFresh: _sizes.forget); + // 37-2: 보던 폴더가 밖에서 지워졌으면 남아 있는 가장 가까운 상위 폴더로 (제목 · 경로 칸도 그 폴더로) + if (r.vanishedUpTo case final up?) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _snack(tr('보던 폴더가 없어져 상위 폴더로 옮겼습니다')); + unawaited(_goTo(pane, up, fresh: true)); + }); + } + } + + /// X-plore 비교 f: 압축 안의 목록 (읽어 둔 것). 새로 고침이면 압축 파일이 바뀌었는지 보고 바뀌었으면 다시 읽는다. + /// 압축 안이 아니거나 아직 읽지 않았으면 null (보통 폴더로 읽음) + Future?> _insideArchiveList(String dir, bool force) async { final arc = _archiveOf(dir); - if (arc != null) { - 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); - } - // 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); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - _snack(tr('보던 폴더가 없어져 상위 폴더로 옮겼습니다')); - unawaited(_goTo(pane, up, fresh: true)); - }); - } - } finally { - pane.loading.remove(dir); - pane.changed(); - } + 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 { } }); - /// 75: 창마다 뒤로 · 앞으로 (가 본 폴더로) + /// 75: 창마다 뒤로 · 앞으로 (가 본 폴더로) - 내역 규칙은 [NavHistory] Future _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 { _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 { 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 []) { - 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 _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 { } } - 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 { } /// 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 { 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 { /// X-plore 비교 d: 마지막으로 누른 폴더 · 시각 (두 번 누르기의 두 번째를 가리려고) (String, DateTime)? _lastFolderTap; - Future _onTap(_Pane pane, _Row row, {bool list = false}) async { + Future _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 { } /// 줄의 종류 ([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 { : ItemKind.file; /// [ClickPolicy] 가 정한 일을 한다 (화면 쪽 동작만 - 규칙은 [ClickPolicy] · [Selection]) - Future _act(_Pane pane, _Row row, ClickAction act, {bool list = false}) async { + Future _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 { 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 _shown(_Pane pane, String dir) => [ for (final x in pane.cache[dir] ?? const []) - 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 { /// 열기 (파일: 선택 설정이면 두 번 누르기, 바로 실행이면 한 번 누르기 / 폴더: 한 번 누르기): /// 트리의 폴더는 펼치기 · 접기, 목록의 폴더는 들어가기 (왼쪽 트리도 따라감), 파일은 실행. - Future _onOpen(_Pane pane, _Row row, {bool list = false}) async { + Future _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 { } Future _historyDialog(_Pane pane) async { - final hist = c.settings.explorerHistory.where(_reachable).toList(); + final hist = c.settings.explorerHistory.where(reachable).toList(); final pick = await showDialog( context: context, builder: (ctx) => SimpleDialog( @@ -5169,11 +4950,11 @@ class _ExplorerPageState extends State { /// 76: 목록 줄의 키 = 항목 경로. 키가 없으면 줄 목록이 바뀔 때 (빠른 필터 · 펼치기 · 새로 고침) 같은 자리의 줄 위젯을 /// 다른 항목에 다시 써서, 방금 누른 파일 줄이 폴더 줄로 바뀌면 두 번 누르기 기다림이 그리는 중에 버려지고 /// 그 누르기가 다른 항목에 들어갔다 (디버그에서는 "setState() called during build") - static Key _rowKey(_Row r) => + static Key _rowKey(TreeRow r) => ValueKey('${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 rows) { Map? 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 { 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 { 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 { 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 { ? 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 { 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 { } /// 한 줄. [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(); diff --git a/test/explorer_rows_contract_test.dart b/test/explorer_rows_contract_test.dart new file mode 100644 index 0000000..ba99094 --- /dev/null +++ b/test/explorer_rows_contract_test.dart @@ -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 byName(List l, String _) => sortEntries(l, SortBy.name); + Future open(PaneState pane, String dir) async { + pane.expanded.add(dir); + await loadDir(pane, dir, sort: byName); + } + + List names(List 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 = []; + 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 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; +} diff --git a/test/nav_history_contract_test.dart b/test/nav_history_contract_test.dart new file mode 100644 index 0000000..c30da77 --- /dev/null +++ b/test/nav_history_contract_test.dart @@ -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'], + [] + ]); + }); +} diff --git a/test/volumes_contract_test.dart b/test/volumes_contract_test.dart new file mode 100644 index 0000000..e293b16 --- /dev/null +++ b/test/volumes_contract_test.dart @@ -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 = []; + s.addListener(() => seen.add(s.ok['X:'])); + final gate = Completer(); + 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(); + final g = s.check('Z:', (_) => late.future); + s.dispose(); + late.complete(true); + expect(await g, isTrue, reason: '닫은 뒤 끝나도 던지지 않음'); + }); +}