From ec66d794a8cb5d09dec4563cb59419a8d17c6e3e Mon Sep 17 00:00:00 2001 From: a2nr Date: Sun, 2 Aug 2026 14:14:53 +0000 Subject: [PATCH] =?UTF-8?q?feat(playground):=20alur=20interaktif=20Run?= =?UTF-8?q?=E2=86=92prompt=E2=86=92jawab=20+=20file=20tree=20collapsible?= =?UTF-8?q?=20&=20dukungan=20.h?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - store: normalisasi lifecycle sesi (runStatus/runSessionId/outputCursor/runError, stdinQueue tetap untuk kompatibilitas) - +page.svelte: session controller — start, polling recursive setTimeout non-overlap, delta output via cursor, stop + cleanup unmount/generation counter - ConsolePanel.svelte: input interaktif (onSendInput/onStop async, mode antrean vs kirim) - FileTree.svelte: validasi rename (foo.h diterima di C), tombol + langsung mode rename, collapsible shell dengan handle persisten - playground-files.ts: validasi nama file (basename, traversal, duplikat case-insensitive) - api.ts/compiler.ts: client sesi interaktif - tests: playground.test.ts + playground-files.test.ts --- frontend/src/lib/services/api.ts | 32 +- .../src/lib/services/playground-files.test.ts | 109 +++++++ frontend/src/lib/services/playground-files.ts | 100 ++++++ frontend/src/lib/stores/playground.test.ts | 84 +++++ frontend/src/lib/stores/playground.ts | 85 ++++- frontend/src/lib/types/compiler.ts | 43 +++ frontend/src/routes/playground/+page.svelte | 303 +++++++++++++++--- .../src/routes/playground/ConsolePanel.svelte | 116 ++++++- .../src/routes/playground/FileTree.svelte | 155 +++++++-- 9 files changed, 929 insertions(+), 98 deletions(-) create mode 100644 frontend/src/lib/services/playground-files.test.ts create mode 100644 frontend/src/lib/services/playground-files.ts diff --git a/frontend/src/lib/services/api.ts b/frontend/src/lib/services/api.ts index f68d7e0..af9c78c 100644 --- a/frontend/src/lib/services/api.ts +++ b/frontend/src/lib/services/api.ts @@ -9,7 +9,13 @@ */ import type { LoginResponse, ValidateTokenResponse } from '$types/auth'; -import type { CompileRequest, CompileResponse } from '$types/compiler'; +import type { + CompileRequest, + CompileResponse, + SessionPollResponse, + SessionStopResponse, + StartSessionRequest +} from '$types/compiler'; import type { Lesson, LessonContent } from '$types/lesson'; const BASE = '/api'; @@ -28,6 +34,11 @@ async function get(path: string, customFetch = fetch): Promise { return res.json() as Promise; } +async function del(path: string, customFetch = fetch): Promise { + const res = await customFetch(`${BASE}${path}`, { method: 'DELETE' }); + return res.json() as Promise; +} + // ── Auth ───────────────────────────────────────────────────────────── export function login(token: string, customFetch = fetch) { @@ -63,6 +74,25 @@ export function compileCode(req: CompileRequest, customFetch = fetch) { return post('/compile', req, customFetch); } +// ── Interactive session (PTY) ─────────────────────────────────────── + +export function startCompileSession(req: StartSessionRequest, customFetch = fetch) { + return post('/compile/sessions', req, customFetch); +} + +export function readCompileSession(sessionId: string, cursor: number, customFetch = fetch) { + const query = cursor > 0 ? `?cursor=${cursor}` : ''; + return get(`/compile/sessions/${sessionId}${query}`, customFetch); +} + +export function sendCompileInput(sessionId: string, text: string, customFetch = fetch) { + return post(`/compile/sessions/${sessionId}/input`, { text }, customFetch); +} + +export function stopCompileSession(sessionId: string, customFetch = fetch) { + return del(`/compile/sessions/${sessionId}`, customFetch); +} + export interface VelxioCompileRequest { code: string; board_fqbn?: string; diff --git a/frontend/src/lib/services/playground-files.test.ts b/frontend/src/lib/services/playground-files.test.ts new file mode 100644 index 0000000..77ad87b --- /dev/null +++ b/frontend/src/lib/services/playground-files.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from 'vitest'; +import { + validateFileName, + uniqueDefaultName, + isAllowedExtension, + getExtension, + languageFromName +} from './playground-files'; + +describe('getExtension / languageFromName', () => { + it('mengambil ekstensi lowercase dengan titik', () => { + expect(getExtension('foo.c')).toBe('.c'); + expect(getExtension('FOO.H')).toBe('.h'); + expect(getExtension('main.py')).toBe('.py'); + expect(getExtension('README')).toBe(''); + }); + + it('languageFromName: .py → python, lainnya → c', () => { + expect(languageFromName('main.py')).toBe('python'); + expect(languageFromName('foo.h')).toBe('c'); + expect(languageFromName('main.c')).toBe('c'); + }); +}); + +describe('isAllowedExtension', () => { + it('C menerima .c dan .h, menolak .py', () => { + expect(isAllowedExtension('a.c', 'c')).toBe(true); + expect(isAllowedExtension('a.h', 'c')).toBe(true); + expect(isAllowedExtension('a.py', 'c')).toBe(false); + }); + + it('Python menerima .py, menolak .h dan .c', () => { + expect(isAllowedExtension('a.py', 'python')).toBe(true); + expect(isAllowedExtension('a.h', 'python')).toBe(false); + expect(isAllowedExtension('a.c', 'python')).toBe(false); + }); +}); + +describe('validateFileName', () => { + it('foo.h diterima di mode C (regresi bug utama)', () => { + const res = validateFileName('foo.h', 'c', ['main.c']); + expect(res.ok).toBe(true); + if (res.ok) expect(res.name).toBe('foo.h'); + }); + + it('menolak nama kosong dan whitespace-only', () => { + expect(validateFileName('', 'c').ok).toBe(false); + expect(validateFileName(' ', 'c').ok).toBe(false); + }); + + it('menolak traversal path: slash, backslash, ".", ".."', () => { + expect(validateFileName('../evil.c', 'c').ok).toBe(false); + expect(validateFileName('dir/evil.c', 'c').ok).toBe(false); + expect(validateFileName('dir\\evil.c', 'c').ok).toBe(false); + expect(validateFileName('.', 'c').ok).toBe(false); + expect(validateFileName('..', 'c').ok).toBe(false); + }); + + it('menolak karakter kontrol', () => { + expect(validateFileName('a\nb.c', 'c').ok).toBe(false); + }); + + it('menolak duplikat case-insensitive', () => { + const res = validateFileName('MAIN.C', 'c', ['main.c']); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.reason).toContain('sudah ada'); + }); + + it('duplikat tidak menolak jika nama berbeda', () => { + expect(validateFileName('main2.c', 'c', ['main.c']).ok).toBe(true); + }); + + it('Python menolak .h dengan pesan jelas', () => { + const res = validateFileName('foo.h', 'python'); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.reason).toContain('.py'); + }); + + it('C menolak .py', () => { + const res = validateFileName('foo.py', 'c'); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.reason).toContain('.c'); + }); + + it('ekstensi tanpa titik ditolak', () => { + expect(validateFileName('README', 'c').ok).toBe(false); + }); +}); + +describe('uniqueDefaultName', () => { + it('mengembalikan base + ekstensi bila bebas', () => { + expect(uniqueDefaultName('untitled', 'c', ['main.c'])).toBe('untitled.c'); + expect(uniqueDefaultName('untitled', 'python', ['main.py'])).toBe('untitled.py'); + }); + + it('menambah angka bila nama sudah dipakai', () => { + expect(uniqueDefaultName('untitled', 'c', ['main.c', 'untitled.c'])).toBe('untitled-2.c'); + }); + + it('menghindari celah penomoran', () => { + expect(uniqueDefaultName('untitled', 'c', ['untitled.c', 'untitled-2.c'])).toBe( + 'untitled-3.c' + ); + }); + + it('case-insensitive terhadap existing', () => { + expect(uniqueDefaultName('untitled', 'c', ['UNTITLED.C'])).toBe('untitled-2.c'); + }); +}); diff --git a/frontend/src/lib/services/playground-files.ts b/frontend/src/lib/services/playground-files.ts new file mode 100644 index 0000000..5bd71d3 --- /dev/null +++ b/frontend/src/lib/services/playground-files.ts @@ -0,0 +1,100 @@ +/** + * Validasi nama file untuk file tree playground (C/Python). + * + * Terpisah dari komponen agar bisa diuji unit tanpa DOM. + */ + +export type PlaygroundLanguage = 'c' | 'python'; + +export const EXTENSIONS: Record = { + c: ['.c', '.h'], + python: ['.py'] +}; + +export type FileNameResult = + | { ok: true; name: string } + | { ok: false; reason: string }; + +const CONTROL_CHARS = /[\u0000-\u001f\u007f]/; +const FORBIDDEN_CHARS = /[\\/]/; + +/** Ekstensi dari nama file (lowercase, termasuk titik). */ +export function getExtension(name: string): string { + const base = name.split(/[\\/]/).pop() ?? name; + const idx = base.lastIndexOf('.'); + if (idx <= 0) return ''; // nama tanpa titik atau nama tersembunyi (.gitignore) + return base.slice(idx).toLowerCase(); +} + +export function languageFromName(name: string): PlaygroundLanguage { + return getExtension(name) === '.py' ? 'python' : 'c'; +} + +export function isAllowedExtension(name: string, language: PlaygroundLanguage): boolean { + return EXTENSIONS[language].includes(getExtension(name)); +} + +/** + * Validasi nama file baru / rename. + * - Hanya basename (tolak slash/backslash) + * - Tolak ".", "..", nama kosong, karakter kontrol + * - Tolak duplikat (case-insensitive) terhadap daftar existing + * - Ekstensi harus valid untuk bahasa + * + * @param existingNames daftar nama file lain (tidak termasuk file yang sedang di-rename) + */ +export function validateFileName( + rawName: string, + language: PlaygroundLanguage, + existingNames: string[] = [] +): FileNameResult { + const name = rawName.trim(); + if (!name) { + return { ok: false, reason: 'Nama file tidak boleh kosong' }; + } + if (name === '.' || name === '..') { + return { ok: false, reason: 'Nama file tidak valid' }; + } + if (FORBIDDEN_CHARS.test(name)) { + return { ok: false, reason: 'Nama file tidak boleh mengandung / atau \\' }; + } + if (CONTROL_CHARS.test(name)) { + return { ok: false, reason: 'Nama file mengandung karakter yang tidak diizinkan' }; + } + if (name.length > 120) { + return { ok: false, reason: 'Nama file terlalu panjang' }; + } + if (!isAllowedExtension(name, language)) { + const allowed = EXTENSIONS[language].join(' / '); + return { + ok: false, + reason: + language === 'python' + ? 'File Python harus berekstensi .py' + : `File C harus berekstensi ${allowed}` + }; + } + const lower = name.toLowerCase(); + if (existingNames.some((n) => n.toLowerCase() === lower)) { + return { ok: false, reason: `File "${name}" sudah ada` }; + } + return { ok: true, name }; +} + +/** + * Buat nama file default unik, mis. "untitled.c", "untitled-2.c", dst. + */ +export function uniqueDefaultName( + base: string, + language: PlaygroundLanguage, + existingNames: string[] +): string { + const ext = EXTENSIONS[language][0]; + const lowerSet = new Set(existingNames.map((n) => n.toLowerCase())); + if (!lowerSet.has((base + ext).toLowerCase())) return base + ext; + let i = 2; + while (lowerSet.has(`${base}-${i}${ext}`.toLowerCase())) { + i += 1; + } + return `${base}-${i}${ext}`; +} diff --git a/frontend/src/lib/stores/playground.test.ts b/frontend/src/lib/stores/playground.test.ts index 85279ac..cf52439 100644 --- a/frontend/src/lib/stores/playground.test.ts +++ b/frontend/src/lib/stores/playground.test.ts @@ -53,3 +53,87 @@ describe('playgroundStore stdinQueue', () => { expect(state.consoleInput).toBe('draft'); }); }); + +describe('playgroundStore session lifecycle', () => { + beforeEach(() => { + playgroundStore.reset(); + }); + + it('startRunSession mengisi sessionId, status, dan mereset cursor/error', () => { + playgroundStore.startRunSession('sess-abc', 'running'); + const state = get(playgroundStore); + expect(state.runSessionId).toBe('sess-abc'); + expect(state.runStatus).toBe('running'); + expect(state.outputCursor).toBe(0); + expect(state.runError).toBeNull(); + }); + + it('hanya satu sesi aktif — start kedua menimpa sesi pertama', () => { + playgroundStore.startRunSession('sess-1', 'running'); + playgroundStore.startRunSession('sess-2', 'running'); + const state = get(playgroundStore); + expect(state.runSessionId).toBe('sess-2'); + expect(state.runStatus).toBe('running'); + }); + + it('advanceOutputCursor tidak pernah mundur', () => { + playgroundStore.startRunSession('sess-1', 'running'); + playgroundStore.advanceOutputCursor(1024); + playgroundStore.advanceOutputCursor(512); + const state = get(playgroundStore); + expect(state.outputCursor).toBe(1024); + }); + + it('finishRun mengubah status ke terminal tapi mempertahankan sessionId', () => { + playgroundStore.startRunSession('sess-1', 'running'); + playgroundStore.finishRun('exited'); + const state = get(playgroundStore); + expect(state.runStatus).toBe('exited'); + expect(state.runSessionId).toBe('sess-1'); + }); + + it('finishRun dapat menyimpan error', () => { + playgroundStore.startRunSession('sess-1', 'running'); + playgroundStore.finishRun('error', 'Traceback: division by zero'); + const state = get(playgroundStore); + expect(state.runStatus).toBe('error'); + expect(state.runError).toBe('Traceback: division by zero'); + }); + + it('resetRunSession membersihkan semua state runtime tapi menyimpan file & fileTreeVisible', () => { + playgroundStore.startRunSession('sess-1', 'running'); + playgroundStore.advanceOutputCursor(42); + playgroundStore.setRunError('boom'); + playgroundStore.setFileTreeVisible(false); + playgroundStore.toggleFileTree(); // → true + + const fileCountBefore = get(playgroundStore).files.length; + playgroundStore.resetRunSession(); + + const state = get(playgroundStore); + expect(state.runStatus).toBe('idle'); + expect(state.runSessionId).toBeNull(); + expect(state.outputCursor).toBe(0); + expect(state.runError).toBeNull(); + expect(state.files.length).toBe(fileCountBefore); + expect(state.fileTreeVisible).toBe(true); + }); + + it('GATE: tidak ada kombinasi idle tapi sessionId masih aktif', () => { + playgroundStore.startRunSession('sess-1', 'running'); + playgroundStore.resetRunSession(); + const state = get(playgroundStore); + expect(state.runStatus === 'idle' ? state.runSessionId === null : true).toBe(true); + // dan kebalikannya: sessionId aktif ⇒ status bukan idle + playgroundStore.startRunSession('sess-2', 'running'); + const s2 = get(playgroundStore); + expect(s2.runSessionId !== null ? s2.runStatus !== 'idle' : true).toBe(true); + }); + + it('updateRunStatus transisi queued → compiling → running', () => { + playgroundStore.startRunSession('sess-1', 'queued'); + playgroundStore.updateRunStatus('compiling'); + playgroundStore.updateRunStatus('running'); + expect(get(playgroundStore).runStatus).toBe('running'); + }); +}); diff --git a/frontend/src/lib/stores/playground.ts b/frontend/src/lib/stores/playground.ts index 3a83ef0..0f752f5 100644 --- a/frontend/src/lib/stores/playground.ts +++ b/frontend/src/lib/stores/playground.ts @@ -3,9 +3,14 @@ * * State untuk route /playground: file tree (C/Python) + console output/input. * Mengikuti pola stores lain di proyek (svelte/store writable). + * + * Lifecycle sesi interaktif: + * idle → queued/compiling/running → exited/error/stopped → idle + * 'stopping' = DELETE session sedang berlangsung. */ import { writable } from 'svelte/store'; +import type { InteractiveRunStatus } from '$types/compiler'; export interface PlaygroundFile { id: string; @@ -22,7 +27,8 @@ export interface ConsoleLine { timestamp: number; } -export type RunState = 'idle' | 'running'; +/** Status sesi dari sisi UI. 'idle' = tidak ada sesi aktif. */ +export type RunStatus = 'idle' | InteractiveRunStatus | 'stopping'; interface PlaygroundState { files: PlaygroundFile[]; @@ -31,7 +37,15 @@ interface PlaygroundState { consoleInput: string; stdinQueue: string[]; consoleVisible: boolean; - running: boolean; + fileTreeVisible: boolean; + /** Status runtime sesi interaktif (bukan sekadar boolean). */ + runStatus: RunStatus; + /** ID sesi interaktif di worker, null saat idle. */ + runSessionId: string | null; + /** Byte cursor output terakhir yang sudah di-append ke console. */ + outputCursor: number; + /** Pesan error runtime/sesi terakhir (null saat tidak ada). */ + runError: string | null; } const STORAGE_KEY = 'elemes_playground_files_v1'; @@ -89,7 +103,11 @@ function initialState(): PlaygroundState { consoleInput: '', stdinQueue: [], consoleVisible: true, - running: false + fileTreeVisible: true, + runStatus: 'idle', + runSessionId: null, + outputCursor: 0, + runError: null }; } @@ -99,6 +117,8 @@ function createPlaygroundStore() { return { subscribe, + // ── File management ────────────────────────────────────────── + addFile: (name: string): string => { const id = generateId(); update((s) => { @@ -156,6 +176,8 @@ function createPlaygroundStore() { }); }, + // ── Console ────────────────────────────────────────────────── + appendConsole: (line: ConsoleLine) => { update((s) => ({ ...s, @@ -194,8 +216,61 @@ function createPlaygroundStore() { update((s) => ({ ...s, consoleVisible: !s.consoleVisible })); }, - setRunning: (running: boolean) => { - update((s) => ({ ...s, running })); + // ── File tree visibility ───────────────────────────────────── + + setFileTreeVisible: (visible: boolean) => { + update((s) => ({ ...s, fileTreeVisible: visible })); + }, + + toggleFileTree: () => { + update((s) => ({ ...s, fileTreeVisible: !s.fileTreeVisible })); + }, + + // ── Interactive session lifecycle ──────────────────────────── + + startRunSession: (sessionId: string, status: InteractiveRunStatus) => { + update((s) => ({ + ...s, + runStatus: status, + runSessionId: sessionId, + outputCursor: 0, + runError: null + })); + }, + + updateRunStatus: (status: RunStatus) => { + update((s) => ({ ...s, runStatus: status })); + }, + + advanceOutputCursor: (cursor: number) => { + update((s) => ({ + ...s, + outputCursor: Math.max(s.outputCursor, cursor) + })); + }, + + setRunError: (error: string) => { + update((s) => ({ ...s, runError: error })); + }, + + /** Transisi sesi → terminal. Hanya terima status terminal. */ + finishRun: (status: InteractiveRunStatus, error?: string | null) => { + update((s) => ({ + ...s, + runStatus: status, + runError: error ?? s.runError + })); + }, + + /** Bersihkan lifecycle sesi sepenuhnya (kembali idle). */ + resetRunSession: () => { + update((s) => ({ + ...s, + runStatus: 'idle', + runSessionId: null, + outputCursor: 0, + runError: null + })); }, reset: () => { diff --git a/frontend/src/lib/types/compiler.ts b/frontend/src/lib/types/compiler.ts index d8b2816..8533454 100644 --- a/frontend/src/lib/types/compiler.ts +++ b/frontend/src/lib/types/compiler.ts @@ -10,3 +10,46 @@ export interface CompileResponse { output: string; error: string; } + +// ── Interactive session (PTY) ─────────────────────────────────────── + +export type InteractiveRunStatus = + | 'queued' + | 'compiling' + | 'running' + | 'exited' + | 'error' + | 'stopped' + | 'expired'; + +export interface CompilerFile { + name: string; + content: string; +} + +export interface StartSessionRequest { + language: 'c' | 'python'; + files: CompilerFile[]; + /** Entry point untuk Python; untuk C diabaikan (semua .c dikompilasi). */ + active_file?: string; + /** Prefilled stdin (kompatibilitas pola lama). */ + stdin?: string; + token?: string; +} + +export interface SessionPollResponse { + session_id: string; + status: InteractiveRunStatus; + /** Delta output sejak cursor terakhir. */ + output: string; + cursor: number; + truncated: boolean; + exit_code: number | null; + error: string | null; +} + +export interface SessionStopResponse { + success: boolean; + status: InteractiveRunStatus; + error: string | null; +} diff --git a/frontend/src/routes/playground/+page.svelte b/frontend/src/routes/playground/+page.svelte index 4c0e75b..e176808 100644 --- a/frontend/src/routes/playground/+page.svelte +++ b/frontend/src/routes/playground/+page.svelte @@ -2,7 +2,13 @@ import { onMount, onDestroy } from 'svelte'; import { auth } from '$stores/auth'; import { playgroundStore, type ConsoleLine } from '$stores/playground'; - import { compileCode } from '$services/api'; + import { + startCompileSession, + readCompileSession, + sendCompileInput, + stopCompileSession + } from '$services/api'; + import type { InteractiveRunStatus } from '$types/compiler'; import DeployFAB from '$components/DeployFAB.svelte'; import CircuitEditor from '$components/CircuitEditor.svelte'; import CodeEditor from '$components/CodeEditor.svelte'; @@ -19,10 +25,24 @@ const activeFile = $derived( $playgroundStore.files.find((f) => f.id === $playgroundStore.activeFileId) ?? null ); - const codeLanguage = $derived( + const codeLanguage = $derived<'c' | 'python'>( activeFile?.name.toLowerCase().endsWith('.py') ? 'python' : 'c' ); + // ── Interactive session state (lokal; lifecycle di store) ── + let pollTimer: ReturnType | null = null; + let generation = 0; + const TERMINAL_STATUSES = new Set([ + 'exited', + 'error', + 'stopped', + 'expired' + ]); + + function consoleLine(type: ConsoleLine['type'], text: string): ConsoleLine { + return { type, text, timestamp: Date.now() }; + } + function selectCodeLanguage(lang: 'c' | 'python') { const ext = lang === 'python' ? '.py' : '.c'; const file = $playgroundStore.files.find((f) => f.name.toLowerCase().endsWith(ext)); @@ -34,58 +54,173 @@ activeTab = 'code'; } - function consoleLine(type: ConsoleLine['type'], text: string): ConsoleLine { - return { type, text, timestamp: Date.now() }; + // ── Session lifecycle ───────────────────────────────────────────── + + function clearPollTimer() { + if (pollTimer) { + clearTimeout(pollTimer); + pollTimer = null; + } } - // ── Run (console output/input via /api/compile) ── - async function runActiveFile() { - const file = activeFile; - if (!file || $playgroundStore.running) return; + function schedulePoll(delayMs: number) { + clearPollTimer(); + pollTimer = setTimeout(() => void pollOnce(), delayMs); + } - const stdin = playgroundStore.consumeStdin(); - playgroundStore.setRunning(true); - playgroundStore.appendConsole( - consoleLine('info', `$ run ${file.name} [${codeLanguage}]`) - ); - if (!stdin) { - playgroundStore.appendConsole( - consoleLine('info', '(tanpa stdin — program yang membaca input akan mendapat EOF)') - ); + function projectFiles(): { files: { name: string; content: string }[]; active_file: string } { + const lang = codeLanguage; + const visible = lang === 'python' ? /\.py$/i : /\.(c|h)$/i; + const files = $playgroundStore.files + .filter((f) => visible.test(f.name)) + .map((f) => ({ name: f.name, content: f.content })); + const activeName = activeFile?.name ?? files[0]?.name ?? ''; + return { files, active_file: activeName }; + } + + async function startRun() { + const s = $playgroundStore; + // Abaikan bila masih ada sesi berjalan/berhenti + if (s.runStatus !== 'idle' && !TERMINAL_STATUSES.has(s.runStatus as InteractiveRunStatus)) { + return; } + const { files, active_file } = projectFiles(); + if (files.length === 0 || !active_file) return; + + // Konsumsi stdin yang diantrekan (prefilled) utk Run ini + const stdin = playgroundStore.consumeStdin(); + const lang = codeLanguage; + playgroundStore.appendConsole( + consoleLine('info', `$ run project [${lang}]${stdin ? ' (dengan stdin antrean)' : ''}`) + ); + + generation += 1; + const myGen = generation; + clearPollTimer(); try { - const res = await compileCode({ - code: file.content, - language: codeLanguage, - token: auth.token || undefined, - stdin + const res = await startCompileSession({ + language: lang, + files, + active_file, + stdin: stdin || undefined, + token: auth.token || undefined }); - - if (res.success) { - if (res.output) { - playgroundStore.appendConsole(consoleLine('output', res.output)); - } - if (res.error) { - playgroundStore.appendConsole(consoleLine('error', res.error)); - } - if (!res.output && !res.error) { - playgroundStore.appendConsole(consoleLine('info', '(selesai, tanpa output)')); - } - } else { + if (myGen !== generation) return; // sesi baru / unmount + if (!res.session_id) { playgroundStore.appendConsole( - consoleLine('error', res.error || res.output || 'Gagal menjalankan program') + consoleLine('error', res.error || 'Gagal memulai sesi program') ); + playgroundStore.resetRunSession(); + return; } + playgroundStore.startRunSession(res.session_id, res.status); + handlePollResponse(res); } catch (err) { + if (myGen !== generation) return; playgroundStore.appendConsole( consoleLine('error', `Error: ${err instanceof Error ? err.message : String(err)}`) ); - } finally { - playgroundStore.setRunning(false); + playgroundStore.resetRunSession(); } } + async function pollOnce() { + const s = $playgroundStore; + const sid = s.runSessionId; + if (!sid || TERMINAL_STATUSES.has(s.runStatus as InteractiveRunStatus)) return; + + const myGen = generation; + try { + const res = await readCompileSession(sid, s.outputCursor); + if (myGen !== generation) return; + handlePollResponse(res); + } catch (err) { + if (myGen !== generation) return; + // Network hiccup — coba lagi dengan delay lebih panjang, jangan mati diam-diam + schedulePoll(1000); + } + } + + function handlePollResponse(res: { + status: InteractiveRunStatus; + output: string; + cursor: number; + truncated?: boolean; + exit_code?: number | null; + error?: string | null; + }) { + const s = $playgroundStore; + if (res.output) { + playgroundStore.appendConsole(consoleLine('output', res.output)); + } + if (res.truncated) { + playgroundStore.appendConsole( + consoleLine('info', '(⚠ output dipotong — batas 256 KiB)') + ); + } + playgroundStore.advanceOutputCursor(res.cursor); + + if (TERMINAL_STATUSES.has(res.status)) { + // Terminal — tampilkan sisa error bila ada + if (res.error) { + playgroundStore.appendConsole(consoleLine('error', res.error)); + } else if (res.exit_code != null && res.exit_code !== 0) { + playgroundStore.appendConsole( + consoleLine('error', `(program keluar dengan kode ${res.exit_code})`) + ); + } else if (res.status === 'exited' && !res.output) { + playgroundStore.appendConsole(consoleLine('info', '(selesai, tanpa output)')); + } + playgroundStore.finishRun(res.status, res.error ?? null); + // Terminal — release session di worker (best effort) + const terminalSid = $playgroundStore.runSessionId; + if (terminalSid) void stopCompileSession(terminalSid); + return; + } + + playgroundStore.updateRunStatus(res.status); + + // Polling adaptif: cepat di awal/berubah, melambat setelah stabil + const changed = res.status === 'queued' || res.status === 'compiling'; + schedulePoll(changed ? 250 : 750); + } + + async function sendInputToSession(text: string) { + const s = $playgroundStore; + if (!s.runSessionId || s.runStatus !== 'running') { + // Jatuh ke mode antrean — ConsolePanel menangani sendiri saat idle; + // di sini hanya mencegah kirim ke sesi yang tidak aktif. + throw new Error('tidak ada sesi aktif'); + } + await sendCompileInput(s.runSessionId, text); + } + + async function stopRun() { + const sid = $playgroundStore.runSessionId; + if (!sid) return; + clearPollTimer(); + playgroundStore.updateRunStatus('stopping'); + try { + await stopCompileSession(sid); + } catch { + // best effort — sesi worker akan di-sweep sendiri + } + const s = $playgroundStore; + if (s.runSessionId === sid) { + playgroundStore.finishRun('stopped', 'program dihentikan oleh pengguna'); + playgroundStore.resetRunSession(); + } + } + + function switchTab(tab: PlaygroundTab) { + if (tab !== 'code' && $playgroundStore.runStatus !== 'idle') { + // Stop sesi aktif saat pindah tab + void stopRun(); + } + activeTab = tab; + } + // ── Velxio + DeployFAB ── let velxioIframe = $state(null); type DeployFABHandle = { setHex: (hex: string | null) => void }; @@ -127,6 +262,14 @@ onDestroy(() => { window.removeEventListener('message', handleMessage); + generation += 1; // invalidasi polling yang sedang berjalan + clearPollTimer(); + const sid = $playgroundStore.runSessionId; + if (sid) { + // Best-effort cleanup sesi (keepalive agar tak ter-blokir navigasi) + void stopCompileSession(sid); + playgroundStore.resetRunSession(); + } }); @@ -144,13 +287,13 @@
- - -
@@ -194,7 +337,24 @@
{:else if activeTab === 'code'}
- activeTab = 'code'} /> + +
+ {#if $playgroundStore.fileTreeVisible} + activeTab = 'code'} /> + {/if} + +
@@ -203,10 +363,14 @@
{/if} + placeholder={ + isActive + ? 'Ketik jawaban, Enter untuk mengirim…' + : 'Ketik input, Enter untuk mengantre, lalu Run…' + } + /> + {#if isActive && !isStopping} + + {:else} + + {/if}
{/if}
@@ -159,6 +231,11 @@ color: var(--color-primary); } + .console-stop { + color: var(--color-danger); + font-size: 0.75rem; + } + .console-output { flex: 1; padding: 0.5rem 0.75rem; @@ -233,15 +310,20 @@ transition: opacity 0.15s; } - .console-send:hover { + .console-send:hover:not(:disabled) { opacity: 0.85; } - ::-webkit-scrollbar { + .console-send:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .console-panel ::-webkit-scrollbar { width: 6px; } - ::-webkit-scrollbar-thumb { + .console-panel ::-webkit-scrollbar-thumb { background: var(--color-border); border-radius: 3px; } diff --git a/frontend/src/routes/playground/FileTree.svelte b/frontend/src/routes/playground/FileTree.svelte index 43bc918..6fa8fd0 100644 --- a/frontend/src/routes/playground/FileTree.svelte +++ b/frontend/src/routes/playground/FileTree.svelte @@ -1,36 +1,79 @@