diff --git a/.vscode/settings.json b/.vscode/settings.json index bbb550d..d1ff3fc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,5 +3,15 @@ "files.associations": { "iostream": "cpp" }, - "editor.formatOnSave": true + "editor.formatOnSave": true, + "[typescript]": { + "editor.codeActionsOnSave": { + "source.organizeImports": "never" + } + }, + "[typescriptreact]": { + "editor.codeActionsOnSave": { + "source.organizeImports": "never" + } + } } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index f1a8d6e..207417a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "competitive-programming-helper", - "version": "2077.0.0", + "version": "2081.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "competitive-programming-helper", - "version": "2077.0.0", + "version": "2081.0.0", "license": "GPL-3.0-or-later", "dependencies": { "@vscode/extension-telemetry": "^0.9.0", diff --git a/package.json b/package.json index 83e53cb..0135027 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "license": "GPL-3.0-or-later", "icon": "icon.png", "publisher": "DivyanshuAgrawal", - "version": "2077.0.0", + "version": "2081.0.0", "engines": { "vscode": "^1.52.0" }, @@ -70,7 +70,7 @@ "cph.general.saveLocation": { "type": "string", "default": "", - "description": "Location where generated .tcs and .bin files will be saved. Leave empty to save the file in the source file directory. Use this to clean up your folders." + "description": "Directory where new problem source files and their .prob metadata will be saved. Supports the ${group} placeholder (e.g. '${group}/'), which is replaced with the problem's group. Leave empty to save in the workspace root for sources and alongside sources in .cph for metadata." }, "cph.general.timeOut": { "type": "number", @@ -360,7 +360,7 @@ }, "cph.general.retainWebviewContext": { "type": "boolean", - "default": false, + "default": true, "description": "Keep the webview active even when it's hidden. May improve performance but may cause some rendering issues." }, "cph.general.defaultLanguageTemplateFileLocation": { diff --git a/src/companion.ts b/src/companion.ts index 0e75aba..38d8d08 100644 --- a/src/companion.ts +++ b/src/companion.ts @@ -4,10 +4,11 @@ import { Problem, CphSubmitResponse, CphEmptyResponse } from './types'; import { saveProblem } from './parser'; import * as vscode from 'vscode'; import path from 'path'; -import { writeFileSync, readFileSync, existsSync } from 'fs'; +import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs'; import { isCodeforcesUrl, isLuoguUrl, isAtCoderUrl, randomId } from './utils'; import { getDefaultLangPref, + getSaveLocationPref, getLanguageId, useShortCodeForcesName, useShortLuoguName, @@ -221,7 +222,20 @@ const handleNewProblem = async (problem: Problem) => { problem.name = splitUrl[splitUrl.length - 1]; } const problemFileName = getProblemFileName(problem, extn); - const srcPath = path.join(folder, problemFileName); + let configuredSaveDir = getSaveLocationPref(); + if (configuredSaveDir && configuredSaveDir.includes('${group}')) { + const groupKey = (problem.group || '').trim(); + const replacement = groupKey === '' ? '' : groupKey; + configuredSaveDir = configuredSaveDir.replace( + /\$\{group\}/g, + replacement, + ); + } + const targetDir = + configuredSaveDir && configuredSaveDir !== '' + ? configuredSaveDir + : folder; + const srcPath = path.join(targetDir, problemFileName); // Add fields absent in competitive companion. problem.srcPath = srcPath; @@ -230,11 +244,29 @@ const handleNewProblem = async (problem: Problem) => { // Pass in index to avoid generating duplicate id id: randomId(index), })); + if (!existsSync(path.dirname(srcPath))) { + try { + // ensure nested paths if user configured nested folder + mkdirSync(path.dirname(srcPath), { recursive: true }); + } catch (e) { + globalThis.logger.error('Failed to create target directory', e); + } + } if (!existsSync(srcPath)) { writeFileSync(srcPath, ''); } saveProblem(srcPath, problem); - const doc = await vscode.workspace.openTextDocument(srcPath); + // Avoid redundant openTextDocument if already open + const visibleEditor = vscode.window.visibleTextEditors.find( + (e) => e.document.fileName === srcPath, + ); + const existingDoc = + visibleEditor?.document || + vscode.workspace.textDocuments.find((d) => d.fileName === srcPath); + const doc = + existingDoc !== undefined + ? existingDoc + : await vscode.workspace.openTextDocument(srcPath); if (defaultLanguage) { const templateLocation = getDefaultLanguageTemplateFileLocation(); diff --git a/src/compiler.ts b/src/compiler.ts index 811d822..bf6f6eb 100644 --- a/src/compiler.ts +++ b/src/compiler.ts @@ -51,7 +51,11 @@ export const getBinSaveLocation = (srcPath: string): string => { const srcFileName = path.parse(srcPath).name; const binFileName = toAsciiFilename(srcFileName) + ext; const binDir = path.dirname(srcPath); - if (savePreference && savePreference !== '') { + if ( + savePreference && + savePreference !== '' && + !savePreference.includes('${') + ) { return path.join(savePreference, binFileName); } return path.join(binDir, binFileName); diff --git a/src/parser.ts b/src/parser.ts index 10c6df2..7fc2949 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1,8 +1,8 @@ import path from 'path'; import fs from 'fs'; +import * as vscode from 'vscode'; import { Problem } from './types'; import { getSaveLocationPref } from './preferences'; -import crypto from 'crypto'; /** * Get the location (file path) to save the generated problem file in. If save @@ -12,30 +12,107 @@ import crypto from 'crypto'; * @param srcPath location of the source code */ export const getProbSaveLocation = (srcPath: string): string => { - const savePreference = getSaveLocationPref(); const srcFileName = path.basename(srcPath); const srcFolder = path.dirname(srcPath); - const hash = crypto - .createHash('md5') - .update(srcPath) - .digest('hex') - .substr(0); - const baseProbName = `.${srcFileName}_${hash}.prob`; + const baseProbName = `${srcFileName}.prob`; const cphFolder = path.join(srcFolder, '.cph'); - if (savePreference && savePreference !== '') { - return path.join(savePreference, baseProbName); - } return path.join(cphFolder, baseProbName); }; +/** Find the .prob path for the given source by scanning ancestor .cph folders. */ +export const findProbPath = (srcPath: string): string | null => { + const srcFolder = path.dirname(srcPath); + const srcFileName = path.basename(srcPath); + + const workspaceFolder = vscode.workspace.getWorkspaceFolder( + vscode.Uri.file(srcPath), + ); + const workspaceRoot = + workspaceFolder?.uri.fsPath ?? path.parse(srcFolder).root; + + const ancestors: string[] = []; + let currentDir = srcFolder; + // Collect ancestor directories up to workspace root (inclusive) + let reachedRoot = false; + while (!reachedRoot) { + ancestors.push(currentDir); + reachedRoot = + path.resolve(currentDir) === path.resolve(workspaceRoot) || + path.dirname(currentDir) === currentDir; + if (!reachedRoot) { + currentDir = path.dirname(currentDir); + } + } + + for (const dir of ancestors) { + const cphFolder = path.join(dir, '.cph'); + if (!fs.existsSync(cphFolder)) { + continue; + } + const files = fs + .readdirSync(cphFolder) + .filter((f) => f.endsWith('.prob')); + if (files.length === 0) { + continue; + } + // Prioritize files that start with the source filename + const prioritized = []; + const nonPrioritized = []; + for (const file of files) { + const name = file.startsWith('.') ? file.slice(1) : file; + if (name.startsWith(srcFileName)) { + prioritized.push(file); + } else { + nonPrioritized.push(file); + } + } + + const all = [...prioritized, ...nonPrioritized]; + + for (const file of all) { + const fullProbPath = path.join(cphFolder, file); + try { + const content = fs.readFileSync(fullProbPath).toString(); + const parsed: Problem = JSON.parse(content); + const recorded = (parsed as any).srcPath as string | undefined; + if (!recorded) { + continue; + } + const parentOfCph = dir; // parent of the .cph folder + let resolvedRecorded: string; + if (path.isAbsolute(recorded)) { + resolvedRecorded = path.normalize(recorded); + } else { + resolvedRecorded = path.resolve(parentOfCph, recorded); + } + const samePath = + path.normalize(resolvedRecorded) === + path.normalize(srcPath); + if (samePath) { + return fullProbPath; + } + } catch (_e) { + // Ignore invalid/partial files + continue; + } + } + } + + return null; +}; + /** Get the problem for a source, `null` if does not exist on the filesystem. */ export const getProblem = (srcPath: string): Problem | null => { - const probPath = getProbSaveLocation(srcPath); - let problem: string; + const probPath = findProbPath(srcPath); + if (!probPath) { + return null; + } try { - problem = fs.readFileSync(probPath).toString(); - return JSON.parse(problem); - } catch (err) { + const content = fs.readFileSync(probPath).toString(); + const parsed: Problem = JSON.parse(content); + parsed.srcPath = srcPath; + return parsed; + } catch (_e) { return null; } }; @@ -45,14 +122,24 @@ export const saveProblem = (srcPath: string, problem: Problem) => { const srcFolder = path.dirname(srcPath); const cphFolder = path.join(srcFolder, '.cph'); - if (getSaveLocationPref() === '' && !fs.existsSync(cphFolder)) { + const pref = getSaveLocationPref(); + if (pref === '' && !fs.existsSync(cphFolder)) { globalThis.logger.log('Making .cph folder'); fs.mkdirSync(cphFolder); } const probPath = getProbSaveLocation(srcPath); + const probDir = path.dirname(probPath); + if (!fs.existsSync(probDir)) { + fs.mkdirSync(probDir, { recursive: true }); + } try { - fs.writeFileSync(probPath, JSON.stringify(problem)); + const problemToSave: Problem = { + ...problem, + // Store path relative to parent of .cph folder when possible + srcPath: path.relative(path.dirname(probDir), srcPath), + }; + fs.writeFileSync(probPath, JSON.stringify(problemToSave, null, 2)); } catch (err) { throw new Error(err as string); } diff --git a/src/preferences.ts b/src/preferences.ts index cb4acc4..c33e89a 100644 --- a/src/preferences.ts +++ b/src/preferences.ts @@ -24,20 +24,35 @@ export const getAutoShowJudgePref = (): boolean => getPreference('general.autoShowJudge'); export const getSaveLocationPref = (): string => { - const pref = getPreference('general.saveLocation'); - const validSaveLocation = pref == '' || fs.existsSync(pref); - if (!validSaveLocation) { + const raw = getPreference('general.saveLocation') as string; + if (raw === '') return ''; + + let resolved = raw; + if (!path.isAbsolute(raw)) { + const folder = workspace.workspaceFolders?.[0]?.uri.fsPath; + if (!folder) { + vscode.window.showErrorMessage( + `Save location '${raw}' is relative but no workspace is open.`, + ); + return ''; + } + resolved = path.join(folder, raw); + } + + try { + // Do not create directories if path contains template placeholders like ${group} + if (!resolved.includes('${')) { + if (!fs.existsSync(resolved)) { + fs.mkdirSync(resolved, { recursive: true }); + } + } + return resolved; + } catch (e) { vscode.window.showErrorMessage( - `Invalid save location, reverting to default. path not exists: ${pref}`, - ); - updatePreference( - 'general.saveLocation', - '', - vscode.ConfigurationTarget.Global, + `Could not create save location: ${resolved}. Falling back to default. ${e}`, ); return ''; } - return pref; }; export const getHideStderrorWhenCompiledOK = (): boolean => diff --git a/src/runTestCases.ts b/src/runTestCases.ts index b95b337..64390e8 100644 --- a/src/runTestCases.ts +++ b/src/runTestCases.ts @@ -30,8 +30,17 @@ export default async () => { const problem = getProblem(srcPath); if (!problem) { - globalThis.logger.log('No problem saved.'); - createLocalProblem(editor); + const judge = getJudgeViewProvider(); + if (judge.isOpen()) { + await createLocalProblem(); + } else { + globalThis.logger.log('No problem saved. Showing create UI.'); + judge.focus(); + judge.extensionToJudgeViewMessage({ + command: 'new-problem', + problem: undefined, + }); + } return; } @@ -51,9 +60,14 @@ export default async () => { vscode.window.showTextDocument(editor.document, vscode.ViewColumn.One); }; -const createLocalProblem = async (editor: vscode.TextEditor) => { +export const createLocalProblem = async () => { globalThis.reporter.sendTelemetryEvent(telmetry.NEW_LOCAL_PROBLEM); globalThis.logger.log('Creating local problem'); + const editor = vscode.window.activeTextEditor; + if (editor === undefined) { + checkUnsupported(''); + return; + } const srcPath = editor.document.fileName; if (checkUnsupported(srcPath)) { return; diff --git a/src/utils.ts b/src/utils.ts index 3fa1b36..efb99a9 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -5,7 +5,7 @@ import path from 'path'; import * as vscode from 'vscode'; import config from './config'; -import { getProbSaveLocation } from './parser'; +import { findProbPath, getProbSaveLocation } from './parser'; import { getCArgsPref, getCppArgsPref, @@ -195,7 +195,7 @@ export const checkUnsupported = (srcPath: string): boolean => { /** Deletes the .prob problem file for a given source code path. */ export const deleteProblemFile = async (srcPath: string) => { globalThis.reporter.sendTelemetryEvent(telmetry.DELETE_ALL_TESTCASES); - const probPath = getProbSaveLocation(srcPath); + const probPath = findProbPath(srcPath) ?? getProbSaveLocation(srcPath); globalThis.logger.log('Deleting problem file', probPath); try { @@ -243,10 +243,11 @@ export const getProblemForDocument = ( } const srcPath = document.fileName; - const probPath = getProbSaveLocation(srcPath); - if (!existsSync(probPath)) { + const probPath = findProbPath(srcPath); + if (!probPath || !existsSync(probPath)) { return undefined; } const problem: Problem = JSON.parse(readFileSync(probPath).toString()); + problem.srcPath = srcPath; return problem; }; diff --git a/src/webview/JudgeView.ts b/src/webview/JudgeView.ts index d06a5b9..9c8637f 100644 --- a/src/webview/JudgeView.ts +++ b/src/webview/JudgeView.ts @@ -6,7 +6,7 @@ import { VSToWebViewMessage, WebviewToVSEvent } from '../types'; import { deleteProblemFile, getProblemForDocument } from '../utils'; import { runSingleAndSave } from './processRunSingle'; import runAllAndSave from './processRunAll'; -import runTestCases from '../runTestCases'; +import { createLocalProblem } from '../runTestCases'; import { getAutoShowJudgePref, getRemoteServerAddressPref, @@ -15,6 +15,16 @@ import { } from '../preferences'; import { setOnlineJudgeEnv } from '../compiler'; +function getNonce() { + let text = ''; + const possible = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + for (let i = 0; i < 32; i++) { + text += possible.charAt(Math.floor(Math.random() * possible.length)); + } + return text; +} + class JudgeViewProvider implements vscode.WebviewViewProvider { public static readonly viewType = 'cph.judgeView'; @@ -26,6 +36,10 @@ class JudgeViewProvider implements vscode.WebviewViewProvider { return this._view === undefined; } + public isOpen() { + return !!this._view && this._view.visible === true; + } + constructor(private readonly _extensionUri: vscode.Uri) {} public resolveWebviewView(webviewView: vscode.WebviewView) { @@ -100,7 +114,7 @@ class JudgeViewProvider implements vscode.WebviewViewProvider { } case 'create-local-problem': { - runTestCases(); + createLocalProblem(); break; } @@ -169,7 +183,9 @@ class JudgeViewProvider implements vscode.WebviewViewProvider { if ( message.command === 'new-problem' && message.problem !== undefined && - getAutoShowJudgePref() + getAutoShowJudgePref() && + vscode.window.activeTextEditor?.document.fileName === + message.problem.srcPath ) { this.focus(); } @@ -241,10 +257,20 @@ class JudgeViewProvider implements vscode.WebviewViewProvider { ? globalThis.remoteMessage.trim() : ' '; + const nonce = getNonce(); + const html = `
+ @@ -257,7 +283,7 @@ class JudgeViewProvider implements vscode.WebviewViewProvider { >report the bug on GitHub. - - + `; diff --git a/src/webview/editorChange.ts b/src/webview/editorChange.ts index 7fbaf13..fa03986 100644 --- a/src/webview/editorChange.ts +++ b/src/webview/editorChange.ts @@ -1,10 +1,10 @@ import * as vscode from 'vscode'; -import { getProbSaveLocation } from '../parser'; +import { findProbPath } from '../parser'; import { existsSync, readFileSync } from 'fs'; import { Problem } from '../types'; import { getJudgeViewProvider } from '../extension'; import { getProblemForDocument } from '../utils'; -import { getAutoShowJudgePref } from '../preferences'; +// import { getAutoShowJudgePref } from '../preferences'; import { setOnlineJudgeEnv } from '../compiler'; /** @@ -33,8 +33,14 @@ export const editorChanged = async (e: vscode.TextEditor | undefined) => { setOnlineJudgeEnv(false); // reset the non-debug mode set in webview. + const openedPath = e.document.fileName; const problem = getProblemForDocument(e.document); + // Abort if user switched editors during loading + if (vscode.window.activeTextEditor?.document.fileName !== openedPath) { + return; + } + if (problem === undefined) { getJudgeViewProvider().extensionToJudgeViewMessage({ command: 'new-problem', @@ -43,13 +49,6 @@ export const editorChanged = async (e: vscode.TextEditor | undefined) => { return; } - if ( - getAutoShowJudgePref() && - getJudgeViewProvider().isViewUninitialized() - ) { - vscode.commands.executeCommand('cph.judgeView.focus'); - } - globalThis.logger.log('Sent problem @', Date.now()); getJudgeViewProvider().extensionToJudgeViewMessage({ command: 'new-problem', @@ -60,13 +59,14 @@ export const editorChanged = async (e: vscode.TextEditor | undefined) => { export const editorClosed = (e: vscode.TextDocument) => { globalThis.logger.log('Closed editor:', e.uri.fsPath); const srcPath = e.uri.fsPath; - const probPath = getProbSaveLocation(srcPath); + const probPath = findProbPath(srcPath); - if (!existsSync(probPath)) { + if (!probPath || !existsSync(probPath)) { return; } const problem: Problem = JSON.parse(readFileSync(probPath).toString()); + problem.srcPath = srcPath; if (getJudgeViewProvider().problemPath === problem.srcPath) { getJudgeViewProvider().extensionToJudgeViewMessage({ diff --git a/src/webview/frontend/App.tsx b/src/webview/frontend/App.tsx index e88f0fc..9e2b282 100644 --- a/src/webview/frontend/App.tsx +++ b/src/webview/frontend/App.tsx @@ -101,6 +101,7 @@ function Judge(props: { const total = cases.length; useEffect(() => { + if (!window.showLiveUserCount) return; const updateLiveUserCount = (): void => { getLiveUserCount().then((count) => setLiveUserCount(count)); }; diff --git a/src/webview/frontend/CaseView.tsx b/src/webview/frontend/CaseView.tsx index 6c05539..c9903bb 100644 --- a/src/webview/frontend/CaseView.tsx +++ b/src/webview/frontend/CaseView.tsx @@ -1,7 +1,7 @@ import { Case, VSToWebViewMessage } from '../../types'; import { useState, createRef, useEffect } from 'react'; -import TextareaAutosize from 'react-textarea-autosize'; import React from 'react'; +import { LinedTextarea } from './Lined'; export default function CaseView(props: { num: number; @@ -22,6 +22,39 @@ export default function CaseView(props: { props.case.result?.pass === true, ); const inputBox = createRef