Files
homeclaw/web-ui/code.js
T
kimandClaude Sonnet 4.6 d9df800524 Release 2.9.1: PPTX wizard project persistence + PDF column extraction
- PDF: PyMuPDF 2-column aware extraction (left col → right col, drop cap merge,
  header/footer/footnote removal), reference stripping, 200k char limit
- Wizard: chunked parallel translation (15k chars/chunk, ~4x faster)
- Wizard: save UPL_ (upload) papers to papers.json (were silently skipped)
- Wizard: restore UPL_ papers from manifest with _isUpload/_localPdf fields
- Wizard: project-papers endpoint correctly resolves UPL_ ko.txt in project dir
- Wizard: images loaded on manifest restore (renderImgGrid unconditional)
- Wizard: outline.json auto-save after generation and slide edits
- Wizard: outline.json fallback load when no PPTX exists
- Wizard: loadProjList() refresh after savePapersToProject()
- Code editor: default font size 14→12px

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 23:29:54 +09:00

2285 lines
100 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,'&amp;').replace(/\\/g,'&#92;').replace(/`/g,'&#96;').replace(/\$/g,'&#36;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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, widgetIds, resolved, 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)">&times;</span>
</div>`;
}).join('');
}
let codeDirLabel = 'code';
function csbCodeDirChanged(val) { /* preview only, saved on button click */ }
async function csbCodeDirSave() {
const input = document.getElementById('csb-codedir');
const val = (input?.value || '').trim() || 'code';
try {
await api('/api/settings/paths', { method: 'POST', body: JSON.stringify({ code_dir: val }) });
codeDirLabel = val;
const folderBtn = document.getElementById('code-folder-btn');
if (folderBtn) folderBtn.querySelector('span').textContent = `workspace/${val}/`;
const headerLabel = document.querySelector('#csb-files span[style*="uppercase"]');
if (headerLabel) headerLabel.textContent = `workspace/${val}/`;
codeSbRefresh();
} catch {}
}
async function csbLoadCodeDir() {
try {
const data = await api('/api/settings/paths');
const dir = data.code_dir || 'code';
codeDirLabel = dir;
const input = document.getElementById('csb-codedir');
if (input) input.value = dir;
const folderBtn = document.getElementById('code-folder-btn');
if (folderBtn) folderBtn.querySelector('span').textContent = `workspace/${dir}/`;
const headerLabel = document.querySelector('#csb-files span[style*="uppercase"]');
if (headerLabel) headerLabel.textContent = `workspace/${dir}/`;
} catch {}
}
function codeSaveFile() {
const file = codeFiles.find(f => f.id === activeCodeFileId);
if (!file || !monacoEditor) return;
file.content = monacoEditor.getValue();
api('/api/code/save', {
method: 'POST',
body: JSON.stringify({ filename: file.name, content: file.content }),
})
.then(r => {
if (r.success) codeAiAppend('system', `💾 저장됨: ${r.path}`);
else throw new Error(r.error);
})
.catch(e => codeAiAppend('system', `⚠️ 저장 실패: ${e.message || e}`));
}
// ── 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) { 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','Courier New',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 (termWsConnected && inst.ptyReady) termWsSend({ type: 'pty_input', shellId: id, data }); });
term.onResize(({ cols, rows }) => { if (termWsConnected && inst.ptyReady) termWsSend({ type: 'pty_resize', shellId: id, cols, rows }); });
if (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)">&times;</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;
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>
bash${codeTermShells.length>1?` <span onclick="closeCodeTermShell('${s.id}',event)" style="color:#3d6b4f;margin-left:3px">&times;</span>`:''}
</div>`;
}).join('');
}
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);
}
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;
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: '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;
file.content = monacoEditor.getValue();
const fpath = `${codeDirLabel}/${file.name}`;
if (mode === 'browser') {
api('/api/code/save', { method: 'POST', body: JSON.stringify({ filename: file.name, content: file.content }) })
.then(r => {
if (r.success) {
const w = window.open(`${location.origin}/code-serve/${codeDirLabel}/${file.name}`, '_blank');
if (!w) codeAiAppend('system', '⚠️ 팝업 차단됨 — 브라우저에서 팝업을 허용해주세요');
}
});
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
const _looksLikeDiff = (code) => {
const lines = code.split('\n');
let diffLineCount = 0;
for (const line of lines) {
// Only count + and - lines (not space-prefixed context lines — those match Python indentation)
if (/^[+\-] /.test(line) || line.startsWith('@@')) diffLineCount++;
}
// If more than 30% of non-empty lines look like diff markers, it's a diff
const nonEmpty = lines.filter(l => l.trim()).length;
return nonEmpty > 0 && diffLineCount / nonEmpty > 0.3;
};
if (lang !== 'diff' && _looksLikeDiff(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;
}
// Compute simple line-level diff: find which lines in original are changed
const origLines = originalText.split('\n');
const modLines = modifiedText.split('\n');
// Use a fast LCS-like comparison to find added/changed lines
// While streaming, we only show "added/changed" lines in green.
// We can't show deleted lines until streaming completes (we don't know
// if the model will output them later or if they're truly deleted).
const decorations = [];
// Simple line-by-line diff: for each line in modified that differs from original
let oi = 0, mi = 0;
while (oi < origLines.length && mi < modLines.length) {
if (origLines[oi] === modLines[mi]) {
// Lines match — unchanged
oi++; mi++;
} else {
// Check if this is an insertion (next orig line matches current mod line)
let matchAhead = false;
for (let look = 1; look <= 3 && oi + look < origLines.length; look++) {
if (origLines[oi + look] === modLines[mi]) {
// Lines ahead match — lines oi..oi+look-1 are deleted, modLines[mi] is context
// Mark deleted lines (we'll skip this in streaming since we keep original)
// Just advance pointers
for (let k = 0; k < look; k++) {
// Original lines that were removed — we can't show this during streaming
// because the model might still output them
}
oi += look;
matchAhead = true;
break;
}
}
if (!matchAhead) {
// Check if this is a modification (mod line is new/changed)
let matchBehind = false;
for (let look = 1; look <= 3 && mi + look < modLines.length; look++) {
if (origLines[oi] === modLines[mi + look]) {
// Lines mi..mi+look-1 are new additions
for (let k = 0; k < look; k++) {
// We can't show added lines in the original editor during streaming
// because we're keeping the original content
}
mi += look;
matchBehind = true;
break;
}
}
if (!matchBehind) {
// Both lines differ — this is a change
oi++; mi++;
}
}
}
}
// The approach above is too complex for real-time. Instead, use a simpler method:
// Show a subtle "AI is editing..." indicator on lines that are different.
// We do this by computing a quick diff and highlighting changed regions.
// Quick diff: find the longest common subsequence alignment
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
// 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
// Copy untouched original lines up to where this hunk begins
const hunkStart = Math.max(0, oldStart - 1);
while (origPos < hunkStart && 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];
if (tag === '+') {
result.push(line.slice(1)); // added line
} else if (tag === '-') {
origPos++; // removed — consume original
} else if (tag === ' ') {
if (origPos < origLines.length) result.push(origLines[origPos]); else result.push(line.slice(1));
origPos++; // context — keep original text
} else {
// No-prefix line: treat as context when it matches the original, else literal
if (origPos < origLines.length && origLines[origPos] === line) { result.push(origLines[origPos]); 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)
function applySimpleDiff(originalContent, diffText) {
const origLines = originalContent.split('\n');
const diffLines = diffText.split('\n');
const resultLines = [];
let origIdx = 0;
for (const line of diffLines) {
if (line.startsWith('+') && !line.startsWith('+++')) {
resultLines.push(line.slice(1));
} else if (line.startsWith('-') && !line.startsWith('---')) {
origIdx++; // Skip original line
} else if (line.startsWith(' ')) {
// Context line
resultLines.push(line.slice(1));
origIdx++;
} else {
// Plain line (no prefix) or header — skip diff headers
if (!line.startsWith('diff ') && !line.startsWith('index ') && !line.startsWith('---') && !line.startsWith('+++')) {
resultLines.push(line);
}
}
}
// Append any remaining original lines
while (origIdx < origLines.length) {
resultLines.push(origLines[origIdx]);
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);
}
async function codeAiSend() {
const input = document.getElementById('code-ai-input');
const message = input.value.trim();
if (!message) return;
input.value = ''; input.style.height = '';
// 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: 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;
}
// 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 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 = lang || 'code';
header.appendChild(langLabel);
const pre = document.createElement('pre');
pre.textContent = code;
const openBtn = document.createElement('button');
openBtn.className = 'code-ai-open-btn';
openBtn.textContent = '📂 에디터에서 열기';
openBtn.onclick = () => codeAiOpenInEditor(code, lang);
wrapper.appendChild(header);
wrapper.appendChild(pre);
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);
}
// Don't update the model yet — acceptCodeDiff() will do it
} else {
codeNewFile(fname, content);
}
}
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 — replace content directly (diff can cause indentation issues)
if (existing.model) existing.model.setValue(code);
else existing.content = code;
switchCodeFile(existing.id);
} 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;
// Build LCS table (space-optimized: only need 2 rows)
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
codeDiffState[fileId] = {
originalText, modifiedText, hunks,
decorationIds: [], viewZoneIds: [], widgetIds: [],
resolved: 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();
}
// 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);
const newLines = ds.modifiedText.split('\n');
const oldLines = ds.originalText.split('\n');
const decorations = [];
for (let hi = 0; hi < ds.hunks.length; hi++) {
if (ds.resolved.has(hi)) continue;
const hunk = ds.hunks[hi];
// Green highlight for added/changed lines
for (let line = hunk.newStart; line <= hunk.newEnd && line <= newLines.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)
if (hunk.type === 'delete' || hunk.type === 'change') {
const deletedLines = oldLines.slice(hunk.oldStart - 1, hunk.oldEnd);
const insertAfter = Math.min(hunk.newStart, newLines.length);
const zoneId = 'dz-' + fileId + '-' + hi;
if (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: insertAfter,
afterColumn: 1,
heightInLines: Math.max(deletedLines.length, 1),
domNode: div,
}));
});
}
}
}
// Apply decorations
ds.decorationIds = monacoEditor.deltaDecorations([], decorations);
// Add hunk action button overlays
for (let hi = 0; hi < ds.hunks.length; hi++) {
if (ds.resolved.has(hi)) continue;
const hunk = ds.hunks[hi];
const line = hunk.newStart;
if (line <= newLines.length) {
_addHunkActionsWidget(fileId, hi, line);
}
}
// Update diff bar info
_updateDiffBarInfo(fileId);
// Scroll to first unresolved hunk
const firstUnresolved = ds.hunks.findIndex((_, i) => !ds.resolved.has(i));
if (firstUnresolved !== -1) {
monacoEditor.revealLineInCenter(ds.hunks[firstUnresolved].newStart);
}
}
// 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
function acceptHunk(fileId, hunkIndex) {
const ds = codeDiffState[fileId];
if (!ds || ds.resolved.has(hunkIndex)) return;
ds.resolved.add(hunkIndex);
// For accepted hunks, the modified text is already in the model.
// Just remove decorations and re-apply.
_applyInlineDiffDecorations(fileId);
if (ds.resolved.size === ds.hunks.length) finalizeInlineDiff(fileId, true);
}
// Reject a single hunk — restore original lines for this hunk
function rejectHunk(fileId, hunkIndex) {
const ds = codeDiffState[fileId];
if (!ds || ds.resolved.has(hunkIndex)) return;
ds.resolved.add(hunkIndex);
const file = codeFiles.find(f => f.id === fileId);
if (!file || !file.model) return;
const hunk = ds.hunks[hunkIndex];
const oldLines = ds.originalText.split('\n');
const currentLines = file.model.getValue().split('\n');
// Replace the hunk's new lines with the original lines
const before = currentLines.slice(0, hunk.newStart - 1);
const after = currentLines.slice(hunk.newEnd);
const originalLines = oldLines.slice(hunk.oldStart - 1, hunk.oldEnd);
const newContent = [...before, ...originalLines, ...after].join('\n');
file.model.setValue(newContent);
// Recompute hunks based on new content vs original
const newHunks = computeDiffHunks(ds.originalText, newContent);
ds.hunks = newHunks;
ds.resolved = new Set();
// Remove overlays and re-apply
document.querySelectorAll('.inline-diff-hunk-actions[data-file-id="' + fileId + '"]').forEach(el => el.remove());
_applyInlineDiffDecorations(fileId);
if (newHunks.length === 0) 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));
finalizeInlineDiff(fileId, true);
}
// Reject all hunks — restore the original text
function rejectAllHunks() {
const fileId = activeCodeFileId;
const ds = codeDiffState[fileId];
if (!ds) return;
const file = codeFiles.find(f => f.id === fileId);
if (file) {
if (file.model) file.model.setValue(ds.originalText);
else file.content = ds.originalText;
}
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가 안 됨").
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;
api('/api/code/save', {
method: 'POST',
body: JSON.stringify({ filename: file.name, content }),
})
.then(r => { if (r.success) codeAiAppend('system', `💾 저장됨: ${r.path}`); else throw new Error(r.error); })
.catch(e => codeAiAppend('system', `⚠️ 저장 실패: ${e.message || e}`));
}
// 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();
}
// Backward-compatible wrappers
function acceptCodeDiff() { acceptAllHunks(); }
function rejectCodeDiff() { rejectAllHunks(); }
function _showDiffUI(show) {
// Now a no-op for editor visibility — editor stays visible.
// Only toggles the diff bar.
const bar = document.getElementById('code-diff-bar');
if (bar) bar.style.display = show ? 'flex' : 'none';
}
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>';
try {
const data = await api('/api/code/files');
const files = data.files || [];
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:'📄' };
tree.innerHTML = files.map(f => {
const ext = f.split('.').pop().toLowerCase();
const icon = extIcon[ext] || '📄';
const safe = f.replace(/\\/g,'\\\\').replace(/'/g,"\\'");
return `<div class="csb-file-item" onclick="csbOpenFile('${safe}')">
<span style="flex-shrink:0">${icon}</span>
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1">${escHtml(f)}</span>
<button class="csb-del" onclick="csbDeleteFile('${safe}',event)" title="삭제">✕</button>
</div>`;
}).join('');
} catch {
tree.innerHTML = '<div style="padding:12px;color:#f87171;font-size:11px;text-align:center">불러오기 실패</div>';
}
}
async function csbOpenFile(filename) {
try {
const data = await api(`/api/code/file?name=${encodeURIComponent(filename)}`);
if (data.success) {
const existing = codeFiles.find(f => f.name === filename);
if (existing) switchCodeFile(existing.id);
else codeNewFile(filename, data.content);
}
} catch {}
}
async function csbDeleteFile(filename, ev) {
ev.stopPropagation();
if (!confirm(`"${filename}" 삭제할까요?`)) return;
try {
const r = await api(`/api/code/file?name=${encodeURIComponent(filename)}`, { method: 'DELETE' });
if (r.success) {
const existing = codeFiles.find(f => f.name === filename);
if (existing) closeCodeFile(existing.id);
codeSbRefresh();
}
} catch {}
}
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');
}
function csbTheme(theme) {
if (window.monaco) monaco.editor.setTheme(theme);
localStorage.setItem('codeEditorTheme', 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 (th) document.getElementById('csb-theme').value = th;
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' },
});
if (th && window.monaco) monaco.editor.setTheme(th);
}
// ── 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 data = await api('/api/code/files');
const files = data.files || [];
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) {
try {
const data = await api(`/api/code/file?name=${encodeURIComponent(filename)}`);
if (data.success) {
const existing = codeFiles.find(f => f.name === filename);
if (existing) { switchCodeFile(existing.id); }
else { codeNewFile(filename, data.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 (!confirm(`"${filename}" 파일을 삭제할까요?`)) return;
try {
const r = await api(`/api/code/file?name=${encodeURIComponent(filename)}`, { method: 'DELETE' });
if (r.success) {
// 열려있는 탭도 닫기
const existing = codeFiles.find(f => f.name === filename);
if (existing) closeCodeFile(existing.id);
codeRefreshFolder();
} else {
alert('삭제 실패: ' + (r.error || ''));
}
} 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() {
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 { codeSbRefresh(); } catch (e) {}
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, { once: true });
else boot();
})();