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']);
+ });
+}