Skip to content

Commit 4f309e4

Browse files
committed
複数行入力に対応
1 parent 0e4c270 commit 4f309e4

File tree

3 files changed

+94
-26
lines changed

3 files changed

+94
-26
lines changed

app/terminal/python/page.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,16 @@ import { TerminalComponent } from "../terminal";
44
import { usePyodide } from "./pyodide";
55

66
export default function PythonPage() {
7-
const { isPyodideReady, runPython } = usePyodide();
7+
const { isPyodideReady, runPython, checkSyntax } = usePyodide();
88
return (
99
<div className="p-4">
1010
<TerminalComponent
1111
ready={isPyodideReady}
1212
initMessage="Welcome to Pyodide Terminal!"
1313
prompt=">>> "
14+
promptMore="... "
1415
sendCommand={runPython}
16+
checkSyntax={checkSyntax}
1517
/>
1618
</div>
1719
);

app/terminal/python/pyodide.ts

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,37 @@
11
// Nextjsではドキュメント通りにpyodideをimportすると動かない? typeのインポートだけはできる
22
import { type PyodideAPI } from "pyodide";
33
import { useState, useEffect, useRef, useCallback } from "react";
4-
import { TerminalOutput } from "../terminal";
4+
import { SyntaxStatus, TerminalOutput } from "../terminal";
55

66
declare global {
77
interface Window {
88
loadPyodide: (options: { indexURL: string }) => Promise<PyodideAPI>;
99
}
1010
}
1111

12+
// Python側で実行する構文チェックのコード
13+
// codeop.compile_commandは、コードが不完全な場合はNoneを返します。
14+
const CHECK_SYNTAX_CODE = `
15+
def __check_syntax():
16+
import codeop
17+
import js
18+
19+
code = js.__code_to_check
20+
compiler = codeop.compile_command
21+
try:
22+
# compile_commandは、コードが完結していればコンパイルオブジェクトを、
23+
# 不完全(まだ続きがある)であればNoneを返す
24+
if compiler(code) is not None:
25+
return "complete"
26+
else:
27+
return "incomplete"
28+
except (SyntaxError, ValueError, OverflowError):
29+
# 明らかな構文エラーの場合
30+
return "invalid"
31+
32+
__check_syntax()
33+
`;
34+
1235
export function usePyodide() {
1336
const pyodideRef = useRef<PyodideAPI>(null);
1437
const [isPyodideReady, setIsPyodideReady] = useState<boolean>(false);
@@ -65,7 +88,7 @@ export function usePyodide() {
6588
const runPython = useCallback<(code: string) => Promise<TerminalOutput[]>>(
6689
async (code: string) => {
6790
const pyodide = pyodideRef.current;
68-
if (!pyodide) {
91+
if (!pyodide || !isPyodideReady) {
6992
return [{ type: "error", message: "Pyodide is not ready yet." }];
7093
}
7194
try {
@@ -90,9 +113,31 @@ export function usePyodide() {
90113
pyodideOutput.current = []; // 出力をクリア
91114
return output;
92115
},
93-
[]
116+
[isPyodideReady]
117+
);
118+
119+
/**
120+
* Pythonコードの構文が完結しているかチェックする
121+
*/
122+
const checkSyntax = useCallback<(code: string) => Promise<SyntaxStatus>>(
123+
async (code) => {
124+
const pyodide = pyodideRef.current;
125+
if (!pyodide || !isPyodideReady) return 'invalid';
126+
127+
// グローバルスコープにチェック対象のコードを渡す
128+
(window as any).__code_to_check = code
129+
try {
130+
// Pythonのコードを実行して結果を受け取る
131+
const status = await pyodide.runPythonAsync(CHECK_SYNTAX_CODE);
132+
return status;
133+
} catch (e) {
134+
console.error("Syntax check error:", e);
135+
return 'invalid';
136+
}
137+
},
138+
[isPyodideReady]
94139
);
95140

96141
// 外部に公開する値と関数
97-
return { isPyodideReady, runPython };
142+
return { isPyodideReady, runPython, checkSyntax };
98143
}

app/terminal/terminal.tsx

Lines changed: 42 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,31 +9,43 @@ export interface TerminalOutput {
99
type: "stdout" | "stderr" | "error" | "return"; // 出力の種類
1010
message: string; // 出力メッセージ
1111
}
12+
export type SyntaxStatus = "complete" | "incomplete" | "invalid"; // 構文チェックの結果
13+
1214
interface TerminalComponentProps {
1315
ready: boolean;
1416
initMessage: string; // ターミナル初期化時のメッセージ
1517
prompt: string; // プロンプト文字列
16-
sendCommand: (command: string) => Promise<TerminalOutput[]>; // コマンド実行時のコールバック関数
18+
promptMore?: string;
19+
// コマンド実行時のコールバック関数
20+
sendCommand: (command: string) => Promise<TerminalOutput[]>;
21+
// 構文チェックのコールバック関数
22+
// incompleteの場合は次の行に続くことを示す
23+
checkSyntax?: (code: string) => Promise<SyntaxStatus>;
1724
}
1825
export function TerminalComponent(props: TerminalComponentProps) {
1926
const terminalRef = useRef<HTMLDivElement>(null!);
2027
const terminalInstanceRef = useRef<Terminal | null>(null);
2128
const [termReady, setTermReady] = useState<boolean>(false);
22-
const inputBuffer = useRef<string>("");
29+
const inputBuffer = useRef<string[]>([""]);
2330

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-
);
31+
const initMessage = useRef<string>(null!);
32+
initMessage.current = props.initMessage;
33+
const prompt = useRef<string>(null!);
34+
prompt.current = props.prompt;
35+
const promptMore = useRef<string>(null!);
36+
promptMore.current = props.promptMore || props.prompt;
37+
const sendCommand = useRef<(command: string) => Promise<TerminalOutput[]>>(null!);
38+
sendCommand.current = props.sendCommand;
39+
const checkSyntax = useRef<(code: string) => Promise<SyntaxStatus>>(null!);
40+
checkSyntax.current = props.checkSyntax || (async () => "complete");
2941

3042
useEffect(() => {
3143
if (terminalInstanceRef.current && termReady && props.ready) {
3244
// 初期メッセージとプロンプトを表示
33-
terminalInstanceRef.current.writeln(initMessage);
34-
terminalInstanceRef.current.write(prompt);
45+
terminalInstanceRef.current.writeln(initMessage.current);
46+
terminalInstanceRef.current.write(prompt.current);
3547
}
36-
}, [initMessage, prompt, props.ready, termReady]);
48+
}, [props.ready, termReady]);
3749

3850
// ターミナルの初期化処理
3951
useEffect(() => {
@@ -67,29 +79,38 @@ export function TerminalComponent(props: TerminalComponentProps) {
6779
}
6880
}
6981
// 出力が終わったらプロンプトを表示
70-
term.write(prompt);
82+
term.write(prompt.current);
7183
};
7284

7385
// キー入力のハンドリング
74-
const onDataHandler = term.onData((key) => {
86+
const onDataHandler = term.onData(async (key) => {
7587
const code = key.charCodeAt(0);
7688

89+
// inputBufferは必ず1行以上ある状態にする
7790
if (code === 13) {
7891
// Enter
79-
term.writeln("");
80-
if (inputBuffer.current.trim().length > 0) {
81-
sendCommand.current(inputBuffer.current).then(onOutput);
82-
inputBuffer.current = "";
92+
const hasContent = inputBuffer.current[inputBuffer.current.length - 1].trim().length > 0;
93+
const status = await checkSyntax.current(inputBuffer.current.join("\n"));
94+
if ((inputBuffer.current.length === 1 && status === "incomplete") || (inputBuffer.current.length >= 2 && hasContent)) {
95+
// 次の行に続く
96+
term.writeln("");
97+
term.write(promptMore.current);
98+
inputBuffer.current.push("");
99+
}else{
100+
// 実行
101+
term.writeln("");
102+
const outputs = await sendCommand.current(inputBuffer.current.join("\n").trim());
103+
onOutput(outputs);
104+
inputBuffer.current = [""];
83105
}
84-
// 新しいプロンプトは外部からのoutputを待ってから表示する
85106
} else if (code === 127) {
86107
// Backspace
87-
if (inputBuffer.current.length > 0) {
108+
if (inputBuffer.current[inputBuffer.current.length - 1].length > 0) {
88109
term.write("\b \b");
89-
inputBuffer.current = inputBuffer.current.slice(0, -1);
110+
inputBuffer.current[inputBuffer.current.length - 1] = inputBuffer.current[inputBuffer.current.length - 1].slice(0, -1);
90111
}
91112
} else if (code >= 32) {
92-
inputBuffer.current += key;
113+
inputBuffer.current[inputBuffer.current.length - 1] += key;
93114
term.write(key);
94115
}
95116
});
@@ -99,7 +120,7 @@ export function TerminalComponent(props: TerminalComponentProps) {
99120
onDataHandler.dispose();
100121
term.dispose();
101122
};
102-
}, [initMessage, prompt]);
123+
}, [initMessage, prompt, promptMore]);
103124

104125
return <div ref={terminalRef} style={{ width: "100%", height: "400px" }} />;
105126
}

0 commit comments

Comments
 (0)