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:
@@ -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 → `'`를 `'`로 바꾸지만 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, '&').replace(/"/g, '"') // HTML 속성 층
|
||||
.replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
function escHtml(s) {
|
||||
return String(s || '').replace(/&/g,'&').replace(/\\/g,'\').replace(/`/g,'`').replace(/\$/g,'$').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
@@ -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}')">
|
||||
|
||||
Reference in New Issue
Block a user