JJ_MKVMaker/lib/ui/explorer_page.dart

6081 lines
277 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'dart:io';
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 CopyTask, FolderBookmark;
import '../app/transfer_job.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 SftpHostKeyException, SftpRegistry;
import '../core/ftp.dart' show FtpCertException, FtpRegistry;
import '../core/smb.dart' show SmbRegistry;
import '../core/webdav.dart' show DavRegistry;
import '../platform/android/android_storage.dart';
import 'app_actions.dart';
import 'explorer_look.dart';
import '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 '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 '../app/components.dart';
import 'zip_page.dart';
import '../core/reader_sources.dart';
import '../l10n/tr.dart';
import 'explorer_bookmarks.dart';
import 'explorer_search.dart';
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 ArchiveEntry, ArchiveKind, ArchivePasswordException, archiveKindOf, extract7zPlan, extractZipPlan, list7z, listZip, planExtract, unsafeArchivePath;
import 'batch_rename_dialog.dart';
import '../core/app_trash.dart';
import '../core/folder_size.dart';
import '../core/batch_rename.dart';
/// 파일 탐색기 (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;
const ExplorerPage({super.key, required this.c, this.rsync = false});
/// 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;
/// 시험: 경로 입력 창이 넣은 경로를 고쳐 쓰고 확인하는 것 (갈 경로, 칸 아래 이유)
@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, '휴지통');
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];
/// 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 _Pane extends ChangeNotifier {
String root;
/// 지금 폴더. 75: 바뀔 때마다 뒤로 가기 내역에 (뒤로 · 앞으로로 움직일 때는 빼고)
String get current => _current;
set current(String v) {
if (!traveling && _current.isNotEmpty && v.isNotEmpty && !samePath(_current, v)) {
back.add(_current);
if (back.length > 50) back.removeAt(0);
forward.clear();
}
_current = v;
}
String _current;
/// 75: 창마다 뒤로 · 앞으로 (Alt+← / →, 마우스 4 · 5번 버튼)
final back = <String>[];
final forward = <String>[];
bool traveling = false;
String? focused;
final expanded = <String>{};
final cache = <String, List<FileEntry>>{};
final loading = <String>{};
final errors = <String, String>{};
/// 88: 처음 보는 (또는 바뀐) SFTP 서버 키 - 오류 안내에 지문 · [지문 확인하고 연결]
final hostKeyErrors = <String, SftpHostKeyException>{};
/// l: 처음 보는 (바뀐) FTPS 자체 서명 인증서 - 폴더마다
final certErrors = <String, FtpCertException>{};
final marked = <String>{};
/// 74: 범위 고르기의 시작 (마지막으로 표시한 것)
String? anchor;
/// X-plore 비교 c: 트리의 "즐겨찾기" 묶음을 펼쳤는지
bool favoritesOpen = false;
/// 76: 빠른 필터 - 열려 있으면 이름에 [filter] 가 든 것만 보인다 (창마다, 닫으면 지움)
bool filterOpen = false;
final filterCtl = TextEditingController();
final filterFocus = FocusNode();
String get filter => filterOpen ? filterCtl.text.trim() : '';
/// 목록 스크롤 (폴더로 이동하면 그 폴더가 보이게)
final scroll = ScrollController();
/// "폴더 + 파일 목록" 배치의 오른쪽 파일 목록 스크롤
final listScroll = ScrollController();
/// 202: 창 위 저장 장치 탭 줄 (넘치면 옆으로 - 휠 · 끌기)
final tabScroll = ScrollController();
_Pane(this.root) : _current = root;
/// 화면을 닫은 뒤 끝난 파일 작업 · 읽기가 알리지 않게
bool _disposed = false;
void changed() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
scroll.dispose();
listScroll.dispose();
tabScroll.dispose();
filterCtl.dispose();
filterFocus.dispose();
super.dispose();
}
}
/// 트리 · 목록의 한 줄
class _Row {
final FileEntry entry;
final int depth;
final bool isRoot;
/// 파일 목록 맨 위의 ".." (상위 폴더로)
final bool isUp;
/// X-plore 비교 c: 트리 맨 위 항목 - [otherRoot] 다른 저장 장치 · 서버, [favorites] 즐겨찾기 묶음, [favorite] 즐겨찾기 하나.
/// X-plore 숨기기: [hiddenToggle] 목록 맨 아래의 [숨긴 항목 보이기] (숨긴 수 [count])
final int kind;
final int count;
static const otherRoot = 1, favorites = 2, favorite = 3, hiddenToggle = 4;
const _Row(this.entry, this.depth, {this.isRoot = false, this.isUp = false, this.kind = 0, this.count = 0});
}
class _ExplorerPageState extends State<ExplorerPage> {
AppController get c => widget.c;
/// 이 기기의 저장 장치: (경로, 이름)
List<(String, String)> _local = [];
/// 165: 드라이브마다 따로 뒤에서 확인 (Windows). null = 확인 중, true = 열 수 있음, false = 연결 안 됨
final Map<String, bool?> _driveOk = {};
final Set<String> _networkDrives = {};
/// 꺼내는 장치 · CD (카드 리더 · USB): 열 수 없으면 "비어 있음" (연결 안 됨이 아니라 매체가 없는 것)
final Set<String> _removableDrives = {};
Future<void> _probeDrive(String path) async {
if (mounted) setState(() => _driveOk[path] = null);
bool ok;
try {
ok = await ExplorerPage.probeDrive(path);
} catch (_) {
ok = false;
}
if (mounted) setState(() => _driveOk[path] = ok);
}
/// 연결 안 된 드라이브를 누르면 다시 연결 (다시 읽기) - 되면 연다
Future<void> _reconnect(_Pane pane, String path) async {
await _probeDrive(path);
if (!mounted) return;
if (_driveOk[path] == true) {
pane.cache.remove(path);
await _goTo(pane, path);
} else {
_snack(_removableDrives.contains(path)
? trf('{0} 이 비어 있습니다 (카드 · USB · 디스크를 넣은 뒤 다시 누르세요).', [path])
: trf('{0} 에 연결할 수 없습니다. 네트워크 · NAS 가 켜져 있는지 확인하세요.', [path]));
}
}
/// 저장 장치 + WebDAV 서버 (환경 설정 > 파일 탐색기 > WebDAV): (경로, 이름)
List<(String, String)> get _volumes => [
..._local,
for (final s in c.settings.webdavServers) ('$davScheme${s.id}/', s.label),
// 88: SFTP 서버
for (final s in c.settings.sftpServers) ('$sftpScheme${s.id}/', s.label),
// 88: SMB 서버 (Android)
for (final s in c.settings.smbServers) ('$smbScheme${s.id}/', s.label),
// l: FTP · FTPS 서버 (평문 FTP 는 이름 앞에 ⚠)
for (final s in c.settings.ftpServers) ('$ftpScheme${s.id}/', s.tls ? s.label : '⚠ ${s.label}'),
];
late final List<_Pane> _panes = [_Pane(''), _Pane('')];
int _active = 0;
bool _ready = false;
/// 화면 단축키 (F5 · Delete …) 를 받는 곳. 76: 빠른 필터 칸을 닫으면 여기로 돌려준다
final _keys = FocusNode(debugLabel: 'explorer');
/// 80: 폴더 크기 (필요할 때 계산하고 이번 실행 동안 기억)
late final _sizes = FolderSizes()..addListener(_onSizes);
/// 80: 폴더 크기가 나오면 - 크기순이면 다시 줄 세우고 화면 갱신
void _onSizes() {
if (!mounted) return;
for (final pane in _panes) {
for (final k in pane.cache.keys.toList()) {
if (_sortOf(k).$1 == 'size') pane.cache[k] = _sorted(pane.cache[k]!, k);
}
}
setState(() {});
}
/// 80: 폴더 크기 글 (모르면 null). 설정이 자동이면 계산을 맡긴다 -
/// 지금 보고 있는 폴더 바로 안의 폴더만 (트리에 보이는 위 폴더들 (C:\ · 사용자 폴더 …) 은 드라이브 전체를 읽게 되어 자동으로 하지 않는다)
String? _dirSize(_Pane pane, FileEntry e) {
final s = _sizes.of(e.path);
if (s != null) return s.label;
if (_sizes.busy(e.path)) return '…';
final mode = c.settings.explorerFolderSize;
// 80 보충 (관리자 판단): WebDAV 는 자동으로 하지 않는다 (서버에서 안의 것을 모두 읽음 - 길게 누르기로만)
if ((mode == 'always' || (mode == 'sort' && _sortOf(pane.current).$1 == 'size')) &&
samePath(vDirname(e.path), pane.current) &&
!isDav(e.path)) {
final forDir = pane.current;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _sizes.request(e.path, forDir: forDir);
});
return '…';
}
return null;
}
@override
void initState() {
super.initState();
if (widget.rsync) {
ExplorerPage._openRsync++;
} else {
ExplorerPage._open++;
}
for (final pane in _panes) {
pane.addListener(_onPane);
}
_resumeReload = AppLifecycleListener(onResume: () => unawaited(_reloadOpen(localOnly: true)));
_hiddenSeen = c.settings.explorerHiddenItems;
c.addListener(_onSettings);
_init();
}
/// 232: 앱으로 돌아오면 (다른 앱 · 탐색기에서 파일을 바꾸고 오면) 보이는 폴더를 다시 읽는다
late final AppLifecycleListener _resumeReload;
bool _reloading = false;
/// 열려 있는 (보이는) 폴더를 두 창 모두 다시 읽는다 - [새로 고침] (183) 과 같다.
/// [localOnly]: 돌아올 때는 WebDAV 폴더는 빼고 (네트워크가 아직 안 붙었을 때 오류가 뜨지 않게 - [새로 고침] 으로는 읽음)
Future<void> _reloadOpen({bool localOnly = false}) async {
if (_reloading) return;
_reloading = true;
try {
// 180: 목록을 먼저 비우지 않고 새로 읽은 것으로 바꾼다 (비우면 목록이 사라졌다 생겨 스크롤 · 자리가 바뀐다)
for (final x in _panes) {
final open = {...x.expanded, x.current};
for (final d in open) {
if (!mounted) return;
if (localOnly && isDav(d)) continue;
await _load(x, d, force: true);
}
x.cache.removeWhere((k, _) => !open.contains(k));
x.changed();
}
} finally {
_reloading = false;
}
}
@override
void dispose() {
c.removeListener(_onSettings);
_keys.dispose();
_sizes.dispose();
if (widget.rsync) {
ExplorerPage._openRsync--;
} else {
ExplorerPage._open--;
ExplorerPage.goToRequest.removeListener(_onGoToRequest);
}
for (final pane in _panes) {
pane.dispose();
}
if (_allFiles != null) _life.dispose();
_resumeReload.dispose();
super.dispose();
}
void _onPane() {
if (mounted) setState(() {});
}
Future<void> _init() async {
_local = await _loadVolumes();
if (_local.isEmpty) _local = [(Platform.isWindows ? r'C:\' : '/', Platform.isWindows ? 'C:' : '/')];
// X-plore 비교 e: 보관 일이 지난 휴지통 항목을 지운다 (뒤에서)
if (_appTrash case final t?) unawaited(t.purgeOlderThan(c.settings.androidTrashDays).catchError((_) => 0));
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)]);
final wants = [
for (var i = 0; i < 2; i++) reach[i] ? saved[i] : _local[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++) {
_panes[i]
..root = _volumeOf(wants[i])
..current = wants[i]
// 48 보충 (관리자): 처음 열면 아무것도 고르지 않은 상태 - 열자마자 폴더 통째가 복사 · 이동 대상이 되지 않게
..focused = null;
}
if (!mounted) return;
setState(() => _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) {
for (final (path, _) in _local) {
final type = driveType(path);
if (type == 4) _networkDrives.add(path);
if (type == 2 || type == 5) _removableDrives.add(path);
unawaited(_probeDrive(path));
}
}
for (var i = 0; i < 2; i++) {
final pane = _panes[i];
unawaited(_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 _panes) {
pane.cache.clear();
for (final d in pane.expanded.toList()) {
await _load(pane, d, force: true);
}
}
}
}
/// 권한이 없을 때 목록 위 안내 (이유 · 할 일 · 허용 화면 열기 · 다시 확인)
Widget? _accessNotice(_Pane 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 = Colors.redAccent}) =>
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: const TextStyle(fontSize: 12, color: JjColors.textDim)),
Wrap(spacing: 8, children: actions),
]),
);
// ───────── 만들다 만 파일 (71) ─────────
/// 복사 · 동기화가 중간에 끊겨 남은 임시 파일 (.jjpart · .jjsync). 지금 쓰는 중인 것 (2분 안에 바뀜) 은 빼고
List<FileEntry> _partials(_Pane 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(_Pane pane, String dir) {
final list = _partials(pane, dir);
if (list.isEmpty) return null;
return _notice(
icon: Icons.broken_image_outlined,
color: Colors.orangeAccent,
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 _panes) {
if (q == pane || q.cache.containsKey(dir)) await _load(q, dir, force: true);
}
if (failed.isNotEmpty) _snack(trf('지우지 못한 파일: {0}', [failed.join(', ')]));
},
child: Text(tr('지우기')),
),
],
);
}
// ───────── 읽을 수 없을 때 (12) ─────────
/// 오류 → (무엇이 문제인지, 무엇을 하면 되는지)
Widget? _errorNotice(_Pane 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: '${_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 _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: '${_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 _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: '${_displayPath(where)}: $title',
body: body,
actions: [
FilledButton.tonal(
onPressed: () {
pane.errors.remove(where);
_goTo(pane, vDirname(where));
},
child: Text(tr('위 폴더로')),
),
],
);
}
return _notice(
icon: dav ? Icons.cloud_off_outlined : Icons.error_outline,
title: '${_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 _load(pane, where, force: true);
for (final d in pane.expanded.toList()) {
if (isSameOrInside(d, where) && d != where) await _load(pane, d, force: true);
}
},
child: Text(locked ? tr('마스터 비밀번호 넣기') : tr('다시 시도')),
),
if (dav && !locked)
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 _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 _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 _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 _displayPath(path);
}
}
/// 화면에 보일 경로 (5 · 14): Android 는 "SD 카드 › JJ_sdtest › jj_mkv", WebDAV 는 "☁ 서버 › 폴더", Windows 는 그대로
String _displayPath(String path) {
if (isDav(path)) {
final d = DavPath.parse(path);
return ['${d.mark} ${d.serverLabel}', ...d.rel.split('/').where((s) => s.isNotEmpty)].join(' › ');
}
if (!Platform.isAndroid) return path;
String? best;
var label = '';
for (final (v, l) in _local) {
if ((samePath(v, path) || p.isWithin(v, path)) && (best == null || v.length > best.length)) {
best = v;
label = l;
}
}
if (best == null) return path;
final rest = p.relative(path, from: best);
return [label, if (rest != '.') ...p.split(rest)].join(' › ');
}
Future<List<(String, String)>> _loadVolumes() async {
if (ExplorerPage.debugVolumes case final f?) return f();
if (Platform.isWindows) return windowsDrives();
if (Platform.isAndroid) {
final v = await AndroidAccess.volumes();
return [for (final x in v) (x.$1, x.$2.isEmpty ? p.basename(x.$1) : x.$2)];
}
return [('/', '/')];
}
/// 설정에서 지운 WebDAV 서버를 보던 창은 이 기기의 첫 저장 장치로
void _dropRemovedServers() {
if (!_ready) return;
for (final pane in _panes) {
if (isDav(pane.root) && !DavPath.parse(pane.root).serverKnown) {
pane.marked.clear();
pane.cache.removeWhere((k, _) => isDav(k));
pane.expanded.removeWhere(isDav);
pane.root = _local.first.$1; // 다시 그릴 때 또 하지 않게
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _goTo(pane, _local.first.$1);
});
}
}
}
/// 다시 열 수 있는 폴더: 로컬은 있으면, WebDAV 는 그 서버가 아직 설정에 있으면 (열 때 확인)
bool _reachable(String path) => isDav(path)
? DavPath.parse(path).serverKnown
: Directory(path).existsSync();
/// [_reachable] 을 화면을 멈추지 않고 (2초 넘게 걸리면 있다고 보고 열어 본다)
Future<bool> _reachableAsync(String path) async {
if (isDav(path)) return _reachable(path);
try {
return await Directory(path).exists().timeout(const Duration(seconds: 2), onTimeout: () => true);
} catch (_) {
return false;
}
}
String _volumeOf(String path) {
if (isDav(path)) {
final d = DavPath.parse(path);
return '${d.scheme}${d.server}/';
}
// 37: Windows 네트워크 공유 (\\NAS\공유) 는 그 공유가 맨 위
if (Platform.isWindows && path.startsWith(r'\\')) return uncRoot(path);
String best = _local.first.$1;
var len = -1;
for (final (v, _) in _local) {
final inside = samePath(v, path) || p.isWithin(v, path) ||
(Platform.isWindows && p.isWithin(v.toLowerCase(), path.toLowerCase()));
if (inside && v.length > len) {
best = v;
len = v.length;
}
}
return best;
}
// ───────── 목록 읽기 · 이동 ─────────
Future<void> _load(_Pane pane, String dir, {bool force = false}) async {
if (!force && pane.cache.containsKey(dir)) return;
// X-plore 비교 f: 압축 안 (읽어 둔 목록에서)
if (_archiveOf(dir) != null) {
pane.cache[dir] = _sorted(_archiveList(dir), dir);
pane.changed();
return;
}
// 80: 다시 읽는 폴더는 안이 바뀌었을 수 있다 - 그 폴더와 위 폴더들의 크기를 잊는다
if (force) _sizes.forget(dir);
pane.loading.add(dir);
pane.changed();
try {
final list = await listEntries(dir, showHidden: c.settings.explorerShowHidden);
pane.cache[dir] = _sorted(list, dir);
// 50: Android 가 막은 폴더는 비어 보인다 (오류 없이) - 비어 있는 것이 아니라 막힌 것이라고 알린다
if (list.isEmpty && isAndroidRestricted(dir)) {
pane.errors[dir] = androidRestrictedError;
} else {
pane.errors.remove(dir);
}
pane.hostKeyErrors.remove(dir);
pane.certErrors.remove(dir);
} catch (e) {
pane.cache[dir] = const [];
pane.errors[dir] = isAndroidRestricted(dir) ? androidRestrictedError : '$e';
if (e is SftpHostKeyException) {
pane.hostKeyErrors[dir] = e;
} else {
pane.hostKeyErrors.remove(dir);
}
if (e is FtpCertException) {
pane.certErrors[dir] = e;
} else {
pane.certErrors.remove(dir);
}
// 37-2: 보던 폴더가 밖에서 지워졌으면 남아 있는 가장 가까운 상위 폴더로 (제목 · 경로 칸도 그 폴더로)
if (!isDav(dir) && samePath(dir, pane.current) && !samePath(dir, pane.root) && !Directory(dir).existsSync()) {
var up = vDirname(dir);
while (!samePath(up, pane.root) && !Directory(up).existsSync() && vDirname(up) != up) {
up = vDirname(up);
}
pane.errors.remove(dir);
pane.cache.remove(dir);
pane.expanded.remove(dir);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_snack(tr('보던 폴더가 없어져 상위 폴더로 옮겼습니다'));
unawaited(_goTo(pane, up, fresh: true));
});
}
} finally {
pane.loading.remove(dir);
pane.changed();
}
}
/// [dir] 의 목록을 정렬. 84: "폴더마다 기억" 이 켜져 있으면 그 폴더의 정렬 (없으면 전체 정렬)
List<FileEntry> _sorted(List<FileEntry> list, String dir) {
final (sortBy, desc) = _sortOf(dir);
final by = SortBy.values.firstWhere((s) => s.name == sortBy, orElse: () => SortBy.name);
return sortEntries(list, by, descending: desc, dirSize: (d) => _sizes.of(d.path)?.bytes);
}
static String _sortKey(String dir) => isDav(dir) ? vNorm(dir) : (Platform.isWindows ? dir.toLowerCase() : dir);
/// 84: [dir] 의 정렬 (기준, 거꾸로)
(String, bool) _sortOf(String dir) {
final s = c.settings;
if (s.explorerSortPerFolder) {
final v = s.explorerFolderSorts[_sortKey(dir)];
if (v != null && v.contains('|')) return (v.split('|').first, v.endsWith('|1'));
}
return (s.explorerSort, s.explorerSortDesc);
}
/// 84: 정렬을 바꾼다 - "폴더마다 기억" 이면 [dir] 에만, 아니면 전체. 모든 창의 목록을 다시 줄 세운다
Future<void> _setSort(String dir, String by, bool desc) async {
await c.updateSettings((x) {
if (x.explorerSortPerFolder) {
final m = Map<String, String>.of(x.explorerFolderSorts)..remove(_sortKey(dir));
m[_sortKey(dir)] = '$by|${desc ? 1 : 0}';
while (m.length > 300) {
m.remove(m.keys.first);
}
x.explorerFolderSorts = m;
} else {
x
..explorerSort = by
..explorerSortDesc = desc;
}
});
for (final pane in _panes) {
for (final k in pane.cache.keys.toList()) {
pane.cache[k] = _sorted(pane.cache[k]!, k);
}
pane.changed();
}
}
/// 84: 열 머리를 누름 - 같은 열이면 거꾸로, 다른 열이면 그 열로 (오름차순)
Future<void> _sortByColumn(_Pane pane, String by) async {
setState(() => _active = _panes.indexOf(pane));
final (now, desc) = _sortOf(pane.current);
await _setSort(pane.current, by, now == by ? !desc : false);
}
/// 그 폴더로: 저장 장치부터 그 폴더까지 펼치고 지금 폴더로 정한다
/// [fresh]: 경로 입력 · 찾기 · 내역으로 갈 때 - 그 폴더를 새로 읽는다 (앱 밖에서 바뀐 것까지 보이게, 122)
/// 폴더로 간다. 그 폴더를 "고른 항목" 으로 잡지 않는다 (48 보충 · 관리자: 누르거나 표시하지 않은 폴더가 복사 · 이동 대상이 되지 않게).
/// 위치는 지금 폴더 표시 · 스크롤로 보인다. [focus] 는 그 폴더를 일부러 고를 때만
Future<void> _goTo(_Pane pane, String dir, {bool remember = true, bool fresh = false, bool focus = false}) async {
// 124 · 128: 사용자가 WebDAV 로 간 것이면 마스터 창을 한 번 취소했어도 다시 묻는다 (켤 때 되살리는 것은 [remember] 없음)
if (remember && isDav(dir)) await SecretGate.pass(force: true);
if (!mounted) return;
pane.root = _volumeOf(dir);
final chain = <String>[];
dir = isDav(dir) ? vNorm(dir) : p.normalize(dir);
var d = dir;
while (true) {
chain.insert(0, d);
if (samePath(d, pane.root) || vDirname(d) == d) break;
d = vDirname(d);
}
for (final (k, x) in chain.indexed) {
pane.expanded.add(x);
await _load(pane, x, force: fresh && k == chain.length - 1);
// 122: 읽어 둔 목록에 다음 폴더가 없으면 (앱 밖에서 새로 만듦) 새로 읽어야 그 폴더까지 펼쳐진다
if (k < chain.length - 1 && !(pane.cache[x] ?? const <FileEntry>[]).any((e) => samePath(e.path, chain[k + 1]))) {
await _load(pane, x, force: true);
}
}
pane.current = dir;
if (focus) pane.focused = dir;
pane.changed();
if (pane.listScroll.hasClients) pane.listScroll.jumpTo(0);
_reveal(pane, dir);
if (remember) _remember(pane, dir);
}
/// 그 항목이 목록 위쪽 1/4 쯤에 보이게 스크롤 (깊은 폴더로 가면 위쪽 폴더들의 다른 항목에 밀려 안 보이므로)
void _reveal(_Pane pane, String path) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !pane.scroll.hasClients) return;
// 화면의 트리와 같은 줄로 센다 (폴더 + 파일 목록 배치 · Rsync 화면은 폴더만)
final i = _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 * _look.rowHeightOf(context) - pos.viewportDimension / 4).clamp(0.0, double.infinity));
});
}
/// 75: 창마다 뒤로 · 앞으로 (가 본 폴더로)
Future<void> _travel(_Pane pane, {required bool forward}) async {
// 앞의 이동이 끝나기 전에 또 불리면 (빨리 두 번 · 같은 누름이 두 번 들어옴) 무시 - 내역에서 두 칸이 빠지지 않게
if (pane.traveling) return;
final from = forward ? pane.forward : pane.back;
if (from.isEmpty) return;
pane.traveling = true;
try {
final to = from.removeLast();
setState(() => _active = _panes.indexOf(pane));
// 지워졌거나 열 수 없는 폴더: 그 항목은 버리고 지금 폴더는 그대로 (이유를 알림)
bool ok;
try {
ok = await vIsDir(to);
} catch (_) {
ok = false;
}
if (!mounted) return;
if (!ok) {
// 트리에도 남지 않게 (밖에서 지운 폴더) - 그 상위 폴더를 두 창 모두 다시 읽는다
for (final x in _panes) {
x.expanded.removeWhere((d) => samePath(d, to) || d.toLowerCase().startsWith('${to.toLowerCase()}${isDav(to) ? '/' : p.separator}'));
}
await _refreshAll([vDirname(to)]);
if (!mounted) return;
setState(() {});
_snack(trf('{0} 폴더가 없거나 열 수 없어 내역에서 뺐습니다', [_displayPath(to)]));
return;
}
(forward ? pane.back : pane.forward).add(pane.current);
await _goTo(pane, to);
} finally {
pane.traveling = false;
}
if (mounted) setState(() {});
}
/// 마지막 폴더 · 내역 저장
void _remember(_Pane pane, String dir) {
final i = _panes.indexOf(pane);
final paths = [..._panes.map((x) => x.current)];
if (widget.rsync) {
unawaited(c.updateSettings((s) => s.rsyncPaths = paths));
} else {
final hist = [dir, ...c.settings.explorerHistory.where((h) => !samePath(h, dir))].take(30).toList();
unawaited(c.updateSettings((s) => s
..explorerPaths = paths
..explorerHistory = hist));
}
if (i >= 0) _active = i;
}
/// 지금 모양 (환경 설정 > 파일 탐색기 > 스타일)
ExplorerStyle get _look => ExplorerStyle.of(c.settings.explorerStyle);
/// 왼쪽 폴더 트리 + 오른쪽 파일 목록 배치
bool get _split => !widget.rsync && c.settings.explorerLayout == 'split';
/// 좌우 두 창 (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 && _look == ExplorerStyle.xplore && c.settings.explorerAlwaysMarks;
/// X-plore 비교 ⚖: X-plore 스타일 탐색기 안에서 밀기 = 창 바꾸기 (설정)
bool get _swipePanes => !widget.rsync && _look == ExplorerStyle.xplore && c.settings.explorerSwipePanes;
/// 밀어서 창 바꾸기: 두 창이면 다른 창을 활성으로, 한 창이면 숨은 창과 바꿔 보인다
bool _switchPane(int dir) {
if (!_swipePanes) return false;
setState(() {
if (_dual || _split) {
_active = 1 - _active;
} else {
final t = _panes[0];
_panes[0] = _panes[1];
_panes[1] = t;
_active = 0;
}
});
_remember(_pane, _pane.current);
_snack(trf('창 바꿈: {0}', [_displayPath(_pane.current)]));
return true;
}
bool get _rootsInTree => !widget.rsync && _look == ExplorerStyle.xplore && c.settings.explorerRootsInTree;
List<_Row> _rows(_Pane pane, {bool foldersOnly = false}) {
final out = <_Row>[];
final rootEntry = FileEntry(pane.root, isDir: true, modified: DateTime(0));
// X-plore 비교 c: 지금 저장 장치 앞뒤로 다른 저장 장치 · 서버 (접힌 줄), 맨 끝에 즐겨찾기
final roots = _rootsInTree ? _volumes : const <(String, String)>[];
final here = roots.indexWhere((v) => samePath(v.$1, pane.root));
for (final (path, _) in roots.take(here < 0 ? 0 : here)) {
out.add(_Row(FileEntry(path, isDir: true, modified: DateTime(0)), 0, kind: _Row.otherRoot));
}
out.add(_Row(rootEntry, 0, isRoot: true));
// 76: 빠른 필터 - 지금 폴더 안만 거른다 (트리의 다른 곳은 그대로).
// 이름이 맞지 않아도 안에 맞는 것이 보이는 폴더는 남긴다 (어디에 있는지 보이게)
var hidden = 0;
bool walk(String dir, int depth) {
if (!pane.expanded.contains(dir)) return false;
var kept = false;
for (final e in pane.cache[dir] ?? const <FileEntry>[]) {
if (foldersOnly && !e.isDir) continue;
// X-plore 숨기기: 목록에서 숨긴 것 (그 안도) 은 [숨긴 항목 보이기] 를 켰을 때만
if (_appHidden(e.path)) {
hidden++;
if (!_showAppHidden) continue;
}
final at = out.length;
out.add(_Row(e, depth));
// X-plore 비교 f: 펼친 압축 (ZIP · 7z · RAR) 도 안을 폴더처럼
final inner = (e.isDir || _archives.containsKey(e.path)) && walk(e.path, depth + 1);
if (!inner && !_passes(pane, e)) {
out.length = at;
} else {
kept = true;
}
}
return kept;
}
walk(pane.root, 1);
if (roots.isNotEmpty) {
for (final (path, _) in here < 0 ? roots : roots.skip(here + 1)) {
out.add(_Row(FileEntry(path, isDir: true, modified: DateTime(0)), 0, kind: _Row.otherRoot));
}
final favs = c.settings.folderBookmarks;
if (favs.isNotEmpty) {
out.add(_Row(FileEntry('\u0000favorites', isDir: true, modified: DateTime(0)), 0, kind: _Row.favorites));
if (pane.favoritesOpen) {
for (final b in favs) {
out.add(_Row(FileEntry(b.path, isDir: true, modified: DateTime(0)), 1, kind: _Row.favorite));
}
}
}
}
// X-plore 숨기기: 펼친 폴더 안에 숨긴 것이 있으면 목록 맨 아래에 [숨긴 항목 n개 보이기] (X-plore 자리)
if (hidden > 0) out.add(_Row(FileEntry('\u0000hidden', isDir: false, modified: DateTime(0)), 0, kind: _Row.hiddenToggle, count: hidden));
return out;
}
/// X-plore 숨기기: [path] 가 목록에서 숨긴 것인지 (앱 설정의 목록 - 파일은 그대로)
bool _appHidden(String path) {
final list = c.settings.explorerHiddenItems;
if (list.isEmpty) return false;
if (!identical(list, _hiddenFrom)) {
_hiddenFrom = list;
_hiddenKeys = {for (final x in list) _hideKey(x)};
}
return _hiddenKeys.contains(_hideKey(path));
}
List<String>? _hiddenFrom;
Set<String> _hiddenKeys = const {};
/// 숨긴 목록에서 같은 경로를 찾는 열쇠 (Windows 는 대소문자 무시)
static String _hideKey(String x) {
if (isDav(x) || x.contains('://')) return x;
final n = p.normalize(x);
return Platform.isWindows ? n.toLowerCase() : n;
}
/// X-plore 숨기기: 옮기거나 이름을 바꾼 것 ([moved] 원래 → 새 경로, 그 안의 것도) 은 숨긴 목록도 새 경로로,
/// 지운 것 ([gone], 그 안의 것도 - 실제로 없어진 것만) 은 목록에서 뺀다 (관리자 결정 10/18)
Future<void> _followHidden({Iterable<(String, String)> moved = const [], Iterable<String> gone = const []}) async {
final list = c.settings.explorerHiddenItems;
if (list.isEmpty || (moved.isEmpty && gone.isEmpty)) return;
final out = <String>[];
var changed = false;
for (final h in list) {
if (gone.any((g) => isSameOrInside(h, g)) && !await vExists(h)) {
changed = true;
continue;
}
var now = h;
for (final (from, to) in moved) {
if (samePath(h, from)) {
now = to;
break;
}
if (isSameOrInside(h, from)) {
now = vJoin(to, isDav(h) ? h.substring(from.length).replaceAll('\\', '/').replaceFirst(RegExp('^/+'), '') : p.relative(h, from: from));
break;
}
}
if (now != h) changed = true;
if (!out.any((x) => _hideKey(x) == _hideKey(now))) out.add(now);
}
if (changed) await c.updateSettings((s) => s.explorerHiddenItems = out);
}
/// X-plore 숨기기: 목록 맨 아래의 [숨긴 항목 n개 보이기] · [다시 숨기기] 를 누르면 (이번 실행 동안, 모든 창에)
bool _showAppHidden = false;
void _toggleAppHidden() {
setState(() => _showAppHidden = !_showAppHidden);
for (final q in _panes) {
q.changed();
}
}
/// 설정 화면에서 숨긴 목록을 바꾸면 (풀기) 바로 목록에 보이게
List<String>? _hiddenSeen;
void _onSettings() {
final now = c.settings.explorerHiddenItems;
if (identical(now, _hiddenSeen) || !mounted) return;
_hiddenSeen = now;
setState(() {});
for (final q in _panes) {
q.changed();
}
}
Widget _hiddenToggleRow(_Row row) {
final look = _look;
final label = _showAppHidden ? trf('숨긴 항목 {0}개 다시 숨기기', [row.count]) : trf('숨긴 항목 {0}개 보이기', [row.count]);
return Semantics(
button: true,
label: label,
excludeSemantics: true,
onTap: _toggleAppHidden,
child: InkWell(
onTap: _toggleAppHidden,
child: Row(children: [
SizedBox(width: treeChevronWidth(defaultTargetPlatform) + 4),
Icon(_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(_Pane pane, _Row row) {
if (row.kind == _Row.hiddenToggle) return _hiddenToggleRow(row);
final look = _look;
final path = row.entry.path;
final volume = _volumes.firstWhere((v) => samePath(v.$1, path), orElse: () => (path, vBasename(path))).$2;
final fav = row.kind == _Row.favorite
? c.settings.folderBookmarks.firstWhere((b) => b.path == path, orElse: () => FolderBookmark(name: vBasename(path), path: path))
: null;
final (IconData icon, Color color, String label, bool? open) = switch (row.kind) {
_Row.otherRoot => (
isDav(path)
? Icons.cloud_outlined
: Platform.isWindows
? Icons.storage
: Icons.sd_storage_outlined,
JjColors.accent,
volume,
false,
),
_Row.favorites => (Icons.star, Colors.amber, tr('즐겨찾기'), pane.favoritesOpen),
_ => (Icons.star_outline, Colors.amber, fav!.name, null),
};
void tap() {
setState(() => _active = _panes.indexOf(pane));
if (row.kind == _Row.favorites) {
pane.favoritesOpen = !pane.favoritesOpen;
pane.changed();
return;
}
unawaited(_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(_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 _panes) {
x.marked.clear();
}
}
});
/// 한 번 누르기.
/// - 선택 모드: 고르기 · 풀기 ([_toggleMark])
/// - 폴더: 바로 펼치기 · 접기 (트리), 들어가기 (오른쪽 파일 목록)
/// - 파일: 누르기 설정이 "선택" 이면 고르기만 (두 번 누르면 실행), "바로 실행" 이면 실행
/// Ctrl 을 누른 채면 선택 모드로 고르기. 목록의 ".." 은 늘 상위 폴더로.
/// X-plore 비교 d: 마지막으로 누른 폴더 · 시각 (두 번 누르기의 두 번째를 가리려고)
(String, DateTime)? _lastFolderTap;
Future<void> _onTap(_Pane pane, _Row row, {bool list = false}) async {
final e = row.entry;
setState(() => _active = _panes.indexOf(pane));
if (row.isUp) {
await _goTo(pane, e.path);
return;
}
if (widget.rsync) {
await _pick(pane, e);
return;
}
// 74: Shift + 누르기 = 마지막으로 표시한 것부터 여기까지 (PC)
if (!row.isRoot && HardwareKeyboard.instance.isShiftPressed && _canRange(pane, e)) {
if (!_selecting) setState(() => _selecting = true);
_markRange(pane, e);
return;
}
if (!row.isRoot && (_selecting || HardwareKeyboard.instance.isControlPressed)) {
if (!_selecting) setState(() => _selecting = true);
await _toggleMark(pane, e);
return;
}
// X-plore 비교 d: 펼쳐 둔 지금 폴더를 다시 누르면 접기 (설정). 두 번 누르기의 두 번째 (짧은 사이) 는 접지 않는다
if (e.isDir && !list && c.settings.explorerRetapCollapse) {
final now = DateTime.now();
final last = _lastFolderTap;
_lastFolderTap = (e.path, now);
final again = last != null && samePath(last.$1, e.path) && now.difference(last.$2) < kDoubleTapTimeout * 2;
if (!again && pane.expanded.contains(e.path) && samePath(pane.current, e.path)) {
await _toggle(pane, e);
return;
}
}
if (e.isDir || !_selectMode) return _onOpen(pane, row, list: list);
pane.focused = e.path;
if (!list) pane.current = vDirname(e.path);
pane.changed();
}
/// 표시되었는지: 자기 자신, 또는 위 폴더가 표시되었으면 (폴더를 고르면 안의 것도 모두 고른 것).
/// Rsync 화면은 고른 그 폴더만.
bool _isMarked(_Pane pane, String path) =>
pane.marked.any((m) => widget.rsync ? samePath(path, m) : isSameOrInside(path, m));
/// Rsync 화면: 창마다 폴더 하나만 고른다 (다시 누르면 취소). 고르면 펼쳐서 안의 폴더가 보이게.
Future<void> _pick(_Pane pane, FileEntry e) async {
if (!e.isDir) return;
if (pane.marked.length == 1 && samePath(pane.marked.first, e.path)) {
setState(pane.marked.clear);
return;
}
setState(() => pane.marked
..clear()
..add(e.path));
if (!pane.expanded.contains(e.path)) {
await _toggle(pane, e);
} else {
pane
..current = e.path
..focused = e.path
..changed();
}
}
/// 74: 범위 고르기를 할 수 있는지 - 마지막으로 표시한 것이 같은 폴더에 있고 다른 항목
bool _canRange(_Pane pane, FileEntry e) {
final a = pane.anchor;
return a != null && !samePath(a, e.path) && samePath(vDirname(a), vDirname(e.path)) && _isMarked(pane, a);
}
/// 76: 빠른 필터를 지나는지 - 지금 폴더 안의 것만 거른다
bool _passes(_Pane pane, FileEntry e) =>
pane.filter.isEmpty ||
!isSameOrInside(e.path, pane.current) ||
samePath(e.path, pane.current) ||
quickFilterMatch(vBasename(e.path), pane.filter);
/// 76: [dir] 에서 지금 보이는 것 (빠른 필터에 맞는 것)
/// X-plore 숨기기: 목록에서 숨긴 것은 [숨긴 항목 보이기] 를 켰을 때만
List<FileEntry> _shown(_Pane pane, String dir) => [
for (final x in pane.cache[dir] ?? const <FileEntry>[])
if (_passes(pane, x) && (_showAppHidden || !_appHidden(x.path))) x
];
/// 74: 마지막으로 표시한 것부터 [to] 까지 (보이는 순서대로) 모두 표시
void _markRange(_Pane pane, FileEntry to) {
final list = _shown(pane, vDirname(to.path));
final a = list.indexWhere((x) => samePath(x.path, pane.anchor!));
final b = list.indexWhere((x) => samePath(x.path, to.path));
if (a < 0 || b < 0) return;
setState(() {
for (var i = a < b ? a : b; i <= (a < b ? b : a); i++) {
pane.marked.add(list[i].path);
}
_normalizeMarks(pane);
pane.anchor = to.path;
pane.focused = to.path;
});
}
/// 74: 고르기 도구 - 이 폴더 ([dir]) 의 항목에서
void _invertMarks(_Pane pane, String dir) => setState(() {
for (final x in _shown(pane, dir)) {
if (!pane.marked.remove(x.path)) pane.marked.add(x.path);
}
_normalizeMarks(pane);
});
void _markWhere(_Pane pane, String dir, bool Function(FileEntry e) test) => setState(() {
pane.marked.addAll([for (final x in _shown(pane, dir)) if (test(x)) x.path]);
_normalizeMarks(pane);
});
Future<void> _markPattern(_Pane 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.marked.length;
_markWhere(pane, dir, (x) => re.hasMatch(vBasename(x.path)));
if (pane.marked.length == before) _snack(trf('"{0}" 에 맞는 항목이 없습니다', [pattern.trim()]));
}
/// 다른 표시 안에 든 표시는 뺀다 (폴더 하나로 안의 것까지 고른 것이므로)
void _normalizeMarks(_Pane pane) {
final all = pane.marked.toList();
pane.marked.removeWhere((m) => all.any((o) => !identical(o, m) && !samePath(o, m) && isSameOrInside(m, o)));
}
/// 선택 모드에서 누르기. 폴더는 자기 자신과 안의 파일 · 폴더 전체가 한 묶음이다.
/// - 고르지 않은 것을 누르면 고른다 (폴더는 펼쳐서 안의 것도 모두 고른 모양으로 보여 줌)
/// - 고른 것을 다시 누르면 자기 자신과 안의 것을 모두 푼다.
/// 위 폴더를 골라서 함께 골라진 것이면 그것만 빼고, 같은 폴더의 나머지는 고른 채로 둔다.
Future<void> _toggleMark(_Pane pane, FileEntry e) async {
pane.focused = e.path;
// X-plore 비교 ⚖ (설정 'xplore'): 폴더 1번 = 폴더 자신 · 2번 = 안의 파일 전체 · 3번 = 풀기
if (e.isDir && c.settings.explorerFolderMarkRule == 'xplore' && !widget.rsync) {
final self = pane.marked.any((m) => samePath(m, e.path));
if (self) {
await _load(pane, e.path);
final kids = [for (final x in pane.cache[e.path] ?? const <FileEntry>[]) if (!x.isDir) x.path];
setState(() {
pane.marked.removeWhere((m) => samePath(m, e.path));
pane.marked.addAll(kids);
pane.expanded.add(e.path);
});
return;
}
final kids = [for (final x in pane.cache[e.path] ?? const <FileEntry>[]) if (!x.isDir) x.path];
final inside = kids.isNotEmpty && kids.every((k) => pane.marked.any((m) => samePath(m, k)));
if (inside) {
setState(() => pane.marked.removeWhere((m) => kids.any((k) => samePath(k, m))));
return;
}
}
if (!_isMarked(pane, e.path)) {
setState(() {
pane.marked.removeWhere((m) => isSameOrInside(m, e.path)); // 안에 따로 고른 것은 이 폴더 하나로
pane.marked.add(e.path);
pane.anchor = e.path;
});
if (e.isDir) {
await _load(pane, e.path);
setState(() => pane.expanded.add(e.path));
}
return;
}
final owner = pane.marked.firstWhere((m) => isSameOrInside(e.path, m));
setState(() => pane.marked.removeWhere((m) => isSameOrInside(m, e.path)));
if (samePath(owner, e.path)) return;
// 위 폴더 [owner] 를 골라 둔 상태: owner 표시를 풀고, e 까지 내려가는 길의 다른 항목은 고른 채로
final keep = <String>[];
var dir = owner;
while (!samePath(dir, e.path)) {
await _load(pane, dir);
String? next;
for (final x in pane.cache[dir] ?? const <FileEntry>[]) {
if (isSameOrInside(e.path, x.path)) {
next = x.path;
} else {
keep.add(x.path);
}
}
if (next == null) break;
dir = next;
}
setState(() {
pane.marked.remove(owner);
pane.marked.addAll(keep);
});
}
/// 열기 (파일: 선택 설정이면 두 번 누르기, 바로 실행이면 한 번 누르기 / 폴더: 한 번 누르기):
/// 트리의 폴더는 펼치기 · 접기, 목록의 폴더는 들어가기 (왼쪽 트리도 따라감), 파일은 실행.
Future<void> _onOpen(_Pane pane, _Row row, {bool list = false}) async {
final e = row.entry;
setState(() => _active = _panes.indexOf(pane));
pane.focused = e.path;
if (row.isUp || (e.isDir && list)) {
await _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();
_remember(pane, e.path);
}
return;
}
if (!list) pane.current = vDirname(e.path);
pane.changed();
await _open(e.path, pane: pane);
}
/// 트리 폴더 펼치기 · 접기 (› 화살표 · 열기)
Future<void> _toggle(_Pane pane, FileEntry e) async {
setState(() => _active = _panes.indexOf(pane));
// X-plore 비교 f: 압축 파일을 폴더처럼 펼치기 (읽기 전용)
if (_canBrowseArchive(e) && !pane.expanded.contains(e.path)) {
if (!await _readArchive(e.path)) return;
pane.cache.remove(e.path);
pane.expanded.add(e.path);
await _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 _load(pane, e.path);
if (pane.listScroll.hasClients) pane.listScroll.jumpTo(0);
_remember(pane, e.path);
}
/// 다른 앱으로 열지 못했을 때: 음악 · 동영상이면 내장 플레이어를 알려 준다 (29)
String _noAppMessage(String path) => isAudioFile(path) || isVideoFile(path)
? tr('이 형식을 여는 앱이 없습니다. 길게 누르거나 오른쪽 클릭 → [내장 플레이어로 재생] 으로 들어 보세요.')
: tr('이 파일을 열 수 있는 앱이 없습니다.');
/// 파일 열기 (기본 동작): 동영상은 내장 플레이어 (또는 확장자별 프로그램), 그 밖은 기본 연결 프로그램
Future<void> _open(String path, {_Pane? pane}) async {
// X-plore 비교 f: 압축 안의 파일 - 그 파일만 임시 폴더로 꺼내 연다
if (_insideArchive(path)) {
final local = await _extractOne(path);
if (local == null || !mounted) return;
path = local;
}
if (isVideoFile(path)) {
await _play([path]);
return;
}
// 86: 7z · RAR 은 열면 압축 풀기 (ZIP 은 아래의 보기 · ZIP 화면)
final kind = archiveKindOf(path);
if (kind == ArchiveKind.sevenZip || kind == ArchiveKind.rar) return _extractArchive(path, pane ?? _pane);
// 이미지 · PDF · ZIP 보기 (컴포넌트를 켰을 때)
if (c.settings.components.contains('viewer')) {
final ext = extOf(path);
if (c.settings.imageExts.contains(ext)) return _viewImages(path, pane);
if (ext == 'pdf') return _viewPdf(path);
if (zipExtensions.contains(ext)) return _openZip(path, pane);
}
// 문서 미리보기 (Windows: 설치한 변환기로 PDF 로 바꿔 봄)
if (c.settings.components.contains('docs') && Platform.isWindows &&
AppComponent.docExtensions.contains(extOf(path))) {
return _viewDoc(path);
}
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(_Pane pane, String dir) async {
await _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 _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, _Pane? pane) async {
final dir = vDirname(path);
// 폴더를 읽지 못하면 (E10: 이제 오류로 알림) 고른 그림 하나만
List<FileEntry> list;
try {
list = pane?.cache[dir] ?? _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);
}
/// 문서 → PDF (진행 창) → 보기
Future<void> _viewDoc(String path) async {
final temp = await c.services.storage.tempDirectory();
if (!mounted) return;
final dialog = showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => AlertDialog(
content: Row(children: [
const CircularProgressIndicator(),
const SizedBox(width: 16),
Expanded(child: Text(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 (mounted) Navigator.of(context).pop();
await dialog;
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);
}
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 _refreshAll([vDirname(path)]);
}
/// ZIP: 환경 설정이 "만화 보기" 면 안의 그림을 바로 보고 (그림이 없으면 목록), 아니면 목록
/// 87: 저장 공간 분석 → 고른 것을 탐색기에서 그 자리로 (지우기는 탐색기의 지우기로만).
/// WebDAV 는 직접 시킬 때만 (이 메뉴) 하고, 서버에서 모두 읽어 오래 걸린다고 먼저 알린다
Future<void> _storage(_Pane pane, String dir) async {
setState(() => _active = _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;
}
final path = await StorageAnalysisPage.open(context, dir);
if (path == null || !mounted) return;
final isDir = await vIsDir(path);
await _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: [
const 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, _Pane pane) async {
final other = _dual ? _panes[1 - _panes.indexOf(pane)].current : null;
final out = await extractArchiveFlow(context, c, path, otherDir: other);
if (out != null && mounted) await _refreshAll([out, vDirname(out)]);
}
Future<void> _openZip(String path, _Pane? 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 : _panes[1 - _panes.indexOf(pane)].current;
final out = await openZip(context, c, path, otherDir: other);
if (out != null) await _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 _refreshAll([dir]);
}
Future<void> _menu(_Pane pane, FileEntry e, Offset at) async {
setState(() => _active = _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.marked.contains(e.path) ? pane.marked.toList() : [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.marked.length > 1 ? pane.marked.toList() : [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, _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 비교 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('글 뷰어로 보기')),
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(_appHidden)
? ('appUnhide', Icons.visibility_outlined, many ? trf('{0}개 숨기기 풀기', [targets.length]) : tr('숨기기 풀기'))
: ('appHide', Icons.visibility_off_outlined, many ? trf('{0}개 목록에서 숨기기', [targets.length]) : tr('목록에서 숨기기')),
// 두 창이면 아이콘 줄의 복사 · 이동은 다른 창으로 - 담아 두었다가 원하는 폴더에 붙여넣기는 여기
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.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: const 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 _goTo(pane, e.path);
case 'openOther':
final other = _panes[1 - _panes.indexOf(pane)];
await _goTo(other, e.path);
setState(() => _active = _panes.indexOf(other));
case 'playFolder':
await _playFolder(e.path);
case 'playInternal':
await _play([e.path], internal: true);
case 'grid':
await _grid(pane, e.path);
case 'text':
await _viewText(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.marked.length > 1 ? pane.marked.toList() : [e.path]);
final other = _dual ? _panes[1 - _panes.indexOf(pane)].current : null;
final made = await createZipFlow(context, c, items, dir: vDirname(items.first), otherDir: other);
if (made != null && mounted) {
await _refreshAll([vDirname(made)]);
pane.focused = made;
pane.changed();
}
case 'appHide':
await c.updateSettings((s) => s.explorerHiddenItems = [
...s.explorerHiddenItems,
for (final x in targets)
if (!_appHidden(x)) x,
]);
for (final q in _panes) {
q.marked.removeAll(targets);
q.changed();
}
if (mounted) setState(() {});
_snack(_showAppHidden
? trf('목록에서 숨겼습니다 ({0}개). 파일은 그대로입니다.', [targets.length])
: trf('목록에서 숨겼습니다 ({0}개). 파일은 그대로이고, 목록 맨 아래의 [숨긴 항목 보이기] 로 다시 볼 수 있습니다.', [targets.length]));
case 'appUnhide':
final keys = {for (final x in targets) _hideKey(x)};
await c.updateSettings((s) => s.explorerHiddenItems = [
for (final x in s.explorerHiddenItems)
if (!keys.contains(_hideKey(x))) x,
]);
for (final q in _panes) {
q.changed();
}
if (mounted) setState(() {});
case 'props':
await showFolderProperties(context, e.path);
case 'hash':
await showFileHash(context, e.path);
case 'folderSize':
if (_sizes.busy(e.path)) {
_sizes.cancel(e.path);
break;
}
// 사용자가 직접 시킨 것: 다른 폴더로 가도 끝까지 (멈추기는 아래 알림 · 같은 메뉴)
_sizes
..forget(e.path)
..request(e.path);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(isDav(e.path)
? trf('"{0}" 크기를 계산합니다. 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다.', [e.name])
: trf('"{0}" 크기를 계산합니다.', [e.name])),
action: SnackBarAction(label: tr('취소'), onPressed: () => _sizes.cancel(e.path)),
));
}
case 'rename':
if (_isMarked(pane, e.path) && pane.marked.length > 1) {
await _batchRename(pane, pane.marked.toList());
} else {
await _rename(pane, e.path);
}
case 'copy':
await _transfer(pane, _isMarked(pane, e.path) ? pane.marked.toList() : [e.path], move: false);
case 'move':
await _transfer(pane, _isMarked(pane, e.path) ? pane.marked.toList() : [e.path], move: true);
case 'clipCopy':
_toClipboard(_isMarked(pane, e.path) ? pane.marked.toList() : [e.path], move: false);
case 'clipMove':
_toClipboard(_isMarked(pane, e.path) ? pane.marked.toList() : [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]));
}
}
// ───────── 기능 ─────────
_Pane get _pane => _panes[_active];
_Pane? get _other => _dual || _hiddenTarget ? _panes[1 - _active] : null;
// ───────── X-plore 비교 f: 압축을 트리 안에서 폴더처럼 (ZIP · 7z · RAR, 읽기 전용) ─────────
/// 펼쳐 본 압축의 목록 (압축 경로 → 항목)
final _archives = <String, List<ArchiveEntry>>{};
/// 트리에서 펼칠 수 있는 압축 (이 기기의 파일 · 탐색기 화면)
bool _canBrowseArchive(FileEntry e) => !widget.rsync && !e.isDir && !isDav(e.path) && archiveKindOf(e.path) != null;
/// [path] 가 들어 있는 (펼쳐 둔) 압축 - 압축 자신도
String? _archiveOf(String path) {
String? best;
for (final a in _archives.keys) {
if ((samePath(path, a) || isSameOrInside(path, a)) && (best == null || a.length > best.length)) best = a;
}
return best;
}
/// 압축 안의 항목인지 (압축 파일 자신은 아님)
bool _insideArchive(String path) {
final a = _archiveOf(path);
return a != null && !samePath(a, path);
}
/// 압축 안 경로 → 압축 안의 이름 ("폴더/파일")
String _innerName(String archive, String path) => p.relative(path, from: archive).replaceAll('\\', '/');
/// 압축의 목록을 읽어 둔다 (비밀번호가 걸린 7z 등은 [압축 풀기] 로 안내). 읽었으면 true
Future<bool> _readArchive(String path) async {
if (_archives.containsKey(path)) return true;
final kind = archiveKindOf(path)!;
try {
if (kind == ArchiveKind.zip) {
_archives[path] = await listZip(path);
} else {
final exe = await bundledSevenZip();
if (exe == null) {
_snack(tr('이 기기에서는 7z · RAR 안을 볼 수 없습니다 (다른 앱으로 열어 주세요)'));
return false;
}
_archives[path] = await list7z(exe, path);
}
return true;
} on ArchivePasswordException {
_snack(tr('비밀번호가 걸린 압축은 안을 미리 볼 수 없습니다 - [압축 풀기] 로 푸세요'));
} catch (e) {
_snack(trf('압축을 읽지 못했습니다: {0}', [e]));
}
return false;
}
/// 압축 안 폴더 [dir] 의 바로 아래 항목 (목록의 폴더 이름이 없어도 경로에서 폴더를 만든다)
List<FileEntry> _archiveList(String dir) {
final arc = _archiveOf(dir)!;
final rel = samePath(dir, arc) ? '' : _innerName(arc, dir);
final out = <String, FileEntry>{};
for (final a in _archives[arc]!) {
if (a.isLink) continue;
final n = a.name.replaceAll('\\', '/').replaceAll(RegExp(r'^(\./)+|/+$'), '');
if (n.isEmpty || unsafeArchivePath(n) != null) continue;
if (rel.isNotEmpty && !n.startsWith('$rel/')) continue;
final rest = rel.isEmpty ? n : n.substring(rel.length + 1);
if (rest.isEmpty) continue;
final first = rest.split('/').first;
final folder = rest.contains('/') || a.isDir;
final path = p.joinAll([arc, ...(rel.isEmpty ? first : '$rel/$first').split('/')]);
final old = out[first];
if (old == null || (folder && !old.isDir)) {
out[first] = FileEntry(path, isDir: folder, size: folder ? 0 : a.size, modified: a.modified ?? DateTime(0));
}
}
return out.values.toList();
}
/// 압축 안의 파일 하나를 임시 폴더로 꺼낸다 (보기 · 재생). 꺼낸 경로 (못 하면 null)
Future<String?> _extractOne(String path) async {
final arc = _archiveOf(path)!;
final inner = _innerName(arc, path);
final dest = p.join(await c.services.storage.tempDirectory(), 'jj_arcview', '${arc.hashCode.toUnsigned(32)}');
final plan = planExtract(_archives[arc]!, names: [inner]);
if (plan.encrypted) {
_snack(tr('비밀번호가 걸린 파일입니다 - [압축 풀기] 로 푸세요'));
return null;
}
try {
if (archiveKindOf(arc) == ArchiveKind.zip) {
await extractZipPlan(arc, dest, plan, conflict: NameConflict.overwrite);
} else {
final exe = await bundledSevenZip();
if (exe == null) return null;
await extract7zPlan(exe, arc, dest, plan, conflict: NameConflict.overwrite);
}
} catch (e) {
_snack(trf('압축에서 꺼내지 못했습니다: {0}', [e]));
return null;
}
final out = p.joinAll([dest, ...inner.split('/')]);
return File(out).existsSync() ? out : null;
}
/// 압축 안의 것을 꺼내기 - 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)) _innerName(arc, x)];
final done = await extractArchiveFlow(context, c, arc, otherDir: otherDir, names: names);
if (done != null && mounted) await _refreshAll([done]);
}
/// 압축 안을 바꾸려 하면 막고 알린다 (읽기 전용). 막았으면 true
/// [self] 면 압축 파일 자신도 (그 안으로 넣는 곳일 때)
bool _blockArchive(List<String> paths, {bool self = false}) {
if (!paths.any((x) => _insideArchive(x) || (self && _archives.containsKey(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) =>
_volumes.firstWhere((v) => samePath(v.$1, dir), orElse: () => (dir, vBasename(dir).isEmpty ? _displayPath(dir) : vBasename(dir))).$2;
/// X-plore 비교 a: 한 창일 때 맨 위의 "→ 갈 곳" 줄 - 숨은 창의 폴더 · [지금 폴더로] · [다른 폴더]
Widget _targetBar() {
final t = _panes[1 - _active];
return Material(
color: JjColors.accent.withValues(alpha: 0.10),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: Row(children: [
const Icon(Icons.arrow_forward, size: 18, color: JjColors.accent),
const SizedBox(width: 6),
// 412 캡처 (10/18): "갈 곳:" 은 늘 보이고, 폴더 이름이 길면 가운데를 줄인다 (전체 경로는 길게 눌러 · 마우스를 올려)
Expanded(
child: Tooltip(
message: _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: () => _goTo(t, _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 _goTo(t, d);
},
child: Text(tr('다른 폴더')),
),
]),
),
);
}
/// 기능의 대상: 표시한 항목, 없으면 마지막으로 누른 항목 (저장 장치 맨 위는 빼고)
List<String> _targets(_Pane pane) {
if (pane.marked.isNotEmpty) return pane.marked.toList();
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> _refresh(_Pane pane, Iterable<String> dirs) async {
for (final d in {...dirs}) {
if (pane.cache.containsKey(d)) await _load(pane, d, force: true);
}
}
/// 265: 복사 · 이동 뒤 - 대상 · 원본의 위 폴더와, 펼쳐 둔 원본 · 대상 안쪽까지 두 창 모두 다시 읽는다.
/// (예전에는 원본의 위 폴더만 읽어, 합치다 남은 원본 폴더 아래에 옮겨진 파일이 계속 보였다) 없어진 폴더는 접는다
Future<void> _refreshAfterTransfer(List<String> sources, String dest) async {
for (final x in _panes) {
final dirs = {
dest,
...sources.map(vDirname),
for (final k in x.cache.keys)
if (isSameOrInside(k, dest) || sources.any((s) => isSameOrInside(k, s))) k,
};
for (final d in dirs) {
if (!mounted) return;
if (!x.cache.containsKey(d)) continue;
if (!await vExists(d)) {
x.cache.remove(d);
x.expanded.remove(d);
continue;
}
// 180: 비우지 않고 새로 읽은 것으로 바꾼다 (자리 · 스크롤 그대로)
await _load(x, d, force: true);
}
}
}
/// 바뀐 폴더를 두 창 모두 다시 읽는다
Future<void> _refreshAll(Iterable<String> dirs) async {
for (final pane in _panes) {
await _refresh(pane, dirs);
}
}
/// X-plore b: 가운데 버튼 길게 누르기 - 복사 ↔ 이동, 지우기 → 영구 삭제로 묻기 (확인 창은 그대로)
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 = _pane;
switch (b) {
case ExplorerButton.up:
if (samePath(pane.current, pane.root)) return;
final up = vDirname(pane.current);
pane.expanded.remove(pane.current);
await _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 _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.marked.isNotEmpty);
} 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 _load(pane, parent, force: true);
await _refreshAll([parent]);
// Rsync 화면: 만든 폴더를 이 창의 원본 · 대상으로 바로 고른다
if (widget.rsync) {
pane.marked
..clear()
..add(made);
}
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.trash:
await _openTrash();
case ExplorerButton.sort:
await _sortDialog();
case ExplorerButton.hidden:
await c.updateSettings((s) => s.explorerShowHidden = !s.explorerShowHidden);
for (final x in _panes) {
x.cache.clear();
for (final d in x.expanded.toList()) {
await _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(_Pane 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 _followHidden(moved: [(path, now)]);
await _refreshAll([vDirname(path)]);
for (final x in _panes) {
if (x.marked.remove(path)) x.marked.add(now);
}
pane.focused = now;
pane.changed();
} catch (e) {
_snack(trf('이름을 바꿀 수 없습니다: {0}', [e]));
}
}
/// 78: 일괄 이름 바꾸기 - 번호는 목록에 보이는 차례대로. 끝나면 [되돌리기]
Future<void> _batchRename(_Pane 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;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(trf('{0}개의 이름을 바꿨습니다.', [done.length])),
duration: const Duration(seconds: 10),
action: SnackBarAction(
label: tr('되돌리기'),
onPressed: () 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(_Pane pane, List<(String, String)> moved) async {
await _followHidden(moved: moved);
await _refreshAll({for (final (a, _) in moved) vDirname(a)});
for (final x in _panes) {
for (final (from, to) in moved) {
if (x.marked.remove(from)) x.marked.add(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(_Pane 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 ok = await showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, set) => AlertDialog(
scrollable: true,
title: Text(permanent ? tr('영구 삭제') : tr('휴지통으로 보내기')),
// 무엇을 지우는지 늘 보여 준다 (여러 개면 앞의 8개 이름 · 폴더)
content: 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: const TextStyle(fontSize: 12, color: Colors.orangeAccent)),
),
if (hiddenInside > 0)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(trf('지우는 폴더 안에 목록에서 숨긴 항목 {0}개가 있습니다 - 함께 지워집니다', [hiddenInside]),
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)),
),
if (local && !canRecycle)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(tr('이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.'),
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)),
),
if (canTrash && !permanent)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(trf('앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).', [c.settings.androidTrashDays]),
style: const 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)')),
),
]),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: permanent ? Colors.red.shade700 : null),
onPressed: () => Navigator.pop(ctx, true),
child: Text(permanent ? tr('영구 삭제') : tr('휴지통으로')),
),
],
),
),
);
if (ok != true) return;
// 하나씩: 실패해도 나머지는 계속. 실제로 어떻게 됐는지 세어 그대로 알린다 (94)
final failed = <(String, String)>[];
// 144: 경로가 너무 길어 휴지통이 받지 않은 것 (이유는 한 번, [영구 삭제] 로 바로 지울 수 있게)
final tooLong = <String>[];
var recycled = 0, nuked = 0, kept = 0;
// 148: 휴지통으로 보낸 것 (되돌리기용) - 정보 파일 시각은 초 단위라 조금 앞에서부터
final since = DateTime.now().subtract(const Duration(seconds: 2));
final sent = <String>[];
// X-plore 비교 e: 앱 휴지통으로 보낸 것 · 옮기지 못한 것 (영구 삭제할지 묻는다)
final trashed = <TrashItem>[];
final notTrashed = <(String, String)>[];
// X-plore 숨기기: 휴지통에서 [되돌리기] 할 수 있는 것 (숨긴 목록에 남겨 되살리면 그대로 숨김)
final undoable = <String>[];
for (final x in paths) {
if (!permanent && canTrash) {
try {
trashed.add(await trash.moveToTrash(x));
undoable.add(x);
recycled++;
} on TrashMoveException catch (e) {
notTrashed.add((x, '${e.cause}'));
}
continue;
}
try {
if (!permanent) {
switch (recycle(x)) {
case RecycleResult.recycled:
recycled++;
sent.add(x);
case RecycleResult.deletedPermanently:
nuked++;
case RecycleResult.cancelled:
kept++;
}
} else {
await FileOps().delete([x]);
}
} on LongPathException {
tooLong.add(x);
} catch (e) {
failed.add((x, e is FileSystemException ? (e.osError?.message ?? e.message) : '$e'));
}
}
// X-plore 비교 e: 휴지통으로 옮기지 못한 것 (다른 저장 장치 · 권한 등) - 몰래 지우지 않고 영구 삭제할지 묻는다
if (notTrashed.isNotEmpty && mounted) {
final nuke = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
icon: const Icon(Icons.warning_amber_rounded, color: Colors.orangeAccent),
title: Text(trf('{0}개를 휴지통으로 옮기지 못했습니다', [notTrashed.length])),
content: 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('영구 삭제하면 되살릴 수 없습니다.')),
]),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('그대로 두기'))),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700),
onPressed: () => Navigator.pop(ctx, true),
child: Text(trf('영구 삭제 ({0}개, 되돌릴 수 없음)', [notTrashed.length])),
),
],
),
);
if (nuke == true) {
for (final (x, _) in notTrashed) {
try {
await FileOps().delete([x]);
} catch (e) {
failed.add((x, '$e'));
}
}
}
}
if ((failed.isNotEmpty || tooLong.isNotEmpty) && mounted) {
final nukeLong = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
icon: const Icon(Icons.error_outline, color: Colors.redAccent),
title: Text(trf('{0}개 중 {1}개를 지우지 못했습니다', [paths.length, failed.length + tooLong.length])),
content: 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: const 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)),
]),
actions: [
if (tooLong.isNotEmpty) ...[
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('그대로 두기'))),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: Colors.redAccent),
onPressed: () => Navigator.pop(ctx, true),
child: Text(trf('영구 삭제 ({0}개, 되돌릴 수 없음)', [tooLong.length])),
),
] else
FilledButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('확인'))),
],
),
);
// 144: 사용자가 [영구 삭제] 를 고른 긴 경로만 지운다
if (nukeLong == true) {
final notDeleted = <String>[];
for (final x in tooLong) {
try {
await FileOps().delete([x]);
} catch (e) {
notDeleted.add('${vBasename(x)}: ${e is FileSystemException ? (e.osError?.message ?? e.message) : e}');
}
}
_snack([
if (tooLong.length > notDeleted.length) trf('{0}개를 영구 삭제했습니다.', [tooLong.length - notDeleted.length]),
if (notDeleted.isNotEmpty) trf('지우지 못함: {0}', [notDeleted.take(3).join(' · ')]),
].join(' '));
}
} else if (!permanent && mounted) {
final text = [
if (recycled > 0) trf('{0}개 항목을 휴지통으로 보냈습니다.', [recycled]),
// 휴지통보다 커서 Windows 가 물었고 사용자가 영구 삭제를 골랐다
if (nuked > 0) trf('{0}개는 휴지통에 들어가지 않아 영구 삭제했습니다 (Windows 가 묻고 고른 대로).', [nuked]),
if (kept > 0) trf('{0}개는 지우지 않았습니다 (취소).', [kept]),
].join(' ');
// 148: 휴지통으로 보낸 것은 바로 [되돌리기]
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
duration: const Duration(seconds: 10),
content: Text(text),
action: trashed.isNotEmpty
? SnackBarAction(label: tr('되돌리기'), onPressed: () => _undoTrash(trashed))
: sent.isEmpty
? null
: SnackBarAction(label: tr('되돌리기'), onPressed: () => _undoRecycle(sent, since)),
));
}
// X-plore 숨기기: 영구로 지운 것 (그 안 포함) 은 숨긴 목록에서 뺀다. 휴지통으로 보낸 것은 되돌리기 뒤에도 숨김이 되게 남긴다
await _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 _panes) {
x.marked.removeAll(paths);
if (_selecting && x.marked.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 _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 _local) v])
: null;
/// X-plore 비교 e: 방금 앱 휴지통으로 보낸 것을 원래 자리로
Future<void> _undoTrash(List<TrashItem> items) async {
final trash = _appTrash;
if (trash == null) return;
final back = <String>[];
for (final i in items) {
try {
back.add(await trash.restore(i));
} catch (_) {}
}
await _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 _refreshAll(back.map(p.dirname));
}
/// 148: 방금 휴지통으로 보낸 것을 원래 자리로 (원래 자리에 같은 이름이 생겼으면 그것은 두고 알림)
Future<void> _undoRecycle(List<String> paths, DateTime since) async {
final failed = <String>[];
var back = 0;
for (final x in paths) {
try {
restoreRecycled(x, since: since);
back++;
} catch (e) {
failed.add('${vBasename(x)}: ${e is FileSystemException ? e.message : e}');
}
}
await _refreshAll(paths.map(p.dirname));
for (final x in _panes) {
x.changed();
}
_snack([
if (back > 0) trf('{0}개 항목을 되돌렸습니다.', [back]),
if (failed.isNotEmpty) trf('되돌리지 못함: {0}', [failed.take(3).join(' · ')]),
].join(' '));
}
/// 다른 창의 지금 폴더로 복사 · 이동 (진행 창 · 취소)
/// 복사 · 이동 버튼: 두 창이면 다른 창의 지금 폴더로, 그 밖의 배치는 담아 두기 (붙여넣기로)
Future<void> _transfer(_Pane pane, List<String> sources, {required bool move}) async {
// 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);
}
/// 복사 · 이동할 항목 (붙여넣기 전까지 담아 둠)
List<String> _clip = [];
bool _clipMove = false;
/// 154: 고른 것이 없을 때 (복사 · 이동이 같은 말)
String _pickFirst(bool move) => move
? tr('옮길 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).')
: tr('복사할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).');
/// 154: 항목 이름 (앞의 3개 + "외 n개")
/// 48: 받는 폴더에 같은 이름이 있는 원본 (같은 폴더로 복사는 늘 새 이름이라 빼고)
/// 48 · 172: 대상 폴더에 같은 이름이 있는 원본 - 두 파일의 크기 · 바뀐 날을 함께 (어느 쪽이 새것인지 보고 고르게)
static Future<List<NameClash>> _findClashes(List<String> sources, String dest) async {
final out = <NameClash>[];
for (final s in sources) {
if (samePath(vDirname(s), dest)) continue;
try {
final there = await vStat(vJoin(dest, vBasename(s)));
if (there == null) continue;
final here = await vStat(s);
out.add((src: s, here: here, there: there));
} catch (_) {}
}
return out;
}
static String _namesOf(List<String> paths) {
final names = [for (final x in paths.take(3)) vBasename(x)].join(', ');
return paths.length > 3 ? trf('{0} 외 {1}개', [names, paths.length - 3]) : names;
}
void _toClipboard(List<String> sources, {required bool move}) {
if (sources.isEmpty) {
_snack(_pickFirst(move));
return;
}
setState(() {
_clip = [...sources];
_clipMove = move;
});
_snack(trf('{0} 을 담았습니다. 넣을 폴더에서 [붙여넣기] 를 누르세요 ({1}).',
[_namesOf(sources), move ? tr('이동') : tr('복사')]));
}
Future<void> _paste(_Pane pane, String dest) async {
if (_clip.isEmpty) {
_snack(tr('붙여넣을 항목이 없습니다. 먼저 [복사] · [이동] 으로 담으세요.'));
return;
}
final items = _clip, move = _clipMove;
final done = await _runTransfer(pane, items, dest, move: move);
// 옮긴 것은 다시 붙여넣을 수 없으므로 비운다 (복사는 여러 곳에 붙여넣을 수 있게 남김)
if (done && move && mounted) setState(() => _clip = []);
}
/// 지금 도는 복사 · 이동 (아래에서 올라오는 진행 막대)
/// (Rsync 화면의 ⇄ 는 둘이 함께)
List<TransferJob> _jobs = const [];
void _onGoToRequest() {
final dir = ExplorerPage.goToRequest.value;
if (dir == null || !_ready || !mounted) return;
ExplorerPage.goToRequest.value = null;
unawaited(_goTo(_pane, dir, fresh: true).then((_) => _reveal(_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 _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 _refreshAll([job.dest, ...job.sources.map(vDirname)]);
for (final x in _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(_Pane pane, List<String> sources, String dest,
{required bool move, void Function(TransferJob job)? onFinished}) async {
if (sources.isEmpty) {
_snack(_pickFirst(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: 같은 폴더로 - 짧은 알림은 놓치기 쉬워 창으로. 이동은 할 것이 없고, 복사는 사본을 만들지 한 번 묻는다
final here = [for (final x in sources) if (samePath(vDirname(x), dest)) x];
var sameFolderCopy = false;
// 이동인데 일부만 이미 그 폴더에 있으면 그것만 건너뛰고 나머지를 옮긴다 (관리자: 섞어 고른 것을 다시 고르지 않게)
var skippedHere = const <String>[];
if (move && here.isNotEmpty && here.length < sources.length) {
skippedHere = here;
sources = [for (final x in sources) if (!here.contains(x)) x];
} else if (here.isNotEmpty && (move || here.length == sources.length)) {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(move ? tr('이미 이 폴더에 있습니다') : tr('같은 폴더입니다')),
content: Text(move
? trf('{0} 은(는) 이미 이 폴더에 있어 옮길 것이 없습니다.\n{1}', [_namesOf(here), vDisplay(dest)])
: trf('같은 폴더에 사본을 만들까요? (이름 (2))\n{0}', [_namesOf(here)])),
actions: [
if (move)
FilledButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('확인')))
else ...[
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('사본 만들기'))),
],
],
),
);
if (ok != true || !mounted) return false;
sameFolderCopy = true; // 이미 물었으니 아래 확인 창은 건너뛴다
}
// 원본 · 대상이 없거나 (다른 곳에서 지움 - 목록을 새로 고침) 폴더를 자기 안으로 넣으려 하면 시작하지 않는다
final problem = transferProblem(sources, dest, move: move);
if (problem != null) {
_snack(problem);
pane.marked.removeWhere((m) => vMissingSync(m));
await _refreshAll([dest, ...sources.map(p.dirname)]);
for (final x in _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;
final go = sameFolderCopy ? true : await showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setInner) => AlertDialog(
scrollable: true,
title: Text(move ? tr('이동') : tr('복사')),
content: 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: const 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: const 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: const TextStyle(color: Colors.orangeAccent)),
// 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: const TextStyle(fontSize: 12, color: Colors.orangeAccent),
),
),
),
],
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)),
]),
),
]);
},
),
]),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(move ? tr('이동') : tr('복사'))),
],
),
),
);
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.marked.clear();
await _refreshAfterTransfer(sources, dest);
// 넣은 폴더가 창의 지금 폴더면 펼쳐서 새 항목이 보이게
for (final x in _panes) {
if (samePath(x.current, dest)) {
x.expanded.add(dest);
await _load(x, dest, force: true);
}
x.changed();
}
final doneText = error is FileOpCancelled
? tr('취소했습니다.')
: error != null
? trf('끝나지 못했습니다: {0}', [error])
: [
trf('{0}개 항목을 {1}', [job.made.length, move ? tr('옮겼습니다.') : tr('복사했습니다.')]),
// 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)]),
if (skippedHere.isNotEmpty) trf('이미 이 폴더에 있어 건너뜀: {0}', [_namesOf(skippedHere)]),
].join(' · ');
// 85: 끝나면 [폴더 열기] - 앱 탐색기에서 넣은 폴더로 (그 폴더를 보고 있는 창, 없으면 지금 창)
if (error == null && mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(doneText),
action: SnackBarAction(
label: tr('폴더 열기'),
onPressed: () {
final show = _panes.where((x) => samePath(x.current, dest)).firstOrNull ?? _pane;
setState(() => _active = _panes.indexOf(show));
unawaited(_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 = _panes[0].marked.firstOrNull, r = _panes[1].marked.firstOrNull;
if (l == null || r == null) {
_snack(tr('왼쪽 · 오른쪽 창에서 폴더를 하나씩 고르세요.'));
return;
}
final runs = [if (right) (l, r), if (left) (r, l)];
// 34: 대상이 원본과 같거나 원본 안이면 막는다 (맞출 때마다 원본이 바뀜). 원본이 대상 안 (안쪽 → 바깥) 은 지우기가 없을 때만
if (runs.any((x) => isSameOrInside(x.$2, x.$1))) {
_snack(tr('대상이 원본과 같거나 원본 안에 있습니다 (맞출 때마다 원본이 바뀝니다). 다른 폴더를 고르세요.'));
return;
}
final nested = runs.any((x) => isSameOrInside(x.$1, x.$2));
if (nested && !c.settings.allowInnerToOuter) {
_snack(tr('원본이 대상 안에 있습니다 (환경 설정에서 막아 둠). 다른 폴더를 고르세요.'));
return;
}
// 23: 다른 쌍은 함께 돌릴 수 있다. 같은 폴더끼리 도는 중이면 (서로 덮어쓰지 않게) 막는다
if (_jobs.any((j) =>
!j.finished &&
runs.any((x) =>
(samePath(j.sources.first, x.$1) && samePath(j.dest, x.$2)) ||
(samePath(j.sources.first, x.$2) && samePath(j.dest, x.$1))))) {
_snack(tr('이 두 폴더는 지금 rsync 하는 중입니다. 끝난 뒤에 하세요.'));
return;
}
for (final (src, dst) in runs) {
final problem = transferProblem([src], dst, move: false);
if (problem != null) {
_snack(problem);
for (final x in _panes) {
x.marked.removeWhere((m) => vMissingSync(m));
}
await _refreshAll([vDirname(l), vDirname(r)]);
return;
}
}
final both = right && left;
final center = CopyCenter.of(c);
// 확인 창에는 기억한 옵션 (없으면 지금 설정) 을 보여 주고, 실행할 때 모니터링에 기억한다
// 96: 확인 창 · 미리 보기 · 실행이 같은 작업 (원본 파일 지우기 = 이동 작업) 과 같은 옵션을 쓴다
List<CopyTask> shownTasks(bool move) =>
[for (final (src, dst) in runs) center.peek([src], dst, contents: true, method: 'rsync', move: move)];
// 178: 환경 설정 (또는 기억한) 옵션에 원본 지우기가 있으면 [원본 파일 지우기] 를 켠 채로 연다 (버튼은 [이동] · 경고).
// 원본 지우기는 이 체크로만 정한다 - 옵션 글에서는 빼고 보이고, 끄면 이번 실행에서 정말 빠진다
final optionMove = runs.map((x) => center.peek([x.$1], x.$2, contents: true, method: 'rsync', move: false)).any((t) => optionsRemoveSource(t.options));
var tasks = shownTasks(optionMove && !both);
String opts(CopyTask t) {
final o = withoutSourceRemovingOptions(t.options);
return both ? withUpdateOption(o) : o;
}
// 95: ⇄ 는 → 다음에 ← (차례로). ← 에서는 지우지 않는다 (→ 가 방금 복사한 것을 지우지 않게)
String runOpts(int i) => both && i > 0 ? withoutDeleteOptions(opts(tasks[i])) : opts(tasks[i]);
if (nested && tasks.any((t) => optionsDelete(opts(t)))) {
_snack(tr('원본이 대상 안에 있으면 지우기 (--delete) 를 함께 쓸 수 없습니다 (대상의 다른 파일이 모두 지워집니다).'));
return;
}
// 한 방향 (→ · ←) 만: 원본 파일 지우기 (--remove-source-files) 와 끝난 뒤 원본 정리
var removeSource = optionMove && !both;
var prune = 'keep';
// 72: 실행 전 비교 결과 (지워질 수를 실행 버튼에)
List<PreviewItem>? preview;
Object? previewError;
// 93: 지우는 실행 (--delete 등. 100: WebDAV 도 따른다) 은 비교가 끝나 지울 목록이 보인 뒤에만
bool deletes() => tasks.any((t) => optionsDelete(opts(t)));
// 105: 원본이 대상 안인데 지우기가 들어가면 막는다 - 창 안에서 옵션이 바뀔 때 (원본 파일 지우기) 와 실행 직전에도 본다
bool nestedDelete() => nested && deletes();
if (!mounted) return;
final go = await showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(builder: (ctx, set) => AlertDialog(
scrollable: true,
// 110: 버튼이 좁아 세로로 쌓여도 [취소] 가 먼저 (아래 · 위가 바뀌어 [취소] 자리에 [실행] 이 오지 않게)
actionsOverflowDirection: VerticalDirection.down,
title: Text(both ? tr('rsync 양쪽 (⇄)') : 'rsync'),
content: SizedBox(
width: 560,
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
for (final t in tasks) ...[
// 109: 끝 폴더가 보이게 짧게, 전체 경로는 작게
Text(shortPath(t.sources.first), style: const TextStyle(fontWeight: FontWeight.w600)),
Text('→ ${shortPath(t.dest)}', style: const TextStyle(fontWeight: FontWeight.w600)),
Text('${vDisplay(t.sources.first)}/ → ${vDisplay(t.dest)}/',
style: const TextStyle(fontSize: 11, color: JjColors.textDim)),
// 111: 이 쌍은 처음 실행 때 기억한 옵션을 쓴다 (환경 설정의 rsync 옵션을 바꿔도 그대로)
if (center.tasks.any((x) => x.id == t.id))
Text(tr('이 폴더 쌍은 기억한 옵션을 씁니다 (환경 설정이 아니라 모니터링 > 복사 · rsync 에서 고칩니다)'),
style: const TextStyle(fontSize: 11, color: JjColors.textDim)),
Text('rsync ${runOpts(tasks.indexOf(t))}${removeSource ? ' --remove-source-files' : ''}'
'${t.bandwidthKBps > 0 ? ' · ${trf('속도 제한 {0} KB/s', [t.bandwidthKBps])}' : ''}',
style: const TextStyle(fontSize: 12, fontFamily: 'monospace', color: JjColors.textDim)),
const SizedBox(height: 10),
],
Text(tr('폴더 안의 것을 맞춥니다 (대상에 같은 이름의 폴더를 새로 만들지 않음).'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
// 222: 이동이면 동작대로 (예전에는 이동일 때도 "복사 · 원본 폴더는 그대로" 라고 썼다)
if (nested)
Text(
removeSource && !both
? tr('원본이 대상 폴더 안에 있습니다: 원본의 내용이 대상 폴더 바로 아래로 옮겨집니다 (원본 파일은 지워지고, 원본 폴더는 아래에서 고른 대로).')
: tr('원본이 대상 폴더 안에 있습니다: 원본의 내용이 대상 폴더 바로 아래에 복사됩니다 (원본 폴더는 그대로 남음).'),
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)),
// 1280×720 등 낮은 화면에서도 보이게: 원본 지우기 체크 · 경고는 비교 목록보다 위에 (178)
if (both) ...[
Text(tr('양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
if (optionMove)
Text(tr('양쪽 (⇄) 에서는 원본 파일을 지우지 않습니다 (옵션의 --remove-source-files 는 쓰지 않음).'),
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)),
] else ...[
CheckboxListTile(
contentPadding: EdgeInsets.zero,
value: removeSource,
onChanged: (v) => set(() {
removeSource = v ?? false;
tasks = shownTasks(removeSource);
preview = null;
previewError = null;
}),
title: Text(tr('원본 파일 지우기 (--remove-source-files)')),
subtitle: Text(tr('대상으로 옮긴 파일을 원본에서 지웁니다 (이동)')),
),
if (removeSource) ...[
// 178: 원본이 지워진다는 것을 분명히 (환경 설정 옵션 때문에 켜졌으면 그렇다고)
Text(
optionMove
? tr('원본 파일이 지워집니다 (이동). 환경 설정의 rsync 옵션에 --remove-source-files 가 있어 켜 두었습니다 - 끄면 이번에는 원본을 남깁니다.')
: tr('원본 파일이 지워집니다 (이동).'),
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent, fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
Text(tr('끝난 뒤 원본의 빈 폴더를 지웁니다 (find 원본/ -type d -empty -delete). 원본 폴더는:'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
const SizedBox(height: 6),
PruneChoice(value: prune, onChanged: (v) => set(() => prune = v)),
],
],
// 72: 무엇이 바뀌는지 파일별로 (→ · ← · 받는 쪽에만 있음 · 지워짐)
const SizedBox(height: 8),
SyncPreviewView(
// 옵션이 바뀌면 (원본 파일 지우기) 다시 비교
key: ValueKey([for (var i = 0; i < tasks.length; i++) runOpts(i)].join('|')),
compute: () async => both
? previewBoth(l, r, delete: deletes())
: [
for (final t in tasks)
...await previewSync(t.sources.first, t.dest,
toRight: t.sources.first == l,
delete: deletes(),
// 229: WebDAV 가 끼면 앱이 직접 - 환경 설정 "받는 쪽이 더 새것이면 건너뛰기" 도 따른다
update: optionsUpdate(opts(t)) || (_viaDav([l, r]) && c.settings.copySkipNewer),
skipLinks: _viaDav([l, r])),
],
// 97: 다시 비교하는 동안은 예전 결과로 실행하지 못하게
onStart: () => set(() {
preview = null;
previewError = null;
}),
onResult: (items) => set(() {
preview = items;
previewError = null;
}),
onError: (e) => set(() {
preview = null;
previewError = e;
}),
),
if (nestedDelete())
Text(tr('원본이 대상 안에 있으면 지우기 (--delete) 를 함께 쓸 수 없습니다 (대상의 다른 파일이 모두 지워집니다).'),
style: const TextStyle(fontSize: 12, color: Colors.redAccent, fontWeight: FontWeight.w600)),
// 110: 이 줄은 늘 같은 자리를 차지한다 (글이 사라져 창 높이가 바뀌면 버튼이 움직인다)
SizedBox(
height: 34,
child: deletes() && preview == null
? Text(
previewError != null
? tr('지우기가 들어간 실행이라, 비교하지 못하면 실행할 수 없습니다. 원본 · 대상을 확인한 뒤 [다시 비교] 를 누르세요.')
: tr('지우기가 들어간 실행이라, 지울 목록이 나온 뒤에 실행할 수 있습니다.'),
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent),
)
: null,
),
const SizedBox(height: 4),
if (_viaDav([l, r]))
Text(tr('WebDAV · SFTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).'),
style: const TextStyle(fontSize: 12, color: JjColors.accent)),
]),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
Builder(builder: (_) {
final dels = preview?.where((x) => x.action == PreviewAction.delete).length ?? 0;
return FilledButton(
style: dels > 0 ? FilledButton.styleFrom(backgroundColor: JjColors.danger) : null,
onPressed: nestedDelete() || (deletes() && preview == null) ? null : () => Navigator.pop(ctx, true),
// 110: 글자는 늘 같게 (비교가 끝나 글이 길어지면 버튼 배치가 바뀌어 [취소] 를 누르려다 [실행] 을 누르게 됨).
// 지울 수는 위의 빨간 글 "받는 쪽에서 N개가 지워집니다" 로 보인다
child: Text(removeSource ? tr('이동') : tr('실행')),
);
}),
],
)),
);
// 110: 무엇을 골랐는지 작업 기록에 (실행 · 취소 · 닫힘)
c.note(trf('Rsync 확인 창 ({0}): {1}', [
both ? '⇄' : right ? '→' : '←',
go == true ? tr('실행') : go == false ? tr('취소') : tr('닫힘 (실행 안 함)'),
]));
if (go != true || !mounted) return;
if (nestedDelete()) {
_snack(tr('원본이 대상 안에 있으면 지우기 (--delete) 를 함께 쓸 수 없습니다 (대상의 다른 파일이 모두 지워집니다).'));
return;
}
final dav = _viaDav([l, r]);
final exe = dav ? '' : await _ensureRsync();
if (exe == null || !mounted) return;
// 96: 확인 창에 보인 그 옵션으로 (기억한 작업의 옵션이 달라도)
final shownOpts = [for (var i = 0; i < tasks.length; i++) runOpts(i)];
var run = [
for (final (src, dst) in runs) await center.remember([src], dst, contents: true, method: 'rsync', move: removeSource),
];
if (removeSource) {
run = [for (final t in run) t.copyWith(prune: prune)];
for (final t in run) {
await center.update(t);
}
}
// 95: 차례로 (→ 가 끝난 뒤 ←). 앞의 것이 실패 · 취소되면 뒤의 것은 하지 않는다
final jobs = <TransferJob>[];
for (var i = 0; i < run.length; i++) {
c.note(trf('rsync 시작 ({0}): {1} → {2}', [tr('Rsync 화면 확인 창의 [실행]'), run[i].sources.first, run[i].dest]));
// 218: Rsync 화면은 rsync 로 (기억한 작업의 방법을 모니터링에서 바꿨어도 - 확인 창에 보인 그대로. WebDAV 가 끼면 앱이 직접)
final j = await center.start(run[i].copyWith(options: shownOpts[i], method: 'rsync'), rsyncExe: dav ? null : exe);
if (!mounted) break;
if (j == null) {
// 205: 시작하지 못하면 이유를 알린다 (예전에는 알림 없이 끝나 "이동이 안 됨" 으로만 보였다)
final why = transferProblem(run[i].sources, run[i].dest, move: run[i].move, contents: run[i].contents);
_snack(trf('시작하지 못했습니다: {0}', [why ?? tr('rsync 를 실행할 수 없습니다')]));
c.note(trf('rsync 시작하지 못함: {0}', [why ?? '']));
break;
}
jobs.add(j);
setState(() => _jobs = [..._jobs.where((x) => !x.finished), j]);
await j.done;
if (j.error != null) break;
}
if (jobs.isEmpty || !mounted) return;
// 원본 폴더까지 지웠으면 고른 표시도 뺀다
for (final x in _panes) {
x.marked.removeWhere((m) => vMissingSync(m));
}
await _refreshAll([l, r, vDirname(l), vDirname(r)]);
for (final x in _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('복사하는 중');
Widget line(String label, double value, String right) => Padding(
padding: const EdgeInsets.only(top: 6),
child: Row(children: [
SizedBox(
width: 300,
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: const 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: const 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: 결과 화면에서 복사 · 이동 · 지우기 · [그 자리로] 를 한 뒤 [찾기 결과로 돌아가기] 로 다시 열 결과 (다음 찾기 · ✕ · 결과 화면을 그냥 닫으면 없어짐)
({_Pane pane, String root, SearchConditions cond, List<FileEntry> found, bool done, Map<String, String> moved})? _searchBack;
/// 79: 찾기 - 이름 · 크기 · 바뀐 날 · 종류 조건 → 결과 목록 화면 (고르고 그대로 복사 · 이동 · 지우기, 누르면 열기 · 재생)
Future<void> _search(_Pane pane) async {
setState(() => _active = _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(_Pane 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 _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: [
const 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: const 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),
),
]),
),
);
}
Future<void> _sortDialog() async {
// 84: "폴더마다 기억" 이면 지금 창의 폴더 정렬
final dir = _pane.current;
var (by, desc) = _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}', [_displayPath(dir)]), style: const 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 _setSort(dir, by, desc);
}
/// 73: 즐겨찾기 - 이 창으로 가기, Rsync 화면이면 짝 (왼쪽 · 오른쪽) 을 한 번에
Future<void> _bookmarks(_Pane pane) async {
setState(() => _active = _panes.indexOf(pane));
await showFolderBookmarks(
context,
c,
current: pane.current,
right: widget.rsync ? _panes[1].current : null,
go: (dir) => _goTo(pane, dir, fresh: true),
goPair: widget.rsync
? (a, b) {
_goTo(_panes[0], a, fresh: true);
_goTo(_panes[1], b, fresh: true);
}
: null,
);
if (mounted) setState(() {});
}
Future<void> _historyDialog(_Pane 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 _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 _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: const 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: const 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(() => _active = 0);
// 배치가 바뀌면 목록을 새로 그리므로 맨 위로 간다: 보던 폴더가 다시 보이게
for (final pane in _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: const 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 || _panes.any((x) => x.marked.isNotEmpty) || (_ready && !samePath(_pane.current, _pane.root));
/// 49: 뒤로 키 - 먼저 고른 것 풀기, 그다음 상위 폴더, 맨 위면 화면 닫기 (위쪽 ← 는 늘 닫기)
Future<void> _onBack() async {
if (_selecting || _panes.any((x) => x.marked.isNotEmpty)) {
_setSelecting(false);
return;
}
if (!samePath(_pane.current, _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: 스타일에 맞는 단축키 (Total Commander: F5 복사 · F6 이동 · F7 새 폴더 · F8 지우기 · Insert/Space 표시 ·
// Ctrl+R 새로 고침 / Windows 탐색기 · X-plore: Ctrl+C · X · V · A · F5 새로 고침)
..._styleKeys(),
const SingleActivator(LogicalKeyboardKey.delete): () => _button(ExplorerButton.delete),
// 65: Shift+Delete = 휴지통을 거치지 않고 (창에서 한 번 더 확인)
const SingleActivator(LogicalKeyboardKey.delete, shift: true): () {
_shiftDelete = true;
_button(ExplorerButton.delete);
},
const SingleActivator(LogicalKeyboardKey.f2): () => _button(ExplorerButton.rename),
// 180: 선택 모드 · 표시 풀기 (복사 · 이동이 끝나도 선택 모드는 그대로이므로 바로 나올 수 있게)
const SingleActivator(LogicalKeyboardKey.escape): () {
// 76: 열린 빠른 필터부터 닫는다
if (_panes[_active].filterOpen) {
_closeFilter(_panes[_active]);
} else if (_selecting || _panes.any((x) => x.marked.isNotEmpty)) {
_setSelecting(false);
}
},
const SingleActivator(LogicalKeyboardKey.keyF, control: true): () => _openFilter(_panes[_active]),
const SingleActivator(LogicalKeyboardKey.backspace): () => _button(ExplorerButton.up),
// 75: 지금 창의 뒤로 · 앞으로
const SingleActivator(LogicalKeyboardKey.arrowLeft, alt: true): () => _travel(_panes[_active], forward: false),
const SingleActivator(LogicalKeyboardKey.arrowRight, alt: true): () => _travel(_panes[_active], forward: true),
const SingleActivator(LogicalKeyboardKey.tab): () =>
setState(() => _active = _dual ? 1 - _active : 0),
};
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(_active, ev),
child: Scaffold(
body: SwipeNav(
current: widget.rsync ? 'rsync' : 'explorer',
child: ListenableBuilder(
listenable: c,
builder: (context, _) {
_dropRemovedServers();
// 80 보충: 화면에서 사라진 폴더를 위해 걸어 둔 자동 크기 계산은 멈추고 뺀다
_sizes.retainAuto([for (final x in _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: !_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(_Pane 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),
middle: Row(children: [
Text(widget.rsync ? 'Rsync' : tr('파일 탐색기'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(width: 12),
// X-plore 경로 막대 (4.4x): 경로를 누르면 위 폴더들 (창의 맨 위까지) 이 나와 골라 간다
Expanded(
child: !_ready
? const SizedBox.shrink()
: PopupMenuButton<String>(
tooltip: tr('위 폴더로 가기'),
position: PopupMenuPosition.under,
onSelected: (d) => _goTo(_pane, d),
itemBuilder: (_) => [
for (final d in _ancestors(_pane))
PopupMenuItem(
value: d,
enabled: !samePath(d, _pane.current),
child: Text(_displayPath(d), maxLines: 1, overflow: TextOverflow.ellipsis),
),
],
child: Text(_displayPath(_pane.current),
maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: JjColors.textDim)),
),
),
PopupMenuButton<Object>(
tooltip: tr('창 배치 · 버튼 구성'),
icon: const Icon(Icons.more_vert),
onSelected: (v) => v is ExplorerButton ? _button(v) : _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(_showAppHidden ? Icons.visibility_off_outlined : Icons.visibility_outlined, size: 18),
const SizedBox(width: 12),
Flexible(
child: Text(_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 (_ready && shape != _shape) {
if (_shape.isNotEmpty) {
for (final pane in _panes) {
_reveal(pane, pane.current);
}
}
_shape = shape;
}
final dual = _dual;
final split = _split;
return LayoutBuilder(builder: (context, box) {
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;
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 = _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) ...[
// 왼쪽 폴더 트리 (좁게) · 오른쪽 그 폴더의 파일 목록
Expanded(flex: 2, child: keep(0, _paneView(0, foldersOnly: true))),
if (middle) bar,
Expanded(flex: 3, child: keep(2, _listView(_panes[0]))),
if (edge) bar,
] else ...[
if (middle) bar,
Expanded(
child: _hiddenTarget
? Column(children: [ListenableBuilder(listenable: _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: _active == k,
onSelected: (_) => setState(() => _active = k),
label: SizedBox(
width: double.infinity,
child: Text(
'$side · ${vBasename(_panes[k].marked.isNotEmpty ? _panes[k].marked.first : _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 >= _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;
_travel(_panes[i], forward: !back);
}
Widget _frame(int i, List<Widget> children) {
final active = i == _active;
return Listener(
// 75: 마우스가 올라간 창에 (지금 활성 창이 아니어도)
onPointerDown: (ev) => _mouseTravel(i, ev),
child: GestureDetector(
onTapDown: (_) {
if (_active != i) setState(() => _active = i);
},
// 82: 창의 빈 곳에 놓으면 그 창의 지금 폴더로. 놓을 수 있으면 강조, 못 놓으면 (같은 창 등) 빨간 테두리
child: DragTarget<DragLoad>(
onWillAcceptWithDetails: (d) => canDropInto(d.data, i, _panes[i].current),
onAcceptWithDetails: (d) => unawaited(_onDrop(d.data, i, _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(_Pane pane, FileEntry e) =>
_isMarked(pane, e.path) && pane.marked.isNotEmpty ? outermostPaths(pane.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) || _archives.containsKey(dest)) {
_snack(tr('압축 안은 읽기 전용입니다 (꺼내기만 할 수 있습니다)'));
return;
}
if (load.paths.any(_insideArchive)) {
await _extractFromArchive(load.paths, dest);
return;
}
setState(() => _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(_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);
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(
duration: const Duration(seconds: 10),
content: Text([
trf('{0}개를 옮겼습니다.', [moved]),
if (notUndoable > 0) trf('합친 폴더 {0}개는 무엇을 옮겼는지 몰라 되돌리지 않습니다.', [notUndoable]),
if (overwritten > 0) trf('같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.', [overwritten]),
].join(' ')),
action: SnackBarAction(label: tr('되돌리기'), onPressed: () => unawaited(_undoMove(_panes[load.from], back))),
));
}
/// 82: 되돌리기 - 원래 폴더마다 지금의 이동으로 (같은 이름이 생겼으면 지금의 같은 이름 창)
Future<void> _undoMove(_Pane 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(_Pane 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.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!);
return Container(
color: JjColors.panel,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
child: Row(children: [
Icon(Icons.checklist, size: 18, color: JjColors.accent),
const SizedBox(width: 6),
Flexible(
child: Text(
[
trf('{0}개 표시함', [pane.marked.length]),
if (size > 0) formatSize(size),
if (folders > 0) trf('폴더 {0}개 (크기 빼고)', [folders]),
].join(' · '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12)),
),
const Spacer(),
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: const TextStyle(fontSize: 13, color: JjColors.accent)),
const Icon(Icons.arrow_drop_down, size: 18, color: JjColors.accent),
]),
),
),
TextButton(
onPressed: () => setState(() {
final dir = listDir ?? pane.current;
pane.marked.addAll([for (final x in _shown(pane, dir)) x.path]);
_normalizeMarks(pane);
}),
child: Text(tr('모두 선택')),
),
TextButton(onPressed: () => setState(pane.marked.clear), child: Text(tr('표시 지우기'))),
TextButton(onPressed: () => _setSelecting(false), child: Text(tr('선택 끝'))),
]),
);
}
/// 트리 창 (저장 장치 고르기 + 트리)
Widget _paneView(int i, {bool foldersOnly = false}) {
final pane = _panes[i];
final rows = _rows(pane, foldersOnly: foldersOnly);
final look = _look;
// 38: 창 높이를 알아야 안내 상자의 높이를 제한할 수 있다
return LayoutBuilder(builder: (context, box) => _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([_accessNotice(pane), _errorNotice(pane, pane.current), _partialNotice(pane, pane.current)], box.maxHeight),
if (look.columns && !foldersOnly)
ExplorerColumnsHeader(
style: look,
markColumn: _selecting,
sortBy: _sortOf(pane.current).$1,
sortDesc: _sortOf(pane.current).$2,
onSort: (by) => _sortByColumn(pane, by),
),
Expanded(
child: Stack(children: [
ListView.builder(
controller: pane.scroll,
itemCount: rows.length,
itemExtent: look.rowHeightOf(context),
// 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.marked.isNotEmpty)) && !foldersOnly) _marksBar(pane),
]));
}
/// 76: 목록 줄의 키 = 항목 경로. 키가 없으면 줄 목록이 바뀔 때 (빠른 필터 · 펼치기 · 새로 고침) 같은 자리의 줄 위젯을
/// 다른 항목에 다시 써서, 방금 누른 파일 줄이 폴더 줄로 바뀌면 두 번 누르기 기다림이 그리는 중에 버려지고
/// 그 누르기가 다른 항목에 들어갔다 (디버그에서는 "setState() called during build")
static Key _rowKey(_Row r) =>
ValueKey<String>('${r.kind != 0 ? 'k${r.kind}:' : r.isRoot ? 'root:' : r.isUp ? 'up:' : ''}${r.entry.path}');
/// 76: 줄이 옮겨 가도 같은 줄 위젯을 찾게 (키 → 지금 자리)
static int? Function(Key) _rowIndexOf(List<_Row> rows) {
Map<Key, int>? index;
return (key) => (index ??= {for (var i = 0; i < rows.length; i++) _rowKey(rows[i]): i})[key];
}
/// "폴더 + 파일 목록" 배치의 오른쪽: 왼쪽 트리에서 고른 폴더의 내용 (맨 위 ".." = 상위 폴더)
Widget _listView(_Pane 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) => _appHidden(e.path)).length;
final rows = [
if (up) _Row(FileEntry(vDirname(dir), isDir: true, modified: DateTime(0)), 0, isUp: true),
for (final e in shown) _Row(e, 0),
// X-plore 숨기기: 맨 아래 [숨긴 항목 n개 보이기]
if (hidden > 0) _Row(FileEntry('\u0000hidden', isDir: false, modified: DateTime(0)), 0, kind: _Row.hiddenToggle, count: hidden),
];
final look = _look;
// 38: 창 높이를 알아야 안내 상자의 높이를 제한할 수 있다
return LayoutBuilder(builder: (context, box) => _frame(0, [
Container(
height: 40,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text(_displayPath(dir), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: JjColors.textDim)),
),
const Divider(height: 1),
if (pane.filterOpen) _filterBar(pane, shown.length),
?_noticeArea([_accessNotice(pane), _errorNotice(pane, dir), _partialNotice(pane, dir)], box.maxHeight),
if (look.columns)
ExplorerColumnsHeader(
style: look,
markColumn: _selecting,
sortBy: _sortOf(dir).$1,
sortDesc: _sortOf(dir).$2,
onSort: (by) => _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: const TextStyle(color: JjColors.textDim)))
: ListView.builder(
controller: pane.listScroll,
itemCount: rows.length,
itemExtent: look.rowHeightOf(context),
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(_Pane pane, [String? text]) {
setState(() {
_active = _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(_Pane pane) {
setState(() {
pane
..filterOpen = false
..filterCtl.clear();
});
_reveal(pane, pane.focused ?? pane.current);
_keys.requestFocus();
}
/// 83: 지금 스타일이 Total Commander 인지 (Rsync 화면은 스타일 단축키를 쓰지 않는다 - 늘 같은 버튼)
bool get _tcKeys => !widget.rsync && _look == ExplorerStyle.totalcmd;
/// 83: 스타일에 맞는 단축키
Map<ShortcutActivator, VoidCallback> _styleKeys() {
if (widget.rsync) return {const SingleActivator(LogicalKeyboardKey.f5): () => _button(ExplorerButton.refresh)};
if (_tcKeys) {
return {
const SingleActivator(LogicalKeyboardKey.f5): () => _button(ExplorerButton.copy),
const SingleActivator(LogicalKeyboardKey.f6): () => _button(ExplorerButton.move),
const SingleActivator(LogicalKeyboardKey.f7): () => _button(ExplorerButton.newFolder),
const SingleActivator(LogicalKeyboardKey.f8): () => _button(ExplorerButton.delete),
const SingleActivator(LogicalKeyboardKey.keyR, control: true): () => _button(ExplorerButton.refresh),
// Insert: 표시하고 다음 줄로 · Space: 표시 (TC 처럼)
const SingleActivator(LogicalKeyboardKey.insert): () => _markFocused(next: true),
const SingleActivator(LogicalKeyboardKey.space): () => _markFocused(next: false),
// 81: 빠른 보기 (TC 의 보기 키)
const SingleActivator(LogicalKeyboardKey.f3): () => _quickLook(_pane),
};
}
return {
// 81: 빠른 보기 (Windows · macOS 처럼 Space)
const SingleActivator(LogicalKeyboardKey.space): () => _quickLook(_pane),
const SingleActivator(LogicalKeyboardKey.f5): () => _button(ExplorerButton.refresh),
const SingleActivator(LogicalKeyboardKey.keyC, control: true): () => _toClipboard(_targets(_pane), move: false),
const SingleActivator(LogicalKeyboardKey.keyX, control: true): () => _toClipboard(_targets(_pane), move: true),
const SingleActivator(LogicalKeyboardKey.keyV, control: true): () => _paste(_pane, _pane.current),
const SingleActivator(LogicalKeyboardKey.keyA, control: true): () {
if (!_selecting) setState(() => _selecting = true);
_markWhere(_pane, _pane.current, (_) => true);
},
};
}
/// 81: 빠른 보기 - 지금 줄 (없으면 아무것도 안 함). 같은 폴더에 보이는 항목을 ← → 로
Future<void> _quickLook(_Pane 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(_goTo(pane, e.path)) : unawaited(_open(e.path, pane: pane)),
),
);
}
/// 83: (TC) 지금 줄을 표시 · 풀기, [next] 면 다음 줄로
Future<void> _markFocused({required bool next}) async {
final pane = _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 && !_panes[_active].filterOpen) return KeyEventResult.ignored;
if (kb.isControlPressed || kb.isAltPressed || kb.isMetaPressed) return KeyEventResult.ignored;
// 글자 칸 (필터 · 경로 입력 등) 에 치는 중이면 그 칸이 받는다
if (_typing) return KeyEventResult.ignored;
if (!_ready || _panes.isEmpty) return KeyEventResult.ignored;
final pane = _panes[_active];
_openFilter(pane, pane.filterOpen ? pane.filterCtl.text + ch : ch);
return KeyEventResult.handled;
}
/// 76: 창 위 줄 아래의 빠른 필터 칸 - 보이는 수 · 맞는 것이 없을 때 안내 · [✕]
Widget _filterBar(_Pane 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(_Pane 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(_Pane 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),
_ => _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 서버 추가')))),
],
);
/// 창 위 줄 앞: 뒤로 · 앞으로 · 즐겨찾기
List<Widget> _headerStart(_Pane pane) => [
// 75: 이 창의 뒤로 · 앞으로 (가 본 폴더)
IconButton(
tooltip: keyHint(tr('뒤로'), 'Alt+←'),
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.arrow_back, size: 16),
onPressed: pane.back.isEmpty ? null : () => _travel(pane, forward: false),
),
IconButton(
tooltip: keyHint(tr('앞으로'), 'Alt+→'),
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.arrow_forward, size: 16),
onPressed: pane.forward.isEmpty ? null : () => _travel(pane, forward: true),
),
// 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: Colors.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(_Pane pane, {List<Widget> lead = const []}) {
final all = [
..._volumes,
// 37: 지금 열어 둔 네트워크 공유 (\\NAS\공유) 도 탭으로
for (final x in _panes)
if (Platform.isWindows && x.root.startsWith(r'\\') && !_volumes.any((v) => samePath(v.$1, x.root))) (x.root, x.root),
];
final tabs = [
...all.where((v) => _driveOk[v.$1] != false || samePath(pane.root, v.$1)),
...all.where((v) => _driveOk[v.$1] == false && !samePath(pane.root, v.$1)),
];
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(
isSftp(path)
? Icons.vpn_key_outlined
: isFtp(path)
? Icons.folder_shared_outlined
: isSmb(path)
? Icons.dns_outlined
: isDav(path)
? Icons.cloud_outlined
: _networkDrives.contains(path)
? Icons.lan_outlined
: Platform.isWindows
? Icons.storage
: Icons.sd_storage_outlined,
size: 16),
// 165: 열 수 없는 드라이브는 숨기지 않고 "연결 안 됨" - 누르면 다시 연결 (다시 읽기)
label: _driveOk[path] == false
? Text('$label · ${_removableDrives.contains(path) ? tr('비어 있음') : tr('연결 안 됨')} ⟳',
style: TextStyle(color: _removableDrives.contains(path) ? JjColors.textDim : Colors.orangeAccent))
: Text(label),
onPressed: () {
setState(() => _active = _panes.indexOf(pane));
if (_driveOk[path] == false) {
unawaited(_reconnect(pane, path));
} else {
_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(_Pane 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),
),
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(_Pane pane) async {
setState(() => _active = _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 _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(_Pane pane, String? root) async {
setState(() => _active = _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 _panes) {
x.cache.removeWhere((k, _) => isDav(k) && DavPath.parse(k).server == saved.id);
}
await _goTo(pane, '$davScheme${saved.id}/');
}
/// 88: SFTP 서버 추가 (없으면 [root] 는 null) · 고치기 (탭 길게 누르기). 저장하면 그 서버로 간다.
Future<void> _editSftp(_Pane pane, String? root) async {
setState(() => _active = _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 _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 _goTo(pane, '$sftpScheme${saved.id}/');
}
/// l: FTP 서버 추가 (없으면 [root] 는 null) · 고치기 (탭 길게 누르기). 저장하면 그 서버로 간다.
Future<void> _editFtp(_Pane pane, String? root) async {
setState(() => _active = _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 _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 _goTo(pane, '$ftpScheme${saved.id}/');
}
/// 88: SMB 서버 추가 (없으면 [root] 는 null, [from] 은 경로 입력에 넣은 주소) · 고치기 (탭 길게 누르기). 저장하면 그 서버로 간다.
Future<void> _editSmb(_Pane pane, String? root, {String? from}) async {
setState(() => _active = _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 _panes) {
x.cache.removeWhere((k, _) => isSmb(k) && DavPath.parse(k).server == saved.id);
}
await _goTo(pane, '$smbScheme${saved.id}/');
}
/// 한 줄. [list]: "폴더 + 파일 목록" 의 오른쪽 목록 (트리 아님). [compact]: 폴더 트리 (열 · 표시 버튼 없이)
Widget _rowView(_Pane pane, _Row row, {bool list = false, bool compact = false}) {
final e = row.entry;
final look = _look;
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 || _archives.containsKey(e.path)) && !list && pane.expanded.contains(e.path);
final loading = pane.loading.contains(e.path);
final name = row.isUp
? '..'
: row.isRoot
? (_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,
);
final chevronWidth = treeChevronWidth(defaultTargetPlatform);
// 트리의 펼치기 화살표 (따로 누름). 189: 접기 · 펼치기는 이것으로만 하므로 줄 높이 전체를 누를 수 있게,
// 손가락으로 누르는 기기 (Android) 는 폭도 넓게
Widget chevron() => SizedBox(
width: chevronWidth,
height: look.rowHeightOf(context),
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) {
final fixed = (list ? 0 : chevronWidth) + (look.rich ? 64 : look.iconSize + 12) + 48.0;
final indent = list ? 0.0 : (row.depth * look.indent).clamp(0.0, (box.maxWidth - fixed).clamp(0.0, double.infinity));
return Row(children: [
SizedBox(width: indent),
if (!list) chevron(),
SizedBox(
width: look.rich ? 56 : look.iconSize + 6,
height: look.rowHeightOf(context) - 8,
child: Center(child: _icon(e, row.isRoot, open, up: row.isUp)),
),
SizedBox(width: look.rich ? 8 : 6),
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: 1, overflow: TextOverflow.ellipsis, style: nameStyle),
if (look.rich && e.isDir && pane.errors[e.path] != null)
Text(tr('읽을 수 없음'), style: dim.copyWith(color: Colors.redAccent))
// 80: 열 없는 모양 (X-plore · 좁은 화면) 은 폴더 이름 아래에 크기
else if (e.isDir && !row.isRoot && !row.isUp && (!look.columns || compact) && _dirSize(pane, e) != null)
Text(_dirSize(pane, e)!, style: dim, maxLines: 1),
],
),
),
]);
});
final real = !row.isRoot && !row.isUp;
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),
);
final cells = !look.columns || compact || !real
? <Widget>[Expanded(child: nameCell())]
: look == ExplorerStyle.windows
? [
Expanded(flex: 6, child: nameCell()),
cell(_date(e.modified), 3),
cell(typeLabel(e), 2),
cell(e.isDir ? (_dirSize(pane, e) ?? '') : formatSize(e.size), 2, align: TextAlign.end),
]
: [
Expanded(flex: 6, child: nameCell()),
cell(e.isDir ? '' : e.ext, 1),
cell(e.isDir ? (_dirSize(pane, e) ?? '<DIR>') : formatSize(e.size), 2, align: TextAlign.end),
const SizedBox(width: 8),
cell(_date(e.modified), 3),
];
final paneIndex = _panes.indexOf(pane);
final drag = real && !widget.rsync;
Widget line = GestureDetector(
// Rsync 화면은 고르기만 (파일 기능 메뉴 없음)
// 25: Rsync 화면에서도 (폴더 메뉴)
onSecondaryTapUp: real ? (d) => _menu(pane, e, d.globalPosition) : null,
onLongPressStart: real ? (d) => _menu(pane, e, d.globalPosition) : null,
child: Material(
color: marked
? JjColors.accent.withValues(alpha: 0.18)
: isFocused
? JjColors.panel
: Colors.transparent,
child: Row(children: [
Expanded(
child: InkWell(
onTap: () => _onTap(pane, row, list: list),
// 선택 모드: 두 번 누르면 열기 (바로 열기 모드는 두 번 누르기를 받지 않음 - 한 번 누르기가 늦어지지 않게)
// 폴더는 한 번 누르기로 바로 펼치므로 두 번 누르기를 받지 않는다 (기다리지 않게)
onDoubleTap: _selectMode && !_selecting && !e.isDir && !row.isUp
? () => _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: const SizedBox(width: 28, height: 36, child: Icon(Icons.drag_indicator, size: 18, color: JjColors.textDim)),
),
),
// 표시 동그라미는 두 번 누르기 영역 밖 (기다리지 않고 바로)
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,
// ✔ 모양: 고르지 않은 것은 흐리게, 고른 것은 진하게
icon: Icon(marked ? Icons.check_circle : Icons.check_circle_outline,
size: look.rich ? 22 : 18,
color: marked ? JjColors.accent : JjColors.textDim.withValues(alpha: 0.35)),
onPressed: () {
setState(() => _active = _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 && _showAppHidden && _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 = _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: 동영상 썸네일 · 그림 미리 보기 (WebDAV 는 받아야 하므로 아이콘만)
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);
}
}
/// 172: 같은 이름 - 보낼 것 (복사 · 이동, [here]) 과 대상 폴더에 이미 있는 것 ([there])
typedef NameClash = ({String src, ({bool isDir, int size, DateTime modified})? here, ({bool isDir, int size, DateTime modified}) there});
/// 172: "이름: 보낼 것 1.2 MB · 2026-10-09 14:00 / 있는 것 900 KB · 2026-10-01 09:00 (보낼 것이 더 새것)"
String clashCompareText(NameClash x) {
String one(({bool isDir, int size, DateTime modified}) s) =>
[if (!s.isDir) formatSize(s.size) else tr('폴더'), if (s.modified.year > 1) _date(s.modified)].join(' · ');
final h = x.here, t = x.there;
final newer = h == null || h.modified.year <= 1 || t.modified.year <= 1
? ''
: h.modified.isAfter(t.modified.add(const Duration(seconds: 2)))
? tr(' (보낼 것이 더 새것)')
: t.modified.isAfter(h.modified.add(const Duration(seconds: 2)))
? tr(' (있는 것이 더 새것)')
: tr(' (바뀐 날 같음)');
return '${vBasename(x.src)}: ${trf('보낼 것 {0} / 있는 것 {1}', [h == null ? '?' : one(h), one(t)])}$newer';
}
/// 203: 바뀐 날을 모름 (잠긴 시스템 파일은 1970-01-01 로 온다) 은 "—"
String _date(DateTime d) => fileDateText(d);
String fileDateText(DateTime d) => d.year <= 1970
? '—'
: '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')} '
'${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}';
/// 기능 버튼 줄 (세로: 두 창 사이 · 가로: 위아래 배치일 때)
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: const 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);
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)
const 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 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 const 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: 빠른 필터 - 비어 있으면 모두, * · ? 가 있으면 이름 전체 패턴, 아니면 이름에 든 글자 (대소문자 무시)
bool quickFilterMatch(String name, String filter) {
if (filter.isEmpty) return true;
if (filter.contains('*') || filter.contains('?')) return globToRegExp(filter).hasMatch(name);
return name.toLowerCase().contains(filter.toLowerCase());
}
/// 146 · 37-2: 경로 입력 창 - 없는 폴더면 닫지 않고 칸 아래에 "폴더가 없거나 열 수 없습니다"
/// 74: 패턴 (예: *.srt, 여러 개는 ; 로) → 이름 전체에 맞는 정규식 (대소문자 무시)
RegExp globToRegExp(String pattern) {
final parts = pattern.split(RegExp(r'[;,]')).map((x) => x.trim()).where((x) => x.isNotEmpty);
String one(String g) => RegExp.escape(g).replaceAll(r'\*', '.*').replaceAll(r'\?', '.');
return RegExp('^(?:${parts.map(one).join('|')})\$', caseSensitive: false);
}
/// 74: 패턴 넣기
class _PatternDialog extends StatefulWidget {
const _PatternDialog();
@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)),
),
]),
),
),
),
),
),
]),
);
}