合并master并保留双侧最新决策记录
合入主站 AGC 请求头归属与项目命名链路改动。 保留本分支 Direct 过程卡决策记录。 保留主站登录归属、workspace 边界与 Native shell 决策记录。
This commit is contained in:
@@ -0,0 +1 @@
|
||||
你是项目命名助手。只根据用户输入提炼一个原创、简短、中文的项目名称;不要使用知名作品名、路径、URL、凭据、Markdown、引号或解释。输出必须有且只有一行纯文本,长度为 2 到 16 个字符。信息不足时输出:未命名游戏原型
|
||||
@@ -13,6 +13,70 @@ const UI_EDITOR_IMAGE_MAX_COUNT: usize = 100;
|
||||
// imageSrc 与签名地址只在本文件的客户端下载阶段存在,绝不进入 Agent observation。
|
||||
const AGENT_EDITOR_ASSET_LIBRARY_MAX_ITEMS: usize = 500;
|
||||
const AGENT_EDITOR_ASSET_ID_MAX_CHARS: usize = 512;
|
||||
const AUTOMATIC_PROJECT_NAME_MAX_PROMPT_CHARS: usize = 8_000;
|
||||
const AUTOMATIC_PROJECT_NAME_MAX_OUTPUT_TOKENS: u32 = 64;
|
||||
const AUTOMATIC_PROJECT_NAME_SYSTEM_PROMPT: &str =
|
||||
include_str!("../prompts/automatic-project-name.md");
|
||||
|
||||
fn is_chinese_project_name_character(value: char) -> bool {
|
||||
matches!(
|
||||
value,
|
||||
'\u{3400}'..='\u{4DBF}'
|
||||
| '\u{4E00}'..='\u{9FFF}'
|
||||
| '\u{F900}'..='\u{FAFF}'
|
||||
| '\u{20000}'..='\u{2FA1F}'
|
||||
| '\u{30000}'..='\u{323AF}'
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_suggested_project_name(value: &str) -> Option<String> {
|
||||
let value = value.trim();
|
||||
let character_count = value.chars().count();
|
||||
if !(2..=16).contains(&character_count) || !value.chars().all(is_chinese_project_name_character)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
normalize_game_creation_project_name(value).ok()
|
||||
}
|
||||
|
||||
pub(crate) fn build_automatic_project_name_prompt(prompt: &str) -> Result<String, String> {
|
||||
let prompt = prompt.trim();
|
||||
if prompt.is_empty() {
|
||||
return Err("首页创作需求为空,不能提炼项目名称".to_string());
|
||||
}
|
||||
Ok(prompt
|
||||
.chars()
|
||||
.take(AUTOMATIC_PROJECT_NAME_MAX_PROMPT_CHARS)
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn request_automatic_project_name(prompt: &str) -> Result<Option<String>, String> {
|
||||
let user_prompt = build_automatic_project_name_prompt(prompt)?;
|
||||
let app_config = load_game_creator_app_config()?;
|
||||
if app_config.agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER {
|
||||
let reply = crate::agent::direct_game_creator_home_codex_chat(
|
||||
AUTOMATIC_PROJECT_NAME_SYSTEM_PROMPT.trim().to_string(),
|
||||
user_prompt,
|
||||
)
|
||||
.await?;
|
||||
return Ok(normalize_suggested_project_name(reply.trim()));
|
||||
}
|
||||
let mut llm = app_config.llm.clone();
|
||||
llm.max_retries = 0;
|
||||
let client = build_game_creator_llm_client_from_llm_config(&llm, "llm")?;
|
||||
let request =
|
||||
LlmRunRequest::single_turn(AUTOMATIC_PROJECT_NAME_SYSTEM_PROMPT.trim(), user_prompt)
|
||||
.with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?)
|
||||
.with_model(llm.model.clone())
|
||||
.with_request_timeout_ms(llm.request_timeout_ms)
|
||||
.with_max_output_tokens(AUTOMATIC_PROJECT_NAME_MAX_OUTPUT_TOKENS)
|
||||
.with_web_search(false);
|
||||
let response = client
|
||||
.run(request)
|
||||
.await
|
||||
.map_err(|error| format!("自动项目命名失败:{error}"))?;
|
||||
Ok(normalize_suggested_project_name(response.text.trim()))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -284,7 +348,11 @@ fn automatic_local_game_projects_root(app: &tauri::AppHandle) -> Result<PathBuf,
|
||||
|
||||
pub(crate) fn create_automatic_local_game_project_at(
|
||||
projects_root: &Path,
|
||||
requested_name: Option<&str>,
|
||||
) -> Result<InitLocalProjectResult, String> {
|
||||
let requested_name = requested_name
|
||||
.map(normalize_game_creation_project_name)
|
||||
.transpose()?;
|
||||
if projects_root.as_os_str().is_empty() || !projects_root.is_absolute() {
|
||||
return Err("自动工作区根目录必须是绝对路径".to_string());
|
||||
}
|
||||
@@ -303,7 +371,9 @@ pub(crate) fn create_automatic_local_game_project_at(
|
||||
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_name = requested_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("GameAgent 项目 {short_id}"));
|
||||
let project_root = projects_root.join(format!("gameagent-{short_id}"));
|
||||
match fs::create_dir(&project_root) {
|
||||
Ok(()) => {
|
||||
@@ -341,8 +411,12 @@ pub(crate) fn create_automatic_local_game_project_at(
|
||||
#[tauri::command]
|
||||
pub(crate) fn create_automatic_local_game_project(
|
||||
app: tauri::AppHandle,
|
||||
name: Option<String>,
|
||||
) -> Result<InitLocalProjectResult, String> {
|
||||
create_automatic_local_game_project_at(&automatic_local_game_projects_root(&app)?)
|
||||
create_automatic_local_game_project_at(
|
||||
&automatic_local_game_projects_root(&app)?,
|
||||
name.as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -357,6 +431,40 @@ pub(crate) fn init_local_game_project(
|
||||
init_local_game_project_at(root, project_id.trim(), name.trim())
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct RenameLocalProjectResult {
|
||||
pub(crate) manifest: GameCreationAppManifest,
|
||||
pub(crate) revision: u64,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn rename_local_game_project(
|
||||
project_path: String,
|
||||
name: String,
|
||||
) -> Result<RenameLocalProjectResult, String> {
|
||||
let next_name = normalize_game_creation_project_name(name.trim())?;
|
||||
let root = validated_local_project_directory_path(project_path.trim())?;
|
||||
enforce_project_permission_policy(&root, "project.rename")?;
|
||||
let _lock = acquire_project_write_lock(&root, "project.rename")?;
|
||||
let current = read_existing_manifest_for_project(&root)?;
|
||||
if current.name == next_name {
|
||||
return Ok(RenameLocalProjectResult {
|
||||
manifest: current,
|
||||
revision: read_game_creator_agent_runtime_project_revision(&root)?.revision,
|
||||
});
|
||||
}
|
||||
|
||||
let manifest = mutate_manifest_at(&root, |manifest| {
|
||||
manifest.name = next_name;
|
||||
Ok(manifest.clone())
|
||||
})?;
|
||||
Ok(RenameLocalProjectResult {
|
||||
manifest,
|
||||
revision: read_game_creator_agent_runtime_project_revision(&root)?.revision,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn import_local_godot_project(
|
||||
project_path: String,
|
||||
@@ -589,6 +697,9 @@ pub(crate) fn validated_local_project_directory_path(
|
||||
if !path.is_absolute() {
|
||||
return Err("项目目录必须是绝对路径".to_string());
|
||||
}
|
||||
if project_path_has_control_chars(path) {
|
||||
return Err("项目目录不能包含控制字符".to_string());
|
||||
}
|
||||
if !path.exists() {
|
||||
return Err("项目目录不存在".to_string());
|
||||
}
|
||||
@@ -1842,6 +1953,15 @@ pub(crate) async fn check_game_creator_llm_config() -> Result<GameCreatorLlmConf
|
||||
.map_err(|error| format!("LLM 配置诊断任务意外终止:{error}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn suggest_automatic_project_name(
|
||||
prompt: String,
|
||||
) -> Result<Option<String>, String> {
|
||||
request_automatic_project_name(prompt.trim())
|
||||
.await
|
||||
.map_err(|error| redact_agent_runtime_error(Path::new("."), &error, 320))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn read_platform_account_session_generation() -> u64 {
|
||||
current_platform_session_generation()
|
||||
|
||||
@@ -2487,6 +2487,8 @@ fn main() {
|
||||
is_local_project_directory_non_empty,
|
||||
inspect_local_project_directory,
|
||||
pick_local_project_directory,
|
||||
rename_local_game_project,
|
||||
suggest_automatic_project_name,
|
||||
pick_local_file,
|
||||
pick_client_extension_file,
|
||||
pick_client_extension_directory,
|
||||
|
||||
@@ -7,6 +7,24 @@ const MANIFEST_LOCK_WAIT_MILLIS: u64 = 10;
|
||||
|
||||
static MANIFEST_LOCK_OPEN_GUARD: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
|
||||
pub(crate) const GAME_CREATION_PROJECT_NAME_MAX_CHARS: usize = 80;
|
||||
|
||||
pub(crate) fn normalize_game_creation_project_name(value: &str) -> Result<String, String> {
|
||||
let name = value.trim();
|
||||
if name.is_empty() {
|
||||
return Err("项目名称不能为空".to_string());
|
||||
}
|
||||
if name.chars().any(char::is_control) {
|
||||
return Err("项目名称不能包含控制字符".to_string());
|
||||
}
|
||||
if name.chars().count() > GAME_CREATION_PROJECT_NAME_MAX_CHARS {
|
||||
return Err(format!(
|
||||
"项目名称最多支持 {GAME_CREATION_PROJECT_NAME_MAX_CHARS} 个字符"
|
||||
));
|
||||
}
|
||||
Ok(name.to_string())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn godot_metadata_is_reparse_point(metadata: &fs::Metadata) -> bool {
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
@@ -424,9 +442,7 @@ pub(crate) fn init_local_game_project_at(
|
||||
if project_id.is_empty() {
|
||||
return Err("项目 ID 不能为空".to_string());
|
||||
}
|
||||
if name.is_empty() {
|
||||
return Err("项目名称不能为空".to_string());
|
||||
}
|
||||
let name = normalize_game_creation_project_name(name)?;
|
||||
|
||||
prepare_game_creator_project_root_for_read(root, true, "本地项目目录")?;
|
||||
for relative in ["game", "assets", "memory", "memory/agents", "exports"] {
|
||||
@@ -506,9 +522,7 @@ pub(crate) fn import_local_godot_project_at(
|
||||
if project_id.is_empty() {
|
||||
return Err("项目 ID 不能为空".to_string());
|
||||
}
|
||||
if name.is_empty() {
|
||||
return Err("项目名称不能为空".to_string());
|
||||
}
|
||||
let name = normalize_game_creation_project_name(name)?;
|
||||
|
||||
let manifest_path = root.join(".agent/manifest.json");
|
||||
if manifest_storage_exists(&manifest_path)? {
|
||||
|
||||
@@ -1499,12 +1499,14 @@ fn project_picker_uses_the_closest_existing_parent_for_a_suggested_new_path() {
|
||||
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)
|
||||
let first = create_automatic_local_game_project_at(&projects_root, None)
|
||||
.expect("create first automatic workspace");
|
||||
let second = create_automatic_local_game_project_at(&projects_root)
|
||||
let second = create_automatic_local_game_project_at(&projects_root, Some("自定义项目名"))
|
||||
.expect("create second automatic workspace");
|
||||
|
||||
assert_ne!(first.project_path, second.project_path);
|
||||
assert!(first.manifest.name.starts_with("GameAgent 项目 "));
|
||||
assert_eq!(second.manifest.name, "自定义项目名");
|
||||
for result in [first, second] {
|
||||
let root = PathBuf::from(&result.project_path);
|
||||
assert_eq!(root.parent(), Some(projects_root.as_path()));
|
||||
@@ -1514,12 +1516,156 @@ fn automatic_local_game_project_allocates_unique_initialized_workspaces() {
|
||||
.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();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_local_game_project_accepts_only_a_safe_custom_name() {
|
||||
let projects_root = unique_project_path();
|
||||
|
||||
let result = create_automatic_local_game_project_at(&projects_root, Some(" 星轨夜航 "))
|
||||
.expect("create named automatic workspace");
|
||||
assert_eq!(result.manifest.name, "星轨夜航");
|
||||
|
||||
for name in [
|
||||
" \n\t",
|
||||
"名称\n包含控制字符",
|
||||
&"长".repeat(GAME_CREATION_PROJECT_NAME_MAX_CHARS + 1),
|
||||
] {
|
||||
assert!(
|
||||
create_automatic_local_game_project_at(&projects_root, Some(name)).is_err(),
|
||||
"unsafe name must fail: {name:?}"
|
||||
);
|
||||
}
|
||||
|
||||
fs::remove_dir_all(projects_root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_local_game_project_updates_only_the_manifest_name() {
|
||||
let root = unique_project_path();
|
||||
let before =
|
||||
init_local_game_project_at(&root, "rename-project", "旧项目名").expect("project init");
|
||||
let revision = read_game_creator_agent_runtime_project_revision(&root).expect("read revision");
|
||||
let task_count = before.manifest.tasks.len();
|
||||
let asset_count = before.manifest.assets.len();
|
||||
|
||||
let renamed = rename_local_game_project(
|
||||
root.to_string_lossy().into_owned(),
|
||||
" 新项目名 ".to_string(),
|
||||
)
|
||||
.expect("rename project");
|
||||
|
||||
assert_eq!(renamed.manifest.name, "新项目名");
|
||||
assert_eq!(renamed.manifest.project_id, before.manifest.project_id);
|
||||
assert_eq!(renamed.manifest.tasks.len(), task_count);
|
||||
assert_eq!(renamed.manifest.assets.len(), asset_count);
|
||||
assert_eq!(renamed.revision, revision.revision);
|
||||
assert_eq!(
|
||||
renamed.manifest,
|
||||
read_manifest_for_project(&root).expect("reread manifest")
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_local_game_project_rejects_unsafe_names_and_uninitialized_projects() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "rename-invalid", "原项目名").expect("project init");
|
||||
|
||||
for name in [
|
||||
" \n\t".to_string(),
|
||||
"名称\n包含控制字符".to_string(),
|
||||
"长".repeat(GAME_CREATION_PROJECT_NAME_MAX_CHARS + 1),
|
||||
] {
|
||||
let error = rename_local_game_project(root.to_string_lossy().into_owned(), name)
|
||||
.expect_err("unsafe rename must fail");
|
||||
assert!(!error.is_empty());
|
||||
}
|
||||
assert_eq!(
|
||||
read_manifest_for_project(&root)
|
||||
.expect("reread manifest")
|
||||
.name,
|
||||
"原项目名"
|
||||
);
|
||||
|
||||
let empty_root = unique_project_path();
|
||||
fs::create_dir_all(&empty_root).expect("create empty root");
|
||||
assert!(rename_local_game_project(
|
||||
empty_root.to_string_lossy().into_owned(),
|
||||
"未初始化".to_string(),
|
||||
)
|
||||
.is_err());
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
fs::remove_dir_all(empty_root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_project_name_suggestions_are_normalized_fail_closed() {
|
||||
assert_eq!(
|
||||
normalize_suggested_project_name(" 星轨夜航 "),
|
||||
Some("星轨夜航".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_suggested_project_name("星河"),
|
||||
Some("星河".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_suggested_project_name("一二三四五六七八九十甲乙丙丁戊己"),
|
||||
Some("一二三四五六七八九十甲乙丙丁戊己".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_suggested_project_name("星轨夜航\n解释"),
|
||||
None,
|
||||
"multi-line suggestions must fail closed"
|
||||
);
|
||||
for value in [
|
||||
"",
|
||||
" \t\r\n",
|
||||
"名",
|
||||
"项目名:名",
|
||||
"名称\n控制字符",
|
||||
"**星轨夜航**",
|
||||
"https://example.com",
|
||||
"C:\\projects\\game",
|
||||
"Bearer sk-secret",
|
||||
"“星轨夜航”",
|
||||
"《星轨夜航》",
|
||||
"项目名称:星轨夜航",
|
||||
"星轨夜航2",
|
||||
"星轨Game",
|
||||
&"长".repeat(17),
|
||||
&"长".repeat(81),
|
||||
] {
|
||||
assert!(
|
||||
normalize_suggested_project_name(value).is_none(),
|
||||
"invalid suggestion: {value:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_project_name_prompt_is_bounded_and_uses_user_requirement_only() {
|
||||
let system_prompt = include_str!("../../prompts/automatic-project-name.md");
|
||||
assert!(system_prompt.contains("长度为 2 到 16 个字符"));
|
||||
assert!(system_prompt.contains("不要使用知名作品名、路径、URL、凭据、Markdown、引号或解释"));
|
||||
|
||||
let prompt = build_automatic_project_name_prompt("做一个在月球邮局送信的解谜游戏")
|
||||
.expect("build prompt");
|
||||
|
||||
assert_eq!(prompt, "做一个在月球邮局送信的解谜游戏");
|
||||
assert_eq!(
|
||||
build_automatic_project_name_prompt(&"长".repeat(8_001)).expect("bound prompt"),
|
||||
"长".repeat(8_000)
|
||||
);
|
||||
|
||||
assert!(build_automatic_project_name_prompt("").is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn automatic_local_game_project_rejects_symlinked_projects_root() {
|
||||
@@ -1531,7 +1677,7 @@ fn automatic_local_game_project_rejects_symlinked_projects_root() {
|
||||
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)
|
||||
let error = create_automatic_local_game_project_at(&projects_root, None)
|
||||
.expect_err("symlinked automatic workspace root must fail");
|
||||
|
||||
assert!(error.contains("不能包含符号链接"));
|
||||
|
||||
@@ -114,6 +114,11 @@ export interface InitLocalProjectResult {
|
||||
manifest: GameCreationAppManifest;
|
||||
}
|
||||
|
||||
export interface RenameLocalProjectResult {
|
||||
manifest: GameCreationAppManifest;
|
||||
revision: number;
|
||||
}
|
||||
|
||||
export interface LocalProjectDirectoryStatus {
|
||||
projectPath: string;
|
||||
exists: boolean;
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import {
|
||||
Check,
|
||||
CircleAlert,
|
||||
Ellipsis,
|
||||
FolderKanban,
|
||||
FolderOpen,
|
||||
FolderPlus,
|
||||
Gamepad2,
|
||||
PenLine,
|
||||
Search,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
type FormEvent,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { closeDialogOnEscape } from '../../app/dialogs';
|
||||
import type { RecentProjectRow } from './model';
|
||||
@@ -40,9 +48,11 @@ function projectStatusTone(project: RecentProjectRow) {
|
||||
function ProjectMoreMenu({
|
||||
project,
|
||||
recentProjects,
|
||||
onRename,
|
||||
}: {
|
||||
project: RecentProjectRow;
|
||||
recentProjects: RecentProjectsController;
|
||||
onRename: (project: RecentProjectRow) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [dropUp, setDropUp] = useState(false);
|
||||
@@ -118,6 +128,18 @@ function ProjectMoreMenu({
|
||||
className={`launcher-project-more-menu${dropUp ? ' launcher-project-more-menu-drop-up' : ''}`}
|
||||
role="menu"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={!project.canOpen}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onRename(project);
|
||||
}}
|
||||
>
|
||||
<PenLine size={15} aria-hidden="true" />
|
||||
重命名
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
@@ -159,6 +181,60 @@ export function ProjectsPage({
|
||||
}) {
|
||||
const rows = recentProjects.filteredProjectRows;
|
||||
const hasStoredProjects = recentProjects.projectRows.length > 0;
|
||||
const [renamingPath, setRenamingPath] = useState<string | null>(null);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
const [renameSaving, setRenameSaving] = useState(false);
|
||||
const [renameError, setRenameError] = useState<string | null>(null);
|
||||
const renameInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!renamingPath) {
|
||||
return;
|
||||
}
|
||||
renameInputRef.current?.focus();
|
||||
renameInputRef.current?.select();
|
||||
}, [renamingPath]);
|
||||
|
||||
function startRename(project: RecentProjectRow) {
|
||||
setRenamingPath(project.path);
|
||||
setRenameValue(project.name);
|
||||
setRenameError(null);
|
||||
}
|
||||
|
||||
function cancelRename() {
|
||||
setRenamingPath(null);
|
||||
setRenameValue('');
|
||||
setRenameSaving(false);
|
||||
setRenameError(null);
|
||||
}
|
||||
|
||||
async function submitRename(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!renamingPath || renameSaving) {
|
||||
return;
|
||||
}
|
||||
const nextName = renameValue.trim();
|
||||
if (!nextName) {
|
||||
setRenameError('项目名称不能为空');
|
||||
return;
|
||||
}
|
||||
if (nextName.length > 80) {
|
||||
setRenameError('项目名称最多支持 80 个字符');
|
||||
return;
|
||||
}
|
||||
setRenameSaving(true);
|
||||
setRenameError(null);
|
||||
try {
|
||||
await homeProject.renameProject(renamingPath, nextName);
|
||||
await recentProjects.refreshRecentWorkspace(renamingPath);
|
||||
cancelRename();
|
||||
} catch (error) {
|
||||
setRenameError(error instanceof Error ? error.message : '重命名项目失败');
|
||||
} finally {
|
||||
setRenameSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="launcher-page launcher-projects-page">
|
||||
<header className="launcher-projects-toolbar">
|
||||
@@ -227,47 +303,110 @@ export function ProjectsPage({
|
||||
</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" />
|
||||
)}
|
||||
rows.map((project) => {
|
||||
const renaming = renamingPath === project.path;
|
||||
return (
|
||||
<article key={project.path}>
|
||||
{renaming ? (
|
||||
<form
|
||||
className="launcher-project-rename"
|
||||
id={`project-rename-${project.path}`}
|
||||
onSubmit={submitRename}
|
||||
>
|
||||
<span className="launcher-project-kind-icon">
|
||||
{project.projectKind === 'godot' ? (
|
||||
<Gamepad2 size={18} aria-hidden="true" />
|
||||
) : (
|
||||
<FolderKanban size={18} aria-hidden="true" />
|
||||
)}
|
||||
</span>
|
||||
<div className="launcher-project-name-cell">
|
||||
<label htmlFor={`project-name-${project.path}`}>
|
||||
项目名称
|
||||
</label>
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
id={`project-name-${project.path}`}
|
||||
value={renameValue}
|
||||
maxLength={80}
|
||||
autoComplete="off"
|
||||
onChange={(event) =>
|
||||
setRenameValue(event.target.value)
|
||||
}
|
||||
/>
|
||||
<small title={project.path}>{project.path}</small>
|
||||
{renameError ? (
|
||||
<small
|
||||
className="launcher-project-rename-error"
|
||||
role="alert"
|
||||
>
|
||||
{renameError}
|
||||
</small>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<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-name-cell">
|
||||
<strong>{project.name}</strong>
|
||||
<small title={project.path}>{project.path}</small>
|
||||
<span
|
||||
className={`launcher-project-status launcher-project-status-${projectStatusTone(project)}`}
|
||||
>
|
||||
{projectStatusTone(project) === 'warning' ? (
|
||||
<CircleAlert size={14} aria-hidden="true" />
|
||||
) : null}
|
||||
{project.status}
|
||||
</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>
|
||||
))
|
||||
{renaming ? (
|
||||
<div
|
||||
className="launcher-project-rename-actions"
|
||||
aria-label="重命名操作"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
form={`project-rename-${project.path}`}
|
||||
disabled={renameSaving}
|
||||
>
|
||||
<Check size={15} aria-hidden="true" />
|
||||
{renameSaving ? '保存中' : '保存'}
|
||||
</button>
|
||||
<button type="button" onClick={cancelRename}>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<ProjectMoreMenu
|
||||
project={project}
|
||||
recentProjects={recentProjects}
|
||||
onRename={startRename}
|
||||
/>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
})
|
||||
) : hasStoredProjects ? (
|
||||
<div className="launcher-empty-projects">
|
||||
<Search size={26} aria-hidden="true" />
|
||||
|
||||
@@ -106,7 +106,11 @@ export function WorkspaceLauncherShell({
|
||||
manifest: current.manifest,
|
||||
source: 'initial',
|
||||
});
|
||||
}, [currentProjectContext?.createdAt, currentProjectContext?.projectPath]);
|
||||
}, [
|
||||
currentProjectContext?.createdAt,
|
||||
currentProjectContext?.projectName,
|
||||
currentProjectContext?.projectPath,
|
||||
]);
|
||||
|
||||
const applyManifestSnapshot = useCallback(
|
||||
(snapshot: ProjectManifestSnapshot) => {
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
LocalProjectFileResult,
|
||||
PendingNonEmptyProject,
|
||||
ProjectStartMode,
|
||||
RenameLocalProjectResult,
|
||||
TauriInvoke,
|
||||
UploadLocalAssetResult,
|
||||
} from '../../app/types';
|
||||
@@ -70,6 +71,21 @@ function createTextAttachmentFile(content: string) {
|
||||
return file;
|
||||
}
|
||||
|
||||
async function suggestAutomaticProjectName(
|
||||
invoke: TauriInvoke,
|
||||
draft: HomeDraft,
|
||||
) {
|
||||
try {
|
||||
return await invoke<string | null>('suggest_automatic_project_name', {
|
||||
prompt: draft.prompt,
|
||||
});
|
||||
} catch {
|
||||
// 项目命名是增强能力:配置缺失、Provider 失败或非法输出都回退默认名,
|
||||
// 不阻断用户真正请求的自动创建和首轮创作。
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function useHomeProjectCreation({
|
||||
setStatus,
|
||||
setLauncherView,
|
||||
@@ -140,7 +156,7 @@ export function useHomeProjectCreation({
|
||||
);
|
||||
const file = createTextAttachmentFile(result.content);
|
||||
|
||||
await createHomeDraftAutomatically(
|
||||
await createHomeDraftAutomaticallyWithOptions(
|
||||
{
|
||||
creationType: 'game',
|
||||
prompt: APPROVED_GDD_BUILD_PROMPT,
|
||||
@@ -152,6 +168,7 @@ export function useHomeProjectCreation({
|
||||
],
|
||||
},
|
||||
'direct-build',
|
||||
{ suggestName: false },
|
||||
);
|
||||
} finally {
|
||||
approvedGddStartInFlightRef.current = false;
|
||||
@@ -555,14 +572,31 @@ export function useHomeProjectCreation({
|
||||
async function createHomeDraftAutomatically(
|
||||
draft: HomeDraft,
|
||||
startMode: ProjectStartMode,
|
||||
) {
|
||||
return createHomeDraftAutomaticallyWithOptions(draft, startMode, {
|
||||
suggestName: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function createHomeDraftAutomaticallyWithOptions(
|
||||
draft: HomeDraft,
|
||||
startMode: ProjectStartMode,
|
||||
options: { suggestName: boolean },
|
||||
) {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) {
|
||||
throw new Error('需要在陶泥儿客户端内运行');
|
||||
}
|
||||
setStatus(options.suggestName ? '正在提炼项目名称' : '正在创建工作区');
|
||||
const suggestedName = options.suggestName
|
||||
? await suggestAutomaticProjectName(invoke, draft)
|
||||
: null;
|
||||
setStatus('正在创建工作区');
|
||||
const result = await invoke<InitLocalProjectResult>(
|
||||
'create_automatic_local_game_project',
|
||||
{
|
||||
name: suggestedName,
|
||||
},
|
||||
);
|
||||
try {
|
||||
await enterCreatedHomeProject(
|
||||
@@ -654,6 +688,31 @@ export function useHomeProjectCreation({
|
||||
}
|
||||
}
|
||||
|
||||
async function renameProject(nextProjectPath: string, nextName: string) {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) {
|
||||
throw new Error('需要在 Tauri App 内运行');
|
||||
}
|
||||
const result = await invoke<RenameLocalProjectResult>(
|
||||
'rename_local_game_project',
|
||||
{
|
||||
projectPath: nextProjectPath,
|
||||
name: nextName,
|
||||
},
|
||||
);
|
||||
setCurrentProjectContext((current) =>
|
||||
current && current.projectPath === nextProjectPath
|
||||
? {
|
||||
...current,
|
||||
projectName: result.manifest.name,
|
||||
manifest: result.manifest,
|
||||
projectRevision: result.revision,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
projectPath,
|
||||
setProjectPath,
|
||||
@@ -673,6 +732,7 @@ export function useHomeProjectCreation({
|
||||
createHomeDraft,
|
||||
createHomeDraftAutomatically,
|
||||
openProject,
|
||||
renameProject,
|
||||
pickAndOpenProject,
|
||||
pickAndCreateProject,
|
||||
confirmCreateInNonEmptyFolder,
|
||||
|
||||
@@ -30,6 +30,21 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
|
||||
useState(false);
|
||||
const [projectSearchQuery, setProjectSearchQuery] = useState('');
|
||||
|
||||
async function inspectRecentWorkspace(
|
||||
invoke: NonNullable<ReturnType<typeof resolveTauriInvoke>>,
|
||||
workspace: string,
|
||||
): Promise<[string, LocalProjectDirectoryStatus | null]> {
|
||||
try {
|
||||
const result = await invoke<LocalProjectDirectoryStatus>(
|
||||
'inspect_local_project_directory',
|
||||
{ projectPath: workspace },
|
||||
);
|
||||
return [workspace, result];
|
||||
} catch {
|
||||
return [workspace, null];
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke || recentWorkspaces.length === 0) {
|
||||
@@ -40,17 +55,9 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
|
||||
let disposed = false;
|
||||
setRecentWorkspaceRefreshing(true);
|
||||
void Promise.all(
|
||||
recentWorkspaces.map(async (workspace) => {
|
||||
try {
|
||||
const result = await invoke<LocalProjectDirectoryStatus>(
|
||||
'inspect_local_project_directory',
|
||||
{ projectPath: workspace },
|
||||
);
|
||||
return [workspace, result] as const;
|
||||
} catch {
|
||||
return [workspace, null] as const;
|
||||
}
|
||||
}),
|
||||
recentWorkspaces.map((workspace) =>
|
||||
inspectRecentWorkspace(invoke, workspace),
|
||||
),
|
||||
).then((entries) => {
|
||||
if (disposed) {
|
||||
return;
|
||||
@@ -68,6 +75,18 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
|
||||
setRecentWorkspaceRefreshKey((current) => current + 1);
|
||||
}
|
||||
|
||||
async function refreshRecentWorkspace(projectPath: string) {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) {
|
||||
return;
|
||||
}
|
||||
const [, status] = await inspectRecentWorkspace(invoke, projectPath);
|
||||
setRecentWorkspaceStatuses((current) => ({
|
||||
...current,
|
||||
[projectPath]: status,
|
||||
}));
|
||||
}
|
||||
|
||||
function handleRecentWorkspaceRemove(projectPath: string) {
|
||||
setRecentWorkspaces(removeRecentWorkspace(projectPath));
|
||||
setRecentWorkspaceStatuses((current) => {
|
||||
@@ -137,6 +156,7 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
|
||||
.filter((project) => project.canOpen)
|
||||
.slice(0, 3),
|
||||
rememberRecentWorkspace,
|
||||
refreshRecentWorkspace,
|
||||
handleRecentWorkspaceRemove,
|
||||
handleRevealProjectDirectory,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ArrowUp } from 'lucide-react';
|
||||
import { ArrowUp, Loader2 } from 'lucide-react';
|
||||
import type {
|
||||
ComponentProps,
|
||||
Dispatch,
|
||||
@@ -142,6 +142,7 @@ export function ProjectSupervisorView({
|
||||
: runtimePanelProps.controlBusy
|
||||
? '思考中'
|
||||
: '发送';
|
||||
const submitting = runtimePanelProps.controlBusy && !needsUserInput;
|
||||
return (
|
||||
<section
|
||||
className={`project-supervisor-surface${directCodex ? ' is-direct-codex' : ''}`}
|
||||
@@ -331,7 +332,11 @@ export function ProjectSupervisorView({
|
||||
title={submitLabel}
|
||||
disabled={runtimePanelProps.controlBusy || needsUserInput}
|
||||
>
|
||||
<ArrowUp size={16} aria-hidden="true" />
|
||||
{submitting ? (
|
||||
<Loader2 size={16} aria-hidden="true" className="animate-spin" />
|
||||
) : (
|
||||
<ArrowUp size={16} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
<small className="project-supervisor-workspace-status">
|
||||
|
||||
@@ -1169,7 +1169,7 @@ textarea {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
minmax(360px, 1fr) minmax(120px, 160px) minmax(150px, 190px)
|
||||
56px;
|
||||
104px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -1235,6 +1235,78 @@ textarea {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.launcher-project-table article > .launcher-project-rename {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.launcher-project-rename label {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.launcher-project-rename input {
|
||||
width: min(100%, 320px);
|
||||
min-width: 0;
|
||||
min-height: 30px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--platform-surface-border);
|
||||
border-radius: 7px;
|
||||
color: var(--platform-text-strong);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.launcher-project-rename input:focus-visible {
|
||||
border-color: var(--platform-warm-text);
|
||||
outline: 2px solid transparent;
|
||||
}
|
||||
|
||||
.launcher-project-rename-error {
|
||||
color: #a64735 !important;
|
||||
}
|
||||
|
||||
.launcher-project-rename-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 5px;
|
||||
justify-self: end;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.launcher-project-rename-actions button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 28px;
|
||||
gap: 3px;
|
||||
padding: 0 7px;
|
||||
border-radius: 7px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.launcher-project-rename-actions button:first-child {
|
||||
border: 0;
|
||||
background: var(--platform-warm-text);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.launcher-project-rename-actions button:last-child {
|
||||
border: 1px solid var(--platform-surface-border);
|
||||
background: transparent;
|
||||
color: var(--platform-text-strong);
|
||||
}
|
||||
|
||||
.launcher-project-table article > .launcher-project-row-main:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.62;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
FolderOpen,
|
||||
Gamepad2,
|
||||
Image,
|
||||
Loader2,
|
||||
type LucideIcon,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
@@ -248,7 +249,15 @@ export default function HomeView({
|
||||
}
|
||||
disabled={homeCreationBusy}
|
||||
>
|
||||
<ArrowUp size={16} aria-hidden="true" />
|
||||
{homeCreationBusy ? (
|
||||
<Loader2
|
||||
size={16}
|
||||
aria-hidden="true"
|
||||
className="animate-spin"
|
||||
/>
|
||||
) : (
|
||||
<ArrowUp size={16} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</RichInputArea>
|
||||
|
||||
@@ -1434,6 +1434,9 @@ export function registerHomeProjectCreationTests() {
|
||||
([command]) => command === 'create_automatic_local_game_project',
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(invoke).toHaveBeenCalledWith('create_automatic_local_game_project', {
|
||||
name: null,
|
||||
});
|
||||
expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_direct_codex', {
|
||||
projectPath: automaticProjectPath,
|
||||
prompt: '你好,今天多少号',
|
||||
@@ -1484,6 +1487,12 @@ export function registerHomeProjectCreationTests() {
|
||||
manifestPath: `${automaticProjectPath}\\.agent\\manifest.json`,
|
||||
};
|
||||
}
|
||||
if (command === 'suggest_automatic_project_name') {
|
||||
expect(args).toEqual({
|
||||
prompt: '按这个角色做游戏',
|
||||
});
|
||||
return '角色参考游戏';
|
||||
}
|
||||
if (command === 'chat_with_game_creator_direct_codex') {
|
||||
return '附件已经进入当前项目。';
|
||||
}
|
||||
@@ -1518,6 +1527,9 @@ export function registerHomeProjectCreationTests() {
|
||||
fireEvent.click(screen.getByRole('button', { name: '开启创作' }));
|
||||
|
||||
expect(await screen.findByLabelText('项目开发工作台')).not.toBeNull();
|
||||
expect(invoke).toHaveBeenCalledWith('create_automatic_local_game_project', {
|
||||
name: '角色参考游戏',
|
||||
});
|
||||
expect(invoke).toHaveBeenCalledWith('upload_local_asset', {
|
||||
projectPath: automaticProjectPath,
|
||||
fileName: '角色参考.png',
|
||||
@@ -2751,6 +2763,111 @@ export function registerRecentProjectsTests() {
|
||||
expect(window.localStorage.length).toBe(0);
|
||||
});
|
||||
|
||||
it('renames a recent project and refreshes the inspected manifest name', async () => {
|
||||
const projectPath = '/tmp/rename-game';
|
||||
const renamedManifest = createGameCreationAppManifest(
|
||||
'rename-game',
|
||||
'星轨夜航',
|
||||
);
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'inspect_local_project_directory') {
|
||||
const renamed =
|
||||
invoke.mock.calls.filter(
|
||||
([candidate]) => candidate === 'rename_local_game_project',
|
||||
).length > 0;
|
||||
return {
|
||||
projectPath,
|
||||
exists: true,
|
||||
isDirectory: true,
|
||||
isGameCreatorProject: true,
|
||||
projectName: renamed ? '星轨夜航' : '旧项目名',
|
||||
recentRunStatus: null,
|
||||
recentRunStopReason: null,
|
||||
};
|
||||
}
|
||||
if (command === 'rename_local_game_project') {
|
||||
expect(args).toEqual({
|
||||
projectPath,
|
||||
name: '星轨夜航',
|
||||
});
|
||||
return { manifest: renamedManifest, revision: 3 };
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
window.localStorage.setItem(
|
||||
'genarrative-ai-game-creator.recent-workspaces.v1',
|
||||
JSON.stringify([projectPath]),
|
||||
);
|
||||
renderLauncherProjectsAt('/?launcher');
|
||||
|
||||
expect(await screen.findByText('旧项目名')).not.toBeNull();
|
||||
fireEvent.click(
|
||||
within(openProjectMoreMenu('旧项目名')).getByRole('menuitem', {
|
||||
name: '重命名',
|
||||
}),
|
||||
);
|
||||
const nameInput = screen.getByLabelText('项目名称');
|
||||
fireEvent.change(nameInput, { target: { value: '星轨夜航' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存' }));
|
||||
|
||||
expect(await screen.findByText('星轨夜航')).not.toBeNull();
|
||||
expect(screen.queryByText('旧项目名')).toBeNull();
|
||||
expect(invoke).toHaveBeenCalledWith('rename_local_game_project', {
|
||||
projectPath,
|
||||
name: '星轨夜航',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps rename editing open when the native command fails', async () => {
|
||||
const projectPath = '/tmp/rename-failed-game';
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'inspect_local_project_directory') {
|
||||
return {
|
||||
projectPath,
|
||||
exists: true,
|
||||
isDirectory: true,
|
||||
isGameCreatorProject: true,
|
||||
projectName: '原项目',
|
||||
recentRunStatus: null,
|
||||
recentRunStopReason: null,
|
||||
};
|
||||
}
|
||||
if (command === 'rename_local_game_project') {
|
||||
expect(args?.name).toBe('失败后的名称');
|
||||
throw new Error('manifest 写入失败');
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
window.localStorage.setItem(
|
||||
'genarrative-ai-game-creator.recent-workspaces.v1',
|
||||
JSON.stringify([projectPath]),
|
||||
);
|
||||
renderLauncherProjectsAt('/?launcher');
|
||||
|
||||
expect(await screen.findByText('原项目')).not.toBeNull();
|
||||
fireEvent.click(
|
||||
within(openProjectMoreMenu('原项目')).getByRole('menuitem', {
|
||||
name: '重命名',
|
||||
}),
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText('项目名称'), {
|
||||
target: { value: '失败后的名称' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存' }));
|
||||
|
||||
expect(await screen.findByRole('alert')).not.toBeNull();
|
||||
expect(screen.getByRole('alert').textContent).toBe('manifest 写入失败');
|
||||
expect((screen.getByLabelText('项目名称') as HTMLInputElement).value).toBe(
|
||||
'失败后的名称',
|
||||
);
|
||||
});
|
||||
|
||||
it('opens a recent launcher project directory in the system file manager', async () => {
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
|
||||
Reference in New Issue
Block a user