diff --git a/extension/bwcontest/media/SubmissionIcons/TeamPanel/correct.png b/extension/bwcontest/media/SubmissionIcons/TeamPanel/correct.png new file mode 100644 index 0000000..0ab8f4a Binary files /dev/null and b/extension/bwcontest/media/SubmissionIcons/TeamPanel/correct.png differ diff --git a/extension/bwcontest/media/SubmissionIcons/TeamPanel/incorrect.png b/extension/bwcontest/media/SubmissionIcons/TeamPanel/incorrect.png new file mode 100644 index 0000000..705135d Binary files /dev/null and b/extension/bwcontest/media/SubmissionIcons/TeamPanel/incorrect.png differ diff --git a/extension/bwcontest/media/SubmissionIcons/TeamPanel/none.png b/extension/bwcontest/media/SubmissionIcons/TeamPanel/none.png new file mode 100644 index 0000000..c2806a6 Binary files /dev/null and b/extension/bwcontest/media/SubmissionIcons/TeamPanel/none.png differ diff --git a/extension/bwcontest/media/SubmissionIcons/TeamPanel/unknown.png b/extension/bwcontest/media/SubmissionIcons/TeamPanel/unknown.png new file mode 100644 index 0000000..71b7422 Binary files /dev/null and b/extension/bwcontest/media/SubmissionIcons/TeamPanel/unknown.png differ diff --git a/extension/bwcontest/package.json b/extension/bwcontest/package.json index 9d703c7..a7ad95f 100644 --- a/extension/bwcontest/package.json +++ b/extension/bwcontest/package.json @@ -9,7 +9,7 @@ "categories": [ "Other" ], - "activationEvents": [], + "activationEvents": ["onStartupFinished"], "main": "./out/main.js", "contributes": { "configuration": { @@ -34,6 +34,11 @@ "type": "string", "default": "", "description": "Path of java bin folder" + }, + "BWContest.debugFastPolling": { + "type": "boolean", + "default": false, + "description": "Enables fast polling, with a command to toggle frequency" } } }, @@ -57,7 +62,12 @@ } ] }, - "commands": [] + "commands": [ + { + "command": "bwcontest.toggleFastPolling", + "title": "BWContest Developer: Toggle Fast Polling" + } + ] }, "scripts": { "vscode:prepublish": "npm run compile", diff --git a/extension/bwcontest/src/SidebarProvider.ts b/extension/bwcontest/src/SidebarProvider.ts index a161c11..118dffb 100644 --- a/extension/bwcontest/src/SidebarProvider.ts +++ b/extension/bwcontest/src/SidebarProvider.ts @@ -3,30 +3,65 @@ import { getNonce } from './getNonce'; import { cloneAndOpenRepo } from './extension'; import { BWPanel } from './problemPanel'; import urlJoin from 'url-join'; +import outputPanelLog from './outputPanelLog'; +import { ContestStateForExtension, ProblemNameForExtension, SubmissionForExtension, SubmissionStateForExtension } from './contestMonitor/contestMonitorSharedTypes'; +import { TeamData } from './sharedTypes'; +import { ContestTeamState, getCachedContestTeamState, clearCachedContestTeamState, submissionsListChanged } from './contestMonitor/contestStateSyncManager'; +import { startTeamStatusPolling, stopTeamStatusPolling } from './contestMonitor/pollingService'; -export type ContestLanguage = 'Java' | 'CSharp' | 'CPP'; - -export type TeamData = { - teamId: number; - contestId: number; - language: ContestLanguage; -}; - -export type WebviewMessageType = { msg: 'onLogin'; data: TeamData } | { msg: 'onLogout' }; +export type WebviewMessageType = + { msg: 'onLogin'; data: TeamData } | + { msg: 'onLogout' } | + { msg: 'teamStatusUpdated'; data: SidebarTeamStatus | null }; export type MessageType = | { msg: 'onTestAndSubmit' } - | { msg: 'onStartup' } + | { msg: 'onUIMount' } | { msg: 'onClone'; data: { contestId: number; teamId: number } } | { msg: 'onLogin'; data: { teamName: string; password: string } } | { msg: 'onLogout' }; +export type SidebarTeamStatus = { + contestState: ContestStateForExtension; + correctProblems: SidebarProblemWithSubmissions[]; + processingProblems: SidebarProblemWithSubmissions[]; + incorrectProblems: SidebarProblemWithSubmissions[]; + notStartedProblems: SidebarProblemWithSubmissions[]; +} + +export type SidebarProblemWithSubmissions = { + problem: ProblemNameForExtension; + overallState: SubmissionStateForExtension | null; + submissions: SubmissionForExtension[]; + modified: boolean; +} + export class SidebarProvider implements vscode.WebviewViewProvider { + private webview: vscode.Webview | null = null; + constructor( private readonly extensionUri: vscode.Uri, private readonly context: vscode.ExtensionContext, private readonly webUrl: string - ) {} + ) { + outputPanelLog.info("Constructing SidebarProvider"); + + const currentSubmissionsList = getCachedContestTeamState(); + outputPanelLog.info("When SidebarProvider constructed, cached submission list is: " + JSON.stringify(currentSubmissionsList)); + this.updateTeamStatus(currentSubmissionsList); + + submissionsListChanged.add(submissionsChangedEventArgs => { + outputPanelLog.trace("Sidebar submission list updating from submissionsListChanged event"); + + if (!submissionsChangedEventArgs) { + return; + } + + this.updateTeamStatus( + submissionsChangedEventArgs.contestTeamState, + submissionsChangedEventArgs.changedProblemIds + )}); + } private async handleLogin( teamName: string, @@ -42,47 +77,135 @@ export class SidebarProvider implements vscode.WebviewViewProvider { }) }); const resData = await res.json(); - if (res.status !== 200 || resData.success !== true) { + if (res.status !== 200) { + outputPanelLog.error('Invalid Login: API returned ' + res.status); + vscode.window.showErrorMessage('BWContest: Login Failure'); + return; + } + + if (resData.success !== true) { + outputPanelLog.error('Invalid Login attempt with message: ' + (resData.message ?? "")); vscode.window.showErrorMessage('BWContest: Invalid Login'); return; } + const sessionToken = resData.token; - this.context.globalState.update('token', sessionToken); const teamRes = await fetch(urlJoin(this.webUrl, `api/team/${sessionToken}`), { method: 'GET' }); const data2 = await teamRes.json(); if (!data2.success) { + outputPanelLog.error('Login attempt retrieved token but not team details. Staying logged out.'); + vscode.window.showErrorMessage('BWContest: Invalid Login'); return; } + + this.context.globalState.update('token', sessionToken); this.context.globalState.update('teamData', data2.data); + + startTeamStatusPolling(); + + outputPanelLog.info('Login succeeded'); webviewPostMessage({ msg: 'onLogin', data: data2.data }); + + const currentSubmissionsList = getCachedContestTeamState(); + outputPanelLog.info("After login, cached submission list is: " + JSON.stringify(currentSubmissionsList)); + this.updateTeamStatus(currentSubmissionsList); } private async handleLogout(webviewPostMessage: (m: WebviewMessageType) => void) { const sessionToken = this.context.globalState.get('token'); if (sessionToken === undefined) { - webviewPostMessage({ msg: 'onLogout' }); + outputPanelLog.error("Team requested logout, but no token was stored locally. Switching to logged out state."); + this.clearLocalTeamDataAndFinishLogout(webviewPostMessage); + return; } + + const teamData = this.context.globalState.get('teamData'); + if (teamData === undefined) { + outputPanelLog.error("Team requested logout with a locally stored token but no teamData. Switching to logged out state."); + this.clearLocalTeamDataAndFinishLogout(webviewPostMessage); + return; + } + const res = await fetch(urlJoin(this.webUrl, '/api/team/logout'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ + teamId: teamData.teamId, token: sessionToken }) }); + if (res.status !== 200) { + outputPanelLog.error(`Team requested logout, failed with status code ${res.status}. Not modifying local state.`); + vscode.window.showErrorMessage(`BWContest: Logout failed with code ${res.status}`); + return; + }; + + const data2 = await res.json(); + const responseMessage = data2.message ? `Message: ${data2.message}` : ''; + + if (data2.success !== true) { + outputPanelLog.error(`Team requested logout, failed with normal status code. Not modifying local state. ` + responseMessage); + vscode.window.showErrorMessage(`BWContest: Logout failed.`); return; } - const data2 = await res.json(); - if (data2.success === true) { - webviewPostMessage({ msg: 'onLogout' }); - this.context.globalState.update('token', undefined); + + outputPanelLog.info(`Team requested logout, completed successfully. ` + responseMessage); + this.clearLocalTeamDataAndFinishLogout(webviewPostMessage); + } + + private clearLocalTeamDataAndFinishLogout(webviewPostMessage: (m: WebviewMessageType) => void) { + webviewPostMessage({ msg: 'onLogout' }); + + stopTeamStatusPolling(); + clearCachedContestTeamState(); + + this.context.globalState.update('token', undefined); + this.context.globalState.update('teamData', undefined); + } + + public updateTeamStatus(contestTeamState : ContestTeamState | null, changedProblemIds = new Set) { + if (contestTeamState == null) { + outputPanelLog.trace("Not updating sidebar submission list because provided state is null"); + return; } + + if (this.webview == null) { + outputPanelLog.trace("Not updating sidebar submission list because webview is null"); + return; + } + + const contestState = contestTeamState.contestState; + const problemsWithSubmissions = contestState.problems.map(p => ({ + problem: p, + overallState: calculateOverallState(contestTeamState.submissionsList.get(p.id) ?? []), + submissions: contestTeamState.submissionsList.get(p.id) ?? [], + modified: changedProblemIds.has(p.id) + })); + + const teamStatus: SidebarTeamStatus = { + contestState, + correctProblems: problemsWithSubmissions.filter(p => p.overallState === 'Correct'), + processingProblems: problemsWithSubmissions.filter(p => p.overallState === 'Processing'), + incorrectProblems: problemsWithSubmissions.filter(p => p.overallState === 'Incorrect'), + notStartedProblems: problemsWithSubmissions.filter(p => p.overallState === null), + } + + const message: WebviewMessageType = { + msg: 'teamStatusUpdated', + data: teamStatus + }; + + outputPanelLog.trace("Posting teamStatusUpdated to webview with message: " + JSON.stringify(message)); + this.webview.postMessage(message); } public resolveWebviewView(webviewView: vscode.WebviewView) { + outputPanelLog.trace("SidebarProvider resolveWebviewView"); const webview = webviewView.webview; + this.webview = webview; webview.options = { enableScripts: true, localResourceRoots: [this.extensionUri] @@ -101,7 +224,8 @@ export class SidebarProvider implements vscode.WebviewViewProvider { } break; } - case 'onStartup': { + case 'onUIMount': { + outputPanelLog.trace("SidebarProvider onUIMount"); const token = this.context.globalState.get('token'); const teamData = this.context.globalState.get('teamData'); if (token !== undefined && teamData !== undefined) { @@ -109,6 +233,10 @@ export class SidebarProvider implements vscode.WebviewViewProvider { msg: 'onLogin', data: teamData }); + + const currentSubmissionsList = getCachedContestTeamState(); + outputPanelLog.trace("onUIMount, currentSubmissionsList is " + JSON.stringify(currentSubmissionsList)); + this.updateTeamStatus(currentSubmissionsList); } break; } @@ -169,3 +297,17 @@ export class SidebarProvider implements vscode.WebviewViewProvider { `; } } +function calculateOverallState(submissions: SubmissionForExtension[]): SubmissionStateForExtension | null { + if (submissions.find(s => s.state === 'Correct')) { + return 'Correct'; + } + else if (submissions.find(s => s.state === 'Processing')) { + return 'Processing'; + } + else if (submissions.find(s => s.state === 'Incorrect')) { + return 'Incorrect'; + } + else { + return null; + } +} \ No newline at end of file diff --git a/extension/bwcontest/src/contestMonitor/contestMonitorSharedTypes.ts b/extension/bwcontest/src/contestMonitor/contestMonitorSharedTypes.ts new file mode 100644 index 0000000..4ec0829 --- /dev/null +++ b/extension/bwcontest/src/contestMonitor/contestMonitorSharedTypes.ts @@ -0,0 +1,29 @@ +export type FullStateForExtension = { + contestState: ContestStateForExtension, + submissions: SubmissionForExtension[] +} + +export type ProblemNameForExtension = { + id: number, + friendlyName: string, +} + +export type ContestStateForExtension = { + startTime: Date | null, + endTime: Date | null, + problems: ProblemNameForExtension[], + isActive: boolean, + isScoreboardFrozen: boolean, +} + +export type SubmissionStateForExtension = 'Processing' | 'Correct' | 'Incorrect'; + +export type SubmissionForExtension = { + id: number, + contestId: number, + teamId: number, + problemId: number, + createdAt: Date, + state: SubmissionStateForExtension + message: string | null +} \ No newline at end of file diff --git a/extension/bwcontest/src/contestMonitor/contestStateSyncManager.ts b/extension/bwcontest/src/contestMonitor/contestStateSyncManager.ts new file mode 100644 index 0000000..ce9446f --- /dev/null +++ b/extension/bwcontest/src/contestMonitor/contestStateSyncManager.ts @@ -0,0 +1,170 @@ +import * as vscode from 'vscode'; +import urlJoin from 'url-join'; +import outputPanelLog from '../outputPanelLog'; +import { extensionSettings } from '../extension'; +import { ContestStateForExtension, ProblemNameForExtension, FullStateForExtension, SubmissionForExtension } from './contestMonitorSharedTypes'; +import { LiteEvent } from '../utilities/LiteEvent'; + +export type ContestTeamState = { + contestState: ContestStateForExtension, + submissionsList: Map +} + +export type SubmissionListStateChangedEventArgs = { + contestTeamState: ContestTeamState, + changedProblemIds: Set +} + +let latestContestTeamState: ContestTeamState | null = null; + +export function getCachedContestTeamState(): ContestTeamState | null { + return latestContestTeamState; +} + +export function clearCachedContestTeamState(): void { + latestContestTeamState = null; +} + +const onSubmissionsListChanged = new LiteEvent(); +export const submissionsListChanged = onSubmissionsListChanged.expose(); + +let latestPollNum = 0; +export async function pollContestStatus(context: vscode.ExtensionContext) { + const pollNum = ++latestPollNum; + outputPanelLog.trace(`Polling contest status, poll #${pollNum}`); + + const sessionToken = context.globalState.get('token'); + if (!sessionToken) { + outputPanelLog.trace(` Ending poll #${pollNum}: No sessionToken`); + return; + } + + const contestStateResponse = await fetch(urlJoin(extensionSettings().webUrl, `api/team/${sessionToken}/contestState`), { + method: 'GET' + }); + + if (contestStateResponse.status != 200) { + outputPanelLog.trace(` Ending poll #${pollNum}: Status check API returned http status ${contestStateResponse.status}`); + return; + } + + const data = await contestStateResponse.json(); + if (!data.success) { + outputPanelLog.trace(` Ending poll #${pollNum}: Status check returned OK but was not successful`); + return; + } + + const fullState: FullStateForExtension = data.data; + outputPanelLog.trace(` Poll #${pollNum} succeeded. Submission count: ${fullState.submissions.length}. Diffing...`); + + diffAndUpdateContestState(fullState); +} + +function diffAndUpdateContestState(fullState: FullStateForExtension) { + const contestState = fullState.contestState; + const currentSubmissionsList = createProblemSubmissionsLookup(contestState.problems, fullState.submissions); + const changedProblemIds = new Set(); + + let anythingChanged = false; + if (latestContestTeamState == null) { + outputPanelLog.trace(` No previously cached data to diff`); + anythingChanged = true; + } + else { + for (const problem of contestState.problems) { + const problemId = problem.id; + const currentSubmissionsForProblem = currentSubmissionsList.get(problemId) ?? []; + const cachedSubmissionsForProblem = latestContestTeamState.submissionsList.get(problemId) ?? []; + + const currentSubmissionsAlreadyInCache = currentSubmissionsForProblem!.filter(s => cachedSubmissionsForProblem.find(ss => ss.id == s.id)); + const currentSubmissionsNotInCache = currentSubmissionsForProblem!.filter(s => !cachedSubmissionsForProblem.find(ss => ss.id == s.id)); + const cachedSubmissionsNotInCurrent = cachedSubmissionsForProblem.filter(s => !currentSubmissionsForProblem!.find(ss => ss.id == s.id)); + + for (const currentSubmission of currentSubmissionsAlreadyInCache ) { + const previousSubmission = cachedSubmissionsForProblem.find(s => s.id == currentSubmission.id)!; + if (currentSubmission.state != previousSubmission.state) { + anythingChanged = true; + changedProblemIds.add(problem.id); + outputPanelLog.trace(` Submission state for #${currentSubmission.id} changed from ${previousSubmission.state} (message '${previousSubmission.message}') to ${currentSubmission.state} (message '${currentSubmission.message}')`); + alertForNewState(problem, currentSubmission); + } else if (currentSubmission.message != previousSubmission.message) { + anythingChanged = true; + changedProblemIds.add(problem.id); + outputPanelLog.trace(` Submission message changed (with same state) for #${currentSubmission.id} from ${previousSubmission.message} to ${currentSubmission.message}`); + } + } + + for (const currentSubmission of currentSubmissionsNotInCache ) { + anythingChanged = true; + changedProblemIds.add(problem.id); + outputPanelLog.trace(` Newly acknowledge submission #${currentSubmission.id} with state ${currentSubmission.state} and message ${currentSubmission.message}`); + alertForNewState(problem, currentSubmission); + } + + for (const previousSubmission of cachedSubmissionsNotInCurrent ) { + anythingChanged = true; + outputPanelLog.trace(` Deleted submission #${previousSubmission.id}`); + } + } + } + + outputPanelLog.trace(anythingChanged ? " Diff has changes, triggering events" : " No changes found"); + + if (anythingChanged) { + latestContestTeamState = { contestState, submissionsList: currentSubmissionsList}; + onSubmissionsListChanged.trigger({ + contestTeamState: latestContestTeamState, + changedProblemIds + }); + } +} + +function createProblemSubmissionsLookup(problems: ProblemNameForExtension[], submissions: SubmissionForExtension[]): Map { + const orderedSubmissionsByProblemId = new Map(); + for (const problem of problems) { + orderedSubmissionsByProblemId.set(problem.id, []); + } + + for (const submission of submissions.sort(s => s.id)) { + orderedSubmissionsByProblemId.get(submission.problemId)!.push(submission); + } + + return orderedSubmissionsByProblemId; +} + +function alertForNewState(problem: ProblemNameForExtension, currentSubmission: SubmissionForExtension) { + // Only alert on state changes team cares about + if (currentSubmission.state === 'Correct') { + vscode.window.showInformationMessage(`BWContest Judge: CORRECT Submission '${problem.friendlyName}'`); + } else if (currentSubmission.state === 'Incorrect') { + const messageDisplayText = currentSubmission.message ? `Message: ${currentSubmission.message}` : ''; + vscode.window.showInformationMessage(`BWContest Judge: INCORRECT Submission '${problem.friendlyName}' ${messageDisplayText}`); + } +} + +export function recordInitialSubmission(submission: SubmissionForExtension): void { + outputPanelLog.trace("Server received new submission, #" + submission.id); + + if (!latestContestTeamState) { + outputPanelLog.trace(" No locally cached submission list state, the normal polling cycle will update the list"); + return; + } + + const existingSubmissionListForProblem = latestContestTeamState.submissionsList.get(submission.problemId); + if (existingSubmissionListForProblem === undefined) { + outputPanelLog.trace(` The cached submission list does not know about problemId #${submission.problemId}. Next polling cycle should fix consistency.`); + return; + } + + if (existingSubmissionListForProblem.find(s => s.id == submission.id)) { + outputPanelLog.trace(` The cached submission list already knows about submissionId #${submission.id}`); + return; + } + + outputPanelLog.trace(` New submission #${submission.id} added to cache, triggering events`); + existingSubmissionListForProblem.push(submission); + onSubmissionsListChanged.trigger({ + contestTeamState: latestContestTeamState, + changedProblemIds: new Set([submission.problemId]), + }); +} \ No newline at end of file diff --git a/extension/bwcontest/src/contestMonitor/pollingService.ts b/extension/bwcontest/src/contestMonitor/pollingService.ts new file mode 100644 index 0000000..2c122d4 --- /dev/null +++ b/extension/bwcontest/src/contestMonitor/pollingService.ts @@ -0,0 +1,66 @@ +import * as vscode from 'vscode'; +import outputPanelLog from '../outputPanelLog'; +import { sleep } from '../utilities/sleep'; +import { pollContestStatus } from './contestStateSyncManager'; +import { SimpleCancellationToken } from '../utilities/SimpleCancellationToken'; + +let extensionContext: vscode.ExtensionContext; + +let currentlyPolling = false; +let currentPollingCancellationToken: SimpleCancellationToken | null = null; +let debugPollingLoopNum = 0; + +const defaultPollingIntervalSeconds = 30; +const developerFastPollingIntervalSeconds = 5; +let pollingIntervalSeconds = defaultPollingIntervalSeconds; + +export async function startTeamStatusPollingOnActivation(context: vscode.ExtensionContext) { + extensionContext = context; + + outputPanelLog.info(`Extension activated, try starting polling loop`); + await startTeamStatusPolling(); +} + +export async function startTeamStatusPolling() { + if (currentlyPolling) { + outputPanelLog.trace("Tried to start team status polling, but it's already running."); + return; + } + else if (!extensionContext.globalState.get('token')) { + outputPanelLog.info("Tried to start team status polling, but team is not logged in."); + return; + } + + currentlyPolling = true; + currentPollingCancellationToken = new SimpleCancellationToken(); + startPollingWorker(currentPollingCancellationToken); +} + +async function startPollingWorker(cancellationToken: SimpleCancellationToken) { + const pollingLoopNum = ++debugPollingLoopNum; + outputPanelLog.trace(`Starting polling loop #${pollingLoopNum}, checking contest/team status every ${pollingIntervalSeconds} seconds`); + + while (!cancellationToken.isCancelled) { + try { + await pollContestStatus(extensionContext); + } + catch (error) { + outputPanelLog.error("Polling contest status failed: " + (error ?? "")); + } + + await sleep(pollingIntervalSeconds * 1000); + } + + outputPanelLog.trace(`Polling loop #${pollingLoopNum} halting, cancellationToken was cancelled`); +} + +export function stopTeamStatusPolling() { + outputPanelLog.trace("Stopping team status polling"); + currentPollingCancellationToken?.cancel(); + currentlyPolling = false; +} + +export function useFastPolling(enabled: boolean): void { + pollingIntervalSeconds = enabled ? developerFastPollingIntervalSeconds : defaultPollingIntervalSeconds; + outputPanelLog.info(`Changed polling interval to ${pollingIntervalSeconds} seconds. Takes effect after current delay.`); +} \ No newline at end of file diff --git a/extension/bwcontest/src/extension.ts b/extension/bwcontest/src/extension.ts index 4361d2c..bb62412 100644 --- a/extension/bwcontest/src/extension.ts +++ b/extension/bwcontest/src/extension.ts @@ -5,12 +5,15 @@ import urlJoin from 'url-join'; import git from 'isomorphic-git'; import path = require('path'); import http from 'isomorphic-git/http/node'; +import outputPanelLog from './outputPanelLog'; +import { startTeamStatusPollingOnActivation, stopTeamStatusPolling, useFastPolling } from './contestMonitor/pollingService'; export interface BWContestSettings { repoBaseUrl: string; webUrl: string; repoClonePath: string; javaPath: string; + debugFastPolling: boolean; } export function extensionSettings(): BWContestSettings { @@ -77,8 +80,16 @@ export async function cloneAndOpenRepo(contestId: number, teamId: number) { } const dir = path.join(currentSettings.repoClonePath, 'BWContest', contestId.toString(), repoName); - await git.clone({ fs, http, dir, url: repoUrl }); + outputPanelLog.info(`Running 'git clone' to directory: ${dir}`); + try { + await git.clone({ fs, http, dir, url: repoUrl }); + } + catch (error) { + outputPanelLog.error("Failed to 'git clone'. The git server might be incorrectly configured. Error: " + error); + throw error; + } + outputPanelLog.info("Closing workspaces..."); closeAllWorkspaces(); const addedFolder = vscode.workspace.updateWorkspaceFolders( @@ -96,14 +107,35 @@ export async function cloneAndOpenRepo(contestId: number, teamId: number) { } export function activate(context: vscode.ExtensionContext) { + outputPanelLog.info("BWContest Extension Activated"); + const sidebarProvider = new SidebarProvider( context.extensionUri, context, extensionSettings().webUrl ); + + let fastPolling = extensionSettings().debugFastPolling; + useFastPolling(fastPolling); + context.subscriptions.push( - vscode.window.registerWebviewViewProvider('bwcontest-sidebar', sidebarProvider) + vscode.window.registerWebviewViewProvider('bwcontest-sidebar', sidebarProvider), + vscode.commands.registerCommand('bwcontest.toggleFastPolling', () => { + if (!extensionSettings().debugFastPolling) { + outputPanelLog.trace("Tried to toggle fast polling, but not allowed."); + return; + } + + fastPolling = !fastPolling; + useFastPolling(fastPolling); + }) ); + + startTeamStatusPollingOnActivation(context); +} + +export function deactivate() { + outputPanelLog.info("BWContest Extension Deactivated"); + stopTeamStatusPolling(); } -export function deactivate() {} diff --git a/extension/bwcontest/src/outputPanelLog.ts b/extension/bwcontest/src/outputPanelLog.ts new file mode 100644 index 0000000..853904b --- /dev/null +++ b/extension/bwcontest/src/outputPanelLog.ts @@ -0,0 +1,7 @@ +import { window } from "vscode"; + +/** Logs to the Output panel of a team's VS Code instance. Useful for diagnosing issues. + * + * Do NOT output anything secret here. */ +const outputPanelLog = window.createOutputChannel('BWContest Log', {log: true}); +export default outputPanelLog; \ No newline at end of file diff --git a/extension/bwcontest/src/problemPanel.ts b/extension/bwcontest/src/problemPanel.ts index c6a0225..29f95d2 100644 --- a/extension/bwcontest/src/problemPanel.ts +++ b/extension/bwcontest/src/problemPanel.ts @@ -4,10 +4,12 @@ import urlJoin from 'url-join'; import { extensionSettings } from './extension'; import { runJava } from './run/java'; import { join } from 'path'; -import { TeamData } from './SidebarProvider'; import { submitProblem } from './submit'; import { runCSharp } from './run/csharp'; import { runCpp } from './run/cpp'; +import { TeamData } from './sharedTypes'; +import outputPanelLog from './outputPanelLog'; +import { recordInitialSubmission } from './contestMonitor/contestStateSyncManager'; export type ProblemData = { id: number; @@ -54,6 +56,7 @@ export class BWPanel { } public static show(context: vscode.ExtensionContext, webUrl: string) { + outputPanelLog.info("Showing BWPanel"); const column = vscode.window.activeTextEditor ? vscode.window.activeTextEditor.viewColumn : undefined; @@ -117,20 +120,27 @@ export class BWPanel { } await vscode.workspace.saveAll(); const ans = await vscode.window.showInformationMessage( - `Are you sure you want to submit ${problem.name}?`, + `Are you sure you want to submit '${problem.name}'?`, 'Yes', 'No' ); if (ans !== 'Yes') { return; } - submitProblem(sessionToken, teamData.contestId, teamData.teamId, problemId).then((result) => { - if (result.success === true) { - vscode.window.showInformationMessage('Submitted!'); + + try { + const submissionResult = await submitProblem(sessionToken, teamData.contestId, teamData.teamId, problemId); + if (submissionResult.success === true) { + recordInitialSubmission(submissionResult.submission); + vscode.window.showInformationMessage(`Submitted '${problem.name}'!`); } else { - vscode.window.showErrorMessage(`Error: ${result.message}`); + vscode.window.showErrorMessage(`Error submitting '${problem.name}': ${submissionResult.message}`); } - }); + } + catch (error) { + vscode.window.showErrorMessage(`Web error submitting '${problem.name}'`); + outputPanelLog.error(`Web error submitting '${problem.name}': ${error}`); + } } private async handleRun(problemId: number, input: string) { diff --git a/extension/bwcontest/src/sharedTypes.ts b/extension/bwcontest/src/sharedTypes.ts new file mode 100644 index 0000000..8c44cc3 --- /dev/null +++ b/extension/bwcontest/src/sharedTypes.ts @@ -0,0 +1,9 @@ +export type ContestLanguage = 'Java' | 'CSharp' | 'CPP'; + +export type TeamData = { + teamId: number; + teamName: string; + contestId: number; + contestName: string; + language: ContestLanguage; +}; \ No newline at end of file diff --git a/extension/bwcontest/src/submit.ts b/extension/bwcontest/src/submit.ts index d87cd49..779f181 100644 --- a/extension/bwcontest/src/submit.ts +++ b/extension/bwcontest/src/submit.ts @@ -4,24 +4,37 @@ import git from 'isomorphic-git'; import path = require('path'); import http from 'isomorphic-git/http/node'; import urlJoin from 'url-join'; +import outputPanelLog from './outputPanelLog'; +import { SubmissionForExtension } from './contestMonitor/contestMonitorSharedTypes'; export async function submitProblem( sessionToken: string, contestId: number, teamId: number, problemId: number -): Promise<{ success: true } | { success: false; message: string }> { - const repoClonePath = extensionSettings().repoClonePath; +): Promise<{ success: true; submission: SubmissionForExtension } | { success: false; message: string }> { + outputPanelLog.info(`Submitting problem id #{${problemId}}...`); - const repoDir = path.join(repoClonePath, 'BWContest', contestId.toString(), teamId.toString()); - await git.add({ fs, dir: repoDir, filepath: '.' }); + let hash: string; + let repoDir: string; - const hash = await git.commit({ - fs, - dir: repoDir, - author: { name: `Team ${teamId}` }, - message: `Submit problem ${problemId}` - }); + try { + const repoClonePath = extensionSettings().repoClonePath; + + repoDir = path.join(repoClonePath, 'BWContest', contestId.toString(), teamId.toString()); + await git.add({ fs, dir: repoDir, filepath: '.' }); + + hash = await git.commit({ + fs, + dir: repoDir, + author: { name: `Team ${teamId}` }, + message: `Submit problem ${problemId}` + }); + } + catch (error) { + outputPanelLog.error("Fail to make commit for submission: " + JSON.stringify(error)); + throw error; + } try { const result = await git.push({ @@ -54,5 +67,6 @@ export async function submitProblem( if (resData.success !== true) { return { success: false, message: resData.message }; } - return { success: true }; + + return { success: true, submission: resData.submission }; } diff --git a/extension/bwcontest/src/utilities/LiteEvent.ts b/extension/bwcontest/src/utilities/LiteEvent.ts new file mode 100644 index 0000000..2ef4c74 --- /dev/null +++ b/extension/bwcontest/src/utilities/LiteEvent.ts @@ -0,0 +1,30 @@ +// Modified from JasonKleban @ https://gist.github.com/JasonKleban/50cee44960c225ac1993c922563aa540 + +export { ILiteEvent, LiteEvent } + +interface ILiteEvent { + add(handler: { (data?: T): void }): void; + remove(handler: { (data?: T): void }): void; +} + +class LiteEvent implements ILiteEvent { + protected handlers: { (data?: T): void; }[] = []; + + public add(handler: { (data?: T): void }): void { + this.handlers.push(handler); + } + + public remove(handler: { (data?: T): void }): boolean { + const countBefore = this.handlers.length; + this.handlers = this.handlers.filter(h => h !== handler); + return countBefore != this.handlers.length; + } + + public trigger(data?: T) { + this.handlers.slice(0).forEach(h => h(data)); + } + + public expose(): ILiteEvent { + return this; + } +} \ No newline at end of file diff --git a/extension/bwcontest/src/utilities/SimpleCancellationToken.ts b/extension/bwcontest/src/utilities/SimpleCancellationToken.ts new file mode 100644 index 0000000..4cf0130 --- /dev/null +++ b/extension/bwcontest/src/utilities/SimpleCancellationToken.ts @@ -0,0 +1,8 @@ +export class SimpleCancellationToken { + private _isCancelled: boolean = false; + get isCancelled() { return this._isCancelled; } + + cancel(): void { + this._isCancelled = true; + } +} \ No newline at end of file diff --git a/extension/bwcontest/src/utilities/sleep.ts b/extension/bwcontest/src/utilities/sleep.ts new file mode 100644 index 0000000..e9d53bf --- /dev/null +++ b/extension/bwcontest/src/utilities/sleep.ts @@ -0,0 +1,3 @@ +export function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} \ No newline at end of file diff --git a/extension/bwcontest/webviews/components/ProblemPanel.svelte b/extension/bwcontest/webviews/components/ProblemPanel.svelte index 984e1bf..200ed0d 100644 --- a/extension/bwcontest/webviews/components/ProblemPanel.svelte +++ b/extension/bwcontest/webviews/components/ProblemPanel.svelte @@ -107,7 +107,7 @@

Sample Input (You can edit this!)

-