From ef1cb069c1db6dcd2882418fc1ac37b2a30d2826 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:33:07 +0900 Subject: [PATCH] =?UTF-8?q?WIP=2080=20=ED=8C=8C=EC=9D=BC=20=ED=83=90?= =?UTF-8?q?=EC=83=89=EA=B8=B0=20=ED=8F=B4=EB=8D=94=20=ED=81=AC=EA=B8=B0:?= =?UTF-8?q?=20=ED=95=84=EC=9A=94=ED=95=A0=20=EB=95=8C=20=EA=B3=84=EC=82=B0?= =?UTF-8?q?=20=C2=B7=20=EA=B8=B0=EC=96=B5,=20=ED=81=AC=EA=B8=B0=EC=88=9C?= =?UTF-8?q?=20=EC=A0=95=EB=A0=AC=20+=2079-a=20[=EA=B7=B8=20=EC=9E=90?= =?UTF-8?q?=EB=A6=AC=EB=A1=9C]=20=EB=92=A4=EC=97=90=EB=8F=84=20=EB=8F=8C?= =?UTF-8?q?=EC=95=84=EA=B0=80=EA=B8=B0=20=EC=A4=84=20(=EA=B2=80=ED=86=A0?= =?UTF-8?q?=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 80 (관리자 지시 F9): 폴더 크기를 목록에 표시하고 크기순으로 정렬 (필요할 때 계산하고 기억). - FolderSizes: 안의 파일 크기 합, 한 번에 하나씩 (디스크 · NAS 를 몰아 읽지 않게), 이번 실행 동안 기억. 읽지 못한 폴더가 있으면 "1.2GB+". 다시 읽는 폴더 (새로 고침 · 복사 · 이동 · 지우기) 는 그 폴더 · 안쪽 · 위 폴더 크기를 잊는다. - 설정 "폴더 크기": 크기순 정렬일 때만 (처음 값) / 늘 계산 / 계산 안 함. 길게 누르기 [폴더 크기 계산] 은 늘 된다. - 내 판단: 자동 계산은 지금 보고 있는 폴더 바로 안의 폴더만 - 트리에 보이는 위 폴더들 (C:\ · 사용자 폴더 …) 까지 하면 드라이브 전체를 읽느라 정작 보고 있는 폴더가 뒤로 밀렸다 (시험에서 발견). - 보이는 곳: Windows 탐색기 모양은 크기 열, TC 모양은 자리, X-plore · 좁은 화면은 폴더 이름 아래. 계산 중 "…". - 크기순 정렬은 아는 폴더 크기로 (모르는 폴더는 가장 작게, 폴더는 늘 위 - 예전 그대로). 크기가 나오면 다시 줄 세운다. - 측정은 바깥 영역 (Zone.root) 에서 - 화면을 그리다 맡긴 계산이 그 영역에 묶이지 않게 (위젯 시험에서 읽기가 끝나지 않았다). 79-a (관리자 판단 10/17): [그 자리로] 로 나가도 [찾기 결과로 돌아가기] 줄을 남긴다. - 시험: folder_size_test (합 · 기억 · 잊기 · "+" · 정렬), explorer_test 80 (크기순 자동 · 새로 고침 뒤 다시 · "안 함" + 길게 누르기), 79-a 그 자리로. Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 6 ++ assets/l10n/ja.json | 6 ++ assets/l10n/zh-Hans.json | 6 ++ lib/app/settings.dart | 5 ++ lib/core/file_ops.dart | 6 +- lib/core/folder_size.dart | 124 +++++++++++++++++++++++++++++++++++++ lib/ui/explorer_page.dart | 59 +++++++++++++++--- lib/ui/settings_page.dart | 15 +++++ test/explorer_test.dart | 52 ++++++++++++++++ test/folder_size_test.dart | 90 +++++++++++++++++++++++++++ 10 files changed, 360 insertions(+), 9 deletions(-) create mode 100644 lib/core/folder_size.dart create mode 100644 test/folder_size_test.dart diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 0ed77c7..6dd063c 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -599,6 +599,7 @@ "경로가 너무 길어 휴지통에 넣을 수 없습니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다).": "The path is too long for the Recycle Bin (the Windows Recycle Bin does not accept paths over 260 characters).", "경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다": "The path is too long (the Windows Recycle Bin doesn't accept paths over 260 characters) - you can delete it permanently", "경로를 복사했습니다: {0}": "Copied path: {0}", + "계산 안 함": "Don't calculate", "계속": "Always", "계속 (앱이 켜져 있는 동안)": "Always (while the app is running)", "계속 받기": "Keep downloading", @@ -771,6 +772,7 @@ "누르기": "Tap", "누르면 일정 바꾸기": "Tap to change the schedule", "늘 건너뛰기": "Always skip", + "늘 계산": "Always calculate", "늘 덮어쓰기": "Always overwrite", "늘 받아서 열기": "Always download and open", "늘 이름 바꾸기": "Always keep both (rename)", @@ -2173,6 +2175,7 @@ "크기 · 보정을 원래대로 돌려 코덱도 \"원본 유지\" 로 되돌렸습니다.": "Size and adjustments are back to original, so the codec was set back to \"Keep original\".", "크기 이상": "Min size", "크기를 바꾸면 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "Changing the size requires re-encoding, so the codec was changed from \"Keep original\" to H.264.", + "크기순 정렬일 때만 (기본)": "Only when sorting by size (default)", "크메르어": "Khmer", "키 있음": "Key saved", "타갈로그어": "Tagalog", @@ -2254,11 +2257,14 @@ "폴더 삭제: {0} (안의 {1}개 포함)": "Folder deleted: {0} (including {1} inside)", "폴더 선택": "Choose folder", "폴더 수정": "Edit folder", + "폴더 안 파일 크기의 합을 목록에 보입니다. 큰 폴더 · NAS 는 읽는 데 오래 걸릴 수 있습니다. 길게 누르기 [폴더 크기 계산] 은 늘 됩니다": "Shows the total size of the files inside each folder. Large folders or a NAS can take a while to read. Long-press [Calculate folder size] always works", "폴더 안의 것을 맞춥니다 (대상에 같은 이름의 폴더를 새로 만들지 않음).": "Syncs the folder contents (no same-named folder is created in the target).", "폴더 열기": "Open folder", "폴더 이름": "Folder name", "폴더 이름 바꾸기 · 이동": "Rename · move folder", "폴더 이름에 쓸 수 없는 글자가 있습니다 (\\ / : * ? \" < > |)": "The folder name has characters that cannot be used (\\ / : * ? \" < > |)", + "폴더 크기": "Folder size", + "폴더 크기 계산": "Calculate folder size", "폴더: {0}": "Folder: {0}", "폴더가 들어 있을 때": "When folders are included", "폴더가 아닙니다: {0}": "Not a folder: {0}", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index b36ee2a..6ea8227 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -599,6 +599,7 @@ "경로가 너무 길어 휴지통에 넣을 수 없습니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다).": "パスが長すぎてごみ箱に入れられません (Windows のごみ箱は 260 文字を超えるパスを受け付けません)。", "경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다": "パスが長すぎます (Windows のごみ箱は 260 文字を超えるパスを受け付けません) - 完全に削除はできます", "경로를 복사했습니다: {0}": "パスをコピーしました: {0}", + "계산 안 함": "計算しない", "계속": "常時", "계속 (앱이 켜져 있는 동안)": "常時 (アプリの起動中)", "계속 받기": "ダウンロードを続ける", @@ -771,6 +772,7 @@ "누르기": "タップ", "누르면 일정 바꾸기": "タップで予定を変更", "늘 건너뛰기": "常にスキップ", + "늘 계산": "常に計算", "늘 덮어쓰기": "常に上書き", "늘 받아서 열기": "常にダウンロードして開く", "늘 이름 바꾸기": "常に名前を変える", @@ -2173,6 +2175,7 @@ "크기 · 보정을 원래대로 돌려 코덱도 \"원본 유지\" 로 되돌렸습니다.": "サイズ・補正を元に戻したので、コーデックも「元のまま」に戻しました。", "크기 이상": "最小サイズ", "크기를 바꾸면 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "サイズを変えると再エンコードが必要なため、コーデックを「元のまま」から H.264 に変更しました。", + "크기순 정렬일 때만 (기본)": "サイズ順のときだけ (既定)", "크메르어": "クメール語", "키 있음": "キーあり", "타갈로그어": "タガログ語", @@ -2254,11 +2257,14 @@ "폴더 삭제: {0} (안의 {1}개 포함)": "フォルダーを削除: {0}(中の {1}個を含む)", "폴더 선택": "フォルダーを選択", "폴더 수정": "フォルダーを編集", + "폴더 안 파일 크기의 합을 목록에 보입니다. 큰 폴더 · NAS 는 읽는 데 오래 걸릴 수 있습니다. 길게 누르기 [폴더 크기 계산] 은 늘 됩니다": "フォルダー内のファイルサイズの合計を一覧に表示します。大きなフォルダー・NAS は読み込みに時間がかかることがあります。長押しの [フォルダーサイズを計算] はいつでも使えます", "폴더 안의 것을 맞춥니다 (대상에 같은 이름의 폴더를 새로 만들지 않음).": "フォルダーの中身を合わせます (対象に同じ名前のフォルダーは作りません)。", "폴더 열기": "フォルダーを開く", "폴더 이름": "フォルダー名", "폴더 이름 바꾸기 · 이동": "フォルダー名の変更・移動", "폴더 이름에 쓸 수 없는 글자가 있습니다 (\\ / : * ? \" < > |)": "フォルダー名に使えない文字があります(\\ / : * ? \" < > |)", + "폴더 크기": "フォルダーサイズ", + "폴더 크기 계산": "フォルダーサイズを計算", "폴더: {0}": "フォルダー: {0}", "폴더가 들어 있을 때": "フォルダーを含むとき", "폴더가 아닙니다: {0}": "フォルダーではありません: {0}", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 4a3f734..6b1c628 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -599,6 +599,7 @@ "경로가 너무 길어 휴지통에 넣을 수 없습니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다).": "路径过长,无法放入回收站 (Windows 回收站不接受超过 260 个字符的路径)。", "경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다": "路径过长 (Windows 回收站不接受超过 260 个字符的路径) - 可以永久删除", "경로를 복사했습니다: {0}": "已复制路径: {0}", + "계산 안 함": "不计算", "계속": "持续", "계속 (앱이 켜져 있는 동안)": "持续 (应用运行期间)", "계속 받기": "继续下载", @@ -771,6 +772,7 @@ "누르기": "点按", "누르면 일정 바꾸기": "点按以更改计划", "늘 건너뛰기": "总是跳过", + "늘 계산": "始终计算", "늘 덮어쓰기": "总是覆盖", "늘 받아서 열기": "始终下载后打开", "늘 이름 바꾸기": "总是重命名", @@ -2173,6 +2175,7 @@ "크기 · 보정을 원래대로 돌려 코덱도 \"원본 유지\" 로 되돌렸습니다.": "大小 · 调整已恢复原样,编解码器也恢复为“保持原样”。", "크기 이상": "最小大小", "크기를 바꾸면 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "更改大小需要重新编码,因此编解码器已从“保持原样”改为 H.264。", + "크기순 정렬일 때만 (기본)": "仅在按大小排序时 (默认)", "크메르어": "高棉语", "키 있음": "已有密钥", "타갈로그어": "他加禄语", @@ -2254,11 +2257,14 @@ "폴더 삭제: {0} (안의 {1}개 포함)": "已删除文件夹: {0}(含其中 {1} 个)", "폴더 선택": "选择文件夹", "폴더 수정": "编辑文件夹", + "폴더 안 파일 크기의 합을 목록에 보입니다. 큰 폴더 · NAS 는 읽는 데 오래 걸릴 수 있습니다. 길게 누르기 [폴더 크기 계산] 은 늘 됩니다": "在列表中显示文件夹内文件大小的总和。大型文件夹或 NAS 可能需要较长时间读取。长按 [计算文件夹大小] 始终可用", "폴더 안의 것을 맞춥니다 (대상에 같은 이름의 폴더를 새로 만들지 않음).": "同步文件夹内的内容 (不会在目标中新建同名文件夹)。", "폴더 열기": "打开文件夹", "폴더 이름": "文件夹名称", "폴더 이름 바꾸기 · 이동": "重命名 · 移动文件夹", "폴더 이름에 쓸 수 없는 글자가 있습니다 (\\ / : * ? \" < > |)": "文件夹名称中有不能使用的字符(\\ / : * ? \" < > |)", + "폴더 크기": "文件夹大小", + "폴더 크기 계산": "计算文件夹大小", "폴더: {0}": "文件夹: {0}", "폴더가 들어 있을 때": "包含文件夹时", "폴더가 아닙니다: {0}": "不是文件夹: {0}", diff --git a/lib/app/settings.dart b/lib/app/settings.dart index 8e06ef4..30f4821 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -413,6 +413,9 @@ class AppSettings { /// 정렬 · 숨긴 항목 String explorerSort = 'name'; bool explorerSortDesc = false; + + /// 80: 폴더 크기 계산 - 'sort' 크기순 정렬일 때만 (처음 값) · 'always' 늘 · 'off' 안 함 (길게 누르기 [폴더 크기 계산] 은 늘 됨) + String explorerFolderSize = 'sort'; bool explorerShowHidden = false; // ── 복사 · 이동 (파일 탐색기) · rsync · lsync (Rsync 화면) ── @@ -731,6 +734,7 @@ class AppSettings { 'explorerButtons': explorerButtons, 'explorerSort': explorerSort, 'explorerSortDesc': explorerSortDesc, + 'explorerFolderSize': explorerFolderSize, 'explorerShowHidden': explorerShowHidden, 'explorerPaths': explorerPaths, 'copyMethodFile': copyMethodFile, @@ -944,6 +948,7 @@ class AppSettings { ..explorerButtons = [for (final x in (j['explorerButtons'] as List?) ?? const []) '$x'] ..explorerSort = const ['date', 'size', 'type'].contains(j['explorerSort']) ? j['explorerSort'] as String : 'name' ..explorerSortDesc = j['explorerSortDesc'] as bool? ?? false + ..explorerFolderSize = const ['sort', 'always', 'off'].contains(j['explorerFolderSize']) ? j['explorerFolderSize'] as String : 'sort' ..explorerShowHidden = j['explorerShowHidden'] as bool? ?? false ..explorerPaths = [for (final x in (j['explorerPaths'] as List?) ?? const []) '$x'] ..copyMethodFile = _method(j['copyMethodFile']) diff --git a/lib/core/file_ops.dart b/lib/core/file_ops.dart index 407a5f0..652511d 100644 --- a/lib/core/file_ops.dart +++ b/lib/core/file_ops.dart @@ -30,13 +30,15 @@ class FileEntry { enum SortBy { name, date, size, type } /// 정렬: 폴더 먼저, 이름은 숫자를 숫자로 비교 (file2 < file10) -List sortEntries(List list, SortBy by, {bool descending = false}) { +/// 80: [dirSize] 가 있으면 크기순에서 폴더도 그 크기로 (모르는 폴더는 가장 작게) +List sortEntries(List list, SortBy by, {bool descending = false, int? Function(FileEntry dir)? dirSize}) { + int sizeOf(FileEntry e) => e.isDir ? (dirSize?.call(e) ?? -1) : e.size; int cmp(FileEntry a, FileEntry b) { if (a.isDir != b.isDir) return a.isDir ? -1 : 1; // 폴더는 거꾸로여도 늘 위 final r = switch (by) { SortBy.name => 0, SortBy.date => a.modified.compareTo(b.modified), - SortBy.size => a.size.compareTo(b.size), + SortBy.size => sizeOf(a).compareTo(sizeOf(b)), SortBy.type => a.ext.compareTo(b.ext), }; final v = r != 0 ? r : naturalCompare(a.name, b.name); diff --git a/lib/core/folder_size.dart b/lib/core/folder_size.dart new file mode 100644 index 0000000..fa10277 --- /dev/null +++ b/lib/core/folder_size.dart @@ -0,0 +1,124 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import 'file_ops.dart'; +import 'vfs.dart'; + +/// 80: 폴더 크기 (안의 파일 크기 합) - 필요할 때 계산하고 이번 실행 동안 기억한다. +/// 한 번에 하나씩 (디스크 · NAS 를 몰아 읽지 않게). 바뀐 폴더는 [forget] 으로 그 폴더와 위 폴더들을 지운다. +class FolderSizes extends ChangeNotifier { + final _known = {}; + final _waiting = []; + final _queued = {}; + bool _running = false; + bool _disposed = false; + + /// 읽기 (시험에서 바꿔 끼움) + final Future> Function(String dir) list; + + FolderSizes({Future> Function(String dir)? list}) : list = list ?? _vList; + + static Future> _vList(String dir) async => [ + for (final e in await vList(dir, strict: true)) FileEntry(e.path, isDir: e.isDir, size: e.size, modified: e.modified), + ]; + + static String _key(String path) => isDav(path) ? vNorm(path) : path.toLowerCase(); + + /// 아는 크기 (없으면 null) + FolderSize? of(String path) => _known[_key(path)]; + + /// 계산 중이거나 기다리는 중인지 + bool busy(String path) => _queued.contains(_key(path)); + + /// 계산해 두기 (이미 알거나 기다리는 중이면 그대로) + void request(String path) { + final k = _key(path); + if (_known.containsKey(k) || _queued.contains(k)) return; + _queued.add(k); + _waiting.add(path); + notifyListeners(); + // 화면을 그리는 중 (프레임 뒤 콜백) 에 맡겨도 그 영역에 묶이지 않게 바깥 영역에서 읽는다 + // (위젯 시험의 가짜 시간 영역에서는 실제 파일 읽기가 끝나지 않았다) + Zone.root.run(() => unawaited(_pump())); + } + + /// 이 폴더 · 그 안의 폴더들 · 위 폴더들의 크기를 잊는다 (안의 것이 바뀜 - 새로 고침 · 복사 · 이동 · 지우기) + void forget(String path) { + final k = _key(path); + final before = _known.length; + _known.removeWhere((x, _) => x.startsWith('$k\\') || x.startsWith('$k/')); + var changed = _known.length != before; + var d = path; + while (true) { + changed |= _known.remove(_key(d)) != null; + final up = vDirname(d); + if (up == d || up.isEmpty) break; + d = up; + } + if (changed && !_disposed) notifyListeners(); + } + + Future _pump() async { + if (_running) return; + _running = true; + try { + while (_waiting.isNotEmpty && !_disposed) { + final path = _waiting.removeAt(0); + final size = await _measure(path); + if (_disposed) return; + _queued.remove(_key(path)); + _known[_key(path)] = size; + notifyListeners(); + } + } finally { + _running = false; + } + } + + /// 안의 파일 크기 합. 읽지 못한 폴더가 있으면 [FolderSize.partial] (화면에 "+") + Future _measure(String root) async { + var bytes = 0; + var partial = false; + final pending = [root]; + while (pending.isNotEmpty && !_disposed) { + final dir = pending.removeLast(); + // 안쪽 폴더를 이미 알면 다시 읽지 않는다 + final known = dir == root ? null : _known[_key(dir)]; + if (known != null) { + bytes += known.bytes; + partial |= known.partial; + continue; + } + try { + for (final e in await list(dir)) { + if (e.isDir) { + pending.add(e.path); + } else if (e.size > 0) { + bytes += e.size; + } + } + } catch (_) { + partial = true; + } + } + return FolderSize(bytes, partial: partial); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} + +class FolderSize { + final int bytes; + + /// 읽지 못한 폴더가 있어 실제보다 작을 수 있음 + final bool partial; + const FolderSize(this.bytes, {this.partial = false}); + + /// "1.2GB" · 읽지 못한 곳이 있으면 "1.2GB+" + String get label => '${formatSize(bytes)}${partial ? '+' : ''}'; +} diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index b9c6aa9..776027a 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -45,6 +45,7 @@ import '../l10n/tr.dart'; import 'explorer_bookmarks.dart'; import 'explorer_search.dart'; import 'batch_rename_dialog.dart'; +import '../core/folder_size.dart'; import '../core/batch_rename.dart'; /// 파일 탐색기 (X-plore 참고): 두 창 (트리 목록) + 가운데 기능 버튼 줄. @@ -317,6 +318,38 @@ class _ExplorerPageState extends State { /// 화면 단축키 (F5 · Delete …) 를 받는 곳. 76: 빠른 필터 칸을 닫으면 여기로 돌려준다 final _keys = FocusNode(debugLabel: 'explorer'); + /// 80: 폴더 크기 (필요할 때 계산하고 이번 실행 동안 기억) + late final _sizes = FolderSizes()..addListener(_onSizes); + + /// 80: 폴더 크기가 나오면 - 크기순이면 다시 줄 세우고 화면 갱신 + void _onSizes() { + if (!mounted) return; + if (c.settings.explorerSort == 'size') { + for (final pane in _panes) { + for (final k in pane.cache.keys.toList()) { + pane.cache[k] = _sorted(pane.cache[k]!); + } + } + } + setState(() {}); + } + + /// 80: 폴더 크기 글 (모르면 null). 설정이 자동이면 계산을 맡긴다 - + /// 지금 보고 있는 폴더 바로 안의 폴더만 (트리에 보이는 위 폴더들 (C:\ · 사용자 폴더 …) 은 드라이브 전체를 읽게 되어 자동으로 하지 않는다) + String? _dirSize(_Pane pane, FileEntry e) { + final s = _sizes.of(e.path); + if (s != null) return s.label; + if (_sizes.busy(e.path)) return '…'; + final mode = c.settings.explorerFolderSize; + if ((mode == 'always' || (mode == 'sort' && c.settings.explorerSort == 'size')) && samePath(vDirname(e.path), pane.current)) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _sizes.request(e.path); + }); + return '…'; + } + return null; + } + @override void initState() { super.initState(); @@ -361,6 +394,7 @@ class _ExplorerPageState extends State { @override void dispose() { _keys.dispose(); + _sizes.dispose(); if (widget.rsync) { ExplorerPage._openRsync--; } else { @@ -725,6 +759,8 @@ class _ExplorerPageState extends State { Future _load(_Pane pane, String dir, {bool force = false}) async { if (!force && pane.cache.containsKey(dir)) return; + // 80: 다시 읽는 폴더는 안이 바뀌었을 수 있다 - 그 폴더와 위 폴더들의 크기를 잊는다 + if (force) _sizes.forget(dir); pane.loading.add(dir); pane.changed(); try { @@ -762,7 +798,7 @@ class _ExplorerPageState extends State { List _sorted(List list) { final by = SortBy.values.firstWhere((s) => s.name == c.settings.explorerSort, orElse: () => SortBy.name); - return sortEntries(list, by, descending: c.settings.explorerSortDesc); + return sortEntries(list, by, descending: c.settings.explorerSortDesc, dirSize: (d) => _sizes.of(d.path)?.bytes); } /// 그 폴더로: 저장 장치부터 그 폴더까지 펼치고 지금 폴더로 정한다 @@ -1310,6 +1346,8 @@ class _ExplorerPageState extends State { // 74: 폰에서 범위 고르기 - 첫 항목을 표시한 뒤 마지막 항목을 길게 눌러 if (_selecting && _canRange(pane, e)) ('range', Icons.unfold_more, tr('여기까지 범위 고르기')), if (e.isDir) ('open', Icons.folder_open, tr('열기')), + // 80: 이 폴더 크기를 지금 계산 (설정이 "안 함" 이어도) + if (e.isDir) ('folderSize', Icons.data_usage, tr('폴더 크기 계산')), if (e.isDir && dual) ('openOther', Icons.vertical_split_outlined, tr('다른 창에서 열기')), if (e.isDir) ('playFolder', Icons.play_circle_outline, tr('이 폴더의 동영상 재생')), if (video || audio) ('playInternal', Icons.play_circle_outline, tr('내장 플레이어로 재생')), @@ -1395,6 +1433,10 @@ class _ExplorerPageState extends State { case 'select': setState(() => _selecting = true); if (!_isMarked(pane, e.path)) await _toggleMark(pane, e); + case 'folderSize': + _sizes + ..forget(e.path) + ..request(e.path); case 'rename': if (_isMarked(pane, e.path) && pane.marked.length > 1) { await _batchRename(pane, pane.marked.toList()); @@ -2685,7 +2727,7 @@ class _ExplorerPageState extends State { /// 79: 마지막 찾기 조건 (이번 실행 동안 다음 찾기 창의 처음 값) SearchConditions _lastSearch = const SearchConditions(); - /// 79-a: 결과 화면에서 복사 · 이동 · 지우기를 한 뒤 [찾기 결과로 돌아가기] 로 다시 열 결과 (다음 찾기 · ✕ · 결과 화면을 그냥 닫으면 없어짐) + /// 79-a: 결과 화면에서 복사 · 이동 · 지우기 · [그 자리로] 를 한 뒤 [찾기 결과로 돌아가기] 로 다시 열 결과 (다음 찾기 · ✕ · 결과 화면을 그냥 닫으면 없어짐) ({_Pane pane, String root, SearchConditions cond, List found, bool done, Map moved})? _searchBack; /// 79: 찾기 - 이름 · 크기 · 바뀐 날 · 종류 조건 → 결과 목록 화면 (고르고 그대로 복사 · 이동 · 지우기, 누르면 열기 · 재생) @@ -2748,7 +2790,8 @@ class _ExplorerPageState extends State { setState(() => _searchBack = null); return; } - if (act.kind != 'reveal') { + // 관리자 판단 (10/17): [그 자리로] 로 나가도 남긴다 - 한 곳을 확인하고 다음 결과로 가는 것이 가장 흔한 쓰임 + { setState(() => _searchBack = ( pane: pane, root: root, @@ -2760,7 +2803,6 @@ class _ExplorerPageState extends State { } switch (act.kind) { case 'reveal': - setState(() => _searchBack = null); final path = act.paths.first; final dir = await vIsDir(path); await _goTo(pane, dir ? path : vDirname(path), fresh: true); @@ -3856,7 +3898,10 @@ class _ExplorerPageState extends State { children: [ Text(name, maxLines: 1, overflow: TextOverflow.ellipsis, style: nameStyle), if (look.rich && e.isDir && pane.errors[e.path] != null) - Text(tr('읽을 수 없음'), style: dim.copyWith(color: Colors.redAccent)), + Text(tr('읽을 수 없음'), style: dim.copyWith(color: Colors.redAccent)) + // 80: 열 없는 모양 (X-plore · 좁은 화면) 은 폴더 이름 아래에 크기 + else if (e.isDir && !row.isRoot && !row.isUp && (!look.columns || compact) && _dirSize(pane, e) != null) + Text(_dirSize(pane, e)!, style: dim, maxLines: 1), ], ), ), @@ -3875,12 +3920,12 @@ class _ExplorerPageState extends State { Expanded(flex: 6, child: nameCell()), cell(_date(e.modified), 3), cell(typeLabel(e), 2), - cell(e.isDir ? '' : formatSize(e.size), 2, align: TextAlign.end), + cell(e.isDir ? (_dirSize(pane, e) ?? '') : formatSize(e.size), 2, align: TextAlign.end), ] : [ Expanded(flex: 6, child: nameCell()), cell(e.isDir ? '' : e.ext, 1), - cell(e.isDir ? '' : formatSize(e.size), 2, align: TextAlign.end), + cell(e.isDir ? (_dirSize(pane, e) ?? '') : formatSize(e.size), 2, align: TextAlign.end), const SizedBox(width: 8), cell(_date(e.modified), 3), ]; diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart index 9ff0f3d..b7e4209 100644 --- a/lib/ui/settings_page.dart +++ b/lib/ui/settings_page.dart @@ -767,6 +767,21 @@ class _SettingsPageState extends State { onChanged: (v) => c.updateSettings((x) => x.explorerClick = v!), ), ), + // 80: 폴더 크기 (필요할 때 계산하고 이번 실행 동안 기억) + SettingTile( + title: Text(tr('폴더 크기')), + subtitle: Text(tr('폴더 안 파일 크기의 합을 목록에 보입니다. 큰 폴더 · NAS 는 읽는 데 오래 걸릴 수 있습니다. ' + '길게 누르기 [폴더 크기 계산] 은 늘 됩니다')), + trailing: DropdownButton( + value: s.explorerFolderSize, + items: [ + DropdownMenuItem(value: 'sort', child: Text(tr('크기순 정렬일 때만 (기본)'))), + DropdownMenuItem(value: 'always', child: Text(tr('늘 계산'))), + DropdownMenuItem(value: 'off', child: Text(tr('계산 안 함'))), + ], + onChanged: (v) => c.updateSettings((x) => x.explorerFolderSize = v ?? 'sort'), + ), + ), SettingTile( title: Text(tr('스타일')), subtitle: Text(tr('아이콘 · 목록 모양: X-plore (썸네일 · 두 줄) · Windows 탐색기 (컬러 아이콘 · 열) · ' diff --git a/test/explorer_test.dart b/test/explorer_test.dart index 4781718..a3cb3b3 100644 --- a/test/explorer_test.dart +++ b/test/explorer_test.dart @@ -964,6 +964,58 @@ void main() { }); }); + testWidgets('80: 크기순 정렬이면 폴더 크기를 계산해 보이고, "안 함" 이어도 길게 누르기 [폴더 크기 계산]', (tester) async { + File(p.join(left, 'sub', 'big.bin')).writeAsBytesSync(List.filled(3000, 1)); + c.settings + ..explorerSort = 'size' + ..explorerSortDesc = true; + await open(tester); + // sub = inner.txt (5) + big.bin (3000) + await settle(tester, () => find.text('3KB').evaluate().isNotEmpty); + expect(find.text('3KB'), findsWidgets); + + // 바뀌면 (새로 고침) 다시 계산 + File(p.join(left, 'sub', 'more.bin')).writeAsBytesSync(List.filled(3000, 1)); + await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.f5)); + await settle(tester, () => find.text('6KB').evaluate().isNotEmpty); + expect(find.text('6KB'), findsWidgets); + }); + + testWidgets('80: "계산 안 함" - 크기를 보이지 않다가 [폴더 크기 계산] 을 누르면', (tester) async { + File(p.join(left, 'sub', 'big.bin')).writeAsBytesSync(List.filled(3000, 1)); + c.settings + ..explorerSort = 'size' + ..explorerFolderSize = 'off'; + await open(tester); + await settle(tester, () => false, rounds: 15); + expect(find.text('3KB'), findsNothing); + await tester.longPress(find.text('sub').first); + await calm(tester); + await act(tester, () => tester.tap(find.text('폴더 크기 계산'))); + await settle(tester, () => find.text('3KB').evaluate().isNotEmpty); + expect(find.text('3KB'), findsWidgets); + }); + + testWidgets('79-a: [그 자리로] 로 나가도 [찾기 결과로 돌아가기] 가 남는다 (관리자 판단) · ✕ 로 닫음', (tester) async { + await open(tester); + await act(tester, () => tester.tap(find.text('찾기').first)); + await calm(tester); + await tester.enterText(find.widgetWithText(TextField, '이름 (비워도 됨)'), 'inner'); + await tester.pump(); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '찾기'))); + await settle(tester, () => find.text('찾기 결과 · 1개').evaluate().isNotEmpty); + await act(tester, () => tester.tap(find.byTooltip('그 자리로'))); + await settle(tester, () => find.text('찾기 결과로 돌아가기').evaluate().isNotEmpty); + await calm(tester); // 결과 화면이 닫히는 움직임까지 + expect(find.byType(SearchResultsPage), findsNothing); + await act(tester, () => tester.tap(find.text('찾기 결과로 돌아가기'))); + await settle(tester, () => find.text('찾기 결과 · 1개').evaluate().isNotEmpty); + // 결과 화면을 그냥 닫으면 없어진다 + await act(tester, () => tester.pageBack()); + await calm(tester); + expect(find.text('찾기 결과로 돌아가기'), findsNothing); + }); + testWidgets('79: 찾기 결과를 목록으로 - 이름 없이 종류 조건으로 찾고, 골라서 그대로 다른 창으로 복사', (tester) async { await open(tester); File(p.join(left, 'sub', 'big.mkv')) diff --git a/test/folder_size_test.dart b/test/folder_size_test.dart new file mode 100644 index 0000000..8243dbd --- /dev/null +++ b/test/folder_size_test.dart @@ -0,0 +1,90 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/core/file_ops.dart'; +import 'package:jj_mkvmaker/core/folder_size.dart'; +import 'package:path/path.dart' as p; + +/// 80: 폴더 크기 - 필요할 때 계산 · 기억, 읽지 못한 곳은 "+", 바뀌면 그 폴더와 위 폴더를 잊음, 크기순 정렬 +void main() { + late Directory tmp; + setUp(() { + tmp = Directory.systemTemp.createTempSync('jj_fsize80_'); + File(p.join(tmp.path, 'a', 'x.bin')) + ..createSync(recursive: true) + ..writeAsBytesSync(List.filled(1000, 0)); + File(p.join(tmp.path, 'a', 'deep', 'y.bin')) + ..createSync(recursive: true) + ..writeAsBytesSync(List.filled(24, 0)); + File(p.join(tmp.path, 'b', 'z.bin')) + ..createSync(recursive: true) + ..writeAsBytesSync(List.filled(5, 0)); + }); + tearDown(() => tmp.deleteSync(recursive: true)); + + Future idle(FolderSizes s, String path) async { + for (var i = 0; i < 200 && s.of(path) == null; i++) { + await Future.delayed(const Duration(milliseconds: 10)); + } + } + + test('안쪽 폴더까지 합 · 기억 · 바뀌면 그 폴더와 위 폴더를 잊음', () async { + var reads = 0; + final s = FolderSizes(list: (d) { + reads++; + return listEntries(d, showHidden: true); + }); + final a = p.join(tmp.path, 'a'); + s.request(a); + expect(s.busy(a), isTrue); + await idle(s, a); + expect(s.of(a)!.bytes, 1024); + expect(s.of(a)!.partial, isFalse); + final first = reads; + s.request(a); + await Future.delayed(const Duration(milliseconds: 20)); + expect(reads, first, reason: '아는 것은 다시 읽지 않는다'); + + // 안쪽 폴더 크기만 잊어도 위 폴더 (a) 도 잊는다 + s.request(p.join(a, 'deep')); + await idle(s, p.join(a, 'deep')); + s.forget(p.join(a, 'deep')); + expect(s.of(a), isNull); + expect(s.of(p.join(a, 'deep')), isNull); + + // 위 폴더를 잊으면 (새로 고침) 안쪽 폴더도 + s.request(p.join(a, 'deep')); + await idle(s, p.join(a, 'deep')); + s.forget(tmp.path); + expect(s.of(p.join(a, 'deep')), isNull); + s.dispose(); + }); + + test('읽지 못한 폴더가 있으면 "+" (실제보다 작을 수 있음)', () async { + final a = p.join(tmp.path, 'a'); + final s = FolderSizes(list: (d) { + if (p.basename(d) == 'deep') throw const FileSystemException('권한 없음'); + return listEntries(d, showHidden: true); + }); + s.request(a); + await idle(s, a); + expect(s.of(a)!.bytes, 1000); + expect(s.of(a)!.label, '1000B+'); + s.dispose(); + }); + + test('크기순 정렬: 아는 폴더는 그 크기로, 모르는 폴더는 가장 작게 (폴더는 늘 위)', () { + final now = DateTime(2026); + final list = [ + FileEntry('/r/f.bin', isDir: false, size: 10, modified: now), + FileEntry('/r/small', isDir: true, modified: now), + FileEntry('/r/big', isDir: true, modified: now), + FileEntry('/r/unknown', isDir: true, modified: now), + ]; + final sizes = {'/r/small': 5, '/r/big': 5000}; + final up = sortEntries(list, SortBy.size, dirSize: (d) => sizes[d.path]); + expect(up.map((e) => e.name), ['unknown', 'small', 'big', 'f.bin']); + final down = sortEntries(list, SortBy.size, descending: true, dirSize: (d) => sizes[d.path]); + expect(down.map((e) => e.name), ['big', 'small', 'unknown', 'f.bin']); + }); +}