Version 1.0
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
import React, { useState } from 'react';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Workspace from './pages/Workspace';
|
||||
|
||||
function App() {
|
||||
const [selectedChallengeId, setSelectedChallengeId] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-darkBg text-gray-100 font-sans">
|
||||
{selectedChallengeId ? (
|
||||
<Workspace
|
||||
challengeId={selectedChallengeId}
|
||||
onBack={() => setSelectedChallengeId(null)}
|
||||
/>
|
||||
) : (
|
||||
<Dashboard
|
||||
onSelectChallenge={(id) => setSelectedChallengeId(id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,88 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const apiHost = typeof window !== 'undefined' && window.location.hostname ? window.location.hostname : '127.0.0.1';
|
||||
const apiClient = axios.create({
|
||||
baseURL: `http://${apiHost}:8000`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
|
||||
export interface Challenge {
|
||||
id: string;
|
||||
challenge_id: string;
|
||||
name: string;
|
||||
difficulty: string;
|
||||
language: string;
|
||||
subject?: string;
|
||||
description: string;
|
||||
requirements: string[];
|
||||
hints: string[];
|
||||
}
|
||||
|
||||
export interface HandbookTopic {
|
||||
id: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
export interface HandbookCatalog {
|
||||
functions: HandbookTopic[];
|
||||
subjects: HandbookTopic[];
|
||||
}
|
||||
|
||||
export const getChallenges = async (): Promise<Challenge[]> => {
|
||||
const response = await apiClient.get('/challenges');
|
||||
return response.data.challenges;
|
||||
};
|
||||
|
||||
export const runCode = async (language: string, code: string, stdin: string = '') => {
|
||||
const response = await apiClient.post('/run', { language, code, stdin });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
|
||||
export const lintCode = async (language: string, code: string) => {
|
||||
const response = await apiClient.post('/lint', { language, code });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getGuidance = async (
|
||||
challengeId: string,
|
||||
language: string,
|
||||
code: string,
|
||||
question: string = ''
|
||||
) => {
|
||||
const response = await apiClient.post('/guide', {
|
||||
challenge_id: challengeId,
|
||||
language,
|
||||
code,
|
||||
question,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getHandbookCatalog = async (language: string): Promise<HandbookCatalog> => {
|
||||
const response = await apiClient.get(`/handbook/catalog/${encodeURIComponent(language)}`);
|
||||
return response.data.catalog;
|
||||
};
|
||||
|
||||
export const getHandbookTopics = async (language: string): Promise<HandbookTopic[]> => {
|
||||
const response = await apiClient.get(`/handbook/topics/${encodeURIComponent(language)}`);
|
||||
return response.data.topics;
|
||||
};
|
||||
|
||||
export const getHandbookExample = async (
|
||||
language: string,
|
||||
topicId: string,
|
||||
topicTitle: string
|
||||
) => {
|
||||
const response = await apiClient.post('/handbook/example', {
|
||||
language,
|
||||
topic_id: topicId,
|
||||
topic_title: topicTitle,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
import React from 'react';
|
||||
|
||||
interface MarkdownViewProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export const MarkdownView: React.FC<MarkdownViewProps> = ({ content }) => {
|
||||
if (!content) return null;
|
||||
|
||||
// Simple, robust custom Markdown renderer for Mentor responses & challenge specs
|
||||
const lines = content.split('\n');
|
||||
const elements: React.ReactNode[] = [];
|
||||
let inCodeBlock = false;
|
||||
let codeBuffer: string[] = [];
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
if (line.trim().startsWith('```')) {
|
||||
if (inCodeBlock) {
|
||||
elements.push(
|
||||
<pre
|
||||
key={`code-${index}`}
|
||||
className="my-3 p-3 bg-black/70 border border-gray-800 rounded-lg text-xs font-mono text-emerald-400 overflow-x-auto"
|
||||
>
|
||||
<code>{codeBuffer.join('\n')}</code>
|
||||
</pre>
|
||||
);
|
||||
codeBuffer = [];
|
||||
inCodeBlock = false;
|
||||
} else {
|
||||
inCodeBlock = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (inCodeBlock) {
|
||||
codeBuffer.push(line);
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.startsWith('# ')) {
|
||||
elements.push(
|
||||
<h1 key={index} className="text-xl font-bold text-white mt-4 mb-2">
|
||||
{line.replace('# ', '')}
|
||||
</h1>
|
||||
);
|
||||
} else if (line.startsWith('## ')) {
|
||||
elements.push(
|
||||
<h2 key={index} className="text-base font-bold text-blue-400 mt-4 mb-2 border-b border-gray-800 pb-1">
|
||||
{line.replace('## ', '')}
|
||||
</h2>
|
||||
);
|
||||
} else if (line.startsWith('### ')) {
|
||||
elements.push(
|
||||
<h3 key={index} className="text-sm font-bold text-amber-400 mt-3 mb-1">
|
||||
{line.replace('### ', '')}
|
||||
</h3>
|
||||
);
|
||||
} else if (line.startsWith('> ')) {
|
||||
elements.push(
|
||||
<blockquote key={index} className="my-2 p-2 bg-blue-950/40 border-l-4 border-blue-500 rounded-r text-xs text-blue-200 italic">
|
||||
{line.replace('> ', '')}
|
||||
</blockquote>
|
||||
);
|
||||
} else if (line.trim().startsWith('- ') || line.trim().startsWith('* ')) {
|
||||
elements.push(
|
||||
<li key={index} className="ml-4 list-disc text-xs text-gray-300 my-1">
|
||||
{formatInline(line.trim().replace(/^[-*]\s+/, ''))}
|
||||
</li>
|
||||
);
|
||||
} else if (line.trim() === '---') {
|
||||
elements.push(<hr key={index} className="my-3 border-gray-800" />);
|
||||
} else if (line.trim().length > 0) {
|
||||
elements.push(
|
||||
<p key={index} className="text-xs text-gray-300 leading-relaxed my-1.5">
|
||||
{formatInline(line)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return <div className="space-y-1">{elements}</div>;
|
||||
};
|
||||
|
||||
function formatInline(text: string): React.ReactNode {
|
||||
// Simple bold and inline code formatter
|
||||
const parts = text.split(/(\*\*.*?\*\*|`.*?`)/g);
|
||||
return parts.map((part, i) => {
|
||||
if (part.startsWith('**') && part.endsWith('**')) {
|
||||
return <strong key={i} className="font-semibold text-white">{part.slice(2, -2)}</strong>;
|
||||
}
|
||||
if (part.startsWith('`') && part.endsWith('`')) {
|
||||
return <code key={i} className="px-1.5 py-0.5 bg-gray-800 text-amber-300 font-mono text-[11px] rounded">{part.slice(1, -1)}</code>;
|
||||
}
|
||||
return part;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,388 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { getChallenges, Challenge } from '../api/client';
|
||||
import { MarkdownView } from '../components/MarkdownView';
|
||||
import { BookOpen, ArrowRight, Code, ShieldCheck, Sparkles, X, Save, Layers } from 'lucide-react';
|
||||
|
||||
interface DashboardProps {
|
||||
onSelectChallenge: (id: string) => void;
|
||||
}
|
||||
|
||||
const Dashboard: React.FC<DashboardProps> = ({ onSelectChallenge }) => {
|
||||
const [challenges, setChallenges] = useState<Challenge[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Generator Modal state
|
||||
const [isGenModalOpen, setIsGenModalOpen] = useState(false);
|
||||
const [promptText, setPromptText] = useState('');
|
||||
const [genLanguage, setGenLanguage] = useState('Python');
|
||||
const [genDifficulty, setGenDifficulty] = useState('Medium');
|
||||
const [genSubject, setGenSubject] = useState('General Concepts');
|
||||
const [genMarkdown, setGenMarkdown] = useState('');
|
||||
const [genFilename, setGenFilename] = useState('');
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [genStatusMsg, setGenStatusMsg] = useState('');
|
||||
|
||||
const fetchChallengesList = () => {
|
||||
setLoading(true);
|
||||
getChallenges()
|
||||
.then(setChallenges)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchChallengesList();
|
||||
}, []);
|
||||
|
||||
const handleGenerateAI = async () => {
|
||||
if (!promptText.trim()) {
|
||||
setGenStatusMsg('Please enter a prompt for the AI generator.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
setGenStatusMsg('⏳ Generating challenge via LLM backend...');
|
||||
|
||||
try {
|
||||
const apiHost = typeof window !== 'undefined' && window.location.hostname ? window.location.hostname : '127.0.0.1';
|
||||
const response = await fetch(`http://${apiHost}:8000/challenges/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
prompt: promptText,
|
||||
language: genLanguage,
|
||||
difficulty: genDifficulty,
|
||||
subject: genSubject,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error ${response.status}`);
|
||||
}
|
||||
|
||||
const res = await response.json();
|
||||
if (res.status === 'success') {
|
||||
setGenMarkdown(res.markdown);
|
||||
setGenFilename(res.filename);
|
||||
setGenStatusMsg(`✓ Challenge generated: '${res.title}'`);
|
||||
} else {
|
||||
setGenStatusMsg(`✗ Failed: ${res.message}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setGenStatusMsg(`✗ Error generating challenge: ${err.message}`);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveChallenge = async () => {
|
||||
if (!genFilename.trim() || !genMarkdown.trim()) {
|
||||
setGenStatusMsg('Please provide a filename and markdown content.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setGenStatusMsg('Saving challenge to disk...');
|
||||
|
||||
try {
|
||||
const apiHost = typeof window !== 'undefined' && window.location.hostname ? window.location.hostname : '127.0.0.1';
|
||||
const response = await fetch(`http://${apiHost}:8000/challenges/save`, {
|
||||
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
filename: genFilename,
|
||||
markdown: genMarkdown,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error ${response.status}`);
|
||||
}
|
||||
|
||||
const res = await response.json();
|
||||
if (res.status === 'success') {
|
||||
setGenStatusMsg(`✓ Saved challenge to ${res.filename}`);
|
||||
fetchChallengesList(); // Refresh dashboard list
|
||||
} else {
|
||||
setGenStatusMsg(`✗ Save failed: ${res.message}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setGenStatusMsg(`✗ Error saving challenge: ${err.message}`);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-darkBg text-gray-300">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
<p className="font-medium text-sm">Loading TactiTerm Challenges...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-darkBg text-red-400">
|
||||
<div className="p-6 bg-darkSurface border border-red-900/50 rounded-xl text-center max-w-md">
|
||||
<p className="font-bold mb-2">Failed to load challenges</p>
|
||||
<p className="text-xs text-gray-400 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-blue-600 text-white text-xs font-semibold rounded-lg hover:bg-blue-500"
|
||||
>
|
||||
Retry Connection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-darkBg text-gray-100 p-8 flex flex-col justify-between">
|
||||
<div>
|
||||
<header className="max-w-6xl mx-auto mb-10 flex justify-between items-center border-b border-darkBorder pb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-extrabold text-white tracking-tight flex items-center gap-3">
|
||||
<Code className="text-blue-500" size={32} />
|
||||
TactiTerm Workspace
|
||||
</h1>
|
||||
<p className="text-gray-400 text-sm mt-1">
|
||||
Build programming confidence from scratch with interactive AI guidance
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-darkSurface border border-darkBorder rounded-full text-xs text-gray-300">
|
||||
<ShieldCheck size={14} className="text-green-400" />
|
||||
<span>Local Engine Active (10 Languages)</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-6xl mx-auto">
|
||||
<h2 className="text-xl font-bold text-gray-200 mb-6">Select a Challenge</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{challenges.map((c) => {
|
||||
const cid = c.id || c.challenge_id;
|
||||
return (
|
||||
<div
|
||||
key={cid}
|
||||
onClick={() => onSelectChallenge(cid)}
|
||||
className="group p-6 bg-darkSurface border border-darkBorder rounded-2xl hover:border-blue-500/80 hover:shadow-xl hover:shadow-blue-500/10 cursor-pointer transition-all duration-200 flex flex-col justify-between"
|
||||
>
|
||||
<div>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="p-2.5 bg-blue-500/10 border border-blue-500/20 rounded-xl text-blue-400">
|
||||
<BookOpen size={20} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2.5 py-0.5 bg-gray-800 text-gray-300 text-[10px] uppercase font-bold rounded-md tracking-wider border border-gray-700">
|
||||
{c.language}
|
||||
</span>
|
||||
<span
|
||||
className={`px-2.5 py-0.5 text-[10px] uppercase font-bold rounded-md tracking-wider border ${
|
||||
c.difficulty.toLowerCase() === 'easy'
|
||||
? 'bg-green-500/10 text-green-400 border-green-500/20'
|
||||
: c.difficulty.toLowerCase() === 'medium'
|
||||
? 'bg-amber-500/10 text-amber-400 border-amber-500/20'
|
||||
: 'bg-red-500/10 text-red-400 border-red-500/20'
|
||||
}`}
|
||||
>
|
||||
{c.difficulty}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-bold text-white group-hover:text-blue-400 transition-colors mb-2">
|
||||
{c.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-1.5 text-xs text-amber-400 font-semibold mb-2">
|
||||
<Layers size={13} />
|
||||
<span>{c.subject || 'General Concepts'}</span>
|
||||
</div>
|
||||
<p className="text-gray-400 text-xs line-clamp-3 leading-relaxed">
|
||||
{c.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-darkBorder/50 flex justify-between items-center text-xs font-semibold text-blue-400 group-hover:translate-x-1 transition-transform">
|
||||
<span>Start Challenge</span>
|
||||
<ArrowRight size={14} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Footer Generator Action Section */}
|
||||
<footer className="max-w-6xl mx-auto w-full mt-12 pt-6 border-t border-darkBorder flex justify-between items-center">
|
||||
<p className="text-xs text-gray-500">TactiTerm Software Engineering Tutor</p>
|
||||
<button
|
||||
onClick={() => setIsGenModalOpen(true)}
|
||||
className="px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-400 hover:to-amber-500 text-white font-bold text-xs rounded-xl shadow-lg shadow-amber-500/20 transition-all flex items-center gap-2"
|
||||
>
|
||||
<Sparkles size={16} />
|
||||
<span>Generate New Challenge with AI</span>
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
{/* AI Challenge Generator Modal */}
|
||||
{isGenModalOpen && (
|
||||
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center p-6 z-50">
|
||||
<div className="bg-darkSurface border border-darkBorder rounded-2xl w-full max-w-5xl h-[85vh] flex flex-col overflow-hidden shadow-2xl">
|
||||
{/* Modal Header */}
|
||||
<div className="p-4 border-b border-darkBorder bg-darkBg flex justify-between items-center">
|
||||
<h2 className="text-base font-bold text-amber-400 flex items-center gap-2">
|
||||
<Sparkles size={18} /> AI Challenge Generator (GenTUI Web)
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setIsGenModalOpen(false)}
|
||||
className="p-1 hover:bg-gray-800 text-gray-400 hover:text-white rounded-lg"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Body */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Controls Column */}
|
||||
<div className="w-1/3 p-5 border-r border-darkBorder bg-darkBg flex flex-col gap-4 overflow-y-auto">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-300 mb-1">AI Prompt:</label>
|
||||
<textarea
|
||||
value={promptText}
|
||||
onChange={(e) => setPromptText(e.target.value)}
|
||||
placeholder="e.g. Medium difficulty Rust challenge on Borrowing & References"
|
||||
className="w-full h-24 bg-darkSurface border border-darkBorder rounded-xl p-3 text-xs text-gray-200 placeholder-gray-500 focus:outline-none focus:border-amber-500 resize-none font-sans"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-300 mb-1">Language:</label>
|
||||
<select
|
||||
value={genLanguage}
|
||||
onChange={(e) => setGenLanguage(e.target.value)}
|
||||
className="w-full bg-darkSurface border border-darkBorder text-gray-200 text-xs rounded-lg p-2 focus:outline-none focus:border-amber-500"
|
||||
>
|
||||
{['Python', 'C#', 'C++', 'Java', 'JavaScript', 'TypeScript', 'Rust', 'Lua', 'HTML', 'Go'].map(
|
||||
(lang) => (
|
||||
<option key={lang} value={lang}>
|
||||
{lang}
|
||||
</option>
|
||||
)
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-300 mb-1">Difficulty:</label>
|
||||
<select
|
||||
value={genDifficulty}
|
||||
onChange={(e) => setGenDifficulty(e.target.value)}
|
||||
className="w-full bg-darkSurface border border-darkBorder text-gray-200 text-xs rounded-lg p-2 focus:outline-none focus:border-amber-500"
|
||||
>
|
||||
{['Easy', 'Medium', 'Hard', 'Advanced'].map((diff) => (
|
||||
<option key={diff} value={diff}>
|
||||
{diff}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-300 mb-1">Subject / Topic:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={genSubject}
|
||||
onChange={(e) => setGenSubject(e.target.value)}
|
||||
placeholder="e.g. Memory Management, Data Structures"
|
||||
className="w-full bg-darkSurface border border-darkBorder rounded-lg p-2 text-xs text-gray-200 focus:outline-none focus:border-amber-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleGenerateAI}
|
||||
disabled={isGenerating}
|
||||
className="w-full py-2.5 bg-amber-600 hover:bg-amber-500 disabled:opacity-50 text-white font-bold text-xs rounded-xl shadow-md transition-all flex items-center justify-center gap-2 mt-2"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<Sparkles size={16} />
|
||||
)}
|
||||
<span>Generate with AI</span>
|
||||
</button>
|
||||
|
||||
<hr className="border-darkBorder my-1" />
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-300 mb-1">Filename (.md):</label>
|
||||
<input
|
||||
type="text"
|
||||
value={genFilename}
|
||||
onChange={(e) => setGenFilename(e.target.value)}
|
||||
placeholder="007-new-challenge.md"
|
||||
className="w-full bg-darkSurface border border-darkBorder rounded-lg p-2 text-xs text-gray-200 focus:outline-none focus:border-amber-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSaveChallenge}
|
||||
disabled={isSaving || !genMarkdown.trim() || !genFilename.trim()}
|
||||
className="w-full py-2.5 bg-green-600 hover:bg-green-500 disabled:opacity-50 text-white font-bold text-xs rounded-xl shadow-md transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
{isSaving ? (
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<Save size={16} />
|
||||
)}
|
||||
<span>Save Challenge to Disk</span>
|
||||
</button>
|
||||
|
||||
{genStatusMsg && (
|
||||
<p className="text-xs text-amber-300 font-mono bg-amber-950/30 p-2.5 rounded-lg border border-amber-800/40">
|
||||
{genStatusMsg}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Raw Editor & Preview Column */}
|
||||
<div className="w-2/3 flex flex-col bg-[#1e1e1e] overflow-hidden">
|
||||
<div className="grid grid-cols-2 flex-1 overflow-hidden">
|
||||
{/* Markdown Editor */}
|
||||
<div className="flex flex-col border-r border-darkBorder p-4 overflow-hidden">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-gray-400 mb-2">Raw Markdown (.md) Editor</h3>
|
||||
<textarea
|
||||
value={genMarkdown}
|
||||
onChange={(e) => setGenMarkdown(e.target.value)}
|
||||
placeholder="# Challenge: Title..."
|
||||
className="w-full flex-1 bg-black/50 border border-darkBorder rounded-xl p-3 text-xs text-emerald-300 font-mono focus:outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Rendered Preview */}
|
||||
<div className="flex flex-col p-4 bg-darkBg overflow-y-auto">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-blue-400 mb-2">Live Rendered Preview</h3>
|
||||
<div className="flex-1 p-3 bg-darkSurface border border-darkBorder rounded-xl">
|
||||
<MarkdownView content={genMarkdown || '*Generated preview will appear here...*'} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
@@ -0,0 +1,732 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Editor } from '@monaco-editor/react';
|
||||
import { registerMonacoCompletions } from '../utils/monacoCompletions';
|
||||
import {
|
||||
getChallenges,
|
||||
runCode,
|
||||
lintCode,
|
||||
getGuidance,
|
||||
getHandbookCatalog,
|
||||
getHandbookExample,
|
||||
Challenge,
|
||||
HandbookTopic,
|
||||
HandbookCatalog,
|
||||
} from '../api/client';
|
||||
import { MarkdownView } from '../components/MarkdownView';
|
||||
import {
|
||||
MessageSquare,
|
||||
ChevronLeft,
|
||||
Play,
|
||||
CheckCircle2,
|
||||
Sparkles,
|
||||
Code,
|
||||
Send,
|
||||
HelpCircle,
|
||||
BookMarked,
|
||||
X,
|
||||
Search,
|
||||
Zap,
|
||||
BookOpen,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Terminal,
|
||||
GripVertical,
|
||||
GripHorizontal,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface WorkspaceProps {
|
||||
challengeId: string;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
|
||||
const [allChallenges, setAllChallenges] = useState<Challenge[]>([]);
|
||||
const [currentChallenge, setCurrentChallenge] = useState<Challenge | null>(null);
|
||||
|
||||
const [code, setCode] = useState('');
|
||||
const [stdinText, setStdinText] = useState('');
|
||||
|
||||
// Resizable Layout Dimensions
|
||||
const [leftWidth, setLeftWidth] = useState(320); // Left Challenge Panel width in px
|
||||
const [rightWidth, setRightWidth] = useState(350); // Right Sidebar width in px
|
||||
const [outputHeight, setOutputHeight] = useState(180); // Output Console height in px
|
||||
|
||||
const isDraggingLeft = useRef(false);
|
||||
const isDraggingRight = useRef(false);
|
||||
const isDraggingOutput = useRef(false);
|
||||
|
||||
const [output, setOutput] = useState<{ status: 'idle' | 'success' | 'error'; text: string }>({
|
||||
status: 'idle',
|
||||
text: 'Execution and lint output will appear here...',
|
||||
});
|
||||
|
||||
// Right sidebar state: 'mentor' | 'handbook' | 'closed'
|
||||
const [rightSidebarMode, setRightSidebarMode] = useState<'mentor' | 'handbook' | 'closed'>('mentor');
|
||||
|
||||
// Mentor state
|
||||
const [guidance, setGuidance] = useState<string>('Ask the mentor a question or click Check Answer below...');
|
||||
const [userQuestion, setUserQuestion] = useState('');
|
||||
const [isLoadingGuidance, setIsLoadingGuidance] = useState(false);
|
||||
|
||||
// Handbook W3Schools state
|
||||
const [handbookCatalog, setHandbookCatalog] = useState<HandbookCatalog>({ functions: [], subjects: [] });
|
||||
const [handbookTab, setHandbookTab] = useState<'functions' | 'subjects'>('functions');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedTopic, setSelectedTopic] = useState<HandbookTopic | null>(null);
|
||||
const [handbookExample, setHandbookExample] = useState<string>(
|
||||
'Select a built-in function or topic above to view W3Schools reference card...'
|
||||
);
|
||||
const [isLoadingExample, setIsLoadingExample] = useState(false);
|
||||
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [isLinting, setIsLinting] = useState(false);
|
||||
|
||||
// Load all challenges
|
||||
useEffect(() => {
|
||||
getChallenges()
|
||||
.then((challenges) => {
|
||||
setAllChallenges(challenges);
|
||||
const match = challenges.find((c) => (c.id || c.challenge_id) === challengeId);
|
||||
if (match) {
|
||||
setCurrentChallenge(match);
|
||||
} else if (challenges.length > 0) {
|
||||
setCurrentChallenge(challenges[0]);
|
||||
}
|
||||
})
|
||||
.catch((err) => console.error('Failed to load challenges:', err));
|
||||
}, [challengeId]);
|
||||
|
||||
// Fetch handbook catalog when current challenge language changes
|
||||
useEffect(() => {
|
||||
if (currentChallenge) {
|
||||
getHandbookCatalog(currentChallenge.language)
|
||||
.then((catalog) => {
|
||||
setHandbookCatalog(catalog);
|
||||
if (catalog.functions.length > 0) {
|
||||
setSelectedTopic(catalog.functions[0]);
|
||||
} else if (catalog.subjects.length > 0) {
|
||||
setSelectedTopic(catalog.subjects[0]);
|
||||
}
|
||||
})
|
||||
.catch((err) => console.error('Failed to load handbook catalog:', err));
|
||||
}
|
||||
}, [currentChallenge]);
|
||||
|
||||
// Global mousemove and mouseup listeners for drag-to-resize splitters
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (isDraggingLeft.current) {
|
||||
const newWidth = Math.max(180, Math.min(600, e.clientX));
|
||||
setLeftWidth(newWidth);
|
||||
} else if (isDraggingRight.current) {
|
||||
const newWidth = Math.max(220, Math.min(700, window.innerWidth - e.clientX));
|
||||
setRightWidth(newWidth);
|
||||
} else if (isDraggingOutput.current) {
|
||||
const newHeight = Math.max(80, Math.min(650, window.innerHeight - e.clientY));
|
||||
setOutputHeight(newHeight);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
isDraggingLeft.current = false;
|
||||
isDraggingRight.current = false;
|
||||
isDraggingOutput.current = false;
|
||||
document.body.style.cursor = 'default';
|
||||
document.body.style.userSelect = 'auto';
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startDraggingLeft = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
isDraggingLeft.current = true;
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
};
|
||||
|
||||
const startDraggingRight = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
isDraggingRight.current = true;
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
};
|
||||
|
||||
const startDraggingOutput = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
isDraggingOutput.current = true;
|
||||
document.body.style.cursor = 'row-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
};
|
||||
|
||||
const handleSelectChallenge = (cid: string) => {
|
||||
const match = allChallenges.find((c) => (c.id || c.challenge_id) === cid);
|
||||
if (match) {
|
||||
setCurrentChallenge(match);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunCode = async (overrideStdin?: string) => {
|
||||
if (!code.trim() || !currentChallenge) return;
|
||||
setIsRunning(true);
|
||||
setOutput({ status: 'idle', text: 'Executing code...' });
|
||||
const inputToSend = overrideStdin !== undefined ? overrideStdin : stdinText;
|
||||
|
||||
try {
|
||||
const res = await runCode(currentChallenge.language.toLowerCase() || 'python', code, inputToSend);
|
||||
if (res.exit_code === 0) {
|
||||
setOutput({
|
||||
status: 'success',
|
||||
text: res.stdout?.trim() ? `Output:\n${res.stdout}` : '✓ Code executed successfully (exit code 0, no stdout).',
|
||||
});
|
||||
} else {
|
||||
const err = res.stderr?.trim() || res.stdout?.trim() || 'Unknown runtime error';
|
||||
setOutput({ status: 'error', text: `✗ Runtime Error (exit code ${res.exit_code}):\n${err}` });
|
||||
}
|
||||
} catch (err: any) {
|
||||
setOutput({ status: 'error', text: `✗ Error running code: ${err.message}` });
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendStdin = () => {
|
||||
handleRunCode(stdinText);
|
||||
};
|
||||
|
||||
const handleLintCode = async () => {
|
||||
if (!code.trim() || !currentChallenge) return;
|
||||
setIsLinting(true);
|
||||
setOutput({ status: 'idle', text: 'Running syntax & style lint...' });
|
||||
try {
|
||||
const res = await lintCode(currentChallenge.language.toLowerCase() || 'python', code);
|
||||
if (res.exit_code === 0) {
|
||||
setOutput({ status: 'success', text: '✓ Syntax & Style clean! No linting errors detected.' });
|
||||
} else {
|
||||
const raw = (res.stdout || '') + '\n' + (res.stderr || '');
|
||||
setOutput({ status: 'error', text: `✗ Lint Error(s):\n${raw.trim()}` });
|
||||
}
|
||||
} catch (err: any) {
|
||||
setOutput({ status: 'error', text: `✗ Error linting code: ${err.message}` });
|
||||
} finally {
|
||||
setIsLinting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAskMentor = async (questionToAsk?: string) => {
|
||||
if (!currentChallenge) return;
|
||||
const q = questionToAsk !== undefined ? questionToAsk : userQuestion.trim();
|
||||
const cid = currentChallenge.id || currentChallenge.challenge_id;
|
||||
|
||||
setIsLoadingGuidance(true);
|
||||
setRightSidebarMode('mentor');
|
||||
setGuidance('⏳ **Mentor is thinking and generating guidance...**\n\n*Analyzing your code, task requirements, and question...*');
|
||||
setUserQuestion('');
|
||||
|
||||
try {
|
||||
const res = await getGuidance(
|
||||
cid,
|
||||
currentChallenge.language.toLowerCase() || 'python',
|
||||
code,
|
||||
q
|
||||
);
|
||||
const qHeader = q ? `### Question / Evaluation:\n> ${q}\n\n---\n\n` : '';
|
||||
setGuidance(`${qHeader}${res.mentor_response || 'No guidance received.'}`);
|
||||
} catch (err: any) {
|
||||
setGuidance(`⚠️ Error consulting Mentor: ${err.message}`);
|
||||
} finally {
|
||||
setIsLoadingGuidance(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectHandbookTopic = async (topic: HandbookTopic) => {
|
||||
if (!currentChallenge) return;
|
||||
setSelectedTopic(topic);
|
||||
setIsLoadingExample(true);
|
||||
setHandbookExample(`⏳ **Generating W3Schools reference card for '${topic.title}' in ${currentChallenge.language}...**\n\n*Consulting LLM backend...*`);
|
||||
|
||||
try {
|
||||
const res = await getHandbookExample(currentChallenge.language, topic.id, topic.title);
|
||||
if (res.status === 'success') {
|
||||
setHandbookExample(res.example_markdown);
|
||||
} else {
|
||||
setHandbookExample(`⚠️ ${res.message}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setHandbookExample(`⚠️ Error generating reference card: ${err.message}`);
|
||||
} finally {
|
||||
setIsLoadingExample(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckAnswer = () => {
|
||||
const checkPrompt =
|
||||
'Please evaluate my code implementation against all the requirements of this challenge. Check if my solution is complete and correct, point out any missing requirements or edge cases, and give me feedback on my answer.';
|
||||
handleAskMentor(checkPrompt);
|
||||
};
|
||||
|
||||
const handleKeyDownQuestion = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleAskMentor();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDownStdin = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSendStdin();
|
||||
}
|
||||
};
|
||||
|
||||
const activeCId = currentChallenge?.id || currentChallenge?.challenge_id || '';
|
||||
|
||||
// Filter handbook catalog based on active tab and search query
|
||||
const rawList = handbookTab === 'functions' ? handbookCatalog.functions : handbookCatalog.subjects;
|
||||
const filteredList = rawList.filter((item) =>
|
||||
searchQuery.trim() === ''
|
||||
? true
|
||||
: item.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
item.desc.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-darkBg text-gray-100 font-sans overflow-hidden">
|
||||
{/* Header Bar */}
|
||||
<header className="h-14 border-b border-darkBorder bg-darkSurface px-4 flex justify-between items-center select-none shadow-md z-10 shrink-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="p-1.5 hover:bg-gray-800 text-gray-400 hover:text-white rounded-lg transition-colors flex items-center gap-1 text-xs"
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
<span>Dashboard</span>
|
||||
</button>
|
||||
<div className="h-4 w-px bg-darkBorder" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Code className="text-blue-500" size={20} />
|
||||
<span className="font-bold text-sm text-white">TactiTerm</span>
|
||||
</div>
|
||||
|
||||
<div className="h-4 w-px bg-darkBorder" />
|
||||
|
||||
{/* Challenge Selector */}
|
||||
<select
|
||||
value={activeCId}
|
||||
onChange={(e) => handleSelectChallenge(e.target.value)}
|
||||
className="bg-darkBg border border-darkBorder text-gray-200 text-xs rounded-lg px-3 py-1.5 focus:outline-none focus:border-blue-500 font-medium"
|
||||
>
|
||||
{allChallenges.map((c) => {
|
||||
const cid = c.id || c.challenge_id;
|
||||
return (
|
||||
<option key={cid} value={cid}>
|
||||
{c.name} ({c.difficulty})
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Action Controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleRunCode()}
|
||||
disabled={isRunning || !code.trim()}
|
||||
className="px-3.5 py-1.5 bg-green-600 hover:bg-green-500 disabled:opacity-50 text-white font-semibold text-xs rounded-lg transition-all flex items-center gap-1.5 shadow-sm"
|
||||
>
|
||||
{isRunning ? (
|
||||
<div className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<Play size={14} fill="currentColor" />
|
||||
)}
|
||||
<span>Run</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleLintCode}
|
||||
disabled={isLinting || !code.trim()}
|
||||
className="px-3.5 py-1.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white font-semibold text-xs rounded-lg transition-all flex items-center gap-1.5 shadow-sm"
|
||||
>
|
||||
{isLinting ? (
|
||||
<div className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 size={14} />
|
||||
)}
|
||||
<span>Lint</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleCheckAnswer}
|
||||
disabled={isLoadingGuidance || !code.trim()}
|
||||
className="px-3.5 py-1.5 bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 text-white font-semibold text-xs rounded-lg transition-all flex items-center gap-1.5 shadow-sm"
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
<span>Check Answer</span>
|
||||
</button>
|
||||
|
||||
{/* Dual Sidebar Toggles */}
|
||||
<button
|
||||
onClick={() => setRightSidebarMode(rightSidebarMode === 'mentor' ? 'closed' : 'mentor')}
|
||||
className={`px-3.5 py-1.5 font-semibold text-xs rounded-lg transition-all flex items-center gap-1.5 shadow-sm border ${
|
||||
rightSidebarMode === 'mentor'
|
||||
? 'bg-amber-500/20 text-amber-300 border-amber-500/40'
|
||||
: 'bg-gray-800 text-gray-300 hover:bg-gray-700 border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<MessageSquare size={14} />
|
||||
<span>Mentor</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setRightSidebarMode(rightSidebarMode === 'handbook' ? 'closed' : 'handbook')}
|
||||
className={`px-3.5 py-1.5 font-semibold text-xs rounded-lg transition-all flex items-center gap-1.5 shadow-sm border ${
|
||||
rightSidebarMode === 'handbook'
|
||||
? 'bg-indigo-500/20 text-indigo-300 border-indigo-500/40'
|
||||
: 'bg-gray-800 text-gray-300 hover:bg-gray-700 border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<BookMarked size={14} />
|
||||
<span>W3Schools Handbook</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Container */}
|
||||
<div className="flex flex-1 overflow-hidden relative">
|
||||
{/* Left Column: Challenge Specifications (Scalable Width) */}
|
||||
<div
|
||||
style={{ width: `${leftWidth}px` }}
|
||||
className="border-r border-darkBorder bg-darkBg flex flex-col overflow-hidden shrink-0"
|
||||
>
|
||||
<div className="p-3 bg-darkSurface border-b border-darkBorder flex justify-between items-center">
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-gray-400">Challenge Details</h2>
|
||||
{currentChallenge && (
|
||||
<span className="px-2 py-0.5 bg-blue-500/10 border border-blue-500/20 text-blue-400 text-[10px] uppercase font-bold rounded">
|
||||
{currentChallenge.language}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-5 overflow-y-auto space-y-6">
|
||||
{currentChallenge ? (
|
||||
<>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white mb-2">{currentChallenge.name}</h1>
|
||||
<span
|
||||
className={`inline-block px-2.5 py-0.5 text-[10px] uppercase font-bold rounded border ${
|
||||
currentChallenge.difficulty.toLowerCase() === 'easy'
|
||||
? 'bg-green-500/10 text-green-400 border-green-500/20'
|
||||
: currentChallenge.difficulty.toLowerCase() === 'medium'
|
||||
? 'bg-amber-500/10 text-amber-400 border-amber-500/20'
|
||||
: 'bg-red-500/10 text-red-400 border-red-500/20'
|
||||
}`}
|
||||
>
|
||||
{currentChallenge.difficulty}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-blue-400 mb-2">Description</h3>
|
||||
<p className="text-xs text-gray-300 leading-relaxed bg-darkSurface p-3 rounded-xl border border-darkBorder/50">
|
||||
{currentChallenge.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{currentChallenge.requirements && currentChallenge.requirements.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-blue-400 mb-2">Requirements</h3>
|
||||
<ul className="space-y-1.5">
|
||||
{currentChallenge.requirements.map((req, idx) => (
|
||||
<li key={idx} className="text-xs text-gray-300 flex items-start gap-2">
|
||||
<span className="text-blue-500 font-bold">•</span>
|
||||
<span>{req}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentChallenge.hints && currentChallenge.hints.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-amber-400 mb-2">Hints</h3>
|
||||
<ul className="space-y-1.5">
|
||||
{currentChallenge.hints.map((hint, idx) => (
|
||||
<li key={idx} className="text-xs text-gray-400 flex items-start gap-2 bg-amber-500/5 p-2 rounded-lg border border-amber-500/10">
|
||||
<HelpCircle size={14} className="text-amber-400 shrink-0 mt-0.5" />
|
||||
<span>{hint}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-gray-400">Loading details...</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resizable Splitter Handle: Left Column */}
|
||||
<div
|
||||
onMouseDown={startDraggingLeft}
|
||||
className="w-1.5 bg-darkBorder hover:bg-blue-500 active:bg-blue-600 cursor-col-resize flex items-center justify-center shrink-0 group transition-colors"
|
||||
title="Drag to resize Challenge Panel"
|
||||
>
|
||||
<GripVertical size={10} className="text-gray-600 group-hover:text-white" />
|
||||
</div>
|
||||
|
||||
{/* Middle Column: Monaco Code Editor & Resizable Output Console */}
|
||||
<div className="flex-1 flex flex-col bg-[#1e1e1e] overflow-hidden">
|
||||
<div className="bg-darkBg text-gray-400 px-4 py-2 text-xs flex justify-between items-center border-b border-darkBorder shrink-0">
|
||||
<span className="font-mono text-blue-400">main.py</span>
|
||||
<span className="text-[11px] text-gray-500">TactiTerm IDE</span>
|
||||
</div>
|
||||
|
||||
{/* Code Editor */}
|
||||
<div className="flex-1 relative">
|
||||
<Editor
|
||||
height="100%"
|
||||
defaultLanguage={currentChallenge?.language.toLowerCase() || 'python'}
|
||||
language={currentChallenge?.language.toLowerCase() || 'python'}
|
||||
defaultValue=""
|
||||
value={code}
|
||||
theme="vs-dark"
|
||||
onMount={(_, monaco) => registerMonacoCompletions(monaco)}
|
||||
onChange={(val) => setCode(val || '')}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
automaticLayout: true,
|
||||
scrollBeyondLastLine: false,
|
||||
padding: { top: 12 },
|
||||
quickSuggestions: true,
|
||||
suggestOnTriggerCharacters: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Resizable Splitter Handle: Output Console */}
|
||||
<div
|
||||
onMouseDown={startDraggingOutput}
|
||||
className="h-1.5 bg-darkBorder hover:bg-blue-500 active:bg-blue-600 cursor-row-resize flex items-center justify-center shrink-0 group transition-colors"
|
||||
title="Drag up/down to resize Output Console"
|
||||
>
|
||||
<GripHorizontal size={10} className="text-gray-600 group-hover:text-white" />
|
||||
</div>
|
||||
|
||||
{/* Scalable Output & Stdin Input Terminal Area */}
|
||||
<div
|
||||
style={{ height: `${outputHeight}px` }}
|
||||
className="border-t border-darkBorder bg-black/90 p-4 font-mono text-xs flex flex-col overflow-hidden shrink-0"
|
||||
>
|
||||
<div className="flex justify-between items-center text-gray-400 pb-2 mb-2 border-b border-gray-800 text-[11px] uppercase font-bold shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal size={14} className="text-blue-400" />
|
||||
<span>Output Console</span>
|
||||
{output.status === 'success' && <span className="text-green-400 lowercase font-normal">(clean)</span>}
|
||||
{output.status === 'error' && <span className="text-red-400 lowercase font-normal">(error)</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<pre className={`flex-1 overflow-y-auto whitespace-pre-wrap leading-relaxed ${
|
||||
output.status === 'error' ? 'text-red-400' : 'text-emerald-400'
|
||||
}`}>
|
||||
{output.text}
|
||||
</pre>
|
||||
|
||||
{/* Interactive Stdin Input Bar */}
|
||||
<div className="mt-3 pt-3 border-t border-gray-800 flex gap-2 font-sans shrink-0">
|
||||
<input
|
||||
type="text"
|
||||
value={stdinText}
|
||||
onChange={(e) => setStdinText(e.target.value)}
|
||||
onKeyDown={handleKeyDownStdin}
|
||||
placeholder="Type program input (stdin) and hit Enter to run with input..."
|
||||
className="flex-1 bg-darkSurface border border-darkBorder rounded-lg px-3 py-1.5 text-xs text-gray-200 placeholder-gray-500 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSendStdin}
|
||||
disabled={isRunning}
|
||||
className="px-3.5 py-1.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white font-semibold text-xs rounded-lg transition-all flex items-center gap-1.5 shadow-sm"
|
||||
>
|
||||
<Send size={13} />
|
||||
<span>Send Input</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resizable Splitter Handle: Right Column */}
|
||||
{rightSidebarMode !== 'closed' && (
|
||||
<div
|
||||
onMouseDown={startDraggingRight}
|
||||
className="w-1.5 bg-darkBorder hover:bg-blue-500 active:bg-blue-600 cursor-col-resize flex items-center justify-center shrink-0 group transition-colors"
|
||||
title="Drag to resize Right Sidebar"
|
||||
>
|
||||
<GripVertical size={10} className="text-gray-600 group-hover:text-white" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right Column Mode 1: Mentor Sidebar (Scalable Width) */}
|
||||
{rightSidebarMode === 'mentor' && (
|
||||
<div
|
||||
style={{ width: `${rightWidth}px` }}
|
||||
className="border-l border-darkBorder bg-darkSurface flex flex-col overflow-hidden shrink-0"
|
||||
>
|
||||
<div className="p-3 border-b border-darkBorder bg-darkBg flex justify-between items-center">
|
||||
<h2 className="flex items-center gap-2 font-bold text-amber-400 text-xs uppercase tracking-wider">
|
||||
<MessageSquare size={16} /> Mentor
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setRightSidebarMode('closed')}
|
||||
className="text-gray-400 hover:text-white text-xs px-2 py-0.5 rounded hover:bg-gray-800"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mentor Guidance View */}
|
||||
<div className="flex-1 p-4 overflow-y-auto space-y-4">
|
||||
<div className="p-4 bg-darkBg border border-darkBorder rounded-xl shadow-inner">
|
||||
<MarkdownView content={guidance} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Question Input Area */}
|
||||
<div className="p-4 border-t border-darkBorder bg-darkBg flex flex-col gap-2">
|
||||
<label className="text-[11px] font-bold text-gray-400 uppercase tracking-wider">
|
||||
Ask Question (Enter to Send):
|
||||
</label>
|
||||
<textarea
|
||||
value={userQuestion}
|
||||
onChange={(e) => setUserQuestion(e.target.value)}
|
||||
onKeyDown={handleKeyDownQuestion}
|
||||
placeholder="e.g. How should I structure my loop? Why is my variable returning None?"
|
||||
className="w-full h-20 bg-darkSurface border border-darkBorder rounded-lg p-2.5 text-xs text-gray-200 placeholder-gray-500 focus:outline-none focus:border-amber-500 resize-none font-sans"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleAskMentor()}
|
||||
disabled={isLoadingGuidance}
|
||||
className="flex-1 py-2 bg-amber-600 hover:bg-amber-500 disabled:opacity-50 text-white font-semibold text-xs rounded-lg transition-all flex items-center justify-center gap-1.5 shadow-sm"
|
||||
>
|
||||
<Send size={14} />
|
||||
<span>Ask Mentor</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleCheckAnswer}
|
||||
disabled={isLoadingGuidance || !code.trim()}
|
||||
className="py-2 px-3 bg-emerald-700 hover:bg-emerald-600 disabled:opacity-50 text-white font-semibold text-xs rounded-lg transition-all flex items-center justify-center gap-1"
|
||||
title="Check Answer"
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
<span>Check</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right Column Mode 2: W3Schools-Style Handbook Sidebar (Scalable Width) */}
|
||||
{rightSidebarMode === 'handbook' && (
|
||||
<div
|
||||
style={{ width: `${rightWidth}px` }}
|
||||
className="border-l border-darkBorder bg-darkSurface flex flex-col overflow-hidden shrink-0"
|
||||
>
|
||||
<div className="p-3 border-b border-darkBorder bg-darkBg flex justify-between items-center">
|
||||
<h2 className="flex items-center gap-2 font-bold text-indigo-400 text-xs uppercase tracking-wider">
|
||||
<BookMarked size={16} /> W3Schools Reference ({currentChallenge?.language || 'Python'})
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setRightSidebarMode('closed')}
|
||||
className="text-gray-400 hover:text-white text-xs px-2 py-0.5 rounded hover:bg-gray-800"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Category Tabs & Search Bar */}
|
||||
<div className="p-3 border-b border-darkBorder bg-darkBg flex flex-col gap-2.5">
|
||||
<div className="flex bg-darkSurface p-1 rounded-xl border border-darkBorder">
|
||||
<button
|
||||
onClick={() => setHandbookTab('functions')}
|
||||
className={`flex-1 py-1.5 text-[11px] font-bold rounded-lg transition-all flex items-center justify-center gap-1.5 ${
|
||||
handbookTab === 'functions'
|
||||
? 'bg-indigo-600 text-white shadow-sm'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Zap size={13} />
|
||||
<span>Built-ins</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setHandbookTab('subjects')}
|
||||
className={`flex-1 py-1.5 text-[11px] font-bold rounded-lg transition-all flex items-center justify-center gap-1.5 ${
|
||||
handbookTab === 'subjects'
|
||||
? 'bg-indigo-600 text-white shadow-sm'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<BookOpen size={13} />
|
||||
<span>Subjects</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-2.5 top-2.5 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Filter functions or topics..."
|
||||
className="w-full bg-darkSurface border border-darkBorder rounded-lg pl-8 pr-3 py-1.5 text-xs text-gray-200 focus:outline-none focus:border-indigo-500 font-sans"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Topic Catalog List */}
|
||||
<div className="p-3 border-b border-darkBorder bg-darkBg flex flex-col gap-1.5 max-h-48 overflow-y-auto">
|
||||
{filteredList.length === 0 ? (
|
||||
<p className="text-xs text-gray-500 italic p-2">No matching functions or topics found.</p>
|
||||
) : (
|
||||
filteredList.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleSelectHandbookTopic(item)}
|
||||
className={`text-left p-2 rounded-lg text-xs transition-all border ${
|
||||
selectedTopic?.id === item.id
|
||||
? 'bg-indigo-600/20 border-indigo-500/50 text-indigo-300 font-bold'
|
||||
: 'bg-darkSurface border-darkBorder/50 text-gray-300 hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<div className="font-semibold flex items-center gap-1.5">
|
||||
{handbookTab === 'functions' ? <Zap size={12} className="text-amber-400" /> : <BookOpen size={12} className="text-blue-400" />}
|
||||
<span>{item.title}</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-gray-400 font-normal line-clamp-1 mt-0.5">{item.desc}</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Generated LLM W3Schools Reference Card View */}
|
||||
<div className="flex-1 p-4 overflow-y-auto">
|
||||
<div className="p-4 bg-darkBg border border-darkBorder rounded-xl shadow-inner">
|
||||
<MarkdownView content={handbookExample} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Workspace;
|
||||
@@ -0,0 +1,281 @@
|
||||
import type { Monaco } from '@monaco-editor/react';
|
||||
|
||||
let registered = false;
|
||||
|
||||
export function registerMonacoCompletions(monaco: Monaco) {
|
||||
if (registered) return;
|
||||
registered = true;
|
||||
|
||||
// Completion items for Python
|
||||
monaco.languages.registerCompletionItemProvider('python', {
|
||||
triggerCharacters: ['.'],
|
||||
provideCompletionItems: (model, position) => {
|
||||
const lineUntilPosition = model.getValueInRange({
|
||||
startLineNumber: position.lineNumber,
|
||||
startColumn: 1,
|
||||
endLineNumber: position.lineNumber,
|
||||
endColumn: position.column,
|
||||
});
|
||||
|
||||
const word = model.getWordUntilPosition(position);
|
||||
const range = {
|
||||
startLineNumber: position.lineNumber,
|
||||
endLineNumber: position.lineNumber,
|
||||
startColumn: word.startColumn,
|
||||
endColumn: word.endColumn,
|
||||
};
|
||||
|
||||
const suggestions = [
|
||||
{
|
||||
label: 'print',
|
||||
kind: monaco.languages.CompletionItemKind.Function,
|
||||
insertText: 'print(${1:value})',
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: 'Prints specified objects to standard output.',
|
||||
range,
|
||||
},
|
||||
{
|
||||
label: 'len',
|
||||
kind: monaco.languages.CompletionItemKind.Function,
|
||||
insertText: 'len(${1:obj})',
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: 'Returns the number of items in a container.',
|
||||
range,
|
||||
},
|
||||
{
|
||||
label: 'range',
|
||||
kind: monaco.languages.CompletionItemKind.Function,
|
||||
insertText: 'range(${1:stop})',
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: 'Generates a sequence of numbers.',
|
||||
range,
|
||||
},
|
||||
{
|
||||
label: 'enumerate',
|
||||
kind: monaco.languages.CompletionItemKind.Function,
|
||||
insertText: 'enumerate(${1:iterable})',
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: 'Yields index and value tuples from an iterable.',
|
||||
range,
|
||||
},
|
||||
{
|
||||
label: 'append',
|
||||
kind: monaco.languages.CompletionItemKind.Method,
|
||||
insertText: 'append(${1:item})',
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: 'Appends a new item to the end of the list.',
|
||||
range,
|
||||
},
|
||||
{
|
||||
label: 'split',
|
||||
kind: monaco.languages.CompletionItemKind.Method,
|
||||
insertText: 'split(${1:sep})',
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: 'Splits string into a list of substrings.',
|
||||
range,
|
||||
},
|
||||
{
|
||||
label: 'def',
|
||||
kind: monaco.languages.CompletionItemKind.Keyword,
|
||||
insertText: 'def ${1:function_name}(${2:params}):\n ${3:pass}',
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: 'Defines a Python function.',
|
||||
range,
|
||||
},
|
||||
];
|
||||
|
||||
return { suggestions };
|
||||
},
|
||||
});
|
||||
|
||||
// Completion items for JavaScript / TypeScript
|
||||
['javascript', 'typescript'].forEach((lang) => {
|
||||
monaco.languages.registerCompletionItemProvider(lang, {
|
||||
triggerCharacters: ['.'],
|
||||
provideCompletionItems: (model, position) => {
|
||||
const lineUntilPosition = model.getValueInRange({
|
||||
startLineNumber: position.lineNumber,
|
||||
startColumn: 1,
|
||||
endLineNumber: position.lineNumber,
|
||||
endColumn: position.column,
|
||||
});
|
||||
|
||||
const word = model.getWordUntilPosition(position);
|
||||
const range = {
|
||||
startLineNumber: position.lineNumber,
|
||||
endLineNumber: position.lineNumber,
|
||||
startColumn: word.startColumn,
|
||||
endColumn: word.endColumn,
|
||||
};
|
||||
|
||||
if (lineUntilPosition.endsWith('console.')) {
|
||||
return {
|
||||
suggestions: [
|
||||
{
|
||||
label: 'log',
|
||||
kind: monaco.languages.CompletionItemKind.Method,
|
||||
insertText: 'log(${1:message});',
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: 'Outputs a message to the debugging console.',
|
||||
range,
|
||||
},
|
||||
{
|
||||
label: 'error',
|
||||
kind: monaco.languages.CompletionItemKind.Method,
|
||||
insertText: 'error(${1:err});',
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: 'Outputs an error message to the console.',
|
||||
range,
|
||||
},
|
||||
{
|
||||
label: 'warn',
|
||||
kind: monaco.languages.CompletionItemKind.Method,
|
||||
insertText: 'warn(${1:msg});',
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: 'Outputs a warning message to the console.',
|
||||
range,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const suggestions = [
|
||||
{
|
||||
label: 'console.log',
|
||||
kind: monaco.languages.CompletionItemKind.Snippet,
|
||||
insertText: 'console.log(${1:val});',
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: 'Print debug message to console.',
|
||||
range,
|
||||
},
|
||||
{
|
||||
label: 'map',
|
||||
kind: monaco.languages.CompletionItemKind.Method,
|
||||
insertText: 'map((${1:item}) => ${2:item})',
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: 'Array map transformation function.',
|
||||
range,
|
||||
},
|
||||
{
|
||||
label: 'filter',
|
||||
kind: monaco.languages.CompletionItemKind.Method,
|
||||
insertText: 'filter((${1:item}) => ${2:true})',
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: 'Array filter function.',
|
||||
range,
|
||||
},
|
||||
];
|
||||
|
||||
return { suggestions };
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Completion items for Rust
|
||||
monaco.languages.registerCompletionItemProvider('rust', {
|
||||
triggerCharacters: ['.', ':'],
|
||||
provideCompletionItems: (model, position) => {
|
||||
const word = model.getWordUntilPosition(position);
|
||||
const range = {
|
||||
startLineNumber: position.lineNumber,
|
||||
endLineNumber: position.lineNumber,
|
||||
startColumn: word.startColumn,
|
||||
endColumn: word.endColumn,
|
||||
};
|
||||
|
||||
const suggestions = [
|
||||
{
|
||||
label: 'println!',
|
||||
kind: monaco.languages.CompletionItemKind.Function,
|
||||
insertText: 'println!("${1:{}}", ${2:val});',
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: 'Prints formatted text to stdout.',
|
||||
range,
|
||||
},
|
||||
{
|
||||
label: 'format!',
|
||||
kind: monaco.languages.CompletionItemKind.Function,
|
||||
insertText: 'format!("${1:{}}", ${2:val})',
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: 'Constructs a formatted String.',
|
||||
range,
|
||||
},
|
||||
{
|
||||
label: 'Vec::push',
|
||||
kind: monaco.languages.CompletionItemKind.Method,
|
||||
insertText: 'push(${1:val});',
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: 'Appends element to back of vector.',
|
||||
range,
|
||||
},
|
||||
];
|
||||
|
||||
return { suggestions };
|
||||
},
|
||||
});
|
||||
|
||||
// Completion items for Go
|
||||
monaco.languages.registerCompletionItemProvider('go', {
|
||||
triggerCharacters: ['.'],
|
||||
provideCompletionItems: (model, position) => {
|
||||
const lineUntilPosition = model.getValueInRange({
|
||||
startLineNumber: position.lineNumber,
|
||||
startColumn: 1,
|
||||
endLineNumber: position.lineNumber,
|
||||
endColumn: position.column,
|
||||
});
|
||||
|
||||
const word = model.getWordUntilPosition(position);
|
||||
const range = {
|
||||
startLineNumber: position.lineNumber,
|
||||
endLineNumber: position.lineNumber,
|
||||
startColumn: word.startColumn,
|
||||
endColumn: word.endColumn,
|
||||
};
|
||||
|
||||
if (lineUntilPosition.endsWith('fmt.')) {
|
||||
return {
|
||||
suggestions: [
|
||||
{
|
||||
label: 'Println',
|
||||
kind: monaco.languages.CompletionItemKind.Function,
|
||||
insertText: 'Println(${1:v})',
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: 'Formats using default formats and writes to standard output.',
|
||||
range,
|
||||
},
|
||||
{
|
||||
label: 'Printf',
|
||||
kind: monaco.languages.CompletionItemKind.Function,
|
||||
insertText: 'Printf("${1:%v}\\n", ${2:v})',
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: 'Formats according to specifier and writes to standard output.',
|
||||
range,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
suggestions: [
|
||||
{
|
||||
label: 'fmt.Println',
|
||||
kind: monaco.languages.CompletionItemKind.Snippet,
|
||||
insertText: 'fmt.Println(${1:v})',
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: 'Print line to stdout.',
|
||||
range,
|
||||
},
|
||||
{
|
||||
label: 'make',
|
||||
kind: monaco.languages.CompletionItemKind.Function,
|
||||
insertText: 'make(${1:type}, ${2:len})',
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: 'Allocates slice, map, or channel.',
|
||||
range,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user