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(); + 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) { @@ -191,12 +224,33 @@ 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} />
@@ -210,10 +264,12 @@ export default function CaseView(props: { > Copy
- {props.case.result != null && ( @@ -239,10 +295,13 @@ export default function CaseView(props: { Set <> - @@ -250,7 +309,7 @@ export default function CaseView(props: { {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 [hoveredLine, setHoveredLine] = useState(null); + const [lineHeightPx, setLineHeightPx] = useState(18); + 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 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 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 a9326fb..d1190af 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; @@ -603,3 +618,81 @@ hr { 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