The 💻 코드 sidebar tab now opens a dedicated code.html page in a new tab, mirroring how the slide wizard opens pptx-wizard.html. - Add web-ui/code.html: standalone code editor page reusing styles.css and the same CodeMirror/xterm/Monaco head as the main app. - Add web-ui/code.js: self-contained code-view logic — inlined shared helpers (api/authHeaders/escHtml/getCookie) + the extracted code-editor block + a bootstrap that inits Monaco and loads models/dir. It mirrors the code block in app.js (kept duplicated so the main app stays untouched). - index.html: tab-code now window.open('/code.html'). The in-page code mode (top mode button) is unchanged, so chat→editor live streaming still works. Verified with Playwright: Monaco initializes, editor/sidebar/toolbar/Coder AI render, no JS errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1254 lines
54 KiB
JavaScript
1254 lines
54 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
|
|
// 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';
|
|
|
|
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 = 'Coder-' + label + '에게 질문...';
|
|
}
|
|
|
|
function initCodeView() {
|
|
updateCodeAiLabel();
|
|
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: 14,
|
|
lineHeight: 22,
|
|
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) codeNewFile('main.py', '# 여기에 코드를 작성하세요\n');
|
|
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();
|
|
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);
|
|
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;
|
|
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;
|
|
return `<div class="code-file-tab${active ? ' active' : ''}" onclick="switchCodeFile('${f.id}')">
|
|
<span>${escHtml(f.name)}</span>
|
|
<span class="code-file-tab-close" onclick="closeCodeFile('${f.id}',event)">×</span>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
const CODE_DIR = 'code';
|
|
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 = `${CODE_DIR}/${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/${CODE_DIR}/${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.
|
|
let v2LiveFileId = null, v2LiveLang = '', v2LiveInBlock = false;
|
|
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) {
|
|
_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) {
|
|
if (existing.model) existing.model.setValue(item.content);
|
|
else existing.content = 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 (!v2LiveFileId) v2LiveFileId = id;
|
|
}
|
|
}
|
|
codePendingQueue = [];
|
|
if (v2LiveFileId) switchCodeFile(v2LiveFileId);
|
|
}
|
|
|
|
function v2LiveCodeUpdate(fullReply) {
|
|
if (typeof codeIsOpenable !== 'function') return;
|
|
const fenceRe = /```([\w\-]*)\s*\n/g;
|
|
let lastOpen = null, m;
|
|
while ((m = fenceRe.exec(fullReply)) !== null) {
|
|
if (lastOpen === null) {
|
|
lastOpen = { lang: m[1] || 'text', start: m.index + m[0].length };
|
|
} else {
|
|
lastOpen = null;
|
|
}
|
|
}
|
|
if (!lastOpen) { v2LiveInBlock = false; return; }
|
|
const code = fullReply.slice(lastOpen.start);
|
|
const lang = lastOpen.lang;
|
|
if (!v2LiveInBlock) {
|
|
if (!codeIsOpenable(code, lang)) return;
|
|
v2LiveInBlock = true;
|
|
v2LiveLang = lang;
|
|
const fname = codeDeriveName(code, lang);
|
|
if (monacoReady && monacoEditor) {
|
|
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 });
|
|
v2LiveFileId = id;
|
|
switchCodeFile(id);
|
|
} else {
|
|
// Monaco not ready yet — queue and trigger init
|
|
codePendingQueue = [{ fname, content: code, lang }];
|
|
initCodeView();
|
|
}
|
|
} else if (v2LiveFileId) {
|
|
const file = codeFiles.find(f => f.id === v2LiveFileId);
|
|
if (file?.model) {
|
|
codeMonacoThrottle(file.model, code);
|
|
}
|
|
} else if (codePendingQueue.length) {
|
|
// Monaco still loading — update queued content
|
|
codePendingQueue[codePendingQueue.length - 1].content = code;
|
|
}
|
|
}
|
|
|
|
async function codeAiSend() {
|
|
const input = document.getElementById('code-ai-input');
|
|
const message = input.value.trim();
|
|
if (!message) return;
|
|
input.value = ''; input.style.height = '';
|
|
|
|
codeAiAppend('user', message);
|
|
codeAiHistory.push({ role: 'user', content: message });
|
|
|
|
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, history: historyForAPI, useTools: true, sessionId: codeAiSessionId, ...(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
|
|
let liveFileId = null, liveLang = '', liveCodeBuf = '', liveInBlock = false;
|
|
|
|
function codeAiLiveUpdate(fullReply) {
|
|
// Find the last unclosed code block
|
|
const fenceRe = /```([\w\-]*)\s*\n/g;
|
|
let lastOpen = null, lastClose = -1, m;
|
|
let pos = 0;
|
|
while ((m = fenceRe.exec(fullReply)) !== null) {
|
|
if (lastOpen === null) {
|
|
lastOpen = { lang: m[1] || 'text', start: m.index + m[0].length };
|
|
} else {
|
|
lastClose = m.index;
|
|
lastOpen = null;
|
|
}
|
|
}
|
|
if (!lastOpen) { liveInBlock = false; return; }
|
|
// We're inside an open code block
|
|
const code = fullReply.slice(lastOpen.start);
|
|
const lang = lastOpen.lang;
|
|
if (!liveInBlock) {
|
|
// Wait until the block looks like a real file (not a short run-command snippet)
|
|
if (!codeIsOpenable(code, lang)) return;
|
|
liveInBlock = true;
|
|
liveLang = lang;
|
|
const fname = codeDeriveName(code, lang);
|
|
if (monacoReady && monacoEditor) {
|
|
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 });
|
|
liveFileId = id;
|
|
switchCodeFile(id);
|
|
} else {
|
|
codePendingQueue = [{ fname, content: code, lang }];
|
|
initCodeView();
|
|
}
|
|
} else if (liveFileId) {
|
|
const file = codeFiles.find(f => f.id === liveFileId);
|
|
if (file?.model) {
|
|
liveCodeBuf = code;
|
|
codeMonacoThrottle(file.model, code);
|
|
}
|
|
} else if (codePendingQueue.length) {
|
|
codePendingQueue[codePendingQueue.length - 1].content = code;
|
|
}
|
|
}
|
|
|
|
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(); codeAiLiveUpdate(reply); }
|
|
} catch {}
|
|
}
|
|
}
|
|
liveInBlock = false; liveFileId = null;
|
|
codeMonacoThrottleFlush();
|
|
codeAiHistory.push({ role: 'assistant', content: reply || '(응답 없음)' });
|
|
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;
|
|
el.textContent = text;
|
|
msgs.appendChild(el);
|
|
codeAiScrollBottom();
|
|
return el;
|
|
}
|
|
|
|
// Render AI message with markdown code blocks parsed out
|
|
function codeAiRenderMarkdown(el, text) {
|
|
el.innerHTML = '';
|
|
const parts = text.split(/(```[\w\-]*[ \t]*\r?\n[\s\S]*?```)/g);
|
|
let codeCount = 0;
|
|
const codeBlocks = [];
|
|
for (const part of parts) {
|
|
const m = part.match(/^```([\w\-]*)[ \t]*\r?\n([\s\S]*?)```\s*$/);
|
|
if (m) {
|
|
const lang = m[1] || 'text';
|
|
const code = m[2];
|
|
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';
|
|
const openBtn = document.createElement('button');
|
|
openBtn.className = 'code-ai-open-btn';
|
|
openBtn.textContent = '📂 에디터에서 열기';
|
|
openBtn.onclick = () => codeAiOpenInEditor(code, lang);
|
|
header.appendChild(langLabel);
|
|
header.appendChild(openBtn);
|
|
const pre = document.createElement('pre');
|
|
pre.textContent = code;
|
|
wrapper.appendChild(header);
|
|
wrapper.appendChild(pre);
|
|
el.appendChild(wrapper);
|
|
codeCount++;
|
|
} else if (part) {
|
|
const span = document.createElement('span');
|
|
span.style.whiteSpace = 'pre-wrap';
|
|
span.textContent = part;
|
|
el.appendChild(span);
|
|
}
|
|
}
|
|
// Auto-open all code blocks in editor
|
|
if (codeBlocks.length) codeBlocks.forEach(b => codeAiOpenInEditor(b.code, b.lang));
|
|
}
|
|
|
|
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, else a random one.
|
|
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(/[*\/>]+$/, '');
|
|
const ext = CODE_EXT_MAP[(lang || '').toLowerCase()] || ((lang && lang.length <= 6) ? lang : 'txt');
|
|
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) {
|
|
if (existing.model) existing.model.setValue(content);
|
|
else existing.content = content;
|
|
switchCodeFile(existing.id);
|
|
} else {
|
|
codeNewFile(fname, content);
|
|
}
|
|
}
|
|
|
|
function codeAiOpenInEditor(code, lang) {
|
|
if (!codeIsOpenable(code, lang)) return;
|
|
if (!monacoReady || !monacoEditor) {
|
|
const fname = codeDeriveName(code, lang);
|
|
codePendingQueue.push({ fname, content: code, lang });
|
|
initCodeView();
|
|
return;
|
|
}
|
|
const fname = codeDeriveName(code, lang);
|
|
const existing = codeFiles.find(f => f.name === fname);
|
|
if (existing) {
|
|
if (existing.model) existing.model.setValue(code);
|
|
else existing.content = code;
|
|
switchCodeFile(existing.id);
|
|
} else {
|
|
codeNewFile(fname, code);
|
|
}
|
|
}
|
|
|
|
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) : 14,
|
|
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();
|
|
})();
|