2033 lines
93 KiB
Dart
2033 lines
93 KiB
Dart
export 'explorer/path_dialog.dart' show looksLikeSmb, smbToUnc;
|
||
|
||
export 'explorer/explorer_buttons.dart' show ExplorerButton;
|
||
export 'explorer/transfer_panel.dart' show TransferProgressChip;
|
||
import 'dart:async';
|
||
import 'dart:io';
|
||
import 'dart:math' as math;
|
||
|
||
import 'package:flutter/gestures.dart' show kBackMouseButton, kForwardMouseButton;
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/services.dart';
|
||
|
||
import '../app/app_controller.dart';
|
||
import '../app/copy_center.dart';
|
||
import '../app/transfer_job.dart';
|
||
import '../core/secret_gate.dart';
|
||
import '../core/file_ops.dart';
|
||
import '../core/playlist.dart' show isVideoFile;
|
||
import '../core/vfs.dart';
|
||
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';
|
||
import 'app_actions.dart';
|
||
import 'explorer_look.dart';
|
||
import 'theme.dart';
|
||
import 'file_error.dart';
|
||
import 'folder_picker.dart' show pickFolderOrDav;
|
||
import 'path_label.dart';
|
||
import 'sftp_settings.dart';
|
||
import 'ftp_settings.dart';
|
||
import '../l10n/tr.dart';
|
||
import 'explorer_bookmarks.dart';
|
||
import 'explorer_search.dart';
|
||
import 'tail_text.dart';
|
||
import '../core/thumb_cache.dart' show thumbMemoryForgetters;
|
||
import '../core/shizuku.dart';
|
||
import 'explorer_keys.dart' show defaultExplorerKeys, effectiveExplorerKeys;
|
||
import 'quick_look.dart';
|
||
import 'explorer_drag.dart';
|
||
import 'storage_page.dart';
|
||
import 'wifi_share_page.dart';
|
||
import '../core/wifi_share.dart' show WifiShare;
|
||
import 'batch_rename_dialog.dart';
|
||
import '../core/batch_rename.dart';
|
||
import 'notice.dart';
|
||
import '../app/volumes.dart';
|
||
import 'explorer/explorer_controller.dart';
|
||
import 'explorer/explorer_host.dart';
|
||
import 'explorer/explorer_pane.dart';
|
||
import 'explorer/file_thumbs.dart';
|
||
import 'explorer/explorer_select.dart';
|
||
import 'explorer/path_dialog.dart';
|
||
import 'explorer/pane_header.dart';
|
||
import 'explorer/pane_view.dart';
|
||
import 'explorer/explorer_buttons.dart';
|
||
import 'explorer/item_menu.dart';
|
||
import 'explorer/name_dialog.dart';
|
||
import 'explorer/open_flow.dart';
|
||
import 'explorer/delete_flow.dart';
|
||
import 'explorer/explorer_jobs.dart';
|
||
import 'explorer/rsync_flow.dart';
|
||
import 'explorer/transfer_flow.dart';
|
||
import 'explorer/write_guard.dart';
|
||
import 'explorer/transfer_panel.dart';
|
||
import 'explorer/marks_bar.dart';
|
||
import 'explorer/toolbar.dart';
|
||
import 'explorer/server_flows.dart';
|
||
|
||
export '../core/explorer/tree_rows.dart' show globToRegExp, quickFilterMatch;
|
||
export '../core/name_clash.dart' show NameClash, clashCompareText, fileDateText;
|
||
|
||
/// 파일 탐색기 (X-plore 참고): 두 창 (트리 목록) + 가운데 기능 버튼 줄.
|
||
///
|
||
/// - 폴더를 누르면 그 자리에서 펼치고 · 접는다 (트리). 마지막으로 누른 폴더가 그 창의 "지금 폴더".
|
||
/// - 동영상을 누르면 기본 동작 (내장 플레이어, 또는 환경 설정 > 재생 > 확장자별 재생 프로그램),
|
||
/// 길게 누르거나 오른쪽 클릭하면 메뉴 (내장 플레이어 · 다른 앱으로 열기 · MKV 목록에 추가 · 이름 바꾸기 …).
|
||
/// - 오른쪽 동그라미로 여러 개를 표시해 복사 · 이동 · 삭제 · 재생한다. 복사 · 이동은 다른 창의 지금 폴더로.
|
||
/// - 창 배치 (두 창 / 한 창, 좌우 / 위아래, 버튼 줄 위치) 와 버튼 구성은 바꿀 수 있다 (위쪽 ⋮ 메뉴).
|
||
///
|
||
/// [rsync] 면 Rsync 화면: 같은 모양이지만 늘 좌우 두 창 · 폴더만 · 창마다 폴더 하나만 고른다.
|
||
/// 가운데 → (왼쪽 → 오른쪽) · ← (오른쪽 → 왼쪽) · ⇄ (둘 다 함께) 로 rsync, [모니터링] 에 실행한 것이 남는다.
|
||
/// 202 · 221: 창 위 줄을 [← → ★] · 탭 · [WebDAV · 경로 입력] 으로 펴는 가장 작은 창 폭. 이보다 좁으면 끝 버튼은 [⋮] 하나 -
|
||
/// 폰 세로 (약 400) 에서 탭 칸이 130px 남짓이라 두 번째 저장소 (SD 카드) 가 잘려 보였다
|
||
|
||
/// 191: Rsync 화면이 좌우 두 창 + 가운데 버튼 줄로 보이는 가장 작은 폭 (이보다 좁으면 창 하나씩)
|
||
const double rsyncTwoPaneMinWidth = 560;
|
||
|
||
class ExplorerPage extends StatefulWidget {
|
||
final AppController c;
|
||
final bool rsync;
|
||
|
||
/// 지금 시각 (폴더 두 번 누르기의 두 번째인지 가르는 데). 시험은 정한 시각을 넘긴다 - 실제 시간에 기대 우연히 통과하지 않게
|
||
final DateTime Function() clock;
|
||
const ExplorerPage({super.key, required this.c, this.rsync = false, this.clock = DateTime.now});
|
||
|
||
/// 165: 드라이브를 열 수 있는지 (시험에서 응답 없는 드라이브로 바꾼다). 화면을 멈추지 않는 비동기
|
||
@visibleForTesting
|
||
static Future<bool> Function(String path) probeDrive = (path) => Directory(path).exists();
|
||
|
||
/// 시험: 이 기기의 저장 장치 목록 (null 이면 실제 목록)
|
||
@visibleForTesting
|
||
static List<(String, String)> Function()? debugVolumes;
|
||
|
||
/// 시험: 경로 입력을 Android 처럼 (SMB 안내, 179). null 이면 실제 기기
|
||
@visibleForTesting
|
||
static bool? debugAndroidPaths;
|
||
|
||
/// 시험: Android 앱 휴지통 (X-plore e) 을 Windows 에서. null 이면 실제 기기
|
||
@visibleForTesting
|
||
static bool? debugAndroidTrash;
|
||
|
||
/// 시험: 폴더 .nomedia 넣기 · 지우기 (Android 갤러리 · 음악 앱에서 숨기기) 를 Windows 에서. null 이면 Android 만
|
||
@visibleForTesting
|
||
static bool? debugNomedia;
|
||
|
||
/// 이번 실행에서 시스템 폴더 경고를 보였는지 (관리자 결정 10/21: 앱을 켠 뒤 처음 열 때 한 번)
|
||
static bool shizukuWarnedThisRun = false;
|
||
|
||
/// 루트도 같게 (따로 센다)
|
||
static bool rootWarnedThisRun = false;
|
||
|
||
/// 시험: 경로 입력 창이 넣은 경로를 고쳐 쓰고 확인하는 것 (갈 경로, 칸 아래 이유)
|
||
@visibleForTesting
|
||
static Future<(String?, String?)> checkPathForTest(String input) =>
|
||
checkEnteredPath(input, android: debugAndroidPaths ?? Platform.isAndroid);
|
||
|
||
static const routeName = 'explorer';
|
||
static const rsyncRouteName = 'rsync';
|
||
static int _open = 0, _openRsync = 0;
|
||
|
||
/// 파일 탐색기로: 이미 열려 있으면 그 화면으로 돌아간다 (보던 폴더 그대로)
|
||
static Future<void> open(NavigatorState nav, {required AppController c}) => _show(nav, c, rsync: false);
|
||
|
||
/// Rsync 화면으로 (이미 열려 있으면 그 화면으로)
|
||
static Future<void> openRsync(NavigatorState nav, {required AppController c}) => _show(nav, c, rsync: true);
|
||
|
||
/// 162: 이 앱의 파일 탐색기로 그 폴더를 연다 (이미 열려 있으면 그 화면에서 그 폴더로). 끝난 작업의 [열기] 등
|
||
static Future<void> openAt(NavigatorState nav, {required AppController c, required String dir}) {
|
||
goToRequest.value = dir;
|
||
return _show(nav, c, rsync: false);
|
||
}
|
||
|
||
/// 열려 있는 (또는 곧 열릴) 탐색기가 갈 폴더 (가면 비운다)
|
||
static final goToRequest = ValueNotifier<String?>(null);
|
||
|
||
static Future<void> _show(NavigatorState nav, AppController c, {required bool rsync}) async {
|
||
final name = rsync ? rsyncRouteName : routeName;
|
||
if ((rsync ? _openRsync : _open) > 0) {
|
||
var found = false;
|
||
nav.popUntil((r) {
|
||
if (r.settings.name == name) found = true;
|
||
return found || r.isFirst;
|
||
});
|
||
if (found) return;
|
||
}
|
||
await nav.push(MaterialPageRoute<void>(
|
||
settings: RouteSettings(name: name),
|
||
builder: (_) => ExplorerPage(c: c, rsync: rsync),
|
||
));
|
||
}
|
||
|
||
@override
|
||
State<ExplorerPage> createState() => _ExplorerPageState();
|
||
}
|
||
|
||
class _ExplorerPageState extends State<ExplorerPage> implements ExplorerHost, ExplorerPaneParts, ExplorerCommands {
|
||
AppController get c => widget.c;
|
||
|
||
/// 탐색기의 상태 · 화면 밖의 일 (E5d-1) - 화면은 부르기만. 화면에 부탁할 것은 [ExplorerHost] 로 이 상태가 해 준다
|
||
late final _ctl = ExplorerController(c, rsync: widget.rsync, host: this)..addListener(_onPane);
|
||
|
||
@override
|
||
bool get alive => mounted;
|
||
@override
|
||
void snack(String text) => _snack(text);
|
||
@override
|
||
void reveal(ExplorerPane pane, String path) => _reveal(pane, path);
|
||
@override
|
||
void afterFrame(VoidCallback f) => WidgetsBinding.instance.addPostFrameCallback((_) => f());
|
||
|
||
Future<void> _probeDrive(String path) => _ctl.drives.check(path, ExplorerPage.probeDrive);
|
||
|
||
/// 연결 안 된 드라이브를 누르면 다시 연결 (다시 읽기) - 되면 연다
|
||
Future<void> _reconnect(ExplorerPane pane, String path) async {
|
||
await _probeDrive(path);
|
||
if (!mounted) return;
|
||
if (_ctl.drives.ok[path] == true) {
|
||
pane.cache.remove(path);
|
||
await _ctl.goTo(pane, path);
|
||
} else {
|
||
_snack(_ctl.drives.removable.contains(path)
|
||
? trf('{0} 이 비어 있습니다 (카드 · USB · 디스크를 넣은 뒤 다시 누르세요).', [path])
|
||
: trf('{0} 에 연결할 수 없습니다. 네트워크 · NAS 가 켜져 있는지 확인하세요.', [path]));
|
||
}
|
||
}
|
||
|
||
/// 화면 단축키 (F5 · Delete …) 를 받는 곳. 76: 빠른 필터 칸을 닫으면 여기로 돌려준다
|
||
final _keys = FocusNode(debugLabel: 'explorer');
|
||
|
||
@override
|
||
void initState() {
|
||
// 관리자 지시 (10/21): 보관함에 넣은 파일의 썸네일 기억을 잊게 (static 함수라 여러 번 넣어도 하나)
|
||
thumbMemoryForgetters
|
||
..add(VideoThumbs.forget)
|
||
..add(BigVideoThumbs.forget);
|
||
super.initState();
|
||
if (widget.rsync) {
|
||
ExplorerPage._openRsync++;
|
||
} else {
|
||
ExplorerPage._open++;
|
||
}
|
||
for (final pane in _ctl.panes) {
|
||
pane.addListener(_onPane);
|
||
}
|
||
_resumeReload = AppLifecycleListener(onResume: () => unawaited(_ctl.reloadOpen(localOnly: true)));
|
||
_hiddenSeen = c.settings.explorerHiddenItems;
|
||
_kindSeen = c.settings.explorerSortByKind;
|
||
c.addListener(_onSettings);
|
||
_init();
|
||
}
|
||
|
||
/// 232: 앱으로 돌아오면 (다른 앱 · 탐색기에서 파일을 바꾸고 오면) 보이는 폴더를 다시 읽는다
|
||
late final AppLifecycleListener _resumeReload;
|
||
@override
|
||
void dispose() {
|
||
c.removeListener(_onSettings);
|
||
_keys.dispose();
|
||
if (widget.rsync) {
|
||
ExplorerPage._openRsync--;
|
||
} else {
|
||
ExplorerPage._open--;
|
||
ExplorerPage.goToRequest.removeListener(_onGoToRequest);
|
||
}
|
||
_transfers.dispose();
|
||
_jobs.dispose();
|
||
_sel.dispose();
|
||
_ctl.dispose();
|
||
if (_allFiles != null) _life.dispose();
|
||
_resumeReload.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _onPane() {
|
||
if (mounted) setState(() {});
|
||
}
|
||
|
||
Future<void> _init() async {
|
||
_ctl.local = ExplorerPage.debugVolumes?.call() ?? await localVolumes();
|
||
if (_ctl.local.isEmpty) _ctl.local = [(Platform.isWindows ? r'C:\' : '/', Platform.isWindows ? 'C:' : '/')];
|
||
// X-plore 비교 e: 보관 일이 지난 휴지통 항목을 지운다 (뒤에서)
|
||
if (_deletes.appTrash case final t?) unawaited(t.purgeOlderThan(c.settings.androidTrashDays).catchError((_) => 0));
|
||
if (Platform.isAndroid) unawaited(_checkAccess());
|
||
final saved = widget.rsync ? c.settings.rsyncPaths : c.settings.explorerPaths;
|
||
// 창마다 따로 읽는다: 한 창이 WebDAV 서버를 기다려도 다른 창 (이 기기 파일) 은 바로 보이게
|
||
// 165: 지난 폴더가 느린 네트워크 드라이브여도 화면이 멈추지 않게 (뒤에서 확인, 오래 걸리면 그냥 열어 본다 - 못 읽으면 그 창에 이유)
|
||
final reach = await Future.wait(
|
||
[for (var i = 0; i < 2; i++) i < saved.length ? reachableAsync(saved[i]) : Future.value(false)]);
|
||
// U1 (점검 10/22): 저장된 경로가 없는 창은 창마다 다른 저장 장치 (오른쪽 = 두 번째, 없으면 첫째) - 두 창이 같은 목록을
|
||
// 두 번 보이면 [복사] 의 "다른 창으로" 가 같은 곳이 되어 헷갈렸다. 저장된 경로가 있으면 그대로
|
||
final wants = [
|
||
for (var i = 0; i < 2; i++) reach[i] ? saved[i] : _ctl.local[i < _ctl.local.length ? i : 0].$1,
|
||
];
|
||
// 162: "이 폴더를 탐색기로" 로 열렸으면 첫 창은 그 폴더 (지난 폴더 대신)
|
||
final asked = widget.rsync ? null : ExplorerPage.goToRequest.value;
|
||
if (asked != null) {
|
||
ExplorerPage.goToRequest.value = null;
|
||
wants[0] = asked;
|
||
}
|
||
for (var i = 0; i < 2; i++) {
|
||
_ctl.panes[i]
|
||
..root = _ctl.volumeOf(wants[i])
|
||
..current = wants[i]
|
||
// 48 보충 (관리자): 처음 열면 아무것도 고르지 않은 상태 - 열자마자 폴더 통째가 복사 · 이동 대상이 되지 않게
|
||
..focused = null;
|
||
}
|
||
if (!mounted) return;
|
||
setState(() => _ctl.ready = true);
|
||
// (이미 열린 화면에 들어온 요청은 그 화면에서)
|
||
if (!widget.rsync) ExplorerPage.goToRequest.addListener(_onGoToRequest);
|
||
// 52: 다른 화면에 다녀와도 아직 도는 복사 · 이동의 진행 막대를 다시 보이고, 끝나면 그 폴더들을 새로 읽는다
|
||
final center = CopyCenter.peekOf(c);
|
||
if (center != null) {
|
||
for (final job in center.jobs.values.where((j) => !j.finished)) {
|
||
_jobs.adopt(job);
|
||
}
|
||
}
|
||
// 165: 드라이브 종류 · 연결은 드라이브마다 따로 뒤에서 (느린 드라이브가 있어도 다른 드라이브 · 창은 바로)
|
||
if (Platform.isWindows) {
|
||
_ctl.drives.classify([for (final (path, _) in _ctl.local) path]);
|
||
for (final (path, _) in _ctl.local) {
|
||
unawaited(_probeDrive(path));
|
||
}
|
||
}
|
||
for (var i = 0; i < 2; i++) {
|
||
final pane = _ctl.panes[i];
|
||
unawaited(_ctl.goTo(pane, wants[i], remember: false).then((_) => _reveal(pane, pane.current)));
|
||
}
|
||
}
|
||
|
||
// ───────── 권한 (Android) ─────────
|
||
|
||
/// "모든 파일에 대한 접근" 권한 (없으면 폴더만 보이고 파일은 안 보인다). null = 아직 모름
|
||
bool? _allFiles;
|
||
|
||
/// 듀얼 앱 (복제한 앱) 인지: 저장소가 /storage/emulated/0 이 아님
|
||
bool _dualApp = false;
|
||
|
||
late final AppLifecycleListener _life = AppLifecycleListener(onResume: () {
|
||
if (Platform.isAndroid && _allFiles == false) unawaited(_checkAccess(reload: true));
|
||
});
|
||
|
||
Future<void> _checkAccess({bool reload = false}) async {
|
||
_life; // 다시 돌아오면 (권한 화면에서 허용하고 오면) 다시 확인
|
||
final ok = await AndroidAccess.hasAllFiles();
|
||
var dual = false;
|
||
try {
|
||
final root = await AndroidAccess.storageRoot();
|
||
dual = root.contains('/emulated/') && !root.endsWith('/emulated/0');
|
||
} catch (_) {}
|
||
if (!mounted) return;
|
||
final changed = ok != _allFiles;
|
||
setState(() {
|
||
_allFiles = ok;
|
||
_dualApp = dual;
|
||
});
|
||
if (reload && changed && ok) {
|
||
for (final pane in _ctl.panes) {
|
||
pane.cache.clear();
|
||
for (final d in pane.expanded.toList()) {
|
||
await _ctl.load(pane, d, force: true);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 권한이 없을 때 목록 위 안내 (이유 · 할 일 · 허용 화면 열기 · 다시 확인)
|
||
/// X-plore 비교 5-2: 시스템 폴더를 보는 동안 늘 주황 띠
|
||
Widget? _shizukuNotice(ExplorerPane pane) {
|
||
if (!isShizuku(pane.current)) return null;
|
||
final root = isRootPath(pane.current);
|
||
return _notice(
|
||
icon: root ? Icons.security : Icons.admin_panel_settings_outlined,
|
||
title: root ? tr('루트 (su) - 읽기 전용') : tr('시스템 폴더 (Shizuku) - 읽기 전용'),
|
||
body: root
|
||
? tr('목록 · 보기 · 꺼내기 (복사) 만 됩니다. 시스템 · 다른 앱의 개인 자료가 있으니 꺼낸 파일을 조심해서 다루세요')
|
||
: tr('목록 · 보기 · 꺼내기 (복사) 만 됩니다. 다른 앱의 데이터가 있을 수 있으니 꺼낸 파일을 조심해서 다루세요'),
|
||
actions: const [],
|
||
color: JjColors.warning,
|
||
);
|
||
}
|
||
|
||
/// X-plore 비교 5-2: 루트 탭 - 이번 실행에서 처음이면 경고, su 가 있고 허락을 받았을 때만 "/" 로 (관리자 3a 결정)
|
||
Future<void> _openRoot(ExplorerPane pane) async {
|
||
if (!ExplorerPage.rootWarnedThisRun) {
|
||
final ok = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
scrollable: true,
|
||
icon: Icon(Icons.security, color: JjColors.warning),
|
||
title: Text(tr('루트 폴더 보기 (읽기 전용)')),
|
||
content: Text(tr('루트 (su) 권한으로 시스템 · 다른 앱의 폴더 전체를 봅니다. 다른 앱의 개인 자료 (대화 · 로그인 정보 · 비밀번호 등) 도 '
|
||
'보일 수 있습니다. 이 앱은 읽기만 하고 (쓰기 · 지우기 · 이름 바꾸기 없음), 꺼낸 파일은 고른 곳에만 둡니다.')),
|
||
actions: [
|
||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
|
||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('알고 봅니다'))),
|
||
],
|
||
),
|
||
);
|
||
if (ok != true || !mounted) return;
|
||
ExplorerPage.rootWarnedThisRun = true;
|
||
}
|
||
try {
|
||
final st = await ShizukuRemote.rootBackend.status();
|
||
if (!st.running) {
|
||
_snack(tr('이 기기에는 su 가 없습니다 - 루팅한 기기에서만 쓸 수 있습니다'));
|
||
return;
|
||
}
|
||
if (!st.granted && !await ShizukuRemote.rootBackend.request()) {
|
||
_snack(tr('루트 허락이 없어 볼 수 없습니다 (su 앱에서 이 앱을 허락하세요)'));
|
||
return;
|
||
}
|
||
} catch (e) {
|
||
_snack(trf('루트에 연결하지 못했습니다: {0}', [e]));
|
||
return;
|
||
}
|
||
if (!mounted) return;
|
||
setState(() => _ctl.active = _ctl.panes.indexOf(pane));
|
||
await _ctl.goTo(pane, rootHome);
|
||
}
|
||
|
||
/// X-plore 비교 5-2: 시스템 폴더 탭 - 이번 실행에서 처음이면 경고, Shizuku 가 켜져 있고 허락을 받았을 때만 Android 폴더로
|
||
Future<void> _openShizuku(ExplorerPane pane) async {
|
||
if (!ExplorerPage.shizukuWarnedThisRun) {
|
||
final ok = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
scrollable: true,
|
||
icon: Icon(Icons.admin_panel_settings_outlined, color: JjColors.warning),
|
||
title: Text(tr('시스템 폴더 보기 (읽기 전용)')),
|
||
content: Text(tr('Shizuku 의 권한으로 Android/data · obb 처럼 막힌 폴더를 봅니다. 다른 앱의 데이터 (대화 · 로그인 정보 등) 가 '
|
||
'있을 수 있습니다. 이 앱은 읽기만 하고 (쓰기 · 지우기 · 이름 바꾸기 없음), 꺼낸 파일은 고른 곳에만 둡니다.')),
|
||
actions: [
|
||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
|
||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('알고 봅니다'))),
|
||
],
|
||
),
|
||
);
|
||
if (ok != true || !mounted) return;
|
||
ExplorerPage.shizukuWarnedThisRun = true;
|
||
}
|
||
try {
|
||
final st = await ShizukuRemote.backend.status();
|
||
if (!st.running) {
|
||
_snack(tr('Shizuku 가 켜져 있지 않습니다 - Shizuku 앱을 설치하고 (무선 디버깅 · adb 로) 시작한 뒤 다시 누르세요'));
|
||
return;
|
||
}
|
||
if (!st.granted && !await ShizukuRemote.backend.request()) {
|
||
_snack(tr('Shizuku 허락이 없어 볼 수 없습니다'));
|
||
return;
|
||
}
|
||
} catch (e) {
|
||
_snack(trf('Shizuku 에 연결하지 못했습니다: {0}', [e]));
|
||
return;
|
||
}
|
||
if (!mounted) return;
|
||
setState(() => _ctl.active = _ctl.panes.indexOf(pane));
|
||
await _ctl.goTo(pane, shizukuHome(_ctl.local.first.$1));
|
||
}
|
||
|
||
Widget? _accessNotice(ExplorerPane pane) {
|
||
if (!Platform.isAndroid || _allFiles != false || isDav(pane.root)) return null;
|
||
return _notice(
|
||
icon: Icons.lock_outline,
|
||
title: tr('파일이 보이지 않습니다: "모든 파일에 대한 접근" 권한이 없습니다'),
|
||
body: _dualApp
|
||
? tr('지금은 듀얼 앱 (복제한 앱) 입니다. Android 가 듀얼 앱에는 이 권한을 주지 않을 수 있습니다. '
|
||
'허용 화면에서 켤 수 없으면 배지 없는 원래 JJ_MKVMaker 아이콘으로 여세요.')
|
||
: tr('폴더는 보여도 그 안의 동영상 · 파일은 이 권한이 있어야 보입니다. 허용 화면에서 JJ_MKVMaker 를 켜고 돌아오세요.'),
|
||
actions: [
|
||
FilledButton.tonal(onPressed: AndroidAccess.request, child: Text(tr('권한 허용 화면 열기'))),
|
||
TextButton(onPressed: () => _checkAccess(reload: true), child: Text(tr('허용했으면 다시 확인'))),
|
||
],
|
||
);
|
||
}
|
||
|
||
/// 목록 위 안내 상자
|
||
Widget _notice(
|
||
{required IconData icon,
|
||
required String title,
|
||
required String body,
|
||
required List<Widget> actions,
|
||
Color? color}) =>
|
||
_noticeBox(icon, title, body, actions, color ?? JjColors.alert);
|
||
|
||
Widget _noticeBox(IconData icon, String title, String body, List<Widget> actions, Color color) =>
|
||
Container(
|
||
width: double.infinity,
|
||
margin: const EdgeInsets.fromLTRB(6, 6, 6, 0),
|
||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 6),
|
||
decoration: BoxDecoration(
|
||
color: color.withValues(alpha: 0.12),
|
||
borderRadius: BorderRadius.circular(8),
|
||
border: Border.all(color: color.withValues(alpha: 0.5)),
|
||
),
|
||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
|
||
Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Icon(icon, size: 18, color: color),
|
||
const SizedBox(width: 8),
|
||
Expanded(child: Text(title, style: const TextStyle(fontWeight: FontWeight.w600))),
|
||
]),
|
||
const SizedBox(height: 4),
|
||
Text(body, style: TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||
Wrap(spacing: 8, children: actions),
|
||
]),
|
||
);
|
||
|
||
// ───────── 만들다 만 파일 (71) ─────────
|
||
|
||
/// 복사 · 동기화가 중간에 끊겨 남은 임시 파일 (.jjpart · .jjsync). 지금 쓰는 중인 것 (2분 안에 바뀜) 은 빼고
|
||
List<FileEntry> _partials(ExplorerPane pane, String dir) {
|
||
final old = DateTime.now().subtract(const Duration(minutes: 2));
|
||
return [
|
||
for (final e in pane.cache[dir] ?? const <FileEntry>[])
|
||
if (!e.isDir && isPartialFile(e.name) && e.modified.isBefore(old)) e,
|
||
];
|
||
}
|
||
|
||
Widget? _partialNotice(ExplorerPane pane, String dir) {
|
||
final list = _partials(pane, dir);
|
||
if (list.isEmpty) return null;
|
||
return _notice(
|
||
icon: Icons.broken_image_outlined,
|
||
color: JjColors.warning,
|
||
title: trf('만들다 만 파일 {0}개', [list.length]),
|
||
body: tr('복사 · 동기화가 중간에 끊겨 남은 임시 파일입니다 (.jjpart). 완성된 파일이 아니므로 지워도 됩니다. '
|
||
'같은 파일을 다시 복사하면 새로 만듭니다.'),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () async {
|
||
final ok = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
scrollable: true,
|
||
title: Text(trf('만들다 만 파일 {0}개를 지울까요?', [list.length])),
|
||
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
for (final e in list.take(50)) Text(e.name, style: const TextStyle(fontSize: 12)),
|
||
if (list.length > 50) Text(trf('… 외 {0}개', [list.length - 50])),
|
||
]),
|
||
actions: [
|
||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
|
||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('지우기'))),
|
||
],
|
||
),
|
||
);
|
||
if (ok != true) return;
|
||
final failed = <String>[];
|
||
for (final e in list) {
|
||
try {
|
||
await vDelete(e.path);
|
||
} catch (_) {
|
||
failed.add(e.name);
|
||
}
|
||
}
|
||
// 같은 폴더를 보는 다른 창도 새로
|
||
for (final q in _ctl.panes) {
|
||
if (q == pane || q.cache.containsKey(dir)) await _ctl.load(q, dir, force: true);
|
||
}
|
||
if (failed.isNotEmpty) _snack(trf('지우지 못한 파일: {0}', [failed.join(', ')]));
|
||
},
|
||
child: Text(tr('지우기')),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
// ───────── 읽을 수 없을 때 (12) ─────────
|
||
|
||
/// 오류 → (무엇이 문제인지, 무엇을 하면 되는지)
|
||
Widget? _errorNotice(ExplorerPane pane, String dir) {
|
||
final err = pane.errors[dir] ?? pane.errors[pane.root];
|
||
if (err == null) return null;
|
||
final where = pane.errors[dir] != null ? dir : pane.root;
|
||
final dav = isDav(where);
|
||
final server = isWebDav(where) ? DavRegistry.server(DavPath.parse(where).server) : null;
|
||
final sftp = isSftp(where) ? SftpRegistry.server(DavPath.parse(where).server) : null;
|
||
final smb = isSmb(where) ? SmbRegistry.server(DavPath.parse(where).server) : null;
|
||
final ftp = isFtp(where) ? FtpRegistry.server(DavPath.parse(where).server) : null;
|
||
// l: 처음 보는 FTPS 자체 서명 인증서 - 지문을 보이고 [지문 확인하고 연결] (바뀐 인증서는 막고 서버 설정으로)
|
||
final cert = pane.certErrors[where] ?? pane.certErrors[pane.root];
|
||
if (ftp != null && cert != null) {
|
||
return _notice(
|
||
icon: cert.changed ? Icons.gpp_bad_outlined : Icons.fingerprint,
|
||
title: '${_ctl.displayPath(where)}: ${cert.changed ? tr('서버 인증서가 바뀌었습니다') : tr('처음 보는 서버 인증서입니다')}',
|
||
body: cert.message,
|
||
actions: [
|
||
if (!cert.changed)
|
||
FilledButton.tonal(
|
||
onPressed: () async {
|
||
if (!await askTrustFtpCert(context, ftp.label, cert)) return;
|
||
await trustFtpCert(c, ftp.id, cert);
|
||
pane.certErrors.remove(where);
|
||
pane.errors.remove(where);
|
||
pane.changed();
|
||
await _ctl.load(pane, where, force: true);
|
||
},
|
||
child: Text(tr('지문 확인하고 연결')),
|
||
),
|
||
TextButton(onPressed: () => _servers.editFtp(context, pane, pane.root), child: Text(tr('서버 설정 고치기'))),
|
||
],
|
||
);
|
||
}
|
||
// 88: 처음 보는 SFTP 서버 키 - 지문을 보이고 [지문 확인하고 연결] (바뀐 키는 막고 서버 설정으로)
|
||
final hostKey = pane.hostKeyErrors[where] ?? pane.hostKeyErrors[pane.root];
|
||
if (sftp != null && hostKey != null) {
|
||
return _notice(
|
||
icon: hostKey.changed ? Icons.gpp_bad_outlined : Icons.fingerprint,
|
||
title: '${_ctl.displayPath(where)}: ${hostKey.changed ? tr('서버의 키가 바뀌었습니다') : tr('처음 연결하는 서버입니다')}',
|
||
body: hostKey.message,
|
||
actions: [
|
||
if (!hostKey.changed)
|
||
FilledButton.tonal(
|
||
onPressed: () async {
|
||
if (!await askTrustHostKey(context, sftp.label, hostKey)) return;
|
||
await trustSftpHostKey(c, sftp.id, hostKey);
|
||
pane.hostKeyErrors.remove(where);
|
||
pane.errors.remove(where);
|
||
pane.changed();
|
||
await _ctl.load(pane, where, force: true);
|
||
},
|
||
child: Text(tr('지문 확인하고 연결')),
|
||
),
|
||
TextButton(onPressed: () => _servers.editSftp(context, pane, pane.root), child: Text(tr('서버 설정 고치기'))),
|
||
],
|
||
);
|
||
}
|
||
final (title, body) = explainFileError(err,
|
||
dav: dav,
|
||
noPassword: (server != null && server.password.isEmpty) ||
|
||
(sftp != null && sftp.password.isEmpty && sftp.privateKey.isEmpty) ||
|
||
(smb != null && smb.user.isNotEmpty && smb.password.isEmpty) ||
|
||
(ftp != null && ftp.user.isNotEmpty && ftp.password.isEmpty));
|
||
final locked = isLockedError(err);
|
||
// 50: 막힌 폴더는 다시 해도 같으니 [다시 시도] 없이 [위 폴더로]
|
||
if (err == androidRestrictedError) {
|
||
return _notice(
|
||
icon: Icons.lock_outline,
|
||
title: '${_ctl.displayPath(where)}: $title',
|
||
body: body,
|
||
actions: [
|
||
FilledButton.tonal(
|
||
onPressed: () {
|
||
pane.errors.remove(where);
|
||
_ctl.goTo(pane, vDirname(where));
|
||
},
|
||
child: Text(tr('위 폴더로')),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
return _notice(
|
||
icon: dav ? Icons.cloud_off_outlined : Icons.error_outline,
|
||
title: '${_ctl.displayPath(where)}: $title',
|
||
body: body,
|
||
actions: [
|
||
FilledButton.tonal(
|
||
onPressed: () async {
|
||
// 133: 마스터를 취소해 막혔으면 여기서 다시 묻는다
|
||
if (locked && !await SecretGate.pass(force: true)) return;
|
||
pane.errors.remove(where);
|
||
pane.changed();
|
||
await _ctl.load(pane, where, force: true);
|
||
for (final d in pane.expanded.toList()) {
|
||
if (isSameOrInside(d, where) && d != where) await _ctl.load(pane, d, force: true);
|
||
}
|
||
},
|
||
child: Text(locked ? tr('마스터 비밀번호 넣기') : tr('다시 시도')),
|
||
),
|
||
// 시스템 폴더 (Shizuku) 는 고칠 서버 설정이 없다
|
||
if (dav && !locked && !isShizuku(where))
|
||
TextButton(
|
||
onPressed: () => sftp != null
|
||
? _servers.editSftp(context, pane, pane.root)
|
||
: smb != null
|
||
? _servers.editSmb(context, pane, pane.root)
|
||
: ftp != null
|
||
? _servers.editFtp(context, pane, pane.root)
|
||
: _servers.editDav(context, pane, pane.root),
|
||
child: Text(tr('서버 설정 고치기'))),
|
||
],
|
||
);
|
||
}
|
||
|
||
/// 화면에 보일 경로 (5 · 14): Android 는 "SD 카드 › JJ_sdtest › jj_mkv", WebDAV 는 "☁ 서버 › 폴더", Windows 는 그대로
|
||
/// 그 항목이 목록 위쪽 1/4 쯤에 보이게 스크롤 (깊은 폴더로 가면 위쪽 폴더들의 다른 항목에 밀려 안 보이므로)
|
||
void _reveal(ExplorerPane pane, String path) => WidgetsBinding.instance.addPostFrameCallback((_) => _revealNow(pane, path));
|
||
|
||
void _revealNow(ExplorerPane pane, String path) {
|
||
if (!mounted || !pane.scroll.hasClients) return;
|
||
// 화면의 트리와 같은 줄로 센다 (폴더 + 파일 목록 배치 · Rsync 화면은 폴더만)
|
||
final i = _ctl.rows(pane, foldersOnly: _split || widget.rsync).indexWhere((r) => r.kind == 0 && samePath(r.entry.path, path));
|
||
if (i < 0) return;
|
||
final pos = pane.scroll.position;
|
||
// 180: 끝 (maxScrollExtent) 으로 자르지 않는다 - 줄이 막 늘어난 때는 끝이 아직 앞의 것이라 보던 폴더로 못 간다.
|
||
// 넘치면 다음 배치 때 실제 끝으로 맞춰진다
|
||
pane.scroll.jumpTo((i * _rowH() - pos.viewportDimension / 4).clamp(0.0, double.infinity));
|
||
}
|
||
|
||
/// 28 확인 (10/20): 창의 목록이 화면에 새로 붙으면 (좁은 Rsync 화면의 [오른쪽] 탭을 처음 볼 때 · 폭이 바뀌어 두 창이 될 때)
|
||
/// 그 창의 지금 폴더를 보이게 한다. 켤 때 그려지지 않은 창은 [_ctl.goTo] 의 스크롤을 건너뛰어, 나중에 보면 맨 위 (저장 장치) 에 있었다
|
||
void _revealNewViews() => WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (!mounted) return;
|
||
for (final pane in _ctl.panes) {
|
||
final on = pane.scroll.hasClients;
|
||
if (on && !pane.onScreen && pane.current.isNotEmpty) _revealNow(pane, pane.current);
|
||
pane.onScreen = on;
|
||
}
|
||
});
|
||
|
||
/// X-plore 비교 40: 목록 글자 크기 (설정 %) · 줄 간격을 넣은 줄 높이
|
||
double get _listTextScale => c.settings.explorerTextScale / 100;
|
||
double _rowH() => _ctl.look.rowHeightOf(context, textScale: _listTextScale, spacing: rowSpacingFactor(c.settings.explorerRowSpacing));
|
||
|
||
/// 왼쪽 폴더 트리 + 오른쪽 파일 목록 배치
|
||
bool get _split => !widget.rsync && c.settings.explorerLayout == 'split' && !_splitTight;
|
||
|
||
/// 관리자 지시 10/20 (28 검토): 글자가 커서 (기기 글자 × 목록 글자 크기) 좁은 화면에 트리 · 목록 두 창이 다 들어가지 않으면
|
||
/// 그동안만 한 창 (트리 + 위 경로 막대) 으로 보인다 - 고른 배치 (설정) 는 그대로, 화면이 넓어지거나 글자가 작아지면 다시 두 창. [_body] 가 정한다
|
||
bool _splitTight = false;
|
||
|
||
/// 관리자 지시 10/20: 좁은 화면 (폭 < [narrowSplitWidth]) 의 "폴더 + 파일 목록" 좌우 배치 - 트리 창에 최소 폭을 두고
|
||
/// ✔ 칸 · 크기 줄을 숨긴다 (412 폭에서 트리가 77px 이라 폴더 이름이 한 글자도 안 보였다). [_body] 가 정한다
|
||
bool _narrowSplit = false;
|
||
static const narrowSplitWidth = 600.0;
|
||
|
||
/// 좌우 두 창 (Rsync 화면은 늘)
|
||
bool get _dual =>
|
||
widget.rsync ||
|
||
c.settings.explorerLayout == 'dual' ||
|
||
// 자동: 넓으면 두 창, 좁은 화면 (폰 세로) 은 한 창 - 같은 목록이 반씩 잘려 두 번 보이지 않게
|
||
(c.settings.explorerLayout == 'auto' && !isCompact(context));
|
||
|
||
|
||
/// 트리 줄. [foldersOnly]: 폴더만 ("폴더 + 파일 목록" 배치의 왼쪽)
|
||
/// X-plore 비교 c: 트리 맨 위에 저장 장치 · 서버 · 즐겨찾기 (X-plore 스타일 · 설정)
|
||
/// X-plore 비교 ⚖: X-plore 스타일에서 표시 동그라미 늘 보이기 (설정)
|
||
bool get _alwaysMarks => !widget.rsync && _ctl.look == ExplorerStyle.xplore && c.settings.explorerAlwaysMarks;
|
||
|
||
/// X-plore 비교 ⚖: X-plore 스타일 탐색기 안에서 밀기 = 창 바꾸기 (설정)
|
||
bool get _swipePanes => !widget.rsync && _ctl.look == ExplorerStyle.xplore && c.settings.explorerSwipePanes;
|
||
|
||
/// 밀어서 창 바꾸기: 두 창이면 다른 창을 활성으로, 한 창이면 숨은 창과 바꿔 보인다
|
||
bool _switchPane(int dir) {
|
||
if (!_swipePanes) return false;
|
||
setState(() {
|
||
if (_dual || _split) {
|
||
_ctl.active = 1 - _ctl.active;
|
||
} else {
|
||
final t = _ctl.panes[0];
|
||
_ctl.panes[0] = _ctl.panes[1];
|
||
_ctl.panes[1] = t;
|
||
_ctl.active = 0;
|
||
}
|
||
});
|
||
_ctl.remember(_ctl.pane, _ctl.pane.current);
|
||
_snack(trf('창 바꿈: {0}', [_ctl.displayPath(_ctl.pane.current)]));
|
||
return true;
|
||
}
|
||
|
||
/// 설정 화면에서 숨긴 목록을 바꾸면 (풀기) 바로 목록에 보이게
|
||
List<String>? _hiddenSeen;
|
||
/// 종류별 기본 정렬 (5-1 ①) 을 켜고 끈 것 - 바뀌면 읽어 둔 목록을 다시 줄 세운다
|
||
bool? _kindSeen;
|
||
|
||
void _onSettings() {
|
||
if (!mounted) return;
|
||
if (_kindSeen != null && _kindSeen != c.settings.explorerSortByKind) {
|
||
for (final pane in _ctl.panes) {
|
||
for (final k in pane.cache.keys.toList()) {
|
||
pane.cache[k] = _ctl.sorted(pane.cache[k]!, k);
|
||
}
|
||
pane.changed();
|
||
}
|
||
}
|
||
_kindSeen = c.settings.explorerSortByKind;
|
||
final now = c.settings.explorerHiddenItems;
|
||
if (identical(now, _hiddenSeen)) return;
|
||
_hiddenSeen = now;
|
||
setState(() {});
|
||
for (final q in _ctl.panes) {
|
||
q.changed();
|
||
}
|
||
}
|
||
|
||
// ───────── 창 보기 (E5d-3: ExplorerPaneView · ExplorerListView 가 부르는 것) ─────────
|
||
|
||
@override
|
||
double get rowHeight => _rowH();
|
||
@override
|
||
double get listTextScale => _listTextScale;
|
||
@override
|
||
bool get narrowSplit => _narrowSplit;
|
||
@override
|
||
bool get alwaysMarks => _alwaysMarks;
|
||
@override
|
||
Widget frame(int i, List<Widget> children) => _frame(i, children);
|
||
@override
|
||
Widget header(ExplorerPane pane) => ExplorerPaneHeader(
|
||
ctl: _ctl,
|
||
servers: _servers,
|
||
pane: pane,
|
||
android: ExplorerPage.debugAndroidPaths ?? Platform.isAndroid,
|
||
onBookmarks: () => openBookmarks(pane),
|
||
onOpenFilter: () => _openFilter(pane),
|
||
onCloseFilter: () => _closeFilter(pane),
|
||
onOpenRoot: () => _openRoot(pane),
|
||
onOpenShizuku: () => _openShizuku(pane),
|
||
onReconnect: (path) => _reconnect(pane, path),
|
||
);
|
||
@override
|
||
List<Widget?> notices(ExplorerPane pane, String dir) =>
|
||
[_shizukuNotice(pane), _accessNotice(pane), _errorNotice(pane, dir), _partialNotice(pane, dir)];
|
||
@override
|
||
Widget marksBar(ExplorerPane pane, {String? listDir}) => ExplorerMarksBar(sel: _sel, pane: pane, listDir: listDir);
|
||
@override
|
||
void onMenu(ExplorerPane pane, FileEntry e, Offset at) => _itemMenu.show(context, pane, e, at);
|
||
@override
|
||
List<String> dragPaths(ExplorerPane pane, FileEntry e) => _dragPaths(pane, e);
|
||
@override
|
||
Future<void> onDrop(DragLoad load, int to, String dest) => _onDrop(load, to, dest);
|
||
@override
|
||
void closeFilter(ExplorerPane pane) => _closeFilter(pane);
|
||
@override
|
||
void filterChanged() => setState(() {});
|
||
|
||
/// 기능 버튼 → 할 일 (E5d-4)
|
||
late final _buttons = ExplorerButtons(_ctl, _sel, this, guard: _guard, transfers: _transfers, deletes: _deletes, rsync: _rsyncFlow, opener: _opener);
|
||
|
||
/// 읽기 전용인 곳 막기 · 진행 작업 · 복사 · 이동 · rsync · 지우기 (E5d-5)
|
||
late final _guard = ExplorerGuard(_ctl);
|
||
late final _jobs = ExplorerJobs(_ctl)..addListener(_onPane);
|
||
late final _transfers = ExplorerTransfers(_ctl, _guard, _jobs)..addListener(_onPane);
|
||
late final _rsyncFlow = ExplorerRsyncFlow(_ctl, _jobs, _transfers);
|
||
late final _deletes = ExplorerDeleteFlow(_ctl, _sel, _guard, androidTrash: () => ExplorerPage.debugAndroidTrash ?? Platform.isAndroid);
|
||
|
||
@override
|
||
String? get otherDir => _other?.current;
|
||
|
||
/// 시험: [휴지통] 열기 (E5e 에서 지움)
|
||
@visibleForTesting
|
||
Future<void> debugOpenTrash() => _deletes.openTrash(context);
|
||
|
||
/// 줄 메뉴 (E5d-5)
|
||
late final _itemMenu = ExplorerItemMenu(_ctl, _sel, this,
|
||
guard: _guard, transfers: _transfers, deletes: _deletes, opener: _opener, nomedia: () => ExplorerPage.debugNomedia ?? Platform.isAndroid);
|
||
|
||
@override
|
||
bool get dual => _dual;
|
||
|
||
/// 파일 열기 · 보기 · 재생 (E5d-5)
|
||
late final ExplorerOpener _opener = ExplorerOpener(_ctl, _sel, _guard,
|
||
otherDirOf: (pane) => _dual ? _ctl.panes[1 - _ctl.panes.indexOf(pane)].current : null);
|
||
|
||
/// 서버 추가 · 고치기 · 경로 입력 (E5d-3)
|
||
late final _servers = ExplorerServers(_ctl);
|
||
|
||
// ───────── 누르기 · 고르기 (E5d-2: ExplorerSelect) ─────────
|
||
|
||
late final _sel = ExplorerSelect(_ctl, clock: widget.clock, openFile: (path, pane) => _opener.open(context, path, pane: pane))
|
||
..addListener(_onPane);
|
||
|
||
/// ZIP: 환경 설정이 "만화 보기" 면 안의 그림을 바로 보고 (그림이 없으면 목록), 아니면 목록
|
||
/// 87: 저장 공간 분석 → 고른 것을 탐색기에서 그 자리로 (지우기는 탐색기의 지우기로만).
|
||
/// WebDAV 는 직접 시킬 때만 (이 메뉴) 하고, 서버에서 모두 읽어 오래 걸린다고 먼저 알린다
|
||
@override
|
||
Future<void> openStorage(ExplorerPane pane, String dir) async {
|
||
setState(() => _ctl.active = _ctl.panes.indexOf(pane));
|
||
if (isDav(dir)) {
|
||
final ok = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
title: Text(tr('저장 공간 분석')),
|
||
content: Text(tr('WebDAV · SFTP 는 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다. 분석할까요?')),
|
||
actions: [
|
||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
|
||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('분석'))),
|
||
],
|
||
),
|
||
);
|
||
if (ok != true || !mounted) return;
|
||
}
|
||
// X-plore 비교 5-1 ⑤ (관리자 지시 10/21): 분석 화면에서도 이 탐색기의 지우기 (확인 · 휴지통) · 이동 (붙여넣기로)
|
||
final path = await StorageAnalysisPage.open(context, dir, onDelete: (paths) async {
|
||
await _deletes.run(context, pane, paths);
|
||
return [for (final x in paths) if (!await vExists(x)) x];
|
||
}, onCut: (paths) => _transfers.toClipboard(paths, move: true));
|
||
if (path == null || !mounted) return;
|
||
final isDir = await vIsDir(path);
|
||
await _ctl.goTo(pane, isDir ? path : vDirname(path), fresh: true);
|
||
pane.focused = path;
|
||
pane.changed();
|
||
_reveal(pane, path);
|
||
}
|
||
|
||
/// 89: Wi-Fi 공유 화면 (이미 켜져 있으면 그 공유를 보인다). 이 기기의 폴더만 - WebDAV 등 원격은 알림
|
||
@override
|
||
Future<void> openWifiShare(String dir) async {
|
||
final on = WifiShare.instance;
|
||
if (on == null && isDav(dir)) {
|
||
_snack(tr('Wi-Fi 공유는 이 기기의 폴더만 할 수 있습니다'));
|
||
return;
|
||
}
|
||
await WifiSharePage.open(context, c, on?.root ?? dir);
|
||
}
|
||
|
||
/// 89: 탐색기 위의 [Wi-Fi 공유 중] 줄 - 화면을 닫아도 켜져 있음을 늘 보이게 (사용자가 모르게 열려 있지 않게)
|
||
Widget _wifiBar(WifiShare s) {
|
||
final left = s.endsAt?.difference(DateTime.now());
|
||
return Material(
|
||
color: JjColors.panelHigh,
|
||
child: InkWell(
|
||
onTap: () => WifiSharePage.open(context, c, s.root),
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||
child: Row(children: [
|
||
Icon(Icons.wifi_tethering, size: 18, color: JjColors.success),
|
||
const SizedBox(width: 6),
|
||
Expanded(
|
||
child: Text(
|
||
left == null
|
||
? trf('Wi-Fi 공유 중 · 코드 {0} · {1}', [s.code, s.root])
|
||
: trf('Wi-Fi 공유 중 · 코드 {0} · {1}분 남음 · {2}', [s.code, (left.inSeconds / 60).ceil().clamp(0, 9999), s.root]),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: const TextStyle(fontSize: 12)),
|
||
),
|
||
TextButton(onPressed: () => s.stop(), child: Text(tr('끄기'))),
|
||
]),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ───────── 기능 ─────────
|
||
|
||
ExplorerPane? get _other => _dual || _hiddenTarget ? _ctl.panes[1 - _ctl.active] : null;
|
||
|
||
// ───────── X-plore 비교 f: 압축을 트리 안에서 폴더처럼 (ZIP · 7z · RAR, 읽기 전용) ─────────
|
||
|
||
/// X-plore 비교 a: 한 창인데 "숨은 다른 창으로" 복사 · 이동 (위에 갈 곳 줄)
|
||
bool get _hiddenTarget => !widget.rsync && !_dual && !_split && c.settings.explorerOnePaneCopy == 'other';
|
||
|
||
/// 폴더 이름 (저장 장치 맨 위면 그 이름) - 좁은 줄에 경로 대신 (전체 경로는 풍선 도움말)
|
||
String _folderName(String dir) =>
|
||
_ctl.volumes.firstWhere((v) => samePath(v.$1, dir), orElse: () => (dir, vBasename(dir).isEmpty ? _ctl.displayPath(dir) : vBasename(dir))).$2;
|
||
|
||
/// X-plore 비교 a: 한 창일 때 맨 위의 "→ 갈 곳" 줄 - 숨은 창의 폴더 · [지금 폴더로] · [다른 폴더]
|
||
Widget _targetBar() {
|
||
final t = _ctl.panes[1 - _ctl.active];
|
||
return Material(
|
||
color: JjColors.accent.withValues(alpha: 0.10),
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||
child: Row(children: [
|
||
Icon(Icons.arrow_forward, size: 18, color: JjColors.accent),
|
||
const SizedBox(width: 6),
|
||
// 412 캡처 (10/18): "갈 곳:" 은 늘 보이고, 폴더 이름이 길면 가운데를 줄인다 (전체 경로는 길게 눌러 · 마우스를 올려)
|
||
Expanded(
|
||
child: Tooltip(
|
||
message: _ctl.displayPath(t.current),
|
||
triggerMode: TooltipTriggerMode.longPress,
|
||
child: LayoutBuilder(builder: (context, box) {
|
||
const style = TextStyle(fontSize: 12.5);
|
||
final label = trf('갈 곳: {0}', ['']);
|
||
final room = box.maxWidth - textWidth(context, label, style);
|
||
return Text('$label${middleEllipsis(context, _folderName(t.current), style, room)}', maxLines: 1, overflow: TextOverflow.clip, style: style);
|
||
}),
|
||
),
|
||
),
|
||
TextButton(
|
||
style: TextButton.styleFrom(visualDensity: VisualDensity.compact),
|
||
onPressed: () => _ctl.goTo(t, _ctl.pane.current),
|
||
child: Text(tr('지금 폴더로')),
|
||
),
|
||
TextButton(
|
||
style: TextButton.styleFrom(visualDensity: VisualDensity.compact),
|
||
onPressed: () async {
|
||
final d = await pickFolderOrDav(context, tr('복사 · 이동 갈 곳'), t.current);
|
||
if (d != null && mounted) await _ctl.goTo(t, d);
|
||
},
|
||
child: Text(tr('다른 폴더')),
|
||
),
|
||
]),
|
||
),
|
||
);
|
||
}
|
||
|
||
void _snack(String text) {
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text)));
|
||
}
|
||
|
||
@override
|
||
Future<void> addMkv(List<String> paths) async {
|
||
if (paths.any(isDav)) {
|
||
_snack(tr('WebDAV · SFTP 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.'));
|
||
return;
|
||
}
|
||
final videos = <String>[];
|
||
final unreadable = <String>[];
|
||
for (final x in paths) {
|
||
if (vIsDirSync(x)) {
|
||
try {
|
||
videos.addAll((await listEntries(x)).where((e) => !e.isDir && isVideoFile(e.path)).map((e) => e.path));
|
||
} catch (_) {
|
||
unreadable.add(vBasename(x)); // 읽지 못한 폴더는 건너뛰고 알린다
|
||
}
|
||
} else if (isVideoFile(x)) {
|
||
videos.add(x);
|
||
}
|
||
}
|
||
if (unreadable.isNotEmpty) _snack(trf('읽을 수 없어 건너뛴 폴더: {0}', [unreadable.take(3).join(', ')]));
|
||
if (videos.isEmpty) {
|
||
if (unreadable.isEmpty) _snack(tr('추가할 동영상이 없습니다.'));
|
||
return;
|
||
}
|
||
await c.addVideos(videos, allowOutputFolder: true);
|
||
_snack(trf('MKV 목록에 {0}개를 추가했습니다.', [videos.length]));
|
||
}
|
||
|
||
@override
|
||
Future<void> renameItem(ExplorerPane pane, String path) async {
|
||
if (_guard.blockArchive([path])) return;
|
||
final name = await askName(context, tr('이름 변경'), vBasename(path));
|
||
if (name == null || name == vBasename(path)) return;
|
||
try {
|
||
final now = await FileOps.rename(path, name);
|
||
await _ctl.followHidden(moved: [(path, now)]);
|
||
await _ctl.refreshAll([vDirname(path)]);
|
||
for (final x in _ctl.panes) {
|
||
x.sel.rename(path, now);
|
||
}
|
||
pane.focused = now;
|
||
pane.changed();
|
||
} catch (e) {
|
||
_snack(trf('이름을 바꿀 수 없습니다: {0}', [e]));
|
||
}
|
||
}
|
||
|
||
/// 78: 일괄 이름 바꾸기 - 번호는 목록에 보이는 차례대로. 끝나면 [되돌리기]
|
||
@override
|
||
Future<void> batchRename(ExplorerPane pane, List<String> paths) async {
|
||
if (_guard.blockArchive(paths)) return;
|
||
int at(String x) {
|
||
final i = (pane.cache[vDirname(x)] ?? const <FileEntry>[]).indexWhere((e) => samePath(e.path, x));
|
||
return i < 0 ? 1 << 30 : i;
|
||
}
|
||
|
||
final ordered = [...paths]..sort((a, b) {
|
||
final d = vDirname(a).compareTo(vDirname(b));
|
||
return d != 0 ? d : at(a).compareTo(at(b));
|
||
});
|
||
final done = await showBatchRename(context, ordered,
|
||
isDir: vIsDirSync,
|
||
initial: RenameRule.fromJson(c.settings.batchRenameLast),
|
||
onRun: (r) => c.updateSettings((s) => s.batchRenameLast = r.toJson()));
|
||
if (done == null || done.isEmpty || !mounted) return;
|
||
await _afterBatchRename(pane, done);
|
||
if (!mounted) return;
|
||
showNotice(ScaffoldMessenger.of(context),
|
||
text: trf('{0}개의 이름을 바꿨습니다.', [done.length]),
|
||
action: (
|
||
tr('되돌리기'),
|
||
() async {
|
||
try {
|
||
final back = await runBatchRename([for (final (orig, now) in done) (now, vBasename(orig))]);
|
||
if (mounted) await _afterBatchRename(pane, back);
|
||
} on BatchRenameException catch (e) {
|
||
_snack(trf('되돌리지 못했습니다: {0}', [e]));
|
||
}
|
||
}
|
||
));
|
||
}
|
||
|
||
/// 바꾼 뒤: 목록을 다시 읽고, 표시 · 초점을 새 이름으로
|
||
Future<void> _afterBatchRename(ExplorerPane pane, List<(String, String)> moved) async {
|
||
await _ctl.followHidden(moved: moved);
|
||
await _ctl.refreshAll({for (final (a, _) in moved) vDirname(a)});
|
||
for (final x in _ctl.panes) {
|
||
for (final (from, to) in moved) {
|
||
x.sel.rename(from, to);
|
||
if (x.focused != null && samePath(x.focused!, from)) x.focused = to;
|
||
}
|
||
x.changed();
|
||
}
|
||
}
|
||
|
||
void _onGoToRequest() {
|
||
final dir = ExplorerPage.goToRequest.value;
|
||
if (dir == null || !_ctl.ready || !mounted) return;
|
||
ExplorerPage.goToRequest.value = null;
|
||
unawaited(_ctl.goTo(_ctl.pane, dir, fresh: true).then((_) => _reveal(_ctl.pane, dir)));
|
||
}
|
||
|
||
/// 79: 마지막 찾기 조건 (이번 실행 동안 다음 찾기 창의 처음 값)
|
||
SearchConditions _lastSearch = const SearchConditions();
|
||
|
||
/// 79-a: 결과 화면에서 복사 · 이동 · 지우기 · [그 자리로] 를 한 뒤 [찾기 결과로 돌아가기] 로 다시 열 결과 (다음 찾기 · ✕ · 결과 화면을 그냥 닫으면 없어짐)
|
||
({ExplorerPane pane, String root, SearchConditions cond, List<FileEntry> found, bool done, Map<String, String> moved})? _searchBack;
|
||
|
||
/// 79: 찾기 - 이름 · 크기 · 바뀐 날 · 종류 조건 → 결과 목록 화면 (고르고 그대로 복사 · 이동 · 지우기, 누르면 열기 · 재생)
|
||
@override
|
||
Future<void> openSearch(ExplorerPane pane) async {
|
||
setState(() => _ctl.active = _ctl.panes.indexOf(pane));
|
||
final cond = await askSearchConditions(context, pane.current, _lastSearch);
|
||
if (cond == null || !mounted) return;
|
||
_lastSearch = cond;
|
||
setState(() => _searchBack = null);
|
||
await _searchPage(pane, pane.current, cond);
|
||
}
|
||
|
||
/// 79-a: [찾기 결과로 돌아가기] - 다시 찾지 않고, 그사이 지운 것은 빼고 옮긴 것은 새 자리로
|
||
Future<void> _backToSearch() async {
|
||
final back = _searchBack;
|
||
if (back == null) return;
|
||
final now = <FileEntry>[];
|
||
for (final e in back.found) {
|
||
if (await vExists(e.path)) {
|
||
now.add(e);
|
||
continue;
|
||
}
|
||
final dest = back.moved[e.path];
|
||
final there = dest == null ? null : vJoin(dest, vBasename(e.path));
|
||
if (there != null && await vExists(there)) {
|
||
now.add(FileEntry(there, isDir: e.isDir, size: e.size, modified: e.modified));
|
||
}
|
||
// 고른 폴더와 함께 옮겨진 안쪽 것: 옮긴 폴더 아래 같은 자리
|
||
else {
|
||
for (final entry in back.moved.entries) {
|
||
if (!isSameOrInside(e.path, entry.key)) continue;
|
||
// 옮긴 폴더 이름 아래 남은 경로 (같은 꼴의 경로라 앞부분을 잘라 쓴다 - WebDAV 도)
|
||
final rest = e.path.length > entry.key.length ? e.path.substring(entry.key.length + 1) : '';
|
||
final moved = rest.isEmpty ? vJoin(entry.value, vBasename(entry.key)) : vJoin(vJoin(entry.value, vBasename(entry.key)), rest);
|
||
if (await vExists(moved)) now.add(FileEntry(moved, isDir: e.isDir, size: e.size, modified: e.modified));
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (!mounted) return;
|
||
await _searchPage(back.pane, back.root, back.cond, initial: now, initialDone: back.done);
|
||
}
|
||
|
||
Future<void> _searchPage(ExplorerPane pane, String root, SearchConditions cond, {List<FileEntry>? initial, bool initialDone = true}) async {
|
||
final other = _other?.current;
|
||
final act = await Navigator.of(context).push<SearchAction>(MaterialPageRoute(
|
||
builder: (_) => SearchResultsPage(
|
||
root: root,
|
||
cond: cond,
|
||
showHidden: c.settings.explorerShowHidden,
|
||
otherDir: other,
|
||
onOpen: (e) => _opener.open(context, e.path, pane: pane),
|
||
initial: initial,
|
||
initialDone: initialDone,
|
||
),
|
||
));
|
||
if (!mounted) return;
|
||
// 결과 화면을 그냥 닫으면 돌아갈 결과도 없앤다
|
||
if (act == null || act.paths.isEmpty) {
|
||
setState(() => _searchBack = null);
|
||
return;
|
||
}
|
||
// 관리자 판단 (10/17): [그 자리로] 로 나가도 남긴다 - 한 곳을 확인하고 다음 결과로 가는 것이 가장 흔한 쓰임
|
||
{
|
||
setState(() => _searchBack = (
|
||
pane: pane,
|
||
root: root,
|
||
cond: cond,
|
||
found: act.found,
|
||
done: act.done,
|
||
moved: act.kind == 'move' && other != null ? {for (final x in act.paths) x: other} : const <String, String>{},
|
||
));
|
||
}
|
||
switch (act.kind) {
|
||
case 'reveal':
|
||
final path = act.paths.first;
|
||
final dir = await vIsDir(path);
|
||
await _ctl.goTo(pane, dir ? path : vDirname(path), fresh: true);
|
||
pane.focused = path;
|
||
pane.changed();
|
||
_reveal(pane, path);
|
||
// 고른 것만 대상 (154 · 155) - 탐색기의 복사 · 이동 · 지우기와 같은 확인 · 진행 패널 · 휴지통
|
||
case 'copy':
|
||
await _transfers.transfer(context, pane, act.paths, move: false, otherDir: otherDir);
|
||
case 'move':
|
||
await _transfers.transfer(context, pane, act.paths, move: true, otherDir: otherDir);
|
||
case 'delete':
|
||
await _deletes.run(context, pane, act.paths);
|
||
}
|
||
}
|
||
|
||
/// 79-a: 탐색기 위의 [찾기 결과로 돌아가기] 줄
|
||
Widget _searchBackBar() {
|
||
final back = _searchBack!;
|
||
return Material(
|
||
color: JjColors.panelHigh,
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||
child: Row(children: [
|
||
Icon(Icons.manage_search, size: 18, color: JjColors.accent),
|
||
const SizedBox(width: 6),
|
||
Expanded(
|
||
child: Text(trf('찾기 결과 ({0}) · {1}', [back.cond.summary, back.root]),
|
||
maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||
),
|
||
TextButton(onPressed: _backToSearch, child: Text(tr('찾기 결과로 돌아가기'))),
|
||
IconButton(
|
||
tooltip: tr('찾기 결과 닫기'),
|
||
visualDensity: VisualDensity.compact,
|
||
icon: const Icon(Icons.close, size: 16),
|
||
onPressed: () => setState(() => _searchBack = null),
|
||
),
|
||
]),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// X-plore 맞추기 (관리자 지시 10/20): [목록에서 숨기기] 전에 무엇이 바뀌는지 알림. [다음부터 묻지 않기] 는 환경 설정에서 다시 켬
|
||
@override
|
||
Future<void> sortDialog() async {
|
||
// 84: "폴더마다 기억" 이면 지금 창의 폴더 정렬
|
||
final dir = _ctl.pane.current;
|
||
var (by, desc) = _ctl.sortOf(dir);
|
||
final ok = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => StatefulBuilder(
|
||
builder: (ctx, set) => AlertDialog(
|
||
scrollable: true,
|
||
title: Text(tr('정렬 기준')),
|
||
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Wrap(spacing: 6, children: [
|
||
for (final (v, label) in [
|
||
('name', tr('이름')),
|
||
('date', tr('바뀐 날')),
|
||
('size', tr('크기')),
|
||
('type', tr('종류 (확장자)')),
|
||
])
|
||
ChoiceChip(label: Text(label), selected: by == v, onSelected: (_) => set(() => by = v)),
|
||
]),
|
||
SwitchListTile(value: desc, title: Text(tr('거꾸로')), onChanged: (x) => set(() => desc = x)),
|
||
if (c.settings.explorerSortPerFolder)
|
||
Text(trf('이 폴더에만: {0}', [_ctl.displayPath(dir)]), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||
]),
|
||
actions: [
|
||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
|
||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('확인'))),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
if (ok != true) return;
|
||
await _ctl.setSort(dir, by, desc);
|
||
}
|
||
|
||
/// 73: 즐겨찾기 - 이 창으로 가기, Rsync 화면이면 짝 (왼쪽 · 오른쪽) 을 한 번에
|
||
@override
|
||
Future<void> openBookmarks(ExplorerPane pane) async {
|
||
setState(() => _ctl.active = _ctl.panes.indexOf(pane));
|
||
await showFolderBookmarks(
|
||
context,
|
||
c,
|
||
current: pane.current,
|
||
right: widget.rsync ? _ctl.panes[1].current : null,
|
||
go: (dir) => _ctl.goTo(pane, dir, fresh: true),
|
||
goPair: widget.rsync
|
||
? (a, b) {
|
||
_ctl.goTo(_ctl.panes[0], a, fresh: true);
|
||
_ctl.goTo(_ctl.panes[1], b, fresh: true);
|
||
}
|
||
: null,
|
||
);
|
||
if (mounted) setState(() {});
|
||
}
|
||
|
||
@override
|
||
Future<void> historyDialog(ExplorerPane pane) async {
|
||
final hist = c.settings.explorerHistory.where(reachable).toList();
|
||
final pick = await showDialog<String>(
|
||
context: context,
|
||
builder: (ctx) => SimpleDialog(
|
||
title: Text(tr('내역 (최근 폴더)')),
|
||
children: [
|
||
if (hist.isEmpty) Padding(padding: const EdgeInsets.all(16), child: Text(tr('없음'))),
|
||
for (final h in hist)
|
||
SimpleDialogOption(
|
||
onPressed: () => Navigator.pop(ctx, h),
|
||
child: Text(h, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
if (pick != null) await _ctl.goTo(pane, pick, fresh: true);
|
||
}
|
||
|
||
/// 118: 두 창이면 좌우 ⇆ 위아래 를 바꾸고, 한 창이면 두 창 (좌우) 으로. 한 번 눌러 바로
|
||
@override
|
||
Future<void> toggleOrientation() async {
|
||
final s = c.settings;
|
||
if (!(_dual || _split)) {
|
||
await c.updateSettings((x) => x
|
||
..explorerLayout = 'dual'
|
||
..explorerOrientation = 'side');
|
||
} else {
|
||
// 지금 보이는 배치의 반대 ('화면 모양 따라' 였으면 지금 모양에서 정함)
|
||
final size = MediaQuery.sizeOf(context);
|
||
final nowSide = switch (s.explorerOrientation) {
|
||
'side' => true,
|
||
'stacked' => false,
|
||
_ => size.width >= size.height,
|
||
};
|
||
await c.updateSettings((x) => x.explorerOrientation = nowSide ? 'stacked' : 'side');
|
||
}
|
||
if (!mounted) return;
|
||
setState(() {});
|
||
for (final pane in _ctl.panes) {
|
||
_reveal(pane, pane.current);
|
||
}
|
||
}
|
||
|
||
@override
|
||
Future<void> layoutDialog() async {
|
||
final s = c.settings;
|
||
var layout = s.explorerLayout, orient = s.explorerOrientation, bar = s.explorerToolbar;
|
||
final ok = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => StatefulBuilder(
|
||
builder: (ctx, set) {
|
||
Widget group(String title, String value, List<(String, String)> items, void Function(String) on) =>
|
||
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 8, left: 4),
|
||
child: Text(title, style: const TextStyle(fontWeight: FontWeight.w600)),
|
||
),
|
||
Wrap(spacing: 6, children: [
|
||
for (final (v, label) in items)
|
||
ChoiceChip(label: Text(label), selected: value == v, onSelected: (_) => set(() => on(v))),
|
||
]),
|
||
]);
|
||
return AlertDialog(
|
||
scrollable: true,
|
||
title: Text(tr('창 배치')),
|
||
content: SizedBox(
|
||
width: 420,
|
||
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
group(tr('창'), layout, [
|
||
('auto', tr('화면 크기 따라')),
|
||
('dual', tr('두 창 (기본)')),
|
||
('split', tr('폴더 + 파일 목록')),
|
||
('single', tr('한 창')),
|
||
], (v) => layout = v),
|
||
if (layout == 'split')
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 6, left: 4),
|
||
child: Text(tr('왼쪽에 폴더, 오른쪽에 왼쪽에서 고른 폴더의 파일들을 보여 줍니다 (Windows 탐색기처럼).'),
|
||
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||
),
|
||
if (layout != 'single')
|
||
group(tr('두 창 배치'), orient, [
|
||
('side', tr('좌우 (기본)')),
|
||
('stacked', tr('위아래')),
|
||
('auto', tr('화면 모양 따라')),
|
||
], (v) => orient = v),
|
||
group(tr('기능 버튼 줄'), bar, [
|
||
('middle', layout != 'single' ? tr('두 창 사이') : tr('왼쪽')),
|
||
('edge', tr('끝 (오른쪽 · 아래)')),
|
||
('hidden', tr('숨김')),
|
||
], (v) => bar = v),
|
||
if (bar == 'hidden')
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 8, left: 4),
|
||
child: Text(tr('숨겨도 위쪽 ⋮ 메뉴에서 창 배치 · 버튼 구성을 바꿀 수 있습니다.'),
|
||
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||
),
|
||
]),
|
||
),
|
||
actions: [
|
||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
|
||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('확인'))),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
);
|
||
if (ok != true) return;
|
||
await c.updateSettings((x) => x
|
||
..explorerLayout = layout
|
||
..explorerOrientation = orient
|
||
..explorerToolbar = bar);
|
||
if (layout == 'single') setState(() => _ctl.active = 0);
|
||
// 배치가 바뀌면 목록을 새로 그리므로 맨 위로 간다: 보던 폴더가 다시 보이게
|
||
for (final pane in _ctl.panes) {
|
||
_reveal(pane, pane.current);
|
||
}
|
||
}
|
||
|
||
@override
|
||
Future<void> buttonsDialog() async {
|
||
final current = ExplorerButton.fromSettings(c.settings.explorerButtons);
|
||
final order = [...current, ...ExplorerButton.explorerValues.where((b) => !current.contains(b))];
|
||
final on = {...current};
|
||
final ok = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => StatefulBuilder(
|
||
builder: (ctx, set) => AlertDialog(
|
||
title: Text(tr('버튼 구성')),
|
||
content: SizedBox(
|
||
width: 380,
|
||
height: 460,
|
||
child: Column(children: [
|
||
Text(tr('보일 버튼을 고르고, 끌어서 순서를 바꿉니다.'),
|
||
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||
const SizedBox(height: 8),
|
||
Expanded(
|
||
child: ReorderableListView(
|
||
buildDefaultDragHandles: true,
|
||
onReorderItem: (a, b) => set(() => order.insert(b, order.removeAt(a))),
|
||
children: [
|
||
for (final b in order)
|
||
CheckboxListTile(
|
||
key: ValueKey(b),
|
||
dense: true,
|
||
value: on.contains(b),
|
||
secondary: Icon(b.icon),
|
||
title: Text(tr(b.label)),
|
||
controlAffinity: ListTileControlAffinity.leading,
|
||
onChanged: (v) => set(() => v == true ? on.add(b) : on.remove(b)),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
]),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => set(() {
|
||
order
|
||
..clear()
|
||
..addAll(ExplorerButton.defaultOrder);
|
||
on
|
||
..clear()
|
||
..addAll(ExplorerButton.explorerValues);
|
||
}),
|
||
child: Text(tr('기본값')),
|
||
),
|
||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
|
||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('확인'))),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
if (ok != true) return;
|
||
final names = [for (final b in order) if (on.contains(b)) b.name];
|
||
await c.updateSettings((x) => x.explorerButtons = names.isEmpty ? [ExplorerButton.layout.name] : names);
|
||
}
|
||
|
||
// ───────── 화면 ─────────
|
||
|
||
/// 49: 뒤로 키가 화면을 닫기 전에 할 일이 있는지 (고른 것이 있거나 맨 위 폴더가 아님)
|
||
bool get _backHasWork =>
|
||
_sel.selecting || _ctl.panes.any((x) => !x.sel.isEmpty) || (_ctl.ready && !samePath(_ctl.pane.current, _ctl.pane.root));
|
||
|
||
/// 49: 뒤로 키 - 먼저 고른 것 풀기, 그다음 상위 폴더, 맨 위면 화면 닫기 (위쪽 ← 는 늘 닫기)
|
||
Future<void> _onBack() async {
|
||
if (_sel.selecting || _ctl.panes.any((x) => !x.sel.isEmpty)) {
|
||
_sel.setSelecting(false);
|
||
return;
|
||
}
|
||
if (!samePath(_ctl.pane.current, _ctl.pane.root)) {
|
||
await _buttons.press(ExplorerButton.up);
|
||
return;
|
||
}
|
||
if (mounted) Navigator.of(context).pop();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return PopScope(
|
||
canPop: !_backHasWork,
|
||
onPopInvokedWithResult: (didPop, _) {
|
||
if (!didPop) unawaited(_onBack());
|
||
},
|
||
child: _buildPage(context),
|
||
);
|
||
}
|
||
|
||
Widget _buildPage(BuildContext context) {
|
||
// P0 (15 지적 10/17): 글자 칸 (빠른 필터 · 경로 등) 에 치는 중이면 파일 단축키를 쓰지 않는다 - 예전에는 CallbackShortcuts 가
|
||
// 글자 칸보다 먼저 키를 받아, 칸에서 Delete 를 누르면 파일 지우기 창이 뜨고 Ctrl+A · C · V · Space 도 파일에 쓰였다
|
||
final bindings = <ShortcutActivator, VoidCallback>{
|
||
// 83 · X-plore 비교 5-1 ⑥: 스타일에 맞는 처음 키 + 사용자가 바꾼 키 ([explorer_keys.dart], 환경 설정 > 파일 탐색기 > 단축키)
|
||
..._keyBindings(),
|
||
// 180: 선택 모드 · 표시 풀기 (복사 · 이동이 끝나도 선택 모드는 그대로이므로 바로 나올 수 있게) - Esc 는 바꾸지 않는다
|
||
const SingleActivator(LogicalKeyboardKey.escape): () {
|
||
// 76: 열린 빠른 필터부터 닫는다
|
||
if (_ctl.panes[_ctl.active].filterOpen) {
|
||
_closeFilter(_ctl.panes[_ctl.active]);
|
||
} else if (_sel.selecting || _ctl.panes.any((x) => !x.sel.isEmpty)) {
|
||
_sel.setSelecting(false);
|
||
}
|
||
},
|
||
};
|
||
return Focus(
|
||
canRequestFocus: false,
|
||
skipTraversal: true,
|
||
onKeyEvent: (_, ev) => _shortcut(bindings, ev),
|
||
child: Focus(
|
||
focusNode: _keys,
|
||
autofocus: true,
|
||
// 76: 목록에서 글자를 바로 치면 지금 창의 빠른 필터가 그 글자로 열린다 (One Commander · TC 처럼)
|
||
onKeyEvent: (_, ev) => _typeToFilter(ev),
|
||
child: Listener(
|
||
// 75: 마우스 4 · 5번 (뒤로 · 앞으로) 버튼 - 창 밖 (위 막대 등) 이면 지금 창 (창 위면 그 창이 먼저 받음, [_frame])
|
||
onPointerDown: (ev) => _mouseTravel(_ctl.active, ev),
|
||
child: Scaffold(
|
||
body: SwipeNav(
|
||
current: widget.rsync ? 'rsync' : 'explorer',
|
||
child: ListenableBuilder(
|
||
listenable: c,
|
||
builder: (context, _) {
|
||
_ctl.dropRemovedServers();
|
||
// 80 보충: 화면에서 사라진 폴더를 위해 걸어 둔 자동 크기 계산은 멈추고 뺀다
|
||
_ctl.sizes.retainAuto([for (final x in _ctl.panes) x.current]);
|
||
return Column(children: [
|
||
_topBar(),
|
||
if (_searchBack != null) _searchBackBar(),
|
||
// 89: Wi-Fi 공유가 켜져 있는 동안
|
||
if (!widget.rsync)
|
||
ValueListenableBuilder<WifiShare?>(
|
||
valueListenable: WifiShare.current,
|
||
builder: (_, s, _) => s == null ? const SizedBox.shrink() : _wifiBar(s),
|
||
),
|
||
Expanded(
|
||
child: Stack(children: [
|
||
// X-plore 비교 ⚖: 탐색기 안에서 밀기 = 창 바꾸기 (안쪽이 먼저 받아 화면 이동은 위 막대에서만)
|
||
Positioned.fill(
|
||
child: !_ctl.ready
|
||
? const Center(child: CircularProgressIndicator())
|
||
: _swipePanes
|
||
? GestureDetector(
|
||
behavior: HitTestBehavior.translucent,
|
||
onHorizontalDragEnd: (d) {
|
||
final v = d.primaryVelocity ?? 0;
|
||
if (v.abs() >= 500) _switchPane(v < 0 ? 1 : -1);
|
||
},
|
||
child: _body())
|
||
: _body()),
|
||
// 복사 · 이동 진행: 아래에서 올라오고 끝나면 내려간다
|
||
Positioned(left: 8, right: 8, bottom: 0, child: ExplorerTransferPanel(c: c, jobs: _jobs.list, rsync: widget.rsync)),
|
||
]),
|
||
),
|
||
]);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 지금 폴더 · 그 위 폴더들 (창의 맨 위 [pane.root] 까지, 가까운 것부터)
|
||
List<String> _ancestors(ExplorerPane pane) {
|
||
final out = <String>[];
|
||
var d = pane.current;
|
||
for (var i = 0; i < 64; i++) {
|
||
out.add(d);
|
||
if (samePath(d, pane.root)) break;
|
||
final up = vDirname(d);
|
||
if (up == d || up.isEmpty) break;
|
||
d = up;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
Widget _topBar() => AppTopBar(
|
||
nav: AppNavButtons(onExplorerPage: !widget.rsync, onRsyncPage: widget.rsync),
|
||
actions: AppActions(c: c),
|
||
// 아주 좁고 글자가 크면 (300 폭 · 1.3 × 150%) 제목도 줄임 - 넘쳐 그리기 오류가 나던 것 (날짜 열 312 조합 시험에서 찾음).
|
||
// 보통은 제목 폭 그대로 (경로 막대에 80 남짓 남을 때까지)
|
||
middle: LayoutBuilder(builder: (context, box) => Row(children: [
|
||
ConstrainedBox(
|
||
constraints: BoxConstraints(maxWidth: math.max(40, box.maxWidth - 92)),
|
||
child: Text(widget.rsync ? 'Rsync' : tr('파일 탐색기'),
|
||
maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||
),
|
||
const SizedBox(width: 12),
|
||
// X-plore 경로 막대 (4.4x): 경로를 누르면 위 폴더들 (창의 맨 위까지) 이 나와 골라 간다
|
||
Expanded(
|
||
child: !_ctl.ready
|
||
? const SizedBox.shrink()
|
||
: PopupMenuButton<String>(
|
||
tooltip: tr('위 폴더로 가기'),
|
||
position: PopupMenuPosition.under,
|
||
onSelected: (d) => _ctl.goTo(_ctl.pane, d),
|
||
itemBuilder: (_) => [
|
||
for (final d in _ancestors(_ctl.pane))
|
||
PopupMenuItem(
|
||
value: d,
|
||
enabled: !samePath(d, _ctl.pane.current),
|
||
child: Text(_ctl.displayPath(d), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||
),
|
||
],
|
||
// 관리자 지시 (10/21, 28 지적): 좁으면 앞을 "…" 로 - 지금 폴더 (끝) 가 늘 보이게
|
||
child: TailText(_ctl.displayPath(_ctl.pane.current), style: TextStyle(color: JjColors.textDim)),
|
||
),
|
||
),
|
||
PopupMenuButton<Object>(
|
||
tooltip: tr('창 배치 · 버튼 구성'),
|
||
icon: const Icon(Icons.more_vert),
|
||
onSelected: (v) => v is ExplorerButton ? _buttons.press(v) : _ctl.toggleAppHidden(),
|
||
itemBuilder: (_) => [
|
||
for (final b in [
|
||
if (!widget.rsync) ...[ExplorerButton.layout, ExplorerButton.buttons],
|
||
ExplorerButton.sort,
|
||
ExplorerButton.hidden,
|
||
if (!widget.rsync) ExplorerButton.storage,
|
||
if (!widget.rsync) ExplorerButton.wifiShare,
|
||
])
|
||
PopupMenuItem(
|
||
value: b,
|
||
child: Row(children: [Icon(b.icon, size: 18), const SizedBox(width: 12), Text(tr(b.label))]),
|
||
),
|
||
// X-plore 숨기기: 큰 폴더를 펼쳐 목록 맨 아래가 멀 때도 (관리자 결정 10/18)
|
||
if (!widget.rsync && c.settings.explorerHiddenItems.isNotEmpty)
|
||
PopupMenuItem(
|
||
value: 'appHidden',
|
||
child: Row(children: [
|
||
Icon(_ctl.showAppHidden ? Icons.visibility_off_outlined : Icons.visibility_outlined, size: 18),
|
||
const SizedBox(width: 12),
|
||
Flexible(
|
||
child: Text(_ctl.showAppHidden
|
||
? tr('목록에서 숨긴 항목 다시 숨기기')
|
||
: trf('목록에서 숨긴 항목 보이기 ({0}개)', [c.settings.explorerHiddenItems.length])),
|
||
),
|
||
]),
|
||
),
|
||
],
|
||
),
|
||
])),
|
||
);
|
||
|
||
/// 모양 · 배치가 바뀌면 (줄 높이 · 창 너비가 달라져) 보던 폴더가 밀려나므로 다시 그 폴더로
|
||
String _shape = '';
|
||
|
||
Widget _body() {
|
||
final s = c.settings;
|
||
final shape = '${s.explorerStyle}|${s.explorerLayout}|${s.explorerOrientation}|${s.explorerToolbar}';
|
||
if (_ctl.ready && shape != _shape) {
|
||
if (_shape.isNotEmpty) {
|
||
for (final pane in _ctl.panes) {
|
||
_reveal(pane, pane.current);
|
||
}
|
||
}
|
||
_shape = shape;
|
||
}
|
||
final dual = _dual;
|
||
return LayoutBuilder(builder: (context, box) {
|
||
// 두 창이 다 들어가는지: 트리 (화살표 · 아이콘 · 여백 78 + 이름 40) + 목록 (아이콘 · ✔ · 여백 104 + 이름 50), 이름은 글자 배율만큼.
|
||
// 1배면 360 폭에도 들어가고, 기기 글자 1.3 × 150% (약 2배) 면 412 폭에도 모자란다
|
||
final wantSplit = !widget.rsync && s.explorerLayout == 'split';
|
||
final textScale = MediaQuery.textScalerOf(context).scale(14) / 14 * _listTextScale;
|
||
final barW = s.explorerToolbar == 'hidden' ? 0.0 : 72.0;
|
||
final sideNow = switch (s.explorerOrientation) { 'side' => true, 'stacked' => false, _ => box.maxWidth >= box.maxHeight };
|
||
final tightWas = _splitTight;
|
||
_splitTight = wantSplit && sideNow && box.maxWidth - barW < 182 + 90 * textScale;
|
||
// 바뀌면 이 builder 밖 (버튼 이름 "두 창" 등) 도 한 번 다시 그린다
|
||
if (tightWas != _splitTight) WidgetsBinding.instance.addPostFrameCallback((_) => mounted ? setState(() {}) : null);
|
||
final split = _split;
|
||
// 폭이 바뀌면 (좁은 한 창 ↔ 두 창) 이 builder 만 다시 불린다 - 그때도 새로 붙은 창을 보이게
|
||
if (_ctl.ready) _revealNewViews();
|
||
final side = !(dual || split) ||
|
||
switch (s.explorerOrientation) {
|
||
'side' => true,
|
||
'stacked' => false,
|
||
_ => box.maxWidth >= box.maxHeight,
|
||
};
|
||
// 191: Rsync 화면이 좁으면 (폴드 접은 화면 · 폰 세로의 좌우 두 창) 창 하나씩 - [왼쪽 | 오른쪽] 으로 바꿔 본다
|
||
// (두 창 + 가운데 버튼 줄이면 창이 140px 남짓이라 폴더 이름이 잘렸다)
|
||
final rsyncOne = widget.rsync && side && box.maxWidth < rsyncTwoPaneMinWidth;
|
||
_narrowSplit = split && side && box.maxWidth < narrowSplitWidth;
|
||
final bar = s.explorerToolbar == 'hidden' && !widget.rsync
|
||
? null
|
||
: ExplorerToolbar(
|
||
// X-plore b: 길게 누르기 = 짝 동작 (Rsync 화면은 없음)
|
||
onLongPress: widget.rsync ? null : _buttons.longPress,
|
||
// Rsync 화면: 늘 같은 버튼 (→ ← ⇄ 모니터링) 을 두 창 사이에
|
||
buttons: widget.rsync ? ExplorerButton.rsyncButtons : ExplorerButton.fromSettings(s.explorerButtons),
|
||
vertical: side && !rsyncOne,
|
||
panesSide: side,
|
||
onPressed: _buttons.press,
|
||
enabled: (b) => switch (b) {
|
||
ExplorerButton.paste => _transfers.clip.isNotEmpty,
|
||
_ => true,
|
||
},
|
||
selected: (b) =>
|
||
(b == ExplorerButton.hidden && s.explorerShowHidden) || (b == ExplorerButton.select && _sel.selecting),
|
||
twoPanes: dual || split,
|
||
pinned: {ExplorerButton.orient, if (_transfers.clip.isNotEmpty) ExplorerButton.paste},
|
||
);
|
||
final middle = bar != null && (widget.rsync || s.explorerToolbar == 'middle');
|
||
final edge = bar != null && !widget.rsync && s.explorerToolbar == 'edge';
|
||
// 180: 창마다 같은 열쇠 - 가로 ↔ 세로 배치가 바뀌어도 (창 크기 · 작업 현황이 열림) 창을 새로 만들지 않는다 (스크롤이 맨 위로 가지 않게)
|
||
Widget keep(int k, Widget w) => KeyedSubtree(key: _viewKeys[k], child: w);
|
||
if (rsyncOne) {
|
||
final i = _ctl.active.clamp(0, 1);
|
||
return Column(children: [
|
||
_rsyncPaneSwitch(),
|
||
?bar,
|
||
Expanded(child: keep(i, ExplorerPaneView(ctl: _ctl, sel: _sel, parts: this, index: i, foldersOnly: true))),
|
||
]);
|
||
}
|
||
final children = <Widget>[
|
||
if (dual) ...[
|
||
Expanded(child: keep(0, ExplorerPaneView(ctl: _ctl, sel: _sel, parts: this, index: 0, foldersOnly: widget.rsync))),
|
||
if (middle) bar,
|
||
Expanded(child: keep(1, ExplorerPaneView(ctl: _ctl, sel: _sel, parts: this, index: 1, foldersOnly: widget.rsync))),
|
||
if (edge) bar,
|
||
] else if (split) ...[
|
||
// 왼쪽 폴더 트리 (좁게) · 오른쪽 그 폴더의 파일 목록. 좁은 화면은 트리에 최소 폭 (130px 또는 버튼 줄 (72) 을 뺀 폭의 42%) -
|
||
// 버튼 줄까지 넣어 38% 로 하면 360 폭에서 오른쪽 목록의 파일 이름이 두세 글자만 남았다
|
||
if (_narrowSplit)
|
||
SizedBox(
|
||
width: math.max(130.0, (box.maxWidth - (middle || edge ? 72 : 0)) * 0.42),
|
||
child: keep(0, ExplorerPaneView(ctl: _ctl, sel: _sel, parts: this, index: 0, foldersOnly: true)))
|
||
else
|
||
Expanded(flex: 2, child: keep(0, ExplorerPaneView(ctl: _ctl, sel: _sel, parts: this, index: 0, foldersOnly: true))),
|
||
if (middle) bar,
|
||
Expanded(flex: 3, child: keep(2, ExplorerListView(ctl: _ctl, sel: _sel, parts: this, pane: _ctl.panes[0]))),
|
||
if (edge) bar,
|
||
] else ...[
|
||
if (middle) bar,
|
||
Expanded(
|
||
child: _hiddenTarget
|
||
? Column(children: [ListenableBuilder(listenable: _ctl.panes[1], builder: (_, _) => _targetBar()), Expanded(child: keep(0, ExplorerPaneView(ctl: _ctl, sel: _sel, parts: this, index: 0)))])
|
||
: keep(0, ExplorerPaneView(ctl: _ctl, sel: _sel, parts: this, index: 0))),
|
||
if (edge) bar,
|
||
],
|
||
];
|
||
return side ? Row(children: children) : Column(children: children);
|
||
});
|
||
}
|
||
|
||
/// 191: 좁은 Rsync 화면의 [왼쪽 | 오른쪽] - 그 창에서 고른 폴더 (없으면 지금 폴더) 이름과 함께
|
||
Widget _rsyncPaneSwitch() => Padding(
|
||
padding: const EdgeInsets.fromLTRB(4, 4, 4, 0),
|
||
child: Row(children: [
|
||
for (final (k, side) in [(0, tr('왼쪽')), (1, tr('오른쪽'))])
|
||
Expanded(
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||
child: ChoiceChip(
|
||
showCheckmark: false,
|
||
selected: _ctl.active == k,
|
||
onSelected: (_) => setState(() => _ctl.active = k),
|
||
label: SizedBox(
|
||
width: double.infinity,
|
||
child: Text(
|
||
'$side · ${vBasename(!_ctl.panes[k].sel.isEmpty ? _ctl.panes[k].sel.marked.first : _ctl.panes[k].current)}',
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
]),
|
||
);
|
||
|
||
/// 180: 왼쪽 창 · 오른쪽 창 · 파일 목록 (배치가 바뀌어도 같은 위젯 - [_body])
|
||
final _viewKeys = [GlobalKey(), GlobalKey(), GlobalKey()];
|
||
|
||
/// 75: 마우스 4 · 5번 - 그 창의 뒤로 · 앞으로 (그 창을 활성으로).
|
||
/// 창 위의 누름은 창 ([_frame]) 과 화면 Listener 가 둘 다 받는다. 받는 곳마다 좌표를 바꾼 새 이벤트라 객체로는 같은지 알 수 없어
|
||
/// 누른 손가락 (pointer) · 시각으로 한 번만 쓴다 (창이 먼저 받는다)
|
||
(int, Duration)? _navPress;
|
||
void _mouseTravel(int i, PointerDownEvent ev) {
|
||
if (i >= _ctl.panes.length) return;
|
||
final back = ev.buttons & kBackMouseButton != 0;
|
||
if (!back && ev.buttons & kForwardMouseButton == 0) return;
|
||
final press = (ev.pointer, ev.timeStamp);
|
||
if (press == _navPress) return;
|
||
_navPress = press;
|
||
_ctl.travel(_ctl.panes[i], forward: !back);
|
||
}
|
||
|
||
Widget _frame(int i, List<Widget> children) {
|
||
final active = i == _ctl.active;
|
||
return Listener(
|
||
// 75: 마우스가 올라간 창에 (지금 활성 창이 아니어도)
|
||
onPointerDown: (ev) => _mouseTravel(i, ev),
|
||
child: GestureDetector(
|
||
onTapDown: (_) {
|
||
if (_ctl.active != i) setState(() => _ctl.active = i);
|
||
},
|
||
// 82: 창의 빈 곳에 놓으면 그 창의 지금 폴더로. 놓을 수 있으면 강조, 못 놓으면 (같은 창 등) 빨간 테두리
|
||
child: DragTarget<DragLoad>(
|
||
onWillAcceptWithDetails: (d) => canDropInto(d.data, i, _ctl.panes[i].current),
|
||
onAcceptWithDetails: (d) => unawaited(_onDrop(d.data, i, _ctl.panes[i].current)),
|
||
builder: (context, ok, no) => Container(
|
||
margin: const EdgeInsets.all(4),
|
||
decoration: BoxDecoration(
|
||
color: ok.isNotEmpty ? JjColors.accent.withValues(alpha: 0.06) : JjColors.bg,
|
||
border: Border.all(
|
||
color: ok.isNotEmpty
|
||
? JjColors.accent
|
||
: no.isNotEmpty
|
||
? JjColors.danger
|
||
: active
|
||
? JjColors.accent
|
||
: JjColors.border,
|
||
width: ok.isNotEmpty || no.isNotEmpty ? 2 : (active ? 1.5 : 1)),
|
||
borderRadius: BorderRadius.circular(6),
|
||
),
|
||
child: Column(children: children),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 82: 끌 항목 - 누른 것이 표시한 것 중 하나면 표시한 것 모두 (바깥 것만), 아니면 그것 하나
|
||
List<String> _dragPaths(ExplorerPane pane, FileEntry e) =>
|
||
_sel.isMarked(pane, e.path) && !pane.sel.isEmpty ? outermostPaths(pane.sel.marked) : [e.path];
|
||
|
||
/// 82: 놓음 - 설정 "끌어 놓기 기본" (묻기 · 복사 · 이동) 뒤 지금의 복사 · 이동 (확인 창 · 같은 이름 · 진행 패널).
|
||
/// 이동이 끝나면 아래 알림에 [되돌리기] (10초)
|
||
Future<void> _onDrop(DragLoad load, int to, String dest) async {
|
||
if (!canDropInto(load, to, dest) || !mounted) return;
|
||
// X-plore 비교 f: 압축 안으로는 놓지 않고, 압축 안의 것은 꺼내기로
|
||
if (_guard.insideArchive(dest) || _ctl.arc.contains(dest)) {
|
||
_snack(tr('압축 안은 읽기 전용입니다 (꺼내기만 할 수 있습니다)'));
|
||
return;
|
||
}
|
||
if (load.paths.any(_guard.insideArchive)) {
|
||
await _guard.extractFrom(context, load.paths, dest);
|
||
return;
|
||
}
|
||
setState(() => _ctl.active = to);
|
||
final mode = c.settings.explorerDropAction;
|
||
final move = mode == 'ask' ? await askDropAction(context, load.paths, dest) : mode == 'move';
|
||
if (move == null || !mounted) return;
|
||
// 되돌리기에 쓸 것: 대상에 이미 있던 이름 (합친 폴더는 통째로 되돌리지 않게)
|
||
final existed = <String>{};
|
||
if (move) {
|
||
for (final s in load.paths) {
|
||
final t = vJoin(dest, vBasename(s));
|
||
if (await vExists(t)) existed.add(t);
|
||
}
|
||
}
|
||
if (!mounted) return;
|
||
await _transfers.run(context, _ctl.panes[load.from], load.paths, dest,
|
||
move: move, onFinished: move ? (job) => _offerUndoMove(load, job, existed) : null);
|
||
}
|
||
|
||
/// 82: 이동 되돌리기 알림. 실제로 옮긴 것만 원래 자리로: 새로 생긴 항목은 통째로, 이미 있던 폴더에 합친 것은 앱이 옮긴 파일만
|
||
/// (robocopy 로 합친 폴더는 무엇을 옮겼는지 몰라 되돌리지 않고 알린다)
|
||
void _offerUndoMove(DragLoad load, TransferJob job, Set<String> existed) {
|
||
// (원래 폴더 → 지금 경로들)
|
||
final back = <String, List<String>>{};
|
||
// 되돌리지 않는 것: 합친 폴더 (robocopy - 무엇을 옮겼는지 모름) · 같은 이름을 덮어쓴 파일 (원래 파일은 이미 없음, 15 지적)
|
||
var notUndoable = 0, overwritten = 0;
|
||
final pairs = job.made.length == load.paths.length
|
||
? [for (var i = 0; i < load.paths.length; i++) (load.paths[i], job.made[i])]
|
||
: [
|
||
for (final m in job.made)
|
||
for (final s in load.paths)
|
||
if (vBasename(s) == vBasename(m)) (s, m),
|
||
];
|
||
for (final (src, now) in pairs) {
|
||
if (!existed.contains(now)) {
|
||
back.putIfAbsent(vDirname(src), () => []).add(now);
|
||
continue;
|
||
}
|
||
// 같은 이름을 덮어쓴 파일: 되돌리면 대상에 아무것도 남지 않으므로 그대로 둔다
|
||
if (!vIsDirSync(now)) {
|
||
overwritten++;
|
||
continue;
|
||
}
|
||
// 합친 폴더: 그 안에서 실제로 옮긴 파일만
|
||
final files = [for (final f in job.movedFiles) if (isSameOrInside(f, src) && !samePath(f, src)) f];
|
||
if (files.isEmpty) {
|
||
notUndoable++;
|
||
continue;
|
||
}
|
||
for (final f in files) {
|
||
final rest = f.substring(src.length + 1);
|
||
back.putIfAbsent(vDirname(f), () => []).add(vJoin(now, rest));
|
||
}
|
||
}
|
||
if (back.isEmpty || !mounted) return;
|
||
final moved = back.values.fold(0, (a, l) => a + l.length);
|
||
showNotice(ScaffoldMessenger.of(context)..hideCurrentSnackBar(),
|
||
text: joinSentences([
|
||
trf('{0}개를 옮겼습니다.', [moved]),
|
||
if (notUndoable > 0) trf('합친 폴더 {0}개는 무엇을 옮겼는지 몰라 되돌리지 않습니다.', [notUndoable]),
|
||
if (overwritten > 0) trf('같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.', [overwritten]),
|
||
]),
|
||
action: (tr('되돌리기'), () => unawaited(_undoMove(_ctl.panes[load.from], back))));
|
||
}
|
||
|
||
/// 82: 되돌리기 - 원래 폴더마다 지금의 이동으로 (같은 이름이 생겼으면 지금의 같은 이름 창)
|
||
Future<void> _undoMove(ExplorerPane pane, Map<String, List<String>> back) async {
|
||
for (final MapEntry(key: dir, value: items) in back.entries) {
|
||
final there = [for (final x in items) if (await vExists(x)) x];
|
||
if (there.isEmpty || !mounted) continue;
|
||
try {
|
||
if (!await vExists(dir)) await vMkdirs(dir);
|
||
} catch (_) {}
|
||
if (!mounted) return;
|
||
await _transfers.run(context, pane, there, dir, move: true);
|
||
}
|
||
}
|
||
|
||
/// 선택 모드 막대: 고른 수 · 모두 고르기 · 선택 끝
|
||
/// 76: 빠른 필터 열기 ([text] 가 있으면 그 글자로 시작)
|
||
void _openFilter(ExplorerPane pane, [String? text]) {
|
||
setState(() {
|
||
_ctl.active = _ctl.panes.indexOf(pane);
|
||
pane.filterOpen = true;
|
||
if (text != null) {
|
||
pane.filterCtl.value = TextEditingValue(text: text, selection: TextSelection.collapsed(offset: text.length));
|
||
}
|
||
});
|
||
WidgetsBinding.instance.addPostFrameCallback((_) => pane.filterFocus.requestFocus());
|
||
// 거른 결과 (지금 폴더 아래) 가 보이게
|
||
_reveal(pane, pane.current);
|
||
}
|
||
|
||
/// 76: 닫으면 글자도 지워 모두 보인다. 줄이 다시 늘어나 맨 위로 가지 않게 고른 것 (없으면 지금 폴더) 으로
|
||
void _closeFilter(ExplorerPane pane) {
|
||
setState(() {
|
||
pane
|
||
..filterOpen = false
|
||
..filterCtl.clear();
|
||
});
|
||
_reveal(pane, pane.focused ?? pane.current);
|
||
_keys.requestFocus();
|
||
}
|
||
|
||
/// 83: 지금 스타일이 Total Commander 인지 (Rsync 화면은 스타일 단축키를 쓰지 않는다 - 늘 같은 버튼)
|
||
bool get _tcKeys => !widget.rsync && Platform.isWindows;
|
||
|
||
/// 83 · X-plore 비교 5-1 ⑥ (관리자 지시 10/21): 동작마다의 키 → 할 일. 처음 키는 스타일대로 (Total Commander: F5 복사 · F6 이동 ·
|
||
/// F7 새 폴더 · F8 지우기 · Insert/Space 표시 · Ctrl+R 새로 고침 / Windows 탐색기 · X-plore: Ctrl+C · X · V · A · F5 새로 고침),
|
||
/// 사용자가 바꾼 것은 설정 explorerKeyOverrides. Rsync 화면은 늘 같은 키 (바꾼 것을 쓰지 않음)
|
||
Map<ShortcutActivator, VoidCallback> _keyBindings() {
|
||
final cb = <String, VoidCallback>{
|
||
'copy': () => _buttons.press(ExplorerButton.copy),
|
||
'move': () => _buttons.press(ExplorerButton.move),
|
||
'newFolder': () => _buttons.press(ExplorerButton.newFolder),
|
||
'delete': () => _buttons.press(ExplorerButton.delete),
|
||
// 65: Shift+Delete = 휴지통을 거치지 않고 (창에서 한 번 더 확인)
|
||
'deletePermanent': () {
|
||
_deletes.shiftDelete = true;
|
||
_buttons.press(ExplorerButton.delete);
|
||
},
|
||
'rename': () => _buttons.press(ExplorerButton.rename),
|
||
'refresh': () => _buttons.press(ExplorerButton.refresh),
|
||
// 81: 빠른 보기
|
||
'quickLook': () => quickLook(_ctl.pane),
|
||
'clipCopy': () => _transfers.toClipboard(_sel.targets(_ctl.pane), move: false),
|
||
'clipCut': () => _transfers.toClipboard(_sel.targets(_ctl.pane), move: true),
|
||
'paste': () => _transfers.paste(context, _ctl.pane, _ctl.pane.current),
|
||
'selectAll': () {
|
||
if (!_sel.selecting) setState(() => _sel.selecting = true);
|
||
_sel.markWhere(_ctl.pane, _ctl.pane.current, (_) => true);
|
||
},
|
||
// TC: Space 표시 · Insert 표시하고 다음 줄로
|
||
'mark': () => _markFocused(next: false),
|
||
'markNext': () => _markFocused(next: true),
|
||
'filter': () => _openFilter(_ctl.panes[_ctl.active]),
|
||
'up': () => _buttons.press(ExplorerButton.up),
|
||
// 75: 지금 창의 뒤로 · 앞으로
|
||
'back': () => _ctl.travel(_ctl.panes[_ctl.active], forward: false),
|
||
'forward': () => _ctl.travel(_ctl.panes[_ctl.active], forward: true),
|
||
'switchPane': () => setState(() => _ctl.active = _dual ? 1 - _ctl.active : 0),
|
||
};
|
||
final keys = widget.rsync
|
||
? {
|
||
for (final e in defaultExplorerKeys(tc: false).entries)
|
||
if (const {'delete', 'deletePermanent', 'rename', 'refresh', 'filter', 'up', 'back', 'forward', 'switchPane'}.contains(e.key))
|
||
e.key: e.value,
|
||
}
|
||
: effectiveExplorerKeys(tc: _tcKeys, overrides: c.settings.explorerKeyOverrides);
|
||
return {
|
||
for (final e in keys.entries)
|
||
if (cb[e.key] != null)
|
||
for (final a in e.value) a: cb[e.key]!,
|
||
};
|
||
}
|
||
|
||
/// 81: 빠른 보기 - 지금 줄 (없으면 아무것도 안 함). 같은 폴더에 보이는 항목을 ← → 로
|
||
@override
|
||
Future<void> quickLook(ExplorerPane pane, [String? path]) async {
|
||
final f = path ?? pane.focused;
|
||
if (f == null || samePath(f, pane.root)) return;
|
||
final list = _sel.shown(pane, vDirname(f));
|
||
final i = list.indexWhere((x) => samePath(x.path, f));
|
||
if (i < 0) return;
|
||
await showDialog<void>(
|
||
context: context,
|
||
builder: (_) => QuickLook(
|
||
entries: list,
|
||
index: i,
|
||
probe: c.services.mediaTool.probe,
|
||
thumbnail: (e) => BigVideoThumbs.of(c, e),
|
||
imageExts: c.settings.imageExts,
|
||
onOpen: (e) => e.isDir ? unawaited(_ctl.goTo(pane, e.path)) : unawaited(_opener.open(context, e.path, pane: pane)),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 83: (TC) 지금 줄을 표시 · 풀기, [next] 면 다음 줄로
|
||
Future<void> _markFocused({required bool next}) async {
|
||
final pane = _ctl.pane;
|
||
final f = pane.focused;
|
||
if (f == null || samePath(f, pane.root)) return;
|
||
final list = _sel.shown(pane, vDirname(f));
|
||
final i = list.indexWhere((x) => samePath(x.path, f));
|
||
if (i < 0) return;
|
||
if (!_sel.selecting) setState(() => _sel.selecting = true);
|
||
await _sel.toggleMark(pane, list[i]);
|
||
if (next && i + 1 < list.length) {
|
||
pane.focused = list[i + 1].path;
|
||
pane.changed();
|
||
_reveal(pane, list[i + 1].path);
|
||
}
|
||
}
|
||
|
||
/// 글자 칸에 치는 중인지 (그 칸이 키를 받아야 한다)
|
||
static bool get _typing => FocusManager.instance.primaryFocus?.context?.findAncestorStateOfType<EditableTextState>() != null;
|
||
|
||
/// 탐색기 단축키: 글자 칸에 치는 중이면 넘기고 (칸의 기본 동작 - 선택 · 복사 · 붙여넣기 · 지우기 · 빈칸), 아니면 맞는 것을 한다
|
||
KeyEventResult _shortcut(Map<ShortcutActivator, VoidCallback> bindings, KeyEvent ev) {
|
||
if (ev is KeyUpEvent || _typing) return KeyEventResult.ignored;
|
||
for (final b in bindings.entries) {
|
||
if (b.key.accepts(ev, HardwareKeyboard.instance)) {
|
||
b.value();
|
||
return KeyEventResult.handled;
|
||
}
|
||
}
|
||
return KeyEventResult.ignored;
|
||
}
|
||
|
||
KeyEventResult _typeToFilter(KeyEvent ev) {
|
||
if (ev is! KeyDownEvent) return KeyEventResult.ignored;
|
||
final ch = ev.character;
|
||
final kb = HardwareKeyboard.instance;
|
||
if (ch == null || ch.isEmpty || ch.runes.first < 0x20 || ch.runes.first == 0x7f) return KeyEventResult.ignored;
|
||
// 83 · 81: Space 는 표시 (TC) · 빠른 보기 (그 밖) - 빠른 필터를 열지 않는다 (필터가 열려 있으면 그대로 글자)
|
||
if (ch == ' ' && !widget.rsync && !_ctl.panes[_ctl.active].filterOpen) return KeyEventResult.ignored;
|
||
if (kb.isControlPressed || kb.isAltPressed || kb.isMetaPressed) return KeyEventResult.ignored;
|
||
// 글자 칸 (필터 · 경로 입력 등) 에 치는 중이면 그 칸이 받는다
|
||
if (_typing) return KeyEventResult.ignored;
|
||
if (!_ctl.ready || _ctl.panes.isEmpty) return KeyEventResult.ignored;
|
||
final pane = _ctl.panes[_ctl.active];
|
||
_openFilter(pane, pane.filterOpen ? pane.filterCtl.text + ch : ch);
|
||
return KeyEventResult.handled;
|
||
}
|
||
|
||
}
|
||
|