4019 lines
177 KiB
Dart
4019 lines
177 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, 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;
|
||
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 'monitor_page.dart';
|
||
import 'player_page.dart';
|
||
import 'rsync_setup.dart';
|
||
import 'theme.dart';
|
||
import 'file_error.dart';
|
||
import 'dav_external.dart';
|
||
import '../platform/windows/recycle_bin.dart';
|
||
import '../core/sync_preview.dart';
|
||
import 'path_label.dart';
|
||
import 'sync_preview_view.dart';
|
||
import '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';
|
||
|
||
/// 파일 탐색기 (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, '버튼 구성');
|
||
|
||
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;
|
||
|
||
/// 목록 스크롤 (폴더로 이동하면 그 폴더가 보이게)
|
||
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();
|
||
super.dispose();
|
||
}
|
||
}
|
||
|
||
/// 트리 · 목록의 한 줄
|
||
class _Row {
|
||
final FileEntry entry;
|
||
final int depth;
|
||
final bool isRoot;
|
||
|
||
/// 파일 목록 맨 위의 ".." (상위 폴더로)
|
||
final bool isUp;
|
||
const _Row(this.entry, this.depth, {this.isRoot = false, this.isUp = false});
|
||
}
|
||
|
||
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;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
if (widget.rsync) {
|
||
ExplorerPage._openRsync++;
|
||
} else {
|
||
ExplorerPage._open++;
|
||
}
|
||
for (final pane in _panes) {
|
||
pane.addListener(_onPane);
|
||
}
|
||
_init();
|
||
}
|
||
|
||
@override
|
||
void 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();
|
||
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('서버 설정 고치기'))),
|
||
],
|
||
);
|
||
}
|
||
|
||
/// 화면에 보일 경로 (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;
|
||
pane.loading.add(dir);
|
||
pane.changed();
|
||
try {
|
||
final list = await listEntries(dir, showHidden: c.settings.explorerShowHidden);
|
||
pane.cache[dir] = _sorted(list);
|
||
// 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();
|
||
}
|
||
}
|
||
|
||
List<FileEntry> _sorted(List<FileEntry> list) {
|
||
final by = SortBy.values.firstWhere((s) => s.name == c.settings.explorerSort, orElse: () => SortBy.name);
|
||
return sortEntries(list, by, descending: c.settings.explorerSortDesc);
|
||
}
|
||
|
||
/// 그 폴더로: 저장 장치부터 그 폴더까지 펼치고 지금 폴더로 정한다
|
||
/// [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) => samePath(r.entry.path, path));
|
||
if (i < 0) return;
|
||
final pos = pane.scroll.position;
|
||
// 180: 끝 (maxScrollExtent) 으로 자르지 않는다 - 줄이 막 늘어난 때는 끝이 아직 앞의 것이라 보던 폴더로 못 간다.
|
||
// 넘치면 다음 배치 때 실제 끝으로 맞춰진다
|
||
pane.scroll.jumpTo((i * _look.rowHeight - 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]: 폴더만 ("폴더 + 파일 목록" 배치의 왼쪽)
|
||
List<_Row> _rows(_Pane pane, {bool foldersOnly = false}) {
|
||
final out = <_Row>[];
|
||
final rootEntry = FileEntry(pane.root, isDir: true, modified: DateTime(0));
|
||
out.add(_Row(rootEntry, 0, isRoot: true));
|
||
void walk(String dir, int depth) {
|
||
if (!pane.expanded.contains(dir)) return;
|
||
for (final e in pane.cache[dir] ?? const <FileEntry>[]) {
|
||
if (foldersOnly && !e.isDir) continue;
|
||
out.add(_Row(e, depth));
|
||
if (e.isDir) walk(e.path, depth + 1);
|
||
}
|
||
}
|
||
|
||
walk(pane.root, 1);
|
||
return out;
|
||
}
|
||
|
||
// ───────── 누르기 ─────────
|
||
|
||
/// 선택 모드 (X-plore 처럼): 각 줄의 ✔ 동그라미가 보이고, 누르면 고르기 · 풀기
|
||
bool _selecting = false;
|
||
|
||
void _setSelecting(bool on) => setState(() {
|
||
_selecting = on;
|
||
if (!on) {
|
||
for (final x in _panes) {
|
||
x.marked.clear();
|
||
}
|
||
}
|
||
});
|
||
|
||
/// 한 번 누르기.
|
||
/// - 선택 모드: 고르기 · 풀기 ([_toggleMark])
|
||
/// - 폴더: 바로 펼치기 · 접기 (트리), 들어가기 (오른쪽 파일 목록)
|
||
/// - 파일: 누르기 설정이 "선택" 이면 고르기만 (두 번 누르면 실행), "바로 실행" 이면 실행
|
||
/// Ctrl 을 누른 채면 선택 모드로 고르기. 목록의 ".." 은 늘 상위 폴더로.
|
||
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;
|
||
}
|
||
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);
|
||
}
|
||
|
||
/// 74: 마지막으로 표시한 것부터 [to] 까지 (보이는 순서대로) 모두 표시
|
||
void _markRange(_Pane pane, FileEntry to) {
|
||
final list = pane.cache[vDirname(to.path)] ?? const <FileEntry>[];
|
||
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 pane.cache[dir] ?? const <FileEntry>[]) {
|
||
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 pane.cache[dir] ?? const <FileEntry>[]) 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;
|
||
}
|
||
// 이미지 · 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));
|
||
} 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: 환경 설정이 "만화 보기" 면 안의 그림을 바로 보고 (그림이 없으면 목록), 아니면 목록
|
||
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;
|
||
// 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)>[
|
||
// 74: 폰에서 범위 고르기 - 첫 항목을 표시한 뒤 마지막 항목을 길게 눌러
|
||
if (_selecting && _canRange(pane, e)) ('range', Icons.unfold_more, tr('여기까지 범위 고르기')),
|
||
if (e.isDir) ('open', Icons.folder_open, 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('선택')),
|
||
('rename', Icons.drive_file_rename_outline, tr('이름 변경')),
|
||
if (dual) ('copy', Icons.copy_outlined, tr('다른 창으로 복사')),
|
||
if (dual) ('move', Icons.drive_file_move_outline, tr('다른 창으로 이동')),
|
||
// 어느 배치에서나: 담아 두었다가 원하는 폴더에서 붙여넣기
|
||
('clipCopy', Icons.content_copy, dual ? tr('복사 (붙여넣기로)') : tr('복사')),
|
||
('clipMove', Icons.content_cut, dual ? tr('이동 (붙여넣기로)') : tr('이동')),
|
||
if (_clip.isNotEmpty)
|
||
('paste', Icons.content_paste, trf('{0} 에 붙여넣기 ({1}개)', [vBasename(e.isDir ? e.path : vDirname(e.path)), _clip.length])),
|
||
('delete', Icons.delete_outline, tr('삭제')),
|
||
if (!dav && (!Platform.isAndroid || e.isDir)) ('reveal', Icons.folder_outlined, tr('파일 관리자에서 보기')),
|
||
('info', Icons.info_outline, tr('정보')),
|
||
];
|
||
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: [
|
||
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 'rename':
|
||
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':
|
||
await _delete(pane, [e.path]);
|
||
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);
|
||
}
|
||
}
|
||
|
||
// ───────── 기능 ─────────
|
||
|
||
_Pane get _pane => _panes[_active];
|
||
_Pane? get _other => _dual ? _panes[1 - _active] : null;
|
||
|
||
/// 기능의 대상: 표시한 항목, 없으면 마지막으로 누른 항목 (저장 장치 맨 위는 빼고)
|
||
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);
|
||
}
|
||
}
|
||
|
||
/// 바뀐 폴더를 두 창 모두 다시 읽는다
|
||
Future<void> _refreshAll(Iterable<String> dirs) async {
|
||
for (final pane in _panes) {
|
||
await _refresh(pane, dirs);
|
||
}
|
||
}
|
||
|
||
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: 두 창 모두 (밖에서 지운 파일이 다른 창에 남지 않게).
|
||
// 180: 목록을 먼저 비우지 않고 새로 읽은 것으로 바꾼다 (비우면 목록이 사라졌다 생겨 스크롤 · 자리가 바뀐다)
|
||
for (final x in _panes) {
|
||
final open = {...x.expanded, x.current};
|
||
for (final d in open) {
|
||
await _load(x, d, force: true);
|
||
}
|
||
x.cache.removeWhere((k, _) => !open.contains(k));
|
||
x.changed();
|
||
}
|
||
case ExplorerButton.play:
|
||
final t = _targets(pane);
|
||
final videos = t.where((x) => !vIsDirSync(x) && isVideoFile(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.length != 1) {
|
||
_snack(tr('이름을 바꿀 항목 하나를 고르세요.'));
|
||
return;
|
||
}
|
||
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.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]));
|
||
}
|
||
}
|
||
|
||
/// Shift+Delete 로 불렀는지 (휴지통을 거치지 않고 지우기를 미리 골라 둔다 - 65)
|
||
bool _shiftDelete = false;
|
||
|
||
/// 지우기. Windows 의 로컬 파일은 기본으로 휴지통으로 (창에서 "영구 삭제" 를 고르거나 Shift+Delete).
|
||
/// WebDAV · Android 는 휴지통이 없어 바로 지운다. 여러 개면 실패해도 나머지를 계속하고, 못 지운 것을 알린다 (65).
|
||
Future<void> _delete(_Pane pane, List<String> paths) async {
|
||
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')),
|
||
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 {
|
||
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}) 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)),
|
||
),
|
||
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 _refreshAll([dest, ...sources.map(p.dirname)]);
|
||
// 넣은 폴더가 창의 지금 폴더면 펼쳐서 새 항목이 보이게
|
||
for (final x in _panes) {
|
||
if (samePath(x.current, dest)) {
|
||
x.expanded.add(dest);
|
||
await _load(x, dest, force: true);
|
||
}
|
||
x.changed();
|
||
}
|
||
_snack(error is FileOpCancelled
|
||
? tr('취소했습니다.')
|
||
: error != null
|
||
? trf('끝나지 못했습니다: {0}', [error])
|
||
: [
|
||
trf('{0}개 항목을 {1}', [job.made.length, move ? tr('옮겼습니다.') : tr('복사했습니다.')]),
|
||
if (job.skipped.isNotEmpty) trf('같은 이름이라 건너뜀: {0}', [_namesOf(job.skipped)]),
|
||
if (skippedHere.isNotEmpty) trf('이미 이 폴더에 있어 건너뜀: {0}', [_namesOf(skippedHere)]),
|
||
].join(' · '));
|
||
// 다 되면 진행 막대가 잠깐 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)),
|
||
if (nested)
|
||
Text(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(), update: optionsUpdate(opts(t))),
|
||
],
|
||
// 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]));
|
||
final j = await center.start(run[i].copyWith(options: shownOpts[i]), 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])
|
||
: trf('rsync 끝: {0}', [jobs.map((j) => '${vBasename(j.sources.first)} → ${vBasename(j.dest)}').join(' · ')]));
|
||
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;
|
||
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: [
|
||
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('닫기')))],
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<void> _search(_Pane pane) async {
|
||
final q = await _askName(trf('찾기: {0}', [pane.current]), '');
|
||
if (q == null || !mounted) return;
|
||
final found = await showDialog<FileEntry>(
|
||
context: context,
|
||
builder: (ctx) => _SearchDialog(root: pane.current, query: q, showHidden: c.settings.explorerShowHidden),
|
||
);
|
||
if (found == null) return;
|
||
await _goTo(pane, found.isDir ? found.path : vDirname(found.path), fresh: true);
|
||
pane.focused = found.path;
|
||
pane.changed();
|
||
_reveal(pane, found.path);
|
||
}
|
||
|
||
Future<void> _sortDialog() async {
|
||
final s = c.settings;
|
||
var by = s.explorerSort;
|
||
var desc = s.explorerSortDesc;
|
||
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)),
|
||
]),
|
||
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
|
||
..explorerSort = by
|
||
..explorerSortDesc = desc);
|
||
for (final pane in _panes) {
|
||
for (final k in pane.cache.keys.toList()) {
|
||
pane.cache[k] = _sorted(pane.cache[k]!);
|
||
}
|
||
pane.changed();
|
||
}
|
||
}
|
||
|
||
/// 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) {
|
||
return CallbackShortcuts(
|
||
bindings: {
|
||
const SingleActivator(LogicalKeyboardKey.f5): () => _button(ExplorerButton.refresh),
|
||
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): () {
|
||
if (_selecting || _panes.any((x) => x.marked.isNotEmpty)) _setSelecting(false);
|
||
},
|
||
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),
|
||
},
|
||
child: Focus(
|
||
autofocus: true,
|
||
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();
|
||
return Column(children: [
|
||
_topBar(),
|
||
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,
|
||
])
|
||
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(
|
||
// 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: 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);
|
||
},
|
||
child: Container(
|
||
margin: const EdgeInsets.all(4),
|
||
decoration: BoxDecoration(
|
||
color: JjColors.bg,
|
||
border: Border.all(color: active ? JjColors.accent : JjColors.border, width: active ? 1.5 : 1),
|
||
borderRadius: BorderRadius.circular(6),
|
||
),
|
||
child: Column(children: children),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 선택 모드 막대: 고른 수 · 모두 고르기 · 선택 끝
|
||
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 pane.cache[dir] ?? const <FileEntry>[]) 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),
|
||
// 38: 안내가 길어도 목록을 덮지 않게 (창 높이의 35% 까지, 넘치면 넘겨 봄)
|
||
?_noticeArea([_accessNotice(pane), _errorNotice(pane, pane.current), _partialNotice(pane, pane.current)], box.maxHeight),
|
||
if (look.columns && !foldersOnly) ExplorerColumnsHeader(style: look, markColumn: _selecting),
|
||
Expanded(
|
||
child: Stack(children: [
|
||
ListView.builder(
|
||
controller: pane.scroll,
|
||
itemCount: rows.length,
|
||
itemExtent: look.rowHeight,
|
||
itemBuilder: (_, k) => _rowView(pane, rows[k], compact: foldersOnly),
|
||
),
|
||
// 읽는 중 (WebDAV 서버를 기다릴 때 등). 180: 목록 위에 겹쳐 그린다 (끼어들면 목록이 밀려 다음 것을 누르려다 엉뚱한 것을 누른다)
|
||
if (pane.loading.isNotEmpty) const Positioned(left: 0, right: 0, top: 0, child: LinearProgressIndicator(minHeight: 2)),
|
||
]),
|
||
),
|
||
if (_selecting && !foldersOnly) _marksBar(pane),
|
||
]));
|
||
}
|
||
|
||
/// "폴더 + 파일 목록" 배치의 오른쪽: 왼쪽 트리에서 고른 폴더의 내용 (맨 위 ".." = 상위 폴더)
|
||
Widget _listView(_Pane pane) {
|
||
final dir = pane.current;
|
||
final entries = pane.cache[dir] ?? const <FileEntry>[];
|
||
final up = !samePath(dir, pane.root);
|
||
final rows = [
|
||
if (up) _Row(FileEntry(vDirname(dir), isDir: true, modified: DateTime(0)), 0, isUp: true),
|
||
for (final e in entries) _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),
|
||
?_noticeArea([_accessNotice(pane), _errorNotice(pane, dir), _partialNotice(pane, dir)], box.maxHeight),
|
||
if (look.columns) ExplorerColumnsHeader(style: look, markColumn: _selecting),
|
||
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.rowHeight,
|
||
itemBuilder: (_, k) => _rowView(pane, rows[k], list: true),
|
||
),
|
||
),
|
||
if (_selecting) _marksBar(pane, listDir: dir),
|
||
]));
|
||
}
|
||
|
||
/// 창 위쪽: 저장 장치 고르기
|
||
/// 창 위 줄: [뒤로 · 앞으로 · 즐겨찾기] · 저장 장치 탭 (넘치면 옆으로 밀기) · [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: tr('뒤로 (Alt+←)'),
|
||
visualDensity: VisualDensity.compact,
|
||
icon: const Icon(Icons.arrow_back, size: 16),
|
||
onPressed: pane.back.isEmpty ? null : () => _travel(pane, forward: false),
|
||
),
|
||
IconButton(
|
||
tooltip: 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),
|
||
),
|
||
];
|
||
|
||
/// 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.rowHeight,
|
||
child: e.isDir && !list && !row.isUp
|
||
? (loading
|
||
? const Center(
|
||
child: SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 1.5)))
|
||
: 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.rowHeight - 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)),
|
||
],
|
||
),
|
||
),
|
||
]);
|
||
});
|
||
|
||
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 ? '' : formatSize(e.size), 2, align: TextAlign.end),
|
||
]
|
||
: [
|
||
Expanded(flex: 6, child: nameCell()),
|
||
cell(e.isDir ? '' : e.ext, 1),
|
||
cell(e.isDir ? '<DIR>' : formatSize(e.size), 2, align: TextAlign.end),
|
||
const SizedBox(width: 8),
|
||
cell(_date(e.modified), 3),
|
||
];
|
||
|
||
return 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),
|
||
),
|
||
),
|
||
),
|
||
// 표시 동그라미는 두 번 누르기 영역 밖 (기다리지 않고 바로)
|
||
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),
|
||
]),
|
||
),
|
||
);
|
||
}
|
||
|
||
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;
|
||
const _Toolbar(
|
||
{required this.buttons,
|
||
required this.vertical,
|
||
required this.onPressed,
|
||
required this.enabled,
|
||
required this.selected,
|
||
this.twoPanes = true,
|
||
this.pinned = const {ExplorerButton.orient},
|
||
bool? panesSide})
|
||
: panesSide = panesSide ?? vertical;
|
||
|
||
/// 두 창이 좌우로 놓였는지 (버튼 이름 "좌 → 우" / "위 → 아래"). 보통 [vertical] 과 같고,
|
||
/// 191 좁은 Rsync 화면 (창 하나씩, 버튼 줄은 가로) 은 좌우
|
||
final bool panesSide;
|
||
|
||
/// 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);
|
||
return Tooltip(
|
||
message: tr(b == ExplorerButton.orient ? _orientTip() : _label(b)),
|
||
child: InkWell(
|
||
onTap: on ? () => onPressed(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),
|
||
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);
|
||
}),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 찾기 결과 (찾는 대로 보여 주고, 누르면 그 자리로)
|
||
class _SearchDialog extends StatefulWidget {
|
||
final String root;
|
||
final String query;
|
||
final bool showHidden;
|
||
const _SearchDialog({required this.root, required this.query, required this.showHidden});
|
||
|
||
@override
|
||
State<_SearchDialog> createState() => _SearchDialogState();
|
||
}
|
||
|
||
class _SearchDialogState extends State<_SearchDialog> {
|
||
final _found = <FileEntry>[];
|
||
StreamSubscription<FileEntry>? _sub;
|
||
bool _done = false;
|
||
|
||
/// 24: 찾기를 멈추는 개수 · 읽지 못한 폴더
|
||
static const limit = 2000;
|
||
final _skipped = <(String, String)>[];
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_sub = searchFiles(widget.root, widget.query, showHidden: widget.showHidden, limit: limit, onSkipped: (dir, e) {
|
||
if (mounted) {
|
||
setState(() => _skipped.add((dir, e is FileSystemException ? (e.osError?.message ?? e.message) : '$e')));
|
||
}
|
||
}).listen(
|
||
(e) => setState(() => _found.add(e)),
|
||
onDone: () => setState(() => _done = true),
|
||
onError: (_) {},
|
||
);
|
||
}
|
||
|
||
/// 아래 안내: 개수 한도에서 멈춤 · 읽지 못한 폴더
|
||
Widget? _footer() {
|
||
final limited = _found.length >= limit;
|
||
if (!limited && _skipped.isEmpty) return null;
|
||
return Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
|
||
if (limited)
|
||
Text(trf('{0}개를 찾아 여기서 멈췄습니다. 이름을 더 자세히 넣거나 아래 폴더로 들어가 찾으세요.', [limit]),
|
||
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)),
|
||
if (_skipped.isNotEmpty)
|
||
ExpansionTile(
|
||
tilePadding: EdgeInsets.zero,
|
||
dense: true,
|
||
title: Text(trf('읽지 못한 폴더 {0}개 (권한 · 연결 문제) - 그 안은 찾지 못했습니다', [_skipped.length]),
|
||
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)),
|
||
children: [
|
||
for (final (dir, why) in _skipped.take(50))
|
||
Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: Text('$dir · $why', style: const TextStyle(fontSize: 11, color: JjColors.textDim)),
|
||
),
|
||
],
|
||
),
|
||
]);
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_sub?.cancel();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) => AlertDialog(
|
||
title: Row(children: [
|
||
Expanded(child: Text(trf('"{0}" 찾기 · {1}개', [widget.query, _found.length]))),
|
||
if (!_done) const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)),
|
||
]),
|
||
content: SizedBox(
|
||
width: 560,
|
||
height: 420,
|
||
child: Column(children: [
|
||
Expanded(
|
||
child: _found.isEmpty && _done
|
||
? Center(child: Text(tr('찾은 것이 없습니다.')))
|
||
: ListView.builder(
|
||
itemCount: _found.length,
|
||
itemBuilder: (_, i) {
|
||
final e = _found[i];
|
||
return ListTile(
|
||
dense: true,
|
||
leading: Icon(e.isDir ? Icons.folder : Icons.insert_drive_file_outlined,
|
||
color: e.isDir ? Colors.amber : null),
|
||
title: Text(e.name),
|
||
subtitle: Text(vDirname(e.path), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||
onTap: () => Navigator.pop(context, e),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
?_footer(),
|
||
]),
|
||
),
|
||
actions: [TextButton(onPressed: () => Navigator.pop(context), child: Text(tr('닫기')))],
|
||
);
|
||
}
|
||
|
||
// ───────── 동영상 정보 · 썸네일 (보이는 줄만, 차례로) ─────────
|
||
|
||
/// 한 번에 하나씩 (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;
|
||
});
|
||
}
|
||
}
|
||
|
||
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),
|
||
]),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
/// 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('확인'))),
|
||
],
|
||
);
|
||
}
|