- Add alternating block background colors (ff-block-even/odd) for functions/classes - Add ¶빈줄숨김 / ▓블록색상 toggle buttons to file panel sidebar (below file tree) - Fix terminal error send: filter Windows prompt+command lines, only send last Traceback block, strip pygame header noise - Show live server log bubble during AI tool calls, fade out on completion Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
5762 lines
267 KiB
JavaScript
5762 lines
267 KiB
JavaScript
// ============================================================================
|
||
// code.js — Standalone Code Editor page (code.html)
|
||
//
|
||
// This is a self-contained extract of the code-editor view that also lives
|
||
// inside the main app (web-ui/app.js). It is loaded ONLY by code.html so the
|
||
|
||
const _DBG = false; // set true to enable streaming/editor debug logs
|
||
// editor can run in its own browser tab (opened from the 💻 코드 sidebar tab),
|
||
// mirroring how the slide wizard opens pptx-wizard.html.
|
||
//
|
||
// The main app keeps its own in-page code mode (for live code streaming from
|
||
// the chat agent), so this file intentionally does not depend on app.js.
|
||
// If you change code-view behaviour, update both this file and app.js.
|
||
// ============================================================================
|
||
|
||
// ── Shared helpers (self-contained copies of app.js utilities) ──────────────
|
||
function getAuthToken() { try { return sessionStorage.getItem('smallclaw_token') || localStorage.getItem('smallclaw_token') || ''; } catch { return ''; } }
|
||
function setAuthToken(t) { try { if (t) { sessionStorage.setItem('smallclaw_token', t); localStorage.setItem('smallclaw_token', t); } else { sessionStorage.removeItem('smallclaw_token'); } } catch {} }
|
||
function authHeaders(extra) { const t = getAuthToken(); const h = { ...(extra || {}) }; if (t) h['Authorization'] = 'Bearer ' + t; return h; }
|
||
function getCookie(name) {
|
||
const value = `; ${document.cookie}`;
|
||
const parts = value.split(`; ${name}=`);
|
||
if (parts.length === 2) return parts.pop().split(';').shift();
|
||
return '';
|
||
}
|
||
function escHtml(s) {
|
||
return String(s || '').replace(/&/g,'&').replace(/\\/g,'\').replace(/`/g,'`').replace(/\$/g,'$').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
async function api(path, opts = {}) {
|
||
const API = window.location.origin || 'http://127.0.0.1:18789';
|
||
const { headers: optsHeaders, credentials: _discard, ...restOpts } = opts;
|
||
const headers = authHeaders({ 'Content-Type': 'application/json', ...optsHeaders });
|
||
const r = await fetch(API + path, { headers, ...restOpts });
|
||
if (r.status === 401) { setAuthToken(''); window.location.href = '/login.html'; throw new Error('Authentication required'); }
|
||
const ct = String(r.headers.get('content-type') || '').toLowerCase();
|
||
const bodyText = await r.text();
|
||
if (!ct.includes('application/json')) {
|
||
const preview = bodyText.slice(0, 120).replace(/\s+/g, ' ');
|
||
throw new Error(`Expected JSON from ${path}, got ${ct || 'unknown'} (HTTP ${r.status}) :: ${preview}`);
|
||
}
|
||
let data = null;
|
||
try { data = bodyText ? JSON.parse(bodyText) : null; }
|
||
catch { const preview = bodyText.slice(0, 120).replace(/\s+/g, ' '); throw new Error(`Invalid JSON from ${path} (HTTP ${r.status}) :: ${preview}`); }
|
||
if (!r.ok) { const msg = (data && (data.error || data.message)) ? String(data.error || data.message) : `HTTP ${r.status}`; throw new Error(msg); }
|
||
return data;
|
||
}
|
||
|
||
// ── Code Editor View ─────────────────────────────────────────────────────────
|
||
let monacoEditor = null;
|
||
let monacoReady = false;
|
||
let codeFiles = []; // [{id, name, content, language, model}]
|
||
let activeCodeFileId = null;
|
||
let codeAiHistory = [];
|
||
// Strip code blocks from a history entry before storage.
|
||
// File content is re-sent via contextMsg on every request, so repeating it in history
|
||
// is pure waste and the main source of payload bloat. Keep only prose/summary text.
|
||
function _histEntry(text) {
|
||
if (!text) return text;
|
||
const stripped = text
|
||
.replace(/```[\s\S]*?```/g, '[코드]') // fenced code blocks → placeholder
|
||
.replace(/<think>[\s\S]*?<\/think>/gi, '') // thinking tags
|
||
.trim();
|
||
return stripped.slice(0, 3000) || text.slice(0, 200); // prose cap; fallback if all stripped
|
||
}
|
||
|
||
// Returns history entries (excluding last/current user msg) up to maxMsgs count AND maxBytes total
|
||
function _histForAPI(hist, maxMsgs = 20, maxBytes = 150 * 1024) {
|
||
if (hist.length <= 1) return [];
|
||
const src = hist.slice(0, -1);
|
||
let bytes = 0;
|
||
const result = [];
|
||
for (let i = src.length - 1; i >= 0 && result.length < maxMsgs; i--) {
|
||
const size = (src[i].content || '').length;
|
||
if (bytes + size > maxBytes) break;
|
||
bytes += size;
|
||
result.unshift({ role: src[i].role, content: src[i].content });
|
||
}
|
||
return result;
|
||
}
|
||
let codeAiAbort = null;
|
||
let _codeAiAutoAccept = false; // when true: skip diff review, write files directly
|
||
let _autoAcceptTurn = false; // true during an auto-accept execute phase → triggers completion message
|
||
let codeAiSessionId = 'code_ai_' + Math.random().toString(36).slice(2, 10);
|
||
let activeProjectName = localStorage.getItem('code_active_project') || null;
|
||
let codeAiModel = localStorage.getItem('codeAiModel') ?? 'glm-5.1:cloud';
|
||
let codeAiTech = localStorage.getItem('codeAiTech') ?? 'auto'; // 'auto' | 'html' | 'python' | 'node'
|
||
let codeAiMode = localStorage.getItem('codeAiMode') || 'code'; // 'ask' | 'code'
|
||
let codeDiffState = {}; // { fileId: { originalText, modifiedText, hunks, decorationIds, viewZoneIds, resolved, rejected, onReject } }
|
||
|
||
const CODE_EXTS = new Set(['js','ts','jsx','tsx','py','html','htm','css','scss','java','c','cpp','cs','go','rs','php','rb','swift','kt','vue','svelte','json','yaml','yml','sh','sql']);
|
||
const isCodeFile = name => CODE_EXTS.has((name.split('.').pop() || '').toLowerCase());
|
||
|
||
const CODE_LANG_MAP = {
|
||
js:'javascript', ts:'typescript', jsx:'javascript', tsx:'typescript',
|
||
py:'python', rb:'ruby', go:'go', rs:'rust', java:'java', cs:'csharp',
|
||
cpp:'cpp', c:'c', php:'php', html:'html', css:'css', scss:'scss',
|
||
json:'json', yaml:'yaml', yml:'yaml', md:'markdown', sh:'shell',
|
||
bash:'shell', sql:'sql', xml:'xml', toml:'ini', vue:'html',
|
||
};
|
||
|
||
function codeDetectLang(name) {
|
||
const ext = (name || '').split('.').pop().toLowerCase();
|
||
return CODE_LANG_MAP[ext] || 'plaintext';
|
||
}
|
||
|
||
function updateCodeAiLabel() {
|
||
const label = codeAiModel || 'AI';
|
||
const input = document.getElementById('code-ai-input');
|
||
if (input) input.placeholder = label + '에게 질문... (예: 게임 만들어줘)';
|
||
}
|
||
|
||
function setCodeAiTech(tech) {
|
||
codeAiTech = tech;
|
||
localStorage.setItem('codeAiTech', tech);
|
||
}
|
||
|
||
function initCodeView() {
|
||
updateCodeAiLabel();
|
||
setCodeAiTech(codeAiTech);
|
||
if (monacoReady) { setTimeout(() => { if (monacoEditor) monacoEditor.layout(); }, 50); return; }
|
||
if (typeof require === 'undefined') return;
|
||
require.config({ paths: { vs: '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: "'D2Coding', 'D2 Coding', monospace",
|
||
minimap: { enabled: true },
|
||
scrollBeyondLastLine: false,
|
||
wordWrap: 'on',
|
||
renderWhitespace: 'selection',
|
||
smoothScrolling: true,
|
||
cursorBlinking: 'smooth',
|
||
mouseWheelZoom: true,
|
||
tabSize: 2,
|
||
});
|
||
monacoEditor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, codeSaveFile);
|
||
codeFlushPendingQueue();
|
||
if (!codeFiles.length && !codePendingQueue.length) {
|
||
// Don't create a placeholder file — the model will create files as needed
|
||
renderCodeFileTabs();
|
||
} else renderCodeFileTabs();
|
||
setupCodeVResize();
|
||
csbRestoreSettings();
|
||
setupCodeEditorDrop();
|
||
});
|
||
}
|
||
|
||
function codeNewFile(name, content) {
|
||
// In client-local + project mode: tabs always store bare filenames; the project
|
||
// subfolder prefix is handled transparently by _clientProjectPath at disk I/O time.
|
||
// In server mode with activeProjectName: prefix IS part of the tab name (existing behaviour).
|
||
let fname = name;
|
||
if (!fname) {
|
||
const raw = prompt('파일 이름:', 'untitled.py');
|
||
if (!raw || !raw.trim()) return;
|
||
fname = (localDirHandle && activeProjectName)
|
||
? raw.trim()
|
||
: (activeProjectName ? `${activeProjectName}/${raw.trim()}` : raw.trim());
|
||
} else if (localDirHandle && activeProjectName) {
|
||
// Strip project prefix so tab names are always bare in client-local mode
|
||
fname = _clientBareFileName(fname.trim());
|
||
}
|
||
fname = fname.trim();
|
||
if (!fname) return;
|
||
const id = 'cf_' + Math.random().toString(36).slice(2, 8);
|
||
const lang = codeDetectLang(fname);
|
||
const model = monacoReady ? monaco.editor.createModel(content || '', lang) : null;
|
||
codeFiles.push({ id, name: fname, content: content || '', language: lang, model });
|
||
switchCodeFile(id);
|
||
}
|
||
|
||
function switchCodeFile(id) {
|
||
const prev = codeFiles.find(f => f.id === activeCodeFileId);
|
||
if (prev && monacoEditor) prev.content = monacoEditor.getValue();
|
||
_DBG && console.log(`[switchCodeFile] ${activeCodeFileId} → ${id} file=${(codeFiles.find(f => f.id === id) || {}).name || '?'}`);
|
||
activeCodeFileId = id;
|
||
const file = codeFiles.find(f => f.id === id);
|
||
if (!file || !monacoEditor) { renderCodeFileTabs(); return; }
|
||
if (!file.model) file.model = monaco.editor.createModel(file.content, file.language);
|
||
|
||
// If this file has an active inline diff, re-apply decorations
|
||
if (codeDiffState[id]) {
|
||
monacoEditor.setModel(file.model);
|
||
monacoEditor.focus();
|
||
_showDiffBar(id);
|
||
_applyInlineDiffDecorations(id);
|
||
} else {
|
||
monacoEditor.setModel(file.model);
|
||
monacoEditor.focus();
|
||
}
|
||
renderCodeFileTabs();
|
||
codeUpdateRunButton();
|
||
if (_hideBlankLines) setTimeout(_applyHideBlankLines, 50);
|
||
if (_blockFlipFlop) setTimeout(_applyBlockFlipFlop, 60);
|
||
}
|
||
|
||
function closeCodeFile(id, ev) {
|
||
if (ev) ev.stopPropagation();
|
||
const idx = codeFiles.findIndex(f => f.id === id);
|
||
if (idx === -1) return;
|
||
// Clean up diff view if active
|
||
if (codeDiffState[id]) cleanupCodeDiff(id);
|
||
const file = codeFiles[idx];
|
||
if (file.model) file.model.dispose();
|
||
codeFiles.splice(idx, 1);
|
||
if (!codeFiles.length) { codeNewFile('untitled.py', ''); return; }
|
||
if (activeCodeFileId === id)
|
||
switchCodeFile(codeFiles[Math.min(idx, codeFiles.length - 1)].id);
|
||
else
|
||
renderCodeFileTabs();
|
||
}
|
||
|
||
function renderCodeFileTabs() {
|
||
const bar = document.getElementById('code-file-tabs');
|
||
if (!bar) return;
|
||
bar.innerHTML = codeFiles.map(f => {
|
||
const active = f.id === activeCodeFileId;
|
||
const ds = codeDiffState[f.id];
|
||
const diff = !!ds;
|
||
const unresolved = diff ? ds.hunks.filter((_, i) => !ds.resolved.has(i)).length : 0;
|
||
return `<div class="code-file-tab${active ? ' active' : ''}${diff ? ' diff-active' : ''}" onclick="switchCodeFile('${f.id}')">
|
||
${unresolved > 0 ? `<span class="code-tab-dot">${unresolved}</span>` : ''}
|
||
<span>${escHtml(f.name)}</span>
|
||
<span class="code-file-tab-close" onclick="closeCodeFile('${f.id}',event)">×</span>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
let codeDirLabel = 'code';
|
||
|
||
// ── Local file system (File System Access API) ────────────────────────────
|
||
let localDirHandle = null;
|
||
let localTermPath = localStorage.getItem('code_local_term_path') || '';
|
||
|
||
async function idbHandleGet() {
|
||
return new Promise(resolve => {
|
||
const req = indexedDB.open('code_editor_v1', 1);
|
||
req.onupgradeneeded = e => e.target.result.createObjectStore('handles');
|
||
req.onsuccess = e => {
|
||
const r = e.target.result.transaction('handles', 'readonly').objectStore('handles').get('dir');
|
||
r.onsuccess = () => resolve(r.result || null);
|
||
r.onerror = () => resolve(null);
|
||
};
|
||
req.onerror = () => resolve(null);
|
||
});
|
||
}
|
||
async function idbHandleSet(handle) {
|
||
return new Promise(resolve => {
|
||
const req = indexedDB.open('code_editor_v1', 1);
|
||
req.onupgradeneeded = e => e.target.result.createObjectStore('handles');
|
||
req.onsuccess = e => {
|
||
const tx = e.target.result.transaction('handles', 'readwrite');
|
||
tx.objectStore('handles').put(handle, 'dir');
|
||
tx.oncomplete = resolve;
|
||
tx.onerror = resolve;
|
||
};
|
||
req.onerror = resolve;
|
||
});
|
||
}
|
||
async function restoreLocalDirHandle() {
|
||
try {
|
||
const handle = await idbHandleGet();
|
||
if (!handle) return;
|
||
const perm = await handle.queryPermission({ mode: 'readwrite' });
|
||
if (perm === 'granted') {
|
||
localDirHandle = handle;
|
||
updateLocalModeUI();
|
||
// Restore per-project AI session
|
||
if (activeProjectName) {
|
||
const savedId = localStorage.getItem('code_proj_session_' + activeProjectName);
|
||
const savedHist = localStorage.getItem('code_proj_history_' + activeProjectName);
|
||
if (savedId) codeAiSessionId = savedId;
|
||
try { if (savedHist) codeAiHistory = JSON.parse(savedHist); } catch {}
|
||
// 채팅 패널 렌더링 (switchProject와 동일)
|
||
const _msgs = document.getElementById('code-ai-messages');
|
||
if (_msgs) {
|
||
_msgs.innerHTML = '';
|
||
if (codeAiHistory.length) {
|
||
codeAiHistory.forEach(m => codeAiAppend(m.role === 'user' ? 'user' : 'assistant', m.content));
|
||
} else {
|
||
_renderSessionsList();
|
||
}
|
||
}
|
||
}
|
||
codeSbRefresh();
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
async function openLocalFolder() {
|
||
if (!window.showDirectoryPicker) {
|
||
alert('이 브라우저는 File System Access API를 지원하지 않습니다.\nChrome 또는 Edge를 사용해주세요.');
|
||
return;
|
||
}
|
||
try {
|
||
localDirHandle = await window.showDirectoryPicker({ mode: 'readwrite' });
|
||
await idbHandleSet(localDirHandle);
|
||
if (!localTermPath) {
|
||
const inp = document.getElementById('csb-termpath');
|
||
if (inp && !inp.value) inp.value = '';
|
||
}
|
||
updateLocalModeUI();
|
||
codeSbRefresh();
|
||
// Auto-load files if a project was already remembered
|
||
if (activeProjectName) _clientLoadProjectFiles();
|
||
} catch (e) {
|
||
if (e.name !== 'AbortError') codeAiAppend('system', '⚠️ 폴더 열기 실패: ' + (e.message || e));
|
||
}
|
||
}
|
||
|
||
function updateLocalModeUI() {
|
||
const btn = document.getElementById('code-local-btn');
|
||
const lbl = document.getElementById('csb-folder-label');
|
||
const folderBtn = document.getElementById('code-folder-btn');
|
||
const folderName = localDirHandle ? (localDirHandle.name || '로컬') : '폴더 없음';
|
||
if (btn) {
|
||
btn.title = localDirHandle ? `📂 ${folderName} — 클릭해서 변경` : '로컬 폴더 열기';
|
||
btn.style.color = localDirHandle ? '#22c55e' : '';
|
||
btn.textContent = localDirHandle ? '📂' : '🖥️';
|
||
btn.onclick = openLocalFolder;
|
||
}
|
||
if (lbl) lbl.textContent = localDirHandle ? folderName : '폴더 없음';
|
||
if (folderBtn) { const s = folderBtn.querySelector('span'); if (s) s.textContent = localDirHandle ? folderName : '폴더 없음'; }
|
||
const hdr = document.getElementById('code-header-dir');
|
||
if (hdr) hdr.textContent = localDirHandle ? (activeProjectName || folderName) : '폴더 없음';
|
||
const dd = document.getElementById('code-dropdown-dir');
|
||
if (dd) dd.textContent = localDirHandle ? `📁 ${folderName}` : '📁 폴더 없음';
|
||
}
|
||
|
||
const SKIP_DIRS = new Set(['node_modules', '__pycache__', '.git', 'dist', 'build', '.venv', 'venv', '.cache', '.next', 'out', 'coverage', 'target', '.idea', '.vs']);
|
||
// Per-file byte cap for AI context. A minified bundle / large data file can be few
|
||
// lines but tens of MB — line-count windowing misses it, so cap by bytes too.
|
||
const MAX_AI_FILE_BYTES = 256 * 1024; // 256KB
|
||
async function localListFiles(dirHandle, prefix) {
|
||
const files = [];
|
||
try {
|
||
for await (const [name, handle] of dirHandle.entries()) {
|
||
if (name.startsWith('.') || name.endsWith('.crswap')) continue;
|
||
const rel = prefix ? `${prefix}/${name}` : name;
|
||
if (handle.kind === 'directory') {
|
||
if (SKIP_DIRS.has(name)) continue;
|
||
files.push(...await localListFiles(handle, rel));
|
||
} else files.push(rel);
|
||
}
|
||
} catch {}
|
||
return files.sort();
|
||
}
|
||
|
||
async function localReadFile(filename) {
|
||
if (!localDirHandle) return null;
|
||
try {
|
||
const parts = filename.split('/');
|
||
let dir = localDirHandle;
|
||
for (let i = 0; i < parts.length - 1; i++) dir = await dir.getDirectoryHandle(parts[i]);
|
||
const fh = await dir.getFileHandle(parts[parts.length - 1]);
|
||
return await (await fh.getFile()).text();
|
||
} catch { return null; }
|
||
}
|
||
|
||
async function localWriteFile(filename, content) {
|
||
if (!localDirHandle) return false;
|
||
try {
|
||
const parts = filename.split('/');
|
||
let dir = localDirHandle;
|
||
for (let i = 0; i < parts.length - 1; i++) dir = await dir.getDirectoryHandle(parts[i], { create: true });
|
||
const fh = await dir.getFileHandle(parts[parts.length - 1], { create: true });
|
||
const w = await fh.createWritable();
|
||
await w.write(content);
|
||
await w.close();
|
||
return true;
|
||
} catch (e) { console.error('[localWriteFile]', e); return false; }
|
||
}
|
||
|
||
async function localDeleteFile(filename) {
|
||
if (!localDirHandle) return false;
|
||
try {
|
||
const parts = filename.split('/');
|
||
let dir = localDirHandle;
|
||
for (let i = 0; i < parts.length - 1; i++) dir = await dir.getDirectoryHandle(parts[i]);
|
||
await dir.removeEntry(parts[parts.length - 1]);
|
||
return true;
|
||
} catch { return false; }
|
||
}
|
||
|
||
// ── Client-local project path helpers ────────────────────────────────────
|
||
// "client-local" = browser File System Access API (localDirHandle).
|
||
// Distinguished from "server-local" = server's own workspace on disk.
|
||
// When client-local root + activeProjectName are both set, all disk I/O
|
||
// is scoped to the project subfolder. The model always sees bare filenames.
|
||
function _clientProjectPath(fname) {
|
||
if (!localDirHandle || !activeProjectName) return fname;
|
||
const bare = fname.startsWith(activeProjectName + '/')
|
||
? fname.slice(activeProjectName.length + 1) : fname;
|
||
return activeProjectName + '/' + bare;
|
||
}
|
||
function _clientBareFileName(fname) {
|
||
if (!activeProjectName) return fname;
|
||
return fname.startsWith(activeProjectName + '/')
|
||
? fname.slice(activeProjectName.length + 1) : fname;
|
||
}
|
||
|
||
// Close all open tabs that belong to the client-local project scope,
|
||
// then load every code file from the new activeProjectName subfolder.
|
||
async function _clientLoadProjectFiles() {
|
||
if (!localDirHandle || !activeProjectName) return;
|
||
|
||
// Close all existing tabs (they belonged to the previous project)
|
||
if (codeFiles.length) {
|
||
codeFiles.forEach(f => { try { f.model?.dispose(); } catch {} });
|
||
codeFiles.length = 0;
|
||
activeCodeFileId = null;
|
||
codeDiffState = {};
|
||
renderCodeFileTabs();
|
||
}
|
||
|
||
// Ensure the session's directory exists on disk (new session → no folder yet)
|
||
await localDirHandle.getDirectoryHandle(activeProjectName, { create: true }).catch(() => {});
|
||
|
||
const allFiles = await localListFiles(localDirHandle, '');
|
||
const projFiles = allFiles.filter(f =>
|
||
f.startsWith(activeProjectName + '/') && isCodeFile(f)
|
||
);
|
||
|
||
for (const fpath of projFiles) {
|
||
const bare = _clientBareFileName(fpath);
|
||
const content = await localReadFile(fpath);
|
||
if (content === null || content.length > 300_000) continue; // skip missing/huge
|
||
const id = 'cf_' + Math.random().toString(36).slice(2, 8);
|
||
const lang = codeDetectLang(bare);
|
||
const model = monacoReady ? monaco.editor.createModel(content, lang) : null;
|
||
codeFiles.push({ id, name: bare, content, language: lang, model });
|
||
}
|
||
|
||
renderCodeFileTabs();
|
||
// Switch to first file if any
|
||
if (codeFiles.length) switchCodeFile(codeFiles[0].id);
|
||
}
|
||
|
||
// ── Terminal output capture (for AI error feedback loop) ─────────────────
|
||
// Keeps a rolling plaintext buffer per shell (ANSI stripped). Used to detect
|
||
// errors after a run and offer one-click "AI에게 전송" from the terminal.
|
||
const _termBuffers = {}; // shellId → string[] (last 300 lines)
|
||
const _TERM_BUF_MAX = 300;
|
||
function _stripTerminal(raw) {
|
||
return raw
|
||
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') // OSC (title 등)
|
||
.replace(/\x1b\[[\d;?]*[a-zA-Z]/g, '') // CSI (색상, 커서, private mode)
|
||
.replace(/\x1b[^[\]]/g, '') // 기타 ESC 시퀀스
|
||
.replace(/[\x00-\x09\x0b-\x1f\x7f]/g, '') // 제어문자 (\n 제외)
|
||
.replace(/\r/g, '');
|
||
}
|
||
|
||
function _termBufferAppend(shellId, raw) {
|
||
if (!shellId) return;
|
||
const text = _stripTerminal(raw);
|
||
if (!text) return;
|
||
const lines = text.split('\n');
|
||
if (!_termBuffers[shellId]) _termBuffers[shellId] = [];
|
||
const buf = _termBuffers[shellId];
|
||
for (const l of lines) { if (l) buf.push(l); }
|
||
while (buf.length > _TERM_BUF_MAX) buf.shift();
|
||
_termDetectAndShowError(shellId);
|
||
}
|
||
|
||
const _ERROR_PATTERNS = [
|
||
/Traceback \(most recent call last\)/,
|
||
/(?:Syntax|Name|Type|Attribute|Import|Indentation|Value|Key|Index|Zero|Overflow|Runtime|OS|IO|File)Error:/,
|
||
/^\s*\^\s*$/, // Python caret indicator
|
||
/Uncaught (?:TypeError|ReferenceError|SyntaxError|Error):/,
|
||
/Error: .+\n?\s+at /,
|
||
/\[Error\]/i,
|
||
];
|
||
let _lastErrorShellId = null;
|
||
let _lastErrorText = '';
|
||
|
||
function _termDetectAndShowError(shellId) {
|
||
const buf = _termBuffers[shellId];
|
||
if (!buf || buf.length < 2) return;
|
||
const recent = buf.slice(-40).join('\n');
|
||
const hasError = _ERROR_PATTERNS.some(re => re.test(recent));
|
||
if (!hasError) return;
|
||
// Deduplicate — don't re-show if same error already triggered
|
||
if (_lastErrorShellId === shellId && _lastErrorText === recent) return;
|
||
_lastErrorShellId = shellId;
|
||
_lastErrorText = recent;
|
||
_showTerminalErrorButton(shellId, recent);
|
||
}
|
||
|
||
function _showTerminalErrorButton(shellId, errorText) {
|
||
const btn = document.getElementById('code-term-ai-error-btn');
|
||
if (btn) { btn.style.display = ''; btn.style.background = '#dc2626'; btn.style.color = '#fff'; btn.dataset.shellId = shellId; }
|
||
}
|
||
|
||
function _sendTerminalErrorToAI(errorText) {
|
||
const input = document.getElementById('code-ai-input');
|
||
if (!input) return;
|
||
|
||
// Filter noise lines
|
||
const filtered = errorText.split('\n').filter(l => {
|
||
const t = l.trim();
|
||
if (!t) return false;
|
||
if (/^[A-Za-z]:\\.+>/.test(t)) return false; // Windows 프롬프트 (명령어 포함)
|
||
if (/^[~/][^\s]*[$#]\s*/.test(t)) return false; // Unix 프롬프트
|
||
if (/^Hello from the pygame community/.test(t)) return false;
|
||
if (/^https?:\/\/www\.pygame\.org/.test(t)) return false;
|
||
return true;
|
||
});
|
||
|
||
// 마지막 Traceback 블록만 추출
|
||
let blockStart = 0;
|
||
for (let i = filtered.length - 1; i >= 0; i--) {
|
||
if (/Traceback \(most recent call last\)/.test(filtered[i])) {
|
||
blockStart = i; break;
|
||
}
|
||
}
|
||
const trimmed = filtered.slice(blockStart).slice(-30).join('\n');
|
||
if (!trimmed) return;
|
||
|
||
input.value = '다음 에러를 고쳐줘. edit_file 도구로 파일을 직접 수정해:\n```\n' + trimmed + '\n```';
|
||
input.style.height = '';
|
||
input.dispatchEvent(new Event('input'));
|
||
codeAiSend();
|
||
}
|
||
|
||
function _getXtermVisibleText(term, maxLines = 80) {
|
||
const buf = term.buffer.active;
|
||
const lines = [];
|
||
const endRow = buf.baseY + buf.cursorY;
|
||
const startRow = Math.max(0, endRow - maxLines);
|
||
for (let i = startRow; i <= endRow; i++) {
|
||
const line = buf.getLine(i);
|
||
if (!line) continue;
|
||
const t = line.translateToString(true);
|
||
if (t.trim()) lines.push(t);
|
||
}
|
||
return lines.join('\n');
|
||
}
|
||
|
||
function _sendActiveTermErrorToAI() {
|
||
const btn = document.getElementById('code-term-ai-error-btn');
|
||
const shellId = btn?.dataset.shellId || _lastErrorShellId || activeCodeTermShellId;
|
||
const shell = codeTermShells.find(s => s.id === shellId) || codeTermShells.find(s => s.id === activeCodeTermShellId);
|
||
const text = (shell?.term ? _getXtermVisibleText(shell.term) : '')
|
||
|| (shellId && _termBuffers[shellId] || Object.values(_termBuffers).at(-1))?.slice(-40).join('\n')
|
||
|| _lastErrorText;
|
||
if (text) _sendTerminalErrorToAI(text);
|
||
if (btn) { btn.style.background = '#1a3a28'; btn.style.color = '#22c55e'; }
|
||
}
|
||
|
||
// Called when a run completes successfully (exit code 0) — clear error button
|
||
function _termOnRunSuccess(shellId) {
|
||
_lastErrorShellId = null;
|
||
_lastErrorText = '';
|
||
const btn = document.getElementById('code-term-ai-error-btn');
|
||
if (btn) btn.style.display = 'none';
|
||
}
|
||
|
||
// ── Companion WebSocket (local Python execution) ───────────────────────────
|
||
let companionWs = null;
|
||
let companionConnected = false;
|
||
const COMPANION_URL = 'ws://127.0.0.1:9000';
|
||
let companionPlatform = 'unix'; // 'windows' | 'unix', set on shell_ready
|
||
let companionHome = ''; // home directory from companion
|
||
const localShellIds = new Set(); // shell IDs routed to companion PTY (tab id = shell id)
|
||
|
||
function companionConnect() {
|
||
if (companionWs && (companionWs.readyState === WebSocket.OPEN || companionWs.readyState === WebSocket.CONNECTING)) return;
|
||
try { companionWs = new WebSocket(COMPANION_URL); } catch { return; }
|
||
companionWs.onopen = () => { companionConnected = true; updateCompanionStatus(true); };
|
||
companionWs.onclose = () => {
|
||
companionConnected = false; companionWs = null;
|
||
updateCompanionStatus(false);
|
||
setTimeout(companionConnect, 5000);
|
||
};
|
||
companionWs.onerror = () => { companionConnected = false; };
|
||
companionWs.onmessage = (ev) => {
|
||
let msg; try { msg = JSON.parse(ev.data); } catch { return; }
|
||
handleCompanionMessage(msg);
|
||
};
|
||
}
|
||
|
||
function updateCompanionStatus(connected) {
|
||
const dot = document.getElementById('companion-dot');
|
||
if (!dot) return;
|
||
dot.style.background = connected ? '#22c55e' : '#6b7280';
|
||
dot.title = connected ? '🐍 Python companion 연결됨' : '🐍 Python companion 미연결 (companion.py 실행 필요) — 클릭해서 재연결';
|
||
const lsBtn = document.getElementById('local-shell-btn');
|
||
if (lsBtn) lsBtn.style.color = connected ? '#22c55e' : '#6b7280';
|
||
}
|
||
|
||
function handleCompanionMessage(msg) {
|
||
// shell_* messages → route by msg.shellId; run messages → active code terminal
|
||
const isShellMsg = msg.type === 'shell_output' || msg.type === 'shell_ready' || msg.type === 'shell_exit';
|
||
const targetId = isShellMsg && msg.shellId ? msg.shellId : activeCodeTermShellId;
|
||
if (!codeTermOpen) toggleCodeTerminal();
|
||
setTimeout(() => {
|
||
const s = codeTermShells.find(s => s.id === targetId) || codeTermShells.find(s => s.id === activeCodeTermShellId);
|
||
if (!s || !s.term) return;
|
||
if (msg.type === 'shell_ready') {
|
||
if (msg.platform) companionPlatform = msg.platform;
|
||
if (msg.home) companionHome = msg.home;
|
||
_companionCdProject(msg.shellId);
|
||
}
|
||
else if (msg.type === 'shell_output') { s.term.write(msg.data); _termBufferAppend(s.id, msg.data); }
|
||
else if (msg.type === 'shell_exit') { s.term.write(`\r\n\x1b[33m[셸 종료 ${msg.code}]\x1b[0m\r\n`); }
|
||
else if (msg.type === 'started') s.term.write(`\r\n\x1b[32m▶ ${msg.filename}\x1b[0m\r\n`);
|
||
else if (msg.type === 'output') { s.term.write(msg.data.replace(/(?<!\r)\n/g, '\r\n')); _termBufferAppend(s.id, msg.data); }
|
||
else if (msg.type === 'exit') { s.term.write(`\r\n\x1b[33m[exit ${msg.code}]\x1b[0m\r\n`); if (msg.code === 0) _termOnRunSuccess(s.id); }
|
||
else if (msg.type === 'error') { s.term.write(`\r\n\x1b[31m오류: ${msg.data}\x1b[0m\r\n`); _termBufferAppend(s.id, msg.data); }
|
||
else if (msg.type === 'killed') s.term.write(`\r\n\x1b[31m[강제 종료]\x1b[0m\r\n`);
|
||
}, codeTermOpen ? 0 : 500);
|
||
}
|
||
|
||
// ⏹ 버튼: 활성 터미널에서 실행 중인 프로세스를 중단
|
||
function codeTermKill() {
|
||
const s = codeTermShells.find(s => s.id === activeCodeTermShellId);
|
||
if (!s) return;
|
||
if (localShellIds.has(s.id)) {
|
||
// 로컬(companion) PTY 셸: Ctrl+C만 전송 (PTY 자체는 유지)
|
||
if (companionConnected && companionWs)
|
||
companionWs.send(JSON.stringify({ type: 'input', shellId: s.id, data: '\x03' }));
|
||
} else {
|
||
// 서버 셸: PTY에 Ctrl+C 전송
|
||
if (termWsConnected) termWsSend({ type: 'pty_input', shellId: s.id, data: '\x03' });
|
||
}
|
||
if (s.term) s.term.focus();
|
||
}
|
||
|
||
function _companionCdProject(shellId) {
|
||
if (!companionConnected || !companionWs || !activeProjectName) return;
|
||
// shellId 없으면 activeCodeTermShellId로 폴백 (구버전 companion 호환)
|
||
const sid = shellId || activeCodeTermShellId;
|
||
const isWin = companionPlatform === 'windows';
|
||
const sep = isWin ? '\\' : '/';
|
||
const base = (localTermPath || companionHome || '').replace(/[/\\]$/, '');
|
||
if (!base) return;
|
||
const target = base + sep + activeProjectName;
|
||
const cmd = isWin
|
||
? `set "PROMPT=$E[A$P$G" && cd /d "${target}" && cls\r`
|
||
: `cd ${JSON.stringify(target)} && clear\r`;
|
||
companionWs.send(JSON.stringify({ type: 'input', shellId: sid, data: cmd }));
|
||
}
|
||
|
||
function csbLoadCodeDir() {}
|
||
|
||
async function codeSaveFile() {
|
||
const file = codeFiles.find(f => f.id === activeCodeFileId);
|
||
if (!file || !monacoEditor) return;
|
||
// A pending AI diff keeps the FULL modified text live in the model, so saving
|
||
// now would persist hunks the user hasn't decided on yet. Make them resolve first.
|
||
if (codeDiffState[activeCodeFileId]) {
|
||
codeAiAppend('system', '⚠️ AI 제안 변경이 검토 대기 중입니다. 먼저 ✅/❌로 적용하거나 취소한 뒤 저장하세요.');
|
||
return;
|
||
}
|
||
file.content = monacoEditor.getValue();
|
||
if (!localDirHandle) { codeAiAppend('system', '⚠️ 폴더를 먼저 선택해주세요 (🖥️ 버튼)'); return; }
|
||
const ok = await localWriteFile(_clientProjectPath(file.name), file.content);
|
||
codeAiAppend('system', ok ? `💾 저장: ${file.name}` : '⚠️ 저장 실패');
|
||
if (ok) codeSbRefresh();
|
||
}
|
||
|
||
// ── Terminal infrastructure (PTY over WebSocket) ─────────────────────────
|
||
let termWs = null;
|
||
let termWsConnected = false;
|
||
|
||
function termWsConnect() {
|
||
if (termWs && (termWs.readyState === WebSocket.OPEN || termWs.readyState === WebSocket.CONNECTING)) return;
|
||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||
let url = `${proto}//${location.host}/ws`;
|
||
const token = sessionStorage.getItem('smallclaw_token') || getCookie('smallclaw_session');
|
||
if (token) url += `?token=${encodeURIComponent(token)}`;
|
||
try { termWs = new WebSocket(url); } catch { return; }
|
||
termWs.onopen = () => {
|
||
termWsConnected = true;
|
||
for (const [shellId, inst] of xtermInstances) {
|
||
if (!inst.ptyReady && !localShellIds.has(shellId)) { inst.ptyReady = true; termWsSend({ type: 'pty_spawn', shellId, cols: inst.term.cols, rows: inst.term.rows }); }
|
||
}
|
||
};
|
||
termWs.onmessage = (ev) => {
|
||
let msg; try { msg = JSON.parse(ev.data); } catch { return; }
|
||
if (msg.type === 'pty_output') { const i = xtermInstances.get(msg.shellId); if (i) { i.term.write(msg.data); _termBufferAppend(msg.shellId, msg.data); } }
|
||
else if (msg.type === 'pty_exit') { const i = xtermInstances.get(msg.shellId); if (i) { i.ptyReady = false; i.term.write('\r\n\x1b[90m[exited]\x1b[0m\r\n'); if (msg.code === 0) _termOnRunSuccess(msg.shellId); } }
|
||
else if (msg.type === 'pty_error') { const i = xtermInstances.get(msg.shellId); if (i) i.term.write('\r\n\x1b[31mPTY error: '+(msg.error||'?')+'\x1b[0m\r\n'); }
|
||
};
|
||
termWs.onclose = () => {
|
||
termWsConnected = false; termWs = null;
|
||
for (const [,i] of xtermInstances) i.ptyReady = false;
|
||
setTimeout(termWsConnect, 3000);
|
||
};
|
||
termWs.onerror = () => { termWsConnected = false; };
|
||
}
|
||
|
||
function termWsSend(msg) {
|
||
if (termWs && termWs.readyState === WebSocket.OPEN) { termWs.send(JSON.stringify(msg)); return true; }
|
||
return false;
|
||
}
|
||
|
||
const xtermInstances = new Map();
|
||
|
||
function createXtermShell(container, shellId) {
|
||
const id = shellId || 'sh_' + Math.random().toString(36).slice(2, 8);
|
||
const sub = document.createElement('div');
|
||
sub.id = 'xterm-' + id;
|
||
sub.style.cssText = 'position:absolute;inset:0';
|
||
container.appendChild(sub);
|
||
const term = new Terminal({
|
||
theme: { background:'#0C0C0C', foreground:'#CCCCCC', cursor:'#22c55e',
|
||
selectionBackground:'rgba(34,197,94,0.3)', red:'#E74856', green:'#16C60C',
|
||
yellow:'#C19C00', blue:'#3B78FF', cyan:'#61D6D6', white:'#CCCCCC',
|
||
brightBlack:'#767676', brightGreen:'#69FF69', brightWhite:'#FFFFFF' },
|
||
fontFamily: "'IBM Plex Mono', 'D2Coding', 'NanumGothicCoding', 'Malgun Gothic', 'AppleGothic', monospace",
|
||
fontSize: 13, lineHeight: 1.4, cursorBlink: true, scrollback: 5000,
|
||
});
|
||
const fitAddon = new FitAddon.FitAddon();
|
||
const webLinksAddon = new WebLinksAddon.WebLinksAddon();
|
||
term.loadAddon(fitAddon); term.loadAddon(webLinksAddon);
|
||
term.open(sub); fitAddon.fit();
|
||
// Intercept Ctrl+` inside xterm to toggle the terminal panel.
|
||
// Return false = xterm won't process this key; return true = xterm handles it normally.
|
||
term.attachCustomKeyEventHandler(e => {
|
||
if (e.type === 'keydown' && e.ctrlKey && (e.key === '`' || e.code === 'Backquote')) {
|
||
toggleCodeTerminal();
|
||
return false;
|
||
}
|
||
return true;
|
||
});
|
||
const inst = { term, fitAddon, ptyReady: false };
|
||
xtermInstances.set(id, inst);
|
||
term.onData(data => {
|
||
if (localShellIds.has(id)) {
|
||
if (companionConnected && companionWs) companionWs.send(JSON.stringify({ type: 'input', shellId: id, data }));
|
||
} else if (termWsConnected && inst.ptyReady) {
|
||
termWsSend({ type: 'pty_input', shellId: id, data });
|
||
}
|
||
});
|
||
term.onResize(({ cols, rows }) => {
|
||
if (localShellIds.has(id)) {
|
||
if (companionConnected && companionWs) companionWs.send(JSON.stringify({ type: 'resize', shellId: id, cols, rows }));
|
||
} else if (termWsConnected && inst.ptyReady) {
|
||
termWsSend({ type: 'pty_resize', shellId: id, cols, rows });
|
||
}
|
||
});
|
||
if (!localShellIds.has(id) && termWsConnected) { inst.ptyReady = true; termWsSend({ type: 'pty_spawn', shellId: id, cols: term.cols, rows: term.rows }); }
|
||
return { id, term, fitAddon, inst };
|
||
}
|
||
|
||
// Init WebSocket connection on load
|
||
fetch('/api/status').then(r => r.json()).catch(() => ({})).finally(termWsConnect);
|
||
|
||
// ── Chat terminal (for chat view) ─────────────────────────────────────────
|
||
let chatTermShells = [];
|
||
let activeChatTermShellId = null;
|
||
let chatTermOpen = false;
|
||
let chatTermHeight = 280;
|
||
|
||
function toggleChatTerminal() {
|
||
const panel = document.getElementById('chat-terminal-panel');
|
||
const btn = document.getElementById('chat-term-btn');
|
||
if (!panel) return;
|
||
chatTermOpen = !chatTermOpen;
|
||
if (chatTermOpen) {
|
||
panel.style.display = 'flex';
|
||
if (btn) btn.classList.add('active');
|
||
if (!chatTermShells.length) { addChatTermShell(); }
|
||
else { setTimeout(() => { const s = chatTermShells.find(s => s.id === activeChatTermShellId); if (s?.fitAddon) s.fitAddon.fit(); if (s?.term) s.term.focus(); }, 50); }
|
||
} else {
|
||
panel.style.display = 'none';
|
||
if (btn) btn.classList.remove('active');
|
||
}
|
||
}
|
||
function renderChatShellTabs() {
|
||
const bar = document.getElementById('chat-shell-tabs'); if (!bar) return;
|
||
bar.innerHTML = chatTermShells.map(s => {
|
||
const a = s.id === activeChatTermShellId;
|
||
return `<div class="ct-tab${a?' active':''}" onclick="switchChatTermShell('${s.id}')"><svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor" style="opacity:.6"><path d="M14 3v10H2V3h12zM1 2v12h14V2H1zm5 9l3-2.5L6 6v5z"/></svg><span>${escHtml(s.name)}</span>${chatTermShells.length>1?`<span class="ct-tab-close" onclick="closeChatTermShell('${s.id}',event)">×</span>`:''}</div>`;
|
||
}).join('');
|
||
}
|
||
function addChatTermShell() {
|
||
const id = 'ct_' + Math.random().toString(36).slice(2,8);
|
||
chatTermShells.push({ id, name:'bash', ptyReady:false, term:null, fitAddon:null });
|
||
switchChatTermShell(id);
|
||
}
|
||
function switchChatTermShell(id) {
|
||
const cur = chatTermShells.find(s => s.id === activeChatTermShellId);
|
||
if (cur) { const sub = document.getElementById('xterm-'+cur.id); if (sub) sub.style.display='none'; }
|
||
activeChatTermShellId = id;
|
||
const shell = chatTermShells.find(s => s.id === id); if (!shell) return;
|
||
if (shell.term) {
|
||
const sub = document.getElementById('xterm-'+shell.id); if (sub) sub.style.display='';
|
||
if (shell.fitAddon) shell.fitAddon.fit(); shell.term.focus();
|
||
} else {
|
||
const container = document.getElementById('chat-term-container');
|
||
if (container) { const r = createXtermShell(container, shell.id); shell.term=r.term; shell.fitAddon=r.fitAddon; shell.ptyReady=r.inst.ptyReady; setTimeout(()=>{r.fitAddon.fit();r.term.focus();},50); }
|
||
}
|
||
renderChatShellTabs();
|
||
}
|
||
function closeChatTermShell(id, ev) {
|
||
if (ev) ev.stopPropagation();
|
||
const idx = chatTermShells.findIndex(s => s.id === id);
|
||
if (idx === -1 || chatTermShells.length <= 1) return;
|
||
termWsSend({ type:'pty_kill', shellId:id });
|
||
const shell = chatTermShells[idx]; if (shell.term) shell.term.dispose();
|
||
xtermInstances.delete(id); const sub = document.getElementById('xterm-'+id); if (sub) sub.remove();
|
||
chatTermShells.splice(idx, 1);
|
||
if (activeChatTermShellId === id) switchChatTermShell(chatTermShells[Math.min(idx,chatTermShells.length-1)].id);
|
||
else renderChatShellTabs();
|
||
}
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
const handle = document.getElementById('chat-term-resize-handle'); if (!handle) return;
|
||
let startY=0, startH=0;
|
||
handle.addEventListener('mousedown', function(e) {
|
||
startY=e.clientY; startH=chatTermHeight;
|
||
const onMove = e2 => { const dy=startY-e2.clientY; chatTermHeight=Math.max(120,Math.min(window.innerHeight*.7,startH+dy)); const p=document.getElementById('chat-terminal-panel'); if(p) p.style.height=chatTermHeight+'px'; const s=chatTermShells.find(s=>s.id===activeChatTermShellId); if(s?.fitAddon) s.fitAddon.fit(); };
|
||
const onUp = () => { document.removeEventListener('mousemove',onMove); document.removeEventListener('mouseup',onUp); };
|
||
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); e.preventDefault();
|
||
});
|
||
});
|
||
|
||
// ── Code editor terminal ──────────────────────────────────────────────────
|
||
let codeTermShells = [];
|
||
let activeCodeTermShellId = null;
|
||
let codeTermOpen = false;
|
||
|
||
function toggleCodeTerminal() {
|
||
const panel = document.getElementById('code-term-panel');
|
||
const btn = document.getElementById('code-term-toggle-btn');
|
||
if (!panel) return;
|
||
codeTermOpen = !codeTermOpen;
|
||
if (codeTermOpen) {
|
||
panel.style.display = 'flex';
|
||
if (btn) btn.classList.add('active');
|
||
if (!codeTermShells.length) { requestAnimationFrame(() => { if (companionConnected) addLocalShell(); else 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.classList.remove('active');
|
||
}
|
||
if (monacoEditor) setTimeout(() => monacoEditor.layout(), 60);
|
||
}
|
||
// ── Browser Preview Panel ────────────────────────────────────────────────────
|
||
let codePreviewOpen = false;
|
||
function toggleCodePreview() {
|
||
const panel = document.getElementById('code-preview-panel');
|
||
const btn = document.getElementById('code-preview-toggle-btn');
|
||
if (!panel) return;
|
||
codePreviewOpen = !codePreviewOpen;
|
||
if (codePreviewOpen) {
|
||
panel.style.display = 'flex';
|
||
if (btn) btn.classList.add('active');
|
||
const urlInput = document.getElementById('code-preview-url');
|
||
const saved = localStorage.getItem('code_preview_url');
|
||
if (saved && urlInput) urlInput.value = saved;
|
||
const iframe = document.getElementById('code-preview-iframe');
|
||
if (iframe && !iframe.src) iframe.src = (urlInput ? urlInput.value : 'http://127.0.0.1:8000');
|
||
} else {
|
||
panel.style.display = 'none';
|
||
if (btn) btn.classList.remove('active');
|
||
}
|
||
if (monacoEditor) setTimeout(() => monacoEditor.layout(), 60);
|
||
}
|
||
function codePreviewRefresh() {
|
||
const urlInput = document.getElementById('code-preview-url');
|
||
const iframe = document.getElementById('code-preview-iframe');
|
||
if (!iframe) return;
|
||
const url = urlInput ? urlInput.value.trim() : 'http://127.0.0.1:8000';
|
||
if (url) { localStorage.setItem('code_preview_url', url); iframe.src = url; }
|
||
}
|
||
// Preview panel resize drag
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
const drag = document.getElementById('code-preview-drag');
|
||
const panel = document.getElementById('code-preview-panel');
|
||
if (!drag || !panel) return;
|
||
let startY=0, startH=0;
|
||
drag.addEventListener('mousedown', e => {
|
||
startY=e.clientY; startH=panel.offsetHeight; e.preventDefault();
|
||
const onMove = e2 => {
|
||
const dy = startY - e2.clientY;
|
||
panel.style.height = Math.max(100, Math.min(window.innerHeight*.7, startH+dy)) + 'px';
|
||
};
|
||
const onUp = () => { document.removeEventListener('mousemove',onMove); document.removeEventListener('mouseup',onUp); drag.style.background=''; };
|
||
drag.addEventListener('mouseover', ()=>drag.style.background='rgba(96,165,250,.4)');
|
||
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp);
|
||
});
|
||
});
|
||
|
||
function renderCodeTermTabs() {
|
||
const bar = document.getElementById('code-term-tabs'); if (!bar) return;
|
||
bar.innerHTML = codeTermShells.map(s => {
|
||
const a = s.id === activeCodeTermShellId;
|
||
const label = s.local ? '🖥️ 로컬' : 'bash';
|
||
return `<div style="display:flex;align-items:center;gap:5px;padding:0 10px;cursor:pointer;font-family:'IBM Plex Mono',monospace;font-size:12px;color:${a?'#c8e6c0':'#3d6b4f'};background:${a?'#0c0c0c':'transparent'};border-top:${a?'2px solid #22c55e':'2px solid transparent'};white-space:nowrap;user-select:none" onclick="switchCodeTermShell('${s.id}')">
|
||
<svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor" style="opacity:.7"><path d="M14 3v10H2V3h12zM1 2v12h14V2H1zm5 9l3-2.5L6 6v5z"/></svg>
|
||
${label}${codeTermShells.length>1?` <span onclick="closeCodeTermShell('${s.id}',event)" style="color:#3d6b4f;margin-left:3px">×</span>`:''}
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function addLocalShell() {
|
||
if (!companionConnected) {
|
||
alert('companion.py가 실행되지 않았습니다.\n\n터미널에서: python companion.py');
|
||
return;
|
||
}
|
||
const id = 'cdt_' + Math.random().toString(36).slice(2, 8);
|
||
codeTermShells.push({ id, name: '로컬', local: true, ptyReady: false, term: null, fitAddon: null });
|
||
localShellIds.add(id);
|
||
if (!codeTermOpen) toggleCodeTerminal();
|
||
switchCodeTermShell(id);
|
||
setTimeout(() => {
|
||
const shell = codeTermShells.find(s => s.id === id);
|
||
if (companionConnected && companionWs && shell?.term) {
|
||
if (shell.fitAddon) shell.fitAddon.fit();
|
||
const rows = shell.term.rows > 0 ? shell.term.rows : 24;
|
||
const cols = shell.term.cols > 0 ? shell.term.cols : 80;
|
||
companionWs.send(JSON.stringify({ type: 'shell', shellId: id, rows, cols }));
|
||
}
|
||
}, 300);
|
||
}
|
||
function addCodeTermShell() {
|
||
const id = 'cdt_' + Math.random().toString(36).slice(2,8);
|
||
codeTermShells.push({ id, name:'☁️서버', ptyReady:false, term:null, fitAddon:null });
|
||
switchCodeTermShell(id);
|
||
if (localTermPath) {
|
||
setTimeout(() => {
|
||
const s = codeTermShells.find(s => s.id === id);
|
||
if (s && s.ptyReady) termWsSend({ type: 'pty_input', shellId: id, data: `cd ${JSON.stringify(localTermPath)}\r` });
|
||
else setTimeout(() => {
|
||
const s2 = codeTermShells.find(s => s.id === id);
|
||
if (s2 && s2.ptyReady) termWsSend({ type: 'pty_input', shellId: id, data: `cd ${JSON.stringify(localTermPath)}\r` });
|
||
}, 800);
|
||
}, 400);
|
||
}
|
||
}
|
||
|
||
function csbTermPathSave() {
|
||
const inp = document.getElementById('csb-termpath');
|
||
localTermPath = (inp?.value || '').trim();
|
||
localStorage.setItem('code_local_term_path', localTermPath);
|
||
codeAiAppend('system', localTermPath ? `✅ 터미널 경로 설정: ${localTermPath}` : '터미널 경로 초기화됨');
|
||
}
|
||
function switchCodeTermShell(id) {
|
||
const cur = codeTermShells.find(s => s.id === activeCodeTermShellId);
|
||
if (cur) { const sub = document.getElementById('xterm-'+cur.id); if (sub) sub.style.display='none'; }
|
||
activeCodeTermShellId = id;
|
||
const shell = codeTermShells.find(s => s.id === id); if (!shell) return;
|
||
if (shell.term) {
|
||
const sub = document.getElementById('xterm-'+shell.id); if (sub) sub.style.display='';
|
||
if (shell.fitAddon) shell.fitAddon.fit(); shell.term.focus();
|
||
} else {
|
||
const container = document.getElementById('code-term-container');
|
||
if (container) { const r = createXtermShell(container, shell.id); shell.term=r.term; shell.fitAddon=r.fitAddon; shell.ptyReady=r.inst.ptyReady; setTimeout(()=>{r.fitAddon.fit();r.term.focus();},50); }
|
||
}
|
||
renderCodeTermTabs();
|
||
}
|
||
function closeCodeTermShell(id, ev) {
|
||
if (ev) ev.stopPropagation();
|
||
const idx = codeTermShells.findIndex(s => s.id === id);
|
||
if (idx === -1 || codeTermShells.length <= 1) return;
|
||
if (localShellIds.has(id)) {
|
||
localShellIds.delete(id);
|
||
if (companionConnected && companionWs)
|
||
companionWs.send(JSON.stringify({ type: 'shell_close', shellId: id }));
|
||
} else {
|
||
termWsSend({ type:'pty_kill', shellId:id });
|
||
}
|
||
const shell = codeTermShells[idx]; if (shell.term) shell.term.dispose();
|
||
xtermInstances.delete(id); const sub = document.getElementById('xterm-'+id); if (sub) sub.remove();
|
||
codeTermShells.splice(idx, 1);
|
||
if (activeCodeTermShellId === id) switchCodeTermShell(codeTermShells[Math.min(idx,codeTermShells.length-1)].id);
|
||
else renderCodeTermTabs();
|
||
}
|
||
// Resize drag (top edge → pull up/down)
|
||
document.addEventListener('click', e => { if (!e.target.closest('#csb-ctx')) csbCtxClose(); });
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
const drag = document.getElementById('code-term-drag');
|
||
const panel = document.getElementById('code-term-panel');
|
||
if (!drag || !panel) return;
|
||
let startY=0, startH=0;
|
||
drag.addEventListener('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');
|
||
}
|
||
// Restore preview URL input
|
||
const savedUrl = localStorage.getItem('code_preview_url');
|
||
if (savedUrl) { const el = document.getElementById('code-preview-url'); if (el) el.value = savedUrl; }
|
||
})();
|
||
|
||
// Ctrl+` shortcut — for Monaco / non-xterm focus; xterm handles it via attachCustomKeyEventHandler
|
||
document.addEventListener('keydown', e => {
|
||
if (e.ctrlKey && (e.key === '`' || e.code === 'Backquote')) {
|
||
e.preventDefault();
|
||
toggleCodeTerminal();
|
||
}
|
||
});
|
||
|
||
// ── Code Run: multi-mode execution ──────────────────────────────────────────
|
||
|
||
const CODE_RUN_MODES = {
|
||
html: [
|
||
{ id: 'browser', icon: '🌐', label: '브라우저', desc: '새 탭에서 열기' },
|
||
{ id: 'terminal', icon: '⌨', label: '터미널', desc: '웹 서버로 실행' },
|
||
],
|
||
htm: 'html',
|
||
py: [
|
||
{ id: 'companion', icon: '🖥️', label: '로컬 실행', desc: 'companion.py (pygame 지원)' },
|
||
{ id: 'pyodide', icon: '🐍', label: 'Pyodide', desc: '브라우저에서 실행' },
|
||
{ id: 'companion', icon: '🖥️', label: 'python3 로컬', desc: '로컬 셸에서 실행' },
|
||
],
|
||
js: [
|
||
{ id: 'companion', icon: '🖥️', label: 'node 로컬', desc: '로컬 셸에서 실행' },
|
||
{ id: 'terminal', icon: '☁️', label: 'node 서버', desc: '서버 터미널에서 실행' },
|
||
],
|
||
ts: [
|
||
{ id: 'companion', icon: '🖥️', label: 'ts-node 로컬', desc: '로컬 셸에서 실행' },
|
||
{ id: 'terminal', icon: '☁️', label: 'ts-node 서버', desc: '서버 터미널에서 실행' },
|
||
],
|
||
go: [
|
||
{ id: 'terminal', icon: '⌨', label: 'go run', desc: '터미널에서 실행' },
|
||
],
|
||
rs: [
|
||
{ id: 'terminal', icon: '⌨', label: 'rustc', desc: '터미널에서 실행' },
|
||
],
|
||
cpp: [
|
||
{ id: 'terminal', icon: '⌨', label: 'g++', desc: '터미널에서 실행' },
|
||
],
|
||
c: [
|
||
{ id: 'terminal', icon: '⌨', label: 'gcc', desc: '터미널에서 실행' },
|
||
],
|
||
sh: [
|
||
{ id: 'terminal', icon: '⌨', label: 'bash', desc: '터미널에서 실행' },
|
||
],
|
||
};
|
||
|
||
function codeGetRunModes(ext) {
|
||
const modes = CODE_RUN_MODES[ext];
|
||
if (!modes) return [{ id: 'terminal', icon: '⌨', label: '터미널', desc: '터미널에서 실행' }];
|
||
if (typeof modes === 'string') return CODE_RUN_MODES[modes];
|
||
return modes;
|
||
}
|
||
|
||
let _codeRunMenuOpen = false;
|
||
|
||
function codeToggleRunMenu() {
|
||
const menu = document.getElementById('code-run-menu');
|
||
if (_codeRunMenuOpen) {
|
||
menu.style.display = 'none';
|
||
_codeRunMenuOpen = false;
|
||
return;
|
||
}
|
||
const file = codeFiles.find(f => f.id === activeCodeFileId);
|
||
if (!file) return;
|
||
const ext = file.name.split('.').pop().toLowerCase();
|
||
const modes = codeGetRunModes(ext);
|
||
menu.innerHTML = modes.map(m =>
|
||
`<div onclick="codeRunWith('${m.id}')" style="display:flex;align-items:center;gap:8px;padding:8px 12px;cursor:pointer;transition:background .1s" onmouseover="this.style.background='#2d4a2d'" onmouseout="this.style.background=''">` +
|
||
`<span style="font-size:14px">${m.icon}</span>` +
|
||
`<div style="flex:1"><div style="font-size:12px;font-weight:600;color:#e5e7eb">${m.label}</div><div style="font-size:10px;color:#9ca3af">${m.desc}</div></div>` +
|
||
`</div>`
|
||
).join('');
|
||
menu.style.display = 'block';
|
||
_codeRunMenuOpen = true;
|
||
const close = (e) => {
|
||
if (!menu.contains(e.target) && !e.target.closest('#code-run-dropdown')) {
|
||
menu.style.display = 'none';
|
||
_codeRunMenuOpen = false;
|
||
document.removeEventListener('click', close);
|
||
}
|
||
};
|
||
setTimeout(() => document.addEventListener('click', close), 0);
|
||
}
|
||
|
||
function codeUpdateRunButton() {
|
||
const file = codeFiles.find(f => f.id === activeCodeFileId);
|
||
const iconEl = document.getElementById('code-run-icon');
|
||
const labelEl = document.getElementById('code-run-label');
|
||
if (!file || !iconEl) return;
|
||
const ext = file.name.split('.').pop().toLowerCase();
|
||
const modes = codeGetRunModes(ext);
|
||
const primary = modes[0];
|
||
iconEl.textContent = primary.icon;
|
||
labelEl.textContent = primary.label;
|
||
}
|
||
|
||
function codeRunDefault() {
|
||
const file = codeFiles.find(f => f.id === activeCodeFileId);
|
||
if (!file || !monacoEditor) return;
|
||
file.content = monacoEditor.getValue();
|
||
const ext = file.name.split('.').pop().toLowerCase();
|
||
const modes = codeGetRunModes(ext);
|
||
codeRunWith(modes[0].id);
|
||
}
|
||
|
||
// Resolve <link href="x.css"> and <script src="x.js"> from local filesystem,
|
||
// inline them into the HTML, then return a blob URL for window.open.
|
||
async function _openHtmlWithInlinedAssets(file) {
|
||
let html = file.content;
|
||
const dir = file.name.includes('/') ? file.name.split('/').slice(0, -1).join('/') : '';
|
||
|
||
// Inline <link rel="stylesheet" href="...">
|
||
const linkRe = /<link\b[^>]*\brel=["']stylesheet["'][^>]*\bhref=["']([^"']+)["'][^>]*\/?>/gi;
|
||
const cssMatches = [...html.matchAll(linkRe)];
|
||
for (const m of cssMatches) {
|
||
const href = m[1];
|
||
if (href.startsWith('http') || href.startsWith('//')) continue;
|
||
const fpath = dir ? `${dir}/${href}` : href;
|
||
const css = await localReadFile(_clientProjectPath(fpath)) ?? await localReadFile(fpath);
|
||
if (css !== null) {
|
||
html = html.replace(m[0], `<style>/* ${href} */\n${css}\n</style>`);
|
||
}
|
||
}
|
||
|
||
// Inline <script src="..."> (skip external URLs)
|
||
const scriptRe = /<script\b([^>]*)\bsrc=["']([^"']+)["'][^>]*><\/script>/gi;
|
||
const scriptMatches = [...html.matchAll(scriptRe)];
|
||
for (const m of scriptMatches) {
|
||
const src = m[2];
|
||
if (src.startsWith('http') || src.startsWith('//')) continue;
|
||
const fpath = dir ? `${dir}/${src}` : src;
|
||
const js = await localReadFile(_clientProjectPath(fpath)) ?? await localReadFile(fpath);
|
||
if (js !== null) {
|
||
html = html.replace(m[0], `<script${m[1]}>\n/* ${src} */\n${js}\n</script>`);
|
||
}
|
||
}
|
||
|
||
const blob = new Blob([html], { type: 'text/html' });
|
||
return URL.createObjectURL(blob);
|
||
}
|
||
|
||
function codeRunWith(mode) {
|
||
const menu = document.getElementById('code-run-menu');
|
||
if (menu) menu.style.display = 'none';
|
||
_codeRunMenuOpen = false;
|
||
|
||
const file = codeFiles.find(f => f.id === activeCodeFileId);
|
||
if (!file || !monacoEditor) return;
|
||
// Don't run while an AI diff is pending — the model holds undecided changes.
|
||
if (codeDiffState[activeCodeFileId]) {
|
||
codeAiAppend('system', '⚠️ AI 제안 변경이 검토 대기 중입니다. 먼저 ✅/❌로 적용하거나 취소한 뒤 실행하세요.');
|
||
return;
|
||
}
|
||
file.content = monacoEditor.getValue();
|
||
const fpath = `${codeDirLabel}/${file.name}`;
|
||
|
||
if (mode === 'browser') {
|
||
const ext = file.name.split('.').pop().toLowerCase();
|
||
if ((ext === 'html' || ext === 'htm') && localDirHandle) {
|
||
// Inline linked CSS and JS so the blob URL can resolve them
|
||
_openHtmlWithInlinedAssets(file).then(url => {
|
||
const w = window.open(url, '_blank');
|
||
if (!w) codeAiAppend('system', '⚠️ 팝업 차단됨 — 브라우저에서 팝업을 허용해주세요');
|
||
setTimeout(() => URL.revokeObjectURL(url), 120000);
|
||
});
|
||
} else {
|
||
const mime = ext === 'css' ? 'text/css' : ext === 'js' ? 'text/javascript' : 'text/plain';
|
||
const blob = new Blob([file.content], { type: mime });
|
||
const url = URL.createObjectURL(blob);
|
||
const w = window.open(url, '_blank');
|
||
if (!w) codeAiAppend('system', '⚠️ 팝업 차단됨 — 브라우저에서 팝업을 허용해주세요');
|
||
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (mode === 'companion') {
|
||
if (!companionConnected) {
|
||
codeAiAppend('system', '⚠️ companion.py가 실행되지 않았습니다. 로컬에서 python companion.py 를 실행하세요.');
|
||
return;
|
||
}
|
||
|
||
if (localDirHandle) {
|
||
// Local folder is open → run via companion shell PTY so output stays in the local tab.
|
||
// Ensure local shell tab exists and is front.
|
||
const existingLocalId = [...localShellIds].find(id => codeTermShells.find(s => s.id === id));
|
||
const needsShell = !existingLocalId;
|
||
if (needsShell) addLocalShell();
|
||
else { if (!codeTermOpen) toggleCodeTerminal(); switchCodeTermShell(existingLocalId); }
|
||
|
||
const ext = file.name.split('.').pop().toLowerCase();
|
||
const bare = _clientBareFileName(file.name);
|
||
const base = (localTermPath || companionHome || '').replace(/[/\\]$/, '');
|
||
// Detect Windows from platform flag OR from drive-letter prefix (C:\...) in base path
|
||
const isWin = companionPlatform === 'windows' || /^[A-Za-z]:/.test(base);
|
||
const sep = isWin ? '\\' : '/';
|
||
const proj = activeProjectName ? sep + activeProjectName : '';
|
||
// Normalise all separators in the assembled path to match the platform
|
||
const rawPath = base ? base + proj + sep + bare : bare;
|
||
const normPath = isWin ? rawPath.replace(/\//g, '\\') : rawPath.replace(/\\/g, '/');
|
||
const localPath = `"${normPath}"`;
|
||
const pyCmd = isWin ? 'python' : 'python3';
|
||
const cmd = (ext === 'js' ? `node ${localPath}` :
|
||
ext === 'ts' ? `npx ts-node ${localPath}` :
|
||
`${pyCmd} ${localPath}`) + '\r';
|
||
|
||
setTimeout(() => {
|
||
const targetShellId = [...localShellIds].find(id => codeTermShells.find(s => s.id === id));
|
||
if (companionConnected && companionWs && targetShellId)
|
||
companionWs.send(JSON.stringify({ type: 'input', shellId: targetShellId, data: cmd }));
|
||
}, needsShell ? 900 : 0);
|
||
} else {
|
||
// No local folder → send code directly (one-shot run, output to active terminal)
|
||
if (!codeTermOpen) toggleCodeTerminal();
|
||
setTimeout(() => {
|
||
companionWs.send(JSON.stringify({ type: 'run', code: file.content, filename: file.name }));
|
||
}, codeTermOpen ? 0 : 500);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (mode === 'pyodide') {
|
||
sessionStorage.setItem('py_run_code', file.content);
|
||
sessionStorage.setItem('py_run_name', file.name);
|
||
const w = window.open('/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);
|
||
// Don't null _monacoThrottleModel here — keep it alive for codeMonacoThrottleFlush.
|
||
// The timer callback applies the latest throttled value, but a later flush
|
||
// (called when streaming ends) must still be able to apply any final value
|
||
// that arrived after this timer fired.
|
||
}
|
||
}, 80);
|
||
}
|
||
function codeMonacoThrottleFlush() {
|
||
if (_monacoThrottleTimer) { clearTimeout(_monacoThrottleTimer); _monacoThrottleTimer = null; }
|
||
if (_monacoThrottleModel && _monacoThrottleValue !== undefined) {
|
||
_monacoThrottleModel.setValue(_monacoThrottleValue);
|
||
}
|
||
_monacoThrottleModel = null;
|
||
}
|
||
|
||
function codeFlushPendingQueue() {
|
||
if (!monacoReady || !monacoEditor || !codePendingQueue.length) return;
|
||
for (const item of codePendingQueue) {
|
||
const existing = codeFiles.find(f => f.name === item.fname);
|
||
if (existing) {
|
||
const oldContent = existing.model ? existing.model.getValue() : existing.content;
|
||
if (oldContent !== item.content) {
|
||
switchCodeFile(existing.id);
|
||
showCodeDiff(existing.id, oldContent, item.content);
|
||
}
|
||
} else {
|
||
const id = 'cf_' + Math.random().toString(36).slice(2, 8);
|
||
const model = monaco.editor.createModel(item.content, codeDetectLang(item.fname));
|
||
codeFiles.push({ id, name: item.fname, content: item.content, language: codeDetectLang(item.fname), model });
|
||
if (!_v2LiveState.fileId) _v2LiveState.fileId = id;
|
||
}
|
||
}
|
||
codePendingQueue = [];
|
||
if (_v2LiveState.fileId) switchCodeFile(_v2LiveState.fileId);
|
||
}
|
||
|
||
// ── Shared live-streaming parser ────────────────────────────────────────────
|
||
// Detects the last unclosed ```code block in a streaming reply and mirrors it
|
||
// into the editor: creates/reuses a tab, throttles model updates, and renames
|
||
// the tab once a real filename surfaces. `state` is a mutable holder
|
||
// { inBlock, lang, fileId } so each caller keeps its own streaming context.
|
||
function codeLiveStreamUpdate(fullReply, state) {
|
||
if (typeof codeIsOpenable !== 'function') return;
|
||
// Parse ALL code blocks using toggle: opening ```lang\n starts a block,
|
||
// bare ``` closes it. This correctly pairs fences and excludes explanation
|
||
// text between blocks. The model may split one file across multiple blocks
|
||
// (e.g. 37 lines + explanation + continuation) — we concatenate all blocks
|
||
// so the editor shows the complete program.
|
||
//
|
||
// CRITICAL: Only match ``` at the beginning of a line (with optional leading
|
||
// whitespace). Without this check, ``` inside code (e.g. in comments like
|
||
// `# ``` or strings) would be misinterpreted as a fence, causing the code
|
||
// block to end prematurely and the editor to clear mid-stream.
|
||
const fenceRe = /```([\w\-]*)[ \t]*\r?\n/g;
|
||
const blocks = [];
|
||
let inBlock = false, blockLang = '', blockStart = 0, m;
|
||
while ((m = fenceRe.exec(fullReply)) !== null) {
|
||
// Require ``` at start of line (only whitespace before it) — prevents
|
||
// false matches inside code (comments, strings, etc.)
|
||
const lineStart = m.index === 0 ? 0 : fullReply.lastIndexOf('\n', m.index - 1) + 1;
|
||
const prefix = fullReply.slice(lineStart, m.index);
|
||
if (prefix.trim() !== '') continue; // ``` is mid-line, skip
|
||
if (!inBlock) {
|
||
blockLang = m[1] || 'text';
|
||
blockStart = m.index + m[0].length;
|
||
inBlock = true;
|
||
} else {
|
||
blocks.push({ lang: blockLang, code: fullReply.slice(blockStart, m.index) });
|
||
inBlock = false;
|
||
}
|
||
}
|
||
if (inBlock) {
|
||
// Unclosed block — still streaming
|
||
blocks.push({ lang: blockLang, code: fullReply.slice(blockStart), streaming: true });
|
||
}
|
||
if (!blocks.length) { state.inBlock = false; return; }
|
||
|
||
// Concatenate all code blocks (same or different language)
|
||
let code = '';
|
||
let lang = blocks[blocks.length - 1].lang || 'text';
|
||
for (const block of blocks) {
|
||
if (code) code += '\n';
|
||
code += block.code;
|
||
if (block.lang && block.lang !== 'text') lang = block.lang;
|
||
}
|
||
code = code.replace(/\n+$/, '');
|
||
_DBG && console.log(`[codeLiveStream] blocks=${blocks.length} lines=${code.split('\n').length} inBlock=${state.inBlock} fileId=${state.fileId} lang=${lang}`);
|
||
|
||
// ── Diff block handling ──────────────────────────────────────────────
|
||
// When the language is 'diff', the block contains a unified diff that
|
||
// should be applied to an existing file (not replace it entirely).
|
||
if (lang === 'diff') {
|
||
if (!state.inBlock) {
|
||
state.inBlock = true;
|
||
state.lang = lang;
|
||
state.isDiff = true;
|
||
// We'll accumulate the diff content and apply after streaming.
|
||
// Priority: explicit filename in diff > active tab.
|
||
// The model names the file it's editing in the diff header or filename comment —
|
||
// that explicit name wins so the right tab gets focused automatically.
|
||
const fname = codeDeriveName(code, lang)
|
||
|| code.match(/^(?:---|\+\+\+)\s+(?:[ab]\/)?(.+?)(?:\t|$)/m)?.[1]?.trim();
|
||
const fnameFile = fname ? (
|
||
codeFiles.find(f => f.name === fname) ||
|
||
codeFiles.find(f => f.name.endsWith('/' + fname))
|
||
) : null;
|
||
const existing = fnameFile
|
||
|| (state.targetFileId && codeFiles.find(f => f.id === state.targetFileId))
|
||
|| codeFiles.find(f => f.id === activeCodeFileId);
|
||
if (existing) {
|
||
state.originalContent = existing.model ? existing.model.getValue() : existing.content;
|
||
state.fileId = existing.id;
|
||
state.diffAccumulated = code;
|
||
switchCodeFile(existing.id);
|
||
} else {
|
||
// No matching file found — create a placeholder
|
||
const id = 'cf_' + Math.random().toString(36).slice(2, 8);
|
||
const model = monacoReady ? monaco.editor.createModel(code, 'diff') : null;
|
||
codeFiles.push({ id, name: fname || 'diff.patch', content: code, language: 'diff', model });
|
||
state.fileId = id;
|
||
state.originalContent = '';
|
||
state.diffAccumulated = code;
|
||
switchCodeFile(id);
|
||
}
|
||
} else if (state.isDiff && state.fileId) {
|
||
// Continue accumulating diff content — keep the original file in the editor;
|
||
// the diff will be applied when streaming completes (see codeLiveStreamUpdate end-of-stream)
|
||
state.diffAccumulated = code;
|
||
}
|
||
return;
|
||
}
|
||
|
||
// ── Full code block handling ────────────────────────────────────────────
|
||
// Detect if the model accidentally wrapped a diff in a non-diff code fence
|
||
// (e.g. ```python with +prefix/-prefix lines) — treat it as a diff block instead.
|
||
// Reuses _isDiffCode (the same +/-/@@ ratio heuristic).
|
||
if (lang !== 'diff' && _isDiffCode(lang, code)) {
|
||
// Model output diff content inside a non-diff code fence — redirect to diff handling
|
||
state.inBlock = true;
|
||
state.lang = 'diff';
|
||
state.isDiff = true;
|
||
// Diff content wrapped in a non-diff fence — target the active/context file too.
|
||
const fname = codeDeriveName(code, lang);
|
||
const existing = (state.targetFileId && codeFiles.find(f => f.id === state.targetFileId))
|
||
|| codeFiles.find(f => f.name === fname)
|
||
|| codeFiles.find(f => f.id === activeCodeFileId);
|
||
if (existing) {
|
||
state.originalContent = existing.model ? existing.model.getValue() : existing.content;
|
||
state.fileId = existing.id;
|
||
state.diffAccumulated = code;
|
||
switchCodeFile(existing.id);
|
||
} else {
|
||
const id = 'cf_' + Math.random().toString(36).slice(2, 8);
|
||
const model = monacoReady ? monaco.editor.createModel(code, codeDetectLang(fname)) : null;
|
||
codeFiles.push({ id, name: fname, content: code, language: codeDetectLang(fname), model });
|
||
state.fileId = id;
|
||
state.originalContent = '';
|
||
state.diffAccumulated = code;
|
||
switchCodeFile(id);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (!state.inBlock) {
|
||
// Wait until the block looks like a real file (not a short run-command snippet)
|
||
if (!codeIsOpenable(code, lang)) return;
|
||
state.inBlock = true;
|
||
state.lang = lang;
|
||
const fname = codeDeriveName(code, lang);
|
||
_DBG && console.log(`[codeLiveStream] NEW BLOCK: fname=${fname} lines=${code.split('\n').length}`);
|
||
if (monacoReady && monacoEditor) {
|
||
// Reuse existing tab with same name instead of creating a duplicate
|
||
const existing = codeFiles.find(f => f.name === fname);
|
||
if (existing) {
|
||
// Save original content so we can show a diff after streaming completes
|
||
state.originalContent = existing.model ? existing.model.getValue() : existing.content;
|
||
state.fileId = existing.id;
|
||
// If editing existing file: keep original in editor, show streamed changes as decorations
|
||
if (state.originalContent && state.originalContent.trim()) {
|
||
state.streamingModifiedText = code;
|
||
_updateStreamingDiffDecorations(state.fileId, state.originalContent, code);
|
||
} else {
|
||
switchCodeFile(existing.id);
|
||
}
|
||
} else {
|
||
const id = 'cf_' + Math.random().toString(36).slice(2, 8);
|
||
const model = monaco.editor.createModel(code, codeDetectLang(fname));
|
||
codeFiles.push({ id, name: fname, content: code, language: codeDetectLang(fname), model });
|
||
state.fileId = id;
|
||
switchCodeFile(id);
|
||
}
|
||
} else {
|
||
// Monaco not ready yet — queue and trigger init
|
||
codePendingQueue = [{ fname, content: code, lang }];
|
||
initCodeView();
|
||
}
|
||
} else if (state.fileId && !state.isDiff) {
|
||
_DBG && console.log(`[codeLiveStream] UPDATE: fileId=${state.fileId} lines=${code.split('\n').length} origContent=${!!state.originalContent} diff=${state.isDiff}`);
|
||
const file = codeFiles.find(f => f.id === state.fileId);
|
||
if (file?.model) {
|
||
// If editing existing file with original content: stream as diff decorations
|
||
if (state.originalContent && state.originalContent.trim()) {
|
||
state.streamingModifiedText = code;
|
||
// Keep the original text in the editor — update streaming diff decorations
|
||
_updateStreamingDiffDecorations(state.fileId, state.originalContent, code);
|
||
// Don't call codeMonacoThrottle here — we keep original in editor
|
||
} else {
|
||
codeMonacoThrottle(file.model, code);
|
||
}
|
||
// Rename tab if a filename comment appears later in the stream
|
||
const betterName = codeDeriveName(code, state.lang);
|
||
if (betterName && !betterName.startsWith('ai_') && file.name.startsWith('ai_')) {
|
||
file.name = betterName;
|
||
file.language = codeDetectLang(betterName);
|
||
renderCodeFileTabs();
|
||
}
|
||
}
|
||
} else if (codePendingQueue.length) {
|
||
// Monaco still loading — update queued content
|
||
codePendingQueue[codePendingQueue.length - 1].content = code;
|
||
}
|
||
}
|
||
|
||
// ── Real-time streaming diff decorations ──────────────────────────────────
|
||
// While the model streams code, compare original vs streamed content and
|
||
// highlight changed/added lines in green — without replacing the editor content.
|
||
// This is the VSCode Copilot approach: keep original, show changes as overlay.
|
||
|
||
// Throttled streaming diff to avoid excessive decoration updates
|
||
let _streamingDiffTimer = null;
|
||
let _streamingDiffFileId = null;
|
||
let _streamingDiffOriginal = '';
|
||
let _streamingDiffModified = '';
|
||
|
||
function _updateStreamingDiffDecorations(fileId, originalText, modifiedText) {
|
||
_streamingDiffFileId = fileId;
|
||
_streamingDiffOriginal = originalText;
|
||
_streamingDiffModified = modifiedText;
|
||
if (_streamingDiffTimer) return; // already scheduled
|
||
_streamingDiffTimer = setTimeout(() => {
|
||
_streamingDiffTimer = null;
|
||
_applyStreamingDiffDecorations(_streamingDiffFileId, _streamingDiffOriginal, _streamingDiffModified);
|
||
}, 120);
|
||
}
|
||
|
||
function _applyStreamingDiffDecorations(fileId, originalText, modifiedText) {
|
||
if (!monacoReady || !monacoEditor || !window.monaco) return;
|
||
const file = codeFiles.find(f => f.id === fileId);
|
||
if (!file || file.id !== activeCodeFileId) return;
|
||
|
||
// Make sure editor still shows original content
|
||
const currentContent = file.model ? file.model.getValue() : file.content;
|
||
if (currentContent !== originalText) {
|
||
// Editor was changed externally — don't override
|
||
return;
|
||
}
|
||
|
||
// Highlight changed regions while keeping the original text in the editor.
|
||
// (We can't reliably place added/deleted lines until streaming completes, so we
|
||
// only color the affected lines.)
|
||
const origLines = originalText.split('\n');
|
||
const hunks = computeDiffHunks(originalText, modifiedText);
|
||
if (!hunks.length) {
|
||
// No changes yet — remove any streaming decorations
|
||
if (file.model && file._streamingDecos) {
|
||
file.model.deltaDecorations(file._streamingDecos, []);
|
||
file._streamingDecos = null;
|
||
}
|
||
return;
|
||
}
|
||
|
||
// We're keeping original text in editor. Show decorations on lines that have changes.
|
||
// For "change" hunks: highlight the original lines that will be modified
|
||
// For "add" hunks: highlight the line before where additions will appear
|
||
// For "delete" hunks: highlight lines that will be removed with strikethrough-like style
|
||
const streamingDecos = [];
|
||
for (const hunk of hunks) {
|
||
if (hunk.type === 'delete') {
|
||
// Lines that will be removed
|
||
for (let line = hunk.oldStart; line <= hunk.oldEnd && line <= origLines.length; line++) {
|
||
streamingDecos.push({
|
||
range: new monaco.Range(line, 1, line, 1),
|
||
options: {
|
||
className: 'inline-diff-streaming-delete',
|
||
glyphMarginClassName: 'inline-diff-glyph-delete',
|
||
isWholeLine: true,
|
||
minimap: { color: '#dc354580', position: monaco.editor.MinimapPosition.Inline },
|
||
overviewRuler: { color: '#dc354560', position: monaco.editor.OverviewRulerLane.Full },
|
||
}
|
||
});
|
||
}
|
||
} else if (hunk.type === 'add') {
|
||
// New lines will appear after oldStart — highlight the line before
|
||
const insertAfter = Math.max(1, hunk.oldStart - 1);
|
||
streamingDecos.push({
|
||
range: new monaco.Range(insertAfter, 1, insertAfter, 1),
|
||
options: {
|
||
className: 'inline-diff-streaming-add-marker',
|
||
isWholeLine: true,
|
||
}
|
||
});
|
||
} else {
|
||
// Change: old lines will be replaced
|
||
for (let line = hunk.oldStart; line <= hunk.oldEnd && line <= origLines.length; line++) {
|
||
streamingDecos.push({
|
||
range: new monaco.Range(line, 1, line, 1),
|
||
options: {
|
||
className: 'inline-diff-streaming-change',
|
||
glyphMarginClassName: 'inline-diff-glyph-change',
|
||
isWholeLine: true,
|
||
minimap: { color: '#ffc10780', position: monaco.editor.MinimapPosition.Inline },
|
||
overviewRuler: { color: '#ffc10760', position: monaco.editor.OverviewRulerLane.Full },
|
||
}
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// Apply streaming decorations
|
||
const oldDecos = file._streamingDecos || [];
|
||
file._streamingDecos = file.model.deltaDecorations(oldDecos, streamingDecos);
|
||
}
|
||
|
||
// Apply a unified diff to original content and return the new content.
|
||
// Parses @@ -a,b +c,d @@ hunks and applies additions/removals.
|
||
function applyUnifiedDiff(originalContent, diffText) {
|
||
const origLines = originalContent.split('\n');
|
||
const diffLines = diffText.split('\n');
|
||
const result = [];
|
||
let origPos = 0; // 0-based cursor into origLines
|
||
|
||
// Whitespace-insensitive compare. Models frequently emit @@ line numbers that
|
||
// are a few lines off, or drop the leading marker space on context lines —
|
||
// both desync origPos and make context lines pull the WRONG original line,
|
||
// which shows up as corrupted indentation. We re-anchor on line content (not
|
||
// position), so the authoritative original text (with its real indentation)
|
||
// is always what gets emitted for unchanged/context lines.
|
||
const norm = (s) => s.trim();
|
||
|
||
// Try to move origPos to the original line matching `content`, scanning a small
|
||
// window ahead. Any original lines skipped on the way were unchanged, so emit
|
||
// them as context. Returns true when aligned.
|
||
const realign = (content) => {
|
||
if (origPos >= origLines.length) return false;
|
||
if (norm(origLines[origPos]) === norm(content)) return true;
|
||
for (let k = 1; k <= 5 && origPos + k < origLines.length; k++) {
|
||
if (norm(origLines[origPos + k]) === norm(content)) {
|
||
for (let j = 0; j < k; j++) result.push(origLines[origPos++]);
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
};
|
||
|
||
// Skip to first hunk header
|
||
let i = 0;
|
||
while (i < diffLines.length && !diffLines[i].startsWith('@@')) i++;
|
||
if (i >= diffLines.length) {
|
||
// No hunks found — try simple +/- line format (no @@ headers)
|
||
return applySimpleDiff(originalContent, diffText);
|
||
}
|
||
|
||
// Walk each hunk in lock-step with the original: copy untouched lines up to the
|
||
// hunk, then preserve context lines, drop '-' lines, and insert '+' lines in
|
||
// order. Tracking origPos keeps multi-hunk patches aligned (the old splice
|
||
// approach lost context lines and drifted on the 2nd hunk → corrupted output).
|
||
while (i < diffLines.length) {
|
||
if (!diffLines[i].startsWith('@@')) { i++; continue; }
|
||
const hunkMatch = diffLines[i].match(/@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
|
||
if (!hunkMatch) { i++; continue; }
|
||
const oldStart = parseInt(hunkMatch[1], 10);
|
||
i++; // Move past @@ line
|
||
|
||
// Position origPos at the start of this hunk. The @@ line number is only a
|
||
// hint — models routinely get it wrong. Prefer anchoring on the hunk's first
|
||
// context/deletion line: find it in the original (forward from where we are)
|
||
// and emit the skipped lines as unchanged. Only fall back to the @@ number for
|
||
// pure-insertion hunks, which have no anchor line to search for.
|
||
let anchor = null;
|
||
for (let p = i; p < diffLines.length && !diffLines[p].startsWith('@@'); p++) {
|
||
const L = diffLines[p];
|
||
if (L.startsWith('\\') || L.startsWith('+++') || L.startsWith('---')) continue;
|
||
const t = L[0];
|
||
if (t === '+') continue;
|
||
anchor = (t === ' ' || t === '-') ? L.slice(1) : L; // context or deletion (or bare context)
|
||
break;
|
||
}
|
||
if (anchor !== null) {
|
||
let found = -1;
|
||
for (let q = origPos; q < origLines.length; q++) {
|
||
if (origLines[q].trim() === anchor.trim()) { found = q; break; }
|
||
}
|
||
if (found >= 0) { while (origPos < found) result.push(origLines[origPos++]); }
|
||
else { const hs = Math.max(0, oldStart - 1); while (origPos < hs && origPos < origLines.length) result.push(origLines[origPos++]); }
|
||
} else {
|
||
const hs = Math.max(0, oldStart - 1);
|
||
while (origPos < hs && origPos < origLines.length) result.push(origLines[origPos++]);
|
||
}
|
||
|
||
// Apply hunk body
|
||
while (i < diffLines.length && !diffLines[i].startsWith('@@')) {
|
||
const line = diffLines[i];
|
||
if (line.startsWith('\\')) { i++; continue; } // "\ No newline at end of file"
|
||
if (line.startsWith('+++') || line.startsWith('---')) { i++; continue; } // file headers
|
||
if (/^`{3}/.test(line)) { i++; continue; } // stray closing fence (no trailing \n on last stream token)
|
||
const tag = line[0];
|
||
const body = line.slice(1); // line minus its 1-char marker
|
||
if (tag === '+') {
|
||
result.push(body); // added line — verbatim, keep its indentation
|
||
} else if (tag === '-') {
|
||
realign(body); // anchor to the line being removed
|
||
origPos++; // removed — consume original
|
||
} else if (tag === ' ') {
|
||
realign(body); // anchor to the matching original line
|
||
if (origPos < origLines.length) result.push(origLines[origPos++]); // context — keep ORIGINAL text (true indentation)
|
||
else result.push(body);
|
||
} else {
|
||
// No-prefix line: treat as context when it matches the original
|
||
// (whitespace-insensitively — models often drop the leading space), else literal
|
||
if (realign(line)) result.push(origLines[origPos++]);
|
||
else result.push(line);
|
||
}
|
||
i++;
|
||
}
|
||
}
|
||
|
||
// Copy any remaining original lines after the last hunk
|
||
while (origPos < origLines.length) result.push(origLines[origPos++]);
|
||
return result.join('\n');
|
||
}
|
||
|
||
// Fallback: simple +/- diff without @@ headers (e.g. model just outputs +added/-removed lines)
|
||
// Format: lines starting with '+ ' are additions, '- ' are deletions, ' ' (single space) are context.
|
||
// Lines with no prefix at all are ignored (filename comments, blank separators, etc.)
|
||
function applySimpleDiff(originalContent, diffText) {
|
||
const origLines = originalContent.split('\n');
|
||
const diffLines = diffText.split('\n');
|
||
const result = [];
|
||
let origPos = 0; // 0-based cursor into origLines
|
||
const norm = (s) => s.trim();
|
||
|
||
// Advance origPos to the first original line at/after origPos matching
|
||
// `content` (whitespace-insensitive), emitting any skipped originals as
|
||
// unchanged context. Returns that index (origPos left there), or -1 unmoved.
|
||
// This anchors '-'/context/bare lines to the REAL original position instead of
|
||
// assuming the diff starts at line 0 — without it a leading deletion consumed
|
||
// the wrong line and insertions floated to the top (context mismatch).
|
||
const seekAndEmit = (content) => {
|
||
for (let q = origPos; q < origLines.length; q++) {
|
||
if (norm(origLines[q]) === norm(content)) {
|
||
while (origPos < q) result.push(origLines[origPos++]);
|
||
return q;
|
||
}
|
||
}
|
||
return -1;
|
||
};
|
||
|
||
for (const line of diffLines) {
|
||
if (line === '') continue; // blank separator noise
|
||
if (line.startsWith('@@') || line.startsWith('\\')) continue; // hunk header / "no newline"
|
||
if (line.startsWith('+++') || line.startsWith('---')) continue; // file headers
|
||
const tag = line[0];
|
||
const body = line.slice(1); // strip exactly ONE marker char
|
||
if (tag === '+') {
|
||
result.push(body); // addition — verbatim, keep indentation
|
||
} else if (tag === '-') {
|
||
if (seekAndEmit(body) >= 0) origPos++; // delete the matched original
|
||
// not found → stale/duplicate deletion, drop silently
|
||
} else if (tag === ' ') {
|
||
if (seekAndEmit(body) >= 0) result.push(origLines[origPos++]); // context — emit ORIGINAL (true indentation)
|
||
// not found → context drifted; skip rather than corrupt
|
||
} else {
|
||
// Bare line: the model frequently drops the leading space on context lines.
|
||
// Treat it as context ONLY when it matches an upcoming original — otherwise
|
||
// it is a stray comment / "# filename:" / prose line and is ignored.
|
||
if (seekAndEmit(line) >= 0) result.push(origLines[origPos++]);
|
||
}
|
||
}
|
||
|
||
while (origPos < origLines.length) result.push(origLines[origPos++]);
|
||
return result.length > 0 ? result.join('\n') : originalContent;
|
||
}
|
||
|
||
// Main v2-chat live streaming (used in the full app) — thin wrapper over the
|
||
// shared parser with its own module-level state.
|
||
function v2LiveCodeUpdate(fullReply) {
|
||
codeLiveStreamUpdate(fullReply, _v2LiveState);
|
||
}
|
||
|
||
// ── /new-project 2-phase project creation ────────────────────────────────
|
||
|
||
// State variables
|
||
let _projMode = false; // 파일 생성 진행 중
|
||
let _projEl = null; // 진행 표시용 DOM 요소
|
||
let _projFileCount = 0; // 생성된 파일 수
|
||
let _projPhase = null; // 'plan' | null
|
||
let _projHistory = []; // 계획 대화 히스토리
|
||
let _projSessionId = null; // 계획 세션 ID
|
||
|
||
const _PROJ_PLAN_SYSTEM = `당신은 소프트웨어 프로젝트 설계 도우미입니다.
|
||
사용자가 원하는 프로젝트를 분석하고 아래 마크다운 형식으로 답하세요.
|
||
|
||
🔧 기술 스택: 언어, 라이브러리, 프레임워크 나열
|
||
|
||
📁 파일별 구현 계획:
|
||
각 파일에 대해 아래 형식으로 작성하세요:
|
||
**파일명** — 역할 한 줄 요약
|
||
- 주요 함수/클래스/컴포넌트: 이름과 역할
|
||
- 핵심 로직: 어떻게 동작하는지 1~2줄
|
||
(파일이 여러 개면 모두 나열)
|
||
|
||
✨ 데이터 흐름 / 구조:
|
||
- 파일/모듈 간 연결 관계 (어디서 어디를 호출하는지)
|
||
- 상태 관리나 데이터 저장 방식
|
||
|
||
⚠️ 구현 시 주의사항:
|
||
- 예상 난이도 포인트나 엣지케이스
|
||
|
||
중요: JSON 출력 절대 금지. 코드 블록 출력 금지. 코드 생성 금지.
|
||
반드시 위의 마크다운 텍스트 형식만 사용하세요.
|
||
|
||
답변 마지막에 반드시 이 한 줄을 추가하세요:
|
||
진행하려면 "만들어줘" 라고 입력하거나, 수정 사항을 알려주세요.`;
|
||
|
||
const _PROJ_GEN_SYSTEM = `당신은 새 프로젝트 파일을 생성하는 어시스턴트입니다.
|
||
도구를 사용하지 말고, 반드시 아래 형식으로 각 파일을 출력하세요. 다른 텍스트 없음.
|
||
|
||
@@FILE: 프로젝트명/파일명.확장자
|
||
파일 내용 (이스케이프 불필요, 있는 그대로)
|
||
@@ENDFILE
|
||
|
||
예시:
|
||
@@FILE: tetris-game/index.html
|
||
<!DOCTYPE html>
|
||
<html lang="ko">
|
||
<head><title>테트리스</title></head>
|
||
<body>...</body>
|
||
</html>
|
||
@@ENDFILE
|
||
|
||
@@FILE: tetris-game/README.md
|
||
## 실행 방법
|
||
index.html을 브라우저에서 열어주세요.
|
||
@@ENDFILE
|
||
|
||
규칙:
|
||
- path: 영문 슬러그 폴더명/파일명 (예: tetris-game/index.html)
|
||
- 실행 진입점 필수 (main.py / index.html / app.py 등)
|
||
- README.md 필수 (마지막)
|
||
- 파일 내용에 @@FILE: 또는 @@ENDFILE 문자열 사용 금지
|
||
- 각 파일에 완전한 동작 코드
|
||
- 웹 게임(테트리스, 뱀, 퍼즐 등)은 index.html 단일 파일로 (모든 CSS/JS를 <style>/<script>에 인라인). 별도 .js/.css 파일 금지`;
|
||
|
||
function _isProjConfirm(msg) {
|
||
return /^(만들어|만들어줘|만들어주세요|진행|진행해|진행해줘|좋아|좋아요|ok|ㅇㅋ|ㅇㅇ|응|예|네|yes|그래|그래줘|go|시작|시작해|시작해줘)[\s!.]*$/i.test(msg.trim());
|
||
}
|
||
|
||
|
||
|
||
function _projStartGenerate() {
|
||
_projMode = true;
|
||
_projFileCount = 0;
|
||
|
||
const el = codeAiAppend('assistant', '');
|
||
el.classList.remove('streaming');
|
||
_projEl = el;
|
||
|
||
const hdr = document.createElement('div');
|
||
hdr.style.cssText = 'font-weight:600;color:var(--brand,#7c3aed);margin-bottom:8px;font-size:13px;';
|
||
hdr.textContent = '🔨 파일 생성 중...';
|
||
el.appendChild(hdr);
|
||
el._hdr = hdr;
|
||
|
||
const list = document.createElement('div');
|
||
list.style.cssText = 'font-size:11px;font-family:monospace;';
|
||
el.appendChild(list);
|
||
el._list = list;
|
||
|
||
const live = document.createElement('pre');
|
||
live.style.cssText = 'margin:4px 0 8px;padding:8px;background:var(--panel-2,#1e1e2e);border:1px solid var(--border,#333);border-radius:5px;font-size:10px;line-height:1.4;max-height:200px;overflow-y:auto;white-space:pre-wrap;word-break:break-all;color:var(--text,#ccc);display:none';
|
||
el.appendChild(live);
|
||
el._live = live;
|
||
|
||
codeAiScrollBottom();
|
||
}
|
||
|
||
async function _projFileCreated(filename, content) {
|
||
if (!_projEl) return;
|
||
_projFileCount++;
|
||
|
||
const row = document.createElement('div');
|
||
row.style.cssText = 'padding:2px 0;display:flex;align-items:center;gap:6px;';
|
||
|
||
const statusSpan = document.createElement('span');
|
||
statusSpan.textContent = '✅';
|
||
statusSpan.style.flexShrink = '0';
|
||
|
||
const pathSpan = document.createElement('span');
|
||
pathSpan.style.cssText = 'color:var(--text,#ccc);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
|
||
pathSpan.textContent = filename;
|
||
|
||
row.appendChild(statusSpan);
|
||
row.appendChild(pathSpan);
|
||
|
||
if (content?.trim()) {
|
||
const toggleBtn = document.createElement('button');
|
||
toggleBtn.style.cssText = 'background:transparent;border:1px solid var(--border,#333);color:var(--muted,#888);border-radius:3px;padding:0 6px;font-size:10px;cursor:pointer;flex-shrink:0;line-height:18px;';
|
||
toggleBtn.textContent = '보기';
|
||
row.appendChild(toggleBtn);
|
||
|
||
const preview = document.createElement('div');
|
||
preview.style.cssText = 'display:none;background:var(--panel-2,#1e1e2e);border:1px solid var(--border,#333);border-radius:5px;padding:8px;margin:3px 0 3px 22px;max-height:180px;overflow-y:auto;';
|
||
const pre = document.createElement('pre');
|
||
pre.style.cssText = 'margin:0;font-size:11px;white-space:pre-wrap;word-break:break-all;color:var(--text,#ccc);';
|
||
pre.textContent = content;
|
||
preview.appendChild(pre);
|
||
toggleBtn.onclick = () => {
|
||
const hidden = preview.style.display === 'none';
|
||
preview.style.display = hidden ? 'block' : 'none';
|
||
toggleBtn.textContent = hidden ? '닫기' : '보기';
|
||
};
|
||
_projEl._list.appendChild(row);
|
||
_projEl._list.appendChild(preview);
|
||
} else {
|
||
_projEl._list.appendChild(row);
|
||
}
|
||
|
||
// 로컬 폴더에 저장 (성공/실패 표시)
|
||
if (localDirHandle) {
|
||
const ok = await localWriteFile(filename, content ?? '');
|
||
if (!ok) {
|
||
statusSpan.textContent = '⚠️';
|
||
pathSpan.style.color = '#f87171';
|
||
pathSpan.title = '디스크 저장 실패';
|
||
}
|
||
}
|
||
|
||
// 에디터 탭에 추가 — client-local 모드에서는 프로젝트 slug를 제거한 베어 이름 사용
|
||
// (_clientLoadProjectFiles와 일관성 유지: 탭은 항상 베어 상대 경로)
|
||
const tabName = localDirHandle && filename.includes('/')
|
||
? filename.slice(filename.indexOf('/') + 1)
|
||
: filename;
|
||
_npAddTab(tabName, content ?? '');
|
||
renderCodeFileTabs();
|
||
codeAiScrollBottom();
|
||
}
|
||
|
||
function _projDone() {
|
||
_projMode = false;
|
||
if (!_projEl) return;
|
||
|
||
if (_projEl._hdr) _projEl._hdr.textContent = `📁 프로젝트 생성 완료`;
|
||
|
||
const doneDiv = document.createElement('div');
|
||
doneDiv.style.cssText = 'margin-top:10px;padding-top:10px;border-top:1px solid var(--border,#333);display:flex;gap:8px;align-items:center;flex-wrap:wrap;';
|
||
|
||
const info = document.createElement('span');
|
||
info.style.cssText = 'font-size:12px;color:var(--green,#4ade80);font-weight:600;';
|
||
info.textContent = `✅ ${_projFileCount}개 파일 생성 완료!`;
|
||
doneDiv.appendChild(info);
|
||
|
||
if (localDirHandle) {
|
||
const sbBtn = document.createElement('button');
|
||
sbBtn.style.cssText = 'background:var(--brand,#7c3aed);color:#fff;border:none;border-radius:5px;padding:4px 12px;font-size:11px;font-weight:600;cursor:pointer;';
|
||
sbBtn.textContent = '📂 파일 탐색기';
|
||
sbBtn.onclick = () => { const b = document.getElementById('code-folder-btn'); if (b) b.click(); };
|
||
doneDiv.appendChild(sbBtn);
|
||
} else {
|
||
const warn = document.createElement('span');
|
||
warn.style.cssText = 'font-size:11px;color:#f87171;';
|
||
warn.textContent = '⚠️ 로컬 폴더 미연결 — 에디터에만 저장됨';
|
||
doneDiv.appendChild(warn);
|
||
}
|
||
|
||
_projEl.appendChild(doneDiv);
|
||
_projEl = null;
|
||
codeSbRefresh();
|
||
codeAiScrollBottom();
|
||
}
|
||
|
||
async function _projStreamSSE(sessionId, message, systemPrompt, history, onChunk) {
|
||
const res = await fetch('/api/chat', {
|
||
method: 'POST',
|
||
signal: codeAiAbort.signal,
|
||
headers: authHeaders({ 'Content-Type': 'application/json', 'Accept': 'text/event-stream' }),
|
||
body: JSON.stringify({
|
||
message,
|
||
pinnedMessages: [{ role: 'system', content: systemPrompt }],
|
||
history: history || [],
|
||
sessionId,
|
||
direct: true,
|
||
max_tokens: 16000,
|
||
...(codeAiModel ? { model: codeAiModel } : {}),
|
||
}),
|
||
});
|
||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||
const reader = res.body.getReader();
|
||
const dec = new TextDecoder();
|
||
let buf = '', fullText = '';
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
buf += dec.decode(value, { stream: true });
|
||
const lines = buf.split('\n'); buf = lines.pop();
|
||
for (const line of lines) {
|
||
if (!line.startsWith('data:')) continue;
|
||
const raw = line.slice(5).trim();
|
||
if (raw === '[DONE]') continue;
|
||
try {
|
||
const ev = JSON.parse(raw);
|
||
let chunk = '';
|
||
if (ev.type === 'token') chunk = ev.content || ev.text || '';
|
||
else if (ev.type === 'text_delta') chunk = ev.delta || '';
|
||
else if (ev.type === 'final' || ev.type === 'done') {
|
||
const reply = ev.text || ev.content || ev.reply || '';
|
||
if (reply.length > fullText.length) fullText = reply;
|
||
}
|
||
if (chunk) { fullText += chunk; onChunk?.(chunk); }
|
||
} catch {}
|
||
}
|
||
}
|
||
return fullText;
|
||
}
|
||
|
||
async function _projPlanChat(message) {
|
||
if (codeAiAbort) codeAiAbort.abort();
|
||
codeAiAbort = new AbortController();
|
||
try {
|
||
// 스트리밍 말풍선 생성
|
||
const bubble = codeAiAppend('assistant', '');
|
||
bubble.classList.add('streaming');
|
||
const mdDiv = document.createElement('div');
|
||
mdDiv.className = 'md-content';
|
||
bubble.appendChild(mdDiv);
|
||
codeAiScrollBottom();
|
||
|
||
let accumulated = '';
|
||
const fullText = await _projStreamSSE(
|
||
_projSessionId, message, _PROJ_PLAN_SYSTEM, _projHistory,
|
||
chunk => {
|
||
accumulated += chunk;
|
||
mdDiv.innerHTML = _renderInlineMarkdown(accumulated);
|
||
codeAiScrollBottom();
|
||
}
|
||
);
|
||
bubble.classList.remove('streaming');
|
||
const text = fullText || accumulated;
|
||
mdDiv.innerHTML = _renderInlineMarkdown(text);
|
||
|
||
// 히스토리에 추가
|
||
_projHistory.push({ role: 'user', content: message });
|
||
_projHistory.push({ role: 'assistant', content: text });
|
||
|
||
// 진행 버튼
|
||
const btnRow = document.createElement('div');
|
||
btnRow.style.cssText = 'margin-top:10px;display:flex;gap:6px;';
|
||
const goBtn = document.createElement('button');
|
||
goBtn.style.cssText = 'background:var(--brand,#7c3aed);color:#fff;border:none;border-radius:6px;padding:5px 14px;font-size:12px;font-weight:700;cursor:pointer;';
|
||
goBtn.textContent = '🔨 진행';
|
||
goBtn.onclick = () => { btnRow.remove(); _projGenerate(); };
|
||
btnRow.appendChild(goBtn);
|
||
bubble.appendChild(btnRow);
|
||
codeAiScrollBottom();
|
||
} catch (e) {
|
||
if (e.name !== 'AbortError')
|
||
codeAiAppend('system', '❌ 오류: ' + (e.message || e));
|
||
}
|
||
}
|
||
|
||
async function _projGenerate() {
|
||
// 로컬 폴더가 열려있지 않으면 먼저 폴더 선택 요청
|
||
if (!localDirHandle) {
|
||
codeAiAppend('system', '📁 프로젝트를 저장할 로컬 폴더를 먼저 선택해주세요.');
|
||
await openLocalFolder();
|
||
if (!localDirHandle) {
|
||
codeAiAppend('system', '⚠️ 폴더 선택이 취소되었습니다. 폴더를 선택한 뒤 다시 시도해주세요.');
|
||
_projDone();
|
||
return;
|
||
}
|
||
}
|
||
if (codeAiAbort) codeAiAbort.abort();
|
||
codeAiAbort = new AbortController();
|
||
_projPhase = null;
|
||
_projStartGenerate();
|
||
try {
|
||
if (_projEl?._hdr) _projEl._hdr.textContent = '🤔 코드 생성 중...';
|
||
// 계획 세션 재사용 — 서버가 history 파라미터를 무시하고 세션 ID로 히스토리를 로드하므로
|
||
// 새 세션을 쓰면 "위 계획대로"가 뭔지 모델이 알 수 없음
|
||
const genSessionId = _projSessionId || ('proj-gen-' + Date.now());
|
||
let genChars = 0;
|
||
let _liveAccum = '';
|
||
let _liveFilePath = null;
|
||
const earlyDone = new Set(); // raw paths processed incrementally during streaming
|
||
const earlyPromises = []; // promises for early _projFileCreated calls — awaited before switchProject
|
||
let _earlyRawSlug = null;
|
||
const _earlyNormPath = rawPath => {
|
||
if (!rawPath.includes('/')) return rawPath;
|
||
const pfx = rawPath.split('/')[0];
|
||
if (!_earlyRawSlug) _earlyRawSlug = pfx;
|
||
const normPfx = activeProjectName
|
||
|| pfx.toLowerCase().replace(/[^a-z0-9가-힣_-]/g, '-').replace(/^-+|-+$/g, '');
|
||
return pfx !== normPfx ? rawPath.replace(pfx + '/', normPfx + '/') : rawPath;
|
||
};
|
||
const fullText = await _projStreamSSE(
|
||
genSessionId, '위 계획대로 각 파일을 @@FILE / @@ENDFILE 형식으로 생성해주세요.',
|
||
_PROJ_GEN_SYSTEM, _projHistory,
|
||
chunk => {
|
||
genChars += chunk.length;
|
||
_liveAccum += chunk;
|
||
|
||
// @@FILE: 헤더 스캔 — 현재 작성 중인 파일 + 부분 내용 표시
|
||
const hdrRe = /@@FILE:\s*(.+?)[ \t]*\r?\n/g;
|
||
const hdrs = [];
|
||
let hm2;
|
||
while ((hm2 = hdrRe.exec(_liveAccum)) !== null)
|
||
hdrs.push({ path: hm2[1].trim(), contentStart: hm2.index + hm2[0].length, idx: hm2.index });
|
||
|
||
if (!hdrs.length) {
|
||
if (_projEl?._hdr) _projEl._hdr.textContent = `🤔 코드 생성 중... (${genChars}자)`;
|
||
return;
|
||
}
|
||
|
||
for (let i = 0; i < hdrs.length; i++) {
|
||
const { path, contentStart } = hdrs[i];
|
||
if (earlyDone.has(path)) continue;
|
||
const nextPos = i + 1 < hdrs.length ? hdrs[i + 1].idx : _liveAccum.length;
|
||
const endIdx = _liveAccum.indexOf('@@ENDFILE', contentStart);
|
||
const isComplete = endIdx !== -1 && endIdx < nextPos;
|
||
|
||
if (isComplete) {
|
||
// 완성된 파일 즉시 체크리스트에 추가 (promise 추적 — switchProject 전 await)
|
||
earlyDone.add(path);
|
||
earlyPromises.push(_projFileCreated(_earlyNormPath(path), _liveAccum.slice(contentStart, endIdx)));
|
||
} else if (i === hdrs.length - 1) {
|
||
// 마지막 헤더 = 현재 스트리밍 중인 파일 — 부분 내용 표시
|
||
const fname = path.split('/').pop();
|
||
if (path !== _liveFilePath) {
|
||
_liveFilePath = path;
|
||
if (_projEl?._hdr) _projEl._hdr.textContent = `✏️ ${fname} 작성 중...`;
|
||
}
|
||
const partial = _liveAccum.slice(contentStart);
|
||
if (_projEl?._live) {
|
||
_projEl._live.style.display = 'block';
|
||
_projEl._live.textContent = partial;
|
||
_projEl._live.scrollTop = _projEl._live.scrollHeight;
|
||
}
|
||
}
|
||
}
|
||
// 마지막 파일도 완료되면 라이브 프리뷰 숨김
|
||
if (earlyDone.has(hdrs[hdrs.length - 1].path) && _projEl?._live)
|
||
_projEl._live.style.display = 'none';
|
||
}
|
||
);
|
||
if (_projEl?._live) _projEl._live.style.display = 'none';
|
||
|
||
// @@FILE / @@ENDFILE 파싱 — @@ENDFILE 없는 마지막 블록도 허용
|
||
const files = [];
|
||
const headerRe = /@@FILE:\s*(.+?)[ \t]*\r?\n/g;
|
||
const headers = [];
|
||
let hm;
|
||
while ((hm = headerRe.exec(fullText)) !== null) {
|
||
headers.push({ path: hm[1].trim(), contentStart: hm.index + hm[0].length });
|
||
}
|
||
for (let i = 0; i < headers.length; i++) {
|
||
const { path, contentStart } = headers[i];
|
||
const nextHeaderPos = i + 1 < headers.length
|
||
? fullText.lastIndexOf('@@FILE:', headers[i + 1].contentStart)
|
||
: fullText.length;
|
||
const endfileIdx = fullText.indexOf('@@ENDFILE', contentStart);
|
||
const contentEnd = (endfileIdx !== -1 && endfileIdx < nextHeaderPos) ? endfileIdx : nextHeaderPos;
|
||
files.push({ path, content: fullText.slice(contentStart, contentEnd) });
|
||
}
|
||
if (files.length === 0) {
|
||
const preview = fullText ? `\n\n모델 출력 (앞 400자):\n${fullText.slice(0, 400)}` : ' (응답 없음)';
|
||
codeAiAppend('system', '❌ 파일 블록을 찾지 못했습니다. 다시 시도해주세요.' + preview);
|
||
return;
|
||
}
|
||
|
||
if (_projEl?._hdr) _projEl._hdr.textContent = '🔨 파일 저장 중...';
|
||
|
||
// 파일 쓰기 전에 slug 확정: AI 출력 slug를 그대로 쓰면 'space-invaDERS' 같은
|
||
// 잘못된 케이스 폴더가 생긴다. activeProjectName 우선, 없으면 소문자 정규화.
|
||
const allNames = files.map(f => f.path);
|
||
const hasPrefix = allNames.some(n => n.includes('/'));
|
||
const rawSlug = hasPrefix ? (allNames[0] || '').split('/')[0] : '';
|
||
const slug = activeProjectName
|
||
|| (rawSlug ? rawSlug.toLowerCase().replace(/[^a-z0-9가-힣_-]/g, '-').replace(/^-+|-+$/g, '') : '');
|
||
// AI가 출력한 경로의 slug prefix → 정규화된 slug로 교체
|
||
const normalizedFiles = files.map(f => ({
|
||
...f,
|
||
rawPath: f.path, // earlyDone 체크용 원본 경로
|
||
path: rawSlug && rawSlug !== slug ? f.path.replace(rawSlug + '/', slug + '/') : f.path,
|
||
}));
|
||
|
||
for (const f of normalizedFiles) {
|
||
if (earlyDone.has(f.rawPath)) continue; // 스트리밍 중 이미 처리됨
|
||
if (f.path && f.content !== undefined)
|
||
await _projFileCreated(f.path, String(f.content));
|
||
}
|
||
|
||
// earlyDone 파일의 디스크 쓰기가 모두 완료된 후 프로젝트 전환
|
||
// (_clientLoadProjectFiles가 디스크에서 파일을 읽으므로 write 완료 전에 전환하면 탭이 비어 컨텍스트 없음)
|
||
await Promise.allSettled(earlyPromises);
|
||
|
||
const mainPath = _npMainFile(
|
||
normalizedFiles.map(f => ({ path: slug ? f.path.replace(slug + '/', '') : f.path })),
|
||
slug
|
||
);
|
||
if (mainPath) switchCodeFile(codeFiles.find(f => f.name === mainPath)?.id);
|
||
// 새 프로젝트로 자동 전환 (AI 세션 분리)
|
||
if (slug && slug !== activeProjectName) {
|
||
switchProject(slug);
|
||
// switchProject 내부의 _clientLoadProjectFiles()는 await 없이 호출되므로
|
||
// codeFiles가 비어있는 상태로 _projGenerate가 반환될 수 있다.
|
||
// 여기서 명시적으로 await해서 다음 메시지 전송 시 파일이 로드된 상태를 보장한다.
|
||
if (localDirHandle) await _clientLoadProjectFiles();
|
||
}
|
||
} catch (e) {
|
||
if (e.name !== 'AbortError')
|
||
codeAiAppend('system', '❌ 프로젝트 생성 오류: ' + (e.message || e));
|
||
} finally {
|
||
_projHistory = [];
|
||
_projSessionId = null;
|
||
_projDone();
|
||
}
|
||
}
|
||
|
||
// 탭 추가만, switchCodeFile 없이 (나중에 메인 파일로 한 번만 포커스)
|
||
function _npAddTab(name, content) {
|
||
const existing = codeFiles.find(f => f.name === name);
|
||
if (existing) {
|
||
if (existing.model) existing.model.setValue(content);
|
||
else existing.content = content;
|
||
return existing.id;
|
||
}
|
||
const id = 'cf_' + Math.random().toString(36).slice(2, 8);
|
||
const lang = codeDetectLang(name);
|
||
const model = monacoReady ? monaco.editor.createModel(content, lang) : null;
|
||
codeFiles.push({ id, name, content, language: lang, model });
|
||
return id;
|
||
}
|
||
|
||
// 메인 진입 파일 추정 (main > app > index > server > 첫 번째)
|
||
function _npMainFile(files, slug) {
|
||
const priority = /^(main|app|index|server)\.(py|js|ts|jsx|tsx|go|rs|java|rb|php)$/i;
|
||
const f = files.find(f => priority.test(f.path.split('/').pop()))
|
||
|| files.find(f => !/\.(md|txt|json|yaml|yml|toml|cfg|ini|env)$/i.test(f.path))
|
||
|| files[0];
|
||
if (!f) return null;
|
||
return slug ? `${slug}/${f.path}`.replace(/\/+/g, '/') : f.path;
|
||
}
|
||
|
||
|
||
let _codeAiSending = false;
|
||
|
||
// Public entry. Guards against overlapping sends and doubles as a Stop control:
|
||
// while a generation is in flight, the 전송 button turns into ⏹ 중지 and a second
|
||
// click aborts the current request instead of starting a racing one.
|
||
async function codeAiSend() {
|
||
if (_codeAiSending) {
|
||
if (codeAiAbort) codeAiAbort.abort();
|
||
return;
|
||
}
|
||
const inp = document.getElementById('code-ai-input');
|
||
if (!inp || !inp.value.trim()) return;
|
||
// 승인 대기 중인 계획 결정 카드가 있으면 입력을 가로채서 버튼 클릭으로 처리
|
||
const pendingCard = document.querySelector('.code-ai-msg.system button:not([disabled])');
|
||
if (pendingCard) {
|
||
const txt = inp.value.trim().toLowerCase();
|
||
const isYes = /^(응|예|ㅇ|ok|yes|실행|승인|go|ㄱ|ㅇㅇ)$/.test(txt);
|
||
const isNo = /^(아니|취소|no|ㄴ|cancel)$/.test(txt);
|
||
const numMatch = txt.match(/^(\d+)$/);
|
||
if (isNo) { inp.value = ''; document.querySelector('.code-ai-msg.system button[style*="#dc3545"]:not([disabled])')?.click(); return; }
|
||
if (numMatch) {
|
||
const optBtn = document.querySelector(`.code-ai-msg.system button[data-opt-num="${numMatch[1]}"]:not([disabled])`);
|
||
if (optBtn) { inp.value = ''; optBtn.click(); return; }
|
||
}
|
||
const confirmBtn = document.querySelector('.code-ai-msg.system button[style*="#28a745"]:not([disabled])');
|
||
if (isYes && confirmBtn) { inp.value = ''; confirmBtn.click(); return; }
|
||
}
|
||
_codeAiSending = true;
|
||
_setSendBtnBusy(true);
|
||
try {
|
||
await _codeAiSendImpl();
|
||
} finally {
|
||
_codeAiSending = false;
|
||
_setSendBtnBusy(false);
|
||
}
|
||
}
|
||
|
||
function _setSendBtnBusy(busy) {
|
||
const btn = document.getElementById('code-ai-send-btn');
|
||
if (!btn) return;
|
||
btn.textContent = busy ? '⏹ 중지' : '전송';
|
||
btn.classList.toggle('busy', busy);
|
||
}
|
||
|
||
async function _codeAiSendImpl() {
|
||
const input = document.getElementById('code-ai-input');
|
||
const message = input.value.trim();
|
||
if (!message) return;
|
||
input.value = ''; input.style.height = '';
|
||
|
||
// 프로젝트 계획 단계 중 → 확인 메시지면 생성, 아니면 계획 계속
|
||
if (_projPhase === 'plan') {
|
||
codeAiAppend('user', message);
|
||
if (_isProjConfirm(message)) {
|
||
await _projGenerate();
|
||
} else {
|
||
await _projPlanChat(message);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// @파일명 참조 처리 — 메시지에서 @xxx.js 패턴을 찾아 파일 내용 삽입
|
||
let resolvedMessage = message;
|
||
const atInlinedFiles = new Set(); // track files already inlined via @ref
|
||
if (localDirHandle) {
|
||
const atRefs = [...message.matchAll(/@([\w./-]+\.\w+)/g)];
|
||
for (const m of atRefs) {
|
||
const fname = activeProjectName ? `${activeProjectName}/${m[1]}` : m[1];
|
||
const content = await localReadFile(fname) ?? await localReadFile(m[1]);
|
||
if (content !== null) {
|
||
const ext = m[1].split('.').pop() || '';
|
||
resolvedMessage = resolvedMessage.replace(m[0],
|
||
`\n[파일: ${m[1]}]\n\`\`\`${ext}\n${content}\n\`\`\`\n`);
|
||
atInlinedFiles.add(m[1]); // bare name
|
||
atInlinedFiles.add(fname); // project-prefixed name
|
||
}
|
||
}
|
||
}
|
||
|
||
// 프로젝트 내 모든 파일 내용을 컨텍스트에 포함 (현재 파일 제외, 별도 첨부)
|
||
let otherFilesCtx = '';
|
||
{
|
||
const currentFileName = codeFiles.find(f => f.id === activeCodeFileId)?.name;
|
||
const seen = new Set();
|
||
const CTX_TOTAL_BYTES = 500 * 1024; // 500KB total cap for other-files section
|
||
const SKIP_NAMES = /^(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|\.DS_Store|thumbs\.db)$/i;
|
||
const addFile = (rel, content) => {
|
||
if (otherFilesCtx.length >= CTX_TOTAL_BYTES) return;
|
||
const ext = rel.split('.').pop() || '';
|
||
const chunk = `[파일: ${rel}]\n\`\`\`${ext}\n${content}\n\`\`\`\n`;
|
||
if (otherFilesCtx.length + chunk.length > CTX_TOTAL_BYTES) {
|
||
otherFilesCtx += `[나머지 파일 생략 — 컨텍스트 크기 초과]\n`;
|
||
} else {
|
||
otherFilesCtx += chunk;
|
||
}
|
||
};
|
||
// 1. All other open tabs (no project prefix required — covers both prefixed and bare filenames)
|
||
for (const f of codeFiles) {
|
||
if (f.name === currentFileName) continue;
|
||
// If activeProjectName set, only include files from same project.
|
||
// 로컬 모드(localDirHandle 있음)에서는 탭이 베어 경로("src/main.py")로 저장될 수 있으므로
|
||
// 첫 세그먼트가 알려진 다른 프로젝트 이름인 경우에만 제외한다.
|
||
if (activeProjectName && f.name.includes('/')) {
|
||
const isCurrentProjPrefix = f.name.startsWith(activeProjectName + '/');
|
||
if (!isCurrentProjPrefix) {
|
||
if (!localDirHandle) { continue; } // 서버 모드: 프로젝트 prefix 없으면 다른 프로젝트
|
||
// 로컬 모드: 첫 세그먼트가 알려진 다른 프로젝트 이름이면 제외
|
||
const firstSeg = f.name.split('/')[0];
|
||
const knownProjects = _knownProjectsGet();
|
||
if (knownProjects.includes(firstSeg) && firstSeg !== activeProjectName) continue;
|
||
}
|
||
}
|
||
if (!isCodeFile(f.name)) continue; // skip .md, .txt etc.
|
||
const bare = f.name.split('/').pop() || '';
|
||
if (SKIP_NAMES.test(bare)) continue;
|
||
seen.add(f.name);
|
||
const rel = activeProjectName ? f.name.replace(activeProjectName + '/', '') : f.name;
|
||
const content = f.model ? f.model.getValue() : f.content;
|
||
if (!content) continue;
|
||
addFile(rel, content);
|
||
}
|
||
// 2. Files on disk not yet opened as tabs
|
||
if (localDirHandle && activeProjectName && otherFilesCtx.length < CTX_TOTAL_BYTES) {
|
||
try {
|
||
const allFiles = await localListFiles(localDirHandle, '');
|
||
const projFiles = allFiles.filter(f => {
|
||
if (!f.startsWith(activeProjectName + '/')) return false;
|
||
const bare = f.slice(activeProjectName.length + 1);
|
||
// seen에는 베어 이름만 있으므로 양쪽 모두 체크해 중복 포함 방지
|
||
return !seen.has(f) && !seen.has(bare) && f !== currentFileName && isCodeFile(f) && !SKIP_NAMES.test(f.split('/').pop() || '');
|
||
});
|
||
for (const fpath of projFiles) {
|
||
if (otherFilesCtx.length >= CTX_TOTAL_BYTES) break;
|
||
const rel = fpath.replace(activeProjectName + '/', '');
|
||
const content = await localReadFile(fpath);
|
||
if (content) addFile(rel, content);
|
||
}
|
||
} catch {}
|
||
}
|
||
}
|
||
|
||
// Include current file context (ask/code 모드 모두 포함)
|
||
const currentFile = codeFiles.find(f => f.id === activeCodeFileId);
|
||
// otherFilesCtx는 currentFile 유무와 무관하게 항상 contextMsg에 포함 (현재 파일 없어도 프로젝트 파일 보이게)
|
||
let contextMsg = otherFilesCtx ? otherFilesCtx + resolvedMessage : resolvedMessage;
|
||
// Add tech hint for the model
|
||
const techHints = { html: '웹 (HTML/CSS/JS)', python: 'Python', node: 'Node.js/JavaScript', auto: '' };
|
||
const techHint = techHints[codeAiTech] || '';
|
||
if (techHint && !contextMsg.includes('[tech]')) {
|
||
contextMsg = `[tech: ${techHint}] ${contextMsg}`;
|
||
}
|
||
if (currentFile && monacoEditor) {
|
||
const content = monacoEditor.getValue();
|
||
// Diff editing only makes sense against REAL existing code. A brand-new tab
|
||
// seeded with a placeholder comment (e.g. "# 여기에 코드를 작성하세요") is not
|
||
// editable code — asking for a diff there makes the model emit a whole program
|
||
// as +lines, which then got saved verbatim ("+가 붙어 나옴"). Treat a file with
|
||
// no substantive (non-comment, non-blank) lines as new → request full code.
|
||
const hasRealCode = content.split('\n').some(l => {
|
||
const t = l.trim();
|
||
return t && !t.startsWith('#') && !t.startsWith('//') && !t.startsWith('/*') && !t.startsWith('*') && !t.startsWith('<!--');
|
||
});
|
||
if (content.trim() && currentFile.name !== 'untitled.py' && hasRealCode) {
|
||
const ext = currentFile.name.split('.').pop() || '';
|
||
const langHint = ext || currentFile.language || '';
|
||
const lines = content.split('\n');
|
||
let fileCtx, omitNote = '';
|
||
if (lines.length <= 3000) {
|
||
fileCtx = lines.map((l, i) => `${i + 1}: ${l}`).join('\n');
|
||
} else {
|
||
// Large file: always include header (first 80 lines) + cursor window + footer
|
||
const errorMatch = message.match(/line\s+(\d+)/i) || message.match(/라인\s*(\d+)/) || message.match(/(\d+)\s*줄/) || message.match(/Line\s+(\d+)/i);
|
||
let focusLine = (monacoEditor?.getPosition?.()?.lineNumber) || 1;
|
||
if (errorMatch) focusLine = parseInt(errorMatch[1]);
|
||
const headerEnd = Math.min(80, lines.length);
|
||
const ctx = 80;
|
||
const s = Math.max(headerEnd + 1, focusLine - ctx);
|
||
const e = Math.min(lines.length, focusLine + ctx);
|
||
const headerLines = lines.slice(0, headerEnd).map((l, i) => `${i + 1}: ${l}`).join('\n');
|
||
const windowLines = s <= e ? lines.slice(s - 1, e).map((l, i) => `${s + i}: ${l}`).join('\n') : '';
|
||
const structLines = [];
|
||
for (let i = 0; i < lines.length; i++) {
|
||
if (/^(class |def |async def |function |public |private |const |let |var |export )/i.test(lines[i].trimStart()))
|
||
structLines.push(`${i + 1}: ${lines[i].trim()}`);
|
||
}
|
||
const overview = structLines.length ? `\n[구조]\n${structLines.join('\n')}\n` : '';
|
||
const windowPart = windowLines && s > headerEnd + 1 ? `\n...(${s - headerEnd - 1}줄 생략)...\n${windowLines}` : (windowLines ? '\n' + windowLines : '');
|
||
fileCtx = headerLines + windowPart;
|
||
omitNote = ` (${lines.length}줄 중 앞 ${headerEnd}줄 + 커서 주변 표시)${overview}`;
|
||
}
|
||
// Byte safety net: a few-line minified/data file passes the line check but can be
|
||
// tens of MB. Hard-cap the assembled context so /api/chat never gets a giant body.
|
||
if (fileCtx.length > MAX_AI_FILE_BYTES) {
|
||
fileCtx = fileCtx.slice(0, MAX_AI_FILE_BYTES) + `\n…[파일이 너무 커서 잘림 — 전체 ${Math.round(content.length / 1024)}KB]`;
|
||
omitNote = omitNote || ` (대용량 ${Math.round(content.length / 1024)}KB — 앞부분만 표시)`;
|
||
}
|
||
// Use bare filename so model never sees the client project prefix
|
||
const displayName = _clientBareFileName(currentFile.name);
|
||
contextMsg = `${otherFilesCtx}[현재 파일: ${displayName}${omitNote}]\n\`\`\`${langHint}\n${fileCtx}\n\`\`\`\n\n${resolvedMessage}`;
|
||
}
|
||
}
|
||
|
||
codeAiAppend('user', message);
|
||
const historyEntry = currentFile ? `[파일: ${_clientBareFileName(currentFile.name)}] ${message}` : message;
|
||
codeAiHistory.push({ role: 'user', content: historyEntry });
|
||
try { updateSessionPanel(); } catch (_) {}
|
||
|
||
if (codeAiAbort) codeAiAbort.abort();
|
||
codeAiAbort = new AbortController();
|
||
const historyForAPI = _histForAPI(codeAiHistory);
|
||
|
||
// ── Ask 모드: 단일 API 호출, 도구 없이 바로 응답 ─────────────────────────
|
||
if (codeAiMode === 'ask') {
|
||
const askEl = codeAiAppend('thinking', '답변 중...');
|
||
let askText = '', askBuf = '';
|
||
try {
|
||
const askRes = await fetch('/api/chat', {
|
||
method: 'POST',
|
||
headers: authHeaders({ 'Content-Type': 'application/json', 'Accept': 'text/event-stream' }),
|
||
signal: codeAiAbort.signal,
|
||
body: JSON.stringify({ message: contextMsg, history: historyForAPI, useTools: false, sessionId: codeAiSessionId, codeAiTech, codeAiLocal: !!localDirHandle, ...(codeAiModel ? { model: codeAiModel } : {}) }),
|
||
});
|
||
if (askRes.status === 401) { setAuthToken(''); _codeAiShowRelogin(); return; }
|
||
if (askRes.status === 413) { askEl.textContent = '❌ 요청 크기 초과. 파일이 너무 크거나 대화 히스토리가 너무 깁니다.'; return; }
|
||
if (askRes.ok) {
|
||
const askReader = askRes.body.getReader();
|
||
const askDec = new TextDecoder();
|
||
askEl.textContent = '';
|
||
askEl.className = 'code-ai-msg assistant';
|
||
while (true) {
|
||
const { done, value } = await askReader.read();
|
||
if (done) break;
|
||
askBuf += askDec.decode(value, { stream: true });
|
||
const askLines = askBuf.split('\n'); askBuf = askLines.pop();
|
||
for (const ln of askLines) {
|
||
if (!ln.startsWith('data:')) continue;
|
||
const d = ln.slice(5).trim();
|
||
if (d === '[DONE]') continue;
|
||
try {
|
||
const ev = JSON.parse(d);
|
||
if (ev.type === 'usage') { _codeAiOnUsage(ev); continue; }
|
||
if (ev.type === 'final' || ev.type === 'done') { if (!askText) askText = ev.text || ev.reply || ev.content || ''; continue; }
|
||
const tok = ev.content || ev.text || ev.delta || null;
|
||
if (tok) { askText += tok; askEl.textContent = askText.replace(/<\/?think>/gi, ''); codeAiScrollBottom(); }
|
||
} catch {}
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {
|
||
if (e.name === 'AbortError') return;
|
||
askText = '오류: ' + (e.message || e);
|
||
}
|
||
askText = askText.replace(/<think>[\s\S]*?<\/think>/gi, '').replace(/<\/think>/gi, '').trim();
|
||
askEl.textContent = askText || '(응답 없음)';
|
||
codeAiRenderMarkdown(askEl, askText || '(응답 없음)');
|
||
codeAiHistory.push({ role: 'assistant', content: _histEntry(askText) || '(응답 없음)' });
|
||
_autoSaveChatSession();
|
||
if (activeProjectName) saveProjectSession(activeProjectName);
|
||
try { updateSessionPanel(); } catch (_) {}
|
||
return;
|
||
}
|
||
|
||
// ── Code 모드: Phase 1 (계획) → confirm → Phase 2 (실행) ──────────────────
|
||
const planInstruction = '\n\n[계획 단계] 실행하지 말고 무엇을 어떻게 바꿀지만 설명해. 질문이면 바로 답해.';
|
||
|
||
const planThinkEl = codeAiAppend('thinking', '계획 중...');
|
||
let planText = '';
|
||
try {
|
||
const planRes = await fetch('/api/chat', {
|
||
method: 'POST',
|
||
headers: authHeaders({ 'Content-Type': 'application/json', 'Accept': 'text/event-stream' }),
|
||
signal: codeAiAbort.signal,
|
||
body: JSON.stringify({ message: contextMsg + planInstruction, history: historyForAPI, useTools: false, sessionId: codeAiSessionId + '_plan', codeAiTech, codeAiLocal: !!localDirHandle, ...(codeAiModel ? { model: codeAiModel } : {}) }),
|
||
});
|
||
if (planRes.status === 401) { setAuthToken(''); _codeAiShowRelogin(); return; }
|
||
if (planRes.ok) {
|
||
const planReader = planRes.body.getReader();
|
||
const planDec = new TextDecoder();
|
||
let planBuf = '';
|
||
planThinkEl.textContent = '';
|
||
planThinkEl.className = 'code-ai-msg assistant';
|
||
while (true) {
|
||
const { done, value } = await planReader.read();
|
||
if (done) break;
|
||
planBuf += planDec.decode(value, { stream: true });
|
||
const planLines = planBuf.split('\n'); planBuf = planLines.pop();
|
||
for (const ln of planLines) {
|
||
if (!ln.startsWith('data:')) continue;
|
||
const d = ln.slice(5).trim();
|
||
if (d === '[DONE]') continue;
|
||
try {
|
||
const ev = JSON.parse(d);
|
||
if (ev.type === 'usage') { _codeAiOnUsage(ev); continue; }
|
||
if (ev.type === 'final' || ev.type === 'done') { if (!planText) planText = ev.text || ev.reply || ev.content || ''; continue; }
|
||
const tok = ev.content || ev.text || ev.delta || null;
|
||
if (tok) { planText += tok; planThinkEl.textContent = planText.replace(/<\/?think>/gi, ''); codeAiScrollBottom(); }
|
||
} catch {}
|
||
}
|
||
}
|
||
}
|
||
} catch(planErr) {
|
||
if (planErr.name === 'AbortError') return;
|
||
planText = '(계획 생성 실패)';
|
||
}
|
||
// Strip leaked <think>...</think> blocks (some models emit them despite think:false)
|
||
planText = planText.replace(/<think>[\s\S]*?<\/think>/gi, '').replace(/<\/think>/gi, '').trim();
|
||
planThinkEl.textContent = planText || '(응답 없음)';
|
||
codeAiRenderMarkdown(planThinkEl, planText || '(응답 없음)');
|
||
|
||
// ── Confirm UI ──────────────────────────────────────────────────────────────
|
||
// Detect if the plan describes edits or is a pure Q&A answer.
|
||
const _EDIT_VERB_RE = /수정|변경|추가|삭제|편집|교체|생성|작성|만들|구현|replace|modify|add\s|remove|insert|update|creat/i;
|
||
const _SEARCH_BLOCK_RE = /------- SEARCH|<<<<<<< SEARCH/;
|
||
const _LIST_RE = /^\s*(\d+[.)]|[-*•])\s+\S/m;
|
||
// 목록 항목 자체가 질문이면 Q&A로 판단 ("수정해 드릴까요?" 등 오탐 방지)
|
||
const _LIST_QUESTION_RE = /^\s*(\d+[.)]|[-*•])\s+.*[??]/m;
|
||
// 파일명 + 편집 동사만 있는 경우도 편집 계획으로 판단 (목록 없이 "X에 추가하겠습니다" 등)
|
||
const _FILE_MENTION_RE = /`[^`]*\.[a-zA-Z]{1,6}`|\b\w+\.(py|js|ts|tsx|html|css|json|yaml|yml|sh|java|cpp|c|go|rs|md|txt|sql|env|toml|cfg)\b/i;
|
||
const isPlanWithEdits =
|
||
_SEARCH_BLOCK_RE.test(planText) ||
|
||
(_LIST_RE.test(planText) && _EDIT_VERB_RE.test(planText) && !_LIST_QUESTION_RE.test(planText)) ||
|
||
(_FILE_MENTION_RE.test(planText) && _EDIT_VERB_RE.test(planText) && !_LIST_QUESTION_RE.test(planText));
|
||
|
||
if (!isPlanWithEdits) {
|
||
// Pure Q&A — plan text is the answer; record it and stop
|
||
codeAiHistory.push({ role: 'assistant', content: _histEntry(planText) || '(응답 없음)' });
|
||
_autoSaveChatSession();
|
||
if (activeProjectName) saveProjectSession(activeProjectName);
|
||
try { updateSessionPanel(); } catch (_) {}
|
||
return;
|
||
}
|
||
|
||
// Parse numbered options from plan text (e.g. "1. CSS 방식\n2. JS 방식")
|
||
// Returns [{num, label}] if 2-5 sequential numbered items found, else []
|
||
const _parsedOptions = (() => {
|
||
const opts = [];
|
||
for (const line of planText.split('\n')) {
|
||
const m = line.match(/^(\d+)[.)]\s+(.+)/);
|
||
if (!m) continue;
|
||
if (parseInt(m[1]) !== opts.length + 1) break;
|
||
opts.push({ num: parseInt(m[1]), label: m[2].replace(/\*\*/g, '').trim() });
|
||
if (opts.length === 6) break;
|
||
}
|
||
if (opts.length < 2) return [];
|
||
// 파일 작업 키워드가 포함된 항목이 있으면 실행 단계 목록 → 선택지 아님
|
||
const fileOpRe = /edit_file|create_file|move_file|delete_file|replace_lines|insert_after|\.[a-zA-Z]{2,4}(?:\s|$|—|:)/;
|
||
if (opts.some(o => fileOpRe.test(o.label))) return [];
|
||
if (/수정\s*계획|변경\s*계획|개선\s*계획|구현\s*계획/.test(planText)) return [];
|
||
const firstPart = planText.slice(0, planText.length * 0.6);
|
||
if (opts.every(o => firstPart.includes(o.label))) return [];
|
||
return opts;
|
||
})();
|
||
|
||
// Show confirm / cancel / modify buttons and wait for user decision
|
||
// Resolves with: true (execute) | false (cancel) | { modify: string } | { option: num, label: string }
|
||
const _planDecision = await new Promise(resolve => {
|
||
const msgs = document.getElementById('code-ai-messages');
|
||
if (!msgs) { resolve(false); return; }
|
||
const signal = codeAiAbort?.signal;
|
||
if (signal?.aborted) { resolve(false); return; }
|
||
|
||
const card = document.createElement('div');
|
||
card.className = 'code-ai-msg system';
|
||
card.style.cssText = 'padding:6px 0;';
|
||
|
||
const mkBtn = (text, bg, extra = '') => {
|
||
const b = document.createElement('button');
|
||
b.textContent = text;
|
||
b.style.cssText = `background:${bg};color:#fff;border:none;border-radius:4px;padding:4px 10px;font-size:12px;font-weight:700;cursor:pointer;${extra}`;
|
||
return b;
|
||
};
|
||
|
||
const allBtns = [];
|
||
const onAbort = () => done(false, true);
|
||
const done = (result, viaAbort) => {
|
||
if (signal) signal.removeEventListener('abort', onAbort);
|
||
allBtns.forEach(b => { b.disabled = true; b.style.opacity = '0.5'; });
|
||
if (modInput) modInput.disabled = true;
|
||
if (viaAbort) card.innerHTML = '<span style="font-size:12px;color:var(--muted)">편집이 중지되었습니다.</span>';
|
||
resolve(result);
|
||
};
|
||
|
||
let modInput = null;
|
||
|
||
if (_parsedOptions.length) {
|
||
// ── 선택지 모드 ──────────────────────────────────────────────────────────
|
||
const optLabel = document.createElement('div');
|
||
optLabel.style.cssText = 'font-size:12px;color:var(--muted);margin-bottom:6px';
|
||
optLabel.textContent = '방법을 선택하세요:';
|
||
card.appendChild(optLabel);
|
||
const optRow = document.createElement('div');
|
||
optRow.style.cssText = 'display:flex;gap:6px;flex-wrap:wrap;margin-bottom:6px';
|
||
for (const opt of _parsedOptions) {
|
||
const short = opt.label.length > 32 ? opt.label.slice(0, 32) + '…' : opt.label;
|
||
const ob = mkBtn(`${opt.num}. ${short}`, '#0d6efd', 'text-align:left;white-space:normal;max-width:200px;line-height:1.4;padding:5px 10px');
|
||
ob.title = opt.label;
|
||
ob.dataset.optNum = String(opt.num);
|
||
ob.onclick = () => done({ option: opt.num, label: opt.label });
|
||
allBtns.push(ob);
|
||
optRow.appendChild(ob);
|
||
}
|
||
card.appendChild(optRow);
|
||
const cancelBtn = mkBtn('❌ 취소', '#dc3545');
|
||
allBtns.push(cancelBtn);
|
||
cancelBtn.onclick = () => done(false);
|
||
card.appendChild(cancelBtn);
|
||
} else {
|
||
// ── 일반 모드: 실행 / 수정 / 취소 ──────────────────────────────────────
|
||
const hint = document.createElement('div');
|
||
hint.style.cssText = 'font-size:12px;color:var(--muted);margin-bottom:6px';
|
||
hint.textContent = '위 계획대로 실행할까요?';
|
||
card.appendChild(hint);
|
||
|
||
const btnRow = document.createElement('div');
|
||
btnRow.style.cssText = 'display:flex;gap:8px;align-items:center;flex-wrap:wrap';
|
||
|
||
const confirmBtn = mkBtn('✅ 실행', '#28a745');
|
||
const modifyBtn = mkBtn('✏️ 수정', '#6c757d');
|
||
const cancelBtn = mkBtn('❌ 취소', '#dc3545');
|
||
allBtns.push(confirmBtn, modifyBtn, cancelBtn);
|
||
|
||
// Modify row (hidden until ✏️ clicked)
|
||
const modRow = document.createElement('div');
|
||
modRow.style.cssText = 'display:none;margin-top:6px;gap:6px;align-items:flex-start;flex-direction:column';
|
||
modInput = document.createElement('textarea');
|
||
modInput.placeholder = '수정 요청 입력 (예: 버튼 색상은 파란색으로)';
|
||
modInput.style.cssText = 'width:100%;box-sizing:border-box;padding:5px 8px;font-size:12px;border:1px solid var(--border,#444);border-radius:4px;background:var(--panel-2,#1e1e2e);color:var(--text,#ccc);resize:vertical;min-height:54px';
|
||
const replanBtn = mkBtn('🔄 다시 계획', '#0d6efd');
|
||
allBtns.push(replanBtn);
|
||
modRow.appendChild(modInput);
|
||
modRow.appendChild(replanBtn);
|
||
|
||
confirmBtn.onclick = () => done(true);
|
||
cancelBtn.onclick = () => done(false);
|
||
modifyBtn.onclick = () => { modRow.style.display = 'flex'; modifyBtn.style.display = 'none'; modInput.focus(); };
|
||
replanBtn.onclick = () => { const t = modInput.value.trim(); done(t ? { modify: t } : false); };
|
||
modInput.addEventListener('keydown', e => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) replanBtn.click(); });
|
||
|
||
btnRow.appendChild(confirmBtn);
|
||
btnRow.appendChild(modifyBtn);
|
||
btnRow.appendChild(cancelBtn);
|
||
card.appendChild(btnRow);
|
||
card.appendChild(modRow);
|
||
} // end else (normal mode)
|
||
if (signal) signal.addEventListener('abort', onAbort, { once: true });
|
||
msgs.appendChild(card);
|
||
codeAiScrollBottom();
|
||
});
|
||
|
||
if (!_planDecision) return; // cancelled
|
||
|
||
// ── Option selected → re-plan with chosen option ────────────────────────────
|
||
if (typeof _planDecision === 'object' && _planDecision.option != null) {
|
||
await _codeAiSend(`${_planDecision.option}번 방법으로 진행해줘: ${_planDecision.label}`);
|
||
return;
|
||
}
|
||
|
||
// ── Re-plan with modification ────────────────────────────────────────────────
|
||
if (typeof _planDecision === 'object' && _planDecision.modify) {
|
||
await _codeAiSend(`위 계획을 다음 조건으로 수정해서 다시 계획해줘: ${_planDecision.modify}`);
|
||
return;
|
||
}
|
||
|
||
const _userConfirmed = _planDecision; // true → proceed to execute
|
||
|
||
// ── Phase 2: execute (with tools, auto-accept) ─────────────────────────────
|
||
_codeAiAutoAccept = true;
|
||
_autoAcceptTurn = true;
|
||
const thinkEl = codeAiAppend('thinking', '편집 실행 중...');
|
||
|
||
// Build full-file context for execute phase (model needs exact SEARCH text). Still
|
||
// byte-capped: a multi-MB minified/data file would otherwise blow the request size.
|
||
let execContextMsg = contextMsg;
|
||
if (currentFile && monacoEditor && localDirHandle) {
|
||
const fullContent = monacoEditor.getValue();
|
||
const ext = currentFile.name.split('.').pop() || '';
|
||
const displayName = _clientBareFileName(currentFile.name);
|
||
let numberedFull = fullContent.split('\n').map((l, i) => `${i + 1}: ${l}`).join('\n');
|
||
let bigNote = '';
|
||
if (numberedFull.length > MAX_AI_FILE_BYTES) {
|
||
numberedFull = numberedFull.slice(0, MAX_AI_FILE_BYTES) + `\n…[파일이 너무 커서 잘림 — 전체 ${Math.round(fullContent.length / 1024)}KB]`;
|
||
bigNote = ` — 대용량, 앞부분만`;
|
||
}
|
||
execContextMsg = `${otherFilesCtx}[현재 파일: ${displayName} (전체 ${fullContent.split('\n').length}줄${bigNote})]\n\`\`\`${ext}\n${numberedFull}\n\`\`\`\n\n${resolvedMessage}`;
|
||
}
|
||
|
||
// Include plan in execute history so model knows exactly what to do.
|
||
// Cap planText: if the plan phase returned huge content (e.g. full file rewrite), sending
|
||
// it raw can push the request body over 50MB and cause 413 errors.
|
||
const planTextCapped = planText.length > 12000 ? planText.slice(0, 12000) + '\n…[계획 잘림]' : planText;
|
||
const execHistory = [
|
||
...historyForAPI,
|
||
{ role: 'assistant', content: planTextCapped },
|
||
{ role: 'user', content: '위 계획대로 도구를 즉시 실행해줘 (edit_file, move_file, create_file 등 계획에 맞는 도구 사용). 계획을 다시 요약하거나 반복하지 마. 첫 번째 메시지부터 바로 도구를 호출해. SEARCH 블록은 파일 내용에서 정확히 복사.' },
|
||
];
|
||
|
||
try {
|
||
if (codeAiAbort) codeAiAbort.abort();
|
||
codeAiAbort = new AbortController();
|
||
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: execContextMsg, history: execHistory, useTools: true, sessionId: codeAiSessionId, codeAiTech, codeAiLocal: !!localDirHandle, ...(codeAiModel ? { model: codeAiModel } : {}) }),
|
||
});
|
||
if (res.status === 401) { setAuthToken(''); _codeAiShowRelogin(); return; }
|
||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||
const reader = res.body.getReader();
|
||
const dec = new TextDecoder();
|
||
let buf = '', reply = '', _preambleBuf = '', _firstToolSeen = false;
|
||
let liveTextEl = null, segReply = '';
|
||
thinkEl.textContent = '🤔 분석 중...';
|
||
thinkEl.className = 'code-ai-msg thinking';
|
||
const _statusMove = (text) => { thinkEl.textContent = text; thinkEl.style.display = ''; const msgs = document.getElementById('code-ai-msgs'); if (msgs) msgs.appendChild(thinkEl); codeAiScrollBottom(); };
|
||
// 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 };
|
||
const _localFailedFiles = new Set(); // track files where local apply failed this turn
|
||
|
||
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 === 'usage') { _codeAiOnUsage(ev); continue; }
|
||
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)) { if (!reply) reply = ev.text || ev.content; (liveTextEl || thinkEl).textContent = reply; codeAiScrollBottom(); continue; }
|
||
else if (ev.type === 'done' && ev.reply) { if (!reply) reply = ev.reply; (liveTextEl || thinkEl).textContent = reply; codeAiScrollBottom(); continue; }
|
||
// Handle tool events for live editor update + action cards
|
||
else if (ev.type === 'tool_call') {
|
||
_firstToolSeen = true;
|
||
// Close current text bubble before showing tool card
|
||
if (liveTextEl) {
|
||
if (!segReply.trim()) liveTextEl.remove();
|
||
else { liveTextEl.classList.remove('streaming'); codeAiRenderMarkdown(liveTextEl, segReply); }
|
||
liveTextEl = null; segReply = '';
|
||
}
|
||
const toolName = ev.action || ev.name || '';
|
||
const toolArgs = ev.args || {};
|
||
const _fname = toolArgs.filename || toolArgs.name || toolArgs.path || '';
|
||
const _short = _fname ? _fname.split('/').pop() : '';
|
||
const _tIcon = _TOOL_ICONS[toolName] || '🔧';
|
||
_statusMove(`${_tIcon} ${toolName}${_short ? ': ' + _short : ''} ...`);
|
||
_showLogBubble();
|
||
liveState.toolCallCount = (liveState.toolCallCount || 0) + 1;
|
||
_codeAiToolAction(toolName, toolArgs, null, null);
|
||
// ── Client-side file intercept: apply tool changes to local folder ──
|
||
// The server returns synthetic success for local sessions, so we surface
|
||
// any client-side apply failure here — otherwise the model (and user)
|
||
// would think the edit landed when nothing actually changed.
|
||
_codeAiApplyToolLocally(toolName, toolArgs).then(r => {
|
||
if (r && r.ok === false) {
|
||
_codeAiToolAction(toolName, null, `⚠️ 로컬 적용 실패: ${r.reason}`, true);
|
||
const failedFname = toolArgs.filename || toolArgs.name || '';
|
||
if (failedFname) _localFailedFiles.add(_clientBareFileName(failedFname.replace(/^code\//, '')));
|
||
}
|
||
});
|
||
}
|
||
else if (ev.type === 'tool_result') {
|
||
const toolName = ev.action || '';
|
||
const toolResult = ev.result || '';
|
||
const toolError = ev.error || false;
|
||
_codeAiToolAction(toolName, null, toolResult, toolError);
|
||
_statusMove('💭 다음 단계 분석 중...');
|
||
// Refresh file list after successful file-modifying tools
|
||
if (!toolError && ['create_file', 'replace_lines', 'insert_after', 'delete_lines', 'find_replace', 'edit_file', 'move_file', 'delete_file'].includes(toolName)) {
|
||
codeSbRefresh();
|
||
}
|
||
}
|
||
if (token) {
|
||
reply += token; segReply += token;
|
||
if (!liveTextEl) {
|
||
liveTextEl = codeAiAppend('assistant', '');
|
||
liveTextEl.classList.add('streaming');
|
||
if (_firstToolSeen) _statusMove('💬 응답 작성 중...');
|
||
}
|
||
liveTextEl.textContent = segReply; codeAiScrollBottom();
|
||
if (_firstToolSeen) {
|
||
try { codeLiveStreamUpdate(segReply, liveState); } catch(e) { console.error('[codeLiveStream] error:', e); liveTextEl.textContent += '\n⚠️ 에디터 스트리밍 오류: ' + (e.message || e); }
|
||
}
|
||
}
|
||
} catch(e) { console.error('[codeAiStream] parse error:', e.message || e, 'data:', data?.slice(0, 80)); }
|
||
}
|
||
}
|
||
liveState.inBlock = false;
|
||
codeMonacoThrottleFlush();
|
||
|
||
// If no tool calls occurred, the buffered preamble IS the reply (pure text response)
|
||
if (!_firstToolSeen && _preambleBuf) { reply = _preambleBuf; thinkEl.textContent = reply; codeAiScrollBottom(); }
|
||
|
||
// 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)
|
||
// First check if this is a multi-file response (model split into CSS/JS/HTML etc.)
|
||
const explicitBlocks = _extractExplicitFileBlocks(reply);
|
||
const hasExplicitBlocks = explicitBlocks.length > 0;
|
||
|
||
if (hasExplicitBlocks) {
|
||
// Multi-file or mixed diff+new-file response: restore the streamed tab
|
||
// first, then process each block individually.
|
||
if (streamFileId) {
|
||
const streamedFile = codeFiles.find(f => f.id === streamFileId);
|
||
if (streamedFile && streamOriginalContent != null && streamOriginalContent.trim()) {
|
||
if (streamedFile.model) streamedFile.model.setValue(streamOriginalContent);
|
||
else streamedFile.content = streamOriginalContent;
|
||
}
|
||
}
|
||
_fileApprovalQueue = [];
|
||
// Separate diff blocks (modify existing files) from new-file blocks
|
||
const diffBlocks = explicitBlocks.filter(b => b.isDiff);
|
||
const newFileBlocks = explicitBlocks.filter(b => !b.isDiff);
|
||
|
||
// Process diff blocks: apply each diff to the corresponding file
|
||
for (const { fname, code } of diffBlocks) {
|
||
const fullName = (activeProjectName && !fname.includes('/')) ? `${activeProjectName}/${fname}` : fname;
|
||
// Clean trailing fence
|
||
const cleanDiff = code.replace(/\n`{3}[\w-]*[ \t]*$/, '\n');
|
||
// Find the target file — try exact match, then project-prefixed, then open tabs
|
||
let targetFile = codeFiles.find(f => f.name === fullName || f.name === fname);
|
||
if (!targetFile) targetFile = codeFiles.find(f => f.name.endsWith('/' + fname));
|
||
if (!targetFile && localDirHandle) {
|
||
// Try loading from disk
|
||
const diskContent = await localReadFile(fullName) ?? await localReadFile(fname);
|
||
if (diskContent !== null) {
|
||
codeNewFile(fullName, diskContent);
|
||
targetFile = codeFiles.find(f => f.name === fullName || f.name === fname);
|
||
}
|
||
}
|
||
if (targetFile) {
|
||
const oldContent = targetFile.model ? targetFile.model.getValue() : targetFile.content;
|
||
const newContent = applyUnifiedDiff(oldContent, cleanDiff);
|
||
if (oldContent !== newContent) {
|
||
if (targetFile.model) targetFile.model.setValue(oldContent);
|
||
else targetFile.content = oldContent;
|
||
showCodeDiff(targetFile.id, oldContent, newContent);
|
||
}
|
||
}
|
||
// If target file not found, the diff block is silently skipped.
|
||
// The user can see the diff in the chat text and apply manually.
|
||
}
|
||
|
||
// Process new-file blocks: create tabs or update existing files
|
||
for (const { fname, code } of newFileBlocks) {
|
||
const fullName = (activeProjectName && !fname.includes('/')) ? `${activeProjectName}/${fname}` : fname;
|
||
const existing = codeFiles.find(f => f.name === fullName || f.name === fname);
|
||
if (existing) {
|
||
const oldContent = existing.model ? existing.model.getValue() : existing.content;
|
||
if (existing.model) existing.model.setValue(code);
|
||
else existing.content = code;
|
||
if (oldContent.trim() && oldContent !== code) {
|
||
if (existing.model) existing.model.setValue(oldContent);
|
||
showCodeDiff(existing.id, oldContent, code);
|
||
} else {
|
||
_enqueueFileApproval(existing.id, existing.name);
|
||
}
|
||
} else {
|
||
const id = 'cf_' + Math.random().toString(36).slice(2, 8);
|
||
const model = monacoReady ? monaco.editor.createModel(code, codeDetectLang(fullName)) : null;
|
||
codeFiles.push({ id, name: fullName, content: code, language: codeDetectLang(fullName), model });
|
||
_enqueueFileApproval(id, fullName);
|
||
}
|
||
}
|
||
|
||
renderCodeFileTabs();
|
||
if (_fileApprovalQueue.length) _showNextApproval();
|
||
else if (explicitBlocks.length) {
|
||
const firstBlock = explicitBlocks[0];
|
||
const firstFname = (activeProjectName && !firstBlock.fname.includes('/')) ? `${activeProjectName}/${firstBlock.fname}` : firstBlock.fname;
|
||
switchCodeFile(codeFiles.find(f => f.name === firstFname || f.name === firstBlock.fname)?.id || activeCodeFileId);
|
||
}
|
||
} else if (streamFileId) {
|
||
let file = codeFiles.find(f => f.id === streamFileId);
|
||
let resolvedOriginal = streamOriginalContent;
|
||
|
||
// Re-resolve diff target: streaming may have landed on the active tab even though
|
||
// the diff names a different file. Extract the real filename and switch to it.
|
||
if (file && streamIsDiff && streamDiffAccumulated) {
|
||
const diffFname = codeDeriveName(streamDiffAccumulated, 'diff')
|
||
|| streamDiffAccumulated.match(/^(?:---|\+\+\+)\s+(?:[ab]\/)?(.+?)(?:\t|$)/m)?.[1]?.trim();
|
||
if (diffFname && !file.name.endsWith(diffFname) && file.name !== diffFname) {
|
||
// Active tab doesn't match diff target — find the right file
|
||
const rightFile = codeFiles.find(f => f.name === diffFname || f.name.endsWith('/' + diffFname));
|
||
if (rightFile) {
|
||
// Restore wrongly-targeted tab and switch to correct file
|
||
if (file.model && streamOriginalContent != null) file.model.setValue(streamOriginalContent);
|
||
resolvedOriginal = rightFile.model ? rightFile.model.getValue() : rightFile.content;
|
||
file = rightFile;
|
||
} else if (localDirHandle) {
|
||
// File not open — load from disk, open as new tab
|
||
const fullFname = (activeProjectName && !diffFname.includes('/'))
|
||
? `${activeProjectName}/${diffFname}` : diffFname;
|
||
const diskContent = await localReadFile(fullFname) ?? await localReadFile(diffFname);
|
||
if (diskContent !== null) {
|
||
if (file.model && streamOriginalContent != null) file.model.setValue(streamOriginalContent);
|
||
codeNewFile(fullFname, diskContent);
|
||
const loaded = codeFiles.find(f => f.name === fullFname || f.name === diffFname);
|
||
if (loaded) { resolvedOriginal = diskContent; file = loaded; }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (file) {
|
||
if (streamIsDiff && streamDiffAccumulated && resolvedOriginal != null) {
|
||
// Diff block: apply the unified diff to the original content.
|
||
// Strip trailing closing fence that leaks in when the model emits ``` with no newline at end.
|
||
const cleanDiff = streamDiffAccumulated.replace(/\n`{3}[\w-]*[ \t]*$/, '\n');
|
||
const newContent = applyUnifiedDiff(resolvedOriginal, cleanDiff);
|
||
if (resolvedOriginal !== newContent) {
|
||
if (file.model) file.model.setValue(resolvedOriginal);
|
||
else file.content = resolvedOriginal;
|
||
showCodeDiff(file.id, resolvedOriginal, newContent);
|
||
}
|
||
} else if (resolvedOriginal != null && resolvedOriginal.trim()) {
|
||
// Full code block: compare original vs streamed modified text
|
||
const newContent = streamModifiedText || (file.model ? file.model.getValue() : file.content);
|
||
if (resolvedOriginal !== newContent) {
|
||
if (file.model) file.model.setValue(resolvedOriginal);
|
||
else file.content = resolvedOriginal;
|
||
showCodeDiff(file.id, resolvedOriginal, newContent);
|
||
}
|
||
} else {
|
||
// New file created by AI — show approval bar
|
||
_showNewFileApproval(file.id, file.name);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Auto-retry: if in code mode, had a file context, but the response contains
|
||
// no diff/code blocks (analysis-only), automatically re-prompt for actual code.
|
||
const hasCodeBlock = /```[\w-]*\n[\s\S]*?\n```/.test(reply || '');
|
||
const hasDiffBlock = /```diff\n[\s\S]*?\n```/.test(reply || '');
|
||
const toolsWereUsed = (liveState.toolCallCount || 0) > 0;
|
||
const looksLikeAnalysis = /^(Diagnosis|Plan|Analysis|진단|분석|해결 방법|수정 사항|변경 사항|Steps|Step\s+\d|Incomplete |Fix |The (?:file|code|error|issue))/im.test(reply || '');
|
||
// Skip retry for conversational/question messages — only retry when user clearly wants code changes
|
||
const isQuestion = /[??]$/.test(message.trim()) ||
|
||
/^(어떻게|어떤|뭐가|뭔|왜|언제|어디|누가|무엇|알려|설명|뭐야|뭐지|어디서|어떤게|차이|차이가|뭘|안녕|하이|hi\b|hello\b|hey\b)/i.test(message.trim()) ||
|
||
/^(what|how|why|when|where|who|which|can you explain|tell me|is there|are there|does|do |did )/i.test(message.trim());
|
||
if (!hasCodeBlock && !hasDiffBlock && !toolsWereUsed && looksLikeAnalysis && !isQuestion && codeAiHistory.length > 0) {
|
||
const retryMsg = '위 분석만 하지 말고 edit_file 또는 create_file 도구를 사용해서 실제로 파일을 수정해줘.';
|
||
codeAiHistory.push({ role: 'assistant', content: _histEntry(reply) || '(응답 없음)' });
|
||
codeAiHistory.push({ role: 'user', content: retryMsg });
|
||
thinkEl.classList.add('streaming');
|
||
thinkEl.textContent = '🔄 분석만 감지 — 도구로 파일 수정 재시도 중...';
|
||
const retryAbort = new AbortController();
|
||
try {
|
||
const retryHistory = _histForAPI(codeAiHistory);
|
||
const retryRes = await fetch('/api/chat', {
|
||
method: 'POST',
|
||
headers: authHeaders({ 'Content-Type': 'application/json', 'Accept': 'text/event-stream' }),
|
||
signal: retryAbort.signal,
|
||
body: JSON.stringify({ message: retryMsg, history: retryHistory, useTools: true, sessionId: codeAiSessionId + '_retry', codeAiTech, codeAiLocal: !!localDirHandle }),
|
||
});
|
||
if (retryRes.ok) {
|
||
const retryReader = retryRes.body.getReader();
|
||
const retryDec = new TextDecoder();
|
||
let retryBuf = '', retryReply = '';
|
||
const retryState = { inBlock: false, lang: '', fileId: null, originalContent: null, isDiff: false, diffAccumulated: null, streamingModifiedText: null, targetFileId: activeCodeFileId };
|
||
while (true) {
|
||
const { done: rDone, value: rVal } = await retryReader.read();
|
||
if (rDone) break;
|
||
retryBuf += retryDec.decode(rVal, { stream: true });
|
||
const rLines = retryBuf.split('\n');
|
||
retryBuf = rLines.pop();
|
||
for (const rl of rLines) {
|
||
if (!rl.startsWith('data:')) continue;
|
||
const rd = rl.slice(5).trim();
|
||
if (rd === '[DONE]') continue;
|
||
try {
|
||
const rev = JSON.parse(rd);
|
||
let rToken = null;
|
||
if (rev.type === 'token' && (rev.content || rev.text)) rToken = rev.content || rev.text;
|
||
else if (rev.type === 'text_delta' && rev.delta) rToken = rev.delta;
|
||
else if (rev.type === 'final' && (rev.text || rev.content)) { if (!retryReply) retryReply = rev.text || rev.content; continue; }
|
||
else if (rev.type === 'done' && rev.reply) { if (!retryReply) retryReply = rev.reply; continue; }
|
||
if (rToken) { retryReply += rToken; thinkEl.textContent = retryReply; codeAiScrollBottom(); try { codeLiveStreamUpdate(retryReply, retryState); } catch(e) { console.error('[codeLiveStream] retry error:', e); } }
|
||
} catch(e) {}
|
||
}
|
||
}
|
||
retryState.inBlock = false;
|
||
codeMonacoThrottleFlush();
|
||
// Clear streaming decorations for retry
|
||
if (retryState.fileId) {
|
||
const rf = codeFiles.find(f => f.id === retryState.fileId);
|
||
if (rf?._streamingDecos) { try { rf.model.deltaDecorations(rf._streamingDecos, []); } catch {} rf._streamingDecos = null; }
|
||
}
|
||
if (_streamingDiffTimer) { clearTimeout(_streamingDiffTimer); _streamingDiffTimer = null; }
|
||
// Retry result always wins — prevents original analysis from being re-rendered on top
|
||
if (retryReply) {
|
||
reply = retryReply;
|
||
// Apply retry result (same logic as main flow)
|
||
const retryBlocks = _extractExplicitFileBlocks(retryReply);
|
||
const retryIsMulti = retryBlocks.length > 1;
|
||
if (retryIsMulti) {
|
||
// Restore original if streaming modified it
|
||
const rStreamFile = codeFiles.find(f => f.id === retryState.fileId);
|
||
if (rStreamFile && retryState.originalContent != null && retryState.originalContent.trim()) {
|
||
if (rStreamFile.model) rStreamFile.model.setValue(retryState.originalContent);
|
||
else rStreamFile.content = retryState.originalContent;
|
||
}
|
||
_fileApprovalQueue = [];
|
||
for (const { fname, code, lang } of retryBlocks) {
|
||
const fullName = (activeProjectName && !fname.includes('/')) ? `${activeProjectName}/${fname}` : fname;
|
||
const existing = codeFiles.find(f => f.name === fullName || f.name === fname);
|
||
if (existing) {
|
||
const oldC = existing.model ? existing.model.getValue() : existing.content;
|
||
if (existing.model) existing.model.setValue(code);
|
||
else existing.content = code;
|
||
if (oldC.trim() && oldC !== code) {
|
||
if (existing.model) existing.model.setValue(oldC);
|
||
showCodeDiff(existing.id, oldC, code);
|
||
} else { _enqueueFileApproval(existing.id, existing.name); }
|
||
} else {
|
||
const id = 'cf_' + Math.random().toString(36).slice(2, 8);
|
||
const model = monacoReady ? monaco.editor.createModel(code, codeDetectLang(fullName)) : null;
|
||
codeFiles.push({ id, name: fullName, content: code, language: codeDetectLang(fullName), model });
|
||
_enqueueFileApproval(id, fullName);
|
||
}
|
||
}
|
||
renderCodeFileTabs();
|
||
if (_fileApprovalQueue.length) _showNextApproval();
|
||
} else if (retryState.isDiff && retryState.diffAccumulated) {
|
||
const rFile = codeFiles.find(f => f.id === retryState.fileId);
|
||
if (rFile) {
|
||
const cleanD = retryState.diffAccumulated.replace(/\n`{3}[\w-]*[ \t]*$/, '\n');
|
||
const newC = applyUnifiedDiff(retryState.originalContent, cleanD);
|
||
if (retryState.originalContent !== newC) {
|
||
if (rFile.model) rFile.model.setValue(retryState.originalContent);
|
||
else rFile.content = retryState.originalContent;
|
||
showCodeDiff(rFile.id, retryState.originalContent, newC);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch(retryErr) {
|
||
if (retryErr.name === 'AbortError') {
|
||
// User sent a new message during retry — that's fine, just log
|
||
console.log('[codeAiRetry] aborted — user sent new message');
|
||
} else {
|
||
console.error('[codeAiRetry] failed:', retryErr);
|
||
}
|
||
}
|
||
// Remove the injected retry turn (assistant analysis + user retry prompt);
|
||
// the final `reply` is pushed once at the end. Mirrors the SEARCH-fix path.
|
||
if (codeAiHistory[codeAiHistory.length - 1]?.role === 'user') codeAiHistory.pop();
|
||
if (codeAiHistory[codeAiHistory.length - 1]?.role === 'assistant') codeAiHistory.pop();
|
||
}
|
||
|
||
// If any edit_file failed locally (SEARCH mismatch), send the actual file
|
||
// content back so the model can retry with correct SEARCH blocks.
|
||
if (_localFailedFiles.size > 0) {
|
||
// Wait a tick so all .then() handlers above have fired
|
||
await new Promise(r => setTimeout(r, 50));
|
||
const failedParts = [];
|
||
for (const fname of _localFailedFiles) {
|
||
const actual = await _codeAiReadLocalFile(fname);
|
||
if (actual != null) {
|
||
failedParts.push(`**${fname}** 현재 내용:\n\`\`\`\n${actual}\n\`\`\``);
|
||
}
|
||
}
|
||
if (failedParts.length) {
|
||
const fixMsg = `edit_file SEARCH 블록이 실제 파일 내용과 일치하지 않았습니다. 아래 현재 내용을 기준으로 다시 수정해줘:\n\n${failedParts.join('\n\n')}`;
|
||
codeAiHistory.push({ role: 'assistant', content: _histEntry(reply) || '(응답 없음)' });
|
||
codeAiHistory.push({ role: 'user', content: fixMsg });
|
||
thinkEl.classList.add('streaming');
|
||
thinkEl.textContent = '🔄 SEARCH 불일치 — 실제 파일로 재시도 중...';
|
||
try {
|
||
const fixRes = await fetch('/api/chat', {
|
||
method: 'POST',
|
||
headers: authHeaders({ 'Content-Type': 'application/json', 'Accept': 'text/event-stream' }),
|
||
signal: codeAiAbort?.signal,
|
||
body: JSON.stringify({ message: fixMsg, history: _histForAPI(codeAiHistory), useTools: true, sessionId: codeAiSessionId + '_fix', codeAiTech, codeAiLocal: !!localDirHandle, ...(codeAiModel ? { model: codeAiModel } : {}) }),
|
||
});
|
||
if (fixRes.ok) {
|
||
const fixReader = fixRes.body.getReader();
|
||
const fixDec = new TextDecoder();
|
||
let fixBuf = '', fixReply = '';
|
||
const fixState = { inBlock: false, lang: '', fileId: null, originalContent: null, isDiff: false, diffAccumulated: null, streamingModifiedText: null, targetFileId: activeCodeFileId };
|
||
while (true) {
|
||
const { done, value } = await fixReader.read();
|
||
if (done) break;
|
||
fixBuf += fixDec.decode(value, { stream: true });
|
||
const fixLines = fixBuf.split('\n'); fixBuf = fixLines.pop();
|
||
for (const ln of fixLines) {
|
||
if (!ln.startsWith('data:')) continue;
|
||
const d = ln.slice(5).trim();
|
||
if (d === '[DONE]') continue;
|
||
try {
|
||
const ev = JSON.parse(d);
|
||
let tok = null;
|
||
if (ev.type === 'token' || ev.type === 'text_delta') tok = ev.content || ev.text || ev.delta || null;
|
||
else if (ev.type === 'final' || ev.type === 'done') { if (!fixReply) fixReply = ev.text || ev.reply || ev.content || ''; }
|
||
else if (ev.type === 'tool_call') {
|
||
_codeAiToolAction(ev.action || ev.name || '', ev.args || {}, null, null);
|
||
_codeAiApplyToolLocally(ev.action || ev.name || '', ev.args || {}).then(r2 => {
|
||
if (r2 && r2.ok === false) _codeAiToolAction(ev.action || ev.name || '', null, `⚠️ 로컬 적용 실패: ${r2.reason}`, true);
|
||
});
|
||
} else if (ev.type === 'tool_result') { _codeAiToolAction(ev.action || '', null, ev.result || '', ev.error || false); }
|
||
if (tok) { fixReply += tok; thinkEl.textContent = fixReply; codeAiScrollBottom(); try { codeLiveStreamUpdate(fixReply, fixState); } catch {} }
|
||
} catch {}
|
||
}
|
||
}
|
||
codeMonacoThrottleFlush();
|
||
reply = fixReply || reply;
|
||
thinkEl.textContent = reply;
|
||
}
|
||
} catch(fixErr) {
|
||
if (fixErr.name !== 'AbortError') console.error('[codeAiFix] failed:', fixErr);
|
||
}
|
||
// Remove the injected retry turn from history (will be replaced below)
|
||
if (codeAiHistory[codeAiHistory.length - 1]?.role === 'user') codeAiHistory.pop();
|
||
if (codeAiHistory[codeAiHistory.length - 1]?.role === 'assistant') codeAiHistory.pop();
|
||
}
|
||
}
|
||
|
||
codeAiHistory.push({ role: 'assistant', content: _histEntry(reply) || '(응답 없음)' });
|
||
_autoSaveChatSession();
|
||
if (activeProjectName) saveProjectSession(activeProjectName);
|
||
try { updateSessionPanel(); } catch (_) {}
|
||
thinkEl.classList.remove('streaming');
|
||
if (_firstToolSeen) {
|
||
thinkEl.remove();
|
||
_hideLogBubble();
|
||
if (liveTextEl) { liveTextEl.classList.remove('streaming'); codeAiRenderMarkdown(liveTextEl, segReply); }
|
||
const doneEl = codeAiAppend('system', '✅ 완료');
|
||
doneEl.style.cssText = 'font-weight:600;color:#4ade80;';
|
||
} else {
|
||
_hideLogBubble();
|
||
thinkEl.className = 'code-ai-msg assistant';
|
||
if (!reply) { thinkEl.textContent = '(응답 없음)'; thinkEl.style.display = ''; }
|
||
else { codeAiRenderMarkdown(thinkEl, reply); thinkEl.style.display = ''; }
|
||
}
|
||
} catch(e) {
|
||
_hideLogBubble();
|
||
if (!thinkEl.isConnected) { const msgs = document.getElementById('code-ai-msgs'); if (msgs) msgs.appendChild(thinkEl); }
|
||
thinkEl.style.display = '';
|
||
thinkEl.className = 'code-ai-msg thinking';
|
||
const errMsg = (e.name === 'AbortError' || (e.message || '').includes('aborted'))
|
||
? '요청이 취소되었습니다. 다시 시도해주세요.'
|
||
: (e.message || e);
|
||
thinkEl.textContent = '오류: ' + errMsg;
|
||
// On error no diff will ever be queued, so clear the auto-accept flag —
|
||
// otherwise it leaks into the next turn and fires a stale "✅ 완료".
|
||
_autoAcceptTurn = false;
|
||
} finally {
|
||
_codeAiAutoAccept = false;
|
||
}
|
||
}
|
||
|
||
// ── Live server log bubble ────────────────────────────────────────────────
|
||
let _logBubble = null;
|
||
let _logPollTimer = null;
|
||
|
||
function _showLogBubble() {
|
||
if (_logBubble) return;
|
||
const msgs = document.getElementById('code-ai-messages');
|
||
if (!msgs) return;
|
||
_logBubble = document.createElement('div');
|
||
_logBubble.className = 'code-ai-log-bubble';
|
||
_logBubble.innerHTML = '<span class="log-live-dot"></span><span class="log-lines">서버 연결 중...</span>';
|
||
msgs.appendChild(_logBubble);
|
||
codeAiScrollBottom();
|
||
_pollLogBubble();
|
||
_logPollTimer = setInterval(_pollLogBubble, 800);
|
||
}
|
||
|
||
async function _pollLogBubble() {
|
||
if (!_logBubble) return;
|
||
try {
|
||
const r = await fetch('/api/logs', { headers: authHeaders({}) });
|
||
if (!r.ok) return;
|
||
const data = await r.json();
|
||
const lines = (data.entries || []).slice(-4).map(e => e.msg.slice(0, 90));
|
||
const linesEl = _logBubble.querySelector('.log-lines');
|
||
if (linesEl) linesEl.textContent = lines.join('\n') || '...';
|
||
codeAiScrollBottom();
|
||
} catch {}
|
||
}
|
||
|
||
function _hideLogBubble() {
|
||
if (_logPollTimer) { clearInterval(_logPollTimer); _logPollTimer = null; }
|
||
const b = _logBubble; _logBubble = null;
|
||
if (b) { b.style.opacity = '0'; setTimeout(() => b.remove(), 400); }
|
||
}
|
||
|
||
// ── Tool action cards for Cline-style visibility ──────────────────────────
|
||
const _TOOL_ICONS = {
|
||
read_file: '📖', list_files: '📂', search_files: '🔍',
|
||
edit_file: '✏️', create_file: '📄', replace_lines: '✏️',
|
||
insert_after: '➕', delete_lines: '🗑️', find_replace: '🔄',
|
||
delete_file: '🗑️', move_file: '📦', shell: '▶️', python_eval: '🐍', web_search: '🌐',
|
||
};
|
||
const _TOOL_COLORS = {
|
||
read_file: '#3b82f6', list_files: '#3b82f6', search_files: '#3b82f6',
|
||
edit_file: '#22c55e', create_file: '#22c55e', replace_lines: '#22c55e',
|
||
insert_after: '#22c55e', delete_lines: '#f87171', find_replace: '#22c55e',
|
||
delete_file: '#f87171', move_file: '#f59e0b', shell: '#f59e0b', python_eval: '#f59e0b', web_search: '#8b5cf6',
|
||
};
|
||
const _FILE_TOOLS = new Set(['read_file', 'create_file', 'edit_file', 'replace_lines', 'insert_after', 'delete_lines', 'find_replace', 'delete_file', 'move_file']);
|
||
|
||
function _codeAiToolAction(toolName, toolArgs, toolResult, toolError) {
|
||
const msgs = document.getElementById('code-ai-messages');
|
||
if (!msgs) return;
|
||
const icon = _TOOL_ICONS[toolName] || '🔧';
|
||
const color = _TOOL_COLORS[toolName] || '#6b7280';
|
||
|
||
if (toolArgs !== null) {
|
||
// Tool call event — show action card
|
||
const card = document.createElement('div');
|
||
card.className = 'code-ai-tool-card';
|
||
card.dataset.tool = toolName;
|
||
const fname = toolArgs.filename || toolArgs.name || '';
|
||
const shortName = fname.replace(/^code\//, '').split('/').pop() || '';
|
||
const isFile = _FILE_TOOLS.has(toolName);
|
||
const detail = toolName === 'move_file'
|
||
? escHtml(`${toolArgs.source || ''} → ${toolArgs.destination || ''}`)
|
||
: isFile ? escHtml(shortName || fname) : (toolArgs.command || toolArgs.query || toolArgs.code || '').slice(0, 60);
|
||
card.innerHTML = `<span class="code-ai-tool-icon" style="color:${color}">${icon}</span> <span class="code-ai-tool-name">${escHtml(toolName)}</span> <span class="code-ai-tool-detail" style="color:var(--muted);font-size:11px">${detail}</span>`;
|
||
msgs.appendChild(card);
|
||
codeAiScrollBottom();
|
||
} else if (toolResult !== null) {
|
||
// Tool result event — update last matching card or append result
|
||
const cards = msgs.querySelectorAll(`.code-ai-tool-card[data-tool="${toolName}"]`);
|
||
const card = cards[cards.length - 1];
|
||
if (card && !card.dataset.done) {
|
||
card.dataset.done = '1';
|
||
const resultIcon = toolError ? '❌' : '✅';
|
||
const noDetail = !toolError && (toolName === 'move_file' || toolName === 'delete_file');
|
||
const resultText = toolError ? escHtml((toolResult || 'Error').slice(0, 120)) : noDetail ? '' : escHtml((toolResult || '').slice(0, 80));
|
||
const resultEl = document.createElement('span');
|
||
resultEl.className = 'code-ai-tool-result';
|
||
resultEl.innerHTML = resultText ? `${resultIcon} ${resultText}` : resultIcon;
|
||
card.appendChild(resultEl);
|
||
if (toolError) card.style.borderLeftColor = '#f87171';
|
||
}
|
||
codeAiScrollBottom();
|
||
}
|
||
}
|
||
|
||
// ── Client-side file intercept: apply tool changes to local folder ──────────
|
||
// When the AI uses file-modifying tools, we also apply them locally so
|
||
// the user's local folder stays in sync with the editor.
|
||
// Applies a model tool_call to the local folder / editor. The server short-circuits
|
||
// file tools for local sessions (synthetic success), so the CLIENT is the source of
|
||
// truth — meaning a silent failure here would let the model believe an edit landed
|
||
// when nothing changed. Returns { ok, reason } so the caller can warn the user.
|
||
async function _codeAiApplyToolLocally(toolName, args) {
|
||
if (!args) return { ok: true };
|
||
// Normalise to bare filename: strip code/ prefix, then strip client project prefix.
|
||
// Model always sends bare names; _clientProjectPath resolves to the disk path.
|
||
let rawFname = (args.filename || args.name || '').replace(/^code\//, '');
|
||
// Auto-inject filename from active tab when model omits it (happens ~40% of the time)
|
||
if (!rawFname) {
|
||
const activeFile = codeFiles.find(f => f.id === activeCodeFileId);
|
||
if (activeFile) rawFname = _clientBareFileName(activeFile.name);
|
||
}
|
||
const fname = _clientBareFileName(rawFname);
|
||
const diskPath = _clientProjectPath(fname);
|
||
const _FILE_TOOLS = new Set(['create_file', 'edit_file', 'replace_lines', 'insert_after', 'delete_lines', 'find_replace', 'delete_file', 'move_file']);
|
||
if (!_FILE_TOOLS.has(toolName)) return { ok: true };
|
||
if (!fname && toolName !== 'move_file') return { ok: false, reason: '파일명이 없습니다' };
|
||
|
||
try {
|
||
switch (toolName) {
|
||
case 'create_file': {
|
||
const content = args.content || '';
|
||
if (localDirHandle) await localWriteFile(diskPath, content);
|
||
codeEditorWriteFile(fname, content);
|
||
return { ok: true };
|
||
}
|
||
case 'edit_file': {
|
||
// Read current content from editor or client-local disk
|
||
const existing = await _codeAiReadLocalFile(fname);
|
||
if (existing === null) return { ok: false, reason: `${fname}을(를) 로컬에서 찾을 수 없습니다` };
|
||
const newContent = _applySearchReplace(existing, args.edits || '');
|
||
if (newContent === null) return { ok: false, reason: `${fname}: SEARCH 블록이 파일 내용과 일치하지 않습니다` };
|
||
if (localDirHandle) await localWriteFile(diskPath, newContent);
|
||
codeEditorWriteFile(fname, newContent);
|
||
return { ok: true };
|
||
}
|
||
case 'replace_lines': {
|
||
const startLine = parseInt(args.start_line) || 1;
|
||
const endLine = parseInt(args.end_line) || startLine;
|
||
const newContent = args.new_content || '';
|
||
const existing = await _codeAiReadLocalFile(fname);
|
||
if (existing === null) return { ok: false, reason: `${fname}을(를) 로컬에서 찾을 수 없습니다` };
|
||
const lines = existing.split('\n');
|
||
const before = lines.slice(0, startLine - 1).join('\n');
|
||
const after = lines.slice(endLine).join('\n');
|
||
const result = endLine < lines.length ? before + '\n' + newContent + '\n' + after : before + '\n' + newContent;
|
||
if (localDirHandle) await localWriteFile(diskPath, result);
|
||
codeEditorWriteFile(fname, result);
|
||
return { ok: true };
|
||
}
|
||
case 'insert_after': {
|
||
const afterLine = parseInt(args.after_line) || 0;
|
||
const content = args.content || '';
|
||
const existing = await _codeAiReadLocalFile(fname);
|
||
if (existing === null) return { ok: false, reason: `${fname}을(를) 로컬에서 찾을 수 없습니다` };
|
||
const lines = existing.split('\n');
|
||
const insertIdx = afterLine >= 0 ? afterLine : 0;
|
||
lines.splice(insertIdx, 0, ...content.split('\n'));
|
||
const result = lines.join('\n');
|
||
if (localDirHandle) await localWriteFile(diskPath, result);
|
||
codeEditorWriteFile(fname, result);
|
||
return { ok: true };
|
||
}
|
||
case 'delete_lines': {
|
||
const startLine = parseInt(args.start_line) || 1;
|
||
const endLine = parseInt(args.end_line) || startLine;
|
||
const existing = await _codeAiReadLocalFile(fname);
|
||
if (existing === null) return { ok: false, reason: `${fname}을(를) 로컬에서 찾을 수 없습니다` };
|
||
const lines = existing.split('\n');
|
||
lines.splice(startLine - 1, endLine - startLine + 1);
|
||
const result = lines.join('\n');
|
||
if (localDirHandle) await localWriteFile(diskPath, result);
|
||
codeEditorWriteFile(fname, result);
|
||
return { ok: true };
|
||
}
|
||
case 'find_replace': {
|
||
const find = args.find || '';
|
||
const replace = args.replace || '';
|
||
const existing = await _codeAiReadLocalFile(fname);
|
||
if (existing === null) return { ok: false, reason: `${fname}을(를) 로컬에서 찾을 수 없습니다` };
|
||
if (!existing.includes(find)) return { ok: false, reason: `${fname}: "${find.slice(0, 30)}…" 텍스트를 찾을 수 없습니다` };
|
||
const result = existing.replace(find, replace);
|
||
if (localDirHandle) await localWriteFile(diskPath, result);
|
||
codeEditorWriteFile(fname, result);
|
||
return { ok: true };
|
||
}
|
||
case 'delete_file': {
|
||
if (localDirHandle) await localDeleteFile(diskPath);
|
||
const fileId = codeFiles.find(f => f.name === fname)?.id;
|
||
if (fileId) { codeFiles.splice(codeFiles.findIndex(f => f.id === fileId), 1); renderCodeFileTabs(); }
|
||
return { ok: true };
|
||
}
|
||
case 'move_file': {
|
||
const srcRaw = _clientBareFileName((args.source || args.from || '').replace(/^code\//, ''));
|
||
const dstRaw = _clientBareFileName((args.destination || args.to || '').replace(/^code\//, ''));
|
||
const srcDisk = _clientProjectPath(srcRaw);
|
||
const dstDisk = _clientProjectPath(dstRaw);
|
||
if (!srcRaw || !dstRaw) return { ok: false, reason: 'source/destination가 없습니다' };
|
||
const content = await _codeAiReadLocalFile(srcRaw);
|
||
if (content === null) return { ok: false, reason: `${srcRaw}을(를) 찾을 수 없습니다 (탭에 파일이 열려있어야 합니다)` };
|
||
if (localDirHandle) {
|
||
const writeOk = await localWriteFile(dstDisk, content);
|
||
if (!writeOk) return { ok: false, reason: `${dstRaw} 쓰기 실패 (폴더 권한 확인)` };
|
||
}
|
||
codeEditorWriteFile(dstRaw, content);
|
||
if (localDirHandle) {
|
||
const delOk = await localDeleteFile(srcDisk);
|
||
if (!delOk) console.warn('[move_file] 소스 파일 삭제 실패:', srcDisk);
|
||
}
|
||
const srcId = codeFiles.find(f => f.name === srcRaw)?.id;
|
||
if (srcId) { codeFiles.splice(codeFiles.findIndex(f => f.id === srcId), 1); renderCodeFileTabs(); }
|
||
// Refresh sidebar after disk I/O is complete
|
||
codeSbRefresh();
|
||
return { ok: true };
|
||
}
|
||
}
|
||
return { ok: true };
|
||
} catch (e) {
|
||
console.warn('[codeAiApplyToolLocally] error:', e);
|
||
return { ok: false, reason: (e && e.message) || String(e) };
|
||
}
|
||
}
|
||
|
||
// Read a file from editor tabs (preferred) or local folder
|
||
async function _codeAiReadLocalFile(fname) {
|
||
// Normalise: strip code/ prefix, then strip project prefix → bare name
|
||
const bare = _clientBareFileName(fname.replace(/^code\//, ''));
|
||
// Try editor tabs first (most up-to-date); tabs store bare names in client-local mode
|
||
const tab = codeFiles.find(f => f.name === bare || f.name === fname || f.name.endsWith('/' + bare));
|
||
if (tab) return tab.model ? tab.model.getValue() : tab.content;
|
||
// Try client-local disk (scoped to project subfolder when applicable)
|
||
if (localDirHandle) {
|
||
const content = await localReadFile(_clientProjectPath(bare));
|
||
if (content !== null) return content;
|
||
// Fallback: bare path (covers root-level files when no activeProjectName)
|
||
if (_clientProjectPath(bare) !== bare) {
|
||
const c2 = await localReadFile(bare);
|
||
if (c2 !== null) return c2;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Strip "123: " line-number prefixes the model copies from context (e.g. "42: def foo():")
|
||
function _stripLineNumbers(text) {
|
||
// Only strip if EVERY non-empty line starts with a number prefix — avoids stripping real code
|
||
const lines = text.split('\n');
|
||
if (lines.every(l => !l.trim() || /^\d+: /.test(l))) {
|
||
return lines.map(l => l.replace(/^\d+: /, '')).join('\n');
|
||
}
|
||
return text;
|
||
}
|
||
|
||
// Apply SEARCH/REPLACE blocks to content (client-side mirror of server edit_file)
|
||
function _applySearchReplace(content, edits) {
|
||
if (!edits || !content) return null;
|
||
// Normalize line endings in both content and edits (\r\n / \r → \n)
|
||
const normNl = s => s.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||
content = normNl(content);
|
||
edits = normNl(edits);
|
||
|
||
const srRegex = /------- SEARCH\n([\s\S]*?)\n=======\n([\s\S]*?)\n\+\+\+\+\+\+\+ REPLACE/g;
|
||
let match;
|
||
let result = content;
|
||
let editCount = 0;
|
||
while ((match = srRegex.exec(edits)) !== null) {
|
||
// Strip line-number prefixes the model might have copied from numbered context
|
||
const searchText = _stripLineNumbers(match[1]);
|
||
const replaceText = _stripLineNumbers(match[2]);
|
||
if (result.includes(searchText)) {
|
||
result = result.replace(searchText, replaceText);
|
||
editCount++;
|
||
} else {
|
||
// Fallback 1: trim trailing whitespace per line
|
||
const trimEnd = s => s.split('\n').map(l => l.trimEnd()).join('\n');
|
||
const trimmedResult = trimEnd(result);
|
||
const trimmedSearch = trimEnd(searchText);
|
||
if (trimmedResult.includes(trimmedSearch)) {
|
||
const lines = result.split('\n');
|
||
const searchLines = searchText.split('\n');
|
||
const normLines = lines.map(l => l.trimEnd());
|
||
const normSearch = searchLines.map(l => l.trimEnd());
|
||
for (let i = 0; i <= normLines.length - normSearch.length; i++) {
|
||
if (normLines.slice(i, i + normSearch.length).join('\n') === normSearch.join('\n')) {
|
||
const originalText = lines.slice(i, i + searchLines.length).join('\n');
|
||
result = result.replace(originalText, replaceText);
|
||
editCount++;
|
||
break;
|
||
}
|
||
}
|
||
} else {
|
||
// Fallback 2: ignore leading indentation (model sometimes guesses wrong indent)
|
||
const stripIndent = s => s.split('\n').map(l => l.trimStart()).join('\n');
|
||
const stripped = stripIndent(result);
|
||
const strippedSearch = stripIndent(searchText);
|
||
if (stripped.includes(strippedSearch)) {
|
||
const lines = result.split('\n');
|
||
const searchLines = searchText.split('\n');
|
||
const normLines = lines.map(l => l.trimStart());
|
||
const normSearch = searchLines.map(l => l.trimStart());
|
||
for (let i = 0; i <= normLines.length - normSearch.length; i++) {
|
||
if (normLines.slice(i, i + normSearch.length).join('\n') === normSearch.join('\n')) {
|
||
// Detect the actual indentation from the first matching line and re-indent replaceText
|
||
const actualIndent = lines[i].match(/^(\s*)/)?.[1] || '';
|
||
const searchIndent = searchLines[0].match(/^(\s*)/)?.[1] || '';
|
||
const reindented = replaceText.split('\n').map(l => {
|
||
const trimmed = l.startsWith(searchIndent) ? l.slice(searchIndent.length) : l.trimStart();
|
||
return actualIndent + trimmed;
|
||
}).join('\n');
|
||
const originalText = lines.slice(i, i + searchLines.length).join('\n');
|
||
result = result.replace(originalText, reindented);
|
||
editCount++;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return editCount > 0 ? result : null;
|
||
}
|
||
|
||
function codeAiAppend(role, text) {
|
||
const msgs = document.getElementById('code-ai-messages');
|
||
if (!msgs) return null;
|
||
const placeholder = msgs.querySelector('.code-ai-msg.thinking');
|
||
if (placeholder && placeholder.textContent.includes('AI에게 전송')) placeholder.remove();
|
||
const el = document.createElement('div');
|
||
el.className = 'code-ai-msg ' + role;
|
||
if (role === 'thinking') el.textContent = text;
|
||
else el.textContent = text;
|
||
// Add streaming class during live token output
|
||
if (role === 'thinking' || role === 'assistant') el.classList.add('streaming');
|
||
msgs.appendChild(el);
|
||
codeAiScrollBottom();
|
||
return el;
|
||
}
|
||
|
||
// ── Inline markdown rendering (bold, italic, inline code, links) ──────────
|
||
function _renderInlineMarkdown(text) {
|
||
// Escape HTML first
|
||
let html = escHtml(text);
|
||
// **bold**
|
||
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||
// *italic*
|
||
html = html.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '<em>$1</em>');
|
||
// `inline code`
|
||
html = html.replace(/`([^`]+)`/g, '<code style="background:var(--panel-2);padding:1px 4px;border-radius:3px;font-family:IBM Plex Mono,monospace;font-size:11px;">$1</code>');
|
||
// [link](url)
|
||
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener" style="color:var(--brand);">$1</a>');
|
||
// Line breaks
|
||
html = html.replace(/\n/g, '<br>');
|
||
return html;
|
||
}
|
||
|
||
function _isDiffCode(lang, code) {
|
||
if (lang === 'diff') return true;
|
||
if (['text', 'sh', 'bash', 'shell', 'console'].includes(lang)) return false;
|
||
const lines = code.split('\n');
|
||
if (!lines.some(l => l.trim())) return false;
|
||
// An @@ hunk header is an unambiguous diff signal.
|
||
if (lines.some(l => l.startsWith('@@'))) return true;
|
||
// Count single-char +/- markers. The old test required a space after the
|
||
// marker (/^[+\-] /), which BOTH missed standard `+code`/`-code` diffs (no
|
||
// space → markers leaked into the file as literal text) AND misfired on
|
||
// markdown "- item" lists. Exclude +++/--- file headers and ++/-- runs.
|
||
const plus = lines.filter(l => /^\+(?!\+\+)/.test(l)).length;
|
||
const minus = lines.filter(l => /^-(?!--)/.test(l)).length;
|
||
if (plus === 0 && minus === 0) return false;
|
||
// A two-sided change (both adds and removes) is unambiguously a diff.
|
||
if (plus > 0 && minus > 0) return true;
|
||
// Single-sided: only a diff if accompanied by real context lines. A diff
|
||
// context line is " <code>" — a marker space then content that is NOT itself a
|
||
// bullet ("- "/"* "/"+ "). This separates a unified-diff body from a plain
|
||
// YAML/markdown list, whose items are bullets at any indent.
|
||
const strongContext = lines.filter(l =>
|
||
/^ /.test(l) && l.trim() && !/^[-*+]\s/.test(l.trim())).length;
|
||
if (strongContext > 0) return true;
|
||
// Note: we do NOT use filename comments as a diff indicator here.
|
||
// `# filename:` comments appear in both diff blocks AND new-file blocks,
|
||
// so using them to detect diffs causes false positives that break multi-file
|
||
// extraction (new files get misclassified as diffs and silently dropped).
|
||
return false;
|
||
}
|
||
|
||
// Render AI message with markdown code blocks parsed out
|
||
// Uses the same line-start fence detection as codeLiveStreamUpdate to avoid
|
||
// false matches on ``` inside code (comments, strings, etc.)
|
||
function codeAiRenderMarkdown(el, text) {
|
||
el.innerHTML = '';
|
||
// Detect and render plan lines (📋 or numbered steps like "1) ... -> 2) ...")
|
||
// before the code blocks
|
||
const planRe = /^[📋🔹▸▶→➤]\s*.*$/m;
|
||
const numberedPlanRe = /^\d+[).]\s+.*(?:\s*[-→>]\s*\d+[).]\s+.*)+/m;
|
||
const isPlan = planRe.test(text) || numberedPlanRe.test(text);
|
||
if (isPlan) {
|
||
// Extract plan lines and render them in a plan box
|
||
const lines = text.split('\n');
|
||
const planLines = [];
|
||
let inPlan = false;
|
||
for (const line of lines) {
|
||
if (/^[📋🔹▸▶→➤]\s*/.test(line) || /^\d+[).]\s+/.test(line)) {
|
||
inPlan = true;
|
||
planLines.push(line);
|
||
} else if (inPlan && line.trim() === '') {
|
||
break;
|
||
} else if (inPlan) {
|
||
planLines.push(line);
|
||
}
|
||
}
|
||
if (planLines.length > 0) {
|
||
const planDiv = document.createElement('div');
|
||
planDiv.className = 'code-ai-plan';
|
||
planDiv.innerHTML = '<strong>📋 계획</strong><br>' + planLines.map(l => escHtml(l)).join('<br>').replace(/[-→>]\s*(\d+[).]\s+)/g, '<br>$1');
|
||
el.appendChild(planDiv);
|
||
// Remove plan lines from text before further processing
|
||
const planEnd = text.indexOf('\n', text.indexOf(planLines[planLines.length - 1]) + planLines[planLines.length - 1].length);
|
||
if (planEnd > 0) text = text.slice(planEnd + 1);
|
||
}
|
||
}
|
||
// Parse code blocks using line-start toggle parser (same as streaming)
|
||
const fenceRe = /```([\w\-]*)[ \t]*\r?\n/g;
|
||
const parts = []; // alternating: text, {lang, code}, text, {lang, code}, ...
|
||
let lastEnd = 0, inBlock = false, blockLang = '', blockStart = 0, m;
|
||
while ((m = fenceRe.exec(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).
|
||
// Strip trailing closing fence that has no newline after it — the fence regex
|
||
// requires \n so a final ``` without newline isn't matched, leaving it in the code.
|
||
if (inBlock) {
|
||
const rawCode = text.slice(blockStart).replace(/\n`{3}[\w-]*[ \t]*$/, '\n');
|
||
parts.push({ type: 'code', lang: blockLang, code: rawCode });
|
||
lastEnd = text.length;
|
||
}
|
||
// Trailing text after last block
|
||
if (lastEnd < text.length) parts.push({ type: 'text', content: text.slice(lastEnd) });
|
||
|
||
let codeCount = 0;
|
||
const codeBlocks = [];
|
||
_DBG && console.log(`[codeAiRenderMarkdown] parts=${parts.length} types=${parts.map(p=>p.type).join(',')}`);
|
||
for (const part of parts) {
|
||
if (part.type === 'code') {
|
||
const lang = part.lang || 'text';
|
||
const code = part.code;
|
||
codeBlocks.push({ lang, code });
|
||
const isDiff = _isDiffCode(lang, code);
|
||
|
||
// Extract filename from '# filename: ...' comment if present
|
||
let diffFilename = null;
|
||
if (isDiff) {
|
||
const m = code.match(/^#\s*filename:\s*(.+)/m);
|
||
if (m) diffFilename = m[1].trim().split('/').pop();
|
||
}
|
||
|
||
const wrapper = document.createElement('div');
|
||
wrapper.className = 'code-ai-code-block';
|
||
const header = document.createElement('div');
|
||
header.className = 'code-ai-code-header';
|
||
const langLabel = document.createElement('span');
|
||
langLabel.textContent = isDiff ? ('diff' + (diffFilename ? ' ' + diffFilename : '')) : (lang || 'code');
|
||
header.appendChild(langLabel);
|
||
|
||
let contentEl;
|
||
if (isDiff) {
|
||
contentEl = document.createElement('div');
|
||
contentEl.className = 'diff-block';
|
||
for (const line of code.split('\n')) {
|
||
const span = document.createElement('span');
|
||
span.className = 'diff-line' + (
|
||
line.startsWith('+') ? ' diff-line-add' :
|
||
line.startsWith('-') ? ' diff-line-del' :
|
||
line.startsWith('@@') ? ' diff-line-hunk' :
|
||
(line.startsWith('---') || line.startsWith('+++') || line.startsWith('#')) ? ' diff-line-file' : ''
|
||
);
|
||
span.textContent = line;
|
||
contentEl.appendChild(span);
|
||
}
|
||
} else {
|
||
contentEl = document.createElement('pre');
|
||
contentEl.textContent = code;
|
||
}
|
||
|
||
wrapper.appendChild(header);
|
||
wrapper.appendChild(contentEl);
|
||
if (isDiff) {
|
||
// Auto-apply diff to editor; no button needed
|
||
setTimeout(() => codeAiApplyDiff(code), 0);
|
||
} else {
|
||
const openBtn = document.createElement('button');
|
||
openBtn.className = 'code-ai-open-btn';
|
||
openBtn.textContent = '📂 에디터에서 열기';
|
||
openBtn.onclick = () => codeAiOpenInEditor(code, lang);
|
||
wrapper.appendChild(openBtn);
|
||
}
|
||
el.appendChild(wrapper);
|
||
codeCount++;
|
||
} else {
|
||
const span = document.createElement('span');
|
||
span.style.whiteSpace = 'pre-wrap';
|
||
span.innerHTML = _renderInlineMarkdown(part.content || '');
|
||
el.appendChild(span);
|
||
}
|
||
}
|
||
// Code blocks were already streamed into the editor by codeLiveStreamUpdate,
|
||
// so no auto-open here — just render the chat UI. The "📂 에디터에서 열기"
|
||
// button above lets users manually re-open a block if needed.
|
||
}
|
||
|
||
const CODE_EXT_MAP = {
|
||
// JavaScript / Node
|
||
javascript:'js', js:'js', node:'js', nodejs:'js',
|
||
typescript:'ts', ts:'ts', jsx:'jsx', tsx:'tsx',
|
||
// Python
|
||
python:'py', py:'py', python3:'py',
|
||
// C family
|
||
c:'c', 'c++':'cpp', cpp:'cpp', 'c#':'cs', csharp:'cs', cs:'cs',
|
||
// JVM
|
||
java:'java', kotlin:'kt', kt:'kt', scala:'scala', groovy:'groovy',
|
||
// Systems / compiled
|
||
rust:'rs', rs:'rs', go:'go', golang:'go', zig:'zig',
|
||
// Web
|
||
html:'html', css:'css', scss:'scss', less:'less', vue:'vue', svelte:'svelte',
|
||
// Data / Config
|
||
json:'json', yaml:'yml', yml:'yml', toml:'toml', xml:'xml', csv:'csv',
|
||
// Shell
|
||
shell:'sh', bash:'sh', sh:'sh', zsh:'sh', fish:'fish', powershell:'ps1', ps1:'ps1',
|
||
// Database
|
||
sql:'sql', sqlite:'sql', postgresql:'sql', mysql:'sql',
|
||
// Mobile / Other
|
||
swift:'swift', dart:'dart', lua:'lua', perl:'pl', r:'r',
|
||
ruby:'rb', rb:'rb', php:'php', elixir:'ex', erlang:'erl',
|
||
haskell:'hs', ocaml:'ml', fsharp:'fs',
|
||
// Docs
|
||
markdown:'md', md:'md', tex:'tex', latex:'tex',
|
||
};
|
||
|
||
// Decide whether a markdown code block is a real file worth opening in the editor.
|
||
// Filters out shell run-instructions (e.g. ```python code/tetris.py```) and tiny snippets.
|
||
function codeIsOpenable(code, lang) {
|
||
const trimmed = (code || '').trim();
|
||
if (!trimmed) return false;
|
||
const lines = trimmed.split('\n').filter(l => l.trim());
|
||
const l = (lang || '').toLowerCase();
|
||
const isShellLang = ['', 'text', 'console', 'sh', 'bash', 'shell', 'zsh', 'powershell', 'ps1'].includes(l);
|
||
const cmdRe = /^\s*[$#>]?\s*(python3?|pip3?|npm|npx|node|yarn|pnpm|deno|bun|cd|ls|dir|cat|echo|curl|wget|git|chmod|mkdir|rm|cp|mv|source|sudo|export|set|run|\.\/)\b/i;
|
||
// Single line: skip shell langs and short command-looking lines
|
||
if (lines.length <= 1) {
|
||
if (isShellLang) return false;
|
||
if (cmdRe.test(trimmed)) return false;
|
||
if (trimmed.length < 40) return false;
|
||
}
|
||
// Short shell block where every line is a command → run instructions, not a file
|
||
if (isShellLang && lines.length <= 3 && lines.every(ln => cmdRe.test(ln))) return false;
|
||
return true;
|
||
}
|
||
|
||
// Derive a filename from an embedded `# filename:` hint, or infer from code content.
|
||
const GENERIC_NAMES = new Set(['init','main','app','index','test','run','start','setup','util','helper','config','handler','utils','helpers','main']);
|
||
function codeDeriveName(code, lang) {
|
||
const m = (code || '').match(/(?:#|\/\/|--|\/\*|<!--)\s*(?:filename|file)\s*[:=]\s*(\S+)/i)
|
||
|| (code || '').match(/^(?:#|\/\/)\s*(\w+\.\w+)/m);
|
||
if (m) return m[1].replace(/[*\/>]+$/, '');
|
||
// Infer filename from code content when no explicit hint
|
||
const l = (lang || '').toLowerCase();
|
||
const ext = CODE_EXT_MAP[l] || ((l && l.length <= 6) ? l : 'txt');
|
||
// Try multiple strategies to find a meaningful name
|
||
// 1. HTML <title> tag
|
||
const htmlTitleMatch = code.match(/<title>([^<]+)<\/title>/i);
|
||
// 2. Class/struct/interface name
|
||
const classMatch = code.match(/(?:class|struct|interface)\s+([A-Z]\w+)/);
|
||
// 3. First function name that's not too generic
|
||
const funcMatches = [...code.matchAll(/(?:def|function|func|fn)\s+([a-zA-Z_]\w*)/g)].map(m => m[1]);
|
||
const specificFunc = funcMatches.find(f => !GENERIC_NAMES.has(f.toLowerCase()));
|
||
// 4. Title/game/app comment
|
||
const titleMatch = code.match(/(?:title|게임|프로그램|app|application)[:\s]+["']?([a-zA-Z0-9_가-힣]+)["']?/i);
|
||
// 5. <h1> heading
|
||
const h1Match = code.match(/<h[12][^>]*>([^<]{2,30})<\/h[12]>/i);
|
||
const candidates = [
|
||
htmlTitleMatch && htmlTitleMatch[1].replace(/[가-힣]/g, '').trim(),
|
||
classMatch && classMatch[1],
|
||
specificFunc,
|
||
titleMatch && titleMatch[1].replace(/[가-힣]/g, ''),
|
||
h1Match && h1Match[1].replace(/[가-힣]/g, '').trim(),
|
||
].filter(Boolean);
|
||
// Pick the first specific (non-generic) name
|
||
for (const name of candidates) {
|
||
const cleaned = name.replace(/[^a-zA-Z0-9_]/g, '').toLowerCase();
|
||
if (cleaned.length >= 3 && cleaned.length <= 30 && !GENERIC_NAMES.has(cleaned)) {
|
||
return cleaned + '.' + ext;
|
||
}
|
||
}
|
||
// Fallback: use any candidate even if generic, or random
|
||
if (candidates.length > 0) {
|
||
const fallback = candidates[0].replace(/[^a-zA-Z0-9_]/g, '').toLowerCase();
|
||
if (fallback.length >= 2) return fallback + '.' + ext;
|
||
}
|
||
return `ai_${Math.random().toString(36).slice(2,7)}.${ext}`;
|
||
}
|
||
|
||
// Write an exact (filename, content) pair into the Monaco editor immediately,
|
||
// creating or updating the tab. Used to mirror the model's create_file in real time.
|
||
function codeEditorWriteFile(fname, content) {
|
||
if (!monacoReady || !monacoEditor) {
|
||
codePendingQueue.push({ fname, content, lang: codeDetectLang(fname) });
|
||
initCodeView();
|
||
return;
|
||
}
|
||
const existing = codeFiles.find(f => f.name === fname);
|
||
if (existing) {
|
||
const oldContent = existing.model ? existing.model.getValue() : existing.content;
|
||
if (oldContent !== content) {
|
||
if (_codeAiAutoAccept) {
|
||
// Auto-accept mode: show diff briefly with scroll preview, then auto-save
|
||
switchCodeFile(existing.id);
|
||
showCodeDiff(existing.id, oldContent, content);
|
||
_scrollAndAcceptDiff(existing.id);
|
||
} else {
|
||
switchCodeFile(existing.id);
|
||
showCodeDiff(existing.id, oldContent, content);
|
||
}
|
||
}
|
||
} else {
|
||
codeNewFile(fname, content);
|
||
}
|
||
}
|
||
|
||
function codeAiApplyDiff(diffCode) {
|
||
if (!monacoReady || !monacoEditor) return;
|
||
|
||
// Extract target filename from diff headers
|
||
let targetName = null;
|
||
const filenameM = diffCode.match(/^#\s*filename:\s*(.+)/m);
|
||
if (filenameM) targetName = filenameM[1].trim();
|
||
if (!targetName) {
|
||
const m = diffCode.match(/^diff --git a\/(\S+)/m) || diffCode.match(/^--- a\/(\S+)/m);
|
||
if (m && !m[1].startsWith('/dev/null')) targetName = m[1];
|
||
}
|
||
|
||
let targetFile = null;
|
||
if (targetName) {
|
||
const basename = targetName.split('/').pop();
|
||
targetFile = codeFiles.find(f => f.name === targetName) || codeFiles.find(f => f.name === basename);
|
||
if (!targetFile) return; // 에디터에 없으면 적용 안 함
|
||
} else {
|
||
targetFile = codeFiles.find(f => f.id === activeCodeFileId);
|
||
}
|
||
if (!targetFile) return;
|
||
|
||
// Skip if streaming already applied a diff for this file
|
||
if (codeDiffState[targetFile.id]) return;
|
||
|
||
const originalContent = targetFile.model ? targetFile.model.getValue() : (targetFile.content || '');
|
||
const newContent = applyUnifiedDiff(originalContent, diffCode);
|
||
if (originalContent === newContent) return;
|
||
|
||
switchCodeFile(targetFile.id);
|
||
showCodeDiff(targetFile.id, originalContent, newContent);
|
||
}
|
||
|
||
function codeAiOpenInEditor(code, lang) {
|
||
_DBG && console.log(`[codeAiOpenInEditor] lang=${lang} lines=${code?.split('\n').length} openable=${codeIsOpenable(code, lang)} monacoReady=${monacoReady}`);
|
||
if (!codeIsOpenable(code, lang)) { _DBG && console.log('[codeAiOpenInEditor] NOT openable, returning'); return; }
|
||
if (!monacoReady || !monacoEditor) {
|
||
const fname = codeDeriveName(code, lang);
|
||
_DBG && console.log(`[codeAiOpenInEditor] Monaco not ready, queueing fname=${fname}`);
|
||
codePendingQueue.push({ fname, content: code, lang });
|
||
initCodeView();
|
||
return;
|
||
}
|
||
const fname = codeDeriveName(code, lang);
|
||
const existing = codeFiles.find(f => f.name === fname);
|
||
if (existing) {
|
||
const oldContent = existing.model ? existing.model.getValue() : existing.content;
|
||
if (oldContent !== code && code.length > 0) {
|
||
// Content differs — show an inline diff for review (line/block level) rather
|
||
// than replacing wholesale, consistent with the streaming edit path.
|
||
switchCodeFile(existing.id);
|
||
showCodeDiff(existing.id, oldContent, code);
|
||
} else {
|
||
// Same content — just switch to the tab
|
||
switchCodeFile(existing.id);
|
||
}
|
||
} else {
|
||
codeNewFile(fname, code);
|
||
}
|
||
}
|
||
|
||
// ── Code Diff View (Inline decorations, VSCode Copilot style) ────────────
|
||
|
||
// Compute diff hunks between original and modified text using LCS algorithm.
|
||
// Returns an array of hunks: [{ oldStart, oldEnd, newStart, newEnd, type }]
|
||
function computeDiffHunks(originalText, modifiedText) {
|
||
const oldLines = (originalText || '').split('\n');
|
||
const newLines = (modifiedText || '').split('\n');
|
||
const m = oldLines.length, n = newLines.length;
|
||
|
||
// The LCS table below is O(m*n) memory. Only collapse to a single coarse,
|
||
// whole-file hunk when it would genuinely blow up — ~5000×5000 lines is still
|
||
// only ~50MB of Uint16, so normal large files keep granular line-level hunks.
|
||
// Beyond that we fall back rather than allocating gigabytes / freezing the tab.
|
||
if (m * n > 25_000_000) {
|
||
if (originalText === modifiedText) return [];
|
||
const type = m === 0 ? 'add' : n === 0 ? 'delete' : 'change';
|
||
return [{ oldStart: 1, oldEnd: m, newStart: 1, newEnd: n, type }];
|
||
}
|
||
|
||
// Build LCS table (full m×n; rows are Uint16Array to keep it compact)
|
||
const dp = Array.from({ length: m + 1 }, () => new Uint16Array(n + 1));
|
||
for (let i = m - 1; i >= 0; i--) {
|
||
for (let j = n - 1; j >= 0; j--) {
|
||
dp[i][j] = oldLines[i] === newLines[j] ? 1 + dp[i + 1][j + 1] : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||
}
|
||
}
|
||
|
||
// Walk back to find matching lines
|
||
const matchedOld = new Set(), matchedNew = new Set();
|
||
let oi = 0, ni = 0;
|
||
while (oi < m && ni < n) {
|
||
if (oldLines[oi] === newLines[ni]) { matchedOld.add(oi); matchedNew.add(ni); oi++; ni++; }
|
||
else if (dp[oi + 1][ni] >= dp[oi][ni + 1]) oi++;
|
||
else ni++;
|
||
}
|
||
|
||
// Group consecutive changes into hunks (merge hunks within 3 lines)
|
||
const hunks = [];
|
||
oi = 0; ni = 0;
|
||
while (oi < m || ni < n) {
|
||
// Skip matching lines
|
||
while (oi < m && ni < n && matchedOld.has(oi) && matchedNew.has(ni) && oldLines[oi] === newLines[ni]) { oi++; ni++; }
|
||
if (oi >= m && ni >= n) break;
|
||
const hOldStart = oi, hNewStart = ni;
|
||
let hOldEnd = oi, hNewEnd = ni;
|
||
// Collect changed lines, extending through nearby matches (within 3 lines)
|
||
while ((oi < m && !matchedOld.has(oi)) || (ni < n && !matchedNew.has(ni))) {
|
||
if (oi < m && !matchedOld.has(oi)) { hOldEnd = oi + 1; oi++; }
|
||
else if (ni < n && !matchedNew.has(ni)) { hNewEnd = ni + 1; ni++; }
|
||
else break;
|
||
// Extend through matching lines if next change is within 3 lines
|
||
let extended = true;
|
||
while (extended) {
|
||
extended = false;
|
||
let matchCount = 0;
|
||
while (oi < m && ni < n && matchedOld.has(oi) && matchedNew.has(ni) && oldLines[oi] === newLines[ni]) {
|
||
hOldEnd = oi + 1; hNewEnd = ni + 1; oi++; ni++; matchCount++;
|
||
// Check if next change is within 3 lines
|
||
let peekO = oi, peekN = ni, hasNearChange = false;
|
||
for (let k = 0; k < 3 && peekO < m && peekN < n; k++) {
|
||
if ((peekO < m && !matchedOld.has(peekO)) || (peekN < n && !matchedNew.has(peekN))) { hasNearChange = true; break; }
|
||
peekO++; peekN++;
|
||
}
|
||
if (hasNearChange) extended = true;
|
||
else if (matchCount >= 3) break;
|
||
}
|
||
}
|
||
}
|
||
if (hOldEnd > hOldStart || hNewEnd > hNewStart) {
|
||
const type = hOldEnd === hOldStart ? 'add' : hNewEnd === hNewStart ? 'delete' : 'change';
|
||
hunks.push({ oldStart: hOldStart + 1, oldEnd: hOldEnd, newStart: hNewStart + 1, newEnd: hNewEnd, type });
|
||
}
|
||
}
|
||
return hunks;
|
||
}
|
||
|
||
// Show inline diff decorations in the regular editor (VSCode Copilot style)
|
||
function showCodeDiff(fileId, originalText, modifiedText, onReject) {
|
||
if (!monacoReady || !window.monaco || !monacoEditor) return;
|
||
const file = codeFiles.find(f => f.id === fileId);
|
||
if (!file) return;
|
||
|
||
// If a pending (un-reviewed) diff already exists for this file, keep the user's
|
||
// original baseline so that a sequence of AI edits collapses into one review.
|
||
// e.g. edit_file called 3× → user sees one combined diff, accepts once, saves once.
|
||
const existingDs = codeDiffState[fileId];
|
||
if (existingDs) {
|
||
originalText = existingDs.originalText;
|
||
}
|
||
|
||
// Clean up any existing diff for this file
|
||
cleanupCodeDiff(fileId);
|
||
|
||
// Compute diff hunks
|
||
const hunks = computeDiffHunks(originalText, modifiedText);
|
||
if (!hunks.length) return; // No changes
|
||
|
||
// Store diff state. `hunks` is computed ONCE here and stays stable for the
|
||
// lifetime of the review — accept/reject only flip per-hunk decisions:
|
||
// resolved = hunks the user has decided on (accepted OR rejected)
|
||
// rejected = subset of resolved that was rejected (restore original lines)
|
||
// The editor content is rebuilt from these decisions (see _rebuildDiffContent),
|
||
// so an accepted hunk never reappears when a later hunk is rejected.
|
||
codeDiffState[fileId] = {
|
||
originalText, modifiedText, hunks,
|
||
decorationIds: [], viewZoneIds: [],
|
||
resolved: new Set(), rejected: new Set(), onReject: onReject || null
|
||
};
|
||
|
||
// Set model to modified text (the "new" version)
|
||
if (file.model) file.model.setValue(modifiedText);
|
||
else file.content = modifiedText;
|
||
|
||
// Switch to this file and apply decorations
|
||
switchCodeFile(fileId);
|
||
_applyInlineDiffDecorations(fileId);
|
||
_showDiffBar(fileId);
|
||
renderCodeFileTabs();
|
||
}
|
||
|
||
// Rebuild the editor content from the current accept/reject decisions.
|
||
// Walks the stable hunks in order: unchanged lines come straight from the
|
||
// original, rejected hunks restore the original lines, accepted/pending hunks
|
||
// use the modified lines. Returns the assembled text plus, for each still-pending
|
||
// hunk, where it lands in that text (so decorations can be placed accurately).
|
||
function _rebuildDiffContent(ds) {
|
||
const oldLines = ds.originalText.split('\n');
|
||
const newLines = ds.modifiedText.split('\n');
|
||
const out = [];
|
||
const pendingRanges = [];
|
||
let cursor = 0; // 0-based cursor into oldLines
|
||
const ordered = ds.hunks.map((h, i) => ({ h, i })).sort((a, b) => a.h.oldStart - b.h.oldStart);
|
||
for (const { h, i } of ordered) {
|
||
// Unchanged original lines before this hunk
|
||
while (cursor < h.oldStart - 1 && cursor < oldLines.length) out.push(oldLines[cursor++]);
|
||
if (ds.rejected.has(i)) {
|
||
// Rejected → keep the original lines
|
||
for (let l = h.oldStart - 1; l < h.oldEnd && l < oldLines.length; l++) out.push(oldLines[l]);
|
||
} else {
|
||
// Accepted or pending → use the modified lines
|
||
const phantomAfter = out.length; // rebuilt line number just above the new block
|
||
for (let l = h.newStart - 1; l < h.newEnd && l < newLines.length; l++) out.push(newLines[l]);
|
||
if (!ds.resolved.has(i)) {
|
||
pendingRanges.push({ hunkIndex: i, hunk: h, greenStart: phantomAfter + 1, greenEnd: out.length, phantomAfter });
|
||
}
|
||
}
|
||
cursor = h.oldEnd;
|
||
}
|
||
while (cursor < oldLines.length) out.push(oldLines[cursor++]);
|
||
return { content: out.join('\n'), pendingRanges };
|
||
}
|
||
|
||
// Apply inline diff decorations to the editor for a given file
|
||
function _applyInlineDiffDecorations(fileId) {
|
||
const ds = codeDiffState[fileId];
|
||
if (!ds || !monacoEditor) return;
|
||
const file = codeFiles.find(f => f.id === fileId);
|
||
if (!file || file.id !== activeCodeFileId) return;
|
||
|
||
// Remove old decorations, view zones, and widgets
|
||
_removeInlineDiffDecorations(fileId);
|
||
|
||
// Rebuild content reflecting current decisions, then decorate the pending hunks
|
||
// at their rebuilt positions.
|
||
const { content, pendingRanges } = _rebuildDiffContent(ds);
|
||
if (file.model) { if (file.model.getValue() !== content) file.model.setValue(content); }
|
||
else file.content = content;
|
||
|
||
const contentLines = content.split('\n');
|
||
const oldLines = ds.originalText.split('\n');
|
||
const decorations = [];
|
||
|
||
for (const pr of pendingRanges) {
|
||
// Green highlight for added/changed lines
|
||
for (let line = pr.greenStart; line <= pr.greenEnd && line <= contentLines.length; line++) {
|
||
decorations.push({
|
||
range: new monaco.Range(line, 1, line, 1),
|
||
options: {
|
||
className: 'inline-diff-added',
|
||
glyphMarginClassName: 'inline-diff-glyph-added',
|
||
isWholeLine: true,
|
||
minimap: { color: '#28a74580', position: monaco.editor.MinimapPosition.Inline },
|
||
overviewRuler: { color: '#28a74560', position: monaco.editor.OverviewRulerLane.Full },
|
||
}
|
||
});
|
||
}
|
||
|
||
// View zone for deleted lines (red "phantom" lines), placed directly ABOVE
|
||
// the green block (phantomAfter is the line number just before it).
|
||
if (pr.hunk.type === 'delete' || pr.hunk.type === 'change') {
|
||
const deletedLines = oldLines.slice(pr.hunk.oldStart - 1, pr.hunk.oldEnd);
|
||
if (deletedLines.length && monacoEditor.changeViewZones) {
|
||
monacoEditor.changeViewZones(ca => {
|
||
const div = document.createElement('div');
|
||
div.className = 'inline-diff-deleted-zone';
|
||
div.innerHTML = deletedLines.map(line =>
|
||
'<div class="inline-diff-deleted-line">' + escHtml(line) + '</div>'
|
||
).join('');
|
||
ds.viewZoneIds.push(ca.addZone({
|
||
afterLineNumber: pr.phantomAfter,
|
||
afterColumn: 1,
|
||
heightInLines: Math.max(deletedLines.length, 1),
|
||
domNode: div,
|
||
}));
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// Apply decorations
|
||
ds.decorationIds = monacoEditor.deltaDecorations([], decorations);
|
||
|
||
// Add hunk action button overlays (anchor to the green block, or the gap for pure deletes)
|
||
for (const pr of pendingRanges) {
|
||
const anchor = pr.greenStart <= pr.greenEnd ? pr.greenStart : pr.phantomAfter + 1;
|
||
const line = Math.min(Math.max(1, anchor), contentLines.length);
|
||
_addHunkActionsWidget(fileId, pr.hunkIndex, line);
|
||
}
|
||
|
||
// Update diff bar info
|
||
_updateDiffBarInfo(fileId);
|
||
|
||
// Scroll to first pending hunk
|
||
if (pendingRanges.length) {
|
||
const pr = pendingRanges[0];
|
||
const anchor = pr.greenStart <= pr.greenEnd ? pr.greenStart : pr.phantomAfter + 1;
|
||
monacoEditor.revealLineInCenter(Math.min(Math.max(1, anchor), contentLines.length));
|
||
}
|
||
}
|
||
|
||
// Remove all inline diff decorations, view zones, and widgets for a file
|
||
function _removeInlineDiffDecorations(fileId) {
|
||
const ds = codeDiffState[fileId];
|
||
if (!ds) return;
|
||
|
||
// Remove decorations
|
||
if (ds.decorationIds.length && monacoEditor) {
|
||
monacoEditor.deltaDecorations(ds.decorationIds, []);
|
||
ds.decorationIds = [];
|
||
}
|
||
|
||
// Remove view zones
|
||
if (ds.viewZoneIds.length && monacoEditor.changeViewZones) {
|
||
monacoEditor.changeViewZones(ca => {
|
||
for (const id of ds.viewZoneIds) ca.removeZone(id);
|
||
});
|
||
ds.viewZoneIds = [];
|
||
}
|
||
|
||
// Remove hunk action button overlays from DOM
|
||
document.querySelectorAll('.inline-diff-hunk-actions[data-file-id="' + fileId + '"]').forEach(el => el.remove());
|
||
|
||
// Dispose disposables
|
||
for (const key of Object.keys(ds)) {
|
||
if (key.startsWith('_hunkDisposables_')) {
|
||
const disposables = ds[key];
|
||
if (Array.isArray(disposables)) disposables.forEach(d => { try { d.dispose(); } catch {} });
|
||
}
|
||
}
|
||
}
|
||
|
||
// Add hunk action buttons as a positioned overlay
|
||
function _addHunkActionsWidget(fileId, hunkIndex, line) {
|
||
const ds = codeDiffState[fileId];
|
||
if (!ds || !monacoEditor) return;
|
||
|
||
const actions = document.createElement('div');
|
||
actions.className = 'inline-diff-hunk-actions';
|
||
actions.dataset.fileId = fileId;
|
||
actions.dataset.hunkIndex = hunkIndex;
|
||
// Per-hunk buttons accept/reject ALL hunks for the file (one-click UX)
|
||
actions.innerHTML = '<button class="inline-diff-accept-btn" onclick="_acceptAllDiff(\'' + fileId + '\')" title="모두 적용">✅</button>' +
|
||
'<button class="inline-diff-reject-btn" onclick="_rejectAllDiff(\'' + fileId + '\')" title="모두 취소">❌</button>';
|
||
|
||
const editorContainer = document.getElementById('code-editor-container');
|
||
if (editorContainer) editorContainer.appendChild(actions);
|
||
|
||
// Position the widget at the line
|
||
const updatePosition = () => {
|
||
if (!monacoEditor || !document.body.contains(actions)) return;
|
||
try {
|
||
const lineTop = monacoEditor.getTopForLineNumber(line);
|
||
const layoutInfo = monacoEditor.getLayoutInfo();
|
||
const contentLeft = layoutInfo.contentLeft;
|
||
const scrollTop = monacoEditor.getScrollTop();
|
||
actions.style.top = (lineTop - scrollTop) + 'px';
|
||
actions.style.left = contentLeft + 'px';
|
||
actions.style.transform = 'translateY(-100%)';
|
||
} catch {}
|
||
};
|
||
updatePosition();
|
||
|
||
const scrollDisp = monacoEditor.onDidScrollChange(updatePosition);
|
||
const layoutDisp = monacoEditor.onDidLayoutChange(updatePosition);
|
||
ds['_hunkDisposables_' + hunkIndex] = [scrollDisp, layoutDisp];
|
||
}
|
||
|
||
// Show the diff action bar at the top
|
||
function _showDiffBar(fileId) {
|
||
const bar = document.getElementById('code-diff-bar');
|
||
const label = document.getElementById('code-diff-label');
|
||
if (!bar || !label) return;
|
||
|
||
const pendingCount = Object.keys(codeDiffState).length;
|
||
const countNote = pendingCount > 1 ? ` (${pendingCount}개 파일 남음)` : '';
|
||
label.textContent = '🔀 AI 제안 변경 사항' + countNote;
|
||
const acceptBtn = document.getElementById('code-diff-accept');
|
||
const rejectBtn = document.getElementById('code-diff-reject');
|
||
// Capture fileId in the closure so clicking works regardless of activeCodeFileId
|
||
if (acceptBtn) {
|
||
acceptBtn.textContent = pendingCount > 1 ? '✅ 적용 →' : '✅ 모두 적용';
|
||
acceptBtn.onclick = () => {
|
||
const ds = codeDiffState[fileId];
|
||
if (!ds) return;
|
||
ds.hunks.forEach((_, i) => { ds.resolved.add(i); ds.rejected.delete(i); });
|
||
_applyInlineDiffDecorations(fileId);
|
||
finalizeInlineDiff(fileId, true);
|
||
};
|
||
}
|
||
if (rejectBtn) {
|
||
rejectBtn.textContent = '❌ 모두 취소';
|
||
rejectBtn.onclick = () => {
|
||
const ds = codeDiffState[fileId];
|
||
if (!ds) return;
|
||
ds.hunks.forEach((_, i) => { ds.resolved.add(i); ds.rejected.add(i); });
|
||
_applyInlineDiffDecorations(fileId);
|
||
if (ds.onReject) ds.onReject();
|
||
finalizeInlineDiff(fileId, true);
|
||
};
|
||
}
|
||
bar.style.display = 'flex';
|
||
|
||
// Editor stays visible (no DiffEditor overlay)
|
||
const editorEl = document.getElementById('code-editor-container');
|
||
if (editorEl) editorEl.style.display = '';
|
||
}
|
||
|
||
// Update the diff bar with hunk count info
|
||
function _updateDiffBarInfo(fileId) {
|
||
const ds = codeDiffState[fileId];
|
||
if (!ds) return;
|
||
const unresolved = ds.hunks.filter((_, i) => !ds.resolved.has(i)).length;
|
||
const infoEl = document.getElementById('code-diff-hunk-info');
|
||
if (infoEl) infoEl.textContent = unresolved > 0 ? (unresolved + '개 변경') : '모두 해결됨';
|
||
}
|
||
|
||
// Accept a single hunk — keep its modified lines. Decisions are just flags;
|
||
// the editor content is rebuilt from them, so accepting one hunk never disturbs
|
||
// the others.
|
||
function acceptHunk(fileId, hunkIndex) {
|
||
const ds = codeDiffState[fileId];
|
||
if (!ds || ds.resolved.has(hunkIndex)) return;
|
||
ds.resolved.add(hunkIndex);
|
||
ds.rejected.delete(hunkIndex);
|
||
|
||
_applyInlineDiffDecorations(fileId);
|
||
|
||
if (ds.resolved.size === ds.hunks.length) finalizeInlineDiff(fileId, true);
|
||
}
|
||
|
||
// Reject a single hunk — restore its original lines. Other hunks (including ones
|
||
// already accepted) keep their decisions because the rebuild is decision-driven.
|
||
function rejectHunk(fileId, hunkIndex) {
|
||
const ds = codeDiffState[fileId];
|
||
if (!ds || ds.resolved.has(hunkIndex)) return;
|
||
ds.resolved.add(hunkIndex);
|
||
ds.rejected.add(hunkIndex);
|
||
|
||
_applyInlineDiffDecorations(fileId);
|
||
|
||
if (ds.resolved.size === ds.hunks.length) finalizeInlineDiff(fileId, true);
|
||
}
|
||
|
||
// Auto-accept diff with scroll preview: reveal each changed hunk in sequence, then save.
|
||
async function _scrollAndAcceptDiff(fileId) {
|
||
const ds = codeDiffState[fileId];
|
||
if (!ds || !monacoEditor) { _acceptAllDiff(fileId); return; }
|
||
|
||
const hunks = ds.hunks;
|
||
if (!hunks.length) { _acceptAllDiff(fileId); return; }
|
||
|
||
// Show preview label in the diff bar
|
||
const label = document.getElementById('code-diff-label');
|
||
if (label) label.textContent = `⏩ 편집 미리보기 (${hunks.length}개 변경)...`;
|
||
const acceptBtn = document.getElementById('code-diff-accept');
|
||
if (acceptBtn) acceptBtn.style.display = 'none'; // hide during preview
|
||
|
||
// Time budget: 500ms per hunk, capped at 5 s total
|
||
const perHunk = Math.min(500, Math.max(150, 5000 / hunks.length));
|
||
|
||
for (let i = 0; i < hunks.length; i++) {
|
||
if (!codeDiffState[fileId]) return; // diff cancelled by user or replaced
|
||
const h = hunks[i];
|
||
if (label) label.textContent = `⏩ 편집 미리보기 (${i + 1}/${hunks.length})...`;
|
||
const targetLine = h.newStart || h.oldStart || 1;
|
||
try { monacoEditor.revealLineInCenter(targetLine, 0 /* Smooth */); } catch {}
|
||
await new Promise(r => setTimeout(r, perHunk));
|
||
}
|
||
|
||
if (!codeDiffState[fileId]) return;
|
||
|
||
// Brief pause at the last hunk before auto-saving
|
||
await new Promise(r => setTimeout(r, 200));
|
||
|
||
_acceptAllDiff(fileId);
|
||
}
|
||
|
||
// Accept all hunks for a specific file (called by both per-hunk widgets and the bar button)
|
||
function _acceptAllDiff(fileId) {
|
||
const ds = codeDiffState[fileId];
|
||
if (!ds) return;
|
||
ds.hunks.forEach((_, i) => { ds.resolved.add(i); ds.rejected.delete(i); });
|
||
_applyInlineDiffDecorations(fileId);
|
||
finalizeInlineDiff(fileId, true);
|
||
}
|
||
|
||
// Reject all hunks for a specific file
|
||
function _rejectAllDiff(fileId) {
|
||
const ds = codeDiffState[fileId];
|
||
if (!ds) return;
|
||
ds.hunks.forEach((_, i) => { ds.resolved.add(i); ds.rejected.add(i); });
|
||
_applyInlineDiffDecorations(fileId);
|
||
if (ds.onReject) ds.onReject();
|
||
finalizeInlineDiff(fileId, true);
|
||
}
|
||
|
||
// Fallback wrappers using activeCodeFileId (kept for HTML onclick= attributes)
|
||
function acceptAllHunks() {
|
||
const fileId = activeCodeFileId || Object.keys(codeDiffState)[0];
|
||
if (fileId) _acceptAllDiff(fileId);
|
||
}
|
||
function rejectAllHunks() {
|
||
const fileId = activeCodeFileId || Object.keys(codeDiffState)[0];
|
||
if (fileId) _rejectAllDiff(fileId);
|
||
}
|
||
|
||
// Show inline re-login form inside the AI chat when session expires (401)
|
||
function _codeAiShowRelogin() {
|
||
const id = 'relogin_' + Date.now();
|
||
const el = codeAiAppend('system', '');
|
||
el.innerHTML = `<div style="display:flex;flex-direction:column;gap:8px;padding:4px 0">
|
||
<span style="color:#f87">⚠️ 세션 만료 — 재로그인</span>
|
||
<input id="${id}_u" placeholder="아이디" style="background:#1e1e1e;border:1px solid #444;color:#eee;padding:6px 8px;border-radius:4px;font-size:13px" />
|
||
<input id="${id}_p" type="password" placeholder="비밀번호" style="background:#1e1e1e;border:1px solid #444;color:#eee;padding:6px 8px;border-radius:4px;font-size:13px" />
|
||
<button onclick="_codeAiDoRelogin('${id}')" style="background:#2d6a2d;color:#eee;border:none;padding:6px 12px;border-radius:4px;cursor:pointer;font-size:13px">로그인</button>
|
||
</div>`;
|
||
setTimeout(() => document.getElementById(id + '_u')?.focus(), 50);
|
||
}
|
||
async function _codeAiDoRelogin(id) {
|
||
const u = document.getElementById(id + '_u')?.value?.trim();
|
||
const p = document.getElementById(id + '_p')?.value;
|
||
if (!u || !p) return;
|
||
try {
|
||
const r = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: u, password: p }) });
|
||
const d = await r.json();
|
||
if (d.token) {
|
||
setAuthToken(d.token);
|
||
const el = document.getElementById(id + '_u')?.closest('.code-ai-msg');
|
||
if (el) el.remove();
|
||
codeAiAppend('system', '✅ 로그인 완료 — 다시 메시지를 보내세요');
|
||
} else {
|
||
codeAiAppend('system', '❌ 로그인 실패: ' + (d.error || '아이디/비밀번호 확인'));
|
||
}
|
||
} catch (e) { codeAiAppend('system', '❌ 로그인 오류: ' + e.message); }
|
||
}
|
||
|
||
// Persist a specific file to workspace/code/ on disk. Accepting a diff only
|
||
// updated the Monaco model in memory — without this the .py file kept the old
|
||
// code, so re-running showed no change ("diff가 안 됨").
|
||
async function codeSaveFileById(fileId, hunkCount) {
|
||
const file = codeFiles.find(f => f.id === fileId);
|
||
if (!file) return;
|
||
const content = (file.model ? file.model.getValue() : file.content) || '';
|
||
file.content = content;
|
||
if (!localDirHandle) {
|
||
codeAiAppend('system', `⚠️ 폴더 미선택 — ${file.name} 저장 생략. 🖥️ 버튼으로 로컬 폴더를 선택해주세요.`);
|
||
return;
|
||
}
|
||
const ok = await localWriteFile(_clientProjectPath(file.name), content);
|
||
const bareName = _clientBareFileName(file.name);
|
||
const countNote = hunkCount > 0 ? ` (${hunkCount}곳)` : '';
|
||
if (ok) { codeAiAppend('system', `💾 저장: ${bareName}${countNote}`); codeSbRefresh(); }
|
||
else codeAiAppend('system', `⚠️ 저장 실패: ${bareName}`);
|
||
}
|
||
|
||
// Finalize inline diff — clean up all decorations and state.
|
||
// `save` persists the accepted result to disk (true for accepts, false for rejects).
|
||
function finalizeInlineDiff(fileId, save) {
|
||
const ds = codeDiffState[fileId];
|
||
if (!ds) return;
|
||
|
||
const hunkCount = ds.hunks.length;
|
||
_removeInlineDiffDecorations(fileId);
|
||
delete codeDiffState[fileId];
|
||
|
||
// Auto-save accepted changes so the on-disk file matches the editor
|
||
if (save) codeSaveFileById(fileId, hunkCount);
|
||
|
||
renderCodeFileTabs();
|
||
|
||
// If more files have pending diffs, switch to next one automatically
|
||
const nextId = Object.keys(codeDiffState)[0];
|
||
if (nextId) {
|
||
switchCodeFile(nextId); // switchCodeFile calls _showDiffBar if codeDiffState[nextId] exists
|
||
return;
|
||
}
|
||
|
||
// No more pending diffs — show completion message if this was an auto-accept turn
|
||
if (save && _autoAcceptTurn) {
|
||
_autoAcceptTurn = false;
|
||
setTimeout(() => codeAiAppend('system', '✅ 완료'), 300);
|
||
}
|
||
|
||
// Hide bar and restore focus
|
||
const bar = document.getElementById('code-diff-bar');
|
||
if (bar) bar.style.display = 'none';
|
||
|
||
const file = codeFiles.find(f => f.id === fileId);
|
||
if (file && monacoEditor && file.id === activeCodeFileId) {
|
||
monacoEditor.setModel(file.model);
|
||
monacoEditor.focus();
|
||
}
|
||
}
|
||
|
||
function cleanupCodeDiff(fileId) {
|
||
finalizeInlineDiff(fileId);
|
||
}
|
||
|
||
// ── New file approval bar + queue (supports multiple files at once) ──────
|
||
let _fileApprovalQueue = []; // [{fileId, fileName}]
|
||
|
||
function _enqueueFileApproval(fileId, fileName) {
|
||
_fileApprovalQueue.push({ fileId, fileName });
|
||
if (_fileApprovalQueue.length === 1) _showNextApproval();
|
||
}
|
||
|
||
function _showNextApproval() {
|
||
if (!_fileApprovalQueue.length) return;
|
||
const { fileId, fileName } = _fileApprovalQueue[0];
|
||
_showNewFileApproval(fileId, fileName);
|
||
}
|
||
|
||
function _showNewFileApproval(fileId, fileName) {
|
||
const file = codeFiles.find(f => f.id === fileId);
|
||
if (!file) { _fileApprovalQueue.shift(); _showNextApproval(); return; }
|
||
// In local mode: auto-save directly, no approval popup needed
|
||
if (localDirHandle) {
|
||
const content = file.model ? file.model.getValue() : (file.content || '');
|
||
localWriteFile(_clientProjectPath(file.name), content).then(ok => {
|
||
if (ok) { codeSbRefresh(); codeAiAppend('system', `💾 저장: ${file.name}`); }
|
||
else codeAiAppend('system', `⚠️ 저장 실패: ${file.name}`);
|
||
});
|
||
_fileApprovalQueue.shift();
|
||
if (_fileApprovalQueue.length) setTimeout(_showNextApproval, 200);
|
||
return;
|
||
}
|
||
switchCodeFile(fileId);
|
||
|
||
const bar = document.getElementById('code-diff-bar');
|
||
const label = document.getElementById('code-diff-label');
|
||
if (!bar || !label) return;
|
||
|
||
const remaining = _fileApprovalQueue.length;
|
||
const queueNote = remaining > 1 ? ` (${remaining}개 중 1번째)` : '';
|
||
label.textContent = '📝 새 파일: ' + fileName + queueNote;
|
||
const acceptBtn = document.getElementById('code-diff-accept');
|
||
const rejectBtn = document.getElementById('code-diff-reject');
|
||
if (acceptBtn) {
|
||
acceptBtn.textContent = remaining > 1 ? '✅ 만들기 →' : '✅ 만들기';
|
||
acceptBtn.onclick = () => {
|
||
bar.style.display = 'none';
|
||
codeSaveFileById(fileId);
|
||
_fileApprovalQueue.shift();
|
||
if (_fileApprovalQueue.length) setTimeout(_showNextApproval, 200);
|
||
};
|
||
}
|
||
if (rejectBtn) {
|
||
rejectBtn.textContent = '❌ 취소';
|
||
rejectBtn.onclick = () => {
|
||
const idx = codeFiles.findIndex(f => f.id === fileId);
|
||
if (idx >= 0) {
|
||
if (codeFiles[idx].model) codeFiles[idx].model.dispose();
|
||
codeFiles.splice(idx, 1);
|
||
}
|
||
if (codeDiffState[fileId]) cleanupCodeDiff(fileId);
|
||
bar.style.display = 'none';
|
||
if (codeFiles.length > 0) switchCodeFile(codeFiles[0].id);
|
||
renderCodeFileTabs();
|
||
_fileApprovalQueue.shift();
|
||
if (_fileApprovalQueue.length) setTimeout(_showNextApproval, 200);
|
||
};
|
||
}
|
||
bar.style.display = 'flex';
|
||
}
|
||
|
||
// Extract blocks that have an explicit `# filename:` / `// filename:` comment
|
||
function _getExplicitFilename(code) {
|
||
const m = (code || '').match(/(?:#|\/\/|--|\/\*|<!--)\s*(?:filename|file)\s*[:=]\s*(\S+)/i);
|
||
return m ? m[1].replace(/[*\/>]+$/, '') : null;
|
||
}
|
||
|
||
function _extractExplicitFileBlocks(text) {
|
||
const fenceRe = /```([\w\-]*)[ \t]*\r?\n/g;
|
||
const result = [];
|
||
let inBlock = false, blockLang = '', blockStart = 0, m;
|
||
while ((m = fenceRe.exec(text)) !== null) {
|
||
const lineStart = m.index === 0 ? 0 : text.lastIndexOf('\n', m.index - 1) + 1;
|
||
if (text.slice(lineStart, m.index).trim() !== '') continue;
|
||
if (!inBlock) { blockLang = m[1] || 'text'; blockStart = m.index + m[0].length; inBlock = true; }
|
||
else {
|
||
const code = text.slice(blockStart, m.index);
|
||
const fname = _getExplicitFilename(code);
|
||
// Include BOTH diff and non-diff blocks with filenames.
|
||
// Previously we excluded diffs here, which caused new files to be lost
|
||
// when the AI outputs a diff (modify existing) + new-file blocks (create new).
|
||
if (fname) result.push({ fname, code, lang: blockLang, isDiff: _isDiffCode(blockLang, code) });
|
||
inBlock = false;
|
||
}
|
||
}
|
||
if (inBlock) {
|
||
const code = text.slice(blockStart);
|
||
const fname = _getExplicitFilename(code);
|
||
if (fname) result.push({ fname, code, lang: blockLang, isDiff: _isDiffCode(blockLang, code) });
|
||
}
|
||
return result;
|
||
}
|
||
function codeAiScrollBottom() {
|
||
const msgs = document.getElementById('code-ai-messages');
|
||
if (msgs) msgs.scrollTop = msgs.scrollHeight;
|
||
}
|
||
|
||
// ── Project session management ────────────────────────────────────────────
|
||
function saveProjectSession(projectName) {
|
||
if (!projectName) return;
|
||
localStorage.setItem('code_proj_session_' + projectName, codeAiSessionId);
|
||
try { localStorage.setItem('code_proj_history_' + projectName, JSON.stringify(codeAiHistory.slice(-40))); } catch {}
|
||
}
|
||
|
||
function _knownProjectsGet() {
|
||
try { return JSON.parse(localStorage.getItem('code_known_projects') || '[]'); } catch { return []; }
|
||
}
|
||
function _knownProjectsAdd(name) {
|
||
if (!name) return;
|
||
const list = _knownProjectsGet();
|
||
if (!list.includes(name)) { list.push(name); localStorage.setItem('code_known_projects', JSON.stringify(list)); }
|
||
}
|
||
function _knownProjectsRemove(name) {
|
||
if (!name) return;
|
||
const list = _knownProjectsGet().filter(p => p !== name);
|
||
localStorage.setItem('code_known_projects', JSON.stringify(list));
|
||
}
|
||
|
||
async function deleteActiveProject() {
|
||
if (!activeProjectName) return;
|
||
if (!confirm(`"${activeProjectName}" 프로젝트를 삭제할까요?\n(폴더와 파일이 모두 삭제됩니다)`)) return;
|
||
const name = activeProjectName;
|
||
// localStorage 정리
|
||
_knownProjectsRemove(name);
|
||
localStorage.removeItem('code_proj_session_' + name);
|
||
localStorage.removeItem('code_proj_history_' + name);
|
||
// 로컬 폴더 삭제
|
||
if (localDirHandle) {
|
||
try { await localDirHandle.removeEntry(name, { recursive: true }); } catch (e) { console.warn('[deleteProject]', e); }
|
||
}
|
||
// 탭 닫기 및 프로젝트 초기화
|
||
codeFiles.forEach(f => { try { f.model?.dispose(); } catch {} });
|
||
codeFiles.length = 0; activeCodeFileId = null; codeDiffState = {};
|
||
renderCodeFileTabs();
|
||
activeProjectName = null;
|
||
localStorage.setItem('code_active_project', '');
|
||
codeAiHistory = [];
|
||
_codeAiLastUsage = null;
|
||
codeAiSessionId = 'code_ai_' + Math.random().toString(36).slice(2, 10);
|
||
updateLocalModeUI();
|
||
updateSessionPanel();
|
||
codeSbRefresh();
|
||
_renderSessionsList();
|
||
}
|
||
|
||
function switchProject(name) {
|
||
_autoSaveChatSession(); // null 프로젝트 상태의 히스토리도 세션 목록에 보존
|
||
if (activeProjectName) saveProjectSession(activeProjectName);
|
||
activeProjectName = name || null;
|
||
localStorage.setItem('code_active_project', activeProjectName || '');
|
||
if (activeProjectName) {
|
||
_knownProjectsAdd(activeProjectName);
|
||
const savedId = localStorage.getItem('code_proj_session_' + activeProjectName);
|
||
const savedHist = localStorage.getItem('code_proj_history_' + activeProjectName);
|
||
codeAiSessionId = savedId || ('code_ai_' + Math.random().toString(36).slice(2, 10));
|
||
try { codeAiHistory = savedHist ? JSON.parse(savedHist) : []; } catch { codeAiHistory = []; }
|
||
saveProjectSession(activeProjectName);
|
||
} else {
|
||
codeAiSessionId = 'code_ai_' + Math.random().toString(36).slice(2, 10);
|
||
codeAiHistory = [];
|
||
}
|
||
updateLocalModeUI();
|
||
updateSessionPanel();
|
||
codeSbRefresh();
|
||
localShellIds.forEach(shellId => _companionCdProject(shellId));
|
||
// 채팅 패널 갱신: 히스토리가 있으면 그대로, 없으면 세션 목록
|
||
const _msgs = document.getElementById('code-ai-messages');
|
||
if (_msgs) {
|
||
_msgs.innerHTML = '';
|
||
if (codeAiHistory.length) {
|
||
codeAiHistory.forEach(m => codeAiAppend(m.role === 'user' ? 'user' : 'assistant', m.content));
|
||
} else {
|
||
_renderSessionsList();
|
||
}
|
||
}
|
||
// In client-local mode: close previous project's tabs and load new project files
|
||
if (localDirHandle) _clientLoadProjectFiles();
|
||
}
|
||
|
||
function csbRenderProjectSelector(projects) {
|
||
const sel = document.getElementById('csb-project-sel');
|
||
const row = document.getElementById('csb-project-row');
|
||
if (!sel || !row) return;
|
||
if (!projects.length) { row.style.display = 'none'; return; }
|
||
row.style.display = 'flex';
|
||
const options = '<option value="">(전체)</option>' +
|
||
projects.map(p => `<option value="${escHtml(p)}"${activeProjectName === p ? ' selected' : ''}>${escHtml(p)}</option>`).join('');
|
||
sel.innerHTML = options;
|
||
sel.value = activeProjectName || '';
|
||
// Also sync the session panel project selector
|
||
const sessSel = document.getElementById('csb-session-project-sel');
|
||
if (sessSel) {
|
||
sessSel.innerHTML = options;
|
||
sessSel.value = activeProjectName || '';
|
||
}
|
||
}
|
||
|
||
// ── Session list (Cline style) ─────────────────────────────────────────────
|
||
const _SESS_KEY = 'code_chat_sessions_v2';
|
||
const _SESS_MAX = 30;
|
||
|
||
function _loadSessions() {
|
||
try { return JSON.parse(localStorage.getItem(_SESS_KEY) || '[]'); } catch { return []; }
|
||
}
|
||
function _saveSessions(list) {
|
||
try { localStorage.setItem(_SESS_KEY, JSON.stringify(list)); } catch {}
|
||
}
|
||
|
||
function _autoSaveChatSession() {
|
||
if (!codeAiHistory.length) return;
|
||
const list = _loadSessions();
|
||
const firstUser = codeAiHistory.find(m => m.role === 'user')?.content || '';
|
||
const title = firstUser.replace(/^\[파일:[^\]]+\]\s*/, '').slice(0, 50) || '(제목 없음)';
|
||
const existing = list.findIndex(s => s.sessionId === codeAiSessionId);
|
||
const entry = {
|
||
id: existing >= 0 ? list[existing].id : ('sess_' + Math.random().toString(36).slice(2, 9)),
|
||
sessionId: codeAiSessionId,
|
||
title,
|
||
project: activeProjectName || '',
|
||
ts: Date.now(),
|
||
msgCount: codeAiHistory.length,
|
||
history: codeAiHistory.slice(-60),
|
||
};
|
||
if (existing >= 0) list[existing] = entry;
|
||
else list.unshift(entry);
|
||
if (list.length > _SESS_MAX) list.length = _SESS_MAX;
|
||
_saveSessions(list);
|
||
}
|
||
|
||
function _backToSessionList() {
|
||
_autoSaveChatSession();
|
||
codeAiHistory = [];
|
||
_codeAiLastUsage = null;
|
||
codeAiSessionId = 'code_ai_' + Math.random().toString(36).slice(2, 10);
|
||
if (activeProjectName) saveProjectSession(activeProjectName);
|
||
const msgs = document.getElementById('code-ai-messages');
|
||
if (msgs) msgs.innerHTML = '';
|
||
_renderSessionsList();
|
||
}
|
||
|
||
function _renderSessionsList() {
|
||
const msgs = document.getElementById('code-ai-messages');
|
||
if (!msgs) return;
|
||
const list = _loadSessions();
|
||
const proj = activeProjectName || '';
|
||
const filtered = proj ? list.filter(s => s.project === proj || !s.project) : list;
|
||
|
||
if (!filtered.length) {
|
||
msgs.innerHTML = '<div class="code-ai-msg thinking" style="text-align:center;padding:24px 8px;color:var(--muted)">저장된 세션이 없습니다.<br>아래에 질문을 입력하면 새 세션이 시작됩니다.</div>';
|
||
return;
|
||
}
|
||
|
||
const relTime = ts => {
|
||
const d = (Date.now() - ts) / 1000;
|
||
if (d < 60) return '방금';
|
||
if (d < 3600) return Math.floor(d/60) + '분 전';
|
||
if (d < 86400) return Math.floor(d/3600) + '시간 전';
|
||
return Math.floor(d/86400) + '일 전';
|
||
};
|
||
|
||
msgs.innerHTML = `
|
||
<div style="padding:8px 10px 4px;display:flex;justify-content:space-between;align-items:center">
|
||
<span style="font-size:10px;color:var(--muted);letter-spacing:.08em;text-transform:uppercase">최근 세션</span>
|
||
<button onclick="_deleteAllSessions(event)" style="font-size:10px;padding:1px 6px;border:1px solid #374151;background:none;color:#6b7280;border-radius:3px;cursor:pointer" onmouseover="this.style.borderColor='#f87171';this.style.color='#f87171'" onmouseout="this.style.borderColor='#374151';this.style.color='#6b7280'">전체 삭제</button>
|
||
</div>
|
||
<div style="padding:0 6px 6px;display:flex;flex-direction:column;gap:3px">
|
||
${filtered.map(s => `
|
||
<div style="display:flex;align-items:center;gap:0;border-radius:5px;overflow:hidden;border:1px solid var(--line);background:var(--panel-2);transition:border-color .15s"
|
||
onmouseover="this.style.borderColor='#3d6b4f'" onmouseout="this.style.borderColor='var(--line)'">
|
||
<div onclick="_resumeSession('${s.id}')" style="flex:1;min-width:0;padding:5px 8px;cursor:pointer;border-left:2px solid #22c55e">
|
||
<div style="display:flex;align-items:center;gap:5px;margin-bottom:2px">
|
||
${s.project ? `<span style="font-size:9px;padding:1px 5px;background:#0d2b1a;color:#22c55e;border-radius:3px;letter-spacing:.03em;flex-shrink:0">${escHtml(s.project)}</span>` : ''}
|
||
<span style="font-size:11px;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(s.title)}</span>
|
||
</div>
|
||
<span style="font-size:10px;color:var(--muted)">${s.msgCount}개 · ${relTime(s.ts)}</span>
|
||
</div>
|
||
<button onclick="_deleteSession('${s.id}', event)" title="세션 삭제" style="flex-shrink:0;width:24px;height:100%;background:none;border:none;border-left:1px solid var(--line);color:#4b5563;cursor:pointer;font-size:12px;display:flex;align-items:center;justify-content:center;align-self:stretch;padding:0" onmouseover="this.style.color='#f87171';this.style.background='rgba(248,113,113,0.08)'" onmouseout="this.style.color='#4b5563';this.style.background='none'">✕</button>
|
||
</div>`).join('')}
|
||
</div>`;
|
||
}
|
||
|
||
function _resumeSession(id) {
|
||
const list = _loadSessions();
|
||
const sess = list.find(s => s.id === id);
|
||
if (!sess) return;
|
||
_autoSaveChatSession(); // save current before switching
|
||
codeAiHistory = sess.history || [];
|
||
codeAiSessionId = sess.sessionId;
|
||
if (activeProjectName) saveProjectSession(activeProjectName);
|
||
const msgs = document.getElementById('code-ai-messages');
|
||
if (msgs) {
|
||
msgs.innerHTML = '';
|
||
codeAiHistory.forEach(m => codeAiAppend(m.role === 'user' ? 'user' : 'assistant', m.content));
|
||
}
|
||
updateSessionPanel();
|
||
}
|
||
|
||
function _deleteSession(id, event) {
|
||
if (event) { event.stopPropagation(); event.preventDefault(); }
|
||
let list = _loadSessions();
|
||
const sess = list.find(s => s.id === id);
|
||
// If deleting current session, clear it
|
||
if (sess && sess.sessionId === codeAiSessionId) {
|
||
codeAiHistory = [];
|
||
codeAiSessionId = 'code_ai_' + Math.random().toString(36).slice(2, 10);
|
||
if (activeProjectName) saveProjectSession(activeProjectName);
|
||
const msgs = document.getElementById('code-ai-messages');
|
||
if (msgs) msgs.innerHTML = '';
|
||
}
|
||
list = list.filter(s => s.id !== id);
|
||
_saveSessions(list);
|
||
// Also clear server-side history for this session
|
||
if (sess) {
|
||
fetch('/api/clear-history', {
|
||
method: 'POST',
|
||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||
body: JSON.stringify({ sessionId: sess.sessionId }),
|
||
}).catch(() => {});
|
||
}
|
||
_renderSessionsList();
|
||
updateSessionPanel();
|
||
}
|
||
|
||
function _deleteAllSessions(event) {
|
||
if (event) { event.stopPropagation(); event.preventDefault(); }
|
||
const list = _loadSessions();
|
||
// Clear server-side history for all sessions
|
||
for (const s of list) {
|
||
fetch('/api/clear-history', {
|
||
method: 'POST',
|
||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||
body: JSON.stringify({ sessionId: s.sessionId }),
|
||
}).catch(() => {});
|
||
}
|
||
_saveSessions([]);
|
||
// Also clear project-specific session storage
|
||
Object.keys(localStorage).filter(k => k.startsWith('code_proj_session_') || k.startsWith('code_proj_history_')).forEach(k => localStorage.removeItem(k));
|
||
codeAiHistory = [];
|
||
codeAiSessionId = 'code_ai_' + Math.random().toString(36).slice(2, 10);
|
||
if (activeProjectName) saveProjectSession(activeProjectName);
|
||
const msgs = document.getElementById('code-ai-messages');
|
||
if (msgs) msgs.innerHTML = '';
|
||
_renderSessionsList();
|
||
updateSessionPanel();
|
||
}
|
||
|
||
async function codeAiClear() {
|
||
_autoSaveChatSession(); // 현재 세션 자동 저장
|
||
if (activeProjectName) saveProjectSession(activeProjectName); // per-project store도 지우기 전에 저장
|
||
fetch('/api/clear-history', {
|
||
method: 'POST',
|
||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||
body: JSON.stringify({ sessionId: codeAiSessionId }),
|
||
}).catch(() => {});
|
||
codeAiHistory = [];
|
||
|
||
// In client-local mode: ask for a project name and create the folder
|
||
if (localDirHandle) {
|
||
const raw = prompt('새 프로젝트 이름:', '');
|
||
const projName = (raw || '').trim().replace(/[^a-zA-Z0-9가-힣_-]/g, '_').replace(/^_+|_+$/g, '');
|
||
if (projName) {
|
||
await localDirHandle.getDirectoryHandle(projName, { create: true }).catch(() => {});
|
||
switchProject(projName);
|
||
// 새 프로젝트 → 마법사 계획 단계 자동 진입
|
||
_projPhase = 'plan';
|
||
_projHistory = [];
|
||
_projSessionId = 'proj-plan-' + Date.now();
|
||
codeAiAppend('system', `📁 프로젝트 "${projName}" 생성됨. 어떤 걸 만들지 설명해주세요.`);
|
||
return;
|
||
}
|
||
}
|
||
|
||
codeAiSessionId = 'code_ai_' + Math.random().toString(36).slice(2, 10);
|
||
if (activeProjectName) saveProjectSession(activeProjectName);
|
||
updateSessionPanel();
|
||
_renderSessionsList(); // 세션 목록으로 돌아가기
|
||
}
|
||
|
||
|
||
// ── Code sidebar panel ────────────────────────────────────────────────────
|
||
let codeSbCurrentTab = 'files';
|
||
|
||
function setCodeSbTab(tab) {
|
||
codeSbCurrentTab = tab;
|
||
['files','settings'].forEach(t => {
|
||
const btn = document.getElementById('csb-tab-' + t);
|
||
if (btn) btn.classList.toggle('active', t === tab);
|
||
const el = document.getElementById('csb-' + t);
|
||
if (el) el.style.display = t === tab ? 'flex' : 'none';
|
||
});
|
||
if (tab === 'files') csbLoadModels();
|
||
}
|
||
|
||
|
||
let _codeAiCtxMaxCached = 200000; // default until server responds
|
||
let _codeAiCtxMaxFetchedFor = ''; // model name the cache is valid for
|
||
// Real token usage from the last turn (server 'usage' SSE). When present, the gauge
|
||
// shows actual prompt tokens / real context window instead of the char/4 estimate.
|
||
let _codeAiLastUsage = null; // { promptTokens, ctxWindow }
|
||
|
||
function _codeAiCtxMax() { return _codeAiCtxMaxCached; }
|
||
|
||
// Called from streaming loops when a 'usage' SSE arrives.
|
||
function _codeAiOnUsage(ev) {
|
||
if (!ev || typeof ev.promptTokens !== 'number') return;
|
||
_codeAiLastUsage = { promptTokens: ev.promptTokens, ctxWindow: ev.ctxWindow || _codeAiCtxMax() };
|
||
try { updateSessionPanel(); } catch (_) {}
|
||
}
|
||
|
||
async function _fetchModelCtxMax(model) {
|
||
const key = model || '';
|
||
if (key === _codeAiCtxMaxFetchedFor) return; // already fetched for this model
|
||
_codeAiCtxMaxFetchedFor = key;
|
||
try {
|
||
const url = '/api/model-context' + (key ? `?model=${encodeURIComponent(key)}` : '');
|
||
const r = await fetch(url, { headers: authHeaders() });
|
||
if (!r.ok) return;
|
||
const d = await r.json();
|
||
if (d.contextWindow) {
|
||
_codeAiCtxMaxCached = d.contextWindow;
|
||
updateSessionPanel();
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
|
||
function toggleCodeAiMode() {
|
||
codeAiMode = codeAiMode === 'ask' ? 'code' : 'ask';
|
||
localStorage.setItem('codeAiMode', codeAiMode);
|
||
updateCodeAiModeBtn();
|
||
}
|
||
function updateCodeAiModeBtn() {
|
||
const btn = document.getElementById('code-ai-mode-btn');
|
||
if (!btn) return;
|
||
if (codeAiMode === 'ask') {
|
||
btn.textContent = '💬 Ask';
|
||
btn.style.color = '#5a9e78';
|
||
btn.style.borderColor = '#2a4a38';
|
||
} else {
|
||
btn.textContent = '✏️ Code';
|
||
btn.style.color = '#7aabf0';
|
||
btn.style.borderColor = '#2a3a5a';
|
||
}
|
||
}
|
||
|
||
function updateSessionPanel() {
|
||
const barProj = document.getElementById('code-ai-session-bar-project');
|
||
const barCount = document.getElementById('code-ai-session-bar-count');
|
||
const backBtn = document.getElementById('code-ai-back-btn');
|
||
if (barProj) barProj.textContent = activeProjectName || '(전체)';
|
||
if (barCount) barCount.textContent = codeAiHistory.length ? `${codeAiHistory.length}개` : '';
|
||
if (backBtn) backBtn.style.display = codeAiHistory.length ? '' : 'none';
|
||
|
||
// Context usage estimate
|
||
const ctxWrap = document.getElementById('code-ai-ctx-wrap');
|
||
const ctxLabel = document.getElementById('code-ai-ctx-label');
|
||
const ctxFill = document.getElementById('code-ai-ctx-fill');
|
||
if (ctxWrap && ctxLabel && ctxFill) {
|
||
// Fill = real prompt tokens from the last turn (fallback: char/4 estimate).
|
||
// Max = the model's NATIVE context_length (true ceiling), not the per-turn num_ctx.
|
||
const totalChars = codeAiHistory.reduce((s, m) => s + (m.content || '').length, 0);
|
||
const estTokens = _codeAiLastUsage ? _codeAiLastUsage.promptTokens : Math.round(totalChars / 4);
|
||
const maxTokens = _codeAiCtxMax();
|
||
const pct = Math.min(100, Math.round(estTokens / maxTokens * 100));
|
||
const kStr = estTokens >= 1000 ? `~${(estTokens/1000).toFixed(1)}k` : `${estTokens}`;
|
||
const maxKStr = maxTokens >= 1000 ? `${(maxTokens/1000).toFixed(0)}k` : `${maxTokens}`;
|
||
ctxLabel.textContent = `${kStr} / ${maxKStr}`;
|
||
ctxFill.style.width = pct + '%';
|
||
ctxFill.style.background = pct >= 80 ? '#ef4444' : pct >= 50 ? '#f59e0b' : '#22c55e';
|
||
ctxWrap.style.display = 'flex';
|
||
}
|
||
// 프로젝트 선택기 (항상 업데이트)
|
||
const sel = document.getElementById('csb-session-project-sel');
|
||
const projRow = document.getElementById('csb-session-project-row');
|
||
if (sel && localDirHandle) {
|
||
localListFiles(localDirHandle, '').then(files => {
|
||
const projects = [...new Set(files.filter(f => f.includes('/')).map(f => f.split('/')[0]))].sort();
|
||
const options = '<option value="">(전체)</option>' +
|
||
projects.map(p => `<option value="${escHtml(p)}"${activeProjectName === p ? ' selected' : ''}>${escHtml(p)}</option>`).join('');
|
||
sel.innerHTML = options;
|
||
sel.value = activeProjectName || '';
|
||
if (projRow) projRow.style.display = projects.length ? '' : 'none';
|
||
}).catch(() => { if (projRow) projRow.style.display = 'none'; });
|
||
} else if (projRow) {
|
||
projRow.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
async function codeSbRefresh() {
|
||
const tree = document.getElementById('csb-file-tree');
|
||
if (!tree) return;
|
||
tree.innerHTML = '<div style="padding:12px;color:var(--muted);font-size:11px;text-align:center">로딩 중...</div>';
|
||
if (!localDirHandle) {
|
||
tree.innerHTML = `<div style="padding:14px 10px;font-size:11px;color:var(--muted);text-align:center">
|
||
<div style="font-size:22px;margin-bottom:6px">🖥️</div>
|
||
<div style="margin-bottom:10px">로컬 폴더를 선택하세요</div>
|
||
<button onclick="openLocalFolder()" style="background:var(--brand);color:#fff;border:none;border-radius:6px;padding:6px 14px;cursor:pointer;font-size:11px;font-weight:700">📁 폴더 열기</button>
|
||
</div>`;
|
||
return;
|
||
}
|
||
try {
|
||
const allFiles = await localListFiles(localDirHandle, '');
|
||
|
||
// Build project list: dirs that contain code files + all known projects (persisted in localStorage)
|
||
const projectSet = new Set(allFiles.filter(f => f.includes('/')).map(f => f.split('/')[0]));
|
||
_knownProjectsGet().forEach(p => projectSet.add(p));
|
||
if (activeProjectName) projectSet.add(activeProjectName);
|
||
const projects = [...projectSet].sort();
|
||
csbRenderProjectSelector(projects);
|
||
|
||
const files = activeProjectName
|
||
? allFiles.filter(f => f.startsWith(activeProjectName + '/'))
|
||
: allFiles;
|
||
|
||
if (!files.length) {
|
||
const emptyMsg = activeProjectName
|
||
? `<div style="padding:10px;font-size:11px;color:var(--muted)">
|
||
<div style="font-weight:600;color:var(--text);margin-bottom:4px">📁 ${escHtml(activeProjectName)}/</div>
|
||
<div style="padding-left:12px;color:var(--muted)">비어 있음 — + 버튼으로 파일 추가</div>
|
||
</div>`
|
||
: '<div style="padding:12px;color:var(--muted);font-size:11px;text-align:center">파일 없음<br><small>+ 버튼으로 추가</small></div>';
|
||
tree.innerHTML = emptyMsg;
|
||
return;
|
||
}
|
||
const extIcon = { py:'🐍', js:'⚡', ts:'🔷', cpp:'⚙️', c:'⚙️', cs:'💜',
|
||
go:'🔵', rs:'🦀', java:'☕', html:'🌐', css:'🎨', sh:'💻', bash:'💻',
|
||
kt:'🎯', swift:'🍎', rb:'💎', php:'🐘', sql:'🗃️', md:'📝', json:'📋',
|
||
yaml:'📋', yml:'📋', txt:'📄' };
|
||
|
||
// 트리 노드 구조 빌드 (leaf = 항상 전체 경로)
|
||
const root = {};
|
||
for (const f of files) {
|
||
const parts = f.split('/');
|
||
let node = root;
|
||
for (let i = 0; i < parts.length - 1; i++) {
|
||
if (!node[parts[i]]) node[parts[i]] = {};
|
||
node = node[parts[i]];
|
||
}
|
||
node[parts[parts.length - 1]] = f; // leaf = full path string
|
||
}
|
||
|
||
// 프로젝트 선택 시: 해당 서브트리를 루트로, parentPath를 프로젝트명으로 시작
|
||
const displayRoot = (activeProjectName && root[activeProjectName]) ? root[activeProjectName] : root;
|
||
const startPath = activeProjectName || '';
|
||
|
||
const openDirs = csbGetOpenDirs();
|
||
function renderNode(node, depth, parentPath) {
|
||
const indent = depth * 14;
|
||
let html = '';
|
||
// 디렉토리 먼저, 파일 나중
|
||
const dirs = [], leafs = [];
|
||
for (const [k, v] of Object.entries(node)) {
|
||
if (typeof v === 'object') dirs.push([k, v]);
|
||
else leafs.push([k, v]);
|
||
}
|
||
dirs.sort((a,b) => a[0].localeCompare(b[0]));
|
||
leafs.sort((a,b) => a[0].localeCompare(b[0]));
|
||
for (const [name, children] of dirs) {
|
||
const dirId = 'csbdir_' + (parentPath+'/'+name).replace(/[^a-z0-9]/gi,'_');
|
||
const isOpen = openDirs.has(dirId);
|
||
const dirPath = (parentPath+'/'+name).replace(/^\//,'');
|
||
const safeDirPath = dirPath.replace(/\\/g,'\\\\').replace(/'/g,"\\'");
|
||
html += `<div class="csb-file-item" data-dirpath="${escHtml(dirPath)}" style="padding-left:${indent+8}px;font-weight:600;color:var(--muted)"
|
||
onclick="csbToggleDir('${dirId}')"
|
||
oncontextmenu="csbCtxMenu(event,this,true)"
|
||
ondragover="event.preventDefault();event.dataTransfer.dropEffect='move';this.classList.add('csb-drop-target')"
|
||
ondragleave="this.classList.remove('csb-drop-target')"
|
||
ondrop="csbDrop(event,this)">
|
||
<span id="${dirId}_arrow" style="font-size:9px;transition:.15s;flex-shrink:0">${isOpen ? '▼' : '▶'}</span><span style="flex-shrink:0">📂</span>
|
||
<span class="csb-item-name" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1">${escHtml(name)}/</span>
|
||
<button class="csb-add-btn" onclick="csbNewInDir(this.closest('[data-dirpath]').dataset.dirpath,event)" title="새 파일">+</button>
|
||
<button class="csb-del" onclick="csbDeleteDir('${safeDirPath}',event)" title="디렉토리 삭제">✕</button>
|
||
</div>
|
||
<div id="${dirId}" style="display:${isOpen ? 'block' : 'none'}">
|
||
${renderNode(children, depth+1, parentPath+'/'+name)}
|
||
</div>`;
|
||
}
|
||
for (const [name, fullPath] of leafs) {
|
||
const ext = name.split('.').pop().toLowerCase();
|
||
const icon = extIcon[ext] || '📄';
|
||
const safe = fullPath.replace(/\\/g,'\\\\').replace(/'/g,"\\'");
|
||
html += `<div class="csb-file-item" data-path="${escHtml(fullPath)}" style="padding-left:${indent+8}px"
|
||
draggable="true"
|
||
ondragstart="event.dataTransfer.setData('text/plain',this.dataset.path);event.dataTransfer.effectAllowed='move'"
|
||
oncontextmenu="csbCtxMenu(event,this,false)"
|
||
onclick="csbOpenFile('${safe}')">
|
||
<span style="flex-shrink:0">${icon}</span>
|
||
<span class="csb-item-name" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1" title="${escHtml(fullPath)}">${escHtml(name)}</span>
|
||
<button class="csb-del" onclick="csbDeleteFile('${safe}',event)" title="삭제">✕</button>
|
||
</div>`;
|
||
}
|
||
return html;
|
||
}
|
||
|
||
tree.innerHTML = renderNode(displayRoot, 0, startPath);
|
||
} catch {
|
||
tree.innerHTML = '<div style="padding:12px;color:#f87171;font-size:11px;text-align:center">불러오기 실패</div>';
|
||
}
|
||
}
|
||
|
||
function csbGetOpenDirs() {
|
||
try { return new Set(JSON.parse(localStorage.getItem('csb_open_dirs') || '[]')); } catch { return new Set(); }
|
||
}
|
||
function csbSaveOpenDirs(set) {
|
||
try { localStorage.setItem('csb_open_dirs', JSON.stringify([...set])); } catch {}
|
||
}
|
||
function csbToggleDir(id) {
|
||
const el = document.getElementById(id);
|
||
const arrow = document.getElementById(id + '_arrow');
|
||
if (!el) return;
|
||
const collapsed = el.style.display === 'none';
|
||
el.style.display = collapsed ? 'block' : 'none';
|
||
if (arrow) arrow.textContent = collapsed ? '▼' : '▶';
|
||
const dirs = csbGetOpenDirs();
|
||
if (collapsed) dirs.add(id); else dirs.delete(id);
|
||
csbSaveOpenDirs(dirs);
|
||
}
|
||
|
||
async function csbOpenFile(filename) {
|
||
if (!localDirHandle) return;
|
||
try {
|
||
const content = await localReadFile(filename);
|
||
if (content === null) return;
|
||
// In client-local+project mode, tabs use bare names; sidebar paths include the project prefix
|
||
const bare = _clientBareFileName(filename);
|
||
const existing = codeFiles.find(f => f.name === bare);
|
||
if (existing) switchCodeFile(existing.id);
|
||
else codeNewFile(bare, content);
|
||
} catch {}
|
||
}
|
||
|
||
async function csbDeleteFile(filename, ev) {
|
||
ev.stopPropagation();
|
||
if (!localDirHandle || !confirm(`"${filename}" 삭제할까요?`)) return;
|
||
try {
|
||
const ok = await localDeleteFile(filename);
|
||
if (ok) {
|
||
const bare = _clientBareFileName(filename);
|
||
const existing = codeFiles.find(f => f.name === bare);
|
||
if (existing) closeCodeFile(existing.id);
|
||
codeSbRefresh();
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
async function csbDeleteDir(dirPath, ev) {
|
||
ev.stopPropagation();
|
||
if (!localDirHandle || !confirm(`"${dirPath}" 디렉토리와 내부 파일을 모두 삭제할까요?`)) return;
|
||
try {
|
||
const parts = dirPath.split('/').filter(Boolean);
|
||
let parent = localDirHandle;
|
||
for (let i = 0; i < parts.length - 1; i++) parent = await parent.getDirectoryHandle(parts[i]);
|
||
await parent.removeEntry(parts[parts.length - 1], { recursive: true });
|
||
// 해당 디렉토리 아래 열려 있는 탭 모두 닫기
|
||
const prefix = dirPath.endsWith('/') ? dirPath : dirPath + '/';
|
||
for (const f of [...codeFiles]) {
|
||
if (f.name === dirPath || f.name.startsWith(prefix)) closeCodeFile(f.id);
|
||
}
|
||
codeSbRefresh();
|
||
} catch (e) {
|
||
alert('삭제 실패: ' + e.message);
|
||
}
|
||
}
|
||
|
||
// ── Context menu ─────────────────────────────────────────────────────────────
|
||
let _csbCtxTarget = null;
|
||
|
||
function csbCtxMenu(ev, el, isDir) {
|
||
ev.preventDefault(); ev.stopPropagation();
|
||
_csbCtxTarget = { path: el.dataset.path || el.dataset.dirpath, isDir };
|
||
const menu = document.getElementById('csb-ctx');
|
||
if (!menu) return;
|
||
const items = isDir ? [
|
||
{ label: '📄 새 파일', action: 'newfile' },
|
||
{ label: '📁 새 폴더', action: 'newfolder' },
|
||
{ label: '✏️ 이름 바꾸기', action: 'rename' },
|
||
{ label: '🗑️ 삭제', action: 'delete' },
|
||
] : [
|
||
{ label: '✏️ 이름 바꾸기', action: 'rename' },
|
||
{ label: '🗑️ 삭제', action: 'delete' },
|
||
];
|
||
menu.innerHTML = items.map(i => `<div class="csb-ctx-item" onclick="csbCtxAction('${i.action}')">${i.label}</div>`).join('');
|
||
menu.style.display = 'block';
|
||
const x = Math.min(ev.clientX, window.innerWidth - 160);
|
||
const y = Math.min(ev.clientY, window.innerHeight - (items.length * 34) - 16);
|
||
menu.style.left = x + 'px';
|
||
menu.style.top = y + 'px';
|
||
}
|
||
function csbCtxClose() {
|
||
const menu = document.getElementById('csb-ctx');
|
||
if (menu) menu.style.display = 'none';
|
||
_csbCtxTarget = null;
|
||
}
|
||
function csbCtxAction(action) {
|
||
const target = _csbCtxTarget;
|
||
csbCtxClose();
|
||
if (!target) return;
|
||
const { path, isDir } = target;
|
||
if (action === 'rename') csbInlineRename(path, isDir);
|
||
else if (action === 'delete') isDir ? csbDeleteDir(path, { stopPropagation: () => {} }) : csbDeleteFile(path, { stopPropagation: () => {} });
|
||
else if (action === 'newfile') csbNewInDir(path, null);
|
||
else if (action === 'newfolder') csbNewFolderAt(path);
|
||
}
|
||
|
||
// ── Inline rename ─────────────────────────────────────────────────────────────
|
||
function csbInlineRename(path, isDir) {
|
||
const tree = document.getElementById('csb-file-tree');
|
||
if (!tree) return;
|
||
const attr = isDir ? 'data-dirpath' : 'data-path';
|
||
const item = tree.querySelector(`[${attr}="${path.replace(/\\/g,'\\\\').replace(/"/g,'\\"')}"]`);
|
||
if (!item) return;
|
||
const nameEl = item.querySelector('.csb-item-name');
|
||
if (!nameEl) return;
|
||
const oldBase = path.split('/').pop();
|
||
const inp = document.createElement('input');
|
||
inp.value = oldBase;
|
||
inp.style.cssText = 'flex:1;min-width:0;font-size:11px;padding:1px 4px;background:var(--panel);color:var(--text);border:1px solid var(--brand);border-radius:3px;outline:none;font-family:inherit';
|
||
nameEl.replaceWith(inp);
|
||
inp.focus(); inp.select();
|
||
let done = false;
|
||
const commit = async () => {
|
||
if (done) return; done = true;
|
||
const newName = inp.value.trim();
|
||
if (newName && newName !== oldBase) await csbRenameExec(path, newName, isDir);
|
||
else await codeSbRefresh();
|
||
};
|
||
inp.onblur = commit;
|
||
inp.onkeydown = e => {
|
||
if (e.key === 'Enter') { e.preventDefault(); commit(); }
|
||
if (e.key === 'Escape') { done = true; codeSbRefresh(); }
|
||
};
|
||
}
|
||
|
||
async function csbRenameExec(oldPath, newName, isDir) {
|
||
if (!localDirHandle) return;
|
||
const parts = oldPath.split('/');
|
||
parts[parts.length - 1] = newName;
|
||
const newPath = parts.join('/');
|
||
try {
|
||
if (!isDir) {
|
||
const content = await localReadFile(oldPath);
|
||
if (content === null) throw new Error('읽기 실패');
|
||
await localWriteFile(newPath, content);
|
||
await localDeleteFile(oldPath);
|
||
const bare = _clientBareFileName(oldPath);
|
||
const tab = codeFiles.find(f => f.name === bare);
|
||
if (tab) { tab.name = _clientBareFileName(newPath); renderCodeFileTabs(); }
|
||
} else {
|
||
const allFiles = await localListFiles(localDirHandle, '');
|
||
const prefix = oldPath + '/';
|
||
for (const f of allFiles.filter(f => f.startsWith(prefix))) {
|
||
const content = await localReadFile(f);
|
||
if (content !== null) await localWriteFile(newPath + '/' + f.slice(prefix.length), content);
|
||
}
|
||
const ps = oldPath.split('/');
|
||
let parent = localDirHandle;
|
||
for (let i = 0; i < ps.length - 1; i++) parent = await parent.getDirectoryHandle(ps[i]);
|
||
await parent.removeEntry(ps[ps.length - 1], { recursive: true });
|
||
for (const f of codeFiles) {
|
||
const disk = _clientProjectPath(f.name);
|
||
if (disk.startsWith(prefix)) f.name = _clientBareFileName(newPath + '/' + disk.slice(prefix.length));
|
||
}
|
||
renderCodeFileTabs();
|
||
}
|
||
await codeSbRefresh();
|
||
} catch (e) { alert('이름 바꾸기 실패: ' + (e.message || e)); await codeSbRefresh(); }
|
||
}
|
||
|
||
// ── New file/folder helpers ───────────────────────────────────────────────────
|
||
async function csbNewInDir(dirPath, ev) {
|
||
if (ev) ev.stopPropagation();
|
||
if (!localDirHandle) return;
|
||
const name = prompt(`새 파일 이름 (${dirPath.split('/').pop()}/ 에 생성):`);
|
||
if (!name?.trim()) return;
|
||
const fullPath = dirPath + '/' + name.trim();
|
||
await localWriteFile(fullPath, '');
|
||
codeNewFile(_clientBareFileName(fullPath), '');
|
||
codeSbRefresh();
|
||
}
|
||
|
||
async function csbNewFolderAt(parentPath, ev) {
|
||
if (ev) ev.stopPropagation();
|
||
if (!localDirHandle) return;
|
||
const name = prompt(`새 폴더 이름 (${parentPath.split('/').pop()}/ 안에 생성):`);
|
||
if (!name?.trim()) return;
|
||
await localWriteFile(parentPath + '/' + name.trim() + '/.gitkeep', '');
|
||
codeSbRefresh();
|
||
}
|
||
|
||
async function csbNewFolderRoot() {
|
||
if (!localDirHandle) return;
|
||
const base = activeProjectName ? activeProjectName + '/' : '';
|
||
const name = prompt('새 폴더 이름:');
|
||
if (!name?.trim()) return;
|
||
await localWriteFile(base + name.trim() + '/.gitkeep', '');
|
||
codeSbRefresh();
|
||
}
|
||
|
||
// ── Drag and drop ─────────────────────────────────────────────────────────────
|
||
function csbDrop(ev, el) {
|
||
ev.preventDefault(); ev.stopPropagation();
|
||
el.classList.remove('csb-drop-target');
|
||
const srcPath = ev.dataTransfer.getData('text/plain');
|
||
const dirPath = el.dataset.dirpath;
|
||
if (!srcPath || !dirPath) return;
|
||
const dstPath = dirPath + '/' + srcPath.split('/').pop();
|
||
if (srcPath === dstPath) return;
|
||
csbMoveFile(srcPath, dstPath);
|
||
}
|
||
|
||
async function csbMoveFile(srcPath, dstPath) {
|
||
if (!localDirHandle) return;
|
||
try {
|
||
const content = await localReadFile(srcPath);
|
||
if (content === null) { alert(`읽기 실패: ${srcPath}`); return; }
|
||
await localWriteFile(dstPath, content);
|
||
await localDeleteFile(srcPath);
|
||
const tab = codeFiles.find(f => f.name === _clientBareFileName(srcPath));
|
||
if (tab) { tab.name = _clientBareFileName(dstPath); renderCodeFileTabs(); }
|
||
await codeSbRefresh();
|
||
} catch (e) { alert('이동 실패: ' + (e.message || e)); }
|
||
}
|
||
|
||
async function csbLoadModels() {
|
||
const sel = document.getElementById('csb-model-select');
|
||
if (!sel) return;
|
||
try {
|
||
const [settingRes, ollamaRes] = await Promise.all([
|
||
fetch('/api/settings/model', { headers: authHeaders() }),
|
||
fetch('/api/ollama/models', { headers: authHeaders() }),
|
||
]);
|
||
const setting = settingRes.ok ? await settingRes.json() : {};
|
||
const ollamaData = ollamaRes.ok ? await ollamaRes.json() : {};
|
||
const primaryModel = setting.primary || '설정 모델';
|
||
const ollamaModels = (ollamaData.models || []).map(m => m.name);
|
||
sel.innerHTML = `<option value="">기본 (${primaryModel})</option>`;
|
||
const allModels = [...ollamaModels];
|
||
if (codeAiModel && !allModels.includes(codeAiModel)) allModels.unshift(codeAiModel);
|
||
for (const m of allModels) {
|
||
const opt = document.createElement('option');
|
||
opt.value = m; opt.textContent = m;
|
||
if (m === codeAiModel) opt.selected = true;
|
||
sel.appendChild(opt);
|
||
}
|
||
if (!codeAiModel) sel.value = '';
|
||
updateCodeAiLabel();
|
||
} catch {}
|
||
}
|
||
|
||
function csbModelChanged() {
|
||
const sel = document.getElementById('csb-model-select');
|
||
codeAiModel = sel ? sel.value : '';
|
||
localStorage.setItem('codeAiModel', codeAiModel);
|
||
updateCodeAiLabel();
|
||
_codeAiCtxMaxFetchedFor = ''; // invalidate cache so next fetch runs
|
||
_fetchModelCtxMax(codeAiModel);
|
||
}
|
||
|
||
function csbFontSize(val) {
|
||
document.getElementById('csb-fontsize-label').textContent = val;
|
||
if (monacoEditor) monacoEditor.updateOptions({ fontSize: parseInt(val) });
|
||
localStorage.setItem('codeEditorFontSize', val);
|
||
}
|
||
|
||
function csbWordWrap(checked) {
|
||
if (monacoEditor) monacoEditor.updateOptions({ wordWrap: checked ? 'on' : 'off' });
|
||
localStorage.setItem('codeEditorWordWrap', checked ? '1' : '0');
|
||
}
|
||
|
||
function csbFontWeight(val) {
|
||
localStorage.setItem('codeEditorFontWeight', val);
|
||
if (monacoEditor) monacoEditor.updateOptions({ fontWeight: val });
|
||
}
|
||
|
||
let _hideBlankLines = false;
|
||
function csbHideBlankLines(checked) {
|
||
_hideBlankLines = !!checked;
|
||
localStorage.setItem('codeEditorHideBlank', _hideBlankLines ? '1' : '0');
|
||
const btn = document.getElementById('code-opt-hideblank');
|
||
if (btn) btn.classList.toggle('active', _hideBlankLines);
|
||
_applyHideBlankLines();
|
||
}
|
||
function _applyHideBlankLines() {
|
||
if (!monacoEditor) return;
|
||
if (!_hideBlankLines) { try { monacoEditor.setHiddenAreas([]); } catch {} return; }
|
||
const model = monacoEditor.getModel();
|
||
if (!model) return;
|
||
const n = model.getLineCount();
|
||
const ranges = [];
|
||
let start = -1;
|
||
let inBlock = false, blockEnd = '';
|
||
for (let i = 1; i <= n; i++) {
|
||
const l = model.getLineContent(i).trim();
|
||
let hide = false;
|
||
if (inBlock) {
|
||
hide = true;
|
||
if (l.includes(blockEnd)) inBlock = false;
|
||
} else if (l === '') {
|
||
hide = true;
|
||
} else if (/^(\/\/|#|--|;)/.test(l)) {
|
||
hide = true;
|
||
} else if (l.startsWith('/*')) {
|
||
hide = true;
|
||
if (!l.slice(2).includes('*/')) { inBlock = true; blockEnd = '*/'; }
|
||
} else if (l.startsWith('<!--')) {
|
||
hide = true;
|
||
if (!l.slice(4).includes('-->')) { inBlock = true; blockEnd = '-->'; }
|
||
} else if (l.startsWith('"""') || l.startsWith("'''")) {
|
||
const d = l.slice(0, 3);
|
||
hide = true;
|
||
if (!l.slice(3).includes(d)) { inBlock = true; blockEnd = d; }
|
||
}
|
||
if (hide) { if (start < 0) start = i; }
|
||
else { if (start >= 0) { ranges.push({ startLineNumber: start, startColumn: 1, endLineNumber: i - 1, endColumn: 1 }); start = -1; } }
|
||
}
|
||
if (start >= 0) ranges.push({ startLineNumber: start, startColumn: 1, endLineNumber: n, endColumn: 1 });
|
||
try { monacoEditor.setHiddenAreas(ranges); } catch {}
|
||
}
|
||
|
||
let _blockFlipFlop = false;
|
||
let _blockFFDecoIds = [];
|
||
|
||
function csbBlockFlipFlop(checked) {
|
||
_blockFlipFlop = !!checked;
|
||
localStorage.setItem('codeEditorBlockFF', _blockFlipFlop ? '1' : '0');
|
||
const btn = document.getElementById('code-opt-blockff');
|
||
if (btn) btn.classList.toggle('active', _blockFlipFlop);
|
||
_applyBlockFlipFlop();
|
||
}
|
||
|
||
function _applyBlockFlipFlop() {
|
||
if (!monacoEditor) return;
|
||
const model = monacoEditor.getModel();
|
||
if (!model) return;
|
||
_blockFFDecoIds = monacoEditor.deltaDecorations(_blockFFDecoIds, []);
|
||
if (!_blockFlipFlop) return;
|
||
|
||
const n = model.getLineCount();
|
||
const decos = [];
|
||
|
||
// Detect top-level function/class/def declarations
|
||
const declRe = /^(export\s+)?(default\s+)?(async\s+)?(function\b|class\b|def\b|async\s+def\b)|^(const|let|var)\s+\w+\s*=\s*(async\s+)?(function|\(|[A-Za-z_$]\w*\s*=>)/;
|
||
const boundaries = [];
|
||
for (let i = 1; i <= n; i++) {
|
||
const line = model.getLineContent(i);
|
||
if (line.match(/^(\s*)/)[1].length === 0 && declRe.test(line.trim())) {
|
||
boundaries.push(i);
|
||
}
|
||
}
|
||
|
||
const ffCls = (idx) => idx % 2 === 0 ? 'ff-block-even' : 'ff-block-odd';
|
||
const ffRange = (from, to, idx) => ({ range: { startLineNumber: from, startColumn: 1, endLineNumber: to, endColumn: 1 }, options: { isWholeLine: true, className: ffCls(idx) } });
|
||
|
||
if (boundaries.length >= 2) {
|
||
// Declaration-based: alternate between declaration boundaries
|
||
for (let b = 0; b < boundaries.length; b++) {
|
||
const from = boundaries[b];
|
||
const to = b + 1 < boundaries.length ? boundaries[b + 1] - 1 : n;
|
||
decos.push(ffRange(from, to, b));
|
||
}
|
||
} else {
|
||
// Fallback: blank-line-separated groups
|
||
let phase = 0, blockStart = -1;
|
||
for (let i = 1; i <= n; i++) {
|
||
const blank = model.getLineContent(i).trim() === '';
|
||
if (!blank && blockStart < 0) { blockStart = i; }
|
||
else if (blank && blockStart >= 0) {
|
||
decos.push(ffRange(blockStart, i - 1, phase));
|
||
blockStart = -1; phase++;
|
||
}
|
||
}
|
||
if (blockStart >= 0) decos.push(ffRange(blockStart, n, phase));
|
||
}
|
||
|
||
_blockFFDecoIds = monacoEditor.deltaDecorations([], decos);
|
||
}
|
||
|
||
const CODE_THEME_GROUPS = [
|
||
{ icon: '🌙', label: '다크', themes: [
|
||
{ v: 'github-dark', label: 'GitHub Dark', bg: '#0d1117' },
|
||
{ v: 'one-dark', label: 'One Dark', bg: '#282c34' },
|
||
{ v: 'dracula', label: 'Dracula', bg: '#282a36' },
|
||
{ v: 'nord', label: 'Nord', bg: '#2e3440' },
|
||
{ v: 'vs-dark', label: 'VS Dark', bg: '#1e1e1e' },
|
||
{ v: 'monokai', label: 'Monokai', bg: '#272822' },
|
||
]},
|
||
{ icon: '☀️', label: '라이트', themes: [
|
||
{ v: 'github-light', label: 'GitHub Light', bg: '#f6f8fa' },
|
||
{ v: 'vs', label: 'VS Light', bg: '#ffffff' },
|
||
]},
|
||
];
|
||
const CODE_THEMES = CODE_THEME_GROUPS.flatMap(g => g.themes);
|
||
|
||
function defineCodeThemes() {
|
||
const d = monaco.editor.defineTheme.bind(monaco.editor);
|
||
d('monokai', { base:'vs-dark', inherit:true, rules:[
|
||
{token:'keyword',foreground:'f92672'},{token:'string',foreground:'e6db74'},
|
||
{token:'comment',foreground:'75715e',fontStyle:'italic'},{token:'number',foreground:'ae81ff'},
|
||
{token:'type',foreground:'66d9ef'},{token:'function',foreground:'a6e22e'},
|
||
], colors:{'editor.background':'#272822','editor.foreground':'#f8f8f2','editor.selectionBackground':'#49483e','editorCursor.foreground':'#f8f8f0'}});
|
||
d('dracula', { base:'vs-dark', inherit:true, rules:[
|
||
{token:'keyword',foreground:'ff79c6'},{token:'string',foreground:'f1fa8c'},
|
||
{token:'comment',foreground:'6272a4',fontStyle:'italic'},{token:'number',foreground:'bd93f9'},
|
||
{token:'type',foreground:'8be9fd'},{token:'function',foreground:'50fa7b'},
|
||
], colors:{'editor.background':'#282a36','editor.foreground':'#f8f8f2','editor.selectionBackground':'#44475a','editorCursor.foreground':'#f8f8f2','editor.lineHighlightBackground':'#44475a'}});
|
||
d('one-dark', { base:'vs-dark', inherit:true, rules:[
|
||
{token:'keyword',foreground:'c678dd'},{token:'string',foreground:'98c379'},
|
||
{token:'comment',foreground:'5c6370',fontStyle:'italic'},{token:'number',foreground:'d19a66'},
|
||
{token:'type',foreground:'e5c07b'},{token:'function',foreground:'61afef'},
|
||
], colors:{'editor.background':'#282c34','editor.foreground':'#abb2bf','editor.selectionBackground':'#3e4451','editorCursor.foreground':'#528bff'}});
|
||
d('nord', { base:'vs-dark', inherit:true, rules:[
|
||
{token:'keyword',foreground:'81a1c1'},{token:'string',foreground:'a3be8c'},
|
||
{token:'comment',foreground:'4c566a',fontStyle:'italic'},{token:'number',foreground:'b48ead'},
|
||
{token:'type',foreground:'8fbcbb'},{token:'function',foreground:'88c0d0'},
|
||
], colors:{'editor.background':'#2e3440','editor.foreground':'#d8dee9','editor.selectionBackground':'#3b4252','editorCursor.foreground':'#d8dee9','editor.lineHighlightBackground':'#3b4252'}});
|
||
d('github-dark', { base:'vs-dark', inherit:true, rules:[
|
||
{token:'keyword',foreground:'ff7b72'},{token:'string',foreground:'a5d6ff'},
|
||
{token:'comment',foreground:'8b949e',fontStyle:'italic'},{token:'number',foreground:'79c0ff'},
|
||
{token:'type',foreground:'ffa657'},{token:'function',foreground:'d2a8ff'},
|
||
], colors:{'editor.background':'#0d1117','editor.foreground':'#c9d1d9','editor.selectionBackground':'#163b55','editorCursor.foreground':'#e6edf3'}});
|
||
d('tomorrow-night', { base:'vs-dark', inherit:true, rules:[
|
||
{token:'keyword',foreground:'b294bb'},{token:'string',foreground:'b5bd68'},
|
||
{token:'comment',foreground:'969896',fontStyle:'italic'},{token:'number',foreground:'de935f'},
|
||
{token:'type',foreground:'81a2be'},{token:'function',foreground:'81a2be'},
|
||
], colors:{'editor.background':'#1d1f21','editor.foreground':'#c5c8c6','editor.selectionBackground':'#373b41','editorCursor.foreground':'#aeafad'}});
|
||
d('solarized-dark', { base:'vs-dark', inherit:true, rules:[
|
||
{token:'keyword',foreground:'859900'},{token:'string',foreground:'2aa198'},
|
||
{token:'comment',foreground:'657b83',fontStyle:'italic'},{token:'number',foreground:'d33682'},
|
||
{token:'type',foreground:'b58900'},{token:'function',foreground:'268bd2'},
|
||
], colors:{'editor.background':'#002b36','editor.foreground':'#839496','editor.selectionBackground':'#073642','editorCursor.foreground':'#839496'}});
|
||
d('github-light', { base:'vs', inherit:true, rules:[
|
||
{token:'keyword',foreground:'d73a49'},{token:'string',foreground:'032f62'},
|
||
{token:'comment',foreground:'6a737d',fontStyle:'italic'},{token:'number',foreground:'005cc5'},
|
||
{token:'type',foreground:'e36209'},{token:'function',foreground:'6f42c1'},
|
||
], colors:{'editor.background':'#f6f8fa','editor.foreground':'#24292e','editor.selectionBackground':'#c8c8fa','editorCursor.foreground':'#24292e'}});
|
||
d('solarized-light', { base:'vs', inherit:true, rules:[
|
||
{token:'keyword',foreground:'859900'},{token:'string',foreground:'2aa198'},
|
||
{token:'comment',foreground:'93a1a1',fontStyle:'italic'},{token:'number',foreground:'d33682'},
|
||
{token:'type',foreground:'b58900'},{token:'function',foreground:'268bd2'},
|
||
], colors:{'editor.background':'#fdf6e3','editor.foreground':'#657b83','editor.selectionBackground':'#eee8d5','editorCursor.foreground':'#657b83'}});
|
||
}
|
||
|
||
function renderThemeGrid() {
|
||
const grid = document.getElementById('csb-theme-grid');
|
||
if (!grid) return;
|
||
const cur = localStorage.getItem('codeEditorTheme') ||
|
||
(document.documentElement.getAttribute('data-theme') !== 'light' ? 'vs-dark' : 'vs');
|
||
grid.className = 'code-theme-groups';
|
||
grid.innerHTML = CODE_THEME_GROUPS.map(g =>
|
||
`<div class="code-theme-row">
|
||
<span class="code-theme-row-lbl">${g.icon} ${g.label}</span>
|
||
${g.themes.map(t =>
|
||
`<div class="code-skin-sw${t.v===cur?' active':''}" data-v="${t.v}" title="${t.label}"
|
||
style="background:${t.bg}" onclick="csbTheme('${t.v}')"></div>`
|
||
).join('')}
|
||
</div>`
|
||
).join('');
|
||
}
|
||
|
||
function setCodePageTheme(name) {
|
||
document.documentElement.setAttribute('data-theme', name);
|
||
localStorage.setItem('code-page-theme', name);
|
||
document.querySelectorAll('.code-theme-btn').forEach(b =>
|
||
b.classList.toggle('active', b.id === 'ctbtn-' + name));
|
||
// Monaco 에디터 테마도 연동
|
||
if (window.monacoEditor && !localStorage.getItem('codeEditorTheme')) {
|
||
const autoTheme = name === 'light' ? 'vs' : name === 'warm' ? 'solarized-light' : 'vs-dark';
|
||
if (window.monaco) monaco.editor.setTheme(autoTheme);
|
||
}
|
||
}
|
||
(function() {
|
||
const t = localStorage.getItem('code-page-theme') || localStorage.getItem('cherryclaw_theme') || 'dark';
|
||
setCodePageTheme(t);
|
||
})();
|
||
|
||
function csbTheme(theme) {
|
||
if (window.monaco) {
|
||
if (!['vs','vs-dark','hc-black','hc-light'].includes(theme)) defineCodeThemes();
|
||
monaco.editor.setTheme(theme);
|
||
}
|
||
localStorage.setItem('codeEditorTheme', theme);
|
||
const lbl = CODE_THEMES.find(t => t.v === theme);
|
||
const el = document.getElementById('csb-theme-lbl');
|
||
if (el) el.textContent = lbl ? lbl.label : theme;
|
||
document.querySelectorAll('#csb-theme-grid .code-skin-sw').forEach(sw =>
|
||
sw.classList.toggle('active', sw.dataset.v === theme));
|
||
}
|
||
|
||
function csbTabSize(val) {
|
||
if (monacoEditor) monacoEditor.updateOptions({ tabSize: parseInt(val) });
|
||
localStorage.setItem('codeEditorTabSize', val);
|
||
}
|
||
|
||
function csbMinimap(checked) {
|
||
if (monacoEditor) monacoEditor.updateOptions({ minimap: { enabled: checked } });
|
||
localStorage.setItem('codeEditorMinimap', checked ? '1' : '0');
|
||
}
|
||
|
||
function csbFontFamily(val) {
|
||
localStorage.setItem('codeEditorFontFamily', val);
|
||
if (monacoEditor) monacoEditor.updateOptions({ fontFamily: val === 'monospace' ? 'monospace' : `'${val}', monospace` });
|
||
}
|
||
|
||
// Restore editor settings from localStorage
|
||
function csbRestoreSettings() {
|
||
const fs = localStorage.getItem('codeEditorFontSize');
|
||
const ww = localStorage.getItem('codeEditorWordWrap');
|
||
const th = localStorage.getItem('codeEditorTheme');
|
||
const ts = localStorage.getItem('codeEditorTabSize');
|
||
const mm = localStorage.getItem('codeEditorMinimap');
|
||
const ff = localStorage.getItem('codeEditorFontFamily') || 'D2Coding';
|
||
const fw = localStorage.getItem('codeEditorFontWeight') || '400';
|
||
const hb = localStorage.getItem('codeEditorHideBlank');
|
||
const bff = localStorage.getItem('codeEditorBlockFF');
|
||
if (fs) { document.getElementById('csb-fontsize').value = fs; document.getElementById('csb-fontsize-label').textContent = fs; }
|
||
if (ww !== null) document.getElementById('csb-wordwrap').checked = ww === '1';
|
||
if (ts) document.getElementById('csb-tabsize').value = ts;
|
||
if (mm !== null) document.getElementById('csb-minimap').checked = mm === '1';
|
||
const ffEl = document.getElementById('csb-fontfamily'); if (ffEl) ffEl.value = ff;
|
||
const fwEl = document.getElementById('csb-fontweight'); if (fwEl) fwEl.value = fw;
|
||
_hideBlankLines = hb === '1';
|
||
_blockFlipFlop = bff === '1';
|
||
const hbBtn = document.getElementById('code-opt-hideblank'); if (hbBtn) hbBtn.classList.toggle('active', _hideBlankLines);
|
||
const bffBtn = document.getElementById('code-opt-blockff'); if (bffBtn) bffBtn.classList.toggle('active', _blockFlipFlop);
|
||
if (monacoEditor) monacoEditor.updateOptions({
|
||
fontSize: fs ? parseInt(fs) : 14,
|
||
wordWrap: ww === '0' ? 'off' : 'on',
|
||
tabSize: ts ? parseInt(ts) : 2,
|
||
minimap: { enabled: mm !== '0' },
|
||
fontFamily: ff === 'monospace' ? 'monospace' : `'${ff}', monospace`,
|
||
fontWeight: fw,
|
||
});
|
||
if (_hideBlankLines) _applyHideBlankLines();
|
||
if (_blockFlipFlop) _applyBlockFlipFlop();
|
||
const activeTheme = th || (document.documentElement.getAttribute('data-theme') !== 'light' ? 'vs-dark' : 'vs');
|
||
if (window.monaco) { defineCodeThemes(); monaco.editor.setTheme(activeTheme); }
|
||
renderThemeGrid();
|
||
const lbl = CODE_THEMES.find(t => t.v === activeTheme);
|
||
const lblEl = document.getElementById('csb-theme-lbl');
|
||
if (lblEl) lblEl.textContent = lbl ? lbl.label : activeTheme;
|
||
}
|
||
|
||
// ── Code folder browser ───────────────────────────────────────────────────
|
||
let codeFolderOpen = false;
|
||
|
||
function toggleCodeFolderBrowser(e) {
|
||
const dd = document.getElementById('code-folder-dropdown');
|
||
const btn = document.getElementById('code-folder-btn');
|
||
if (!dd || !btn) return;
|
||
codeFolderOpen = !codeFolderOpen;
|
||
if (codeFolderOpen) {
|
||
const r = btn.getBoundingClientRect();
|
||
dd.style.top = (r.bottom + 4) + 'px';
|
||
dd.style.left = r.left + 'px';
|
||
dd.style.display = 'block';
|
||
codeRefreshFolder();
|
||
} else {
|
||
dd.style.display = 'none';
|
||
}
|
||
if (e) e.stopPropagation();
|
||
}
|
||
|
||
async function codeRefreshFolder() {
|
||
const list = document.getElementById('code-folder-list');
|
||
if (!list) return;
|
||
list.innerHTML = '<div style="padding:8px 12px;color:var(--muted);font-size:11px">로딩 중...</div>';
|
||
try {
|
||
const files = localDirHandle ? await localListFiles(localDirHandle, '') : [];
|
||
if (!files.length) {
|
||
list.innerHTML = '<div style="padding:8px 12px;color:var(--muted);font-size:11px">파일 없음</div>';
|
||
return;
|
||
}
|
||
list.innerHTML = files.map(f => {
|
||
const ext = f.split('.').pop().toLowerCase();
|
||
const icon = { py:'🐍', js:'⚡', ts:'🔷', cpp:'⚙️', c:'⚙️', cs:'💜',
|
||
go:'🔵', rs:'🦀', java:'☕', html:'🌐', css:'🎨', sh:'💻',
|
||
kt:'🎯', swift:'🍎', rb:'💎', php:'🐘', sql:'🗃️', md:'📝',
|
||
}[ext] || '📄';
|
||
const safe = f.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
||
return `<div class="code-folder-item">
|
||
<span style="flex:1;display:flex;align-items:center;gap:7px;overflow:hidden;min-width:0"
|
||
onclick="codeOpenFromFolder('${safe}')">
|
||
${icon} <span style="overflow:hidden;text-overflow:ellipsis">${escHtml(f)}</span>
|
||
</span>
|
||
<button class="code-folder-del" onclick="codeDeleteFromFolder('${safe}',event)" title="삭제">✕</button>
|
||
</div>`;
|
||
}).join('');
|
||
} catch {
|
||
list.innerHTML = '<div style="padding:8px 12px;color:#f00;font-size:11px">불러오기 실패</div>';
|
||
}
|
||
}
|
||
|
||
async function codeOpenFromFolder(filename) {
|
||
if (!localDirHandle) return;
|
||
try {
|
||
const content = await localReadFile(filename);
|
||
if (content === null) return;
|
||
const bare = _clientBareFileName(filename);
|
||
const existing = codeFiles.find(f => f.name === bare);
|
||
if (existing) switchCodeFile(existing.id);
|
||
else codeNewFile(bare, content);
|
||
} catch {}
|
||
const dd = document.getElementById('code-folder-dropdown');
|
||
if (dd) dd.style.display = 'none';
|
||
codeFolderOpen = false;
|
||
}
|
||
|
||
async function codeDeleteFromFolder(filename, ev) {
|
||
ev.stopPropagation();
|
||
if (!localDirHandle || !confirm(`"${filename}" 파일을 삭제할까요?`)) return;
|
||
try {
|
||
const ok = await localDeleteFile(filename);
|
||
if (ok) {
|
||
const bare = _clientBareFileName(filename);
|
||
const existing = codeFiles.find(f => f.name === bare);
|
||
if (existing) closeCodeFile(existing.id);
|
||
codeRefreshFolder();
|
||
}
|
||
} catch(e) {
|
||
alert('삭제 실패: ' + e.message);
|
||
}
|
||
}
|
||
|
||
// Close folder dropdown when clicking outside
|
||
document.addEventListener('click', e => {
|
||
if (!codeFolderOpen) return;
|
||
const dd = document.getElementById('code-folder-dropdown');
|
||
const btn = document.getElementById('code-folder-btn');
|
||
if (dd && !dd.contains(e.target) && btn && !btn.contains(e.target)) {
|
||
dd.style.display = 'none'; codeFolderOpen = false;
|
||
}
|
||
});
|
||
|
||
// ── Vertical resize handle between editor and AI panel ────────────────────
|
||
function setupCodeEditorDrop() {
|
||
const container = document.getElementById('code-editor-container');
|
||
if (!container) return;
|
||
container.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; container.style.outline = '2px solid var(--brand)'; });
|
||
container.addEventListener('dragleave', () => { container.style.outline = ''; });
|
||
container.addEventListener('drop', e => {
|
||
e.preventDefault();
|
||
container.style.outline = '';
|
||
const files = Array.from(e.dataTransfer.files);
|
||
if (!files.length) return;
|
||
files.forEach(file => {
|
||
const reader = new FileReader();
|
||
reader.onload = ev => {
|
||
const content = ev.target.result;
|
||
const existing = codeFiles.find(f => f.name === file.name);
|
||
if (existing) { existing.model.setValue(content); switchCodeFile(existing.id); }
|
||
else codeNewFile(file.name, content);
|
||
};
|
||
reader.readAsText(file);
|
||
});
|
||
});
|
||
}
|
||
|
||
function setupCodeVResize() {
|
||
const handle = document.getElementById('code-vresize');
|
||
const aiPanel = document.getElementById('code-ai-panel');
|
||
if (!handle || !aiPanel) return;
|
||
const modelCard = document.getElementById('code-ai-model-card');
|
||
const syncModelCard = w => { if (modelCard) modelCard.style.width = w + 'px'; };
|
||
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';
|
||
syncModelCard(newW);
|
||
if (monacoEditor) monacoEditor.layout();
|
||
}
|
||
function onVUp() {
|
||
document.removeEventListener('mousemove', onVMove);
|
||
document.removeEventListener('mouseup', onVUp);
|
||
}
|
||
}
|
||
|
||
|
||
// ── Standalone bootstrap ────────────────────────────────────────────────────
|
||
// In the main app these are triggered by setMode('code') / setSidebarTab('code').
|
||
// Here we run them once the DOM is ready since this page IS the code view.
|
||
(function bootCodePage() {
|
||
async function boot() {
|
||
try { setCodeSbTab('files'); } catch (e) {}
|
||
try { initCodeView(); } catch (e) { console.error('[code] initCodeView failed', e); }
|
||
try { csbLoadModels(); } catch (e) {}
|
||
try { csbLoadCodeDir(); } catch (e) {}
|
||
try { updateLocalModeUI(); } catch (e) {}
|
||
try { const inp = document.getElementById('csb-termpath'); if (inp && localTermPath) inp.value = localTermPath; } catch (e) {}
|
||
try { await restoreLocalDirHandle(); } catch (e) {}
|
||
try { updateSessionPanel(); } catch (e) {}
|
||
try { updateCodeAiModeBtn(); } catch (e) {}
|
||
try { _fetchModelCtxMax(codeAiModel); } catch (e) {}
|
||
try { const p = document.getElementById('code-ai-panel'), c = document.getElementById('code-ai-model-card'); if (p && c) c.style.width = p.offsetWidth + 'px'; } catch (e) {}
|
||
try { codeSbRefresh(); } catch (e) {}
|
||
try { companionConnect(); } catch (e) {}
|
||
// 히스토리 없으면 세션 목록 표시
|
||
try { if (!codeAiHistory.length) _renderSessionsList(); } catch (e) {}
|
||
}
|
||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, { once: true });
|
||
else boot();
|
||
})();
|