Merge remote-tracking branch 'origin/master' into codex/agc-runtime-generation-reliability
# Conflicts: # apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs # apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts # apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts
This commit is contained in:
@@ -1238,10 +1238,10 @@ if (
|
||||
clientWindow.width !== 1280 ||
|
||||
clientWindow.height !== 800 ||
|
||||
clientWindow.minWidth !== 1280 ||
|
||||
clientWindow.minHeight !== 800
|
||||
clientWindow.minHeight !== 720
|
||||
) {
|
||||
throw new Error(
|
||||
'AI game creator shell client window must keep the landscape workbench size',
|
||||
'AI game creator shell client window must default to 1280x800 and stay at least 1280x720',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1473,6 +1473,22 @@ if (
|
||||
);
|
||||
}
|
||||
|
||||
const gameChatReleaseWindows = gameChatReleaseTauriConfig.app?.windows ?? [];
|
||||
const gameChatReleaseClientWindow = gameChatReleaseWindows[0];
|
||||
if (
|
||||
gameChatReleaseWindows.length !== 1 ||
|
||||
gameChatReleaseClientWindow?.label !== 'client' ||
|
||||
gameChatReleaseClientWindow?.url !== 'index.html' ||
|
||||
gameChatReleaseClientWindow?.width !== 1280 ||
|
||||
gameChatReleaseClientWindow?.height !== 800 ||
|
||||
gameChatReleaseClientWindow?.minWidth !== 1280 ||
|
||||
gameChatReleaseClientWindow?.minHeight !== 720
|
||||
) {
|
||||
throw new Error(
|
||||
'AI game creator game-chat client window must default to 1280x800 and stay at least 1280x720',
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
tauriConfig.version !== '0.1.0' ||
|
||||
packageConfig.version !== '0.1.0' ||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,5 +1,91 @@
|
||||
use super::*;
|
||||
|
||||
const AUTOMATIC_PROJECTS_DIRECTORY_NAME: &str = "Genarrative GameAgent";
|
||||
|
||||
fn automatic_local_game_projects_root(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
app.path()
|
||||
.document_dir()
|
||||
.map(|documents_root| documents_root.join(AUTOMATIC_PROJECTS_DIRECTORY_NAME))
|
||||
.map_err(|error| format!("无法读取系统文档目录:{error}"))
|
||||
}
|
||||
|
||||
pub(crate) fn closest_existing_project_picker_directory(path: &Path) -> Option<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(
|
||||
projects_root: &Path,
|
||||
) -> Result<InitLocalProjectResult, String> {
|
||||
if projects_root.as_os_str().is_empty() || !projects_root.is_absolute() {
|
||||
return Err("自动工作区根目录必须是绝对路径".to_string());
|
||||
}
|
||||
fs::create_dir_all(projects_root).map_err(|error| {
|
||||
format!(
|
||||
"创建自动工作区根目录失败:{}: {error}",
|
||||
projects_root.display()
|
||||
)
|
||||
})?;
|
||||
let metadata = fs::symlink_metadata(projects_root).map_err(|error| {
|
||||
format!(
|
||||
"读取自动工作区根目录失败:{}: {error}",
|
||||
projects_root.display()
|
||||
)
|
||||
})?;
|
||||
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
||||
return Err("自动工作区根目录必须是普通文件夹".to_string());
|
||||
}
|
||||
|
||||
for _ in 0..16 {
|
||||
let workspace_id = uuid::Uuid::new_v4().simple().to_string();
|
||||
let short_id = &workspace_id[..8];
|
||||
let project_name = format!("GameAgent 项目 {short_id}");
|
||||
let project_root = projects_root.join(format!("gameagent-{short_id}"));
|
||||
match fs::create_dir(&project_root) {
|
||||
Ok(()) => {
|
||||
let result = (|| {
|
||||
enforce_project_permission_policy(&project_root, "project.create")?;
|
||||
let _lock = acquire_project_write_lock(&project_root, "project.create")?;
|
||||
init_local_game_project_at(
|
||||
&project_root,
|
||||
&format!("gameagent-{workspace_id}"),
|
||||
&project_name,
|
||||
)
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = fs::remove_dir_all(&project_root);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"创建自动工作区失败:{}: {error}",
|
||||
project_root.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err("自动工作区命名冲突,请重试".to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn create_automatic_local_game_project(
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<InitLocalProjectResult, String> {
|
||||
create_automatic_local_game_project_at(&automatic_local_game_projects_root(&app)?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn init_local_game_project(
|
||||
project_path: String,
|
||||
@@ -20,6 +106,9 @@ pub(crate) fn import_local_godot_project(
|
||||
) -> Result<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())
|
||||
}
|
||||
@@ -65,12 +154,14 @@ pub(crate) fn inspect_local_project_directory(
|
||||
return Err("项目目录不能包含控制字符".to_string());
|
||||
}
|
||||
let recent_run_trace = recent_game_creator_run_trace(root);
|
||||
let godot_project_root = discover_local_godot_project_root(root)?;
|
||||
Ok(LocalProjectDirectoryStatus {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
exists: root.exists(),
|
||||
is_directory: root.is_dir(),
|
||||
is_game_creator_project: is_game_creator_project_directory(root),
|
||||
is_godot_project: is_godot_project_directory(root),
|
||||
is_godot_project: godot_project_root.is_some(),
|
||||
godot_project_root,
|
||||
project_name: game_creator_project_name(root),
|
||||
manifest_error: game_creator_project_manifest_error(root),
|
||||
recent_run_status: recent_run_trace.as_ref().map(|trace| trace.status.clone()),
|
||||
@@ -78,16 +169,6 @@ pub(crate) fn inspect_local_project_directory(
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn is_godot_project_directory(root: &Path) -> bool {
|
||||
if !root.is_dir() {
|
||||
return false;
|
||||
}
|
||||
let project_file = root.join("project.godot");
|
||||
fs::symlink_metadata(project_file)
|
||||
.map(|metadata| metadata.is_file() && !metadata.file_type().is_symlink())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub(crate) fn is_game_creator_project_directory(root: &Path) -> bool {
|
||||
if !root.is_dir() {
|
||||
return false;
|
||||
@@ -127,9 +208,23 @@ pub(crate) fn recent_game_creator_run_trace(root: &Path) -> Option<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);
|
||||
}
|
||||
@@ -216,7 +311,7 @@ pub(crate) fn get_local_game_manifest(
|
||||
return Err(format!("不支持通过 manifest 执行命令:{command_id}"));
|
||||
}
|
||||
enforce_project_permission_policy(root, command_id)?;
|
||||
read_manifest_for_project(root)
|
||||
read_manifest_for_project_with_godot_root_calibration(root)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -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>,
|
||||
@@ -2178,6 +2179,7 @@ fn main() {
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
create_automatic_local_game_project,
|
||||
init_local_game_project,
|
||||
import_local_godot_project,
|
||||
is_local_project_directory_non_empty,
|
||||
|
||||
@@ -1,10 +1,216 @@
|
||||
use super::*;
|
||||
|
||||
use super::filesystem::validate_portable_project_path_component;
|
||||
|
||||
const MANIFEST_LOCK_WAIT_ATTEMPTS: usize = 500;
|
||||
const MANIFEST_LOCK_WAIT_MILLIS: u64 = 10;
|
||||
|
||||
static MANIFEST_LOCK_OPEN_GUARD: OnceLock<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::io::AsRawHandle;
|
||||
use windows_sys::Win32::Storage::FileSystem::{
|
||||
GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION,
|
||||
};
|
||||
|
||||
let file = fs::File::open(&project_file).map_err(|error| {
|
||||
format!(
|
||||
"打开 Godot 项目文件失败:{}: {error}",
|
||||
project_file.display()
|
||||
)
|
||||
})?;
|
||||
// SAFETY: the structure is plain data initialized by GetFileInformationByHandle.
|
||||
let mut information = unsafe { std::mem::zeroed::<BY_HANDLE_FILE_INFORMATION>() };
|
||||
// SAFETY: file owns a live handle and information is a valid output pointer.
|
||||
if unsafe { GetFileInformationByHandle(file.as_raw_handle().cast(), &mut information) } == 0
|
||||
{
|
||||
return Err(format!(
|
||||
"读取 Godot 项目文件 Windows 身份失败:{}",
|
||||
std::io::Error::last_os_error()
|
||||
));
|
||||
}
|
||||
if information.nNumberOfLinks != 1 {
|
||||
return Err(format!(
|
||||
"Godot 项目文件不能是硬链接文件:{}",
|
||||
project_file.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn validate_godot_project_child_name(name: &std::ffi::OsStr) -> Result<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 +461,11 @@ pub(crate) fn import_local_godot_project_at(
|
||||
return Err("项目目录不能包含控制字符".to_string());
|
||||
}
|
||||
if !root.is_dir() {
|
||||
return Err("Godot 项目目录不存在或不是文件夹".to_string());
|
||||
}
|
||||
if !is_godot_project_directory(root) {
|
||||
return Err("所选文件夹不是有效的 Godot 项目:缺少普通文件 project.godot".to_string());
|
||||
return Err("Godot 工作区目录不存在或不是文件夹".to_string());
|
||||
}
|
||||
let godot_project_root = discover_local_godot_project_root(root)?.ok_or_else(|| {
|
||||
"所选工作区未在根目录或一层子目录发现有效的普通文件 project.godot".to_string()
|
||||
})?;
|
||||
if project_id.is_empty() {
|
||||
return Err("项目 ID 不能为空".to_string());
|
||||
}
|
||||
@@ -269,7 +475,11 @@ pub(crate) fn import_local_godot_project_at(
|
||||
|
||||
let manifest_path = root.join(".agent/manifest.json");
|
||||
if manifest_storage_exists(&manifest_path)? {
|
||||
let manifest = read_manifest(&manifest_path)?;
|
||||
let mut manifest = read_manifest(&manifest_path)?;
|
||||
if manifest.godot_project_root.as_deref() != Some(godot_project_root.as_str()) {
|
||||
manifest.godot_project_root = Some(godot_project_root);
|
||||
write_manifest(&manifest_path, &manifest)?;
|
||||
}
|
||||
return Ok(InitLocalProjectResult {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
manifest_path: manifest_path.to_string_lossy().into_owned(),
|
||||
@@ -286,6 +496,7 @@ pub(crate) fn import_local_godot_project_at(
|
||||
"projectId": project_id,
|
||||
"name": name,
|
||||
"projectKind": "godot",
|
||||
"godotProjectRoot": godot_project_root,
|
||||
}),
|
||||
)?;
|
||||
}
|
||||
@@ -299,7 +510,8 @@ pub(crate) fn import_local_godot_project_at(
|
||||
})?;
|
||||
}
|
||||
|
||||
let manifest = new_game_creation_app_manifest(project_id, name);
|
||||
let mut manifest = new_game_creation_app_manifest(project_id, name);
|
||||
manifest.godot_project_root = Some(godot_project_root);
|
||||
write_manifest(&manifest_path, &manifest)?;
|
||||
|
||||
Ok(InitLocalProjectResult {
|
||||
@@ -380,6 +592,17 @@ pub(crate) fn read_manifest_for_project(root: &Path) -> Result<GameCreationAppMa
|
||||
})
|
||||
}
|
||||
|
||||
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> {
|
||||
@@ -958,6 +1181,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)
|
||||
@@ -1048,6 +1273,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}"))?;
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|error| format!("创建 manifest 目录失败:{}: {error}", parent.display()))?;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -1260,6 +1260,66 @@ fn init_local_game_project_creates_manifest_and_dirs() {
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_picker_uses_the_closest_existing_parent_for_a_suggested_new_path() {
|
||||
let documents_root = unique_project_path();
|
||||
fs::create_dir_all(&documents_root).expect("create documents fixture");
|
||||
let suggested_project_path = documents_root
|
||||
.join("Genarrative GameAgent")
|
||||
.join("gameagent-new");
|
||||
|
||||
assert_eq!(
|
||||
closest_existing_project_picker_directory(&suggested_project_path),
|
||||
Some(documents_root.clone())
|
||||
);
|
||||
|
||||
fs::remove_dir_all(documents_root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_local_game_project_allocates_unique_initialized_workspaces() {
|
||||
let projects_root = unique_project_path();
|
||||
|
||||
let first = create_automatic_local_game_project_at(&projects_root)
|
||||
.expect("create first automatic workspace");
|
||||
let second = create_automatic_local_game_project_at(&projects_root)
|
||||
.expect("create second automatic workspace");
|
||||
|
||||
assert_ne!(first.project_path, second.project_path);
|
||||
for result in [first, second] {
|
||||
let root = PathBuf::from(&result.project_path);
|
||||
assert_eq!(root.parent(), Some(projects_root.as_path()));
|
||||
assert!(root
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.starts_with("gameagent-")));
|
||||
assert!(root.join(".agent/manifest.json").is_file());
|
||||
assert!(root.join("game/index.html").is_file());
|
||||
assert!(result.manifest.name.starts_with("GameAgent 项目 "));
|
||||
}
|
||||
|
||||
fs::remove_dir_all(projects_root).ok();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn automatic_local_game_project_rejects_symlinked_projects_root() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let container = unique_project_path();
|
||||
let target = container.join("target");
|
||||
let projects_root = container.join("projects");
|
||||
fs::create_dir_all(&target).expect("create automatic workspace target");
|
||||
symlink(&target, &projects_root).expect("symlink automatic workspace root");
|
||||
|
||||
let error = create_automatic_local_game_project_at(&projects_root)
|
||||
.expect_err("symlinked automatic workspace root must fail");
|
||||
|
||||
assert!(error.contains("普通文件夹"));
|
||||
assert!(fs::read_dir(&target).expect("read target").next().is_none());
|
||||
fs::remove_dir_all(container).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_local_game_project_requires_absolute_path() {
|
||||
let error = init_local_game_project_at(Path::new("relative-game"), "project-1", "demo")
|
||||
@@ -1321,6 +1381,7 @@ fn project_directory_status_distinguishes_missing_file_and_dir() {
|
||||
is_directory: false,
|
||||
is_game_creator_project: false,
|
||||
is_godot_project: false,
|
||||
godot_project_root: None,
|
||||
project_name: None,
|
||||
manifest_error: None,
|
||||
recent_run_status: None,
|
||||
@@ -1334,6 +1395,8 @@ fn project_directory_status_distinguishes_missing_file_and_dir() {
|
||||
assert!(file_status.exists);
|
||||
assert!(!file_status.is_directory);
|
||||
assert!(!file_status.is_game_creator_project);
|
||||
assert!(!file_status.is_godot_project);
|
||||
assert_eq!(file_status.godot_project_root, None);
|
||||
assert_eq!(file_status.project_name, None);
|
||||
assert_eq!(file_status.manifest_error, None);
|
||||
assert_eq!(file_status.recent_run_status, None);
|
||||
@@ -1345,6 +1408,8 @@ fn project_directory_status_distinguishes_missing_file_and_dir() {
|
||||
assert!(dir_status.exists);
|
||||
assert!(dir_status.is_directory);
|
||||
assert!(!dir_status.is_game_creator_project);
|
||||
assert!(!dir_status.is_godot_project);
|
||||
assert_eq!(dir_status.godot_project_root, None);
|
||||
assert_eq!(dir_status.project_name, None);
|
||||
assert_eq!(dir_status.manifest_error, None);
|
||||
assert_eq!(dir_status.recent_run_status, None);
|
||||
@@ -1403,6 +1468,64 @@ fn project_directory_status_distinguishes_missing_file_and_dir() {
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_directory_status_reports_workspace_relative_godot_root() {
|
||||
let root = unique_project_path();
|
||||
let godot_root = root.join("godot-game");
|
||||
fs::create_dir_all(&godot_root).expect("create nested Godot project");
|
||||
fs::write(godot_root.join("project.godot"), "[application]\n")
|
||||
.expect("write nested project.godot");
|
||||
|
||||
let status = inspect_local_project_directory(root.to_string_lossy().to_string())
|
||||
.expect("inspect nested Godot workspace");
|
||||
|
||||
assert!(status.exists);
|
||||
assert!(status.is_directory);
|
||||
assert!(status.is_godot_project);
|
||||
assert_eq!(status.godot_project_root.as_deref(), Some("godot-game"));
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_directory_status_rejects_ambiguous_direct_child_godot_projects() {
|
||||
let root = unique_project_path();
|
||||
for child in ["alpha", "beta"] {
|
||||
let godot_root = root.join(child);
|
||||
fs::create_dir_all(&godot_root).expect("create nested Godot project");
|
||||
fs::write(godot_root.join("project.godot"), "[application]\n")
|
||||
.expect("write nested project.godot");
|
||||
}
|
||||
|
||||
let error = inspect_local_project_directory(root.to_string_lossy().to_string())
|
||||
.expect_err("ambiguous Godot workspace must fail inspection");
|
||||
|
||||
assert!(error.contains("多个 Godot 项目"), "{error}");
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn godot_import_command_rejects_ambiguity_before_creating_the_project_lock() {
|
||||
let root = unique_project_path();
|
||||
for child in ["alpha", "beta"] {
|
||||
let godot_root = root.join(child);
|
||||
fs::create_dir_all(&godot_root).expect("create nested Godot project");
|
||||
fs::write(godot_root.join("project.godot"), "[application]\n")
|
||||
.expect("write nested project.godot");
|
||||
}
|
||||
|
||||
let error = import_local_godot_project(
|
||||
root.to_string_lossy().into_owned(),
|
||||
"ambiguous".to_string(),
|
||||
"Ambiguous".to_string(),
|
||||
)
|
||||
.expect_err("ambiguous Godot workspace must fail before locking");
|
||||
|
||||
assert!(error.contains("多个 Godot 项目"), "{error}");
|
||||
assert!(!root.join(PROJECT_WRITE_LOCK_PATH).exists());
|
||||
assert!(!root.join(".agent").exists());
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_project_directory_open_path_requires_existing_absolute_directory() {
|
||||
let root = unique_project_path();
|
||||
|
||||
@@ -2691,7 +2691,7 @@ async fn runtime_v11_closure_repository_context_drift_replans_before_auto_mutati
|
||||
}
|
||||
assert_eq!(
|
||||
fs::read_to_string(root.join("AGENTS.md")).expect("read drifted rules"),
|
||||
"drifted rules\\n"
|
||||
"drifted rules\n"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
|
||||
@@ -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';
|
||||
@@ -608,9 +607,7 @@ export function App({
|
||||
);
|
||||
const eagerSupervisorProject =
|
||||
projectSupervisorOnly && Boolean(initialProjectPath) && !gameChatOnly;
|
||||
const [projectPath, setProjectPath] = useState(
|
||||
initialProjectPath || defaultProjectPath,
|
||||
);
|
||||
const [projectPath, setProjectPath] = useState(initialProjectPath);
|
||||
const [workspaceProjectKind, setWorkspaceProjectKind] =
|
||||
useState<LocalProjectKind>(initialProjectKind);
|
||||
const [localProject, setLocalProject] =
|
||||
@@ -625,6 +622,7 @@ export function App({
|
||||
);
|
||||
const localProjectPathRef = useRef<string | null>(null);
|
||||
localProjectPathRef.current = localProject?.projectPath ?? null;
|
||||
|
||||
const manifestRefreshMountedRef = useRef(true);
|
||||
const manifestRefreshStatesRef = useRef(
|
||||
new Map<
|
||||
@@ -3306,6 +3304,7 @@ export function App({
|
||||
try {
|
||||
const selectedPath = await invoke<string | null>(
|
||||
'pick_local_project_directory',
|
||||
projectPath.trim() ? { initialPath: projectPath.trim() } : undefined,
|
||||
);
|
||||
if (gameChatProjectSelectionVersionRef.current !== selectionVersion) {
|
||||
return;
|
||||
|
||||
@@ -5,7 +5,6 @@ export const seedManifest = createGameCreationAppManifest(
|
||||
'未命名游戏原型',
|
||||
);
|
||||
|
||||
export const defaultProjectPath = '/tmp/genarrative-ai-game-draft';
|
||||
export const AGENT_RUN_HISTORY_MAX_COUNT = 100;
|
||||
export const AGENT_RUN_HISTORY_INITIAL_VISIBLE_COUNT = 20;
|
||||
export const AGENT_RUN_HISTORY_VISIBLE_STEP = 20;
|
||||
|
||||
@@ -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,9 +1,153 @@
|
||||
import { FolderKanban } from 'lucide-react';
|
||||
import {
|
||||
CircleAlert,
|
||||
Ellipsis,
|
||||
FolderKanban,
|
||||
FolderOpen,
|
||||
FolderPlus,
|
||||
Gamepad2,
|
||||
Search,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
import { closeDialogOnEscape } from '../../app/dialogs';
|
||||
import type { RecentProjectRow } from './model';
|
||||
import type { HomeProjectCreationController } from './useHomeProjectCreation';
|
||||
import type { RecentProjectsController } from './useRecentProjects';
|
||||
|
||||
function projectKindLabel(project: RecentProjectRow) {
|
||||
if (project.projectKind === 'godot') {
|
||||
return project.godotProjectRoot && project.godotProjectRoot !== '.'
|
||||
? `Godot · ${project.godotProjectRoot}`
|
||||
: 'Godot';
|
||||
}
|
||||
if (project.projectKind === 'web') {
|
||||
return 'GameAgent';
|
||||
}
|
||||
return '待识别';
|
||||
}
|
||||
|
||||
function projectStatusTone(project: RecentProjectRow) {
|
||||
if (project.canOpen) {
|
||||
return 'ready';
|
||||
}
|
||||
if (project.status === '检查中') {
|
||||
return 'checking';
|
||||
}
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
function ProjectMoreMenu({
|
||||
project,
|
||||
recentProjects,
|
||||
}: {
|
||||
project: RecentProjectRow;
|
||||
recentProjects: RecentProjectsController;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [dropUp, setDropUp] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) {
|
||||
setDropUp(false);
|
||||
return;
|
||||
}
|
||||
const trigger = triggerRef.current;
|
||||
const menu = menuRef.current?.querySelector<HTMLElement>(
|
||||
'.launcher-project-more-menu',
|
||||
);
|
||||
const table = trigger?.closest<HTMLElement>('.launcher-project-table');
|
||||
if (!trigger || !menu || !table) {
|
||||
return;
|
||||
}
|
||||
const triggerBounds = trigger.getBoundingClientRect();
|
||||
const tableBounds = table.getBoundingClientRect();
|
||||
const menuHeight = menu.offsetHeight;
|
||||
const availableBelow =
|
||||
Math.min(window.innerHeight, tableBounds.bottom) - triggerBounds.bottom;
|
||||
const availableAbove = triggerBounds.top - Math.max(0, tableBounds.top);
|
||||
setDropUp(
|
||||
availableBelow < menuHeight + 8 && availableAbove > availableBelow,
|
||||
);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
function closeIfOutside(event: PointerEvent) {
|
||||
if (
|
||||
event.target instanceof Node &&
|
||||
!menuRef.current?.contains(event.target)
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
function closeOnEscape(event: KeyboardEvent) {
|
||||
if (event.key !== 'Escape') {
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
document.addEventListener('pointerdown', closeIfOutside);
|
||||
document.addEventListener('keydown', closeOnEscape);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', closeIfOutside);
|
||||
document.removeEventListener('keydown', closeOnEscape);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="launcher-project-more" ref={menuRef}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className="launcher-project-more-trigger"
|
||||
aria-label={`${project.name}的更多操作`}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
<Ellipsis size={18} aria-hidden="true" />
|
||||
</button>
|
||||
{open ? (
|
||||
<div
|
||||
className={`launcher-project-more-menu${dropUp ? ' launcher-project-more-menu-drop-up' : ''}`}
|
||||
role="menu"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={!project.canReveal}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
void recentProjects.handleRevealProjectDirectory(project.path);
|
||||
}}
|
||||
>
|
||||
<FolderOpen size={15} aria-hidden="true" />
|
||||
显示目录
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="launcher-project-remove-action"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
recentProjects.handleRecentWorkspaceRemove(project.path);
|
||||
}}
|
||||
>
|
||||
<X size={15} aria-hidden="true" />
|
||||
从列表移除
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectsPage({
|
||||
status,
|
||||
homeProject,
|
||||
@@ -13,147 +157,137 @@ export function ProjectsPage({
|
||||
homeProject: HomeProjectCreationController;
|
||||
recentProjects: RecentProjectsController;
|
||||
}) {
|
||||
const rows = recentProjects.filteredProjectRows;
|
||||
const hasStoredProjects = recentProjects.projectRows.length > 0;
|
||||
return (
|
||||
<section className="launcher-page launcher-projects-page">
|
||||
<header>
|
||||
<div>
|
||||
<h1>项目组</h1>
|
||||
<p>
|
||||
<header className="launcher-projects-toolbar">
|
||||
<div className="launcher-projects-heading">
|
||||
<h1>项目</h1>
|
||||
<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-table">
|
||||
{recentProjects.projectRows.length > 0 ? (
|
||||
recentProjects.projectRows.map((project) => (
|
||||
<article key={project.path}>
|
||||
<div className="launcher-projects-controls">
|
||||
<label className="launcher-project-search">
|
||||
<Search size={16} aria-hidden="true" />
|
||||
<input
|
||||
type="search"
|
||||
aria-label="搜索项目"
|
||||
placeholder="搜索名称、路径、类型或状态"
|
||||
value={recentProjects.projectSearchQuery}
|
||||
onChange={(event) =>
|
||||
recentProjects.setProjectSearchQuery(event.target.value)
|
||||
}
|
||||
/>
|
||||
{recentProjects.projectSearchQuery ? (
|
||||
<button
|
||||
type="button"
|
||||
className="launcher-project-row-main"
|
||||
disabled={!project.canOpen}
|
||||
onClick={() => {
|
||||
homeProject.setProjectPath(project.path);
|
||||
void homeProject.openProject(project.path, 'open');
|
||||
}}
|
||||
aria-label="清除项目搜索"
|
||||
onClick={() => recentProjects.setProjectSearchQuery('')}
|
||||
>
|
||||
<strong>{project.name}</strong>
|
||||
<small>{project.path}</small>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<span>{project.status}</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!project.canOpen}
|
||||
onClick={() => {
|
||||
homeProject.setProjectPath(project.path);
|
||||
void homeProject.openProject(project.path, 'open');
|
||||
}}
|
||||
>
|
||||
打开
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`显示 ${project.path}`}
|
||||
disabled={!project.canReveal}
|
||||
onClick={() => {
|
||||
void recentProjects.handleRevealProjectDirectory(
|
||||
project.path,
|
||||
);
|
||||
}}
|
||||
>
|
||||
显示
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`移除 ${project.path}`}
|
||||
onClick={() =>
|
||||
recentProjects.handleRecentWorkspaceRemove(project.path)
|
||||
}
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
</article>
|
||||
))
|
||||
) : (
|
||||
<div className="launcher-empty-projects">
|
||||
<FolderKanban size={28} aria-hidden="true" />
|
||||
<strong>暂无项目</strong>
|
||||
) : null}
|
||||
</label>
|
||||
<div className="launcher-project-actions" aria-label="项目操作">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void homeProject.handlePickProjectDirectory();
|
||||
}}
|
||||
className="launcher-project-open-action"
|
||||
aria-label="打开项目"
|
||||
disabled={homeProject.projectBusy}
|
||||
onClick={() => void homeProject.pickAndOpenProject()}
|
||||
>
|
||||
选择本地项目目录
|
||||
<FolderOpen size={16} aria-hidden="true" />
|
||||
{homeProject.projectAction === 'opening' ? '打开中…' : '打开项目'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="launcher-project-create-action"
|
||||
aria-label="新建项目"
|
||||
disabled={homeProject.projectBusy}
|
||||
onClick={() => void homeProject.pickAndCreateProject()}
|
||||
>
|
||||
<FolderPlus size={16} aria-hidden="true" />
|
||||
{homeProject.projectAction === 'creating'
|
||||
? '新建中…'
|
||||
: '新建项目'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<div className="launcher-project-list-shell" aria-label="项目列表">
|
||||
<div className="launcher-project-table-header">
|
||||
<span>项目</span>
|
||||
<span>类型</span>
|
||||
<span>状态</span>
|
||||
<span>操作</span>
|
||||
</div>
|
||||
<div className="launcher-project-table">
|
||||
{rows.length > 0 ? (
|
||||
rows.map((project) => (
|
||||
<article key={project.path}>
|
||||
<button
|
||||
type="button"
|
||||
className="launcher-project-row-main"
|
||||
aria-label={`打开项目 ${project.name}`}
|
||||
disabled={!project.canOpen}
|
||||
onClick={() => {
|
||||
homeProject.setProjectPath(project.path);
|
||||
void homeProject.openProject(project.path, 'open');
|
||||
}}
|
||||
>
|
||||
<span className="launcher-project-kind-icon">
|
||||
{project.projectKind === 'godot' ? (
|
||||
<Gamepad2 size={18} aria-hidden="true" />
|
||||
) : (
|
||||
<FolderKanban size={18} aria-hidden="true" />
|
||||
)}
|
||||
</span>
|
||||
<span className="launcher-project-name-cell">
|
||||
<strong>{project.name}</strong>
|
||||
<small title={project.path}>{project.path}</small>
|
||||
</span>
|
||||
</button>
|
||||
<span className="launcher-project-kind-cell">
|
||||
{projectKindLabel(project)}
|
||||
</span>
|
||||
<span
|
||||
className={`launcher-project-status launcher-project-status-${projectStatusTone(project)}`}
|
||||
>
|
||||
{projectStatusTone(project) === 'warning' ? (
|
||||
<CircleAlert size={14} aria-hidden="true" />
|
||||
) : null}
|
||||
{project.status}
|
||||
</span>
|
||||
<ProjectMoreMenu
|
||||
project={project}
|
||||
recentProjects={recentProjects}
|
||||
/>
|
||||
</article>
|
||||
))
|
||||
) : hasStoredProjects ? (
|
||||
<div className="launcher-empty-projects">
|
||||
<Search size={26} aria-hidden="true" />
|
||||
<strong>没有匹配的项目</strong>
|
||||
<small>换一个名称、路径、类型或状态试试</small>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => recentProjects.setProjectSearchQuery('')}
|
||||
>
|
||||
清除搜索
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="launcher-empty-projects">
|
||||
<FolderKanban size={28} aria-hidden="true" />
|
||||
<strong>暂无项目</strong>
|
||||
<small>使用右上角打开已有项目,或新建一个工作区</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -63,6 +63,7 @@ export function WorkspaceLauncherShell({
|
||||
setAgentResults: setActiveProjectAgentResults,
|
||||
resetLauncherHomeDraft,
|
||||
createHomeDraft,
|
||||
createHomeDraftAutomatically,
|
||||
openProject,
|
||||
} = homeProject;
|
||||
const activeProjectContextRef = useRef(currentProjectContext);
|
||||
@@ -259,12 +260,13 @@ export function WorkspaceLauncherShell({
|
||||
homeAgentModeItems={homeAgentModeItems}
|
||||
recentProjectRows={recentProjectRows}
|
||||
onCreateDraft={createHomeDraft}
|
||||
onCreateDraftAutomatically={createHomeDraftAutomatically}
|
||||
onProjectsOpen={() => setLauncherView('projects')}
|
||||
onProjectOpen={(path) => {
|
||||
setProjectPath(path);
|
||||
void openProject(path, 'open');
|
||||
}}
|
||||
onGodotProjectOpen={() => void homeProject.openGodotProject()}
|
||||
onProjectPick={() => void homeProject.pickAndOpenProject()}
|
||||
/>
|
||||
) : launcherView === 'projects' ? (
|
||||
<ProjectsPage
|
||||
|
||||
@@ -57,6 +57,10 @@ export type RecentProjectRow = {
|
||||
path: string;
|
||||
name: string;
|
||||
status: string;
|
||||
projectKind: 'web' | 'godot' | 'unknown';
|
||||
godotProjectRoot: string | null;
|
||||
recentRunStatus: string | null;
|
||||
recentRunStopReason: string | null;
|
||||
canReveal: boolean;
|
||||
canOpen: boolean;
|
||||
};
|
||||
@@ -134,15 +138,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,
|
||||
@@ -285,15 +280,19 @@ export function buildRecentProjectRows(
|
||||
? '不是文件夹'
|
||||
: directoryStatus?.manifestError
|
||||
? '无法读取'
|
||||
: directoryStatus?.isGameCreatorProject === false
|
||||
? '未初始化'
|
||||
: directoryStatus?.recentRunStatus
|
||||
? `run: ${directoryStatus.recentRunStatus}${
|
||||
directoryStatus.recentRunStopReason
|
||||
? ` · ${directoryStatus.recentRunStopReason}`
|
||||
: ''
|
||||
}`
|
||||
: '本地项目';
|
||||
: directoryStatus?.isGodotProject === true &&
|
||||
directoryStatus?.isGameCreatorProject === false
|
||||
? '可导入'
|
||||
: directoryStatus?.isGameCreatorProject === false
|
||||
? '未初始化'
|
||||
: directoryStatus?.recentRunStatus
|
||||
? formatRecentProjectRunStatus(
|
||||
directoryStatus.recentRunStatus,
|
||||
directoryStatus.recentRunStopReason,
|
||||
)
|
||||
: directoryStatus?.isGodotProject
|
||||
? '可打开'
|
||||
: '本地项目';
|
||||
const canReveal =
|
||||
!recentWorkspaceRefreshing &&
|
||||
Boolean(directoryStatus) &&
|
||||
@@ -303,6 +302,14 @@ export function buildRecentProjectRows(
|
||||
path: workspace,
|
||||
name: projectName,
|
||||
status,
|
||||
projectKind: directoryStatus?.isGodotProject
|
||||
? 'godot'
|
||||
: directoryStatus?.isGameCreatorProject
|
||||
? 'web'
|
||||
: 'unknown',
|
||||
godotProjectRoot: directoryStatus?.godotProjectRoot ?? null,
|
||||
recentRunStatus: directoryStatus?.recentRunStatus ?? null,
|
||||
recentRunStopReason: directoryStatus?.recentRunStopReason ?? null,
|
||||
canReveal,
|
||||
canOpen:
|
||||
!recentWorkspaceRefreshing &&
|
||||
@@ -310,7 +317,34 @@ export function buildRecentProjectRows(
|
||||
directoryStatus?.exists !== false &&
|
||||
directoryStatus?.isDirectory !== false &&
|
||||
!directoryStatus?.manifestError &&
|
||||
directoryStatus?.isGameCreatorProject !== false,
|
||||
(directoryStatus?.isGameCreatorProject !== false ||
|
||||
directoryStatus?.isGodotProject === true),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function formatRecentProjectRunStatus(
|
||||
status: string,
|
||||
stopReason: string | null,
|
||||
) {
|
||||
const statusLabel =
|
||||
{
|
||||
completed: '已完成',
|
||||
done: '已完成',
|
||||
failed: '运行失败',
|
||||
running: '运行中',
|
||||
pending: '等待运行',
|
||||
cancelled: '已取消',
|
||||
}[status] ?? status;
|
||||
const stopReasonLabel = stopReason
|
||||
? ({
|
||||
'preview-running': '预览运行中',
|
||||
completed: '已完成',
|
||||
failed: '运行失败',
|
||||
cancelled: '已取消',
|
||||
}[stopReason] ?? stopReason)
|
||||
: null;
|
||||
return stopReasonLabel && stopReasonLabel !== statusLabel
|
||||
? `${statusLabel} · ${stopReasonLabel}`
|
||||
: statusLabel;
|
||||
}
|
||||
|
||||
@@ -766,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,7 +1,8 @@
|
||||
import {
|
||||
type Dispatch,
|
||||
type FormEvent,
|
||||
type SetStateAction,
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
@@ -9,7 +10,7 @@ import type {
|
||||
GameCreationAppManifest,
|
||||
GameCreationAppPreviewState,
|
||||
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import { defaultProjectPath, seedManifest } from '../../app/constants';
|
||||
import { seedManifest } from '../../app/constants';
|
||||
import { useEscapeToClose } from '../../app/dialogs';
|
||||
import { resolveTauriInvoke } from '../../app/tauri';
|
||||
import type {
|
||||
@@ -57,7 +58,15 @@ export function useHomeProjectCreation({
|
||||
setAgentChatProjectPath,
|
||||
rememberRecentWorkspace,
|
||||
}: UseHomeProjectCreationOptions) {
|
||||
const [projectPath, setProjectPath] = useState(defaultProjectPath);
|
||||
const [projectPath, setProjectPathState] = useState('');
|
||||
const [projectAction, setProjectAction] = useState<
|
||||
'opening' | 'creating' | null
|
||||
>(null);
|
||||
const projectActionRef = useRef(projectAction);
|
||||
projectActionRef.current = projectAction;
|
||||
const setProjectPath = useCallback((nextProjectPath: string) => {
|
||||
setProjectPathState(nextProjectPath);
|
||||
}, []);
|
||||
const [currentProjectContext, setCurrentProjectContext] =
|
||||
useState<LauncherProjectContext | null>(null);
|
||||
const [activeProjectPreview, setActiveProjectPreview] =
|
||||
@@ -153,6 +162,58 @@ export function useHomeProjectCreation({
|
||||
return imported;
|
||||
}
|
||||
|
||||
async function enterCreatedHomeProject(
|
||||
invoke: TauriInvoke,
|
||||
result: InitLocalProjectResult,
|
||||
mode: HomeAgentMode,
|
||||
prompt: string,
|
||||
attachments: HomeAttachmentDraft[],
|
||||
) {
|
||||
const importedAttachments = await importHomeAttachments(
|
||||
invoke,
|
||||
result.projectPath,
|
||||
attachments,
|
||||
);
|
||||
const sessionId = await ensureProjectSupervisorActiveSessionId(
|
||||
invoke,
|
||||
result.projectPath,
|
||||
);
|
||||
if (!sessionId) {
|
||||
throw new Error('项目总控 Agent active Session 不可用');
|
||||
}
|
||||
await submitProjectSupervisorRuntimeTask({
|
||||
invoke,
|
||||
projectPath: result.projectPath,
|
||||
sessionId,
|
||||
prompt: buildHomeConversationContent(mode, prompt, attachments),
|
||||
runtime: null,
|
||||
runProfile: 'autonomous-game-build',
|
||||
source: 'project-supervisor-game-chat',
|
||||
});
|
||||
enterProjectDevelopment({
|
||||
projectPath: result.projectPath,
|
||||
projectName:
|
||||
result.manifest.name || projectNameFromPath(result.projectPath),
|
||||
projectKind: 'web',
|
||||
manifest: result.manifest,
|
||||
projectRevision: await readCurrentProjectRevision(
|
||||
invoke,
|
||||
result.projectPath,
|
||||
),
|
||||
mode,
|
||||
initialPrompt:
|
||||
prompt.trim() ||
|
||||
(attachments.length > 0
|
||||
? '用户上传了参考附件,等待后续补充需求。'
|
||||
: ''),
|
||||
attachments: importedAttachments,
|
||||
recentRunStatus: null,
|
||||
recentRunStopReason: null,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
resetLauncherHomeDraft();
|
||||
}
|
||||
|
||||
async function createHomeProjectFromDirectory(
|
||||
nextProjectPath: string,
|
||||
mode: HomeAgentMode,
|
||||
@@ -196,28 +257,14 @@ export function useHomeProjectCreation({
|
||||
name: projectNameFromPath(trimmedProjectPath),
|
||||
},
|
||||
);
|
||||
const importedAttachments = await importHomeAttachments(
|
||||
invoke,
|
||||
result.projectPath,
|
||||
attachments,
|
||||
);
|
||||
try {
|
||||
const sessionId = await ensureProjectSupervisorActiveSessionId(
|
||||
await enterCreatedHomeProject(
|
||||
invoke,
|
||||
result.projectPath,
|
||||
result,
|
||||
mode,
|
||||
prompt,
|
||||
attachments,
|
||||
);
|
||||
if (!sessionId) {
|
||||
throw new Error('项目总控 Agent active Session 不可用');
|
||||
}
|
||||
await submitProjectSupervisorRuntimeTask({
|
||||
invoke,
|
||||
projectPath: result.projectPath,
|
||||
sessionId,
|
||||
prompt: buildHomeConversationContent(mode, prompt, attachments),
|
||||
runtime: null,
|
||||
runProfile: 'autonomous-game-build',
|
||||
source: 'project-supervisor-game-chat',
|
||||
});
|
||||
setStatus('已创建项目并交给项目总控 Agent');
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
@@ -226,28 +273,6 @@ export function useHomeProjectCreation({
|
||||
}`,
|
||||
);
|
||||
}
|
||||
enterProjectDevelopment({
|
||||
projectPath: result.projectPath,
|
||||
projectName:
|
||||
result.manifest.name || projectNameFromPath(result.projectPath),
|
||||
projectKind: 'web',
|
||||
manifest: result.manifest,
|
||||
projectRevision: await readCurrentProjectRevision(
|
||||
invoke,
|
||||
result.projectPath,
|
||||
),
|
||||
mode,
|
||||
initialPrompt:
|
||||
prompt.trim() ||
|
||||
(attachments.length > 0
|
||||
? '用户上传了参考附件,等待后续补充需求。'
|
||||
: ''),
|
||||
attachments: importedAttachments,
|
||||
recentRunStatus: null,
|
||||
recentRunStopReason: null,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
resetLauncherHomeDraft();
|
||||
return '已创建项目并进入项目开发';
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
@@ -256,10 +281,37 @@ export function useHomeProjectCreation({
|
||||
}
|
||||
}
|
||||
|
||||
async function finishAutomaticallyCreatedHomeProject(
|
||||
result: InitLocalProjectResult,
|
||||
mode: HomeAgentMode,
|
||||
prompt: string,
|
||||
attachments: HomeAttachmentDraft[],
|
||||
) {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) {
|
||||
throw new Error('需要在 Tauri App 内运行');
|
||||
}
|
||||
setStatus('正在启动项目总控 Agent');
|
||||
try {
|
||||
await enterCreatedHomeProject(invoke, result, mode, prompt, attachments);
|
||||
setStatus('已自动创建工作区并开始工作');
|
||||
return '已自动创建工作区并开始工作';
|
||||
} catch (error) {
|
||||
const message = `工作区已创建;首条需求投递失败:${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`;
|
||||
setStatus(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function createProjectFromProjectPage(
|
||||
nextProjectPath: string,
|
||||
skipNonEmptyCheck = false,
|
||||
) {
|
||||
if (projectActionRef.current) {
|
||||
return;
|
||||
}
|
||||
const trimmedProjectPath = validateProjectPath(nextProjectPath);
|
||||
if (!trimmedProjectPath) {
|
||||
return;
|
||||
@@ -269,6 +321,8 @@ export function useHomeProjectCreation({
|
||||
setStatus('需要在 Tauri App 内运行');
|
||||
return;
|
||||
}
|
||||
projectActionRef.current = 'creating';
|
||||
setProjectAction('creating');
|
||||
setStatus('正在创建项目');
|
||||
try {
|
||||
if (!skipNonEmptyCheck) {
|
||||
@@ -313,6 +367,11 @@ export function useHomeProjectCreation({
|
||||
});
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
if (projectActionRef.current === 'creating') {
|
||||
projectActionRef.current = null;
|
||||
setProjectAction(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,6 +380,9 @@ export function useHomeProjectCreation({
|
||||
await createProjectFromProjectPage(nextProjectPath);
|
||||
return;
|
||||
}
|
||||
if (projectActionRef.current) {
|
||||
return;
|
||||
}
|
||||
const trimmedProjectPath = validateProjectPath(nextProjectPath);
|
||||
if (!trimmedProjectPath) {
|
||||
return;
|
||||
@@ -330,6 +392,8 @@ export function useHomeProjectCreation({
|
||||
setStatus('需要在 Tauri App 内运行');
|
||||
return;
|
||||
}
|
||||
projectActionRef.current = 'opening';
|
||||
setProjectAction('opening');
|
||||
setStatus('正在打开');
|
||||
try {
|
||||
const directoryStatus = await invoke<LocalProjectDirectoryStatus>(
|
||||
@@ -344,18 +408,28 @@ export function useHomeProjectCreation({
|
||||
setStatus('项目路径不是文件夹');
|
||||
return;
|
||||
}
|
||||
if (!directoryStatus.isGameCreatorProject) {
|
||||
setStatus('这不是已初始化的 AI 游戏项目,请使用新建项目。');
|
||||
return;
|
||||
}
|
||||
let projectManifest = seedManifest;
|
||||
try {
|
||||
let projectManifest: GameCreationAppManifest;
|
||||
if (directoryStatus.isGameCreatorProject) {
|
||||
projectManifest = await invoke<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({
|
||||
@@ -363,7 +437,11 @@ export function useHomeProjectCreation({
|
||||
projectName:
|
||||
directoryStatus.projectName ||
|
||||
projectNameFromPath(trimmedProjectPath),
|
||||
projectKind: directoryStatus.isGodotProject ? 'godot' : 'web',
|
||||
projectKind:
|
||||
directoryStatus.godotProjectRoot !== null &&
|
||||
directoryStatus.godotProjectRoot !== undefined
|
||||
? 'godot'
|
||||
: 'web',
|
||||
manifest: projectManifest,
|
||||
projectRevision: await readCurrentProjectRevision(
|
||||
invoke,
|
||||
@@ -378,6 +456,11 @@ export function useHomeProjectCreation({
|
||||
});
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
if (projectActionRef.current === 'opening') {
|
||||
projectActionRef.current = null;
|
||||
setProjectAction(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,11 +497,6 @@ export function useHomeProjectCreation({
|
||||
pendingNonEmptyProject !== null,
|
||||
);
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
void openProject(projectPath, 'open');
|
||||
}
|
||||
|
||||
async function createHomeDraft(draft: HomeDraft) {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) {
|
||||
@@ -426,6 +504,7 @@ export function useHomeProjectCreation({
|
||||
}
|
||||
const selectedPath = await invoke<string | null>(
|
||||
'pick_local_project_directory',
|
||||
projectPath.trim() ? { initialPath: projectPath.trim() } : undefined,
|
||||
);
|
||||
if (!selectedPath) {
|
||||
return '已取消';
|
||||
@@ -438,93 +517,90 @@ export function useHomeProjectCreation({
|
||||
);
|
||||
}
|
||||
|
||||
async function handlePickProjectDirectory() {
|
||||
async function createHomeDraftAutomatically(draft: HomeDraft) {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) {
|
||||
throw new Error('需要在 Tauri App 内运行');
|
||||
}
|
||||
setStatus('正在创建工作区');
|
||||
const result = await invoke<InitLocalProjectResult>(
|
||||
'create_automatic_local_game_project',
|
||||
);
|
||||
return finishAutomaticallyCreatedHomeProject(
|
||||
result,
|
||||
draft.mode,
|
||||
draft.prompt,
|
||||
draft.attachments,
|
||||
);
|
||||
}
|
||||
|
||||
async function pickAndOpenProject() {
|
||||
if (projectActionRef.current) {
|
||||
return;
|
||||
}
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) {
|
||||
setStatus('需要在 Tauri App 内运行');
|
||||
return;
|
||||
}
|
||||
setStatus('正在选择');
|
||||
projectActionRef.current = 'opening';
|
||||
setProjectAction('opening');
|
||||
setStatus('正在选择项目');
|
||||
try {
|
||||
const selectedPath = await invoke<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -539,13 +615,15 @@ export function useHomeProjectCreation({
|
||||
setAgentRuntimeSummaries,
|
||||
activeProjectAgentResults,
|
||||
setAgentResults,
|
||||
projectAction,
|
||||
projectBusy: projectAction !== null,
|
||||
pendingNonEmptyProject,
|
||||
resetLauncherHomeDraft,
|
||||
createHomeDraft,
|
||||
createHomeDraftAutomatically,
|
||||
openProject,
|
||||
openGodotProject,
|
||||
handleSubmit,
|
||||
handlePickProjectDirectory,
|
||||
pickAndOpenProject,
|
||||
pickAndCreateProject,
|
||||
confirmCreateInNonEmptyFolder,
|
||||
cancelCreateInNonEmptyFolder,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { type Dispatch, type SetStateAction, useEffect, useState } from 'react';
|
||||
import {
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { resolveTauriInvoke } from '../../app/tauri';
|
||||
import type { LocalProjectDirectoryStatus } from '../../app/types';
|
||||
@@ -8,7 +14,6 @@ import {
|
||||
} from '../project-summary/projectSummary';
|
||||
import {
|
||||
buildRecentProjectRows,
|
||||
clearRecentWorkspaces,
|
||||
readRecentWorkspaces,
|
||||
removeRecentWorkspace,
|
||||
writeRecentWorkspace,
|
||||
@@ -23,6 +28,7 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
|
||||
const [recentWorkspaceRefreshKey, setRecentWorkspaceRefreshKey] = useState(0);
|
||||
const [recentWorkspaceRefreshing, setRecentWorkspaceRefreshing] =
|
||||
useState(false);
|
||||
const [projectSearchQuery, setProjectSearchQuery] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const invoke = resolveTauriInvoke();
|
||||
@@ -62,13 +68,6 @@ export function useRecentProjects(setStatus: Dispatch<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 +76,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)) {
|
||||
@@ -112,18 +106,38 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
|
||||
recentWorkspaceStatuses,
|
||||
recentWorkspaceRefreshing,
|
||||
);
|
||||
const filteredProjectRows = useMemo(() => {
|
||||
const normalizedQuery = projectSearchQuery.trim().toLocaleLowerCase();
|
||||
if (!normalizedQuery) {
|
||||
return projectRows;
|
||||
}
|
||||
return projectRows.filter((project) =>
|
||||
[
|
||||
project.name,
|
||||
project.path,
|
||||
project.status,
|
||||
project.godotProjectRoot ?? '',
|
||||
project.projectKind === 'godot'
|
||||
? 'godot'
|
||||
: project.projectKind === 'web'
|
||||
? 'gameagent web'
|
||||
: 'unknown',
|
||||
].some((value) => value.toLocaleLowerCase().includes(normalizedQuery)),
|
||||
);
|
||||
}, [projectRows, projectSearchQuery]);
|
||||
|
||||
return {
|
||||
recentWorkspaces,
|
||||
recentWorkspaceRefreshing,
|
||||
projectRows,
|
||||
filteredProjectRows,
|
||||
projectSearchQuery,
|
||||
setProjectSearchQuery,
|
||||
recentProjectRows: projectRows
|
||||
.filter((project) => project.canOpen)
|
||||
.slice(0, 3),
|
||||
rememberRecentWorkspace,
|
||||
handleRecentWorkspaceRefresh,
|
||||
handleRecentWorkspaceRemove,
|
||||
handleRecentWorkspaceClear,
|
||||
handleRevealProjectDirectory,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -281,6 +281,25 @@ textarea {
|
||||
padding: 118px 0 0;
|
||||
}
|
||||
|
||||
.launcher-projects-page {
|
||||
align-content: start;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
width: calc(100vw - var(--launcher-sidebar-width));
|
||||
max-width: none;
|
||||
height: 100dvh;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 92px 28px 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.launcher-main:has(.launcher-projects-page) {
|
||||
height: 100dvh;
|
||||
min-height: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.launcher-page header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
@@ -315,119 +334,382 @@ textarea {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.launcher-project-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: end;
|
||||
gap: 10px;
|
||||
margin: 16px 0 14px;
|
||||
padding: 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.launcher-project-form label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.launcher-project-form input {
|
||||
min-width: 0;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #d8dde5;
|
||||
border-radius: 8px;
|
||||
color: #111827;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.launcher-page-actions button,
|
||||
.launcher-page .launcher-project-list-actions button,
|
||||
.launcher-project-table article > button,
|
||||
.launcher-empty-projects button {
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-height: 36px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #d8dde5;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #111827;
|
||||
border: 1px solid var(--platform-surface-border);
|
||||
border-radius: 9px;
|
||||
background: var(--platform-button-secondary-fill);
|
||||
color: var(--platform-button-secondary-text);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.launcher-page .launcher-project-list-actions button:disabled {
|
||||
.launcher-page .launcher-project-list-actions button:disabled,
|
||||
.launcher-empty-projects button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.launcher-page-actions button:last-child {
|
||||
border-color: #111827;
|
||||
background: #111827;
|
||||
color: #fff;
|
||||
.launcher-projects-page button:focus-visible,
|
||||
.launcher-project-search input:focus-visible {
|
||||
outline: 3px solid var(--platform-warm-text);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.launcher-projects-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(160px, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.launcher-projects-heading {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.launcher-projects-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.launcher-project-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: clamp(260px, 28vw, 390px);
|
||||
height: 38px;
|
||||
gap: 8px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--platform-surface-border);
|
||||
border-radius: 9px;
|
||||
background: var(--platform-input-fill);
|
||||
color: var(--platform-text-soft);
|
||||
}
|
||||
|
||||
.launcher-project-search:focus-within {
|
||||
border-color: var(--platform-surface-hover-border);
|
||||
box-shadow: 0 0 0 3px var(--platform-input-focus-ring);
|
||||
}
|
||||
|
||||
.launcher-project-search input {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--platform-text-strong);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.launcher-project-search button {
|
||||
display: grid;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--platform-text-soft);
|
||||
cursor: pointer;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.launcher-project-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.launcher-project-actions button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 38px;
|
||||
gap: 7px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--platform-surface-border);
|
||||
border-radius: 9px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.launcher-project-actions button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.launcher-project-actions .launcher-project-open-action {
|
||||
background: var(--platform-button-secondary-fill);
|
||||
color: var(--platform-button-secondary-text);
|
||||
}
|
||||
|
||||
.launcher-project-actions .launcher-project-create-action {
|
||||
border-color: var(--platform-button-primary-border);
|
||||
background: var(--platform-button-primary-fill);
|
||||
color: var(--platform-button-primary-text);
|
||||
}
|
||||
|
||||
.launcher-project-page-status {
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
.launcher-project-list-shell {
|
||||
display: grid;
|
||||
grid-template-rows: 42px minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--platform-surface-border);
|
||||
border-radius: 12px;
|
||||
background: var(--platform-subpanel-fill);
|
||||
box-shadow: var(--platform-panel-shadow);
|
||||
}
|
||||
|
||||
.launcher-project-table-header,
|
||||
.launcher-project-table article {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
minmax(360px, 1fr) minmax(120px, 160px) minmax(150px, 190px)
|
||||
56px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.launcher-project-table-header {
|
||||
height: 42px;
|
||||
padding: 0 14px;
|
||||
border-bottom: 1px solid var(--platform-surface-border);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--platform-subpanel-fill) 82%,
|
||||
var(--platform-warm-bg)
|
||||
);
|
||||
color: var(--platform-text-soft);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.launcher-project-table-header span:last-child {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.launcher-project-table {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
align-content: start;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.launcher-project-table article {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto auto auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 62px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
min-height: 66px;
|
||||
padding: 0 14px;
|
||||
border-bottom: 1px solid var(--platform-surface-border);
|
||||
background: var(--platform-subpanel-fill);
|
||||
transition: background 120ms ease;
|
||||
}
|
||||
|
||||
.launcher-project-table article:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.launcher-project-table
|
||||
article:has(.launcher-project-row-main:not(:disabled)):hover {
|
||||
background: var(--platform-nav-item-hover-fill);
|
||||
}
|
||||
|
||||
.launcher-project-table article > .launcher-project-row-main {
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
height: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
gap: 12px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.launcher-project-table article > .launcher-project-row-main:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.launcher-project-kind-icon {
|
||||
display: grid;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 9px;
|
||||
background: var(--platform-warm-bg);
|
||||
color: var(--platform-warm-text);
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.launcher-project-name-cell {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.launcher-project-table article strong {
|
||||
display: block;
|
||||
color: #111827;
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
color: var(--platform-text-strong);
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.launcher-project-table article small {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: #8b8b8b;
|
||||
font-size: 12px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
color: var(--platform-text-soft);
|
||||
font-size: 11px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.launcher-project-table article span {
|
||||
color: #6b7280;
|
||||
.launcher-project-kind-cell {
|
||||
overflow: hidden;
|
||||
padding-right: 16px;
|
||||
color: var(--platform-text-base);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.launcher-project-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-self: start;
|
||||
max-width: calc(100% - 12px);
|
||||
min-height: 24px;
|
||||
gap: 5px;
|
||||
overflow: hidden;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.launcher-project-status-ready {
|
||||
background: color-mix(in srgb, #2d9d62 12%, transparent);
|
||||
color: #237b4d;
|
||||
}
|
||||
|
||||
.launcher-project-status-checking {
|
||||
background: color-mix(in srgb, var(--platform-warm-text) 12%, transparent);
|
||||
color: var(--platform-warm-text);
|
||||
}
|
||||
|
||||
.launcher-project-status-warning {
|
||||
background: color-mix(in srgb, #d15f45 12%, transparent);
|
||||
color: #a64735;
|
||||
}
|
||||
|
||||
.launcher-project-more {
|
||||
position: relative;
|
||||
display: grid;
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.launcher-project-more-trigger {
|
||||
display: grid;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--platform-text-soft);
|
||||
cursor: pointer;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.launcher-project-more-trigger:hover,
|
||||
.launcher-project-more-trigger[aria-expanded='true'] {
|
||||
background: var(--platform-nav-item-hover-fill);
|
||||
color: var(--platform-text-strong);
|
||||
}
|
||||
|
||||
.launcher-project-more-menu {
|
||||
position: absolute;
|
||||
top: 38px;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
width: 164px;
|
||||
gap: 4px;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--platform-surface-border);
|
||||
border-radius: 10px;
|
||||
background: var(--platform-subpanel-fill);
|
||||
box-shadow: 0 16px 34px rgb(75 47 32 / 18%);
|
||||
}
|
||||
|
||||
.launcher-project-more-menu-drop-up {
|
||||
top: auto;
|
||||
bottom: 38px;
|
||||
}
|
||||
|
||||
.launcher-project-more-menu button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
min-height: 34px;
|
||||
gap: 8px;
|
||||
padding: 0 9px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--platform-text-strong);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.launcher-project-more-menu button:hover {
|
||||
background: var(--platform-nav-item-hover-fill);
|
||||
}
|
||||
|
||||
.launcher-project-more-menu button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.launcher-project-more-menu .launcher-project-remove-action {
|
||||
color: #a64735;
|
||||
}
|
||||
|
||||
.launcher-empty-projects {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 12px;
|
||||
min-height: 220px;
|
||||
padding: 44px;
|
||||
border: 1px dashed #d8dde5;
|
||||
border-radius: 8px;
|
||||
color: #6b7280;
|
||||
min-height: 260px;
|
||||
padding: 48px;
|
||||
color: var(--platform-text-soft);
|
||||
}
|
||||
|
||||
.launcher-empty-projects strong {
|
||||
@@ -2677,14 +2959,15 @@ textarea {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.launcher-project-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.launcher-page {
|
||||
width: min(100%, calc(100vw - 76px));
|
||||
}
|
||||
|
||||
.launcher-projects-page {
|
||||
width: calc(100vw - var(--launcher-sidebar-width));
|
||||
padding: 72px 18px 20px;
|
||||
}
|
||||
|
||||
.launcher-agent-chat-page > header,
|
||||
.launcher-agent-chat-main > header {
|
||||
align-items: stretch;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
COMMAND_PRIORITY_HIGH,
|
||||
createCommand,
|
||||
KEY_ENTER_COMMAND,
|
||||
type LexicalCommand,
|
||||
PASTE_COMMAND,
|
||||
} from 'lexical';
|
||||
@@ -20,15 +21,13 @@ import { Upload } from 'lucide-react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
import type { Draft, HomeAttachmentDraft } from '../../useHomeDraftStore';
|
||||
import {
|
||||
$createAttachmentNode,
|
||||
AttachmentNode,
|
||||
} from './attachmentNode';
|
||||
import { $createAttachmentNode, AttachmentNode } from './attachmentNode';
|
||||
|
||||
type RichInputAreaProps = {
|
||||
value: Draft;
|
||||
placeholder: string;
|
||||
onChange: (value: Draft) => void;
|
||||
onEnter: () => void;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
const INSERT_ATTACHMENTS_COMMAND: LexicalCommand<HomeAttachmentDraft[]> =
|
||||
@@ -104,9 +103,27 @@ function selectEditableEndWhenNeeded() {
|
||||
|
||||
function EditorPlugins({
|
||||
onChange,
|
||||
}: Pick<RichInputAreaProps, 'onChange'>) {
|
||||
onEnter,
|
||||
}: Pick<RichInputAreaProps, 'onChange' | 'onEnter'>) {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
editor.registerCommand(
|
||||
KEY_ENTER_COMMAND,
|
||||
(event) => {
|
||||
if (!event || event.shiftKey || event.isComposing) {
|
||||
return false;
|
||||
}
|
||||
event.preventDefault();
|
||||
onEnter();
|
||||
return true;
|
||||
},
|
||||
COMMAND_PRIORITY_HIGH,
|
||||
),
|
||||
[editor, onEnter],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
editor.registerCommand(
|
||||
@@ -239,7 +256,7 @@ export default function RichInputArea(props: RichInputAreaProps) {
|
||||
/>
|
||||
{props.children}
|
||||
</div>
|
||||
<EditorPlugins onChange={props.onChange} />
|
||||
<EditorPlugins onChange={props.onChange} onEnter={props.onEnter} />
|
||||
</LexicalComposer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,12 +5,10 @@ import {
|
||||
Plus,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import type {
|
||||
FormEvent,
|
||||
} from 'react';
|
||||
import { useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
import BRAND_ICON from '../../../../../packages/shared/src/icons/taonier-product-ip.png'
|
||||
import BRAND_ICON from '../../../../../packages/shared/src/icons/taonier-product-ip.png';
|
||||
import RichInputArea, { UploadButton } from './components/RichInputArea';
|
||||
import {
|
||||
richTextToAttachments,
|
||||
@@ -49,9 +47,10 @@ type HomeViewProps = {
|
||||
homeAgentModeItems: readonly HomeAgentModeItem[];
|
||||
recentProjectRows: readonly HomeProjectRow[];
|
||||
onCreateDraft: (draft: HomeDraft) => Promise<string>;
|
||||
onCreateDraftAutomatically: (draft: HomeDraft) => Promise<string>;
|
||||
onProjectsOpen: () => void;
|
||||
onProjectOpen: (path: string) => void;
|
||||
onGodotProjectOpen: () => void;
|
||||
onProjectPick: () => void;
|
||||
};
|
||||
|
||||
export default function HomeView({
|
||||
@@ -61,9 +60,10 @@ export default function HomeView({
|
||||
homeAgentModeItems,
|
||||
recentProjectRows,
|
||||
onCreateDraft,
|
||||
onCreateDraftAutomatically,
|
||||
onProjectsOpen,
|
||||
onProjectOpen,
|
||||
onGodotProjectOpen,
|
||||
onProjectPick,
|
||||
}: HomeViewProps) {
|
||||
const homeAgentMode = useLauncherHomeDraftStore((state) => state.mode);
|
||||
const homeRichText = useLauncherHomeDraftStore((state) => state.draft);
|
||||
@@ -72,6 +72,7 @@ export default function HomeView({
|
||||
(state) => state.setRichText,
|
||||
);
|
||||
const [homeCreationBusy, setHomeCreationBusy] = useState(false);
|
||||
const homeCreationBusyRef = useRef(false);
|
||||
const activeHomeMode =
|
||||
homeAgentModeItems.find((item) => item.mode === homeAgentMode) ??
|
||||
homeAgentModeItems[0];
|
||||
@@ -80,8 +81,13 @@ export default function HomeView({
|
||||
return null;
|
||||
}
|
||||
|
||||
async function handleHomeSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
async function createFromHome(
|
||||
createDraft: (draft: HomeDraft) => Promise<string>,
|
||||
pendingStatus: string,
|
||||
) {
|
||||
if (homeCreationBusyRef.current) {
|
||||
return;
|
||||
}
|
||||
const referencedAttachments = richTextToAttachments(homeRichText);
|
||||
const prompt = richTextToPrompt(homeRichText);
|
||||
if (!prompt) {
|
||||
@@ -91,11 +97,12 @@ export default function HomeView({
|
||||
);
|
||||
return;
|
||||
}
|
||||
homeCreationBusyRef.current = true;
|
||||
setHomeCreationBusy(true);
|
||||
onStatusChange('请选择项目目录');
|
||||
onStatusChange(pendingStatus);
|
||||
try {
|
||||
onStatusChange(
|
||||
await onCreateDraft({
|
||||
await createDraft({
|
||||
mode: homeAgentMode,
|
||||
prompt,
|
||||
attachments: referencedAttachments,
|
||||
@@ -104,10 +111,16 @@ export default function HomeView({
|
||||
} catch (error) {
|
||||
onStatusChange(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
homeCreationBusyRef.current = false;
|
||||
setHomeCreationBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleHomeSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
void createFromHome(onCreateDraft, '请选择项目目录');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="platform-theme platform-theme--light min-h-screen bg-(image:--platform-body-fill) text-(--platform-text-strong)">
|
||||
<section
|
||||
@@ -164,6 +177,9 @@ export default function HomeView({
|
||||
value={homeRichText}
|
||||
placeholder={activeHomeMode.placeholder}
|
||||
onChange={setHomeRichText}
|
||||
onEnter={() => {
|
||||
void createFromHome(onCreateDraftAutomatically, '正在创建工作区');
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-[1fr_auto] items-center gap-2.5 text-[12px] text-(--platform-text-soft)">
|
||||
<UploadButton />
|
||||
@@ -197,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)"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user