diff --git a/frontend/src/lib/services/code-runner/c-worker.ts b/frontend/src/lib/services/code-runner/c-worker.ts index 87543d2..efb7e55 100644 --- a/frontend/src/lib/services/code-runner/c-worker.ts +++ b/frontend/src/lib/services/code-runner/c-worker.ts @@ -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; } - self.postMessage({ type: 'waiting_for_input' } satisfies WorkerMessage); - Atomics.wait(stdinArray, 0, 0); + 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) => { const { code, stdinBuffer, stdinText } = e.data; const start = performance.now(); @@ -69,37 +106,25 @@ self.onmessage = (e: MessageEvent) => { 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) => { }, consoleWrite: (text: string) => { if (text) { - callbackError += text + '\n'; self.postMessage({ type: 'stdout', text: text + '\n' } satisfies WorkerMessage); } }, diff --git a/frontend/src/lib/services/code-runner/client-runner.test.ts b/frontend/src/lib/services/code-runner/client-runner.test.ts index d854e1e..0732a53 100644 --- a/frontend/src/lib/services/code-runner/client-runner.test.ts +++ b/frontend/src/lib/services/code-runner/client-runner.test.ts @@ -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((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 +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 diff --git a/frontend/src/lib/services/code-runner/client-runner.ts b/frontend/src/lib/services/code-runner/client-runner.ts index 69a97eb..ee068bb 100644 --- a/frontend/src/lib/services/code-runner/client-runner.ts +++ b/frontend/src/lib/services/code-runner/client-runner.ts @@ -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 | null = null; + + const clearTimer = () => { + if (currentTimer) { + clearTimeout(currentTimer); + currentTimer = null; + } + }; + + 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: + reasonMsg ?? + `⏱️ Eksekusi dihentikan — batas waktu ${durationMs / 1000} detik terlampaui (kemungkinan infinite loop).` + }); + }, durationMs); + }; + + this.onInputSent = () => { + if (!settled) { + resetTimer(timeoutMs); + } + }; const worker = this.spawnWorker(lang); - - const timer = setTimeout(() => { - if (settled) return; - settled = true; - this.killWorker(lang); - resolve({ - success: false, - output: output.trimEnd(), - error: `⏱️ Eksekusi dihentikan — batas waktu ${timeoutMs / 1000} detik terlampaui (kemungkinan infinite loop).` - }); - }, timeoutMs); + resetTimer(timeoutMs); worker.onmessage = (e: MessageEvent) => { 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, diff --git a/frontend/src/lib/services/code-runner/python-worker.ts b/frontend/src/lib/services/code-runner/python-worker.ts index 79e5816..c215814 100644 --- a/frontend/src/lib/services/code-runner/python-worker.ts +++ b/frontend/src/lib/services/code-runner/python-worker.ts @@ -8,20 +8,24 @@ let stdinDataView: Uint8Array | null = null; let prefilledLines: string[] = []; let prefilledLineIdx = 0; -function readlineSync(): string { - if (prefilledLines.length > 0) { - if (prefilledLineIdx < prefilledLines.length) { - return prefilledLines[prefilledLineIdx++]; - } - return ''; +function readlineSync(prompt?: string): string { + if (prompt) { + self.postMessage({ type: 'stdout', text: prompt } satisfies WorkerMessage); + } + + if (prefilledLineIdx < prefilledLines.length) { + return prefilledLines[prefilledLineIdx++]; } if (!stdinArray || !stdinDataView) { return ''; } - self.postMessage({ type: 'waiting_for_input' } satisfies WorkerMessage); - Atomics.wait(stdinArray, 0, 0); + // 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 { }); 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 `); diff --git a/frontend/src/lib/stores/playground.test.ts b/frontend/src/lib/stores/playground.test.ts index cf52439..89573c7 100644 --- a/frontend/src/lib/stores/playground.test.ts +++ b/frontend/src/lib/stores/playground.test.ts @@ -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(''); + }); }); diff --git a/frontend/src/lib/stores/playground.ts b/frontend/src/lib/stores/playground.ts index 0f752f5..a1d86d0 100644 --- a/frontend/src/lib/stores/playground.ts +++ b/frontend/src/lib/stores/playground.ts @@ -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 +export const DEFAULT_C_CONTENT = `#include + +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 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) => { + const withId: ConsoleLine = + 'id' in line && line.id + ? (line as ConsoleLine) + : { + ...(line as Omit), + 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 })); }, diff --git a/frontend/src/routes/playground/+page.svelte b/frontend/src/routes/playground/+page.svelte index 164ed35..310ebf4 100644 --- a/frontend/src/routes/playground/+page.svelte +++ b/frontend/src/routes/playground/+page.svelte @@ -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) { - // Best-effort cleanup sesi (keepalive agar tak ter-blokir navigasi) - void stopCompileSession(sid); + if (sid === 'local-browser') { + clientRunner.terminate(); + } else { + // Best-effort cleanup sesi (keepalive agar tak ter-blokir navigasi) + void stopCompileSession(sid); + } playgroundStore.resetRunSession(); } }); diff --git a/frontend/src/routes/playground/ConsolePanel.svelte b/frontend/src/routes/playground/ConsolePanel.svelte index 7c33fdb..1b7377d 100644 --- a/frontend/src/routes/playground/ConsolePanel.svelte +++ b/frontend/src/routes/playground/ConsolePanel.svelte @@ -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,13 +70,15 @@ } 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(); - void sendInput(); + if (isActive) { + void sendInput(); + } } } @@ -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…' + );
@@ -98,7 +118,7 @@ Console
{#if statusLabel()} - {statusLabel()} + {statusLabel()} {/if} {#if isActive && !isStopping} - {:else} - - {/if} +
{/if}
@@ -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) { diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 2b05069..a8a9e70 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -33,5 +33,11 @@ export default defineConfig({ changeOrigin: true } } + }, + preview: { + headers: { + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Embedder-Policy': 'credentialless' + } } });