Skip to content

Commit 0e4c270

Browse files
committed
PythonのREPLが表示できた
1 parent 05e15b3 commit 0e4c270

File tree

6 files changed

+258
-0
lines changed

6 files changed

+258
-0
lines changed

app/terminal/python/page.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
"use client";
2+
3+
import { TerminalComponent } from "../terminal";
4+
import { usePyodide } from "./pyodide";
5+
6+
export default function PythonPage() {
7+
const { isPyodideReady, runPython } = usePyodide();
8+
return (
9+
<div className="p-4">
10+
<TerminalComponent
11+
ready={isPyodideReady}
12+
initMessage="Welcome to Pyodide Terminal!"
13+
prompt=">>> "
14+
sendCommand={runPython}
15+
/>
16+
</div>
17+
);
18+
}

app/terminal/python/pyodide.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// Nextjsではドキュメント通りにpyodideをimportすると動かない? typeのインポートだけはできる
2+
import { type PyodideAPI } from "pyodide";
3+
import { useState, useEffect, useRef, useCallback } from "react";
4+
import { TerminalOutput } from "../terminal";
5+
6+
declare global {
7+
interface Window {
8+
loadPyodide: (options: { indexURL: string }) => Promise<PyodideAPI>;
9+
}
10+
}
11+
12+
export function usePyodide() {
13+
const pyodideRef = useRef<PyodideAPI>(null);
14+
const [isPyodideReady, setIsPyodideReady] = useState<boolean>(false);
15+
const pyodideOutput = useRef<TerminalOutput[]>([]);
16+
17+
useEffect(() => {
18+
// next.config.ts 内でpyodideをimportし、バージョンを取得している
19+
const PYODIDE_CDN = `https://cdn.jsdelivr.net/pyodide/v${process.env.PYODIDE_VERSION}/full/`;
20+
21+
const initPyodide = () => {
22+
window
23+
.loadPyodide({
24+
indexURL: PYODIDE_CDN,
25+
})
26+
.then((pyodide) => {
27+
pyodideRef.current = pyodide;
28+
29+
// 標準出力とエラーをハンドリングする設定
30+
pyodide.setStdout({
31+
batched: (str) => {
32+
pyodideOutput.current.push({ type: "stdout", message: str });
33+
},
34+
});
35+
pyodide.setStderr({
36+
batched: (str) => {
37+
pyodideOutput.current.push({ type: "stderr", message: str });
38+
},
39+
});
40+
41+
setIsPyodideReady(true);
42+
});
43+
};
44+
45+
// スクリプトタグを動的に追加
46+
if ("loadPyodide" in window) {
47+
initPyodide();
48+
} else {
49+
const script = document.createElement("script");
50+
script.src = `${PYODIDE_CDN}pyodide.js`;
51+
script.async = true;
52+
script.onload = initPyodide;
53+
script.onerror = () => {
54+
// TODO
55+
};
56+
document.body.appendChild(script);
57+
58+
// コンポーネントのクリーンアップ時にスクリプトタグを削除
59+
return () => {
60+
document.body.removeChild(script);
61+
};
62+
}
63+
}, []);
64+
65+
const runPython = useCallback<(code: string) => Promise<TerminalOutput[]>>(
66+
async (code: string) => {
67+
const pyodide = pyodideRef.current;
68+
if (!pyodide) {
69+
return [{ type: "error", message: "Pyodide is not ready yet." }];
70+
}
71+
try {
72+
const result = await pyodide.runPythonAsync(code);
73+
if (result !== undefined) {
74+
pyodideOutput.current.push({
75+
type: "return",
76+
message: String(result),
77+
});
78+
} else {
79+
// 標準出力/エラーがない場合
80+
}
81+
} catch (e) {
82+
console.log(e);
83+
if (e instanceof Error) {
84+
pyodideOutput.current.push({ type: "error", message: e.message });
85+
} else {
86+
pyodideOutput.current.push({ type: "error", message: String(e) });
87+
}
88+
}
89+
const output = [...pyodideOutput.current];
90+
pyodideOutput.current = []; // 出力をクリア
91+
return output;
92+
},
93+
[]
94+
);
95+
96+
// 外部に公開する値と関数
97+
return { isPyodideReady, runPython };
98+
}

app/terminal/terminal.tsx

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"use client";
2+
3+
import React, { useEffect, useRef, useState } from "react";
4+
import { Terminal } from "@xterm/xterm";
5+
import { FitAddon } from "@xterm/addon-fit";
6+
import "@xterm/xterm/css/xterm.css";
7+
8+
export interface TerminalOutput {
9+
type: "stdout" | "stderr" | "error" | "return"; // 出力の種類
10+
message: string; // 出力メッセージ
11+
}
12+
interface TerminalComponentProps {
13+
ready: boolean;
14+
initMessage: string; // ターミナル初期化時のメッセージ
15+
prompt: string; // プロンプト文字列
16+
sendCommand: (command: string) => Promise<TerminalOutput[]>; // コマンド実行時のコールバック関数
17+
}
18+
export function TerminalComponent(props: TerminalComponentProps) {
19+
const terminalRef = useRef<HTMLDivElement>(null!);
20+
const terminalInstanceRef = useRef<Terminal | null>(null);
21+
const [termReady, setTermReady] = useState<boolean>(false);
22+
const inputBuffer = useRef<string>("");
23+
24+
const [initMessage] = useState<string>(props.initMessage);
25+
const [prompt] = useState<string>(props.prompt);
26+
const sendCommand = useRef<(command: string) => Promise<TerminalOutput[]>>(
27+
props.sendCommand
28+
);
29+
30+
useEffect(() => {
31+
if (terminalInstanceRef.current && termReady && props.ready) {
32+
// 初期メッセージとプロンプトを表示
33+
terminalInstanceRef.current.writeln(initMessage);
34+
terminalInstanceRef.current.write(prompt);
35+
}
36+
}, [initMessage, prompt, props.ready, termReady]);
37+
38+
// ターミナルの初期化処理
39+
useEffect(() => {
40+
const term = new Terminal({
41+
cursorBlink: true,
42+
convertEol: true,
43+
});
44+
terminalInstanceRef.current = term;
45+
46+
const fitAddon = new FitAddon();
47+
term.loadAddon(fitAddon);
48+
term.open(terminalRef.current);
49+
fitAddon.fit();
50+
51+
setTermReady(true);
52+
// TODO: loadingメッセージ
53+
// TODO: ターミナルのサイズ変更に対応する
54+
55+
const onOutput = (outputs: TerminalOutput[]) => {
56+
for (const output of outputs) {
57+
// 出力内容に応じて色を変える
58+
const message = String(output.message).replace(/\n/g, "\r\n");
59+
switch (output.type) {
60+
case "stderr":
61+
case "error":
62+
term.writeln(`\x1b[1;31m${message}\x1b[0m`);
63+
break;
64+
default:
65+
term.writeln(message);
66+
break;
67+
}
68+
}
69+
// 出力が終わったらプロンプトを表示
70+
term.write(prompt);
71+
};
72+
73+
// キー入力のハンドリング
74+
const onDataHandler = term.onData((key) => {
75+
const code = key.charCodeAt(0);
76+
77+
if (code === 13) {
78+
// Enter
79+
term.writeln("");
80+
if (inputBuffer.current.trim().length > 0) {
81+
sendCommand.current(inputBuffer.current).then(onOutput);
82+
inputBuffer.current = "";
83+
}
84+
// 新しいプロンプトは外部からのoutputを待ってから表示する
85+
} else if (code === 127) {
86+
// Backspace
87+
if (inputBuffer.current.length > 0) {
88+
term.write("\b \b");
89+
inputBuffer.current = inputBuffer.current.slice(0, -1);
90+
}
91+
} else if (code >= 32) {
92+
inputBuffer.current += key;
93+
term.write(key);
94+
}
95+
});
96+
97+
// アンマウント時のクリーンアップ
98+
return () => {
99+
onDataHandler.dispose();
100+
term.dispose();
101+
};
102+
}, [initMessage, prompt]);
103+
104+
return <div ref={terminalRef} style={{ width: "100%", height: "400px" }} />;
105+
}

next.config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { NextConfig } from "next";
22
import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare";
3+
import { version as pyodideVersion } from "pyodide";
34

45
initOpenNextCloudflareForDev();
56

@@ -11,6 +12,9 @@ const nextConfig: NextConfig = {
1112
typescript: {
1213
ignoreBuildErrors: true,
1314
},
15+
env: {
16+
PYODIDE_VERSION: pyodideVersion,
17+
},
1418
};
1519

1620
export default nextConfig;

package-lock.json

Lines changed: 30 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@
1616
"dependencies": {
1717
"@google/generative-ai": "^0.24.1",
1818
"@opennextjs/cloudflare": "^1.6.3",
19+
"@xterm/addon-fit": "^0.11.0-beta.115",
20+
"@xterm/xterm": "^5.6.0-beta.115",
1921
"next": "<15.4",
22+
"pyodide": "^0.28.1",
2023
"react": "19.1.0",
2124
"react-dom": "19.1.0",
2225
"react-markdown": "^10.1.0",

0 commit comments

Comments
 (0)