feat: 코드 에디터에서 서버 워크스페이스로 작업 (업로드는 계속 로컬)
에디터는 File System Access API로 브라우저가 고른 로컬 폴더만 읽고 썼다. 그래서 서버에서 파일을 고쳐도 에디터에는 닿지 않는다 — 2026-08-15에 실제로 서버 사본을 고쳐놓고 보드에는 에디터의 다른 파일이 올라가, 같은 스케치가 두 벌로 갈린 채 한참을 헤맸다. 서버 API(/api/code/files, /api/code/file, /api/code/save)는 이미 다 구현돼 있었는데 프런트가 한 번도 부르지 않고 있었다. 코드에 "In server mode with activeProjectName" 같은 주석만 남아 있는 걸로 보아 예전에 만들려다 만 흔적이다. 호출부가 15곳이라 그쪽을 다 고치는 대신 localReadFile/localWriteFile/localListFiles/ localDeleteFile 네 개 원시함수만 서버로 분기시켰다. 로컬 폴더 유무로 막던 가드 9곳은 codeStorageReady()로 바꿔 서버 모드에서도 통과한다. 업로드는 그대로 로컬이다. Web Serial은 브라우저에서만 가능하지만, 굽는 대상이 에디터 버퍼(_cupCode)라 파일이 어디서 왔든 상관없다 — 저장 위치만 옮겨간다. 로컬 PTY 셸(1822행)과 Bash 도구(3140행)는 성격상 로컬 전용이라 제외했다. 함께 고친 백엔드 결함: POST /api/code/save가 path.basename()으로 경로를 납작하게 만들어 `projA/hello.ino`와 `projB/hello.ino`가 같은 `hello.ino`로 떨어졌다. 읽기는 이미 하위 경로를 지원하고 있어서 읽은 곳과 쓴 곳이 어긋나는 상태였다. resolve + 접두사 검사로 탈출만 막고 하위 경로는 허용하도록 수정. 실측: projA/projB에 같은 파일명으로 저장 → 서로 덮어쓰지 않음, 목록에 경로 그대로 노출, `../../../tmp/pwned.txt`는 403. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -28,11 +28,18 @@ export function registerCodeRoutes(app: express.Application): void {
|
||||
res.status(400).json({ success: false, error: 'filename required' }); return;
|
||||
}
|
||||
const codeDir = getCodeDir(req);
|
||||
const safeName = path.basename(filename);
|
||||
const target = path.join(codeDir, safeName);
|
||||
fs.mkdirSync(codeDir, { recursive: true });
|
||||
// basename()으로 납작하게 만들면 `proj/a.ino`가 `a.ino`로 떨어져 다른 프로젝트의
|
||||
// 같은 이름 파일을 조용히 덮어쓴다. 읽기(GET /api/code/file)는 이미 하위 경로를
|
||||
// 지원하므로 쓰기만 다르게 두면 읽은 곳과 쓴 곳이 어긋난다.
|
||||
// resolve + 접두사 검사로 탈출만 막고 하위 경로는 허용한다.
|
||||
const target = path.resolve(codeDir, filename);
|
||||
if (target !== codeDir && !target.startsWith(codeDir + path.sep)) {
|
||||
res.status(403).json({ success: false, error: 'Access denied' }); return;
|
||||
}
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, content ?? '', 'utf-8');
|
||||
res.json({ success: true, path: `code/${safeName}`, size: Buffer.byteLength(content ?? '', 'utf-8') });
|
||||
const rel = path.relative(codeDir, target).split(path.sep).join('/');
|
||||
res.json({ success: true, path: `code/${rel}`, size: Buffer.byteLength(content ?? '', 'utf-8') });
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ success: false, error: String(e?.message || e) });
|
||||
}
|
||||
|
||||
@@ -255,6 +255,7 @@
|
||||
<span id="csb-folder-label" style="font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em">폴더 없음</span>
|
||||
<div style="display:flex;gap:4px">
|
||||
<button id="code-local-btn" onclick="openLocalFolder()" title="로컬 폴더 열기" style="background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 3px">🖥️</button>
|
||||
<button id="code-storage-btn" onclick="codeToggleStorageMode()" title="저장 위치: 로컬 폴더 ↔ 서버 워크스페이스" style="background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 3px">☁️</button>
|
||||
<button onclick="codeSaveFile()" title="파일 저장 (Ctrl+S)" style="background:none;border:none;cursor:pointer;color:var(--muted);font-size:13px;line-height:1;padding:0 3px">💾</button>
|
||||
<button onclick="codeNewFile()" title="새 파일" style="background:none;border:none;cursor:pointer;color:var(--muted);font-size:16px;line-height:1;padding:0 3px">+</button>
|
||||
<button onclick="csbNewFolderRoot()" title="새 폴더" style="background:none;border:none;cursor:pointer;color:var(--muted);font-size:13px;line-height:1;padding:0 3px">📁</button>
|
||||
|
||||
@@ -340,29 +340,100 @@ async function openLocalFolder() {
|
||||
}
|
||||
|
||||
function updateLocalModeUI() {
|
||||
codeUpdateStorageBtn();
|
||||
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 || '로컬') : '폴더 없음';
|
||||
// 서버 모드에서는 로컬 폴더가 없어도 "폴더 없음"이 아니라 어디에 저장되는지를 보여준다.
|
||||
const folderName = localDirHandle ? (localDirHandle.name || '로컬')
|
||||
: (codeIsServerMode() ? '서버 워크스페이스' : '폴더 없음');
|
||||
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 ready = codeStorageReady();
|
||||
if (lbl) lbl.textContent = ready ? folderName : '폴더 없음';
|
||||
if (folderBtn) { const s = folderBtn.querySelector('span'); if (s) s.textContent = ready ? folderName : '폴더 없음'; }
|
||||
const hdr = document.getElementById('code-header-dir');
|
||||
if (hdr) hdr.textContent = localDirHandle ? (activeProjectName || folderName) : '폴더 없음';
|
||||
if (hdr) hdr.textContent = ready ? (activeProjectName || folderName) : '폴더 없음';
|
||||
const dd = document.getElementById('code-dropdown-dir');
|
||||
if (dd) dd.textContent = localDirHandle ? `📁 ${folderName}` : '📁 폴더 없음';
|
||||
if (dd) dd.textContent = ready ? `📁 ${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
|
||||
// ── 저장 위치: 로컬 폴더 vs 서버 워크스페이스 ────────────────────────────────
|
||||
// 에디터는 File System Access API로 브라우저가 고른 로컬 폴더만 읽고 썼다. 그래서 서버에서
|
||||
// 파일을 고쳐도 에디터에는 닿지 않고, 같은 스케치가 두 벌로 갈리는 일이 실제로 벌어졌다
|
||||
// (2026-08-15: 서버 사본을 고쳐놨는데 보드에 올라간 건 에디터의 다른 파일이었다).
|
||||
// 서버 쪽 API(/api/code/*)는 이미 다 있었는데 프런트가 한 번도 부르지 않고 있었다.
|
||||
//
|
||||
// 호출부가 15곳이라 그쪽을 다 고치는 대신, 아래 네 개 원시함수만 분기시킨다.
|
||||
// 업로드는 Web Serial이라 브라우저에서만 가능한데, 에디터 버퍼(_cupCode)를 굽는 구조라
|
||||
// 파일이 어디서 왔든 상관없다 — 저장 위치를 서버로 옮겨도 업로드는 그대로 로컬이다.
|
||||
let codeStorageMode = 'local';
|
||||
try { codeStorageMode = localStorage.getItem('code_storage_mode') || 'local'; } catch {}
|
||||
const codeIsServerMode = () => codeStorageMode === 'server';
|
||||
/** 파일 작업이 가능한 상태인가 — 로컬은 폴더를 골라야 하고, 서버는 항상 가능하다. */
|
||||
const codeStorageReady = () => codeIsServerMode() || !!localDirHandle;
|
||||
|
||||
async function codeServerList() {
|
||||
try {
|
||||
const d = await api('/api/code/files');
|
||||
return (d?.files || []).slice().sort();
|
||||
} catch { return []; }
|
||||
}
|
||||
async function codeServerRead(filename) {
|
||||
try {
|
||||
const d = await api('/api/code/file?name=' + encodeURIComponent(filename));
|
||||
return d?.success ? d.content : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
async function codeServerWrite(filename, content) {
|
||||
try {
|
||||
const d = await api('/api/code/save', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ filename, content }),
|
||||
});
|
||||
return !!d?.success;
|
||||
} catch { return false; }
|
||||
}
|
||||
async function codeServerDelete(filename) {
|
||||
try {
|
||||
const d = await api('/api/code/file?name=' + encodeURIComponent(filename), { method: 'DELETE' });
|
||||
return !!d?.success;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
function codeUpdateStorageBtn() {
|
||||
const b = document.getElementById('code-storage-btn');
|
||||
if (!b) return;
|
||||
const server = codeIsServerMode();
|
||||
b.textContent = server ? '☁️' : '💾';
|
||||
b.style.color = server ? '#22c55e' : '';
|
||||
b.title = server
|
||||
? '저장 위치: 서버 워크스페이스 (클릭하면 로컬 폴더로)'
|
||||
: '저장 위치: 로컬 폴더 (클릭하면 서버 워크스페이스로)';
|
||||
}
|
||||
|
||||
function codeToggleStorageMode() {
|
||||
codeStorageMode = codeIsServerMode() ? 'local' : 'server';
|
||||
try { localStorage.setItem('code_storage_mode', codeStorageMode); } catch {}
|
||||
codeUpdateStorageBtn();
|
||||
codeAiAppend('system', codeIsServerMode()
|
||||
? '☁️ 저장 위치를 서버 워크스페이스로 바꿨습니다. 열려 있는 탭은 그대로이니, 저장하면 서버에 씁니다.'
|
||||
: '💾 저장 위치를 로컬 폴더로 되돌렸습니다.');
|
||||
codeSbRefresh();
|
||||
}
|
||||
|
||||
async function localListFiles(dirHandle, prefix) {
|
||||
// 서버 모드에서는 dirHandle이 없으므로 인자를 무시한다(호출부를 안 바꾸기 위한 절충).
|
||||
if (codeIsServerMode()) return await codeServerList();
|
||||
if (!dirHandle) return [];
|
||||
const files = [];
|
||||
try {
|
||||
for await (const [name, handle] of dirHandle.entries()) {
|
||||
@@ -378,6 +449,7 @@ async function localListFiles(dirHandle, prefix) {
|
||||
}
|
||||
|
||||
async function localReadFile(filename) {
|
||||
if (codeIsServerMode()) return await codeServerRead(filename);
|
||||
if (!localDirHandle) return null;
|
||||
try {
|
||||
const parts = filename.split('/');
|
||||
@@ -389,6 +461,7 @@ async function localReadFile(filename) {
|
||||
}
|
||||
|
||||
async function localWriteFile(filename, content) {
|
||||
if (codeIsServerMode()) return await codeServerWrite(filename, content);
|
||||
if (!localDirHandle) return false;
|
||||
try {
|
||||
const parts = filename.split('/');
|
||||
@@ -403,6 +476,7 @@ async function localWriteFile(filename, content) {
|
||||
}
|
||||
|
||||
async function localDeleteFile(filename) {
|
||||
if (codeIsServerMode()) return await codeServerDelete(filename);
|
||||
if (!localDirHandle) return false;
|
||||
try {
|
||||
const parts = filename.split('/');
|
||||
@@ -891,7 +965,7 @@ async function codeSaveFile() {
|
||||
return;
|
||||
}
|
||||
file.content = monacoEditor.getValue();
|
||||
if (!localDirHandle) { codeAiAppend('system', '⚠️ 폴더를 먼저 선택해주세요 (🖥️ 버튼)'); return; }
|
||||
if (!codeStorageReady()) { codeAiAppend('system', '⚠️ 저장 위치를 먼저 정해주세요 — 🖥️ 버튼으로 로컬 폴더를 열거나 서버 모드로 전환하세요'); return; }
|
||||
const ok = await localWriteFile(_clientProjectPath(file.name), file.content);
|
||||
codeAiAppend('system', ok ? `💾 저장: ${file.name}` : '⚠️ 저장 실패');
|
||||
if (ok) codeSbRefresh();
|
||||
@@ -2592,7 +2666,7 @@ async function _codeAiSendImpl() {
|
||||
// @파일명 참조 처리 — 메시지에서 @xxx.js 패턴을 찾아 파일 내용 삽입
|
||||
let resolvedMessage = message;
|
||||
const atInlinedFiles = new Set(); // track files already inlined via @ref
|
||||
if (localDirHandle) {
|
||||
if (codeStorageReady()) {
|
||||
const atRefs = [...message.matchAll(/@([\w./-]+\.\w+)/g)];
|
||||
for (const m of atRefs) {
|
||||
const fname = activeProjectName ? `${activeProjectName}/${m[1]}` : m[1];
|
||||
@@ -2658,7 +2732,7 @@ async function _codeAiSendImpl() {
|
||||
addFile(cachedName, cachedContent);
|
||||
}
|
||||
// 2. Files on disk not yet opened as tabs — manifest only (Claude Code 방식: 목록만 주고 필요 시 coder_read_file로 요청)
|
||||
if (localDirHandle) {
|
||||
if (codeStorageReady()) {
|
||||
try {
|
||||
const allFiles = await localListFiles(localDirHandle, '');
|
||||
const projFiles = activeProjectName
|
||||
@@ -3026,7 +3100,7 @@ async function _executeToolLocal(name, args) {
|
||||
if (fname.startsWith('#!') || /^\//.test(fname)) return { result: `오류: 유효하지 않은 파일명: ${fname.slice(0, 60)}`, error: true };
|
||||
const bare = _clientBareFileName(fname);
|
||||
const diskPath = _clientProjectPath(bare);
|
||||
if (localDirHandle) await localWriteFile(diskPath, newContent);
|
||||
if (codeStorageReady()) await localWriteFile(diskPath, newContent);
|
||||
codeEditorWriteFile(bare, newContent);
|
||||
_readFileCache[bare] = newContent;
|
||||
return { result: `${bare} 저장 완료 (${newContent.split('\n').length}줄)`, error: false };
|
||||
@@ -3046,14 +3120,14 @@ async function _executeToolLocal(name, args) {
|
||||
}
|
||||
const updated = existing.replace(oldStr, newStr);
|
||||
const diskPath = _clientProjectPath(bare);
|
||||
if (localDirHandle) await localWriteFile(diskPath, updated);
|
||||
if (codeStorageReady()) await localWriteFile(diskPath, updated);
|
||||
codeEditorWriteFile(bare, updated);
|
||||
_readFileCache[bare] = updated;
|
||||
return { result: `${bare} 수정 완료`, error: false };
|
||||
}
|
||||
case 'Glob': {
|
||||
const pattern = args.pattern || '**/*';
|
||||
if (!localDirHandle) return { result: '(로컬 폴더가 연결되지 않았습니다)', error: false };
|
||||
if (!codeStorageReady()) return { result: '(저장 위치가 설정되지 않았습니다 — 로컬 폴더 또는 서버 모드)', error: false };
|
||||
const allFiles = await localListFiles(localDirHandle, '');
|
||||
const matches = allFiles.filter(f => _globMatch(pattern, f));
|
||||
return { result: matches.length ? matches.join('\n') : '(매칭 파일 없음)', error: false };
|
||||
@@ -3282,7 +3356,7 @@ async function _codeAiReadLocalFile(fname) {
|
||||
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) {
|
||||
if (codeStorageReady()) {
|
||||
const content = await localReadFile(_clientProjectPath(bare));
|
||||
if (content !== null) return content;
|
||||
// Fallback: bare path (covers root-level files when no activeProjectName)
|
||||
@@ -4224,8 +4298,8 @@ async function codeSaveFileById(fileId, hunkCount) {
|
||||
if (!file) return;
|
||||
const content = (file.model ? file.model.getValue() : file.content) || '';
|
||||
file.content = content;
|
||||
if (!localDirHandle) {
|
||||
codeAiAppend('system', `⚠️ 폴더 미선택 — ${file.name} 저장 생략. 🖥️ 버튼으로 로컬 폴더를 선택해주세요.`);
|
||||
if (!codeStorageReady()) {
|
||||
codeAiAppend('system', `⚠️ 저장 위치 미설정 — ${file.name} 저장 생략. 🖥️ 버튼으로 로컬 폴더를 열거나 서버 모드로 전환하세요.`);
|
||||
return;
|
||||
}
|
||||
const ok = await localWriteFile(_clientProjectPath(file.name), content);
|
||||
@@ -4296,7 +4370,7 @@ 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) {
|
||||
if (codeStorageReady()) {
|
||||
const content = file.model ? file.model.getValue() : (file.content || '');
|
||||
localWriteFile(_clientProjectPath(file.name), content).then(ok => {
|
||||
if (ok) { codeSbRefresh(); codeAiAppend('system', `💾 저장: ${file.name}`); }
|
||||
|
||||
Reference in New Issue
Block a user