Skip to content

Commit c8e5f94

Browse files
committed
feat: runFilesでのリアルタイムdiagnostic出力機能およびエディタ表示を追加
1 parent 1978eb2 commit c8e5f94

16 files changed

Lines changed: 628 additions & 44 deletions

File tree

‎app/globals.css‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,24 @@ mycdark:
114114
.ace_selected-word {
115115
@apply border-primary!;
116116
}
117+
.ace_error-marker {
118+
position: absolute;
119+
background-color: rgba(239, 68, 68, 0.2);
120+
border-bottom: 2px wavy rgb(239, 68, 68);
121+
z-index: 20;
122+
}
123+
.ace_warning-marker {
124+
position: absolute;
125+
background-color: rgba(245, 158, 11, 0.2);
126+
border-bottom: 2px wavy rgb(245, 158, 11);
127+
z-index: 20;
128+
}
129+
.ace_info-marker {
130+
position: absolute;
131+
background-color: rgba(59, 130, 246, 0.2);
132+
border-bottom: 2px dotted rgb(59, 130, 246);
133+
z-index: 20;
134+
}
117135

118136
.rounded-box-modal {
119137
@apply rounded-box;

‎app/terminal/editor.tsx‎

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { lazy, Suspense, useEffect, useState } from "react";
3+
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
44
import clsx from "clsx";
55
import { useChangeTheme } from "@/themeToggle";
66
import { useEmbedContext } from "./embedContext";
@@ -41,7 +41,59 @@ interface EditorProps {
4141
}
4242
export function EditorComponent(props: EditorProps) {
4343
const theme = useChangeTheme();
44-
const { files, writeFile } = useEmbedContext();
44+
const { files, writeFile, diagnostics } = useEmbedContext();
45+
const fileDiagnostics = useMemo(
46+
() => diagnostics[props.filename] ?? [],
47+
[diagnostics, props.filename]
48+
);
49+
50+
const annotations = useMemo(() => {
51+
return fileDiagnostics.map((diag) => ({
52+
row: Math.max(0, diag.startLineNumber - 1),
53+
column: Math.max(0, (diag.startColumn ?? 1) - 1),
54+
text: diag.message,
55+
type: diag.severity ?? "error", // "error" | "warning" | "info"
56+
}));
57+
}, [fileDiagnostics]);
58+
59+
const markers = useMemo(() => {
60+
return fileDiagnostics.map((diag) => {
61+
const startRow = Math.max(0, diag.startLineNumber - 1);
62+
const endRow = diag.endLineNumber
63+
? Math.max(0, diag.endLineNumber - 1)
64+
: startRow;
65+
const startCol =
66+
diag.startColumn !== undefined ? Math.max(0, diag.startColumn - 1) : 0;
67+
const endCol =
68+
diag.endColumn !== undefined
69+
? Math.max(0, diag.endColumn - 1)
70+
: Number.MAX_SAFE_INTEGER;
71+
72+
const isError = (diag.severity ?? "error") === "error";
73+
const isWarning = diag.severity === "warning";
74+
const className = isError
75+
? "ace_error-marker"
76+
: isWarning
77+
? "ace_warning-marker"
78+
: "ace_info-marker";
79+
80+
return {
81+
startRow,
82+
startCol,
83+
endRow,
84+
endCol,
85+
className,
86+
type:
87+
diag.startColumn !== undefined &&
88+
diag.endColumn !== undefined &&
89+
startRow === endRow
90+
? ("text" as const)
91+
: ("fullLine" as const),
92+
inFront: false,
93+
};
94+
});
95+
}, [fileDiagnostics]);
96+
4597
const code = files[props.filename] || props.initContent;
4698
useEffect(() => {
4799
if (!files[props.filename] && props.initContent) {
@@ -202,6 +254,8 @@ export function EditorComponent(props: EditorProps) {
202254
value={code}
203255
onChange={(code: string) => writeFile({ [props.filename]: code })}
204256
setOptions={{ useWorker: false }}
257+
annotations={annotations}
258+
markers={markers}
205259
/>
206260
</Suspense>
207261
) : (

‎app/terminal/embedContext.tsx‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { ReplCommand, ReplOutput } from "@my-code/runtime/interface";
3+
import { Diagnostic, ReplCommand, ReplOutput } from "@my-code/runtime/interface";
44
import {
55
createContext,
66
ReactNode,
@@ -40,6 +40,10 @@ interface IEmbedContext {
4040
execResults: Readonly<Record<Filename, ReplOutput[]>>;
4141
clearExecResult: (filename: Filename) => void;
4242
addExecOutput: (filename: Filename, output: ReplOutput) => void;
43+
44+
diagnostics: Readonly<Record<Filename, Diagnostic[]>>;
45+
clearDiagnostics: (filename?: Filename) => void;
46+
addDiagnostic: (filename: Filename, diagnostic: Diagnostic) => void;
4347
}
4448
const EmbedContext = createContext<IEmbedContext>(null!);
4549

@@ -80,11 +84,15 @@ export function EmbedContextProvider({
8084
const [execResults, setExecResults] = useState<
8185
Record<Filename, ReplOutput[]>
8286
>({});
87+
const [diagnostics, setDiagnostics] = useState<
88+
Record<Filename, Diagnostic[]>
89+
>({});
8390
if (pageKey && pageKey !== prevPageKey) {
8491
setPrevPageKey(pageKey);
8592
setReplOutputs({});
8693
setCommandIdCounters({});
8794
setExecResults({});
95+
setDiagnostics({});
8896
}
8997

9098
const writeFile = useCallback(
@@ -181,6 +189,30 @@ export function EmbedContextProvider({
181189
[]
182190
);
183191

192+
const clearDiagnostics = useCallback(
193+
(filename?: Filename) =>
194+
setDiagnostics((diags) => {
195+
if (filename !== undefined) {
196+
const next = { ...diags };
197+
delete next[filename];
198+
return next;
199+
}
200+
return {};
201+
}),
202+
[]
203+
);
204+
const addDiagnostic = useCallback(
205+
(filename: Filename, diagnostic: Diagnostic) =>
206+
setDiagnostics((diags) => {
207+
const current = diags[filename] ? [...diags[filename]] : [];
208+
return {
209+
...diags,
210+
[filename]: [...current, diagnostic],
211+
};
212+
}),
213+
[]
214+
);
215+
184216
return (
185217
<EmbedContext.Provider
186218
value={{
@@ -192,6 +224,9 @@ export function EmbedContextProvider({
192224
execResults,
193225
clearExecResult,
194226
addExecOutput,
227+
diagnostics,
228+
clearDiagnostics,
229+
addDiagnostic,
195230
}}
196231
>
197232
{children}

‎app/terminal/exec.tsx‎

Lines changed: 40 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,14 @@ export function ExecFile(props: ExecProps) {
6969
}
7070
},
7171
});
72-
const { files, clearExecResult, addExecOutput, writeFile } =
73-
useEmbedContext();
72+
const {
73+
files,
74+
clearExecResult,
75+
addExecOutput,
76+
writeFile,
77+
clearDiagnostics,
78+
addDiagnostic,
79+
} = useEmbedContext();
7480

7581
if (props.language.runtime === undefined) {
7682
throw new Error(
@@ -94,29 +100,39 @@ export function ExecFile(props: ExecProps) {
94100
// TODO: 1つのファイル名しか受け付けないところに無理やりコンマ区切りで全部のファイル名を突っ込んでいる
95101
const filenameKey = props.filenames.join(",");
96102
clearExecResult(filenameKey);
103+
for (const fname of props.filenames) {
104+
clearDiagnostics(fname);
105+
}
97106
setContents("");
98107
let isFirstOutput = true;
99-
await runFiles(props.filenames, files, (output) => {
100-
if (output.type === "file") {
101-
writeFile({ [output.filename]: output.content });
102-
return;
103-
}
104-
addExecOutput(filenameKey, output);
105-
if (isFirstOutput) {
106-
// Clear "実行中です..." message only on first output
107-
clearTerminal(terminalInstanceRef.current!);
108-
isFirstOutput = false;
108+
await runFiles(
109+
props.filenames,
110+
files,
111+
(output) => {
112+
if (output.type === "file") {
113+
writeFile({ [output.filename]: output.content });
114+
return;
115+
}
116+
addExecOutput(filenameKey, output);
117+
if (isFirstOutput) {
118+
// Clear "実行中です..." message only on first output
119+
clearTerminal(terminalInstanceRef.current!);
120+
isFirstOutput = false;
121+
}
122+
// Append only the new output
123+
writeOutput(
124+
terminalInstanceRef.current!,
125+
output,
126+
undefined,
127+
null, // ファイル実行で"return"メッセージが返ってくることはないはずなので、Prismを渡す必要はない
128+
props.language
129+
);
130+
setContents((prev) => prev + output.message + "\n");
131+
},
132+
(diagnostic) => {
133+
addDiagnostic(diagnostic.filename, diagnostic);
109134
}
110-
// Append only the new output
111-
writeOutput(
112-
terminalInstanceRef.current!,
113-
output,
114-
undefined,
115-
null, // ファイル実行で"return"メッセージが返ってくることはないはずなので、Prismを渡す必要はない
116-
props.language
117-
);
118-
setContents((prev) => prev + output.message + "\n");
119-
});
135+
);
120136
setExecutionState("idle");
121137
if (isFirstOutput) {
122138
// If there was no output, clear the "実行中です..." message
@@ -132,6 +148,8 @@ export function ExecFile(props: ExecProps) {
132148
clearExecResult,
133149
addExecOutput,
134150
writeFile,
151+
clearDiagnostics,
152+
addDiagnostic,
135153
terminalInstanceRef,
136154
props.language,
137155
files,
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from "./python";
2+
export * from "./ruby";
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { Diagnostic } from "../interface";
2+
3+
/**
4+
* Parses Python error/traceback string to extract diagnostic information.
5+
*
6+
* @param traceback - The traceback string or error message from Python
7+
* @param homePrefix - The virtual home directory prefix to strip (default: "/home/pyodide/")
8+
* @returns Array of Diagnostic objects
9+
*/
10+
export function parsePythonTraceback(
11+
traceback: string,
12+
homePrefix: string = "/home/pyodide/"
13+
): Diagnostic[] {
14+
if (!traceback) return [];
15+
16+
const lines = traceback.trim().split("\n");
17+
if (lines.length === 0) return [];
18+
19+
// Extract the last error message line (e.g., "Exception: This is a test error" or "SyntaxError: ...")
20+
let errorMessage = lines[lines.length - 1].trim();
21+
for (let i = lines.length - 1; i >= 0; i--) {
22+
const line = lines[i].trim();
23+
if (line && !line.startsWith("^") && !line.startsWith("File \"") && !line.startsWith("Traceback")) {
24+
errorMessage = line;
25+
break;
26+
}
27+
}
28+
29+
const diagnostics: Diagnostic[] = [];
30+
const fileLineRegex = /File "([^"]+)", line (\d+)(?:, in (.+))?/;
31+
32+
for (let i = 0; i < lines.length; i++) {
33+
const match = fileLineRegex.exec(lines[i]);
34+
if (match) {
35+
let rawFilename = match[1];
36+
const lineNum = parseInt(match[2], 10);
37+
38+
// Normalize filename by removing homePrefix or leading slashes
39+
if (rawFilename.startsWith(homePrefix)) {
40+
rawFilename = rawFilename.slice(homePrefix.length);
41+
} else if (rawFilename.startsWith("/")) {
42+
rawFilename = rawFilename.slice(1);
43+
}
44+
45+
// Ignore internal names like <exec>, <string> if not matching normal files
46+
if (rawFilename === "<exec>" || rawFilename === "<string>") {
47+
continue;
48+
}
49+
50+
// Check if there is a column indicator on subsequent lines (e.g. for SyntaxError with ^)
51+
let startColumn: number | undefined = undefined;
52+
let endColumn: number | undefined = undefined;
53+
for (let j = i + 1; j < Math.min(i + 4, lines.length); j++) {
54+
const nextLine = lines[j];
55+
if (fileLineRegex.test(nextLine)) break;
56+
const caretIndex = nextLine.indexOf("^");
57+
if (caretIndex !== -1) {
58+
// In Python SyntaxError output, caret points to character (1-indexed)
59+
startColumn = caretIndex + 1;
60+
const caretEnd = nextLine.lastIndexOf("^");
61+
if (caretEnd > caretIndex) {
62+
endColumn = caretEnd + 2;
63+
}
64+
break;
65+
}
66+
}
67+
68+
diagnostics.push({
69+
filename: rawFilename,
70+
startLineNumber: lineNum,
71+
startColumn,
72+
endLineNumber: lineNum,
73+
endColumn,
74+
message: errorMessage,
75+
severity: "error",
76+
});
77+
}
78+
}
79+
80+
return diagnostics;
81+
}

0 commit comments

Comments
 (0)