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:
parent
c103b55dc4
commit
ec66d794a8
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue