diff --git a/ide/electron/main.cjs b/ide/electron/main.cjs index 1ba5284..f445f85 100644 --- a/ide/electron/main.cjs +++ b/ide/electron/main.cjs @@ -833,6 +833,7 @@ ipcMain.handle("schutz:replaceInFiles", async (_e, root, query, replacement, opt const inc = globToMatcher(opts?.include); const exc = globToMatcher(opts?.exclude); let changed = 0, files = 0; + const skipped = []; // UTF-8 이 아니라 건드리지 않은 파일 async function walk(dirAbs, relBase, depth) { if (depth > MAX_DEPTH) return; let items; @@ -850,8 +851,14 @@ ipcMain.handle("schutz:replaceInFiles", async (_e, root, query, replacement, opt const abs = path.join(dirAbs, it.name); let st; try { st = await fs.stat(abs); } catch { continue; } if (st.size > MAX_FILE_BYTES) continue; - let text; try { text = await fs.readFile(abs, "utf8"); } catch { continue; } - if (text.indexOf(String.fromCharCode(0)) >= 0) continue; + let buf; try { buf = await fs.readFile(abs); } catch { continue; } + // UTF-8 이 아니면 손대지 않는다. 여기가 readFile 보다 위험하다 — 사용자가 열어 + // 보지도 않은 파일을 훑으며 쓰기 때문에, CP949 로 된 파일 하나가 치환 한 번에 + // 조용히 파괴됐다(26바이트 → 39바이트, 한글이 전부 U+FFFD 로). 성공 토스트까지 + // 떴다. 무엇을 건너뛰었는지는 아래에서 돌려준다 — 조용히 빼면 "다 됐다" 로 읽힌다. + const kind = encoding.detect(buf); + if (kind) { skipped.push(rel); continue; } + const text = buf.toString("utf8"); re.lastIndex = 0; const matches = text.match(re); if (!matches || !matches.length) continue; @@ -864,9 +871,9 @@ ipcMain.handle("schutz:replaceInFiles", async (_e, root, query, replacement, opt } // walk 가 중간에 죽으면 부분 결과가 남는다 — 성공인 척하지 말고 어디까지 됐는지 알린다 try { await walk(root, "", 0); } catch (e) { - return { changed, files, partial: true, error: e && e.message ? e.message : String(e) }; + return { changed, files, skipped, partial: true, error: e && e.message ? e.message : String(e) }; } - return { changed, files }; + return { changed, files, skipped }; }); // ── 에이전트 명령 실행 ───────────────────────────────────────────────────── diff --git a/ide/src/App.tsx b/ide/src/App.tsx index 87ca412..f1be11a 100644 --- a/ide/src/App.tsx +++ b/ide/src/App.tsx @@ -5259,6 +5259,11 @@ ${(r.output || "").slice(0, 2000)}`; if (r.error) { this.toast("error", t("sc3.replaceFailed") + r.error); return; } if (r.partial) this.toast("error", t("sc3.replacePartial", { files: r.files, changed: r.changed })); else this.toast("ok", t("sc3.replaceResult", { files: r.files, changed: r.changed })); + // UTF-8 이 아니라 건너뛴 파일은 반드시 말한다. 안 말하면 "전부 바꿨다" 로 읽히고, + // 그 파일들만 옛 이름이 남아 나중에 빌드가 깨진 뒤에야 알게 된다. + if (r.skipped?.length) { + this.toast("error", t("enc.replaceSkipped", { n: r.skipped.length, files: r.skipped.slice(0, 6).join(", ") })); + } // 모든 non-dirty owned 모델 재로드 — 열린 탭뿐 아니라 preload(닫힌) 모델도 디스크 반영(#8: 나중에 열면 stale 방지). dirty 는 위에서 차단됨. void projectModels.reloadAll(ws.root, (r, rel) => window.schutz!.readFile(r, rel), this.isDirtyRel); this.setState(st => { const pv = { ...st.paneVer }; for (const p of this.allOpen(st)) pv[p] = (pv[p] ?? 0) + 1; return { paneVer: pv }; }); diff --git a/ide/src/i18n/dict/enc.ts b/ide/src/i18n/dict/enc.ts index 5dadbf8..21f3c12 100644 --- a/ide/src/i18n/dict/enc.ts +++ b/ide/src/i18n/dict/enc.ts @@ -5,5 +5,7 @@ export const dict: Record = { "enc.notUtf8": { ko: "이 파일은 UTF-8 이 아닙니다. 열면 글자가 깨지고 저장하면 원본이 사라지므로 열지 않았습니다. UTF-8 로 변환한 뒤 다시 열어 주세요.", en: "This file is not UTF-8. Opening it would garble the text and saving would destroy the original, so it was not opened. Convert it to UTF-8 and try again.", de: "Diese Datei ist nicht UTF-8. Das Öffnen würde den Text zerstören und das Speichern das Original vernichten — sie wurde nicht geöffnet. Bitte in UTF-8 konvertieren.", ja: "このファイルは UTF-8 ではありません。開くと文字化けし、保存すると原本が失われるため開きませんでした。UTF-8 に変換してから開いてください。" }, "enc.utf16": { ko: "이 파일은 UTF-16 입니다. 아직 UTF-8 만 다룰 수 있어 열지 않았습니다 — 열었다면 저장하는 순간 원본이 사라졌을 것입니다.", en: "This file is UTF-16. Only UTF-8 is supported, so it was not opened — saving it would have destroyed the original.", de: "Diese Datei ist UTF-16. Es wird nur UTF-8 unterstützt, daher wurde sie nicht geöffnet — beim Speichern wäre das Original verloren gegangen.", ja: "このファイルは UTF-16 です。現在 UTF-8 のみ対応のため開きませんでした — 保存していれば原本が失われていました。" }, + // 찾아 바꾸기가 건너뛴 파일 — 조용히 빼면 "전부 바꿨다" 로 읽힌다. + "enc.replaceSkipped": { ko: "UTF-8 이 아니라 건드리지 않은 파일 {n}개: {files}", en: "{n} file(s) left untouched because they are not UTF-8: {files}", de: "{n} Datei(en) unberührt gelassen, da nicht UTF-8: {files}", ja: "UTF-8 ではないため触れなかったファイル {n} 件: {files}" }, "enc.binary": { ko: "텍스트 파일이 아닙니다(NUL 바이트가 있습니다). 편집기로 열면 내용이 망가집니다.", en: "This is not a text file (it contains NUL bytes). Opening it in the editor would corrupt it.", de: "Das ist keine Textdatei (sie enthält NUL-Bytes). Im Editor zu öffnen würde sie beschädigen.", ja: "テキストファイルではありません(NUL バイトがあります)。エディターで開くと壊れます。" }, }; diff --git a/ide/src/schutz.d.ts b/ide/src/schutz.d.ts index e7c7b6c..dee527b 100644 --- a/ide/src/schutz.d.ts +++ b/ide/src/schutz.d.ts @@ -214,8 +214,9 @@ interface SchutzApi { Promise<{ ok: boolean; quota?: QuotaInfo; error?: string }>; onQuota(cb: (line: string) => void): () => void; - /** error=정규식 거부 등으로 아무것도 안 함 · partial=도중 실패해 일부만 적용됨 */ - replaceInFiles(root: string, query: string, replacement: string, opts?: any): Promise<{ changed: number; files: number; error?: string; partial?: boolean }>; + /** error=정규식 거부 등으로 아무것도 안 함 · partial=도중 실패해 일부만 적용됨 + * skipped=UTF-8 이 아니라 건드리지 않은 파일(쓰면 원본이 파괴된다) */ + replaceInFiles(root: string, query: string, replacement: string, opts?: any): Promise<{ changed: number; files: number; error?: string; partial?: boolean; skipped?: string[] }>; cliCheck(): Promise<{ agents: Record }>; agentCommands(root: string | null): Promise<{ commands: { name: string; origin: "claude" | "codex"; scope: "user" | "project"; description: string; argHint: string; body: string }[] }>; cliChatCounts(): Promise<{ counts: Record }>;