E5d-3: 창 머리 · 창 보기 · 경로 입력 · 서버 흐름을 모듈로 (explorer_page 4,857 → 4,227)
- explorer/pane_header.dart: ExplorerPaneHeader (뒤로 · 앞으로 · 즐겨찾기 · 필터 · 저장 장치 탭 · 끝 버튼 / 좁으면 ⋮) - explorer/pane_view.dart: ExplorerPaneParts 약속 · ExplorerPaneView · ExplorerListView · ExplorerFilterBar · ExplorerExtraRow · 열 배치 · 줄 키 · 안내 높이 - explorer/path_dialog.dart: PathDialog · checkEnteredPath · SMB 주소 도우미 - explorer/server_flows.dart: ExplorerServers (WebDAV · SFTP · FTP · SMB 추가 · 고치기 · 경로 입력) - 약속 시험: test/explorer_pane_view_contract_test.dart - 동작은 그대로 (전체 단위 2회 · 점검 통합 2회 · 픽셀 비교) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>split_pages
parent
e6e4499f22
commit
b5b1b13d61
|
|
@ -0,0 +1,287 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/gestures.dart' show PointerDeviceKind, PointerScrollEvent;
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../core/file_ops.dart' show samePath;
|
||||||
|
import '../../core/shizuku.dart' show isRootPath, isShizuku;
|
||||||
|
import '../../core/vfs.dart';
|
||||||
|
import '../../l10n/tr.dart';
|
||||||
|
import '../dlna_page.dart' show openDlna;
|
||||||
|
import '../key_hint.dart';
|
||||||
|
import '../theme.dart';
|
||||||
|
import 'explorer_controller.dart';
|
||||||
|
import 'explorer_pane.dart';
|
||||||
|
import 'server_flows.dart';
|
||||||
|
|
||||||
|
/// 202 · 221: 창 위 줄을 [← → ★] · 탭 · [WebDAV · 경로 입력] 으로 펴는 가장 작은 창 폭. 이보다 좁으면 끝 버튼은 [⋮] 하나 -
|
||||||
|
/// 폰 세로 (약 400) 에서 탭 칸이 130px 남짓이라 두 번째 저장소 (SD 카드) 가 잘려 보였다
|
||||||
|
const double paneHeaderWideWidth = 480;
|
||||||
|
|
||||||
|
/// 약속 (E5d-3): 창 머리 한 줄 - [뒤로 · 앞으로 · 즐겨찾기 · 빠른 필터] · 저장 장치 탭 · [서버 추가 · 경로 입력].
|
||||||
|
/// 창 상태는 [ExplorerController], 서버 · 경로 흐름은 [ExplorerServers], 화면에 남은 일 (즐겨찾기 · 필터 · 루트 · Shizuku ·
|
||||||
|
/// 다시 연결) 은 콜백. 누르면 [pane] 이 지금 창이 된다. [android]: SMB 서버 추가 메뉴 · 경로 입력의 SMB 안내
|
||||||
|
class ExplorerPaneHeader extends StatelessWidget {
|
||||||
|
const ExplorerPaneHeader({
|
||||||
|
super.key,
|
||||||
|
required this.ctl,
|
||||||
|
required this.servers,
|
||||||
|
required this.pane,
|
||||||
|
required this.android,
|
||||||
|
required this.onBookmarks,
|
||||||
|
required this.onOpenFilter,
|
||||||
|
required this.onCloseFilter,
|
||||||
|
required this.onOpenRoot,
|
||||||
|
required this.onOpenShizuku,
|
||||||
|
required this.onReconnect,
|
||||||
|
});
|
||||||
|
|
||||||
|
final ExplorerController ctl;
|
||||||
|
final ExplorerServers servers;
|
||||||
|
final ExplorerPane pane;
|
||||||
|
final bool android;
|
||||||
|
final VoidCallback onBookmarks, onOpenFilter, onCloseFilter;
|
||||||
|
final Future<void> Function() onOpenRoot, onOpenShizuku;
|
||||||
|
final Future<void> Function(String path) onReconnect;
|
||||||
|
|
||||||
|
/// 창 위쪽: 저장 장치 고르기
|
||||||
|
/// 창 위 줄: [뒤로 · 앞으로 · 즐겨찾기] · 저장 장치 탭 (넘치면 옆으로 밀기) · [WebDAV · 경로 입력].
|
||||||
|
/// 202: 드라이브가 많아 넘쳐도 끝의 버튼은 늘 보이게 (예전에는 줄 전체가 한 목록이라 화면 밖으로 밀려 누를 수 없었다)
|
||||||
|
/// 좁은 창 (위아래 두 창 · 폰, [paneHeaderWideWidth] 미만) 은 앞 버튼도 탭과 함께 밀고, 끝 버튼은 [⋮] 하나로 (늘 보임)
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => SizedBox(
|
||||||
|
height: 40,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, box) => box.maxWidth >= paneHeaderWideWidth
|
||||||
|
? Row(children: [
|
||||||
|
..._start(),
|
||||||
|
Expanded(child: _driveTabs(context)),
|
||||||
|
..._end(context),
|
||||||
|
])
|
||||||
|
: Row(children: [
|
||||||
|
Expanded(child: _driveTabs(context, lead: _start())),
|
||||||
|
_menu(context),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 202: 좁은 창의 끝 버튼 - WebDAV 서버 추가 · 경로 입력
|
||||||
|
Widget _menu(BuildContext context) => PopupMenuButton<String>(
|
||||||
|
tooltip: tr('경로 입력 · 네트워크 서버'),
|
||||||
|
icon: const Icon(Icons.more_vert, size: 18),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
onSelected: (v) => switch (v) {
|
||||||
|
'path' => servers.enterPath(context, pane, android: android),
|
||||||
|
'sftp' => servers.editSftp(context, pane, null),
|
||||||
|
'smb' => servers.editSmb(context, pane, null),
|
||||||
|
'ftp' => servers.editFtp(context, pane, null),
|
||||||
|
'dlna' => openDlna(context, ctl.c),
|
||||||
|
_ => servers.editDav(context, pane, null),
|
||||||
|
},
|
||||||
|
itemBuilder: (_) => [
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'path',
|
||||||
|
child: ListTile(dense: true, leading: const Icon(Icons.edit_location_alt_outlined), title: Text(tr('경로 입력')))),
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'dav', child: ListTile(dense: true, leading: const Icon(Icons.cloud_outlined), title: Text(tr('WebDAV 서버 추가')))),
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'sftp', child: ListTile(dense: true, leading: const Icon(Icons.vpn_key_outlined), title: Text(tr('SFTP 서버 추가')))),
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'ftp', child: ListTile(dense: true, leading: const Icon(Icons.folder_shared_outlined), title: Text(tr('FTP 서버 추가')))),
|
||||||
|
// 88: SMB 는 Android 만 (Windows 는 경로 입력에 \\서버\공유)
|
||||||
|
if (android)
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'smb', child: ListTile(dense: true, leading: const Icon(Icons.dns_outlined), title: Text(tr('SMB 서버 추가')))),
|
||||||
|
// X-plore 비교 5-2 (사용자 결정 10/21): 같은 망의 미디어 서버 보기 (DLNA, 보기만)
|
||||||
|
if (!ctl.rsync)
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'dlna', child: ListTile(dense: true, leading: const Icon(Icons.cast_outlined), title: Text(tr('미디어 서버 (DLNA)')))),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 창 위 줄 앞: 뒤로 · 앞으로 · 즐겨찾기
|
||||||
|
List<Widget> _start() => [
|
||||||
|
// 75: 이 창의 뒤로 · 앞으로 (가 본 폴더)
|
||||||
|
IconButton(
|
||||||
|
tooltip: keyHint(tr('뒤로'), 'Alt+←'),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
icon: const Icon(Icons.arrow_back, size: 16),
|
||||||
|
onPressed: !pane.nav.canBack ? null : () => ctl.travel(pane, forward: false),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: keyHint(tr('앞으로'), 'Alt+→'),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
icon: const Icon(Icons.arrow_forward, size: 16),
|
||||||
|
onPressed: !pane.nav.canForward ? null : () => ctl.travel(pane, forward: true),
|
||||||
|
),
|
||||||
|
// 73: 즐겨찾기 (창 위에서 한 번에 - 드라이브가 많아도 보이게 맨 앞)
|
||||||
|
IconButton(
|
||||||
|
tooltip: tr('즐겨찾기'),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
icon: Icon(
|
||||||
|
ctl.c.settings.folderBookmarks.any((b) => !b.isPair && samePath(b.path, pane.current)) ? Icons.star : Icons.star_outline,
|
||||||
|
size: 16,
|
||||||
|
color: JjColors.amber),
|
||||||
|
onPressed: () => onBookmarks(),
|
||||||
|
),
|
||||||
|
// 76: 빠른 필터 (켜져 있으면 강조)
|
||||||
|
IconButton(
|
||||||
|
tooltip: keyHint(tr('빠른 필터'), 'Ctrl+F'),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
isSelected: pane.filterOpen,
|
||||||
|
icon: Icon(pane.filterOpen ? Icons.filter_alt : Icons.filter_alt_outlined,
|
||||||
|
size: 16, color: pane.filterOpen ? JjColors.accent : null),
|
||||||
|
onPressed: () => pane.filterOpen ? onCloseFilter() : onOpenFilter(),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 202: 저장 장치 탭 - 넘치면 마우스 휠 · 끌기로도 옆으로. 비어 있거나 연결 안 된 드라이브는 숨기지 않고 뒤쪽으로
|
||||||
|
Widget _driveTabs(BuildContext context, {List<Widget> lead = const []}) {
|
||||||
|
final all = [
|
||||||
|
...ctl.volumes,
|
||||||
|
// 37: 지금 열어 둔 네트워크 공유 (\\NAS\공유) 도 탭으로
|
||||||
|
for (final x in ctl.panes)
|
||||||
|
if (Platform.isWindows && x.root.startsWith(r'\\') && !ctl.volumes.any((v) => samePath(v.$1, x.root))) (x.root, x.root),
|
||||||
|
];
|
||||||
|
final tabs = [
|
||||||
|
...all.where((v) => ctl.drives.ok[v.$1] != false || samePath(pane.root, v.$1)),
|
||||||
|
...all.where((v) => ctl.drives.ok[v.$1] == false && !samePath(pane.root, v.$1)),
|
||||||
|
];
|
||||||
|
return Listener(
|
||||||
|
// 마우스 휠 (위 · 아래) 을 옆으로
|
||||||
|
onPointerSignal: (e) {
|
||||||
|
if (e is! PointerScrollEvent || !pane.tabScroll.hasClients) return;
|
||||||
|
final pos = pane.tabScroll.position;
|
||||||
|
final d = e.scrollDelta.dx != 0 ? e.scrollDelta.dx : e.scrollDelta.dy;
|
||||||
|
pane.tabScroll.jumpTo((pos.pixels + d).clamp(0.0, pos.maxScrollExtent));
|
||||||
|
},
|
||||||
|
child: ScrollConfiguration(
|
||||||
|
// 마우스로 끌어도 옆으로 (데스크톱은 기본으로 마우스 끌기가 꺼져 있음)
|
||||||
|
behavior: ScrollConfiguration.of(context).copyWith(
|
||||||
|
dragDevices: {PointerDeviceKind.touch, PointerDeviceKind.mouse, PointerDeviceKind.trackpad, PointerDeviceKind.stylus},
|
||||||
|
scrollbars: false,
|
||||||
|
),
|
||||||
|
child: ListView(
|
||||||
|
controller: pane.tabScroll,
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
children: [
|
||||||
|
...lead,
|
||||||
|
for (final (path, label) in tabs)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 4),
|
||||||
|
child: TextButton.icon(
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||||
|
backgroundColor: samePath(pane.root, path) ? JjColors.accent.withValues(alpha: 0.18) : null,
|
||||||
|
foregroundColor: samePath(pane.root, path) ? JjColors.accent : null,
|
||||||
|
),
|
||||||
|
icon: Icon(
|
||||||
|
isRootPath(path)
|
||||||
|
? Icons.security
|
||||||
|
: isShizuku(path)
|
||||||
|
? Icons.admin_panel_settings_outlined
|
||||||
|
: isSftp(path)
|
||||||
|
? Icons.vpn_key_outlined
|
||||||
|
: isFtp(path)
|
||||||
|
? Icons.folder_shared_outlined
|
||||||
|
: isSmb(path)
|
||||||
|
? Icons.dns_outlined
|
||||||
|
: isDav(path)
|
||||||
|
? Icons.cloud_outlined
|
||||||
|
: ctl.drives.network.contains(path)
|
||||||
|
? Icons.lan_outlined
|
||||||
|
: Platform.isWindows
|
||||||
|
? Icons.storage
|
||||||
|
: Icons.sd_storage_outlined,
|
||||||
|
size: 16),
|
||||||
|
// 165: 열 수 없는 드라이브는 숨기지 않고 "연결 안 됨" - 누르면 다시 연결 (다시 읽기)
|
||||||
|
label: ctl.drives.ok[path] == false
|
||||||
|
? Text('$label · ${ctl.drives.removable.contains(path) ? tr('비어 있음') : tr('연결 안 됨')} ⟳',
|
||||||
|
style: TextStyle(color: ctl.drives.removable.contains(path) ? JjColors.textDim : JjColors.warning))
|
||||||
|
: Text(label),
|
||||||
|
onPressed: () {
|
||||||
|
ctl
|
||||||
|
..active = ctl.panes.indexOf(pane)
|
||||||
|
..update();
|
||||||
|
if (isRootPath(path)) {
|
||||||
|
unawaited(onOpenRoot());
|
||||||
|
} else if (isShizuku(path)) {
|
||||||
|
unawaited(onOpenShizuku());
|
||||||
|
} else if (ctl.drives.ok[path] == false) {
|
||||||
|
unawaited(onReconnect(path));
|
||||||
|
} else {
|
||||||
|
ctl.goTo(pane, path);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLongPress: isSftp(path)
|
||||||
|
? () => servers.editSftp(context, pane, path)
|
||||||
|
: isFtp(path)
|
||||||
|
? () => servers.editFtp(context, pane, path)
|
||||||
|
: isSmb(path)
|
||||||
|
? () => servers.editSmb(context, pane, path)
|
||||||
|
: isDav(path)
|
||||||
|
? () => servers.editDav(context, pane, path)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 202: 창 위 줄 끝 - 탭이 넘쳐도 늘 보이는 버튼 (WebDAV 서버 추가 · 경로 입력)
|
||||||
|
List<Widget> _end(BuildContext context) => [
|
||||||
|
if (ctl.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: () => servers.editDav(context, pane, null),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
IconButton(
|
||||||
|
tooltip: tr('WebDAV 서버 추가'),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
icon: const Icon(Icons.add, size: 16),
|
||||||
|
onPressed: () => servers.editDav(context, pane, null),
|
||||||
|
),
|
||||||
|
// 37: 경로를 직접 넣어 가기 (Windows 는 \\NAS\공유 도)
|
||||||
|
// 88: SFTP 서버 추가
|
||||||
|
IconButton(
|
||||||
|
tooltip: tr('SFTP 서버 추가'),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
icon: const Icon(Icons.vpn_key_outlined, size: 16),
|
||||||
|
onPressed: () => servers.editSftp(context, pane, null),
|
||||||
|
),
|
||||||
|
// l: FTP · FTPS 서버 추가
|
||||||
|
IconButton(
|
||||||
|
tooltip: tr('FTP 서버 추가'),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
icon: const Icon(Icons.folder_shared_outlined, size: 16),
|
||||||
|
onPressed: () => servers.editFtp(context, pane, null),
|
||||||
|
),
|
||||||
|
// X-plore 비교 5-2 (사용자 결정 10/21): 같은 망의 미디어 서버 보기 (DLNA, 보기만)
|
||||||
|
if (!ctl.rsync)
|
||||||
|
IconButton(
|
||||||
|
tooltip: tr('미디어 서버 (DLNA)'),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
icon: const Icon(Icons.cast_outlined, size: 16),
|
||||||
|
onPressed: () => openDlna(context, ctl.c),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: tr('경로 입력'),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
icon: const Icon(Icons.edit_location_alt_outlined, size: 16),
|
||||||
|
onPressed: () => servers.enterPath(context, pane, android: android),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,378 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show defaultTargetPlatform;
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart' show LogicalKeyboardKey;
|
||||||
|
|
||||||
|
import '../../app/settings.dart' show FolderBookmark;
|
||||||
|
import '../../core/explorer/tree_rows.dart';
|
||||||
|
import '../../core/file_ops.dart' show FileEntry, isSameOrInside, samePath;
|
||||||
|
import '../../core/vfs.dart' show isDav, vBasename, vDirname;
|
||||||
|
import '../../l10n/tr.dart';
|
||||||
|
import '../explorer_drag.dart';
|
||||||
|
import '../explorer_look.dart';
|
||||||
|
import '../key_hint.dart';
|
||||||
|
import '../theme.dart';
|
||||||
|
import 'explorer_controller.dart';
|
||||||
|
import 'explorer_pane.dart';
|
||||||
|
import 'explorer_select.dart';
|
||||||
|
import 'row_view.dart';
|
||||||
|
|
||||||
|
/// 약속 (E5d-3): 창 하나를 그리는 데 화면이 주는 것. 창의 틀 (활성 · 놓기) · 창 머리 · 안내 · 표시 막대 · 줄 메뉴 ·
|
||||||
|
/// 끌어 놓기 · 필터 닫기는 아직 화면의 일이라 여기로 받는다 (나눌 때마다 이 목록이 줄어든다)
|
||||||
|
abstract interface class ExplorerPaneParts {
|
||||||
|
/// 줄 높이 (목록 글자 크기 · 줄 간격을 넣은 것)
|
||||||
|
double get rowHeight;
|
||||||
|
|
||||||
|
/// 목록 글자 배율 (설정 %)
|
||||||
|
double get listTextScale;
|
||||||
|
|
||||||
|
/// 좁은 "폴더 + 파일 목록" 의 트리 (이름만)
|
||||||
|
bool get narrowSplit;
|
||||||
|
|
||||||
|
/// X-plore 스타일 표시 동그라미 늘 보이기
|
||||||
|
bool get alwaysMarks;
|
||||||
|
|
||||||
|
Widget frame(int i, List<Widget> children);
|
||||||
|
Widget header(ExplorerPane pane);
|
||||||
|
|
||||||
|
/// [dir] 위의 안내들 (없는 것은 null)
|
||||||
|
List<Widget?> notices(ExplorerPane pane, String dir);
|
||||||
|
Widget marksBar(ExplorerPane pane, {String? listDir});
|
||||||
|
void onMenu(ExplorerPane pane, FileEntry e, Offset at);
|
||||||
|
List<String> dragPaths(ExplorerPane pane, FileEntry e);
|
||||||
|
Future<void> onDrop(DragLoad load, int to, String dest);
|
||||||
|
void closeFilter(ExplorerPane pane);
|
||||||
|
|
||||||
|
/// 필터 글자가 바뀜 (화면을 다시 그림)
|
||||||
|
void filterChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 관리자 지시 10/20 · 10/21: 좁은 창의 열 - 숨길 열 · 날짜를 짧게 쓸지. 줄의 흐린 글과 같은 모양으로 날짜 글 폭을 잰다
|
||||||
|
/// (날짜가 "2026-10-2…" 로 잘리지 않게). 열 머리 · 줄이 같이 쓴다
|
||||||
|
void layoutPaneColumns(BuildContext context, ExplorerPane pane, ExplorerStyle look, double width,
|
||||||
|
{required double listTextScale, required bool marks}) {
|
||||||
|
final scale = MediaQuery.textScalerOf(context).scale(14) / 14 * listTextScale;
|
||||||
|
final style = DefaultTextStyle.of(context).style.merge(TextStyle(fontSize: look.fontSize - 2));
|
||||||
|
double measure(String t) =>
|
||||||
|
(TextPainter(text: TextSpan(text: t, style: style), textDirection: TextDirection.ltr, textScaler: TextScaler.linear(scale), maxLines: 1)
|
||||||
|
..layout())
|
||||||
|
.width;
|
||||||
|
final lay = columnLayout(look, width, marks: marks, textScale: scale, measure: measure);
|
||||||
|
pane.colHide = lay.$1;
|
||||||
|
pane.colDateShort = lay.$2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 76: 목록 줄의 키 = 항목 경로. 키가 없으면 줄 목록이 바뀔 때 (빠른 필터 · 펼치기 · 새로 고침) 같은 자리의 줄 위젯을
|
||||||
|
/// 다른 항목에 다시 써서, 방금 누른 파일 줄이 폴더 줄로 바뀌면 두 번 누르기 기다림이 그리는 중에 버려지고
|
||||||
|
/// 그 누르기가 다른 항목에 들어갔다 (디버그에서는 "setState() called during build")
|
||||||
|
Key paneRowKey(TreeRow r) =>
|
||||||
|
ValueKey<String>('${r.kind != 0 ? 'k${r.kind}:' : r.isRoot ? 'root:' : r.isUp ? 'up:' : ''}${r.entry.path}');
|
||||||
|
|
||||||
|
/// 76: 줄이 옮겨 가도 같은 줄 위젯을 찾게 (키 → 지금 자리)
|
||||||
|
int? Function(Key) _rowIndexOf(List<TreeRow> rows) {
|
||||||
|
Map<Key, int>? index;
|
||||||
|
return (key) => (index ??= {for (var i = 0; i < rows.length; i++) paneRowKey(rows[i]): i})[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 목록 위 안내들 (없으면 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 _rowOf(ExplorerController ctl, ExplorerSelect sel, ExplorerPaneParts parts, ExplorerPane pane, TreeRow row,
|
||||||
|
{bool list = false, bool compact = false}) =>
|
||||||
|
KeyedSubtree(
|
||||||
|
key: paneRowKey(row),
|
||||||
|
child: row.kind != 0
|
||||||
|
? ExplorerExtraRow(ctl: ctl, pane: pane, row: row)
|
||||||
|
: ExplorerRowView(
|
||||||
|
ctl: ctl,
|
||||||
|
sel: sel,
|
||||||
|
pane: pane,
|
||||||
|
row: row,
|
||||||
|
list: list,
|
||||||
|
compact: compact,
|
||||||
|
rowHeight: parts.rowHeight,
|
||||||
|
narrowSplit: parts.narrowSplit,
|
||||||
|
alwaysMarks: parts.alwaysMarks,
|
||||||
|
onMenu: parts.onMenu,
|
||||||
|
dragPaths: parts.dragPaths,
|
||||||
|
onDrop: parts.onDrop));
|
||||||
|
|
||||||
|
/// 트리 창 [index] (저장 장치 고르기 + 트리). [foldersOnly]: 폴더만 ("폴더 + 파일 목록" 배치의 왼쪽 · Rsync)
|
||||||
|
class ExplorerPaneView extends StatelessWidget {
|
||||||
|
const ExplorerPaneView({super.key, required this.ctl, required this.sel, required this.parts, required this.index, this.foldersOnly = false});
|
||||||
|
|
||||||
|
final ExplorerController ctl;
|
||||||
|
final ExplorerSelect sel;
|
||||||
|
final ExplorerPaneParts parts;
|
||||||
|
final int index;
|
||||||
|
final bool foldersOnly;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final pane = ctl.panes[index];
|
||||||
|
final rows = ctl.rows(pane, foldersOnly: foldersOnly);
|
||||||
|
final look = ctl.look;
|
||||||
|
// 38: 창 높이를 알아야 안내 상자의 높이를 제한할 수 있다
|
||||||
|
return LayoutBuilder(builder: (context, box) {
|
||||||
|
// 열을 그리는 창만 숨길 열을 정한다 ("폴더 + 파일 목록" 의 트리는 열이 없고 목록과 같은 창을 쓴다)
|
||||||
|
if (look.columns && !foldersOnly) {
|
||||||
|
layoutPaneColumns(context, pane, look, box.maxWidth, listTextScale: parts.listTextScale, marks: sel.selecting);
|
||||||
|
}
|
||||||
|
return parts.frame(index, [
|
||||||
|
parts.header(pane),
|
||||||
|
const Divider(height: 1),
|
||||||
|
// 보이는 수는 맞는 것만 (맞는 것이 든 위 폴더는 빼고)
|
||||||
|
if (pane.filterOpen)
|
||||||
|
ExplorerFilterBar(
|
||||||
|
pane: pane,
|
||||||
|
shown: rows.where((r) => !r.isRoot && isSameOrInside(r.entry.path, pane.current) && !samePath(r.entry.path, pane.current) && quickFilterMatch(vBasename(r.entry.path), pane.filter)).length,
|
||||||
|
onClose: () => parts.closeFilter(pane),
|
||||||
|
onChanged: parts.filterChanged),
|
||||||
|
// 38: 안내가 길어도 목록을 덮지 않게 (창 높이의 35% 까지, 넘치면 넘겨 봄)
|
||||||
|
?noticeArea(parts.notices(pane, pane.current), box.maxHeight),
|
||||||
|
if (look.columns && !foldersOnly)
|
||||||
|
ExplorerColumnsHeader(
|
||||||
|
style: look,
|
||||||
|
markColumn: sel.selecting,
|
||||||
|
hide: pane.colHide,
|
||||||
|
sortBy: ctl.sortOf(pane.current).$1,
|
||||||
|
sortDesc: ctl.sortOf(pane.current).$2,
|
||||||
|
onSort: (by) => ctl.sortByColumn(pane, by),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Stack(children: [
|
||||||
|
scaleListText(context, parts.listTextScale, ListView.builder(
|
||||||
|
controller: pane.scroll,
|
||||||
|
itemCount: rows.length,
|
||||||
|
itemExtent: parts.rowHeight,
|
||||||
|
// 76: 줄 위젯은 항목을 따라간다 ([paneRowKey])
|
||||||
|
itemBuilder: (_, k) => _rowOf(ctl, sel, parts, pane, rows[k], compact: foldersOnly),
|
||||||
|
findChildIndexCallback: _rowIndexOf(rows),
|
||||||
|
)),
|
||||||
|
// 읽는 중 (WebDAV 서버를 기다릴 때 등). 180: 목록 위에 겹쳐 그린다 (끼어들면 목록이 밀려 다음 것을 누르려다 엉뚱한 것을 누른다)
|
||||||
|
if (pane.loading.isNotEmpty) const Positioned(left: 0, right: 0, top: 0, child: LinearProgressIndicator(minHeight: 2)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
if ((sel.selecting || (parts.alwaysMarks && !pane.sel.isEmpty)) && !foldersOnly) parts.marksBar(pane),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "폴더 + 파일 목록" 배치의 오른쪽: 왼쪽 트리에서 고른 폴더의 내용 (맨 위 ".." = 상위 폴더)
|
||||||
|
class ExplorerListView extends StatelessWidget {
|
||||||
|
const ExplorerListView({super.key, required this.ctl, required this.sel, required this.parts, required this.pane});
|
||||||
|
|
||||||
|
final ExplorerController ctl;
|
||||||
|
final ExplorerSelect sel;
|
||||||
|
final ExplorerPaneParts parts;
|
||||||
|
final ExplorerPane pane;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final dir = pane.current;
|
||||||
|
final entries = pane.cache[dir] ?? const <FileEntry>[];
|
||||||
|
final up = !samePath(dir, pane.root);
|
||||||
|
final shown = sel.shown(pane, dir);
|
||||||
|
final hidden = entries.where((e) => ctl.appHidden(e.path)).length;
|
||||||
|
final rows = [
|
||||||
|
if (up) TreeRow(FileEntry(vDirname(dir), isDir: true, modified: DateTime(0)), 0, isUp: true),
|
||||||
|
for (final e in shown) TreeRow(e, 0),
|
||||||
|
// X-plore 숨기기: 맨 아래 [숨긴 항목 n개 보이기]
|
||||||
|
if (hidden > 0) TreeRow(FileEntry('\u0000hidden', isDir: false, modified: DateTime(0)), 0, kind: TreeRow.hiddenToggle, count: hidden),
|
||||||
|
];
|
||||||
|
final look = ctl.look;
|
||||||
|
// 38: 창 높이를 알아야 안내 상자의 높이를 제한할 수 있다
|
||||||
|
return LayoutBuilder(builder: (context, box) {
|
||||||
|
if (look.columns) layoutPaneColumns(context, pane, look, box.maxWidth, listTextScale: parts.listTextScale, marks: sel.selecting);
|
||||||
|
return parts.frame(0, [
|
||||||
|
Container(
|
||||||
|
height: 40,
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: Text(ctl.displayPath(dir), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: JjColors.textDim)),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
if (pane.filterOpen) ExplorerFilterBar(pane: pane, shown: shown.length, onClose: () => parts.closeFilter(pane), onChanged: parts.filterChanged),
|
||||||
|
?noticeArea(parts.notices(pane, dir), box.maxHeight),
|
||||||
|
if (look.columns)
|
||||||
|
ExplorerColumnsHeader(
|
||||||
|
style: look,
|
||||||
|
markColumn: sel.selecting,
|
||||||
|
hide: pane.colHide,
|
||||||
|
sortBy: ctl.sortOf(dir).$1,
|
||||||
|
sortDesc: ctl.sortOf(dir).$2,
|
||||||
|
onSort: (by) => ctl.sortByColumn(pane, by),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
// 180: 다시 읽는 동안은 앞의 목록을 그대로 (빙글이로 바꾸면 목록이 새로 만들어져 스크롤이 맨 위로 간다)
|
||||||
|
child: pane.loading.contains(dir) && !pane.cache.containsKey(dir)
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: entries.isEmpty && !up
|
||||||
|
? Center(child: Text(tr('비어 있음'), style: TextStyle(color: JjColors.textDim)))
|
||||||
|
: scaleListText(context, parts.listTextScale, ListView.builder(
|
||||||
|
controller: pane.listScroll,
|
||||||
|
itemCount: rows.length,
|
||||||
|
itemExtent: parts.rowHeight,
|
||||||
|
itemBuilder: (_, k) => _rowOf(ctl, sel, parts, pane, rows[k], list: true),
|
||||||
|
findChildIndexCallback: _rowIndexOf(rows),
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
if (sel.selecting) parts.marksBar(pane, listDir: dir),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 76: 창 위 줄 아래의 빠른 필터 칸 - 보이는 수 · 맞는 것이 없을 때 안내 · [✕]
|
||||||
|
class ExplorerFilterBar extends StatelessWidget {
|
||||||
|
const ExplorerFilterBar({super.key, required this.pane, required this.shown, required this.onClose, required this.onChanged});
|
||||||
|
|
||||||
|
final ExplorerPane pane;
|
||||||
|
final int shown;
|
||||||
|
final VoidCallback onClose, onChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final none = pane.filter.isNotEmpty && shown == 0;
|
||||||
|
return Container(
|
||||||
|
height: 40,
|
||||||
|
color: JjColors.panel,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
child: Row(children: [
|
||||||
|
Icon(Icons.filter_alt, size: 16, color: JjColors.accent),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
child: CallbackShortcuts(
|
||||||
|
bindings: {const SingleActivator(LogicalKeyboardKey.escape): onClose},
|
||||||
|
child: TextField(
|
||||||
|
controller: pane.filterCtl,
|
||||||
|
focusNode: pane.filterFocus,
|
||||||
|
style: const TextStyle(fontSize: 13),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
isDense: true,
|
||||||
|
border: InputBorder.none,
|
||||||
|
hintText: tr('지금 폴더에서 이름에 든 글자로 좁히기 (* · ? 패턴도 됨)'),
|
||||||
|
),
|
||||||
|
onChanged: (_) => onChanged(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (pane.filter.isNotEmpty)
|
||||||
|
Text(none ? tr('맞는 것이 없습니다') : trf('{0}개 보임', [shown]),
|
||||||
|
style: TextStyle(fontSize: 12, color: none ? JjColors.danger : JjColors.textDim)),
|
||||||
|
IconButton(
|
||||||
|
tooltip: keyHint(tr('필터 닫기 (모두 보기)'), 'Esc'),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
icon: const Icon(Icons.close, size: 16),
|
||||||
|
onPressed: onClose,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// X-plore 비교 c: 트리 맨 위 항목 줄 (다른 저장 장치 · 서버 · 즐겨찾기) - 누르면 그곳으로, 즐겨찾기 묶음은 펼치기 · 접기.
|
||||||
|
/// X-plore 숨기기: [숨긴 항목 n개 보이기] 줄도 (누르면 보이기 · 다시 숨기기)
|
||||||
|
class ExplorerExtraRow extends StatelessWidget {
|
||||||
|
const ExplorerExtraRow({super.key, required this.ctl, required this.pane, required this.row});
|
||||||
|
|
||||||
|
final ExplorerController ctl;
|
||||||
|
final ExplorerPane pane;
|
||||||
|
final TreeRow row;
|
||||||
|
|
||||||
|
Widget _hiddenToggle() {
|
||||||
|
final look = ctl.look;
|
||||||
|
final label = ctl.showAppHidden ? trf('숨긴 항목 {0}개 다시 숨기기', [row.count]) : trf('숨긴 항목 {0}개 보이기', [row.count]);
|
||||||
|
return Semantics(
|
||||||
|
button: true,
|
||||||
|
label: label,
|
||||||
|
excludeSemantics: true,
|
||||||
|
onTap: ctl.toggleAppHidden,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: ctl.toggleAppHidden,
|
||||||
|
child: Row(children: [
|
||||||
|
SizedBox(width: treeChevronWidth(defaultTargetPlatform) + 4),
|
||||||
|
Icon(ctl.showAppHidden ? Icons.visibility_off_outlined : Icons.visibility_outlined, size: look.iconSize * 0.8, color: JjColors.textDim),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(label,
|
||||||
|
maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: look.fontSize - 1, color: JjColors.textDim)),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (row.kind == TreeRow.hiddenToggle) return _hiddenToggle();
|
||||||
|
final look = ctl.look;
|
||||||
|
final path = row.entry.path;
|
||||||
|
final volume = ctl.volumes.firstWhere((v) => samePath(v.$1, path), orElse: () => (path, vBasename(path))).$2;
|
||||||
|
final fav = row.kind == TreeRow.favorite
|
||||||
|
? ctl.c.settings.folderBookmarks.firstWhere((b) => b.path == path, orElse: () => FolderBookmark(name: vBasename(path), path: path))
|
||||||
|
: null;
|
||||||
|
final (IconData icon, Color color, String label, bool? open) = switch (row.kind) {
|
||||||
|
TreeRow.otherRoot => (
|
||||||
|
isDav(path)
|
||||||
|
? Icons.cloud_outlined
|
||||||
|
: Platform.isWindows
|
||||||
|
? Icons.storage
|
||||||
|
: Icons.sd_storage_outlined,
|
||||||
|
JjColors.accent,
|
||||||
|
volume,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
TreeRow.favorites => (Icons.star, JjColors.amber, tr('즐겨찾기'), pane.favoritesOpen),
|
||||||
|
_ => (Icons.star_outline, JjColors.amber, fav!.name, null),
|
||||||
|
};
|
||||||
|
void tap() {
|
||||||
|
ctl
|
||||||
|
..active = ctl.panes.indexOf(pane)
|
||||||
|
..update();
|
||||||
|
if (row.kind == TreeRow.favorites) {
|
||||||
|
pane.favoritesOpen = !pane.favoritesOpen;
|
||||||
|
pane.changed();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unawaited(ctl.goTo(pane, path, fresh: true).then((_) => ctl.host.reveal(pane, path)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Semantics(
|
||||||
|
button: true,
|
||||||
|
label: label,
|
||||||
|
excludeSemantics: true,
|
||||||
|
onTap: tap,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: tap,
|
||||||
|
child: Row(children: [
|
||||||
|
SizedBox(width: look.indent * row.depth + 4),
|
||||||
|
SizedBox(
|
||||||
|
width: treeChevronWidth(defaultTargetPlatform),
|
||||||
|
child: open == null ? null : Icon(open ? Icons.expand_more : Icons.chevron_right, size: 18, color: JjColors.textDim),
|
||||||
|
),
|
||||||
|
Icon(icon, size: look.iconSize * 0.8, color: color),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Text(label, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: look.fontSize)),
|
||||||
|
if (fav != null)
|
||||||
|
Text(ctl.displayPath(path),
|
||||||
|
maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: look.fontSize - 3, color: JjColors.textDim)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../core/vfs.dart' show isDav;
|
||||||
|
import '../../l10n/tr.dart';
|
||||||
|
|
||||||
|
/// 약속 (E5d-3): 경로 입력 창 (37 · 146 · 179 · 190). 넣은 경로를 [checkEnteredPath] 로 고쳐 쓰고 확인해,
|
||||||
|
/// 갈 수 있으면 그 경로를 돌려주고 (창 닫힘), 아니면 칸 아래에 이유를 보인다 (창은 그대로 - 고쳐서 다시 넣게).
|
||||||
|
/// [android]: SMB 주소면 열지 않고 [SMB 서버 추가] · [WebDAV 서버 추가] 로 안내 ([PathDialog.addSmb] · [PathDialog.addDav] 를 돌려줌).
|
||||||
|
|
||||||
|
/// 179: SMB 경로 (smb:// · \\서버\공유)
|
||||||
|
bool looksLikeSmb(String path) => RegExp(r'^(smb|cifs)://', caseSensitive: false).hasMatch(path) || path.startsWith(r'\\');
|
||||||
|
|
||||||
|
/// 190: smb://서버/공유/폴더 (cifs:// 도) → \\서버\공유\폴더. SMB 주소가 아니면 null
|
||||||
|
String? smbToUnc(String path) {
|
||||||
|
final m = RegExp(r'^(smb|cifs)://(.+)$', caseSensitive: false).firstMatch(path.trim());
|
||||||
|
if (m == null) return null;
|
||||||
|
final parts = m.group(2)!.split(RegExp(r'[/\\]+')).where((s) => s.isNotEmpty).map(_decodeSmbPart).toList();
|
||||||
|
if (parts.isEmpty) return null;
|
||||||
|
return '\\\\${parts.join('\\')}';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// %20 같은 주소 글자는 풀고, 한글 등 그대로 쓴 글자는 그대로
|
||||||
|
String _decodeSmbPart(String s) {
|
||||||
|
try {
|
||||||
|
return Uri.decodeComponent(s);
|
||||||
|
} catch (_) {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String get smbNotSupported => tr('네트워크 공유 (SMB) 는 경로로 바로 열지 않습니다. [SMB 서버 추가] 로 서버를 넣으면 탭으로 열립니다');
|
||||||
|
|
||||||
|
/// 146: 넣은 경로를 고쳐 쓰기 · 확인. 갈 수 있으면 그 경로, 아니면 칸 아래에 보일 이유
|
||||||
|
Future<(String?, String?)> checkEnteredPath(String input, {required bool android}) async {
|
||||||
|
var path = input.trim().replaceAll('"', '');
|
||||||
|
if (path.isEmpty) return (null, null);
|
||||||
|
// 179: Android 는 네트워크 공유 (SMB) 를 아직 못 연다 - 폴더가 없다고 하지 않고 WebDAV 로 안내
|
||||||
|
if (android && 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('폴더가 없거나 열 수 없습니다'));
|
||||||
|
}
|
||||||
|
|
||||||
|
class PathDialog extends StatefulWidget {
|
||||||
|
const PathDialog({super.key, required this.initial, required this.android});
|
||||||
|
final String initial;
|
||||||
|
final bool android;
|
||||||
|
|
||||||
|
/// 창이 이것을 돌려주면 WebDAV 서버 추가 창을 연다
|
||||||
|
static const addDav = '\u0000webdav';
|
||||||
|
|
||||||
|
/// 88: 이것 + 넣은 주소를 돌려주면 SMB 서버 추가 창 (주소로 채움)
|
||||||
|
static const addSmb = '\u0000smb:';
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PathDialog> createState() => _PathDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PathDialogState extends State<PathDialog> {
|
||||||
|
late final _ctl = TextEditingController(text: widget.initial)
|
||||||
|
..selection = TextSelection(baseOffset: 0, extentOffset: widget.initial.length);
|
||||||
|
String? _error;
|
||||||
|
bool _checking = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_ctl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
setState(() => _checking = true);
|
||||||
|
final (path, error) = await checkEnteredPath(_ctl.text, android: widget.android);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_checking = false;
|
||||||
|
_error = error;
|
||||||
|
});
|
||||||
|
if (path != null) Navigator.pop(context, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => AlertDialog(
|
||||||
|
scrollable: true,
|
||||||
|
title: Text(Platform.isWindows ? tr('경로 입력 (예: D:\\영상 · \\\\NAS\\공유)') : tr('경로 입력')),
|
||||||
|
content: TextField(
|
||||||
|
controller: _ctl,
|
||||||
|
autofocus: true,
|
||||||
|
decoration: InputDecoration(hintText: tr('경로'), errorText: _error, errorMaxLines: 3),
|
||||||
|
// 고치기 시작하면 앞의 오류는 지운다
|
||||||
|
onChanged: (_) {
|
||||||
|
if (_error != null) setState(() => _error = null);
|
||||||
|
},
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
// 179: SMB 를 넣었으면 바로 WebDAV 서버 추가로
|
||||||
|
if (_error == smbNotSupported) ...[
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context, PathDialog.addDav), child: Text(tr('WebDAV 서버 추가'))),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, '${PathDialog.addSmb}${_ctl.text}'), child: Text(tr('SMB 서버 추가'))),
|
||||||
|
],
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context), child: Text(tr('취소'))),
|
||||||
|
FilledButton(onPressed: _checking ? null : _submit, child: Text(tr('확인'))),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../core/ftp.dart' show FtpRegistry;
|
||||||
|
import '../../core/sftp.dart' show SftpRegistry;
|
||||||
|
import '../../core/smb.dart' show SmbRegistry;
|
||||||
|
import '../../core/vfs.dart';
|
||||||
|
import '../../core/webdav.dart' show DavRegistry;
|
||||||
|
import '../ftp_settings.dart';
|
||||||
|
import '../sftp_settings.dart';
|
||||||
|
import '../smb_settings.dart';
|
||||||
|
import '../webdav_settings.dart';
|
||||||
|
import 'explorer_controller.dart';
|
||||||
|
import 'explorer_pane.dart';
|
||||||
|
import 'path_dialog.dart';
|
||||||
|
|
||||||
|
/// 약속 (E5d-3): 창에서 네트워크 서버 추가 · 고치기 · 경로 입력 흐름. 서버 설정 창은 webdav · sftp · ftp · smb_settings 가,
|
||||||
|
/// 경로 확인은 [PathDialog] 가 하고, 여기는 그 결과로 창을 옮길 뿐이다.
|
||||||
|
/// - [root] 가 null 이면 새 서버, 아니면 그 탭의 서버를 고친다. 저장하면 그 서버의 읽어 둔 목록 (· 호스트 키 · 인증서 오류) 을
|
||||||
|
/// 모든 창에서 버리고 [pane] 을 그 서버로 옮긴다. 취소하면 아무것도 바꾸지 않는다.
|
||||||
|
/// - 부를 때마다 [pane] 을 지금 창으로. 창이 닫힌 뒤 (Host 가 살아 있지 않음) 에는 옮기지 않는다.
|
||||||
|
class ExplorerServers {
|
||||||
|
ExplorerServers(this.ctl);
|
||||||
|
|
||||||
|
final ExplorerController ctl;
|
||||||
|
|
||||||
|
void _activate(ExplorerPane pane) {
|
||||||
|
ctl.active = ctl.panes.indexOf(pane);
|
||||||
|
ctl.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 경로를 직접 넣어 가기 (37). 146 · 37-2: 없는 폴더면 창을 닫지 않고 칸 아래에 알린다 (고쳐서 다시 넣을 수 있게)
|
||||||
|
Future<void> enterPath(BuildContext context, ExplorerPane pane, {required bool android}) async {
|
||||||
|
_activate(pane);
|
||||||
|
final path = await showDialog<String>(context: context, builder: (_) => PathDialog(initial: pane.current, android: android));
|
||||||
|
if (path == null || !ctl.host.alive || !context.mounted) return;
|
||||||
|
// 179: SMB 를 넣었다가 [WebDAV 서버 추가] 를 누름
|
||||||
|
if (path == PathDialog.addDav) return editDav(context, pane, null);
|
||||||
|
// 88: Android 에서 smb:// · \\서버\공유 를 넣고 [SMB 서버 추가]
|
||||||
|
if (path.startsWith(PathDialog.addSmb)) return editSmb(context, pane, null, from: path.substring(PathDialog.addSmb.length));
|
||||||
|
await ctl.goTo(pane, path, fresh: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WebDAV 서버 추가 (없으면 [root] 는 null) · 고치기 (탭 길게 누르기). 저장하면 그 서버로 간다.
|
||||||
|
Future<void> editDav(BuildContext context, ExplorerPane pane, String? root) async {
|
||||||
|
_activate(pane);
|
||||||
|
final old = root == null ? null : DavRegistry.server(DavPath.parse(root).server);
|
||||||
|
final saved = await editDavServer(context, ctl.c, old: old);
|
||||||
|
if (saved == null || !ctl.host.alive) return;
|
||||||
|
for (final x in ctl.panes) {
|
||||||
|
x.cache.removeWhere((k, _) => isDav(k) && DavPath.parse(k).server == saved.id);
|
||||||
|
}
|
||||||
|
await ctl.goTo(pane, '$davScheme${saved.id}/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 88: SFTP 서버 추가 (없으면 [root] 는 null) · 고치기 (탭 길게 누르기). 저장하면 그 서버로 간다.
|
||||||
|
Future<void> editSftp(BuildContext context, ExplorerPane pane, String? root) async {
|
||||||
|
_activate(pane);
|
||||||
|
final old = root == null ? null : SftpRegistry.server(DavPath.parse(root).server);
|
||||||
|
final saved = await editSftpServer(context, ctl.c, old: old);
|
||||||
|
if (saved == null || !ctl.host.alive) return;
|
||||||
|
for (final x in ctl.panes) {
|
||||||
|
x.cache.removeWhere((k, _) => isSftp(k) && DavPath.parse(k).server == saved.id);
|
||||||
|
x.hostKeyErrors.removeWhere((k, _) => isSftp(k) && DavPath.parse(k).server == saved.id);
|
||||||
|
}
|
||||||
|
await ctl.goTo(pane, '$sftpScheme${saved.id}/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// l: FTP 서버 추가 (없으면 [root] 는 null) · 고치기 (탭 길게 누르기). 저장하면 그 서버로 간다.
|
||||||
|
Future<void> editFtp(BuildContext context, ExplorerPane pane, String? root) async {
|
||||||
|
_activate(pane);
|
||||||
|
final old = root == null ? null : FtpRegistry.server(DavPath.parse(root).server);
|
||||||
|
final saved = await editFtpServer(context, ctl.c, old: old);
|
||||||
|
if (saved == null || !ctl.host.alive) return;
|
||||||
|
for (final x in ctl.panes) {
|
||||||
|
x.cache.removeWhere((k, _) => isFtp(k) && DavPath.parse(k).server == saved.id);
|
||||||
|
x.certErrors.removeWhere((k, _) => isFtp(k) && DavPath.parse(k).server == saved.id);
|
||||||
|
}
|
||||||
|
await ctl.goTo(pane, '$ftpScheme${saved.id}/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 88: SMB 서버 추가 (없으면 [root] 는 null, [from] 은 경로 입력에 넣은 주소) · 고치기 (탭 길게 누르기). 저장하면 그 서버로 간다.
|
||||||
|
Future<void> editSmb(BuildContext context, ExplorerPane pane, String? root, {String? from}) async {
|
||||||
|
_activate(pane);
|
||||||
|
final old = root == null ? null : SmbRegistry.server(DavPath.parse(root).server);
|
||||||
|
final saved = await editSmbServer(context, ctl.c, old: old, from: from);
|
||||||
|
if (saved == null || !ctl.host.alive) return;
|
||||||
|
for (final x in ctl.panes) {
|
||||||
|
x.cache.removeWhere((k, _) => isSmb(k) && DavPath.parse(k).server == saved.id);
|
||||||
|
}
|
||||||
|
await ctl.goTo(pane, '$smbScheme${saved.id}/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
|
export 'explorer/path_dialog.dart' show looksLikeSmb, smbToUnc;
|
||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart' show defaultTargetPlatform;
|
import 'package:flutter/gestures.dart' show kBackMouseButton, kForwardMouseButton;
|
||||||
import 'package:flutter/gestures.dart' show kBackMouseButton, kForwardMouseButton, PointerDeviceKind, PointerScrollEvent;
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
|
@ -27,7 +28,6 @@ import '../platform/android/android_storage.dart';
|
||||||
import '../platform/android/hwp_pdf_ffi.dart' show RhwpEngine;
|
import '../platform/android/hwp_pdf_ffi.dart' show RhwpEngine;
|
||||||
import 'app_actions.dart';
|
import 'app_actions.dart';
|
||||||
import 'explorer_look.dart';
|
import 'explorer_look.dart';
|
||||||
import 'key_hint.dart';
|
|
||||||
import 'monitor_page.dart';
|
import 'monitor_page.dart';
|
||||||
import 'player_page.dart';
|
import 'player_page.dart';
|
||||||
import 'rsync_setup.dart';
|
import 'rsync_setup.dart';
|
||||||
|
|
@ -43,8 +43,6 @@ import 'progress_dialog.dart';
|
||||||
import 'sync_preview_view.dart';
|
import 'sync_preview_view.dart';
|
||||||
import 'sftp_settings.dart';
|
import 'sftp_settings.dart';
|
||||||
import 'ftp_settings.dart';
|
import 'ftp_settings.dart';
|
||||||
import 'smb_settings.dart';
|
|
||||||
import 'webdav_settings.dart';
|
|
||||||
import 'reader_page.dart';
|
import 'reader_page.dart';
|
||||||
import '../app/component_store.dart';
|
import '../app/component_store.dart';
|
||||||
import 'zip_page.dart';
|
import 'zip_page.dart';
|
||||||
|
|
@ -58,7 +56,6 @@ import '../core/thumb_cache.dart' show thumbMemoryForgetters;
|
||||||
import '../core/shizuku.dart';
|
import '../core/shizuku.dart';
|
||||||
import 'vault_page.dart' show openVault;
|
import 'vault_page.dart' show openVault;
|
||||||
import 'app_manager_page.dart' show openAppManager;
|
import 'app_manager_page.dart' show openAppManager;
|
||||||
import 'dlna_page.dart' show openDlna;
|
|
||||||
import 'explorer_keys.dart' show defaultExplorerKeys, effectiveExplorerKeys;
|
import 'explorer_keys.dart' show defaultExplorerKeys, effectiveExplorerKeys;
|
||||||
import 'sqlite_viewer.dart' show openSqliteViewer, sqliteExtensions;
|
import 'sqlite_viewer.dart' show openSqliteViewer, sqliteExtensions;
|
||||||
import 'quick_look.dart';
|
import 'quick_look.dart';
|
||||||
|
|
@ -94,7 +91,10 @@ import 'explorer/explorer_host.dart';
|
||||||
import 'explorer/explorer_pane.dart';
|
import 'explorer/explorer_pane.dart';
|
||||||
import 'explorer/file_thumbs.dart';
|
import 'explorer/file_thumbs.dart';
|
||||||
import 'explorer/explorer_select.dart';
|
import 'explorer/explorer_select.dart';
|
||||||
import 'explorer/row_view.dart';
|
import 'explorer/path_dialog.dart';
|
||||||
|
import 'explorer/pane_header.dart';
|
||||||
|
import 'explorer/pane_view.dart';
|
||||||
|
import 'explorer/server_flows.dart';
|
||||||
|
|
||||||
export '../core/explorer/tree_rows.dart' show globToRegExp, quickFilterMatch;
|
export '../core/explorer/tree_rows.dart' show globToRegExp, quickFilterMatch;
|
||||||
export '../core/name_clash.dart' show NameClash, clashCompareText, fileDateText;
|
export '../core/name_clash.dart' show NameClash, clashCompareText, fileDateText;
|
||||||
|
|
@ -111,7 +111,6 @@ export '../core/name_clash.dart' show NameClash, clashCompareText, fileDateText;
|
||||||
/// 가운데 → (왼쪽 → 오른쪽) · ← (오른쪽 → 왼쪽) · ⇄ (둘 다 함께) 로 rsync, [모니터링] 에 실행한 것이 남는다.
|
/// 가운데 → (왼쪽 → 오른쪽) · ← (오른쪽 → 왼쪽) · ⇄ (둘 다 함께) 로 rsync, [모니터링] 에 실행한 것이 남는다.
|
||||||
/// 202 · 221: 창 위 줄을 [← → ★] · 탭 · [WebDAV · 경로 입력] 으로 펴는 가장 작은 창 폭. 이보다 좁으면 끝 버튼은 [⋮] 하나 -
|
/// 202 · 221: 창 위 줄을 [← → ★] · 탭 · [WebDAV · 경로 입력] 으로 펴는 가장 작은 창 폭. 이보다 좁으면 끝 버튼은 [⋮] 하나 -
|
||||||
/// 폰 세로 (약 400) 에서 탭 칸이 130px 남짓이라 두 번째 저장소 (SD 카드) 가 잘려 보였다
|
/// 폰 세로 (약 400) 에서 탭 칸이 130px 남짓이라 두 번째 저장소 (SD 카드) 가 잘려 보였다
|
||||||
const double paneHeaderWideWidth = 480;
|
|
||||||
|
|
||||||
/// 191: Rsync 화면이 좌우 두 창 + 가운데 버튼 줄로 보이는 가장 작은 폭 (이보다 좁으면 창 하나씩)
|
/// 191: Rsync 화면이 좌우 두 창 + 가운데 버튼 줄로 보이는 가장 작은 폭 (이보다 좁으면 창 하나씩)
|
||||||
const double rsyncTwoPaneMinWidth = 560;
|
const double rsyncTwoPaneMinWidth = 560;
|
||||||
|
|
@ -152,7 +151,8 @@ class ExplorerPage extends StatefulWidget {
|
||||||
|
|
||||||
/// 시험: 경로 입력 창이 넣은 경로를 고쳐 쓰고 확인하는 것 (갈 경로, 칸 아래 이유)
|
/// 시험: 경로 입력 창이 넣은 경로를 고쳐 쓰고 확인하는 것 (갈 경로, 칸 아래 이유)
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
static Future<(String?, String?)> checkPathForTest(String input) => _ExplorerPageState.checkEnteredPath(input);
|
static Future<(String?, String?)> checkPathForTest(String input) =>
|
||||||
|
checkEnteredPath(input, android: debugAndroidPaths ?? Platform.isAndroid);
|
||||||
|
|
||||||
static const routeName = 'explorer';
|
static const routeName = 'explorer';
|
||||||
static const rsyncRouteName = 'rsync';
|
static const rsyncRouteName = 'rsync';
|
||||||
|
|
@ -267,7 +267,7 @@ enum ExplorerButton {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ExplorerPageState extends State<ExplorerPage> implements ExplorerHost {
|
class _ExplorerPageState extends State<ExplorerPage> implements ExplorerHost, ExplorerPaneParts {
|
||||||
AppController get c => widget.c;
|
AppController get c => widget.c;
|
||||||
|
|
||||||
/// 탐색기의 상태 · 화면 밖의 일 (E5d-1) - 화면은 부르기만. 화면에 부탁할 것은 [ExplorerHost] 로 이 상태가 해 준다
|
/// 탐색기의 상태 · 화면 밖의 일 (E5d-1) - 화면은 부르기만. 화면에 부탁할 것은 [ExplorerHost] 로 이 상태가 해 준다
|
||||||
|
|
@ -545,16 +545,6 @@ class _ExplorerPageState extends State<ExplorerPage> implements ExplorerHost {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 목록 위 안내들 (없으면 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(
|
Widget _notice(
|
||||||
{required IconData icon,
|
{required IconData icon,
|
||||||
|
|
@ -677,7 +667,7 @@ class _ExplorerPageState extends State<ExplorerPage> implements ExplorerHost {
|
||||||
},
|
},
|
||||||
child: Text(tr('지문 확인하고 연결')),
|
child: Text(tr('지문 확인하고 연결')),
|
||||||
),
|
),
|
||||||
TextButton(onPressed: () => _editFtp(pane, pane.root), child: Text(tr('서버 설정 고치기'))),
|
TextButton(onPressed: () => _servers.editFtp(context, pane, pane.root), child: Text(tr('서버 설정 고치기'))),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -701,7 +691,7 @@ class _ExplorerPageState extends State<ExplorerPage> implements ExplorerHost {
|
||||||
},
|
},
|
||||||
child: Text(tr('지문 확인하고 연결')),
|
child: Text(tr('지문 확인하고 연결')),
|
||||||
),
|
),
|
||||||
TextButton(onPressed: () => _editSftp(pane, pane.root), child: Text(tr('서버 설정 고치기'))),
|
TextButton(onPressed: () => _servers.editSftp(context, pane, pane.root), child: Text(tr('서버 설정 고치기'))),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -751,12 +741,12 @@ class _ExplorerPageState extends State<ExplorerPage> implements ExplorerHost {
|
||||||
if (dav && !locked && !isShizuku(where))
|
if (dav && !locked && !isShizuku(where))
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => sftp != null
|
onPressed: () => sftp != null
|
||||||
? _editSftp(pane, pane.root)
|
? _servers.editSftp(context, pane, pane.root)
|
||||||
: smb != null
|
: smb != null
|
||||||
? _editSmb(pane, pane.root)
|
? _servers.editSmb(context, pane, pane.root)
|
||||||
: ftp != null
|
: ftp != null
|
||||||
? _editFtp(pane, pane.root)
|
? _servers.editFtp(context, pane, pane.root)
|
||||||
: _editDav(pane, pane.root),
|
: _servers.editDav(context, pane, pane.root),
|
||||||
child: Text(tr('서버 설정 고치기'))),
|
child: Text(tr('서버 설정 고치기'))),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
@ -897,89 +887,49 @@ class _ExplorerPageState extends State<ExplorerPage> implements ExplorerHost {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _hiddenToggleRow(TreeRow row) {
|
// ───────── 창 보기 (E5d-3: ExplorerPaneView · ExplorerListView 가 부르는 것) ─────────
|
||||||
final look = _ctl.look;
|
|
||||||
final label = _ctl.showAppHidden ? trf('숨긴 항목 {0}개 다시 숨기기', [row.count]) : trf('숨긴 항목 {0}개 보이기', [row.count]);
|
|
||||||
return Semantics(
|
|
||||||
button: true,
|
|
||||||
label: label,
|
|
||||||
excludeSemantics: true,
|
|
||||||
onTap: _ctl.toggleAppHidden,
|
|
||||||
child: InkWell(
|
|
||||||
onTap: _ctl.toggleAppHidden,
|
|
||||||
child: Row(children: [
|
|
||||||
SizedBox(width: treeChevronWidth(defaultTargetPlatform) + 4),
|
|
||||||
Icon(_ctl.showAppHidden ? Icons.visibility_off_outlined : Icons.visibility_outlined, size: look.iconSize * 0.8, color: JjColors.textDim),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: Text(label,
|
|
||||||
maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: look.fontSize - 1, color: JjColors.textDim)),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// X-plore 비교 c: 트리 맨 위 항목 줄 (다른 저장 장치 · 서버 · 즐겨찾기) - 누르면 그곳으로, 즐겨찾기 묶음은 펼치기 · 접기
|
@override
|
||||||
Widget _extraRow(ExplorerPane pane, TreeRow row) {
|
double get rowHeight => _rowH();
|
||||||
if (row.kind == TreeRow.hiddenToggle) return _hiddenToggleRow(row);
|
@override
|
||||||
final look = _ctl.look;
|
double get listTextScale => _listTextScale;
|
||||||
final path = row.entry.path;
|
@override
|
||||||
final volume = _ctl.volumes.firstWhere((v) => samePath(v.$1, path), orElse: () => (path, vBasename(path))).$2;
|
bool get narrowSplit => _narrowSplit;
|
||||||
final fav = row.kind == TreeRow.favorite
|
@override
|
||||||
? c.settings.folderBookmarks.firstWhere((b) => b.path == path, orElse: () => FolderBookmark(name: vBasename(path), path: path))
|
bool get alwaysMarks => _alwaysMarks;
|
||||||
: null;
|
@override
|
||||||
final (IconData icon, Color color, String label, bool? open) = switch (row.kind) {
|
Widget frame(int i, List<Widget> children) => _frame(i, children);
|
||||||
TreeRow.otherRoot => (
|
@override
|
||||||
isDav(path)
|
Widget header(ExplorerPane pane) => ExplorerPaneHeader(
|
||||||
? Icons.cloud_outlined
|
ctl: _ctl,
|
||||||
: Platform.isWindows
|
servers: _servers,
|
||||||
? Icons.storage
|
pane: pane,
|
||||||
: Icons.sd_storage_outlined,
|
android: ExplorerPage.debugAndroidPaths ?? Platform.isAndroid,
|
||||||
JjColors.accent,
|
onBookmarks: () => _bookmarks(pane),
|
||||||
volume,
|
onOpenFilter: () => _openFilter(pane),
|
||||||
false,
|
onCloseFilter: () => _closeFilter(pane),
|
||||||
),
|
onOpenRoot: () => _openRoot(pane),
|
||||||
TreeRow.favorites => (Icons.star, JjColors.amber, tr('즐겨찾기'), pane.favoritesOpen),
|
onOpenShizuku: () => _openShizuku(pane),
|
||||||
_ => (Icons.star_outline, JjColors.amber, fav!.name, null),
|
onReconnect: (path) => _reconnect(pane, path),
|
||||||
};
|
);
|
||||||
void tap() {
|
@override
|
||||||
setState(() => _ctl.active = _ctl.panes.indexOf(pane));
|
List<Widget?> notices(ExplorerPane pane, String dir) =>
|
||||||
if (row.kind == TreeRow.favorites) {
|
[_shizukuNotice(pane), _accessNotice(pane), _errorNotice(pane, dir), _partialNotice(pane, dir)];
|
||||||
pane.favoritesOpen = !pane.favoritesOpen;
|
@override
|
||||||
pane.changed();
|
Widget marksBar(ExplorerPane pane, {String? listDir}) => _marksBar(pane, listDir: listDir);
|
||||||
return;
|
@override
|
||||||
}
|
void onMenu(ExplorerPane pane, FileEntry e, Offset at) => _menu(pane, e, at);
|
||||||
unawaited(_ctl.goTo(pane, path, fresh: true).then((_) => _reveal(pane, path)));
|
@override
|
||||||
}
|
List<String> dragPaths(ExplorerPane pane, FileEntry e) => _dragPaths(pane, e);
|
||||||
|
@override
|
||||||
|
Future<void> onDrop(DragLoad load, int to, String dest) => _onDrop(load, to, dest);
|
||||||
|
@override
|
||||||
|
void closeFilter(ExplorerPane pane) => _closeFilter(pane);
|
||||||
|
@override
|
||||||
|
void filterChanged() => setState(() {});
|
||||||
|
|
||||||
return Semantics(
|
/// 서버 추가 · 고치기 · 경로 입력 (E5d-3)
|
||||||
button: true,
|
late final _servers = ExplorerServers(_ctl);
|
||||||
label: label,
|
|
||||||
excludeSemantics: true,
|
|
||||||
onTap: tap,
|
|
||||||
child: InkWell(
|
|
||||||
onTap: tap,
|
|
||||||
child: Row(children: [
|
|
||||||
SizedBox(width: look.indent * row.depth + 4),
|
|
||||||
SizedBox(
|
|
||||||
width: treeChevronWidth(defaultTargetPlatform),
|
|
||||||
child: open == null ? null : Icon(open ? Icons.expand_more : Icons.chevron_right, size: 18, color: JjColors.textDim),
|
|
||||||
),
|
|
||||||
Icon(icon, size: look.iconSize * 0.8, color: color),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: Column(mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
||||||
Text(label, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: look.fontSize)),
|
|
||||||
if (fav != null)
|
|
||||||
Text(_ctl.displayPath(path),
|
|
||||||
maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: look.fontSize - 3, color: JjColors.textDim)),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ───────── 누르기 · 고르기 (E5d-2: ExplorerSelect) ─────────
|
// ───────── 누르기 · 고르기 (E5d-2: ExplorerSelect) ─────────
|
||||||
|
|
||||||
|
|
@ -3517,14 +3467,14 @@ class _ExplorerPageState extends State<ExplorerPage> implements ExplorerHost {
|
||||||
return Column(children: [
|
return Column(children: [
|
||||||
_rsyncPaneSwitch(),
|
_rsyncPaneSwitch(),
|
||||||
?bar,
|
?bar,
|
||||||
Expanded(child: keep(i, _paneView(i, foldersOnly: true))),
|
Expanded(child: keep(i, ExplorerPaneView(ctl: _ctl, sel: _sel, parts: this, index: i, foldersOnly: true))),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
final children = <Widget>[
|
final children = <Widget>[
|
||||||
if (dual) ...[
|
if (dual) ...[
|
||||||
Expanded(child: keep(0, _paneView(0, foldersOnly: widget.rsync))),
|
Expanded(child: keep(0, ExplorerPaneView(ctl: _ctl, sel: _sel, parts: this, index: 0, foldersOnly: widget.rsync))),
|
||||||
if (middle) bar,
|
if (middle) bar,
|
||||||
Expanded(child: keep(1, _paneView(1, foldersOnly: widget.rsync))),
|
Expanded(child: keep(1, ExplorerPaneView(ctl: _ctl, sel: _sel, parts: this, index: 1, foldersOnly: widget.rsync))),
|
||||||
if (edge) bar,
|
if (edge) bar,
|
||||||
] else if (split) ...[
|
] else if (split) ...[
|
||||||
// 왼쪽 폴더 트리 (좁게) · 오른쪽 그 폴더의 파일 목록. 좁은 화면은 트리에 최소 폭 (130px 또는 버튼 줄 (72) 을 뺀 폭의 42%) -
|
// 왼쪽 폴더 트리 (좁게) · 오른쪽 그 폴더의 파일 목록. 좁은 화면은 트리에 최소 폭 (130px 또는 버튼 줄 (72) 을 뺀 폭의 42%) -
|
||||||
|
|
@ -3532,18 +3482,18 @@ class _ExplorerPageState extends State<ExplorerPage> implements ExplorerHost {
|
||||||
if (_narrowSplit)
|
if (_narrowSplit)
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: math.max(130.0, (box.maxWidth - (middle || edge ? 72 : 0)) * 0.42),
|
width: math.max(130.0, (box.maxWidth - (middle || edge ? 72 : 0)) * 0.42),
|
||||||
child: keep(0, _paneView(0, foldersOnly: true)))
|
child: keep(0, ExplorerPaneView(ctl: _ctl, sel: _sel, parts: this, index: 0, foldersOnly: true)))
|
||||||
else
|
else
|
||||||
Expanded(flex: 2, child: keep(0, _paneView(0, foldersOnly: true))),
|
Expanded(flex: 2, child: keep(0, ExplorerPaneView(ctl: _ctl, sel: _sel, parts: this, index: 0, foldersOnly: true))),
|
||||||
if (middle) bar,
|
if (middle) bar,
|
||||||
Expanded(flex: 3, child: keep(2, _listView(_ctl.panes[0]))),
|
Expanded(flex: 3, child: keep(2, ExplorerListView(ctl: _ctl, sel: _sel, parts: this, pane: _ctl.panes[0]))),
|
||||||
if (edge) bar,
|
if (edge) bar,
|
||||||
] else ...[
|
] else ...[
|
||||||
if (middle) bar,
|
if (middle) bar,
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _hiddenTarget
|
child: _hiddenTarget
|
||||||
? Column(children: [ListenableBuilder(listenable: _ctl.panes[1], builder: (_, _) => _targetBar()), Expanded(child: keep(0, _paneView(0)))])
|
? Column(children: [ListenableBuilder(listenable: _ctl.panes[1], builder: (_, _) => _targetBar()), Expanded(child: keep(0, ExplorerPaneView(ctl: _ctl, sel: _sel, parts: this, index: 0)))])
|
||||||
: keep(0, _paneView(0))),
|
: keep(0, ExplorerPaneView(ctl: _ctl, sel: _sel, parts: this, index: 0))),
|
||||||
if (edge) bar,
|
if (edge) bar,
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
@ -3801,135 +3751,6 @@ class _ExplorerPageState extends State<ExplorerPage> implements ExplorerHost {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 트리 창 (저장 장치 고르기 + 트리)
|
|
||||||
/// 관리자 지시 10/20 · 10/21: 좁은 창의 열 - 숨길 열 · 날짜를 짧게 쓸지. 줄의 흐린 글과 같은 모양으로 날짜 글 폭을 잰다
|
|
||||||
/// (날짜가 "2026-10-2…" 로 잘리지 않게). 열 머리 · 줄이 같이 쓴다
|
|
||||||
void _layoutColumns(BuildContext context, ExplorerPane pane, ExplorerStyle look, double width) {
|
|
||||||
final scale = MediaQuery.textScalerOf(context).scale(14) / 14 * _listTextScale;
|
|
||||||
final style = DefaultTextStyle.of(context).style.merge(TextStyle(fontSize: look.fontSize - 2));
|
|
||||||
double measure(String t) =>
|
|
||||||
(TextPainter(text: TextSpan(text: t, style: style), textDirection: TextDirection.ltr, textScaler: TextScaler.linear(scale), maxLines: 1)
|
|
||||||
..layout())
|
|
||||||
.width;
|
|
||||||
final lay = columnLayout(look, width, marks: _sel.selecting, textScale: scale, measure: measure);
|
|
||||||
pane.colHide = lay.$1;
|
|
||||||
pane.colDateShort = lay.$2;
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _paneView(int i, {bool foldersOnly = false}) {
|
|
||||||
final pane = _ctl.panes[i];
|
|
||||||
final rows = _ctl.rows(pane, foldersOnly: foldersOnly);
|
|
||||||
final look = _ctl.look;
|
|
||||||
// 38: 창 높이를 알아야 안내 상자의 높이를 제한할 수 있다
|
|
||||||
return LayoutBuilder(builder: (context, box) {
|
|
||||||
// 열을 그리는 창만 숨길 열을 정한다 ("폴더 + 파일 목록" 의 트리는 열이 없고 목록과 같은 창을 쓴다)
|
|
||||||
if (look.columns && !foldersOnly) _layoutColumns(context, pane, look, box.maxWidth);
|
|
||||||
return _frame(i, [
|
|
||||||
_paneHeader(pane),
|
|
||||||
const Divider(height: 1),
|
|
||||||
// 보이는 수는 맞는 것만 (맞는 것이 든 위 폴더는 빼고)
|
|
||||||
if (pane.filterOpen)
|
|
||||||
_filterBar(pane, rows.where((r) => !r.isRoot && isSameOrInside(r.entry.path, pane.current) && !samePath(r.entry.path, pane.current) && quickFilterMatch(vBasename(r.entry.path), pane.filter)).length),
|
|
||||||
// 38: 안내가 길어도 목록을 덮지 않게 (창 높이의 35% 까지, 넘치면 넘겨 봄)
|
|
||||||
?_noticeArea([_shizukuNotice(pane), _accessNotice(pane), _errorNotice(pane, pane.current), _partialNotice(pane, pane.current)], box.maxHeight),
|
|
||||||
if (look.columns && !foldersOnly)
|
|
||||||
ExplorerColumnsHeader(
|
|
||||||
style: look,
|
|
||||||
markColumn: _sel.selecting,
|
|
||||||
hide: pane.colHide,
|
|
||||||
sortBy: _ctl.sortOf(pane.current).$1,
|
|
||||||
sortDesc: _ctl.sortOf(pane.current).$2,
|
|
||||||
onSort: (by) => _ctl.sortByColumn(pane, by),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: Stack(children: [
|
|
||||||
scaleListText(context, _listTextScale, ListView.builder(
|
|
||||||
controller: pane.scroll,
|
|
||||||
itemCount: rows.length,
|
|
||||||
itemExtent: _rowH(),
|
|
||||||
// 76: 줄 위젯은 항목을 따라간다 (아래 [_rowKey])
|
|
||||||
itemBuilder: (_, k) => KeyedSubtree(
|
|
||||||
key: _rowKey(rows[k]),
|
|
||||||
child: rows[k].kind != 0 ? _extraRow(pane, rows[k]) : ExplorerRowView(ctl: _ctl, sel: _sel, pane: pane, row: rows[k], compact: foldersOnly, rowHeight: _rowH(), narrowSplit: _narrowSplit, alwaysMarks: _alwaysMarks, onMenu: _menu, dragPaths: _dragPaths, onDrop: _onDrop)),
|
|
||||||
findChildIndexCallback: _rowIndexOf(rows),
|
|
||||||
)),
|
|
||||||
// 읽는 중 (WebDAV 서버를 기다릴 때 등). 180: 목록 위에 겹쳐 그린다 (끼어들면 목록이 밀려 다음 것을 누르려다 엉뚱한 것을 누른다)
|
|
||||||
if (pane.loading.isNotEmpty) const Positioned(left: 0, right: 0, top: 0, child: LinearProgressIndicator(minHeight: 2)),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
if ((_sel.selecting || (_alwaysMarks && !pane.sel.isEmpty)) && !foldersOnly) _marksBar(pane),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 76: 목록 줄의 키 = 항목 경로. 키가 없으면 줄 목록이 바뀔 때 (빠른 필터 · 펼치기 · 새로 고침) 같은 자리의 줄 위젯을
|
|
||||||
/// 다른 항목에 다시 써서, 방금 누른 파일 줄이 폴더 줄로 바뀌면 두 번 누르기 기다림이 그리는 중에 버려지고
|
|
||||||
/// 그 누르기가 다른 항목에 들어갔다 (디버그에서는 "setState() called during build")
|
|
||||||
static Key _rowKey(TreeRow r) =>
|
|
||||||
ValueKey<String>('${r.kind != 0 ? 'k${r.kind}:' : r.isRoot ? 'root:' : r.isUp ? 'up:' : ''}${r.entry.path}');
|
|
||||||
|
|
||||||
/// 76: 줄이 옮겨 가도 같은 줄 위젯을 찾게 (키 → 지금 자리)
|
|
||||||
static int? Function(Key) _rowIndexOf(List<TreeRow> rows) {
|
|
||||||
Map<Key, int>? index;
|
|
||||||
return (key) => (index ??= {for (var i = 0; i < rows.length; i++) _rowKey(rows[i]): i})[key];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// "폴더 + 파일 목록" 배치의 오른쪽: 왼쪽 트리에서 고른 폴더의 내용 (맨 위 ".." = 상위 폴더)
|
|
||||||
Widget _listView(ExplorerPane pane) {
|
|
||||||
final dir = pane.current;
|
|
||||||
final entries = pane.cache[dir] ?? const <FileEntry>[];
|
|
||||||
final up = !samePath(dir, pane.root);
|
|
||||||
final shown = _sel.shown(pane, dir);
|
|
||||||
final hidden = entries.where((e) => _ctl.appHidden(e.path)).length;
|
|
||||||
final rows = [
|
|
||||||
if (up) TreeRow(FileEntry(vDirname(dir), isDir: true, modified: DateTime(0)), 0, isUp: true),
|
|
||||||
for (final e in shown) TreeRow(e, 0),
|
|
||||||
// X-plore 숨기기: 맨 아래 [숨긴 항목 n개 보이기]
|
|
||||||
if (hidden > 0) TreeRow(FileEntry('\u0000hidden', isDir: false, modified: DateTime(0)), 0, kind: TreeRow.hiddenToggle, count: hidden),
|
|
||||||
];
|
|
||||||
final look = _ctl.look;
|
|
||||||
// 38: 창 높이를 알아야 안내 상자의 높이를 제한할 수 있다
|
|
||||||
return LayoutBuilder(builder: (context, box) {
|
|
||||||
if (look.columns) _layoutColumns(context, pane, look, box.maxWidth);
|
|
||||||
return _frame(0, [
|
|
||||||
Container(
|
|
||||||
height: 40,
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
||||||
child: Text(_ctl.displayPath(dir), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: JjColors.textDim)),
|
|
||||||
),
|
|
||||||
const Divider(height: 1),
|
|
||||||
if (pane.filterOpen) _filterBar(pane, shown.length),
|
|
||||||
?_noticeArea([_shizukuNotice(pane), _accessNotice(pane), _errorNotice(pane, dir), _partialNotice(pane, dir)], box.maxHeight),
|
|
||||||
if (look.columns)
|
|
||||||
ExplorerColumnsHeader(
|
|
||||||
style: look,
|
|
||||||
markColumn: _sel.selecting,
|
|
||||||
hide: pane.colHide,
|
|
||||||
sortBy: _ctl.sortOf(dir).$1,
|
|
||||||
sortDesc: _ctl.sortOf(dir).$2,
|
|
||||||
onSort: (by) => _ctl.sortByColumn(pane, by),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
// 180: 다시 읽는 동안은 앞의 목록을 그대로 (빙글이로 바꾸면 목록이 새로 만들어져 스크롤이 맨 위로 간다)
|
|
||||||
child: pane.loading.contains(dir) && !pane.cache.containsKey(dir)
|
|
||||||
? const Center(child: CircularProgressIndicator())
|
|
||||||
: entries.isEmpty && !up
|
|
||||||
? Center(child: Text(tr('비어 있음'), style: TextStyle(color: JjColors.textDim)))
|
|
||||||
: scaleListText(context, _listTextScale, ListView.builder(
|
|
||||||
controller: pane.listScroll,
|
|
||||||
itemCount: rows.length,
|
|
||||||
itemExtent: _rowH(),
|
|
||||||
itemBuilder: (_, k) => KeyedSubtree(
|
|
||||||
key: _rowKey(rows[k]), child: rows[k].kind != 0 ? _extraRow(pane, rows[k]) : ExplorerRowView(ctl: _ctl, sel: _sel, pane: pane, row: rows[k], list: true, rowHeight: _rowH(), narrowSplit: _narrowSplit, alwaysMarks: _alwaysMarks, onMenu: _menu, dragPaths: _dragPaths, onDrop: _onDrop)),
|
|
||||||
findChildIndexCallback: _rowIndexOf(rows),
|
|
||||||
)),
|
|
||||||
),
|
|
||||||
if (_sel.selecting) _marksBar(pane, listDir: dir),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 76: 빠른 필터 열기 ([text] 가 있으면 그 글자로 시작)
|
/// 76: 빠른 필터 열기 ([text] 가 있으면 그 글자로 시작)
|
||||||
void _openFilter(ExplorerPane pane, [String? text]) {
|
void _openFilter(ExplorerPane pane, [String? text]) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
@ -4075,368 +3896,6 @@ class _ExplorerPageState extends State<ExplorerPage> implements ExplorerHost {
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 76: 창 위 줄 아래의 빠른 필터 칸 - 보이는 수 · 맞는 것이 없을 때 안내 · [✕]
|
|
||||||
Widget _filterBar(ExplorerPane pane, int shown) {
|
|
||||||
final none = pane.filter.isNotEmpty && shown == 0;
|
|
||||||
return Container(
|
|
||||||
height: 40,
|
|
||||||
color: JjColors.panel,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
||||||
child: Row(children: [
|
|
||||||
Icon(Icons.filter_alt, size: 16, color: JjColors.accent),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Expanded(
|
|
||||||
child: CallbackShortcuts(
|
|
||||||
bindings: {const SingleActivator(LogicalKeyboardKey.escape): () => _closeFilter(pane)},
|
|
||||||
child: TextField(
|
|
||||||
controller: pane.filterCtl,
|
|
||||||
focusNode: pane.filterFocus,
|
|
||||||
style: const TextStyle(fontSize: 13),
|
|
||||||
decoration: InputDecoration(
|
|
||||||
isDense: true,
|
|
||||||
border: InputBorder.none,
|
|
||||||
hintText: tr('지금 폴더에서 이름에 든 글자로 좁히기 (* · ? 패턴도 됨)'),
|
|
||||||
),
|
|
||||||
onChanged: (_) => setState(() {}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (pane.filter.isNotEmpty)
|
|
||||||
Text(none ? tr('맞는 것이 없습니다') : trf('{0}개 보임', [shown]),
|
|
||||||
style: TextStyle(fontSize: 12, color: none ? JjColors.danger : JjColors.textDim)),
|
|
||||||
IconButton(
|
|
||||||
tooltip: keyHint(tr('필터 닫기 (모두 보기)'), 'Esc'),
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
icon: const Icon(Icons.close, size: 16),
|
|
||||||
onPressed: () => _closeFilter(pane),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 창 위쪽: 저장 장치 고르기
|
|
||||||
/// 창 위 줄: [뒤로 · 앞으로 · 즐겨찾기] · 저장 장치 탭 (넘치면 옆으로 밀기) · [WebDAV · 경로 입력].
|
|
||||||
/// 202: 드라이브가 많아 넘쳐도 끝의 버튼은 늘 보이게 (예전에는 줄 전체가 한 목록이라 화면 밖으로 밀려 누를 수 없었다)
|
|
||||||
/// 좁은 창 (위아래 두 창 · 폰, [paneHeaderWideWidth] 미만) 은 앞 버튼도 탭과 함께 밀고, 끝 버튼은 [⋮] 하나로 (늘 보임)
|
|
||||||
Widget _paneHeader(ExplorerPane pane) => SizedBox(
|
|
||||||
height: 40,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
|
||||||
child: LayoutBuilder(
|
|
||||||
builder: (context, box) => box.maxWidth >= paneHeaderWideWidth
|
|
||||||
? Row(children: [
|
|
||||||
..._headerStart(pane),
|
|
||||||
Expanded(child: _driveTabs(pane)),
|
|
||||||
..._headerEnd(pane),
|
|
||||||
])
|
|
||||||
: Row(children: [
|
|
||||||
Expanded(child: _driveTabs(pane, lead: _headerStart(pane))),
|
|
||||||
_headerMenu(pane),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
/// 202: 좁은 창의 끝 버튼 - WebDAV 서버 추가 · 경로 입력
|
|
||||||
Widget _headerMenu(ExplorerPane pane) => PopupMenuButton<String>(
|
|
||||||
tooltip: tr('경로 입력 · 네트워크 서버'),
|
|
||||||
icon: const Icon(Icons.more_vert, size: 18),
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
onSelected: (v) => switch (v) {
|
|
||||||
'path' => _enterPath(pane),
|
|
||||||
'sftp' => _editSftp(pane, null),
|
|
||||||
'smb' => _editSmb(pane, null),
|
|
||||||
'ftp' => _editFtp(pane, null),
|
|
||||||
'dlna' => openDlna(context, c),
|
|
||||||
_ => _editDav(pane, null),
|
|
||||||
},
|
|
||||||
itemBuilder: (_) => [
|
|
||||||
PopupMenuItem(
|
|
||||||
value: 'path',
|
|
||||||
child: ListTile(dense: true, leading: const Icon(Icons.edit_location_alt_outlined), title: Text(tr('경로 입력')))),
|
|
||||||
PopupMenuItem(
|
|
||||||
value: 'dav', child: ListTile(dense: true, leading: const Icon(Icons.cloud_outlined), title: Text(tr('WebDAV 서버 추가')))),
|
|
||||||
PopupMenuItem(
|
|
||||||
value: 'sftp', child: ListTile(dense: true, leading: const Icon(Icons.vpn_key_outlined), title: Text(tr('SFTP 서버 추가')))),
|
|
||||||
PopupMenuItem(
|
|
||||||
value: 'ftp', child: ListTile(dense: true, leading: const Icon(Icons.folder_shared_outlined), title: Text(tr('FTP 서버 추가')))),
|
|
||||||
// 88: SMB 는 Android 만 (Windows 는 경로 입력에 \\서버\공유)
|
|
||||||
if (ExplorerPage.debugAndroidPaths ?? Platform.isAndroid)
|
|
||||||
PopupMenuItem(
|
|
||||||
value: 'smb', child: ListTile(dense: true, leading: const Icon(Icons.dns_outlined), title: Text(tr('SMB 서버 추가')))),
|
|
||||||
// X-plore 비교 5-2 (사용자 결정 10/21): 같은 망의 미디어 서버 보기 (DLNA, 보기만)
|
|
||||||
if (!widget.rsync)
|
|
||||||
PopupMenuItem(
|
|
||||||
value: 'dlna', child: ListTile(dense: true, leading: const Icon(Icons.cast_outlined), title: Text(tr('미디어 서버 (DLNA)')))),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
/// 창 위 줄 앞: 뒤로 · 앞으로 · 즐겨찾기
|
|
||||||
List<Widget> _headerStart(ExplorerPane pane) => [
|
|
||||||
// 75: 이 창의 뒤로 · 앞으로 (가 본 폴더)
|
|
||||||
IconButton(
|
|
||||||
tooltip: keyHint(tr('뒤로'), 'Alt+←'),
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
icon: const Icon(Icons.arrow_back, size: 16),
|
|
||||||
onPressed: !pane.nav.canBack ? null : () => _ctl.travel(pane, forward: false),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
tooltip: keyHint(tr('앞으로'), 'Alt+→'),
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
icon: const Icon(Icons.arrow_forward, size: 16),
|
|
||||||
onPressed: !pane.nav.canForward ? null : () => _ctl.travel(pane, forward: true),
|
|
||||||
),
|
|
||||||
// 73: 즐겨찾기 (창 위에서 한 번에 - 드라이브가 많아도 보이게 맨 앞)
|
|
||||||
IconButton(
|
|
||||||
tooltip: tr('즐겨찾기'),
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
icon: Icon(
|
|
||||||
c.settings.folderBookmarks.any((b) => !b.isPair && samePath(b.path, pane.current)) ? Icons.star : Icons.star_outline,
|
|
||||||
size: 16,
|
|
||||||
color: JjColors.amber),
|
|
||||||
onPressed: () => _bookmarks(pane),
|
|
||||||
),
|
|
||||||
// 76: 빠른 필터 (켜져 있으면 강조)
|
|
||||||
IconButton(
|
|
||||||
tooltip: keyHint(tr('빠른 필터'), 'Ctrl+F'),
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
isSelected: pane.filterOpen,
|
|
||||||
icon: Icon(pane.filterOpen ? Icons.filter_alt : Icons.filter_alt_outlined,
|
|
||||||
size: 16, color: pane.filterOpen ? JjColors.accent : null),
|
|
||||||
onPressed: () => pane.filterOpen ? _closeFilter(pane) : _openFilter(pane),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
/// 202: 저장 장치 탭 - 넘치면 마우스 휠 · 끌기로도 옆으로. 비어 있거나 연결 안 된 드라이브는 숨기지 않고 뒤쪽으로
|
|
||||||
Widget _driveTabs(ExplorerPane pane, {List<Widget> lead = const []}) {
|
|
||||||
final all = [
|
|
||||||
..._ctl.volumes,
|
|
||||||
// 37: 지금 열어 둔 네트워크 공유 (\\NAS\공유) 도 탭으로
|
|
||||||
for (final x in _ctl.panes)
|
|
||||||
if (Platform.isWindows && x.root.startsWith(r'\\') && !_ctl.volumes.any((v) => samePath(v.$1, x.root))) (x.root, x.root),
|
|
||||||
];
|
|
||||||
final tabs = [
|
|
||||||
...all.where((v) => _ctl.drives.ok[v.$1] != false || samePath(pane.root, v.$1)),
|
|
||||||
...all.where((v) => _ctl.drives.ok[v.$1] == false && !samePath(pane.root, v.$1)),
|
|
||||||
];
|
|
||||||
return Listener(
|
|
||||||
// 마우스 휠 (위 · 아래) 을 옆으로
|
|
||||||
onPointerSignal: (e) {
|
|
||||||
if (e is! PointerScrollEvent || !pane.tabScroll.hasClients) return;
|
|
||||||
final pos = pane.tabScroll.position;
|
|
||||||
final d = e.scrollDelta.dx != 0 ? e.scrollDelta.dx : e.scrollDelta.dy;
|
|
||||||
pane.tabScroll.jumpTo((pos.pixels + d).clamp(0.0, pos.maxScrollExtent));
|
|
||||||
},
|
|
||||||
child: ScrollConfiguration(
|
|
||||||
// 마우스로 끌어도 옆으로 (데스크톱은 기본으로 마우스 끌기가 꺼져 있음)
|
|
||||||
behavior: ScrollConfiguration.of(context).copyWith(
|
|
||||||
dragDevices: {PointerDeviceKind.touch, PointerDeviceKind.mouse, PointerDeviceKind.trackpad, PointerDeviceKind.stylus},
|
|
||||||
scrollbars: false,
|
|
||||||
),
|
|
||||||
child: ListView(
|
|
||||||
controller: pane.tabScroll,
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
children: [
|
|
||||||
...lead,
|
|
||||||
for (final (path, label) in tabs)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(right: 4),
|
|
||||||
child: TextButton.icon(
|
|
||||||
style: TextButton.styleFrom(
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
|
||||||
backgroundColor: samePath(pane.root, path) ? JjColors.accent.withValues(alpha: 0.18) : null,
|
|
||||||
foregroundColor: samePath(pane.root, path) ? JjColors.accent : null,
|
|
||||||
),
|
|
||||||
icon: Icon(
|
|
||||||
isRootPath(path)
|
|
||||||
? Icons.security
|
|
||||||
: isShizuku(path)
|
|
||||||
? Icons.admin_panel_settings_outlined
|
|
||||||
: isSftp(path)
|
|
||||||
? Icons.vpn_key_outlined
|
|
||||||
: isFtp(path)
|
|
||||||
? Icons.folder_shared_outlined
|
|
||||||
: isSmb(path)
|
|
||||||
? Icons.dns_outlined
|
|
||||||
: isDav(path)
|
|
||||||
? Icons.cloud_outlined
|
|
||||||
: _ctl.drives.network.contains(path)
|
|
||||||
? Icons.lan_outlined
|
|
||||||
: Platform.isWindows
|
|
||||||
? Icons.storage
|
|
||||||
: Icons.sd_storage_outlined,
|
|
||||||
size: 16),
|
|
||||||
// 165: 열 수 없는 드라이브는 숨기지 않고 "연결 안 됨" - 누르면 다시 연결 (다시 읽기)
|
|
||||||
label: _ctl.drives.ok[path] == false
|
|
||||||
? Text('$label · ${_ctl.drives.removable.contains(path) ? tr('비어 있음') : tr('연결 안 됨')} ⟳',
|
|
||||||
style: TextStyle(color: _ctl.drives.removable.contains(path) ? JjColors.textDim : JjColors.warning))
|
|
||||||
: Text(label),
|
|
||||||
onPressed: () {
|
|
||||||
setState(() => _ctl.active = _ctl.panes.indexOf(pane));
|
|
||||||
if (isRootPath(path)) {
|
|
||||||
unawaited(_openRoot(pane));
|
|
||||||
} else if (isShizuku(path)) {
|
|
||||||
unawaited(_openShizuku(pane));
|
|
||||||
} else if (_ctl.drives.ok[path] == false) {
|
|
||||||
unawaited(_reconnect(pane, path));
|
|
||||||
} else {
|
|
||||||
_ctl.goTo(pane, path);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onLongPress: isSftp(path)
|
|
||||||
? () => _editSftp(pane, path)
|
|
||||||
: isFtp(path)
|
|
||||||
? () => _editFtp(pane, path)
|
|
||||||
: isSmb(path)
|
|
||||||
? () => _editSmb(pane, path)
|
|
||||||
: isDav(path)
|
|
||||||
? () => _editDav(pane, path)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 202: 창 위 줄 끝 - 탭이 넘쳐도 늘 보이는 버튼 (WebDAV 서버 추가 · 경로 입력)
|
|
||||||
List<Widget> _headerEnd(ExplorerPane pane) => [
|
|
||||||
if (c.settings.webdavServers.isEmpty)
|
|
||||||
TextButton.icon(
|
|
||||||
style: TextButton.styleFrom(
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
|
||||||
),
|
|
||||||
icon: const Icon(Icons.cloud_outlined, size: 16),
|
|
||||||
label: const Text('WebDAV'),
|
|
||||||
onPressed: () => _editDav(pane, null),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
IconButton(
|
|
||||||
tooltip: tr('WebDAV 서버 추가'),
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
icon: const Icon(Icons.add, size: 16),
|
|
||||||
onPressed: () => _editDav(pane, null),
|
|
||||||
),
|
|
||||||
// 37: 경로를 직접 넣어 가기 (Windows 는 \\NAS\공유 도)
|
|
||||||
// 88: SFTP 서버 추가
|
|
||||||
IconButton(
|
|
||||||
tooltip: tr('SFTP 서버 추가'),
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
icon: const Icon(Icons.vpn_key_outlined, size: 16),
|
|
||||||
onPressed: () => _editSftp(pane, null),
|
|
||||||
),
|
|
||||||
// l: FTP · FTPS 서버 추가
|
|
||||||
IconButton(
|
|
||||||
tooltip: tr('FTP 서버 추가'),
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
icon: const Icon(Icons.folder_shared_outlined, size: 16),
|
|
||||||
onPressed: () => _editFtp(pane, null),
|
|
||||||
),
|
|
||||||
// X-plore 비교 5-2 (사용자 결정 10/21): 같은 망의 미디어 서버 보기 (DLNA, 보기만)
|
|
||||||
if (!widget.rsync)
|
|
||||||
IconButton(
|
|
||||||
tooltip: tr('미디어 서버 (DLNA)'),
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
icon: const Icon(Icons.cast_outlined, size: 16),
|
|
||||||
onPressed: () => openDlna(context, c),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
tooltip: tr('경로 입력'),
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
icon: const Icon(Icons.edit_location_alt_outlined, size: 16),
|
|
||||||
onPressed: () => _enterPath(pane),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
/// 경로를 직접 넣어 가기 (37). 146 · 37-2: 없는 폴더면 창을 닫지 않고 칸 아래에 알린다 (고쳐서 다시 넣을 수 있게)
|
|
||||||
Future<void> _enterPath(ExplorerPane pane) async {
|
|
||||||
setState(() => _ctl.active = _ctl.panes.indexOf(pane));
|
|
||||||
final path = await showDialog<String>(context: context, builder: (_) => _PathDialog(initial: pane.current));
|
|
||||||
if (path == null || !mounted) return;
|
|
||||||
// 179: SMB 를 넣었다가 [WebDAV 서버 추가] 를 누름
|
|
||||||
if (path == _PathDialog.addDav) return _editDav(pane, null);
|
|
||||||
// 88: Android 에서 smb:// · \\서버\공유 를 넣고 [SMB 서버 추가]
|
|
||||||
if (path.startsWith(_PathDialog.addSmb)) return _editSmb(pane, null, from: path.substring(_PathDialog.addSmb.length));
|
|
||||||
await _ctl.goTo(pane, path, fresh: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 146: 넣은 경로를 고쳐 쓰기 · 확인. 갈 수 있으면 그 경로, 아니면 칸 아래에 보일 이유
|
|
||||||
static Future<(String?, String?)> checkEnteredPath(String input) async {
|
|
||||||
var path = input.trim().replaceAll('"', '');
|
|
||||||
if (path.isEmpty) return (null, null);
|
|
||||||
// 179: Android 는 네트워크 공유 (SMB) 를 아직 못 연다 - 폴더가 없다고 하지 않고 WebDAV 로 안내
|
|
||||||
if ((ExplorerPage.debugAndroidPaths ?? Platform.isAndroid) && looksLikeSmb(path)) return (null, smbNotSupported);
|
|
||||||
// 190: Windows 는 smb:// · cifs:// 주소를 \\서버\공유 로 바꿔 연다 (주소가 틀린 줄 알지 않게)
|
|
||||||
final unc = Platform.isWindows ? smbToUnc(path) : null;
|
|
||||||
if (unc != null) path = unc;
|
|
||||||
if (Platform.isWindows && RegExp(r'^\\\\[^\\/]+[\\/]?$').hasMatch(path)) {
|
|
||||||
return (null, tr('네트워크 공유는 공유 폴더 이름까지 넣으세요 (예: \\\\NAS\\영상).'));
|
|
||||||
}
|
|
||||||
if (!isDav(path) && Platform.isWindows && RegExp(r'^[a-zA-Z]:$').hasMatch(path)) path = '$path\\';
|
|
||||||
bool ok;
|
|
||||||
try {
|
|
||||||
ok = isDav(path) || await Directory(path).exists();
|
|
||||||
} catch (_) {
|
|
||||||
ok = false;
|
|
||||||
}
|
|
||||||
if (!ok && unc != null) {
|
|
||||||
return (null, trf('폴더가 없거나 열 수 없습니다. Windows 의 네트워크 공유는 \\\\서버\\공유 형식입니다 (바꾼 경로: {0})', [unc]));
|
|
||||||
}
|
|
||||||
return ok ? (path, null) : (null, tr('폴더가 없거나 열 수 없습니다'));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// WebDAV 서버 추가 (없으면 [root] 는 null) · 고치기 (탭 길게 누르기). 저장하면 그 서버로 간다.
|
|
||||||
Future<void> _editDav(ExplorerPane pane, String? root) async {
|
|
||||||
setState(() => _ctl.active = _ctl.panes.indexOf(pane));
|
|
||||||
final old = root == null ? null : DavRegistry.server(DavPath.parse(root).server);
|
|
||||||
final saved = await editDavServer(context, c, old: old);
|
|
||||||
if (saved == null || !mounted) return;
|
|
||||||
for (final x in _ctl.panes) {
|
|
||||||
x.cache.removeWhere((k, _) => isDav(k) && DavPath.parse(k).server == saved.id);
|
|
||||||
}
|
|
||||||
await _ctl.goTo(pane, '$davScheme${saved.id}/');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 88: SFTP 서버 추가 (없으면 [root] 는 null) · 고치기 (탭 길게 누르기). 저장하면 그 서버로 간다.
|
|
||||||
Future<void> _editSftp(ExplorerPane pane, String? root) async {
|
|
||||||
setState(() => _ctl.active = _ctl.panes.indexOf(pane));
|
|
||||||
final old = root == null ? null : SftpRegistry.server(DavPath.parse(root).server);
|
|
||||||
final saved = await editSftpServer(context, c, old: old);
|
|
||||||
if (saved == null || !mounted) return;
|
|
||||||
for (final x in _ctl.panes) {
|
|
||||||
x.cache.removeWhere((k, _) => isSftp(k) && DavPath.parse(k).server == saved.id);
|
|
||||||
x.hostKeyErrors.removeWhere((k, _) => isSftp(k) && DavPath.parse(k).server == saved.id);
|
|
||||||
}
|
|
||||||
await _ctl.goTo(pane, '$sftpScheme${saved.id}/');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// l: FTP 서버 추가 (없으면 [root] 는 null) · 고치기 (탭 길게 누르기). 저장하면 그 서버로 간다.
|
|
||||||
Future<void> _editFtp(ExplorerPane pane, String? root) async {
|
|
||||||
setState(() => _ctl.active = _ctl.panes.indexOf(pane));
|
|
||||||
final old = root == null ? null : FtpRegistry.server(DavPath.parse(root).server);
|
|
||||||
final saved = await editFtpServer(context, c, old: old);
|
|
||||||
if (saved == null || !mounted) return;
|
|
||||||
for (final x in _ctl.panes) {
|
|
||||||
x.cache.removeWhere((k, _) => isFtp(k) && DavPath.parse(k).server == saved.id);
|
|
||||||
x.certErrors.removeWhere((k, _) => isFtp(k) && DavPath.parse(k).server == saved.id);
|
|
||||||
}
|
|
||||||
await _ctl.goTo(pane, '$ftpScheme${saved.id}/');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 88: SMB 서버 추가 (없으면 [root] 는 null, [from] 은 경로 입력에 넣은 주소) · 고치기 (탭 길게 누르기). 저장하면 그 서버로 간다.
|
|
||||||
Future<void> _editSmb(ExplorerPane pane, String? root, {String? from}) async {
|
|
||||||
setState(() => _ctl.active = _ctl.panes.indexOf(pane));
|
|
||||||
final old = root == null ? null : SmbRegistry.server(DavPath.parse(root).server);
|
|
||||||
final saved = await editSmbServer(context, c, old: old, from: from);
|
|
||||||
if (saved == null || !mounted) return;
|
|
||||||
for (final x in _ctl.panes) {
|
|
||||||
x.cache.removeWhere((k, _) => isSmb(k) && DavPath.parse(k).server == saved.id);
|
|
||||||
}
|
|
||||||
await _ctl.goTo(pane, '$smbScheme${saved.id}/');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 203: 바뀐 날을 모름 (잠긴 시스템 파일은 1970-01-01 로 온다) 은 "—"
|
/// 203: 바뀐 날을 모름 (잠긴 시스템 파일은 1970-01-01 로 온다) 은 "—"
|
||||||
|
|
@ -4715,93 +4174,6 @@ class _PatternDialogState extends State<_PatternDialog> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 179: SMB 경로 (smb:// · \\서버\공유)
|
|
||||||
bool looksLikeSmb(String path) => RegExp(r'^(smb|cifs)://', caseSensitive: false).hasMatch(path) || path.startsWith(r'\\');
|
|
||||||
|
|
||||||
/// 190: smb://서버/공유/폴더 (cifs:// 도) → \\서버\공유\폴더. SMB 주소가 아니면 null
|
|
||||||
String? smbToUnc(String path) {
|
|
||||||
final m = RegExp(r'^(smb|cifs)://(.+)$', caseSensitive: false).firstMatch(path.trim());
|
|
||||||
if (m == null) return null;
|
|
||||||
final parts = m.group(2)!.split(RegExp(r'[/\\]+')).where((s) => s.isNotEmpty).map(_decodeSmbPart).toList();
|
|
||||||
if (parts.isEmpty) return null;
|
|
||||||
return '\\\\${parts.join('\\')}';
|
|
||||||
}
|
|
||||||
|
|
||||||
/// %20 같은 주소 글자는 풀고, 한글 등 그대로 쓴 글자는 그대로
|
|
||||||
String _decodeSmbPart(String s) {
|
|
||||||
try {
|
|
||||||
return Uri.decodeComponent(s);
|
|
||||||
} catch (_) {
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String get smbNotSupported => tr('네트워크 공유 (SMB) 는 경로로 바로 열지 않습니다. [SMB 서버 추가] 로 서버를 넣으면 탭으로 열립니다');
|
|
||||||
|
|
||||||
class _PathDialog extends StatefulWidget {
|
|
||||||
const _PathDialog({required this.initial});
|
|
||||||
final String initial;
|
|
||||||
|
|
||||||
/// 창이 이것을 돌려주면 WebDAV 서버 추가 창을 연다
|
|
||||||
static const addDav = '\u0000webdav';
|
|
||||||
|
|
||||||
/// 88: 이것 + 넣은 주소를 돌려주면 SMB 서버 추가 창 (주소로 채움)
|
|
||||||
static const addSmb = '\u0000smb:';
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_PathDialog> createState() => _PathDialogState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _PathDialogState extends State<_PathDialog> {
|
|
||||||
late final _ctl = TextEditingController(text: widget.initial)
|
|
||||||
..selection = TextSelection(baseOffset: 0, extentOffset: widget.initial.length);
|
|
||||||
String? _error;
|
|
||||||
bool _checking = false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_ctl.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _submit() async {
|
|
||||||
setState(() => _checking = true);
|
|
||||||
final (path, error) = await _ExplorerPageState.checkEnteredPath(_ctl.text);
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() {
|
|
||||||
_checking = false;
|
|
||||||
_error = error;
|
|
||||||
});
|
|
||||||
if (path != null) Navigator.pop(context, path);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) => AlertDialog(
|
|
||||||
scrollable: true,
|
|
||||||
title: Text(Platform.isWindows ? tr('경로 입력 (예: D:\\영상 · \\\\NAS\\공유)') : tr('경로 입력')),
|
|
||||||
content: TextField(
|
|
||||||
controller: _ctl,
|
|
||||||
autofocus: true,
|
|
||||||
decoration: InputDecoration(hintText: tr('경로'), errorText: _error, errorMaxLines: 3),
|
|
||||||
// 고치기 시작하면 앞의 오류는 지운다
|
|
||||||
onChanged: (_) {
|
|
||||||
if (_error != null) setState(() => _error = null);
|
|
||||||
},
|
|
||||||
onSubmitted: (_) => _submit(),
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
// 179: SMB 를 넣었으면 바로 WebDAV 서버 추가로
|
|
||||||
if (_error == smbNotSupported) ...[
|
|
||||||
TextButton(onPressed: () => Navigator.pop(context, _PathDialog.addDav), child: Text(tr('WebDAV 서버 추가'))),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context, '${_PathDialog.addSmb}${_ctl.text}'), child: Text(tr('SMB 서버 추가'))),
|
|
||||||
],
|
|
||||||
TextButton(onPressed: () => Navigator.pop(context), child: Text(tr('취소'))),
|
|
||||||
FilledButton(onPressed: _checking ? null : _submit, child: Text(tr('확인'))),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 77: 길게 누르기 메뉴 위의 아이콘 줄 - (id, 아이콘, 짧은 이름, 풍선 도움말, 누를 수 있음).
|
/// 77: 길게 누르기 메뉴 위의 아이콘 줄 - (id, 아이콘, 짧은 이름, 풍선 도움말, 누를 수 있음).
|
||||||
/// 폰에서도 무엇인지 알 수 있게 아이콘 아래에 짧은 이름
|
/// 폰에서도 무엇인지 알 수 있게 아이콘 아래에 짧은 이름
|
||||||
class _MenuIconRow extends PopupMenuEntry<String> {
|
class _MenuIconRow extends PopupMenuEntry<String> {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,208 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:jj_mkvmaker/app/app_controller.dart';
|
||||||
|
import 'package:jj_mkvmaker/core/file_ops.dart' show FileEntry, samePath;
|
||||||
|
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
|
||||||
|
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
|
||||||
|
import 'package:jj_mkvmaker/services/platform_services.dart';
|
||||||
|
import 'package:jj_mkvmaker/ui/explorer/explorer_controller.dart';
|
||||||
|
import 'package:jj_mkvmaker/ui/explorer/explorer_host.dart';
|
||||||
|
import 'package:jj_mkvmaker/ui/explorer/explorer_pane.dart';
|
||||||
|
import 'package:jj_mkvmaker/ui/explorer/explorer_select.dart';
|
||||||
|
import 'package:jj_mkvmaker/ui/explorer/pane_header.dart';
|
||||||
|
import 'package:jj_mkvmaker/ui/explorer/pane_view.dart';
|
||||||
|
import 'package:jj_mkvmaker/ui/explorer/path_dialog.dart';
|
||||||
|
import 'package:jj_mkvmaker/ui/explorer/server_flows.dart';
|
||||||
|
import 'package:jj_mkvmaker/ui/explorer_drag.dart' show DragLoad;
|
||||||
|
import 'package:jj_mkvmaker/ui/explorer_look.dart' show rowSpacingFactor;
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
|
/// E5d-3 약속 시험: path_dialog · server_flows · pane_header · pane_view 맨 위 약속 (실제 시험 폴더 · 실제 화면 · 실제 창)
|
||||||
|
void main() {
|
||||||
|
late Directory tmp;
|
||||||
|
late String root, show;
|
||||||
|
setUp(() {
|
||||||
|
tmp = Directory.systemTemp.createTempSync('jj_pane_ui_');
|
||||||
|
root = tmp.path;
|
||||||
|
show = p.join(root, 'Show');
|
||||||
|
Directory(show).createSync();
|
||||||
|
File(p.join(show, 'ep1.mkv')).writeAsStringSync('1');
|
||||||
|
File(p.join(root, 'a.txt')).writeAsStringSync('a');
|
||||||
|
});
|
||||||
|
tearDown(() => tmp.deleteSync(recursive: true));
|
||||||
|
|
||||||
|
/// 실제 I/O 를 기다린 뒤 그린다
|
||||||
|
Future<void> settle(WidgetTester t) async {
|
||||||
|
for (var i = 0; i < 10; i++) {
|
||||||
|
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 30)));
|
||||||
|
await t.pump(const Duration(milliseconds: 100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('경로 확인: 빈 칸 · 있는 폴더 · 없는 폴더 · Android 의 SMB', () async {
|
||||||
|
expect(await checkEnteredPath(' ', android: false), (null, null));
|
||||||
|
expect(await checkEnteredPath('"$show"', android: false), (show, null));
|
||||||
|
final (miss, why) = await checkEnteredPath(p.join(root, 'none'), android: false);
|
||||||
|
expect([miss, why!.isNotEmpty], [null, true]);
|
||||||
|
expect(await checkEnteredPath('smb://nas/v', android: true), (null, smbNotSupported));
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<(ExplorerController, GlobalKey<_HostState>)> open(WidgetTester t, {double width = 800}) async {
|
||||||
|
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||||
|
final key = GlobalKey<_HostState>();
|
||||||
|
await t.binding.setSurfaceSize(Size(width, 600));
|
||||||
|
addTearDown(() => t.binding.setSurfaceSize(null));
|
||||||
|
await t.pumpWidget(MaterialApp(home: _Host(key: key)));
|
||||||
|
final ctl = ExplorerController(c, rsync: false, host: key.currentState!)..local = [(root, 'T:')];
|
||||||
|
final sel = ExplorerSelect(ctl, clock: DateTime.now, openFile: (_, _) async {});
|
||||||
|
addTearDown(() {
|
||||||
|
sel.dispose();
|
||||||
|
ctl.dispose();
|
||||||
|
});
|
||||||
|
await t.runAsync(() => ctl.goTo(ctl.panes[0], root));
|
||||||
|
key.currentState!.attach(ctl, sel);
|
||||||
|
await t.pump();
|
||||||
|
return (ctl, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('경로 입력 창: 없는 폴더는 창을 닫지 않고 이유 · 있는 폴더는 창을 옮김 · Android SMB 는 서버 추가로', (t) async {
|
||||||
|
final (ctl, key) = await open(t);
|
||||||
|
final q = ctl.panes[0];
|
||||||
|
final servers = ExplorerServers(ctl);
|
||||||
|
var done = false;
|
||||||
|
servers.enterPath(key.currentContext!, q, android: false).then((_) => done = true);
|
||||||
|
await t.pump();
|
||||||
|
await t.enterText(find.byType(TextField).last, p.join(root, 'none'));
|
||||||
|
await t.tap(find.text('확인'));
|
||||||
|
await settle(t);
|
||||||
|
expect([find.byType(PathDialog).evaluate().length, find.text('폴더가 없거나 열 수 없습니다').evaluate().length], [1, 1]);
|
||||||
|
await t.enterText(find.byType(TextField).last, show);
|
||||||
|
await t.tap(find.text('확인'));
|
||||||
|
await settle(t);
|
||||||
|
expect([done, find.byType(PathDialog).evaluate().isEmpty, samePath(q.current, show)], [true, true, true]);
|
||||||
|
|
||||||
|
String? smb;
|
||||||
|
showDialog<String>(context: key.currentContext!, builder: (_) => const PathDialog(initial: '', android: true)).then((v) => smb = v);
|
||||||
|
await t.pump();
|
||||||
|
await t.enterText(find.byType(TextField).last, 'smb://nas/v');
|
||||||
|
await t.tap(find.text('확인'));
|
||||||
|
await settle(t);
|
||||||
|
await t.tap(find.text('SMB 서버 추가'));
|
||||||
|
await settle(t);
|
||||||
|
expect(smb, '${PathDialog.addSmb}smb://nas/v');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('창 보기: 머리 · 줄 · 빠른 필터 수 · 좁은 창은 [⋮]', (t) async {
|
||||||
|
final (ctl, key) = await open(t);
|
||||||
|
final q = ctl.panes[0];
|
||||||
|
expect(find.text('a.txt'), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.more_vert), findsNothing, reason: '넓은 창은 끝 버튼을 펼침');
|
||||||
|
// 뒤로 · 앞으로: 처음은 갈 곳 없음 → 폴더를 옮기면 뒤로가 켜짐
|
||||||
|
expect(t.widget<IconButton>(find.widgetWithIcon(IconButton, Icons.arrow_back)).onPressed, isNull);
|
||||||
|
await t.runAsync(() => ctl.goTo(q, show));
|
||||||
|
await t.pump();
|
||||||
|
expect(t.widget<IconButton>(find.widgetWithIcon(IconButton, Icons.arrow_back)).onPressed, isNotNull);
|
||||||
|
await t.runAsync(() => ctl.goTo(q, root));
|
||||||
|
await t.pump();
|
||||||
|
// 빠른 필터: 머리 단추 → 화면 콜백 → 칸 · 보이는 수
|
||||||
|
await t.tap(find.widgetWithIcon(IconButton, Icons.filter_alt_outlined));
|
||||||
|
await t.pump();
|
||||||
|
expect(key.currentState!.calls, ['openFilter']);
|
||||||
|
await t.enterText(find.byType(TextField), 'a.t');
|
||||||
|
await t.pump();
|
||||||
|
expect(find.text('1개 보임'), findsOneWidget);
|
||||||
|
await t.tap(find.widgetWithIcon(IconButton, Icons.close));
|
||||||
|
await t.pump();
|
||||||
|
expect(key.currentState!.calls, ['openFilter', 'filterChanged', 'closeFilter']);
|
||||||
|
await t.binding.setSurfaceSize(const Size(400, 600));
|
||||||
|
await t.pump();
|
||||||
|
expect(find.byIcon(Icons.more_vert), findsOneWidget, reason: '좁은 창 ($paneHeaderWideWidth 미만) 은 [⋮] 하나');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Host extends StatefulWidget {
|
||||||
|
const _Host({super.key});
|
||||||
|
@override
|
||||||
|
State<_Host> createState() => _HostState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 실제 화면: ExplorerHost · ExplorerPaneParts 를 이 화면이 맡는다 (틀 · 안내 · 표시 막대는 비움, 부른 것은 [calls] 에)
|
||||||
|
class _HostState extends State<_Host> implements ExplorerHost, ExplorerPaneParts {
|
||||||
|
ExplorerController? ctl;
|
||||||
|
ExplorerSelect? sel;
|
||||||
|
final calls = <String>[];
|
||||||
|
|
||||||
|
void attach(ExplorerController c, ExplorerSelect s) {
|
||||||
|
ctl = c..addListener(_redraw);
|
||||||
|
sel = s;
|
||||||
|
for (final q in c.panes) {
|
||||||
|
q.addListener(_redraw);
|
||||||
|
}
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _redraw() {
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get alive => mounted;
|
||||||
|
@override
|
||||||
|
void snack(String text) => ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text)));
|
||||||
|
@override
|
||||||
|
void reveal(ExplorerPane pane, String path) {}
|
||||||
|
@override
|
||||||
|
void afterFrame(VoidCallback f) => WidgetsBinding.instance.addPostFrameCallback((_) => f());
|
||||||
|
|
||||||
|
@override
|
||||||
|
double get rowHeight => ctl!.look.rowHeightOf(context, textScale: 1, spacing: rowSpacingFactor(''));
|
||||||
|
@override
|
||||||
|
double get listTextScale => 1;
|
||||||
|
@override
|
||||||
|
bool get narrowSplit => false;
|
||||||
|
@override
|
||||||
|
bool get alwaysMarks => false;
|
||||||
|
@override
|
||||||
|
Widget frame(int i, List<Widget> children) => Column(children: children);
|
||||||
|
@override
|
||||||
|
Widget header(ExplorerPane pane) => ExplorerPaneHeader(
|
||||||
|
ctl: ctl!,
|
||||||
|
servers: ExplorerServers(ctl!),
|
||||||
|
pane: pane,
|
||||||
|
android: false,
|
||||||
|
onBookmarks: () => calls.add('bookmarks'),
|
||||||
|
onOpenFilter: () => setState(() {
|
||||||
|
calls.add('openFilter');
|
||||||
|
pane.filterOpen = true;
|
||||||
|
}),
|
||||||
|
onCloseFilter: () => closeFilter(pane),
|
||||||
|
onOpenRoot: () async => calls.add('root'),
|
||||||
|
onOpenShizuku: () async => calls.add('shizuku'),
|
||||||
|
onReconnect: (_) async => calls.add('reconnect'),
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
List<Widget?> notices(ExplorerPane pane, String dir) => const [];
|
||||||
|
@override
|
||||||
|
Widget marksBar(ExplorerPane pane, {String? listDir}) => const SizedBox();
|
||||||
|
@override
|
||||||
|
void onMenu(ExplorerPane pane, FileEntry e, Offset at) {}
|
||||||
|
@override
|
||||||
|
List<String> dragPaths(ExplorerPane pane, FileEntry e) => [e.path];
|
||||||
|
@override
|
||||||
|
Future<void> onDrop(DragLoad load, int to, String dest) async {}
|
||||||
|
@override
|
||||||
|
void closeFilter(ExplorerPane pane) => setState(() {
|
||||||
|
calls.add('closeFilter');
|
||||||
|
pane
|
||||||
|
..filterOpen = false
|
||||||
|
..filterCtl.clear();
|
||||||
|
});
|
||||||
|
@override
|
||||||
|
void filterChanged() => setState(() => calls.add('filterChanged'));
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Scaffold(
|
||||||
|
body: ctl == null ? const SizedBox() : ExplorerPaneView(ctl: ctl!, sel: sel!, parts: this, index: 0));
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue