Folder support, Custom theme support, Boiler Plate Code support
This commit is contained in:
@@ -43,19 +43,38 @@ export interface HandbookCatalog {
|
||||
subjects: HandbookTopic[];
|
||||
}
|
||||
|
||||
export const getConfig = async (): Promise<{ enable_boilerplate: boolean }> => {
|
||||
try {
|
||||
const response = await apiClient.get('/config');
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
return { enable_boilerplate: false };
|
||||
}
|
||||
};
|
||||
|
||||
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 });
|
||||
export const runCode = async (
|
||||
language: string,
|
||||
code: string,
|
||||
stdin: string = '',
|
||||
files?: Record<string, string>,
|
||||
activeFile?: string
|
||||
) => {
|
||||
const response = await apiClient.post('/run', { language, code, stdin, files, active_file: activeFile });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
|
||||
export const lintCode = async (language: string, code: string) => {
|
||||
const response = await apiClient.post('/lint', { language, code });
|
||||
export const lintCode = async (
|
||||
language: string,
|
||||
code: string,
|
||||
files?: Record<string, string>,
|
||||
activeFile?: string
|
||||
) => {
|
||||
const response = await apiClient.post('/lint', { language, code, files, active_file: activeFile });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -63,13 +82,17 @@ export const getGuidance = async (
|
||||
challengeId: string,
|
||||
language: string,
|
||||
code: string,
|
||||
question: string = ''
|
||||
question: string = '',
|
||||
files?: Record<string, string>,
|
||||
activeFile?: string
|
||||
) => {
|
||||
const response = await apiClient.post('/guide', {
|
||||
challenge_id: challengeId,
|
||||
language,
|
||||
code,
|
||||
question,
|
||||
files,
|
||||
active_file: activeFile,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
getGuidance,
|
||||
getHandbookCatalog,
|
||||
getHandbookExample,
|
||||
getConfig,
|
||||
Challenge,
|
||||
HandbookTopic,
|
||||
HandbookCatalog,
|
||||
@@ -32,18 +33,128 @@ import {
|
||||
Terminal,
|
||||
GripVertical,
|
||||
GripHorizontal,
|
||||
Folder,
|
||||
FolderPlus,
|
||||
Plus,
|
||||
Edit2,
|
||||
Trash2,
|
||||
FileCode,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface WorkspaceProps {
|
||||
challengeId: string;
|
||||
challengeId?: string;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
function getLanguageFromFilename(filename: string): string {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || '';
|
||||
const map: Record<string, string> = {
|
||||
cpp: 'cpp',
|
||||
hpp: 'cpp',
|
||||
h: 'cpp',
|
||||
cxx: 'cpp',
|
||||
py: 'python',
|
||||
java: 'java',
|
||||
cs: 'csharp',
|
||||
rs: 'rust',
|
||||
go: 'go',
|
||||
js: 'javascript',
|
||||
ts: 'typescript',
|
||||
html: 'html',
|
||||
lua: 'lua',
|
||||
json: 'json',
|
||||
md: 'markdown',
|
||||
};
|
||||
return map[ext] || 'python';
|
||||
}
|
||||
|
||||
function getDefaultFilesForLanguage(language?: string, enableBoilerplate: boolean = false): Record<string, string> {
|
||||
const norm = (language || 'python').toLowerCase().trim();
|
||||
if (!enableBoilerplate) {
|
||||
if (norm === 'cpp' || norm === 'c++' || norm === 'cxx') {
|
||||
return { 'main.cpp': '' };
|
||||
} else if (norm === 'java') {
|
||||
return { 'Main.java': '' };
|
||||
} else if (norm === 'csharp' || norm === 'c#') {
|
||||
return { 'Program.cs': '' };
|
||||
} else if (norm === 'rust') {
|
||||
return { 'main.rs': '' };
|
||||
} else if (norm === 'go') {
|
||||
return { 'main.go': '' };
|
||||
} else if (norm === 'javascript' || norm === 'js') {
|
||||
return { 'main.js': '' };
|
||||
} else if (norm === 'typescript' || norm === 'ts') {
|
||||
return { 'main.ts': '' };
|
||||
} else if (norm === 'html') {
|
||||
return { 'index.html': '' };
|
||||
} else if (norm === 'lua') {
|
||||
return { 'main.lua': '' };
|
||||
}
|
||||
return { 'main.py': '' };
|
||||
}
|
||||
|
||||
if (norm === 'cpp' || norm === 'c++' || norm === 'cxx') {
|
||||
return {
|
||||
'main.cpp': '// Main implementation\n#include "main.h"\n#include <iostream>\n\nint main() {\n std::cout << "Hello from C++!" << std::endl;\n return 0;\n}\n',
|
||||
'main.h': '// Main header file\n#ifndef MAIN_H\n#define MAIN_H\n\n// Declarations\n\n#endif\n',
|
||||
};
|
||||
} else if (norm === 'java') {
|
||||
return {
|
||||
'Main.java': 'public class Main {\n public static void main(String[] args) {\n System.out.println("Hello from Java!");\n }\n}\n',
|
||||
};
|
||||
} else if (norm === 'csharp' || norm === 'c#') {
|
||||
return {
|
||||
'Program.cs': 'using System;\n\nclass Program {\n static void Main() {\n Console.WriteLine("Hello from C#!");\n }\n}\n',
|
||||
};
|
||||
} else if (norm === 'rust') {
|
||||
return {
|
||||
'main.rs': 'fn main() {\n println!("Hello from Rust!");\n}\n',
|
||||
};
|
||||
} else if (norm === 'go') {
|
||||
return {
|
||||
'main.go': 'package main\nimport "fmt"\n\nfunc main() {\n fmt.Println("Hello from Go!")\n}\n',
|
||||
};
|
||||
} else if (norm === 'javascript' || norm === 'js') {
|
||||
return {
|
||||
'main.js': 'console.log("Hello from JavaScript!");\n',
|
||||
};
|
||||
} else if (norm === 'typescript' || norm === 'ts') {
|
||||
return {
|
||||
'main.ts': 'console.log("Hello from TypeScript!");\n',
|
||||
};
|
||||
} else if (norm === 'html') {
|
||||
return {
|
||||
'index.html': '<!DOCTYPE html>\n<html>\n<head>\n <title>App</title>\n</head>\n<body>\n <h1>Hello World</h1>\n</body>\n</html>\n',
|
||||
};
|
||||
} else if (norm === 'lua') {
|
||||
return {
|
||||
'main.lua': 'print("Hello from Lua!");\n',
|
||||
};
|
||||
}
|
||||
return {
|
||||
'main.py': '# Main script\nprint("Hello from Python!")\n',
|
||||
};
|
||||
}
|
||||
|
||||
const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
|
||||
const [allChallenges, setAllChallenges] = useState<Challenge[]>([]);
|
||||
const [currentChallenge, setCurrentChallenge] = useState<Challenge | null>(null);
|
||||
const [enableBoilerplate, setEnableBoilerplate] = useState(false);
|
||||
|
||||
// Multi-file & Folder state
|
||||
const [files, setFiles] = useState<Record<string, string>>({ 'main.py': '' });
|
||||
const [folders, setFolders] = useState<string[]>([]);
|
||||
const [activeFile, setActiveFile] = useState<string>('main.py');
|
||||
const [openTabs, setOpenTabs] = useState<string[]>(['main.py']);
|
||||
|
||||
// Modals for File & Folder Operations
|
||||
const [isNewFileModalOpen, setIsNewFileModalOpen] = useState(false);
|
||||
const [newFileNameInput, setNewFileNameInput] = useState('');
|
||||
const [isNewFolderModalOpen, setIsNewFolderModalOpen] = useState(false);
|
||||
const [newFolderNameInput, setNewFolderNameInput] = useState('');
|
||||
const [isRenameModalOpen, setIsRenameModalOpen] = useState(false);
|
||||
const [renameFileNameInput, setRenameFileNameInput] = useState('');
|
||||
|
||||
const [code, setCode] = useState('');
|
||||
const [stdinText, setStdinText] = useState('');
|
||||
|
||||
// Resizable Layout Dimensions
|
||||
@@ -59,8 +170,8 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
|
||||
text: 'Execution and lint output will appear here...',
|
||||
});
|
||||
|
||||
// Right sidebar state: 'mentor' | 'handbook' | 'closed'
|
||||
const [rightSidebarMode, setRightSidebarMode] = useState<'mentor' | 'handbook' | 'closed'>('mentor');
|
||||
// Right sidebar state: 'mentor' | 'handbook' | 'files' | 'closed'
|
||||
const [rightSidebarMode, setRightSidebarMode] = useState<'mentor' | 'handbook' | 'files' | 'closed'>('mentor');
|
||||
|
||||
// Mentor state
|
||||
const [guidance, setGuidance] = useState<string>('Ask the mentor a question or click Check Answer below...');
|
||||
@@ -80,6 +191,15 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [isLinting, setIsLinting] = useState(false);
|
||||
|
||||
// Load backend config on mount
|
||||
useEffect(() => {
|
||||
getConfig()
|
||||
.then((cfg) => {
|
||||
setEnableBoilerplate(cfg.enable_boilerplate);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Load all challenges
|
||||
useEffect(() => {
|
||||
getChallenges()
|
||||
@@ -95,7 +215,7 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
|
||||
.catch((err) => console.error('Failed to load challenges:', err));
|
||||
}, [challengeId]);
|
||||
|
||||
// Fetch handbook catalog when current challenge language changes
|
||||
// Load handbook catalog when current challenge language changes
|
||||
useEffect(() => {
|
||||
if (currentChallenge) {
|
||||
getHandbookCatalog(currentChallenge.language)
|
||||
@@ -105,8 +225,45 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
|
||||
setHandbookExample('');
|
||||
})
|
||||
.catch((err) => console.error('Failed to load handbook catalog:', err));
|
||||
|
||||
const defaultFiles = getDefaultFilesForLanguage(currentChallenge.language, enableBoilerplate);
|
||||
setFiles(defaultFiles);
|
||||
const keys = Object.keys(defaultFiles);
|
||||
const first = keys[0] || 'main.py';
|
||||
setActiveFile(first);
|
||||
setOpenTabs(keys);
|
||||
}
|
||||
}, [currentChallenge]);
|
||||
}, [currentChallenge, enableBoilerplate]);
|
||||
|
||||
const handleCreateFolder = (folderName: string) => {
|
||||
const name = folderName.trim();
|
||||
if (!name) return;
|
||||
if (!folders.includes(name)) {
|
||||
setFolders((prev) => [...prev, name]);
|
||||
}
|
||||
setIsNewFolderModalOpen(false);
|
||||
setNewFolderNameInput('');
|
||||
};
|
||||
|
||||
// Global keyboard shortcuts (Ctrl+1..9 for tabs, Ctrl+T for new file)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 't') {
|
||||
e.preventDefault();
|
||||
setNewFileNameInput('');
|
||||
setIsNewFileModalOpen(true);
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key >= '1' && e.key <= '9') {
|
||||
const idx = parseInt(e.key, 10) - 1;
|
||||
if (idx < openTabs.length) {
|
||||
e.preventDefault();
|
||||
setActiveFile(openTabs[idx]);
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [openTabs]);
|
||||
|
||||
// Global mousemove and mouseup listeners for drag-to-resize splitters
|
||||
useEffect(() => {
|
||||
@@ -177,14 +334,76 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Multi-file tab & file operation handlers
|
||||
const handleCreateFile = (filename: string) => {
|
||||
const name = filename.trim();
|
||||
if (!name) return;
|
||||
if (files[name] !== undefined) {
|
||||
if (!openTabs.includes(name)) {
|
||||
setOpenTabs((prev) => [...prev, name]);
|
||||
}
|
||||
setActiveFile(name);
|
||||
} else {
|
||||
setFiles((prev) => ({ ...prev, [name]: '' }));
|
||||
setOpenTabs((prev) => [...prev, name]);
|
||||
setActiveFile(name);
|
||||
}
|
||||
setIsNewFileModalOpen(false);
|
||||
setNewFileNameInput('');
|
||||
};
|
||||
|
||||
const handleCloseTab = (tabName: string, e?: React.MouseEvent) => {
|
||||
if (e) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
if (openTabs.length <= 1) return;
|
||||
const nextTabs = openTabs.filter((t) => t !== tabName);
|
||||
setOpenTabs(nextTabs);
|
||||
if (activeFile === tabName) {
|
||||
setActiveFile(nextTabs[nextTabs.length - 1]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRenameActiveFile = (newName: string) => {
|
||||
const name = newName.trim();
|
||||
if (!name || name === activeFile) {
|
||||
setIsRenameModalOpen(false);
|
||||
return;
|
||||
}
|
||||
const content = files[activeFile] || '';
|
||||
const newFiles = { ...files };
|
||||
delete newFiles[activeFile];
|
||||
newFiles[name] = content;
|
||||
|
||||
setFiles(newFiles);
|
||||
setOpenTabs((prev) => prev.map((t) => (t === activeFile ? name : t)));
|
||||
setActiveFile(name);
|
||||
setIsRenameModalOpen(false);
|
||||
setRenameFileNameInput('');
|
||||
};
|
||||
|
||||
const handleDeleteFile = (fileName: string) => {
|
||||
if (Object.keys(files).length <= 1) return;
|
||||
const newFiles = { ...files };
|
||||
delete newFiles[fileName];
|
||||
setFiles(newFiles);
|
||||
const nextTabs = openTabs.filter((t) => t !== fileName);
|
||||
setOpenTabs(nextTabs.length > 0 ? nextTabs : [Object.keys(newFiles)[0]]);
|
||||
if (activeFile === fileName) {
|
||||
setActiveFile(Object.keys(newFiles)[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunCode = async (overrideStdin?: string) => {
|
||||
if (!code.trim() || !currentChallenge) return;
|
||||
const currentCode = files[activeFile] || '';
|
||||
if (!currentCode.trim() || !currentChallenge) return;
|
||||
setIsRunning(true);
|
||||
setOutput({ status: 'idle', text: 'Executing code...' });
|
||||
const inputToSend = overrideStdin !== undefined ? overrideStdin : stdinText;
|
||||
|
||||
try {
|
||||
const res = await runCode(getMonacoLanguage(currentChallenge.language), code, inputToSend);
|
||||
const lang = getLanguageFromFilename(activeFile);
|
||||
const res = await runCode(lang, currentCode, inputToSend, files, activeFile);
|
||||
if (res.exit_code === 0) {
|
||||
setOutput({
|
||||
status: 'success',
|
||||
@@ -206,11 +425,13 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
|
||||
};
|
||||
|
||||
const handleLintCode = async () => {
|
||||
if (!code.trim() || !currentChallenge) return;
|
||||
const currentCode = files[activeFile] || '';
|
||||
if (!currentCode.trim() || !currentChallenge) return;
|
||||
setIsLinting(true);
|
||||
setOutput({ status: 'idle', text: 'Running syntax & style lint...' });
|
||||
try {
|
||||
const res = await lintCode(getMonacoLanguage(currentChallenge.language), code);
|
||||
const lang = getLanguageFromFilename(activeFile);
|
||||
const res = await lintCode(lang, currentCode, files, activeFile);
|
||||
if (res.exit_code === 0) {
|
||||
setOutput({ status: 'success', text: '✓ Syntax & Style clean! No linting errors detected.' });
|
||||
} else {
|
||||
@@ -235,11 +456,14 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
|
||||
setUserQuestion('');
|
||||
|
||||
try {
|
||||
const lang = getLanguageFromFilename(activeFile);
|
||||
const res = await getGuidance(
|
||||
cid,
|
||||
getMonacoLanguage(currentChallenge.language),
|
||||
code,
|
||||
q
|
||||
lang,
|
||||
files[activeFile] || '',
|
||||
q,
|
||||
files,
|
||||
activeFile
|
||||
);
|
||||
const qHeader = q ? `### Question / Evaluation:\n> ${q}\n\n---\n\n` : '';
|
||||
setGuidance(`${qHeader}${res.mentor_response || 'No guidance received.'}`);
|
||||
@@ -343,7 +567,7 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleRunCode()}
|
||||
disabled={isRunning || !code.trim()}
|
||||
disabled={isRunning || !(files[activeFile] || '').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 ? (
|
||||
@@ -356,7 +580,7 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
|
||||
|
||||
<button
|
||||
onClick={handleLintCode}
|
||||
disabled={isLinting || !code.trim()}
|
||||
disabled={isLinting || !(files[activeFile] || '').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 ? (
|
||||
@@ -369,14 +593,27 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
|
||||
|
||||
<button
|
||||
onClick={handleCheckAnswer}
|
||||
disabled={isLoadingGuidance || !code.trim()}
|
||||
disabled={isLoadingGuidance || !(files[activeFile] || '').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 */}
|
||||
{/* Sidebar Toggles: File Browser (Folder icon), Mentor, Syntax Handbook */}
|
||||
<button
|
||||
onClick={() => setRightSidebarMode(rightSidebarMode === 'files' ? 'closed' : 'files')}
|
||||
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 === 'files'
|
||||
? 'bg-blue-500/20 text-blue-300 border-blue-500/40'
|
||||
: 'bg-gray-800 text-gray-300 hover:bg-gray-700 border-gray-700'
|
||||
}`}
|
||||
title="Toggle File Tree Sidebar"
|
||||
>
|
||||
<Folder size={14} />
|
||||
<span>Files</span>
|
||||
</button>
|
||||
|
||||
<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 ${
|
||||
@@ -405,7 +642,7 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
|
||||
|
||||
{/* Main Container */}
|
||||
<div className="flex flex-1 overflow-hidden relative">
|
||||
{/* Full-Screen Drag Overlay to bypass Monaco Editor event capture */}
|
||||
{/* Full-Screen Drag Overlay */}
|
||||
{draggingType && (
|
||||
<div
|
||||
onMouseMove={(e) => {
|
||||
@@ -434,7 +671,8 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
{/* Left Column: Challenge Specifications (Scalable Width) */}
|
||||
|
||||
{/* Left Column: Challenge Specifications */}
|
||||
<div
|
||||
style={{ width: `${leftWidth}px` }}
|
||||
className="border-r border-transparent bg-darkBg flex flex-col overflow-hidden shrink-0"
|
||||
@@ -507,7 +745,7 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resizable Splitter Handle: Left Column */}
|
||||
{/* Splitter Handle: Left */}
|
||||
<div
|
||||
onMouseDown={startDraggingLeft}
|
||||
className="w-2.5 -mx-1 z-10 bg-transparent hover:bg-blue-500/50 active:bg-blue-600 cursor-col-resize flex items-center justify-center shrink-0 group transition-colors select-none"
|
||||
@@ -516,27 +754,76 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
|
||||
<GripVertical size={12} className="text-gray-600/40 group-hover:text-white" />
|
||||
</div>
|
||||
|
||||
{/* Middle Column: Monaco Code Editor & Resizable Output Console */}
|
||||
{/* Middle Column: Multi-File Tabs, Monaco Code Editor & Resizable Output Console */}
|
||||
<div className="flex-1 flex flex-col bg-[#1e1e1e] overflow-hidden min-w-0 min-h-0">
|
||||
<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">{getLanguageFileName(currentChallenge?.language)}</span>
|
||||
<span className="text-[11px] text-gray-500">TactiTerm IDE</span>
|
||||
{/* Multi-File Tab Bar */}
|
||||
<div className="bg-darkBg text-gray-400 px-2 py-1 text-xs flex items-center border-b border-darkBorder shrink-0 overflow-x-auto gap-1 select-none">
|
||||
{openTabs.map((tab, idx) => (
|
||||
<div
|
||||
key={tab}
|
||||
onClick={() => setActiveFile(tab)}
|
||||
onAuxClick={(e) => {
|
||||
if (e.button === 1) handleCloseTab(tab, e);
|
||||
}}
|
||||
onDoubleClick={() => {
|
||||
if (tab === activeFile) {
|
||||
setRenameFileNameInput(tab);
|
||||
setIsRenameModalOpen(true);
|
||||
}
|
||||
}}
|
||||
className={`px-3 py-1 rounded-t-md text-xs font-mono flex items-center gap-2 cursor-pointer border-t border-x transition-colors ${
|
||||
tab === activeFile
|
||||
? 'bg-[#1e1e1e] text-blue-400 border-darkBorder font-semibold'
|
||||
: 'bg-darkBg text-gray-400 border-transparent hover:bg-darkSurface hover:text-gray-200'
|
||||
}`}
|
||||
title="Double-click to rename | Middle-click to close"
|
||||
>
|
||||
<FileCode size={13} className={tab === activeFile ? 'text-blue-400' : 'text-gray-500'} />
|
||||
<span>{tab}</span>
|
||||
<span className="text-[10px] text-gray-500 opacity-60 font-sans">^{idx + 1}</span>
|
||||
{openTabs.length > 1 && (
|
||||
<button
|
||||
onClick={(e) => handleCloseTab(tab, e)}
|
||||
className="hover:text-red-400 p-0.5 rounded transition-colors ml-1"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setNewFileNameInput('');
|
||||
setIsNewFileModalOpen(true);
|
||||
}}
|
||||
className="p-1 hover:bg-gray-800 text-gray-400 hover:text-white rounded transition-colors ml-1"
|
||||
title="New File Tab (Ctrl+T)"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
|
||||
<div className="ml-auto text-[11px] text-gray-500 font-mono pr-2">
|
||||
{getLanguageFromFilename(activeFile)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Code Editor */}
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
<Editor
|
||||
height="100%"
|
||||
defaultLanguage={getMonacoLanguage(currentChallenge?.language)}
|
||||
language={getMonacoLanguage(currentChallenge?.language)}
|
||||
defaultLanguage={getLanguageFromFilename(activeFile)}
|
||||
language={getLanguageFromFilename(activeFile)}
|
||||
defaultValue=""
|
||||
value={code}
|
||||
value={files[activeFile] || ''}
|
||||
theme="vs-dark"
|
||||
onMount={(editor, monaco) => {
|
||||
editorRef.current = editor;
|
||||
registerMonacoCompletions(monaco);
|
||||
}}
|
||||
onChange={(val) => setCode(val || '')}
|
||||
onChange={(val) =>
|
||||
setFiles((prev) => ({ ...prev, [activeFile]: val || '' }))
|
||||
}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
@@ -611,7 +898,111 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right Column Mode 1: Mentor Sidebar (Scalable Width) */}
|
||||
{/* Right Column Mode 1: File Browser Sidebar */}
|
||||
{rightSidebarMode === 'files' && (
|
||||
<div
|
||||
style={{ width: `${rightWidth}px` }}
|
||||
className="border-l border-transparent 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-blue-400 text-xs uppercase tracking-wider">
|
||||
<Folder size={16} /> File Browser
|
||||
</h2>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
setNewFolderNameInput('');
|
||||
setIsNewFolderModalOpen(true);
|
||||
}}
|
||||
className="p-1 hover:bg-gray-800 text-gray-300 hover:text-white rounded"
|
||||
title="Create Folder"
|
||||
>
|
||||
<FolderPlus size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setNewFileNameInput('');
|
||||
setIsNewFileModalOpen(true);
|
||||
}}
|
||||
className="p-1 hover:bg-gray-800 text-gray-300 hover:text-white rounded"
|
||||
title="Create File"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRightSidebarMode('closed')}
|
||||
className="text-gray-400 hover:text-white text-xs p-1 rounded hover:bg-gray-800"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 flex-1 overflow-y-auto space-y-1 bg-darkBg">
|
||||
{folders.map((folderName) => (
|
||||
<div
|
||||
key={`folder-${folderName}`}
|
||||
className="flex items-center justify-between p-2 rounded-lg text-xs font-mono bg-darkSurface border border-darkBorder/50 text-amber-300 select-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 overflow-hidden">
|
||||
<Folder size={14} className="text-amber-400" />
|
||||
<span className="truncate">{folderName}/</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{Object.keys(files).map((fileName) => (
|
||||
<div
|
||||
key={fileName}
|
||||
onClick={() => {
|
||||
if (!openTabs.includes(fileName)) {
|
||||
setOpenTabs((prev) => [...prev, fileName]);
|
||||
}
|
||||
setActiveFile(fileName);
|
||||
}}
|
||||
className={`flex items-center justify-between p-2 rounded-lg text-xs font-mono transition-all cursor-pointer border ${
|
||||
fileName === activeFile
|
||||
? 'bg-blue-600/20 border-blue-500/50 text-blue-300 font-bold'
|
||||
: 'bg-darkSurface border-darkBorder/50 text-gray-300 hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 overflow-hidden">
|
||||
<FileCode size={14} className={fileName === activeFile ? 'text-blue-400' : 'text-gray-500'} />
|
||||
<span className="truncate">{fileName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setRenameFileNameInput(fileName);
|
||||
setActiveFile(fileName);
|
||||
setIsRenameModalOpen(true);
|
||||
}}
|
||||
className="p-1 text-gray-400 hover:text-white"
|
||||
title="Rename File"
|
||||
>
|
||||
<Edit2 size={12} />
|
||||
</button>
|
||||
{Object.keys(files).length > 1 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteFile(fileName);
|
||||
}}
|
||||
className="p-1 text-gray-400 hover:text-red-400"
|
||||
title="Delete File"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right Column Mode 2: Mentor Sidebar (Scalable Width) */}
|
||||
{rightSidebarMode === 'mentor' && (
|
||||
<div
|
||||
style={{ width: `${rightWidth}px` }}
|
||||
@@ -673,7 +1064,7 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
|
||||
|
||||
<button
|
||||
onClick={handleCheckAnswer}
|
||||
disabled={isLoadingGuidance || !code.trim()}
|
||||
disabled={isLoadingGuidance || !(files[activeFile] || '').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"
|
||||
>
|
||||
@@ -689,7 +1080,7 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right Column Mode 2: Handbook Sidebar (Scalable Width) */}
|
||||
{/* Right Column Mode 3: Handbook Sidebar (Scalable Width) */}
|
||||
{rightSidebarMode === 'handbook' && (
|
||||
<div
|
||||
style={{ width: `${rightWidth}px` }}
|
||||
@@ -746,7 +1137,7 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Topic Catalog List (Fills full height when no topic selected) */}
|
||||
{/* Topic Catalog List */}
|
||||
<div className={`p-3 bg-darkBg flex flex-col gap-1.5 overflow-y-auto ${
|
||||
selectedTopic ? 'max-h-52 border-b border-darkBorder' : 'flex-1'
|
||||
}`}>
|
||||
@@ -788,7 +1179,7 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Generated LLM Reference Card View (ONLY renders after selecting a topic) */}
|
||||
{/* Generated LLM Reference Card View */}
|
||||
{selectedTopic && (
|
||||
<div className="flex-1 p-4 overflow-y-auto bg-darkSurface border-t border-darkBorder">
|
||||
<div className="p-4 bg-darkBg border border-darkBorder rounded-xl shadow-inner">
|
||||
@@ -808,6 +1199,117 @@ const Workspace: React.FC<WorkspaceProps> = ({ challengeId, onBack }) => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New File Modal */}
|
||||
{isNewFileModalOpen && (
|
||||
<div className="fixed inset-0 bg-black/60 z-[999999] flex items-center justify-center p-4">
|
||||
<div className="bg-darkSurface border border-darkBorder rounded-xl p-5 w-full max-w-sm shadow-xl flex flex-col gap-4">
|
||||
<h3 className="text-sm font-bold text-white flex items-center gap-2">
|
||||
<Plus size={16} className="text-blue-400" /> Create New File
|
||||
</h3>
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={newFileNameInput}
|
||||
onChange={(e) => setNewFileNameInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleCreateFile(newFileNameInput);
|
||||
if (e.key === 'Escape') setIsNewFileModalOpen(false);
|
||||
}}
|
||||
placeholder="e.g. helper.cpp, utils.h, data.json"
|
||||
className="w-full bg-darkBg border border-darkBorder rounded-lg px-3 py-2 text-xs text-white placeholder-gray-500 focus:outline-none focus:border-blue-500 font-mono"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setIsNewFileModalOpen(false)}
|
||||
className="px-3 py-1.5 text-xs text-gray-400 hover:text-white rounded-lg hover:bg-gray-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleCreateFile(newFileNameInput)}
|
||||
className="px-4 py-1.5 text-xs font-semibold bg-blue-600 hover:bg-blue-500 text-white rounded-lg"
|
||||
>
|
||||
Create File
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New Folder Modal */}
|
||||
{isNewFolderModalOpen && (
|
||||
<div className="fixed inset-0 bg-black/60 z-[999999] flex items-center justify-center p-4">
|
||||
<div className="bg-darkSurface border border-darkBorder rounded-xl p-5 w-full max-w-sm shadow-xl flex flex-col gap-4">
|
||||
<h3 className="text-sm font-bold text-white flex items-center gap-2">
|
||||
<FolderPlus size={16} className="text-amber-400" /> Create New Folder
|
||||
</h3>
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={newFolderNameInput}
|
||||
onChange={(e) => setNewFolderNameInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleCreateFolder(newFolderNameInput);
|
||||
if (e.key === 'Escape') setIsNewFolderModalOpen(false);
|
||||
}}
|
||||
placeholder="e.g. src, include, components"
|
||||
className="w-full bg-darkBg border border-darkBorder rounded-lg px-3 py-2 text-xs text-white placeholder-gray-500 focus:outline-none focus:border-amber-500 font-mono"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setIsNewFolderModalOpen(false)}
|
||||
className="px-3 py-1.5 text-xs text-gray-400 hover:text-white rounded-lg hover:bg-gray-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleCreateFolder(newFolderNameInput)}
|
||||
className="px-4 py-1.5 text-xs font-semibold bg-amber-600 hover:bg-amber-500 text-white rounded-lg"
|
||||
>
|
||||
Create Folder
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rename File Modal */}
|
||||
{isRenameModalOpen && (
|
||||
<div className="fixed inset-0 bg-black/60 z-[999999] flex items-center justify-center p-4">
|
||||
<div className="bg-darkSurface border border-darkBorder rounded-xl p-5 w-full max-w-sm shadow-xl flex flex-col gap-4">
|
||||
<h3 className="text-sm font-bold text-white flex items-center gap-2">
|
||||
<Edit2 size={16} className="text-amber-400" /> Rename File
|
||||
</h3>
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={renameFileNameInput}
|
||||
onChange={(e) => setRenameFileNameInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleRenameActiveFile(renameFileNameInput);
|
||||
if (e.key === 'Escape') setIsRenameModalOpen(false);
|
||||
}}
|
||||
placeholder="Enter new filename..."
|
||||
className="w-full bg-darkBg border border-darkBorder rounded-lg px-3 py-2 text-xs text-white focus:outline-none focus:border-amber-500 font-mono"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setIsRenameModalOpen(false)}
|
||||
className="px-3 py-1.5 text-xs text-gray-400 hover:text-white rounded-lg hover:bg-gray-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRenameActiveFile(renameFileNameInput)}
|
||||
className="px-4 py-1.5 text-xs font-semibold bg-amber-600 hover:bg-amber-500 text-white rounded-lg"
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user