From b5b1b13d619e3bac99cfd54d57e1e64695033b5c Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:57:37 +0900 Subject: [PATCH] =?UTF-8?q?E5d-3:=20=EC=B0=BD=20=EB=A8=B8=EB=A6=AC=20?= =?UTF-8?q?=C2=B7=20=EC=B0=BD=20=EB=B3=B4=EA=B8=B0=20=C2=B7=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EC=9E=85=EB=A0=A5=20=C2=B7=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=ED=9D=90=EB=A6=84=EC=9D=84=20=EB=AA=A8=EB=93=88=EB=A1=9C=20(ex?= =?UTF-8?q?plorer=5Fpage=204,857=20=E2=86=92=204,227)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- lib/ui/explorer/pane_header.dart | 287 ++++++++ lib/ui/explorer/pane_view.dart | 378 ++++++++++ lib/ui/explorer/path_dialog.dart | 123 ++++ lib/ui/explorer/server_flows.dart | 92 +++ lib/ui/explorer_page.dart | 758 ++------------------- test/explorer_pane_view_contract_test.dart | 208 ++++++ 6 files changed, 1153 insertions(+), 693 deletions(-) create mode 100644 lib/ui/explorer/pane_header.dart create mode 100644 lib/ui/explorer/pane_view.dart create mode 100644 lib/ui/explorer/path_dialog.dart create mode 100644 lib/ui/explorer/server_flows.dart create mode 100644 test/explorer_pane_view_contract_test.dart diff --git a/lib/ui/explorer/pane_header.dart b/lib/ui/explorer/pane_header.dart new file mode 100644 index 0000000..4b50036 --- /dev/null +++ b/lib/ui/explorer/pane_header.dart @@ -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 Function() onOpenRoot, onOpenShizuku; + final Future 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( + 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 _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 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 _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), + ), + ]; +} diff --git a/lib/ui/explorer/pane_view.dart b/lib/ui/explorer/pane_view.dart new file mode 100644 index 0000000..3741987 --- /dev/null +++ b/lib/ui/explorer/pane_view.dart @@ -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 children); + Widget header(ExplorerPane pane); + + /// [dir] 위의 안내들 (없는 것은 null) + List notices(ExplorerPane pane, String dir); + Widget marksBar(ExplorerPane pane, {String? listDir}); + void onMenu(ExplorerPane pane, FileEntry e, Offset at); + List dragPaths(ExplorerPane pane, FileEntry e); + Future 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('${r.kind != 0 ? 'k${r.kind}:' : r.isRoot ? 'root:' : r.isUp ? 'up:' : ''}${r.entry.path}'); + +/// 76: 줄이 옮겨 가도 같은 줄 위젯을 찾게 (키 → 지금 자리) +int? Function(Key) _rowIndexOf(List rows) { + Map? index; + return (key) => (index ??= {for (var i = 0; i < rows.length; i++) paneRowKey(rows[i]): i})[key]; +} + +/// 목록 위 안내들 (없으면 null). 좁은 화면 (접은 폴드 · 두 창) 에서 목록이 밀려 사라지지 않게 높이를 제한한다 +Widget? noticeArea(List notices, double paneHeight) { + final list = notices.whereType().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 []; + 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)), + ]), + ), + ]), + ), + ); + } +} diff --git a/lib/ui/explorer/path_dialog.dart b/lib/ui/explorer/path_dialog.dart new file mode 100644 index 0000000..d9f7305 --- /dev/null +++ b/lib/ui/explorer/path_dialog.dart @@ -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 createState() => _PathDialogState(); +} + +class _PathDialogState extends State { + 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 _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('확인'))), + ], + ); +} diff --git a/lib/ui/explorer/server_flows.dart b/lib/ui/explorer/server_flows.dart new file mode 100644 index 0000000..689c083 --- /dev/null +++ b/lib/ui/explorer/server_flows.dart @@ -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 enterPath(BuildContext context, ExplorerPane pane, {required bool android}) async { + _activate(pane); + final path = await showDialog(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 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 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 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 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}/'); + } +} diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index db58449..ca644cc 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -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 implements ExplorerHost { +class _ExplorerPageState extends State implements ExplorerHost, ExplorerPaneParts { AppController get c => widget.c; /// 탐색기의 상태 · 화면 밖의 일 (E5d-1) - 화면은 부르기만. 화면에 부탁할 것은 [ExplorerHost] 로 이 상태가 해 준다 @@ -545,16 +545,6 @@ class _ExplorerPageState extends State implements ExplorerHost { ); } - /// 목록 위 안내들 (없으면 null). 좁은 화면 (접은 폴드 · 두 창) 에서 목록이 밀려 사라지지 않게 높이를 제한한다 - Widget? _noticeArea(List notices, double paneHeight) { - final list = notices.whereType().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 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 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 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 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))); - } + @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 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 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 dragPaths(ExplorerPane pane, FileEntry e) => _dragPaths(pane, e); + @override + Future onDrop(DragLoad load, int to, String dest) => _onDrop(load, to, dest); + @override + void closeFilter(ExplorerPane pane) => _closeFilter(pane); + @override + void filterChanged() => setState(() {}); - 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)), - ]), - ), - ]), - ), - ); - } + /// 서버 추가 · 고치기 · 경로 입력 (E5d-3) + late final _servers = ExplorerServers(_ctl); // ───────── 누르기 · 고르기 (E5d-2: ExplorerSelect) ───────── @@ -3517,14 +3467,14 @@ class _ExplorerPageState extends State 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 = [ 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 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 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('${r.kind != 0 ? 'k${r.kind}:' : r.isRoot ? 'root:' : r.isUp ? 'up:' : ''}${r.entry.path}'); - - /// 76: 줄이 옮겨 가도 같은 줄 위젯을 찾게 (키 → 지금 자리) - static int? Function(Key) _rowIndexOf(List rows) { - Map? 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 []; - 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 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( - 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 _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 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 _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 _enterPath(ExplorerPane pane) async { - setState(() => _ctl.active = _ctl.panes.indexOf(pane)); - final path = await showDialog(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 _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 _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 _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 _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 _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 { diff --git a/test/explorer_pane_view_contract_test.dart b/test/explorer_pane_view_contract_test.dart new file mode 100644 index 0000000..863f4cb --- /dev/null +++ b/test/explorer_pane_view_contract_test.dart @@ -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 settle(WidgetTester t) async { + for (var i = 0; i < 10; i++) { + await t.runAsync(() => Future.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(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(find.widgetWithIcon(IconButton, Icons.arrow_back)).onPressed, isNull); + await t.runAsync(() => ctl.goTo(q, show)); + await t.pump(); + expect(t.widget(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 = []; + + 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 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 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 dragPaths(ExplorerPane pane, FileEntry e) => [e.path]; + @override + Future 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)); +}