diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..f020a65 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,7 @@ +jest.config.js +tsconfig.js +.eslintrc.js +webpack.config.js +webpack.frontend.js +out +dist \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..eeb85a7 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,35 @@ +module.exports = { + env: { + browser: true, + es6: true, + }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:prettier/recommended', + 'plugin:react/recommended', + ], + globals: { + Atomics: 'readonly', + SharedArrayBuffer: 'readonly', + }, + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 2018, + sourceType: 'module', + }, + plugins: ['@typescript-eslint', 'prettier'], + rules: { + 'prettier/prettier': 'error', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/ban-ts-comment': 'off', + '@typescript-eslint/prefer-namespace-keyword': 'off', + '@typescript-eslint/no-namespace': 'off', + '@typescript-eslint/no-explicit-any': 'off', + }, + settings: { + react: { + version: 'detect', + }, + }, +}; diff --git a/.gitignore b/.gitignore index 705e366..69ba7b6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,126 @@ -node_modules -.vscode-test/ -*.vsix -test/ -frontend/ -dist/ -dev/ -vsc-extension-quickstart.md -.vscode \ No newline at end of file +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..779e350 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,8 @@ +{ + "printWidth": 80, + "tabWidth": 4, + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "proseWrap": "always" +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..0915202 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=733558 + // for the documentation about the extensions.json format + "recommendations": [ + "dbaeumer.vscode-eslint" + ] +} \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..6899e23 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,28 @@ +// A launch configuration that launches the extension inside a new window +// Use IntelliSense to learn about possible attributes. +// Hover to view descriptions of existing attributes. +// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run Extension", + "type": "extensionHost", + "request": "launch", + "runtimeExecutable": "${execPath}", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}" + ] + }, + { + "name": "Extension Tests", + "type": "extensionHost", + "request": "launch", + "runtimeExecutable": "${execPath}", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}", + "--extensionTestsPath=${workspaceFolder}/test/suite/index" + ] + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..4748804 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "eslint.enable": false +} \ No newline at end of file diff --git a/releases/competitive-programming-helper-4.0.0.vsix b/releases/competitive-programming-helper-4.0.0.vsix new file mode 100644 index 0000000..2030db9 Binary files /dev/null and b/releases/competitive-programming-helper-4.0.0.vsix differ diff --git a/src/webview/frontend/App.tsx b/src/webview/frontend/App.tsx new file mode 100644 index 0000000..ebe237a --- /dev/null +++ b/src/webview/frontend/App.tsx @@ -0,0 +1,269 @@ +import React, { useState, useEffect } from 'react'; +import ReactDOM from 'react-dom'; +import { + Problem, + WebviewToVSEvent, + TestCase, + Case, + VSToWebViewMessage, + ResultCommand, + RunningCommand, +} from '../../types'; +import CaseView from './CaseView'; +declare const acquireVsCodeApi: () => { + postMessage: (message: WebviewToVSEvent) => void; +}; +const vscodeApi = acquireVsCodeApi(); + +const getProblemFromDOM = (): Problem => { + console.log('Got problem from dom!'); + const element = document.getElementById('problem') as HTMLElement; + return JSON.parse(element.innerText); +}; + +const getCasesFromProblem = (problem: Problem): Case[] => { + console.log('Get cases from problem!'); + return problem.tests.map((testCase) => ({ + id: testCase.id, + result: null, + testcase: testCase, + })); +}; + +function App() { + const [problem, useProblem] = useState(() => getProblemFromDOM()); + const [cases, useCases] = useState(() => + getCasesFromProblem(getProblemFromDOM()), + ); + const [focusLast, useFocusLast] = useState(false); + 1; + const [forceRunning, useForceRunning] = useState(false); + + // Update problem if cases change. The only place where `useProblem` is + // allowed to ensure sync. + useEffect(() => { + const testCases: TestCase[] = cases.map((c) => c.testcase); + console.log(cases); + useProblem({ + ...problem, + tests: testCases, + }); + }, [cases]); + + useEffect(() => { + console.log('Adding event listeners'); + const fn = (event: any) => { + const data: VSToWebViewMessage = event.data; + console.log('Got event in web view', event.data); + switch (data.command) { + case 'run-single-result': { + handleRunSingleResult(data); + break; + } + case 'running': { + handleRunning(data); + break; + } + case 'run-all': { + runAll(); + break; + } + default: { + console.log('Invalid event', event.data); + } + } + }; + window.addEventListener('message', fn); + return () => { + console.log('Cleaned up event listeners'); + window.removeEventListener('message', fn); + }; + }, [problem, cases]); + + const handleRunSingleResult = (data: ResultCommand) => { + const idx = cases.findIndex( + (testCase) => testCase.id === data.result.id, + ); + if (idx === -1) { + console.error('Invalid single result', problem, data); + return; + } + const newCases = cases.slice(); + newCases[idx].result = data.result; + useCases(newCases); + }; + + const handleRunning = (data: RunningCommand) => { + useForceRunning(data.id); + }; + + const rerun = (id: number, input: string, output: string) => { + const idx = problem.tests.findIndex((testCase) => testCase.id === id); + + if (idx === -1) { + console.log('No id in problem tests', problem, id); + return; + } + + problem.tests[idx].input = input; + problem.tests[idx].output = output; + + vscodeApi.postMessage({ + command: 'run-single-and-save', + problem, + id, + }); + }; + + // Remove a case. + const remove = (id: number) => { + const newCases = cases.filter((value) => value.id !== id); + useCases(newCases); + }; + + // Save problem if it changes. + useEffect(() => { + save(); + console.log('Saved', problem); + }, [problem]); + + // Create a new Case + const newCase = () => { + console.log(cases); + const id = Date.now(); + const testCase: TestCase = { + id, + input: '', + output: '', + }; + useCases([ + ...cases, + { + id, + result: null, + testcase: testCase, + }, + ]); + useFocusLast(true); + }; + + // Save the problem + const save = () => { + vscodeApi.postMessage({ + command: 'save', + problem, + }); + }; + + // Stop running executions. + const stop = () => { + vscodeApi.postMessage({ + command: 'kill-running', + problem, + }); + }; + + const runAll = () => { + console.log(problem); + vscodeApi.postMessage({ + command: 'run-all-and-save', + problem, + }); + }; + + const debounceFocusLast = () => { + setTimeout(() => { + useFocusLast(false); + }, 100); + }; + + const debounceForceRunning = () => { + setTimeout(() => { + useForceRunning(false); + }, 100); + }; + + const getRunningProp = (value: Case) => { + if (forceRunning === value.id) { + console.log('Forcing Running'); + debounceForceRunning(); + return forceRunning === value.id; + } + return false; + }; + + const updateCase = (id: number, input: string, output: string) => { + const newCases: Case[] = cases.map((testCase) => { + if (testCase.id === id) { + return { + id, + result: testCase.result, + testcase: { + id, + input, + output, + }, + }; + } else { + return testCase; + } + }); + useCases(newCases); + }; + + const views: JSX.Element[] = []; + cases.forEach((value, index) => { + if (focusLast && index === cases.length - 1) { + views.push( + , + ); + debounceFocusLast(); + } else { + views.push( + , + ); + } + }); + + return ( +
+
+

{problem.name}

+
+
{views}
+
+ + + + +
+
+ ); +} + +ReactDOM.render(, document.getElementById('app')); diff --git a/src/webview/frontend/CaseView.tsx b/src/webview/frontend/CaseView.tsx new file mode 100644 index 0000000..c4c746e --- /dev/null +++ b/src/webview/frontend/CaseView.tsx @@ -0,0 +1,186 @@ +import { Case } from '../../types'; +import { useState, createRef, useEffect } from 'react'; +import TextareaAutosize from 'react-autosize-textarea/lib'; +import React from 'react'; + +const reloadIcon = '↺'; +const deleteIcon = '⨯'; + +export default function CaseView(props: { + num: number; + case: Case; + rerun: (id: number, input: string, output: string) => void; + updateCase: (id: number, input: string, output: string) => void; + remove: (num: number) => void; + doFocus?: boolean; + forceRunning: boolean; +}) { + const { id, result } = props.case; + + const [input, useInput] = useState(props.case.testcase.input); + const [output, useOutput] = useState(props.case.testcase.output); + const [running, useRuning] = useState(false); + const [minimized, useMinimized] = useState( + props.case.result?.pass === true, + ); + const inputBox = createRef(); + + useEffect(() => { + if (props.doFocus) { + console.log('Scrolling', inputBox.current); + inputBox.current?.scrollIntoView({ behavior: 'smooth' }); + } + }, [props.doFocus]); + + useEffect(() => { + props.updateCase(props.case.id, input, output); + }, [input, output]); + + useEffect(() => { + if (props.forceRunning) { + console.log('Case was forced to run!'); + useRuning(true); + } + }, [props.forceRunning]); + + const handleInputChange = ( + event: React.ChangeEvent, + ) => { + useInput(event.target.value); + }; + + const handleOutputChange = ( + event: React.ChangeEvent, + ) => { + useOutput(event.target.value); + }; + + const rerun = () => { + useRuning(true); + props.rerun(id, input, output); + }; + + const expand = () => { + useMinimized(false); + }; + + const minimize = () => { + useMinimized(true); + }; + + useEffect(() => { + if (props.case.result !== null) { + useRuning(false); + props.case.result.pass ? useMinimized(true) : useMinimized(false); + } + }, [props.case.result]); + + useEffect(() => { + if (running === true) { + useMinimized(true); + } + }, [running]); + + let resultText; + // Handle several cases for result text + if (result?.signal) { + resultText = result?.signal; + } else if (result?.stdout) { + resultText = result.stdout.trim() || ' '; + } + if (!result) { + resultText = 'Run to show output'; + } + if (running) { + resultText = '...'; + } + const passFailText = result ? (result.pass ? 'passed' : 'failed') : ''; + const caseClassName = 'case ' + (running ? 'running' : passFailText); + const timeText = result?.timeOut ? 'Timed Out' : result?.time + 'ms'; + + return ( +
+
+
+
Testcase {props.num}
+ {running && Running} + {result && !running && ( +
+ + {result.pass ? 'Passed' : 'Failed'} + + {timeText} +
+ )} +
+
+
+ + + {minimized && ( + + )} + {!minimized && ( + + )} +
+
+
+ {!minimized && ( + <> + Input: + + Expected Output: + + Received Output: + + + )} +
+ ); +} diff --git a/src/webview/frontend/app.css b/src/webview/frontend/app.css new file mode 100644 index 0000000..8fe88c3 --- /dev/null +++ b/src/webview/frontend/app.css @@ -0,0 +1,248 @@ +* { + -webkit-touch-callout: none; /* iOS Safari */ + -webkit-user-select: none; /* Safari */ + -khtml-user-select: none; /* Konqueror HTML */ + -moz-user-select: none; /* Firefox */ + -ms-user-select: none; /* Internet Explorer/Edge */ + user-select: none; /* Non-prefixed version, currently + supported by Chrome and Opera */ + cursor: default !important; + box-sizing: border-box; +} +.selectable { + -webkit-touch-callout: text; /* iOS Safari */ + -webkit-user-select: text; /* Safari */ + -khtml-user-select: text; /* Konqueror HTML */ + -moz-user-select: text; /* Firefox */ + -ms-user-select: text; /* Internet Explorer/Edge */ + user-select: text; /* Non-prefixed version, currently + supported by Chrome and Opera */ + cursor: text !important; +} +body { + margin-bottom: 100px; + padding: 0px; +} +.btn:hover { + opacity: 0.8; +} +.actions { + position: fixed; + bottom: 0px; + left: 0px; + width: 100%; + padding: 15px; + background: var(--vscode-sideBar-background); + box-shadow: 0px 0px 10px 0px rgba(50, 50, 50, 0.3); +} +.case { + background: rgba(0, 0, 0, 0.1); + padding: 8px; + margin-bottom: 2px; +} + +.exec-time { + background: #fbff001c; + color: white; + padding: 2px 5px 2px 5px; + font-size: 80%; + border-radius: 5px; +} + +a.btn { + text-decoration: none; + display: inline-block; +} + +.first-time-message { + padding: 10px; + background: rgba(0, 0, 0, 0.1); +} +.first-time-message ul li { + margin-bottom: 10px; +} +.pre, +pre, +textarea { + background: var(--input-background); + width: 100%; + display: block; + background: rgba(0, 0, 0, 0.2); + outline: none !important; + border: 0px; + color: bisque; + max-width: 100%; + max-height: 250px !important; + overflow-y: auto; + resize: none; + border: 1px solid transparent; + box-sizing: content-box !important; + margin-bottom: 4px; +} + +.clearfix { + clear: both; + content: ''; + width: 100%; + display: block; +} + +::-webkit-resizer { + display: none; +} +textarea:focus, +textarea:active { + background: black; + outline: none !important; + border: 1px solid #3393cc; +} + +.case-number { + font-weight: bold; + font-size: 1.15em; + color: rgb(25, 152, 211); +} + +.case-metadata { + margin-bottom: 5px; +} + +.right { + float: right; +} + +.left { + float: left; +} + +.time { + color: rgb(185, 185, 84); +} + +.fail { + color: rgb(126, 38, 38); +} + +.pass { + color: rgb(37, 87, 37); +} +.btn { + padding: 2px 5px 2px 5px; + background: #3393cc91; + color: white; + outline: none; + margin-right: 4px; + margin-bottom: 5px; + border: 2px solid transparent; +} +.btn-green { + background: #70b92791; +} +.btn-red { + background: #b9274391; +} +.btn:focus { + border-color: pink; +} +.btn-orange { + background: orange; +} + +.w-80 { + width: 80px; +} + +h4 { + padding: 5px; +} +#filepath { + display: none; +} +#running-next-box { + padding: 10px; +} +body.vscode-light * { + color: black !important; +} +body.vscode-light textarea, +pre { + background: rgba(255, 255, 255, 0.8); + border: 1px solid whitesmoke !important; +} + +body.vscode-light .case { + background: rgba(255, 255, 255, 0.2); +} + +body.vscode-light textarea:focus { + background: rgba(0, 0, 0, 0.2); + outline: none !important; + border: 1px solid lightblue; +} + +button:disabled { + background: rgb(66, 66, 66) !important; + opacity: 0.1 !important; +} + +#problem, +#results { + display: none; +} + +.result-data { + font-size: 1.15em; + margin-left: 5px; +} + +.result-pass { + color: greenyellow; + padding-right: 5px; +} +.result-fail { + color: orangered; + padding-right: 5px; +} +.running-text { + color: yellow; + padding-right: 5px; + padding-left: 5px; + font-size: 1.15em; +} + +.problem-name { + font-size: 18px; + margin: 5px; + padding: 10px; +} + +.case { + border-left: 5px solid var(--vscode-input-background); + border-radius: 5px; +} + +.case.running { + /* animation: runs 1s linear infinite; */ + border-color: yellow; +} + +.case.passed { + border-left-color: green; +} + +.case.failed { + border-left-color: orangered; +} + +.signal { + padding: 4px; +} + +/* @keyframes runs { + 50% { + border-color: orange; + } + 100% { + border-color: var(--input-background); + } +} */ diff --git a/src/webview/frontend/index.html b/src/webview/frontend/index.html new file mode 100644 index 0000000..8354902 --- /dev/null +++ b/src/webview/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + + + + +
+ + + + \ No newline at end of file