From a80d8bcafe76d2efc409c6a6e8da1881be88f3ed Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:42:08 +0900 Subject: [PATCH] =?UTF-8?q?WIP=20X-plore=20f:=20=EC=95=95=EC=B6=95=20(ZIP?= =?UTF-8?q?=20=C2=B7=207z=20=C2=B7=20RAR)=20=EC=9D=84=20=ED=8A=B8=EB=A6=AC?= =?UTF-8?q?=20=EC=95=88=EC=97=90=EC=84=9C=20=ED=8F=B4=EB=8D=94=EC=B2=98?= =?UTF-8?q?=EB=9F=BC=20=ED=8E=BC=EC=B9=98=EA=B8=B0=20=C2=B7=20=EC=9D=BD?= =?UTF-8?q?=EA=B8=B0=20=EC=A0=84=EC=9A=A9=20(=EA=B2=80=ED=86=A0=20?= =?UTF-8?q?=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 압축 파일의 › 로 펼치면 안의 폴더 · 파일이 트리에 (목록에 폴더 이름이 없어도 경로에서 폴더를 만든다). 안의 파일을 열면 그 파일만 임시 폴더 (jj_arcview) 로 꺼내 연다 (보기 · 재생). - 읽기 전용 (관리자 지시): 압축 안의 이름 바꾸기 · 일괄 이름 · 지우기 · 이동은 막고 알림. 압축 안으로 붙여넣기 · 놓기 · 새 폴더도 막음. 압축 안의 것을 복사 · 붙여넣기 · 끌어 놓으면 86 의 풀기 창 (위험한 경로 · 링크 검사 · 같은 이름 · 갈 곳) 으로 꺼낸다. - 비밀번호가 걸린 압축 · 파일은 미리 보지 않고 [압축 풀기] 로 안내 (비밀번호를 명령 줄 · 기록에 남기지 않는 86 흐름 그대로). - 7z · RAR 은 동봉 7-Zip 이 있을 때만 (없으면 알림). WebDAV · SFTP 위의 압축과 Rsync 화면은 펼치지 않음. - 내 판단: 압축 안 폴더를 펼쳐 지금 폴더가 압축 안이 되면 [새 폴더] · 붙여넣기를 막는다 (캡처에서 발견). - 알려진 한계: 펼친 뒤 압축 파일이 바뀌면 다시 펼칠 때까지 예전 목록. - 시험: explorer_test "X-plore f", 캡처 release\test\shots_xplore_f (archive_ko/en_412/960). Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 5 + assets/l10n/ja.json | 5 + assets/l10n/zh-Hans.json | 5 + .../windows_xplore_shot_test.dart | 14 +- lib/ui/explorer_page.dart | 175 +++++++++++++++++- test/explorer_test.dart | 36 ++++ 6 files changed, 235 insertions(+), 5 deletions(-) diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 8d455ed..87ce6c1 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -1322,7 +1322,9 @@ "비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)": "Passwords · API keys (WebDAV · OpenSubtitles · AI services)", "비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "Passwords and the subtitle API key aren't backed up, so please enter them again. Auto Backup runs about once a day, so changes made after it may be missing.", "비밀번호 기억 (안전 저장소)": "Remember passwords (secure storage)", + "비밀번호가 걸린 압축은 안을 미리 볼 수 없습니다 - [압축 풀기] 로 푸세요": "Can't preview a password-protected archive - use [Extract]", "비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)": "Some files are password-protected (you'll be asked when extracting)", + "비밀번호가 걸린 파일입니다 - [압축 풀기] 로 푸세요": "This file is password-protected - use [Extract]", "비밀번호가 저장된 기능을 쓸 때": "When using features with saved passwords", "비밀번호가 틀렸습니다": "Wrong password", "비밀번호가 틀렸습니다 ({0}번)": "Wrong password ({0} times)", @@ -1553,12 +1555,14 @@ "안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "{0} files inside are newer on the receiving side - merging overwrites them with older ones", "알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "Notifications show as JJ_MKVMaker and open the app when clicked (current user only, no admin rights)", "암호화 안 됨 (http)": "Not encrypted (http)", + "압축 안은 읽기 전용입니다 (꺼내기만 할 수 있습니다)": "Inside an archive is read-only (you can only extract)", "압축 옆 새 폴더 \"{0}\"": "New folder \"{0}\" next to the archive", "압축 파일 안에 없습니다": "Not in the archive", "압축 파일 안에 프로그램이 없습니다.": "The program is not in the archive.", "압축 푸는 중 · {0}": "Extracting · {0}", "압축 풀기": "Extract", "압축 풀기 · {0}": "Extract · {0}", + "압축에서 꺼내지 못했습니다: {0}": "Couldn't extract from the archive: {0}", "압축을 읽지 못했습니다: {0}": "Couldn't read the archive: {0}", "압축을 풀 수 없습니다: {0}": "Cannot extract the archive: {0}", "압축이 있는 폴더": "Archive's folder", @@ -1807,6 +1811,7 @@ "이 기기에는 원문이 아니라 되돌릴 수 없는 해시만 남습니다.": "Only an irreversible hash is stored on this device, not the password itself.", "이 기기에서 한 장 약 {0} (지난번 걸린 시간 기준)": "About {0} per image on this device (based on last time)", "이 기기에서 한 장 약 {0} · 모두 약 {1} (지난번 걸린 시간 기준)": "About {0} per image, {1} in total on this device (based on last time)", + "이 기기에서는 7z · RAR 안을 볼 수 없습니다 (다른 앱으로 열어 주세요)": "7z / RAR contents can't be browsed on this device (open it with another app)", "이 기기에서는 AI 번역을 쓸 수 없습니다.": "AI translation is not available on this device.", "이 기기에서는 AI 엔진을 찾지 못했습니다. 앱을 다시 설치해 보세요": "The AI engine was not found on this device. Try reinstalling the app", "이 기기에서는 쓸 수 없는 컴포넌트입니다": "This component is not available on this device", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 36146db..fc50b1c 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -1322,7 +1322,9 @@ "비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)": "パスワード・API キー (WebDAV・OpenSubtitles・AI サービス)", "비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "パスワード・字幕 API キーはバックアップされないので、もう一度入力してください。自動バックアップは 1 日に 1 回程度作成されるため、その後に変更したものは含まれていない場合があります。", "비밀번호 기억 (안전 저장소)": "パスワードを記憶 (安全な保存領域)", + "비밀번호가 걸린 압축은 안을 미리 볼 수 없습니다 - [압축 풀기] 로 푸세요": "パスワード付きアーカイブの中はプレビューできません - [展開] で展開してください", "비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)": "パスワード付きのファイルがあります (展開時に確認します)", + "비밀번호가 걸린 파일입니다 - [압축 풀기] 로 푸세요": "パスワード付きのファイルです - [展開] で展開してください", "비밀번호가 저장된 기능을 쓸 때": "パスワードを保存した機能を使うとき", "비밀번호가 틀렸습니다": "パスワードが違います", "비밀번호가 틀렸습니다 ({0}번)": "パスワードが違います ({0} 回)", @@ -1553,12 +1555,14 @@ "안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "中に受け取る側の方が新しいファイルが {0} 件 - 合わせると古いもので上書きします", "알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知が JJ_MKVMaker の名前で表示され、クリックするとアプリが開きます (管理者権限なしで現在のユーザーのみ)", "암호화 안 됨 (http)": "暗号化なし (http)", + "압축 안은 읽기 전용입니다 (꺼내기만 할 수 있습니다)": "アーカイブ内は読み取り専用です (取り出しのみできます)", "압축 옆 새 폴더 \"{0}\"": "アーカイブの隣の新しいフォルダー「{0}」", "압축 파일 안에 없습니다": "圧縮ファイル内にありません", "압축 파일 안에 프로그램이 없습니다.": "圧縮ファイル内にプログラムがありません。", "압축 푸는 중 · {0}": "展開中 · {0}", "압축 풀기": "展開", "압축 풀기 · {0}": "展開 · {0}", + "압축에서 꺼내지 못했습니다: {0}": "アーカイブから取り出せませんでした: {0}", "압축을 읽지 못했습니다: {0}": "アーカイブを読み込めませんでした: {0}", "압축을 풀 수 없습니다: {0}": "圧縮ファイルを展開できません: {0}", "압축이 있는 폴더": "アーカイブのあるフォルダー", @@ -1807,6 +1811,7 @@ "이 기기에는 원문이 아니라 되돌릴 수 없는 해시만 남습니다.": "この端末にはパスワード自体ではなく、元に戻せないハッシュだけが残ります。", "이 기기에서 한 장 약 {0} (지난번 걸린 시간 기준)": "この端末で 1 枚約 {0} (前回の所要時間から)", "이 기기에서 한 장 약 {0} · 모두 약 {1} (지난번 걸린 시간 기준)": "この端末で 1 枚約 {0}・全部で約 {1} (前回の所要時間から)", + "이 기기에서는 7z · RAR 안을 볼 수 없습니다 (다른 앱으로 열어 주세요)": "この端末では 7z・RAR の中を表示できません (他のアプリで開いてください)", "이 기기에서는 AI 번역을 쓸 수 없습니다.": "この端末では AI 翻訳を使えません。", "이 기기에서는 AI 엔진을 찾지 못했습니다. 앱을 다시 설치해 보세요": "この端末で AI エンジンが見つかりませんでした。アプリを再インストールしてください", "이 기기에서는 쓸 수 없는 컴포넌트입니다": "この端末では使えないコンポーネントです", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 0a790fa..066f4ed 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -1322,7 +1322,9 @@ "비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)": "密码 · API 密钥 (WebDAV · OpenSubtitles · AI 服务)", "비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "密码 · 字幕 API 密钥不会备份,请重新输入。自动备份大约每天创建一次,之后更改的内容可能缺失。", "비밀번호 기억 (안전 저장소)": "记住密码 (安全存储)", + "비밀번호가 걸린 압축은 안을 미리 볼 수 없습니다 - [압축 풀기] 로 푸세요": "无法预览有密码的压缩包 - 请用 [解压]", "비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)": "部分文件设有密码 (解压时会询问)", + "비밀번호가 걸린 파일입니다 - [압축 풀기] 로 푸세요": "此文件有密码 - 请用 [解压]", "비밀번호가 저장된 기능을 쓸 때": "使用保存了密码的功能时", "비밀번호가 틀렸습니다": "密码错误", "비밀번호가 틀렸습니다 ({0}번)": "密码错误 ({0} 次)", @@ -1553,12 +1555,14 @@ "안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "里面有 {0} 个文件在接收端较新 - 合并会用旧文件覆盖", "알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知以 JJ_MKVMaker 名义显示,点击即可打开应用 (仅当前用户,无需管理员权限)", "암호화 안 됨 (http)": "未加密 (http)", + "압축 안은 읽기 전용입니다 (꺼내기만 할 수 있습니다)": "压缩包内为只读 (只能解压取出)", "압축 옆 새 폴더 \"{0}\"": "压缩包旁的新文件夹“{0}”", "압축 파일 안에 없습니다": "压缩文件中没有", "압축 파일 안에 프로그램이 없습니다.": "压缩文件中没有程序。", "압축 푸는 중 · {0}": "正在解压 · {0}", "압축 풀기": "解压", "압축 풀기 · {0}": "解压 · {0}", + "압축에서 꺼내지 못했습니다: {0}": "无法从压缩包取出: {0}", "압축을 읽지 못했습니다: {0}": "无法读取压缩包: {0}", "압축을 풀 수 없습니다: {0}": "无法解压: {0}", "압축이 있는 폴더": "压缩包所在文件夹", @@ -1807,6 +1811,7 @@ "이 기기에는 원문이 아니라 되돌릴 수 없는 해시만 남습니다.": "本设备只保存无法还原的哈希值,而不是密码本身。", "이 기기에서 한 장 약 {0} (지난번 걸린 시간 기준)": "本设备每张约 {0} (按上次耗时)", "이 기기에서 한 장 약 {0} · 모두 약 {1} (지난번 걸린 시간 기준)": "本设备每张约 {0} · 共约 {1} (按上次耗时)", + "이 기기에서는 7z · RAR 안을 볼 수 없습니다 (다른 앱으로 열어 주세요)": "此设备无法浏览 7z / RAR 内容 (请用其他应用打开)", "이 기기에서는 AI 번역을 쓸 수 없습니다.": "此设备无法使用 AI 翻译。", "이 기기에서는 AI 엔진을 찾지 못했습니다. 앱을 다시 설치해 보세요": "在此设备上找不到 AI 引擎。请尝试重新安装应用", "이 기기에서는 쓸 수 없는 컴포넌트입니다": "此组件在本设备上不可用", diff --git a/integration_test/windows_xplore_shot_test.dart b/integration_test/windows_xplore_shot_test.dart index 6f53ce1..17f8d19 100644 --- a/integration_test/windows_xplore_shot_test.dart +++ b/integration_test/windows_xplore_shot_test.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'dart:ui' as ui; +import 'package:archive/archive.dart' show Archive, ArchiveFile, ZipEncoder; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -16,7 +17,7 @@ import 'package:jj_mkvmaker/ui/theme.dart'; import 'package:path/path.dart' as p; /// X-plore 비교 P1 (관리자 지시 10/18): X-plore 스타일 탐색기를 PNG 로 (JJ_SHOT_DIR, 없으면 임시 폴더). -/// 트리 맨 위 저장 장치 · 즐겨찾기 (c) · 표시 동그라미 늘 보이기 (⚖) · 한 창의 "갈 곳" 줄 (a). +/// 트리 맨 위 저장 장치 · 즐겨찾기 (c) · 표시 동그라미 늘 보이기 (⚖) · 한 창의 "갈 곳" 줄 (a) · 압축을 폴더처럼 (f). /// 크기 412 (폰 세로, 한 창) · 960 (두 창), 언어 ko · en. 시험 파일은 임시 폴더에만, 설정은 메모리에만. void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); @@ -39,6 +40,9 @@ void main() { } File(p.join(a.path, 'Show S01', 'ep01.mkv')).writeAsBytesSync(List.filled(5000, 0)); File(p.join(a.path, 'notes.txt')).writeAsStringSync('memo'); + File(p.join(a.path, 'Photos.zip')).writeAsBytesSync(ZipEncoder().encode(Archive() + ..addFile(ArchiveFile.string('2026/beach.jpg', 'x')) + ..addFile(ArchiveFile.string('readme.txt', 'readme')))); ExplorerPage.debugVolumes = () => [(a.path, 'C:'), (b.path, 'D:')]; addTearDown(() => ExplorerPage.debugVolumes = null); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); @@ -76,6 +80,14 @@ void main() { await t.tap(find.byKey(const ValueKey('k2:\u0000favorites')).first); await settle(); await shot('tree'); + // f: 압축을 › 로 펼치기 (안의 폴더도) + Finder chevron(String name) => find.descendant( + of: find.ancestor(of: find.text(name), matching: find.byType(InkWell)).first, matching: find.byIcon(Icons.chevron_right)); + await t.tap(chevron('Photos.zip').first); + await settle(); + await t.tap(chevron('2026').first); + await settle(); + await shot('archive'); await t.pumpWidget(const SizedBox()); await settle(4); }); diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index b033aee..b825dcc 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -50,7 +50,8 @@ import 'quick_look.dart'; import 'explorer_drag.dart'; import 'archive_ui.dart'; import 'storage_page.dart'; -import '../core/archive_ops.dart' show ArchiveKind, archiveKindOf; +import '../core/archive_ops.dart' + show ArchiveEntry, ArchiveKind, ArchivePasswordException, archiveKindOf, extract7zPlan, extractZipPlan, list7z, listZip, planExtract, unsafeArchivePath; import 'batch_rename_dialog.dart'; import '../core/app_trash.dart'; import '../core/folder_size.dart'; @@ -786,6 +787,12 @@ class _ExplorerPageState extends State { Future _load(_Pane pane, String dir, {bool force = false}) async { if (!force && pane.cache.containsKey(dir)) return; + // X-plore 비교 f: 압축 안 (읽어 둔 목록에서) + if (_archiveOf(dir) != null) { + pane.cache[dir] = _sorted(_archiveList(dir), dir); + pane.changed(); + return; + } // 80: 다시 읽는 폴더는 안이 바뀌었을 수 있다 - 그 폴더와 위 폴더들의 크기를 잊는다 if (force) _sizes.forget(dir); pane.loading.add(dir); @@ -1035,7 +1042,8 @@ class _ExplorerPageState extends State { if (foldersOnly && !e.isDir) continue; final at = out.length; out.add(_Row(e, depth)); - final inner = e.isDir && walk(e.path, depth + 1); + // X-plore 비교 f: 펼친 압축 (ZIP · 7z · RAR) 도 안을 폴더처럼 + final inner = (e.isDir || _archives.containsKey(e.path)) && walk(e.path, depth + 1); if (!inner && !_passes(pane, e)) { out.length = at; } else { @@ -1363,6 +1371,14 @@ class _ExplorerPageState extends State { /// 트리 폴더 펼치기 · 접기 (› 화살표 · 열기) Future _toggle(_Pane pane, FileEntry e) async { setState(() => _active = _panes.indexOf(pane)); + // X-plore 비교 f: 압축 파일을 폴더처럼 펼치기 (읽기 전용) + if (_canBrowseArchive(e) && !pane.expanded.contains(e.path)) { + if (!await _readArchive(e.path)) return; + pane.cache.remove(e.path); + pane.expanded.add(e.path); + await _load(pane, e.path); + return; + } if (pane.expanded.contains(e.path)) { pane.expanded.remove(e.path); // 접은 폴더 안이 지금 폴더였으면 접은 폴더로 @@ -1386,6 +1402,12 @@ class _ExplorerPageState extends State { /// 파일 열기 (기본 동작): 동영상은 내장 플레이어 (또는 확장자별 프로그램), 그 밖은 기본 연결 프로그램 Future _open(String path, {_Pane? pane}) async { + // X-plore 비교 f: 압축 안의 파일 - 그 파일만 임시 폴더로 꺼내 연다 + if (_insideArchive(path)) { + final local = await _extractOne(path); + if (local == null || !mounted) return; + path = local; + } if (isVideoFile(path)) { await _play([path]); return; @@ -1788,6 +1810,121 @@ class _ExplorerPageState extends State { _Pane get _pane => _panes[_active]; _Pane? get _other => _dual || _hiddenTarget ? _panes[1 - _active] : null; + // ───────── X-plore 비교 f: 압축을 트리 안에서 폴더처럼 (ZIP · 7z · RAR, 읽기 전용) ───────── + + /// 펼쳐 본 압축의 목록 (압축 경로 → 항목) + final _archives = >{}; + + /// 트리에서 펼칠 수 있는 압축 (이 기기의 파일 · 탐색기 화면) + bool _canBrowseArchive(FileEntry e) => !widget.rsync && !e.isDir && !isDav(e.path) && archiveKindOf(e.path) != null; + + /// [path] 가 들어 있는 (펼쳐 둔) 압축 - 압축 자신도 + String? _archiveOf(String path) { + String? best; + for (final a in _archives.keys) { + if ((samePath(path, a) || isSameOrInside(path, a)) && (best == null || a.length > best.length)) best = a; + } + return best; + } + + /// 압축 안의 항목인지 (압축 파일 자신은 아님) + bool _insideArchive(String path) { + final a = _archiveOf(path); + return a != null && !samePath(a, path); + } + + /// 압축 안 경로 → 압축 안의 이름 ("폴더/파일") + String _innerName(String archive, String path) => p.relative(path, from: archive).replaceAll('\\', '/'); + + /// 압축의 목록을 읽어 둔다 (비밀번호가 걸린 7z 등은 [압축 풀기] 로 안내). 읽었으면 true + Future _readArchive(String path) async { + if (_archives.containsKey(path)) return true; + final kind = archiveKindOf(path)!; + try { + if (kind == ArchiveKind.zip) { + _archives[path] = await listZip(path); + } else { + final exe = await bundledSevenZip(); + if (exe == null) { + _snack(tr('이 기기에서는 7z · RAR 안을 볼 수 없습니다 (다른 앱으로 열어 주세요)')); + return false; + } + _archives[path] = await list7z(exe, path); + } + return true; + } on ArchivePasswordException { + _snack(tr('비밀번호가 걸린 압축은 안을 미리 볼 수 없습니다 - [압축 풀기] 로 푸세요')); + } catch (e) { + _snack(trf('압축을 읽지 못했습니다: {0}', [e])); + } + return false; + } + + /// 압축 안 폴더 [dir] 의 바로 아래 항목 (목록의 폴더 이름이 없어도 경로에서 폴더를 만든다) + List _archiveList(String dir) { + final arc = _archiveOf(dir)!; + final rel = samePath(dir, arc) ? '' : _innerName(arc, dir); + final out = {}; + for (final a in _archives[arc]!) { + if (a.isLink) continue; + final n = a.name.replaceAll('\\', '/').replaceAll(RegExp(r'^(\./)+|/+$'), ''); + if (n.isEmpty || unsafeArchivePath(n) != null) continue; + if (rel.isNotEmpty && !n.startsWith('$rel/')) continue; + final rest = rel.isEmpty ? n : n.substring(rel.length + 1); + if (rest.isEmpty) continue; + final first = rest.split('/').first; + final folder = rest.contains('/') || a.isDir; + final path = p.joinAll([arc, ...(rel.isEmpty ? first : '$rel/$first').split('/')]); + final old = out[first]; + if (old == null || (folder && !old.isDir)) { + out[first] = FileEntry(path, isDir: folder, size: folder ? 0 : a.size, modified: a.modified ?? DateTime(0)); + } + } + return out.values.toList(); + } + + /// 압축 안의 파일 하나를 임시 폴더로 꺼낸다 (보기 · 재생). 꺼낸 경로 (못 하면 null) + Future _extractOne(String path) async { + final arc = _archiveOf(path)!; + final inner = _innerName(arc, path); + final dest = p.join(await c.services.storage.tempDirectory(), 'jj_arcview', '${arc.hashCode.toUnsigned(32)}'); + final plan = planExtract(_archives[arc]!, names: [inner]); + if (plan.encrypted) { + _snack(tr('비밀번호가 걸린 파일입니다 - [압축 풀기] 로 푸세요')); + return null; + } + try { + if (archiveKindOf(arc) == ArchiveKind.zip) { + await extractZipPlan(arc, dest, plan, conflict: NameConflict.overwrite); + } else { + final exe = await bundledSevenZip(); + if (exe == null) return null; + await extract7zPlan(exe, arc, dest, plan, conflict: NameConflict.overwrite); + } + } catch (e) { + _snack(trf('압축에서 꺼내지 못했습니다: {0}', [e])); + return null; + } + final out = p.joinAll([dest, ...inner.split('/')]); + return File(out).existsSync() ? out : null; + } + + /// 압축 안의 것을 꺼내기 - 86 의 풀기 창 (검사 · 갈 곳 · 같은 이름) 그대로 + Future _extractFromArchive(List paths, String? otherDir) async { + final arc = _archiveOf(paths.firstWhere(_insideArchive))!; + final names = [for (final x in paths) if (_insideArchive(x) && samePath(_archiveOf(x)!, arc)) _innerName(arc, x)]; + final done = await extractArchiveFlow(context, c, arc, otherDir: otherDir, names: names); + if (done != null && mounted) await _refreshAll([done]); + } + + /// 압축 안을 바꾸려 하면 막고 알린다 (읽기 전용). 막았으면 true + /// [self] 면 압축 파일 자신도 (그 안으로 넣는 곳일 때) + bool _blockArchive(List paths, {bool self = false}) { + if (!paths.any((x) => _insideArchive(x) || (self && _archives.containsKey(x)))) return false; + _snack(tr('압축 안은 읽기 전용입니다 (꺼내기만 할 수 있습니다)')); + return true; + } + /// X-plore 비교 a: 한 창인데 "숨은 다른 창으로" 복사 · 이동 (위에 갈 곳 줄) bool get _hiddenTarget => !widget.rsync && !_dual && !_split && c.settings.explorerOnePaneCopy == 'other'; @@ -1929,6 +2066,8 @@ class _ExplorerPageState extends State { final t = _targets(pane); await _addMkv(t.isEmpty ? [pane.current] : t); case ExplorerButton.newFolder: + // X-plore 비교 f: 펼친 압축 안 (지금 폴더가 압축 안의 폴더) 에는 만들지 않는다 + if (_blockArchive([pane.current], self: true)) return; final name = await _askName(tr('새 폴더'), ''); if (name == null) return; try { @@ -2041,6 +2180,7 @@ class _ExplorerPageState extends State { } Future _rename(_Pane pane, String path) async { + if (_blockArchive([path])) return; final name = await _askName(tr('이름 변경'), vBasename(path)); if (name == null || name == vBasename(path)) return; try { @@ -2058,6 +2198,7 @@ class _ExplorerPageState extends State { /// 78: 일괄 이름 바꾸기 - 번호는 목록에 보이는 차례대로. 끝나면 [되돌리기] Future _batchRename(_Pane pane, List paths) async { + if (_blockArchive(paths)) return; int at(String x) { final i = (pane.cache[vDirname(x)] ?? const []).indexWhere((e) => samePath(e.path, x)); return i < 0 ? 1 << 30 : i; @@ -2106,6 +2247,7 @@ class _ExplorerPageState extends State { /// 지우기. Windows 의 로컬 파일은 기본으로 휴지통으로 (창에서 "영구 삭제" 를 고르거나 Shift+Delete). /// WebDAV · Android 는 휴지통이 없어 바로 지운다. 여러 개면 실패해도 나머지를 계속하고, 못 지운 것을 알린다 (65). Future _delete(_Pane pane, List paths) async { + if (_blockArchive(paths)) return; paths = outermostPaths(paths); // 79-c: 고른 폴더 안의 것은 한 번만 (확인 창 개수도) final shift = _shiftDelete; _shiftDelete = false; @@ -2398,6 +2540,15 @@ class _ExplorerPageState extends State { /// 다른 창의 지금 폴더로 복사 · 이동 (진행 창 · 취소) /// 복사 · 이동 버튼: 두 창이면 다른 창의 지금 폴더로, 그 밖의 배치는 담아 두기 (붙여넣기로) Future _transfer(_Pane pane, List sources, {required bool move}) async { + // X-plore 비교 f: 압축 안의 것 - 복사는 꺼내기 (86 의 풀기 검사 그대로), 이동은 막음 + if (sources.any(_insideArchive)) { + if (move) { + _blockArchive(sources); + return; + } + await _extractFromArchive(sources, _other?.current); + return; + } sources = outermostPaths(sources); // 79-c: 고른 폴더 안의 것은 한 번만 final other = _other; if (other == null) { @@ -2511,6 +2662,13 @@ class _ExplorerPageState extends State { _snack(_pickFirst(move)); return false; } + // X-plore 비교 f: 압축 안으로는 넣지 않고 (읽기 전용), 압축 안의 것을 붙여넣으면 꺼내기로 + if (_blockArchive([dest], self: true)) return false; + if (sources.any(_insideArchive)) { + if (move) return !_blockArchive(sources); + await _extractFromArchive(sources, dest); + return true; + } // 23: 앞의 복사 · 이동이 끝나지 않아도 함께 시작한다 (아래 진행 막대에 하나씩). 같은 것을 같은 곳으로만 막는다 if (_jobs.any((j) => !j.finished && samePath(j.dest, dest) && j.sources.toSet().containsAll(sources))) { _snack(tr('같은 항목을 같은 곳으로 복사 · 이동하는 중입니다.')); @@ -3888,6 +4046,15 @@ class _ExplorerPageState extends State { /// 이동이 끝나면 아래 알림에 [되돌리기] (10초) Future _onDrop(DragLoad load, int to, String dest) async { if (!canDropInto(load, to, dest) || !mounted) return; + // X-plore 비교 f: 압축 안으로는 놓지 않고, 압축 안의 것은 꺼내기로 + if (_insideArchive(dest) || _archives.containsKey(dest)) { + _snack(tr('압축 안은 읽기 전용입니다 (꺼내기만 할 수 있습니다)')); + return; + } + if (load.paths.any(_insideArchive)) { + await _extractFromArchive(load.paths, dest); + return; + } setState(() => _active = to); final mode = c.settings.explorerDropAction; final move = mode == 'ask' ? await askDropAction(context, load.paths, dest) : mode == 'move'; @@ -4541,7 +4708,7 @@ class _ExplorerPageState extends State { final isCurrent = e.isDir && !list && !row.isUp && samePath(pane.current, e.path); final isFocused = !row.isUp && pane.focused != null && samePath(pane.focused!, e.path); final marked = _isMarked(pane, e.path); - final open = e.isDir && !list && pane.expanded.contains(e.path); + final open = (e.isDir || _archives.containsKey(e.path)) && !list && pane.expanded.contains(e.path); final loading = pane.loading.contains(e.path); final name = row.isUp ? '..' @@ -4563,7 +4730,7 @@ class _ExplorerPageState extends State { Widget chevron() => SizedBox( width: chevronWidth, height: look.rowHeightOf(context), - child: e.isDir && !list && !row.isUp + child: (e.isDir || _canBrowseArchive(e)) && !list && !row.isUp ? (loading ? const Center( child: SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 1.5))) diff --git a/test/explorer_test.dart b/test/explorer_test.dart index df5d64a..3d42cfd 100644 --- a/test/explorer_test.dart +++ b/test/explorer_test.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io'; +import 'package:archive/archive.dart' show Archive, ArchiveFile, ZipEncoder; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; @@ -255,6 +256,41 @@ void main() { await tester.pump(const Duration(seconds: 1)); }); + testWidgets('X-plore f: 압축을 트리 안에서 폴더처럼 펼치기 · 안은 읽기 전용 · 복사는 꺼내기', (tester) async { + final zip = p.join(left, 'pack.zip'); + File(zip).writeAsBytesSync(ZipEncoder().encode(Archive() + ..addFile(ArchiveFile.string('top.txt', 'top')) + ..addFile(ArchiveFile.string('a/b.txt', 'bee')))); + await open(tester); + await settle(tester, () => find.text('pack.zip').evaluate().isNotEmpty); + // › 로 펼치면 압축 안의 것이 폴더처럼 (목록에 폴더 이름이 없어도 경로에서 폴더를 만든다) + await act(tester, () => tester.tap(chevronOf('pack.zip').first)); + await settle(tester, () => find.text('top.txt').evaluate().isNotEmpty); + expect(find.text('a'), findsOneWidget); + await act(tester, () => tester.tap(chevronOf('a').first)); + await settle(tester, () => find.text('b.txt').evaluate().isNotEmpty); + expect(find.text('b.txt'), findsOneWidget); + // 안의 것을 지우려 하면 막는다 (압축은 그대로) + await act(tester, () => tester.tap(find.text('top.txt'))); + await settle(tester, () => false, rounds: 10); + await act(tester, () => tester.tap(find.text('삭제').first)); + await settle(tester, () => find.textContaining('읽기 전용').evaluate().isNotEmpty); + expect(find.textContaining('압축 안은 읽기 전용'), findsOneWidget); + expect(File(zip).existsSync(), isTrue); + expect(Directory(p.join(left, 'pack')).existsSync(), isFalse, reason: '풀지 않음'); + ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); + await tester.pump(const Duration(seconds: 1)); + // 압축 안의 폴더를 펼쳐 지금 폴더가 압축 안이면 [새 폴더] 도 막는다 (이름을 묻지 않음) + await act(tester, () => tester.tap(find.text('a'))); + await settle(tester, () => false, rounds: 10); + await act(tester, () => tester.tap(find.text('새 폴더').first)); + await settle(tester, () => find.textContaining('읽기 전용').evaluate().isNotEmpty); + expect(find.textContaining('압축 안은 읽기 전용'), findsOneWidget); + expect(find.byType(TextField), findsNothing, reason: '이름 묻는 창 없음'); + ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars(); + await tester.pump(const Duration(seconds: 1)); + }); + testWidgets('X-plore e: Android 앱 휴지통 - 지우기 = 휴지통으로 · [되돌리기] · [휴지통] 화면에서 되살리기', (tester) async { // 저장 장치는 시험 폴더 (실제 C:\ 에 .jj_trash 를 만들지 않게) ExplorerPage.debugAndroidTrash = true;