From cb67ac5607f5bdb2955ed3d4d51b86e9eef835e6 Mon Sep 17 00:00:00 2001 From: toorux Date: Wed, 7 Oct 2026 19:10:55 +0800 Subject: [PATCH] Add fullscreen media viewer and download unsupported files on open --- docs/user-guide/terminal-and-files.md | 4 +- docs/zh-CN/user-guide/terminal-and-files.md | 4 +- tests/file_manager_fixture.tsx | 12 ++-- tests/file_media_preview_fixture.tsx | 41 ++++++++++++ ui/locales/en-US.json | 9 ++- ui/locales/zh-CN.json | 9 ++- ui/src/file-manager.css | 19 ++++++ ui/src/file-manager.tsx | 17 ++++- ui/src/file-media-preview.tsx | 72 +++++++++++++++++++++ 9 files changed, 176 insertions(+), 11 deletions(-) create mode 100644 tests/file_media_preview_fixture.tsx create mode 100644 ui/src/file-media-preview.tsx diff --git a/docs/user-guide/terminal-and-files.md b/docs/user-guide/terminal-and-files.md index 02b968c..e49e0c9 100644 --- a/docs/user-guide/terminal-and-files.md +++ b/docs/user-guide/terminal-and-files.md @@ -62,10 +62,12 @@ Select an image or video to preview it. PNG, JPEG, GIF, WebP, AVIF, BMP, MP4, We ## Access and privacy -Terminal, file and transfer routes use the existing authenticated manager connection and port. Mutating routes and the terminal WebSocket require an exact matching Origin; plugin sandboxes cannot use them. The service runs as the configured Steam user, independently of the root daemon. Preview/download content is read only after explicit selection. Test media are generated fixtures; Framely does not automatically read or upload Arcturus recordings. +Terminal, file and transfer routes use the existing authenticated manager connection and port. Mutating routes and the terminal WebSocket require an exact matching Origin; plugin sandboxes cannot use them. The service runs as the configured Steam user, independently of the root daemon. Full previews and downloads read content after explicit selection; icon views read only visible media to generate lazy thumbnails. There is no background directory media scan. Test media are generated fixtures; development and validation do not read or upload private Arcturus recordings. ## Diagnostic export **Settings → General → Export logs** and the installer’s **Export logs** use the same collector. The archive includes recent APK operation logs, Lepton’s saved Android logs, and a limited snapshot of running containers: Android boot, user unlock, storage mounting, PackageManager readiness and recent main/system/crash logs. Rootless containers are inspected as the configured Steam session user. Stopped containers are not started. App state is summarized without tokens or icons; APK files, saves, media and raw memory dumps are excluded. File and archive limits retain recent output; missing sources, truncation and command timeouts are listed in `report.json`. Logs can contain application information; review before sharing. File sorting (name, size or modification time, including ascending/descending order) is remembered separately for each folder. The **View** menu switches between a list and small, medium or large icons; this choice applies to all folders. Preferences are saved on Frame and restored when reopening the panel in a browser or headset. Icon views lazily generate thumbnails for visible images and videos using browser decoding, with at most two concurrent jobs. Videos use a frame around one second. Thumbnails are cached in bounded memory while the page remains open and refreshed when file metadata changes. Scrolling out of view cancels pending work; unsupported formats or decoding failures retain the file-type icon. No FFmpeg or whole-folder media scan is used. + +Clicking an image or video opens a full-panel viewer. Use the wheel or zoom buttons to zoom, drag the content to pan, and use Previous/Next or the arrow keys to browse the image/video files in the current displayed list. Fit to window resets the view; Esc closes it. Video playback controls remain available. If browser decoding fails, download the file from the viewer. UTF-8 text up to 5 MiB still opens in the editor; binary files, unsupported encodings and larger files download directly when clicked. diff --git a/docs/zh-CN/user-guide/terminal-and-files.md b/docs/zh-CN/user-guide/terminal-and-files.md index 1f2eab2..e920f94 100644 --- a/docs/zh-CN/user-guide/terminal-and-files.md +++ b/docs/zh-CN/user-guide/terminal-and-files.md @@ -64,10 +64,12 @@ ## 访问与隐私 -终端、文件与传输使用现有管理面板登录和端口;修改接口及终端 WebSocket 严格匹配 Origin,插件沙箱不可调用。接口运行于 Steam 用户会话服务,不借用 root 本体执行文件操作。预览与下载仅在用户主动选择后读取内容;测试媒体为生成的样本,不自动读取或上传 Arcturus 录制文件。 +终端、文件与传输使用现有管理面板登录和端口;修改接口及终端 WebSocket 严格匹配 Origin,插件沙箱不可调用。接口运行于 Steam 用户会话服务,不借用 root 本体执行文件操作。完整预览与下载在用户主动选择后读取内容;图标视图仅为当前可见的媒体按需读取并生成缩略图,不后台扫描目录媒体。测试媒体为生成的样本,开发及验证不读取或上传私人 Arcturus 录制文件。 ## 导出诊断日志 **设置 → 通用 → 导出日志** 和安装器的 **导出日志** 使用同一收集流程。日志包包含近期 APK 操作日志、Lepton 已保存的 Android 日志,以及运行中容器的有限快照:Android 启动、用户解锁、存储挂载、包管理器就绪状态和近期 main/system/crash 日志。以配置的 Steam 会话用户检查无 root 容器,不启动已停止的容器。应用状态只导出摘要,排除令牌和图标;不读取 APK、存档、媒体或原始内存转储。单文件及总大小受限,保留近期输出;缺失来源、截断和命令超时记录在 `report.json`。日志可能包含应用信息,分享前请检查。 文件排序(名称、大小或修改时间,以及升序/降序)按文件夹分别记忆。“展示方式”可切换列表、小图标、中图标和大图标,此选项对所有文件夹统一生效。偏好保存在 Frame,重新打开浏览器或头显管理面板时恢复。图标视图仅为当前可见的图片和视频按需生成缩略图,使用浏览器解码,最多同时处理两项。视频截取约第 1 秒的画面。缩略图使用有上限的页面内存缓存,文件元数据变化后重新生成;滚出可视区域会取消待处理任务。不支持的格式或解码失败时保留文件类型图标。不使用 FFmpeg,也不扫描整个文件夹的媒体。 + +点击图片或视频会打开全屏遮罩预览。使用滚轮或缩放按钮缩放,拖动画面移动,通过上一个/下一个或方向键切换当前展示列表中的图片、视频。“适应窗口”恢复初始视图,Esc 关闭;视频保留播放控制。浏览器无法解码时,可在预览中下载文件。5 MiB 以内的 UTF-8 文本仍在编辑器中打开;二进制文件、不支持的编码及更大的文件点击后直接下载。 diff --git a/tests/file_manager_fixture.tsx b/tests/file_manager_fixture.tsx index a1dc153..f3d6974 100644 --- a/tests/file_manager_fixture.tsx +++ b/tests/file_manager_fixture.tsx @@ -8,8 +8,9 @@ const wait=(ms=40)=>new Promise(r=>setTimeout(r,ms)); const until=async(f:()=>unknown)=>{for(let n=0;n<150;n++){if(f())return;await wait();}throw Error('Timeout '+document.body.innerText.slice(-800));}; const home='/home/steamos',downloads=home+'/Downloads'; const longFolder='一个很长的文件夹名称 - Project documents and archived screenshots'; +let binaryMode=false; let preferences:any={view:'list',folders:{[home]:{sort:'size',descending:true},[downloads]:{sort:'modified',descending:false}}}; -const calls:any[]=[]; +const calls:any[]=[],downloadsClicked:string[]=[];const anchorClick=HTMLAnchorElement.prototype.click;HTMLAnchorElement.prototype.click=function(){downloadsClicked.push(this.getAttribute('href')??'');}; const files=(directory:string)=>[ {name:'Photos',path:directory+'/Photos',directory:true,symlink:false,size:0,mode:493,uid:1000,gid:1000}, {name:'A very long photograph name with spaces.png',path:directory+'/picture.png',directory:false,symlink:false,size:8192,modified:1791360000,mode:420,uid:1000,gid:1000}, @@ -26,7 +27,7 @@ window.fetch=async(url,init)=>{ result=structuredClone(preferences); }else if(p.operation==='list'){ let directory=p.path||home;if(directory==='/alias')directory=home; - let entries=files(directory); + let entries=files(directory);if(binaryMode)entries=[{...entries[2],name:'sample.bin',path:directory+'/sample.bin'}]; if(p.hidden){ // Tree requests do not consume the sort checks below. const child=directory==='/'?{name:'home',path:'/home'}:directory==='/home'?{name:'steamos',path:home}:directory===home?{name:longFolder,path:home+'/'+longFolder}:null; entries=child?[{...child,directory:true,symlink:false,size:0,mode:493,uid:1000,gid:1000}]:[]; @@ -34,7 +35,8 @@ window.fetch=async(url,init)=>{ result={path:directory,parent:'/',entries,total:entries.length,roots:[{name:'Home',path:home},{name:'Downloads',path:downloads}]}; }else if(p.operation==='preview'){const canvas=document.createElement('canvas');canvas.width=80;canvas.height=60;const ctx=canvas.getContext('2d')!;ctx.fillStyle='#8bb4ce';ctx.fillRect(0,0,80,60);result={url:p.path.endsWith('.png')?canvas.toDataURL(): 'data:video/webm;base64,AA==',token:p.path};} else if(p.operation==='preview.release')result=true; - else if(p.operation==='read')result={text:'fixture text',revision:'revision'}; + else if(p.operation==='read'){if(p.path.endsWith('notes.txt'))result={text:'fixture text',revision:'revision'};else return new Response(JSON.stringify({error:'Binary files cannot be edited as text'}));} + else if(p.operation==='download')result={url:'/fixture-download'}; else throw Error('Unexpected operation '+p.operation); return new Response(JSON.stringify({result})); }; @@ -71,12 +73,14 @@ const changeAddress=async(value:string)=>{const input=document.querySelectorready()&&document.querySelector('.file-address input')!.value===home); if(listing().sort!=='size')throw Error('Canonical folder preference not restored'); const tile=document.querySelector('.file-tile-open[title="notes.txt"]')!;tile.click();await until(()=>document.querySelector('.file-editor'));button('Close').click(); + document.querySelector('.file-tile-open[title="A very long photograph name with spaces.png"]')!.click();await until(()=>document.querySelector('.file-media-overlay img'));await until(()=>document.querySelector('.file-media-overlay img')?.getAttribute('src'));document.querySelector('.file-media-overlay [aria-label="Close"]')!.click();await until(()=>!document.querySelector('.file-media-overlay')); await wait(400);console.log('FRAMELY_PREVIEW_INSTALL_FILE_MANAGER_ICONS_EN');await wait(300); configureLanguage('zh-CN',[]);render('three');await wait();await until(()=>ready()&&document.querySelector('.icons-medium'));await select('展示方式','大图标');await until(()=>document.querySelector('.icons-large')); await wait(400);console.log('FRAMELY_PREVIEW_INSTALL_FILE_MANAGER_ICONS_ZH');await wait(300); await select('展示方式','列表');await until(()=>document.querySelector('.file-table'));document.querySelector('[aria-label="选择本页"]')!.click();await until(()=>document.querySelectorAll('.file-table tr.selected').length===4); if(calls.some(p=>p.operation==='preview'&&!/\.(png|webm)$/.test(p.path)))throw Error('Non-media fetched for thumbnails'); if(!calls.some(p=>p.operation==='preview.release'))throw Error('Source preview grants not released'); - console.log('FRAMELY_BRIDGE_PASS'); + binaryMode=true;document.querySelector('[aria-label="刷新"]')!.click();await until(()=>document.querySelector('.file-name')?.textContent?.includes('sample.bin'));document.querySelector('.file-name')!.click();await until(()=>downloadsClicked.includes('/fixture-download'));if(document.querySelector('.modal'))throw Error('Unsupported file opened a dialog'); + HTMLAnchorElement.prototype.click=anchorClick;console.log('FRAMELY_BRIDGE_PASS'); }catch(e){console.error('FRAMELY_BRIDGE_FAIL '+String(e)+' '+(e as Error).stack);}}; console.log('FRAMELY_VIEW_READY'); diff --git a/tests/file_media_preview_fixture.tsx b/tests/file_media_preview_fixture.tsx new file mode 100644 index 0000000..4e8cd8e --- /dev/null +++ b/tests/file_media_preview_fixture.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import {createRoot} from 'react-dom/client'; +import {FileMediaPreview} from '../ui/src/file-media-preview'; +import {configureLanguage} from '../ui/src/i18n'; +import '../ui/src/style.css'; +import '../ui/src/file-manager.css'; +const wait=(ms=40)=>new Promise(r=>setTimeout(r,ms)); +const until=async(f:()=>unknown)=>{for(let n=0;n<200;n++){if(f())return;await wait();}throw Error('Preview timeout '+document.body.innerText);}; +const root=createRoot(document.getElementById('root')!); +let imageUrl='',videoUrl='',closed=0;const released:string[]=[],downloads:string[]=[]; +window.fetch=async(_url,init)=>{const p=JSON.parse(String(init?.body));if(p.operation==='preview.release'){released.push(p.token);return new Response(JSON.stringify({result:true}));}if(p.operation!=='preview')throw Error('Unexpected operation');await wait(60);return new Response(JSON.stringify({result:{url:p.path==='/broken.png'?'data:image/png;base64,AA==':p.path.endsWith('.webm')?videoUrl:imageUrl,token:p.path}}));}; +const entries=[{path:'/first.png',name:'Generated landscape.png'},{path:'/clip.webm',name:'Generated video.webm'},{path:'/last.png',name:'Second generated image.png'},{path:'/broken.png',name:'Unsupported image.png'}]; +const render=(initial=0)=>root.render(<>{closed++;root.render();}} onDownload={path=>downloads.push(path)}/>); +const button=(label:string)=>document.querySelector(`.file-media-overlay [aria-label="${label}"]`)!; +const loaded=()=>document.querySelector('.file-media-canvas')?.style.visibility==='visible'&&!document.querySelector('.file-media-loading')&&!document.querySelector('.file-media-error'); +const transform=()=>document.querySelector('.file-media-canvas')!.style.transform; +async function samples(){const canvas=document.createElement('canvas');canvas.width=640;canvas.height=360;const ctx=canvas.getContext('2d')!;ctx.fillStyle='#63869b';ctx.fillRect(0,0,640,360);ctx.fillStyle='#bcc4c9';ctx.beginPath();ctx.moveTo(0,360);ctx.lineTo(220,100);ctx.lineTo(450,360);ctx.fill();imageUrl=canvas.toDataURL();const stream=canvas.captureStream(10),recorder=new MediaRecorder(stream,{mimeType:'video/webm;codecs=vp8'}),chunks:Blob[]=[];recorder.ondataavailable=e=>chunks.push(e.data);const stopped=new Promise(r=>recorder.onstop=()=>r());recorder.start();const timer=setInterval(()=>ctx.fillRect(500,100,40,40),70);await wait(1200);recorder.stop();await stopped;clearInterval(timer);stream.getTracks().forEach(t=>t.stop());videoUrl=URL.createObjectURL(new Blob(chunks,{type:'video/webm'}));} +(window as any).runInstallReviewChecks=async()=>{try{ + configureLanguage('en-US',[]);root.render(
);await wait(300);await samples();render();await until(loaded); + const overlay=document.querySelector('.file-media-overlay')!,bounds=overlay.getBoundingClientRect();if(bounds.width!==innerWidth||bounds.height!==innerHeight)throw Error('Viewer not fullscreen'); + if(!button('Previous file').disabled)throw Error('Previous not disabled on first file'); + button('Zoom in').click();await until(()=>document.querySelector('output')?.textContent==='125%'); + const stage=document.querySelector('.file-media-stage')!;stage.dispatchEvent(new WheelEvent('wheel',{deltaY:-100,clientX:180,clientY:150,bubbles:true,cancelable:true}));await until(()=>transform().includes('scale(1.5')); + // Synthetic events cannot capture native pointers. Exercise the same pan handler + // while retaining real browser media decoding and layout. + const capture=Element.prototype.setPointerCapture;Element.prototype.setPointerCapture=function(){}; + stage.dispatchEvent(new PointerEvent('pointerdown',{pointerId:7,clientX:100,clientY:100,button:0,bubbles:true,cancelable:true}));const before=transform();stage.dispatchEvent(new PointerEvent('pointermove',{pointerId:7,clientX:140,clientY:120,bubbles:true,cancelable:true}));await until(()=>transform()!==before);stage.dispatchEvent(new PointerEvent('pointerup',{pointerId:7,bubbles:true}));Element.prototype.setPointerCapture=capture; + button('Fit to window').click();await until(()=>transform()==='translate(0px, 0px) scale(1)'); + overlay.dispatchEvent(new KeyboardEvent('keydown',{key:'ArrowRight',bubbles:true}));await until(()=>document.querySelector('video')&&loaded()); + if(!document.querySelector('video')!.controls||!released.includes('/first.png'))throw Error('Video controls/grant release missing'); + button('Download').click();if(downloads[0]!=='/clip.webm')throw Error('Wrong video downloaded'); + button('Next file').click();await until(()=>document.querySelector('img')?.getAttribute('alt')===entries[2].name&&loaded()); + if(!transform().includes('scale(1)'))throw Error('Switch retained transform'); + button('Next file').click();await until(()=>document.querySelector('.file-media-error'));if(!button('Next file').disabled)throw Error('Next not disabled on last file'); + document.querySelector('.file-media-error button')!.click();if(!downloads.includes('/broken.png'))throw Error('Decode failure download missing'); + button('Previous file').click();await until(loaded);await wait(200);console.log('FRAMELY_PREVIEW_INSTALL_FILE_MEDIA_EN');await wait(250); + overlay.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));await until(()=>closed===1&&!document.querySelector('.file-media-overlay'));await until(()=>released.includes('/last.png')); + configureLanguage('zh-CN',[]);render();await until(loaded);if(!button('放大')||!button('下一个文件'))throw Error('Chinese controls missing');await wait(200);console.log('FRAMELY_PREVIEW_INSTALL_FILE_MEDIA_ZH');await wait(250); + button('关闭').click();await until(()=>closed===2);URL.revokeObjectURL(videoUrl);console.log('FRAMELY_BRIDGE_PASS'); + }catch(e){console.error('FRAMELY_BRIDGE_FAIL '+String(e)+' '+(e as Error).stack);}}; +console.log('FRAMELY_VIEW_READY'); diff --git a/ui/locales/en-US.json b/ui/locales/en-US.json index 7d7e63e..c443521 100644 --- a/ui/locales/en-US.json +++ b/ui/locales/en-US.json @@ -992,6 +992,13 @@ "选择 {0}": "Select {0}", "Invalid file view": "Invalid file view", "Invalid file sort": "Invalid file sort", - "Invalid file sort direction": "Invalid file sort direction" + "Invalid file sort direction": "Invalid file sort direction", + "缩小": "Zoom out", + "放大": "Zoom in", + "缩放比例": "Zoom level", + "适应窗口": "Fit to window", + "上一个文件": "Previous file", + "下一个文件": "Next file", + "滚轮缩放 · 拖动移动 · ← → 切换 · Esc 关闭": "Scroll to zoom · Drag to pan · ← → Browse · Esc Close" } } diff --git a/ui/locales/zh-CN.json b/ui/locales/zh-CN.json index c232156..56931b5 100644 --- a/ui/locales/zh-CN.json +++ b/ui/locales/zh-CN.json @@ -992,6 +992,13 @@ "选择 {0}": "选择 {0}", "Invalid file view": "无效的文件展示方式", "Invalid file sort": "无效的文件排序方式", - "Invalid file sort direction": "无效的文件排序方向" + "Invalid file sort direction": "无效的文件排序方向", + "缩小": "缩小", + "放大": "放大", + "缩放比例": "缩放比例", + "适应窗口": "适应窗口", + "上一个文件": "上一个文件", + "下一个文件": "下一个文件", + "滚轮缩放 · 拖动移动 · ← → 切换 · Esc 关闭": "滚轮缩放 · 拖动移动 · ← → 切换 · Esc 关闭" } } diff --git a/ui/src/file-manager.css b/ui/src/file-manager.css index 94b32a7..427ce8e 100644 --- a/ui/src/file-manager.css +++ b/ui/src/file-manager.css @@ -185,3 +185,22 @@ .file-manager .file-thumbnail{position:relative;width:100%;height:100%;display:grid;place-items:center} .file-manager .file-media-thumbnail{width:100%;height:100%;object-fit:contain;border-radius:5px;user-select:none;pointer-events:none} .file-manager .file-video-marker{position:absolute;bottom:2px;left:2px;color:#f4f4f6;filter:drop-shadow(0 1px 2px #000);pointer-events:none} +/* Media viewer covers the entire panel or browser, independently of file layout. */ +.file-manager.file-media-overlay{position:fixed;inset:0;z-index:150;display:flex;flex-direction:column;background:#111113f5;color:#ededf0;outline:none;--file-text:#ededf0;--file-muted:#a6a6af} +.file-media-header{position:relative;z-index:2;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:14px 20px;border-bottom:1px solid #ffffff12;background:#19191b} +.file-media-header>div:first-child{display:flex;align-items:baseline;gap:14px;min-width:0} +.file-media-header strong{font-size:16px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.file-media-header span,.file-media-actions output{color:#a6a6af;font-size:13px;white-space:nowrap} +.file-media-actions{display:flex;align-items:center;gap:5px;flex-shrink:0} +.file-manager .file-media-actions button,.file-manager .file-media-prev,.file-manager .file-media-next{display:grid;place-items:center;width:40px;height:40px;padding:0;border:1px solid #ffffff14;border-radius:10px;background:#29292de0;color:#ececef} +.file-media-actions output{min-width:44px;text-align:center;font-variant-numeric:tabular-nums} +.file-media-stage{position:relative;flex:1;min-height:0;overflow:hidden;touch-action:none;user-select:none;cursor:grab} +.file-media-stage:active{cursor:grabbing} +.file-media-canvas{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;transform-origin:center;will-change:transform;pointer-events:none} +.file-media-canvas img,.file-media-canvas video{max-width:calc(100% - 120px);max-height:calc(100% - 32px);object-fit:contain;display:block} +.file-media-canvas video{pointer-events:auto;cursor:auto;touch-action:auto} +.file-media-loading,.file-media-error{position:absolute;inset:0;display:flex;flex-direction:column;gap:14px;align-items:center;justify-content:center;padding:40px;text-align:center;color:#b1b1bb;cursor:default} +.file-manager .file-media-prev,.file-manager .file-media-next{position:absolute;top:50%;z-index:3;width:44px;height:52px} +.file-media-prev{left:16px}.file-media-next{right:16px} +.file-media-footer{padding:9px 16px;text-align:center;font-size:12px;color:#9999a5;border-top:1px solid #ffffff0a} +@media(max-width:700px){.file-media-header{flex-wrap:wrap;padding:10px 12px;gap:8px}.file-media-header>div:first-child{width:100%;justify-content:space-between}.file-media-actions{margin-left:auto}.file-media-canvas img,.file-media-canvas video{max-width:calc(100% - 76px)}.file-media-prev{left:6px}.file-media-next{right:6px}.file-manager .file-media-prev,.file-manager .file-media-next{width:32px;height:44px}.file-media-footer{font-size:11px}} diff --git a/ui/src/file-manager.tsx b/ui/src/file-manager.tsx index e4e007a..5239334 100644 --- a/ui/src/file-manager.tsx +++ b/ui/src/file-manager.tsx @@ -2,6 +2,7 @@ import React,{useEffect,useRef,useState} from 'react'; import {IconFolder,IconFile,IconArrowUp,IconRefresh,IconUpload,IconDownload,IconStar,IconTrash,IconChevronRight,IconChevronDown,IconSearch,IconFolderPlus,IconPlus,IconCopy,IconCut,IconClipboard,IconDots,IconPencil,IconX,IconArrowDown,IconHome,IconDeviceSdCard,IconFileText,IconPhoto,IconMovie,IconFileZip,IconExternalLink,IconInfoCircle,IconArrowLeft} from '@tabler/icons-react'; import {managerApi,ManagerError} from './manager-api'; import {FileThumbnail} from './file-thumbnail'; +import {FileMediaPreview,mediaKind,MediaEntry} from './file-media-preview'; import {api} from './api'; import {uploadFile} from './upload'; import {Select} from './localized-select'; @@ -33,7 +34,7 @@ function FolderTree({name,path,onOpen,current,depth=0}:{name:string;path:string; return
{error&&expanded&&{error}}{expanded&&
{children.map(e=>)}{more&&}
}
; } export function FileManager({active=true}:{active?:boolean}){ - const[data,setData]=useState({path:'',entries:[],total:0,roots:[]}),[path,setPath]=useState(''),[address,setAddress]=useState(''),[query,setQuery]=useState(''),[hidden,setHidden]=useState(false),[offset,setOffset]=useState(0),[selected,setSelected]=useState([]),[bookmarks,setBookmarks]=useState([]),[error,setError]=useState(''),[busy,setBusy]=useState(false),[loading,setLoading]=useState(true),[dialog,setDialog]=useState(null),[value,setValue]=useState(''),[policy,setPolicy]=useState('keep'),[permanent,setPermanent]=useState(false),[job,setJob]=useState(null),[clipboard,setClipboard]=useState<{paths:string[];cut:boolean}|null>(null),[preview,setPreview]=useState<{entry:Entry;url:string;video:boolean}|null>(null),[editor,setEditor]=useState<{path:string;text:string;revision:string}|null>(null),[modified,setModified]=useState(false),[trash,setTrash]=useState(null),[upload,setUpload]=useState<{name:string;received:number;total:number}|null>(null),[results,setResults]=useState(null),[counter,setCounter]=useState(0),[context,setContext]=useState(false),[bookmarkMenu,setBookmarkMenu]=useState(null),[outcomes,setOutcomes]=useState([]),[menu,setMenu]=useState<{x:number;y:number;blank:boolean}|null>(null); + const[data,setData]=useState({path:'',entries:[],total:0,roots:[]}),[path,setPath]=useState(''),[address,setAddress]=useState(''),[query,setQuery]=useState(''),[hidden,setHidden]=useState(false),[offset,setOffset]=useState(0),[selected,setSelected]=useState([]),[bookmarks,setBookmarks]=useState([]),[error,setError]=useState(''),[busy,setBusy]=useState(false),[loading,setLoading]=useState(true),[dialog,setDialog]=useState(null),[value,setValue]=useState(''),[policy,setPolicy]=useState('keep'),[permanent,setPermanent]=useState(false),[job,setJob]=useState(null),[clipboard,setClipboard]=useState<{paths:string[];cut:boolean}|null>(null),[preview,setPreview]=useState<{entries:MediaEntry[];initial:number}|null>(null),[editor,setEditor]=useState<{path:string;text:string;revision:string}|null>(null),[modified,setModified]=useState(false),[trash,setTrash]=useState(null),[upload,setUpload]=useState<{name:string;received:number;total:number}|null>(null),[results,setResults]=useState(null),[counter,setCounter]=useState(0),[context,setContext]=useState(false),[bookmarkMenu,setBookmarkMenu]=useState(null),[outcomes,setOutcomes]=useState([]),[menu,setMenu]=useState<{x:number;y:number;blank:boolean}|null>(null); const[filePreferences,setFilePreferences]=useState({view:'list',folders:{}}),[preferencesReady,setPreferencesReady]=useState(false); const preferenceWrites=useRef>(Promise.resolve()); const sort=filePreferences.folders[path]?.sort??'name',descending=filePreferences.folders[path]?.descending??false,view=filePreferences.view; @@ -68,7 +69,17 @@ export function FileManager({active=true}:{active?:boolean}){ async function run(p:any){try{await perform(p);}catch{}} function download(url:string){const a=document.createElement('a');a.href=url;a.download='';document.body.appendChild(a);a.click();a.remove();} async function getDownload(paths=selected){try{if(paths.length===1&&!shown.find(e=>e.path===paths[0])?.directory){const v=await perform({operation:'download',path:paths[0]});download(v.url);}else{const v=await perform({operation:'download.batch',paths});download(v.url);}}catch{}} - async function open(e:Entry){if(e.directory){navigate(e.path);return;}const ext=e.name.split('.').pop()?.toLowerCase()??'';if(['png','jpg','jpeg','gif','webp','avif','bmp','mp4','webm','mov','m4v'].includes(ext)){try{const v=await perform({operation:'preview',path:e.path});setPreview({entry:e,url:v.url,video:['mp4','webm','mov','m4v'].includes(ext)});}catch{}return;}try{const v=await perform({operation:'read',path:e.path});setEditor({path:e.path,...v});setModified(false);}catch{openDialog({title:t('文件属性'),kind:'properties',entry:e,value:e.mode.toString(8)});}} + async function open(e:Entry){ + if(e.directory){navigate(e.path);return;} + if(mediaKind(e)){const entries=shown.filter(item=>!item.directory&&mediaKind(item));setMenu(null);setError('');setPreview({entries,initial:Math.max(0,entries.findIndex(item=>item.path===e.path))});return;} + // UTF-8 text remains editable. Other formats download instead of opening properties. + if(e.size>5*1024*1024){await getDownload([e.path]);return;} + setBusy(true);setError(''); + try{const v=await managerApi('files',{operation:'read',path:e.path});setEditor({path:e.path,...v});setModified(false);} + catch(error){const raw=error instanceof ManagerError?error.raw:String(error);if(['Binary files cannot be edited as text','Only UTF-8 text can be edited','Text editor supports files up to 5 MiB'].some(message=>raw.includes(message)))await getDownload([e.path]);else setError(String(error));} + finally{setBusy(false);} + } + useEffect(()=>()=>cancelUpload.current?.abort(),[]); async function uploads(files:File[]){ setBusy(true);setError('');const controller=new AbortController();cancelUpload.current=controller;let id=''; @@ -173,7 +184,7 @@ export function FileManager({active=true}:{active?:boolean}){ {dialog.kind==='delete'&&<>

{t('请选择删除方式。')}

{policy==='overwrite'&&

{t('确认后会覆盖同名文件或整个同名文件夹。')}

}}{dialog.kind==='compress'&&

{t('支持 .zip、.tar 和 .tar.gz。')}

}{dialog.kind==='trash.purge'&&

{t('永久删除无法撤销。')}

}{dialog.kind==='editor.discard'&&

{t('关闭将丢弃未保存的修改。')}

}{dialog.kind==='editor.overwrite'&&

{t('文件已被外部修改。可重新加载,或确认覆盖。')}

}
{dialog.kind==='editor.overwrite'&&}
} - {active&&preview&&

{preview.entry.name}

{error&&

{error}

}{preview.video?
} + {active&&preview&&setPreview(null)} onDownload={path=>void getDownload([path])}/>} {active&&editor&&!dialog&&

{editor.path.split('/').pop()}{modified?' *':''}

{error&&

{error}

}