import { FileCode2, FileText, RefreshCw, Save, ScrollText } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { platformApiClient } from "../api/client"; import type { LogEntryBody, PluginFileWorkspaceResponse, ServerConfigDiffPreviewResponse, ServerConfigResponse } from "../api/types"; import type { ScumOperationsPageContract } from "../contracts/scumOperations"; import { DiffView } from "./OperationControls"; import { EmptyState, ErrorState, LoadingState, ResultBadge } from "./StateViews"; type State = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; config: ServerConfigResponse; values: Record; unknown: Array<[string, string]> }; export function ScumFileConfigWorkbench({ contract, workspace }: { contract: ScumOperationsPageContract; workspace: PluginFileWorkspaceResponse }) { const [state, setState] = useState({ status: "loading" }); const [directoryKey, setDirectoryKey] = useState(workspace.defaultDirectoryKey); const [selectedFileKey, setSelectedFileKey] = useState(workspace.files.find((file) => file.directoryKey === workspace.defaultDirectoryKey)?.key ?? ""); const [preview, setPreview] = useState(null); const [result, setResult] = useState(""); const [logEntries, setLogEntries] = useState(null); const files = useMemo(() => workspace.files.filter((file) => file.directoryKey === directoryKey), [directoryKey, workspace.files]); const selectedFile = workspace.files.find((file) => file.key === selectedFileKey); const fields = workspace.configFields.filter((field) => field.fileKey === selectedFileKey); const load = useCallback(async () => { setState({ status: "loading" }); setPreview(null); setResult(""); try { const config = await platformApiClient.getServerConfig(contract.serverInstanceId); const parsed = parseIni(config.content); const values = Object.fromEntries(workspace.configFields.map((field) => [field.key, parsed[field.configKey] ?? field.defaultValue ?? ""])); const known = new Set(workspace.configFields.map((field) => field.configKey)); setState({ status: "ready", config, values, unknown: Object.entries(parsed).filter(([key]) => !known.has(key)) }); } catch (error) { setState({ status: "error", reason: error instanceof Error ? error.message : "无法读取平台配置投影。" }); } }, [contract.serverInstanceId, workspace.configFields]); useEffect(() => { void load(); }, [load]); useEffect(() => { setSelectedFileKey(workspace.files.find((file) => file.directoryKey === directoryKey)?.key ?? ""); setLogEntries(null); setPreview(null); }, [directoryKey, workspace.files]); async function openLog() { if (!selectedFile?.streamKey) return; setLogEntries(null); try { const streams = await platformApiClient.listLogStreams(); const stream = streams.items.find((item) => item.serverInstanceId === contract.serverInstanceId && item.streamKey === selectedFile.streamKey); if (!stream) { setLogEntries([]); return; } const data = await platformApiClient.queryLogStream({ logStreamId: stream.id, afterSeq: Math.max(0, stream.latestSeq - 200), limit: 200 }); setLogEntries(data.entries); } catch (error) { setResult(error instanceof Error ? error.message : "日志文件内容读取失败。" ); setLogEntries([]); } } async function previewChanges() { if (state.status !== "ready") return; const proposed = patchIni(state.config.content, fields, state.values); try { setPreview(await platformApiClient.previewServerConfigDiff(contract.serverInstanceId, { expectedConfigVersion: state.config.configVersion, expectedChecksum: state.config.checksum, key: state.config.key ?? selectedFileKey, proposedContent: proposed })); } catch (error) { setResult(error instanceof Error ? error.message : "配置差异预览失败。" ); } } async function approve() { if (!preview) return; try { const response = await platformApiClient.approveServerConfigWrite(contract.serverInstanceId, { expectedConfigVersion: preview.configVersion, expectedChecksum: preview.checksum, key: preview.key, proposedContent: preview.proposedContent, idempotencyKey: `scum-files-config-${Date.now()}` }); setResult(`已派发配置写入任务 ${response.job.id}。`); setPreview(null); await load(); } catch (error) { setResult(error instanceof Error ? error.message : "配置写入审批失败。" ); } } if (state.status === "loading") return ; if (state.status === "error") return void load()} />; return

文件与配置

默认展开插件声明的配置目录;仅模型字段可编辑,保存前必须预览差异。

{workspace.directories.map((directory) => )}
{files.map((file) => )}
{selectedFile?.kind === "config" &&

配置字段 · {selectedFile.label}

{fields.length} 项已建模
{fields.map((field) => )}
未建模字段(只读){state.unknown.length ?
{state.unknown.map(([key, value]) =>
{key}{value}
)}
:

没有未建模字段。

}
{preview && }
{preview && ({ kind: line.kind === "context" ? "same" : line.kind, text: line.content }))} />}
} {selectedFile?.kind === "log" &&

日志文件 · {selectedFile.label}

{logEntries === null ? : logEntries.length === 0 ? :
{logEntries.map((entry) => `${entry.timestamp} ${entry.line}`).join("\n")}
}
} {result && }
; } function parseIni(content: string): Record { return Object.fromEntries(content.split(/\r?\n/).flatMap((line) => { const match = line.match(/^\s*([^=;#\s]+)\s*=\s*(.*?)\s*$/); return match ? [[match[1], match[2]]] : []; })); } function patchIni(content: string, fields: PluginFileWorkspaceResponse["configFields"], values: Record): string { let next = content; for (const field of fields) { const pattern = new RegExp(`(^\\s*${escapeRegExp(field.configKey)}\\s*=\\s*).*?$`, "m"); next = pattern.test(next) ? next.replace(pattern, `$1${values[field.key]}`) : `${next.replace(/\s*$/, "")}\n${field.configKey}=${values[field.key]}`; } return next; } function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }