diff --git a/ide/electron/main.cjs b/ide/electron/main.cjs index a57a0e3..9a6c662 100644 --- a/ide/electron/main.cjs +++ b/ide/electron/main.cjs @@ -1447,6 +1447,7 @@ ipcMain.on("schutz:termReconcile", (e, ids) => { }); // ── 파일 워처 (외부 변경 감지) ────────────────────────────────────────────── +const touchSet = require("./touchSet.cjs"); const watchers = new Map(); // webContents.id → { watcher, timer } ipcMain.on("schutz:watchStart", (e, root) => { @@ -1461,15 +1462,15 @@ ipcMain.on("schutz:watchStart", (e, root) => { // 어떤 파일이 움직였는지 모아 둔다. fs.watch 는 처음부터 이름을 주는데, 예전엔 // 무시 규칙에만 쓰고 버려서 렌더러에는 "뭔가 바뀌었다" 는 빈 신호만 갔다. // 그래서 확장의 파일 감시자를 만들 수가 없었다. - let touched = new Set(); - const MAX_TOUCHED = 2000; // 대량 변경(브랜치 전환)에서 무한히 쌓이지 않게 + // 상한은 있되 넘치면 넘쳤다고 말한다 — touchSet.cjs 에 사연이 있다. + const touched = touchSet.makeTouchSet(); try { const watcher = fs.watch ? require("fs").watch(root, { recursive: true }, (_type, filename) => { if (filename) { const parts = String(filename).replace(/\\/g, "/").split("/"); if (parts.some(seg => IGNORE_DIRS.has(seg))) return; // node_modules/.git 등 무시 if (parts[parts.length - 1].endsWith(".schutz-tmp")) return; // 우리가 방금 만든 임시 파일 — 새로고침을 유발할 이유가 없다 - if (touched.size < MAX_TOUCHED) touched.add(String(filename).replace(/\\/g, "/")); + touched.add(filename); } dirty = true; const cur = watchers.get(wid); @@ -1478,9 +1479,10 @@ ipcMain.on("schutz:watchStart", (e, root) => { cur.timer = setTimeout(() => { if (dirty && !e.sender.isDestroyed()) { dirty = false; - const rels = [...touched]; - touched = new Set(); - e.sender.send("schutz:fsChange", rels); + const d = touched.drain(); + // overflow 를 함께 보낸다. 받는 쪽이 "이 목록이 전부" 라고 믿으면 안 되는 + // 경우를 알아야, 개별 이름 대신 트리 비교로 판정할 수 있다. + e.sender.send("schutz:fsChange", d.rels, d.overflow); } }, 350); } diff --git a/ide/electron/preload.cjs b/ide/electron/preload.cjs index 6954018..5a1de21 100644 --- a/ide/electron/preload.cjs +++ b/ide/electron/preload.cjs @@ -52,7 +52,9 @@ contextBridge.exposeInMainWorld("schutz", { onFsChange: (cb) => { // 바뀐 파일들의 상대 경로를 함께 넘긴다. 예전엔 인자 없이 불러서, 무엇이 // 움직였는지 아는 쪽(메인)의 정보가 렌더러에 닿지 않았다. - const h = (_e, rels) => cb(Array.isArray(rels) ? rels : []); + // overflow=true 면 이 목록이 전부가 아니다(상한에 걸려 이름이 잘렸다). + // 받는 쪽은 개별 이름 대신 트리 비교로 판정해야 한다. + const h = (_e, rels, overflow) => cb(Array.isArray(rels) ? rels : [], !!overflow); ipcRenderer.on("schutz:fsChange", h); return () => ipcRenderer.removeListener("schutz:fsChange", h); }, diff --git a/ide/electron/touchSet.cjs b/ide/electron/touchSet.cjs new file mode 100644 index 0000000..ff00894 --- /dev/null +++ b/ide/electron/touchSet.cjs @@ -0,0 +1,39 @@ +// 워처가 알려 준 경로를 한 번의 알림으로 모으는 자루. +// +// 예전엔 `if (touched.size < MAX_TOUCHED) touched.add(...)` 한 줄이었다. 상한을 넘으면 +// **말없이 버렸다.** 앱 자신은 멀쩡하다(새로고침 때 트리를 통째로 다시 읽으니까). +// 손해는 확장의 파일 감시자가 본다 — 브랜치를 갈아타 파일 3000개가 바뀌면 확장은 +// 앞의 것만 통보받고 나머지는 영영 모른 채, 자기가 다 안다고 여긴다. +// +// 두 가지를 고친다. 상한을 실제 저장소 크기에 맞게 올리고(2000 → 20000), 그래도 +// 넘치면 넘쳤다고 말한다. 조용히 자르는 것만은 안 한다. + +const DEFAULT_MAX = 20000; + +function makeTouchSet(max = DEFAULT_MAX) { + let set = new Set(); + let dropped = 0; + return { + add(rel) { + const r = String(rel || "").replace(/\\/g, "/"); + if (!r) return; + if (set.has(r)) return; + if (set.size >= max) { dropped++; return; } + set.add(r); + }, + get size() { return set.size; }, + /** 상한에 걸려 버린 경로가 있었나 — 있으면 이 알림은 전부가 아니다. */ + get overflowed() { return dropped > 0; }, + get dropped() { return dropped; }, + /** 모은 것을 꺼내고 자루를 비운다. */ + drain() { + const rels = [...set]; + const info = { rels, overflow: dropped > 0, dropped }; + set = new Set(); + dropped = 0; + return info; + }, + }; +} + +module.exports = { makeTouchSet, DEFAULT_MAX }; diff --git a/ide/electron/touchSet.d.cts b/ide/electron/touchSet.d.cts new file mode 100644 index 0000000..d791c78 --- /dev/null +++ b/ide/electron/touchSet.d.cts @@ -0,0 +1,16 @@ +// touchSet.cjs 의 타입. 메인 프로세스 코드지만 판단 부분은 테스트에서 그대로 부른다. +export interface TouchDrain { + rels: string[]; + /** 상한에 걸려 버린 경로가 있었다 — 이 목록은 전부가 아니다. */ + overflow: boolean; + dropped: number; +} +export interface TouchSet { + add(rel: unknown): void; + readonly size: number; + readonly overflowed: boolean; + readonly dropped: number; + drain(): TouchDrain; +} +export declare function makeTouchSet(max?: number): TouchSet; +export declare const DEFAULT_MAX: number; diff --git a/ide/src/App.tsx b/ide/src/App.tsx index e3a30fe..349a1da 100644 --- a/ide/src/App.tsx +++ b/ide/src/App.tsx @@ -4464,7 +4464,10 @@ ${(r.output || "").slice(0, 2000)}`; private _fsTouched = new Set(); /** 사라졌는데 미저장 편집이 있어 남겨 둔 파일들 — 이미 알린 것을 되풀이하지 않게. */ private _goneTold = new Set(); - private onFsChange = (rels?: string[]) => { + /** 이번 알림 묶음에서 워처가 이름을 다 못 준 적이 있었나. syncFromDisk 가 지우고 간다. */ + private _fsOverflow = false; + private onFsChange = (rels?: string[], overflow?: boolean) => { + if (overflow) this._fsOverflow = true; for (const r of rels ?? []) this._fsTouched.add(r); if (this._fsTimer) clearTimeout(this._fsTimer); this._fsTimer = setTimeout(() => void this.syncFromDisk(), 250); @@ -4489,10 +4492,21 @@ ${(r.output || "").slice(0, 2000)}`; // 일 수 있다. 그때 트리 전체를 비교하면 멀쩡한 파일에 지워짐을 쏜다 — 그걸 받은 // 확장은 인덱스에서 실제로 지운다. 그래서 **워처가 이름을 준 경로만** 본다. const capped = tree.truncated || ws.truncated; + const overflow = this._fsOverflow; + this._fsOverflow = false; const t = new Set(touched); + // 워처가 이름을 다 못 줬으면(overflow) 이름 목록으로 좁히는 것이 위험하다 — + // 잘려 나간 파일이 "아무 일 없었다" 가 되기 때문이다. 트리가 온전한 한 + // 앞뒤 전체를 비교하는 쪽이 만들어짐·지워짐을 놓치지 않는다. + // 트리까지 잘렸으면(capped) 얘기가 다르다: 그때 전체를 비교하면 멀쩡한 파일에 + // 지워짐을 쏘게 되므로, 덜 아는 쪽(이름 목록)을 그대로 쓴다. const delta = capped ? fsClassify(before.filter(r => t.has(r)), after.filter(r => t.has(r)), touched) : fsClassify(before, after, touched); + if (overflow && capped) { + // 둘 다 잘린 경우에만 정말로 모르는 채 넘어간다. 조용히 넘기지는 않는다. + console.warn("[fs] 변경 이름과 트리가 모두 상한에 걸렸습니다 — 이번 알림은 일부만 반영됩니다."); + } if (delta.created.length || delta.changed.length || delta.deleted.length) extHost.notifyFsDelta(delta); } // 사라진 파일(외부 삭제·브랜치 전환)의 stale 모델·진단·문제패널 항목 정리 — 트리 완전할 때만(truncated 면 실존 파일 오삭제 위험) diff --git a/ide/src/engine/touchSet.test.ts b/ide/src/engine/touchSet.test.ts new file mode 100644 index 0000000..36e2cde --- /dev/null +++ b/ide/src/engine/touchSet.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from "vitest"; +import { makeTouchSet, DEFAULT_MAX } from "../../electron/touchSet.cjs"; + +describe("makeTouchSet", () => { + it("모은 것을 그대로 돌려준다", () => { + const s = makeTouchSet(); + s.add("a.ts"); s.add("b/c.ts"); + const d = s.drain(); + expect(d.rels).toEqual(["a.ts", "b/c.ts"]); + expect(d.overflow).toBe(false); + }); + + it("역슬래시를 슬래시로 맞춘다 — 윈도우 워처가 주는 모양이다", () => { + const s = makeTouchSet(); + s.add("src\\editor\\pane.ts"); + expect(s.drain().rels).toEqual(["src/editor/pane.ts"]); + }); + + it("같은 경로를 두 번 세지 않는다", () => { + const s = makeTouchSet(); + s.add("a.ts"); s.add("a.ts"); s.add("a.ts"); + expect(s.size).toBe(1); + }); + + it("빈 값은 담지 않는다", () => { + const s = makeTouchSet(); + s.add(""); s.add(null); s.add(undefined); + expect(s.size).toBe(0); + expect(s.drain().overflow).toBe(false); + }); + + // 이것이 이 파일의 이유다. 예전엔 상한을 넘으면 말없이 버렸고, 그 알림을 받은 + // 확장은 자기가 전부 안다고 여겼다. + it("상한을 넘으면 버렸다고 말한다", () => { + const s = makeTouchSet(3); + for (const r of ["a", "b", "c", "d", "e"]) s.add(r); + const d = s.drain(); + expect(d.rels).toEqual(["a", "b", "c"]); + expect(d.overflow).toBe(true); + expect(d.dropped).toBe(2); + }); + + it("상한 이하면 넘쳤다고 하지 않는다", () => { + const s = makeTouchSet(3); + s.add("a"); s.add("b"); s.add("c"); + expect(s.drain().overflow).toBe(false); + }); + + it("중복은 상한을 깎지 않는다", () => { + const s = makeTouchSet(2); + s.add("a"); s.add("a"); s.add("b"); + const d = s.drain(); + expect(d.rels).toEqual(["a", "b"]); + expect(d.overflow).toBe(false); + }); + + it("꺼내면 비고, 넘침 표시도 함께 지워진다", () => { + const s = makeTouchSet(1); + s.add("a"); s.add("b"); + expect(s.drain().overflow).toBe(true); + const second = s.drain(); + expect(second.rels).toEqual([]); + expect(second.overflow).toBe(false); + }); + + it("기본 상한은 실제 저장소 규모를 담을 만큼 크다", () => { + // 2000 은 브랜치 하나만 갈아타도 넘쳤다. + expect(DEFAULT_MAX).toBeGreaterThanOrEqual(20000); + }); +}); diff --git a/ide/src/schutz.d.ts b/ide/src/schutz.d.ts index f5f3042..9b58d27 100644 --- a/ide/src/schutz.d.ts +++ b/ide/src/schutz.d.ts @@ -147,7 +147,8 @@ interface SchutzApi { watchStop(): void; /** 바뀐 파일들의 워크스페이스 상대 경로. 감시자가 이름을 못 준 변경도 있으므로 * 이 목록은 완전하지 않다 — 트리 비교로 보완해서 쓴다. */ - onFsChange(cb: (rels: string[]) => void): () => void; + /** overflow=true 면 rels 가 전부가 아니다 — 이름이 상한에서 잘렸다. */ + onFsChange(cb: (rels: string[], overflow?: boolean) => void): () => void; /** 저장 안 한 파일 목록을 메인에 알려 둔다 — 종료를 붙잡을지 메인이 이걸로 정한다. * 종료를 누른 뒤에 물어보면 늦다(그때는 트레이가 이미 사라진 뒤다). */ reportDirty?(files: string[]): void;