449 lines
17 KiB
Dart
449 lines
17 KiB
Dart
import 'dart:async';
|
||
import 'dart:io';
|
||
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:path/path.dart' as p;
|
||
|
||
import '../../app/app_controller.dart';
|
||
import '../../app/archive_browse.dart';
|
||
import '../../app/volumes.dart' as vol;
|
||
import '../../app/volumes.dart' show DriveStatus;
|
||
import '../../core/app_hidden.dart';
|
||
import '../../core/explorer/dir_loader.dart';
|
||
import '../../core/explorer/tree_rows.dart';
|
||
import '../../core/file_ops.dart';
|
||
import '../../core/folder_size.dart';
|
||
import '../../core/playlist.dart' show isAudioFile, isVideoFile;
|
||
import '../../core/secret_gate.dart';
|
||
import '../../core/shizuku.dart';
|
||
import '../../core/vfs.dart';
|
||
import '../../l10n/tr.dart';
|
||
import '../archive_ui.dart' show bundledSevenZip;
|
||
import '../explorer_look.dart';
|
||
import '../file_error.dart';
|
||
import 'explorer_host.dart';
|
||
import 'explorer_pane.dart';
|
||
|
||
/// 약속 (E5d-1): 탐색기 화면 하나의 상태와 화면 밖의 일 - 창 두 개 · 지금 창 · 저장 장치 · 드라이브 · 폴더 크기 · 압축 ·
|
||
/// 목록에서 숨기기 · 읽기 · 정렬 · 이동 · 뒤로/앞으로 · 기억 · 새로 고침.
|
||
/// 일은 이미 있는 모듈에 맡기고 묶어 부르기만 한다 (PaneState · NavHistory · dir_loader · tree_rows · volumes ·
|
||
/// archive_browse · app_hidden · folder_size). 화면에 부탁할 것 (알림 · 스크롤 · 다음 그리기) 은 [ExplorerHost] 로.
|
||
/// 바뀌면 [notifyListeners] (화면이 다시 그림). 오류는 던지지 않고 창의 오류 · 알림으로.
|
||
class ExplorerController extends ChangeNotifier {
|
||
ExplorerController(this.c, {required this.rsync, required this.host}) {
|
||
sizes.addListener(_onSizes);
|
||
drives.addListener(notifyListeners);
|
||
}
|
||
|
||
final AppController c;
|
||
final bool rsync;
|
||
final ExplorerHost host;
|
||
|
||
late final List<ExplorerPane> panes = [ExplorerPane('', rsync: rsync), ExplorerPane('', rsync: rsync)];
|
||
int active = 0;
|
||
bool ready = false;
|
||
ExplorerPane get pane => panes[active];
|
||
|
||
/// 이 기기의 저장 장치: (경로, 이름)
|
||
List<(String, String)> local = [];
|
||
|
||
/// 165: 드라이브마다 따로 뒤에서 확인한 상태
|
||
final drives = DriveStatus();
|
||
|
||
/// 80: 폴더 크기 (필요할 때 계산하고 이번 실행 동안 기억)
|
||
final sizes = FolderSizes();
|
||
|
||
/// X-plore 비교 f: 압축을 폴더처럼 - 잊으면 창들의 그 안 목록도 지운다
|
||
late final arc = ArchiveBrowser(bundledSevenZip, onForget: (a) {
|
||
for (final q in panes) {
|
||
q.cache.removeWhere((k, _) => samePath(k, a) || isSameOrInside(k, a));
|
||
}
|
||
});
|
||
|
||
/// X-plore 숨기기: 목록에서 숨긴 것 (앱 설정의 목록 - 파일은 그대로)
|
||
final hidden = HiddenList();
|
||
|
||
/// X-plore 숨기기: [숨긴 항목 n개 보이기] (이번 실행 동안, 모든 창에)
|
||
bool showAppHidden = false;
|
||
|
||
void update() => notifyListeners();
|
||
|
||
@override
|
||
void dispose() {
|
||
sizes.dispose();
|
||
drives.dispose();
|
||
for (final q in panes) {
|
||
q.dispose();
|
||
}
|
||
super.dispose();
|
||
}
|
||
|
||
// ───────── 저장 장치 ─────────
|
||
|
||
/// 저장 장치 + 서버들 (WebDAV · SFTP · SMB · FTP · 시스템 폴더 · 루트): (경로, 이름)
|
||
List<(String, String)> get volumes => [
|
||
...local,
|
||
for (final s in c.settings.webdavServers) ('$davScheme${s.id}/', s.label),
|
||
for (final s in c.settings.sftpServers) ('$sftpScheme${s.id}/', s.label),
|
||
for (final s in c.settings.smbServers) ('$smbScheme${s.id}/', s.label),
|
||
// l: 평문 FTP 는 이름 앞에 ⚠
|
||
for (final s in c.settings.ftpServers) ('$ftpScheme${s.id}/', s.tls ? s.label : '⚠ ${s.label}'),
|
||
if (c.settings.shizukuEnabled && shizukuSupported()) ('$shizukuScheme$shizukuServer/', tr('시스템 (읽기 전용)')),
|
||
if (c.settings.rootEnabled && rootSupported()) (rootHome, tr('루트 (읽기 전용)')),
|
||
];
|
||
|
||
String volumeOf(String path) => vol.volumeOf(path, local);
|
||
|
||
/// 화면에 보일 경로 (WebDAV 는 "☁ 서버 › 폴더", Android 는 저장 장치 이름부터)
|
||
String displayPath(String path) {
|
||
if (isDav(path)) {
|
||
final d = DavPath.parse(path);
|
||
return ['${d.mark} ${d.serverLabel}', ...d.rel.split('/').where((s) => s.isNotEmpty)].join(' › ');
|
||
}
|
||
if (!Platform.isAndroid) return path;
|
||
String? best;
|
||
var label = '';
|
||
for (final (v, l) in local) {
|
||
if ((samePath(v, path) || p.isWithin(v, path)) && (best == null || v.length > best.length)) {
|
||
best = v;
|
||
label = l;
|
||
}
|
||
}
|
||
if (best == null) return path;
|
||
final rest = p.relative(path, from: best);
|
||
return [label, if (rest != '.') ...p.split(rest)].join(' › ');
|
||
}
|
||
|
||
/// 설정에서 지운 WebDAV 서버를 보던 창은 이 기기의 첫 저장 장치로
|
||
void dropRemovedServers() {
|
||
if (!ready) return;
|
||
for (final q in panes) {
|
||
if (isDav(q.root) && !DavPath.parse(q.root).serverKnown) {
|
||
q.sel.clear();
|
||
q.cache.removeWhere((k, _) => isDav(k));
|
||
q.expanded.removeWhere(isDav);
|
||
q.root = local.first.$1; // 다시 그릴 때 또 하지 않게
|
||
host.afterFrame(() {
|
||
if (host.alive) unawaited(goTo(q, local.first.$1));
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// ───────── 폴더 크기 (80) ─────────
|
||
|
||
void _onSizes() {
|
||
if (!host.alive) return;
|
||
for (final q in panes) {
|
||
for (final k in q.cache.keys.toList()) {
|
||
if (sortOf(k).$1 == 'size') q.cache[k] = sorted(q.cache[k]!, k);
|
||
}
|
||
}
|
||
notifyListeners();
|
||
}
|
||
|
||
/// 폴더 크기 글 (모르면 null). 설정이 자동이면 계산을 맡긴다 - 지금 보는 폴더 바로 안의 폴더만, WebDAV 는 하지 않음
|
||
String? dirSize(ExplorerPane q, FileEntry e) {
|
||
final s = sizes.of(e.path);
|
||
if (s != null) return s.label;
|
||
if (sizes.busy(e.path)) return '…';
|
||
final mode = c.settings.explorerFolderSize;
|
||
if ((mode == 'always' || (mode == 'sort' && sortOf(q.current).$1 == 'size')) &&
|
||
samePath(vDirname(e.path), q.current) &&
|
||
!isDav(e.path)) {
|
||
final forDir = q.current;
|
||
host.afterFrame(() {
|
||
if (host.alive) sizes.request(e.path, forDir: forDir);
|
||
});
|
||
return '…';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ───────── 목록 읽기 ─────────
|
||
|
||
Future<void> load(ExplorerPane q, String dir, {bool force = false}) async {
|
||
final r = await loadDir(q, dir,
|
||
force: force,
|
||
showHidden: c.settings.explorerShowHidden,
|
||
sort: sorted,
|
||
inside: _insideArchiveList,
|
||
osBlocked: (d) => isAndroidRestricted(d) ? androidRestrictedError : null,
|
||
onFresh: sizes.forget);
|
||
// 37-2: 보던 폴더가 밖에서 지워졌으면 남아 있는 가장 가까운 상위 폴더로
|
||
if (r.vanishedUpTo case final up?) {
|
||
host.afterFrame(() {
|
||
if (!host.alive) return;
|
||
host.snack(tr('보던 폴더가 없어져 상위 폴더로 옮겼습니다'));
|
||
unawaited(goTo(q, up, fresh: true));
|
||
});
|
||
}
|
||
}
|
||
|
||
Future<List<FileEntry>?> _insideArchiveList(String dir, bool force) async {
|
||
final a = arc.archiveOf(dir);
|
||
if (a == null) return null;
|
||
if (force && !await readArchive(a)) return const [];
|
||
return arc.contains(a) ? arc.list(dir) : null;
|
||
}
|
||
|
||
/// 압축의 목록을 읽어 둔다 (못 읽으면 까닭을 알림). 읽었으면 true
|
||
Future<bool> readArchive(String path) async {
|
||
switch (await arc.read(path)) {
|
||
case ArchiveRead.ok:
|
||
return true;
|
||
case ArchiveRead.gone:
|
||
return false;
|
||
case ArchiveRead.noTool:
|
||
host.snack(tr('이 기기에서는 7z · RAR 안을 볼 수 없습니다 (다른 앱으로 열어 주세요)'));
|
||
case ArchiveRead.password:
|
||
host.snack(tr('비밀번호가 걸린 압축은 안을 미리 볼 수 없습니다 - [압축 풀기] 로 푸세요'));
|
||
case ArchiveRead.failed:
|
||
host.snack(trf('압축을 읽지 못했습니다: {0}', [arc.lastError]));
|
||
}
|
||
return false;
|
||
}
|
||
|
||
Future<void> refresh(ExplorerPane q, Iterable<String> dirs) async {
|
||
for (final d in {...dirs}) {
|
||
if (q.cache.containsKey(d)) await load(q, d, force: true);
|
||
}
|
||
}
|
||
|
||
Future<void> refreshAll(Iterable<String> dirs) async {
|
||
for (final q in panes) {
|
||
await refresh(q, dirs);
|
||
}
|
||
}
|
||
|
||
/// 복사 · 이동 뒤: 대상 · 원본 폴더와 그 안의 읽어 둔 것을 새로 (없어진 것은 잊음, 자리 · 스크롤 그대로)
|
||
Future<void> refreshAfterTransfer(List<String> sources, String dest) async {
|
||
for (final x in panes) {
|
||
final dirs = {
|
||
dest,
|
||
...sources.map(vDirname),
|
||
for (final k in x.cache.keys)
|
||
if (isSameOrInside(k, dest) || sources.any((s) => isSameOrInside(k, s))) k,
|
||
};
|
||
for (final d in dirs) {
|
||
if (!host.alive) return;
|
||
if (!x.cache.containsKey(d)) continue;
|
||
if (!await vExists(d)) {
|
||
x.cache.remove(d);
|
||
x.expanded.remove(d);
|
||
continue;
|
||
}
|
||
await load(x, d, force: true);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 열려 있는 (보이는) 폴더를 두 창 모두 다시 읽는다 ([localOnly]: WebDAV 등은 빼고)
|
||
bool _reloading = false;
|
||
Future<void> reloadOpen({bool localOnly = false}) async {
|
||
if (_reloading) return;
|
||
_reloading = true;
|
||
try {
|
||
for (final x in panes) {
|
||
final open = {...x.expanded, x.current};
|
||
for (final d in open) {
|
||
if (!host.alive) return;
|
||
if (localOnly && isDav(d)) continue;
|
||
await load(x, d, force: true);
|
||
}
|
||
x.cache.removeWhere((k, _) => !open.contains(k));
|
||
x.changed();
|
||
}
|
||
} finally {
|
||
_reloading = false;
|
||
}
|
||
}
|
||
|
||
// ───────── 정렬 (84) ─────────
|
||
|
||
List<FileEntry> sorted(List<FileEntry> list, String dir) {
|
||
final (sortBy, desc) = sortOf(dir, list: list);
|
||
final by = SortBy.values.firstWhere((s) => s.name == sortBy, orElse: () => SortBy.name);
|
||
return sortEntries(list, by, descending: desc, dirSize: (d) => sizes.of(d.path)?.bytes);
|
||
}
|
||
|
||
static String _sortKey(String dir) => isDav(dir) ? vNorm(dir) : (Platform.isWindows ? dir.toLowerCase() : dir);
|
||
|
||
/// [dir] 의 정렬 (기준, 거꾸로) - 폴더마다 기억한 것 → 종류별 기본 정렬 → 전체 정렬
|
||
(String, bool) sortOf(String dir, {List<FileEntry>? list}) {
|
||
final s = c.settings;
|
||
if (s.explorerSortPerFolder || s.explorerSortByKind) {
|
||
final v = s.explorerFolderSorts[_sortKey(dir)];
|
||
if (v != null && v.contains('|')) return (v.split('|').first, v.endsWith('|1'));
|
||
}
|
||
if (s.explorerSortByKind) {
|
||
final k = _kindSort(dir, list: list);
|
||
if (k != null) return k;
|
||
}
|
||
return (s.explorerSort, s.explorerSortDesc);
|
||
}
|
||
|
||
/// X-plore 비교 5-1 ①: 파일의 반 넘게가 사진 · 동영상이면 날짜 (새것 먼저), 음악이면 이름, 그 밖은 null
|
||
(String, bool)? _kindSort(String dir, {List<FileEntry>? list}) {
|
||
if (list == null) {
|
||
for (final q in panes) {
|
||
list = q.cache[dir];
|
||
if (list != null) break;
|
||
}
|
||
}
|
||
final files = [for (final e in list ?? const <FileEntry>[]) if (!e.isDir) e];
|
||
if (files.isEmpty) return null;
|
||
final media = files.where((e) => c.settings.imageExts.contains(e.ext) || isVideoFile(e.path)).length;
|
||
if (media * 2 > files.length) return ('date', true);
|
||
if (files.where((e) => isAudioFile(e.path)).length * 2 > files.length) return ('name', false);
|
||
return null;
|
||
}
|
||
|
||
/// 정렬을 바꾼다 - "폴더마다 기억" (또는 종류별 정렬이 쓰이는 폴더) 이면 [dir] 에만, 아니면 전체. 모든 창을 다시 줄 세운다
|
||
Future<void> setSort(String dir, String by, bool desc) async {
|
||
final kindHere = c.settings.explorerSortByKind && _kindSort(dir) != null;
|
||
await c.updateSettings((x) {
|
||
if (x.explorerSortPerFolder || kindHere) {
|
||
final m = Map<String, String>.of(x.explorerFolderSorts)..remove(_sortKey(dir));
|
||
m[_sortKey(dir)] = '$by|${desc ? 1 : 0}';
|
||
while (m.length > 300) {
|
||
m.remove(m.keys.first);
|
||
}
|
||
x.explorerFolderSorts = m;
|
||
} else {
|
||
x
|
||
..explorerSort = by
|
||
..explorerSortDesc = desc;
|
||
}
|
||
});
|
||
resortAll();
|
||
}
|
||
|
||
/// 읽어 둔 목록을 모두 다시 줄 세운다
|
||
void resortAll() {
|
||
for (final q in panes) {
|
||
for (final k in q.cache.keys.toList()) {
|
||
q.cache[k] = sorted(q.cache[k]!, k);
|
||
}
|
||
q.changed();
|
||
}
|
||
}
|
||
|
||
/// 열 머리를 누름 - 같은 열이면 거꾸로, 다른 열이면 그 열로 (오름차순)
|
||
Future<void> sortByColumn(ExplorerPane q, String by) async {
|
||
active = panes.indexOf(q);
|
||
notifyListeners();
|
||
final (now, desc) = sortOf(q.current);
|
||
await setSort(q.current, by, now == by ? !desc : false);
|
||
}
|
||
|
||
// ───────── 이동 ─────────
|
||
|
||
/// 그 폴더로: 저장 장치부터 그 폴더까지 펼치고 지금 폴더로 정한다. [fresh] 면 그 폴더를 새로 읽고,
|
||
/// [focus] 일 때만 그 폴더를 고른 항목으로 (48 보충). [remember] 면 마지막 폴더 · 내역에 남긴다
|
||
Future<void> goTo(ExplorerPane q, String dir, {bool remember = true, bool fresh = false, bool focus = false}) async {
|
||
// 124 · 128: 사용자가 WebDAV 로 간 것이면 마스터 창을 한 번 취소했어도 다시 묻는다
|
||
if (remember && isDav(dir)) await SecretGate.pass(force: true);
|
||
if (!host.alive) return;
|
||
q.root = volumeOf(dir);
|
||
final chain = <String>[];
|
||
dir = isDav(dir) ? vNorm(dir) : p.normalize(dir);
|
||
var d = dir;
|
||
while (true) {
|
||
chain.insert(0, d);
|
||
if (samePath(d, q.root) || vDirname(d) == d) break;
|
||
d = vDirname(d);
|
||
}
|
||
for (final (k, x) in chain.indexed) {
|
||
q.expanded.add(x);
|
||
await load(q, x, force: fresh && k == chain.length - 1);
|
||
// 122: 읽어 둔 목록에 다음 폴더가 없으면 (앱 밖에서 새로 만듦) 새로 읽어야 그 폴더까지 펼쳐진다
|
||
if (k < chain.length - 1 && !(q.cache[x] ?? const <FileEntry>[]).any((e) => samePath(e.path, chain[k + 1]))) {
|
||
await load(q, x, force: true);
|
||
}
|
||
}
|
||
q.current = dir;
|
||
if (focus) q.focused = dir;
|
||
q.changed();
|
||
if (q.listScroll.hasClients) q.listScroll.jumpTo(0);
|
||
host.reveal(q, dir);
|
||
if (remember) this.remember(q, dir);
|
||
}
|
||
|
||
/// 75: 창마다 뒤로 · 앞으로 (가 본 폴더로) - 내역 규칙은 NavHistory. 갈 수 없는 폴더는 내역에서 빼고 알림
|
||
Future<void> travel(ExplorerPane q, {required bool forward}) async {
|
||
final to = q.nav.take(forward: forward);
|
||
if (to == null) return;
|
||
try {
|
||
active = panes.indexOf(q);
|
||
notifyListeners();
|
||
bool ok;
|
||
try {
|
||
ok = await vIsDir(to);
|
||
} catch (_) {
|
||
ok = false;
|
||
}
|
||
if (!host.alive) return;
|
||
if (!ok) {
|
||
for (final x in panes) {
|
||
x.expanded.removeWhere((d) => samePath(d, to) || d.toLowerCase().startsWith('${to.toLowerCase()}${isDav(to) ? '/' : p.separator}'));
|
||
}
|
||
await refreshAll([vDirname(to)]);
|
||
if (!host.alive) return;
|
||
notifyListeners();
|
||
host.snack(trf('{0} 폴더가 없거나 열 수 없어 내역에서 뺐습니다', [displayPath(to)]));
|
||
return;
|
||
}
|
||
q.nav.arrive(q.current, forward: forward);
|
||
await goTo(q, to);
|
||
} finally {
|
||
q.nav.finish();
|
||
}
|
||
if (host.alive) notifyListeners();
|
||
}
|
||
|
||
/// 마지막 폴더 · 내역 저장
|
||
void remember(ExplorerPane q, String dir) {
|
||
final i = panes.indexOf(q);
|
||
final paths = [...panes.map((x) => x.current)];
|
||
if (rsync) {
|
||
unawaited(c.updateSettings((s) => s.rsyncPaths = paths));
|
||
} else {
|
||
final hist = [dir, ...c.settings.explorerHistory.where((h) => !samePath(h, dir))].take(30).toList();
|
||
unawaited(c.updateSettings((s) => s
|
||
..explorerPaths = paths
|
||
..explorerHistory = hist));
|
||
}
|
||
if (i >= 0) active = i;
|
||
}
|
||
|
||
// ───────── 줄 목록 · 목록에서 숨기기 ─────────
|
||
|
||
ExplorerStyle get look => ExplorerStyle.of(c.settings.explorerStyle);
|
||
bool get rootsInTree => !rsync && look == ExplorerStyle.xplore && c.settings.explorerRootsInTree;
|
||
|
||
List<TreeRow> rows(ExplorerPane q, {bool foldersOnly = false}) => buildTreeRows(q,
|
||
foldersOnly: foldersOnly,
|
||
roots: rootsInTree ? [for (final (path, _) in volumes) path] : const [],
|
||
favorites: rootsInTree ? [for (final b in c.settings.folderBookmarks) b.path] : const [],
|
||
isHidden: appHidden,
|
||
showHidden: showAppHidden,
|
||
browsable: arc.contains);
|
||
|
||
bool appHidden(String path) => hidden.isHidden(c.settings.explorerHiddenItems, path);
|
||
|
||
/// 옮기거나 이름을 바꾼 것은 숨긴 목록도 새 경로로, 지운 것은 목록에서 뺀다 (관리자 결정 10/18)
|
||
Future<void> followHidden({Iterable<(String, String)> moved = const [], Iterable<String> gone = const []}) async {
|
||
final out = await HiddenList.follow(c.settings.explorerHiddenItems, moved: moved, gone: gone);
|
||
if (out != null) await c.updateSettings((s) => s.explorerHiddenItems = out);
|
||
}
|
||
|
||
void toggleAppHidden() {
|
||
showAppHidden = !showAppHidden;
|
||
notifyListeners();
|
||
for (final q in panes) {
|
||
q.changed();
|
||
}
|
||
}
|
||
}
|