完善资源管理工作台与有界预览

将资源卡本体化并补齐分区缩放滚动与依赖聚类

增加全进程三槽预览调度范围取消与安全分块读取

保持布局 CAS 预览缓存回收和依赖图权威合同

补齐资源管理前端 Tauri AppSurface 回归并同步文档
This commit is contained in:
2026-08-11 18:04:11 +08:00
parent 3c457feb4c
commit ffb4cb5d62
32 changed files with 8777 additions and 1566 deletions
@@ -1255,14 +1255,32 @@ pub(crate) fn read_local_project_file(
}
#[tauri::command]
pub(crate) fn read_local_project_image_preview(
pub(crate) async fn read_local_project_image_preview(
preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>,
project_path: String,
relative_path: String,
scope_id: String,
request_id: String,
) -> Result<LocalProjectImagePreview, String> {
preview_manager
.run(&scope_id, &request_id, move |cancellation| {
read_local_project_image_preview_at(&project_path, &relative_path, cancellation)
})
.await
}
pub(crate) fn read_local_project_image_preview_at(
project_path: &str,
relative_path: &str,
cancellation: &ProjectResourcePreviewScopeCancellation,
) -> Result<LocalProjectImagePreview, String> {
cancellation.check()?;
let root = Path::new(project_path.trim());
enforce_project_auto_permission_policy(root, "file.read")?;
cancellation.check()?;
let normalized_path = normalize_relative_path(relative_path.trim())?;
let manifest = read_manifest(&root.join(".agent/manifest.json"))?;
cancellation.check()?;
let is_registered_asset = manifest
.assets
.iter()
@@ -1274,18 +1292,37 @@ pub(crate) fn read_local_project_image_preview(
if !is_registered_asset && !is_completed_task_artifact {
return Err("只能预览已登记资源或已完成任务的图片产物".to_string());
}
load_local_project_image_preview(root, &normalized_path)
cancellation.check()?;
load_local_project_image_preview_with_cancellation(root, &normalized_path, cancellation)
}
#[tauri::command]
pub(crate) fn read_local_project_text_preview(
pub(crate) async fn read_local_project_text_preview(
preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>,
project_path: String,
relative_path: String,
scope_id: String,
request_id: String,
) -> Result<LocalProjectTextPreview, String> {
preview_manager
.run(&scope_id, &request_id, move |cancellation| {
read_local_project_text_preview_at(&project_path, &relative_path, cancellation)
})
.await
}
pub(crate) fn read_local_project_text_preview_at(
project_path: &str,
relative_path: &str,
cancellation: &ProjectResourcePreviewScopeCancellation,
) -> Result<LocalProjectTextPreview, String> {
cancellation.check()?;
let root = Path::new(project_path.trim());
enforce_project_auto_permission_policy(root, "file.read")?;
cancellation.check()?;
let normalized_path = normalize_relative_path(relative_path.trim())?;
let manifest = read_manifest(&root.join(".agent/manifest.json"))?;
cancellation.check()?;
let is_registered_document = manifest.assets.iter().any(|asset| {
asset.local_path == normalized_path
&& is_supported_project_text_resource(&asset.local_path, &asset.media_type)
@@ -1297,19 +1334,44 @@ pub(crate) fn read_local_project_text_preview(
if !is_registered_document {
return Err("只能读取当前项目已登记的文档资源".to_string());
}
load_local_project_text_preview(root, &normalized_path)
cancellation.check()?;
load_local_project_text_preview_with_cancellation(root, &normalized_path, cancellation)
}
#[tauri::command]
pub(crate) fn read_local_project_media_preview(
pub(crate) async fn read_local_project_media_preview(
preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>,
project_path: String,
relative_path: String,
category: String,
scope_id: String,
request_id: String,
) -> Result<LocalProjectMediaPreview, String> {
preview_manager
.run(&scope_id, &request_id, move |cancellation| {
read_local_project_media_preview_at(
&project_path,
&relative_path,
&category,
cancellation,
)
})
.await
}
pub(crate) fn read_local_project_media_preview_at(
project_path: &str,
relative_path: &str,
category: &str,
cancellation: &ProjectResourcePreviewScopeCancellation,
) -> Result<LocalProjectMediaPreview, String> {
cancellation.check()?;
let root = Path::new(project_path.trim());
enforce_project_auto_permission_policy(root, "file.read")?;
cancellation.check()?;
let normalized_path = normalize_relative_path(relative_path.trim())?;
let manifest = read_manifest(&root.join(".agent/manifest.json"))?;
cancellation.check()?;
let kind = match category.trim() {
"art" => ProjectMediaPreviewKind::Art,
"audio" => ProjectMediaPreviewKind::Audio,
@@ -1334,7 +1396,16 @@ pub(crate) fn read_local_project_media_preview(
if !is_registered_media {
return Err("只能预览当前项目已登记的媒体资源".to_string());
}
load_local_project_media_preview(root, &normalized_path, kind)
cancellation.check()?;
load_local_project_media_preview_with_cancellation(root, &normalized_path, kind, cancellation)
}
#[tauri::command]
pub(crate) fn cancel_local_project_resource_preview_scope(
preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>,
scope_id: String,
) -> Result<(), String> {
preview_manager.cancel_scope(&scope_id)
}
#[tauri::command]
@@ -2,6 +2,7 @@ use crate::project::{
normalize_relative_path, open_project_snapshot_regular_file,
reject_sensitive_project_file_read, resolve_local_project_path,
};
use crate::resource_preview_scheduler::ProjectResourcePreviewScopeCancellation;
use base64::Engine as _;
use serde::Serialize;
use sha2::{Digest, Sha256};
@@ -15,6 +16,7 @@ pub(crate) const AGENT_RUNTIME_IMAGE_INSPECT_MAX_FILE_BYTES: u64 = 8 * 1024 * 10
pub(crate) const AGENT_RUNTIME_IMAGE_INSPECT_MAX_TOTAL_BYTES: u64 = 12 * 1024 * 1024;
const PROJECT_IMAGE_PREVIEW_MAX_DIMENSION: u32 = 8_192;
const PROJECT_IMAGE_PREVIEW_MAX_PIXELS: u64 = 32 * 1024 * 1024;
const PROJECT_IMAGE_PREVIEW_READ_CHUNK_BYTES: usize = 64 * 1024;
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -41,12 +43,34 @@ impl AgentRuntimeInspectionImage {
base64::engine::general_purpose::STANDARD.encode(&self.bytes)
)
}
fn data_url_with_cancellation(
&self,
cancellation: &ProjectResourcePreviewScopeCancellation,
) -> Result<String, String> {
cancellation.check()?;
Ok(self.data_url())
}
}
#[cfg(test)]
pub(crate) fn load_local_project_image_preview(
root: &Path,
relative_path: &str,
) -> Result<LocalProjectImagePreview, String> {
load_local_project_image_preview_with_cancellation(
root,
relative_path,
&ProjectResourcePreviewScopeCancellation::uncancelled(),
)
}
pub(crate) fn load_local_project_image_preview_with_cancellation(
root: &Path,
relative_path: &str,
cancellation: &ProjectResourcePreviewScopeCancellation,
) -> Result<LocalProjectImagePreview, String> {
cancellation.check()?;
let normalized = normalize_relative_path(relative_path.trim())?;
if !normalized.starts_with("assets/") && !normalized.starts_with("game/") {
return Err("图片预览只允许读取 assets/ 或 game/ 下的项目图片".to_string());
@@ -54,12 +78,15 @@ pub(crate) fn load_local_project_image_preview(
reject_sensitive_project_file_read(&normalized)?;
let absolute = resolve_local_project_path(root, &normalized)?;
validate_agent_runtime_inspection_ancestors(root, &absolute)?;
let image = read_agent_runtime_inspection_image(&absolute, normalized)?;
cancellation.check()?;
let image =
read_agent_runtime_inspection_image_with_cancellation(&absolute, normalized, cancellation)?;
cancellation.check()?;
Ok(LocalProjectImagePreview {
path: image.relative_path.clone(),
media_type: image.media_type.to_string(),
byte_len: image.byte_len,
data_url: image.data_url(),
data_url: image.data_url_with_cancellation(cancellation)?,
})
}
@@ -236,7 +263,21 @@ fn read_agent_runtime_inspection_image(
path: &Path,
relative_path: String,
) -> Result<AgentRuntimeInspectionImage, String> {
read_agent_runtime_inspection_image_with_cancellation(
path,
relative_path,
&ProjectResourcePreviewScopeCancellation::uncancelled(),
)
}
fn read_agent_runtime_inspection_image_with_cancellation(
path: &Path,
relative_path: String,
cancellation: &ProjectResourcePreviewScopeCancellation,
) -> Result<AgentRuntimeInspectionImage, String> {
cancellation.check()?;
let (mut file, initial_metadata) = open_project_snapshot_regular_file(path, "视觉检查图片")?;
cancellation.check()?;
if initial_metadata.len() == 0 {
return Err(format!("image.inspect 图片不能为空:{relative_path}"));
}
@@ -248,10 +289,26 @@ fn read_agent_runtime_inspection_image(
}
let mut bytes = Vec::with_capacity(initial_metadata.len() as usize);
file.by_ref()
.take(AGENT_RUNTIME_IMAGE_INSPECT_MAX_FILE_BYTES + 1)
.read_to_end(&mut bytes)
.map_err(|error| format!("读取 image.inspect 图片失败:{relative_path}: {error}"))?;
let mut chunk = [0_u8; PROJECT_IMAGE_PREVIEW_READ_CHUNK_BYTES];
loop {
cancellation.check()?;
let remaining =
(AGENT_RUNTIME_IMAGE_INSPECT_MAX_FILE_BYTES + 1).saturating_sub(bytes.len() as u64);
if remaining == 0 {
break;
}
let read_len = usize::try_from(remaining)
.unwrap_or(usize::MAX)
.min(chunk.len());
let count = file
.read(&mut chunk[..read_len])
.map_err(|error| format!("读取 image.inspect 图片失败:{relative_path}: {error}"))?;
if count == 0 {
break;
}
bytes.extend_from_slice(&chunk[..count]);
}
cancellation.check()?;
if bytes.len() as u64 > AGENT_RUNTIME_IMAGE_INSPECT_MAX_FILE_BYTES {
return Err(format!(
"image.inspect 单张图片不能超过 {} MiB{relative_path}",
@@ -270,14 +327,17 @@ fn read_agent_runtime_inspection_image(
));
}
cancellation.check()?;
let (reopened, reopened_metadata) = open_project_snapshot_regular_file(path, "视觉检查图片")?;
if !same_open_file_identity(&file, &initial_metadata, &reopened, &reopened_metadata)? {
return Err(format!(
"image.inspect 图片路径读取期间发生替换:{relative_path}"
));
}
cancellation.check()?;
let media_type = detect_agent_runtime_image_media_type(&bytes)
.ok_or_else(|| format!("image.inspect 只支持 PNG、JPEG 或 WEBP{relative_path}"))?;
cancellation.check()?;
let (width, height) = detect_raster_image_dimensions(&bytes, media_type)
.ok_or_else(|| format!("image.inspect 图片结构无效:{relative_path}"))?;
let pixels = u64::from(width)
@@ -293,6 +353,7 @@ fn read_agent_runtime_inspection_image(
"image.inspect 图片尺寸过大:{width}x{height},最大边长 {PROJECT_IMAGE_PREVIEW_MAX_DIMENSION},最大像素 {PROJECT_IMAGE_PREVIEW_MAX_PIXELS}{relative_path}"
));
}
cancellation.check()?;
let sha256 = format!("{:x}", Sha256::digest(&bytes));
Ok(AgentRuntimeInspectionImage {
relative_path,
@@ -588,6 +649,28 @@ mod tests {
assert!(preview.data_url.starts_with("data:image/png;base64,"));
}
#[test]
fn cancelled_image_preview_does_not_enter_base64_encoding() {
let root = tempfile::tempdir().expect("temp root");
fs::create_dir_all(root.path().join("assets/ui")).expect("asset dir");
fs::write(root.path().join("assets/ui/prototype.png"), png_bytes()).expect("image");
let image = read_agent_runtime_inspection_image(
&root.path().join("assets/ui/prototype.png"),
"assets/ui/prototype.png".to_string(),
)
.expect("read project image");
let cancellation = ProjectResourcePreviewScopeCancellation::uncancelled();
cancellation.cancel();
assert_eq!(
image.data_url_with_cancellation(&cancellation),
Err(
crate::resource_preview_scheduler::PROJECT_RESOURCE_PREVIEW_CANCELLED_ERROR
.to_string()
)
);
}
#[test]
fn local_project_image_preview_rejects_non_project_asset_paths() {
let root = tempfile::tempdir().expect("temp root");
@@ -74,6 +74,7 @@ mod provider_handoff;
mod provider_retry;
mod repository_context;
mod resource_inspect;
mod resource_preview_scheduler;
mod runner;
mod swarm_cli;
mod tool_plan_handoff;
@@ -104,6 +105,7 @@ use process_session::*;
use project::*;
use repository_context::*;
use resource_inspect::*;
use resource_preview_scheduler::*;
use runner::*;
use swarm_cli::*;
use user_input::*;
@@ -2040,6 +2042,7 @@ fn main() {
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_clipboard_manager::init())
.manage(game_creator_preview_registry())
.manage(ProjectResourcePreviewReadManager::default())
.setup(move |app| {
if let Some(path) = setup_log.as_deref() {
let _ = append_bounded_diagnostic_line(path, "startup.setup.begin");
@@ -2223,6 +2226,7 @@ fn main() {
read_local_project_image_preview,
read_local_project_text_preview,
read_local_project_media_preview,
cancel_local_project_resource_preview_scope,
write_local_project_file,
delete_local_project_file,
read_local_game_memory,
@@ -862,11 +862,27 @@ mod tests {
.expect_err("unknown schema must fail");
assert!(schema_error.contains("schema"));
let coordinate_error = update_project_resource_canvas_layout_at(
let boundary = update_project_resource_canvas_layout_at(
&root,
ProjectResourceCanvasLayoutMode::Type,
"layout-invalid",
0,
vec![layout_position(
"asset-coordinate-boundary",
RESOURCE_LAYOUT_MAX_COORDINATE,
)],
)
.expect("boundary coordinate must remain valid");
assert_eq!(
boundary.layout.positions[0].x,
RESOURCE_LAYOUT_MAX_COORDINATE
);
let coordinate_error = update_project_resource_canvas_layout_at(
&root,
ProjectResourceCanvasLayoutMode::Type,
"layout-invalid",
boundary.layout.revision,
vec![layout_position(
"asset-coordinate",
RESOURCE_LAYOUT_MAX_COORDINATE + 1,
@@ -5,6 +5,7 @@ use crate::project::{
normalize_relative_path, open_project_snapshot_regular_file,
reject_sensitive_project_file_read, resolve_local_project_path,
};
use crate::resource_preview_scheduler::ProjectResourcePreviewScopeCancellation;
use base64::Engine as _;
use serde::Serialize;
use std::io::Read;
@@ -12,6 +13,7 @@ use std::path::Path;
const PROJECT_TEXT_PREVIEW_MAX_FILE_BYTES: u64 = 2 * 1024 * 1024;
const PROJECT_MEDIA_PREVIEW_MAX_FILE_BYTES: u64 = 32 * 1024 * 1024;
const PROJECT_RESOURCE_PREVIEW_READ_CHUNK_BYTES: usize = 64 * 1024;
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -69,10 +71,24 @@ pub(crate) fn is_supported_project_audio_resource(path: &str, media_type: &str)
) || media_type.starts_with("audio/")
}
#[cfg(test)]
pub(crate) fn load_local_project_text_preview(
root: &Path,
relative_path: &str,
) -> Result<LocalProjectTextPreview, String> {
load_local_project_text_preview_with_cancellation(
root,
relative_path,
&ProjectResourcePreviewScopeCancellation::uncancelled(),
)
}
pub(crate) fn load_local_project_text_preview_with_cancellation(
root: &Path,
relative_path: &str,
cancellation: &ProjectResourcePreviewScopeCancellation,
) -> Result<LocalProjectTextPreview, String> {
cancellation.check()?;
let normalized = normalize_relative_path(relative_path.trim())?;
reject_sensitive_project_file_read(&normalized)?;
let media_type = project_text_media_type(&normalized)
@@ -82,7 +98,9 @@ pub(crate) fn load_local_project_text_preview(
&normalized,
PROJECT_TEXT_PREVIEW_MAX_FILE_BYTES,
"项目文档",
cancellation,
)?;
cancellation.check()?;
let content =
String::from_utf8(bytes).map_err(|_| "文档预览只支持 UTF-8 编码的文本文件".to_string())?;
Ok(LocalProjectTextPreview {
@@ -93,11 +111,27 @@ pub(crate) fn load_local_project_text_preview(
})
}
#[cfg(test)]
pub(crate) fn load_local_project_media_preview(
root: &Path,
relative_path: &str,
kind: ProjectMediaPreviewKind,
) -> Result<LocalProjectMediaPreview, String> {
load_local_project_media_preview_with_cancellation(
root,
relative_path,
kind,
&ProjectResourcePreviewScopeCancellation::uncancelled(),
)
}
pub(crate) fn load_local_project_media_preview_with_cancellation(
root: &Path,
relative_path: &str,
kind: ProjectMediaPreviewKind,
cancellation: &ProjectResourcePreviewScopeCancellation,
) -> Result<LocalProjectMediaPreview, String> {
cancellation.check()?;
let normalized = normalize_relative_path(relative_path.trim())?;
reject_sensitive_project_file_read(&normalized)?;
let bytes = read_stable_project_resource(
@@ -105,39 +139,70 @@ pub(crate) fn load_local_project_media_preview(
&normalized,
PROJECT_MEDIA_PREVIEW_MAX_FILE_BYTES,
"项目媒体资源",
cancellation,
)?;
if bytes.is_empty() {
return Err("媒体文件为空,无法预览".to_string());
}
cancellation.check()?;
let media_type = detect_project_media_type(&normalized, &bytes, kind)?;
cancellation.check()?;
Ok(LocalProjectMediaPreview {
path: normalized,
media_type: media_type.to_string(),
byte_len: bytes.len() as u64,
data_url: format!(
"data:{media_type};base64,{}",
base64::engine::general_purpose::STANDARD.encode(bytes)
),
data_url: encode_project_resource_preview_data_url(media_type, &bytes, cancellation)?,
})
}
fn encode_project_resource_preview_data_url(
media_type: &str,
bytes: &[u8],
cancellation: &ProjectResourcePreviewScopeCancellation,
) -> Result<String, String> {
cancellation.check()?;
Ok(format!(
"data:{media_type};base64,{}",
base64::engine::general_purpose::STANDARD.encode(bytes)
))
}
fn read_stable_project_resource(
root: &Path,
normalized: &str,
max_bytes: u64,
label: &str,
cancellation: &ProjectResourcePreviewScopeCancellation,
) -> Result<Vec<u8>, String> {
cancellation.check()?;
let absolute = resolve_local_project_path(root, normalized)?;
validate_agent_runtime_inspection_ancestors(root, &absolute)?;
cancellation.check()?;
let (mut file, initial_metadata) = open_project_snapshot_regular_file(&absolute, label)?;
cancellation.check()?;
if initial_metadata.len() > max_bytes {
return Err(format!("{label}不能超过 {} MiB", max_bytes / 1024 / 1024));
}
let mut bytes = Vec::with_capacity(initial_metadata.len() as usize);
file.by_ref()
.take(max_bytes + 1)
.read_to_end(&mut bytes)
.map_err(|error| format!("读取{label}失败:{normalized}: {error}"))?;
let mut chunk = [0_u8; PROJECT_RESOURCE_PREVIEW_READ_CHUNK_BYTES];
loop {
cancellation.check()?;
let remaining = (max_bytes + 1).saturating_sub(bytes.len() as u64);
if remaining == 0 {
break;
}
let read_len = usize::try_from(remaining)
.unwrap_or(usize::MAX)
.min(chunk.len());
let count = file
.read(&mut chunk[..read_len])
.map_err(|error| format!("读取{label}失败:{normalized}: {error}"))?;
if count == 0 {
break;
}
bytes.extend_from_slice(&chunk[..count]);
}
cancellation.check()?;
if bytes.len() as u64 > max_bytes {
return Err(format!("{label}不能超过 {} MiB", max_bytes / 1024 / 1024));
}
@@ -150,10 +215,12 @@ fn read_stable_project_resource(
{
return Err(format!("{label}读取期间发生漂移:{normalized}"));
}
cancellation.check()?;
let (reopened, reopened_metadata) = open_project_snapshot_regular_file(&absolute, label)?;
if !same_open_file_identity(&file, &initial_metadata, &reopened, &reopened_metadata)? {
return Err(format!("{label}路径读取期间发生替换:{normalized}"));
}
cancellation.check()?;
Ok(bytes)
}
@@ -438,4 +505,17 @@ mod tests {
assert!(load_local_project_text_preview(root.path(), "docs/link.md").is_err());
assert!(load_local_project_text_preview(root.path(), "docs/hard.md").is_err());
}
#[test]
fn cancelled_media_preview_does_not_enter_base64_encoding() {
let cancellation = ProjectResourcePreviewScopeCancellation::uncancelled();
cancellation.cancel();
assert_eq!(
encode_project_resource_preview_data_url("audio/mpeg", b"ID3", &cancellation),
Err(
crate::resource_preview_scheduler::PROJECT_RESOURCE_PREVIEW_CANCELLED_ERROR
.to_string()
)
);
}
}
File diff suppressed because it is too large Load Diff
@@ -6004,6 +6004,8 @@ fn project_checkpoint_excludes_and_preserves_sensitive_local_files() {
#[test]
fn local_project_image_preview_obeys_auto_file_read_policy() {
let root = unique_project_path();
let project_path = root.to_string_lossy().into_owned();
let cancellation = ProjectResourcePreviewScopeCancellation::uncancelled();
init_local_game_project_at(&root, "image-preview-policy", "图片预览策略项目")
.expect("project init");
fs::create_dir_all(root.join("assets")).expect("asset dir");
@@ -6041,11 +6043,9 @@ fn local_project_image_preview_obeys_auto_file_read_policy() {
},
)
.expect("confirm policy");
let confirm_error = read_local_project_image_preview(
root.to_string_lossy().into_owned(),
"assets/preview.png".to_string(),
)
.expect_err("confirm policy blocks automatic preview");
let confirm_error =
read_local_project_image_preview_at(&project_path, "assets/preview.png", &cancellation)
.expect_err("confirm policy blocks automatic preview");
assert!(confirm_error.contains("要求用户确认:file.read"));
write_project_permission_policy_at(
@@ -6057,11 +6057,9 @@ fn local_project_image_preview_obeys_auto_file_read_policy() {
},
)
.expect("deny policy");
let deny_error = read_local_project_image_preview(
root.to_string_lossy().into_owned(),
"assets/preview.png".to_string(),
)
.expect_err("deny policy blocks preview");
let deny_error =
read_local_project_image_preview_at(&project_path, "assets/preview.png", &cancellation)
.expect_err("deny policy blocks preview");
assert!(deny_error.contains("拒绝执行:file.read"));
write_project_permission_policy_at(
@@ -6073,17 +6071,16 @@ fn local_project_image_preview_obeys_auto_file_read_policy() {
},
)
.expect("auto policy");
let preview = read_local_project_image_preview(
root.to_string_lossy().into_owned(),
"assets/preview.png".to_string(),
)
.expect("auto preview");
let preview =
read_local_project_image_preview_at(&project_path, "assets/preview.png", &cancellation)
.expect("auto preview");
assert_eq!(preview.media_type, "image/png");
fs::write(root.join("assets/unregistered.png"), &preview_bytes).expect("unregistered image");
let unregistered_error = read_local_project_image_preview(
root.to_string_lossy().into_owned(),
"assets/unregistered.png".to_string(),
let unregistered_error = read_local_project_image_preview_at(
&project_path,
"assets/unregistered.png",
&cancellation,
)
.expect_err("unregistered image rejected");
assert!(unregistered_error.contains("只能预览已登记资源"));
@@ -6094,6 +6091,8 @@ fn local_project_image_preview_obeys_auto_file_read_policy() {
#[test]
fn local_project_resource_previews_require_registered_safe_resources() {
let root = unique_project_path();
let project_path = root.to_string_lossy().into_owned();
let cancellation = ProjectResourcePreviewScopeCancellation::uncancelled();
init_local_game_project_at(&root, "resource-preview-policy", "资源预览策略项目")
.expect("project init");
fs::create_dir_all(root.join("assets")).expect("asset dir");
@@ -6151,44 +6150,37 @@ fn local_project_resource_previews_require_registered_safe_resources() {
)
.expect("register audio");
let document = read_local_project_text_preview(
root.to_string_lossy().into_owned(),
"game/design.md".to_string(),
)
.expect("read registered document");
let document =
read_local_project_text_preview_at(&project_path, "game/design.md", &cancellation)
.expect("read registered document");
assert_eq!(document.media_type, "text/markdown");
assert!(document.content.contains("安全正文"));
let svg = read_local_project_media_preview(
root.to_string_lossy().into_owned(),
"assets/icon.svg".to_string(),
"art".to_string(),
)
.expect("read registered svg");
let svg =
read_local_project_media_preview_at(&project_path, "assets/icon.svg", "art", &cancellation)
.expect("read registered svg");
assert_eq!(svg.media_type, "image/svg+xml");
let audio = read_local_project_media_preview(
root.to_string_lossy().into_owned(),
"assets/bgm.mp3".to_string(),
"audio".to_string(),
let audio = read_local_project_media_preview_at(
&project_path,
"assets/bgm.mp3",
"audio",
&cancellation,
)
.expect("read registered audio");
assert_eq!(audio.media_type, "audio/mpeg");
let unregistered_error = read_local_project_text_preview(
root.to_string_lossy().into_owned(),
"game/unregistered.md".to_string(),
)
.expect_err("unregistered document rejected");
let unregistered_error =
read_local_project_text_preview_at(&project_path, "game/unregistered.md", &cancellation)
.expect_err("unregistered document rejected");
assert!(unregistered_error.contains("已登记的文档资源"));
assert!(read_local_project_text_preview(
root.to_string_lossy().into_owned(),
"../outside.md".to_string(),
)
.is_err());
assert!(read_local_project_media_preview(
root.to_string_lossy().into_owned(),
"assets/bgm.mp3".to_string(),
"art".to_string(),
assert!(
read_local_project_text_preview_at(&project_path, "../outside.md", &cancellation,).is_err()
);
assert!(read_local_project_media_preview_at(
&project_path,
"assets/bgm.mp3",
"art",
&cancellation,
)
.is_err());
@@ -22,6 +22,11 @@ import type {
PendingCommand,
PendingUiConfirmation,
} from '../../app/types';
import {
cancelLocalProjectResourcePreviewScope,
createProjectResourcePreviewRequestId,
createProjectResourcePreviewScopeId,
} from '../../services/projectResourcePreviewTransport';
import {
formatAgentRuntimeEvent,
isAgentRuntimeTerminalState,
@@ -1184,6 +1189,7 @@ export function SupervisorChatOnlyView({
);
return undefined;
}
const scopeId = createProjectResourcePreviewScopeId();
let cancelled = false;
setSelectedResultImage(null);
setResultImagePreviews(
@@ -1201,6 +1207,8 @@ export function SupervisorChatOnlyView({
{
projectPath,
relativePath: image.path,
scopeId,
requestId: createProjectResourcePreviewRequestId(),
},
);
if (
@@ -1222,6 +1230,7 @@ export function SupervisorChatOnlyView({
});
return () => {
cancelled = true;
cancelLocalProjectResourcePreviewScope(scopeId);
};
// resultImageKey is the stable semantic identity for the derived image list.
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -0,0 +1,51 @@
const PROJECT_RESOURCE_PREVIEW_CANCELLED_ERROR =
'project-resource-preview-scope-cancelled';
let projectResourcePreviewOpaqueIdSequence = 0;
function createProjectResourcePreviewOpaqueId(prefix: 'scope' | 'request') {
projectResourcePreviewOpaqueIdSequence += 1;
const cryptoApi = globalThis.crypto;
const randomId = cryptoApi?.randomUUID?.();
if (randomId) {
return `${prefix}:${randomId}`;
}
if (cryptoApi?.getRandomValues) {
const randomWords = cryptoApi.getRandomValues(new Uint32Array(4));
return `${prefix}:${Array.from(randomWords, (word) => word.toString(36)).join('-')}`;
}
return `${prefix}:${Date.now().toString(36)}:${Math.random().toString(36).slice(2)}:${projectResourcePreviewOpaqueIdSequence.toString(36)}`;
}
export function createProjectResourcePreviewScopeId() {
return createProjectResourcePreviewOpaqueId('scope');
}
export function createProjectResourcePreviewRequestId() {
return createProjectResourcePreviewOpaqueId('request');
}
export function isProjectResourcePreviewCancellation(error: unknown) {
const message =
error instanceof Error
? error.message
: typeof error === 'string'
? error
: null;
return message === PROJECT_RESOURCE_PREVIEW_CANCELLED_ERROR;
}
export function cancelLocalProjectResourcePreviewScope(scopeId: string) {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
return;
}
try {
void Promise.resolve(
invoke('cancel_local_project_resource_preview_scope', { scopeId }),
).catch(() => undefined);
} catch {
// The epoch/owner fence remains authoritative if the WebView is already
// tearing down and can no longer deliver the best-effort cancellation.
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,167 @@
import type { ProjectResourceCanvasLayoutMode } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import type { ProjectResource } from './resourceProjectionModel';
export const PROJECT_RESOURCE_CARD_PREVIEW_CONCURRENCY = 3;
export const PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT = 48;
export const PROJECT_RESOURCE_CARD_PREVIEW_CACHE_BYTE_LIMIT = 64 * 1024 * 1024;
export const PROJECT_RESOURCE_CARD_PREVIEW_QUEUE_LIMIT = 96;
export const PROJECT_RESOURCE_CARD_PREVIEW_ACTIVE_QUEUE_RESERVE =
PROJECT_RESOURCE_CARD_PREVIEW_CONCURRENCY;
export type ProjectResourceCardPreviewKind =
| 'raster-image'
| 'media-image'
| 'video'
| 'audio'
| 'document'
| 'version'
| 'placeholder';
export type ProjectResourceCardPreviewPayload = {
path: string;
mediaType: string;
byteLen: number;
sourceUrl?: string;
content?: string;
};
export type ProjectResourceCardPreviewTransportPayload = Omit<
ProjectResourceCardPreviewPayload,
'sourceUrl'
> & {
dataUrl?: string;
};
export type ProjectResourceCardPreviewCacheEntry = {
identity: string;
retainedBytes: number;
};
export type ProjectResourceCardPreviewState =
| { status: 'idle' }
| { status: 'loading' }
| {
status: 'loaded';
preview: ProjectResourceCardPreviewPayload;
}
| { status: 'failed'; error: string; retryable: boolean };
export const IDLE_PROJECT_RESOURCE_CARD_PREVIEW = {
status: 'idle',
} as const satisfies ProjectResourceCardPreviewState;
function normalizedProjectResourceCardPreviewRetainedBytes(
retainedBytes: number,
) {
return Number.isSafeInteger(retainedBytes) && retainedBytes >= 0
? retainedBytes
: PROJECT_RESOURCE_CARD_PREVIEW_CACHE_BYTE_LIMIT + 1;
}
export function projectResourceCardPreviewEvictionIdentities(
entries: readonly ProjectResourceCardPreviewCacheEntry[],
protectedIdentity: string | null,
): string[] {
let retainedCount = entries.length;
let retainedBytes = entries.reduce(
(total, entry) =>
total +
normalizedProjectResourceCardPreviewRetainedBytes(entry.retainedBytes),
0,
);
const evicted = new Set<string>();
const overBudget = () =>
retainedCount > PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT ||
retainedBytes > PROJECT_RESOURCE_CARD_PREVIEW_CACHE_BYTE_LIMIT;
for (const entry of entries) {
if (!overBudget()) {
break;
}
if (entry.identity === protectedIdentity) {
continue;
}
evicted.add(entry.identity);
retainedCount -= 1;
retainedBytes -= normalizedProjectResourceCardPreviewRetainedBytes(
entry.retainedBytes,
);
}
if (overBudget() && protectedIdentity) {
const protectedEntry = entries.find(
(entry) => entry.identity === protectedIdentity,
);
if (protectedEntry && !evicted.has(protectedEntry.identity)) {
evicted.add(protectedEntry.identity);
}
}
return Array.from(evicted);
}
const rasterImageExtension = /\.(png|jpe?g|webp)$/iu;
const extendedImageExtension = /\.(gif|svg|avif|bmp)$/iu;
const videoExtension = /\.(mp4|webm|mov)$/iu;
export function projectResourceCardPreviewKind(
resource: ProjectResource,
): ProjectResourceCardPreviewKind {
if (resource.category === 'version') {
return 'version';
}
if (resource.category === 'document') {
return 'document';
}
if (resource.category === 'audio') {
return 'audio';
}
const mediaType = resource.mediaType.trim().toLowerCase();
if (mediaType.startsWith('video/') || videoExtension.test(resource.path)) {
return 'video';
}
if (
['image/png', 'image/jpeg', 'image/jpg', 'image/webp'].includes(
mediaType,
) ||
rasterImageExtension.test(resource.path)
) {
return 'raster-image';
}
if (
mediaType.startsWith('image/') ||
extendedImageExtension.test(resource.path)
) {
return 'media-image';
}
return 'placeholder';
}
export function projectResourceCardPreviewIdentity(input: {
projectPath: string;
projectId: string;
mode: ProjectResourceCanvasLayoutMode;
resource: ProjectResource;
}) {
return JSON.stringify([
input.projectPath,
input.projectId,
input.mode,
input.resource.id,
input.resource.category,
input.resource.path,
input.resource.mediaType,
]);
}
export function summarizeProjectResourceDocument(content: string) {
return content
.replace(/!\[[^\]]*\]\([^)]*\)/gu, ' ')
.replace(/\[([^\]]+)\]\([^)]*\)/gu, '$1')
.replace(/<[^>]*>/gu, ' ')
.replace(/[`*_~>#|{}[\]]/gu, ' ')
.replace(/^\s*[-+]\s+/gmu, '')
.replace(/\s+/gu, ' ')
.trim()
.slice(0, 180);
}
@@ -0,0 +1,122 @@
import type {
ProjectResourceCanvasLayoutMode,
ProjectResourceCanvasSection,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { RESOURCE_CANVAS_CARD_HEIGHT } from './resourceCanvasLayoutModel';
export const RESOURCE_SECTION_HEIGHT_STEP = 72;
export const RESOURCE_SECTION_MIN_HEIGHT = RESOURCE_CANVAS_CARD_HEIGHT + 88;
export const RESOURCE_SECTION_DEFAULT_HEIGHT = 340;
export const RESOURCE_SECTION_FALLBACK_MAX_HEIGHT = 640;
export const RESOURCE_CANVAS_VERTICAL_INSET = 24;
export const RESOURCE_SECTION_ZOOM_MIN = 0.5;
export const RESOURCE_SECTION_ZOOM_MAX = 2;
export const RESOURCE_SECTION_ZOOM_DEFAULT = 1;
export const RESOURCE_SECTION_ZOOM_STEP = 0.1;
export type ProjectResourceSectionHeightBounds = {
min: number;
max: number;
};
export type ProjectResourceSectionHeightAction =
| 'decrease'
| 'increase'
| 'reset';
export type ProjectResourceSectionZoomAction =
| 'decrease'
| 'increase'
| 'reset';
export function projectResourceSectionHeightKey(input: {
projectId: string;
mode: ProjectResourceCanvasLayoutMode;
section: ProjectResourceCanvasSection;
}) {
return `${input.projectId}\n${input.mode}\n${input.section}`;
}
export function projectResourceSectionHeightBounds(
canvasClientHeight: number,
): ProjectResourceSectionHeightBounds {
const availableHeight =
Number.isFinite(canvasClientHeight) && canvasClientHeight > 0
? Math.floor(canvasClientHeight) - RESOURCE_CANVAS_VERTICAL_INSET
: RESOURCE_SECTION_FALLBACK_MAX_HEIGHT;
return {
min: RESOURCE_SECTION_MIN_HEIGHT,
max: Math.max(RESOURCE_SECTION_MIN_HEIGHT, availableHeight),
};
}
export function clampProjectResourceSectionHeight(
height: number,
bounds: ProjectResourceSectionHeightBounds,
) {
const normalized = Number.isFinite(height)
? Math.round(height)
: RESOURCE_SECTION_DEFAULT_HEIGHT;
return Math.min(bounds.max, Math.max(bounds.min, normalized));
}
export function defaultProjectResourceSectionHeight(
bounds: ProjectResourceSectionHeightBounds,
) {
return clampProjectResourceSectionHeight(
RESOURCE_SECTION_DEFAULT_HEIGHT,
bounds,
);
}
export function updateProjectResourceSectionHeight(
currentHeight: number,
action: ProjectResourceSectionHeightAction,
bounds: ProjectResourceSectionHeightBounds,
) {
if (action === 'reset') {
return defaultProjectResourceSectionHeight(bounds);
}
const delta =
action === 'increase'
? RESOURCE_SECTION_HEIGHT_STEP
: -RESOURCE_SECTION_HEIGHT_STEP;
return clampProjectResourceSectionHeight(currentHeight + delta, bounds);
}
export function clampProjectResourceSectionZoom(zoom: number) {
const normalized = Number.isFinite(zoom)
? Math.round(zoom * 100) / 100
: RESOURCE_SECTION_ZOOM_DEFAULT;
return Math.min(
RESOURCE_SECTION_ZOOM_MAX,
Math.max(RESOURCE_SECTION_ZOOM_MIN, normalized),
);
}
export function updateProjectResourceSectionZoom(
currentZoom: number,
action: ProjectResourceSectionZoomAction,
) {
if (action === 'reset') {
return RESOURCE_SECTION_ZOOM_DEFAULT;
}
return clampProjectResourceSectionZoom(
currentZoom +
(action === 'increase'
? RESOURCE_SECTION_ZOOM_STEP
: -RESOURCE_SECTION_ZOOM_STEP),
);
}
export function projectResourceSectionZoomFromWheel(
currentZoom: number,
deltaY: number,
) {
if (!Number.isFinite(deltaY) || deltaY === 0) {
return clampProjectResourceSectionZoom(currentZoom);
}
return clampProjectResourceSectionZoom(
currentZoom * Math.exp(-deltaY * 0.0025),
);
}
@@ -6,12 +6,16 @@ import type {
ProjectResourceCanvasSection,
UpdateProjectResourceCanvasLayoutResult,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { isSafeProjectResourceCanvasLayoutRevision } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import {
isSafeProjectResourceCanvasCoordinate,
isSafeProjectResourceCanvasLayoutRevision,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import {
createEmptyResourceCanvasLayout,
moveResourceCanvasPosition,
reconcileResourceCanvasLayout,
type ResourceCanvasItem,
type ResourceCanvasLayoutTopology,
} from './resourceCanvasLayoutModel';
type LayoutNotice =
@@ -74,6 +78,73 @@ export function createResourceSignature(resources: ResourceCanvasItem[]) {
.join('\n');
}
/**
* A bounded, identity-only input to dependency automatic placement. Display
* labels deliberately stay out: the resource list's coordination signature
* already covers them, while topology changes must be detectable even when
* every depth and resource count is unchanged.
*/
export function createResourceTopologySignature(
topology: ResourceCanvasLayoutTopology | undefined,
) {
if (!topology) {
return '';
}
const entries = [
...topology.referenceEdges
.map(
(edge) =>
`r:${JSON.stringify([edge.sourceResourceId, edge.targetResourceId])}`,
)
.sort(compareStableText),
...topology.taskFlows
.map((flow) =>
`f:${JSON.stringify([
[...new Set(flow.sourceResourceIds)]
.sort(compareStableText),
[...new Set(flow.targetResourceIds)]
.sort(compareStableText),
])}`,
)
.sort(compareStableText),
];
return stableTopologyDigest([...new Set(entries)].sort(compareStableText));
}
function compareStableText(left: string, right: string) {
return left < right ? -1 : left > right ? 1 : 0;
}
/**
* Keeps the sidecar coordination input fixed-size even for a large graph.
* The canonical entries retain all stable endpoint and flow-member identities
* while being digested; labels and browser geometry never participate.
*/
function stableTopologyDigest(entries: readonly string[]) {
let forward = 0x811c9dc5;
let reverse = 0x9e3779b9;
for (const entry of entries) {
for (let index = 0; index < entry.length; index += 1) {
forward = Math.imul(forward ^ entry.charCodeAt(index), 0x01000193);
reverse = Math.imul(
reverse ^ entry.charCodeAt(entry.length - index - 1),
0x85ebca6b,
);
}
forward = Math.imul(forward ^ 10, 0x01000193);
reverse = Math.imul(reverse ^ 10, 0x85ebca6b);
}
return `${(entries.length >>> 0).toString(16).padStart(8, '0')}:${(
forward >>> 0
)
.toString(16)
.padStart(8, '0')}:${(
reverse >>> 0
)
.toString(16)
.padStart(8, '0')}`;
}
function layoutMatchesScope(
layout: ProjectResourceCanvasLayout,
scope: LayoutScope,
@@ -100,23 +171,46 @@ function positionsEqual(
);
}
function layoutCoordinatesAreSafe(layout: ProjectResourceCanvasLayout) {
return layout.positions.every(
(position) =>
isSafeProjectResourceCanvasCoordinate(position.x) &&
isSafeProjectResourceCanvasCoordinate(position.y),
);
}
function reconcileLayout(
source: ProjectResourceCanvasLayout,
resources: ResourceCanvasItem[],
rederiveAutomaticPositions: boolean,
topology: ResourceCanvasLayoutTopology | undefined,
) {
if (!rederiveAutomaticPositions) {
return reconcileResourceCanvasLayout(source, resources);
const reconciled = reconcileResourceCanvasLayout(
source,
resources,
topology,
);
return layoutCoordinatesAreSafe(reconciled.layout)
? reconciled
: { layout: source, changed: false };
}
const manualSource = {
...source,
positions: source.positions.filter((position) => position.manuallyPlaced),
};
const reconciled = reconcileResourceCanvasLayout(manualSource, resources);
return {
const reconciled = reconcileResourceCanvasLayout(
manualSource,
resources,
topology,
);
const result = {
layout: reconciled.layout,
changed: !positionsEqual(source.positions, reconciled.layout.positions),
};
return layoutCoordinatesAreSafe(result.layout)
? result
: { layout: source, changed: false };
}
export function useProjectResourceCanvasLayout({
@@ -124,6 +218,7 @@ export function useProjectResourceCanvasLayout({
projectId,
mode,
resources,
topology,
initializationReady = true,
rederiveAutomaticPositions = false,
}: {
@@ -131,19 +226,29 @@ export function useProjectResourceCanvasLayout({
projectId: string;
mode: ProjectResourceCanvasLayoutMode;
resources: ResourceCanvasItem[];
topology?: ResourceCanvasLayoutTopology;
initializationReady?: boolean;
rederiveAutomaticPositions?: boolean;
}) {
const scopeKey = createScopeKey(projectPath, projectId, mode);
const topologySignature = useMemo(
() => createResourceTopologySignature(topology),
[topology],
);
const resourceSignature = useMemo(
() => createResourceSignature(resources),
[resources],
() => [createResourceSignature(resources), topologySignature].join('\n'),
[resources, topologySignature],
);
const fallback = useMemo(
() => {
const empty = createEmptyResourceCanvasLayout(projectId, mode);
return initializationReady
? reconcileLayout(empty, resources, rederiveAutomaticPositions).layout
? reconcileLayout(
empty,
resources,
rederiveAutomaticPositions,
topology,
).layout
: empty;
}, [
initializationReady,
@@ -151,6 +256,7 @@ export function useProjectResourceCanvasLayout({
projectId,
rederiveAutomaticPositions,
resources,
topology,
]);
const [layout, setLayout] = useState<ProjectResourceCanvasLayout>(fallback);
const [notice, setNotice] = useState<LayoutNotice>('');
@@ -159,6 +265,7 @@ export function useProjectResourceCanvasLayout({
const layoutRef = useRef<ProjectResourceCanvasLayout>(fallback);
const persistedLayoutRef = useRef<ProjectResourceCanvasLayout>(fallback);
const resourcesRef = useRef(resources);
const topologyRef = useRef(topology);
const resourceSignatureRef = useRef(resourceSignature);
const initializedScopeEpochRef = useRef<number | null>(null);
const scopeRef = useRef<LayoutScope>({
@@ -177,6 +284,7 @@ export function useProjectResourceCanvasLayout({
>(() => undefined);
resourcesRef.current = resources;
topologyRef.current = topology;
resourceSignatureRef.current = resourceSignature;
const applyLayout = useCallback((next: ProjectResourceCanvasLayout) => {
@@ -202,6 +310,7 @@ export function useProjectResourceCanvasLayout({
persistedLayoutRef.current,
resourcesRef.current,
rederiveAutomaticPositions,
topologyRef.current,
).layout;
for (const intent of writeQueueRef.current) {
if (intent.scopeEpoch === scopeEpoch && intent.kind === 'manual') {
@@ -281,6 +390,7 @@ export function useProjectResourceCanvasLayout({
persistedLayoutRef.current,
resourcesRef.current,
rederiveAutomaticPositions,
topologyRef.current,
);
if (intent.kind === 'resources' && !reconciled.changed) {
removeWriteIntent(intent);
@@ -329,7 +439,10 @@ export function useProjectResourceCanvasLayout({
}
const expectedRevision = persistedLayoutRef.current.revision;
if (!isSafeProjectResourceCanvasLayoutRevision(expectedRevision)) {
if (
!isSafeProjectResourceCanvasLayoutRevision(expectedRevision) ||
!layoutCoordinatesAreSafe(candidate)
) {
removeWriteIntent(intent);
rebuildOptimisticLayout(scope.epoch);
if (intent.kind === 'manual') {
@@ -364,9 +477,10 @@ export function useProjectResourceCanvasLayout({
return;
}
if (
!isSafeProjectResourceCanvasLayoutRevision(result.layout.revision)
!isSafeProjectResourceCanvasLayoutRevision(result.layout.revision) ||
!layoutCoordinatesAreSafe(result.layout)
) {
throw new Error('layout response revision is not a safe integer');
throw new Error('layout response revision or coordinates are invalid');
}
if (!layoutMatchesScope(result.layout, currentScope)) {
throw new Error('layout response scope mismatch');
@@ -383,6 +497,7 @@ export function useProjectResourceCanvasLayout({
result.layout,
resourcesRef.current,
rederiveAutomaticPositions,
topologyRef.current,
)
.changed
) {
@@ -403,6 +518,7 @@ export function useProjectResourceCanvasLayout({
result.layout,
resourcesRef.current,
rederiveAutomaticPositions,
topologyRef.current,
).changed;
const nextRetry =
intent.kind === 'resources' ? intent.conflictRetries + 1 : 0;
@@ -501,6 +617,7 @@ export function useProjectResourceCanvasLayout({
emptyLayout,
resourcesRef.current,
rederiveAutomaticPositions,
topologyRef.current,
).layout;
persistedLayoutRef.current = initialFallback;
applyLayout(initialFallback);
@@ -523,8 +640,11 @@ export function useProjectResourceCanvasLayout({
if (cancelled || scopeRef.current.epoch !== epoch) {
return;
}
if (!isSafeProjectResourceCanvasLayoutRevision(loaded.revision)) {
throw new Error('layout response revision is not a safe integer');
if (
!isSafeProjectResourceCanvasLayoutRevision(loaded.revision) ||
!layoutCoordinatesAreSafe(loaded)
) {
throw new Error('layout response revision or coordinates are invalid');
}
if (!layoutMatchesScope(loaded, scope)) {
return;
@@ -536,6 +656,7 @@ export function useProjectResourceCanvasLayout({
loaded,
resourcesRef.current,
rederiveAutomaticPositions,
topologyRef.current,
).changed
) {
enqueueResourceSyncRef.current(epoch);
@@ -580,6 +701,7 @@ export function useProjectResourceCanvasLayout({
layoutRef.current,
resourcesRef.current,
rederiveAutomaticPositions,
topologyRef.current,
);
applyLayout(reconciledCurrent.layout);
if (
@@ -588,6 +710,7 @@ export function useProjectResourceCanvasLayout({
persistedLayoutRef.current,
resourcesRef.current,
rederiveAutomaticPositions,
topologyRef.current,
).changed
) {
enqueueResourceSyncRef.current(scope.epoch);
@@ -0,0 +1,239 @@
import {
type RefObject,
useCallback,
useLayoutEffect,
useMemo,
useState,
} from 'react';
import type {
ProjectResourceCanvasLayoutMode,
ProjectResourceCanvasSection,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import {
clampProjectResourceSectionHeight,
clampProjectResourceSectionZoom,
defaultProjectResourceSectionHeight,
type ProjectResourceSectionHeightAction,
projectResourceSectionHeightBounds,
projectResourceSectionHeightKey,
type ProjectResourceSectionZoomAction,
RESOURCE_SECTION_ZOOM_DEFAULT,
RESOURCE_SECTION_ZOOM_MAX,
RESOURCE_SECTION_ZOOM_MIN,
updateProjectResourceSectionHeight,
updateProjectResourceSectionZoom,
} from './resourceSectionHeightModel';
const RESOURCE_LAYOUT_MODES: readonly ProjectResourceCanvasLayoutMode[] = [
'dependency',
'type',
];
const RESOURCE_SECTIONS: readonly ProjectResourceCanvasSection[] = [
'document',
'version',
'art',
'audio',
];
export type ProjectResourceSectionHeightState = {
height: number;
min: number;
max: number;
defaultHeight: number;
atMin: boolean;
atMax: boolean;
atDefault: boolean;
zoom: number;
atMinZoom: boolean;
atMaxZoom: boolean;
atDefaultZoom: boolean;
};
export function useProjectResourceSectionHeights(input: {
projectId: string;
mode: ProjectResourceCanvasLayoutMode;
canvasRef: RefObject<HTMLDivElement | null>;
enabled: boolean;
}) {
const { canvasRef, enabled, mode, projectId } = input;
const [bounds, setBounds] = useState(() =>
projectResourceSectionHeightBounds(0),
);
const [sessionHeights, setSessionHeights] = useState<Map<string, number>>(
() => new Map(),
);
const [sessionZooms, setSessionZooms] = useState<Map<string, number>>(
() => new Map(),
);
useLayoutEffect(() => {
if (!enabled) {
return undefined;
}
let frameId: number | null = null;
const measure = () => {
frameId = null;
const canvasHeight = canvasRef.current?.clientHeight ?? 0;
if (canvasHeight <= 0) {
return;
}
const nextBounds = projectResourceSectionHeightBounds(canvasHeight);
setBounds((current) =>
current.min === nextBounds.min && current.max === nextBounds.max
? current
: nextBounds,
);
setSessionHeights((current) => {
let next: Map<string, number> | null = null;
for (const layoutMode of RESOURCE_LAYOUT_MODES) {
for (const section of RESOURCE_SECTIONS) {
const key = projectResourceSectionHeightKey({
projectId,
mode: layoutMode,
section,
});
const height = current.get(key);
if (height === undefined) {
continue;
}
const clamped = clampProjectResourceSectionHeight(
height,
nextBounds,
);
if (clamped !== height) {
next ??= new Map(current);
next.set(key, clamped);
}
}
}
return next ?? current;
});
};
const scheduleMeasure = () => {
if (frameId !== null) {
return;
}
frameId = window.requestAnimationFrame(measure);
};
measure();
window.addEventListener('resize', scheduleMeasure);
return () => {
if (frameId !== null) {
window.cancelAnimationFrame(frameId);
}
window.removeEventListener('resize', scheduleMeasure);
};
}, [canvasRef, enabled, projectId]);
const sectionStates = useMemo(() => {
const defaultHeight = defaultProjectResourceSectionHeight(bounds);
return new Map(
RESOURCE_SECTIONS.map((section) => {
const key = projectResourceSectionHeightKey({
projectId,
mode,
section,
});
const height = clampProjectResourceSectionHeight(
sessionHeights.get(key) ?? defaultHeight,
bounds,
);
const zoom = clampProjectResourceSectionZoom(
sessionZooms.get(key) ?? RESOURCE_SECTION_ZOOM_DEFAULT,
);
return [
section,
{
height,
min: bounds.min,
max: bounds.max,
defaultHeight,
atMin: height === bounds.min,
atMax: height === bounds.max,
atDefault: height === defaultHeight,
zoom,
atMinZoom: zoom === RESOURCE_SECTION_ZOOM_MIN,
atMaxZoom: zoom === RESOURCE_SECTION_ZOOM_MAX,
atDefaultZoom: zoom === RESOURCE_SECTION_ZOOM_DEFAULT,
} satisfies ProjectResourceSectionHeightState,
] as const;
}),
);
}, [bounds, mode, projectId, sessionHeights, sessionZooms]);
const updateSectionHeight = useCallback(
(
section: ProjectResourceCanvasSection,
action: ProjectResourceSectionHeightAction,
) => {
const key = projectResourceSectionHeightKey({ projectId, mode, section });
setSessionHeights((current) => {
const currentHeight = clampProjectResourceSectionHeight(
current.get(key) ?? defaultProjectResourceSectionHeight(bounds),
bounds,
);
const nextHeight = updateProjectResourceSectionHeight(
currentHeight,
action,
bounds,
);
if (nextHeight === currentHeight) {
return current;
}
const next = new Map(current);
next.set(key, nextHeight);
return next;
});
},
[bounds, mode, projectId],
);
const setSectionZoom = useCallback(
(section: ProjectResourceCanvasSection, zoom: number) => {
const key = projectResourceSectionHeightKey({ projectId, mode, section });
const nextZoom = clampProjectResourceSectionZoom(zoom);
setSessionZooms((current) => {
const currentZoom = clampProjectResourceSectionZoom(
current.get(key) ?? RESOURCE_SECTION_ZOOM_DEFAULT,
);
if (nextZoom === currentZoom) {
return current;
}
const next = new Map(current);
next.set(key, nextZoom);
return next;
});
},
[mode, projectId],
);
const updateSectionZoom = useCallback(
(
section: ProjectResourceCanvasSection,
action: ProjectResourceSectionZoomAction,
) => {
const key = projectResourceSectionHeightKey({ projectId, mode, section });
setSessionZooms((current) => {
const currentZoom = clampProjectResourceSectionZoom(
current.get(key) ?? RESOURCE_SECTION_ZOOM_DEFAULT,
);
const nextZoom = updateProjectResourceSectionZoom(currentZoom, action);
if (nextZoom === currentZoom) {
return current;
}
const next = new Map(current);
next.set(key, nextZoom);
return next;
});
},
[mode, projectId],
);
return {
sectionStates,
setSectionZoom,
updateSectionHeight,
updateSectionZoom,
};
}
File diff suppressed because it is too large Load Diff
@@ -29,6 +29,14 @@ const originalPointerEvent = Object.getOwnPropertyDescriptor(
window,
'PointerEvent',
);
const originalIntersectionObserver = Object.getOwnPropertyDescriptor(
window,
'IntersectionObserver',
);
const originalResizeObserver = Object.getOwnPropertyDescriptor(
window,
'ResizeObserver',
);
class TestPointerEvent extends MouseEvent {
readonly pointerId: number;
@@ -758,6 +766,20 @@ afterEach(() => {
} else {
Reflect.deleteProperty(window, 'PointerEvent');
}
if (originalIntersectionObserver) {
Object.defineProperty(
window,
'IntersectionObserver',
originalIntersectionObserver,
);
} else {
Reflect.deleteProperty(window, 'IntersectionObserver');
}
if (originalResizeObserver) {
Object.defineProperty(window, 'ResizeObserver', originalResizeObserver);
} else {
Reflect.deleteProperty(window, 'ResizeObserver');
}
});
export {
act,
@@ -1297,7 +1297,9 @@ export function registerHomeProjectCreationTests() {
'第一行\n第二行\n第三行',
);
expect(
await screen.findByText('assets/uploads/reference.png'),
await screen.findByRole('button', {
name: '打开资源详情:美术资源 reference.png',
}),
).not.toBeNull();
expect(invoke).toHaveBeenCalledWith('init_local_game_project', {
projectPath: '/tmp/home-created-game',
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest';
import {
GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE,
isSafeProjectResourceCanvasCoordinate,
} from '../../../packages/shared/src/contracts/gameCreationApp';
import {
createEmptyResourceCanvasLayout,
moveResourceCanvasPosition,
@@ -7,8 +11,10 @@ import {
RESOURCE_CANVAS_CARD_HEIGHT,
RESOURCE_CANVAS_CARD_WIDTH,
RESOURCE_CANVAS_COLUMN_GAP,
RESOURCE_CANVAS_ROW_GAP,
RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP,
RESOURCE_CANVAS_DEPENDENCY_ROW_GAP,
type ResourceCanvasItem,
type ResourceCanvasLayoutTopology,
} from '../src/view/project-development/resourceCanvasLayoutModel';
function resource(
@@ -26,6 +32,24 @@ function resource(
};
}
function dependencyTopology(
referenceEdges: ResourceCanvasLayoutTopology['referenceEdges'] = [],
taskFlows: ResourceCanvasLayoutTopology['taskFlows'] = [],
): ResourceCanvasLayoutTopology {
return { referenceEdges, taskFlows };
}
function positionById(
layout: ReturnType<typeof reconcileResourceCanvasLayout>['layout'],
resourceId: string,
) {
const position = layout.positions.find(
(candidate) => candidate.resourceId === resourceId,
);
expect(position).toBeDefined();
return position!;
}
describe('resource canvas layout model', () => {
it('creates non-overlapping defaults and keeps the two modes independent', () => {
const resources = [
@@ -46,14 +70,14 @@ describe('resource canvas layout model', () => {
{ resourceId: 'a', x: 0, y: 0, manuallyPlaced: false },
{
resourceId: 'c',
x: RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP,
x: RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP,
y: 0,
manuallyPlaced: false,
},
{
resourceId: 'b',
x: 0,
y: RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_ROW_GAP,
y: RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP,
manuallyPlaced: false,
},
]);
@@ -180,6 +204,330 @@ describe('resource canvas layout model', () => {
]);
});
it('keeps connected art cards close while placing separate components before isolates', () => {
const layout = reconcileResourceCanvasLayout(
createEmptyResourceCanvasLayout('project-clusters', 'dependency'),
[
resource('art:canvas', 'art', 0),
resource('art:wireframe', 'art', 1),
resource('art:character', 'art', 0),
resource('art:screen', 'art', 1),
resource('art:unrelated', 'art', 0),
],
dependencyTopology([
{
sourceResourceId: 'art:canvas',
targetResourceId: 'art:wireframe',
},
{
sourceResourceId: 'art:character',
targetResourceId: 'art:screen',
},
]),
).layout;
const canvas = positionById(layout, 'art:canvas');
const wireframe = positionById(layout, 'art:wireframe');
const character = positionById(layout, 'art:character');
const screen = positionById(layout, 'art:screen');
const unrelated = positionById(layout, 'art:unrelated');
expect(wireframe.y).toBe(canvas.y);
expect(screen.y).toBe(character.y);
expect(character.y - canvas.y).toBeGreaterThanOrEqual(
RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP,
);
expect(unrelated.y).toBeGreaterThan(screen.y);
});
it('ignores cross-category references and task flows when forming layout clusters', () => {
const layout = reconcileResourceCanvasLayout(
createEmptyResourceCanvasLayout(
'project-no-cross-category',
'dependency',
),
[
resource('art:a', 'art', 0),
resource('art:b', 'art', 0),
resource('document:a', 'document', 0),
],
dependencyTopology(
[
{
sourceResourceId: 'document:a',
targetResourceId: 'art:a',
},
],
[
{
sourceResourceIds: ['document:a'],
targetResourceIds: ['art:a'],
},
],
),
).layout;
expect(positionById(layout, 'art:a').y).toBe(0);
expect(positionById(layout, 'art:b').y).toBe(
RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP,
);
expect(positionById(layout, 'document:a')).toMatchObject({
section: 'document',
y: 0,
});
});
it('uses bounded layer scans to align multi-edge targets with their upstream neighbors', () => {
const layout = reconcileResourceCanvasLayout(
createEmptyResourceCanvasLayout('project-crossings', 'dependency'),
[
resource('source:bottom', 'art', 0),
resource('source:top', 'art', 0),
resource('target:first', 'art', 1),
resource('target:second', 'art', 1),
],
dependencyTopology(
[
{
sourceResourceId: 'source:bottom',
targetResourceId: 'target:second',
},
{
sourceResourceId: 'source:top',
targetResourceId: 'target:first',
},
],
[
{
sourceResourceIds: ['source:bottom', 'source:top'],
targetResourceIds: ['target:first', 'target:second'],
},
],
),
).layout;
expect(positionById(layout, 'source:bottom').y).toBeLessThan(
positionById(layout, 'source:top').y,
);
expect(positionById(layout, 'target:second').y).toBeLessThan(
positionById(layout, 'target:first').y,
);
});
it('centers narrow dependency layers so diamond edges have balanced lengths', () => {
const layout = reconcileResourceCanvasLayout(
createEmptyResourceCanvasLayout('project-balanced-diamond', 'dependency'),
[
resource('source', 'art', 0),
resource('middle:upper', 'art', 1),
resource('middle:lower', 'art', 1),
resource('target', 'art', 2),
],
dependencyTopology([
{ sourceResourceId: 'source', targetResourceId: 'middle:upper' },
{ sourceResourceId: 'source', targetResourceId: 'middle:lower' },
{ sourceResourceId: 'middle:upper', targetResourceId: 'target' },
{ sourceResourceId: 'middle:lower', targetResourceId: 'target' },
]),
).layout;
const dependencySlotHeight =
RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP;
const dependencySlotWidth =
RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP;
expect(positionById(layout, 'middle:lower')).toMatchObject({
x: dependencySlotWidth,
y: 0,
});
expect(positionById(layout, 'middle:upper')).toMatchObject({
x: dependencySlotWidth,
y: dependencySlotHeight,
});
expect(positionById(layout, 'source')).toMatchObject({
x: 0,
y: dependencySlotHeight / 2,
});
expect(positionById(layout, 'target')).toMatchObject({
x: dependencySlotWidth * 2,
y: dependencySlotHeight / 2,
});
});
it('keeps cycles contiguous, preserves their Rust depth, and leaves cross-section cards in place', () => {
const layout = reconcileResourceCanvasLayout(
createEmptyResourceCanvasLayout('project-cycles', 'dependency'),
[
resource('art:cycle-a', 'art', 2),
resource('art:cycle-b', 'art', 2),
resource('art:after-cycle', 'art', 3),
resource('art:self', 'art', 0),
resource('document:brief', 'document', 0),
],
dependencyTopology([
{ sourceResourceId: 'art:cycle-a', targetResourceId: 'art:cycle-b' },
{ sourceResourceId: 'art:cycle-b', targetResourceId: 'art:cycle-a' },
{
sourceResourceId: 'art:cycle-b',
targetResourceId: 'art:after-cycle',
},
{ sourceResourceId: 'art:self', targetResourceId: 'art:self' },
{
sourceResourceId: 'document:brief',
targetResourceId: 'art:cycle-a',
},
]),
).layout;
const cycleA = positionById(layout, 'art:cycle-a');
const cycleB = positionById(layout, 'art:cycle-b');
const afterCycle = positionById(layout, 'art:after-cycle');
expect(cycleA.x).toBe(
2 * (RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP),
);
expect(cycleB.x).toBe(cycleA.x);
expect(Math.abs(cycleA.y - cycleB.y)).toBe(
RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP,
);
expect(afterCycle.x).toBe(
3 * (RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP),
);
expect(positionById(layout, 'document:brief').section).toBe('document');
});
it('keeps same-depth cycle members consecutive when another related card shares the layer', () => {
const layout = reconcileResourceCanvasLayout(
createEmptyResourceCanvasLayout('project-cycle-contiguity', 'dependency'),
[
resource('art:cycle-a', 'art', 2),
resource('art:cycle-b', 'art', 2),
resource('art:sibling', 'art', 2),
resource('art:after', 'art', 3),
],
dependencyTopology([
{ sourceResourceId: 'art:cycle-a', targetResourceId: 'art:cycle-b' },
{ sourceResourceId: 'art:cycle-b', targetResourceId: 'art:cycle-a' },
{ sourceResourceId: 'art:cycle-b', targetResourceId: 'art:after' },
{ sourceResourceId: 'art:sibling', targetResourceId: 'art:after' },
]),
).layout;
const cycleRows = ['art:cycle-a', 'art:cycle-b']
.map((resourceId) => positionById(layout, resourceId).y)
.sort((left, right) => left - right);
const siblingRow = positionById(layout, 'art:sibling').y;
expect(cycleRows[1] - cycleRows[0]).toBe(
RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP,
);
expect(siblingRow).toBeGreaterThan(cycleRows[1]);
});
it('is deterministic, does not modify type placement, and avoids historical manual positions', () => {
const resources = [
resource('art:source', 'art', 0),
resource('art:target', 'art', 1),
resource('art:other', 'art', 1),
];
const topology = dependencyTopology([
{ sourceResourceId: 'art:source', targetResourceId: 'art:target' },
]);
const source = createEmptyResourceCanvasLayout(
'project-stable',
'dependency',
);
source.positions = [
{
resourceId: 'art:other',
section: 'art',
x: RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP,
y: 0,
manuallyPlaced: true,
},
];
const first = reconcileResourceCanvasLayout(
source,
resources,
topology,
).layout;
const second = reconcileResourceCanvasLayout(
source,
resources,
topology,
).layout;
const type = reconcileResourceCanvasLayout(
createEmptyResourceCanvasLayout('project-stable', 'type'),
resources,
topology,
).layout;
expect(second.positions).toEqual(first.positions);
expect(positionById(first, 'art:other')).toMatchObject({
x: RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP,
y: 0,
manuallyPlaced: true,
});
expect(positionById(first, 'art:target').y).toBe(
RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP,
);
expect(positionById(type, 'art:other')).toMatchObject({ x: 0, y: 0 });
});
it('saturates super-deep dependency columns and avoids collisions within the coordinate contract', () => {
const resources = [
resource('art:depth-4385', 'art', 4_385),
resource('art:depth-4386-a', 'art', 4_386),
resource('art:depth-4386-b', 'art', 4_386),
resource('art:depth-9000', 'art', 9_000),
];
const layout = reconcileResourceCanvasLayout(
createEmptyResourceCanvasLayout('project-deep-dependency', 'dependency'),
resources,
).layout;
const repeatedLayout = reconcileResourceCanvasLayout(
createEmptyResourceCanvasLayout('project-deep-dependency', 'dependency'),
resources,
).layout;
const lastLegalColumn =
Math.floor(
GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE /
(RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP),
) *
(RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP);
const deepPositions = [
positionById(layout, 'art:depth-4386-a'),
positionById(layout, 'art:depth-4386-b'),
positionById(layout, 'art:depth-9000'),
];
expect(lastLegalColumn).toBe(999_780);
expect(
isSafeProjectResourceCanvasCoordinate(
GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE,
),
).toBe(true);
expect(
isSafeProjectResourceCanvasCoordinate(
GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE + 1,
),
).toBe(false);
expect(deepPositions.map((position) => position.x)).toEqual([
lastLegalColumn,
lastLegalColumn,
lastLegalColumn,
]);
expect(new Set(deepPositions.map((position) => position.y)).size).toBe(3);
expect(repeatedLayout.positions).toEqual(layout.positions);
expect(
layout.positions.every(
(position) =>
isSafeProjectResourceCanvasCoordinate(position.x) &&
isSafeProjectResourceCanvasCoordinate(position.y),
),
).toBe(true);
expect(resources.map((resource) => resource.dependencyDepth)).toEqual([
4_385, 4_386, 4_386, 9_000,
]);
});
it.each(['dependency', 'type'] as const)(
'reconciles 4096 resources in %s mode within the bounded layout budget',
(mode) => {
@@ -205,4 +553,32 @@ describe('resource canvas layout model', () => {
expect(elapsedMs).toBeLessThan(2000);
},
);
it('keeps dependency clustering within the 4096-resource layout budget', () => {
const resources = Array.from({ length: 4096 }, (_, index) =>
resource(
`resource-${index.toString().padStart(4, '0')}`,
'art',
index % 64,
),
);
const topology = dependencyTopology(
resources.slice(1).map((target, index) => ({
sourceResourceId: resources[index]!.id,
targetResourceId: target.id,
})),
);
const startedAt = performance.now();
const layout = reconcileResourceCanvasLayout(
createEmptyResourceCanvasLayout(
'project-cluster-performance',
'dependency',
),
resources,
topology,
).layout;
expect(layout.positions).toHaveLength(4096);
expect(performance.now() - startedAt).toBeLessThan(2000);
});
});
@@ -0,0 +1,103 @@
import { describe, expect, it } from 'vitest';
import { RESOURCE_CANVAS_CARD_HEIGHT } from '../src/view/project-development/resourceCanvasLayoutModel';
import {
clampProjectResourceSectionZoom,
defaultProjectResourceSectionHeight,
projectResourceSectionHeightBounds,
projectResourceSectionHeightKey,
projectResourceSectionZoomFromWheel,
RESOURCE_CANVAS_VERTICAL_INSET,
RESOURCE_SECTION_DEFAULT_HEIGHT,
RESOURCE_SECTION_HEIGHT_STEP,
RESOURCE_SECTION_MIN_HEIGHT,
RESOURCE_SECTION_ZOOM_DEFAULT,
RESOURCE_SECTION_ZOOM_MAX,
RESOURCE_SECTION_ZOOM_MIN,
updateProjectResourceSectionHeight,
updateProjectResourceSectionZoom,
} from '../src/view/project-development/resourceSectionHeightModel';
describe('resource section height model', () => {
it('isolates session keys by project, mode, and section', () => {
expect(
projectResourceSectionHeightKey({
projectId: 'project-a',
mode: 'dependency',
section: 'document',
}),
).not.toBe(
projectResourceSectionHeightKey({
projectId: 'project-a',
mode: 'type',
section: 'document',
}),
);
expect(
projectResourceSectionHeightKey({
projectId: 'project-a',
mode: 'dependency',
section: 'document',
}),
).not.toBe(
projectResourceSectionHeightKey({
projectId: 'project-b',
mode: 'dependency',
section: 'document',
}),
);
});
it('keeps a full card row above the minimum and clamps to the canvas height', () => {
expect(RESOURCE_SECTION_MIN_HEIGHT).toBeGreaterThan(
RESOURCE_CANVAS_CARD_HEIGHT,
);
const bounds = projectResourceSectionHeightBounds(480);
expect(bounds).toEqual({
min: RESOURCE_SECTION_MIN_HEIGHT,
max: 480 - RESOURCE_CANVAS_VERTICAL_INSET,
});
let height = defaultProjectResourceSectionHeight(bounds);
expect(height).toBe(RESOURCE_SECTION_DEFAULT_HEIGHT);
height = updateProjectResourceSectionHeight(height, 'increase', bounds);
expect(height).toBe(
RESOURCE_SECTION_DEFAULT_HEIGHT + RESOURCE_SECTION_HEIGHT_STEP,
);
height = updateProjectResourceSectionHeight(height, 'increase', bounds);
expect(height).toBe(bounds.max);
height = updateProjectResourceSectionHeight(height, 'reset', bounds);
expect(height).toBe(RESOURCE_SECTION_DEFAULT_HEIGHT);
});
it('uses the minimum as the hard upper bound only when the viewport is smaller', () => {
const bounds = projectResourceSectionHeightBounds(120);
expect(bounds.min).toBe(RESOURCE_SECTION_MIN_HEIGHT);
expect(bounds.max).toBe(RESOURCE_SECTION_MIN_HEIGHT);
expect(
updateProjectResourceSectionHeight(
RESOURCE_SECTION_DEFAULT_HEIGHT,
'increase',
bounds,
),
).toBe(RESOURCE_SECTION_MIN_HEIGHT);
});
it('clamps button and trackpad zoom deterministically', () => {
expect(
updateProjectResourceSectionZoom(
RESOURCE_SECTION_ZOOM_DEFAULT,
'increase',
),
).toBe(1.1);
expect(updateProjectResourceSectionZoom(1.1, 'decrease')).toBe(1);
expect(updateProjectResourceSectionZoom(1.7, 'reset')).toBe(1);
expect(clampProjectResourceSectionZoom(0.1)).toBe(
RESOURCE_SECTION_ZOOM_MIN,
);
expect(clampProjectResourceSectionZoom(4)).toBe(RESOURCE_SECTION_ZOOM_MAX);
expect(projectResourceSectionZoomFromWheel(1, -100)).toBeGreaterThan(1);
expect(projectResourceSectionZoomFromWheel(1, 100)).toBeLessThan(1);
expect(projectResourceSectionZoomFromWheel(1.23, 0)).toBe(1.23);
});
});
@@ -9,14 +9,21 @@ import type {
ProjectResourceCanvasPosition,
} from '../../../packages/shared/src/contracts/gameCreationApp';
import { GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION } from '../../../packages/shared/src/contracts/gameCreationApp';
import type { ResourceCanvasItem } from '../src/view/project-development/resourceCanvasLayoutModel';
import {
RESOURCE_CANVAS_CARD_WIDTH,
RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP,
type ResourceCanvasItem,
} from '../src/view/project-development/resourceCanvasLayoutModel';
import {
createResourceSignature,
createResourceTopologySignature,
useProjectResourceCanvasLayout,
} from '../src/view/project-development/useProjectResourceCanvasLayout';
const projectId = 'layout-hook-project';
const projectPath = '/tmp/layout-hook-project';
const dependencySlotWidth =
RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP;
function resource(id: string): ResourceCanvasItem {
return {
@@ -83,6 +90,45 @@ describe('useProjectResourceCanvasLayout', () => {
);
});
it('uses a stable identity-only topology signature', () => {
const topology = {
referenceEdges: [
{ sourceResourceId: 'asset-b', targetResourceId: 'asset-c' },
{ sourceResourceId: 'asset-a', targetResourceId: 'asset-b' },
],
taskFlows: [
{
sourceResourceIds: ['asset-a', 'asset-b'],
targetResourceIds: ['asset-c'],
},
],
};
expect(createResourceTopologySignature(topology)).toBe(
createResourceTopologySignature({
referenceEdges: [...topology.referenceEdges].reverse(),
taskFlows: [
{
sourceResourceIds: ['asset-b', 'asset-a', 'asset-a'],
targetResourceIds: ['asset-c'],
},
],
}),
);
});
it('keeps large topology signatures bounded without using resource labels', () => {
const signature = createResourceTopologySignature({
referenceEdges: Array.from({ length: 4095 }, (_, index) => ({
sourceResourceId: `resource-${index}`,
targetResourceId: `resource-${index + 1}`,
})),
taskFlows: [],
});
expect(signature).toMatch(/^[0-9a-f]{8}:[0-9a-f]{8}:[0-9a-f]{8}$/u);
expect(signature).toHaveLength(26);
});
it('waits for dependency graph initialization before reading or writing layout', async () => {
const updates: ProjectResourceCanvasPosition[][] = [];
const invoke = vi.fn(
@@ -131,7 +177,7 @@ describe('useProjectResourceCanvasLayout', () => {
await waitFor(() => expect(updates).toHaveLength(1));
expect(result.current.layout.positions[0]).toMatchObject({
resourceId: 'resource-a',
x: 392,
x: dependencySlotWidth * 2,
manuallyPlaced: false,
});
expect(invoke.mock.calls[0]?.[0]).toBe(
@@ -186,13 +232,13 @@ describe('useProjectResourceCanvasLayout', () => {
}),
expect.objectContaining({
resourceId: 'resource-depth-1',
x: 196,
x: dependencySlotWidth,
y: 0,
manuallyPlaced: false,
}),
expect.objectContaining({
resourceId: 'resource-depth-2',
x: 392,
x: dependencySlotWidth * 2,
y: 0,
manuallyPlaced: false,
}),
@@ -242,7 +288,7 @@ describe('useProjectResourceCanvasLayout', () => {
expect.arrayContaining([
expect.objectContaining({
resourceId: 'resource-a',
x: 392,
x: dependencySlotWidth * 2,
manuallyPlaced: false,
}),
expect.objectContaining({
@@ -255,6 +301,80 @@ describe('useProjectResourceCanvasLayout', () => {
);
});
it('rederives automatic positions when topology changes without a depth change', async () => {
const resources = [
resource('resource-a'),
{ ...resource('resource-b'), dependencyDepth: 1 },
{ ...resource('resource-c'), dependencyDepth: 1 },
];
const updates: ProjectResourceCanvasPosition[][] = [];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_canvas_layout') {
return persistedLayout('dependency', 4, [
automaticPosition('resource-a', 0, 0),
automaticPosition('resource-b', 196, 128),
automaticPosition('resource-c', 196, 0),
]);
}
if (command === 'update_local_project_resource_canvas_layout') {
const positions = structuredClone(
args?.positions as ProjectResourceCanvasPosition[],
);
updates.push(positions);
return {
status: 'updated' as const,
layout: persistedLayout(
'dependency',
updates.length + 4,
positions,
),
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const { rerender } = renderHook(
({ topology }) =>
useProjectResourceCanvasLayout({
projectPath,
projectId,
mode: 'dependency',
resources,
topology,
rederiveAutomaticPositions: true,
}),
{
initialProps: {
topology: {
referenceEdges: [
{
sourceResourceId: 'resource-a',
targetResourceId: 'resource-b',
},
],
taskFlows: [],
},
},
},
);
await waitFor(() => expect(updates).toHaveLength(1));
rerender({
topology: {
referenceEdges: [
{ sourceResourceId: 'resource-a', targetResourceId: 'resource-c' },
],
taskFlows: [],
},
});
await waitFor(() => expect(updates).toHaveLength(2));
expect(
updates[1]?.find(({ resourceId }) => resourceId === 'resource-c'),
).toMatchObject({ x: dependencySlotWidth, y: 0, manuallyPlaced: false });
});
it('rejects an unsafe revision from the initial IPC read without writing', async () => {
const resourceA = resource('resource-a');
const invoke = vi.fn(async (command: string) => {

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