// ============================================================================ // code.js โ€” Standalone Code Editor page (code.html) // // This is a self-contained extract of the code-editor view that also lives // inside the main app (web-ui/app.js). It is loaded ONLY by code.html so the const _DBG = false; // set true to enable streaming/editor debug logs // editor can run in its own browser tab (opened from the ๐Ÿ’ป ์ฝ”๋“œ sidebar tab), // mirroring how the slide wizard opens pptx-wizard.html. // // The main app keeps its own in-page code mode (for live code streaming from // the chat agent), so this file intentionally does not depend on app.js. // If you change code-view behaviour, update both this file and app.js. // ============================================================================ // โ”€โ”€ Shared helpers (self-contained copies of app.js utilities) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function getAuthToken() { try { return sessionStorage.getItem('smallclaw_token') || localStorage.getItem('smallclaw_token') || ''; } catch { return ''; } } function setAuthToken(t) { try { if (t) { sessionStorage.setItem('smallclaw_token', t); localStorage.setItem('smallclaw_token', t); } else { sessionStorage.removeItem('smallclaw_token'); } } catch {} } function authHeaders(extra) { const t = getAuthToken(); const h = { ...(extra || {}) }; if (t) h['Authorization'] = 'Bearer ' + t; return h; } function getCookie(name) { const value = `; ${document.cookie}`; const parts = value.split(`; ${name}=`); if (parts.length === 2) return parts.pop().split(';').shift(); return ''; } function escHtml(s) { return String(s || '').replace(/&/g,'&').replace(/\\/g,'\').replace(/`/g,'`').replace(/\$/g,'$').replace(//g,'>').replace(/"/g,'"'); } async function api(path, opts = {}) { const API = window.location.origin || 'http://127.0.0.1:18789'; const { headers: optsHeaders, credentials: _discard, ...restOpts } = opts; const headers = authHeaders({ 'Content-Type': 'application/json', ...optsHeaders }); const r = await fetch(API + path, { headers, ...restOpts }); if (r.status === 401) { setAuthToken(''); window.location.href = '/login.html'; throw new Error('Authentication required'); } const ct = String(r.headers.get('content-type') || '').toLowerCase(); const bodyText = await r.text(); if (!ct.includes('application/json')) { const preview = bodyText.slice(0, 120).replace(/\s+/g, ' '); throw new Error(`Expected JSON from ${path}, got ${ct || 'unknown'} (HTTP ${r.status}) :: ${preview}`); } let data = null; try { data = bodyText ? JSON.parse(bodyText) : null; } catch { const preview = bodyText.slice(0, 120).replace(/\s+/g, ' '); throw new Error(`Invalid JSON from ${path} (HTTP ${r.status}) :: ${preview}`); } if (!r.ok) { const msg = (data && (data.error || data.message)) ? String(data.error || data.message) : `HTTP ${r.status}`; throw new Error(msg); } return data; } // โ”€โ”€ Code Editor View โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ let monacoEditor = null; let monacoReady = false; let codeFiles = []; // [{id, name, content, language, model}] let activeCodeFileId = null; let codeAiHistory = []; // Strip code blocks from a history entry before storage. // File content is re-sent via contextMsg on every request, so repeating it in history // is pure waste and the main source of payload bloat. Keep only prose/summary text. function _histEntry(text) { if (!text) return text; const stripped = text .replace(/```[\s\S]*?```/g, '[์ฝ”๋“œ]') // fenced code blocks โ†’ placeholder .replace(/[\s\S]*?<\/think>/gi, '') // thinking tags .trim(); return stripped.slice(0, 3000) || text.slice(0, 200); // prose cap; fallback if all stripped } // Returns history entries (excluding last/current user msg) up to maxMsgs count AND maxBytes total function _histForAPI(hist, maxMsgs = 20, maxBytes = 150 * 1024) { if (hist.length <= 1) return []; const src = hist.slice(0, -1); let bytes = 0; const result = []; for (let i = src.length - 1; i >= 0 && result.length < maxMsgs; i--) { const size = (src[i].content || '').length; if (bytes + size > maxBytes) break; bytes += size; result.unshift({ role: src[i].role, content: src[i].content }); } return result; } let codeAiAbort = null; let _codeAiAutoAccept = false; // when true: skip diff review, write files directly let _autoAcceptTurn = false; // true during an auto-accept execute phase โ†’ triggers completion message let _prevTurnDoneTools = []; // legacy: kept for session-clear compatibility let _readFileCache = {}; // { bareFilename: fullContent } โ€” persists across turns so model remembers Read files let _historySummary = ''; // compressed summary of old turns injected into system prompt let _karpathyMode = localStorage.getItem('codeKarpathyMode') === '1'; // Andrej Karpathy coding guidelines let _pendingImages = []; // { dataUrl: string, mimeType: string }[] let codeAiSessionId = 'code_ai_' + Math.random().toString(36).slice(2, 10); let activeProjectName = localStorage.getItem('code_active_project') || null; let codeAiModel = localStorage.getItem('codeAiModel') ?? 'glm-5.1:cloud'; let codeAiTech = localStorage.getItem('codeAiTech') ?? 'auto'; // 'auto' | 'html' | 'python' | 'node' let codeDiffState = {}; // { fileId: { originalText, modifiedText, hunks, decorationIds, viewZoneIds, resolved, rejected, onReject } } // Claude Code-style tool definitions sent directly to Ollama by the client loop const CLIENT_TOOL_DEFS = [ { type: 'function', function: { name: 'Read', description: 'ํŒŒ์ผ ๋‚ด์šฉ์„ ์ค„๋ฒˆํ˜ธ์™€ ํ•จ๊ป˜ ์ฝ์Šต๋‹ˆ๋‹ค. offset/limit์œผ๋กœ ๋ถ€๋ถ„ ์ฝ๊ธฐ ๊ฐ€๋Šฅ. ์ปจํ…์ŠคํŠธ์— ์ด๋ฏธ ์žˆ๋Š” ํŒŒ์ผ์—๋Š” ์‚ฌ์šฉํ•˜์ง€ ๋งˆ์„ธ์š”.', parameters: { type: 'object', properties: { file: { type: 'string', description: 'ํŒŒ์ผ ๊ฒฝ๋กœ' }, offset: { type: 'number', description: '์‹œ์ž‘ ์ค„ ๋ฒˆํ˜ธ (1-based, ์„ ํƒ)' }, limit: { type: 'number', description: '์ฝ์„ ์ค„ ์ˆ˜ (์„ ํƒ)' } }, required: ['file'] } } }, { type: 'function', function: { name: 'Write', description: '์ƒˆ ํŒŒ์ผ์„ ์ƒ์„ฑํ•˜๊ฑฐ๋‚˜ ์ „์ฒด ๋‚ด์šฉ์„ ๊ต์ฒดํ•ฉ๋‹ˆ๋‹ค. ๊ธฐ์กด ํŒŒ์ผ ์ˆ˜์ •์—๋Š” Edit์„ ์‚ฌ์šฉํ•˜์„ธ์š”.', parameters: { type: 'object', properties: { file: { type: 'string' }, content: { type: 'string', description: 'ํŒŒ์ผ ์ „์ฒด ๋‚ด์šฉ' } }, required: ['file', 'content'] } } }, { type: 'function', function: { name: 'Edit', description: 'ํŒŒ์ผ ๋‚ด ์ •ํ™•ํ•œ ๋ฌธ์ž์—ด์„ ๊ต์ฒดํ•ฉ๋‹ˆ๋‹ค. old_string์€ ํŒŒ์ผ์— ์ •ํ™•ํžˆ ์กด์žฌํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค (๊ณต๋ฐฑ, ๋“ค์—ฌ์“ฐ๊ธฐ, ์ค„๋ฐ”๊ฟˆ ํฌํ•จ).', parameters: { type: 'object', properties: { file: { type: 'string' }, old_string: { type: 'string', description: '๊ต์ฒดํ•  ๊ธฐ์กด ํ…์ŠคํŠธ (์ •ํ™•ํžˆ ์ผ์น˜ํ•ด์•ผ ํ•จ)' }, new_string: { type: 'string', description: '์ƒˆ ํ…์ŠคํŠธ' } }, required: ['file', 'old_string', 'new_string'] } } }, { type: 'function', function: { name: 'Glob', description: 'ํŒŒ์ผ ํŒจํ„ด์œผ๋กœ ํŒŒ์ผ ๋ชฉ๋ก์„ ๊ฒ€์ƒ‰ํ•ฉ๋‹ˆ๋‹ค. ์˜ˆ: "**/*.py", "src/*.ts"', parameters: { type: 'object', properties: { pattern: { type: 'string', description: '๊ธ€๋กœ๋ธŒ ํŒจํ„ด' } }, required: ['pattern'] } } }, { type: 'function', function: { name: 'Grep', description: 'ํŒŒ์ผ์—์„œ ํ…์ŠคํŠธ๋ฅผ ๊ฒ€์ƒ‰ํ•ฉ๋‹ˆ๋‹ค. ๊ฒฐ๊ณผ๋Š” "ํŒŒ์ผ:์ค„๋ฒˆํ˜ธ: ๋‚ด์šฉ" ํ˜•์‹์ž…๋‹ˆ๋‹ค.', parameters: { type: 'object', properties: { pattern: { type: 'string', description: '๊ฒ€์ƒ‰ ํŒจํ„ด (์ •๊ทœ์‹ ๊ฐ€๋Šฅ)' }, path: { type: 'string', description: '๊ฒ€์ƒ‰ ๋ฒ”์œ„ ํŒŒ์ผ/๋””๋ ‰ํ† ๋ฆฌ (์„ ํƒ, ๊ธฐ๋ณธ: ์ „์ฒด ํ”„๋กœ์ ํŠธ)' }, case_insensitive: { type: 'boolean', description: '๋Œ€์†Œ๋ฌธ์ž ๋ฌด์‹œ (๊ธฐ๋ณธ: false)' } }, required: ['pattern'] } } }, { type: 'function', function: { name: 'Bash', description: '์‰˜ ๋ช…๋ น์„ ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค. companion.py ์—ฐ๊ฒฐ ์‹œ์—๋งŒ ์ž‘๋™ํ•ฉ๋‹ˆ๋‹ค.', parameters: { type: 'object', properties: { command: { type: 'string', description: '์‹คํ–‰ํ•  ๋ช…๋ น์–ด' } }, required: ['command'] } } }, ]; const CODE_EXTS = new Set(['js','ts','jsx','tsx','py','html','htm','css','scss','java','c','cpp','cs','go','rs','php','rb','swift','kt','vue','svelte','json','yaml','yml','sh','sql']); const isCodeFile = name => CODE_EXTS.has((name.split('.').pop() || '').toLowerCase()); const CODE_LANG_MAP = { js:'javascript', ts:'typescript', jsx:'javascript', tsx:'typescript', py:'python', rb:'ruby', go:'go', rs:'rust', java:'java', cs:'csharp', cpp:'cpp', c:'c', php:'php', html:'html', css:'css', scss:'scss', json:'json', yaml:'yaml', yml:'yaml', md:'markdown', sh:'shell', bash:'shell', sql:'sql', xml:'xml', toml:'ini', vue:'html', }; function codeDetectLang(name) { const ext = (name || '').split('.').pop().toLowerCase(); return CODE_LANG_MAP[ext] || 'plaintext'; } function updateCodeAiLabel() { const label = codeAiModel || 'AI'; const input = document.getElementById('code-ai-input'); if (input) input.placeholder = label + '์—๊ฒŒ ์งˆ๋ฌธ... (์˜ˆ: ๊ฒŒ์ž„ ๋งŒ๋“ค์–ด์ค˜)'; } function setCodeAiTech(tech) { codeAiTech = tech; localStorage.setItem('codeAiTech', tech); } function initCodeView() { updateCodeAiLabel(); setCodeAiTech(codeAiTech); if (monacoReady) { setTimeout(() => { if (monacoEditor) monacoEditor.layout(); }, 50); return; } if (typeof require === 'undefined') return; require.config({ paths: { vs: '/vendor/vs' } }); require(['vs/editor/editor.main'], function() { monacoReady = true; const isDark = document.documentElement.getAttribute('data-theme') !== 'light'; monacoEditor = monaco.editor.create(document.getElementById('code-editor-container'), { theme: isDark ? 'vs-dark' : 'vs', language: 'plaintext', automaticLayout: true, fontSize: 14, lineHeight: 22, fontFamily: "'D2Coding', 'D2 Coding', monospace", minimap: { enabled: true }, scrollBeyondLastLine: false, wordWrap: 'on', renderWhitespace: 'selection', smoothScrolling: true, cursorBlinking: 'smooth', mouseWheelZoom: true, tabSize: 2, }); monacoEditor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, codeSaveFile); // Broadcast .ino edits to emulator in real-time let _syncDebounce = null; monacoEditor.onDidChangeModelContent(() => { if (_esp32SyncBusy) return; clearTimeout(_syncDebounce); _syncDebounce = setTimeout(() => { const file = codeFiles.find(f => f.id === activeCodeFileId); if (!file || !file.name.endsWith('.ino')) return; _esp32CodeChannel.postMessage({ type: 'code', code: monacoEditor.getValue() }); }, 300); }); codeFlushPendingQueue(); if (!codeFiles.length && !codePendingQueue.length) { // Don't create a placeholder file โ€” the model will create files as needed renderCodeFileTabs(); } else renderCodeFileTabs(); setupCodeVResize(); csbRestoreSettings(); setupCodeEditorDrop(); }); } function codeNewFile(name, content) { // In client-local + project mode: tabs always store bare filenames; the project // subfolder prefix is handled transparently by _clientProjectPath at disk I/O time. // In server mode with activeProjectName: prefix IS part of the tab name (existing behaviour). let fname = name; if (!fname) { const raw = prompt('ํŒŒ์ผ ์ด๋ฆ„:', 'untitled.py'); if (!raw || !raw.trim()) return; fname = (localDirHandle && activeProjectName) ? raw.trim() : (activeProjectName ? `${activeProjectName}/${raw.trim()}` : raw.trim()); } else if (localDirHandle && activeProjectName) { // Strip project prefix so tab names are always bare in client-local mode fname = _clientBareFileName(fname.trim()); } fname = fname.trim(); if (!fname) return; const id = 'cf_' + Math.random().toString(36).slice(2, 8); const lang = codeDetectLang(fname); const model = monacoReady ? monaco.editor.createModel(content || '', lang) : null; codeFiles.push({ id, name: fname, content: content || '', language: lang, model }); switchCodeFile(id); } function switchCodeFile(id) { const prev = codeFiles.find(f => f.id === activeCodeFileId); if (prev && monacoEditor) prev.content = monacoEditor.getValue(); _DBG && console.log(`[switchCodeFile] ${activeCodeFileId} โ†’ ${id} file=${(codeFiles.find(f => f.id === id) || {}).name || '?'}`); activeCodeFileId = id; const file = codeFiles.find(f => f.id === id); if (!file || !monacoEditor) { renderCodeFileTabs(); return; } if (!file.model) file.model = monaco.editor.createModel(file.content, file.language); // If this file has an active inline diff, re-apply decorations if (codeDiffState[id]) { monacoEditor.setModel(file.model); monacoEditor.focus(); _showDiffBar(id); _applyInlineDiffDecorations(id); } else { monacoEditor.setModel(file.model); monacoEditor.focus(); } renderCodeFileTabs(); codeUpdateRunButton(); if (_hideBlankLines) setTimeout(_applyHideBlankLines, 50); if (_blockFlipFlop) setTimeout(_applyBlockFlipFlop, 60); } function closeCodeFile(id, ev) { if (ev) ev.stopPropagation(); const idx = codeFiles.findIndex(f => f.id === id); if (idx === -1) return; // Clean up diff view if active if (codeDiffState[id]) cleanupCodeDiff(id); const file = codeFiles[idx]; if (file.model) file.model.dispose(); codeFiles.splice(idx, 1); if (!codeFiles.length) { codeNewFile('untitled.py', ''); return; } if (activeCodeFileId === id) switchCodeFile(codeFiles[Math.min(idx, codeFiles.length - 1)].id); else renderCodeFileTabs(); } function renderCodeFileTabs() { const bar = document.getElementById('code-file-tabs'); if (!bar) return; bar.innerHTML = codeFiles.map(f => { const active = f.id === activeCodeFileId; const ds = codeDiffState[f.id]; const diff = !!ds; const unresolved = diff ? ds.hunks.filter((_, i) => !ds.resolved.has(i)).length : 0; return `
${unresolved > 0 ? `${unresolved}` : ''} ${escHtml(f.name)} ×
`; }).join(''); } let codeDirLabel = 'code'; // โ”€โ”€ Local file system (File System Access API) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ let localDirHandle = null; let localTermPath = localStorage.getItem('code_local_term_path') || ''; async function idbHandleGet() { return new Promise(resolve => { const req = indexedDB.open('code_editor_v1', 1); req.onupgradeneeded = e => e.target.result.createObjectStore('handles'); req.onsuccess = e => { const r = e.target.result.transaction('handles', 'readonly').objectStore('handles').get('dir'); r.onsuccess = () => resolve(r.result || null); r.onerror = () => resolve(null); }; req.onerror = () => resolve(null); }); } async function idbHandleSet(handle) { return new Promise(resolve => { const req = indexedDB.open('code_editor_v1', 1); req.onupgradeneeded = e => e.target.result.createObjectStore('handles'); req.onsuccess = e => { const tx = e.target.result.transaction('handles', 'readwrite'); tx.objectStore('handles').put(handle, 'dir'); tx.oncomplete = resolve; tx.onerror = resolve; }; req.onerror = resolve; }); } async function restoreLocalDirHandle() { try { const handle = await idbHandleGet(); if (!handle) return; const perm = await handle.queryPermission({ mode: 'readwrite' }); if (perm === 'granted') { localDirHandle = handle; updateLocalModeUI(); // Restore per-project AI session if (activeProjectName) { const savedId = localStorage.getItem('code_proj_session_' + activeProjectName); const savedHist = localStorage.getItem('code_proj_history_' + activeProjectName); if (savedId) codeAiSessionId = savedId; try { if (savedHist) codeAiHistory = JSON.parse(savedHist); } catch {} // ์ฑ„ํŒ… ํŒจ๋„ ๋ Œ๋”๋ง (switchProject์™€ ๋™์ผ) const _msgs = document.getElementById('code-ai-messages'); if (_msgs) { _msgs.innerHTML = ''; if (codeAiHistory.length) { codeAiHistory.forEach(m => codeAiAppend(m.role === 'user' ? 'user' : 'assistant', m.content)); } else { _renderSessionsList(); } } } codeSbRefresh(); } } catch {} } async function openLocalFolder() { if (!window.showDirectoryPicker) { alert('์ด ๋ธŒ๋ผ์šฐ์ €๋Š” File System Access API๋ฅผ ์ง€์›ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.\nChrome ๋˜๋Š” Edge๋ฅผ ์‚ฌ์šฉํ•ด์ฃผ์„ธ์š”.'); return; } try { localDirHandle = await window.showDirectoryPicker({ mode: 'readwrite' }); await idbHandleSet(localDirHandle); if (!localTermPath) { const inp = document.getElementById('csb-termpath'); if (inp && !inp.value) inp.value = ''; } updateLocalModeUI(); codeSbRefresh(); // Auto-load files if a project was already remembered if (activeProjectName) _clientLoadProjectFiles(); } catch (e) { if (e.name !== 'AbortError') codeAiAppend('system', 'โš ๏ธ ํด๋” ์—ด๊ธฐ ์‹คํŒจ: ' + (e.message || e)); } } function updateLocalModeUI() { const btn = document.getElementById('code-local-btn'); const lbl = document.getElementById('csb-folder-label'); const folderBtn = document.getElementById('code-folder-btn'); const folderName = localDirHandle ? (localDirHandle.name || '๋กœ์ปฌ') : 'ํด๋” ์—†์Œ'; if (btn) { btn.title = localDirHandle ? `๐Ÿ“‚ ${folderName} โ€” ํด๋ฆญํ•ด์„œ ๋ณ€๊ฒฝ` : '๋กœ์ปฌ ํด๋” ์—ด๊ธฐ'; btn.style.color = localDirHandle ? '#22c55e' : ''; btn.textContent = localDirHandle ? '๐Ÿ“‚' : '๐Ÿ–ฅ๏ธ'; btn.onclick = openLocalFolder; } if (lbl) lbl.textContent = localDirHandle ? folderName : 'ํด๋” ์—†์Œ'; if (folderBtn) { const s = folderBtn.querySelector('span'); if (s) s.textContent = localDirHandle ? folderName : 'ํด๋” ์—†์Œ'; } const hdr = document.getElementById('code-header-dir'); if (hdr) hdr.textContent = localDirHandle ? (activeProjectName || folderName) : 'ํด๋” ์—†์Œ'; const dd = document.getElementById('code-dropdown-dir'); if (dd) dd.textContent = localDirHandle ? `๐Ÿ“ ${folderName}` : '๐Ÿ“ ํด๋” ์—†์Œ'; } const SKIP_DIRS = new Set(['node_modules', '__pycache__', '.git', 'dist', 'build', '.venv', 'venv', '.cache', '.next', 'out', 'coverage', 'target', '.idea', '.vs']); // Per-file byte cap for AI context. A minified bundle / large data file can be few // lines but tens of MB โ€” line-count windowing misses it, so cap by bytes too. const MAX_AI_FILE_BYTES = 256 * 1024; // 256KB async function localListFiles(dirHandle, prefix) { const files = []; try { for await (const [name, handle] of dirHandle.entries()) { if (name.startsWith('.') || name.endsWith('.crswap')) continue; const rel = prefix ? `${prefix}/${name}` : name; if (handle.kind === 'directory') { if (SKIP_DIRS.has(name)) continue; files.push(...await localListFiles(handle, rel)); } else files.push(rel); } } catch {} return files.sort(); } async function localReadFile(filename) { if (!localDirHandle) return null; try { const parts = filename.split('/'); let dir = localDirHandle; for (let i = 0; i < parts.length - 1; i++) dir = await dir.getDirectoryHandle(parts[i]); const fh = await dir.getFileHandle(parts[parts.length - 1]); return await (await fh.getFile()).text(); } catch { return null; } } async function localWriteFile(filename, content) { if (!localDirHandle) return false; try { const parts = filename.split('/'); let dir = localDirHandle; for (let i = 0; i < parts.length - 1; i++) dir = await dir.getDirectoryHandle(parts[i], { create: true }); const fh = await dir.getFileHandle(parts[parts.length - 1], { create: true }); const w = await fh.createWritable(); await w.write(content); await w.close(); return true; } catch (e) { console.error('[localWriteFile]', e); return false; } } async function localDeleteFile(filename) { if (!localDirHandle) return false; try { const parts = filename.split('/'); let dir = localDirHandle; for (let i = 0; i < parts.length - 1; i++) dir = await dir.getDirectoryHandle(parts[i]); await dir.removeEntry(parts[parts.length - 1]); return true; } catch { return false; } } // โ”€โ”€ Client-local project path helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // "client-local" = browser File System Access API (localDirHandle). // Distinguished from "server-local" = server's own workspace on disk. // When client-local root + activeProjectName are both set, all disk I/O // is scoped to the project subfolder. The model always sees bare filenames. function _clientProjectPath(fname) { if (!localDirHandle || !activeProjectName) return fname; const bare = fname.startsWith(activeProjectName + '/') ? fname.slice(activeProjectName.length + 1) : fname; return activeProjectName + '/' + bare; } function _clientBareFileName(fname) { if (!activeProjectName) return fname; return fname.startsWith(activeProjectName + '/') ? fname.slice(activeProjectName.length + 1) : fname; } // Close all open tabs that belong to the client-local project scope, // then load every code file from the new activeProjectName subfolder. async function _clientLoadProjectFiles() { if (!localDirHandle || !activeProjectName) return; // Close all existing tabs (they belonged to the previous project) if (codeFiles.length) { codeFiles.forEach(f => { try { f.model?.dispose(); } catch {} }); codeFiles.length = 0; activeCodeFileId = null; codeDiffState = {}; renderCodeFileTabs(); } // Ensure the session's directory exists on disk (new session โ†’ no folder yet) await localDirHandle.getDirectoryHandle(activeProjectName, { create: true }).catch(() => {}); const allFiles = await localListFiles(localDirHandle, ''); const projFiles = allFiles.filter(f => f.startsWith(activeProjectName + '/') && isCodeFile(f) ); for (const fpath of projFiles) { const bare = _clientBareFileName(fpath); const content = await localReadFile(fpath); if (content === null || content.length > 300_000) continue; // skip missing/huge const id = 'cf_' + Math.random().toString(36).slice(2, 8); const lang = codeDetectLang(bare); const model = monacoReady ? monaco.editor.createModel(content, lang) : null; codeFiles.push({ id, name: bare, content, language: lang, model }); } renderCodeFileTabs(); // Switch to first file if any if (codeFiles.length) switchCodeFile(codeFiles[0].id); // Inject file manifest into empty AI history so AI knows existing files if (codeFiles.length > 0 && codeAiHistory.length === 0) { const manifest = codeFiles.map(f => f.name).join(', '); codeAiHistory.push({ role: 'user', content: `[๊ธฐ์กด ํ”„๋กœ์ ํŠธ ํŒŒ์ผ: ${manifest}]\n์ด ํŒŒ์ผ๋“ค์ด ์ด๋ฏธ ์กด์žฌํ•ฉ๋‹ˆ๋‹ค. ์ƒˆ๋กœ ๋งŒ๋“ค์ง€ ๋งˆ์„ธ์š”.` }); codeAiHistory.push({ role: 'assistant', content: '๊ธฐ์กด ํ”„๋กœ์ ํŠธ ํŒŒ์ผ ๋ชฉ๋ก์„ ํ™•์ธํ–ˆ์Šต๋‹ˆ๋‹ค.' }); } } // โ”€โ”€ Terminal output capture (for AI error feedback loop) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Keeps a rolling plaintext buffer per shell (ANSI stripped). Used to detect // errors after a run and offer one-click "AI์—๊ฒŒ ์ „์†ก" from the terminal. const _termBuffers = {}; // shellId โ†’ string[] (last 300 lines) const _TERM_BUF_MAX = 300; function _stripTerminal(raw) { return raw .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') // OSC (title ๋“ฑ) .replace(/\x1b\[[\d;?]*[a-zA-Z]/g, '') // CSI (์ƒ‰์ƒ, ์ปค์„œ, private mode) .replace(/\x1b[^[\]]/g, '') // ๊ธฐํƒ€ ESC ์‹œํ€€์Šค .replace(/[\x00-\x09\x0b-\x1f\x7f]/g, '') // ์ œ์–ด๋ฌธ์ž (\n ์ œ์™ธ) .replace(/\r/g, ''); } function _termBufferAppend(shellId, raw) { if (!shellId) return; const text = _stripTerminal(raw); if (!text) return; const lines = text.split('\n'); if (!_termBuffers[shellId]) _termBuffers[shellId] = []; const buf = _termBuffers[shellId]; for (const l of lines) { if (l) buf.push(l); } while (buf.length > _TERM_BUF_MAX) buf.shift(); _termDetectAndShowError(shellId); } const _ERROR_PATTERNS = [ // Python /Traceback \(most recent call last\)/, /(?:Syntax|Name|Type|Attribute|Import|Indentation|Value|Key|Index|Zero|Overflow|Runtime|OS|IO|File)Error:/, /^\s*\^\s*$/, // JavaScript / Node /Uncaught (?:TypeError|ReferenceError|SyntaxError|Error):/, /Error: .+\n?\s+at /, /\[Error\]/i, // GCC / Arduino ์ปดํŒŒ์ผ ์—๋Ÿฌ /\berror:\s/, /undefined reference to\b/, /multiple definition of\b/, /expected\s.+before\s/, /invalid conversion from\b/, /cannot convert\b/, /sketch too big/i, /\w+\.ino:\d+:\d+:.*error/, /\w+\.cpp:\d+:\d+:.*error/, /recipe for target.*failed/i, /make\[1\].*Error/, ]; let _lastErrorShellId = null; let _lastErrorText = ''; function _termDetectAndShowError(shellId) { const buf = _termBuffers[shellId]; if (!buf || buf.length < 2) return; const recent = buf.slice(-40).join('\n'); const hasError = _ERROR_PATTERNS.some(re => re.test(recent)); if (!hasError) return; // Deduplicate โ€” don't re-show if same error already triggered if (_lastErrorShellId === shellId && _lastErrorText === recent) return; _lastErrorShellId = shellId; _lastErrorText = recent; _showTerminalErrorButton(shellId, recent); } function _showTerminalErrorButton(shellId, errorText) { const btn = document.getElementById('code-term-ai-error-btn'); if (btn) { btn.style.display = ''; btn.style.background = '#dc2626'; btn.style.color = '#fff'; btn.dataset.shellId = shellId; } } function _sendTerminalErrorToAI(errorText) { const input = document.getElementById('code-ai-input'); if (!input) return; // Filter noise lines const filtered = errorText.split('\n').filter(l => { const t = l.trim(); if (!t) return false; if (/^[A-Za-z]:\\.+>/.test(t)) return false; // Windows ํ”„๋กฌํ”„ํŠธ (๋ช…๋ น์–ด ํฌํ•จ) if (/^[~/][^\s]*[$#]\s*/.test(t)) return false; // Unix ํ”„๋กฌํ”„ํŠธ if (/^Hello from the pygame community/.test(t)) return false; if (/^https?:\/\/www\.pygame\.org/.test(t)) return false; return true; }); // ๋งˆ์ง€๋ง‰ Traceback ๋ธ”๋ก๋งŒ ์ถ”์ถœ let blockStart = 0; for (let i = filtered.length - 1; i >= 0; i--) { if (/Traceback \(most recent call last\)/.test(filtered[i])) { blockStart = i; break; } } const trimmed = filtered.slice(blockStart).slice(-30).join('\n'); if (!trimmed) return; input.value = '๋‹ค์Œ ์—๋Ÿฌ๋ฅผ ๊ณ ์ณ์ค˜. Read๋กœ ํŒŒ์ผ์„ ์ฝ์€ ๋’ค Edit ๋˜๋Š” Write ๋„๊ตฌ๋กœ ํŒŒ์ผ์„ ์ง์ ‘ ์ˆ˜์ •ํ•ด:\n```\n' + trimmed + '\n```'; input.style.height = ''; input.dispatchEvent(new Event('input')); codeAiSend(); } function _getXtermVisibleText(term, maxLines = 80) { const buf = term.buffer.active; const lines = []; const endRow = buf.baseY + buf.cursorY; const startRow = Math.max(0, endRow - maxLines); for (let i = startRow; i <= endRow; i++) { const line = buf.getLine(i); if (!line) continue; const t = line.translateToString(true); if (t.trim()) lines.push(t); } return lines.join('\n'); } function _sendActiveTermErrorToAI() { const btn = document.getElementById('code-term-ai-error-btn'); const shellId = btn?.dataset.shellId || _lastErrorShellId || activeCodeTermShellId; const shell = codeTermShells.find(s => s.id === shellId) || codeTermShells.find(s => s.id === activeCodeTermShellId); const text = (shell?.term ? _getXtermVisibleText(shell.term) : '') || (shellId && _termBuffers[shellId] || Object.values(_termBuffers).at(-1))?.slice(-40).join('\n') || _lastErrorText; if (text) _sendTerminalErrorToAI(text); if (btn) { btn.style.background = '#1a3a28'; btn.style.color = '#22c55e'; } } // Called when a run completes successfully (exit code 0) โ€” clear error button function _termOnRunSuccess(shellId) { _lastErrorShellId = null; _lastErrorText = ''; const btn = document.getElementById('code-term-ai-error-btn'); if (btn) btn.style.display = 'none'; } // โ”€โ”€ Companion WebSocket (local Python execution) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ let companionWs = null; let companionConnected = false; let companionEverConnected = false; // ํ•œ ๋ฒˆ์ด๋ผ๋„ ์„ฑ๊ณตํ•œ ์  ์žˆ๋Š”์ง€ const COMPANION_URL = 'ws://127.0.0.1:9000'; let companionPlatform = 'unix'; // 'windows' | 'unix', set on shell_ready let companionHome = ''; // home directory from companion const localShellIds = new Set(); // shell IDs routed to companion PTY (tab id = shell id) function companionConnect() { if (companionWs && (companionWs.readyState === WebSocket.OPEN || companionWs.readyState === WebSocket.CONNECTING)) return; try { companionWs = new WebSocket(COMPANION_URL); } catch { return; } companionWs.onopen = () => { companionConnected = true; companionEverConnected = true; updateCompanionStatus(true); }; companionWs.onclose = () => { companionConnected = false; companionWs = null; updateCompanionStatus(false); // ํ•œ ๋ฒˆ์ด๋ผ๋„ ์—ฐ๊ฒฐ๋๋˜ ๊ฒฝ์šฐ์—๋งŒ ์ž๋™ ์žฌ์—ฐ๊ฒฐ (๋ฏธ์—ฐ๊ฒฐ ์ƒํƒœ์„œ ์ฝ˜์†” ์—๋Ÿฌ spam ๋ฐฉ์ง€) if (companionEverConnected) setTimeout(companionConnect, 5000); }; companionWs.onerror = () => { companionConnected = false; }; companionWs.onmessage = (ev) => { let msg; try { msg = JSON.parse(ev.data); } catch { return; } handleCompanionMessage(msg); }; } function updateCompanionStatus(connected) { const dot = document.getElementById('companion-dot'); if (!dot) return; dot.style.background = connected ? '#22c55e' : '#6b7280'; dot.title = connected ? '๐Ÿ Python companion ์—ฐ๊ฒฐ๋จ' : '๐Ÿ Python companion ๋ฏธ์—ฐ๊ฒฐ (companion.py ์‹คํ–‰ ํ•„์š”) โ€” ํด๋ฆญํ•ด์„œ ์žฌ์—ฐ๊ฒฐ'; const lsBtn = document.getElementById('local-shell-btn'); if (lsBtn) lsBtn.style.color = connected ? '#22c55e' : '#6b7280'; } function handleCompanionMessage(msg) { // shell_* messages โ†’ route by msg.shellId; run messages โ†’ active code terminal const isShellMsg = msg.type === 'shell_output' || msg.type === 'shell_ready' || msg.type === 'shell_exit'; const targetId = isShellMsg && msg.shellId ? msg.shellId : activeCodeTermShellId; if (!codeTermOpen) toggleCodeTerminal(); setTimeout(() => { const s = codeTermShells.find(s => s.id === targetId) || codeTermShells.find(s => s.id === activeCodeTermShellId); if (!s || !s.term) return; if (msg.type === 'shell_ready') { if (msg.platform) companionPlatform = msg.platform; if (msg.home) companionHome = msg.home; _companionCdProject(msg.shellId); } else if (msg.type === 'shell_output') { s.term.write(msg.data); _termBufferAppend(s.id, msg.data); } else if (msg.type === 'shell_exit') { s.term.write(`\r\n\x1b[33m[์…ธ ์ข…๋ฃŒ ${msg.code}]\x1b[0m\r\n`); } else if (msg.type === 'started') s.term.write(`\r\n\x1b[32mโ–ถ ${msg.filename}\x1b[0m\r\n`); else if (msg.type === 'output') { s.term.write(msg.data.replace(/(? s.id === activeCodeTermShellId); if (!s) return; if (localShellIds.has(s.id)) { // ๋กœ์ปฌ(companion) PTY ์…ธ: Ctrl+C๋งŒ ์ „์†ก (PTY ์ž์ฒด๋Š” ์œ ์ง€) if (companionConnected && companionWs) companionWs.send(JSON.stringify({ type: 'input', shellId: s.id, data: '\x03' })); } else { // ์„œ๋ฒ„ ์…ธ: PTY์— Ctrl+C ์ „์†ก if (termWsConnected) termWsSend({ type: 'pty_input', shellId: s.id, data: '\x03' }); } if (s.term) s.term.focus(); } function _companionCdProject(shellId) { if (!companionConnected || !companionWs) return; const sid = shellId || activeCodeTermShellId; const isWin = companionPlatform === 'windows'; const sep = isWin ? '\\' : '/'; const base = (localTermPath || companionHome || '').replace(/[/\\]$/, ''); if (!base) return; const target = activeProjectName ? base + sep + activeProjectName : base; const cmd = isWin ? `cd /d "${target}"\r` : `cd ${JSON.stringify(target)}\r`; companionWs.send(JSON.stringify({ type: 'input', shellId: sid, data: cmd })); } function csbLoadCodeDir() {} async function codeSaveFile() { const file = codeFiles.find(f => f.id === activeCodeFileId); if (!file || !monacoEditor) return; // A pending AI diff keeps the FULL modified text live in the model, so saving // now would persist hunks the user hasn't decided on yet. Make them resolve first. if (codeDiffState[activeCodeFileId]) { codeAiAppend('system', 'โš ๏ธ AI ์ œ์•ˆ ๋ณ€๊ฒฝ์ด ๊ฒ€ํ†  ๋Œ€๊ธฐ ์ค‘์ž…๋‹ˆ๋‹ค. ๋จผ์ € โœ…/โŒ๋กœ ์ ์šฉํ•˜๊ฑฐ๋‚˜ ์ทจ์†Œํ•œ ๋’ค ์ €์žฅํ•˜์„ธ์š”.'); return; } file.content = monacoEditor.getValue(); if (!localDirHandle) { codeAiAppend('system', 'โš ๏ธ ํด๋”๋ฅผ ๋จผ์ € ์„ ํƒํ•ด์ฃผ์„ธ์š” (๐Ÿ–ฅ๏ธ ๋ฒ„ํŠผ)'); return; } const ok = await localWriteFile(_clientProjectPath(file.name), file.content); codeAiAppend('system', ok ? `๐Ÿ’พ ์ €์žฅ: ${file.name}` : 'โš ๏ธ ์ €์žฅ ์‹คํŒจ'); if (ok) codeSbRefresh(); } // โ”€โ”€ Terminal infrastructure (PTY over WebSocket) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ let termWs = null; let termWsConnected = false; function termWsConnect() { if (termWs && (termWs.readyState === WebSocket.OPEN || termWs.readyState === WebSocket.CONNECTING)) return; const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; let url = `${proto}//${location.host}/ws`; const token = sessionStorage.getItem('smallclaw_token') || getCookie('smallclaw_session'); if (token) url += `?token=${encodeURIComponent(token)}`; try { termWs = new WebSocket(url); } catch { return; } termWs.onopen = () => { termWsConnected = true; for (const [shellId, inst] of xtermInstances) { if (!inst.ptyReady && !localShellIds.has(shellId)) { inst.ptyReady = true; termWsSend({ type: 'pty_spawn', shellId, cols: inst.term.cols, rows: inst.term.rows }); } } }; termWs.onmessage = (ev) => { let msg; try { msg = JSON.parse(ev.data); } catch { return; } if (msg.type === 'pty_output') { const i = xtermInstances.get(msg.shellId); if (i) { i.term.write(msg.data); _termBufferAppend(msg.shellId, msg.data); } } else if (msg.type === 'pty_exit') { const i = xtermInstances.get(msg.shellId); if (i) { i.ptyReady = false; i.term.write('\r\n\x1b[90m[exited]\x1b[0m\r\n'); if (msg.code === 0) _termOnRunSuccess(msg.shellId); } } else if (msg.type === 'pty_error') { const i = xtermInstances.get(msg.shellId); if (i) i.term.write('\r\n\x1b[31mPTY error: '+(msg.error||'?')+'\x1b[0m\r\n'); } }; termWs.onclose = () => { termWsConnected = false; termWs = null; for (const [,i] of xtermInstances) i.ptyReady = false; setTimeout(termWsConnect, 3000); }; termWs.onerror = () => { termWsConnected = false; }; } function termWsSend(msg) { if (termWs && termWs.readyState === WebSocket.OPEN) { termWs.send(JSON.stringify(msg)); return true; } return false; } const xtermInstances = new Map(); function createXtermShell(container, shellId) { const id = shellId || 'sh_' + Math.random().toString(36).slice(2, 8); const sub = document.createElement('div'); sub.id = 'xterm-' + id; sub.style.cssText = 'position:absolute;inset:0'; container.appendChild(sub); const term = new Terminal({ theme: { background:'#4B1D45', foreground:'#CCCCCC', cursor:'#22c55e', selectionBackground:'rgba(34,197,94,0.3)', red:'#E74856', green:'#16C60C', yellow:'#C19C00', blue:'#3B78FF', cyan:'#61D6D6', white:'#CCCCCC', brightBlack:'#767676', brightGreen:'#69FF69', brightWhite:'#FFFFFF' }, fontFamily: "'IBM Plex Mono', 'D2Coding', 'NanumGothicCoding', 'Malgun Gothic', 'AppleGothic', monospace", fontSize: 13, lineHeight: 1.4, cursorBlink: true, scrollback: 5000, }); const fitAddon = new FitAddon.FitAddon(); const webLinksAddon = new WebLinksAddon.WebLinksAddon(); term.loadAddon(fitAddon); term.loadAddon(webLinksAddon); term.open(sub); fitAddon.fit(); // Intercept Ctrl+` inside xterm to toggle the terminal panel. // Return false = xterm won't process this key; return true = xterm handles it normally. term.attachCustomKeyEventHandler(e => { if (e.type === 'keydown' && e.ctrlKey && (e.key === '`' || e.code === 'Backquote')) { toggleCodeTerminal(); return false; } if (e.type === 'keydown' && e.key === 'Escape' && codeTermFullscreen) { toggleCodeTermFullscreen(); return false; } return true; }); const inst = { term, fitAddon, ptyReady: false }; xtermInstances.set(id, inst); term.onData(data => { if (localShellIds.has(id)) { if (companionConnected && companionWs) companionWs.send(JSON.stringify({ type: 'input', shellId: id, data })); } else if (termWsConnected && inst.ptyReady) { termWsSend({ type: 'pty_input', shellId: id, data }); } }); term.onResize(({ cols, rows }) => { if (localShellIds.has(id)) { if (companionConnected && companionWs) companionWs.send(JSON.stringify({ type: 'resize', shellId: id, cols, rows })); } else if (termWsConnected && inst.ptyReady) { termWsSend({ type: 'pty_resize', shellId: id, cols, rows }); } }); if (!localShellIds.has(id) && termWsConnected) { inst.ptyReady = true; termWsSend({ type: 'pty_spawn', shellId: id, cols: term.cols, rows: term.rows }); } return { id, term, fitAddon, inst }; } // Init WebSocket connection on load fetch('/api/status').then(r => r.json()).catch(() => ({})).finally(termWsConnect); // โ”€โ”€ Chat terminal (for chat view) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ let chatTermShells = []; let activeChatTermShellId = null; let chatTermOpen = false; let chatTermHeight = 280; function toggleChatTerminal() { const panel = document.getElementById('chat-terminal-panel'); const btn = document.getElementById('chat-term-btn'); if (!panel) return; chatTermOpen = !chatTermOpen; if (chatTermOpen) { panel.style.display = 'flex'; if (btn) btn.classList.add('active'); if (!chatTermShells.length) { addChatTermShell(); } else { setTimeout(() => { const s = chatTermShells.find(s => s.id === activeChatTermShellId); if (s?.fitAddon) s.fitAddon.fit(); if (s?.term) s.term.focus(); }, 50); } } else { panel.style.display = 'none'; if (btn) btn.classList.remove('active'); } } function renderChatShellTabs() { const bar = document.getElementById('chat-shell-tabs'); if (!bar) return; bar.innerHTML = chatTermShells.map(s => { const a = s.id === activeChatTermShellId; return `
${escHtml(s.name)}${chatTermShells.length>1?`×`:''}
`; }).join(''); } function addChatTermShell() { const id = 'ct_' + Math.random().toString(36).slice(2,8); chatTermShells.push({ id, name:'bash', ptyReady:false, term:null, fitAddon:null }); switchChatTermShell(id); } function switchChatTermShell(id) { const cur = chatTermShells.find(s => s.id === activeChatTermShellId); if (cur) { const sub = document.getElementById('xterm-'+cur.id); if (sub) sub.style.display='none'; } activeChatTermShellId = id; const shell = chatTermShells.find(s => s.id === id); if (!shell) return; if (shell.term) { const sub = document.getElementById('xterm-'+shell.id); if (sub) sub.style.display=''; if (shell.fitAddon) shell.fitAddon.fit(); shell.term.focus(); } else { const container = document.getElementById('chat-term-container'); if (container) { const r = createXtermShell(container, shell.id); shell.term=r.term; shell.fitAddon=r.fitAddon; shell.ptyReady=r.inst.ptyReady; setTimeout(()=>{r.fitAddon.fit();r.term.focus();},50); } } renderChatShellTabs(); } function closeChatTermShell(id, ev) { if (ev) ev.stopPropagation(); const idx = chatTermShells.findIndex(s => s.id === id); if (idx === -1 || chatTermShells.length <= 1) return; termWsSend({ type:'pty_kill', shellId:id }); const shell = chatTermShells[idx]; if (shell.term) shell.term.dispose(); xtermInstances.delete(id); const sub = document.getElementById('xterm-'+id); if (sub) sub.remove(); chatTermShells.splice(idx, 1); if (activeChatTermShellId === id) switchChatTermShell(chatTermShells[Math.min(idx,chatTermShells.length-1)].id); else renderChatShellTabs(); } document.addEventListener('DOMContentLoaded', function() { const handle = document.getElementById('chat-term-resize-handle'); if (!handle) return; let startY=0, startH=0; handle.addEventListener('mousedown', function(e) { startY=e.clientY; startH=chatTermHeight; const onMove = e2 => { const dy=startY-e2.clientY; chatTermHeight=Math.max(120,Math.min(window.innerHeight*.7,startH+dy)); const p=document.getElementById('chat-terminal-panel'); if(p) p.style.height=chatTermHeight+'px'; const s=chatTermShells.find(s=>s.id===activeChatTermShellId); if(s?.fitAddon) s.fitAddon.fit(); }; const onUp = () => { document.removeEventListener('mousemove',onMove); document.removeEventListener('mouseup',onUp); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); e.preventDefault(); }); }); // โ”€โ”€ Code editor terminal โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ let codeTermShells = []; let activeCodeTermShellId = null; let codeTermOpen = false; function toggleCodeTerminal() { const panel = document.getElementById('code-term-panel'); const btn = document.getElementById('code-term-toggle-btn'); if (!panel) return; codeTermOpen = !codeTermOpen; if (codeTermOpen) { panel.style.display = 'flex'; if (btn) btn.classList.add('active'); const { min, max } = _bottomPanelHeightBounds(); const cur = panel.offsetHeight; if (cur < min || cur > max) panel.style.height = Math.round((min + max) / 2) + 'px'; if (!codeTermShells.length) { setTimeout(() => { if (companionConnected) addLocalShell(); else addCodeTermShell(); }, 60); } else { setTimeout(() => { const s = codeTermShells.find(s => s.id === activeCodeTermShellId); if (s?.fitAddon) s.fitAddon.fit(); if (s?.term) { s.term.focus(); const inst = xtermInstances.get(s.id); if (inst?.ptyReady) { if (localShellIds.has(s.id)) { if (companionConnected && companionWs) companionWs.send(JSON.stringify({ type: 'input', shellId: s.id, data: companionPlatform === 'windows' ? 'cls\r' : 'clear\r' })); } else { termWsSend({ type: 'pty_input', shellId: s.id, data: 'clear\r' }); } } } }, 50); } } else { panel.style.display = 'none'; if (btn) btn.classList.remove('active'); } if (monacoEditor) setTimeout(() => monacoEditor.layout(), 60); } let codeTermFullscreen = false; function toggleCodeTermFullscreen() { const panel = document.getElementById('code-term-panel'); const btn = document.getElementById('code-term-fullscreen-btn'); if (!panel) return; codeTermFullscreen = !codeTermFullscreen; panel.classList.toggle('code-term-fullscreen', codeTermFullscreen); if (btn) { btn.classList.toggle('active', codeTermFullscreen); btn.title = codeTermFullscreen ? '์ „์ฒดํ™”๋ฉด ์ข…๋ฃŒ (Esc)' : '์ „์ฒดํ™”๋ฉด'; } setTimeout(() => { const s = codeTermShells.find(s => s.id === activeCodeTermShellId); if (s?.fitAddon) s.fitAddon.fit(); if (s?.term) s.term.focus(); }, 60); } document.addEventListener('keydown', e => { if (e.key === 'Escape' && codeTermFullscreen) toggleCodeTermFullscreen(); }); // โ”€โ”€ Browser Preview Panel โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ let codePreviewOpen = false; function toggleCodePreview() { const panel = document.getElementById('code-preview-panel'); const btn = document.getElementById('code-preview-toggle-btn'); if (!panel) return; codePreviewOpen = !codePreviewOpen; if (codePreviewOpen) { panel.style.display = 'flex'; if (btn) btn.classList.add('active'); const urlInput = document.getElementById('code-preview-url'); const saved = localStorage.getItem('code_preview_url'); if (saved && urlInput) urlInput.value = saved; const iframe = document.getElementById('code-preview-iframe'); if (iframe && !iframe.getAttribute('src')) iframe.src = (urlInput ? urlInput.value : 'http://127.0.0.1:8000'); } else { panel.style.display = 'none'; if (btn) btn.classList.remove('active'); } if (monacoEditor) setTimeout(() => monacoEditor.layout(), 60); } let _emuPanelOpen = false; let _emuPanelUrl = ''; function toggleEmuPanel() { const panel = document.getElementById('code-emu-panel'); const btn = document.getElementById('code-emu-btn'); if (!panel) return; _emuPanelOpen = !_emuPanelOpen; panel.style.display = _emuPanelOpen ? 'flex' : 'none'; if (btn) btn.classList.toggle('active', _emuPanelOpen); if (!_emuPanelOpen) { const iframe = document.getElementById('code-emu-iframe'); if (iframe) iframe.src = ''; _emuPanelUrl = ''; } if (monacoEditor) setTimeout(() => monacoEditor.layout(), 60); } function _emuPanelLoad(url, title) { const panel = document.getElementById('code-emu-panel'); const iframe = document.getElementById('code-emu-iframe'); const lbl = document.getElementById('emu-panel-title'); const btn = document.getElementById('code-emu-btn'); if (!panel || !iframe) return; _emuPanelUrl = url; iframe.src = url; if (lbl) lbl.textContent = title || '์—๋ฎฌ๋ ˆ์ดํ„ฐ'; if (!_emuPanelOpen) { _emuPanelOpen = true; panel.style.display = 'flex'; if (btn) btn.classList.add('active'); if (monacoEditor) setTimeout(() => monacoEditor.layout(), 60); } } function emuPanelNewTab() { if (_emuPanelUrl) window.open(_emuPanelUrl, '_blank'); } // ํ•˜๋‹จ ํŒจ๋„(ํด๋กœ๋“œ ์•ฑ, ํ„ฐ๋ฏธ๋„ ๋“ฑ) ๋†’์ด๋Š” ํ•ญ์ƒ ์ „์ฒด ๋†’์ด์˜ 1/2~7/10 ์‚ฌ์ด๋กœ ์ œํ•œํ•œ๋‹ค. function _bottomPanelHeightBounds() { const total = document.getElementById('code-view')?.clientHeight || window.innerHeight; return { min: total * 0.5, max: total * 0.7 }; } let _claudePanelOpen = false; function toggleClaudeAppPanel() { const panel = document.getElementById('code-claude-panel'); const btn = document.getElementById('code-claude-btn'); if (!panel) return; _claudePanelOpen = !_claudePanelOpen; panel.style.display = _claudePanelOpen ? 'flex' : 'none'; if (btn) btn.classList.toggle('active', _claudePanelOpen); if (_claudePanelOpen) { const iframe = document.getElementById('code-claude-iframe'); if (iframe && !iframe.getAttribute('src')) iframe.src = '/claude-app.html'; const { min, max } = _bottomPanelHeightBounds(); const cur = panel.offsetHeight; if (cur < min || cur > max) panel.style.height = Math.round((min + max) / 2) + 'px'; } if (monacoEditor) setTimeout(() => monacoEditor.layout(), 60); } // Claude App panel resize drag document.addEventListener('DOMContentLoaded', function() { const drag = document.getElementById('code-claude-drag'); const panel = document.getElementById('code-claude-panel'); if (!drag || !panel) return; let startY = 0, startH = 0; drag.addEventListener('mouseover', () => drag.style.background = 'rgba(189,147,249,.4)'); drag.addEventListener('mousedown', e => { startY = e.clientY; startH = panel.offsetHeight; e.preventDefault(); const { min, max } = _bottomPanelHeightBounds(); // ๋“œ๋ž˜๊ทธ ์ค‘ ๋งˆ์šฐ์Šค๊ฐ€ iframe ์œ„๋กœ ์ง€๋‚˜๊ฐ€๋ฉด ๋ถ€๋ชจ document๊ฐ€ mousemove/mouseup์„ ๋ชป ๋ฐ›์•„ // ๋“œ๋ž˜๊ทธ๊ฐ€ ์•ˆ ํ’€๋ฆฌ๋ฏ€๋กœ, ๋“œ๋ž˜๊ทธํ•˜๋Š” ๋™์•ˆ์€ iframe์ด ๋งˆ์šฐ์Šค ์ด๋ฒคํŠธ๋ฅผ ๊ฐ€๋กœ์ฑ„์ง€ ์•Š๊ฒŒ ํ•œ๋‹ค. const iframe = document.getElementById('code-claude-iframe'); if (iframe) iframe.style.pointerEvents = 'none'; const onMove = e2 => { const dy = startY - e2.clientY; panel.style.height = Math.max(min, Math.min(max, startH + dy)) + 'px'; }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); drag.style.background = ''; if (iframe) iframe.style.pointerEvents = ''; }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }); }); function openArduinoEmulator() { const file = codeFiles.find(f => f.id === activeCodeFileId); const btn = document.getElementById('code-emu-btn'); if (!file || !file.name.endsWith('.ino')) { _emuPanelLoad('/arduino-emulator.html', '์—๋ฎฌ๋ ˆ์ดํ„ฐ'); return; } const content = file.model ? file.model.getValue() : (file.content || ''); const fqbn = _csbSelBoard.fqbn; const fqbnArch = fqbn.split(':')[1] || ''; if (fqbnArch === 'esp32') { if (btn) { btn.textContent = 'โณ ์ปดํŒŒ์ผ ์ค‘โ€ฆ'; btn.disabled = true; } _emuPanelLoad('/arduino-emulator.html?loading=1', `ESP32 โ€” ${file.name}`); codeAiAppend('system', `๐Ÿ–ฅ๏ธ ESP32 ์—๋ฎฌ๋ ˆ์ดํ„ฐ ์ปดํŒŒ์ผ ์ค‘... [${_csbSelBoard.name}]`); try { localStorage.setItem('esp32_sketch', content); } catch {} api('/api/arduino/qemu/start', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ code: content, fqbn }) }) .then(d => { if (btn) { btn.textContent = '๐Ÿ–ฅ๏ธ ์—๋ฎฌ๋ ˆ์ดํ„ฐ'; btn.disabled = false; } if (d.error) { const isUnsupported = d.error.includes('๋ฏธ์ง€์›') || d.error.includes('RISC-V'); if (isUnsupported) codeAiAppend('system', 'โš ๏ธ ' + d.error); else sendCompileErrorToAI(d.error, file.name, content); toggleEmuPanel(); return; } const _proj = activeProjectName ? '&project=' + encodeURIComponent(activeProjectName) : ''; const url = `/arduino-emulator.html?session=${d.id}&file=${encodeURIComponent(file.name)}&fqbn=${encodeURIComponent(fqbn)}${_proj}`; _emuPanelLoad(url, `ESP32 โ€” ${file.name}`); codeAiAppend('system', `โœ… ESP32 ์—๋ฎฌ๋ ˆ์ดํ„ฐ ์‹คํ–‰ ์ค‘.`); }).catch(e => { if (btn) { btn.textContent = '๐Ÿ–ฅ๏ธ ์—๋ฎฌ๋ ˆ์ดํ„ฐ'; btn.disabled = false; } codeAiAppend('system', 'โš ๏ธ ' + e.message); toggleEmuPanel(); }); } else if (fqbnArch === 'esp8266') { if (btn) { btn.textContent = 'โณ ์ปดํŒŒ์ผ ์ค‘โ€ฆ'; btn.disabled = true; } _emuPanelLoad('/arduino-emulator.html?loading=1', `ESP8266 โ€” ${file.name}`); codeAiAppend('system', `๐Ÿ–ฅ๏ธ ESP8266 ์—๋ฎฌ๋ ˆ์ดํ„ฐ ์ปดํŒŒ์ผ ์ค‘... [${_csbSelBoard.name}]`); try { localStorage.setItem('esp8266_sketch', content); } catch {} api('/api/arduino/esp8266/start', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ code: content, fqbn }) }) .then(d => { if (btn) { btn.textContent = '๐Ÿ–ฅ๏ธ ์—๋ฎฌ๋ ˆ์ดํ„ฐ'; btn.disabled = false; } if (d.error) { sendCompileErrorToAI(d.error, file.name, content); toggleEmuPanel(); return; } const _proj = activeProjectName ? '&project=' + encodeURIComponent(activeProjectName) : ''; const url = `/arduino-emulator.html?session=${d.id}&file=${encodeURIComponent(file.name)}&fqbn=${encodeURIComponent(fqbn)}${_proj}`; _emuPanelLoad(url, `ESP8266 โ€” ${file.name}`); codeAiAppend('system', `โœ… ESP8266 ์—๋ฎฌ๋ ˆ์ดํ„ฐ ์‹คํ–‰ ์ค‘.`); }).catch(e => { if (btn) { btn.textContent = '๐Ÿ–ฅ๏ธ ์—๋ฎฌ๋ ˆ์ดํ„ฐ'; btn.disabled = false; } codeAiAppend('system', 'โš ๏ธ ' + e.message); toggleEmuPanel(); }); } else if (fqbn.startsWith('STMicroelectronics:stm32')) { if (btn) { btn.textContent = 'โณ ์ปดํŒŒ์ผ ์ค‘โ€ฆ'; btn.disabled = true; } _emuPanelLoad('/arduino-emulator.html?loading=1', `STM32 โ€” ${file.name}`); codeAiAppend('system', `๐Ÿ–ฅ๏ธ STM32 Renode ์—๋ฎฌ๋ ˆ์ดํ„ฐ ์ปดํŒŒ์ผ ์ค‘... [${_csbSelBoard.name}]`); try { localStorage.setItem('stm32_sketch', content); } catch {} api('/api/arduino/renode/start', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ code: content, fqbn }) }) .then(d => { if (btn) { btn.textContent = '๐Ÿ–ฅ๏ธ ์—๋ฎฌ๋ ˆ์ดํ„ฐ'; btn.disabled = false; } if (d.error) { sendCompileErrorToAI(d.error, file.name, content); toggleEmuPanel(); return; } const _proj = activeProjectName ? '&project=' + encodeURIComponent(activeProjectName) : ''; const url = `/arduino-emulator.html?session=${d.id}&file=${encodeURIComponent(file.name)}&fqbn=${encodeURIComponent(fqbn)}${_proj}`; _emuPanelLoad(url, `STM32 โ€” ${file.name}`); codeAiAppend('system', `โœ… STM32 Renode ์—๋ฎฌ๋ ˆ์ดํ„ฐ ์‹คํ–‰ ์ค‘.`); }).catch(e => { if (btn) { btn.textContent = '๐Ÿ–ฅ๏ธ ์—๋ฎฌ๋ ˆ์ดํ„ฐ'; btn.disabled = false; } codeAiAppend('system', 'โš ๏ธ ' + e.message); toggleEmuPanel(); }); } else { // AVR ๋ฐ ๊ธฐํƒ€ if (btn) { btn.textContent = 'โณ ์ปดํŒŒ์ผ ์ค‘โ€ฆ'; btn.disabled = true; } fetch('/api/arduino/compile', { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders({}) }, body: JSON.stringify({ code: content, board: fqbn, fqbn }), }).then(r => r.json()).then(data => { if (btn) { btn.textContent = '๐Ÿ–ฅ๏ธ ์—๋ฎฌ๋ ˆ์ดํ„ฐ'; btn.disabled = false; } if (data.autoInstalled?.length) codeAiAppend('system', `๐Ÿ“ฆ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์ž๋™ ์„ค์น˜: ${data.autoInstalled.join(', ')}`); if (data.error) { sendCompileErrorToAI(data.error, file.name, content); return; } if (data.hex) { sessionStorage.setItem('arduino_hex', data.hex); sessionStorage.setItem('arduino_sketch', content); _emuPanelLoad('/arduino-emulator.html', `AVR โ€” ${file.name}`); } else if (data.boardType === 'rp2040') { sessionStorage.setItem('arduino_uf2', data.bin); sessionStorage.setItem('arduino_sketch', content); _emuPanelLoad('/arduino-emulator.html', `RP2040 โ€” ${file.name}`); } else if (data.bin) { const ext = data.boardType === 'uf2' ? '.uf2' : '.bin'; const blob = new Blob([Uint8Array.from(atob(data.bin), c => c.charCodeAt(0))], { type: 'application/octet-stream' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = file.name.replace(/\.ino$/, '') + ext; a.click(); URL.revokeObjectURL(url); codeAiAppend('system', `โœ… ์ปดํŒŒ์ผ ์™„๋ฃŒ (${(data.size/1024).toFixed(1)} KB) โ€” ${ext.slice(1).toUpperCase()} ๋‹ค์šด๋กœ๋“œ๋จ.\n๋ณด๋“œ์— ์˜ฌ๋ฆฌ๋ ค๋ฉด โšก ์—…๋กœ๋“œ ๋ฒ„ํŠผ์„ ์‚ฌ์šฉํ•˜์„ธ์š”.`); } }).catch(e => { if (btn) { btn.textContent = '๐Ÿ–ฅ๏ธ ์—๋ฎฌ๋ ˆ์ดํ„ฐ'; btn.disabled = false; } codeAiAppend('system', 'โš ๏ธ ์„œ๋ฒ„ ์˜ค๋ฅ˜: ' + e.message); }); } } // โ”€โ”€ Arduino ์—…๋กœ๋“œ ํŽ˜์ด์ง€ ์—ด๊ธฐ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function csbUploadToBoard() { const file = codeFiles.find(f => f.id === activeCodeFileId); if (!file || !file.name.endsWith('.ino')) { codeAiAppend('system', 'โš ๏ธ .ino ํŒŒ์ผ์„ ์„ ํƒํ•ด์ฃผ์„ธ์š”'); return; } cupOpenWith(file); } function codePreviewRefresh() { const urlInput = document.getElementById('code-preview-url'); const iframe = document.getElementById('code-preview-iframe'); if (!iframe) return; const url = urlInput ? urlInput.value.trim() : 'http://127.0.0.1:8000'; if (url) { localStorage.setItem('code_preview_url', url); iframe.src = url; } } // Preview panel resize drag document.addEventListener('DOMContentLoaded', function() { const drag = document.getElementById('code-preview-drag'); const panel = document.getElementById('code-preview-panel'); if (!drag || !panel) return; let startY=0, startH=0; drag.addEventListener('mouseover', ()=>drag.style.background='rgba(96,165,250,.4)'); drag.addEventListener('mousedown', e => { startY=e.clientY; startH=panel.offsetHeight; e.preventDefault(); const iframe = document.getElementById('code-preview-iframe'); if (iframe) iframe.style.pointerEvents = 'none'; const onMove = e2 => { const dy = startY - e2.clientY; panel.style.height = Math.max(100, Math.min(window.innerHeight*.7, startH+dy)) + 'px'; }; const onUp = () => { document.removeEventListener('mousemove',onMove); document.removeEventListener('mouseup',onUp); drag.style.background=''; if (iframe) iframe.style.pointerEvents = ''; }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }); }); function renderCodeTermTabs() { const bar = document.getElementById('code-term-tabs'); if (!bar) return; bar.innerHTML = codeTermShells.map(s => { const a = s.id === activeCodeTermShellId; const label = s.local ? '๐Ÿ–ฅ๏ธ ๋กœ์ปฌ' : 'bash'; return `
${label}${codeTermShells.length>1?` ×`:''}
`; }).join(''); } function addLocalShell() { if (!companionConnected) { alert('companion.py๊ฐ€ ์‹คํ–‰๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.\n\nํ„ฐ๋ฏธ๋„์—์„œ: python companion.py'); return; } const id = 'cdt_' + Math.random().toString(36).slice(2, 8); codeTermShells.push({ id, name: '๋กœ์ปฌ', local: true, ptyReady: false, term: null, fitAddon: null }); localShellIds.add(id); if (!codeTermOpen) toggleCodeTerminal(); switchCodeTermShell(id); setTimeout(() => { const shell = codeTermShells.find(s => s.id === id); if (companionConnected && companionWs && shell?.term) { if (shell.fitAddon) shell.fitAddon.fit(); const rows = shell.term.rows > 0 ? shell.term.rows : 24; const cols = shell.term.cols > 0 ? shell.term.cols : 80; companionWs.send(JSON.stringify({ type: 'shell', shellId: id, rows, cols })); setTimeout(() => { if (companionConnected && companionWs) companionWs.send(JSON.stringify({ type: 'input', shellId: id, data: companionPlatform === 'windows' ? 'cls\r' : 'clear\r' })); }, 800); } }, 300); } function addCodeTermShell() { const id = 'cdt_' + Math.random().toString(36).slice(2,8); codeTermShells.push({ id, name:'โ˜๏ธ์„œ๋ฒ„', ptyReady:false, term:null, fitAddon:null }); switchCodeTermShell(id); const _termClear = (delay) => setTimeout(() => { const inst = xtermInstances.get(id); if (inst?.ptyReady) termWsSend({ type: 'pty_input', shellId: id, data: 'clear\r' }); else setTimeout(() => { const i2 = xtermInstances.get(id); if (i2?.ptyReady) termWsSend({ type: 'pty_input', shellId: id, data: 'clear\r' }); }, 800); }, delay); if (localTermPath) { setTimeout(() => { const inst = xtermInstances.get(id); if (inst?.ptyReady) { termWsSend({ type: 'pty_input', shellId: id, data: `cd ${JSON.stringify(localTermPath)}\r` }); _termClear(200); } else setTimeout(() => { const i2 = xtermInstances.get(id); if (i2?.ptyReady) { termWsSend({ type: 'pty_input', shellId: id, data: `cd ${JSON.stringify(localTermPath)}\r` }); _termClear(200); } }, 800); }, 400); } else { _termClear(400); } } function csbTermPathSave() { const inp = document.getElementById('csb-termpath'); localTermPath = (inp?.value || '').trim(); localStorage.setItem('code_local_term_path', localTermPath); // Show confirmation inline (not in chat window) const status = document.getElementById('csb-termpath-status'); if (status) { status.textContent = localTermPath ? `โœ… ์ €์žฅ๋จ: ${localTermPath}` : '๊ฒฝ๋กœ ์ดˆ๊ธฐํ™”๋จ'; status.style.color = 'var(--brand)'; setTimeout(() => { status.textContent = '์ƒˆ ํ„ฐ๋ฏธ๋„ ํƒญ ์—ด๋ฉด ์ด ๊ฒฝ๋กœ๋กœ ์ž๋™ ์ด๋™'; status.style.color = ''; }, 2500); } // Apply cd to all currently open terminals immediately if (localTermPath) { const isWin = companionPlatform === 'windows'; for (const s of codeTermShells) { if (localShellIds.has(s.id)) { if (companionConnected && companionWs) { const cmd = isWin ? `cd /d "${localTermPath}"\r` : `cd ${JSON.stringify(localTermPath)}\r`; companionWs.send(JSON.stringify({ type: 'input', shellId: s.id, data: cmd })); } } else if (s.ptyReady) { termWsSend({ type: 'pty_input', shellId: s.id, data: `cd ${JSON.stringify(localTermPath)}\r` }); } } } } function switchCodeTermShell(id) { const cur = codeTermShells.find(s => s.id === activeCodeTermShellId); if (cur) { const sub = document.getElementById('xterm-'+cur.id); if (sub) sub.style.display='none'; } activeCodeTermShellId = id; const shell = codeTermShells.find(s => s.id === id); if (!shell) return; if (shell.term) { const sub = document.getElementById('xterm-'+shell.id); if (sub) sub.style.display=''; if (shell.fitAddon) shell.fitAddon.fit(); shell.term.focus(); } else { const container = document.getElementById('code-term-container'); if (container) { const r = createXtermShell(container, shell.id); shell.term=r.term; shell.fitAddon=r.fitAddon; shell.ptyReady=r.inst.ptyReady; setTimeout(()=>{r.fitAddon.fit();r.term.focus();},50); } } renderCodeTermTabs(); } function closeCodeTermShell(id, ev) { if (ev) ev.stopPropagation(); const idx = codeTermShells.findIndex(s => s.id === id); if (idx === -1 || codeTermShells.length <= 1) return; if (localShellIds.has(id)) { localShellIds.delete(id); if (companionConnected && companionWs) companionWs.send(JSON.stringify({ type: 'shell_close', shellId: id })); } else { termWsSend({ type:'pty_kill', shellId:id }); } const shell = codeTermShells[idx]; if (shell.term) shell.term.dispose(); xtermInstances.delete(id); const sub = document.getElementById('xterm-'+id); if (sub) sub.remove(); codeTermShells.splice(idx, 1); if (activeCodeTermShellId === id) switchCodeTermShell(codeTermShells[Math.min(idx,codeTermShells.length-1)].id); else renderCodeTermTabs(); } // Resize drag (top edge โ†’ pull up/down) document.addEventListener('click', e => { if (!e.target.closest('#csb-ctx')) csbCtxClose(); }); document.addEventListener('DOMContentLoaded', function() { const drag = document.getElementById('code-term-drag'); const panel = document.getElementById('code-term-panel'); if (!drag || !panel) return; let startY=0, startH=0; drag.addEventListener('mouseover', ()=>drag.style.background='rgba(34,197,94,.4)'); drag.addEventListener('mousedown', e => { startY=e.clientY; startH=panel.offsetHeight; e.preventDefault(); const { min, max } = _bottomPanelHeightBounds(); const onMove = e2 => { const dy = startY - e2.clientY; panel.style.height = Math.max(min, Math.min(max, startH+dy)) + 'px'; const s = codeTermShells.find(s => s.id === activeCodeTermShellId); if (s?.fitAddon) s.fitAddon.fit(); }; const onUp = () => { document.removeEventListener('mousemove',onMove); document.removeEventListener('mouseup',onUp); drag.style.background=''; }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }); }); // Restore log panel collapsed state (function() { if (localStorage.getItem('logPanelCollapsed') === '1') { const panel = document.getElementById('sidebar-log-panel'); if (panel) panel.classList.add('collapsed'); } // Restore preview URL input const savedUrl = localStorage.getItem('code_preview_url'); if (savedUrl) { const el = document.getElementById('code-preview-url'); if (el) el.value = savedUrl; } })(); // Ctrl+` shortcut โ€” for Monaco / non-xterm focus; xterm handles it via attachCustomKeyEventHandler document.addEventListener('keydown', e => { if (e.ctrlKey && (e.key === '`' || e.code === 'Backquote')) { e.preventDefault(); toggleCodeTerminal(); } }); // โ”€โ”€ Code Run: multi-mode execution โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const CODE_RUN_MODES = { html: [ { id: 'browser', icon: '๐ŸŒ', label: '๋ธŒ๋ผ์šฐ์ €', desc: '์ƒˆ ํƒญ์—์„œ ์—ด๊ธฐ' }, { id: 'terminal', icon: 'โŒจ', label: 'ํ„ฐ๋ฏธ๋„', desc: '์›น ์„œ๋ฒ„๋กœ ์‹คํ–‰' }, ], htm: 'html', py: [ { id: 'companion', icon: '๐Ÿ–ฅ๏ธ', label: '๋กœ์ปฌ ์‹คํ–‰', desc: 'companion.py (pygame ์ง€์›)' }, { id: 'pyodide', icon: '๐Ÿ', label: 'Pyodide', desc: '๋ธŒ๋ผ์šฐ์ €์—์„œ ์‹คํ–‰' }, { id: 'companion', icon: '๐Ÿ–ฅ๏ธ', label: 'python3 ๋กœ์ปฌ', desc: '๋กœ์ปฌ ์…ธ์—์„œ ์‹คํ–‰' }, ], js: [ { id: 'companion', icon: '๐Ÿ–ฅ๏ธ', label: 'node ๋กœ์ปฌ', desc: '๋กœ์ปฌ ์…ธ์—์„œ ์‹คํ–‰' }, { id: 'terminal', icon: 'โ˜๏ธ', label: 'node ์„œ๋ฒ„', desc: '์„œ๋ฒ„ ํ„ฐ๋ฏธ๋„์—์„œ ์‹คํ–‰' }, ], ts: [ { id: 'companion', icon: '๐Ÿ–ฅ๏ธ', label: 'ts-node ๋กœ์ปฌ', desc: '๋กœ์ปฌ ์…ธ์—์„œ ์‹คํ–‰' }, { id: 'terminal', icon: 'โ˜๏ธ', label: 'ts-node ์„œ๋ฒ„', desc: '์„œ๋ฒ„ ํ„ฐ๋ฏธ๋„์—์„œ ์‹คํ–‰' }, ], go: [ { id: 'terminal', icon: 'โŒจ', label: 'go run', desc: 'ํ„ฐ๋ฏธ๋„์—์„œ ์‹คํ–‰' }, ], rs: [ { id: 'terminal', icon: 'โŒจ', label: 'rustc', desc: 'ํ„ฐ๋ฏธ๋„์—์„œ ์‹คํ–‰' }, ], cpp: [ { id: 'terminal', icon: 'โŒจ', label: 'g++', desc: 'ํ„ฐ๋ฏธ๋„์—์„œ ์‹คํ–‰' }, ], c: [ { id: 'terminal', icon: 'โŒจ', label: 'gcc', desc: 'ํ„ฐ๋ฏธ๋„์—์„œ ์‹คํ–‰' }, ], sh: [ { id: 'terminal', icon: 'โŒจ', label: 'bash', desc: 'ํ„ฐ๋ฏธ๋„์—์„œ ์‹คํ–‰' }, ], ino: [ { id: 'arduino', icon: '๐Ÿ”Œ', label: '์—๋ฎฌ๋ ˆ์ดํ„ฐ', desc: 'AVR/STM32/ESP ์—๋ฎฌ๋ ˆ์ดํ„ฐ (๋ณด๋“œ์— ๋”ฐ๋ผ ์ž๋™ ์„ ํƒ)' }, { id: 'esp32', icon: 'โšก', label: 'ESP32 ํ”Œ๋ž˜์‹œ', desc: 'Web Serial๋กœ ESP32์— ์—…๋กœ๋“œ' }, { id: 'terminal', icon: 'โŒจ', label: 'arduino-cli', desc: 'ํ„ฐ๋ฏธ๋„์—์„œ ๋นŒ๋“œ/์—…๋กœ๋“œ' }, ], }; function codeGetRunModes(ext) { const modes = CODE_RUN_MODES[ext]; if (!modes) return [{ id: 'terminal', icon: 'โŒจ', label: 'ํ„ฐ๋ฏธ๋„', desc: 'ํ„ฐ๋ฏธ๋„์—์„œ ์‹คํ–‰' }]; if (typeof modes === 'string') return CODE_RUN_MODES[modes]; return modes; } let _codeRunMenuOpen = false; function codeToggleRunMenu() { const menu = document.getElementById('code-run-menu'); if (_codeRunMenuOpen) { menu.style.display = 'none'; _codeRunMenuOpen = false; return; } const file = codeFiles.find(f => f.id === activeCodeFileId); if (!file) return; const ext = file.name.split('.').pop().toLowerCase(); const modes = codeGetRunModes(ext); menu.innerHTML = modes.map(m => `
` + `${m.icon}` + `
${m.label}
${m.desc}
` + `
` ).join(''); menu.style.display = 'block'; _codeRunMenuOpen = true; const close = (e) => { if (!menu.contains(e.target) && !e.target.closest('#code-run-dropdown')) { menu.style.display = 'none'; _codeRunMenuOpen = false; document.removeEventListener('click', close); } }; setTimeout(() => document.addEventListener('click', close), 0); } function codeUpdateRunButton() { const file = codeFiles.find(f => f.id === activeCodeFileId); const iconEl = document.getElementById('code-run-icon'); const labelEl = document.getElementById('code-run-label'); if (!file || !iconEl) return; const ext = file.name.split('.').pop().toLowerCase(); const modes = codeGetRunModes(ext); const primary = modes[0]; iconEl.textContent = primary.icon; labelEl.textContent = primary.label; } function codeRunDefault() { const file = codeFiles.find(f => f.id === activeCodeFileId); if (!file || !monacoEditor) return; file.content = monacoEditor.getValue(); const ext = file.name.split('.').pop().toLowerCase(); const modes = codeGetRunModes(ext); codeRunWith(modes[0].id); } // Resolve and `); } } const blob = new Blob([html], { type: 'text/html' }); return URL.createObjectURL(blob); } function codeRunWith(mode) { const menu = document.getElementById('code-run-menu'); if (menu) menu.style.display = 'none'; _codeRunMenuOpen = false; const file = codeFiles.find(f => f.id === activeCodeFileId); if (!file || !monacoEditor) return; // Don't run while an AI diff is pending โ€” the model holds undecided changes. if (codeDiffState[activeCodeFileId]) { codeAiAppend('system', 'โš ๏ธ AI ์ œ์•ˆ ๋ณ€๊ฒฝ์ด ๊ฒ€ํ†  ๋Œ€๊ธฐ ์ค‘์ž…๋‹ˆ๋‹ค. ๋จผ์ € โœ…/โŒ๋กœ ์ ์šฉํ•˜๊ฑฐ๋‚˜ ์ทจ์†Œํ•œ ๋’ค ์‹คํ–‰ํ•˜์„ธ์š”.'); return; } file.content = monacoEditor.getValue(); const fpath = `${codeDirLabel}/${file.name}`; if (mode === 'browser') { const ext = file.name.split('.').pop().toLowerCase(); if ((ext === 'html' || ext === 'htm') && localDirHandle) { // Inline linked CSS and JS so the blob URL can resolve them _openHtmlWithInlinedAssets(file).then(url => { const w = window.open(url, '_blank'); if (!w) codeAiAppend('system', 'โš ๏ธ ํŒ์—… ์ฐจ๋‹จ๋จ โ€” ๋ธŒ๋ผ์šฐ์ €์—์„œ ํŒ์—…์„ ํ—ˆ์šฉํ•ด์ฃผ์„ธ์š”'); setTimeout(() => URL.revokeObjectURL(url), 120000); }); } else { const mime = ext === 'css' ? 'text/css' : ext === 'js' ? 'text/javascript' : 'text/plain'; const blob = new Blob([file.content], { type: mime }); const url = URL.createObjectURL(blob); const w = window.open(url, '_blank'); if (!w) codeAiAppend('system', 'โš ๏ธ ํŒ์—… ์ฐจ๋‹จ๋จ โ€” ๋ธŒ๋ผ์šฐ์ €์—์„œ ํŒ์—…์„ ํ—ˆ์šฉํ•ด์ฃผ์„ธ์š”'); setTimeout(() => URL.revokeObjectURL(url), 60000); } return; } if (mode === 'companion') { if (!companionConnected) { codeAiAppend('system', 'โš ๏ธ companion.py๊ฐ€ ์‹คํ–‰๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. ๋กœ์ปฌ์—์„œ python companion.py ๋ฅผ ์‹คํ–‰ํ•˜์„ธ์š”.'); return; } if (localDirHandle) { // Local folder is open โ†’ run via companion shell PTY so output stays in the local tab. // Ensure local shell tab exists and is front. const existingLocalId = [...localShellIds].find(id => codeTermShells.find(s => s.id === id)); const needsShell = !existingLocalId; if (needsShell) addLocalShell(); else { if (!codeTermOpen) toggleCodeTerminal(); switchCodeTermShell(existingLocalId); } const ext = file.name.split('.').pop().toLowerCase(); const bare = _clientBareFileName(file.name); const base = (localTermPath || companionHome || '').replace(/[/\\]$/, ''); // Detect Windows from platform flag OR from drive-letter prefix (C:\...) in base path const isWin = companionPlatform === 'windows' || /^[A-Za-z]:/.test(base); const sep = isWin ? '\\' : '/'; const proj = activeProjectName ? sep + activeProjectName : ''; // Normalise all separators in the assembled path to match the platform const rawPath = base ? base + proj + sep + bare : bare; const normPath = isWin ? rawPath.replace(/\//g, '\\') : rawPath.replace(/\\/g, '/'); const localPath = `"${normPath}"`; const pyCmd = isWin ? 'python' : 'python3'; const cmd = (ext === 'js' ? `node ${localPath}` : ext === 'ts' ? `npx ts-node ${localPath}` : `${pyCmd} ${localPath}`) + '\r'; setTimeout(() => { const targetShellId = [...localShellIds].find(id => codeTermShells.find(s => s.id === id)); if (companionConnected && companionWs && targetShellId) companionWs.send(JSON.stringify({ type: 'input', shellId: targetShellId, data: cmd })); }, needsShell ? 900 : 0); } else { // No local folder โ†’ send code directly (one-shot run, output to active terminal) if (!codeTermOpen) toggleCodeTerminal(); setTimeout(() => { companionWs.send(JSON.stringify({ type: 'run', code: file.content, filename: file.name })); }, codeTermOpen ? 0 : 500); } return; } if (mode === 'pyodide') { sessionStorage.setItem('py_run_code', file.content); sessionStorage.setItem('py_run_name', file.name); const w = window.open('/python-runner.html', '_blank'); if (!w) codeAiAppend('system', 'โš ๏ธ ํŒ์—… ์ฐจ๋‹จ๋จ โ€” ๋ธŒ๋ผ์šฐ์ €์—์„œ ํŒ์—…์„ ํ—ˆ์šฉํ•ด์ฃผ์„ธ์š”'); return; } if (mode === 'esp32') { _emuPanelLoad('/arduino-emulator.html?loading=1', `ESP32 โ€” ${file.name}`); codeAiAppend('system', `๐Ÿ–ฅ๏ธ ESP32 ์—๋ฎฌ๋ ˆ์ดํ„ฐ ์ปดํŒŒ์ผ ์ค‘... (${file.name})`); try { localStorage.setItem('esp32_sketch', file.content); } catch {} api('/api/arduino/qemu/start', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ code: file.content, fqbn: 'esp32:esp32:esp32' }) }) .then(d => { if (d.error) { sendCompileErrorToAI(d.error, file.name, file.content); toggleEmuPanel(); return; } const _proj2 = activeProjectName ? '&project=' + encodeURIComponent(activeProjectName) : ''; _emuPanelLoad(`/arduino-emulator.html?session=${d.id}&file=${encodeURIComponent(file.name)}&fqbn=esp32%3Aesp32%3Aesp32${_proj2}`, `ESP32 โ€” ${file.name}`); codeAiAppend('system', `โœ… ESP32 ์—๋ฎฌ๋ ˆ์ดํ„ฐ ์‹คํ–‰ ์ค‘.`); }).catch(e => { codeAiAppend('system', 'โš ๏ธ ' + e.message); toggleEmuPanel(); }); return; } if (mode === 'esp8266') { _emuPanelLoad('/arduino-emulator.html?loading=1', `ESP8266 โ€” ${file.name}`); codeAiAppend('system', `๐Ÿ–ฅ๏ธ ESP8266 ์—๋ฎฌ๋ ˆ์ดํ„ฐ ์ปดํŒŒ์ผ ์ค‘... (${file.name})`); try { localStorage.setItem('esp8266_sketch', file.content); } catch {} const _esp8266Fqbn = (_csbSelBoard.fqbn.startsWith('esp8266:')) ? _csbSelBoard.fqbn : 'esp8266:esp8266:nodemcuv2'; api('/api/arduino/esp8266/start', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ code: file.content, fqbn: _esp8266Fqbn }) }) .then(d => { if (d.error) { sendCompileErrorToAI(d.error, file.name, file.content); toggleEmuPanel(); return; } const _proj2 = activeProjectName ? '&project=' + encodeURIComponent(activeProjectName) : ''; _emuPanelLoad(`/arduino-emulator.html?session=${d.id}&file=${encodeURIComponent(file.name)}&fqbn=${encodeURIComponent(_esp8266Fqbn)}${_proj2}`, `ESP8266 โ€” ${file.name}`); codeAiAppend('system', `โœ… ESP8266 ์—๋ฎฌ๋ ˆ์ดํ„ฐ ์‹คํ–‰ ์ค‘.`); }).catch(e => { codeAiAppend('system', 'โš ๏ธ ' + e.message); toggleEmuPanel(); }); return; } if (mode === 'arduino') { const _isEsp32Board = (_csbSelBoard.fqbn.split(':')[1] || '') === 'esp32'; const _isEsp8266Board = (_csbSelBoard.fqbn.split(':')[0] || '') === 'esp8266'; const _isStm32Board = _csbSelBoard.fqbn.startsWith('STMicroelectronics:stm32'); if (_isEsp32Board || _isEsp8266Board || _isStm32Board) { // ESP32/ESP8266/STM32 ๊ณ„์—ด โ€” openArduinoEmulator()๋กœ ์—๋ฎฌ๋ ˆ์ดํ„ฐ ์‹คํ–‰ openArduinoEmulator(); return; } codeAiAppend('system', `๐Ÿ”Œ ์ปดํŒŒ์ผ ์ค‘... (${file.name}) [${_csbSelBoard.name}]`); fetch('/api/arduino/compile', { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders({}) }, body: JSON.stringify({ code: file.content, board: _csbSelBoard.fqbn, fqbn: _csbSelBoard.fqbn }), }).then(r => r.json()).then(data => { if (data.autoInstalled?.length) codeAiAppend('system', `๐Ÿ“ฆ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์ž๋™ ์„ค์น˜: ${data.autoInstalled.join(', ')}`); if (data.error) { const extra = data.install ? `\n\n์„ค์น˜ ํ•„์š”: ${data.install}` : ''; sendCompileErrorToAI(data.error + extra, file.name, file.content); return; } sessionStorage.setItem('arduino_filename', file.name); sessionStorage.setItem('arduino_project', activeProjectName || ''); sessionStorage.setItem('arduino_board', _csbSelBoard.fqbn); // boardType์— ๋”ฐ๋ผ ๊ฒฐ๊ณผ ์ฒ˜๋ฆฌ if (data.merged) { // (์ด๋ฏธ ์œ„์—์„œ ์ฒ˜๋ฆฌ๋จ, fallback) codeAiAppend('system', `โœ… ์ปดํŒŒ์ผ ์™„๋ฃŒ.`); } else if (data.hex) { sessionStorage.setItem('arduino_hex', data.hex); sessionStorage.setItem('arduino_sketch', file.content); _emuPanelLoad('/arduino-emulator.html', `AVR โ€” ${file.name}`); } else if (data.boardType === 'rp2040') { sessionStorage.setItem('arduino_uf2', data.bin); sessionStorage.setItem('arduino_sketch', file.content); _emuPanelLoad('/arduino-emulator.html', `RP2040 โ€” ${file.name}`); } else if (data.bin) { const ext = data.boardType === 'uf2' ? '.uf2' : '.bin'; const blob = new Blob([Uint8Array.from(atob(data.bin), c => c.charCodeAt(0))], { type: 'application/octet-stream' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = file.name.replace(/\.ino$/, '') + ext; a.click(); URL.revokeObjectURL(url); const kb = (data.size / 1024).toFixed(1); codeAiAppend('system', `โœ… ์ปดํŒŒ์ผ ์™„๋ฃŒ (${kb} KB) โ€” ${ext.slice(1).toUpperCase()} ๋‹ค์šด๋กœ๋“œ๋จ.\n๋ณด๋“œ์— ์˜ฌ๋ฆฌ๋ ค๋ฉด โšก ์—…๋กœ๋“œ ๋ฒ„ํŠผ์„ ์‚ฌ์šฉํ•˜์„ธ์š”. [${_csbSelBoard.name}]`); } }).catch(e => codeAiAppend('system', 'โš ๏ธ ์„œ๋ฒ„ ์˜ค๋ฅ˜: ' + e.message)); return; } // terminal modes const ext = file.name.split('.').pop().toLowerCase(); let cmd; if (mode === 'terminal') { cmd = ext === 'py' ? `python3 ${fpath}` : ext === 'js' ? `node ${fpath}` : ext === 'ts' ? `npx ts-node ${fpath}` : ext === 'go' ? `go run ${fpath}` : ext === 'cpp' ? `g++ -o /tmp/_coder_out ${fpath} && /tmp/_coder_out` : ext === 'c' ? `gcc -o /tmp/_coder_out ${fpath} && /tmp/_coder_out` : ext === 'rs' ? `rustc -o /tmp/_coder_out ${fpath} && /tmp/_coder_out` : ext === 'sh' ? `bash ${fpath}` : `./${fpath}`; } else { cmd = mode === 'python3' ? `python3 ${fpath}` : mode === 'node' ? `node ${fpath}` : `${mode} ${fpath}`; } if (!codeTermOpen) toggleCodeTerminal(); setTimeout(() => { const s = codeTermShells.find(s => s.id === activeCodeTermShellId); if (s && s.ptyReady) termWsSend({ type: 'pty_input', shellId: s.id, data: cmd + '\r' }); }, codeTermOpen ? 0 : 500); } function _boardSpecificHints(code = '') { const fqbn = _csbSelBoard.fqbn || ''; const arch = fqbn.split(':')[1] || ''; const usesWifi = /WiFi|ESP8266WiFi|HTTPClient|WebServer/i.test(code); // ์–ธ์–ด ์ง์ ‘ ์„ ํƒ (ํ•˜๋“œ์›จ์–ด ๋ณด๋“œ ์•„๋‹˜) if (fqbn === 'lang:python') { return '\n' + [ '์–ธ์–ด: Python', '- ์ˆœ์ˆ˜ Python ์ฝ”๋“œ๋กœ ์ž‘์„ฑํ•˜์„ธ์š”. Arduino/C++ ๋ฌธ๋ฒ• ์‚ฌ์šฉ ๊ธˆ์ง€', '- ์Šคํฌ๋ฆฝํŠธ๋Š” ํ‘œ์ค€ Python ํ™˜๊ฒฝ์—์„œ ์‹คํ–‰๋ฉ๋‹ˆ๋‹ค', ].join('\n'); } if (fqbn === 'lang:cpp') { return '\n' + [ '์–ธ์–ด: C++ (์ผ๋ฐ˜)', '- ํ‘œ์ค€ C++17 ์ฝ”๋“œ๋กœ ์ž‘์„ฑํ•˜์„ธ์š”. Arduino ์ „์šฉ API ์‚ฌ์šฉ ๊ธˆ์ง€', ].join('\n'); } if (fqbn === 'lang:other') return ''; // ํ•˜๋“œ์›จ์–ด ๋ณด๋“œ: ๊ณตํ†ต Arduino ๊ทœ์น™ + arch๋ณ„ ์ถ”๊ฐ€ ๊ทœ์น™ if (!arch) return ''; const base = ['Arduino (C++) ์ฝ”๋”ฉ ๊ทœ์น™:', '- ์–ธ์–ด: C++ (Arduino ์Šค์ผ€์น˜). PC ์ „์šฉ ์•ฑ์ด ํ•„์š”ํ•˜๋ฉด ํ•ด๋‹น ์–ธ์–ด ๋ณ‘ํ–‰ ๊ฐ€๋Šฅ']; const extra = []; if (arch === 'esp32') { extra.push('- analogWrite() ์—†์Œ โ†’ ledcAttach(pin, freq, res) + ledcWrite(pin, duty)'); extra.push('- loop() ์žฅ์‹œ๊ฐ„ blocking โ†’ watchdog ๋ฆฌ์…‹. delay() ๋˜๋Š” yield() ํ•„์ˆ˜'); extra.push('- SPIFFS ๋Œ€์‹  LittleFS ๊ถŒ์žฅ (esp32 3.x ์ฝ”์–ด)'); if (usesWifi) extra.push( '- WiFi ๋‚ด์žฅ โ€” #include ํ•˜๋‚˜๋กœ ์ถฉ๋ถ„. SPI.h ์ถ”๊ฐ€ ๊ธˆ์ง€', '- WiFi.firmwareVersion() ์—†์Œ (WiFiNINA ์ „์šฉ)'); } else if (arch === 'esp8266') { extra.push('- analogWrite() ์žˆ์Œ (PWM, 0-1023)'); extra.push('- loop()์—์„œ yield() ๋˜๋Š” delay() ํ•„์ˆ˜ (watchdog ๋ฐฉ์ง€)'); extra.push('- ledcAttach ๋“ฑ ESP32 ์ „์šฉ API ์‚ฌ์šฉ ๊ธˆ์ง€'); if (usesWifi) extra.push( '- WiFi: #include . #include ์‚ฌ์šฉ ๊ธˆ์ง€', '- ์•”ํ˜ธํ™” ์ƒ์ˆ˜: ENC_TYPE_NONE / ENC_TYPE_CCMP / ENC_TYPE_TKIP'); } else if (arch === 'stm32') { extra.push('- ํ•€ ์ด๋ฆ„: PB_3ยทPA_0 ๋“ฑ STM32 PinName ์ƒ์ˆ˜ ์‚ฌ์šฉ'); extra.push('- Serial = USART1 (PA9/PA10), Serial.begin(115200) ํ•„์ˆ˜'); extra.push('- analogWrite() ์—†์Œ โ†’ HardwareTimer + PWM ์ฑ„๋„ ์‚ฌ์šฉ'); extra.push('- Wire(I2C1): SDA=PB7, SCL=PB6 / SPI1: MOSI=PA7, MISO=PA6, SCK=PA5'); } else if (arch === 'avr') { if (usesWifi) extra.push('- WiFi ์‹ค๋“œ: #include + #include '); } // sam, samd, megaavr ๋“ฑ์€ base๋งŒ ์ ์šฉ return '\n' + [...base, ...extra].join('\n'); } async function csbCompileOnly() { if (!_csbSelBoard) { codeAiAppend('system', 'โš  ๋ณด๋“œ๋ฅผ ๋จผ์ € ์„ ํƒํ•˜์„ธ์š”.'); return; } const file = codeFiles.find(f => f.id === activeCodeFileId); if (!file || !file.name.endsWith('.ino')) { codeAiAppend('system', 'โš  .ino ํŒŒ์ผ์„ ์„ ํƒํ•˜์„ธ์š”.'); return; } const code = file.model?.getValue() || file.content || ''; const btn = document.getElementById('code-compile-btn'); if (btn) { btn.textContent = 'โณ ์ปดํŒŒ์ผ ์ค‘โ€ฆ'; btn.disabled = true; } codeAiAppend('system', `๐Ÿ”จ ์ปดํŒŒ์ผ ์ค‘... [${_csbSelBoard.name}]`); try { const r = await fetch('/api/arduino/compile', { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() }, body: JSON.stringify({ code, fqbn: _csbSelBoard.fqbn }), }); const data = await r.json(); if (data.error) { sendCompileErrorToAI(data.error, file.name, code); } else { const kb = data.size ? (data.size / 1024).toFixed(1) : null; const extra = data.autoInstalled?.length ? `\n์ž๋™ ์„ค์น˜๋œ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ: ${data.autoInstalled.join(', ')}` : ''; codeAiAppend('system', `โœ… ์ปดํŒŒ์ผ ์„ฑ๊ณต${kb ? ` (${kb} KB)` : ''} โ€” ์˜ค๋ฅ˜ ์—†์Œ.${extra}`); // ์—…๋กœ๋“œ ํŒจ๋„์—์„œ ์žฌ์ปดํŒŒ์ผ ์—†์ด ๋ฐ”๋กœ ์—…๋กœ๋“œํ•  ์ˆ˜ ์žˆ๋„๋ก ์บ์‹œ _cupCompiledData = data; _cupCompiledForCode = code; _cupCompiledForFqbn = _csbSelBoard.fqbn; _cupCode = code; _cupFqbn = _csbSelBoard.fqbn; _cupBoardName = _csbSelBoard.name; _cupFname = file.name; } } catch (e) { codeAiAppend('system', `์ปดํŒŒ์ผ ์˜ค๋ฅ˜: ${e.message}`); } finally { if (btn) { btn.textContent = '๐Ÿ”จ ์ปดํŒŒ์ผ'; btn.disabled = false; } } } function sendCompileErrorToAI(error, filename, code) { const inp = document.getElementById('code-ai-input'); if (!inp) return; const boardInfo = `๋Œ€์ƒ ๋ณด๋“œ: ${_csbSelBoard.name} (FQBN: ${_csbSelBoard.fqbn})`; const hints = _boardSpecificHints(code); // Library conflict (redefinition) โ€” not fixable by editing the .ino file if (/redefinition of/.test(error)) { const m = error.match(/\/sketch\/(\S+?\.h)/); const conflictHeader = m ? m[1] : 'unknown.h'; inp.value = `์•„๋‘์ด๋…ธ ์ปดํŒŒ์ผ ์˜ค๋ฅ˜: **๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์ถฉ๋Œ**์ž…๋‹ˆ๋‹ค. ${boardInfo}${hints}\n\n์›์ธ: \`${conflictHeader}\`๊ฐ€ ์ฝ”์–ด ๋‚ด์žฅ ํ—ค๋”์™€ ์ค‘๋ณต ์ •์˜(redefinition) ์ถฉ๋Œ์„ ์ผ์œผํ‚ค๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ${filename} ์ฝ”๋“œ์˜ \`#include\` ๋ฐฉํ–ฅ์„ ์กฐ์ •ํ•˜๊ฑฐ๋‚˜ ์™ธ๋ถ€ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ๊ต์ฒดํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.\n\n์˜ค๋ฅ˜:\n\`\`\`\n${error}\n\`\`\`\n\nํ•ด๊ฒฐ ๋ฐฉ๋ฒ•์„ ์„ค๋ช…ํ•ด์ฃผ์„ธ์š”. ํŒŒ์ผ ๊ฒ€์ƒ‰์€ ํ•˜์ง€ ์•Š์•„๋„ ๋ฉ๋‹ˆ๋‹ค.`; } else { inp.value = `์•„๋‘์ด๋…ธ ์ปดํŒŒ์ผ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค. ${boardInfo}${hints}\n\nRead๋กœ ${filename}์„ ์ฝ์€ ๋’ค Edit ๋˜๋Š” Write ๋„๊ตฌ๋กœ ํŒŒ์ผ์„ ์ง์ ‘ ์ˆ˜์ •ํ•ด์ค˜.\n\n์˜ค๋ฅ˜:\n\`\`\`\n${error}\n\`\`\``; } codeAiSend(); } function codeSendSelectionToAI() { if (!monacoEditor) return; const sel = monacoEditor.getSelection(); const selected = sel && !sel.isEmpty() ? monacoEditor.getModel().getValueInRange(sel) : monacoEditor.getValue(); const file = codeFiles.find(f => f.id === activeCodeFileId); const lang = file ? file.language : 'code'; const prompt = `๋‹ค์Œ ${lang} ์ฝ”๋“œ๋ฅผ ๋ถ„์„ํ•ด์ค˜:\n\`\`\`${lang}\n${selected}\n\`\`\``; document.getElementById('code-ai-input').value = prompt; codeAiSend(); } // โ”€โ”€โ”€ V2 Chat Live Code Streaming โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Global state for streaming code into the editor during the main v2 chat. const _v2LiveState = { inBlock: false, lang: '', fileId: null, originalContent: null, isDiff: false, diffAccumulated: null }; let codePendingQueue = []; // [{fname, content, lang}] โ€” queued when Monaco not ready // Throttled Monaco model update โ€” avoids freezing UI by limiting setValue calls to ~80ms intervals let _monacoThrottleTimer = null; let _monacoThrottleModel = null; let _monacoThrottleValue = ''; function codeMonacoThrottle(model, value) { _monacoThrottleModel = model; _monacoThrottleValue = value; if (_monacoThrottleTimer) return; // already scheduled _monacoThrottleTimer = setTimeout(() => { _monacoThrottleTimer = null; if (_monacoThrottleModel && _monacoThrottleValue !== undefined) { const _lines = _monacoThrottleValue.split('\n').length; _DBG && console.log(`[codeMonacoThrottle] setValue: ${_lines} lines`); _monacoThrottleModel.setValue(_monacoThrottleValue); // Don't null _monacoThrottleModel here โ€” keep it alive for codeMonacoThrottleFlush. // The timer callback applies the latest throttled value, but a later flush // (called when streaming ends) must still be able to apply any final value // that arrived after this timer fired. } }, 80); } function codeMonacoThrottleFlush() { if (_monacoThrottleTimer) { clearTimeout(_monacoThrottleTimer); _monacoThrottleTimer = null; } if (_monacoThrottleModel && _monacoThrottleValue !== undefined) { _monacoThrottleModel.setValue(_monacoThrottleValue); } _monacoThrottleModel = null; } function codeFlushPendingQueue() { if (!monacoReady || !monacoEditor || !codePendingQueue.length) return; for (const item of codePendingQueue) { const existing = codeFiles.find(f => f.name === item.fname); if (existing) { const oldContent = existing.model ? existing.model.getValue() : existing.content; if (oldContent !== item.content) { switchCodeFile(existing.id); showCodeDiff(existing.id, oldContent, item.content); } } else { const id = 'cf_' + Math.random().toString(36).slice(2, 8); const model = monaco.editor.createModel(item.content, codeDetectLang(item.fname)); codeFiles.push({ id, name: item.fname, content: item.content, language: codeDetectLang(item.fname), model }); if (!_v2LiveState.fileId) _v2LiveState.fileId = id; } } codePendingQueue = []; if (_v2LiveState.fileId) switchCodeFile(_v2LiveState.fileId); } // โ”€โ”€ Shared live-streaming parser โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Detects the last unclosed ```code block in a streaming reply and mirrors it // into the editor: creates/reuses a tab, throttles model updates, and renames // the tab once a real filename surfaces. `state` is a mutable holder // { inBlock, lang, fileId } so each caller keeps its own streaming context. function codeLiveStreamUpdate(fullReply, state) { if (typeof codeIsOpenable !== 'function') return; // Parse ALL code blocks using toggle: opening ```lang\n starts a block, // bare ``` closes it. This correctly pairs fences and excludes explanation // text between blocks. The model may split one file across multiple blocks // (e.g. 37 lines + explanation + continuation) โ€” we concatenate all blocks // so the editor shows the complete program. // // CRITICAL: Only match ``` at the beginning of a line (with optional leading // whitespace). Without this check, ``` inside code (e.g. in comments like // `# ``` or strings) would be misinterpreted as a fence, causing the code // block to end prematurely and the editor to clear mid-stream. const fenceRe = /```([\w\-]*)[ \t]*\r?\n/g; const blocks = []; let inBlock = false, blockLang = '', blockStart = 0, m; while ((m = fenceRe.exec(fullReply)) !== null) { // Require ``` at start of line (only whitespace before it) โ€” prevents // false matches inside code (comments, strings, etc.) const lineStart = m.index === 0 ? 0 : fullReply.lastIndexOf('\n', m.index - 1) + 1; const prefix = fullReply.slice(lineStart, m.index); if (prefix.trim() !== '') continue; // ``` is mid-line, skip if (!inBlock) { blockLang = m[1] || 'text'; blockStart = m.index + m[0].length; inBlock = true; } else { blocks.push({ lang: blockLang, code: fullReply.slice(blockStart, m.index) }); inBlock = false; } } if (inBlock) { // Unclosed block โ€” still streaming blocks.push({ lang: blockLang, code: fullReply.slice(blockStart), streaming: true }); } if (!blocks.length) { state.inBlock = false; return; } // Concatenate all code blocks (same or different language) let code = ''; let lang = blocks[blocks.length - 1].lang || 'text'; for (const block of blocks) { if (code) code += '\n'; code += block.code; if (block.lang && block.lang !== 'text') lang = block.lang; } code = code.replace(/\n+$/, ''); _DBG && console.log(`[codeLiveStream] blocks=${blocks.length} lines=${code.split('\n').length} inBlock=${state.inBlock} fileId=${state.fileId} lang=${lang}`); // โ”€โ”€ Diff block handling โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // When the language is 'diff', the block contains a unified diff that // should be applied to an existing file (not replace it entirely). if (lang === 'diff') { if (!state.inBlock) { state.inBlock = true; state.lang = lang; state.isDiff = true; // We'll accumulate the diff content and apply after streaming. // Priority: explicit filename in diff > active tab. // The model names the file it's editing in the diff header or filename comment โ€” // that explicit name wins so the right tab gets focused automatically. const fname = codeDeriveName(code, lang) || code.match(/^(?:---|\+\+\+)\s+(?:[ab]\/)?(.+?)(?:\t|$)/m)?.[1]?.trim(); const fnameFile = fname ? ( codeFiles.find(f => f.name === fname) || codeFiles.find(f => f.name.endsWith('/' + fname)) ) : null; const existing = fnameFile || (state.targetFileId && codeFiles.find(f => f.id === state.targetFileId)) || codeFiles.find(f => f.id === activeCodeFileId); if (existing) { state.originalContent = existing.model ? existing.model.getValue() : existing.content; state.fileId = existing.id; state.diffAccumulated = code; switchCodeFile(existing.id); } else { // No matching file found โ€” create a placeholder const id = 'cf_' + Math.random().toString(36).slice(2, 8); const model = monacoReady ? monaco.editor.createModel(code, 'diff') : null; codeFiles.push({ id, name: fname || 'diff.patch', content: code, language: 'diff', model }); state.fileId = id; state.originalContent = ''; state.diffAccumulated = code; switchCodeFile(id); } } else if (state.isDiff && state.fileId) { // Continue accumulating diff content โ€” keep the original file in the editor; // the diff will be applied when streaming completes (see codeLiveStreamUpdate end-of-stream) state.diffAccumulated = code; } return; } // โ”€โ”€ Full code block handling โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Detect if the model accidentally wrapped a diff in a non-diff code fence // (e.g. ```python with +prefix/-prefix lines) โ€” treat it as a diff block instead. // Reuses _isDiffCode (the same +/-/@@ ratio heuristic). if (lang !== 'diff' && _isDiffCode(lang, code)) { // Model output diff content inside a non-diff code fence โ€” redirect to diff handling state.inBlock = true; state.lang = 'diff'; state.isDiff = true; // Diff content wrapped in a non-diff fence โ€” target the active/context file too. const fname = codeDeriveName(code, lang); const existing = (state.targetFileId && codeFiles.find(f => f.id === state.targetFileId)) || codeFiles.find(f => f.name === fname) || codeFiles.find(f => f.id === activeCodeFileId); if (existing) { state.originalContent = existing.model ? existing.model.getValue() : existing.content; state.fileId = existing.id; state.diffAccumulated = code; switchCodeFile(existing.id); } else { const id = 'cf_' + Math.random().toString(36).slice(2, 8); const model = monacoReady ? monaco.editor.createModel(code, codeDetectLang(fname)) : null; codeFiles.push({ id, name: fname, content: code, language: codeDetectLang(fname), model }); state.fileId = id; state.originalContent = ''; state.diffAccumulated = code; switchCodeFile(id); } return; } if (!state.inBlock) { // Wait until the block looks like a real file (not a short run-command snippet) if (!codeIsOpenable(code, lang)) return; state.inBlock = true; state.lang = lang; const fname = codeDeriveName(code, lang); _DBG && console.log(`[codeLiveStream] NEW BLOCK: fname=${fname} lines=${code.split('\n').length}`); if (monacoReady && monacoEditor) { // Reuse existing tab with same name instead of creating a duplicate const existing = codeFiles.find(f => f.name === fname); if (existing) { // Save original content so we can show a diff after streaming completes state.originalContent = existing.model ? existing.model.getValue() : existing.content; state.fileId = existing.id; // If editing existing file: keep original in editor, show streamed changes as decorations if (state.originalContent && state.originalContent.trim()) { state.streamingModifiedText = code; _updateStreamingDiffDecorations(state.fileId, state.originalContent, code); } else { switchCodeFile(existing.id); } } else { const id = 'cf_' + Math.random().toString(36).slice(2, 8); const model = monaco.editor.createModel(code, codeDetectLang(fname)); codeFiles.push({ id, name: fname, content: code, language: codeDetectLang(fname), model }); state.fileId = id; switchCodeFile(id); } } else { // Monaco not ready yet โ€” queue and trigger init codePendingQueue = [{ fname, content: code, lang }]; initCodeView(); } } else if (state.fileId && !state.isDiff) { _DBG && console.log(`[codeLiveStream] UPDATE: fileId=${state.fileId} lines=${code.split('\n').length} origContent=${!!state.originalContent} diff=${state.isDiff}`); const file = codeFiles.find(f => f.id === state.fileId); if (file?.model) { // If editing existing file with original content: stream as diff decorations if (state.originalContent && state.originalContent.trim()) { state.streamingModifiedText = code; // Keep the original text in the editor โ€” update streaming diff decorations _updateStreamingDiffDecorations(state.fileId, state.originalContent, code); // Don't call codeMonacoThrottle here โ€” we keep original in editor } else { codeMonacoThrottle(file.model, code); } // Rename tab if a filename comment appears later in the stream const betterName = codeDeriveName(code, state.lang); if (betterName && !betterName.startsWith('ai_') && file.name.startsWith('ai_')) { file.name = betterName; file.language = codeDetectLang(betterName); renderCodeFileTabs(); } } } else if (codePendingQueue.length) { // Monaco still loading โ€” update queued content codePendingQueue[codePendingQueue.length - 1].content = code; } } // โ”€โ”€ Real-time streaming diff decorations โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // While the model streams code, compare original vs streamed content and // highlight changed/added lines in green โ€” without replacing the editor content. // This is the VSCode Copilot approach: keep original, show changes as overlay. // Throttled streaming diff to avoid excessive decoration updates let _streamingDiffTimer = null; let _streamingDiffFileId = null; let _streamingDiffOriginal = ''; let _streamingDiffModified = ''; function _updateStreamingDiffDecorations(fileId, originalText, modifiedText) { _streamingDiffFileId = fileId; _streamingDiffOriginal = originalText; _streamingDiffModified = modifiedText; if (_streamingDiffTimer) return; // already scheduled _streamingDiffTimer = setTimeout(() => { _streamingDiffTimer = null; _applyStreamingDiffDecorations(_streamingDiffFileId, _streamingDiffOriginal, _streamingDiffModified); }, 120); } function _applyStreamingDiffDecorations(fileId, originalText, modifiedText) { if (!monacoReady || !monacoEditor || !window.monaco) return; const file = codeFiles.find(f => f.id === fileId); if (!file || file.id !== activeCodeFileId) return; // Make sure editor still shows original content const currentContent = file.model ? file.model.getValue() : file.content; if (currentContent !== originalText) { // Editor was changed externally โ€” don't override return; } // Highlight changed regions while keeping the original text in the editor. // (We can't reliably place added/deleted lines until streaming completes, so we // only color the affected lines.) const origLines = originalText.split('\n'); const hunks = computeDiffHunks(originalText, modifiedText); if (!hunks.length) { // No changes yet โ€” remove any streaming decorations if (file.model && file._streamingDecos) { file.model.deltaDecorations(file._streamingDecos, []); file._streamingDecos = null; } return; } // We're keeping original text in editor. Show decorations on lines that have changes. // For "change" hunks: highlight the original lines that will be modified // For "add" hunks: highlight the line before where additions will appear // For "delete" hunks: highlight lines that will be removed with strikethrough-like style const streamingDecos = []; for (const hunk of hunks) { if (hunk.type === 'delete') { // Lines that will be removed for (let line = hunk.oldStart; line <= hunk.oldEnd && line <= origLines.length; line++) { streamingDecos.push({ range: new monaco.Range(line, 1, line, 1), options: { className: 'inline-diff-streaming-delete', glyphMarginClassName: 'inline-diff-glyph-delete', isWholeLine: true, minimap: { color: '#dc354580', position: monaco.editor.MinimapPosition.Inline }, overviewRuler: { color: '#dc354560', position: monaco.editor.OverviewRulerLane.Full }, } }); } } else if (hunk.type === 'add') { // New lines will appear after oldStart โ€” highlight the line before const insertAfter = Math.max(1, hunk.oldStart - 1); streamingDecos.push({ range: new monaco.Range(insertAfter, 1, insertAfter, 1), options: { className: 'inline-diff-streaming-add-marker', isWholeLine: true, } }); } else { // Change: old lines will be replaced for (let line = hunk.oldStart; line <= hunk.oldEnd && line <= origLines.length; line++) { streamingDecos.push({ range: new monaco.Range(line, 1, line, 1), options: { className: 'inline-diff-streaming-change', glyphMarginClassName: 'inline-diff-glyph-change', isWholeLine: true, minimap: { color: '#ffc10780', position: monaco.editor.MinimapPosition.Inline }, overviewRuler: { color: '#ffc10760', position: monaco.editor.OverviewRulerLane.Full }, } }); } } } // Apply streaming decorations const oldDecos = file._streamingDecos || []; file._streamingDecos = file.model.deltaDecorations(oldDecos, streamingDecos); } // Apply a unified diff to original content and return the new content. // Parses @@ -a,b +c,d @@ hunks and applies additions/removals. function applyUnifiedDiff(originalContent, diffText) { const origLines = originalContent.split('\n'); const diffLines = diffText.split('\n'); const result = []; let origPos = 0; // 0-based cursor into origLines // Whitespace-insensitive compare. Models frequently emit @@ line numbers that // are a few lines off, or drop the leading marker space on context lines โ€” // both desync origPos and make context lines pull the WRONG original line, // which shows up as corrupted indentation. We re-anchor on line content (not // position), so the authoritative original text (with its real indentation) // is always what gets emitted for unchanged/context lines. const norm = (s) => s.trim(); // Try to move origPos to the original line matching `content`, scanning a small // window ahead. Any original lines skipped on the way were unchanged, so emit // them as context. Returns true when aligned. const realign = (content) => { if (origPos >= origLines.length) return false; if (norm(origLines[origPos]) === norm(content)) return true; for (let k = 1; k <= 5 && origPos + k < origLines.length; k++) { if (norm(origLines[origPos + k]) === norm(content)) { for (let j = 0; j < k; j++) result.push(origLines[origPos++]); return true; } } return false; }; // Skip to first hunk header let i = 0; while (i < diffLines.length && !diffLines[i].startsWith('@@')) i++; if (i >= diffLines.length) { // No hunks found โ€” try simple +/- line format (no @@ headers) return applySimpleDiff(originalContent, diffText); } // Walk each hunk in lock-step with the original: copy untouched lines up to the // hunk, then preserve context lines, drop '-' lines, and insert '+' lines in // order. Tracking origPos keeps multi-hunk patches aligned (the old splice // approach lost context lines and drifted on the 2nd hunk โ†’ corrupted output). while (i < diffLines.length) { if (!diffLines[i].startsWith('@@')) { i++; continue; } const hunkMatch = diffLines[i].match(/@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/); if (!hunkMatch) { i++; continue; } const oldStart = parseInt(hunkMatch[1], 10); i++; // Move past @@ line // Position origPos at the start of this hunk. The @@ line number is only a // hint โ€” models routinely get it wrong. Prefer anchoring on the hunk's first // context/deletion line: find it in the original (forward from where we are) // and emit the skipped lines as unchanged. Only fall back to the @@ number for // pure-insertion hunks, which have no anchor line to search for. let anchor = null; for (let p = i; p < diffLines.length && !diffLines[p].startsWith('@@'); p++) { const L = diffLines[p]; if (L.startsWith('\\') || L.startsWith('+++') || L.startsWith('---')) continue; const t = L[0]; if (t === '+') continue; anchor = (t === ' ' || t === '-') ? L.slice(1) : L; // context or deletion (or bare context) break; } if (anchor !== null) { let found = -1; for (let q = origPos; q < origLines.length; q++) { if (origLines[q].trim() === anchor.trim()) { found = q; break; } } if (found >= 0) { while (origPos < found) result.push(origLines[origPos++]); } else { const hs = Math.max(0, oldStart - 1); while (origPos < hs && origPos < origLines.length) result.push(origLines[origPos++]); } } else { const hs = Math.max(0, oldStart - 1); while (origPos < hs && origPos < origLines.length) result.push(origLines[origPos++]); } // Apply hunk body while (i < diffLines.length && !diffLines[i].startsWith('@@')) { const line = diffLines[i]; if (line.startsWith('\\')) { i++; continue; } // "\ No newline at end of file" if (line.startsWith('+++') || line.startsWith('---')) { i++; continue; } // file headers if (/^`{3}/.test(line)) { i++; continue; } // stray closing fence (no trailing \n on last stream token) const tag = line[0]; const body = line.slice(1); // line minus its 1-char marker if (tag === '+') { result.push(body); // added line โ€” verbatim, keep its indentation } else if (tag === '-') { realign(body); // anchor to the line being removed origPos++; // removed โ€” consume original } else if (tag === ' ') { realign(body); // anchor to the matching original line if (origPos < origLines.length) result.push(origLines[origPos++]); // context โ€” keep ORIGINAL text (true indentation) else result.push(body); } else { // No-prefix line: treat as context when it matches the original // (whitespace-insensitively โ€” models often drop the leading space), else literal if (realign(line)) result.push(origLines[origPos++]); else result.push(line); } i++; } } // Copy any remaining original lines after the last hunk while (origPos < origLines.length) result.push(origLines[origPos++]); return result.join('\n'); } // Fallback: simple +/- diff without @@ headers (e.g. model just outputs +added/-removed lines) // Format: lines starting with '+ ' are additions, '- ' are deletions, ' ' (single space) are context. // Lines with no prefix at all are ignored (filename comments, blank separators, etc.) function applySimpleDiff(originalContent, diffText) { const origLines = originalContent.split('\n'); const diffLines = diffText.split('\n'); const result = []; let origPos = 0; // 0-based cursor into origLines const norm = (s) => s.trim(); // Advance origPos to the first original line at/after origPos matching // `content` (whitespace-insensitive), emitting any skipped originals as // unchanged context. Returns that index (origPos left there), or -1 unmoved. // This anchors '-'/context/bare lines to the REAL original position instead of // assuming the diff starts at line 0 โ€” without it a leading deletion consumed // the wrong line and insertions floated to the top (context mismatch). const seekAndEmit = (content) => { for (let q = origPos; q < origLines.length; q++) { if (norm(origLines[q]) === norm(content)) { while (origPos < q) result.push(origLines[origPos++]); return q; } } return -1; }; for (const line of diffLines) { if (line === '') continue; // blank separator noise if (line.startsWith('@@') || line.startsWith('\\')) continue; // hunk header / "no newline" if (line.startsWith('+++') || line.startsWith('---')) continue; // file headers const tag = line[0]; const body = line.slice(1); // strip exactly ONE marker char if (tag === '+') { result.push(body); // addition โ€” verbatim, keep indentation } else if (tag === '-') { if (seekAndEmit(body) >= 0) origPos++; // delete the matched original // not found โ†’ stale/duplicate deletion, drop silently } else if (tag === ' ') { if (seekAndEmit(body) >= 0) result.push(origLines[origPos++]); // context โ€” emit ORIGINAL (true indentation) // not found โ†’ context drifted; skip rather than corrupt } else { // Bare line: the model frequently drops the leading space on context lines. // Treat it as context ONLY when it matches an upcoming original โ€” otherwise // it is a stray comment / "# filename:" / prose line and is ignored. if (seekAndEmit(line) >= 0) result.push(origLines[origPos++]); } } while (origPos < origLines.length) result.push(origLines[origPos++]); return result.length > 0 ? result.join('\n') : originalContent; } // Main v2-chat live streaming (used in the full app) โ€” thin wrapper over the // shared parser with its own module-level state. function v2LiveCodeUpdate(fullReply) { codeLiveStreamUpdate(fullReply, _v2LiveState); } // โ”€โ”€ /new-project 2-phase project creation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // State variables let _codeAiSending = false; // Public entry. Guards against overlapping sends and doubles as a Stop control: // while a generation is in flight, the ์ „์†ก button turns into โน ์ค‘์ง€ and a second // click aborts the current request instead of starting a racing one. async function codeAiSend() { if (_codeAiSending) { if (codeAiAbort) codeAiAbort.abort(); return; } const inp = document.getElementById('code-ai-input'); if (!inp || !inp.value.trim()) return; _codeAiSending = true; _setSendBtnBusy(true); try { await _codeAiSendImpl(); } finally { _codeAiSending = false; _setSendBtnBusy(false); } } function _setSendBtnBusy(busy) { const btn = document.getElementById('code-ai-send-btn'); if (!btn) return; btn.textContent = busy ? 'โน ์ค‘์ง€' : '์ „์†ก'; btn.classList.toggle('busy', busy); } async function _codeAiSendImpl() { const input = document.getElementById('code-ai-input'); const message = input.value.trim(); const attachedImages = [..._pendingImages]; if (!message && !attachedImages.length) return; input.value = ''; input.style.height = ''; _pendingImages = []; _codeAiRenderImagePreview(); // @ํŒŒ์ผ๋ช… ์ฐธ์กฐ ์ฒ˜๋ฆฌ โ€” ๋ฉ”์‹œ์ง€์—์„œ @xxx.js ํŒจํ„ด์„ ์ฐพ์•„ ํŒŒ์ผ ๋‚ด์šฉ ์‚ฝ์ž… let resolvedMessage = message; const atInlinedFiles = new Set(); // track files already inlined via @ref if (localDirHandle) { const atRefs = [...message.matchAll(/@([\w./-]+\.\w+)/g)]; for (const m of atRefs) { const fname = activeProjectName ? `${activeProjectName}/${m[1]}` : m[1]; const content = await localReadFile(fname) ?? await localReadFile(m[1]); if (content !== null) { const ext = m[1].split('.').pop() || ''; resolvedMessage = resolvedMessage.replace(m[0], `\n[ํŒŒ์ผ: ${m[1]}]\n\`\`\`${ext}\n${content}\n\`\`\`\n`); atInlinedFiles.add(m[1]); // bare name atInlinedFiles.add(fname); // project-prefixed name } } } // ํ”„๋กœ์ ํŠธ ๋‚ด ๋ชจ๋“  ํŒŒ์ผ ๋‚ด์šฉ์„ ์ปจํ…์ŠคํŠธ์— ํฌํ•จ (ํ˜„์žฌ ํŒŒ์ผ ์ œ์™ธ, ๋ณ„๋„ ์ฒจ๋ถ€) let otherFilesCtx = ''; { const currentFileName = codeFiles.find(f => f.id === activeCodeFileId)?.name; const seen = new Set(); const CTX_TOTAL_BYTES = 500 * 1024; // 500KB total cap for other-files section const SKIP_NAMES = /^(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|\.DS_Store|thumbs\.db)$/i; const addFile = (rel, content) => { if (otherFilesCtx.length >= CTX_TOTAL_BYTES) return; const ext = rel.split('.').pop() || ''; const chunk = `[ํŒŒ์ผ: ${rel}]\n\`\`\`${ext}\n${content}\n\`\`\`\n`; if (otherFilesCtx.length + chunk.length > CTX_TOTAL_BYTES) { otherFilesCtx += `[๋‚˜๋จธ์ง€ ํŒŒ์ผ ์ƒ๋žต โ€” ์ปจํ…์ŠคํŠธ ํฌ๊ธฐ ์ดˆ๊ณผ]\n`; } else { otherFilesCtx += chunk; } }; // 1. All other open tabs (no project prefix required โ€” covers both prefixed and bare filenames) for (const f of codeFiles) { if (f.name === currentFileName) continue; // If activeProjectName set, only include files from same project. // ๋กœ์ปฌ ๋ชจ๋“œ(localDirHandle ์žˆ์Œ)์—์„œ๋Š” ํƒญ์ด ๋ฒ ์–ด ๊ฒฝ๋กœ("src/main.py")๋กœ ์ €์žฅ๋  ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ // ์ฒซ ์„ธ๊ทธ๋จผํŠธ๊ฐ€ ์•Œ๋ ค์ง„ ๋‹ค๋ฅธ ํ”„๋กœ์ ํŠธ ์ด๋ฆ„์ธ ๊ฒฝ์šฐ์—๋งŒ ์ œ์™ธํ•œ๋‹ค. if (activeProjectName && f.name.includes('/')) { const isCurrentProjPrefix = f.name.startsWith(activeProjectName + '/'); if (!isCurrentProjPrefix) { if (!localDirHandle) { continue; } // ์„œ๋ฒ„ ๋ชจ๋“œ: ํ”„๋กœ์ ํŠธ prefix ์—†์œผ๋ฉด ๋‹ค๋ฅธ ํ”„๋กœ์ ํŠธ // ๋กœ์ปฌ ๋ชจ๋“œ: ์ฒซ ์„ธ๊ทธ๋จผํŠธ๊ฐ€ ์•Œ๋ ค์ง„ ๋‹ค๋ฅธ ํ”„๋กœ์ ํŠธ ์ด๋ฆ„์ด๋ฉด ์ œ์™ธ const firstSeg = f.name.split('/')[0]; const knownProjects = _knownProjectsGet(); if (knownProjects.includes(firstSeg) && firstSeg !== activeProjectName) continue; } } if (!isCodeFile(f.name)) continue; // skip .md, .txt etc. const bare = f.name.split('/').pop() || ''; if (SKIP_NAMES.test(bare)) continue; seen.add(f.name); const rel = activeProjectName ? f.name.replace(activeProjectName + '/', '') : f.name; const content = f.model ? f.model.getValue() : f.content; if (!content) continue; addFile(rel, content); } // 1.5. Files Read by model in prior turns โ€” inject full content (cross-turn context memory) for (const [cachedName, cachedContent] of Object.entries(_readFileCache)) { const currentBare = currentFileName ? _clientBareFileName(currentFileName) : ''; if (cachedName === currentBare) continue; if (seen.has(cachedName) || seen.has(activeProjectName + '/' + cachedName)) continue; seen.add(cachedName); addFile(cachedName, cachedContent); } // 2. Files on disk not yet opened as tabs โ€” manifest only (Claude Code ๋ฐฉ์‹: ๋ชฉ๋ก๋งŒ ์ฃผ๊ณ  ํ•„์š” ์‹œ coder_read_file๋กœ ์š”์ฒญ) if (localDirHandle) { try { const allFiles = await localListFiles(localDirHandle, ''); const projFiles = activeProjectName ? allFiles.filter(f => f.startsWith(activeProjectName + '/')) : allFiles; const manifest = projFiles .filter(f => { const bare = activeProjectName ? f.slice(activeProjectName.length + 1) : f; return !seen.has(f) && !seen.has(bare) && f !== currentFileName && isCodeFile(f) && !SKIP_NAMES.test(f.split('/').pop() || ''); }) .map(f => activeProjectName ? f.slice(activeProjectName.length + 1) : f); if (manifest.length) otherFilesCtx += `[๋กœ์ปฌ ํŒŒ์ผ (Read ํˆด๋กœ ์š”์ฒญ ๊ฐ€๋Šฅ): ${manifest.join(', ')}]\n\n`; } catch {} } } // Include current file context (ask/code ๋ชจ๋“œ ๋ชจ๋‘ ํฌํ•จ) const currentFile = codeFiles.find(f => f.id === activeCodeFileId); // otherFilesCtx๋Š” currentFile ์œ ๋ฌด์™€ ๋ฌด๊ด€ํ•˜๊ฒŒ ํ•ญ์ƒ contextMsg์— ํฌํ•จ (ํ˜„์žฌ ํŒŒ์ผ ์—†์–ด๋„ ํ”„๋กœ์ ํŠธ ํŒŒ์ผ ๋ณด์ด๊ฒŒ) let contextMsg = otherFilesCtx ? otherFilesCtx + resolvedMessage : resolvedMessage; // Add tech hint for the model const techHints = { html: '์›น (HTML/CSS/JS)', python: 'Python', node: 'Node.js/JavaScript', auto: '' }; const techHint = techHints[codeAiTech] || ''; if (techHint && !contextMsg.includes('[tech]')) { contextMsg = `[tech: ${techHint}] ${contextMsg}`; } if (currentFile && monacoEditor) { const content = monacoEditor.getValue(); // Diff editing only makes sense against REAL existing code. A brand-new tab // seeded with a placeholder comment (e.g. "# ์—ฌ๊ธฐ์— ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•˜์„ธ์š”") is not // editable code โ€” asking for a diff there makes the model emit a whole program // as +lines, which then got saved verbatim ("+๊ฐ€ ๋ถ™์–ด ๋‚˜์˜ด"). Treat a file with // no substantive (non-comment, non-blank) lines as new โ†’ request full code. const hasRealCode = content.split('\n').some(l => { const t = l.trim(); return t && !t.startsWith('#') && !t.startsWith('//') && !t.startsWith('/*') && !t.startsWith('*') && !t.startsWith('')) { inBlock = true; blockEnd = '-->'; } } else if (l.startsWith('"""') || l.startsWith("'''")) { const d = l.slice(0, 3); hide = true; if (!l.slice(3).includes(d)) { inBlock = true; blockEnd = d; } } if (hide) { if (start < 0) start = i; } else { if (start >= 0) { ranges.push({ startLineNumber: start, startColumn: 1, endLineNumber: i - 1, endColumn: 1 }); start = -1; } } } if (start >= 0) ranges.push({ startLineNumber: start, startColumn: 1, endLineNumber: n, endColumn: 1 }); try { monacoEditor.setHiddenAreas(ranges); } catch {} } let _blockFlipFlop = false; let _blockFFDecoIds = []; function csbBlockFlipFlop(checked) { _blockFlipFlop = !!checked; localStorage.setItem('codeEditorBlockFF', _blockFlipFlop ? '1' : '0'); const btn = document.getElementById('code-opt-blockff'); if (btn) btn.classList.toggle('active', _blockFlipFlop); _applyBlockFlipFlop(); } function _applyBlockFlipFlop() { if (!monacoEditor) return; const model = monacoEditor.getModel(); if (!model) return; _blockFFDecoIds = monacoEditor.deltaDecorations(_blockFFDecoIds, []); if (!_blockFlipFlop) return; const n = model.getLineCount(); const decos = []; // Detect top-level function/class/def declarations const declRe = /^(export\s+)?(default\s+)?(async\s+)?(function\b|class\b|def\b|async\s+def\b)|^(const|let|var)\s+\w+\s*=\s*(async\s+)?(function|\(|[A-Za-z_$]\w*\s*=>)/; const boundaries = []; for (let i = 1; i <= n; i++) { const line = model.getLineContent(i); if (line.match(/^(\s*)/)[1].length === 0 && declRe.test(line.trim())) { boundaries.push(i); } } const ffCls = (idx) => idx % 2 === 0 ? 'ff-block-even' : 'ff-block-odd'; const ffRange = (from, to, idx) => ({ range: { startLineNumber: from, startColumn: 1, endLineNumber: to, endColumn: 1 }, options: { isWholeLine: true, className: ffCls(idx) } }); if (boundaries.length >= 2) { // Declaration-based: alternate between declaration boundaries for (let b = 0; b < boundaries.length; b++) { const from = boundaries[b]; const to = b + 1 < boundaries.length ? boundaries[b + 1] - 1 : n; decos.push(ffRange(from, to, b)); } } else { // Fallback: blank-line-separated groups let phase = 0, blockStart = -1; for (let i = 1; i <= n; i++) { const blank = model.getLineContent(i).trim() === ''; if (!blank && blockStart < 0) { blockStart = i; } else if (blank && blockStart >= 0) { decos.push(ffRange(blockStart, i - 1, phase)); blockStart = -1; phase++; } } if (blockStart >= 0) decos.push(ffRange(blockStart, n, phase)); } _blockFFDecoIds = monacoEditor.deltaDecorations([], decos); } const CODE_THEME_GROUPS = [ { icon: '๐ŸŒ™', label: '๋‹คํฌ', themes: [ { v: 'github-dark', label: 'GitHub Dark', bg: '#0d1117' }, { v: 'one-dark', label: 'One Dark', bg: '#282c34' }, { v: 'dracula', label: 'Dracula', bg: '#282a36' }, { v: 'nord', label: 'Nord', bg: '#2e3440' }, { v: 'vs-dark', label: 'VS Dark', bg: '#1e1e1e' }, { v: 'monokai', label: 'Monokai', bg: '#272822' }, ]}, { icon: 'โ˜€๏ธ', label: '๋ผ์ดํŠธ', themes: [ { v: 'github-light', label: 'GitHub Light', bg: '#f6f8fa' }, { v: 'vs', label: 'VS Light', bg: '#ffffff' }, ]}, ]; const CODE_THEMES = CODE_THEME_GROUPS.flatMap(g => g.themes); function defineCodeThemes() { const d = monaco.editor.defineTheme.bind(monaco.editor); d('monokai', { base:'vs-dark', inherit:true, rules:[ {token:'keyword',foreground:'f92672'},{token:'string',foreground:'e6db74'}, {token:'comment',foreground:'75715e',fontStyle:'italic'},{token:'number',foreground:'ae81ff'}, {token:'type',foreground:'66d9ef'},{token:'function',foreground:'a6e22e'}, ], colors:{'editor.background':'#272822','editor.foreground':'#f8f8f2','editor.selectionBackground':'#49483e','editorCursor.foreground':'#f8f8f0'}}); d('dracula', { base:'vs-dark', inherit:true, rules:[ {token:'keyword',foreground:'ff79c6'},{token:'string',foreground:'f1fa8c'}, {token:'comment',foreground:'6272a4',fontStyle:'italic'},{token:'number',foreground:'bd93f9'}, {token:'type',foreground:'8be9fd'},{token:'function',foreground:'50fa7b'}, ], colors:{'editor.background':'#282a36','editor.foreground':'#f8f8f2','editor.selectionBackground':'#44475a','editorCursor.foreground':'#f8f8f2','editor.lineHighlightBackground':'#44475a'}}); d('one-dark', { base:'vs-dark', inherit:true, rules:[ {token:'keyword',foreground:'c678dd'},{token:'string',foreground:'98c379'}, {token:'comment',foreground:'5c6370',fontStyle:'italic'},{token:'number',foreground:'d19a66'}, {token:'type',foreground:'e5c07b'},{token:'function',foreground:'61afef'}, ], colors:{'editor.background':'#282c34','editor.foreground':'#abb2bf','editor.selectionBackground':'#3e4451','editorCursor.foreground':'#528bff'}}); d('nord', { base:'vs-dark', inherit:true, rules:[ {token:'keyword',foreground:'81a1c1'},{token:'string',foreground:'a3be8c'}, {token:'comment',foreground:'4c566a',fontStyle:'italic'},{token:'number',foreground:'b48ead'}, {token:'type',foreground:'8fbcbb'},{token:'function',foreground:'88c0d0'}, ], colors:{'editor.background':'#2e3440','editor.foreground':'#d8dee9','editor.selectionBackground':'#3b4252','editorCursor.foreground':'#d8dee9','editor.lineHighlightBackground':'#3b4252'}}); d('github-dark', { base:'vs-dark', inherit:true, rules:[ {token:'keyword',foreground:'ff7b72'},{token:'string',foreground:'a5d6ff'}, {token:'comment',foreground:'8b949e',fontStyle:'italic'},{token:'number',foreground:'79c0ff'}, {token:'type',foreground:'ffa657'},{token:'function',foreground:'d2a8ff'}, ], colors:{'editor.background':'#0d1117','editor.foreground':'#c9d1d9','editor.selectionBackground':'#163b55','editorCursor.foreground':'#e6edf3'}}); d('tomorrow-night', { base:'vs-dark', inherit:true, rules:[ {token:'keyword',foreground:'b294bb'},{token:'string',foreground:'b5bd68'}, {token:'comment',foreground:'969896',fontStyle:'italic'},{token:'number',foreground:'de935f'}, {token:'type',foreground:'81a2be'},{token:'function',foreground:'81a2be'}, ], colors:{'editor.background':'#1d1f21','editor.foreground':'#c5c8c6','editor.selectionBackground':'#373b41','editorCursor.foreground':'#aeafad'}}); d('solarized-dark', { base:'vs-dark', inherit:true, rules:[ {token:'keyword',foreground:'859900'},{token:'string',foreground:'2aa198'}, {token:'comment',foreground:'657b83',fontStyle:'italic'},{token:'number',foreground:'d33682'}, {token:'type',foreground:'b58900'},{token:'function',foreground:'268bd2'}, ], colors:{'editor.background':'#002b36','editor.foreground':'#839496','editor.selectionBackground':'#073642','editorCursor.foreground':'#839496'}}); d('github-light', { base:'vs', inherit:true, rules:[ {token:'keyword',foreground:'d73a49'},{token:'string',foreground:'032f62'}, {token:'comment',foreground:'6a737d',fontStyle:'italic'},{token:'number',foreground:'005cc5'}, {token:'type',foreground:'e36209'},{token:'function',foreground:'6f42c1'}, ], colors:{'editor.background':'#f6f8fa','editor.foreground':'#24292e','editor.selectionBackground':'#c8c8fa','editorCursor.foreground':'#24292e'}}); d('solarized-light', { base:'vs', inherit:true, rules:[ {token:'keyword',foreground:'859900'},{token:'string',foreground:'2aa198'}, {token:'comment',foreground:'93a1a1',fontStyle:'italic'},{token:'number',foreground:'d33682'}, {token:'type',foreground:'b58900'},{token:'function',foreground:'268bd2'}, ], colors:{'editor.background':'#fdf6e3','editor.foreground':'#657b83','editor.selectionBackground':'#eee8d5','editorCursor.foreground':'#657b83'}}); } function renderThemeGrid() { const grid = document.getElementById('csb-theme-grid'); if (!grid) return; const cur = localStorage.getItem('codeEditorTheme') || (document.documentElement.getAttribute('data-theme') !== 'light' ? 'vs-dark' : 'vs'); grid.className = 'code-theme-groups'; grid.innerHTML = CODE_THEME_GROUPS.map(g => `
${g.icon} ${g.label} ${g.themes.map(t => `
` ).join('')}
` ).join(''); } function setCodePageTheme(name) { document.documentElement.setAttribute('data-theme', name); localStorage.setItem('code-page-theme', name); document.querySelectorAll('.code-theme-btn').forEach(b => b.classList.toggle('active', b.id === 'ctbtn-' + name)); // Monaco ์—๋””ํ„ฐ ํ…Œ๋งˆ๋„ ์—ฐ๋™ if (window.monacoEditor && !localStorage.getItem('codeEditorTheme')) { const autoTheme = name === 'light' ? 'vs' : name === 'warm' ? 'solarized-light' : 'vs-dark'; if (window.monaco) monaco.editor.setTheme(autoTheme); } } (function() { const t = localStorage.getItem('code-page-theme') || localStorage.getItem('cherryclaw_theme') || 'dark'; setCodePageTheme(t); })(); function csbTheme(theme) { if (window.monaco) { if (!['vs','vs-dark','hc-black','hc-light'].includes(theme)) defineCodeThemes(); monaco.editor.setTheme(theme); } localStorage.setItem('codeEditorTheme', theme); const lbl = CODE_THEMES.find(t => t.v === theme); const el = document.getElementById('csb-theme-lbl'); if (el) el.textContent = lbl ? lbl.label : theme; document.querySelectorAll('#csb-theme-grid .code-skin-sw').forEach(sw => sw.classList.toggle('active', sw.dataset.v === theme)); } function csbTabSize(val) { if (monacoEditor) monacoEditor.updateOptions({ tabSize: parseInt(val) }); localStorage.setItem('codeEditorTabSize', val); } function csbMinimap(checked) { if (monacoEditor) monacoEditor.updateOptions({ minimap: { enabled: checked } }); localStorage.setItem('codeEditorMinimap', checked ? '1' : '0'); } function csbFontFamily(val) { localStorage.setItem('codeEditorFontFamily', val); if (monacoEditor) monacoEditor.updateOptions({ fontFamily: val === 'monospace' ? 'monospace' : `'${val}', monospace` }); } // Restore editor settings from localStorage function csbRestoreSettings() { const fs = localStorage.getItem('codeEditorFontSize'); const ww = localStorage.getItem('codeEditorWordWrap'); const th = localStorage.getItem('codeEditorTheme'); const ts = localStorage.getItem('codeEditorTabSize'); const mm = localStorage.getItem('codeEditorMinimap'); const ff = localStorage.getItem('codeEditorFontFamily') || 'D2Coding'; const fw = localStorage.getItem('codeEditorFontWeight') || '400'; const hb = localStorage.getItem('codeEditorHideBlank'); const bff = localStorage.getItem('codeEditorBlockFF'); if (fs) { document.getElementById('csb-fontsize').value = fs; document.getElementById('csb-fontsize-label').textContent = fs; } if (ww !== null) document.getElementById('csb-wordwrap').checked = ww === '1'; if (ts) document.getElementById('csb-tabsize').value = ts; if (mm !== null) document.getElementById('csb-minimap').checked = mm === '1'; const ffEl = document.getElementById('csb-fontfamily'); if (ffEl) ffEl.value = ff; const fwEl = document.getElementById('csb-fontweight'); if (fwEl) fwEl.value = fw; _hideBlankLines = hb === '1'; _blockFlipFlop = bff === '1'; const kp = localStorage.getItem('codeKarpathyMode'); _karpathyMode = kp === '1'; const hbBtn = document.getElementById('code-opt-hideblank'); if (hbBtn) hbBtn.classList.toggle('active', _hideBlankLines); const bffBtn = document.getElementById('code-opt-blockff'); if (bffBtn) bffBtn.classList.toggle('active', _blockFlipFlop); const kpBtn = document.getElementById('code-opt-karpathy'); if (kpBtn) kpBtn.classList.toggle('active', _karpathyMode); if (monacoEditor) monacoEditor.updateOptions({ fontSize: fs ? parseInt(fs) : 14, wordWrap: ww === '0' ? 'off' : 'on', tabSize: ts ? parseInt(ts) : 2, minimap: { enabled: mm !== '0' }, fontFamily: ff === 'monospace' ? 'monospace' : `'${ff}', monospace`, fontWeight: fw, }); if (_hideBlankLines) _applyHideBlankLines(); if (_blockFlipFlop) _applyBlockFlipFlop(); const activeTheme = th || (document.documentElement.getAttribute('data-theme') !== 'light' ? 'vs-dark' : 'vs'); if (window.monaco) { defineCodeThemes(); monaco.editor.setTheme(activeTheme); } renderThemeGrid(); const lbl = CODE_THEMES.find(t => t.v === activeTheme); const lblEl = document.getElementById('csb-theme-lbl'); if (lblEl) lblEl.textContent = lbl ? lbl.label : activeTheme; } // โ”€โ”€ Code folder browser โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ let codeFolderOpen = false; function toggleCodeFolderBrowser(e) { const dd = document.getElementById('code-folder-dropdown'); const btn = document.getElementById('code-folder-btn'); if (!dd || !btn) return; codeFolderOpen = !codeFolderOpen; if (codeFolderOpen) { const r = btn.getBoundingClientRect(); dd.style.top = (r.bottom + 4) + 'px'; dd.style.left = r.left + 'px'; dd.style.display = 'block'; codeRefreshFolder(); } else { dd.style.display = 'none'; } if (e) e.stopPropagation(); } async function codeRefreshFolder() { const list = document.getElementById('code-folder-list'); if (!list) return; list.innerHTML = '
๋กœ๋”ฉ ์ค‘...
'; try { const files = localDirHandle ? await localListFiles(localDirHandle, '') : []; if (!files.length) { list.innerHTML = '
ํŒŒ์ผ ์—†์Œ
'; return; } list.innerHTML = files.map(f => { const ext = f.split('.').pop().toLowerCase(); const icon = { py:'๐Ÿ', js:'โšก', ts:'๐Ÿ”ท', cpp:'โš™๏ธ', c:'โš™๏ธ', cs:'๐Ÿ’œ', go:'๐Ÿ”ต', rs:'๐Ÿฆ€', java:'โ˜•', html:'๐ŸŒ', css:'๐ŸŽจ', sh:'๐Ÿ’ป', kt:'๐ŸŽฏ', swift:'๐ŸŽ', rb:'๐Ÿ’Ž', php:'๐Ÿ˜', sql:'๐Ÿ—ƒ๏ธ', md:'๐Ÿ“', }[ext] || '๐Ÿ“„'; const safe = f.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); return `
${icon} ${escHtml(f)}
`; }).join(''); } catch { list.innerHTML = '
๋ถˆ๋Ÿฌ์˜ค๊ธฐ ์‹คํŒจ
'; } } async function codeOpenFromFolder(filename) { if (!localDirHandle) return; try { const content = await localReadFile(filename); if (content === null) return; const bare = _clientBareFileName(filename); const existing = codeFiles.find(f => f.name === bare); if (existing) switchCodeFile(existing.id); else codeNewFile(bare, content); } catch {} const dd = document.getElementById('code-folder-dropdown'); if (dd) dd.style.display = 'none'; codeFolderOpen = false; } async function codeDeleteFromFolder(filename, ev) { ev.stopPropagation(); if (!localDirHandle || !confirm(`"${filename}" ํŒŒ์ผ์„ ์‚ญ์ œํ• ๊นŒ์š”?`)) return; try { const ok = await localDeleteFile(filename); if (ok) { const bare = _clientBareFileName(filename); const existing = codeFiles.find(f => f.name === bare); if (existing) closeCodeFile(existing.id); codeRefreshFolder(); } } catch(e) { alert('์‚ญ์ œ ์‹คํŒจ: ' + e.message); } } // Close folder dropdown when clicking outside document.addEventListener('click', e => { if (!codeFolderOpen) return; const dd = document.getElementById('code-folder-dropdown'); const btn = document.getElementById('code-folder-btn'); if (dd && !dd.contains(e.target) && btn && !btn.contains(e.target)) { dd.style.display = 'none'; codeFolderOpen = false; } }); // โ”€โ”€ Vertical resize handle between editor and AI panel โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function setupCodeEditorDrop() { const container = document.getElementById('code-editor-container'); if (!container) return; container.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; container.style.outline = '2px solid var(--brand)'; }); container.addEventListener('dragleave', () => { container.style.outline = ''; }); container.addEventListener('drop', e => { e.preventDefault(); container.style.outline = ''; const files = Array.from(e.dataTransfer.files); if (!files.length) return; files.forEach(file => { const reader = new FileReader(); reader.onload = ev => { const content = ev.target.result; const existing = codeFiles.find(f => f.name === file.name); if (existing) { existing.model.setValue(content); switchCodeFile(existing.id); } else codeNewFile(file.name, content); }; reader.readAsText(file); }); }); } function setupCodeVResize() { const handle = document.getElementById('code-vresize'); const codeView = document.getElementById('code-view'); const aiPanel = document.getElementById('code-ai-panel'); if (!handle || !aiPanel || !codeView) return; let startX = 0, startW = 0; handle.addEventListener('mousedown', function(e) { startX = e.clientX; startW = aiPanel.offsetWidth; document.addEventListener('mousemove', onVMove); document.addEventListener('mouseup', onVUp); e.preventDefault(); }); function onVMove(e) { const dx = startX - e.clientX; const newW = Math.max(220, Math.min(window.innerWidth * 0.6, startW + dx)); codeView.style.setProperty('--ai-panel-w', newW + 'px'); if (monacoEditor) monacoEditor.layout(); } function onVUp() { document.removeEventListener('mousemove', onVMove); document.removeEventListener('mouseup', onVUp); } } // โ”€โ”€ Standalone bootstrap โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // In the main app these are triggered by setMode('code') / setSidebarTab('code'). // BroadcastChannel: real-time code sync between code editor and emulator const _esp32CodeChannel = new BroadcastChannel('esp32_code_sync'); let _esp32SyncBusy = false; _esp32CodeChannel.onmessage = (e) => { if (e.data?.type !== 'code') return; const file = codeFiles.find(f => f.id === activeCodeFileId); if (!file || !file.name.endsWith('.ino') || !monacoEditor) return; _esp32SyncBusy = true; const pos = monacoEditor.getPosition(); if (file.model) file.model.setValue(e.data.code); if (pos) monacoEditor.setPosition(pos); _esp32SyncBusy = false; }; // โ”€โ”€ Upload Panel (โšก) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ let _cupOpen = false; let _cupPort = null; let _cupBusy = false; let _cupCode = '', _cupFqbn = '', _cupBoardName = '', _cupFname = ''; let _cupCompiledData = null; let _cupCompiledForCode = ''; let _cupCompiledForFqbn = ''; let _cupBoardOptions = []; let _cupSelOptions = {}; let _cupMonActive = false; let _cupMonReader = null; let _cupMonWriter = null; let _cupMonBuf = ''; const _cupDecoder = new TextDecoder(); function toggleUploadPanel() { const panel = document.getElementById('code-upload-panel'); if (!panel) return; _cupOpen = !_cupOpen; panel.style.display = _cupOpen ? 'flex' : 'none'; const btn = document.getElementById('code-upload-btn'); if (btn) btn.classList.toggle('active', _cupOpen); if (monacoEditor) setTimeout(() => monacoEditor.layout(), 60); } function cupOpenWith(file) { _cupCode = file.model ? file.model.getValue() : (file.content || ''); _cupFqbn = _csbSelBoard?.fqbn || 'arduino:avr:uno'; _cupBoardName = _csbSelBoard?.name || _cupFqbn; _cupFname = file.name; _cupSelOptions = {}; const panel = document.getElementById('code-upload-panel'); if (!_cupOpen) { _cupOpen = true; panel.style.display = 'flex'; const btn = document.getElementById('code-upload-btn'); if (btn) btn.classList.add('active'); if (monacoEditor) setTimeout(() => monacoEditor.layout(), 60); } cupTab('log'); document.getElementById('cup-tab-log').innerHTML = ''; const method = _cupGetUploadMethod(_cupFqbn); _cupUpdateInfoRows(); document.getElementById('cup-stm32-hint').style.display = method === 'stm32-uart' ? '' : 'none'; document.getElementById('cup-uf2-hint').style.display = method === 'download-uf2' ? '' : 'none'; document.getElementById('cup-port-name').textContent = _cupPort ? 'โœ“ ํฌํŠธ ์„ ํƒ๋จ' : 'ํฌํŠธ ์—†์Œ'; document.getElementById('cup-btn-upload').disabled = !_cupPort; document.getElementById('cup-btn-download').disabled = !_cupPort; cupLog(`๋ณด๋“œ: ${_cupBoardName}`, 'info'); cupLog(`๋ฐฉ์‹: ${_cupMethodLabel(method)}`, 'info'); if (method === 'stm32-uart') cupLog('STM32: BOOT0=HIGH ํ›„ RESET โ†’ โšก ์—…๋กœ๋“œ', 'warn'); else if (method === 'avr-stk500') cupLog('AVR: ํฌํŠธ ์„ ํƒ ํ›„ โšก ์—…๋กœ๋“œ (์ž๋™ DTR ๋ฆฌ์…‹)', 'info'); else if (method === 'rp2040-emulator') cupLog('RP2040: โšก ์—…๋กœ๋“œ ๋ฒ„ํŠผ์œผ๋กœ ์—๋ฎฌ๋ ˆ์ดํ„ฐ ์‹คํ–‰ / โฌ‡ ๋‹ค์šด๋กœ๋“œ๋กœ UF2 ํŒŒ์ผ ์ €์žฅ', 'info'); else if (method === 'download-uf2' || method === 'download-bin') cupLog('์ด ๋ณด๋“œ๋Š” โฌ‡ ๋‹ค์šด๋กœ๋“œ ํ›„ ์ˆ˜๋™ ๋ณต์‚ฌ ๋ฐฉ์‹์ž…๋‹ˆ๋‹ค.', 'warn'); else if (method === 'esp32-flash') cupLog('ESP32/ESP8266: ๐Ÿ”Œ ํฌํŠธ ์„ ํƒ ํ›„ โšก ์—…๋กœ๋“œ\n ์ปดํŒŒ์ผ: ์„œ๋ฒ„ | ํ”Œ๋ž˜์‹ฑ: ๋ธŒ๋ผ์šฐ์ € esptool-js', 'info'); if (!navigator.serial) cupLog('โš  WebSerial ๋ฏธ์ง€์› ๋ธŒ๋ผ์šฐ์ €์ž…๋‹ˆ๋‹ค. Chrome/Edge๋ฅผ ์‚ฌ์šฉํ•˜์„ธ์š”.', 'warn'); _cupLoadBoardOptions(); } function cupTab(name) { const tabs = { log: 'block', opts: 'block', mon: 'flex' }; Object.keys(tabs).forEach(t => { const p = document.getElementById('cup-tab-' + t); const b = document.getElementById('cup-tbtn-' + t); if (p) p.style.display = t === name ? tabs[t] : 'none'; if (b) { b.style.color = t === name ? '#e89040' : '#8a7050'; b.style.borderBottom = t === name ? '2px solid #e89040' : '2px solid transparent'; } }); } function cupLog(msg, cls = 'info') { const out = document.getElementById('cup-tab-log'); if (!out) return; const colors = { info: '#9aacb8', ok: '#5dc48a', err: '#e07070', prog: '#80a8e0', warn: '#e8c860' }; const span = document.createElement('span'); span.style.color = colors[cls] || '#6a7880'; span.style.display = 'block'; span.textContent = msg; out.appendChild(span); out.scrollTop = out.scrollHeight; } function _cupSetStatus(cls, text) { const el = document.getElementById('cup-status'); if (!el) return; const styles = { idle:{ bg:'#2a1806', color:'#6a5030' }, ready:{ bg:'#1a3020', color:'#4d9e74' }, compiling:{ bg:'#2a1e08', color:'#ccaa50' }, uploading:{ bg:'#2a1208', color:'#c87d30' }, done:{ bg:'#1a3020', color:'#4d9e74' }, error:{ bg:'#2a1010', color:'#c46060' } }; const s = styles[cls] || styles.idle; el.style.background = s.bg; el.style.color = s.color; el.textContent = text; } function _cupSetProgress(pct) { const bar = document.getElementById('cup-progress'); if (!bar) return; if (pct == null) { bar.style.display = 'none'; return; } bar.style.display = 'block'; bar.value = pct; } function _cupGetUploadMethod(fqbn) { const arch = fqbn.split(':')[1] || '', vendor = fqbn.split(':')[0] || ''; if (arch === 'avr') return 'avr-stk500'; if (vendor === 'STMicroelectronics') return 'stm32-uart'; if (arch === 'samd' || arch === 'sam') return 'download-bin'; if (arch === 'mbed_rp2040' || arch === 'rp2040') return 'rp2040-emulator'; if (arch === 'esp32' || arch === 'esp8266') return 'esp32-flash'; return 'download-bin'; } function _cupMethodLabel(m) { return { 'avr-stk500':'STK500 (WebSerial)', 'stm32-uart':'STM32 UART', 'download-bin':'.bin ๋‹ค์šด๋กœ๋“œ', 'download-uf2':'UF2 ๋“œ๋ž˜๊ทธ&๋“œ๋กญ', 'rp2040-emulator':'RP2040 ์—๋ฎฌ๋ ˆ์ดํ„ฐ', 'esp32-flash':'esptool-js (๋ธŒ๋ผ์šฐ์ €)' }[m] || m; } function _cupGetEffectiveFqbn() { const keys = Object.keys(_cupSelOptions); if (!keys.length) return _cupFqbn; return _cupFqbn + ':' + keys.map(k => k + '=' + _cupSelOptions[k]).join(','); } function _cupUpdateInfoRows() { const el = document.getElementById('cup-info-rows'); if (!el) return; const method = _cupGetUploadMethod(_cupFqbn); el.innerHTML = [['๋ฐฉ์‹', _cupMethodLabel(method)], ['๋ณด๋“œ', _cupBoardName], ['FQBN', _cupGetEffectiveFqbn()], ['ํŒŒ์ผ', _cupFname]] .map(([k, v]) => `
${k}${v}
`).join(''); } async function _cupLoadBoardOptions() { const baseFqbn = _cupFqbn.split(':').slice(0, 3).join(':'); try { const r = await fetch(`/api/arduino/board/options?fqbn=${encodeURIComponent(baseFqbn)}`, { credentials: 'include', headers: authHeaders() }); const d = await r.json(); _cupBoardOptions = d.options || []; _cupSelOptions = {}; _cupRenderBoardOptions(); } catch {} } function _cupRenderBoardOptions() { const list = document.getElementById('cup-board-opts-list'); if (!list) return; if (!_cupBoardOptions.length) { list.innerHTML = ''; return; } list.innerHTML = '
๋ณด๋“œ ์˜ต์…˜
' + _cupBoardOptions.map(o => { const sel = _cupSelOptions[o.id] ?? (o.values.find(v => v.selected)?.value ?? o.values[0]?.value ?? ''); const opts = o.values.map(v => ``).join(''); const optId = JSON.stringify(o.id), defVal = o.values.find(v => v.selected)?.value ?? o.values[0]?.value ?? ''; return `
${o.label}
`; }).join(''); } async function cupSelectPort() { if (!navigator.serial) { cupLog('โŒ WebSerial ๋ฏธ์ง€์›. Chrome/Edge๋ฅผ ์‚ฌ์šฉํ•˜์„ธ์š”.', 'err'); return; } try { _cupPort = await navigator.serial.requestPort(); const info = _cupPort.getInfo(); const vid = info.usbVendorId ? `VID:0x${info.usbVendorId.toString(16).padStart(4,'0')}` : ''; const pid = info.usbProductId ? `PID:0x${info.usbProductId.toString(16).padStart(4,'0')}` : ''; const label = [vid, pid].filter(Boolean).join(' ') || '์„ ํƒ๋จ'; document.getElementById('cup-port-name').textContent = 'โœ“ ' + label; document.getElementById('cup-btn-upload').disabled = false; document.getElementById('cup-btn-download').disabled = false; cupLog(`ํฌํŠธ ์„ ํƒ๋จ: ${label}`, 'ok'); _cupSetStatus('ready', '์ค€๋น„๋จ'); } catch(e) { if (e.name !== 'NotFoundError') cupLog('ํฌํŠธ ์„ ํƒ ์˜ค๋ฅ˜: ' + e.message, 'err'); } } async function _cupCompile() { const arch = _cupFqbn.split(':')[1] || ''; if (/ESP8266WiFi|ESP8266WebServer|ESP8266HTTPClient/i.test(_cupCode) && arch !== 'esp8266') { _cupFqbn = 'esp8266:esp8266:nodemcuv2'; _cupSelOptions = {}; cupLog(`โš  ESP8266 ๊ฐ์ง€ โ†’ FQBN ์ž๋™ ๋ณ€๊ฒฝ: ${_cupFqbn}`, 'warn'); _cupUpdateInfoRows(); _cupLoadBoardOptions(); } else if (/^#include\s+/m.test(_cupCode) && arch !== 'esp32' && arch !== 'esp8266') { _cupFqbn = 'esp32:esp32:esp32'; _cupSelOptions = {}; cupLog(`โš  ESP32 WiFi ๊ฐ์ง€ โ†’ FQBN ์ž๋™ ๋ณ€๊ฒฝ: ${_cupFqbn}`, 'warn'); _cupUpdateInfoRows(); _cupLoadBoardOptions(); } cupLog('์„œ๋ฒ„์—์„œ ์ปดํŒŒ์ผ ์ค‘...', 'prog'); _cupSetStatus('compiling', '์ปดํŒŒ์ผ ์ค‘...'); const r = await fetch('/api/arduino/compile', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', ...authHeaders() }, body: JSON.stringify({ code: _cupCode, fqbn: _cupGetEffectiveFqbn() }), }); const data = await r.json(); if (data.error) { cupLog('์ปดํŒŒ์ผ ์˜ค๋ฅ˜:\n' + data.error, 'err'); _cupSetStatus('error', '์ปดํŒŒ์ผ ์‹คํŒจ'); return null; } const kb = data.size ? ` (${(data.size/1024).toFixed(1)} KB)` : ''; cupLog(`โœ… ์ปดํŒŒ์ผ ์™„๋ฃŒ${kb}`, 'ok'); _cupCompiledForCode = _cupCode; _cupCompiledForFqbn = _cupGetEffectiveFqbn(); return data; } function _cupCacheHit() { return _cupCompiledData && _cupCompiledForCode === _cupCode && _cupCompiledForFqbn === _cupGetEffectiveFqbn(); } async function cupDoDownload() { cupTab('log'); let data; if (_cupCacheHit()) { data = _cupCompiledData; cupLog('์บ์‹œ๋œ ์ปดํŒŒ์ผ ๊ฒฐ๊ณผ ์‚ฌ์šฉ', 'info'); } else { data = await _cupCompile(); if (!data) return; } _cupCompiledData = data; const isHex = !!data.hex; const content = isHex ? data.hex : _cupBase64ToBytes(data.bin || data.merged); const ext = (data.boardType === 'uf2' || data.boardType === 'rp2040') ? '.uf2' : (isHex ? '.hex' : '.bin'); const blob = new Blob([content], { type: isHex ? 'text/plain' : 'application/octet-stream' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = _cupFname.replace(/\.[^.]+$/, '') + ext; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 5000); cupLog(`๋‹ค์šด๋กœ๋“œ: ${a.download}`, 'ok'); _cupSetStatus('done', '๋‹ค์šด๋กœ๋“œ๋จ'); } async function cupDoUpload() { if (_cupBusy) return; if (!_cupPort) { cupLog('ํฌํŠธ๋ฅผ ๋จผ์ € ์„ ํƒํ•˜์„ธ์š” (๐Ÿ”Œ ํฌํŠธ ๋ฒ„ํŠผ)', 'err'); return; } cupTab('log'); const method = _cupGetUploadMethod(_cupGetEffectiveFqbn()); if (method === 'esp32-flash') { await _cupUploadESP32(); return; } if (method === 'rp2040-emulator') { let data; if (_cupCacheHit()) { data = _cupCompiledData; cupLog('์บ์‹œ๋œ ์ปดํŒŒ์ผ ๊ฒฐ๊ณผ ์‚ฌ์šฉ', 'info'); } else { data = await _cupCompile(); if (!data) return; _cupCompiledData = data; } if (!data) return; sessionStorage.setItem('arduino_uf2', data.bin); sessionStorage.setItem('arduino_sketch', _cupCode || ''); _emuPanelLoad('/arduino-emulator.html', `RP2040 โ€” ${_cupFname}`); return; } if (method === 'download-uf2' || method === 'download-bin') { await cupDoDownload(); return; } _cupBusy = true; document.getElementById('cup-btn-upload').disabled = true; document.getElementById('cup-btn-download').disabled = true; document.getElementById('cup-tab-log').innerHTML = ''; _cupSetProgress(0); try { let data; if (_cupCacheHit()) { data = _cupCompiledData; cupLog('์บ์‹œ๋œ ์ปดํŒŒ์ผ ๊ฒฐ๊ณผ ์‚ฌ์šฉ', 'info'); } else { data = await _cupCompile(); if (!data) return; _cupCompiledData = data; } cupLog(`\n--- ์—…๋กœ๋“œ ์‹œ์ž‘ [${_cupBoardName}] ---`, 'prog'); _cupSetStatus('uploading', '์—…๋กœ๋“œ ์ค‘...'); _cupSetProgress(5); if (method === 'avr-stk500') await _cupUploadAVR(data.hex); else if (method === 'stm32-uart') await _cupUploadSTM32(_cupBase64ToBytes(data.bin)); } catch(e) { cupLog('โŒ ์˜ค๋ฅ˜: ' + e.message, 'err'); _cupSetStatus('error', '์—…๋กœ๋“œ ์‹คํŒจ'); _cupSetProgress(null); } finally { _cupBusy = false; document.getElementById('cup-btn-upload').disabled = false; document.getElementById('cup-btn-download').disabled = false; } } async function _cupUploadESP32() { _cupBusy = true; document.getElementById('cup-btn-upload').disabled = true; document.getElementById('cup-btn-download').disabled = true; document.getElementById('cup-tab-log').innerHTML = ''; _cupSetProgress(0); let transport = null, success = false; try { let data; if (_cupCacheHit()) { data = _cupCompiledData; cupLog('์บ์‹œ๋œ ์ปดํŒŒ์ผ ๊ฒฐ๊ณผ ์‚ฌ์šฉ', 'info'); } else { data = await _cupCompile(); if (!data) return; _cupCompiledData = data; } const b64 = data.merged || data.bin; if (!b64) throw new Error('์ปดํŒŒ์ผ ๊ฒฐ๊ณผ์— ๋ฐ”์ด๋„ˆ๋ฆฌ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค'); cupLog('esptool-js ๋กœ๋”ฉ ์ค‘...', 'prog'); const { ESPLoader, Transport, HardReset } = await import('/esptool-bundle.js'); cupLog('๋ณด๋“œ ์—ฐ๊ฒฐ ์ค‘ (์ž๋™ ๋ฆฌ์…‹)...', 'prog'); _cupSetStatus('uploading', '์—ฐ๊ฒฐ ์ค‘...'); transport = new Transport(_cupPort, true); const terminal = { clean:()=>{}, writeLine:m=>{ if(m.trim()) cupLog(m,'info'); }, write:m=>{ if(m.trim()) cupLog(m,'info'); } }; const esploader = new ESPLoader({ transport, baudrate:115200, romBaudrate:115200, enableTracing:false, terminal }); let chip; try { chip = await esploader.main(); } catch(e) { cupLog('โš  ์ž๋™ ๋ฆฌ์…‹ ์‹คํŒจ โ€” BOOT ๋ˆ„๋ฅธ์ฑ„ RST โ†’ BOOT ๋†“๊ธฐ ํ›„ ์žฌ์‹œ๋„.', 'warn'); throw e; } cupLog(`์นฉ ๊ฐ์ง€: ${chip}`, 'ok'); _cupSetProgress(10); _cupSetStatus('uploading', '์—…๋กœ๋“œ ์ค‘...'); const binData = _cupBase64ToBytes(b64); let _t0 = Date.now(), _w0 = 0; await esploader.writeFlash({ fileArray: [{ data: binData, address: 0x0 }], flashSize:'keep', flashMode:'keep', flashFreq:'keep', eraseAll:false, compress:true, reportProgress: (_fi, written, total) => { const pct = Math.round(written/total*100); _cupSetProgress(10 + pct*0.88); const now = Date.now(); if (_w0===0 && written>0) { _t0=now; _w0=written; } const kbps = (now-_t0)>200 ? ((written-_w0)/1024/((now-_t0)/1000)).toFixed(1) : '...'; document.getElementById('cup-port-name').textContent = `ํ”Œ๋ž˜์‹ฑ ${(written/1024).toFixed(1)}/${(total/1024).toFixed(1)} KB (${pct}%) ยท ${kbps} KB/s`; }, }); try { await new HardReset(transport).reset(); } catch {} cupLog('โœ… ์—…๋กœ๋“œ ์™„๋ฃŒ! ๋ณด๋“œ๊ฐ€ ์žฌ์‹œ์ž‘๋ฉ๋‹ˆ๋‹ค.', 'ok'); _cupSetStatus('done', '์™„๋ฃŒ'); _cupSetProgress(100); success = true; } catch(e) { cupLog('โŒ ์˜ค๋ฅ˜: ' + e.message, 'err'); _cupSetStatus('error', '์—…๋กœ๋“œ ์‹คํŒจ'); _cupSetProgress(null); } finally { try { if (transport) await transport.disconnect(); } catch {} _cupBusy = false; document.getElementById('cup-btn-upload').disabled = false; document.getElementById('cup-btn-download').disabled = false; document.getElementById('cup-port-name').textContent = _cupPort ? 'โœ“ ํฌํŠธ ์„ ํƒ๋จ' : 'ํฌํŠธ ์—†์Œ'; setTimeout(() => _cupSetProgress(null), 3000); } if (success) { cupTab('mon'); _cupMonAppendSys('[์—…๋กœ๋“œ ์™„๋ฃŒ โ€” ์‹œ๋ฆฌ์–ผ ๋ชจ๋‹ˆํ„ฐ ์ž๋™ ์—ฐ๊ฒฐ ์ค‘...]'); await new Promise(r => setTimeout(r, 1500)); await cupMonOpen(); } } function _cupBase64ToBytes(b64) { const raw = atob(b64), out = new Uint8Array(raw.length); for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); return out; } function _cupParseIntelHex(hexText) { const pages = new Map(); let extAddr = 0; for (const line of hexText.split('\n')) { const t = line.trim(); if (!t.startsWith(':') || t.length < 11) continue; const bytes = []; for (let i=1; i>7, off=a&0x7f; if (!pages.has(pg)) pages.set(pg,new Uint8Array(128).fill(0xff)); pages.get(pg)[off]=bytes[4+i]; } } return pages; } async function _cupWrite(writer, data) { await writer.write(data instanceof Uint8Array ? data : new Uint8Array(Array.isArray(data) ? data : [data])); } async function _cupRead(reader, n, ms=5000) { const buf=new Uint8Array(n); let pos=0; const deadline=Date.now()+ms; while (posdeadline) throw new Error(`์‹œ๋ฆฌ์–ผ ํƒ€์ž„์•„์›ƒ (${n}B ์ค‘ ${pos}B)`); const {value,done} = await reader.read(); if (done) throw new Error('ํฌํŠธ๊ฐ€ ๋‹ซํ˜”์Šต๋‹ˆ๋‹ค'); const take=Math.min(value.length,n-pos); buf.set(value.slice(0,take),pos); pos+=take; } return buf; } async function _cupUploadAVR(hexText) { const INSYNC=0x14,OK=0x10,EOP=0x20,CMD_SYNC=0x30,CMD_SET_DEV=0x42,CMD_ENTER=0x50,CMD_LEAVE=0x51,CMD_ADDR=0x55,CMD_PROG=0x64; await _cupPort.open({ baudRate:115200, dataBits:8, stopBits:1, parity:'none' }); const reader=_cupPort.readable.getReader(), writer=_cupPort.writable.getWriter(); const cmdOk = async (bytes,extra=0,ms=3000) => { await _cupWrite(writer, new Uint8Array([...bytes,EOP])); const r=await _cupRead(reader,2+extra,ms); if (r[0]!==INSYNC) throw new Error(`STK500: INSYNC ์—†์Œ (0x${r[0].toString(16)})`); if (r[1+extra]!==OK) throw new Error(`STK500: OK ์—†์Œ (0x${r[1+extra].toString(16)})`); return r.slice(1,1+extra); }; try { if (_cupPort.setSignals) { await _cupPort.setSignals({ dataTerminalReady:false, requestToSend:false }); await new Promise(r=>setTimeout(r,100)); await _cupPort.setSignals({ dataTerminalReady:true, requestToSend:true }); await new Promise(r=>setTimeout(r,50)); await _cupPort.setSignals({ dataTerminalReady:false }); } await new Promise(r=>setTimeout(r,250)); cupLog('๋™๊ธฐํ™” ์ค‘...', 'prog'); let synced=false; for (let i=0; i<12&&!synced; i++) { try { await _cupWrite(writer,[CMD_SYNC,EOP]); const r=await _cupRead(reader,2,300); if(r[0]===INSYNC&&r[1]===OK) synced=true; } catch {} } if (!synced) throw new Error('๋ณด๋“œ์— ์—ฐ๊ฒฐํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ์ผ€์ด๋ธ”/DTR ๋ฆฌ์…‹์„ ํ™•์ธํ•˜์„ธ์š”.'); cupLog('โœ“ ๋™๊ธฐํ™”๋จ', 'ok'); await cmdOk([CMD_SET_DEV,0x86,0x00,0x07,0x7f,0x83,0xff,0x00,0x01,0x01,0x01,0x01,0x03,0xff,0x00,0x80,0x04,0x00,0x00,0x00,0x80,0x00]); await cmdOk([CMD_ENTER]); cupLog('โœ“ ํ”„๋กœ๊ทธ๋ž˜๋ฐ ๋ชจ๋“œ ์ง„์ž…', 'ok'); const pages=_cupParseIntelHex(hexText), sorted=[...pages.entries()].sort((a,b)=>a[0]-b[0]); let done=0; for (const [pg, pgData] of sorted) { const waddr=pg<<6; await cmdOk([CMD_ADDR,waddr&0xff,(waddr>>8)&0xff]); await cmdOk([CMD_PROG,0x00,0x80,0x46,...pgData]); done++; _cupSetProgress(5+Math.round(done/sorted.length*90)); document.getElementById('cup-port-name').textContent = `์—…๋กœ๋“œ ์ค‘... ${done}/${sorted.length} ํŽ˜์ด์ง€`; } await cmdOk([CMD_LEAVE]); _cupSetProgress(100); cupLog(`โœ… ์—…๋กœ๋“œ ์™„๋ฃŒ! (${sorted.length} ํŽ˜์ด์ง€)`, 'ok'); _cupSetStatus('done', '์™„๋ฃŒ โœ“'); document.getElementById('cup-port-name').textContent = 'โœ“ ํฌํŠธ ์„ ํƒ๋จ'; await new Promise(r=>setTimeout(r,500)); _cupSetProgress(null); } finally { try{reader.releaseLock();}catch{} try{writer.releaseLock();}catch{} try{await _cupPort.close();}catch{} } } async function _cupUploadSTM32(binData) { const ACK=0x79; await _cupPort.open({ baudRate:115200, dataBits:8, stopBits:1, parity:'even' }); const reader=_cupPort.readable.getReader(), writer=_cupPort.writable.getWriter(); const w = async d=>_cupWrite(writer,d), readByte=async(ms=6000)=>(await _cupRead(reader,1,ms))[0]; const waitAck = async(ms=12000)=>{ const b=await readByte(ms); if(b===ACK)return; if(b===0x1F)throw new Error('STM32 NACK'); throw new Error(`์‘๋‹ต: 0x${b.toString(16)}`); }; const xor4=a=>a[0]^a[1]^a[2]^a[3], addrPkt=addr=>{ const b=[(addr>>24)&0xff,(addr>>16)&0xff,(addr>>8)&0xff,addr&0xff]; return new Uint8Array([...b,xor4(b)]); }; try { cupLog('STM32 ๋™๊ธฐํ™” ์ค‘...', 'prog'); cupLog('(BOOT0=HIGH ์ƒํƒœ์—์„œ RESET ํ™•์ธ)', 'warn'); let synced=false; for (let i=0; i<5&&!synced; i++) { try{await w([0x7F]);const b=await readByte(1200);if(b===ACK||b===0x1F)synced=true;}catch{} } if (!synced) throw new Error('๋™๊ธฐํ™” ์‹คํŒจ โ€” BOOT0=HIGH ํ›„ RESET ํ•˜์„ธ์š”.'); cupLog('โœ“ ๋™๊ธฐํ™”๋จ', 'ok'); await w([0x00,0xFF]); await waitAck(3000); const n=await readByte(); await _cupRead(reader,n+1,3000); await waitAck(3000); cupLog('ํ”Œ๋ž˜์‹œ ์ง€์šฐ๋Š” ์ค‘... (์ตœ๋Œ€ 30์ดˆ)', 'prog'); _cupSetProgress(10); await w([0x44,0xBB]); await waitAck(5000); await w([0xFF,0xFF,0x00]); await waitAck(30000); cupLog('โœ“ ํ”Œ๋ž˜์‹œ ์ง€์›€', 'ok'); _cupSetProgress(20); const START=0x08000000, CHUNK=256, total=Math.ceil(binData.length/CHUNK); cupLog(`${binData.length} ๋ฐ”์ดํŠธ ์“ฐ๋Š” ์ค‘ (${total} ๋ธ”๋ก)...`, 'prog'); for (let i=0; isetTimeout(r,500)); _cupSetProgress(null); } finally { try{reader.releaseLock();}catch{} try{writer.releaseLock();}catch{} try{await _cupPort.close();}catch{} } } // โ”€โ”€ Serial monitor โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function _cupMonAppendSys(msg) { const out = document.getElementById('cup-mon-output'); if (!out) return; const div = document.createElement('div'); div.style.color = '#907050'; div.style.fontStyle = 'italic'; div.textContent = msg; out.appendChild(div); if (document.getElementById('cup-mon-auto')?.checked) out.scrollTop = out.scrollHeight; } function cupMonAppend(text) { const out = document.getElementById('cup-mon-output'); if (!out) return; const showTs = document.getElementById('cup-mon-ts')?.checked; const autoScroll = document.getElementById('cup-mon-auto')?.checked; _cupMonBuf += text; const parts = _cupMonBuf.split('\n'); _cupMonBuf = parts.pop(); for (const raw of parts) { const div = document.createElement('div'); div.style.color = '#c8e8c0'; if (showTs) { const d=new Date(), ts=`${d.getHours().toString().padStart(2,'0')}:${d.getMinutes().toString().padStart(2,'0')}:${d.getSeconds().toString().padStart(2,'0')}.${d.getMilliseconds().toString().padStart(3,'0')}`; const s=document.createElement('span'); s.style.cssText='color:#907050;font-size:11px;margin-right:6px;opacity:.7;user-select:none'; s.textContent=ts; div.appendChild(s); div.appendChild(document.createTextNode(raw.replace(/\r/g,''))); } else { div.textContent = raw.replace(/\r/g,''); } out.appendChild(div); } if (autoScroll) out.scrollTop = out.scrollHeight; } async function _cupMonReadLoop() { while (_cupMonActive && _cupMonReader) { try { const {value,done}=await _cupMonReader.read(); if(done)break; cupMonAppend(_cupDecoder.decode(value,{stream:true})); } catch(e) { if (_cupMonActive) _cupMonAppendSys(`[์˜ค๋ฅ˜: ${e.message}]`); break; } } if (_cupMonActive) cupMonClose(); } async function cupMonToggle() { if (_cupMonActive) await cupMonClose(); else await cupMonOpen(); } async function cupMonOpen() { if (!_cupPort) { _cupMonAppendSys('[ํฌํŠธ๋ฅผ ๋จผ์ € ์„ ํƒํ•˜์„ธ์š”]'); return; } if (_cupBusy) { _cupMonAppendSys('[์—…๋กœ๋“œ ์ค‘์ž…๋‹ˆ๋‹ค. ์™„๋ฃŒ ํ›„ ์—ฐ๊ฒฐํ•˜์„ธ์š”]'); return; } const baud = parseInt(document.getElementById('cup-mon-baud')?.value) || 115200; try { await _cupPort.open({ baudRate: baud }); _cupMonReader = _cupPort.readable.getReader(); _cupMonWriter = _cupPort.writable.getWriter(); _cupMonActive = true; const btn = document.getElementById('cup-mon-btn'); if (btn) { btn.textContent='โ–  ์—ฐ๊ฒฐ ํ•ด์ œ'; btn.style.color='#e87070'; btn.style.borderColor='color-mix(in srgb,#e87070 35%,#241a0e)'; } const inp = document.getElementById('cup-mon-input'); if (inp) inp.disabled = false; _cupMonAppendSys(`[์—ฐ๊ฒฐ๋จ โ€” ${baud} baud]`); _cupMonReadLoop(); } catch(e) { _cupMonAppendSys(`[์—ฐ๊ฒฐ ์‹คํŒจ: ${e.message}]`); } } async function cupMonClose() { _cupMonActive = false; try{await _cupMonReader?.cancel();}catch{} try{_cupMonReader?.releaseLock();}catch{} try{_cupMonWriter?.releaseLock();}catch{} try{await _cupPort?.close();}catch{} _cupMonReader=null; _cupMonWriter=null; const btn=document.getElementById('cup-mon-btn'); if (btn) { btn.textContent='โ–ถ ์—ฐ๊ฒฐ'; btn.style.color='#4ade80'; btn.style.borderColor='color-mix(in srgb,#4ade80 35%,#241a0e)'; } const inp=document.getElementById('cup-mon-input'); if(inp) inp.disabled=true; _cupMonAppendSys('[์—ฐ๊ฒฐ ํ•ด์ œ๋จ]'); } async function cupMonSend() { if (!_cupMonWriter) return; const input=document.getElementById('cup-mon-input'); if(!input) return; const endings={none:'',nl:'\n',cr:'\r',crnl:'\r\n'}; const suffix=endings[document.getElementById('cup-mon-line-end')?.value]??'\r\n'; const text=input.value+suffix; input.value=''; try { await _cupMonWriter.write(new TextEncoder().encode(text)); const div=document.createElement('div'); div.style.color='#94b0d8'; div.textContent=text.replace(/[\r\n]/g,''); document.getElementById('cup-mon-output')?.appendChild(div); } catch(e) { _cupMonAppendSys(`[์ „์†ก ์˜ค๋ฅ˜: ${e.message}]`); } } function cupMonClear() { const el=document.getElementById('cup-mon-output'); if(el) el.innerHTML=''; _cupMonBuf=''; } // Emulator panel resize drag document.addEventListener('DOMContentLoaded', function() { const drag = document.getElementById('code-emu-drag'); const panel = document.getElementById('code-emu-panel'); if (!drag || !panel) return; let startY=0, startH=0; drag.addEventListener('mouseover', ()=>drag.style.background='rgba(34,197,94,.4)'); drag.addEventListener('mousedown', e => { startY=e.clientY; startH=panel.offsetHeight; e.preventDefault(); const iframe = document.getElementById('code-emu-iframe'); if (iframe) iframe.style.pointerEvents = 'none'; const onMove = e2 => { const dy=startY-e2.clientY; panel.style.height=Math.max(120,Math.min(window.innerHeight*.75,startH+dy))+'px'; }; const onUp = () => { document.removeEventListener('mousemove',onMove); document.removeEventListener('mouseup',onUp); drag.style.background=''; if (iframe) iframe.style.pointerEvents = ''; }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }); }); // Upload panel resize drag document.addEventListener('DOMContentLoaded', function() { const drag = document.getElementById('code-upload-drag'); const panel = document.getElementById('code-upload-panel'); if (!drag || !panel) return; let startY=0, startH=0; drag.addEventListener('mousedown', e => { startY=e.clientY; startH=panel.offsetHeight; e.preventDefault(); const onMove = e2 => { const dy=startY-e2.clientY; panel.style.height=Math.max(80,Math.min(window.innerHeight*.6,startH+dy))+'px'; }; const onUp = () => { document.removeEventListener('mousemove',onMove); document.removeEventListener('mouseup',onUp); drag.style.background=''; }; drag.addEventListener('mouseover', ()=>drag.style.background='rgba(200,125,48,.4)'); document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }); }); // Here we run them once the DOM is ready since this page IS the code view. (function bootCodePage() { async function boot() { try { setCodeSbTab('files'); } catch (e) {} try { initCodeView(); } catch (e) { console.error('[code] initCodeView failed', e); } try { csbLoadModels(); } catch (e) {} try { csbLoadCodeDir(); } catch (e) {} try { updateLocalModeUI(); } catch (e) {} try { const inp = document.getElementById('csb-termpath'); if (inp && localTermPath) inp.value = localTermPath; } catch (e) {} try { await restoreLocalDirHandle(); } catch (e) {} try { updateSessionPanel(); } catch (e) {} try { _fetchModelCtxMax(codeAiModel); } catch (e) {} try { codeSbRefresh(); } catch (e) {} try { companionConnect(); } catch (e) {} // ํžˆ์Šคํ† ๋ฆฌ ์—†์œผ๋ฉด ์„ธ์…˜ ๋ชฉ๋ก ํ‘œ์‹œ try { if (!codeAiHistory.length) _renderSessionsList(); } catch (e) {} } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, { once: true }); else boot(); })();