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:collection';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/foundation.dart' show defaultTargetPlatform;
|
||||
import 'package:flutter/gestures.dart' show kBackMouseButton, kForwardMouseButton, PointerDeviceKind, PointerScrollEvent;
|
||||
import 'package:flutter/gestures.dart' show kBackMouseButton, kForwardMouseButton;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
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 'app_actions.dart';
|
||||
import 'explorer_look.dart';
|
||||
import 'key_hint.dart';
|
||||
import 'monitor_page.dart';
|
||||
import 'player_page.dart';
|
||||
import 'rsync_setup.dart';
|
||||
|
|
@ -43,8 +43,6 @@ import 'progress_dialog.dart';
|
|||
import 'sync_preview_view.dart';
|
||||
import 'sftp_settings.dart';
|
||||
import 'ftp_settings.dart';
|
||||
import 'smb_settings.dart';
|
||||
import 'webdav_settings.dart';
|
||||
import 'reader_page.dart';
|
||||
import '../app/component_store.dart';
|
||||
import 'zip_page.dart';
|
||||
|
|
@ -58,7 +56,6 @@ import '../core/thumb_cache.dart' show thumbMemoryForgetters;
|
|||
import '../core/shizuku.dart';
|
||||
import 'vault_page.dart' show openVault;
|
||||
import 'app_manager_page.dart' show openAppManager;
|
||||
import 'dlna_page.dart' show openDlna;
|
||||
import 'explorer_keys.dart' show defaultExplorerKeys, effectiveExplorerKeys;
|
||||
import 'sqlite_viewer.dart' show openSqliteViewer, sqliteExtensions;
|
||||
import 'quick_look.dart';
|
||||
|
|
@ -94,7 +91,10 @@ import 'explorer/explorer_host.dart';
|
|||
import 'explorer/explorer_pane.dart';
|
||||
import 'explorer/file_thumbs.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/name_clash.dart' show NameClash, clashCompareText, fileDateText;
|
||||
|
|
@ -111,7 +111,6 @@ export '../core/name_clash.dart' show NameClash, clashCompareText, fileDateText;
|
|||
/// 가운데 → (왼쪽 → 오른쪽) · ← (오른쪽 → 왼쪽) · ⇄ (둘 다 함께) 로 rsync, [모니터링] 에 실행한 것이 남는다.
|
||||
/// 202 · 221: 창 위 줄을 [← → ★] · 탭 · [WebDAV · 경로 입력] 으로 펴는 가장 작은 창 폭. 이보다 좁으면 끝 버튼은 [⋮] 하나 -
|
||||
/// 폰 세로 (약 400) 에서 탭 칸이 130px 남짓이라 두 번째 저장소 (SD 카드) 가 잘려 보였다
|
||||
const double paneHeaderWideWidth = 480;
|
||||
|
||||
/// 191: Rsync 화면이 좌우 두 창 + 가운데 버튼 줄로 보이는 가장 작은 폭 (이보다 좁으면 창 하나씩)
|
||||
const double rsyncTwoPaneMinWidth = 560;
|
||||
|
|
@ -152,7 +151,8 @@ class ExplorerPage extends StatefulWidget {
|
|||
|
||||
/// 시험: 경로 입력 창이 넣은 경로를 고쳐 쓰고 확인하는 것 (갈 경로, 칸 아래 이유)
|
||||
@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 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;
|
||||
|
||||
/// 탐색기의 상태 · 화면 밖의 일 (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(
|
||||
{required IconData icon,
|
||||
|
|
@ -677,7 +667,7 @@ class _ExplorerPageState extends State<ExplorerPage> implements ExplorerHost {
|
|||
},
|
||||
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('지문 확인하고 연결')),
|
||||
),
|
||||
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))
|
||||
TextButton(
|
||||
onPressed: () => sftp != null
|
||||
? _editSftp(pane, pane.root)
|
||||
? _servers.editSftp(context, pane, pane.root)
|
||||
: smb != null
|
||||
? _editSmb(pane, pane.root)
|
||||
? _servers.editSmb(context, pane, pane.root)
|
||||
: ftp != null
|
||||
? _editFtp(pane, pane.root)
|
||||
: _editDav(pane, pane.root),
|
||||
? _servers.editFtp(context, pane, pane.root)
|
||||
: _servers.editDav(context, pane, pane.root),
|
||||
child: Text(tr('서버 설정 고치기'))),
|
||||
],
|
||||
);
|
||||
|
|
@ -897,89 +887,49 @@ class _ExplorerPageState extends State<ExplorerPage> implements ExplorerHost {
|
|||
}
|
||||
}
|
||||
|
||||
Widget _hiddenToggleRow(TreeRow row) {
|
||||
final look = _ctl.look;
|
||||
final label = _ctl.showAppHidden ? trf('숨긴 항목 {0}개 다시 숨기기', [row.count]) : trf('숨긴 항목 {0}개 보이기', [row.count]);
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: label,
|
||||
excludeSemantics: true,
|
||||
onTap: _ctl.toggleAppHidden,
|
||||
child: InkWell(
|
||||
onTap: _ctl.toggleAppHidden,
|
||||
child: Row(children: [
|
||||
SizedBox(width: treeChevronWidth(defaultTargetPlatform) + 4),
|
||||
Icon(_ctl.showAppHidden ? Icons.visibility_off_outlined : Icons.visibility_outlined, size: look.iconSize * 0.8, color: JjColors.textDim),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(label,
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: look.fontSize - 1, color: JjColors.textDim)),
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
// ───────── 창 보기 (E5d-3: ExplorerPaneView · ExplorerListView 가 부르는 것) ─────────
|
||||
|
||||
/// X-plore 비교 c: 트리 맨 위 항목 줄 (다른 저장 장치 · 서버 · 즐겨찾기) - 누르면 그곳으로, 즐겨찾기 묶음은 펼치기 · 접기
|
||||
Widget _extraRow(ExplorerPane pane, TreeRow row) {
|
||||
if (row.kind == TreeRow.hiddenToggle) return _hiddenToggleRow(row);
|
||||
final look = _ctl.look;
|
||||
final path = row.entry.path;
|
||||
final volume = _ctl.volumes.firstWhere((v) => samePath(v.$1, path), orElse: () => (path, vBasename(path))).$2;
|
||||
final fav = row.kind == TreeRow.favorite
|
||||
? c.settings.folderBookmarks.firstWhere((b) => b.path == path, orElse: () => FolderBookmark(name: vBasename(path), path: path))
|
||||
: null;
|
||||
final (IconData icon, Color color, String label, bool? open) = switch (row.kind) {
|
||||
TreeRow.otherRoot => (
|
||||
isDav(path)
|
||||
? Icons.cloud_outlined
|
||||
: Platform.isWindows
|
||||
? Icons.storage
|
||||
: Icons.sd_storage_outlined,
|
||||
JjColors.accent,
|
||||
volume,
|
||||
false,
|
||||
),
|
||||
TreeRow.favorites => (Icons.star, JjColors.amber, tr('즐겨찾기'), pane.favoritesOpen),
|
||||
_ => (Icons.star_outline, JjColors.amber, fav!.name, null),
|
||||
};
|
||||
void tap() {
|
||||
setState(() => _ctl.active = _ctl.panes.indexOf(pane));
|
||||
if (row.kind == TreeRow.favorites) {
|
||||
pane.favoritesOpen = !pane.favoritesOpen;
|
||||
pane.changed();
|
||||
return;
|
||||
}
|
||||
unawaited(_ctl.goTo(pane, path, fresh: true).then((_) => _reveal(pane, path)));
|
||||
}
|
||||
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: label,
|
||||
excludeSemantics: true,
|
||||
onTap: tap,
|
||||
child: InkWell(
|
||||
onTap: tap,
|
||||
child: Row(children: [
|
||||
SizedBox(width: look.indent * row.depth + 4),
|
||||
SizedBox(
|
||||
width: treeChevronWidth(defaultTargetPlatform),
|
||||
child: open == null ? null : Icon(open ? Icons.expand_more : Icons.chevron_right, size: 18, color: JjColors.textDim),
|
||||
),
|
||||
Icon(icon, size: look.iconSize * 0.8, color: color),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(label, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: look.fontSize)),
|
||||
if (fav != null)
|
||||
Text(_ctl.displayPath(path),
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: look.fontSize - 3, color: JjColors.textDim)),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
@override
|
||||
double get rowHeight => _rowH();
|
||||
@override
|
||||
double get listTextScale => _listTextScale;
|
||||
@override
|
||||
bool get narrowSplit => _narrowSplit;
|
||||
@override
|
||||
bool get alwaysMarks => _alwaysMarks;
|
||||
@override
|
||||
Widget frame(int i, List<Widget> children) => _frame(i, children);
|
||||
@override
|
||||
Widget header(ExplorerPane pane) => ExplorerPaneHeader(
|
||||
ctl: _ctl,
|
||||
servers: _servers,
|
||||
pane: pane,
|
||||
android: ExplorerPage.debugAndroidPaths ?? Platform.isAndroid,
|
||||
onBookmarks: () => _bookmarks(pane),
|
||||
onOpenFilter: () => _openFilter(pane),
|
||||
onCloseFilter: () => _closeFilter(pane),
|
||||
onOpenRoot: () => _openRoot(pane),
|
||||
onOpenShizuku: () => _openShizuku(pane),
|
||||
onReconnect: (path) => _reconnect(pane, path),
|
||||
);
|
||||
}
|
||||
@override
|
||||
List<Widget?> notices(ExplorerPane pane, String dir) =>
|
||||
[_shizukuNotice(pane), _accessNotice(pane), _errorNotice(pane, dir), _partialNotice(pane, dir)];
|
||||
@override
|
||||
Widget marksBar(ExplorerPane pane, {String? listDir}) => _marksBar(pane, listDir: listDir);
|
||||
@override
|
||||
void onMenu(ExplorerPane pane, FileEntry e, Offset at) => _menu(pane, e, at);
|
||||
@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(() {});
|
||||
|
||||
/// 서버 추가 · 고치기 · 경로 입력 (E5d-3)
|
||||
late final _servers = ExplorerServers(_ctl);
|
||||
|
||||
// ───────── 누르기 · 고르기 (E5d-2: ExplorerSelect) ─────────
|
||||
|
||||
|
|
@ -3517,14 +3467,14 @@ class _ExplorerPageState extends State<ExplorerPage> implements ExplorerHost {
|
|||
return Column(children: [
|
||||
_rsyncPaneSwitch(),
|
||||
?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>[
|
||||
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,
|
||||
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,
|
||||
] else if (split) ...[
|
||||
// 왼쪽 폴더 트리 (좁게) · 오른쪽 그 폴더의 파일 목록. 좁은 화면은 트리에 최소 폭 (130px 또는 버튼 줄 (72) 을 뺀 폭의 42%) -
|
||||
|
|
@ -3532,18 +3482,18 @@ class _ExplorerPageState extends State<ExplorerPage> implements ExplorerHost {
|
|||
if (_narrowSplit)
|
||||
SizedBox(
|
||||
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
|
||||
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,
|
||||
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,
|
||||
] else ...[
|
||||
if (middle) bar,
|
||||
Expanded(
|
||||
child: _hiddenTarget
|
||||
? Column(children: [ListenableBuilder(listenable: _ctl.panes[1], builder: (_, _) => _targetBar()), Expanded(child: keep(0, _paneView(0)))])
|
||||
: keep(0, _paneView(0))),
|
||||
? Column(children: [ListenableBuilder(listenable: _ctl.panes[1], builder: (_, _) => _targetBar()), Expanded(child: keep(0, ExplorerPaneView(ctl: _ctl, sel: _sel, parts: this, index: 0)))])
|
||||
: keep(0, ExplorerPaneView(ctl: _ctl, sel: _sel, parts: this, index: 0))),
|
||||
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] 가 있으면 그 글자로 시작)
|
||||
void _openFilter(ExplorerPane pane, [String? text]) {
|
||||
setState(() {
|
||||
|
|
@ -4075,368 +3896,6 @@ class _ExplorerPageState extends State<ExplorerPage> implements ExplorerHost {
|
|||
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 로 온다) 은 "—"
|
||||
|
|
@ -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, 아이콘, 짧은 이름, 풍선 도움말, 누를 수 있음).
|
||||
/// 폰에서도 무엇인지 알 수 있게 아이콘 아래에 짧은 이름
|
||||
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