Skip to content

Commit b2734d5

Browse files
committed
fix(build-run): 统一命令对话框的继承与校验逻辑
将 Build/Run 命令对话框的草稿处理抽离为独立辅助模块, 统一默认值、配置克隆、命令有效性判断与持久化前的标准化逻辑。 子 worktree 在“覆盖当前节点”和“继承父项目”之间切换时, 会同步对齐草稿内容;保存到父项目时会清理当前节点的覆盖配置, 从而真正恢复对父项目命令的继承行为。 同时将执行失败和保存失败从原生 alert 改为应用内提示弹窗, 避免打断当前弹窗的焦点与交互流程,并补充对应单元测试。 补充修复: - 将 hasBuildRunCommand 声明为类型守卫,修复 resolveEffectiveBuildRunCommand 无法通过 web 类型检查的问题。 产品层面: - Build/Run 配置面板在 worktree/父项目之间切换时行为更一致; - 恢复继承与错误提示更稳定,减少误保存和交互中断。 Signed-off-by: Lulu <58587930+lulu-sk@users.noreply.github.com>
1 parent cf085a0 commit b2734d5

File tree

3 files changed

+384
-101
lines changed

3 files changed

+384
-101
lines changed

web/src/App.tsx

Lines changed: 179 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,14 @@ import {
105105
import { getCachedThemeSetting, useThemeController, writeThemeSettingCache, type ThemeMode, type ThemeSetting } from "@/lib/theme";
106106
import { loadHiddenProjectIds, loadShowHiddenProjects, saveHiddenProjectIds, saveShowHiddenProjects } from "@/lib/projects-hidden";
107107
import { loadConsoleSession, saveConsoleSession, type PersistedConsoleTab } from "@/lib/console-session";
108+
import {
109+
cloneBuildRunCommandConfig,
110+
createEmptyBuildRunCommandConfig,
111+
hasBuildRunCommand,
112+
normalizeBuildRunCommandDraft,
113+
removeBuildRunCommandConfig,
114+
upsertBuildRunCommandConfig,
115+
} from "@/lib/build-run-config";
108116
import {
109117
clearWorktreeCreateTransientPrefs,
110118
loadWorktreeCreatePrefs,
@@ -558,15 +566,15 @@ export default function CodexFlowManagerUI() {
558566
const [buildRunCfgByDirKey, setBuildRunCfgByDirKey] = useState<Record<string, DirBuildRunConfig | null>>({});
559567
const buildRunCfgByDirKeyRef = useRef<Record<string, DirBuildRunConfig | null>>({});
560568
const [dirDrag, setDirDrag] = useState<{ draggingId: string; overId?: string; position?: "before" | "after" | "asChild" | "root-end" } | null>(null);
561-
const [buildRunDialog, setBuildRunDialog] = useState<BuildRunDialogState>(() => ({
562-
open: false,
563-
action: "build",
564-
projectId: "",
565-
saveScope: "self",
566-
parentProjectId: undefined,
567-
draft: { mode: "simple", commandText: "", cwd: "", env: [], backend: { kind: "system" } } as any,
568-
advanced: false,
569-
}));
569+
const [buildRunDialog, setBuildRunDialog] = useState<BuildRunDialogState>(() => ({
570+
open: false,
571+
action: "build",
572+
projectId: "",
573+
saveScope: "self",
574+
parentProjectId: undefined,
575+
draft: createEmptyBuildRunCommandConfig(),
576+
advanced: false,
577+
}));
570578
const [dirLabelDialog, setDirLabelDialog] = useState<DirLabelDialogState>(() => ({ open: false, projectId: "", draft: "" }));
571579
const [gitWorktreeAutoCommitEnabled, setGitWorktreeAutoCommitEnabled] = useState<boolean>(true);
572580
const [gitWorktreeCopyRulesOnCreate, setGitWorktreeCopyRulesOnCreate] = useState<boolean>(true);
@@ -4752,31 +4760,98 @@ export default function CodexFlowManagerUI() {
47524760
* - 先读自身配置
47534761
* - 若自身无配置且为子节点,则继承父节点配置
47544762
*/
4755-
const resolveEffectiveBuildRunCommand = useCallback(async (project: Project, action: BuildRunAction): Promise<{
4756-
effective: BuildRunCommandConfig | null;
4763+
const resolveEffectiveBuildRunCommand = useCallback(async (project: Project, action: BuildRunAction): Promise<{
4764+
effective: BuildRunCommandConfig | null;
47574765
inherited: boolean;
47584766
parentProjectId?: string;
47594767
defaultSaveScope: "self" | "parent";
4760-
}> => {
4761-
const selfCfg = await ensureBuildRunConfigLoaded(project.winPath);
4762-
const selfCmd = (selfCfg as any)?.[action] as BuildRunCommandConfig | undefined;
4763-
if (selfCmd) return { effective: selfCmd, inherited: false, defaultSaveScope: "self" };
4764-
4765-
const parentId = String(dirTreeStore.parentById[project.id] || "").trim();
4766-
if (parentId) {
4767-
const parent = projectsRef.current.find((x) => x.id === parentId) || null;
4768-
if (parent) {
4769-
const parentCfg = await ensureBuildRunConfigLoaded(parent.winPath);
4770-
const parentCmd = (parentCfg as any)?.[action] as BuildRunCommandConfig | undefined;
4771-
if (parentCmd) return { effective: parentCmd, inherited: true, parentProjectId: parentId, defaultSaveScope: "parent" };
4772-
return { effective: null, inherited: false, parentProjectId: parentId, defaultSaveScope: "parent" };
4773-
}
4774-
}
4775-
return { effective: null, inherited: false, defaultSaveScope: "self" };
4776-
}, [dirTreeStore.parentById, ensureBuildRunConfigLoaded]);
4777-
4778-
/**
4779-
* 触发 Build/Run:
4768+
}> => {
4769+
const selfCfg = await ensureBuildRunConfigLoaded(project.winPath);
4770+
const selfCmd = (selfCfg as any)?.[action] as BuildRunCommandConfig | undefined;
4771+
if (hasBuildRunCommand(selfCmd)) return { effective: selfCmd, inherited: false, defaultSaveScope: "self" };
4772+
4773+
const parentId = String(dirTreeStore.parentById[project.id] || "").trim();
4774+
if (parentId) {
4775+
const parent = projectsRef.current.find((x) => x.id === parentId) || null;
4776+
if (parent) {
4777+
const parentCfg = await ensureBuildRunConfigLoaded(parent.winPath);
4778+
const parentCmd = (parentCfg as any)?.[action] as BuildRunCommandConfig | undefined;
4779+
if (hasBuildRunCommand(parentCmd)) return { effective: parentCmd, inherited: true, parentProjectId: parentId, defaultSaveScope: "parent" };
4780+
return { effective: null, inherited: false, parentProjectId: parentId, defaultSaveScope: "parent" };
4781+
}
4782+
}
4783+
return { effective: null, inherited: false, defaultSaveScope: "self" };
4784+
}, [dirTreeStore.parentById, ensureBuildRunConfigLoaded]);
4785+
4786+
/**
4787+
* 中文说明:统一使用应用内提示弹窗展示 Build/Run 相关错误,避免原生 alert 破坏弹窗交互与焦点状态。
4788+
*/
4789+
const showBuildRunNotice = useCallback((action: BuildRunAction, message: string) => {
4790+
const text = String(message || "").trim();
4791+
if (!text) return;
4792+
setNoticeDialog({
4793+
open: true,
4794+
title: action === "build"
4795+
? (t("projects:buildCommandTitle", "配置 Build 命令") as string)
4796+
: (t("projects:runCommandTitle", "配置 Run 命令") as string),
4797+
message: text,
4798+
});
4799+
}, [t]);
4800+
4801+
/**
4802+
* 中文说明:切换 Build/Run 配置保存范围,并在切回“继承父项目”时同步对齐草稿内容。
4803+
*/
4804+
const switchBuildRunDialogSaveScope = useCallback(async (nextScope: "self" | "parent") => {
4805+
const snapshot = buildRunDialog;
4806+
if (!snapshot.open) return;
4807+
if (snapshot.saveScope === nextScope) return;
4808+
4809+
const target = projectsRef.current.find((item) => item.id === snapshot.projectId) || null;
4810+
if (!target) return;
4811+
4812+
const parentIdFromTree = String(dirTreeStore.parentById[target.id] || "").trim();
4813+
const parentId = String(snapshot.parentProjectId || parentIdFromTree || "").trim();
4814+
const parent = parentId ? (projectsRef.current.find((item) => item.id === parentId) || null) : null;
4815+
4816+
if (nextScope === "parent") {
4817+
if (!parentId || !parent) return;
4818+
const parentCfg = await ensureBuildRunConfigLoaded(parent.winPath);
4819+
const parentCmd = (parentCfg as any)?.[snapshot.action] as BuildRunCommandConfig | undefined;
4820+
const nextDraft = hasBuildRunCommand(parentCmd)
4821+
? cloneBuildRunCommandConfig(parentCmd)
4822+
: createEmptyBuildRunCommandConfig();
4823+
setBuildRunDialog((prev) => {
4824+
if (!prev.open || prev.projectId !== snapshot.projectId || prev.action !== snapshot.action)
4825+
return prev;
4826+
return {
4827+
...prev,
4828+
saveScope: "parent",
4829+
parentProjectId: parentId,
4830+
draft: nextDraft,
4831+
advanced: nextDraft.mode === "advanced",
4832+
};
4833+
});
4834+
return;
4835+
}
4836+
4837+
const selfCfg = await ensureBuildRunConfigLoaded(target.winPath);
4838+
const selfCmd = (selfCfg as any)?.[snapshot.action] as BuildRunCommandConfig | undefined;
4839+
setBuildRunDialog((prev) => {
4840+
if (!prev.open || prev.projectId !== snapshot.projectId || prev.action !== snapshot.action)
4841+
return prev;
4842+
const nextDraft = hasBuildRunCommand(selfCmd) ? cloneBuildRunCommandConfig(selfCmd) : prev.draft;
4843+
return {
4844+
...prev,
4845+
saveScope: "self",
4846+
parentProjectId: parentId || prev.parentProjectId,
4847+
draft: nextDraft,
4848+
advanced: hasBuildRunCommand(selfCmd) ? nextDraft.mode === "advanced" : prev.advanced,
4849+
};
4850+
});
4851+
}, [buildRunDialog, dirTreeStore.parentById, ensureBuildRunConfigLoaded]);
4852+
4853+
/**
4854+
* 触发 Build/Run:
47804855
* - 若无配置:打开配置对话框
47814856
* - 否则:直接外部终端执行
47824857
* - edit=true:强制进入“编辑命令”
@@ -4785,14 +4860,12 @@ export default function CodexFlowManagerUI() {
47854860
const p = project;
47864861
if (!p?.id || !p?.winPath) return;
47874862
const resolved = await resolveEffectiveBuildRunCommand(p, action);
4788-
const effective = resolved.effective;
4789-
if (edit || !effective) {
4790-
const draft = effective
4791-
? ({ ...effective, env: Array.isArray(effective.env) ? effective.env : [] } as BuildRunCommandConfig)
4792-
: ({ mode: "simple", commandText: "", cwd: "", env: [], backend: { kind: "system" } } as BuildRunCommandConfig);
4793-
const advanced = draft.mode === "advanced";
4794-
setBuildRunDialog({
4795-
open: true,
4863+
const effective = resolved.effective;
4864+
if (edit || !effective) {
4865+
const draft = effective ? cloneBuildRunCommandConfig(effective) : createEmptyBuildRunCommandConfig();
4866+
const advanced = draft.mode === "advanced";
4867+
setBuildRunDialog({
4868+
open: true,
47964869
action,
47974870
projectId: p.id,
47984871
saveScope: resolved.defaultSaveScope,
@@ -4805,16 +4878,19 @@ export default function CodexFlowManagerUI() {
48054878

48064879
const cwd = String(effective.cwd || "").trim() || p.winPath;
48074880
const title = `${getDirNodeLabel(p)} ${action === "build" ? "Build" : "Run"}`;
4808-
try {
4809-
const res: any = await (window as any).host?.buildRun?.exec?.({ dir: p.winPath, cwd, title, command: effective });
4810-
if (!(res && res.ok)) throw new Error(res?.error || "failed");
4811-
} catch (e: any) {
4812-
alert(String((t("projects:buildRunFailed", "执行失败:{{error}}") as any) || "").replace("{{error}}", String(e?.message || e)));
4813-
}
4814-
}, [getDirNodeLabel, resolveEffectiveBuildRunCommand, t]);
4815-
4816-
/**
4817-
* 保存 Build/Run 配置对话框的草稿到本地持久化存储。
4881+
try {
4882+
const res: any = await (window as any).host?.buildRun?.exec?.({ dir: p.winPath, cwd, title, command: effective });
4883+
if (!(res && res.ok)) throw new Error(res?.error || "failed");
4884+
} catch (e: any) {
4885+
showBuildRunNotice(
4886+
action,
4887+
String((t("projects:buildRunFailed", "执行失败:{{error}}") as any) || "").replace("{{error}}", String(e?.message || e)),
4888+
);
4889+
}
4890+
}, [createEmptyBuildRunCommandConfig, getDirNodeLabel, resolveEffectiveBuildRunCommand, showBuildRunNotice]);
4891+
4892+
/**
4893+
* 保存 Build/Run 配置对话框的草稿到本地持久化存储。
48184894
*/
48194895
const saveBuildRunDialog = useCallback(async () => {
48204896
const dlg = buildRunDialog;
@@ -4826,50 +4902,56 @@ export default function CodexFlowManagerUI() {
48264902
const parentIdFromTree = String(dirTreeStore.parentById[target.id] || "").trim();
48274903
const parentId = String(dlg.parentProjectId || parentIdFromTree || "").trim();
48284904

4829-
const saveProject = (() => {
4830-
if (dlg.saveScope !== "parent") return target;
4831-
if (!parentId) return target;
4832-
return projectsRef.current.find((x) => x.id === parentId) || target;
4833-
})();
4834-
4835-
const draft = dlg.draft || ({} as any);
4836-
const nextCmd: BuildRunCommandConfig = { ...draft } as any;
4837-
nextCmd.cwd = String(draft.cwd || "").trim();
4838-
nextCmd.backend = (draft.backend && typeof draft.backend === "object") ? draft.backend : { kind: "system" };
4839-
nextCmd.env = Array.isArray(draft.env)
4840-
? draft.env.map((r: any) => ({ key: String(r?.key || ""), value: String(r?.value ?? "") }))
4841-
: [];
4842-
4843-
if (dlg.advanced) {
4844-
nextCmd.mode = "advanced";
4845-
nextCmd.commandText = undefined;
4846-
nextCmd.cmd = String(draft.cmd || "").trim();
4847-
nextCmd.args = Array.isArray(draft.args) ? draft.args.map((x: any) => String(x ?? "")).filter((x: string) => x.trim().length > 0) : [];
4848-
if (!nextCmd.cmd) {
4849-
alert(t("projects:buildRunMissingCmd", "请输入命令") as string);
4850-
return;
4851-
}
4852-
} else {
4853-
nextCmd.mode = "simple";
4854-
nextCmd.cmd = undefined;
4855-
nextCmd.args = undefined;
4856-
nextCmd.commandText = String(draft.commandText || "").replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim();
4857-
if (!nextCmd.commandText) {
4858-
alert(t("projects:buildRunMissingCmd", "请输入命令") as string);
4859-
return;
4860-
}
4861-
}
4862-
4863-
const existing = (await ensureBuildRunConfigLoaded(saveProject.winPath)) || {};
4864-
const nextCfg: DirBuildRunConfig = { ...(existing as any), [dlg.action]: nextCmd } as any;
4865-
const ok = await persistBuildRunConfig(saveProject.winPath, nextCfg);
4866-
if (!ok) {
4867-
alert(t("projects:buildRunSaveFailed", "保存失败") as string);
4868-
return;
4869-
}
4870-
4871-
setBuildRunDialog((prev) => ({ ...prev, open: false }));
4872-
}, [buildRunDialog, dirTreeStore.parentById, ensureBuildRunConfigLoaded, persistBuildRunConfig, t]);
4905+
const saveProject = (() => {
4906+
if (dlg.saveScope !== "parent") return target;
4907+
if (!parentId) return target;
4908+
return projectsRef.current.find((x) => x.id === parentId) || target;
4909+
})();
4910+
4911+
const nextCmd = normalizeBuildRunCommandDraft(dlg.draft, dlg.advanced);
4912+
const hasCommand = hasBuildRunCommand(nextCmd);
4913+
const selfExisting = (await ensureBuildRunConfigLoaded(target.winPath)) || {};
4914+
const parentExisting = saveProject.id === target.id
4915+
? selfExisting
4916+
: ((await ensureBuildRunConfigLoaded(saveProject.winPath)) || {});
4917+
const parentCurrentCmd = (parentExisting as any)?.[dlg.action] as BuildRunCommandConfig | undefined;
4918+
4919+
if (dlg.saveScope === "self" && !hasCommand) {
4920+
showBuildRunNotice(dlg.action, t("projects:buildRunMissingCmd", "请输入命令") as string);
4921+
return;
4922+
}
4923+
4924+
if (dlg.saveScope === "parent" && saveProject.id !== target.id && !hasCommand && hasBuildRunCommand(parentCurrentCmd)) {
4925+
showBuildRunNotice(dlg.action, t("projects:buildRunMissingCmd", "请输入命令") as string);
4926+
return;
4927+
}
4928+
4929+
if (dlg.saveScope === "self") {
4930+
const nextCfg = upsertBuildRunCommandConfig(selfExisting, dlg.action, nextCmd);
4931+
const ok = await persistBuildRunConfig(target.winPath, nextCfg);
4932+
if (!ok) {
4933+
showBuildRunNotice(dlg.action, t("projects:buildRunSaveFailed", "保存失败") as string);
4934+
return;
4935+
}
4936+
} else {
4937+
let parentOk = true;
4938+
let selfOk = true;
4939+
if (saveProject.id !== target.id && hasCommand) {
4940+
const nextParentCfg = upsertBuildRunCommandConfig(parentExisting, dlg.action, nextCmd);
4941+
parentOk = await persistBuildRunConfig(saveProject.winPath, nextParentCfg);
4942+
}
4943+
if (saveProject.id !== target.id && parentOk) {
4944+
const nextSelfCfg = removeBuildRunCommandConfig(selfExisting, dlg.action);
4945+
selfOk = await persistBuildRunConfig(target.winPath, nextSelfCfg);
4946+
}
4947+
if (!(parentOk && selfOk)) {
4948+
showBuildRunNotice(dlg.action, t("projects:buildRunSaveFailed", "保存失败") as string);
4949+
return;
4950+
}
4951+
}
4952+
4953+
setBuildRunDialog((prev) => ({ ...prev, open: false }));
4954+
}, [buildRunDialog, dirTreeStore.parentById, ensureBuildRunConfigLoaded, persistBuildRunConfig, showBuildRunNotice, t]);
48734955

48744956
/**
48754957
* 关闭 Build/Run 配置对话框(不保存)。
@@ -8812,14 +8894,10 @@ export default function CodexFlowManagerUI() {
88128894
? "border-[var(--cf-accent)]/30 bg-[var(--cf-accent)]/5 dark:border-[var(--cf-accent)]/40 dark:bg-[var(--cf-accent)]/10"
88138895
: "border-slate-200/70 bg-white/60 dark:border-[var(--cf-border)] dark:bg-[var(--cf-surface-muted)]"
88148896
}`}
8815-
onClick={() => {
8816-
setBuildRunDialog((prev) => ({
8817-
...prev,
8818-
saveScope: override ? "parent" : "self",
8819-
parentProjectId: parentId || prev.parentProjectId,
8820-
}));
8821-
}}
8822-
>
8897+
onClick={() => {
8898+
void switchBuildRunDialogSaveScope(override ? "parent" : "self");
8899+
}}
8900+
>
88238901
<div className="pt-0.5 shrink-0">
88248902
<div className={`h-3.5 w-3.5 rounded-full border flex items-center justify-center transition-colors ${
88258903
override ? "border-[var(--cf-accent)] bg-[var(--cf-accent)]" : "border-slate-400 bg-transparent"

0 commit comments

Comments
 (0)