Show storage usage details and refine storage settings for 0.7.1

This commit is contained in:
toorux committed 2026-10-11 12:21:10 +08:00
1 parent a9d61df041
commit d3628b0bcf
11 files changed
+328 -48

No files matched your search

Generated
+1 -1
View File
@@ -425,7 +425,7 @@ dependencies = [
[[package]]
name = "framely"
version = "0.7.0-preview.2"
version = "0.7.1"
dependencies = [
"anyhow",
"base64",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "framely"
version = "0.7.0-preview.2"
version = "0.7.1"
edition = "2021"
license = "AGPL-3.0-only"
+3 -3
View File
@@ -38,7 +38,7 @@ 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.7.0-preview.2`), `.github/workflows/release.yml`, Linux ARM64 and pinned CEF.
- Device: root Cargo version, `v<version>` (currently `v0.7.1`), `.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,8 +47,8 @@ Both workflows cache dependencies/builds by product/platform/toolchain/configura
```bash
# After committing and pushing the intended source:
git tag v0.7.0-preview.2
git push origin v0.7.0-preview.2
git tag v0.7.1
git push origin v0.7.1
# Independent installer release:
git tag installer-v0.4.1-preview.10
git push origin installer-v0.4.1-preview.10
+3 -3
View File
@@ -48,7 +48,7 @@ CEF 按内容哈希保存在 `/home/.framely/cef/<运行库标识>`,更新时
Framely 和桌面安装器保留在同一仓库,使用独立的版本号、标签和发布工作流:
- Framely:版本由根 `Cargo.toml` 定义,标签为 `v<版本>`(当前 `v0.7.0-preview.2`);`.github/workflows/release.yml` 只构建 Frame Linux ARM64 发行,使用锁定的 CEF 并执行自动检查。
- Framely:版本由根 `Cargo.toml` 定义,标签为 `v<版本>`(当前 `v0.7.1`);`.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,8 +59,8 @@ Framely 和桌面安装器保留在同一仓库,使用独立的版本号、标
```bash
# 提交并推送代码后,选择需要发布的产品
git tag v0.7.0-preview.2
git push origin v0.7.0-preview.2
git tag v0.7.1
git push origin v0.7.1
# 独立发布安装器,不触发 Framely 构建
git tag installer-v0.4.1-preview.10
+45 -3
View File
@@ -1003,20 +1003,40 @@ pub fn storage_status(home: &Path) -> Result<Value> {
let db = load(home)?;
let mut context_bytes = 0u64;
let mut contexts_incomplete = false;
let mut context_details = Vec::new();
for id in &db.owned_contexts {
if safe(id) {
let (size, incomplete) = crate::storage_location::display_bytes(&contexts.join(id))?;
let (size, incomplete) = crate::storage_location::disk_bytes(&contexts.join(id))?;
context_bytes = context_bytes.saturating_add(size);
contexts_incomplete |= incomplete;
context_details.push(json!({"id":id,"name":id,"bytes":size,"sizeIncomplete":incomplete}));
}
}
let (apk_bytes, apk_incomplete) = crate::storage_location::display_bytes(&apk)?;
let (apk_bytes, apk_incomplete) = crate::storage_location::disk_bytes(&apk)?;
let mut apk_details = Vec::new();
if let Ok(entries) = fs::read_dir(apk.join("apks")) {
for entry in entries {
let entry = match entry { Ok(entry) => entry, Err(_) => continue };
let path = entry.path();
if !entry.file_type().is_ok_and(|kind| kind.is_dir()) { continue; }
let key = entry.file_name();
let record = db.records.values().find(|record| hash(&record.id) == key.to_string_lossy());
let (size, incomplete) = crate::storage_location::disk_bytes(&path)?;
let name = record.map_or_else(|| key.to_string_lossy().into_owned(), |record| record.metadata.name.clone());
let id = record.map_or_else(|| key.to_string_lossy().into_owned(), |record| record.id.clone());
apk_details.push(json!({"id":id,"name":name,"bytes":size,"sizeIncomplete":incomplete}));
}
}
apk_details.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
let apk_device = fs::metadata(&apk).ok().map(|m| m.dev());
let context_device = fs::metadata(&contexts).ok().map(|m| m.dev());
let volume = crate::storage_location::volume(home)?;
let journal = fs::read(storage_journal_path(home)).ok().and_then(|b|serde_json::from_slice::<Value>(&b).ok());
let committed = journal.as_ref().is_some_and(|v| v["target"] == json!(if v["kind"] == "apk" { &apk } else { &contexts }));
if committed {
let _ = fs::remove_file(storage_journal_path(home));
}
Ok(json!({"apk":{"path":apk,"bytes":apk_bytes,"sizeIncomplete":apk_incomplete},"contexts":{"path":contexts,"bytes":context_bytes,"sizeIncomplete":contexts_incomplete},"interrupted":if committed {None} else {journal}}))
Ok(json!({"apk":{"path":apk,"bytes":apk_bytes,"sizeIncomplete":apk_incomplete,"device":apk_device,"details":apk_details},"contexts":{"path":contexts,"bytes":context_bytes,"sizeIncomplete":contexts_incomplete,"device":context_device,"details":context_details},"volume":volume,"interrupted":if committed {None} else {journal}}))
}
pub fn migrate_storage(home: &Path, kind: &str, target: &Path, cancel: Cancellation, progress: Arc<dyn Fn(Value) + Send + Sync>) -> Result<Value> {
ensure!(matches!(kind, "apk" | "contexts"), "无效存储类型");
@@ -1100,6 +1120,28 @@ pub fn migrate_storage(home: &Path, kind: &str, target: &Path, cancel: Cancellat
mod storage_migration_tests {
use super::*;
#[test]
fn storage_status_lists_apk_and_managed_container_sizes() {
let _serial = TEST_SERIAL.lock().unwrap();
let temp = tempfile::tempdir().unwrap();
let home = temp.path().join("home");
fs::create_dir(&home).unwrap();
let apk_root = init(&home).unwrap();
fs::create_dir_all(apk_root.join("apks/example")).unwrap();
fs::write(apk_root.join("apks/example/base.apk"), vec![1; 8192]).unwrap();
let container = contexts_root(&home).join("managed");
fs::create_dir_all(&container).unwrap();
fs::write(container.join("data"), vec![2; 8192]).unwrap();
let mut db = load(&home).unwrap();
db.owned_contexts.push("managed".into());
save(&home, &db).unwrap();
let status = storage_status(&home).unwrap();
assert_eq!(status["apk"]["details"][0]["id"], "example");
assert!(status["apk"]["details"][0]["bytes"].as_u64().unwrap() > 0);
assert_eq!(status["contexts"]["details"][0]["id"], "managed");
assert!(status["contexts"]["details"][0]["bytes"].as_u64().unwrap() > 0);
assert!(status["volume"]["total"].as_u64().unwrap() > 0);
}
#[test]
fn apk_and_owned_contexts_move_independently() {
let _serial = TEST_SERIAL.lock().unwrap();
let temp = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap();
+12 -1
View File
@@ -793,7 +793,18 @@ impl Service {
}
let id = p["plugin"].as_str().unwrap_or("").to_owned();
match method {
"storage.plugin.status" => Ok(json!({"path":self.plugin_store(),"bytes":crate::storage_location::bytes(&self.plugin_store().join("plugins"))? + crate::storage_location::bytes(&self.plugin_store().join("data"))?,"available":self.ensure_plugin_store().is_ok(),"migrating":self.plugin_migration,"interrupted":fs::read(self.root.join("plugin-storage-migration.json")).ok().and_then(|b|serde_json::from_slice::<Value>(&b).ok()).map(|v|v["target"].clone())})),
"storage.plugin.status" => {
let store = self.plugin_store();
let (program_bytes, program_incomplete) = crate::storage_location::disk_bytes(&store.join("plugins"))?;
let (data_bytes, data_incomplete) = crate::storage_location::disk_bytes(&store.join("data"))?;
let details = self.db.plugins.iter().map(|(id, installed)| {
let (program, a) = crate::storage_location::disk_bytes(&store.join("plugins").join(id))?;
let (data, b) = crate::storage_location::disk_bytes(&store.join("data").join(id))?;
Ok(json!({"id":id,"name":installed.manifest.name,"bytes":program.saturating_add(data),"programBytes":program,"dataBytes":data,"sizeIncomplete":a || b}))
}).collect::<Result<Vec<Value>>>()?;
let device = fs::metadata(store).ok().map(|m| std::os::unix::fs::MetadataExt::dev(&m));
Ok(json!({"path":store,"bytes":program_bytes.saturating_add(data_bytes),"sizeIncomplete":program_incomplete || data_incomplete,"device":device,"details":details,"available":self.ensure_plugin_store().is_ok(),"migrating":self.plugin_migration,"interrupted":fs::read(self.root.join("plugin-storage-migration.json")).ok().and_then(|b|serde_json::from_slice::<Value>(&b).ok()).map(|v|v["target"].clone())}))
},
"host.ui.visibility" => {
let views = serde_json::from_value(p)?;
if self.visibility.report(views, Instant::now())? {
+95 -2
View File
@@ -3,6 +3,71 @@ use serde_json::Value;
use sha2::{Digest, Sha256};
use std::{collections::BTreeMap, ffi::CString, fs, io::Read, os::unix::fs::{MetadataExt, FileTypeExt, symlink, PermissionsExt}, path::{Path, PathBuf}, process::Command};
/// Disk blocks used by a tree. The root may be an intentional storage link
/// (the default plugin directories are links); nested links and mounts are skipped.
pub fn disk_bytes(path: &Path) -> Result<(u64, bool)> {
let root = match fs::symlink_metadata(path) {
Ok(meta) => meta,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok((0, false)),
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => return Ok((0, true)),
Err(error) => return Err(error.into()),
};
let path = if root.file_type().is_symlink() {
match path.canonicalize() {
Ok(path) => path,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok((0, true)),
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => return Ok((0, true)),
Err(error) => return Err(error.into()),
}
} else { path.to_path_buf() };
let root = fs::symlink_metadata(&path)?;
let mut seen = std::collections::HashSet::new();
let mut total = 0u64;
let mut incomplete = false;
fn walk(path: &Path, dev: u64, seen: &mut std::collections::HashSet<(u64, u64)>, total: &mut u64, incomplete: &mut bool) -> Result<()> {
let meta = match fs::symlink_metadata(path) {
Ok(meta) => meta,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => { *incomplete = true; return Ok(()); }
Err(error) => return Err(error.into()),
};
if meta.dev() != dev || !seen.insert((meta.dev(), meta.ino())) { return Ok(()); }
*total = total.saturating_add(meta.blocks().saturating_mul(512));
if meta.is_dir() && !meta.file_type().is_symlink() {
let entries = match fs::read_dir(path) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => { *incomplete = true; return Ok(()); }
Err(error) => return Err(error.into()),
};
for entry in entries {
match entry {
Ok(entry) => walk(&entry.path(), dev, seen, total, incomplete)?,
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => *incomplete = true,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {},
Err(error) => return Err(error.into()),
}
}
}
Ok(())
}
walk(&path, root.dev(), &mut seen, &mut total, &mut incomplete)?;
Ok((total, incomplete))
}
pub fn volume(path: &Path) -> Result<Value> {
let device = fs::metadata(path)?.dev();
let path = std::ffi::CString::new(path.as_os_str().as_encoded_bytes())?;
let mut stat = std::mem::MaybeUninit::<libc::statvfs>::uninit();
let result = unsafe { libc::statvfs(path.as_ptr(), stat.as_mut_ptr()) };
if result != 0 { return Err(std::io::Error::last_os_error().into()); }
let stat = unsafe { stat.assume_init() };
let block = stat.f_frsize as u64;
let total = (stat.f_blocks as u64).saturating_mul(block);
let free = (stat.f_bfree as u64).saturating_mul(block);
Ok(serde_json::json!({"total":total,"free":free,"device":device}))
}
pub fn bytes(path: &Path) -> Result<u64> {
let meta = match fs::symlink_metadata(path) {
Ok(meta) => meta,
@@ -23,8 +88,8 @@ pub fn bytes(path: &Path) -> Result<u64> {
Ok(total)
}
/// The settings page can show a lower bound when a container contains
/// root-owned overlay work directories. Migration still uses strict `bytes`.
/// Legacy logical-size check retained for the migration permission test.
#[cfg(test)]
pub fn display_bytes(path: &Path) -> Result<(u64, bool)> {
let meta = match fs::symlink_metadata(path) {
Ok(meta) => meta,
@@ -364,4 +429,32 @@ mod tests {
assert_eq!(fs::read(copied_work.join("overlay-state")).unwrap(), b"private");
fs::remove_dir_all(target).unwrap();
}
#[test]
fn disk_usage_does_not_follow_links_or_count_hardlinks_twice() {
let temp = tempfile::tempdir().unwrap();
let file = temp.path().join("payload");
fs::write(&file, vec![7; 8192]).unwrap();
fs::hard_link(&file, temp.path().join("same-blocks")).unwrap();
symlink(&file, temp.path().join("shortcut")).unwrap();
let (used, incomplete) = disk_bytes(temp.path()).unwrap();
let root_blocks = fs::metadata(temp.path()).unwrap().blocks() * 512;
let file_blocks = fs::metadata(&file).unwrap().blocks() * 512;
let link_blocks = fs::symlink_metadata(temp.path().join("shortcut")).unwrap().blocks() * 512;
assert_eq!(used, root_blocks + file_blocks + link_blocks);
assert!(!incomplete);
}
#[test]
fn disk_usage_follows_storage_root_link_but_not_nested_links() {
let temp = tempfile::tempdir().unwrap();
let target = temp.path().join("real-plugins");
fs::create_dir(&target).unwrap();
fs::write(target.join("plugin"), vec![3; 8192]).unwrap();
let external = temp.path().join("unrelated");
fs::write(&external, vec![4; 8192]).unwrap();
symlink(&external, target.join("nested-link")).unwrap();
let root = temp.path().join("plugins");
symlink(&target, &root).unwrap();
assert_eq!(disk_bytes(&root).unwrap(), disk_bytes(&target).unwrap());
assert!(disk_bytes(&root).unwrap().0 > 0);
}
}
+18 -1
View File
@@ -1262,6 +1262,23 @@
"目标须为空目录,并支持 Linux 文件权限。迁移期间相关程序会暂时停止。": "The destination must be empty and support Linux file permissions. Affected programs will pause during migration.",
"迁移到新位置": "Move to new location",
"APK 文件": "APK files",
"安卓容器": "Android containers"
"安卓容器": "Android containers",
"存储占用": "Storage usage",
"头显用户存储卷": "Headset user storage",
"总容量": "Total capacity",
"已使用": "Used",
"系统及其他": "System and other",
"空闲": "Free",
"已迁移到其他磁盘的数据仅显示在下方明细,不计入此卷。": "Data moved to another drive appears in the details below and is excluded from this volume.",
"部分目录无法读取,Framely 占用可能低于实际值。": "Some folders could not be read, so Framely usage may be understated.",
"占用明细": "Usage details",
"程序": "Program",
"数据": "Data",
"备份、上传文件及其他": "Backups, uploads and other files",
"其他插件文件": "Other plugin files",
"其他容器文件": "Other container files",
"暂无数据": "No data yet",
"其他磁盘": "Other drive",
"{0} 项": "{0} items"
}
}
+18 -1
View File
@@ -1262,6 +1262,23 @@
"目标须为空目录,并支持 Linux 文件权限。迁移期间相关程序会暂时停止。": "目标须为空目录,并支持 Linux 文件权限。迁移期间相关程序会暂时停止。",
"迁移到新位置": "迁移到新位置",
"APK 文件": "APK 文件",
"安卓容器": "安卓容器"
"安卓容器": "安卓容器",
"存储占用": "存储占用",
"头显用户存储卷": "头显用户存储卷",
"总容量": "总容量",
"已使用": "已使用",
"系统及其他": "系统及其他",
"空闲": "空闲",
"已迁移到其他磁盘的数据仅显示在下方明细,不计入此卷。": "已迁移到其他磁盘的数据仅显示在下方明细,不计入此卷。",
"部分目录无法读取,Framely 占用可能低于实际值。": "部分目录无法读取,Framely 占用可能低于实际值。",
"占用明细": "占用明细",
"程序": "程序",
"数据": "数据",
"备份、上传文件及其他": "备份、上传文件及其他",
"其他插件文件": "其他插件文件",
"其他容器文件": "其他容器文件",
"暂无数据": "暂无数据",
"其他磁盘": "其他磁盘",
"{0} 项": "{0} 项"
}
}
+69 -18
View File
@@ -1,25 +1,74 @@
.storage-settings{width:100%;min-width:0}
.storage-settings>.list-heading{margin-bottom:28px}
.storage-section{margin-bottom:0}
.storage-section-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:24px}
.storage-section-heading h2{margin:0 0 4px}
.storage-section-heading .sub{margin:0;font-size:18px}
.storage-size{flex:none;color:var(--muted);font-size:18px;font-variant-numeric:tabular-nums;white-space:nowrap}
.storage-location{display:flex;align-items:center;justify-content:space-between;gap:24px;margin-top:24px}
.storage-location>div{display:grid;gap:7px;min-width:0}
.storage-location span{color:var(--muted);font-size:16px}
.storage-location code,.storage-warning code{font:17px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace;overflow-wrap:anywhere;word-break:break-word}
.storage-location button{flex:none}
.storage-warning{margin:18px 0 0;padding:12px 16px;border-left:3px solid #c6a779;background:#2d2923;color:#ecd7b8;font-size:17px;line-height:1.5}
.storage-overview{margin-bottom:16px}
.storage-overview-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:24px}
.storage-overview-heading h2{margin:0 0 4px}
.storage-overview-heading p{margin:0}
.storage-overview-total{display:grid;text-align:right;line-height:1.2}
.storage-overview-total strong{font-size:30px;font-weight:600;font-variant-numeric:tabular-nums;letter-spacing:-.04em}
.storage-overview-total span{margin-top:6px;color:var(--muted);font-size:15px}
.storage-meter{display:flex;width:100%;height:15px;margin-top:28px;border-radius:100px;overflow:hidden;background:#34383e}
.storage-meter span{height:100%;flex:none;min-width:0}
.storage-meter-plugin,.storage-dot-plugin{background:#93c5ed}
.storage-meter-apk,.storage-dot-apk{background:#79aaa4}
.storage-meter-contexts,.storage-dot-contexts{background:#c1a879}
.storage-meter-other,.storage-dot-other{background:#626871}
.storage-meter-free,.storage-dot-free{background:#34383e}
.storage-overview-usage{display:flex;justify-content:space-between;gap:16px;margin:11px 0 25px;color:var(--muted);font-size:15px}
.storage-overview-usage strong{color:var(--text);font-weight:550;font-variant-numeric:tabular-nums}
.storage-legend{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:0 22px;border-top:1px solid var(--line)}
.storage-legend-item{display:flex;align-items:center;gap:11px;min-width:0;padding:13px 0;border-bottom:1px solid var(--line);font-size:16px}
.storage-legend-item i{width:9px;height:9px;border-radius:50%;flex:none}
.storage-legend-item span{min-width:0;flex:1;color:var(--muted)}
.storage-legend-item small{font-size:13px;color:var(--muted)}
.storage-legend-item strong{font-weight:550;white-space:nowrap;font-variant-numeric:tabular-nums}
.storage-overview-note{margin:15px 0 0;color:var(--muted);font-size:15px;line-height:1.5}
/* Category identity stays in the left rail; paths and drill-down data live together on the right. */
.storage-section{padding:32px 0 36px;margin:0}
.storage-section-layout{display:grid;grid-template-columns:minmax(210px,27%) minmax(0,1fr);column-gap:clamp(28px,5vw,88px)}
.storage-section-heading{display:flex;flex-direction:column;align-items:flex-start;min-width:0}
.storage-kind-icon{display:grid;place-items:center;width:46px;height:46px;margin-bottom:17px;border:1px solid #4a5560;border-radius:5px;color:var(--blue);background:#93c5ed10}
.storage-kind-apk{color:#9ac6c0;border-color:#445953;background:#79aaa410}
.storage-kind-contexts{color:#dbc294;border-color:#605543;background:#c1a87910}
.storage-section-heading h2{margin:0 0 5px;font-size:25px;line-height:1.2}
.storage-section-heading .sub{max-width:260px;margin:0;color:var(--muted);font-size:16px;line-height:1.45}
.storage-size{margin-top:24px;color:var(--text);font-size:29px;font-weight:600;line-height:1.15;letter-spacing:-.035em;font-variant-numeric:tabular-nums;white-space:nowrap}
.storage-section-content{min-width:0}
.storage-location{display:grid;grid-template-columns:22px minmax(0,1fr) auto;align-items:center;gap:15px;min-width:0;min-height:84px;padding:15px 0;border-bottom:1px solid var(--line)}
.storage-location>svg{align-self:start;margin-top:8px;color:var(--muted)}
.storage-location>div{display:grid;gap:5px;min-width:0}
.storage-location span{color:var(--muted);font-size:14px;line-height:1.2}
.storage-location code,.storage-warning code{font:16px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace;overflow-wrap:anywhere;word-break:break-word}
.storage-location button{min-height:44px;padding:9px 14px;flex:none;white-space:nowrap;font-size:16px}
.storage-section .storage-details{margin:0;padding:0;border:0}
.storage-section .storage-details summary{display:flex;align-items:center;gap:12px;min-height:58px;padding:10px 0;color:var(--text);font-size:16px;font-weight:550;list-style:none;cursor:pointer}
.storage-section .storage-details summary::-webkit-details-marker{display:none}
.storage-section .storage-details summary:hover{color:var(--blue)}
.storage-section .storage-details summary small{margin-left:auto;color:var(--muted);font-size:14px;font-weight:400;font-variant-numeric:tabular-nums}
.storage-section .storage-details summary svg{color:var(--muted);transition:transform .15s ease}
.storage-section .storage-details[open] summary svg{transform:rotate(90deg);color:var(--blue)}
.storage-detail-list{margin:0 0 8px 22px;padding:3px 18px 3px 20px;border-left:2px solid #536273;background:#ffffff07}
.storage-detail-row{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:20px;min-height:61px;padding:11px 0}
.storage-detail-row+.storage-detail-row{border-top:1px solid #ffffff14}
.storage-detail-row>div{display:flex;flex-direction:column;min-width:0;gap:3px}
.storage-detail-row strong{font-size:16px;font-weight:550;line-height:1.4;overflow-wrap:anywhere}
.storage-detail-row small{font-size:13px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.storage-detail-row>span{flex:none;font-size:16px;font-weight:550;font-variant-numeric:tabular-nums;white-space:nowrap}
.storage-detail-other strong{color:var(--muted)}
.storage-empty{margin:0;padding:18px 0;color:var(--muted);font-size:16px}
.storage-warning{margin:14px 0 0;padding:12px 16px;border-left:3px solid #c6a779;background:#2d2923;color:#ecd7b8;font-size:16px;line-height:1.5}
.storage-warning code{display:block;margin-top:6px;color:inherit}
.storage-change{margin-top:24px;padding-top:24px;border-top:1px solid var(--line);max-width:860px}
.storage-change label{margin:0 0 9px;font-size:18px}
.storage-change{margin:0;padding:20px 0 22px;border-bottom:1px solid var(--line)}
.storage-change label{margin:0 0 10px;font-size:16px;font-weight:550}
.storage-target{display:flex;align-items:stretch;gap:10px}
.storage-target input{min-width:0;flex:1;width:auto!important;margin:0!important;font:17px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace}
.storage-target button{flex:none}
.storage-change>.sub{margin:10px 0 0;font-size:16px;line-height:1.5}
.storage-actions{display:flex;justify-content:flex-end;margin-top:18px}
.storage-progress{display:flex;align-items:center;gap:10px;margin-top:18px;color:var(--blue);font-size:17px}
.storage-target input{min-width:0;flex:1;width:auto!important;margin:0!important;font:16px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace}
.storage-target button{flex:none;white-space:nowrap;font-size:16px}
.storage-change>.sub{margin:10px 0 0;font-size:14px;line-height:1.5}
.storage-actions{display:flex;justify-content:flex-end;margin-top:17px}
.storage-actions button{white-space:nowrap;font-size:16px}
.storage-progress{display:flex;align-items:center;gap:10px;margin:14px 0;padding:12px 0;color:var(--blue);font-size:16px}
.storage-location button:active:not(:disabled),.storage-target button:active:not(:disabled),.storage-actions button:active:not(:disabled){transform:scale(.98)}
@media(prefers-reduced-motion:no-preference){.storage-progress svg{animation:storage-turn 1.4s linear infinite}}
@keyframes storage-turn{to{transform:rotate(360deg)}}
.storage-loading{display:grid}
@@ -43,4 +92,6 @@
.storage-picker-folders p{padding:22px 10px}
.storage-picker footer{display:flex;justify-content:flex-end;gap:10px;padding-top:14px;border-top:1px solid var(--line)}
.storage-picker .error{margin:0}
@media(max-width:700px){.storage-section-heading{gap:12px}.storage-size{font-size:16px}.storage-location{align-items:flex-start;flex-direction:column;gap:15px}.storage-location button{align-self:flex-start}.storage-target{flex-wrap:wrap}.storage-target input{flex-basis:100%;width:100%!important}.storage-target button{width:100%}.storage-actions button{width:100%}.storage-picker{max-height:calc(100dvh - 24px);padding:20px}.storage-picker footer button{flex:1;min-width:0;white-space:nowrap}}
@media(max-width:900px){.storage-section-layout{grid-template-columns:1fr;gap:20px}.storage-section-heading{display:grid;grid-template-columns:46px minmax(0,1fr) auto;column-gap:14px;align-items:center}.storage-kind-icon{margin:0;grid-row:1 / span 2}.storage-section-heading h2{font-size:22px}.storage-section-heading .sub{font-size:15px;max-width:none}.storage-size{margin:0;grid-column:3;grid-row:1 / span 2;font-size:22px}.storage-section-content{padding-left:60px}}
@media(max-width:700px){.storage-overview-total strong{font-size:24px}.storage-legend{grid-template-columns:1fr 1fr;gap:0 14px}.storage-legend-item{font-size:14px;gap:7px}.storage-legend-item strong{font-size:14px}.storage-section{padding:27px 0 31px}.storage-section-layout{gap:16px}.storage-section-content{padding-left:0}.storage-location{grid-template-columns:20px minmax(0,1fr);gap:12px}.storage-location>svg{margin-top:4px}.storage-location button{grid-column:2;justify-self:start;margin-top:4px}.storage-detail-list{margin-left:8px;padding-left:16px;padding-right:8px}.storage-target{flex-wrap:wrap}.storage-target input{flex-basis:100%;width:100%!important}.storage-target button{width:100%}.storage-actions button{width:100%}.storage-picker{max-height:calc(100dvh - 24px);padding:20px}.storage-picker footer button{flex:1;min-width:0;white-space:nowrap}}
@media(prefers-reduced-motion:reduce){.storage-section .storage-details summary svg{transition:none}}
+63 -14
View File
@@ -1,17 +1,20 @@
import React, {useEffect, useState} from 'react';
import {IconArrowLeft, IconFolder, IconFolderOpen, IconLoader2, IconX} from '@tabler/icons-react';
import {IconArrowLeft, IconChevronRight, IconDeviceMobile, IconFolder, IconFolderOpen, IconLoader2, IconPackage, IconPuzzle, IconX} from '@tabler/icons-react';
import {api} from './api';
import {managerApi} from './manager-api';
import {t} from './i18n';
import './storage-settings.css';
type Kind = 'plugin' | 'apk' | 'contexts';
type Location = {path: string; bytes: number; sizeIncomplete?: boolean; available?: boolean; interrupted?: string | null};
type Detail = {id: string; name: string; bytes: number; programBytes?: number; dataBytes?: number; sizeIncomplete?: boolean};
type Location = {path: string; bytes: number; device?: number | null; details?: Detail[]; sizeIncomplete?: boolean; available?: boolean; interrupted?: string | null};
type Places = Record<Kind, Location>;
type Volume = {total: number; free: number; device: number};
type Job = {phase: string; step?: string; error?: string; result?: {path: string; cleanupWarnings?: string[]}};
type Listing = {path: string; parent: string | null; roots: {name: string; path: string}[]; entries: {name: string; path: string; directory: boolean}[]};
const kinds: Kind[] = ['plugin', 'apk', 'contexts'];
const kindIcons = {plugin: IconPuzzle, apk: IconPackage, contexts: IconDeviceMobile};
const labels: Record<Kind, {title: string; description: string; folder: string}> = {
plugin: {title: '插件', description: '插件程序和运行数据', folder: 'Framely-Plugins'},
apk: {title: 'APK 文件', description: '安装包、上传文件和备份', folder: 'Framely-APK'},
@@ -61,6 +64,7 @@ function FolderBrowser({close, choose}: {close: () => void; choose: (path: strin
export function StorageSettings() {
const [places, setPlaces] = useState<Places | null>(null);
const [volume, setVolume] = useState<Volume | null>(null);
const [paths, setPaths] = useState<Record<Kind, string>>({plugin: '', apk: '', contexts: ''});
const [editing, setEditing] = useState<Kind | null>(null);
const [picker, setPicker] = useState<Kind | null>(null);
@@ -74,7 +78,7 @@ export function StorageSettings() {
async function refresh() {
const [plugin, other] = await Promise.all([
api<Location>('storage.plugin.status'),
api<{apk: Location; contexts: Location; interrupted?: {kind: Kind; target: string} | null}>('apk.storage.status'),
api<{apk: Location; contexts: Location; volume: Volume; interrupted?: {kind: Kind; target: string} | null}>('apk.storage.status'),
]);
const next: Places = {
plugin,
@@ -82,6 +86,7 @@ export function StorageSettings() {
contexts: {...other.contexts, interrupted: other.interrupted?.kind === 'contexts' ? other.interrupted.target : null},
};
setPlaces(next);
setVolume(other.volume);
setPaths(previous => ({
plugin: previous.plugin || plugin.interrupted || plugin.path,
apk: previous.apk || next.apk.interrupted || next.apk.path,
@@ -136,24 +141,68 @@ export function StorageSettings() {
{notice && <p className="banner" role="status">{notice}</p>}
{error && <p className="error" role="alert">{error}{!places && <button onClick={() => void reload()}>{t('重试')}</button>}</p>}
{loading && !places && <div className="storage-loading" role="status" aria-label={t('正在读取存储位置')}>{kinds.map(kind => <div key={kind}><i/><span/><small/></div>)}</div>}
{places && volume && (() => {
const onVolume = (kind: Kind) => places[kind].bytes === 0 || places[kind].device === volume.device;
const managed = kinds.reduce((sum, kind) => sum + (onVolume(kind) ? places[kind].bytes : 0), 0);
const free = Math.min(volume.free, volume.total);
const used = Math.max(0, volume.total - free);
const other = Math.max(0, used - managed);
const segments = [
...kinds.map(kind => ({key: kind, label: t(labels[kind].title), bytes: onVolume(kind) ? places[kind].bytes : 0, displayBytes: places[kind].bytes, moved: !onVolume(kind)})),
{key: 'other', label: t('系统及其他'), bytes: other, displayBytes: other, moved: false},
{key: 'free', label: t('空闲'), bytes: free, displayBytes: free, moved: false},
];
return <section className="settings-card storage-overview" aria-labelledby="storage-overview-title">
<div className="storage-overview-heading"><div><h2 id="storage-overview-title">{t('存储占用')}</h2><p className="sub">{t('头显用户存储卷')}</p></div><div className="storage-overview-total"><strong>{size(volume.total)}</strong><span>{t('总容量')}</span></div></div>
<div className="storage-meter" role="img" aria-label={`${t('已使用')} ${size(used)},${t('空闲')} ${size(free)}`}>{segments.map(segment => <span key={segment.key} className={`storage-meter-${segment.key}`} style={{width: `${volume.total ? segment.bytes / volume.total * 100 : 0}%`}} title={`${segment.label} ${size(segment.bytes)}`}/>)}</div>
<div className="storage-overview-usage"><span>{t('已使用')} <strong>{size(used)}</strong></span><span>{t('空闲')} <strong>{size(free)}</strong></span></div>
<div className="storage-legend">{segments.map(segment => <div key={segment.key} className="storage-legend-item"><i className={`storage-dot-${segment.key}`}/><span>{segment.label}{segment.moved && <small> · {t('其他磁盘')}</small>}</span><strong>{size(segment.displayBytes)}</strong></div>)}</div>
{kinds.some(kind => !onVolume(kind)) && <p className="storage-overview-note">{t('已迁移到其他磁盘的数据仅显示在下方明细,不计入此卷。')}</p>}
{kinds.some(kind => places[kind].sizeIncomplete) && <p className="storage-overview-note">{t('部分目录无法读取,Framely 占用可能低于实际值。')}</p>}
</section>;
})()}
{places && kinds.map(kind => {
const place = places[kind];
const busy = !!job || !!active;
const changing = editing === kind;
const migrating = active === kind;
const unchanged = !paths[kind] || paths[kind] === place.path;
const details = [...(place.details ?? [])].sort((a, b) => b.bytes - a.bytes);
const remainder = Math.max(0, place.bytes - details.reduce((sum, detail) => sum + detail.bytes, 0));
const detailCount = details.length + (remainder > 0 ? 1 : 0);
const KindIcon = kindIcons[kind];
return <section className="settings-card storage-section" key={kind} aria-labelledby={`storage-${kind}`}>
<div className="storage-section-heading"><div><h2 id={`storage-${kind}`}>{t(labels[kind].title)}</h2><p className="sub">{t(labels[kind].description)}</p></div><span className="storage-size" title={place.sizeIncomplete ? t('部分目录无法读取,显示可统计的最小占用') : undefined}>{place.sizeIncomplete ? '≥ ' : ''}{size(place.bytes)}</span></div>
<div className="storage-location"><div><span>{t('当前位置')}</span><code title={place.path}>{place.path}</code></div><button disabled={busy || place.available === false} onClick={() => changing ? setEditing(null) : edit(kind)}>{changing ? t('取消更改') : place.interrupted ? t('继续迁移') : t('更改位置')}</button></div>
{place.available === false && <p className="storage-warning" role="status">{t('当前目录不可用,请先连接存储设备。')}</p>}
{place.interrupted && <p className="storage-warning" role="status">{t('上次迁移未完成,原目录仍在使用。可重试原目标或选择新的空目录。')}<code>{place.interrupted}</code></p>}
{changing && <div className="storage-change">
<label htmlFor={`storage-target-${kind}`}>{t('新位置')}</label>
<div className="storage-target"><input id={`storage-target-${kind}`} value={paths[kind]} disabled={busy} onChange={event => setPaths(previous => ({...previous, [kind]: event.target.value}))} spellCheck={false}/><button disabled={busy} onClick={() => setPicker(kind)}><IconFolderOpen size={21}/>{t('浏览')}</button></div>
<p className="sub">{t('目标须为空目录,并支持 Linux 文件权限。迁移期间相关程序会暂时停止。')}</p>
<div className="storage-actions"><button className="primary" disabled={busy || place.available === false || unchanged} onClick={() => void migrate(kind)}>{t('迁移到新位置')}</button></div>
</div>}
{migrating && <div className="storage-progress" role="status"><IconLoader2 size={20}/><span>{t(steps[step] ?? step)}</span></div>}
<div className="storage-section-layout">
<div className="storage-section-heading">
<span className={`storage-kind-icon storage-kind-${kind}`} aria-hidden="true"><KindIcon size={26} stroke={1.7}/></span>
<div><h2 id={`storage-${kind}`}>{t(labels[kind].title)}</h2><p className="sub">{t(labels[kind].description)}</p></div>
<span className="storage-size" title={place.sizeIncomplete ? t('部分目录无法读取,显示可统计的最小占用') : undefined}>{place.sizeIncomplete ? '≥ ' : ''}{size(place.bytes)}</span>
</div>
<div className="storage-section-content">
<div className="storage-location">
<IconFolder size={22} stroke={1.7} aria-hidden="true"/>
<div><span>{t('当前位置')}</span><code title={place.path}>{place.path}</code></div>
<button disabled={busy || place.available === false} onClick={() => changing ? setEditing(null) : edit(kind)}>{changing ? t('取消更改') : place.interrupted ? t('继续迁移') : t('更改位置')}</button>
</div>
{place.available === false && <p className="storage-warning" role="status">{t('当前目录不可用,请先连接存储设备。')}</p>}
{place.interrupted && <p className="storage-warning" role="status">{t('上次迁移未完成,原目录仍在使用。可重试原目标或选择新的空目录。')}<code>{place.interrupted}</code></p>}
{changing && <div className="storage-change">
<label htmlFor={`storage-target-${kind}`}>{t('新位置')}</label>
<div className="storage-target"><input id={`storage-target-${kind}`} value={paths[kind]} disabled={busy} onChange={event => setPaths(previous => ({...previous, [kind]: event.target.value}))} spellCheck={false}/><button disabled={busy} onClick={() => setPicker(kind)}><IconFolderOpen size={21}/>{t('浏览')}</button></div>
<p className="sub">{t('目标须为空目录,并支持 Linux 文件权限。迁移期间相关程序会暂时停止。')}</p>
<div className="storage-actions"><button className="primary" disabled={busy || place.available === false || unchanged} onClick={() => void migrate(kind)}>{t('迁移到新位置')}</button></div>
</div>}
{migrating && <div className="storage-progress" role="status"><IconLoader2 size={20}/><span>{t(steps[step] ?? step)}</span></div>}
<details className="storage-details">
<summary><span>{t('占用明细')}</span><small>{t('{0} 项', {'0': detailCount})}</small><IconChevronRight size={19} aria-hidden="true"/></summary>
<div className="storage-detail-list">
{details.map(detail => <div className="storage-detail-row" key={detail.id}><div><strong>{detail.name || detail.id}</strong>{detail.id !== detail.name && <small title={detail.id}>{detail.id}</small>}{kind === 'plugin' && <small>{t('程序')} {size(detail.programBytes ?? 0)} · {t('数据')} {size(detail.dataBytes ?? 0)}</small>}</div><span title={detail.sizeIncomplete ? t('部分目录无法读取,显示可统计的最小占用') : undefined}>{detail.sizeIncomplete ? '≥ ' : ''}{size(detail.bytes)}</span></div>)}
{remainder > 0 && <div className="storage-detail-row storage-detail-other"><div><strong>{t(kind === 'apk' ? '备份、上传文件及其他' : kind === 'plugin' ? '其他插件文件' : '其他容器文件')}</strong></div><span>{size(remainder)}</span></div>}
{detailCount === 0 && <p className="storage-empty">{t('暂无数据')}</p>}
</div>
</details>
</div>
</div>
</section>;
})}
{picker && <FolderBrowser close={() => setPicker(null)} choose={parent => setPaths(previous => ({...previous, [picker]: `${parent.replace(/\/$/, '')}/${labels[picker].folder}`}))}/>}