Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions ide/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -813,7 +813,9 @@ export class App extends React.Component<{ playOpening?: boolean }, S> {
if (silent || !await this.askConfirm({ title: t("confirm.overwriteTitle"), body: t("sc1.externalChangedOverwrite", { rel }), okLabel: t("confirm.overwriteOk"), danger: true })) { failed.push(rel + " (" + t("sc1.externalChangedSkipped") + ")"); continue; }
}
try {
await window.schutz.writeFile(ws.root, rel, content);
// BOM 은 본문이 아니라 파일의 표식이라 모델 밖에 있다 — 도로 붙여 써야
// 손대지 않은 표식이 조용히 사라지지 않는다. 기준선은 BOM 없는 값으로 둔다.
await window.schutz.writeFile(ws.root, rel, projectModels.diskText(rel) ?? content);
projectModels.markSaved(ws.root, rel, content);
this.notifySaved(rel);
this.setState(st => ({ paneDirty: { ...st.paneDirty, [rel]: false } }));
Expand Down Expand Up @@ -1916,7 +1918,7 @@ export class App extends React.Component<{ playOpening?: boolean }, S> {
if (exists) throw new Error(t("proposal.fileExists"));
newContent = eff;
await this.captureBefore(ws.root, p);
await window.schutz.writeFile(ws.root, p.rel, newContent);
await window.schutz.writeFile(ws.root, p.rel, this.forDisk(p.rel, newContent));
await this.markAfter(ws.root, p);
const tree = await window.schutz.readTree(ws.root);
this.setState({ workspace: tree });
Expand All @@ -1933,7 +1935,7 @@ export class App extends React.Component<{ playOpening?: boolean }, S> {
if (res.ok === false) throw new Error(t(res.error === "multiple" ? "sc1.orig_multiple" : "sc1.orig_not_found"));
newContent = res.text;
await this.captureBefore(ws.root, p);
await window.schutz.writeFile(ws.root, p.rel, newContent);
await window.schutz.writeFile(ws.root, p.rel, this.forDisk(p.rel, newContent));
await this.markAfter(ws.root, p);
editStart = res.start;
editEnd = res.end;
Expand Down Expand Up @@ -5388,6 +5390,16 @@ ${(r.output || "").slice(0, 2000)}`;
private isDirtyRel = (rel: string): boolean => !!this.state.paneDirty[rel] || projectModels.isDirty(rel);
/** 이 파일에 편집을 얹을 **기준 텍스트** — 미저장 버퍼가 있으면 그 내용, 없으면 null(호출측이 디스크를 읽는다).
* 디스크를 기준으로 쓰면 그 write 가 미저장 편집을 조용히 덮는다. */
/** 디스크에 쓸 문자열로 맞춘다.
*
* BOM 은 본문이 아니라 파일의 표식이다. 기준 텍스트가 모델에서 왔으면 BOM 이 빠져
* 있고, 디스크에서 왔으면 붙어 있다 — 그대로 쓰면 전자에서 표식이 조용히 사라진다.
* 한 번 떼고 그 파일이 원래 갖고 있었는지로만 다시 붙인다(두 번 붙지 않게). */
private forDisk(rel: string, text: string): string {
const BOM = "";
const body = text.startsWith(BOM) ? text.slice(BOM.length) : text;
return (projectModels.hasBom(rel) || text.startsWith(BOM)) ? BOM + body : body;
}
private baseTextFor(root: string, rel: string): string | null {
if (!this.isDirtyRel(rel)) return null;
const m = projectModels.getByRel(rel);
Expand Down Expand Up @@ -5596,7 +5608,7 @@ ${(r.output || "").slice(0, 2000)}`;
// 사람이 없는 자리에서 남의 편집을 지우는 것보다 낫다.
if (ext !== null && ext !== content) return false;
try {
await window.schutz.writeFile(ws.root, rel, content);
await window.schutz.writeFile(ws.root, rel, projectModels.diskText(rel) ?? content);
projectModels.markSaved(ws.root, rel, content);
this.notifySaved(rel);
this.setState(st => ({ paneDirty: { ...st.paneDirty, [rel]: false } }));
Expand Down
4 changes: 3 additions & 1 deletion ide/src/editor/MonacoPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,9 @@ function MonacoPaneImpl({ root, rel, onDirtyChange, onSaved, onConfirm, onStatus
if (!ok) return;
}
try {
await window.schutz.writeFile(root, rel, text);
// BOM 은 본문이 아니라 파일의 표식이라 모델 밖에 있다 — 도로 붙여 써야
// 손대지 않은 표식이 조용히 사라지지 않는다. 기준선은 BOM 없는 값으로 둔다.
await window.schutz.writeFile(root, rel, projectModels.diskText(rel) ?? text);
projectModels.markSaved(root, rel, text);
savedRef.current = text;
setDirty(false);
Expand Down
42 changes: 41 additions & 1 deletion ide/src/editor/projectModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,44 @@ export function getByRel(rel: string): monaco.editor.ITextModel | null {
return null;
}

/* ── BOM ────────────────────────────────────────────────────────────────────
UTF-8 BOM 은 **본문이 아니라 파일의 표식**이다. Monaco 도 그렇게 보고 따로 들고
있어서 getValue() 는 BOM 을 빼고 준다. 그런데 디스크에서 읽은 문자열에는 BOM 이
들어 있으므로, 그걸 그대로 기준선으로 삼으면 두 값이 영원히 다르다 —
**BOM 파일은 열자마자 "저장 안 함" 이 되고, 모두 저장이 손도 안 댄 파일을 고쳐
쓰면서 BOM 을 떼어 버린다.** 파일 전체가 바뀐 diff 가 되고, BOM 을 요구하는
도구에서는 빌드가 깨진다.

그래서 안쪽은 전부 BOM 없이 다룬다. 읽을 때 떼고, 있었다는 사실만 기억했다가,
디스크에 쓸 때만 도로 붙인다. 비교·오프셋 계산은 전부 BOM 없는 문자열로 도니
한 글자씩 밀리는 일도 없다. */
const BOM = "\uFEFF";
const hadBom = new Map<string, boolean>(); // uri → BOM 이 있었나
function splitBom(text: string): string {
return text.startsWith(BOM) ? text.slice(BOM.length) : text;
}
/** 디스크에 쓸 문자열 — 원래 BOM 이 있었으면 도로 붙인다. 없으면 null. */
export function diskText(rel: string): string | null {
const key = relIndex.get(rel);
if (!key) return null;
const m = owned.get(key);
if (!m || m.isDisposed()) return null;
return (hadBom.get(key) ? BOM : "") + m.getValue();
}
/** 이 파일이 BOM 을 달고 있었나 — 모델 밖에서 쓴 텍스트를 저장할 때 필요하다. */
export function hasBom(rel: string): boolean {
const key = relIndex.get(rel);
return !!key && hadBom.get(key) === true;
}

/** 모델 확보 — 이미 있으면 재사용(중복 URI createModel throw 회피) */
export function ensure(root: string, rel: string, content: string, lang?: string): monaco.editor.ITextModel {
const uri = uriFor(root, rel);
const key = uri.toString();
const existing = monaco.editor.getModel(uri);
if (existing) { owned.set(key, existing); relIndex.set(rel, key); savedContent.set(key, existing.getValue()); return existing; }
if (content.startsWith(BOM)) hadBom.set(key, true);
content = splitBom(content);
const language = lang ?? languageOf(rel);
const model = monaco.editor.createModel(content, language, uri);
owned.set(key, model);
Expand Down Expand Up @@ -149,6 +181,9 @@ export function reload(root: string, rel: string, content: string, isDirty: bool
const m = getByRel(rel);
if (!m) { if (isTsLike(rel)) ensure(root, rel, content); return; }
const key = uriFor(root, rel).toString();
// 디스크에서 온 문자열이라 BOM 이 붙어 있을 수 있다. 안쪽 비교는 전부 BOM 없이 한다.
hadBom.set(key, content.startsWith(BOM));
content = splitBom(content);
const prevSaved = savedContent.get(key);
// 디스크가 실제로 바뀌었고(이전 기준선과 다름) 버퍼와도 다르면 충돌 — 저장 전에 사용자에게 물어야 한다
if (isDirty && m.getValue() !== content && prevSaved !== undefined && prevSaved !== content) {
Expand All @@ -167,6 +202,7 @@ export function clearExternalChange(rel: string): void { externalChanged.delete(
export function drop(root: string, rel: string): void {
const uri = uriFor(root, rel);
const key = uri.toString();
hadBom.delete(key);
const m = owned.get(key);
if (m && !m.isDisposed()) { try { lsp.didClose(key, m.getLanguageId()); } catch { /* */ } m.dispose(); }
owned.delete(key);
Expand Down Expand Up @@ -200,9 +236,12 @@ export function rekeyUnder(root: string, oldRel: string, newRel: string): void {
if (!m || m.isDisposed()) { drop(root, r); continue; }
const value = m.getValue();
const saved = savedContent.get(oldKey);
const bom = hadBom.get(oldKey) === true;
drop(root, r); // 옛 모델 폐기 + lsp.didClose
ensure(root, to, value); // 새 URI 모델(값=버퍼) + lsp.didOpen
if (saved !== undefined) savedContent.set(uriFor(root, to).toString(), saved); // 디스크(=옛 saved) 기준 유지 → dirty 델타 보존
const newKey = uriFor(root, to).toString();
if (bom) hadBom.set(newKey, true); // 이름이 바뀌어도 BOM 은 그 파일의 것이다
if (saved !== undefined) savedContent.set(newKey, saved); // 디스크(=옛 saved) 기준 유지 → dirty 델타 보존
}
}

Expand Down Expand Up @@ -232,6 +271,7 @@ export async function reloadAll(root: string, readFile: (r: string, rel: string)
}

export function disposeAll(): void {
hadBom.clear();
for (const m of owned.values()) { try { if (!m.isDisposed()) m.dispose(); } catch { /* */ } }
owned.clear();
relIndex.clear();
Expand Down
Loading