WIP X-plore 비교 5-1 ⑤ 저장 공간 분석에서 그 자리 작업 (관리자 지시 10/21): 큰 폴더 · 큰 파일 줄의 ⋮ (길게 누르기 · 오른쪽 단추도) [그 자리로] · [지우기 (확인 · 휴지통)] · [이동 (붙여넣기로)] - 지우기는 예전 관리자 판단대로 탐색기의 지우기 (같은 확인 창 · 휴지통) 를 부르고, 실제로 지워진 것만 다시 훑지 않고 결과에서 뺌 (위 폴더들의 크기 · 파일 수도 덜어 냄), 취소면 그대로. 이동은 탐색기의 [이동 (붙여넣기로)] 에 담고 안내 · 시험 (결과에서 빼기 · 화면 취소 / 확인 / 담기) (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>xplore_p3b
parent
dcc805c4b8
commit
7dfbc400d1
|
|
@ -2176,6 +2176,7 @@
|
|||
"이동: 옮길 폴더를 골라 이동 버튼을 만듭니다 (환경 설정 > MKV 만들기 > 이동 버튼에서 여러 개 만들 수 있음)": "Move: choose a folder to create a move button (you can make several in Settings > Make MKV > Move buttons)",
|
||||
"이동: 이 동영상을 이동 폴더로 옮깁니다{0}": "Move: moves this video to the move folder{0}",
|
||||
"이동: 체크한 동영상 {0}개를 이동 폴더로 옮깁니다": "Move: moves the {0} checked videos to the move folder",
|
||||
"이동할 것으로 담았습니다 - 탐색기에서 넣을 폴더를 길게 눌러 [붙여넣기]": "Ready to move - in the file explorer, long-press the destination folder and choose [Paste]",
|
||||
"이동할 폴더가 정해지지 않았습니다 (환경 설정 > MKV 만들기 > 이동 버튼)": "No move folder is set (Settings > Make MKV > Move buttons)",
|
||||
"이동할 폴더가 정해지지 않았습니다 (환경 설정 > 저장 위치)": "No move folder is set (Settings > Save locations)",
|
||||
"이동할 폴더를 만들 수 없습니다: {0} ({1})": "Cannot create the move folder: {0} ({1})",
|
||||
|
|
@ -2522,6 +2523,7 @@
|
|||
"지우고 맞추기": "Delete and sync",
|
||||
"지우고 초기화": "Delete and reset",
|
||||
"지우기": "Delete",
|
||||
"지우기 (확인 · 휴지통)": "Delete (confirm · trash)",
|
||||
"지우기 포함": "with delete",
|
||||
"지우기가 들어 있는 작업입니다": "This task deletes files",
|
||||
"지우기가 들어간 실행이라, 비교하지 못하면 실행할 수 없습니다. 원본 · 대상을 확인한 뒤 [다시 비교] 를 누르세요.": "This run deletes files, so it can't run without a comparison. Check the source and target, then press [Compare again].",
|
||||
|
|
|
|||
|
|
@ -2176,6 +2176,7 @@
|
|||
"이동: 옮길 폴더를 골라 이동 버튼을 만듭니다 (환경 설정 > MKV 만들기 > 이동 버튼에서 여러 개 만들 수 있음)": "移動: 移動先フォルダーを選んで移動ボタンを作ります(環境設定 > MKV 作成 > 移動ボタンで複数作れます)",
|
||||
"이동: 이 동영상을 이동 폴더로 옮깁니다{0}": "移動: この動画を移動フォルダーに移します{0}",
|
||||
"이동: 체크한 동영상 {0}개를 이동 폴더로 옮깁니다": "移動: チェックした動画 {0}個を移動フォルダーに移します",
|
||||
"이동할 것으로 담았습니다 - 탐색기에서 넣을 폴더를 길게 눌러 [붙여넣기]": "移動するものとして保持しました - ファイルエクスプローラーで移動先のフォルダーを長押しして [貼り付け]",
|
||||
"이동할 폴더가 정해지지 않았습니다 (환경 설정 > MKV 만들기 > 이동 버튼)": "移動先フォルダーが決まっていません(環境設定 > MKV 作成 > 移動ボタン)",
|
||||
"이동할 폴더가 정해지지 않았습니다 (환경 설정 > 저장 위치)": "移動フォルダーが決まっていません(環境設定 > 保存先)",
|
||||
"이동할 폴더를 만들 수 없습니다: {0} ({1})": "移動フォルダーを作成できません: {0}({1})",
|
||||
|
|
@ -2522,6 +2523,7 @@
|
|||
"지우고 맞추기": "削除して同期",
|
||||
"지우고 초기화": "削除して初期化",
|
||||
"지우기": "削除",
|
||||
"지우기 (확인 · 휴지통)": "削除 (確認・ごみ箱)",
|
||||
"지우기 포함": "削除あり",
|
||||
"지우기가 들어 있는 작업입니다": "削除を含む作業です",
|
||||
"지우기가 들어간 실행이라, 비교하지 못하면 실행할 수 없습니다. 원본 · 대상을 확인한 뒤 [다시 비교] 를 누르세요.": "削除を含む実行のため、比較できないと実行できません。元・対象を確認してから [もう一度比較] を押してください。",
|
||||
|
|
|
|||
|
|
@ -2176,6 +2176,7 @@
|
|||
"이동: 옮길 폴더를 골라 이동 버튼을 만듭니다 (환경 설정 > MKV 만들기 > 이동 버튼에서 여러 개 만들 수 있음)": "移动: 选择目标文件夹以创建移动按钮(可在 设置 > 制作 MKV > 移动按钮 中创建多个)",
|
||||
"이동: 이 동영상을 이동 폴더로 옮깁니다{0}": "移动: 将此视频移到移动目标文件夹{0}",
|
||||
"이동: 체크한 동영상 {0}개를 이동 폴더로 옮깁니다": "移动: 将勾选的 {0} 个视频移到移动目标文件夹",
|
||||
"이동할 것으로 담았습니다 - 탐색기에서 넣을 폴더를 길게 눌러 [붙여넣기]": "已准备移动 - 在文件管理器中长按目标文件夹并选择 [粘贴]",
|
||||
"이동할 폴더가 정해지지 않았습니다 (환경 설정 > MKV 만들기 > 이동 버튼)": "尚未设置移动目标文件夹(设置 > 制作 MKV > 移动按钮)",
|
||||
"이동할 폴더가 정해지지 않았습니다 (환경 설정 > 저장 위치)": "尚未设置移动目标文件夹(设置 > 保存位置)",
|
||||
"이동할 폴더를 만들 수 없습니다: {0} ({1})": "无法创建移动目标文件夹: {0}({1})",
|
||||
|
|
@ -2522,6 +2523,7 @@
|
|||
"지우고 맞추기": "删除并同步",
|
||||
"지우고 초기화": "删除并重置",
|
||||
"지우기": "删除",
|
||||
"지우기 (확인 · 휴지통)": "删除 (确认 · 回收站)",
|
||||
"지우기 포함": "含删除",
|
||||
"지우기가 들어 있는 작업입니다": "此任务包含删除",
|
||||
"지우기가 들어간 실행이라, 비교하지 못하면 실행할 수 없습니다. 원본 · 대상을 확인한 뒤 [다시 비교] 를 누르세요.": "此操作包含删除,无法比较时不能执行。请检查源和目标后按 [重新比较]。",
|
||||
|
|
|
|||
|
|
@ -32,6 +32,41 @@ class StorageScan {
|
|||
|
||||
/// [dir] 바로 안의 폴더를 큰 것부터
|
||||
List<String> biggestIn(String dir) => [...?children[key(dir)]]..sort((a, b) => sizeOf(b).compareTo(sizeOf(a)));
|
||||
|
||||
/// X-plore 비교 5-1 ⑤ (관리자 지시 10/21): 지운 것 · 옮긴 것을 다시 훑지 않고 결과에서 뺀다 - 폴더면 그 아래 모두, 파일이면 그것.
|
||||
/// 위 폴더들 (분석한 곳까지) 의 크기 · 파일 수에서 덜어 낸다. [fileSize] 는 큰 파일 목록에 없는 파일의 크기 (모르면 0)
|
||||
void remove(String path, {int fileSize = 0}) {
|
||||
final k = key(path);
|
||||
var (size, files) = (fileSize, 1);
|
||||
final folder = folders[k];
|
||||
if (folder != null) {
|
||||
(size, files) = folder;
|
||||
// 그 아래 폴더 모두
|
||||
final stack = [k];
|
||||
while (stack.isNotEmpty) {
|
||||
final d = stack.removeLast();
|
||||
folders.remove(d);
|
||||
for (final c in children.remove(d) ?? const <String>[]) {
|
||||
stack.add(key(c));
|
||||
}
|
||||
}
|
||||
bigFiles.removeWhere((f) => isSameOrInside(f.path, path));
|
||||
} else {
|
||||
final i = bigFiles.indexWhere((f) => key(f.path) == k);
|
||||
if (i >= 0) size = bigFiles.removeAt(i).size;
|
||||
}
|
||||
final parent = vDirname(path);
|
||||
children[key(parent)]?.removeWhere((c) => key(c) == k);
|
||||
// 위 폴더들 (분석한 곳까지) 에서 덜어 냄
|
||||
var d = parent;
|
||||
while (true) {
|
||||
final dk = key(d);
|
||||
final v = folders[dk];
|
||||
if (v != null) folders[dk] = ((v.$1 - size).clamp(0, 1 << 62), (v.$2 - files).clamp(0, 1 << 62));
|
||||
if (dk == key(root) || samePath(vDirname(d), d)) break;
|
||||
d = vDirname(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 87: 고른 폴더 (또는 드라이브) 아래를 한 번 훑어 [StorageScan] 을 만든다
|
||||
|
|
|
|||
|
|
@ -1944,7 +1944,11 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
);
|
||||
if (ok != true || !mounted) return;
|
||||
}
|
||||
final path = await StorageAnalysisPage.open(context, dir);
|
||||
// X-plore 비교 5-1 ⑤ (관리자 지시 10/21): 분석 화면에서도 이 탐색기의 지우기 (확인 · 휴지통) · 이동 (붙여넣기로)
|
||||
final path = await StorageAnalysisPage.open(context, dir, onDelete: (paths) async {
|
||||
await _delete(pane, paths);
|
||||
return [for (final x in paths) if (!await vExists(x)) x];
|
||||
}, onCut: (paths) => _toClipboard(paths, move: true));
|
||||
if (path == null || !mounted) return;
|
||||
final isDir = await vIsDir(path);
|
||||
await _goTo(pane, isDir ? path : vDirname(path), fresh: true);
|
||||
|
|
|
|||
|
|
@ -9,17 +9,26 @@ import 'theme.dart';
|
|||
|
||||
/// 87: 저장 공간 분석 - 고른 폴더 · 드라이브 아래의 큰 폴더 · 큰 파일 차례.
|
||||
/// 폴더를 누르면 그 안으로 (한 번 훑은 결과로 바로), [그 자리로] 는 탐색기에서 그 자리로 (돌려주는 값).
|
||||
/// 지우기는 여기서 하지 않는다 - 탐색기의 지우기 (확인 · 휴지통) 로만 (관리자 판단)
|
||||
/// 지우기는 탐색기의 지우기 (확인 · 휴지통) 로만 (관리자 판단) - X-plore 비교 5-1 ⑤ (10/21): 이 화면의 ⋮ · 길게 누르기에서 탐색기의
|
||||
/// 그 지우기 ([onDelete]) 를 부르고, 지운 것은 다시 훑지 않고 결과에서 뺀다. 이동은 탐색기의 [이동 (붙여넣기로)] ([onCut])
|
||||
class StorageAnalysisPage extends StatefulWidget {
|
||||
final String root;
|
||||
|
||||
/// 시험에서 바꿔 끼움
|
||||
final StorageScanner? scanner;
|
||||
const StorageAnalysisPage({super.key, required this.root, this.scanner});
|
||||
|
||||
/// 탐색기의 지우기 (확인 창 · 휴지통). 실제로 지워진 것을 돌려준다 (취소면 빈 목록)
|
||||
final Future<List<String>> Function(List<String> paths)? onDelete;
|
||||
|
||||
/// 탐색기의 [이동 (붙여넣기로)] 에 담기
|
||||
final void Function(List<String> paths)? onCut;
|
||||
const StorageAnalysisPage({super.key, required this.root, this.scanner, this.onDelete, this.onCut});
|
||||
|
||||
/// 연다. 탐색기에서 갈 자리 (없으면 null)
|
||||
static Future<String?> open(BuildContext context, String root) =>
|
||||
Navigator.of(context).push<String>(MaterialPageRoute(builder: (_) => StorageAnalysisPage(root: root)));
|
||||
static Future<String?> open(BuildContext context, String root,
|
||||
{Future<List<String>> Function(List<String> paths)? onDelete, void Function(List<String> paths)? onCut}) =>
|
||||
Navigator.of(context)
|
||||
.push<String>(MaterialPageRoute(builder: (_) => StorageAnalysisPage(root: root, onDelete: onDelete, onCut: onCut)));
|
||||
|
||||
@override
|
||||
State<StorageAnalysisPage> createState() => _StorageAnalysisPageState();
|
||||
|
|
@ -63,6 +72,54 @@ class _StorageAnalysisPageState extends State<StorageAnalysisPage> {
|
|||
}
|
||||
}
|
||||
|
||||
/// X-plore 비교 5-1 ⑤: 그 자리에서 할 일 - [그 자리로] · [지우기] (탐색기의 확인 · 휴지통) · [이동 (붙여넣기로)]
|
||||
Future<void> _act(String path, String what, {int size = 0}) async {
|
||||
switch (what) {
|
||||
case 'go':
|
||||
Navigator.pop(context, path);
|
||||
case 'delete':
|
||||
final done = await widget.onDelete?.call([path]) ?? const <String>[];
|
||||
if (!mounted || done.isEmpty) return;
|
||||
setState(() {
|
||||
for (final x in done) {
|
||||
_scan?.remove(x, fileSize: size);
|
||||
}
|
||||
if (done.any((x) => isSameOrInside(_cur, x))) _cur = vDirname(done.first);
|
||||
});
|
||||
case 'cut':
|
||||
widget.onCut?.call([path]);
|
||||
ScaffoldMessenger.maybeOf(context)
|
||||
?.showSnackBar(SnackBar(content: Text(tr('이동할 것으로 담았습니다 - 탐색기에서 넣을 폴더를 길게 눌러 [붙여넣기]'))));
|
||||
}
|
||||
}
|
||||
|
||||
/// 줄의 ⋮ 메뉴 (길게 누르기도 같은 메뉴)
|
||||
Widget _menuButton(String path, {int size = 0}) => PopupMenuButton<String>(
|
||||
tooltip: tr('더 보기'),
|
||||
icon: const Icon(Icons.more_vert, size: 20),
|
||||
onSelected: (w) => _act(path, w, size: size),
|
||||
itemBuilder: (_) => [
|
||||
PopupMenuItem(value: 'go', child: ListTile(dense: true, leading: const Icon(Icons.folder_open), title: Text(tr('그 자리로')))),
|
||||
if (widget.onDelete != null)
|
||||
PopupMenuItem(value: 'delete', child: ListTile(dense: true, leading: const Icon(Icons.delete_outline), title: Text(tr('지우기 (확인 · 휴지통)')))),
|
||||
if (widget.onCut != null)
|
||||
PopupMenuItem(value: 'cut', child: ListTile(dense: true, leading: const Icon(Icons.content_cut), title: Text(tr('이동 (붙여넣기로)')))),
|
||||
],
|
||||
);
|
||||
|
||||
Future<void> _menuAt(Offset at, String path, {int size = 0}) async {
|
||||
final w = await showMenu<String>(
|
||||
context: context,
|
||||
position: RelativeRect.fromLTRB(at.dx, at.dy, at.dx, at.dy),
|
||||
items: [
|
||||
PopupMenuItem(value: 'go', child: Text(tr('그 자리로'))),
|
||||
if (widget.onDelete != null) PopupMenuItem(value: 'delete', child: Text(tr('지우기 (확인 · 휴지통)'))),
|
||||
if (widget.onCut != null) PopupMenuItem(value: 'cut', child: Text(tr('이동 (붙여넣기로)'))),
|
||||
],
|
||||
);
|
||||
if (w != null && mounted) await _act(path, w, size: size);
|
||||
}
|
||||
|
||||
Widget _bar(double v) => ClipRRect(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
child: LinearProgressIndicator(value: v.clamp(0, 1), minHeight: 6, color: JjColors.accent, backgroundColor: JjColors.border),
|
||||
|
|
@ -164,7 +221,10 @@ class _StorageAnalysisPageState extends State<StorageAnalysisPage> {
|
|||
itemBuilder: (_, i) {
|
||||
final d = list[i];
|
||||
final size = s.sizeOf(d);
|
||||
return ListTile(
|
||||
return GestureDetector(
|
||||
onLongPressStart: (x) => _menuAt(x.globalPosition, d),
|
||||
onSecondaryTapUp: (x) => _menuAt(x.globalPosition, d),
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
leading: const Icon(Icons.folder, color: Colors.amber),
|
||||
title: Text(scanName(d), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
|
|
@ -174,13 +234,9 @@ class _StorageAnalysisPageState extends State<StorageAnalysisPage> {
|
|||
const SizedBox(height: 2),
|
||||
Text('${formatSize(size)} · ${trf('파일 {0}개', [s.filesOf(d)])}', style: const TextStyle(fontSize: 11)),
|
||||
]),
|
||||
trailing: IconButton(
|
||||
tooltip: tr('그 자리로'),
|
||||
icon: const Icon(Icons.drive_file_move_outline, size: 20),
|
||||
onPressed: () => Navigator.pop(context, d),
|
||||
),
|
||||
trailing: _menuButton(d),
|
||||
onTap: () => setState(() => _cur = d),
|
||||
);
|
||||
));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -212,7 +268,10 @@ class _StorageAnalysisPageState extends State<StorageAnalysisPage> {
|
|||
itemCount: list.length,
|
||||
itemBuilder: (_, i) {
|
||||
final f = list[i];
|
||||
return ListTile(
|
||||
return GestureDetector(
|
||||
onLongPressStart: (x) => _menuAt(x.globalPosition, f.path, size: f.size),
|
||||
onSecondaryTapUp: (x) => _menuAt(x.globalPosition, f.path, size: f.size),
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
leading: const Icon(Icons.insert_drive_file_outlined),
|
||||
title: Text(f.name, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
|
|
@ -228,9 +287,10 @@ class _StorageAnalysisPageState extends State<StorageAnalysisPage> {
|
|||
maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11)),
|
||||
),
|
||||
]),
|
||||
// 누르면 탐색기에서 그 파일 자리로 (지우기 등은 탐색기에서)
|
||||
trailing: _menuButton(f.path, size: f.size),
|
||||
// 누르면 탐색기에서 그 파일 자리로
|
||||
onTap: () => Navigator.pop(context, f.path),
|
||||
);
|
||||
));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,4 +81,71 @@ void main() {
|
|||
expect(done, isTrue);
|
||||
expect(picked, p.join(tmp.path, 'movies', 'a.mkv'));
|
||||
});
|
||||
test('5-1 ⑤ 지운 것을 다시 훑지 않고 빼기: 폴더 (그 아래 모두) · 파일 - 위 폴더들의 크기 · 파일 수에서 덜어 냄', () async {
|
||||
final s = await StorageScanner().scan(tmp.path);
|
||||
final movies = p.join(tmp.path, 'movies');
|
||||
s.remove(p.join(movies, 'old'));
|
||||
expect(s.sizeOf(movies), 5000);
|
||||
expect(s.filesOf(movies), 1);
|
||||
expect(s.sizeOf(tmp.path), 5110);
|
||||
expect(s.biggestIn(movies), isEmpty);
|
||||
expect(s.bigFiles.map((f) => f.name), isNot(contains('b.mkv')));
|
||||
s.remove(p.join(movies, 'a.mkv'));
|
||||
expect((s.sizeOf(movies), s.filesOf(movies)), (0, 0));
|
||||
expect((s.sizeOf(tmp.path), s.filesOf(tmp.path)), (110, 2));
|
||||
// 큰 파일 목록에 없는 파일은 알려 준 크기로
|
||||
s.remove(p.join(tmp.path, 'unknown.bin'), fileSize: 10);
|
||||
expect(s.sizeOf(tmp.path), 100);
|
||||
});
|
||||
|
||||
testWidgets('5-1 ⑤ 화면: ⋮ [지우기 (확인 · 휴지통)] 는 탐색기의 지우기를 부르고 결과에서 뺌 · 취소면 그대로 · [이동 (붙여넣기로)] 는 담기', (tester) async {
|
||||
tester.view.physicalSize = const Size(1000, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
final asked = <List<String>>[];
|
||||
final cut = <List<String>>[];
|
||||
var confirm = false;
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: StorageAnalysisPage(
|
||||
root: tmp.path,
|
||||
onDelete: (paths) async {
|
||||
asked.add(paths);
|
||||
if (!confirm) return const [];
|
||||
for (final x in paths) {
|
||||
Directory(x).deleteSync(recursive: true);
|
||||
}
|
||||
return paths;
|
||||
},
|
||||
onCut: cut.add,
|
||||
),
|
||||
));
|
||||
for (var i = 0; i < 200 && find.text('movies').evaluate().isEmpty; i++) {
|
||||
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 20)));
|
||||
await tester.pump();
|
||||
}
|
||||
Future<void> menu(String row, String item) async {
|
||||
await tester.tap(find.descendant(of: find.widgetWithText(ListTile, row), matching: find.byTooltip('더 보기')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text(item).last);
|
||||
for (var i = 0; i < 10; i++) {
|
||||
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 20)));
|
||||
await tester.pump();
|
||||
}
|
||||
}
|
||||
|
||||
final movies = p.join(tmp.path, 'movies');
|
||||
// 취소 (탐색기 확인 창에서 [취소]) → 그대로
|
||||
await menu('movies', '지우기 (확인 · 휴지통)');
|
||||
expect(asked.single, [movies]);
|
||||
expect(find.text('movies'), findsOneWidget);
|
||||
// 확인 → 목록에서 빠지고 위 폴더 크기도 줄어듦
|
||||
confirm = true;
|
||||
await menu('movies', '지우기 (확인 · 휴지통)');
|
||||
expect(find.text('movies'), findsNothing);
|
||||
expect(find.textContaining('110B'), findsWidgets);
|
||||
// 이동 (붙여넣기로)
|
||||
await menu('docs', '이동 (붙여넣기로)');
|
||||
expect(cut.single, [p.join(tmp.path, 'docs')]);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue