Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
245 changes: 243 additions & 2 deletions src/components/AddTaskPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,24 @@ import {
addTaskPanelResizeStep,
} from '@/types/config';
import { Tooltip } from './ui/Tooltip';
import type { TaskItem, ActionConfig, GroupItem } from '@/types/interface';
import type {
TaskItem,
ActionConfig,
GroupItem,
PreTaskConfig,
OptionDefinition,
OptionValue,
CaseItem,
} from '@/types/interface';
import { normalizePreTaskConfigs } from '@/types/interface';
import type { MxuSpecialTaskDefinition } from '@/types/specialTasks';
import {
getAllMxuSpecialTasks,
MXU_LAUNCH_TASK_NAME,
MXU_KILLPROC_TASK_NAME,
} from '@/types/specialTasks';
import { generateId } from '@/stores/helpers';
import { generateId, initializeAllOptionValues } from '@/stores/helpers';
import { findSwitchCase } from '@/utils/optionHelpers';
import { getProcessNameFromPath } from '@/utils/paths';
import clsx from 'clsx';

Expand Down Expand Up @@ -160,6 +170,145 @@ function createDefaultAction(defaultProgram?: string): ActionConfig {
};
}

/**
* 以兼容 shell_words crate(POSIX 解析)的方式对单个参数做引号转义。
* 字母/数字/常用安全符号原样输出,其他字符使用单引号包裹。
*/
function shellQuote(arg: string): string {
if (arg === '') return "''";
if (/^[a-zA-Z0-9._/\-+=:@%]+$/.test(arg)) return arg;
return `'${arg.replace(/'/g, `'\\''`)}'`;
}

/** 将参数数组以 shell 安全形式 join 为字符串,便于回写 ActionConfig.args */
function joinArgsShell(args: string[] | undefined): string {
if (!args || args.length === 0) return '';
return args.map(shellQuote).join(' ');
}

/** 将 OptionValue 转换为协议要求的「原始 JSON 取值」(select/switch -> case.name 字符串等) */
function rawValueFromOptionValue(value: OptionValue, optionDef: OptionDefinition): unknown {
if (value.type === 'select') return value.caseName;
if (value.type === 'checkbox') return value.caseNames;
if (value.type === 'input') return value.values;
if (value.type === 'switch') {
const cases = (optionDef as { cases?: CaseItem[] }).cases;
const matched = findSwitchCase(cases, value.value);
return matched?.name ?? (value.value ? 'Yes' : 'No');
}
return null;
}

/**
* 按 PI v2.7.0 规范,构造 pretask option 的「单行紧凑 JSON」字符串。
* 仅快照默认值,不在此处做控制器/资源过滤(pretask 顶层即生效)。
* 若无 option 或 allOptions 为空,则返回 null。
*/
function buildPretaskOptionJson(
optionKeys: string[] | undefined,
allOptions: Record<string, OptionDefinition> | undefined,
): string | null {
if (!optionKeys || optionKeys.length === 0 || !allOptions) return null;
const valuesMap = initializeAllOptionValues(optionKeys, allOptions);
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(valuesMap)) {
const def = allOptions[key];
if (!def) continue;
Comment on lines +214 to +216
result[key] = rawValueFromOptionValue(value, def);
}
return JSON.stringify(result);
}

/** 以 pretask 配置生成新的 ActionConfig,args 末尾按需追加 option 取值 JSON */
function createActionFromPretask(
pretask: PreTaskConfig,
allOptions: Record<string, OptionDefinition> | undefined,
resolvedLabel?: string,
): ActionConfig {
const args = [...(pretask.args ?? [])];
const optionJson = buildPretaskOptionJson(pretask.option, allOptions);
if (optionJson !== null) {
args.push(optionJson);
}
return {
id: generateId(),
enabled: true,
program: pretask.exec,
args: joinArgsShell(args),
// PI 协议要求 Client 等待每个 pretask 结束并在失败时中止启动;这里以 ActionConfig 形式承载,
// 通过保留 waitForExit=true 来贴近协议语义(用户仍可在 UI 中调整)。
waitForExit: true,
skipIfRunning: false,
useCmd: false,
customName: resolvedLabel,
};
}

/** pretask 按钮:与 TaskButton 风格保持一致,但不依赖 controller/resource 兼容性 */
function PreTaskButton({
pretask,
count,
label,
langKey,
basePath,
onClick,
}: {
pretask: PreTaskConfig;
count: number;
label: string;
langKey: string;
basePath: string;
onClick: () => void;
}) {
const { t } = useTranslation();
const { resolveI18nText, interfaceTranslations } = useAppStore();

const translations = interfaceTranslations[langKey];
const resolvedDescription = useResolvedContent(
pretask.description ? resolveI18nText(pretask.description, langKey) : undefined,
basePath,
translations,
);

const hasDescription = !!resolvedDescription.html || resolvedDescription.loading;

const tooltipContent = hasDescription ? (
<div className="space-y-2">
{resolvedDescription.loading ? (
<div className="flex items-center gap-1.5 text-text-muted">
<Loader2 className="w-3 h-3 animate-spin" />
<span>{t('taskItem.loadingDescription')}</span>
</div>
) : resolvedDescription.html ? (
<div
className="text-text-secondary [&_p]:my-0.5 [&_a]:text-accent [&_a]:hover:underline"
dangerouslySetInnerHTML={{ __html: resolvedDescription.html }}
/>
) : null}
</div>
) : null;

return (
<Tooltip content={tooltipContent} side="top" align="center" maxWidth="max-w-xs">
<button
onClick={onClick}
className={clsx(
'relative flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors text-left',
'bg-bg-secondary hover:bg-bg-hover text-text-primary border border-border hover:border-accent',
)}
>
<Plus className="w-4 h-4 shrink-0 text-accent" />
<span className="flex-1 truncate">{label}</span>
{count > 0 && (
<span className="shrink-0 px-1.5 py-0.5 text-xs rounded-full font-medium bg-accent/10 text-accent">
{count}
</span>
)}
</button>
</Tooltip>
);
}

export function AddTaskPanel() {
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState('');
Expand Down Expand Up @@ -402,6 +551,56 @@ export function AddTaskPanel() {
});
const [ungroupedExpanded, setUngroupedExpanded] = useState(true);
const [specialExpanded, setSpecialExpanded] = useState(true);
const [pretaskExpanded, setPretaskExpanded] = useState(true);

// v2.7.0: 收集合并后的 pretask 列表(loader 已合并主文件 + import)
const allPretasks = useMemo(
() => normalizePreTaskConfigs(projectInterface?.pretask),
[projectInterface?.pretask],
);

// 按搜索关键词过滤 pretask(与 task 一致:匹配 name 或解析后的 label)
const filteredPretasks = useMemo(() => {
if (allPretasks.length === 0) return [];
const searchLower = searchQuery.toLowerCase();
return allPretasks.filter((p) => {
const label = resolveI18nText(p.label, langKey) || p.name || p.exec;
return (
(p.name?.toLowerCase().includes(searchLower) ?? false) ||
p.exec.toLowerCase().includes(searchLower) ||
label.toLowerCase().includes(searchLower)
);
});
}, [allPretasks, searchQuery, resolveI18nText, langKey]);

// 统计每个 pretask 已被加入 preActions 的次数(按 program 路径匹配)
const pretaskCounts = useMemo(() => {
const counts: Record<string, number> = {};
const preActions = instance?.preActions ?? [];
for (const p of allPretasks) {
counts[p.exec] = preActions.filter((a) => a.program === p.exec).length;
}
Comment on lines +580 to +582
return counts;
}, [allPretasks, instance?.preActions]);

/** 点击 pretask 按钮:基于 pretask 配置生成 ActionConfig,加入 preActions */
const handleAddPretask = useCallback(
(pretask: PreTaskConfig) => {
if (!instance) return;
const resolvedLabel = resolveI18nText(pretask.label, langKey) || pretask.name || undefined;
const action = createActionFromPretask(pretask, projectInterface?.option, resolvedLabel);
addPreAction(instance.id, action);
setShowAddTaskPanel(false);
},
[
instance,
projectInterface?.option,
resolveI18nText,
langKey,
addPreAction,
setShowAddTaskPanel,
],
);

// 当分组定义变化时,移除已失效 key,并为新分组注入 default_expand 默认值
useEffect(() => {
Expand Down Expand Up @@ -521,6 +720,45 @@ export function AddTaskPanel() {
);
};

/** 渲染 pretask 分组(与普通 task 分组风格一致) */
const renderPretaskSection = () => {
if (filteredPretasks.length === 0) return null;
const contentId = 'add-task-panel-section-pretask';
return (
<div>
{renderSectionHeader(
t('addTaskPanel.pretasks'),
pretaskExpanded,
() => setPretaskExpanded((prev) => !prev),
filteredPretasks.length,
contentId,
)}
{pretaskExpanded && (
<div id={contentId} className="mt-1">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-8 gap-2">
{filteredPretasks.map((pretask) => {
const label =
resolveI18nText(pretask.label, langKey) || pretask.name || pretask.exec;
const key = pretask.name || pretask.exec;
return (
<PreTaskButton
key={key}
pretask={pretask}
Comment on lines +740 to +746
count={pretaskCounts[pretask.exec] || 0}
label={label}
langKey={langKey}
basePath={basePath}
onClick={() => handleAddPretask(pretask)}
/>
);
})}
</div>
</div>
)}
</div>
);
};

/** 渲染分组区块(带可折叠标题) */
const renderGroupSection = (group: GroupItem, tasks: TaskItem[]) => {
if (tasks.length === 0) return null;
Expand Down Expand Up @@ -724,6 +962,9 @@ export function AddTaskPanel() {
filteredTasks.length > 0 && renderTaskGrid(filteredTasks)
)}

{/* 前置任务(pretask):放在普通任务与特殊任务之间,仅当存在配置且有活动实例时渲染 */}
{instance && renderPretaskSection()}

{instance && (
<div>
{renderSectionHeader(
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,7 @@ export default {
specialTasks: 'Special Tasks',
allSpecialTasksAdded: 'All added',
ungroupedTasks: 'Others',
pretasks: 'Pre-tasks',
resizeHandleAriaLabel: 'Resize add task panel height',
},

Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/ja-JP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,7 @@ export default {
allSpecialTasksAdded: 'すべて追加済み',
collapse: 'パネルを閉じる',
ungroupedTasks: 'その他',
pretasks: '事前タスク',
resizeHandleAriaLabel: 'タスク追加パネルの高さを調整',
},

Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/ko-KR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,7 @@ export default {
specialTasks: '특수 작업',
allSpecialTasksAdded: '모두 추가됨',
ungroupedTasks: '기타',
pretasks: '사전 작업',
resizeHandleAriaLabel: '작업 추가 패널 높이 조정',
},

Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,7 @@ export default {
allSpecialTasksAdded: '已全部添加',
collapse: '收起面板',
ungroupedTasks: '其他',
pretasks: '前置任务',
resizeHandleAriaLabel: '调整添加任务面板高度',
},

Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,7 @@ export default {
allSpecialTasksAdded: '已全部新增',
collapse: '收起面板',
ungroupedTasks: '其他',
pretasks: '前置任務',
resizeHandleAriaLabel: '調整新增任務面板高度',
},

Expand Down
14 changes: 13 additions & 1 deletion src/services/interfaceLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import type {
ControllerType,
PresetItem,
GroupItem,
PreTaskConfig,
} from '@/types/interface';
import { normalizePreTaskConfigs } from '@/types/interface';
import { loggers } from '@/utils/logger';
import { parseJsonc } from '@/utils/jsonc';
import { isTauri } from '@/utils/paths';
Expand All @@ -15,7 +17,7 @@ import { setBackendPort } from '@/utils/backendApi';
const log = loggers.app;

/**
* 可导入的 PI 文件结构(支持 task、option、preset 和 group 字段)
* 可导入的 PI 文件结构(支持 task、option、preset、grouppretask 字段)
*/
interface ImportableInterface {
task?: TaskItem[];
Expand All @@ -24,6 +26,8 @@ interface ImportableInterface {
preset?: PresetItem[];
/** v2.4.0: 支持导入 group */
group?: GroupItem[];
/** v2.7.1: 支持导入 pretask */
pretask?: PreTaskConfig | PreTaskConfig[];
}

export interface LoadResult {
Expand Down Expand Up @@ -233,6 +237,14 @@ function mergeImported(pi: ProjectInterface, imported: ImportableInterface): voi
log.info(`合并了 ${imported.preset.length} 个导入的 preset`);
}

// v2.7.1: 合并 pretask(主文件已在 normalizePretask 处理;此处把导入文件的条目按数组顺序追加)
const importedPretasks = normalizePreTaskConfigs(imported.pretask);
if (importedPretasks.length > 0) {
const existing = normalizePreTaskConfigs(pi.pretask);
pi.pretask = [...existing, ...importedPretasks];
log.info(`合并了 ${importedPretasks.length} 个导入的 pretask`);
Comment on lines +240 to +245
}

// v2.4.0: 合并 group 数组(按 name 去重,保持先定义优先)
if (imported.group && imported.group.length > 0) {
const existingGroups = pi.group || [];
Expand Down
Loading
Loading