From d5f2ffe036de17647ecf5fb662efdf587035b02d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 09:11:19 +0000 Subject: [PATCH 1/2] Initial plan From ee5ab639fc9d28b56dda601f650b3a6d4bc13737 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 09:23:20 +0000 Subject: [PATCH 2/2] refactor: pass UpdatedFile via onOutput callback instead of writeFile Co-authored-by: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> --- app/terminal/exec.tsx | 7 ++++- app/terminal/page.tsx | 7 +---- app/terminal/repl.tsx | 12 ++++++- app/terminal/runtime.tsx | 10 ++++-- app/terminal/tests.ts | 45 ++++++++++++++++----------- app/terminal/typescript/runtime.tsx | 21 ++++++------- app/terminal/wandbox/runtime.tsx | 6 ++-- app/terminal/worker/jsEval.worker.ts | 15 +++------ app/terminal/worker/pyodide.worker.ts | 20 ++++++------ app/terminal/worker/ruby.worker.ts | 21 +++++++------ app/terminal/worker/runtime.tsx | 42 +++++++++++-------------- 11 files changed, 111 insertions(+), 95 deletions(-) diff --git a/app/terminal/exec.tsx b/app/terminal/exec.tsx index eed8414f..7e7b34e3 100644 --- a/app/terminal/exec.tsx +++ b/app/terminal/exec.tsx @@ -32,7 +32,7 @@ export function ExecFile(props: ExecProps) { } }, }); - const { files, clearExecResult, addExecOutput } = useEmbedContext(); + const { files, clearExecResult, addExecOutput, writeFile } = useEmbedContext(); const { ready, runFiles, getCommandlineStr, runtimeInfo, interrupt } = useRuntime(props.language); @@ -52,6 +52,10 @@ export function ExecFile(props: ExecProps) { clearExecResult(filenameKey); let isFirstOutput = true; await runFiles(props.filenames, files, (output) => { + if (output.type === "file") { + writeFile({ [output.filename]: output.content }); + return; + } addExecOutput(filenameKey, output); if (isFirstOutput) { // Clear "実行中です..." message only on first output @@ -77,6 +81,7 @@ export function ExecFile(props: ExecProps) { runFiles, clearExecResult, addExecOutput, + writeFile, terminalInstanceRef, props.language, files, diff --git a/app/terminal/page.tsx b/app/terminal/page.tsx index a5dcafb3..afce4ebd 100644 --- a/app/terminal/page.tsx +++ b/app/terminal/page.tsx @@ -5,7 +5,6 @@ import "mocha/mocha.css"; import { Fragment, useEffect, useRef, useState } from "react"; import { useWandbox } from "./wandbox/runtime"; import { RuntimeContext, RuntimeLang } from "./runtime"; -import { useEmbedContext } from "./embedContext"; import { defineTests } from "./tests"; import { usePyodide } from "./worker/pyodide"; import { useRuby } from "./worker/ruby"; @@ -220,10 +219,6 @@ function MochaTest() { const [mochaState, setMochaState] = useState<"idle" | "running" | "finished">( "idle" ); - const { files } = useEmbedContext(); - const filesRef = useRef(files); - filesRef.current = files; - const runTest = async () => { if (typeof window !== "undefined") { setMochaState("running"); @@ -234,7 +229,7 @@ function MochaTest() { for (const lang of Object.keys(runtimeRef.current) as RuntimeLang[]) { runtimeRef.current[lang].init?.(); - defineTests(lang, runtimeRef, filesRef); + defineTests(lang, runtimeRef); } const runner = mocha.run(); diff --git a/app/terminal/repl.tsx b/app/terminal/repl.tsx index bca122bf..af245043 100644 --- a/app/terminal/repl.tsx +++ b/app/terminal/repl.tsx @@ -82,7 +82,7 @@ export function ReplTerminal({ language, initContent, }: ReplComponentProps) { - const { addReplCommand, addReplOutput } = useEmbedContext(); + const { addReplCommand, addReplOutput, writeFile } = useEmbedContext(); const [Prism, setPrism] = useState(null); useEffect(() => { @@ -229,6 +229,10 @@ export function ReplTerminal({ let executionDone = false; await runtimeMutex.runExclusive(async () => { await runCommand(command, (output) => { + if (output.type === "file") { + writeFile({ [output.filename]: output.content }); + return; + } if (executionDone) { // すでに完了していて次のコマンドのプロンプトが出ている場合、その前に挿入 updateBuffer(null, () => { @@ -285,6 +289,7 @@ export function ReplTerminal({ runtimeMutex, runCommand, handleOutput, + writeFile, tabSize, addReplCommand, addReplOutput, @@ -346,6 +351,10 @@ export function ReplTerminal({ for (const cmd of initCommand!) { const outputs: ReplOutput[] = []; await runCommand(cmd.command, (output) => { + if (output.type === "file") { + writeFile({ [output.filename]: output.content }); + return; + } outputs.push(output); }); initCommandResult.push({ @@ -380,6 +389,7 @@ export function ReplTerminal({ runtimeMutex, updateBuffer, handleOutput, + writeFile, termReady, terminalInstanceRef, Prism, diff --git a/app/terminal/runtime.tsx b/app/terminal/runtime.tsx index 5fe2e75b..28eace76 100644 --- a/app/terminal/runtime.tsx +++ b/app/terminal/runtime.tsx @@ -12,6 +12,12 @@ import { WorkerProvider } from "./worker/runtime"; import { TypeScriptProvider, useTypeScript } from "./typescript/runtime"; import { MarkdownLang } from "@/[lang]/[pageId]/styledSyntaxHighlighter"; +export interface UpdatedFile { + type: "file"; + filename: string; + content: string; +} + /** * Common runtime context interface for different languages * @@ -26,7 +32,7 @@ export interface RuntimeContext { // repl runCommand?: ( command: string, - onOutput: (output: ReplOutput) => void + onOutput: (output: ReplOutput | UpdatedFile) => void ) => Promise; checkSyntax?: (code: string) => Promise; splitReplExamples?: (content: string) => ReplCommand[]; @@ -34,7 +40,7 @@ export interface RuntimeContext { runFiles: ( filenames: string[], files: Readonly>, - onOutput: (output: ReplOutput) => void + onOutput: (output: ReplOutput | UpdatedFile) => void ) => Promise; getCommandlineStr?: (filenames: string[]) => string; runtimeInfo?: RuntimeInfo; diff --git a/app/terminal/tests.ts b/app/terminal/tests.ts index 60a7701a..3f24cc49 100644 --- a/app/terminal/tests.ts +++ b/app/terminal/tests.ts @@ -1,12 +1,11 @@ import { expect } from "chai"; import { RefObject } from "react"; -import { emptyMutex, RuntimeContext, RuntimeLang } from "./runtime"; +import { emptyMutex, RuntimeContext, RuntimeLang, UpdatedFile } from "./runtime"; import { ReplOutput } from "./repl"; export function defineTests( lang: RuntimeLang, - runtimeRef: RefObject>, - filesRef: RefObject>> + runtimeRef: RefObject> ) { describe(`${lang} Runtime`, function () { this.timeout( @@ -46,7 +45,7 @@ export function defineTests( const outputs: ReplOutput[] = []; await (runtimeRef.current[lang].mutex || emptyMutex).runExclusive(() => runtimeRef.current[lang].runCommand!(printCode, (output) => { - outputs.push(output); + if (output.type !== "file") outputs.push(output); }) ); console.log(`${lang} REPL stdout test: `, outputs); @@ -79,7 +78,7 @@ export function defineTests( await runtimeRef.current[lang].runCommand!( printIntVarCode, (output) => { - outputs.push(output); + if (output.type !== "file") outputs.push(output); } ); } @@ -109,7 +108,7 @@ export function defineTests( const outputs: ReplOutput[] = []; await (runtimeRef.current[lang].mutex || emptyMutex).runExclusive(() => runtimeRef.current[lang].runCommand!(errorCode, (output) => { - outputs.push(output); + if (output.type !== "file") outputs.push(output); }) ); console.log(`${lang} REPL error capture test: `, outputs); @@ -153,7 +152,7 @@ export function defineTests( const outputs: ReplOutput[] = []; await (runtimeRef.current[lang].mutex || emptyMutex).runExclusive(() => runtimeRef.current[lang].runCommand!(printIntVarCode, (output) => { - outputs.push(output); + if (output.type !== "file") outputs.push(output); }) ); console.log(`${lang} REPL interrupt recovery test: `, outputs); @@ -176,12 +175,17 @@ export function defineTests( if (!writeCode) { this.skip(); } + const updatedFiles: UpdatedFile[] = []; await (runtimeRef.current[lang].mutex || emptyMutex).runExclusive(() => - runtimeRef.current[lang].runCommand!(writeCode, () => {}) + runtimeRef.current[lang].runCommand!(writeCode, (output) => { + if (output.type === "file") { + updatedFiles.push(output); + } + }) ); - // wait for files to be updated - await new Promise((resolve) => setTimeout(resolve, 100)); - expect(filesRef.current[targetFile]).to.equal(msg); + expect( + updatedFiles.find((f) => f.filename === targetFile)?.content + ).to.equal(msg); }); }); @@ -211,7 +215,7 @@ export function defineTests( [filename]: code, }, (output) => { - outputs.push(output); + if (output.type !== "file") outputs.push(output); } ); console.log(`${lang} single file stdout test: `, outputs); @@ -247,7 +251,7 @@ export function defineTests( [filename]: code, }, (output) => { - outputs.push(output); + if (output.type !== "file") outputs.push(output); } ); console.log(`${lang} single file error capture test: `, outputs); @@ -305,7 +309,7 @@ export function defineTests( } const outputs: ReplOutput[] = []; await runtimeRef.current[lang].runFiles(execFiles, codes, (output) => { - outputs.push(output); + if (output.type !== "file") outputs.push(output); }); console.log(`${lang} multifile stdout test: `, outputs); expect(outputs).to.be.deep.include({ type: "stdout", message: msg }); @@ -333,16 +337,21 @@ export function defineTests( if (!filename || !code) { this.skip(); } + const updatedFiles: UpdatedFile[] = []; await runtimeRef.current[lang].runFiles( [filename], { [filename]: code, }, - () => {} + (output) => { + if (output.type === "file") { + updatedFiles.push(output); + } + } ); - // wait for files to be updated - await new Promise((resolve) => setTimeout(resolve, 100)); - expect(filesRef.current[targetFile]).to.equal(msg); + expect( + updatedFiles.find((f) => f.filename === targetFile)?.content + ).to.equal(msg); }); }); }); diff --git a/app/terminal/typescript/runtime.tsx b/app/terminal/typescript/runtime.tsx index 8637b002..76fd1f14 100644 --- a/app/terminal/typescript/runtime.tsx +++ b/app/terminal/typescript/runtime.tsx @@ -11,9 +11,8 @@ import { useMemo, useState, } from "react"; -import { useEmbedContext } from "../embedContext"; import { ReplOutput } from "../repl"; -import { RuntimeContext, RuntimeInfo } from "../runtime"; +import { RuntimeContext, RuntimeInfo, UpdatedFile } from "../runtime"; export const compilerOptions: CompilerOptions = { lib: ["ESNext", "WebWorker"], @@ -93,12 +92,11 @@ export function useTypeScript(jsEval: RuntimeContext): RuntimeContext { jsInit?.(); }, [tsInit, jsInit]); - const { writeFile } = useEmbedContext(); const runFiles = useCallback( async ( filenames: string[], files: Readonly>, - onOutput: (output: ReplOutput) => void + onOutput: (output: ReplOutput | UpdatedFile) => void ) => { if (tsEnv === null || typeof window === "undefined") { onOutput({ type: "error", message: "TypeScript is not ready yet." }); @@ -137,25 +135,26 @@ export function useTypeScript(jsEval: RuntimeContext): RuntimeContext { } const emitOutput = tsEnv.languageService.getEmitOutput(filenames[0]); - files = await writeFile( - Object.fromEntries( - emitOutput.outputFiles.map((of) => [of.name, of.text]) - ) + const emittedFiles: Record = Object.fromEntries( + emitOutput.outputFiles.map((of) => [of.name, of.text]) ); + for (const [filename, content] of Object.entries(emittedFiles)) { + onOutput({ type: "file", filename, content }); + } - for (const filename of Object.keys(files)) { + for (const filename of Object.keys(emittedFiles)) { tsEnv.deleteFile(filename); } console.log(emitOutput); await jsEval.runFiles( [emitOutput.outputFiles[0].name], - files, + { ...files, ...emittedFiles }, onOutput ); } }, - [tsEnv, writeFile, jsEval] + [tsEnv, jsEval] ); const runtimeInfo = useMemo( diff --git a/app/terminal/wandbox/runtime.tsx b/app/terminal/wandbox/runtime.tsx index c7d069bf..6c0b62f5 100644 --- a/app/terminal/wandbox/runtime.tsx +++ b/app/terminal/wandbox/runtime.tsx @@ -10,7 +10,7 @@ import { import useSWR from "swr"; import { compilerInfoFetcher, SelectedCompiler } from "./api"; import { cppRunFiles, selectCppCompiler } from "./cpp"; -import { RuntimeContext, RuntimeInfo, RuntimeLang } from "../runtime"; +import { RuntimeContext, RuntimeInfo, RuntimeLang, UpdatedFile } from "../runtime"; import { ReplOutput } from "../repl"; import { rustRunFiles, selectRustCompiler } from "./rust"; @@ -26,7 +26,7 @@ interface IWandboxContext { ) => ( filenames: string[], files: Readonly>, - onOutput: (output: ReplOutput) => void + onOutput: (output: ReplOutput | UpdatedFile) => void ) => Promise; runtimeInfo: Record | undefined, } @@ -70,7 +70,7 @@ export function WandboxProvider({ children }: { children: ReactNode }) { async ( filenames: string[], files: Readonly>, - onOutput: (output: ReplOutput) => void + onOutput: (output: ReplOutput | UpdatedFile) => void ) => { if (!selectedCompiler) { onOutput({ type: "error", message: "Wandbox is not ready yet." }); diff --git a/app/terminal/worker/jsEval.worker.ts b/app/terminal/worker/jsEval.worker.ts index 712a8b8b..1aee0b37 100644 --- a/app/terminal/worker/jsEval.worker.ts +++ b/app/terminal/worker/jsEval.worker.ts @@ -3,6 +3,7 @@ import { expose } from "comlink"; import type { ReplOutput } from "../repl"; import type { WorkerCapabilities } from "./runtime"; +import type { UpdatedFile } from "../runtime"; import inspect from "object-inspect"; import { replLikeEval, checkSyntax } from "@my-code/js-eval"; @@ -37,10 +38,8 @@ async function init(/*_interruptBuffer?: Uint8Array*/): Promise<{ async function runCode( code: string, - onOutput: (output: ReplOutput) => void -): Promise<{ - updatedFiles: Record; -}> { + onOutput: (output: ReplOutput | UpdatedFile) => void +): Promise { currentOutputCallback = onOutput; try { const result = await replLikeEval(code); @@ -63,15 +62,13 @@ async function runCode( }); } } - - return { updatedFiles: {} as Record }; } function runFile( name: string, files: Record, - onOutput: (output: ReplOutput) => void -): { updatedFiles: Record } { + onOutput: (output: ReplOutput | UpdatedFile) => void +): void { // pyodide worker などと異なり、複数ファイルを読み込んでimportのようなことをするのには対応していません。 currentOutputCallback = onOutput; try { @@ -91,8 +88,6 @@ function runFile( }); } } - - return { updatedFiles: {} as Record }; } async function restoreState(commands: string[]): Promise { diff --git a/app/terminal/worker/pyodide.worker.ts b/app/terminal/worker/pyodide.worker.ts index 9464b0c8..63d14027 100644 --- a/app/terminal/worker/pyodide.worker.ts +++ b/app/terminal/worker/pyodide.worker.ts @@ -8,6 +8,7 @@ import { version as pyodideVersion } from "pyodide/package.json"; import type { PyCallable } from "pyodide/ffi"; import type { WorkerCapabilities } from "./runtime"; import type { ReplOutput } from "../repl"; +import type { UpdatedFile } from "../runtime"; import execfile_py from "./pyodide/execfile.py?raw"; import check_syntax_py from "./pyodide/check_syntax.py?raw"; @@ -62,10 +63,8 @@ async function init( async function runCode( code: string, - onOutput: (output: ReplOutput) => void -): Promise<{ - updatedFiles: Record; -}> { + onOutput: (output: ReplOutput | UpdatedFile) => void +): Promise { if (!pyodide) { throw new Error("Pyodide not initialized"); } @@ -110,15 +109,16 @@ async function runCode( } const updatedFiles = readAllFiles(); - - return { updatedFiles }; + for (const [filename, content] of Object.entries(updatedFiles)) { + onOutput({ type: "file", filename, content }); + } } async function runFile( name: string, files: Record, - onOutput: (output: ReplOutput) => void -): Promise<{ updatedFiles: Record }> { + onOutput: (output: ReplOutput | UpdatedFile) => void +): Promise { if (!pyodide) { throw new Error("Pyodide not initialized"); } @@ -166,7 +166,9 @@ async function runFile( } const updatedFiles = readAllFiles(); - return { updatedFiles }; + for (const [filename, content] of Object.entries(updatedFiles)) { + onOutput({ type: "file", filename, content }); + } } async function checkSyntax( diff --git a/app/terminal/worker/ruby.worker.ts b/app/terminal/worker/ruby.worker.ts index 5177187d..dc6c5b84 100644 --- a/app/terminal/worker/ruby.worker.ts +++ b/app/terminal/worker/ruby.worker.ts @@ -6,6 +6,7 @@ import { DefaultRubyVM } from "@ruby/wasm-wasi/dist/browser"; import type { RubyVM } from "@ruby/wasm-wasi/dist/vm"; import type { WorkerCapabilities } from "./runtime"; import type { ReplOutput, ReplOutputType } from "../repl"; +import type { UpdatedFile } from "../runtime"; import init_rb from "./ruby/init.rb?raw"; @@ -103,10 +104,8 @@ function formatRubyError(error: unknown, isFile: boolean): string { async function runCode( code: string, - onOutput: (output: ReplOutput) => void -): Promise<{ - updatedFiles: Record; -}> { + onOutput: (output: ReplOutput | UpdatedFile) => void +): Promise { if (!rubyVM) { throw new Error("Ruby VM not initialized"); } @@ -141,15 +140,16 @@ async function runCode( } const updatedFiles = readAllFiles(); - - return { updatedFiles }; + for (const [filename, content] of Object.entries(updatedFiles)) { + onOutput({ type: "file", filename, content }); + } } async function runFile( name: string, files: Record, - onOutput: (output: ReplOutput) => void -): Promise<{ updatedFiles: Record }> { + onOutput: (output: ReplOutput | UpdatedFile) => void +): Promise { if (!rubyVM) { throw new Error("Ruby VM not initialized"); } @@ -191,8 +191,9 @@ async function runFile( } const updatedFiles = readAllFiles(); - - return { updatedFiles }; + for (const [filename, content] of Object.entries(updatedFiles)) { + onOutput({ type: "file", filename, content }); + } } async function checkSyntax( diff --git a/app/terminal/worker/runtime.tsx b/app/terminal/worker/runtime.tsx index 6ada8525..fee97029 100644 --- a/app/terminal/worker/runtime.tsx +++ b/app/terminal/worker/runtime.tsx @@ -10,10 +10,9 @@ import { useState, } from "react"; import { wrap, Remote, proxy } from "comlink"; -import { RuntimeContext, RuntimeLang } from "../runtime"; +import { RuntimeContext, RuntimeLang, UpdatedFile } from "../runtime"; import { ReplOutput, SyntaxStatus } from "../repl"; import { Mutex, MutexInterface } from "async-mutex"; -import { useEmbedContext } from "../embedContext"; type WorkerLang = "python" | "ruby" | "javascript"; export type WorkerCapabilities = { @@ -27,13 +26,13 @@ export interface WorkerAPI { ): Promise<{ capabilities: WorkerCapabilities }>; runCode( code: string, - onOutput: (output: ReplOutput) => void - ): Promise<{ updatedFiles: Record }>; + onOutput: (output: ReplOutput | UpdatedFile) => void + ): Promise; runFile( name: string, files: Record, - onOutput: (output: ReplOutput) => void - ): Promise<{ updatedFiles: Record }>; + onOutput: (output: ReplOutput | UpdatedFile) => void + ): Promise; checkSyntax(code: string): Promise<{ status: SyntaxStatus }>; restoreState(commands: string[]): Promise; } @@ -51,9 +50,8 @@ export function WorkerProvider({ const workerApiRef = useRef | null>(null); const [ready, setReady] = useState(false); const mutex = useMemo(() => new Mutex(), []); - const { writeFile } = useEmbedContext(); - - // Worker-specific state + const [doInit, setDoInit] = useState(false); + const init = useCallback(() => setDoInit(true), []); const interruptBuffer = useRef(null); const capabilities = useRef(null); const commandHistory = useRef([]); @@ -98,9 +96,6 @@ export function WorkerProvider({ capabilities.current = payload.capabilities; }, [lang, mutex]); - const [doInit, setDoInit] = useState(false); - const init = useCallback(() => setDoInit(true), []); - // Helper function to wrap worker API calls and track pending promises // This ensures promises are rejected when the worker is terminated const trackPromise = useCallback((promise: Promise): Promise => { @@ -172,7 +167,7 @@ export function WorkerProvider({ }, [initializeWorker, mutex]); const runCommand = useCallback( - async (code: string, onOutput: (output: ReplOutput) => void): Promise => { + async (code: string, onOutput: (output: ReplOutput | UpdatedFile) => void): Promise => { if (!mutex.isLocked()) { throw new Error(`mutex of context must be locked for runCommand`); } @@ -193,18 +188,18 @@ export function WorkerProvider({ try { const output: ReplOutput[] = []; - const { updatedFiles } = await trackPromise( + await trackPromise( workerApiRef.current.runCode( code, - proxy((item: ReplOutput) => { - output.push(item); + proxy((item: ReplOutput | UpdatedFile) => { + if (item.type !== "file") { + output.push(item); + } onOutput(item); }) ) ); - writeFile(updatedFiles); - // Save command to history if interrupt method is 'restart' if (capabilities.current?.interrupt === "restart") { const hasError = output.some((o) => o.type === "error"); @@ -220,7 +215,7 @@ export function WorkerProvider({ } } }, - [ready, writeFile, mutex, trackPromise] + [ready, mutex, trackPromise] ); const checkSyntax = useCallback( @@ -238,7 +233,7 @@ export function WorkerProvider({ async ( filenames: string[], files: Readonly>, - onOutput: (output: ReplOutput) => void + onOutput: (output: ReplOutput | UpdatedFile) => void ): Promise => { if (filenames.length !== 1) { onOutput({ @@ -261,19 +256,18 @@ export function WorkerProvider({ interruptBuffer.current[0] = 0; } return mutex.runExclusive(async () => { - const { updatedFiles } = await trackPromise( + await trackPromise( workerApiRef.current!.runFile( filenames[0], files, - proxy((item: ReplOutput) => { + proxy((item: ReplOutput | UpdatedFile) => { onOutput(item); }) ) ); - writeFile(updatedFiles); }); }, - [ready, writeFile, mutex, trackPromise] + [ready, mutex, trackPromise] ); return (