UNPKG

@peterspackman/occjs

Version:

JavaScript/WebAssembly bindings for OCC - a quantum chemistry and crystallography library

772 lines (678 loc) 26.6 kB
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>OCC Terminal</title> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" /> <script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; background: #1a1b26; min-height: 100vh; display: flex; overflow: hidden; } .sidebar { width: 280px; background: #24283b; border-right: 1px solid #414868; display: flex; flex-direction: column; overflow: hidden; } .sidebar-header { padding: 1rem; border-bottom: 1px solid #414868; color: #c0caf5; font-weight: 600; font-size: 0.9rem; display: flex; align-items: center; justify-content: space-between; } .refresh-btn { background: #414868; border: none; color: #c0caf5; padding: 0.25rem 0.5rem; border-radius: 4px; cursor: pointer; font-size: 0.8rem; } .refresh-btn:hover { background: #565f89; } .file-list { flex: 1; overflow-y: auto; padding: 0.5rem 0; } .file-item, .dir-item { padding: 0.4rem 1rem; color: #a9b1d6; cursor: pointer; display: flex; align-items: center; gap: 0.25rem; font-size: 0.85rem; font-family: 'Menlo', 'Monaco', 'Courier New', monospace; transition: background 0.15s; user-select: none; } .file-item:hover, .dir-item:hover { background: #414868; color: #c0caf5; } .dir-item { font-weight: 500; } .dir-arrow { font-size: 0.6rem; width: 12px; transition: transform 0.15s; flex-shrink: 0; } .dir-arrow.expanded { transform: rotate(90deg); } .dir-children { display: none; } .dir-children.expanded { display: block; } .file-item.nested { padding-left: 2.5rem; } .main-content { flex: 1; display: flex; flex-direction: column; position: relative; } .terminal-wrapper { flex: 1; position: relative; } #terminal { position: absolute; top: 0; left: 0; right: 0; bottom: 0; padding: 1rem; } .drop-overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(122, 162, 247, 0.1); border: 3px dashed #7aa2f7; display: none; align-items: center; justify-content: center; font-size: 1.5rem; color: #7aa2f7; pointer-events: none; z-index: 1000; } .drop-overlay.active { display: flex; } .status-bar { background: #24283b; border-top: 1px solid #414868; padding: 0.5rem 1rem; color: #565f89; font-size: 0.8rem; display: flex; gap: 1rem; } .status-item { display: flex; align-items: center; gap: 0.25rem; } .status-ready { color: #9ece6a; } .status-error { color: #f7768e; } /* Scrollbar styling */ .file-list::-webkit-scrollbar { width: 8px; } .file-list::-webkit-scrollbar-track { background: #1a1b26; } .file-list::-webkit-scrollbar-thumb { background: #414868; border-radius: 4px; } .file-list::-webkit-scrollbar-thumb:hover { background: #565f89; } .empty-state { padding: 2rem 1rem; text-align: center; color: #565f89; font-size: 0.85rem; } </style> </head> <body> <div class="sidebar"> <div class="sidebar-header"> Files <button class="refresh-btn" id="refreshBtn"></button> </div> <div class="file-list" id="fileList"> <div class="empty-state">Loading...</div> </div> </div> <div class="main-content"> <div class="terminal-wrapper"> <div id="terminal"></div> <div class="drop-overlay" id="dropOverlay"> 📁 Drop files here to upload </div> </div> <div class="status-bar"> <div class="status-item" id="statusIndicator"> <span></span> <span id="statusText">Loading...</span> </div> </div> </div> <script type="module"> let currentFile = null; let moduleLoaded = false; let currentLine = ''; let isRunningCommand = false; let cwd = '/'; let commandHistory = []; let historyIndex = -1; let worker = null; let workerReady = false; // Initialize persistent worker function initWorker() { worker = new Worker('occ-worker.js', { type: 'module' }); worker.onmessage = (e) => { handleWorkerMessage(e.data); }; worker.onerror = (error) => { term.writeln(`\x1b[91mWorker error: ${error.message}\x1b[0m\r\n`); }; } // Initialize xterm.js terminal with Tokyo Night theme const term = new Terminal({ theme: { background: '#1a1b26', foreground: '#a9b1d6', cursor: '#c0caf5', cursorAccent: '#1a1b26', selection: '#283457', black: '#32344a', red: '#f7768e', green: '#9ece6a', yellow: '#e0af68', blue: '#7aa2f7', magenta: '#ad8ee6', cyan: '#449dab', white: '#787c99', brightBlack: '#444b6a', brightRed: '#ff7a93', brightGreen: '#b9f27c', brightYellow: '#ff9e64', brightBlue: '#7da6ff', brightMagenta: '#bb9af7', brightCyan: '#0db9d7', brightWhite: '#acb0d0' }, fontFamily: '"Cascadia Code", Menlo, Monaco, "Courier New", monospace', fontSize: 13, cursorBlink: true, scrollback: 10000 }); term.open(document.getElementById('terminal')); term.writeln('\x1b[1;34mOCC WebAssembly Terminal\x1b[0m'); term.writeln('Type "help" for commands. Use Up/Down arrows for history'); term.writeln('Drag & drop files to upload\r\n'); // Handle messages from persistent worker function handleWorkerMessage(msg) { switch (msg.type) { case 'ready': workerReady = true; setStatus('Ready', true); term.writeln('\x1b[32mReady\x1b[0m'); writePrompt(); refreshFileList(); break; case 'error': term.writeln(`\x1b[91m${msg.text}\x1b[0m`); break; case 'ls': msg.files.forEach(f => term.writeln(f)); term.writeln(''); isRunningCommand = false; writePrompt(); break; case 'cat': msg.content.split('\n').forEach(line => term.writeln(line)); term.writeln(''); isRunningCommand = false; writePrompt(); break; case 'pwd': cwd = msg.path; term.writeln(cwd + '\r\n'); isRunningCommand = false; writePrompt(); break; case 'cd': cwd = msg.path; isRunningCommand = false; writePrompt(); break; case 'mkdir': isRunningCommand = false; writePrompt(); refreshFileList(); break; case 'writeFile': case 'syncFiles': refreshFileList(); break; case 'getFiles': // Handled by specific promises, don't write to terminal break; } } // Terminal input handling function writePrompt() { term.write(`\r\n\x1b[36m${cwd}\x1b[0m \x1b[32m$\x1b[0m `); } term.onData(data => { if (isRunningCommand) return; // Handle arrow keys for history if (data === '\x1b[A') { // Arrow Up if (commandHistory.length > 0 && historyIndex < commandHistory.length - 1) { term.write('\r\x1b[K'); writePrompt(); historyIndex++; currentLine = commandHistory[commandHistory.length - 1 - historyIndex]; term.write(currentLine); } return; } if (data === '\x1b[B') { // Arrow Down if (historyIndex > 0) { term.write('\r\x1b[K'); writePrompt(); historyIndex--; currentLine = commandHistory[commandHistory.length - 1 - historyIndex]; term.write(currentLine); } else if (historyIndex === 0) { term.write('\r\x1b[K'); writePrompt(); historyIndex = -1; currentLine = ''; } return; } // Handle Enter if (data === '\r') { term.write('\r\n'); if (currentLine.trim()) { commandHistory.push(currentLine.trim()); historyIndex = -1; handleCommand(currentLine.trim()); currentLine = ''; } else { writePrompt(); } return; } // Handle Backspace if (data === '\x7f') { if (currentLine.length > 0) { currentLine = currentLine.slice(0, -1); term.write('\b \b'); } return; } // Handle Ctrl+C if (data === '\x03') { term.write('^C\r\n'); currentLine = ''; isRunningCommand = false; writePrompt(); return; } // Regular character input if (data >= String.fromCharCode(0x20) && data <= String.fromCharCode(0x7e)) { currentLine += data; term.write(data); } }); // Fit terminal to container function fitTerminal() { const container = document.getElementById('terminal'); const dims = { cols: Math.floor(container.clientWidth / 9), rows: Math.floor(container.clientHeight / 17) }; term.resize(dims.cols, dims.rows); } window.addEventListener('resize', fitTerminal); setTimeout(fitTerminal, 100); // Handle terminal commands async function handleCommand(cmdLine) { if (!workerReady) { term.writeln('\x1b[91mModule not ready yet, please wait...\x1b[0m\r\n'); return; } isRunningCommand = true; const parts = cmdLine.trim().split(/\s+/); const cmd = parts[0]; const args = parts.slice(1); try { switch (cmd) { case 'clear': term.clear(); isRunningCommand = false; writePrompt(); break; case 'pwd': worker.postMessage({ type: 'pwd' }); break; case 'cd': let targetPath; if (args.length === 0) { targetPath = '/'; } else if (args[0] === '..') { if (cwd !== '/') { targetPath = cwd.substring(0, cwd.lastIndexOf('/')) || '/'; } else { targetPath = '/'; } } else if (args[0].startsWith('/')) { targetPath = args[0]; } else { targetPath = cwd === '/' ? '/' + args[0] : cwd + '/' + args[0]; } worker.postMessage({ type: 'cd', data: { path: targetPath } }); break; case 'ls': const targetDir = args.length > 0 ? args[0] : cwd; worker.postMessage({ type: 'ls', data: { path: targetDir } }); break; case 'mkdir': if (args.length === 0) { term.writeln('mkdir: missing operand\r\n'); isRunningCommand = false; writePrompt(); } else { const mkdirPath = args[0].startsWith('/') ? args[0] : (cwd === '/' ? '/' + args[0] : cwd + '/' + args[0]); worker.postMessage({ type: 'mkdir', data: { path: mkdirPath } }); } break; case 'cat': if (args.length === 0) { term.writeln('cat: missing operand\r\n'); isRunningCommand = false; writePrompt(); } else { const catPath = args[0].startsWith('/') ? args[0] : (cwd === '/' ? '/' + args[0] : cwd + '/' + args[0]); worker.postMessage({ type: 'cat', data: { path: catPath } }); } break; case 'help': term.writeln('Available commands:'); term.writeln(' occ <args> - Run OCC with arguments'); term.writeln(' ls [dir] - List directory contents'); term.writeln(' cd <dir> - Change directory'); term.writeln(' pwd - Print working directory'); term.writeln(' cat <file> - Display file contents'); term.writeln(' mkdir <dir> - Create directory'); term.writeln(' clear - Clear the terminal'); term.writeln(' help - Show this help\r\n'); isRunningCommand = false; writePrompt(); break; case 'occ': await runOccCommand(args); return; default: term.writeln(`\x1b[91mCommand not found: ${cmd}\x1b[0m`); term.writeln('Type "help" for available commands\r\n'); isRunningCommand = false; writePrompt(); } } catch (e) { term.writeln(`\x1b[91mError: ${e.message}\x1b[0m\r\n`); isRunningCommand = false; writePrompt(); } } // Run OCC command in temporary worker (fresh state each time) async function runOccCommand(args) { return new Promise((resolve, reject) => { setStatus('Running...', false); // Get current filesystem state from persistent worker const getFilesPromise = new Promise((resolveFiles) => { const handler = (e) => { if (e.data.type === 'getFiles') { worker.removeEventListener('message', handler); resolveFiles(e.data.files); } }; worker.addEventListener('message', handler); worker.postMessage({ type: 'getFiles', data: { path: '/' } }); }); getFilesPromise.then((files) => { const occWorker = new Worker('occ-run-worker.js', { type: 'module' }); occWorker.onmessage = (e) => { const { type, text, code, files: outputFiles } = e.data; switch (type) { case 'output': term.writeln(text); break; case 'error': term.writeln(`\x1b[91m${text}\x1b[0m`); break; case 'ready': break; case 'exit': worker.postMessage({ type: 'syncFiles', data: { files: outputFiles } }); setStatus(code === 0 ? 'Ready' : 'Error', code === 0); occWorker.terminate(); isRunningCommand = false; writePrompt(); resolve(code); break; } }; occWorker.onerror = (error) => { term.writeln(`\x1b[91mWorker error: ${error.message}\x1b[0m`); setStatus('Error', false); occWorker.terminate(); isRunningCommand = false; writePrompt(); reject(error); }; occWorker.postMessage({ command: args.join(' '), cwd: cwd, files: files }); }); }); } // Status bar function setStatus(text, isReady) { const indicator = document.getElementById('statusIndicator'); const statusText = document.getElementById('statusText'); statusText.textContent = text; indicator.className = 'status-item ' + (isReady ? 'status-ready' : (text === 'Error' ? 'status-error' : '')); } // File list management function refreshFileList() { const handler = (e) => { if (e.data.type === 'getFiles') { worker.removeEventListener('message', handler); updateFileList(e.data.files); } }; worker.addEventListener('message', handler); worker.postMessage({ type: 'getFiles', data: { path: '/' } }); } // Track expanded directories const expandedDirs = new Set(['/']); function updateFileList(files) { const fileList = document.getElementById('fileList'); const paths = Object.keys(files).sort(); if (paths.length === 0) { fileList.innerHTML = '<div class="empty-state">No files</div>'; return; } // Build tree structure const tree = {}; paths.forEach(path => { const parts = path.split('/').filter(p => p); let current = tree; parts.forEach((part, idx) => { if (idx === parts.length - 1) { // This is a file if (!current._files) current._files = []; current._files.push({ name: part, path: path }); } else { // This is a directory if (!current[part]) current[part] = {}; current = current[part]; } }); }); // Render tree function renderTree(node, level = 0, parentPath = '') { let html = ''; // Render directories for (const [name, children] of Object.entries(node)) { if (name === '_files') continue; const dirPath = parentPath + '/' + name; const isExpanded = expandedDirs.has(dirPath); const indent = level * 1.5; html += ` <div class="dir-item" data-dir="${dirPath}" style="padding-left: ${indent + 1}rem"> <span class="dir-arrow ${isExpanded ? 'expanded' : ''}">▶</span> <span>${name}/</span> </div> <div class="dir-children ${isExpanded ? 'expanded' : ''}" data-parent="${dirPath}"> ${renderTree(children, level + 1, dirPath)} </div> `; } // Render files in this directory if (node._files) { node._files.forEach(file => { const indent = level * 1.5; html += ` <div class="file-item" data-path="${file.path}" style="padding-left: ${indent + 1.2}rem"> <span>${file.name}</span> </div> `; }); } return html; } fileList.innerHTML = renderTree(tree); // Add click handlers for directories fileList.querySelectorAll('.dir-item').forEach(item => { item.addEventListener('click', (e) => { e.stopPropagation(); const dirPath = item.dataset.dir; const arrow = item.querySelector('.dir-arrow'); const children = fileList.querySelector(`.dir-children[data-parent="${dirPath}"]`); if (expandedDirs.has(dirPath)) { expandedDirs.delete(dirPath); arrow.classList.remove('expanded'); children.classList.remove('expanded'); } else { expandedDirs.add(dirPath); arrow.classList.add('expanded'); children.classList.add('expanded'); } }); }); // Add click handlers for files (download) fileList.querySelectorAll('.file-item').forEach(item => { item.addEventListener('click', () => { const path = item.dataset.path; downloadFile(path, files[path]); }); }); } function downloadFile(path, content) { const blob = new Blob([content], { type: 'application/octet-stream' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = path.split('/').pop(); document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); term.writeln(`\x1b[32mDownloaded: ${path}\x1b[0m\r\n`); } document.getElementById('refreshBtn').addEventListener('click', refreshFileList); // Drag and drop file upload const mainContent = document.querySelector('.main-content'); const dropOverlay = document.getElementById('dropOverlay'); mainContent.addEventListener('dragover', (e) => { e.preventDefault(); dropOverlay.classList.add('active'); }); mainContent.addEventListener('dragleave', (e) => { if (e.target === mainContent) { dropOverlay.classList.remove('active'); } }); mainContent.addEventListener('drop', async (e) => { e.preventDefault(); dropOverlay.classList.remove('active'); const files = Array.from(e.dataTransfer.files); for (const file of files) { const content = await file.arrayBuffer(); const uint8Array = new Uint8Array(content); worker.postMessage({ type: 'writeFile', data: { path: cwd === '/' ? '/' + file.name : cwd + '/' + file.name, content: uint8Array } }); term.writeln(`\x1b[32mUploaded: ${file.name}\x1b[0m\r\n`); } setTimeout(refreshFileList, 100); }); // Initialize worker initWorker(); setStatus('Loading...', false); </script> </body> </html>