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)), ]), ), ]), ), ); } } /// 창 하나의 틀 - 지금 창 테두리 · 누르면 지금 창 · 마우스 4 · 5번 ([onPointer]) · 82: 빈 곳에 놓으면 그 창의 지금 폴더로 /// (놓을 수 있으면 강조, 못 놓으면 빨간 테두리) class ExplorerPaneFrame extends StatelessWidget { const ExplorerPaneFrame( {super.key, required this.ctl, required this.index, required this.onPointer, required this.onDrop, required this.children}); final ExplorerController ctl; final int index; final void Function(int index, PointerDownEvent ev) onPointer; final Future Function(DragLoad load, int to, String dest) onDrop; final List children; @override Widget build(BuildContext context) { final i = index; final active = i == ctl.active; return Listener( // 75: 마우스가 올라간 창에 (지금 활성 창이 아니어도) onPointerDown: (ev) => onPointer(i, ev), child: GestureDetector( onTapDown: (_) { if (ctl.active != i) { ctl ..active = i ..update(); } }, // 82: 창의 빈 곳에 놓으면 그 창의 지금 폴더로. 놓을 수 있으면 강조, 못 놓으면 (같은 창 등) 빨간 테두리 child: DragTarget( onWillAcceptWithDetails: (d) => canDropInto(d.data, i, ctl.panes[i].current), onAcceptWithDetails: (d) => unawaited(onDrop(d.data, i, ctl.panes[i].current)), builder: (context, ok, no) => Container( margin: const EdgeInsets.all(4), decoration: BoxDecoration( color: ok.isNotEmpty ? JjColors.accent.withValues(alpha: 0.06) : JjColors.bg, border: Border.all( color: ok.isNotEmpty ? JjColors.accent : no.isNotEmpty ? JjColors.danger : active ? JjColors.accent : JjColors.border, width: ok.isNotEmpty || no.isNotEmpty ? 2 : (active ? 1.5 : 1)), borderRadius: BorderRadius.circular(6), ), child: Column(children: children), ), ), ), ); } }