SevdanurGenc's picture
Upload 300 files
9438a57 verified
Raw History Blame Contribute Delete
18.5 kB
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Editor from '@monaco-editor/react';
import { api, subscribe } from '../api';
import ModelSelect, { allModels, providerOf, PROVIDER_LABEL } from './ModelSelect';
import { isNum, usePref } from '../settings';
import type { Device } from '../types';
import type { AgentEvent, Analysis, Health, JobState, Language, Mutant, RunResult, Sample, TestKind } from '../types';
import AgentFlow from './AgentFlow';
import Results from './Results';
import { useI18n, type MsgKey } from '../i18n';
import { useTheme } from '../theme';
import { ACCEPT, MAX_TOTAL, baseName, helpersFor, langOf, pickTarget, readInputs, type InFile } from '../projectFiles';
const LANGS: { id: Language; label: string }[] = [
{ id: 'python', label: 'Python' },
{ id: 'javascript', label: 'JavaScript' },
{ id: 'typescript', label: 'TypeScript' },
{ id: 'java', label: 'Java' },
];
const KINDS: TestKind[] = ['unit', 'interface', 'ui'];
const MONACO_LANG: Record<string, string> = { python: 'python', javascript: 'javascript', typescript: 'typescript', java: 'java' };
interface Project { files: InFile[]; target: string }
const STARTER = `def fizzbuzz(n: int) -> str:
"""1..n için FizzBuzz; n < 1 ise ValueError."""
if n < 1:
raise ValueError("n en az 1 olmalı")
out = []
for i in range(1, n + 1):
if i % 15 == 0:
out.append("FizzBuzz")
elif i % 3 == 0:
out.append("Fizz")
elif i % 5 == 0:
out.append("Buzz")
else:
out.append(str(i))
return " ".join(out)
`;
const STARTER_EN = STARTER.replace('1..n için FizzBuzz; n < 1 ise ValueError.', 'FizzBuzz for 1..n; ValueError if n < 1.')
.replace('n en az 1 olmalı', 'n must be at least 1');
export interface LiveView {
analysis: Analysis | null;
testCode: string;
testFile: string;
run: RunResult | null;
mutants: Mutant[];
mutantTotal: number;
}
export default function Workspace({ health, jobId }: { health: Health | null; jobId?: string }) {
const { dark } = useTheme();
const { t, lang } = useI18n();
const [language, setLanguage] = useState<Language>('python');
const [kind, setKind] = useState<TestKind>('unit');
const [code, setCode] = useState(() => (lang === 'en' ? STARTER_EN : STARTER));
const [html, setHtml] = useState('');
const [samples, setSamples] = useState<Sample[]>([]);
// Ayarlar taşınabilir ayar dosyasında saklanır (ayarlar/testajani-ayarlar.json)
const [prefModel, setPrefModel] = usePref<string>('ws.model', '');
const [jobModel, setJobModel] = useState<string | null>(null); // Geçmiş'ten açılan koşunun modeli (varsayılanı değiştirmez)
const model = jobModel ?? (prefModel && (!health || allModels(health).some((m) => m.id === prefModel)) ? prefModel : (health?.default_model || prefModel));
const setModel = (m: string) => { setJobModel(null); setPrefModel(m); };
const [device, setDevice] = usePref<Device | null>('ws.device', null, (v) => v === 'auto' || v === 'gpu' || v === 'cpu');
const [maxIter, setMaxIter] = usePref('ws.maxIter', health?.max_iterations ?? 4, isNum);
const [target, setTarget] = usePref('ws.target', health?.coverage_target ?? 85, isNum);
const [mutation, setMutation] = usePref('ws.mutation', true);
const [maxMutants, setMaxMutants] = usePref('ws.maxMutants', 25, isNum);
const [mutRound, setMutRound] = usePref('ws.mutRound', true);
const [project, setProject] = useState<Project | null>(null);
const [job, setJob] = useState<JobState | null>(null);
const [events, setEvents] = useState<AgentEvent[]>([]);
const [running, setRunning] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [dragging, setDragging] = useState(false);
const unsub = useRef<null | (() => void)>(null);
const fileInput = useRef<HTMLInputElement>(null);
useEffect(() => { api.samples().then(setSamples).catch(() => {}); }, []);
// Başlangıç örneği değiştirilmediyse dil değişince onun da dilini değiştir
useEffect(() => { setCode((c) => (c === STARTER || c === STARTER_EN ? (lang === 'en' ? STARTER_EN : STARTER) : c)); }, [lang]);
const attach = useCallback((id: string) => {
unsub.current?.();
setEvents([]);
setRunning(true);
unsub.current = subscribe(id, (e) => setEvents((prev) => [...prev, e]), async () => {
try { setJob(await api.job(id)); } catch { /* yoksay */ }
setRunning(false);
});
}, []);
// URL'de iş kimliği varsa (Geçmiş'ten açıldıysa) yükle
useEffect(() => {
if (!jobId) return;
api.job(jobId).then((j) => {
setJob(j);
setLanguage(j.request.language);
setKind(j.request.test_kind);
setCode(j.request.code);
if (j.request.model) setJobModel(j.request.model);
setHtml(j.request.html || '');
const ex = j.request.extra_files || {};
const tgt = j.request.source_name || 'solution';
setProject(Object.keys(ex).length ? { target: tgt, files: [{ path: tgt, text: j.request.code },
...Object.entries(ex).map(([path, text]) => ({ path, text }))] } : null);
if (j.status === 'running' || j.status === 'queued') attach(j.id);
else setEvents(j.events);
}).catch((e) => setErr(String(e.message || e)));
}, [jobId, attach]);
useEffect(() => () => unsub.current?.(), []);
const start = async () => {
setErr(null);
setJob(null);
try {
const { id } = await api.createJob({
language, code, test_kind: kind, html: kind === 'ui' && html.trim() ? html : null,
model: model || null, device: cloud ? null : device, max_iterations: maxIter, coverage_target: target,
run_mutation: mutation, max_mutants: maxMutants, lang, mutation_rounds: mutation && mutRound ? 1 : 0,
...(project ? { extra_files: helpersFor(project.files, project.target).extra, source_name: baseName(project.target) } : {}),
});
window.history.replaceState(null, '', `#/job/${id}`);
const j = await api.job(id);
setJob(j);
attach(id);
} catch (e: any) {
setErr(String(e.message || e));
}
};
const stop = async () => { if (job) await api.cancel(job.id); };
const loadSample = (id: string) => {
const s = samples.find((x) => x.id === id);
if (!s) return;
setLanguage(s.language);
setKind(s.kind);
setCode(s.code);
setHtml(s.html || '');
setProject(null);
};
// Yerel dosya(lar)ı ya da zip'i yükler. Tek dosya: uzantıdan dil seçilir. HTML + JS: Web UI testi.
// Birden çok kaynak dosyası: proje kipi — biri test edilir, diğerleri yanına yardımcı olarak konur.
const loadFiles = async (list: FileList | File[]) => {
setErr(null);
setNotice(null);
let res;
try { res = await readInputs(list); } catch { setErr(t('ws.err.zip')); return; }
const { files, skipped, tooBig } = res;
if (tooBig.length && !files.some((f) => langOf(f.path))) { setErr(t('ws.err.big', { name: tooBig[0], kb: '>200' })); return; }
if (!files.some((f) => langOf(f.path))) {
setErr(t('ws.err.type', { list: ACCEPT.replace(/,/g, ', ') }));
return;
}
const pages = files.filter((f) => langOf(f.path) === 'html');
const scripts = files.filter((f) => langOf(f.path) === 'javascript');
const sources = files.filter((f) => { const l = langOf(f.path); return l && l !== 'html'; });
const skipNote = skipped.length + tooBig.length ? ' ' + t('ws.skipped', { n: skipped.length + tooBig.length }) : '';
if (pages.length === 1 && scripts.length <= 1 && sources.length === scripts.length) { // Web UI
setLanguage('javascript'); setKind('ui'); setProject(null);
if (scripts[0]) { setCode(scripts[0].text); setHtml(pages[0].text); }
else { setCode(pages[0].text); setHtml(''); }
setNotice((scripts[0] ? t('ws.loaded.ui2', { js: baseName(scripts[0].path), html: baseName(pages[0].path) })
: t('ws.loaded.ui1', { html: baseName(pages[0].path) })) + skipNote);
return;
}
const target = pickTarget(files);
if (!target) { setErr(t('ws.err.type', { list: ACCEPT.replace(/,/g, ', ') })); return; }
if (files.length === 1) {
setProject(null);
selectTarget({ files, target }, false);
setNotice(t('ws.loaded', { name: baseName(target) }) + skipNote);
return;
}
const total = files.reduce((a, f) => a + f.text.length, 0);
if (total > MAX_TOTAL) { setErr(t('ws.err.total')); return; }
selectTarget({ files, target }, true);
setNotice(t('ws.project.loaded', { n: files.length, name: baseName(target) }) + skipNote);
};
const selectTarget = (p: Project, keepProject: boolean) => {
const f = p.files.find((x) => x.path === p.target)!;
const l = langOf(f.path) as Language;
setLanguage(l);
if (kind === 'ui' && l !== 'javascript') setKind('unit');
setCode(f.text);
setHtml('');
setProject(keepProject ? p : null);
};
const helperInfo = project ? helpersFor(project.files, project.target) : null;
// Akan olaylardan canlı görünüm; iş bitince sunucudaki kesin sonuç (en iyi sürüm) kullanılır.
const live: LiveView = useMemo(() => {
const final = job?.result && !running ? job.result : null;
let analysis: Analysis | null = null, testCode = '', run: RunResult | null = null;
const muts = new Map<number, Mutant>();
let mutantTotal = 0;
for (const e of events) {
if (e.type === 'result' && e.data.analysis) analysis = e.data.analysis;
if (e.type === 'tool_call' && e.data.tool === 'write_tests') testCode = e.data.code;
if (e.type === 'tool_result' && e.data.run) run = e.data.run;
if (e.type === 'mutant') { muts.set(e.data.mutant.id, e.data.mutant); mutantTotal = e.data.total; }
}
if (final) {
return {
analysis: final.analysis, testCode: final.test_code, testFile: final.test_filename,
run: final.final_run, mutants: final.mutation?.mutants || [...muts.values()],
mutantTotal: final.mutation?.total ?? mutantTotal,
};
}
return { analysis, testCode, testFile: job?.result?.test_filename || '', run, mutants: [...muts.values()], mutantTotal };
}, [events, job, running]);
const kindDisabled = (k: TestKind) => k === 'ui' && language !== 'javascript';
const codeLang = kind === 'ui' && /^\s*(<!doctype|<html)/i.test(code) ? 'html' : MONACO_LANG[language];
const prov = providerOf(model);
const cloud = prov !== 'ollama';
const effDevice: Device = device || health?.providers?.device || 'auto';
return (
<main className="workspace">
<section className="panel" aria-labelledby="code-h">
<div className="panel-head">
<h2 id="code-h">{t('ws.code')}</h2>
<div className="seg" role="group" aria-label={t('ws.language')}>
{LANGS.map((l) => (
<button key={l.id} aria-pressed={language === l.id}
onClick={() => { setLanguage(l.id); setProject(null); if (l.id !== 'javascript' && kind === 'ui') setKind('unit'); }}>
{l.label}
</button>
))}
</div>
<div className="spacer" />
<button className="btn btn-sm" onClick={() => fileInput.current?.click()} title={t('ws.openFile.title', { accept: ACCEPT })}>
{t('ws.openFile')}
</button>
<input ref={fileInput} type="file" accept={ACCEPT} multiple hidden
onChange={(e) => { if (e.target.files) loadFiles(e.target.files); e.target.value = ''; }} />
<select aria-label={t('ws.loadSample.aria')} value="" onChange={(e) => loadSample(e.target.value)} style={{ maxWidth: 170 }}>
<option value="">{t('ws.loadSample')}</option>
{(['dev', 'test', 'bagimsiz'] as const).map((sp) => {
const group = samples.filter((s) => (s.split || 'dev') === sp);
return group.length ? (
<optgroup key={sp} label={t(`ws.split.${sp}` as MsgKey)}>
{group.map((s) => <option key={s.id} value={s.id}>{lang === 'en' ? s.title_en || s.title : s.title}</option>)}
</optgroup>
) : null;
})}
</select>
</div>
{project && helperInfo && (
<div className="project-bar">
<span className="small muted">{t('ws.project')}</span>
<select value={project.target} aria-label={t('ws.project.target')} title={t('ws.project.target')}
onChange={(e) => selectTarget({ ...project, target: e.target.value }, true)}>
{project.files.filter((f) => { const l = langOf(f.path); return l && l !== 'html'; }).map((f) => (
<option key={f.path} value={f.path}>{f.path}</option>))}
</select>
<span className="small muted" title={Object.keys(helperInfo.extra).join('\n')}>
{t('ws.project.helpers', { n: Object.keys(helperInfo.extra).length })}
{helperInfo.ignored.length > 0 && ` · ${t('ws.project.ignored', { n: helperInfo.ignored.length })}`}
</span>
<div className="spacer" />
<button className="btn btn-sm" onClick={() => setProject(null)} title={t('ws.project.clear')}>×</button>
</div>
)}
<div className="panel-head" style={{ minHeight: 0, paddingTop: 8, paddingBottom: 8 }}>
<span className="small muted">{t('ws.testKind')}</span>
<div className="seg" role="group" aria-label={t('ws.testKind')}>
{KINDS.map((k) => (
<button key={k} aria-pressed={kind === k} disabled={kindDisabled(k)} title={t(`kind.${k}.hint` as MsgKey)}
onClick={() => setKind(k)}>{t(`kind.${k}` as MsgKey)}</button>
))}
</div>
<span className="small muted">{t(`kind.${kind}.hint` as MsgKey)}</span>
</div>
<div className={`editor-wrap${dragging ? ' dragging' : ''}`}
onDragOver={(e) => { if (e.dataTransfer.types.includes('Files')) { e.preventDefault(); setDragging(true); } }}
onDragLeave={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragging(false); }}
onDrop={(e) => { e.preventDefault(); setDragging(false); if (e.dataTransfer.files.length) loadFiles(e.dataTransfer.files); }}>
{dragging && <div className="drop-hint">{t('ws.drop')}</div>}
<Editor
height="100%" language={codeLang} value={code} onChange={(v) => setCode(v || '')}
theme={dark ? 'vs-dark' : 'vs'}
options={{ minimap: { enabled: false }, fontSize: 13, fontFamily: 'JetBrains Mono, monospace', scrollBeyondLastLine: false, tabSize: 4, automaticLayout: true,
unicodeHighlight: { ambiguousCharacters: false, invisibleCharacters: false } }}
/>
</div>
{kind === 'ui' && codeLang !== 'html' && (
<details className="html-toggle">
<summary>{t('ws.html', { file: 'app.js' })}</summary>
<textarea value={html} onChange={(e) => setHtml(e.target.value)} rows={8} className="mono"
placeholder={'<!doctype html>…<script src="app.js"></script>'} style={{ width: '100%', marginTop: 8 }} />
</details>
)}
<div className="run-bar">
<label className="field">{t('ws.model')}
<ModelSelect health={health} value={model} onChange={setModel} style={{ maxWidth: 220 }} />
</label>
<div className="field device-seg" title={t('ws.device.title')}>{t('ws.device')}
<div className="seg" role="radiogroup" aria-label={t('ws.device')}>
{cloud ? <button disabled aria-pressed>{t('ws.device.cloud')}</button>
: (['auto', 'gpu', 'cpu'] as const).map((d) => (
<button key={d} role="radio" aria-checked={effDevice === d} aria-pressed={effDevice === d}
onClick={() => setDevice(d)}>{t(`conn.device.${d}`)}</button>))}
</div>
</div>
<label className="field">{t('ws.maxIter')}
<input type="number" min={1} max={10} value={maxIter} onChange={(e) => setMaxIter(+e.target.value)} style={{ width: 64 }} />
</label>
<label className="field">{t('ws.target')}
<input type="number" min={0} max={100} value={target} onChange={(e) => setTarget(+e.target.value)} style={{ width: 72 }} />
</label>
<label className="field">{t('ws.mutants')}
<span className="row" style={{ gap: 6 }}>
<input type="checkbox" checked={mutation} onChange={(e) => setMutation(e.target.checked)} aria-label={t('ws.mutants.aria')} />
<input type="number" min={0} max={200} value={maxMutants} disabled={!mutation}
onChange={(e) => setMaxMutants(+e.target.value)} style={{ width: 64 }} aria-label={t('ws.maxMutants.aria')} />
</span>
</label>
<label className="field" title={t('ws.mutRound.hint')}>{t('ws.mutRound')}
<span className="row" style={{ gap: 6, minHeight: 34, alignItems: 'center' }}>
<input type="checkbox" checked={mutation && mutRound} disabled={!mutation}
onChange={(e) => setMutRound(e.target.checked)} aria-label={t('ws.mutRound')} />
</span>
</label>
{running
? <button className="btn danger" onClick={stop}>{t('ws.stop')}</button>
: <button className="btn primary" onClick={start} disabled={!code.trim()}>{t('ws.generate')}</button>}
</div>
{cloud && <div className="info-box small cloud-note">{t('ws.cloudNote', { p: PROVIDER_LABEL[prov] })}</div>}
{err && <div className="error-box" style={{ margin: '0 14px 14px' }}>{err}</div>}
{notice && !err && <div className="small muted" style={{ margin: '0 14px 10px' }}>{notice}</div>}
{health && !health.ollama.ok && !cloud && (
<div className="warn-box" style={{ margin: '0 14px 14px' }}>
{t('ws.ollamaDown', { url: health.ollama.url })} <code>ollama serve</code> · <code>ollama pull {health.default_model}</code>
</div>
)}
</section>
<AgentFlow events={events} job={job} running={running} />
<Results live={live} job={job} running={running} code={code} language={language} kind={kind} onJob={setJob} />
</main>
);
}