- 코드 에디터 저장: 서버 API → File System Access API로 전환 - codeSaveFileById에 저장 성공/실패/폴더미선택 피드백 메시지 추가 - 프로젝트 생성 형식: project-files JSON → @@FILE/@@ENDFILE 델리미터로 변경 - JSON 이스케이프 오류(Unterminated string) 근본 해결 - 계획 단계 시스템 프롬프트 강화: JSON 출력 절대 금지 명시 - 프로젝트 생성 세션 ID: 계획 세션 재사용 (서버가 history 파라미터 무시하므로) - _projFileCreated async화: 파일 쓰기 완료 후 사이드바 리프레시 - 헤더/드롭다운 폴더명 표시 동적 업데이트 (workspace/code/ 하드코딩 제거) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3159 lines
140 KiB
JavaScript
3159 lines
140 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') || ''; } catch { return ''; } }
|
||
function setAuthToken(t) { try { t ? sessionStorage.setItem('smallclaw_token', t) : 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 = '/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 = [];
|
||
let codeAiAbort = null;
|
||
let codeAiSessionId = 'code_ai_' + Math.random().toString(36).slice(2, 10);
|
||
let codeAiModel = localStorage.getItem('codeAiModel') ?? 'glm-5.1:cloud';
|
||
let codeAiMode = localStorage.getItem('codeAiMode') ?? 'code'; // 'code' or 'ask'
|
||
let codeDiffState = {}; // { fileId: { originalText, modifiedText, hunks, decorationIds, viewZoneIds, resolved, rejected, onReject } }
|
||
|
||
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 title = document.getElementById('code-ai-title');
|
||
if (title) title.textContent = '💻 Coder-' + label;
|
||
const input = document.getElementById('code-ai-input');
|
||
if (input) input.placeholder = (codeAiMode === 'ask' ? 'Ask-' : 'Coder-') + label + '에게 질문... (예: 게임 만들어줘)';
|
||
}
|
||
|
||
function setCodeAiMode(mode) {
|
||
codeAiMode = mode;
|
||
localStorage.setItem('codeAiMode', mode);
|
||
const codeBtn = document.getElementById('code-ai-mode-code');
|
||
const askBtn = document.getElementById('code-ai-mode-ask');
|
||
if (codeBtn) codeBtn.classList.toggle('active', mode === 'code');
|
||
if (askBtn) askBtn.classList.toggle('active', mode === 'ask');
|
||
updateCodeAiLabel();
|
||
}
|
||
|
||
function initCodeView() {
|
||
updateCodeAiLabel();
|
||
setCodeAiMode(codeAiMode); // Initialize mode selector UI
|
||
if (monacoReady) { setTimeout(() => { if (monacoEditor) monacoEditor.layout(); }, 50); return; }
|
||
if (typeof require === 'undefined') return;
|
||
require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/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: 12,
|
||
lineHeight: 19,
|
||
fontFamily: "'IBM Plex Mono', 'Fira Code', 'Courier New', monospace",
|
||
minimap: { enabled: true },
|
||
scrollBeyondLastLine: false,
|
||
wordWrap: 'on',
|
||
renderWhitespace: 'selection',
|
||
smoothScrolling: true,
|
||
cursorBlinking: 'smooth',
|
||
tabSize: 2,
|
||
});
|
||
monacoEditor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, codeSaveFile);
|
||
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) {
|
||
const fname = name || prompt('파일 이름:', 'untitled.py');
|
||
if (!fname || !fname.trim()) 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.trim(), 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();
|
||
}
|
||
|
||
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();
|
||
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();
|
||
} 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 ? folderName : '폴더 없음';
|
||
const dd = document.getElementById('code-dropdown-dir');
|
||
if (dd) dd.textContent = localDirHandle ? `📁 ${folderName}` : '📁 폴더 없음';
|
||
}
|
||
|
||
async function localListFiles(dirHandle, prefix) {
|
||
const files = [];
|
||
try {
|
||
for await (const [name, handle] of dirHandle.entries()) {
|
||
if (name.startsWith('.')) continue;
|
||
const rel = prefix ? `${prefix}/${name}` : name;
|
||
if (handle.kind === 'directory') 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; }
|
||
}
|
||
|
||
// ── Companion WebSocket (local Python execution) ───────────────────────────
|
||
let companionWs = null;
|
||
let companionConnected = false;
|
||
const COMPANION_URL = 'ws://127.0.0.1:9000';
|
||
let companionShellId = null; // code terminal shell ID backed by companion PTY
|
||
const localShellIds = new Set(); // shell IDs routed to companion instead of server PTY
|
||
|
||
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; updateCompanionStatus(true); };
|
||
companionWs.onclose = () => {
|
||
companionConnected = false; companionWs = null;
|
||
companionShellId = null; // 재연결 시 이전 셸 상태 초기화
|
||
updateCompanionStatus(false);
|
||
setTimeout(companionConnect, 5000);
|
||
};
|
||
companionWs.onerror = () => { companionConnected = false; };
|
||
companionWs.onmessage = (ev) => {
|
||
let msg; try { msg = JSON.parse(ev.data); } catch { return; }
|
||
handleCompanionMessage(msg);
|
||
};
|
||
}
|
||
|
||
function updateCompanionStatus(connected) {
|
||
const dot = document.getElementById('companion-dot');
|
||
if (!dot) return;
|
||
dot.style.background = connected ? '#22c55e' : '#6b7280';
|
||
dot.title = connected ? '🐍 Python companion 연결됨' : '🐍 Python companion 미연결 (companion.py 실행 필요) — 클릭해서 재연결';
|
||
const lsBtn = document.getElementById('local-shell-btn');
|
||
if (lsBtn) lsBtn.style.color = connected ? '#22c55e' : '#6b7280';
|
||
}
|
||
|
||
function handleCompanionMessage(msg) {
|
||
// shell_* messages → local shell tab; run messages → active code terminal
|
||
const isShellMsg = msg.type === 'shell_output' || msg.type === 'shell_ready' || msg.type === 'shell_exit';
|
||
const targetId = isShellMsg && companionShellId ? companionShellId : 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') s.term.write('\x1b[32m🖥️ 로컬 셸\x1b[0m\r\n');
|
||
else if (msg.type === 'shell_output') s.term.write(msg.data);
|
||
else if (msg.type === 'shell_exit') { s.term.write(`\r\n\x1b[33m[셸 종료 ${msg.code}]\x1b[0m\r\n`); companionShellId = null; }
|
||
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'));
|
||
else if (msg.type === 'exit') s.term.write(`\r\n\x1b[33m[exit ${msg.code}]\x1b[0m\r\n`);
|
||
else if (msg.type === 'error') s.term.write(`\r\n\x1b[31m오류: ${msg.data}\x1b[0m\r\n`);
|
||
else if (msg.type === 'killed') s.term.write(`\r\n\x1b[31m[강제 종료]\x1b[0m\r\n`);
|
||
}, codeTermOpen ? 0 : 500);
|
||
}
|
||
|
||
function companionKill() {
|
||
if (companionWs && companionConnected) companionWs.send(JSON.stringify({ type: 'kill' }));
|
||
}
|
||
|
||
function csbCodeDirChanged(val) {}
|
||
function csbCodeDirSave() {}
|
||
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(file.name, file.content);
|
||
codeAiAppend('system', ok ? `💾 저장: ${file.name}` : '⚠️ 저장 실패');
|
||
}
|
||
|
||
// ── 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); }
|
||
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'); } }
|
||
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:'#0C0C0C', 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();
|
||
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', 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', 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.style.color = '#22c55e';
|
||
if (!codeTermShells.length) { addCodeTermShell(); }
|
||
else { setTimeout(() => { const s = codeTermShells.find(s => s.id === activeCodeTermShellId); if (s?.fitAddon) s.fitAddon.fit(); if (s?.term) s.term.focus(); }, 50); }
|
||
} else {
|
||
panel.style.display = 'none';
|
||
if (btn) btn.style.color = '#6fcf8a';
|
||
}
|
||
if (monacoEditor) setTimeout(() => monacoEditor.layout(), 60);
|
||
}
|
||
function renderCodeTermTabs() {
|
||
const bar = document.getElementById('code-term-tabs'); if (!bar) return;
|
||
bar.innerHTML = codeTermShells.map(s => {
|
||
const a = s.id === activeCodeTermShellId;
|
||
const label = s.local ? '🖥️ 로컬' : 'bash';
|
||
return `<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() {
|
||
if (!companionConnected) {
|
||
alert('companion.py가 실행되지 않았습니다.\n\n터미널에서: python companion.py');
|
||
return;
|
||
}
|
||
const id = 'cdt_' + Math.random().toString(36).slice(2, 8);
|
||
codeTermShells.push({ id, name: '로컬', local: true, ptyReady: false, term: null, fitAddon: null });
|
||
localShellIds.add(id);
|
||
if (companionShellId) companionKill();
|
||
companionShellId = id;
|
||
if (!codeTermOpen) toggleCodeTerminal();
|
||
switchCodeTermShell(id);
|
||
setTimeout(() => {
|
||
const shell = codeTermShells.find(s => s.id === id);
|
||
if (companionConnected && companionWs && shell?.term) {
|
||
companionWs.send(JSON.stringify({ type: 'shell', rows: shell.term.rows, cols: shell.term.cols }));
|
||
}
|
||
}, 300);
|
||
}
|
||
function addCodeTermShell() {
|
||
const id = 'cdt_' + Math.random().toString(36).slice(2,8);
|
||
codeTermShells.push({ id, name:'bash', ptyReady:false, term:null, fitAddon:null });
|
||
switchCodeTermShell(id);
|
||
if (localTermPath) {
|
||
setTimeout(() => {
|
||
const s = codeTermShells.find(s => s.id === id);
|
||
if (s && s.ptyReady) termWsSend({ type: 'pty_input', shellId: id, data: `cd ${JSON.stringify(localTermPath)}\r` });
|
||
else setTimeout(() => {
|
||
const s2 = codeTermShells.find(s => s.id === id);
|
||
if (s2 && s2.ptyReady) termWsSend({ type: 'pty_input', shellId: id, data: `cd ${JSON.stringify(localTermPath)}\r` });
|
||
}, 800);
|
||
}, 400);
|
||
}
|
||
}
|
||
|
||
function csbTermPathSave() {
|
||
const inp = document.getElementById('csb-termpath');
|
||
localTermPath = (inp?.value || '').trim();
|
||
localStorage.setItem('code_local_term_path', localTermPath);
|
||
codeAiAppend('system', localTermPath ? `✅ 터미널 경로 설정: ${localTermPath}` : '터미널 경로 초기화됨');
|
||
}
|
||
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 (companionShellId === id) { companionKill(); companionShellId = null; }
|
||
} 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('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('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 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=''; };
|
||
drag.addEventListener('mouseover', ()=>drag.style.background='rgba(34,197,94,.4)');
|
||
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');
|
||
}
|
||
})();
|
||
|
||
// Ctrl+` shortcut
|
||
document.addEventListener('keydown', e => {
|
||
if (e.ctrlKey && e.key === '`') { e.preventDefault(); if (document.getElementById('code-view')?.classList.contains('active')) 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: 'terminal', icon: '⌨', label: 'python3', desc: '서버 터미널에서 실행' },
|
||
],
|
||
js: [
|
||
{ id: 'terminal', icon: '⌨', label: 'node', desc: '터미널에서 실행' },
|
||
],
|
||
ts: [
|
||
{ 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: '터미널에서 실행' },
|
||
],
|
||
};
|
||
|
||
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);
|
||
}
|
||
|
||
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();
|
||
const mime = ext === 'html' || ext === 'htm' ? 'text/html' : ext === 'css' ? 'text/css' : '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 (!codeTermOpen) toggleCodeTerminal();
|
||
setTimeout(() => {
|
||
companionWs.send(JSON.stringify({ type: 'run', code: file.content, filename: file.name }));
|
||
}, codeTermOpen ? 0 : 500);
|
||
return;
|
||
}
|
||
|
||
if (mode === 'pyodide') {
|
||
sessionStorage.setItem('py_run_code', file.content);
|
||
sessionStorage.setItem('py_run_name', file.name);
|
||
const w = window.open('/python-runner.html', '_blank');
|
||
if (!w) codeAiAppend('system', '⚠️ 팝업 차단됨 — 브라우저에서 팝업을 허용해주세요');
|
||
return;
|
||
}
|
||
|
||
// 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 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);
|
||
_monacoThrottleModel = null;
|
||
}
|
||
}, 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.
|
||
// Try to find the filename from the diff header or filename comment.
|
||
// A diff must target an existing file — prefer the file we sent as context
|
||
// (the active tab), falling back to a name match. Deriving a name from the
|
||
// diff body text picked the wrong/new file, so the diff never hit the open
|
||
// file ("diff가 동일 파일에서 일어나지 않음").
|
||
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 {
|
||
// 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
|
||
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 resultLines = [];
|
||
|
||
let origIdx = 0;
|
||
for (const line of diffLines) {
|
||
const tag = line[0];
|
||
// Strip exactly ONE marker char. Stripping the conventional "+ " as two
|
||
// chars ate a leading space off indented additions → off-by-one indentation.
|
||
if (tag === '+') {
|
||
resultLines.push(line.slice(1));
|
||
} else if (tag === '-') {
|
||
origIdx++; // Skip original line
|
||
} else if (tag === ' ') {
|
||
// Context line. Accept any space-prefixed line, but only when it actually
|
||
// matches the next original (whitespace-insensitively) — guards against
|
||
// bare indented code being mistaken for context. Emit the ORIGINAL text so
|
||
// the real indentation is preserved.
|
||
const content = line.slice(1);
|
||
if (origIdx < origLines.length && origLines[origIdx].trim() === content.trim()) {
|
||
resultLines.push(origLines[origIdx]);
|
||
origIdx++;
|
||
}
|
||
// else: skip — indented code mistaken for context
|
||
}
|
||
// All other lines (no prefix, headers, comments) are ignored
|
||
}
|
||
|
||
// Append any remaining original lines
|
||
while (origIdx < origLines.length) {
|
||
resultLines.push(origLines[origIdx++]);
|
||
}
|
||
|
||
return resultLines.length > 0 ? resultLines.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 _projMode = false; // 파일 생성 진행 중
|
||
let _projEl = null; // 진행 표시용 DOM 요소
|
||
let _projFileCount = 0; // 생성된 파일 수
|
||
let _projPhase = null; // 'plan' | null
|
||
let _projHistory = []; // 계획 대화 히스토리
|
||
let _projSessionId = null; // 계획 세션 ID
|
||
|
||
const _PROJ_PLAN_SYSTEM = `당신은 소프트웨어 프로젝트 설계 도우미입니다.
|
||
사용자가 원하는 프로젝트를 분석하고 아래 마크다운 형식으로 답하세요.
|
||
|
||
🔧 기술 스택: 언어, 라이브러리, 프레임워크 나열
|
||
|
||
📁 파일 구조:
|
||
- 파일명: 역할 설명
|
||
(5~10개)
|
||
|
||
✨ 핵심 기능:
|
||
1. 기능 설명
|
||
(3~5가지)
|
||
|
||
중요: JSON 출력 절대 금지. 코드 블록 출력 금지. 코드 생성 금지.
|
||
반드시 위의 마크다운 텍스트 형식만 사용하세요.
|
||
|
||
답변 마지막에 반드시 이 한 줄을 추가하세요:
|
||
진행하려면 "만들어줘" 라고 입력하거나, 수정 사항을 알려주세요.`;
|
||
|
||
const _PROJ_GEN_SYSTEM = `당신은 새 프로젝트 파일을 생성하는 어시스턴트입니다.
|
||
도구를 사용하지 말고, 반드시 아래 형식으로 각 파일을 출력하세요. 다른 텍스트 없음.
|
||
|
||
@@FILE: 프로젝트명/파일명.확장자
|
||
파일 내용 (이스케이프 불필요, 있는 그대로)
|
||
@@ENDFILE
|
||
|
||
예시:
|
||
@@FILE: tetris-game/index.html
|
||
<!DOCTYPE html>
|
||
<html lang="ko">
|
||
<head><title>테트리스</title></head>
|
||
<body>...</body>
|
||
</html>
|
||
@@ENDFILE
|
||
|
||
@@FILE: tetris-game/README.md
|
||
## 실행 방법
|
||
index.html을 브라우저에서 열어주세요.
|
||
@@ENDFILE
|
||
|
||
규칙:
|
||
- path: 영문 슬러그 폴더명/파일명 (예: tetris-game/index.html)
|
||
- 실행 진입점 필수 (main.py / index.html / app.py 등)
|
||
- README.md 필수 (마지막)
|
||
- 파일 내용에 @@FILE: 또는 @@ENDFILE 문자열 사용 금지
|
||
- 각 파일에 완전한 동작 코드
|
||
- 웹 게임(테트리스, 뱀, 퍼즐 등)은 index.html 단일 파일로 (모든 CSS/JS를 <style>/<script>에 인라인). 별도 .js/.css 파일 금지`;
|
||
|
||
function _isProjConfirm(msg) {
|
||
return /^(만들어|만들어줘|만들어주세요|진행|진행해|진행해줘|좋아|좋아요|ok|ㅇㅋ|ㅇㅇ|응|예|네|yes|그래|그래줘|go|시작|시작해|시작해줘)[\s!.]*$/i.test(msg.trim());
|
||
}
|
||
|
||
function _isNewProjectIntent(msg) {
|
||
if (!msg || msg.length > 200) return false;
|
||
const m = msg;
|
||
const hasCreate = /만들(어|자|어줘|어주|고\s*싶|어\s*주|어보자|어봐|어봐줘)|짜(줘|자|봐|봐줘)|개발(해|하자|해줘|해봐)|작성(해|하자|해줘)|생성(해|하자|해줘)|구현(해|하자|해줘)|코딩(해|해줘)|제작(해|하자|해줘)/.test(m);
|
||
const hasProject = /게임|앱|app\b|프로그램|프로젝트|웹사이트|웹\s*앱|서버|봇|bot\b|스크립트|도구|툴|플러그인|시스템|대시보드|크롤러|계산기|타이머|채팅|클론|api\s*서버/.test(m);
|
||
return hasCreate && hasProject;
|
||
}
|
||
|
||
function _projStartGenerate() {
|
||
_projMode = true;
|
||
_projFileCount = 0;
|
||
|
||
const el = codeAiAppend('assistant', '');
|
||
el.classList.remove('streaming');
|
||
_projEl = el;
|
||
|
||
const hdr = document.createElement('div');
|
||
hdr.style.cssText = 'font-weight:600;color:var(--brand,#7c3aed);margin-bottom:8px;font-size:13px;';
|
||
hdr.textContent = '🔨 파일 생성 중...';
|
||
el.appendChild(hdr);
|
||
el._hdr = hdr;
|
||
|
||
const list = document.createElement('div');
|
||
list.style.cssText = 'font-size:11px;font-family:monospace;';
|
||
el.appendChild(list);
|
||
el._list = list;
|
||
codeAiScrollBottom();
|
||
}
|
||
|
||
async function _projFileCreated(filename, content) {
|
||
if (!_projEl) return;
|
||
_projFileCount++;
|
||
|
||
const row = document.createElement('div');
|
||
row.style.cssText = 'padding:2px 0;display:flex;align-items:center;gap:6px;';
|
||
|
||
const statusSpan = document.createElement('span');
|
||
statusSpan.textContent = '✅';
|
||
statusSpan.style.flexShrink = '0';
|
||
|
||
const pathSpan = document.createElement('span');
|
||
pathSpan.style.cssText = 'color:var(--text,#ccc);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
|
||
pathSpan.textContent = filename;
|
||
|
||
row.appendChild(statusSpan);
|
||
row.appendChild(pathSpan);
|
||
|
||
if (content?.trim()) {
|
||
const toggleBtn = document.createElement('button');
|
||
toggleBtn.style.cssText = 'background:transparent;border:1px solid var(--border,#333);color:var(--muted,#888);border-radius:3px;padding:0 6px;font-size:10px;cursor:pointer;flex-shrink:0;line-height:18px;';
|
||
toggleBtn.textContent = '보기';
|
||
row.appendChild(toggleBtn);
|
||
|
||
const preview = document.createElement('div');
|
||
preview.style.cssText = 'display:none;background:var(--panel-2,#1e1e2e);border:1px solid var(--border,#333);border-radius:5px;padding:8px;margin:3px 0 3px 22px;max-height:180px;overflow-y:auto;';
|
||
const pre = document.createElement('pre');
|
||
pre.style.cssText = 'margin:0;font-size:11px;white-space:pre-wrap;word-break:break-all;color:var(--text,#ccc);';
|
||
pre.textContent = content;
|
||
preview.appendChild(pre);
|
||
toggleBtn.onclick = () => {
|
||
const hidden = preview.style.display === 'none';
|
||
preview.style.display = hidden ? 'block' : 'none';
|
||
toggleBtn.textContent = hidden ? '닫기' : '보기';
|
||
};
|
||
_projEl._list.appendChild(row);
|
||
_projEl._list.appendChild(preview);
|
||
} else {
|
||
_projEl._list.appendChild(row);
|
||
}
|
||
|
||
// 로컬 폴더에 저장
|
||
if (localDirHandle) await localWriteFile(filename, content ?? '');
|
||
|
||
// 에디터 탭에 추가
|
||
_npAddTab(filename, content ?? '');
|
||
renderCodeFileTabs();
|
||
codeAiScrollBottom();
|
||
}
|
||
|
||
function _projDone() {
|
||
_projMode = false;
|
||
if (!_projEl) return;
|
||
|
||
if (_projEl._hdr) _projEl._hdr.textContent = `📁 프로젝트 생성 완료`;
|
||
|
||
const doneDiv = document.createElement('div');
|
||
doneDiv.style.cssText = 'margin-top:10px;padding-top:10px;border-top:1px solid var(--border,#333);display:flex;gap:8px;align-items:center;flex-wrap:wrap;';
|
||
|
||
const info = document.createElement('span');
|
||
info.style.cssText = 'font-size:12px;color:var(--green,#4ade80);font-weight:600;';
|
||
info.textContent = `✅ ${_projFileCount}개 파일 생성 완료!`;
|
||
doneDiv.appendChild(info);
|
||
|
||
if (localDirHandle) {
|
||
const sbBtn = document.createElement('button');
|
||
sbBtn.style.cssText = 'background:var(--brand,#7c3aed);color:#fff;border:none;border-radius:5px;padding:4px 12px;font-size:11px;font-weight:600;cursor:pointer;';
|
||
sbBtn.textContent = '📂 파일 탐색기';
|
||
sbBtn.onclick = () => { const b = document.getElementById('code-folder-btn'); if (b) b.click(); };
|
||
doneDiv.appendChild(sbBtn);
|
||
}
|
||
|
||
_projEl.appendChild(doneDiv);
|
||
_projEl = null;
|
||
codeSbRefresh();
|
||
codeAiScrollBottom();
|
||
}
|
||
|
||
async function _projStreamSSE(sessionId, message, systemPrompt, history, onChunk) {
|
||
const res = await fetch('/api/chat', {
|
||
method: 'POST',
|
||
signal: codeAiAbort.signal,
|
||
headers: authHeaders({ 'Content-Type': 'application/json', 'Accept': 'text/event-stream' }),
|
||
body: JSON.stringify({
|
||
message,
|
||
pinnedMessages: [{ role: 'system', content: systemPrompt }],
|
||
history: history || [],
|
||
sessionId,
|
||
direct: true,
|
||
...(codeAiModel ? { model: codeAiModel } : {}),
|
||
}),
|
||
});
|
||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||
const reader = res.body.getReader();
|
||
const dec = new TextDecoder();
|
||
let buf = '', fullText = '';
|
||
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);
|
||
let chunk = '';
|
||
if (ev.type === 'token') chunk = ev.content || ev.text || '';
|
||
else if (ev.type === 'text_delta') chunk = ev.delta || '';
|
||
else if (ev.type === 'final' || ev.type === 'done') {
|
||
const reply = ev.text || ev.content || ev.reply || '';
|
||
if (reply.length > fullText.length) fullText = reply;
|
||
}
|
||
if (chunk) { fullText += chunk; onChunk?.(chunk); }
|
||
} catch {}
|
||
}
|
||
}
|
||
return fullText;
|
||
}
|
||
|
||
async function _projPlanChat(message) {
|
||
if (codeAiAbort) codeAiAbort.abort();
|
||
codeAiAbort = new AbortController();
|
||
try {
|
||
// 스트리밍 말풍선 생성
|
||
const bubble = codeAiAppend('assistant', '');
|
||
bubble.classList.add('streaming');
|
||
const mdDiv = document.createElement('div');
|
||
mdDiv.className = 'md-content';
|
||
bubble.appendChild(mdDiv);
|
||
codeAiScrollBottom();
|
||
|
||
let accumulated = '';
|
||
const fullText = await _projStreamSSE(
|
||
_projSessionId, message, _PROJ_PLAN_SYSTEM, _projHistory,
|
||
chunk => {
|
||
accumulated += chunk;
|
||
mdDiv.innerHTML = _renderInlineMarkdown(accumulated);
|
||
codeAiScrollBottom();
|
||
}
|
||
);
|
||
bubble.classList.remove('streaming');
|
||
const text = fullText || accumulated;
|
||
mdDiv.innerHTML = _renderInlineMarkdown(text);
|
||
|
||
// 히스토리에 추가
|
||
_projHistory.push({ role: 'user', content: message });
|
||
_projHistory.push({ role: 'assistant', content: text });
|
||
|
||
// 진행 버튼
|
||
const btnRow = document.createElement('div');
|
||
btnRow.style.cssText = 'margin-top:10px;display:flex;gap:6px;';
|
||
const goBtn = document.createElement('button');
|
||
goBtn.style.cssText = 'background:var(--brand,#7c3aed);color:#fff;border:none;border-radius:6px;padding:5px 14px;font-size:12px;font-weight:700;cursor:pointer;';
|
||
goBtn.textContent = '🔨 진행';
|
||
goBtn.onclick = () => { btnRow.remove(); _projGenerate(); };
|
||
btnRow.appendChild(goBtn);
|
||
bubble.appendChild(btnRow);
|
||
codeAiScrollBottom();
|
||
} catch (e) {
|
||
if (e.name !== 'AbortError')
|
||
codeAiAppend('system', '❌ 오류: ' + (e.message || e));
|
||
}
|
||
}
|
||
|
||
async function _projGenerate() {
|
||
if (codeAiAbort) codeAiAbort.abort();
|
||
codeAiAbort = new AbortController();
|
||
_projPhase = null;
|
||
_projStartGenerate();
|
||
try {
|
||
if (_projEl?._hdr) _projEl._hdr.textContent = '🤔 코드 생성 중...';
|
||
// 계획 세션 재사용 — 서버가 history 파라미터를 무시하고 세션 ID로 히스토리를 로드하므로
|
||
// 새 세션을 쓰면 "위 계획대로"가 뭔지 모델이 알 수 없음
|
||
const genSessionId = _projSessionId || ('proj-gen-' + Date.now());
|
||
let genChars = 0;
|
||
const fullText = await _projStreamSSE(
|
||
genSessionId, '위 계획대로 각 파일을 @@FILE / @@ENDFILE 형식으로 생성해주세요.',
|
||
_PROJ_GEN_SYSTEM, _projHistory,
|
||
chunk => {
|
||
genChars += chunk.length;
|
||
if (_projEl?._hdr) _projEl._hdr.textContent = `🤔 코드 생성 중... (${genChars}자)`;
|
||
}
|
||
);
|
||
|
||
// @@FILE / @@ENDFILE 델리미터 파싱 (JSON 이스케이프 문제 없음)
|
||
const fileRe = /@@FILE:\s*(.+?)\r?\n([\s\S]*?)@@ENDFILE/g;
|
||
const files = [];
|
||
let fm;
|
||
while ((fm = fileRe.exec(fullText)) !== null) {
|
||
files.push({ path: fm[1].trim(), content: fm[2] });
|
||
}
|
||
if (files.length === 0) {
|
||
const preview = fullText ? `\n\n모델 출력 (앞 400자):\n${fullText.slice(0, 400)}` : ' (응답 없음)';
|
||
codeAiAppend('system', '❌ 파일 블록을 찾지 못했습니다. 다시 시도해주세요.' + preview);
|
||
return;
|
||
}
|
||
|
||
if (_projEl?._hdr) _projEl._hdr.textContent = '🔨 파일 저장 중...';
|
||
for (const f of files) {
|
||
if (f.path && f.content !== undefined)
|
||
await _projFileCreated(f.path, String(f.content));
|
||
}
|
||
|
||
const allNames = files.map(f => f.path);
|
||
const slug = (allNames[0] || '').split('/')[0];
|
||
const mainPath = _npMainFile(files.map(f => ({ path: f.path.replace(slug + '/', '') })), slug);
|
||
if (mainPath) switchCodeFile(codeFiles.find(f => f.name === mainPath)?.id);
|
||
} catch (e) {
|
||
if (e.name !== 'AbortError')
|
||
codeAiAppend('system', '❌ 프로젝트 생성 오류: ' + (e.message || e));
|
||
} finally {
|
||
_projHistory = [];
|
||
_projSessionId = null;
|
||
_projDone();
|
||
}
|
||
}
|
||
|
||
// 탭 추가만, switchCodeFile 없이 (나중에 메인 파일로 한 번만 포커스)
|
||
function _npAddTab(name, content) {
|
||
const existing = codeFiles.find(f => f.name === name);
|
||
if (existing) {
|
||
if (existing.model) existing.model.setValue(content);
|
||
else existing.content = content;
|
||
return existing.id;
|
||
}
|
||
const id = 'cf_' + Math.random().toString(36).slice(2, 8);
|
||
const lang = codeDetectLang(name);
|
||
const model = monacoReady ? monaco.editor.createModel(content, lang) : null;
|
||
codeFiles.push({ id, name, content, language: lang, model });
|
||
return id;
|
||
}
|
||
|
||
// 메인 진입 파일 추정 (main > app > index > server > 첫 번째)
|
||
function _npMainFile(files, slug) {
|
||
const priority = /^(main|app|index|server)\.(py|js|ts|jsx|tsx|go|rs|java|rb|php)$/i;
|
||
const f = files.find(f => priority.test(f.path.split('/').pop()))
|
||
|| files.find(f => !/\.(md|txt|json|yaml|yml|toml|cfg|ini|env)$/i.test(f.path))
|
||
|| files[0];
|
||
return f ? `${slug}/${f.path}`.replace(/\/+/g, '/') : null;
|
||
}
|
||
|
||
|
||
async function codeAiSend() {
|
||
const input = document.getElementById('code-ai-input');
|
||
const message = input.value.trim();
|
||
if (!message) return;
|
||
input.value = ''; input.style.height = '';
|
||
|
||
// 프로젝트 계획 단계 중 → 확인 메시지면 생성, 아니면 계획 계속
|
||
if (_projPhase === 'plan') {
|
||
codeAiAppend('user', message);
|
||
if (_isProjConfirm(message)) {
|
||
await _projGenerate();
|
||
} else {
|
||
await _projPlanChat(message);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 새 프로젝트 의도 감지 → 계획 단계 시작
|
||
if (_isNewProjectIntent(message)) {
|
||
codeAiAppend('user', message);
|
||
_projPhase = 'plan';
|
||
_projHistory = [];
|
||
_projSessionId = 'proj-plan-' + Date.now();
|
||
await _projPlanChat(message);
|
||
return;
|
||
}
|
||
|
||
// Include current file context so the model knows what to edit (Code mode only)
|
||
const currentFile = codeFiles.find(f => f.id === activeCodeFileId);
|
||
let contextMsg = message;
|
||
if (codeAiMode === 'code' && 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');
|
||
// For long files (>80 lines), send focused context around the error/cursor
|
||
// plus structural overview (class/function defs) so the model understands the file
|
||
if (lines.length > 80) {
|
||
// Find relevant region: cursor line, or error line from message, or top
|
||
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 ? monacoEditor.getPosition().lineNumber : 1;
|
||
if (errorMatch) focusLine = parseInt(errorMatch[1]);
|
||
const contextLines = 30; // lines before and after focus
|
||
const startLine = Math.max(1, focusLine - contextLines);
|
||
const endLine = Math.min(lines.length, focusLine + contextLines);
|
||
const truncated = lines.slice(startLine - 1, endLine).map((l, i) => `${startLine + i}: ${l}`).join('\n');
|
||
|
||
// Also send structural overview: class/function/def lines so model knows the file structure
|
||
const structureLines = [];
|
||
const structureRegex = /^(class |def |async def |function |public |private |const |let |var |export |import |from |#[#]* )/i;
|
||
for (let i = 0; i < lines.length; i++) {
|
||
if (structureRegex.test(lines[i].trimStart())) {
|
||
structureLines.push(`${i + 1}: ${lines[i].trim()}`);
|
||
}
|
||
}
|
||
const structureOverview = structureLines.length > 0 ? `\n\n[파일 구조]\n${structureLines.join('\n')}\n` : '';
|
||
|
||
const omittedBefore = startLine - 1;
|
||
const omittedAfter = lines.length - endLine;
|
||
contextMsg = `[현재 파일: ${currentFile.name} (${lines.length}줄${omittedBefore ? `, 위 ${omittedBefore}줄 생략` : ''}${omittedAfter ? `, 아래 ${omittedAfter}줄 생략` : ''})]${structureOverview}\n\`\`\`${langHint}\n${truncated}\n\`\`\`\n⚠️ 이 파일을 수정하려면 전체 코드를 다시 출력하지 말고 \`\`\`diff 블록으로 변경된 줄만 출력하세요. - 로 삭제된 줄, + 로 추가된 줄, @@ 라인번호 @@ 헤더 포함. 들여쓰기가 맞는지 확인하세요.\n\n${message}`;
|
||
} else {
|
||
contextMsg = `[현재 파일: ${currentFile.name}]\n\`\`\`${langHint}\n${content}\n\`\`\`\n⚠️ 이 파일을 수정하려면 전체 코드를 다시 출력하지 말고 \`\`\`diff 블록으로 변경된 줄만 출력하세요.\n\n${message}`;
|
||
}
|
||
}
|
||
}
|
||
|
||
codeAiAppend('user', message);
|
||
codeAiHistory.push({ role: 'user', content: contextMsg });
|
||
|
||
const thinkEl = codeAiAppend('thinking', '생각 중...');
|
||
|
||
try {
|
||
if (codeAiAbort) codeAiAbort.abort();
|
||
codeAiAbort = new AbortController();
|
||
const historyForAPI = codeAiHistory.slice(-10, -1).map(m => ({ role: m.role, content: m.content }));
|
||
const res = await fetch('/api/chat', {
|
||
method: 'POST',
|
||
headers: authHeaders({ 'Content-Type': 'application/json', 'Accept': 'text/event-stream' }),
|
||
signal: codeAiAbort.signal,
|
||
body: JSON.stringify({ message: contextMsg, history: historyForAPI, useTools: false, direct: true, sessionId: codeAiSessionId, codeAiMode: codeAiMode, ...(codeAiModel ? { model: codeAiModel } : {}) }),
|
||
});
|
||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||
const reader = res.body.getReader();
|
||
const dec = new TextDecoder();
|
||
let buf = '', reply = '';
|
||
thinkEl.textContent = '';
|
||
thinkEl.className = 'code-ai-msg assistant';
|
||
// Live streaming state (mirrored into the editor via codeLiveStreamUpdate)
|
||
// Pin the diff target to the file that was active when the user hit send —
|
||
// that's the file we attached as "[현재 파일: …]" context, so any returned
|
||
// diff/edit must land there rather than spawning a new tab.
|
||
const liveState = { inBlock: false, lang: '', fileId: null, originalContent: null, isDiff: false, diffAccumulated: null, streamingModifiedText: null, targetFileId: activeCodeFileId };
|
||
|
||
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 data = line.slice(5).trim();
|
||
if (data === '[DONE]') continue;
|
||
try {
|
||
const ev = JSON.parse(data);
|
||
let token = null;
|
||
if (ev.type === 'token' && (ev.content || ev.text)) token = ev.content || ev.text;
|
||
else if (ev.type === 'text_delta' && ev.delta) token = ev.delta;
|
||
else if (ev.type === 'final' && (ev.text || ev.content)) { reply = ev.text || ev.content; thinkEl.textContent = reply; codeAiScrollBottom(); continue; }
|
||
else if (ev.type === 'done' && ev.reply) { reply = ev.reply; thinkEl.textContent = reply; codeAiScrollBottom(); continue; }
|
||
// Handle tool_call / tool_result events for live editor update
|
||
else if (ev.type === 'tool_call' && ev.action === 'create_file') {
|
||
const cf = ev.args?.filename || ev.args?.name || '';
|
||
const cc = ev.args?.content || '';
|
||
if (cf && cc) codeEditorWriteFile(cf.replace(/^code\//, ''), cc);
|
||
}
|
||
else if (ev.type === 'tool_result' && ev.action === 'create_file' && !ev.error) {
|
||
codeSbRefresh();
|
||
}
|
||
if (token) { reply += token; thinkEl.textContent = reply; codeAiScrollBottom(); try { codeLiveStreamUpdate(reply, liveState); } catch(e) { console.error('[codeLiveStream] error:', e); thinkEl.textContent += '\n⚠️ 에디터 스트리밍 오류: ' + (e.message || e); } }
|
||
} catch(e) { console.error('[codeAiStream] parse error:', e.message || e, 'data:', data?.slice(0, 80)); }
|
||
}
|
||
}
|
||
liveState.inBlock = false;
|
||
codeMonacoThrottleFlush();
|
||
|
||
// Save streaming state before resetting
|
||
const streamFileId = liveState.fileId;
|
||
const streamOriginalContent = liveState.originalContent;
|
||
const streamIsDiff = liveState.isDiff;
|
||
const streamDiffAccumulated = liveState.diffAccumulated;
|
||
const streamModifiedText = liveState.streamingModifiedText || null;
|
||
liveState.fileId = null;
|
||
liveState.isDiff = false;
|
||
liveState.diffAccumulated = null;
|
||
liveState.streamingModifiedText = null;
|
||
|
||
// Clear streaming diff decorations
|
||
if (streamFileId) {
|
||
const file = codeFiles.find(f => f.id === streamFileId);
|
||
if (file?._streamingDecos) {
|
||
try { file.model.deltaDecorations(file._streamingDecos, []); } catch {}
|
||
file._streamingDecos = null;
|
||
}
|
||
}
|
||
if (_streamingDiffTimer) { clearTimeout(_streamingDiffTimer); _streamingDiffTimer = null; }
|
||
|
||
// After streaming completes: apply diff or show inline diff (Code mode only)
|
||
if (codeAiMode === 'code' && streamFileId) {
|
||
const file = codeFiles.find(f => f.id === streamFileId);
|
||
if (file) {
|
||
if (streamIsDiff && streamDiffAccumulated && streamOriginalContent != null) {
|
||
// Diff block: apply the unified diff to the original content
|
||
const newContent = applyUnifiedDiff(streamOriginalContent, streamDiffAccumulated);
|
||
if (streamOriginalContent !== newContent) {
|
||
if (file.model) file.model.setValue(streamOriginalContent);
|
||
else file.content = streamOriginalContent;
|
||
showCodeDiff(file.id, streamOriginalContent, newContent);
|
||
}
|
||
} else if (streamOriginalContent != null && streamOriginalContent.trim()) {
|
||
// Full code block: compare original vs streamed modified text
|
||
// Use streamingModifiedText (what the model output) instead of editor content
|
||
// (which may still show original if we used streaming diff decorations)
|
||
const newContent = streamModifiedText || (file.model ? file.model.getValue() : file.content);
|
||
if (streamOriginalContent !== newContent) {
|
||
// Restore original in editor, then show inline diff
|
||
if (file.model) file.model.setValue(streamOriginalContent);
|
||
else file.content = streamOriginalContent;
|
||
showCodeDiff(file.id, streamOriginalContent, newContent);
|
||
}
|
||
} else {
|
||
// New file created by AI — show approval bar
|
||
_showNewFileApproval(file.id, file.name);
|
||
}
|
||
}
|
||
}
|
||
|
||
codeAiHistory.push({ role: 'assistant', content: reply || '(응답 없음)' });
|
||
thinkEl.classList.remove('streaming'); // Stop cursor animation
|
||
if (!reply) { thinkEl.textContent = '(응답 없음)'; }
|
||
else codeAiRenderMarkdown(thinkEl, reply);
|
||
} catch(e) {
|
||
thinkEl.className = 'code-ai-msg thinking';
|
||
thinkEl.textContent = '오류: ' + (e.message || e);
|
||
}
|
||
}
|
||
|
||
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;
|
||
if (role === 'thinking') el.textContent = text;
|
||
else 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');
|
||
const nonEmpty = lines.filter(l => l.trim()).length;
|
||
if (nonEmpty === 0) return false;
|
||
const diffLines = lines.filter(l => /^[+\-] /.test(l) || l.startsWith('@@')).length;
|
||
return diffLines / nonEmpty > 0.3;
|
||
}
|
||
|
||
// 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 = '';
|
||
// 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(text)) !== null) {
|
||
// Require ``` at start of line (only whitespace before it)
|
||
const lineStart = m.index === 0 ? 0 : text.lastIndexOf('\n', m.index - 1) + 1;
|
||
const prefix = text.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: text.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: text.slice(blockStart, m.index) });
|
||
lastEnd = m.index + m[0].length;
|
||
inBlock = false;
|
||
}
|
||
}
|
||
// Handle unclosed block (streaming or malformed)
|
||
if (inBlock) {
|
||
parts.push({ type: 'code', lang: blockLang, code: text.slice(blockStart) });
|
||
lastEnd = text.length;
|
||
}
|
||
// Trailing text after last block
|
||
if (lastEnd < text.length) parts.push({ type: 'text', content: text.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);
|
||
if (isDiff) {
|
||
// Auto-apply diff to editor; no button needed
|
||
setTimeout(() => codeAiApplyDiff(code), 0);
|
||
} else {
|
||
const openBtn = document.createElement('button');
|
||
openBtn.className = 'code-ai-open-btn';
|
||
openBtn.textContent = '📂 에디터에서 열기';
|
||
openBtn.onclick = () => codeAiOpenInEditor(code, lang);
|
||
wrapper.appendChild(openBtn);
|
||
}
|
||
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 create_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) {
|
||
// Show diff for modifications to existing files
|
||
switchCodeFile(existing.id);
|
||
showCodeDiff(existing.id, oldContent, content);
|
||
}
|
||
// The diff bar's accept/reject (acceptAllHunks/rejectAllHunks) commits the model
|
||
} 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;
|
||
|
||
// 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;
|
||
actions.innerHTML = '<button class="inline-diff-accept-btn" onclick="acceptHunk(\'' + fileId + '\',' + hunkIndex + ')" title="이 변경 적용">✅</button>' +
|
||
'<button class="inline-diff-reject-btn" onclick="rejectHunk(\'' + fileId + '\',' + hunkIndex + ')" 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;
|
||
|
||
label.textContent = '🔀 AI 제안 변경 사항';
|
||
const acceptBtn = document.getElementById('code-diff-accept');
|
||
const rejectBtn = document.getElementById('code-diff-reject');
|
||
if (acceptBtn) { acceptBtn.textContent = '✅ 모두 적용'; acceptBtn.onclick = () => acceptAllHunks(); }
|
||
if (rejectBtn) { rejectBtn.textContent = '❌ 모두 취소'; rejectBtn.onclick = () => rejectAllHunks(); }
|
||
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);
|
||
}
|
||
|
||
// Accept all hunks — keep the modified text as-is
|
||
function acceptAllHunks() {
|
||
const fileId = activeCodeFileId;
|
||
const ds = codeDiffState[fileId];
|
||
if (!ds) return;
|
||
ds.hunks.forEach((_, i) => { ds.resolved.add(i); ds.rejected.delete(i); });
|
||
_applyInlineDiffDecorations(fileId); // rebuild model to the fully-accepted content
|
||
finalizeInlineDiff(fileId, true);
|
||
}
|
||
|
||
// Reject all hunks — restore the original text
|
||
function rejectAllHunks() {
|
||
const fileId = activeCodeFileId;
|
||
const ds = codeDiffState[fileId];
|
||
if (!ds) return;
|
||
ds.hunks.forEach((_, i) => { ds.resolved.add(i); ds.rejected.add(i); });
|
||
_applyInlineDiffDecorations(fileId); // rebuild model back to the original content
|
||
if (ds.onReject) ds.onReject();
|
||
finalizeInlineDiff(fileId, true);
|
||
}
|
||
|
||
// 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) {
|
||
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(file.name, content);
|
||
if (ok) codeAiAppend('system', `💾 저장: ${file.name}`);
|
||
else codeAiAppend('system', `⚠️ 저장 실패: ${file.name}`);
|
||
}
|
||
|
||
// 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;
|
||
|
||
_removeInlineDiffDecorations(fileId);
|
||
delete codeDiffState[fileId];
|
||
|
||
// Auto-save accepted changes so the on-disk file matches the editor
|
||
if (save) codeSaveFileById(fileId);
|
||
|
||
// Hide diff bar
|
||
const bar = document.getElementById('code-diff-bar');
|
||
if (bar) bar.style.display = 'none';
|
||
|
||
// Restore normal editor focus
|
||
const file = codeFiles.find(f => f.id === fileId);
|
||
if (file && monacoEditor && file.id === activeCodeFileId) {
|
||
monacoEditor.setModel(file.model);
|
||
monacoEditor.focus();
|
||
}
|
||
renderCodeFileTabs();
|
||
}
|
||
|
||
function cleanupCodeDiff(fileId) {
|
||
finalizeInlineDiff(fileId);
|
||
}
|
||
|
||
// ── New file approval bar ────────────────────────────────────────────────
|
||
function _showNewFileApproval(fileId, fileName) {
|
||
const file = codeFiles.find(f => f.id === fileId);
|
||
if (!file) return;
|
||
switchCodeFile(fileId);
|
||
|
||
const bar = document.getElementById('code-diff-bar');
|
||
const label = document.getElementById('code-diff-label');
|
||
if (!bar || !label) return;
|
||
|
||
label.textContent = '📝 새 파일: ' + fileName;
|
||
const acceptBtn = document.getElementById('code-diff-accept');
|
||
const rejectBtn = document.getElementById('code-diff-reject');
|
||
if (acceptBtn) {
|
||
acceptBtn.textContent = '✅ 만들기';
|
||
acceptBtn.onclick = () => { bar.style.display = 'none'; codeSaveFileById(fileId); };
|
||
}
|
||
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();
|
||
};
|
||
}
|
||
bar.style.display = 'flex';
|
||
}
|
||
function codeAiScrollBottom() {
|
||
const msgs = document.getElementById('code-ai-messages');
|
||
if (msgs) msgs.scrollTop = msgs.scrollHeight;
|
||
}
|
||
|
||
function codeAiClear() {
|
||
const msgs = document.getElementById('code-ai-messages');
|
||
if (msgs) msgs.innerHTML = '<div class="code-ai-msg thinking">코드를 선택하고 "AI에게 전송"을 누르거나, 아래에 질문을 입력하세요.</div>';
|
||
codeAiHistory = [];
|
||
}
|
||
|
||
// ── Code sidebar panel ────────────────────────────────────────────────────
|
||
let codeSbCurrentTab = 'files';
|
||
|
||
function setCodeSbTab(tab) {
|
||
codeSbCurrentTab = tab;
|
||
['files','model','settings'].forEach(t => {
|
||
document.getElementById('csb-tab-' + t).classList.toggle('active', t === tab);
|
||
const el = document.getElementById('csb-' + t);
|
||
if (el) el.style.display = t === tab ? 'flex' : 'none';
|
||
});
|
||
if (tab === 'model') csbLoadModels();
|
||
}
|
||
|
||
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 files = await localListFiles(localDirHandle, '');
|
||
if (!files.length) {
|
||
tree.innerHTML = '<div style="padding:12px;color:var(--muted);font-size:11px;text-align:center">파일 없음<br><small>+ 버튼으로 추가</small></div>';
|
||
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:'📄' };
|
||
|
||
// 트리 노드 구조 빌드
|
||
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
|
||
}
|
||
|
||
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" style="padding-left:${indent+8}px;font-weight:600;color:var(--muted)" onclick="csbToggleDir('${dirId}')">
|
||
<span id="${dirId}_arrow" style="font-size:9px;transition:.15s;flex-shrink:0">${isOpen ? '▼' : '▶'}</span><span style="flex-shrink:0">📂</span>
|
||
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1">${escHtml(name)}/</span>
|
||
<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" style="padding-left:${indent+8}px" onclick="csbOpenFile('${safe}')">
|
||
<span style="flex-shrink:0">${icon}</span>
|
||
<span 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(root, 0, '');
|
||
} 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;
|
||
const existing = codeFiles.find(f => f.name === filename);
|
||
if (existing) switchCodeFile(existing.id);
|
||
else codeNewFile(filename, content);
|
||
} catch {}
|
||
}
|
||
|
||
async function csbDeleteFile(filename, ev) {
|
||
ev.stopPropagation();
|
||
if (!localDirHandle || !confirm(`"${filename}" 삭제할까요?`)) return;
|
||
try {
|
||
const ok = await localDeleteFile(filename);
|
||
if (ok) {
|
||
const existing = codeFiles.find(f => f.name === filename);
|
||
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);
|
||
}
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
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');
|
||
}
|
||
|
||
const CODE_THEME_GROUPS = [
|
||
{ icon: '🌙', label: '다크', themes: [
|
||
{ v: 'vs-dark', label: 'VS Dark', bg: '#1e1e1e' },
|
||
{ v: 'one-dark', label: 'One Dark', bg: '#282c34' },
|
||
{ v: 'nord', label: 'Nord', bg: '#2e3440' },
|
||
{ v: 'github-dark', label: 'GitHub Dark', bg: '#0d1117' },
|
||
{ v: 'tomorrow-night', label: 'Tomorrow Night',bg: '#1d1f21' },
|
||
{ v: 'hc-black', label: 'High Contrast', bg: '#000000' },
|
||
]},
|
||
{ icon: '☀️', label: '라이트', themes: [
|
||
{ v: 'vs', label: 'VS Light', bg: '#ffffff' },
|
||
{ v: 'github-light', label: 'GitHub Light',bg: '#f6f8fa' },
|
||
]},
|
||
{ icon: '🌿', label: '웜', themes: [
|
||
{ v: 'monokai', label: 'Monokai', bg: '#272822' },
|
||
{ v: 'dracula', label: 'Dracula', bg: '#282a36' },
|
||
{ v: 'solarized-dark', label: 'Solarized Dark', bg: '#002b36' },
|
||
{ v: 'solarized-light', label: 'Solarized Light',bg: '#fdf6e3' },
|
||
]},
|
||
];
|
||
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('cherryclaw_theme') || 'dark';
|
||
setCodePageTheme(t);
|
||
})();
|
||
|
||
function csbTheme(theme) {
|
||
if (window.monaco) {
|
||
if (!['vs','vs-dark','hc-black','hc-light'].includes(theme)) defineCodeThemes();
|
||
monaco.editor.setTheme(theme);
|
||
}
|
||
localStorage.setItem('codeEditorTheme', theme);
|
||
const lbl = CODE_THEMES.find(t => t.v === theme);
|
||
const el = document.getElementById('csb-theme-lbl');
|
||
if (el) el.textContent = lbl ? lbl.label : theme;
|
||
document.querySelectorAll('#csb-theme-grid .code-skin-sw').forEach(sw =>
|
||
sw.classList.toggle('active', sw.dataset.v === theme));
|
||
}
|
||
|
||
function csbTabSize(val) {
|
||
if (monacoEditor) monacoEditor.updateOptions({ tabSize: parseInt(val) });
|
||
localStorage.setItem('codeEditorTabSize', val);
|
||
}
|
||
|
||
function csbMinimap(checked) {
|
||
if (monacoEditor) monacoEditor.updateOptions({ minimap: { enabled: checked } });
|
||
localStorage.setItem('codeEditorMinimap', checked ? '1' : '0');
|
||
}
|
||
|
||
// 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');
|
||
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';
|
||
if (monacoEditor) monacoEditor.updateOptions({
|
||
fontSize: fs ? parseInt(fs) : 12,
|
||
wordWrap: ww === '0' ? 'off' : 'on',
|
||
tabSize: ts ? parseInt(ts) : 2,
|
||
minimap: { enabled: mm !== '0' },
|
||
});
|
||
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 existing = codeFiles.find(f => f.name === filename);
|
||
if (existing) switchCodeFile(existing.id);
|
||
else codeNewFile(filename, 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 existing = codeFiles.find(f => f.name === filename);
|
||
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 aiPanel = document.getElementById('code-ai-panel');
|
||
if (!handle || !aiPanel) 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));
|
||
aiPanel.style.width = 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').
|
||
// 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 { codeSbRefresh(); } catch (e) {}
|
||
try { companionConnect(); } catch (e) {}
|
||
}
|
||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, { once: true });
|
||
else boot();
|
||
})();
|