Folder support, Custom theme support, Boiler Plate Code support

This commit is contained in:
Alexander R.
2026-07-29 00:51:54 +00:00
parent 0559f7549a
commit 4aa62722c6
108 changed files with 3020 additions and 152 deletions
+29 -6
View File
@@ -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;
};
+535 -33
View File
@@ -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>
);
};