feat(playground): implement synchronous interactive input for client-side C and Python
- Unbuffer C stdout via setbuf(stdout, NULL) so prompts flush before blocking on input - Break Emscripten FS.read loop with justFinishedLine and upgrade float/%f to double/%lf for PicoC compatibility - Provide reactive waitingForInput state with auto-focus, dynamic placeholder, and highlighted send button in ConsolePanel - Enable full multi-step interactive stdin flow in browser execution mode via SharedArrayBuffer and Atomics
This commit is contained in:
parent
0decd6ce86
commit
c765399715
|
|
@ -8,19 +8,26 @@ let charIndex: number = 0;
|
|||
let justFinishedLine: boolean = false;
|
||||
let prefilledStdin: string = '';
|
||||
let prefilledIdx: number = 0;
|
||||
let stdoutBuf: number[] = [];
|
||||
let stderrBuf: number[] = [];
|
||||
|
||||
function flushStdout(): void {
|
||||
if (stdoutBuf.length > 0) {
|
||||
const text = new TextDecoder().decode(new Uint8Array(stdoutBuf));
|
||||
self.postMessage({ type: 'stdout', text } satisfies WorkerMessage);
|
||||
stdoutBuf = [];
|
||||
}
|
||||
}
|
||||
|
||||
function flushStderr(): void {
|
||||
if (stderrBuf.length > 0) {
|
||||
const text = new TextDecoder().decode(new Uint8Array(stderrBuf));
|
||||
self.postMessage({ type: 'stderr', text } satisfies WorkerMessage);
|
||||
stderrBuf = [];
|
||||
}
|
||||
}
|
||||
|
||||
function stdinRead(): number | null {
|
||||
if (prefilledStdin.length > 0) {
|
||||
if (prefilledIdx < prefilledStdin.length) {
|
||||
return prefilledStdin.charCodeAt(prefilledIdx++);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!stdinArray || !stdinDataView) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (charIndex < currentLine.length) {
|
||||
return currentLine.charCodeAt(charIndex++);
|
||||
}
|
||||
|
|
@ -30,8 +37,21 @@ function stdinRead(): number | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
if (prefilledIdx < prefilledStdin.length) {
|
||||
return prefilledStdin.charCodeAt(prefilledIdx++);
|
||||
}
|
||||
|
||||
if (!stdinArray || !stdinDataView) {
|
||||
return null;
|
||||
}
|
||||
|
||||
flushStdout();
|
||||
|
||||
// If main thread has not already written input (flag != 1), notify UI and wait
|
||||
if (Atomics.load(stdinArray, 0) === 0) {
|
||||
self.postMessage({ type: 'waiting_for_input' } satisfies WorkerMessage);
|
||||
Atomics.wait(stdinArray, 0, 0);
|
||||
}
|
||||
|
||||
const len = Atomics.load(stdinArray, 1);
|
||||
if (len <= 0) {
|
||||
|
|
@ -52,6 +72,23 @@ function stdinRead(): number | null {
|
|||
return currentLine.charCodeAt(charIndex++);
|
||||
}
|
||||
|
||||
function prepareCCode(code: string): string {
|
||||
if (!code.includes('setbuf(') && !code.includes('setvbuf(')) {
|
||||
const mainRegex = /\b(int|void)?\s*main\s*\([^)]*\)\s*\{/;
|
||||
if (mainRegex.test(code)) {
|
||||
code = code.replace(mainRegex, (match) => `${match}\n setbuf(stdout, NULL);`);
|
||||
}
|
||||
}
|
||||
// In PicoC, 32-bit float in scanf("%f") writes 0 due to 64-bit JS numbers representation;
|
||||
// upgrading to double/%lf allows normal float code to execute accurately.
|
||||
code = code.replace(/\bfloat\b/g, 'double');
|
||||
code = code.replace(/scanf\s*\(\s*"([^"]*)"/g, (match, fmt) => {
|
||||
const upgradedFmt = fmt.replace(/%f/g, '%lf');
|
||||
return `scanf("${upgradedFmt}"`;
|
||||
});
|
||||
return code;
|
||||
}
|
||||
|
||||
self.onmessage = (e: MessageEvent<RunRequest>) => {
|
||||
const { code, stdinBuffer, stdinText } = e.data;
|
||||
const start = performance.now();
|
||||
|
|
@ -69,37 +106,25 @@ self.onmessage = (e: MessageEvent<RunRequest>) => {
|
|||
currentLine = '';
|
||||
charIndex = 0;
|
||||
justFinishedLine = false;
|
||||
stdoutBuf = [];
|
||||
stderrBuf = [];
|
||||
|
||||
try {
|
||||
let stdoutBuf: number[] = [];
|
||||
let stderrBuf: number[] = [];
|
||||
let callbackError = '';
|
||||
|
||||
let hasExecuted = false;
|
||||
const execute = () => {
|
||||
if (hasExecuted) return;
|
||||
hasExecuted = true;
|
||||
try {
|
||||
pc.runc(code, (text: string) => {
|
||||
pc.runc(prepareCCode(code), (text: string) => {
|
||||
if (text) {
|
||||
callbackError += text + '\n';
|
||||
self.postMessage({ type: 'stdout', text: text + '\n' } satisfies WorkerMessage);
|
||||
}
|
||||
});
|
||||
if (stdoutBuf.length > 0) {
|
||||
const remaining = new TextDecoder().decode(new Uint8Array(stdoutBuf));
|
||||
self.postMessage({ type: 'stdout', text: remaining } satisfies WorkerMessage);
|
||||
stdoutBuf = [];
|
||||
}
|
||||
if (stderrBuf.length > 0) {
|
||||
const remaining = new TextDecoder().decode(new Uint8Array(stderrBuf));
|
||||
self.postMessage({ type: 'stderr', text: remaining } satisfies WorkerMessage);
|
||||
stderrBuf = [];
|
||||
}
|
||||
flushStdout();
|
||||
flushStderr();
|
||||
self.postMessage({
|
||||
type: 'done',
|
||||
success: !callbackError,
|
||||
error: callbackError || undefined,
|
||||
success: true,
|
||||
executionTimeMs: performance.now() - start
|
||||
} satisfies WorkerMessage);
|
||||
} catch (err: any) {
|
||||
|
|
@ -143,7 +168,6 @@ self.onmessage = (e: MessageEvent<RunRequest>) => {
|
|||
},
|
||||
consoleWrite: (text: string) => {
|
||||
if (text) {
|
||||
callbackError += text + '\n';
|
||||
self.postMessage({ type: 'stdout', text: text + '\n' } satisfies WorkerMessage);
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -73,6 +73,43 @@ int main() {
|
|||
// PicoC catches syntax errors either via throw or internal error handler
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it('evaluates C scanf and prints interactive response', async () => {
|
||||
let captured = '';
|
||||
const inputData = 'Budi\n';
|
||||
let inputIdx = 0;
|
||||
await new Promise<void>((resolve) => {
|
||||
const pc = (picoc as any)({
|
||||
stdin: () => {
|
||||
if (inputIdx < inputData.length) {
|
||||
return inputData.charCodeAt(inputIdx++);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
stdout: (c: number) => {
|
||||
captured += String.fromCharCode(c);
|
||||
}
|
||||
});
|
||||
pc.consoleWrite = (text: string) => {
|
||||
captured += text;
|
||||
};
|
||||
pc.onRuntimeInitialized = () => {
|
||||
const code = `
|
||||
#include <stdio.h>
|
||||
int main() {
|
||||
char nama[50];
|
||||
scanf("%s", nama);
|
||||
printf("Halo, %s!\\n", nama);
|
||||
return 0;
|
||||
}
|
||||
`;
|
||||
pc.runc(code, () => {});
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
|
||||
expect(captured).toContain('Halo, Budi!');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MicroPython WASM Engine Direct Evaluation', () => {
|
||||
|
|
@ -103,6 +140,70 @@ print("LIST:", kuadrat_genap(6))
|
|||
|
||||
expect(lines.join('').trim()).toBe('LIST: [0, 4, 16]');
|
||||
});
|
||||
|
||||
it('evaluates Python input with prompt forwarding to readline', async () => {
|
||||
const lines: string[] = [];
|
||||
let receivedPrompt = '';
|
||||
const mp = await loadMicroPython({
|
||||
stdout: (line: string) => lines.push(line),
|
||||
linebuffer: true
|
||||
});
|
||||
|
||||
mp.registerJsModule('_elemes_io', {
|
||||
readline: (prompt?: string) => {
|
||||
receivedPrompt = prompt || '';
|
||||
return 'Budi';
|
||||
}
|
||||
});
|
||||
|
||||
mp.runPython(`
|
||||
import _elemes_io
|
||||
import builtins
|
||||
|
||||
def _interactive_input(prompt=''):
|
||||
return _elemes_io.readline(prompt)
|
||||
|
||||
builtins.input = _interactive_input
|
||||
|
||||
nama = input("Siapa nama kamu? ")
|
||||
print(f"Halo, {nama}!")
|
||||
`);
|
||||
|
||||
expect(receivedPrompt).toBe('Siapa nama kamu? ');
|
||||
expect(lines.join('').trim()).toBe('Halo, Budi!');
|
||||
});
|
||||
|
||||
it('evaluates multiple Python input calls sequentially', async () => {
|
||||
const lines: string[] = [];
|
||||
const inputs = ['Budi', '25'];
|
||||
let inputIdx = 0;
|
||||
const mp = await loadMicroPython({
|
||||
stdout: (line: string) => lines.push(line),
|
||||
linebuffer: true
|
||||
});
|
||||
|
||||
mp.registerJsModule('_elemes_io', {
|
||||
readline: () => {
|
||||
return inputs[inputIdx++] || '';
|
||||
}
|
||||
});
|
||||
|
||||
mp.runPython(`
|
||||
import _elemes_io
|
||||
import builtins
|
||||
|
||||
def _interactive_input(prompt=''):
|
||||
return _elemes_io.readline(prompt)
|
||||
|
||||
builtins.input = _interactive_input
|
||||
|
||||
nama = input()
|
||||
umur = input()
|
||||
print(f"Nama: {nama}, Umur: {umur}")
|
||||
`);
|
||||
|
||||
expect(lines.join('').trim()).toBe('Nama: Budi, Umur: 25');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ClientRunner Interface and Options', () => {
|
||||
|
|
@ -111,6 +212,21 @@ describe('ClientRunner Interface and Options', () => {
|
|||
expect(typeof runner.isSharedArrayBufferSupported).toBe('boolean');
|
||||
});
|
||||
|
||||
it('sendInput handles strings larger than SAB buffer without throwing RangeError', () => {
|
||||
const runner = new ClientRunner();
|
||||
if (runner.isSharedArrayBufferSupported) {
|
||||
const largeString = 'A'.repeat(5000);
|
||||
(runner as any).ensureSAB();
|
||||
expect(() => {
|
||||
runner.sendInput(largeString);
|
||||
}).not.toThrow();
|
||||
const sab = (runner as any).stdinSAB;
|
||||
const arr = new Int32Array(sab);
|
||||
const len = Atomics.load(arr, 1);
|
||||
expect(len).toBeLessThanOrEqual(sab.byteLength - 8);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns error when invoked in non-browser environment', async () => {
|
||||
const runner = new ClientRunner();
|
||||
// In node vitest, window or Worker might not be standard browser window
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import CWorker from './c-worker.ts?worker';
|
|||
import PyWorker from './python-worker.ts?worker';
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 5000;
|
||||
const INTERACTIVE_TIMEOUT_MS = 60000; // 60s for human interactive typing
|
||||
const SAB_SIZE = 4096; // 4 KB for one line of stdin buffer
|
||||
|
||||
export interface RunOptions {
|
||||
|
|
@ -18,6 +19,7 @@ export class ClientRunner {
|
|||
private cWorker: Worker | null = null;
|
||||
private pyWorker: Worker | null = null;
|
||||
private stdinSAB: SharedArrayBuffer | null = null;
|
||||
private onInputSent?: () => void;
|
||||
|
||||
onWaitingForInput?: () => void;
|
||||
onStdout?: (text: string) => void;
|
||||
|
|
@ -44,10 +46,13 @@ export class ClientRunner {
|
|||
const arr = new Int32Array(this.stdinSAB);
|
||||
const view = new Uint8Array(this.stdinSAB);
|
||||
const encoded = new TextEncoder().encode(text.endsWith('\n') ? text : text + '\n');
|
||||
view.set(encoded, 8);
|
||||
Atomics.store(arr, 1, encoded.length);
|
||||
const maxPayload = this.stdinSAB.byteLength - 8;
|
||||
const payload = encoded.length > maxPayload ? encoded.slice(0, maxPayload) : encoded;
|
||||
view.set(payload, 8);
|
||||
Atomics.store(arr, 1, payload.length);
|
||||
Atomics.store(arr, 0, 1); // flag ready = 1
|
||||
Atomics.notify(arr, 0); // wake up worker
|
||||
this.onInputSent?.();
|
||||
}
|
||||
|
||||
async run(
|
||||
|
|
@ -70,19 +75,40 @@ export class ClientRunner {
|
|||
let output = '';
|
||||
let error = '';
|
||||
let settled = false;
|
||||
let currentTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const worker = this.spawnWorker(lang);
|
||||
const clearTimer = () => {
|
||||
if (currentTimer) {
|
||||
clearTimeout(currentTimer);
|
||||
currentTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
const resetTimer = (durationMs: number, reasonMsg?: string) => {
|
||||
clearTimer();
|
||||
currentTimer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
this.onInputSent = undefined;
|
||||
this.killWorker(lang);
|
||||
resolve({
|
||||
success: false,
|
||||
output: output.trimEnd(),
|
||||
error: `⏱️ Eksekusi dihentikan — batas waktu ${timeoutMs / 1000} detik terlampaui (kemungkinan infinite loop).`
|
||||
error:
|
||||
reasonMsg ??
|
||||
`⏱️ Eksekusi dihentikan — batas waktu ${durationMs / 1000} detik terlampaui (kemungkinan infinite loop).`
|
||||
});
|
||||
}, timeoutMs);
|
||||
}, durationMs);
|
||||
};
|
||||
|
||||
this.onInputSent = () => {
|
||||
if (!settled) {
|
||||
resetTimer(timeoutMs);
|
||||
}
|
||||
};
|
||||
|
||||
const worker = this.spawnWorker(lang);
|
||||
resetTimer(timeoutMs);
|
||||
|
||||
worker.onmessage = (e: MessageEvent<WorkerMessage>) => {
|
||||
const msg = e.data;
|
||||
|
|
@ -95,12 +121,18 @@ export class ClientRunner {
|
|||
this.onStderr?.(msg.text);
|
||||
options.onStderr?.(msg.text);
|
||||
} else if (msg.type === 'waiting_for_input') {
|
||||
// Program sedang menunggu input pengguna — beri waktu lebih longgar untuk mengetik
|
||||
resetTimer(
|
||||
INTERACTIVE_TIMEOUT_MS,
|
||||
`⏱️ Eksekusi dihentikan — waktu tunggu input (${INTERACTIVE_TIMEOUT_MS / 1000} detik) terlampaui.`
|
||||
);
|
||||
this.onWaitingForInput?.();
|
||||
options.onWaitingForInput?.();
|
||||
} else if (msg.type === 'done') {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
clearTimer();
|
||||
this.onInputSent = undefined;
|
||||
resolve({
|
||||
success: msg.success,
|
||||
output: output.trimEnd(),
|
||||
|
|
@ -113,7 +145,8 @@ export class ClientRunner {
|
|||
console.error('[client-runner] Worker onerror event:', errEvent);
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
clearTimer();
|
||||
this.onInputSent = undefined;
|
||||
const details = [
|
||||
errEvent.message,
|
||||
errEvent.filename ? `(${errEvent.filename}:${errEvent.lineno}:${errEvent.colno})` : null,
|
||||
|
|
|
|||
|
|
@ -8,20 +8,24 @@ let stdinDataView: Uint8Array | null = null;
|
|||
let prefilledLines: string[] = [];
|
||||
let prefilledLineIdx = 0;
|
||||
|
||||
function readlineSync(): string {
|
||||
if (prefilledLines.length > 0) {
|
||||
function readlineSync(prompt?: string): string {
|
||||
if (prompt) {
|
||||
self.postMessage({ type: 'stdout', text: prompt } satisfies WorkerMessage);
|
||||
}
|
||||
|
||||
if (prefilledLineIdx < prefilledLines.length) {
|
||||
return prefilledLines[prefilledLineIdx++];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!stdinArray || !stdinDataView) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// If main thread has not already written input (flag != 1), notify UI and wait
|
||||
if (Atomics.load(stdinArray, 0) === 0) {
|
||||
self.postMessage({ type: 'waiting_for_input' } satisfies WorkerMessage);
|
||||
Atomics.wait(stdinArray, 0, 0);
|
||||
}
|
||||
|
||||
const len = Atomics.load(stdinArray, 1);
|
||||
if (len <= 0) {
|
||||
|
|
@ -53,7 +57,7 @@ async function getMicroPython(): Promise<any> {
|
|||
});
|
||||
|
||||
mpInstance.registerJsModule('_elemes_io', {
|
||||
readline: () => readlineSync()
|
||||
readline: (prompt?: string) => readlineSync(prompt)
|
||||
});
|
||||
|
||||
mpInstance.runPython(`
|
||||
|
|
@ -61,9 +65,7 @@ import _elemes_io
|
|||
import builtins
|
||||
|
||||
def _interactive_input(prompt=''):
|
||||
if prompt:
|
||||
print(prompt, end='')
|
||||
return _elemes_io.readline()
|
||||
return _elemes_io.readline(prompt)
|
||||
|
||||
builtins.input = _interactive_input
|
||||
`);
|
||||
|
|
|
|||
|
|
@ -22,15 +22,25 @@ describe('playgroundStore stdinQueue', () => {
|
|||
expect(state.stdinQueue).toEqual([]);
|
||||
});
|
||||
|
||||
it('consumeStdin mengembalikan seluruh antrean + draft, digabung dengan newline, lalu mengosongkan', () => {
|
||||
it('consumeStdin mengembalikan seluruh antrean tanpa memakan draft consoleInput', () => {
|
||||
playgroundStore.enqueueStdin('Budi');
|
||||
playgroundStore.enqueueStdin('17');
|
||||
playgroundStore.setConsoleInput('draft yang belum dikirim');
|
||||
const stdin = playgroundStore.consumeStdin();
|
||||
expect(stdin).toBe('Budi\n17\ndraft yang belum dikirim');
|
||||
expect(stdin).toBe('Budi\n17\n');
|
||||
const state = get(playgroundStore);
|
||||
expect(state.stdinQueue).toEqual([]);
|
||||
expect(state.consoleInput).toBe('');
|
||||
expect(state.consoleInput).toBe('draft yang belum dikirim');
|
||||
});
|
||||
|
||||
it('appendConsole menambahkan id unik pada ConsoleLine', () => {
|
||||
playgroundStore.appendConsole({ type: 'output', text: 'Hello', timestamp: 123 } as any);
|
||||
playgroundStore.appendConsole({ type: 'output', text: 'World', timestamp: 123 } as any);
|
||||
const history = get(playgroundStore).consoleHistory;
|
||||
expect(history.length).toBe(2);
|
||||
expect(history[0].id).toBeDefined();
|
||||
expect(history[1].id).toBeDefined();
|
||||
expect(history[0].id).not.toBe(history[1].id);
|
||||
});
|
||||
|
||||
it('consumeStdin mengembalikan string kosong jika tidak ada baris maupun draft', () => {
|
||||
|
|
@ -136,4 +146,56 @@ describe('playgroundStore session lifecycle', () => {
|
|||
playgroundStore.updateRunStatus('running');
|
||||
expect(get(playgroundStore).runStatus).toBe('running');
|
||||
});
|
||||
|
||||
it('setWaitingForInput mengubah state dan dibersihkan saat finishRun/resetRunSession', () => {
|
||||
playgroundStore.startRunSession('sess-1', 'running');
|
||||
expect(get(playgroundStore).waitingForInput).toBe(false);
|
||||
|
||||
playgroundStore.setWaitingForInput(true);
|
||||
expect(get(playgroundStore).waitingForInput).toBe(true);
|
||||
|
||||
playgroundStore.finishRun('exited');
|
||||
expect(get(playgroundStore).waitingForInput).toBe(false);
|
||||
|
||||
playgroundStore.setWaitingForInput(true);
|
||||
expect(get(playgroundStore).waitingForInput).toBe(true);
|
||||
|
||||
playgroundStore.resetRunSession();
|
||||
expect(get(playgroundStore).waitingForInput).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('playgroundStore default files & content', () => {
|
||||
beforeEach(() => {
|
||||
playgroundStore.reset();
|
||||
});
|
||||
|
||||
it('file default memuat template C dengan scanf dan template Python dengan input', () => {
|
||||
const state = get(playgroundStore);
|
||||
const cFile = state.files.find((f) => f.name === 'main.c');
|
||||
const pyFile = state.files.find((f) => f.name === 'main.py');
|
||||
|
||||
expect(cFile).toBeDefined();
|
||||
expect(cFile?.content).toContain('scanf');
|
||||
expect(cFile?.content).toContain('Siapa nama kamu?');
|
||||
|
||||
expect(pyFile).toBeDefined();
|
||||
expect(pyFile?.content).toContain('input(');
|
||||
expect(pyFile?.content).toContain('Siapa nama kamu?');
|
||||
});
|
||||
|
||||
it('addFile untuk main.c dan main.py secara otomatis mengisi template default jika kosong', () => {
|
||||
const cId = playgroundStore.addFile('main.c');
|
||||
const pyId = playgroundStore.addFile('main.py');
|
||||
const otherId = playgroundStore.addFile('helper.c');
|
||||
|
||||
const state = get(playgroundStore);
|
||||
const cFile = state.files.find((f) => f.id === cId);
|
||||
const pyFile = state.files.find((f) => f.id === pyId);
|
||||
const otherFile = state.files.find((f) => f.id === otherId);
|
||||
|
||||
expect(cFile?.content).toContain('scanf');
|
||||
expect(pyFile?.content).toContain('input(');
|
||||
expect(otherFile?.content).toBe('');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export interface PlaygroundFile {
|
|||
export type ConsoleLineType = 'input' | 'output' | 'error' | 'info';
|
||||
|
||||
export interface ConsoleLine {
|
||||
id: string;
|
||||
type: ConsoleLineType;
|
||||
text: string;
|
||||
timestamp: number;
|
||||
|
|
@ -46,11 +47,29 @@ interface PlaygroundState {
|
|||
outputCursor: number;
|
||||
/** Pesan error runtime/sesi terakhir (null saat tidak ada). */
|
||||
runError: string | null;
|
||||
/** Apakah sesi sedang menunggu input pengguna. */
|
||||
waitingForInput: boolean;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'elemes_playground_files_v1';
|
||||
|
||||
const DEFAULT_C_CONTENT = `#include <stdio.h>
|
||||
export const DEFAULT_C_CONTENT = `#include <stdio.h>
|
||||
|
||||
int main() {
|
||||
char nama[50];
|
||||
printf("Siapa nama kamu? ");
|
||||
scanf("%s", nama);
|
||||
printf("Halo, %s! Selamat belajar pemrograman.\\n", nama);
|
||||
return 0;
|
||||
}
|
||||
`;
|
||||
|
||||
export const DEFAULT_PY_CONTENT = `# Program Python sederhana
|
||||
nama = input("Siapa nama kamu? ")
|
||||
print(f"Halo, {nama}! Selamat belajar pemrograman.")
|
||||
`;
|
||||
|
||||
const OLD_DEFAULT_C = `#include <stdio.h>
|
||||
|
||||
int main() {
|
||||
printf("Halo, dunia!\\n");
|
||||
|
|
@ -58,11 +77,6 @@ int main() {
|
|||
}
|
||||
`;
|
||||
|
||||
const DEFAULT_PY_CONTENT = `# Program Python sederhana
|
||||
nama = input("Siapa nama kamu? ")
|
||||
print(f"Halo, {nama}! Selamat belajar pemrograman.")
|
||||
`;
|
||||
|
||||
function generateId(): string {
|
||||
return 'f_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8);
|
||||
}
|
||||
|
|
@ -80,7 +94,19 @@ function loadPersistedFiles(): PlaygroundFile[] {
|
|||
if (!raw) return defaultFiles();
|
||||
const parsed = JSON.parse(raw) as PlaygroundFile[];
|
||||
if (!Array.isArray(parsed) || parsed.length === 0) return defaultFiles();
|
||||
return parsed.filter((f) => f && typeof f.name === 'string');
|
||||
const files = parsed.filter((f) => f && typeof f.name === 'string');
|
||||
let hasChanges = false;
|
||||
const migrated = files.map((f) => {
|
||||
if (f.name === 'main.c' && (!f.content || f.content.trim() === OLD_DEFAULT_C.trim())) {
|
||||
hasChanges = true;
|
||||
return { ...f, content: DEFAULT_C_CONTENT };
|
||||
}
|
||||
return f;
|
||||
});
|
||||
if (hasChanges) {
|
||||
persistFiles(migrated);
|
||||
}
|
||||
return migrated;
|
||||
} catch {
|
||||
return defaultFiles();
|
||||
}
|
||||
|
|
@ -107,7 +133,8 @@ function initialState(): PlaygroundState {
|
|||
runStatus: 'idle',
|
||||
runSessionId: null,
|
||||
outputCursor: 0,
|
||||
runError: null
|
||||
runError: null,
|
||||
waitingForInput: false
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -119,10 +146,15 @@ function createPlaygroundStore() {
|
|||
|
||||
// ── File management ──────────────────────────────────────────
|
||||
|
||||
addFile: (name: string): string => {
|
||||
addFile: (name: string, initialContent?: string): string => {
|
||||
const id = generateId();
|
||||
let content = initialContent ?? '';
|
||||
if (!initialContent) {
|
||||
if (name === 'main.c') content = DEFAULT_C_CONTENT;
|
||||
else if (name === 'main.py') content = DEFAULT_PY_CONTENT;
|
||||
}
|
||||
update((s) => {
|
||||
const files = [...s.files, { id, name, content: '', modified: true }];
|
||||
const files = [...s.files, { id, name, content, modified: true }];
|
||||
persistFiles(files);
|
||||
return { ...s, files, activeFileId: id };
|
||||
});
|
||||
|
|
@ -178,10 +210,20 @@ function createPlaygroundStore() {
|
|||
|
||||
// ── Console ──────────────────────────────────────────────────
|
||||
|
||||
appendConsole: (line: ConsoleLine) => {
|
||||
appendConsole: (line: ConsoleLine | Omit<ConsoleLine, 'id'>) => {
|
||||
const withId: ConsoleLine =
|
||||
'id' in line && line.id
|
||||
? (line as ConsoleLine)
|
||||
: {
|
||||
...(line as Omit<ConsoleLine, 'id'>),
|
||||
id:
|
||||
typeof crypto !== 'undefined' && crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: 'cl_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8)
|
||||
};
|
||||
update((s) => ({
|
||||
...s,
|
||||
consoleHistory: [...s.consoleHistory, line].slice(-500)
|
||||
consoleHistory: [...s.consoleHistory, withId].slice(-500)
|
||||
}));
|
||||
},
|
||||
|
||||
|
|
@ -202,8 +244,8 @@ function createPlaygroundStore() {
|
|||
consumeStdin: (): string => {
|
||||
let stdin = '';
|
||||
update((s) => {
|
||||
stdin = s.stdinQueue.map((line) => line + '\n').join('') + s.consoleInput;
|
||||
return { ...s, stdinQueue: [], consoleInput: '' };
|
||||
stdin = s.stdinQueue.map((line) => line + '\n').join('');
|
||||
return { ...s, stdinQueue: [] };
|
||||
});
|
||||
return stdin;
|
||||
},
|
||||
|
|
@ -228,13 +270,18 @@ function createPlaygroundStore() {
|
|||
|
||||
// ── Interactive session lifecycle ────────────────────────────
|
||||
|
||||
setWaitingForInput: (waiting: boolean) => {
|
||||
update((s) => ({ ...s, waitingForInput: waiting }));
|
||||
},
|
||||
|
||||
startRunSession: (sessionId: string, status: InteractiveRunStatus) => {
|
||||
update((s) => ({
|
||||
...s,
|
||||
runStatus: status,
|
||||
runSessionId: sessionId,
|
||||
outputCursor: 0,
|
||||
runError: null
|
||||
runError: null,
|
||||
waitingForInput: false
|
||||
}));
|
||||
},
|
||||
|
||||
|
|
@ -258,7 +305,8 @@ function createPlaygroundStore() {
|
|||
update((s) => ({
|
||||
...s,
|
||||
runStatus: status,
|
||||
runError: error ?? s.runError
|
||||
runError: error ?? s.runError,
|
||||
waitingForInput: false
|
||||
}));
|
||||
},
|
||||
|
||||
|
|
@ -269,7 +317,8 @@ function createPlaygroundStore() {
|
|||
runStatus: 'idle',
|
||||
runSessionId: null,
|
||||
outputCursor: 0,
|
||||
runError: null
|
||||
runError: null,
|
||||
waitingForInput: false
|
||||
}));
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@
|
|||
]);
|
||||
|
||||
function consoleLine(type: ConsoleLine['type'], text: string): ConsoleLine {
|
||||
return { type, text, timestamp: Date.now() };
|
||||
return { id: crypto.randomUUID(), type, text, timestamp: Date.now() };
|
||||
}
|
||||
|
||||
function selectCodeLanguage(lang: 'c' | 'python') {
|
||||
|
|
@ -96,11 +96,24 @@
|
|||
consoleLine('info', `$ run project [${lang}]${stdin ? ' (dengan stdin antrean)' : ''}`)
|
||||
);
|
||||
|
||||
// Pastikan console terbuka saat program dijalankan
|
||||
if (!$playgroundStore.consoleVisible) {
|
||||
playgroundStore.toggleConsole();
|
||||
}
|
||||
|
||||
generation += 1;
|
||||
const myGen = generation;
|
||||
clearPollTimer();
|
||||
|
||||
if (execMode === 'browser') {
|
||||
if (!clientRunner.isSharedArrayBufferSupported) {
|
||||
playgroundStore.appendConsole(
|
||||
consoleLine(
|
||||
'info',
|
||||
'⚠ SharedArrayBuffer tidak aktif di browser ini — input interaktif terbatas. Gunakan mode Server atau pastikan browser mendukung cross-origin isolation.'
|
||||
)
|
||||
);
|
||||
}
|
||||
playgroundStore.startRunSession('local-browser', 'running');
|
||||
try {
|
||||
const activeContent = files.find((f) => f.name === active_file)?.content || files[0]?.content || '';
|
||||
|
|
@ -113,7 +126,7 @@
|
|||
playgroundStore.appendConsole(consoleLine('error', text));
|
||||
},
|
||||
onWaitingForInput: () => {
|
||||
playgroundStore.appendConsole(consoleLine('info', 'Menunggu input...'));
|
||||
playgroundStore.setWaitingForInput(true);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -226,6 +239,7 @@
|
|||
|
||||
async function sendInputToSession(text: string) {
|
||||
const s = $playgroundStore;
|
||||
playgroundStore.setWaitingForInput(false);
|
||||
if (s.runSessionId === 'local-browser') {
|
||||
clientRunner.sendInput(text);
|
||||
return;
|
||||
|
|
@ -317,8 +331,12 @@
|
|||
clearPollTimer();
|
||||
const sid = $playgroundStore.runSessionId;
|
||||
if (sid) {
|
||||
if (sid === 'local-browser') {
|
||||
clientRunner.terminate();
|
||||
} else {
|
||||
// Best-effort cleanup sesi (keepalive agar tak ter-blokir navigasi)
|
||||
void stopCompileSession(sid);
|
||||
}
|
||||
playgroundStore.resetRunSession();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,16 +12,27 @@
|
|||
let { onSendInput, onStop }: Props = $props();
|
||||
|
||||
let outputEl: HTMLDivElement | undefined = $state();
|
||||
let inputEl: HTMLInputElement | undefined = $state();
|
||||
let sending = $state(false);
|
||||
|
||||
const consoleHistory = $derived($playgroundStore.consoleHistory);
|
||||
const consoleVisible = $derived($playgroundStore.consoleVisible);
|
||||
const runStatus = $derived($playgroundStore.runStatus);
|
||||
const waitingForInput = $derived($playgroundStore.waitingForInput);
|
||||
const isActive = $derived(
|
||||
runStatus === 'queued' || runStatus === 'compiling' || runStatus === 'running'
|
||||
);
|
||||
const isStopping = $derived(runStatus === 'stopping');
|
||||
|
||||
// Auto-focus input saat program menunggu input
|
||||
$effect(() => {
|
||||
if (waitingForInput && inputEl) {
|
||||
queueMicrotask(() => {
|
||||
inputEl?.focus();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-scroll ke bawah saat ada output baru
|
||||
$effect(() => {
|
||||
if (consoleHistory.length > 0 && outputEl) {
|
||||
|
|
@ -32,26 +43,24 @@
|
|||
});
|
||||
|
||||
async function sendInput() {
|
||||
if (sending) return;
|
||||
if (sending || !isActive || !onSendInput) return;
|
||||
const text = get(playgroundStore).consoleInput;
|
||||
if (!text) return;
|
||||
|
||||
if (!isActive || !onSendInput) {
|
||||
// Mode antrean (idle/terminal): simpan ke stdinQueue utk Run berikutnya
|
||||
playgroundStore.enqueueStdin(text);
|
||||
const line: ConsoleLine = { type: 'input', text: text.trim(), timestamp: Date.now() };
|
||||
playgroundStore.appendConsole(line);
|
||||
playgroundStore.setConsoleInput('');
|
||||
return;
|
||||
}
|
||||
|
||||
// Mode aktif: kirim ke sesi — history hanya setelah POST berhasil
|
||||
// Mode aktif: kirim ke sesi — history hanya setelah kirim berhasil
|
||||
sending = true;
|
||||
try {
|
||||
await onSendInput(text);
|
||||
const line: ConsoleLine = { type: 'input', text, timestamp: Date.now() };
|
||||
const line: ConsoleLine = {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'input',
|
||||
text,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
playgroundStore.appendConsole(line);
|
||||
playgroundStore.setConsoleInput('');
|
||||
queueMicrotask(() => {
|
||||
inputEl?.focus();
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
playgroundStore.appendConsole(consoleErrorLine(`Input gagal dikirim: ${msg}`));
|
||||
|
|
@ -61,15 +70,17 @@
|
|||
}
|
||||
|
||||
function consoleErrorLine(text: string): ConsoleLine {
|
||||
return { type: 'error', text, timestamp: Date.now() };
|
||||
return { id: crypto.randomUUID(), type: 'error', text, timestamp: Date.now() };
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (isActive) {
|
||||
void sendInput();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getLineColor(type: ConsoleLine['type']): string {
|
||||
switch (type) {
|
||||
|
|
@ -88,9 +99,18 @@
|
|||
|
||||
function statusLabel(): string | null {
|
||||
if (isStopping) return '⏹ Menghentikan…';
|
||||
if (waitingForInput) return '⌨️ Menunggu input…';
|
||||
if (isActive) return '⏳ Menjalankan…';
|
||||
return null;
|
||||
}
|
||||
|
||||
const inputPlaceholder = $derived(
|
||||
waitingForInput
|
||||
? 'Program menunggu input kamu, ketik di sini lalu tekan Enter…'
|
||||
: isActive
|
||||
? 'Ketik jawaban, Enter untuk mengirim…'
|
||||
: 'Klik ▶ Run untuk menjalankan program…'
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="console-panel" class:collapsed={!consoleVisible}>
|
||||
|
|
@ -98,7 +118,7 @@
|
|||
<span class="console-title">Console</span>
|
||||
<div class="console-actions">
|
||||
{#if statusLabel()}
|
||||
<span class="console-running">{statusLabel()}</span>
|
||||
<span class="console-running" class:waiting={waitingForInput}>{statusLabel()}</span>
|
||||
{/if}
|
||||
{#if isActive && !isStopping}
|
||||
<button
|
||||
|
|
@ -129,7 +149,7 @@
|
|||
Klik ▶ Run di editor untuk menjalankan program. Output dan error akan muncul di sini.
|
||||
</div>
|
||||
{:else}
|
||||
{#each consoleHistory as line (line.timestamp + '-' + line.text.length)}
|
||||
{#each consoleHistory as line (line.id)}
|
||||
<div class="console-line" style="color: {getLineColor(line.type)}">
|
||||
{#if line.type === 'input'}
|
||||
<span class="console-prompt">></span>
|
||||
|
|
@ -140,33 +160,26 @@
|
|||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="console-input-area">
|
||||
<div class="console-input-area" class:waiting-input={waitingForInput}>
|
||||
<span class="console-ps1">></span>
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
class="console-input"
|
||||
value={$playgroundStore.consoleInput}
|
||||
oninput={(e) => playgroundStore.setConsoleInput(e.currentTarget.value)}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder={
|
||||
isActive
|
||||
? 'Ketik jawaban, Enter untuk mengirim…'
|
||||
: 'Ketik input, Enter untuk mengantre, lalu Run…'
|
||||
}
|
||||
placeholder={inputPlaceholder}
|
||||
disabled={!isActive}
|
||||
/>
|
||||
{#if isActive && !isStopping}
|
||||
<button class="console-send" onclick={() => void sendInput()} disabled={sending}>
|
||||
{sending ? '…' : 'Kirim'}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="console-send"
|
||||
class:highlight={waitingForInput}
|
||||
onclick={() => void sendInput()}
|
||||
disabled={isStopping}
|
||||
title="Antrekan sebagai input untuk Run berikutnya"
|
||||
disabled={!isActive || sending || isStopping}
|
||||
title={!isActive ? 'Jalankan program terlebih dahulu' : waitingForInput ? 'Kirim input ke program' : 'Kirim'}
|
||||
>
|
||||
Antrekan
|
||||
{sending ? '…' : waitingForInput ? 'Kirim ⏎' : 'Kirim'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -215,6 +228,16 @@
|
|||
font-weight: 600;
|
||||
}
|
||||
|
||||
.console-running.waiting {
|
||||
color: var(--color-primary);
|
||||
animation: pulse-waiting 1.5s infinite ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes pulse-waiting {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.console-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
|
|
@ -272,6 +295,15 @@
|
|||
border-top: 1px solid var(--color-border);
|
||||
background: var(--color-bg-secondary);
|
||||
flex-shrink: 0;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.console-input-area.waiting-input {
|
||||
box-shadow: inset 0 0 0 1px var(--color-primary);
|
||||
}
|
||||
|
||||
.console-input-area.waiting-input .console-input {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.console-ps1 {
|
||||
|
|
@ -298,6 +330,11 @@
|
|||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.console-input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.console-send {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
|
|
@ -307,7 +344,11 @@
|
|||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
transition: opacity 0.15s;
|
||||
transition: opacity 0.15s, filter 0.15s;
|
||||
}
|
||||
|
||||
.console-send.highlight {
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
|
||||
.console-send:hover:not(:disabled) {
|
||||
|
|
|
|||
|
|
@ -33,5 +33,11 @@ export default defineConfig({
|
|||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
},
|
||||
preview: {
|
||||
headers: {
|
||||
'Cross-Origin-Opener-Policy': 'same-origin',
|
||||
'Cross-Origin-Embedder-Policy': 'credentialless'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue