优化AGC项目入口并识别Godot工作区
项目页只保留打开项目和新建项目并统一原生目录选择 按根目录及一层子目录识别project.godot并记录相对Godot根 保持.agent位于用户选择的工作区根并补齐Windows构建门禁 补充响应式布局、测试、技术方案和共享项目记忆
This commit is contained in:
@@ -1237,10 +1237,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',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1472,6 +1472,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' ||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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::<Result<Vec<PathBuf>, 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
|
||||
}
|
||||
@@ -9,12 +9,19 @@ fn automatic_local_game_projects_root(app: &tauri::AppHandle) -> Result<PathBuf,
|
||||
.map_err(|error| format!("无法读取系统文档目录:{error}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn get_default_local_game_project_path(app: tauri::AppHandle) -> Result<String, String> {
|
||||
Ok(automatic_local_game_projects_root(&app)?
|
||||
.join("gameagent-new")
|
||||
.to_string_lossy()
|
||||
.into_owned())
|
||||
pub(crate) fn closest_existing_project_picker_directory(path: &Path) -> Option<PathBuf> {
|
||||
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(
|
||||
@@ -99,6 +106,9 @@ pub(crate) fn import_local_godot_project(
|
||||
) -> Result<InitLocalProjectResult, String> {
|
||||
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())
|
||||
}
|
||||
@@ -144,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()),
|
||||
@@ -157,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;
|
||||
@@ -206,9 +208,23 @@ pub(crate) fn recent_game_creator_run_trace(root: &Path) -> Option<GameCreationA
|
||||
#[tauri::command]
|
||||
pub(crate) async fn pick_local_project_directory(
|
||||
app: tauri::AppHandle,
|
||||
initial_path: Option<String>,
|
||||
) -> Result<Option<String>, 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);
|
||||
}
|
||||
@@ -295,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]
|
||||
|
||||
@@ -128,6 +128,7 @@ struct LocalProjectDirectoryStatus {
|
||||
is_directory: bool,
|
||||
is_game_creator_project: bool,
|
||||
is_godot_project: bool,
|
||||
godot_project_root: Option<String>,
|
||||
project_name: Option<String>,
|
||||
manifest_error: Option<String>,
|
||||
recent_run_status: Option<String>,
|
||||
@@ -2179,7 +2180,6 @@ fn main() {
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
create_automatic_local_game_project,
|
||||
get_default_local_game_project_path,
|
||||
init_local_game_project,
|
||||
import_local_godot_project,
|
||||
is_local_project_directory_non_empty,
|
||||
|
||||
@@ -1,10 +1,196 @@
|
||||
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<Mutex<()>> = 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<bool, String> {
|
||||
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::fs::MetadataExt;
|
||||
if metadata.number_of_links() != Some(1) {
|
||||
return Err(format!(
|
||||
"Godot 项目文件必须可确认是无硬链接普通文件:{}",
|
||||
project_file.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn validate_godot_project_child_name(name: &std::ffi::OsStr) -> Result<String, String> {
|
||||
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<Option<String>, 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 +441,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 +455,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 +476,7 @@ pub(crate) fn import_local_godot_project_at(
|
||||
"projectId": project_id,
|
||||
"name": name,
|
||||
"projectKind": "godot",
|
||||
"godotProjectRoot": godot_project_root,
|
||||
}),
|
||||
)?;
|
||||
}
|
||||
@@ -299,7 +490,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 {
|
||||
@@ -361,6 +553,17 @@ pub(crate) fn read_manifest_for_project(root: &Path) -> Result<GameCreationAppMa
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
pub(crate) fn read_manifest_for_project_with_godot_root_calibration(
|
||||
root: &Path,
|
||||
) -> Result<GameCreationAppManifest, String> {
|
||||
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<GameCreationAppManifest, String> {
|
||||
@@ -918,6 +1121,8 @@ pub(crate) fn read_manifest(path: &Path) -> Result<GameCreationAppManifest, Stri
|
||||
.map_err(|error| format!("读取 {label} 失败:{}: {error}", source_path.display()))?;
|
||||
let manifest: GameCreationAppManifest = serde_json::from_str(&payload)
|
||||
.map_err(|error| format!("解析 {label} 失败:{}: {error}", source_path.display()))?;
|
||||
validate_manifest_godot_project_root(manifest.godot_project_root.as_deref())
|
||||
.map_err(|error| format!("校验 {label} Godot 项目根失败:{error}"))?;
|
||||
validate_game_iteration_versions(&manifest.versions)
|
||||
.map_err(|error| format!("校验 {label} 项目版本失败:{error}"))?;
|
||||
Ok(manifest)
|
||||
@@ -1008,6 +1213,8 @@ fn write_manifest_with_lock_hook<F>(
|
||||
where
|
||||
F: FnOnce(),
|
||||
{
|
||||
validate_manifest_godot_project_root(manifest.godot_project_root.as_deref())
|
||||
.map_err(|error| format!("校验 manifest Godot 项目根失败:{error}"))?;
|
||||
validate_game_iteration_versions(&manifest.versions)
|
||||
.map_err(|error| format!("校验 manifest 项目版本失败:{error}"))?;
|
||||
let payload = serde_json::to_string_pretty(manifest)
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -1264,6 +1264,22 @@ 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();
|
||||
@@ -1369,6 +1385,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,
|
||||
@@ -1382,6 +1399,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);
|
||||
@@ -1393,6 +1412,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);
|
||||
@@ -1451,6 +1472,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();
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"minWidth": 1280,
|
||||
"minHeight": 800
|
||||
"minHeight": 720
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"minWidth": 1280,
|
||||
"minHeight": 800
|
||||
"minHeight": 720
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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"), "<main>GameAgent</main>");
|
||||
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"), "<main>GameAgent</main>");
|
||||
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()));
|
||||
}
|
||||
@@ -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';
|
||||
@@ -605,9 +604,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<LocalProjectKind>(initialProjectKind);
|
||||
const [localProject, setLocalProject] =
|
||||
@@ -623,26 +620,6 @@ export function App({
|
||||
const localProjectPathRef = useRef<string | null>(null);
|
||||
localProjectPathRef.current = localProject?.projectPath ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (projectPath || initialProjectPath) {
|
||||
return;
|
||||
}
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) {
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
void invoke<string>('get_default_local_game_project_path')
|
||||
.then((defaultPath) => {
|
||||
if (active && defaultPath.trim()) {
|
||||
setProjectPath(defaultPath);
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [initialProjectPath, projectPath]);
|
||||
const manifestRefreshMountedRef = useRef(true);
|
||||
const manifestRefreshStatesRef = useRef(
|
||||
new Map<
|
||||
@@ -3324,6 +3301,7 @@ export function App({
|
||||
try {
|
||||
const selectedPath = await invoke<string | null>(
|
||||
'pick_local_project_directory',
|
||||
projectPath.trim() ? { initialPath: projectPath.trim() } : undefined,
|
||||
);
|
||||
if (gameChatProjectSelectionVersionRef.current !== selectionVersion) {
|
||||
return;
|
||||
|
||||
@@ -5,8 +5,6 @@ export const seedManifest = createGameCreationAppManifest(
|
||||
'未命名游戏原型',
|
||||
);
|
||||
|
||||
export const defaultProjectPath =
|
||||
import.meta.env.MODE === 'test' ? '/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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { FolderKanban } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
import { FolderKanban, FolderOpen, FolderPlus } from 'lucide-react';
|
||||
|
||||
import { closeDialogOnEscape } from '../../app/dialogs';
|
||||
import type { HomeProjectCreationController } from './useHomeProjectCreation';
|
||||
@@ -14,88 +13,52 @@ export function ProjectsPage({
|
||||
homeProject: HomeProjectCreationController;
|
||||
recentProjects: RecentProjectsController;
|
||||
}) {
|
||||
const loadDefaultProjectPath = homeProject.loadDefaultProjectPath;
|
||||
useEffect(() => {
|
||||
void loadDefaultProjectPath();
|
||||
}, [loadDefaultProjectPath]);
|
||||
|
||||
return (
|
||||
<section className="launcher-page launcher-projects-page">
|
||||
<header>
|
||||
<div>
|
||||
<h1>项目组</h1>
|
||||
<p>
|
||||
<p className="launcher-project-page-status" aria-live="polite">
|
||||
{recentProjects.recentWorkspaceRefreshing
|
||||
? '正在检查项目状态'
|
||||
: status}
|
||||
</p>
|
||||
</div>
|
||||
<div className="launcher-project-list-actions">
|
||||
<button
|
||||
type="button"
|
||||
disabled={recentProjects.recentWorkspaceRefreshing}
|
||||
onClick={recentProjects.handleRecentWorkspaceRefresh}
|
||||
>
|
||||
{recentProjects.recentWorkspaceRefreshing ? '刷新中' : '刷新'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={recentProjects.handleRecentWorkspaceClear}
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<form
|
||||
className="launcher-project-form"
|
||||
onSubmit={homeProject.handleSubmit}
|
||||
>
|
||||
<label>
|
||||
项目目录
|
||||
<input
|
||||
aria-label="项目目录"
|
||||
value={homeProject.projectPath}
|
||||
onChange={(event) =>
|
||||
homeProject.setProjectPath(event.currentTarget.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<div className="launcher-page-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void homeProject.handlePickProjectDirectory();
|
||||
}}
|
||||
>
|
||||
选择
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void homeProject.openGodotProject()}
|
||||
>
|
||||
打开 Godot 项目
|
||||
</button>
|
||||
<button type="submit">打开</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void homeProject.openProject(homeProject.projectPath, 'create');
|
||||
}}
|
||||
>
|
||||
新建项目
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void recentProjects.handleRevealProjectDirectory(
|
||||
homeProject.projectPath,
|
||||
);
|
||||
}}
|
||||
>
|
||||
显示目录
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="launcher-project-actions" aria-label="项目操作">
|
||||
<button
|
||||
type="button"
|
||||
className="launcher-project-open-action"
|
||||
aria-label="打开项目"
|
||||
disabled={homeProject.projectBusy}
|
||||
onClick={() => void homeProject.pickAndOpenProject()}
|
||||
>
|
||||
<FolderOpen size={18} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>
|
||||
{homeProject.projectAction === 'opening' ? '打开中…' : '打开项目'}
|
||||
</strong>
|
||||
<small>选择已有工作区或 Godot 工程</small>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="launcher-project-create-action"
|
||||
aria-label="新建项目"
|
||||
disabled={homeProject.projectBusy}
|
||||
onClick={() => void homeProject.pickAndCreateProject()}
|
||||
>
|
||||
<FolderPlus size={18} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>
|
||||
{homeProject.projectAction === 'creating'
|
||||
? '新建中…'
|
||||
: '新建项目'}
|
||||
</strong>
|
||||
<small>选择文件夹并创建工作区</small>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="launcher-project-table">
|
||||
{recentProjects.projectRows.length > 0 ? (
|
||||
recentProjects.projectRows.map((project) => (
|
||||
@@ -150,14 +113,7 @@ export function ProjectsPage({
|
||||
<div className="launcher-empty-projects">
|
||||
<FolderKanban size={28} aria-hidden="true" />
|
||||
<strong>暂无项目</strong>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void homeProject.handlePickProjectDirectory();
|
||||
}}
|
||||
>
|
||||
选择本地项目目录
|
||||
</button>
|
||||
<small>打开已有项目,或从上方新建一个项目</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -266,7 +266,7 @@ export function WorkspaceLauncherShell({
|
||||
setProjectPath(path);
|
||||
void openProject(path, 'open');
|
||||
}}
|
||||
onGodotProjectOpen={() => void homeProject.openGodotProject()}
|
||||
onProjectPick={() => void homeProject.pickAndOpenProject()}
|
||||
/>
|
||||
) : launcherView === 'projects' ? (
|
||||
<ProjectsPage
|
||||
|
||||
@@ -133,15 +133,6 @@ export function removeRecentWorkspace(path: string) {
|
||||
return recent;
|
||||
}
|
||||
|
||||
export function clearRecentWorkspaces() {
|
||||
try {
|
||||
window.localStorage.removeItem(RECENT_WORKSPACES_STORAGE_KEY);
|
||||
} catch {
|
||||
// WebView storage can be unavailable in restricted test shells.
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function isTransientProjectOpenMessage(
|
||||
message: ChatMessage,
|
||||
projectPath: string,
|
||||
|
||||
@@ -126,27 +126,6 @@ export function useDeveloperAgentPanel(launcherView: LauncherView) {
|
||||
agentChatRuntimeSyncConversationRef,
|
||||
} = state;
|
||||
|
||||
useEffect(() => {
|
||||
if (launcherView !== 'agent-chat' || agentChatProjectPath) {
|
||||
return;
|
||||
}
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) {
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
void invoke<string>('get_default_local_game_project_path')
|
||||
.then((defaultPath) => {
|
||||
if (active && defaultPath.trim()) {
|
||||
setAgentChatProjectPath(defaultPath);
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [agentChatProjectPath, launcherView, setAgentChatProjectPath]);
|
||||
|
||||
useEffect(() => {
|
||||
setAgentChatRunSubmitMode('steer');
|
||||
}, [
|
||||
@@ -787,6 +766,9 @@ export function useDeveloperAgentPanel(launcherView: LauncherView) {
|
||||
try {
|
||||
const selectedPath = await invoke<string | null>(
|
||||
'pick_local_project_directory',
|
||||
agentChatProjectPath.trim()
|
||||
? { initialPath: agentChatProjectPath.trim() }
|
||||
: undefined,
|
||||
);
|
||||
if (!selectedPath) {
|
||||
setAgentChatStatus('已取消');
|
||||
|
||||
@@ -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 ?? '',
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
type Dispatch,
|
||||
type FormEvent,
|
||||
type SetStateAction,
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
@@ -10,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 {
|
||||
@@ -58,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<LauncherProjectContext | null>(null);
|
||||
const [activeProjectPreview, setActiveProjectPreview] =
|
||||
@@ -74,26 +82,6 @@ export function useHomeProjectCreation({
|
||||
(state) => state.reset,
|
||||
);
|
||||
|
||||
const loadDefaultProjectPath = useCallback(async () => {
|
||||
if (projectPath) {
|
||||
return;
|
||||
}
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const defaultPath = await invoke<string>(
|
||||
'get_default_local_game_project_path',
|
||||
);
|
||||
if (defaultPath.trim()) {
|
||||
setProjectPath((currentPath) => currentPath || defaultPath);
|
||||
}
|
||||
} catch {
|
||||
// The user can still choose a directory manually.
|
||||
}
|
||||
}, [projectPath]);
|
||||
|
||||
function validateProjectPath(nextProjectPath: string) {
|
||||
const trimmedProjectPath = nextProjectPath.trim();
|
||||
if (!trimmedProjectPath || !isAbsoluteProjectPath(trimmedProjectPath)) {
|
||||
@@ -320,6 +308,9 @@ export function useHomeProjectCreation({
|
||||
nextProjectPath: string,
|
||||
skipNonEmptyCheck = false,
|
||||
) {
|
||||
if (projectActionRef.current) {
|
||||
return;
|
||||
}
|
||||
const trimmedProjectPath = validateProjectPath(nextProjectPath);
|
||||
if (!trimmedProjectPath) {
|
||||
return;
|
||||
@@ -329,6 +320,8 @@ export function useHomeProjectCreation({
|
||||
setStatus('需要在 Tauri App 内运行');
|
||||
return;
|
||||
}
|
||||
projectActionRef.current = 'creating';
|
||||
setProjectAction('creating');
|
||||
setStatus('正在创建项目');
|
||||
try {
|
||||
if (!skipNonEmptyCheck) {
|
||||
@@ -373,6 +366,11 @@ export function useHomeProjectCreation({
|
||||
});
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
if (projectActionRef.current === 'creating') {
|
||||
projectActionRef.current = null;
|
||||
setProjectAction(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,6 +379,9 @@ export function useHomeProjectCreation({
|
||||
await createProjectFromProjectPage(nextProjectPath);
|
||||
return;
|
||||
}
|
||||
if (projectActionRef.current) {
|
||||
return;
|
||||
}
|
||||
const trimmedProjectPath = validateProjectPath(nextProjectPath);
|
||||
if (!trimmedProjectPath) {
|
||||
return;
|
||||
@@ -390,6 +391,8 @@ export function useHomeProjectCreation({
|
||||
setStatus('需要在 Tauri App 内运行');
|
||||
return;
|
||||
}
|
||||
projectActionRef.current = 'opening';
|
||||
setProjectAction('opening');
|
||||
setStatus('正在打开');
|
||||
try {
|
||||
const directoryStatus = await invoke<LocalProjectDirectoryStatus>(
|
||||
@@ -404,18 +407,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<GameCreationAppManifest>(
|
||||
'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<InitLocalProjectResult>(
|
||||
'import_local_godot_project',
|
||||
{
|
||||
projectPath: trimmedProjectPath,
|
||||
projectId: seedManifest.projectId,
|
||||
name: projectNameFromPath(trimmedProjectPath),
|
||||
},
|
||||
);
|
||||
projectManifest = result.manifest;
|
||||
} else {
|
||||
setStatus('这不是已初始化的 AI 游戏项目,请使用新建项目。');
|
||||
return;
|
||||
}
|
||||
setStatus('已打开项目');
|
||||
enterProjectDevelopment({
|
||||
@@ -423,7 +436,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,
|
||||
@@ -438,6 +455,11 @@ export function useHomeProjectCreation({
|
||||
});
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
if (projectActionRef.current === 'opening') {
|
||||
projectActionRef.current = null;
|
||||
setProjectAction(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -474,11 +496,6 @@ export function useHomeProjectCreation({
|
||||
pendingNonEmptyProject !== null,
|
||||
);
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
void openProject(projectPath, 'open');
|
||||
}
|
||||
|
||||
async function createHomeDraft(draft: HomeDraft) {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) {
|
||||
@@ -486,6 +503,7 @@ export function useHomeProjectCreation({
|
||||
}
|
||||
const selectedPath = await invoke<string | null>(
|
||||
'pick_local_project_directory',
|
||||
projectPath.trim() ? { initialPath: projectPath.trim() } : undefined,
|
||||
);
|
||||
if (!selectedPath) {
|
||||
return '已取消';
|
||||
@@ -515,93 +533,73 @@ export function useHomeProjectCreation({
|
||||
);
|
||||
}
|
||||
|
||||
async function handlePickProjectDirectory() {
|
||||
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<string | null>(
|
||||
'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<string | null>(
|
||||
'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<LocalProjectDirectoryStatus>(
|
||||
'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<GameCreationAppManifest>(
|
||||
'get_local_game_manifest',
|
||||
{ projectPath: trimmedProjectPath },
|
||||
),
|
||||
}
|
||||
: await invoke<InitLocalProjectResult>('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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -616,15 +614,15 @@ export function useHomeProjectCreation({
|
||||
setAgentRuntimeSummaries,
|
||||
activeProjectAgentResults,
|
||||
setAgentResults,
|
||||
projectAction,
|
||||
projectBusy: projectAction !== null,
|
||||
pendingNonEmptyProject,
|
||||
resetLauncherHomeDraft,
|
||||
loadDefaultProjectPath,
|
||||
createHomeDraft,
|
||||
createHomeDraftAutomatically,
|
||||
openProject,
|
||||
openGodotProject,
|
||||
handleSubmit,
|
||||
handlePickProjectDirectory,
|
||||
pickAndOpenProject,
|
||||
pickAndCreateProject,
|
||||
confirmCreateInNonEmptyFolder,
|
||||
cancelCreateInNonEmptyFolder,
|
||||
};
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
} from '../project-summary/projectSummary';
|
||||
import {
|
||||
buildRecentProjectRows,
|
||||
clearRecentWorkspaces,
|
||||
readRecentWorkspaces,
|
||||
removeRecentWorkspace,
|
||||
writeRecentWorkspace,
|
||||
@@ -62,13 +61,6 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
|
||||
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 +69,6 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
|
||||
});
|
||||
}
|
||||
|
||||
function handleRecentWorkspaceClear() {
|
||||
setRecentWorkspaces(clearRecentWorkspaces());
|
||||
setRecentWorkspaceStatuses({});
|
||||
}
|
||||
|
||||
async function handleRevealProjectDirectory(projectPath: string) {
|
||||
const trimmedProjectPath = projectPath.trim();
|
||||
if (!trimmedProjectPath || !isAbsoluteProjectPath(trimmedProjectPath)) {
|
||||
@@ -121,9 +108,7 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
|
||||
.filter((project) => project.canOpen)
|
||||
.slice(0, 3),
|
||||
rememberRecentWorkspace,
|
||||
handleRecentWorkspaceRefresh,
|
||||
handleRecentWorkspaceRemove,
|
||||
handleRecentWorkspaceClear,
|
||||
handleRevealProjectDirectory,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -315,58 +315,89 @@ 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-project-actions button,
|
||||
.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-project-actions button:disabled,
|
||||
.launcher-page .launcher-project-list-actions button:disabled,
|
||||
.launcher-project-table article > button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.launcher-page-actions button:last-child {
|
||||
border-color: #111827;
|
||||
background: #111827;
|
||||
color: #fff;
|
||||
.launcher-project-actions button:focus-visible,
|
||||
.launcher-project-table article > button:focus-visible {
|
||||
outline: 3px solid var(--platform-warm-text);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.launcher-project-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin: 16px 0 14px;
|
||||
}
|
||||
|
||||
.launcher-project-actions button {
|
||||
justify-content: flex-start;
|
||||
min-height: 76px;
|
||||
padding: 14px 16px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.launcher-project-actions button > span {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.launcher-project-actions button strong {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.launcher-project-actions button small {
|
||||
color: var(--platform-text-soft);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.launcher-project-actions .launcher-project-open-action {
|
||||
border-color: var(--platform-surface-border);
|
||||
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-actions .launcher-project-create-action small {
|
||||
color: inherit;
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
.launcher-project-page-status {
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
.launcher-project-table {
|
||||
@@ -405,6 +436,9 @@ textarea {
|
||||
|
||||
.launcher-project-table article small {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
margin-top: 4px;
|
||||
color: #8b8b8b;
|
||||
font-size: 12px;
|
||||
@@ -2677,14 +2711,61 @@ textarea {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.launcher-project-form {
|
||||
.launcher-project-actions {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.launcher-project-actions button {
|
||||
min-height: 66px;
|
||||
}
|
||||
|
||||
.launcher-page {
|
||||
width: min(100%, calc(100vw - 76px));
|
||||
}
|
||||
|
||||
.launcher-projects-page {
|
||||
padding-top: 54px;
|
||||
}
|
||||
|
||||
.launcher-project-table article {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.launcher-project-table article > .launcher-project-row-main,
|
||||
.launcher-project-table article > span {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.launcher-project-table article > .launcher-project-row-main {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.launcher-project-table article > .launcher-project-row-main strong {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.launcher-project-table article > span {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.launcher-project-table article > button:not(.launcher-project-row-main) {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.launcher-project-table article > button:last-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.launcher-agent-chat-page > header,
|
||||
.launcher-agent-chat-main > header {
|
||||
align-items: stretch;
|
||||
|
||||
@@ -50,7 +50,7 @@ type HomeViewProps = {
|
||||
onCreateDraftAutomatically: (draft: HomeDraft) => Promise<string>;
|
||||
onProjectsOpen: () => void;
|
||||
onProjectOpen: (path: string) => void;
|
||||
onGodotProjectOpen: () => void;
|
||||
onProjectPick: () => void;
|
||||
};
|
||||
|
||||
export default function HomeView({
|
||||
@@ -63,7 +63,7 @@ export default function HomeView({
|
||||
onCreateDraftAutomatically,
|
||||
onProjectsOpen,
|
||||
onProjectOpen,
|
||||
onGodotProjectOpen,
|
||||
onProjectPick,
|
||||
}: HomeViewProps) {
|
||||
const homeAgentMode = useLauncherHomeDraftStore((state) => state.mode);
|
||||
const homeRichText = useLauncherHomeDraftStore((state) => state.draft);
|
||||
@@ -213,10 +213,10 @@ export default function HomeView({
|
||||
<button
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 border-0 bg-transparent p-0 text-[12px] text-(--platform-warm-text)"
|
||||
type="button"
|
||||
onClick={onGodotProjectOpen}
|
||||
onClick={onProjectPick}
|
||||
>
|
||||
<FolderOpen size={14} aria-hidden="true" />
|
||||
打开 Godot 项目
|
||||
打开项目
|
||||
</button>
|
||||
<button
|
||||
className="cursor-pointer border-0 bg-transparent p-0 text-[12px] text-(--platform-warm-text)"
|
||||
|
||||
@@ -16,6 +16,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
const nativeClipboardMock = vi.hoisted(() => ({
|
||||
text: '',
|
||||
}));
|
||||
const TEST_LOCAL_PROJECT_PATH = '/tmp/genarrative-ai-game-draft';
|
||||
|
||||
const originalCreateObjectUrl = Object.getOwnPropertyDescriptor(
|
||||
URL,
|
||||
@@ -120,6 +121,15 @@ const testAuthUser: AuthUser = {
|
||||
function renderAppAt(path: string) {
|
||||
window.history.pushState({}, '', path);
|
||||
render(React.createElement(App));
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.has('dev') && !params.has('projectPath')) {
|
||||
const projectPathInput = screen.queryByLabelText('本地项目目录');
|
||||
if (projectPathInput) {
|
||||
fireEvent.change(projectPathInput, {
|
||||
target: { value: TEST_LOCAL_PROJECT_PATH },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderLauncherAt(
|
||||
@@ -157,6 +167,27 @@ function renderLauncherProjectsAt(path: string) {
|
||||
fireEvent.click(screen.getByRole('button', { name: '项目组' }));
|
||||
}
|
||||
|
||||
function pickProjectFromLauncher(
|
||||
projectPath: string,
|
||||
action: '打开项目' | '新建项目' = '打开项目',
|
||||
) {
|
||||
const invoke = window.__TAURI__?.core?.invoke;
|
||||
if (!invoke) {
|
||||
throw new Error('Tauri invoke is not configured');
|
||||
}
|
||||
window.__TAURI__ = {
|
||||
...window.__TAURI__,
|
||||
core: {
|
||||
...window.__TAURI__.core,
|
||||
invoke: async (command, args) =>
|
||||
command === 'pick_local_project_directory'
|
||||
? projectPath
|
||||
: invoke(command, args),
|
||||
},
|
||||
};
|
||||
fireEvent.click(screen.getByRole('button', { name: action }));
|
||||
}
|
||||
|
||||
function submitChat(value: string) {
|
||||
fireEvent.change(screen.getByLabelText('创作想法'), {
|
||||
target: { value },
|
||||
@@ -489,9 +520,7 @@ function createProjectSupervisorRuntimeHarness({
|
||||
currentProjectMessages.push({
|
||||
schemaVersion: 'game-creator-conversation.v1',
|
||||
...message,
|
||||
...(args?.messageId
|
||||
? { messageId: String(args.messageId) }
|
||||
: {}),
|
||||
...(args?.messageId ? { messageId: String(args.messageId) } : {}),
|
||||
updatedAt:
|
||||
typeof message.updatedAt === 'number'
|
||||
? message.updatedAt
|
||||
@@ -634,7 +663,8 @@ function createProjectSupervisorRuntimeHarness({
|
||||
runtimeUpdateHandler = handler;
|
||||
}
|
||||
if (eventName === 'game-creator-manifest-invalidated') {
|
||||
manifestInvalidatedHandler = handler as unknown as typeof manifestInvalidatedHandler;
|
||||
manifestInvalidatedHandler =
|
||||
handler as unknown as typeof manifestInvalidatedHandler;
|
||||
}
|
||||
return () => {
|
||||
if (runtimeUpdateHandler === handler) {
|
||||
@@ -804,6 +834,7 @@ export {
|
||||
mockRoleAgentReply,
|
||||
nativeClipboardMock,
|
||||
openMainProject,
|
||||
pickProjectFromLauncher,
|
||||
ProjectDevelopmentView,
|
||||
projectSupervisorResponseStream,
|
||||
React,
|
||||
@@ -818,6 +849,7 @@ export {
|
||||
screen,
|
||||
selectDeveloperAgentChatMode,
|
||||
submitChat,
|
||||
TEST_LOCAL_PROJECT_PATH,
|
||||
testAuthUser,
|
||||
vi,
|
||||
waitFor,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user