mirror of
https://github.com/SteamFramelyHomebrew/framely.git
synced 2026-10-26 02:03:28 +01:00
feat: add user file management, transfers, editing and media preview
This commit is contained in:
1 parent
e32d3d9155
commit
2664d953cd
5 files changed
+1749
-2
No files matched your search
@@ -9,6 +9,7 @@ mod ipc;
|
||||
mod jobs;
|
||||
mod launcher_search;
|
||||
mod localization;
|
||||
mod manager_files;
|
||||
mod model;
|
||||
mod package;
|
||||
mod planner;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+241
-2
@@ -33,7 +33,7 @@ struct Agent {
|
||||
jobs: crate::jobs::Jobs,
|
||||
launcher_search: Mutex<crate::launcher_search::SearchCache>,
|
||||
terminals: crate::terminal::Terminals,
|
||||
|
||||
files: Arc<crate::manager_files::Files>,
|
||||
catalog_cache: Arc<Mutex<std::collections::BTreeMap<String, Value>>>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
@@ -591,6 +591,94 @@ impl Agent {
|
||||
let result = self.jobs.uploads.append(id, offset, &bytes)?;
|
||||
return send_json(r, json!({"result":result}));
|
||||
}
|
||||
if let Some(id) = path.strip_prefix("/manager-api/file-content/") {
|
||||
ensure!(
|
||||
matches!(r.method(), Method::Get | Method::Head),
|
||||
"Invalid download method"
|
||||
);
|
||||
ensure!(
|
||||
!r.headers()
|
||||
.iter()
|
||||
.any(|h| h.field.equiv("Sec-Fetch-Site") && h.value.as_str() == "cross-site"),
|
||||
"Cross-origin download denied"
|
||||
);
|
||||
ensure!(
|
||||
!r.headers()
|
||||
.iter()
|
||||
.any(|h| h.field.equiv("Origin") && h.value.as_str() != expected_origin),
|
||||
"Cross-origin download denied"
|
||||
);
|
||||
ensure!(
|
||||
self.core("agreement.status", json!({}))?["accepted"] == true,
|
||||
"请先同意用户协议和隐私声明"
|
||||
);
|
||||
let (file, inline) = self.files.content(id)?;
|
||||
return serve_user_file(r, &file, inline);
|
||||
}
|
||||
if path == "/manager-api/files" || path.starts_with("/manager-api/file-upload/") {
|
||||
ensure!(r.method() == &Method::Post, "Invalid file manager method");
|
||||
ensure!(
|
||||
r.headers()
|
||||
.iter()
|
||||
.any(|h| h.field.equiv("Origin") && h.value.as_str() == expected_origin),
|
||||
"Cross-origin manager request denied"
|
||||
);
|
||||
ensure!(
|
||||
self.core("agreement.status", json!({}))?["accepted"] == true,
|
||||
"请先同意用户协议和隐私声明"
|
||||
);
|
||||
if let Some(upload) = path.strip_prefix("/manager-api/file-upload/") {
|
||||
let (id, offset) = upload.split_once('/').context("Invalid upload path")?;
|
||||
let offset: u64 = offset.parse()?;
|
||||
let mut bytes = Vec::new();
|
||||
r.as_reader().take(1048577).read_to_end(&mut bytes)?;
|
||||
let result = self.files.append(id, offset, &bytes);
|
||||
return send_json(
|
||||
r,
|
||||
match result {
|
||||
Ok(v) => json!({"result":v}),
|
||||
Err(e) => json!({"error":e.to_string()}),
|
||||
},
|
||||
);
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
r.as_reader()
|
||||
.take(6 * 1024 * 1024 + 1)
|
||||
.read_to_end(&mut bytes)?;
|
||||
ensure!(
|
||||
bytes.len() <= 6 * 1024 * 1024,
|
||||
"File manager request too large"
|
||||
);
|
||||
let result = (|| -> Result<Value> {
|
||||
let p: Value = serde_json::from_slice(&bytes)?;
|
||||
let operation = p["operation"].as_str().unwrap_or("list");
|
||||
if matches!(
|
||||
operation,
|
||||
"copy"
|
||||
| "move"
|
||||
| "delete"
|
||||
| "search"
|
||||
| "compress"
|
||||
| "extract"
|
||||
| "download.batch"
|
||||
) {
|
||||
let files = self.files.clone();
|
||||
let home = crate::steam::home()?;
|
||||
return self.jobs.task_progress(
|
||||
&format!("files:{operation}:{}", crate::manager_files::token()),
|
||||
move |cancel, progress| files.task(&home, &p, cancel, progress),
|
||||
);
|
||||
}
|
||||
self.files.api(&crate::steam::home()?, &p)
|
||||
})();
|
||||
return send_json(
|
||||
r,
|
||||
match result {
|
||||
Ok(v) => json!({"result":v}),
|
||||
Err(e) => json!({"error":e.to_string()}),
|
||||
},
|
||||
);
|
||||
}
|
||||
if path == "/manager-api/terminal" || path == "/manager-api/terminal/ws" {
|
||||
ensure!(
|
||||
r.headers()
|
||||
@@ -1422,6 +1510,102 @@ fn send_file(r: Request, p: &Path, cors: bool) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn serve_user_file(r: Request, path: &Path, inline: bool) -> Result<()> {
|
||||
use std::io::{Seek, SeekFrom};
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.custom_flags(libc::O_NONBLOCK)
|
||||
.open(path)?;
|
||||
ensure!(file.metadata()?.is_file(), "Select a regular file");
|
||||
let length = file.metadata()?.len();
|
||||
let range = r
|
||||
.headers()
|
||||
.iter()
|
||||
.find(|h| h.field.equiv("Range"))
|
||||
.map(|h| h.value.as_str());
|
||||
let mut start = 0;
|
||||
let mut end = length.saturating_sub(1);
|
||||
let mut partial = false;
|
||||
if let Some(range) = range {
|
||||
let parsed = (|| -> Result<(u64, u64)> {
|
||||
let value = range.strip_prefix("bytes=").context("Invalid range")?;
|
||||
ensure!(!value.contains(','), "Multiple ranges are not supported");
|
||||
let (first, last) = value.split_once('-').context("Invalid range")?;
|
||||
let (first, last) = if first.is_empty() {
|
||||
let size: u64 = last.parse()?;
|
||||
ensure!(size > 0, "Invalid range");
|
||||
(length.saturating_sub(size), length.saturating_sub(1))
|
||||
} else {
|
||||
let first: u64 = first.parse()?;
|
||||
let last = if last.is_empty() {
|
||||
length.saturating_sub(1)
|
||||
} else {
|
||||
last.parse::<u64>()?.min(length.saturating_sub(1))
|
||||
};
|
||||
(first, last)
|
||||
};
|
||||
ensure!(
|
||||
length > 0 && first < length && first <= last,
|
||||
"Range is outside the file"
|
||||
);
|
||||
Ok((first, last))
|
||||
})();
|
||||
match parsed {
|
||||
Ok((a, b)) => {
|
||||
start = a;
|
||||
end = b;
|
||||
partial = true;
|
||||
}
|
||||
Err(_) => {
|
||||
r.respond(
|
||||
Response::empty(416)
|
||||
.with_header(header("Content-Range", &format!("bytes */{length}"))),
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
let size = if length == 0 { 0 } else { end - start + 1 };
|
||||
file.seek(SeekFrom::Start(start))?;
|
||||
let mime = if inline {
|
||||
crate::manager_files::media_type(path).unwrap_or("application/octet-stream")
|
||||
} else {
|
||||
"application/octet-stream"
|
||||
};
|
||||
let filename = path.file_name().unwrap_or_default().to_string_lossy();
|
||||
let encoded = url::form_urlencoded::byte_serialize(filename.as_bytes()).collect::<String>();
|
||||
let mut headers = vec![
|
||||
header("Content-Type", mime),
|
||||
header("Accept-Ranges", "bytes"),
|
||||
header("X-Content-Type-Options", "nosniff"),
|
||||
header("Cache-Control", "no-store"),
|
||||
header("Content-Security-Policy", "default-src 'none'; sandbox"),
|
||||
header(
|
||||
"Content-Disposition",
|
||||
&format!(
|
||||
"{}; filename*=UTF-8''{encoded}",
|
||||
if inline { "inline" } else { "attachment" }
|
||||
),
|
||||
),
|
||||
];
|
||||
if partial {
|
||||
headers.push(header(
|
||||
"Content-Range",
|
||||
&format!("bytes {start}-{end}/{length}"),
|
||||
));
|
||||
}
|
||||
let response = Response::new(
|
||||
StatusCode(if partial { 206 } else { 200 }),
|
||||
headers,
|
||||
file.take(size),
|
||||
Some(size as usize),
|
||||
None,
|
||||
);
|
||||
r.respond(response)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn serve(
|
||||
socket: PathBuf,
|
||||
state: PathBuf,
|
||||
@@ -1464,8 +1648,16 @@ pub fn serve(
|
||||
jobs: crate::jobs::Jobs::default(),
|
||||
launcher_search: Mutex::default(),
|
||||
terminals: crate::terminal::Terminals::default(),
|
||||
files: Arc::default(),
|
||||
catalog_cache: Arc::default(),
|
||||
});
|
||||
let file_agent = agent.clone();
|
||||
std::thread::spawn(move || loop {
|
||||
if let Ok(home) = crate::steam::home() {
|
||||
let _ = file_agent.files.maintain(&home);
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs(3600));
|
||||
});
|
||||
let network_agent = agent.clone();
|
||||
std::thread::spawn(move || network_listener(network_agent, || true));
|
||||
let subscriptions_agent = agent.clone();
|
||||
@@ -1850,6 +2042,7 @@ mod tests {
|
||||
jobs: crate::jobs::Jobs::default(),
|
||||
launcher_search: Mutex::default(),
|
||||
terminals: crate::terminal::Terminals::default(),
|
||||
files: Arc::default(),
|
||||
catalog_cache: Arc::default(),
|
||||
})
|
||||
}
|
||||
@@ -2195,6 +2388,48 @@ mod tests {
|
||||
core_worker.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_streams_support_range_head_and_safe_download_headers() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let path = root.path().join("generated sample.mp4");
|
||||
fs::write(&path, b"0123456789").unwrap();
|
||||
let server = Server::http("127.0.0.1:0").unwrap();
|
||||
let origin = format!("http://{}", server.server_addr());
|
||||
let thread = std::thread::spawn(move || {
|
||||
for _ in 0..7 {
|
||||
serve_user_file(server.recv().unwrap(), &path, true).unwrap();
|
||||
}
|
||||
});
|
||||
for (range, expected) in [
|
||||
("bytes=2-5", "2345"),
|
||||
("bytes=-3", "789"),
|
||||
("bytes=8-", "89"),
|
||||
] {
|
||||
let reply = ureq::get(&origin).set("Range", range).call().unwrap();
|
||||
assert_eq!(reply.status(), 206);
|
||||
assert_eq!(reply.header("Accept-Ranges"), Some("bytes"));
|
||||
assert_eq!(reply.header("X-Content-Type-Options"), Some("nosniff"));
|
||||
assert_eq!(reply.into_string().unwrap(), expected);
|
||||
}
|
||||
for range in ["bytes=10-", "bytes=1-2,4-5"] {
|
||||
assert!(matches!(
|
||||
ureq::get(&origin).set("Range", range).call(),
|
||||
Err(ureq::Error::Status(416, _))
|
||||
));
|
||||
}
|
||||
let reply = ureq::get(&origin).call().unwrap();
|
||||
assert_eq!(reply.header("Content-Type"), Some("video/mp4"));
|
||||
assert!(reply
|
||||
.header("Content-Disposition")
|
||||
.unwrap()
|
||||
.contains("generated+sample.mp4"));
|
||||
assert_eq!(reply.into_string().unwrap(), "0123456789");
|
||||
let reply = ureq::head(&origin).call().unwrap();
|
||||
assert_eq!(reply.header("Content-Length"), Some("10"));
|
||||
assert!(reply.into_string().unwrap().is_empty());
|
||||
thread.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_panel_requires_login_and_same_origin_and_blocks_native_routes() {
|
||||
let password = "密码".repeat(70);
|
||||
@@ -2396,7 +2631,11 @@ mod tests {
|
||||
.set("Origin", "null")
|
||||
.send_json(json!({"method":"ui.events"}))
|
||||
.is_err());
|
||||
for route in ["/manager-api/terminal"] {
|
||||
for route in [
|
||||
"/manager-api/files",
|
||||
"/manager-api/terminal",
|
||||
"/manager-api/file-upload/id/0",
|
||||
] {
|
||||
for origin in ["null", "http://other.example", ""] {
|
||||
assert!(ureq::post(&format!("{}{route}", agent.origin))
|
||||
.set("Cookie", &format!("framely={}", agent.web_key))
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import React,{useEffect,useRef,useState} from 'react';
|
||||
import {IconFolder,IconFile,IconArrowUp,IconRefresh,IconUpload,IconDownload,IconStar,IconTrash,IconChevronRight,IconChevronDown} from '@tabler/icons-react';
|
||||
import {managerApi,ManagerError} from './manager-api';
|
||||
import {api} from './api';
|
||||
import {Select} from './localized-select';
|
||||
import {SwitchRow} from './switch';
|
||||
import {t} from './i18n';
|
||||
import './manager-tools.css';
|
||||
type Entry={name:string;path:string;directory:boolean;symlink:boolean;size:number;modified?:number;mode:number;uid:number;gid:number;linkTarget?:string};
|
||||
type Listing={path:string;parent?:string;entries:Entry[];total:number;roots:{name:string;path:string}[]};
|
||||
type Bookmark={name:string;path:string};
|
||||
type Job={id:string;phase:string;bytes?:number;totalBytes?:number;completed?:number;total?:number;visited?:number;matches?:number;error?:string;result?:any};
|
||||
type Dialog={title:string;kind:string;path?:string;name?:string;value?:string;entry?:Entry;files?:File[]};
|
||||
function bytes(n:number){return n<1024?`${n} B`:n<1024**2?`${(n/1024).toFixed(1)} KiB`:n<1024**3?`${(n/1024**2).toFixed(1)} MiB`:`${(n/1024**3).toFixed(1)} GiB`;}
|
||||
function FolderTree({name,path,onOpen,current,depth=0}:{name:string;path:string;onOpen:(path:string)=>void;current:string;depth?:number}){
|
||||
const[expanded,setExpanded]=useState(false),[children,setChildren]=useState<Entry[]>([]),[error,setError]=useState(''),[offset,setOffset]=useState(0),[more,setMore]=useState(false);
|
||||
async function expand(next=0){try{const v=await managerApi<Listing>('files',{operation:'list',path,offset:next});setChildren(previous=>next?[...previous,...v.entries.filter(e=>e.directory)]:v.entries.filter(e=>e.directory));setOffset(next+200);setMore(v.total>next+200);setExpanded(true);}catch(e){setError(String(e));}}
|
||||
return <div><div className="file-side-entry"><button className={current===path?'active':''} onClick={()=>onOpen(path)}><IconFolder size={18}/>{name}</button>{depth<20&&<button aria-label={t(expanded?'收起文件夹':'展开文件夹')} onClick={()=>expanded?setExpanded(false):void expand()}>{expanded?<IconChevronDown size={16}/>:<IconChevronRight size={16}/>}</button>}</div>{error&&expanded&&<small>{error}</small>}{expanded&&<div className="file-tree">{children.map(e=><FolderTree key={e.path} name={e.name} path={e.path} current={current} onOpen={onOpen} depth={depth+1}/>)}{more&&<button onClick={()=>void expand(offset)}>{t('加载更多')}</button>}</div>}</div>;
|
||||
}
|
||||
export function FileManager({active=true}:{active?:boolean}){
|
||||
const[data,setData]=useState<Listing>({path:'',entries:[],total:0,roots:[]}),[path,setPath]=useState(''),[address,setAddress]=useState(''),[query,setQuery]=useState(''),[hidden,setHidden]=useState(false),[sort,setSort]=useState('name'),[descending,setDescending]=useState(false),[offset,setOffset]=useState(0),[selected,setSelected]=useState<string[]>([]),[bookmarks,setBookmarks]=useState<Bookmark[]>([]),[error,setError]=useState(''),[busy,setBusy]=useState(false),[dialog,setDialog]=useState<Dialog|null>(null),[value,setValue]=useState(''),[policy,setPolicy]=useState('keep'),[permanent,setPermanent]=useState(false),[job,setJob]=useState<Job|null>(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<any[]|null>(null),[upload,setUpload]=useState<{name:string;received:number;total:number}|null>(null),[results,setResults]=useState<any[]|null>(null),[counter,setCounter]=useState(0),[context,setContext]=useState(false),[outcomes,setOutcomes]=useState<any[]>([]);
|
||||
const fileInput=useRef<HTMLInputElement>(null),directoryInput=useRef<HTMLInputElement>(null),sequence=useRef(0),cancelUpload=useRef(false),jobId=useRef('');
|
||||
useEffect(()=>{if(!modified)return;const guard=(event:BeforeUnloadEvent)=>{event.preventDefault();event.returnValue='';};window.addEventListener('beforeunload',guard);return()=>window.removeEventListener('beforeunload',guard);},[modified]);
|
||||
useEffect(()=>{if(!active)return;const back=(event:Event)=>{if(event instanceof KeyboardEvent&&event.key!=='Escape')return;if(dialog){setDialog(null);}else if(preview){setPreview(null);}else if(editor){if(modified)openDialog({title:t('未保存的修改'),kind:'editor.discard'});else setEditor(null);}};window.addEventListener('keydown',back);window.addEventListener('framely.back',back);return()=>{window.removeEventListener('keydown',back);window.removeEventListener('framely.back',back);};},[active,dialog,preview,editor,modified]);
|
||||
const refresh=()=>setCounter(n=>n+1);
|
||||
function navigate(next:string){setPath(next);setAddress(next);setSelected([]);setOffset(0);setResults(null);setQuery('');setTrash(null);setContext(false);}
|
||||
useEffect(()=>{let live=true;managerApi<Bookmark[]>('files',{operation:'preferences'}).then(v=>{if(live)setBookmarks(v);}).catch(e=>setError(String(e)));return()=>{live=false;};},[]);
|
||||
useEffect(()=>{const seq=++sequence.current;const timer=setTimeout(()=>{managerApi<Listing>('files',{operation:'list',path:path||undefined,query,hidden,sort,descending,offset}).then(v=>{if(seq!==sequence.current)return;setData(v);setAddress(v.path);if(!path)setPath(v.path);}).catch(e=>{if(seq===sequence.current)setError(String(e));});},120);return()=>clearTimeout(timer);},[path,query,hidden,sort,descending,offset,counter]);
|
||||
useEffect(()=>setSelected([]),[query,offset,sort,descending,hidden]);
|
||||
const shown=results??data.entries;
|
||||
async function saveBookmarks(next:Bookmark[]){try{await managerApi('files',{operation:'preferences',value:next});setBookmarks(next);}catch(e){setError(String(e));throw e;}}
|
||||
function openDialog(d:Dialog){setDialog(d);setValue(d.value??d.name??'');setPolicy('keep');setPermanent(false);setContext(false);}
|
||||
async function perform(p:any){setError('');setBusy(true);try{const result=await managerApi('files',p);if(result.job){jobId.current=result.job;for(;;){const v=await api<Job>('job.status',{job:result.job});setJob(v);if(v.phase==='done'){jobId.current='';if(v.result?.items){setOutcomes(v.result.items);const errors=v.result.items.filter((i:any)=>i.error);if(errors.length)setError(errors.map((i:any)=>`${i.path}: ${i.error}`).join('\n'));}return v.result;}if(v.phase==='failed'||v.phase==='cancelled'){jobId.current='';throw new Error(v.error??t('任务已取消'));}await new Promise(r=>setTimeout(r,200));}}return result;}catch(e){setError(String(e));throw e;}finally{setBusy(false);refresh();}}
|
||||
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 uploads(files:File[]){setBusy(true);cancelUpload.current=false;let id='';try{let total=files.reduce((n,f)=>n+f.size,0),received=0;for(const file of files){if(cancelUpload.current)break;const relative=file.webkitRelativePath||file.name;id=(await managerApi('files',{operation:'upload.start',directory:path,name:relative,size:file.size,conflict:policy})).id;setUpload({name:relative,received,total});for(let offset=0;offset<file.size;offset+=1024*1024){if(cancelUpload.current)throw new Error(t('任务已取消'));const chunk=file.slice(offset,offset+1024*1024);const r=await fetch(`/manager-api/file-upload/${id}/${offset}`,{method:'POST',body:chunk});const v=await r.json();if(!r.ok||v.error)throw new Error(v.error??String(r.status));received+=chunk.size;setUpload({name:relative,received,total});}if(cancelUpload.current)throw new Error(t('任务已取消'));await managerApi('files',{operation:'upload.finish',id,approve:policy==='overwrite'});id='';}}catch(e){setError(String(e));if(id)await managerApi('files',{operation:'upload.cancel',id}).catch(()=>{});}finally{setBusy(false);setUpload(null);refresh();}}
|
||||
async function submit(){if(!dialog)return;const d=dialog;if(['delete','paste','compress','extract'].includes(d.kind))setDialog(null);try{
|
||||
if(d.kind==='bookmark'){const next=[...bookmarks.filter(b=>b.path!==d.path),{name:value,path:d.path!}];await saveBookmarks(next);}
|
||||
else if(d.kind==='bookmark.rename'){await saveBookmarks(bookmarks.map(b=>b.path===d.path?{...b,name:value}:b));}
|
||||
else if(d.kind==='create'||d.kind==='folder'){await perform({operation:'create',directory:path,name:value,folder:d.kind==='folder'});}
|
||||
else if(d.kind==='rename'){await perform({operation:'rename',path:d.path,name:value});}
|
||||
else if(d.kind==='delete'){await perform({operation:'delete',paths:selected,permanent,approve:permanent});setSelected([]);}
|
||||
else if(d.kind==='paste'){await perform({operation:clipboard?.cut?'move':'copy',paths:clipboard?.paths,destination:path,conflict:policy,approve:policy==='overwrite'});if(clipboard?.cut)setClipboard(null);}
|
||||
else if(d.kind==='properties'){await perform({operation:'chmod',path:d.entry!.path,mode:parseInt(value,8)});}
|
||||
else if(d.kind==='compress'){const format=value.endsWith('.tar.gz')?'tar.gz':value.endsWith('.tar')?'tar':'zip';await perform({operation:'compress',paths:selected,path:path+'/'+value,format,conflict:policy,approve:policy==='overwrite'});}
|
||||
else if(d.kind==='extract'){await perform({operation:'extract',path:d.path,destination:value,conflict:policy,approve:policy==='overwrite'});}
|
||||
else if(d.kind==='trash.purge'){await perform({operation:'trash.purge',id:d.path,all:!d.path,approve:true});setTrash(await managerApi('files',{operation:'trash.list'}));}
|
||||
else if(d.kind==='trash.restore'){await perform({operation:'trash.restore',id:d.path,conflict:policy,approve:policy==='overwrite'});setTrash(await managerApi('files',{operation:'trash.list'}));}
|
||||
else if(d.kind==='upload'){setDialog(null);await uploads(d.files??[]);return;}
|
||||
else if(d.kind==='editor.discard'){setEditor(null);setModified(false);}
|
||||
else if(d.kind==='editor.overwrite'){const v=await perform({operation:'write',...editor,approve:true});setEditor(e=>e?{...e,...v}:null);setModified(false);}
|
||||
setDialog(null);
|
||||
}catch{}}
|
||||
async function saveText(){if(!editor)return;try{const v=await perform({operation:'write',...editor});setEditor(e=>e?{...e,...v}:null);setModified(false);}catch(e){if((e instanceof ManagerError?e.raw:String(e)).includes('File changed outside the editor'))openDialog({title:t('保存冲突'),kind:'editor.overwrite'});}}
|
||||
const picked=shown.find(e=>e.path===selected[0]);
|
||||
const actions=<><button disabled={!selected.length||busy} onClick={()=>{setClipboard({paths:selected,cut:false});setContext(false);}}>{t('复制')}</button><button disabled={!selected.length||busy} onClick={()=>{setClipboard({paths:selected,cut:true});setContext(false);}}>{t('剪切')}</button><button disabled={!clipboard||busy} onClick={()=>openDialog({title:t('粘贴文件'),kind:'paste'})}>{t('粘贴')}</button><button disabled={selected.length!==1||busy} onClick={()=>openDialog({title:t('重命名'),kind:'rename',path:selected[0],value:picked?.name})}>{t('重命名')}</button><button disabled={!selected.length||busy} onClick={()=>openDialog({title:t('删除文件'),kind:'delete'})}>{t('删除')}</button><button disabled={selected.length!==1||busy} onClick={()=>openDialog({title:t('文件属性'),kind:'properties',entry:picked,value:picked?.mode.toString(8)})}>{t('属性')}</button><button disabled={!selected.length||busy} onClick={()=>openDialog({title:t('创建压缩包'),kind:'compress',value:'archive.zip'})}>{t('压缩')}</button><button disabled={selected.length!==1||busy} onClick={()=>openDialog({title:t('解压文件'),kind:'extract',path:selected[0],value:path})}>{t('解压')}</button><button disabled={selected.length!==1||busy} onClick={()=>void open(picked!)}>{t('打开')}</button></>;
|
||||
return <section className="manager-tool"><div className="list-heading"><div><h1>{t('文件')}</h1><p>{t('浏览和管理 Frame 文件。')}</p></div><div className="file-actions"><button disabled={busy} onClick={()=>fileInput.current?.click()}><IconUpload size={20}/>{t('上传文件')}</button><button disabled={busy} onClick={()=>directoryInput.current?.click()}>{t('上传文件夹')}</button><button disabled={!selected.length||busy} onClick={()=>void getDownload()}><IconDownload size={20}/>{t('下载文件')}</button></div></div>
|
||||
<input ref={fileInput} type="file" multiple hidden onChange={e=>{if(e.target.files)openDialog({title:t('上传文件'),kind:'upload',files:Array.from(e.target.files)});e.target.value='';}}/><input ref={directoryInput} type="file" multiple hidden {...{'webkitdirectory':''} as any} onChange={e=>{if(e.target.files)openDialog({title:t('上传文件夹'),kind:'upload',files:Array.from(e.target.files)});e.target.value='';}}/>
|
||||
{error&&<p className="error" style={{whiteSpace:'pre-wrap'}}>{error}<button onClick={()=>setError('')}>×</button></p>}
|
||||
{upload&&<div className="file-progress"><span>{upload.name}</span><progress value={upload.received} max={upload.total}/><span>{bytes(upload.received)} / {bytes(upload.total)}</span><button onClick={()=>cancelUpload.current=true}>{t('取消')}</button></div>}
|
||||
{job&&<div className="file-progress"><span>{t(job.phase)}</span>{job.totalBytes?<progress value={job.bytes??0} max={job.totalBytes}/>:<progress/>}<span>{job.total?`${job.completed??0}/${job.total}`:job.visited?`${job.visited} · ${job.matches??0}`:job.bytes?bytes(job.bytes):''}</span>{!['done','failed','cancelled'].includes(job.phase)?<button onClick={()=>void api('job.cancel',{job:job.id}).catch(e=>setError(String(e)))}>{t('取消')}</button>:<button onClick={()=>setJob(null)}>×</button>}</div>}
|
||||
{outcomes.length>0&&<details className="file-outcomes"><summary>{t('逐项结果')} · {outcomes.length}</summary>{outcomes.map((item,index)=><p key={index}>{item.path} · {item.error??t(item.result?.skipped||item.skipped?'已跳过':'已完成')}</p>)}</details>}
|
||||
<div className="file-toolbar"><button aria-label={t('上级目录')} disabled={!data.parent} onClick={()=>navigate(data.parent!)}><IconArrowUp size={20}/></button><input aria-label={t('文件路径')} value={address} onChange={e=>setAddress(e.target.value)} onKeyDown={e=>{if(e.key==='Enter')navigate(address);}}/><button aria-label={t('刷新')} onClick={refresh}><IconRefresh size={20}/></button><button aria-label={t('添加到快速访问')} onClick={()=>openDialog({title:t('添加到快速访问'),kind:'bookmark',path,value:path.split('/').pop()||'/'})}><IconStar size={20}/></button></div>
|
||||
<div className="file-breadcrumbs"><button onClick={()=>navigate('/')}>/</button>{path.split('/').filter(Boolean).map((part,i,parts)=><button key={i} onClick={()=>navigate('/'+parts.slice(0,i+1).join('/'))}>{part} /</button>)}</div>
|
||||
<div className="file-layout"><aside className="file-sidebar"><h3>{t('快速访问')}</h3>{data.roots.map(root=><FolderTree key={root.path} name={t(root.name)} path={root.path} current={path} onOpen={navigate}/>)}{bookmarks.map((b,index)=><div className="file-side-entry" key={b.path}><button onClick={()=>navigate(b.path)}><IconStar size={18}/>{b.name}</button><button disabled={index===0} aria-label={t('上移快捷入口')} onClick={()=>{const next=[...bookmarks];[next[index-1],next[index]]=[next[index],next[index-1]];void saveBookmarks(next);}}>↑</button><button disabled={index===bookmarks.length-1} aria-label={t('下移快捷入口')} onClick={()=>{const next=[...bookmarks];[next[index+1],next[index]]=[next[index],next[index+1]];void saveBookmarks(next);}}>↓</button><button aria-label={t('重命名快捷入口')} onClick={()=>openDialog({title:t('重命名快捷入口'),kind:'bookmark.rename',path:b.path,value:b.name})}>⋯</button><button aria-label={t('移除快捷入口')} onClick={()=>void saveBookmarks(bookmarks.filter(v=>v.path!==b.path))}>×</button></div>)}<button onClick={()=>void managerApi('files',{operation:'trash.list'}).then(setTrash).catch(e=>setError(String(e)))}><IconTrash size={18}/>{t('回收站')}</button></aside>
|
||||
<div className="file-main">{trash?<><div className="file-toolbar"><h2>{t('回收站')}</h2><button onClick={()=>setTrash(null)}>{t('返回文件')}</button><button disabled={!trash.length||busy} onClick={()=>openDialog({title:t('清空回收站'),kind:'trash.purge'})}>{t('清空回收站')}</button></div><p>{t('回收站文件保留 30 天,到期自动删除。')}</p>{trash.map(item=><div className="settings-card" key={item.id}><b>{item.name}</b><p>{item.path}</p><p>{t('到期时间')}:{new Date(item.expires*1000).toLocaleString()}</p><div className="file-actions"><button onClick={()=>openDialog({title:t('恢复文件'),kind:'trash.restore',path:item.id})}>{t('恢复')}</button><button className="danger" onClick={()=>openDialog({title:t('永久删除'),kind:'trash.purge',path:item.id})}>{t('永久删除')}</button></div></div>)}</>:<><div className="file-toolbar"><input aria-label={t('搜索文件')} placeholder={t('搜索当前目录')} value={query} onChange={e=>{setQuery(e.target.value);setOffset(0);setResults(null);}}/><button disabled={!query||busy} onClick={()=>void perform({operation:'search',path,query}).then(v=>{setResults(v.entries);setSelected([]);}).catch(()=>{})}>{t('搜索子目录')}</button><Select label={t('排序')} value={sort} onChange={setSort} options={[{value:'name',label:t('名称')},{value:'size',label:t('大小')},{value:'modified',label:t('修改时间')}]}/><button onClick={()=>setDescending(v=>!v)}>{descending?'↓':'↑'}</button></div><SwitchRow label={t('显示隐藏文件')} checked={hidden} onChange={setHidden}/>
|
||||
<div className="file-toolbar"><button disabled={busy} onClick={()=>openDialog({title:t('新建文件夹'),kind:'folder'})}>{t('新建文件夹')}</button><button disabled={busy} onClick={()=>openDialog({title:t('新建文件'),kind:'create'})}>{t('新建文件')}</button><button disabled={!clipboard||busy} onClick={()=>openDialog({title:t('粘贴文件'),kind:'paste'})}>{t('粘贴')}</button><button onClick={()=>setContext(v=>!v)}>{t('操作')}</button><span>{selected.length} / {results?results.length:data.total}</span></div>{context&&<div className="file-actions settings-card">{actions}</div>}
|
||||
<table className="file-table"><thead><tr><th><input type="checkbox" aria-label={t('选择本页')} checked={shown.length>0&&shown.every(e=>selected.includes(e.path))} onChange={e=>setSelected(e.target.checked?shown.map(v=>v.path):[])}/></th><th>{t('名称')}</th><th className="file-size">{t('大小')}</th><th className="file-time">{t('修改时间')}</th></tr></thead><tbody>{shown.map(e=><tr key={e.path} className={selected.includes(e.path)?'selected':''} onContextMenu={ev=>{ev.preventDefault();setSelected([e.path]);setContext(true);}}><td><input type="checkbox" aria-label={e.name} checked={selected.includes(e.path)} onChange={ev=>setSelected(v=>ev.target.checked?[...v,e.path]:v.filter(p=>p!==e.path))}/></td><td><button className="file-name" onClick={()=>void open(e)}>{e.directory?<IconFolder size={20}/>:<IconFile size={20}/>} {e.name}{e.symlink?' ↗':''}</button></td><td className="file-size">{e.directory?'—':bytes(e.size)}</td><td className="file-time">{e.modified?new Date(e.modified*1000).toLocaleString():''}</td></tr>)}</tbody></table>{!shown.length&&<p className="empty">{t('没有文件')}</p>}{!results&&<div className="file-toolbar"><button disabled={offset===0} onClick={()=>setOffset(v=>Math.max(0,v-200))}>{t('上一页')}</button><span>{Math.floor(offset/200)+1} / {Math.max(1,Math.ceil(data.total/200))}</span><button disabled={offset+200>=data.total} onClick={()=>setOffset(v=>v+200)}>{t('下一页')}</button></div>}</>}
|
||||
</div></div>
|
||||
{active&&dialog&&<div className="modal" role="dialog" aria-modal="true"><h2>{dialog.title}</h2>{dialog.kind==='properties'&&dialog.entry&&<div className="file-details"><p>{dialog.entry.path}</p><p>{bytes(dialog.entry.size)} · UID {dialog.entry.uid} / GID {dialog.entry.gid}</p>{dialog.entry.linkTarget&&<p>{dialog.entry.linkTarget}</p>}</div>}{['create','folder','rename','bookmark','bookmark.rename','compress','extract','properties'].includes(dialog.kind)&&<label>{t(dialog.kind==='properties'?'权限(八进制)':dialog.kind==='extract'?'目标目录':'名称')}<input value={value} onChange={e=>setValue(e.target.value)}/></label>}
|
||||
{dialog.kind==='delete'&&<><p>{t('请选择删除方式。')}</p><Select label={t('删除方式')} value={permanent?'permanent':'trash'} onChange={v=>setPermanent(v==='permanent')} options={[{value:'trash',label:t('移至回收站(30 天)')},{value:'permanent',label:t('永久删除')}]}/>{permanent&&<p>{t('永久删除无法撤销。')}</p>}</>}
|
||||
{['paste','upload','compress','extract','trash.restore'].includes(dialog.kind)&&<><p>{t('遇到同名文件时')}</p><Select label={t('同名处理')} value={policy} onChange={setPolicy} options={[{value:'keep',label:t('保留两份')},{value:'skip',label:t('跳过')},...(dialog.kind==='trash.restore'?[]:[{value:'overwrite',label:t('覆盖')}])]}/>{policy==='overwrite'&&<p>{t('确认后会覆盖同名文件或整个同名文件夹。')}</p>}</>}{dialog.kind==='compress'&&<p>{t('支持 .zip、.tar 和 .tar.gz。')}</p>}{dialog.kind==='trash.purge'&&<p>{t('永久删除无法撤销。')}</p>}{dialog.kind==='editor.discard'&&<p>{t('关闭将丢弃未保存的修改。')}</p>}{dialog.kind==='editor.overwrite'&&<p>{t('文件已被外部修改。可重新加载,或确认覆盖。')}</p>}
|
||||
<footer>{dialog.kind==='editor.overwrite'&&<button onClick={()=>void perform({operation:'read',path:editor?.path}).then(v=>{setEditor(e=>e?{...e,...v}:null);setModified(false);setDialog(null);}).catch(()=>{})}>{t('重新加载')}</button>}<button disabled={busy} onClick={()=>setDialog(null)}>{t('取消')}</button><button disabled={busy} className={permanent||dialog.kind==='trash.purge'?'danger':'primary'} onClick={()=>void submit()}>{t('确认')}</button></footer></div>}
|
||||
{active&&preview&&<div className="modal file-preview" role="dialog" aria-modal="true"><h2>{preview.entry.name}</h2>{preview.video?<video src={preview.url} controls playsInline onError={()=>setError(t('浏览器无法播放此格式,请下载后打开。'))}/>:<img src={preview.url} alt={preview.entry.name} onError={()=>setError(t('浏览器无法预览此格式,请下载后打开。'))}/>}<footer><button onClick={()=>void getDownload([preview.entry.path])}>{t('下载文件')}</button><button onClick={()=>setPreview(null)}>{t('关闭')}</button></footer></div>}
|
||||
{active&&editor&&!dialog&&<div className="modal" role="dialog" aria-modal="true"><h2>{editor.path.split('/').pop()}{modified?' *':''}</h2><textarea aria-label={t('文本内容')} className="file-editor" value={editor.text} onChange={e=>{setEditor(v=>v?{...v,text:e.target.value}:null);setModified(true);}}/><footer><button disabled={busy} onClick={()=>void saveText()}>{t('保存')}</button><button onClick={()=>{if(!modified)setEditor(null);else openDialog({title:t('未保存的修改'),kind:'editor.discard'});}}>{t('关闭')}</button></footer></div>}
|
||||
</section>;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import {FileManager} from './file-manager';
|
||||
import {TerminalManager} from './terminal';
|
||||
import {FrameFilePicker} from './file-picker';
|
||||
import {ManagerSwitcher,managerTab,sectionFor,type ManagerSection} from './manager-switcher';
|
||||
@@ -34,6 +35,8 @@ function App(){
|
||||
useEffect(()=>{const ticket=install?.ticket;return()=>{if(ticket)void api('job.cancel',{job:ticket}).catch(()=>{});};},[install?.ticket]);
|
||||
const localePacks=useRef<LanguagePack[]|null>(null);const localePreference=useRef<string|null>(null);
|
||||
const readError=useReadInbox(!managerMode&&tab==='notifications'&&!page&&!!status.agreement?.accepted,status.inbox??[],refresh);
|
||||
const [filesVisited,setFilesVisited]=useState(tab==='files');
|
||||
useEffect(()=>{if(tab==='files')setFilesVisited(true);},[tab]);
|
||||
const apkPanel=managerMode&&tab.startsWith('apk');
|
||||
const managerSection=sectionFor(tab);
|
||||
useEffect(()=>{if(managerMode){try{localStorage.setItem('framely.manager.'+sectionFor(tab),tab);if(!location.hash.startsWith('#plugin:'))history.replaceState(null,'',location.pathname+location.search+'#'+tab);}catch{}}},[tab]);
|
||||
@@ -101,6 +104,7 @@ function App(){
|
||||
{listedPlugins.length===0&&<div className="empty-state"><span className="empty-symbol"><Icon name={tab==='favorites'?'star':'plugins'} size={46}/></span><h2>{query?t("未找到插件"):tab==='favorites'?t("没有收藏的插件"):onlyDisabledInstalled?t("没有已启用的插件"):t("还没有安装插件")}</h2><p>{query?t("换一个关键词再试。"):tab==='favorites'?t("点击插件旁的星标,将它加入常用。"):onlyDisabledInstalled?t("打开插件管理以启用插件。"):t("打开插件管理以安装插件。")}</p><button className="primary" onClick={()=>query?setQuery(''):tab==='favorites'?setTab('installed'):managerMode?setImporting(true):void api('host.manager.open')}>{query?t("清除搜索"):tab==='favorites'?t("查看已安装"):managerMode?t("安装插件"):t("打开插件管理")}</button></div>}
|
||||
</>}
|
||||
{tab==='notifications'&&!managerMode&&<><NotificationCenter inbox items={status.inbox??[]} plugins={status.database.plugins} safeMode={status.database.safeMode} refresh={refresh} controls={<PopupPermission config={status.database.notificationSettings??defaultNotificationConfig} refresh={refresh}/>}/>{readError&&<p className="error" role="alert">{readError}</p>}</>}
|
||||
{managerMode&&(filesVisited||tab==='files')&&<div hidden={tab!=='files'}><FileManager active={tab==='files'}/></div>}
|
||||
{tab==='terminal'&&managerMode&&<TerminalManager/>}
|
||||
{apkPanel&&<ApkManager view={tab==='apk-containers'?'containers':tab==='apk-cleanup'?'cleanup':'apps'} onViewChange={view=>setTab(view==='apps'?'apk':'apk-'+view)}/>}
|
||||
{tab==='launcher-settings'&&managerMode&&<><div className="list-heading"><div><h1>{t('启动台')}</h1><p>{t('启动方式、菜单与分类。')}</p></div></div><LauncherSettings config={status.database.launcher} refresh={refresh}/></>}
|
||||
|
||||
Reference in new issue
Block a user