合并资源工作台分支:PR review 前端批次并入(含滚轮归属与重命名 CAS)
- 把远端最新 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:
@@ -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 都要扫一遍 manifest(O(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("关联素材 ID:asset-hero\n")
|
||||
|| section.trim_end().ends_with("关联素材 ID:asset-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>(¤t)
|
||||
.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(
|
||||
¤t_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
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 画布浮层是否可以被清焦点顺手关掉。
|
||||
*
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user