实现项目名称修改与自动提炼
- 新增项目重命名 Tauri 命令、名称校验和 manifest 写入边界 - 项目页支持行内重命名并同步当前上下文、窗口标题和最近项目 - 首页自动建项前增加单次 LLM 命名提炼和失败回退 - 补充共享命令契约与 Rust、前端回归测试
This commit is contained in:
@@ -13,6 +13,117 @@ 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_ATTACHMENTS: usize = 20;
|
||||
const AUTOMATIC_PROJECT_NAME_MAX_OUTPUT_TOKENS: u32 = 64;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub(crate) struct AutomaticProjectNameAttachment {
|
||||
pub(crate) name: String,
|
||||
pub(crate) media_type: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_suggested_project_name(value: &str) -> Option<String> {
|
||||
let value = value.trim();
|
||||
let mut lines = value.lines();
|
||||
let first_line = lines.next()?.trim();
|
||||
if first_line.is_empty() || lines.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
let first_line = first_line
|
||||
.strip_prefix("项目名称:")
|
||||
.or_else(|| first_line.strip_prefix("项目名称:"))
|
||||
.or_else(|| first_line.strip_prefix("项目名:"))
|
||||
.or_else(|| first_line.strip_prefix("项目名:"))
|
||||
.unwrap_or(first_line)
|
||||
.trim();
|
||||
let bytes = first_line.as_bytes();
|
||||
let stripped = if bytes.len() >= 2 {
|
||||
let first = first_line.chars().next()?;
|
||||
let last = first_line.chars().last()?;
|
||||
if (first == '"' && last == '"')
|
||||
|| (first == '“' && last == '”')
|
||||
|| (first == '\'' && last == '\'')
|
||||
|| (first == '`' && last == '`')
|
||||
{
|
||||
first_line[first.len_utf8()..first_line.len() - last.len_utf8()].trim()
|
||||
} else {
|
||||
first_line
|
||||
}
|
||||
} else {
|
||||
first_line
|
||||
};
|
||||
if stripped.chars().count() < 2 {
|
||||
return None;
|
||||
}
|
||||
normalize_game_creation_project_name(stripped).ok()
|
||||
}
|
||||
|
||||
pub(crate) fn build_automatic_project_name_prompt(
|
||||
prompt: &str,
|
||||
attachments: &[AutomaticProjectNameAttachment],
|
||||
) -> Result<String, String> {
|
||||
let prompt = prompt.trim();
|
||||
let mut attachment_lines = Vec::new();
|
||||
for attachment in attachments
|
||||
.iter()
|
||||
.take(AUTOMATIC_PROJECT_NAME_MAX_ATTACHMENTS)
|
||||
{
|
||||
let name = attachment.name.trim();
|
||||
if name.is_empty() || name.chars().any(char::is_control) {
|
||||
continue;
|
||||
}
|
||||
let name: String = name.chars().take(160).collect();
|
||||
let media_type = attachment
|
||||
.media_type
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty() && !value.chars().any(char::is_control))
|
||||
.map(|value| value.chars().take(80).collect::<String>());
|
||||
attachment_lines.push(match media_type {
|
||||
Some(media_type) => format!("- 附件:{name}({media_type})"),
|
||||
None => format!("- 附件:{name}"),
|
||||
});
|
||||
}
|
||||
if prompt.is_empty() && attachment_lines.is_empty() {
|
||||
return Err("首页创作需求为空,不能提炼项目名称".to_string());
|
||||
}
|
||||
let bounded_prompt: String = prompt
|
||||
.chars()
|
||||
.take(AUTOMATIC_PROJECT_NAME_MAX_PROMPT_CHARS)
|
||||
.collect();
|
||||
Ok(match attachment_lines.is_empty() {
|
||||
true => bounded_prompt,
|
||||
false if prompt.is_empty() => attachment_lines.join("\n"),
|
||||
false => format!("{bounded_prompt}\n{}", attachment_lines.join("\n")),
|
||||
})
|
||||
}
|
||||
|
||||
async fn request_automatic_project_name(
|
||||
prompt: &str,
|
||||
attachments: &[AutomaticProjectNameAttachment],
|
||||
) -> Result<Option<String>, String> {
|
||||
let user_prompt = build_automatic_project_name_prompt(prompt, attachments)?;
|
||||
let app_config = load_game_creator_app_config()?;
|
||||
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(
|
||||
"你是项目命名助手。只根据用户输入提炼一个原创、简短、中文的项目名称;不要使用知名作品名、路径、URL、凭据、Markdown、引号或解释。输出必须有且只有一行纯文本,长度为 2 到 16 个字符。信息不足时输出:未命名游戏原型",
|
||||
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 +395,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 +418,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 +458,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 +478,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 +744,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());
|
||||
}
|
||||
@@ -1824,6 +1982,16 @@ pub(crate) fn check_game_creator_llm_config() -> GameCreatorLlmConfigStatus {
|
||||
check_game_creator_llm_config_from_config()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn suggest_automatic_project_name(
|
||||
prompt: String,
|
||||
attachments: Option<Vec<AutomaticProjectNameAttachment>>,
|
||||
) -> Result<Option<String>, String> {
|
||||
request_automatic_project_name(prompt.trim(), attachments.as_deref().unwrap_or_default())
|
||||
.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()
|
||||
|
||||
@@ -2401,6 +2401,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,144 @@ 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("星轨夜航\n解释"),
|
||||
None,
|
||||
"multi-line suggestions must fail closed"
|
||||
);
|
||||
for value in [
|
||||
"",
|
||||
" \t\r\n",
|
||||
"名",
|
||||
"项目名:名",
|
||||
"名称\n控制字符",
|
||||
&"长".repeat(81),
|
||||
] {
|
||||
assert!(
|
||||
normalize_suggested_project_name(value).is_none(),
|
||||
"invalid suggestion: {value:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_project_name_prompt_is_bounded_and_uses_attachment_metadata_only() {
|
||||
let prompt = build_automatic_project_name_prompt(
|
||||
"做一个在月球邮局送信的解谜游戏",
|
||||
&[
|
||||
AutomaticProjectNameAttachment {
|
||||
name: " 月球邮局参考.png ".to_string(),
|
||||
media_type: Some("image/png".to_string()),
|
||||
},
|
||||
AutomaticProjectNameAttachment {
|
||||
name: "bad\nname".to_string(),
|
||||
media_type: Some("image/png".to_string()),
|
||||
},
|
||||
],
|
||||
)
|
||||
.expect("build prompt");
|
||||
|
||||
assert!(prompt.contains("做一个在月球邮局送信的解谜游戏"));
|
||||
assert!(prompt.contains("附件:月球邮局参考.png(image/png)"));
|
||||
assert!(!prompt.contains("bad\nname"));
|
||||
|
||||
assert!(build_automatic_project_name_prompt("", &[]).is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn automatic_local_game_project_rejects_symlinked_projects_root() {
|
||||
@@ -1531,7 +1665,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,25 @@ 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,
|
||||
attachments: draft.attachments.map((attachment) => ({
|
||||
name: attachment.file.name,
|
||||
mediaType: attachment.file.type || null,
|
||||
})),
|
||||
});
|
||||
} catch {
|
||||
// 项目命名是增强能力:配置缺失、Provider 失败或非法输出都回退默认名,
|
||||
// 不阻断用户真正请求的自动创建和首轮创作。
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function useHomeProjectCreation({
|
||||
setStatus,
|
||||
setLauncherView,
|
||||
@@ -140,7 +160,7 @@ export function useHomeProjectCreation({
|
||||
);
|
||||
const file = createTextAttachmentFile(result.content);
|
||||
|
||||
await createHomeDraftAutomatically(
|
||||
await createHomeDraftAutomaticallyWithOptions(
|
||||
{
|
||||
creationType: 'game',
|
||||
prompt: APPROVED_GDD_BUILD_PROMPT,
|
||||
@@ -152,6 +172,7 @@ export function useHomeProjectCreation({
|
||||
],
|
||||
},
|
||||
'direct-build',
|
||||
{ suggestName: false },
|
||||
);
|
||||
} finally {
|
||||
approvedGddStartInFlightRef.current = false;
|
||||
@@ -555,14 +576,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 +692,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 +736,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,
|
||||
};
|
||||
|
||||
@@ -1168,7 +1168,7 @@ textarea {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
minmax(360px, 1fr) minmax(120px, 160px) minmax(150px, 190px)
|
||||
56px;
|
||||
104px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -1234,6 +1234,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;
|
||||
|
||||
@@ -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,18 @@ export function registerHomeProjectCreationTests() {
|
||||
manifestPath: `${automaticProjectPath}\\.agent\\manifest.json`,
|
||||
};
|
||||
}
|
||||
if (command === 'suggest_automatic_project_name') {
|
||||
expect(args).toEqual({
|
||||
prompt: '按这个角色做游戏',
|
||||
attachments: [
|
||||
{
|
||||
name: '角色参考.png',
|
||||
mediaType: 'image/png',
|
||||
},
|
||||
],
|
||||
});
|
||||
return '角色参考游戏';
|
||||
}
|
||||
if (command === 'chat_with_game_creator_direct_codex') {
|
||||
return '附件已经进入当前项目。';
|
||||
}
|
||||
@@ -1518,6 +1533,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',
|
||||
@@ -2755,6 +2773,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>) => {
|
||||
|
||||
@@ -19,7 +19,7 @@ describe('AI 游戏创作 App 共享契约', () => {
|
||||
it('keeps command permissions explicit', () => {
|
||||
const commandIds = GAME_CREATION_APP_COMMANDS.map((command) => command.id);
|
||||
|
||||
expect(GAME_CREATION_APP_COMMANDS).toHaveLength(63);
|
||||
expect(GAME_CREATION_APP_COMMANDS).toHaveLength(64);
|
||||
expect(commandIds).toContain('project.git_inspect');
|
||||
expect(commandIds).toContain('project.git_commit');
|
||||
expect(commandIds).toContain('project.patchset');
|
||||
@@ -60,6 +60,11 @@ describe('AI 游戏创作 App 共享契约', () => {
|
||||
(command) => command.id === 'command.run_limited',
|
||||
)?.permission,
|
||||
).toBe('confirm');
|
||||
expect(
|
||||
GAME_CREATION_APP_COMMANDS.find(
|
||||
(command) => command.id === 'project.rename',
|
||||
)?.permission,
|
||||
).toBe('confirm');
|
||||
expect(
|
||||
GAME_CREATION_APP_COMMANDS.find(
|
||||
(command) => command.id === 'command.exec',
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface GameCreationAppCommandDescriptor {
|
||||
export const GAME_CREATION_APP_COMMANDS = [
|
||||
{ id: 'help.show', permission: 'auto' },
|
||||
{ id: 'project.create', permission: 'confirm' },
|
||||
{ id: 'project.rename', permission: 'confirm' },
|
||||
{ id: 'project.status', permission: 'auto' },
|
||||
{ id: 'project.index', permission: 'auto' },
|
||||
{ id: 'project.checkpoint', permission: 'confirm' },
|
||||
|
||||
@@ -21,9 +21,10 @@ pub struct GameCreationAppCommandDescriptor {
|
||||
pub permission: GameCreationAppPermission,
|
||||
}
|
||||
|
||||
pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 63] = [
|
||||
pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 64] = [
|
||||
command("help.show", GameCreationAppPermission::Auto),
|
||||
command("project.create", GameCreationAppPermission::Confirm),
|
||||
command("project.rename", GameCreationAppPermission::Confirm),
|
||||
command("project.status", GameCreationAppPermission::Auto),
|
||||
command("project.index", GameCreationAppPermission::Auto),
|
||||
command("project.checkpoint", GameCreationAppPermission::Confirm),
|
||||
@@ -965,7 +966,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn command_contract_keeps_expected_permissions() {
|
||||
assert_eq!(GAME_CREATION_APP_COMMANDS.len(), 63);
|
||||
assert_eq!(GAME_CREATION_APP_COMMANDS.len(), 64);
|
||||
|
||||
let command_ids = GAME_CREATION_APP_COMMANDS
|
||||
.iter()
|
||||
@@ -1018,6 +1019,15 @@ mod tests {
|
||||
.expect("command should exist");
|
||||
assert_eq!(command.permission, GameCreationAppPermission::Confirm);
|
||||
|
||||
let project_rename = GAME_CREATION_APP_COMMANDS
|
||||
.iter()
|
||||
.find(|command| command.id == "project.rename")
|
||||
.expect("project.rename should exist");
|
||||
assert_eq!(
|
||||
project_rename.permission,
|
||||
GameCreationAppPermission::Confirm
|
||||
);
|
||||
|
||||
let command_exec = GAME_CREATION_APP_COMMANDS
|
||||
.iter()
|
||||
.find(|command| command.id == "command.exec")
|
||||
|
||||
Reference in New Issue
Block a user