Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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<T>`
- `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
35 changes: 35 additions & 0 deletions apps/vscode-extension/src/atcoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export interface AtCoderProblem {
outputFormat: string;
samples: SampleCase[];
sampleUrl?: string;
timeLimit?: number;
memoryLimit?: number;
}

function decodeEntities(text: string): string {
Expand Down Expand Up @@ -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 = /<tr[^>]*>([\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(/<td[^>]*>([\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>([^<]+)<\/title>/i);
const title = titleMatch ? cleanText(titleMatch[1]) : "Untitled";
Expand Down Expand Up @@ -208,6 +241,8 @@ export function parseProblemPage(html: string, url: string): AtCoderProblem {
outputFormat,
samples,
sampleUrl,
timeLimit: extractTimeLimit(html),
memoryLimit: extractMemoryLimit(html),
};
}

Expand Down
15 changes: 14 additions & 1 deletion apps/vscode-extension/src/extension.ts
Original file line number Diff line number Diff line change
@@ -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[]) => {
Expand Down Expand Up @@ -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"]);
Expand Down
8 changes: 7 additions & 1 deletion apps/vscode-extension/src/tools/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,) {
Expand Down Expand Up @@ -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;
}
Expand Down
84 changes: 84 additions & 0 deletions apps/vscode-extension/src/tools/cph.ts
Original file line number Diff line number Diff line change
@@ -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();
});
}
14 changes: 14 additions & 0 deletions packages/webview/src/WebviewApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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>

Expand Down
4 changes: 3 additions & 1 deletion packages/webview/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading