diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 560d8ee6f..fcb8bc25d 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -1238,10 +1238,10 @@ if ( clientWindow.width !== 1280 || clientWindow.height !== 800 || clientWindow.minWidth !== 1280 || - clientWindow.minHeight !== 800 + clientWindow.minHeight !== 720 ) { throw new Error( - 'AI game creator shell client window must keep the landscape workbench size', + 'AI game creator shell client window must default to 1280x800 and stay at least 1280x720', ); } @@ -1473,6 +1473,22 @@ if ( ); } +const gameChatReleaseWindows = gameChatReleaseTauriConfig.app?.windows ?? []; +const gameChatReleaseClientWindow = gameChatReleaseWindows[0]; +if ( + gameChatReleaseWindows.length !== 1 || + gameChatReleaseClientWindow?.label !== 'client' || + gameChatReleaseClientWindow?.url !== 'index.html' || + gameChatReleaseClientWindow?.width !== 1280 || + gameChatReleaseClientWindow?.height !== 800 || + gameChatReleaseClientWindow?.minWidth !== 1280 || + gameChatReleaseClientWindow?.minHeight !== 720 +) { + throw new Error( + 'AI game creator game-chat client window must default to 1280x800 and stay at least 1280x720', + ); +} + if ( tauriConfig.version !== '0.1.0' || packageConfig.version !== '0.1.0' || diff --git a/apps/ai-game-creator-shell/src-tauri/build.rs b/apps/ai-game-creator-shell/src-tauri/build.rs index 2b9088e97..2d16ab7be 100644 --- a/apps/ai-game-creator-shell/src-tauri/build.rs +++ b/apps/ai-game-creator-shell/src-tauri/build.rs @@ -1,3 +1,5 @@ +#[path = "build_support/frontend_dist_guard.rs"] +mod frontend_dist_guard; #[path = "build_support/runtime_prompt_bundle.rs"] mod runtime_prompt_bundle; @@ -64,5 +66,14 @@ fn main() { .join("agent_runtime_prompt_bundle.rs"); fs::write(&output_path, compiled.rust_source) .unwrap_or_else(|error| panic!("写入 Prompt Bundle 生成代码失败:{error}")); + if !tauri_build::is_dev() { + let frontend_dist = manifest_dir + .parent() + .expect("AI 游戏创作 Tauri manifest 必须位于应用目录下") + .join("dist"); + println!("cargo:rerun-if-changed={}", frontend_dist.display()); + frontend_dist_guard::validate_frontend_dist(&frontend_dist) + .unwrap_or_else(|error| panic!("生产 frontendDist 检查失败:{error}")); + } tauri_build::build() } diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/frontend_dist_guard.rs b/apps/ai-game-creator-shell/src-tauri/build_support/frontend_dist_guard.rs new file mode 100644 index 000000000..f8527c120 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/build_support/frontend_dist_guard.rs @@ -0,0 +1,100 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +const FORBIDDEN_TEST_PROJECT_PATH: &[u8] = b"/tmp/genarrative-ai-game-draft"; + +pub fn validate_frontend_dist(dist_root: &Path) -> Result<(), String> { + let root_metadata = fs::symlink_metadata(dist_root) + .map_err(|error| format!("读取生产 frontendDist 失败:.: {error}"))?; + if metadata_is_link(&root_metadata) { + return Err("生产 frontendDist 不能是符号链接:.".to_string()); + } + if !root_metadata.is_dir() { + return Err("生产 frontendDist 必须是目录:.".to_string()); + } + + validate_frontend_dist_directory(dist_root, dist_root) +} + +fn validate_frontend_dist_directory(dist_root: &Path, directory: &Path) -> Result<(), String> { + let mut entries = fs::read_dir(directory) + .map_err(|error| { + format!( + "读取生产 frontendDist 目录失败:{}: {error}", + relative_dist_path(dist_root, directory) + ) + })? + .map(|entry| { + entry.map(|entry| entry.path()).map_err(|error| { + format!( + "读取生产 frontendDist 目录项失败:{}: {error}", + relative_dist_path(dist_root, directory) + ) + }) + }) + .collect::, String>>()?; + entries.sort(); + + for path in entries { + let relative_path = relative_dist_path(dist_root, &path); + let metadata = fs::symlink_metadata(&path) + .map_err(|error| format!("读取生产 frontendDist 资源失败:{relative_path}: {error}"))?; + if metadata_is_link(&metadata) { + return Err(format!( + "生产 frontendDist 不能包含符号链接:{relative_path}" + )); + } + if metadata.is_dir() { + validate_frontend_dist_directory(dist_root, &path)?; + continue; + } + if !metadata.is_file() { + return Err(format!( + "生产 frontendDist 只能包含普通文件和目录:{relative_path}" + )); + } + + let bytes = fs::read(&path) + .map_err(|error| format!("读取生产 frontendDist 文件失败:{relative_path}: {error}"))?; + if contains_bytes(&bytes, FORBIDDEN_TEST_PROJECT_PATH) { + return Err(format!( + "生产 frontendDist 包含禁止的测试默认路径 /tmp/genarrative-ai-game-draft:{relative_path}" + )); + } + } + + Ok(()) +} + +fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool { + !needle.is_empty() + && haystack + .windows(needle.len()) + .any(|window| window == needle) +} + +fn relative_dist_path(dist_root: &Path, path: &Path) -> String { + path.strip_prefix(dist_root) + .ok() + .filter(|relative| !relative.as_os_str().is_empty()) + .map(|relative| relative.display().to_string()) + .unwrap_or_else(|| ".".to_string()) +} + +fn metadata_is_link(metadata: &fs::Metadata) -> bool { + if metadata.file_type().is_symlink() { + return true; + } + + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return true; + } + } + + false +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 498616bd4..3497aae4a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1,5 +1,91 @@ use super::*; +const AUTOMATIC_PROJECTS_DIRECTORY_NAME: &str = "Genarrative GameAgent"; + +fn automatic_local_game_projects_root(app: &tauri::AppHandle) -> Result { + app.path() + .document_dir() + .map(|documents_root| documents_root.join(AUTOMATIC_PROJECTS_DIRECTORY_NAME)) + .map_err(|error| format!("无法读取系统文档目录:{error}")) +} + +pub(crate) fn closest_existing_project_picker_directory(path: &Path) -> Option { + let mut candidate = if path.exists() && path.is_dir() { + Some(path) + } else { + path.parent() + }; + while let Some(directory) = candidate { + if directory.is_dir() { + return Some(directory.to_path_buf()); + } + candidate = directory.parent(); + } + None +} + +pub(crate) fn create_automatic_local_game_project_at( + projects_root: &Path, +) -> Result { + if projects_root.as_os_str().is_empty() || !projects_root.is_absolute() { + return Err("自动工作区根目录必须是绝对路径".to_string()); + } + fs::create_dir_all(projects_root).map_err(|error| { + format!( + "创建自动工作区根目录失败:{}: {error}", + projects_root.display() + ) + })?; + let metadata = fs::symlink_metadata(projects_root).map_err(|error| { + format!( + "读取自动工作区根目录失败:{}: {error}", + projects_root.display() + ) + })?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err("自动工作区根目录必须是普通文件夹".to_string()); + } + + for _ in 0..16 { + let workspace_id = uuid::Uuid::new_v4().simple().to_string(); + let short_id = &workspace_id[..8]; + let project_name = format!("GameAgent 项目 {short_id}"); + let project_root = projects_root.join(format!("gameagent-{short_id}")); + match fs::create_dir(&project_root) { + Ok(()) => { + let result = (|| { + enforce_project_permission_policy(&project_root, "project.create")?; + let _lock = acquire_project_write_lock(&project_root, "project.create")?; + init_local_game_project_at( + &project_root, + &format!("gameagent-{workspace_id}"), + &project_name, + ) + })(); + if result.is_err() { + let _ = fs::remove_dir_all(&project_root); + } + return result; + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => { + return Err(format!( + "创建自动工作区失败:{}: {error}", + project_root.display() + )); + } + } + } + Err("自动工作区命名冲突,请重试".to_string()) +} + +#[tauri::command] +pub(crate) fn create_automatic_local_game_project( + app: tauri::AppHandle, +) -> Result { + create_automatic_local_game_project_at(&automatic_local_game_projects_root(&app)?) +} + #[tauri::command] pub(crate) fn init_local_game_project( project_path: String, @@ -20,6 +106,9 @@ pub(crate) fn import_local_godot_project( ) -> Result { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "project.create")?; + if discover_local_godot_project_root(root)?.is_none() { + return Err("所选工作区未在根目录或一层子目录发现有效的普通文件 project.godot".to_string()); + } let _lock = acquire_project_write_lock(root, "project.create")?; import_local_godot_project_at(root, project_id.trim(), name.trim()) } @@ -65,12 +154,14 @@ pub(crate) fn inspect_local_project_directory( return Err("项目目录不能包含控制字符".to_string()); } let recent_run_trace = recent_game_creator_run_trace(root); + let godot_project_root = discover_local_godot_project_root(root)?; Ok(LocalProjectDirectoryStatus { project_path: root.to_string_lossy().into_owned(), exists: root.exists(), is_directory: root.is_dir(), is_game_creator_project: is_game_creator_project_directory(root), - is_godot_project: is_godot_project_directory(root), + is_godot_project: godot_project_root.is_some(), + godot_project_root, project_name: game_creator_project_name(root), manifest_error: game_creator_project_manifest_error(root), recent_run_status: recent_run_trace.as_ref().map(|trace| trace.status.clone()), @@ -78,16 +169,6 @@ pub(crate) fn inspect_local_project_directory( }) } -pub(crate) fn is_godot_project_directory(root: &Path) -> bool { - if !root.is_dir() { - return false; - } - let project_file = root.join("project.godot"); - fs::symlink_metadata(project_file) - .map(|metadata| metadata.is_file() && !metadata.file_type().is_symlink()) - .unwrap_or(false) -} - pub(crate) fn is_game_creator_project_directory(root: &Path) -> bool { if !root.is_dir() { return false; @@ -127,9 +208,23 @@ pub(crate) fn recent_game_creator_run_trace(root: &Path) -> Option, ) -> Result, String> { let (sender, receiver) = tokio::sync::oneshot::channel(); let mut dialog = app.dialog().file().set_title("选择游戏项目目录"); + if let Some(initial_path) = initial_path + .as_deref() + .map(str::trim) + .filter(|path| !path.is_empty()) + { + let initial_path = Path::new(initial_path); + if initial_path.is_absolute() && !project_path_has_control_chars(initial_path) { + let starting_directory = closest_existing_project_picker_directory(initial_path); + if let Some(starting_directory) = starting_directory { + dialog = dialog.set_directory(starting_directory); + } + } + } if let Some(window) = app.get_webview_window("client") { dialog = dialog.set_parent(&window); } @@ -216,7 +311,7 @@ pub(crate) fn get_local_game_manifest( return Err(format!("不支持通过 manifest 执行命令:{command_id}")); } enforce_project_permission_policy(root, command_id)?; - read_manifest_for_project(root) + read_manifest_for_project_with_godot_root_calibration(root) } #[tauri::command] diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 81681f082..bb23a8e46 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -128,6 +128,7 @@ struct LocalProjectDirectoryStatus { is_directory: bool, is_game_creator_project: bool, is_godot_project: bool, + godot_project_root: Option, project_name: Option, manifest_error: Option, recent_run_status: Option, @@ -2178,6 +2179,7 @@ fn main() { Ok(()) }) .invoke_handler(tauri::generate_handler![ + create_automatic_local_game_project, init_local_game_project, import_local_godot_project, is_local_project_directory_non_empty, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index fd1d82f5e..8872e29dc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -1,10 +1,216 @@ use super::*; +use super::filesystem::validate_portable_project_path_component; + const MANIFEST_LOCK_WAIT_ATTEMPTS: usize = 500; const MANIFEST_LOCK_WAIT_MILLIS: u64 = 10; static MANIFEST_LOCK_OPEN_GUARD: OnceLock> = OnceLock::new(); +#[cfg(windows)] +fn godot_metadata_is_reparse_point(metadata: &fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 +} + +#[cfg(not(windows))] +fn godot_metadata_is_reparse_point(_metadata: &fs::Metadata) -> bool { + false +} + +fn godot_metadata_is_link(metadata: &fs::Metadata) -> bool { + metadata.file_type().is_symlink() || godot_metadata_is_reparse_point(metadata) +} + +fn inspect_godot_project_marker(root: &Path) -> Result { + let project_file = root.join("project.godot"); + let metadata = match fs::symlink_metadata(&project_file) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => { + return Err(format!( + "读取 Godot 项目文件失败:{}: {error}", + project_file.display() + )); + } + }; + if godot_metadata_is_link(&metadata) { + return Err(format!( + "Godot 项目文件不能是符号链接或 reparse point:{}", + project_file.display() + )); + } + if !metadata.is_file() { + return Err(format!( + "Godot 项目文件必须是普通文件:{}", + project_file.display() + )); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if metadata.nlink() != 1 { + return Err(format!( + "Godot 项目文件不能是硬链接文件:{}", + project_file.display() + )); + } + } + #[cfg(windows)] + { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{ + GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION, + }; + + let file = fs::File::open(&project_file).map_err(|error| { + format!( + "打开 Godot 项目文件失败:{}: {error}", + project_file.display() + ) + })?; + // SAFETY: the structure is plain data initialized by GetFileInformationByHandle. + let mut information = unsafe { std::mem::zeroed::() }; + // SAFETY: file owns a live handle and information is a valid output pointer. + if unsafe { GetFileInformationByHandle(file.as_raw_handle().cast(), &mut information) } == 0 + { + return Err(format!( + "读取 Godot 项目文件 Windows 身份失败:{}", + std::io::Error::last_os_error() + )); + } + if information.nNumberOfLinks != 1 { + return Err(format!( + "Godot 项目文件不能是硬链接文件:{}", + project_file.display() + )); + } + } + Ok(true) +} + +fn validate_godot_project_child_name(name: &std::ffi::OsStr) -> Result { + let name = name + .to_str() + .ok_or_else(|| "Godot 项目子目录名称必须是有效 UTF-8".to_string())?; + validate_portable_project_path_component(name) + .map_err(|error| format!("Godot 项目子目录名称不可跨平台使用:{error}"))?; + Ok(name.to_string()) +} + +fn validate_manifest_godot_project_root(value: Option<&str>) -> Result<(), String> { + let Some(value) = value else { + return Ok(()); + }; + if value == "." { + return Ok(()); + } + if value.is_empty() + || Path::new(value).is_absolute() + || value.contains('/') + || value.contains('\\') + || matches!(value, "..") + { + return Err("manifest godotProjectRoot 只能是 . 或单个安全相对目录名".to_string()); + } + validate_portable_project_path_component(value) + .map_err(|error| format!("manifest godotProjectRoot 无效:{error}")) +} + +pub(crate) fn discover_local_godot_project_root( + workspace_root: &Path, +) -> Result, String> { + if workspace_root.as_os_str().is_empty() { + return Err("项目目录不能为空".to_string()); + } + if !workspace_root.is_absolute() { + return Err("项目目录必须是绝对路径".to_string()); + } + if project_path_has_control_chars(workspace_root) { + return Err("项目目录不能包含控制字符".to_string()); + } + + let workspace_metadata = match fs::symlink_metadata(workspace_root) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(format!( + "读取项目目录失败:{}: {error}", + workspace_root.display() + )); + } + }; + if godot_metadata_is_link(&workspace_metadata) { + return Err("项目目录不能是符号链接或 reparse point".to_string()); + } + if !workspace_metadata.is_dir() { + return Ok(None); + } + + if inspect_godot_project_marker(workspace_root)? { + return Ok(Some(".".to_string())); + } + + let mut matches = Vec::new(); + let entries = fs::read_dir(workspace_root).map_err(|error| { + format!( + "读取 Godot 工作区目录失败:{}: {error}", + workspace_root.display() + ) + })?; + for entry in entries { + let entry = entry.map_err(|error| { + format!( + "读取 Godot 工作区目录项失败:{}: {error}", + workspace_root.display() + ) + })?; + let path = entry.path(); + let metadata = fs::symlink_metadata(&path) + .map_err(|error| format!("读取 Godot 项目候选失败:{}: {error}", path.display()))?; + if godot_metadata_is_link(&metadata) { + continue; + } + if !metadata.is_dir() || !inspect_godot_project_marker(&path)? { + continue; + } + matches.push(validate_godot_project_child_name(&entry.file_name())?); + } + matches.sort(); + if matches.len() > 1 { + return Err(format!( + "工作区一层子目录中发现多个 Godot 项目:{}", + matches.join("、") + )); + } + let Some(relative_root) = matches.pop() else { + return Ok(None); + }; + + let candidate_root = workspace_root.join(&relative_root); + let candidate_metadata = fs::symlink_metadata(&candidate_root).map_err(|error| { + format!( + "复核 Godot 项目目录失败:{}: {error}", + candidate_root.display() + ) + })?; + if godot_metadata_is_link(&candidate_metadata) || !candidate_metadata.is_dir() { + return Err(format!( + "Godot 项目目录在发现期间发生替换或不是普通目录:{}", + candidate_root.display() + )); + } + if !inspect_godot_project_marker(&candidate_root)? { + return Err(format!( + "Godot 项目文件在发现期间消失:{}", + candidate_root.join("project.godot").display() + )); + } + Ok(Some(relative_root)) +} + #[derive(Debug)] struct ManifestWriteLock { _file: File, @@ -255,11 +461,11 @@ pub(crate) fn import_local_godot_project_at( return Err("项目目录不能包含控制字符".to_string()); } if !root.is_dir() { - return Err("Godot 项目目录不存在或不是文件夹".to_string()); - } - if !is_godot_project_directory(root) { - return Err("所选文件夹不是有效的 Godot 项目:缺少普通文件 project.godot".to_string()); + return Err("Godot 工作区目录不存在或不是文件夹".to_string()); } + let godot_project_root = discover_local_godot_project_root(root)?.ok_or_else(|| { + "所选工作区未在根目录或一层子目录发现有效的普通文件 project.godot".to_string() + })?; if project_id.is_empty() { return Err("项目 ID 不能为空".to_string()); } @@ -269,7 +475,11 @@ pub(crate) fn import_local_godot_project_at( let manifest_path = root.join(".agent/manifest.json"); if manifest_storage_exists(&manifest_path)? { - let manifest = read_manifest(&manifest_path)?; + let mut manifest = read_manifest(&manifest_path)?; + if manifest.godot_project_root.as_deref() != Some(godot_project_root.as_str()) { + manifest.godot_project_root = Some(godot_project_root); + write_manifest(&manifest_path, &manifest)?; + } return Ok(InitLocalProjectResult { project_path: root.to_string_lossy().into_owned(), manifest_path: manifest_path.to_string_lossy().into_owned(), @@ -286,6 +496,7 @@ pub(crate) fn import_local_godot_project_at( "projectId": project_id, "name": name, "projectKind": "godot", + "godotProjectRoot": godot_project_root, }), )?; } @@ -299,7 +510,8 @@ pub(crate) fn import_local_godot_project_at( })?; } - let manifest = new_game_creation_app_manifest(project_id, name); + let mut manifest = new_game_creation_app_manifest(project_id, name); + manifest.godot_project_root = Some(godot_project_root); write_manifest(&manifest_path, &manifest)?; Ok(InitLocalProjectResult { @@ -380,6 +592,17 @@ pub(crate) fn read_manifest_for_project(root: &Path) -> Result Result { + let godot_project_root = discover_local_godot_project_root(root)?; + let (manifest_path, mut manifest) = read_or_create_manifest(root)?; + ensure_manifest_seed_tasks(root, &mut manifest); + manifest.godot_project_root = godot_project_root; + write_manifest(&manifest_path, &manifest)?; + Ok(manifest) +} + pub(crate) fn read_existing_manifest_for_project( root: &Path, ) -> Result { @@ -958,6 +1181,8 @@ pub(crate) fn read_manifest(path: &Path) -> Result( where F: FnOnce(), { + validate_manifest_godot_project_root(manifest.godot_project_root.as_deref()) + .map_err(|error| format!("校验 manifest Godot 项目根失败:{error}"))?; if let Some(parent) = path.parent() { fs::create_dir_all(parent) .map_err(|error| format!("创建 manifest 目录失败:{}: {error}", parent.display()))?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/import_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/import_tests.rs index 367e46815..b226c4158 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/import_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/import_tests.rs @@ -14,15 +14,29 @@ fn godot_import_test_path(label: &str) -> PathBuf { )) } +fn write_godot_project(root: &Path, name: &str) { + fs::create_dir_all(root).expect("create Godot project root"); + fs::write( + root.join("project.godot"), + format!("[application]\nconfig/name=\"{name}\"\n"), + ) + .expect("write project.godot"); +} + +fn assert_manifest_godot_root(root: &Path, expected: &str) { + let manifest = read_manifest(&root.join(".agent/manifest.json")).expect("read manifest"); + assert_eq!(manifest.godot_project_root.as_deref(), Some(expected)); + let payload: serde_json::Value = serde_json::from_str( + &fs::read_to_string(root.join(".agent/manifest.json")).expect("read manifest payload"), + ) + .expect("parse manifest payload"); + assert_eq!(payload["godotProjectRoot"], expected); +} + #[test] fn imports_godot_project_without_creating_parallel_game_directories() { let root = godot_import_test_path("valid"); - fs::create_dir_all(&root).expect("create Godot project root"); - fs::write( - root.join("project.godot"), - "[application]\nconfig/name=\"Existing\"\n", - ) - .expect("write project.godot"); + write_godot_project(&root, "Existing"); fs::create_dir(root.join("scenes")).expect("create existing scenes"); fs::write(root.join("scenes/main.tscn"), "[gd_scene format=3]\n") .expect("write existing scene"); @@ -32,8 +46,20 @@ fn imports_godot_project_without_creating_parallel_game_directories() { assert_eq!(result.project_path, root.to_string_lossy()); assert_eq!(result.manifest.project_id, "godot-project"); + assert_eq!(result.manifest.godot_project_root.as_deref(), Some(".")); assert!(root.join(".agent/manifest.json").is_file()); assert!(root.join(".agent/agent.db").is_file()); + let import_record: serde_json::Value = serde_json::from_str( + fs::read_to_string(root.join(".agent/agent.db")) + .expect("read Godot import Agent DB") + .lines() + .next() + .expect("Godot import Agent DB record"), + ) + .expect("parse Godot import Agent DB record"); + assert_eq!(import_record["recordType"], "project.import"); + assert_eq!(import_record["godotProjectRoot"], "."); + assert_manifest_godot_root(&root, "."); assert_eq!( fs::read_to_string(root.join("scenes/main.tscn")).expect("read preserved scene"), "[gd_scene format=3]\n" @@ -49,9 +75,314 @@ fn imports_godot_project_without_creating_parallel_game_directories() { .expect("reopen imported Godot project"); assert_eq!(reopened.manifest.project_id, "godot-project"); assert_eq!(reopened.manifest.name, "Existing"); + assert_eq!(reopened.manifest.godot_project_root.as_deref(), Some(".")); fs::remove_dir_all(root).ok(); } +#[test] +fn imports_a_unique_direct_child_godot_project_into_the_workspace_root() { + let workspace = godot_import_test_path("child"); + let godot_root = workspace.join("game-source"); + write_godot_project(&godot_root, "Nested"); + fs::create_dir(godot_root.join("scenes")).expect("create nested scenes"); + fs::write(godot_root.join("scenes/main.tscn"), "[gd_scene format=3]\n") + .expect("write nested scene"); + + let result = import_local_godot_project_at(&workspace, "nested-godot", "Nested") + .expect("import nested Godot project"); + + assert_eq!(result.project_path, workspace.to_string_lossy()); + assert_eq!( + result.manifest.godot_project_root.as_deref(), + Some("game-source") + ); + assert!(workspace.join(".agent/manifest.json").is_file()); + assert!(workspace.join(".agent/agent.db").is_file()); + let import_record: serde_json::Value = serde_json::from_str( + fs::read_to_string(workspace.join(".agent/agent.db")) + .expect("read nested Godot import Agent DB") + .lines() + .next() + .expect("nested Godot import Agent DB record"), + ) + .expect("parse nested Godot import Agent DB record"); + assert_eq!(import_record["godotProjectRoot"], "game-source"); + assert!(!godot_root.join(".agent").exists()); + assert_manifest_godot_root(&workspace, "game-source"); + assert_eq!( + fs::read_to_string(godot_root.join("scenes/main.tscn")).expect("read nested scene"), + "[gd_scene format=3]\n" + ); + + fs::remove_dir_all(workspace).ok(); +} + +#[test] +fn root_godot_project_takes_priority_over_direct_child_projects() { + let workspace = godot_import_test_path("root-priority"); + write_godot_project(&workspace, "Root"); + write_godot_project(&workspace.join("nested"), "Nested"); + + let result = import_local_godot_project_at(&workspace, "root-godot", "Root") + .expect("root Godot project should win"); + + assert_eq!(result.manifest.godot_project_root.as_deref(), Some(".")); + assert_manifest_godot_root(&workspace, "."); + assert!(!workspace.join("nested/.agent").exists()); + fs::remove_dir_all(workspace).ok(); +} + +#[test] +fn rejects_multiple_direct_child_godot_projects_before_writing_agent_metadata() { + let workspace = godot_import_test_path("multiple-children"); + write_godot_project(&workspace.join("alpha"), "Alpha"); + write_godot_project(&workspace.join("beta"), "Beta"); + + let error = import_local_godot_project_at(&workspace, "ambiguous", "Ambiguous") + .expect_err("multiple direct child Godot projects must fail"); + + assert!(error.contains("多个 Godot 项目"), "{error}"); + assert!(!workspace.join(".agent").exists()); + assert!(!workspace.join("alpha/.agent").exists()); + assert!(!workspace.join("beta/.agent").exists()); + fs::remove_dir_all(workspace).ok(); +} + +#[test] +fn ignores_godot_projects_below_the_first_child_level() { + let workspace = godot_import_test_path("too-deep"); + write_godot_project(&workspace.join("parent/nested"), "Too Deep"); + + let error = import_local_godot_project_at(&workspace, "too-deep", "Too Deep") + .expect_err("second-level Godot project must not be imported"); + + assert!(error.contains("根目录或一层子目录"), "{error}"); + assert!(!workspace.join(".agent").exists()); + fs::remove_dir_all(workspace).ok(); +} + +#[test] +fn calibrates_existing_manifest_to_the_discovered_godot_root() { + let workspace = godot_import_test_path("calibrate-existing"); + init_local_game_project_at(&workspace, "existing-project", "Existing Project") + .expect("initialize existing workspace"); + write_godot_project(&workspace.join("godot-game"), "Godot Game"); + let manifest_path = workspace.join(".agent/manifest.json"); + let mut stale_manifest = read_manifest(&manifest_path).expect("read existing manifest"); + stale_manifest.godot_project_root = Some("stale-root".to_string()); + write_manifest(&manifest_path, &stale_manifest).expect("write stale Godot root"); + + let result = import_local_godot_project_at(&workspace, "ignored", "Ignored") + .expect("reopen existing Godot workspace"); + + assert_eq!(result.manifest.project_id, "existing-project"); + assert_eq!(result.manifest.name, "Existing Project"); + assert_eq!( + result.manifest.godot_project_root.as_deref(), + Some("godot-game") + ); + assert_manifest_godot_root(&workspace, "godot-game"); + assert!(!workspace.join("godot-game/.agent").exists()); + fs::remove_dir_all(workspace).ok(); +} + +#[test] +fn ambiguous_layout_does_not_rewrite_an_existing_manifest() { + let workspace = godot_import_test_path("ambiguous-existing"); + init_local_game_project_at(&workspace, "existing-project", "Existing Project") + .expect("initialize existing workspace"); + write_godot_project(&workspace.join("game"), "Game"); + write_godot_project(&workspace.join("other"), "Other"); + let manifest_path = workspace.join(".agent/manifest.json"); + let original = fs::read(&manifest_path).expect("read original manifest"); + + let error = import_local_godot_project_at(&workspace, "ignored", "Ignored") + .expect_err("ambiguous existing workspace must fail"); + + assert!(error.contains("多个 Godot 项目"), "{error}"); + assert_eq!( + fs::read(&manifest_path).expect("read unchanged manifest"), + original + ); + fs::remove_dir_all(workspace).ok(); +} + +#[test] +fn opening_manifest_calibrates_missing_godot_root_without_moving_workspace_metadata() { + let workspace = godot_import_test_path("calibrate-on-open"); + init_local_game_project_at(&workspace, "existing-project", "Existing Project") + .expect("initialize existing workspace"); + write_godot_project(&workspace.join("godot-game"), "Godot Game"); + + let manifest = read_manifest_for_project_with_godot_root_calibration(&workspace) + .expect("open and calibrate Godot workspace"); + + assert_eq!(manifest.project_id, "existing-project"); + assert_eq!(manifest.godot_project_root.as_deref(), Some("godot-game")); + assert_manifest_godot_root(&workspace, "godot-game"); + assert!(!workspace.join("godot-game/.agent").exists()); + fs::remove_dir_all(workspace).ok(); +} + +#[test] +fn opening_plain_workspace_clears_a_stale_safe_godot_root() { + let workspace = godot_import_test_path("clear-stale-root"); + init_local_game_project_at(&workspace, "existing-project", "Existing Project") + .expect("initialize existing workspace"); + let manifest_path = workspace.join(".agent/manifest.json"); + let mut stale_manifest = read_manifest(&manifest_path).expect("read existing manifest"); + stale_manifest.godot_project_root = Some("old-game".to_string()); + write_manifest(&manifest_path, &stale_manifest).expect("write stale Godot root"); + + let manifest = read_manifest_for_project_with_godot_root_calibration(&workspace) + .expect("open and calibrate plain workspace"); + + assert_eq!(manifest.project_id, "existing-project"); + assert_eq!(manifest.godot_project_root, None); + let persisted = read_manifest(&manifest_path).expect("read calibrated manifest"); + assert_eq!(persisted.godot_project_root, None); + fs::remove_dir_all(workspace).ok(); +} + +#[test] +fn manifest_rejects_unsafe_godot_project_roots() { + let workspace = godot_import_test_path("unsafe-manifest-root"); + let manifest_path = workspace.join(".agent/manifest.json"); + let mut manifest = new_game_creation_app_manifest("unsafe", "Unsafe"); + manifest.godot_project_root = Some("../outside".to_string()); + + let error = write_manifest(&manifest_path, &manifest) + .expect_err("unsafe manifest Godot root must fail closed"); + + assert!(error.contains("godotProjectRoot"), "{error}"); + assert!(!manifest_path.exists()); + fs::remove_dir_all(workspace).ok(); +} + +#[test] +fn manifest_read_rejects_unsafe_persisted_godot_project_root() { + let workspace = godot_import_test_path("unsafe-persisted-root"); + let manifest_path = workspace.join(".agent/manifest.json"); + fs::create_dir_all(manifest_path.parent().expect("manifest parent")) + .expect("create manifest parent"); + let mut payload = serde_json::to_value(new_game_creation_app_manifest("unsafe", "Unsafe")) + .expect("serialize manifest fixture"); + payload["godotProjectRoot"] = serde_json::json!("../outside"); + fs::write( + &manifest_path, + format!( + "{}\n", + serde_json::to_string_pretty(&payload).expect("serialize unsafe manifest fixture") + ), + ) + .expect("write unsafe manifest fixture"); + + let error = read_manifest(&manifest_path) + .expect_err("unsafe persisted manifest Godot root must fail closed"); + + assert!(error.contains("godotProjectRoot"), "{error}"); + fs::remove_dir_all(workspace).ok(); +} + +#[cfg(unix)] +#[test] +fn rejects_symbolic_link_project_marker_without_writing_agent_metadata() { + use std::os::unix::fs::symlink; + + let workspace = godot_import_test_path("linked-marker"); + fs::create_dir_all(&workspace).expect("create linked marker workspace"); + fs::write(workspace.join("real.godot"), "[application]\n").expect("write real marker"); + symlink("real.godot", workspace.join("project.godot")).expect("link project marker"); + + let error = import_local_godot_project_at(&workspace, "linked", "Linked") + .expect_err("linked project.godot must fail"); + + assert!(error.contains("符号链接"), "{error}"); + assert!(!workspace.join(".agent").exists()); + fs::remove_dir_all(workspace).ok(); +} + +#[cfg(unix)] +#[test] +fn ignores_symbolic_link_child_candidate_without_writing_agent_metadata() { + use std::os::unix::fs::symlink; + + let workspace = godot_import_test_path("linked-child"); + let target = godot_import_test_path("linked-child-target"); + fs::create_dir_all(&workspace).expect("create linked child workspace"); + write_godot_project(&target, "Linked Child"); + symlink(&target, workspace.join("linked-game")).expect("link Godot child"); + + let error = import_local_godot_project_at(&workspace, "linked", "Linked") + .expect_err("linked Godot child must not be followed"); + + assert!(error.contains("根目录或一层子目录"), "{error}"); + assert!(!workspace.join(".agent").exists()); + fs::remove_dir_all(workspace).ok(); + fs::remove_dir_all(target).ok(); +} + +#[cfg(unix)] +#[test] +fn ignores_unrelated_symbolic_link_while_importing_a_unique_regular_child() { + use std::os::unix::fs::symlink; + + let workspace = godot_import_test_path("unrelated-linked-child"); + let unrelated_target = godot_import_test_path("unrelated-linked-target"); + write_godot_project(&workspace.join("game"), "Game"); + fs::create_dir_all(&unrelated_target).expect("create unrelated target"); + symlink(&unrelated_target, workspace.join("vendor-link")).expect("link unrelated directory"); + + let result = import_local_godot_project_at(&workspace, "regular", "Regular") + .expect("unrelated directory link must be ignored"); + + assert_eq!(result.manifest.godot_project_root.as_deref(), Some("game")); + assert_manifest_godot_root(&workspace, "game"); + fs::remove_dir_all(workspace).ok(); + fs::remove_dir_all(unrelated_target).ok(); +} + +#[cfg(unix)] +#[test] +fn rejects_linked_marker_inside_a_regular_child_candidate() { + use std::os::unix::fs::symlink; + + let workspace = godot_import_test_path("linked-child-marker"); + let godot_root = workspace.join("game"); + fs::create_dir_all(&godot_root).expect("create regular child candidate"); + fs::write(godot_root.join("real.godot"), "[application]\n").expect("write real marker"); + symlink("real.godot", godot_root.join("project.godot")).expect("link project marker"); + + let error = import_local_godot_project_at(&workspace, "linked", "Linked") + .expect_err("linked marker in a regular child must fail"); + + assert!(error.contains("符号链接"), "{error}"); + assert!(!workspace.join(".agent").exists()); + fs::remove_dir_all(workspace).ok(); +} + +#[cfg(windows)] +#[test] +fn ignores_windows_reparse_child_candidate_without_writing_agent_metadata() { + let workspace = godot_import_test_path("linked-child"); + let target = godot_import_test_path("linked-child-target"); + fs::create_dir_all(&workspace).expect("create linked child workspace"); + write_godot_project(&target, "Linked Child"); + if std::os::windows::fs::symlink_dir(&target, workspace.join("linked-game")).is_err() { + fs::remove_dir_all(workspace).ok(); + fs::remove_dir_all(target).ok(); + return; + } + + let error = import_local_godot_project_at(&workspace, "linked", "Linked") + .expect_err("reparse Godot child must not be followed"); + + assert!(error.contains("根目录或一层子目录"), "{error}"); + assert!(!workspace.join(".agent").exists()); + fs::remove_dir_all(workspace).ok(); + fs::remove_dir_all(target).ok(); +} + #[test] fn rejects_non_godot_directory_without_writing_agent_metadata() { let root = godot_import_test_path("invalid"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 39fb4ea1c..1fda1fa86 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -1260,6 +1260,66 @@ fn init_local_game_project_creates_manifest_and_dirs() { fs::remove_dir_all(root).ok(); } +#[test] +fn project_picker_uses_the_closest_existing_parent_for_a_suggested_new_path() { + let documents_root = unique_project_path(); + fs::create_dir_all(&documents_root).expect("create documents fixture"); + let suggested_project_path = documents_root + .join("Genarrative GameAgent") + .join("gameagent-new"); + + assert_eq!( + closest_existing_project_picker_directory(&suggested_project_path), + Some(documents_root.clone()) + ); + + fs::remove_dir_all(documents_root).ok(); +} + +#[test] +fn automatic_local_game_project_allocates_unique_initialized_workspaces() { + let projects_root = unique_project_path(); + + let first = create_automatic_local_game_project_at(&projects_root) + .expect("create first automatic workspace"); + let second = create_automatic_local_game_project_at(&projects_root) + .expect("create second automatic workspace"); + + assert_ne!(first.project_path, second.project_path); + for result in [first, second] { + let root = PathBuf::from(&result.project_path); + assert_eq!(root.parent(), Some(projects_root.as_path())); + assert!(root + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("gameagent-"))); + assert!(root.join(".agent/manifest.json").is_file()); + assert!(root.join("game/index.html").is_file()); + assert!(result.manifest.name.starts_with("GameAgent 项目 ")); + } + + fs::remove_dir_all(projects_root).ok(); +} + +#[cfg(unix)] +#[test] +fn automatic_local_game_project_rejects_symlinked_projects_root() { + use std::os::unix::fs::symlink; + + let container = unique_project_path(); + let target = container.join("target"); + let projects_root = container.join("projects"); + fs::create_dir_all(&target).expect("create automatic workspace target"); + symlink(&target, &projects_root).expect("symlink automatic workspace root"); + + let error = create_automatic_local_game_project_at(&projects_root) + .expect_err("symlinked automatic workspace root must fail"); + + assert!(error.contains("普通文件夹")); + assert!(fs::read_dir(&target).expect("read target").next().is_none()); + fs::remove_dir_all(container).ok(); +} + #[test] fn init_local_game_project_requires_absolute_path() { let error = init_local_game_project_at(Path::new("relative-game"), "project-1", "demo") @@ -1321,6 +1381,7 @@ fn project_directory_status_distinguishes_missing_file_and_dir() { is_directory: false, is_game_creator_project: false, is_godot_project: false, + godot_project_root: None, project_name: None, manifest_error: None, recent_run_status: None, @@ -1334,6 +1395,8 @@ fn project_directory_status_distinguishes_missing_file_and_dir() { assert!(file_status.exists); assert!(!file_status.is_directory); assert!(!file_status.is_game_creator_project); + assert!(!file_status.is_godot_project); + assert_eq!(file_status.godot_project_root, None); assert_eq!(file_status.project_name, None); assert_eq!(file_status.manifest_error, None); assert_eq!(file_status.recent_run_status, None); @@ -1345,6 +1408,8 @@ fn project_directory_status_distinguishes_missing_file_and_dir() { assert!(dir_status.exists); assert!(dir_status.is_directory); assert!(!dir_status.is_game_creator_project); + assert!(!dir_status.is_godot_project); + assert_eq!(dir_status.godot_project_root, None); assert_eq!(dir_status.project_name, None); assert_eq!(dir_status.manifest_error, None); assert_eq!(dir_status.recent_run_status, None); @@ -1403,6 +1468,64 @@ fn project_directory_status_distinguishes_missing_file_and_dir() { fs::remove_dir_all(root).ok(); } +#[test] +fn project_directory_status_reports_workspace_relative_godot_root() { + let root = unique_project_path(); + let godot_root = root.join("godot-game"); + fs::create_dir_all(&godot_root).expect("create nested Godot project"); + fs::write(godot_root.join("project.godot"), "[application]\n") + .expect("write nested project.godot"); + + let status = inspect_local_project_directory(root.to_string_lossy().to_string()) + .expect("inspect nested Godot workspace"); + + assert!(status.exists); + assert!(status.is_directory); + assert!(status.is_godot_project); + assert_eq!(status.godot_project_root.as_deref(), Some("godot-game")); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn project_directory_status_rejects_ambiguous_direct_child_godot_projects() { + let root = unique_project_path(); + for child in ["alpha", "beta"] { + let godot_root = root.join(child); + fs::create_dir_all(&godot_root).expect("create nested Godot project"); + fs::write(godot_root.join("project.godot"), "[application]\n") + .expect("write nested project.godot"); + } + + let error = inspect_local_project_directory(root.to_string_lossy().to_string()) + .expect_err("ambiguous Godot workspace must fail inspection"); + + assert!(error.contains("多个 Godot 项目"), "{error}"); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn godot_import_command_rejects_ambiguity_before_creating_the_project_lock() { + let root = unique_project_path(); + for child in ["alpha", "beta"] { + let godot_root = root.join(child); + fs::create_dir_all(&godot_root).expect("create nested Godot project"); + fs::write(godot_root.join("project.godot"), "[application]\n") + .expect("write nested project.godot"); + } + + let error = import_local_godot_project( + root.to_string_lossy().into_owned(), + "ambiguous".to_string(), + "Ambiguous".to_string(), + ) + .expect_err("ambiguous Godot workspace must fail before locking"); + + assert!(error.contains("多个 Godot 项目"), "{error}"); + assert!(!root.join(PROJECT_WRITE_LOCK_PATH).exists()); + assert!(!root.join(".agent").exists()); + fs::remove_dir_all(root).ok(); +} + #[test] fn local_project_directory_open_path_requires_existing_absolute_directory() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs index 39b8c96ad..8fc86653b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs @@ -2691,7 +2691,7 @@ async fn runtime_v11_closure_repository_context_drift_replans_before_auto_mutati } assert_eq!( fs::read_to_string(root.join("AGENTS.md")).expect("read drifted rules"), - "drifted rules\\n" + "drifted rules\n" ); fs::remove_dir_all(root).ok(); diff --git a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json index c5f5c340a..fad771711 100644 --- a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json +++ b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json @@ -19,7 +19,7 @@ "width": 1280, "height": 800, "minWidth": 1280, - "minHeight": 800 + "minHeight": 720 } ], "security": { diff --git a/apps/ai-game-creator-shell/src-tauri/tauri.game-chat-release.conf.json b/apps/ai-game-creator-shell/src-tauri/tauri.game-chat-release.conf.json index 5d1096ce0..41996c7dc 100644 --- a/apps/ai-game-creator-shell/src-tauri/tauri.game-chat-release.conf.json +++ b/apps/ai-game-creator-shell/src-tauri/tauri.game-chat-release.conf.json @@ -15,7 +15,7 @@ "width": 1280, "height": 800, "minWidth": 1280, - "minHeight": 800 + "minHeight": 720 } ] }, diff --git a/apps/ai-game-creator-shell/src-tauri/tests/frontend_dist_build.rs b/apps/ai-game-creator-shell/src-tauri/tests/frontend_dist_build.rs new file mode 100644 index 000000000..dd2fa3f64 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/tests/frontend_dist_build.rs @@ -0,0 +1,141 @@ +#[path = "../build_support/frontend_dist_guard.rs"] +mod frontend_dist_guard; + +use frontend_dist_guard::validate_frontend_dist; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +static TEST_DIRECTORY_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +struct TestDirectory { + path: PathBuf, +} + +impl TestDirectory { + fn new(label: &str) -> Self { + let sequence = TEST_DIRECTORY_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "genarrative-frontend-dist-{label}-{}-{sequence}", + std::process::id() + )); + fs::create_dir(&path).expect("create frontend dist test directory"); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TestDirectory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +fn write_file(path: &Path, content: impl AsRef<[u8]>) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create frontend dist fixture parent"); + } + fs::write(path, content).expect("write frontend dist fixture"); +} + +#[test] +fn clean_frontend_dist_passes() { + let fixture = TestDirectory::new("clean"); + let dist = fixture.path().join("dist"); + write_file(&dist.join("index.html"), "
GameAgent
"); + write_file( + &dist.join("assets/index.js"), + "const defaultProjectPath = '';", + ); + + validate_frontend_dist(&dist).expect("clean frontendDist should pass"); +} + +#[test] +fn nested_frontend_asset_with_test_default_path_fails_with_relative_path() { + let fixture = TestDirectory::new("marker"); + let dist = fixture.path().join("dist"); + write_file(&dist.join("index.html"), "
GameAgent
"); + write_file( + &dist.join("assets/chunks/launcher.js"), + "const projectPath = '/tmp/genarrative-ai-game-draft';", + ); + + let error = validate_frontend_dist(&dist) + .expect_err("production frontendDist must reject the test default path"); + let portable_error = error.replace('\\', "/"); + assert!(error.contains("/tmp/genarrative-ai-game-draft")); + assert!(portable_error.contains("assets/chunks/launcher.js")); + assert!(!error.contains(&dist.display().to_string())); +} + +#[test] +fn test_fixture_outside_frontend_dist_does_not_fail_the_guard() { + let fixture = TestDirectory::new("fixture-scope"); + let dist = fixture.path().join("dist"); + write_file(&dist.join("assets/index.js"), "const projectPath = '';"); + write_file( + &fixture.path().join("tests/project-path.fixture.txt"), + "/tmp/genarrative-ai-game-draft", + ); + + validate_frontend_dist(&dist) + .expect("explicit test fixtures outside frontendDist must remain allowed"); +} + +#[test] +fn missing_frontend_dist_fails_without_leaking_an_absolute_path() { + let fixture = TestDirectory::new("missing"); + let dist = fixture.path().join("dist"); + + let error = validate_frontend_dist(&dist) + .expect_err("production frontendDist must exist before it can be embedded"); + assert!(error.contains("frontendDist")); + assert!(error.contains(":.")); + assert!(!error.contains(&dist.display().to_string())); +} + +#[cfg(unix)] +#[test] +fn frontend_dist_symlink_fails_with_relative_path() { + use std::os::unix::fs::symlink; + + let fixture = TestDirectory::new("symlink"); + let dist = fixture.path().join("dist"); + let target = fixture.path().join("fixture.js"); + write_file(&target, "const projectPath = '';"); + fs::create_dir_all(dist.join("assets")).expect("create symlink fixture parent"); + symlink(&target, dist.join("assets/linked.js")).expect("create frontend dist symlink"); + + let error = + validate_frontend_dist(&dist).expect_err("production frontendDist must reject symlinks"); + let portable_error = error.replace('\\', "/"); + assert!(error.contains("符号链接")); + assert!(portable_error.contains("assets/linked.js")); + assert!(!error.contains(&dist.display().to_string())); +} + +#[cfg(windows)] +#[test] +fn frontend_dist_symlink_fails_when_windows_allows_creating_it() { + use std::os::windows::fs::symlink_file; + + let fixture = TestDirectory::new("windows-symlink"); + let dist = fixture.path().join("dist"); + let target = fixture.path().join("fixture.js"); + write_file(&target, "const projectPath = '';"); + fs::create_dir_all(dist.join("assets")).expect("create symlink fixture parent"); + if symlink_file(&target, dist.join("assets/linked.js")).is_err() { + return; + } + + let error = validate_frontend_dist(&dist) + .expect_err("production frontendDist must reject Windows symlinks"); + let portable_error = error.replace('\\', "/"); + assert!(error.contains("符号链接")); + assert!(portable_error.contains("assets/linked.js")); + assert!(!error.contains(&dist.display().to_string())); +} diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index a37e3f958..0c871fa11 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -32,7 +32,6 @@ import { AGENT_RUN_HISTORY_VISIBLE_STEP, CONVERSATION_INITIAL_VISIBLE_COUNT, CONVERSATION_VISIBLE_STEP, - defaultProjectPath, PROJECT_SUPERVISOR_AGENT_ID, seedManifest, } from './app/constants'; @@ -608,9 +607,7 @@ export function App({ ); const eagerSupervisorProject = projectSupervisorOnly && Boolean(initialProjectPath) && !gameChatOnly; - const [projectPath, setProjectPath] = useState( - initialProjectPath || defaultProjectPath, - ); + const [projectPath, setProjectPath] = useState(initialProjectPath); const [workspaceProjectKind, setWorkspaceProjectKind] = useState(initialProjectKind); const [localProject, setLocalProject] = @@ -625,6 +622,7 @@ export function App({ ); const localProjectPathRef = useRef(null); localProjectPathRef.current = localProject?.projectPath ?? null; + const manifestRefreshMountedRef = useRef(true); const manifestRefreshStatesRef = useRef( new Map< @@ -3306,6 +3304,7 @@ export function App({ try { const selectedPath = await invoke( 'pick_local_project_directory', + projectPath.trim() ? { initialPath: projectPath.trim() } : undefined, ); if (gameChatProjectSelectionVersionRef.current !== selectionVersion) { return; diff --git a/apps/ai-game-creator-shell/src/app/constants.ts b/apps/ai-game-creator-shell/src/app/constants.ts index 371ec89cf..909379253 100644 --- a/apps/ai-game-creator-shell/src/app/constants.ts +++ b/apps/ai-game-creator-shell/src/app/constants.ts @@ -5,7 +5,6 @@ export const seedManifest = createGameCreationAppManifest( '未命名游戏原型', ); -export const defaultProjectPath = '/tmp/genarrative-ai-game-draft'; export const AGENT_RUN_HISTORY_MAX_COUNT = 100; export const AGENT_RUN_HISTORY_INITIAL_VISIBLE_COUNT = 20; export const AGENT_RUN_HISTORY_VISIBLE_STEP = 20; diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 348bb4d54..caa7ee2bf 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -96,6 +96,7 @@ export interface LocalProjectDirectoryStatus { isDirectory: boolean; isGameCreatorProject: boolean; isGodotProject: boolean; + godotProjectRoot: string | null; projectName: string | null; manifestError?: string | null; recentRunStatus: string | null; diff --git a/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx b/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx index 42baedf55..f783c2693 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx @@ -1,9 +1,153 @@ -import { FolderKanban } from 'lucide-react'; +import { + CircleAlert, + Ellipsis, + FolderKanban, + FolderOpen, + FolderPlus, + Gamepad2, + Search, + X, +} from 'lucide-react'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { closeDialogOnEscape } from '../../app/dialogs'; +import type { RecentProjectRow } from './model'; import type { HomeProjectCreationController } from './useHomeProjectCreation'; import type { RecentProjectsController } from './useRecentProjects'; +function projectKindLabel(project: RecentProjectRow) { + if (project.projectKind === 'godot') { + return project.godotProjectRoot && project.godotProjectRoot !== '.' + ? `Godot · ${project.godotProjectRoot}` + : 'Godot'; + } + if (project.projectKind === 'web') { + return 'GameAgent'; + } + return '待识别'; +} + +function projectStatusTone(project: RecentProjectRow) { + if (project.canOpen) { + return 'ready'; + } + if (project.status === '检查中') { + return 'checking'; + } + return 'warning'; +} + +function ProjectMoreMenu({ + project, + recentProjects, +}: { + project: RecentProjectRow; + recentProjects: RecentProjectsController; +}) { + const [open, setOpen] = useState(false); + const [dropUp, setDropUp] = useState(false); + const menuRef = useRef(null); + const triggerRef = useRef(null); + + useLayoutEffect(() => { + if (!open) { + setDropUp(false); + return; + } + const trigger = triggerRef.current; + const menu = menuRef.current?.querySelector( + '.launcher-project-more-menu', + ); + const table = trigger?.closest('.launcher-project-table'); + if (!trigger || !menu || !table) { + return; + } + const triggerBounds = trigger.getBoundingClientRect(); + const tableBounds = table.getBoundingClientRect(); + const menuHeight = menu.offsetHeight; + const availableBelow = + Math.min(window.innerHeight, tableBounds.bottom) - triggerBounds.bottom; + const availableAbove = triggerBounds.top - Math.max(0, tableBounds.top); + setDropUp( + availableBelow < menuHeight + 8 && availableAbove > availableBelow, + ); + }, [open]); + + useEffect(() => { + if (!open) { + return; + } + function closeIfOutside(event: PointerEvent) { + if ( + event.target instanceof Node && + !menuRef.current?.contains(event.target) + ) { + setOpen(false); + } + } + function closeOnEscape(event: KeyboardEvent) { + if (event.key !== 'Escape') { + return; + } + setOpen(false); + triggerRef.current?.focus(); + } + document.addEventListener('pointerdown', closeIfOutside); + document.addEventListener('keydown', closeOnEscape); + return () => { + document.removeEventListener('pointerdown', closeIfOutside); + document.removeEventListener('keydown', closeOnEscape); + }; + }, [open]); + + return ( +
+ + {open ? ( +
+ + +
+ ) : null} +
+ ); +} + export function ProjectsPage({ status, homeProject, @@ -13,147 +157,137 @@ export function ProjectsPage({ homeProject: HomeProjectCreationController; recentProjects: RecentProjectsController; }) { + const rows = recentProjects.filteredProjectRows; + const hasStoredProjects = recentProjects.projectRows.length > 0; return (
-
-
-

项目组

-

+

+
+

项目

+

{recentProjects.recentWorkspaceRefreshing ? '正在检查项目状态' : status}

-
- - -
-
-
- -
- - - - - -
-
-
- {recentProjects.projectRows.length > 0 ? ( - recentProjects.projectRows.map((project) => ( -
+
+
- )) - ) : ( -
-
+
+
+
+ 项目 + 类型 + 状态 + 操作 +
+
+ {rows.length > 0 ? ( + rows.map((project) => ( +
+ + + {projectKindLabel(project)} + + + {projectStatusTone(project) === 'warning' ? ( + + +
+ )) + ) : hasStoredProjects ? ( +
+
+ ) : ( +
+
+ )} +
); diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index 3ed8b76eb..ca97b8d2d 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -63,6 +63,7 @@ export function WorkspaceLauncherShell({ setAgentResults: setActiveProjectAgentResults, resetLauncherHomeDraft, createHomeDraft, + createHomeDraftAutomatically, openProject, } = homeProject; const activeProjectContextRef = useRef(currentProjectContext); @@ -259,12 +260,13 @@ export function WorkspaceLauncherShell({ homeAgentModeItems={homeAgentModeItems} recentProjectRows={recentProjectRows} onCreateDraft={createHomeDraft} + onCreateDraftAutomatically={createHomeDraftAutomatically} onProjectsOpen={() => setLauncherView('projects')} onProjectOpen={(path) => { setProjectPath(path); void openProject(path, 'open'); }} - onGodotProjectOpen={() => void homeProject.openGodotProject()} + onProjectPick={() => void homeProject.pickAndOpenProject()} /> ) : launcherView === 'projects' ? ( ( 'pick_local_project_directory', + agentChatProjectPath.trim() + ? { initialPath: agentChatProjectPath.trim() } + : undefined, ); if (!selectedPath) { setAgentChatStatus('已取消'); diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useDeveloperAgentState.ts b/apps/ai-game-creator-shell/src/features/app-shell/useDeveloperAgentState.ts index ca64ace7f..b57dc2872 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useDeveloperAgentState.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useDeveloperAgentState.ts @@ -1,6 +1,6 @@ import { useRef, useState } from 'react'; -import { defaultProjectPath, seedManifest } from '../../app/constants'; +import { seedManifest } from '../../app/constants'; import type { AgentBackgroundSubmitMode, AgentChatInteractionMode, @@ -18,8 +18,7 @@ import { deriveAgentStatusCards } from '../project-summary/agentPresentation'; export function useDeveloperAgentState() { const launcherAgentChatAgents = deriveAgentStatusCards(seedManifest, null); - const [agentChatProjectPath, setAgentChatProjectPath] = - useState(defaultProjectPath); + const [agentChatProjectPath, setAgentChatProjectPath] = useState(''); const [agentChatSelectedAgentId, setAgentChatSelectedAgentId] = useState( launcherAgentChatAgents[0]?.id ?? '', ); diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts index 58bb64ddf..27c66e43c 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts @@ -1,7 +1,8 @@ import { type Dispatch, - type FormEvent, type SetStateAction, + useCallback, + useRef, useState, } from 'react'; @@ -9,7 +10,7 @@ import type { GameCreationAppManifest, GameCreationAppPreviewState, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; -import { defaultProjectPath, seedManifest } from '../../app/constants'; +import { seedManifest } from '../../app/constants'; import { useEscapeToClose } from '../../app/dialogs'; import { resolveTauriInvoke } from '../../app/tauri'; import type { @@ -57,7 +58,15 @@ export function useHomeProjectCreation({ setAgentChatProjectPath, rememberRecentWorkspace, }: UseHomeProjectCreationOptions) { - const [projectPath, setProjectPath] = useState(defaultProjectPath); + const [projectPath, setProjectPathState] = useState(''); + const [projectAction, setProjectAction] = useState< + 'opening' | 'creating' | null + >(null); + const projectActionRef = useRef(projectAction); + projectActionRef.current = projectAction; + const setProjectPath = useCallback((nextProjectPath: string) => { + setProjectPathState(nextProjectPath); + }, []); const [currentProjectContext, setCurrentProjectContext] = useState(null); const [activeProjectPreview, setActiveProjectPreview] = @@ -153,6 +162,58 @@ export function useHomeProjectCreation({ return imported; } + async function enterCreatedHomeProject( + invoke: TauriInvoke, + result: InitLocalProjectResult, + mode: HomeAgentMode, + prompt: string, + attachments: HomeAttachmentDraft[], + ) { + const importedAttachments = await importHomeAttachments( + invoke, + result.projectPath, + attachments, + ); + const sessionId = await ensureProjectSupervisorActiveSessionId( + invoke, + result.projectPath, + ); + if (!sessionId) { + throw new Error('项目总控 Agent active Session 不可用'); + } + await submitProjectSupervisorRuntimeTask({ + invoke, + projectPath: result.projectPath, + sessionId, + prompt: buildHomeConversationContent(mode, prompt, attachments), + runtime: null, + runProfile: 'autonomous-game-build', + source: 'project-supervisor-game-chat', + }); + enterProjectDevelopment({ + projectPath: result.projectPath, + projectName: + result.manifest.name || projectNameFromPath(result.projectPath), + projectKind: 'web', + manifest: result.manifest, + projectRevision: await readCurrentProjectRevision( + invoke, + result.projectPath, + ), + mode, + initialPrompt: + prompt.trim() || + (attachments.length > 0 + ? '用户上传了参考附件,等待后续补充需求。' + : ''), + attachments: importedAttachments, + recentRunStatus: null, + recentRunStopReason: null, + createdAt: Date.now(), + }); + resetLauncherHomeDraft(); + } + async function createHomeProjectFromDirectory( nextProjectPath: string, mode: HomeAgentMode, @@ -196,28 +257,14 @@ export function useHomeProjectCreation({ name: projectNameFromPath(trimmedProjectPath), }, ); - const importedAttachments = await importHomeAttachments( - invoke, - result.projectPath, - attachments, - ); try { - const sessionId = await ensureProjectSupervisorActiveSessionId( + await enterCreatedHomeProject( invoke, - result.projectPath, + result, + mode, + prompt, + attachments, ); - if (!sessionId) { - throw new Error('项目总控 Agent active Session 不可用'); - } - await submitProjectSupervisorRuntimeTask({ - invoke, - projectPath: result.projectPath, - sessionId, - prompt: buildHomeConversationContent(mode, prompt, attachments), - runtime: null, - runProfile: 'autonomous-game-build', - source: 'project-supervisor-game-chat', - }); setStatus('已创建项目并交给项目总控 Agent'); } catch (error) { throw new Error( @@ -226,28 +273,6 @@ export function useHomeProjectCreation({ }`, ); } - enterProjectDevelopment({ - projectPath: result.projectPath, - projectName: - result.manifest.name || projectNameFromPath(result.projectPath), - projectKind: 'web', - manifest: result.manifest, - projectRevision: await readCurrentProjectRevision( - invoke, - result.projectPath, - ), - mode, - initialPrompt: - prompt.trim() || - (attachments.length > 0 - ? '用户上传了参考附件,等待后续补充需求。' - : ''), - attachments: importedAttachments, - recentRunStatus: null, - recentRunStopReason: null, - createdAt: Date.now(), - }); - resetLauncherHomeDraft(); return '已创建项目并进入项目开发'; } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -256,10 +281,37 @@ export function useHomeProjectCreation({ } } + async function finishAutomaticallyCreatedHomeProject( + result: InitLocalProjectResult, + mode: HomeAgentMode, + prompt: string, + attachments: HomeAttachmentDraft[], + ) { + const invoke = resolveTauriInvoke(); + if (!invoke) { + throw new Error('需要在 Tauri App 内运行'); + } + setStatus('正在启动项目总控 Agent'); + try { + await enterCreatedHomeProject(invoke, result, mode, prompt, attachments); + setStatus('已自动创建工作区并开始工作'); + return '已自动创建工作区并开始工作'; + } catch (error) { + const message = `工作区已创建;首条需求投递失败:${ + error instanceof Error ? error.message : String(error) + }`; + setStatus(message); + throw new Error(message); + } + } + async function createProjectFromProjectPage( nextProjectPath: string, skipNonEmptyCheck = false, ) { + if (projectActionRef.current) { + return; + } const trimmedProjectPath = validateProjectPath(nextProjectPath); if (!trimmedProjectPath) { return; @@ -269,6 +321,8 @@ export function useHomeProjectCreation({ setStatus('需要在 Tauri App 内运行'); return; } + projectActionRef.current = 'creating'; + setProjectAction('creating'); setStatus('正在创建项目'); try { if (!skipNonEmptyCheck) { @@ -313,6 +367,11 @@ export function useHomeProjectCreation({ }); } catch (error) { setStatus(error instanceof Error ? error.message : String(error)); + } finally { + if (projectActionRef.current === 'creating') { + projectActionRef.current = null; + setProjectAction(null); + } } } @@ -321,6 +380,9 @@ export function useHomeProjectCreation({ await createProjectFromProjectPage(nextProjectPath); return; } + if (projectActionRef.current) { + return; + } const trimmedProjectPath = validateProjectPath(nextProjectPath); if (!trimmedProjectPath) { return; @@ -330,6 +392,8 @@ export function useHomeProjectCreation({ setStatus('需要在 Tauri App 内运行'); return; } + projectActionRef.current = 'opening'; + setProjectAction('opening'); setStatus('正在打开'); try { const directoryStatus = await invoke( @@ -344,18 +408,28 @@ export function useHomeProjectCreation({ setStatus('项目路径不是文件夹'); return; } - if (!directoryStatus.isGameCreatorProject) { - setStatus('这不是已初始化的 AI 游戏项目,请使用新建项目。'); - return; - } - let projectManifest = seedManifest; - try { + let projectManifest: GameCreationAppManifest; + if (directoryStatus.isGameCreatorProject) { projectManifest = await invoke( 'get_local_game_manifest', { projectPath: trimmedProjectPath }, ); - } catch { - // The Supervisor surface can still restore conversation/runtime state. + } else if ( + directoryStatus.godotProjectRoot !== null && + directoryStatus.godotProjectRoot !== undefined + ) { + const result = await invoke( + 'import_local_godot_project', + { + projectPath: trimmedProjectPath, + projectId: seedManifest.projectId, + name: projectNameFromPath(trimmedProjectPath), + }, + ); + projectManifest = result.manifest; + } else { + setStatus('这不是已初始化的 AI 游戏项目,请使用新建项目。'); + return; } setStatus('已打开项目'); enterProjectDevelopment({ @@ -363,7 +437,11 @@ export function useHomeProjectCreation({ projectName: directoryStatus.projectName || projectNameFromPath(trimmedProjectPath), - projectKind: directoryStatus.isGodotProject ? 'godot' : 'web', + projectKind: + directoryStatus.godotProjectRoot !== null && + directoryStatus.godotProjectRoot !== undefined + ? 'godot' + : 'web', manifest: projectManifest, projectRevision: await readCurrentProjectRevision( invoke, @@ -378,6 +456,11 @@ export function useHomeProjectCreation({ }); } catch (error) { setStatus(error instanceof Error ? error.message : String(error)); + } finally { + if (projectActionRef.current === 'opening') { + projectActionRef.current = null; + setProjectAction(null); + } } } @@ -414,11 +497,6 @@ export function useHomeProjectCreation({ pendingNonEmptyProject !== null, ); - function handleSubmit(event: FormEvent) { - event.preventDefault(); - void openProject(projectPath, 'open'); - } - async function createHomeDraft(draft: HomeDraft) { const invoke = resolveTauriInvoke(); if (!invoke) { @@ -426,6 +504,7 @@ export function useHomeProjectCreation({ } const selectedPath = await invoke( 'pick_local_project_directory', + projectPath.trim() ? { initialPath: projectPath.trim() } : undefined, ); if (!selectedPath) { return '已取消'; @@ -438,93 +517,90 @@ export function useHomeProjectCreation({ ); } - async function handlePickProjectDirectory() { + async function createHomeDraftAutomatically(draft: HomeDraft) { + const invoke = resolveTauriInvoke(); + if (!invoke) { + throw new Error('需要在 Tauri App 内运行'); + } + setStatus('正在创建工作区'); + const result = await invoke( + 'create_automatic_local_game_project', + ); + return finishAutomaticallyCreatedHomeProject( + result, + draft.mode, + draft.prompt, + draft.attachments, + ); + } + + async function pickAndOpenProject() { + if (projectActionRef.current) { + return; + } const invoke = resolveTauriInvoke(); if (!invoke) { setStatus('需要在 Tauri App 内运行'); return; } - setStatus('正在选择'); + projectActionRef.current = 'opening'; + setProjectAction('opening'); + setStatus('正在选择项目'); try { const selectedPath = await invoke( 'pick_local_project_directory', + projectPath.trim() ? { initialPath: projectPath.trim() } : undefined, ); if (!selectedPath) { setStatus('已取消'); return; } setProjectPath(selectedPath); - setStatus('已选择项目目录'); + projectActionRef.current = null; + setProjectAction(null); + await openProject(selectedPath, 'open'); } catch (error) { setStatus(error instanceof Error ? error.message : String(error)); + } finally { + if (projectActionRef.current === 'opening') { + projectActionRef.current = null; + setProjectAction(null); + } } } - async function openGodotProject() { + async function pickAndCreateProject() { + if (projectActionRef.current) { + return; + } const invoke = resolveTauriInvoke(); if (!invoke) { setStatus('需要在 Tauri App 内运行'); return; } - setStatus('正在选择 Godot 项目'); + projectActionRef.current = 'creating'; + setProjectAction('creating'); + setStatus('正在选择新项目文件夹'); try { const selectedPath = await invoke( 'pick_local_project_directory', + projectPath.trim() ? { initialPath: projectPath.trim() } : undefined, ); if (!selectedPath) { setStatus('已取消'); return; } - const trimmedProjectPath = validateProjectPath(selectedPath); - if (!trimmedProjectPath) { - return; - } - const directoryStatus = await invoke( - 'inspect_local_project_directory', - { projectPath: trimmedProjectPath }, - ); - if (!directoryStatus.exists || !directoryStatus.isDirectory) { - setStatus('Godot 项目目录不存在或不是文件夹'); - return; - } - if (!directoryStatus.isGodotProject) { - setStatus('所选文件夹不是 Godot 项目'); - return; - } - const result = directoryStatus.isGameCreatorProject - ? { - projectPath: trimmedProjectPath, - manifestPath: `${trimmedProjectPath.replace(/[\\/]+$/, '')}/.agent/manifest.json`, - manifest: await invoke( - 'get_local_game_manifest', - { projectPath: trimmedProjectPath }, - ), - } - : await invoke('import_local_godot_project', { - projectPath: trimmedProjectPath, - projectId: seedManifest.projectId, - name: projectNameFromPath(trimmedProjectPath), - }); - setStatus('已打开 Godot 项目'); - enterProjectDevelopment({ - projectPath: result.projectPath, - projectName: - result.manifest.name || projectNameFromPath(result.projectPath), - projectKind: 'godot', - manifest: result.manifest, - projectRevision: await readCurrentProjectRevision( - invoke, - result.projectPath, - ), - mode: null, - initialPrompt: '', - attachments: [], - recentRunStatus: directoryStatus.recentRunStatus, - recentRunStopReason: directoryStatus.recentRunStopReason, - createdAt: Date.now(), - }); + setProjectPath(selectedPath); + projectActionRef.current = null; + setProjectAction(null); + await createProjectFromProjectPage(selectedPath); } catch (error) { setStatus(error instanceof Error ? error.message : String(error)); + } finally { + if (projectActionRef.current === 'creating') { + projectActionRef.current = null; + setProjectAction(null); + } } } @@ -539,13 +615,15 @@ export function useHomeProjectCreation({ setAgentRuntimeSummaries, activeProjectAgentResults, setAgentResults, + projectAction, + projectBusy: projectAction !== null, pendingNonEmptyProject, resetLauncherHomeDraft, createHomeDraft, + createHomeDraftAutomatically, openProject, - openGodotProject, - handleSubmit, - handlePickProjectDirectory, + pickAndOpenProject, + pickAndCreateProject, confirmCreateInNonEmptyFolder, cancelCreateInNonEmptyFolder, }; diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts b/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts index 8540e268c..ba71d0520 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts @@ -1,4 +1,10 @@ -import { type Dispatch, type SetStateAction, useEffect, useState } from 'react'; +import { + type Dispatch, + type SetStateAction, + useEffect, + useMemo, + useState, +} from 'react'; import { resolveTauriInvoke } from '../../app/tauri'; import type { LocalProjectDirectoryStatus } from '../../app/types'; @@ -8,7 +14,6 @@ import { } from '../project-summary/projectSummary'; import { buildRecentProjectRows, - clearRecentWorkspaces, readRecentWorkspaces, removeRecentWorkspace, writeRecentWorkspace, @@ -23,6 +28,7 @@ export function useRecentProjects(setStatus: Dispatch>) { const [recentWorkspaceRefreshKey, setRecentWorkspaceRefreshKey] = useState(0); const [recentWorkspaceRefreshing, setRecentWorkspaceRefreshing] = useState(false); + const [projectSearchQuery, setProjectSearchQuery] = useState(''); useEffect(() => { const invoke = resolveTauriInvoke(); @@ -62,13 +68,6 @@ export function useRecentProjects(setStatus: Dispatch>) { setRecentWorkspaceRefreshKey((current) => current + 1); } - function handleRecentWorkspaceRefresh() { - if (recentWorkspaces.length === 0 || recentWorkspaceRefreshing) { - return; - } - setRecentWorkspaceRefreshKey((current) => current + 1); - } - function handleRecentWorkspaceRemove(projectPath: string) { setRecentWorkspaces(removeRecentWorkspace(projectPath)); setRecentWorkspaceStatuses((current) => { @@ -77,11 +76,6 @@ export function useRecentProjects(setStatus: Dispatch>) { }); } - function handleRecentWorkspaceClear() { - setRecentWorkspaces(clearRecentWorkspaces()); - setRecentWorkspaceStatuses({}); - } - async function handleRevealProjectDirectory(projectPath: string) { const trimmedProjectPath = projectPath.trim(); if (!trimmedProjectPath || !isAbsoluteProjectPath(trimmedProjectPath)) { @@ -112,18 +106,38 @@ export function useRecentProjects(setStatus: Dispatch>) { recentWorkspaceStatuses, recentWorkspaceRefreshing, ); + const filteredProjectRows = useMemo(() => { + const normalizedQuery = projectSearchQuery.trim().toLocaleLowerCase(); + if (!normalizedQuery) { + return projectRows; + } + return projectRows.filter((project) => + [ + project.name, + project.path, + project.status, + project.godotProjectRoot ?? '', + project.projectKind === 'godot' + ? 'godot' + : project.projectKind === 'web' + ? 'gameagent web' + : 'unknown', + ].some((value) => value.toLocaleLowerCase().includes(normalizedQuery)), + ); + }, [projectRows, projectSearchQuery]); return { recentWorkspaces, recentWorkspaceRefreshing, projectRows, + filteredProjectRows, + projectSearchQuery, + setProjectSearchQuery, recentProjectRows: projectRows .filter((project) => project.canOpen) .slice(0, 3), rememberRecentWorkspace, - handleRecentWorkspaceRefresh, handleRecentWorkspaceRemove, - handleRecentWorkspaceClear, handleRevealProjectDirectory, }; } diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index c9f977db6..0ae68b823 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -281,6 +281,25 @@ textarea { padding: 118px 0 0; } +.launcher-projects-page { + align-content: start; + grid-template-rows: auto minmax(0, 1fr); + gap: 18px; + width: calc(100vw - var(--launcher-sidebar-width)); + max-width: none; + height: 100dvh; + min-height: 0; + margin: 0; + padding: 92px 28px 24px; + overflow: hidden; +} + +.launcher-main:has(.launcher-projects-page) { + height: 100dvh; + min-height: 0; + padding-bottom: 0; +} + .launcher-page header { display: flex; align-items: flex-end; @@ -315,119 +334,382 @@ textarea { gap: 8px; } -.launcher-project-form { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: end; - gap: 10px; - margin: 16px 0 14px; - padding: 12px; - border: 1px solid #e5e7eb; - border-radius: 8px; - background: #fff; -} - -.launcher-project-form label { - display: grid; - gap: 6px; - min-width: 0; - color: #6b7280; - font-size: 12px; -} - -.launcher-project-form input { - min-width: 0; - height: 34px; - padding: 0 10px; - border: 1px solid #d8dde5; - border-radius: 8px; - color: #111827; - background: #fff; -} - .launcher-page-actions button, .launcher-page .launcher-project-list-actions button, -.launcher-project-table article > button, .launcher-empty-projects button { - height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 36px; padding: 0 12px; - border: 1px solid #d8dde5; - border-radius: 8px; - background: #fff; - color: #111827; + border: 1px solid var(--platform-surface-border); + border-radius: 9px; + background: var(--platform-button-secondary-fill); + color: var(--platform-button-secondary-text); font-size: 12px; + font-weight: 700; + white-space: nowrap; + cursor: pointer; } -.launcher-page .launcher-project-list-actions button:disabled { +.launcher-page .launcher-project-list-actions button:disabled, +.launcher-empty-projects button:disabled { cursor: not-allowed; opacity: 0.55; } -.launcher-page-actions button:last-child { - border-color: #111827; - background: #111827; - color: #fff; +.launcher-projects-page button:focus-visible, +.launcher-project-search input:focus-visible { + outline: 3px solid var(--platform-warm-text); + outline-offset: 2px; +} + +.launcher-projects-toolbar { + display: grid; + grid-template-columns: minmax(160px, 1fr) auto; + align-items: center; + gap: 24px; +} + +.launcher-projects-heading { + min-width: 0; +} + +.launcher-projects-controls { + display: flex; + align-items: center; + gap: 10px; +} + +.launcher-project-search { + display: flex; + align-items: center; + width: clamp(260px, 28vw, 390px); + height: 38px; + gap: 8px; + padding: 0 10px; + border: 1px solid var(--platform-surface-border); + border-radius: 9px; + background: var(--platform-input-fill); + color: var(--platform-text-soft); +} + +.launcher-project-search:focus-within { + border-color: var(--platform-surface-hover-border); + box-shadow: 0 0 0 3px var(--platform-input-focus-ring); +} + +.launcher-project-search input { + min-width: 0; + flex: 1; + border: 0; + outline: 0; + background: transparent; + color: var(--platform-text-strong); + font: inherit; + font-size: 13px; +} + +.launcher-project-search button { + display: grid; + width: 24px; + height: 24px; + padding: 0; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--platform-text-soft); + cursor: pointer; + place-items: center; +} + +.launcher-project-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.launcher-project-actions button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 38px; + gap: 7px; + padding: 0 14px; + border: 1px solid var(--platform-surface-border); + border-radius: 9px; + font-size: 12px; + font-weight: 800; + white-space: nowrap; + cursor: pointer; +} + +.launcher-project-actions button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.launcher-project-actions .launcher-project-open-action { + background: var(--platform-button-secondary-fill); + color: var(--platform-button-secondary-text); +} + +.launcher-project-actions .launcher-project-create-action { + border-color: var(--platform-button-primary-border); + background: var(--platform-button-primary-fill); + color: var(--platform-button-primary-text); +} + +.launcher-project-page-status { + min-height: 18px; +} + +.launcher-project-list-shell { + display: grid; + grid-template-rows: 42px minmax(0, 1fr); + min-height: 0; + overflow: hidden; + border: 1px solid var(--platform-surface-border); + border-radius: 12px; + background: var(--platform-subpanel-fill); + box-shadow: var(--platform-panel-shadow); +} + +.launcher-project-table-header, +.launcher-project-table article { + display: grid; + grid-template-columns: + minmax(360px, 1fr) minmax(120px, 160px) minmax(150px, 190px) + 56px; + align-items: center; + min-width: 0; +} + +.launcher-project-table-header { + height: 42px; + padding: 0 14px; + border-bottom: 1px solid var(--platform-surface-border); + background: color-mix( + in srgb, + var(--platform-subpanel-fill) 82%, + var(--platform-warm-bg) + ); + color: var(--platform-text-soft); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.03em; +} + +.launcher-project-table-header span:last-child { + text-align: center; } .launcher-project-table { display: grid; - gap: 8px; + align-content: start; + min-height: 0; + overflow-y: auto; } .launcher-project-table article { - display: grid; - grid-template-columns: minmax(0, 1fr) auto auto auto auto; - align-items: center; - gap: 10px; - min-height: 62px; - padding: 10px 12px; - border: 1px solid #e5e7eb; - border-radius: 8px; - background: #fff; + min-height: 66px; + padding: 0 14px; + border-bottom: 1px solid var(--platform-surface-border); + background: var(--platform-subpanel-fill); + transition: background 120ms ease; +} + +.launcher-project-table article:last-child { + border-bottom: 0; +} + +.launcher-project-table + article:has(.launcher-project-row-main:not(:disabled)):hover { + background: var(--platform-nav-item-hover-fill); } .launcher-project-table article > .launcher-project-row-main { - display: grid; - justify-items: start; - height: auto; + display: flex; + align-items: center; + justify-content: flex-start; + width: 100%; + height: 100%; min-width: 0; + gap: 12px; padding: 0; border: 0; background: transparent; + color: inherit; text-align: left; + cursor: pointer; +} + +.launcher-project-table article > .launcher-project-row-main:disabled { + cursor: not-allowed; + opacity: 0.62; +} + +.launcher-project-kind-icon { + display: grid; + width: 34px; + height: 34px; + flex: 0 0 auto; + border-radius: 9px; + background: var(--platform-warm-bg); + color: var(--platform-warm-text); + place-items: center; +} + +.launcher-project-name-cell { + display: grid; + min-width: 0; + gap: 4px; } .launcher-project-table article strong { display: block; - color: #111827; - font-size: 14px; + overflow: hidden; + color: var(--platform-text-strong); + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; } .launcher-project-table article small { display: block; - margin-top: 4px; - color: #8b8b8b; - font-size: 12px; + width: 100%; + min-width: 0; + max-width: 100%; + margin: 0; + color: var(--platform-text-soft); + font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.launcher-project-table article span { - color: #6b7280; +.launcher-project-kind-cell { + overflow: hidden; + padding-right: 16px; + color: var(--platform-text-base); font-size: 12px; + text-overflow: ellipsis; white-space: nowrap; } +.launcher-project-status { + display: inline-flex; + align-items: center; + justify-self: start; + max-width: calc(100% - 12px); + min-height: 24px; + gap: 5px; + overflow: hidden; + padding: 0 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.launcher-project-status-ready { + background: color-mix(in srgb, #2d9d62 12%, transparent); + color: #237b4d; +} + +.launcher-project-status-checking { + background: color-mix(in srgb, var(--platform-warm-text) 12%, transparent); + color: var(--platform-warm-text); +} + +.launcher-project-status-warning { + background: color-mix(in srgb, #d15f45 12%, transparent); + color: #a64735; +} + +.launcher-project-more { + position: relative; + display: grid; + justify-self: center; +} + +.launcher-project-more-trigger { + display: grid; + width: 34px; + height: 34px; + padding: 0; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--platform-text-soft); + cursor: pointer; + place-items: center; +} + +.launcher-project-more-trigger:hover, +.launcher-project-more-trigger[aria-expanded='true'] { + background: var(--platform-nav-item-hover-fill); + color: var(--platform-text-strong); +} + +.launcher-project-more-menu { + position: absolute; + top: 38px; + right: 0; + z-index: 20; + display: grid; + width: 164px; + gap: 4px; + padding: 6px; + border: 1px solid var(--platform-surface-border); + border-radius: 10px; + background: var(--platform-subpanel-fill); + box-shadow: 0 16px 34px rgb(75 47 32 / 18%); +} + +.launcher-project-more-menu-drop-up { + top: auto; + bottom: 38px; +} + +.launcher-project-more-menu button { + display: flex; + align-items: center; + justify-content: flex-start; + min-height: 34px; + gap: 8px; + padding: 0 9px; + border: 0; + border-radius: 7px; + background: transparent; + color: var(--platform-text-strong); + font-size: 12px; + font-weight: 700; + cursor: pointer; +} + +.launcher-project-more-menu button:hover { + background: var(--platform-nav-item-hover-fill); +} + +.launcher-project-more-menu button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.launcher-project-more-menu .launcher-project-remove-action { + color: #a64735; +} + .launcher-empty-projects { display: grid; justify-items: center; gap: 12px; - min-height: 220px; - padding: 44px; - border: 1px dashed #d8dde5; - border-radius: 8px; - color: #6b7280; + min-height: 260px; + padding: 48px; + color: var(--platform-text-soft); } .launcher-empty-projects strong { @@ -2677,14 +2959,15 @@ textarea { white-space: nowrap; } - .launcher-project-form { - grid-template-columns: 1fr; - } - .launcher-page { width: min(100%, calc(100vw - 76px)); } + .launcher-projects-page { + width: calc(100vw - var(--launcher-sidebar-width)); + padding: 72px 18px 20px; + } + .launcher-agent-chat-page > header, .launcher-agent-chat-main > header { align-items: stretch; diff --git a/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/index.tsx b/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/index.tsx index f8a36c53e..4c45a8d75 100644 --- a/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/index.tsx +++ b/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/index.tsx @@ -13,6 +13,7 @@ import { COMMAND_PRIORITY_EDITOR, COMMAND_PRIORITY_HIGH, createCommand, + KEY_ENTER_COMMAND, type LexicalCommand, PASTE_COMMAND, } from 'lexical'; @@ -20,15 +21,13 @@ import { Upload } from 'lucide-react'; import React, { useEffect, useRef } from 'react'; import type { Draft, HomeAttachmentDraft } from '../../useHomeDraftStore'; -import { - $createAttachmentNode, - AttachmentNode, -} from './attachmentNode'; +import { $createAttachmentNode, AttachmentNode } from './attachmentNode'; type RichInputAreaProps = { value: Draft; placeholder: string; onChange: (value: Draft) => void; + onEnter: () => void; children?: React.ReactNode; }; const INSERT_ATTACHMENTS_COMMAND: LexicalCommand = @@ -104,9 +103,27 @@ function selectEditableEndWhenNeeded() { function EditorPlugins({ onChange, -}: Pick) { + onEnter, +}: Pick) { const [editor] = useLexicalComposerContext(); + useEffect( + () => + editor.registerCommand( + KEY_ENTER_COMMAND, + (event) => { + if (!event || event.shiftKey || event.isComposing) { + return false; + } + event.preventDefault(); + onEnter(); + return true; + }, + COMMAND_PRIORITY_HIGH, + ), + [editor, onEnter], + ); + useEffect( () => editor.registerCommand( @@ -239,7 +256,7 @@ export default function RichInputArea(props: RichInputAreaProps) { /> {props.children} - + ); } diff --git a/apps/ai-game-creator-shell/src/view/home/index.tsx b/apps/ai-game-creator-shell/src/view/home/index.tsx index 768674a36..0ffca6ee9 100644 --- a/apps/ai-game-creator-shell/src/view/home/index.tsx +++ b/apps/ai-game-creator-shell/src/view/home/index.tsx @@ -5,12 +5,10 @@ import { Plus, Sparkles, } from 'lucide-react'; -import type { - FormEvent, -} from 'react'; -import { useState } from 'react'; +import type { FormEvent } from 'react'; +import { useRef, useState } from 'react'; -import BRAND_ICON from '../../../../../packages/shared/src/icons/taonier-product-ip.png' +import BRAND_ICON from '../../../../../packages/shared/src/icons/taonier-product-ip.png'; import RichInputArea, { UploadButton } from './components/RichInputArea'; import { richTextToAttachments, @@ -49,9 +47,10 @@ type HomeViewProps = { homeAgentModeItems: readonly HomeAgentModeItem[]; recentProjectRows: readonly HomeProjectRow[]; onCreateDraft: (draft: HomeDraft) => Promise; + onCreateDraftAutomatically: (draft: HomeDraft) => Promise; onProjectsOpen: () => void; onProjectOpen: (path: string) => void; - onGodotProjectOpen: () => void; + onProjectPick: () => void; }; export default function HomeView({ @@ -61,9 +60,10 @@ export default function HomeView({ homeAgentModeItems, recentProjectRows, onCreateDraft, + onCreateDraftAutomatically, onProjectsOpen, onProjectOpen, - onGodotProjectOpen, + onProjectPick, }: HomeViewProps) { const homeAgentMode = useLauncherHomeDraftStore((state) => state.mode); const homeRichText = useLauncherHomeDraftStore((state) => state.draft); @@ -72,6 +72,7 @@ export default function HomeView({ (state) => state.setRichText, ); const [homeCreationBusy, setHomeCreationBusy] = useState(false); + const homeCreationBusyRef = useRef(false); const activeHomeMode = homeAgentModeItems.find((item) => item.mode === homeAgentMode) ?? homeAgentModeItems[0]; @@ -80,8 +81,13 @@ export default function HomeView({ return null; } - async function handleHomeSubmit(event: FormEvent) { - event.preventDefault(); + async function createFromHome( + createDraft: (draft: HomeDraft) => Promise, + pendingStatus: string, + ) { + if (homeCreationBusyRef.current) { + return; + } const referencedAttachments = richTextToAttachments(homeRichText); const prompt = richTextToPrompt(homeRichText); if (!prompt) { @@ -91,11 +97,12 @@ export default function HomeView({ ); return; } + homeCreationBusyRef.current = true; setHomeCreationBusy(true); - onStatusChange('请选择项目目录'); + onStatusChange(pendingStatus); try { onStatusChange( - await onCreateDraft({ + await createDraft({ mode: homeAgentMode, prompt, attachments: referencedAttachments, @@ -104,10 +111,16 @@ export default function HomeView({ } catch (error) { onStatusChange(error instanceof Error ? error.message : String(error)); } finally { + homeCreationBusyRef.current = false; setHomeCreationBusy(false); } } + function handleHomeSubmit(event: FormEvent) { + event.preventDefault(); + void createFromHome(onCreateDraft, '请选择项目目录'); + } + return (
{ + void createFromHome(onCreateDraftAutomatically, '正在创建工作区'); + }} >
@@ -197,10 +213,10 @@ export default function HomeView({