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
14 changes: 8 additions & 6 deletions ide/electron/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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);
Expand All @@ -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);
}
Expand Down
4 changes: 3 additions & 1 deletion ide/electron/preload.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
Expand Down
39 changes: 39 additions & 0 deletions ide/electron/touchSet.cjs
Original file line number Diff line number Diff line change
@@ -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 };
16 changes: 16 additions & 0 deletions ide/electron/touchSet.d.cts
Original file line number Diff line number Diff line change
@@ -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;
16 changes: 15 additions & 1 deletion ide/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4464,7 +4464,10 @@ ${(r.output || "").slice(0, 2000)}`;
private _fsTouched = new Set<string>();
/** 사라졌는데 미저장 편집이 있어 남겨 둔 파일들 — 이미 알린 것을 되풀이하지 않게. */
private _goneTold = new Set<string>();
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);
Expand All @@ -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 면 실존 파일 오삭제 위험)
Expand Down
70 changes: 70 additions & 0 deletions ide/src/engine/touchSet.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
3 changes: 2 additions & 1 deletion ide/src/schutz.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading