优化AGC项目入口并识别Godot工作区
Project CI / Repository checks (push) Successful in 1m14s
Project CI / Frontend tests (push) Successful in 3m9s
Project CI / Backend tests (push) Successful in 3m58s
Project CI / Native shell tests (push) Successful in 14m6s

项目页只保留打开项目和新建项目并统一原生目录选择
按根目录及一层子目录识别project.godot并记录相对Godot根
保持.agent位于用户选择的工作区根并补齐Windows构建门禁
补充响应式布局、测试、技术方案和共享项目记忆
This commit is contained in:
2026-08-14 21:33:33 +08:00
parent 4d3f87cd39
commit 578f8019fc
35 changed files with 1567 additions and 613 deletions
@@ -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()));
}
+2 -24
View File
@@ -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,
};
}
+122 -41
View File
@@ -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