인버터 옆처럼 USB를 다시 꽂기 어려운 곳에 보드를 설치하면 시리얼 경로가 무용지물이 된다. 코드앱 업로드 패널은 지금까지 USB 전용이었다 — 플래싱을 브라우저에서 Web Serial(esptool-js/STK500/STM32 UART)로 직접 하는 구조라 무선 경로가 없었다. OTA는 브라우저가 할 수 없는 일이다. 페이지에서 보드로 espota 핸드셰이크를 열 방법이 없어서, 이 업로드 경로만 서버를 경유한다. - 백엔드: /api/arduino/upload의 port가 IP면 OTA로 분기 — 컴파일해서 .bin을 만들고 코어에 들어있는 espota.py로 밀어넣는다. espota.py는 arduino-cli가 아니라 ESP 코어에 딸려오고 코어를 올릴 때마다 버전 디렉토리가 바뀌므로 호출 시점에 최신 버전부터 찾는다. 부트로더/파티션 이미지는 걸러내고 애플리케이션 바이너리만 보낸다. ESP32는 3232, ESP8266은 8266 포트. - 암호는 exec가 아니라 execFile로 넘긴다 — 사용자가 넣는 값이라 셸에 닿으면 안 된다. - 프런트: 패널에 📡 OTA 토글과 보드 IP/암호 입력줄 추가. OTA 모드에서는 시리얼 포트를 안 골라도 업로드 버튼이 살아있어야 하므로 disabled 조건을 분리했다. 에러 힌트는 espota.py의 logging.error() 문구를 직접 읽고 맞췄다. 처음엔 "No response from device"를 기준으로 짐작해서 썼는데, 실제로 응답 없는 보드는 **"No Answer"**를 뱉어서 힌트가 한 번도 안 떴다(실측 확인). 안 뜨는 힌트는 없느니만 못하다. 인증 실패/무응답/Listen 실패를 각각 다른 안내로 매핑. 실측: 실제 rems_solar_esp8266.ino로 엔드포인트 호출 → 컴파일 성공, .bin 탐색, espota 실행, 응답 없는 IP에 대해 3단계 확인 순서 힌트까지 정상 출력. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6956 lines
330 KiB
JavaScript
6956 lines
330 KiB
JavaScript
// ============================================================================
|
||
// 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,'>').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='/html/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(/<think>[\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 `<div class="code-file-tab${active ? ' active' : ''}${diff ? ' diff-active' : ''}" onclick="switchCodeFile('${f.id}')">
|
||
${unresolved > 0 ? `<span class="code-tab-dot">${unresolved}</span>` : ''}
|
||
<span>${escHtml(f.name)}</span>
|
||
<span class="code-file-tab-close" onclick="closeCodeFile('${f.id}',event)">×</span>
|
||
</div>`;
|
||
}).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';
|
||
}
|
||
|
||
// reqId로 상관관계를 맺는 companion.py 요청/응답 헬퍼 (claude-app.js의 동일 함수 포팅)
|
||
function companionRequest(type, payload, responseType, timeoutMs = 15000) {
|
||
return new Promise((resolve, reject) => {
|
||
if (!companionWs || companionWs.readyState !== WebSocket.OPEN) { reject(new Error('companion.py가 연결되지 않았습니다')); return; }
|
||
const reqId = type + '_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6);
|
||
const timeout = setTimeout(() => { companionWs.removeEventListener('message', handler); reject(new Error('시간 초과')); }, timeoutMs);
|
||
const handler = (evt) => {
|
||
let d; try { d = JSON.parse(evt.data); } catch { return; }
|
||
if (d.reqId === reqId && d.type === responseType) {
|
||
clearTimeout(timeout);
|
||
companionWs.removeEventListener('message', handler);
|
||
resolve(d);
|
||
}
|
||
};
|
||
companionWs.addEventListener('message', handler);
|
||
companionWs.send(JSON.stringify({ type, reqId, ...payload }));
|
||
});
|
||
}
|
||
|
||
// ── 📱 USB 실기기 (companion.py를 통한 로컬 PC의 adb/scrcpy) ──────────────────
|
||
let _androidUsbOpen = false;
|
||
let _androidUsbDevices = []; // [{serial, state, model}]
|
||
|
||
function _androidUsbLog(text) {
|
||
const log = document.getElementById('android-usb-log');
|
||
if (!log) return;
|
||
const atBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 30;
|
||
log.textContent += (log.textContent ? '\n' : '') + text;
|
||
if (atBottom) log.scrollTop = log.scrollHeight;
|
||
}
|
||
|
||
function _androidUsbSetStatus(text, colorBg, colorFg) {
|
||
const el = document.getElementById('android-usb-status');
|
||
if (!el) return;
|
||
el.textContent = text;
|
||
if (colorBg) el.style.background = colorBg;
|
||
if (colorFg) el.style.color = colorFg;
|
||
}
|
||
|
||
function _androidUsbSetHint(text) {
|
||
const el = document.getElementById('android-usb-hint');
|
||
if (!el) return;
|
||
if (!text) { el.style.display = 'none'; el.textContent = ''; return; }
|
||
el.style.display = '';
|
||
el.textContent = text;
|
||
}
|
||
|
||
function _androidUsbSetButtonsEnabled(enabled) {
|
||
['android-usb-install-btn', 'android-usb-logcat-btn', 'android-usb-mirror-btn'].forEach(id => {
|
||
const btn = document.getElementById(id);
|
||
if (btn) btn.disabled = !enabled;
|
||
});
|
||
}
|
||
|
||
function _androidUsbSelectedSerial() {
|
||
const sel = document.getElementById('android-usb-device-sel');
|
||
return sel && sel.value ? sel.value : (_androidUsbDevices[0]?.serial || null);
|
||
}
|
||
|
||
// companion.py가 이번 기능을 지원하지 않는 구버전일 때(browse_file/scrcpy_start 타임아웃)
|
||
// exec 계열의 일반 타임아웃과 구분되는 안내 문구를 보여준다.
|
||
function _androidUsbOutdatedCompanionMessage() {
|
||
return 'companion.py가 이 기능을 지원하지 않는 것 같습니다. 최신 버전으로 다시 다운로드해주세요 '
|
||
+ '(⬇ 다운로드 버튼, 또는 자동시작 사용자는 install_autostart.py 재실행).';
|
||
}
|
||
|
||
function toggleAndroidUsbPanel() {
|
||
const panel = document.getElementById('android-usb-panel');
|
||
const btn = document.getElementById('code-usb-android-btn');
|
||
if (!panel) return;
|
||
_androidUsbOpen = !_androidUsbOpen;
|
||
panel.style.display = _androidUsbOpen ? 'flex' : 'none';
|
||
if (btn) btn.classList.toggle('active', _androidUsbOpen);
|
||
if (monacoEditor) setTimeout(() => monacoEditor.layout(), 60);
|
||
if (_androidUsbOpen) androidUsbRefreshDevices();
|
||
}
|
||
|
||
async function androidUsbRefreshDevices() {
|
||
if (!companionConnected) {
|
||
_androidUsbSetStatus('companion 미연결', '#3a2a1a', '#e8a05a');
|
||
_androidUsbSetHint('companion.py가 실행 중이 아닙니다. 상단의 🐍 표시를 클릭해서 연결하세요.');
|
||
_androidUsbSetButtonsEnabled(false);
|
||
return;
|
||
}
|
||
_androidUsbSetStatus('확인 중...', '#1a3448', '#5aa8e8');
|
||
try {
|
||
const res = await companionRequest('exec', { command: 'adb devices -l' }, 'exec_result', 31000);
|
||
const out = res.output || '';
|
||
_androidUsbLog('$ adb devices -l\n' + out);
|
||
if (res.error) {
|
||
if (/not recognized|command not found|no such file/i.test(out)) {
|
||
_androidUsbSetStatus('adb 없음', '#3a1a1a', '#e85a5a');
|
||
_androidUsbSetHint('adb를 찾을 수 없습니다. Android SDK Platform-Tools를 설치하고 PATH에 추가해주세요.');
|
||
} else {
|
||
_androidUsbSetStatus('오류', '#3a1a1a', '#e85a5a');
|
||
_androidUsbSetHint(out);
|
||
}
|
||
_androidUsbDevices = [];
|
||
_androidUsbSetButtonsEnabled(false);
|
||
return;
|
||
}
|
||
const lines = out.split('\n').map(l => l.trim());
|
||
const startIdx = lines.findIndex(l => l.startsWith('List of devices attached'));
|
||
const deviceLines = (startIdx >= 0 ? lines.slice(startIdx + 1) : lines).filter(l => l);
|
||
_androidUsbDevices = deviceLines.map(l => {
|
||
const parts = l.split(/\s+/);
|
||
const serial = parts[0];
|
||
const state = parts[1] || 'unknown';
|
||
const modelTok = parts.find(p => p.startsWith('model:'));
|
||
const model = modelTok ? modelTok.slice(6) : '';
|
||
return { serial, state, model };
|
||
}).filter(d => d.serial);
|
||
|
||
const sel = document.getElementById('android-usb-device-sel');
|
||
if (sel) {
|
||
sel.innerHTML = _androidUsbDevices.map(d => `<option value="${d.serial}">${d.model || d.serial}${d.state !== 'device' ? ' (' + d.state + ')' : ''}</option>`).join('');
|
||
sel.style.display = _androidUsbDevices.length > 1 ? '' : 'none';
|
||
}
|
||
|
||
if (_androidUsbDevices.length === 0) {
|
||
_androidUsbSetStatus('기기 없음', '#1a3448', '#5aa8e8');
|
||
_androidUsbSetHint('USB로 연결하고 휴대폰의 설정 > 개발자 옵션에서 \'USB 디버깅\'을 켜주세요.');
|
||
_androidUsbSetButtonsEnabled(false);
|
||
} else if (_androidUsbDevices.every(d => d.state !== 'device')) {
|
||
_androidUsbSetStatus('⚠️ 인증 대기', '#3a2a1a', '#e8a05a');
|
||
_androidUsbSetHint('휴대폰 화면에 뜬 \'USB 디버깅을 허용하시겠습니까?\' 팝업에서 허용을 눌러주세요.');
|
||
_androidUsbSetButtonsEnabled(false);
|
||
} else {
|
||
_androidUsbSetStatus(`${_androidUsbDevices.length}대 연결됨`, '#1a3a2a', '#4ade80');
|
||
_androidUsbSetHint('');
|
||
_androidUsbSetButtonsEnabled(true);
|
||
}
|
||
} catch (e) {
|
||
_androidUsbSetStatus('오류', '#3a1a1a', '#e85a5a');
|
||
_androidUsbSetHint(e.message === '시간 초과' ? '시간 초과 (adb devices가 응답하지 않습니다)' : e.message);
|
||
_androidUsbSetButtonsEnabled(false);
|
||
}
|
||
}
|
||
|
||
async function androidUsbInstallApk() {
|
||
const serial = _androidUsbSelectedSerial();
|
||
try {
|
||
const picked = await companionRequest('browse_file', {
|
||
title: '설치할 APK 선택',
|
||
filetypes: [['APK 파일', '*.apk'], ['모든 파일', '*.*']],
|
||
}, 'browse_file_result', 120000);
|
||
if (picked.error) { _androidUsbLog('⚠️ ' + picked.error); return; }
|
||
if (!picked.path) { _androidUsbLog('(설치 취소됨)'); return; }
|
||
const cmd = `adb ${serial ? '-s ' + serial + ' ' : ''}install -r "${picked.path}"`;
|
||
_androidUsbLog('$ ' + cmd);
|
||
const res = await companionRequest('exec', { command: cmd }, 'exec_result', 31000);
|
||
_androidUsbLog(res.output || '');
|
||
if (/INSTALL_FAILED_/i.test(res.output || '')) _androidUsbLog('⚠️ 설치 실패 — 서명/버전 불일치일 수 있습니다.');
|
||
} catch (e) {
|
||
_androidUsbLog('⚠️ ' + (e.message === '시간 초과' ? _androidUsbOutdatedCompanionMessage() : e.message));
|
||
}
|
||
}
|
||
|
||
function androidUsbOpenLogcat() {
|
||
if (!companionConnected) { _androidUsbLog('⚠️ companion.py 미연결'); return; }
|
||
const serial = _androidUsbSelectedSerial();
|
||
const pkg = (document.getElementById('android-usb-pkg-filter')?.value || '').trim();
|
||
const id = addLocalShell('📱 Logcat');
|
||
const sFlag = serial ? `-s ${serial} ` : '';
|
||
let cmd = `adb ${sFlag}logcat -c && adb ${sFlag}logcat -v time`;
|
||
if (pkg) cmd += companionPlatform === 'windows' ? ` | findstr /I "${pkg}"` : ` | grep -i "${pkg}"`;
|
||
setTimeout(() => {
|
||
if (companionConnected && companionWs) companionWs.send(JSON.stringify({ type: 'input', shellId: id, data: cmd + '\r' }));
|
||
}, 1300);
|
||
}
|
||
|
||
async function androidUsbStartMirror() {
|
||
const serial = _androidUsbSelectedSerial();
|
||
_androidUsbLog('🪞 화면 미러링 시작 중...');
|
||
try {
|
||
const res = await companionRequest('scrcpy_start', { serial }, 'scrcpy_result', 8000);
|
||
if (res.ok) _androidUsbLog('🪞 scrcpy 실행됨 (기기 화면 창이 곧 나타납니다)');
|
||
else _androidUsbLog('⚠️ ' + res.error);
|
||
} catch (e) {
|
||
_androidUsbLog('⚠️ ' + (e.message === '시간 초과' ? _androidUsbOutdatedCompanionMessage() : e.message));
|
||
}
|
||
}
|
||
|
||
function handleCompanionMessage(msg) {
|
||
// 순수 요청/응답 타입(companionRequest로 처리됨) — 터미널 패널을 강제로 열지 않는다
|
||
if (msg.type === 'browse_file_result' || msg.type === 'scrcpy_result') return;
|
||
// 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(/(?<!\r)\n/g, '\r\n')); _termBufferAppend(s.id, msg.data); }
|
||
else if (msg.type === 'exit') { s.term.write(`\r\n\x1b[33m[exit ${msg.code}]\x1b[0m\r\n`); if (msg.code === 0) _termOnRunSuccess(s.id); }
|
||
else if (msg.type === 'error') { s.term.write(`\r\n\x1b[31m오류: ${msg.data}\x1b[0m\r\n`); _termBufferAppend(s.id, msg.data); }
|
||
else if (msg.type === 'killed') s.term.write(`\r\n\x1b[31m[강제 종료]\x1b[0m\r\n`);
|
||
}, codeTermOpen ? 0 : 500);
|
||
}
|
||
|
||
// ⏹ 버튼: 활성 터미널에서 실행 중인 프로세스를 중단
|
||
function codeTermKill() {
|
||
const s = codeTermShells.find(s => 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 `<div class="ct-tab${a?' active':''}" onclick="switchChatTermShell('${s.id}')"><svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor" style="opacity:.6"><path d="M14 3v10H2V3h12zM1 2v12h14V2H1zm5 9l3-2.5L6 6v5z"/></svg><span>${escHtml(s.name)}</span>${chatTermShells.length>1?`<span class="ct-tab-close" onclick="closeChatTermShell('${s.id}',event)">×</span>`:''}</div>`;
|
||
}).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 || '에뮬레이터';
|
||
// Always force the panel visible instead of gating on _emuPanelOpen — a
|
||
// back-forward-cached page restore (or any path where this JS-level flag
|
||
// drifts from the DOM's actual display state) left `panel.style.display`
|
||
// stuck at its closed default forever after, since this only ever ran the
|
||
// one time the flag flipped false->true.
|
||
_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);
|
||
});
|
||
}
|
||
}
|
||
|
||
function openAndroidEmulator(_isRetry) {
|
||
const btn = document.getElementById('code-android-btn');
|
||
if (btn) { btn.textContent = '⏳ 확인 중…'; btn.disabled = true; }
|
||
// Idle sessions no longer auto-die, so one from before a page reload may
|
||
// still be running — reconnect to it instead of calling /start (which
|
||
// would just 429 "already running" and close the panel).
|
||
api('/api/android/mine')
|
||
.then(d => {
|
||
if (btn) { btn.textContent = '🤖 Android'; btn.disabled = false; }
|
||
_emuPanelLoad('/android-emulator.html?session=' + d.id, 'Android');
|
||
codeAiAppend('system', '✅ 기존 Android 에뮬레이터 세션에 재연결됨.');
|
||
})
|
||
.catch(e => {
|
||
// A bare "Failed to fetch" (network-layer failure, not our JSON 404
|
||
// for "no session") happens reliably on the very first click right
|
||
// after this page finishes loading — likely the browser's per-origin
|
||
// connection pool still busy with the editor/terminal/etc. assets.
|
||
// One silent retry after a beat clears it without bothering the user;
|
||
// real 404s ("no session yet") resolve immediately either way.
|
||
if (!_isRetry && /Failed to fetch/i.test(e.message || '')) {
|
||
setTimeout(() => openAndroidEmulator(true), 600);
|
||
return;
|
||
}
|
||
_startNewAndroidEmulator(btn);
|
||
});
|
||
}
|
||
|
||
function _startNewAndroidEmulator(btn, _isRetry) {
|
||
if (btn) { btn.textContent = '⏳ 부팅 중…'; btn.disabled = true; }
|
||
_emuPanelLoad('/android-emulator.html?loading=1', 'Android');
|
||
if (!_isRetry) codeAiAppend('system', '🤖 Android 에뮬레이터 부팅 중... (최대 90초)');
|
||
api('/api/android/start', { method: 'POST' })
|
||
.then(d => {
|
||
if (btn) { btn.textContent = '🤖 Android'; btn.disabled = false; }
|
||
if (d.error) {
|
||
codeAiAppend('system', '⚠️ ' + d.error);
|
||
toggleEmuPanel();
|
||
return;
|
||
}
|
||
_emuPanelLoad('/android-emulator.html?session=' + d.id, 'Android');
|
||
codeAiAppend('system', '✅ Android 에뮬레이터 실행 중.');
|
||
}).catch(e => {
|
||
// Same first-click connection-pool hiccup as in openAndroidEmulator()
|
||
// above — retry once before bothering the user with it.
|
||
if (!_isRetry && /Failed to fetch/i.test(e.message || '')) {
|
||
setTimeout(() => _startNewAndroidEmulator(btn, true), 600);
|
||
return;
|
||
}
|
||
if (btn) { btn.textContent = '🤖 Android'; btn.disabled = false; }
|
||
codeAiAppend('system', '⚠️ ' + e.message);
|
||
toggleEmuPanel();
|
||
});
|
||
}
|
||
|
||
function resetAndroidEmulator() {
|
||
const btn = document.getElementById('code-android-reset-btn');
|
||
if (btn) { btn.disabled = true; }
|
||
api('/api/android/reset', { method: 'POST' })
|
||
.then(d => {
|
||
if (btn) { btn.disabled = false; }
|
||
codeAiAppend('system', `🔄 Android 세션 ${d.killed}개 강제 종료됨. 다시 열어주세요.`);
|
||
if (_emuPanelOpen) toggleEmuPanel();
|
||
}).catch(e => {
|
||
if (btn) { 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 ? (s.name || '🖥️ 로컬') : 'bash';
|
||
return `<div style="display:flex;align-items:center;gap:5px;padding:0 10px;cursor:pointer;font-family:'IBM Plex Mono',monospace;font-size:12px;color:${a?'#c8e6c0':'#3d6b4f'};background:${a?'#0c0c0c':'transparent'};border-top:${a?'2px solid #22c55e':'2px solid transparent'};white-space:nowrap;user-select:none" onclick="switchCodeTermShell('${s.id}')">
|
||
<svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor" style="opacity:.7"><path d="M14 3v10H2V3h12zM1 2v12h14V2H1zm5 9l3-2.5L6 6v5z"/></svg>
|
||
${label}${codeTermShells.length>1?` <span onclick="closeCodeTermShell('${s.id}',event)" style="color:#3d6b4f;margin-left:3px">×</span>`:''}
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function addLocalShell(label) {
|
||
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: label || '🖥️ 로컬', 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);
|
||
return id;
|
||
}
|
||
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 =>
|
||
`<div onclick="codeRunWith('${m.id}')" style="display:flex;align-items:center;gap:8px;padding:8px 12px;cursor:pointer;transition:background .1s" onmouseover="this.style.background='#2d4a2d'" onmouseout="this.style.background=''">` +
|
||
`<span style="font-size:14px">${m.icon}</span>` +
|
||
`<div style="flex:1"><div style="font-size:12px;font-weight:600;color:#e5e7eb">${m.label}</div><div style="font-size:10px;color:#9ca3af">${m.desc}</div></div>` +
|
||
`</div>`
|
||
).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 <link href="x.css"> and <script src="x.js"> from local filesystem,
|
||
// inline them into the HTML, then return a blob URL for window.open.
|
||
async function _openHtmlWithInlinedAssets(file) {
|
||
let html = file.content;
|
||
const dir = file.name.includes('/') ? file.name.split('/').slice(0, -1).join('/') : '';
|
||
|
||
// Inline <link rel="stylesheet" href="...">
|
||
const linkRe = /<link\b[^>]*\brel=["']stylesheet["'][^>]*\bhref=["']([^"']+)["'][^>]*\/?>/gi;
|
||
const cssMatches = [...html.matchAll(linkRe)];
|
||
for (const m of cssMatches) {
|
||
const href = m[1];
|
||
if (href.startsWith('http') || href.startsWith('//')) continue;
|
||
const fpath = dir ? `${dir}/${href}` : href;
|
||
const css = await localReadFile(_clientProjectPath(fpath)) ?? await localReadFile(fpath);
|
||
if (css !== null) {
|
||
html = html.replace(m[0], `<style>/* ${href} */\n${css}\n</style>`);
|
||
}
|
||
}
|
||
|
||
// Inline <script src="..."> (skip external URLs)
|
||
const scriptRe = /<script\b([^>]*)\bsrc=["']([^"']+)["'][^>]*><\/script>/gi;
|
||
const scriptMatches = [...html.matchAll(scriptRe)];
|
||
for (const m of scriptMatches) {
|
||
const src = m[2];
|
||
if (src.startsWith('http') || src.startsWith('//')) continue;
|
||
const fpath = dir ? `${dir}/${src}` : src;
|
||
const js = await localReadFile(_clientProjectPath(fpath)) ?? await localReadFile(fpath);
|
||
if (js !== null) {
|
||
html = html.replace(m[0], `<script${m[1]}>\n/* ${src} */\n${js}\n</script>`);
|
||
}
|
||
}
|
||
|
||
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('/html/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 <WiFi.h> 하나로 충분. 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 <ESP8266WiFi.h>. #include <WiFi.h> 사용 금지',
|
||
'- 암호화 상수: 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 <SPI.h> + #include <WiFiNINA.h>');
|
||
}
|
||
// 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('<!--');
|
||
});
|
||
if (content.trim() && currentFile.name !== 'untitled.py' && hasRealCode) {
|
||
const ext = currentFile.name.split('.').pop() || '';
|
||
const langHint = ext || currentFile.language || '';
|
||
const lines = content.split('\n');
|
||
let fileCtx, omitNote = '';
|
||
if (lines.length <= 3000) {
|
||
fileCtx = lines.map((l, i) => `${i + 1}: ${l}`).join('\n');
|
||
} else {
|
||
// Large file: always include header (first 80 lines) + cursor window + footer
|
||
const errorMatch = message.match(/line\s+(\d+)/i) || message.match(/라인\s*(\d+)/) || message.match(/(\d+)\s*줄/) || message.match(/Line\s+(\d+)/i);
|
||
let focusLine = (monacoEditor?.getPosition?.()?.lineNumber) || 1;
|
||
if (errorMatch) focusLine = parseInt(errorMatch[1]);
|
||
const headerEnd = Math.min(80, lines.length);
|
||
const ctx = 80;
|
||
const s = Math.max(headerEnd + 1, focusLine - ctx);
|
||
const e = Math.min(lines.length, focusLine + ctx);
|
||
const headerLines = lines.slice(0, headerEnd).map((l, i) => `${i + 1}: ${l}`).join('\n');
|
||
const windowLines = s <= e ? lines.slice(s - 1, e).map((l, i) => `${s + i}: ${l}`).join('\n') : '';
|
||
const structLines = [];
|
||
for (let i = 0; i < lines.length; i++) {
|
||
if (/^(class |def |async def |function |public |private |const |let |var |export )/i.test(lines[i].trimStart()))
|
||
structLines.push(`${i + 1}: ${lines[i].trim()}`);
|
||
}
|
||
const overview = structLines.length ? `\n[구조]\n${structLines.join('\n')}\n` : '';
|
||
const windowPart = windowLines && s > headerEnd + 1 ? `\n...(${s - headerEnd - 1}줄 생략)...\n${windowLines}` : (windowLines ? '\n' + windowLines : '');
|
||
fileCtx = headerLines + windowPart;
|
||
omitNote = ` (${lines.length}줄 중 앞 ${headerEnd}줄 + 커서 주변 표시)${overview}`;
|
||
}
|
||
// Byte safety net: a few-line minified/data file passes the line check but can be
|
||
// tens of MB. Hard-cap the assembled context so /api/chat never gets a giant body.
|
||
if (fileCtx.length > MAX_AI_FILE_BYTES) {
|
||
fileCtx = fileCtx.slice(0, MAX_AI_FILE_BYTES) + `\n…[파일이 너무 커서 잘림 — 전체 ${Math.round(content.length / 1024)}KB]`;
|
||
omitNote = omitNote || ` (대용량 ${Math.round(content.length / 1024)}KB — 앞부분만 표시)`;
|
||
}
|
||
// Use bare filename so model never sees the client project prefix
|
||
const displayName = _clientBareFileName(currentFile.name);
|
||
contextMsg = `${otherFilesCtx}[현재 파일: ${displayName}${omitNote}]\n\`\`\`${langHint}\n${fileCtx}\n\`\`\`\n\n${resolvedMessage}`;
|
||
}
|
||
}
|
||
|
||
// 이미지 첨부 시 채팅 버블에 썸네일 표시
|
||
const userBubble = codeAiAppend('user', message || '🖼️');
|
||
if (attachedImages.length) {
|
||
attachedImages.forEach(img => {
|
||
const im = document.createElement('img');
|
||
im.src = img.dataUrl;
|
||
im.style.cssText = 'max-width:120px;max-height:90px;border-radius:4px;display:block;margin-top:4px';
|
||
userBubble.appendChild(im);
|
||
});
|
||
}
|
||
const historyEntry = currentFile ? `[파일: ${_clientBareFileName(currentFile.name)}] ${message}` : message;
|
||
codeAiHistory.push({ role: 'user', content: historyEntry });
|
||
try { updateSessionPanel(); } catch (_) {}
|
||
|
||
if (codeAiAbort) codeAiAbort.abort();
|
||
codeAiAbort = new AbortController();
|
||
|
||
_codeAiAutoAccept = true;
|
||
_autoAcceptTurn = true;
|
||
const thinkEl = codeAiAppend('thinking', '생각 중');
|
||
let _execSecs = 0;
|
||
const _execTimer = setInterval(() => {
|
||
if (!thinkEl.classList.contains('thinking')) { clearInterval(_execTimer); return; }
|
||
_execSecs++;
|
||
const base = thinkEl.dataset.status || '생각 중';
|
||
const warn = _execSecs >= 60 ? ' ⚠️ 모델이 길게 생각 중' : '';
|
||
thinkEl.textContent = `${base} ${_execSecs}s${warn}`;
|
||
}, 1000);
|
||
_showLogBubble();
|
||
|
||
// Full-file context (Edit tool needs exact text to match)
|
||
let execContextMsg = contextMsg;
|
||
if (currentFile && monacoEditor && localDirHandle) {
|
||
const fullContent = monacoEditor.getValue();
|
||
const ext = currentFile.name.split('.').pop() || '';
|
||
const displayName = _clientBareFileName(currentFile.name);
|
||
let numberedFull = fullContent.split('\n').map((l, i) => `${i + 1}: ${l}`).join('\n');
|
||
let bigNote = '';
|
||
if (numberedFull.length > MAX_AI_FILE_BYTES) {
|
||
numberedFull = numberedFull.slice(0, MAX_AI_FILE_BYTES) + `\n…[파일이 너무 커서 잘림 — 전체 ${Math.round(fullContent.length / 1024)}KB]`;
|
||
bigNote = ` — 대용량, 앞부분만`;
|
||
}
|
||
execContextMsg = `${otherFilesCtx}[현재 파일: ${displayName} (전체 ${fullContent.split('\n').length}줄${bigNote})]\n\`\`\`${ext}\n${numberedFull}\n\`\`\`\n\n${resolvedMessage}`;
|
||
}
|
||
|
||
try {
|
||
if (codeAiAbort) codeAiAbort.abort();
|
||
codeAiAbort = new AbortController();
|
||
|
||
// Build messages for client-owned loop (Claude Code style)
|
||
const _statusMove = (text) => { thinkEl.dataset.status = text; thinkEl.textContent = `${text} ${_execSecs}s`; thinkEl.style.display = ''; const msgs = document.getElementById('code-ai-msgs'); if (msgs) msgs.appendChild(thinkEl); codeAiScrollBottom(); };
|
||
const sysPrompt = _buildCodeSystemPrompt();
|
||
// 이미지 첨부 시 content를 배열로 구성 (vision API 포맷)
|
||
const userContent = attachedImages.length
|
||
? [
|
||
{ type: 'text', text: execContextMsg },
|
||
...attachedImages.map(img => ({ type: 'image_url', image_url: { url: img.dataUrl } })),
|
||
]
|
||
: execContextMsg;
|
||
let messages = [
|
||
{ role: 'system', content: sysPrompt },
|
||
..._histForAPI(codeAiHistory),
|
||
{ role: 'user', content: userContent },
|
||
];
|
||
let finalReply = '', _firstToolSeen = false;
|
||
let liveTextEl = null, segReply = '';
|
||
_statusMove('분석 중');
|
||
// Client-owned conversation loop (Claude Code style: Read/Write/Edit/Glob/Grep/Bash)
|
||
|
||
while (true) {
|
||
const { reply, toolCalls, usage } = await _codeAiOneRound(messages, (tok) => {
|
||
segReply += tok;
|
||
if (!liveTextEl) { liveTextEl = codeAiAppend('assistant', ''); liveTextEl.classList.add('streaming'); }
|
||
liveTextEl.textContent = segReply; codeAiScrollBottom();
|
||
});
|
||
finalReply = reply || segReply;
|
||
if (usage) _codeAiOnUsage(usage);
|
||
if (!toolCalls.length) break;
|
||
|
||
// Finalize pre-tool text bubble
|
||
if (liveTextEl) {
|
||
if (!segReply.trim()) liveTextEl.remove();
|
||
else { liveTextEl.classList.remove('streaming'); codeAiRenderMarkdown(liveTextEl, segReply); }
|
||
liveTextEl = null; segReply = '';
|
||
}
|
||
_firstToolSeen = true;
|
||
|
||
// Execute each tool client-side
|
||
const ollamaToolCalls = [], toolResultContents = [];
|
||
for (const tc of toolCalls) {
|
||
const { id, name, args } = tc;
|
||
const shortFname = (args.file || args.filename || args.pattern || args.command || '').split('/').pop() || '';
|
||
_statusMove(`${_TOOL_ICONS[name] || '🔧'} ${name}${shortFname ? ': ' + shortFname : ''} ...`);
|
||
_showLogBubble();
|
||
_codeAiToolAction(name, args, null, null);
|
||
const { result, error } = await _executeToolLocal(name, args);
|
||
_codeAiToolAction(name, null, result, error);
|
||
if (!error && ['Write', 'Edit'].includes(name)) setTimeout(() => codeSbRefresh(), 200);
|
||
ollamaToolCalls.push({ id: id || `call_${name}_${Date.now()}`, function: { name, arguments: args } });
|
||
toolResultContents.push(result);
|
||
}
|
||
_statusMove('💭 다음 단계 분석 중...');
|
||
|
||
// Append tool round to messages (Ollama native format)
|
||
messages.push({ role: 'assistant', content: finalReply || '', tool_calls: ollamaToolCalls });
|
||
for (const r of toolResultContents) messages.push({ role: 'tool', content: r });
|
||
}
|
||
|
||
// Finalize history and UI
|
||
codeAiHistory.push({ role: 'assistant', content: _histEntry(finalReply) || '(응답 없음)' });
|
||
_compressHistory(); // fire-and-forget: runs in background, saves after completion
|
||
_autoSaveChatSession();
|
||
if (activeProjectName) saveProjectSession(activeProjectName);
|
||
try { updateSessionPanel(); } catch (_) {}
|
||
thinkEl.classList.remove('streaming');
|
||
if (_firstToolSeen) {
|
||
thinkEl.remove();
|
||
_hideLogBubble();
|
||
if (liveTextEl) { liveTextEl.classList.remove('streaming'); codeAiRenderMarkdown(liveTextEl, segReply); }
|
||
const doneEl = codeAiAppend('system', '✅ 완료');
|
||
doneEl.style.cssText = 'font-weight:600;color:#4ade80;';
|
||
setTimeout(() => codeSbRefresh(), 400);
|
||
} else {
|
||
_hideLogBubble();
|
||
thinkEl.className = 'code-ai-msg assistant';
|
||
if (!finalReply) { thinkEl.textContent = '(응답 없음)'; thinkEl.style.display = ''; }
|
||
else { codeAiRenderMarkdown(thinkEl, finalReply); thinkEl.style.display = ''; }
|
||
}
|
||
} catch(e) {
|
||
_hideLogBubble();
|
||
if (!thinkEl.isConnected) { const msgs = document.getElementById('code-ai-msgs'); if (msgs) msgs.appendChild(thinkEl); }
|
||
thinkEl.style.display = '';
|
||
thinkEl.className = 'code-ai-msg thinking';
|
||
const errMsg = (e.name === 'AbortError' || (e.message || '').includes('aborted'))
|
||
? '요청이 취소되었습니다. 다시 시도해주세요.'
|
||
: (e.message || e);
|
||
thinkEl.textContent = '오류: ' + errMsg;
|
||
_autoAcceptTurn = false;
|
||
} finally {
|
||
clearInterval(_execTimer);
|
||
_codeAiAutoAccept = false;
|
||
}
|
||
}
|
||
|
||
// ── Live server log bubble ────────────────────────────────────────────────
|
||
let _logBubble = null;
|
||
let _logPollTimer = null;
|
||
let _logBubbleStartCount = 0; // totalCount watermark — only show logs from after bubble appeared
|
||
|
||
function _showLogBubble() {
|
||
if (_logBubble) return;
|
||
const msgs = document.getElementById('code-ai-messages');
|
||
if (!msgs) return;
|
||
_logBubble = document.createElement('div');
|
||
_logBubble.className = 'code-ai-log-bubble';
|
||
_logBubble.innerHTML = '<span class="log-live-dot"></span><span class="log-lines"></span>';
|
||
msgs.appendChild(_logBubble);
|
||
codeAiScrollBottom();
|
||
// Record current totalCount so we only show logs that arrive during AI processing
|
||
fetch('/api/logs', { headers: authHeaders({}) })
|
||
.then(r => r.json()).then(d => { _logBubbleStartCount = d.totalCount || 0; })
|
||
.catch(() => {})
|
||
.finally(() => { _logPollTimer = setInterval(_pollLogBubble, 800); });
|
||
}
|
||
|
||
async function _pollLogBubble() {
|
||
if (!_logBubble) return;
|
||
try {
|
||
const r = await fetch('/api/logs', { headers: authHeaders({}) });
|
||
if (!r.ok) return;
|
||
const data = await r.json();
|
||
const allEntries = data.entries || [];
|
||
const newCount = Math.max(0, (data.totalCount || 0) - _logBubbleStartCount);
|
||
const newEntries = newCount > 0 ? allEntries.slice(-Math.min(newCount, allEntries.length)) : [];
|
||
const lines = newEntries.filter(e => !/^\[v\d\]|^\[task\]|^\[orch\]/.test(e.msg)).slice(-4).map(e => e.msg.slice(0, 90));
|
||
const linesEl = _logBubble.querySelector('.log-lines');
|
||
if (linesEl) linesEl.textContent = lines.join('\n');
|
||
codeAiScrollBottom();
|
||
} catch {}
|
||
}
|
||
|
||
function _hideLogBubble() {
|
||
if (_logPollTimer) { clearInterval(_logPollTimer); _logPollTimer = null; }
|
||
const b = _logBubble; _logBubble = null;
|
||
if (b) { b.style.opacity = '0'; setTimeout(() => b.remove(), 400); }
|
||
}
|
||
|
||
// ── Tool action cards for Cline-style visibility ──────────────────────────
|
||
const _TOOL_ICONS = {
|
||
Read: '📖', Write: '📄', Edit: '✏️', Glob: '🔍', Grep: '🔎', Bash: '▶️',
|
||
};
|
||
const _TOOL_COLORS = {
|
||
Read: '#3b82f6', Write: '#22c55e', Edit: '#f59e0b', Glob: '#3b82f6', Grep: '#8b5cf6', Bash: '#f59e0b',
|
||
};
|
||
const _FILE_TOOLS = new Set(['Read', 'Write', 'Edit', 'Glob', 'Grep']);
|
||
|
||
function _codeAiToolAction(toolName, toolArgs, toolResult, toolError) {
|
||
const msgs = document.getElementById('code-ai-messages');
|
||
if (!msgs) return;
|
||
const icon = _TOOL_ICONS[toolName] || '🔧';
|
||
const color = _TOOL_COLORS[toolName] || '#6b7280';
|
||
|
||
if (toolArgs !== null) {
|
||
// Tool call event — show action card
|
||
const card = document.createElement('div');
|
||
card.className = 'code-ai-tool-card';
|
||
card.dataset.tool = toolName;
|
||
const _looksLikeFilename = (s) => s && typeof s === 'string' && s.length < 200 && !s.includes('\n') && !s.includes('\t');
|
||
const fname = (_looksLikeFilename(toolArgs.file) ? toolArgs.file : _looksLikeFilename(toolArgs.filename) ? toolArgs.filename : toolArgs.name || '');
|
||
const shortName = fname.replace(/^code\//, '').split('/').pop() || '';
|
||
const isFile = _FILE_TOOLS.has(toolName);
|
||
let detail;
|
||
if (toolName === 'Write') {
|
||
const lines = toolArgs.content ? toolArgs.content.split('\n').length : 0;
|
||
detail = escHtml((shortName || fname) + (lines ? ` (${lines}줄)` : ''));
|
||
} else if (isFile) {
|
||
detail = escHtml(shortName || toolArgs.pattern || fname);
|
||
} else {
|
||
detail = escHtml((toolArgs.command || toolArgs.query || toolArgs.code || '').slice(0, 60));
|
||
}
|
||
card.innerHTML = `<span class="code-ai-tool-icon" style="color:${color}">${icon}</span> <span class="code-ai-tool-name">${escHtml(toolName)}</span> <span class="code-ai-tool-detail" style="color:var(--muted);font-size:11px">${detail}</span>`;
|
||
msgs.appendChild(card);
|
||
codeAiScrollBottom();
|
||
} else if (toolResult !== null) {
|
||
// Tool result event — update last matching card or append result
|
||
const cards = msgs.querySelectorAll(`.code-ai-tool-card[data-tool="${toolName}"]`);
|
||
const card = cards[cards.length - 1];
|
||
if (card && !card.dataset.done) {
|
||
card.dataset.done = '1';
|
||
const resultIcon = toolError ? '❌' : '✅';
|
||
const resultText = toolError ? escHtml((toolResult || 'Error').slice(0, 120)) : escHtml((toolResult || '').slice(0, 80));
|
||
const resultEl = document.createElement('span');
|
||
resultEl.className = 'code-ai-tool-result';
|
||
resultEl.innerHTML = resultText ? `${resultIcon} ${resultText}` : resultIcon;
|
||
card.appendChild(resultEl);
|
||
if (toolError) card.style.borderLeftColor = '#f87171';
|
||
}
|
||
codeAiScrollBottom();
|
||
}
|
||
}
|
||
|
||
// ── Client-owned tool execution (Read/Write/Edit/Glob/Grep/Bash) ──────────────
|
||
|
||
function _globMatch(pattern, filepath) {
|
||
// Convert glob pattern to regex: ** → any path, * → non-slash chars, ? → one non-slash
|
||
const re = pattern
|
||
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
||
.replace(/\*\*/g, '__DOUBLESTAR__')
|
||
.replace(/\*/g, '[^/]*')
|
||
.replace(/\?/g, '[^/]')
|
||
.replace(/__DOUBLESTAR__/g, '.*');
|
||
return new RegExp('^' + re + '$').test(filepath);
|
||
}
|
||
|
||
async function _executeToolLocal(name, args) {
|
||
try {
|
||
switch (name) {
|
||
case 'Read': {
|
||
const fname = args.file || '';
|
||
if (!fname) return { result: '오류: file 인수가 필요합니다', error: true };
|
||
const raw = await localReadFile(_clientProjectPath(_clientBareFileName(fname)))
|
||
?? await localReadFile(_clientBareFileName(fname));
|
||
if (raw === null) {
|
||
const tab = codeFiles.find(f => f.name === fname || f.name.endsWith('/' + fname));
|
||
if (tab) {
|
||
const tc = tab.model ? tab.model.getValue() : tab.content;
|
||
_readFileCache[_clientBareFileName(fname)] = tc;
|
||
const lines = tc.split('\n');
|
||
const offset = args.offset ? Math.max(0, (args.offset|0) - 1) : 0;
|
||
const limit = args.limit ? (args.limit|0) : lines.length;
|
||
return { result: lines.slice(offset, offset + limit).map((l, i) => `${offset + i + 1}: ${l}`).join('\n'), error: false };
|
||
}
|
||
return { result: `오류: ${fname} 파일을 찾을 수 없습니다`, error: true };
|
||
}
|
||
_readFileCache[_clientBareFileName(fname)] = raw;
|
||
const lines = raw.split('\n');
|
||
const offset = args.offset ? Math.max(0, (args.offset|0) - 1) : 0;
|
||
const limit = args.limit ? (args.limit|0) : lines.length;
|
||
const sliced = lines.slice(offset, offset + limit);
|
||
return { result: sliced.map((l, i) => `${offset + i + 1}: ${l}`).join('\n'), error: false };
|
||
}
|
||
case 'Write': {
|
||
const fname = args.file || '';
|
||
const newContent = args.content ?? '';
|
||
if (!fname) return { result: '오류: file 인수가 필요합니다', error: true };
|
||
if (fname.startsWith('#!') || /^\//.test(fname)) return { result: `오류: 유효하지 않은 파일명: ${fname.slice(0, 60)}`, error: true };
|
||
const bare = _clientBareFileName(fname);
|
||
const diskPath = _clientProjectPath(bare);
|
||
if (localDirHandle) await localWriteFile(diskPath, newContent);
|
||
codeEditorWriteFile(bare, newContent);
|
||
_readFileCache[bare] = newContent;
|
||
return { result: `${bare} 저장 완료 (${newContent.split('\n').length}줄)`, error: false };
|
||
}
|
||
case 'Edit': {
|
||
const fname = args.file || '';
|
||
const oldStr = args.old_string ?? '';
|
||
const newStr = args.new_string ?? '';
|
||
if (!fname) return { result: '오류: file 인수가 필요합니다', error: true };
|
||
if (fname.startsWith('#!') || /^\//.test(fname)) return { result: `오류: 유효하지 않은 파일명: ${fname.slice(0, 60)}`, error: true };
|
||
const bare = _clientBareFileName(fname);
|
||
const existing = await _codeAiReadLocalFile(bare);
|
||
if (existing === null) return { result: `오류: ${bare}을(를) 찾을 수 없습니다`, error: true };
|
||
if (!existing.includes(oldStr)) {
|
||
const preview = oldStr.slice(0, 60).replace(/\n/g, '↵');
|
||
return { result: `오류: old_string을 파일에서 찾을 수 없습니다\n검색: "${preview}…"`, error: true };
|
||
}
|
||
const updated = existing.replace(oldStr, newStr);
|
||
const diskPath = _clientProjectPath(bare);
|
||
if (localDirHandle) await localWriteFile(diskPath, updated);
|
||
codeEditorWriteFile(bare, updated);
|
||
_readFileCache[bare] = updated;
|
||
return { result: `${bare} 수정 완료`, error: false };
|
||
}
|
||
case 'Glob': {
|
||
const pattern = args.pattern || '**/*';
|
||
if (!localDirHandle) return { result: '(로컬 폴더가 연결되지 않았습니다)', error: false };
|
||
const allFiles = await localListFiles(localDirHandle, '');
|
||
const matches = allFiles.filter(f => _globMatch(pattern, f));
|
||
return { result: matches.length ? matches.join('\n') : '(매칭 파일 없음)', error: false };
|
||
}
|
||
case 'Grep': {
|
||
const pattern = args.pattern || '';
|
||
const searchPath = args.path || null;
|
||
const ci = !!args.case_insensitive;
|
||
if (!localDirHandle && !searchPath) return { result: '(로컬 폴더가 연결되지 않았습니다)', error: false };
|
||
let files;
|
||
if (searchPath) {
|
||
files = [_clientBareFileName(searchPath)];
|
||
} else {
|
||
files = await localListFiles(localDirHandle, '');
|
||
const CODE_EXTS_RE = /\.(js|ts|jsx|tsx|py|java|c|cpp|h|cs|go|rs|php|rb|swift|kt|html|css|json|yaml|yml|xml|md|sh|sql)$/i;
|
||
files = files.filter(f => CODE_EXTS_RE.test(f));
|
||
}
|
||
let regex;
|
||
try { regex = new RegExp(pattern, ci ? 'gi' : 'g'); } catch { regex = new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), ci ? 'gi' : 'g'); }
|
||
const results = [];
|
||
for (const f of files) {
|
||
if (results.length >= 200) break;
|
||
const fc = await localReadFile(_clientProjectPath(f)) ?? await localReadFile(f);
|
||
if (!fc) continue;
|
||
fc.split('\n').forEach((line, idx) => {
|
||
if (regex.test(line)) results.push(`${f}:${idx + 1}: ${line.trim()}`);
|
||
regex.lastIndex = 0;
|
||
});
|
||
}
|
||
return { result: results.slice(0, 200).join('\n') || '(결과 없음)', error: false };
|
||
}
|
||
case 'Bash': {
|
||
const cmd = args.command || '';
|
||
if (!cmd) return { result: '오류: command 인수가 필요합니다', error: true };
|
||
if (!localDirHandle) return { result: '⚠️ Bash를 사용하려면 로컬 폴더를 먼저 연결해주세요 (🖥️ 버튼)', error: true };
|
||
if (!companionWs || companionWs.readyState !== WebSocket.OPEN) {
|
||
return { result: '⚠️ companion.py가 연결되지 않았습니다. Bash 명령을 실행하려면 companion.py를 먼저 실행하세요.', error: true };
|
||
}
|
||
return await new Promise((resolve) => {
|
||
const reqId = 'bash_' + Date.now();
|
||
const timeout = setTimeout(() => resolve({ result: '오류: 명령 실행 시간 초과 (30s)', error: true }), 30000);
|
||
const handler = (evt) => {
|
||
try {
|
||
const d = JSON.parse(evt.data);
|
||
if (d.reqId === reqId) {
|
||
clearTimeout(timeout);
|
||
companionWs.removeEventListener('message', handler);
|
||
resolve({ result: d.output ?? d.result ?? '', error: !!d.error });
|
||
}
|
||
} catch {}
|
||
};
|
||
companionWs.addEventListener('message', handler);
|
||
companionWs.send(JSON.stringify({ type: 'exec', command: cmd, reqId }));
|
||
});
|
||
}
|
||
default:
|
||
return { result: `알 수 없는 툴: ${name}`, error: true };
|
||
}
|
||
} catch (e) {
|
||
return { result: `오류: ${e?.message || e}`, error: true };
|
||
}
|
||
}
|
||
|
||
async function _compressHistory() {
|
||
const COMPRESS_AT = 24;
|
||
const KEEP_RECENT = 8;
|
||
if (codeAiHistory.length <= COMPRESS_AT) return;
|
||
|
||
const compressEntries = codeAiHistory.slice(0, codeAiHistory.length - KEEP_RECENT);
|
||
const keepEntries = codeAiHistory.slice(codeAiHistory.length - KEEP_RECENT);
|
||
|
||
const prevSummary = _historySummary ? `[이전 요약]\n${_historySummary}\n\n` : '';
|
||
const histText = compressEntries.map(m => {
|
||
const role = m.role === 'user' ? '사용자' : 'AI';
|
||
return `${role}: ${(m.content || '').slice(0, 600)}`;
|
||
}).join('\n\n');
|
||
|
||
const summaryMessages = [
|
||
{ role: 'system', content: '당신은 코딩 세션 요약 전문가입니다.' },
|
||
{ role: 'user', content: `${prevSummary}다음 코딩 대화를 요약하세요. 어떤 파일을 다뤘는지, 어떤 수정을 했는지, 해결된 문제와 현재 상태를 5~8문장으로 간결하게:\n\n${histText}` },
|
||
];
|
||
|
||
try {
|
||
const resp = await fetch('/api/code/round', {
|
||
method: 'POST',
|
||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||
body: JSON.stringify({ messages: summaryMessages, tools: [], sessionId: codeAiSessionId }),
|
||
});
|
||
if (!resp.ok) return;
|
||
const reader = resp.body.getReader();
|
||
const dec = new TextDecoder();
|
||
let buf = '', summary = '';
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
buf += dec.decode(value, { stream: true });
|
||
const lines = buf.split('\n'); buf = lines.pop() ?? '';
|
||
for (const line of lines) {
|
||
if (!line.startsWith('data:')) continue;
|
||
try {
|
||
const ev = JSON.parse(line.slice(5).trim());
|
||
if (ev.type === 'done') summary = ev.reply || summary;
|
||
else if (ev.type === 'token') summary += ev.text;
|
||
} catch {}
|
||
}
|
||
}
|
||
if (!summary.trim()) return;
|
||
_historySummary = summary.trim();
|
||
codeAiHistory = keepEntries;
|
||
try { _autoSaveChatSession(); if (activeProjectName) saveProjectSession(activeProjectName); } catch {}
|
||
} catch {}
|
||
}
|
||
|
||
async function _codeAiOneRound(messages, onToken) {
|
||
const resp = await fetch('/api/code/round', {
|
||
method: 'POST',
|
||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||
signal: codeAiAbort?.signal,
|
||
body: JSON.stringify({
|
||
messages,
|
||
tools: CLIENT_TOOL_DEFS,
|
||
sessionId: codeAiSessionId,
|
||
...(codeAiModel ? { model: codeAiModel } : {}),
|
||
...(_csbSelBoard?.fqbn ? { boardContext: `${_csbSelBoard.name} (${_csbSelBoard.fqbn})` } : {}),
|
||
}),
|
||
});
|
||
if (!resp.ok) throw new Error(`/api/code/round ${resp.status}: ${await resp.text()}`);
|
||
const reader = resp.body.getReader();
|
||
const dec = new TextDecoder();
|
||
let buf = '', reply = '', usage = null, toolCalls = [];
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
buf += dec.decode(value, { stream: true });
|
||
const lines = buf.split('\n');
|
||
buf = lines.pop() ?? '';
|
||
for (const line of lines) {
|
||
if (!line.startsWith('data:')) continue;
|
||
const raw = line.slice(5).trim();
|
||
if (raw === '[DONE]') continue;
|
||
try {
|
||
const ev = JSON.parse(raw);
|
||
if (ev.type === 'token' && ev.text) { onToken(ev.text); reply += ev.text; }
|
||
else if (ev.type === 'usage') { usage = ev; }
|
||
else if (ev.type === 'done') {
|
||
reply = ev.reply || reply;
|
||
toolCalls = ev.toolCalls || [];
|
||
usage = ev.usage || usage;
|
||
} else if (ev.type === 'error') { throw new Error(ev.message || 'Server error'); }
|
||
} catch (parseErr) {
|
||
if (parseErr.message?.includes('Server error') || parseErr.message?.includes('/api/code/round')) throw parseErr;
|
||
}
|
||
}
|
||
}
|
||
return { reply, toolCalls, usage };
|
||
}
|
||
|
||
function _buildCodeSystemPrompt() {
|
||
// 현재 에디터에 열린 파일 목록
|
||
const openFiles = codeFiles.map(f => f.name).filter(Boolean);
|
||
const activeFile = (() => { try { return codeFiles.find(f => f.id === activeCodeFileId)?.name || ''; } catch { return ''; } })();
|
||
const summaryBlock = _historySummary ? `\n[이전 대화 요약]\n${_historySummary}\n` : '';
|
||
const _activeCode = (() => { try { const f = codeFiles.find(f => f.id === activeCodeFileId); return f?.model?.getValue() || f?.content || ''; } catch { return ''; } })();
|
||
const boardHints = _boardSpecificHints(_activeCode);
|
||
const isLangOnly = _csbSelBoard?.fqbn?.startsWith('lang:');
|
||
const libBlock = (!isLangOnly && _csbBoardLibs.length)
|
||
? `\n설치된 라이브러리: ${_csbBoardLibs.join(', ')}`
|
||
: '';
|
||
const boardBlock = _csbSelBoard?.fqbn
|
||
? `\n## ${isLangOnly ? '코딩 언어' : '대상 보드'}: ${_csbSelBoard.name}${isLangOnly ? '' : ` (${_csbSelBoard.fqbn})`}${boardHints}${libBlock}\n`
|
||
: '';
|
||
|
||
// 에디터 파일 컨텍스트 — 로컬 폴더와 분리하여 강조
|
||
let editorNote = '';
|
||
if (openFiles.length) {
|
||
editorNote = `\n## 에디터 파일 (작업 대상)
|
||
현재 에디터에 열린 파일: ${openFiles.join(', ')}${activeFile ? `\n현재 활성 파일: **${activeFile}**` : ''}
|
||
⚠️ 코드 작업은 이 파일들을 대상으로 하세요. 로컬 폴더를 Glob/list해서 프로젝트를 파악하려 하지 마세요.\n`;
|
||
}
|
||
|
||
// 로컬 폴더 노트 (파일 저장 경로)
|
||
const localNote = localDirHandle
|
||
? `로컬 폴더 "${localDirHandle.name}"이 파일 저장소로 연결되어 있습니다.`
|
||
: '로컬 폴더가 연결되지 않았습니다.';
|
||
|
||
const karpathyBlock = _karpathyMode ? `
|
||
## Karpathy 코딩 가이드라인 (필수)
|
||
1. Think Before Coding — 가정을 명시하고, 모호하면 질문하세요. 여러 해석 가능 시 제시 후 선택하세요.
|
||
2. Simplicity First — 최소한의 코드로 문제를 해결하세요. 요청받지 않은 기능, 단일 사용 추상화, 불필요한 에러 핸들링을 넣지 마세요.
|
||
3. Surgical Changes — 사용자 요청과 직접 관련 없는 코드는 건드리지 마세요. 기존 스타일을 그대로 따르세요. 내 변경으로 인한 고아 코드만 제거하세요.
|
||
4. Goal-Driven Execution — 작업을 검증 가능한 목표로 변환하세요. "버그 수정" → "재현 테스트 작성 → 통과".
|
||
` : '';
|
||
return `당신은 전문 코드 어시스턴트입니다. ${localNote}${boardBlock}${editorNote}${summaryBlock}${karpathyBlock}
|
||
|
||
## 툴 사용 지침
|
||
- 파일을 수정하기 전에 반드시 Read로 현재 내용을 확인하세요.
|
||
- **Edit**: 1~2곳의 정밀 교체에만 사용. old_string은 파일 내용과 정확히 일치해야 합니다 (공백·들여쓰기 포함).
|
||
- **Write**: 수정이 3곳 이상이거나 전체 리팩토링·일괄 변경 시 사용. Read로 읽은 내용을 수정한 뒤 Write로 교체하세요. Edit 반복보다 훨씬 빠릅니다.
|
||
- Glob으로 파일 목록 검색, Grep으로 코드 내용 검색이 가능합니다.
|
||
- Bash는 companion.py 연결 시 터미널 명령을 실행합니다.
|
||
- **파일을 2번 검색해도 없으면 즉시 멈추세요.** 같은 패턴으로 반복 검색하지 말고, 파일이 없다는 사실을 사용자에게 설명한 뒤 다른 해결책을 제안하세요.
|
||
|
||
## 파일 경로 규칙 (중요)
|
||
- 파일 경로에 절대경로·Windows 경로를 절대 사용하지 마세요: Documents/, Desktop/, C:\\, /home/ 등 금지.
|
||
- 프로젝트 루트 기준 상대 경로만 사용하세요. 예: \`main.ino\`, \`src/utils.cpp\`, \`lib/helper.h\`
|
||
- 새 프로젝트를 만들면 해당 폴더가 루트입니다. 별도의 상위 폴더를 만들지 마세요.
|
||
|
||
## 회로도 파일 규칙 (중요)
|
||
- \`circuit.json\` (회로도)은 사용자가 **명시적으로 회로도 편집을 요청**할 때만 읽거나 수정하세요.
|
||
- "TFT 연결해줘", "회로도에 부품 추가해줘" 같이 회로도 편집을 직접 언급한 경우에만 해당.
|
||
- 코드 작성·분석·업로드 등 일반 작업 중에는 circuit.json을 열지 마세요.
|
||
|
||
## 응답 원칙
|
||
- **코드 변경은 반드시 Edit 또는 Write 도구로만** 하세요. 텍스트로 "수정했습니다", "수정 완료", 마크다운 코드 블록 출력으로 파일이 바뀌지 않습니다.
|
||
- 변경 사항은 도구 호출 후 한 줄로 간결하게 설명하세요.
|
||
- 여러 파일을 수정해야 한다면 순서대로 각각 처리하세요.`;
|
||
}
|
||
|
||
|
||
|
||
// Read a file from editor tabs (preferred) or local folder
|
||
async function _codeAiReadLocalFile(fname) {
|
||
// Normalise: strip code/ prefix, then strip project prefix → bare name
|
||
const bare = _clientBareFileName(fname.replace(/^code\//, ''));
|
||
// Try editor tabs first (most up-to-date); tabs store bare names in client-local mode
|
||
const tab = codeFiles.find(f => f.name === bare || f.name === fname || f.name.endsWith('/' + bare));
|
||
if (tab) return tab.model ? tab.model.getValue() : tab.content;
|
||
// Try client-local disk (scoped to project subfolder when applicable)
|
||
if (localDirHandle) {
|
||
const content = await localReadFile(_clientProjectPath(bare));
|
||
if (content !== null) return content;
|
||
// Fallback: bare path (covers root-level files when no activeProjectName)
|
||
if (_clientProjectPath(bare) !== bare) {
|
||
const c2 = await localReadFile(bare);
|
||
if (c2 !== null) return c2;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Strip "123: " line-number prefixes the model copies from context (e.g. "42: def foo():")
|
||
function _stripLineNumbers(text) {
|
||
// Only strip if EVERY non-empty line starts with a number prefix — avoids stripping real code
|
||
const lines = text.split('\n');
|
||
if (lines.every(l => !l.trim() || /^\d+: /.test(l))) {
|
||
return lines.map(l => l.replace(/^\d+: /, '')).join('\n');
|
||
}
|
||
return text;
|
||
}
|
||
|
||
// Apply SEARCH/REPLACE blocks to content (client-side mirror of server coder_patch_file)
|
||
function _applySearchReplace(content, edits) {
|
||
if (!edits || !content) return null;
|
||
// Normalize line endings in both content and edits (\r\n / \r → \n)
|
||
const normNl = s => s.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||
content = normNl(content);
|
||
edits = normNl(edits);
|
||
|
||
const srRegex = /------- SEARCH\n([\s\S]*?)\n=======\n([\s\S]*?)\n\+\+\+\+\+\+\+ REPLACE/g;
|
||
let match;
|
||
let result = content;
|
||
let editCount = 0;
|
||
while ((match = srRegex.exec(edits)) !== null) {
|
||
// Strip line-number prefixes the model might have copied from numbered context
|
||
const searchText = _stripLineNumbers(match[1]);
|
||
const replaceText = _stripLineNumbers(match[2]);
|
||
if (result.includes(searchText)) {
|
||
result = result.replace(searchText, replaceText);
|
||
editCount++;
|
||
} else {
|
||
// Fallback 1: trim trailing whitespace per line
|
||
const trimEnd = s => s.split('\n').map(l => l.trimEnd()).join('\n');
|
||
const trimmedResult = trimEnd(result);
|
||
const trimmedSearch = trimEnd(searchText);
|
||
if (trimmedResult.includes(trimmedSearch)) {
|
||
const lines = result.split('\n');
|
||
const searchLines = searchText.split('\n');
|
||
const normLines = lines.map(l => l.trimEnd());
|
||
const normSearch = searchLines.map(l => l.trimEnd());
|
||
for (let i = 0; i <= normLines.length - normSearch.length; i++) {
|
||
if (normLines.slice(i, i + normSearch.length).join('\n') === normSearch.join('\n')) {
|
||
const originalText = lines.slice(i, i + searchLines.length).join('\n');
|
||
result = result.replace(originalText, replaceText);
|
||
editCount++;
|
||
break;
|
||
}
|
||
}
|
||
} else {
|
||
// Fallback 2: ignore leading indentation (model sometimes guesses wrong indent)
|
||
const stripIndent = s => s.split('\n').map(l => l.trimStart()).join('\n');
|
||
const stripped = stripIndent(result);
|
||
const strippedSearch = stripIndent(searchText);
|
||
if (stripped.includes(strippedSearch)) {
|
||
const lines = result.split('\n');
|
||
const searchLines = searchText.split('\n');
|
||
const normLines = lines.map(l => l.trimStart());
|
||
const normSearch = searchLines.map(l => l.trimStart());
|
||
for (let i = 0; i <= normLines.length - normSearch.length; i++) {
|
||
if (normLines.slice(i, i + normSearch.length).join('\n') === normSearch.join('\n')) {
|
||
// Detect the actual indentation from the first matching line and re-indent replaceText
|
||
const actualIndent = lines[i].match(/^(\s*)/)?.[1] || '';
|
||
const searchIndent = searchLines[0].match(/^(\s*)/)?.[1] || '';
|
||
const reindented = replaceText.split('\n').map(l => {
|
||
const trimmed = l.startsWith(searchIndent) ? l.slice(searchIndent.length) : l.trimStart();
|
||
return actualIndent + trimmed;
|
||
}).join('\n');
|
||
const originalText = lines.slice(i, i + searchLines.length).join('\n');
|
||
result = result.replace(originalText, reindented);
|
||
editCount++;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return editCount > 0 ? result : null;
|
||
}
|
||
|
||
function codeAiAppend(role, text) {
|
||
const msgs = document.getElementById('code-ai-messages');
|
||
if (!msgs) return null;
|
||
const placeholder = msgs.querySelector('.code-ai-msg.thinking');
|
||
if (placeholder && placeholder.textContent.includes('AI에게 전송')) placeholder.remove();
|
||
const el = document.createElement('div');
|
||
el.className = 'code-ai-msg ' + role;
|
||
el.textContent = text;
|
||
// Add streaming class during live token output
|
||
if (role === 'thinking' || role === 'assistant') el.classList.add('streaming');
|
||
msgs.appendChild(el);
|
||
codeAiScrollBottom();
|
||
return el;
|
||
}
|
||
|
||
// ── Inline markdown rendering (bold, italic, inline code, links) ──────────
|
||
function _renderInlineMarkdown(text) {
|
||
// Escape HTML first
|
||
let html = escHtml(text);
|
||
// **bold**
|
||
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||
// *italic*
|
||
html = html.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '<em>$1</em>');
|
||
// `inline code`
|
||
html = html.replace(/`([^`]+)`/g, '<code style="background:var(--panel-2);padding:1px 4px;border-radius:3px;font-family:IBM Plex Mono,monospace;font-size:11px;">$1</code>');
|
||
// [link](url)
|
||
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener" style="color:var(--brand);">$1</a>');
|
||
// Line breaks
|
||
html = html.replace(/\n/g, '<br>');
|
||
return html;
|
||
}
|
||
|
||
function _isDiffCode(lang, code) {
|
||
if (lang === 'diff') return true;
|
||
if (['text', 'sh', 'bash', 'shell', 'console'].includes(lang)) return false;
|
||
const lines = code.split('\n');
|
||
if (!lines.some(l => l.trim())) return false;
|
||
// An @@ hunk header is an unambiguous diff signal.
|
||
if (lines.some(l => l.startsWith('@@'))) return true;
|
||
// Count single-char +/- markers. The old test required a space after the
|
||
// marker (/^[+\-] /), which BOTH missed standard `+code`/`-code` diffs (no
|
||
// space → markers leaked into the file as literal text) AND misfired on
|
||
// markdown "- item" lists. Exclude +++/--- file headers and ++/-- runs.
|
||
const plus = lines.filter(l => /^\+(?!\+\+)/.test(l)).length;
|
||
const minus = lines.filter(l => /^-(?!--)/.test(l)).length;
|
||
if (plus === 0 && minus === 0) return false;
|
||
// A two-sided change (both adds and removes) is unambiguously a diff.
|
||
if (plus > 0 && minus > 0) return true;
|
||
// Single-sided: only a diff if accompanied by real context lines. A diff
|
||
// context line is " <code>" — a marker space then content that is NOT itself a
|
||
// bullet ("- "/"* "/"+ "). This separates a unified-diff body from a plain
|
||
// YAML/markdown list, whose items are bullets at any indent.
|
||
const strongContext = lines.filter(l =>
|
||
/^ /.test(l) && l.trim() && !/^[-*+]\s/.test(l.trim())).length;
|
||
if (strongContext > 0) return true;
|
||
// Note: we do NOT use filename comments as a diff indicator here.
|
||
// `# filename:` comments appear in both diff blocks AND new-file blocks,
|
||
// so using them to detect diffs causes false positives that break multi-file
|
||
// extraction (new files get misclassified as diffs and silently dropped).
|
||
return false;
|
||
}
|
||
|
||
// Render AI message with markdown code blocks parsed out
|
||
// Uses the same line-start fence detection as codeLiveStreamUpdate to avoid
|
||
// false matches on ``` inside code (comments, strings, etc.)
|
||
function codeAiRenderMarkdown(el, text) {
|
||
el.innerHTML = '';
|
||
// Detect explicit plan blocks: 📋 marker OR 3+ consecutive numbered items (1. / 1) )
|
||
// Narrow enough to avoid false-positives on normal prose starting with → or ▶
|
||
const planMarkerRe = /^📋\s*.+/m;
|
||
const numberedListRe = /(?:^\d+[).]\s+.+\n){3,}/m;
|
||
const isPlan = planMarkerRe.test(text) || numberedListRe.test(text);
|
||
let remaining = text;
|
||
if (isPlan) {
|
||
const lines = text.split('\n');
|
||
const planLines = [];
|
||
let inPlan = false;
|
||
for (const line of lines) {
|
||
if (/^📋/.test(line) || /^\d+[).]\s+/.test(line)) { inPlan = true; planLines.push(line); }
|
||
else if (inPlan && line.trim() === '') break;
|
||
else if (inPlan) planLines.push(line);
|
||
}
|
||
if (planLines.length >= 2) {
|
||
const planDiv = document.createElement('div');
|
||
planDiv.className = 'code-ai-plan';
|
||
// Only prepend 📋 계획 header if the first line isn't already a header/emoji title
|
||
const firstLine = planLines[0].trim();
|
||
const hasTitle = /^📋/.test(firstLine);
|
||
const header = hasTitle ? '' : '<strong>📋 계획</strong><br>';
|
||
planDiv.innerHTML = header + planLines.map(l => escHtml(l)).join('<br>');
|
||
el.appendChild(planDiv);
|
||
const lastPlanLine = planLines[planLines.length - 1];
|
||
const planEnd = text.indexOf('\n', text.indexOf(lastPlanLine) + lastPlanLine.length);
|
||
remaining = planEnd > 0 ? text.slice(planEnd + 1) : '';
|
||
}
|
||
}
|
||
// Parse code blocks using line-start toggle parser (same as streaming)
|
||
const fenceRe = /```([\w\-]*)[ \t]*\r?\n/g;
|
||
const parts = []; // alternating: text, {lang, code}, text, {lang, code}, ...
|
||
let lastEnd = 0, inBlock = false, blockLang = '', blockStart = 0, m;
|
||
while ((m = fenceRe.exec(remaining)) !== null) {
|
||
// Require ``` at start of line (only whitespace before it)
|
||
const lineStart = m.index === 0 ? 0 : remaining.lastIndexOf('\n', m.index - 1) + 1;
|
||
const prefix = remaining.slice(lineStart, m.index);
|
||
if (prefix.trim() !== '') continue; // mid-line ```, skip
|
||
if (!inBlock) {
|
||
// Text before this opening fence
|
||
if (m.index > lastEnd) parts.push({ type: 'text', content: remaining.slice(lastEnd, m.index) });
|
||
blockLang = m[1] || 'text';
|
||
blockStart = m.index + m[0].length;
|
||
inBlock = true;
|
||
} else {
|
||
// Closing fence — emit the code block
|
||
parts.push({ type: 'code', lang: blockLang, code: remaining.slice(blockStart, m.index) });
|
||
lastEnd = m.index + m[0].length;
|
||
inBlock = false;
|
||
}
|
||
}
|
||
// Handle unclosed block (streaming or malformed).
|
||
if (inBlock) {
|
||
const rawCode = remaining.slice(blockStart).replace(/\n`{3}[\w-]*[ \t]*$/, '\n');
|
||
parts.push({ type: 'code', lang: blockLang, code: rawCode });
|
||
lastEnd = remaining.length;
|
||
}
|
||
// Trailing text after last block
|
||
if (lastEnd < remaining.length) parts.push({ type: 'text', content: remaining.slice(lastEnd) });
|
||
|
||
let codeCount = 0;
|
||
const codeBlocks = [];
|
||
_DBG && console.log(`[codeAiRenderMarkdown] parts=${parts.length} types=${parts.map(p=>p.type).join(',')}`);
|
||
for (const part of parts) {
|
||
if (part.type === 'code') {
|
||
const lang = part.lang || 'text';
|
||
const code = part.code;
|
||
codeBlocks.push({ lang, code });
|
||
const isDiff = _isDiffCode(lang, code);
|
||
|
||
// Extract filename from '# filename: ...' comment if present
|
||
let diffFilename = null;
|
||
if (isDiff) {
|
||
const m = code.match(/^#\s*filename:\s*(.+)/m);
|
||
if (m) diffFilename = m[1].trim().split('/').pop();
|
||
}
|
||
|
||
const wrapper = document.createElement('div');
|
||
wrapper.className = 'code-ai-code-block';
|
||
const header = document.createElement('div');
|
||
header.className = 'code-ai-code-header';
|
||
const langLabel = document.createElement('span');
|
||
langLabel.textContent = isDiff ? ('diff' + (diffFilename ? ' ' + diffFilename : '')) : (lang || 'code');
|
||
header.appendChild(langLabel);
|
||
|
||
let contentEl;
|
||
if (isDiff) {
|
||
contentEl = document.createElement('div');
|
||
contentEl.className = 'diff-block';
|
||
for (const line of code.split('\n')) {
|
||
const span = document.createElement('span');
|
||
span.className = 'diff-line' + (
|
||
line.startsWith('+') ? ' diff-line-add' :
|
||
line.startsWith('-') ? ' diff-line-del' :
|
||
line.startsWith('@@') ? ' diff-line-hunk' :
|
||
(line.startsWith('---') || line.startsWith('+++') || line.startsWith('#')) ? ' diff-line-file' : ''
|
||
);
|
||
span.textContent = line;
|
||
contentEl.appendChild(span);
|
||
}
|
||
} else {
|
||
contentEl = document.createElement('pre');
|
||
contentEl.textContent = code;
|
||
}
|
||
|
||
wrapper.appendChild(header);
|
||
wrapper.appendChild(contentEl);
|
||
const actionBtn = document.createElement('button');
|
||
actionBtn.className = 'code-ai-open-btn';
|
||
if (isDiff) {
|
||
actionBtn.textContent = '🔀 diff 적용';
|
||
actionBtn.onclick = () => { codeAiApplyDiff(code); actionBtn.textContent = '✅ 적용됨'; actionBtn.disabled = true; };
|
||
} else {
|
||
actionBtn.textContent = '📂 에디터에서 열기';
|
||
actionBtn.onclick = () => codeAiOpenInEditor(code, lang);
|
||
}
|
||
wrapper.appendChild(actionBtn);
|
||
el.appendChild(wrapper);
|
||
codeCount++;
|
||
} else {
|
||
const span = document.createElement('span');
|
||
span.style.whiteSpace = 'pre-wrap';
|
||
span.innerHTML = _renderInlineMarkdown(part.content || '');
|
||
el.appendChild(span);
|
||
}
|
||
}
|
||
// Code blocks were already streamed into the editor by codeLiveStreamUpdate,
|
||
// so no auto-open here — just render the chat UI. The "📂 에디터에서 열기"
|
||
// button above lets users manually re-open a block if needed.
|
||
}
|
||
|
||
const CODE_EXT_MAP = {
|
||
// JavaScript / Node
|
||
javascript:'js', js:'js', node:'js', nodejs:'js',
|
||
typescript:'ts', ts:'ts', jsx:'jsx', tsx:'tsx',
|
||
// Python
|
||
python:'py', py:'py', python3:'py',
|
||
// C family
|
||
c:'c', 'c++':'cpp', cpp:'cpp', 'c#':'cs', csharp:'cs', cs:'cs',
|
||
// JVM
|
||
java:'java', kotlin:'kt', kt:'kt', scala:'scala', groovy:'groovy',
|
||
// Systems / compiled
|
||
rust:'rs', rs:'rs', go:'go', golang:'go', zig:'zig',
|
||
// Web
|
||
html:'html', css:'css', scss:'scss', less:'less', vue:'vue', svelte:'svelte',
|
||
// Data / Config
|
||
json:'json', yaml:'yml', yml:'yml', toml:'toml', xml:'xml', csv:'csv',
|
||
// Shell
|
||
shell:'sh', bash:'sh', sh:'sh', zsh:'sh', fish:'fish', powershell:'ps1', ps1:'ps1',
|
||
// Database
|
||
sql:'sql', sqlite:'sql', postgresql:'sql', mysql:'sql',
|
||
// Mobile / Other
|
||
swift:'swift', dart:'dart', lua:'lua', perl:'pl', r:'r',
|
||
ruby:'rb', rb:'rb', php:'php', elixir:'ex', erlang:'erl',
|
||
haskell:'hs', ocaml:'ml', fsharp:'fs',
|
||
// Docs
|
||
markdown:'md', md:'md', tex:'tex', latex:'tex',
|
||
};
|
||
|
||
// Decide whether a markdown code block is a real file worth opening in the editor.
|
||
// Filters out shell run-instructions (e.g. ```python code/tetris.py```) and tiny snippets.
|
||
function codeIsOpenable(code, lang) {
|
||
const trimmed = (code || '').trim();
|
||
if (!trimmed) return false;
|
||
const lines = trimmed.split('\n').filter(l => l.trim());
|
||
const l = (lang || '').toLowerCase();
|
||
const isShellLang = ['', 'text', 'console', 'sh', 'bash', 'shell', 'zsh', 'powershell', 'ps1'].includes(l);
|
||
const cmdRe = /^\s*[$#>]?\s*(python3?|pip3?|npm|npx|node|yarn|pnpm|deno|bun|cd|ls|dir|cat|echo|curl|wget|git|chmod|mkdir|rm|cp|mv|source|sudo|export|set|run|\.\/)\b/i;
|
||
// Single line: skip shell langs and short command-looking lines
|
||
if (lines.length <= 1) {
|
||
if (isShellLang) return false;
|
||
if (cmdRe.test(trimmed)) return false;
|
||
if (trimmed.length < 40) return false;
|
||
}
|
||
// Short shell block where every line is a command → run instructions, not a file
|
||
if (isShellLang && lines.length <= 3 && lines.every(ln => cmdRe.test(ln))) return false;
|
||
return true;
|
||
}
|
||
|
||
// Derive a filename from an embedded `# filename:` hint, or infer from code content.
|
||
const GENERIC_NAMES = new Set(['init','main','app','index','test','run','start','setup','util','helper','config','handler','utils','helpers','main']);
|
||
function codeDeriveName(code, lang) {
|
||
const m = (code || '').match(/(?:#|\/\/|--|\/\*|<!--)\s*(?:filename|file)\s*[:=]\s*(\S+)/i)
|
||
|| (code || '').match(/^(?:#|\/\/)\s*(\w+\.\w+)/m);
|
||
if (m) return m[1].replace(/[*\/>]+$/, '');
|
||
// Infer filename from code content when no explicit hint
|
||
const l = (lang || '').toLowerCase();
|
||
const ext = CODE_EXT_MAP[l] || ((l && l.length <= 6) ? l : 'txt');
|
||
// Try multiple strategies to find a meaningful name
|
||
// 1. HTML <title> tag
|
||
const htmlTitleMatch = code.match(/<title>([^<]+)<\/title>/i);
|
||
// 2. Class/struct/interface name
|
||
const classMatch = code.match(/(?:class|struct|interface)\s+([A-Z]\w+)/);
|
||
// 3. First function name that's not too generic
|
||
const funcMatches = [...code.matchAll(/(?:def|function|func|fn)\s+([a-zA-Z_]\w*)/g)].map(m => m[1]);
|
||
const specificFunc = funcMatches.find(f => !GENERIC_NAMES.has(f.toLowerCase()));
|
||
// 4. Title/game/app comment
|
||
const titleMatch = code.match(/(?:title|게임|프로그램|app|application)[:\s]+["']?([a-zA-Z0-9_가-힣]+)["']?/i);
|
||
// 5. <h1> heading
|
||
const h1Match = code.match(/<h[12][^>]*>([^<]{2,30})<\/h[12]>/i);
|
||
const candidates = [
|
||
htmlTitleMatch && htmlTitleMatch[1].replace(/[가-힣]/g, '').trim(),
|
||
classMatch && classMatch[1],
|
||
specificFunc,
|
||
titleMatch && titleMatch[1].replace(/[가-힣]/g, ''),
|
||
h1Match && h1Match[1].replace(/[가-힣]/g, '').trim(),
|
||
].filter(Boolean);
|
||
// Pick the first specific (non-generic) name
|
||
for (const name of candidates) {
|
||
const cleaned = name.replace(/[^a-zA-Z0-9_]/g, '').toLowerCase();
|
||
if (cleaned.length >= 3 && cleaned.length <= 30 && !GENERIC_NAMES.has(cleaned)) {
|
||
return cleaned + '.' + ext;
|
||
}
|
||
}
|
||
// Fallback: use any candidate even if generic, or random
|
||
if (candidates.length > 0) {
|
||
const fallback = candidates[0].replace(/[^a-zA-Z0-9_]/g, '').toLowerCase();
|
||
if (fallback.length >= 2) return fallback + '.' + ext;
|
||
}
|
||
return `ai_${Math.random().toString(36).slice(2,7)}.${ext}`;
|
||
}
|
||
|
||
// Write an exact (filename, content) pair into the Monaco editor immediately,
|
||
// creating or updating the tab. Used to mirror the model's coder_write_file in real time.
|
||
function codeEditorWriteFile(fname, content) {
|
||
if (!monacoReady || !monacoEditor) {
|
||
codePendingQueue.push({ fname, content, lang: codeDetectLang(fname) });
|
||
initCodeView();
|
||
return;
|
||
}
|
||
const existing = codeFiles.find(f => f.name === fname);
|
||
if (existing) {
|
||
const oldContent = existing.model ? existing.model.getValue() : existing.content;
|
||
if (oldContent !== content) {
|
||
if (_codeAiAutoAccept) {
|
||
// Auto-accept mode: show diff briefly with scroll preview, then auto-save
|
||
switchCodeFile(existing.id);
|
||
showCodeDiff(existing.id, oldContent, content);
|
||
_scrollAndAcceptDiff(existing.id);
|
||
} else {
|
||
switchCodeFile(existing.id);
|
||
showCodeDiff(existing.id, oldContent, content);
|
||
}
|
||
}
|
||
} else {
|
||
codeNewFile(fname, content);
|
||
}
|
||
}
|
||
|
||
function codeAiApplyDiff(diffCode) {
|
||
if (!monacoReady || !monacoEditor) return;
|
||
|
||
// Extract target filename from diff headers
|
||
let targetName = null;
|
||
const filenameM = diffCode.match(/^#\s*filename:\s*(.+)/m);
|
||
if (filenameM) targetName = filenameM[1].trim();
|
||
if (!targetName) {
|
||
const m = diffCode.match(/^diff --git a\/(\S+)/m) || diffCode.match(/^--- a\/(\S+)/m);
|
||
if (m && !m[1].startsWith('/dev/null')) targetName = m[1];
|
||
}
|
||
|
||
let targetFile = null;
|
||
if (targetName) {
|
||
const basename = targetName.split('/').pop();
|
||
targetFile = codeFiles.find(f => f.name === targetName) || codeFiles.find(f => f.name === basename);
|
||
if (!targetFile) return; // 에디터에 없으면 적용 안 함
|
||
} else {
|
||
targetFile = codeFiles.find(f => f.id === activeCodeFileId);
|
||
}
|
||
if (!targetFile) return;
|
||
|
||
// Skip if streaming already applied a diff for this file
|
||
if (codeDiffState[targetFile.id]) return;
|
||
|
||
const originalContent = targetFile.model ? targetFile.model.getValue() : (targetFile.content || '');
|
||
const newContent = applyUnifiedDiff(originalContent, diffCode);
|
||
if (originalContent === newContent) return;
|
||
|
||
switchCodeFile(targetFile.id);
|
||
showCodeDiff(targetFile.id, originalContent, newContent);
|
||
}
|
||
|
||
function codeAiOpenInEditor(code, lang) {
|
||
_DBG && console.log(`[codeAiOpenInEditor] lang=${lang} lines=${code?.split('\n').length} openable=${codeIsOpenable(code, lang)} monacoReady=${monacoReady}`);
|
||
if (!codeIsOpenable(code, lang)) { _DBG && console.log('[codeAiOpenInEditor] NOT openable, returning'); return; }
|
||
if (!monacoReady || !monacoEditor) {
|
||
const fname = codeDeriveName(code, lang);
|
||
_DBG && console.log(`[codeAiOpenInEditor] Monaco not ready, queueing fname=${fname}`);
|
||
codePendingQueue.push({ fname, content: code, lang });
|
||
initCodeView();
|
||
return;
|
||
}
|
||
const fname = codeDeriveName(code, lang);
|
||
const existing = codeFiles.find(f => f.name === fname);
|
||
if (existing) {
|
||
const oldContent = existing.model ? existing.model.getValue() : existing.content;
|
||
if (oldContent !== code && code.length > 0) {
|
||
// Content differs — show an inline diff for review (line/block level) rather
|
||
// than replacing wholesale, consistent with the streaming edit path.
|
||
switchCodeFile(existing.id);
|
||
showCodeDiff(existing.id, oldContent, code);
|
||
} else {
|
||
// Same content — just switch to the tab
|
||
switchCodeFile(existing.id);
|
||
}
|
||
} else {
|
||
codeNewFile(fname, code);
|
||
}
|
||
}
|
||
|
||
// ── Code Diff View (Inline decorations, VSCode Copilot style) ────────────
|
||
|
||
// Compute diff hunks between original and modified text using LCS algorithm.
|
||
// Returns an array of hunks: [{ oldStart, oldEnd, newStart, newEnd, type }]
|
||
function computeDiffHunks(originalText, modifiedText) {
|
||
const oldLines = (originalText || '').split('\n');
|
||
const newLines = (modifiedText || '').split('\n');
|
||
const m = oldLines.length, n = newLines.length;
|
||
|
||
// The LCS table below is O(m*n) memory. Only collapse to a single coarse,
|
||
// whole-file hunk when it would genuinely blow up — ~5000×5000 lines is still
|
||
// only ~50MB of Uint16, so normal large files keep granular line-level hunks.
|
||
// Beyond that we fall back rather than allocating gigabytes / freezing the tab.
|
||
if (m * n > 25_000_000) {
|
||
if (originalText === modifiedText) return [];
|
||
const type = m === 0 ? 'add' : n === 0 ? 'delete' : 'change';
|
||
return [{ oldStart: 1, oldEnd: m, newStart: 1, newEnd: n, type }];
|
||
}
|
||
|
||
// Build LCS table (full m×n; rows are Uint16Array to keep it compact)
|
||
const dp = Array.from({ length: m + 1 }, () => new Uint16Array(n + 1));
|
||
for (let i = m - 1; i >= 0; i--) {
|
||
for (let j = n - 1; j >= 0; j--) {
|
||
dp[i][j] = oldLines[i] === newLines[j] ? 1 + dp[i + 1][j + 1] : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||
}
|
||
}
|
||
|
||
// Walk back to find matching lines
|
||
const matchedOld = new Set(), matchedNew = new Set();
|
||
let oi = 0, ni = 0;
|
||
while (oi < m && ni < n) {
|
||
if (oldLines[oi] === newLines[ni]) { matchedOld.add(oi); matchedNew.add(ni); oi++; ni++; }
|
||
else if (dp[oi + 1][ni] >= dp[oi][ni + 1]) oi++;
|
||
else ni++;
|
||
}
|
||
|
||
// Group consecutive changes into hunks (merge hunks within 3 lines)
|
||
const hunks = [];
|
||
oi = 0; ni = 0;
|
||
while (oi < m || ni < n) {
|
||
// Skip matching lines
|
||
while (oi < m && ni < n && matchedOld.has(oi) && matchedNew.has(ni) && oldLines[oi] === newLines[ni]) { oi++; ni++; }
|
||
if (oi >= m && ni >= n) break;
|
||
const hOldStart = oi, hNewStart = ni;
|
||
let hOldEnd = oi, hNewEnd = ni;
|
||
// Collect changed lines, extending through nearby matches (within 3 lines)
|
||
while ((oi < m && !matchedOld.has(oi)) || (ni < n && !matchedNew.has(ni))) {
|
||
if (oi < m && !matchedOld.has(oi)) { hOldEnd = oi + 1; oi++; }
|
||
else if (ni < n && !matchedNew.has(ni)) { hNewEnd = ni + 1; ni++; }
|
||
else break;
|
||
// Extend through matching lines if next change is within 3 lines
|
||
let extended = true;
|
||
while (extended) {
|
||
extended = false;
|
||
let matchCount = 0;
|
||
while (oi < m && ni < n && matchedOld.has(oi) && matchedNew.has(ni) && oldLines[oi] === newLines[ni]) {
|
||
hOldEnd = oi + 1; hNewEnd = ni + 1; oi++; ni++; matchCount++;
|
||
// Check if next change is within 3 lines
|
||
let peekO = oi, peekN = ni, hasNearChange = false;
|
||
for (let k = 0; k < 3 && peekO < m && peekN < n; k++) {
|
||
if ((peekO < m && !matchedOld.has(peekO)) || (peekN < n && !matchedNew.has(peekN))) { hasNearChange = true; break; }
|
||
peekO++; peekN++;
|
||
}
|
||
if (hasNearChange) extended = true;
|
||
else if (matchCount >= 3) break;
|
||
}
|
||
}
|
||
}
|
||
if (hOldEnd > hOldStart || hNewEnd > hNewStart) {
|
||
const type = hOldEnd === hOldStart ? 'add' : hNewEnd === hNewStart ? 'delete' : 'change';
|
||
hunks.push({ oldStart: hOldStart + 1, oldEnd: hOldEnd, newStart: hNewStart + 1, newEnd: hNewEnd, type });
|
||
}
|
||
}
|
||
return hunks;
|
||
}
|
||
|
||
// Show inline diff decorations in the regular editor (VSCode Copilot style)
|
||
function showCodeDiff(fileId, originalText, modifiedText, onReject) {
|
||
if (!monacoReady || !window.monaco || !monacoEditor) return;
|
||
const file = codeFiles.find(f => f.id === fileId);
|
||
if (!file) return;
|
||
|
||
// If a pending (un-reviewed) diff already exists for this file, keep the user's
|
||
// original baseline so that a sequence of AI edits collapses into one review.
|
||
// e.g. coder_patch_file called 3× → user sees one combined diff, accepts once, saves once.
|
||
const existingDs = codeDiffState[fileId];
|
||
if (existingDs) {
|
||
originalText = existingDs.originalText;
|
||
}
|
||
|
||
// Clean up any existing diff for this file
|
||
cleanupCodeDiff(fileId);
|
||
|
||
// Compute diff hunks
|
||
const hunks = computeDiffHunks(originalText, modifiedText);
|
||
if (!hunks.length) return; // No changes
|
||
|
||
// Store diff state. `hunks` is computed ONCE here and stays stable for the
|
||
// lifetime of the review — accept/reject only flip per-hunk decisions:
|
||
// resolved = hunks the user has decided on (accepted OR rejected)
|
||
// rejected = subset of resolved that was rejected (restore original lines)
|
||
// The editor content is rebuilt from these decisions (see _rebuildDiffContent),
|
||
// so an accepted hunk never reappears when a later hunk is rejected.
|
||
codeDiffState[fileId] = {
|
||
originalText, modifiedText, hunks,
|
||
decorationIds: [], viewZoneIds: [],
|
||
resolved: new Set(), rejected: new Set(), onReject: onReject || null
|
||
};
|
||
|
||
// Set model to modified text (the "new" version)
|
||
if (file.model) file.model.setValue(modifiedText);
|
||
else file.content = modifiedText;
|
||
|
||
// Switch to this file and apply decorations
|
||
switchCodeFile(fileId);
|
||
_applyInlineDiffDecorations(fileId);
|
||
_showDiffBar(fileId);
|
||
renderCodeFileTabs();
|
||
}
|
||
|
||
// Rebuild the editor content from the current accept/reject decisions.
|
||
// Walks the stable hunks in order: unchanged lines come straight from the
|
||
// original, rejected hunks restore the original lines, accepted/pending hunks
|
||
// use the modified lines. Returns the assembled text plus, for each still-pending
|
||
// hunk, where it lands in that text (so decorations can be placed accurately).
|
||
function _rebuildDiffContent(ds) {
|
||
const oldLines = ds.originalText.split('\n');
|
||
const newLines = ds.modifiedText.split('\n');
|
||
const out = [];
|
||
const pendingRanges = [];
|
||
let cursor = 0; // 0-based cursor into oldLines
|
||
const ordered = ds.hunks.map((h, i) => ({ h, i })).sort((a, b) => a.h.oldStart - b.h.oldStart);
|
||
for (const { h, i } of ordered) {
|
||
// Unchanged original lines before this hunk
|
||
while (cursor < h.oldStart - 1 && cursor < oldLines.length) out.push(oldLines[cursor++]);
|
||
if (ds.rejected.has(i)) {
|
||
// Rejected → keep the original lines
|
||
for (let l = h.oldStart - 1; l < h.oldEnd && l < oldLines.length; l++) out.push(oldLines[l]);
|
||
} else {
|
||
// Accepted or pending → use the modified lines
|
||
const phantomAfter = out.length; // rebuilt line number just above the new block
|
||
for (let l = h.newStart - 1; l < h.newEnd && l < newLines.length; l++) out.push(newLines[l]);
|
||
if (!ds.resolved.has(i)) {
|
||
pendingRanges.push({ hunkIndex: i, hunk: h, greenStart: phantomAfter + 1, greenEnd: out.length, phantomAfter });
|
||
}
|
||
}
|
||
cursor = h.oldEnd;
|
||
}
|
||
while (cursor < oldLines.length) out.push(oldLines[cursor++]);
|
||
return { content: out.join('\n'), pendingRanges };
|
||
}
|
||
|
||
// Apply inline diff decorations to the editor for a given file
|
||
function _applyInlineDiffDecorations(fileId) {
|
||
const ds = codeDiffState[fileId];
|
||
if (!ds || !monacoEditor) return;
|
||
const file = codeFiles.find(f => f.id === fileId);
|
||
if (!file || file.id !== activeCodeFileId) return;
|
||
|
||
// Remove old decorations, view zones, and widgets
|
||
_removeInlineDiffDecorations(fileId);
|
||
|
||
// Rebuild content reflecting current decisions, then decorate the pending hunks
|
||
// at their rebuilt positions.
|
||
const { content, pendingRanges } = _rebuildDiffContent(ds);
|
||
if (file.model) { if (file.model.getValue() !== content) file.model.setValue(content); }
|
||
else file.content = content;
|
||
|
||
const contentLines = content.split('\n');
|
||
const oldLines = ds.originalText.split('\n');
|
||
const decorations = [];
|
||
|
||
for (const pr of pendingRanges) {
|
||
// Green highlight for added/changed lines
|
||
for (let line = pr.greenStart; line <= pr.greenEnd && line <= contentLines.length; line++) {
|
||
decorations.push({
|
||
range: new monaco.Range(line, 1, line, 1),
|
||
options: {
|
||
className: 'inline-diff-added',
|
||
glyphMarginClassName: 'inline-diff-glyph-added',
|
||
isWholeLine: true,
|
||
minimap: { color: '#28a74580', position: monaco.editor.MinimapPosition.Inline },
|
||
overviewRuler: { color: '#28a74560', position: monaco.editor.OverviewRulerLane.Full },
|
||
}
|
||
});
|
||
}
|
||
|
||
// View zone for deleted lines (red "phantom" lines), placed directly ABOVE
|
||
// the green block (phantomAfter is the line number just before it).
|
||
if (pr.hunk.type === 'delete' || pr.hunk.type === 'change') {
|
||
const deletedLines = oldLines.slice(pr.hunk.oldStart - 1, pr.hunk.oldEnd);
|
||
if (deletedLines.length && monacoEditor.changeViewZones) {
|
||
monacoEditor.changeViewZones(ca => {
|
||
const div = document.createElement('div');
|
||
div.className = 'inline-diff-deleted-zone';
|
||
div.innerHTML = deletedLines.map(line =>
|
||
'<div class="inline-diff-deleted-line">' + escHtml(line) + '</div>'
|
||
).join('');
|
||
ds.viewZoneIds.push(ca.addZone({
|
||
afterLineNumber: pr.phantomAfter,
|
||
afterColumn: 1,
|
||
heightInLines: Math.max(deletedLines.length, 1),
|
||
domNode: div,
|
||
}));
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// Apply decorations
|
||
ds.decorationIds = monacoEditor.deltaDecorations([], decorations);
|
||
|
||
// Add hunk action button overlays (anchor to the green block, or the gap for pure deletes)
|
||
for (const pr of pendingRanges) {
|
||
const anchor = pr.greenStart <= pr.greenEnd ? pr.greenStart : pr.phantomAfter + 1;
|
||
const line = Math.min(Math.max(1, anchor), contentLines.length);
|
||
_addHunkActionsWidget(fileId, pr.hunkIndex, line);
|
||
}
|
||
|
||
// Update diff bar info
|
||
_updateDiffBarInfo(fileId);
|
||
|
||
// Scroll to first pending hunk
|
||
if (pendingRanges.length) {
|
||
const pr = pendingRanges[0];
|
||
const anchor = pr.greenStart <= pr.greenEnd ? pr.greenStart : pr.phantomAfter + 1;
|
||
monacoEditor.revealLineInCenter(Math.min(Math.max(1, anchor), contentLines.length));
|
||
}
|
||
}
|
||
|
||
// Remove all inline diff decorations, view zones, and widgets for a file
|
||
function _removeInlineDiffDecorations(fileId) {
|
||
const ds = codeDiffState[fileId];
|
||
if (!ds) return;
|
||
|
||
// Remove decorations
|
||
if (ds.decorationIds.length && monacoEditor) {
|
||
monacoEditor.deltaDecorations(ds.decorationIds, []);
|
||
ds.decorationIds = [];
|
||
}
|
||
|
||
// Remove view zones
|
||
if (ds.viewZoneIds.length && monacoEditor.changeViewZones) {
|
||
monacoEditor.changeViewZones(ca => {
|
||
for (const id of ds.viewZoneIds) ca.removeZone(id);
|
||
});
|
||
ds.viewZoneIds = [];
|
||
}
|
||
|
||
// Remove hunk action button overlays from DOM
|
||
document.querySelectorAll('.inline-diff-hunk-actions[data-file-id="' + fileId + '"]').forEach(el => el.remove());
|
||
|
||
// Dispose disposables
|
||
for (const key of Object.keys(ds)) {
|
||
if (key.startsWith('_hunkDisposables_')) {
|
||
const disposables = ds[key];
|
||
if (Array.isArray(disposables)) disposables.forEach(d => { try { d.dispose(); } catch {} });
|
||
}
|
||
}
|
||
}
|
||
|
||
// Add hunk action buttons as a positioned overlay
|
||
function _addHunkActionsWidget(fileId, hunkIndex, line) {
|
||
const ds = codeDiffState[fileId];
|
||
if (!ds || !monacoEditor) return;
|
||
|
||
const actions = document.createElement('div');
|
||
actions.className = 'inline-diff-hunk-actions';
|
||
actions.dataset.fileId = fileId;
|
||
actions.dataset.hunkIndex = hunkIndex;
|
||
// Per-hunk buttons accept/reject ALL hunks for the file (one-click UX)
|
||
actions.innerHTML = '<button class="inline-diff-accept-btn" onclick="_acceptAllDiff(\'' + fileId + '\')" title="모두 적용">✅</button>' +
|
||
'<button class="inline-diff-reject-btn" onclick="_rejectAllDiff(\'' + fileId + '\')" title="모두 취소">❌</button>';
|
||
|
||
const editorContainer = document.getElementById('code-editor-container');
|
||
if (editorContainer) editorContainer.appendChild(actions);
|
||
|
||
// Position the widget at the line
|
||
const updatePosition = () => {
|
||
if (!monacoEditor || !document.body.contains(actions)) return;
|
||
try {
|
||
const lineTop = monacoEditor.getTopForLineNumber(line);
|
||
const layoutInfo = monacoEditor.getLayoutInfo();
|
||
const contentLeft = layoutInfo.contentLeft;
|
||
const scrollTop = monacoEditor.getScrollTop();
|
||
actions.style.top = (lineTop - scrollTop) + 'px';
|
||
actions.style.left = contentLeft + 'px';
|
||
actions.style.transform = 'translateY(-100%)';
|
||
} catch {}
|
||
};
|
||
updatePosition();
|
||
|
||
const scrollDisp = monacoEditor.onDidScrollChange(updatePosition);
|
||
const layoutDisp = monacoEditor.onDidLayoutChange(updatePosition);
|
||
ds['_hunkDisposables_' + hunkIndex] = [scrollDisp, layoutDisp];
|
||
}
|
||
|
||
// Show the diff action bar at the top
|
||
function _showDiffBar(fileId) {
|
||
const bar = document.getElementById('code-diff-bar');
|
||
const label = document.getElementById('code-diff-label');
|
||
if (!bar || !label) return;
|
||
|
||
const pendingCount = Object.keys(codeDiffState).length;
|
||
const countNote = pendingCount > 1 ? ` (${pendingCount}개 파일 남음)` : '';
|
||
label.textContent = '🔀 AI 제안 변경 사항' + countNote;
|
||
const acceptBtn = document.getElementById('code-diff-accept');
|
||
const rejectBtn = document.getElementById('code-diff-reject');
|
||
// Capture fileId in the closure so clicking works regardless of activeCodeFileId
|
||
if (acceptBtn) {
|
||
acceptBtn.textContent = pendingCount > 1 ? '✅ 적용 →' : '✅ 모두 적용';
|
||
acceptBtn.onclick = () => {
|
||
const ds = codeDiffState[fileId];
|
||
if (!ds) return;
|
||
ds.hunks.forEach((_, i) => { ds.resolved.add(i); ds.rejected.delete(i); });
|
||
_applyInlineDiffDecorations(fileId);
|
||
finalizeInlineDiff(fileId, true);
|
||
};
|
||
}
|
||
if (rejectBtn) {
|
||
rejectBtn.textContent = '❌ 모두 취소';
|
||
rejectBtn.onclick = () => {
|
||
const ds = codeDiffState[fileId];
|
||
if (!ds) return;
|
||
ds.hunks.forEach((_, i) => { ds.resolved.add(i); ds.rejected.add(i); });
|
||
_applyInlineDiffDecorations(fileId);
|
||
if (ds.onReject) ds.onReject();
|
||
finalizeInlineDiff(fileId, true);
|
||
};
|
||
}
|
||
bar.style.display = 'flex';
|
||
|
||
// Editor stays visible (no DiffEditor overlay)
|
||
const editorEl = document.getElementById('code-editor-container');
|
||
if (editorEl) editorEl.style.display = '';
|
||
}
|
||
|
||
// Update the diff bar with hunk count info
|
||
function _updateDiffBarInfo(fileId) {
|
||
const ds = codeDiffState[fileId];
|
||
if (!ds) return;
|
||
const unresolved = ds.hunks.filter((_, i) => !ds.resolved.has(i)).length;
|
||
const infoEl = document.getElementById('code-diff-hunk-info');
|
||
if (infoEl) infoEl.textContent = unresolved > 0 ? (unresolved + '개 변경') : '모두 해결됨';
|
||
}
|
||
|
||
// Accept a single hunk — keep its modified lines. Decisions are just flags;
|
||
// the editor content is rebuilt from them, so accepting one hunk never disturbs
|
||
// the others.
|
||
function acceptHunk(fileId, hunkIndex) {
|
||
const ds = codeDiffState[fileId];
|
||
if (!ds || ds.resolved.has(hunkIndex)) return;
|
||
ds.resolved.add(hunkIndex);
|
||
ds.rejected.delete(hunkIndex);
|
||
|
||
_applyInlineDiffDecorations(fileId);
|
||
|
||
if (ds.resolved.size === ds.hunks.length) finalizeInlineDiff(fileId, true);
|
||
}
|
||
|
||
// Reject a single hunk — restore its original lines. Other hunks (including ones
|
||
// already accepted) keep their decisions because the rebuild is decision-driven.
|
||
function rejectHunk(fileId, hunkIndex) {
|
||
const ds = codeDiffState[fileId];
|
||
if (!ds || ds.resolved.has(hunkIndex)) return;
|
||
ds.resolved.add(hunkIndex);
|
||
ds.rejected.add(hunkIndex);
|
||
|
||
_applyInlineDiffDecorations(fileId);
|
||
|
||
if (ds.resolved.size === ds.hunks.length) finalizeInlineDiff(fileId, true);
|
||
}
|
||
|
||
// Auto-accept diff with scroll preview: reveal each changed hunk in sequence, then save.
|
||
async function _scrollAndAcceptDiff(fileId) {
|
||
const ds = codeDiffState[fileId];
|
||
if (!ds || !monacoEditor) { _acceptAllDiff(fileId); return; }
|
||
|
||
const hunks = ds.hunks;
|
||
if (!hunks.length) { _acceptAllDiff(fileId); return; }
|
||
|
||
// Show preview label in the diff bar
|
||
const label = document.getElementById('code-diff-label');
|
||
if (label) label.textContent = `⏩ 편집 미리보기 (${hunks.length}개 변경)...`;
|
||
const acceptBtn = document.getElementById('code-diff-accept');
|
||
if (acceptBtn) acceptBtn.style.display = 'none'; // hide during preview
|
||
|
||
// Time budget: 500ms per hunk, capped at 5 s total
|
||
const perHunk = Math.min(500, Math.max(150, 5000 / hunks.length));
|
||
|
||
for (let i = 0; i < hunks.length; i++) {
|
||
if (!codeDiffState[fileId]) return; // diff cancelled by user or replaced
|
||
const h = hunks[i];
|
||
if (label) label.textContent = `⏩ 편집 미리보기 (${i + 1}/${hunks.length})...`;
|
||
const targetLine = h.newStart || h.oldStart || 1;
|
||
try { monacoEditor.revealLineInCenter(targetLine, 0 /* Smooth */); } catch {}
|
||
await new Promise(r => setTimeout(r, perHunk));
|
||
}
|
||
|
||
if (!codeDiffState[fileId]) return;
|
||
|
||
// Brief pause at the last hunk before auto-saving
|
||
await new Promise(r => setTimeout(r, 200));
|
||
|
||
_acceptAllDiff(fileId);
|
||
}
|
||
|
||
// Accept all hunks for a specific file (called by both per-hunk widgets and the bar button)
|
||
function _acceptAllDiff(fileId) {
|
||
const ds = codeDiffState[fileId];
|
||
if (!ds) return;
|
||
ds.hunks.forEach((_, i) => { ds.resolved.add(i); ds.rejected.delete(i); });
|
||
_applyInlineDiffDecorations(fileId);
|
||
finalizeInlineDiff(fileId, true);
|
||
}
|
||
|
||
// Reject all hunks for a specific file
|
||
function _rejectAllDiff(fileId) {
|
||
const ds = codeDiffState[fileId];
|
||
if (!ds) return;
|
||
ds.hunks.forEach((_, i) => { ds.resolved.add(i); ds.rejected.add(i); });
|
||
_applyInlineDiffDecorations(fileId);
|
||
if (ds.onReject) ds.onReject();
|
||
finalizeInlineDiff(fileId, true);
|
||
}
|
||
|
||
// Fallback wrappers using activeCodeFileId (kept for HTML onclick= attributes)
|
||
function acceptAllHunks() {
|
||
const fileId = activeCodeFileId || Object.keys(codeDiffState)[0];
|
||
if (fileId) _acceptAllDiff(fileId);
|
||
}
|
||
function rejectAllHunks() {
|
||
const fileId = activeCodeFileId || Object.keys(codeDiffState)[0];
|
||
if (fileId) _rejectAllDiff(fileId);
|
||
}
|
||
|
||
// Show inline re-login form inside the AI chat when session expires (401)
|
||
function _codeAiShowRelogin() {
|
||
const id = 'relogin_' + Date.now();
|
||
const el = codeAiAppend('system', '');
|
||
el.innerHTML = `<div style="display:flex;flex-direction:column;gap:8px;padding:4px 0">
|
||
<span style="color:#f87">⚠️ 세션 만료 — 재로그인</span>
|
||
<input id="${id}_u" placeholder="아이디" style="background:#1e1e1e;border:1px solid #444;color:#eee;padding:6px 8px;border-radius:4px;font-size:13px" />
|
||
<input id="${id}_p" type="password" placeholder="비밀번호" style="background:#1e1e1e;border:1px solid #444;color:#eee;padding:6px 8px;border-radius:4px;font-size:13px" />
|
||
<button onclick="_codeAiDoRelogin('${id}')" style="background:#2d6a2d;color:#eee;border:none;padding:6px 12px;border-radius:4px;cursor:pointer;font-size:13px">로그인</button>
|
||
</div>`;
|
||
setTimeout(() => document.getElementById(id + '_u')?.focus(), 50);
|
||
}
|
||
async function _codeAiDoRelogin(id) {
|
||
const u = document.getElementById(id + '_u')?.value?.trim();
|
||
const p = document.getElementById(id + '_p')?.value;
|
||
if (!u || !p) return;
|
||
try {
|
||
const r = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: u, password: p }) });
|
||
const d = await r.json();
|
||
if (d.token) {
|
||
setAuthToken(d.token);
|
||
const el = document.getElementById(id + '_u')?.closest('.code-ai-msg');
|
||
if (el) el.remove();
|
||
codeAiAppend('system', '✅ 로그인 완료 — 다시 메시지를 보내세요');
|
||
} else {
|
||
codeAiAppend('system', '❌ 로그인 실패: ' + (d.error || '아이디/비밀번호 확인'));
|
||
}
|
||
} catch (e) { codeAiAppend('system', '❌ 로그인 오류: ' + e.message); }
|
||
}
|
||
|
||
// Persist a specific file to workspace/code/ on disk. Accepting a diff only
|
||
// updated the Monaco model in memory — without this the .py file kept the old
|
||
// code, so re-running showed no change ("diff가 안 됨").
|
||
async function codeSaveFileById(fileId, hunkCount) {
|
||
const file = codeFiles.find(f => f.id === fileId);
|
||
if (!file) return;
|
||
const content = (file.model ? file.model.getValue() : file.content) || '';
|
||
file.content = content;
|
||
if (!localDirHandle) {
|
||
codeAiAppend('system', `⚠️ 폴더 미선택 — ${file.name} 저장 생략. 🖥️ 버튼으로 로컬 폴더를 선택해주세요.`);
|
||
return;
|
||
}
|
||
const ok = await localWriteFile(_clientProjectPath(file.name), content);
|
||
const bareName = _clientBareFileName(file.name);
|
||
const countNote = hunkCount > 0 ? ` (${hunkCount}곳)` : '';
|
||
if (ok) { codeAiAppend('system', `💾 저장: ${bareName}${countNote}`); codeSbRefresh(); }
|
||
else codeAiAppend('system', `⚠️ 저장 실패: ${bareName}`);
|
||
}
|
||
|
||
// Finalize inline diff — clean up all decorations and state.
|
||
// `save` persists the accepted result to disk (true for accepts, false for rejects).
|
||
function finalizeInlineDiff(fileId, save) {
|
||
const ds = codeDiffState[fileId];
|
||
if (!ds) return;
|
||
|
||
const hunkCount = ds.hunks.length;
|
||
_removeInlineDiffDecorations(fileId);
|
||
delete codeDiffState[fileId];
|
||
|
||
// Auto-save accepted changes so the on-disk file matches the editor
|
||
if (save) codeSaveFileById(fileId, hunkCount);
|
||
|
||
renderCodeFileTabs();
|
||
|
||
// If more files have pending diffs, switch to next one automatically
|
||
const nextId = Object.keys(codeDiffState)[0];
|
||
if (nextId) {
|
||
switchCodeFile(nextId); // switchCodeFile calls _showDiffBar if codeDiffState[nextId] exists
|
||
return;
|
||
}
|
||
|
||
// No more pending diffs — show completion message if this was an auto-accept turn
|
||
if (save && _autoAcceptTurn) {
|
||
_autoAcceptTurn = false;
|
||
setTimeout(() => codeAiAppend('system', '✅ 완료'), 300);
|
||
}
|
||
|
||
// Hide bar and restore focus
|
||
const bar = document.getElementById('code-diff-bar');
|
||
if (bar) bar.style.display = 'none';
|
||
|
||
const file = codeFiles.find(f => f.id === fileId);
|
||
if (file && monacoEditor && file.id === activeCodeFileId) {
|
||
monacoEditor.setModel(file.model);
|
||
monacoEditor.focus();
|
||
}
|
||
}
|
||
|
||
function cleanupCodeDiff(fileId) {
|
||
finalizeInlineDiff(fileId);
|
||
}
|
||
|
||
// ── New file approval bar + queue (supports multiple files at once) ──────
|
||
let _fileApprovalQueue = []; // [{fileId, fileName}]
|
||
|
||
function _enqueueFileApproval(fileId, fileName) {
|
||
_fileApprovalQueue.push({ fileId, fileName });
|
||
if (_fileApprovalQueue.length === 1) _showNextApproval();
|
||
}
|
||
|
||
function _showNextApproval() {
|
||
if (!_fileApprovalQueue.length) return;
|
||
const { fileId, fileName } = _fileApprovalQueue[0];
|
||
_showNewFileApproval(fileId, fileName);
|
||
}
|
||
|
||
function _showNewFileApproval(fileId, fileName) {
|
||
const file = codeFiles.find(f => f.id === fileId);
|
||
if (!file) { _fileApprovalQueue.shift(); _showNextApproval(); return; }
|
||
// In local mode: auto-save directly, no approval popup needed
|
||
if (localDirHandle) {
|
||
const content = file.model ? file.model.getValue() : (file.content || '');
|
||
localWriteFile(_clientProjectPath(file.name), content).then(ok => {
|
||
if (ok) { codeSbRefresh(); codeAiAppend('system', `💾 저장: ${file.name}`); }
|
||
else codeAiAppend('system', `⚠️ 저장 실패: ${file.name}`);
|
||
});
|
||
_fileApprovalQueue.shift();
|
||
if (_fileApprovalQueue.length) setTimeout(_showNextApproval, 200);
|
||
return;
|
||
}
|
||
switchCodeFile(fileId);
|
||
|
||
const bar = document.getElementById('code-diff-bar');
|
||
const label = document.getElementById('code-diff-label');
|
||
if (!bar || !label) return;
|
||
|
||
const remaining = _fileApprovalQueue.length;
|
||
const queueNote = remaining > 1 ? ` (${remaining}개 중 1번째)` : '';
|
||
label.textContent = '📝 새 파일: ' + fileName + queueNote;
|
||
const acceptBtn = document.getElementById('code-diff-accept');
|
||
const rejectBtn = document.getElementById('code-diff-reject');
|
||
if (acceptBtn) {
|
||
acceptBtn.textContent = remaining > 1 ? '✅ 만들기 →' : '✅ 만들기';
|
||
acceptBtn.onclick = () => {
|
||
bar.style.display = 'none';
|
||
codeSaveFileById(fileId);
|
||
_fileApprovalQueue.shift();
|
||
if (_fileApprovalQueue.length) setTimeout(_showNextApproval, 200);
|
||
};
|
||
}
|
||
if (rejectBtn) {
|
||
rejectBtn.textContent = '❌ 취소';
|
||
rejectBtn.onclick = () => {
|
||
const idx = codeFiles.findIndex(f => f.id === fileId);
|
||
if (idx >= 0) {
|
||
if (codeFiles[idx].model) codeFiles[idx].model.dispose();
|
||
codeFiles.splice(idx, 1);
|
||
}
|
||
if (codeDiffState[fileId]) cleanupCodeDiff(fileId);
|
||
bar.style.display = 'none';
|
||
if (codeFiles.length > 0) switchCodeFile(codeFiles[0].id);
|
||
renderCodeFileTabs();
|
||
_fileApprovalQueue.shift();
|
||
if (_fileApprovalQueue.length) setTimeout(_showNextApproval, 200);
|
||
};
|
||
}
|
||
bar.style.display = 'flex';
|
||
}
|
||
|
||
// Extract blocks that have an explicit `# filename:` / `// filename:` comment
|
||
function _getExplicitFilename(code) {
|
||
const m = (code || '').match(/(?:#|\/\/|--|\/\*|<!--)\s*(?:filename|file)\s*[:=]\s*(\S+)/i);
|
||
return m ? m[1].replace(/[*\/>]+$/, '') : null;
|
||
}
|
||
|
||
function _extractExplicitFileBlocks(text) {
|
||
const fenceRe = /```([\w\-]*)[ \t]*\r?\n/g;
|
||
const result = [];
|
||
let inBlock = false, blockLang = '', blockStart = 0, m;
|
||
while ((m = fenceRe.exec(text)) !== null) {
|
||
const lineStart = m.index === 0 ? 0 : text.lastIndexOf('\n', m.index - 1) + 1;
|
||
if (text.slice(lineStart, m.index).trim() !== '') continue;
|
||
if (!inBlock) { blockLang = m[1] || 'text'; blockStart = m.index + m[0].length; inBlock = true; }
|
||
else {
|
||
const code = text.slice(blockStart, m.index);
|
||
const fname = _getExplicitFilename(code);
|
||
// Include BOTH diff and non-diff blocks with filenames.
|
||
// Previously we excluded diffs here, which caused new files to be lost
|
||
// when the AI outputs a diff (modify existing) + new-file blocks (create new).
|
||
if (fname) result.push({ fname, code, lang: blockLang, isDiff: _isDiffCode(blockLang, code) });
|
||
inBlock = false;
|
||
}
|
||
}
|
||
if (inBlock) {
|
||
const code = text.slice(blockStart);
|
||
const fname = _getExplicitFilename(code);
|
||
if (fname) result.push({ fname, code, lang: blockLang, isDiff: _isDiffCode(blockLang, code) });
|
||
}
|
||
return result;
|
||
}
|
||
function codeAiScrollBottom() {
|
||
const msgs = document.getElementById('code-ai-messages');
|
||
if (msgs) msgs.scrollTop = msgs.scrollHeight;
|
||
}
|
||
|
||
// ── Project session management ────────────────────────────────────────────
|
||
function saveProjectSession(projectName) {
|
||
if (!projectName) return;
|
||
localStorage.setItem('code_proj_session_' + projectName, codeAiSessionId);
|
||
try { localStorage.setItem('code_proj_history_' + projectName, JSON.stringify(codeAiHistory.slice(-40))); } catch {}
|
||
}
|
||
|
||
function _knownProjectsGet() {
|
||
try { return JSON.parse(localStorage.getItem('code_known_projects') || '[]'); } catch { return []; }
|
||
}
|
||
function _knownProjectsAdd(name) {
|
||
if (!name) return;
|
||
const list = _knownProjectsGet();
|
||
if (!list.includes(name)) { list.push(name); localStorage.setItem('code_known_projects', JSON.stringify(list)); }
|
||
}
|
||
function _knownProjectsRemove(name) {
|
||
if (!name) return;
|
||
const list = _knownProjectsGet().filter(p => p !== name);
|
||
localStorage.setItem('code_known_projects', JSON.stringify(list));
|
||
}
|
||
|
||
async function deleteActiveProject() {
|
||
if (!activeProjectName) return;
|
||
if (!confirm(`"${activeProjectName}" 프로젝트를 삭제할까요?\n(폴더와 파일이 모두 삭제됩니다)`)) return;
|
||
const name = activeProjectName;
|
||
// localStorage 정리
|
||
_knownProjectsRemove(name);
|
||
localStorage.removeItem('code_proj_session_' + name);
|
||
localStorage.removeItem('code_proj_history_' + name);
|
||
// 로컬 폴더 삭제
|
||
if (localDirHandle) {
|
||
try { await localDirHandle.removeEntry(name, { recursive: true }); } catch (e) { console.warn('[deleteProject]', e); }
|
||
}
|
||
// 탭 닫기 및 프로젝트 초기화
|
||
codeFiles.forEach(f => { try { f.model?.dispose(); } catch {} });
|
||
codeFiles.length = 0; activeCodeFileId = null; codeDiffState = {};
|
||
renderCodeFileTabs();
|
||
activeProjectName = null;
|
||
localStorage.setItem('code_active_project', '');
|
||
codeAiHistory = []; _prevTurnDoneTools = []; _readFileCache = {}; _historySummary = '';
|
||
_codeAiLastUsage = null;
|
||
codeAiSessionId = 'code_ai_' + Math.random().toString(36).slice(2, 10);
|
||
updateLocalModeUI();
|
||
updateSessionPanel();
|
||
codeSbRefresh();
|
||
_renderSessionsList();
|
||
}
|
||
|
||
function switchProject(name) {
|
||
_autoSaveChatSession(); // null 프로젝트 상태의 히스토리도 세션 목록에 보존
|
||
if (activeProjectName) saveProjectSession(activeProjectName);
|
||
activeProjectName = name || null;
|
||
localStorage.setItem('code_active_project', activeProjectName || '');
|
||
if (activeProjectName) {
|
||
_knownProjectsAdd(activeProjectName);
|
||
const savedId = localStorage.getItem('code_proj_session_' + activeProjectName);
|
||
const savedHist = localStorage.getItem('code_proj_history_' + activeProjectName);
|
||
codeAiSessionId = savedId || ('code_ai_' + Math.random().toString(36).slice(2, 10));
|
||
try { codeAiHistory = savedHist ? JSON.parse(savedHist) : []; } catch { codeAiHistory = []; _prevTurnDoneTools = []; _readFileCache = {}; _historySummary = ''; }
|
||
_readFileCache = {}; _historySummary = '';
|
||
saveProjectSession(activeProjectName);
|
||
} else {
|
||
codeAiSessionId = 'code_ai_' + Math.random().toString(36).slice(2, 10);
|
||
codeAiHistory = []; _prevTurnDoneTools = []; _readFileCache = {}; _historySummary = '';
|
||
}
|
||
updateLocalModeUI();
|
||
updateSessionPanel();
|
||
codeSbRefresh();
|
||
localShellIds.forEach(shellId => _companionCdProject(shellId));
|
||
// 채팅 패널 갱신: 히스토리가 있으면 그대로, 없으면 세션 목록
|
||
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();
|
||
}
|
||
}
|
||
// In client-local mode: close previous project's tabs and load new project files
|
||
if (localDirHandle) _clientLoadProjectFiles();
|
||
}
|
||
|
||
function csbRenderProjectSelector(projects) {
|
||
const sel = document.getElementById('csb-project-sel');
|
||
const row = document.getElementById('csb-project-row');
|
||
if (!sel || !row) return;
|
||
if (!projects.length) { row.style.display = 'none'; return; }
|
||
row.style.display = 'flex';
|
||
const options = '<option value="">(전체)</option>' +
|
||
projects.map(p => `<option value="${escHtml(p)}"${activeProjectName === p ? ' selected' : ''}>${escHtml(p)}</option>`).join('');
|
||
sel.innerHTML = options;
|
||
sel.value = activeProjectName || '';
|
||
}
|
||
|
||
// ── Session list (Cline style) ─────────────────────────────────────────────
|
||
const _SESS_KEY = 'code_chat_sessions_v2';
|
||
const _SESS_MAX = 30;
|
||
|
||
function _loadSessions() {
|
||
try { return JSON.parse(localStorage.getItem(_SESS_KEY) || '[]'); } catch { return []; }
|
||
}
|
||
function _saveSessions(list) {
|
||
try { localStorage.setItem(_SESS_KEY, JSON.stringify(list)); } catch {}
|
||
}
|
||
|
||
function _autoSaveChatSession() {
|
||
if (!codeAiHistory.length) return;
|
||
const list = _loadSessions();
|
||
const firstUser = codeAiHistory.find(m => m.role === 'user')?.content || '';
|
||
const title = firstUser.replace(/^\[파일:[^\]]+\]\s*/, '').slice(0, 50) || '(제목 없음)';
|
||
const existing = list.findIndex(s => s.sessionId === codeAiSessionId);
|
||
const entry = {
|
||
id: existing >= 0 ? list[existing].id : ('sess_' + Math.random().toString(36).slice(2, 9)),
|
||
sessionId: codeAiSessionId,
|
||
title,
|
||
project: activeProjectName || '',
|
||
ts: Date.now(),
|
||
msgCount: codeAiHistory.length,
|
||
history: codeAiHistory.slice(-60),
|
||
};
|
||
if (existing >= 0) list[existing] = entry;
|
||
else list.unshift(entry);
|
||
if (list.length > _SESS_MAX) list.length = _SESS_MAX;
|
||
_saveSessions(list);
|
||
}
|
||
|
||
function _backToSessionList() {
|
||
_autoSaveChatSession();
|
||
codeAiHistory = []; _prevTurnDoneTools = []; _readFileCache = {}; _historySummary = '';
|
||
_codeAiLastUsage = null;
|
||
codeAiSessionId = 'code_ai_' + Math.random().toString(36).slice(2, 10);
|
||
if (activeProjectName) saveProjectSession(activeProjectName);
|
||
const msgs = document.getElementById('code-ai-messages');
|
||
if (msgs) msgs.innerHTML = '';
|
||
_renderSessionsList();
|
||
}
|
||
|
||
function _renderSessionsList() {
|
||
const msgs = document.getElementById('code-ai-messages');
|
||
if (!msgs) return;
|
||
const list = _loadSessions();
|
||
const proj = activeProjectName || '';
|
||
const filtered = proj ? list.filter(s => s.project === proj || !s.project) : list;
|
||
|
||
if (!filtered.length) {
|
||
msgs.innerHTML = '<div class="code-ai-msg thinking" style="text-align:center;padding:24px 8px;color:var(--muted)">저장된 세션이 없습니다.<br>아래에 질문을 입력하면 새 세션이 시작됩니다.</div>';
|
||
return;
|
||
}
|
||
|
||
const relTime = ts => {
|
||
const d = (Date.now() - ts) / 1000;
|
||
if (d < 60) return '방금';
|
||
if (d < 3600) return Math.floor(d/60) + '분 전';
|
||
if (d < 86400) return Math.floor(d/3600) + '시간 전';
|
||
return Math.floor(d/86400) + '일 전';
|
||
};
|
||
|
||
msgs.innerHTML = `
|
||
<div style="padding:8px 10px 4px;display:flex;justify-content:space-between;align-items:center">
|
||
<span style="font-size:10px;color:var(--muted);letter-spacing:.08em;text-transform:uppercase">최근 세션</span>
|
||
<button onclick="_deleteAllSessions(event)" style="font-size:10px;padding:1px 6px;border:1px solid #374151;background:none;color:#6b7280;border-radius:3px;cursor:pointer" onmouseover="this.style.borderColor='#f87171';this.style.color='#f87171'" onmouseout="this.style.borderColor='#374151';this.style.color='#6b7280'">전체 삭제</button>
|
||
</div>
|
||
<div style="padding:0 6px 6px;display:flex;flex-direction:column;gap:3px">
|
||
${filtered.map(s => `
|
||
<div style="display:flex;align-items:center;gap:0;border-radius:5px;overflow:hidden;border:1px solid var(--line);background:var(--panel-2);transition:border-color .15s"
|
||
onmouseover="this.style.borderColor='#3d6b4f'" onmouseout="this.style.borderColor='var(--line)'">
|
||
<div onclick="_resumeSession('${s.id}')" style="flex:1;min-width:0;padding:5px 8px;cursor:pointer;border-left:2px solid #22c55e">
|
||
<div style="display:flex;align-items:center;gap:5px;margin-bottom:2px">
|
||
${s.project ? `<span style="font-size:9px;padding:1px 5px;background:#0d2b1a;color:#22c55e;border-radius:3px;letter-spacing:.03em;flex-shrink:0">${escHtml(s.project)}</span>` : ''}
|
||
<span style="font-size:11px;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(s.title)}</span>
|
||
</div>
|
||
<span style="font-size:10px;color:var(--muted)">${s.msgCount}개 · ${relTime(s.ts)}</span>
|
||
</div>
|
||
<button onclick="_deleteSession('${s.id}', event)" title="세션 삭제" style="flex-shrink:0;width:24px;height:100%;background:none;border:none;border-left:1px solid var(--line);color:#4b5563;cursor:pointer;font-size:12px;display:flex;align-items:center;justify-content:center;align-self:stretch;padding:0" onmouseover="this.style.color='#f87171';this.style.background='rgba(248,113,113,0.08)'" onmouseout="this.style.color='#4b5563';this.style.background='none'">✕</button>
|
||
</div>`).join('')}
|
||
</div>`;
|
||
}
|
||
|
||
function _resumeSession(id) {
|
||
const list = _loadSessions();
|
||
const sess = list.find(s => s.id === id);
|
||
if (!sess) return;
|
||
_autoSaveChatSession(); // save current before switching
|
||
codeAiHistory = sess.history || [];
|
||
codeAiSessionId = sess.sessionId;
|
||
if (activeProjectName) saveProjectSession(activeProjectName);
|
||
const msgs = document.getElementById('code-ai-messages');
|
||
if (msgs) {
|
||
msgs.innerHTML = '';
|
||
codeAiHistory.forEach(m => codeAiAppend(m.role === 'user' ? 'user' : 'assistant', m.content));
|
||
}
|
||
updateSessionPanel();
|
||
}
|
||
|
||
function _deleteSession(id, event) {
|
||
if (event) { event.stopPropagation(); event.preventDefault(); }
|
||
let list = _loadSessions();
|
||
const sess = list.find(s => s.id === id);
|
||
// If deleting current session, clear it
|
||
if (sess && sess.sessionId === codeAiSessionId) {
|
||
codeAiHistory = []; _prevTurnDoneTools = []; _readFileCache = {}; _historySummary = '';
|
||
codeAiSessionId = 'code_ai_' + Math.random().toString(36).slice(2, 10);
|
||
if (activeProjectName) saveProjectSession(activeProjectName);
|
||
const msgs = document.getElementById('code-ai-messages');
|
||
if (msgs) msgs.innerHTML = '';
|
||
}
|
||
list = list.filter(s => s.id !== id);
|
||
_saveSessions(list);
|
||
// Also clear server-side history for this session
|
||
if (sess) {
|
||
fetch('/api/clear-history', {
|
||
method: 'POST',
|
||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||
body: JSON.stringify({ sessionId: sess.sessionId }),
|
||
}).catch(() => {});
|
||
}
|
||
_renderSessionsList();
|
||
updateSessionPanel();
|
||
}
|
||
|
||
function _deleteAllSessions(event) {
|
||
if (event) { event.stopPropagation(); event.preventDefault(); }
|
||
const list = _loadSessions();
|
||
// Clear server-side history for all sessions
|
||
for (const s of list) {
|
||
fetch('/api/clear-history', {
|
||
method: 'POST',
|
||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||
body: JSON.stringify({ sessionId: s.sessionId }),
|
||
}).catch(() => {});
|
||
}
|
||
_saveSessions([]);
|
||
// Also clear project-specific session storage
|
||
Object.keys(localStorage).filter(k => k.startsWith('code_proj_session_') || k.startsWith('code_proj_history_')).forEach(k => localStorage.removeItem(k));
|
||
codeAiHistory = []; _prevTurnDoneTools = []; _readFileCache = {}; _historySummary = '';
|
||
codeAiSessionId = 'code_ai_' + Math.random().toString(36).slice(2, 10);
|
||
if (activeProjectName) saveProjectSession(activeProjectName);
|
||
const msgs = document.getElementById('code-ai-messages');
|
||
if (msgs) msgs.innerHTML = '';
|
||
_renderSessionsList();
|
||
updateSessionPanel();
|
||
}
|
||
|
||
async function codeAiClear() {
|
||
_autoSaveChatSession(); // 현재 세션 자동 저장
|
||
if (activeProjectName) saveProjectSession(activeProjectName); // per-project store도 지우기 전에 저장
|
||
fetch('/api/clear-history', {
|
||
method: 'POST',
|
||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||
body: JSON.stringify({ sessionId: codeAiSessionId }),
|
||
}).catch(() => {});
|
||
codeAiHistory = []; _prevTurnDoneTools = []; _readFileCache = {}; _historySummary = '';
|
||
|
||
// In client-local mode: ask for a project name and create the folder
|
||
if (localDirHandle) {
|
||
const raw = prompt('새 프로젝트 이름:', '');
|
||
const projName = (raw || '').trim().replace(/[^a-zA-Z0-9가-힣_-]/g, '_').replace(/^_+|_+$/g, '');
|
||
if (projName) {
|
||
await localDirHandle.getDirectoryHandle(projName, { create: true }).catch(() => {});
|
||
switchProject(projName);
|
||
codeAiAppend('system', `📁 프로젝트 "${projName}" 생성됨 (빈 폴더). 파일은 Write 툴로 만드세요. Bash는 companion 연결 시에만 사용 가능합니다. 어떤 걸 만들지 설명해주세요.`);
|
||
return;
|
||
}
|
||
}
|
||
|
||
codeAiSessionId = 'code_ai_' + Math.random().toString(36).slice(2, 10);
|
||
if (activeProjectName) saveProjectSession(activeProjectName);
|
||
updateSessionPanel();
|
||
_renderSessionsList(); // 세션 목록으로 돌아가기
|
||
}
|
||
|
||
|
||
// ── Code sidebar panel ────────────────────────────────────────────────────
|
||
let codeSbCurrentTab = 'files';
|
||
|
||
function setCodeSbTab(tab) {
|
||
codeSbCurrentTab = tab;
|
||
['files','settings','libraries'].forEach(t => {
|
||
const btn = document.getElementById('csb-tab-' + t);
|
||
if (btn) btn.classList.toggle('active', t === tab);
|
||
const el = document.getElementById('csb-' + t);
|
||
if (el) el.style.display = t === tab ? 'flex' : 'none';
|
||
});
|
||
if (tab === 'files') csbLoadModels();
|
||
if (tab === 'libraries') {
|
||
if (_csbArdSection === 'lib') csbLibLoad();
|
||
if (_csbArdSection === 'board') csbBoardLoad();
|
||
}
|
||
}
|
||
|
||
// ── 라이브러리/보드 매니저 (code editor sidebar) ──────────────────────────────
|
||
let _csbLibCurrentTab = 'installed';
|
||
let _csbLibFilterMode = 'all'; // 'all' | 'compat' | 'incompat'
|
||
let _csbLibViewMode = 'flat'; // 'flat' | 'category'
|
||
let _csbLibArchFilter = ''; // '' = 전체, 'esp32' | 'esp8266' | 'avr' ...
|
||
let _csbLibCache = [];
|
||
let _csbArdSection = 'lib';
|
||
|
||
const _CAT_KO = {
|
||
'Communication': '통신',
|
||
'Data Processing': '데이터 처리',
|
||
'Data Storage': '데이터 저장',
|
||
'Device Control': '장치 제어',
|
||
'Display': '디스플레이',
|
||
'Encryption': '암호화',
|
||
'Imaging': '이미징',
|
||
'Other': '기타',
|
||
'Sensors': '센서',
|
||
'Signal Input/Output':'신호 입출력',
|
||
'Timing': '타이밍',
|
||
'Uncategorized': '미분류',
|
||
};
|
||
|
||
function csbArdSection(sec) {
|
||
_csbArdSection = sec;
|
||
['lib','board'].forEach(s => {
|
||
const btn = document.getElementById('csb-ardsec-' + s);
|
||
if (btn) btn.classList.toggle('active', s === sec);
|
||
const el = document.getElementById('csb-ard-' + s);
|
||
if (el) el.style.display = s === sec ? 'flex' : 'none';
|
||
});
|
||
if (sec === 'lib') csbLibLoad();
|
||
if (sec === 'board') csbBoardLoad();
|
||
}
|
||
|
||
function csbLibTab(tab) {
|
||
_csbLibCurrentTab = tab;
|
||
['installed','search'].forEach(t => {
|
||
const btn = document.getElementById('csb-libtab-' + t);
|
||
if (btn) btn.classList.toggle('active', t === tab);
|
||
});
|
||
const sr = document.getElementById('csb-lib-search-row');
|
||
if (sr) sr.style.display = tab === 'search' ? 'flex' : 'none';
|
||
const fr = document.getElementById('csb-lib-filter-row');
|
||
if (fr) fr.style.display = tab === 'installed' ? 'flex' : 'none';
|
||
document.getElementById('csb-lib-status').textContent = '';
|
||
if (tab === 'installed') csbLibLoad();
|
||
else _csbLibListEl().innerHTML = '<div style="padding:16px;color:var(--muted);font-size:11px;text-align:center">키워드를 입력하세요.</div>';
|
||
}
|
||
|
||
function csbLibFilter(mode) {
|
||
_csbLibFilterMode = mode;
|
||
['all','compat','incompat'].forEach(m => {
|
||
const btn = document.getElementById('csb-libf-' + m);
|
||
if (!btn) return;
|
||
const active = m === mode;
|
||
btn.style.background = active ? 'var(--brand)' : 'var(--panel-2)';
|
||
btn.style.color = active ? '#fff' : 'var(--muted)';
|
||
btn.style.borderColor = active ? 'var(--brand)' : 'var(--line)';
|
||
});
|
||
_csbLibRenderInstalled(_csbLibCache);
|
||
}
|
||
|
||
function csbLibView(mode) {
|
||
_csbLibViewMode = mode;
|
||
['flat','category'].forEach(m => {
|
||
const btn = document.getElementById('csb-libv-' + m);
|
||
if (!btn) return;
|
||
const active = m === mode;
|
||
btn.style.background = active ? 'var(--brand)' : 'var(--panel-2)';
|
||
btn.style.color = active ? '#fff' : 'var(--muted)';
|
||
btn.style.borderColor = active ? 'var(--brand)' : 'var(--line)';
|
||
});
|
||
_csbLibRenderInstalled(_csbLibCache);
|
||
}
|
||
|
||
function _csbLibSetArch(arch) {
|
||
_csbLibArchFilter = arch;
|
||
_csbLibUpdateArchChips(_csbLibCache);
|
||
_csbLibRenderInstalled(_csbLibCache);
|
||
}
|
||
|
||
function _csbLibUpdateArchChips(libs) {
|
||
const archRow = document.getElementById('csb-lib-arch-row');
|
||
if (!archRow) return;
|
||
// Count non-wildcard archs
|
||
const archCount = {};
|
||
for (const l of libs) {
|
||
for (const a of (l.architectures || [])) {
|
||
if (a !== '*') archCount[a] = (archCount[a] || 0) + 1;
|
||
}
|
||
}
|
||
// Show archs with ≥2 libs, sorted by count desc
|
||
const major = Object.entries(archCount).filter(([,c]) => c >= 2).sort((a,b) => b[1]-a[1]).map(([a]) => a);
|
||
if (!major.length) { archRow.style.display = 'none'; return; }
|
||
const all = ['', ...major];
|
||
archRow.innerHTML = all.map(a => {
|
||
const label = a ? a.toUpperCase() : '전체';
|
||
const cnt = a ? (archCount[a] || 0) : libs.length;
|
||
const active = _csbLibArchFilter === a;
|
||
return `<button onclick="_csbLibSetArch('${a}')" style="font-size:9px;padding:1px 6px;border-radius:3px;border:1px solid ${active?'var(--brand)':'var(--line)'};background:${active?'var(--brand)':'var(--panel-2)'};color:${active?'#fff':'var(--muted)'};cursor:pointer;font-family:inherit" title="${cnt}개">${label}</button>`;
|
||
}).join('');
|
||
archRow.style.display = 'flex';
|
||
}
|
||
|
||
function _csbLibListEl() { return document.getElementById('csb-lib-list'); }
|
||
function _csbLibStatus(msg) { const el = document.getElementById('csb-lib-status'); if (el) el.textContent = msg; }
|
||
function _csbEsc(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,'''); }
|
||
|
||
async function csbLibLoad() {
|
||
_csbLibListEl().innerHTML = '<div style="padding:16px;color:var(--muted);font-size:11px;text-align:center">불러오는 중...</div>';
|
||
try {
|
||
const fqbn = _csbSelBoard?.fqbn ? '&fqbn=' + encodeURIComponent(_csbSelBoard.fqbn) : '';
|
||
const r = await fetch('/api/arduino/libs?all=1' + fqbn, { headers: authHeaders() });
|
||
const data = await r.json();
|
||
_csbLibCache = data.libs || [];
|
||
_csbLibArchFilter = '';
|
||
_csbLibUpdateArchChips(_csbLibCache);
|
||
_csbLibRenderInstalled(_csbLibCache);
|
||
} catch { _csbLibListEl().innerHTML = '<div style="padding:16px;color:var(--muted);font-size:11px;text-align:center">오류</div>'; }
|
||
}
|
||
|
||
function _csbArchBadge(architectures) {
|
||
const arch = _csbSelBoard.fqbn.split(':')[1] || '';
|
||
const archs = architectures || [];
|
||
const isAll = archs.includes('*') || archs.length === 0;
|
||
const compat = isAll || archs.some(a => a === arch || a.startsWith(arch) || arch.startsWith(a));
|
||
if (isAll) return `<span style="font-size:9px;padding:1px 5px;border-radius:3px;background:#1a2a1a;color:#6ee7b7;border:1px solid #2d4a2d">모든 보드</span>`;
|
||
const label = archs.slice(0,3).join(', ') + (archs.length>3?'…':'');
|
||
const color = compat ? '#6ee7b7' : '#f87171';
|
||
const bg = compat ? '#1a2a1a' : '#2a1a1a';
|
||
const bd = compat ? '#2d4a2d' : '#4a2d2d';
|
||
const icon = compat ? '✓' : '✗';
|
||
return `<span style="font-size:9px;padding:1px 5px;border-radius:3px;background:${bg};color:${color};border:1px solid ${bd}" title="호환 아키텍처: ${archs.join(', ')}">${icon} ${_csbEsc(label)}</span>`;
|
||
}
|
||
|
||
function _csbIsCompat(l) {
|
||
const arch = _csbSelBoard.fqbn.split(':')[1] || '';
|
||
const archs = l.architectures || [];
|
||
return archs.includes('*') || archs.length === 0 || archs.some(a => a === arch || a.startsWith(arch) || arch.startsWith(a));
|
||
}
|
||
|
||
function _csbLibRenderInstalled(libs) {
|
||
const el = _csbLibListEl();
|
||
if (!libs.length) { el.innerHTML = '<div style="padding:16px;color:var(--muted);font-size:11px;text-align:center">설치된 라이브러리 없음</div>'; return; }
|
||
|
||
// Apply board compat filter
|
||
let filtered = libs;
|
||
if (_csbLibFilterMode === 'compat') filtered = libs.filter(l => _csbIsCompat(l));
|
||
if (_csbLibFilterMode === 'incompat') filtered = libs.filter(l => !_csbIsCompat(l));
|
||
|
||
// Apply architecture filter
|
||
if (_csbLibArchFilter) {
|
||
filtered = filtered.filter(l => {
|
||
const archs = l.architectures || [];
|
||
return archs.includes('*') || archs.length === 0 || archs.includes(_csbLibArchFilter) ||
|
||
archs.some(a => a.startsWith(_csbLibArchFilter) || _csbLibArchFilter.startsWith(a));
|
||
});
|
||
}
|
||
|
||
// Dedup by name when a specific arch or compat filter is active
|
||
// Priority: user-installed > platform, specific arch > wildcard *, newer version
|
||
if (_csbLibArchFilter || _csbLibFilterMode === 'compat') {
|
||
const byName = new Map();
|
||
for (const l of filtered) {
|
||
if (!byName.has(l.name)) { byName.set(l.name, []); }
|
||
byName.get(l.name).push(l);
|
||
}
|
||
filtered = [...byName.values()].map(group => group.reduce((best, l) => {
|
||
if (l.location === 'user' && best.location !== 'user') return l;
|
||
if (best.location === 'user' && l.location !== 'user') return best;
|
||
const lWild = !(l.architectures||[]).length || (l.architectures||[]).includes('*');
|
||
const bWild = !(best.architectures||[]).length || (best.architectures||[]).includes('*');
|
||
if (!lWild && bWild) return l;
|
||
if (lWild && !bWild) return best;
|
||
return (l.version||'') >= (best.version||'') ? l : best;
|
||
}));
|
||
}
|
||
|
||
// Sort: platform/core first, then compatible, then name
|
||
filtered = [...filtered].sort((a, b) => {
|
||
const pa = a.location === 'platform', pb = b.location === 'platform';
|
||
if (pa && !pb) return -1;
|
||
if (!pa && pb) return 1;
|
||
const ca = _csbIsCompat(a), cb = _csbIsCompat(b);
|
||
if (ca && !cb) return -1;
|
||
if (!ca && cb) return 1;
|
||
return a.name.localeCompare(b.name);
|
||
});
|
||
|
||
if (!filtered.length) {
|
||
const msg = _csbLibFilterMode === 'compat' ? `${_csbSelBoard.name}과 호환되는 라이브러리 없음`
|
||
: _csbLibArchFilter ? `${_csbLibArchFilter.toUpperCase()} 라이브러리 없음`
|
||
: '비호환 라이브러리 없음';
|
||
el.innerHTML = `<div style="padding:16px;color:var(--muted);font-size:11px;text-align:center">${msg}</div>`;
|
||
return;
|
||
}
|
||
|
||
if (_csbLibViewMode === 'category') { _csbLibRenderGrouped(filtered); return; }
|
||
|
||
const fqbn = _csbSelBoard?.fqbn || '';
|
||
el.innerHTML = _csbBuiltinCard() + filtered.map(l => _csbLibCard(l, fqbn)).join('');
|
||
}
|
||
|
||
function _csbLibCard(l, fqbn) {
|
||
const sid = 'csbex_' + l.name.replace(/\W/g,'_');
|
||
const archs = l.architectures || [];
|
||
const compat = _csbIsCompat(l);
|
||
const isPlatform = l.location === 'platform';
|
||
const incompatWarn = !compat ? `<div style="font-size:10px;color:#f87171;margin-top:3px">⚠ 현재 보드(${_csbEsc(_csbSelBoard.name)})와 호환되지 않을 수 있음</div>` : '';
|
||
const platformBadge = isPlatform ? `<span style="font-size:9px;padding:1px 5px;border-radius:3px;background:#1a2a3a;color:#60a5fa;border:1px solid #2d4a6a">코어</span>` : '';
|
||
const exRows = (l.examples||[]).map(ex =>
|
||
`<div style="display:flex;align-items:center;padding:3px 0;gap:6px">
|
||
<span style="font-size:10px;color:var(--muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">📄 ${_csbEsc(ex)}</span>
|
||
<button onclick="csbLibOpenExample('${_csbEsc(l.name)}','${_csbEsc(ex)}',${compat},'${_csbEsc(fqbn)}')"
|
||
style="font-size:10px;padding:2px 7px;background:none;border:1px solid var(--line);color:${compat?'var(--brand)':'#f87171'};border-radius:4px;cursor:pointer;font-family:inherit;white-space:nowrap"
|
||
title="${compat?'':'⚠ 비호환 예제 — 열면 AI가 자동으로 변환해드립니다'}">${compat?'열기':'열기 ⚠'}</button>
|
||
</div>`).join('');
|
||
const hasEx = l.examples?.length > 0;
|
||
return `<div style="padding:8px 10px;border-bottom:1px solid var(--line)${!compat?';border-left:2px solid #7f1d1d':''}">
|
||
<div style="display:flex;align-items:flex-start;gap:6px">
|
||
<div style="flex:1;min-width:0">
|
||
<div style="display:flex;align-items:center;gap:5px;flex-wrap:wrap">
|
||
<span style="font-size:11px;font-weight:700;color:var(--text)">${_csbEsc(l.name)}</span>
|
||
${platformBadge}${_csbArchBadge(archs)}
|
||
</div>
|
||
<div style="font-size:10px;color:var(--muted);margin-top:1px">${_csbEsc(l.author||'')} · v${_csbEsc(l.version||'?')}${l.category?' · '+_csbEsc(l.category):''}</div>
|
||
${l.sentence?`<div style="font-size:10px;color:var(--muted);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${_csbEsc(l.sentence)}</div>`:''}
|
||
${incompatWarn}
|
||
</div>
|
||
<div style="display:flex;gap:4px;flex-shrink:0">
|
||
${hasEx?`<button onclick="document.getElementById('${sid}').style.display=document.getElementById('${sid}').style.display==='none'?'block':'none'"
|
||
style="font-size:10px;padding:2px 7px;background:none;border:1px solid var(--line);color:var(--muted);border-radius:4px;cursor:pointer;font-family:inherit">예제</button>`:''}
|
||
${isPlatform?'':`<button onclick="csbLibUninstall('${_csbEsc(l.name)}')"
|
||
style="font-size:10px;padding:2px 7px;background:none;border:1px solid var(--line);color:#f87171;border-radius:4px;cursor:pointer;font-family:inherit">삭제</button>`}
|
||
</div>
|
||
</div>
|
||
${hasEx?`<div id="${sid}" style="display:none;margin-top:5px;padding:5px 8px;background:var(--panel-2);border-radius:5px">${exRows}</div>`:''}
|
||
</div>`;
|
||
}
|
||
|
||
function _csbLibRenderGrouped(libs) {
|
||
const el = _csbLibListEl();
|
||
const fqbn = _csbSelBoard?.fqbn || '';
|
||
// Group by category
|
||
const groups = {};
|
||
for (const l of libs) {
|
||
const cat = l.category || 'Uncategorized';
|
||
if (!groups[cat]) groups[cat] = [];
|
||
groups[cat].push(l);
|
||
}
|
||
// Sort categories: put Uncategorized/Other last
|
||
const last = new Set(['Uncategorized', 'Other', '']);
|
||
const sorted = Object.keys(groups).sort((a,b) => {
|
||
if (last.has(a) && !last.has(b)) return 1;
|
||
if (!last.has(a) && last.has(b)) return -1;
|
||
return a.localeCompare(b);
|
||
});
|
||
el.innerHTML = _csbBuiltinCard() + sorted.map(cat => {
|
||
const ko = _CAT_KO[cat] || cat;
|
||
const gid = 'csbg_' + cat.replace(/\W/g,'_');
|
||
const cards = groups[cat].map(l => _csbLibCard(l, fqbn)).join('');
|
||
const cnt = groups[cat].length;
|
||
return `<div>
|
||
<div onclick="const b=document.getElementById('${gid}');b.style.display=b.style.display==='none'?'block':'none'"
|
||
style="display:flex;align-items:center;gap:6px;padding:6px 10px;cursor:pointer;background:var(--panel-2);border-bottom:1px solid var(--line);position:sticky;top:0;z-index:1">
|
||
<span style="font-size:11px;font-weight:700;color:var(--text)">${_csbEsc(ko)}</span>
|
||
${cat !== ko ? `<span style="font-size:9px;color:var(--muted)">${_csbEsc(cat)}</span>` : ''}
|
||
<span style="font-size:10px;color:var(--muted);margin-left:auto">${cnt}개</span>
|
||
</div>
|
||
<div id="${gid}">${cards}</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
const _BUILTIN_EX = [
|
||
{ name:'Blink.ino', desc:'LED 깜빡이기', code:
|
||
`void setup() {
|
||
pinMode(LED_BUILTIN, OUTPUT);
|
||
}
|
||
|
||
void loop() {
|
||
digitalWrite(LED_BUILTIN, HIGH);
|
||
delay(1000);
|
||
digitalWrite(LED_BUILTIN, LOW);
|
||
delay(1000);
|
||
}` },
|
||
{ name:'Button.ino', desc:'버튼으로 LED 제어', code:
|
||
`const int BTN = 0;
|
||
const int LED = LED_BUILTIN;
|
||
|
||
void setup() {
|
||
pinMode(BTN, INPUT_PULLUP);
|
||
pinMode(LED, OUTPUT);
|
||
}
|
||
|
||
void loop() {
|
||
digitalWrite(LED, digitalRead(BTN) == LOW ? HIGH : LOW);
|
||
}` },
|
||
{ name:'AnalogRead.ino', desc:'아날로그 읽기', code:
|
||
`void setup() {
|
||
Serial.begin(115200);
|
||
}
|
||
|
||
void loop() {
|
||
int val = analogRead(A0);
|
||
Serial.println(val);
|
||
delay(200);
|
||
}` },
|
||
{ name:'Fade.ino', desc:'LED 밝기 조절 (PWM)', code:
|
||
`const int LED = LED_BUILTIN;
|
||
int brightness = 0, step = 5;
|
||
|
||
void setup() {
|
||
pinMode(LED, OUTPUT);
|
||
}
|
||
|
||
void loop() {
|
||
analogWrite(LED, brightness);
|
||
brightness += step;
|
||
if (brightness <= 0 || brightness >= 255) step = -step;
|
||
delay(15);
|
||
}` },
|
||
{ name:'BlinkWithoutDelay.ino', desc:'딜레이 없는 깜빡이기', code:
|
||
`const int LED = LED_BUILTIN;
|
||
bool state = false;
|
||
unsigned long prev = 0;
|
||
|
||
void setup() {
|
||
pinMode(LED, OUTPUT);
|
||
}
|
||
|
||
void loop() {
|
||
unsigned long now = millis();
|
||
if (now - prev >= 1000) {
|
||
prev = now;
|
||
state = !state;
|
||
digitalWrite(LED, state);
|
||
}
|
||
}` },
|
||
{ name:'SerialHello.ino', desc:'시리얼 출력', code:
|
||
`void setup() {
|
||
Serial.begin(115200);
|
||
Serial.println("Hello, World!");
|
||
}
|
||
|
||
void loop() {
|
||
if (Serial.available()) {
|
||
String s = Serial.readStringUntil('\\n');
|
||
Serial.print("받음: ");
|
||
Serial.println(s);
|
||
}
|
||
}` },
|
||
];
|
||
|
||
function csbLibOpenBuiltin(idx) {
|
||
const ex = _BUILTIN_EX[idx];
|
||
if (!ex) return;
|
||
codeNewFile(ex.name, ex.code);
|
||
setCodeSbTab('files');
|
||
}
|
||
|
||
function _csbBuiltinCard() {
|
||
const rows = _BUILTIN_EX.map((ex, i) =>
|
||
`<div style="display:flex;align-items:center;padding:3px 0;gap:6px">
|
||
<span style="font-size:10px;color:var(--muted);flex:1">📄 ${_csbEsc(ex.desc)}</span>
|
||
<button onclick="csbLibOpenBuiltin(${i})"
|
||
style="font-size:10px;padding:2px 7px;background:none;border:1px solid var(--line);color:var(--brand);border-radius:4px;cursor:pointer;font-family:inherit;white-space:nowrap">열기</button>
|
||
</div>`).join('');
|
||
return `<div style="padding:8px 10px;border-bottom:2px solid var(--line);border-left:3px solid #facc15">
|
||
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px">
|
||
<span style="font-size:11px;font-weight:700;color:var(--text)">Arduino 기본 예제</span>
|
||
<span style="font-size:9px;padding:1px 5px;border-radius:3px;background:#2a2010;color:#facc15;border:1px solid #4a3a10">기본</span>
|
||
</div>
|
||
<div style="margin-top:4px;padding:5px 8px;background:var(--panel-2);border-radius:5px">${rows}</div>
|
||
</div>`;
|
||
}
|
||
|
||
async function csbLibOpenExample(libName, exName, isCompat = true, fqbn = '') {
|
||
_csbLibStatus(`예제 로딩: ${exName}...`);
|
||
try {
|
||
const fqbnParam = (fqbn || _csbSelBoard?.fqbn || '');
|
||
const r = await fetch(`/api/arduino/libs/example?lib=${encodeURIComponent(libName)}&example=${encodeURIComponent(exName)}&fqbn=${encodeURIComponent(fqbnParam)}`, { headers: authHeaders() });
|
||
const data = await r.json();
|
||
if (data.error) { _csbLibStatus('오류: ' + data.error); return; }
|
||
_csbLibStatus('');
|
||
codeNewFile(data.name, data.code);
|
||
setCodeSbTab('files');
|
||
if (!isCompat) {
|
||
// Auto-send to AI for board adaptation
|
||
const hints = _boardSpecificHints(data.code);
|
||
const inp = document.getElementById('code-ai-input');
|
||
if (inp) {
|
||
inp.value = `이 예제는 ${libName} 라이브러리용이지만 현재 보드(${_csbSelBoard.name}, FQBN: ${_csbSelBoard.fqbn})와 호환되지 않을 수 있어.\n${hints}\n\nRead로 ${data.name}을 읽은 뒤 이 보드에서 동작하도록 코드를 수정해줘. 호환되지 않는 API는 이 보드에 맞는 방식으로 교체해.`;
|
||
codeAiSend();
|
||
}
|
||
}
|
||
} catch(e) { _csbLibStatus('오류: ' + e.message); }
|
||
}
|
||
|
||
async function csbLibSearch() {
|
||
const q = (document.getElementById('csb-lib-q')?.value || '').trim();
|
||
if (!q) return;
|
||
_csbLibListEl().innerHTML = '<div style="padding:16px;color:var(--muted);font-size:11px;text-align:center">검색 중...</div>';
|
||
_csbLibStatus('');
|
||
try {
|
||
const r = await fetch('/api/arduino/libs/search?q=' + encodeURIComponent(q), { headers: authHeaders() });
|
||
const data = await r.json();
|
||
_csbLibRenderSearch(data.libs || []);
|
||
} catch { _csbLibListEl().innerHTML = '<div style="padding:16px;color:var(--muted);font-size:11px;text-align:center">오류</div>'; }
|
||
}
|
||
|
||
function _csbLibRenderSearch(libs) {
|
||
const el = _csbLibListEl();
|
||
if (!libs.length) { el.innerHTML = '<div style="padding:16px;color:var(--muted);font-size:11px;text-align:center">검색 결과 없음</div>'; return; }
|
||
el.innerHTML = libs.map(l => `
|
||
<div style="padding:8px 10px;border-bottom:1px solid var(--line);display:flex;align-items:flex-start;gap:6px">
|
||
<div style="flex:1;min-width:0">
|
||
<div style="display:flex;align-items:center;gap:5px;flex-wrap:wrap">
|
||
<span style="font-size:11px;font-weight:700;color:var(--text)">${_csbEsc(l.name)}</span>
|
||
${l.category?`<span style="font-size:9px;padding:1px 5px;border:1px solid var(--line);border-radius:3px;color:var(--muted)">${_csbEsc(l.category)}</span>`:''}
|
||
${_csbArchBadge(l.architectures||[])}
|
||
</div>
|
||
<div style="font-size:10px;color:var(--muted);margin-top:1px">${_csbEsc(l.author||'')} · v${_csbEsc(l.version||'?')}</div>
|
||
${l.sentence?`<div style="font-size:10px;color:var(--muted);margin-top:2px">${_csbEsc(l.sentence)}</div>`:''}
|
||
</div>
|
||
<div style="flex-shrink:0;padding-top:2px">
|
||
${l.installed
|
||
? `<span style="font-size:10px;color:var(--brand);border:1px solid;border-radius:4px;padding:2px 6px">✅ 설치됨</span>`
|
||
: `<button onclick="csbLibInstall('${_csbEsc(l.name)}',this)"
|
||
style="font-size:10px;padding:3px 8px;background:var(--brand);color:#fff;border:none;border-radius:4px;cursor:pointer;font-family:inherit">설치</button>`}
|
||
</div>
|
||
</div>`).join('');
|
||
}
|
||
|
||
async function csbLibInstall(name, btn) {
|
||
_csbLibStatus(`설치 중: ${name}...`);
|
||
if (btn) { btn.disabled = true; btn.textContent = '...'; }
|
||
try {
|
||
const r = await fetch('/api/arduino/libs/install', {
|
||
method: 'POST', headers: { ...authHeaders(), 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name }),
|
||
});
|
||
const data = await r.json();
|
||
if (data.error) { _csbLibStatus('오류: ' + data.error.slice(0,100)); if(btn){btn.disabled=false;btn.textContent='설치';} return; }
|
||
_csbLibStatus(`✅ ${name} 설치 완료`);
|
||
if (btn) btn.outerHTML = `<span style="font-size:10px;color:var(--brand);border:1px solid;border-radius:4px;padding:2px 6px">✅ 설치됨</span>`;
|
||
} catch(e) { _csbLibStatus('오류: ' + e.message); if(btn){btn.disabled=false;btn.textContent='설치';} }
|
||
}
|
||
|
||
async function csbLibUninstall(name) {
|
||
if (!confirm(`"${name}" 을 삭제할까요?`)) return;
|
||
_csbLibStatus(`삭제 중: ${name}...`);
|
||
try {
|
||
const r = await fetch('/api/arduino/libs/uninstall', {
|
||
method: 'DELETE', headers: { ...authHeaders(), 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name }),
|
||
});
|
||
const data = await r.json();
|
||
if (data.error) { _csbLibStatus('오류: ' + data.error.slice(0,100)); return; }
|
||
_csbLibStatus(`✅ ${name} 삭제 완료`);
|
||
csbLibLoad();
|
||
} catch(e) { _csbLibStatus('오류: ' + e.message); }
|
||
}
|
||
|
||
// ── 보드 매니저 ───────────────────────────────────────────────────────────────
|
||
// ── 보드 선택기 ───────────────────────────────────────────────────────────────
|
||
const CSB_BOARD_DEFAULT = { fqbn: 'arduino:avr:uno', name: 'Arduino UNO' };
|
||
let _csbSelBoard = (() => {
|
||
try { return JSON.parse(localStorage.getItem('csb_selected_board') || 'null') || CSB_BOARD_DEFAULT; }
|
||
catch { return CSB_BOARD_DEFAULT; }
|
||
})();
|
||
let _csbSelAllBoards = [];
|
||
let _csbBoardLibs = []; // 현재 보드의 설치된 라이브러리 캐시
|
||
let _csbBoardLibsFqbn = ''; // 캐시된 fqbn
|
||
|
||
async function _csbFetchLibs(fqbn) {
|
||
if (!fqbn || fqbn.startsWith('lang:')) { _csbBoardLibs = []; _csbBoardLibsFqbn = fqbn; return; }
|
||
if (fqbn === _csbBoardLibsFqbn) return;
|
||
try {
|
||
const data = await api('/api/arduino/libs?fqbn=' + encodeURIComponent(fqbn));
|
||
_csbBoardLibs = (data.libs || []).map(l => l.name).filter(Boolean);
|
||
_csbBoardLibsFqbn = fqbn;
|
||
} catch { _csbBoardLibs = []; }
|
||
}
|
||
|
||
// 초기 로드 시 현재 보드 라이브러리 가져오기
|
||
_csbFetchLibs(_csbSelBoard.fqbn);
|
||
|
||
function _csbBoardSelSave(board) {
|
||
_csbSelBoard = board;
|
||
try { localStorage.setItem('csb_selected_board', JSON.stringify(board)); } catch {}
|
||
_csbFetchLibs(board.fqbn);
|
||
const el = document.getElementById('csb-board-sel-name');
|
||
if (el) el.textContent = board.name;
|
||
const badge = document.getElementById('csb-board-qemu-badge');
|
||
if (badge) {
|
||
if (board.qemu === 'esp8266-native') {
|
||
badge.textContent = '🖥 Native'; badge.style.display = 'inline';
|
||
badge.style.background = '#0c3a4a'; badge.style.color = '#67e8f9'; badge.style.borderColor = '#0e7490';
|
||
} else if (board.qemu === 'renode') {
|
||
badge.textContent = '🖥 Renode'; badge.style.display = 'inline';
|
||
badge.style.background = '#1a0a3a'; badge.style.color = '#c084fc'; badge.style.borderColor = '#7c3aed';
|
||
} else if (board.qemu) {
|
||
badge.textContent = '🖥 QEMU'; badge.style.display = 'inline';
|
||
badge.style.background = board.qemu === 'esp32s3' ? '#1a3a5a' : '#1a3a1a';
|
||
badge.style.color = board.qemu === 'esp32s3' ? '#60a5fa' : '#22c55e';
|
||
badge.style.borderColor = board.qemu === 'esp32s3' ? '#2a5a8a' : '#2a5a2a';
|
||
} else {
|
||
badge.style.display = 'none';
|
||
}
|
||
}
|
||
}
|
||
|
||
function csbBoardSelOpen() {
|
||
const dd = document.getElementById('csb-board-sel-dropdown');
|
||
if (!dd) return;
|
||
const open = dd.style.display !== 'none';
|
||
if (open) { dd.style.display = 'none'; return; }
|
||
// position:fixed + 여기서 좌표 계산 — 부모 패널이 overflow:hidden/overflow-y:auto라
|
||
// position:absolute로는 그 경계 밖으로 못 나가고 잘림(2026-07-21, "2칸만 보임" 리포트).
|
||
// 버튼 기준 뷰포트 좌표로 직접 배치해 어떤 조상의 overflow에도 안 걸리게 한다.
|
||
const btn = document.getElementById('csb-board-sel-display');
|
||
const rect = btn ? btn.getBoundingClientRect() : { left: 20, top: window.innerHeight - 40 };
|
||
const margin = 20;
|
||
dd.style.left = margin + 'px';
|
||
dd.style.width = `calc(100vw - ${margin * 2}px)`;
|
||
dd.style.bottom = (window.innerHeight - rect.top + 4) + 'px';
|
||
dd.style.maxHeight = 'min(70vh, 620px)';
|
||
dd.style.display = 'flex';
|
||
const list = document.getElementById('csb-board-sel-list');
|
||
if (list) list.style.maxHeight = 'min(60vh, 560px)';
|
||
const q = document.getElementById('csb-board-sel-q');
|
||
if (q) { q.value = ''; q.focus(); }
|
||
csbBoardSelSearch();
|
||
}
|
||
|
||
function csbBoardSelClose() {
|
||
const dd = document.getElementById('csb-board-sel-dropdown');
|
||
if (dd) dd.style.display = 'none';
|
||
}
|
||
|
||
const _LANG_BOARDS = [
|
||
{ fqbn: 'lang:python', name: '🐍 Python', desc: 'Python 스크립트' },
|
||
{ fqbn: 'lang:cpp', name: '⚡ C++ (일반)', desc: '표준 C++, Arduino 아님' },
|
||
{ fqbn: 'lang:other', name: '📝 기타 언어', desc: 'AI가 언어를 판단' },
|
||
];
|
||
|
||
async function csbBoardSelSearch() {
|
||
const q = (document.getElementById('csb-board-sel-q')?.value || '').trim();
|
||
const list = document.getElementById('csb-board-sel-list');
|
||
if (!list) return;
|
||
list.innerHTML = '<div style="grid-column:1/-1;padding:10px;color:var(--muted);font-size:11px;text-align:center">불러오는 중...</div>';
|
||
try {
|
||
// 언어 옵션 고정 섹션 — 그리드 레이아웃에서도 헤더/구분선은 전체 폭을 차지해야 하므로 grid-column:1/-1
|
||
const langFiltered = q ? _LANG_BOARDS.filter(b => b.name.includes(q) || b.desc.includes(q)) : _LANG_BOARDS;
|
||
const langHtml = langFiltered.length ? [
|
||
`<div style="grid-column:1/-1;padding:4px 10px 2px;font-size:9px;color:var(--muted);font-weight:600;letter-spacing:.05em;text-transform:uppercase">언어</div>`,
|
||
...langFiltered.map(b => {
|
||
const sel = b.fqbn === _csbSelBoard.fqbn;
|
||
return `<div data-fqbn="${_csbEsc(b.fqbn)}" data-name="${_csbEsc(b.name)}" data-qemu="" data-chip=""
|
||
style="padding:6px 10px;cursor:pointer;font-size:11px;border-bottom:1px solid var(--line);display:flex;flex-direction:column;gap:1px;${sel?'background:var(--brand);color:#fff':''}">
|
||
<span style="font-weight:600;${sel?'color:#fff':'color:var(--text)'}">${_csbEsc(b.name)}</span>
|
||
<span style="font-size:10px;${sel?'color:rgba(255,255,255,.7)':'color:var(--muted)'}">${b.desc}</span>
|
||
</div>`;
|
||
}),
|
||
`<div style="grid-column:1/-1;padding:4px 10px 2px;font-size:9px;color:var(--muted);font-weight:600;letter-spacing:.05em;text-transform:uppercase;border-top:1px solid var(--line)">보드</div>`,
|
||
].join('') : '';
|
||
|
||
const data = await api('/api/arduino/boards/listall?q=' + encodeURIComponent(q));
|
||
const boards = data.boards || [];
|
||
if (!boards.length && !langFiltered.length) { list.innerHTML = '<div style="grid-column:1/-1;padding:10px;color:var(--muted);font-size:11px;text-align:center">검색 결과 없음</div>'; return; }
|
||
list.innerHTML = langHtml + boards.map(b => {
|
||
const sel = b.fqbn === _csbSelBoard.fqbn;
|
||
const qemuBadge = b.qemu === 'esp8266-native'
|
||
? `<span style="font-size:9px;padding:1px 5px;border-radius:3px;margin-left:4px;background:#0c3a4a;color:#67e8f9;border:1px solid #0e7490">🖥 Native</span>`
|
||
: b.qemu === 'renode'
|
||
? `<span style="font-size:9px;padding:1px 5px;border-radius:3px;margin-left:4px;background:#1a0a3a;color:#c084fc;border:1px solid #7c3aed">🖥 Renode</span>`
|
||
: b.qemu
|
||
? `<span style="font-size:9px;padding:1px 5px;border-radius:3px;margin-left:4px;
|
||
background:${b.qemu==='esp32s3'?'#1a3a5a':'#1a3a1a'};
|
||
color:${b.qemu==='esp32s3'?'#60a5fa':'#22c55e'};
|
||
border:1px solid ${b.qemu==='esp32s3'?'#2a5a8a':'#2a5a2a'}">🖥 QEMU</span>`
|
||
: '';
|
||
const varBadge = (b.variants > 1)
|
||
? `<span style="font-size:9px;padding:1px 5px;border-radius:3px;margin-left:4px;background:var(--panel-2);color:var(--muted);border:1px solid var(--line)">+${b.variants-1}</span>`
|
||
: '';
|
||
return `<div data-fqbn="${_csbEsc(b.fqbn)}" data-name="${_csbEsc(b.name)}"
|
||
data-qemu="${b.qemu||''}" data-chip="${b.chip||''}"
|
||
style="padding:7px 10px;cursor:pointer;font-size:11px;border-bottom:1px solid var(--line);display:flex;flex-direction:column;gap:2px;${sel?'background:var(--brand);color:#fff':''}">
|
||
<span style="display:flex;align-items:center;flex-wrap:wrap;gap:0">
|
||
<span style="font-weight:600;${sel?'color:#fff':'color:var(--text)'}">${_csbEsc(b.name)}</span>
|
||
${sel?'':qemuBadge}${sel?'':varBadge}
|
||
</span>
|
||
<span style="font-size:10px;${sel?'color:rgba(255,255,255,.75)':'color:var(--muted)'}">${_csbEsc(b.fqbn)}</span>
|
||
</div>`;
|
||
}).join('');
|
||
list.querySelectorAll('[data-fqbn]').forEach(el => {
|
||
el.addEventListener('click', () => {
|
||
_csbBoardSelSave({ fqbn: el.dataset.fqbn, name: el.dataset.name,
|
||
qemu: el.dataset.qemu || false, chip: el.dataset.chip || '' });
|
||
csbBoardSelClose();
|
||
});
|
||
el.addEventListener('mouseenter', () => { if (el.dataset.fqbn !== _csbSelBoard.fqbn) el.style.background = 'var(--bg)'; });
|
||
el.addEventListener('mouseleave', () => { if (el.dataset.fqbn !== _csbSelBoard.fqbn) el.style.background = ''; });
|
||
});
|
||
} catch(e) { list.innerHTML = `<div style="grid-column:1/-1;padding:10px;color:var(--muted);font-size:11px;text-align:center">오류: ${e.message}</div>`; }
|
||
}
|
||
|
||
function csbBoardSelKey(e) {
|
||
if (e.key === 'Escape') csbBoardSelClose();
|
||
}
|
||
|
||
// 드롭다운 외부 클릭 시 닫기
|
||
document.addEventListener('click', e => {
|
||
const display = document.getElementById('csb-board-sel-display');
|
||
const dd = document.getElementById('csb-board-sel-dropdown');
|
||
if (dd && display && !display.contains(e.target) && !dd.contains(e.target))
|
||
dd.style.display = 'none';
|
||
});
|
||
|
||
// 초기 표시 업데이트
|
||
(function() {
|
||
const el = document.getElementById('csb-board-sel-name');
|
||
if (el) el.textContent = _csbSelBoard.name;
|
||
// 저장된 보드에 qemu 정보가 있으면 배지 표시
|
||
if (_csbSelBoard.qemu) _csbBoardSelSave(_csbSelBoard);
|
||
})();
|
||
|
||
let _csbBoardCurrentTab = 'installed';
|
||
|
||
function csbBoardTab(tab) {
|
||
_csbBoardCurrentTab = tab;
|
||
['installed','search'].forEach(t => {
|
||
const btn = document.getElementById('csb-boardtab-' + t);
|
||
if (btn) btn.classList.toggle('active', t === tab);
|
||
});
|
||
const sr = document.getElementById('csb-board-search-row');
|
||
if (sr) sr.style.display = tab === 'search' ? 'flex' : 'none';
|
||
document.getElementById('csb-board-status').textContent = '';
|
||
if (tab === 'installed') csbBoardLoad();
|
||
else csbBoardSearch();
|
||
}
|
||
|
||
function _csbBoardStatus(msg) { const el = document.getElementById('csb-board-status'); if (el) el.textContent = msg; }
|
||
|
||
async function csbBoardLoad() {
|
||
document.getElementById('csb-board-list').innerHTML = '<div style="padding:16px;color:var(--muted);font-size:11px;text-align:center">불러오는 중...</div>';
|
||
try {
|
||
const data = await api('/api/arduino/cores');
|
||
_csbBoardRenderInstalled(data.cores || []);
|
||
} catch(e) { document.getElementById('csb-board-list').innerHTML = `<div style="padding:16px;color:var(--muted);font-size:11px;text-align:center">오류: ${e.message}</div>`; }
|
||
}
|
||
|
||
function _csbBoardRenderInstalled(cores) {
|
||
const el = document.getElementById('csb-board-list');
|
||
if (!cores.length) { el.innerHTML = '<div style="padding:16px;color:var(--muted);font-size:11px;text-align:center">설치된 보드 없음</div>'; return; }
|
||
el.innerHTML = cores.map(c => {
|
||
const boardList = c.boards.slice(0,6).map(b => `<span style="font-size:10px;color:var(--muted);background:var(--panel-2);border:1px solid var(--line);border-radius:3px;padding:1px 5px">${_csbEsc(b)}</span>`).join(' ');
|
||
const more = c.boards.length > 6 ? `<span style="font-size:10px;color:var(--muted)">+${c.boards.length-6}개</span>` : '';
|
||
return `<div style="padding:8px 10px;border-bottom:1px solid var(--line)">
|
||
<div style="display:flex;align-items:flex-start;gap:6px">
|
||
<div style="flex:1;min-width:0">
|
||
<div style="font-size:11px;font-weight:700;color:var(--text)">${_csbEsc(c.name)}</div>
|
||
<div style="font-size:10px;color:var(--muted);margin-top:1px">${_csbEsc(c.id)} · v${_csbEsc(c.version)} · ${_csbEsc(c.maintainer)}</div>
|
||
<div style="display:flex;flex-wrap:wrap;gap:3px;margin-top:5px">${boardList}${more}</div>
|
||
</div>
|
||
<button onclick="csbBoardUninstall('${_csbEsc(c.id)}')"
|
||
style="font-size:10px;padding:2px 7px;background:none;border:1px solid var(--line);color:#f87171;border-radius:4px;cursor:pointer;font-family:inherit;flex-shrink:0">삭제</button>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
async function csbBoardSearch() {
|
||
const q = (document.getElementById('csb-board-q')?.value || '').trim();
|
||
document.getElementById('csb-board-list').innerHTML = '<div style="padding:16px;color:var(--muted);font-size:11px;text-align:center">검색 중...</div>';
|
||
_csbBoardStatus('');
|
||
try {
|
||
const data = await api('/api/arduino/cores/search?q=' + encodeURIComponent(q));
|
||
_csbBoardRenderSearch(data.cores || []);
|
||
} catch(e) { document.getElementById('csb-board-list').innerHTML = `<div style="padding:16px;color:var(--muted);font-size:11px;text-align:center">오류: ${e.message}</div>`; }
|
||
}
|
||
|
||
function _csbBoardRenderSearch(cores) {
|
||
const el = document.getElementById('csb-board-list');
|
||
if (!cores.length) { el.innerHTML = '<div style="padding:16px;color:var(--muted);font-size:11px;text-align:center">검색 결과 없음</div>'; return; }
|
||
el.innerHTML = cores.map(c => {
|
||
const boardList = c.boards.slice(0,4).map(b => `<span style="font-size:9px;color:var(--muted);background:var(--panel-2);border:1px solid var(--line);border-radius:3px;padding:1px 4px">${_csbEsc(b)}</span>`).join(' ');
|
||
return `<div style="padding:8px 10px;border-bottom:1px solid var(--line);display:flex;align-items:flex-start;gap:6px">
|
||
<div style="flex:1;min-width:0">
|
||
<div style="font-size:11px;font-weight:700;color:var(--text)">${_csbEsc(c.name)}</div>
|
||
<div style="font-size:10px;color:var(--muted);margin-top:1px">${_csbEsc(c.id)} · v${_csbEsc(c.version)}</div>
|
||
<div style="display:flex;flex-wrap:wrap;gap:3px;margin-top:4px">${boardList}${c.boards.length>4?`<span style="font-size:9px;color:var(--muted)">+${c.boards.length-4}개</span>`:''}</div>
|
||
</div>
|
||
<div style="flex-shrink:0;padding-top:2px">
|
||
${c.installed
|
||
? `<span style="font-size:10px;color:var(--brand);border:1px solid;border-radius:4px;padding:2px 6px">✅ 설치됨</span>`
|
||
: `<button onclick="csbBoardInstall('${_csbEsc(c.id)}',this)"
|
||
style="font-size:10px;padding:3px 8px;background:var(--brand);color:#fff;border:none;border-radius:4px;cursor:pointer;font-family:inherit">설치</button>`}
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
async function csbBoardInstall(id, btn) {
|
||
_csbBoardStatus(`설치 중: ${id} (수 분 소요)...`);
|
||
if (btn) { btn.disabled = true; btn.textContent = '...'; }
|
||
try {
|
||
const data = await api('/api/arduino/cores/install', { method: 'POST', body: JSON.stringify({ id }) });
|
||
if (data.error) { _csbBoardStatus('오류: ' + data.error.slice(0,150)); if(btn){btn.disabled=false;btn.textContent='설치';} return; }
|
||
_csbBoardStatus(`✅ ${id} 설치 완료`);
|
||
if (btn) btn.outerHTML = `<span style="font-size:10px;color:var(--brand);border:1px solid;border-radius:4px;padding:2px 6px">✅ 설치됨</span>`;
|
||
} catch(e) { _csbBoardStatus('오류: ' + e.message); if(btn){btn.disabled=false;btn.textContent='설치';} }
|
||
}
|
||
|
||
async function csbBoardUninstall(id) {
|
||
if (!confirm(`"${id}" 보드를 삭제할까요?`)) return;
|
||
_csbBoardStatus(`삭제 중: ${id}...`);
|
||
try {
|
||
const data = await api('/api/arduino/cores/uninstall', { method: 'DELETE', body: JSON.stringify({ id }) });
|
||
if (data.error) { _csbBoardStatus('오류: ' + data.error.slice(0,100)); return; }
|
||
_csbBoardStatus(`✅ ${id} 삭제 완료`);
|
||
csbBoardLoad();
|
||
} catch(e) { _csbBoardStatus('오류: ' + e.message); }
|
||
}
|
||
|
||
|
||
let _codeAiCtxMaxCached = 200000; // default until server responds
|
||
let _codeAiCtxMaxFetchedFor = ''; // model name the cache is valid for
|
||
// Real token usage from the last turn (server 'usage' SSE). When present, the gauge
|
||
// shows actual prompt tokens / real context window instead of the char/4 estimate.
|
||
let _codeAiLastUsage = null; // { promptTokens, ctxWindow }
|
||
|
||
function _codeAiCtxMax() { return _codeAiCtxMaxCached; }
|
||
|
||
// Called from streaming loops when a 'usage' SSE arrives.
|
||
function _codeAiOnUsage(ev) {
|
||
if (!ev || typeof ev.promptTokens !== 'number') return;
|
||
_codeAiLastUsage = { promptTokens: ev.promptTokens, ctxWindow: ev.ctxWindow || _codeAiCtxMax() };
|
||
try { updateSessionPanel(); } catch (_) {}
|
||
}
|
||
|
||
async function _fetchModelCtxMax(model) {
|
||
const key = model || '';
|
||
if (key === _codeAiCtxMaxFetchedFor) return; // already fetched for this model
|
||
_codeAiCtxMaxFetchedFor = key;
|
||
try {
|
||
const url = '/api/model-context' + (key ? `?model=${encodeURIComponent(key)}` : '');
|
||
const r = await fetch(url, { headers: authHeaders() });
|
||
if (!r.ok) return;
|
||
const d = await r.json();
|
||
if (d.provisional) {
|
||
// Guess, not a measurement — undo the once-per-model latch so the next call re-asks.
|
||
_codeAiCtxMaxFetchedFor = null;
|
||
return;
|
||
}
|
||
if (d.contextWindow) {
|
||
_codeAiCtxMaxCached = d.contextWindow;
|
||
updateSessionPanel();
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
|
||
|
||
function updateSessionPanel() {
|
||
const barProj = document.getElementById('code-ai-session-bar-project');
|
||
const barCount = document.getElementById('code-ai-session-bar-count');
|
||
const backBtn = document.getElementById('code-ai-back-btn');
|
||
if (barProj) barProj.textContent = activeProjectName || '(전체)';
|
||
if (barCount) barCount.textContent = codeAiHistory.length ? `${codeAiHistory.length}개` : '';
|
||
if (backBtn) backBtn.style.display = codeAiHistory.length ? '' : 'none';
|
||
|
||
// Context usage estimate
|
||
const ctxWrap = document.getElementById('code-ai-ctx-wrap');
|
||
const ctxLabel = document.getElementById('code-ai-ctx-label');
|
||
const ctxFill = document.getElementById('code-ai-ctx-fill');
|
||
if (ctxWrap && ctxLabel && ctxFill) {
|
||
// Fill = real prompt tokens from the last turn (fallback: char/4 estimate).
|
||
// Max = the model's NATIVE context_length (true ceiling), not the per-turn num_ctx.
|
||
const totalChars = codeAiHistory.reduce((s, m) => s + (m.content || '').length, 0);
|
||
const estTokens = _codeAiLastUsage ? _codeAiLastUsage.promptTokens : Math.round(totalChars / 4);
|
||
const maxTokens = _codeAiCtxMax();
|
||
const pct = Math.min(100, Math.round(estTokens / maxTokens * 100));
|
||
const kStr = estTokens >= 1000 ? `~${(estTokens/1000).toFixed(1)}k` : `${estTokens}`;
|
||
const maxKStr = maxTokens >= 1000 ? `${(maxTokens/1000).toFixed(0)}k` : `${maxTokens}`;
|
||
ctxLabel.textContent = `${kStr} / ${maxKStr}`;
|
||
ctxFill.style.width = pct + '%';
|
||
ctxFill.style.background = pct >= 80 ? '#ef4444' : pct >= 50 ? '#f59e0b' : '#22c55e';
|
||
ctxWrap.style.display = 'flex';
|
||
}
|
||
}
|
||
|
||
async function codeSbRefresh() {
|
||
const tree = document.getElementById('csb-file-tree');
|
||
if (!tree) return;
|
||
tree.innerHTML = '<div style="padding:12px;color:var(--muted);font-size:11px;text-align:center">로딩 중...</div>';
|
||
if (!localDirHandle) {
|
||
tree.innerHTML = `<div style="padding:14px 10px;font-size:11px;color:var(--muted);text-align:center">
|
||
<div style="font-size:22px;margin-bottom:6px">🖥️</div>
|
||
<div style="margin-bottom:10px">로컬 폴더를 선택하세요</div>
|
||
<button onclick="openLocalFolder()" style="background:var(--brand);color:#fff;border:none;border-radius:6px;padding:6px 14px;cursor:pointer;font-size:11px;font-weight:700">📁 폴더 열기</button>
|
||
</div>`;
|
||
return;
|
||
}
|
||
try {
|
||
const allFiles = await localListFiles(localDirHandle, '');
|
||
|
||
// Build project list: dirs that contain code files + all known projects (persisted in localStorage)
|
||
const projectSet = new Set(allFiles.filter(f => f.includes('/')).map(f => f.split('/')[0]));
|
||
_knownProjectsGet().forEach(p => projectSet.add(p));
|
||
if (activeProjectName) projectSet.add(activeProjectName);
|
||
const projects = [...projectSet].sort();
|
||
csbRenderProjectSelector(projects);
|
||
|
||
const files = activeProjectName
|
||
? allFiles.filter(f => f.startsWith(activeProjectName + '/'))
|
||
: allFiles;
|
||
|
||
if (!files.length) {
|
||
const emptyMsg = activeProjectName
|
||
? `<div style="padding:10px;font-size:11px;color:var(--muted)">
|
||
<div style="font-weight:600;color:var(--text);margin-bottom:4px">📁 ${escHtml(activeProjectName)}/</div>
|
||
<div style="padding-left:12px;color:var(--muted)">비어 있음 — + 버튼으로 파일 추가</div>
|
||
</div>`
|
||
: '<div style="padding:12px;color:var(--muted);font-size:11px;text-align:center">파일 없음<br><small>+ 버튼으로 추가</small></div>';
|
||
tree.innerHTML = emptyMsg;
|
||
return;
|
||
}
|
||
const extIcon = { py:'🐍', js:'⚡', ts:'🔷', cpp:'⚙️', c:'⚙️', cs:'💜',
|
||
go:'🔵', rs:'🦀', java:'☕', html:'🌐', css:'🎨', sh:'💻', bash:'💻',
|
||
kt:'🎯', swift:'🍎', rb:'💎', php:'🐘', sql:'🗃️', md:'📝', json:'📋',
|
||
yaml:'📋', yml:'📋', txt:'📄' };
|
||
|
||
// 트리 노드 구조 빌드 (leaf = 항상 전체 경로)
|
||
const root = {};
|
||
for (const f of files) {
|
||
const parts = f.split('/');
|
||
let node = root;
|
||
for (let i = 0; i < parts.length - 1; i++) {
|
||
if (!node[parts[i]]) node[parts[i]] = {};
|
||
node = node[parts[i]];
|
||
}
|
||
node[parts[parts.length - 1]] = f; // leaf = full path string
|
||
}
|
||
|
||
// 프로젝트 선택 시: 해당 서브트리를 루트로, parentPath를 프로젝트명으로 시작
|
||
const displayRoot = (activeProjectName && root[activeProjectName]) ? root[activeProjectName] : root;
|
||
const startPath = activeProjectName || '';
|
||
|
||
const openDirs = csbGetOpenDirs();
|
||
function renderNode(node, depth, parentPath) {
|
||
const indent = depth * 14;
|
||
let html = '';
|
||
// 디렉토리 먼저, 파일 나중
|
||
const dirs = [], leafs = [];
|
||
for (const [k, v] of Object.entries(node)) {
|
||
if (typeof v === 'object') dirs.push([k, v]);
|
||
else leafs.push([k, v]);
|
||
}
|
||
dirs.sort((a,b) => a[0].localeCompare(b[0]));
|
||
leafs.sort((a,b) => a[0].localeCompare(b[0]));
|
||
for (const [name, children] of dirs) {
|
||
const dirId = 'csbdir_' + (parentPath+'/'+name).replace(/[^a-z0-9]/gi,'_');
|
||
const isOpen = openDirs.has(dirId);
|
||
const dirPath = (parentPath+'/'+name).replace(/^\//,'');
|
||
const safeDirPath = dirPath.replace(/\\/g,'\\\\').replace(/'/g,"\\'");
|
||
html += `<div class="csb-file-item" data-dirpath="${escHtml(dirPath)}" style="padding-left:${indent+8}px;font-weight:600;color:var(--muted)"
|
||
onclick="csbToggleDir('${dirId}')"
|
||
oncontextmenu="csbCtxMenu(event,this,true)"
|
||
ondragover="event.preventDefault();event.dataTransfer.dropEffect='move';this.classList.add('csb-drop-target')"
|
||
ondragleave="this.classList.remove('csb-drop-target')"
|
||
ondrop="csbDrop(event,this)">
|
||
<span id="${dirId}_arrow" style="font-size:9px;transition:.15s;flex-shrink:0">${isOpen ? '▼' : '▶'}</span><span style="flex-shrink:0">📂</span>
|
||
<span class="csb-item-name" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1">${escHtml(name)}/</span>
|
||
<button class="csb-add-btn" onclick="csbNewInDir(this.closest('[data-dirpath]').dataset.dirpath,event)" title="새 파일">+</button>
|
||
<button class="csb-del" onclick="csbDeleteDir('${safeDirPath}',event)" title="디렉토리 삭제">✕</button>
|
||
</div>
|
||
<div id="${dirId}" style="display:${isOpen ? 'block' : 'none'}">
|
||
${renderNode(children, depth+1, parentPath+'/'+name)}
|
||
</div>`;
|
||
}
|
||
for (const [name, fullPath] of leafs) {
|
||
const ext = name.split('.').pop().toLowerCase();
|
||
const icon = extIcon[ext] || '📄';
|
||
const safe = fullPath.replace(/\\/g,'\\\\').replace(/'/g,"\\'");
|
||
html += `<div class="csb-file-item" data-path="${escHtml(fullPath)}" style="padding-left:${indent+8}px"
|
||
draggable="true"
|
||
ondragstart="event.dataTransfer.setData('text/plain',this.dataset.path);event.dataTransfer.effectAllowed='move'"
|
||
oncontextmenu="csbCtxMenu(event,this,false)"
|
||
onclick="csbOpenFile('${safe}')">
|
||
<span style="flex-shrink:0">${icon}</span>
|
||
<span class="csb-item-name" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1" title="${escHtml(fullPath)}">${escHtml(name)}</span>
|
||
<button class="csb-del" onclick="csbDeleteFile('${safe}',event)" title="삭제">✕</button>
|
||
</div>`;
|
||
}
|
||
return html;
|
||
}
|
||
|
||
tree.innerHTML = renderNode(displayRoot, 0, startPath);
|
||
} catch {
|
||
tree.innerHTML = '<div style="padding:12px;color:#f87171;font-size:11px;text-align:center">불러오기 실패</div>';
|
||
}
|
||
}
|
||
|
||
function csbGetOpenDirs() {
|
||
try { return new Set(JSON.parse(localStorage.getItem('csb_open_dirs') || '[]')); } catch { return new Set(); }
|
||
}
|
||
function csbSaveOpenDirs(set) {
|
||
try { localStorage.setItem('csb_open_dirs', JSON.stringify([...set])); } catch {}
|
||
}
|
||
function csbToggleDir(id) {
|
||
const el = document.getElementById(id);
|
||
const arrow = document.getElementById(id + '_arrow');
|
||
if (!el) return;
|
||
const collapsed = el.style.display === 'none';
|
||
el.style.display = collapsed ? 'block' : 'none';
|
||
if (arrow) arrow.textContent = collapsed ? '▼' : '▶';
|
||
const dirs = csbGetOpenDirs();
|
||
if (collapsed) dirs.add(id); else dirs.delete(id);
|
||
csbSaveOpenDirs(dirs);
|
||
}
|
||
|
||
async function csbOpenFile(filename) {
|
||
if (!localDirHandle) return;
|
||
try {
|
||
const content = await localReadFile(filename);
|
||
if (content === null) return;
|
||
// In client-local+project mode, tabs use bare names; sidebar paths include the project prefix
|
||
const bare = _clientBareFileName(filename);
|
||
const existing = codeFiles.find(f => f.name === bare);
|
||
if (existing) switchCodeFile(existing.id);
|
||
else codeNewFile(bare, content);
|
||
} catch {}
|
||
}
|
||
|
||
async function csbDeleteFile(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);
|
||
codeSbRefresh();
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
async function csbDeleteDir(dirPath, ev) {
|
||
ev.stopPropagation();
|
||
if (!localDirHandle || !confirm(`"${dirPath}" 디렉토리와 내부 파일을 모두 삭제할까요?`)) return;
|
||
try {
|
||
const parts = dirPath.split('/').filter(Boolean);
|
||
let parent = localDirHandle;
|
||
for (let i = 0; i < parts.length - 1; i++) parent = await parent.getDirectoryHandle(parts[i]);
|
||
await parent.removeEntry(parts[parts.length - 1], { recursive: true });
|
||
// 해당 디렉토리 아래 열려 있는 탭 모두 닫기
|
||
const prefix = dirPath.endsWith('/') ? dirPath : dirPath + '/';
|
||
for (const f of [...codeFiles]) {
|
||
if (f.name === dirPath || f.name.startsWith(prefix)) closeCodeFile(f.id);
|
||
}
|
||
codeSbRefresh();
|
||
} catch (e) {
|
||
alert('삭제 실패: ' + e.message);
|
||
}
|
||
}
|
||
|
||
// ── Context menu ─────────────────────────────────────────────────────────────
|
||
let _csbCtxTarget = null;
|
||
|
||
function csbCtxMenu(ev, el, isDir) {
|
||
ev.preventDefault(); ev.stopPropagation();
|
||
_csbCtxTarget = { path: el.dataset.path || el.dataset.dirpath, isDir };
|
||
const menu = document.getElementById('csb-ctx');
|
||
if (!menu) return;
|
||
const items = isDir ? [
|
||
{ label: '📄 새 파일', action: 'newfile' },
|
||
{ label: '📁 새 폴더', action: 'newfolder' },
|
||
{ label: '✏️ 이름 바꾸기', action: 'rename' },
|
||
{ label: '🗑️ 삭제', action: 'delete' },
|
||
] : [
|
||
{ label: '✏️ 이름 바꾸기', action: 'rename' },
|
||
{ label: '🗑️ 삭제', action: 'delete' },
|
||
];
|
||
menu.innerHTML = items.map(i => `<div class="csb-ctx-item" onclick="csbCtxAction('${i.action}')">${i.label}</div>`).join('');
|
||
menu.style.display = 'block';
|
||
const x = Math.min(ev.clientX, window.innerWidth - 160);
|
||
const y = Math.min(ev.clientY, window.innerHeight - (items.length * 34) - 16);
|
||
menu.style.left = x + 'px';
|
||
menu.style.top = y + 'px';
|
||
}
|
||
function csbCtxClose() {
|
||
const menu = document.getElementById('csb-ctx');
|
||
if (menu) menu.style.display = 'none';
|
||
_csbCtxTarget = null;
|
||
}
|
||
function csbCtxAction(action) {
|
||
const target = _csbCtxTarget;
|
||
csbCtxClose();
|
||
if (!target) return;
|
||
const { path, isDir } = target;
|
||
if (action === 'rename') csbInlineRename(path, isDir);
|
||
else if (action === 'delete') isDir ? csbDeleteDir(path, { stopPropagation: () => {} }) : csbDeleteFile(path, { stopPropagation: () => {} });
|
||
else if (action === 'newfile') csbNewInDir(path, null);
|
||
else if (action === 'newfolder') csbNewFolderAt(path);
|
||
}
|
||
|
||
// ── Inline rename ─────────────────────────────────────────────────────────────
|
||
function csbInlineRename(path, isDir) {
|
||
const tree = document.getElementById('csb-file-tree');
|
||
if (!tree) return;
|
||
const attr = isDir ? 'data-dirpath' : 'data-path';
|
||
const item = tree.querySelector(`[${attr}="${path.replace(/\\/g,'\\\\').replace(/"/g,'\\"')}"]`);
|
||
if (!item) return;
|
||
const nameEl = item.querySelector('.csb-item-name');
|
||
if (!nameEl) return;
|
||
const oldBase = path.split('/').pop();
|
||
const inp = document.createElement('input');
|
||
inp.value = oldBase;
|
||
inp.style.cssText = 'flex:1;min-width:0;font-size:11px;padding:1px 4px;background:var(--panel);color:var(--text);border:1px solid var(--brand);border-radius:3px;outline:none;font-family:inherit';
|
||
nameEl.replaceWith(inp);
|
||
inp.focus(); inp.select();
|
||
let done = false;
|
||
const commit = async () => {
|
||
if (done) return; done = true;
|
||
const newName = inp.value.trim();
|
||
if (newName && newName !== oldBase) await csbRenameExec(path, newName, isDir);
|
||
else await codeSbRefresh();
|
||
};
|
||
inp.onblur = commit;
|
||
inp.onkeydown = e => {
|
||
if (e.key === 'Enter') { e.preventDefault(); commit(); }
|
||
if (e.key === 'Escape') { done = true; codeSbRefresh(); }
|
||
};
|
||
}
|
||
|
||
async function csbRenameExec(oldPath, newName, isDir) {
|
||
if (!localDirHandle) return;
|
||
const parts = oldPath.split('/');
|
||
parts[parts.length - 1] = newName;
|
||
const newPath = parts.join('/');
|
||
try {
|
||
if (!isDir) {
|
||
const content = await localReadFile(oldPath);
|
||
if (content === null) throw new Error('읽기 실패');
|
||
await localWriteFile(newPath, content);
|
||
await localDeleteFile(oldPath);
|
||
const bare = _clientBareFileName(oldPath);
|
||
const tab = codeFiles.find(f => f.name === bare);
|
||
if (tab) { tab.name = _clientBareFileName(newPath); renderCodeFileTabs(); }
|
||
} else {
|
||
const allFiles = await localListFiles(localDirHandle, '');
|
||
const prefix = oldPath + '/';
|
||
for (const f of allFiles.filter(f => f.startsWith(prefix))) {
|
||
const content = await localReadFile(f);
|
||
if (content !== null) await localWriteFile(newPath + '/' + f.slice(prefix.length), content);
|
||
}
|
||
const ps = oldPath.split('/');
|
||
let parent = localDirHandle;
|
||
for (let i = 0; i < ps.length - 1; i++) parent = await parent.getDirectoryHandle(ps[i]);
|
||
await parent.removeEntry(ps[ps.length - 1], { recursive: true });
|
||
for (const f of codeFiles) {
|
||
const disk = _clientProjectPath(f.name);
|
||
if (disk.startsWith(prefix)) f.name = _clientBareFileName(newPath + '/' + disk.slice(prefix.length));
|
||
}
|
||
renderCodeFileTabs();
|
||
}
|
||
await codeSbRefresh();
|
||
} catch (e) { alert('이름 바꾸기 실패: ' + (e.message || e)); await codeSbRefresh(); }
|
||
}
|
||
|
||
// ── New file/folder helpers ───────────────────────────────────────────────────
|
||
async function csbNewInDir(dirPath, ev) {
|
||
if (ev) ev.stopPropagation();
|
||
if (!localDirHandle) return;
|
||
const name = prompt(`새 파일 이름 (${dirPath.split('/').pop()}/ 에 생성):`);
|
||
if (!name?.trim()) return;
|
||
const fullPath = dirPath + '/' + name.trim();
|
||
await localWriteFile(fullPath, '');
|
||
codeNewFile(_clientBareFileName(fullPath), '');
|
||
codeSbRefresh();
|
||
}
|
||
|
||
async function csbNewFolderAt(parentPath, ev) {
|
||
if (ev) ev.stopPropagation();
|
||
if (!localDirHandle) return;
|
||
const name = prompt(`새 폴더 이름 (${parentPath.split('/').pop()}/ 안에 생성):`);
|
||
if (!name?.trim()) return;
|
||
await localWriteFile(parentPath + '/' + name.trim() + '/.gitkeep', '');
|
||
codeSbRefresh();
|
||
}
|
||
|
||
async function csbNewFolderRoot() {
|
||
if (!localDirHandle) return;
|
||
const base = activeProjectName ? activeProjectName + '/' : '';
|
||
const name = prompt('새 폴더 이름:');
|
||
if (!name?.trim()) return;
|
||
await localWriteFile(base + name.trim() + '/.gitkeep', '');
|
||
codeSbRefresh();
|
||
}
|
||
|
||
// ── Drag and drop ─────────────────────────────────────────────────────────────
|
||
function csbDrop(ev, el) {
|
||
ev.preventDefault(); ev.stopPropagation();
|
||
el.classList.remove('csb-drop-target');
|
||
const srcPath = ev.dataTransfer.getData('text/plain');
|
||
const dirPath = el.dataset.dirpath;
|
||
if (!srcPath || !dirPath) return;
|
||
const dstPath = dirPath + '/' + srcPath.split('/').pop();
|
||
if (srcPath === dstPath) return;
|
||
csbMoveFile(srcPath, dstPath);
|
||
}
|
||
|
||
async function csbMoveFile(srcPath, dstPath) {
|
||
if (!localDirHandle) return;
|
||
try {
|
||
const content = await localReadFile(srcPath);
|
||
if (content === null) { alert(`읽기 실패: ${srcPath}`); return; }
|
||
await localWriteFile(dstPath, content);
|
||
await localDeleteFile(srcPath);
|
||
const tab = codeFiles.find(f => f.name === _clientBareFileName(srcPath));
|
||
if (tab) { tab.name = _clientBareFileName(dstPath); renderCodeFileTabs(); }
|
||
await codeSbRefresh();
|
||
} catch (e) { alert('이동 실패: ' + (e.message || e)); }
|
||
}
|
||
|
||
async function csbLoadModels() {
|
||
const sel = document.getElementById('csb-model-select');
|
||
if (!sel) return;
|
||
try {
|
||
const [settingRes, ollamaRes] = await Promise.all([
|
||
fetch('/api/settings/model', { headers: authHeaders() }),
|
||
fetch('/api/ollama/models', { headers: authHeaders() }),
|
||
]);
|
||
const setting = settingRes.ok ? await settingRes.json() : {};
|
||
const ollamaData = ollamaRes.ok ? await ollamaRes.json() : {};
|
||
const primaryModel = setting.primary || '설정 모델';
|
||
const ollamaModels = (ollamaData.models || []).map(m => m.name);
|
||
sel.innerHTML = `<option value="">기본 (${primaryModel})</option>`;
|
||
const allModels = [...ollamaModels];
|
||
if (codeAiModel && !allModels.includes(codeAiModel)) allModels.unshift(codeAiModel);
|
||
for (const m of allModels) {
|
||
const opt = document.createElement('option');
|
||
opt.value = m; opt.textContent = m;
|
||
if (m === codeAiModel) opt.selected = true;
|
||
sel.appendChild(opt);
|
||
}
|
||
if (!codeAiModel) sel.value = '';
|
||
updateCodeAiLabel();
|
||
} catch {}
|
||
}
|
||
|
||
function csbModelChanged() {
|
||
const sel = document.getElementById('csb-model-select');
|
||
codeAiModel = sel ? sel.value : '';
|
||
localStorage.setItem('codeAiModel', codeAiModel);
|
||
updateCodeAiLabel();
|
||
_codeAiCtxMaxFetchedFor = ''; // invalidate cache so next fetch runs
|
||
_fetchModelCtxMax(codeAiModel);
|
||
}
|
||
|
||
function csbFontSize(val) {
|
||
document.getElementById('csb-fontsize-label').textContent = val;
|
||
if (monacoEditor) monacoEditor.updateOptions({ fontSize: parseInt(val) });
|
||
localStorage.setItem('codeEditorFontSize', val);
|
||
}
|
||
|
||
function csbWordWrap(checked) {
|
||
if (monacoEditor) monacoEditor.updateOptions({ wordWrap: checked ? 'on' : 'off' });
|
||
localStorage.setItem('codeEditorWordWrap', checked ? '1' : '0');
|
||
}
|
||
|
||
function csbFontWeight(val) {
|
||
localStorage.setItem('codeEditorFontWeight', val);
|
||
if (monacoEditor) monacoEditor.updateOptions({ fontWeight: val });
|
||
}
|
||
|
||
function csbKarpathy(checked) {
|
||
_karpathyMode = !!checked;
|
||
localStorage.setItem('codeKarpathyMode', _karpathyMode ? '1' : '0');
|
||
const btn = document.getElementById('code-opt-karpathy');
|
||
if (btn) btn.classList.toggle('active', _karpathyMode);
|
||
}
|
||
|
||
// ── Chat image attach (paste / drag-drop) ────────────────────────────────────
|
||
function _codeAiAddImage(file) {
|
||
if (!file || !file.type.startsWith('image/')) return;
|
||
const reader = new FileReader();
|
||
reader.onload = e => {
|
||
_pendingImages.push({ dataUrl: e.target.result, mimeType: file.type });
|
||
_codeAiRenderImagePreview();
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
|
||
function _codeAiPasteImage(e) {
|
||
const items = e.clipboardData?.items;
|
||
if (!items) return;
|
||
for (const item of items) {
|
||
if (item.kind === 'file' && item.type.startsWith('image/')) {
|
||
e.preventDefault();
|
||
_codeAiAddImage(item.getAsFile());
|
||
}
|
||
}
|
||
}
|
||
|
||
function _codeAiDropImage(e) {
|
||
e.preventDefault();
|
||
document.getElementById('code-ai-input-row')?.classList.remove('drag-over');
|
||
for (const file of (e.dataTransfer?.files || [])) _codeAiAddImage(file);
|
||
}
|
||
|
||
function _codeAiRenderImagePreview() {
|
||
const wrap = document.getElementById('code-ai-img-preview');
|
||
if (!wrap) return;
|
||
if (!_pendingImages.length) { wrap.style.display = 'none'; wrap.innerHTML = ''; return; }
|
||
wrap.style.display = 'flex';
|
||
wrap.innerHTML = '';
|
||
_pendingImages.forEach((img, i) => {
|
||
const thumb = document.createElement('div');
|
||
thumb.className = 'code-ai-img-thumb';
|
||
thumb.innerHTML = `<img src="${img.dataUrl}" style="max-width:80px;max-height:60px;border-radius:4px;display:block"><button onclick="_codeAiRemoveImage(${i})" title="제거" style="position:absolute;top:-4px;right:-4px;background:#ef4444;color:#fff;border:none;border-radius:50%;width:16px;height:16px;font-size:10px;cursor:pointer;line-height:1;padding:0">×</button>`;
|
||
wrap.appendChild(thumb);
|
||
});
|
||
}
|
||
|
||
function _codeAiRemoveImage(i) {
|
||
_pendingImages.splice(i, 1);
|
||
_codeAiRenderImagePreview();
|
||
}
|
||
|
||
let _hideBlankLines = false;
|
||
function csbHideBlankLines(checked) {
|
||
_hideBlankLines = !!checked;
|
||
localStorage.setItem('codeEditorHideBlank', _hideBlankLines ? '1' : '0');
|
||
const btn = document.getElementById('code-opt-hideblank');
|
||
if (btn) btn.classList.toggle('active', _hideBlankLines);
|
||
_applyHideBlankLines();
|
||
}
|
||
function _applyHideBlankLines() {
|
||
if (!monacoEditor) return;
|
||
if (!_hideBlankLines) { try { monacoEditor.setHiddenAreas([]); } catch {} return; }
|
||
const model = monacoEditor.getModel();
|
||
if (!model) return;
|
||
const n = model.getLineCount();
|
||
const ranges = [];
|
||
let start = -1;
|
||
let inBlock = false, blockEnd = '';
|
||
for (let i = 1; i <= n; i++) {
|
||
const l = model.getLineContent(i).trim();
|
||
let hide = false;
|
||
if (inBlock) {
|
||
hide = true;
|
||
if (l.includes(blockEnd)) inBlock = false;
|
||
} else if (l === '') {
|
||
hide = true;
|
||
} else if (/^(\/\/|--|;)/.test(l) || /^#(?!include|define|ifdef|ifndef|endif|else|elif|pragma|undef|error|warning)/.test(l)) {
|
||
hide = true;
|
||
} else if (l.startsWith('/*')) {
|
||
hide = true;
|
||
if (!l.slice(2).includes('*/')) { inBlock = true; blockEnd = '*/'; }
|
||
} else if (l.startsWith('<!--')) {
|
||
hide = true;
|
||
if (!l.slice(4).includes('-->')) { 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 =>
|
||
`<div class="code-theme-row">
|
||
<span class="code-theme-row-lbl">${g.icon} ${g.label}</span>
|
||
${g.themes.map(t =>
|
||
`<div class="code-skin-sw${t.v===cur?' active':''}" data-v="${t.v}" title="${t.label}"
|
||
style="background:${t.bg}" onclick="csbTheme('${t.v}')"></div>`
|
||
).join('')}
|
||
</div>`
|
||
).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('homeclaw_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 = '<div style="padding:8px 12px;color:var(--muted);font-size:11px">로딩 중...</div>';
|
||
try {
|
||
const files = localDirHandle ? await localListFiles(localDirHandle, '') : [];
|
||
if (!files.length) {
|
||
list.innerHTML = '<div style="padding:8px 12px;color:var(--muted);font-size:11px">파일 없음</div>';
|
||
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 `<div class="code-folder-item">
|
||
<span style="flex:1;display:flex;align-items:center;gap:7px;overflow:hidden;min-width:0"
|
||
onclick="codeOpenFromFolder('${safe}')">
|
||
${icon} <span style="overflow:hidden;text-overflow:ellipsis">${escHtml(f)}</span>
|
||
</span>
|
||
<button class="code-folder-del" onclick="codeDeleteFromFolder('${safe}',event)" title="삭제">✕</button>
|
||
</div>`;
|
||
}).join('');
|
||
} catch {
|
||
list.innerHTML = '<div style="padding:8px 12px;color:#f00;font-size:11px">불러오기 실패</div>';
|
||
}
|
||
}
|
||
|
||
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]) => `<div style="display:flex;gap:10px;font-size:13px"><span style="color:#907050;min-width:40px">${k}</span><span style="color:#c8a060;word-break:break-all">${v}</span></div>`).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 = '<div style="font-size:12px;font-weight:700;color:#907050;margin-bottom:8px;text-transform:uppercase;letter-spacing:.05em">보드 옵션</div>' +
|
||
_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 => `<option value="${v.value}"${v.value === sel ? ' selected' : ''}>${v.label}</option>`).join('');
|
||
const optId = JSON.stringify(o.id), defVal = o.values.find(v => v.selected)?.value ?? o.values[0]?.value ?? '';
|
||
return `<div style="display:flex;align-items:center;gap:8px;padding:4px 0"><span style="color:#907050;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px" title="${o.label}">${o.label}</span><select style="background:#3a2818;border:1px solid #5a3820;color:#c8a060;border-radius:4px;padding:3px 6px;font-size:12px;font-family:'IBM Plex Mono',monospace;flex:1.4" onchange="if(this.value!==this.dataset.def)_cupSelOptions[${optId}]=this.value;else delete _cupSelOptions[${optId}];_cupUpdateInfoRows()" data-def="${defVal}">${opts}</select></div>`;
|
||
}).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+<WiFi\.h>/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', '다운로드됨');
|
||
}
|
||
|
||
/**
|
||
* Toggle the OTA target bar.
|
||
*
|
||
* OTA cannot run in the browser the way the serial paths do — esptool-js talks over Web Serial,
|
||
* but pushing firmware to a board over WiFi is an espota handshake to the device, which a page
|
||
* has no way to open. So this one upload path goes through the server.
|
||
*/
|
||
function cupToggleOta() {
|
||
const bar = document.getElementById('cup-ota-bar');
|
||
const on = bar.style.display === 'none' || !bar.style.display;
|
||
bar.style.display = on ? 'flex' : 'none';
|
||
const btn = document.getElementById('cup-btn-ota');
|
||
btn.style.background = on ? '#e89040' : '#3a2818';
|
||
btn.style.color = on ? '#241a0e' : '#e89040';
|
||
// Serial upload needs a chosen port; OTA needs an IP instead, so the button must not stay
|
||
// disabled just because no serial port was picked.
|
||
if (on) document.getElementById('cup-btn-upload').disabled = false;
|
||
else document.getElementById('cup-btn-upload').disabled = !_cupPort;
|
||
cupLog(on ? '📡 OTA 모드 — 보드 IP와 암호를 넣고 ⚡ 업로드' : '📡 OTA 모드 해제', 'info');
|
||
}
|
||
|
||
function _cupOtaTarget() {
|
||
const bar = document.getElementById('cup-ota-bar');
|
||
if (!bar || bar.style.display === 'none' || !bar.style.display) return null;
|
||
const ip = (document.getElementById('cup-ota-ip').value || '').trim();
|
||
return ip ? { ip, password: document.getElementById('cup-ota-pw').value || '' } : null;
|
||
}
|
||
|
||
/** Compile + push over WiFi, both on the server (see /api/arduino/upload). */
|
||
async function _cupUploadOTA(target) {
|
||
_cupBusy = true;
|
||
document.getElementById('cup-btn-upload').disabled = true;
|
||
document.getElementById('cup-btn-download').disabled = true;
|
||
document.getElementById('cup-tab-log').innerHTML = '';
|
||
// espota reports progress on the server's stdout, which we only see when it finishes, so the
|
||
// bar is indeterminate rather than faked.
|
||
_cupSetProgress(null);
|
||
_cupSetStatus('uploading', 'OTA 업로드 중...');
|
||
cupLog(`📡 OTA 업로드 [${_cupBoardName}] → ${target.ip}`, 'prog');
|
||
cupLog('서버에서 컴파일 후 무선 전송합니다. 30초 이상 걸릴 수 있습니다.', 'info');
|
||
try {
|
||
const r = await fetch('/api/arduino/upload', {
|
||
method: 'POST', credentials: 'include',
|
||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||
body: JSON.stringify({
|
||
code: _cupCode, fqbn: _cupGetEffectiveFqbn(),
|
||
port: target.ip, otaPassword: target.password,
|
||
}),
|
||
});
|
||
const data = await r.json();
|
||
if (data.error) { cupLog('❌ ' + data.error, 'err'); _cupSetStatus('error', 'OTA 실패'); }
|
||
else { cupLog(data.output || '완료', 'ok'); _cupSetStatus('done', '완료 ✓'); }
|
||
} catch (e) {
|
||
cupLog('❌ 오류: ' + e.message, 'err'); _cupSetStatus('error', 'OTA 실패');
|
||
} finally {
|
||
_cupSetProgress(null);
|
||
_cupBusy = false;
|
||
document.getElementById('cup-btn-upload').disabled = false;
|
||
document.getElementById('cup-btn-download').disabled = false;
|
||
}
|
||
}
|
||
|
||
async function cupDoUpload() {
|
||
if (_cupBusy) return;
|
||
const ota = _cupOtaTarget();
|
||
if (ota) { cupTab('log'); await _cupUploadOTA(ota); return; }
|
||
if (document.getElementById('cup-ota-bar')?.style.display === 'flex') {
|
||
cupLog('보드 IP를 입력하세요 (예: 192.168.0.42)', 'err'); 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('/js/code-editor/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<t.length-1; i+=2) bytes.push(parseInt(t.substr(i,2),16));
|
||
const len=bytes[0], addr=(bytes[1]<<8)|bytes[2], type=bytes[3];
|
||
if (type===1) break; if (type===4) { extAddr=((bytes[4]<<8)|bytes[5])<<16; continue; } if (type!==0) continue;
|
||
const fullAddr = extAddr+addr;
|
||
for (let i=0; i<len; i++) { const a=fullAddr+i, pg=a>>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 (pos<n) {
|
||
if (Date.now()>deadline) 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; i<binData.length; i+=CHUNK) {
|
||
const raw=binData.slice(i,i+CHUNK), padded=new Uint8Array(CHUNK).fill(0xff); padded.set(raw);
|
||
await w([0x31,0xCE]); await waitAck(5000); await w(addrPkt(START+i)); await waitAck(5000);
|
||
let ck=CHUNK-1; for(const b of padded)ck^=b;
|
||
const pkt=new Uint8Array(CHUNK+2); pkt[0]=CHUNK-1; pkt.set(padded,1); pkt[CHUNK+1]=ck;
|
||
await w(pkt); await waitAck(10000);
|
||
const blk=Math.floor(i/CHUNK)+1; _cupSetProgress(20+Math.round(blk/total*75));
|
||
document.getElementById('cup-port-name').textContent = `업로드 중... ${blk}/${total} 블록`;
|
||
}
|
||
_cupSetProgress(97); cupLog('실행 중...', 'prog');
|
||
await w([0x21,0xDE]); await waitAck(5000); await w(addrPkt(START)); try{await waitAck(2000);}catch{}
|
||
_cupSetProgress(100); cupLog('✅ 업로드 완료! BOOT0=GND 후 RESET 하세요.', '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{} }
|
||
}
|
||
|
||
// ── 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);
|
||
});
|
||
});
|
||
|
||
// USB 실기기 panel resize drag
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
const drag = document.getElementById('android-usb-drag');
|
||
const panel = document.getElementById('android-usb-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(90,168,232,.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();
|
||
})();
|