feat: 工作空间切换校验 + 初始化检测 + 继承基础设置

新增功能:
- workspace:check IPC — 校验路径有效性 + 检测 4 个必需文件状态
- workspace:inheritFiles IPC — 从旧工作空间继承 SOUL/AGENTS/USERS 到新工作空间
- app:restart IPC — 渲染进程触发应用重启(app.relaunch + app.exit)

前端 WorkspaceSettings 改造:
- 选择文件夹后立即校验路径(是否存在、是否目录、4 个必需文件状态)
- 显示工作空间状态:
  · 新工作空间 → 提示将自动创建 4 个文件
  · 部分文件缺失 → 提示缺失文件将自动创建
  · 已有工作空间 → 提示将直接加载现有配置
- 继承选项:当切换到新工作空间时,可勾选继承 SOUL/AGENTS/USERS.md
  · MEMORY.md 不继承(记忆与工作空间项目上下文绑定)
  · 继承白名单:仅允许 SOUL.md、AGENTS.md、USERS.md(防路径遍历)
- 确认切换按钮 + 取消按钮
- 切换完成后弹出重启确认对话框(立即重启 / 稍后手动重启)

安全考虑:
- workspace:inheritFiles 使用文件名白名单,防止路径遍历攻击
- workspace:check 返回 resolvedPath,前端只展示不直接用于文件操作
- app:restart 延迟 200ms 让 IPC 响应先返回
This commit is contained in:
thzxx
2026-07-12 13:59:31 +08:00
parent aa13a98f63
commit 0363176215
4 changed files with 315 additions and 2 deletions
+169 -2
View File
@@ -72,11 +72,77 @@ const PROVIDER_URLS: Record<string, string> = { deepseek: 'https://api.deepseek.
function WorkspaceSettings() {
const [workspacePath, setWorkspacePath] = useConfig('workspace.path', '');
// 切换工作空间的中间状态
const [pendingPath, setPendingPath] = useState<string | null>(null);
const [checkResult, setCheckResult] = useState<MetonaWorkspaceCheckResult | null>(null);
const [checking, setChecking] = useState(false);
const [inheritSoul, setInheritSoul] = useState(true);
const [inheritAgents, setInheritAgents] = useState(true);
const [inheritUsers, setInheritUsers] = useState(true);
const [applying, setApplying] = useState(false);
const [showRestartDialog, setShowRestartDialog] = useState(false);
const currentPath = workspacePath;
const handleSelect = async () => {
if (!window.metona?.app?.selectFolder) return;
const r = await window.metona.app.selectFolder(workspacePath || undefined);
if (!r.canceled && r.path) setWorkspacePath(r.path);
const r = await window.metona.app.selectFolder(currentPath || undefined);
if (!r.canceled && r.path) {
// 立即校验新路径
setPendingPath(r.path);
setChecking(true);
setCheckResult(null);
try {
const result = await window.metona.workspace.check(r.path);
setCheckResult(result);
} catch (err) {
console.error('[SettingsModal]', err);
setCheckResult({ valid: false, reason: (err as Error).message });
} finally {
setChecking(false);
}
}
};
const handleApply = async () => {
if (!pendingPath || !checkResult?.valid) return;
setApplying(true);
try {
// 如果勾选了继承文件,从旧工作空间复制到新工作空间
const filesToInherit: string[] = [];
if (inheritSoul) filesToInherit.push('SOUL.md');
if (inheritAgents) filesToInherit.push('AGENTS.md');
if (inheritUsers) filesToInherit.push('USERS.md');
if (filesToInherit.length > 0 && currentPath) {
try {
await window.metona.workspace.inheritFiles({
targetPath: pendingPath,
sourcePath: currentPath,
files: filesToInherit,
});
} catch (err) {
console.error('[SettingsModal] Inherit failed:', err);
}
}
// 保存新路径到配置
setWorkspacePath(pendingPath);
// 弹出重启确认对话框
setShowRestartDialog(true);
// 清理中间状态
setPendingPath(null);
setCheckResult(null);
} catch (err) {
console.error('[SettingsModal]', err);
} finally {
setApplying(false);
}
};
const handleCancel = () => {
setPendingPath(null);
setCheckResult(null);
};
const handleOpen = () => { if (workspacePath) window.metona?.app?.showItemInFolder(workspacePath); };
@@ -94,7 +160,108 @@ function WorkspaceSettings() {
📂
</Button>
)}
{/* 校验中状态 */}
{checking && (
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', py: 1 }}>
<CircularProgress size={14} />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>...</Typography>
</Stack>
)}
{/* 校验失败 */}
{pendingPath && checkResult && !checkResult.valid && (
<Alert severity="error" sx={{ py: 0.5 }}>
<Typography variant="caption">{checkResult.reason}</Typography>
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
<Button size="small" onClick={handleCancel}></Button>
</Stack>
</Alert>
)}
{/* 校验成功 — 显示工作空间状态 + 继承选项 */}
{pendingPath && checkResult?.valid && (
<Stack spacing={1.5} sx={{ p: 1.5, borderRadius: 1.5, bgcolor: 'background.default', border: '1px solid', borderColor: 'divider' }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.primary' }}>
{checkResult.path}
</Typography>
{checkResult.isNewWorkspace ? (
<Alert severity="info" sx={{ py: 0.5, '& .MuiAlert-message': { fontSize: 12 } }}>
4 SOUL.mdAGENTS.mdMEMORY.mdUSERS.md
</Alert>
) : checkResult.missingFiles && checkResult.missingFiles.length > 0 ? (
<Alert severity="warning" sx={{ py: 0.5, '& .MuiAlert-message': { fontSize: 12 } }}>
{checkResult.missingFiles.length} {checkResult.missingFiles.join(', ')}
</Alert>
) : (
<Alert severity="success" sx={{ py: 0.5, '& .MuiAlert-message': { fontSize: 12 } }}>
4
</Alert>
)}
{/* 继承选项(仅当当前有工作空间且新工作空间需要创建文件时显示) */}
{currentPath && currentPath !== pendingPath && (checkResult.isNewWorkspace || (checkResult.missingFiles && checkResult.missingFiles.length > 0)) && (
<Stack spacing={0.5} sx={{ mt: 0.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
</Typography>
<FormControlLabel
control={<Checkbox size="small" checked={inheritSoul} onChange={(e) => setInheritSoul(e.target.checked)} />}
label={<Typography variant="caption">SOUL.md</Typography>}
/>
<FormControlLabel
control={<Checkbox size="small" checked={inheritAgents} onChange={(e) => setInheritAgents(e.target.checked)} />}
label={<Typography variant="caption">AGENTS.md</Typography>}
/>
<FormControlLabel
control={<Checkbox size="small" checked={inheritUsers} onChange={(e) => setInheritUsers(e.target.checked)} />}
label={<Typography variant="caption">USERS.md</Typography>}
/>
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: 10, pl: 3 }}>
MEMORY.md
</Typography>
</Stack>
)}
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
<Button
variant="contained"
size="small"
onClick={handleApply}
disabled={applying}
startIcon={applying ? <CircularProgress size={12} /> : undefined}
>
{applying ? '应用中...' : '确认切换'}
</Button>
<Button variant="outlined" size="small" onClick={handleCancel} disabled={applying}>
</Button>
</Stack>
</Stack>
)}
<Typography variant="caption" sx={{ color: 'text.disabled' }}></Typography>
{/* 重启确认对话框 */}
<Dialog open={showRestartDialog} onClose={() => setShowRestartDialog(false)} maxWidth="xs" fullWidth>
<DialogTitle sx={{ fontSize: 14 }}></DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
</Typography>
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: 12, mt: 0.5, p: 1, borderRadius: 1, bgcolor: 'background.default' }}>
{workspacePath}
</Typography>
<Typography variant="body2" sx={{ mt: 1.5, color: 'text.secondary' }}>
</Typography>
</DialogContent>
<DialogActions>
<Button size="small" onClick={() => setShowRestartDialog(false)}></Button>
<Button size="small" variant="contained" color="primary" onClick={() => window.metona?.app?.restart()}></Button>
</DialogActions>
</Dialog>
</Stack>
);
}