Harness_swap / apps /vscode /src /wizardPanel.ts
smlflg's picture
Initial public upload from Projekte/Harness_swap
69723b7 verified
Raw History Blame Contribute Delete
16.1 kB
import * as path from "node:path";
import * as vscode from "vscode";
import type { MigrationResult } from "@harness-swap/schema";
import { buildZipArchive, exportFileName, runMigration } from "@harness-swap/migrate-core";
import type { WorkspaceFolder } from "vscode";
import { WorkspaceSwapAdapter } from "./adapters/workspaceFs";
import {
asMigrationOptions,
isReadyMessage,
isRunStartMessage,
isStopRunMessage,
type HostLogMessage,
type HostOutgoingMessage,
type HostProgressMessage,
type WebviewIncomingMessage,
WizardHostMessageType
} from "./protocol/messages";
import type { OutputChannel } from "vscode";
interface WorkspaceFolderChoice {
uri: string;
name: string;
}
export class WizardPanel {
public static readonly viewType = "harnessSwap.wizard";
private static current: WizardPanel | undefined;
public static createOrShow(context: vscode.ExtensionContext, outputChannel: OutputChannel): WizardPanel {
if (WizardPanel.current) {
WizardPanel.current.panel.reveal(vscode.ViewColumn.Beside);
return WizardPanel.current;
}
const folderChoices: WorkspaceFolderChoice[] =
vscode.workspace.workspaceFolders?.map((folder: WorkspaceFolder) => ({
uri: folder.uri.fsPath,
name: folder.name
})) ?? [];
const panel = vscode.window.createWebviewPanel(
WizardPanel.viewType,
"Harness Swap Migration Wizard",
vscode.ViewColumn.One,
{
enableScripts: true,
localResourceRoots: [context.extensionUri],
retainContextWhenHidden: false
}
);
const instance = new WizardPanel(panel, folderChoices, outputChannel);
WizardPanel.current = instance;
return instance;
}
public static currentPanel(): WizardPanel | undefined {
return WizardPanel.current;
}
private readonly adapter: WorkspaceSwapAdapter;
private readonly disposables: vscode.Disposable[] = [];
private activeRun: AbortController | undefined;
private constructor(
private readonly panel: vscode.WebviewPanel,
private readonly workspaceFolders: WorkspaceFolderChoice[],
private readonly outputChannel: OutputChannel
) {
this.adapter = new WorkspaceSwapAdapter({
log: (level, message) => {
const levelPrefix = level.toUpperCase().padEnd(7, " ");
this.outputChannel.appendLine(`[${levelPrefix}] ${message}`);
}
});
this.panel.webview.html = this.renderHtml(this.workspaceFolders);
this.panel.onDidDispose(() => this.dispose(), null, this.disposables);
this.panel.webview.onDidReceiveMessage((message: WebviewIncomingMessage) => void this.onMessage(message), this, this.disposables);
}
public dispose(): void {
this.activeRun?.abort();
this.panel.dispose();
for (const disposable of this.disposables.splice(0)) {
disposable.dispose();
}
if (WizardPanel.current === this) {
WizardPanel.current = undefined;
}
}
private async onMessage(message: WebviewIncomingMessage): Promise<void> {
if (isReadyMessage(message)) {
this.postHost({ type: WizardHostMessageType.LOG, level: "info", message: "Wizard is ready." });
return;
}
if (isStopRunMessage(message)) {
if (!this.activeRun) {
this.postHost({ type: WizardHostMessageType.LOG, level: "warning", message: "No active run to stop." });
return;
}
this.activeRun.abort();
this.postHost({ type: WizardHostMessageType.LOG, level: "warning", message: "Migration stop requested." });
return;
}
if (!isRunStartMessage(message)) {
this.postHost({
type: WizardHostMessageType.ERROR,
message: "Received malformed wizard message."
});
return;
}
if (this.activeRun) {
this.postHost({
type: WizardHostMessageType.ERROR,
message: "A migration is already running. Please stop it before starting another."
});
return;
}
await this.executeMigration(message.sourceRoot, message.outputRoot, message.packageName, message.options);
}
private async executeMigration(
sourceRoot: string,
outputRoot: string | undefined,
packageName: string | undefined,
options: Parameters<typeof asMigrationOptions>[0]
): Promise<void> {
if (!sourceRoot) {
this.error("A source root is required.");
return;
}
this.activeRun = new AbortController();
const signal = this.activeRun.signal;
try {
this.postHost({
type: WizardHostMessageType.LOG,
level: "info",
message: `Starting migration from ${sourceRoot}`
});
const migrationOptions = asMigrationOptions(options);
this.postProgress("discover", "Collecting workspace files");
const files = await this.adapter.listFiles(sourceRoot, signal);
signal.throwIfAborted();
this.postHost({
type: WizardHostMessageType.PROGRESS,
phase: "transform",
runId: "pending",
detail: `Preparing migration from ${files.length} files`
});
const result = await runMigration(files, migrationOptions);
signal.throwIfAborted();
const runId = result.runId;
const userOutputRoot =
outputRoot && outputRoot.length > 0
? (path.isAbsolute(outputRoot) ? path.normalize(outputRoot) : path.resolve(sourceRoot, outputRoot))
: path.join(sourceRoot, ".harness-swap");
const outputRootIsInsideWorkspace =
outputRoot === undefined ||
path.isAbsolute(outputRoot) ||
path.resolve(sourceRoot) === path.resolve(userOutputRoot) ||
isDescendant(path.resolve(sourceRoot), path.resolve(userOutputRoot));
if (!outputRootIsInsideWorkspace) {
this.error("Output root must be inside the selected source workspace or an absolute path.");
return;
}
const sanitizedName = (packageName ?? "").trim().replaceAll("\\", "_").replaceAll("/", "_");
const safePackageName = ensureZipExtension(sanitizedName.length > 0 ? sanitizedName : exportFileName(result));
const outputPackage = path.join(userOutputRoot, safePackageName);
this.postProgress("package", `Writing package to ${outputPackage}`);
const archiveBytes = await buildZipArchive(result.files);
await this.adapter.writeFile(outputPackage, archiveBytes, signal);
signal.throwIfAborted();
this.postProgress("done", `Done. Created ZIP with ${result.files.length} files.`);
this.logResult(result, outputPackage);
this.postResult(result, outputPackage);
this.postHost({
type: WizardHostMessageType.LOG,
level: "info",
message: `Migration complete: ${outputPackage}`
});
} catch (err) {
if ((err as Error).name === "AbortError") {
this.postHost({
type: WizardHostMessageType.ERROR,
message: "Migration was cancelled."
});
} else {
const message = err instanceof Error ? err.message : String(err);
this.error(message);
}
} finally {
this.activeRun = undefined;
this.postHost({
type: WizardHostMessageType.LOG,
level: "debug",
message: "Run loop complete."
});
}
}
private logResult(result: MigrationResult, outputPackage: string): void {
for (const event of result.events) {
const line = `[${result.runId}] ${event.step}: ${event.message}`;
const level: HostLogMessage["level"] =
event.severity === "warning"
? "warning"
: event.severity === "error"
? "error"
: event.severity === "debug"
? "debug"
: "info";
this.postHost({ type: WizardHostMessageType.LOG, level, message: `${line} (${outputPackage})` });
}
}
private postHost(message: HostOutgoingMessage): void {
void this.panel.webview.postMessage(message);
}
private postProgress(phase: HostProgressMessage["phase"], detail: string): void {
this.postHost({ type: WizardHostMessageType.PROGRESS, phase, detail });
}
private postResult(result: MigrationResult, outputPackage: string): void {
this.postHost({
type: WizardHostMessageType.RESULT,
runId: result.runId,
outputFolder: outputPackage,
fileCount: result.files.length
});
}
private error(message: string): void {
this.postHost({
type: WizardHostMessageType.ERROR,
message
});
this.outputChannel.appendLine(`[ERROR] ${message}`);
}
private renderHtml(folderChoices: WorkspaceFolderChoice[]): string {
const hasWorkspaceFolders = folderChoices.length > 0;
const hasSourceOption = hasWorkspaceFolders ? "true" : "false";
const optionsMarkup = folderChoices.length > 0
? folderChoices
.map(
(folder) =>
`<option value="${escapeHtml(folder.uri)}">${escapeHtml(folder.name)} (${escapeHtml(folder.uri)})</option>`
)
.join("")
: `<option value="">No folders in this workspace</option>`;
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; img-src vscode-resource: https:; script-src 'unsafe-inline'; style-src 'unsafe-inline';"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Harness Swap Wizard</title>
<style>
:root { color-scheme: light dark; font-family: var(--vscode-font-family, sans-serif); }
body { margin: 12px; font-size: 13px; line-height: 1.4; }
.row { margin-bottom: 12px; }
.grid { display: grid; gap: 8px; }
label { display: block; margin-bottom: 4px; }
button { cursor: pointer; }
input, select { width: 100%; box-sizing: border-box; }
.log { margin-top: 12px; border: 1px solid var(--vscode-panel-border); padding: 8px; height: 260px; overflow: auto; white-space: pre-wrap; }
.line { margin-bottom: 2px; }
.ok { color: var(--vscode-terminal-ansiGreen); }
.warn { color: var(--vscode-terminal-ansiYellow); }
.error { color: var(--vscode-terminal-ansiRed); }
.debug { color: var(--vscode-terminal-ansiBlue); }
.header { margin-bottom: 8px; font-weight: 600; }
</style>
</head>
<body>
<div class="header">Harness Swap Migration Wizard</div>
<div class="grid">
<div class="row">
<label for="source-root">Source workspace folder</label>
<select id="source-root" ${hasWorkspaceFolders ? "" : "disabled"}>
${optionsMarkup}
</select>
</div>
<div class="row">
<label for="output-root">Output root (optional)</label>
<input id="output-root" type="text" placeholder="Defaults to &lt;source&gt;/.harness-swap" />
</div>
<div class="row">
<label for="package-name">Package file</label>
<input id="package-name" type="text" placeholder="Defaults to harness-swap-&lt;source&gt;-&lt;timestamp&gt;.zip" />
</div>
<div class="row">
<label for="max-file-bytes">Max file bytes</label>
<input id="max-file-bytes" type="number" min="1" max="10485760" value="524288" />
</div>
<div class="row">
<label for="target-model">Target model (optional)</label>
<input id="target-model" type="text" placeholder="gpt-5.4" />
</div>
<div class="row">
<label><input id="strict" type="checkbox" /> Strict mode</label>
</div>
<div class="row">
<label><input id="include-evidence" type="checkbox" checked /> Include evidence</label>
</div>
<div class="row">
<button id="run">Run migration</button>
<button id="stop" type="button">Stop</button>
</div>
<div id="status" class="row"></div>
<div id="log" class="log" aria-live="polite"></div>
</div>
<script>
const hasSourceFolder = ${hasSourceOption};
const vscode = acquireVsCodeApi();
const logEl = document.getElementById("log");
const statusEl = document.getElementById("status");
const sourceRootEl = document.getElementById("source-root");
const outputRootEl = document.getElementById("output-root");
const packageNameEl = document.getElementById("package-name");
const maxFileBytesEl = document.getElementById("max-file-bytes");
const targetModelEl = document.getElementById("target-model");
const strictEl = document.getElementById("strict");
const evidenceEl = document.getElementById("include-evidence");
const runEl = document.getElementById("run");
const stopEl = document.getElementById("stop");
const write = (line, cls = "") => {
const node = document.createElement("div");
node.className = "line " + cls;
node.textContent = line;
logEl.appendChild(node);
logEl.scrollTop = logEl.scrollHeight;
};
if (!hasSourceFolder) {
runEl.disabled = true;
statusEl.textContent = "No workspace folders available. Open a folder and try again.";
}
runEl.addEventListener("click", () => {
const maxFileBytes = Number.parseInt(maxFileBytesEl.value, 10);
vscode.postMessage({
type: "wizard.run.start",
sourceRoot: sourceRootEl.value,
outputRoot: outputRootEl.value || undefined,
packageName: packageNameEl.value || undefined,
options: {
strict: strictEl.checked,
includeEvidence: evidenceEl.checked,
targetModel: targetModelEl.value || undefined,
maxFileBytes: Number.isFinite(maxFileBytes) && maxFileBytes > 0 ? maxFileBytes : undefined
}
});
statusEl.textContent = "Run requested...";
});
stopEl.addEventListener("click", () => {
vscode.postMessage({ type: "wizard.run.stop" });
statusEl.textContent = "Stop requested.";
});
window.addEventListener("message", (event) => {
const msg = event.data;
switch (msg.type) {
case "wizard.log":
write("[" + msg.level + "] " + msg.message, msg.level === "error" ? "error" : msg.level === "warning" ? "warn" : msg.level === "debug" ? "debug" : "ok");
break;
case "wizard.progress":
statusEl.textContent = msg.phase + ": " + msg.detail;
write("STATUS: " + msg.phase + ": " + msg.detail);
break;
case "wizard.result":
statusEl.textContent = "Done: " + msg.fileCount + " files exported to " + msg.outputFolder;
write("Done: " + msg.fileCount + " files exported to " + msg.outputFolder);
break;
case "wizard.error":
statusEl.textContent = "Error: " + msg.message;
write("ERROR: " + msg.message, "error");
break;
}
});
vscode.postMessage({ type: "wizard.ready" });
</script>
</body>
</html>
`;
}
}
function isDescendant(basePath: string, candidatePath: string): boolean {
const normalizedBase = `${path.resolve(basePath)}${path.sep}`;
return candidatePath.startsWith(normalizedBase) && path.resolve(candidatePath) !== path.resolve(basePath);
}
function ensureZipExtension(value: string): string {
return value.toLowerCase().endsWith(".zip") ? value : `${value}.zip`;
}
function escapeHtml(text: string): string {
return text
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}