WIP X-plore f: 압축 (ZIP · 7z · RAR) 을 트리 안에서 폴더처럼 펼치기 · 읽기 전용 (검토 대기)

- 압축 파일의 › 로 펼치면 안의 폴더 · 파일이 트리에 (목록에 폴더 이름이 없어도 경로에서 폴더를 만든다).
  안의 파일을 열면 그 파일만 임시 폴더 (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 <noreply@anthropic.com>
xplore
RyuJongJin 2026-10-18 00:42:08 +09:00
parent b6e47178e3
commit a80d8bcafe
6 changed files with 235 additions and 5 deletions

View File

@ -1322,7 +1322,9 @@
"비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)": "Passwords · API keys (WebDAV · OpenSubtitles · AI services)", "비밀번호 · 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.", "비밀번호 · 자막 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)", "비밀번호 기억 (안전 저장소)": "Remember passwords (secure storage)",
"비밀번호가 걸린 압축은 안을 미리 볼 수 없습니다 - [압축 풀기] 로 푸세요": "Can't preview a password-protected archive - use [Extract]",
"비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)": "Some files are password-protected (you'll be asked when extracting)", "비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)": "Some files are password-protected (you'll be asked when extracting)",
"비밀번호가 걸린 파일입니다 - [압축 풀기] 로 푸세요": "This file is password-protected - use [Extract]",
"비밀번호가 저장된 기능을 쓸 때": "When using features with saved passwords", "비밀번호가 저장된 기능을 쓸 때": "When using features with saved passwords",
"비밀번호가 틀렸습니다": "Wrong password", "비밀번호가 틀렸습니다": "Wrong password",
"비밀번호가 틀렸습니다 ({0}번)": "Wrong password ({0} times)", "비밀번호가 틀렸습니다 ({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", "안쪽에 받는 쪽이 더 새 파일 {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)", "알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "Notifications show as JJ_MKVMaker and open the app when clicked (current user only, no admin rights)",
"암호화 안 됨 (http)": "Not encrypted (http)", "암호화 안 됨 (http)": "Not encrypted (http)",
"압축 안은 읽기 전용입니다 (꺼내기만 할 수 있습니다)": "Inside an archive is read-only (you can only extract)",
"압축 옆 새 폴더 \"{0}\"": "New folder \"{0}\" next to the archive", "압축 옆 새 폴더 \"{0}\"": "New folder \"{0}\" next to the archive",
"압축 파일 안에 없습니다": "Not in the archive", "압축 파일 안에 없습니다": "Not in the archive",
"압축 파일 안에 프로그램이 없습니다.": "The program is not in the archive.", "압축 파일 안에 프로그램이 없습니다.": "The program is not in the archive.",
"압축 푸는 중 · {0}": "Extracting · {0}", "압축 푸는 중 · {0}": "Extracting · {0}",
"압축 풀기": "Extract", "압축 풀기": "Extract",
"압축 풀기 · {0}": "Extract · {0}", "압축 풀기 · {0}": "Extract · {0}",
"압축에서 꺼내지 못했습니다: {0}": "Couldn't extract from the archive: {0}",
"압축을 읽지 못했습니다: {0}": "Couldn't read the archive: {0}", "압축을 읽지 못했습니다: {0}": "Couldn't read the archive: {0}",
"압축을 풀 수 없습니다: {0}": "Cannot extract the archive: {0}", "압축을 풀 수 없습니다: {0}": "Cannot extract the archive: {0}",
"압축이 있는 폴더": "Archive's folder", "압축이 있는 폴더": "Archive's folder",
@ -1807,6 +1811,7 @@
"이 기기에는 원문이 아니라 되돌릴 수 없는 해시만 남습니다.": "Only an irreversible hash is stored on this device, not the password itself.", "이 기기에는 원문이 아니라 되돌릴 수 없는 해시만 남습니다.": "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} (지난번 걸린 시간 기준)": "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)", "이 기기에서 한 장 약 {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 번역을 쓸 수 없습니다.": "AI translation is not available on this device.",
"이 기기에서는 AI 엔진을 찾지 못했습니다. 앱을 다시 설치해 보세요": "The AI engine was not found on this device. Try reinstalling the app", "이 기기에서는 AI 엔진을 찾지 못했습니다. 앱을 다시 설치해 보세요": "The AI engine was not found on this device. Try reinstalling the app",
"이 기기에서는 쓸 수 없는 컴포넌트입니다": "This component is not available on this device", "이 기기에서는 쓸 수 없는 컴포넌트입니다": "This component is not available on this device",

View File

@ -1322,7 +1322,9 @@
"비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)": "パスワード・API キー (WebDAV・OpenSubtitles・AI サービス)", "비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)": "パスワード・API キー (WebDAV・OpenSubtitles・AI サービス)",
"비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "パスワード・字幕 API キーはバックアップされないので、もう一度入力してください。自動バックアップは 1 日に 1 回程度作成されるため、その後に変更したものは含まれていない場合があります。", "비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "パスワード・字幕 API キーはバックアップされないので、もう一度入力してください。自動バックアップは 1 日に 1 回程度作成されるため、その後に変更したものは含まれていない場合があります。",
"비밀번호 기억 (안전 저장소)": "パスワードを記憶 (安全な保存領域)", "비밀번호 기억 (안전 저장소)": "パスワードを記憶 (安全な保存領域)",
"비밀번호가 걸린 압축은 안을 미리 볼 수 없습니다 - [압축 풀기] 로 푸세요": "パスワード付きアーカイブの中はプレビューできません - [展開] で展開してください",
"비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)": "パスワード付きのファイルがあります (展開時に確認します)", "비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)": "パスワード付きのファイルがあります (展開時に確認します)",
"비밀번호가 걸린 파일입니다 - [압축 풀기] 로 푸세요": "パスワード付きのファイルです - [展開] で展開してください",
"비밀번호가 저장된 기능을 쓸 때": "パスワードを保存した機能を使うとき", "비밀번호가 저장된 기능을 쓸 때": "パスワードを保存した機能を使うとき",
"비밀번호가 틀렸습니다": "パスワードが違います", "비밀번호가 틀렸습니다": "パスワードが違います",
"비밀번호가 틀렸습니다 ({0}번)": "パスワードが違います ({0} 回)", "비밀번호가 틀렸습니다 ({0}번)": "パスワードが違います ({0} 回)",
@ -1553,12 +1555,14 @@
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "中に受け取る側の方が新しいファイルが {0} 件 - 合わせると古いもので上書きします", "안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "中に受け取る側の方が新しいファイルが {0} 件 - 合わせると古いもので上書きします",
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知が JJ_MKVMaker の名前で表示され、クリックするとアプリが開きます (管理者権限なしで現在のユーザーのみ)", "알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知が JJ_MKVMaker の名前で表示され、クリックするとアプリが開きます (管理者権限なしで現在のユーザーのみ)",
"암호화 안 됨 (http)": "暗号化なし (http)", "암호화 안 됨 (http)": "暗号化なし (http)",
"압축 안은 읽기 전용입니다 (꺼내기만 할 수 있습니다)": "アーカイブ内は読み取り専用です (取り出しのみできます)",
"압축 옆 새 폴더 \"{0}\"": "アーカイブの隣の新しいフォルダー「{0}」", "압축 옆 새 폴더 \"{0}\"": "アーカイブの隣の新しいフォルダー「{0}」",
"압축 파일 안에 없습니다": "圧縮ファイル内にありません", "압축 파일 안에 없습니다": "圧縮ファイル内にありません",
"압축 파일 안에 프로그램이 없습니다.": "圧縮ファイル内にプログラムがありません。", "압축 파일 안에 프로그램이 없습니다.": "圧縮ファイル内にプログラムがありません。",
"압축 푸는 중 · {0}": "展開中 · {0}", "압축 푸는 중 · {0}": "展開中 · {0}",
"압축 풀기": "展開", "압축 풀기": "展開",
"압축 풀기 · {0}": "展開 · {0}", "압축 풀기 · {0}": "展開 · {0}",
"압축에서 꺼내지 못했습니다: {0}": "アーカイブから取り出せませんでした: {0}",
"압축을 읽지 못했습니다: {0}": "アーカイブを読み込めませんでした: {0}", "압축을 읽지 못했습니다: {0}": "アーカイブを読み込めませんでした: {0}",
"압축을 풀 수 없습니다: {0}": "圧縮ファイルを展開できません: {0}", "압축을 풀 수 없습니다: {0}": "圧縮ファイルを展開できません: {0}",
"압축이 있는 폴더": "アーカイブのあるフォルダー", "압축이 있는 폴더": "アーカイブのあるフォルダー",
@ -1807,6 +1811,7 @@
"이 기기에는 원문이 아니라 되돌릴 수 없는 해시만 남습니다.": "この端末にはパスワード自体ではなく、元に戻せないハッシュだけが残ります。", "이 기기에는 원문이 아니라 되돌릴 수 없는 해시만 남습니다.": "この端末にはパスワード自体ではなく、元に戻せないハッシュだけが残ります。",
"이 기기에서 한 장 약 {0} (지난번 걸린 시간 기준)": "この端末で 1 枚約 {0} (前回の所要時間から)", "이 기기에서 한 장 약 {0} (지난번 걸린 시간 기준)": "この端末で 1 枚約 {0} (前回の所要時間から)",
"이 기기에서 한 장 약 {0} · 모두 약 {1} (지난번 걸린 시간 기준)": "この端末で 1 枚約 {0}・全部で約 {1} (前回の所要時間から)", "이 기기에서 한 장 약 {0} · 모두 약 {1} (지난번 걸린 시간 기준)": "この端末で 1 枚約 {0}・全部で約 {1} (前回の所要時間から)",
"이 기기에서는 7z · RAR 안을 볼 수 없습니다 (다른 앱으로 열어 주세요)": "この端末では 7z・RAR の中を表示できません (他のアプリで開いてください)",
"이 기기에서는 AI 번역을 쓸 수 없습니다.": "この端末では AI 翻訳を使えません。", "이 기기에서는 AI 번역을 쓸 수 없습니다.": "この端末では AI 翻訳を使えません。",
"이 기기에서는 AI 엔진을 찾지 못했습니다. 앱을 다시 설치해 보세요": "この端末で AI エンジンが見つかりませんでした。アプリを再インストールしてください", "이 기기에서는 AI 엔진을 찾지 못했습니다. 앱을 다시 설치해 보세요": "この端末で AI エンジンが見つかりませんでした。アプリを再インストールしてください",
"이 기기에서는 쓸 수 없는 컴포넌트입니다": "この端末では使えないコンポーネントです", "이 기기에서는 쓸 수 없는 컴포넌트입니다": "この端末では使えないコンポーネントです",

View File

@ -1322,7 +1322,9 @@
"비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)": "密码 · API 密钥 (WebDAV · OpenSubtitles · AI 服务)", "비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)": "密码 · API 密钥 (WebDAV · OpenSubtitles · AI 服务)",
"비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "密码 · 字幕 API 密钥不会备份,请重新输入。自动备份大约每天创建一次,之后更改的内容可能缺失。", "비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "密码 · 字幕 API 密钥不会备份,请重新输入。自动备份大约每天创建一次,之后更改的内容可能缺失。",
"비밀번호 기억 (안전 저장소)": "记住密码 (安全存储)", "비밀번호 기억 (안전 저장소)": "记住密码 (安全存储)",
"비밀번호가 걸린 압축은 안을 미리 볼 수 없습니다 - [압축 풀기] 로 푸세요": "无法预览有密码的压缩包 - 请用 [解压]",
"비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)": "部分文件设有密码 (解压时会询问)", "비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)": "部分文件设有密码 (解压时会询问)",
"비밀번호가 걸린 파일입니다 - [압축 풀기] 로 푸세요": "此文件有密码 - 请用 [解压]",
"비밀번호가 저장된 기능을 쓸 때": "使用保存了密码的功能时", "비밀번호가 저장된 기능을 쓸 때": "使用保存了密码的功能时",
"비밀번호가 틀렸습니다": "密码错误", "비밀번호가 틀렸습니다": "密码错误",
"비밀번호가 틀렸습니다 ({0}번)": "密码错误 ({0} 次)", "비밀번호가 틀렸습니다 ({0}번)": "密码错误 ({0} 次)",
@ -1553,12 +1555,14 @@
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "里面有 {0} 个文件在接收端较新 - 合并会用旧文件覆盖", "안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "里面有 {0} 个文件在接收端较新 - 合并会用旧文件覆盖",
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知以 JJ_MKVMaker 名义显示,点击即可打开应用 (仅当前用户,无需管理员权限)", "알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知以 JJ_MKVMaker 名义显示,点击即可打开应用 (仅当前用户,无需管理员权限)",
"암호화 안 됨 (http)": "未加密 (http)", "암호화 안 됨 (http)": "未加密 (http)",
"압축 안은 읽기 전용입니다 (꺼내기만 할 수 있습니다)": "压缩包内为只读 (只能解压取出)",
"압축 옆 새 폴더 \"{0}\"": "压缩包旁的新文件夹“{0}”", "압축 옆 새 폴더 \"{0}\"": "压缩包旁的新文件夹“{0}”",
"압축 파일 안에 없습니다": "压缩文件中没有", "압축 파일 안에 없습니다": "压缩文件中没有",
"압축 파일 안에 프로그램이 없습니다.": "压缩文件中没有程序。", "압축 파일 안에 프로그램이 없습니다.": "压缩文件中没有程序。",
"압축 푸는 중 · {0}": "正在解压 · {0}", "압축 푸는 중 · {0}": "正在解压 · {0}",
"압축 풀기": "解压", "압축 풀기": "解压",
"압축 풀기 · {0}": "解压 · {0}", "압축 풀기 · {0}": "解压 · {0}",
"압축에서 꺼내지 못했습니다: {0}": "无法从压缩包取出: {0}",
"압축을 읽지 못했습니다: {0}": "无法读取压缩包: {0}", "압축을 읽지 못했습니다: {0}": "无法读取压缩包: {0}",
"압축을 풀 수 없습니다: {0}": "无法解压: {0}", "압축을 풀 수 없습니다: {0}": "无法解压: {0}",
"압축이 있는 폴더": "压缩包所在文件夹", "압축이 있는 폴더": "压缩包所在文件夹",
@ -1807,6 +1811,7 @@
"이 기기에는 원문이 아니라 되돌릴 수 없는 해시만 남습니다.": "本设备只保存无法还原的哈希值,而不是密码本身。", "이 기기에는 원문이 아니라 되돌릴 수 없는 해시만 남습니다.": "本设备只保存无法还原的哈希值,而不是密码本身。",
"이 기기에서 한 장 약 {0} (지난번 걸린 시간 기준)": "本设备每张约 {0} (按上次耗时)", "이 기기에서 한 장 약 {0} (지난번 걸린 시간 기준)": "本设备每张约 {0} (按上次耗时)",
"이 기기에서 한 장 약 {0} · 모두 약 {1} (지난번 걸린 시간 기준)": "本设备每张约 {0} · 共约 {1} (按上次耗时)", "이 기기에서 한 장 약 {0} · 모두 약 {1} (지난번 걸린 시간 기준)": "本设备每张约 {0} · 共约 {1} (按上次耗时)",
"이 기기에서는 7z · RAR 안을 볼 수 없습니다 (다른 앱으로 열어 주세요)": "此设备无法浏览 7z / RAR 内容 (请用其他应用打开)",
"이 기기에서는 AI 번역을 쓸 수 없습니다.": "此设备无法使用 AI 翻译。", "이 기기에서는 AI 번역을 쓸 수 없습니다.": "此设备无法使用 AI 翻译。",
"이 기기에서는 AI 엔진을 찾지 못했습니다. 앱을 다시 설치해 보세요": "在此设备上找不到 AI 引擎。请尝试重新安装应用", "이 기기에서는 AI 엔진을 찾지 못했습니다. 앱을 다시 설치해 보세요": "在此设备上找不到 AI 引擎。请尝试重新安装应用",
"이 기기에서는 쓸 수 없는 컴포넌트입니다": "此组件在本设备上不可用", "이 기기에서는 쓸 수 없는 컴포넌트입니다": "此组件在本设备上不可用",

View File

@ -1,6 +1,7 @@
import 'dart:io'; import 'dart:io';
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:archive/archive.dart' show Archive, ArchiveFile, ZipEncoder;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.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; import 'package:path/path.dart' as p;
/// X-plore 비교 P1 (관리자 지시 10/18): X-plore 스타일 탐색기를 PNG 로 (JJ_SHOT_DIR, 없으면 임시 폴더). /// X-plore 비교 P1 (관리자 지시 10/18): X-plore 스타일 탐색기를 PNG 로 (JJ_SHOT_DIR, 없으면 임시 폴더).
/// 트리 맨 위 저장 장치 · 즐겨찾기 (c) · 표시 동그라미 늘 보이기 (⚖) · 한 창의 "갈 곳" 줄 (a). /// 트리 맨 위 저장 장치 · 즐겨찾기 (c) · 표시 동그라미 늘 보이기 (⚖) · 한 창의 "갈 곳" 줄 (a) · 압축을 폴더처럼 (f).
/// 크기 412 (폰 세로, 한 창) · 960 (두 창), 언어 ko · en. 시험 파일은 임시 폴더에만, 설정은 메모리에만. /// 크기 412 (폰 세로, 한 창) · 960 (두 창), 언어 ko · en. 시험 파일은 임시 폴더에만, 설정은 메모리에만.
void main() { void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized(); 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, 'Show S01', 'ep01.mkv')).writeAsBytesSync(List.filled(5000, 0));
File(p.join(a.path, 'notes.txt')).writeAsStringSync('memo'); 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:')]; ExplorerPage.debugVolumes = () => [(a.path, 'C:'), (b.path, 'D:')];
addTearDown(() => ExplorerPage.debugVolumes = null); addTearDown(() => ExplorerPage.debugVolumes = null);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
@ -76,6 +80,14 @@ void main() {
await t.tap(find.byKey(const ValueKey<String>('k2:\u0000favorites')).first); await t.tap(find.byKey(const ValueKey<String>('k2:\u0000favorites')).first);
await settle(); await settle();
await shot('tree'); 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 t.pumpWidget(const SizedBox());
await settle(4); await settle(4);
}); });

View File

@ -50,7 +50,8 @@ import 'quick_look.dart';
import 'explorer_drag.dart'; import 'explorer_drag.dart';
import 'archive_ui.dart'; import 'archive_ui.dart';
import 'storage_page.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 'batch_rename_dialog.dart';
import '../core/app_trash.dart'; import '../core/app_trash.dart';
import '../core/folder_size.dart'; import '../core/folder_size.dart';
@ -786,6 +787,12 @@ class _ExplorerPageState extends State<ExplorerPage> {
Future<void> _load(_Pane pane, String dir, {bool force = false}) async { Future<void> _load(_Pane pane, String dir, {bool force = false}) async {
if (!force && pane.cache.containsKey(dir)) return; 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: 다시 읽는 폴더는 안이 바뀌었을 수 있다 - 그 폴더와 위 폴더들의 크기를 잊는다 // 80: 다시 읽는 폴더는 안이 바뀌었을 수 있다 - 그 폴더와 위 폴더들의 크기를 잊는다
if (force) _sizes.forget(dir); if (force) _sizes.forget(dir);
pane.loading.add(dir); pane.loading.add(dir);
@ -1035,7 +1042,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
if (foldersOnly && !e.isDir) continue; if (foldersOnly && !e.isDir) continue;
final at = out.length; final at = out.length;
out.add(_Row(e, depth)); 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)) { if (!inner && !_passes(pane, e)) {
out.length = at; out.length = at;
} else { } else {
@ -1363,6 +1371,14 @@ class _ExplorerPageState extends State<ExplorerPage> {
/// 트리 폴더 펼치기 · 접기 (› 화살표 · 열기) /// 트리 폴더 펼치기 · 접기 (› 화살표 · 열기)
Future<void> _toggle(_Pane pane, FileEntry e) async { Future<void> _toggle(_Pane pane, FileEntry e) async {
setState(() => _active = _panes.indexOf(pane)); 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)) { if (pane.expanded.contains(e.path)) {
pane.expanded.remove(e.path); pane.expanded.remove(e.path);
// 접은 폴더 안이 지금 폴더였으면 접은 폴더로 // 접은 폴더 안이 지금 폴더였으면 접은 폴더로
@ -1386,6 +1402,12 @@ class _ExplorerPageState extends State<ExplorerPage> {
/// 파일 열기 (기본 동작): 동영상은 내장 플레이어 (또는 확장자별 프로그램), 그 밖은 기본 연결 프로그램 /// 파일 열기 (기본 동작): 동영상은 내장 플레이어 (또는 확장자별 프로그램), 그 밖은 기본 연결 프로그램
Future<void> _open(String path, {_Pane? pane}) async { Future<void> _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)) { if (isVideoFile(path)) {
await _play([path]); await _play([path]);
return; return;
@ -1788,6 +1810,121 @@ class _ExplorerPageState extends State<ExplorerPage> {
_Pane get _pane => _panes[_active]; _Pane get _pane => _panes[_active];
_Pane? get _other => _dual || _hiddenTarget ? _panes[1 - _active] : null; _Pane? get _other => _dual || _hiddenTarget ? _panes[1 - _active] : null;
// ───────── X-plore 비교 f: 압축을 트리 안에서 폴더처럼 (ZIP · 7z · RAR, 읽기 전용) ─────────
/// 펼쳐 본 압축의 목록 (압축 경로 → 항목)
final _archives = <String, List<ArchiveEntry>>{};
/// 트리에서 펼칠 수 있는 압축 (이 기기의 파일 · 탐색기 화면)
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<bool> _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<FileEntry> _archiveList(String dir) {
final arc = _archiveOf(dir)!;
final rel = samePath(dir, arc) ? '' : _innerName(arc, dir);
final out = <String, FileEntry>{};
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<String?> _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<void> _extractFromArchive(List<String> 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<String> paths, {bool self = false}) {
if (!paths.any((x) => _insideArchive(x) || (self && _archives.containsKey(x)))) return false;
_snack(tr('압축 안은 읽기 전용입니다 (꺼내기만 할 수 있습니다)'));
return true;
}
/// X-plore 비교 a: 한 창인데 "숨은 다른 창으로" 복사 · 이동 (위에 갈 곳 줄) /// X-plore 비교 a: 한 창인데 "숨은 다른 창으로" 복사 · 이동 (위에 갈 곳 줄)
bool get _hiddenTarget => !widget.rsync && !_dual && !_split && c.settings.explorerOnePaneCopy == 'other'; bool get _hiddenTarget => !widget.rsync && !_dual && !_split && c.settings.explorerOnePaneCopy == 'other';
@ -1929,6 +2066,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
final t = _targets(pane); final t = _targets(pane);
await _addMkv(t.isEmpty ? [pane.current] : t); await _addMkv(t.isEmpty ? [pane.current] : t);
case ExplorerButton.newFolder: case ExplorerButton.newFolder:
// X-plore 비교 f: 펼친 압축 안 (지금 폴더가 압축 안의 폴더) 에는 만들지 않는다
if (_blockArchive([pane.current], self: true)) return;
final name = await _askName(tr('새 폴더'), ''); final name = await _askName(tr('새 폴더'), '');
if (name == null) return; if (name == null) return;
try { try {
@ -2041,6 +2180,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
} }
Future<void> _rename(_Pane pane, String path) async { Future<void> _rename(_Pane pane, String path) async {
if (_blockArchive([path])) return;
final name = await _askName(tr('이름 변경'), vBasename(path)); final name = await _askName(tr('이름 변경'), vBasename(path));
if (name == null || name == vBasename(path)) return; if (name == null || name == vBasename(path)) return;
try { try {
@ -2058,6 +2198,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
/// 78: 일괄 이름 바꾸기 - 번호는 목록에 보이는 차례대로. 끝나면 [되돌리기] /// 78: 일괄 이름 바꾸기 - 번호는 목록에 보이는 차례대로. 끝나면 [되돌리기]
Future<void> _batchRename(_Pane pane, List<String> paths) async { Future<void> _batchRename(_Pane pane, List<String> paths) async {
if (_blockArchive(paths)) return;
int at(String x) { int at(String x) {
final i = (pane.cache[vDirname(x)] ?? const <FileEntry>[]).indexWhere((e) => samePath(e.path, x)); final i = (pane.cache[vDirname(x)] ?? const <FileEntry>[]).indexWhere((e) => samePath(e.path, x));
return i < 0 ? 1 << 30 : i; return i < 0 ? 1 << 30 : i;
@ -2106,6 +2247,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
/// 지우기. Windows 의 로컬 파일은 기본으로 휴지통으로 (창에서 "영구 삭제" 를 고르거나 Shift+Delete). /// 지우기. Windows 의 로컬 파일은 기본으로 휴지통으로 (창에서 "영구 삭제" 를 고르거나 Shift+Delete).
/// WebDAV · Android 는 휴지통이 없어 바로 지운다. 여러 개면 실패해도 나머지를 계속하고, 못 지운 것을 알린다 (65). /// WebDAV · Android 는 휴지통이 없어 바로 지운다. 여러 개면 실패해도 나머지를 계속하고, 못 지운 것을 알린다 (65).
Future<void> _delete(_Pane pane, List<String> paths) async { Future<void> _delete(_Pane pane, List<String> paths) async {
if (_blockArchive(paths)) return;
paths = outermostPaths(paths); // 79-c: 고른 폴더 안의 것은 한 번만 (확인 창 개수도) paths = outermostPaths(paths); // 79-c: 고른 폴더 안의 것은 한 번만 (확인 창 개수도)
final shift = _shiftDelete; final shift = _shiftDelete;
_shiftDelete = false; _shiftDelete = false;
@ -2398,6 +2540,15 @@ class _ExplorerPageState extends State<ExplorerPage> {
/// 다른 창의 지금 폴더로 복사 · 이동 (진행 창 · 취소) /// 다른 창의 지금 폴더로 복사 · 이동 (진행 창 · 취소)
/// 복사 · 이동 버튼: 두 창이면 다른 창의 지금 폴더로, 그 밖의 배치는 담아 두기 (붙여넣기로) /// 복사 · 이동 버튼: 두 창이면 다른 창의 지금 폴더로, 그 밖의 배치는 담아 두기 (붙여넣기로)
Future<void> _transfer(_Pane pane, List<String> sources, {required bool move}) async { Future<void> _transfer(_Pane pane, List<String> 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: 고른 폴더 안의 것은 한 번만 sources = outermostPaths(sources); // 79-c: 고른 폴더 안의 것은 한 번만
final other = _other; final other = _other;
if (other == null) { if (other == null) {
@ -2511,6 +2662,13 @@ class _ExplorerPageState extends State<ExplorerPage> {
_snack(_pickFirst(move)); _snack(_pickFirst(move));
return false; 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: 앞의 복사 · 이동이 끝나지 않아도 함께 시작한다 (아래 진행 막대에 하나씩). 같은 것을 같은 곳으로만 막는다 // 23: 앞의 복사 · 이동이 끝나지 않아도 함께 시작한다 (아래 진행 막대에 하나씩). 같은 것을 같은 곳으로만 막는다
if (_jobs.any((j) => !j.finished && samePath(j.dest, dest) && j.sources.toSet().containsAll(sources))) { if (_jobs.any((j) => !j.finished && samePath(j.dest, dest) && j.sources.toSet().containsAll(sources))) {
_snack(tr('같은 항목을 같은 곳으로 복사 · 이동하는 중입니다.')); _snack(tr('같은 항목을 같은 곳으로 복사 · 이동하는 중입니다.'));
@ -3888,6 +4046,15 @@ class _ExplorerPageState extends State<ExplorerPage> {
/// 이동이 끝나면 아래 알림에 [되돌리기] (10초) /// 이동이 끝나면 아래 알림에 [되돌리기] (10초)
Future<void> _onDrop(DragLoad load, int to, String dest) async { Future<void> _onDrop(DragLoad load, int to, String dest) async {
if (!canDropInto(load, to, dest) || !mounted) return; 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); setState(() => _active = to);
final mode = c.settings.explorerDropAction; final mode = c.settings.explorerDropAction;
final move = mode == 'ask' ? await askDropAction(context, load.paths, dest) : mode == 'move'; final move = mode == 'ask' ? await askDropAction(context, load.paths, dest) : mode == 'move';
@ -4541,7 +4708,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
final isCurrent = e.isDir && !list && !row.isUp && samePath(pane.current, e.path); 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 isFocused = !row.isUp && pane.focused != null && samePath(pane.focused!, e.path);
final marked = _isMarked(pane, 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 loading = pane.loading.contains(e.path);
final name = row.isUp final name = row.isUp
? '..' ? '..'
@ -4563,7 +4730,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
Widget chevron() => SizedBox( Widget chevron() => SizedBox(
width: chevronWidth, width: chevronWidth,
height: look.rowHeightOf(context), height: look.rowHeightOf(context),
child: e.isDir && !list && !row.isUp child: (e.isDir || _canBrowseArchive(e)) && !list && !row.isUp
? (loading ? (loading
? const Center( ? const Center(
child: SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 1.5))) child: SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 1.5)))

View File

@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:archive/archive.dart' show Archive, ArchiveFile, ZipEncoder;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -255,6 +256,41 @@ void main() {
await tester.pump(const Duration(seconds: 1)); 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 { testWidgets('X-plore e: Android 앱 휴지통 - 지우기 = 휴지통으로 · [되돌리기] · [휴지통] 화면에서 되살리기', (tester) async {
// 저장 장치는 시험 폴더 (실제 C:\ 에 .jj_trash 를 만들지 않게) // 저장 장치는 시험 폴더 (실제 C:\ 에 .jj_trash 를 만들지 않게)
ExplorerPage.debugAndroidTrash = true; ExplorerPage.debugAndroidTrash = true;