5498 lines
260 KiB
Dart
5498 lines
260 KiB
Dart
import 'dart:async';
|
||
import 'dart:collection';
|
||
import 'dart:convert';
|
||
import 'dart:io';
|
||
import 'dart:math' as math;
|
||
|
||
import 'package:crypto/crypto.dart';
|
||
import 'package:flutter/foundation.dart' show defaultTargetPlatform;
|
||
import 'package:flutter/gestures.dart' show kBackMouseButton, kDoubleTapTimeout, kForwardMouseButton, PointerDeviceKind, PointerScrollEvent;
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:path/path.dart' as p;
|
||
|
||
import '../app/app_controller.dart';
|
||
import '../app/copy_center.dart';
|
||
import '../app/live_sync.dart';
|
||
import '../app/settings.dart' show FolderBookmark;
|
||
import '../app/transfer_job.dart';
|
||
import '../core/selection.dart';
|
||
import '../core/secret_gate.dart';
|
||
import '../core/sync_tools.dart';
|
||
import '../core/file_ops.dart';
|
||
import '../core/playlist.dart' show isAudioFile, isVideoFile;
|
||
import '../core/vfs.dart';
|
||
import '../core/sftp.dart' show 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 '../platform/android/hwp_pdf_ffi.dart' show RhwpEngine;
|
||
import 'app_actions.dart';
|
||
import 'explorer_look.dart';
|
||
import 'key_hint.dart';
|
||
import 'monitor_page.dart';
|
||
import 'player_page.dart';
|
||
import 'rsync_setup.dart';
|
||
import 'theme.dart';
|
||
import 'file_error.dart';
|
||
import 'folder_picker.dart' show pickFolderOrDav;
|
||
import 'trash_page.dart';
|
||
import 'dav_external.dart';
|
||
import '../platform/windows/recycle_bin.dart';
|
||
import '../core/sync_preview.dart';
|
||
import 'path_label.dart';
|
||
import 'progress_dialog.dart';
|
||
import 'sync_preview_view.dart';
|
||
import 'sftp_settings.dart';
|
||
import 'ftp_settings.dart';
|
||
import 'smb_settings.dart';
|
||
import 'webdav_settings.dart';
|
||
import 'reader_page.dart';
|
||
import '../app/component_store.dart';
|
||
import 'zip_page.dart';
|
||
import '../core/reader_sources.dart';
|
||
import '../core/hwp_source.dart';
|
||
import '../l10n/tr.dart';
|
||
import 'explorer_bookmarks.dart';
|
||
import 'explorer_search.dart';
|
||
import 'remote_thumb_view.dart';
|
||
import 'tail_text.dart';
|
||
import '../core/thumb_cache.dart' show thumbMemoryForgetters;
|
||
import '../core/shizuku.dart';
|
||
import 'vault_page.dart' show openVault;
|
||
import 'app_manager_page.dart' show openAppManager;
|
||
import 'dlna_page.dart' show openDlna;
|
||
import 'explorer_keys.dart' show defaultExplorerKeys, effectiveExplorerKeys;
|
||
import 'sqlite_viewer.dart' show openSqliteViewer, sqliteExtensions;
|
||
import 'quick_look.dart';
|
||
import 'file_tools.dart' show showFileHash, showFolderProperties;
|
||
import '../core/file_attributes.dart' show readFileAttributes;
|
||
import 'explorer_drag.dart';
|
||
import 'archive_ui.dart';
|
||
import 'explorer_grid.dart';
|
||
import 'text_viewer.dart';
|
||
import 'storage_page.dart';
|
||
import 'wifi_share_page.dart';
|
||
import '../core/wifi_share.dart' show WifiShare;
|
||
import '../core/archive_ops.dart' show archiveKindOf;
|
||
import 'batch_rename_dialog.dart';
|
||
import '../core/app_trash.dart';
|
||
import '../core/batch_rename.dart';
|
||
import 'notice.dart';
|
||
import 'confirm_dialog.dart';
|
||
import '../core/name_clash.dart';
|
||
import 'adaptive_row.dart';
|
||
import '../app/volumes.dart';
|
||
import '../core/explorer/tree_rows.dart';
|
||
import '../app/delete_ops.dart';
|
||
import '../app/transfer_plan.dart';
|
||
import '../core/file_clipboard.dart';
|
||
import '../app/archive_browse.dart';
|
||
import '../app/file_opener.dart';
|
||
import '../core/app_hidden.dart';
|
||
import '../app/rsync_runner.dart';
|
||
import 'explorer/rsync_run_dialog.dart';
|
||
import 'explorer/explorer_controller.dart';
|
||
import 'explorer/explorer_host.dart';
|
||
import 'explorer/explorer_pane.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 카드) 가 잘려 보였다
|
||
const double paneHeaderWideWidth = 480;
|
||
|
||
/// 191: Rsync 화면이 좌우 두 창 + 가운데 버튼 줄로 보이는 가장 작은 폭 (이보다 좁으면 창 하나씩)
|
||
const double rsyncTwoPaneMinWidth = 560;
|
||
|
||
/// 189: 트리 화살표 칸의 폭 - 손가락으로 누르는 기기 (Android · iOS) 는 넓게 (줄 높이와 함께 누르는 영역)
|
||
double treeChevronWidth(TargetPlatform platform) =>
|
||
platform == TargetPlatform.android || platform == TargetPlatform.iOS ? 44 : 20;
|
||
|
||
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) => _ExplorerPageState.checkEnteredPath(input);
|
||
|
||
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();
|
||
}
|
||
|
||
/// 기능 버튼 (가운데 줄). 이름은 설정 (explorerButtons) 에 저장된다.
|
||
enum ExplorerButton {
|
||
up(Icons.arrow_upward, '상위 폴더'),
|
||
refresh(Icons.refresh, '새로 고침'),
|
||
select(Icons.checklist, '선택'),
|
||
// ── Rsync 화면에만 ──
|
||
toRight(Icons.arrow_forward, '좌 → 우'),
|
||
toLeft(Icons.arrow_back, '좌 ← 우'),
|
||
both(Icons.compare_arrows, '좌 ⇄ 우'),
|
||
monitor(Icons.monitor_heart_outlined, '모니터링'),
|
||
// ──
|
||
play(Icons.play_circle_outline, '재생'),
|
||
addMkv(Icons.playlist_add, 'MKV 목록에 추가'),
|
||
newFolder(Icons.create_new_folder_outlined, '새 폴더'),
|
||
rename(Icons.drive_file_rename_outline, '이름 변경'),
|
||
copy(Icons.copy_outlined, '복사'),
|
||
move(Icons.drive_file_move_outline, '이동'),
|
||
paste(Icons.content_paste, '붙여넣기'),
|
||
delete(Icons.delete_outline, '삭제'),
|
||
search(Icons.search, '찾기'),
|
||
sort(Icons.sort, '정렬 기준'),
|
||
hidden(Icons.visibility_outlined, '숨은 항목 표시'),
|
||
history(Icons.history, '내역'),
|
||
// 73: 즐겨찾기 (지금 폴더 넣기 · 한 번에 가기, Rsync 는 짝)
|
||
bookmarks(Icons.star_outline, '즐겨찾기'),
|
||
// 118: 두 창을 좌우 ⇆ / 위아래 ⇅ 로 한 번에 바꾸기 (한 창이면 두 창 (좌우) 으로)
|
||
orient(Icons.swap_horiz, '좌우 ⇆ / 위아래 ⇅'),
|
||
layout(Icons.view_quilt_outlined, '창 배치'),
|
||
buttons(Icons.tune, '버튼 구성'),
|
||
// 87: 지금 폴더 (드라이브) 의 큰 폴더 · 큰 파일
|
||
storage(Icons.pie_chart_outline, '저장 공간 분석'),
|
||
// 89: 지금 폴더를 같은 Wi-Fi 의 다른 기기 브라우저에서 보기 · 받기 (읽기 전용)
|
||
wifiShare(Icons.wifi_tethering, 'Wi-Fi 공유'),
|
||
// X-plore 비교 e: 휴지통 (Android 앱 휴지통 · Windows 는 시스템 휴지통 열기)
|
||
trash(Icons.restore_from_trash_outlined, '휴지통'),
|
||
// X-plore 비교 5-2 (사용자 결정 10/21): 앱 관리자 (Android 만)
|
||
apps(Icons.apps, '앱 관리자'),
|
||
// X-plore 비교 5-2 (사용자 결정 10/21): 암호 보관함 (환경 설정 > 보안 에서 켰을 때 열림)
|
||
vault(Icons.enhanced_encryption_outlined, '암호 보관함');
|
||
|
||
final IconData icon;
|
||
final String label;
|
||
const ExplorerButton(this.icon, this.label);
|
||
|
||
/// Rsync 화면에만 있는 버튼 (파일 탐색기 버튼 구성에서는 빼기)
|
||
bool get rsyncOnly => this == toRight || this == toLeft || this == both || this == monitor;
|
||
|
||
/// 파일 탐색기에서 고를 수 있는 버튼
|
||
static List<ExplorerButton> get explorerValues => [for (final b in values) if (!b.rsyncOnly && (b != apps || appsButtonSupported())) b];
|
||
|
||
/// 앱 관리자 버튼을 쓰는 기기 (Android - 시험에서 바꿈)
|
||
static bool Function() appsButtonSupported = () => Platform.isAndroid;
|
||
|
||
/// Rsync 화면의 버튼 (늘 이대로, 두 창 사이)
|
||
// 208: 방향 실행 (Rsync 화면의 핵심) 을 앞에 - 좁아서 [더 보기] 로 가는 것은 뒤쪽
|
||
static const rsyncButtons = [toRight, toLeft, both, monitor, up, refresh, newFolder, bookmarks];
|
||
|
||
/// 147 (관리자): 처음 값 (버튼 구성을 바꾸지 않았을 때) 은 자주 쓰는 것부터 - 좁은 화면에서 [더 보기] 로 가는 것은 뒤쪽
|
||
static List<ExplorerButton> get defaultOrder {
|
||
const first = [copy, move, delete, newFolder, rename, select, search, bookmarks, orient];
|
||
return [...first, for (final b in explorerValues) if (!first.contains(b)) b];
|
||
}
|
||
|
||
static List<ExplorerButton> fromSettings(List<String> names) {
|
||
final out = [
|
||
for (final n in names)
|
||
for (final b in explorerValues)
|
||
if (b.name == n) b,
|
||
];
|
||
// 사용자가 정한 순서는 그대로 (비어 있을 때만 처음 값)
|
||
return out.isEmpty ? defaultOrder : out;
|
||
}
|
||
}
|
||
|
||
class _ExplorerPageState extends State<ExplorerPage> implements ExplorerHost {
|
||
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(_Thumbs.forget)
|
||
..add(_BigThumbs.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);
|
||
}
|
||
_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 (_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)) {
|
||
_adoptJob(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('허용했으면 다시 확인'))),
|
||
],
|
||
);
|
||
}
|
||
|
||
/// 목록 위 안내들 (없으면 null). 좁은 화면 (접은 폴드 · 두 창) 에서 목록이 밀려 사라지지 않게 높이를 제한한다
|
||
Widget? _noticeArea(List<Widget?> notices, double paneHeight) {
|
||
final list = notices.whereType<Widget>().toList();
|
||
if (list.isEmpty) return null;
|
||
return ConstrainedBox(
|
||
constraints: BoxConstraints(maxHeight: paneHeight.isFinite ? paneHeight * 0.35 : 240),
|
||
child: SingleChildScrollView(child: Column(mainAxisSize: MainAxisSize.min, children: list)),
|
||
);
|
||
}
|
||
|
||
/// 목록 위 안내 상자
|
||
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: () => _editFtp(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: () => _editSftp(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
|
||
? _editSftp(pane, pane.root)
|
||
: smb != null
|
||
? _editSmb(pane, pane.root)
|
||
: ftp != null
|
||
? _editFtp(pane, pane.root)
|
||
: _editDav(pane, pane.root),
|
||
child: Text(tr('서버 설정 고치기'))),
|
||
],
|
||
);
|
||
}
|
||
|
||
/// 77: [경로 복사] 에 넣을 것 - 실제 경로 (Android 도 "SD 카드 ›" 가 아닌 /storage/…), WebDAV 는 서버 주소 (아이디 · 비밀번호는 빼고)
|
||
String _copyablePath(String path) {
|
||
if (!isDav(path)) return path;
|
||
final d = DavPath.parse(path);
|
||
// 88: SFTP 는 sftp://아이디@서버:포트/경로 (시작 폴더를 비웠으면 홈 기준 ~/)
|
||
if (d.isSftp) {
|
||
final s = SftpRegistry.server(d.server);
|
||
if (s == null) return _ctl.displayPath(path);
|
||
final base = s.root.isEmpty ? '/~' : (s.root.startsWith('/') ? s.root : '/${s.root}');
|
||
return 'sftp://${s.user.isEmpty ? '' : '${s.user}@'}${s.host}${s.port == 22 ? '' : ':${s.port}'}$base${d.isRoot ? '' : d.rel}';
|
||
}
|
||
// l: FTP 는 ftp://아이디@서버:포트/경로 (암호화 990 은 ftps://, 비밀번호 없음)
|
||
if (d.isFtp) {
|
||
final s = FtpRegistry.server(d.server);
|
||
if (s == null) return _ctl.displayPath(path);
|
||
final implicit = s.security == 'implicit';
|
||
final r = s.root.trim().replaceAll(RegExp(r'^/+|/+$'), '');
|
||
return '${implicit ? 'ftps' : 'ftp'}://${s.user.isEmpty ? '' : '${Uri.encodeComponent(s.user)}@'}${s.host}'
|
||
'${s.port == (implicit ? 990 : 21) ? '' : ':${s.port}'}${r.isEmpty ? '' : '/$r'}${d.isRoot ? '' : d.rel}';
|
||
}
|
||
// 88: SMB 는 smb://서버/공유/경로 (아이디 · 비밀번호 없음)
|
||
if (d.isSmb) {
|
||
final s = SmbRegistry.server(d.server);
|
||
if (s == null) return _ctl.displayPath(path);
|
||
final r = s.root.trim().replaceAll(r'\', '/').replaceAll(RegExp(r'^/+|/+$'), '');
|
||
return 'smb://${s.host}${s.port == 445 ? '' : ':${s.port}'}/${s.share}${r.isEmpty ? '' : '/$r'}${d.isRoot ? '' : d.rel}';
|
||
}
|
||
try {
|
||
return DavRegistry.client(d.server).uriOf(d.rel).replace(userInfo: '').toString();
|
||
} catch (_) {
|
||
return _ctl.displayPath(path);
|
||
}
|
||
}
|
||
|
||
/// 화면에 보일 경로 (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));
|
||
|
||
/// 한 번 누르면 선택 · 두 번 누르면 열기 (아니면 한 번에 바로 열기)
|
||
bool get _selectMode => c.settings.explorerClick != 'open';
|
||
|
||
/// 트리 줄. [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();
|
||
}
|
||
}
|
||
|
||
Widget _hiddenToggleRow(TreeRow row) {
|
||
final look = _ctl.look;
|
||
final label = _ctl.showAppHidden ? trf('숨긴 항목 {0}개 다시 숨기기', [row.count]) : trf('숨긴 항목 {0}개 보이기', [row.count]);
|
||
return Semantics(
|
||
button: true,
|
||
label: label,
|
||
excludeSemantics: true,
|
||
onTap: _ctl.toggleAppHidden,
|
||
child: InkWell(
|
||
onTap: _ctl.toggleAppHidden,
|
||
child: Row(children: [
|
||
SizedBox(width: treeChevronWidth(defaultTargetPlatform) + 4),
|
||
Icon(_ctl.showAppHidden ? Icons.visibility_off_outlined : Icons.visibility_outlined, size: look.iconSize * 0.8, color: JjColors.textDim),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Text(label,
|
||
maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: look.fontSize - 1, color: JjColors.textDim)),
|
||
),
|
||
]),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// X-plore 비교 c: 트리 맨 위 항목 줄 (다른 저장 장치 · 서버 · 즐겨찾기) - 누르면 그곳으로, 즐겨찾기 묶음은 펼치기 · 접기
|
||
Widget _extraRow(ExplorerPane pane, TreeRow row) {
|
||
if (row.kind == TreeRow.hiddenToggle) return _hiddenToggleRow(row);
|
||
final look = _ctl.look;
|
||
final path = row.entry.path;
|
||
final volume = _ctl.volumes.firstWhere((v) => samePath(v.$1, path), orElse: () => (path, vBasename(path))).$2;
|
||
final fav = row.kind == TreeRow.favorite
|
||
? c.settings.folderBookmarks.firstWhere((b) => b.path == path, orElse: () => FolderBookmark(name: vBasename(path), path: path))
|
||
: null;
|
||
final (IconData icon, Color color, String label, bool? open) = switch (row.kind) {
|
||
TreeRow.otherRoot => (
|
||
isDav(path)
|
||
? Icons.cloud_outlined
|
||
: Platform.isWindows
|
||
? Icons.storage
|
||
: Icons.sd_storage_outlined,
|
||
JjColors.accent,
|
||
volume,
|
||
false,
|
||
),
|
||
TreeRow.favorites => (Icons.star, JjColors.amber, tr('즐겨찾기'), pane.favoritesOpen),
|
||
_ => (Icons.star_outline, JjColors.amber, fav!.name, null),
|
||
};
|
||
void tap() {
|
||
setState(() => _ctl.active = _ctl.panes.indexOf(pane));
|
||
if (row.kind == TreeRow.favorites) {
|
||
pane.favoritesOpen = !pane.favoritesOpen;
|
||
pane.changed();
|
||
return;
|
||
}
|
||
unawaited(_ctl.goTo(pane, path, fresh: true).then((_) => _reveal(pane, path)));
|
||
}
|
||
|
||
return Semantics(
|
||
button: true,
|
||
label: label,
|
||
excludeSemantics: true,
|
||
onTap: tap,
|
||
child: InkWell(
|
||
onTap: tap,
|
||
child: Row(children: [
|
||
SizedBox(width: look.indent * row.depth + 4),
|
||
SizedBox(
|
||
width: treeChevronWidth(defaultTargetPlatform),
|
||
child: open == null ? null : Icon(open ? Icons.expand_more : Icons.chevron_right, size: 18, color: JjColors.textDim),
|
||
),
|
||
Icon(icon, size: look.iconSize * 0.8, color: color),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Column(mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Text(label, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: look.fontSize)),
|
||
if (fav != null)
|
||
Text(_ctl.displayPath(path),
|
||
maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: look.fontSize - 3, color: JjColors.textDim)),
|
||
]),
|
||
),
|
||
]),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ───────── 누르기 ─────────
|
||
|
||
/// 선택 모드 (X-plore 처럼): 각 줄의 ✔ 동그라미가 보이고, 누르면 고르기 · 풀기
|
||
bool _selecting = false;
|
||
|
||
void _setSelecting(bool on) => setState(() {
|
||
_selecting = on;
|
||
if (!on) {
|
||
for (final x in _ctl.panes) {
|
||
x.sel.clear();
|
||
}
|
||
}
|
||
});
|
||
|
||
/// 한 번 누르기.
|
||
/// - 선택 모드: 고르기 · 풀기 ([_toggleMark])
|
||
/// - 폴더: 바로 펼치기 · 접기 (트리), 들어가기 (오른쪽 파일 목록)
|
||
/// - 파일: 누르기 설정이 "선택" 이면 고르기만 (두 번 누르면 실행), "바로 실행" 이면 실행
|
||
/// Ctrl 을 누른 채면 선택 모드로 고르기. 목록의 ".." 은 늘 상위 폴더로.
|
||
/// X-plore 비교 d: 마지막으로 누른 폴더 · 시각 (두 번 누르기의 두 번째를 가리려고)
|
||
(String, DateTime)? _lastFolderTap;
|
||
|
||
Future<void> _onTap(ExplorerPane pane, TreeRow row, {bool list = false}) async {
|
||
final e = row.entry;
|
||
setState(() => _ctl.active = _ctl.panes.indexOf(pane));
|
||
if (widget.rsync && !row.isUp) {
|
||
await _pick(pane, e);
|
||
return;
|
||
}
|
||
final real = !row.isRoot && !row.isUp;
|
||
// 74: Shift + 누르기 = 마지막으로 표시한 것부터 여기까지 (PC)
|
||
if (real && HardwareKeyboard.instance.isShiftPressed && _canRange(pane, e)) {
|
||
if (!_selecting) setState(() => _selecting = true);
|
||
_markRange(pane, e);
|
||
return;
|
||
}
|
||
// PC: Ctrl + 누르기 = 선택 모드로 고르기
|
||
if (real && HardwareKeyboard.instance.isControlPressed) {
|
||
if (!_selecting) setState(() => _selecting = true);
|
||
await _toggleMark(pane, e);
|
||
return;
|
||
}
|
||
// 폴더는 두 번 누르기를 기다리지 않는다 - 짧은 사이의 두 번째 누르기로 방금 펼친 것을 접지 않게
|
||
final now = widget.clock();
|
||
final last = _lastFolderTap;
|
||
_lastFolderTap = (e.path, now);
|
||
final repeat = last != null && samePath(last.$1, e.path) && now.difference(last.$2) < kDoubleTapTimeout * 2;
|
||
final act = ClickPolicy.decide(Press.tap, _kindOf(row),
|
||
selectMode: _selecting,
|
||
expanded: pane.expanded.contains(e.path),
|
||
list: list,
|
||
repeat: repeat,
|
||
tapOpens: !_selectMode,
|
||
retapCollapses: c.settings.explorerRetapCollapse);
|
||
await _act(pane, row, act, list: list);
|
||
}
|
||
|
||
/// 줄의 종류 ([ClickPolicy] 에 넘김)
|
||
ItemKind _kindOf(TreeRow row) => row.isUp
|
||
? ItemKind.up
|
||
: row.isRoot
|
||
? ItemKind.root
|
||
: row.entry.isDir
|
||
? ItemKind.folder
|
||
: ItemKind.file;
|
||
|
||
/// [ClickPolicy] 가 정한 일을 한다 (화면 쪽 동작만 - 규칙은 [ClickPolicy] · [Selection])
|
||
Future<void> _act(ExplorerPane pane, TreeRow row, ClickAction act, {bool list = false}) async {
|
||
final e = row.entry;
|
||
switch (act) {
|
||
case ClickAction.none:
|
||
case ClickAction.menu:
|
||
return;
|
||
case ClickAction.focus:
|
||
pane.focused = e.path;
|
||
if (!list) pane.current = vDirname(e.path);
|
||
pane.changed();
|
||
case ClickAction.open:
|
||
await _onOpen(pane, row, list: list);
|
||
case ClickAction.collapse:
|
||
await _toggle(pane, e);
|
||
case ClickAction.expand:
|
||
if (!pane.expanded.contains(e.path)) {
|
||
await _toggle(pane, e);
|
||
} else if (!samePath(pane.current, e.path)) {
|
||
// 펼쳐 둔 폴더 (다시 눌러 접기를 끈 설정): 그 폴더를 지금 폴더로
|
||
pane
|
||
..current = e.path
|
||
..focused = e.path
|
||
..changed();
|
||
_ctl.remember(pane, e.path);
|
||
}
|
||
case ClickAction.toggleMark:
|
||
if (!_selecting) setState(() => _selecting = true);
|
||
await _toggleMark(pane, e);
|
||
}
|
||
}
|
||
|
||
/// 표시되었는지: 자기 자신, 또는 위 폴더가 표시되었으면 (폴더를 고르면 안의 것도 모두 고른 것).
|
||
/// Rsync 화면은 고른 그 폴더만.
|
||
bool _isMarked(ExplorerPane pane, String path) => pane.sel.isMarked(path);
|
||
|
||
/// [Selection] 이 폴더를 나눌 때 안의 경로 (읽어 둔 목록, 없으면 읽음)
|
||
Future<List<String>> _childPaths(ExplorerPane pane, String dir) async {
|
||
await _ctl.load(pane, dir);
|
||
return [for (final x in pane.cache[dir] ?? const <FileEntry>[]) x.path];
|
||
}
|
||
|
||
/// Rsync 화면: 창마다 폴더 하나만 고른다 (다시 누르면 취소, 규칙 8). 고르면 펼쳐서 안의 폴더가 보이게.
|
||
Future<void> _pick(ExplorerPane pane, FileEntry e) async {
|
||
if (!e.isDir) return;
|
||
await pane.sel.toggle(e.path, isDir: true, list: (d) => _childPaths(pane, d));
|
||
if (!mounted) return;
|
||
setState(() {});
|
||
if (!pane.sel.has(e.path)) return;
|
||
if (!pane.expanded.contains(e.path)) {
|
||
await _toggle(pane, e);
|
||
} else {
|
||
pane
|
||
..current = e.path
|
||
..focused = e.path
|
||
..changed();
|
||
}
|
||
}
|
||
|
||
/// 74: 범위 고르기를 할 수 있는지 - 마지막으로 표시한 것이 같은 폴더에 있고 다른 항목
|
||
bool _canRange(ExplorerPane pane, FileEntry e) {
|
||
final a = pane.sel.anchor;
|
||
return a != null && !samePath(a, e.path) && samePath(vDirname(a), vDirname(e.path)) && _isMarked(pane, a);
|
||
}
|
||
|
||
/// 76: [dir] 에서 지금 보이는 것 (빠른 필터에 맞는 것)
|
||
/// X-plore 숨기기: 목록에서 숨긴 것은 [숨긴 항목 보이기] 를 켰을 때만
|
||
List<FileEntry> _shown(ExplorerPane pane, String dir) => [
|
||
for (final x in pane.cache[dir] ?? const <FileEntry>[])
|
||
if (passesFilter(pane, x) && (_ctl.showAppHidden || !_ctl.appHidden(x.path))) x
|
||
];
|
||
|
||
/// 74: 마지막으로 표시한 것부터 [to] 까지 (보이는 순서대로) 모두 표시
|
||
void _markRange(ExplorerPane pane, FileEntry to) => setState(() {
|
||
pane.sel.range([for (final x in _shown(pane, vDirname(to.path))) x.path], to.path);
|
||
pane.focused = to.path;
|
||
});
|
||
|
||
/// 74: 고르기 도구 - 이 폴더 ([dir]) 의 항목에서
|
||
void _invertMarks(ExplorerPane pane, String dir) =>
|
||
setState(() => pane.sel.invert([for (final x in _shown(pane, dir)) x.path]));
|
||
|
||
void _markWhere(ExplorerPane pane, String dir, bool Function(FileEntry e) test) =>
|
||
setState(() => pane.sel.addAll([for (final x in _shown(pane, dir)) if (test(x)) x.path]));
|
||
|
||
Future<void> _markPattern(ExplorerPane pane, String dir) async {
|
||
final pattern = await showDialog<String>(context: context, builder: (_) => const _PatternDialog());
|
||
if (pattern == null || pattern.trim().isEmpty) return;
|
||
final re = globToRegExp(pattern);
|
||
final before = pane.sel.marked.length;
|
||
_markWhere(pane, dir, (x) => re.hasMatch(vBasename(x.path)));
|
||
if (pane.sel.marked.length == before) _snack(trf('"{0}" 에 맞는 항목이 없습니다', [pattern.trim()]));
|
||
}
|
||
|
||
/// 선택 모드에서 누르기 (규칙 5 · 6 은 [Selection.toggle]). 고른 폴더는 펼쳐서 안의 것도 고른 모양으로 보여 준다.
|
||
Future<void> _toggleMark(ExplorerPane pane, FileEntry e) async {
|
||
pane.focused = e.path;
|
||
try {
|
||
await pane.sel.toggle(e.path, isDir: e.isDir, list: (d) => _childPaths(pane, d));
|
||
} on FileSystemException catch (err) {
|
||
_snack(explainFileError('$err', dav: isDav(e.path)).$1);
|
||
}
|
||
if (!mounted) return;
|
||
setState(() {});
|
||
if (e.isDir && pane.sel.has(e.path) && !pane.expanded.contains(e.path)) {
|
||
await _ctl.load(pane, e.path);
|
||
if (mounted) setState(() => pane.expanded.add(e.path));
|
||
}
|
||
}
|
||
|
||
/// 열기 (파일: 선택 설정이면 두 번 누르기, 바로 실행이면 한 번 누르기 / 폴더: 한 번 누르기):
|
||
/// 트리의 폴더는 펼치기 · 접기, 목록의 폴더는 들어가기 (왼쪽 트리도 따라감), 파일은 실행.
|
||
Future<void> _onOpen(ExplorerPane pane, TreeRow row, {bool list = false}) async {
|
||
final e = row.entry;
|
||
setState(() => _ctl.active = _ctl.panes.indexOf(pane));
|
||
pane.focused = e.path;
|
||
if (row.isUp || (e.isDir && list)) {
|
||
await _ctl.goTo(pane, e.path);
|
||
return;
|
||
}
|
||
if (e.isDir) {
|
||
// 189: 이름을 누르면 그 폴더로 (닫혀 있으면 펼친다). 이름으로는 접지 않는다 - 다시 누르거나 두 번 누르면 접혀
|
||
// 아래 줄이 움직여 다른 것을 누르게 된다. 접기 · 펼치기는 › 화살표로 (탐색기 · Total Commander 처럼)
|
||
if (!pane.expanded.contains(e.path)) {
|
||
await _toggle(pane, e);
|
||
} else if (!samePath(pane.current, e.path)) {
|
||
pane
|
||
..current = e.path
|
||
..changed();
|
||
_ctl.remember(pane, e.path);
|
||
}
|
||
return;
|
||
}
|
||
if (!list) pane.current = vDirname(e.path);
|
||
pane.changed();
|
||
await _open(e.path, pane: pane);
|
||
}
|
||
|
||
/// 트리 폴더 펼치기 · 접기 (› 화살표 · 열기)
|
||
Future<void> _toggle(ExplorerPane pane, FileEntry e) async {
|
||
setState(() => _ctl.active = _ctl.panes.indexOf(pane));
|
||
// X-plore 비교 f: 압축 파일을 폴더처럼 펼치기 (읽기 전용)
|
||
if (_canBrowseArchive(e) && !pane.expanded.contains(e.path)) {
|
||
if (!await _ctl.readArchive(e.path)) return;
|
||
pane.cache.remove(e.path);
|
||
pane.expanded.add(e.path);
|
||
await _ctl.load(pane, e.path);
|
||
return;
|
||
}
|
||
if (pane.expanded.contains(e.path)) {
|
||
pane.expanded.remove(e.path);
|
||
// 접은 폴더 안이 지금 폴더였으면 접은 폴더로
|
||
if (isSameOrInside(pane.current, e.path)) pane.current = e.path;
|
||
pane.changed();
|
||
return;
|
||
}
|
||
pane
|
||
..expanded.add(e.path)
|
||
..current = e.path
|
||
..focused = e.path;
|
||
await _ctl.load(pane, e.path);
|
||
if (pane.listScroll.hasClients) pane.listScroll.jumpTo(0);
|
||
_ctl.remember(pane, e.path);
|
||
}
|
||
|
||
/// 다른 앱으로 열지 못했을 때: 음악 · 동영상이면 내장 플레이어를 알려 준다 (29)
|
||
String _noAppMessage(String path) => isAudioFile(path) || isVideoFile(path)
|
||
? tr('이 형식을 여는 앱이 없습니다. 길게 누르거나 오른쪽 클릭 → [내장 플레이어로 재생] 으로 들어 보세요.')
|
||
: tr('이 파일을 열 수 있는 앱이 없습니다.');
|
||
|
||
/// 파일 열기 (기본 동작): 동영상은 내장 플레이어 (또는 확장자별 프로그램), 그 밖은 기본 연결 프로그램
|
||
Future<void> _open(String path, {ExplorerPane? pane}) async {
|
||
// X-plore 비교 f: 압축 안의 파일 - 그 파일만 임시 폴더로 꺼내 연다
|
||
if (_insideArchive(path)) {
|
||
final local = await _extractOne(path);
|
||
if (local == null || !mounted) return;
|
||
path = local;
|
||
}
|
||
// 여는 길은 file_opener (E5c) 가 고른다
|
||
switch (openRouteFor(path,
|
||
components: c.settings.components,
|
||
imageExts: c.settings.imageExts,
|
||
android: Platform.isAndroid,
|
||
windows: Platform.isWindows,
|
||
hwpEngine: RhwpEngine.available)) {
|
||
case OpenRoute.player:
|
||
return _play([path]);
|
||
case OpenRoute.extractArchive:
|
||
return _extractArchive(path, pane ?? _ctl.pane);
|
||
case OpenRoute.images:
|
||
return _viewImages(path, pane);
|
||
case OpenRoute.pdf:
|
||
return _viewPdf(path);
|
||
case OpenRoute.zip:
|
||
return _openZip(path, pane);
|
||
// 관리자 지시 (10/20): Android HWP · HWPX 는 앱 안에서 PDF 로 바꿔 본다. 못 열면 이유를 알리고 다른 앱으로
|
||
case OpenRoute.hwp:
|
||
if (await _viewHwp(path)) return;
|
||
case OpenRoute.docPreview:
|
||
return _viewDoc(path);
|
||
case OpenRoute.external:
|
||
}
|
||
final local = await _fetch(path);
|
||
if (local == null) return;
|
||
final ok = await c.services.shell.openWith(local);
|
||
if (ok || !mounted) return;
|
||
// X-plore 비교 h: 글 파일을 열 앱이 없으면 앱 안의 글 뷰어로
|
||
if (textExtensions.contains(extOf(path))) return _viewText(local);
|
||
_snack(_noAppMessage(path));
|
||
}
|
||
|
||
/// X-plore 비교 h: 글 뷰어 (WebDAV 등은 받은 뒤)
|
||
Future<void> _viewText(String path) async {
|
||
final local = await _fetch(path);
|
||
if (local == null || !mounted) return;
|
||
await Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => TextViewerPage(path: local)));
|
||
}
|
||
|
||
/// X-plore 비교 g: 폴더의 그림 · 동영상을 격자로. 길게 누른 것은 탐색기에서 그 자리로
|
||
Future<void> _grid(ExplorerPane pane, String dir) async {
|
||
await _ctl.load(pane, dir);
|
||
if (!mounted) return;
|
||
final list = _shown(pane, dir);
|
||
final at = await Navigator.of(context).push<String>(MaterialPageRoute(
|
||
builder: (_) => ExplorerGridPage(
|
||
title: vBasename(dir).isEmpty ? dir : vBasename(dir),
|
||
entries: list,
|
||
imageExts: c.settings.imageExts,
|
||
videoThumb: (e) => _BigThumbs.of(c, e),
|
||
onOpen: (e) => _open(e.path, pane: pane),
|
||
),
|
||
));
|
||
if (at == null || !mounted) return;
|
||
await _ctl.goTo(pane, dir);
|
||
pane.focused = at;
|
||
pane.changed();
|
||
}
|
||
|
||
/// WebDAV 파일은 열거나 재생하려면 임시 폴더로 받는다 (dav_external.dart)
|
||
Future<String?> _fetch(String path) => fetchDav(context, c, path);
|
||
|
||
/// 동영상 재생 (WebDAV 는 받지 않고 바로 스트리밍)
|
||
Future<void> _play(List<String> paths, {bool internal = false, bool keepOrder = false}) async {
|
||
if (!mounted || paths.isEmpty) return;
|
||
await playFiles(context, c, paths, internal: internal, keepOrder: keepOrder);
|
||
}
|
||
|
||
/// 그림 보기: 같은 폴더의 그림을 지금 정렬 순서대로 넘겨 본다
|
||
Future<void> _viewImages(String path, ExplorerPane? pane) async {
|
||
final dir = vDirname(path);
|
||
// 폴더를 읽지 못하면 (E10: 이제 오류로 알림) 고른 그림 하나만
|
||
List<FileEntry> list;
|
||
try {
|
||
list = pane?.cache[dir] ?? _ctl.sorted(await listEntries(dir, showHidden: c.settings.explorerShowHidden), dir);
|
||
} catch (_) {
|
||
list = const [];
|
||
}
|
||
final images = [
|
||
for (final e in list)
|
||
if (!e.isDir && c.settings.imageExts.contains(extOf(e.path))) e.path,
|
||
];
|
||
if (!images.any((x) => samePath(x, path))) images.insert(0, path);
|
||
final start = images.indexWhere((x) => samePath(x, path));
|
||
final temp = await c.services.storage.tempDirectory();
|
||
if (!mounted) return;
|
||
await openReader(context, c, ImageFilesSource(images, tempDir: temp, title: vBasename(dir)), start: start);
|
||
}
|
||
|
||
/// X-plore 맞추기 (관리자 지시 10/20): 폴더의 그림 (지금 정렬 순서) 을 보기 화면으로 열고 [계속 보기] 를 켠 채 시작
|
||
Future<void> _slideshow(ExplorerPane pane, String dir) async {
|
||
List<FileEntry> list;
|
||
try {
|
||
list = pane.cache[dir] ?? _ctl.sorted(await listEntries(dir, showHidden: c.settings.explorerShowHidden), dir);
|
||
} catch (e) {
|
||
_snack(trf('폴더를 읽을 수 없습니다: {0}', [e]));
|
||
return;
|
||
}
|
||
final images = [
|
||
for (final e in list)
|
||
if (!e.isDir && c.settings.imageExts.contains(extOf(e.path)) && (_ctl.showAppHidden || !_ctl.appHidden(e.path))) e.path,
|
||
];
|
||
if (images.isEmpty) {
|
||
_snack(tr('이 폴더에는 그림이 없습니다'));
|
||
return;
|
||
}
|
||
final temp = await c.services.storage.tempDirectory();
|
||
if (!mounted) return;
|
||
await openReader(context, c, ImageFilesSource(images, tempDir: temp, title: vBasename(dir)), autoPlay: true);
|
||
}
|
||
|
||
/// 문서 → PDF (진행 창) → 보기
|
||
Future<void> _viewDoc(String path) async {
|
||
final temp = await c.services.storage.tempDirectory();
|
||
if (!mounted) return;
|
||
final dialog = showProgressDialog(context, trf('PDF 로 바꾸는 중: {0}', [vBasename(path)]));
|
||
String? pdf;
|
||
Object? error;
|
||
try {
|
||
final local = await vLocalCopy(path, temp);
|
||
pdf = await ComponentStore.shared.convertToPdf(local, p.join(temp, 'jj_docs'));
|
||
} catch (e) {
|
||
error = e;
|
||
}
|
||
// 사용자가 [취소] · ← 로 그만두었으면 결과를 보이지 않는다 (바꾸기는 끝까지 돌았음)
|
||
if (dialog.cancelled()) return;
|
||
dialog.close();
|
||
await dialog.done;
|
||
if (pdf == null) {
|
||
_snack(trf('미리 볼 수 없습니다: {0}', [error]));
|
||
return;
|
||
}
|
||
PdfSource src;
|
||
try {
|
||
src = await PdfSource.open(pdf, tempDir: temp);
|
||
} catch (e) {
|
||
_snack(trf('PDF 를 열 수 없습니다: {0}', [e]));
|
||
return;
|
||
}
|
||
if (!mounted) return;
|
||
await openReader(context, c, src);
|
||
}
|
||
|
||
/// 관리자 지시 (10/20): HWP · HWPX → 보기 화면 (보는 쪽부터 몇 쪽씩 PDF 로 바꿈 - [HwpSource]). 열지 못하면 false
|
||
Future<bool> _viewHwp(String path) async {
|
||
final temp = await c.services.storage.tempDirectory();
|
||
if (!mounted) return true;
|
||
final dialog = showProgressDialog(context, trf('여는 중: {0}', [vBasename(path)]));
|
||
HwpSource? src;
|
||
Object? error;
|
||
try {
|
||
src = await HwpSource.open(path, engine: RhwpEngine(), tempDir: temp, fontDirs: RhwpEngine.fontDirs, log: c.note);
|
||
} catch (e) {
|
||
error = e;
|
||
}
|
||
// 사용자가 [취소] · ← 로 그만두었으면 연 문서를 닫고 끝 (다른 앱으로도 열지 않음)
|
||
if (dialog.cancelled()) {
|
||
await src?.dispose();
|
||
return true;
|
||
}
|
||
dialog.close();
|
||
await dialog.done;
|
||
if (src == null) {
|
||
_snack(trf('앱 안에서 열지 못해 다른 앱으로 엽니다: {0}', [error]));
|
||
return false;
|
||
}
|
||
if (!mounted) {
|
||
await src.dispose();
|
||
return true;
|
||
}
|
||
await openReader(context, c, src);
|
||
return true;
|
||
}
|
||
|
||
Future<void> _viewPdf(String path) async {
|
||
final temp = await c.services.storage.tempDirectory();
|
||
PdfSource src;
|
||
try {
|
||
src = await PdfSource.open(path, tempDir: temp);
|
||
} catch (e) {
|
||
_snack(trf('PDF 를 열 수 없습니다: {0}', [e]));
|
||
return;
|
||
}
|
||
if (!mounted) return;
|
||
await openReader(context, c, src);
|
||
await _ctl.refreshAll([vDirname(path)]);
|
||
}
|
||
|
||
/// ZIP: 환경 설정이 "만화 보기" 면 안의 그림을 바로 보고 (그림이 없으면 목록), 아니면 목록
|
||
/// 87: 저장 공간 분석 → 고른 것을 탐색기에서 그 자리로 (지우기는 탐색기의 지우기로만).
|
||
/// WebDAV 는 직접 시킬 때만 (이 메뉴) 하고, 서버에서 모두 읽어 오래 걸린다고 먼저 알린다
|
||
Future<void> _storage(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 _delete(pane, paths);
|
||
return [for (final x in paths) if (!await vExists(x)) x];
|
||
}, onCut: (paths) => _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 등 원격은 알림
|
||
Future<void> _wifiShare(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('끄기'))),
|
||
]),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 86: 압축 풀기 (검사 · 확인 · 진행 · 취소) 뒤 목록 새로 고침
|
||
Future<void> _extractArchive(String path, ExplorerPane pane) async {
|
||
final other = _dual ? _ctl.panes[1 - _ctl.panes.indexOf(pane)].current : null;
|
||
final out = await extractArchiveFlow(context, c, path, otherDir: other);
|
||
if (out != null && mounted) await _ctl.refreshAll([out, vDirname(out)]);
|
||
}
|
||
|
||
Future<void> _openZip(String path, ExplorerPane? pane) async {
|
||
try {
|
||
if (c.settings.zipComic && await openZipComic(context, c, path)) return;
|
||
} catch (e) {
|
||
_snack(trf('열 수 없습니다: {0}', [e]));
|
||
return;
|
||
}
|
||
if (!mounted) return;
|
||
final other = pane == null || !_dual ? null : _ctl.panes[1 - _ctl.panes.indexOf(pane)].current;
|
||
final out = await openZip(context, c, path, otherDir: other);
|
||
if (out != null) await _ctl.refreshAll([out, vDirname(out)]);
|
||
}
|
||
|
||
/// 고른 그림들을 한 장씩 PDF 로 (같은 폴더에, 이름을 묻는다)
|
||
Future<void> _imagesToPdf(List<String> images) async {
|
||
if (images.isEmpty) return;
|
||
final dir = vDirname(images.first);
|
||
final first = vBasename(images.first);
|
||
final name = await _askName(tr('PDF 로 만들기'), '${first.contains('.') ? first.substring(0, first.lastIndexOf('.')) : first}.pdf');
|
||
if (name == null || name.trim().isEmpty) return;
|
||
final out = vJoin(dir, name.toLowerCase().endsWith('.pdf') ? name : '$name.pdf');
|
||
final temp = await c.services.storage.tempDirectory();
|
||
try {
|
||
await imagesToPdf(images, out, tempDir: temp);
|
||
_snack(trf('그림 {0}장으로 PDF 를 만들었습니다: {1}', [images.length, vBasename(out)]));
|
||
} catch (e) {
|
||
_snack(trf('PDF 를 만들지 못했습니다: {0}', [e]));
|
||
}
|
||
await _ctl.refreshAll([dir]);
|
||
}
|
||
|
||
Future<void> _menu(ExplorerPane pane, FileEntry e, Offset at) async {
|
||
setState(() => _ctl.active = _ctl.panes.indexOf(pane));
|
||
pane.focused = e.path;
|
||
pane.changed();
|
||
final video = !e.isDir && isVideoFile(e.path);
|
||
final audio = !e.isDir && isAudioFile(e.path);
|
||
final dav = isDav(e.path);
|
||
// 그림 → PDF: 표시한 것 중 그림 (누른 것이 표시에 없으면 누른 것만)
|
||
final viewer = c.settings.components.contains('viewer');
|
||
final picked = pane.sel.has(e.path) ? pane.sel.marked : [e.path];
|
||
final images = [for (final x in picked) if (c.settings.imageExts.contains(extOf(x))) x];
|
||
final ext = extOf(e.path);
|
||
final viewable = viewer && !e.isDir &&
|
||
(c.settings.imageExts.contains(ext) || ext == 'pdf' || zipExtensions.contains(ext));
|
||
final dual = _dual;
|
||
// 77: 누른 것이 표시한 것 중 하나면 표시한 것 모두에 (복사 · 이동 · 삭제 · 공유 · 경로 복사)
|
||
final targets = _isMarked(pane, e.path) && pane.sel.marked.length > 1 ? pane.sel.marked : [e.path];
|
||
final many = targets.length > 1;
|
||
// 데이터 검토 (10/18): 펼친 압축 안의 것은 실제 파일이 아니다 - 그것으로 새 파일을 만들거나 내놓는 항목 (ZIP 묶기 · Wi-Fi 공유 · PDF) 은 숨긴다
|
||
final inArchive = targets.any(_insideArchive) || _insideArchive(e.path);
|
||
// 25: Rsync 화면은 폴더만 보이므로 폴더에 맞는 것만 (고르기 · 열기 · 이름 변경 · 삭제 · 정보)
|
||
final items = widget.rsync
|
||
? <(String, IconData, String)>[
|
||
('pickRsync', Icons.check_circle_outline,
|
||
_isMarked(pane, e.path) ? tr('고르기 취소') : tr('rsync 에 고르기')),
|
||
('open', Icons.folder_open, tr('열기')),
|
||
('openOther', Icons.vertical_split_outlined, tr('다른 창에서 열기')),
|
||
('rename', Icons.drive_file_rename_outline, tr('이름 변경')),
|
||
('delete', Icons.delete_outline, tr('삭제')),
|
||
if (!dav) ('reveal', Icons.folder_outlined, tr('파일 관리자에서 보기')),
|
||
('info', Icons.info_outline, tr('정보')),
|
||
]
|
||
: <(String, IconData, String)>[
|
||
// 77: 복사 · 이동 · 이름 변경 · 삭제 · 공유는 위의 아이콘 줄 ([_menuIcons]). 아래는 상황별 항목
|
||
// 74: 폰에서 범위 고르기 - 첫 항목을 표시한 뒤 마지막 항목을 길게 눌러
|
||
if (_selecting && _canRange(pane, e)) ('range', Icons.unfold_more, tr('여기까지 범위 고르기')),
|
||
if (e.isDir) ('open', Icons.folder_open, tr('열기')),
|
||
// 81: 빠른 보기 (PC 는 Space · TC 는 F3)
|
||
if (!e.isDir) ('quickLook', Icons.preview_outlined, tr('미리 보기')),
|
||
// 86: 압축 풀기 (ZIP · 7z · RAR) · ZIP 으로 묶기 (표시한 것 모두)
|
||
if (!e.isDir && archiveKindOf(e.path) != null) ('extract', Icons.unarchive_outlined, tr('압축 풀기')),
|
||
if (!dav && !inArchive) ('zip', Icons.archive_outlined, many ? trf('{0}개 ZIP 으로 묶기', [targets.length]) : tr('ZIP 으로 묶기')),
|
||
// 87: 저장 공간 분석 (이 폴더 아래의 큰 폴더 · 큰 파일)
|
||
if (e.isDir) ('storage', Icons.pie_chart_outline, tr('저장 공간 분석')),
|
||
// 89: 이 폴더를 Wi-Fi 로 공유 (이 기기의 폴더만)
|
||
if (e.isDir && !dav && !widget.rsync && !inArchive) ('wifiShare', Icons.wifi_tethering, tr('Wi-Fi 로 공유')),
|
||
// 80: 이 폴더 크기를 지금 계산 (설정이 "안 함" 이어도)
|
||
if (e.isDir) ('folderSize', Icons.data_usage, _ctl.sizes.busy(e.path) ? tr('폴더 크기 계산 멈추기') : tr('폴더 크기 계산')),
|
||
// X-plore P3: 폴더 속성 (안의 파일 · 폴더 수 · 크기 · 만든 · 바뀐 때) · 파일 해시 (MD5 · SHA-256) - 이 기기의 것만 (원격은 다 받아야 해서 뺌)
|
||
if (e.isDir && !dav) ('props', Icons.fact_check_outlined, tr('폴더 속성')),
|
||
if (!e.isDir && !dav) ('hash', Icons.tag, tr('해시 (MD5 · SHA-256)')),
|
||
if (e.isDir && dual) ('openOther', Icons.vertical_split_outlined, tr('다른 창에서 열기')),
|
||
if (e.isDir) ('playFolder', Icons.play_circle_outline, tr('이 폴더의 동영상 재생')),
|
||
// X-plore 맞추기 (관리자 지시 10/20): 이 폴더의 그림을 [계속 보기] 로
|
||
if (e.isDir && viewer) ('slideshow', Icons.slideshow_outlined, tr('사진 슬라이드쇼')),
|
||
// X-plore 비교 g: 그림 · 동영상을 썸네일 격자로
|
||
if (e.isDir && !dav) ('grid', Icons.grid_view, tr('격자로 보기 (그림 · 동영상)')),
|
||
if (video || audio) ('playInternal', Icons.play_circle_outline, tr('내장 플레이어로 재생')),
|
||
if (viewable) ('view', Icons.visibility_outlined, tr('보기')),
|
||
// X-plore 비교 h: 어떤 파일이든 글로 열어 보기 (읽기 전용)
|
||
if (!e.isDir && !video && !audio) ('text', Icons.article_outlined, tr('글 뷰어로 보기')),
|
||
// X-plore 비교 5-1 ④ (관리자 지시 10/21): SQLite (읽기 전용)
|
||
if (!e.isDir && !inArchive && sqliteExtensions.contains(ext)) ('sqlite', Icons.table_chart_outlined, tr('SQLite 보기 (읽기 전용)')),
|
||
// X-plore 비교 5-2 (사용자 결정 10/21): 암호 보관함에 넣기. 2단계 (관리자 지시 10/21): 폴더째 (안의 구조 그대로) · 원격 파일도 (받아서 넣음)
|
||
if (c.settings.vaultEnabled && !inArchive)
|
||
('vaultAdd', Icons.enhanced_encryption_outlined, many ? trf('{0}개 보관함에 넣기', [targets.length]) : tr('보관함에 넣기')),
|
||
if (!e.isDir) ('openWith', Icons.open_in_new, tr('다른 앱으로 열기')),
|
||
if (!e.isDir && !video) ('openDefault', Icons.launch, tr('기본 앱으로 열기')),
|
||
if (video && !dav) ('addMkv', Icons.playlist_add, tr('MKV 목록에 추가')),
|
||
if (viewer && images.isNotEmpty && !inArchive)
|
||
('toPdf', Icons.picture_as_pdf_outlined,
|
||
images.length == 1 ? tr('PDF 로 만들기') : trf('그림 {0}장을 PDF 로 만들기', [images.length])),
|
||
('select', Icons.check_circle_outline, tr('선택')),
|
||
// X-plore 숨기기: 목록에서만 숨긴다 (파일은 그대로) · 숨긴 것을 보이고 있으면 풀기
|
||
targets.every(_ctl.appHidden)
|
||
? ('appUnhide', Icons.visibility_outlined, many ? trf('{0}개 숨기기 풀기', [targets.length]) : tr('숨기기 풀기'))
|
||
: ('appHide', Icons.visibility_off_outlined, many ? trf('{0}개 목록에서 숨기기', [targets.length]) : tr('목록에서 숨기기')),
|
||
// X-plore 맞추기 (관리자 지시 10/20): 폴더에 .nomedia 를 두면 갤러리 · 음악 앱이 그 안의 그림 · 동영상 · 음악을 보이지 않는다 (Android)
|
||
if (e.isDir && !dav && !inArchive && (ExplorerPage.debugNomedia ?? Platform.isAndroid))
|
||
File(p.join(e.path, '.nomedia')).existsSync()
|
||
? ('nomediaOff', Icons.photo_library_outlined, tr('갤러리 · 음악 앱에 다시 보이기 (.nomedia 지우기)'))
|
||
: ('nomediaOn', Icons.hide_image_outlined, tr('갤러리 · 음악 앱에서 숨기기 (.nomedia)')),
|
||
// 두 창이면 아이콘 줄의 복사 · 이동은 다른 창으로 - 담아 두었다가 원하는 폴더에 붙여넣기는 여기
|
||
if (dual) ('clipCopy', Icons.content_copy, tr('복사 (붙여넣기로)')),
|
||
if (dual) ('clipMove', Icons.content_cut, tr('이동 (붙여넣기로)')),
|
||
if (_clip.isNotEmpty)
|
||
('paste', Icons.content_paste, trf('{0} 에 붙여넣기 ({1}개)', [vBasename(e.isDir ? e.path : vDirname(e.path)), _clip.items.length])),
|
||
('copyPath', Icons.link, many ? trf('경로 복사 ({0}개)', [targets.length]) : tr('경로 복사')),
|
||
if (e.isDir && !c.settings.folderBookmarks.any((b) => !b.isPair && samePath(b.path, e.path)))
|
||
('bookmark', Icons.star_outline, tr('즐겨찾기에 추가')),
|
||
if (!dav && (!Platform.isAndroid || e.isDir)) ('reveal', Icons.folder_outlined, tr('파일 관리자에서 보기')),
|
||
('info', Icons.info_outline, tr('정보')),
|
||
];
|
||
// 77: 위의 아이콘 줄 (X-plore · One Commander 처럼 자주 쓰는 것을 한 줄에)
|
||
final icons = widget.rsync
|
||
? const <(String, IconData, String, String, bool)>[]
|
||
: <(String, IconData, String, String, bool)>[
|
||
(dual ? 'copy' : 'clipCopy', Icons.copy_outlined, tr('복사'), dual ? tr('다른 창으로 복사') : tr('복사 (붙여넣기로)'), true),
|
||
(dual ? 'move' : 'clipMove', Icons.drive_file_move_outline, tr('이동'), dual ? tr('다른 창으로 이동') : tr('이동 (붙여넣기로)'), true),
|
||
// 78: 여럿이면 일괄 이름 바꾸기
|
||
('rename', Icons.drive_file_rename_outline, tr('이름 변경'), many ? trf('{0}개 이름 일괄 바꾸기', [targets.length]) : tr('이름 변경'), true),
|
||
('delete', Icons.delete_outline, tr('삭제'), many ? trf('{0}개 삭제', [targets.length]) : tr('삭제'), true),
|
||
if (c.services.shell.canShare) ('share', Icons.share_outlined, tr('공유'), tr('공유 (다른 앱으로 보내기)'), targets.any((x) => !vIsDirSync(x))),
|
||
];
|
||
final size = MediaQuery.sizeOf(context);
|
||
final pick = await showMenu<String>(
|
||
context: context,
|
||
position: RelativeRect.fromLTRB(at.dx, at.dy, size.width - at.dx, size.height - at.dy),
|
||
items: [
|
||
// 77: 여러 개를 골랐으면 무엇에 하는지 먼저
|
||
if (many)
|
||
PopupMenuItem<String>(
|
||
enabled: false,
|
||
height: 32,
|
||
child: Text(trf('{0}개 항목', [targets.length]),
|
||
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: JjColors.accent)),
|
||
),
|
||
if (icons.isNotEmpty) ...[_MenuIconRow(icons), const PopupMenuDivider(height: 1)],
|
||
for (final (id, icon, label) in items)
|
||
PopupMenuItem(
|
||
value: id,
|
||
child: Row(children: [
|
||
Icon(icon, size: 18),
|
||
const SizedBox(width: 12),
|
||
Flexible(child: Text(label, overflow: TextOverflow.ellipsis)),
|
||
]),
|
||
),
|
||
],
|
||
);
|
||
if (pick == null || !mounted) return;
|
||
switch (pick) {
|
||
case 'pickRsync':
|
||
await _pick(pane, e);
|
||
case 'open':
|
||
await _ctl.goTo(pane, e.path);
|
||
case 'openOther':
|
||
final other = _ctl.panes[1 - _ctl.panes.indexOf(pane)];
|
||
await _ctl.goTo(other, e.path);
|
||
setState(() => _ctl.active = _ctl.panes.indexOf(other));
|
||
case 'playFolder':
|
||
await _playFolder(e.path);
|
||
case 'slideshow':
|
||
await _slideshow(pane, e.path);
|
||
case 'playInternal':
|
||
await _play([e.path], internal: true);
|
||
case 'grid':
|
||
await _grid(pane, e.path);
|
||
case 'text':
|
||
await _viewText(e.path);
|
||
case 'sqlite':
|
||
await openSqliteViewer(context, c, e.path);
|
||
case 'vaultAdd':
|
||
await openVault(context, c, add: targets, deleteOriginals: (paths, {required permanent}) {
|
||
// 관리자 지시 (10/21): [영구 삭제] 를 고르면 휴지통을 거치지 않음 (Shift+Delete 와 같은 길 - 탐색기의 영구 삭제 확인 창을 한 번 더)
|
||
_shiftDelete = permanent;
|
||
return _delete(pane, paths);
|
||
});
|
||
await _ctl.refreshAll([vDirname(e.path)]);
|
||
case 'openWith':
|
||
final local = await _fetch(e.path);
|
||
if (local != null && !await c.services.shell.openWith(local, choose: true)) _snack(_noAppMessage(e.path));
|
||
case 'openDefault':
|
||
final local = await _fetch(e.path);
|
||
if (local != null && !await c.services.shell.openWith(local)) _snack(_noAppMessage(e.path));
|
||
case 'addMkv':
|
||
await _addMkv([e.path]);
|
||
case 'range':
|
||
_markRange(pane, e);
|
||
case 'select':
|
||
setState(() => _selecting = true);
|
||
if (!_isMarked(pane, e.path)) await _toggleMark(pane, e);
|
||
case 'quickLook':
|
||
await _quickLook(pane, e.path);
|
||
case 'extract':
|
||
await _extractArchive(e.path, pane);
|
||
case 'storage':
|
||
await _storage(pane, e.path);
|
||
case 'wifiShare':
|
||
await _wifiShare(e.path);
|
||
case 'zip':
|
||
final items = outermostPaths(_isMarked(pane, e.path) && pane.sel.marked.length > 1 ? pane.sel.marked : [e.path]);
|
||
final other = _dual ? _ctl.panes[1 - _ctl.panes.indexOf(pane)].current : null;
|
||
final made = await createZipFlow(context, c, items, dir: vDirname(items.first), otherDir: other);
|
||
if (made != null && mounted) {
|
||
await _ctl.refreshAll([vDirname(made)]);
|
||
pane.focused = made;
|
||
pane.changed();
|
||
}
|
||
case 'appHide':
|
||
if (c.settings.explorerHideConfirm && !await _confirmAppHide(targets)) return;
|
||
await c.updateSettings((s) => s.explorerHiddenItems = [
|
||
...s.explorerHiddenItems,
|
||
for (final x in targets)
|
||
if (!_ctl.appHidden(x)) x,
|
||
]);
|
||
for (final q in _ctl.panes) {
|
||
targets.forEach(q.sel.forget);
|
||
q.changed();
|
||
}
|
||
if (mounted) setState(() {});
|
||
_snack(_ctl.showAppHidden
|
||
? trf('목록에서 숨겼습니다 ({0}개). 파일은 그대로입니다.', [targets.length])
|
||
: trf('목록에서 숨겼습니다 ({0}개). 파일은 그대로이고, 목록 맨 아래의 [숨긴 항목 보이기] 로 다시 볼 수 있습니다.', [targets.length]));
|
||
case 'appUnhide':
|
||
final keys = {for (final x in targets) HiddenList.keyOf(x)};
|
||
await c.updateSettings((s) => s.explorerHiddenItems = [
|
||
for (final x in s.explorerHiddenItems)
|
||
if (!keys.contains(HiddenList.keyOf(x))) x,
|
||
]);
|
||
for (final q in _ctl.panes) {
|
||
q.changed();
|
||
}
|
||
if (mounted) setState(() {});
|
||
case 'nomediaOn' || 'nomediaOff':
|
||
await _nomedia(pane, e.path, on: pick == 'nomediaOn');
|
||
case 'props':
|
||
await showFolderProperties(context, e.path);
|
||
case 'hash':
|
||
await showFileHash(context, e.path);
|
||
case 'folderSize':
|
||
if (_ctl.sizes.busy(e.path)) {
|
||
_ctl.sizes.cancel(e.path);
|
||
break;
|
||
}
|
||
// 사용자가 직접 시킨 것: 다른 폴더로 가도 끝까지 (멈추기는 아래 알림 · 같은 메뉴)
|
||
_ctl.sizes
|
||
..forget(e.path)
|
||
..request(e.path);
|
||
if (mounted) {
|
||
showNotice(ScaffoldMessenger.of(context),
|
||
text: isDav(e.path)
|
||
? trf('"{0}" 크기를 계산합니다. 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다.', [e.name])
|
||
: trf('"{0}" 크기를 계산합니다.', [e.name]),
|
||
action: (tr('취소'), () => _ctl.sizes.cancel(e.path)));
|
||
}
|
||
case 'rename':
|
||
if (_isMarked(pane, e.path) && pane.sel.marked.length > 1) {
|
||
await _batchRename(pane, pane.sel.marked);
|
||
} else {
|
||
await _rename(pane, e.path);
|
||
}
|
||
case 'copy':
|
||
await _transfer(pane, _isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: false);
|
||
case 'move':
|
||
await _transfer(pane, _isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: true);
|
||
case 'clipCopy':
|
||
_toClipboard(_isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: false);
|
||
case 'clipMove':
|
||
_toClipboard(_isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: true);
|
||
case 'paste':
|
||
await _paste(pane, e.isDir ? e.path : vDirname(e.path));
|
||
case 'delete':
|
||
// 77: 메뉴 머리의 "n개 항목" 과 같게 (예전에는 표시한 것이 여럿이어도 누른 것 하나만 지웠다)
|
||
await _delete(pane, targets);
|
||
case 'share':
|
||
await _share(targets);
|
||
case 'copyPath':
|
||
final paths = targets.map(_copyablePath).toList();
|
||
await Clipboard.setData(ClipboardData(text: paths.join('\n')));
|
||
_snack(many ? trf('경로 {0}개를 복사했습니다', [paths.length]) : trf('경로를 복사했습니다: {0}', [paths.single]));
|
||
case 'bookmark':
|
||
final name = vBasename(e.path).isEmpty ? e.path : vBasename(e.path);
|
||
await c.updateSettings((s) => s.folderBookmarks = [...s.folderBookmarks, FolderBookmark(name: name, path: e.path)]);
|
||
_snack(trf('즐겨찾기에 넣었습니다: {0}', [name]));
|
||
setState(() {});
|
||
case 'view':
|
||
await _open(e.path, pane: pane);
|
||
case 'toPdf':
|
||
await _imagesToPdf(images);
|
||
case 'reveal':
|
||
await c.services.shell.revealFile(e.path);
|
||
case 'info':
|
||
await _info(e);
|
||
}
|
||
}
|
||
|
||
/// 77: 공유 - 파일만 (폴더는 보낼 수 없어 빼고 알림). WebDAV 파일은 받아서
|
||
Future<void> _share(List<String> paths) async {
|
||
final files = [for (final x in paths) if (!vIsDirSync(x)) x];
|
||
if (files.isEmpty) {
|
||
_snack(tr('폴더는 공유할 수 없습니다. 파일을 고르세요.'));
|
||
return;
|
||
}
|
||
final local = <String>[];
|
||
for (final f in files) {
|
||
final l = await _fetch(f);
|
||
if (l == null || !mounted) return;
|
||
local.add(l);
|
||
}
|
||
if (!await c.services.shell.shareFiles(local)) {
|
||
_snack(tr('공유 창을 열 수 없습니다.'));
|
||
} else if (files.length < paths.length) {
|
||
_snack(trf('폴더 {0}개는 빼고 파일만 보냈습니다.', [paths.length - files.length]));
|
||
}
|
||
}
|
||
|
||
// ───────── 기능 ─────────
|
||
|
||
ExplorerPane? get _other => _dual || _hiddenTarget ? _ctl.panes[1 - _ctl.active] : null;
|
||
|
||
// ───────── X-plore 비교 f: 압축을 트리 안에서 폴더처럼 (ZIP · 7z · RAR, 읽기 전용) ─────────
|
||
|
||
/// 트리에서 펼칠 수 있는 압축 (이 기기의 파일 · 탐색기 화면)
|
||
bool _canBrowseArchive(FileEntry e) => !widget.rsync && ArchiveBrowser.canBrowse(e);
|
||
String? _archiveOf(String path) => _ctl.arc.archiveOf(path);
|
||
bool _insideArchive(String path) => _ctl.arc.inside(path);
|
||
|
||
/// 압축 안의 파일 하나를 임시 폴더로 꺼낸다 (보기 · 재생). 꺼낸 경로 (못 하면 null)
|
||
Future<String?> _extractOne(String path) async {
|
||
final (out, why) = await _ctl.arc.extractOne(path, await c.services.storage.tempDirectory());
|
||
if (why == ArchiveRead.password) _snack(tr('비밀번호가 걸린 파일입니다 - [압축 풀기] 로 푸세요'));
|
||
if (why == ArchiveRead.failed) _snack(trf('압축에서 꺼내지 못했습니다: {0}', [_ctl.arc.lastError]));
|
||
return out;
|
||
}
|
||
|
||
/// 압축 안의 것을 꺼내기 - 86 의 풀기 창 (검사 · 갈 곳 · 같은 이름) 그대로
|
||
Future<void> _extractFromArchive(List<String> paths, String? otherDir) async {
|
||
final arc = _archiveOf(paths.firstWhere(_insideArchive))!;
|
||
final names = [for (final x in paths) if (_insideArchive(x) && samePath(_archiveOf(x)!, arc)) _ctl.arc.innerName(arc, x)];
|
||
final done = await extractArchiveFlow(context, c, arc, otherDir: otherDir, names: names);
|
||
if (done != null && mounted) await _ctl.refreshAll([done]);
|
||
}
|
||
|
||
/// 관리자 지시 (10/21, 28 지적): 시스템 폴더 · 루트 (shz://) 가 원본이나 받는 곳인 이동은 시작하지 않는다 -
|
||
/// 복사는 되고 원본 지우기에서 오류로 끝나던 것 (끌어 놓기 · 붙여넣기 · 버튼 · 메뉴 모두 여기를 지남). 막았으면 true
|
||
bool _blockShizukuMove(List<String> sources, String? dest, {required bool move}) {
|
||
if (!move || !(sources.any(isShizuku) || (dest != null && isShizuku(dest)))) return false;
|
||
_snack(tr('읽기 전용이라 옮길 수 없습니다 - 복사를 쓰세요'));
|
||
return true;
|
||
}
|
||
|
||
/// 압축 안을 바꾸려 하면 막고 알린다 (읽기 전용). 막았으면 true
|
||
/// [self] 면 압축 파일 자신도 (그 안으로 넣는 곳일 때)
|
||
bool _blockArchive(List<String> paths, {bool self = false}) {
|
||
// X-plore 비교 5-2: 시스템 폴더 (Shizuku) 도 읽기 전용 - 쓰기 · 지우기 · 이름 바꾸기 · 옮기기 · 새 폴더 · 넣기를 막는다 (꺼내기만)
|
||
if (paths.any(isShizuku)) {
|
||
_snack(paths.any(isRootPath)
|
||
? tr('루트 폴더는 읽기 전용입니다 (꺼내기 (복사) 만 할 수 있습니다)')
|
||
: tr('시스템 폴더 (Shizuku) 는 읽기 전용입니다 (꺼내기 (복사) 만 할 수 있습니다)'));
|
||
return true;
|
||
}
|
||
if (!paths.any((x) => _insideArchive(x) || (self && _ctl.arc.contains(x)))) return false;
|
||
_snack(tr('압축 안은 읽기 전용입니다 (꺼내기만 할 수 있습니다)'));
|
||
return true;
|
||
}
|
||
|
||
/// 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('다른 폴더')),
|
||
),
|
||
]),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 기능의 대상: 표시한 항목, 없으면 마지막으로 누른 항목 (저장 장치 맨 위는 빼고)
|
||
List<String> _targets(ExplorerPane pane) {
|
||
if (!pane.sel.isEmpty) return pane.sel.marked;
|
||
final f = pane.focused;
|
||
if (f == null || samePath(f, pane.root)) return const [];
|
||
return [f];
|
||
}
|
||
|
||
void _snack(String text) {
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text)));
|
||
}
|
||
|
||
Future<void> _buttonLong(ExplorerButton b) async {
|
||
switch (b) {
|
||
case ExplorerButton.copy:
|
||
return _button(ExplorerButton.move);
|
||
case ExplorerButton.move:
|
||
return _button(ExplorerButton.copy);
|
||
case ExplorerButton.delete:
|
||
_shiftDelete = true;
|
||
return _button(ExplorerButton.delete);
|
||
default:
|
||
return;
|
||
}
|
||
}
|
||
|
||
Future<void> _button(ExplorerButton b) async {
|
||
final pane = _ctl.pane;
|
||
switch (b) {
|
||
case ExplorerButton.up:
|
||
if (samePath(pane.current, pane.root)) return;
|
||
final up = vDirname(pane.current);
|
||
pane.expanded.remove(pane.current);
|
||
await _ctl.goTo(pane, up);
|
||
case ExplorerButton.select:
|
||
_setSelecting(!_selecting);
|
||
case ExplorerButton.monitor:
|
||
await MonitorPage.open(context, c);
|
||
case ExplorerButton.toRight:
|
||
await _runRsync(right: true, left: false);
|
||
case ExplorerButton.toLeft:
|
||
await _runRsync(right: false, left: true);
|
||
case ExplorerButton.both:
|
||
await _runRsync(right: true, left: true);
|
||
case ExplorerButton.refresh:
|
||
// 183: 두 창 모두 (밖에서 지운 파일이 다른 창에 남지 않게)
|
||
await _ctl.reloadOpen();
|
||
case ExplorerButton.play:
|
||
final t = _targets(pane);
|
||
// 181: 강조한 것이 mp3 등 소리 파일이어도 그것을 (예전에는 동영상이 아니라고 빼고 폴더의 동영상을 틀었다)
|
||
final videos = t.where((x) => !vIsDirSync(x) && (isVideoFile(x) || isAudioFile(x))).toList();
|
||
if (videos.isNotEmpty) {
|
||
await _play(videos, keepOrder: !pane.sel.isEmpty);
|
||
} else {
|
||
await _playFolder(t.length == 1 && vIsDirSync(t.first) ? t.first : pane.current);
|
||
}
|
||
case ExplorerButton.addMkv:
|
||
final t = _targets(pane);
|
||
await _addMkv(t.isEmpty ? [pane.current] : t);
|
||
case ExplorerButton.newFolder:
|
||
// X-plore 비교 f: 펼친 압축 안 (지금 폴더가 압축 안의 폴더) 에는 만들지 않는다
|
||
if (_blockArchive([pane.current], self: true)) return;
|
||
final name = await _askName(tr('새 폴더'), '');
|
||
if (name == null) return;
|
||
try {
|
||
final parent = pane.current;
|
||
final made = await FileOps.makeFolder(parent, name);
|
||
// 만든 폴더가 트리에 보이게: 부모를 펼치고 다시 읽은 뒤 그 줄까지 스크롤
|
||
pane.expanded.add(parent);
|
||
await _ctl.load(pane, parent, force: true);
|
||
await _ctl.refreshAll([parent]);
|
||
// Rsync 화면: 만든 폴더를 이 창의 원본 · 대상으로 바로 고른다
|
||
if (widget.rsync) {
|
||
pane.sel.clear();
|
||
await pane.sel.toggle(made, isDir: true, list: (_) async => const []);
|
||
}
|
||
pane.focused = made;
|
||
pane.changed();
|
||
_reveal(pane, made);
|
||
} catch (e) {
|
||
_snack(trf('폴더를 만들 수 없습니다: {0}', [e]));
|
||
}
|
||
case ExplorerButton.rename:
|
||
final t = _targets(pane);
|
||
if (t.isEmpty) {
|
||
_snack(tr('이름을 바꿀 항목 하나를 고르세요.'));
|
||
return;
|
||
}
|
||
// 78: 여럿을 골랐으면 일괄 이름 바꾸기
|
||
if (t.length > 1) return _batchRename(pane, t);
|
||
await _rename(pane, t.first);
|
||
case ExplorerButton.copy:
|
||
await _transfer(pane, _targets(pane), move: false);
|
||
case ExplorerButton.move:
|
||
await _transfer(pane, _targets(pane), move: true);
|
||
case ExplorerButton.paste:
|
||
await _paste(pane, pane.current);
|
||
case ExplorerButton.delete:
|
||
await _delete(pane, _targets(pane));
|
||
case ExplorerButton.search:
|
||
await _search(pane);
|
||
case ExplorerButton.storage:
|
||
await _storage(pane, pane.current);
|
||
case ExplorerButton.wifiShare:
|
||
await _wifiShare(pane.current);
|
||
case ExplorerButton.apps:
|
||
await openAppManager(context);
|
||
case ExplorerButton.vault:
|
||
if (!c.settings.vaultEnabled) {
|
||
_snack(tr('암호 보관함은 환경 설정 > 보안 에서 켭니다 (처음 꺼짐)'));
|
||
} else {
|
||
await openVault(context, c);
|
||
}
|
||
case ExplorerButton.trash:
|
||
await _openTrash();
|
||
case ExplorerButton.sort:
|
||
await _sortDialog();
|
||
case ExplorerButton.hidden:
|
||
await c.updateSettings((s) => s.explorerShowHidden = !s.explorerShowHidden);
|
||
for (final x in _ctl.panes) {
|
||
x.cache.clear();
|
||
for (final d in x.expanded.toList()) {
|
||
await _ctl.load(x, d, force: true);
|
||
}
|
||
}
|
||
_snack(c.settings.explorerShowHidden ? tr('숨은 항목을 보입니다.') : tr('숨은 항목을 감춥니다.'));
|
||
case ExplorerButton.history:
|
||
await _historyDialog(pane);
|
||
case ExplorerButton.bookmarks:
|
||
await _bookmarks(pane);
|
||
case ExplorerButton.orient:
|
||
await _toggleOrientation();
|
||
case ExplorerButton.layout:
|
||
await _layoutDialog();
|
||
case ExplorerButton.buttons:
|
||
await _buttonsDialog();
|
||
}
|
||
}
|
||
|
||
Future<void> _playFolder(String dir) async {
|
||
final List<String> list;
|
||
try {
|
||
list = (await listEntries(dir)).where((e) => !e.isDir && isVideoFile(e.path)).map((e) => e.path).toList();
|
||
} catch (e) {
|
||
_snack(trf('읽을 수 없습니다: {0}', [explainFileError('$e', dav: isDav(dir)).$1]));
|
||
return;
|
||
}
|
||
if (list.isEmpty) {
|
||
_snack(tr('이 폴더에 동영상이 없습니다.'));
|
||
return;
|
||
}
|
||
if (!mounted) return;
|
||
await _play(list);
|
||
}
|
||
|
||
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]));
|
||
}
|
||
|
||
Future<void> _rename(ExplorerPane pane, String path) async {
|
||
if (_blockArchive([path])) return;
|
||
final name = await _askName(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: 일괄 이름 바꾸기 - 번호는 목록에 보이는 차례대로. 끝나면 [되돌리기]
|
||
Future<void> _batchRename(ExplorerPane pane, List<String> paths) async {
|
||
if (_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();
|
||
}
|
||
}
|
||
|
||
/// Shift+Delete 로 불렀는지 (휴지통을 거치지 않고 지우기를 미리 골라 둔다 - 65)
|
||
bool _shiftDelete = false;
|
||
|
||
/// 지우기. Windows 의 로컬 파일은 기본으로 휴지통으로 (창에서 "영구 삭제" 를 고르거나 Shift+Delete).
|
||
/// WebDAV · Android 는 휴지통이 없어 바로 지운다. 여러 개면 실패해도 나머지를 계속하고, 못 지운 것을 알린다 (65).
|
||
Future<void> _delete(ExplorerPane pane, List<String> paths) async {
|
||
if (_blockArchive(paths)) return;
|
||
paths = outermostPaths(paths); // 79-c: 고른 폴더 안의 것은 한 번만 (확인 창 개수도)
|
||
final shift = _shiftDelete;
|
||
_shiftDelete = false;
|
||
if (paths.isEmpty) {
|
||
_snack(tr('지울 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).'));
|
||
return;
|
||
}
|
||
// 휴지통이 있는 곳: Windows 의 고정 디스크 (94: 네트워크 드라이브 · \\NAS · USB 메모리는 휴지통이 없어 처음부터 영구 삭제로 묻는다)
|
||
final local = Platform.isWindows && paths.every((x) => !isDav(x));
|
||
// X-plore 비교 e: Android 는 앱 휴지통 (저장 장치의 .jj_trash, 보관 일이 0 이면 끔)
|
||
final trash = _appTrash;
|
||
final canTrash = trash != null && paths.every((x) => !isDav(x) && trash.trashDirFor(x) != null && !trash.isInTrash(x));
|
||
final canRecycle = (local && c.settings.recycleOnDelete && paths.every(hasRecycleBin)) || canTrash;
|
||
var permanent = !canRecycle || shift;
|
||
// X-plore 숨기기: 지우는 폴더 안에 목록에서 숨긴 것이 있으면 알린다 (숨긴 것도 함께 지워진다)
|
||
final hiddenInside = [
|
||
for (final h in c.settings.explorerHiddenItems)
|
||
if (paths.any((x) => !samePath(h, x) && isSameOrInside(h, x))) h
|
||
].length;
|
||
final otherMarks = [for (final x in _ctl.panes) if (!identical(x, pane)) x.sel.marked.length].fold(0, (a, b) => a + b);
|
||
// U7: 확인 창은 공용 confirmDialog - 영구 삭제로 열면 Enter 의 첫 초점은 [취소]
|
||
final ok = await confirmDialog(
|
||
context,
|
||
title: () => permanent ? tr('영구 삭제') : tr('휴지통으로 보내기'),
|
||
danger: () => permanent,
|
||
confirmLabel: () => permanent ? tr('영구 삭제') : tr('휴지통으로'),
|
||
// 무엇을 지우는지 늘 보여 준다 (여러 개면 앞의 8개 이름 · 폴더)
|
||
body: (ctx, set) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Text(paths.length == 1
|
||
? '${permanent ? trf('"{0}" 을(를) 지울까요? 되돌릴 수 없습니다.', [vBasename(paths.first)]) : trf('"{0}" 을(를) 휴지통으로 보낼까요?', [vBasename(paths.first)])}'
|
||
'\n${vDirname(paths.first)}'
|
||
: [
|
||
permanent
|
||
? trf('{0}개 항목을 지울까요? 되돌릴 수 없습니다.', [paths.length])
|
||
: trf('{0}개 항목을 휴지통으로 보낼까요?', [paths.length]),
|
||
for (final x in paths.take(8)) '· ${vBasename(x)}',
|
||
if (paths.length > 8) trf('… 외 {0}개', [paths.length - 8]),
|
||
{for (final x in paths) vDirname(x)}.join('\n'),
|
||
].join('\n')),
|
||
// 79-b: 폴더가 있으면 몇 개인지 (안의 것도 함께 지워진다)
|
||
if (paths.any(vIsDirSync))
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 8),
|
||
child: Text(trf('그중 폴더 {0}개 - 안의 파일 · 폴더도 모두 함께', [paths.where(vIsDirSync).length]),
|
||
style: TextStyle(fontSize: 12, color: JjColors.warning)),
|
||
),
|
||
if (hiddenInside > 0)
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 8),
|
||
child: Text(trf('지우는 폴더 안에 목록에서 숨긴 항목 {0}개가 있습니다 - 함께 지워집니다', [hiddenInside]),
|
||
style: TextStyle(fontSize: 12, color: JjColors.warning)),
|
||
),
|
||
if (local && !canRecycle)
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 8),
|
||
child: Text(tr('이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.'),
|
||
style: TextStyle(fontSize: 12, color: JjColors.warning)),
|
||
),
|
||
if (canTrash && !permanent)
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 8),
|
||
child: Text(trf('앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).', [c.settings.androidTrashDays]),
|
||
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||
),
|
||
if (canRecycle)
|
||
CheckboxListTile(
|
||
contentPadding: EdgeInsets.zero,
|
||
value: permanent,
|
||
onChanged: (v) => set(() => permanent = v ?? false),
|
||
title: Text(canTrash ? tr('휴지통을 거치지 않고 영구 삭제') : tr('휴지통을 거치지 않고 영구 삭제 (Shift+Delete)')),
|
||
),
|
||
// U4: 다른 창의 표시는 이번 대상이 아님
|
||
if (otherMarks > 0)
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 8),
|
||
child: Text(trf('다른 창에 {0}개 표시됨 (이번 대상 아님)', [otherMarks]),
|
||
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||
),
|
||
]),
|
||
);
|
||
if (!ok) return;
|
||
// 하나씩: 실패해도 나머지는 계속. 실제로 어떻게 됐는지 세어 그대로 알린다 (94) - 지우기 규칙은 delete_ops (E5b)
|
||
final report = await deletePaths(paths, permanent: permanent, trash: !permanent && canTrash ? trash : null);
|
||
final failed = report.failed, tooLong = report.tooLong, sent = report.sent, trashed = report.trashed;
|
||
final notTrashed = report.notTrashed, undoable = report.undoable, since = report.since;
|
||
final recycled = report.recycled, nuked = report.nuked, kept = report.kept;
|
||
// X-plore 비교 e: 휴지통으로 옮기지 못한 것 (다른 저장 장치 · 권한 등) - 몰래 지우지 않고 영구 삭제할지 묻는다
|
||
if (notTrashed.isNotEmpty && mounted) {
|
||
// U7: 되돌릴 수 없는 일 - Enter 의 첫 초점은 [그대로 두기]
|
||
final nuke = await confirmDialog(
|
||
context,
|
||
icon: Icon(Icons.warning_amber_rounded, color: JjColors.warning),
|
||
title: () => trf('{0}개를 휴지통으로 옮기지 못했습니다', [notTrashed.length]),
|
||
danger: () => true,
|
||
cancelLabel: tr('그대로 두기'),
|
||
confirmLabel: () => trf('영구 삭제 ({0}개, 되돌릴 수 없음)', [notTrashed.length]),
|
||
body: (ctx, set) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
for (final (x, why) in notTrashed.take(10)) Text('· ${vBasename(x)} - $why', style: const TextStyle(fontSize: 12)),
|
||
const SizedBox(height: 8),
|
||
Text(tr('영구 삭제하면 되살릴 수 없습니다.')),
|
||
]),
|
||
);
|
||
if (nuke) {
|
||
for (final (x, e) in await deleteForever([for (final (x, _) in notTrashed) x])) {
|
||
failed.add((x, '$e'));
|
||
}
|
||
}
|
||
}
|
||
if ((failed.isNotEmpty || tooLong.isNotEmpty) && mounted) {
|
||
final failTitle = trf('{0}개 중 {1}개를 지우지 못했습니다', [paths.length, failed.length + tooLong.length]);
|
||
final failIcon = Icon(Icons.error_outline, color: JjColors.alert);
|
||
Widget failBody() => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
// 144: 긴 경로는 이유 한 줄 + 이름만 (영구 삭제는 아래 버튼으로)
|
||
if (tooLong.isNotEmpty) ...[
|
||
Text(tr('경로가 너무 길어 휴지통에 넣을 수 없습니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다).'),
|
||
style: const TextStyle(fontWeight: FontWeight.w600)),
|
||
const SizedBox(height: 4),
|
||
for (final x in tooLong.take(30)) Text('· ${vBasename(x)}', style: const TextStyle(fontSize: 12)),
|
||
if (tooLong.length > 30) Text(trf('… 외 {0}개', [tooLong.length - 30])),
|
||
const SizedBox(height: 8),
|
||
],
|
||
for (final (x, why) in failed.take(30)) ...[
|
||
Text(vBasename(x), style: const TextStyle(fontWeight: FontWeight.w600)),
|
||
Text('${vDirname(x)} · $why', style: TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||
const SizedBox(height: 4),
|
||
],
|
||
if (failed.length > 30) Text(trf('… 외 {0}개', [failed.length - 30])),
|
||
const SizedBox(height: 6),
|
||
// 이유를 이미 위에 적었으므로 짐작하는 말은 빼고, 실제로 지운 것이 있을 때만 "나머지는 지웠습니다"
|
||
if (failed.length + tooLong.length < paths.length) Text(tr('나머지는 지웠습니다.'), style: const TextStyle(fontSize: 12)),
|
||
]);
|
||
// U7: 긴 경로를 영구 삭제할지 묻는 것은 되돌릴 수 없는 일 - Enter 의 첫 초점은 [그대로 두기]. 아니면 알림만
|
||
final nukeLong = tooLong.isNotEmpty
|
||
? await confirmDialog(
|
||
context,
|
||
icon: failIcon,
|
||
title: () => failTitle,
|
||
danger: () => true,
|
||
cancelLabel: tr('그대로 두기'),
|
||
confirmLabel: () => trf('영구 삭제 ({0}개, 되돌릴 수 없음)', [tooLong.length]),
|
||
body: (ctx, set) => failBody(),
|
||
)
|
||
: await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
scrollable: true,
|
||
icon: failIcon,
|
||
title: Text(failTitle),
|
||
content: failBody(),
|
||
actions: [FilledButton(autofocus: true, onPressed: () => Navigator.pop(ctx), child: Text(tr('확인')))],
|
||
),
|
||
) ==
|
||
true;
|
||
// 144: 사용자가 [영구 삭제] 를 고른 긴 경로만 지운다
|
||
if (nukeLong == true) {
|
||
final notDeleted = [for (final (x, e) in await deleteForever(tooLong)) '${vBasename(x)}: ${deleteFailReason(e)}'];
|
||
_snack(joinSentences([
|
||
if (tooLong.length > notDeleted.length) trf('{0}개를 영구 삭제했습니다.', [tooLong.length - notDeleted.length]),
|
||
if (notDeleted.isNotEmpty) trf('지우지 못함: {0}', [notDeleted.take(3).join(' · ')]),
|
||
]));
|
||
}
|
||
} else if (!permanent && mounted) {
|
||
final text = joinSentences([
|
||
if (recycled > 0) trf('{0}개 항목을 휴지통으로 보냈습니다.', [recycled]),
|
||
// 휴지통보다 커서 Windows 가 물었고 사용자가 영구 삭제를 골랐다
|
||
if (nuked > 0) trf('{0}개는 휴지통에 들어가지 않아 영구 삭제했습니다 (Windows 가 묻고 고른 대로).', [nuked]),
|
||
if (kept > 0) trf('{0}개는 지우지 않았습니다 (취소).', [kept]),
|
||
]);
|
||
// 148: 휴지통으로 보낸 것은 바로 [되돌리기]
|
||
showNotice(ScaffoldMessenger.of(context),
|
||
text: text,
|
||
action: trashed.isNotEmpty
|
||
? (tr('되돌리기'), () => _undoTrash(trashed))
|
||
: sent.isEmpty
|
||
? null
|
||
: (tr('되돌리기'), () => _undoRecycle(sent, since)));
|
||
}
|
||
// X-plore 숨기기: 영구로 지운 것 (그 안 포함) 은 숨긴 목록에서 뺀다. 휴지통으로 보낸 것은 되돌리기 뒤에도 숨김이 되게 남긴다
|
||
await _ctl.followHidden(gone: [
|
||
for (final x in paths)
|
||
if (!sent.contains(x) && !undoable.contains(x) && !failed.any((f) => f.$1 == x)) x,
|
||
]);
|
||
for (final x in _ctl.panes) {
|
||
paths.forEach(x.sel.forget);
|
||
if (_selecting && x.sel.isEmpty) _selecting = false;
|
||
x.expanded.removeWhere((d) => paths.any((s) => isSameOrInside(d, s)));
|
||
if (paths.any((s) => isSameOrInside(x.current, s))) x.current = vDirname(paths.first);
|
||
}
|
||
await _ctl.refreshAll(paths.map(p.dirname));
|
||
}
|
||
|
||
/// X-plore 비교 e: Android 앱 휴지통 (보관 일이 0 이면 null - 바로 영구 삭제)
|
||
AppTrash? get _appTrash => (ExplorerPage.debugAndroidTrash ?? Platform.isAndroid) && c.settings.androidTrashDays > 0
|
||
? AppTrash([for (final (v, _) in _ctl.local) v])
|
||
: null;
|
||
|
||
/// X-plore 비교 e: 방금 앱 휴지통으로 보낸 것을 원래 자리로
|
||
Future<void> _undoTrash(List<TrashItem> items) async {
|
||
final trash = _appTrash;
|
||
if (trash == null) return;
|
||
final back = await undoTrash(trash, items);
|
||
await _ctl.refreshAll(back.map(p.dirname));
|
||
_snack(trf('{0}개를 원래 자리로 되살렸습니다', [back.length]));
|
||
}
|
||
|
||
/// 시험: [휴지통] 열기
|
||
@visibleForTesting
|
||
Future<void> debugOpenTrash() => _openTrash();
|
||
|
||
/// X-plore 비교 e: [휴지통] - Android 는 앱 휴지통 화면, Windows 는 시스템 휴지통
|
||
Future<void> _openTrash() async {
|
||
final trash = _appTrash;
|
||
if (trash == null) {
|
||
if (Platform.isWindows) {
|
||
await Process.start('explorer.exe', ['shell:RecycleBinFolder']);
|
||
} else {
|
||
_snack(tr('휴지통이 꺼져 있습니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).'));
|
||
}
|
||
return;
|
||
}
|
||
final back = await Navigator.of(context).push<List<String>>(
|
||
MaterialPageRoute(builder: (_) => TrashPage(trash: trash, days: c.settings.androidTrashDays)));
|
||
if (back != null && back.isNotEmpty && mounted) await _ctl.refreshAll(back.map(p.dirname));
|
||
}
|
||
|
||
/// 148: 방금 휴지통으로 보낸 것을 원래 자리로 (원래 자리에 같은 이름이 생겼으면 그것은 두고 알림)
|
||
Future<void> _undoRecycle(List<String> paths, DateTime since) async {
|
||
final (:back, failed: why) = undoRecycle(paths, since);
|
||
final failed = [for (final (x, e) in why) '${vBasename(x)}: $e'];
|
||
await _ctl.refreshAll(paths.map(p.dirname));
|
||
for (final x in _ctl.panes) {
|
||
x.changed();
|
||
}
|
||
_snack(joinSentences([
|
||
if (back > 0) trf('{0}개 항목을 되돌렸습니다.', [back]),
|
||
if (failed.isNotEmpty) trf('되돌리지 못함: {0}', [failed.take(3).join(' · ')]),
|
||
]));
|
||
}
|
||
|
||
/// 다른 창의 지금 폴더로 복사 · 이동 (진행 창 · 취소)
|
||
/// 복사 · 이동 버튼: 두 창이면 다른 창의 지금 폴더로, 그 밖의 배치는 담아 두기 (붙여넣기로)
|
||
Future<void> _transfer(ExplorerPane pane, List<String> sources, {required bool move}) async {
|
||
if (_blockShizukuMove(sources, null, move: move)) return;
|
||
// X-plore 비교 f: 압축 안의 것 - 복사는 꺼내기 (86 의 풀기 검사 그대로), 이동은 막음
|
||
if (sources.any(_insideArchive)) {
|
||
if (move) {
|
||
_blockArchive(sources);
|
||
return;
|
||
}
|
||
await _extractFromArchive(sources, _other?.current);
|
||
return;
|
||
}
|
||
sources = outermostPaths(sources); // 79-c: 고른 폴더 안의 것은 한 번만
|
||
final other = _other;
|
||
if (other == null) {
|
||
_toClipboard(sources, move: move);
|
||
return;
|
||
}
|
||
await _runTransfer(pane, sources, other.current, move: move);
|
||
}
|
||
|
||
/// 복사 · 이동할 항목 (붙여넣기 전까지 담아 둠) - FileClipboard (E5b)
|
||
final _clip = FileClipboard();
|
||
|
||
/// 154: 고른 것이 없을 때 (복사 · 이동이 같은 말)
|
||
String _pickFirst(bool move) => move
|
||
? tr('옮길 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).')
|
||
: tr('복사할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).');
|
||
|
||
void _toClipboard(List<String> sources, {required bool move}) {
|
||
if (_blockShizukuMove(sources, null, move: move)) return;
|
||
if (sources.isEmpty) {
|
||
_snack(_pickFirst(move));
|
||
return;
|
||
}
|
||
setState(() => _clip.put(sources, move: move));
|
||
_snack(trf('{0} 을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).',
|
||
[namesOf(sources), move ? tr('이동') : tr('복사')]));
|
||
}
|
||
|
||
Future<void> _paste(ExplorerPane pane, String dest) async {
|
||
if (_clip.isEmpty) {
|
||
_snack(tr('붙여넣을 항목이 없습니다. 먼저 [복사] · [이동] 으로 담으세요.'));
|
||
return;
|
||
}
|
||
final done = await _runTransfer(pane, _clip.items, dest, move: _clip.move);
|
||
// 옮긴 것은 다시 붙여넣을 수 없으므로 비운다 (복사는 여러 곳에 붙여넣을 수 있게 남김)
|
||
if (mounted) setState(() => _clip.pasted(done: done));
|
||
}
|
||
|
||
/// 지금 도는 복사 · 이동 (아래에서 올라오는 진행 막대)
|
||
/// (Rsync 화면의 ⇄ 는 둘이 함께)
|
||
List<TransferJob> _jobs = const [];
|
||
|
||
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)));
|
||
}
|
||
|
||
/// 52: 이 화면을 떠난 사이에 시작된 (또는 앞의 화면이 시작한) 작업을 다시 보이고, 끝나면 새로 읽고 막대를 내린다
|
||
/// X-plore 숨기기: 옮긴 것은 숨긴 목록도 새 자리로 (원래 자리에 없고 새 자리에 있는 것만 - 같은 이름 바꾸기 · 실패는 그대로)
|
||
Future<void> _followMovedJob(TransferJob job) async {
|
||
if (!job.move || job.contents || c.settings.explorerHiddenItems.isEmpty) return;
|
||
final moved = <(String, String)>[];
|
||
for (final s in job.sources) {
|
||
final to = vJoin(job.dest, vBasename(s));
|
||
if (!await vExists(s) && await vExists(to)) moved.add((s, to));
|
||
}
|
||
await _ctl.followHidden(moved: moved);
|
||
}
|
||
|
||
void _adoptJob(TransferJob job) {
|
||
if (_jobs.contains(job)) return;
|
||
setState(() => _jobs = [..._jobs, job]);
|
||
unawaited(job.done.then((_) async {
|
||
if (!mounted) return;
|
||
await _followMovedJob(job);
|
||
if (!mounted) return;
|
||
await _ctl.refreshAll([job.dest, ...job.sources.map(vDirname)]);
|
||
for (final x in _ctl.panes) {
|
||
x.changed();
|
||
}
|
||
await Future<void>.delayed(const Duration(milliseconds: 1200));
|
||
if (mounted && _jobs.contains(job)) setState(() => _jobs = [for (final j in _jobs) if (j != job) j]);
|
||
}));
|
||
}
|
||
|
||
/// 한쪽이라도 WebDAV 면 rsync 대신 앱이 직접 맞춘다 (rsync 는 WebDAV 를 모름)
|
||
bool _viaDav(Iterable<String> paths) => paths.any(isDav);
|
||
|
||
/// rsync 가 없으면 (Windows · 내려받기 설정) 지금 내려받을지 묻는다. 쓸 수 있는 rsync 경로 (없으면 null)
|
||
Future<String?> _ensureRsync() async {
|
||
final s = c.settings;
|
||
final have = await rsyncExecutable(s);
|
||
if (have != null) return have;
|
||
if (!mounted) return null;
|
||
if (s.rsyncSource == 'custom' || !Platform.isWindows) {
|
||
_snack(tr('rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > 파일 탐색기에서 경로를 확인하세요.'));
|
||
return null;
|
||
}
|
||
return installRsyncWithDialog(context);
|
||
}
|
||
|
||
/// [dest] 로 복사 · 이동 (확인 · 아래 진행 막대 · 취소). 끝까지 했으면 true.
|
||
Future<bool> _runTransfer(ExplorerPane pane, List<String> sources, String dest,
|
||
{required bool move, void Function(TransferJob job)? onFinished}) async {
|
||
if (sources.isEmpty) {
|
||
_snack(_pickFirst(move));
|
||
return false;
|
||
}
|
||
if (_blockShizukuMove(sources, dest, move: move)) return false;
|
||
// X-plore 비교 f: 압축 안으로는 넣지 않고 (읽기 전용), 압축 안의 것을 붙여넣으면 꺼내기로
|
||
if (_blockArchive([dest], self: true)) return false;
|
||
if (sources.any(_insideArchive)) {
|
||
if (move) return !_blockArchive(sources);
|
||
await _extractFromArchive(sources, dest);
|
||
return true;
|
||
}
|
||
// 23: 앞의 복사 · 이동이 끝나지 않아도 함께 시작한다 (아래 진행 막대에 하나씩). 같은 것을 같은 곳으로만 막는다
|
||
if (_jobs.any((j) => !j.finished && samePath(j.dest, dest) && j.sources.toSet().containsAll(sources))) {
|
||
_snack(tr('같은 항목을 같은 곳으로 복사 · 이동하는 중입니다.'));
|
||
return false;
|
||
}
|
||
// 135: 같은 폴더로 - 짧은 알림은 놓치기 쉬워 창으로. 이동은 할 것이 없고, 복사는 사본을 만들지 한 번 묻는다 (계획은 transfer_plan)
|
||
final plan = planTransfer(sources, dest, move: move);
|
||
sources = plan.sources;
|
||
final skippedHere = plan.skippedHere, here = plan.here;
|
||
var sameFolderCopy = false;
|
||
if (plan.nothingToMove) {
|
||
// 알림만 ([확인] 에 첫 초점 - Enter 로 닫기)
|
||
await showDialog<void>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
scrollable: true,
|
||
title: Text(tr('이미 이 폴더에 있습니다')),
|
||
content: Text(trf('{0} 은(는) 이미 이 폴더에 있어 옮길 것이 없습니다.\n{1}', [namesOf(here), vDisplay(dest)])),
|
||
actions: [FilledButton(autofocus: true, onPressed: () => Navigator.pop(ctx), child: Text(tr('확인')))],
|
||
),
|
||
);
|
||
return false;
|
||
}
|
||
if (plan.askCopyHere) {
|
||
final ok = await confirmDialog(context,
|
||
title: () => tr('같은 폴더입니다'),
|
||
confirmLabel: () => tr('사본 만들기'),
|
||
body: (ctx, set) => Text(trf('같은 폴더에 사본을 만들까요? (이름 (2))\n{0}', [namesOf(here)])));
|
||
if (!ok || !mounted) return false;
|
||
sameFolderCopy = true; // 이미 물었으니 아래 확인 창은 건너뛴다
|
||
}
|
||
// 원본 · 대상이 없거나 (다른 곳에서 지움 - 목록을 새로 고침) 폴더를 자기 안으로 넣으려 하면 시작하지 않는다
|
||
final problem = plan.problem;
|
||
if (problem != null) {
|
||
_snack(problem);
|
||
pane.sel.removeWhere(vMissingSync);
|
||
await _ctl.refreshAll([dest, ...sources.map(p.dirname)]);
|
||
for (final x in _ctl.panes) {
|
||
x.changed();
|
||
}
|
||
return false;
|
||
}
|
||
// 파일 탐색기: 현재 방식 · robocopy (rsync 는 Rsync 화면 - 그쪽만 모니터링에 남는다)
|
||
final center = CopyCenter.of(c);
|
||
final task = center.fresh(sources, dest, move: move);
|
||
final method = CopyMethod.of(task.method);
|
||
// 48: 받는 폴더에 같은 이름이 있으면 덮어쓰기 · 건너뛰기 · 이름 바꾸기를 고른다
|
||
// (앱이 직접 할 때만 - robocopy · rsync 는 그 프로그램의 규칙. 같은 폴더로 복사는 늘 새 이름)
|
||
final builtin = method == CopyMethod.builtin || _viaDav([...sources, dest]);
|
||
// 확인 창은 바로 띄우고, 같은 이름은 창 안에서 뒤에서 찾는다 (느린 네트워크 · WebDAV 폴더라도 창이 늦게 뜨지 않게).
|
||
// 다 찾기 전에 누르면 이름 바꾸기 (원래 파일을 건드리지 않는 쪽)
|
||
final clashesFuture = builtin ? findClashes(sources, dest) : Future.value(const <NameClash>[]);
|
||
// 48: 환경 설정 "같은 이름이 있을 때" 가 늘 하는 것이면 묻지 않고 그대로 (확인 창에 알림만)
|
||
final always = switch (c.settings.copyConflict) {
|
||
'rename' => NameConflict.rename,
|
||
'overwrite' => NameConflict.overwrite,
|
||
'skip' => NameConflict.skip,
|
||
_ => null,
|
||
};
|
||
var conflict = always ?? NameConflict.rename;
|
||
// U4 (점검 10/22, 관리자 결정): 다른 창의 표시는 그대로 두고 이번 대상이 아님을 알린다
|
||
final otherMarks = [for (final x in _ctl.panes) if (!identical(x, pane)) x.sel.marked.length].fold(0, (a, b) => a + b);
|
||
// U7: 공용 confirmDialog - 늘 덮어쓰기 설정이면 (되돌릴 수 없음) Enter 의 첫 초점은 [취소]
|
||
final go = sameFolderCopy
|
||
? true
|
||
: await confirmDialog(
|
||
context,
|
||
title: () => move ? tr('이동') : tr('복사'),
|
||
confirmLabel: () => move ? tr('이동') : tr('복사'),
|
||
danger: () => conflict == NameConflict.overwrite,
|
||
body: (ctx, setInner) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
// 61: 문장 조각을 이어 붙이지 않는다 (언어마다 어순이 달라 "…로 {복사할까요?}" 가 깨졌다)
|
||
Text('${move ? trf('{0}개 항목을 다음 폴더로 옮길까요?\n{1}', [sources.length, vDisplay(dest)]) : trf('{0}개 항목을 다음 폴더로 복사할까요?\n{1}', [sources.length, vDisplay(dest)])}'
|
||
'\n${namesOf(sources)}'
|
||
'${skippedHere.isEmpty ? '' : '\n${trf('이미 이 폴더에 있어 건너뜀: {0}', [namesOf(skippedHere)])}'}'
|
||
'\n\n${trf('방법: {0}', [tr(method.label)])}'
|
||
'${method == CopyMethod.builtin ? '' : ' · ${withoutSourceRemovingOptions(task.options)}${move ? (method == CopyMethod.rsync ? ' --remove-source-files' : ' /MOVE') : ''}'}'
|
||
'${task.bandwidthKBps > 0 ? ' · ${trf('속도 제한 {0} KB/s', [task.bandwidthKBps])}' : ''}'),
|
||
// 178 보충: 환경 설정의 원본 지우기 옵션은 복사에서 뺀다 - 왜 빠졌는지 알림
|
||
if (!move && method != CopyMethod.builtin && optionsRemoveSource(task.options))
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 6),
|
||
child: Text(
|
||
trf('환경 설정의 {0} 는 복사에서는 쓰지 않습니다 (이동은 [이동] 으로)',
|
||
[splitOptions(task.options).where(isSourceRemovingOption).join(' ')]),
|
||
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||
),
|
||
FutureBuilder<List<NameClash>>(
|
||
future: clashesFuture,
|
||
builder: (ctx, snap) {
|
||
if (snap.connectionState != ConnectionState.done) {
|
||
return Padding(
|
||
padding: const EdgeInsets.only(top: 12),
|
||
child: Text(tr('같은 이름이 있는지 확인하는 중 …'), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||
);
|
||
}
|
||
final clashes = snap.data ?? const <NameClash>[];
|
||
if (clashes.isEmpty) return const SizedBox.shrink();
|
||
return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
const SizedBox(height: 12),
|
||
Text(trf('같은 이름이 이미 있습니다: {0}', [namesOf([for (final x in clashes) x.src])]),
|
||
style: TextStyle(color: JjColors.warning)),
|
||
// 172: 두 파일의 크기 · 바뀐 날 (3개까지)
|
||
for (final x in clashes.take(3)) ...[
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 4, left: 8),
|
||
child: Text(clashCompareText(x), style: const TextStyle(fontSize: 12)),
|
||
),
|
||
// 229: 폴더를 합치면 안쪽에서 받는 쪽이 더 새 파일 - 건너뛰는지 (환경 설정) 미리
|
||
if (x.there.isDir && !isDav(x.src) && !isDav(dest))
|
||
FutureBuilder<int>(
|
||
future: FileOps.countNewerInside(x.src, vJoin(dest, vBasename(x.src))),
|
||
builder: (_, n) => Padding(
|
||
padding: const EdgeInsets.only(left: 20),
|
||
child: Text(
|
||
n.connectionState != ConnectionState.done
|
||
? tr('안쪽 파일을 확인하는 중 …')
|
||
: (n.data ?? 0) == 0
|
||
? ''
|
||
: c.settings.copySkipNewer
|
||
? trf('안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)', [n.data])
|
||
: trf('안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다', [n.data]),
|
||
style: TextStyle(fontSize: 12, color: JjColors.warning),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
if (always != null)
|
||
Text(
|
||
trf('환경 설정대로 {0} (환경 설정 > 파일 탐색기 > 같은 이름이 있을 때)', [
|
||
switch (always) {
|
||
NameConflict.rename => tr('이름을 바꿉니다'),
|
||
NameConflict.overwrite => tr('덮어씁니다'),
|
||
NameConflict.skip => tr('건너뜁니다'),
|
||
}
|
||
]),
|
||
style: const TextStyle(fontSize: 12))
|
||
else
|
||
RadioGroup<NameConflict>(
|
||
groupValue: conflict,
|
||
onChanged: (x) => setInner(() => conflict = x ?? conflict),
|
||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||
for (final (v, label) in [
|
||
(NameConflict.rename, tr('이름 바꾸기 (예: 이름 (2))')),
|
||
(NameConflict.overwrite, tr('덮어쓰기 (원래 파일은 없어집니다)')),
|
||
(NameConflict.skip, tr('건너뛰기')),
|
||
])
|
||
RadioListTile<NameConflict>(dense: true, contentPadding: EdgeInsets.zero, value: v, title: Text(label)),
|
||
]),
|
||
),
|
||
]);
|
||
},
|
||
),
|
||
if (otherMarks > 0)
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 8),
|
||
child: Text(trf('다른 창에 {0}개 표시됨 (이번 대상 아님)', [otherMarks]),
|
||
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||
),
|
||
]),
|
||
);
|
||
if (go != true || !mounted) return false;
|
||
// 104: 설정의 옵션에 지우기 (robocopy /MIR · /PURGE 등) 가 있으면 미리 보기를 거친다
|
||
if (!await confirmDeletingRun(context, task) || !mounted) return false;
|
||
String? rsync;
|
||
if (method == CopyMethod.rsync && !_viaDav([...sources, dest])) {
|
||
rsync = await _ensureRsync();
|
||
if (rsync == null || !mounted) return false;
|
||
}
|
||
final job = await center.start(task, rsyncExe: rsync, conflict: conflict);
|
||
if (job == null || !mounted) return false;
|
||
setState(() => _jobs = [..._jobs.where((j) => !j.finished), job]);
|
||
await job.done;
|
||
final error = job.error;
|
||
await _followMovedJob(job);
|
||
if (!mounted) return false;
|
||
// 180: 표시만 지우고 선택 모드는 그대로 (꺼지면 동그라미 칸 · 아래 막대가 사라져 자리가 바뀐다)
|
||
pane.sel.clear();
|
||
await _ctl.refreshAfterTransfer(sources, dest);
|
||
// 넣은 폴더가 창의 지금 폴더면 펼쳐서 새 항목이 보이게
|
||
for (final x in _ctl.panes) {
|
||
if (samePath(x.current, dest)) {
|
||
x.expanded.add(dest);
|
||
await _ctl.load(x, dest, force: true);
|
||
}
|
||
x.changed();
|
||
}
|
||
final doneText = error is FileOpCancelled
|
||
? tr('취소했습니다.')
|
||
: error != null
|
||
? trf('끝나지 못했습니다: {0}', [error])
|
||
: joinSentences([
|
||
trf('{0}개 항목을 {1}', [job.made.length, move ? tr('옮겼습니다.') : tr('복사했습니다.')]),
|
||
// 관리자 지시 (10/21, 15 찾음 F3): 뒤 조각도 문장으로 쓰고 빈칸으로 잇는다 (예전에는 "…옮겼습니다. · 1개는 …" 처럼 마침표 뒤에 " · ")
|
||
// 264: 작업 기록에만 있던 것도 알림에 (폴더를 합칠 때 받는 쪽이 더 새 파일 등)
|
||
if (move && job.leftInSource > 0) trf('{0}개는 원본에 남았습니다 (건너뜀).', [job.leftInSource]),
|
||
if (!move && job.keptNewer > 0) trf('받는 쪽이 더 새것이라 {0}개를 건너뛰었습니다.', [job.keptNewer]),
|
||
if (job.skipped.isNotEmpty) trf('같은 이름이라 건너뛰었습니다: {0}.', [namesOf(job.skipped)]),
|
||
// M1 (10/19): 원격 맞추기에서 받는 쪽의 같은 이름 폴더는 --delete 없이 지우지 않는다
|
||
if (job.dirConflicts.isNotEmpty) trf('같은 이름의 폴더가 있어 {0}개를 건너뛰었습니다: {1}.', [job.dirConflicts.length, namesOf(job.dirConflicts)]),
|
||
if (skippedHere.isNotEmpty) trf('이미 이 폴더에 있어 건너뛰었습니다: {0}.', [namesOf(skippedHere)]),
|
||
]);
|
||
// 85: 끝나면 [폴더 열기] - 앱 탐색기에서 넣은 폴더로 (그 폴더를 보고 있는 창, 없으면 지금 창)
|
||
if (error == null && mounted) {
|
||
// U2 (점검 10/22): 끝 알림은 정해진 시간 뒤 닫힘 - 아래 표시 줄을 가리지 않게
|
||
showNotice(ScaffoldMessenger.of(context),
|
||
text: doneText,
|
||
action: (
|
||
tr('폴더 열기'),
|
||
() {
|
||
final show = _ctl.panes.where((x) => samePath(x.current, dest)).firstOrNull ?? _ctl.pane;
|
||
setState(() => _ctl.active = _ctl.panes.indexOf(show));
|
||
unawaited(_ctl.goTo(show, dest, fresh: true));
|
||
}
|
||
));
|
||
} else {
|
||
_snack(doneText);
|
||
}
|
||
// 82: 끌어 놓기의 이동 되돌리기 (끝난 알림 바로 뒤에 - 진행 막대가 내려가기를 기다리지 않게)
|
||
if (error == null && mounted) onFinished?.call(job);
|
||
// 다 되면 진행 막대가 잠깐 100% 를 보인 뒤 내려간다
|
||
await Future<void>.delayed(const Duration(milliseconds: 1200));
|
||
if (mounted && _jobs.contains(job)) setState(() => _jobs = [for (final j in _jobs) if (j != job) j]);
|
||
return error == null;
|
||
}
|
||
|
||
/// Rsync 화면: 고른 왼쪽 · 오른쪽 폴더로 rsync. [right] 왼쪽 → 오른쪽, [left] 오른쪽 → 왼쪽 (둘 다면 함께).
|
||
/// 폴더 "안의 것" 을 맞춘다 (원본/ → 대상/). 함께 할 때는 받는 쪽이 더 새 파일은 건너뛴다 (-u).
|
||
/// 실행한 것은 모니터링에 남는다 (같은 것을 다시 하면 기억한 옵션으로).
|
||
/// 확인 창이 떠 있는 동안 또 누르면 무시 (창이 겹쳐 아래 창을 잘못 누르지 않게 - 110)
|
||
bool _rsyncAsking = false;
|
||
|
||
Future<void> _runRsync({required bool right, required bool left}) async {
|
||
if (_rsyncAsking) return;
|
||
_rsyncAsking = true;
|
||
try {
|
||
await _runRsyncInner(right: right, left: left);
|
||
} finally {
|
||
_rsyncAsking = false;
|
||
}
|
||
}
|
||
|
||
Future<void> _runRsyncInner({required bool right, required bool left}) async {
|
||
final l = _ctl.panes[0].sel.marked.firstOrNull, r = _ctl.panes[1].sel.marked.firstOrNull;
|
||
// 막는 조건 · 작업 · 옵션 · 미리 보기 · 실행은 RsyncRun (E5c), 확인 창은 rsync_run_dialog
|
||
final (:run, :problem, :missing) = RsyncRun.prepare(CopyCenter.of(c),
|
||
l: l,
|
||
r: r,
|
||
right: right,
|
||
left: left,
|
||
allowInnerToOuter: c.settings.allowInnerToOuter,
|
||
jobs: _jobs,
|
||
viaDav: l != null && r != null && _viaDav([l, r]),
|
||
copySkipNewer: c.settings.copySkipNewer);
|
||
if (run == null) {
|
||
_snack(problem!);
|
||
if (missing) {
|
||
for (final x in _ctl.panes) {
|
||
x.sel.removeWhere(vMissingSync);
|
||
}
|
||
await _ctl.refreshAll([vDirname(l!), vDirname(r!)]);
|
||
}
|
||
return;
|
||
}
|
||
if (!mounted) return;
|
||
final go = await showRsyncRunDialog(context, run);
|
||
// 110: 무엇을 골랐는지 작업 기록에 (실행 · 취소 · 닫힘)
|
||
c.note(trf('Rsync 확인 창 ({0}): {1}', [
|
||
run.both ? '⇄' : right ? '→' : '←',
|
||
go == true ? tr('실행') : go == false ? tr('취소') : tr('닫힘 (실행 안 함)'),
|
||
]));
|
||
if (go != true || !mounted) return;
|
||
if (run.nestedDelete()) {
|
||
_snack(RsyncRun.nestedDeleteMessage);
|
||
return;
|
||
}
|
||
final exe = run.viaDav ? '' : await _ensureRsync();
|
||
if (exe == null || !mounted) return;
|
||
final res = await run.execute(
|
||
rsyncExe: exe,
|
||
alive: () => mounted,
|
||
note: c.note,
|
||
onStarted: (j) => setState(() => _jobs = [..._jobs.where((x) => !x.finished), j]));
|
||
// 205: 시작하지 못하면 이유를 알린다
|
||
if (res.notStarted != null && mounted) _snack(trf('시작하지 못했습니다: {0}', [res.notStarted]));
|
||
final jobs = res.jobs;
|
||
if (jobs.isEmpty || !mounted) return;
|
||
// 원본 폴더까지 지웠으면 고른 표시도 뺀다
|
||
for (final x in _ctl.panes) {
|
||
x.sel.removeWhere(vMissingSync);
|
||
}
|
||
await _ctl.refreshAll([run.l, run.r, vDirname(run.l), vDirname(run.r)]);
|
||
for (final x in _ctl.panes) {
|
||
x.changed();
|
||
}
|
||
final error = jobs.map((j) => j.error).whereType<Object>().firstOrNull;
|
||
_snack(error is FileOpCancelled
|
||
? tr('취소했습니다.')
|
||
: error != null
|
||
? trf('끝나지 못했습니다: {0}', [error])
|
||
// 212 · 216: 이동인지 · 몇 개인지 · 원본에 남은 것
|
||
: jobs.map((j) => j.doneSummary()).join('\n'));
|
||
await Future<void>.delayed(const Duration(milliseconds: 1200));
|
||
if (mounted) setState(() => _jobs = [for (final j in _jobs) if (!jobs.contains(j)) j]);
|
||
}
|
||
|
||
/// 아래에서 올라오는 진행 막대 (위: 전체 항목 중 · 아래: 지금 폴더의 파일 중)
|
||
Widget _transferPanel() {
|
||
final jobs = _jobs;
|
||
// 85: 숨겼으면 오른쪽 아래 작은 표시 "복사 · 이동 n개 · 45%" (누르면 다시 펼침)
|
||
if (jobs.isNotEmpty && c.settings.explorerProgressHidden) {
|
||
return Align(
|
||
alignment: Alignment.bottomRight,
|
||
child: Padding(
|
||
padding: const EdgeInsets.only(bottom: 8),
|
||
child: ListenableBuilder(
|
||
listenable: Listenable.merge(jobs),
|
||
builder: (context, _) {
|
||
final run = [for (final j in jobs) if (!j.finished) j];
|
||
// 관리자 판단: 숨겨도 멈춘 (실패한) 것은 분명히 - 빨간 표시 (끝난 알림 · 묻는 창은 숨김과 상관없이 늘 뜬다)
|
||
final failed = jobs.any((j) => j.finished && j.error != null && j.error is! FileOpCancelled);
|
||
final pct = run.isEmpty || run.any((j) => j.counting)
|
||
? null
|
||
: (run.fold<double>(0, (a, j) => a + j.overall) / run.length * 100).round();
|
||
return TransferProgressChip(
|
||
running: run.length,
|
||
percent: pct,
|
||
failed: failed,
|
||
onTap: () => c.updateSettings((x) => x.explorerProgressHidden = false));
|
||
},
|
||
),
|
||
),
|
||
);
|
||
}
|
||
return AnimatedSlide(
|
||
offset: jobs.isEmpty ? const Offset(0, 1.2) : Offset.zero,
|
||
duration: const Duration(milliseconds: 250),
|
||
curve: Curves.easeOut,
|
||
child: jobs.isEmpty
|
||
? const SizedBox(height: 0)
|
||
: Material(
|
||
elevation: 12,
|
||
color: JjColors.panel,
|
||
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
|
||
child: Padding(
|
||
padding: const EdgeInsets.fromLTRB(16, 10, 12, 12),
|
||
// 여러 개가 함께 돌면 (23) 높이를 넘지 않게 넘겨 보기
|
||
child: ConstrainedBox(
|
||
constraints: BoxConstraints(maxHeight: MediaQuery.sizeOf(context).height * 0.45),
|
||
child: SingleChildScrollView(
|
||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||
// 85: 숨기기 (아래 작은 표시로) - 다른 파일을 보면서 기다릴 수 있게. Android 는 알림줄에도 진행률
|
||
Align(
|
||
alignment: Alignment.centerRight,
|
||
child: SizedBox(
|
||
height: 28,
|
||
child: TextButton.icon(
|
||
style: TextButton.styleFrom(visualDensity: VisualDensity.compact),
|
||
onPressed: () => c.updateSettings((x) => x.explorerProgressHidden = true),
|
||
icon: const Icon(Icons.expand_more, size: 18),
|
||
label: Text(tr('숨기기'), style: const TextStyle(fontSize: 12)),
|
||
),
|
||
),
|
||
),
|
||
for (final (k, job) in jobs.indexed) ...[
|
||
if (k > 0) const Divider(height: 18),
|
||
ListenableBuilder(listenable: job, builder: (context, _) => _jobView(job)),
|
||
],
|
||
]),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 진행 막대 하나 (위: 전체 항목 중 · 아래: 지금 폴더의 파일 중)
|
||
Widget _jobView(TransferJob job) {
|
||
String pct(double v) => '${(v * 100).round()}%';
|
||
final cur = job.index < job.total ? job.index : job.total - 1;
|
||
final curFiles = job.total == 0 ? 0 : job.filesTotal[cur.clamp(0, job.total - 1)];
|
||
final curDone = job.total == 0 ? 0 : job.filesDone[cur.clamp(0, job.total - 1)];
|
||
final title = job.finished
|
||
? (job.error == null ? tr('완료') : tr('멈춤'))
|
||
: job.move
|
||
? tr('옮기는 중')
|
||
: tr('복사하는 중');
|
||
final labelMax = MediaQuery.sizeOf(context).width * 0.45;
|
||
Widget line(String label, double value, String right) => Padding(
|
||
padding: const EdgeInsets.only(top: 6),
|
||
child: Row(children: [
|
||
SizedBox(
|
||
width: math.min(300, labelMax), // 좁은 화면 (폰 세로) 은 폭의 45% 까지
|
||
child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12.5)),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(3),
|
||
child: LinearProgressIndicator(
|
||
minHeight: 8,
|
||
value: job.counting ? null : value,
|
||
// 빈 곳과 찬 곳이 분명히 다르게
|
||
color: JjColors.accent,
|
||
backgroundColor: JjColors.border,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 10),
|
||
SizedBox(
|
||
width: 150,
|
||
child: Text(right, textAlign: TextAlign.end, style: TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||
),
|
||
]),
|
||
);
|
||
return Column(mainAxisSize: MainAxisSize.min, children: [
|
||
Row(children: [
|
||
Icon(job.move ? Icons.drive_file_move_outline : Icons.copy_outlined, size: 18, color: JjColors.accent),
|
||
const SizedBox(width: 8),
|
||
Text('$title · ${tr(job.method.label)}', style: const TextStyle(fontWeight: FontWeight.w600)),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
// Rsync 화면: 원본 → 대상 (⇄ 는 두 줄)
|
||
child: Text(widget.rsync ? '${job.sources.join(', ')}/ → ${job.dest}/' : job.dest,
|
||
maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||
),
|
||
if (!job.finished) TextButton(onPressed: job.cancel, child: Text(tr('취소'))),
|
||
]),
|
||
// Rsync 화면 (폴더 하나를 맞춤): 위 = 지금 복사하는 파일 (그 파일의 진행률 · 속도), 아래 = 폴더 전체 (파일 수)
|
||
if (widget.rsync) ...[
|
||
line(
|
||
job.counting
|
||
? tr('파일 세는 중…')
|
||
: job.pruning
|
||
? (job.finished ? trf('빈 폴더 {0}개 지움', [job.pruned]) : tr('원본의 빈 폴더 정리 중…'))
|
||
: job.finished
|
||
? tr('끝')
|
||
: job.currentFile.isEmpty
|
||
? tr('시작하는 중…')
|
||
: job.currentFile.split('/').last,
|
||
job.finished ? 1.0 : (job.filePercent ?? 0),
|
||
[
|
||
if (job.filePercent != null && !job.finished) pct(job.filePercent!),
|
||
if (!job.finished && job.speed != null) '${formatSize(job.speed!.round())}/s',
|
||
].join(' · '),
|
||
),
|
||
line(
|
||
trf('{0} 전체', [job.currentName.isEmpty ? vBasename(job.sources.first) : job.currentName]),
|
||
job.folderProgress,
|
||
'${pct(job.folderProgress)} · ${job.checkTotal > 0 && !job.finished ? trf('항목 {0}/{1}', [job.checked, job.checkTotal]) : trf('파일 {0}/{1}', [job.allDone, job.allFiles])}',
|
||
),
|
||
] else ...[
|
||
// 파일 탐색기 (여러 항목): 위 = 전체 항목 중 · 아래 = 지금 폴더의 파일 중
|
||
line(
|
||
trf('전체 {0}개 중 {1}번째', [job.total, (cur + 1).clamp(1, job.total)]),
|
||
job.overall,
|
||
'${pct(job.overall)} · ${trf('파일 {0}/{1}', [job.allDone, job.allFiles])}'
|
||
'${!job.finished && job.speed != null ? ' · ${formatSize(job.speed!.round())}/s' : ''}',
|
||
),
|
||
line(
|
||
job.counting
|
||
? tr('파일 세는 중…')
|
||
: job.pruning
|
||
? (job.finished ? trf('빈 폴더 {0}개 지움', [job.pruned]) : tr('원본의 빈 폴더 정리 중…'))
|
||
: trf('{0} 안의 파일', [job.currentName]),
|
||
job.current,
|
||
'${pct(job.current)} · ${trf('파일 {0}/{1}', [curDone, curFiles])}',
|
||
),
|
||
],
|
||
]);
|
||
}
|
||
|
||
Future<String?> _askName(String title, String initial, {String? hint, bool selectAll = false}) {
|
||
final ctl = TextEditingController(text: initial);
|
||
final dot = initial.lastIndexOf('.');
|
||
ctl.selection = TextSelection(baseOffset: 0, extentOffset: dot > 0 && !selectAll ? dot : initial.length);
|
||
return showDialog<String>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
scrollable: true,
|
||
title: Text(title),
|
||
content: TextField(
|
||
controller: ctl,
|
||
autofocus: true,
|
||
decoration: InputDecoration(hintText: hint ?? tr('이름')),
|
||
onSubmitted: (v) => Navigator.pop(ctx, v.trim().isEmpty ? null : v.trim()),
|
||
),
|
||
actions: [
|
||
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('취소'))),
|
||
FilledButton(
|
||
onPressed: () => Navigator.pop(ctx, ctl.text.trim().isEmpty ? null : ctl.text.trim()),
|
||
child: Text(tr('확인'))),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<void> _info(FileEntry e) async {
|
||
final size = e.isDir ? await FileOps.totalSize([e.path]) : e.size;
|
||
final meta = !e.isDir && !isDav(e.path) && isVideoFile(e.path) ? await _Meta.of(c, e.path) : null;
|
||
// 관리자 지시 10/18: 만든 때 · 숨김 · 읽기 전용 · 시스템 (보기만, 이 기기의 것만)
|
||
final attrs = isDav(e.path) ? null : readFileAttributes(e.path);
|
||
if (!mounted) return;
|
||
await showDialog<void>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
scrollable: true,
|
||
title: Text(e.name),
|
||
content: SelectableText([
|
||
e.path,
|
||
'${tr('크기')}: ${formatSize(size)}',
|
||
if (attrs?.created != null) '${tr('만든 날')}: ${_date(attrs!.created!)}',
|
||
if (e.modified.year > 1) '${tr('바뀐 날')}: ${_date(e.modified)}',
|
||
if (attrs != null) '${tr('속성')}: ${attrs.labels.isEmpty ? tr('없음') : attrs.labels.join(' · ')}',
|
||
if (meta != null && meta.isNotEmpty) '${tr('동영상')}: $meta',
|
||
].join('\n')),
|
||
actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('닫기')))],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 79: 마지막 찾기 조건 (이번 실행 동안 다음 찾기 창의 처음 값)
|
||
SearchConditions _lastSearch = const SearchConditions();
|
||
|
||
/// 79-a: 결과 화면에서 복사 · 이동 · 지우기 · [그 자리로] 를 한 뒤 [찾기 결과로 돌아가기] 로 다시 열 결과 (다음 찾기 · ✕ · 결과 화면을 그냥 닫으면 없어짐)
|
||
({ExplorerPane pane, String root, SearchConditions cond, List<FileEntry> found, bool done, Map<String, String> moved})? _searchBack;
|
||
|
||
/// 79: 찾기 - 이름 · 크기 · 바뀐 날 · 종류 조건 → 결과 목록 화면 (고르고 그대로 복사 · 이동 · 지우기, 누르면 열기 · 재생)
|
||
Future<void> _search(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) => _open(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 _transfer(pane, act.paths, move: false);
|
||
case 'move':
|
||
await _transfer(pane, act.paths, move: true);
|
||
case 'delete':
|
||
await _delete(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): [목록에서 숨기기] 전에 무엇이 바뀌는지 알림. [다음부터 묻지 않기] 는 환경 설정에서 다시 켬
|
||
Future<bool> _confirmAppHide(List<String> targets) async {
|
||
var dontAsk = false;
|
||
final ok = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => StatefulBuilder(
|
||
builder: (ctx, set) => AlertDialog(
|
||
scrollable: true,
|
||
title: Text(targets.length > 1 ? trf('{0}개 목록에서 숨기기', [targets.length]) : tr('목록에서 숨기기')),
|
||
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
if (targets.length == 1) Text(vBasename(targets.single), style: const TextStyle(fontWeight: FontWeight.bold)),
|
||
const SizedBox(height: 6),
|
||
Text(tr('이 앱의 파일 탐색기 목록에서만 빠집니다. 파일은 지우거나 옮기거나 이름을 바꾸지 않고, 다른 앱 · 갤러리에서는 그대로 보입니다.')),
|
||
const SizedBox(height: 6),
|
||
Text(tr('목록 맨 아래의 [숨긴 항목 보이기] 나 환경 설정 > 파일 탐색기에서 다시 볼 수 있습니다.'),
|
||
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||
CheckboxListTile(
|
||
contentPadding: EdgeInsets.zero,
|
||
controlAffinity: ListTileControlAffinity.leading,
|
||
value: dontAsk,
|
||
onChanged: (v) => set(() => dontAsk = v ?? false),
|
||
title: Text(tr('다음부터 묻지 않기')),
|
||
),
|
||
]),
|
||
actions: [
|
||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
|
||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('숨기기'))),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
if (ok != true) return false;
|
||
if (dontAsk) await c.updateSettings((s) => s.explorerHideConfirm = false);
|
||
return true;
|
||
}
|
||
|
||
/// X-plore 맞추기 (관리자 지시 10/20): 폴더에 빈 .nomedia 파일을 넣거나 지운다 (Android 갤러리 · 음악 앱에서 숨기기 · 다시 보이기).
|
||
/// 그 폴더의 파일은 그대로 - 무엇이 바뀌는지 먼저 묻는다. 갤러리에 반영되는 때는 그 앱이 다시 읽을 때다
|
||
Future<void> _nomedia(ExplorerPane pane, String dir, {required bool on}) async {
|
||
final ok = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
scrollable: true,
|
||
title: Text(on ? tr('갤러리 · 음악 앱에서 숨기기 (.nomedia)') : tr('갤러리 · 음악 앱에 다시 보이기 (.nomedia 지우기)')),
|
||
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Text(vBasename(dir), style: const TextStyle(fontWeight: FontWeight.bold)),
|
||
const SizedBox(height: 6),
|
||
Text(on
|
||
? tr('이 폴더에 빈 .nomedia 파일을 만듭니다. 갤러리 · 음악 앱이 이 폴더 (안의 폴더 포함) 의 그림 · 동영상 · 음악을 보이지 않게 됩니다.')
|
||
: tr('이 폴더의 .nomedia 파일을 지웁니다. 갤러리 · 음악 앱이 이 폴더의 그림 · 동영상 · 음악을 다시 보이게 됩니다.')),
|
||
const SizedBox(height: 6),
|
||
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(on ? tr('숨기기') : tr('다시 보이기'))),
|
||
],
|
||
),
|
||
);
|
||
if (ok != true || !mounted) return;
|
||
final f = File(p.join(dir, '.nomedia'));
|
||
try {
|
||
if (on) {
|
||
if (!await f.exists()) await f.writeAsBytes(const []);
|
||
} else if (await f.exists()) {
|
||
await f.delete();
|
||
}
|
||
} catch (err) {
|
||
_snack(trf('바꾸지 못했습니다: {0}', [err]));
|
||
return;
|
||
}
|
||
// 관리자 지시 10/20: 갤러리가 바로 알도록 .nomedia 와 그 폴더를 다시 읽게 (넣을 때 · 지울 때 모두, 실패해도 조용히)
|
||
await c.services.shell.mediaScan([f.path, dir]);
|
||
await _ctl.refreshAll([dir]);
|
||
pane.changed();
|
||
_snack(on ? tr('갤러리 · 음악 앱에서 숨겼습니다 (.nomedia). 파일은 그대로입니다.') : tr('.nomedia 를 지웠습니다. 갤러리 · 음악 앱에 다시 보입니다.'));
|
||
}
|
||
|
||
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 화면이면 짝 (왼쪽 · 오른쪽) 을 한 번에
|
||
Future<void> _bookmarks(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(() {});
|
||
}
|
||
|
||
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: 두 창이면 좌우 ⇆ 위아래 를 바꾸고, 한 창이면 두 창 (좌우) 으로. 한 번 눌러 바로
|
||
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);
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
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 =>
|
||
_selecting || _ctl.panes.any((x) => !x.sel.isEmpty) || (_ctl.ready && !samePath(_ctl.pane.current, _ctl.pane.root));
|
||
|
||
/// 49: 뒤로 키 - 먼저 고른 것 풀기, 그다음 상위 폴더, 맨 위면 화면 닫기 (위쪽 ← 는 늘 닫기)
|
||
Future<void> _onBack() async {
|
||
if (_selecting || _ctl.panes.any((x) => !x.sel.isEmpty)) {
|
||
_setSelecting(false);
|
||
return;
|
||
}
|
||
if (!samePath(_ctl.pane.current, _ctl.pane.root)) {
|
||
await _button(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 (_selecting || _ctl.panes.any((x) => !x.sel.isEmpty)) {
|
||
_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: _transferPanel()),
|
||
]),
|
||
),
|
||
]);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 지금 폴더 · 그 위 폴더들 (창의 맨 위 [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 ? _button(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
|
||
: _Toolbar(
|
||
// X-plore b: 길게 누르기 = 짝 동작 (Rsync 화면은 없음)
|
||
onLongPress: widget.rsync ? null : _buttonLong,
|
||
// Rsync 화면: 늘 같은 버튼 (→ ← ⇄ 모니터링) 을 두 창 사이에
|
||
buttons: widget.rsync ? ExplorerButton.rsyncButtons : ExplorerButton.fromSettings(s.explorerButtons),
|
||
vertical: side && !rsyncOne,
|
||
panesSide: side,
|
||
onPressed: _button,
|
||
enabled: (b) => switch (b) {
|
||
ExplorerButton.paste => _clip.isNotEmpty,
|
||
_ => true,
|
||
},
|
||
selected: (b) =>
|
||
(b == ExplorerButton.hidden && s.explorerShowHidden) || (b == ExplorerButton.select && _selecting),
|
||
twoPanes: dual || split,
|
||
pinned: {ExplorerButton.orient, if (_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, _paneView(i, foldersOnly: true))),
|
||
]);
|
||
}
|
||
final children = <Widget>[
|
||
if (dual) ...[
|
||
Expanded(child: keep(0, _paneView(0, foldersOnly: widget.rsync))),
|
||
if (middle) bar,
|
||
Expanded(child: keep(1, _paneView(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, _paneView(0, foldersOnly: true)))
|
||
else
|
||
Expanded(flex: 2, child: keep(0, _paneView(0, foldersOnly: true))),
|
||
if (middle) bar,
|
||
Expanded(flex: 3, child: keep(2, _listView(_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, _paneView(0)))])
|
||
: keep(0, _paneView(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) =>
|
||
_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 (_insideArchive(dest) || _ctl.arc.contains(dest)) {
|
||
_snack(tr('압축 안은 읽기 전용입니다 (꺼내기만 할 수 있습니다)'));
|
||
return;
|
||
}
|
||
if (load.paths.any(_insideArchive)) {
|
||
await _extractFromArchive(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 _runTransfer(_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 _runTransfer(pane, there, dir, move: true);
|
||
}
|
||
}
|
||
|
||
/// 선택 모드 막대: 고른 수 · 모두 고르기 · 선택 끝
|
||
Widget _marksBar(ExplorerPane pane, {String? listDir}) {
|
||
final dir = listDir ?? pane.current;
|
||
// 74: 고른 개수와 합계 크기 (읽어 둔 목록 기준, 폴더 크기는 빼고)
|
||
var size = 0, folders = 0;
|
||
final byPath = {for (final l in pane.cache.values) for (final x in l) x.path: x};
|
||
for (final m in pane.sel.marked) {
|
||
final x = byPath[m];
|
||
if (x == null) continue;
|
||
if (x.isDir) {
|
||
folders++;
|
||
} else {
|
||
size += x.size;
|
||
}
|
||
}
|
||
final focusedExt = pane.focused == null || byPath[pane.focused]?.isDir != false ? '' : extOf(pane.focused!);
|
||
final info = <Widget>[
|
||
Icon(Icons.checklist, size: 18, color: JjColors.accent),
|
||
const SizedBox(width: 6),
|
||
Flexible(
|
||
child: Text(
|
||
[
|
||
trf('{0}개 표시함', [pane.sel.marked.length]),
|
||
if (size > 0) formatSize(size),
|
||
if (folders > 0) trf('폴더 {0}개 (크기 빼고)', [folders]),
|
||
].join(' · '),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: const TextStyle(fontSize: 12)),
|
||
),
|
||
];
|
||
final actions = <Widget>[
|
||
PopupMenuButton<String>(
|
||
tooltip: tr('고르기 도구'),
|
||
onSelected: (v) {
|
||
switch (v) {
|
||
case 'invert':
|
||
_invertMarks(pane, dir);
|
||
case 'pattern':
|
||
_markPattern(pane, dir);
|
||
case 'sameExt':
|
||
_markWhere(pane, dir, (x) => !x.isDir && extOf(x.path) == focusedExt);
|
||
}
|
||
},
|
||
itemBuilder: (_) => [
|
||
PopupMenuItem(value: 'invert', child: Text(tr('반전 (고른 것 ↔ 안 고른 것)'))),
|
||
PopupMenuItem(value: 'pattern', child: Text(tr('패턴으로 고르기 (예: *.srt)'))),
|
||
PopupMenuItem(
|
||
value: 'sameExt',
|
||
enabled: focusedExt.isNotEmpty,
|
||
child: Text(focusedExt.isEmpty ? tr('같은 확장자 모두') : trf('같은 확장자 모두 (.{0})', [focusedExt])),
|
||
),
|
||
],
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||
Text(tr('고르기 도구'), style: TextStyle(fontSize: 13, color: JjColors.accent)),
|
||
Icon(Icons.arrow_drop_down, size: 18, color: JjColors.accent),
|
||
]),
|
||
),
|
||
),
|
||
TextButton(
|
||
onPressed: () => setState(() {
|
||
final dir = listDir ?? pane.current;
|
||
pane.sel.addAll([for (final x in _shown(pane, dir)) x.path]);
|
||
}),
|
||
child: Text(tr('모두 선택')),
|
||
),
|
||
TextButton(onPressed: () => setState(pane.sel.clear), child: Text(tr('표시 지우기'))),
|
||
TextButton(onPressed: () => _setSelecting(false), child: Text(tr('선택 끝'))),
|
||
];
|
||
return Container(
|
||
color: JjColors.panel,
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||
// 좁은 창 (폰 세로의 두 창 - 규칙 7, 점검 10/22) 은 두 줄로
|
||
child: AdaptiveRow(lead: info, trail: actions, minWidth: 560),
|
||
);
|
||
}
|
||
|
||
/// 트리 창 (저장 장치 고르기 + 트리)
|
||
/// 관리자 지시 10/20 · 10/21: 좁은 창의 열 - 숨길 열 · 날짜를 짧게 쓸지. 줄의 흐린 글과 같은 모양으로 날짜 글 폭을 잰다
|
||
/// (날짜가 "2026-10-2…" 로 잘리지 않게). 열 머리 · 줄이 같이 쓴다
|
||
void _layoutColumns(BuildContext context, ExplorerPane pane, ExplorerStyle look, double width) {
|
||
final scale = MediaQuery.textScalerOf(context).scale(14) / 14 * _listTextScale;
|
||
final style = DefaultTextStyle.of(context).style.merge(TextStyle(fontSize: look.fontSize - 2));
|
||
double measure(String t) =>
|
||
(TextPainter(text: TextSpan(text: t, style: style), textDirection: TextDirection.ltr, textScaler: TextScaler.linear(scale), maxLines: 1)
|
||
..layout())
|
||
.width;
|
||
final lay = columnLayout(look, width, marks: _selecting, textScale: scale, measure: measure);
|
||
pane.colHide = lay.$1;
|
||
pane.colDateShort = lay.$2;
|
||
}
|
||
|
||
Widget _paneView(int i, {bool foldersOnly = false}) {
|
||
final pane = _ctl.panes[i];
|
||
final rows = _ctl.rows(pane, foldersOnly: foldersOnly);
|
||
final look = _ctl.look;
|
||
// 38: 창 높이를 알아야 안내 상자의 높이를 제한할 수 있다
|
||
return LayoutBuilder(builder: (context, box) {
|
||
// 열을 그리는 창만 숨길 열을 정한다 ("폴더 + 파일 목록" 의 트리는 열이 없고 목록과 같은 창을 쓴다)
|
||
if (look.columns && !foldersOnly) _layoutColumns(context, pane, look, box.maxWidth);
|
||
return _frame(i, [
|
||
_paneHeader(pane),
|
||
const Divider(height: 1),
|
||
// 보이는 수는 맞는 것만 (맞는 것이 든 위 폴더는 빼고)
|
||
if (pane.filterOpen)
|
||
_filterBar(pane, rows.where((r) => !r.isRoot && isSameOrInside(r.entry.path, pane.current) && !samePath(r.entry.path, pane.current) && quickFilterMatch(vBasename(r.entry.path), pane.filter)).length),
|
||
// 38: 안내가 길어도 목록을 덮지 않게 (창 높이의 35% 까지, 넘치면 넘겨 봄)
|
||
?_noticeArea([_shizukuNotice(pane), _accessNotice(pane), _errorNotice(pane, pane.current), _partialNotice(pane, pane.current)], box.maxHeight),
|
||
if (look.columns && !foldersOnly)
|
||
ExplorerColumnsHeader(
|
||
style: look,
|
||
markColumn: _selecting,
|
||
hide: pane.colHide,
|
||
sortBy: _ctl.sortOf(pane.current).$1,
|
||
sortDesc: _ctl.sortOf(pane.current).$2,
|
||
onSort: (by) => _ctl.sortByColumn(pane, by),
|
||
),
|
||
Expanded(
|
||
child: Stack(children: [
|
||
scaleListText(context, _listTextScale, ListView.builder(
|
||
controller: pane.scroll,
|
||
itemCount: rows.length,
|
||
itemExtent: _rowH(),
|
||
// 76: 줄 위젯은 항목을 따라간다 (아래 [_rowKey])
|
||
itemBuilder: (_, k) => KeyedSubtree(
|
||
key: _rowKey(rows[k]),
|
||
child: rows[k].kind != 0 ? _extraRow(pane, rows[k]) : _rowView(pane, rows[k], compact: foldersOnly)),
|
||
findChildIndexCallback: _rowIndexOf(rows),
|
||
)),
|
||
// 읽는 중 (WebDAV 서버를 기다릴 때 등). 180: 목록 위에 겹쳐 그린다 (끼어들면 목록이 밀려 다음 것을 누르려다 엉뚱한 것을 누른다)
|
||
if (pane.loading.isNotEmpty) const Positioned(left: 0, right: 0, top: 0, child: LinearProgressIndicator(minHeight: 2)),
|
||
]),
|
||
),
|
||
if ((_selecting || (_alwaysMarks && !pane.sel.isEmpty)) && !foldersOnly) _marksBar(pane),
|
||
]);
|
||
});
|
||
}
|
||
|
||
/// 76: 목록 줄의 키 = 항목 경로. 키가 없으면 줄 목록이 바뀔 때 (빠른 필터 · 펼치기 · 새로 고침) 같은 자리의 줄 위젯을
|
||
/// 다른 항목에 다시 써서, 방금 누른 파일 줄이 폴더 줄로 바뀌면 두 번 누르기 기다림이 그리는 중에 버려지고
|
||
/// 그 누르기가 다른 항목에 들어갔다 (디버그에서는 "setState() called during build")
|
||
static Key _rowKey(TreeRow r) =>
|
||
ValueKey<String>('${r.kind != 0 ? 'k${r.kind}:' : r.isRoot ? 'root:' : r.isUp ? 'up:' : ''}${r.entry.path}');
|
||
|
||
/// 76: 줄이 옮겨 가도 같은 줄 위젯을 찾게 (키 → 지금 자리)
|
||
static int? Function(Key) _rowIndexOf(List<TreeRow> rows) {
|
||
Map<Key, int>? index;
|
||
return (key) => (index ??= {for (var i = 0; i < rows.length; i++) _rowKey(rows[i]): i})[key];
|
||
}
|
||
|
||
/// "폴더 + 파일 목록" 배치의 오른쪽: 왼쪽 트리에서 고른 폴더의 내용 (맨 위 ".." = 상위 폴더)
|
||
Widget _listView(ExplorerPane pane) {
|
||
final dir = pane.current;
|
||
final entries = pane.cache[dir] ?? const <FileEntry>[];
|
||
final up = !samePath(dir, pane.root);
|
||
final shown = _shown(pane, dir);
|
||
final hidden = entries.where((e) => _ctl.appHidden(e.path)).length;
|
||
final rows = [
|
||
if (up) TreeRow(FileEntry(vDirname(dir), isDir: true, modified: DateTime(0)), 0, isUp: true),
|
||
for (final e in shown) TreeRow(e, 0),
|
||
// X-plore 숨기기: 맨 아래 [숨긴 항목 n개 보이기]
|
||
if (hidden > 0) TreeRow(FileEntry('\u0000hidden', isDir: false, modified: DateTime(0)), 0, kind: TreeRow.hiddenToggle, count: hidden),
|
||
];
|
||
final look = _ctl.look;
|
||
// 38: 창 높이를 알아야 안내 상자의 높이를 제한할 수 있다
|
||
return LayoutBuilder(builder: (context, box) {
|
||
if (look.columns) _layoutColumns(context, pane, look, box.maxWidth);
|
||
return _frame(0, [
|
||
Container(
|
||
height: 40,
|
||
alignment: Alignment.centerLeft,
|
||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||
child: Text(_ctl.displayPath(dir), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: JjColors.textDim)),
|
||
),
|
||
const Divider(height: 1),
|
||
if (pane.filterOpen) _filterBar(pane, shown.length),
|
||
?_noticeArea([_shizukuNotice(pane), _accessNotice(pane), _errorNotice(pane, dir), _partialNotice(pane, dir)], box.maxHeight),
|
||
if (look.columns)
|
||
ExplorerColumnsHeader(
|
||
style: look,
|
||
markColumn: _selecting,
|
||
hide: pane.colHide,
|
||
sortBy: _ctl.sortOf(dir).$1,
|
||
sortDesc: _ctl.sortOf(dir).$2,
|
||
onSort: (by) => _ctl.sortByColumn(pane, by),
|
||
),
|
||
Expanded(
|
||
// 180: 다시 읽는 동안은 앞의 목록을 그대로 (빙글이로 바꾸면 목록이 새로 만들어져 스크롤이 맨 위로 간다)
|
||
child: pane.loading.contains(dir) && !pane.cache.containsKey(dir)
|
||
? const Center(child: CircularProgressIndicator())
|
||
: entries.isEmpty && !up
|
||
? Center(child: Text(tr('비어 있음'), style: TextStyle(color: JjColors.textDim)))
|
||
: scaleListText(context, _listTextScale, ListView.builder(
|
||
controller: pane.listScroll,
|
||
itemCount: rows.length,
|
||
itemExtent: _rowH(),
|
||
itemBuilder: (_, k) => KeyedSubtree(
|
||
key: _rowKey(rows[k]), child: rows[k].kind != 0 ? _extraRow(pane, rows[k]) : _rowView(pane, rows[k], list: true)),
|
||
findChildIndexCallback: _rowIndexOf(rows),
|
||
)),
|
||
),
|
||
if (_selecting) _marksBar(pane, listDir: dir),
|
||
]);
|
||
});
|
||
}
|
||
|
||
/// 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': () => _button(ExplorerButton.copy),
|
||
'move': () => _button(ExplorerButton.move),
|
||
'newFolder': () => _button(ExplorerButton.newFolder),
|
||
'delete': () => _button(ExplorerButton.delete),
|
||
// 65: Shift+Delete = 휴지통을 거치지 않고 (창에서 한 번 더 확인)
|
||
'deletePermanent': () {
|
||
_shiftDelete = true;
|
||
_button(ExplorerButton.delete);
|
||
},
|
||
'rename': () => _button(ExplorerButton.rename),
|
||
'refresh': () => _button(ExplorerButton.refresh),
|
||
// 81: 빠른 보기
|
||
'quickLook': () => _quickLook(_ctl.pane),
|
||
'clipCopy': () => _toClipboard(_targets(_ctl.pane), move: false),
|
||
'clipCut': () => _toClipboard(_targets(_ctl.pane), move: true),
|
||
'paste': () => _paste(_ctl.pane, _ctl.pane.current),
|
||
'selectAll': () {
|
||
if (!_selecting) setState(() => _selecting = true);
|
||
_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': () => _button(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: 빠른 보기 - 지금 줄 (없으면 아무것도 안 함). 같은 폴더에 보이는 항목을 ← → 로
|
||
Future<void> _quickLook(ExplorerPane pane, [String? path]) async {
|
||
final f = path ?? pane.focused;
|
||
if (f == null || samePath(f, pane.root)) return;
|
||
final list = _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) => _BigThumbs.of(c, e),
|
||
imageExts: c.settings.imageExts,
|
||
onOpen: (e) => e.isDir ? unawaited(_ctl.goTo(pane, e.path)) : unawaited(_open(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 = _shown(pane, vDirname(f));
|
||
final i = list.indexWhere((x) => samePath(x.path, f));
|
||
if (i < 0) return;
|
||
if (!_selecting) setState(() => _selecting = true);
|
||
await _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;
|
||
}
|
||
|
||
/// 76: 창 위 줄 아래의 빠른 필터 칸 - 보이는 수 · 맞는 것이 없을 때 안내 · [✕]
|
||
Widget _filterBar(ExplorerPane pane, int shown) {
|
||
final none = pane.filter.isNotEmpty && shown == 0;
|
||
return Container(
|
||
height: 40,
|
||
color: JjColors.panel,
|
||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||
child: Row(children: [
|
||
Icon(Icons.filter_alt, size: 16, color: JjColors.accent),
|
||
const SizedBox(width: 6),
|
||
Expanded(
|
||
child: CallbackShortcuts(
|
||
bindings: {const SingleActivator(LogicalKeyboardKey.escape): () => _closeFilter(pane)},
|
||
child: TextField(
|
||
controller: pane.filterCtl,
|
||
focusNode: pane.filterFocus,
|
||
style: const TextStyle(fontSize: 13),
|
||
decoration: InputDecoration(
|
||
isDense: true,
|
||
border: InputBorder.none,
|
||
hintText: tr('지금 폴더에서 이름에 든 글자로 좁히기 (* · ? 패턴도 됨)'),
|
||
),
|
||
onChanged: (_) => setState(() {}),
|
||
),
|
||
),
|
||
),
|
||
if (pane.filter.isNotEmpty)
|
||
Text(none ? tr('맞는 것이 없습니다') : trf('{0}개 보임', [shown]),
|
||
style: TextStyle(fontSize: 12, color: none ? JjColors.danger : JjColors.textDim)),
|
||
IconButton(
|
||
tooltip: keyHint(tr('필터 닫기 (모두 보기)'), 'Esc'),
|
||
visualDensity: VisualDensity.compact,
|
||
icon: const Icon(Icons.close, size: 16),
|
||
onPressed: () => _closeFilter(pane),
|
||
),
|
||
]),
|
||
);
|
||
}
|
||
|
||
/// 창 위쪽: 저장 장치 고르기
|
||
/// 창 위 줄: [뒤로 · 앞으로 · 즐겨찾기] · 저장 장치 탭 (넘치면 옆으로 밀기) · [WebDAV · 경로 입력].
|
||
/// 202: 드라이브가 많아 넘쳐도 끝의 버튼은 늘 보이게 (예전에는 줄 전체가 한 목록이라 화면 밖으로 밀려 누를 수 없었다)
|
||
/// 좁은 창 (위아래 두 창 · 폰, [paneHeaderWideWidth] 미만) 은 앞 버튼도 탭과 함께 밀고, 끝 버튼은 [⋮] 하나로 (늘 보임)
|
||
Widget _paneHeader(ExplorerPane pane) => SizedBox(
|
||
height: 40,
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||
child: LayoutBuilder(
|
||
builder: (context, box) => box.maxWidth >= paneHeaderWideWidth
|
||
? Row(children: [
|
||
..._headerStart(pane),
|
||
Expanded(child: _driveTabs(pane)),
|
||
..._headerEnd(pane),
|
||
])
|
||
: Row(children: [
|
||
Expanded(child: _driveTabs(pane, lead: _headerStart(pane))),
|
||
_headerMenu(pane),
|
||
]),
|
||
),
|
||
),
|
||
);
|
||
|
||
/// 202: 좁은 창의 끝 버튼 - WebDAV 서버 추가 · 경로 입력
|
||
Widget _headerMenu(ExplorerPane pane) => PopupMenuButton<String>(
|
||
tooltip: tr('경로 입력 · 네트워크 서버'),
|
||
icon: const Icon(Icons.more_vert, size: 18),
|
||
padding: EdgeInsets.zero,
|
||
onSelected: (v) => switch (v) {
|
||
'path' => _enterPath(pane),
|
||
'sftp' => _editSftp(pane, null),
|
||
'smb' => _editSmb(pane, null),
|
||
'ftp' => _editFtp(pane, null),
|
||
'dlna' => openDlna(context, c),
|
||
_ => _editDav(pane, null),
|
||
},
|
||
itemBuilder: (_) => [
|
||
PopupMenuItem(
|
||
value: 'path',
|
||
child: ListTile(dense: true, leading: const Icon(Icons.edit_location_alt_outlined), title: Text(tr('경로 입력')))),
|
||
PopupMenuItem(
|
||
value: 'dav', child: ListTile(dense: true, leading: const Icon(Icons.cloud_outlined), title: Text(tr('WebDAV 서버 추가')))),
|
||
PopupMenuItem(
|
||
value: 'sftp', child: ListTile(dense: true, leading: const Icon(Icons.vpn_key_outlined), title: Text(tr('SFTP 서버 추가')))),
|
||
PopupMenuItem(
|
||
value: 'ftp', child: ListTile(dense: true, leading: const Icon(Icons.folder_shared_outlined), title: Text(tr('FTP 서버 추가')))),
|
||
// 88: SMB 는 Android 만 (Windows 는 경로 입력에 \\서버\공유)
|
||
if (ExplorerPage.debugAndroidPaths ?? Platform.isAndroid)
|
||
PopupMenuItem(
|
||
value: 'smb', child: ListTile(dense: true, leading: const Icon(Icons.dns_outlined), title: Text(tr('SMB 서버 추가')))),
|
||
// X-plore 비교 5-2 (사용자 결정 10/21): 같은 망의 미디어 서버 보기 (DLNA, 보기만)
|
||
if (!widget.rsync)
|
||
PopupMenuItem(
|
||
value: 'dlna', child: ListTile(dense: true, leading: const Icon(Icons.cast_outlined), title: Text(tr('미디어 서버 (DLNA)')))),
|
||
],
|
||
);
|
||
|
||
/// 창 위 줄 앞: 뒤로 · 앞으로 · 즐겨찾기
|
||
List<Widget> _headerStart(ExplorerPane pane) => [
|
||
// 75: 이 창의 뒤로 · 앞으로 (가 본 폴더)
|
||
IconButton(
|
||
tooltip: keyHint(tr('뒤로'), 'Alt+←'),
|
||
visualDensity: VisualDensity.compact,
|
||
icon: const Icon(Icons.arrow_back, size: 16),
|
||
onPressed: !pane.nav.canBack ? null : () => _ctl.travel(pane, forward: false),
|
||
),
|
||
IconButton(
|
||
tooltip: keyHint(tr('앞으로'), 'Alt+→'),
|
||
visualDensity: VisualDensity.compact,
|
||
icon: const Icon(Icons.arrow_forward, size: 16),
|
||
onPressed: !pane.nav.canForward ? null : () => _ctl.travel(pane, forward: true),
|
||
),
|
||
// 73: 즐겨찾기 (창 위에서 한 번에 - 드라이브가 많아도 보이게 맨 앞)
|
||
IconButton(
|
||
tooltip: tr('즐겨찾기'),
|
||
visualDensity: VisualDensity.compact,
|
||
icon: Icon(
|
||
c.settings.folderBookmarks.any((b) => !b.isPair && samePath(b.path, pane.current)) ? Icons.star : Icons.star_outline,
|
||
size: 16,
|
||
color: JjColors.amber),
|
||
onPressed: () => _bookmarks(pane),
|
||
),
|
||
// 76: 빠른 필터 (켜져 있으면 강조)
|
||
IconButton(
|
||
tooltip: keyHint(tr('빠른 필터'), 'Ctrl+F'),
|
||
visualDensity: VisualDensity.compact,
|
||
isSelected: pane.filterOpen,
|
||
icon: Icon(pane.filterOpen ? Icons.filter_alt : Icons.filter_alt_outlined,
|
||
size: 16, color: pane.filterOpen ? JjColors.accent : null),
|
||
onPressed: () => pane.filterOpen ? _closeFilter(pane) : _openFilter(pane),
|
||
),
|
||
];
|
||
|
||
/// 202: 저장 장치 탭 - 넘치면 마우스 휠 · 끌기로도 옆으로. 비어 있거나 연결 안 된 드라이브는 숨기지 않고 뒤쪽으로
|
||
Widget _driveTabs(ExplorerPane pane, {List<Widget> lead = const []}) {
|
||
final all = [
|
||
..._ctl.volumes,
|
||
// 37: 지금 열어 둔 네트워크 공유 (\\NAS\공유) 도 탭으로
|
||
for (final x in _ctl.panes)
|
||
if (Platform.isWindows && x.root.startsWith(r'\\') && !_ctl.volumes.any((v) => samePath(v.$1, x.root))) (x.root, x.root),
|
||
];
|
||
final tabs = [
|
||
...all.where((v) => _ctl.drives.ok[v.$1] != false || samePath(pane.root, v.$1)),
|
||
...all.where((v) => _ctl.drives.ok[v.$1] == false && !samePath(pane.root, v.$1)),
|
||
];
|
||
return Listener(
|
||
// 마우스 휠 (위 · 아래) 을 옆으로
|
||
onPointerSignal: (e) {
|
||
if (e is! PointerScrollEvent || !pane.tabScroll.hasClients) return;
|
||
final pos = pane.tabScroll.position;
|
||
final d = e.scrollDelta.dx != 0 ? e.scrollDelta.dx : e.scrollDelta.dy;
|
||
pane.tabScroll.jumpTo((pos.pixels + d).clamp(0.0, pos.maxScrollExtent));
|
||
},
|
||
child: ScrollConfiguration(
|
||
// 마우스로 끌어도 옆으로 (데스크톱은 기본으로 마우스 끌기가 꺼져 있음)
|
||
behavior: ScrollConfiguration.of(context).copyWith(
|
||
dragDevices: {PointerDeviceKind.touch, PointerDeviceKind.mouse, PointerDeviceKind.trackpad, PointerDeviceKind.stylus},
|
||
scrollbars: false,
|
||
),
|
||
child: ListView(
|
||
controller: pane.tabScroll,
|
||
scrollDirection: Axis.horizontal,
|
||
children: [
|
||
...lead,
|
||
for (final (path, label) in tabs)
|
||
Padding(
|
||
padding: const EdgeInsets.only(right: 4),
|
||
child: TextButton.icon(
|
||
style: TextButton.styleFrom(
|
||
visualDensity: VisualDensity.compact,
|
||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||
backgroundColor: samePath(pane.root, path) ? JjColors.accent.withValues(alpha: 0.18) : null,
|
||
foregroundColor: samePath(pane.root, path) ? JjColors.accent : null,
|
||
),
|
||
icon: Icon(
|
||
isRootPath(path)
|
||
? Icons.security
|
||
: isShizuku(path)
|
||
? Icons.admin_panel_settings_outlined
|
||
: isSftp(path)
|
||
? Icons.vpn_key_outlined
|
||
: isFtp(path)
|
||
? Icons.folder_shared_outlined
|
||
: isSmb(path)
|
||
? Icons.dns_outlined
|
||
: isDav(path)
|
||
? Icons.cloud_outlined
|
||
: _ctl.drives.network.contains(path)
|
||
? Icons.lan_outlined
|
||
: Platform.isWindows
|
||
? Icons.storage
|
||
: Icons.sd_storage_outlined,
|
||
size: 16),
|
||
// 165: 열 수 없는 드라이브는 숨기지 않고 "연결 안 됨" - 누르면 다시 연결 (다시 읽기)
|
||
label: _ctl.drives.ok[path] == false
|
||
? Text('$label · ${_ctl.drives.removable.contains(path) ? tr('비어 있음') : tr('연결 안 됨')} ⟳',
|
||
style: TextStyle(color: _ctl.drives.removable.contains(path) ? JjColors.textDim : JjColors.warning))
|
||
: Text(label),
|
||
onPressed: () {
|
||
setState(() => _ctl.active = _ctl.panes.indexOf(pane));
|
||
if (isRootPath(path)) {
|
||
unawaited(_openRoot(pane));
|
||
} else if (isShizuku(path)) {
|
||
unawaited(_openShizuku(pane));
|
||
} else if (_ctl.drives.ok[path] == false) {
|
||
unawaited(_reconnect(pane, path));
|
||
} else {
|
||
_ctl.goTo(pane, path);
|
||
}
|
||
},
|
||
onLongPress: isSftp(path)
|
||
? () => _editSftp(pane, path)
|
||
: isFtp(path)
|
||
? () => _editFtp(pane, path)
|
||
: isSmb(path)
|
||
? () => _editSmb(pane, path)
|
||
: isDav(path)
|
||
? () => _editDav(pane, path)
|
||
: null,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 202: 창 위 줄 끝 - 탭이 넘쳐도 늘 보이는 버튼 (WebDAV 서버 추가 · 경로 입력)
|
||
List<Widget> _headerEnd(ExplorerPane pane) => [
|
||
if (c.settings.webdavServers.isEmpty)
|
||
TextButton.icon(
|
||
style: TextButton.styleFrom(
|
||
visualDensity: VisualDensity.compact,
|
||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||
),
|
||
icon: const Icon(Icons.cloud_outlined, size: 16),
|
||
label: const Text('WebDAV'),
|
||
onPressed: () => _editDav(pane, null),
|
||
)
|
||
else
|
||
IconButton(
|
||
tooltip: tr('WebDAV 서버 추가'),
|
||
visualDensity: VisualDensity.compact,
|
||
icon: const Icon(Icons.add, size: 16),
|
||
onPressed: () => _editDav(pane, null),
|
||
),
|
||
// 37: 경로를 직접 넣어 가기 (Windows 는 \\NAS\공유 도)
|
||
// 88: SFTP 서버 추가
|
||
IconButton(
|
||
tooltip: tr('SFTP 서버 추가'),
|
||
visualDensity: VisualDensity.compact,
|
||
icon: const Icon(Icons.vpn_key_outlined, size: 16),
|
||
onPressed: () => _editSftp(pane, null),
|
||
),
|
||
// l: FTP · FTPS 서버 추가
|
||
IconButton(
|
||
tooltip: tr('FTP 서버 추가'),
|
||
visualDensity: VisualDensity.compact,
|
||
icon: const Icon(Icons.folder_shared_outlined, size: 16),
|
||
onPressed: () => _editFtp(pane, null),
|
||
),
|
||
// X-plore 비교 5-2 (사용자 결정 10/21): 같은 망의 미디어 서버 보기 (DLNA, 보기만)
|
||
if (!widget.rsync)
|
||
IconButton(
|
||
tooltip: tr('미디어 서버 (DLNA)'),
|
||
visualDensity: VisualDensity.compact,
|
||
icon: const Icon(Icons.cast_outlined, size: 16),
|
||
onPressed: () => openDlna(context, c),
|
||
),
|
||
IconButton(
|
||
tooltip: tr('경로 입력'),
|
||
visualDensity: VisualDensity.compact,
|
||
icon: const Icon(Icons.edit_location_alt_outlined, size: 16),
|
||
onPressed: () => _enterPath(pane),
|
||
),
|
||
];
|
||
|
||
/// 경로를 직접 넣어 가기 (37). 146 · 37-2: 없는 폴더면 창을 닫지 않고 칸 아래에 알린다 (고쳐서 다시 넣을 수 있게)
|
||
Future<void> _enterPath(ExplorerPane pane) async {
|
||
setState(() => _ctl.active = _ctl.panes.indexOf(pane));
|
||
final path = await showDialog<String>(context: context, builder: (_) => _PathDialog(initial: pane.current));
|
||
if (path == null || !mounted) return;
|
||
// 179: SMB 를 넣었다가 [WebDAV 서버 추가] 를 누름
|
||
if (path == _PathDialog.addDav) return _editDav(pane, null);
|
||
// 88: Android 에서 smb:// · \\서버\공유 를 넣고 [SMB 서버 추가]
|
||
if (path.startsWith(_PathDialog.addSmb)) return _editSmb(pane, null, from: path.substring(_PathDialog.addSmb.length));
|
||
await _ctl.goTo(pane, path, fresh: true);
|
||
}
|
||
|
||
/// 146: 넣은 경로를 고쳐 쓰기 · 확인. 갈 수 있으면 그 경로, 아니면 칸 아래에 보일 이유
|
||
static Future<(String?, String?)> checkEnteredPath(String input) async {
|
||
var path = input.trim().replaceAll('"', '');
|
||
if (path.isEmpty) return (null, null);
|
||
// 179: Android 는 네트워크 공유 (SMB) 를 아직 못 연다 - 폴더가 없다고 하지 않고 WebDAV 로 안내
|
||
if ((ExplorerPage.debugAndroidPaths ?? Platform.isAndroid) && looksLikeSmb(path)) return (null, smbNotSupported);
|
||
// 190: Windows 는 smb:// · cifs:// 주소를 \\서버\공유 로 바꿔 연다 (주소가 틀린 줄 알지 않게)
|
||
final unc = Platform.isWindows ? smbToUnc(path) : null;
|
||
if (unc != null) path = unc;
|
||
if (Platform.isWindows && RegExp(r'^\\\\[^\\/]+[\\/]?$').hasMatch(path)) {
|
||
return (null, tr('네트워크 공유는 공유 폴더 이름까지 넣으세요 (예: \\\\NAS\\영상).'));
|
||
}
|
||
if (!isDav(path) && Platform.isWindows && RegExp(r'^[a-zA-Z]:$').hasMatch(path)) path = '$path\\';
|
||
bool ok;
|
||
try {
|
||
ok = isDav(path) || await Directory(path).exists();
|
||
} catch (_) {
|
||
ok = false;
|
||
}
|
||
if (!ok && unc != null) {
|
||
return (null, trf('폴더가 없거나 열 수 없습니다. Windows 의 네트워크 공유는 \\\\서버\\공유 형식입니다 (바꾼 경로: {0})', [unc]));
|
||
}
|
||
return ok ? (path, null) : (null, tr('폴더가 없거나 열 수 없습니다'));
|
||
}
|
||
|
||
/// WebDAV 서버 추가 (없으면 [root] 는 null) · 고치기 (탭 길게 누르기). 저장하면 그 서버로 간다.
|
||
Future<void> _editDav(ExplorerPane pane, String? root) async {
|
||
setState(() => _ctl.active = _ctl.panes.indexOf(pane));
|
||
final old = root == null ? null : DavRegistry.server(DavPath.parse(root).server);
|
||
final saved = await editDavServer(context, c, old: old);
|
||
if (saved == null || !mounted) return;
|
||
for (final x in _ctl.panes) {
|
||
x.cache.removeWhere((k, _) => isDav(k) && DavPath.parse(k).server == saved.id);
|
||
}
|
||
await _ctl.goTo(pane, '$davScheme${saved.id}/');
|
||
}
|
||
|
||
/// 88: SFTP 서버 추가 (없으면 [root] 는 null) · 고치기 (탭 길게 누르기). 저장하면 그 서버로 간다.
|
||
Future<void> _editSftp(ExplorerPane pane, String? root) async {
|
||
setState(() => _ctl.active = _ctl.panes.indexOf(pane));
|
||
final old = root == null ? null : SftpRegistry.server(DavPath.parse(root).server);
|
||
final saved = await editSftpServer(context, c, old: old);
|
||
if (saved == null || !mounted) return;
|
||
for (final x in _ctl.panes) {
|
||
x.cache.removeWhere((k, _) => isSftp(k) && DavPath.parse(k).server == saved.id);
|
||
x.hostKeyErrors.removeWhere((k, _) => isSftp(k) && DavPath.parse(k).server == saved.id);
|
||
}
|
||
await _ctl.goTo(pane, '$sftpScheme${saved.id}/');
|
||
}
|
||
|
||
/// l: FTP 서버 추가 (없으면 [root] 는 null) · 고치기 (탭 길게 누르기). 저장하면 그 서버로 간다.
|
||
Future<void> _editFtp(ExplorerPane pane, String? root) async {
|
||
setState(() => _ctl.active = _ctl.panes.indexOf(pane));
|
||
final old = root == null ? null : FtpRegistry.server(DavPath.parse(root).server);
|
||
final saved = await editFtpServer(context, c, old: old);
|
||
if (saved == null || !mounted) return;
|
||
for (final x in _ctl.panes) {
|
||
x.cache.removeWhere((k, _) => isFtp(k) && DavPath.parse(k).server == saved.id);
|
||
x.certErrors.removeWhere((k, _) => isFtp(k) && DavPath.parse(k).server == saved.id);
|
||
}
|
||
await _ctl.goTo(pane, '$ftpScheme${saved.id}/');
|
||
}
|
||
|
||
/// 88: SMB 서버 추가 (없으면 [root] 는 null, [from] 은 경로 입력에 넣은 주소) · 고치기 (탭 길게 누르기). 저장하면 그 서버로 간다.
|
||
Future<void> _editSmb(ExplorerPane pane, String? root, {String? from}) async {
|
||
setState(() => _ctl.active = _ctl.panes.indexOf(pane));
|
||
final old = root == null ? null : SmbRegistry.server(DavPath.parse(root).server);
|
||
final saved = await editSmbServer(context, c, old: old, from: from);
|
||
if (saved == null || !mounted) return;
|
||
for (final x in _ctl.panes) {
|
||
x.cache.removeWhere((k, _) => isSmb(k) && DavPath.parse(k).server == saved.id);
|
||
}
|
||
await _ctl.goTo(pane, '$smbScheme${saved.id}/');
|
||
}
|
||
|
||
/// 한 줄. [list]: "폴더 + 파일 목록" 의 오른쪽 목록 (트리 아님). [compact]: 폴더 트리 (열 · 표시 버튼 없이)
|
||
Widget _rowView(ExplorerPane pane, TreeRow row, {bool list = false, bool compact = false}) {
|
||
final e = row.entry;
|
||
final look = _ctl.look;
|
||
// 좁은 "폴더 + 파일 목록" 의 트리 (관리자 지시 10/20): ✔ 칸 · 크기 줄 없이 이름만, 줄에 두 줄이 들어가면 이름을 두 줄까지
|
||
final slim = compact && _narrowSplit && !list;
|
||
final nameLines = slim && _rowH() >= MediaQuery.textScalerOf(context).scale(look.fontSize) * _listTextScale * 2.5 + 6 ? 2 : 1;
|
||
final isCurrent = e.isDir && !list && !row.isUp && samePath(pane.current, e.path);
|
||
final isFocused = !row.isUp && pane.focused != null && samePath(pane.focused!, e.path);
|
||
final marked = _isMarked(pane, e.path);
|
||
final open = (e.isDir || _ctl.arc.contains(e.path)) && !list && pane.expanded.contains(e.path);
|
||
final loading = pane.loading.contains(e.path);
|
||
final name = row.isUp
|
||
? '..'
|
||
: row.isRoot
|
||
? (_ctl.volumes.firstWhere((v) => samePath(v.$1, e.path), orElse: () => (e.path, e.path)).$2)
|
||
: look == ExplorerStyle.totalcmd
|
||
? totalCmdName(e)
|
||
: e.name;
|
||
final dim = TextStyle(fontSize: look.fontSize - 2, color: JjColors.textDim);
|
||
final nameStyle = TextStyle(
|
||
fontSize: look.fontSize,
|
||
fontWeight: isCurrent || (look == ExplorerStyle.totalcmd && e.isDir) ? FontWeight.w600 : FontWeight.normal,
|
||
color: isCurrent ? JjColors.accent : null,
|
||
);
|
||
|
||
// 좁은 트리 (slim) 는 조금 좁게 (36 - 줄 높이 전체를 누르므로 손가락으로도 눌림)
|
||
final chevronWidth = slim ? math.min(36.0, treeChevronWidth(defaultTargetPlatform)) : treeChevronWidth(defaultTargetPlatform);
|
||
// 트리의 펼치기 화살표 (따로 누름). 189: 접기 · 펼치기는 이것으로만 하므로 줄 높이 전체를 누를 수 있게,
|
||
// 손가락으로 누르는 기기 (Android) 는 폭도 넓게
|
||
Widget chevron() => SizedBox(
|
||
width: chevronWidth,
|
||
height: _rowH(),
|
||
child: (e.isDir || _canBrowseArchive(e)) && !list && !row.isUp
|
||
? (loading
|
||
? const Center(
|
||
child: SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 1.5)))
|
||
// 241: TalkBack 이 읽는 이름 ("… 펼치기" · "… 접기")
|
||
: Semantics(
|
||
button: true,
|
||
label: open ? trf('{0} 접기', [name]) : trf('{0} 펼치기', [name]),
|
||
excludeSemantics: true,
|
||
onTap: () => _toggle(pane, e),
|
||
child: InkResponse(
|
||
radius: chevronWidth / 2 + 4,
|
||
containedInkWell: false,
|
||
onTap: () => _toggle(pane, e),
|
||
child: Center(
|
||
child: Icon(open ? Icons.expand_more : Icons.chevron_right, size: 18, color: JjColors.textDim)),
|
||
),
|
||
))
|
||
: null,
|
||
);
|
||
|
||
// 좁은 창 (폰 세로의 좌우 두 창) 에서 깊은 폴더: 들여쓰기는 이름 자리를 남기는 만큼만 (넘치지 않게)
|
||
Widget nameCell() => LayoutBuilder(builder: (context, box) {
|
||
// 이름 자리는 48 또는 줄 폭의 40% (관리자 지시 10/20: 글자가 크고 폴더가 깊으면 들여쓰기에 밀려 이름이 몇 글자만 남았다)
|
||
final fixed = (list ? 0 : chevronWidth) + (look.rich ? 64 : look.iconSize + 12) + math.max(48.0, box.maxWidth * 0.4);
|
||
// 좁은 트리 (slim): 들여쓰기는 반만 (폭의 1/10 까지) - 깊은 폴더에서 이름이 한 글자도 안 보이지 않게
|
||
final indent = list
|
||
? 0.0
|
||
: slim
|
||
? (row.depth * look.indent / 2).clamp(0.0, box.maxWidth * 0.1).toDouble()
|
||
: (row.depth * look.indent).clamp(0.0, (box.maxWidth - fixed).clamp(0.0, double.infinity)).toDouble();
|
||
// 아주 좁은 창 (412 폭 "폴더 + 파일 목록" 의 트리, 77px 쯤): 아이콘 칸 · 틈을 남은 폭만큼만 (아이콘은 줄여 그림) - 넘치지 않게
|
||
final room = (box.maxWidth - indent - (list ? 0 : chevronWidth)).clamp(0.0, double.infinity);
|
||
// 좁은 트리 (slim) 는 큰 썸네일 칸 (56) 대신 작은 아이콘 칸 (28 까지, 아이콘은 줄여 그림)
|
||
final iconW = math.min(slim ? math.min(look.iconSize, 24.0) + 4 : look.rich ? 56.0 : look.iconSize + 6, room);
|
||
final gapW = math.min(slim ? 4.0 : look.rich ? 8.0 : 6.0, room - iconW);
|
||
return Row(children: [
|
||
SizedBox(width: indent),
|
||
if (!list) chevron(),
|
||
SizedBox(
|
||
width: iconW,
|
||
height: _rowH() - 8,
|
||
child: Center(child: FittedBox(fit: BoxFit.scaleDown, child: _icon(e, row.isRoot, open, up: row.isUp))),
|
||
),
|
||
SizedBox(width: gapW),
|
||
Expanded(
|
||
child: look.rich && !e.isDir && !row.isUp
|
||
? Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Text(name, maxLines: 1, overflow: TextOverflow.ellipsis, style: nameStyle),
|
||
// 좁은 화면 · 깊은 폴더에서 넘치지 않게. 960 캡처 (10/18): 동영상 정보와 한 줄 글로 (둘로 나누면 정보가 비어도
|
||
// 폭의 반을 차지해 넓은 창에서도 날짜가 잘렸다)
|
||
isVideoFile(e.path)
|
||
? _MetaText(c: c, path: e.path, lead: '${formatSize(e.size)} · ${_date(e.modified)}', leadStyle: dim)
|
||
: Text('${formatSize(e.size)} · ${_date(e.modified)}', style: dim, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||
],
|
||
)
|
||
: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Text(name, maxLines: nameLines, overflow: TextOverflow.ellipsis, style: nameStyle),
|
||
if (slim)
|
||
const SizedBox.shrink()
|
||
else if (look.rich && e.isDir && pane.errors[e.path] != null)
|
||
Text(tr('읽을 수 없음'), style: dim.copyWith(color: JjColors.alert))
|
||
// 80: 열 없는 모양 (X-plore · 좁은 화면) 은 폴더 이름 아래에 크기
|
||
else if (e.isDir && !row.isRoot && !row.isUp && (!look.columns || compact) && _ctl.dirSize(pane, e) != null)
|
||
Text(_ctl.dirSize(pane, e)!, style: dim, maxLines: 1),
|
||
],
|
||
),
|
||
),
|
||
]);
|
||
});
|
||
|
||
final real = !row.isRoot && !row.isUp;
|
||
// 길게 · 오른쪽 = 메뉴 (규칙 2, [ClickPolicy])
|
||
final menu = ClickPolicy.decide(Press.longPress, _kindOf(row), selectMode: _selecting) == ClickAction.menu;
|
||
Widget cell(String t, int flex, {TextAlign align = TextAlign.start}) => Expanded(
|
||
flex: flex,
|
||
child: Text(t, style: dim, textAlign: align, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||
);
|
||
// 관리자 지시 10/20: 좁으면 유형 → 날짜 열을 숨김 (크기는 남김, [columnHideLevel])
|
||
final hide = pane.colHide;
|
||
final cells = !look.columns || compact || !real
|
||
? <Widget>[Expanded(child: nameCell())]
|
||
: look == ExplorerStyle.windows
|
||
? [
|
||
Expanded(flex: 6, child: nameCell()),
|
||
if (hide < 2) cell(columnDateText(e.modified, short: pane.colDateShort), 3),
|
||
if (hide < 1) cell(typeLabel(e), 2),
|
||
cell(e.isDir ? (_ctl.dirSize(pane, e) ?? '') : formatSize(e.size), 2, align: TextAlign.end),
|
||
]
|
||
: [
|
||
Expanded(flex: 6, child: nameCell()),
|
||
if (hide < 1) cell(e.isDir ? '' : e.ext, 1),
|
||
cell(e.isDir ? (_ctl.dirSize(pane, e) ?? '<DIR>') : formatSize(e.size), 2, align: TextAlign.end),
|
||
if (hide < 2) ...[const SizedBox(width: 8), cell(columnDateText(e.modified, short: pane.colDateShort), 3)],
|
||
];
|
||
|
||
final paneIndex = _ctl.panes.indexOf(pane);
|
||
final drag = real && !widget.rsync;
|
||
Widget line = GestureDetector(
|
||
// Rsync 화면은 고르기만 (파일 기능 메뉴 없음)
|
||
// 25: Rsync 화면에서도 (폴더 메뉴)
|
||
onSecondaryTapUp: menu ? (d) => _menu(pane, e, d.globalPosition) : null,
|
||
onLongPressStart: menu ? (d) => _menu(pane, e, d.globalPosition) : null,
|
||
child: Material(
|
||
// U5 (점검 10/22): 지금 줄 (한 번 누른 것 - 표시가 없을 때 [복사] 등의 대상) 은 진한 바탕 + 왼쪽 강조 막대
|
||
color: marked
|
||
? JjColors.accent.withValues(alpha: 0.18)
|
||
: isFocused
|
||
? JjColors.accent.withValues(alpha: 0.09)
|
||
: Colors.transparent,
|
||
shape: isFocused ? Border(left: BorderSide(color: JjColors.accent, width: 3)) : null,
|
||
child: Row(children: [
|
||
Expanded(
|
||
child: InkWell(
|
||
onTap: () => _onTap(pane, row, list: list),
|
||
// 선택 모드: 두 번 누르면 열기 (바로 열기 모드는 두 번 누르기를 받지 않음 - 한 번 누르기가 늦어지지 않게)
|
||
// 폴더는 한 번 누르기로 바로 펼치므로 두 번 누르기를 받지 않는다 (기다리지 않게)
|
||
onDoubleTap: ClickPolicy.decide(Press.doubleTap, _kindOf(row), selectMode: _selecting, tapOpens: !_selectMode) ==
|
||
ClickAction.open
|
||
? () => _onOpen(pane, row, list: list)
|
||
: null,
|
||
child: Padding(
|
||
padding: const EdgeInsets.only(left: 6, right: 4),
|
||
child: Row(children: cells),
|
||
),
|
||
),
|
||
),
|
||
// 82: 터치로 끌기 - 선택 모드에서 표시한 줄의 ≡ 손잡이 (길게 누르기는 메뉴 그대로)
|
||
if (drag && _selecting && marked)
|
||
Draggable<DragLoad>(
|
||
data: DragLoad(paneIndex, _dragPaths(pane, e)),
|
||
feedback: dragFeedback(_dragPaths(pane, e)),
|
||
child: Tooltip(
|
||
message: tr('끌어서 다른 창으로'),
|
||
child: SizedBox(width: 28, height: 36, child: Icon(Icons.drag_indicator, size: 18, color: JjColors.textDim)),
|
||
),
|
||
),
|
||
// 표시 동그라미는 두 번 누르기 영역 밖 (기다리지 않고 바로)
|
||
if (slim)
|
||
const SizedBox.shrink()
|
||
else if ((real || (widget.rsync && row.isRoot)) && (_selecting || widget.rsync || _alwaysMarks))
|
||
SizedBox(
|
||
width: 36,
|
||
child: IconButton(
|
||
tooltip: widget.rsync
|
||
? (marked ? tr('고르기 취소') : tr('이 폴더를 rsync 원본 · 대상으로 고르기'))
|
||
: marked
|
||
? tr('표시 지우기')
|
||
: tr('표시 (여러 개 고르기)'),
|
||
visualDensity: VisualDensity.compact,
|
||
padding: EdgeInsets.zero,
|
||
// 규칙 4: V 모양 - 고르지 않은 것은 흐리게, 고른 것은 진하게
|
||
icon: Icon(Icons.check,
|
||
size: look.rich ? 24 : 20,
|
||
color: marked ? JjColors.accent : JjColors.textDim.withValues(alpha: 0.35)),
|
||
onPressed: () {
|
||
setState(() => _ctl.active = _ctl.panes.indexOf(pane));
|
||
widget.rsync ? _pick(pane, e) : _toggleMark(pane, e);
|
||
},
|
||
),
|
||
)
|
||
else if (_selecting || widget.rsync || _alwaysMarks)
|
||
const SizedBox(width: 36),
|
||
]),
|
||
),
|
||
);
|
||
// X-plore 숨기기: 숨긴 것을 보이고 있을 때 숨긴 줄은 흐리게
|
||
if (real && _ctl.showAppHidden && _ctl.appHidden(e.path)) line = Opacity(opacity: 0.5, child: line);
|
||
if (!drag) return line;
|
||
// 82: 폴더 줄 위에 놓으면 그 폴더 안으로 (놓을 수 있으면 강조)
|
||
if (e.isDir) {
|
||
final inner = line;
|
||
line = DragTarget<DragLoad>(
|
||
onWillAcceptWithDetails: (d) => canDropInto(d.data, paneIndex, e.path),
|
||
onAcceptWithDetails: (d) => unawaited(_onDrop(d.data, paneIndex, e.path)),
|
||
builder: (context, ok, _) => ok.isEmpty
|
||
? inner
|
||
: DecoratedBox(
|
||
decoration: BoxDecoration(
|
||
color: JjColors.accent.withValues(alpha: 0.15),
|
||
border: Border.all(color: JjColors.accent, width: 1.5),
|
||
),
|
||
child: inner,
|
||
),
|
||
);
|
||
}
|
||
// 82: 마우스로 8px 넘게 끌면 시작 (고르기 · 두 번 누르기는 그대로)
|
||
final paths = _dragPaths(pane, e);
|
||
return MouseDraggable<DragLoad>(data: DragLoad(paneIndex, paths), feedback: dragFeedback(paths), child: line);
|
||
}
|
||
|
||
Widget _icon(FileEntry e, bool isRoot, bool open, {bool up = false}) {
|
||
final look = _ctl.look;
|
||
if (up) return Icon(Icons.arrow_upward, size: look.iconSize, color: JjColors.textDim);
|
||
if (isRoot) return Icon(isDav(e.path) ? Icons.cloud : Icons.sd_storage, color: JjColors.accent, size: look.iconSize - 2);
|
||
// X-plore 비교 5-1 ② (관리자 지시 10/21): 원격은 설정 (처음 값 Wi-Fi 에서만) 에 따라 받아서 - 받기 전 · 못 받으면 아이콘
|
||
if (look.rich && isDav(e.path) && c.settings.explorerRemoteThumbs != 'off' && remoteThumbKind(e)) {
|
||
final (icon, color) = fileIcon(look, e, open: open);
|
||
return RemoteThumb(
|
||
key: ValueKey('rthumb:${e.path}'), c: c, entry: e, fallback: Icon(icon, size: 28, color: color));
|
||
}
|
||
// X-plore: 동영상 썸네일 · 그림 미리 보기
|
||
if (look.rich && !e.isDir && !isDav(e.path)) {
|
||
if (isVideoFile(e.path)) return _Thumb(c: c, entry: e);
|
||
const images = {'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'};
|
||
if (images.contains(e.ext)) {
|
||
return ClipRRect(
|
||
borderRadius: BorderRadius.circular(3),
|
||
child: Image.file(File(e.path),
|
||
width: 56, height: 44, fit: BoxFit.cover, cacheWidth: 112,
|
||
errorBuilder: (_, _, _) => const Icon(Icons.image_outlined, size: 28)),
|
||
);
|
||
}
|
||
}
|
||
final (icon, color) = fileIcon(look, e, open: open);
|
||
return Icon(icon, size: look.rich && !e.isDir ? 28 : look.iconSize, color: color);
|
||
}
|
||
}
|
||
|
||
/// 203: 바뀐 날을 모름 (잠긴 시스템 파일은 1970-01-01 로 온다) 은 "—"
|
||
String _date(DateTime d) => fileDateText(d);
|
||
|
||
|
||
/// 기능 버튼 줄 (세로: 두 창 사이 · 가로: 위아래 배치일 때)
|
||
class _Toolbar extends StatelessWidget {
|
||
final List<ExplorerButton> buttons;
|
||
final bool vertical;
|
||
final void Function(ExplorerButton) onPressed;
|
||
final bool Function(ExplorerButton) enabled;
|
||
final bool Function(ExplorerButton) selected;
|
||
|
||
/// 창이 둘인지 (두 창 · 폴더 + 파일 목록). 하나면 [orient] 는 "두 창으로"
|
||
final bool twoPanes;
|
||
|
||
/// 좁아도 [더 보기] 로 보내지 않는 버튼 (창 배치 · 담아 둔 것이 있을 때 붙여넣기)
|
||
final Set<ExplorerButton> pinned;
|
||
|
||
/// X-plore b: 길게 누르기 = 짝 동작 (복사 ↔ 이동, 지우기 → 영구 삭제). 짝이 없는 버튼은 null
|
||
final void Function(ExplorerButton)? onLongPress;
|
||
const _Toolbar(
|
||
{required this.buttons,
|
||
required this.vertical,
|
||
required this.onPressed,
|
||
required this.enabled,
|
||
required this.selected,
|
||
this.onLongPress,
|
||
this.twoPanes = true,
|
||
this.pinned = const {ExplorerButton.orient},
|
||
bool? panesSide})
|
||
: panesSide = panesSide ?? vertical;
|
||
|
||
/// 두 창이 좌우로 놓였는지 (버튼 이름 "좌 → 우" / "위 → 아래"). 보통 [vertical] 과 같고,
|
||
/// 191 좁은 Rsync 화면 (창 하나씩, 버튼 줄은 가로) 은 좌우
|
||
final bool panesSide;
|
||
|
||
/// X-plore b: 길게 누르면 하는 짝 동작의 이름 (없으면 null)
|
||
static String? longPressLabel(ExplorerButton b) => switch (b) {
|
||
ExplorerButton.copy => '이동',
|
||
ExplorerButton.move => '복사',
|
||
ExplorerButton.delete => '영구 삭제',
|
||
_ => null,
|
||
};
|
||
|
||
/// 118: 지금 배치를 보여 준다 (누르면 반대로)
|
||
String _orientLabel() => !twoPanes ? '두 창으로' : panesSide ? '좌우 ⇆' : '위아래 ⇅';
|
||
String _orientTip() => !twoPanes
|
||
? '두 창으로 보기 (좌우)'
|
||
: panesSide
|
||
? '창 배치: 좌우 (누르면 위아래)'
|
||
: '창 배치: 위아래 (누르면 좌우)';
|
||
|
||
/// 창이 위아래로 놓이면 (버튼 줄이 가로) "좌 → 우" 대신 "위 → 아래"
|
||
String _label(ExplorerButton b) => b == ExplorerButton.orient
|
||
? _orientLabel()
|
||
: panesSide
|
||
? b.label
|
||
: switch (b) {
|
||
ExplorerButton.toRight => '위 → 아래',
|
||
ExplorerButton.toLeft => '위 ← 아래',
|
||
ExplorerButton.both => '위 ⇄ 아래',
|
||
_ => b.label,
|
||
};
|
||
|
||
IconData _icon(ExplorerButton b) => b == ExplorerButton.orient
|
||
? (!twoPanes ? Icons.vertical_split_outlined : panesSide ? Icons.swap_horiz : Icons.swap_vert)
|
||
: panesSide
|
||
? b.icon
|
||
: switch (b) {
|
||
ExplorerButton.toRight => Icons.arrow_downward,
|
||
ExplorerButton.toLeft => Icons.arrow_upward,
|
||
ExplorerButton.both => Icons.swap_vert,
|
||
_ => b.icon,
|
||
};
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
Widget btn(ExplorerButton b) {
|
||
final on = enabled(b);
|
||
final sel = selected(b);
|
||
final pair = longPressLabel(b);
|
||
final long = onLongPress;
|
||
return Tooltip(
|
||
message: tr(b == ExplorerButton.orient ? _orientTip() : _label(b)) +
|
||
(pair != null && long != null ? ' · ${trf('길게 누르면 {0}', [tr(pair)])}' : ''),
|
||
child: InkWell(
|
||
onTap: on ? () => onPressed(b) : null,
|
||
onLongPress: on && pair != null && long != null ? () => long(b) : null,
|
||
borderRadius: BorderRadius.circular(6),
|
||
child: SizedBox(
|
||
width: 72,
|
||
height: 58,
|
||
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||
Icon(_icon(b), size: 22, color: !on ? JjColors.textDim.withValues(alpha: 0.4) : sel ? JjColors.accent : null),
|
||
const SizedBox(height: 2),
|
||
// 188: 두 줄로 꺾인 글 (일본어 "新しいフォルダー" 등) 이 버튼 아래로 잘렸다 - 남는 높이에 맞게 줄여 다 보이게
|
||
Flexible(
|
||
child: FittedBox(
|
||
fit: BoxFit.scaleDown,
|
||
child: SizedBox(
|
||
width: 68,
|
||
child: Text(tr(_label(b)),
|
||
maxLines: 2,
|
||
textAlign: TextAlign.center,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(fontSize: 10.5, height: 1.1, color: on ? null : JjColors.textDim)),
|
||
),
|
||
),
|
||
),
|
||
]),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// 147: 다 들어가지 않으면 (좁은 · 낮은 화면) 들어가는 만큼 보이고 나머지는 [더 보기 ⋯] 메뉴로 - 밀어야 보이는 것을 몰라 못 찾던 것
|
||
Widget more(List<ExplorerButton> rest) => Builder(
|
||
builder: (context) => Tooltip(
|
||
message: tr('더 보기'),
|
||
child: InkWell(
|
||
borderRadius: BorderRadius.circular(6),
|
||
onTap: () async {
|
||
final box = context.findRenderObject()! as RenderBox;
|
||
final at = box.localToGlobal(Offset.zero);
|
||
final pick = await showMenu<ExplorerButton>(
|
||
context: context,
|
||
position: RelativeRect.fromLTRB(at.dx + box.size.width, at.dy, at.dx + box.size.width, at.dy),
|
||
items: [
|
||
for (final b in rest)
|
||
PopupMenuItem(
|
||
value: b,
|
||
enabled: enabled(b),
|
||
height: 40,
|
||
child: Row(children: [
|
||
Icon(_icon(b), size: 18, color: selected(b) ? JjColors.accent : null),
|
||
const SizedBox(width: 10),
|
||
Text(tr(_label(b))),
|
||
]),
|
||
),
|
||
],
|
||
);
|
||
if (pick != null) onPressed(pick);
|
||
},
|
||
child: SizedBox(
|
||
width: 72,
|
||
height: 58,
|
||
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||
const Icon(Icons.more_horiz, size: 22),
|
||
const SizedBox(height: 2),
|
||
Text(tr('더 보기'), style: const TextStyle(fontSize: 10.5, height: 1.1)),
|
||
]),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
|
||
return Container(
|
||
color: JjColors.panel,
|
||
width: vertical ? 76 : null,
|
||
height: vertical ? null : 62,
|
||
padding: const EdgeInsets.all(2),
|
||
child: LayoutBuilder(builder: (context, box) {
|
||
final room = vertical ? box.maxHeight : box.maxWidth;
|
||
final each = vertical ? 58.0 : 72.0;
|
||
var shown = buttons;
|
||
var rest = const <ExplorerButton>[];
|
||
if (room.isFinite && buttons.length * each > room) {
|
||
final fit = (room / each).floor() - 1; // 하나는 [더 보기] 자리
|
||
if (fit >= 1) {
|
||
shown = buttons.take(fit).toList();
|
||
rest = buttons.skip(fit).toList();
|
||
// 창 배치 버튼은 늘 줄에 (사용자가 가운데 아이콘 모음에 두라고 함, 10/9) - 넘치면 마지막 자리를 내준다.
|
||
// 73 (관리자): 복사 · 이동으로 담아 둔 것이 있으면 [붙여넣기] 도 (담아 두고 붙여넣기를 찾지 못하는 일이 없게)
|
||
for (final b in buttons.where((b) => pinned.contains(b) && rest.contains(b))) {
|
||
final i = shown.lastIndexWhere((x) => !pinned.contains(x));
|
||
if (i < 0) break;
|
||
final out = shown[i];
|
||
shown = [...shown.sublist(0, i), ...shown.sublist(i + 1), b];
|
||
rest = [out, ...rest.where((x) => x != b)];
|
||
}
|
||
}
|
||
}
|
||
final children = [for (final b in shown) btn(b), if (rest.isNotEmpty) more(rest)];
|
||
return vertical ? Column(children: children) : Row(children: children);
|
||
}),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ───────── 동영상 정보 · 썸네일 (보이는 줄만, 차례로) ─────────
|
||
|
||
/// 한 번에 하나씩 (FFmpeg 를 동시에 많이 띄우지 않게)
|
||
class _Queue {
|
||
final int max;
|
||
int _running = 0;
|
||
final _waiting = Queue<Completer<void>>();
|
||
_Queue(this.max);
|
||
|
||
Future<T> run<T>(Future<T> Function() job) async {
|
||
if (_running >= max) {
|
||
final w = Completer<void>();
|
||
_waiting.add(w);
|
||
await w.future;
|
||
}
|
||
_running++;
|
||
try {
|
||
return await job();
|
||
} finally {
|
||
_running--;
|
||
if (_waiting.isNotEmpty) _waiting.removeFirst().complete();
|
||
}
|
||
}
|
||
}
|
||
|
||
/// "1920×1080 · 1:23:45"
|
||
class _Meta {
|
||
static final _cache = <String, Future<String>>{};
|
||
static final _q = _Queue(2);
|
||
|
||
static Future<String> of(AppController c, String path) => _cache[path] ??= _q.run(() async {
|
||
try {
|
||
final info = await c.services.mediaTool.probe(path);
|
||
final v = info.ofType('video').where((s) => (s.width ?? 0) > 0).firstOrNull;
|
||
final d = info.duration;
|
||
return [
|
||
if (v != null) '${v.width}×${v.height}',
|
||
if (d != null) _dur(d),
|
||
].join(' · ');
|
||
} catch (_) {
|
||
return '';
|
||
}
|
||
});
|
||
|
||
static String _dur(Duration d) {
|
||
final h = d.inHours, m = d.inMinutes % 60, s = d.inSeconds % 60;
|
||
String two(int x) => x.toString().padLeft(2, '0');
|
||
return h > 0 ? '$h:${two(m)}:${two(s)}' : '$m:${two(s)}';
|
||
}
|
||
}
|
||
|
||
class _MetaText extends StatelessWidget {
|
||
final AppController c;
|
||
final String path;
|
||
|
||
/// 앞에 붙일 글 (크기 · 날짜) - 동영상 정보와 한 줄로 이어 그린다
|
||
final String lead;
|
||
final TextStyle? leadStyle;
|
||
const _MetaText({required this.c, required this.path, this.lead = '', this.leadStyle});
|
||
|
||
@override
|
||
Widget build(BuildContext context) => FutureBuilder<String>(
|
||
future: _Meta.of(c, path),
|
||
builder: (_, s) => Text.rich(
|
||
TextSpan(children: [
|
||
if (lead.isNotEmpty) TextSpan(text: lead, style: leadStyle),
|
||
if ((s.data ?? '').isNotEmpty) TextSpan(text: '${lead.isEmpty ? '' : ' '}${s.data}', style: TextStyle(fontSize: 11, color: JjColors.accent)),
|
||
]),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis),
|
||
);
|
||
}
|
||
|
||
/// 동영상 썸네일 (임시 폴더의 jj_thumbs 에 저장해 다시 쓰기). MKV 만들기 등 작업 중에는 만들지 않는다 (FFmpeg 를 같이 씀).
|
||
class _Thumbs {
|
||
static final _cache = <String, Future<File?>>{};
|
||
static final _q = _Queue(1);
|
||
|
||
/// 보관함에 넣은 파일의 기억을 잊는다 (관리자 지시 10/21)
|
||
static void forget(String path) => _cache.removeWhere((k, _) => k.startsWith('$path|'));
|
||
|
||
static Future<File?> of(AppController c, FileEntry e) {
|
||
final key = '${e.path}|${e.size}|${e.modified.millisecondsSinceEpoch}';
|
||
return _cache[key] ??= _q.run(() async {
|
||
try {
|
||
final dir = Directory(p.join(await c.services.storage.tempDirectory(), 'jj_thumbs'));
|
||
await dir.create(recursive: true);
|
||
final out = File(p.join(dir.path, '${md5.convert(utf8.encode(key))}.jpg'));
|
||
if (await out.exists() && await out.length() > 0) return out;
|
||
if (c.busy) {
|
||
_cache.remove(key); // 작업이 끝나면 다시 시도
|
||
return null;
|
||
}
|
||
for (final at in ['5', '0']) {
|
||
await c.services.mediaTool.runFfmpeg([
|
||
'-hide_banner', '-loglevel', 'error', '-ss', at, '-i', e.path, //
|
||
'-frames:v', '1', '-vf', 'scale=160:-2', '-y', out.path,
|
||
]);
|
||
if (await out.exists() && await out.length() > 0) return out;
|
||
}
|
||
} catch (_) {}
|
||
return null;
|
||
});
|
||
}
|
||
}
|
||
|
||
/// 85: 진행 패널을 숨겼을 때 오른쪽 아래 작은 표시 "복사 · 이동 n개 · 45%" (멈추면 빨갛게). 누르면 다시 펼침
|
||
class TransferProgressChip extends StatelessWidget {
|
||
/// 도는 것 수 (0 이면 끝) · 진행률 (모르면 null) · 멈춘 (실패한) 것이 있는지
|
||
final int running;
|
||
final int? percent;
|
||
final bool failed;
|
||
final VoidCallback onTap;
|
||
const TransferProgressChip({super.key, required this.running, this.percent, this.failed = false, required this.onTap});
|
||
|
||
@override
|
||
Widget build(BuildContext context) => Material(
|
||
elevation: 6,
|
||
color: failed ? JjColors.danger.withValues(alpha: 0.25) : JjColors.panelHigh,
|
||
borderRadius: BorderRadius.circular(18),
|
||
child: InkWell(
|
||
borderRadius: BorderRadius.circular(18),
|
||
onTap: onTap,
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||
// 멈춤은 진행 고리 대신 경고 표시 (꽉 찬 고리는 "끝났다" 로 보였다)
|
||
if (failed)
|
||
Icon(Icons.error_outline, size: 16, color: JjColors.danger)
|
||
else
|
||
SizedBox(
|
||
width: 14,
|
||
height: 14,
|
||
child: CircularProgressIndicator(strokeWidth: 2, value: running == 0 ? 1 : (percent == null ? null : percent! / 100)),
|
||
),
|
||
const SizedBox(width: 8),
|
||
// 좁은 화면 · 큰 글자면 두 줄로 (412 · 글자 1.6배에서 화면을 넘었다 - 253 시험)
|
||
Flexible(
|
||
child: Text(
|
||
failed
|
||
? tr('복사 · 이동 멈춤 - 눌러서 보기')
|
||
: running == 0
|
||
? tr('복사 · 이동 끝')
|
||
: percent == null
|
||
? trf('복사 · 이동 {0}개', [running])
|
||
: trf('복사 · 이동 {0}개 · {1}%', [running, percent]),
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: const TextStyle(fontSize: 12.5),
|
||
),
|
||
),
|
||
const SizedBox(width: 4),
|
||
Tooltip(message: tr('진행 패널 보기'), child: const Icon(Icons.expand_less, size: 18)),
|
||
]),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 81: 빠른 보기의 큰 썸네일 (가로 640, 같은 jj_thumbs 에 따로 저장). 사용자가 연 것이라 작업 중에도 만든다
|
||
class _BigThumbs {
|
||
static final _cache = <String, Future<File?>>{};
|
||
static final _q = _Queue(1);
|
||
|
||
static void forget(String path) => _cache.removeWhere((k, _) => k.startsWith('$path|'));
|
||
|
||
static Future<File?> of(AppController c, FileEntry e) {
|
||
final key = '${e.path}|${e.size}|${e.modified.millisecondsSinceEpoch}|640';
|
||
return _cache[key] ??= _q.run(() async {
|
||
try {
|
||
final dir = Directory(p.join(await c.services.storage.tempDirectory(), 'jj_thumbs'));
|
||
await dir.create(recursive: true);
|
||
final out = File(p.join(dir.path, '${md5.convert(utf8.encode(key))}.jpg'));
|
||
if (await out.exists() && await out.length() > 0) return out;
|
||
for (final at in ['5', '0']) {
|
||
await c.services.mediaTool.runFfmpeg([
|
||
'-hide_banner', '-loglevel', 'error', '-ss', at, '-i', e.path, //
|
||
'-frames:v', '1', '-vf', 'scale=640:-2', '-y', out.path,
|
||
]);
|
||
if (await out.exists() && await out.length() > 0) return out;
|
||
}
|
||
} catch (_) {}
|
||
_cache.remove(key);
|
||
return null;
|
||
});
|
||
}
|
||
}
|
||
|
||
class _Thumb extends StatelessWidget {
|
||
final AppController c;
|
||
final FileEntry entry;
|
||
const _Thumb({required this.c, required this.entry});
|
||
|
||
@override
|
||
Widget build(BuildContext context) => FutureBuilder<File?>(
|
||
future: _Thumbs.of(c, entry),
|
||
builder: (_, s) {
|
||
final f = s.data;
|
||
if (f == null) return Icon(Icons.movie_outlined, size: 28, color: JjColors.textDim);
|
||
return ClipRRect(
|
||
borderRadius: BorderRadius.circular(3),
|
||
child: Stack(alignment: Alignment.center, children: [
|
||
Image.file(f, width: 56, height: 44, fit: BoxFit.cover,
|
||
errorBuilder: (_, _, _) => const Icon(Icons.movie_outlined, size: 28)),
|
||
const Icon(Icons.play_arrow, size: 18, color: Colors.white70),
|
||
]),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
/// 76: 빠른 필터 - 비어 있으면 모두, * · ? 가 있으면 이름 전체 패턴, 아니면 이름에 든 글자 (대소문자 무시)
|
||
/// 74: 패턴 넣기
|
||
class _PatternDialog extends StatefulWidget {
|
||
const _PatternDialog();
|
||
|
||
@override
|
||
State<_PatternDialog> createState() => _PatternDialogState();
|
||
}
|
||
|
||
class _PatternDialogState extends State<_PatternDialog> {
|
||
final _ctl = TextEditingController(text: '*.srt');
|
||
|
||
@override
|
||
void dispose() {
|
||
_ctl.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) => AlertDialog(
|
||
title: Text(tr('패턴으로 고르기')),
|
||
content: TextField(
|
||
controller: _ctl,
|
||
autofocus: true,
|
||
decoration: InputDecoration(helperText: tr('* 는 아무 글자, ? 는 한 글자. 여러 개는 ; 로 (예: *.srt; *.ass)')),
|
||
onSubmitted: (v) => Navigator.pop(context, v),
|
||
),
|
||
actions: [
|
||
TextButton(onPressed: () => Navigator.pop(context), child: Text(tr('취소'))),
|
||
FilledButton(onPressed: () => Navigator.pop(context, _ctl.text), child: Text(tr('고르기'))),
|
||
],
|
||
);
|
||
}
|
||
|
||
/// 179: SMB 경로 (smb:// · \\서버\공유)
|
||
bool looksLikeSmb(String path) => RegExp(r'^(smb|cifs)://', caseSensitive: false).hasMatch(path) || path.startsWith(r'\\');
|
||
|
||
/// 190: smb://서버/공유/폴더 (cifs:// 도) → \\서버\공유\폴더. SMB 주소가 아니면 null
|
||
String? smbToUnc(String path) {
|
||
final m = RegExp(r'^(smb|cifs)://(.+)$', caseSensitive: false).firstMatch(path.trim());
|
||
if (m == null) return null;
|
||
final parts = m.group(2)!.split(RegExp(r'[/\\]+')).where((s) => s.isNotEmpty).map(_decodeSmbPart).toList();
|
||
if (parts.isEmpty) return null;
|
||
return '\\\\${parts.join('\\')}';
|
||
}
|
||
|
||
/// %20 같은 주소 글자는 풀고, 한글 등 그대로 쓴 글자는 그대로
|
||
String _decodeSmbPart(String s) {
|
||
try {
|
||
return Uri.decodeComponent(s);
|
||
} catch (_) {
|
||
return s;
|
||
}
|
||
}
|
||
|
||
String get smbNotSupported => tr('네트워크 공유 (SMB) 는 경로로 바로 열지 않습니다. [SMB 서버 추가] 로 서버를 넣으면 탭으로 열립니다');
|
||
|
||
class _PathDialog extends StatefulWidget {
|
||
const _PathDialog({required this.initial});
|
||
final String initial;
|
||
|
||
/// 창이 이것을 돌려주면 WebDAV 서버 추가 창을 연다
|
||
static const addDav = '\u0000webdav';
|
||
|
||
/// 88: 이것 + 넣은 주소를 돌려주면 SMB 서버 추가 창 (주소로 채움)
|
||
static const addSmb = '\u0000smb:';
|
||
|
||
@override
|
||
State<_PathDialog> createState() => _PathDialogState();
|
||
}
|
||
|
||
class _PathDialogState extends State<_PathDialog> {
|
||
late final _ctl = TextEditingController(text: widget.initial)
|
||
..selection = TextSelection(baseOffset: 0, extentOffset: widget.initial.length);
|
||
String? _error;
|
||
bool _checking = false;
|
||
|
||
@override
|
||
void dispose() {
|
||
_ctl.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
Future<void> _submit() async {
|
||
setState(() => _checking = true);
|
||
final (path, error) = await _ExplorerPageState.checkEnteredPath(_ctl.text);
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_checking = false;
|
||
_error = error;
|
||
});
|
||
if (path != null) Navigator.pop(context, path);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) => AlertDialog(
|
||
scrollable: true,
|
||
title: Text(Platform.isWindows ? tr('경로 입력 (예: D:\\영상 · \\\\NAS\\공유)') : tr('경로 입력')),
|
||
content: TextField(
|
||
controller: _ctl,
|
||
autofocus: true,
|
||
decoration: InputDecoration(hintText: tr('경로'), errorText: _error, errorMaxLines: 3),
|
||
// 고치기 시작하면 앞의 오류는 지운다
|
||
onChanged: (_) {
|
||
if (_error != null) setState(() => _error = null);
|
||
},
|
||
onSubmitted: (_) => _submit(),
|
||
),
|
||
actions: [
|
||
// 179: SMB 를 넣었으면 바로 WebDAV 서버 추가로
|
||
if (_error == smbNotSupported) ...[
|
||
TextButton(onPressed: () => Navigator.pop(context, _PathDialog.addDav), child: Text(tr('WebDAV 서버 추가'))),
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(context, '${_PathDialog.addSmb}${_ctl.text}'), child: Text(tr('SMB 서버 추가'))),
|
||
],
|
||
TextButton(onPressed: () => Navigator.pop(context), child: Text(tr('취소'))),
|
||
FilledButton(onPressed: _checking ? null : _submit, child: Text(tr('확인'))),
|
||
],
|
||
);
|
||
}
|
||
|
||
/// 77: 길게 누르기 메뉴 위의 아이콘 줄 - (id, 아이콘, 짧은 이름, 풍선 도움말, 누를 수 있음).
|
||
/// 폰에서도 무엇인지 알 수 있게 아이콘 아래에 짧은 이름
|
||
class _MenuIconRow extends PopupMenuEntry<String> {
|
||
final List<(String, IconData, String, String, bool)> items;
|
||
const _MenuIconRow(this.items);
|
||
|
||
@override
|
||
double get height => 60;
|
||
|
||
@override
|
||
bool represents(String? value) => false;
|
||
|
||
@override
|
||
State<_MenuIconRow> createState() => _MenuIconRowState();
|
||
}
|
||
|
||
class _MenuIconRowState extends State<_MenuIconRow> {
|
||
@override
|
||
Widget build(BuildContext context) => Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||
for (final (id, icon, label, tip, on) in widget.items)
|
||
Tooltip(
|
||
message: tip,
|
||
child: InkWell(
|
||
borderRadius: BorderRadius.circular(6),
|
||
onTap: on ? () => Navigator.pop(context, id) : null,
|
||
child: Semantics(
|
||
button: true,
|
||
enabled: on,
|
||
label: tip,
|
||
excludeSemantics: true,
|
||
child: SizedBox(
|
||
width: 56,
|
||
height: 56,
|
||
child: Opacity(
|
||
opacity: on ? 1 : 0.38,
|
||
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||
Icon(icon, size: 20),
|
||
const SizedBox(height: 2),
|
||
FittedBox(
|
||
fit: BoxFit.scaleDown,
|
||
child: Text(label, maxLines: 1, style: const TextStyle(fontSize: 11)),
|
||
),
|
||
]),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
]),
|
||
);
|
||
}
|