diff --git a/CHANGELOG.md b/CHANGELOG.md index 207d6acb..9446403d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### Added +- Persistent sessions for running testcases and stress tester. Whenever the active editor changes, the previous session is saved and restored when the file is opened again - Ctrl+Enter hotkey to save currently edited textarea - Ctrl+Enter to append newline instead of sending current online input - Automatically show notification to check changelog when extension is updated @@ -10,9 +11,9 @@ ### Changed -- Use native addons to run solutions and enforce limits instead of using child_process. This bypasses the event loop and allows for more accurate limits as well as accurate metrics. Using native addons also means we effectively restrict this extension to only run on Windows, Linux, and macOS, which are the platforms that VSCode supports +- Use native addons to run solutions and enforce limits instead of using child_process. This bypasses the event loop and allows for more accurate limits as well as accurate metrics. Using native addons also means we effectively restrict this extension to only run on Windows, Linux, and macOS. The web platform is excluded. - Use total CPU time to enforce time limit and a 2x multipler to enforce the wall time -- The extension excludes the web platform support explicitly and will not provide a "universal" VSIX. It would have failed implicity in the past, but now this restriction is enforced to platform specific VSIX. +- Changes are debounced on judge prevent rapid IO bottlenecks - Trim off trailing whitespaces when requesting full data - Don't save ongoing running statuses to avoid blocking interacting with testcase on malfunction - Removed unused save all functionality diff --git a/package.json b/package.json index bdd0c3e7..c559afc1 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,38 @@ "command": "fastolympiccoding.toggleJudgeSettings", "when": "false" } + ], + "view/item/context": [ + { + "command": "fastolympiccoding.listenForCompetitiveCompanion", + "group": "inline", + "when": "view == fastolympiccoding.panel && viewItem == companion-stopped" + }, + { + "command": "fastolympiccoding.stopCompetitiveCompanion", + "group": "inline", + "when": "view == fastolympiccoding.panel && viewItem == companion-listening" + }, + { + "command": "fastolympiccoding.stopBackgroundTests", + "group": "inline", + "when": "view == fastolympiccoding.panel && viewItem == judge-background-file" + }, + { + "command": "fastolympiccoding.stopStressSession", + "group": "inline", + "when": "view == fastolympiccoding.panel && viewItem == stress-background-file" + }, + { + "command": "fastolympiccoding.stopAllBackgroundTests", + "group": "inline", + "when": "view == fastolympiccoding.panel && viewItem == judge-background-group" + }, + { + "command": "fastolympiccoding.stopAllStressSessions", + "group": "inline", + "when": "view == fastolympiccoding.panel && viewItem == stress-background-group" + } ] }, "viewsContainers": { @@ -84,6 +116,13 @@ "title": "Fast Olympic Coding", "icon": "$(zap)" } + ], + "panel": [ + { + "id": "fastolympiccodingpanel", + "title": "Fast Olympic Coding", + "icon": "$(zap)" + } ] }, "views": { @@ -100,6 +139,13 @@ "type": "webview", "name": "Stress" } + ], + "fastolympiccodingpanel": [ + { + "id": "fastolympiccoding.panel", + "name": "Status", + "icon": "$(zap)" + } ] }, "configuration": [ @@ -295,12 +341,14 @@ { "command": "fastolympiccoding.listenForCompetitiveCompanion", "title": "Listen for Competitive Companion", - "category": "Fast Olympic Coding" + "category": "Fast Olympic Coding", + "icon": "$(broadcast)" }, { "command": "fastolympiccoding.stopCompetitiveCompanion", "title": "Stop Competitive Companion", - "category": "Fast Olympic Coding" + "category": "Fast Olympic Coding", + "icon": "$(circle-slash)" }, { "command": "fastolympiccoding.toggleJudgeSettings", @@ -318,6 +366,30 @@ "command": "fastolympiccoding.showChangelog", "title": "Show Changelog", "category": "Fast Olympic Coding" + }, + { + "command": "fastolympiccoding.stopBackgroundTests", + "title": "Stop", + "category": "Fast Olympic Coding", + "icon": "$(debug-stop)" + }, + { + "command": "fastolympiccoding.stopStressSession", + "title": "Stop Stress Test", + "category": "Fast Olympic Coding", + "icon": "$(debug-stop)" + }, + { + "command": "fastolympiccoding.stopAllBackgroundTests", + "title": "Stop All Running Tests", + "category": "Fast Olympic Coding", + "icon": "$(circle-slash)" + }, + { + "command": "fastolympiccoding.stopAllStressSessions", + "title": "Stop All Stress Sessions", + "category": "Fast Olympic Coding", + "icon": "$(circle-slash)" } ], "keybindings": [ diff --git a/src/extension/competitiveCompanion.ts b/src/extension/competitiveCompanion.ts index 7d32c5d7..ff3132db 100644 --- a/src/extension/competitiveCompanion.ts +++ b/src/extension/competitiveCompanion.ts @@ -38,7 +38,6 @@ class ProblemQueue { // Module state let server: http.Server | undefined; -let statusBarItem: vscode.StatusBarItem | undefined; let prevSelection: string | undefined; let prevBatchId: string | undefined; @@ -226,23 +225,6 @@ function createRequestHandler(judge: JudgeViewProvider): http.RequestListener { }; } -/** - * Creates the status bar item for Competitive Companion. - */ -export function createStatusBarItem(context: vscode.ExtensionContext): vscode.StatusBarItem { - statusBarItem = vscode.window.createStatusBarItem( - "fastolympiccoding.listeningForCompetitiveCompanion", - vscode.StatusBarAlignment.Left - ); - statusBarItem.name = "Competitive Companion Indicator"; - statusBarItem.text = "$(zap)"; - statusBarItem.tooltip = "Listening For Competitive Companion"; - statusBarItem.hide(); - context.subscriptions.push(statusBarItem); - - return statusBarItem; -} - /** * Starts listening for Competitive Companion connections. */ @@ -259,7 +241,7 @@ export function createListener(judgeViewProvider: JudgeViewProvider): void { const port = config.get("port")!; const logger = getLogger("competitive-companion"); logger.info(`Listener started on port ${port}`); - statusBarItem?.show(); + _onDidChangeListening.fire(true); }); server.once("error", (error) => { const logger = getLogger("competitive-companion"); @@ -270,7 +252,7 @@ export function createListener(judgeViewProvider: JudgeViewProvider): void { }); server.once("close", () => { server = undefined; - statusBarItem?.hide(); + _onDidChangeListening.fire(false); }); const config = vscode.workspace.getConfiguration("fastolympiccoding"); @@ -282,6 +264,15 @@ export function createListener(judgeViewProvider: JudgeViewProvider): void { * Stops listening for Competitive Companion connections. */ export function stopCompetitiveCompanion(): void { - server?.close(); - server = undefined; + if (server) { + server.close(); + server = undefined; + } +} + +export function isListening(): boolean { + return server !== undefined; } + +const _onDidChangeListening = new vscode.EventEmitter(); +export const onDidChangeListening = _onDidChangeListening.event; diff --git a/src/extension/index.ts b/src/extension/index.ts index 18cff09f..0901c2ac 100644 --- a/src/extension/index.ts +++ b/src/extension/index.ts @@ -3,11 +3,7 @@ import * as path from "node:path"; import * as vscode from "vscode"; import { compile, clearCompileCache } from "./utils/runtime"; -import { - createStatusBarItem, - createListener, - stopCompetitiveCompanion, -} from "./competitiveCompanion"; +import { createListener, stopCompetitiveCompanion } from "./competitiveCompanion"; import { registerRunSettingsCommands } from "./runSettingsCommands"; import { initializeRunSettingsWatcher, @@ -17,10 +13,13 @@ import { import { initLogging } from "./utils/logging"; import JudgeViewProvider from "./providers/JudgeViewProvider"; import StressViewProvider from "./providers/StressViewProvider"; +import PanelViewProvider from "./providers/PanelViewProvider"; import { showChangelog } from "./changelog"; +import { createStatusBarItem } from "./statusBar"; let judgeViewProvider: JudgeViewProvider; let stressViewProvider: StressViewProvider; +let panelViewProvider: PanelViewProvider; type Dependencies = Record; @@ -76,6 +75,15 @@ function registerViewProviders(context: vscode.ExtensionContext): void { context.subscriptions.push( vscode.window.registerWebviewViewProvider(stressViewProvider.getViewId(), stressViewProvider) ); + + // Panel tree view in panel area (bottom) + panelViewProvider = new PanelViewProvider(context, judgeViewProvider, stressViewProvider); + context.subscriptions.push( + vscode.window.createTreeView("fastolympiccoding.panel", { + treeDataProvider: panelViewProvider, + showCollapseAll: false, + }) + ); } function registerDocumentContentProviders(context: vscode.ExtensionContext): void { @@ -246,6 +254,46 @@ function registerCommands(context: vscode.ExtensionContext): void { context.subscriptions.push( vscode.commands.registerCommand("fastolympiccoding.showChangelog", () => showChangelog(context)) ); + + context.subscriptions.push( + vscode.commands.registerCommand("fastolympiccoding.showPanel", () => { + vscode.commands.executeCommand("fastolympiccoding.panel.focus"); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand( + "fastolympiccoding.stopBackgroundTests", + (item: { filePath?: string }) => { + if (item?.filePath) { + void judgeViewProvider.stopBackgroundTasksForFile(item.filePath); + } + } + ) + ); + + context.subscriptions.push( + vscode.commands.registerCommand( + "fastolympiccoding.stopStressSession", + (item: { filePath?: string }) => { + if (item?.filePath) { + stressViewProvider.stopStressSession(item.filePath); + } + } + ) + ); + + context.subscriptions.push( + vscode.commands.registerCommand("fastolympiccoding.stopAllStressSessions", () => { + stressViewProvider.stopAll(); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand("fastolympiccoding.stopAllBackgroundTests", () => { + void judgeViewProvider.stopAllBackgroundTasks(); + }) + ); } export function activate(context: vscode.ExtensionContext): void { diff --git a/src/extension/providers/JudgeViewProvider.ts b/src/extension/providers/JudgeViewProvider.ts index d7800c09..aabd4102 100644 --- a/src/extension/providers/JudgeViewProvider.ts +++ b/src/extension/providers/JudgeViewProvider.ts @@ -1,5 +1,6 @@ import * as vscode from "vscode"; import * as v from "valibot"; +import * as crypto from "crypto"; import { ProblemSchema, @@ -57,8 +58,6 @@ const FileDataSchema = v.fallback( { timeLimit: 0, memoryLimit: 0, testcases: [] } ); -const defaultFileData = v.parse(FileDataSchema, {}); - type State = Omit< ITestcase, "stdin" | "stderr" | "stdout" | "acceptedStdout" | "interactorSecret" @@ -68,13 +67,19 @@ type State = Omit< stdout: TextHandler; acceptedStdout: TextHandler; interactorSecret: TextHandler; - id: number; process: Runnable; interactorProcess: Runnable; interactorSecretResolver?: () => void; donePromise: Promise | null; + cancellationSource?: vscode.CancellationTokenSource; }; +interface RuntimeContext { + state: State[]; + timeLimit: number; + memoryLimit: number; +} + type ExecutionContext = { token: vscode.CancellationToken; testcase: State; @@ -123,25 +128,47 @@ function updateInteractiveTestcaseFromTermination( } export default class extends BaseViewProvider { - private _state: State[] = []; - private _timeLimit = 0; - private _memoryLimit = 0; - private _newId = 0; - private _fileCancellation?: vscode.CancellationTokenSource; - private _activeDebugTestcaseId?: number; + // Centralized context storage for all files (both active and background) + private _contexts: Map = new Map(); + + private _activeDebugTestcaseUuid?: string; + + private _onDidChangeBackgroundTasks = new vscode.EventEmitter(); + readonly onDidChangeBackgroundTasks = this._onDidChangeBackgroundTasks.event; + + private _viewReady!: Promise; + private _resolveViewReady!: () => void; + + private _resetViewReady() { + this._viewReady = new Promise((resolve) => { + this._resolveViewReady = resolve; + }); + } + + // Accessor for the current file's context + private get _runtime(): RuntimeContext { + if (!this._currentFile) { + throw new Error("No current file active"); + } + const ctx = this._contexts.get(this._currentFile); + if (!ctx) { + throw new Error(`Context not initialized for ${this._currentFile}`); + } + return ctx; + } // If the testcase is interactive, ensure interactive settings are also valid and resolved private async _getExecutionContext( - id: number, + uuid: string, extraVariables?: Record ): Promise { - const token = this._fileCancellation?.token; - if (!token || token.isCancellationRequested || !this._currentFile) { + const testcase = this._findTestcase(uuid); + if (!testcase) { return null; } - const testcase = this._findTestcase(id); - if (!testcase) { + const token = testcase.cancellationSource?.token; + if (!token || token.isCancellationRequested || !this._currentFile) { return null; } @@ -152,7 +179,7 @@ export default class extends BaseViewProvider { if (token.isCancellationRequested) { @@ -306,33 +333,33 @@ export default class extends BaseViewProvider { testcase.process.stdin?.end(); - }); + }) + .run(interactorArgs!, 0, 0, cwd); testcase.process .on("stderr:data", (data: string) => testcase.stderr.write(data, "force")) @@ -426,7 +445,14 @@ export default class extends BaseViewProvider { testcase.interactorProcess.stdin?.end(); - }); + }) + .run( + runCommand, + bypassLimits ? 0 : this._runtime.timeLimit, + bypassLimits ? 0 : this._runtime.memoryLimit, + cwd + ); + this._onDidChangeBackgroundTasks.fire(); const [termination, interactorTermination] = await Promise.all([ testcase.process.done, @@ -440,29 +466,30 @@ export default class extends BaseViewProvider) { switch (msg.type) { case "LOADED": + this._resolveViewReady(); this.loadCurrentFileData(); break; case "NEXT": @@ -496,16 +523,21 @@ export default class extends BaseViewProvider { - const id = session.configuration?.fastolympiccodingTestcaseId; - if (typeof id !== "number") { + const uuid = session.configuration?.fastolympiccodingTestcaseUuid; + if (typeof uuid !== "string") { return; } - this._activeDebugTestcaseId = id; + this._activeDebugTestcaseUuid = uuid; }), vscode.debug.onDidTerminateDebugSession((session) => { - const id = session.configuration?.fastolympiccodingTestcaseId; - if (typeof id === "number" && this._activeDebugTestcaseId === id) { - this._stop(id); - this._activeDebugTestcaseId = undefined; + const uuid = session.configuration?.fastolympiccodingTestcaseId; + if (typeof uuid === "string" && this._activeDebugTestcaseUuid === uuid) { + this._stop(uuid); + this._activeDebugTestcaseUuid = undefined; } }) ); + this._resetViewReady(); this.onShow(); } // Judge has state if there are testcases loaded protected override _hasState(): boolean { - return this._state.length > 0 || this._timeLimit !== 0 || this._memoryLimit !== 0; + return ( + this._runtime.state.length > 0 || + this._runtime.timeLimit !== 0 || + this._runtime.memoryLimit !== 0 + ); } protected override _sendShowMessage(visible: boolean): void { @@ -548,151 +585,127 @@ export default class extends BaseViewProvider { + this._onDidChangeBackgroundTasks.fire(); + }); + } } - this._state = []; - this._timeLimit = 0; - this._memoryLimit = 0; - this._newId = 0; - this._currentFile = undefined; + this._currentFile = undefined; this._sendShowMessage(false); + this._onDidChangeBackgroundTasks.fire(); } - protected override _switchToFile(file: string) { - // Cancel any in-flight operations for the previous file - this._fileCancellation?.cancel(); - this._fileCancellation?.dispose(); - this._fileCancellation = new vscode.CancellationTokenSource(); + private _syncTestcaseState(testcase: State) { + const uuid = testcase.uuid; + super._postMessage({ type: "SET", uuid, property: "stdin", value: testcase.stdin.data }); + super._postMessage({ type: "SET", uuid, property: "stderr", value: testcase.stderr.data }); + super._postMessage({ type: "SET", uuid, property: "stdout", value: testcase.stdout.data }); + super._postMessage({ + type: "SET", + uuid, + property: "acceptedStdout", + value: testcase.acceptedStdout.data, + }); + super._postMessage({ type: "SET", uuid, property: "elapsed", value: testcase.elapsed }); + super._postMessage({ type: "SET", uuid, property: "memoryBytes", value: testcase.memoryBytes }); + super._postMessage({ type: "SET", uuid, property: "status", value: testcase.status }); + super._postMessage({ type: "SET", uuid, property: "shown", value: testcase.shown }); + super._postMessage({ type: "SET", uuid, property: "toggled", value: testcase.toggled }); + super._postMessage({ type: "SET", uuid, property: "skipped", value: testcase.skipped }); + super._postMessage({ type: "SET", uuid, property: "mode", value: testcase.mode }); + super._postMessage({ + type: "SET", + uuid, + property: "interactorSecret", + value: testcase.interactorSecret.data, + }); + } + + protected override async _switchToFile(file: string) { + await this._viewReady; + this._moveCurrentStateToBackground(); + + // Ensure target context exists + if (!this._contexts.has(file)) { + // LOAD FROM DISK (simplified for brevity of replacement, assuming unmodified lines follow) + const storageData = super.readStorage()[file]; + const fileData = v.parse(FileDataSchema, storageData ?? {}); + const timeLimit = fileData.timeLimit; + const memoryLimit = fileData.memoryLimit; + const state: State[] = []; + + // Parse testcases + for (const rawTestcase of fileData.testcases) { + try { + const testcase = v.parse(TestcaseSchema, rawTestcase); + state.push(this._createTestcaseState(testcase.mode, testcase)); + } catch (e) { + console.error("Failed to parse testcase", e); + } + } - // Stop any processes tied to the previous file, and clear state/webview. - this.stopAll(); - for (const testcase of this._state) { - super._postMessage({ type: "DELETE", id: testcase.id }); + this._contexts.set(file, { + state, + timeLimit, + memoryLimit, + }); } - this._state = []; - this._timeLimit = 0; - this._memoryLimit = 0; - this._newId = 0; + // Switch file this._currentFile = file; this._sendShowMessage(true); - const storageData = super.readStorage()[file]; - const fileData = v.parse(FileDataSchema, storageData ?? {}); - const testcases = fileData.testcases; - this._timeLimit = fileData.timeLimit; - this._memoryLimit = fileData.memoryLimit; - for (let i = 0; i < testcases.length; i++) { - const testcase = v.parse(TestcaseSchema, testcases[i]); - this._addTestcase(testcase.mode, testcase); - } + // Rehydrate UI + const ctx = this._runtime; super._postMessage({ type: "INITIAL_STATE", - timeLimit: this._timeLimit, - memoryLimit: this._memoryLimit, + timeLimit: ctx.timeLimit, + memoryLimit: ctx.memoryLimit, }); + + for (const testcase of ctx.state) { + super._postMessage({ type: "NEW", uuid: testcase.uuid }); + this._syncTestcaseState(testcase); + + if (testcase.donePromise) { + void this._awaitTestcaseCompletion(testcase.uuid); + } + } } - protected override _rehydrateWebviewFromState() { + protected override async _rehydrateWebviewFromState() { + await this._viewReady; super._postMessage({ type: "INITIAL_STATE", - timeLimit: this._timeLimit, - memoryLimit: this._memoryLimit, + timeLimit: this._runtime.timeLimit, + memoryLimit: this._runtime.memoryLimit, }); - for (const testcase of this._state) { - const id = testcase.id; + for (const testcase of this._runtime.state) { + const uuid = testcase.uuid; - // Ensure a clean slate for this id in the webview. - super._postMessage({ type: "DELETE", id }); - super._postMessage({ type: "NEW", id }); - - super._postMessage({ - type: "SET", - id, - property: "stdin", - value: testcase.stdin.data, - }); - super._postMessage({ - type: "SET", - id, - property: "stderr", - value: testcase.stderr.data, - }); - super._postMessage({ - type: "SET", - id, - property: "stdout", - value: testcase.stdout.data, - }); - super._postMessage({ - type: "SET", - id, - property: "acceptedStdout", - value: testcase.acceptedStdout.data, - }); - super._postMessage({ - type: "SET", - id, - property: "elapsed", - value: testcase.elapsed, - }); - super._postMessage({ - type: "SET", - id, - property: "memoryBytes", - value: testcase.memoryBytes, - }); - super._postMessage({ - type: "SET", - id, - property: "status", - value: testcase.status, - }); - super._postMessage({ - type: "SET", - id, - property: "shown", - value: testcase.shown, - }); - super._postMessage({ - type: "SET", - id, - property: "toggled", - value: testcase.toggled, - }); - super._postMessage({ - type: "SET", - id, - property: "skipped", - value: testcase.skipped, - }); - super._postMessage({ - type: "SET", - id, - property: "mode", - value: testcase.mode, - }); - super._postMessage({ - type: "SET", - id, - property: "interactorSecret", - value: testcase.interactorSecret.data, - }); + // Ensure a clean slate for this uuid in the webview. + super._postMessage({ type: "DELETE", uuid }); + super._postMessage({ type: "NEW", uuid }); + this._syncTestcaseState(testcase); } } addFromCompetitiveCompanion(file: string, data: IProblem) { const testcases: ITestcase[] = data.tests.map( (test: ITest): ITestcase => ({ + uuid: crypto.randomUUID(), stdin: test.input, stderr: "", stdout: "", @@ -711,18 +724,19 @@ export default class extends BaseViewProvider testcase.id)]; - for (const id of ids) { - this._delete(id); + const uuids = [...this._runtime.state.map((testcase) => testcase.uuid)]; + for (const uuid of uuids) { + this._delete(uuid); } } @@ -786,51 +800,121 @@ export default class extends BaseViewProvider t.donePromise !== null).map((state) => state.uuid); + } + + getAllBackgroundTasks(): Map { + const result = new Map(); + for (const [file, context] of this._contexts.entries()) { + const runningTasks = context.state.filter((t) => t.donePromise !== null); + if (runningTasks.length > 0) { + result.set( + file, + runningTasks.map((state) => state.uuid) + ); + } + } + return result; + } + + async stopBackgroundTasksForFile(file: string): Promise { + const context = this._contexts.get(file); + if (!context) { + return; + } + + // Stop all processes + for (const state of context.state) { + state.process.stop(); + state.interactorProcess.stop(); + } + + // Wait for all to complete + const donePromises: Promise[] = []; + for (const state of context.state) { + if (state.donePromise) { + donePromises.push(state.donePromise); + } + } + await Promise.all(donePromises); + + this._onDidChangeBackgroundTasks.fire(); + } + + async stopAllBackgroundTasks(): Promise { + const files = Array.from(this._contexts.keys()); + await Promise.all(files.map((file) => this.stopBackgroundTasksForFile(file))); + } + private _nextTestcase({ mode }: v.InferOutput) { void this._run(this._addTestcase(mode, undefined), true); } - private _action({ id, action }: v.InferOutput) { + private _action({ uuid, action }: v.InferOutput) { switch (action) { case "RUN": - void this._run(id, false); + void this._run(uuid, false); break; case "DEBUG": - void this._debug(id); + void this._debug(uuid); break; case "STOP": - this._stop(id); + this._stop(uuid); break; case "DELETE": - this._delete(id); + this._delete(uuid); break; case "ACCEPT": - this._accept(id); + this._accept(uuid); break; case "DECLINE": - this._decline(id); + this._decline(uuid); break; case "TOGGLE_VISIBILITY": - this._toggleVisibility(id); + this._toggleVisibility(uuid); break; case "TOGGLE_SKIP": - this._toggleSkip(id); + this._toggleSkip(uuid); break; case "COMPARE": - this._compare(id); + this._compare(uuid); break; } + this.requestSave(); } - private _saveFileData() { - const file = this._currentFile; - if (!file) { - return; + private _saveTimer: NodeJS.Timeout | undefined; + + private forceSave() { + if (this._saveTimer) { + clearTimeout(this._saveTimer); + this._saveTimer = undefined; } + this._saveAllState(); + } - const testcases: ITestcase[] = []; - for (const testcase of this._state) { - testcases.push({ + private requestSave() { + if (this._saveTimer) { + clearTimeout(this._saveTimer); + } + this._saveTimer = setTimeout(() => { + this._saveAllState(); + this._saveTimer = undefined; + }, 1000); + } + + private _saveAllState() { + const allData = this.readStorage(); + + const serialize = (state: State[], timeLimit: number, memoryLimit: number): FileData => { + const testcases: ITestcase[] = state.map((testcase) => ({ + uuid: testcase.uuid, stdin: testcase.stdin.data, stderr: testcase.stderr.data, stdout: testcase.stdout.data, @@ -843,31 +927,38 @@ export default class extends BaseViewProvider) { - const id = this._newId++; - this._state.push(this._createTestcaseState(id, mode, testcase)); - return id; + const newState = this._createTestcaseState(mode, testcase); + this._runtime.state.push(newState); + + return newState.uuid; } - private _createTestcaseState(id: number, mode: Mode, testcase?: Partial) { - // using partial type to have backward compatibility with old testcases - // create a new testcase in webview and fill it in later - super._postMessage({ type: "NEW", id }); + private _createTestcaseState(mode: Mode, testcase?: Partial) { + const uuid = testcase?.uuid ?? crypto.randomUUID(); + + // Create a new testcase in webview + super._postMessage({ type: "NEW", uuid }); const newTestcase: State = { + uuid, stdin: new TextHandler(), stderr: new TextHandler(), stdout: new TextHandler(), @@ -880,7 +971,6 @@ export default class extends BaseViewProvider super._postMessage({ type: "STDIO", - id, + uuid, stdio: "STDIN", data, }); newTestcase.stderr.callback = (data: string) => super._postMessage({ type: "STDIO", - id, + uuid, stdio: "STDERR", data, }); newTestcase.stdout.callback = (data: string) => super._postMessage({ type: "STDIO", - id, + uuid, stdio: "STDOUT", data, }); newTestcase.acceptedStdout.callback = (data: string) => super._postMessage({ type: "STDIO", - id, + uuid, stdio: "ACCEPTED_STDOUT", data, }); newTestcase.interactorSecret.callback = (data: string) => super._postMessage({ type: "STDIO", - id, + uuid, stdio: "INTERACTOR_SECRET", data, }); @@ -932,207 +1022,203 @@ export default class extends BaseViewProvider { - const ctx = await this._getExecutionContext(id); - if (!ctx) { + private async _awaitTestcaseCompletion(uuid: string): Promise { + const testcase = this._findTestcase(uuid); + if (!testcase?.donePromise) { return; } - const testcase = this._findTestcase(id); + await testcase.donePromise; + testcase.donePromise = null; + this._onDidChangeBackgroundTasks.fire(); + } + + private async _run(uuid: string, bypassLimits: boolean): Promise { + const testcase = this._findTestcase(uuid); if (!testcase) { return; } const donePromise = testcase.donePromise; if (donePromise) { - await donePromise; return; } + testcase.cancellationSource = new vscode.CancellationTokenSource(); testcase.donePromise = new Promise((resolve) => { void (async () => { + const ctx = await this._getExecutionContext(uuid); + if (!ctx) { + resolve(); + return; + } + if (ctx.testcase.mode === "interactive") { - await this._launchInteractiveTestcase(ctx, newTestcase, false); + await this._launchInteractiveTestcase(ctx, bypassLimits, false); } else { - await this._launchTestcase(ctx, newTestcase, false); + await this._launchTestcase(ctx, bypassLimits, false); } resolve(); })(); }); - await testcase.donePromise; - testcase.donePromise = null; + await this._awaitTestcaseCompletion(uuid); } - private async _debug(id: number): Promise { - let debugPort: number; - try { - debugPort = await findAvailablePort(); - } catch (error) { - const logger = getLogger("judge"); - const errorMessage = error instanceof Error ? error.message : String(error); - logger.error(`Failed to allocate debug port because ${errorMessage}`); - vscode.window.showErrorMessage("Failed to find available port for debugging"); + private async _debug(uuid: string): Promise { + const testcase = this._findTestcase(uuid); + if (!testcase) { return; } - const extraVariables = { debugPort: String(debugPort) }; - const ctx = await this._getExecutionContext(id, extraVariables); - if (!ctx || !this._currentFile) { + if (testcase.donePromise) { return; } - if (!ctx.languageSettings.debugCommand) { - const logger = getLogger("judge"); - logger.error(`No debug command for ${this._currentFile}`); - showOpenRunSettingsErrorWindow( - `No debug command for ${this._currentFile}`, - this._currentFile - ); - return; - } - if (!ctx.languageSettings.debugAttachConfig) { - const logger = getLogger("judge"); - logger.error(`No debug attach configuration for ${this._currentFile}`); - showOpenRunSettingsErrorWindow( - `No debug attach configuration for ${this._currentFile}`, - this._currentFile - ); - return; - } + testcase.cancellationSource = new vscode.CancellationTokenSource(); + testcase.donePromise = new Promise((resolve) => { + void (async () => { + let debugPort: number; + try { + debugPort = await findAvailablePort(); + } catch (error) { + const logger = getLogger("judge"); + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error(`Failed to allocate debug port because ${errorMessage}`); + vscode.window.showErrorMessage("Failed to find available port for debugging"); + resolve(); + return; + } + const extraVariables = { debugPort: String(debugPort) }; - // get the attach debug configuration - const folder = - vscode.workspace.getWorkspaceFolder(vscode.Uri.file(this._currentFile)) ?? - vscode.workspace.workspaceFolders?.at(0); - const attachConfig = vscode.workspace - .getConfiguration("launch", folder) - .get("configurations", []) - .find((config) => config.name === ctx.languageSettings.debugAttachConfig); - if (!attachConfig) { - const logger = getLogger("judge"); - logger.error( - `Debug attach configuration "${ctx.languageSettings.debugAttachConfig}" not found` - ); - showOpenRunSettingsErrorWindow( - `Debug attach configuration "${ctx.languageSettings.debugAttachConfig}" not found`, - this._currentFile - ); - return; - } + const ctx = await this._getExecutionContext(uuid, extraVariables); + if (!ctx || !this._currentFile) { + resolve(); + return; + } - // No limits for debugging testcases - if (ctx.testcase.mode === "interactive") { - this._launchInteractiveTestcase(ctx, true, true); - } else { - this._launchTestcase(ctx, true, true); - } + if (!ctx.languageSettings.debugCommand) { + const logger = getLogger("judge"); + logger.error(`No debug command for ${this._currentFile}`); + showOpenRunSettingsErrorWindow( + `No debug command for ${this._currentFile}`, + this._currentFile + ); + resolve(); + return; + } + if (!ctx.languageSettings.debugAttachConfig) { + const logger = getLogger("judge"); + logger.error(`No debug attach configuration for ${this._currentFile}`); + showOpenRunSettingsErrorWindow( + `No debug attach configuration for ${this._currentFile}`, + this._currentFile + ); + resolve(); + return; + } - // Wait for the debug process to spawn before attaching - const spawnedPromises = [ctx.testcase.process.spawned]; - if (ctx.testcase.mode === "interactive") { - spawnedPromises.push(ctx.testcase.interactorProcess.spawned); - } - const spawned = await Promise.all(spawnedPromises); - let allSpawned = true; - for (const spawnedProcess of spawned) { - if (!spawnedProcess) { - allSpawned = false; - } - } + // get the attach debug configuration + const folder = + vscode.workspace.getWorkspaceFolder(vscode.Uri.file(this._currentFile)) ?? + vscode.workspace.workspaceFolders?.at(0); + const attachConfig = vscode.workspace + .getConfiguration("launch", folder) + .get("configurations", []) + .find((config) => config.name === ctx.languageSettings.debugAttachConfig); + if (!attachConfig) { + const logger = getLogger("judge"); + logger.error( + `Debug attach configuration "${ctx.languageSettings.debugAttachConfig}" not found` + ); + showOpenRunSettingsErrorWindow( + `Debug attach configuration "${ctx.languageSettings.debugAttachConfig}" not found`, + this._currentFile + ); + resolve(); + return; + } - if (!allSpawned || ctx.token.isCancellationRequested) { - await ctx.testcase.process.done; - await ctx.testcase.interactorProcess.done; - const logger = getLogger("judge"); - logger.error(`Debug process failed to spawn`); - vscode.window.showErrorMessage(`Debug process failed to spawn`); - return; - } + // No limits for debugging testcases + if (ctx.testcase.mode === "interactive") { + this._launchInteractiveTestcase(ctx, true, true); + } else { + this._launchTestcase(ctx, true, true); + } - // resolve the values in the attach configuration - const resolvedConfig = resolveVariables(attachConfig, this._currentFile, extraVariables); - - // Tag this debug session so we can identify which testcase is being debugged. - // VS Code preserves custom fields on session.configuration. - resolvedConfig.fastolympiccodingTestcaseId = id; - - // Slight delay to ensure process is listening - // This is less than ideal because it relies on timing, but there is no reliable way - // to detect when the debug server is ready without attempting a connection. If we try - // to connect as a client, we might interfere with the debug session because the server - // treats the first connection as the debuggee. I also tried to check if the port is listening - // via platform specific means, but then I ran into the problem of mismatching PIDs and - // the server running in IPv6 vs IPv4 mode. - await new Promise((resolve) => setTimeout(resolve, 2000)); - - // The configuration is user-provided, and may be invalid. Let VS Code handle validation. - // We just need to bypass our type system here. - const started = await vscode.debug.startDebugging( - folder, - resolvedConfig as vscode.DebugConfiguration - ); - if (!started) { - this._stop(id); - } + // Wait for the debug process to spawn before attaching + const spawnedPromises = [ctx.testcase.process.spawned]; + if (ctx.testcase.mode === "interactive") { + spawnedPromises.push(ctx.testcase.interactorProcess.spawned); + } + const spawned = await Promise.all(spawnedPromises); + let allSpawned = true; + for (const spawnedProcess of spawned) { + if (!spawnedProcess) { + allSpawned = false; + } + } + + if (!allSpawned || ctx.token.isCancellationRequested) { + await ctx.testcase.process.done; + await ctx.testcase.interactorProcess.done; + const logger = getLogger("judge"); + logger.error(`Debug process failed to spawn`); + vscode.window.showErrorMessage(`Debug process failed to spawn`); + resolve(); + return; + } + + // resolve the values in the attach configuration + const resolvedConfig = resolveVariables(attachConfig, this._currentFile, extraVariables); + + // Tag this debug session so we can identify which testcase is being debugged. + // VS Code preserves custom fields on session.configuration. + resolvedConfig.fastolympiccodingTestcaseUuid = uuid; + + // Slight delay to ensure process is listening + // This is less than ideal because it relies on timing, but there is no reliable way + // to detect when the debug server is ready without attempting a connection. If we try + // to connect as a client, we might interfere with the debug session because the server + // treats the first connection as the debuggee. I also tried to check if the port is listening + // via platform specific means, but then I ran into the problem of mismatching PIDs and + // the server running in IPv6 vs IPv4 mode. + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // The configuration is user-provided, and may be invalid. Let VS Code handle validation. + // We just need to bypass our type system here. + const started = await vscode.debug.startDebugging( + folder, + resolvedConfig as vscode.DebugConfiguration + ); + if (!started) { + this._stop(uuid); + } + + await testcase.process.done; + await testcase.interactorProcess.done; + resolve(); + })(); + }); + + await this._awaitTestcaseCompletion(uuid); } - private _stop(id: number) { - const testcase = this._findTestcase(id); + private _stop(uuid: string) { + const testcase = this._findTestcase(uuid); if (!testcase) { return; } // If this testcase is the one currently being debugged, stop the VS Code debug session. // This is more reliable than killing the spawned debug-wrapper process alone. - if (this._activeDebugTestcaseId === id && vscode.debug.activeDebugSession) { + if (this._activeDebugTestcaseUuid === uuid && vscode.debug.activeDebugSession) { void vscode.debug.stopDebugging(vscode.debug.activeDebugSession); } @@ -1140,23 +1226,20 @@ export default class extends BaseViewProvider t.id === id); - const state = this._state[idx]; - - state.process.dispose(); - state.interactorProcess.dispose(); - + private _delete(uuid: string) { + this._stop(uuid); + super._postMessage({ type: "DELETE", uuid }); + const idx = this._runtime.state.findIndex((t) => t.uuid === uuid); if (idx !== -1) { - this._state.splice(idx, 1); + const state = this._runtime.state[idx]; + state.process.dispose(); + state.interactorProcess.dispose(); + this._runtime.state.splice(idx, 1); } - this._saveFileData(); } - private _accept(id: number) { - const testcase = this._findTestcase(id); + private _accept(uuid: string) { + const testcase = this._findTestcase(uuid); if (!testcase) { return; } @@ -1165,24 +1248,22 @@ export default class extends BaseViewProvider) { - const testcase = this._findTestcase(id); + private _viewStdio({ uuid, stdio }: v.InferOutput) { + const testcase = this._findTestcase(uuid); if (!testcase) { return; } @@ -1281,8 +1359,8 @@ export default class extends BaseViewProvider) { - const testcase = this._findTestcase(id); + private _stdin({ uuid, data }: v.InferOutput) { + const testcase = this._findTestcase(uuid); if (!testcase) { return; } @@ -1296,14 +1374,14 @@ export default class extends BaseViewProvider) { - const testcase = this._findTestcase(id); + private async _save({ uuid, stdio, data }: v.InferOutput) { + const testcase = this._findTestcase(uuid); if (!testcase) { return; } // Clear the webview's edit field - const propertyMap: Record = { + const propertyMap: Record = { STDIN: "stdin", ACCEPTED_STDOUT: "acceptedStdout", INTERACTOR_SECRET: "interactorSecret", @@ -1314,7 +1392,7 @@ export default class extends BaseViewProvider) { - this._timeLimit = limit; - this._saveFileData(); + this._runtime.timeLimit = limit; + this.requestSave(); } private _setMemoryLimit({ limit }: v.InferOutput) { - this._memoryLimit = limit; - this._saveFileData(); + this._runtime.memoryLimit = limit; + this.requestSave(); } private _requestTrimmedData({ - id, + uuid, stdio, }: v.InferOutput) { - const testcase = this._findTestcase(id); + const testcase = this._findTestcase(uuid); if (!testcase) { return; } @@ -1387,7 +1465,7 @@ export default class extends BaseViewProvider) { - const testcase = this._findTestcase(id); + private _requestFullData({ uuid, stdio }: v.InferOutput) { + const testcase = this._findTestcase(uuid); if (!testcase) { return; } @@ -1452,13 +1530,13 @@ export default class extends BaseViewProvider t.id === id); + private _findTestcase(uuid: string): State | undefined { + return this._runtime.state.find((t) => t.uuid === uuid); } } diff --git a/src/extension/providers/PanelViewProvider.ts b/src/extension/providers/PanelViewProvider.ts new file mode 100644 index 00000000..0bd1be3e --- /dev/null +++ b/src/extension/providers/PanelViewProvider.ts @@ -0,0 +1,218 @@ +import * as vscode from "vscode"; +import type JudgeViewProvider from "./JudgeViewProvider"; +import type StressViewProvider from "./StressViewProvider"; +import { isListening, onDidChangeListening } from "../competitiveCompanion"; +import { getStatusBarItem } from "../statusBar"; + +class StatusTreeItem extends vscode.TreeItem { + constructor( + public readonly label: string, + public readonly collapsibleState: vscode.TreeItemCollapsibleState, + public readonly description?: string, + public readonly iconPath?: vscode.ThemeIcon, + public readonly command?: vscode.Command, + public readonly filePath?: string, + public readonly context?: "judge" | "stress" | "companion" + ) { + super(label, collapsibleState); + this.description = description; + this.iconPath = iconPath; + this.command = command; + } +} + +export default class PopupViewProvider implements vscode.TreeDataProvider { + private _onDidChangeTreeData = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + + constructor( + private _context: vscode.ExtensionContext, + private _judgeViewProvider: JudgeViewProvider, + private _stressViewProvider: StressViewProvider + ) { + this._context.subscriptions.push(onDidChangeListening(() => this.refresh())); + this._context.subscriptions.push( + this._judgeViewProvider.onDidChangeBackgroundTasks(() => this.refresh()) + ); + this._context.subscriptions.push( + this._stressViewProvider.onDidChangeBackgroundTasks(() => this.refresh()) + ); + this._updateStatus(); + } + + refresh(): void { + this._onDidChangeTreeData.fire(); + this._updateStatus(); + } + + private _updateStatus() { + const statusBarItem = getStatusBarItem(); + if (!statusBarItem) { + return; + } + + const config = vscode.workspace.getConfiguration("fastolympiccoding"); + const port = config.get("port")!; + const listening = isListening(); + + // Status parts + const parts: string[] = []; + + // Companion status + if (listening) { + parts.push(`$(broadcast) ${port}`); + } + + // Judge status + const backgroundTasks = this._judgeViewProvider.getAllBackgroundTasks(); + let totalRunningTests = 0; + for (const uuids of backgroundTasks.values()) { + totalRunningTests += uuids.length; + } + + if (totalRunningTests > 0) { + parts.push(`$(run) ${totalRunningTests}`); + } + + // Stress status + const stressSessions = this._stressViewProvider.getRunningStressSessions(); + if (stressSessions.length > 0) { + parts.push(`$(debug-alt) ${stressSessions.length}`); + } + + if (parts.length === 0) { + statusBarItem.text = "$(zap) Fast Olympic Coding"; + statusBarItem.backgroundColor = undefined; + } else { + statusBarItem.text = `$(zap) Fast Olympic Coding: ${parts.join(" ")}`; + if (totalRunningTests > 0 || stressSessions.length > 0) { + statusBarItem.backgroundColor = new vscode.ThemeColor("statusBarItem.warningBackground"); + } else { + statusBarItem.backgroundColor = undefined; + } + } + } + + getTreeItem(element: StatusTreeItem): vscode.TreeItem { + return element; + } + + getChildren(element?: StatusTreeItem): Thenable { + if (!element) { + // Root level items + const config = vscode.workspace.getConfiguration("fastolympiccoding"); + const port = config.get("port")!; + const listening = isListening(); + + // Show port only when listening + const description = listening ? `Listening on port ${port}` : "Not listening"; + + const companionItem = new StatusTreeItem( + "Competitive Companion", + vscode.TreeItemCollapsibleState.None, + description, + new vscode.ThemeIcon(listening ? "broadcast" : "circle-slash"), + undefined, + undefined, + "companion" + ); + companionItem.contextValue = listening ? "companion-listening" : "companion-stopped"; + + // Get count of background judge tasks + const backgroundTasks = this._judgeViewProvider.getAllBackgroundTasks(); + const judgeTaskCount = backgroundTasks.size; + const judgeDescription = + judgeTaskCount > 0 ? `${judgeTaskCount} file${judgeTaskCount > 1 ? "s" : ""}` : undefined; + + const judgeItem = new StatusTreeItem( + "Judge Testcases", + vscode.TreeItemCollapsibleState.Expanded, + judgeDescription, + new vscode.ThemeIcon("run"), + undefined, + undefined, + "judge" + ); + judgeItem.contextValue = "judge-background-group"; + + // Get count of background stress tasks + const stressSessions = this._stressViewProvider.getRunningStressSessions(); + const stressSessionCount = stressSessions.length; + const stressDescription = + stressSessionCount > 0 + ? `${stressSessionCount} file${stressSessionCount > 1 ? "s" : ""}` + : undefined; + + const stressItem = new StatusTreeItem( + "Stress Test Processes", + vscode.TreeItemCollapsibleState.Expanded, + stressDescription, + new vscode.ThemeIcon("debug-alt"), + undefined, + undefined, + "stress" + ); + stressItem.contextValue = "stress-background-group"; + + return Promise.resolve([companionItem, judgeItem, stressItem]); + } + + if (element.label === "Judge Testcases") { + const backgroundTasks = this._judgeViewProvider.getAllBackgroundTasks(); + const items: StatusTreeItem[] = []; + + for (const [file, uuids] of backgroundTasks.entries()) { + const relativePath = vscode.workspace.asRelativePath(file); + const item = new StatusTreeItem( + relativePath, + vscode.TreeItemCollapsibleState.None, + `${uuids.length} test${uuids.length > 1 ? "s" : ""} running`, + new vscode.ThemeIcon("loading~spin"), + { + command: "vscode.open", + title: "Open File", + arguments: [vscode.Uri.file(file)], + }, + file, + "judge" + ); + item.contextValue = "judge-background-file"; + items.push(item); + } + + // Sort by file name + items.sort((a, b) => a.label.localeCompare(b.label)); + return Promise.resolve(items); + } + + if (element.label === "Stress Test Processes") { + const stressSessions = this._stressViewProvider.getRunningStressSessions(); + const items: StatusTreeItem[] = []; + + for (const file of stressSessions) { + const relativePath = vscode.workspace.asRelativePath(file); + const item = new StatusTreeItem( + relativePath, + vscode.TreeItemCollapsibleState.None, + "Running", + new vscode.ThemeIcon("loading~spin"), + { + command: "vscode.open", + title: "Open File", + arguments: [vscode.Uri.file(file)], + }, + file, + "stress" + ); + item.contextValue = "stress-background-file"; + items.push(item); + } + + // Sort by file name + items.sort((a, b) => a.label.localeCompare(b.label)); + return Promise.resolve(items); + } + + return Promise.resolve([]); + } +} diff --git a/src/extension/providers/StressViewProvider.ts b/src/extension/providers/StressViewProvider.ts index e921d4c1..93b2b162 100644 --- a/src/extension/providers/StressViewProvider.ts +++ b/src/extension/providers/StressViewProvider.ts @@ -61,25 +61,45 @@ type State = { closeHandler: (code: number | null) => void; }; +interface StressContext { + state: State[]; + stopFlag: boolean; + clearFlag: boolean; + running: boolean; + interactiveMode: boolean; + interactiveSecretPromise: Promise | null; + interactorSecretResolver?: () => void; + donePromise: Promise | null; +} + export default class extends BaseViewProvider { - private _state: State[] = []; - private _stopFlag = false; - private _clearFlag = false; - private _running = false; - private _interactiveMode = false; - private _interactiveSecretPromise: Promise | null = null; - private _interactorSecretResolver?: () => void; - private _donePromise: Promise | null = null; - private _generatorState: State; - private _solutionState: State; - private _judgeState: State; - - private _findState(id: StateId): State | null { - const index = this._state.findIndex((state) => state.state === id); - if (index === -1) { - return null; - } - return this._state[index]; + private _contexts: Map = new Map(); + private _onDidChangeBackgroundTasks = new vscode.EventEmitter(); + readonly onDidChangeBackgroundTasks = this._onDidChangeBackgroundTasks.event; + + private get _currentContext(): StressContext | undefined { + return this._currentFile ? this._contexts.get(this._currentFile) : undefined; + } + + // Used by PanelViewProvider + getRunningStressSessions(): string[] { + const running: string[] = []; + for (const [file, ctx] of this._contexts) { + if (ctx.running) { + running.push(file); + } + } + return running; + } + + stopStressSession(file: string) { + const ctx = this._contexts.get(file); + if (ctx && ctx.running) { + ctx.stopFlag = true; + for (const state of ctx.state) { + state.process.stop(); + } + } } onMessage(msg: v.InferOutput): void { @@ -108,11 +128,18 @@ export default class extends BaseViewProvider { - super._postMessage({ - type: "STDIO", - id: state.state, - stdio: "STDIN", - data, - }); - }; - state.stdout.callback = (data: string) => { - super._postMessage({ - type: "STDIO", - id: state.state, - stdio: "STDOUT", - data, - }); - }; - state.stderr.callback = (data: string) => { - super._postMessage({ - type: "STDIO", - id: state.state, - stdio: "STDERR", - data, - }); - }; - } - this.onShow(); } @@ -213,48 +161,99 @@ export default class extends BaseViewProvider s.state === fileState.state); if (!state) continue; state.status = fileState.status; - state.stdin.reset(); - state.stdout.reset(); - state.stderr.reset(); state.stdin.write(fileState.stdin, "force"); state.stdout.write(fileState.stdout, "force"); state.stderr.write(fileState.stderr, "force"); } - // Send full state to webview. - this._rehydrateWebviewFromState(); + return { + state: states, + stopFlag: false, + clearFlag: false, + running: false, + interactiveMode: persistedState.interactiveMode, + interactiveSecretPromise: null, + donePromise: null, + }; + } + + private _createState(file: string, id: StateId): State { + const state: State = { + state: id, + stdin: new TextHandler(), + stdout: new TextHandler(), + stderr: new TextHandler(), + status: "NA", + process: new Runnable(), + errorHandler: (err) => this._onProcessError(file, id, err), + stdoutDataHandler: (data) => this._onStdoutData(file, id, data), + stdoutEndHandler: () => this._onStdoutEnd(file, id), + stderrDataHandler: (data) => this._onStderrData(file, id, data), + stderrEndHandler: () => this._onStderrEnd(), + closeHandler: (code) => this._onProcessClose(file, id, code), + }; + + // Set up callbacks to send STDIO messages to webview if this is current file + const updateWebview = (stdio: "STDIN" | "STDOUT" | "STDERR", data: string) => { + if (this._currentFile === file) { + super._postMessage({ + type: "STDIO", + id, + stdio, + data, + }); + } + }; + + state.stdin.callback = (data) => updateWebview("STDIN", data); + state.stdout.callback = (data) => updateWebview("STDOUT", data); + state.stderr.callback = (data) => updateWebview("STDERR", data); + + return state; } protected override _rehydrateWebviewFromState() { - super._postMessage({ type: "INIT", interactiveMode: this._interactiveMode }); - super._postMessage({ type: "CLEAR" }); - for (const state of this._state) { + const ctx = this._currentContext; + if (!ctx) return; + + super._postMessage({ type: "INIT", interactiveMode: ctx.interactiveMode }); + super._postMessage({ type: "CLEAR" }); // Reset view + + for (const state of ctx.state) { super._postMessage({ type: "STATUS", id: state.state, @@ -282,7 +281,10 @@ export default class extends BaseViewProvider { - const donePromise = this._donePromise; + const ctx = this._currentContext; + if (!ctx) return; + + const donePromise = ctx.donePromise; if (donePromise) { await donePromise; return; @@ -292,33 +294,43 @@ export default class extends BaseViewProvider((resolve) => { + const currentFile = this._currentFile; + ctx.donePromise = new Promise((resolve) => { void (async () => { - await this._doRun(); + await this._doRun(currentFile); resolve(); })(); }); - await this._donePromise; - this._donePromise = null; + + this._onDidChangeBackgroundTasks.fire(); + await ctx.donePromise; + ctx.donePromise = null; + this._onDidChangeBackgroundTasks.fire(); } stop() { - if (this._running) { - this._stopFlag = true; - for (const state of this._state) { - state.process.stop(); - } + if (this._currentFile) { + this.stopStressSession(this._currentFile); + } + } + + stopAll() { + for (const file of this._contexts.keys()) { + this.stopStressSession(file); } } - private async _doRun() { + private async _doRun(file: string) { + const ctx = this._contexts.get(file); + if (!ctx) return; + const config = vscode.workspace.getConfiguration("fastolympiccoding"); const delayBetweenTestcases = config.get("delayBetweenTestcases")!; const testcaseTimeLimit = config.get("stressTestcaseTimeLimit")!; const testcaseMemoryLimit = config.get("stressTestcaseMemoryLimit")!; const timeLimit = config.get("stressTimeLimit")!; - const solutionSettings = getFileRunSettings(this._currentFile!); + const solutionSettings = getFileRunSettings(file); if (!solutionSettings) { return; } @@ -326,7 +338,7 @@ export default class extends BaseViewProvider { const logger = getLogger("stress"); - logger.error(`No run command for ${this._currentFile}`); - showOpenRunSettingsErrorWindow(`No run command for ${this._currentFile}`); + logger.error(msg); + showOpenRunSettingsErrorWindow(msg); + }; + + if (!solutionSettings.languageSettings.runCommand) { + logError(`No run command for ${file}`); return; } if (!generatorSettings.languageSettings.runCommand) { - const logger = getLogger("stress"); - logger.error(`No run command for ${solutionSettings.generatorFile}`); - showOpenRunSettingsErrorWindow(`No run command for ${solutionSettings.generatorFile}`); + logError(`No run command for ${solutionSettings.generatorFile}`); return; } if (!judgeSettings.languageSettings.runCommand) { - const logger = getLogger("stress"); - const judgeFile = this._interactiveMode + const judgeFile = ctx.interactiveMode ? solutionSettings.interactorFile! : solutionSettings.goodSolutionFile!; - logger.error(`No run command for ${judgeFile}`); - showOpenRunSettingsErrorWindow(`No run command for ${judgeFile}`); + logError(`No run command for ${judgeFile}`); return; } const callback = (state: State, code: number) => { const status = code ? "CE" : "NA"; state.status = status; - super._postMessage({ type: "STATUS", id: state.state, status }); - + if (this._currentFile === file) { + super._postMessage({ type: "STATUS", id: state.state, status }); + } return code; }; + const compilePromises: Promise[] = []; - const addCompilePromise = (state: State, file: string) => { - const compilePromise = compile(file, this._context); + const addCompilePromise = (state: State, filePath: string) => { + const compilePromise = compile(filePath, this._context); if (compilePromise) { - super._postMessage({ - type: "STATUS", - id: state.state, - status: "COMPILING", - }); + if (this._currentFile === file) { + super._postMessage({ + type: "STATUS", + id: state.state, + status: "COMPILING", + }); + } compilePromises.push(compilePromise.then(callback.bind(this, state))); } }; - addCompilePromise(this._generatorState, solutionSettings.generatorFile!); - addCompilePromise(this._solutionState, this._currentFile!); - if (this._interactiveMode) { - addCompilePromise(this._judgeState, solutionSettings.interactorFile!); + + const generatorState = ctx.state.find((s) => s.state === "Generator")!; + const solutionState = ctx.state.find((s) => s.state === "Solution")!; + const judgeState = ctx.state.find((s) => s.state === "Judge")!; + + addCompilePromise(generatorState, solutionSettings.generatorFile!); + addCompilePromise(solutionState, file); + if (ctx.interactiveMode) { + addCompilePromise(judgeState, solutionSettings.interactorFile!); } else { - addCompilePromise(this._judgeState, solutionSettings.goodSolutionFile!); + addCompilePromise(judgeState, solutionSettings.goodSolutionFile!); } const compileCodes = await Promise.all(compilePromises); - let anyFailedToCompile = false; - for (const code of compileCodes) { - if (code) { - anyFailedToCompile = true; - } - } - if (anyFailedToCompile) { - this._saveState(); + if (compileCodes.some((code) => code !== 0)) { + this._saveState(file); return; } - for (const id of StateIdValue) { - super._postMessage({ - type: "STATUS", - id, - status: "RUNNING", - }); + if (this._currentFile === file) { + for (const id of StateIdValue) { + super._postMessage({ + type: "STATUS", + id, + status: "RUNNING", + }); + } } - this._stopFlag = false; - this._clearFlag = false; - this._running = true; + ctx.stopFlag = false; + ctx.clearFlag = false; + ctx.running = true; + this._onDidChangeBackgroundTasks.fire(); + const start = Date.now(); - while (!this._stopFlag && (timeLimit === 0 || Date.now() - start <= timeLimit)) { - super._postMessage({ type: "CLEAR" }); - for (const state of this._state) { + while (!ctx.stopFlag && (timeLimit === 0 || Date.now() - start <= timeLimit)) { + if (this._currentFile === file) { + super._postMessage({ type: "CLEAR" }); + } + for (const state of ctx.state) { state.stdin.reset(); state.stdout.reset(); state.stderr.reset(); - super._postMessage({ - type: "STATUS", - id: state.state, - status: "RUNNING", - }); + if (this._currentFile === file) { + super._postMessage({ + type: "STATUS", + id: state.state, + status: "RUNNING", + }); + } } const seed = crypto.randomBytes(8).readBigUInt64BE(); - this._interactiveSecretPromise = new Promise((resolve) => { - this._interactorSecretResolver = resolve; + ctx.interactiveSecretPromise = new Promise((resolve) => { + ctx.interactorSecretResolver = resolve; }); - this._judgeState.process - .on("error", this._judgeState.errorHandler) - .on("stdout:data", this._judgeState.stdoutDataHandler) - .on("stdout:end", this._judgeState.stdoutEndHandler) - .on("stderr:data", this._judgeState.stderrDataHandler) - .on("stderr:end", this._judgeState.stderrEndHandler) - .on("close", this._judgeState.closeHandler); - this._judgeState.process.run( + const setupProcess = (state: State) => { + state.process + .on("error", state.errorHandler) + .on("stdout:data", state.stdoutDataHandler) + .on("stdout:end", state.stdoutEndHandler) + .on("stderr:data", state.stderrDataHandler) + .on("stderr:end", state.stderrEndHandler) + .on("close", state.closeHandler); + }; + + setupProcess(judgeState); + judgeState.process.run( judgeSettings.languageSettings.runCommand, testcaseTimeLimit, testcaseMemoryLimit, solutionSettings.languageSettings.currentWorkingDirectory ); - this._generatorState.process - .on("spawn", () => { - this._generatorState.process.stdin?.write(`${seed}\n`); - }) - .on("error", this._generatorState.errorHandler) - .on("stdout:data", this._generatorState.stdoutDataHandler) - .on("stdout:end", this._generatorState.stdoutEndHandler) - .on("stderr:data", this._generatorState.stderrDataHandler) - .on("stderr:end", this._generatorState.stderrEndHandler) - .on("close", this._generatorState.closeHandler); - this._generatorState.process.run( + setupProcess(generatorState); + generatorState.process.on("spawn", () => { + generatorState.process.stdin?.write(`${seed}\n`); + }); + generatorState.process.run( generatorSettings.languageSettings.runCommand, 0, 0, solutionSettings.languageSettings.currentWorkingDirectory ); - this._solutionState.process - .on("error", this._solutionState.errorHandler) - .on("stdout:data", this._solutionState.stdoutDataHandler) - .on("stdout:end", this._solutionState.stdoutEndHandler) - .on("stderr:data", this._solutionState.stderrDataHandler) - .on("stderr:end", this._solutionState.stderrEndHandler) - .on("close", this._solutionState.closeHandler); - this._solutionState.process.run( + setupProcess(solutionState); + solutionState.process.run( solutionSettings.languageSettings.runCommand, testcaseTimeLimit, testcaseMemoryLimit, @@ -477,101 +492,116 @@ export default class extends BaseViewProvider { const termination = await state.process.done; state.status = mapTestcaseTermination(termination); - super._postMessage({ - type: "STATUS", - id: state.state, - status: state.status, - }); + if (this._currentFile === file) { + super._postMessage({ + type: "STATUS", + id: state.state, + status: state.status, + }); + } resolve(terminationSeverityNumber(termination)); })(); }); }; - const generatorPromise = executionPromise(this._generatorState); - const solutionPromise = executionPromise(this._solutionState); - const judgePromise = executionPromise(this._judgeState); + const generatorPromise = executionPromise(generatorState); + const solutionPromise = executionPromise(solutionState); + const judgePromise = executionPromise(judgeState); const severities = await Promise.all([generatorPromise, solutionPromise, judgePromise]); const maxSeverity = Math.max(...severities) as Severity; - if (this._interactiveMode) { + if (ctx.interactiveMode) { if (maxSeverity === 0) { - // All the processes finished successfully, therefore the judge - // returned 0 so the answer is correct - this._solutionState.status = "AC"; - super._postMessage({ - type: "STATUS", - id: this._solutionState.state, - status: this._solutionState.status, - }); + // All finished successfully + solutionState.status = "AC"; + if (this._currentFile === file) { + super._postMessage({ + type: "STATUS", + id: solutionState.state, + status: solutionState.status, + }); + } } else if (maxSeverity === 1) { - // The stress tester was stopped + // Stopped break; } else if ( - this._solutionState.process.exitCode === null || - this._judgeState.process.exitCode === null + solutionState.process.exitCode === null || + judgeState.process.exitCode === null ) { - // The one of the two processes crashed. + // Crashed break; - } else if (this._judgeState.process.exitCode !== 0) { - // Judge returned non-zero code which means answer is invalid - this._judgeState.status = "NA"; - this._solutionState.status = "WA"; - super._postMessage({ - type: "STATUS", - id: this._judgeState.state, - status: this._judgeState.status, - }); - super._postMessage({ - type: "STATUS", - id: this._solutionState.state, - status: this._solutionState.status, - }); + } else if (judgeState.process.exitCode !== 0) { + // WA + judgeState.status = "NA"; + solutionState.status = "WA"; + if (this._currentFile === file) { + super._postMessage({ + type: "STATUS", + id: judgeState.state, + status: judgeState.status, + }); + super._postMessage({ + type: "STATUS", + id: solutionState.state, + status: solutionState.status, + }); + } break; } } else { if (maxSeverity > 0) { - // Either the stress tester was stopped or something had gone wrong break; - } else if (this._solutionState.stdout.data !== this._judgeState.stdout.data) { - this._solutionState.status = "WA"; - super._postMessage({ - type: "STATUS", - id: this._solutionState.state, - status: this._solutionState.status, - }); + } else if (solutionState.stdout.data !== judgeState.stdout.data) { + solutionState.status = "WA"; + if (this._currentFile === file) { + super._postMessage({ + type: "STATUS", + id: solutionState.state, + status: solutionState.status, + }); + } break; } else { - this._solutionState.status = "AC"; - super._postMessage({ - type: "STATUS", - id: this._solutionState.state, - status: this._solutionState.status, - }); + solutionState.status = "AC"; + if (this._currentFile === file) { + super._postMessage({ + type: "STATUS", + id: solutionState.state, + status: solutionState.status, + }); + } } } await new Promise((resolve) => setTimeout(() => resolve(), delayBetweenTestcases)); } - this._running = false; - if (this._clearFlag) { - for (const state of this._state) { + ctx.running = false; + + if (ctx.clearFlag) { + for (const state of ctx.state) { state.stdin.reset(); state.stdout.reset(); state.stderr.reset(); state.status = "NA"; } - super._postMessage({ type: "CLEAR" }); + if (this._currentFile === file) { + super._postMessage({ type: "CLEAR" }); + } } - this._clearFlag = false; + ctx.clearFlag = false; - this._saveState(); + this._saveState(file); + this._onDidChangeBackgroundTasks.fire(); } private _view({ id, stdio }: v.InferOutput) { - const state = this._findState(id); + const ctx = this._currentContext; + if (!ctx) return; + const state = ctx.state.find((s) => s.state === id); + if (!state) { return; } @@ -593,6 +623,9 @@ export default class extends BaseViewProvider s.state === "Generator")!; + const solutionState = ctx.state.find((s) => s.state === "Solution")!; + const judgeState = ctx.state.find((s) => s.state === "Judge")!; + + if (ctx.interactiveMode) { + const currentState = ctx.state.find((s) => s.state === id); if (currentState?.state === "Solution") { this._testcaseViewProvider.addTestcaseToFile(resolvedFile, { - stdin: this._judgeState.stdout.data, - stderr: this._solutionState.stderr.data + this._judgeState.stderr.data, - stdout: this._solutionState.stdout.data, + uuid: crypto.randomUUID(), + stdin: judgeState.stdout.data, // Interactor output is stdin for solution + stderr: solutionState.stderr.data + judgeState.stderr.data, + stdout: solutionState.stdout.data, acceptedStdout: "", elapsed: currentState?.process.elapsed ?? 0, memoryBytes: currentState?.process.maxMemoryBytes ?? 0, @@ -626,14 +664,15 @@ export default class extends BaseViewProvider s.state === id); this._testcaseViewProvider.addTestcaseToFile(resolvedFile, { - stdin: this._generatorState.stdout.data, + uuid: crypto.randomUUID(), + stdin: generatorState.stdout.data, stderr: currentState?.stderr.data ?? "", stdout: currentState?.stdout.data ?? "", - acceptedStdout: this._judgeState.stdout.data, + acceptedStdout: judgeState.stdout.data, elapsed: currentState?.process.elapsed ?? 0, memoryBytes: currentState?.process.maxMemoryBytes ?? 0, status: currentState?.status ?? "NA", @@ -666,11 +706,14 @@ export default class extends BaseViewProvider) { - this._interactiveMode = interactiveMode; + const ctx = this._currentContext; + if (!ctx) { + return; + } - this._saveState(); + ctx.interactiveMode = interactiveMode; + if (this._currentFile) { + void this._saveState(this._currentFile); + } } - private _saveState() { - const file = this._currentFile; - if (!file) { + private _saveState(file: string) { + const ctx = this._contexts.get(file); + if (!ctx) { return; } const defaultData = v.parse(FileDataSchema, {}); const data: FileData = { - interactiveMode: this._interactiveMode, + interactiveMode: ctx.interactiveMode, states: [], }; - for (const state of this._state) { + for (const state of ctx.state) { data.states.push({ state: state.state, status: state.status, @@ -714,50 +765,65 @@ export default class extends BaseViewProvider s.state === stateId); + logger.error(`${file} ${stateId} process error: ${data.message}`); + if (state) { + state.stderr.write(data.message, "final"); + } } - for (const stateId of StateIdValue) { - const state = this._findState(stateId); - state?.process.kill(); + const ctx = this._contexts.get(file); + if (ctx) { + for (const s of ctx.state) { + s.process.kill(); + } } } - private async _onStdoutData(stateId: StateId, data: string) { - const state = this._findState(stateId); + private async _onStdoutData(file: string, stateId: StateId, data: string) { + const ctx = this._contexts.get(file); + if (!ctx) { + return; + } + + const state = ctx.state.find((s) => s.state === stateId); + + const generatorState = ctx.state.find((s) => s.state === "Generator")!; + const solutionState = ctx.state.find((s) => s.state === "Solution")!; + const judgeState = ctx.state.find((s) => s.state === "Judge")!; + if (stateId === "Generator") { - const writeMode: WriteMode = this._interactiveMode ? "force" : "batch"; - this._generatorState.stdout.write(data, writeMode); + const writeMode: WriteMode = ctx.interactiveMode ? "force" : "batch"; + generatorState.stdout.write(data, writeMode); - if (this._interactiveMode) { + if (ctx.interactiveMode) { // Generator provides the secret for the interactor - this._judgeState.process.stdin?.write(data); + judgeState.process.stdin?.write(data); } else { // Generator pipes to solution and good solution - this._solutionState.process.stdin?.write(data); - this._judgeState.process.stdin?.write(data); + solutionState.process.stdin?.write(data); + judgeState.process.stdin?.write(data); } } else if (stateId === "Judge") { - if (this._interactiveMode) { - this._solutionState.process.stdin?.write(data); + if (ctx.interactiveMode) { + solutionState.process.stdin?.write(data); state?.stdout.write(data, "force"); } else { state?.stdout.write(data, "batch"); } } else { - if (this._interactiveMode) { + if (ctx.interactiveMode) { // Make sure generator sends the secret before sending our queries - if (this._interactiveSecretPromise) { - await this._interactiveSecretPromise; - this._interactiveSecretPromise = null; + if (ctx.interactiveSecretPromise) { + await ctx.interactiveSecretPromise; + ctx.interactiveSecretPromise = null; } - this._judgeState.process.stdin?.write(data); + judgeState.process.stdin?.write(data); state?.stdout.write(data, "force"); } else { state?.stdout.write(data, "batch"); @@ -765,42 +831,53 @@ export default class extends BaseViewProvider s.state === stateId); state?.stdout.write("", "final"); } - private _onProcessClose(stateId: StateId, code: number | null) { - const state = this._findState(stateId); + private _onProcessClose(file: string, stateId: StateId, code: number | null) { + const ctx = this._contexts.get(file); + if (!ctx) { + return; + } + + const state = ctx.state.find((s) => s.state === stateId); if (!state) { return; } if (code !== 0) { - for (const siblingId of StateIdValue) { - const sibling = this._findState(siblingId); - if (sibling && sibling.state !== stateId) { + for (const sibling of ctx.state) { + if (sibling.state !== stateId) { sibling.process.stop(); } } } } - private _onStderrData(stateId: StateId, data: string) { - const state = this._findState(stateId); - const writeMode: WriteMode = this._interactiveMode ? "force" : "batch"; + private _onStderrData(file: string, stateId: StateId, data: string) { + const ctx = this._contexts.get(file); + if (!ctx) { + return; + } + + const state = ctx.state.find((s) => s.state === stateId); + const writeMode: WriteMode = ctx.interactiveMode ? "force" : "batch"; state?.stderr.write(data, writeMode); } - private _onStderrEnd(stateId: StateId) { - const state = this._findState(stateId); - state?.stderr.write("", "final"); - } + private _onStderrEnd() {} toggleWebviewSettings() { super._postMessage({ type: "SETTINGS_TOGGLE" }); diff --git a/src/extension/statusBar.ts b/src/extension/statusBar.ts new file mode 100644 index 00000000..b4f8c42e --- /dev/null +++ b/src/extension/statusBar.ts @@ -0,0 +1,27 @@ +import * as vscode from "vscode"; + +let statusBarItem: vscode.StatusBarItem | undefined; + +/** + * Creates a general-purpose status bar item. + */ +export function createStatusBarItem(context: vscode.ExtensionContext): vscode.StatusBarItem { + statusBarItem = vscode.window.createStatusBarItem( + "fastolympiccoding.statusBarItem", + vscode.StatusBarAlignment.Left + ); + statusBarItem.name = "Fast Olympic Coding"; + statusBarItem.text = "$(zap) Fast Olympic Coding"; + statusBarItem.command = "fastolympiccoding.showPanel"; + statusBarItem.show(); + context.subscriptions.push(statusBarItem); + + return statusBarItem; +} + +/** + * Gets the status bar item instance. + */ +export function getStatusBarItem(): vscode.StatusBarItem | undefined { + return statusBarItem; +} diff --git a/src/extension/utils/runtime.ts b/src/extension/utils/runtime.ts index e02ca02c..ff4e5b91 100644 --- a/src/extension/utils/runtime.ts +++ b/src/extension/utils/runtime.ts @@ -590,7 +590,7 @@ export function compile(file: string, context: vscode.ExtensionContext): Promise const extension = path.extname(file); const languageSettings = settings[extension] as LanguageSettings; if (!languageSettings.compileCommand) { - return null; + return Promise.resolve(0); } return doCompile(file, languageSettings.compileCommand, context); } diff --git a/src/shared/judge-messages.ts b/src/shared/judge-messages.ts index b8cec156..c87aeb05 100644 --- a/src/shared/judge-messages.ts +++ b/src/shared/judge-messages.ts @@ -45,26 +45,26 @@ export const NextMessageSchema = v.object({ export const ActionMessageSchema = v.object({ type: v.literal("ACTION"), - id: v.number(), + uuid: v.string(), action: ActionSchema, }); export const SaveMessageSchema = v.object({ type: v.literal("SAVE"), - id: v.number(), + uuid: v.string(), stdio: StdioSchema, data: v.string(), }); export const ViewMessageSchema = v.object({ type: v.literal("VIEW"), - id: v.number(), + uuid: v.string(), stdio: StdioSchema, }); export const StdinMessageSchema = v.object({ type: v.literal("STDIN"), - id: v.number(), + uuid: v.string(), data: v.string(), }); @@ -80,13 +80,13 @@ export const SetMemoryLimitSchema = v.object({ export const RequestTrimmedDataMessageSchema = v.object({ type: v.literal("REQUEST_TRIMMED_DATA"), - id: v.number(), + uuid: v.string(), stdio: v.picklist(StdioValues), }); export const RequestFullDataMessageSchema = v.object({ type: v.literal("REQUEST_FULL_DATA"), - id: v.number(), + uuid: v.string(), stdio: v.picklist(StdioValues), }); @@ -122,12 +122,12 @@ export const WebviewMessageTypeSchema = v.picklist(WebviewMessageTypeValues); export const NewMessageSchema = v.object({ type: v.literal("NEW"), - id: v.number(), + uuid: v.string(), }); export const SetMessageSchema = v.object({ type: v.literal("SET"), - id: v.number(), + uuid: v.string(), property: v.picklist([ "stdin", "stderr", @@ -147,14 +147,14 @@ export const SetMessageSchema = v.object({ export const StdioMessageSchema = v.object({ type: v.literal("STDIO"), - id: v.number(), + uuid: v.string(), stdio: StdioSchema, data: v.string(), }); export const DeleteMessageSchema = v.object({ type: v.literal("DELETE"), - id: v.number(), + uuid: v.string(), }); export const ShowMessageSchema = v.object({ diff --git a/src/shared/schemas.ts b/src/shared/schemas.ts index 6682c5cc..b80ee53e 100644 --- a/src/shared/schemas.ts +++ b/src/shared/schemas.ts @@ -46,6 +46,7 @@ export const TestSchema = v.object({ }); export const TestcaseSchema = v.object({ + uuid: v.fallback(v.string(), () => crypto.randomUUID()), stdin: v.fallback(v.string(), ""), stderr: v.fallback(v.string(), ""), stdout: v.fallback(v.string(), ""), @@ -59,8 +60,9 @@ export const TestcaseSchema = v.object({ mode: v.fallback(v.picklist(MODES), "standard"), interactorSecret: v.fallback(v.string(), ""), }); + export type Testcase = v.InferOutput; -export type TestcaseProperty = keyof Testcase; +export type TestcaseProperty = Exclude; export const InputTypeValues = ["stdin", "file", "regex"] as const; export type InputType = (typeof InputTypeValues)[number]; diff --git a/src/webview/judge/App.svelte b/src/webview/judge/App.svelte index aa76b65c..5dec20bf 100644 --- a/src/webview/judge/App.svelte +++ b/src/webview/judge/App.svelte @@ -16,53 +16,51 @@ import Testcase from "./Testcase.svelte"; // Reactive state using Svelte 5 runes - let testcases = $state<{ id: number; data: TestcaseType }[]>([]); + let testcases = $state([]); let newTimeLimit = $state(0); let newMemoryLimit = $state(0); let show = $state(true); let showSettings = $state(false); let showTestcaseDropdown = $state(false); - // Helper to find testcase by id - function findTestcaseIndex(id: number): number { - return testcases.findIndex((t) => t.id === id); + // Helper to find testcase by uuid + function findTestcaseIndex(uuid: string): number { + return testcases.findIndex((t) => t.uuid === uuid); } // Message handlers - function handleNew({ id }: v.InferOutput) { - const existing = findTestcaseIndex(id); + function handleNew({ uuid }: v.InferOutput) { + const existing = findTestcaseIndex(uuid); if (existing === -1) { testcases.push({ - id, - data: { - stdin: "", - stderr: "", - stdout: "", - acceptedStdout: "", - elapsed: 0, - memoryBytes: 0, - status: "NA", - shown: true, - toggled: false, - skipped: false, - mode: "standard", - interactorSecret: "", - }, + uuid, + stdin: "", + stderr: "", + stdout: "", + acceptedStdout: "", + elapsed: 0, + memoryBytes: 0, + status: "NA", + shown: true, + toggled: false, + skipped: false, + mode: "standard", + interactorSecret: "", }); } } - function handleSet({ id, property, value }: v.InferOutput) { - const idx = findTestcaseIndex(id); + function handleSet({ uuid, property, value }: v.InferOutput) { + const idx = findTestcaseIndex(uuid); if (idx !== -1) { - (testcases[idx].data as unknown as Record)[property] = value; + (testcases[idx] as unknown as Record)[property] = value; } } - function handleStdio({ id, data, stdio }: v.InferOutput) { - const idx = findTestcaseIndex(id); + function handleStdio({ uuid, data, stdio }: v.InferOutput) { + const idx = findTestcaseIndex(uuid); if (idx === -1) return; - const tc = testcases[idx].data; + const tc = testcases[idx]; switch (stdio) { case "STDIN": tc.stdin += data; @@ -82,8 +80,8 @@ } } - function handleDelete({ id }: v.InferOutput) { - const idx = findTestcaseIndex(id); + function handleDelete({ uuid }: v.InferOutput) { + const idx = findTestcaseIndex(uuid); if (idx !== -1) { testcases.splice(idx, 1); } @@ -223,8 +221,8 @@ {:else}
- {#each testcases as testcase, index (testcase.id)} - + {#each testcases as testcase, index (testcase.uuid)} + {/each}
{/if} {#if status === "WA" && testcase.mode !== "interactive"} @@ -129,7 +142,8 @@ class="action-button action-button--always-visible codicon codicon-diff-single" data-tooltip="Compare Answers" aria-label="Compare" - onclick={() => postProviderMessage({ type: "ACTION", id, action: "COMPARE" })} + onclick={() => + postProviderMessage({ type: "ACTION", uuid: testcase.uuid, action: "COMPARE" })} > {/if} {/snippet} @@ -143,13 +157,17 @@ variant="accepted" onexpand={() => handleExpandStdio("ACCEPTED_STDOUT")} onpreedit={() => { - postProviderMessage({ type: "REQUEST_FULL_DATA", id, stdio: "ACCEPTED_STDOUT" }); + postProviderMessage({ + type: "REQUEST_FULL_DATA", + uuid: testcase.uuid, + stdio: "ACCEPTED_STDOUT", + }); }} onsave={handleSaveAcceptedStdout} oncancel={() => { postProviderMessage({ type: "REQUEST_TRIMMED_DATA", - id, + uuid: testcase.uuid, stdio: "ACCEPTED_STDOUT", }); }} @@ -160,7 +178,8 @@ class="action-button action-button--always-visible codicon codicon-close" data-tooltip="Decline Answer" aria-label="Decline" - onclick={() => postProviderMessage({ type: "ACTION", id, action: "DECLINE" })} + onclick={() => + postProviderMessage({ type: "ACTION", uuid: testcase.uuid, action: "DECLINE" })} > {/if} {/snippet} @@ -171,11 +190,11 @@
{:else if status === "COMPILING"}
- +
{:else if status === "RUNNING"}
- + {#if visible} {#if testcase.mode === "interactive"} {#if testcase.interactorSecret === "" || testcase.interactorSecret === "\n"} @@ -186,7 +205,7 @@ onsave={() => { postProviderMessage({ type: "SAVE", - id, + uuid: testcase.uuid, stdio: "INTERACTOR_SECRET", data: newInteractorSecret, }); @@ -216,7 +235,7 @@ ctrlEnterNewline={true} onkeyup={(e) => { if (e.key === "Enter" && !e.ctrlKey) { - postProviderMessage({ type: "STDIN", id, data: newStdin }); + postProviderMessage({ type: "STDIN", uuid: testcase.uuid, data: newStdin }); newStdin = ""; } }} diff --git a/src/webview/judge/TestcaseToolbar.svelte b/src/webview/judge/TestcaseToolbar.svelte index ba0f3c46..8d7e22f1 100644 --- a/src/webview/judge/TestcaseToolbar.svelte +++ b/src/webview/judge/TestcaseToolbar.svelte @@ -9,15 +9,14 @@ type ITestcase = v.InferOutput; interface Props { - id: number; testcase: ITestcase; onprerun: () => void; } - let { id, testcase, onprerun }: Props = $props(); + let { testcase, onprerun }: Props = $props(); function handleAction(action: ActionValue) { - postProviderMessage({ type: "ACTION", id, action }); + postProviderMessage({ type: "ACTION", uuid: testcase.uuid, action }); } function handleRun() {