合并资源工作台分支:PR review 前端批次并入(含滚轮归属与重命名 CAS)
Project CI / Backend tests (pull_request) Failing after 14s
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / Frontend tests (pull_request) Successful in 2m50s
Project CI / Native shell tests (pull_request) Successful in 18m4s

- 把远端最新 5ab33a8db 合入本分支(PR #316 review 前端三笔:d04fe83f1、d1581892d、a9a5fba92,41 文件)
- 冲突零;同时保留两侧对滚轮的处理:本批把 ResourceBookScene 的重复 onWheel 改成 manager 原生监听(body portal 收不到),远端那笔的浮层守卫仍在 handleResourceBookWheel 内兜住留在 DOM 里带滚动区的浮层,互补不冲突
- 含追加契约:confirmResourceRename 补 expectedProjectId/expectedProjectRevision(与删除/分类同一套 CAS 口径)+ 共享错误码映射 projectAssetCommandErrorMessage
- 验证:npm run typecheck exit 0;resourceRename / resourceCanvasFloatingDismiss / projectResourceLiveIntegration / resourceReferenceInput 四文件全绿
This commit is contained in:
2026-09-12 20:52:38 +08:00
36 changed files with 2231 additions and 255 deletions
@@ -172,13 +172,26 @@ fn render_runtime_region_reference_line(
let width = sanitize_reference_dimension(reference.width);
let height = sanitize_reference_dimension(reference.height);
// `resourceIds` 是本模块唯一由客户端直接给出、且自身还是一条列表的字段:条数不设界时,
// 每个 id 都要扫一遍 manifestO(assets)),注入提示词的 `关联素材 ID:…` 行也会跟着无界
// 变长(最终只被 32 MiB 写入护栏拦下,变成一条和原因无关的连接级错误)。这里按模块的
// 失败关闭口径直接拒绝超限,而不是静默丢掉用户选中的关联。
if reference.resource_ids.len() > MAX_DIRECT_CODEX_REFERENCES {
return Err(format!(
"运行画面区域一次最多关联 {MAX_DIRECT_CODEX_REFERENCES} 个素材,请重新点选"
));
}
let mut related_resource_ids = Vec::new();
for resource_id in &reference.resource_ids {
let resource_id = validate_resource_reference_id(resource_id)?;
if !manifest.assets.iter().any(|asset| asset.id == resource_id) {
return Err("运行画面引用的素材已变化,请重新点选".to_string());
}
related_resource_ids.push(resource_id);
// 去重:同一个 id 在注入提示词里重复出现没有信息量,只是把行撑长。
// 条数已按上限收口,所以这里的逐项比较不会退化成大面积二次扫描。
if !related_resource_ids.contains(&resource_id) {
related_resource_ids.push(resource_id);
}
}
let mut parts = vec![format!("名称:{label}")];
@@ -325,4 +338,45 @@ mod tests {
assert!(!section.contains("onclick"));
assert!(!section.contains("secret"));
}
#[test]
fn runtime_region_dedupes_and_bounds_related_resource_ids() {
let project = fixture_project();
// 同一个 id 重复出现只应产生一条关联。
let duplicated: DirectCodexTurnReference = serde_json::from_str(
r#"{"type":"runtime-region","label":"开始按钮","resourceIds":["asset-hero","asset-hero"," asset-hero "],"text":"开始游戏"}"#,
)
.expect("reference json");
let section = render_direct_codex_references_section(
project.path(),
std::slice::from_ref(&duplicated),
)
.expect("render")
.expect("section");
assert!(
section.contains("关联素材 IDasset-hero\n")
|| section.trim_end().ends_with("关联素材 IDasset-hero"),
"{section}"
);
assert!(
!section.contains("asset-hero,"),
"重复 id 不得在注入提示词里重复出现:{section}"
);
// 超出上限直接失败关闭:不能按对方给的长度注入提示词。
let oversized_ids = (0..MAX_DIRECT_CODEX_REFERENCES + 1)
.map(|_| "\"asset-hero\"".to_string())
.collect::<Vec<_>>()
.join(",");
let oversized: DirectCodexTurnReference = serde_json::from_str(&format!(
r#"{{"type":"runtime-region","label":"开始按钮","resourceIds":[{oversized_ids}]}}"#
))
.expect("reference json");
let error = render_direct_codex_references_section(
project.path(),
std::slice::from_ref(&oversized),
)
.expect_err("oversized resource id list must fail closed");
assert!(error.contains("最多关联"), "{error}");
}
}
@@ -13,7 +13,9 @@ use std::path::{Path, PathBuf};
const DIRECT_PROJECT_HISTORY_SCAN_PROBE_STARTED: usize = 0;
const DIRECT_PROJECT_HISTORY_SCAN_PROBE_FINISHED: usize = 1;
const DIRECT_PROJECT_HISTORY_RECORD_TYPE: &str = "response_item";
/// 写入侧与读取侧共用同一个信封类型:`project.jsonl` 由项目主对话与 DirectProject 共享,
/// 这个值一旦只在写入侧改动,读取侧就会把对方的行当成坏行,整份历史立刻读不出来。
pub(crate) const DIRECT_PROJECT_HISTORY_RECORD_TYPE: &str = "response_item";
/// 格式切换到 `response_item`#282)之前,DirectProject 主对话通过通用对话写入器
/// 落到同一份 `project.jsonl`,行形状是 `PersistedLocalConversationMessageRecord`。
const DIRECT_PROJECT_HISTORY_LEGACY_SCHEMA_VERSION: &str = "game-creator-conversation.v1";
@@ -1793,7 +1793,7 @@ fn direct_codex_failure_recovery_hint(stage: DirectCodexFailureStage, error: &st
return "项目对话历史存在本版本无法识别的记录,旧格式已兼容读取,请检查项目诊断后修复该历史文件再发送需求";
}
if direct_project_history_contention_failure(error) {
return "另一个客户端进程正在读写该项目的历史或项目锁,本轮历史未能落盘;请稍后重试,若确认没有其它客户端在运行请重启客户端后再发送需求";
return "另一个客户端进程正在读写该项目的历史,本轮历史未能落盘;请稍后重试,若确认没有其它客户端在运行请重启客户端后再发送需求";
}
match stage {
DirectCodexFailureStage::ArtPreparation => {
@@ -1880,15 +1880,18 @@ fn direct_project_history_shape_failure(error: &str) -> bool {
.any(|marker| error.contains(marker))
}
/// 跨进程锁 / 项目锁争用
/// 追加写的**跨进程追加锁**超时
///
/// 与"行形状"类相反:它不是同一份历史的同一个结论,而是别的进程此刻正拿着锁——锁本身
/// 没有残留(所有权是句柄,进程退出即释放),所以"稍后重试"是真能生效的动作。提示因此
/// 指向现象与动作,而不是原来 CodeGeneration 阶段那句"请检查运行时配置后重试"。
/// 判据只认两个常量,不各自复制中文。
///
/// 判据刻意只认追加锁那一个常量:项目写锁争用(`PROJECT_WRITE_LOCK_CONTENTION_PREFIX`
/// 在 [`direct_codex_failure_recovery_hint`] 里更早、更具体地判掉了("当前项目仍有写入正在
/// 结束"),把项目写锁也写进这里只会得到一段永远走不到的判据,并让"历史未能落盘"这句
/// 与真实原因不符的描述有机会出现。
fn direct_project_history_contention_failure(error: &str) -> bool {
error.contains(crate::project::PROJECT_APPEND_LOCK_TIMEOUT_MARKER)
|| error.contains(crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX)
}
fn direct_codex_error_is_mud_points_insufficient(error: &str) -> bool {
@@ -4625,6 +4628,42 @@ mod tests {
assert!(direct_codex_failure_is_retryable(io_error));
}
/// 锁争用提示按"更具体的那条赢":项目写锁争用走上面的专用提示,
/// 追加锁超时才落到"历史未能落盘"。两条判据不得重叠。
#[test]
fn direct_project_history_contention_hint_is_append_lock_only() {
let append_lock_timeout = format!(
"获取DirectProject 历史追加写{}",
crate::project::PROJECT_APPEND_LOCK_TIMEOUT_MARKER
);
assert!(direct_project_history_contention_failure(
&append_lock_timeout
));
assert_eq!(
direct_codex_failure_recovery_hint(
DirectCodexFailureStage::CodeGeneration,
&append_lock_timeout
),
"另一个客户端进程正在读写该项目的历史,本轮历史未能落盘;请稍后重试,若确认没有其它客户端在运行请重启客户端后再发送需求"
);
let write_lock_contention = format!(
"{}C:/project",
crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX
);
assert!(
!direct_project_history_contention_failure(&write_lock_contention),
"项目写锁争用不得再落进历史争用判据"
);
assert_eq!(
direct_codex_failure_recovery_hint(
DirectCodexFailureStage::CodeGeneration,
&write_lock_contention
),
"当前项目仍有写入正在结束,请稍后再次发送该需求"
);
}
#[test]
fn client_turn_id_is_strictly_normalized_and_bounded() {
assert_eq!(
@@ -2043,6 +2043,9 @@ pub(crate) struct DeleteLocalProjectAssetResult {
/// - 素材不可变,**不删除磁盘文件**,只摘掉 manifest 登记;
/// - 与资源分类更新同口径:持项目写锁后按 `expectedProjectId` / 当前 revision 做 CAS
/// 失败时 manifest 与 revision 都不变。
/// - **幂等**CAS 通过后登记已不在 manifest 里(上一次调用删掉了登记、只在推进 revision
/// 时失败)时按 no-op 成功收敛,并照常推进 revision —— 这是「manifest 已提交但 revision
/// 未推进」那条中断路径的自愈方式,重试不再报「项目资源不存在」。
pub(crate) fn delete_manifest_asset_at(
root: &Path,
expected_project_id: &str,
@@ -2091,12 +2094,21 @@ pub(crate) fn delete_manifest_asset_at(
.iter()
.position(|asset| asset.id == asset_id)
else {
return Err(format!("项目资源不存在:{asset_id}"));
// 幂等收敛:上一次调用可能已经把登记删掉、只在最后推进 revision 时失败
// manifest 已落盘、revision 仍停在旧值)。此时 `expectedProjectId` 与
// `expectedProjectRevision` 两项 CAS 都已通过,唯一正确的处置是把它当
// no-op 成功 —— 重试再报「项目资源不存在」会让这条命令永远无法自愈。
// 收敛路径上 `localPath` 回报空串(前端只用 assetId 与 revision)。
return Ok(());
};
local_path = manifest.assets[index].local_path.clone();
manifest.assets.remove(index);
if delete_referenced_versions {
let referenced_version_ids: Vec<String> =
// 引用集合在这里算一次即可;成员判定用 `HashSet`:逐版本 `Vec::contains`
// 在引用该素材的版本很多时是 O(V²)。
// (版本守卫侧的 `allowed_version_removals` 闭包按约定在写入前另算一次,
// 它拿不到这里的可变借用,所以两次扫描无法合并。)
let referenced_version_ids: std::collections::HashSet<String> =
asset_referencing_versions(manifest, asset_id)
.into_iter()
.map(|version| version.version_id)
@@ -1066,14 +1066,21 @@ pub(crate) fn replace_local_project_version_resource(
/// 重命名一个已登记素材:磁盘文件改名 + 更新 manifest 的 `localPath`,资产 `id` 不变。
///
/// 只允许在资产当前所在目录内改名,扩展名必须一致,同目录不得已有同名文件;manifest 写失败
/// 时把文件改回原名,不留半成品。
/// 时把文件改回原名,不留半成品。与删除 / 分类更新同口径:持项目写锁后按 `expectedProjectId`
/// 与当前 revision 做 CAS,失败时磁盘、manifest 与 revision 都不动。
#[tauri::command]
pub(crate) fn rename_local_project_asset(
input: RenameLocalProjectAssetInput,
) -> Result<RenameLocalProjectAssetResult, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "asset.register")?;
rename_local_project_asset_at(root, &input.asset_id, &input.new_file_name, None)
rename_local_project_asset_at(
root,
&input.expected_project_id,
input.expected_project_revision,
&input.asset_id,
&input.new_file_name,
)
}
#[tauri::command]
@@ -3572,10 +3572,7 @@ impl ProjectAppendLock {
error_label: &str,
max_attempts: usize,
) -> Result<ProjectAppendGuard<'_>, String> {
let process_guard = self
.process_lock
.lock()
.map_err(|_| format!("获取{error_label}进程内锁失败:锁已损坏"))?;
let process_guard = self.try_lock_process(error_label, max_attempts)?;
let os_lock =
acquire_project_append_os_lock(&self.os_lock_path, error_label, max_attempts)?;
Ok(ProjectAppendGuard {
@@ -3583,6 +3580,39 @@ impl ProjectAppendLock {
_os_lock: os_lock,
})
}
/// 进程内锁也按**有界**等待取,窗口与调用方自己的窗口同长。
///
/// 这里曾经是无上限的 `lock()`:写者拿着进程内锁把 10s 的跨进程等待窗口走完,同一个进程里的
/// `lock_short` 读者就一直卡在 `process_lock.lock()` 上 —— "短窗口让读路径保持可响应"只在
/// 跨进程成立,同进程内并不成立。
///
/// 顺序不变:进程内锁**永远先于**跨进程锁取,且从不在持跨进程锁时回头取进程内锁,
/// 因此不存在 ABBA。拿不到时复用 `PROJECT_APPEND_LOCK_TIMEOUT_MARKER`,调用方既有的
/// "争用可重试"判据照样成立。
fn try_lock_process(
&self,
error_label: &str,
max_attempts: usize,
) -> Result<std::sync::MutexGuard<'_, ()>, String> {
let max_attempts = max_attempts.max(1);
for attempt in 0..max_attempts {
match self.process_lock.try_lock() {
Ok(guard) => return Ok(guard),
Err(std::sync::TryLockError::Poisoned(_)) => {
return Err(format!("获取{error_label}进程内锁失败:锁已损坏"));
}
Err(std::sync::TryLockError::WouldBlock) => {
if attempt + 1 < max_attempts {
thread::sleep(PROJECT_APPEND_LOCK_RETRY_INTERVAL);
}
}
}
}
Err(format!(
"获取{error_label}{PROJECT_APPEND_LOCK_TIMEOUT_MARKER}:同一个客户端进程内还有一次追加写未结束(进程内锁等待已达上限)"
))
}
}
pub(crate) fn project_append_lock_for(path: &Path) -> Result<ProjectAppendLock, String> {
@@ -3647,7 +3677,7 @@ fn acquire_project_append_os_lock(
let max_attempts = max_attempts.max(1);
for attempt in 0..max_attempts {
if let Some(mut file) = try_open_project_append_os_lock(path, error_label)? {
refresh_project_append_lock_diagnostic(&mut file, error_label);
refresh_project_append_lock_diagnostic(&mut file, path, error_label);
return Ok(file);
}
if attempt + 1 < max_attempts {
@@ -3666,10 +3696,21 @@ fn acquire_project_append_os_lock(
/// 它**只用于报错文案**:不判活、不回收、不抢占。读不到就明说读不到——现场最怕的是
/// 一句"检查运行时配置",那既不是现象也不是动作。
fn project_append_lock_holder_diagnostic(path: &Path) -> String {
// Windows 上持锁方把锁文件本身按 zero-share 打开(`try_open_project_append_os_lock` 里的
// `share_mode(0)`),别的进程连"读"都拿不到它,所以先读与锁同目录、按普通共享方式写出的
// 旁路诊断文件。旁路读不到再退回锁文件本身:Unix 一直是这么读的,而且老版本客户端持锁时
// 也只有锁文件里有线索。两个来源都读不到才算"身份不可读"。
if let Ok(content) = fs::read_to_string(project_append_lock_holder_record_path(path)) {
return describe_project_append_lock_holder(&content);
}
let Ok(content) = fs::read_to_string(path) else {
return "持锁方身份不可读:锁文件正被独占持有或已不可读".to_string();
};
let Ok(record) = serde_json::from_str::<serde_json::Value>(&content) else {
describe_project_append_lock_holder(&content)
}
fn describe_project_append_lock_holder(content: &str) -> String {
let Ok(record) = serde_json::from_str::<serde_json::Value>(content) else {
return "持锁方身份不可读:锁文件里没有可解析的诊断元数据".to_string();
};
let pid = record.get("pid").and_then(serde_json::Value::as_u64);
@@ -3687,23 +3728,32 @@ fn project_append_lock_holder_diagnostic(path: &Path) -> String {
}
}
/// 把"谁在持这把锁"写进锁文件,供事后排障;同一进程重复取同一把锁时不重复写
/// 旁路诊断文件:与锁同级、普通共享写入,专门给"锁文件本身读不到"的平台用
///
/// 它是**只读诊断**,不参与判活 / 回收 / 抢占:拿它永远拿不到锁,锁所有权始终是那个
/// zero-share 句柄。写入失败也不影响取锁结果(见 [`refresh_project_append_lock_diagnostic`])。
fn project_append_lock_holder_record_path(path: &Path) -> PathBuf {
let file_name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("append.lock");
path.with_file_name(format!("{file_name}.holder.json"))
}
/// 把"谁在持这把锁"写进锁文件与旁路诊断文件,供事后排障;同一进程重复取同一把锁时不重复写。
///
/// 所有权是句柄本身,不靠文件内容成立:这里写失败绝不影响取锁结果。诊断元数据也不参与
/// 任何判活/回收/抢占判断(既有口径见 docs/project-memory/shared-memory/decision-log.md
/// 2026-09-09「项目写锁残留回收与启动诊断」)。
fn refresh_project_append_lock_diagnostic(file: &mut File, error_label: &str) {
fn refresh_project_append_lock_diagnostic(file: &mut File, path: &Path, error_label: &str) {
let pid = std::process::id();
let mut current = String::new();
if file.seek(SeekFrom::Start(0)).is_ok()
let lock_file_already_records_this_process = file.seek(SeekFrom::Start(0)).is_ok()
&& std::io::Read::read_to_string(file, &mut current).is_ok()
&& serde_json::from_str::<serde_json::Value>(&current)
.ok()
.and_then(|value| value.get("pid").and_then(serde_json::Value::as_u64))
== Some(u64::from(pid))
{
return;
}
== Some(u64::from(pid));
let Ok(serialized) = serde_json::to_string(&serde_json::json!({
"acquiredAt": unix_timestamp(),
"label": error_label,
@@ -3714,11 +3764,19 @@ fn refresh_project_append_lock_diagnostic(file: &mut File, error_label: &str) {
})) else {
return;
};
if file.set_len(0).is_err() || file.seek(SeekFrom::Start(0)).is_err() {
return;
if !lock_file_already_records_this_process {
if file.set_len(0).is_err() || file.seek(SeekFrom::Start(0)).is_err() {
return;
}
let _ = file.write_all(serialized.as_bytes());
let _ = file.flush();
}
let _ = file.write_all(serialized.as_bytes());
let _ = file.flush();
// 旁路文件每次取锁都刷新(不跟着上面的"本进程已写过"短路):上一次可能就是写它失败的那次。
// 失败静默:它只是排障线索,不能影响取锁结果。
let _ = fs::write(
project_append_lock_holder_record_path(path),
serialized.as_bytes(),
);
}
/// 测试专用探针:判断某个追加写目标此刻是否被别人持有 OS 锁。
@@ -3825,7 +3883,17 @@ fn release_project_append_os_lock_then_repair_acl(
) -> Result<(), String> {
drop(held_lock);
crate::secure_windows_game_creator_path_for_current_user_with_auto_elevation(path, false, true)
.map_err(|repair_error| format!("{strict_error};自动提权修复未完成:{repair_error}"))
.map_err(|repair_error| {
// 只有"可能靠提权修好"的错误才配得上这句后缀。非提权类失败(锁文件在打开与严格校验
// 之间被删掉、ACL 报错不在提权口径内)时,自动修复没有做任何事就原样返回了同一个
// 错误,再贴一句"自动提权修复未完成"等于把同一条错误报两遍,还把现场指向一个
// 根本没发生过的动作。
if crate::config::windows_acl_error_may_need_elevation(&repair_error) {
format!("{strict_error};自动提权修复未完成:{repair_error}")
} else {
strict_error.to_string()
}
})
}
#[cfg(windows)]
@@ -3197,6 +3197,91 @@ fn append_lock_holder_diagnostic_reports_the_recorded_pid() {
fs::remove_dir_all(&root).ok();
}
/// 取锁必须同时写出与锁同级的旁路诊断文件。
///
/// Windows 上持锁方以 zero-share 打开锁文件本身,别的进程连读都读不到,"谁在持锁"就只能靠
/// 这个普通共享的旁路文件回答——那正是这套 UAC / 锁争用改动要落地的平台。
#[test]
fn append_lock_acquisition_writes_the_holder_record_sidecar() {
let (root, target) = append_lock_test_target("append-lock-holder-sidecar");
let lock = project_append_lock_for(&target).expect("resolve lock");
drop(
lock.lock("DirectProject 历史追加写")
.expect("acquire append lock"),
);
let lock_path = project_append_os_lock_path(&target).expect("lock path");
let sidecar = project_append_lock_holder_record_path(&lock_path);
let raw = fs::read_to_string(&sidecar).expect("read holder record sidecar");
let record: serde_json::Value =
serde_json::from_str(&raw).expect("parse holder record sidecar");
assert_eq!(
record["pid"],
serde_json::json!(u64::from(std::process::id()))
);
assert_eq!(
record["label"],
serde_json::json!("DirectProject 历史追加写")
);
assert!(record["acquiredAt"].is_number(), "{raw}");
fs::remove_dir_all(&root).ok();
}
/// 锁文件读不到时,诊断必须退回旁路文件,而不是直接判"身份不可读"。
#[test]
fn append_lock_holder_diagnostic_falls_back_to_the_sidecar() {
let (root, target) = append_lock_test_target("append-lock-holder-sidecar-fallback");
let lock_path = project_append_os_lock_path(&target).expect("lock path");
fs::create_dir_all(lock_path.parent().expect("lock parent")).expect("lock dir");
fs::write(
project_append_lock_holder_record_path(&lock_path),
"{\"acquiredAt\":1,\"label\":\"旁路用途\",\"pid\":4242,\"processStartedAt\":7}",
)
.expect("seed holder record sidecar");
// 锁文件此刻不存在:旧实现只会报"身份不可读"。
assert!(!lock_path.exists());
let diagnostic = project_append_lock_holder_diagnostic(&lock_path);
assert!(diagnostic.contains("pid=4242"), "{diagnostic}");
assert!(diagnostic.contains("旁路用途"), "{diagnostic}");
fs::remove_dir_all(&root).ok();
}
/// 同进程内的读路径不能被写者的进程内锁拖住:短窗口必须有界地失败,而不是等写者走完
/// 完整的跨进程等待窗口。
#[test]
fn append_lock_short_wait_is_bounded_while_a_same_process_writer_holds_the_lock() {
let (root, target) = append_lock_test_target("append-lock-process-lock-bound");
let lock = project_append_lock_for(&target).expect("resolve lock");
let writer = lock
.lock("DirectProject 历史追加写")
.expect("acquire append lock");
let started = std::time::Instant::now();
let error = match lock.lock_short("DirectProject 历史追加读") {
Ok(_guard) => panic!("进程内锁被占满时短窗口必须失败而不是无限等待"),
Err(error) => error,
};
let elapsed = started.elapsed();
assert!(
error.contains(PROJECT_APPEND_LOCK_TIMEOUT_MARKER),
"{error}"
);
assert!(
elapsed < std::time::Duration::from_secs(5),
"短窗口必须留在 1s 级,实际等待 {elapsed:?}"
);
drop(writer);
drop(
lock.lock_short("DirectProject 历史追加读")
.expect("reacquire after writer releases"),
);
fs::remove_dir_all(&root).ok();
}
/// 真的被别人独占持有时:超时文案必须报出锁路径与持锁方线索,而且**不留残留**——
/// 释放后立刻要能重新取到(这把锁的所有权是句柄,所以本来就不该有 stale 回收)。
#[test]
@@ -2,10 +2,16 @@ use super::*;
use std::fs::File;
use std::io::{Read, Write};
/// 显式保存:把项目内已登记的素材文件复制到用户选定的目标路径。
/// 显式保存:把项目内的素材文件复制到用户选定的目标路径。
///
/// 这里刻意不做"浏览器下载"——AGC 是 Tauri/WebView2 宿主,未注册 `on_download`
/// 时 `<a download>` 能否落盘不可靠,所以保存路径由原生对话框给出,复制由 Rust 完成。
///
/// 允许导出的范围 = "项目根内真实存在的普通文件"减去敏感配置与 Runtime 控制面
/// (见 [`resolve_export_source_file`])。**不额外要求文件已在 manifest 里登记**
/// 资源画布里的 DirectProject 附件(`attachment:<localPath>`)是客户端导入的项目内文件,
/// 用户同样要能把它们另存出来,但它们不是 manifest 资产;按登记与否放行会把这条
/// 正常路径一并拒掉。素材保存因此与通用项目读取共用同一套路径门禁,而不是更宽的门禁。
const ASSET_EXPORT_COPY_CHUNK_BYTES: usize = 64 * 1024;
#[derive(Clone, Debug, Deserialize)]
@@ -25,10 +31,14 @@ pub(crate) struct SaveLocalProjectAssetFileResult {
fn resolve_export_source_file(root: &Path, relative_path: &str) -> Result<PathBuf, String> {
let normalized = normalize_relative_path(relative_path.trim())?;
if normalized.is_empty() {
return Err("待保存的素材路径不能为空".to_string());
}
let source = root.join(&normalized);
// 导出同样是"把项目里的文件交出去",必须与通用项目读取路径同一套拒绝口径:
// 敏感配置(`.env` 等)与 `.agent/runtime|checkpoints|workbench` 控制面都不能被复制到项目外。
reject_agent_runtime_private_control_path(&normalized)?;
reject_sensitive_project_file_read(&normalized)?;
// 逐段复核符号链接与 Windows reparse point,而不是只看最后一段:`assets` 本身是链接
// 或 junction 时,`root.join(...)` 仍会解析到项目外,导出就变成任意文件读取。
// 这里与改名路径共用 `resolve_local_project_path`,保证是同一条边界判据。
let source = resolve_local_project_path(root, &normalized)?;
// 只允许保存项目根内真实存在的普通文件:符号链接与目录都要拒绝。
let metadata = std::fs::symlink_metadata(&source)
.map_err(|error| format!("素材文件不存在或不可读:{error}"))?;
@@ -63,19 +73,83 @@ fn resolve_export_destination_file(destination_path: &str) -> Result<PathBuf, St
Ok(destination)
}
pub(crate) fn save_local_project_asset_file_at(
input: SaveLocalProjectAssetFileInput,
) -> Result<SaveLocalProjectAssetFileResult, String> {
let root = Path::new(input.project_path.trim());
validate_project_root(root)?;
enforce_project_auto_permission_policy(root, "file.read")?;
/// 已存在路径的文件身份;读不到身份、或身份是退化常量时返回 `None`。
///
/// FAT/exFAT 与部分网络卷上 `file index` 恒为 0,那不是一个能区分文件的身份,
/// 拿它比较只会把正常导出误判成"自我覆盖",所以这种值一律当成"没有身份"。
fn export_path_file_identity(path: &Path) -> Option<(u64, u64)> {
let identity = File::open(path)
.ok()
.and_then(|file| open_file_identity_key(&file).ok())?;
if identity.1 == 0 {
None
} else {
Some(identity)
}
}
let source = resolve_export_source_file(root, &input.relative_path)?;
let destination = resolve_export_destination_file(&input.destination_path)?;
/// 路径等价判据:Windows / macOS 的文件系统默认大小写不敏感,同一份文件可能只差大小写。
fn export_paths_equivalent(left: &Path, right: &Path) -> bool {
if cfg!(any(windows, target_os = "macos")) {
left.to_string_lossy()
.eq_ignore_ascii_case(&right.to_string_lossy())
} else {
left == right
}
}
let mut reader = File::open(&source).map_err(|error| format!("打开素材文件失败:{error}"))?;
/// 保存目标不能与源素材是同一个文件。
///
/// 前端用的是原生保存对话框,用户完全可以导航进项目素材目录并选中源文件本身;`File::create`
/// 会先把目标截断,随后的复制只能读到 0 字节,于是"复制成功"`byte_len = 0`)返回,而源素材
/// 已经被就地毁掉。路径等价、以及符号链接 / 硬链接指向同一份文件,都要在创建写句柄之前拦住。
fn reject_export_destination_matching_source(
source: &Path,
destination: &Path,
) -> Result<(), String> {
if !destination.exists() {
return Ok(());
}
let same_file = match (
export_path_file_identity(source),
export_path_file_identity(destination),
) {
(Some(source_identity), Some(destination_identity)) => {
source_identity == destination_identity
}
_ => false,
};
let same_path = matches!(
(source.canonicalize(), destination.canonicalize()),
(Ok(source_canonical), Ok(destination_canonical))
if export_paths_equivalent(&source_canonical, &destination_canonical)
);
if same_file || same_path {
return Err("保存目标不能与素材源文件相同".to_string());
}
Ok(())
}
/// 复制目标文件名用的临时兄弟路径:必须与目标同目录,`rename` 才是同卷原子替换。
fn export_destination_temp_path(destination: &Path) -> PathBuf {
let file_name = destination
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("asset-export");
destination.with_file_name(format!(
".{file_name}.tmp.{}.{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
))
}
fn copy_export_source_bytes_to(source: &Path, temp_path: &Path) -> Result<u64, String> {
let mut reader = File::open(source).map_err(|error| format!("打开素材文件失败:{error}"))?;
let mut writer =
File::create(&destination).map_err(|error| format!("创建保存目标失败:{error}"))?;
File::create(temp_path).map_err(|error| format!("创建保存目标失败:{error}"))?;
let mut buffer = vec![0_u8; ASSET_EXPORT_COPY_CHUNK_BYTES];
let mut byte_len = 0_u64;
loop {
@@ -93,6 +167,42 @@ pub(crate) fn save_local_project_asset_file_at(
writer
.flush()
.map_err(|error| format!("刷新保存目标失败:{error}"))?;
// 替换前必须落盘:只 `flush` 的话,崩溃窗口里换过去的可能是还没写完的内容。
writer
.sync_all()
.map_err(|error| format!("落盘保存目标失败:{error}"))?;
Ok(byte_len)
}
/// 先整份复制到目标同目录的临时文件,再原子替换目标。
///
/// 直接往最终目标写会在 `File::create` 那一刻截断既有文件:复制中途失败就同时留下半截文件
/// 和"原目标已被毁掉"两个后果。manifest 写入(`write_manifest_locked`)用的是同一套手法。
fn copy_export_source_to_destination(source: &Path, destination: &Path) -> Result<u64, String> {
let temp_path = export_destination_temp_path(destination);
let result = copy_export_source_bytes_to(source, &temp_path).and_then(|byte_len| {
fs::rename(&temp_path, destination)
.map_err(|error| format!("替换保存目标失败:{}: {error}", destination.display()))?;
Ok(byte_len)
});
if result.is_err() {
let _ = fs::remove_file(&temp_path);
}
result
}
pub(crate) fn save_local_project_asset_file_at(
input: SaveLocalProjectAssetFileInput,
) -> Result<SaveLocalProjectAssetFileResult, String> {
let root = Path::new(input.project_path.trim());
validate_project_root(root)?;
enforce_project_auto_permission_policy(root, "file.read")?;
let source = resolve_export_source_file(root, &input.relative_path)?;
let destination = resolve_export_destination_file(&input.destination_path)?;
reject_export_destination_matching_source(&source, &destination)?;
let byte_len = copy_export_source_to_destination(&source, &destination)?;
Ok(SaveLocalProjectAssetFileResult {
destination_path: destination.to_string_lossy().into_owned(),
@@ -246,4 +356,165 @@ mod tests {
let _ = std::fs::remove_dir_all(&root);
let _ = std::fs::remove_dir_all(&destination_directory);
}
/// 敏感配置与 Runtime 控制面文件都不能被导出到项目外:导出走的是同一条
/// `file.read` 拒绝口径,而不是只做路径拼接。
#[test]
fn rejects_sensitive_and_agent_control_files_as_source() {
let (root, assets) = create_export_project();
std::fs::write(root.join(".env"), "SECRET=1").expect("write .env");
std::fs::write(assets.join("hero.png"), b"payload").expect("write source asset");
let agent_runtime = root.join(".agent/runtime");
std::fs::create_dir_all(&agent_runtime).expect("create agent runtime dir");
std::fs::write(agent_runtime.join("run.json"), "{}").expect("write control file");
let destination_directory = unique_asset_export_directory("destination");
std::fs::create_dir_all(&destination_directory).expect("create destination directory");
for relative_path in [".env", ".agent/runtime/run.json"] {
let error = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput {
project_path: root.to_string_lossy().into_owned(),
relative_path: relative_path.to_string(),
destination_path: destination_directory
.join("copy.bin")
.to_string_lossy()
.into_owned(),
})
.expect_err("sensitive or control-plane source must be rejected");
assert!(
error.contains("拒绝读取敏感配置文件") || error.contains("私有控制面"),
"意外错误:{error}"
);
}
assert!(!destination_directory.join("copy.bin").exists());
let _ = std::fs::remove_dir_all(&root);
let _ = std::fs::remove_dir_all(&destination_directory);
}
/// 中间目录是符号链接时不能把项目外的文件当素材导出:只查最后一段会漏掉这条逃逸路径。
#[test]
fn rejects_source_reached_through_an_intermediate_symlink() {
let (root, assets) = create_export_project();
let outside = unique_asset_export_directory("outside");
std::fs::create_dir_all(&outside).expect("create outside directory");
std::fs::write(outside.join("secret.png"), b"outside-secret").expect("write outside file");
std::fs::remove_dir_all(&assets).expect("remove real assets dir");
// Windows 上建目录符号链接需要开发者模式或特权;拿不到权限就跳过这条断言,
// 不让环境能力决定测试结论。Unix 上恒可用。
#[cfg(windows)]
let symlink_created = std::os::windows::fs::symlink_dir(&outside, &assets).is_ok();
#[cfg(not(windows))]
let symlink_created = std::os::unix::fs::symlink(&outside, &assets).is_ok();
if !symlink_created || !assets.join("secret.png").is_file() {
let _ = std::fs::remove_dir_all(&root);
let _ = std::fs::remove_dir_all(&outside);
return;
}
let destination_directory = unique_asset_export_directory("destination");
std::fs::create_dir_all(&destination_directory).expect("create destination directory");
let error = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput {
project_path: root.to_string_lossy().into_owned(),
relative_path: "assets/secret.png".to_string(),
destination_path: destination_directory
.join("leak.png")
.to_string_lossy()
.into_owned(),
})
.expect_err("source behind an intermediate symlink must be rejected");
assert!(error.contains("符号链接"), "意外错误:{error}");
assert!(!destination_directory.join("leak.png").exists());
let _ = std::fs::remove_dir_all(&root);
let _ = std::fs::remove_dir_all(&outside);
let _ = std::fs::remove_dir_all(&destination_directory);
}
/// 保存目标就是源文件时必须拒绝,并且源素材一个字节都不能变。
#[test]
fn rejects_destination_that_is_the_source_file() {
let (root, assets) = create_export_project();
let source_bytes = b"genarrative-asset-export-payload".to_vec();
std::fs::write(assets.join("hero.png"), &source_bytes).expect("write source asset");
let source = assets.join("hero.png");
let error = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput {
project_path: root.to_string_lossy().into_owned(),
relative_path: "assets/hero.png".to_string(),
destination_path: source.to_string_lossy().into_owned(),
})
.expect_err("destination equal to the source must be rejected");
assert!(error.contains("不能与素材源文件相同"), "{error}");
assert_eq!(
std::fs::read(&source).expect("read source asset"),
source_bytes,
"拒绝自我覆盖时源素材必须原样保留"
);
let _ = std::fs::remove_dir_all(&root);
}
/// 硬链接指向同一份文件时同样拒绝:路径不同但内容是同一份,覆盖等于毁掉源素材。
#[test]
fn rejects_hard_linked_destination_pointing_at_the_source() {
let (root, assets) = create_export_project();
let source_bytes = b"genarrative-asset-export-payload".to_vec();
std::fs::write(assets.join("hero.png"), &source_bytes).expect("write source asset");
let destination_directory = unique_asset_export_directory("destination");
std::fs::create_dir_all(&destination_directory).expect("create destination directory");
let destination = destination_directory.join("hero-alias.png");
if std::fs::hard_link(assets.join("hero.png"), &destination).is_err() {
let _ = std::fs::remove_dir_all(&root);
let _ = std::fs::remove_dir_all(&destination_directory);
return;
}
let error = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput {
project_path: root.to_string_lossy().into_owned(),
relative_path: "assets/hero.png".to_string(),
destination_path: destination.to_string_lossy().into_owned(),
})
.expect_err("hard-linked destination must be rejected");
assert!(error.contains("不能与素材源文件相同"), "{error}");
assert_eq!(
std::fs::read(assets.join("hero.png")).expect("read source asset"),
source_bytes
);
let _ = std::fs::remove_dir_all(&root);
let _ = std::fs::remove_dir_all(&destination_directory);
}
/// 覆盖既有目标走临时文件 + 原子替换:内容整体换新,且不留临时文件残渣。
#[test]
fn replaces_an_existing_destination_without_leaving_temp_files() {
let (root, assets) = create_export_project();
let source_bytes = b"genarrative-asset-export-payload".to_vec();
std::fs::write(assets.join("hero.png"), &source_bytes).expect("write source asset");
let destination_directory = unique_asset_export_directory("destination");
std::fs::create_dir_all(&destination_directory).expect("create destination directory");
let destination = destination_directory.join("hero-copy.png");
std::fs::write(&destination, b"stale-and-longer-content").expect("write stale destination");
let result = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput {
project_path: root.to_string_lossy().into_owned(),
relative_path: "assets/hero.png".to_string(),
destination_path: destination.to_string_lossy().into_owned(),
})
.expect("save asset file");
assert_eq!(result.byte_len, source_bytes.len() as u64);
assert_eq!(
std::fs::read(&destination).expect("read copied asset"),
source_bytes
);
let entries = std::fs::read_dir(&destination_directory)
.expect("read destination directory")
.count();
assert_eq!(entries, 1, "原子替换不得留下临时文件");
let _ = std::fs::remove_dir_all(&root);
let _ = std::fs::remove_dir_all(&destination_directory);
}
}
@@ -6,10 +6,15 @@ use super::filesystem::validate_portable_project_path_component;
///
/// 只收"新文件名"而不是新旧两个全路径:改名被限制在资产当前所在目录内,目录由 manifest
/// 里的 `localPath` 决定,调用方无法指定目标目录。
///
/// `expectedProjectId` / `expectedProjectRevision` 与删除、分类更新同口径:陈旧客户端
/// 不允许凭旧 revision 改写素材文件与 manifest。
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct RenameLocalProjectAssetInput {
pub(crate) project_path: String,
pub(crate) expected_project_id: String,
pub(crate) expected_project_revision: u64,
pub(crate) asset_id: String,
pub(crate) new_file_name: String,
}
@@ -22,12 +27,24 @@ pub(crate) struct RenameLocalProjectAssetResult {
pub(crate) committed_project_revision: u64,
}
/// 故障注入点:让测试在不依赖只读路径的前提下验证"manifest 写失败必须回滚文件改名"。
/// 生产路径始终传 `None`
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum RenameLocalProjectAssetFaultStage {
/// 文件已改名、manifest 尚未写入时失败。
ManifestWrite,
/// 测试注入:`.agent/runtime/test-fail-next-asset-rename-manifest-write` 存在时,下一步
/// manifest 写入按失败返回,用来验证"文件已改名必须改回原名"的回滚
///
/// 与 `agent_db` 的 `test-fail-next-*` 同一套约定,且整段是 `#[cfg(test)]`:生产签名
/// [`rename_local_project_asset_at`])不接受任何故障注入参数,也没有第二个入口能把函数
/// 推上"只回滚、绝不写 manifest"的那条路。
#[cfg(test)]
fn take_rename_manifest_write_failure_injection(root: &Path) -> Result<(), String> {
let failure_path = root.join(".agent/runtime/test-fail-next-asset-rename-manifest-write");
match std::fs::read_to_string(&failure_path) {
Ok(_) => {
std::fs::remove_file(&failure_path)
.map_err(|error| format!("清理素材改名 manifest 写失败注入标记失败:{error}"))?;
Err("fault-injected:rename-asset-manifest-write".to_string())
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(format!("读取素材改名 manifest 写失败注入标记失败:{error}")),
}
}
/// 校验调用方给出的新文件名,返回 trim 后的名字。
@@ -64,9 +81,9 @@ fn asset_file_extension(file_name: &str) -> Option<String> {
/// 一个帧的 `imageSrc` 是否就是被改名的那份本地文件。
///
/// 判据:既不是绝对路径、也不是带 scheme 的远程地址、也不是反斜杠路径;去掉目录后文件名与旧
/// 一致(大小写不敏感,和扩展名比较同口径)。只认真正指向该文件的帧,同目录但指向其它文件的帧
/// 保持原样,避免改名连带改坏别的引用。
/// 判据:既不是绝对路径、也不是带 scheme 的远程地址、也不是反斜杠路径;去掉目录后目录与旧
/// 目录等价、文件名与旧名一致(都按所在文件系统的大小写语义比较)。只认真正指向该文件的帧,
/// 同目录但指向其它文件的帧保持原样,避免改名连带改坏别的引用。
fn asset_local_frame_matches(image_src: &str, directory: &str, previous_file_name: &str) -> bool {
let image_src = image_src.trim();
if image_src.is_empty()
@@ -81,7 +98,21 @@ fn asset_local_frame_matches(image_src: &str, directory: &str, previous_file_nam
Some((directory, file_name)) => (directory, file_name),
None => ("", image_src),
};
frame_directory == directory && frame_file_name.eq_ignore_ascii_case(previous_file_name)
asset_frame_directory_matches(frame_directory, directory)
&& frame_file_name.eq_ignore_ascii_case(previous_file_name)
}
/// 目录段的大小写语义必须与所在文件系统一致。
///
/// 文件名一直按大小写不敏感比较(`Assets/Hero.png` 与 `assets/hero.png` 在 Windows / macOS 上
/// 是同一份文件);目录段如果按大小写敏感比较,同一份文件在 `imageSequenceFrames` 里写成
/// 不同目录大小写时就对齐不上,改名后留下悬空帧引用。Linux 保持大小写敏感。
fn asset_frame_directory_matches(frame_directory: &str, directory: &str) -> bool {
if cfg!(any(windows, target_os = "macos")) {
frame_directory.eq_ignore_ascii_case(directory)
} else {
frame_directory == directory
}
}
/// 把 manifest 条目里指向被改名文件的序列帧对齐到新 `localPath`。
@@ -125,26 +156,53 @@ fn rollback_asset_file_rename(
///
/// 事务顺序(全程持既有项目写锁,期间不会出现第二个项目写者;manifest 写入仍走既有边界:
/// 版本数组不可变校验 + 安装后回读一致性校验):
/// 0. 与删除 / 分类更新同口径的 CAS`expectedProjectId` 两段复核 + 当前 revision 比对,
/// 失败时磁盘、manifest、revision 全都不动;
/// 1. 读 manifest 定位资产,取旧 `localPath`
/// 2. 校验新文件名、扩展名一致、目标不冲突,然后 `rename` 磁盘文件;
/// 3. 更新 manifest 的 `localPath``id` / `kind` / `mediaType` / `source` / `category` / `tags`
/// 全部不变),并把 `imageSequenceFrames` 里指向该文件的帧对齐到新路径;
/// 4. 写 manifest
/// 5. 第 4 步失败时把文件改回原名,不留"文件已改名但 manifest 还是旧路径"的半成品;
/// 改回原名也失败时,两个错误都报出来并标记 reconciliation-required
/// 改回原名也失败时,两个错误都报出来并标记 reconciliation-required
/// 6. 第 4 步成功、第 6 步(推进 revision)失败时**不回滚**:文件与 manifest 都已提交,
/// 这是已提交但对账未完成的状态,按 `reconciliation-required:` 前缀上报,不按普通失败
/// 处理(普通失败会诱导调用方"重试一次",把已提交的改名再走一遍)。
/// 此时磁盘与 manifest 都在**新**名字上,同名重试会命中上面的 no-op 分支直接成功,
/// 所以调用方按"已经改名成功"刷新即可收敛。
pub(crate) fn rename_local_project_asset_at(
root: &Path,
expected_project_id: &str,
expected_project_revision: u64,
asset_id: &str,
new_file_name: &str,
fault: Option<RenameLocalProjectAssetFaultStage>,
) -> Result<RenameLocalProjectAssetResult, String> {
let asset_id = asset_id.trim();
if asset_id.is_empty() {
return Err("素材重命名 assetId 不能为空".to_string());
}
let new_file_name = normalize_asset_file_name(new_file_name)?;
if expected_project_revision
> shared_contracts::game_creation_app::GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION
{
return Err("expectedProjectRevision 超出 JavaScript 安全整数范围".to_string());
}
let expected_project_id = expected_project_id.trim();
if expected_project_id.is_empty() {
return Err("素材重命名 expectedProjectId 不能为空".to_string());
}
if read_existing_manifest_for_project(root)?.project_id != expected_project_id {
return Err("project-identity-conflict".to_string());
}
let _lock = acquire_project_write_lock(root, "asset.register")?;
if read_existing_manifest_for_project(root)?.project_id != expected_project_id {
return Err("project-identity-conflict".to_string());
}
if read_game_creator_agent_runtime_project_revision(root)?.revision != expected_project_revision
{
return Err("project-revision-conflict".to_string());
}
let manifest_path = root.join(".agent/manifest.json");
let mut manifest = read_existing_manifest_for_project(root)?;
let index = manifest
@@ -232,12 +290,13 @@ pub(crate) fn rename_local_project_asset_at(
let asset = manifest.assets[index].clone();
// 文件已改名:从这里开始的任何失败都必须把文件改回原名。
let write_error = match fault {
Some(RenameLocalProjectAssetFaultStage::ManifestWrite) => {
Some("fault-injected:rename-asset-manifest-write".to_string())
}
None => write_manifest(&manifest_path, &manifest).err(),
};
// 注入结果必须并进 `write_error`、不能就地 `?` 返回,否则会绕过下面的回滚。
#[cfg(test)]
let injected_write_error = take_rename_manifest_write_failure_injection(root).err();
#[cfg(not(test))]
let injected_write_error: Option<String> = None;
let write_error =
injected_write_error.or_else(|| write_manifest(&manifest_path, &manifest).err());
if let Some(error) = write_error {
return Err(rollback_asset_file_rename(
&current_absolute,
@@ -246,8 +305,13 @@ pub(crate) fn rename_local_project_asset_at(
));
}
let committed_project_revision = advance_agent_runtime_project_revision_locked(root)
.map_err(|error| format!("素材已改名,但项目 revision 未能推进:{error}"))?;
let committed_project_revision = advance_agent_runtime_project_revision_locked(root).map_err(
|error| {
format!(
"reconciliation-required: 素材已改名为 {next_local_path} 且 manifest 已落盘,但项目 revision 未能推进:{error}"
)
},
)?;
Ok(RenameLocalProjectAssetResult {
asset,
previous_local_path,
@@ -981,9 +981,14 @@ fn read_persisted_local_conversation_records_unlocked(
/// 只认 DirectProject 那一种明确枚举的行信封:`type=response_item` 且带 `payload`。
/// 其余任何形状都不算“对方的行”,仍由上方按坏行失败关闭。
///
/// 类型串引用写入侧的 `DIRECT_PROJECT_HISTORY_RECORD_TYPE`,不复制字面量:两边读写的
/// 是同一份 `project.jsonl`,一旦只在写入侧改名,这里就会把每一行 DirectProject 记录
/// 都判成坏行并以 `解析对话记录失败` 失败关闭,共享文件的读取整条断掉。
fn is_direct_project_history_row(line: &str) -> bool {
serde_json::from_str::<serde_json::Value>(line).is_ok_and(|parsed| {
parsed.get("type").and_then(serde_json::Value::as_str) == Some("response_item")
parsed.get("type").and_then(serde_json::Value::as_str)
== Some(crate::agent::DIRECT_PROJECT_HISTORY_RECORD_TYPE)
&& parsed.get("payload").is_some()
})
}
@@ -1108,7 +1108,8 @@ pub(crate) struct UpdateLocalProjectResourceClassificationResult {
/// 只更新单个 manifest 资产条目的功能分类与自定义标签,保持条目字段构成与顺序不变。
/// `category` 必须是 6 个合法值之一,非法值直接失败、不回退到 `kind` 派生;
/// `tags` 按既有归一化语义 trim、去空、去重
/// `tags` 按既有归一化语义 trim、去空、去重,并在此处再按数量与单标签长度收口:
/// manifest 会被整份读、整份解析、整份重写,无界标签等于让后续每一次读写都为它付费。
pub(crate) fn update_manifest_asset_classification_at(
root: &Path,
expected_project_id: &str,
@@ -1132,12 +1133,12 @@ pub(crate) fn update_manifest_asset_classification_at(
}
let category = game_creation_app_asset_category_from_str(category)
.ok_or_else(|| format!("非法资源分类:{}", category.trim()))?;
let tags = normalize_game_creation_app_asset_tags(&tags);
let tags = normalize_manifest_asset_tags(&tags)?;
if read_existing_manifest_for_project(root)?.project_id != expected_project_id {
return Err("project-identity-conflict".to_string());
}
let _lock = acquire_project_write_lock(root, "asset.register")?;
let _lock = acquire_project_write_lock(root, "asset.classification.update")?;
if read_existing_manifest_for_project(root)?.project_id != expected_project_id {
return Err("project-identity-conflict".to_string());
}
@@ -1164,19 +1165,26 @@ pub(crate) fn update_manifest_asset_classification_at(
// 与 `assets.rs` 的 `asset.register` / `asset.update` 同一位置约定(manifest 写成功后追加)。
// 放在推进 revision 之前是刻意的——分类已经真实落盘,审计不能因为紧随其后的 revision 推进
// 失败而缺失,否则「改过但查不到」正是这条缺陷要修的东西;推进失败仍然照旧报错(见下方 map_err)。
append_agent_db_record(
root,
serde_json::json!({
"recordType": "asset.classification.update",
"assetId": asset.id,
"expectedProjectRevision": expected_project_revision,
"previousCategory": previous_category,
"previousTags": previous_tags,
"category": asset.category,
"tags": asset.tags,
}),
)
.map_err(|error| format!("资源分类已写入,但审计记录失败:{error}"))?;
//
// 这条位置约定的代价是"重试会走到同一个值":上次调用可能已经写进同一份分类、只在推进
// revision 时失败(revision 未变,所以重试会再次通过 CAS 并重新落到同一个值)。前后值完全
// 相同的记录不是"从 X 改成 Y",重复追加只会污染这份审计要保住的语义,因此跳过。
let classification_changed = previous_category != asset.category || previous_tags != asset.tags;
if classification_changed {
append_agent_db_record(
root,
serde_json::json!({
"recordType": "asset.classification.update",
"assetId": asset.id,
"expectedProjectRevision": expected_project_revision,
"previousCategory": previous_category,
"previousTags": previous_tags,
"category": asset.category,
"tags": asset.tags,
}),
)
.map_err(|error| format!("资源分类已写入,但审计记录失败:{error}"))?;
}
let committed_project_revision = advance_agent_runtime_project_revision_locked(root)
.map_err(|error| format!("资源分类已写入,但项目 revision 未能推进:{error}"))?;
Ok(UpdateLocalProjectResourceClassificationResult {
@@ -1333,6 +1341,32 @@ fn normalize_manifest_task_id_list(
Ok(output)
}
/// 资源标签的持久化上界:manifest 每次写入都被整份序列化重写、每次读取都被整份解析,
/// 标签数量与单标签长度若无界,客户端就能让这份文件无限膨胀,并把成本摊到之后每一次读写上。
const ASSET_CLASSIFICATION_MAX_TAGS: usize = 16;
const ASSET_CLASSIFICATION_MAX_TAG_CHARS: usize = 32;
/// 复用共享契约的归一化(trim / 去空 / 去重),再按 manifest 自己的持久化上界失败关闭。
///
/// 这里刻意不做"超长就截断":截断后的标签与共享标签库里的同一条不再相等,
/// 用户看到的标签和实际存下来的标签会静默分叉,问题只是从"文件变大"换成"标签对不上"。
fn normalize_manifest_asset_tags(tags: &[String]) -> Result<Vec<String>, String> {
let normalized = normalize_game_creation_app_asset_tags(tags);
for tag in &normalized {
if tag.chars().count() > ASSET_CLASSIFICATION_MAX_TAG_CHARS {
return Err(format!(
"单个资源标签不能超过 {ASSET_CLASSIFICATION_MAX_TAG_CHARS} 个字符"
));
}
}
if normalized.len() > ASSET_CLASSIFICATION_MAX_TAGS {
return Err(format!(
"资源标签最多支持 {ASSET_CLASSIFICATION_MAX_TAGS}"
));
}
Ok(normalized)
}
pub(crate) fn read_or_create_manifest(
root: &Path,
) -> Result<(PathBuf, GameCreationAppManifest), String> {
@@ -1556,14 +1590,20 @@ fn remove_manifest_backup(path: &Path) -> Result<(), String> {
/// 这条缓存只服务「这个资源是否已登记」这一层判断;它不放松 `file.read` 权限、项目边界、
/// 敏感路径、普通文件或读取漂移门禁 —— 那些门禁分别在 [`crate::commands::read_local_project_image_preview_at`]
/// 与真实字节读取路径上执行,且真实读取从不经过本缓存。
///
/// 填充侧同样是"快照 → 读取 → 复核快照":读取前后文件身份一致才入缓存。
pub(crate) fn read_manifest_cached_for_preview(
path: &Path,
) -> Result<GameCreationAppManifest, String> {
if let Some(cached) = cached_preview_manifest_if_unchanged(path) {
return Ok(cached);
}
// **先取身份快照,再读内容**:反过来(先读内容、后取身份)时,读取与取身份之间的一次
// 并发替换会让缓存里出现"旧内容 + 新文件身份",此后每个读取都会通过身份复核并一直返回
// 旧 manifest,直到下一次显式失效。读完复核一次身份,只有两次快照一致才允许入缓存。
let before = preview_manifest_snapshot(path);
let manifest = read_manifest(path)?;
remember_preview_manifest(path, &manifest);
remember_preview_manifest_if_stable(path, before, &manifest);
Ok(manifest)
}
@@ -1575,6 +1615,16 @@ struct CachedPreviewManifest {
manifest: GameCreationAppManifest,
}
/// 缓存命中判据里"这一份文件此刻是什么"的快照:长度 + 修改时间 + 句柄身份。
///
/// 阅读侧与写入侧共用同一个快照函数,保证"缓存的内容"与"快照的身份"永远是同一时刻取的。
#[derive(Clone, Debug, Eq, PartialEq)]
struct PreviewManifestSnapshot {
len: u64,
modified: Option<SystemTime>,
identity: Option<(u64, u64)>,
}
/// 缓存容量只需覆盖「同时打开的项目数」,取 8 已远大于实际并发。
const PREVIEW_MANIFEST_CACHE_LIMIT: usize = 8;
@@ -1596,8 +1646,9 @@ fn forget_preview_manifest(path: &Path) {
}
}
fn cached_preview_manifest_if_unchanged(path: &Path) -> Option<GameCreationAppManifest> {
let key = path.to_string_lossy().into_owned();
/// 取当前文件身份快照;路径不是普通文件(缺失、符号链接、目录、reparse point)时返回 `None`
/// 表示"这份文件此刻不可缓存"。
fn preview_manifest_snapshot(path: &Path) -> Option<PreviewManifestSnapshot> {
let metadata = fs::symlink_metadata(path).ok()?;
if metadata.file_type().is_symlink() || !metadata.is_file() {
return None;
@@ -1605,17 +1656,27 @@ fn cached_preview_manifest_if_unchanged(path: &Path) -> Option<GameCreationAppMa
if metadata_is_windows_reparse_point(&metadata) {
return None;
}
let len = metadata.len();
let modified = metadata.modified().ok();
let identity = fs::File::open(path)
.ok()
.and_then(|file| open_file_identity_key(&file).ok());
Some(PreviewManifestSnapshot {
len: metadata.len(),
modified: metadata.modified().ok(),
identity: fs::File::open(path)
.ok()
.and_then(|file| open_file_identity_key(&file).ok()),
})
}
fn cached_preview_manifest_if_unchanged(path: &Path) -> Option<GameCreationAppManifest> {
let key = path.to_string_lossy().into_owned();
let snapshot = preview_manifest_snapshot(path)?;
let mut cache = preview_manifest_cache().lock().ok()?;
let index = cache
.iter()
.position(|(cached_key, _)| cached_key == &key)?;
let cached = &cache[index].1;
if cached.len != len || cached.modified != modified || cached.identity != identity {
if cached.len != snapshot.len
|| cached.modified != snapshot.modified
|| cached.identity != snapshot.identity
{
cache.remove(index);
return None;
}
@@ -1626,17 +1687,27 @@ fn cached_preview_manifest_if_unchanged(path: &Path) -> Option<GameCreationAppMa
Some(manifest)
}
fn remember_preview_manifest(path: &Path, manifest: &GameCreationAppManifest) {
let key = path.to_string_lossy().into_owned();
let Ok(metadata) = fs::symlink_metadata(path) else {
/// 读取前后两次快照一致才入缓存:不一致说明读取期间文件被替换过,`manifest` 未必对应当前文件。
fn remember_preview_manifest_if_stable(
path: &Path,
before: Option<PreviewManifestSnapshot>,
manifest: &GameCreationAppManifest,
) {
let Some(before) = before else {
return;
};
if metadata.file_type().is_symlink() || !metadata.is_file() {
if preview_manifest_snapshot(path).as_ref() != Some(&before) {
return;
}
let identity = fs::File::open(path)
.ok()
.and_then(|file| open_file_identity_key(&file).ok());
remember_preview_manifest(path, before, manifest);
}
fn remember_preview_manifest(
path: &Path,
snapshot: PreviewManifestSnapshot,
manifest: &GameCreationAppManifest,
) {
let key = path.to_string_lossy().into_owned();
let Ok(mut cache) = preview_manifest_cache().lock() else {
return;
};
@@ -1646,9 +1717,9 @@ fn remember_preview_manifest(path: &Path, manifest: &GameCreationAppManifest) {
cache.push_back((
key,
CachedPreviewManifest {
len: metadata.len(),
modified: metadata.modified().ok(),
identity,
len: snapshot.len,
modified: snapshot.modified,
identity: snapshot.identity,
manifest: manifest.clone(),
},
));
@@ -1841,11 +1912,9 @@ where
}
let _write_lock = acquire_manifest_write_lock(path)?;
after_lock();
let result = write_manifest_locked(path, manifest, allowed_version_removals);
if result.is_ok() {
forget_preview_manifest(path);
}
result
// 预览缓存的失效放在装盘公共体里(见 `write_manifest_locked_with_version_guard`),
// 这里不再单独调用一次:所有写入通道必须共用同一个失效点。
write_manifest_locked(path, manifest, allowed_version_removals)
}
fn write_manifest_locked(
@@ -1915,6 +1984,14 @@ fn write_manifest_locked_with_version_guard(
"manifest 临时文件",
)?;
install_manifest_temp_with(path, &temp_path, |from, to| fs::rename(from, to))?;
// 文件已经被替换:从这一刻起缓存里的内容与磁盘不再对应,必须失效。
//
// 失效点放在这个唯一的装盘公共体里,所有写入通道(`write_manifest`、`mutate_manifest_at`、
// `mutate_manifest_at_allowing_version_removals`、`mutate_manifest_allowing_version_binding_rewrites`
// 都覆盖到;命中判据只有长度 + mtime + 文件身份,兜不住同一 mtime 粒度内的同长度原地改写,
// 而 Windows 上某些卷的文件身份又是恒等常量。失败路径(安装后回读不一致)同样要失效 ——
// 那时文件也已经换过了,留着旧缓存才是错的。
forget_preview_manifest(path);
prepare_game_creator_private_path_for_read(path, false, "manifest")?;
let installed = read_manifest(path)?;
if installed != *manifest {
@@ -1965,6 +2042,8 @@ mod classification_tests;
#[cfg(test)]
mod import_tests;
#[cfg(test)]
mod preview_cache_tests;
#[cfg(test)]
mod recovery_tests;
#[cfg(test)]
mod version_binding_rewrite_tests;
@@ -420,6 +420,106 @@ fn asset_classification_rejections_do_not_append_agent_db_audit_records() {
fs::remove_dir_all(root).ok();
}
/// 审计只记「真的改了什么」:把同一个分类与同一组标签再写一次不留记录。
///
/// 这条同时是「上一次已写入、只在推进 revision 时失败」那种中断态的重试语义:
/// 那次重试会重新落到同一个值,如果照旧追加,审计里就会出现一条
/// `previousCategory == category` 的假变更记录。
#[test]
fn asset_classification_repeat_write_does_not_append_a_noop_audit_record() {
let (root, asset_id) = project_with_two_assets("audit-idempotent");
let project_id = read_existing_manifest_for_project(&root)
.expect("read manifest")
.project_id;
let revision_before = read_game_creator_agent_runtime_project_revision(&root)
.expect("read revision before")
.revision;
let first = update_manifest_asset_classification_at(
&root,
&project_id,
revision_before,
&asset_id,
"scene",
vec!["主舞台".to_string()],
)
.expect("first classification update");
assert_eq!(first.committed_project_revision, revision_before + 1);
assert_eq!(classification_audit_records(&root).len(), 1);
let repeat = update_manifest_asset_classification_at(
&root,
&project_id,
first.committed_project_revision,
&asset_id,
"scene",
vec!["主舞台".to_string()],
)
.expect("repeating the same classification must succeed");
assert_eq!(repeat.committed_project_revision, revision_before + 2);
assert_eq!(repeat.asset.category, GameCreationAppAssetCategory::Scene);
assert_eq!(repeat.asset.tags, vec!["主舞台"]);
assert_eq!(
classification_audit_records(&root).len(),
1,
"同一个值重复写入不得追加假变更审计"
);
fs::remove_dir_all(root).ok();
}
/// 标签必须在写入前有界:数量与单标签长度都要拒绝,manifest 与 revision 都不动。
#[test]
fn asset_classification_rejects_unbounded_tags() {
let (root, asset_id) = project_with_two_assets("tag-bounds");
let project_id = read_existing_manifest_for_project(&root)
.expect("read manifest")
.project_id;
let revision_before = read_game_creator_agent_runtime_project_revision(&root)
.expect("read revision before")
.revision;
let manifest_before = read_existing_manifest_for_project(&root).expect("read manifest before");
let too_many = (0..17)
.map(|index| format!("标签{index}"))
.collect::<Vec<_>>();
let many_error = update_manifest_asset_classification_at(
&root,
&project_id,
revision_before,
&asset_id,
"scene",
too_many,
)
.expect_err("too many tags must be rejected");
assert!(many_error.contains("最多支持"), "unexpected: {many_error}");
let too_long = vec!["".repeat(33)];
let long_error = update_manifest_asset_classification_at(
&root,
&project_id,
revision_before,
&asset_id,
"scene",
too_long,
)
.expect_err("an over-long tag must be rejected");
assert!(long_error.contains("不能超过"), "unexpected: {long_error}");
assert_eq!(
read_existing_manifest_for_project(&root).expect("read manifest after"),
manifest_before
);
assert_eq!(
read_game_creator_agent_runtime_project_revision(&root)
.expect("read revision after")
.revision,
revision_before
);
fs::remove_dir_all(root).ok();
}
/// 审计写失败必须可见:分类已经落盘的既定行为不变(不静默回滚),但命令要报错,
/// 而且不能留下一条假的审计。
#[test]
@@ -0,0 +1,165 @@
use super::*;
/// 独立的临时项目根:缓存是进程内全局状态,按路径做键,所以每个用例必须用自己的路径。
fn preview_cache_test_root(test_name: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"genarrative-manifest-preview-cache-{test_name}-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
))
}
fn preview_cache_manifest_path(root: &Path) -> PathBuf {
let path = root.join(".agent/manifest.json");
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create preview cache manifest directory");
}
path
}
/// 直接落盘一份 manifest 内容(不走 `write_manifest`,避免它自己带来的缓存失效干扰断言)。
fn install_preview_cache_manifest(path: &Path, manifest: &GameCreationAppManifest) {
let payload = serde_json::to_string_pretty(manifest).expect("serialize preview cache manifest");
fs::write(path, format!("{payload}\n")).expect("install preview cache manifest");
}
fn preview_cache_entry_count(path: &Path) -> usize {
let key = path.to_string_lossy().into_owned();
preview_manifest_cache()
.lock()
.expect("lock preview manifest cache")
.iter()
.filter(|(cached_key, _)| cached_key == &key)
.count()
}
/// 读取前后身份一致时正常入缓存并命中。
#[test]
fn preview_cache_serves_a_repeated_read_from_the_snapshot() {
let root = preview_cache_test_root("hit");
let path = preview_cache_manifest_path(&root);
let manifest = new_game_creation_app_manifest("preview-cache-project", "预览缓存项目");
install_preview_cache_manifest(&path, &manifest);
let first = read_manifest_cached_for_preview(&path).expect("first cached read");
assert_eq!(first.project_id, "preview-cache-project");
assert_eq!(preview_cache_entry_count(&path), 1);
let second = read_manifest_cached_for_preview(&path).expect("second cached read");
assert_eq!(second, first);
assert_eq!(preview_cache_entry_count(&path), 1);
fs::remove_dir_all(root).ok();
}
/// 读取期间文件被并发替换(快照对不上)时不得入缓存:
/// 否则缓存里会出现"旧内容 + 新文件身份",之后每个读取都会通过身份复核并一直返回旧内容。
#[test]
fn preview_cache_skips_insertion_when_the_file_changed_during_the_read() {
let root = preview_cache_test_root("toc-tou");
let path = preview_cache_manifest_path(&root);
let stale = new_game_creation_app_manifest("preview-cache-stale", "旧内容");
let fresh = new_game_creation_app_manifest("preview-cache-fresh", "新内容");
install_preview_cache_manifest(&path, &stale);
// 读之前取快照 ⇒ 这里模拟"读完之后、入缓存之前"文件被并发写者替换。
// 生产写入是"临时文件 + rename",所以这里也用 rename:原地 `fs::write` 改写不会换文件身份,
// 长度与 mtime 又可能落在同一粒度内,快照根本区分不出来(那正是写入侧必须显式失效的原因)。
let before = preview_manifest_snapshot(&path).expect("snapshot before read");
let replacement_path = path.with_file_name(".manifest.json.concurrent");
install_preview_cache_manifest(&replacement_path, &fresh);
fs::rename(&replacement_path, &path).expect("replace manifest concurrently");
remember_preview_manifest_if_stable(&path, Some(before), &stale);
assert_eq!(
preview_cache_entry_count(&path),
0,
"读取期间文件变化时不得写入缓存"
);
let read = read_manifest_cached_for_preview(&path).expect("read after replacement");
assert_eq!(read.project_id, "preview-cache-fresh");
fs::remove_dir_all(root).ok();
}
/// 缓存命中判据里"文件身份"这一维必须真的在起作用:
/// 用同长度内容 + 原封不动的 mtime 走 rename 替换文件(长度与修改时间都对得上,只有身份变了),
/// 旧条目必须被丢弃并重新读取,否则缓存会一直返回替换前的 manifest。
#[test]
fn preview_cache_drops_entries_whose_file_identity_changed() {
let root = preview_cache_test_root("identity-drift");
let path = preview_cache_manifest_path(&root);
let stale = new_game_creation_app_manifest("preview-cache-before", "替换前");
let fresh = new_game_creation_app_manifest("preview-cache-after-", "替换后");
install_preview_cache_manifest(&path, &stale);
let cached = read_manifest_cached_for_preview(&path).expect("prime the cache");
assert_eq!(cached.project_id, "preview-cache-before");
let original = fs::metadata(&path).expect("original manifest metadata");
let replacement_path = path.with_file_name(".manifest.json.replacement");
install_preview_cache_manifest(&replacement_path, &fresh);
assert_eq!(
fs::metadata(&replacement_path)
.expect("replacement metadata")
.len(),
original.len(),
"这条用例要求替换内容与原内容同长度"
);
let replacement = fs::OpenOptions::new()
.write(true)
.open(&replacement_path)
.expect("open replacement manifest");
replacement
.set_times(fs::FileTimes::new().set_modified(original.modified().expect("original mtime")))
.expect("restore replacement mtime");
drop(replacement);
fs::rename(&replacement_path, &path).expect("replace manifest by rename");
let read = read_manifest_cached_for_preview(&path).expect("read after external replacement");
assert_eq!(
read.project_id, "preview-cache-after-",
"长度与修改时间都一致时,只有文件身份能拦住外部替换后的旧缓存"
);
fs::remove_dir_all(root).ok();
}
/// 主写入通道必须让预览缓存失效:`mutate_manifest_at_allowing_version_removals`
/// (素材登记 / 删除 / 分类更新 / 版本增删)走的是 `write_manifest_locked`
/// 曾经是唯一不调用 `forget_preview_manifest` 的写入路径。
#[test]
fn mutate_manifest_at_allowing_version_removals_invalidates_the_preview_cache() {
let root = preview_cache_test_root("invalidate");
let path = preview_cache_manifest_path(&root);
let mut manifest = new_game_creation_app_manifest("preview-cache-mutate", "原始项目名");
manifest.goal = Some("原始目标".to_string());
install_preview_cache_manifest(&path, &manifest);
let cached = read_manifest_cached_for_preview(&path).expect("prime the cache");
assert_eq!(cached.goal.as_deref(), Some("原始目标"));
assert_eq!(preview_cache_entry_count(&path), 1);
// 同长度原地改写:命中判据里的长度不变,只有显式失效能保证不返回旧内容。
mutate_manifest_at_allowing_version_removals(&root, &no_version_removals, |manifest| {
manifest.goal = Some("新目标值".to_string());
Ok(())
})
.expect("mutate manifest");
assert_eq!(
preview_cache_entry_count(&path),
0,
"写入通道必须显式失效预览缓存"
);
let read = read_manifest_cached_for_preview(&path).expect("read after mutation");
assert_eq!(
read.goal.as_deref(),
Some("新目标值"),
"写入通道必须显式失效预览缓存"
);
fs::remove_dir_all(root).ok();
}
@@ -1,4 +1,5 @@
use super::*;
use futures::StreamExt;
use reqwest::multipart::{Form, Part};
use std::collections::BTreeMap;
use std::path::PathBuf;
@@ -2460,20 +2461,39 @@ fn is_external_resource_edit_endpoint(endpoint: &str) -> bool {
/// 从服务端 4xx 响应体里取出**可读的失败原因**,让用户看到的不是笼统的 HTTP 状态码。
///
/// 平台错误体在不同路由上可能是 `{error:{message,details:{message}}}`、`{error:"文本"}`、
/// `{details:{message}}` 或顶层 `{message}`。**`details.message` 优先**:平台把所有 4xx 都
/// `{details:{message}}` 或顶层 `{message}`。**`error.details.message` 优先**:平台把所有 4xx 都
/// 兜底成同一条通用文案(`http_error.rs` 的 `resolve_http_error`),真实原因写在
/// `details.message` 里(例如「当前素材类型不支持图片快速编辑」),只读 `error.message`
/// 会让用户永远看到「请求参数不合法」。
async fn editor_api_rejection_reason(response: reqwest::Response) -> Option<String> {
let body = response.text().await.ok()?;
// 错误体来自外部编辑器端点,不能无上限地缓冲:有界的读法把"上游返回一个超大 4xx 正文"
// 变成"读不到可读原因",而不是让 Tauri 后端按对方给的长度分配。
const MAX_EDITOR_ERROR_BODY_BYTES: usize = 64 * 1024;
let mut body = Vec::new();
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.ok()?;
if body.len() + chunk.len() > MAX_EDITOR_ERROR_BODY_BYTES {
return None;
}
body.extend_from_slice(&chunk);
}
let body = String::from_utf8(body).ok()?;
editor_api_rejection_reason_from_body(body.as_str())
}
/// 追加到用户可见原因里的外部字段上限:正文已经按 64KiB 收口,但单个字段仍可能把
/// 「不让整段正文进 UI」的初衷抵消掉,所以每个追加项单独截断。
const MAX_EDITOR_ERROR_DETAIL_CHARS: usize = 80;
fn editor_api_rejection_reason_from_body(body: &str) -> Option<String> {
let value: serde_json::Value = serde_json::from_str(body.trim()).ok()?;
let error = value.get("error");
let details = value.get("details");
let error_details = details.or_else(|| error.and_then(|error| error.get("details")));
// 先 `error.details`、再顶层 `details`:与下面注释声明的优先级一致。
// 反过来写会让同时带两种形状的响应体永远命中笼统的顶层 `details.message`
// 真正可操作的原因(`error.details.message`)永远看不到。
let error_details = error.and_then(|error| error.get("details")).or(details);
// 取值顺序:`error.details.message` → `details.message` → `error.message` → `error` 文本
// → 顶层 `message`。前两者才是平台写进去的具体原因,后两者只是兜底。
let candidate = error_details
@@ -2512,16 +2532,20 @@ fn editor_api_rejection_reason_from_body(body: &str) -> Option<String> {
.filter(|code| !code.is_empty() && *code != "BAD_REQUEST");
let mut reason: String = candidate.chars().take(200).collect();
if let Some(provider) = detail_text("provider") {
reason.push_str(format!("provider {provider}").as_str());
reason.push_str("provider ");
reason.extend(provider.chars().take(MAX_EDITOR_ERROR_DETAIL_CHARS));
}
if let Some(asset_kind) = detail_text("assetKind") {
reason.push_str(format!("|素材类型 {asset_kind}").as_str());
reason.push_str("|素材类型 ");
reason.extend(asset_kind.chars().take(MAX_EDITOR_ERROR_DETAIL_CHARS));
}
if let Some(media_type) = detail_text("mediaType") {
reason.push_str(format!("|媒体类型 {media_type}").as_str());
reason.push_str("|媒体类型 ");
reason.extend(media_type.chars().take(MAX_EDITOR_ERROR_DETAIL_CHARS));
}
if let Some(code) = code {
reason.push_str(format!("|错误码 {code}").as_str());
reason.push_str("|错误码 ");
reason.extend(code.chars().take(MAX_EDITOR_ERROR_DETAIL_CHARS));
}
Some(reason)
}
@@ -5612,6 +5636,55 @@ mod tests {
assert_eq!(reason, "".repeat(200));
}
/// 两种形状同时出现时 `error.details.message` 必须赢;每个透出字段都要单独有界。
///
/// 平台自身目前只发 `error.details`,所以这条是"代码与文档契约一致"的护栏:
/// 一旦取值顺序被写反,同时带顶层 `details` 的响应体就只剩笼统兜底文案。
#[test]
fn editor_api_rejection_reason_prefers_nested_error_details_and_caps_every_field() {
assert_eq!(
editor_api_rejection_reason_from_body(
serde_json::json!({
"error": { "details": { "message": "嵌套里的具体原因" } },
"details": { "message": "顶层的通用文案" },
"message": "更外层文案",
})
.to_string()
.as_str(),
),
Some("嵌套里的具体原因".to_string()),
"error.details.message 必须优先于顶层 details.message"
);
// 单个超长字段不能把"有界正文"的初衷抵消掉:provider / assetKind / mediaType / code
// 都按 80 字符截断,最终文案长度因此是常数级而不是随外部正文增长。
let long = "".repeat(500);
let reason = editor_api_rejection_reason_from_body(
serde_json::json!({
"error": {
"code": long.clone(),
"details": {
"message": "具体原因",
"provider": long.clone(),
"assetKind": long.clone(),
"mediaType": long.clone(),
},
}
})
.to_string()
.as_str(),
)
.expect("超长字段不应让原因缺失");
let truncated = "".repeat(80);
assert_eq!(
reason,
format!(
"具体原因|provider {truncated}|素材类型 {truncated}|媒体类型 {truncated}|错误码 {truncated}"
),
"每个透出字段都必须按 80 字符截断"
);
}
async fn with_test_external_editor_credentials<T>(
api_base_url: &str,
api_key: &str,
@@ -354,7 +354,8 @@ fn version_removal_stays_rejected_outside_the_confirmed_asset_delete() {
fs::remove_dir_all(root).ok();
}
/// 删除同样走 CAS:陈旧 revision、跨项目身份与不存在的素材都被拒绝,manifest 与 revision 不变
/// 删除同样走 CAS:陈旧 revision、跨项目身份都被拒绝,manifest 与 revision 不变
/// 登记已经不在 manifest 里时按幂等 no-op 成功收敛(见下一条用例)。
#[test]
fn asset_delete_enforces_revision_and_identity_cas() {
let root = asset_delete_project_fixture();
@@ -371,14 +372,6 @@ fn asset_delete_enforces_revision_and_identity_cas() {
.expect_err("cross-project identity must be rejected");
assert_eq!(identity_error, "project-identity-conflict");
let missing_error =
delete_manifest_asset_at(&root, "project-1", revision_before, "absent", true)
.expect_err("missing asset must be rejected");
assert!(
missing_error.contains("项目资源不存在"),
"unexpected error: {missing_error}"
);
let manifest = read_manifest_for_project(&root).expect("read manifest after rejected deletes");
assert!(
manifest.assets.iter().any(|asset| asset.id == asset_id),
@@ -392,3 +385,39 @@ fn asset_delete_enforces_revision_and_identity_cas() {
fs::remove_dir_all(root).ok();
}
/// 「manifest 已提交、revision 未推进」那条中断路径必须能自愈:
/// 用同一个 `expectedProjectRevision` 重试要按 no-op 成功收敛,并补推进 revision。
#[test]
fn asset_delete_retry_converges_when_only_the_revision_advance_failed() {
let root = asset_delete_project_fixture();
let asset_id = register_asset_delete_fixture_asset(&root, "assets/hero.png", "art-asset-plan");
let revision_before = project_revision(&root);
delete_asset_at_current_revision(&root, &asset_id, false).expect("first delete commits");
let committed = project_revision(&root);
assert_eq!(committed, revision_before + 1);
// 复现中断态:登记已经删掉,但 revision 被写回旧值(等价于当年推进失败)。
let mut revision = read_game_creator_agent_runtime_project_revision(&root).expect("read");
revision.revision = revision_before;
write_game_creator_agent_runtime_project_revision(&root, &revision).expect("rewind revision");
let retry = delete_manifest_asset_at(&root, "project-1", revision_before, &asset_id, false)
.expect("retry must converge instead of reporting a missing asset");
assert_eq!(retry.asset_id, asset_id);
assert_eq!(retry.committed_project_revision, revision_before + 1);
assert!(retry.file_retained);
assert_eq!(
project_revision(&root),
revision_before + 1,
"收敛路径必须补推进 revision,否则工作区会停在旧 revision"
);
let manifest = read_manifest_for_project(&root).expect("read manifest after convergent retry");
assert!(
!manifest.assets.iter().any(|asset| asset.id == asset_id),
"收敛路径不得把登记写回去"
);
fs::remove_dir_all(root).ok();
}
@@ -48,6 +48,21 @@ fn asset_rename_revision(root: &Path) -> u64 {
.revision
}
/// 用当前 revision 发起重命名:模拟前端的正常调用(CAS 要求调用方带上它读到的 revision)。
fn rename_asset_at_current_revision(
root: &Path,
asset_id: &str,
new_file_name: &str,
) -> Result<RenameLocalProjectAssetResult, String> {
rename_local_project_asset_at(
root,
"project-1",
asset_rename_revision(root),
asset_id,
new_file_name,
)
}
/// 成功改名:磁盘文件真的换了名字,manifest 只改 `localPath`,资产 `id` 与其余字段不变,
/// 项目 revision 前进一格。
#[test]
@@ -56,8 +71,8 @@ fn rename_local_project_asset_renames_file_and_keeps_asset_identity() {
let before = asset_rename_entry(&root, &asset_id);
let revision_before = asset_rename_revision(&root);
let result = rename_local_project_asset_at(&root, &asset_id, "hero-idle.png", None)
.expect("rename asset");
let result =
rename_asset_at_current_revision(&root, &asset_id, "hero-idle.png").expect("rename asset");
assert_eq!(result.previous_local_path, "assets/hero.png");
assert_eq!(result.asset.local_path, "assets/hero-idle.png");
@@ -93,7 +108,7 @@ fn rename_local_project_asset_is_a_noop_for_the_same_file_name() {
let revision_before = asset_rename_revision(&root);
let result =
rename_local_project_asset_at(&root, &asset_id, "hero.png", None).expect("no-op rename");
rename_asset_at_current_revision(&root, &asset_id, "hero.png").expect("no-op rename");
assert_eq!(result.previous_local_path, "assets/hero.png");
assert_eq!(result.asset.local_path, "assets/hero.png");
@@ -125,7 +140,7 @@ fn rename_local_project_asset_rejects_unsafe_file_names() {
"..png",
"assets/../hero.png",
] {
let error = rename_local_project_asset_at(&root, &asset_id, name, None)
let error = rename_asset_at_current_revision(&root, &asset_id, name)
.expect_err("unsafe file name must be rejected");
assert!(
error.contains("不能为空") || error.contains("路径分隔符") || error.contains(".."),
@@ -148,7 +163,7 @@ fn rename_local_project_asset_rejects_cross_directory_targets() {
let (root, asset_id) = asset_rename_project_fixture();
fs::create_dir_all(root.join("assets/nested")).expect("nested dir");
let error = rename_local_project_asset_at(&root, &asset_id, "nested/hero.png", None)
let error = rename_asset_at_current_revision(&root, &asset_id, "nested/hero.png")
.expect_err("cross directory rename must be rejected");
assert!(error.contains("路径分隔符"), "意外错误:{error}");
@@ -168,7 +183,7 @@ fn rename_local_project_asset_rejects_extension_changes() {
let (root, asset_id) = asset_rename_project_fixture();
for name in ["hero.jpg", "hero", "hero.png.bak"] {
let error = rename_local_project_asset_at(&root, &asset_id, name, None)
let error = rename_asset_at_current_revision(&root, &asset_id, name)
.expect_err("extension change must be rejected");
assert!(error.contains("扩展名"), "意外错误:{error}");
}
@@ -183,7 +198,7 @@ fn rename_local_project_asset_rejects_extension_changes() {
asset_rename_source(),
)
.expect("register extensionless asset");
let error = rename_local_project_asset_at(&root, &extensionless.id, "notes.txt", None)
let error = rename_asset_at_current_revision(&root, &extensionless.id, "notes.txt")
.expect_err("adding an extension must be rejected");
assert!(error.contains("扩展名"), "意外错误:{error}");
@@ -205,7 +220,7 @@ fn rename_local_project_asset_rejects_existing_target_name() {
.expect("other asset file");
let manifest_before = fs::read(root.join(".agent/manifest.json")).expect("manifest bytes");
let error = rename_local_project_asset_at(&root, &asset_id, "other.png", None)
let error = rename_asset_at_current_revision(&root, &asset_id, "other.png")
.expect_err("existing target must be rejected");
assert!(error.contains("同名文件"), "意外错误:{error}");
@@ -230,12 +245,12 @@ fn rename_local_project_asset_rejects_existing_target_name() {
fn rename_local_project_asset_requires_an_existing_asset_and_file() {
let (root, asset_id) = asset_rename_project_fixture();
let error = rename_local_project_asset_at(&root, "missing-asset", "hero-idle.png", None)
let error = rename_asset_at_current_revision(&root, "missing-asset", "hero-idle.png")
.expect_err("unknown asset must be rejected");
assert!(error.contains("项目资源不存在"), "意外错误:{error}");
fs::remove_file(root.join("assets/hero.png")).expect("remove asset file");
let error = rename_local_project_asset_at(&root, &asset_id, "hero-idle.png", None)
let error = rename_asset_at_current_revision(&root, &asset_id, "hero-idle.png")
.expect_err("missing file must be rejected");
assert!(error.contains("素材文件不存在"), "意外错误:{error}");
assert!(!root.join("assets/hero-idle.png").exists());
@@ -253,14 +268,14 @@ fn rename_local_project_asset_rolls_back_the_file_when_manifest_write_fails() {
let (root, asset_id) = asset_rename_project_fixture();
let manifest_before = fs::read(root.join(".agent/manifest.json")).expect("manifest bytes");
let revision_before = asset_rename_revision(&root);
let error = rename_local_project_asset_at(
&root,
&asset_id,
"hero-idle.png",
Some(RenameLocalProjectAssetFaultStage::ManifestWrite),
fs::write(
root.join(".agent/runtime/test-fail-next-asset-rename-manifest-write"),
"注入下一次 manifest 写失败",
)
.expect_err("injected manifest write failure must surface");
.expect("write rename manifest failure injection marker");
let error = rename_asset_at_current_revision(&root, &asset_id, "hero-idle.png")
.expect_err("injected manifest write failure must surface");
assert!(error.contains("fault-injected"), "意外错误:{error}");
assert_eq!(
@@ -277,6 +292,101 @@ fn rename_local_project_asset_rolls_back_the_file_when_manifest_write_fails() {
fs::remove_dir_all(root).ok();
}
/// 重命名同样走 CAS:陈旧 revision 与跨项目身份都被拒绝,磁盘、manifest 与 revision 都不动。
#[test]
fn rename_local_project_asset_enforces_revision_and_identity_cas() {
let (root, asset_id) = asset_rename_project_fixture();
let manifest_before = fs::read(root.join(".agent/manifest.json")).expect("manifest bytes");
let revision_before = asset_rename_revision(&root);
let revision_error = rename_local_project_asset_at(
&root,
"project-1",
revision_before + 1,
&asset_id,
"hero-idle.png",
)
.expect_err("stale revision must be rejected");
assert_eq!(revision_error, "project-revision-conflict");
let identity_error = rename_local_project_asset_at(
&root,
"project-2",
revision_before,
&asset_id,
"hero-idle.png",
)
.expect_err("cross-project identity must be rejected");
assert_eq!(identity_error, "project-identity-conflict");
let blank_identity_error =
rename_local_project_asset_at(&root, " ", revision_before, &asset_id, "hero-idle.png")
.expect_err("blank expectedProjectId must be rejected");
assert!(
blank_identity_error.contains("不能为空"),
"{blank_identity_error}"
);
assert!(root.join("assets/hero.png").is_file());
assert!(!root.join("assets/hero-idle.png").exists());
assert_eq!(
fs::read(root.join(".agent/manifest.json")).expect("manifest bytes"),
manifest_before
);
assert_eq!(asset_rename_revision(&root), revision_before);
fs::remove_dir_all(root).ok();
}
/// `imageSequenceFrames` 的目录段按文件系统大小写语义比较:同一份文件写成别的目录大小写
/// 也要被对齐(Windows / macOS 上 `Assets/hero.png` 与 `assets/hero.png` 是同一份文件)。
#[test]
fn rename_local_project_asset_aligns_frames_with_case_insensitive_directory() {
let (root, asset_id) = asset_rename_project_fixture();
mutate_manifest_at(&root, |manifest| {
let asset = manifest
.assets
.iter_mut()
.find(|asset| asset.id == asset_id)
.expect("asset entry");
asset.image_sequence_frames = Some(
[
("Assets/hero.png", 32u32, 32u32),
("other/hero.png", 64, 64),
]
.into_iter()
.map(
|(image_src, width, height)| GameCreationAppImageSequenceFrame {
image_src: image_src.to_string(),
object_key: None,
asset_object_id: None,
width,
height,
},
)
.collect(),
);
Ok(())
})
.expect("seed image sequence frames");
rename_asset_at_current_revision(&root, &asset_id, "hero-idle.png").expect("rename asset");
let frames = asset_rename_entry(&root, &asset_id)
.image_sequence_frames
.expect("image sequence frames");
let expected = if cfg!(any(windows, target_os = "macos")) {
"assets/hero-idle.png"
} else {
"Assets/hero.png"
};
assert_eq!(frames[0].image_src, expected);
// 指向别的目录的帧在任何平台都不动。
assert_eq!(frames[1].image_src, "other/hero.png");
fs::remove_dir_all(root).ok();
}
/// `imageSequenceFrames` 里指向被改名文件的本地帧对齐到新路径,指向别的文件或远程地址的帧不动。
#[test]
fn rename_local_project_asset_aligns_image_sequence_frames_of_the_renamed_file() {
@@ -309,7 +419,7 @@ fn rename_local_project_asset_aligns_image_sequence_frames_of_the_renamed_file()
})
.expect("seed image sequence frames");
rename_local_project_asset_at(&root, &asset_id, "hero-idle.png", None).expect("rename asset");
rename_asset_at_current_revision(&root, &asset_id, "hero-idle.png").expect("rename asset");
let frames = asset_rename_entry(&root, &asset_id)
.image_sequence_frames
@@ -37,6 +37,37 @@ export function isResourceCanvasInteractionTarget(
return Boolean(target?.closest(RESOURCE_CANVAS_INTERACTION_SELECTOR));
}
/**
* 画布浮层里有自己滚动区的那几个:落在它们里面的滚轮归浮层,画布不得消费。
*
* 词表与 `RESOURCE_CANVAS_INTERACTION_SELECTOR` 的浮层成员同一套类名,只去掉按钮 / 输入 /
* 资源卡这类本身不该吞滚轮的交互控件(在卡片上滚轮照旧平移 / 缩放画布):
* - `.image-canvas-editor__generation-composer`:快速编辑 / 角色动画浮层(自带提示词滚动区);
* - `.game-resource-info-panel`:资源信息浮层(`overflow: auto`);
* - `.game-resource-filter-panel`:筛选面板(卡片区 `overflow-y: auto`)。
*/
export const RESOURCE_CANVAS_WHEEL_OVERLAY_SELECTOR = [
'.image-canvas-editor__generation-composer',
'.game-resource-info-panel',
'.game-resource-filter-panel',
].join(', ');
/**
* 这一次滚轮是不是落在「自己吃滚轮的画布浮层」里。
*
* portal 到 `document.body` 的浮层(`@` 资源选择器与候选菜单、共享模型 / 比例弹出层…)由
* `isResourceReferenceOverlayTarget` 与共享 hook 的 portal 弹层判据负责,这里只管留在画布
* DOM 里的浮层。
*/
export function isResourceCanvasWheelOverlayTarget(
target: EventTarget | null,
): boolean {
return (
target instanceof Element &&
target.closest(RESOURCE_CANVAS_WHEEL_OVERLAY_SELECTOR) !== null
);
}
/**
* 画布浮层是否可以被清焦点顺手关掉。
*
+58 -46
View File
@@ -5768,31 +5768,45 @@ iframe.preview-frame {
transition: none;
}
/* 缩略卡悬停 / 聚焦时高亮它所在栏目(那一摞)的标题条。
取值必须与 `PROJECT_RESOURCE_CANVAS_SECTIONS`7 个现行分区)逐一对齐:
`code` / `art` 是旧四、五栏目的历史值,总览里不会渲染,写它们等于空规则;
少写一个现行分区,对应栏目就完全没有悬停高亮。 */
.game-resource-book-manager--main:has(
.game-resource-book-thumbnail[data-resource-book-category='code']:hover,
.game-resource-book-thumbnail[data-resource-book-category='code']:focus-visible
.game-resource-book-thumbnail[data-resource-book-category='ui-interaction']:hover,
.game-resource-book-thumbnail[data-resource-book-category='ui-interaction']:focus-visible
)
.game-resource-book-scene-titlebar[data-resource-book-category='code'],
.game-resource-book-scene-titlebar[data-resource-book-category='ui-interaction'],
.game-resource-book-manager--main:has(
.game-resource-book-thumbnail[data-resource-book-category='character']:hover,
.game-resource-book-thumbnail[data-resource-book-category='character']:focus-visible
)
.game-resource-book-scene-titlebar[data-resource-book-category='character'],
.game-resource-book-manager--main:has(
.game-resource-book-thumbnail[data-resource-book-category='scene']:hover,
.game-resource-book-thumbnail[data-resource-book-category='scene']:focus-visible
)
.game-resource-book-scene-titlebar[data-resource-book-category='scene'],
.game-resource-book-manager--main:has(
.game-resource-book-thumbnail[data-resource-book-category='audio']:hover,
.game-resource-book-thumbnail[data-resource-book-category='audio']:focus-visible
)
.game-resource-book-scene-titlebar[data-resource-book-category='audio'],
.game-resource-book-manager--main:has(
.game-resource-book-thumbnail[data-resource-book-category='document']:hover,
.game-resource-book-thumbnail[data-resource-book-category='document']:focus-visible
)
.game-resource-book-scene-titlebar[data-resource-book-category='document'],
.game-resource-book-manager--main:has(
.game-resource-book-thumbnail[data-resource-book-category='unclassified']:hover,
.game-resource-book-thumbnail[data-resource-book-category='unclassified']:focus-visible
)
.game-resource-book-scene-titlebar[data-resource-book-category='unclassified'],
.game-resource-book-manager--main:has(
.game-resource-book-thumbnail[data-resource-book-category='version']:hover,
.game-resource-book-thumbnail[data-resource-book-category='version']:focus-visible
)
.game-resource-book-scene-titlebar[data-resource-book-category='version'],
.game-resource-book-manager--main:has(
.game-resource-book-thumbnail[data-resource-book-category='art']:hover,
.game-resource-book-thumbnail[data-resource-book-category='art']:focus-visible
)
.game-resource-book-scene-titlebar[data-resource-book-category='art'],
.game-resource-book-manager--main:has(
.game-resource-book-thumbnail[data-resource-book-category='audio']:hover,
.game-resource-book-thumbnail[data-resource-book-category='audio']:focus-visible
)
.game-resource-book-scene-titlebar[data-resource-book-category='audio'] {
.game-resource-book-scene-titlebar[data-resource-book-category='version'] {
border-top-color: #cc8060;
border-right-color: #cc8060;
border-bottom-color: #ebd9cf;
@@ -6902,10 +6916,13 @@ iframe.preview-frame {
0 0 0 2px rgb(216 115 66 / 24%);
}
/* 卡片本体是 `border: 0`(下面那条基规则)。`border-color` 单独写没有意义——0 宽的边框
画不出颜色,所以选中 / 悬停 / 聚焦都必须写成完整的 `border`,否则这三个状态在视觉上
完全看不出来。宽度与圆角保持 1px / 12px`box-sizing: border-box` 下不会改变卡片尺寸。 */
.game-resource-card:hover,
.game-resource-card:focus-within,
.game-resource-card.is-selected {
border-color: #d57b51;
border: 1px solid #d57b51;
outline: 0;
box-shadow: 0 8px 22px rgb(195 105 62 / 15%);
}
@@ -10418,13 +10435,18 @@ button.design-workspace-tree__entry:hover,
box-shadow: none;
}
/* direct-codex 输入区去默认灰描边:焦点可见性交给下面那条
`.project-supervisor-composer .resource-reference-input:focus-within`
`--platform-input-focus-ring` 光环)。这条规则比它多一个 `is-direct-codex` 类,
优先级更高,所以**不能写 `box-shadow: none`**——写回去会把那条光环整条盖掉,
而 base `.resource-reference-input-editor` 又是 `outline: 0`,输入区就完全没有
可见焦点了(无障碍回归)。 */
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.resource-reference-input:focus-within {
border: 0;
outline: none;
box-shadow: none;
}
.game-workbench-chat
@@ -10704,18 +10726,19 @@ button.design-workspace-tree__entry:hover,
line-height: 1.6;
}
/* 陶泥儿输入区的操作排(`@` / AI 润色 / 恢复原文)回到文档流:`grid-column: 1 / -1`
让它独占编辑器下面的一行,`align-items: end` + `justify-content: flex-end` 把它
贴到输入框右下角。之前这里用 `position: absolute; right: 48px; bottom: 10px`
把整排从网格里摘出来浮在输入框中间,AI 润色因此看起来压在文本区里、和下面那排
主操作(`.project-supervisor-composer-controls` 的 `@` / 快速 / 发送)脱节。
/* 陶泥儿输入区的操作排(`AI 润色` / `恢复原文`)回到文档流:网格第二行右侧那一列,
`justify-content: flex-end` 贴住输入框右下角。之前这里用
`position: absolute; right: 48px; bottom: 10px` 把整排从网格里摘出来浮在输入框中间,
AI 润色因此看起来压在文本区里、和下面那排主操作(`.project-supervisor-composer-controls`
的 `@` / 快速 / 发送)脱节。
不设 `min-height`:这一行的高度由 28px 方钮自己撑开,行高一旦被顶起来会连带把
编辑器的 `min-height: 96px` 改掉。 */
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer
.resource-reference-input-actions {
grid-column: 1 / -1;
grid-row: 2;
grid-column: 2;
position: static;
display: flex;
align-items: end;
@@ -10724,46 +10747,35 @@ button.design-workspace-tree__entry:hover,
height: auto;
}
/* 「润色中… / 润色失败」和应用排同一行:状态文字靠左占剩余宽度,操作排靠右。
单独占一行会把输入框整体顶高 22.8px,把操作排和下面那排主操作推开一个状态行的
距离;共行则状态出现/消失都不改变输入框高度。 */
/* 「润色中… / 润色失败」和应用排**同一行**:状态文字在第二行左侧那一列(占剩余宽度),
操作排在第二行右侧那一列。单独占一行会把输入框整体顶高一个状态行的距离;而给它
`grid-column: 1 / -1` 又会和操作排落进同一个网格单元互相重叠(状态文字长时压到按钮下面)。
共行分列则状态出现/消失都不改变输入框高度,也不会互相压。 */
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer
.resource-reference-input-status {
grid-column: 1 / -1;
grid-row: 2;
grid-column: 1;
align-self: center;
min-width: 0;
margin-top: 0;
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer
.resource-reference-input-at {
position: static !important;
right: auto !important;
bottom: auto !important;
display: grid;
width: 30px;
min-width: 30px;
height: 30px;
min-height: 30px;
padding: 0;
border: 0;
border-radius: 9px;
background: var(--platform-button-primary-fill);
color: var(--platform-button-primary-text);
place-items: center;
}
/* direct-codex 里 `ResourceReferenceInput` 收到的是 `showTriggerButton={!directCodex}`
即输入框内的 `.resource-reference-input-at` 根本不会渲染(`@` 触发钮是下面控制排里的
`.project-supervisor-reference-trigger`)。这里原本有一条针对它的
`position: static !important` / 30px 主色方块规则,永远匹配不到,已删除;
要改 direct-codex 的 `@` 触发钮外观请改 `.project-supervisor-reference-trigger`。 */
.design-agent-reasoning {
margin: 8px 0;
color: var(--text-muted);
font-size: 0.82em;
}
.design-agent-reasoning summary { cursor: pointer; }
.design-agent-reasoning summary {
cursor: pointer;
}
.design-agent-reasoning pre {
margin: 6px 0 0;
white-space: pre-wrap;
@@ -88,7 +88,10 @@ import {
import { ImageCanvasProjectAssetPickerDialog } from '../../../../../src/components/image-editor/ImageCanvasProjectAssetPickerDialog';
import { ImageCanvasQuickEditPanelView } from '../../../../../src/components/image-editor/ImageCanvasQuickEditPanelView';
import { ImageCanvasSelectedLayerToolbarView } from '../../../../../src/components/image-editor/ImageCanvasSelectedLayerToolbarView';
import { useImageCanvasFloatingOptionDismiss } from '../../../../../src/components/image-editor/useImageCanvasFloatingOptionDismiss';
import {
isFloatingOverlayWheelEvent,
useImageCanvasFloatingOptionDismiss,
} from '../../../../../src/components/image-editor/useImageCanvasFloatingOptionDismiss';
import { DesignWorkspacePanel } from '../../features/project-workspace/DesignWorkspacePanel';
import {
LocalGamePreviewFrame,
@@ -116,6 +119,7 @@ import {
import {
canDismissResourceCanvasQuickEdit,
isResourceCanvasInteractionTarget,
isResourceCanvasWheelOverlayTarget,
resolveResourceCanvasFloatingPanelDismissOpen,
resolveResourceCanvasFloatingPanelOpen,
resolveResourceCanvasFocusEscapeActive,
@@ -1123,6 +1127,23 @@ function ResourceBookTitleBar({
);
}
/**
*
* - `@` portal `document.body`
* - DOM / /
* `isResourceCanvasWheelOverlayTarget`
*
* preventDefault
*/
function isResourceCanvasFloatingOverlayWheelTarget(
target: EventTarget | null,
) {
return (
isResourceReferenceOverlayTarget(target) ||
isResourceCanvasWheelOverlayTarget(target)
);
}
function ResourceBookScene({
plan,
resourceBookState,
@@ -4435,11 +4456,28 @@ export default function ProjectDevelopmentView({
const handleResourceBookWheel = useCallback(
(event: ReactWheelEvent<HTMLDivElement> | WheelEvent) => {
resourceBookTransitionControllerRef.current.settle();
const sceneElement = resourceBookManagerRef.current;
if (!sceneElement) {
return;
}
/*
React portal 沿 **React ** React portal
`document.body` `@` `onWheel`
DOM
`isFloatingOverlayWheelEvent` = +
/
*/
if (
isFloatingOverlayWheelEvent(event.target, {
boundaryRefs: [resourceBookManagerRef],
isInsideExtraOverlay: isResourceCanvasFloatingOverlayWheelTarget,
})
) {
return;
}
resourceBookTransitionControllerRef.current.settle();
event.preventDefault();
const rect = sceneElement.getBoundingClientRect();
const screenPoint = {
@@ -893,6 +893,93 @@ describe('project resource live canvas integration', () => {
expect(deriveCalls[0]?.prompt).toBe(expectedText);
});
/**
* 用户报的原始现象:在「快速编辑 → 插入素材引用」开出的选择器列表上滚鼠标滚轮,
* 滚的不是列表,而是背后的资源画布(画布跟着缩放 / 平移)。
*
* 链路:选择器 portal 到 `document.body`,而 React 的 portal 事件沿 **React 树** 冒泡
* React 把委托监听挂在 portal 容器上),所以它的 wheel 照样走到画布场景根的
* `onWheel`;修复前那一下会被画布消费掉。这里用真实事件序列钉住「浮层里的滚轮归浮层、
* 画布视口一格不动」,同时用对照用例钉住「画布本体的滚轮照旧」。
*/
it('在选择素材浮层里滚轮:浮层自己收到、画布视口不动,画布本体滚轮照旧', async () => {
installTauri();
render(<DerivedWorkbench includeArt />);
fireEvent.click(screen.getByRole('button', { name: '打开待归类' }));
fireEvent.click(await findResourceSelectButton('source-art.png'));
const toolbar = await screen.findByRole('toolbar', {
name: '图片工具栏',
});
fireEvent.click(within(toolbar).getByRole('button', { name: '快速编辑' }));
const panel = await screen.findByRole('dialog', {
name: '快速编辑图片',
});
fireEvent.click(
within(panel).getByRole('button', { name: '插入素材引用' }),
);
const picker = await screen.findByRole('dialog', { name: '选择素材' });
// 前提自检:选择器 DOM 上确实不在资源画本里(这正是 React 事件仍会冒泡到画布的原因)。
const manager = document.querySelector('.game-resource-book-manager');
expect(manager?.contains(picker)).toBe(false);
expect(picker.parentElement).toBe(document.body);
const list = picker.querySelector('.resource-reference-picker-list');
if (!list) throw new Error('missing picker list');
const overlayWheelCalls = vi.fn();
list.addEventListener('wheel', overlayWheelCalls);
const readViewport = () =>
document
.querySelector('[data-resource-viewport]')
?.getAttribute('data-resource-viewport');
const readSceneWorldTransform = () =>
document
.querySelector('.game-resource-book-scene-world')
?.getAttribute('style');
const viewportBefore = readViewport();
const sceneWorldBefore = readSceneWorldTransform();
expect(viewportBefore).toBeTruthy();
// 真实事件序列:从浮层内部元素派发滚轮(等同用户在选择器列表上滚)。
const overlayWheel = new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
deltaY: 240,
clientX: 80,
clientY: 60,
});
act(() => {
list.dispatchEvent(overlayWheel);
});
// 浮层自己收到该事件、且没被画布消费(列表按原生行为滚动)。
expect(overlayWheelCalls).toHaveBeenCalledTimes(1);
expect(overlayWheel.defaultPrevented).toBe(false);
// 画布视口一格不动。
expect(readViewport()).toBe(viewportBefore);
expect(readSceneWorldTransform()).toBe(sceneWorldBefore);
list.removeEventListener('wheel', overlayWheelCalls);
// 对照用例:画布本体(场景根)上的滚轮必须照旧平移视口——修复没把画布交互一起关掉。
const sceneRoot = document.querySelector('.game-resource-book-scene');
if (!sceneRoot) throw new Error('missing scene root');
const canvasWheel = new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
deltaY: 120,
clientX: 90,
clientY: 70,
});
act(() => {
sceneRoot.dispatchEvent(canvasWheel);
});
expect(canvasWheel.defaultPrevented).toBe(true);
expect(readViewport()).not.toBe(viewportBefore);
});
it('creates a brand new media asset from the canvas generation entry with a create-mode derive request', async () => {
const { deriveCalls } = installTauri({ failFirstDerive: true });
render(<DerivedWorkbench />);
@@ -6,11 +6,15 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
import type { QuickEditPanelState } from '../../../src/components/image-editor/ImageCanvasEditorTypes';
import { ImageCanvasQuickEditPanelView } from '../../../src/components/image-editor/ImageCanvasQuickEditPanelView';
import { useImageCanvasFloatingOptionDismiss } from '../../../src/components/image-editor/useImageCanvasFloatingOptionDismiss';
import {
isFloatingOverlayWheelEvent,
useImageCanvasFloatingOptionDismiss,
} from '../../../src/components/image-editor/useImageCanvasFloatingOptionDismiss';
import {
canDismissResourceCanvasQuickEdit,
isResourceCanvasHostOverlayOpen,
isResourceCanvasInteractionTarget,
isResourceCanvasWheelOverlayTarget,
resolveResourceCanvasFloatingPanelDismissOpen,
resolveResourceCanvasFloatingPanelOpen,
resolveResourceCanvasFocusEscapeActive,
@@ -354,6 +358,69 @@ describe('资源画布浮层关闭时机', () => {
});
});
describe('画布滚轮归属判据', () => {
/**
* 与「点外部关闭」同一份口径:portal 出去的东西 DOM 上不在边界里,但 React 的事件会沿
* React 树冒泡到画布宿主的 `onWheel`。滚轮这类事件必须按 DOM 判归属,否则用户在
* `@` 选择器列表上滚动时,背后的画布会跟着平移 / 缩放。
*/
test('portal 出去的浮层与已登记浮层归浮层,边界里的画布元素归画布', () => {
document.body.innerHTML = `
<div id="owner">
<div id="card-blank">画布空白</div>
<div class="image-canvas-editor__portal-menu"><span id="menu-item">共享弹出层选项</span></div>
<div class="resource-reference-picker"><span id="picker-item">选择器条目</span></div>
</div>
<div id="portal"><span id="portal-item">portal 到 body 的候选项</span></div>
`;
const ownerRef = { current: document.querySelector<HTMLElement>('#owner') };
const options = {
boundaryRefs: [ownerRef],
isInsideExtraOverlay: (target: EventTarget | null) =>
target instanceof Element &&
target.closest('.resource-reference-picker') !== null,
};
const at = (selector: string) =>
document.querySelector<HTMLElement>(selector);
// 画布自己的:DOM 在边界里,也不是浮层。
expect(isFloatingOverlayWheelEvent(at('#card-blank'), options)).toBe(false);
// portal 到 body 的浮层:DOM 根本不在边界里 ⇒ 归浮层。
expect(isFloatingOverlayWheelEvent(at('#portal-item'), options)).toBe(true);
// 边界里但落在共享弹出层上:与「点外部关闭」同口径 ⇒ 归浮层。
expect(isFloatingOverlayWheelEvent(at('#menu-item'), options)).toBe(true);
// 边界里但由宿主登记成浮层(`@` 选择器 / 候选菜单)⇒ 归浮层。
expect(isFloatingOverlayWheelEvent(at('#picker-item'), options)).toBe(true);
// 判不出归属(没有事件目标)时不抢:归浮层。
expect(isFloatingOverlayWheelEvent(null, options)).toBe(true);
document.body.innerHTML = '';
});
test('自带滚动区的画布浮层归浮层,资源卡与画布空白仍归画布', () => {
document.body.innerHTML = `
<div class="game-resource-book-manager">
<div class="game-resource-card"><span id="card">卡片</span></div>
<div id="blank">画布空白</div>
<div class="image-canvas-editor__generation-composer"><span id="prompt">快速编辑提示词</span></div>
<div class="game-resource-info-panel"><span id="info">信息浮层</span></div>
<div class="game-resource-filter-panel"><span id="filter">筛选面板</span></div>
</div>
`;
const at = (selector: string) =>
document.querySelector<HTMLElement>(selector);
expect(isResourceCanvasWheelOverlayTarget(at('#card'))).toBe(false);
expect(isResourceCanvasWheelOverlayTarget(at('#blank'))).toBe(false);
expect(isResourceCanvasWheelOverlayTarget(at('#prompt'))).toBe(true);
expect(isResourceCanvasWheelOverlayTarget(at('#info'))).toBe(true);
expect(isResourceCanvasWheelOverlayTarget(at('#filter'))).toBe(true);
expect(isResourceCanvasWheelOverlayTarget(null)).toBe(false);
document.body.innerHTML = '';
});
});
/** 与 AGC 一样只给必要 props:下拉弹层就地渲染,不走 portal。 */
function QuickEditPanelHarness({
initialPanel,
@@ -2,6 +2,15 @@
> 当前口径:本文件保留可复用的排障经验;历史条目的旧路由、旧版本和已删除文档仅作根因背景,不得据此恢复退役入口。当前命令、路由和 schema 以代码与 `docs/README.md` 为准。
## 2026-09-12 画布滚轮要按 DOM 归属判定,portal 出去的浮层不能把滚轮让给画布
- **现象**:资源卡「快速编辑」里用 `@` 开出「选择素材」浮层后,在选择器列表上滚鼠标滚轮,列表自己在滚,背后的资源画布也一起平移 / 缩放(用户口语:「滚轮还是回滚到画布上」)。
- **原因**:选择器与输入区候选菜单 `createPortal(..., document.body)`,DOM 上不在画布管理区里;但 React 的 portal 事件沿 **React 树** 冒泡(React 把委托监听挂在 portal 容器上),所以它们的 `wheel` 照样走到画布场景根的 `onWheel`,被当成画布手势消费。React 的 `wheel` 委托监听是 passive 的:`preventDefault()` 是空操作(只报 warning),真正出问题的是视口状态被改写——所以「事件没被 preventDefault」不能作为「画布没吃这一下」的判据。
- **处理**:滚轮归属与「点外部关闭」共用同一份浮层口径(`src/components/image-editor/useImageCanvasFloatingOptionDismiss.ts``isEventInsideFloatingOverlay` + `isFloatingOverlayWheelEvent`)——DOM 不在宿主边界里的(portal 出去的一律算浮层)与已登记为浮层内部的都归浮层。资源画布只保留 `handleResourceBookWheel` 一处守卫,留在画布 DOM 里自带滚动区的浮层(快速编辑 / 信息 / 筛选,见 `RESOURCE_CANVAS_WHEEL_OVERLAY_SELECTOR`)按同一入口登记,不要再逐浮层加 `stopPropagation`。判据判不出归属时(没有元素目标)不抢滚轮。
- **排查顺序**:先确认浮层是不是 portal 出去的;是的话不要先怀疑 CSS `overflow``overscroll-behavior` 或事件被 `preventDefault`,直接查画布宿主上的 `onWheel` / `onPointerDown` 有没有做 DOM 归属判断。
- **验证**`npm run test -- apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx`(判据单测,含 portal / 共享弹出层 / 已登记浮层三种来源与「判不出归属不抢」)与 `npm run test -- apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx`(真实事件序列:选择器列表里派发 `wheel` → 画布 `data-resource-viewport` 不变、浮层自己收到该事件;对照组:场景根上派发 `wheel` → 视口照旧变化)。
- **关联**`src/components/image-editor/useImageCanvasFloatingOptionDismiss.ts``apps/ai-game-creator-shell/src/view/project-development/index.tsx``handleResourceBookWheel`)、`apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts`
## 2026-09-12 策划项目重开前必须恢复运行模式
- 工作台不能只依赖创建时的内存 `startMode`:重开时丢失该值会挂载游戏资源画布,而对话恢复后又进入策划状态,造成左右区域不一致。
@@ -6,7 +6,10 @@ import { resolve } from 'node:path';
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, test, vi } from 'vitest';
import { PlatformResourceFilterBar } from './PlatformResourceFilterBar';
import {
PlatformResourceFilterBar,
type PlatformResourceFilterBarProps,
} from './PlatformResourceFilterBar';
import { PlatformSegmentedTabs } from './PlatformSegmentedTabs';
const CATEGORY_ITEMS = [
@@ -15,9 +18,10 @@ const CATEGORY_ITEMS = [
];
/**
* 分段页签子项宽度下限是「不叠字」的唯一判据:grid 的轨道按容器等分、scroll 的
* flex 子项默认可收缩,两者都会把中文标签压到内容宽度以下,标签虽然 nowrap,
* 文字仍会画出自己的盒子压到相邻上。这条规则一旦被删掉就要失败。
* 分段页签子项宽度下限只属于 `layout="scroll"`:那一条有 `overflow-x-auto` 做兜底,
* 宽度下限换来的是「不叠字」。grid 布局没有溢出容器、轨道是 `minmax(0, 1fr)`,同样的
* `min-width: max-content` 只会把内容画到相邻上。这两条声明级断言(jsdom 不计算外部
* 样式表,可见性只能钉在声明与类名上)钉住「下限只挂在 --scroll 修饰类」。
*/
function segmentedTabsStylesheet() {
return readFileSync(
@@ -110,6 +114,50 @@ describe('PlatformResourceFilterBar', () => {
expect(screen.getByRole('button', { name: '全部' })).toBeTruthy();
});
test('标签行是横向滚动容器且不画滚动条', () => {
render(
<PlatformResourceFilterBar
ariaLabel="素材筛选"
categoryItems={CATEGORY_ITEMS}
activeCategoryId="all"
onCategoryChange={() => {}}
tagItems={[{ tag: '像素风', assetCount: 1 }]}
onToggleTag={() => {}}
/>,
);
const row = screen.getByRole('group', { name: '素材筛选标签' });
expect(row.className).toContain('platform-category-chip-scroll');
// `platform-category-chip-scroll` 只提供 `overflow-x: auto`;不配 `scrollbar-hide`
// 它就会成为唯一的「看得见滚动条」的横向滚动容器。
expect(row.className).toContain('scrollbar-hide');
expect(segmentedTabsStylesheet()).toMatch(
/\.scrollbar-hide\s*\{[^}]*scrollbar-width:\s*none/s,
);
});
test('没有 onToggleTag 时标签 chip 不得渲染成可点按钮', () => {
// 类型上 `tagItems` 必须带 `onToggleTag`(联合类型让这个非法状态不可表达);
// 这里刻意 `as unknown as` 绕过类型检查,模拟不带类型检查的调用方,
// 断言运行时兜底是「禁用」而不是「看起来可点、点了没反应」。
const props = {
ariaLabel: '素材筛选',
categoryItems: CATEGORY_ITEMS,
activeCategoryId: 'all',
onCategoryChange: () => {},
tagItems: [{ tag: '像素风', assetCount: 1 }],
activeTags: [],
} as unknown as PlatformResourceFilterBarProps<'all' | 'character'>;
render(<PlatformResourceFilterBar {...props} />);
const chip = screen.getByRole('button', {
name: /像素风/,
}) as HTMLButtonElement;
expect(chip.disabled).toBe(true);
expect(chip.getAttribute('aria-pressed')).toBe('false');
});
test('never lets a category chip shrink below its label width', () => {
const { container } = render(
<PlatformResourceFilterBar
@@ -123,6 +171,7 @@ describe('PlatformResourceFilterBar', () => {
// 分类条是 scroll 布局:容器横向可滚,标签不换行。
const row = container.querySelector('.platform-segmented-tabs');
expect(row).not.toBeNull();
expect(row?.className).toContain('platform-segmented-tabs--scroll');
expect(row?.className).toContain('overflow-x-auto');
for (const button of row?.querySelectorAll('button') ?? []) {
expect(button.className).toContain('whitespace-nowrap');
@@ -130,11 +179,15 @@ describe('PlatformResourceFilterBar', () => {
// 单靠 nowrap 不够:子项必须有不低于内容宽度的下限,否则文字会画出盒子压到相邻项。
expect(segmentedTabsStylesheet()).toMatch(
/\.platform-segmented-tabs--scroll\s*>\s*button\s*\{[^}]*min-width:\s*max-content/s,
);
// 反过来,没有溢出容器的 grid 布局不得吃这条下限(否则内容会压到相邻格上)。
expect(segmentedTabsStylesheet()).not.toMatch(
/\.platform-segmented-tabs\s*>\s*button\s*\{[^}]*min-width:\s*max-content/s,
);
});
test('grid 布局的分段页签同样带宽度下限钩子', () => {
test('grid 布局的分段页签不带 scroll 修饰类,靠轨道等分收口', () => {
render(
<PlatformSegmentedTabs
items={CATEGORY_ITEMS}
@@ -149,5 +202,8 @@ describe('PlatformResourceFilterBar', () => {
.closest('div');
expect(row?.className).toContain('platform-segmented-tabs');
expect(row?.className).toContain('grid-cols-2');
// grid 分支没有溢出容器,宽度下限一旦回到这里就会压到相邻格(本轮 review 的回归点)。
expect(row?.className).not.toContain('platform-segmented-tabs--scroll');
expect(row?.className).not.toContain('overflow-x-auto');
});
});
@@ -21,7 +21,7 @@ export type PlatformResourceFilterSearch = {
inputRef?: Ref<HTMLInputElement>;
};
export type PlatformResourceFilterBarProps<TId extends string = string> = {
type PlatformResourceFilterBarBaseProps<TId extends string = string> = {
/** 控件组无障碍名称,例如「资源筛选」。 */
ariaLabel: string;
/** 搜索行;宿主不需要搜索时省略即可,其余筛选照常渲染。 */
@@ -30,14 +30,29 @@ export type PlatformResourceFilterBarProps<TId extends string = string> = {
categoryItems: readonly PlatformResourceFilterOption<TId>[];
activeCategoryId: TId;
onCategoryChange: (id: TId) => void;
/** 派生标签库;为空时不渲染标签行。 */
tagItems?: readonly PlatformResourceTagOption[];
/** 已选标签(多选叠加)。 */
activeTags?: readonly string[];
onToggleTag?: (tag: string) => void;
className?: string;
};
/**
* `tagItems` 与 `onToggleTag` 是**成对的**:标签 chip 是带 `aria-pressed` 的按钮,
* 只给标签、不给处理函数就会渲染出一排「看起来可点、点了没反应」的控件。
* 这里用联合类型让这个非法状态在编译期就不可表达;`Component` 内部再兜一层
* `disabled`,供不带类型检查的调用方(JS / 动态构造的 props)也拿到诚实的行为。
*/
export type PlatformResourceFilterBarProps<TId extends string = string> =
| (PlatformResourceFilterBarBaseProps<TId> & {
tagItems?: undefined;
activeTags?: readonly string[];
onToggleTag?: undefined;
})
| (PlatformResourceFilterBarBaseProps<TId> & {
/** 派生标签库;为空数组时不渲染标签行。 */
tagItems: readonly PlatformResourceTagOption[];
/** 已选标签(多选叠加)。 */
activeTags?: readonly string[];
onToggleTag: (tag: string) => void;
});
function tagChipClassName(active: boolean) {
return [
'platform-category-chip gap-1.5 px-2.5 text-xs font-bold',
@@ -104,7 +119,7 @@ export function PlatformResourceFilterBar<TId extends string = string>({
/>
{tagOptions.length > 0 ? (
<div
className="platform-category-chip-scroll min-w-0 flex-1"
className="platform-category-chip-scroll scrollbar-hide min-w-0 flex-1"
role="group"
aria-label={`${ariaLabel}标签`}
>
@@ -116,6 +131,9 @@ export function PlatformResourceFilterBar<TId extends string = string>({
type="button"
aria-pressed={active}
className={tagChipClassName(active)}
// 类型上 `tagItems` 必然带 `onToggleTag`;这一层兜底是给不带类型检查的
// 调用方(JS / 动态构造的 props):没有处理函数就不该渲染成可点按钮。
disabled={!onToggleTag}
onClick={() => onToggleTag?.(option.tag)}
>
<Tag className="h-3 w-3" aria-hidden="true" />
@@ -193,7 +193,7 @@ export function PlatformSegmentedTabs<TId extends string = string>({
className={[
'platform-segmented-tabs',
layout === 'scroll'
? 'flex min-w-0 items-center overflow-x-auto scrollbar-hide'
? 'platform-segmented-tabs--scroll flex min-w-0 items-center overflow-x-auto scrollbar-hide'
: 'grid',
PLATFORM_SEGMENTED_TABS_FRAME_CLASS[frame],
PLATFORM_SEGMENTED_TABS_GAP_CLASS[gap],

Some files were not shown because too many files have changed in this diff Show More