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 { 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[0] ): Promise { 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) => `` ) .join("") : ``; return ` Harness Swap Wizard
Harness Swap Migration Wizard
`; } } 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("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """); }