From 32c1700da66bf8462b6d602341f2d8a4e9962d33 Mon Sep 17 00:00:00 2001 From: Ruslan Bel'kov Date: Mon, 8 Sep 2025 22:28:43 +0300 Subject: [PATCH 01/14] Allow to create folder and add .cph and code files to it --- package.json | 2 +- src/companion.ts | 18 ++++++++++++++-- src/parser.ts | 43 +++++++++++++++++++++++++++++-------- src/preferences.ts | 32 ++++++++++++++++++--------- src/utils.ts | 1 + src/webview/editorChange.ts | 1 + 6 files changed, 75 insertions(+), 22 deletions(-) diff --git a/package.json b/package.json index 83e53cb..6ce9bda 100644 --- a/package.json +++ b/package.json @@ -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. Leave empty to save in the workspace root for sources and alongside sources in .cph for metadata." }, "cph.general.timeOut": { "type": "number", diff --git a/src/companion.ts b/src/companion.ts index 0e75aba..af7455b 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,12 @@ const handleNewProblem = async (problem: Problem) => { problem.name = splitUrl[splitUrl.length - 1]; } const problemFileName = getProblemFileName(problem, extn); - const srcPath = path.join(folder, problemFileName); + const configuredSaveDir = getSaveLocationPref(); + const targetDir = + configuredSaveDir && configuredSaveDir !== '' + ? configuredSaveDir + : folder; + const srcPath = path.join(targetDir, problemFileName); // Add fields absent in competitive companion. problem.srcPath = srcPath; @@ -230,6 +236,14 @@ 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, ''); } diff --git a/src/parser.ts b/src/parser.ts index 10c6df2..e800ad5 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -12,7 +12,6 @@ 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 @@ -20,11 +19,8 @@ export const getProbSaveLocation = (srcPath: string): string => { .update(srcPath) .digest('hex') .substr(0); - const baseProbName = `.${srcFileName}_${hash}.prob`; + const baseProbName = `${srcFileName}_${hash}.prob`; const cphFolder = path.join(srcFolder, '.cph'); - if (savePreference && savePreference !== '') { - return path.join(savePreference, baseProbName); - } return path.join(cphFolder, baseProbName); }; @@ -34,9 +30,29 @@ export const getProblem = (srcPath: string): Problem | null => { let problem: string; try { problem = fs.readFileSync(probPath).toString(); - return JSON.parse(problem); + const parsed: Problem = JSON.parse(problem); + parsed.srcPath = srcPath; + return parsed; } catch (err) { - return null; + // Fallback to legacy .prob path (leading dot in filename) + try { + const srcFileName = path.basename(srcPath); + const srcFolder = path.dirname(srcPath); + const hash = crypto + .createHash('md5') + .update(srcPath) + .digest('hex') + .substr(0); + const baseProbNameLegacy = `.${srcFileName}_${hash}.prob`; + const cphFolder = path.join(srcFolder, '.cph'); + const legacyProbPath = path.join(cphFolder, baseProbNameLegacy); + problem = fs.readFileSync(legacyProbPath).toString(); + const parsed: Problem = JSON.parse(problem); + parsed.srcPath = srcPath; + return parsed; + } catch (err2) { + return null; + } } }; @@ -45,14 +61,23 @@ 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, + srcPath: path.basename(srcPath), + }; + fs.writeFileSync(probPath, JSON.stringify(problemToSave)); } catch (err) { throw new Error(err as string); } diff --git a/src/preferences.ts b/src/preferences.ts index cb4acc4..476d020 100644 --- a/src/preferences.ts +++ b/src/preferences.ts @@ -24,20 +24,32 @@ 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 { + 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/utils.ts b/src/utils.ts index 3fa1b36..6cf03a0 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -248,5 +248,6 @@ export const getProblemForDocument = ( return undefined; } const problem: Problem = JSON.parse(readFileSync(probPath).toString()); + problem.srcPath = srcPath; return problem; }; diff --git a/src/webview/editorChange.ts b/src/webview/editorChange.ts index 7fbaf13..1628522 100644 --- a/src/webview/editorChange.ts +++ b/src/webview/editorChange.ts @@ -67,6 +67,7 @@ export const editorClosed = (e: vscode.TextDocument) => { } const problem: Problem = JSON.parse(readFileSync(probPath).toString()); + problem.srcPath = srcPath; if (getJudgeViewProvider().problemPath === problem.srcPath) { getJudgeViewProvider().extensionToJudgeViewMessage({ From 873d8e406dea8e83b6b59c48d64e5db814174681 Mon Sep 17 00:00:00 2001 From: Ruslan Bel'kov Date: Tue, 9 Sep 2025 00:58:33 +0300 Subject: [PATCH 02/14] Prioritize finding existing .prob file that points to that code file; Search for .cph folder from code file parent folder to workspace; Open CPH Judge tab only if .prob file is associated; Ctrl + Alt + B will open CPH Judge but will not create a .prob file until second invocation --- src/companion.ts | 12 +++- src/parser.ts | 114 ++++++++++++++++++++++++++---------- src/runTestCases.ts | 20 ++++++- src/utils.ts | 8 +-- src/webview/JudgeView.ts | 12 +++- src/webview/editorChange.ts | 19 +++--- 6 files changed, 132 insertions(+), 53 deletions(-) diff --git a/src/companion.ts b/src/companion.ts index af7455b..e224278 100644 --- a/src/companion.ts +++ b/src/companion.ts @@ -248,7 +248,17 @@ const handleNewProblem = async (problem: Problem) => { 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/parser.ts b/src/parser.ts index e800ad5..fcbc353 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 @@ -14,45 +14,94 @@ import crypto from 'crypto'; export const getProbSaveLocation = (srcPath: string): string => { 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'); 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) + while (true) { + ancestors.push(currentDir); + if ( + path.resolve(currentDir) === path.resolve(workspaceRoot) || + path.dirname(currentDir) === currentDir + ) { + break; + } + 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 = [ + ...files.filter((f) => f.startsWith(srcFileName)), + ...files.filter((f) => !f.startsWith(srcFileName)), + ]; + + for (const file of prioritized) { + 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); + } + if (path.normalize(resolvedRecorded) === path.normalize(srcPath)) { + 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(); - const parsed: Problem = JSON.parse(problem); + const content = fs.readFileSync(probPath).toString(); + const parsed: Problem = JSON.parse(content); parsed.srcPath = srcPath; return parsed; - } catch (err) { - // Fallback to legacy .prob path (leading dot in filename) - try { - const srcFileName = path.basename(srcPath); - const srcFolder = path.dirname(srcPath); - const hash = crypto - .createHash('md5') - .update(srcPath) - .digest('hex') - .substr(0); - const baseProbNameLegacy = `.${srcFileName}_${hash}.prob`; - const cphFolder = path.join(srcFolder, '.cph'); - const legacyProbPath = path.join(cphFolder, baseProbNameLegacy); - problem = fs.readFileSync(legacyProbPath).toString(); - const parsed: Problem = JSON.parse(problem); - parsed.srcPath = srcPath; - return parsed; - } catch (err2) { - return null; - } + } catch (_e) { + return null; } }; @@ -75,7 +124,8 @@ export const saveProblem = (srcPath: string, problem: Problem) => { try { const problemToSave: Problem = { ...problem, - srcPath: path.basename(srcPath), + // Store path relative to parent of .cph folder when possible + srcPath: path.relative(path.dirname(probDir), srcPath), }; fs.writeFileSync(probPath, JSON.stringify(problemToSave)); } catch (err) { 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 6cf03a0..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,8 +243,8 @@ 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()); diff --git a/src/webview/JudgeView.ts b/src/webview/JudgeView.ts index d06a5b9..7310467 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, @@ -26,6 +26,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 +104,7 @@ class JudgeViewProvider implements vscode.WebviewViewProvider { } case 'create-local-problem': { - runTestCases(); + createLocalProblem(); break; } @@ -169,7 +173,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(); } diff --git a/src/webview/editorChange.ts b/src/webview/editorChange.ts index 1628522..8df5b87 100644 --- a/src/webview/editorChange.ts +++ b/src/webview/editorChange.ts @@ -1,5 +1,5 @@ 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'; @@ -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,9 +59,9 @@ 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; } From c2738c227d16de7087e574fbd286e71996cdb202 Mon Sep 17 00:00:00 2001 From: Ruslan Bel'kov Date: Tue, 9 Sep 2025 01:04:28 +0300 Subject: [PATCH 03/14] bumk v2078.0.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index f1a8d6e..2f962be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "competitive-programming-helper", - "version": "2077.0.0", + "version": "2078.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "competitive-programming-helper", - "version": "2077.0.0", + "version": "2078.0.0", "license": "GPL-3.0-or-later", "dependencies": { "@vscode/extension-telemetry": "^0.9.0", diff --git a/package.json b/package.json index 6ce9bda..244ebfd 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": "2078.0.0", "engines": { "vscode": "^1.52.0" }, From 69c6ee8fdcf08e81083fa15231b691c4c36de00b Mon Sep 17 00:00:00 2001 From: Ruslan Bel'kov Date: Tue, 9 Sep 2025 01:53:38 +0300 Subject: [PATCH 04/14] Allow ${group} syntax in saveDir; Prettify .prob files --- .vscode/settings.json | 12 +++++++++++- package.json | 2 +- src/companion.ts | 7 ++++++- src/compiler.ts | 2 +- src/parser.ts | 2 +- src/preferences.ts | 7 +++++-- 6 files changed, 25 insertions(+), 7 deletions(-) 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.json b/package.json index 244ebfd..c4d3ad0 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ "cph.general.saveLocation": { "type": "string", "default": "", - "description": "Directory where new problem source files and their .prob metadata will be saved. Leave empty to save in the workspace root for sources and alongside sources in .cph for metadata." + "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", diff --git a/src/companion.ts b/src/companion.ts index e224278..097979f 100644 --- a/src/companion.ts +++ b/src/companion.ts @@ -222,7 +222,12 @@ const handleNewProblem = async (problem: Problem) => { problem.name = splitUrl[splitUrl.length - 1]; } const problemFileName = getProblemFileName(problem, extn); - const configuredSaveDir = getSaveLocationPref(); + 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 diff --git a/src/compiler.ts b/src/compiler.ts index 811d822..cbf4530 100644 --- a/src/compiler.ts +++ b/src/compiler.ts @@ -51,7 +51,7 @@ 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 fcbc353..b17e515 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -127,7 +127,7 @@ export const saveProblem = (srcPath: string, 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)); + 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 476d020..c33e89a 100644 --- a/src/preferences.ts +++ b/src/preferences.ts @@ -40,8 +40,11 @@ export const getSaveLocationPref = (): string => { } try { - if (!fs.existsSync(resolved)) { - fs.mkdirSync(resolved, { recursive: true }); + // 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) { From 987e9dca08a42b3d06f8e677a2d3af900dff88c3 Mon Sep 17 00:00:00 2001 From: Ruslan Bel'kov Date: Tue, 9 Sep 2025 01:58:42 +0300 Subject: [PATCH 05/14] bump v2079.0.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2f962be..e7d4520 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "competitive-programming-helper", - "version": "2078.0.0", + "version": "2079.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "competitive-programming-helper", - "version": "2078.0.0", + "version": "2079.0.0", "license": "GPL-3.0-or-later", "dependencies": { "@vscode/extension-telemetry": "^0.9.0", diff --git a/package.json b/package.json index c4d3ad0..1a22d12 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "license": "GPL-3.0-or-later", "icon": "icon.png", "publisher": "DivyanshuAgrawal", - "version": "2078.0.0", + "version": "2079.0.0", "engines": { "vscode": "^1.52.0" }, From d8dc60943a0edb82ab71aa7d98e5227f1c4f4d5b Mon Sep 17 00:00:00 2001 From: Ruslan Bel'kov Date: Tue, 9 Sep 2025 02:21:42 +0300 Subject: [PATCH 06/14] Ignore leading dot in .prob file in search sorting --- src/parser.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/parser.ts b/src/parser.ts index b17e515..6e4be13 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -55,12 +55,20 @@ export const findProbPath = (srcPath: string): string | null => { continue; } // Prioritize files that start with the source filename - const prioritized = [ - ...files.filter((f) => f.startsWith(srcFileName)), - ...files.filter((f) => !f.startsWith(srcFileName)), - ]; + 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 prioritized) { + for (const file of all) { const fullProbPath = path.join(cphFolder, file); try { const content = fs.readFileSync(fullProbPath).toString(); From 55f14159280334b13a597b7b7426f01573953a2f Mon Sep 17 00:00:00 2001 From: Ruslan Bel'kov Date: Tue, 9 Sep 2025 02:54:48 +0300 Subject: [PATCH 07/14] Set retainWebviewContext to true by default to speed up loading; Do not request use count if disabled --- package.json | 2 +- src/webview/frontend/App.tsx | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 1a22d12..8277bdb 100644 --- a/package.json +++ b/package.json @@ -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/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)); }; From bd11c2a150f821108d110b98ff191c831edaf010 Mon Sep 17 00:00:00 2001 From: Ruslan Bel'kov Date: Tue, 9 Sep 2025 03:06:37 +0300 Subject: [PATCH 08/14] Setup Content Security Policy --- src/webview/JudgeView.ts | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/webview/JudgeView.ts b/src/webview/JudgeView.ts index 7310467..9c8637f 100644 --- a/src/webview/JudgeView.ts +++ b/src/webview/JudgeView.ts @@ -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'; @@ -247,10 +257,20 @@ class JudgeViewProvider implements vscode.WebviewViewProvider { ? globalThis.remoteMessage.trim() : ' '; + const nonce = getNonce(); + const html = ` + @@ -263,7 +283,7 @@ class JudgeViewProvider implements vscode.WebviewViewProvider { >report the bug on GitHub. - - + `; From a5430aaedc76fc064a200d24a6751ca21f8b53ce Mon Sep 17 00:00:00 2001 From: Ruslan Bel'kov Date: Tue, 9 Sep 2025 03:10:09 +0300 Subject: [PATCH 09/14] Fulfill linter --- src/companion.ts | 5 ++++- src/compiler.ts | 6 +++++- src/parser.ts | 20 ++++++++++++-------- src/webview/editorChange.ts | 2 +- 4 files changed, 22 insertions(+), 11 deletions(-) diff --git a/src/companion.ts b/src/companion.ts index 097979f..38d8d08 100644 --- a/src/companion.ts +++ b/src/companion.ts @@ -226,7 +226,10 @@ const handleNewProblem = async (problem: Problem) => { if (configuredSaveDir && configuredSaveDir.includes('${group}')) { const groupKey = (problem.group || '').trim(); const replacement = groupKey === '' ? '' : groupKey; - configuredSaveDir = configuredSaveDir.replace(/\$\{group\}/g, replacement); + configuredSaveDir = configuredSaveDir.replace( + /\$\{group\}/g, + replacement, + ); } const targetDir = configuredSaveDir && configuredSaveDir !== '' diff --git a/src/compiler.ts b/src/compiler.ts index cbf4530..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 !== '' && !savePreference.includes('${')) { + 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 6e4be13..7fc2949 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -27,20 +27,21 @@ export const findProbPath = (srcPath: string): string | null => { const workspaceFolder = vscode.workspace.getWorkspaceFolder( vscode.Uri.file(srcPath), ); - const workspaceRoot = workspaceFolder?.uri.fsPath ?? path.parse(srcFolder).root; + const workspaceRoot = + workspaceFolder?.uri.fsPath ?? path.parse(srcFolder).root; const ancestors: string[] = []; let currentDir = srcFolder; // Collect ancestor directories up to workspace root (inclusive) - while (true) { + let reachedRoot = false; + while (!reachedRoot) { ancestors.push(currentDir); - if ( + reachedRoot = path.resolve(currentDir) === path.resolve(workspaceRoot) || - path.dirname(currentDir) === currentDir - ) { - break; + path.dirname(currentDir) === currentDir; + if (!reachedRoot) { + currentDir = path.dirname(currentDir); } - currentDir = path.dirname(currentDir); } for (const dir of ancestors) { @@ -84,7 +85,10 @@ export const findProbPath = (srcPath: string): string | null => { } else { resolvedRecorded = path.resolve(parentOfCph, recorded); } - if (path.normalize(resolvedRecorded) === path.normalize(srcPath)) { + const samePath = + path.normalize(resolvedRecorded) === + path.normalize(srcPath); + if (samePath) { return fullProbPath; } } catch (_e) { diff --git a/src/webview/editorChange.ts b/src/webview/editorChange.ts index 8df5b87..fa03986 100644 --- a/src/webview/editorChange.ts +++ b/src/webview/editorChange.ts @@ -4,7 +4,7 @@ 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'; /** From 94c92a6202f7fca8fe6a92b1fe1bfca03c3ab59c Mon Sep 17 00:00:00 2001 From: Ruslan Bel'kov Date: Tue, 9 Sep 2025 03:11:02 +0300 Subject: [PATCH 10/14] bump v2080.0.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index e7d4520..132f254 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "competitive-programming-helper", - "version": "2079.0.0", + "version": "2080.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "competitive-programming-helper", - "version": "2079.0.0", + "version": "2080.0.0", "license": "GPL-3.0-or-later", "dependencies": { "@vscode/extension-telemetry": "^0.9.0", diff --git a/package.json b/package.json index 8277bdb..2262d2d 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "license": "GPL-3.0-or-later", "icon": "icon.png", "publisher": "DivyanshuAgrawal", - "version": "2079.0.0", + "version": "2080.0.0", "engines": { "vscode": "^1.52.0" }, From 4b8cad483af5571b620f26ed54c4667886450bda Mon Sep 17 00:00:00 2001 From: Ruslan Bel'kov Date: Sat, 13 Sep 2025 22:51:27 +0300 Subject: [PATCH 11/14] Highlight word difference in red and green in output --- src/webview/frontend/CaseView.tsx | 54 ++++++++++++++++++++++++++++--- src/webview/frontend/app.css | 39 +++++++++++++++------- 2 files changed, 76 insertions(+), 17 deletions(-) diff --git a/src/webview/frontend/CaseView.tsx b/src/webview/frontend/CaseView.tsx index 6c05539..b3ab6c0 100644 --- a/src/webview/frontend/CaseView.tsx +++ b/src/webview/frontend/CaseView.tsx @@ -114,6 +114,49 @@ export default function CaseView(props: { const caseClassName = 'case ' + (running ? 'running' : passFailText); const timeText = result?.timeOut ? 'Timed Out' : result?.time + 'ms'; + const renderHighlighted = ( + actual: string, + expected: string, + ): React.ReactNode[] => { + const nodes: React.ReactNode[] = []; + const tokenRegex = /\S+/gu; + const actualLines = actual.split('\n'); + const expectedLines = expected.split('\n'); + for (let lineIdx = 0; lineIdx < actualLines.length; lineIdx++) { + const aLine = actualLines[lineIdx]; + const eLine = expectedLines[lineIdx] ?? ''; + const expectedTokens = eLine.match(tokenRegex) || []; + const actualTokens = aLine.match(tokenRegex) || []; + const countMismatch = actualTokens.length !== expectedTokens.length; + let lastIndex = 0; + let tokenIndex = 0; + for (const match of aLine.matchAll(tokenRegex)) { + const start = match.index ?? 0; + const end = start + match[0].length; + if (start > lastIndex) nodes.push(aLine.slice(lastIndex, start)); + const token = match[0]; + const expectedToken = expectedTokens[tokenIndex]; + const ok = + !countMismatch && + expectedToken !== undefined && + expectedToken === token; + nodes.push( + + {token} + , + ); + lastIndex = end; + tokenIndex++; + } + if (lastIndex < aLine.length) nodes.push(aLine.slice(lastIndex)); + if (lineIdx < actualLines.length - 1) nodes.push('\n'); + } + return nodes; + }; + return (
@@ -239,11 +282,12 @@ export default function CaseView(props: { Set
<> - +
+ {renderHighlighted( + trunctateStdout(resultText), + output, + )} +
)} diff --git a/src/webview/frontend/app.css b/src/webview/frontend/app.css index a9326fb..80e05a1 100644 --- a/src/webview/frontend/app.css +++ b/src/webview/frontend/app.css @@ -17,7 +17,7 @@ scrollbar-width: thin; } -.selectable { +.selectable * { -webkit-touch-callout: text; /* iOS Safari */ -webkit-user-select: text; @@ -100,11 +100,9 @@ body { } .case { - background: linear-gradient( - to right, - rgba(0, 0, 0, 0.1), - rgba(87, 87, 87, 0.2) - ); + background: linear-gradient(to right, + rgba(0, 0, 0, 0.1), + rgba(87, 87, 87, 0.2)); border-bottom: 1px solid rgba(78, 78, 78, 0.3); border-top: 1px solid rgba(22, 22, 22, 0.3); padding: 8px; @@ -194,6 +192,17 @@ textarea:active { border: 1px solid #3393cc; } +.pre-block { + white-space: pre-wrap; + background: var(--input-background); + color: bisque; + width: 100%; + max-height: 250px !important; + overflow-y: auto; + border: 1px solid rgba(100, 100, 100, 0.2); + padding: 4px; +} + .case-metadata { display: flex; } @@ -348,11 +357,9 @@ pre { } body.vscode-light .case { - background: linear-gradient( - to right, - rgba(255, 255, 255, 0.9), - rgba(220, 220, 220, 0.9) - ); + background: linear-gradient(to right, + rgba(255, 255, 255, 0.9), + rgba(220, 220, 220, 0.9)); box-shadow: none; border-left: 5px solid rgba(0, 0, 0, 0.3); margin-bottom: 4px; @@ -524,6 +531,14 @@ body.vscode-light .notification { border: 1px solid rgb(92, 95, 97); } +.token-ok { + color: rgb(64, 175, 64); +} + +.token-bad { + color: rgb(206, 40, 95); +} + /* @keyframes runs { 50% { border-color: orange; @@ -602,4 +617,4 @@ hr { .fallback { display: block !important; } -} +} \ No newline at end of file From fdd4580224a47740cb8b2c3ef6d1d02edf6b8863 Mon Sep 17 00:00:00 2001 From: Ruslan Bel'kov Date: Sun, 14 Sep 2025 01:16:01 +0300 Subject: [PATCH 12/14] Add line number column --- src/webview/frontend/CaseView.tsx | 69 ++++++++-- src/webview/frontend/Lined.tsx | 208 ++++++++++++++++++++++++++++++ src/webview/frontend/app.css | 78 +++++++++++ 3 files changed, 345 insertions(+), 10 deletions(-) create mode 100644 src/webview/frontend/Lined.tsx diff --git a/src/webview/frontend/CaseView.tsx b/src/webview/frontend/CaseView.tsx index b3ab6c0..42db6ef 100644 --- a/src/webview/frontend/CaseView.tsx +++ b/src/webview/frontend/CaseView.tsx @@ -2,6 +2,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 +23,32 @@ export default function CaseView(props: { props.case.result?.pass === true, ); const inputBox = createRef(); + const [externalHoverLine, setExternalHoverLine] = useState(null); + + const inputGutterLabel = (lineIdx: number, allLines: string[]): string => { + const first = (allLines[0] ?? '').trim(); + const t = parseInt(first, 10); + if (!Number.isFinite(t) || t <= 0) return String(lineIdx + 1); + const nonEmpty = allLines.filter((l, idx) => idx === 0 ? true : l.trim().length > 0); + const totalNonEmpty = nonEmpty.length; + // Try k = 1..10 to find a plausible grouping + for (let k = 1; k <= 3; k++) { + if (totalNonEmpty === t * k + 1) { + if (lineIdx === 0) return 'T'; + return String(lineIdx); + } + } + return String(lineIdx + 1); + }; + + const hasTHeader = (allLines: string[]): boolean => { + const first = (allLines[0] ?? '').trim(); + const t = parseInt(first, 10); + if (!Number.isFinite(t) || t <= 0) return false; + const totalNonEmpty = allLines.filter((l, idx) => idx === 0 ? true : l.trim().length > 0).length; + for (let k = 1; k <= 3; k++) if (totalNonEmpty === t * k + 1) return true; + return false; + }; useEffect(() => { if (props.doFocus) { @@ -234,12 +261,30 @@ export default function CaseView(props: { > Copy - { + const idx = externalHoverLine; + if (idx == null) return null; + const lines = input.split('\n'); + const withHeader = hasTHeader(lines); + const contentCount = withHeader ? Math.max(0, lines.length - 1) : lines.length; + if (idx >= contentCount) return null; + return withHeader ? idx + 1 : idx; + })()} + onHoverLineChange={(idx: number | null) => { + if (!hasTHeader(input.split('\n'))) { + setExternalHoverLine(idx); + } else { + if (idx == null || idx === 0) setExternalHoverLine(null); + else setExternalHoverLine((idx ?? 1) - 1); + } + }} + gutterLabelForLine={inputGutterLabel} />
@@ -253,10 +298,12 @@ export default function CaseView(props: { > Copy
- {props.case.result != null && ( @@ -282,19 +329,21 @@ export default function CaseView(props: { Set <> -
- {renderHighlighted( - trunctateStdout(resultText), - output, - )} -
+ )} {stderror && stderror.length > 0 && ( <> Standard Error: - ) => void; + className?: string; + autoFocus?: boolean; + inputRef?: React.RefObject; + readOnly?: boolean; + externalHoverLine?: number | null; + onHoverLineChange?: (line: number | null) => void; + diffAgainst?: string; + gutterLabelForLine?: (lineIndex: number, allLines: string[]) => string; + hoverHighlightForLine?: ( + lineIndex: number, + allLines: string[], + ) => { startLine: number; endLineExclusive: number } | null; +}; + +export function LinedTextarea(props: LinedTextareaProps) { + const { value, onChange, className, autoFocus, inputRef, readOnly, externalHoverLine, onHoverLineChange, diffAgainst, gutterLabelForLine, hoverHighlightForLine } = props; + const internalRef = useRef(null); + const gutterRef = useRef(null); + const overlayInnerRef = useRef(null); + const gutterWheelHandlerRef = useRef<(e: WheelEvent) => void>(); + const [hoveredLine, setHoveredLine] = useState(null); + const [lineHeightPx, setLineHeightPx] = useState(18); + const [overlayFontFamily, setOverlayFontFamily] = useState(undefined); + const [overlayFontSize, setOverlayFontSize] = useState(undefined); + const [overlayPadding, setOverlayPadding] = useState<{ top?: string; right?: string; bottom?: string; left?: string }>({}); + + const lines = useMemo(() => Math.max(1, value.split('\n').length), [value]); + + useEffect(() => { + const ta = (inputRef?.current ?? internalRef.current); + const gutter = gutterRef.current; + if (!ta || !gutter) return; + const onScroll = () => { + if (gutter) gutter.scrollTop = ta.scrollTop; + if (overlayInnerRef.current) { + overlayInnerRef.current.style.transform = `translateY(${-ta.scrollTop}px)`; + } + }; + ta.addEventListener('scroll', onScroll); + // Forward wheel events from gutter to textarea for unified scrolling + const onWheel = (e: WheelEvent) => { + ta.scrollTop += e.deltaY; + }; + gutter.addEventListener('wheel', onWheel); + return () => { + ta.removeEventListener('scroll', onScroll); + gutter.removeEventListener('wheel', onWheel as EventListener); + }; + }, [inputRef]); + + useEffect(() => { + const ta = (inputRef?.current ?? internalRef.current); + if (!ta) return; + const computed = window.getComputedStyle(ta); + const lh = parseFloat(computed.lineHeight); + if (!Number.isNaN(lh)) setLineHeightPx(lh); + setOverlayFontFamily(computed.fontFamily); + setOverlayFontSize(computed.fontSize); + setOverlayPadding({ + top: computed.paddingTop, + right: computed.paddingRight, + bottom: computed.paddingBottom, + left: computed.paddingLeft, + }); + }, [inputRef]); + + const handleMouseMove = (e: React.MouseEvent) => { + const ta = (inputRef?.current ?? internalRef.current); + if (!ta) return; + const rect = ta.getBoundingClientRect(); + const localY = e.clientY - rect.top + ta.scrollTop; + const idx = Math.max(0, Math.min(lines - 1, Math.floor(localY / lineHeightPx))); + setHoveredLine(idx); + onHoverLineChange?.(idx); + }; + + const handleMouseLeave = () => { + setHoveredLine(null); + onHoverLineChange?.(null); + }; + + type TAStyle = React.ComponentProps['style']; + const displayHoverLine = externalHoverLine ?? hoveredLine; + const allLines = useMemo(() => value.split('\n'), [value]); + const hoverRange = useMemo(() => { + if (displayHoverLine == null) return null; + const custom = hoverHighlightForLine?.(displayHoverLine, allLines); + if (custom) return custom; + return { startLine: displayHoverLine, endLineExclusive: displayHoverLine + 1 }; + }, [displayHoverLine, hoverHighlightForLine, allLines]); + const minHeightPx = useMemo(() => lines * lineHeightPx, [lines, lineHeightPx]); + + const highlightedNodes = useMemo(() => { + if (diffAgainst == null) return null; + const nodes: React.ReactNode[] = []; + const tokenRegex = /\S+/gu; + const actualLines = value.split('\n'); + const expectedLines = diffAgainst.split('\n'); + for (let lineIdx = 0; lineIdx < actualLines.length; lineIdx++) { + const aLine = actualLines[lineIdx]; + const eLine = expectedLines[lineIdx] ?? ''; + const expectedTokens = eLine.match(tokenRegex) || []; + const actualTokens = aLine.match(tokenRegex) || []; + const countMismatch = actualTokens.length !== expectedTokens.length; + let lastIndex = 0; + let tokenIndex = 0; + for (const match of aLine.matchAll(tokenRegex)) { + const start = match.index ?? 0; + const end = start + match[0].length; + if (start > lastIndex) nodes.push(aLine.slice(lastIndex, start)); + const token = match[0]; + const expectedToken = expectedTokens[tokenIndex]; + const ok = !countMismatch && expectedToken !== undefined && expectedToken === token; + nodes.push( + + {token} + , + ); + lastIndex = end; + tokenIndex++; + } + if (lastIndex < aLine.length) nodes.push(aLine.slice(lastIndex)); + if (lineIdx < actualLines.length - 1) nodes.push('\n'); + } + return nodes; + }, [value, diffAgainst]); + const bgStyle = useMemo(() => { + if (!hoverRange) return {} as TAStyle; + const top = hoverRange.startLine * lineHeightPx; + const bottom = hoverRange.endLineExclusive * lineHeightPx; + const from = `${Math.max(0, top)}px`; + const to = `${bottom}px`; + const color = 'rgba(200, 200, 200, 0.08)'; + return { + backgroundImage: `linear-gradient(to bottom, transparent ${from}, ${color} ${from}, ${color} ${to}, transparent ${to})`, + backgroundAttachment: 'local', + } as TAStyle; + }, [hoverRange, lineHeightPx]); + + return ( +
+ +
+ {diffAgainst != null && ( + + )} + +
+
+ ); +} diff --git a/src/webview/frontend/app.css b/src/webview/frontend/app.css index 80e05a1..d1190af 100644 --- a/src/webview/frontend/app.css +++ b/src/webview/frontend/app.css @@ -617,4 +617,82 @@ hr { .fallback { display: block !important; } +} + +/* Lined editor */ +.lined-container { + position: relative; + display: grid; + grid-template-columns: auto 1fr; + align-items: stretch; +} + +.lined-gutter { + background: rgba(0, 0, 0, 0.08); + border: 1px solid rgba(100, 100, 100, 0.12); + border-right: none; + overflow-y: hidden; + max-height: 250px !important; +} + +.lined-number { + padding: 0px 6px; + text-align: right; + font-family: Consolas, 'Ubuntu Mono', monospace; + color: rgba(255, 255, 255, 0.45); + box-sizing: content-box; +} + +.lined-content>.pre-block, +.lined-content>textarea { + border-left: none; +} + +/* Remove any inner padding inside lined textareas */ +.lined-content>textarea { + padding: 0 !important; +} + +.lined-container .lined-gutter+.lined-content>.pre-block, +.lined-container .lined-gutter+.lined-content>textarea { + border-left: none; +} + +.lined-number.is-hovered { + background: rgba(200, 200, 200, 0.08); +} + +body.vscode-light .lined-gutter { + background: rgba(0, 0, 0, 0.03); +} + +body.vscode-light .lined-number { + color: rgba(0, 0, 0, 0.45); +} + +body.vscode-light .lined-number.is-hovered { + background: rgba(0, 0, 0, 0.06); +} + +/* Overlay for diff rendering inside lined textareas */ +.lined-content { + position: relative; +} + +.lined-overlay { + position: absolute; + inset: 0; + pointer-events: none; + overflow: hidden; +} + +.lined-overlay-inner { + white-space: pre-wrap; + background: transparent; + color: inherit; + border: 0; + padding: 0; + max-height: none; + /* overlay should not clip vertically; base textarea limits height */ + overflow: visible; } \ No newline at end of file From e0c77a0be3a0e3a437f66dcdd6c1eddbccad2302 Mon Sep 17 00:00:00 2001 From: Ruslan Bel'kov Date: Sun, 14 Sep 2025 01:27:49 +0300 Subject: [PATCH 13/14] Fullfill linter --- src/webview/frontend/CaseView.tsx | 66 ++++++------------------ src/webview/frontend/Lined.tsx | 85 +++++++++++++++++++++++-------- 2 files changed, 81 insertions(+), 70 deletions(-) diff --git a/src/webview/frontend/CaseView.tsx b/src/webview/frontend/CaseView.tsx index 42db6ef..c9903bb 100644 --- a/src/webview/frontend/CaseView.tsx +++ b/src/webview/frontend/CaseView.tsx @@ -1,6 +1,5 @@ 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'; @@ -23,13 +22,17 @@ export default function CaseView(props: { props.case.result?.pass === true, ); const inputBox = createRef(); - const [externalHoverLine, setExternalHoverLine] = useState(null); + const [externalHoverLine, setExternalHoverLine] = useState( + null, + ); const inputGutterLabel = (lineIdx: number, allLines: string[]): string => { const first = (allLines[0] ?? '').trim(); const t = parseInt(first, 10); if (!Number.isFinite(t) || t <= 0) return String(lineIdx + 1); - const nonEmpty = allLines.filter((l, idx) => idx === 0 ? true : l.trim().length > 0); + const nonEmpty = allLines.filter((l, idx) => + idx === 0 ? true : l.trim().length > 0, + ); const totalNonEmpty = nonEmpty.length; // Try k = 1..10 to find a plausible grouping for (let k = 1; k <= 3; k++) { @@ -45,8 +48,11 @@ export default function CaseView(props: { const first = (allLines[0] ?? '').trim(); const t = parseInt(first, 10); if (!Number.isFinite(t) || t <= 0) return false; - const totalNonEmpty = allLines.filter((l, idx) => idx === 0 ? true : l.trim().length > 0).length; - for (let k = 1; k <= 3; k++) if (totalNonEmpty === t * k + 1) return true; + const totalNonEmpty = allLines.filter((l, idx) => + idx === 0 ? true : l.trim().length > 0, + ).length; + for (let k = 1; k <= 3; k++) + if (totalNonEmpty === t * k + 1) return true; return false; }; @@ -141,49 +147,6 @@ export default function CaseView(props: { const caseClassName = 'case ' + (running ? 'running' : passFailText); const timeText = result?.timeOut ? 'Timed Out' : result?.time + 'ms'; - const renderHighlighted = ( - actual: string, - expected: string, - ): React.ReactNode[] => { - const nodes: React.ReactNode[] = []; - const tokenRegex = /\S+/gu; - const actualLines = actual.split('\n'); - const expectedLines = expected.split('\n'); - for (let lineIdx = 0; lineIdx < actualLines.length; lineIdx++) { - const aLine = actualLines[lineIdx]; - const eLine = expectedLines[lineIdx] ?? ''; - const expectedTokens = eLine.match(tokenRegex) || []; - const actualTokens = aLine.match(tokenRegex) || []; - const countMismatch = actualTokens.length !== expectedTokens.length; - let lastIndex = 0; - let tokenIndex = 0; - for (const match of aLine.matchAll(tokenRegex)) { - const start = match.index ?? 0; - const end = start + match[0].length; - if (start > lastIndex) nodes.push(aLine.slice(lastIndex, start)); - const token = match[0]; - const expectedToken = expectedTokens[tokenIndex]; - const ok = - !countMismatch && - expectedToken !== undefined && - expectedToken === token; - nodes.push( - - {token} - , - ); - lastIndex = end; - tokenIndex++; - } - if (lastIndex < aLine.length) nodes.push(aLine.slice(lastIndex)); - if (lineIdx < actualLines.length - 1) nodes.push('\n'); - } - return nodes; - }; - return (
@@ -272,7 +235,9 @@ export default function CaseView(props: { if (idx == null) return null; const lines = input.split('\n'); const withHeader = hasTHeader(lines); - const contentCount = withHeader ? Math.max(0, lines.length - 1) : lines.length; + const contentCount = withHeader + ? Math.max(0, lines.length - 1) + : lines.length; if (idx >= contentCount) return null; return withHeader ? idx + 1 : idx; })()} @@ -280,7 +245,8 @@ export default function CaseView(props: { if (!hasTHeader(input.split('\n'))) { setExternalHoverLine(idx); } else { - if (idx == null || idx === 0) setExternalHoverLine(null); + if (idx == null || idx === 0) + setExternalHoverLine(null); else setExternalHoverLine((idx ?? 1) - 1); } }} diff --git a/src/webview/frontend/Lined.tsx b/src/webview/frontend/Lined.tsx index d22f7bd..b48de50 100644 --- a/src/webview/frontend/Lined.tsx +++ b/src/webview/frontend/Lined.tsx @@ -19,21 +19,42 @@ type LinedTextareaProps = { }; export function LinedTextarea(props: LinedTextareaProps) { - const { value, onChange, className, autoFocus, inputRef, readOnly, externalHoverLine, onHoverLineChange, diffAgainst, gutterLabelForLine, hoverHighlightForLine } = props; + const { + value, + onChange, + className, + autoFocus, + inputRef, + readOnly, + externalHoverLine, + onHoverLineChange, + diffAgainst, + gutterLabelForLine, + hoverHighlightForLine, + } = props; const internalRef = useRef(null); const gutterRef = useRef(null); const overlayInnerRef = useRef(null); - const gutterWheelHandlerRef = useRef<(e: WheelEvent) => void>(); + // const [hoveredLine, setHoveredLine] = useState(null); const [lineHeightPx, setLineHeightPx] = useState(18); - const [overlayFontFamily, setOverlayFontFamily] = useState(undefined); - const [overlayFontSize, setOverlayFontSize] = useState(undefined); - const [overlayPadding, setOverlayPadding] = useState<{ top?: string; right?: string; bottom?: string; left?: string }>({}); + const [overlayFontFamily, setOverlayFontFamily] = useState< + string | undefined + >(undefined); + const [overlayFontSize, setOverlayFontSize] = useState( + undefined, + ); + const [overlayPadding, setOverlayPadding] = useState<{ + top?: string; + right?: string; + bottom?: string; + left?: string; + }>({}); const lines = useMemo(() => Math.max(1, value.split('\n').length), [value]); useEffect(() => { - const ta = (inputRef?.current ?? internalRef.current); + const ta = inputRef?.current ?? internalRef.current; const gutter = gutterRef.current; if (!ta || !gutter) return; const onScroll = () => { @@ -55,7 +76,7 @@ export function LinedTextarea(props: LinedTextareaProps) { }, [inputRef]); useEffect(() => { - const ta = (inputRef?.current ?? internalRef.current); + const ta = inputRef?.current ?? internalRef.current; if (!ta) return; const computed = window.getComputedStyle(ta); const lh = parseFloat(computed.lineHeight); @@ -71,11 +92,14 @@ export function LinedTextarea(props: LinedTextareaProps) { }, [inputRef]); const handleMouseMove = (e: React.MouseEvent) => { - const ta = (inputRef?.current ?? internalRef.current); + const ta = inputRef?.current ?? internalRef.current; if (!ta) return; const rect = ta.getBoundingClientRect(); const localY = e.clientY - rect.top + ta.scrollTop; - const idx = Math.max(0, Math.min(lines - 1, Math.floor(localY / lineHeightPx))); + const idx = Math.max( + 0, + Math.min(lines - 1, Math.floor(localY / lineHeightPx)), + ); setHoveredLine(idx); onHoverLineChange?.(idx); }; @@ -92,9 +116,11 @@ export function LinedTextarea(props: LinedTextareaProps) { if (displayHoverLine == null) return null; const custom = hoverHighlightForLine?.(displayHoverLine, allLines); if (custom) return custom; - return { startLine: displayHoverLine, endLineExclusive: displayHoverLine + 1 }; + return { + startLine: displayHoverLine, + endLineExclusive: displayHoverLine + 1, + }; }, [displayHoverLine, hoverHighlightForLine, allLines]); - const minHeightPx = useMemo(() => lines * lineHeightPx, [lines, lineHeightPx]); const highlightedNodes = useMemo(() => { if (diffAgainst == null) return null; @@ -113,12 +139,19 @@ export function LinedTextarea(props: LinedTextareaProps) { for (const match of aLine.matchAll(tokenRegex)) { const start = match.index ?? 0; const end = start + match[0].length; - if (start > lastIndex) nodes.push(aLine.slice(lastIndex, start)); + if (start > lastIndex) + nodes.push(aLine.slice(lastIndex, start)); const token = match[0]; const expectedToken = expectedTokens[tokenIndex]; - const ok = !countMismatch && expectedToken !== undefined && expectedToken === token; + const ok = + !countMismatch && + expectedToken !== undefined && + expectedToken === token; nodes.push( - + {token} , ); @@ -152,14 +185,24 @@ export function LinedTextarea(props: LinedTextareaProps) { style={{ paddingTop: 0, paddingBottom: overlayPadding.bottom }} > {Array.from({ length: lines }, (_, i) => { - const inRange = hoverRange && i >= hoverRange.startLine && i < hoverRange.endLineExclusive; + const inRange = + hoverRange && + i >= hoverRange.startLine && + i < hoverRange.endLineExclusive; return (
- {gutterLabelForLine ? gutterLabelForLine(i, allLines) : (i + 1)} + {gutterLabelForLine + ? gutterLabelForLine(i, allLines) + : i + 1}
); })} @@ -196,9 +239,11 @@ export function LinedTextarea(props: LinedTextareaProps) { onMouseLeave={handleMouseLeave} style={{ ...(bgStyle as TAStyle), - backgroundColor: diffAgainst != null ? 'transparent' : undefined, + backgroundColor: + diffAgainst != null ? 'transparent' : undefined, color: diffAgainst != null ? 'transparent' : undefined, - caretColor: diffAgainst != null ? 'transparent' : undefined, + caretColor: + diffAgainst != null ? 'transparent' : undefined, overflowY: 'auto', }} /> From fd15a8bce0b2bd2f614c7b3ca26ca9196d9cc4e7 Mon Sep 17 00:00:00 2001 From: Ruslan Bel'kov Date: Sun, 14 Sep 2025 01:27:55 +0300 Subject: [PATCH 14/14] bump v2081.0.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 132f254..207417a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "competitive-programming-helper", - "version": "2080.0.0", + "version": "2081.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "competitive-programming-helper", - "version": "2080.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 2262d2d..0135027 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "license": "GPL-3.0-or-later", "icon": "icon.png", "publisher": "DivyanshuAgrawal", - "version": "2080.0.0", + "version": "2081.0.0", "engines": { "vscode": "^1.52.0" },