mirror of
https://github.com/SteamFramelyHomebrew/framely.git
synced 2026-10-18 19:01:27 +02:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8e5ab3d77 | ||
|
|
dc1bd07145 | ||
|
|
4005216a42 | ||
|
|
425983a5fb | ||
|
|
cb67ac5607 | ||
|
|
6cb70b8098 | ||
|
|
79db4a80fc | ||
|
|
6a0e302fd6 | ||
|
|
cbf285d957 |
No files matched your search
Generated
+1
-1
@@ -425,7 +425,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "framely"
|
||||
version = "0.5.1-preview.4"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "framely"
|
||||
version = "0.5.1-preview.4"
|
||||
version = "0.6.0"
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
|
||||
@@ -7,9 +7,17 @@
|
||||
|
||||
[简体中文](README.zh-CN.md)
|
||||
|
||||
> Framely is still under rapid development. Preview releases may change frequently; features and interactions are still being refined.
|
||||
> Framely is still under rapid development. Features and interfaces may change between preview releases. Android app and game compatibility depends on Lepton and the app itself.
|
||||
|
||||
A React plugin manager for Steam Frame, with a Rust core service, a separate CEF/OpenVR host, a network management panel, and a desktop installer. Manage plugins, sources, windows, notifications, and updates.
|
||||
Framely provides a unified spatial launcher for Steam Frame and a management panel available both inside the headset and in a PC browser.
|
||||
|
||||
- **Unified launcher**: launch Android apps in Lepton, Linux desktop applications, local Steam games, and games available through Steam Remote Play.
|
||||
- **APK sideloading and management**: install, update, and uninstall APKs, manage application containers and data, and provide controller input mapping and rumble feedback for Android games that support controllers.
|
||||
- **Plugin installation and management**: install and manage Framely plugins, browse the plugin library, and extend Frame with additional features.
|
||||
- **Remote management from your PC**: access Frame's management panel in a PC browser to manage files, APKs, and plugins. Upload, download, and edit files, and preview common image and video formats.
|
||||
- **Web terminal**: use an interactive terminal running on Frame, execute shell commands, and connect to other devices with `ssh`. The web terminal does not require an SSH connection to Frame.
|
||||
|
||||
Framely uses a Rust core service and a separate CEF/OpenVR host, with a desktop installer for setup. It also manages plugin sources, windows, notifications, and updates.
|
||||
|
||||

|
||||
|
||||
|
||||
+10
-2
@@ -7,9 +7,17 @@
|
||||
|
||||
[English](README.md)
|
||||
|
||||
> Framely 目前仍在快速迭代中,Preview 版本可能频繁更新,功能与交互仍在持续完善。
|
||||
> Framely 目前仍在快速迭代中,功能和界面可能随预览版本调整。安卓应用与游戏的兼容性取决于 Lepton 和应用本身。
|
||||
|
||||
Steam Frame 的 React 插件管理器:Rust 核心服务、独立 CEF/OpenVR 宿主、网络管理面板和电脑端安装器。可管理插件、来源、窗口、通知和更新。
|
||||
Framely 为 Steam Frame 提供统一的空间启动台,以及可在头显内和 PC 浏览器中使用的管理面板。
|
||||
|
||||
- **统一启动入口**:启动 Lepton 中的安卓应用、Linux 桌面程序、Steam 本地游戏和 Steam 远程畅玩游戏。
|
||||
- **APK 侧载与管理**:安装、更新和卸载 APK,管理应用容器与数据;为支持手柄的安卓游戏提供手柄输入映射和振动反馈。
|
||||
- **插件安装与管理**:安装和管理 Framely 插件,通过插件库发现并扩展功能。
|
||||
- **PC 网页远程管理**:在 PC 浏览器中访问 Frame 的管理面板,管理设备文件、APK 和插件,支持文件上传、下载、编辑及常见图片和视频预览。
|
||||
- **网页终端**:通过管理面板操作 Frame 的交互式终端,支持 Shell 命令和使用 `ssh` 连接其他设备,无需先通过 SSH 连接 Frame。
|
||||
|
||||
Framely 使用 Rust 核心服务和独立的 CEF/OpenVR 宿主,并提供电脑端安装器,也可管理插件来源、窗口、通知和更新。
|
||||
|
||||

|
||||
|
||||
|
||||
+6
-6
@@ -38,8 +38,8 @@ Deleted `/home` data cannot be recovered. OS updates may require manual interven
|
||||
|
||||
Device and installer share the repository with independent versions/tags/workflows:
|
||||
|
||||
- Device: root Cargo version, `v<version>` (currently `v0.4.2-preview.12`), `.github/workflows/release.yml`, Linux ARM64 and pinned CEF.
|
||||
- Installer: `installer/Cargo.toml`, `installer-v<version>` (currently `installer-v0.4.1-preview.8`), `.github/workflows/installer-release.yml`, Linux x64/ARM64, Windows x64, macOS Intel/Apple Silicon, pinned GPUI Kit.
|
||||
- Device: root Cargo version, `v<version>` (currently `v0.6.0`), `.github/workflows/release.yml`, Linux ARM64 and pinned CEF.
|
||||
- Installer: `installer/Cargo.toml`, `installer-v<version>` (currently `installer-v0.4.1-preview.10`), `.github/workflows/installer-release.yml`, Linux x64/ARM64, Windows x64, macOS Intel/Apple Silicon, pinned GPUI Kit.
|
||||
|
||||
Versions need not match or ship together. Update the relevant Cargo.lock with version changes. Installer package/macOS versions use the installer version.
|
||||
|
||||
@@ -47,11 +47,11 @@ Both workflows cache dependencies/builds by product/platform/toolchain/configura
|
||||
|
||||
```bash
|
||||
# After committing and pushing the intended source:
|
||||
git tag v0.4.2-preview.12
|
||||
git push origin v0.4.2-preview.12
|
||||
git tag v0.6.0
|
||||
git push origin v0.6.0
|
||||
# Independent installer release:
|
||||
git tag installer-v0.4.1-preview.8
|
||||
git push origin installer-v0.4.1-preview.8
|
||||
git tag installer-v0.4.1-preview.10
|
||||
git push origin installer-v0.4.1-preview.10
|
||||
```
|
||||
|
||||
Prerelease suffixes create GitHub Prereleases without Latest. Installer releases are never Latest. Only stable device releases become Latest, keeping default installer/update URLs on the device product. The desktop installer filters device archive names and does not mistake ARM64 installer packages for device runtimes. Download desktop installers from their explicit installer tags.
|
||||
|
||||
@@ -34,7 +34,7 @@ Use the address bar, breadcrumbs, parent button or expandable folder tree. Quick
|
||||
|
||||
Drag the divider between the sidebar and file list to resize it; the width is remembered in this browser and limited automatically on narrow windows. Quick access stays in a fixed-height upper section. The folder tree fills the remaining sidebar height; both lists scroll independently, with their headings kept visible. Opening a folder in the file list or address bar expands the tree to its location and highlights it.
|
||||
|
||||
The file list provides hidden-file visibility, sorting, checkboxes for multiple selection, current-folder filtering and cancellable recursive filename search. Recursive search does not follow directory links. Lists are paged in groups of 200; recursive results are limited to 5,000 matches and 500,000 visited entries. Media contents are never scanned to create thumbnails.
|
||||
The file list provides hidden-file visibility, sorting, checkboxes for multiple selection, current-folder filtering and cancellable recursive filename search. Recursive search does not follow directory links. Lists are paged in groups of 200; recursive results are limited to 5,000 matches and 500,000 visited entries. Media contents are not scanned in the background.
|
||||
|
||||
**Actions** provides copy, cut, paste, rename, delete, properties, permission editing, compression, extraction and opening files. Right-click a file or folder to open a menu at the pointer; right-clicking a selected item preserves the multi-selection. Right-click the empty list area for new file/folder, paste, upload and refresh actions. Escape or clicking elsewhere closes the menu. Symbolic links have an arrow marker and their target in Properties. Copy preserves links; deleting a link does not delete its target. Special filesystem objects cannot be copied or archived.
|
||||
|
||||
@@ -62,8 +62,14 @@ 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.
|
||||
|
||||
The viewer controls are grouped in a floating capsule centered at the bottom, with the filename below. Clicking blank space closes the viewer; clicking media or controls and dragging to pan do not.
|
||||
@@ -48,8 +48,8 @@ CEF 按内容哈希保存在 `/home/.framely/cef/<运行库标识>`,更新时
|
||||
|
||||
Framely 和桌面安装器保留在同一仓库,使用独立的版本号、标签和发布工作流:
|
||||
|
||||
- Framely:版本由根 `Cargo.toml` 定义,标签为 `v<版本>`(当前 `v0.4.2-preview.12`);`.github/workflows/release.yml` 只构建 Frame Linux ARM64 发行,使用锁定的 CEF 并执行自动检查。
|
||||
- 安装器:版本由 `installer/Cargo.toml` 定义,标签为 `installer-v<版本>`(当前 `installer-v0.4.1-preview.8`);`.github/workflows/installer-release.yml` 只构建 Linux x64/ARM64、Windows x64、macOS x64/ARM64 的原生安装器,使用锁定的 GPUI Kit。
|
||||
- Framely:版本由根 `Cargo.toml` 定义,标签为 `v<版本>`(当前 `v0.6.0`);`.github/workflows/release.yml` 只构建 Frame Linux ARM64 发行,使用锁定的 CEF 并执行自动检查。
|
||||
- 安装器:版本由 `installer/Cargo.toml` 定义,标签为 `installer-v<版本>`(当前 `installer-v0.4.1-preview.10`);`.github/workflows/installer-release.yml` 只构建 Linux x64/ARM64、Windows x64、macOS x64/ARM64 的原生安装器,使用锁定的 GPUI Kit。
|
||||
|
||||
两个版本不需要相同,也不需要同时发布。修改各自版本时同步对应的 `Cargo.lock`;安装器包名及 macOS 应用版本使用安装器版本。
|
||||
|
||||
@@ -59,12 +59,12 @@ Framely 和桌面安装器保留在同一仓库,使用独立的版本号、标
|
||||
|
||||
```bash
|
||||
# 提交并推送代码后,选择需要发布的产品
|
||||
git tag v0.4.2-preview.12
|
||||
git push origin v0.4.2-preview.12
|
||||
git tag v0.6.0
|
||||
git push origin v0.6.0
|
||||
|
||||
# 独立发布安装器,不触发 Framely 构建
|
||||
git tag installer-v0.4.1-preview.8
|
||||
git push origin installer-v0.4.1-preview.8
|
||||
git tag installer-v0.4.1-preview.10
|
||||
git push origin installer-v0.4.1-preview.10
|
||||
```
|
||||
|
||||
带 `-preview.N` 等预发布后缀的标签会发布为 GitHub Prerelease,不设置为 Latest。安装器 Release 始终设置为非 Latest;只有 Framely 正式 Release 设置为 Latest。因此设备安装入口和默认更新清单的 `releases/latest/download/...` 继续指向 Framely 本体。安装器读取发行列表时按本体包名筛选,不把 Linux ARM64 安装器误当成设备发行包。安装器下载应使用具体的 `installer-v<版本>` Release。
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
|
||||
通过地址栏、面包屑、上级按钮或可展开文件夹树导航。快速访问包含主目录、下载、挂载存储、文件系统根目录、回收站及自定义文件夹。星标按钮添加当前目录,旁边按钮可改名、排序、移除;暂不可用的目录保留记录,打开时显示访问错误。
|
||||
|
||||
文件列表支持隐藏文件、排序、多选、当前目录过滤及可取消的递归文件名搜索。递归搜索不跟随目录链接;列表每页 200 项,递归搜索最多返回 5,000 个匹配项、访问 500,000 项。不扫描媒体内容生成缩略图。
|
||||
文件列表支持隐藏文件、排序、多选、当前目录过滤及可取消的递归文件名搜索。递归搜索不跟随目录链接;列表每页 200 项,递归搜索最多返回 5,000 个匹配项、访问 500,000 项。不在后台扫描媒体内容。
|
||||
|
||||
“操作”提供复制、剪切、粘贴、重命名、删除、属性、权限修改、压缩、解压及打开。右键文件会在指针处打开操作菜单。符号链接显示箭头,属性显示目标;复制保留链接,删除链接不会删除目标。特殊文件不能复制或压缩。
|
||||
|
||||
@@ -64,8 +64,14 @@
|
||||
|
||||
## 访问与隐私
|
||||
|
||||
终端、文件与传输使用现有管理面板登录和端口;修改接口及终端 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 文本仍在编辑器中打开;二进制文件、不支持的编码及更大的文件点击后直接下载。
|
||||
|
||||
查看器操作集中在底部居中的悬浮胶囊条中,文件名在下方居中显示。点击空白区域关闭;点击媒体、操作按钮或拖动画面不会误关闭。
|
||||
+47
-11
@@ -43,6 +43,9 @@ pub fn lifecycle_started() {
|
||||
lifecycle::start();
|
||||
}
|
||||
static MUTATION: Mutex<()> = Mutex::new(());
|
||||
// Integration tests must serialize all callers of the process-wide APK mutation lock.
|
||||
#[cfg(test)]
|
||||
pub(crate) static TEST_SERIAL: Mutex<()> = Mutex::new(());
|
||||
#[derive(Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Record {
|
||||
@@ -2461,6 +2464,14 @@ pub fn operate(
|
||||
}
|
||||
result
|
||||
}
|
||||
// Explicitly release flock: concurrent forked children can briefly inherit the
|
||||
// descriptor before exec, so closing this process's descriptor alone is insufficient.
|
||||
struct OperationLock(fs::File);
|
||||
impl Drop for OperationLock {
|
||||
fn drop(&mut self) {
|
||||
unsafe { libc::flock(self.0.as_raw_fd(), libc::LOCK_UN) };
|
||||
}
|
||||
}
|
||||
fn operate_internal(
|
||||
home: &Path,
|
||||
kind: &str,
|
||||
@@ -2474,14 +2485,16 @@ fn operate_internal(
|
||||
.map_err(|_| anyhow::anyhow!("Another APK operation is running"))?;
|
||||
cancel.check()?;
|
||||
let r = init(home)?;
|
||||
let lock = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.mode(0o600)
|
||||
.open(r.join("operation.lock"))?;
|
||||
let lock = OperationLock(
|
||||
fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.mode(0o600)
|
||||
.open(r.join("operation.lock"))?,
|
||||
);
|
||||
ensure!(
|
||||
unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0,
|
||||
unsafe { libc::flock(lock.0.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0,
|
||||
"Another APK operation is running"
|
||||
);
|
||||
let mut db = load(home)?;
|
||||
@@ -3362,7 +3375,31 @@ pub fn launch_app_with_progress(
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn operation_lock_releases_while_an_inherited_descriptor_remains_open() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let path = t.path().join("operation.lock");
|
||||
let lock = OperationLock(fs::File::create(&path).unwrap());
|
||||
assert_eq!(
|
||||
unsafe { libc::flock(lock.0.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
|
||||
0
|
||||
);
|
||||
// dup shares the open-file description, just as a descriptor inherited by fork does.
|
||||
let inherited = lock.0.try_clone().unwrap();
|
||||
let observer = fs::File::open(path).unwrap();
|
||||
assert_ne!(
|
||||
unsafe { libc::flock(observer.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
|
||||
0
|
||||
);
|
||||
drop(lock);
|
||||
assert_eq!(
|
||||
unsafe { libc::flock(observer.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
|
||||
0
|
||||
);
|
||||
drop(inherited);
|
||||
}
|
||||
#[test]
|
||||
fn lifecycle_settings_migrate_persist_and_reject_invalid_values() {
|
||||
let _guard = TEST_SERIAL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let home = t.path();
|
||||
assert!(!load(home).unwrap().auto_stop_container);
|
||||
@@ -3483,7 +3520,7 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn gamepad_setting_defaults_off_and_round_trips() {
|
||||
let _guard = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _guard = TEST_SERIAL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
assert!(!load(home).unwrap().gamepad_enabled);
|
||||
@@ -3868,7 +3905,7 @@ setup_podman_mounts
|
||||
}
|
||||
#[test]
|
||||
fn local_apk_review_snapshots_source_and_rejects_invalid_selections() {
|
||||
let _serial = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _serial = TEST_SERIAL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let source = home.path().join("selected.APK");
|
||||
crate::apk_metadata::fixture(&source, true, 7);
|
||||
@@ -3933,7 +3970,6 @@ setup_podman_mounts
|
||||
.join("com.example.app.framely-preserve-token.legacy-link")
|
||||
.is_symlink());
|
||||
}
|
||||
static SERIAL: Mutex<()> = Mutex::new(());
|
||||
struct Fixture {
|
||||
dir: tempfile::TempDir,
|
||||
home: PathBuf,
|
||||
@@ -3947,7 +3983,7 @@ setup_podman_mounts
|
||||
}
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let guard = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let guard = TEST_SERIAL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
steam_ui::TEST_RESULT.with(|p| *p.borrow_mut() = Some(json!(0x92345678u32)));
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path().join("home");
|
||||
|
||||
@@ -715,6 +715,9 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn inspect_apk_with_cesu8_resources() {
|
||||
let _guard = crate::apk::TEST_SERIAL
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
use std::io::Write;
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let p = d.path().join("emoji.apk");
|
||||
|
||||
+231
-2
@@ -68,6 +68,73 @@ fn save_json(path: &Path, v: &Value) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ViewPreferences {
|
||||
#[serde(default = "default_file_view")]
|
||||
view: String,
|
||||
#[serde(default)]
|
||||
folders: BTreeMap<String, FolderSort>,
|
||||
}
|
||||
fn default_file_view() -> String {
|
||||
"list".into()
|
||||
}
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct FolderSort {
|
||||
sort: String,
|
||||
descending: bool,
|
||||
}
|
||||
fn view_preferences(home: &Path, p: &Value) -> Result<Value> {
|
||||
let file = state(home)?.join("view-preferences.json");
|
||||
let mut preferences: ViewPreferences = if file.exists() {
|
||||
serde_json::from_slice(&fs::read(&file)?)?
|
||||
} else {
|
||||
ViewPreferences {
|
||||
view: default_file_view(),
|
||||
..Default::default()
|
||||
}
|
||||
};
|
||||
let mut changed = false;
|
||||
if let Some(view) = p.get("view") {
|
||||
let view = view.as_str().context("Invalid file view")?;
|
||||
ensure!(
|
||||
matches!(
|
||||
view,
|
||||
"list" | "icons-small" | "icons-medium" | "icons-large"
|
||||
),
|
||||
"Invalid file view"
|
||||
);
|
||||
preferences.view = view.into();
|
||||
changed = true;
|
||||
}
|
||||
if p.get("path").is_some() {
|
||||
let dir = PathBuf::from(strparam(p, "path")?);
|
||||
ensure!(dir.is_absolute(), "Use an absolute file path");
|
||||
let dir = dir.canonicalize()?;
|
||||
ensure!(dir.is_dir(), "Select a directory");
|
||||
let sort = strparam(p, "sort")?;
|
||||
ensure!(
|
||||
matches!(sort, "name" | "size" | "modified"),
|
||||
"Invalid file sort"
|
||||
);
|
||||
let descending = p["descending"]
|
||||
.as_bool()
|
||||
.context("Invalid file sort direction")?;
|
||||
preferences.folders.insert(
|
||||
dir.to_str().context("Invalid directory path")?.into(),
|
||||
FolderSort {
|
||||
sort: sort.into(),
|
||||
descending,
|
||||
},
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
let value = serde_json::to_value(preferences)?;
|
||||
if changed {
|
||||
save_json(&file, &value)?;
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct Files {
|
||||
preferences: Mutex<()>,
|
||||
@@ -110,8 +177,28 @@ fn entry(p: &Path) -> Result<Value> {
|
||||
} else {
|
||||
m.is_dir()
|
||||
};
|
||||
// Revision uses target metadata (including nanoseconds), never media bytes.
|
||||
let media_revision = if !dir && media_type(p).is_some() {
|
||||
let target = if link {
|
||||
fs::metadata(p).ok()
|
||||
} else {
|
||||
Some(m.clone())
|
||||
};
|
||||
target.filter(|m| m.is_file()).map(|m| {
|
||||
format!(
|
||||
"{}:{}:{}:{}:{}",
|
||||
m.dev(),
|
||||
m.ino(),
|
||||
m.len(),
|
||||
m.mtime(),
|
||||
m.mtime_nsec()
|
||||
)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(
|
||||
json!({"name":p.file_name().unwrap_or_default().to_string_lossy(),"path":p,"directory":dir,"symlink":link,"size":m.len(),"modified":m.modified().ok().and_then(|t|t.duration_since(UNIX_EPOCH).ok()).map(|t|t.as_secs()),"mode":m.mode()&0o7777,"uid":m.uid(),"gid":m.gid(),"linkTarget":if link{fs::read_link(p).ok()}else{None}}),
|
||||
json!({"name":p.file_name().unwrap_or_default().to_string_lossy(),"path":p,"directory":dir,"symlink":link,"size":m.len(),"mediaRevision":media_revision,"modified":m.modified().ok().and_then(|t|t.duration_since(UNIX_EPOCH).ok()).map(|t|t.as_secs()),"mode":m.mode()&0o7777,"uid":m.uid(),"gid":m.gid(),"linkTarget":if link{fs::read_link(p).ok()}else{None}}),
|
||||
)
|
||||
}
|
||||
fn list(p: &Value, home: &Path) -> Result<Value> {
|
||||
@@ -453,6 +540,10 @@ impl Files {
|
||||
let operation = p["operation"].as_str().unwrap_or("list");
|
||||
match operation {
|
||||
"list" => list(p, home),"properties" => entry(&path(p,"path")?),
|
||||
"view.preferences" => {
|
||||
let _guard = self.preferences.lock().unwrap();
|
||||
view_preferences(home, p)
|
||||
},
|
||||
"preferences" => {
|
||||
let _guard = self.preferences.lock().unwrap();
|
||||
let file = state(home)?.join("preferences.json");
|
||||
@@ -561,9 +652,15 @@ impl Files {
|
||||
path:file, inline, owned:false, expires:now()+3600
|
||||
});
|
||||
Ok(json!({
|
||||
"url":format!("/manager-api/file-content/{id}")
|
||||
"url":format!("/manager-api/file-content/{id}"),"token":if inline{Some(id)}else{None}
|
||||
}))
|
||||
},
|
||||
"preview.release" => {
|
||||
let id = strparam(p,"token")?;
|
||||
let mut downloads = self.downloads.lock().unwrap();
|
||||
if downloads.get(id).is_some_and(|d| d.inline && !d.owned) { downloads.remove(id); }
|
||||
Ok(json!(true))
|
||||
},
|
||||
"upload.start" => {
|
||||
let parent = PathBuf::from(strparam(p,"directory")?).canonicalize()?;
|
||||
let relative = Path::new(strparam(p,"name")?);
|
||||
@@ -1110,6 +1207,70 @@ fn target_resolution(path: &Path, p: &Value) -> Result<Option<PathBuf>> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn media_revisions_track_replacements_and_symlink_targets() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let image = temp.path().join("sample.png");
|
||||
fs::write(&image, b"generated").unwrap();
|
||||
let first = entry(&image).unwrap()["mediaRevision"].clone();
|
||||
let replacement = temp.path().join("replacement.png");
|
||||
fs::write(&replacement, b"different").unwrap();
|
||||
fs::rename(&replacement, &image).unwrap();
|
||||
let second = entry(&image).unwrap()["mediaRevision"].clone();
|
||||
assert_ne!(first, second);
|
||||
let link = temp.path().join("link.png");
|
||||
symlink(&image, &link).unwrap();
|
||||
assert_eq!(entry(&link).unwrap()["mediaRevision"], second);
|
||||
fs::write(temp.path().join("other.png"), b"different").unwrap();
|
||||
fs::remove_file(&link).unwrap();
|
||||
symlink(temp.path().join("other.png"), &link).unwrap();
|
||||
assert_ne!(entry(&link).unwrap()["mediaRevision"], second);
|
||||
fs::write(temp.path().join("notes.txt"), b"generated").unwrap();
|
||||
assert!(entry(&temp.path().join("notes.txt")).unwrap()["mediaRevision"].is_null());
|
||||
}
|
||||
#[test]
|
||||
fn thumbnail_grants_can_be_released_without_revoking_downloads() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let image = temp.path().join("sample.png");
|
||||
fs::write(&image, b"generated").unwrap();
|
||||
let files = Files::default();
|
||||
let preview = files
|
||||
.api(temp.path(), &json!({"operation":"preview","path":image}))
|
||||
.unwrap();
|
||||
let token = preview["token"].as_str().unwrap();
|
||||
assert!(files.content(token).unwrap().1);
|
||||
files
|
||||
.api(
|
||||
temp.path(),
|
||||
&json!({"operation":"preview.release","token":token}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(files.content(token).is_err());
|
||||
let download = files
|
||||
.api(temp.path(), &json!({"operation":"download","path":image}))
|
||||
.unwrap();
|
||||
let token = download["url"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.unwrap();
|
||||
files
|
||||
.api(
|
||||
temp.path(),
|
||||
&json!({"operation":"preview.release","token":token}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!files.content(token).unwrap().1);
|
||||
let unsafe_file = temp.path().join("active.svg");
|
||||
fs::write(&unsafe_file, b"generated").unwrap();
|
||||
assert!(files
|
||||
.api(
|
||||
temp.path(),
|
||||
&json!({"operation":"preview","path":unsafe_file})
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
#[test]
|
||||
fn centered_paths_and_link_safety() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path();
|
||||
@@ -1522,6 +1683,74 @@ mod operation_tests {
|
||||
assert_eq!(fs::read_dir(dest).unwrap().count(), 0);
|
||||
}
|
||||
#[test]
|
||||
fn folder_sort_and_global_view_preferences_persist_independently() {
|
||||
if unsafe { libc::geteuid() } == 0 {
|
||||
return;
|
||||
}
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let a = home.path().join("a");
|
||||
let b = home.path().join("b");
|
||||
fs::create_dir(&a).unwrap();
|
||||
fs::create_dir(&b).unwrap();
|
||||
let manager = Files::default();
|
||||
let defaults = manager
|
||||
.api(home.path(), &json!({"operation":"view.preferences"}))
|
||||
.unwrap();
|
||||
assert_eq!(defaults, json!({"view":"list","folders":{}}));
|
||||
manager
|
||||
.api(
|
||||
home.path(),
|
||||
&json!({"operation":"view.preferences","path":a,"sort":"size","descending":true}),
|
||||
)
|
||||
.unwrap();
|
||||
manager.api(home.path(), &json!({"operation":"view.preferences","path":b,"sort":"modified","descending":false})).unwrap();
|
||||
manager
|
||||
.api(
|
||||
home.path(),
|
||||
&json!({"operation":"view.preferences","view":"icons-large"}),
|
||||
)
|
||||
.unwrap();
|
||||
// Canonical paths avoid separate preferences for aliases/trailing slashes.
|
||||
let alias = home.path().join("alias");
|
||||
symlink(&a, &alias).unwrap();
|
||||
manager.api(home.path(), &json!({"operation":"view.preferences","path":alias,"sort":"name","descending":false})).unwrap();
|
||||
let reloaded = Files::default()
|
||||
.api(home.path(), &json!({"operation":"view.preferences"}))
|
||||
.unwrap();
|
||||
assert_eq!(reloaded["view"], "icons-large");
|
||||
assert_eq!(reloaded["folders"].as_object().unwrap().len(), 2);
|
||||
assert_eq!(
|
||||
reloaded["folders"][a.to_str().unwrap()],
|
||||
json!({"sort":"name","descending":false})
|
||||
);
|
||||
assert_eq!(
|
||||
reloaded["folders"][b.to_str().unwrap()],
|
||||
json!({"sort":"modified","descending":false})
|
||||
);
|
||||
for bad in [
|
||||
json!({"view":"giant"}),
|
||||
json!({"path":a,"sort":"invalid","descending":false}),
|
||||
json!({"path":a,"sort":"size","descending":"true"}),
|
||||
json!({"path":"relative","sort":"name","descending":false}),
|
||||
] {
|
||||
let mut p = bad;
|
||||
p["operation"] = json!("view.preferences");
|
||||
assert!(manager.api(home.path(), &p).is_err());
|
||||
}
|
||||
assert_eq!(
|
||||
manager
|
||||
.api(home.path(), &json!({"operation":"view.preferences"}))
|
||||
.unwrap(),
|
||||
reloaded
|
||||
);
|
||||
assert_eq!(
|
||||
manager
|
||||
.api(home.path(), &json!({"operation":"preferences"}))
|
||||
.unwrap(),
|
||||
json!([])
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn bookmarks_survive_missing_directories_and_editor_requires_revision() {
|
||||
if unsafe { libc::geteuid() } == 0 {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import React from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import {FileManager} from '../ui/src/file-manager';
|
||||
import {configureLanguage} from '../ui/src/i18n';
|
||||
import {Brand} from '../ui/src/icons';
|
||||
import '../ui/src/style.css';
|
||||
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[]=[],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},
|
||||
{name:'notes.txt',path:directory+'/notes.txt',directory:false,symlink:false,size:64,modified:1791350000,mode:420,uid:1000,gid:1000},
|
||||
{name:'movie.webm',path:directory+'/movie.webm',directory:false,symlink:true,size:2048,modified:1791340000,mode:420,uid:1000,gid:1000}];
|
||||
window.fetch=async(url,init)=>{
|
||||
if(String(url)!=='/manager-api/files')throw Error('Unexpected request '+url);
|
||||
const p=JSON.parse(String(init?.body));calls.push(p);let result:any;
|
||||
if(p.operation==='preferences')result=[];
|
||||
else if(p.operation==='view.preferences'){
|
||||
await wait(40);
|
||||
if(p.view)preferences.view=p.view;
|
||||
if(p.path)preferences.folders[p.path]={sort:p.sort,descending:p.descending};
|
||||
result=structuredClone(preferences);
|
||||
}else if(p.operation==='list'){
|
||||
let directory=p.path||home;if(directory==='/alias')directory=home;
|
||||
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}]:[];
|
||||
}
|
||||
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'){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}));
|
||||
};
|
||||
const root=createRoot(document.getElementById('root')!);
|
||||
const render=(key:string)=>root.render(<div className="app manager"><header className="shell-header"><Brand wordmark/><span>Files</span></header><div className="workspace tools-workspace"><main className="content file-content"><div className="file-workspace-frame"><FileManager key={key}/></div></main></div></div>);
|
||||
const button=(label:string)=>[...document.querySelectorAll<HTMLButtonElement>('.file-manager button')].find(b=>b.textContent?.trim()===label)!;
|
||||
const select=async(label:string,option:string)=>{document.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)!.click();await until(()=>document.querySelector('[role=listbox]'));[...document.querySelectorAll<HTMLButtonElement>('[role=option]')].find(b=>b.textContent===option)!.click();await wait();};
|
||||
const ready=()=>document.querySelector('.file-list-scroll')?.getAttribute('aria-busy')==='false';
|
||||
const listing=()=>calls.filter(p=>p.operation==='list'&&!p.hidden).at(-1);
|
||||
const changeAddress=async(value:string)=>{const input=document.querySelector<HTMLInputElement>('.file-address input')!;Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')!.set!.call(input,value);input.dispatchEvent(new Event('input',{bubbles:true}));await wait();input.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true}));};
|
||||
(window as any).runInstallReviewChecks=async()=>{try{
|
||||
configureLanguage('en-US',[]);render('one');await until(ready);await wait(200);
|
||||
if(calls.some(p=>p.operation==='preview'))throw Error('List view fetched thumbnails');
|
||||
if(listing().sort!=='size'||listing().descending!==true)throw Error('Home sort not restored on initial canonical path');
|
||||
await until(()=>[...document.querySelectorAll('.file-tree-entry button>span')].some(node=>node.textContent===longFolder));
|
||||
const tree=document.querySelector<HTMLElement>('.file-tree-scroll')!,name=[...tree.querySelectorAll<HTMLElement>('button>span')].find(node=>node.textContent===longFolder)!;
|
||||
if(getComputedStyle(name).textOverflow==='ellipsis'||name.scrollWidth>name.clientWidth+1)throw Error('Tree folder name clipped');
|
||||
if(tree.scrollWidth<=tree.clientWidth)throw Error('Tree does not expose horizontal overflow');
|
||||
tree.scrollLeft=tree.scrollWidth;if(tree.scrollLeft<=0)throw Error('Cannot scroll long tree names');tree.scrollLeft=0;
|
||||
if(name.closest('button')?.title!==home+'/'+longFolder)throw Error('Full path tooltip missing');
|
||||
|
||||
await select('View','Large icons');await until(()=>document.querySelector('.file-icon-grid.icons-large'));
|
||||
if(document.querySelector('.file-table'))throw Error('Table remained in icon view');
|
||||
const checkbox=document.querySelector<HTMLInputElement>('.file-tile-check input')!;checkbox.click();await until(()=>document.querySelector('.file-icon-tile.selected'));
|
||||
await select('View','Small icons');await until(()=>document.querySelector('.icons-small .file-icon-tile.selected'));
|
||||
document.querySelector('.file-tile-open')!.dispatchEvent(new MouseEvent('contextmenu',{bubbles:true,clientX:500,clientY:400}));await until(()=>document.querySelector('.file-context-menu'));if(!button('Copy'))throw Error('Context actions missing');window.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape'}));
|
||||
button('Downloads').click();await until(()=>ready()&&listing().path===downloads);
|
||||
if(listing().sort!=='modified'||listing().descending!==false||!document.querySelector('.icons-small'))throw Error('Folder sort/global view scope incorrect');
|
||||
await select('Sort','Name');await until(ready);document.querySelector<HTMLButtonElement>('[aria-label="Ascending"]')!.click();await until(()=>ready()&&listing().descending===true);
|
||||
button('Home').click();await until(()=>ready()&&listing().path===home);if(listing().sort!=='size'||listing().descending!==true)throw Error('Home sort overwritten by downloads');
|
||||
button('Downloads').click();await until(()=>ready()&&listing().path===downloads);if(listing().sort!=='name'||listing().descending!==true)throw Error('Changed sort not remembered');
|
||||
await select('View','Medium icons');await until(()=>preferences.view==='icons-medium');render('two');await wait();await until(()=>ready()&&document.querySelector('.icons-medium'));
|
||||
if(listing().sort!=='size')throw Error('Preferences failed across remount');
|
||||
await changeAddress('/alias');await until(()=>ready()&&document.querySelector<HTMLInputElement>('.file-address input')!.value===home);
|
||||
if(listing().sort!=='size')throw Error('Canonical folder preference not restored');
|
||||
const tile=document.querySelector<HTMLButtonElement>('.file-tile-open[title="notes.txt"]')!;tile.click();await until(()=>document.querySelector('.file-editor'));button('Close').click();
|
||||
document.querySelector<HTMLButtonElement>('.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<HTMLButtonElement>('.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<HTMLInputElement>('[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');
|
||||
binaryMode=true;document.querySelector<HTMLButtonElement>('[aria-label="刷新"]')!.click();await until(()=>document.querySelector('.file-name')?.textContent?.includes('sample.bin'));document.querySelector<HTMLButtonElement>('.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');
|
||||
@@ -0,0 +1,44 @@
|
||||
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(<><button id="opener">Files</button><FileMediaPreview entries={entries} initial={initial} onClose={()=>{closed++;root.render(<button id="opener">Files</button>);}} onDownload={path=>downloads.push(path)}/></>);
|
||||
const button=(label:string)=>document.querySelector<HTMLButtonElement>(`.file-media-overlay [aria-label="${label}"]`)!;
|
||||
const loaded=()=>document.querySelector<HTMLElement>('.file-media-canvas')?.style.visibility==='visible'&&!document.querySelector('.file-media-loading')&&!document.querySelector('.file-media-error');
|
||||
const transform=()=>document.querySelector<HTMLElement>('.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<void>(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(<div className="app manager" style={{height:'100vh',background:'#222'}}><button>Files</button></div>);await wait(300);await samples();render();await until(loaded);
|
||||
const overlay=document.querySelector<HTMLElement>('.file-media-overlay')!,bounds=overlay.getBoundingClientRect();if(bounds.width!==innerWidth||bounds.height!==innerHeight)throw Error('Viewer not fullscreen');
|
||||
const toolbar=document.querySelector<HTMLElement>('.file-media-actions')!.getBoundingClientRect(),caption=document.querySelector<HTMLElement>('.file-media-caption')!.getBoundingClientRect();if(Math.abs(toolbar.x+toolbar.width/2-innerWidth/2)>1||toolbar.bottom>innerHeight||caption.top<toolbar.bottom||document.querySelector('.file-media-header'))throw Error('Bottom capsule/caption layout incorrect');
|
||||
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<HTMLElement>('.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}));stage.click();await wait();if(closed)throw Error('Panning closed viewer');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());
|
||||
const player=document.querySelector('video')!,videoBounds=player.getBoundingClientRect();if(player.videoWidth!==640||player.videoHeight!==360||videoBounds.height<100)throw Error('Video picture missing or collapsed');
|
||||
if(!player.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');
|
||||
button('Download').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);
|
||||
document.querySelector('img')!.dispatchEvent(new MouseEvent('click',{bubbles:true}));await wait();if(closed!==1)throw Error('Media click closed viewer');
|
||||
const blank=document.querySelector<HTMLElement>('.file-media-stage')!;Element.prototype.setPointerCapture=function(){};blank.dispatchEvent(new PointerEvent('pointerdown',{pointerId:9,clientX:10,clientY:10,button:0,bubbles:true}));blank.dispatchEvent(new PointerEvent('pointerup',{pointerId:9,bubbles:true}));Element.prototype.setPointerCapture=capture;blank.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');
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import {FileThumbnail} from '../ui/src/file-thumbnail';
|
||||
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('Thumbnail timeout');};
|
||||
const root=createRoot(document.getElementById('root')!);
|
||||
const requests:string[]=[],released:string[]=[],cancelled:string[]=[];
|
||||
let active=0,maxActive=0,imageUrl='',videoUrl='',revision='1';
|
||||
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 request');
|
||||
requests.push(p.path);active++;maxActive=Math.max(maxActive,active);
|
||||
try{await new Promise<void>((resolve,reject)=>{const timer=setTimeout(resolve,p.path==='/slow.png'?1000:80);init?.signal?.addEventListener('abort',()=>{clearTimeout(timer);cancelled.push(p.path);reject(new DOMException('Aborted','AbortError'));},{once:true});});}
|
||||
finally{active--;}
|
||||
return new Response(JSON.stringify({result:{url:p.path==='/bad.png'?'data:image/png;base64,AA==':p.path.endsWith('.webm')?videoUrl:imageUrl,token:p.path}}));
|
||||
};
|
||||
const render=(paths:string[],active=true)=>root.render(<div className="file-manager" style={{background:'#222',color:'#ddd',padding:20}}><div id="viewport" style={{height:180,width:360,overflow:'auto'}}>{paths.map(path=><div key={path} data-path={path} style={{height:140,display:'flex',gap:20,alignItems:'center'}}><span style={{width:100,height:100}}><FileThumbnail active={active} entry={{path,name:path,directory:false,size:100,mediaRevision:revision}}><span className="fallback">File icon</span></FileThumbnail></span>{path}</div>)}</div></div>);
|
||||
const thumbnail=(path:string)=>document.querySelector(`[data-path="${path}"] .file-media-thumbnail`);
|
||||
async function samples(){
|
||||
const canvas=document.createElement('canvas');canvas.width=120;canvas.height=80;const ctx=canvas.getContext('2d')!;
|
||||
ctx.fillStyle='#77b0cf';ctx.fillRect(0,0,120,80);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<void>(r=>recorder.onstop=()=>r());recorder.start();
|
||||
const timer=setInterval(()=>{ctx.fillStyle='#c89665';ctx.fillRect(10,10,100,60);},70);
|
||||
await wait(1300);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{
|
||||
await samples();render(['/one.png','/two.png','/three.png','/clip.webm','/bad.png']);
|
||||
await until(()=>thumbnail('/one.png')&&thumbnail('/two.png'));
|
||||
if(requests.includes('/three.png')||requests.includes('/clip.webm'))throw Error('Offscreen media loaded');
|
||||
document.getElementById('viewport')!.scrollTop=280;await until(()=>thumbnail('/three.png')&&thumbnail('/clip.webm'));
|
||||
if(!document.querySelector('[data-path="/clip.webm"] .file-video-marker'))throw Error('Video marker missing');
|
||||
document.getElementById('viewport')!.scrollTop=560;await until(()=>released.includes('/bad.png'));await wait(100);
|
||||
if(thumbnail('/bad.png')||!document.querySelector('[data-path="/bad.png"] .fallback'))throw Error('Decode failure did not retain icon');
|
||||
const count=requests.filter(p=>p==='/one.png').length;document.getElementById('viewport')!.scrollTop=0;await until(()=>thumbnail('/one.png'));
|
||||
if(requests.filter(p=>p==='/one.png').length!==count)throw Error('Thumbnail cache not reused');
|
||||
revision='2';render(['/one.png']);await until(()=>requests.filter(p=>p==='/one.png').length===count+1&&thumbnail('/one.png'));
|
||||
render(['/slow.png']);await until(()=>requests.includes('/slow.png'));render(['/notes.txt']);await until(()=>cancelled.includes('/slow.png'));
|
||||
if(requests.includes('/notes.txt')||maxActive>2)throw Error('Invalid source request or unbounded parallelism');
|
||||
render(['/inactive.png'],false);await wait(300);if(requests.includes('/inactive.png'))throw Error('Inactive panel fetched media');
|
||||
render(['/one.png','/clip.webm']);await until(()=>thumbnail('/one.png')&&thumbnail('/clip.webm'));
|
||||
console.log('FRAMELY_PREVIEW_INSTALL_FILE_THUMBNAILS');await wait(300);
|
||||
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');
|
||||
+18
-1
@@ -983,6 +983,23 @@
|
||||
"帧缓冲兼容修复": "Framebuffer compatibility fix",
|
||||
"仅用于部分游戏有声音但黑屏的问题,默认关闭。修改后需重启应用生效,不改变系统驱动或游戏文件。": "For specific games with sound but a black screen. Off by default. Restart the app after changing this option; system drivers and game files are unchanged.",
|
||||
"Invalid framebuffer compatibility setting": "Invalid framebuffer compatibility setting",
|
||||
"Framebuffer compatibility runtime is missing; install a complete Framely build": "Framebuffer compatibility runtime is missing; install a complete Framely build"
|
||||
"Framebuffer compatibility runtime is missing; install a complete Framely build": "Framebuffer compatibility runtime is missing; install a complete Framely build",
|
||||
"展示方式": "View",
|
||||
"列表": "List",
|
||||
"小图标": "Small icons",
|
||||
"中图标": "Medium icons",
|
||||
"大图标": "Large icons",
|
||||
"选择 {0}": "Select {0}",
|
||||
"Invalid file view": "Invalid file view",
|
||||
"Invalid file sort": "Invalid file sort",
|
||||
"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",
|
||||
"预览操作": "Preview controls"
|
||||
}
|
||||
}
|
||||
+18
-1
@@ -983,6 +983,23 @@
|
||||
"帧缓冲兼容修复": "帧缓冲兼容修复",
|
||||
"仅用于部分游戏有声音但黑屏的问题,默认关闭。修改后需重启应用生效,不改变系统驱动或游戏文件。": "仅用于部分游戏有声音但黑屏的问题,默认关闭。修改后需重启应用生效,不改变系统驱动或游戏文件。",
|
||||
"Invalid framebuffer compatibility setting": "无效的帧缓冲兼容设置",
|
||||
"Framebuffer compatibility runtime is missing; install a complete Framely build": "缺少帧缓冲兼容修复组件,请安装完整的 Framely 更新包。"
|
||||
"Framebuffer compatibility runtime is missing; install a complete Framely build": "缺少帧缓冲兼容修复组件,请安装完整的 Framely 更新包。",
|
||||
"展示方式": "展示方式",
|
||||
"列表": "列表",
|
||||
"小图标": "小图标",
|
||||
"中图标": "中图标",
|
||||
"大图标": "大图标",
|
||||
"选择 {0}": "选择 {0}",
|
||||
"Invalid file view": "无效的文件展示方式",
|
||||
"Invalid file sort": "无效的文件排序方式",
|
||||
"Invalid file sort direction": "无效的文件排序方向",
|
||||
"缩小": "缩小",
|
||||
"放大": "放大",
|
||||
"缩放比例": "缩放比例",
|
||||
"适应窗口": "适应窗口",
|
||||
"上一个文件": "上一个文件",
|
||||
"下一个文件": "下一个文件",
|
||||
"滚轮缩放 · 拖动移动 · ← → 切换 · Esc 关闭": "滚轮缩放 · 拖动移动 · ← → 切换 · Esc 关闭",
|
||||
"预览操作": "预览操作"
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,10 @@
|
||||
.file-manager .file-tree-entry{gap:0;min-height:38px}
|
||||
.file-manager .file-tree-entry>button.file-tree-toggle{flex:none;padding:4px;width:26px;min-width:26px;border:0}
|
||||
.file-manager .file-tree-entry>button:last-child{width:auto;flex:1;min-width:0;padding-left:4px;gap:8px}
|
||||
/* Keep complete tree names on one line; the tree scrolls horizontally. */
|
||||
.file-manager .file-tree-scroll>div{width:max-content;min-width:100%}
|
||||
.file-manager .file-tree-entry>button:last-child{flex:1 0 auto;min-width:max-content}
|
||||
.file-manager .file-tree-entry>button>span{overflow:visible;text-overflow:clip;white-space:nowrap;flex:none}
|
||||
.file-manager .file-directory-tree .file-tree{padding-left:12px;border-left:1px solid #ffffff0a;margin-left:12px}
|
||||
.file-manager .file-side-entry{display:flex;align-items:center;min-width:0;position:relative}
|
||||
.file-manager .file-side-entry>button:first-child{min-width:0}
|
||||
@@ -155,3 +159,55 @@
|
||||
.file-manager .file-context-menu button{display:flex;width:100%;text-align:left;justify-content:flex-start;min-height:34px;font-size:13px;padding:6px 10px}
|
||||
.file-manager .file-context-menu button:focus-visible{background:#3a3a3f;outline:none}
|
||||
.file-manager .file-context-menu [role=separator]{height:1px;margin:5px 6px;background:var(--file-line)}
|
||||
/* File display preference is global; folders retain only their sort order. */
|
||||
.file-manager .file-view-control .framely-select{min-width:112px}
|
||||
.file-manager .file-view-control .framely-select button{font-size:13px;min-height:36px;padding:6px 9px;background:transparent!important;border-color:var(--file-line)}
|
||||
.file-manager .file-icon-grid{--file-tile-width:152px;display:grid;grid-template-columns:repeat(auto-fill,minmax(min(var(--file-tile-width),100%),1fr));gap:12px 8px;padding:20px 8px;align-content:start}
|
||||
.file-manager .file-icon-grid.icons-small{--file-tile-width:116px;gap:8px 4px}
|
||||
.file-manager .file-icon-grid.icons-large{--file-tile-width:200px;gap:20px 12px}
|
||||
.file-manager .file-icon-tile{position:relative;min-width:0;border-radius:8px;align-self:start}
|
||||
.file-manager .file-icon-tile:hover{background:#ffffff06}
|
||||
.file-manager .file-icon-tile.selected{background:#3b3b41}
|
||||
.file-manager .file-tile-open{display:flex;flex-direction:column;align-items:center;justify-content:flex-start;width:100%;padding:16px 8px 12px;gap:10px;white-space:normal}
|
||||
.file-manager .file-tile-open:hover:not(:disabled){background:transparent}
|
||||
.file-manager .file-tile-open .file-entry-icon{position:relative;width:96px;height:78px}
|
||||
.file-manager .icons-small .file-tile-open .file-entry-icon{width:64px;height:54px}
|
||||
.file-manager .icons-large .file-tile-open .file-entry-icon{width:124px;height:106px}
|
||||
.file-manager .file-tile-name{font-size:13px;line-height:1.45;overflow-wrap:anywhere;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;min-height:38px;max-width:100%;text-align:center}
|
||||
.file-manager .file-link-marker{position:absolute;bottom:0;right:0;background:var(--file-bg);border-radius:4px}
|
||||
.file-manager .file-tile-check{position:absolute;top:6px;left:6px;z-index:1;display:grid;place-items:center;padding:4px;opacity:0}
|
||||
.file-manager .file-icon-tile:hover .file-tile-check,.file-manager .file-icon-tile:focus-within .file-tile-check,.file-manager .file-icon-tile.selected .file-tile-check{opacity:1}
|
||||
@media(hover:none){.file-manager .file-tile-check{opacity:1}}
|
||||
.file-manager .file-grid-select-all{display:inline-flex;align-items:center;gap:7px;margin-right:16px}
|
||||
.file-manager .file-grid-skeleton{display:flex;flex-direction:column;align-items:center;gap:16px;padding:16px 8px}
|
||||
.file-manager .file-grid-skeleton i{width:48px;height:56px;background:#ffffff09;border-radius:6px}
|
||||
.file-manager .file-grid-skeleton span{width:70%;height:12px;background:#ffffff09;border-radius:4px}
|
||||
.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}
|
||||
/* Preserve Framely dark theme. DESIGN_VARIANCE: 4, MOTION_INTENSITY: 2, VISUAL_DENSITY: 2.
|
||||
Bottom capsule uses a CSS glass approximation, not an Apple platform material. */
|
||||
.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-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% - 48px);max-height:calc(100% - 40px);object-fit:contain;display:block}
|
||||
.file-media-canvas img{pointer-events:auto;}
|
||||
.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-media-dock{display:flex;flex-direction:column;align-items:center;gap:14px;flex-shrink:0;padding:8px 16px max(20px,env(safe-area-inset-bottom));}
|
||||
.file-media-actions{display:flex;align-items:center;gap:3px;max-width:100%;padding:7px 9px;border-radius:999px;border:1px solid #ffffff1f;background:#2a2a2dd9;backdrop-filter:blur(16px);box-shadow:inset 0 1px 0 #ffffff12,0 8px 30px #05050750;}
|
||||
.file-manager .file-media-actions button{display:grid;place-items:center;width:42px;height:42px;flex-shrink:0;padding:0;border:0;border-radius:50%;background:transparent;color:#ececef;box-shadow:none;transition:background .16s ease,transform .16s ease;}
|
||||
.file-manager .file-media-actions button:hover:not(:disabled){background:#ffffff12;}
|
||||
.file-manager .file-media-actions button:active:not(:disabled){transform:scale(.94);background:#ffffff1b;}
|
||||
.file-manager .file-media-actions button:focus-visible{outline:2px solid #c5c5cc;outline-offset:1px;}
|
||||
.file-manager .file-media-actions button:disabled{color:#77777f;opacity:1;cursor:default;}
|
||||
.file-media-actions output{min-width:46px;text-align:center;color:#c5c5cd;font-size:12px;font-variant-numeric:tabular-nums;}
|
||||
.file-media-divider{height:18px;width:1px;background:#ffffff18;margin:0 6px;flex-shrink:0;}
|
||||
.file-media-caption{max-width:min(720px,90vw);text-align:center;}
|
||||
.file-media-caption p{margin:0;color:#e1e1e7;font-size:15px;font-weight:500;line-height:1.5;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.file-media-caption>span{display:block;margin-top:4px;color:#a6a6b0;font-size:12px;font-variant-numeric:tabular-nums;}
|
||||
@media(max-width:640px){.file-media-dock{padding-left:10px;padding-right:10px;gap:11px}.file-media-actions{gap:0;padding:5px 7px}.file-manager .file-media-actions button{width:36px;height:36px}.file-media-actions output{min-width:38px;font-size:11px}.file-media-divider{margin:0 4px}.file-media-caption p{font-size:13px}.file-media-canvas img,.file-media-canvas video{max-width:calc(100% - 24px);max-height:calc(100% - 24px)}}
|
||||
@media(max-width:360px){.file-manager .file-media-actions button{width:33px;height:36px}.file-media-divider{margin:0 3px}.file-media-actions output{min-width:34px}}
|
||||
@media(prefers-reduced-transparency:reduce){.file-media-actions{background:#2a2a2d;backdrop-filter:none}.file-manager.file-media-overlay{background:#111113}}
|
||||
@media(prefers-reduced-motion:reduce){.file-manager .file-media-actions button{transition:none;}}
|
||||
+43
-11
@@ -1,6 +1,8 @@
|
||||
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';
|
||||
@@ -8,9 +10,12 @@ import {SwitchRow} from './switch';
|
||||
import {t} from './i18n';
|
||||
import './manager-tools.css';
|
||||
import './file-manager.css';
|
||||
type Entry={name:string;path:string;directory:boolean;symlink:boolean;size:number;modified?:number;mode:number;uid:number;gid:number;linkTarget?:string};
|
||||
type Entry={name:string;path:string;directory:boolean;symlink:boolean;size:number;modified?:number;mediaRevision?:string|null;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 FileSort='name'|'size'|'modified';
|
||||
type FileView='list'|'icons-small'|'icons-medium'|'icons-large';
|
||||
type FilePreferences={view:FileView;folders:Record<string,{sort:FileSort;descending:boolean}>};
|
||||
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`;}
|
||||
@@ -26,10 +31,19 @@ function FolderTree({name,path,onOpen,current,depth=0}:{name:string;path:string;
|
||||
}catch(e){if(id===request.current){setError(String(e));setExpanded(true);}}}
|
||||
useEffect(()=>{if(depth<20&&(depth===0||ancestor||current===path))void expand(0,ancestor);return()=>{request.current++;};},[path,current]);
|
||||
useEffect(()=>{if(current!==path||!row.current)return;const node=row.current,sidebar=node.closest<HTMLElement>('.file-tree-scroll');if(!sidebar)return;const a=node.getBoundingClientRect(),b=sidebar.getBoundingClientRect();if(a.top<b.top)sidebar.scrollTop+=a.top-b.top-8;else if(a.bottom>b.bottom)sidebar.scrollTop+=a.bottom-b.bottom+8;},[current,path]);
|
||||
return <div><div ref={row} className="file-side-entry file-tree-entry"><button className="file-tree-toggle" disabled={depth>=20} aria-expanded={expanded} aria-label={t(expanded?'收起文件夹':'展开文件夹')} onClick={()=>{if(expanded){request.current++;setExpanded(false);}else void expand();}}>{expanded?<IconChevronDown size={16}/>:<IconChevronRight size={16}/>}</button><button aria-current={current===path?'location':undefined} className={current===path?'active':''} onClick={()=>onOpen(path)}><IconFolder size={18}/><span>{name}</span></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>;
|
||||
return <div><div ref={row} className="file-side-entry file-tree-entry"><button className="file-tree-toggle" disabled={depth>=20} aria-expanded={expanded} aria-label={t(expanded?'收起文件夹':'展开文件夹')} onClick={()=>{if(expanded){request.current++;setExpanded(false);}else void expand();}}>{expanded?<IconChevronDown size={16}/>:<IconChevronRight size={16}/>}</button><button title={path} aria-current={current===path?'location':undefined} className={current===path?'active':''} onClick={()=>onOpen(path)}><IconFolder size={18}/><span>{name}</span></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),[loading,setLoading]=useState(true),[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),[bookmarkMenu,setBookmarkMenu]=useState<string|null>(null),[outcomes,setOutcomes]=useState<any[]>([]),[menu,setMenu]=useState<{x:number;y:number;blank:boolean}|null>(null);
|
||||
const[data,setData]=useState<Listing>({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<string[]>([]),[bookmarks,setBookmarks]=useState<Bookmark[]>([]),[error,setError]=useState(''),[busy,setBusy]=useState(false),[loading,setLoading]=useState(true),[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<{entries:MediaEntry[];initial:number}|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),[bookmarkMenu,setBookmarkMenu]=useState<string|null>(null),[outcomes,setOutcomes]=useState<any[]>([]),[menu,setMenu]=useState<{x:number;y:number;blank:boolean}|null>(null);
|
||||
const[filePreferences,setFilePreferences]=useState<FilePreferences>({view:'list',folders:{}}),[preferencesReady,setPreferencesReady]=useState(false);
|
||||
const preferenceWrites=useRef<Promise<unknown>>(Promise.resolve());
|
||||
const sort=filePreferences.folders[path]?.sort??'name',descending=filePreferences.folders[path]?.descending??false,view=filePreferences.view;
|
||||
function saveFilePreferences(patch:Record<string,unknown>,update:(previous:FilePreferences)=>FilePreferences){
|
||||
setFilePreferences(update);
|
||||
preferenceWrites.current=preferenceWrites.current.catch(()=>{}).then(()=>managerApi('files',{operation:'view.preferences',...patch})).catch(e=>setError(String(e)));
|
||||
}
|
||||
function changeSort(nextSort:FileSort=sort,nextDescending:boolean=descending){if(!path||!preferencesReady)return;setOffset(0);setResults(null);saveFilePreferences({path,sort:nextSort,descending:nextDescending},previous=>({...previous,folders:{...previous.folders,[path]:{sort:nextSort,descending:nextDescending}}}));}
|
||||
function changeView(next:FileView){saveFilePreferences({view:next},previous=>({...previous,view:next}));}
|
||||
const layout=useRef<HTMLDivElement>(null),separator=useRef<HTMLDivElement>(null),menuElement=useRef<HTMLDivElement>(null),resize=useRef<{id:number;x:number;width:number;previous:string}|null>(null);
|
||||
const sidebarKey='framely-file-sidebar-width';
|
||||
function limitWidth(width:number){return Math.max(132,Math.min(width,480,(layout.current?.clientWidth??1200)*.4));}
|
||||
@@ -43,9 +57,10 @@ export function FileManager({active=true}:{active?:boolean}){
|
||||
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(menu){setMenu(null);}else 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,menu,dialog,preview,editor,modified]);
|
||||
const refresh=()=>setCounter(n=>n+1);
|
||||
function navigate(next:string){setMenu(null);setError('');setData(previous=>({...previous,path:next,entries:[],total:0,parent:next==='/'?undefined:next.replace(/\/?[^/]+\/?$/,'')||'/'}));setLoading(true);setPath(next);setAddress(next);setSelected([]);setOffset(0);setResults(null);setQuery('');setTrash(null);setContext(false);}
|
||||
function navigate(next:string){setMenu(null);setError('');setData(previous=>({...previous,path:next,entries:[],total:0,parent:next==='/'?undefined:next.replace(/\/?[^/]+\/?$/,'')||'/'}));setLoading(true);setPath(next);setAddress(next);setSelected([]);setOffset(0);setResults(null);setQuery('');setTrash(null);setContext(false);refresh();}
|
||||
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;setLoading(true);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);setLoading(false);if(path!==v.path)setPath(v.path);}).catch(e=>{if(seq===sequence.current){setError(String(e));setLoading(false);setSelected([]);setData(previous=>({...previous,entries:[],total:0}));}});},120);return()=>clearTimeout(timer);},[path,query,hidden,sort,descending,offset,counter]);
|
||||
useEffect(()=>{let live=true;managerApi<FilePreferences>('files',{operation:'view.preferences'}).then(v=>{if(live)setFilePreferences(v);}).catch(e=>{if(live)setError(String(e));}).finally(()=>{if(live)setPreferencesReady(true);});return()=>{live=false;};},[]);
|
||||
useEffect(()=>{if(!preferencesReady)return;const seq=++sequence.current;setLoading(true);const timer=setTimeout(()=>{managerApi<Listing>('files',{operation:'list',path:path||undefined,query,hidden,sort,descending,offset}).then(v=>{if(seq!==sequence.current)return;if(path!==v.path){setPath(v.path);setAddress(v.path);const saved=filePreferences.folders[v.path];if((saved?.sort??'name')!==sort||(saved?.descending??false)!==descending){setData(previous=>({...previous,path:v.path,entries:[],total:0}));return;}}setData(v);setAddress(v.path);setLoading(false);}).catch(e=>{if(seq===sequence.current){setError(String(e));setLoading(false);setSelected([]);setData(previous=>({...previous,entries:[],total:0}));}});},120);return()=>clearTimeout(timer);},[path,query,hidden,sort,descending,offset,counter,preferencesReady]);
|
||||
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;}}
|
||||
@@ -54,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='';
|
||||
@@ -104,6 +129,8 @@ export function FileManager({active=true}:{active?:boolean}){
|
||||
const bookmarkCurrent=bookmarks.some(b=>b.path===path);
|
||||
const fileKind=(entry:Entry)=>entry.directory?t('文件夹'):entry.name.split('.').length>1?entry.name.split('.').pop()!.toUpperCase():t('文件');
|
||||
const formatDate=(seconds?:number)=>seconds?new Date(seconds*1000).toLocaleString(document.documentElement.lang,{year:'numeric',month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}):'-';
|
||||
const entryIcon=(entry:Entry,size:number)=>entry.directory?<IconFolder size={size}/>:['png','jpg','jpeg','gif','webp','avif','bmp'].includes(entry.name.split('.').pop()?.toLowerCase()??'')?<IconPhoto size={size}/>:['mp4','webm','mov','m4v'].includes(entry.name.split('.').pop()?.toLowerCase()??'')?<IconMovie size={size}/>:['zip','tar','gz'].includes(entry.name.split('.').pop()?.toLowerCase()??'')?<IconFileZip size={size}/>:<IconFileText size={size}/>;
|
||||
function selectEntry(entry:Entry,checked:boolean){setSelected(previous=>checked?[...new Set([...previous,entry.path])]:previous.filter(path=>path!==entry.path));}
|
||||
const actions=<>
|
||||
<button role={menu?'menuitem':undefined} disabled={!selectedCount||busy} onClick={()=>{setClipboard({paths:selected,cut:false});setContext(false);}}><IconCopy size={18}/>{t('复制')}</button>
|
||||
<button role={menu?'menuitem':undefined} disabled={!selectedCount||busy} onClick={()=>{setClipboard({paths:selected,cut:true});setContext(false);}}><IconCut size={18}/>{t('剪切')}</button>
|
||||
@@ -138,11 +165,16 @@ export function FileManager({active=true}:{active?:boolean}){
|
||||
<button disabled={busy} onClick={()=>directoryInput.current?.click()}><IconFolderPlus size={18}/>{t('上传文件夹')}</button>
|
||||
<button disabled={!selectedCount||busy} onClick={()=>void getDownload()}><IconDownload size={18}/>{t('下载文件')}</button><button className={context?'active':''} aria-expanded={context} onClick={()=>setContext(v=>!v)}><IconDots size={18}/>{t('操作')}</button></div></div>
|
||||
{context&&<div className="file-operation-panel">{actions}</div>}
|
||||
<div className="file-filter-bar"><label className="file-search"><span className="file-sr-only">{t('搜索文件')}</span><IconSearch size={18}/><input aria-label={t('搜索文件')} placeholder={t('搜索当前目录')} value={query} onChange={e=>{setQuery(e.target.value);setOffset(0);setResults(null);}}/>{query&&<button className="file-icon-button" aria-label={t('清除选择')} onClick={()=>{setQuery('');setResults(null);}}><IconX size={16}/></button>}</label><button disabled={!query||busy} onClick={()=>void perform({operation:'search',path,query}).then(v=>{setResults(v.entries);setSelected([]);}).catch(()=>{})}>{t('搜索子目录')}</button><div className="file-sort"><Select label={t('排序')} value={sort} onChange={setSort} options={[{value:'name',label:t('名称')},{value:'size',label:t('大小')},{value:'modified',label:t('修改时间')}]}/><button className="file-icon-button" aria-label={t(descending?'降序':'升序')} onClick={()=>setDescending(v=>!v)}>{descending?<IconArrowDown size={18}/>:<IconArrowUp size={18}/>}</button></div><SwitchRow label={t('显示隐藏文件')} checked={hidden} onChange={setHidden}/></div>
|
||||
<div className="file-list-scroll" aria-busy={loading} onContextMenu={event=>showMenu(event)}><table className="file-table"><thead><tr><th className="file-check"><input type="checkbox" aria-label={t('选择本页')} checked={shown.length>0&&shown.every(entry=>selected.includes(entry.path))} onChange={event=>setSelected(event.target.checked?shown.map(entry=>entry.path):[])}/></th><th>{t('名称')}</th><th className="file-kind">{t('类型')}</th><th className="file-size">{t('大小')}</th><th className="file-time">{t('修改时间')}</th></tr></thead><tbody>
|
||||
{loading&&!shown.length?Array.from({length:5},(_,index)=><tr className="file-skeleton" key={index} aria-hidden="true"><td/><td><i/></td><td className="file-kind"><i/></td><td className="file-size"><i/></td><td className="file-time"><i/></td></tr>):shown.map(entry=><tr key={entry.path} className={selected.includes(entry.path)?'selected':''} onContextMenu={event=>showMenu(event,entry)}><td className="file-check"><input type="checkbox" aria-label={entry.name} checked={selected.includes(entry.path)} onChange={event=>setSelected(v=>event.target.checked?[...new Set([...v,entry.path])]:v.filter(p=>p!==entry.path))}/></td><td><button className="file-name" onClick={()=>void open(entry)}><span className={`file-entry-icon ${entry.directory?'folder':''}`}>{entry.directory?<IconFolder size={22}/>:['png','jpg','jpeg','gif','webp','avif','bmp'].includes(entry.name.split('.').pop()?.toLowerCase()??'')?<IconPhoto size={22}/>:['mp4','webm','mov','m4v'].includes(entry.name.split('.').pop()?.toLowerCase()??'')?<IconMovie size={22}/>:['zip','tar','gz'].includes(entry.name.split('.').pop()?.toLowerCase()??'')?<IconFileZip size={22}/>:<IconFileText size={22}/>}</span><span>{entry.name}</span>{entry.symlink&&<IconExternalLink size={14}/>}</button></td><td className="file-kind">{fileKind(entry)}</td><td className="file-size">{entry.directory?'-':bytes(entry.size)}</td><td className="file-time">{formatDate(entry.modified)}</td></tr>)}</tbody></table>
|
||||
<div className="file-filter-bar"><label className="file-search"><span className="file-sr-only">{t('搜索文件')}</span><IconSearch size={18}/><input aria-label={t('搜索文件')} placeholder={t('搜索当前目录')} value={query} onChange={e=>{setQuery(e.target.value);setOffset(0);setResults(null);}}/>{query&&<button className="file-icon-button" aria-label={t('清除选择')} onClick={()=>{setQuery('');setResults(null);}}><IconX size={16}/></button>}</label><button disabled={!query||busy} onClick={()=>void perform({operation:'search',path,query}).then(v=>{setResults(v.entries);setSelected([]);}).catch(()=>{})}>{t('搜索子目录')}</button><div className="file-sort"><Select label={t('排序')} value={sort} disabled={!preferencesReady||!path||loading} onChange={value=>changeSort(value as FileSort)} options={[{value:'name',label:t('名称')},{value:'size',label:t('大小')},{value:'modified',label:t('修改时间')}]}/><button className="file-icon-button" aria-label={t(descending?'降序':'升序')} disabled={!preferencesReady||!path||loading} onClick={()=>changeSort(sort,!descending)}>{descending?<IconArrowDown size={18}/>:<IconArrowUp size={18}/>}</button></div><div className="file-view-control"><Select label={t('展示方式')} value={view} disabled={!preferencesReady} onChange={value=>changeView(value as FileView)} options={[{value:'list',label:t('列表')},{value:'icons-small',label:t('小图标')},{value:'icons-medium',label:t('中图标')},{value:'icons-large',label:t('大图标')}]}/></div><SwitchRow label={t('显示隐藏文件')} checked={hidden} onChange={setHidden}/></div>
|
||||
<div className="file-list-scroll" aria-busy={loading} onContextMenu={event=>showMenu(event)}>{view==='list'?<table className="file-table"><thead><tr><th className="file-check"><input type="checkbox" aria-label={t('选择本页')} checked={shown.length>0&&shown.every(entry=>selected.includes(entry.path))} onChange={event=>setSelected(event.target.checked?shown.map(entry=>entry.path):[])}/></th><th>{t('名称')}</th><th className="file-kind">{t('类型')}</th><th className="file-size">{t('大小')}</th><th className="file-time">{t('修改时间')}</th></tr></thead><tbody>
|
||||
{loading&&!shown.length?Array.from({length:5},(_,index)=><tr className="file-skeleton" key={index} aria-hidden="true"><td/><td><i/></td><td className="file-kind"><i/></td><td className="file-size"><i/></td><td className="file-time"><i/></td></tr>):shown.map(entry=><tr key={entry.path} className={selected.includes(entry.path)?'selected':''} onContextMenu={event=>showMenu(event,entry)}><td className="file-check"><input type="checkbox" aria-label={entry.name} checked={selected.includes(entry.path)} onChange={event=>setSelected(v=>event.target.checked?[...new Set([...v,entry.path])]:v.filter(p=>p!==entry.path))}/></td><td><button className="file-name" onClick={()=>void open(entry)}><span className={`file-entry-icon ${entry.directory?'folder':''}`}>{entryIcon(entry,22)}</span><span>{entry.name}</span>{entry.symlink&&<IconExternalLink size={14}/>}</button></td><td className="file-kind">{fileKind(entry)}</td><td className="file-size">{entry.directory?'-':bytes(entry.size)}</td><td className="file-time">{formatDate(entry.modified)}</td></tr>)}</tbody></table>:<div className={`file-icon-grid ${view}`}>
|
||||
{loading&&!shown.length?Array.from({length:8},(_,index)=><div className="file-grid-skeleton" aria-hidden="true" key={index}><i/><span/></div>):shown.map(entry=><div key={entry.path} className={`file-icon-tile ${selected.includes(entry.path)?'selected':''}`} onContextMenu={event=>showMenu(event,entry)}>
|
||||
<label className="file-tile-check"><input type="checkbox" aria-label={t('选择 {0}',{0:entry.name})} checked={selected.includes(entry.path)} onChange={event=>selectEntry(entry,event.target.checked)}/></label>
|
||||
<button className="file-tile-open" title={entry.name} onClick={()=>void open(entry)}><span className={`file-entry-icon ${entry.directory?'folder':''}`}><FileThumbnail entry={entry} active={active}>{entryIcon(entry,view==='icons-large'?96:view==='icons-medium'?68:44)}</FileThumbnail>{entry.symlink&&<IconExternalLink className="file-link-marker" size={16}/>}</span><span className="file-tile-name">{entry.name}</span></button>
|
||||
</div>)}
|
||||
</div>}
|
||||
{!loading&&!shown.length&&<div className="file-empty"><IconFolder size={40}/><h3>{t(query?'没有匹配的文件':'此文件夹为空')}</h3><p>{t(query?'试试其他名称,或搜索子目录。':'上传文件或新建文件夹。')}</p></div>}</div>
|
||||
<footer className="file-status-bar"><span>{selectedCount?t('已选择 {0} 项',{0:selectedCount}):t('共 {0} 项',{0:results?results.length:data.total})}{clipboard&&<small>{t(clipboard.cut?'已剪切 {0} 项':'已复制 {0} 项',{0:clipboard.paths.length})}</small>}</span>{!results&&<div className="file-pagination"><button className="file-icon-button" aria-label={t('上一页')} disabled={offset===0} onClick={()=>setOffset(v=>Math.max(0,v-200))}><IconChevronRight className="file-chevron-back" size={18}/></button><span>{Math.floor(offset/200)+1} / {Math.max(1,Math.ceil(data.total/200))}</span><button className="file-icon-button" aria-label={t('下一页')} disabled={offset+200>=data.total} onClick={()=>setOffset(v=>v+200)}><IconChevronRight size={18}/></button></div>}</footer>
|
||||
<footer className="file-status-bar"><span>{view!=='list'&&!trash&&<label className="file-grid-select-all"><input type="checkbox" aria-label={t('选择本页')} checked={shown.length>0&&shown.every(entry=>selected.includes(entry.path))} onChange={event=>setSelected(event.target.checked?shown.map(entry=>entry.path):[])}/>{t('选择本页')}</label>}{selectedCount?t('已选择 {0} 项',{0:selectedCount}):t('共 {0} 项',{0:results?results.length:data.total})}{clipboard&&<small>{t(clipboard.cut?'已剪切 {0} 项':'已复制 {0} 项',{0:clipboard.paths.length})}</small>}</span>{!results&&<div className="file-pagination"><button className="file-icon-button" aria-label={t('上一页')} disabled={offset===0} onClick={()=>setOffset(v=>Math.max(0,v-200))}><IconChevronRight className="file-chevron-back" size={18}/></button><span>{Math.floor(offset/200)+1} / {Math.max(1,Math.ceil(data.total/200))}</span><button className="file-icon-button" aria-label={t('下一页')} disabled={offset+200>=data.total} onClick={()=>setOffset(v=>v+200)}><IconChevronRight size={18}/></button></div>}</footer>
|
||||
</>}
|
||||
</div></div>
|
||||
{active&&menu&&<div ref={menuElement} className="file-context-menu" role="menu" aria-label={t('文件操作')} style={{left:menu.x,top:menu.y}} onContextMenu={event=>event.preventDefault()} onClick={event=>{if((event.target as HTMLElement).closest('button:not(:disabled)'))setMenu(null);}} onKeyDown={event=>{const buttons=Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('button:not(:disabled)'));if(['ArrowDown','ArrowUp','Home','End'].includes(event.key)){event.preventDefault();const index=buttons.indexOf(document.activeElement as HTMLButtonElement),next=event.key==='Home'?0:event.key==='End'?buttons.length-1:(index+(event.key==='ArrowDown'?1:-1)+buttons.length)%buttons.length;buttons[next]?.focus();}if(event.key==='Escape')setMenu(null);}}>
|
||||
@@ -152,7 +184,7 @@ export function FileManager({active=true}:{active?:boolean}){
|
||||
{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" aria-label={preview.entry.name}><h2>{preview.entry.name}</h2>{error&&<p className="error">{error}</p>}{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&&preview&&<FileMediaPreview entries={preview.entries} initial={preview.initial} onClose={()=>setPreview(null)} onDownload={path=>void getDownload([path])}/>}
|
||||
{active&&editor&&!dialog&&<div className="modal file-editor-modal" role="dialog" aria-modal="true"><h2>{editor.path.split('/').pop()}{modified?' *':''}</h2>{error&&<p className="error">{error}</p>}<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>;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import React,{useEffect,useRef,useState} from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
import {IconChevronLeft,IconChevronRight,IconDownload,IconX,IconZoomIn,IconZoomOut,IconArrowsMaximize} from '@tabler/icons-react';
|
||||
import {managerApi} from './manager-api';
|
||||
import {t} from './i18n';
|
||||
export type MediaEntry={path:string;name:string};
|
||||
const images=new Set(['png','jpg','jpeg','gif','webp','avif','bmp']),videos=new Set(['mp4','webm','mov','m4v']);
|
||||
export function mediaKind(entry:MediaEntry){const ext=entry.name.split('.').pop()?.toLowerCase()??'';return images.has(ext)?'image':videos.has(ext)?'video':'';}
|
||||
type Transform={scale:number;x:number;y:number};
|
||||
const fitted:Transform={scale:1,x:0,y:0};
|
||||
export function FileMediaPreview({entries,initial,onClose,onDownload}:{entries:MediaEntry[];initial:number;onClose:()=>void;onDownload:(path:string)=>void}){
|
||||
const[index,setIndex]=useState(initial),[source,setSource]=useState<{path:string;url:string}|null>(null),[error,setError]=useState(''),[ready,setReady]=useState(false);
|
||||
const entry=entries[index],video=mediaKind(entry)==='video',url=source?.path===entry.path?source.url:'';
|
||||
const overlay=useRef<HTMLDivElement>(null),stage=useRef<HTMLDivElement>(null),view=useRef(fitted),points=useRef(new Map<number,{x:number;y:number}>());
|
||||
const readyState=useRef(ready);readyState.current=ready;
|
||||
const blankClick=useRef(true),pressOrigin=useRef({x:0,y:0});
|
||||
const canvas=useRef<HTMLDivElement>(null),level=useRef<HTMLOutputElement>(null),zoomIn=useRef<HTMLButtonElement>(null),zoomOut=useRef<HTMLButtonElement>(null);
|
||||
const callbacks=useRef({onClose,onDownload});callbacks.current={onClose,onDownload};
|
||||
function apply(next:Transform){view.current=next;if(canvas.current)canvas.current.style.transform=`translate(${next.x}px, ${next.y}px) scale(${next.scale})`;if(level.current)level.current.textContent=Math.round(next.scale*100)+'%';if(zoomIn.current)zoomIn.current.disabled=!readyState.current||next.scale>=8;if(zoomOut.current)zoomOut.current.disabled=!readyState.current||next.scale<=.25;}
|
||||
useEffect(()=>apply(view.current),[ready]);
|
||||
function zoom(scale:number,anchor={x:0,y:0}){const current=view.current,next=Math.min(8,Math.max(.25,scale)),ratio=next/current.scale;apply({scale:next,x:anchor.x-(anchor.x-current.x)*ratio,y:anchor.y-(anchor.y-current.y)*ratio});}
|
||||
function move(direction:number){setIndex(current=>Math.max(0,Math.min(entries.length-1,current+direction)));}
|
||||
useEffect(()=>{
|
||||
const previous=document.activeElement as HTMLElement|null;overlay.current?.focus();
|
||||
const overflow=document.body.style.overflow;document.body.style.overflow='hidden';
|
||||
return()=>{document.body.style.overflow=overflow;if(previous?.isConnected)previous.focus();};
|
||||
},[]);
|
||||
useEffect(()=>{
|
||||
setSource(null);setError('');setReady(false);apply(fitted);points.current.clear();let live=true,token:string|undefined;
|
||||
const release=()=>{if(token){void managerApi('files',{operation:'preview.release',token}).catch(()=>{});token=undefined;}};
|
||||
// A completed request is always released, even if the user closes while it is pending.
|
||||
void managerApi<{url:string;token?:string}>('files',{operation:'preview',path:entry.path}).then(result=>{token=result.token;if(live)setSource({path:entry.path,url:result.url});else release();}).catch(e=>{if(live)setError(String(e));});
|
||||
return()=>{live=false;release();};
|
||||
},[entry.path]);
|
||||
function keyDown(event:React.KeyboardEvent){
|
||||
if(event.key==='Tab'){
|
||||
const buttons=Array.from(overlay.current!.querySelectorAll<HTMLElement>('button:not(:disabled),video[controls]'));
|
||||
const first=buttons[0],last=buttons.at(-1);if(event.shiftKey&&(document.activeElement===first||document.activeElement===overlay.current)){event.preventDefault();last?.focus();}else if(!event.shiftKey&&(document.activeElement===last||document.activeElement===overlay.current)){event.preventDefault();first?.focus();}return;
|
||||
}
|
||||
// Let the native player retain its own keyboard controls.
|
||||
if((event.target as HTMLElement).tagName==='VIDEO'&&event.key!=='Escape')return;
|
||||
if(event.key==='Escape'){event.preventDefault();event.stopPropagation();callbacks.current.onClose();}
|
||||
else if(event.key==='ArrowLeft'){event.preventDefault();move(-1);}
|
||||
else if(event.key==='ArrowRight'){event.preventDefault();move(1);}
|
||||
else if(event.key==='+'||event.key==='='){event.preventDefault();zoom(view.current.scale*1.25);}
|
||||
else if(event.key==='-'){event.preventDefault();zoom(view.current.scale/1.25);}
|
||||
else if(event.key==='0'){event.preventDefault();apply(fitted);}
|
||||
}
|
||||
useEffect(()=>{const element=stage.current!;const wheel=(event:WheelEvent)=>{event.preventDefault();event.stopPropagation();const bounds=element.getBoundingClientRect();zoom(view.current.scale*Math.exp(-event.deltaY*.002),{x:event.clientX-bounds.left-bounds.width/2,y:event.clientY-bounds.top-bounds.height/2});};element.addEventListener('wheel',wheel,{passive:false});return()=>element.removeEventListener('wheel',wheel);},[]);
|
||||
function position(event:React.PointerEvent){return{x:event.clientX,y:event.clientY};}
|
||||
function geometry(){const p=[...points.current.values()];return{center:{x:(p[0].x+(p[1]?.x??p[0].x))/2,y:(p[0].y+(p[1]?.y??p[0].y))/2},distance:p[1]?Math.hypot(p[1].x-p[0].x,p[1].y-p[0].y):0};}
|
||||
function isBlank(target:HTMLElement){return target===stage.current||target.classList.contains('file-media-loading')||target.classList.contains('file-media-error');}
|
||||
function start(event:React.PointerEvent){blankClick.current=isBlank(event.target as HTMLElement);pressOrigin.current=position(event);if(points.current.size)blankClick.current=false;if(event.button!==0||!ready||(event.target as HTMLElement).closest('button'))return;const player=(event.target as HTMLElement).closest('video');if(player&&event.clientY>player.getBoundingClientRect().bottom-48)return;event.preventDefault();points.current.set(event.pointerId,position(event));event.currentTarget.setPointerCapture(event.pointerId);}
|
||||
function drag(event:React.PointerEvent){if(!points.current.has(event.pointerId))return;if(Math.hypot(event.clientX-pressOrigin.current.x,event.clientY-pressOrigin.current.y)>6)blankClick.current=false;event.preventDefault();const before=geometry();points.current.set(event.pointerId,position(event));const after=geometry(),current=view.current;
|
||||
if(before.distance&&after.distance){const bounds=stage.current!.getBoundingClientRect(),anchor={x:before.center.x-bounds.left-bounds.width/2,y:before.center.y-bounds.top-bounds.height/2};zoom(current.scale*after.distance/before.distance,anchor);}
|
||||
apply({...view.current,x:view.current.x+after.center.x-before.center.x,y:view.current.y+after.center.y-before.center.y});
|
||||
}
|
||||
function end(event:React.PointerEvent){points.current.delete(event.pointerId);if(event.currentTarget.hasPointerCapture(event.pointerId))event.currentTarget.releasePointerCapture(event.pointerId);}
|
||||
return createPortal(<div className="file-manager file-media-overlay" data-framely-no-scroll-drag ref={overlay} role="dialog" aria-modal="true" aria-label={entry.name} tabIndex={-1} onKeyDown={keyDown} onClick={event=>{const target=event.target as HTMLElement;if((isBlank(target)&&blankClick.current)||target===overlay.current||target.classList.contains('file-media-dock'))callbacks.current.onClose();}}>
|
||||
<div className="file-media-stage" ref={stage} onPointerDown={start} onPointerMove={drag} onPointerUp={end} onPointerCancel={end} onLostPointerCapture={event=>points.current.delete(event.pointerId)} onDoubleClick={event=>{if((event.target as HTMLElement).tagName!=='VIDEO')view.current.scale===1?zoom(2):apply(fitted);}}>
|
||||
{!ready&&!error&&<div className="file-media-loading" role="status">{t('加载中…')}</div>}
|
||||
{error?<div className="file-media-error" role="alert"><p>{error}</p></div>:url&&<div className="file-media-canvas" ref={canvas} style={{transform:'translate(0px, 0px) scale(1)',visibility:ready?'visible':'hidden'}}>
|
||||
{video?<video key={url} src={url} controls playsInline preload="metadata" onLoadedData={()=>setReady(true)} onError={()=>setError(t('浏览器无法播放此格式,请下载后打开。'))}/>:<img key={url} src={url} alt={entry.name} draggable={false} onLoad={()=>setReady(true)} onError={()=>setError(t('浏览器无法预览此格式,请下载后打开。'))}/>}
|
||||
</div>}
|
||||
</div>
|
||||
<footer className="file-media-dock">
|
||||
<div className="file-media-actions" role="toolbar" aria-label={t('预览操作')}>
|
||||
<button aria-label={t('上一个文件')} title={t('上一个文件')} disabled={index===0} onClick={()=>move(-1)}><IconChevronLeft size={21} strokeWidth={1.7}/></button>
|
||||
<button aria-label={t('下一个文件')} title={t('下一个文件')} disabled={index===entries.length-1} onClick={()=>move(1)}><IconChevronRight size={21} strokeWidth={1.7}/></button>
|
||||
<span className="file-media-divider" aria-hidden="true"/>
|
||||
<button ref={zoomOut} aria-label={t('缩小')} title={t('缩小')} disabled={!ready} onClick={()=>zoom(view.current.scale/1.25)}><IconZoomOut size={21} strokeWidth={1.7}/></button>
|
||||
<output ref={level} aria-label={t('缩放比例')}>100%</output>
|
||||
<button ref={zoomIn} aria-label={t('放大')} title={t('放大')} disabled={!ready} onClick={()=>zoom(view.current.scale*1.25)}><IconZoomIn size={21} strokeWidth={1.7}/></button>
|
||||
<button aria-label={t('适应窗口')} title={t('适应窗口')} disabled={!ready} onClick={()=>apply(fitted)}><IconArrowsMaximize size={20} strokeWidth={1.7}/></button>
|
||||
<span className="file-media-divider" aria-hidden="true"/>
|
||||
<button aria-label={t('下载文件')} title={t('下载文件')} onClick={()=>callbacks.current.onDownload(entry.path)}><IconDownload size={21} strokeWidth={1.7}/></button>
|
||||
<button aria-label={t('关闭')} title={t('关闭')} onClick={()=>callbacks.current.onClose()}><IconX size={21} strokeWidth={1.7}/></button>
|
||||
</div>
|
||||
<div className="file-media-caption"><p title={entry.name}>{entry.name}</p><span>{index+1} / {entries.length}</span></div>
|
||||
</footer>
|
||||
</div>,document.body);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import React,{useEffect,useRef,useState} from 'react';
|
||||
import {IconPlayerPlayFilled} from '@tabler/icons-react';
|
||||
import {managerApi} from './manager-api';
|
||||
|
||||
type Entry={path:string;name:string;directory:boolean;size:number;modified?:number;mediaRevision?:string|null};
|
||||
type Cached={url:string|null;bytes:number;refs:number;retired:boolean;failedUntil:number};
|
||||
type Watcher={notify:(url:string|null)=>void;cached?:Cached};
|
||||
type Work={key:string;entry:Entry;watchers:Set<Watcher>;controller:AbortController};
|
||||
const images=new Set(['png','jpg','jpeg','gif','webp','avif','bmp']);
|
||||
const videos=new Set(['mp4','webm','mov','m4v']);
|
||||
const cache=new Map<string,Cached>(),work=new Map<string,Work>(),queue:Work[]=[];
|
||||
const visibility=new Map<Element,(visible:boolean)=>void>();
|
||||
let observer:IntersectionObserver|undefined,active=0;
|
||||
function kind(entry:Entry){if(entry.directory)return '';const ext=entry.name.split('.').pop()?.toLowerCase()??'';return images.has(ext)?'image':videos.has(ext)?'video':'';}
|
||||
function releaseCached(value:Cached){value.refs--;if(value.retired&&value.refs===0&&value.url)URL.revokeObjectURL(value.url);}
|
||||
function retire(value:Cached){value.retired=true;if(value.refs===0&&value.url)URL.revokeObjectURL(value.url);}
|
||||
function trimCache(){let bytes=[...cache.values()].reduce((sum,item)=>sum+item.bytes,0);while(cache.size>128||bytes>16*1024*1024){const key=cache.keys().next().value!;const value=cache.get(key)!;cache.delete(key);bytes-=value.bytes;retire(value);}}
|
||||
function waitFor(element:HTMLImageElement|HTMLVideoElement,event:string,signal:AbortSignal):Promise<void>{
|
||||
signal.throwIfAborted();return new Promise((resolve,reject)=>{
|
||||
const cleanup=()=>{element.removeEventListener(event,done);element.removeEventListener('error',failed);signal.removeEventListener('abort',abort);};
|
||||
const done=()=>{cleanup();resolve();},failed=()=>{cleanup();reject(new Error('Media decoding failed'));},abort=()=>{cleanup();reject(signal.reason);};
|
||||
element.addEventListener(event,done,{once:true});element.addEventListener('error',failed,{once:true});signal.addEventListener('abort',abort,{once:true});
|
||||
});
|
||||
}
|
||||
async function snapshot(source:CanvasImageSource,width:number,height:number,signal:AbortSignal):Promise<Blob>{
|
||||
signal.throwIfAborted();if(!width||!height)throw Error('Empty media frame');
|
||||
const scale=Math.min(1,320/width,320/height),canvas=document.createElement('canvas');
|
||||
canvas.width=Math.max(1,Math.round(width*scale));canvas.height=Math.max(1,Math.round(height*scale));
|
||||
const context=canvas.getContext('2d');if(!context)throw Error('Canvas unavailable');
|
||||
context.drawImage(source,0,0,canvas.width,canvas.height);
|
||||
const blob=await new Promise<Blob>((resolve,reject)=>canvas.toBlob(value=>value?resolve(value):reject(Error('Thumbnail encoding failed')),'image/webp',.8));
|
||||
signal.throwIfAborted();return blob;
|
||||
}
|
||||
async function decode(url:string,video:boolean,signal:AbortSignal):Promise<Blob>{
|
||||
if(!video){
|
||||
const image=new Image();image.decoding='async';
|
||||
try{const loaded=waitFor(image,'load',signal);image.src=url;await loaded;return await snapshot(image,image.naturalWidth,image.naturalHeight,signal);}
|
||||
finally{image.removeAttribute('src');}
|
||||
}
|
||||
const player=document.createElement('video');player.muted=true;player.playsInline=true;player.preload='metadata';
|
||||
try{
|
||||
const metadata=waitFor(player,'loadedmetadata',signal);player.src=url;await metadata;
|
||||
if(player.duration<=0)throw Error('Empty video');
|
||||
// Some local WebM files omit duration metadata but still support seeking.
|
||||
const target=Number.isFinite(player.duration)?Math.min(1,Math.max(0,player.duration-.05)):1;
|
||||
if(target>0){const sought=waitFor(player,'seeked',signal);player.currentTime=target;await sought;}
|
||||
if(player.readyState<2)await waitFor(player,'loadeddata',signal);
|
||||
return await snapshot(player,player.videoWidth,player.videoHeight,signal);
|
||||
}finally{player.pause();player.removeAttribute('src');player.load();}
|
||||
}
|
||||
async function generate(task:Work):Promise<Blob>{
|
||||
const signal=task.controller.signal;let token:string|undefined;
|
||||
try{
|
||||
const result=await managerApi<{url:string;token?:string}>('files',{operation:'preview',path:task.entry.path},signal);token=result.token;
|
||||
signal.throwIfAborted();return await decode(result.url,kind(task.entry)==='video',signal);
|
||||
}finally{
|
||||
// The cached thumbnail no longer needs the source media token. Source bytes
|
||||
// remain on Frame except for this normal authenticated browser preview.
|
||||
if(token)void managerApi('files',{operation:'preview.release',token}).catch(()=>{});
|
||||
}
|
||||
}
|
||||
function pump(){
|
||||
while(active<2&&queue.length){
|
||||
const task=queue.shift()!;if(!task.watchers.size||task.controller.signal.aborted)continue;active++;
|
||||
const timeout=setTimeout(()=>task.controller.abort(new DOMException('Thumbnail timed out','TimeoutError')),10000);
|
||||
void generate(task).then(blob=>{
|
||||
if(task.controller.signal.aborted||!task.watchers.size)return;
|
||||
const cached:Cached={url:URL.createObjectURL(blob),bytes:blob.size,refs:0,retired:false,failedUntil:0};
|
||||
cache.set(task.key,cached);for(const watcher of task.watchers){watcher.cached=cached;cached.refs++;watcher.notify(cached.url);}trimCache();
|
||||
}).catch(()=>{
|
||||
if(task.controller.signal.aborted||!task.watchers.size)return;
|
||||
const cached:Cached={url:null,bytes:0,refs:0,retired:false,failedUntil:Date.now()+60000};
|
||||
cache.set(task.key,cached);for(const watcher of task.watchers){watcher.cached=cached;cached.refs++;watcher.notify(null);}trimCache();
|
||||
}).finally(()=>{clearTimeout(timeout);if(work.get(task.key)===task)work.delete(task.key);active--;pump();});
|
||||
}
|
||||
}
|
||||
function subscribe(key:string,entry:Entry,notify:Watcher['notify']){
|
||||
const cached=cache.get(key);
|
||||
if(cached&&(!cached.failedUntil||cached.failedUntil>Date.now())){
|
||||
cache.delete(key);cache.set(key,cached);cached.refs++;notify(cached.url);return()=>releaseCached(cached);
|
||||
}
|
||||
if(cached){cache.delete(key);retire(cached);}
|
||||
let task=work.get(key);
|
||||
if(!task||task.controller.signal.aborted){task={key,entry,watchers:new Set(),controller:new AbortController()};work.set(key,task);queue.push(task);}
|
||||
const watcher:Watcher={notify};task.watchers.add(watcher);pump();
|
||||
return()=>{task!.watchers.delete(watcher);if(watcher.cached)releaseCached(watcher.cached);else if(!task!.watchers.size){task!.controller.abort();const index=queue.indexOf(task!);if(index>=0)queue.splice(index,1);if(work.get(key)===task)work.delete(key);}};
|
||||
}
|
||||
export function FileThumbnail({entry,children,active=true}:{entry:Entry;children:React.ReactNode;active?:boolean}){
|
||||
const node=useRef<HTMLSpanElement>(null),[visible,setVisible]=useState(false),[url,setUrl]=useState<string|null>(null);
|
||||
const type=kind(entry),key=JSON.stringify([entry.path,entry.mediaRevision??[entry.size,entry.modified],type]);
|
||||
useEffect(()=>{
|
||||
if(!type||!node.current||typeof IntersectionObserver==='undefined')return;
|
||||
observer??=new IntersectionObserver(entries=>{for(const item of entries)visibility.get(item.target)?.(item.isIntersecting);},{threshold:.01});
|
||||
const target=node.current;visibility.set(target,setVisible);observer.observe(target);
|
||||
return()=>{observer?.unobserve(target);visibility.delete(target);};
|
||||
},[type]);
|
||||
useEffect(()=>{
|
||||
setUrl(null);if(!active||!visible||!type)return;
|
||||
let dispose:(()=>void)|undefined,live=true;
|
||||
const timer=setTimeout(()=>{dispose=subscribe(key,entry,value=>{if(live)setUrl(value);});},120);
|
||||
return()=>{live=false;clearTimeout(timer);dispose?.();};
|
||||
},[key,visible,type,active]);
|
||||
return <span className="file-thumbnail" ref={node}>{url?<><img src={url} className="file-media-thumbnail" alt="" draggable={false}/>{type==='video'&&<IconPlayerPlayFilled className="file-video-marker" size={14} aria-hidden="true"/>}</>:children}</span>;
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
import {t} from './i18n';
|
||||
export class ManagerError extends Error {constructor(public readonly raw:string){super(t(raw));}}
|
||||
export async function managerApi<T=any>(area:string,params:unknown={}):Promise<T>{const r=await fetch('/manager-api/'+area,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(params)});if(r.status===401){location.replace('/manager');throw new Error(t('请重新登录'));}if(!r.ok)throw new Error(t('服务请求失败:{0}',{0:r.status}));const v=await r.json();if(v.error)throw new ManagerError(v.error);return v.result;}
|
||||
export async function managerApi<T=any>(area:string,params:unknown={},signal?:AbortSignal):Promise<T>{const r=await fetch('/manager-api/'+area,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(params),signal});if(r.status===401){location.replace('/manager');throw new Error(t('请重新登录'));}if(!r.ok)throw new Error(t('服务请求失败:{0}',{0:r.status}));const v=await r.json();if(v.error)throw new ManagerError(v.error);return v.result;}
|
||||
Reference in new issue
Block a user