feat(playground): alur interaktif Run→prompt→jawab + file tree collapsible & dukungan .h

- 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
This commit is contained in:
a2nr 2026-08-02 14:14:53 +00:00
parent c103b55dc4
commit ec66d794a8
9 changed files with 929 additions and 98 deletions

View File

@ -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<T>(path: string, customFetch = fetch): Promise<T> {
return res.json() as Promise<T>;
}
async function del<T>(path: string, customFetch = fetch): Promise<T> {
const res = await customFetch(`${BASE}${path}`, { method: 'DELETE' });
return res.json() as Promise<T>;
}
// ── Auth ─────────────────────────────────────────────────────────────
export function login(token: string, customFetch = fetch) {
@ -63,6 +74,25 @@ export function compileCode(req: CompileRequest, customFetch = fetch) {
return post<CompileResponse>('/compile', req, customFetch);
}
// ── Interactive session (PTY) ───────────────────────────────────────
export function startCompileSession(req: StartSessionRequest, customFetch = fetch) {
return post<SessionPollResponse>('/compile/sessions', req, customFetch);
}
export function readCompileSession(sessionId: string, cursor: number, customFetch = fetch) {
const query = cursor > 0 ? `?cursor=${cursor}` : '';
return get<SessionPollResponse>(`/compile/sessions/${sessionId}${query}`, customFetch);
}
export function sendCompileInput(sessionId: string, text: string, customFetch = fetch) {
return post<SessionPollResponse>(`/compile/sessions/${sessionId}/input`, { text }, customFetch);
}
export function stopCompileSession(sessionId: string, customFetch = fetch) {
return del<SessionStopResponse>(`/compile/sessions/${sessionId}`, customFetch);
}
export interface VelxioCompileRequest {
code: string;
board_fqbn?: string;

View File

@ -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');
});
});

View File

@ -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<PlaygroundLanguage, readonly string[]> = {
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}`;
}

View File

@ -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');
});
});

View File

@ -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: () => {

View File

@ -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;
}

View File

@ -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<typeof setTimeout> | null = null;
let generation = 0;
const TERMINAL_STATUSES = new Set<InteractiveRunStatus>([
'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<HTMLIFrameElement | null>(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();
}
});
</script>
@ -144,13 +287,13 @@
<div class="playground-page">
<!-- Tab bar -->
<div class="pg-tabs">
<button class="pg-tab" class:active={activeTab === 'velxio'} onclick={() => activeTab = 'velxio'}>
<button class="pg-tab" class:active={activeTab === 'velxio'} onclick={() => switchTab('velxio')}>
Arduino
</button>
<button class="pg-tab" class:active={activeTab === 'flowchart'} onclick={() => activeTab = 'flowchart'}>
<button class="pg-tab" class:active={activeTab === 'flowchart'} onclick={() => switchTab('flowchart')}>
Flowchart
</button>
<button class="pg-tab" class:active={activeTab === 'circuit'} onclick={() => activeTab = 'circuit'}>
<button class="pg-tab" class:active={activeTab === 'circuit'} onclick={() => switchTab('circuit')}>
Circuit
</button>
<div class="pg-tab-group">
@ -194,7 +337,24 @@
</div>
{:else if activeTab === 'code'}
<div class="pg-code-wrap">
<FileTree language={codeLanguage} onselect={() => activeTab = 'code'} />
<!-- File tree shell: tree dilepas dari DOM saat collapsed agar flexbox menghitung ulang -->
<div
class="pg-file-tree-shell"
class:collapsed={!$playgroundStore.fileTreeVisible}
>
{#if $playgroundStore.fileTreeVisible}
<FileTree language={codeLanguage} onselect={() => activeTab = 'code'} />
{/if}
<button
class="pg-file-tree-handle"
aria-expanded={$playgroundStore.fileTreeVisible}
aria-label={$playgroundStore.fileTreeVisible ? 'Sembunyikan file' : 'Tampilkan file'}
title={$playgroundStore.fileTreeVisible ? 'Sembunyikan file' : 'Tampilkan file'}
onclick={() => playgroundStore.toggleFileTree()}
>
{#if $playgroundStore.fileTreeVisible}{:else}{/if}
</button>
</div>
<div class="pg-code-main">
<div class="pg-code-toolbar">
@ -203,10 +363,14 @@
<div class="pg-toolbar-spacer"></div>
<button
class="pg-run-btn"
onclick={runActiveFile}
disabled={$playgroundStore.running || !activeFile}
onclick={() => void startRun()}
disabled={($playgroundStore.runStatus !== 'idle' &&
!['exited', 'error', 'stopped', 'expired'].includes($playgroundStore.runStatus)) ||
!activeFile}
>
{#if $playgroundStore.running}
{#if $playgroundStore.runStatus === 'stopping'}
⏹ Menghentikan…
{:else if ['queued', 'compiling', 'running'].includes($playgroundStore.runStatus)}
⏳ Menjalankan…
{:else}
▶ Run
@ -234,7 +398,10 @@
<!-- Console (C/Python tab) -->
{#if activeTab === 'code'}
<ConsolePanel />
<ConsolePanel
onSendInput={sendInputToSession}
onStop={stopRun}
/>
{/if}
<!-- DeployFAB only on Velxio tab -->
@ -355,6 +522,49 @@
display: flex;
}
.pg-file-tree-shell {
display: flex;
flex-shrink: 0;
min-height: 0;
width: 230px;
transition: width 0.18s ease;
}
.pg-file-tree-shell > :global(.file-tree) {
flex: 1;
min-width: 0;
}
.pg-file-tree-shell.collapsed {
width: 26px;
}
.pg-file-tree-handle {
flex-shrink: 0;
width: 26px;
background: var(--color-bg-secondary);
border: none;
border-left: 1px solid var(--color-border);
color: var(--color-text-muted);
cursor: pointer;
font-size: 0.9rem;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
transition: background 0.15s, color 0.15s;
}
.pg-file-tree-handle:hover {
background: var(--color-bg);
color: var(--color-primary);
}
.pg-file-tree-handle:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
.pg-code-main {
flex: 1;
min-width: 0;
@ -456,5 +666,8 @@
.pg-iframe {
min-height: 400px;
}
.pg-file-tree-shell {
width: 180px;
}
}
</style>

View File

@ -2,10 +2,25 @@
import { get } from 'svelte/store';
import { playgroundStore, type ConsoleLine } from '$stores/playground';
interface Props {
/** Kirim input ke sesi aktif; throw bila gagal (input tidak di-commit). */
onSendInput?: (text: string) => Promise<void>;
/** Stop sesi aktif (DELETE). */
onStop?: () => Promise<void>;
}
let { onSendInput, onStop }: Props = $props();
let outputEl: HTMLDivElement | undefined = $state();
let sending = $state(false);
const consoleHistory = $derived($playgroundStore.consoleHistory);
const consoleVisible = $derived($playgroundStore.consoleVisible);
const runStatus = $derived($playgroundStore.runStatus);
const isActive = $derived(
runStatus === 'queued' || runStatus === 'compiling' || runStatus === 'running'
);
const isStopping = $derived(runStatus === 'stopping');
// Auto-scroll ke bawah saat ada output baru
$effect(() => {
@ -16,19 +31,44 @@
}
});
function sendInput() {
const text = get(playgroundStore).consoleInput.trim();
async function sendInput() {
if (sending) return;
const text = get(playgroundStore).consoleInput;
if (!text) return;
// Tambah ke antrean stdin (bukan hanya history)
playgroundStore.enqueueStdin(text);
// Tetap tampilkan di console history sebagai baris input
const line: ConsoleLine = { type: 'input', text, timestamp: Date.now() };
playgroundStore.appendConsole(line);
playgroundStore.setConsoleInput('');
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
sending = true;
try {
await onSendInput(text);
const line: ConsoleLine = { type: 'input', text, timestamp: Date.now() };
playgroundStore.appendConsole(line);
playgroundStore.setConsoleInput('');
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
playgroundStore.appendConsole(consoleErrorLine(`Input gagal dikirim: ${msg}`));
} finally {
sending = false;
}
}
function consoleErrorLine(text: string): ConsoleLine {
return { type: 'error', text, timestamp: Date.now() };
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') sendInput();
if (e.key === 'Enter') {
e.preventDefault();
void sendInput();
}
}
function getLineColor(type: ConsoleLine['type']): string {
@ -45,14 +85,29 @@
return 'var(--color-text)';
}
}
function statusLabel(): string | null {
if (isStopping) return '⏹ Menghentikan…';
if (isActive) return '⏳ Menjalankan…';
return null;
}
</script>
<div class="console-panel" class:collapsed={!consoleVisible}>
<div class="console-header">
<span class="console-title">Console</span>
<div class="console-actions">
{#if $playgroundStore.running}
<span class="console-running">⏳ Menjalankan…</span>
{#if statusLabel()}
<span class="console-running">{statusLabel()}</span>
{/if}
{#if isActive && !isStopping}
<button
class="console-btn console-stop"
onclick={() => void onStop?.()}
title="Hentikan program"
>
⏹ Stop
</button>
{/if}
<button
class="console-btn"
@ -92,9 +147,26 @@
value={$playgroundStore.consoleInput}
oninput={(e) => playgroundStore.setConsoleInput(e.currentTarget.value)}
onkeydown={handleKeydown}
placeholder="Ketik input, enter/Kirim untuk antre, lalu Run…"
/>
<button class="console-send" onclick={sendInput}>Kirim</button>
placeholder={
isActive
? 'Ketik jawaban, Enter untuk mengirim…'
: 'Ketik input, Enter untuk mengantre, lalu Run…'
}
/>
{#if isActive && !isStopping}
<button class="console-send" onclick={() => void sendInput()} disabled={sending}>
{sending ? '…' : 'Kirim'}
</button>
{:else}
<button
class="console-send"
onclick={() => void sendInput()}
disabled={isStopping}
title="Antrekan sebagai input untuk Run berikutnya"
>
Antrekan
</button>
{/if}
</div>
{/if}
</div>
@ -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;
}

View File

@ -1,36 +1,79 @@
<script lang="ts">
import { playgroundStore } from '$stores/playground';
import {
uniqueDefaultName,
validateFileName,
type PlaygroundLanguage
} from '$services/playground-files';
interface Props {
language?: 'c' | 'python';
language?: PlaygroundLanguage;
onselect?: (id: string) => void;
}
let { language = 'c', onselect }: Props = $props();
const fileExt = $derived(language === 'python' ? '.py' : '.c');
const langLabel = $derived(language === 'python' ? 'Python' : 'C');
const filteredFiles = $derived(
$playgroundStore.files.filter((f) => f.name.toLowerCase().endsWith(fileExt))
$playgroundStore.files.filter((f) => {
const ext = f.name.includes('.') ? f.name.slice(f.name.lastIndexOf('.')) : '';
return language === 'python' ? ext === '.py' : ext === '.c' || ext === '.h';
})
);
let renamingId = $state<string | null>(null);
let renameValue = $state('');
let renameError = $state<string | null>(null);
let renameInput = $state<HTMLInputElement | null>(null);
/** Nama file lain (untuk deteksi duplikat saat rename). */
function otherNames(currentId: string | null): string[] {
return $playgroundStore.files
.filter((f) => f.id !== currentId)
.map((f) => f.name);
}
function selectFile(id: string) {
playgroundStore.setActiveFile(id);
onselect?.(id);
}
function startRename(id: string, currentName: string) {
function beginRename(id: string, initialName: string) {
renamingId = id;
renameValue = currentName;
renameValue = initialName;
renameError = null;
// Fokus + select nama (tanpa ekstensi) di tick berikutnya
queueMicrotask(() => {
renameInput?.focus();
const dot = initialName.lastIndexOf('.');
renameInput?.setSelectionRange(0, dot > 0 ? dot : initialName.length);
});
}
/** Rename kosong/Enter → batal (tanpa menghapus file). */
function cancelRename() {
renamingId = null;
renameError = null;
}
function commitRename(id: string) {
const name = renameValue.trim();
if (name) playgroundStore.renameFile(id, name);
const res = validateFileName(renameValue, language, otherNames(id));
if (!res.ok) {
renameError = res.reason;
return; // input tetap terbuka — JANGAN commit nama invalid
}
playgroundStore.renameFile(id, res.name);
renamingId = null;
renameError = null;
}
/** Tambah file baru dengan nama unik, langsung mode rename. */
function addFile() {
const base = 'untitled';
const name = uniqueDefaultName(base, language, $playgroundStore.files.map((f) => f.name));
const id = playgroundStore.addFile(name);
beginRename(id, name);
}
function getFileIcon(name: string): string {
@ -57,13 +100,16 @@
<div class="file-tree">
<div class="ft-header">
<span class="ft-title">{langLabel} Files</span>
<button
class="ft-new-btn"
onclick={() => playgroundStore.addFile(language === 'python' ? 'main.py' : 'main.c')}
title={`Buat file ${fileExt} baru`}
>
+
</button>
<div class="ft-header-actions">
<button
class="ft-new-btn"
onclick={addFile}
title={`Buat file ${language === 'python' ? '.py' : '.c/.h'} baru`}
aria-label="Tambah file baru"
>
+
</button>
</div>
</div>
{#if filteredFiles.length === 0}
@ -86,28 +132,47 @@
>
<span class="ft-icon">{getFileIcon(file.name)}</span>
{#if renamingId === file.id}
<input
class="ft-rename"
bind:value={renameValue}
onkeydown={(e) => {
if (e.key === 'Enter') commitRename(file.id);
if (e.key === 'Escape') renamingId = null;
}}
onclick={(e) => e.stopPropagation()}
/>
<span class="ft-rename-wrap">
<input
bind:this={renameInput}
class="ft-rename"
class:invalid={!!renameError}
value={renameValue}
oninput={(e) => {
renameValue = e.currentTarget.value;
renameError = null;
}}
onkeydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
commitRename(file.id);
}
if (e.key === 'Escape') {
e.preventDefault();
cancelRename();
}
}}
onclick={(e) => e.stopPropagation()}
aria-label="Nama file baru"
/>
{#if renameError}
<span class="ft-rename-error" role="alert">{renameError}</span>
{/if}
</span>
{:else}
<span class="ft-name">{file.name}</span>
{/if}
{#if file.modified}
<span class="ft-dot" title="Belum tersimpan">·</span>
{/if}
<span class="ft-actions">
<span class="ft-row-actions">
<button
class="ft-action"
title="Ubah nama"
aria-label={`Ubah nama ${file.name}`}
onclick={(e) => {
e.stopPropagation();
startRename(file.id, file.name);
beginRename(file.id, file.name);
}}
>
@ -115,6 +180,7 @@
<button
class="ft-action"
title="Hapus"
aria-label={`Hapus ${file.name}`}
onclick={(e) => {
e.stopPropagation();
playgroundStore.deleteFile(file.id);
@ -157,6 +223,11 @@
color: var(--color-text-muted);
}
.ft-header-actions {
display: flex;
gap: 0.25rem;
}
.ft-new-btn {
background: var(--color-primary);
border: none;
@ -226,9 +297,16 @@
min-width: 0;
}
.ft-rename {
.ft-rename-wrap {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.ft-rename {
width: 100%;
font-size: 0.8rem;
padding: 0.15rem 0.3rem;
border: 1px solid var(--color-primary);
@ -238,13 +316,23 @@
outline: none;
}
.ft-rename.invalid {
border-color: var(--color-danger);
}
.ft-rename-error {
font-size: 0.68rem;
color: var(--color-danger);
line-height: 1.3;
}
.ft-dot {
color: var(--color-warning);
font-size: 0.9rem;
flex-shrink: 0;
}
.ft-actions {
.ft-row-actions {
display: flex;
gap: 0.15rem;
flex-shrink: 0;
@ -252,7 +340,9 @@
transition: opacity 0.15s;
}
.ft-item:hover .ft-actions {
.ft-item:hover .ft-row-actions,
.ft-item:focus-within .ft-row-actions,
.ft-item:focus .ft-row-actions {
opacity: 1;
}
@ -272,11 +362,16 @@
color: var(--color-primary);
}
::-webkit-scrollbar {
.ft-action:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
.file-tree ::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-thumb {
.file-tree ::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 3px;
}