- 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>
2285 lines
100 KiB
JavaScript
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,'&').replace(/\\/g,'\').replace(/`/g,'`').replace(/\$/g,'$').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
async function api(path, opts = {}) {
|
|
const API = window.location.origin || 'http://127.0.0.1:18789';
|
|
const { headers: optsHeaders, credentials: _discard, ...restOpts } = opts;
|
|
const headers = authHeaders({ 'Content-Type': 'application/json', ...optsHeaders });
|
|
const r = await fetch(API + path, { headers, ...restOpts });
|
|
if (r.status === 401) { setAuthToken(''); window.location.href = '/login.html'; throw new Error('Authentication required'); }
|
|
const ct = String(r.headers.get('content-type') || '').toLowerCase();
|
|
const bodyText = await r.text();
|
|
if (!ct.includes('application/json')) {
|
|
const preview = bodyText.slice(0, 120).replace(/\s+/g, ' ');
|
|
throw new Error(`Expected JSON from ${path}, got ${ct || 'unknown'} (HTTP ${r.status}) :: ${preview}`);
|
|
}
|
|
let data = null;
|
|
try { data = bodyText ? JSON.parse(bodyText) : null; }
|
|
catch { const preview = bodyText.slice(0, 120).replace(/\s+/g, ' '); throw new Error(`Invalid JSON from ${path} (HTTP ${r.status}) :: ${preview}`); }
|
|
if (!r.ok) { const msg = (data && (data.error || data.message)) ? String(data.error || data.message) : `HTTP ${r.status}`; throw new Error(msg); }
|
|
return data;
|
|
}
|
|
|
|
// ── Code Editor View ─────────────────────────────────────────────────────────
|
|
let monacoEditor = null;
|
|
let monacoReady = false;
|
|
let codeFiles = []; // [{id, name, content, language, model}]
|
|
let activeCodeFileId = null;
|
|
let codeAiHistory = [];
|
|
let codeAiAbort = null;
|
|
let codeAiSessionId = 'code_ai_' + Math.random().toString(36).slice(2, 10);
|
|
let codeAiModel = localStorage.getItem('codeAiModel') ?? 'glm-5.1:cloud';
|
|
let codeAiMode = localStorage.getItem('codeAiMode') ?? 'code'; // 'code' or 'ask'
|
|
let codeDiffState = {}; // { fileId: { originalText, modifiedText, hunks, decorationIds, viewZoneIds, 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)">×</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)">×</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">×</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();
|
|
})();
|