From 8b0d4aa21ea41a3f0323c9526c3a1b4d5a2df1b4 Mon Sep 17 00:00:00 2001 From: Hoshino Date: Wed, 5 Aug 2026 18:50:24 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E5=88=B0=20cph?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/CLAUDE.md | 75 +++++++++++++++++++ apps/vscode-extension/src/atcoder.ts | 35 +++++++++ apps/vscode-extension/src/extension.ts | 15 +++- apps/vscode-extension/src/tools/command.ts | 8 ++- apps/vscode-extension/src/tools/cph.ts | 84 ++++++++++++++++++++++ packages/webview/src/WebviewApp.tsx | 14 ++++ packages/webview/src/types.ts | 4 +- 7 files changed, 232 insertions(+), 3 deletions(-) create mode 100644 .claude/CLAUDE.md create mode 100644 apps/vscode-extension/src/tools/cph.ts diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..4079ae4 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,75 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +A VS Code extension called "AtCoder Helper neo" for browsing AtCoder competitive programming contest problems, with LaTeX rendering, DeepL translation, code submission, and contest registration. Chinese-language UI and documentation. + +## Commands + +```bash +pnpm install # Install dependencies +pnpm build # Build all packages (turbo) +pnpm dev # Dev watch mode (extension + webview) +pnpm lint # ESLint all packages +pnpm format # Prettier format +pnpm test # Run all tests + +# Run unit tests directly +cd apps/vscode-extension && pnpm test + +# Package extension +cd apps/vscode-extension && pnpm package +# Output: release/extension.vsix +``` + +Debug: press F5 in VS Code to launch Extension Host (preLaunchTask runs `pnpm dev`). + +## Architecture + +Monorepo with pnpm workspaces + Turborepo. Webpack dual-config builds both the Node extension and the React webview into `apps/vscode-extension/dist/`. + +**Dependency flow:** `apps/vscode-extension` → `packages/webview` → `packages/ui` + `packages/core` + +Webpack resolves `@template/ui` and `@template/core` via path aliases directly to source — no need to pre-build packages during development. + +### Extension host (Node, CommonJS) + +Entry: `apps/vscode-extension/src/extension.ts` — registers commands, creates WebView panel, routes messages. + +Communication protocol: WebView sends `{ command: "..." }`, Extension responds with `{ type: "..." }`. Message types defined in `packages/webview/src/types.ts` (`WebviewMessage` union). Adding a new message requires updating both `types.ts` and the `switch` in `extension.ts`. + +Network layer (`tools/fetch.ts`): custom HTTP client using Node https/http/zlib, no external deps. Three error classes: `CfError` (Cloudflare), `ProxyError`, `LoginRequiredError` — handled uniformly by `handleErrorWithCfAndLogin()`. + +### WebView frontend (React, browser target) + +Entry: `packages/webview/src/index.tsx` → `WebviewApp.tsx`. Uses Tailwind CSS with `var(--vscode-*)` variables for theme adaptation. KaTeX math is pre-rendered server-side in the extension host. + +### Shared packages + +- `packages/core` — `MessageBus` (pub/sub) and `StateManager` +- `packages/ui` — Button, Card, Input, Spinner components styled with VS Code CSS variables + +## Code Conventions + +- 4-space indent, no `export default`, prefer `interface` over `type` +- No `any` — use `unknown` instead +- Functions max 120 lines (CI enforces 130 hard limit) +- Import order: external deps → `@template/*` packages → relative paths +- Component files: PascalCase. Tool/utility files: camelCase +- Error handling: use `CfError`/`ProxyError`/`LoginRequiredError`, never bare `throw new Error()` +- Git branches: `feat/xxx`, `fix/xxx`, `refactor/xxx`, `docs/xxx` +- Main branch: `main`, development branch: `dev` + +## CI + +PR to `main` triggers: lint → build → test → function length check (>130 lines fails). Results posted as PR comment. + +## Git Branch Rules (Claude MUST follow) + +- **NEVER** commit, push, or merge to `main` branch directly +- **NEVER** run `git checkout main` or `git push origin main` +- **ALWAYS** work on `dev` branch or feature branches (`feat/*`, `fix/*`, `refactor/*`, `docs/*`) +- Before any Git operation, confirm current branch with `git branch --show-current` +- If user asks to modify `main`, remind them to use `dev` instead \ No newline at end of file diff --git a/apps/vscode-extension/src/atcoder.ts b/apps/vscode-extension/src/atcoder.ts index 81318a5..e3d5817 100644 --- a/apps/vscode-extension/src/atcoder.ts +++ b/apps/vscode-extension/src/atcoder.ts @@ -18,6 +18,8 @@ export interface AtCoderProblem { outputFormat: string; samples: SampleCase[]; sampleUrl?: string; + timeLimit?: number; + memoryLimit?: number; } function decodeEntities(text: string): string { @@ -148,6 +150,37 @@ function getLangContent(html: string, lang: "en" | "ja"): string { return html.slice(start); } +function extractHeaderValue(html: string, labels: string[]): string | undefined { + const rowRegex = /]*>([\s\S]*?)<\/tr>/gi; + let rowMatch: RegExpExecArray | null; + for (; (rowMatch = rowRegex.exec(html)) !== null;) { + const row = rowMatch[1]; + if (!labels.some((label) => row.includes(label))) continue; + const tds = Array.from(row.matchAll(/]*>([\s\S]*?)<\/td>/gi)); + if (tds.length === 0) continue; + const value = cleanText(tds[tds.length - 1][1]); + return value || undefined; + } + return undefined; +} + +function extractTimeLimit(html: string): number | undefined { + const value = extractHeaderValue(html, ["Time Limit", "時間制限"]); + const match = value && value.match(/(\d+(?:\.\d+)?)/); + if (!match) return undefined; + const ms = value!.toLowerCase().includes("sec") + ? parseFloat(match[1]) * 1000 + : parseFloat(match[1]); + return Math.round(ms); +} + +function extractMemoryLimit(html: string): number | undefined { + const value = extractHeaderValue(html, ["Memory Limit", "メモリ制限"]); + const match = value && value.match(/(\d+(?:\.\d+)?)/); + if (!match) return undefined; + return Math.round(parseFloat(match[1])); +} + export function parseProblemPage(html: string, url: string): AtCoderProblem { const titleMatch = html.match(/([^<]+)<\/title>/i); const title = titleMatch ? cleanText(titleMatch[1]) : "Untitled"; @@ -208,6 +241,8 @@ export function parseProblemPage(html: string, url: string): AtCoderProblem { outputFormat, samples, sampleUrl, + timeLimit: extractTimeLimit(html), + memoryLimit: extractMemoryLimit(html), }; } diff --git a/apps/vscode-extension/src/extension.ts b/apps/vscode-extension/src/extension.ts index 2600c91..06d2bb8 100644 --- a/apps/vscode-extension/src/extension.ts +++ b/apps/vscode-extension/src/extension.ts @@ -1,12 +1,14 @@ import * as vscode from "vscode"; import * as path from "path"; -import { fetchAtCoderProblem, fetchAtCoderTasks } from "./atcoder"; +import { AtCoderProblem, fetchAtCoderProblem, fetchAtCoderTasks } from "./atcoder"; import { CfError, ProxyError, LoginRequiredError, setSessionCookie, fetchSubStatus, fetchSubmitHistory } from "./tools/fetch"; import { fetchContest, signedUpContest } from "./tools/SignUpContest"; import { translateTextRaw, translateTextFree } from "./tools/deepl"; import { runCommand } from "./tools/command"; import { fetchSubmitPage, submitCodeWithRedirect } from "./tools/submit"; +import { buildCphProblem, sendToCph } from "./tools/cph" import { IncomingMessage } from "./tools/types"; +import { send } from "process"; const log = { info: (...args: unknown[]) => { @@ -262,6 +264,17 @@ export async function handleFetchSubHistory(contest: string, send: (payload: Rec } } +export async function handleExportToCph(problem: AtCoderProblem, send: (payload: Record<string, unknown>) => void) { + try { + const payload = buildCphProblem(problem); + await sendToCph(payload); + send({ type: "cphExportResult", success: true, message: "success send to cph" }); + } catch (error) { + const message = error instanceof Error ? error.message : "fail to send cph"; + send({ type: "error", success: false, message: message }); + } +} + async function pullSubmitStatu(contest: string, taskName: string, send: (payload: Record<string, unknown>) => void,): Promise<void> { const maxSetp = 15; const judgeStatus = new Set(["AC", "WA", "TLE", "MLE", "RE", "CE", "OLE"]); diff --git a/apps/vscode-extension/src/tools/command.ts b/apps/vscode-extension/src/tools/command.ts index 6b342eb..3d635be 100644 --- a/apps/vscode-extension/src/tools/command.ts +++ b/apps/vscode-extension/src/tools/command.ts @@ -11,12 +11,13 @@ import { handleFetchSubmitPage, handleSubmitCode, handleFetchSubHistory, + handleExportToCph, } from "../extension"; const loadCommands = new Set(["loadContest", "loadProblem", "openBrowser"]); const deeplCommands = new Set(["translate", "setApiKey"]); const cookieCommands = new Set(["getCookie", "setCookie"]); -const problemCommands = new Set(["registerContest", "copyMarkdown", "alert"]); +const problemCommands = new Set(["registerContest", "copyMarkdown", "alert", "sendCph"]); const submitCommands = new Set(["fetchSubmitPage", "submitCode", "fetchSubmissionHistory", "fetchSubmissionDetail"]); export async function runCommand(message: IncomingMessage, context: vscode.ExtensionContext, sendToWebview: (payload: Record<string, unknown>) => void,) { @@ -63,6 +64,11 @@ async function runProblem(command: IncomingMessage, context: vscode.ExtensionCon vscode.window.showInformationMessage(command.text ?? ""); sendToWebview({ type: "update", text: `Extension received: ${command.text ?? ""}` }); return true; + case "sendCph": + if (command.problem) { + await handleExportToCph(command.problem, sendToWebview); + } + return true; default: return false; } diff --git a/apps/vscode-extension/src/tools/cph.ts b/apps/vscode-extension/src/tools/cph.ts new file mode 100644 index 0000000..930fc32 --- /dev/null +++ b/apps/vscode-extension/src/tools/cph.ts @@ -0,0 +1,84 @@ +import * as http from "http"; +import { AtCoderProblem } from "../atcoder"; + +export interface CphTestCase { + input: string; + output: string; + id: number; +} + +export interface CphProblem { + name: string; + url: string; + interactive: boolean; + memoryLimit: number; + timeLimit: number; + group: string; + tests: CphTestCase[]; + srcPath: string; + local: boolean; +} + +export class CphNotRunningError extends Error { + constructor() { + super( + "未检测到 CPH 插件(localhost:27121 无响应)。\n" + + "请安装并启用 Competitive Programming Helper 扩展后重试。" + ); + this.name = "CphNotRunningError"; + } +} + +export function buildCphProblem(problem: AtCoderProblem): CphProblem { + const tests = problem.samples.map((sample, index) => ({ + input: sample.input, + output: sample.output, + id: index + 1, + })); + return { + name: problem.title, + url: problem.url, + interactive: false, + memoryLimit: (problem.memoryLimit ?? 1024) * 1024 * 1024, + timeLimit: problem.timeLimit ?? 2000, + group: `AtCoder - ${problem.contest}`, + tests, + srcPath: "", + local: false, + }; +} + +export function sendToCph(problem: CphProblem): Promise<void> { + const body = JSON.stringify(problem); + return new Promise<void>((resolve, reject) => { + const req = http.request( + "http://localhost:27121", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(body), + }, + }, + (res) => { + res.resume(); + res.on("end", () => { + if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { + resolve(); + } else { + reject(new Error(`CPH 返回状态码 ${res.statusCode}`)); + } + }); + } + ); + req.on("error", (err) => { + if ((err as NodeJS.ErrnoException).code === "ECONNREFUSED") { + reject(new CphNotRunningError()); + } else { + reject(new Error(`连接 CPH 失败: ${err.message}`)); + } + }); + req.write(body); + req.end(); + }); +} diff --git a/packages/webview/src/WebviewApp.tsx b/packages/webview/src/WebviewApp.tsx index da02959..471c659 100644 --- a/packages/webview/src/WebviewApp.tsx +++ b/packages/webview/src/WebviewApp.tsx @@ -83,6 +83,12 @@ const WebviewApp: React.FC<WebviewAppProps> = ({ vscode.postMessage({ command: "translate", payload: texts, targetLang: "ZH", translationMode } as WebviewMessage); }; + const doExportToCph = () => { + if (!problem) return; + setStatus("正在导出到 CPH..."); + vscode.postMessage({ command: "sendCph", problem }); + }; + const handleFetchSubmitPage = () => { setSubmitResult(null); setSubmitTasks([]); @@ -157,6 +163,11 @@ const WebviewApp: React.FC<WebviewAppProps> = ({ setIsLoading(false); setTranslating(false); } + if (message.type === "cphExportResult") { + const ok = message.success === true; + setStatus(ok ? (message.message ?? "已发送到 CPH") : (message.message ?? "导出到 CPH 失败")); + setIsLoading(false); + } if (message.type === "cf_challenge") { setCfUrl(message.url ?? null); setIsLoading(false); @@ -619,6 +630,9 @@ const WebviewApp: React.FC<WebviewAppProps> = ({ <Button onClick={doCopyMarkdown} size="sm" variant="secondary" className="h-[26px] text-[11px]"> 复制 Markdown </Button> + <Button onClick={doExportToCph} size="sm" variant="secondary" className="h-[26px] text-[11px]" title="导出到 CPH(需已安装 Competitive Programming Helper)"> + 导出 CPH + </Button> </div> </div> diff --git a/packages/webview/src/types.ts b/packages/webview/src/types.ts index 5b9875b..ea68c3e 100644 --- a/packages/webview/src/types.ts +++ b/packages/webview/src/types.ts @@ -34,9 +34,11 @@ export interface SubmissionRecord { export interface WebviewMessage { type?: string; - command?: 'alert' | 'error' | 'loadContest' | 'loadProblem' | 'openBrowser' | 'translate' | 'setApiKey' | 'setCookie' | 'getCookie' | 'loginRequired' | 'registerContest' | 'copyMarkdown' | 'fetchSubmitPage' | 'submitCode' | 'fetchSubmissionHistory'; + command?: 'alert' | 'error' | 'loadContest' | 'loadProblem' | 'openBrowser' | 'translate' | 'setApiKey' | 'setCookie' | 'getCookie' | 'loginRequired' | 'registerContest' | 'copyMarkdown' | 'sendCph' | 'fetchSubmitPage' | 'submitCode' | 'fetchSubmissionHistory'; statusMessage?: string; text?: string; + success?: boolean; + message?: string; contest?: string; task?: string; taskScreenName?: string;