5189 lines
235 KiB
Dart
5189 lines
235 KiB
Dart
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/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 'dav_external.dart';
|
||
import '../platform/windows/recycle_bin.dart';
|
||
import '../core/sync_preview.dart';
|
||
import 'path_label.dart';
|
||
import 'sync_preview_view.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 'explorer_drag.dart';
|
||
import 'archive_ui.dart';
|
||
import 'storage_page.dart';
|
||
import '../core/archive_ops.dart' show ArchiveKind, archiveKindOf;
|
||
import 'batch_rename_dialog.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;
|
||
|
||
/// 시험: 경로 입력 창이 넣은 경로를 고쳐 쓰고 확인하는 것 (갈 경로, 칸 아래 이유)
|
||
@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, '저장 공간 분석');
|
||
|
||
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>{};
|
||
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] 즐겨찾기 하나
|
||
final int kind;
|
||
static const otherRoot = 1, favorites = 2, favorite = 3;
|
||
const _Row(this.entry, this.depth, {this.isRoot = false, this.isUp = false, this.kind = 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),
|
||
];
|
||
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)));
|
||
_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() {
|
||
_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:' : '/')];
|
||
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 = dav ? DavRegistry.server(DavPath.parse(where).server) : null;
|
||
final (title, body) = explainFileError(err, dav: dav, noPassword: server != null && server.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: () => _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);
|
||
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);
|
||
final label = DavRegistry.server(d.server)?.label ?? 'WebDAV';
|
||
return ['☁ $label', ...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) && DavRegistry.server(DavPath.parse(pane.root).server) == null) {
|
||
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)
|
||
? DavRegistry.server(DavPath.parse(path).server) != null
|
||
: 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)) return '$davScheme${DavPath.parse(path).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;
|
||
// 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);
|
||
}
|
||
} catch (e) {
|
||
pane.cache[dir] = const [];
|
||
pane.errors[dir] = isAndroidRestricted(dir) ? androidRestrictedError : '$e';
|
||
// 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 스타일 · 설정)
|
||
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: 빠른 필터 - 지금 폴더 안만 거른다 (트리의 다른 곳은 그대로).
|
||
// 이름이 맞지 않아도 안에 맞는 것이 보이는 폴더는 남긴다 (어디에 있는지 보이게)
|
||
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;
|
||
final at = out.length;
|
||
out.add(_Row(e, depth));
|
||
final inner = e.isDir && 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));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/// X-plore 비교 c: 트리 맨 위 항목 줄 (다른 저장 장치 · 서버 · 즐겨찾기) - 누르면 그곳으로, 즐겨찾기 묶음은 펼치기 · 접기
|
||
Widget _extraRow(_Pane pane, _Row 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] 에서 지금 보이는 것 (빠른 필터에 맞는 것)
|
||
List<FileEntry> _shown(_Pane pane, String dir) => [for (final x in pane.cache[dir] ?? const <FileEntry>[]) if (_passes(pane, x)) 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;
|
||
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));
|
||
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 {
|
||
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) _snack(_noAppMessage(path));
|
||
}
|
||
|
||
/// 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 는 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다. 분석할까요?')),
|
||
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);
|
||
}
|
||
|
||
/// 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;
|
||
// 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) ('zip', Icons.archive_outlined, many ? trf('{0}개 ZIP 으로 묶기', [targets.length]) : tr('ZIP 으로 묶기')),
|
||
// 87: 저장 공간 분석 (이 폴더 아래의 큰 폴더 · 큰 파일)
|
||
if (e.isDir) ('storage', Icons.pie_chart_outline, tr('저장 공간 분석')),
|
||
// 80: 이 폴더 크기를 지금 계산 (설정이 "안 함" 이어도)
|
||
if (e.isDir) ('folderSize', Icons.data_usage, _sizes.busy(e.path) ? tr('폴더 크기 계산 멈추기') : tr('폴더 크기 계산')),
|
||
if (e.isDir && dual) ('openOther', Icons.vertical_split_outlined, tr('다른 창에서 열기')),
|
||
if (e.isDir) ('playFolder', Icons.play_circle_outline, tr('이 폴더의 동영상 재생')),
|
||
if (video || audio) ('playInternal', Icons.play_circle_outline, tr('내장 플레이어로 재생')),
|
||
if (viewable) ('view', Icons.visibility_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)
|
||
('toPdf', Icons.picture_as_pdf_outlined,
|
||
images.length == 1 ? tr('PDF 로 만들기') : trf('그림 {0}장을 PDF 로 만들기', [images.length])),
|
||
('select', Icons.check_circle_outline, 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 '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 '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 '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 비교 a: 한 창인데 "숨은 다른 창으로" 복사 · 이동 (위에 갈 곳 줄)
|
||
bool get _hiddenTarget => !widget.rsync && !_dual && !_split && c.settings.explorerOnePaneCopy == 'other';
|
||
|
||
/// 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),
|
||
Expanded(
|
||
child: Tooltip(
|
||
message: _displayPath(t.current),
|
||
child: Text(trf('복사 · 이동 갈 곳: {0}', [_displayPath(t.current)]),
|
||
maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12.5)),
|
||
),
|
||
),
|
||
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:
|
||
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.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 의 동영상은 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 {
|
||
final name = await _askName(tr('이름 변경'), vBasename(path));
|
||
if (name == null || name == vBasename(path)) return;
|
||
try {
|
||
final now = await FileOps.rename(path, name);
|
||
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 {
|
||
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);
|
||
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 _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 {
|
||
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));
|
||
final canRecycle = local && c.settings.recycleOnDelete && paths.every(hasRecycleBin);
|
||
var permanent = !canRecycle || shift;
|
||
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 (local && !canRecycle)
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 8),
|
||
child: Text(tr('이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.'),
|
||
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)),
|
||
),
|
||
if (canRecycle)
|
||
CheckboxListTile(
|
||
contentPadding: EdgeInsets.zero,
|
||
value: permanent,
|
||
onChanged: (v) => set(() => permanent = v ?? false),
|
||
title: Text(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>[];
|
||
for (final x in paths) {
|
||
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'));
|
||
}
|
||
}
|
||
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: sent.isEmpty ? null : SnackBarAction(label: tr('되돌리기'), onPressed: () => _undoRecycle(sent, since)),
|
||
));
|
||
}
|
||
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));
|
||
}
|
||
|
||
/// 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 {
|
||
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: 이 화면을 떠난 사이에 시작된 (또는 앞의 화면이 시작한) 작업을 다시 보이고, 끝나면 새로 읽고 막대를 내린다
|
||
void _adoptJob(TransferJob job) {
|
||
if (_jobs.contains(job)) return;
|
||
setState(() => _jobs = [..._jobs, job]);
|
||
unawaited(job.done.then((_) async {
|
||
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;
|
||
}
|
||
// 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;
|
||
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: 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;
|
||
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 (e.modified.year > 1) '${tr('바뀐 날')}: ${_date(e.modified)}',
|
||
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(),
|
||
Expanded(
|
||
child: Stack(children: [
|
||
Positioned.fill(child: _ready ? _body() : const Center(child: CircularProgressIndicator())),
|
||
// 복사 · 이동 진행: 아래에서 올라오고 끝나면 내려간다
|
||
Positioned(left: 8, right: 8, bottom: 0, child: _transferPanel()),
|
||
]),
|
||
),
|
||
]);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
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),
|
||
Expanded(
|
||
child: Text(_ready ? _displayPath(_pane.current) : '',
|
||
maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: JjColors.textDim)),
|
||
),
|
||
PopupMenuButton<ExplorerButton>(
|
||
tooltip: tr('창 배치 · 버튼 구성'),
|
||
icon: const Icon(Icons.more_vert),
|
||
onSelected: _button,
|
||
itemBuilder: (_) => [
|
||
for (final b in [
|
||
if (!widget.rsync) ...[ExplorerButton.layout, ExplorerButton.buttons],
|
||
ExplorerButton.sort,
|
||
ExplorerButton.hidden,
|
||
if (!widget.rsync) ExplorerButton.storage,
|
||
])
|
||
PopupMenuItem(
|
||
value: b,
|
||
child: Row(children: [Icon(b.icon, size: 18), const SizedBox(width: 12), Text(tr(b.label))]),
|
||
),
|
||
],
|
||
),
|
||
]),
|
||
);
|
||
|
||
/// 모양 · 배치가 바뀌면 (줄 높이 · 창 너비가 달라져) 보던 폴더가 밀려나므로 다시 그 폴더로
|
||
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;
|
||
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 && !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 rows = [
|
||
if (up) _Row(FileEntry(vDirname(dir), isDir: true, modified: DateTime(0)), 0, isUp: true),
|
||
for (final e in shown) _Row(e, 0),
|
||
];
|
||
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: _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('경로 입력 · WebDAV'),
|
||
icon: const Icon(Icons.more_vert, size: 18),
|
||
padding: EdgeInsets.zero,
|
||
onSelected: (v) => v == 'path' ? _enterPath(pane) : _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 서버 추가')))),
|
||
],
|
||
);
|
||
|
||
/// 창 위 줄 앞: 뒤로 · 앞으로 · 즐겨찾기
|
||
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(
|
||
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: 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\공유 도)
|
||
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);
|
||
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}/');
|
||
}
|
||
|
||
/// 한 줄. [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 && !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 && !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),
|
||
Row(children: [
|
||
// 좁은 화면 · 깊은 폴더에서 넘치지 않게
|
||
Flexible(
|
||
child: Text('${formatSize(e.size)} · ${_date(e.modified)}',
|
||
style: dim, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||
),
|
||
if (isVideoFile(e.path)) ...[
|
||
const SizedBox(width: 8),
|
||
Flexible(child: _MetaText(c: c, path: e.path)),
|
||
],
|
||
]),
|
||
],
|
||
)
|
||
: 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))
|
||
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)
|
||
const SizedBox(width: 36),
|
||
]),
|
||
),
|
||
);
|
||
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;
|
||
const _MetaText({required this.c, required this.path});
|
||
|
||
@override
|
||
Widget build(BuildContext context) => FutureBuilder<String>(
|
||
future: _Meta.of(c, path),
|
||
builder: (_, s) => Text(s.data ?? '',
|
||
maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11, color: JjColors.accent)),
|
||
);
|
||
}
|
||
|
||
/// 동영상 썸네일 (임시 폴더의 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) 는 아직 지원하지 않습니다. WebDAV 탭을 쓰세요');
|
||
|
||
class _PathDialog extends StatefulWidget {
|
||
const _PathDialog({required this.initial});
|
||
final String initial;
|
||
|
||
/// 창이 이것을 돌려주면 WebDAV 서버 추가 창을 연다
|
||
static const addDav = '\u0000webdav';
|
||
|
||
@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), 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)),
|
||
),
|
||
]),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
]),
|
||
);
|
||
}
|