diff --git a/extension/bwcontest/package.json b/extension/bwcontest/package.json index b629e35..aa8a1a5 100644 --- a/extension/bwcontest/package.json +++ b/extension/bwcontest/package.json @@ -68,8 +68,25 @@ { "command": "bwcontest.toggleFastPolling", "title": "BWContest Developer: Toggle Fast Polling" + }, + { + "command": "bwcontest.refreshState", + "title": "Refresh" + }, + { + "command": "bwcontest.showTestSubmitPage", + "title": "BWContest: Show Test/Submit Page" } - ] + ], + "menus": { + "view/title": [ + { + "command": "bwcontest.refreshState", + "group": "navigation", + "when": "view == bwcontest-sidebar" + } + ] + } }, "scripts": { "vscode:prepublish": "npm run compile", diff --git a/extension/bwcontest/src/SidebarProvider.ts b/extension/bwcontest/src/SidebarProvider.ts index 3ae72db..d1bf3a4 100644 --- a/extension/bwcontest/src/SidebarProvider.ts +++ b/extension/bwcontest/src/SidebarProvider.ts @@ -1,6 +1,15 @@ import * as vscode from 'vscode'; import { getNonce } from './getNonce'; -import { cloneAndOpenRepo } from './extension'; +import { + RepoState, + clearCachedRepoState, + cloneOpenRepo, + cloneRepo, + openRepo, + getCachedRepoState, + refreshRepoState, + repoStateChanged +} from './teamRepoManager'; import { BWPanel } from './problemPanel'; import urlJoin from 'url-join'; import outputPanelLog from './outputPanelLog'; @@ -22,12 +31,15 @@ import { startTeamStatusPolling, stopTeamStatusPolling } from './contestMonitor/ export type WebviewMessageType = | { msg: 'onLogin'; data: TeamData } | { msg: 'onLogout' } - | { msg: 'teamStatusUpdated'; data: SidebarTeamStatus | null }; + | { msg: 'teamStatusUpdated'; data: SidebarTeamStatus | null } + | { msg: 'repoStateUpdated'; data: RepoState }; export type MessageType = | { msg: 'onTestAndSubmit' } | { msg: 'onUIMount' } - | { msg: 'onClone'; data: { contestId: number; teamId: number } } + | { msg: 'onCloneOpenRepo'; data: { contestId: number; teamId: number } } + | { msg: 'onCloneRepo'; data: { contestId: number; teamId: number } } + | { msg: 'onOpenRepo'; data: { contestId: number; teamId: number } } | { msg: 'onLogin'; data: { teamName: string; password: string } } | { msg: 'onLogout' }; @@ -75,6 +87,22 @@ export class SidebarProvider implements vscode.WebviewViewProvider { submissionsChangedEventArgs.changedProblemIds ); }); + + const currentRepoState = getCachedRepoState(); + outputPanelLog.info( + 'When SidebarProvider constructed, cached repo state is: ' + currentRepoState + ); + this.updateRepoStatus(currentRepoState); + + repoStateChanged.add((repoChangedEventArgs) => { + outputPanelLog.trace('Repo status updating from event'); + + if (!repoChangedEventArgs) { + return; + } + + this.updateRepoStatus(repoChangedEventArgs.state); + }); } private async handleLogin( @@ -129,6 +157,11 @@ export class SidebarProvider implements vscode.WebviewViewProvider { 'After login, cached submission list is: ' + JSON.stringify(currentSubmissionsList) ); this.updateTeamStatus(currentSubmissionsList); + + const currentRepoState = getCachedRepoState(); + outputPanelLog.info('After login, cached repo state is: ' + currentRepoState); + this.updateRepoStatus(currentRepoState); + refreshRepoState(); } private async handleLogout(webviewPostMessage: (m: WebviewMessageType) => void) { @@ -189,6 +222,8 @@ export class SidebarProvider implements vscode.WebviewViewProvider { stopTeamStatusPolling(); clearCachedContestTeamState(); + clearCachedRepoState(); + this.context.globalState.update('token', undefined); this.context.globalState.update('teamData', undefined); } @@ -236,6 +271,24 @@ export class SidebarProvider implements vscode.WebviewViewProvider { this.webview.postMessage(message); } + public updateRepoStatus(state: RepoState) { + if (this.webview == null) { + outputPanelLog.trace('Not updating sidebar repo state because webview is null'); + return; + } + + const message: WebviewMessageType = { + msg: 'repoStateUpdated', + data: state + }; + + outputPanelLog.trace( + 'Posting repoStateUpdated to webview with message: ' + JSON.stringify(message) + ); + + this.webview.postMessage(message); + } + public resolveWebviewView(webviewView: vscode.WebviewView) { outputPanelLog.trace('SidebarProvider resolveWebviewView'); const webview = webviewView.webview; @@ -273,11 +326,23 @@ export class SidebarProvider implements vscode.WebviewViewProvider { 'onUIMount, currentSubmissionsList is ' + JSON.stringify(currentSubmissionsList) ); this.updateTeamStatus(currentSubmissionsList); + + const currentRepoState = getCachedRepoState(); + outputPanelLog.trace('onUIMount, currentRepoState is ' + currentRepoState); + this.updateRepoStatus(currentRepoState); } break; } - case 'onClone': { - cloneAndOpenRepo(m.data.contestId, m.data.teamId); + case 'onCloneOpenRepo': { + cloneOpenRepo(m.data.contestId, m.data.teamId); + break; + } + case 'onCloneRepo': { + cloneRepo(m.data.contestId, m.data.teamId); + break; + } + case 'onOpenRepo': { + openRepo(m.data.contestId, m.data.teamId); break; } case 'onLogin': { diff --git a/extension/bwcontest/src/extension.ts b/extension/bwcontest/src/extension.ts index f11eb17..403bea7 100644 --- a/extension/bwcontest/src/extension.ts +++ b/extension/bwcontest/src/extension.ts @@ -1,16 +1,17 @@ import * as vscode from 'vscode'; import { SidebarProvider } from './SidebarProvider'; -import * as fs from 'fs-extra'; -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'; +import { + clearCachedRepoState, + refreshRepoState, + setRepoManagerExtensionContext +} from './teamRepoManager'; +import { BWPanel } from './problemPanel'; export interface BWContestSettings { repoBaseUrl: string; @@ -24,93 +25,6 @@ export function extensionSettings(): BWContestSettings { return vscode.workspace.getConfiguration().get('BWContest')!; } -function closeAllWorkspaces() { - const workspaceFolders = vscode.workspace.workspaceFolders; - if (!workspaceFolders) { - return; - } - const removedFolders = vscode.workspace.updateWorkspaceFolders(0, workspaceFolders.length); - if (!removedFolders) { - return; - } -} - -export async function cloneAndOpenRepo(contestId: number, teamId: number) { - const currentSettings = vscode.workspace.getConfiguration().get('BWContest'); - - if (!currentSettings || currentSettings.repoBaseUrl == '') { - vscode.window.showErrorMessage('BWContest: BWContest.repoBaseURL not set'); - return; - } - if (!currentSettings || currentSettings.repoClonePath == '') { - vscode.window.showErrorMessage('BWContest: BWContest.repoClonePath not set'); - return; - } - if (!currentSettings || currentSettings.webUrl == '') { - vscode.window.showErrorMessage('BWContest: BWContest.webUrl not set'); - return; - } - - const repoUrl = urlJoin( - currentSettings.repoBaseUrl, - contestId.toString(), - `${teamId.toString()}.git` - ); - - const repoName = teamId.toString(); - - if (!fs.existsSync(`${currentSettings.repoClonePath}/BWContest`)) { - fs.mkdirSync(`${currentSettings.repoClonePath}/BWContest`); - } - if (!fs.existsSync(`${currentSettings.repoClonePath}/BWContest/${contestId.toString()}`)) { - fs.mkdirSync(`${currentSettings.repoClonePath}/BWContest/${contestId.toString()}`); - } - - const clonedRepoPath = `${ - currentSettings.repoClonePath - }/BWContest/${contestId.toString()}/${repoName}`; - - if (fs.existsSync(clonedRepoPath)) { - const confirm = await vscode.window.showWarningMessage( - 'The repo already exists. Do you want to replace it?', - 'Delete and Replace', - 'Cancel' - ); - if (confirm !== 'Delete and Replace') { - return; - } - closeAllWorkspaces(); - fs.removeSync(clonedRepoPath); - } - - const dir = path.join(currentSettings.repoClonePath, 'BWContest', contestId.toString(), repoName); - 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( - vscode.workspace.workspaceFolders?.length ?? 0, - 0, - { uri: vscode.Uri.file(clonedRepoPath), name: 'BWContest' } - ); - - if (!addedFolder) { - vscode.window.showErrorMessage('BWContest: Failed to open cloned repo'); - return; - } - - vscode.window.showInformationMessage('BWContest: Repo cloned and opened'); -} - export function activate(context: vscode.ExtensionContext) { outputPanelLog.info('BWContest Extension Activated'); @@ -133,13 +47,27 @@ export function activate(context: vscode.ExtensionContext) { fastPolling = !fastPolling; useFastPolling(fastPolling); + }), + vscode.commands.registerCommand('bwcontest.showTestSubmitPage', () => { + BWPanel.show(context, extensionSettings().webUrl); + }), + vscode.commands.registerCommand('bwcontest.refreshState', () => { + refreshRepoState(); }) ); startTeamStatusPollingOnActivation(context); + + setRepoManagerExtensionContext(context); + refreshRepoState(); + + vscode.workspace.onDidChangeWorkspaceFolders(() => { + refreshRepoState(); + }); } export function deactivate() { outputPanelLog.info('BWContest Extension Deactivated'); stopTeamStatusPolling(); + clearCachedRepoState(); } diff --git a/extension/bwcontest/src/teamRepoManager.ts b/extension/bwcontest/src/teamRepoManager.ts new file mode 100644 index 0000000..905fa85 --- /dev/null +++ b/extension/bwcontest/src/teamRepoManager.ts @@ -0,0 +1,318 @@ +import * as vscode from 'vscode'; +import * as fs from 'fs-extra'; +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 { BWContestSettings } from './extension'; +import { LiteEvent } from './utilities/LiteEvent'; +import { TeamData } from './sharedTypes'; +import * as os from 'os'; + +let latestRepoState: RepoState = 'No Team'; + +const onRepoStateChanged = new LiteEvent(); +export const repoStateChanged = onRepoStateChanged.expose(); + +export type RepoState = 'No Team' | 'No Repo' | 'Repo Exists, Not Open' | 'Repo Open'; + +export type RepoChangedEventArgs = { + state: RepoState; +}; + +export function getCachedRepoState(): RepoState { + return latestRepoState; +} + +export function clearCachedRepoState(): void { + outputPanelLog.trace(`Clearing cached repoState`); + setRepoState('No Team'); +} + +export async function refreshRepoState(): Promise { + outputPanelLog.trace(`Refreshing repoState`); + + if (!repoManagerExtensionContext) { + outputPanelLog.trace(` -> repoState is 'No Team' because something is misconfigured`); + return setRepoState('No Team'); + } + + const teamData = repoManagerExtensionContext.globalState.get('teamData'); + if (teamData === undefined) { + outputPanelLog.trace(` -> repoState is 'No Team' because no globalState for teamData`); + return setRepoState('No Team'); + } + + const repoPaths = getRepoPaths(teamData.contestId, teamData.teamId); + if (!repoPaths.success) { + outputPanelLog.trace(` -> repoState is 'No Team' can't calculate repo paths`); + return setRepoState('No Team'); + } + + const { clonedRepoPath } = repoPaths; + + outputPanelLog.trace(` -> inspecting local repoPath ${clonedRepoPath}`); + if (!fs.existsSync(clonedRepoPath)) { + outputPanelLog.trace(` -> repoState is 'No Repo', the local repo path doesn't exist at all`); + return setRepoState('No Repo'); + } + + if (!(await directoryHasGitRepo(clonedRepoPath))) { + outputPanelLog.trace( + ` -> repoState is 'No Repo', the local repo path exists but does not have a git repo` + ); + return setRepoState('No Repo'); + } + + if (vscode.workspace.workspaceFolders) { + const existingOpenFolderForRepo = vscode.workspace.workspaceFolders.filter((f) => { + const p = + os.platform() === 'win32' + ? path.normalize(f.uri.path.slice(1)) + : path.normalize(f.uri.path); + return p === path.normalize(clonedRepoPath); + })[0]; + if (existingOpenFolderForRepo) { + outputPanelLog.trace( + ` -> repoState is 'Repo Open', we found the repo path in VSCode's workspaceFolders` + ); + return setRepoState('Repo Open'); + } + } + + const workspaceFoldersLogText = vscode.workspace.workspaceFolders + ? vscode.workspace.workspaceFolders.map((f) => f.uri).join(', ') + : '(no workspaceFolders)'; + outputPanelLog.trace( + ` -> repoState is 'Repo Exists, Not Open', did not find repoPath (${clonedRepoPath}) in VSCode's workspaceFolders (${workspaceFoldersLogText})` + ); + return setRepoState('Repo Exists, Not Open'); +} + +function setRepoState(state: RepoState): void { + if (state != latestRepoState) { + outputPanelLog.trace(`Detected repoState change: ${latestRepoState} -> ${state}`); + latestRepoState = state; + onRepoStateChanged.trigger({ state }); + } else { + outputPanelLog.trace(`No repoState change, same value: ${state}`); + } +} + +export async function cloneOpenRepo(contestId: number, teamId: number): Promise { + const result = (await cloneRepoWorker(contestId, teamId)) && openRepoWorker(contestId, teamId); + refreshRepoState(); + return result; +} + +export async function cloneRepo(contestId: number, teamId: number): Promise { + const result = await cloneRepoWorker(contestId, teamId); + refreshRepoState(); + return result; +} + +export function openRepo(contestId: number, teamId: number): boolean { + const result = openRepoWorker(contestId, teamId); + refreshRepoState(); + return result; +} + +async function cloneRepoWorker(contestId: number, teamId: number): Promise { + const repoPaths = getRepoPaths(contestId, teamId); + if (!repoPaths.success) { + vscode.window.showErrorMessage('BWContest: BWContestSettings not configured'); + return false; + } + + const { repoUrl, clonedRepoPath } = repoPaths; + + outputPanelLog.trace(`Trying to cloneRepo`); + outputPanelLog.trace(` URL: ${repoUrl}`); + outputPanelLog.trace(` Local Directory: ${clonedRepoPath}`); + + if (!fs.existsSync(clonedRepoPath)) { + outputPanelLog.trace('Local Directory does not exist, creating it'); + try { + fs.mkdirSync(clonedRepoPath, { recursive: true }); + } catch (error) { + vscode.window.showErrorMessage( + `BWContest: Could not create directory '${clonedRepoPath}': ${error}` + ); + return false; + } + + outputPanelLog.trace('Local Directory created, starting clone'); + return await doClone(clonedRepoPath, repoUrl); + } + + if (fs.readdirSync(clonedRepoPath).length == 0) { + outputPanelLog.trace('Local Directory exists but is empty, starting clone'); + return await doClone(clonedRepoPath, repoUrl); + } + + outputPanelLog.trace('Local Directory exists and is non-empty'); + + const gitHasRemote = await directoryHasGitRepo(clonedRepoPath); + + const deleteAndReplacePrompt = gitHasRemote + ? 'The repository already exists, replacing it will delete local changes. Are you sure?' + : 'The repository directory exists with no git repo, deleting it will delete local changes. Are you sure?'; + + const confirm = await vscode.window.showWarningMessage( + deleteAndReplacePrompt, + 'Delete and Replace', + 'Cancel' + ); + + if (confirm !== 'Delete and Replace') { + return false; + } + + outputPanelLog.trace(`Team has chosen to delete non-empty Local Directory`); + + try { + const existingItemsInDir = fs.readdirSync(clonedRepoPath, { + recursive: false, + encoding: 'utf8' + }); + + outputPanelLog.trace(` Removing ${existingItemsInDir.length} items`); + for (const existingItemInDir of existingItemsInDir) { + const fullPath = path.join(clonedRepoPath, existingItemInDir); + outputPanelLog.trace(` Removing ${fullPath}`); + fs.rmSync(fullPath, { recursive: true, force: true }); + } + } catch (error) { + vscode.window.showErrorMessage( + `BWContest: Failed to delete contents of Local Directory '${clonedRepoPath}': ${error}` + ); + return false; + } + + try { + const itemsInDirAfterDelete = fs.readdirSync(clonedRepoPath, { + recursive: false, + encoding: 'utf8' + }); + + outputPanelLog.trace( + `Local Directory should now be empty, there are ${itemsInDirAfterDelete.length} item(s): ${itemsInDirAfterDelete.join(', ')}` + ); + if (itemsInDirAfterDelete.length > 0) { + vscode.window.showErrorMessage(`BWContest: Failed to delete contents of Local Directory`); + return false; + } + } catch (error) { + vscode.window.showErrorMessage( + `BWContest: Failed to delete contents of Local Directory '${clonedRepoPath}': ${error}` + ); + return false; + } + + outputPanelLog.trace(`Local Directory is now empty, starting clone`); + return await doClone(clonedRepoPath, repoUrl); +} + +async function directoryHasGitRepo(path: string): Promise { + try { + await git.listRemotes({ fs, dir: path }); + return true; + } catch (error) { + return false; + } +} + +async function doClone(targetDirectory: string, repoUrl: string): Promise { + outputPanelLog.trace(`Running 'git clone' from url ${repoUrl} to directory: ${targetDirectory}`); + try { + await git.clone({ fs, http, dir: targetDirectory, url: repoUrl }); + } catch (error) { + vscode.window.showErrorMessage(`BWContest: Failed to git clone: ${error}`); + return false; + } + + vscode.window.showInformationMessage('BWContest: Repo cloned!'); + return true; +} + +function openRepoWorker(contestId: number, teamId: number): boolean { + const repoPaths = getRepoPaths(contestId, teamId); + if (!repoPaths.success) { + vscode.window.showErrorMessage('BWContest: BWContestSettings not configured'); + return false; + } + + const { clonedRepoPath } = repoPaths; + + if (!fs.existsSync(clonedRepoPath)) { + vscode.window.showErrorMessage('BWContest: Local repo not found, clone first.'); + return false; + } + + if (vscode.workspace.workspaceFolders) { + const existingOpenFolderForRepo = vscode.workspace.workspaceFolders.filter((f) => { + const p = + os.platform() === 'win32' + ? path.normalize(f.uri.path.slice(1)) + : path.normalize(f.uri.path); + return p === path.normalize(clonedRepoPath); + })[0]; + if (existingOpenFolderForRepo) { + vscode.window.showInformationMessage('BWContest: Repo is already opened'); + return false; + } + } + + outputPanelLog.trace(`Opening local repo from '${clonedRepoPath}'`); + const workspaceUpdateValid = vscode.workspace.updateWorkspaceFolders( + 0, + vscode.workspace.workspaceFolders?.length ?? 0, + { uri: vscode.Uri.file(clonedRepoPath), name: 'BWContest' } + ); + + if (!workspaceUpdateValid) { + vscode.window.showErrorMessage('BWContest: Request to open repository in VSCode failed'); + return false; + } + + // Shouldn't happen, or if it does the IDE will restart shortly after + vscode.window.showInformationMessage('BWContest: Team repository opened!'); + return true; +} + +export function getRepoPaths( + contestId: number, + teamId: number +): { success: false } | { success: true; repoUrl: string; clonedRepoPath: string } { + const currentSettings = vscode.workspace.getConfiguration().get('BWContest'); + + if (!currentSettings || currentSettings.repoBaseUrl == '') { + // vscode.window.showErrorMessage('BWContest: BWContest.repoBaseURL not set'); + return { success: false }; + } + if (!currentSettings || currentSettings.repoClonePath == '') { + // vscode.window.showErrorMessage('BWContest: BWContest.repoClonePath not set'); + return { success: false }; + } + if (!currentSettings || currentSettings.webUrl == '') { + // vscode.window.showErrorMessage('BWContest: BWContest.webUrl not set'); + return { success: false }; + } + + return { + success: true, + repoUrl: urlJoin(currentSettings.repoBaseUrl, contestId.toString(), `${teamId.toString()}.git`), + clonedRepoPath: path.join( + currentSettings.repoClonePath, + 'BWContest', + contestId.toString(), + teamId.toString() + ) + }; +} + +let repoManagerExtensionContext: vscode.ExtensionContext | null; +export function setRepoManagerExtensionContext(context: vscode.ExtensionContext) { + repoManagerExtensionContext = context; +} diff --git a/extension/bwcontest/webviews/components/Sidebar.svelte b/extension/bwcontest/webviews/components/Sidebar.svelte index 7e93024..8c88e82 100644 --- a/extension/bwcontest/webviews/components/Sidebar.svelte +++ b/extension/bwcontest/webviews/components/Sidebar.svelte @@ -7,6 +7,7 @@ MessageType, SidebarTeamStatus } from '../../src/SidebarProvider'; + import type { RepoState } from '../../src/teamRepoManager'; let teamname: string; let password: string; @@ -16,16 +17,36 @@ let teamData: TeamData | null = null; let teamStatus: SidebarTeamStatus | null = null; + let repoState: RepoState | null = null; + let totalProblems = 0; function postMessage(message: MessageType) { vscode.postMessage(message); } - function onClone() { + function onCloneOpenRepo() { if (teamData) { postMessage({ - msg: 'onClone', + msg: 'onCloneOpenRepo', + data: { contestId: teamData.contestId, teamId: teamData.teamId } + }); + } + } + + function onCloneRepo() { + if (teamData) { + postMessage({ + msg: 'onCloneRepo', + data: { contestId: teamData.contestId, teamId: teamData.teamId } + }); + } + } + + function onOpenRepo() { + if (teamData) { + postMessage({ + msg: 'onOpenRepo', data: { contestId: teamData.contestId, teamId: teamData.teamId } }); } @@ -71,6 +92,8 @@ teamStatus.incorrectProblems.length + teamStatus.notStartedProblems.length : 0; + } else if (m.msg === 'repoStateUpdated') { + repoState = m.data; } }); @@ -111,10 +134,24 @@

Actions

-
- - -
+ {#if repoState == 'No Team'} + Team not connected, click Refresh at the top of this panel + {:else if repoState == 'No Repo'} +
+ +
+ {:else if repoState == 'Repo Exists, Not Open'} +
+ +
+ {:else if repoState == 'Repo Open'} +
+ + +
+ {:else} + Checking repo state... + {/if}

Problem Progress