fix: 코드앱 전체 점검 — 조용한 실패 3건 + 이스케이프 2층 누락

전체 점검에서 나온 것들. 앞선 커밋들과 달리 서버 모드 전용 문제가 아니라
로컬 모드에도 있던 버그다.

1) Write/Edit 툴이 쓰기 결과를 확인하지 않고 "저장 완료"를 돌려줬다.
   실패해도 모델은 파일이 생긴 줄 알고 다음 단계로 넘어간다 — 디스크에
   없는 파일을 Edit하려다 엉키거나, 다 끝났다고 보고해버린다.

2) 이름 변경이 파일을 잃을 수 있었다. 복사 후 삭제 구조인데 복사 성공을
   안 보고 원본을 지웠다. 새 경로에 못 썼는데 원본이 사라지면 파일이
   없어진다. 디렉토리 쪽은 삭제가 재귀라 더 나빴다. 이제 전부 옮겨진 게
   확인돼야만 원본을 지운다.

3) deleteActiveProject가 localStorage를 먼저 지우고 폴더 삭제를 나중에
   했다. 삭제가 실패하면 파일은 디스크에 남았는데 앱에서는 접근할 길이
   없는 유령 폴더가 된다. 순서를 뒤집어 성공했을 때만 정리한다.

4) `onclick="fn('${...}')"`은 HTML 속성과 JS 문자열 **두 층**을 순서대로
   통과하는데(브라우저가 엔티티를 먼저 풀고 그 결과를 JS가 읽는다),
   기존 헬퍼 둘이 각각 반쪽이었다:
     _csbEsc → `'`를 `'`로 바꾸지만 HTML이 도로 `'`로 풀어 JS 문자열이 끊김
     safe    → `\'`로 JS는 막지만 `"`가 그대로라 속성이 조기 종료
   escAttrJs()로 JS 이스케이프 → HTML 이스케이프 순서를 지킨다. 따옴표가
   든 파일명(it's.py, say"hi".py)은 리눅스에서 합법이라 실제로 만들어진다.
   5개 케이스가 두 층을 통과해 원본으로 복원되는 것과, 서버 쪽 쓰기·목록·
   읽기가 정상인 것을 확인했다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-08-18 11:45:13 +09:00
co-authored by Claude Opus 5
parent 3a8438d7a7
commit 1f4c0ae0ee
+41 -10
View File
@@ -23,6 +23,19 @@ function getCookie(name) {
if (parts.length === 2) return parts.pop().split(';').shift();
return '';
}
// `onclick="fn('${...}')"` 처럼 **HTML 속성 안의 JS 문자열**에 값을 끼울 때 쓴다.
// 이 자리는 두 층을 순서대로 통과한다: 브라우저가 HTML 엔티티를 먼저 풀고, 그 결과를 JS가 읽는다.
// 그래서 한 층만 막으면 다른 층에서 새는데, 기존 헬퍼 둘이 정확히 그 상태였다:
// escHtml/_csbEsc → `'`를 `&#39;`로 바꾸지만 HTML이 도로 `'`로 풀어줘서 JS 문자열이 끊긴다
// safe(수동) → `\'`로 JS는 막지만 `"`가 그대로라 속성이 조기 종료된다
// JS 이스케이프를 먼저 하고(백슬래시·작은따옴표), 그 위에 HTML 이스케이프를 얹어야 맞다.
// 따옴표가 든 파일명(`it's.py`, `say"hi".py`)은 리눅스에서 합법이라 실제로 만들어질 수 있다.
function escAttrJs(s) {
return String(s ?? '')
.replace(/\\/g, '\\\\').replace(/'/g, "\\'") // JS 문자열 층
.replace(/&/g, '&amp;').replace(/"/g, '&quot;') // HTML 속성 층
.replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function escHtml(s) {
return String(s || '').replace(/&/g,'&amp;').replace(/\\/g,'&#92;').replace(/`/g,'&#96;').replace(/\$/g,'&#36;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
@@ -3194,7 +3207,12 @@ 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 (codeStorageReady()) await localWriteFile(diskPath, newContent);
// 쓰기 결과를 확인하지 않고 "저장 완료"를 돌려주면, 실패했는데도 모델은 파일이
// 생긴 줄 알고 다음 단계로 넘어간다. 디스크에 없는 파일을 Edit하려다 엉키거나,
// 다 끝났다고 보고해버린다.
if (codeStorageReady() && !await localWriteFile(diskPath, newContent)) {
return { result: `오류: ${bare} 저장 실패 (${codeIsServerMode() ? '서버' : '로컬 폴더'}에 쓰지 못했습니다)`, error: true };
}
codeEditorWriteFile(bare, newContent);
_readFileCache[bare] = newContent;
return { result: `${bare} 저장 완료 (${newContent.split('\n').length}줄)`, error: false };
@@ -3214,7 +3232,9 @@ async function _executeToolLocal(name, args) {
}
const updated = existing.replace(oldStr, newStr);
const diskPath = _clientProjectPath(bare);
if (codeStorageReady()) await localWriteFile(diskPath, updated);
if (codeStorageReady() && !await localWriteFile(diskPath, updated)) {
return { result: `오류: ${bare} 저장 실패 (${codeIsServerMode() ? '서버' : '로컬 폴더'}에 쓰지 못했습니다)`, error: true };
}
codeEditorWriteFile(bare, updated);
_readFileCache[bare] = updated;
return { result: `${bare} 수정 완료`, error: false };
@@ -4603,12 +4623,16 @@ async function deleteActiveProject() {
if (!activeProjectName) return;
if (!confirm(`"${activeProjectName}" 프로젝트를 삭제할까요?\n(폴더와 파일이 모두 삭제됩니다)`)) return;
const name = activeProjectName;
// localStorage 정리
// 폴더 삭제를 **먼저** 하고, 성공했을 때만 localStorage와 화면 상태를 지운다.
// 순서가 반대였을 때는 삭제가 실패해도 프로젝트가 목록에서 사라져서, 파일은 디스크에
// 남았는데 앱에서는 접근할 길이 없는 유령 폴더가 됐다.
if (!await projectRemoveDir(name)) {
alert(`⚠️ "${name}" 폴더를 삭제하지 못했습니다. 프로젝트를 그대로 둡니다.`);
return;
}
_knownProjectsRemove(name);
localStorage.removeItem(_projStoreKey('session', name));
localStorage.removeItem(_projStoreKey('history', name));
// 프로젝트 폴더 삭제 (서버/로컬 공통)
await projectRemoveDir(name);
// 탭 닫기 및 프로젝트 초기화
codeFiles.forEach(f => { try { f.model?.dispose(); } catch {} });
codeFiles.length = 0; activeCodeFileId = null; codeDiffState = {};
@@ -5816,7 +5840,7 @@ async function codeSbRefresh() {
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,"\\'");
const safeDirPath = escAttrJs(dirPath);
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)"
@@ -5835,7 +5859,7 @@ async function codeSbRefresh() {
for (const [name, fullPath] of leafs) {
const ext = name.split('.').pop().toLowerCase();
const icon = extIcon[ext] || '📄';
const safe = fullPath.replace(/\\/g,'\\\\').replace(/'/g,"\\'");
const safe = escAttrJs(fullPath);
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'"
@@ -6007,7 +6031,9 @@ async function csbRenameExec(oldPath, newName, isDir) {
if (!isDir) {
const content = await localReadFile(oldPath);
if (content === null) throw new Error('읽기 실패');
await localWriteFile(newPath, content);
// 이름 변경은 복사 후 삭제다. 쓰기 결과를 안 보고 지우면 **파일이 사라진다** —
// 새 경로에는 안 써졌는데 원본은 없어진다. 쓰기가 확인돼야만 원본을 지운다.
if (!await localWriteFile(newPath, content)) throw new Error('새 이름으로 쓰지 못했습니다 (원본은 그대로 둡니다)');
await localDeleteFile(oldPath);
const bare = _clientBareFileName(oldPath);
const tab = codeFiles.find(f => f.name === bare);
@@ -6015,9 +6041,14 @@ async function csbRenameExec(oldPath, newName, isDir) {
} 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);
if (content === null) throw new Error(`${f} 읽기 실패 (원본은 그대로 둡니다)`);
if (!await localWriteFile(newPath + '/' + f.slice(prefix.length), content)) {
throw new Error(`${f} 이동 실패 (원본은 그대로 둡니다)`);
}
}
// 예전엔 서버에 디렉토리 삭제 API가 없어서 파일만 하나씩 지웠고, 빈 원본 디렉토리가
// 디스크에 그대로 남았다(트리는 파일 기준이라 안 보였을 뿐). 이제 /api/code/dir이 있다.
@@ -6522,7 +6553,7 @@ async function codeRefreshFolder() {
go:'🔵', rs:'🦀', java:'☕', html:'🌐', css:'🎨', sh:'💻',
kt:'🎯', swift:'🍎', rb:'💎', php:'🐘', sql:'🗃️', md:'📝',
}[ext] || '📄';
const safe = f.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
const safe = escAttrJs(f);
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}')">