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
RyuJongJin 2026-10-22 11:57:37 +09:00
parent e6e4499f22
commit b5b1b13d61
6 changed files with 1153 additions and 693 deletions

View File

@ -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),
),
];
}

View File

@ -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)),
]),
),
]),
),
);
}
}

View File

@ -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('확인'))),
],
);
}

View File

@ -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}/');
}
}

View File

@ -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)));
}
@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(() {});
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<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> {

View File

@ -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));
}