完善资源管理工作台与有界预览
将资源卡本体化并补齐分区缩放滚动与依赖聚类 增加全进程三槽预览调度范围取消与安全分块读取 保持布局 CAS 预览缓存回收和依赖图权威合同 补齐资源管理前端 Tauri AppSurface 回归并同步文档
This commit is contained in:
@@ -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
+457
-450
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+680
-29
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);
|
||||
}
|
||||
+122
@@ -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),
|
||||
);
|
||||
}
|
||||
+135
-12
@@ -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);
|
||||
|
||||
+659
File diff suppressed because it is too large
Load Diff
+239
@@ -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
Reference in New Issue
Block a user